2026-07-21 19:43:49 +08:00
|
|
|
|
"""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
|
|
|
|
|
|
|
2026-07-21 19:49:20 +08:00
|
|
|
|
from v1.alerts import AlertRecord, AlertSink
|
2026-07-21 19:43:49 +08:00
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 19:49:20 +08:00
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 19:43:49 +08:00
|
|
|
|
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)
|
2026-07-21 23:14:05 +08:00
|
|
|
|
self._draw_diagnostics(painter)
|
2026-07-21 19:43:49 +08:00
|
|
|
|
painter.end()
|
|
|
|
|
|
|
2026-07-21 23:14:05 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-21 19:43:49 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 22:44:09 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 19:43:49 +08:00
|
|
|
|
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,
|
2026-07-21 22:44:09 +08:00
|
|
|
|
config_path: Optional[str] = None,
|
|
|
|
|
|
camera: Optional[Dict] = None,
|
2026-07-21 19:43:49 +08:00
|
|
|
|
parent: Optional[QtWidgets.QWidget] = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
super().__init__(parent)
|
|
|
|
|
|
self._draft = draft
|
2026-07-21 22:44:09 +08:00
|
|
|
|
self._config_path = config_path
|
2026-07-21 19:43:49 +08:00
|
|
|
|
self._spins: Dict[str, QtWidgets.QDoubleSpinBox] = {}
|
2026-07-21 22:44:09 +08:00
|
|
|
|
self._probe_worker: Optional[ConnectionTestWorker] = None
|
2026-07-21 19:43:49 +08:00
|
|
|
|
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)
|
2026-07-21 22:44:09 +08:00
|
|
|
|
layout.addWidget(self._build_camera_box(camera or {}))
|
2026-07-21 19:43:49 +08:00
|
|
|
|
|
|
|
|
|
|
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("已重置为已保存值")
|
|
|
|
|
|
|
2026-07-21 22:44:09 +08:00
|
|
|
|
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)
|
2026-07-21 22:56:14 +08:00
|
|
|
|
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)))
|
2026-07-21 22:44:09 +08:00
|
|
|
|
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)
|
2026-07-21 22:56:14 +08:00
|
|
|
|
form.addRow("传输协议", self.transport_combo)
|
|
|
|
|
|
form.addRow("连接超时(秒)", self.timeout_spin)
|
|
|
|
|
|
form.addRow("", self.low_latency_check)
|
2026-07-21 22:44:09 +08:00
|
|
|
|
|
|
|
|
|
|
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 _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(),
|
2026-07-21 22:56:14 +08:00
|
|
|
|
transport=self.transport_combo.currentData(),
|
|
|
|
|
|
timeout_seconds=self.timeout_spin.value(),
|
|
|
|
|
|
low_latency=self.low_latency_check.isChecked(),
|
2026-07-21 22:44:09 +08:00
|
|
|
|
)
|
|
|
|
|
|
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("已保存到本地配置,将在下次开始监控时生效")
|
|
|
|
|
|
|
2026-07-21 19:43:49 +08:00
|
|
|
|
|
|
|
|
|
|
class MainWindow(QtWidgets.QMainWindow):
|
|
|
|
|
|
"""Top dual-tab window: 实时监控 / 设置."""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(
|
2026-07-21 22:44:09 +08:00
|
|
|
|
self, draft: SettingsDraft, env_ready: bool, model_summary: str, source_name: str,
|
|
|
|
|
|
config_path: Optional[str] = None, camera: Optional[Dict] = None,
|
2026-07-21 19:43:49 +08:00
|
|
|
|
) -> None:
|
|
|
|
|
|
super().__init__()
|
|
|
|
|
|
self.setWindowTitle("Silver Pose · {0}".format(source_name))
|
|
|
|
|
|
self.resize(1280, 800)
|
|
|
|
|
|
self.setStyleSheet(_QSS)
|
|
|
|
|
|
self.monitor = MonitorTab()
|
2026-07-21 22:44:09 +08:00
|
|
|
|
self.settings = SettingsTab(draft, env_ready, model_summary, config_path, camera)
|
2026-07-21 19:43:49 +08:00
|
|
|
|
tabs = QtWidgets.QTabWidget()
|
|
|
|
|
|
tabs.setTabPosition(QtWidgets.QTabWidget.North)
|
|
|
|
|
|
tabs.addTab(self.monitor, "实时监控")
|
|
|
|
|
|
tabs.addTab(self.settings, "设置")
|
|
|
|
|
|
self.setCentralWidget(tabs)
|