update exe GUI build
This commit is contained in:
+73
-103
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user