added dd06 test support, organized 2d viewer and 3d viewer code organization.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
"""Finger 3D Qt dashboard (mesh + UV arrows + force charts)."""
|
||||
|
||||
from .dashboard import FingerDashboardWindow, run_dashboard
|
||||
from .layout import (
|
||||
LOG_PANEL,
|
||||
LAYOUT,
|
||||
MESH_CONTACT_ARROW,
|
||||
MESH_LIGHTING,
|
||||
PANEL_SETUP,
|
||||
TOP_ROW,
|
||||
DashboardLayout,
|
||||
MeshLighting,
|
||||
PanelRow,
|
||||
PanelSetup,
|
||||
PanelSlot,
|
||||
top_row_splitter_sizes,
|
||||
top_row_slot_index,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DashboardLayout",
|
||||
"FingerDashboardWindow",
|
||||
"LAYOUT",
|
||||
"LOG_PANEL",
|
||||
"MESH_CONTACT_ARROW",
|
||||
"MESH_LIGHTING",
|
||||
"MeshLighting",
|
||||
"PANEL_SETUP",
|
||||
"TOP_ROW",
|
||||
"PanelRow",
|
||||
"PanelSetup",
|
||||
"PanelSlot",
|
||||
"run_dashboard",
|
||||
"top_row_splitter_sizes",
|
||||
"top_row_slot_index",
|
||||
]
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Discover USB cameras for the dashboard (Windows DSHOW/MSMF, Linux V4L2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
import cv2
|
||||
|
||||
DEFAULT_MAX_CAMERA_INDEX = 10
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CameraInfo:
|
||||
index: int
|
||||
backend_name: str
|
||||
backend_api: int
|
||||
width: int
|
||||
height: int
|
||||
|
||||
@property
|
||||
def source(self) -> str:
|
||||
return str(self.index)
|
||||
|
||||
def label(self) -> str:
|
||||
return f"{self.index} ({self.width}x{self.height}) [{self.backend_name}]"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _suppress_opencv_noise():
|
||||
"""Hide OpenCV VideoCapture probe warnings (stderr + OpenCV log)."""
|
||||
prev_level = None
|
||||
set_level = None
|
||||
try:
|
||||
logging = getattr(getattr(cv2, "utils", None), "logging", None)
|
||||
if logging is not None:
|
||||
set_level = getattr(logging, "setLogLevel", None)
|
||||
get_level = getattr(logging, "getLogLevel", None)
|
||||
silent = getattr(logging, "LOG_LEVEL_SILENT", 0)
|
||||
if set_level is not None:
|
||||
if get_level is not None:
|
||||
prev_level = get_level()
|
||||
set_level(silent)
|
||||
except Exception:
|
||||
prev_level = None
|
||||
|
||||
devnull = open(os.devnull, "w")
|
||||
try:
|
||||
with contextlib.redirect_stderr(devnull):
|
||||
stderr_fd = sys.stderr.fileno()
|
||||
saved_fd = os.dup(stderr_fd)
|
||||
try:
|
||||
os.dup2(devnull.fileno(), stderr_fd)
|
||||
yield
|
||||
finally:
|
||||
os.dup2(saved_fd, stderr_fd)
|
||||
os.close(saved_fd)
|
||||
finally:
|
||||
devnull.close()
|
||||
if set_level is not None and prev_level is not None:
|
||||
try:
|
||||
set_level(prev_level)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _backend_candidates() -> list[tuple[str, int]]:
|
||||
candidates: list[tuple[str, int]] = []
|
||||
if sys.platform == "win32":
|
||||
if hasattr(cv2, "CAP_DSHOW"):
|
||||
candidates.append(("DSHOW", cv2.CAP_DSHOW))
|
||||
if hasattr(cv2, "CAP_MSMF"):
|
||||
candidates.append(("MSMF", cv2.CAP_MSMF))
|
||||
elif sys.platform.startswith("linux"):
|
||||
# Prefer v4l2 for USB camera indices on Linux.
|
||||
if hasattr(cv2, "CAP_V4L2"):
|
||||
candidates.append(("V4L2", cv2.CAP_V4L2))
|
||||
if not candidates:
|
||||
candidates.append(("ANY", cv2.CAP_ANY))
|
||||
return candidates
|
||||
|
||||
|
||||
def _try_open(index: int, backend_name: str, backend_api: int) -> CameraInfo | None:
|
||||
cap = cv2.VideoCapture(index, backend_api)
|
||||
if not cap.isOpened():
|
||||
cap.release()
|
||||
return None
|
||||
ok, frame = cap.read()
|
||||
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
|
||||
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
|
||||
if not ok or frame is None:
|
||||
if width <= 0 or height <= 0:
|
||||
cap.release()
|
||||
return None
|
||||
else:
|
||||
h, w = frame.shape[:2]
|
||||
width = width or int(w)
|
||||
height = height or int(h)
|
||||
cap.release()
|
||||
return CameraInfo(
|
||||
index=index,
|
||||
backend_name=backend_name,
|
||||
backend_api=backend_api,
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
|
||||
|
||||
def list_cameras(max_index: int = DEFAULT_MAX_CAMERA_INDEX) -> list[CameraInfo]:
|
||||
"""Probe indices ``0 .. max_index`` and return working cameras."""
|
||||
found: list[CameraInfo] = []
|
||||
seen_indices: set[int] = set()
|
||||
backends = _backend_candidates()
|
||||
|
||||
with _suppress_opencv_noise():
|
||||
for index in range(0, max_index + 1):
|
||||
for backend_name, backend_api in backends:
|
||||
info = _try_open(index, backend_name, backend_api)
|
||||
if info is None:
|
||||
continue
|
||||
if index in seen_indices:
|
||||
break
|
||||
found.append(info)
|
||||
seen_indices.add(index)
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
def pick_default_camera(cameras: list[CameraInfo], preferred: str | None = None) -> str | None:
|
||||
if not cameras:
|
||||
return None
|
||||
preferred = (preferred or "").strip()
|
||||
if preferred:
|
||||
for cam in cameras:
|
||||
if cam.source == preferred:
|
||||
return cam.source
|
||||
return cameras[0].source
|
||||
@@ -0,0 +1,702 @@
|
||||
"""Unified Qt dashboard for the finger tactile example."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
|
||||
from PyQt5.QtCore import Qt, QTimer
|
||||
from PyQt5.QtGui import QIcon
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication,
|
||||
QFileDialog,
|
||||
QMainWindow,
|
||||
QSplitter,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from pipeline.config import PipelineConfig
|
||||
from pipeline.csv_recorder import CsvRecorder
|
||||
from pipeline.service import FingerPipeline
|
||||
|
||||
from gui.app_paths import resource_path
|
||||
from .camera_discovery import list_cameras, pick_default_camera
|
||||
from .preferences import get_preferred_video_source, load_preferences, save_preferences
|
||||
from .layout import (
|
||||
DashboardLayout,
|
||||
LAYOUT,
|
||||
PANEL_SETUP,
|
||||
PanelSetup,
|
||||
top_row_splitter_sizes,
|
||||
)
|
||||
from .mesh_view_state import MeshViewState
|
||||
from .styles import STYLE
|
||||
from gui.widgets import TeeStream, WidgetLogHandler, make_log_widget
|
||||
from .widgets import (
|
||||
ArrowPanelWidget,
|
||||
ForceChartWidget,
|
||||
MeshViewportWidget,
|
||||
SettingsPanelWidget,
|
||||
)
|
||||
|
||||
|
||||
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.csv_recorder = CsvRecorder(config.csv_save_dir)
|
||||
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 = resource_path("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)
|
||||
|
||||
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)
|
||||
|
||||
setup = layout.panel_setup
|
||||
self._log_panel_visible = setup.log_panel.enabled
|
||||
self.mesh_state = MeshViewState.from_layout(layout)
|
||||
self.mesh_viewport = MeshViewportWidget(layout=layout, mesh_state=self.mesh_state)
|
||||
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.settings_panel = SettingsPanelWidget(
|
||||
layout=layout,
|
||||
mesh_state=self.mesh_state,
|
||||
show_log=self._log_panel_visible,
|
||||
)
|
||||
# CLI / pipeline defaults first, then overlay saved user preferences.
|
||||
self.settings_panel.set_video_source(str(self.config.video))
|
||||
self.settings_panel.set_config_path(self.config.config)
|
||||
self.settings_panel.set_calibration_path(self.config.calib or "")
|
||||
self.settings_panel.set_finger_export_dir(self.config.export_dir)
|
||||
self.settings_panel.set_csv_save_dir(self.config.csv_save_dir)
|
||||
|
||||
self._preferences = load_preferences()
|
||||
self.settings_panel.apply_preference_state(self._preferences)
|
||||
self._log_panel_visible = bool(self.settings_panel.show_log_check.isChecked())
|
||||
self.mesh_viewport.set_free_rotation(bool(self.settings_panel.free_rotate_check.isChecked()))
|
||||
if self.arrow_panel is not None:
|
||||
self.arrow_panel.set_background(self.settings_panel.arrow_background())
|
||||
# Sync pipeline config fields from restored panel values.
|
||||
self.config.video = self.settings_panel.video_source() or self.config.video
|
||||
self.config.config = self.settings_panel.config_path() or self.config.config
|
||||
self.config.export_dir = self.settings_panel.finger_export_dir() or self.config.export_dir
|
||||
self.config.csv_save_dir = self.settings_panel.csv_save_dir() or self.config.csv_save_dir
|
||||
|
||||
self.settings_panel.log_visibility_changed.connect(self._on_log_visibility_changed)
|
||||
self.settings_panel.free_rotation_changed.connect(self._on_free_rotation_changed)
|
||||
if self.arrow_panel is not None:
|
||||
self.settings_panel.arrow_background_changed.connect(self._on_arrow_background_changed)
|
||||
else:
|
||||
self.settings_panel.arrow_background_changed.connect(lambda _v: self._save_preferences())
|
||||
self.settings_panel.view_reset_requested.connect(self.mesh_viewport.reset_view)
|
||||
self.settings_panel.browse_video_requested.connect(self._select_camera)
|
||||
self.settings_panel.camera_refresh_requested.connect(self._refresh_cameras)
|
||||
self.settings_panel.camera_selection_changed.connect(self._on_camera_selection_changed)
|
||||
self.settings_panel.browse_config_requested.connect(self._select_config)
|
||||
self.settings_panel.browse_finger_assets_requested.connect(self._select_finger_assets)
|
||||
self.settings_panel.browse_csv_dir_requested.connect(self._select_csv_dir)
|
||||
self.settings_panel.csv_record_toggled.connect(self._on_csv_record_toggled)
|
||||
self.settings_panel.show_flow_arrows_check.toggled.connect(lambda _v: self._save_preferences())
|
||||
self.settings_panel.btn_play_pause.clicked.connect(self._on_play_pause_clicked)
|
||||
self.settings_panel.btn_reset_tracker.clicked.connect(self._on_reset)
|
||||
self.settings_panel.set_play_pause_state("run")
|
||||
self.settings_panel.set_csv_controls_for_pipeline(False)
|
||||
self._refresh_cameras(announce=True)
|
||||
|
||||
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["settings"] = self.settings_panel
|
||||
self._panel_widgets["log"] = self.log_widget
|
||||
|
||||
self.charts_column = QSplitter(Qt.Vertical)
|
||||
self.charts_column.addWidget(self.force_charts.widget)
|
||||
if setup.log_panel.enabled:
|
||||
self.charts_column.addWidget(self.log_widget)
|
||||
self.log_widget.setVisible(self._log_panel_visible)
|
||||
self.charts_column.setStretchFactor(0, setup.charts_log_stretch)
|
||||
self.charts_column.setStretchFactor(1, setup.log_stretch)
|
||||
self.charts_column.setChildrenCollapsible(False)
|
||||
|
||||
h_splitter = QSplitter(Qt.Horizontal)
|
||||
for slot in setup.top_row.slots:
|
||||
if not slot.enabled:
|
||||
continue
|
||||
if slot.id == "charts":
|
||||
h_splitter.addWidget(self.charts_column)
|
||||
else:
|
||||
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
|
||||
|
||||
root_layout.addWidget(h_splitter)
|
||||
|
||||
self.resize(*self._target_window_size())
|
||||
QTimer.singleShot(0, self._apply_panel_layout)
|
||||
|
||||
def _set_log_panel_visible(self, visible: bool) -> None:
|
||||
self._log_panel_visible = bool(visible)
|
||||
self.log_widget.setVisible(self._log_panel_visible)
|
||||
QTimer.singleShot(0, self._apply_panel_layout)
|
||||
|
||||
def _on_log_visibility_changed(self, visible: bool) -> None:
|
||||
self._set_log_panel_visible(visible)
|
||||
self._save_preferences()
|
||||
|
||||
def _on_free_rotation_changed(self, enabled: bool) -> None:
|
||||
self.mesh_viewport.set_free_rotation(enabled)
|
||||
self._save_preferences()
|
||||
|
||||
def _on_arrow_background_changed(self, value: str) -> None:
|
||||
if self.arrow_panel is not None:
|
||||
self.arrow_panel.set_background(value)
|
||||
self._save_preferences()
|
||||
|
||||
def _save_preferences(self) -> None:
|
||||
prefs = self.settings_panel.preference_state()
|
||||
self._preferences = prefs
|
||||
save_preferences(prefs)
|
||||
# Keep in-memory pipeline config aligned with panel values.
|
||||
video = prefs.get("video_source")
|
||||
if isinstance(video, str) and video.strip():
|
||||
self.config.video = video.strip()
|
||||
config_path = prefs.get("config_path")
|
||||
if isinstance(config_path, str) and config_path.strip():
|
||||
self.config.config = config_path.strip()
|
||||
assets = prefs.get("finger_assets_dir")
|
||||
if isinstance(assets, str) and assets.strip():
|
||||
self.config.export_dir = assets.strip()
|
||||
csv_dir = prefs.get("csv_save_dir")
|
||||
if isinstance(csv_dir, str) and csv_dir.strip():
|
||||
self.config.csv_save_dir = csv_dir.strip()
|
||||
|
||||
def _apply_panel_layout(self) -> None:
|
||||
setup = self.layout.panel_setup
|
||||
scale = self._layout_scale_factor()
|
||||
column_height = self._scaled_top_row_height(scale)
|
||||
charts_slot = self.layout.top_slot("charts")
|
||||
log_slot = setup.log_panel
|
||||
|
||||
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(
|
||||
self._scaled_px(slot.width_px, scale),
|
||||
column_height,
|
||||
)
|
||||
elif slot.id == "settings":
|
||||
settings_height = (
|
||||
self._scaled_px(slot.height_px, scale) if slot.height_px > 0 else column_height
|
||||
)
|
||||
self.settings_panel.set_panel_size(
|
||||
self._scaled_px(slot.width_px, scale),
|
||||
settings_height,
|
||||
)
|
||||
|
||||
if (
|
||||
self._log_panel_visible
|
||||
and log_slot.enabled
|
||||
and log_slot.height_px > 0
|
||||
):
|
||||
self.log_widget.show()
|
||||
log_height = self._scaled_px(log_slot.height_px, scale)
|
||||
charts_height = self._scaled_px(charts_slot.height_px, scale)
|
||||
self.log_widget.setFixedHeight(log_height)
|
||||
self.force_charts.set_panel_size(
|
||||
self._scaled_px(charts_slot.width_px, scale),
|
||||
charts_height,
|
||||
)
|
||||
self.charts_column.setSizes([charts_height, log_height])
|
||||
else:
|
||||
self.log_widget.hide()
|
||||
self.force_charts.set_panel_size(
|
||||
self._scaled_px(charts_slot.width_px, scale),
|
||||
column_height,
|
||||
)
|
||||
self.charts_column.setSizes([column_height, 0])
|
||||
|
||||
self.h_splitter.setSizes(
|
||||
[self._scaled_px(slot.width_px, scale) for slot in setup.top_row.slots if slot.enabled]
|
||||
)
|
||||
|
||||
def _target_window_size(self) -> tuple[int, int]:
|
||||
"""Match the top-level window to the fixed panel layout.
|
||||
|
||||
The packaged Windows build can use different style metrics than a dev run,
|
||||
so sizing only from hard-coded window dimensions may stretch or compress the
|
||||
fixed-width panels. Derive the initial size from the actual panel layout and
|
||||
current splitter handle widths instead.
|
||||
"""
|
||||
layout = self.layout
|
||||
enabled_slots = [slot for slot in layout.panel_setup.top_row.slots if slot.enabled]
|
||||
margins = self.centralWidget().layout().contentsMargins()
|
||||
|
||||
width = sum(slot.width_px for slot in enabled_slots)
|
||||
width += max(len(enabled_slots) - 1, 0) * self.h_splitter.handleWidth()
|
||||
width += margins.left() + margins.right()
|
||||
|
||||
height = layout.charts_column_height_px()
|
||||
if (
|
||||
self._log_panel_visible
|
||||
and layout.panel_setup.log_panel.enabled
|
||||
and layout.panel_setup.log_panel.height_px > 0
|
||||
):
|
||||
height += self.charts_column.handleWidth()
|
||||
height += margins.top() + margins.bottom()
|
||||
|
||||
return max(layout.window_width, width), max(layout.window_height, height)
|
||||
|
||||
def _layout_scale_factor(self) -> float:
|
||||
"""Uniformly scale the dashboard to fit the available content area.
|
||||
|
||||
Only scale up when the user has explicitly maximized/fullscreened the
|
||||
window. Keeping normal windowed mode at scale 1 avoids a feedback loop
|
||||
where larger fixed-size child widgets increase the top-level size hint,
|
||||
which can make packaged Windows builds keep growing after launch.
|
||||
"""
|
||||
if not (self.isMaximized() or self.isFullScreen()):
|
||||
return 1.0
|
||||
|
||||
central = self.centralWidget()
|
||||
if central is None:
|
||||
return 1.0
|
||||
root_layout = central.layout()
|
||||
if root_layout is None:
|
||||
return 1.0
|
||||
|
||||
margins = root_layout.contentsMargins()
|
||||
available_width = max(1, central.width() - margins.left() - margins.right())
|
||||
available_height = max(1, central.height() - margins.top() - margins.bottom())
|
||||
|
||||
enabled_slots = [slot for slot in self.layout.panel_setup.top_row.slots if slot.enabled]
|
||||
base_width = sum(slot.width_px for slot in enabled_slots)
|
||||
base_width += max(len(enabled_slots) - 1, 0) * self.h_splitter.handleWidth()
|
||||
|
||||
base_height = self.layout.charts_column_height_px()
|
||||
if (
|
||||
self._log_panel_visible
|
||||
and self.layout.panel_setup.log_panel.enabled
|
||||
and self.layout.panel_setup.log_panel.height_px > 0
|
||||
):
|
||||
base_height += self.charts_column.handleWidth()
|
||||
if base_width <= 0 or base_height <= 0:
|
||||
return 1.0
|
||||
return min(available_width / base_width, available_height / base_height)
|
||||
|
||||
@staticmethod
|
||||
def _scaled_px(value: int, scale: float) -> int:
|
||||
return max(int(round(value * scale)), 64)
|
||||
|
||||
def _scaled_top_row_height(self, scale: float) -> int:
|
||||
return self._scaled_px(self.layout.charts_column_height_px(), scale)
|
||||
|
||||
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:
|
||||
self.settings_panel.set_play_pause_state(state)
|
||||
|
||||
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")
|
||||
self.settings_panel.set_finger_export_dir_enabled(False)
|
||||
QApplication.processEvents()
|
||||
|
||||
self.config.video = self.settings_panel.video_source()
|
||||
self.config.config = self.settings_panel.config_path()
|
||||
cal = self.settings_panel.calibration_path()
|
||||
self.config.calib = cal or None
|
||||
self.config.export_dir = self.settings_panel.finger_export_dir()
|
||||
self.config.csv_save_dir = self.settings_panel.csv_save_dir() or "./outputs/csv"
|
||||
|
||||
if not self.config.video:
|
||||
self._set_play_pause_button("run")
|
||||
self.settings_panel.set_finger_export_dir_enabled(True)
|
||||
print("请先选择摄像机或视频文件。")
|
||||
return
|
||||
|
||||
self._save_preferences()
|
||||
|
||||
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")
|
||||
self.settings_panel.set_finger_export_dir_enabled(True)
|
||||
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.settings_panel.btn_reset_tracker.setEnabled(True)
|
||||
self.settings_panel.set_camera_controls_enabled(False)
|
||||
self.settings_panel.set_csv_controls_for_pipeline(True)
|
||||
print(
|
||||
f"▶ 启动 — 摄像机={self.config.video}, 配置={self.config.config}, "
|
||||
f"标定={self.config.calib or '(none)'}, 资产={self.config.export_dir}"
|
||||
)
|
||||
|
||||
def _on_stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._stop_csv_recording(announce=True)
|
||||
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.settings_panel.btn_reset_tracker.setEnabled(False)
|
||||
self.settings_panel.set_finger_export_dir_enabled(True)
|
||||
self.settings_panel.set_camera_controls_enabled(True)
|
||||
self.settings_panel.set_csv_controls_for_pipeline(False)
|
||||
self._update_play_enabled_for_source()
|
||||
|
||||
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)
|
||||
|
||||
measurement = self.pipeline.measure_for_csv(frame, self._frame_index)
|
||||
if self.csv_recorder.is_recording:
|
||||
self.csv_recorder.write(measurement)
|
||||
|
||||
flow_mag = 0.0
|
||||
if frame.flow.size:
|
||||
flow_mag = float((frame.flow[:, :, 0] ** 2 + frame.flow[:, :, 1] ** 2).mean() ** 0.5)
|
||||
|
||||
contact_xyz_mm = None
|
||||
if (
|
||||
math.isfinite(measurement.contact_x)
|
||||
and math.isfinite(measurement.contact_y)
|
||||
and math.isfinite(measurement.contact_z)
|
||||
):
|
||||
contact_xyz_mm = (
|
||||
measurement.contact_x,
|
||||
measurement.contact_y,
|
||||
measurement.contact_z,
|
||||
)
|
||||
|
||||
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,
|
||||
contact_xyz_mm=contact_xyz_mm,
|
||||
)
|
||||
)
|
||||
|
||||
def _refresh_cameras(self, announce: bool = True) -> None:
|
||||
"""Scan cameras and populate the settings dropdown."""
|
||||
keep_file = self.settings_panel.is_file_source()
|
||||
existing_file = self.settings_panel.video_source() if keep_file else ""
|
||||
|
||||
preferred = None
|
||||
current = self.settings_panel.selected_camera_source()
|
||||
saved = get_preferred_video_source(self._preferences)
|
||||
cli_video = str(self.config.video).strip()
|
||||
if current.isdigit():
|
||||
preferred = current
|
||||
elif saved and str(saved).isdigit():
|
||||
preferred = str(saved)
|
||||
elif cli_video.isdigit():
|
||||
preferred = cli_video
|
||||
|
||||
cameras = list_cameras()
|
||||
selected = pick_default_camera(cameras, preferred)
|
||||
|
||||
self.settings_panel.set_camera_options(cameras, selected)
|
||||
if keep_file and existing_file:
|
||||
self.settings_panel.set_video_source(existing_file)
|
||||
elif selected:
|
||||
self.settings_panel.set_video_source(selected)
|
||||
self.config.video = selected
|
||||
elif not keep_file:
|
||||
self.config.video = ""
|
||||
|
||||
self._update_play_enabled_for_source()
|
||||
self._save_preferences()
|
||||
if announce:
|
||||
if cameras:
|
||||
names = ", ".join(cam.label() for cam in cameras)
|
||||
print(f"已发现 {len(cameras)} 路摄像机:{names}")
|
||||
if selected and not keep_file:
|
||||
print(f"当前摄像机:{selected}")
|
||||
else:
|
||||
print("未发现可用摄像机。可点击 Refresh 重扫,或通过 … 选择视频文件。")
|
||||
|
||||
def _update_play_enabled_for_source(self) -> None:
|
||||
if self._running:
|
||||
return
|
||||
usable = self.settings_panel.has_usable_source()
|
||||
self.settings_panel.set_play_pause_state("run")
|
||||
self.settings_panel.btn_play_pause.setEnabled(usable)
|
||||
|
||||
def _on_camera_selection_changed(self, camera_id: str) -> None:
|
||||
camera_id = str(camera_id).strip()
|
||||
if not camera_id:
|
||||
return
|
||||
self.config.video = camera_id
|
||||
self._save_preferences()
|
||||
self._update_play_enabled_for_source()
|
||||
|
||||
def _select_camera(self) -> None:
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"选择视频文件",
|
||||
"data",
|
||||
"视频 (*.mp4 *.avi *.mov);;所有文件 (*)",
|
||||
)
|
||||
if path:
|
||||
self.settings_panel.set_video_source(path)
|
||||
self.config.video = path
|
||||
self._save_preferences()
|
||||
self._update_play_enabled_for_source()
|
||||
|
||||
def _select_config(self) -> None:
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"选择配置文件",
|
||||
"config",
|
||||
"JSON (*.json);;所有文件 (*)",
|
||||
)
|
||||
if path:
|
||||
self.settings_panel.set_config_path(path)
|
||||
self.config.config = path
|
||||
self._save_preferences()
|
||||
|
||||
def _select_finger_assets(self) -> None:
|
||||
path = QFileDialog.getExistingDirectory(
|
||||
self,
|
||||
"选择 finger 资产目录",
|
||||
self.settings_panel.finger_export_dir() or "assets/finger",
|
||||
)
|
||||
if path:
|
||||
self.settings_panel.set_finger_export_dir(path)
|
||||
self.config.export_dir = path
|
||||
self._save_preferences()
|
||||
|
||||
def _select_csv_dir(self) -> None:
|
||||
path = QFileDialog.getExistingDirectory(
|
||||
self,
|
||||
"选择 CSV 保存目录",
|
||||
self.settings_panel.csv_save_dir() or "./outputs/csv",
|
||||
)
|
||||
if path:
|
||||
self.settings_panel.set_csv_save_dir(path)
|
||||
self.config.csv_save_dir = path
|
||||
self._save_preferences()
|
||||
|
||||
def _on_csv_record_toggled(self, start: bool) -> None:
|
||||
if start:
|
||||
self._start_csv_recording()
|
||||
else:
|
||||
self._stop_csv_recording(announce=True)
|
||||
|
||||
def _start_csv_recording(self) -> None:
|
||||
if not self._running:
|
||||
print("请先启动传感器后再记录 CSV。")
|
||||
self.settings_panel.set_csv_recording(False)
|
||||
return
|
||||
save_dir = self.settings_panel.csv_save_dir() or "./outputs/csv"
|
||||
self.config.csv_save_dir = save_dir
|
||||
try:
|
||||
path = self.csv_recorder.start(save_dir)
|
||||
except OSError as exc:
|
||||
print(f"无法开始 CSV 记录:{exc}")
|
||||
self.settings_panel.set_csv_recording(False)
|
||||
return
|
||||
self.settings_panel.set_csv_recording(True)
|
||||
print(f"开始 CSV 记录:{path}")
|
||||
|
||||
def _stop_csv_recording(self, *, announce: bool = False) -> None:
|
||||
if not self.csv_recorder.is_recording:
|
||||
self.settings_panel.set_csv_recording(False)
|
||||
return
|
||||
path, count = self.csv_recorder.stop()
|
||||
self.settings_panel.set_csv_recording(False)
|
||||
if announce:
|
||||
if path is not None:
|
||||
print(f"CSV 记录结束:{path},总行数:{count}")
|
||||
else:
|
||||
print(f"CSV 记录结束,总行数:{count}")
|
||||
|
||||
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._stop_csv_recording(announce=True)
|
||||
self._save_preferences()
|
||||
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 gui.app import get_or_create_qapp
|
||||
|
||||
print("\n按键说明:Q 退出 | R 重置追踪器 | S 开始录像 | E 结束录像 | ESC 全屏")
|
||||
print("GUI:Record CSV / Stop CSV 写入接触点与力到所选目录。")
|
||||
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_()
|
||||
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
Finger dashboard layout — edit this file to define the panel hierarchy and sizes.
|
||||
|
||||
Structure:
|
||||
|
||||
┌─ toolbar ─────────────────────────────────────────────────────┐
|
||||
├─ mesh ─┬─ charts ─┬─ settings ────────────────────────────────┤
|
||||
│ ├─ log ────┤ │
|
||||
└────────┴──────────┴─────────────────────────────────────────┘
|
||||
|
||||
Each panel uses explicit width_px and height_px.
|
||||
charts_log_stretch / log_stretch control charts vs log height inside the charts column.
|
||||
|
||||
MeshLighting (below) controls the 3D mesh panel sun light:
|
||||
azimuth_deg — horizontal angle in degrees (0° = +X, 90° = +Y)
|
||||
elevation_deg — vertical angle in degrees (positive = from above)
|
||||
intensity — Filament sun strength (typical 30000–120000)
|
||||
ambient/diffuse — Windows viewer shading weights (0–1)
|
||||
|
||||
MESH_BACKGROUND (below) sets the 3D mesh panel clear color (RGB 0–255).
|
||||
|
||||
MESH_AXIS_OVERLAY (below) controls the fixed top-right pre-rendered 3D axis triad:
|
||||
enabled, padding_px, size_px, opacity
|
||||
|
||||
MESH_CONTACT_ARROW (below) controls the yellow 3D contact arrow on the mesh panel:
|
||||
reference_normal_force — Fn at which arrow reaches full size (log/power/linear curve)
|
||||
force_strength_mode — "log" | "power" | "linear"
|
||||
min/max_length_fraction, min/max_thickness_scale, color
|
||||
|
||||
Mesh panel has two sizes (edit MESH_RENDER_* and TOP_ROW mesh slot):
|
||||
display (TOP_ROW mesh width_px/height_px) — Qt widget size on screen
|
||||
render (MESH_RENDER_WIDTH/HEIGHT) — Open3D offscreen resolution (upscaled for display)
|
||||
|
||||
Performance (3D dashboard; edit DashboardLayout defaults):
|
||||
timer_interval_ms — Qt timer period (16 ≈ 60 Hz tick rate)
|
||||
mesh_render_stride — render 3D mesh every N frames (2 ≈ half mesh cost)
|
||||
chart_update_stride — update force charts every N frames
|
||||
mesh_async_render — reserved for future use; mesh rendering currently stays on the UI thread
|
||||
|
||||
Edit TOP_ROW.slots to reorder or resize panels; set ``enabled=False`` to hide a panel (e.g. arrows).
|
||||
Log sits under charts only (not under mesh). Settings is the rightmost column.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from gui.log_config import LOG_CONFIG, LogConfig
|
||||
from viz.contact_arrow import ContactArrowStyle, DEFAULT_CONTACT_ARROW_STYLE
|
||||
from viz.flow_arrow import FlowArrowStyle
|
||||
from viz.render_2d import ArrowBackground, ArrowCanvasColors
|
||||
|
||||
PanelId = Literal["mesh", "arrows", "charts", "log", "settings"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PanelSlot:
|
||||
"""One panel in the dashboard."""
|
||||
|
||||
id: PanelId
|
||||
title: str
|
||||
width_px: int
|
||||
height_px: int
|
||||
stretch: int = 0
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PanelRow:
|
||||
"""Horizontal row of panels (the top visual strip)."""
|
||||
|
||||
slots: tuple[PanelSlot, ...]
|
||||
collapsible: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PanelSetup:
|
||||
"""Full panel hierarchy: top row + log nested under charts."""
|
||||
|
||||
top_row: PanelRow
|
||||
log_panel: PanelSlot
|
||||
charts_log_stretch: int = 5
|
||||
log_stretch: int = 1
|
||||
|
||||
|
||||
# --- Top strip: mesh | arrows? | charts | settings (edit order and sizes here) ---
|
||||
TOP_ROW = PanelRow(
|
||||
slots=(
|
||||
PanelSlot("mesh", "3D mesh", width_px=400, height_px=400),
|
||||
PanelSlot("arrows", "UV Unrolled", width_px=400, height_px=400, enabled=True),
|
||||
PanelSlot("charts", "Force charts", width_px=300, height_px=400),
|
||||
PanelSlot("settings", "Settings", width_px=250, height_px=0),
|
||||
),
|
||||
)
|
||||
|
||||
LOG_PANEL = PanelSlot("log", "Log", width_px=0, height_px=100)
|
||||
|
||||
PANEL_SETUP = PanelSetup(
|
||||
top_row=TOP_ROW,
|
||||
log_panel=LOG_PANEL,
|
||||
charts_log_stretch=5,
|
||||
log_stretch=1,
|
||||
)
|
||||
|
||||
# Open3D offscreen resolution for the mesh panel (independent of on-screen widget size).
|
||||
MESH_RENDER_WIDTH_PX = 500
|
||||
MESH_RENDER_HEIGHT_PX = 500
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MeshLighting:
|
||||
"""3D mesh panel light — edit MESH_LIGHTING or DashboardLayout.mesh_lighting."""
|
||||
|
||||
azimuth_deg: float = -45.0 # finger outline
|
||||
elevation_deg: float = -35.0 # tip to root
|
||||
intensity: float = 75000.0
|
||||
ambient: float = 0.35
|
||||
diffuse: float = 0.65
|
||||
|
||||
@property
|
||||
def direction(self) -> tuple[float, float, float]:
|
||||
"""Unit light direction derived from azimuth / elevation (degrees)."""
|
||||
az = math.radians(self.azimuth_deg)
|
||||
el = math.radians(self.elevation_deg)
|
||||
cos_el = math.cos(el)
|
||||
return (cos_el * math.cos(az), cos_el * math.sin(az), math.sin(el))
|
||||
|
||||
|
||||
MESH_LIGHTING = MeshLighting(
|
||||
azimuth_deg=0,
|
||||
elevation_deg=0,
|
||||
intensity=75000.0,
|
||||
ambient=0.5,
|
||||
diffuse=0.5,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MeshBackground:
|
||||
"""3D mesh panel clear color — edit MESH_BACKGROUND or DashboardLayout.mesh_background."""
|
||||
|
||||
color: tuple[int, int, int] = (0, 0, 0)
|
||||
|
||||
|
||||
MESH_BACKGROUND = MeshBackground(color=(0, 0, 0))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MeshAxisOverlay:
|
||||
"""Fixed pre-rendered XYZ triad HUD on the 3D mesh RGB output."""
|
||||
|
||||
enabled: bool = True
|
||||
padding_px: int = 12
|
||||
size_px: int = 96
|
||||
opacity: float = 1.0
|
||||
|
||||
|
||||
MESH_AXIS_OVERLAY = MeshAxisOverlay()
|
||||
|
||||
|
||||
# 3D mesh contact arrow — edit here for dashboard tuning (see ContactArrowStyle in viz/contact_arrow.py).
|
||||
MESH_CONTACT_ARROW = ContactArrowStyle(
|
||||
reference_normal_force=10000.0,
|
||||
force_strength_mode="log",
|
||||
force_strength_gamma=0.5,
|
||||
min_length_fraction=0.02,
|
||||
max_length_fraction=0.30,
|
||||
min_thickness_scale=0.20,
|
||||
max_thickness_scale=1.0,
|
||||
normal_force_scale=1.0,
|
||||
color=(1.0, 0.92, 0.10),
|
||||
)
|
||||
|
||||
# 3D mesh flow line arrows — tail_color/head_color are RGB 0–255 (see FlowArrowStyle).
|
||||
MESH_FLOW_ARROW = FlowArrowStyle(
|
||||
threshold=2.0,
|
||||
grid_spacing=20,
|
||||
arrow_scale=0.5,
|
||||
length_margin=1.0,
|
||||
tail_color=(40, 100, 220),
|
||||
head_color=(40, 100, 220),
|
||||
head_fraction=0.5,
|
||||
opacity=1,
|
||||
line_width=15,
|
||||
head_line_width=30.0,
|
||||
)
|
||||
|
||||
# UV arrow synthetic canvas — BGR tuples (outside valid_mask, inside valid_mask).
|
||||
ARROW_CANVAS_COLORS = ArrowCanvasColors(
|
||||
outside_bgr=(0, 0, 0),
|
||||
uv_bgr=(128, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DashboardLayout:
|
||||
"""Window chrome, chart styling, and panel hierarchy."""
|
||||
|
||||
panel_setup: PanelSetup = PANEL_SETUP
|
||||
|
||||
window_title: str = "Orisys Finger Dashboard"
|
||||
window_width: int = 1300
|
||||
window_height: int = 560
|
||||
|
||||
root_margin_px: int = 6
|
||||
root_spacing_px: int = 6
|
||||
toolbar_spacing_px: int = 8
|
||||
|
||||
plot_buffer_points: int = 120
|
||||
plot_sample_rate_hz: float = 30.0
|
||||
chart_title_font_pt: int = 11
|
||||
chart_axis_label_font_pt: int = 9
|
||||
chart_tick_font_pt: int = 9
|
||||
y_axis_min_span: float = 10000.0
|
||||
y_range_smooth_alpha: float = 0.5
|
||||
curve_color: str = "#00E5FF"
|
||||
|
||||
# UV arrow panel: min flow magnitude to draw an arrow (display only; not dd02-ov.json contact.flow_threshold)
|
||||
arrow_flow_threshold: float = 1.0
|
||||
# "unrolled" = sensor texture; "pure_color" = solid canvas (tune colors via arrow_canvas_colors)
|
||||
arrow_background: ArrowBackground = "pure_color"
|
||||
arrow_canvas_colors: ArrowCanvasColors = ARROW_CANVAS_COLORS
|
||||
|
||||
mesh_lighting: MeshLighting = MESH_LIGHTING
|
||||
mesh_background: MeshBackground = MESH_BACKGROUND
|
||||
mesh_contact_arrow: ContactArrowStyle = MESH_CONTACT_ARROW
|
||||
mesh_flow_arrow: FlowArrowStyle = MESH_FLOW_ARROW
|
||||
mesh_axis_overlay: MeshAxisOverlay = MESH_AXIS_OVERLAY
|
||||
|
||||
log_config: LogConfig = LOG_CONFIG
|
||||
|
||||
mesh_render_width_px: int = MESH_RENDER_WIDTH_PX
|
||||
mesh_render_height_px: int = MESH_RENDER_HEIGHT_PX
|
||||
|
||||
timer_interval_ms: int = 16
|
||||
mesh_async_render: bool = False
|
||||
mesh_render_stride: int = 2
|
||||
chart_update_stride: int = 2
|
||||
|
||||
def mesh_display_size(self) -> tuple[int, int]:
|
||||
"""On-screen mesh panel size from TOP_ROW mesh slot."""
|
||||
slot = self.top_slot("mesh")
|
||||
return slot.width_px, slot.height_px
|
||||
|
||||
def mesh_render_size(self) -> tuple[int, int]:
|
||||
"""Open3D offscreen render target size (may differ from display size)."""
|
||||
return self.mesh_render_width_px, self.mesh_render_height_px
|
||||
|
||||
def slot(self, panel_id: PanelId) -> PanelSlot | None:
|
||||
if self.panel_setup.log_panel.id == panel_id:
|
||||
return self.panel_setup.log_panel
|
||||
for slot in self.panel_setup.top_row.slots:
|
||||
if slot.id == panel_id:
|
||||
return slot
|
||||
return None
|
||||
|
||||
def charts_column_height_px(self) -> int:
|
||||
"""Total height of the charts column (charts + log when enabled)."""
|
||||
charts = self.top_slot("charts")
|
||||
height = charts.height_px
|
||||
log = self.panel_setup.log_panel
|
||||
if log.enabled and log.height_px > 0:
|
||||
height += log.height_px
|
||||
return height
|
||||
|
||||
def top_slot(self, panel_id: PanelId) -> PanelSlot:
|
||||
for slot in self.panel_setup.top_row.slots:
|
||||
if slot.id == panel_id:
|
||||
return slot
|
||||
raise KeyError(f"panel {panel_id!r} not in top row")
|
||||
|
||||
@property
|
||||
def mesh_panel_title(self) -> str:
|
||||
return self.top_slot("mesh").title
|
||||
|
||||
@property
|
||||
def arrows_panel_title(self) -> str:
|
||||
return self.top_slot("arrows").title
|
||||
|
||||
@property
|
||||
def settings_panel_title(self) -> str:
|
||||
return self.top_slot("settings").title
|
||||
|
||||
|
||||
LAYOUT = DashboardLayout()
|
||||
|
||||
|
||||
def top_row_splitter_sizes(layout: DashboardLayout = LAYOUT) -> tuple[int, ...]:
|
||||
"""Horizontal splitter widths from panel setup."""
|
||||
return tuple(slot.width_px for slot in layout.panel_setup.top_row.slots if slot.enabled)
|
||||
|
||||
|
||||
def top_row_slot_index(panel_id: PanelId, layout: DashboardLayout = LAYOUT) -> int:
|
||||
"""Index of a panel in the horizontal top splitter."""
|
||||
for i, slot in enumerate(layout.panel_setup.top_row.slots):
|
||||
if slot.id == panel_id:
|
||||
return i
|
||||
raise KeyError(f"panel {panel_id!r} not in top row")
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Background Open3D mesh rendering for the Qt dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from PyQt5.QtCore import QMutex, QMutexLocker, QThread, pyqtSignal
|
||||
|
||||
|
||||
class MeshRenderWorker(QThread):
|
||||
"""Render finger mesh off the GUI thread; drops stale frames."""
|
||||
|
||||
rgb_ready = pyqtSignal(object)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._mutex = QMutex()
|
||||
self._stop = False
|
||||
self._wake = False
|
||||
self._renderer = None
|
||||
self._pending: dict | None = None
|
||||
|
||||
def set_renderer(self, renderer) -> None:
|
||||
with QMutexLocker(self._mutex):
|
||||
self._renderer = renderer
|
||||
|
||||
def clear_renderer(self) -> None:
|
||||
with QMutexLocker(self._mutex):
|
||||
self._renderer = None
|
||||
self._pending = None
|
||||
|
||||
def submit(self, *, flow, scalar_map, centroid, centroid_found, fnormal, valid_mask=None) -> None:
|
||||
with QMutexLocker(self._mutex):
|
||||
self._pending = {
|
||||
"flow": flow,
|
||||
"scalar_map": scalar_map,
|
||||
"centroid": centroid,
|
||||
"centroid_found": centroid_found,
|
||||
"fnormal": fnormal,
|
||||
"valid_mask": valid_mask,
|
||||
}
|
||||
self._wake = True
|
||||
if not self.isRunning():
|
||||
self.start()
|
||||
|
||||
def stop_worker(self) -> None:
|
||||
with QMutexLocker(self._mutex):
|
||||
self._stop = True
|
||||
self._wake = True
|
||||
self.wait(2000)
|
||||
|
||||
def run(self) -> None:
|
||||
while True:
|
||||
with QMutexLocker(self._mutex):
|
||||
if self._stop:
|
||||
break
|
||||
if not self._wake or self._renderer is None or self._pending is None:
|
||||
self.msleep(1)
|
||||
continue
|
||||
job = self._pending
|
||||
self._pending = None
|
||||
self._wake = False
|
||||
renderer = self._renderer
|
||||
|
||||
rgb = renderer.render(
|
||||
flow=job["flow"],
|
||||
scalar_map=job["scalar_map"],
|
||||
centroid=job["centroid"],
|
||||
centroid_found=job["centroid_found"],
|
||||
fnormal=job["fnormal"],
|
||||
valid_mask=job["valid_mask"],
|
||||
)
|
||||
self.rgb_ready.emit(np.ascontiguousarray(rgb))
|
||||
|
||||
with QMutexLocker(self._mutex):
|
||||
if self._pending is not None:
|
||||
self._wake = True
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Runtime mesh viewport UI state shared by settings and the 3D mesh panel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import QObject, pyqtSignal
|
||||
|
||||
from .layout import DashboardLayout, LAYOUT
|
||||
|
||||
|
||||
class MeshViewState(QObject):
|
||||
"""Mutable runtime toggles for the embedded mesh viewport."""
|
||||
|
||||
flow_arrows_changed = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, *, flow_arrows_enabled: bool = True):
|
||||
super().__init__()
|
||||
self._flow_arrows_enabled = bool(flow_arrows_enabled)
|
||||
|
||||
@property
|
||||
def flow_arrows_enabled(self) -> bool:
|
||||
return self._flow_arrows_enabled
|
||||
|
||||
@classmethod
|
||||
def from_layout(cls, layout: DashboardLayout = LAYOUT) -> MeshViewState:
|
||||
return cls(flow_arrows_enabled=layout.mesh_flow_arrow.enabled)
|
||||
|
||||
def set_flow_arrows_enabled(self, enabled: bool) -> None:
|
||||
enabled = bool(enabled)
|
||||
if enabled == self._flow_arrows_enabled:
|
||||
return
|
||||
self._flow_arrows_enabled = enabled
|
||||
self.flow_arrows_changed.emit(enabled)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Load/save dashboard user preferences for the finger 3D viewer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from gui.app_paths import resource_path
|
||||
|
||||
PREFERENCE_FILE = "preference.json"
|
||||
LEGACY_CAMERA_SELECTION_FILE = "camera_selection.json"
|
||||
# Keep preference.json at examples/gui/ for backward compatibility.
|
||||
_GUI_DIR = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def preference_path() -> Path:
|
||||
# Keep preference file at examples/gui/preference.json
|
||||
return _GUI_DIR / PREFERENCE_FILE
|
||||
|
||||
|
||||
def legacy_camera_selection_path() -> Path:
|
||||
return resource_path(LEGACY_CAMERA_SELECTION_FILE)
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def load_preferences() -> dict[str, Any]:
|
||||
"""Load preferences, migrating camera_id from legacy file when needed."""
|
||||
prefs = _read_json(preference_path())
|
||||
if "video_source" not in prefs:
|
||||
legacy = _read_json(legacy_camera_selection_path())
|
||||
camera_id = legacy.get("camera_id")
|
||||
if camera_id is not None and str(camera_id).strip():
|
||||
prefs["video_source"] = str(camera_id).strip()
|
||||
return prefs
|
||||
|
||||
|
||||
def save_preferences(prefs: dict[str, Any]) -> None:
|
||||
"""Write preferences atomically enough for dashboard use."""
|
||||
if not isinstance(prefs, dict):
|
||||
return
|
||||
path = preference_path()
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(prefs, indent=2) + "\n", encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def get_preferred_video_source(prefs: dict[str, Any] | None = None) -> str | None:
|
||||
data = prefs if prefs is not None else load_preferences()
|
||||
value = data.get("video_source")
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Qt stylesheet for the finger dashboard."""
|
||||
|
||||
UI_FONT_STACK = '"Segoe UI Variable", "Segoe UI", "Inter", "Microsoft YaHei UI", sans-serif'
|
||||
LOG_FONT_STACK = '"Cascadia Mono", "JetBrains Mono", "IBM Plex Mono", "Consolas", monospace'
|
||||
UI_FONT_PRIMARY = "Segoe UI"
|
||||
LOG_FONT_PRIMARY = "Cascadia Mono"
|
||||
|
||||
STYLE = """
|
||||
QMainWindow {
|
||||
background-color: #000000;
|
||||
}
|
||||
|
||||
QLabel#panelLabel, QLabel#meshViewport {
|
||||
border: 1px solid #262626;
|
||||
border-radius: 4px;
|
||||
background-color: #0b0b0b;
|
||||
}
|
||||
|
||||
QWidget#settingsPanel {
|
||||
border: 1px solid #262626;
|
||||
border-radius: 4px;
|
||||
background-color: #0b0b0b;
|
||||
}
|
||||
QWidget#settingsPanel QLabel#settingsTitle {
|
||||
color: #cccccc;
|
||||
font-family: __UI_FONT__;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QWidget#settingsPanel QLabel#settingsCaption {
|
||||
color: #777777;
|
||||
font-family: __UI_FONT__;
|
||||
font-size: 11px;
|
||||
}
|
||||
QWidget#settingsPanel QCheckBox {
|
||||
color: #999999;
|
||||
font-family: __UI_FONT__;
|
||||
font-size: 12px;
|
||||
spacing: 6px;
|
||||
}
|
||||
QPushButton#btnResetView {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
QPushButton {
|
||||
background-color: #1a1a1a;
|
||||
color: #999999;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 4px;
|
||||
padding: 6px 16px;
|
||||
font-family: __UI_FONT__;
|
||||
font-size: 13px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #262626;
|
||||
color: #cccccc;
|
||||
border: 1px solid #555555;
|
||||
}
|
||||
QPushButton#btnPlayPause {
|
||||
background-color: #0d2818;
|
||||
color: #4caf84;
|
||||
border: 1px solid #1a4d30;
|
||||
padding: 4px 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
QPushButton#btnPlayPause:hover {
|
||||
background-color: #143d24;
|
||||
color: #6fd4a0;
|
||||
}
|
||||
QPushButton#btnPlayPause:disabled {
|
||||
background-color: #1a1a1a;
|
||||
color: #555555;
|
||||
border: 1px solid #333333;
|
||||
}
|
||||
QPushButton#btnPlayPause[playState="pause"] {
|
||||
background-color: #3d2808;
|
||||
color: #ff9800;
|
||||
border: 1px solid #6d4400;
|
||||
}
|
||||
QPushButton#btnPlayPause[playState="pause"]:hover {
|
||||
background-color: #523208;
|
||||
color: #ffb74d;
|
||||
border: 1px solid #8a5a00;
|
||||
}
|
||||
QPushButton#btnPlayPause[playState="pause"]:disabled {
|
||||
background-color: #1a1a1a;
|
||||
color: #555555;
|
||||
border: 1px solid #333333;
|
||||
}
|
||||
QLineEdit {
|
||||
background-color: #0d0d0d;
|
||||
color: #999999;
|
||||
border: 1px solid #333333;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
font-family: __UI_FONT__;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
GraphicsLayoutWidget {
|
||||
border: 1px solid #262626;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
QPlainTextEdit {
|
||||
background-color: #0b0b0b;
|
||||
color: #999999;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 4px;
|
||||
font-family: __LOG_FONT__;
|
||||
font-size: 12px;
|
||||
padding: 8px;
|
||||
selection-background-color: #1a3a4a;
|
||||
selection-color: #cccccc;
|
||||
}
|
||||
|
||||
QPlainTextEdit QScrollBar:vertical {
|
||||
background-color: #0b0b0b;
|
||||
width: 6px;
|
||||
margin: 1px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
QPlainTextEdit QScrollBar::handle:vertical {
|
||||
background-color: #262626;
|
||||
border-radius: 3px;
|
||||
min-height: 30px;
|
||||
}
|
||||
QPlainTextEdit QScrollBar::handle:vertical:hover {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
QPlainTextEdit QScrollBar::add-line:vertical,
|
||||
QPlainTextEdit QScrollBar::sub-line:vertical {
|
||||
height: 0px;
|
||||
}
|
||||
QPlainTextEdit QScrollBar::add-page:vertical,
|
||||
QPlainTextEdit QScrollBar::sub-page:vertical {
|
||||
background: none;
|
||||
}
|
||||
|
||||
QSplitter::handle {
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
QSplitter::handle:vertical {
|
||||
height: 1px;
|
||||
}
|
||||
QSplitter::handle:horizontal {
|
||||
width: 1px;
|
||||
}
|
||||
""".replace("__UI_FONT__", UI_FONT_STACK).replace("__LOG_FONT__", LOG_FONT_STACK)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Monochrome play/pause icons for the dashboard toolbar (avoids Windows emoji glyphs)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Literal
|
||||
|
||||
from PyQt5.QtCore import QPointF, QRectF, Qt
|
||||
from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap, QPolygonF
|
||||
|
||||
TransportKind = Literal["play", "pause"]
|
||||
|
||||
_DEFAULT_ICON_PX = 18
|
||||
|
||||
|
||||
def transport_icon(kind: TransportKind, color: str, *, size: int = _DEFAULT_ICON_PX) -> QIcon:
|
||||
"""Build a flat play triangle or pause bars in ``color`` (#rrggbb)."""
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.transparent)
|
||||
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.Antialiasing, True)
|
||||
painter.setBrush(QColor(color))
|
||||
painter.setPen(Qt.NoPen)
|
||||
|
||||
if kind == "play":
|
||||
w = size * 0.52
|
||||
h = size * 0.60
|
||||
x0 = (size - w) * 0.58
|
||||
y0 = (size - h) / 2.0
|
||||
painter.drawPolygon(
|
||||
QPolygonF(
|
||||
[
|
||||
QPointF(x0, y0),
|
||||
QPointF(x0, y0 + h),
|
||||
QPointF(x0 + w, y0 + h / 2.0),
|
||||
]
|
||||
)
|
||||
)
|
||||
else:
|
||||
bar_w = size * 0.17
|
||||
bar_h = size * 0.60
|
||||
gap = size * 0.14
|
||||
y0 = (size - bar_h) / 2.0
|
||||
x0 = (size - (2.0 * bar_w + gap)) / 2.0
|
||||
radius = max(1.0, size * 0.06)
|
||||
painter.drawRoundedRect(QRectF(x0, y0, bar_w, bar_h), radius, radius)
|
||||
painter.drawRoundedRect(QRectF(x0 + bar_w + gap, y0, bar_w, bar_h), radius, radius)
|
||||
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
|
||||
|
||||
def reset_icon(color: str, *, size: int = 16) -> QIcon:
|
||||
"""Build a circular reset / refresh arrow in ``color`` (#rrggbb)."""
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.transparent)
|
||||
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.Antialiasing, True)
|
||||
stroke = max(1.4, size * 0.11)
|
||||
pen = QPen(QColor(color), stroke, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(Qt.NoBrush)
|
||||
|
||||
margin = size * 0.2
|
||||
rect = QRectF(margin, margin, size - 2.0 * margin, size - 2.0 * margin)
|
||||
start_deg = 55.0
|
||||
painter.drawArc(rect, int(start_deg * 16), int(-280 * 16))
|
||||
|
||||
cx, cy = rect.center().x(), rect.center().y()
|
||||
radius = rect.width() / 2.0
|
||||
angle_rad = math.radians(start_deg)
|
||||
tip_x = cx + radius * math.cos(angle_rad)
|
||||
tip_y = cy - radius * math.sin(angle_rad)
|
||||
tangent = angle_rad + math.pi / 2.0
|
||||
head = max(2.0, size * 0.18)
|
||||
wing = max(2.0, size * 0.14)
|
||||
painter.setBrush(QColor(color))
|
||||
painter.setPen(Qt.NoPen)
|
||||
painter.drawPolygon(
|
||||
QPolygonF(
|
||||
[
|
||||
QPointF(tip_x, tip_y),
|
||||
QPointF(tip_x - head * math.cos(tangent) + wing * math.sin(tangent),
|
||||
tip_y + head * math.sin(tangent) + wing * math.cos(tangent)),
|
||||
QPointF(tip_x - head * math.cos(tangent) - wing * math.sin(tangent),
|
||||
tip_y + head * math.sin(tangent) - wing * math.cos(tangent)),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Finger-specific Qt widgets for the 3D dashboard."""
|
||||
|
||||
from .arrow_image import ArrowPanelWidget
|
||||
from .force_charts import ForceChartWidget
|
||||
from .image_panel import ImagePanel
|
||||
from .mesh_viewport import MeshViewportWidget
|
||||
from .settings_panel import SettingsPanelWidget
|
||||
|
||||
__all__ = [
|
||||
"ArrowPanelWidget",
|
||||
"ForceChartWidget",
|
||||
"ImagePanel",
|
||||
"MeshViewportWidget",
|
||||
"SettingsPanelWidget",
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""UV flow-arrow panel for the Qt dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import cv2
|
||||
|
||||
from pipeline.frame import FingerFrameView
|
||||
from viz.render_2d import ArrowBackground, ArrowCanvasColors, render_uv_arrows
|
||||
|
||||
from ..layout import DashboardLayout, LAYOUT
|
||||
from .image_panel import ImagePanel
|
||||
from gui.widgets.qt_pixmap import ndarray_to_pixmap
|
||||
|
||||
|
||||
class ArrowPanelWidget(ImagePanel):
|
||||
"""UV unrolled flow-arrow panel."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
flow_threshold: float = 0.5,
|
||||
background: ArrowBackground = "unrolled",
|
||||
canvas_colors: ArrowCanvasColors | None = None,
|
||||
layout: DashboardLayout = LAYOUT,
|
||||
parent=None,
|
||||
):
|
||||
slot = layout.top_slot("arrows")
|
||||
super().__init__(
|
||||
title=layout.arrows_panel_title,
|
||||
object_name="panelLabel",
|
||||
default_side=min(slot.width_px, slot.height_px),
|
||||
parent=parent,
|
||||
)
|
||||
self.flow_threshold = flow_threshold
|
||||
self.background = background
|
||||
self.canvas_colors = canvas_colors if canvas_colors is not None else layout.arrow_canvas_colors
|
||||
|
||||
def set_flow_threshold(self, threshold: float) -> None:
|
||||
self.flow_threshold = threshold
|
||||
|
||||
def set_background(self, background: ArrowBackground) -> None:
|
||||
self.background = background
|
||||
|
||||
def update_frame(self, frame: FingerFrameView) -> None:
|
||||
arrows = render_uv_arrows(
|
||||
frame,
|
||||
flow_threshold=self.flow_threshold,
|
||||
background=self.background,
|
||||
canvas_colors=self.canvas_colors,
|
||||
)
|
||||
arrows = cv2.rotate(arrows, cv2.ROTATE_180)
|
||||
self.set_source_pixmap(ndarray_to_pixmap(arrows, bgr=True))
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Embedded PyQtGraph force charts for the Qt dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from PyQt5.QtGui import QFont
|
||||
from PyQt5.QtWidgets import QSizePolicy
|
||||
|
||||
from ..layout import DashboardLayout, LAYOUT
|
||||
from ..styles import LOG_FONT_PRIMARY, UI_FONT_PRIMARY
|
||||
from pipeline.frame import FingerFrameView
|
||||
from viz.signal_filters import lowpass_filter
|
||||
|
||||
|
||||
def _require_pyqtgraph():
|
||||
try:
|
||||
import pyqtgraph as pg
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"PyQtGraph is not installed. Install with: pip install pyqt5 pyqtgraph"
|
||||
) from exc
|
||||
return pg
|
||||
|
||||
|
||||
class ForceChartWidget:
|
||||
"""Rolling normal/shear force charts as an embeddable QWidget."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
layout: DashboardLayout = LAYOUT,
|
||||
lowpass_cutoff_hz: float = 5.0,
|
||||
parent=None,
|
||||
):
|
||||
pg = _require_pyqtgraph()
|
||||
pg.setConfigOption("background", "#000000")
|
||||
pg.setConfigOption("foreground", "#777777")
|
||||
pg.setConfigOptions(antialias=True)
|
||||
|
||||
self._layout = layout
|
||||
self._pg = pg
|
||||
self.npoints = layout.plot_buffer_points
|
||||
self.sample_rate_hz = layout.plot_sample_rate_hz
|
||||
self.lowpass_cutoff_hz = lowpass_cutoff_hz
|
||||
self._plot_window_sec = self.npoints / self.sample_rate_hz
|
||||
self._plot_sample_count = 0
|
||||
self._y_range_alpha = layout.y_range_smooth_alpha
|
||||
self._y_range_smooth: list[tuple[float, float] | None] = [None, None, None]
|
||||
|
||||
self.widget = pg.GraphicsLayoutWidget(parent=parent)
|
||||
charts = layout.top_slot("charts")
|
||||
self.set_panel_size(charts.width_px, charts.height_px)
|
||||
self._init_plot_buffer()
|
||||
self._create_plots()
|
||||
|
||||
def set_panel_size(self, width: int, height: int) -> None:
|
||||
"""Apply layout width/height; height 0 means fill top-row height."""
|
||||
width = max(int(width), 64)
|
||||
self.widget.setFixedWidth(width)
|
||||
if height > 0:
|
||||
self.widget.setFixedHeight(height)
|
||||
self.widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
else:
|
||||
self.widget.setMaximumHeight(16777215)
|
||||
self.widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
|
||||
|
||||
def _init_plot_buffer(self) -> None:
|
||||
self.data = np.full((self.npoints, 2, 3), np.nan, dtype=np.float64)
|
||||
self._plot_sample_count = 0
|
||||
self._time_origin: float | None = None
|
||||
self._y_range_smooth = [None, None, None]
|
||||
|
||||
def _create_plots(self) -> None:
|
||||
pg = self._pg
|
||||
layout = self._layout
|
||||
titles = ["Normal Deformation", "X Deformation", "Y Deformation"]
|
||||
self.plots = []
|
||||
self.curves = []
|
||||
self.dot_glows = []
|
||||
self.dot_cores = []
|
||||
|
||||
axis_pen = pg.mkPen(color=(38, 38, 38), width=1)
|
||||
label_font = QFont(UI_FONT_PRIMARY, layout.chart_axis_label_font_pt)
|
||||
title_pt = layout.chart_title_font_pt
|
||||
|
||||
for i, title in enumerate(titles):
|
||||
plot = self.widget.addPlot(title=None)
|
||||
plot.setTitle(title, color="#999999", size=f"{title_pt}pt")
|
||||
plot.titleLabel.setFont(QFont(UI_FONT_PRIMARY, title_pt))
|
||||
plot.setLabel("bottom", "Time", units="s")
|
||||
plot.getAxis("bottom").label.setFont(label_font)
|
||||
plot.showAxis("top", False)
|
||||
plot.showAxis("right", False)
|
||||
plot.getAxis("left").setPen(axis_pen)
|
||||
plot.getAxis("bottom").setPen(axis_pen)
|
||||
plot.showGrid(x=False, y=True, alpha=0.12)
|
||||
|
||||
tick_font = QFont(LOG_FONT_PRIMARY, layout.chart_tick_font_pt)
|
||||
tick_font.setStyleHint(QFont.Monospace)
|
||||
plot.getAxis("left").setTickFont(tick_font)
|
||||
plot.getAxis("bottom").setTickFont(tick_font)
|
||||
|
||||
line_pen = pg.mkPen(color=layout.curve_color, width=2)
|
||||
curve = plot.plot(pen=line_pen, fillLevel=0)
|
||||
dot_glow = plot.plot(
|
||||
[],
|
||||
[],
|
||||
pen=None,
|
||||
symbol="o",
|
||||
symbolSize=8,
|
||||
symbolPen=None,
|
||||
symbolBrush=(0, 229, 255, 76),
|
||||
)
|
||||
dot_core = plot.plot(
|
||||
[],
|
||||
[],
|
||||
pen=None,
|
||||
symbol="o",
|
||||
symbolSize=4,
|
||||
symbolPen=None,
|
||||
symbolBrush=(255, 255, 255, 255),
|
||||
)
|
||||
plot.setXRange(0, self._plot_window_sec, padding=0)
|
||||
plot.enableAutoRange(x=False, y=False)
|
||||
|
||||
self.plots.append(plot)
|
||||
self.curves.append(curve)
|
||||
self.dot_glows.append(dot_glow)
|
||||
self.dot_cores.append(dot_core)
|
||||
if i < 2:
|
||||
self.widget.nextRow()
|
||||
|
||||
def reset(self) -> None:
|
||||
self._init_plot_buffer()
|
||||
for plot in self.plots:
|
||||
plot.setXRange(0, self._plot_window_sec, padding=0)
|
||||
|
||||
def update_frame(self, frame: FingerFrameView) -> None:
|
||||
if self._time_origin is None:
|
||||
self._time_origin = frame.timestamp
|
||||
|
||||
if self._plot_sample_count < self.npoints:
|
||||
idx = self._plot_sample_count
|
||||
self.data[idx, 0, :] = frame.timestamp
|
||||
self.data[idx, 1, 0] = frame.fnormal
|
||||
self.data[idx, 1, 1] = frame.fshearx
|
||||
self.data[idx, 1, 2] = frame.fsheary
|
||||
self._plot_sample_count += 1
|
||||
else:
|
||||
for channel in range(3):
|
||||
self.data[:-1, 0, channel] = self.data[1:, 0, channel]
|
||||
self.data[:-1, 1, channel] = self.data[1:, 1, channel]
|
||||
self.data[-1, 0, :] = frame.timestamp
|
||||
self.data[-1, 1, 0] = frame.fnormal
|
||||
self.data[-1, 1, 1] = frame.fshearx
|
||||
self.data[-1, 1, 2] = frame.fsheary
|
||||
|
||||
n = self._plot_sample_count
|
||||
if n == 0 or self._time_origin is None:
|
||||
return
|
||||
|
||||
layout = self._layout
|
||||
data_plot = self.data[:n]
|
||||
times = data_plot[:, 0, 0] - self._time_origin
|
||||
x_end = float(times[-1])
|
||||
if x_end < self._plot_window_sec:
|
||||
x_start = 0.0
|
||||
x_end = self._plot_window_sec
|
||||
else:
|
||||
x_start = x_end - self._plot_window_sec
|
||||
|
||||
for channel in range(3):
|
||||
y = lowpass_filter(
|
||||
data_plot[:, 1, channel].copy(),
|
||||
self.lowpass_cutoff_hz,
|
||||
sample_rate_hz=self.sample_rate_hz,
|
||||
)
|
||||
|
||||
curve = self.curves[channel]
|
||||
curve.setData(times, y)
|
||||
finite = np.isfinite(y)
|
||||
if not np.any(finite):
|
||||
continue
|
||||
ymin, ymax = float(np.min(y[finite])), float(np.max(y[finite]))
|
||||
if self._y_range_smooth[channel] is None:
|
||||
self._y_range_smooth[channel] = (ymin, ymax)
|
||||
else:
|
||||
prev_min, prev_max = self._y_range_smooth[channel]
|
||||
alpha = self._y_range_alpha
|
||||
ymin = alpha * ymin + (1 - alpha) * prev_min
|
||||
ymax = alpha * ymax + (1 - alpha) * prev_max
|
||||
span = ymax - ymin
|
||||
if span < layout.y_axis_min_span:
|
||||
mid = (ymin + ymax) / 2
|
||||
half = layout.y_axis_min_span / 2
|
||||
ymin = mid - half
|
||||
ymax = mid + half
|
||||
self._y_range_smooth[channel] = (ymin, ymax)
|
||||
pad = max((ymax - ymin) * 0.12, 0.01)
|
||||
self.plots[channel].setYRange(ymin - pad, ymax + pad)
|
||||
self.dot_glows[channel].setData(times[-1:], y[-1:])
|
||||
self.dot_cores[channel].setData(times[-1:], y[-1:])
|
||||
|
||||
for plot in self.plots:
|
||||
plot.setXRange(x_start, x_end, padding=0)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Square 1:1 image panel host for dashboard viewports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QPixmap
|
||||
from PyQt5.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from gui.widgets.qt_pixmap import pixmap_keep_aspect
|
||||
|
||||
|
||||
class ImagePanel(QWidget):
|
||||
"""Fixed square widget that displays an image at 1:1 aspect ratio."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
title: str = "",
|
||||
object_name: str = "panelLabel",
|
||||
default_side: int = 256,
|
||||
min_side: int = 64,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._side = default_side
|
||||
self._min_side = min_side
|
||||
self._source_pixmap: QPixmap | None = None
|
||||
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.label = QLabel(title)
|
||||
self.label.setObjectName(object_name)
|
||||
self.label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(self.label)
|
||||
self.set_side_length(self._side)
|
||||
|
||||
def set_side_length(self, side: int) -> None:
|
||||
self.set_fixed_size(side, side)
|
||||
|
||||
def set_fixed_size(self, width: int, height: int) -> None:
|
||||
width = max(int(width), self._min_side)
|
||||
height = max(int(height), self._min_side)
|
||||
if width == self.width() and height == self.height() and width == self._side:
|
||||
return
|
||||
self._side = width
|
||||
self.setFixedSize(width, height)
|
||||
self.label.setFixedSize(width, height)
|
||||
self.on_side_length_changed(min(width, height))
|
||||
self._refresh_pixmap()
|
||||
|
||||
@property
|
||||
def side_length(self) -> int:
|
||||
return self._side
|
||||
|
||||
def on_side_length_changed(self, side: int) -> None:
|
||||
"""Hook for subclasses (e.g. resize an offscreen renderer)."""
|
||||
|
||||
def set_source_pixmap(self, pixmap: QPixmap | None) -> None:
|
||||
self._source_pixmap = pixmap
|
||||
self._refresh_pixmap()
|
||||
|
||||
def _refresh_pixmap(self) -> None:
|
||||
if self._source_pixmap is None or self._source_pixmap.isNull():
|
||||
return
|
||||
self.label.setPixmap(pixmap_keep_aspect(self._source_pixmap, self.label.size()))
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self._refresh_pixmap()
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Embedded offscreen 3D mesh viewport for the Qt dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import QEvent, Qt
|
||||
from PyQt5.QtGui import QMouseEvent
|
||||
|
||||
from pipeline.frame import FingerFrameView
|
||||
from viz.offscreen_mesh import OffscreenFingerMeshRenderer
|
||||
|
||||
from ..layout import DashboardLayout, LAYOUT
|
||||
from ..mesh_view_state import MeshViewState
|
||||
from ..mesh_render_worker import MeshRenderWorker
|
||||
from gui.widgets.qt_pixmap import ndarray_to_pixmap
|
||||
from .image_panel import ImagePanel
|
||||
|
||||
_VIEW_ROTATE_SENSITIVITY = 0.35
|
||||
|
||||
|
||||
class MeshViewportWidget(ImagePanel):
|
||||
"""Render the finger mesh offscreen in a square 1:1 viewport."""
|
||||
|
||||
def __init__(self, parent=None, *, layout: DashboardLayout = LAYOUT, mesh_state: MeshViewState | None = None):
|
||||
self._layout = layout
|
||||
self._mesh_state = mesh_state or MeshViewState.from_layout(layout)
|
||||
self._renderer: OffscreenFingerMeshRenderer | None = None
|
||||
self._mesh_worker: MeshRenderWorker | None = None
|
||||
self._mesh_map = "depth"
|
||||
self._threshold = 0.0
|
||||
self._enable_3d = True
|
||||
self._free_rotation = False
|
||||
self._dragging = False
|
||||
self._last_drag_pos = None
|
||||
self._last_frame: FingerFrameView | None = None
|
||||
display_w, display_h = layout.mesh_display_size()
|
||||
super().__init__(
|
||||
title=layout.mesh_panel_title,
|
||||
object_name="meshViewport",
|
||||
default_side=min(display_w, display_h),
|
||||
parent=parent,
|
||||
)
|
||||
self.label.installEventFilter(self)
|
||||
self.label.setMouseTracking(True)
|
||||
self._mesh_state.flow_arrows_changed.connect(self._on_flow_arrows_changed)
|
||||
|
||||
def set_free_rotation(self, enabled: bool) -> None:
|
||||
self._free_rotation = bool(enabled)
|
||||
if not self._free_rotation:
|
||||
self._dragging = False
|
||||
self._last_drag_pos = None
|
||||
self.label.setCursor(Qt.OpenHandCursor if self._free_rotation else Qt.ArrowCursor)
|
||||
|
||||
def _on_flow_arrows_changed(self, enabled: bool) -> None:
|
||||
if self._renderer is not None:
|
||||
self._renderer.flow_arrow.set_enabled(enabled)
|
||||
self._refresh_last_frame()
|
||||
|
||||
def reset_view(self) -> None:
|
||||
if self._renderer is not None:
|
||||
self._renderer.reset_view()
|
||||
self._refresh_last_frame()
|
||||
|
||||
def eventFilter(self, watched, event):
|
||||
if watched is self.label and self._free_rotation and self._renderer is not None:
|
||||
etype = event.type()
|
||||
if etype == QEvent.MouseButtonPress and isinstance(event, QMouseEvent):
|
||||
if event.button() == Qt.LeftButton:
|
||||
self._dragging = True
|
||||
self._last_drag_pos = event.pos()
|
||||
self.label.setCursor(Qt.ClosedHandCursor)
|
||||
return True
|
||||
elif etype == QEvent.MouseMove and isinstance(event, QMouseEvent):
|
||||
if self._dragging and self._last_drag_pos is not None:
|
||||
delta = event.pos() - self._last_drag_pos
|
||||
self._last_drag_pos = event.pos()
|
||||
self._renderer.rotate_view(
|
||||
delta.x() * _VIEW_ROTATE_SENSITIVITY,
|
||||
delta.y() * _VIEW_ROTATE_SENSITIVITY,
|
||||
)
|
||||
self._refresh_last_frame()
|
||||
return True
|
||||
elif etype == QEvent.MouseButtonRelease and isinstance(event, QMouseEvent):
|
||||
if event.button() == Qt.LeftButton and self._dragging:
|
||||
self._dragging = False
|
||||
self._last_drag_pos = None
|
||||
self.label.setCursor(Qt.OpenHandCursor)
|
||||
return True
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def configure(self, pipeline) -> None:
|
||||
self._mesh_map = pipeline.config.mesh_map
|
||||
self._threshold = pipeline.config.flow_threshold
|
||||
self._enable_3d = pipeline.config.enable_3d and pipeline.uv_mesh is not None
|
||||
self._stop_worker()
|
||||
if self._renderer is not None:
|
||||
self._renderer.close()
|
||||
self._renderer = None
|
||||
if not self._enable_3d:
|
||||
self.label.setText(
|
||||
"3D mesh disabled" if not pipeline.config.enable_3d else "3D mesh unavailable"
|
||||
)
|
||||
self.set_source_pixmap(None)
|
||||
return
|
||||
self.label.setText("")
|
||||
self._init_renderer(pipeline)
|
||||
|
||||
def _init_renderer(self, pipeline) -> None:
|
||||
if not self._enable_3d or pipeline.uv_mesh is None:
|
||||
return
|
||||
render_w, render_h = self._layout.mesh_render_size()
|
||||
self._renderer = OffscreenFingerMeshRenderer(
|
||||
pipeline.uv_mesh,
|
||||
width=render_w,
|
||||
height=render_h,
|
||||
threshold=self._threshold,
|
||||
color_mode=self._mesh_map,
|
||||
world_to_cam=pipeline.world_to_cam,
|
||||
lookat=pipeline.mesh_lookat,
|
||||
mesh_lighting=self._layout.mesh_lighting,
|
||||
mesh_background=self._layout.mesh_background.color,
|
||||
contact_arrow_style=self._layout.mesh_contact_arrow,
|
||||
flow_arrow_style=self._layout.mesh_flow_arrow,
|
||||
axis_overlay=self._layout.mesh_axis_overlay,
|
||||
)
|
||||
self._renderer.flow_arrow.set_enabled(self._mesh_state.flow_arrows_enabled)
|
||||
|
||||
def _stop_worker(self) -> None:
|
||||
if self._mesh_worker is not None:
|
||||
self._mesh_worker.stop_worker()
|
||||
self._mesh_worker = None
|
||||
|
||||
def on_side_length_changed(self, side: int) -> None:
|
||||
"""Display widget resized; offscreen render size stays on layout.mesh_render_*."""
|
||||
del side # display size only; ImagePanel upscales the pixmap to fit the label
|
||||
|
||||
def _on_rgb_ready(self, rgb) -> None:
|
||||
self.set_source_pixmap(ndarray_to_pixmap(rgb, bgr=False))
|
||||
|
||||
def _refresh_last_frame(self) -> None:
|
||||
if self._last_frame is None or self._renderer is None:
|
||||
return
|
||||
frame = self._last_frame
|
||||
rgb = self._renderer.render(
|
||||
flow=frame.flow,
|
||||
scalar_map=frame.depth_map,
|
||||
centroid=frame.centroid,
|
||||
centroid_found=frame.centroid_found,
|
||||
fnormal=frame.fnormal,
|
||||
valid_mask=frame.valid_mask,
|
||||
)
|
||||
self.set_source_pixmap(ndarray_to_pixmap(rgb, bgr=False))
|
||||
|
||||
def update_frame(self, frame: FingerFrameView, *, async_render: bool | None = None) -> float:
|
||||
"""Update mesh colors on the same thread that owns the renderer."""
|
||||
if not self._enable_3d or self._renderer is None:
|
||||
return 0.0
|
||||
|
||||
self._last_frame = frame
|
||||
del async_render # Offscreen Open3D renderers are not safe to drive from a QThread.
|
||||
|
||||
import time
|
||||
|
||||
t0 = time.perf_counter()
|
||||
rgb = self._renderer.render(
|
||||
flow=frame.flow,
|
||||
scalar_map=frame.depth_map,
|
||||
centroid=frame.centroid,
|
||||
centroid_found=frame.centroid_found,
|
||||
fnormal=frame.fnormal,
|
||||
valid_mask=frame.valid_mask,
|
||||
)
|
||||
self.set_source_pixmap(ndarray_to_pixmap(rgb, bgr=False))
|
||||
return (time.perf_counter() - t0) * 1000.0
|
||||
|
||||
def close_renderer(self) -> None:
|
||||
self._stop_worker()
|
||||
if self._renderer is not None:
|
||||
self._renderer.close()
|
||||
self._renderer = None
|
||||
self._last_frame = None
|
||||
@@ -0,0 +1,474 @@
|
||||
"""Settings side panel for the finger dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import QSize, Qt, pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
|
||||
from viz.render_2d import ArrowBackground
|
||||
|
||||
from ..layout import DashboardLayout, LAYOUT
|
||||
from ..mesh_view_state import MeshViewState
|
||||
from ..transport_icons import reset_icon, transport_icon
|
||||
|
||||
_PLAY_PAUSE_BUTTON_NAME = "btnPlayPause"
|
||||
_PLAY_PAUSE_ICON_PX = 18
|
||||
_PLAY_PAUSE_ICON_COLORS = {
|
||||
"run": "#4caf84",
|
||||
"starting": "#555555",
|
||||
"pause": "#ff9800",
|
||||
}
|
||||
|
||||
|
||||
class SettingsPanelWidget(QWidget):
|
||||
"""Right-side settings: log, sources, and 3D view controls."""
|
||||
|
||||
log_visibility_changed = pyqtSignal(bool)
|
||||
free_rotation_changed = pyqtSignal(bool)
|
||||
arrow_background_changed = pyqtSignal(str)
|
||||
view_reset_requested = pyqtSignal()
|
||||
browse_video_requested = pyqtSignal()
|
||||
browse_config_requested = pyqtSignal()
|
||||
browse_finger_assets_requested = pyqtSignal()
|
||||
browse_csv_dir_requested = pyqtSignal()
|
||||
csv_record_toggled = pyqtSignal(bool)
|
||||
camera_refresh_requested = pyqtSignal()
|
||||
camera_selection_changed = pyqtSignal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
*,
|
||||
layout: DashboardLayout = LAYOUT,
|
||||
mesh_state: MeshViewState | None = None,
|
||||
show_log: bool = True,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._layout = layout
|
||||
self._mesh_state = mesh_state or MeshViewState.from_layout(layout)
|
||||
self.setObjectName("settingsPanel")
|
||||
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(8, 8, 8, 8)
|
||||
root.setSpacing(6)
|
||||
|
||||
title = QLabel(layout.settings_panel_title)
|
||||
title.setObjectName("settingsTitle")
|
||||
root.addWidget(title)
|
||||
|
||||
control_row = QHBoxLayout()
|
||||
control_row.setSpacing(8)
|
||||
|
||||
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))
|
||||
control_row.addWidget(self.btn_play_pause)
|
||||
|
||||
self.btn_reset_tracker = QPushButton("Reset Sensor")
|
||||
self.btn_reset_tracker.setToolTip("Reset tracker")
|
||||
self.btn_reset_tracker.setEnabled(False)
|
||||
control_row.addWidget(self.btn_reset_tracker, 1)
|
||||
root.addLayout(control_row)
|
||||
|
||||
self.show_log_check = QCheckBox("Show log panel")
|
||||
self.show_log_check.setChecked(show_log)
|
||||
self.show_log_check.toggled.connect(self.log_visibility_changed.emit)
|
||||
root.addWidget(self.show_log_check)
|
||||
|
||||
self.show_flow_arrows_check = QCheckBox("Show displacement arrows")
|
||||
self.show_flow_arrows_check.setChecked(self._mesh_state.flow_arrows_enabled)
|
||||
self.show_flow_arrows_check.toggled.connect(self._mesh_state.set_flow_arrows_enabled)
|
||||
root.addWidget(self.show_flow_arrows_check)
|
||||
|
||||
root.addWidget(self._caption("UV arrow background"))
|
||||
self.arrow_background_combo = QComboBox()
|
||||
for label, value in (
|
||||
("Unrolled", "unrolled"),
|
||||
("Pure color", "pure_color"),
|
||||
):
|
||||
self.arrow_background_combo.addItem(label, value)
|
||||
self.set_arrow_background(layout.arrow_background)
|
||||
self.arrow_background_combo.currentIndexChanged.connect(self._emit_arrow_background_changed)
|
||||
root.addWidget(self.arrow_background_combo)
|
||||
|
||||
root.addWidget(self._caption("Video source"))
|
||||
video_row = QHBoxLayout()
|
||||
video_row.setSpacing(6)
|
||||
self.camera_combo = QComboBox()
|
||||
self.camera_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.camera_combo.setToolTip("Select an available camera")
|
||||
self.camera_combo.currentIndexChanged.connect(self._on_camera_combo_changed)
|
||||
video_row.addWidget(self.camera_combo, 1)
|
||||
self.btn_refresh_cameras = QPushButton("Refresh")
|
||||
self.btn_refresh_cameras.setFixedWidth(64)
|
||||
self.btn_refresh_cameras.setToolTip("Re-scan available cameras")
|
||||
self.btn_refresh_cameras.clicked.connect(self.camera_refresh_requested.emit)
|
||||
video_row.addWidget(self.btn_refresh_cameras)
|
||||
self.btn_browse_video = QPushButton("…")
|
||||
self.btn_browse_video.setFixedWidth(32)
|
||||
self.btn_browse_video.setToolTip("Browse video file")
|
||||
self.btn_browse_video.clicked.connect(self.browse_video_requested.emit)
|
||||
video_row.addWidget(self.btn_browse_video)
|
||||
root.addLayout(video_row)
|
||||
|
||||
self.video_file_label = QLabel("")
|
||||
self.video_file_label.setObjectName("settingsCaption")
|
||||
self.video_file_label.setWordWrap(True)
|
||||
self.video_file_label.hide()
|
||||
root.addWidget(self.video_file_label)
|
||||
|
||||
self._source_mode = "camera" # "camera" | "file"
|
||||
self._video_file_path = ""
|
||||
self._updating_camera_options = False
|
||||
|
||||
root.addWidget(self._caption("Config"))
|
||||
config_row = QHBoxLayout()
|
||||
config_row.setSpacing(6)
|
||||
self.config_edit = QLineEdit("./config/dd02-ov.json")
|
||||
config_row.addWidget(self.config_edit, 1)
|
||||
self.btn_browse_config = QPushButton("…")
|
||||
self.btn_browse_config.setFixedWidth(32)
|
||||
self.btn_browse_config.setToolTip("Browse config JSON")
|
||||
self.btn_browse_config.clicked.connect(self.browse_config_requested.emit)
|
||||
config_row.addWidget(self.btn_browse_config)
|
||||
root.addLayout(config_row)
|
||||
|
||||
# Optional calibration path is kept for future re-enablement but hidden for now.
|
||||
self.cal_edit = QLineEdit("")
|
||||
self.cal_edit.setPlaceholderText("Optional calibration path")
|
||||
self.cal_edit.hide()
|
||||
|
||||
root.addWidget(self._caption("Finger assets"))
|
||||
finger_assets_row = QHBoxLayout()
|
||||
finger_assets_row.setSpacing(6)
|
||||
self.finger_assets_edit = QLineEdit(DEFAULT_FINGER_EXPORT_DIR)
|
||||
self.finger_assets_edit.setPlaceholderText("Blender export bundle directory")
|
||||
finger_assets_row.addWidget(self.finger_assets_edit, 1)
|
||||
self.btn_browse_finger_assets = QPushButton("…")
|
||||
self.btn_browse_finger_assets.setFixedWidth(32)
|
||||
self.btn_browse_finger_assets.setToolTip("Browse finger asset directory")
|
||||
self.btn_browse_finger_assets.clicked.connect(self.browse_finger_assets_requested.emit)
|
||||
finger_assets_row.addWidget(self.btn_browse_finger_assets)
|
||||
root.addLayout(finger_assets_row)
|
||||
|
||||
root.addWidget(self._caption("CSV save directory"))
|
||||
csv_dir_row = QHBoxLayout()
|
||||
csv_dir_row.setSpacing(6)
|
||||
self.csv_dir_edit = QLineEdit("./outputs/csv")
|
||||
self.csv_dir_edit.setPlaceholderText("Directory for CSV recordings")
|
||||
csv_dir_row.addWidget(self.csv_dir_edit, 1)
|
||||
self.btn_browse_csv_dir = QPushButton("…")
|
||||
self.btn_browse_csv_dir.setFixedWidth(32)
|
||||
self.btn_browse_csv_dir.setToolTip("Browse CSV save directory")
|
||||
self.btn_browse_csv_dir.clicked.connect(self.browse_csv_dir_requested.emit)
|
||||
csv_dir_row.addWidget(self.btn_browse_csv_dir)
|
||||
root.addLayout(csv_dir_row)
|
||||
|
||||
self.btn_csv_record = QPushButton("Record CSV")
|
||||
self.btn_csv_record.setToolTip("Start writing contact/force CSV")
|
||||
self.btn_csv_record.setEnabled(False)
|
||||
self.btn_csv_record.clicked.connect(self._on_csv_toggle_clicked)
|
||||
root.addWidget(self.btn_csv_record)
|
||||
self._csv_recording = False
|
||||
self._pipeline_running = False
|
||||
|
||||
root.addSpacing(4)
|
||||
rotate_row = QHBoxLayout()
|
||||
rotate_row.setSpacing(6)
|
||||
self.free_rotate_check = QCheckBox("Free rotate 3D view")
|
||||
self.free_rotate_check.toggled.connect(self.free_rotation_changed.emit)
|
||||
rotate_row.addWidget(self.free_rotate_check, 1)
|
||||
|
||||
self.btn_reset_view = QPushButton()
|
||||
self.btn_reset_view.setObjectName("btnResetView")
|
||||
self.btn_reset_view.setToolTip("Reset 3D mesh camera")
|
||||
self.btn_reset_view.setIcon(reset_icon("#999999"))
|
||||
self.btn_reset_view.setIconSize(QSize(14, 14))
|
||||
self.btn_reset_view.setFixedSize(28, 28)
|
||||
self.btn_reset_view.clicked.connect(self.view_reset_requested.emit)
|
||||
rotate_row.addWidget(self.btn_reset_view)
|
||||
root.addLayout(rotate_row)
|
||||
|
||||
root.addStretch()
|
||||
|
||||
@staticmethod
|
||||
def _caption(text: str) -> QLabel:
|
||||
label = QLabel(text)
|
||||
label.setObjectName("settingsCaption")
|
||||
return label
|
||||
|
||||
def set_panel_size(self, width: int, height: int) -> None:
|
||||
self.setFixedSize(max(int(width), 64), max(int(height), 64))
|
||||
|
||||
def set_play_pause_state(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 set_camera_options(self, cameras, selected_value: str | None = None) -> None:
|
||||
"""Populate camera combo from discovered cameras.
|
||||
|
||||
``cameras`` items may be objects with ``.source`` / ``.label()`` or
|
||||
``(source, label)`` tuples.
|
||||
"""
|
||||
self._updating_camera_options = True
|
||||
self.camera_combo.blockSignals(True)
|
||||
self.camera_combo.clear()
|
||||
|
||||
sources: list[str] = []
|
||||
for cam in cameras:
|
||||
if hasattr(cam, "source") and callable(getattr(cam, "label", None)):
|
||||
source = str(cam.source)
|
||||
label = str(cam.label())
|
||||
else:
|
||||
source, label = cam
|
||||
source = str(source)
|
||||
label = str(label)
|
||||
self.camera_combo.addItem(label, source)
|
||||
sources.append(source)
|
||||
|
||||
preferred = (selected_value or "").strip()
|
||||
idx = self.camera_combo.findData(preferred) if preferred else -1
|
||||
if idx < 0 and self.camera_combo.count() > 0:
|
||||
idx = 0
|
||||
if idx >= 0:
|
||||
self.camera_combo.setCurrentIndex(idx)
|
||||
# File mode is cleared only when we intentionally select a camera.
|
||||
if self._source_mode != "file":
|
||||
self._source_mode = "camera"
|
||||
self._video_file_path = ""
|
||||
self.video_file_label.hide()
|
||||
self.camera_combo.setEnabled(self.camera_combo.count() > 0)
|
||||
self.camera_combo.blockSignals(False)
|
||||
self._updating_camera_options = False
|
||||
|
||||
def set_video_source(self, value: str) -> None:
|
||||
"""Set current video source (camera index string or file path)."""
|
||||
value = (value or "").strip()
|
||||
if not value:
|
||||
return
|
||||
# Numeric camera index → select in combo if present; else keep as preferred text via adding.
|
||||
if value.isdigit() or (value.startswith("-") and value[1:].isdigit()):
|
||||
idx = self.camera_combo.findData(value)
|
||||
if idx >= 0:
|
||||
self._source_mode = "camera"
|
||||
self._video_file_path = ""
|
||||
self.video_file_label.hide()
|
||||
self.camera_combo.blockSignals(True)
|
||||
self.camera_combo.setCurrentIndex(idx)
|
||||
self.camera_combo.blockSignals(False)
|
||||
else:
|
||||
# Not yet scanned / not present: remember as pending camera selection.
|
||||
self._source_mode = "camera"
|
||||
self._video_file_path = ""
|
||||
self.video_file_label.hide()
|
||||
return
|
||||
|
||||
# Treat as video file path.
|
||||
self._source_mode = "file"
|
||||
self._video_file_path = value
|
||||
self.video_file_label.setText(f"File: {value}")
|
||||
self.video_file_label.show()
|
||||
|
||||
def selected_camera_source(self) -> str:
|
||||
data = self.camera_combo.currentData()
|
||||
return str(data) if data is not None else ""
|
||||
|
||||
def video_source(self) -> str:
|
||||
if self._source_mode == "file" and self._video_file_path:
|
||||
return self._video_file_path
|
||||
return self.selected_camera_source()
|
||||
|
||||
def has_usable_source(self) -> bool:
|
||||
if self._source_mode == "file" and self._video_file_path:
|
||||
return True
|
||||
return bool(self.selected_camera_source())
|
||||
|
||||
def is_file_source(self) -> bool:
|
||||
return self._source_mode == "file" and bool(self._video_file_path)
|
||||
|
||||
def set_camera_controls_enabled(self, enabled: bool) -> None:
|
||||
self.btn_refresh_cameras.setEnabled(enabled)
|
||||
self.btn_browse_video.setEnabled(enabled)
|
||||
self.camera_combo.setEnabled(enabled and self.camera_combo.count() > 0)
|
||||
|
||||
def _on_camera_combo_changed(self, _index: int) -> None:
|
||||
if self._updating_camera_options:
|
||||
return
|
||||
source = self.selected_camera_source()
|
||||
if not source:
|
||||
return
|
||||
self._source_mode = "camera"
|
||||
self._video_file_path = ""
|
||||
self.video_file_label.hide()
|
||||
self.camera_selection_changed.emit(source)
|
||||
|
||||
def set_config_path(self, value: str) -> None:
|
||||
self.config_edit.setText(value)
|
||||
|
||||
def set_calibration_path(self, value: str) -> None:
|
||||
self.cal_edit.setText(value or "")
|
||||
|
||||
def set_finger_export_dir(self, value: str) -> None:
|
||||
self.finger_assets_edit.setText(value)
|
||||
|
||||
def set_finger_export_dir_enabled(self, enabled: bool) -> None:
|
||||
self.finger_assets_edit.setEnabled(enabled)
|
||||
self.btn_browse_finger_assets.setEnabled(enabled)
|
||||
|
||||
def set_csv_save_dir(self, value: str) -> None:
|
||||
self.csv_dir_edit.setText(value)
|
||||
|
||||
def set_csv_controls_for_pipeline(self, running: bool) -> None:
|
||||
"""Update CSV controls from pipeline start/stop."""
|
||||
self._pipeline_running = bool(running)
|
||||
if not self._pipeline_running:
|
||||
self._csv_recording = False
|
||||
self._refresh_csv_controls()
|
||||
|
||||
def set_csv_recording(self, recording: bool) -> None:
|
||||
"""Update CSV controls after a successful start/stop of recording."""
|
||||
self._csv_recording = bool(recording) and self._pipeline_running
|
||||
self._refresh_csv_controls()
|
||||
|
||||
def _refresh_csv_controls(self) -> None:
|
||||
self.btn_csv_record.setEnabled(self._pipeline_running)
|
||||
if self._csv_recording:
|
||||
self.btn_csv_record.setText("Stop CSV")
|
||||
self.btn_csv_record.setToolTip("Stop CSV recording")
|
||||
self.btn_csv_record.setProperty("recording", True)
|
||||
else:
|
||||
self.btn_csv_record.setText("Record CSV")
|
||||
self.btn_csv_record.setToolTip("Start writing contact/force CSV")
|
||||
self.btn_csv_record.setProperty("recording", False)
|
||||
style = self.btn_csv_record.style()
|
||||
style.unpolish(self.btn_csv_record)
|
||||
style.polish(self.btn_csv_record)
|
||||
self.btn_csv_record.update()
|
||||
|
||||
dir_enabled = not self._csv_recording
|
||||
self.csv_dir_edit.setEnabled(dir_enabled)
|
||||
self.btn_browse_csv_dir.setEnabled(dir_enabled)
|
||||
|
||||
def _on_csv_toggle_clicked(self) -> None:
|
||||
if not self._pipeline_running:
|
||||
return
|
||||
self.csv_record_toggled.emit(not self._csv_recording)
|
||||
|
||||
def _emit_arrow_background_changed(self, _index: int) -> None:
|
||||
value = self.arrow_background_combo.currentData()
|
||||
if value:
|
||||
self.arrow_background_changed.emit(value)
|
||||
|
||||
def set_arrow_background(self, value: ArrowBackground) -> None:
|
||||
idx = self.arrow_background_combo.findData(value)
|
||||
if idx < 0:
|
||||
return
|
||||
self.arrow_background_combo.blockSignals(True)
|
||||
self.arrow_background_combo.setCurrentIndex(idx)
|
||||
self.arrow_background_combo.blockSignals(False)
|
||||
|
||||
def arrow_background(self) -> ArrowBackground:
|
||||
value = self.arrow_background_combo.currentData()
|
||||
return value if value else "unrolled"
|
||||
|
||||
def config_path(self) -> str:
|
||||
return self.config_edit.text().strip()
|
||||
|
||||
def calibration_path(self) -> str:
|
||||
return self.cal_edit.text().strip()
|
||||
|
||||
def finger_export_dir(self) -> str:
|
||||
return self.finger_assets_edit.text().strip()
|
||||
|
||||
def csv_save_dir(self) -> str:
|
||||
return self.csv_dir_edit.text().strip()
|
||||
|
||||
def preference_state(self) -> dict:
|
||||
"""Serialize user-facing settings suitable for preference.json."""
|
||||
return {
|
||||
"video_source": self.video_source(),
|
||||
"config_path": self.config_path(),
|
||||
"finger_assets_dir": self.finger_export_dir(),
|
||||
"csv_save_dir": self.csv_save_dir(),
|
||||
"show_log": bool(self.show_log_check.isChecked()),
|
||||
"show_flow_arrows": bool(self.show_flow_arrows_check.isChecked()),
|
||||
"arrow_background": self.arrow_background(),
|
||||
"free_rotate": bool(self.free_rotate_check.isChecked()),
|
||||
}
|
||||
|
||||
def apply_preference_state(self, state: dict | None) -> None:
|
||||
"""Restore user-facing settings from preference.json."""
|
||||
if not isinstance(state, dict) or not state:
|
||||
return
|
||||
|
||||
show_log = state.get("show_log")
|
||||
if isinstance(show_log, bool):
|
||||
self.show_log_check.blockSignals(True)
|
||||
self.show_log_check.setChecked(show_log)
|
||||
self.show_log_check.blockSignals(False)
|
||||
|
||||
show_flow = state.get("show_flow_arrows")
|
||||
if isinstance(show_flow, bool):
|
||||
self.show_flow_arrows_check.blockSignals(True)
|
||||
self.show_flow_arrows_check.setChecked(show_flow)
|
||||
self.show_flow_arrows_check.blockSignals(False)
|
||||
self._mesh_state.set_flow_arrows_enabled(show_flow)
|
||||
|
||||
arrow_bg = state.get("arrow_background")
|
||||
if isinstance(arrow_bg, str) and arrow_bg:
|
||||
self.set_arrow_background(arrow_bg) # type: ignore[arg-type]
|
||||
|
||||
free_rotate = state.get("free_rotate")
|
||||
if isinstance(free_rotate, bool):
|
||||
self.free_rotate_check.blockSignals(True)
|
||||
self.free_rotate_check.setChecked(free_rotate)
|
||||
self.free_rotate_check.blockSignals(False)
|
||||
|
||||
config_path = state.get("config_path")
|
||||
if isinstance(config_path, str) and config_path.strip():
|
||||
self.set_config_path(config_path.strip())
|
||||
|
||||
assets = state.get("finger_assets_dir")
|
||||
if isinstance(assets, str) and assets.strip():
|
||||
self.set_finger_export_dir(assets.strip())
|
||||
|
||||
csv_dir = state.get("csv_save_dir")
|
||||
if isinstance(csv_dir, str) and csv_dir.strip():
|
||||
self.set_csv_save_dir(csv_dir.strip())
|
||||
|
||||
video = state.get("video_source")
|
||||
if isinstance(video, str) and video.strip():
|
||||
self.set_video_source(video.strip())
|
||||
Reference in New Issue
Block a user