added dd06 test support, organized 2d viewer and 3d viewer code organization.

This commit is contained in:
ykcheungab
2026-07-26 17:03:44 +08:00
parent 28e6c7c8a9
commit 2e3d0a0609
32 changed files with 2326 additions and 936 deletions
+15
View File
@@ -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())