428 lines
14 KiB
Python
428 lines
14 KiB
Python
"""Unified Qt dashboard for the finger tactile example."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from PyQt5.QtCore import QSize, 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 .transport_icons import transport_icon
|
|
from .widgets import (
|
|
ArrowPanelWidget,
|
|
ForceChartWidget,
|
|
MeshViewportWidget,
|
|
TeeStream,
|
|
WidgetLogHandler,
|
|
make_log_widget,
|
|
)
|
|
|
|
|
|
_PLAY_PAUSE_BUTTON_NAME = "btnPlayPause"
|
|
_PLAY_PAUSE_ICON_PX = 18
|
|
_PLAY_PAUSE_ICON_COLORS = {
|
|
"run": "#4caf84",
|
|
"starting": "#555555",
|
|
"pause": "#ff9800",
|
|
}
|
|
|
|
|
|
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._frame_index = 0
|
|
self._wall_fps = 0.0
|
|
self._wall_fps_alpha = 0.15
|
|
|
|
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("Sensor")
|
|
self.btn_camera.clicked.connect(self._select_camera)
|
|
bar.addWidget(self.btn_camera)
|
|
self.camera_edit = QLineEdit("0")
|
|
self.camera_edit.setPlaceholderText("Camera ID or Video Path")
|
|
bar.addWidget(self.camera_edit, 1)
|
|
|
|
self.btn_config = QPushButton("Config")
|
|
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("Reset")
|
|
self.btn_reset.setEnabled(False)
|
|
self.btn_reset.clicked.connect(self._on_reset)
|
|
bar.addWidget(self.btn_reset)
|
|
|
|
self.btn_play_pause = QPushButton()
|
|
self.btn_play_pause.setObjectName(_PLAY_PAUSE_BUTTON_NAME)
|
|
self.btn_play_pause.setFixedSize(36, 32)
|
|
self.btn_play_pause.setIconSize(QSize(_PLAY_PAUSE_ICON_PX, _PLAY_PAUSE_ICON_PX))
|
|
self.btn_play_pause.clicked.connect(self._on_play_pause_clicked)
|
|
self._set_play_pause_button("run")
|
|
bar.addWidget(self.btn_play_pause)
|
|
|
|
root_layout.addLayout(bar)
|
|
|
|
setup = layout.panel_setup
|
|
self.mesh_viewport = MeshViewportWidget(layout=layout)
|
|
arrows_slot = layout.slot("arrows")
|
|
self.arrow_panel: ArrowPanelWidget | None = None
|
|
if arrows_slot is not None and arrows_slot.enabled:
|
|
self.arrow_panel = ArrowPanelWidget(
|
|
flow_threshold=layout.arrow_flow_threshold,
|
|
background=layout.arrow_background,
|
|
layout=layout,
|
|
)
|
|
self.force_charts = ForceChartWidget(layout=layout, lowpass_cutoff_hz=self.config.lowpass_cutoff_hz)
|
|
self.log_widget = make_log_widget(layout.log_config)
|
|
|
|
self._panel_widgets: dict[str, QWidget] = {}
|
|
self._panel_widgets["mesh"] = self.mesh_viewport
|
|
if self.arrow_panel is not None:
|
|
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 _set_play_pause_button(self, state: str) -> None:
|
|
"""``run`` = green play, ``starting`` = grey disabled play, ``pause`` = orange pause."""
|
|
btn = self.btn_play_pause
|
|
if state == "run":
|
|
btn.setToolTip("Run")
|
|
btn.setEnabled(True)
|
|
elif state == "starting":
|
|
btn.setToolTip("Starting…")
|
|
btn.setEnabled(False)
|
|
elif state == "pause":
|
|
btn.setToolTip("Pause")
|
|
btn.setEnabled(True)
|
|
else:
|
|
raise ValueError(f"unknown play/pause state: {state!r}")
|
|
|
|
kind = "pause" if state == "pause" else "play"
|
|
btn.setText("")
|
|
btn.setIcon(transport_icon(kind, _PLAY_PAUSE_ICON_COLORS[state]))
|
|
btn.setProperty("playState", state)
|
|
style = btn.style()
|
|
style.unpolish(btn)
|
|
style.polish(btn)
|
|
btn.update()
|
|
|
|
def _on_play_pause_clicked(self) -> None:
|
|
if self._running:
|
|
self._on_stop()
|
|
else:
|
|
self._on_start()
|
|
|
|
def _on_start(self) -> None:
|
|
if self._running:
|
|
return
|
|
self._set_play_pause_button("starting")
|
|
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._set_play_pause_button("run")
|
|
print(f"启动失败: {exc}")
|
|
return
|
|
|
|
self._running = True
|
|
self._frame_index = 0
|
|
self._wall_fps = 0.0
|
|
self.timer.start(self.layout.timer_interval_ms)
|
|
self._set_play_pause_button("pause")
|
|
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.log_handler.clear_status_line()
|
|
self._set_play_pause_button("starting")
|
|
QApplication.processEvents()
|
|
|
|
self.mesh_viewport.close_renderer()
|
|
self.pipeline.close()
|
|
|
|
self._set_play_pause_button("run")
|
|
self.btn_reset.setEnabled(False)
|
|
|
|
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_t0 = time.perf_counter()
|
|
frame, timings = self.pipeline.tick_timed()
|
|
if frame is None:
|
|
print("采集结束或读取失败。")
|
|
self._on_stop()
|
|
return
|
|
|
|
self._frame_index += 1
|
|
layout = self.layout
|
|
profile = self.pipeline.profiler
|
|
|
|
mesh_ms = 0.0
|
|
if layout.mesh_render_stride <= 1 or self._frame_index % layout.mesh_render_stride == 0:
|
|
mesh_ms = self.mesh_viewport.update_frame(frame)
|
|
if profile is not None and mesh_ms > 0:
|
|
profile.record("mesh", mesh_ms)
|
|
|
|
arrows_ms = 0.0
|
|
if self.arrow_panel is not None:
|
|
t0 = time.perf_counter()
|
|
self.arrow_panel.update_frame(frame)
|
|
arrows_ms = (time.perf_counter() - t0) * 1000.0
|
|
if profile is not None:
|
|
profile.record("arrows", arrows_ms)
|
|
|
|
charts_ms = 0.0
|
|
if layout.chart_update_stride <= 1 or self._frame_index % layout.chart_update_stride == 0:
|
|
t0 = time.perf_counter()
|
|
self.force_charts.update_frame(frame)
|
|
charts_ms = (time.perf_counter() - t0) * 1000.0
|
|
if profile is not None:
|
|
profile.record("charts", charts_ms)
|
|
|
|
total_ms = (time.perf_counter() - frame_t0) * 1000.0
|
|
instant_fps = 1000.0 / total_ms if total_ms > 0 else 0.0
|
|
if self._wall_fps <= 0:
|
|
self._wall_fps = instant_fps
|
|
else:
|
|
self._wall_fps = (
|
|
self._wall_fps_alpha * instant_fps
|
|
+ (1.0 - self._wall_fps_alpha) * self._wall_fps
|
|
)
|
|
|
|
if profile is not None:
|
|
profile.record("total", total_ms)
|
|
self.pipeline.record_total_frame(total_ms)
|
|
|
|
flow_mag = 0.0
|
|
if frame.flow.size:
|
|
flow_mag = float((frame.flow[:, :, 0] ** 2 + frame.flow[:, :, 1] ** 2).mean() ** 0.5)
|
|
|
|
log_cfg = layout.log_config
|
|
if log_cfg.status_enabled:
|
|
self.log_handler.set_status_line(
|
|
log_cfg.format_status(
|
|
fps=frame.fps,
|
|
fnormal=frame.fnormal,
|
|
fshearx=frame.fshearx,
|
|
fsheary=frame.fsheary,
|
|
flow_rms=flow_mag,
|
|
mask_coverage=frame.mask_coverage,
|
|
wall_fps=self._wall_fps,
|
|
)
|
|
)
|
|
|
|
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 全屏")
|
|
if config.profile:
|
|
print(f"Profiling enabled (report every {config.profile_every} frames).")
|
|
print("Log FPS= is end-to-end dashboard rate; sdk= is Sensor internal rate.")
|
|
|
|
app = get_or_create_qapp()
|
|
window = FingerDashboardWindow(config, layout=layout)
|
|
window.show()
|
|
QTimer.singleShot(0, window._apply_panel_layout)
|
|
return app.exec_()
|