From 7feae9550e56ed00987a3628a8dfb94876e5da81 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Sun, 21 Jun 2026 12:01:34 +0800 Subject: [PATCH] update exe GUI build --- README.md | 3 +- examples/README.md | 50 +++++++ examples/app_paths.py | 44 ++++++ examples/finger_pipeline_3dview.py | 10 +- examples/gui/dashboard.py | 176 ++++++++++------------- examples/gui/layout.py | 47 ++++--- examples/gui/mesh_view_state.py | 32 +++++ examples/gui/styles.py | 26 ++++ examples/gui/transport_icons.py | 46 ++++++- examples/gui/widgets/__init__.py | 2 + examples/gui/widgets/mesh_viewport.py | 77 ++++++++++- examples/gui/widgets/settings_panel.py | 184 +++++++++++++++++++++++++ examples/viz/flow_arrow.py | 106 +++++++++++++- examples/viz/mesh_camera.py | 79 +++++++++++ examples/viz/offscreen_mesh.py | 140 ++++++++++++++----- pyproject.toml | 4 +- src/orisys/Stitcher.py | 4 +- src/orisys/finger/exports.py | 5 +- 18 files changed, 862 insertions(+), 173 deletions(-) create mode 100644 examples/app_paths.py create mode 100644 examples/gui/mesh_view_state.py create mode 100644 examples/gui/widgets/settings_panel.py diff --git a/README.md b/README.md index 5a76526..e21d31b 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,8 @@ cfg = orisys.export_config("ddjx01", "./config/exported_config.json") - Example entry points: `examples/README.md` - User documentation: `docs/get_started.md` -- Packaging helper: `build/build_nuitka.py` +- Packaging helper: `build/build_nuitka.py` (compiled SDK wheel) +- GUI standalone folder: `build/build_gui_win.bat` → `dist/OrisysDashboard/` - Specialized utilities: `tools/` ## Notes for Collaborators diff --git a/examples/README.md b/examples/README.md index 63ac050..13ff078 100644 --- a/examples/README.md +++ b/examples/README.md @@ -39,3 +39,53 @@ pip install -e ".[gui]" ## Notes - Dashboard: click **启动** to start acquisition; `Q` quit, `R` reset, `S`/`E` record, `Esc` fullscreen. + +## Windows standalone folder (CPU-only) + +Build a portable folder with bundled Python, packages, `config/`, and `assets/`: + +```bat +conda activate py310 +pip install -e ".[gui]" +pip install nuitka ordered-set zstandard + +build\build_gui_win.bat +``` + +Output: + +``` +dist/OrisysDashboard/ + OrisysDashboard.exe + config/ + assets/ + README.txt + (+ bundled DLLs and runtime files) +``` + +Run: + +```bat +cd dist\OrisysDashboard +OrisysDashboard.exe --video path\to\video.mp4 +``` + +After building, smoke-test bundled files and `--help`: + +```bat +python build\verify.py --gui-dist +``` + +Manual QA on a machine without Python: + +1. Launch `OrisysDashboard.exe` — dashboard window opens +2. Play a bundled or local video — 3D mesh and charts update +3. Confirm default config resolves to `config\dd02-ov.json` +4. Toggle settings (log, displacement arrows, thresholds) + +Notes: + +- One-folder layout is recommended (not a single-file exe) for Open3D + PyQt5 reliability +- CPU-only: CUDA/CuPy are excluded from the build +- Expect a large folder (Open3D, OpenCV, SciPy, Qt) +- Frozen path resolution lives in `examples/app_paths.py` diff --git a/examples/app_paths.py b/examples/app_paths.py new file mode 100644 index 0000000..3136362 --- /dev/null +++ b/examples/app_paths.py @@ -0,0 +1,44 @@ +"""Resolve bundled resource paths for dev runs and Nuitka standalone builds.""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +_EXAMPLES_DIR = Path(__file__).resolve().parent +_REPO_ROOT = _EXAMPLES_DIR.parent + + +def is_frozen() -> bool: + return getattr(sys, "frozen", False) + + +def app_root() -> Path: + """Directory containing the executable when frozen, else repository root.""" + if is_frozen(): + return Path(sys.executable).resolve().parent + return _REPO_ROOT + + +def resource_path(*parts: str) -> Path: + return app_root().joinpath(*parts) + + +def default_config_path() -> str: + return str(resource_path("config", "dd02-ov.json")) + + +def configure_frozen_runtime() -> None: + """Use the app folder as CWD so relative paths resolve next to the exe.""" + if is_frozen(): + os.chdir(app_root()) + + +def ensure_examples_import_path() -> None: + """Editable dev runs: keep examples/ on sys.path.""" + if is_frozen(): + return + examples = str(_EXAMPLES_DIR) + if examples not in sys.path: + sys.path.insert(0, examples) diff --git a/examples/finger_pipeline_3dview.py b/examples/finger_pipeline_3dview.py index f4786f4..0567d93 100644 --- a/examples/finger_pipeline_3dview.py +++ b/examples/finger_pipeline_3dview.py @@ -12,13 +12,13 @@ Where to customize the GUI: import argparse import os import sys -from pathlib import Path os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0") -_EXAMPLES_DIR = Path(__file__).resolve().parent -if str(_EXAMPLES_DIR) not in sys.path: - sys.path.insert(0, str(_EXAMPLES_DIR)) +from app_paths import configure_frozen_runtime, default_config_path, ensure_examples_import_path + +configure_frozen_runtime() +ensure_examples_import_path() from gui import run_dashboard from pipeline import PipelineConfig @@ -30,7 +30,7 @@ def _parse_args(): description="UV-unroll finger tactile pipeline with unified Qt dashboard" ) parser.add_argument("--video", "-v", default="0", help="Camera index or video file path") - parser.add_argument("--config", "-c", default="./config/dd02-ov.json", help="Orisys SDK config") + parser.add_argument("--config", "-c", default=default_config_path(), help="Orisys SDK config") parser.add_argument("--cal", default="", help="Optional calibration file path") parser.add_argument("--verbose", action="store_true", help="SDK verbose logs") parser.add_argument( diff --git a/examples/gui/dashboard.py b/examples/gui/dashboard.py index 3b22e57..c5b1ec4 100644 --- a/examples/gui/dashboard.py +++ b/examples/gui/dashboard.py @@ -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() diff --git a/examples/gui/layout.py b/examples/gui/layout.py index 7cec9a5..32e0eac 100644 --- a/examples/gui/layout.py +++ b/examples/gui/layout.py @@ -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() diff --git a/examples/gui/mesh_view_state.py b/examples/gui/mesh_view_state.py new file mode 100644 index 0000000..bfe4f04 --- /dev/null +++ b/examples/gui/mesh_view_state.py @@ -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) diff --git a/examples/gui/styles.py b/examples/gui/styles.py index c23ed3d..c226b3d 100644 --- a/examples/gui/styles.py +++ b/examples/gui/styles.py @@ -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; diff --git a/examples/gui/transport_icons.py b/examples/gui/transport_icons.py index 8e336f4..20cdafd 100644 --- a/examples/gui/transport_icons.py +++ b/examples/gui/transport_icons.py @@ -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) diff --git a/examples/gui/widgets/__init__.py b/examples/gui/widgets/__init__.py index 7bce673..6ab2fa6 100644 --- a/examples/gui/widgets/__init__.py +++ b/examples/gui/widgets/__init__.py @@ -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", diff --git a/examples/gui/widgets/mesh_viewport.py b/examples/gui/widgets/mesh_viewport.py index d881ff9..d862955 100644 --- a/examples/gui/widgets/mesh_viewport.py +++ b/examples/gui/widgets/mesh_viewport.py @@ -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 diff --git a/examples/gui/widgets/settings_panel.py b/examples/gui/widgets/settings_panel.py new file mode 100644 index 0000000..6e920ab --- /dev/null +++ b/examples/gui/widgets/settings_panel.py @@ -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() diff --git a/examples/viz/flow_arrow.py b/examples/viz/flow_arrow.py index 4ee41dd..e31ee29 100644 --- a/examples/viz/flow_arrow.py +++ b/examples/viz/flow_arrow.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Literal +from typing import Callable, Literal import cv2 import numpy as np @@ -396,21 +396,32 @@ class FlowArrowOverlay: self.mesh = mesh self.style = style or DEFAULT_FLOW_ARROW_STYLE + self._enabled = bool(self.style.enabled) self._mesh_span = _mesh_span(mesh.rest_vertices) _cylinder_template(o3d) _cone_template(o3d) - self.arrow_mesh = create_flow_mesh_geometry(o3d) if self.style.enabled else None + self.arrow_mesh = create_flow_mesh_geometry(o3d) if self._enabled else None @property def enabled(self) -> bool: - return self.style.enabled + return self._enabled + + def set_enabled(self, enabled: bool) -> None: + enabled = bool(enabled) + if enabled == self._enabled: + return + self._enabled = enabled + if enabled and self.arrow_mesh is None: + import open3d as o3d + + self.arrow_mesh = create_flow_mesh_geometry(o3d) def compute( self, flow, valid_mask: np.ndarray | None = None, ) -> tuple[np.ndarray, np.ndarray, np.ndarray] | None: - if not self.style.enabled or self.arrow_mesh is None: + if not self._enabled or self.arrow_mesh is None: return None return build_flow_arrow_mesh( @@ -429,3 +440,90 @@ class FlowArrowOverlay: self.arrow_mesh.triangles = o3d.utility.Vector3iVector(arrow_triangles.astype(np.int32)) self.arrow_mesh.vertex_colors = o3d.utility.Vector3dVector(arrow_colors.astype(np.float64)) self.arrow_mesh.compute_vertex_normals() + + +FLOW_ARROWS_GEOMETRY_NAME = "flow_arrows" + + +@dataclass +class FlowArrowFilamentSlot: + added: bool = False + + +@dataclass +class FlowArrowHiddenSlot: + in_vis: bool = False + + +def ensure_flow_arrow_in_hidden_vis( + overlay: FlowArrowOverlay, + vis, + slot: FlowArrowHiddenSlot, +) -> None: + """Add flow-arrow geometry to a HiddenVisualizer window when enabled at init.""" + if overlay.enabled and overlay.arrow_mesh is not None and not slot.in_vis: + vis.add_geometry(overlay.arrow_mesh) + slot.in_vis = True + + +def update_flow_arrows_filament( + overlay: FlowArrowOverlay, + flow, + valid_mask, + *, + scene, + material, + slot: FlowArrowFilamentSlot, + mesh_to_o3d: Callable, +) -> None: + """Sync flow-arrow TriangleMesh geometry to a Filament offscreen scene.""" + flow_result = overlay.compute(flow, valid_mask=valid_mask) + if not overlay.enabled: + if slot.added: + scene.remove_geometry(FLOW_ARROWS_GEOMETRY_NAME) + slot.added = False + return + if flow_result is None or overlay.arrow_mesh is None: + return + + flow_vertices, flow_triangles, flow_colors = flow_result + if len(flow_vertices) > 0: + flow_mesh = mesh_to_o3d(flow_vertices, flow_triangles, flow_colors) + if not slot.added: + scene.add_geometry(FLOW_ARROWS_GEOMETRY_NAME, flow_mesh, material) + slot.added = True + else: + scene.remove_geometry(FLOW_ARROWS_GEOMETRY_NAME) + scene.add_geometry(FLOW_ARROWS_GEOMETRY_NAME, flow_mesh, material) + elif slot.added: + scene.remove_geometry(FLOW_ARROWS_GEOMETRY_NAME) + slot.added = False + + +def update_flow_arrows_hidden( + overlay: FlowArrowOverlay, + flow, + valid_mask, + *, + vis, + slot: FlowArrowHiddenSlot, +) -> None: + """Sync flow-arrow TriangleMesh geometry to a HiddenVisualizer window.""" + flow_result = overlay.compute(flow, valid_mask=valid_mask) + if not overlay.enabled: + if slot.in_vis and overlay.arrow_mesh is not None: + empty = np.empty((0, 3), dtype=np.float64) + empty_i = np.empty((0, 3), dtype=np.int32) + overlay.apply(empty, empty_i, empty) + vis.update_geometry(overlay.arrow_mesh) + return + if overlay.arrow_mesh is None: + return + if not slot.in_vis: + vis.add_geometry(overlay.arrow_mesh) + slot.in_vis = True + if flow_result is not None: + flow_vertices, flow_triangles, flow_colors = flow_result + overlay.apply(flow_vertices, flow_triangles, flow_colors) + vis.update_geometry(overlay.arrow_mesh) + diff --git a/examples/viz/mesh_camera.py b/examples/viz/mesh_camera.py index 9c2f8e0..6dc4f2d 100644 --- a/examples/viz/mesh_camera.py +++ b/examples/viz/mesh_camera.py @@ -38,6 +38,12 @@ def resolve_viz_pose_path(path: str | Path | None = None) -> Path: Path.cwd() / raw, here.parents[2] / raw, ] + try: + from app_paths import app_root + + candidates.insert(0, app_root() / raw) + except ImportError: + pass for candidate in candidates: if candidate.is_file(): return candidate.resolve() @@ -74,6 +80,79 @@ def camera_look_at_from_world_to_cam( return lookat, eye, up +def default_look_at_view( + mesh_vertices: np.ndarray, + lookat: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Fallback look_at triple when no world_to_cam pose is available.""" + center = np.asarray(lookat, dtype=np.float64).reshape(3) + extent = np.ptp(np.asarray(mesh_vertices, dtype=np.float64), axis=0) + span = float(np.max(extent)) + eye = center + np.array([0.0, 0.0, span * 2.0], dtype=np.float64) + up = np.array([0.0, 1.0, 0.0], dtype=np.float64) + return center, eye, up + + +def _rotation_matrix(axis: np.ndarray, angle_rad: float) -> np.ndarray: + axis = np.asarray(axis, dtype=np.float64).reshape(3) + norm = float(np.linalg.norm(axis)) + if norm < 1e-12: + return np.eye(3, dtype=np.float64) + axis = axis / norm + x, y, z = axis + cross = np.array( + [[0.0, -z, y], [z, 0.0, -x], [-y, x, 0.0]], + dtype=np.float64, + ) + return np.eye(3, dtype=np.float64) + np.sin(angle_rad) * cross + (1.0 - np.cos(angle_rad)) * (cross @ cross) + + +def orbit_eye_about_target( + target: np.ndarray, + eye: np.ndarray, + up: np.ndarray, + dx_deg: float, + dy_deg: float, +) -> tuple[np.ndarray, np.ndarray]: + """Orbit ``eye`` around ``target``; dx/dy are drag deltas in degrees.""" + target = np.asarray(target, dtype=np.float64).reshape(3) + eye = np.asarray(eye, dtype=np.float64).reshape(3) + up = np.asarray(up, dtype=np.float64).reshape(3) + + offset = eye - target + if float(np.linalg.norm(offset)) < 1e-9: + offset = np.array([0.0, 0.0, 1.0], dtype=np.float64) + + world_up = up.copy() + if float(np.linalg.norm(world_up)) < 1e-9: + world_up = np.array([0.0, 1.0, 0.0], dtype=np.float64) + world_up /= np.linalg.norm(world_up) + + if abs(dx_deg) > 1e-9: + rot = _rotation_matrix(world_up, np.deg2rad(dx_deg)) + offset = rot @ offset + up = rot @ up + + forward = -offset + forward_norm = float(np.linalg.norm(forward)) + if forward_norm > 1e-9: + forward = forward / forward_norm + right = np.cross(forward, up) + right_norm = float(np.linalg.norm(right)) + if right_norm > 1e-9: + right = right / right_norm + if abs(dy_deg) > 1e-9: + rot = _rotation_matrix(right, np.deg2rad(dy_deg)) + offset = rot @ offset + up = rot @ up + + new_eye = target + offset + up_norm = float(np.linalg.norm(up)) + if up_norm > 1e-9: + up = up / up_norm + return new_eye, up + + def apply_view_from_world_to_cam(vis, world_to_cam: np.ndarray, *, lookat: np.ndarray) -> None: """Apply Blender-exported world_to_cam as the Open3D default view.""" world_to_cam = np.asarray(world_to_cam, dtype=np.float64) diff --git a/examples/viz/offscreen_mesh.py b/examples/viz/offscreen_mesh.py index ca5a726..ac7a5de 100644 --- a/examples/viz/offscreen_mesh.py +++ b/examples/viz/offscreen_mesh.py @@ -10,12 +10,21 @@ from orisys.finger.mesh import UVSurfaceMesh from orisys.finger.viewer import flow_magnitude_vertex_colors, scalar_map_vertex_colors from .contact_arrow import ContactArrowOverlay -from .flow_arrow import FlowArrowOverlay +from .flow_arrow import ( + FlowArrowFilamentSlot, + FlowArrowHiddenSlot, + FlowArrowOverlay, + ensure_flow_arrow_in_hidden_vis, + update_flow_arrows_filament, + update_flow_arrows_hidden, +) from .mesh_camera import ( apply_filament_sun_light, apply_shade_factors, apply_view_from_world_to_cam, camera_look_at_from_world_to_cam, + default_look_at_view, + orbit_eye_about_target, precompute_shade_factors, ) @@ -132,19 +141,35 @@ class _FilamentOffscreenBackend: self._material = _make_lit_material(o3d) self._mesh_added = False self._arrow_added = False - self._flow_arrow_added = False + self._flow_arrow_slot = FlowArrowFilamentSlot() + self._reset_view_state() self._apply_camera() - def _apply_camera(self) -> None: + def _reset_view_state(self) -> None: if self.world_to_cam is not None: center, eye, up = camera_look_at_from_world_to_cam(self.world_to_cam, lookat=self.lookat) else: - center = self.lookat.astype(np.float64) - extent = np.ptp(self.mesh.rest_vertices, axis=0) - span = float(np.max(extent)) - eye = center + np.array([0.0, 0.0, span * 2.0], dtype=np.float64) - up = np.array([0.0, 1.0, 0.0], dtype=np.float64) - self._renderer.scene.camera.look_at(center, eye, up) + center, eye, up = default_look_at_view(self.mesh.rest_vertices, self.lookat) + self._view_center = center + self._view_eye = eye + self._view_up = up + + def _apply_camera(self) -> None: + self._renderer.scene.camera.look_at(self._view_center, self._view_eye, self._view_up) + + def rotate_view(self, dx: float, dy: float) -> None: + self._view_eye, self._view_up = orbit_eye_about_target( + self._view_center, + self._view_eye, + self._view_up, + dx, + dy, + ) + self._apply_camera() + + def reset_view(self) -> None: + self._reset_view_state() + self._apply_camera() def resize(self, width: int, height: int) -> None: width = max(int(width), 64) @@ -164,7 +189,18 @@ class _FilamentOffscreenBackend: self._material = _make_lit_material(self._o3d) self._mesh_added = False self._arrow_added = False - self._flow_arrow_added = False + self._flow_arrow_slot = FlowArrowFilamentSlot() + center, eye, up = self._view_center, self._view_eye, self._view_up + self._renderer = self._o3d.visualization.rendering.OffscreenRenderer(self.width, self.height) + self._renderer.scene.set_background(_background_rgba01(self.mesh_background)) + if self.mesh_lighting is not None: + apply_filament_sun_light( + self._renderer, + direction=self.mesh_lighting.direction, + intensity=self.mesh_lighting.intensity, + ) + self._material = _make_lit_material(self._o3d) + self._view_center, self._view_eye, self._view_up = center, eye, up self._apply_camera() def render( @@ -211,20 +247,15 @@ class _FilamentOffscreenBackend: self._renderer.scene.remove_geometry("contact_arrow") self._arrow_added = False - flow_result = self._flow_arrow.compute(flow, valid_mask=valid_mask) - if flow_result is not None and self._flow_arrow.arrow_mesh is not None: - flow_vertices, flow_triangles, flow_colors = flow_result - if len(flow_vertices) > 0: - flow_mesh = _mesh_to_o3d(o3d, flow_vertices, flow_triangles, flow_colors) - if not self._flow_arrow_added: - self._renderer.scene.add_geometry("flow_arrows", flow_mesh, self._material) - self._flow_arrow_added = True - else: - self._renderer.scene.remove_geometry("flow_arrows") - self._renderer.scene.add_geometry("flow_arrows", flow_mesh, self._material) - elif self._flow_arrow_added: - self._renderer.scene.remove_geometry("flow_arrows") - self._flow_arrow_added = False + update_flow_arrows_filament( + self._flow_arrow, + flow, + valid_mask, + scene=self._renderer.scene, + material=self._material, + slot=self._flow_arrow_slot, + mesh_to_o3d=lambda v, t, c: _mesh_to_o3d(o3d, v, t, c), + ) return _numpy_o3d_image_to_rgb(self._renderer.render_to_image()) @@ -280,6 +311,7 @@ class _HiddenVisualizerBackend: self._flow_arrow = FlowArrowOverlay(mesh, style=flow_arrow_style) self.vis = None self.tri_mesh = None + self._flow_arrow_slot = FlowArrowHiddenSlot() self._create_window() def _create_window(self) -> None: @@ -313,8 +345,7 @@ class _HiddenVisualizerBackend: if self._contact_arrow.arrow_mesh is not None: self.vis.add_geometry(self._contact_arrow.arrow_mesh) - if self._flow_arrow.enabled and self._flow_arrow.arrow_mesh is not None: - self.vis.add_geometry(self._flow_arrow.arrow_mesh) + ensure_flow_arrow_in_hidden_vis(self._flow_arrow, self.vis, self._flow_arrow_slot) render_option = self.vis.get_render_option() render_option.mesh_show_back_face = True @@ -322,21 +353,44 @@ class _HiddenVisualizerBackend: if hasattr(render_option, "light_on"): render_option.light_on = False + self._apply_default_view() + + def _apply_default_view(self) -> None: + if self.vis is None: + return if self.world_to_cam is not None: apply_view_from_world_to_cam(self.vis, self.world_to_cam, lookat=self.lookat) else: view_control = self.vis.get_view_control() - center = self.lookat.astype(np.float64) + center, eye, up = default_look_at_view(self.mesh.rest_vertices, self.lookat) + view_control.set_lookat(center) + front = center - eye + front_norm = float(np.linalg.norm(front)) + if front_norm > 1e-9: + front = front / front_norm + else: + front = np.array([0.0, 0.0, -1.0], dtype=np.float64) + view_control.set_front(front) + view_control.set_up(up) extent = np.ptp(self.mesh.rest_vertices, axis=0) span = float(np.max(extent)) - view_control.set_lookat(center) - view_control.set_front(np.array([0.0, 0.0, -1.0])) - view_control.set_up(np.array([0.0, 1.0, 0.0])) view_control.set_zoom(0.5 if span > 0 else 0.7) self.vis.poll_events() self.vis.update_renderer() + def rotate_view(self, dx: float, dy: float) -> None: + if self.vis is None: + return + self.vis.get_view_control().rotate(float(dx), float(dy)) + self.vis.poll_events() + self.vis.update_renderer() + + def reset_view(self) -> None: + if self.vis is None: + return + self._apply_default_view() + def resize(self, width: int, height: int) -> None: width = max(int(width), 64) height = max(int(height), 64) @@ -377,11 +431,13 @@ class _HiddenVisualizerBackend: self._contact_arrow.apply(arrow_vertices, arrow_colors) self.vis.update_geometry(self._contact_arrow.arrow_mesh) - flow_result = self._flow_arrow.compute(flow, valid_mask=valid_mask) - if flow_result is not None and self._flow_arrow.arrow_mesh is not None: - flow_vertices, flow_triangles, flow_colors = flow_result - self._flow_arrow.apply(flow_vertices, flow_triangles, flow_colors) - self.vis.update_geometry(self._flow_arrow.arrow_mesh) + update_flow_arrows_hidden( + self._flow_arrow, + flow, + valid_mask, + vis=self.vis, + slot=self._flow_arrow_slot, + ) self.vis.poll_events() self.vis.update_renderer() @@ -452,6 +508,20 @@ class OffscreenFingerMeshRenderer: def resize(self, width: int, height: int) -> None: self._backend.resize(width, height) + def rotate_view(self, dx: float, dy: float) -> None: + rotate = getattr(self._backend, "rotate_view", None) + if rotate is not None: + rotate(dx, dy) + + def reset_view(self) -> None: + reset = getattr(self._backend, "reset_view", None) + if reset is not None: + reset() + + @property + def flow_arrow(self) -> FlowArrowOverlay: + return self._backend._flow_arrow + def render( self, *, diff --git a/pyproject.toml b/pyproject.toml index 5b3850e..a3a204a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ dev_install = true name = "examples" source = "examples" target = "examples" -patterns = ["*.py", "README.md", "viz/*.py", "pipeline/*.py", "gui/*.py", "gui/widgets/*.py"] +patterns = ["*.py", "README.md", "app_paths.py", "viz/**/*.py", "pipeline/**/*.py", "gui/**/*.py"] preserve_tree = true [[tool.orisys.bundle.items]] @@ -64,7 +64,7 @@ patterns = ["*.md", "*.pdf"] name = "assets" source = "assets" target = "assets" -patterns = ["finger/30fps/*"] +patterns = ["finger/30fps/*", "finger/visualization/*"] preserve_tree = true dev_install = true diff --git a/src/orisys/Stitcher.py b/src/orisys/Stitcher.py index b1e9008..f76c925 100644 --- a/src/orisys/Stitcher.py +++ b/src/orisys/Stitcher.py @@ -3,9 +3,9 @@ import numpy as np import scipy.fft from scipy import ndimage import cv2 -from numba import njit +# from numba import njit -@njit(cache=False) +# @njit(cache=False) def _blend_kernel(blend_patch, wsum_patch, cov_patch, src_patch, w_patch): """Fused incremental weighted-average composite (numba-accelerated).""" sh = src_patch.shape[0] diff --git a/src/orisys/finger/exports.py b/src/orisys/finger/exports.py index de6d7cd..7f39d22 100644 --- a/src/orisys/finger/exports.py +++ b/src/orisys/finger/exports.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys from dataclasses import dataclass from pathlib import Path @@ -44,10 +45,12 @@ def _candidate_export_dirs(export_dir) -> list[Path]: here = Path(__file__).resolve() candidates = [ - Path.cwd() / raw, # repo root / user cwd + Path.cwd() / raw, here.parents[1] / raw, # packaged wheel: orisys/assets/... here.parents[3] / raw, # editable install: repo/assets/... ] + if getattr(sys, "frozen", False): + candidates.insert(0, Path(sys.executable).resolve().parent / raw) unique: list[Path] = [] for candidate in candidates: resolved = candidate.resolve()