334 lines
11 KiB
Python
334 lines
11 KiB
Python
"""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_()
|