update exe GUI build

This commit is contained in:
ykcheungab
2026-06-21 12:01:34 +08:00
parent 258948bcb7
commit 7feae9550e
18 changed files with 862 additions and 173 deletions
+73 -103
View File
@@ -4,17 +4,13 @@ from __future__ import annotations
import sys
import time
from pathlib import Path
from PyQt5.QtCore import QSize, Qt, QTimer
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import (
QApplication,
QFileDialog,
QHBoxLayout,
QLineEdit,
QMainWindow,
QPushButton,
QSplitter,
QVBoxLayout,
QWidget,
@@ -23,6 +19,7 @@ from PyQt5.QtWidgets import (
from pipeline.config import PipelineConfig
from pipeline.service import FingerPipeline
from app_paths import resource_path
from .layout import (
DashboardLayout,
LAYOUT,
@@ -30,27 +27,19 @@ from .layout import (
PanelSetup,
top_row_splitter_sizes,
)
from .mesh_view_state import MeshViewState
from .styles import STYLE
from .transport_icons import transport_icon
from .widgets import (
ArrowPanelWidget,
ForceChartWidget,
MeshViewportWidget,
SettingsPanelWidget,
TeeStream,
WidgetLogHandler,
make_log_widget,
)
_PLAY_PAUSE_BUTTON_NAME = "btnPlayPause"
_PLAY_PAUSE_ICON_PX = 18
_PLAY_PAUSE_ICON_COLORS = {
"run": "#4caf84",
"starting": "#555555",
"pause": "#ff9800",
}
class FingerDashboardWindow(QMainWindow):
"""Single-window finger demo: 3D mesh | UV arrows | force charts."""
@@ -73,7 +62,7 @@ class FingerDashboardWindow(QMainWindow):
self.setWindowTitle(layout.window_title)
self.setStyleSheet(STYLE)
logo_path = Path(__file__).resolve().parents[2] / "logo.png"
logo_path = resource_path("logo.png")
if logo_path.is_file():
self.setWindowIcon(QIcon(str(logo_path)))
@@ -83,10 +72,6 @@ class FingerDashboardWindow(QMainWindow):
self.timer = QTimer()
self.timer.timeout.connect(self._on_timer)
self.camera_edit.setText(str(config.video))
self.config_edit.setText(config.config)
self.cal_edit.setText(config.calib or "")
def _setup_ui(self) -> None:
layout = self.layout
central = QWidget()
@@ -95,42 +80,10 @@ class FingerDashboardWindow(QMainWindow):
root_layout.setContentsMargins(layout.root_margin_px, layout.root_margin_px, layout.root_margin_px, layout.root_margin_px)
root_layout.setSpacing(layout.root_spacing_px)
bar = QHBoxLayout()
bar.setSpacing(layout.toolbar_spacing_px)
self.btn_camera = QPushButton("Sensor")
self.btn_camera.clicked.connect(self._select_camera)
bar.addWidget(self.btn_camera)
self.camera_edit = QLineEdit("0")
self.camera_edit.setPlaceholderText("Camera ID or Video Path")
bar.addWidget(self.camera_edit, 1)
self.btn_config = QPushButton("Config")
self.btn_config.clicked.connect(self._select_config)
bar.addWidget(self.btn_config)
self.config_edit = QLineEdit("./config/dd02-ov.json")
bar.addWidget(self.config_edit, 1)
self.cal_edit = QLineEdit("")
bar.addWidget(self.cal_edit, 1)
self.btn_reset = QPushButton("Reset")
self.btn_reset.setEnabled(False)
self.btn_reset.clicked.connect(self._on_reset)
bar.addWidget(self.btn_reset)
self.btn_play_pause = QPushButton()
self.btn_play_pause.setObjectName(_PLAY_PAUSE_BUTTON_NAME)
self.btn_play_pause.setFixedSize(36, 32)
self.btn_play_pause.setIconSize(QSize(_PLAY_PAUSE_ICON_PX, _PLAY_PAUSE_ICON_PX))
self.btn_play_pause.clicked.connect(self._on_play_pause_clicked)
self._set_play_pause_button("run")
bar.addWidget(self.btn_play_pause)
root_layout.addLayout(bar)
setup = layout.panel_setup
self.mesh_viewport = MeshViewportWidget(layout=layout)
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:
@@ -141,53 +94,91 @@ class FingerDashboardWindow(QMainWindow):
)
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,
)
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.log_visibility_changed.connect(self._set_log_panel_visible)
self.settings_panel.free_rotation_changed.connect(self.mesh_viewport.set_free_rotation)
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.browse_config_requested.connect(self._select_config)
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._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
# Top row: mesh | arrows | charts (order from layout.PANEL_SETUP.top_row)
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
h_splitter.addWidget(self._panel_widgets[slot.id])
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
# Vertical: top panel | log panel
v_splitter = QSplitter(Qt.Vertical)
v_splitter.addWidget(h_splitter)
if setup.log_panel.enabled:
v_splitter.addWidget(self.log_widget)
v_splitter.setStretchFactor(0, setup.top_stretch)
v_splitter.setStretchFactor(1, setup.log_stretch)
else:
v_splitter.setStretchFactor(0, 1)
self.v_splitter = v_splitter
root_layout.addWidget(v_splitter)
root_layout.addWidget(h_splitter)
self.resize(layout.window_width, layout.window_height)
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 _apply_panel_layout(self) -> None:
setup = self.layout.panel_setup
column_height = self.layout.charts_column_height_px()
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(slot.width_px, slot.height_px)
elif slot.id == "charts":
self.force_charts.set_panel_size(slot.width_px, slot.height_px)
self._panel_widgets[slot.id].set_fixed_size(slot.width_px, column_height)
elif slot.id == "settings":
settings_height = slot.height_px if slot.height_px > 0 else column_height
self.settings_panel.set_panel_size(slot.width_px, settings_height)
if setup.log_panel.enabled and setup.log_panel.height_px > 0:
self.log_widget.setFixedHeight(setup.log_panel.height_px)
if (
self._log_panel_visible
and log_slot.enabled
and log_slot.height_px > 0
):
self.log_widget.show()
self.log_widget.setFixedHeight(log_slot.height_px)
self.force_charts.set_panel_size(charts_slot.width_px, charts_slot.height_px)
self.charts_column.setSizes([charts_slot.height_px, log_slot.height_px])
else:
self.log_widget.hide()
self.force_charts.set_panel_size(charts_slot.width_px, column_height)
self.charts_column.setSizes([column_height, 0])
self.h_splitter.setSizes(list(top_row_splitter_sizes(self.layout)))
@@ -209,28 +200,7 @@ class FingerDashboardWindow(QMainWindow):
sys.stderr = sys.stdout
def _set_play_pause_button(self, state: str) -> None:
"""``run`` = green play, ``starting`` = grey disabled play, ``pause`` = orange pause."""
btn = self.btn_play_pause
if state == "run":
btn.setToolTip("Run")
btn.setEnabled(True)
elif state == "starting":
btn.setToolTip("Starting…")
btn.setEnabled(False)
elif state == "pause":
btn.setToolTip("Pause")
btn.setEnabled(True)
else:
raise ValueError(f"unknown play/pause state: {state!r}")
kind = "pause" if state == "pause" else "play"
btn.setText("")
btn.setIcon(transport_icon(kind, _PLAY_PAUSE_ICON_COLORS[state]))
btn.setProperty("playState", state)
style = btn.style()
style.unpolish(btn)
style.polish(btn)
btn.update()
self.settings_panel.set_play_pause_state(state)
def _on_play_pause_clicked(self) -> None:
if self._running:
@@ -244,9 +214,9 @@ class FingerDashboardWindow(QMainWindow):
self._set_play_pause_button("starting")
QApplication.processEvents()
self.config.video = self.camera_edit.text().strip()
self.config.config = self.config_edit.text().strip()
cal = self.cal_edit.text().strip()
self.config.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
try:
@@ -266,7 +236,7 @@ class FingerDashboardWindow(QMainWindow):
self._wall_fps = 0.0
self.timer.start(self.layout.timer_interval_ms)
self._set_play_pause_button("pause")
self.btn_reset.setEnabled(True)
self.settings_panel.btn_reset_tracker.setEnabled(True)
print(
f"▶ 启动 — 摄像机={self.config.video}, 配置={self.config.config}, "
f"标定={self.config.calib or '(none)'}"
@@ -285,7 +255,7 @@ class FingerDashboardWindow(QMainWindow):
self.pipeline.close()
self._set_play_pause_button("run")
self.btn_reset.setEnabled(False)
self.settings_panel.btn_reset_tracker.setEnabled(False)
def _on_reset(self) -> None:
if not self._running:
@@ -371,7 +341,7 @@ class FingerDashboardWindow(QMainWindow):
"视频 (*.mp4 *.avi *.mov);;所有文件 (*)",
)
if path:
self.camera_edit.setText(path)
self.settings_panel.set_video_source(path)
def _select_config(self) -> None:
path, _ = QFileDialog.getOpenFileName(
@@ -381,7 +351,7 @@ class FingerDashboardWindow(QMainWindow):
"JSON (*.json);;所有文件 (*)",
)
if path:
self.config_edit.setText(path)
self.settings_panel.set_config_path(path)
def keyPressEvent(self, event):
key = event.key()
+29 -18
View File
@@ -3,15 +3,13 @@ Finger dashboard layout — edit this file to define the panel hierarchy and siz
Structure:
┌─ toolbar ─────────────────────────────────────────────┐
├─ TOP (stretch: top_stretch) ──────────────────────────┤
│ ┌ mesh ─┬─ arrows ─┬─ charts ─┐ (left → right) │
│ └───────┴───────────┴─────────┘ │
├─ LOG (stretch: log_stretch) ──────────────────────────┤
└───────────────────────────────────────────────────────┘
┌─ toolbar ─────────────────────────────────────────────────────┐
├─ mesh ─┬─ charts ─┬─ settings ────────────────────────────────┤
│ ├─ log ────┤ │
└────────┴──────────┴─────────────────────────────────────────┘
Each panel uses explicit width_px and height_px.
top_stretch / log_stretch control how extra vertical space is shared on resize.
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)
@@ -37,8 +35,7 @@ Performance (3D dashboard; edit DashboardLayout defaults):
mesh_async_render — background mesh render (Linux only; Windows uses sync)
Edit TOP_ROW.slots to reorder or resize panels; set ``enabled=False`` to hide a panel (e.g. arrows).
Edit PANEL_SETUP to change top vs log proportions.
Edit LOG_CONFIG for live status line and log panel appearance.
Log sits under charts only (not under mesh). Settings is the rightmost column.
"""
from __future__ import annotations
@@ -51,7 +48,7 @@ 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"]
PanelId = Literal["mesh", "arrows", "charts", "log", "settings"]
@dataclass(frozen=True)
@@ -76,20 +73,21 @@ class PanelRow:
@dataclass(frozen=True)
class PanelSetup:
"""Full panel hierarchy: top row + bottom log."""
"""Full panel hierarchy: top row + log nested under charts."""
top_row: PanelRow
log_panel: PanelSlot
top_stretch: int = 5
charts_log_stretch: int = 5
log_stretch: int = 1
# --- Top strip: mesh | arrows | charts (edit order and sizes here) ---
# --- Top strip: mesh | arrows? | charts | settings (edit order and sizes here) ---
TOP_ROW = PanelRow(
slots=(
PanelSlot("mesh", "3D mesh", width_px=500, height_px=500),
PanelSlot("mesh", "3D mesh", width_px=600, height_px=600),
PanelSlot("arrows", "UV Unrolled", width_px=500, height_px=500, enabled=False),
PanelSlot("charts", "Force charts", width_px=500, height_px=500),
PanelSlot("charts", "Force charts", width_px=400, height_px=500),
PanelSlot("settings", "Settings", width_px=200, height_px=0),
),
)
@@ -98,7 +96,7 @@ LOG_PANEL = PanelSlot("log", "Log", width_px=0, height_px=100)
PANEL_SETUP = PanelSetup(
top_row=TOP_ROW,
log_panel=LOG_PANEL,
top_stretch=5,
charts_log_stretch=5,
log_stretch=1,
)
@@ -225,8 +223,8 @@ class DashboardLayout:
panel_setup: PanelSetup = PANEL_SETUP
window_title: str = "Orisys Finger Dashboard"
window_width: int = 1000
window_height: int = 600
window_width: int = 1100
window_height: int = 620
root_margin_px: int = 6
root_spacing_px: int = 6
@@ -279,6 +277,15 @@ class DashboardLayout:
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:
@@ -293,6 +300,10 @@ class DashboardLayout:
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()
+32
View File
@@ -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)
+26
View File
@@ -16,6 +16,32 @@ QLabel#panelLabel, QLabel#meshViewport {
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;
+45 -1
View File
@@ -2,10 +2,11 @@
from __future__ import annotations
import math
from typing import Literal
from PyQt5.QtCore import QPointF, QRectF, Qt
from PyQt5.QtGui import QColor, QIcon, QPainter, QPixmap, QPolygonF
from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap, QPolygonF
TransportKind = Literal["play", "pause"]
@@ -48,3 +49,46 @@ def transport_icon(kind: TransportKind, color: str, *, size: int = _DEFAULT_ICON
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)
+2
View File
@@ -1,5 +1,6 @@
"""Qt widgets for the finger dashboard."""
from .settings_panel import SettingsPanelWidget
from .force_charts import ForceChartWidget
from .log_panel import TeeStream, WidgetLogHandler, make_log_widget
from .mesh_viewport import MeshViewportWidget
@@ -9,6 +10,7 @@ from .image_panel import ImagePanel
__all__ = [
"ArrowPanelWidget",
"SettingsPanelWidget",
"ForceChartWidget",
"MeshViewportWidget",
"ImagePanel",
+76 -1
View File
@@ -4,25 +4,36 @@ from __future__ import annotations
import sys
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 .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):
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,
@@ -30,6 +41,53 @@ class MeshViewportWidget(ImagePanel):
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
@@ -69,6 +127,7 @@ class MeshViewportWidget(ImagePanel):
contact_arrow_style=self._layout.mesh_contact_arrow,
flow_arrow_style=self._layout.mesh_flow_arrow,
)
self._renderer.flow_arrow.set_enabled(self._mesh_state.flow_arrows_enabled)
def _stop_worker(self) -> None:
if self._mesh_worker is not None:
@@ -82,11 +141,26 @@ class MeshViewportWidget(ImagePanel):
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. Returns render wall time (0 when async submit only)."""
if not self._enable_3d or self._renderer is None:
return 0.0
self._last_frame = frame
use_async = self._layout.mesh_async_render if async_render is None else async_render
if use_async and self._mesh_worker is not None:
self._mesh_worker.submit(
@@ -118,3 +192,4 @@ class MeshViewportWidget(ImagePanel):
if self._renderer is not None:
self._renderer.close()
self._renderer = None
self._last_frame = None
+184
View File
@@ -0,0 +1,184 @@
"""Settings side panel for the finger dashboard."""
from __future__ import annotations
from PyQt5.QtCore import QSize, Qt, pyqtSignal
from PyQt5.QtWidgets import (
QCheckBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
)
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)
view_reset_requested = pyqtSignal()
browse_video_requested = pyqtSignal()
browse_config_requested = pyqtSignal()
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(10, 10, 10, 10)
root.setSpacing(10)
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("Video source"))
video_row = QHBoxLayout()
video_row.setSpacing(6)
self.video_edit = QLineEdit("0")
self.video_edit.setPlaceholderText("Camera ID or video path")
video_row.addWidget(self.video_edit, 1)
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)
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)
self.cal_edit = QLineEdit("")
self.cal_edit.setPlaceholderText("Optional calibration path")
root.addWidget(self._caption("Calibration"))
root.addWidget(self.cal_edit)
root.addSpacing(8)
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_video_source(self, value: str) -> None:
self.video_edit.setText(value)
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 video_source(self) -> str:
return self.video_edit.text().strip()
def config_path(self) -> str:
return self.config_edit.text().strip()
def calibration_path(self) -> str:
return self.cal_edit.text().strip()