Files
silver_pose/v1/gui.py
T
ilaandClaude Opus 4.8 289f781134 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>
2026-07-21 19:43:49 +08:00

266 lines
10 KiB
Python
Raw 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.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)