feat(v1): add PyQt dual-tab monitor and settings UI

Qt-free view_model builds monitor view state and isolates the settings
draft from the running config snapshot; gui.py/app.py are a thin PyQt5
shell that only renders already-decided FrameAnalysis. 39 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ila
2026-07-21 19:43:49 +08:00
co-authored by Claude Opus 4.8
parent 16ba9b3944
commit 289f781134
8 changed files with 832 additions and 9 deletions
+137
View File
@@ -0,0 +1,137 @@
"""Assemble configuration, the fall pipeline worker, and the PyQt window.
The GUI never computes events. A background worker owns the video source and the
``FallPipeline`` and emits already-decided ``FrameAnalysis`` objects; the window
only renders them. Settings edits become a new immutable runtime snapshot at the
next start, so screenshots and logs stay traceable to the actual config version.
"""
import os
import sys
from dataclasses import replace
from pathlib import Path
from typing import Optional
from PyQt5 import QtCore, QtWidgets
from v1.config import AppConfig, EventConfig, load_config
from v1.pipeline import FallPipeline
from v1.pose import PoseAdapter
from v1.video_source import SourceMode, SourceStatus, VideoSource
from v1.view_model import SettingsDraft, build_monitor_view
class FrameWorker(QtCore.QThread):
"""Read one source, run the pipeline, and emit render-only analyses."""
frame_ready = QtCore.pyqtSignal(object)
def __init__(self, config: AppConfig, pose_adapter: PoseAdapter) -> None:
super().__init__()
self._config = config
self._pose_adapter = pose_adapter
self._stop = False
def run(self) -> None:
mode = SourceMode.STREAM if self._config.source_url.startswith("rtsp") else SourceMode.REPLAY
source = VideoSource(self._config.source_url, mode=mode)
pipeline = FallPipeline.from_config(self._config, self._pose_adapter)
try:
while not self._stop:
packet = source.read()
analysis = pipeline.process(packet)
self.frame_ready.emit(analysis)
if packet.status in (SourceStatus.CLOSED, SourceStatus.EOF):
break
self.msleep(5)
finally:
source.close()
def stop(self) -> None:
self._stop = True
def _running_config(config: AppConfig, draft: SettingsDraft) -> AppConfig:
values = draft.running_values
event = EventConfig(
keypoint_confidence_threshold=values["keypoint_confidence_threshold"],
suspect_window_seconds=values["suspect_window_seconds"],
confirm_window_seconds=values["confirm_window_seconds"],
recovery_window_seconds=values["recovery_window_seconds"],
cooldown_seconds=values["cooldown_seconds"],
)
return replace(
config, event=event, confidence_threshold=values["model_confidence_threshold"]
)
def _draft_from_config(config: AppConfig) -> SettingsDraft:
return SettingsDraft(
{
"keypoint_confidence_threshold": config.event.keypoint_confidence_threshold,
"suspect_window_seconds": config.event.suspect_window_seconds,
"confirm_window_seconds": config.event.confirm_window_seconds,
"recovery_window_seconds": config.event.recovery_window_seconds,
"cooldown_seconds": config.event.cooldown_seconds,
"model_confidence_threshold": config.confidence_threshold,
}
)
class ApplicationController:
"""Own the window, draft and worker lifecycle without doing event logic."""
def __init__(self, config: AppConfig, pose_adapter: PoseAdapter) -> None:
from v1.gui import MainWindow
self._config = config
self._pose_adapter = pose_adapter
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])
self.window = MainWindow(self._draft, env_ready, model_summary, config.source_id)
self.window.monitor.start_requested.connect(self.start)
self.window.monitor.stop_requested.connect(self.stop)
self._worker: Optional[FrameWorker] = None
def start(self) -> None:
if self._worker is not None:
return
self._draft.start_monitoring()
running = _running_config(self._config, self._draft)
worker = FrameWorker(running, self._pose_adapter)
worker.frame_ready.connect(self._on_frame)
worker.finished.connect(self._on_finished)
self._worker = worker
self.window.monitor.set_running(True)
worker.start()
def stop(self) -> None:
if self._worker is not None:
self._worker.stop()
def _on_frame(self, analysis) -> None:
view = build_monitor_view(
analysis, self._draft.running_values["keypoint_confidence_threshold"]
)
self.window.monitor.render_view(analysis.packet.image, view)
def _on_finished(self) -> None:
self._worker = None
self.window.monitor.set_running(False)
def main(config_path: Optional[str] = None) -> int:
path = Path(config_path or os.environ.get("SILVER_POSE_CONFIG", "v1/config.local.json"))
config = load_config(path)
pose_adapter = PoseAdapter(
config.model_path, config.model_sha256, config.confidence_threshold
)
app = QtWidgets.QApplication(sys.argv)
controller = ApplicationController(config, pose_adapter)
controller.window.show()
return app.exec_()
if __name__ == "__main__":
raise SystemExit(main())
+265
View File
@@ -0,0 +1,265 @@
"""Thin PyQt5 rendering shell for the V1 monitor and settings tabs.
This module only renders ``MonitorViewState`` and binds ``SettingsDraft``; it
never runs Pose, tracking or fall decisions. All event facts arrive from the
``FallPipeline`` worker in ``v1/app.py``. Because acceptance-critical logic lives
in ``v1/view_model.py`` (Qt-free and unit-tested), this file is the only surface
that requires a Windows + PyQt5 + display smoke test.
"""
from typing import Dict, Optional
import numpy as np
from PyQt5 import QtCore, QtGui, QtWidgets
from v1.view_model import (
FIELD_BOUNDS,
DraftValidationError,
MonitorViewState,
SettingsDraft,
StatusColor,
)
COLOR_HEX: Dict[StatusColor, str] = {
StatusColor.SUCCESS: "#15803D",
StatusColor.CAUTION: "#B45309",
StatusColor.CRITICAL: "#C62828",
StatusColor.OFFLINE: "#64748B",
}
# Light Windows theme tokens (see docs/ui/silver-pose-ui-ux-spec.md).
_QSS = """
QWidget { background: #EAF1F8; color: #1E293B; font-family: 'Segoe UI Variable','Microsoft YaHei UI',sans-serif; }
QTabWidget::pane { background: #FFFFFF; border: 1px solid #C8D4E3; }
QTabBar::tab { padding: 8px 20px; background: #FFFFFF; color: #52657C; }
QTabBar::tab:selected { color: #1E293B; border-bottom: 2px solid #2563EB; }
QGroupBox { background: #FFFFFF; border: 1px solid #C8D4E3; border-radius: 8px; margin-top: 12px; }
QPushButton { background: #FFFFFF; border: 1px solid #C8D4E3; border-radius: 8px; padding: 8px 14px; min-height: 40px; }
QPushButton:focus { border: 2px solid #2563EB; }
QLabel[role="primary-action"] { }
"""
_FIELD_LABELS = {
"keypoint_confidence_threshold": "关键点置信度阈值",
"suspect_window_seconds": "快速下移到水平的最大间隔(秒)",
"confirm_window_seconds": "确认窗口(秒,1–3)",
"recovery_window_seconds": "恢复稳定窗口(秒)",
"cooldown_seconds": "确认后恢复判断冷却(秒)",
"model_confidence_threshold": "模型检测置信度阈值",
}
_FIELD_STEP = {
"keypoint_confidence_threshold": 0.05,
"suspect_window_seconds": 0.1,
"confirm_window_seconds": 0.1,
"recovery_window_seconds": 0.5,
"cooldown_seconds": 1.0,
"model_confidence_threshold": 0.05,
}
def bgr_to_qimage(image: np.ndarray) -> QtGui.QImage:
"""Convert an OpenCV BGR frame to a QImage owning a contiguous copy."""
rgb = np.ascontiguousarray(image[:, :, ::-1])
height, width, _ = rgb.shape
return QtGui.QImage(
rgb.data, width, height, 3 * width, QtGui.QImage.Format_RGB888
).copy()
class VideoView(QtWidgets.QWidget):
"""Paint the latest frame plus box/skeleton/id/state overlays."""
def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
super().__init__(parent)
self.setMinimumSize(480, 270)
self._image: Optional[QtGui.QImage] = None
self._view: Optional[MonitorViewState] = None
def update_frame(self, image: Optional[np.ndarray], view: MonitorViewState) -> None:
self._image = bgr_to_qimage(image) if image is not None else None
self._view = view
self.update()
def paintEvent(self, _event: QtGui.QPaintEvent) -> None:
painter = QtGui.QPainter(self)
painter.fillRect(self.rect(), QtGui.QColor("#0B1220"))
if self._image is None:
painter.setPen(QtGui.QColor("#EAF1F8"))
text = self._view.status_text if self._view else "无画面"
painter.drawText(self.rect(), QtCore.Qt.AlignCenter, text)
painter.end()
return
target = self._fitted_rect(self._image)
painter.drawImage(target, self._image)
if self._view is not None:
self._draw_overlays(painter, target, self._image)
painter.end()
def _fitted_rect(self, image: QtGui.QImage) -> QtCore.QRect:
widget = self.rect()
scale = min(widget.width() / image.width(), widget.height() / image.height())
width = int(image.width() * scale)
height = int(image.height() * scale)
left = widget.left() + (widget.width() - width) // 2
top = widget.top() + (widget.height() - height) // 2
return QtCore.QRect(left, top, width, height)
def _draw_overlays(
self, painter: QtGui.QPainter, target: QtCore.QRect, image: QtGui.QImage
) -> None:
scale_x = target.width() / image.width()
scale_y = target.height() / image.height()
def to_widget(x: float, y: float) -> QtCore.QPointF:
return QtCore.QPointF(target.left() + x * scale_x, target.top() + y * scale_y)
for overlay in self._view.people:
color = QtGui.QColor(COLOR_HEX[overlay.color])
painter.setPen(QtGui.QPen(color, 3))
left, top, right, bottom = overlay.box_xyxy
painter.drawRect(
QtCore.QRectF(to_widget(left, top), to_widget(right, bottom))
)
painter.setPen(QtGui.QPen(color, 2))
for start, end in overlay.skeleton_segments:
painter.drawLine(to_widget(start.x, start.y), to_widget(end.x, end.y))
for point in overlay.keypoints:
if point is not None:
painter.drawEllipse(to_widget(point.x, point.y), 2.5, 2.5)
painter.setPen(QtGui.QColor("#FFFFFF"))
painter.drawText(to_widget(left, top - 6), overlay.label)
class MonitorTab(QtWidgets.QWidget):
"""Live view: video, connection status, recent events, start/stop."""
start_requested = QtCore.pyqtSignal()
stop_requested = QtCore.pyqtSignal()
def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
super().__init__(parent)
layout = QtWidgets.QVBoxLayout(self)
self._status = QtWidgets.QLabel("未开始")
self._status.setMinimumHeight(24)
self.video = VideoView()
self.events = QtWidgets.QListWidget()
self.events.setMaximumHeight(140)
self.start_button = QtWidgets.QPushButton("开始监控")
self.stop_button = QtWidgets.QPushButton("停止监控")
self.stop_button.setEnabled(False)
self.start_button.clicked.connect(self.start_requested.emit)
self.stop_button.clicked.connect(self.stop_requested.emit)
controls = QtWidgets.QHBoxLayout()
controls.addWidget(self.start_button)
controls.addWidget(self.stop_button)
controls.addStretch(1)
layout.addWidget(self._status)
layout.addWidget(self.video, 1)
layout.addWidget(QtWidgets.QLabel("最近事件"))
layout.addWidget(self.events)
layout.addLayout(controls)
def render_view(self, image, view: MonitorViewState) -> None:
self.video.update_frame(image, view)
color = COLOR_HEX[view.status_color]
self._status.setText("连接状态:{0}".format(view.status_text))
self._status.setStyleSheet("color: {0}; font-weight: 600;".format(color))
for event in view.events:
self.events.insertItem(
0,
"{0} · {1} · 延迟 {2:.2f}s".format(
event.event_id, event.track_id, event.latency_seconds
),
)
def set_running(self, running: bool) -> None:
self.start_button.setEnabled(not running)
self.stop_button.setEnabled(running)
class SettingsTab(QtWidgets.QWidget):
"""Edit a non-sensitive draft; changes apply only on the next start."""
def __init__(
self, draft: SettingsDraft, env_ready: bool, model_summary: str,
parent: Optional[QtWidgets.QWidget] = None,
) -> None:
super().__init__(parent)
self._draft = draft
self._spins: Dict[str, QtWidgets.QDoubleSpinBox] = {}
layout = QtWidgets.QVBoxLayout(self)
source_box = QtWidgets.QGroupBox("来源与模型(只读)")
source_form = QtWidgets.QFormLayout(source_box)
source_form.addRow("RTSP 环境变量", QtWidgets.QLabel("已就绪" if env_ready else "未就绪"))
source_form.addRow("模型", QtWidgets.QLabel(model_summary))
layout.addWidget(source_box)
params_box = QtWidgets.QGroupBox("事件参数草稿")
form = QtWidgets.QFormLayout(params_box)
values = draft.draft_values
for key, (low, high) in FIELD_BOUNDS.items():
spin = QtWidgets.QDoubleSpinBox()
spin.setRange(low, high)
spin.setSingleStep(_FIELD_STEP[key])
spin.setDecimals(2)
spin.setValue(values[key])
spin.valueChanged.connect(lambda value, name=key: self._on_edit(name, value))
self._spins[key] = spin
form.addRow(_FIELD_LABELS[key], spin)
layout.addWidget(params_box)
self.status = QtWidgets.QLabel("")
self.save_button = QtWidgets.QPushButton("保存(下次启动生效)")
self.reset_button = QtWidgets.QPushButton("重置")
self.save_button.clicked.connect(self._on_save)
self.reset_button.clicked.connect(self._on_reset)
actions = QtWidgets.QHBoxLayout()
actions.addWidget(self.save_button)
actions.addWidget(self.reset_button)
actions.addStretch(1)
layout.addWidget(self.status)
layout.addLayout(actions)
layout.addStretch(1)
def _on_edit(self, key: str, value: float) -> None:
try:
self._draft.edit(key, value)
except DraftValidationError as exc:
self.status.setText("校验失败:{0}".format(exc))
return
self.status.setText("已修改,未保存" if self._draft.is_dirty else "")
def _on_save(self) -> None:
self.status.setText(self._draft.save())
def _on_reset(self) -> None:
self._draft.discard()
for key, spin in self._spins.items():
spin.blockSignals(True)
spin.setValue(self._draft.draft_values[key])
spin.blockSignals(False)
self.status.setText("已重置为已保存值")
class MainWindow(QtWidgets.QMainWindow):
"""Top dual-tab window: 实时监控 / 设置."""
def __init__(
self, draft: SettingsDraft, env_ready: bool, model_summary: str, source_name: str
) -> 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)
tabs = QtWidgets.QTabWidget()
tabs.setTabPosition(QtWidgets.QTabWidget.North)
tabs.addTab(self.monitor, "实时监控")
tabs.addTab(self.settings, "设置")
self.setCentralWidget(tabs)
+148
View File
@@ -0,0 +1,148 @@
from types import SimpleNamespace
import pytest
from v1.fall_state import FallState
from v1.video_source import SourceStatus
from v1.view_model import (
DraftValidationError,
SettingsDraft,
StatusColor,
build_monitor_view,
config_version,
)
def _keypoint(x, y, confidence=0.9):
return SimpleNamespace(x=float(x), y=float(y), confidence=float(confidence))
def _person(track_id, state, keypoints=None, box=(10.0, 10.0, 40.0, 120.0)):
points = keypoints or [_keypoint(index, index) for index in range(17)]
pose = SimpleNamespace(box_xyxy=box, box_confidence=0.9, keypoints=tuple(points))
tracked = SimpleNamespace(track_id=track_id, detected_at_monotonic=1.0, pose=pose)
return SimpleNamespace(tracked_pose=tracked, pose_evidence=None, state=state)
def _analysis(status=SourceStatus.CONNECTED, people=(), events=(), has_image=True):
image = object() if has_image else None
packet = SimpleNamespace(image=image, timestamp_monotonic=1.0, status=status, error=None)
return SimpleNamespace(packet=packet, people=tuple(people), events=tuple(events))
def test_state_maps_to_semantic_color_and_red_is_confirmed_only():
view = build_monitor_view(
_analysis(
people=[
_person("P-0001", FallState.NORMAL),
_person("P-0002", FallState.SUSPECT),
_person("P-0003", FallState.CONFIRMED),
]
)
)
colors = {overlay.track_id: overlay.color for overlay in view.people}
assert colors["P-0001"] is StatusColor.SUCCESS
assert colors["P-0002"] is StatusColor.CAUTION
assert colors["P-0003"] is StatusColor.CRITICAL
critical = [o for o in view.people if o.color is StatusColor.CRITICAL]
assert [o.state for o in critical] == [FallState.CONFIRMED]
assert view.highest_state is FallState.CONFIRMED
def test_disconnected_frame_shows_reconnect_text_and_no_fall():
view = build_monitor_view(_analysis(status=SourceStatus.RETRYING, people=(), has_image=False))
assert view.connected is False
assert view.status_text == "正在重连…"
assert view.status_color is StatusColor.OFFLINE
assert view.people == ()
assert view.highest_state is FallState.NORMAL
def test_skeleton_overlay_drops_low_confidence_keypoints_and_labels_person():
points = [_keypoint(index, index, 0.9) for index in range(17)]
points[9] = _keypoint(9, 9, 0.1) # left wrist below threshold
view = build_monitor_view(
_analysis(people=[_person("P-0007", FallState.SUSPECT, keypoints=points)]),
keypoint_min_confidence=0.4,
)
overlay = view.people[0]
assert overlay.keypoints[9] is None
assert overlay.keypoints[7] is not None
# Edges touching keypoint 9 (5-7-9 arm) must not draw the 7->9 segment.
assert all(
not (a is overlay.keypoints[7] and b is None) for a, b in overlay.skeleton_segments
)
assert overlay.label == "P-0007 · SUSPECT"
assert overlay.box_xyxy == (10.0, 10.0, 40.0, 120.0)
def test_confirmed_event_becomes_a_badge():
event = SimpleNamespace(event_id="FALL-000001", track_id="P-0003", latency_seconds=1.8)
view = build_monitor_view(_analysis(events=[event]))
assert len(view.events) == 1
assert view.events[0].event_id == "FALL-000001"
assert view.events[0].latency_seconds == 1.8
def _initial():
return {
"keypoint_confidence_threshold": 0.4,
"suspect_window_seconds": 0.5,
"confirm_window_seconds": 1.8,
"recovery_window_seconds": 2.0,
"cooldown_seconds": 10.0,
"model_confidence_threshold": 0.25,
}
def test_editing_draft_does_not_change_running_or_saved_config():
draft = SettingsDraft(_initial())
draft.edit("confirm_window_seconds", 2.5)
assert draft.is_dirty is True
assert draft.draft_values["confirm_window_seconds"] == 2.5
assert draft.saved_values["confirm_window_seconds"] == 1.8
assert draft.running_values["confirm_window_seconds"] == 1.8
assert draft.has_pending_for_next_start is False
def test_save_marks_pending_but_running_only_changes_on_start():
draft = SettingsDraft(_initial())
before_version = draft.running_version
draft.edit("confirm_window_seconds", 2.5)
message = draft.save()
assert "下次开始监控" in message
assert draft.is_dirty is False
assert draft.has_pending_for_next_start is True
assert draft.running_values["confirm_window_seconds"] == 1.8
assert draft.running_version == before_version
new_version = draft.start_monitoring()
assert draft.running_values["confirm_window_seconds"] == 2.5
assert draft.has_pending_for_next_start is False
assert new_version != before_version
def test_discard_reverts_draft_to_saved():
draft = SettingsDraft(_initial())
draft.edit("cooldown_seconds", 20.0)
draft.discard()
assert draft.is_dirty is False
assert draft.draft_values["cooldown_seconds"] == 10.0
def test_out_of_range_draft_value_is_rejected():
draft = SettingsDraft(_initial())
with pytest.raises(DraftValidationError):
draft.edit("confirm_window_seconds", 5.0)
with pytest.raises(DraftValidationError):
draft.edit("keypoint_confidence_threshold", 1.5)
with pytest.raises(DraftValidationError):
draft.edit("unknown_field", 1.0)
def test_config_version_is_stable_and_sensitive_to_values():
values = _initial()
assert config_version(values) == config_version(dict(values))
changed = dict(values)
changed["cooldown_seconds"] = 12.0
assert config_version(values) != config_version(changed)
+262
View File
@@ -0,0 +1,262 @@
"""Pure-Python view state for the V1 monitor/settings UI (no Qt dependency).
The GUI layer only renders these structures; it never runs Pose, tracking or
event decisions. All event facts arrive already computed from ``FallPipeline``.
This keeps the acceptance-critical UI logic testable without PyQt5 or a display.
"""
import hashlib
import json
from dataclasses import dataclass
from enum import Enum
from typing import Dict, Optional, Sequence, Tuple
from v1.fall_state import FallState
from v1.video_source import SourceStatus
# COCO-17 skeleton edges (keypoint index pairs) used to draw the person overlay.
SKELETON_EDGES: Tuple[Tuple[int, int], ...] = (
(5, 7),
(7, 9),
(6, 8),
(8, 10),
(5, 6),
(5, 11),
(6, 12),
(11, 12),
(11, 13),
(13, 15),
(12, 14),
(14, 16),
(0, 5),
(0, 6),
)
class StatusColor(str, Enum):
"""Semantic color tokens from the UI spec. Red (CRITICAL) is CONFIRMED-only."""
SUCCESS = "success" # NORMAL / online -> #15803D
CAUTION = "caution" # SUSPECT / notice -> #B45309
CRITICAL = "critical" # CONFIRMED fall -> #C62828
OFFLINE = "offline" # disconnected -> #64748B
STATE_COLOR: Dict[FallState, StatusColor] = {
FallState.NORMAL: StatusColor.SUCCESS,
FallState.SUSPECT: StatusColor.CAUTION,
FallState.CONFIRMED: StatusColor.CRITICAL,
FallState.RECOVERING: StatusColor.CAUTION,
}
_STATE_SEVERITY: Dict[FallState, int] = {
FallState.NORMAL: 0,
FallState.RECOVERING: 1,
FallState.SUSPECT: 2,
FallState.CONFIRMED: 3,
}
CONNECTION_TEXT: Dict[SourceStatus, str] = {
SourceStatus.CONNECTED: "在线",
SourceStatus.RETRYING: "正在重连…",
SourceStatus.ERROR: "连接错误",
SourceStatus.EOF: "录像结束",
SourceStatus.CLOSED: "已停止",
}
@dataclass(frozen=True)
class Point:
x: float
y: float
@dataclass(frozen=True)
class PersonOverlay:
track_id: str
state: FallState
color: StatusColor
box_xyxy: Tuple[float, float, float, float]
keypoints: Tuple[Optional[Point], ...]
skeleton_segments: Tuple[Tuple[Point, Point], ...]
label: str
@dataclass(frozen=True)
class EventBadge:
event_id: str
track_id: str
latency_seconds: float
@dataclass(frozen=True)
class MonitorViewState:
connected: bool
status_text: str
status_color: StatusColor
has_frame: bool
people: Tuple[PersonOverlay, ...]
events: Tuple[EventBadge, ...]
highest_state: FallState
def build_monitor_view(analysis, keypoint_min_confidence: float = 0.4) -> MonitorViewState:
"""Turn one ``FrameAnalysis`` into render-only instructions.
Non-connected frames never carry people or a fall label, matching the rule
that connection problems must not surface as fall alarms.
"""
if not 0.0 <= keypoint_min_confidence <= 1.0:
raise ValueError("keypoint_min_confidence must be between 0 and 1")
packet = analysis.packet
status = packet.status
connected = status is SourceStatus.CONNECTED
overlays = tuple(
_person_overlay(person, keypoint_min_confidence) for person in analysis.people
)
events = tuple(
EventBadge(event.event_id, event.track_id, event.latency_seconds)
for event in analysis.events
)
return MonitorViewState(
connected=connected,
status_text=CONNECTION_TEXT.get(status, str(getattr(status, "value", status))),
status_color=StatusColor.SUCCESS if connected else StatusColor.OFFLINE,
has_frame=packet.image is not None,
people=overlays,
events=events,
highest_state=_highest_state(overlays),
)
def _person_overlay(person, min_confidence: float) -> PersonOverlay:
pose = person.tracked_pose.pose
state = person.state
points = tuple(
Point(keypoint.x, keypoint.y) if keypoint.confidence >= min_confidence else None
for keypoint in pose.keypoints
)
segments = tuple(
(points[start], points[end])
for start, end in SKELETON_EDGES
if points[start] is not None and points[end] is not None
)
return PersonOverlay(
track_id=person.tracked_pose.track_id,
state=state,
color=STATE_COLOR[state],
box_xyxy=pose.box_xyxy,
keypoints=points,
skeleton_segments=segments,
label="{0} · {1}".format(person.tracked_pose.track_id, state.value),
)
def _highest_state(overlays: Sequence[PersonOverlay]) -> FallState:
highest = FallState.NORMAL
for overlay in overlays:
if _STATE_SEVERITY[overlay.state] > _STATE_SEVERITY[highest]:
highest = overlay.state
return highest
# --- Settings draft with an explicit next-start apply lifecycle --------------
FIELD_BOUNDS: Dict[str, Tuple[float, float]] = {
"keypoint_confidence_threshold": (0.0, 1.0),
"suspect_window_seconds": (0.0, 30.0),
"confirm_window_seconds": (1.0, 3.0),
"recovery_window_seconds": (0.0, 300.0),
"cooldown_seconds": (0.0, 3600.0),
"model_confidence_threshold": (0.0, 1.0),
}
class DraftValidationError(ValueError):
"""Raised when a settings draft value is outside its allowed range."""
def config_version(values: Dict[str, float]) -> str:
canonical = json.dumps(
{key: float(values[key]) for key in FIELD_BOUNDS},
sort_keys=True,
separators=(",", ":"),
)
return "cfg-" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12]
class SettingsDraft:
"""Hold three isolated copies: running snapshot, saved, and editable draft.
Editing changes only the draft. Saving copies the draft into ``saved`` but
does not touch the running snapshot. ``start_monitoring`` promotes the saved
values into a new immutable running snapshot; this is the only moment the
running configuration version changes.
"""
def __init__(self, initial: Dict[str, float]) -> None:
missing = set(FIELD_BOUNDS) - set(initial)
if missing:
raise ValueError("missing settings fields: {0}".format(sorted(missing)))
self._running = {key: float(initial[key]) for key in FIELD_BOUNDS}
for key, value in self._running.items():
low, high = FIELD_BOUNDS[key]
if not low <= value <= high:
raise DraftValidationError(
"{0} must be between {1} and {2}".format(key, low, high)
)
self._saved = dict(self._running)
self._draft = dict(self._running)
def edit(self, key: str, value) -> None:
if key not in FIELD_BOUNDS:
raise DraftValidationError("unknown settings field: {0}".format(key))
try:
parsed = float(value)
except (TypeError, ValueError):
raise DraftValidationError("{0} must be numeric".format(key))
low, high = FIELD_BOUNDS[key]
if not low <= parsed <= high:
raise DraftValidationError(
"{0} must be between {1} and {2}".format(key, low, high)
)
self._draft[key] = parsed
@property
def draft_values(self) -> Dict[str, float]:
return dict(self._draft)
@property
def saved_values(self) -> Dict[str, float]:
return dict(self._saved)
@property
def running_values(self) -> Dict[str, float]:
return dict(self._running)
@property
def is_dirty(self) -> bool:
return self._draft != self._saved
@property
def has_pending_for_next_start(self) -> bool:
return self._saved != self._running
@property
def running_version(self) -> str:
return config_version(self._running)
def save(self) -> str:
self._saved = dict(self._draft)
if self._saved == self._running:
return "已保存,与当前运行配置一致"
return "已保存,将在下次开始监控时生效"
def discard(self) -> None:
self._draft = dict(self._saved)
def start_monitoring(self) -> str:
self._running = dict(self._saved)
return self.running_version