Files
ilaandClaude Opus 4.8 d3cd8ecb55 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>
2026-07-22 00:00:51 +08:00

500 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.alerts import AlertRecord, AlertSink
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 QtAlertSink(AlertSink):
"""Desktop sound + one non-blocking popup per confirmed event (Windows smoke)."""
def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None:
self._parent = parent
def play_sound(self, event) -> None:
QtWidgets.QApplication.beep()
def show_popup(self, record: AlertRecord) -> None:
box = QtWidgets.QMessageBox(self._parent)
box.setIcon(QtWidgets.QMessageBox.Warning)
box.setWindowTitle("确认摔倒")
box.setText(
"人员 {0} 确认摔倒\n确认延迟 {1:.2f} 秒\n截图:{2}".format(
record.event.track_id,
record.event.latency_seconds,
record.screenshot_path.name,
)
)
box.setStandardButtons(QtWidgets.QMessageBox.Ok)
box.button(QtWidgets.QMessageBox.Ok).setText("我已知晓")
box.setModal(False)
box.show()
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)
self._draw_diagnostics(painter)
painter.end()
def _draw_diagnostics(self, painter: QtGui.QPainter) -> None:
lines = list(self._view.diagnostics)
if not lines:
return
painter.setFont(QtGui.QFont("Consolas", 9))
metrics = painter.fontMetrics()
line_h = metrics.height()
box_w = max(metrics.width(line) for line in lines) + 12
box_h = line_h * len(lines) + 8
painter.fillRect(QtCore.QRect(6, 6, box_w, box_h), QtGui.QColor(0, 0, 0, 160))
painter.setPen(QtGui.QColor("#9BE9A8"))
y = 6 + line_h
for line in lines:
painter.drawText(12, y, line)
y += line_h
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 ConnectionTestWorker(QtCore.QThread):
"""Build the RTSP URL and probe one frame off the GUI thread."""
probed = QtCore.pyqtSignal(bool, str, object)
def __init__(self, host, port, channel, username, password) -> None:
super().__init__()
self._args = (host, port, channel, username, password)
def run(self) -> None:
from v1.camera import build_rtsp_url, probe_stream
host, port, channel, username, password = self._args
try:
url = build_rtsp_url(host, port, username, password, channel)
except Exception as exc: # noqa: BLE001 - report any build error to the UI
self.probed.emit(False, "参数错误:{0}".format(exc), None)
return
result = probe_stream(url, attempts=40)
self.probed.emit(result.ok, result.message, result.frame)
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,
config_path: Optional[str] = None,
camera: Optional[Dict] = None,
event_tuning: Optional[Dict] = None,
parent: Optional[QtWidgets.QWidget] = None,
) -> None:
super().__init__(parent)
self._draft = draft
self._config_path = config_path
self._spins: Dict[str, QtWidgets.QDoubleSpinBox] = {}
self._probe_worker: Optional[ConnectionTestWorker] = None
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)
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)
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("已重置为已保存值")
def _build_camera_box(self, camera: Dict) -> QtWidgets.QGroupBox:
box = QtWidgets.QGroupBox("摄像头连接(保存至本地未跟踪配置)")
outer = QtWidgets.QHBoxLayout(box)
form = QtWidgets.QFormLayout()
self.host_edit = QtWidgets.QLineEdit(str(camera.get("host", "")))
self.port_spin = QtWidgets.QSpinBox()
self.port_spin.setRange(1, 65535)
self.port_spin.setValue(int(camera.get("port", 554)))
self.channel_combo = QtWidgets.QComboBox()
self.channel_combo.addItem("101 主码流", "101")
self.channel_combo.addItem("102 子码流", "102")
self.channel_combo.setCurrentIndex(1 if str(camera.get("channel", "102")) == "102" else 0)
self.user_edit = QtWidgets.QLineEdit(str(camera.get("username", "")))
self.password_edit = QtWidgets.QLineEdit(str(camera.get("password", "")))
self.password_edit.setEchoMode(QtWidgets.QLineEdit.Password)
self.transport_combo = QtWidgets.QComboBox()
self.transport_combo.addItem("TCP", "tcp")
self.transport_combo.addItem("UDP", "udp")
self.transport_combo.setCurrentIndex(1 if str(camera.get("transport", "tcp")) == "udp" else 0)
self.timeout_spin = QtWidgets.QDoubleSpinBox()
self.timeout_spin.setRange(0.0, 60.0)
self.timeout_spin.setSingleStep(1.0)
self.timeout_spin.setDecimals(1)
self.timeout_spin.setValue(float(camera.get("timeout_seconds", 5.0)))
self.low_latency_check = QtWidgets.QCheckBox("低延迟(丢弃缓冲)")
self.low_latency_check.setChecked(bool(camera.get("low_latency", True)))
form.addRow("IP / 主机", self.host_edit)
form.addRow("端口", self.port_spin)
form.addRow("通道", self.channel_combo)
form.addRow("账号", self.user_edit)
form.addRow("密码", self.password_edit)
form.addRow("传输协议", self.transport_combo)
form.addRow("连接超时(秒)", self.timeout_spin)
form.addRow("", self.low_latency_check)
self.test_button = QtWidgets.QPushButton("测试连接")
self.save_camera_button = QtWidgets.QPushButton("保存连接到本地配置")
self.test_button.clicked.connect(self._on_test_connection)
self.save_camera_button.clicked.connect(self._on_save_camera)
self.camera_status = QtWidgets.QLabel("")
buttons = QtWidgets.QHBoxLayout()
buttons.addWidget(self.test_button)
buttons.addWidget(self.save_camera_button)
buttons.addStretch(1)
form.addRow(buttons)
form.addRow(self.camera_status)
self.preview = QtWidgets.QLabel("预览区域")
self.preview.setMinimumSize(320, 180)
self.preview.setAlignment(QtCore.Qt.AlignCenter)
self.preview.setStyleSheet("background:#0B1220;color:#EAF1F8;")
outer.addLayout(form, 1)
outer.addWidget(self.preview, 1)
return box
def _on_test_connection(self) -> None:
if self._probe_worker is not None:
return
self.camera_status.setText("正在测试连接…")
self.test_button.setEnabled(False)
worker = ConnectionTestWorker(
self.host_edit.text().strip(),
self.port_spin.value(),
self.channel_combo.currentData(),
self.user_edit.text().strip(),
self.password_edit.text(),
)
worker.probed.connect(self._on_probe_done)
worker.finished.connect(self._on_probe_finished)
self._probe_worker = worker
worker.start()
def _on_probe_done(self, ok: bool, message: str, frame) -> None:
self.camera_status.setText(message)
if ok and frame is not None:
image = bgr_to_qimage(frame)
pixmap = QtGui.QPixmap.fromImage(image).scaled(
self.preview.size(), QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation
)
self.preview.setPixmap(pixmap)
def _on_probe_finished(self) -> None:
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("未指定本地配置路径,无法保存")
return
from v1.config import write_local_camera_source
try:
write_local_camera_source(
self._config_path,
source_id=self.host_edit.text().strip() or "ip-camera",
host=self.host_edit.text().strip(),
port=self.port_spin.value(),
channel=self.channel_combo.currentData(),
username=self.user_edit.text().strip(),
password=self.password_edit.text(),
transport=self.transport_combo.currentData(),
timeout_seconds=self.timeout_spin.value(),
low_latency=self.low_latency_check.isChecked(),
)
except Exception as exc: # noqa: BLE001 - surface any write error to the UI
self.camera_status.setText("保存失败:{0}".format(exc))
return
self.camera_status.setText("已保存到本地配置,将在下次开始监控时生效")
class MainWindow(QtWidgets.QMainWindow):
"""Top dual-tab window: 实时监控 / 设置."""
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, event_tuning
)
tabs = QtWidgets.QTabWidget()
tabs.setTabPosition(QtWidgets.QTabWidget.North)
tabs.addTab(self.monitor, "实时监控")
tabs.addTab(self.settings, "设置")
self.setCentralWidget(tabs)