feat(v1): edit detection sensitivity from the settings tab
Adds write_local_event_tuning and a sensitivity group (confirm window, horizontal angle, require-rapid-drop, require-lower-body) that persists to the untracked local config and applies next start. 79 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -35,7 +35,7 @@
|
||||
| T-208 | 每帧摔倒判定诊断叠加 | T-201 | 管线暴露每人策略证据;实时画面角落叠加每人 `accepted/horizontal/角度/rapid/candidate/state`(拒绝时显示原因),便于定位真实摔倒未确认卡在哪一步;诊断字符串构建 Qt-free 可测。 | DONE |
|
||||
| T-209 | 放宽俯视场景摔倒判定灵敏度(可配置) | T-208 | 判定灵敏度参数化:`require_rapid_drop`(默认关)、`require_lower_body`(默认关)、`horizontal_angle_threshold_degrees`(默认放宽);默认下持续水平即可进入疑似,仅靠确认窗防误报;参数进 config 与 config_version,逻辑可测。 | DONE |
|
||||
| T-210 | 修复截图标注乱码(cv2 ASCII 文字) | T-202 | 截图标注文字改为纯 ASCII(`cv2.putText` 只支持 ASCII),去掉非 ASCII 分隔符导致的乱码;实时画面标签不变;ASCII 标签逻辑可测。 | DONE |
|
||||
| T-211 | 检测灵敏度参数进设置页 | T-209 | 设置页可编辑 `require_rapid_drop`/`require_lower_body`/`horizontal_angle_threshold_degrees`/`confirm_window_seconds`(下拉/数字/勾选),保存写回未跟踪 `config.local.json`、下次启动生效;写回逻辑可测。 | DOING |
|
||||
| T-211 | 检测灵敏度参数进设置页 | T-209 | 设置页可编辑 `require_rapid_drop`/`require_lower_body`/`horizontal_angle_threshold_degrees`/`confirm_window_seconds`(下拉/数字/勾选),保存写回未跟踪 `config.local.json`、下次启动生效;写回逻辑可测。 | DONE |
|
||||
| T-212 | 实时流只处理最新帧降延迟 | T-203 | STREAM 后台读帧只保留最新帧、丢弃积压,实时延迟不随推理速度增长;REPLAY 逐帧不丢;最新帧保留逻辑可测。 | TODO |
|
||||
| T-213 | 截图中文标注(Pillow) | T-210 | CONFIRMED 截图可用中文标注(如“确认摔倒 P-0001”);缺 Pillow/字体时回退 ASCII 不崩溃;标签与回退逻辑可测。 | TODO |
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- V1 代码:已建立安全配置、显式 Replay/Stream 视频源、Pose、轻量跟踪、质量/几何证据、倒地领域规则、按 ID 四态事件机及 `v1/pipeline.py` 事件管线;运行事件带非敏感 `config_version`,缺帧/低质量/断流会中断证据确认。新增 `v1/view_model.py`(Qt-free 监控视图状态与设置草稿三份隔离)、`v1/gui.py` 薄 PyQt5 双 Tab 外壳与 `v1/app.py` 装配(`FrameWorker` 只发出已判定的 `FrameAnalysis`,窗口只渲染)。新增 `v1/alerts.py`:按 `event_id` 去重,对首次 CONFIRMED 保存带标注截图、追加 JSONL 事件行,并经可注入 `AlertSink`(Windows 侧 `QtAlertSink` 提供声音与一次性弹窗)触发一次声音/弹窗。新增 `v1/camera.py`(RTSP URL 构建 + 有界连接测试抓帧)与 `config.py` 的结构化来源(host/端口/通道/账号/密码,凭证百分号编码)、`write_local_camera_source`;设置页新增摄像头连接分组、测试连接与预览,以及传输协议/连接超时/低延迟三项抓流调优(由 `config` 存值、`app` 开流前写入 `OPENCV_FFMPEG_CAPTURE_OPTIONS`)。真实海康流已用 `VideoSource(STREAM)` 连通(1920×1080)。
|
||||
- V2 代码:`v2/` 目录存在但尚无实现。
|
||||
- 非代码设计工件:docs/ui/silver-pose-ui-ux-spec.md、docs/ui/2026-07-20-html-prototype-plan.md、docs/ui/silver-pose-v1-prototype.html 与 docs/ui/silver-pose-v2-prototype.html 已建立。v2 HTML 是符合正式浅色 Windows 规范的当前视觉参考:浅灰蓝底、白色卡片,红色只表示确认摔倒、其弹窗和事件证据;文件名中的 v2 只表示原型设计修订,不能理解为 Go V2 实现已开始。v1 HTML 保留为历史深色对照。两者均使用顶部双 Tab、设置草稿与状态交互,且画面、事件和时间都是模拟数据,不连接真实摄像头、模型或网络,也不改变 Phase 1 任务顺序。
|
||||
- 测试:`python -m compileall -q v1 demo` 已通过(含 `gui.py`、`app.py`、`alerts.py`、`camera.py` 语法);`python -m pytest v1/tests -v` 当前有 78 项配置、视频源、Pose、跟踪、证据、领域规则、状态机、管线、视图模型、报警工件、摄像头/URL 构建、抓流参数拼串、每帧诊断、灵敏度放宽、截图 ASCII 标注、断流重连和事件汇总测试并已通过(含来源模式显式声明、模型置信度经适配器生效、事件号跨轮唯一、结构化凭证不入 config_version 且公开示例无凭证的守卫)。`demo/1.mp4` 的首两帧回放时间戳已验证为 0.000000 与 0.033333 秒;T-106 的真实模型/录像 smoke 在首帧得到 2 名已分析人员、第二帧得到 0 名人员且未创建事件;T-201 的视图冒烟以真实录像解码 + 真实管线 + 确定性假 Pose 适配器驱动 `build_monitor_view`,得到稳定 ID、box、14 段骨架、17/17 关键点与 NORMAL/success,断流帧 0 人且不显示摔倒标签;T-202 的报警冒烟用 `demo/1.mp4` 首帧(848×480)落盘一张可被 `cv2.imread` 读回的标注截图(480×848×3,约 330 KB)与一行 JSONL(相对截图路径、含 config_version/source_id、无 rtsp),重复派发返回 0。这些只验证管线、视图与报警工件可运行,不表示摔倒识别准确率。`init.ps1` 会检查运行时依赖、编译旧基线并运行 V1 测试,但不会安装软件包。
|
||||
- 测试:`python -m compileall -q v1 demo` 已通过(含 `gui.py`、`app.py`、`alerts.py`、`camera.py` 语法);`python -m pytest v1/tests -v` 当前有 79 项配置、视频源、Pose、跟踪、证据、领域规则、状态机、管线、视图模型、报警工件、摄像头/URL 构建、抓流参数拼串、每帧诊断、灵敏度放宽、截图 ASCII 标注、断流重连、事件汇总和灵敏度写回测试并已通过(含来源模式显式声明、模型置信度经适配器生效、事件号跨轮唯一、结构化凭证不入 config_version 且公开示例无凭证的守卫)。`demo/1.mp4` 的首两帧回放时间戳已验证为 0.000000 与 0.033333 秒;T-106 的真实模型/录像 smoke 在首帧得到 2 名已分析人员、第二帧得到 0 名人员且未创建事件;T-201 的视图冒烟以真实录像解码 + 真实管线 + 确定性假 Pose 适配器驱动 `build_monitor_view`,得到稳定 ID、box、14 段骨架、17/17 关键点与 NORMAL/success,断流帧 0 人且不显示摔倒标签;T-202 的报警冒烟用 `demo/1.mp4` 首帧(848×480)落盘一张可被 `cv2.imread` 读回的标注截图(480×848×3,约 330 KB)与一行 JSONL(相对截图路径、含 config_version/source_id、无 rtsp),重复派发返回 0。这些只验证管线、视图与报警工件可运行,不表示摔倒识别准确率。`init.ps1` 会检查运行时依赖、编译旧基线并运行 V1 测试,但不会安装软件包。
|
||||
- 模型:`demo/best.pt` 可加载为 YOLO Pose,类别 `person`,`kpt_shape=[17, 3]`;与 `D:\PythonP\fall_detection\best.pt` 哈希一致。
|
||||
- 当前标准启动:`./init.ps1`。
|
||||
- 当前标准验证:`python -m compileall -q demo`。
|
||||
|
||||
@@ -370,3 +370,12 @@
|
||||
- 阻塞:设置页控件需 Windows 冒烟;写回与 config 往返本环境可测。
|
||||
- 决策:与摄像头连接同样走"写回未跟踪 config.local.json、下次启动生效",不进数值草稿(含布尔控件)。
|
||||
- 下一步:写 `write_local_event_tuning` 往返测试,加设置页控件与保存。
|
||||
|
||||
## 【2026-07-22】T-211 检测灵敏度参数进设置页(完成)
|
||||
|
||||
- 状态:DONE
|
||||
- 变更:`config.py` 新增 `write_local_event_tuning`(只更新 event 块的 require_rapid_drop/require_lower_body/horizontal_angle_threshold_degrees/confirm_window_seconds,保留其它字段)。`gui.py` 设置页新增「检测灵敏度」分组:确认窗口数字、水平角度阈值数字、两个勾选(要求快速下移/要求膝踝清晰),保存按钮写回本地配置。`app.py` 从 config 预填这四项传入设置页。
|
||||
- 验证:新增 config 测试(`write_local_event_tuning` 往返且保留其它 event 字段);`python3 -m pytest v1/tests -q` 为 79 passed;`python3 -m compileall -q v1` 退出码 0。
|
||||
- 阻塞:设置页控件需 Windows 冒烟;写回与 config 往返本环境已验证。
|
||||
- 决策:灵敏度与摄像头连接一致,走"写回未跟踪 config.local.json、下次启动生效",不进数值草稿(含布尔控件不便统一)。
|
||||
- 下一步:T-212 实时流只处理最新帧降延迟。
|
||||
|
||||
@@ -99,8 +99,15 @@ class ApplicationController:
|
||||
self._draft = _draft_from_config(config)
|
||||
env_ready = bool(config.source_url)
|
||||
model_summary = "{0} · {1}…".format(config.model_path.name, config.model_sha256[:12])
|
||||
event_tuning = {
|
||||
"require_rapid_drop": config.event.require_rapid_drop,
|
||||
"require_lower_body": config.event.require_lower_body,
|
||||
"horizontal_angle_threshold_degrees": config.event.horizontal_angle_threshold_degrees,
|
||||
"confirm_window_seconds": config.event.confirm_window_seconds,
|
||||
}
|
||||
self.window = MainWindow(
|
||||
self._draft, env_ready, model_summary, config.source_id, config_path, camera
|
||||
self._draft, env_ready, model_summary, config.source_id,
|
||||
config_path, camera, event_tuning,
|
||||
)
|
||||
self.window.monitor.start_requested.connect(self.start)
|
||||
self.window.monitor.stop_requested.connect(self.stop)
|
||||
|
||||
@@ -196,6 +196,31 @@ def write_local_camera_source(
|
||||
return path
|
||||
|
||||
|
||||
def write_local_event_tuning(
|
||||
config_path: Path,
|
||||
require_rapid_drop: bool,
|
||||
require_lower_body: bool,
|
||||
horizontal_angle_threshold_degrees: float,
|
||||
confirm_window_seconds: float,
|
||||
) -> Path:
|
||||
"""Persist detection-sensitivity tuning into the local config's event block.
|
||||
|
||||
Only the tuning keys are updated; other event fields are preserved. Applies
|
||||
at the next start (the running config is a start-time snapshot).
|
||||
"""
|
||||
|
||||
path = Path(config_path)
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
event = dict(data.get("event", {}))
|
||||
event["require_rapid_drop"] = bool(require_rapid_drop)
|
||||
event["require_lower_body"] = bool(require_lower_body)
|
||||
event["horizontal_angle_threshold_degrees"] = float(horizontal_angle_threshold_degrees)
|
||||
event["confirm_window_seconds"] = float(confirm_window_seconds)
|
||||
data["event"] = event
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def load_config(path: Path) -> AppConfig:
|
||||
"""Load one public/local configuration pair without persisting credentials.
|
||||
|
||||
|
||||
@@ -254,6 +254,7 @@ class SettingsTab(QtWidgets.QWidget):
|
||||
self, draft: SettingsDraft, env_ready: bool, model_summary: str,
|
||||
config_path: Optional[str] = None,
|
||||
camera: Optional[Dict] = None,
|
||||
event_tuning: Optional[Dict] = None,
|
||||
parent: Optional[QtWidgets.QWidget] = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -269,6 +270,7 @@ class SettingsTab(QtWidgets.QWidget):
|
||||
source_form.addRow("模型", QtWidgets.QLabel(model_summary))
|
||||
layout.addWidget(source_box)
|
||||
layout.addWidget(self._build_camera_box(camera or {}))
|
||||
layout.addWidget(self._build_sensitivity_box(event_tuning or {}))
|
||||
|
||||
params_box = QtWidgets.QGroupBox("事件参数草稿")
|
||||
form = QtWidgets.QFormLayout(params_box)
|
||||
@@ -402,6 +404,53 @@ class SettingsTab(QtWidgets.QWidget):
|
||||
self._probe_worker = None
|
||||
self.test_button.setEnabled(True)
|
||||
|
||||
def _build_sensitivity_box(self, tuning: Dict) -> QtWidgets.QGroupBox:
|
||||
box = QtWidgets.QGroupBox("检测灵敏度(保存至本地配置,下次启动生效)")
|
||||
form = QtWidgets.QFormLayout(box)
|
||||
self.confirm_spin = QtWidgets.QDoubleSpinBox()
|
||||
self.confirm_spin.setRange(1.0, 3.0)
|
||||
self.confirm_spin.setSingleStep(0.1)
|
||||
self.confirm_spin.setDecimals(1)
|
||||
self.confirm_spin.setValue(float(tuning.get("confirm_window_seconds", 1.8)))
|
||||
self.angle_spin = QtWidgets.QDoubleSpinBox()
|
||||
self.angle_spin.setRange(0.0, 90.0)
|
||||
self.angle_spin.setSingleStep(5.0)
|
||||
self.angle_spin.setDecimals(0)
|
||||
self.angle_spin.setValue(float(tuning.get("horizontal_angle_threshold_degrees", 45.0)))
|
||||
self.rapid_check = QtWidgets.QCheckBox("要求先快速下移(更严,误报更少)")
|
||||
self.rapid_check.setChecked(bool(tuning.get("require_rapid_drop", False)))
|
||||
self.lower_body_check = QtWidgets.QCheckBox("要求膝踝清晰(更严)")
|
||||
self.lower_body_check.setChecked(bool(tuning.get("require_lower_body", False)))
|
||||
form.addRow("确认窗口(秒,1–3,主防误报)", self.confirm_spin)
|
||||
form.addRow("水平角度阈值(°,越大越易判水平)", self.angle_spin)
|
||||
form.addRow("", self.rapid_check)
|
||||
form.addRow("", self.lower_body_check)
|
||||
self.sensitivity_status = QtWidgets.QLabel("")
|
||||
self.save_sensitivity_button = QtWidgets.QPushButton("保存灵敏度到本地配置")
|
||||
self.save_sensitivity_button.clicked.connect(self._on_save_sensitivity)
|
||||
form.addRow(self.save_sensitivity_button)
|
||||
form.addRow(self.sensitivity_status)
|
||||
return box
|
||||
|
||||
def _on_save_sensitivity(self) -> None:
|
||||
if not self._config_path:
|
||||
self.sensitivity_status.setText("未指定本地配置路径,无法保存")
|
||||
return
|
||||
from v1.config import write_local_event_tuning
|
||||
|
||||
try:
|
||||
write_local_event_tuning(
|
||||
self._config_path,
|
||||
require_rapid_drop=self.rapid_check.isChecked(),
|
||||
require_lower_body=self.lower_body_check.isChecked(),
|
||||
horizontal_angle_threshold_degrees=self.angle_spin.value(),
|
||||
confirm_window_seconds=self.confirm_spin.value(),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - surface any write error to the UI
|
||||
self.sensitivity_status.setText("保存失败:{0}".format(exc))
|
||||
return
|
||||
self.sensitivity_status.setText("已保存,将在下次开始监控时生效")
|
||||
|
||||
def _on_save_camera(self) -> None:
|
||||
if not self._config_path:
|
||||
self.camera_status.setText("未指定本地配置路径,无法保存")
|
||||
@@ -433,13 +482,16 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
def __init__(
|
||||
self, draft: SettingsDraft, env_ready: bool, model_summary: str, source_name: str,
|
||||
config_path: Optional[str] = None, camera: Optional[Dict] = None,
|
||||
event_tuning: Optional[Dict] = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.setWindowTitle("Silver Pose · {0}".format(source_name))
|
||||
self.resize(1280, 800)
|
||||
self.setStyleSheet(_QSS)
|
||||
self.monitor = MonitorTab()
|
||||
self.settings = SettingsTab(draft, env_ready, model_summary, config_path, camera)
|
||||
self.settings = SettingsTab(
|
||||
draft, env_ready, model_summary, config_path, camera, event_tuning
|
||||
)
|
||||
tabs = QtWidgets.QTabWidget()
|
||||
tabs.setTabPosition(QtWidgets.QTabWidget.North)
|
||||
tabs.addTab(self.monitor, "实时监控")
|
||||
|
||||
@@ -129,6 +129,32 @@ def test_write_local_camera_source_round_trips_through_load_config(tmp_path):
|
||||
assert config.source_id == "hik"
|
||||
|
||||
|
||||
def test_write_local_event_tuning_round_trips_and_preserves_other_fields(tmp_path):
|
||||
from v1.config import write_local_event_tuning
|
||||
|
||||
config_file = tmp_path / "config.local.json"
|
||||
_write_config(
|
||||
config_file,
|
||||
{"id": "seed", "host": "192.0.2.10", "username": "u", "password": "p"},
|
||||
)
|
||||
|
||||
write_local_event_tuning(
|
||||
config_file,
|
||||
require_rapid_drop=True,
|
||||
require_lower_body=True,
|
||||
horizontal_angle_threshold_degrees=35.0,
|
||||
confirm_window_seconds=2.5,
|
||||
)
|
||||
config = load_config(config_file)
|
||||
|
||||
assert config.event.require_rapid_drop is True
|
||||
assert config.event.require_lower_body is True
|
||||
assert config.event.horizontal_angle_threshold_degrees == 35.0
|
||||
assert config.event.confirm_window_seconds == 2.5
|
||||
# unrelated event fields are preserved from the seed
|
||||
assert config.event.cooldown_seconds == 10.0
|
||||
|
||||
|
||||
def test_public_example_config_has_no_embedded_credentials():
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
Reference in New Issue
Block a user