Files
silver_pose/v1/gui.py
T

266 lines
10 KiB
Python
Raw Normal View History

"""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)