"""Unified Qt dashboard for the finger tactile example.""" from __future__ import annotations import sys from pathlib import Path from PyQt5.QtCore import Qt, QTimer from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import ( QApplication, QFileDialog, QHBoxLayout, QLineEdit, QMainWindow, QPushButton, QSplitter, QVBoxLayout, QWidget, ) from pipeline.config import PipelineConfig from pipeline.service import FingerPipeline from .layout import ( DashboardLayout, LAYOUT, PANEL_SETUP, PanelSetup, top_row_splitter_sizes, ) from .styles import STYLE from .widgets import ( ArrowPanelWidget, ForceChartWidget, MeshViewportWidget, TeeStream, WidgetLogHandler, make_log_widget, ) class FingerDashboardWindow(QMainWindow): """Single-window finger demo: 3D mesh | UV arrows | force charts.""" def __init__( self, config: PipelineConfig, *, layout: DashboardLayout = LAYOUT, log_file=None, ): super().__init__() self.config = config self.layout = layout self.pipeline = FingerPipeline(config) self._running = False self.setWindowTitle(layout.window_title) self.setStyleSheet(STYLE) logo_path = Path(__file__).resolve().parents[2] / "logo.png" if logo_path.is_file(): self.setWindowIcon(QIcon(str(logo_path))) self._setup_ui() self._wire_logging(log_file) self.timer = QTimer() self.timer.timeout.connect(self._on_timer) self.camera_edit.setText(str(config.video)) self.config_edit.setText(config.config) self.cal_edit.setText(config.calib or "") def _setup_ui(self) -> None: layout = self.layout central = QWidget() self.setCentralWidget(central) root_layout = QVBoxLayout(central) root_layout.setContentsMargins(layout.root_margin_px, layout.root_margin_px, layout.root_margin_px, layout.root_margin_px) root_layout.setSpacing(layout.root_spacing_px) bar = QHBoxLayout() bar.setSpacing(layout.toolbar_spacing_px) self.btn_camera = QPushButton("摄像机") self.btn_camera.clicked.connect(self._select_camera) bar.addWidget(self.btn_camera) self.camera_edit = QLineEdit("0") self.camera_edit.setPlaceholderText("摄像头编号 或 视频路径") bar.addWidget(self.camera_edit, 1) self.btn_config = QPushButton("配置") self.btn_config.clicked.connect(self._select_config) bar.addWidget(self.btn_config) self.config_edit = QLineEdit("./config/dd02-ov.json") bar.addWidget(self.config_edit, 1) self.cal_edit = QLineEdit("") bar.addWidget(self.cal_edit, 1) self.btn_reset = QPushButton("重置") self.btn_reset.setEnabled(False) self.btn_reset.clicked.connect(self._on_reset) bar.addWidget(self.btn_reset) self.btn_start = QPushButton("启动") self.btn_start.setObjectName("btnStart") self.btn_start.clicked.connect(self._on_start) bar.addWidget(self.btn_start) self.btn_stop = QPushButton("暂停") self.btn_stop.setObjectName("btnStop") self.btn_stop.setEnabled(False) self.btn_stop.clicked.connect(self._on_stop) bar.addWidget(self.btn_stop) root_layout.addLayout(bar) setup = layout.panel_setup self._panel_widgets: dict[str, QWidget] = {} self.mesh_viewport = MeshViewportWidget(layout=layout) self.arrow_panel = ArrowPanelWidget(flow_threshold=layout.arrow_flow_threshold, layout=layout) self.force_charts = ForceChartWidget(layout=layout, lowpass_cutoff_hz=self.config.lowpass_cutoff_hz) self.log_widget = make_log_widget() self._panel_widgets["mesh"] = self.mesh_viewport self._panel_widgets["arrows"] = self.arrow_panel self._panel_widgets["charts"] = self.force_charts.widget self._panel_widgets["log"] = self.log_widget # Top row: mesh | arrows | charts (order from layout.PANEL_SETUP.top_row) h_splitter = QSplitter(Qt.Horizontal) for slot in setup.top_row.slots: if not slot.enabled: continue h_splitter.addWidget(self._panel_widgets[slot.id]) h_splitter.setStretchFactor(h_splitter.count() - 1, slot.stretch) h_splitter.setChildrenCollapsible(setup.top_row.collapsible) self.h_splitter = h_splitter # Vertical: top panel | log panel v_splitter = QSplitter(Qt.Vertical) v_splitter.addWidget(h_splitter) if setup.log_panel.enabled: v_splitter.addWidget(self.log_widget) v_splitter.setStretchFactor(0, setup.top_stretch) v_splitter.setStretchFactor(1, setup.log_stretch) else: v_splitter.setStretchFactor(0, 1) self.v_splitter = v_splitter root_layout.addWidget(v_splitter) self.resize(layout.window_width, layout.window_height) QTimer.singleShot(0, self._apply_panel_layout) def _apply_panel_layout(self) -> None: setup = self.layout.panel_setup for slot in setup.top_row.slots: if not slot.enabled: continue if slot.id in ("mesh", "arrows"): self._panel_widgets[slot.id].set_fixed_size(slot.width_px, slot.height_px) elif slot.id == "charts": self.force_charts.set_panel_size(slot.width_px, slot.height_px) if setup.log_panel.enabled and setup.log_panel.height_px > 0: self.log_widget.setFixedHeight(setup.log_panel.height_px) self.h_splitter.setSizes(list(top_row_splitter_sizes(self.layout))) def resizeEvent(self, event): super().resizeEvent(event) QTimer.singleShot(0, self._apply_panel_layout) def showEvent(self, event): super().showEvent(event) QTimer.singleShot(0, self._apply_panel_layout) def _wire_logging(self, log_file) -> None: streams = [] if log_file is not None: streams.append(log_file) self.log_handler = WidgetLogHandler(self.log_widget) streams.append(self.log_handler) sys.stdout = TeeStream(*streams) sys.stderr = sys.stdout def _on_start(self) -> None: if self._running: return self.btn_start.setText("启动中...") self.btn_start.setEnabled(False) QApplication.processEvents() self.config.video = self.camera_edit.text().strip() self.config.config = self.config_edit.text().strip() cal = self.cal_edit.text().strip() self.config.calib = cal or None try: self.pipeline = FingerPipeline(self.config) self.pipeline.open() self.mesh_viewport.configure(self.pipeline) self.force_charts.reset() QTimer.singleShot(0, self._apply_panel_layout) except Exception as exc: self.mesh_viewport.close_renderer() self.btn_start.setText("启动") self.btn_start.setEnabled(True) print(f"启动失败: {exc}") return self._running = True self.timer.start(self.layout.timer_interval_ms) self.btn_start.setText("启动") self.btn_stop.setEnabled(True) self.btn_reset.setEnabled(True) print( f"▶ 启动 — 摄像机={self.config.video}, 配置={self.config.config}, " f"标定={self.config.calib or '(none)'}" ) def _on_stop(self) -> None: if not self._running: return self._running = False self.timer.stop() self.btn_stop.setText("停止中...") self.btn_stop.setEnabled(False) QApplication.processEvents() self.mesh_viewport.close_renderer() self.pipeline.close() self.btn_start.setEnabled(True) self.btn_stop.setText("暂停") self.btn_reset.setEnabled(False) print("⏸ 已暂停") def _on_reset(self) -> None: if not self._running: print("请先启动传感器后再重置追踪器。") return self.pipeline.reset() print("追踪器已重置。") def _on_timer(self) -> None: if not self._running: return frame = self.pipeline.tick() if frame is None: print("采集结束或读取失败。") self._on_stop() return flow_mag = 0.0 if frame.flow.size: flow_mag = float((frame.flow[:, :, 0] ** 2 + frame.flow[:, :, 1] ** 2).mean() ** 0.5) print( f"FPS={frame.fps:.2f} flow_rms={flow_mag:.2f} " f"mask={frame.mask_coverage:.2f} normal={frame.fnormal:.2f} " f"shear=({frame.fshearx:.2f}, {frame.fsheary:.2f})", end="\r", ) self.mesh_viewport.update_frame(frame) self.arrow_panel.update_frame(frame) self.force_charts.update_frame(frame) def _select_camera(self) -> None: path, _ = QFileDialog.getOpenFileName( self, "选择视频文件", "data", "视频 (*.mp4 *.avi *.mov);;所有文件 (*)", ) if path: self.camera_edit.setText(path) def _select_config(self) -> None: path, _ = QFileDialog.getOpenFileName( self, "选择配置文件", "config", "JSON (*.json);;所有文件 (*)", ) if path: self.config_edit.setText(path) def keyPressEvent(self, event): key = event.key() if key == Qt.Key_Escape: if self.isFullScreen(): self.showNormal() else: self.showFullScreen() elif key == Qt.Key_Q: print("\n正在退出...") self.close() elif key == Qt.Key_S: if self.pipeline.start_recording(): print("开始录像。") elif key == Qt.Key_E: self.pipeline.stop_recording() print("录像结束。") elif key == Qt.Key_R: self._on_reset() else: super().keyPressEvent(event) def closeEvent(self, event): self.timer.stop() self.mesh_viewport.close_renderer() self.pipeline.close() event.accept() def run_dashboard(config: PipelineConfig, *, layout: DashboardLayout = LAYOUT) -> int: """Launch the unified finger dashboard and block until the window closes.""" from .app import get_or_create_qapp print("\n按键说明:Q 退出 | R 重置追踪器 | S 开始录像 | E 结束录像 | ESC 全屏") app = get_or_create_qapp() window = FingerDashboardWindow(config, layout=layout) window.show() QTimer.singleShot(0, window._apply_panel_layout) return app.exec_()