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
+2 -1
View File
@@ -75,7 +75,8 @@ cfg = orisys.export_config("ddjx01", "./config/exported_config.json")
- Example entry points: `examples/README.md` - Example entry points: `examples/README.md`
- User documentation: `docs/get_started.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/` - Specialized utilities: `tools/`
## Notes for Collaborators ## Notes for Collaborators
+50
View File
@@ -39,3 +39,53 @@ pip install -e ".[gui]"
## Notes ## Notes
- Dashboard: click **启动** to start acquisition; `Q` quit, `R` reset, `S`/`E` record, `Esc` fullscreen. - 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`
+44
View File
@@ -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)
+5 -5
View File
@@ -12,13 +12,13 @@ Where to customize the GUI:
import argparse import argparse
import os import os
import sys import sys
from pathlib import Path
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0") os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
_EXAMPLES_DIR = Path(__file__).resolve().parent from app_paths import configure_frozen_runtime, default_config_path, ensure_examples_import_path
if str(_EXAMPLES_DIR) not in sys.path:
sys.path.insert(0, str(_EXAMPLES_DIR)) configure_frozen_runtime()
ensure_examples_import_path()
from gui import run_dashboard from gui import run_dashboard
from pipeline import PipelineConfig from pipeline import PipelineConfig
@@ -30,7 +30,7 @@ def _parse_args():
description="UV-unroll finger tactile pipeline with unified Qt dashboard" 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("--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("--cal", default="", help="Optional calibration file path")
parser.add_argument("--verbose", action="store_true", help="SDK verbose logs") parser.add_argument("--verbose", action="store_true", help="SDK verbose logs")
parser.add_argument( parser.add_argument(
+73 -103
View File
@@ -4,17 +4,13 @@ from __future__ import annotations
import sys import sys
import time 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.QtGui import QIcon
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QApplication,
QFileDialog, QFileDialog,
QHBoxLayout,
QLineEdit,
QMainWindow, QMainWindow,
QPushButton,
QSplitter, QSplitter,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
@@ -23,6 +19,7 @@ from PyQt5.QtWidgets import (
from pipeline.config import PipelineConfig from pipeline.config import PipelineConfig
from pipeline.service import FingerPipeline from pipeline.service import FingerPipeline
from app_paths import resource_path
from .layout import ( from .layout import (
DashboardLayout, DashboardLayout,
LAYOUT, LAYOUT,
@@ -30,27 +27,19 @@ from .layout import (
PanelSetup, PanelSetup,
top_row_splitter_sizes, top_row_splitter_sizes,
) )
from .mesh_view_state import MeshViewState
from .styles import STYLE from .styles import STYLE
from .transport_icons import transport_icon
from .widgets import ( from .widgets import (
ArrowPanelWidget, ArrowPanelWidget,
ForceChartWidget, ForceChartWidget,
MeshViewportWidget, MeshViewportWidget,
SettingsPanelWidget,
TeeStream, TeeStream,
WidgetLogHandler, WidgetLogHandler,
make_log_widget, 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): class FingerDashboardWindow(QMainWindow):
"""Single-window finger demo: 3D mesh | UV arrows | force charts.""" """Single-window finger demo: 3D mesh | UV arrows | force charts."""
@@ -73,7 +62,7 @@ class FingerDashboardWindow(QMainWindow):
self.setWindowTitle(layout.window_title) self.setWindowTitle(layout.window_title)
self.setStyleSheet(STYLE) self.setStyleSheet(STYLE)
logo_path = Path(__file__).resolve().parents[2] / "logo.png" logo_path = resource_path("logo.png")
if logo_path.is_file(): if logo_path.is_file():
self.setWindowIcon(QIcon(str(logo_path))) self.setWindowIcon(QIcon(str(logo_path)))
@@ -83,10 +72,6 @@ class FingerDashboardWindow(QMainWindow):
self.timer = QTimer() self.timer = QTimer()
self.timer.timeout.connect(self._on_timer) 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: def _setup_ui(self) -> None:
layout = self.layout layout = self.layout
central = QWidget() 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.setContentsMargins(layout.root_margin_px, layout.root_margin_px, layout.root_margin_px, layout.root_margin_px)
root_layout.setSpacing(layout.root_spacing_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 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") arrows_slot = layout.slot("arrows")
self.arrow_panel: ArrowPanelWidget | None = None self.arrow_panel: ArrowPanelWidget | None = None
if arrows_slot is not None and arrows_slot.enabled: 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.force_charts = ForceChartWidget(layout=layout, lowpass_cutoff_hz=self.config.lowpass_cutoff_hz)
self.log_widget = make_log_widget(layout.log_config) 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: dict[str, QWidget] = {}
self._panel_widgets["mesh"] = self.mesh_viewport self._panel_widgets["mesh"] = self.mesh_viewport
if self.arrow_panel is not None: if self.arrow_panel is not None:
self._panel_widgets["arrows"] = self.arrow_panel self._panel_widgets["arrows"] = self.arrow_panel
self._panel_widgets["charts"] = self.force_charts.widget self._panel_widgets["charts"] = self.force_charts.widget
self._panel_widgets["settings"] = self.settings_panel
self._panel_widgets["log"] = self.log_widget 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) h_splitter = QSplitter(Qt.Horizontal)
for slot in setup.top_row.slots: for slot in setup.top_row.slots:
if not slot.enabled: if not slot.enabled:
continue 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.setStretchFactor(h_splitter.count() - 1, slot.stretch)
h_splitter.setChildrenCollapsible(setup.top_row.collapsible) h_splitter.setChildrenCollapsible(setup.top_row.collapsible)
self.h_splitter = h_splitter self.h_splitter = h_splitter
# Vertical: top panel | log panel root_layout.addWidget(h_splitter)
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)
self.resize(layout.window_width, layout.window_height) self.resize(layout.window_width, layout.window_height)
QTimer.singleShot(0, self._apply_panel_layout) 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: def _apply_panel_layout(self) -> None:
setup = self.layout.panel_setup 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: for slot in setup.top_row.slots:
if not slot.enabled: if not slot.enabled:
continue continue
if slot.id in ("mesh", "arrows"): if slot.id in ("mesh", "arrows"):
self._panel_widgets[slot.id].set_fixed_size(slot.width_px, slot.height_px) self._panel_widgets[slot.id].set_fixed_size(slot.width_px, column_height)
elif slot.id == "charts": elif slot.id == "settings":
self.force_charts.set_panel_size(slot.width_px, slot.height_px) 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: if (
self.log_widget.setFixedHeight(setup.log_panel.height_px) 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))) self.h_splitter.setSizes(list(top_row_splitter_sizes(self.layout)))
@@ -209,28 +200,7 @@ class FingerDashboardWindow(QMainWindow):
sys.stderr = sys.stdout sys.stderr = sys.stdout
def _set_play_pause_button(self, state: str) -> None: def _set_play_pause_button(self, state: str) -> None:
"""``run`` = green play, ``starting`` = grey disabled play, ``pause`` = orange pause.""" self.settings_panel.set_play_pause_state(state)
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 _on_play_pause_clicked(self) -> None: def _on_play_pause_clicked(self) -> None:
if self._running: if self._running:
@@ -244,9 +214,9 @@ class FingerDashboardWindow(QMainWindow):
self._set_play_pause_button("starting") self._set_play_pause_button("starting")
QApplication.processEvents() QApplication.processEvents()
self.config.video = self.camera_edit.text().strip() self.config.video = self.settings_panel.video_source()
self.config.config = self.config_edit.text().strip() self.config.config = self.settings_panel.config_path()
cal = self.cal_edit.text().strip() cal = self.settings_panel.calibration_path()
self.config.calib = cal or None self.config.calib = cal or None
try: try:
@@ -266,7 +236,7 @@ class FingerDashboardWindow(QMainWindow):
self._wall_fps = 0.0 self._wall_fps = 0.0
self.timer.start(self.layout.timer_interval_ms) self.timer.start(self.layout.timer_interval_ms)
self._set_play_pause_button("pause") self._set_play_pause_button("pause")
self.btn_reset.setEnabled(True) self.settings_panel.btn_reset_tracker.setEnabled(True)
print( print(
f"▶ 启动 — 摄像机={self.config.video}, 配置={self.config.config}, " f"▶ 启动 — 摄像机={self.config.video}, 配置={self.config.config}, "
f"标定={self.config.calib or '(none)'}" f"标定={self.config.calib or '(none)'}"
@@ -285,7 +255,7 @@ class FingerDashboardWindow(QMainWindow):
self.pipeline.close() self.pipeline.close()
self._set_play_pause_button("run") self._set_play_pause_button("run")
self.btn_reset.setEnabled(False) self.settings_panel.btn_reset_tracker.setEnabled(False)
def _on_reset(self) -> None: def _on_reset(self) -> None:
if not self._running: if not self._running:
@@ -371,7 +341,7 @@ class FingerDashboardWindow(QMainWindow):
"视频 (*.mp4 *.avi *.mov);;所有文件 (*)", "视频 (*.mp4 *.avi *.mov);;所有文件 (*)",
) )
if path: if path:
self.camera_edit.setText(path) self.settings_panel.set_video_source(path)
def _select_config(self) -> None: def _select_config(self) -> None:
path, _ = QFileDialog.getOpenFileName( path, _ = QFileDialog.getOpenFileName(
@@ -381,7 +351,7 @@ class FingerDashboardWindow(QMainWindow):
"JSON (*.json);;所有文件 (*)", "JSON (*.json);;所有文件 (*)",
) )
if path: if path:
self.config_edit.setText(path) self.settings_panel.set_config_path(path)
def keyPressEvent(self, event): def keyPressEvent(self, event):
key = event.key() 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: Structure:
┌─ toolbar ─────────────────────────────────────────────┐ ┌─ toolbar ─────────────────────────────────────────────────────┐
├─ TOP (stretch: top_stretch) ──────────────────────────┤ ├─ mesh ─┬─ charts ─┬─ settings ────────────────────────────────┤
│ ┌ mesh ─┬─ arrows ─┬─ charts ─┐ (left → right) │ │ ├─ log ────┤ │
│ └───────┴───────────┴─────────┘ │ └────────┴──────────┴─────────────────────────────────────────┘
├─ LOG (stretch: log_stretch) ──────────────────────────┤
└───────────────────────────────────────────────────────┘
Each panel uses explicit width_px and height_px. 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: MeshLighting (below) controls the 3D mesh panel sun light:
azimuth_deg — horizontal angle in degrees (0° = +X, 90° = +Y) 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) 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 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. Log sits under charts only (not under mesh). Settings is the rightmost column.
Edit LOG_CONFIG for live status line and log panel appearance.
""" """
from __future__ import annotations 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.flow_arrow import FlowArrowStyle
from viz.render_2d import ArrowBackground, ArrowCanvasColors from viz.render_2d import ArrowBackground, ArrowCanvasColors
PanelId = Literal["mesh", "arrows", "charts", "log"] PanelId = Literal["mesh", "arrows", "charts", "log", "settings"]
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -76,20 +73,21 @@ class PanelRow:
@dataclass(frozen=True) @dataclass(frozen=True)
class PanelSetup: class PanelSetup:
"""Full panel hierarchy: top row + bottom log.""" """Full panel hierarchy: top row + log nested under charts."""
top_row: PanelRow top_row: PanelRow
log_panel: PanelSlot log_panel: PanelSlot
top_stretch: int = 5 charts_log_stretch: int = 5
log_stretch: int = 1 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( TOP_ROW = PanelRow(
slots=( 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("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( PANEL_SETUP = PanelSetup(
top_row=TOP_ROW, top_row=TOP_ROW,
log_panel=LOG_PANEL, log_panel=LOG_PANEL,
top_stretch=5, charts_log_stretch=5,
log_stretch=1, log_stretch=1,
) )
@@ -225,8 +223,8 @@ class DashboardLayout:
panel_setup: PanelSetup = PANEL_SETUP panel_setup: PanelSetup = PANEL_SETUP
window_title: str = "Orisys Finger Dashboard" window_title: str = "Orisys Finger Dashboard"
window_width: int = 1000 window_width: int = 1100
window_height: int = 600 window_height: int = 620
root_margin_px: int = 6 root_margin_px: int = 6
root_spacing_px: int = 6 root_spacing_px: int = 6
@@ -279,6 +277,15 @@ class DashboardLayout:
return slot return slot
return None 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: def top_slot(self, panel_id: PanelId) -> PanelSlot:
for slot in self.panel_setup.top_row.slots: for slot in self.panel_setup.top_row.slots:
if slot.id == panel_id: if slot.id == panel_id:
@@ -293,6 +300,10 @@ class DashboardLayout:
def arrows_panel_title(self) -> str: def arrows_panel_title(self) -> str:
return self.top_slot("arrows").title return self.top_slot("arrows").title
@property
def settings_panel_title(self) -> str:
return self.top_slot("settings").title
LAYOUT = DashboardLayout() 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; 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 { QPushButton {
background-color: #1a1a1a; background-color: #1a1a1a;
color: #999999; color: #999999;
+45 -1
View File
@@ -2,10 +2,11 @@
from __future__ import annotations from __future__ import annotations
import math
from typing import Literal from typing import Literal
from PyQt5.QtCore import QPointF, QRectF, Qt 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"] TransportKind = Literal["play", "pause"]
@@ -48,3 +49,46 @@ def transport_icon(kind: TransportKind, color: str, *, size: int = _DEFAULT_ICON
painter.end() painter.end()
return QIcon(pixmap) 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.""" """Qt widgets for the finger dashboard."""
from .settings_panel import SettingsPanelWidget
from .force_charts import ForceChartWidget from .force_charts import ForceChartWidget
from .log_panel import TeeStream, WidgetLogHandler, make_log_widget from .log_panel import TeeStream, WidgetLogHandler, make_log_widget
from .mesh_viewport import MeshViewportWidget from .mesh_viewport import MeshViewportWidget
@@ -9,6 +10,7 @@ from .image_panel import ImagePanel
__all__ = [ __all__ = [
"ArrowPanelWidget", "ArrowPanelWidget",
"SettingsPanelWidget",
"ForceChartWidget", "ForceChartWidget",
"MeshViewportWidget", "MeshViewportWidget",
"ImagePanel", "ImagePanel",
+76 -1
View File
@@ -4,25 +4,36 @@ from __future__ import annotations
import sys import sys
from PyQt5.QtCore import QEvent, Qt
from PyQt5.QtGui import QMouseEvent
from pipeline.frame import FingerFrameView from pipeline.frame import FingerFrameView
from viz.offscreen_mesh import OffscreenFingerMeshRenderer from viz.offscreen_mesh import OffscreenFingerMeshRenderer
from ..layout import DashboardLayout, LAYOUT from ..layout import DashboardLayout, LAYOUT
from ..mesh_view_state import MeshViewState
from ..mesh_render_worker import MeshRenderWorker from ..mesh_render_worker import MeshRenderWorker
from .qt_pixmap import ndarray_to_pixmap from .qt_pixmap import ndarray_to_pixmap
from .image_panel import ImagePanel from .image_panel import ImagePanel
_VIEW_ROTATE_SENSITIVITY = 0.35
class MeshViewportWidget(ImagePanel): class MeshViewportWidget(ImagePanel):
"""Render the finger mesh offscreen in a square 1:1 viewport.""" """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._layout = layout
self._mesh_state = mesh_state or MeshViewState.from_layout(layout)
self._renderer: OffscreenFingerMeshRenderer | None = None self._renderer: OffscreenFingerMeshRenderer | None = None
self._mesh_worker: MeshRenderWorker | None = None self._mesh_worker: MeshRenderWorker | None = None
self._mesh_map = "depth" self._mesh_map = "depth"
self._threshold = 0.0 self._threshold = 0.0
self._enable_3d = True 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() display_w, display_h = layout.mesh_display_size()
super().__init__( super().__init__(
title=layout.mesh_panel_title, title=layout.mesh_panel_title,
@@ -30,6 +41,53 @@ class MeshViewportWidget(ImagePanel):
default_side=min(display_w, display_h), default_side=min(display_w, display_h),
parent=parent, 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: def configure(self, pipeline) -> None:
self._mesh_map = pipeline.config.mesh_map self._mesh_map = pipeline.config.mesh_map
@@ -69,6 +127,7 @@ class MeshViewportWidget(ImagePanel):
contact_arrow_style=self._layout.mesh_contact_arrow, contact_arrow_style=self._layout.mesh_contact_arrow,
flow_arrow_style=self._layout.mesh_flow_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: def _stop_worker(self) -> None:
if self._mesh_worker is not None: if self._mesh_worker is not None:
@@ -82,11 +141,26 @@ class MeshViewportWidget(ImagePanel):
def _on_rgb_ready(self, rgb) -> None: def _on_rgb_ready(self, rgb) -> None:
self.set_source_pixmap(ndarray_to_pixmap(rgb, bgr=False)) 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: def update_frame(self, frame: FingerFrameView, *, async_render: bool | None = None) -> float:
"""Update mesh colors. Returns render wall time (0 when async submit only).""" """Update mesh colors. Returns render wall time (0 when async submit only)."""
if not self._enable_3d or self._renderer is None: if not self._enable_3d or self._renderer is None:
return 0.0 return 0.0
self._last_frame = frame
use_async = self._layout.mesh_async_render if async_render is None else async_render 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: if use_async and self._mesh_worker is not None:
self._mesh_worker.submit( self._mesh_worker.submit(
@@ -118,3 +192,4 @@ class MeshViewportWidget(ImagePanel):
if self._renderer is not None: if self._renderer is not None:
self._renderer.close() self._renderer.close()
self._renderer = None 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()
+102 -4
View File
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal from typing import Callable, Literal
import cv2 import cv2
import numpy as np import numpy as np
@@ -396,21 +396,32 @@ class FlowArrowOverlay:
self.mesh = mesh self.mesh = mesh
self.style = style or DEFAULT_FLOW_ARROW_STYLE self.style = style or DEFAULT_FLOW_ARROW_STYLE
self._enabled = bool(self.style.enabled)
self._mesh_span = _mesh_span(mesh.rest_vertices) self._mesh_span = _mesh_span(mesh.rest_vertices)
_cylinder_template(o3d) _cylinder_template(o3d)
_cone_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 @property
def enabled(self) -> bool: 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( def compute(
self, self,
flow, flow,
valid_mask: np.ndarray | None = None, valid_mask: np.ndarray | None = None,
) -> tuple[np.ndarray, np.ndarray, np.ndarray] | 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 None
return build_flow_arrow_mesh( 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.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.vertex_colors = o3d.utility.Vector3dVector(arrow_colors.astype(np.float64))
self.arrow_mesh.compute_vertex_normals() 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)
+79
View File
@@ -38,6 +38,12 @@ def resolve_viz_pose_path(path: str | Path | None = None) -> Path:
Path.cwd() / raw, Path.cwd() / raw,
here.parents[2] / 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: for candidate in candidates:
if candidate.is_file(): if candidate.is_file():
return candidate.resolve() return candidate.resolve()
@@ -74,6 +80,79 @@ def camera_look_at_from_world_to_cam(
return lookat, eye, up 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: 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.""" """Apply Blender-exported world_to_cam as the Open3D default view."""
world_to_cam = np.asarray(world_to_cam, dtype=np.float64) world_to_cam = np.asarray(world_to_cam, dtype=np.float64)
+105 -35
View File
@@ -10,12 +10,21 @@ from orisys.finger.mesh import UVSurfaceMesh
from orisys.finger.viewer import flow_magnitude_vertex_colors, scalar_map_vertex_colors from orisys.finger.viewer import flow_magnitude_vertex_colors, scalar_map_vertex_colors
from .contact_arrow import ContactArrowOverlay 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 ( from .mesh_camera import (
apply_filament_sun_light, apply_filament_sun_light,
apply_shade_factors, apply_shade_factors,
apply_view_from_world_to_cam, apply_view_from_world_to_cam,
camera_look_at_from_world_to_cam, camera_look_at_from_world_to_cam,
default_look_at_view,
orbit_eye_about_target,
precompute_shade_factors, precompute_shade_factors,
) )
@@ -132,19 +141,35 @@ class _FilamentOffscreenBackend:
self._material = _make_lit_material(o3d) self._material = _make_lit_material(o3d)
self._mesh_added = False self._mesh_added = False
self._arrow_added = False self._arrow_added = False
self._flow_arrow_added = False self._flow_arrow_slot = FlowArrowFilamentSlot()
self._reset_view_state()
self._apply_camera() self._apply_camera()
def _apply_camera(self) -> None: def _reset_view_state(self) -> None:
if self.world_to_cam is not 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) center, eye, up = camera_look_at_from_world_to_cam(self.world_to_cam, lookat=self.lookat)
else: else:
center = self.lookat.astype(np.float64) center, eye, up = default_look_at_view(self.mesh.rest_vertices, self.lookat)
extent = np.ptp(self.mesh.rest_vertices, axis=0) self._view_center = center
span = float(np.max(extent)) self._view_eye = eye
eye = center + np.array([0.0, 0.0, span * 2.0], dtype=np.float64) self._view_up = up
up = np.array([0.0, 1.0, 0.0], dtype=np.float64)
self._renderer.scene.camera.look_at(center, eye, 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: def resize(self, width: int, height: int) -> None:
width = max(int(width), 64) width = max(int(width), 64)
@@ -164,7 +189,18 @@ class _FilamentOffscreenBackend:
self._material = _make_lit_material(self._o3d) self._material = _make_lit_material(self._o3d)
self._mesh_added = False self._mesh_added = False
self._arrow_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() self._apply_camera()
def render( def render(
@@ -211,20 +247,15 @@ class _FilamentOffscreenBackend:
self._renderer.scene.remove_geometry("contact_arrow") self._renderer.scene.remove_geometry("contact_arrow")
self._arrow_added = False self._arrow_added = False
flow_result = self._flow_arrow.compute(flow, valid_mask=valid_mask) update_flow_arrows_filament(
if flow_result is not None and self._flow_arrow.arrow_mesh is not None: self._flow_arrow,
flow_vertices, flow_triangles, flow_colors = flow_result flow,
if len(flow_vertices) > 0: valid_mask,
flow_mesh = _mesh_to_o3d(o3d, flow_vertices, flow_triangles, flow_colors) scene=self._renderer.scene,
if not self._flow_arrow_added: material=self._material,
self._renderer.scene.add_geometry("flow_arrows", flow_mesh, self._material) slot=self._flow_arrow_slot,
self._flow_arrow_added = True mesh_to_o3d=lambda v, t, c: _mesh_to_o3d(o3d, v, t, c),
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
return _numpy_o3d_image_to_rgb(self._renderer.render_to_image()) 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._flow_arrow = FlowArrowOverlay(mesh, style=flow_arrow_style)
self.vis = None self.vis = None
self.tri_mesh = None self.tri_mesh = None
self._flow_arrow_slot = FlowArrowHiddenSlot()
self._create_window() self._create_window()
def _create_window(self) -> None: def _create_window(self) -> None:
@@ -313,8 +345,7 @@ class _HiddenVisualizerBackend:
if self._contact_arrow.arrow_mesh is not None: if self._contact_arrow.arrow_mesh is not None:
self.vis.add_geometry(self._contact_arrow.arrow_mesh) self.vis.add_geometry(self._contact_arrow.arrow_mesh)
if self._flow_arrow.enabled and self._flow_arrow.arrow_mesh is not None: ensure_flow_arrow_in_hidden_vis(self._flow_arrow, self.vis, self._flow_arrow_slot)
self.vis.add_geometry(self._flow_arrow.arrow_mesh)
render_option = self.vis.get_render_option() render_option = self.vis.get_render_option()
render_option.mesh_show_back_face = True render_option.mesh_show_back_face = True
@@ -322,21 +353,44 @@ class _HiddenVisualizerBackend:
if hasattr(render_option, "light_on"): if hasattr(render_option, "light_on"):
render_option.light_on = False 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: if self.world_to_cam is not None:
apply_view_from_world_to_cam(self.vis, self.world_to_cam, lookat=self.lookat) apply_view_from_world_to_cam(self.vis, self.world_to_cam, lookat=self.lookat)
else: else:
view_control = self.vis.get_view_control() 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) extent = np.ptp(self.mesh.rest_vertices, axis=0)
span = float(np.max(extent)) 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) view_control.set_zoom(0.5 if span > 0 else 0.7)
self.vis.poll_events() self.vis.poll_events()
self.vis.update_renderer() 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: def resize(self, width: int, height: int) -> None:
width = max(int(width), 64) width = max(int(width), 64)
height = max(int(height), 64) height = max(int(height), 64)
@@ -377,11 +431,13 @@ class _HiddenVisualizerBackend:
self._contact_arrow.apply(arrow_vertices, arrow_colors) self._contact_arrow.apply(arrow_vertices, arrow_colors)
self.vis.update_geometry(self._contact_arrow.arrow_mesh) self.vis.update_geometry(self._contact_arrow.arrow_mesh)
flow_result = self._flow_arrow.compute(flow, valid_mask=valid_mask) update_flow_arrows_hidden(
if flow_result is not None and self._flow_arrow.arrow_mesh is not None: self._flow_arrow,
flow_vertices, flow_triangles, flow_colors = flow_result flow,
self._flow_arrow.apply(flow_vertices, flow_triangles, flow_colors) valid_mask,
self.vis.update_geometry(self._flow_arrow.arrow_mesh) vis=self.vis,
slot=self._flow_arrow_slot,
)
self.vis.poll_events() self.vis.poll_events()
self.vis.update_renderer() self.vis.update_renderer()
@@ -452,6 +508,20 @@ class OffscreenFingerMeshRenderer:
def resize(self, width: int, height: int) -> None: def resize(self, width: int, height: int) -> None:
self._backend.resize(width, height) 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( def render(
self, self,
*, *,
+2 -2
View File
@@ -51,7 +51,7 @@ dev_install = true
name = "examples" name = "examples"
source = "examples" source = "examples"
target = "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 preserve_tree = true
[[tool.orisys.bundle.items]] [[tool.orisys.bundle.items]]
@@ -64,7 +64,7 @@ patterns = ["*.md", "*.pdf"]
name = "assets" name = "assets"
source = "assets" source = "assets"
target = "assets" target = "assets"
patterns = ["finger/30fps/*"] patterns = ["finger/30fps/*", "finger/visualization/*"]
preserve_tree = true preserve_tree = true
dev_install = true dev_install = true
+2 -2
View File
@@ -3,9 +3,9 @@ import numpy as np
import scipy.fft import scipy.fft
from scipy import ndimage from scipy import ndimage
import cv2 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): def _blend_kernel(blend_patch, wsum_patch, cov_patch, src_patch, w_patch):
"""Fused incremental weighted-average composite (numba-accelerated).""" """Fused incremental weighted-average composite (numba-accelerated)."""
sh = src_patch.shape[0] sh = src_patch.shape[0]
+4 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -44,10 +45,12 @@ def _candidate_export_dirs(export_dir) -> list[Path]:
here = Path(__file__).resolve() here = Path(__file__).resolve()
candidates = [ candidates = [
Path.cwd() / raw, # repo root / user cwd Path.cwd() / raw,
here.parents[1] / raw, # packaged wheel: orisys/assets/... here.parents[1] / raw, # packaged wheel: orisys/assets/...
here.parents[3] / raw, # editable install: repo/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] = [] unique: list[Path] = []
for candidate in candidates: for candidate in candidates:
resolved = candidate.resolve() resolved = candidate.resolve()