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
+50
View File
@@ -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`
+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 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(
+73 -103
View File
@@ -4,17 +4,13 @@ from __future__ import annotations
import sys
import time
from pathlib import Path
from PyQt5.QtCore import QSize, Qt, QTimer
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import (
QApplication,
QFileDialog,
QHBoxLayout,
QLineEdit,
QMainWindow,
QPushButton,
QSplitter,
QVBoxLayout,
QWidget,
@@ -23,6 +19,7 @@ from PyQt5.QtWidgets import (
from pipeline.config import PipelineConfig
from pipeline.service import FingerPipeline
from app_paths import resource_path
from .layout import (
DashboardLayout,
LAYOUT,
@@ -30,27 +27,19 @@ from .layout import (
PanelSetup,
top_row_splitter_sizes,
)
from .mesh_view_state import MeshViewState
from .styles import STYLE
from .transport_icons import transport_icon
from .widgets import (
ArrowPanelWidget,
ForceChartWidget,
MeshViewportWidget,
SettingsPanelWidget,
TeeStream,
WidgetLogHandler,
make_log_widget,
)
_PLAY_PAUSE_BUTTON_NAME = "btnPlayPause"
_PLAY_PAUSE_ICON_PX = 18
_PLAY_PAUSE_ICON_COLORS = {
"run": "#4caf84",
"starting": "#555555",
"pause": "#ff9800",
}
class FingerDashboardWindow(QMainWindow):
"""Single-window finger demo: 3D mesh | UV arrows | force charts."""
@@ -73,7 +62,7 @@ class FingerDashboardWindow(QMainWindow):
self.setWindowTitle(layout.window_title)
self.setStyleSheet(STYLE)
logo_path = Path(__file__).resolve().parents[2] / "logo.png"
logo_path = resource_path("logo.png")
if logo_path.is_file():
self.setWindowIcon(QIcon(str(logo_path)))
@@ -83,10 +72,6 @@ class FingerDashboardWindow(QMainWindow):
self.timer = QTimer()
self.timer.timeout.connect(self._on_timer)
self.camera_edit.setText(str(config.video))
self.config_edit.setText(config.config)
self.cal_edit.setText(config.calib or "")
def _setup_ui(self) -> None:
layout = self.layout
central = QWidget()
@@ -95,42 +80,10 @@ class FingerDashboardWindow(QMainWindow):
root_layout.setContentsMargins(layout.root_margin_px, layout.root_margin_px, layout.root_margin_px, layout.root_margin_px)
root_layout.setSpacing(layout.root_spacing_px)
bar = QHBoxLayout()
bar.setSpacing(layout.toolbar_spacing_px)
self.btn_camera = QPushButton("Sensor")
self.btn_camera.clicked.connect(self._select_camera)
bar.addWidget(self.btn_camera)
self.camera_edit = QLineEdit("0")
self.camera_edit.setPlaceholderText("Camera ID or Video Path")
bar.addWidget(self.camera_edit, 1)
self.btn_config = QPushButton("Config")
self.btn_config.clicked.connect(self._select_config)
bar.addWidget(self.btn_config)
self.config_edit = QLineEdit("./config/dd02-ov.json")
bar.addWidget(self.config_edit, 1)
self.cal_edit = QLineEdit("")
bar.addWidget(self.cal_edit, 1)
self.btn_reset = QPushButton("Reset")
self.btn_reset.setEnabled(False)
self.btn_reset.clicked.connect(self._on_reset)
bar.addWidget(self.btn_reset)
self.btn_play_pause = QPushButton()
self.btn_play_pause.setObjectName(_PLAY_PAUSE_BUTTON_NAME)
self.btn_play_pause.setFixedSize(36, 32)
self.btn_play_pause.setIconSize(QSize(_PLAY_PAUSE_ICON_PX, _PLAY_PAUSE_ICON_PX))
self.btn_play_pause.clicked.connect(self._on_play_pause_clicked)
self._set_play_pause_button("run")
bar.addWidget(self.btn_play_pause)
root_layout.addLayout(bar)
setup = layout.panel_setup
self.mesh_viewport = MeshViewportWidget(layout=layout)
self._log_panel_visible = setup.log_panel.enabled
self.mesh_state = MeshViewState.from_layout(layout)
self.mesh_viewport = MeshViewportWidget(layout=layout, mesh_state=self.mesh_state)
arrows_slot = layout.slot("arrows")
self.arrow_panel: ArrowPanelWidget | None = None
if arrows_slot is not None and arrows_slot.enabled:
@@ -141,53 +94,91 @@ class FingerDashboardWindow(QMainWindow):
)
self.force_charts = ForceChartWidget(layout=layout, lowpass_cutoff_hz=self.config.lowpass_cutoff_hz)
self.log_widget = make_log_widget(layout.log_config)
self.settings_panel = SettingsPanelWidget(
layout=layout,
mesh_state=self.mesh_state,
show_log=self._log_panel_visible,
)
self.settings_panel.set_video_source(str(self.config.video))
self.settings_panel.set_config_path(self.config.config)
self.settings_panel.set_calibration_path(self.config.calib or "")
self.settings_panel.log_visibility_changed.connect(self._set_log_panel_visible)
self.settings_panel.free_rotation_changed.connect(self.mesh_viewport.set_free_rotation)
self.settings_panel.view_reset_requested.connect(self.mesh_viewport.reset_view)
self.settings_panel.browse_video_requested.connect(self._select_camera)
self.settings_panel.browse_config_requested.connect(self._select_config)
self.settings_panel.btn_play_pause.clicked.connect(self._on_play_pause_clicked)
self.settings_panel.btn_reset_tracker.clicked.connect(self._on_reset)
self.settings_panel.set_play_pause_state("run")
self._panel_widgets: dict[str, QWidget] = {}
self._panel_widgets["mesh"] = self.mesh_viewport
if self.arrow_panel is not None:
self._panel_widgets["arrows"] = self.arrow_panel
self._panel_widgets["charts"] = self.force_charts.widget
self._panel_widgets["settings"] = self.settings_panel
self._panel_widgets["log"] = self.log_widget
# Top row: mesh | arrows | charts (order from layout.PANEL_SETUP.top_row)
self.charts_column = QSplitter(Qt.Vertical)
self.charts_column.addWidget(self.force_charts.widget)
if setup.log_panel.enabled:
self.charts_column.addWidget(self.log_widget)
self.log_widget.setVisible(self._log_panel_visible)
self.charts_column.setStretchFactor(0, setup.charts_log_stretch)
self.charts_column.setStretchFactor(1, setup.log_stretch)
self.charts_column.setChildrenCollapsible(False)
h_splitter = QSplitter(Qt.Horizontal)
for slot in setup.top_row.slots:
if not slot.enabled:
continue
h_splitter.addWidget(self._panel_widgets[slot.id])
if slot.id == "charts":
h_splitter.addWidget(self.charts_column)
else:
h_splitter.addWidget(self._panel_widgets[slot.id])
h_splitter.setStretchFactor(h_splitter.count() - 1, slot.stretch)
h_splitter.setChildrenCollapsible(setup.top_row.collapsible)
self.h_splitter = h_splitter
# Vertical: top panel | log panel
v_splitter = QSplitter(Qt.Vertical)
v_splitter.addWidget(h_splitter)
if setup.log_panel.enabled:
v_splitter.addWidget(self.log_widget)
v_splitter.setStretchFactor(0, setup.top_stretch)
v_splitter.setStretchFactor(1, setup.log_stretch)
else:
v_splitter.setStretchFactor(0, 1)
self.v_splitter = v_splitter
root_layout.addWidget(v_splitter)
root_layout.addWidget(h_splitter)
self.resize(layout.window_width, layout.window_height)
QTimer.singleShot(0, self._apply_panel_layout)
def _set_log_panel_visible(self, visible: bool) -> None:
self._log_panel_visible = bool(visible)
self.log_widget.setVisible(self._log_panel_visible)
QTimer.singleShot(0, self._apply_panel_layout)
def _apply_panel_layout(self) -> None:
setup = self.layout.panel_setup
column_height = self.layout.charts_column_height_px()
charts_slot = self.layout.top_slot("charts")
log_slot = setup.log_panel
for slot in setup.top_row.slots:
if not slot.enabled:
continue
if slot.id in ("mesh", "arrows"):
self._panel_widgets[slot.id].set_fixed_size(slot.width_px, slot.height_px)
elif slot.id == "charts":
self.force_charts.set_panel_size(slot.width_px, slot.height_px)
self._panel_widgets[slot.id].set_fixed_size(slot.width_px, column_height)
elif slot.id == "settings":
settings_height = slot.height_px if slot.height_px > 0 else column_height
self.settings_panel.set_panel_size(slot.width_px, settings_height)
if setup.log_panel.enabled and setup.log_panel.height_px > 0:
self.log_widget.setFixedHeight(setup.log_panel.height_px)
if (
self._log_panel_visible
and log_slot.enabled
and log_slot.height_px > 0
):
self.log_widget.show()
self.log_widget.setFixedHeight(log_slot.height_px)
self.force_charts.set_panel_size(charts_slot.width_px, charts_slot.height_px)
self.charts_column.setSizes([charts_slot.height_px, log_slot.height_px])
else:
self.log_widget.hide()
self.force_charts.set_panel_size(charts_slot.width_px, column_height)
self.charts_column.setSizes([column_height, 0])
self.h_splitter.setSizes(list(top_row_splitter_sizes(self.layout)))
@@ -209,28 +200,7 @@ class FingerDashboardWindow(QMainWindow):
sys.stderr = sys.stdout
def _set_play_pause_button(self, state: str) -> None:
"""``run`` = green play, ``starting`` = grey disabled play, ``pause`` = orange pause."""
btn = self.btn_play_pause
if state == "run":
btn.setToolTip("Run")
btn.setEnabled(True)
elif state == "starting":
btn.setToolTip("Starting…")
btn.setEnabled(False)
elif state == "pause":
btn.setToolTip("Pause")
btn.setEnabled(True)
else:
raise ValueError(f"unknown play/pause state: {state!r}")
kind = "pause" if state == "pause" else "play"
btn.setText("")
btn.setIcon(transport_icon(kind, _PLAY_PAUSE_ICON_COLORS[state]))
btn.setProperty("playState", state)
style = btn.style()
style.unpolish(btn)
style.polish(btn)
btn.update()
self.settings_panel.set_play_pause_state(state)
def _on_play_pause_clicked(self) -> None:
if self._running:
@@ -244,9 +214,9 @@ class FingerDashboardWindow(QMainWindow):
self._set_play_pause_button("starting")
QApplication.processEvents()
self.config.video = self.camera_edit.text().strip()
self.config.config = self.config_edit.text().strip()
cal = self.cal_edit.text().strip()
self.config.video = self.settings_panel.video_source()
self.config.config = self.settings_panel.config_path()
cal = self.settings_panel.calibration_path()
self.config.calib = cal or None
try:
@@ -266,7 +236,7 @@ class FingerDashboardWindow(QMainWindow):
self._wall_fps = 0.0
self.timer.start(self.layout.timer_interval_ms)
self._set_play_pause_button("pause")
self.btn_reset.setEnabled(True)
self.settings_panel.btn_reset_tracker.setEnabled(True)
print(
f"▶ 启动 — 摄像机={self.config.video}, 配置={self.config.config}, "
f"标定={self.config.calib or '(none)'}"
@@ -285,7 +255,7 @@ class FingerDashboardWindow(QMainWindow):
self.pipeline.close()
self._set_play_pause_button("run")
self.btn_reset.setEnabled(False)
self.settings_panel.btn_reset_tracker.setEnabled(False)
def _on_reset(self) -> None:
if not self._running:
@@ -371,7 +341,7 @@ class FingerDashboardWindow(QMainWindow):
"视频 (*.mp4 *.avi *.mov);;所有文件 (*)",
)
if path:
self.camera_edit.setText(path)
self.settings_panel.set_video_source(path)
def _select_config(self) -> None:
path, _ = QFileDialog.getOpenFileName(
@@ -381,7 +351,7 @@ class FingerDashboardWindow(QMainWindow):
"JSON (*.json);;所有文件 (*)",
)
if path:
self.config_edit.setText(path)
self.settings_panel.set_config_path(path)
def keyPressEvent(self, event):
key = event.key()
+29 -18
View File
@@ -3,15 +3,13 @@ Finger dashboard layout — edit this file to define the panel hierarchy and siz
Structure:
┌─ toolbar ─────────────────────────────────────────────┐
├─ TOP (stretch: top_stretch) ──────────────────────────┤
│ ┌ mesh ─┬─ arrows ─┬─ charts ─┐ (left → right) │
│ └───────┴───────────┴─────────┘ │
├─ LOG (stretch: log_stretch) ──────────────────────────┤
└───────────────────────────────────────────────────────┘
┌─ toolbar ─────────────────────────────────────────────────────┐
├─ mesh ─┬─ charts ─┬─ settings ────────────────────────────────┤
│ ├─ log ────┤ │
└────────┴──────────┴─────────────────────────────────────────┘
Each panel uses explicit width_px and height_px.
top_stretch / log_stretch control how extra vertical space is shared on resize.
charts_log_stretch / log_stretch control charts vs log height inside the charts column.
MeshLighting (below) controls the 3D mesh panel sun light:
azimuth_deg — horizontal angle in degrees (0° = +X, 90° = +Y)
@@ -37,8 +35,7 @@ Performance (3D dashboard; edit DashboardLayout defaults):
mesh_async_render — background mesh render (Linux only; Windows uses sync)
Edit TOP_ROW.slots to reorder or resize panels; set ``enabled=False`` to hide a panel (e.g. arrows).
Edit PANEL_SETUP to change top vs log proportions.
Edit LOG_CONFIG for live status line and log panel appearance.
Log sits under charts only (not under mesh). Settings is the rightmost column.
"""
from __future__ import annotations
@@ -51,7 +48,7 @@ from viz.contact_arrow import ContactArrowStyle, DEFAULT_CONTACT_ARROW_STYLE
from viz.flow_arrow import FlowArrowStyle
from viz.render_2d import ArrowBackground, ArrowCanvasColors
PanelId = Literal["mesh", "arrows", "charts", "log"]
PanelId = Literal["mesh", "arrows", "charts", "log", "settings"]
@dataclass(frozen=True)
@@ -76,20 +73,21 @@ class PanelRow:
@dataclass(frozen=True)
class PanelSetup:
"""Full panel hierarchy: top row + bottom log."""
"""Full panel hierarchy: top row + log nested under charts."""
top_row: PanelRow
log_panel: PanelSlot
top_stretch: int = 5
charts_log_stretch: int = 5
log_stretch: int = 1
# --- Top strip: mesh | arrows | charts (edit order and sizes here) ---
# --- Top strip: mesh | arrows? | charts | settings (edit order and sizes here) ---
TOP_ROW = PanelRow(
slots=(
PanelSlot("mesh", "3D mesh", width_px=500, height_px=500),
PanelSlot("mesh", "3D mesh", width_px=600, height_px=600),
PanelSlot("arrows", "UV Unrolled", width_px=500, height_px=500, enabled=False),
PanelSlot("charts", "Force charts", width_px=500, height_px=500),
PanelSlot("charts", "Force charts", width_px=400, height_px=500),
PanelSlot("settings", "Settings", width_px=200, height_px=0),
),
)
@@ -98,7 +96,7 @@ LOG_PANEL = PanelSlot("log", "Log", width_px=0, height_px=100)
PANEL_SETUP = PanelSetup(
top_row=TOP_ROW,
log_panel=LOG_PANEL,
top_stretch=5,
charts_log_stretch=5,
log_stretch=1,
)
@@ -225,8 +223,8 @@ class DashboardLayout:
panel_setup: PanelSetup = PANEL_SETUP
window_title: str = "Orisys Finger Dashboard"
window_width: int = 1000
window_height: int = 600
window_width: int = 1100
window_height: int = 620
root_margin_px: int = 6
root_spacing_px: int = 6
@@ -279,6 +277,15 @@ class DashboardLayout:
return slot
return None
def charts_column_height_px(self) -> int:
"""Total height of the charts column (charts + log when enabled)."""
charts = self.top_slot("charts")
height = charts.height_px
log = self.panel_setup.log_panel
if log.enabled and log.height_px > 0:
height += log.height_px
return height
def top_slot(self, panel_id: PanelId) -> PanelSlot:
for slot in self.panel_setup.top_row.slots:
if slot.id == panel_id:
@@ -293,6 +300,10 @@ class DashboardLayout:
def arrows_panel_title(self) -> str:
return self.top_slot("arrows").title
@property
def settings_panel_title(self) -> str:
return self.top_slot("settings").title
LAYOUT = DashboardLayout()
+32
View File
@@ -0,0 +1,32 @@
"""Runtime mesh viewport UI state shared by settings and the 3D mesh panel."""
from __future__ import annotations
from PyQt5.QtCore import QObject, pyqtSignal
from .layout import DashboardLayout, LAYOUT
class MeshViewState(QObject):
"""Mutable runtime toggles for the embedded mesh viewport."""
flow_arrows_changed = pyqtSignal(bool)
def __init__(self, *, flow_arrows_enabled: bool = True):
super().__init__()
self._flow_arrows_enabled = bool(flow_arrows_enabled)
@property
def flow_arrows_enabled(self) -> bool:
return self._flow_arrows_enabled
@classmethod
def from_layout(cls, layout: DashboardLayout = LAYOUT) -> MeshViewState:
return cls(flow_arrows_enabled=layout.mesh_flow_arrow.enabled)
def set_flow_arrows_enabled(self, enabled: bool) -> None:
enabled = bool(enabled)
if enabled == self._flow_arrows_enabled:
return
self._flow_arrows_enabled = enabled
self.flow_arrows_changed.emit(enabled)
+26
View File
@@ -16,6 +16,32 @@ QLabel#panelLabel, QLabel#meshViewport {
background-color: #0b0b0b;
}
QWidget#settingsPanel {
border: 1px solid #262626;
border-radius: 4px;
background-color: #0b0b0b;
}
QWidget#settingsPanel QLabel#settingsTitle {
color: #cccccc;
font-family: __UI_FONT__;
font-size: 13px;
font-weight: 600;
}
QWidget#settingsPanel QLabel#settingsCaption {
color: #777777;
font-family: __UI_FONT__;
font-size: 11px;
}
QWidget#settingsPanel QCheckBox {
color: #999999;
font-family: __UI_FONT__;
font-size: 12px;
spacing: 6px;
}
QPushButton#btnResetView {
padding: 2px;
}
QPushButton {
background-color: #1a1a1a;
color: #999999;
+45 -1
View File
@@ -2,10 +2,11 @@
from __future__ import annotations
import math
from typing import Literal
from PyQt5.QtCore import QPointF, QRectF, Qt
from PyQt5.QtGui import QColor, QIcon, QPainter, QPixmap, QPolygonF
from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap, QPolygonF
TransportKind = Literal["play", "pause"]
@@ -48,3 +49,46 @@ def transport_icon(kind: TransportKind, color: str, *, size: int = _DEFAULT_ICON
painter.end()
return QIcon(pixmap)
def reset_icon(color: str, *, size: int = 16) -> QIcon:
"""Build a circular reset / refresh arrow in ``color`` (#rrggbb)."""
pixmap = QPixmap(size, size)
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.Antialiasing, True)
stroke = max(1.4, size * 0.11)
pen = QPen(QColor(color), stroke, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
painter.setPen(pen)
painter.setBrush(Qt.NoBrush)
margin = size * 0.2
rect = QRectF(margin, margin, size - 2.0 * margin, size - 2.0 * margin)
start_deg = 55.0
painter.drawArc(rect, int(start_deg * 16), int(-280 * 16))
cx, cy = rect.center().x(), rect.center().y()
radius = rect.width() / 2.0
angle_rad = math.radians(start_deg)
tip_x = cx + radius * math.cos(angle_rad)
tip_y = cy - radius * math.sin(angle_rad)
tangent = angle_rad + math.pi / 2.0
head = max(2.0, size * 0.18)
wing = max(2.0, size * 0.14)
painter.setBrush(QColor(color))
painter.setPen(Qt.NoPen)
painter.drawPolygon(
QPolygonF(
[
QPointF(tip_x, tip_y),
QPointF(tip_x - head * math.cos(tangent) + wing * math.sin(tangent),
tip_y + head * math.sin(tangent) + wing * math.cos(tangent)),
QPointF(tip_x - head * math.cos(tangent) - wing * math.sin(tangent),
tip_y + head * math.sin(tangent) - wing * math.cos(tangent)),
]
)
)
painter.end()
return QIcon(pixmap)
+2
View File
@@ -1,5 +1,6 @@
"""Qt widgets for the finger dashboard."""
from .settings_panel import SettingsPanelWidget
from .force_charts import ForceChartWidget
from .log_panel import TeeStream, WidgetLogHandler, make_log_widget
from .mesh_viewport import MeshViewportWidget
@@ -9,6 +10,7 @@ from .image_panel import ImagePanel
__all__ = [
"ArrowPanelWidget",
"SettingsPanelWidget",
"ForceChartWidget",
"MeshViewportWidget",
"ImagePanel",
+76 -1
View File
@@ -4,25 +4,36 @@ from __future__ import annotations
import sys
from PyQt5.QtCore import QEvent, Qt
from PyQt5.QtGui import QMouseEvent
from pipeline.frame import FingerFrameView
from viz.offscreen_mesh import OffscreenFingerMeshRenderer
from ..layout import DashboardLayout, LAYOUT
from ..mesh_view_state import MeshViewState
from ..mesh_render_worker import MeshRenderWorker
from .qt_pixmap import ndarray_to_pixmap
from .image_panel import ImagePanel
_VIEW_ROTATE_SENSITIVITY = 0.35
class MeshViewportWidget(ImagePanel):
"""Render the finger mesh offscreen in a square 1:1 viewport."""
def __init__(self, parent=None, *, layout: DashboardLayout = LAYOUT):
def __init__(self, parent=None, *, layout: DashboardLayout = LAYOUT, mesh_state: MeshViewState | None = None):
self._layout = layout
self._mesh_state = mesh_state or MeshViewState.from_layout(layout)
self._renderer: OffscreenFingerMeshRenderer | None = None
self._mesh_worker: MeshRenderWorker | None = None
self._mesh_map = "depth"
self._threshold = 0.0
self._enable_3d = True
self._free_rotation = False
self._dragging = False
self._last_drag_pos = None
self._last_frame: FingerFrameView | None = None
display_w, display_h = layout.mesh_display_size()
super().__init__(
title=layout.mesh_panel_title,
@@ -30,6 +41,53 @@ class MeshViewportWidget(ImagePanel):
default_side=min(display_w, display_h),
parent=parent,
)
self.label.installEventFilter(self)
self.label.setMouseTracking(True)
self._mesh_state.flow_arrows_changed.connect(self._on_flow_arrows_changed)
def set_free_rotation(self, enabled: bool) -> None:
self._free_rotation = bool(enabled)
if not self._free_rotation:
self._dragging = False
self._last_drag_pos = None
self.label.setCursor(Qt.OpenHandCursor if self._free_rotation else Qt.ArrowCursor)
def _on_flow_arrows_changed(self, enabled: bool) -> None:
if self._renderer is not None:
self._renderer.flow_arrow.set_enabled(enabled)
self._refresh_last_frame()
def reset_view(self) -> None:
if self._renderer is not None:
self._renderer.reset_view()
self._refresh_last_frame()
def eventFilter(self, watched, event):
if watched is self.label and self._free_rotation and self._renderer is not None:
etype = event.type()
if etype == QEvent.MouseButtonPress and isinstance(event, QMouseEvent):
if event.button() == Qt.LeftButton:
self._dragging = True
self._last_drag_pos = event.pos()
self.label.setCursor(Qt.ClosedHandCursor)
return True
elif etype == QEvent.MouseMove and isinstance(event, QMouseEvent):
if self._dragging and self._last_drag_pos is not None:
delta = event.pos() - self._last_drag_pos
self._last_drag_pos = event.pos()
self._renderer.rotate_view(
delta.x() * _VIEW_ROTATE_SENSITIVITY,
delta.y() * _VIEW_ROTATE_SENSITIVITY,
)
self._refresh_last_frame()
return True
elif etype == QEvent.MouseButtonRelease and isinstance(event, QMouseEvent):
if event.button() == Qt.LeftButton and self._dragging:
self._dragging = False
self._last_drag_pos = None
self.label.setCursor(Qt.OpenHandCursor)
return True
return super().eventFilter(watched, event)
def configure(self, pipeline) -> None:
self._mesh_map = pipeline.config.mesh_map
@@ -69,6 +127,7 @@ class MeshViewportWidget(ImagePanel):
contact_arrow_style=self._layout.mesh_contact_arrow,
flow_arrow_style=self._layout.mesh_flow_arrow,
)
self._renderer.flow_arrow.set_enabled(self._mesh_state.flow_arrows_enabled)
def _stop_worker(self) -> None:
if self._mesh_worker is not None:
@@ -82,11 +141,26 @@ class MeshViewportWidget(ImagePanel):
def _on_rgb_ready(self, rgb) -> None:
self.set_source_pixmap(ndarray_to_pixmap(rgb, bgr=False))
def _refresh_last_frame(self) -> None:
if self._last_frame is None or self._renderer is None:
return
frame = self._last_frame
rgb = self._renderer.render(
flow=frame.flow,
scalar_map=frame.depth_map,
centroid=frame.centroid,
centroid_found=frame.centroid_found,
fnormal=frame.fnormal,
valid_mask=frame.valid_mask,
)
self.set_source_pixmap(ndarray_to_pixmap(rgb, bgr=False))
def update_frame(self, frame: FingerFrameView, *, async_render: bool | None = None) -> float:
"""Update mesh colors. Returns render wall time (0 when async submit only)."""
if not self._enable_3d or self._renderer is None:
return 0.0
self._last_frame = frame
use_async = self._layout.mesh_async_render if async_render is None else async_render
if use_async and self._mesh_worker is not None:
self._mesh_worker.submit(
@@ -118,3 +192,4 @@ class MeshViewportWidget(ImagePanel):
if self._renderer is not None:
self._renderer.close()
self._renderer = None
self._last_frame = None
+184
View File
@@ -0,0 +1,184 @@
"""Settings side panel for the finger dashboard."""
from __future__ import annotations
from PyQt5.QtCore import QSize, Qt, pyqtSignal
from PyQt5.QtWidgets import (
QCheckBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from ..layout import DashboardLayout, LAYOUT
from ..mesh_view_state import MeshViewState
from ..transport_icons import reset_icon, transport_icon
_PLAY_PAUSE_BUTTON_NAME = "btnPlayPause"
_PLAY_PAUSE_ICON_PX = 18
_PLAY_PAUSE_ICON_COLORS = {
"run": "#4caf84",
"starting": "#555555",
"pause": "#ff9800",
}
class SettingsPanelWidget(QWidget):
"""Right-side settings: log, sources, and 3D view controls."""
log_visibility_changed = pyqtSignal(bool)
free_rotation_changed = pyqtSignal(bool)
view_reset_requested = pyqtSignal()
browse_video_requested = pyqtSignal()
browse_config_requested = pyqtSignal()
def __init__(
self,
parent=None,
*,
layout: DashboardLayout = LAYOUT,
mesh_state: MeshViewState | None = None,
show_log: bool = True,
):
super().__init__(parent)
self._layout = layout
self._mesh_state = mesh_state or MeshViewState.from_layout(layout)
self.setObjectName("settingsPanel")
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
root = QVBoxLayout(self)
root.setContentsMargins(10, 10, 10, 10)
root.setSpacing(10)
title = QLabel(layout.settings_panel_title)
title.setObjectName("settingsTitle")
root.addWidget(title)
control_row = QHBoxLayout()
control_row.setSpacing(8)
self.btn_play_pause = QPushButton()
self.btn_play_pause.setObjectName(_PLAY_PAUSE_BUTTON_NAME)
self.btn_play_pause.setFixedSize(36, 32)
self.btn_play_pause.setIconSize(QSize(_PLAY_PAUSE_ICON_PX, _PLAY_PAUSE_ICON_PX))
control_row.addWidget(self.btn_play_pause)
self.btn_reset_tracker = QPushButton("Reset Sensor")
self.btn_reset_tracker.setToolTip("Reset tracker")
self.btn_reset_tracker.setEnabled(False)
control_row.addWidget(self.btn_reset_tracker, 1)
root.addLayout(control_row)
self.show_log_check = QCheckBox("Show log panel")
self.show_log_check.setChecked(show_log)
self.show_log_check.toggled.connect(self.log_visibility_changed.emit)
root.addWidget(self.show_log_check)
self.show_flow_arrows_check = QCheckBox("Show displacement arrows")
self.show_flow_arrows_check.setChecked(self._mesh_state.flow_arrows_enabled)
self.show_flow_arrows_check.toggled.connect(self._mesh_state.set_flow_arrows_enabled)
root.addWidget(self.show_flow_arrows_check)
root.addWidget(self._caption("Video source"))
video_row = QHBoxLayout()
video_row.setSpacing(6)
self.video_edit = QLineEdit("0")
self.video_edit.setPlaceholderText("Camera ID or video path")
video_row.addWidget(self.video_edit, 1)
self.btn_browse_video = QPushButton("…")
self.btn_browse_video.setFixedWidth(32)
self.btn_browse_video.setToolTip("Browse video file")
self.btn_browse_video.clicked.connect(self.browse_video_requested.emit)
video_row.addWidget(self.btn_browse_video)
root.addLayout(video_row)
root.addWidget(self._caption("Config"))
config_row = QHBoxLayout()
config_row.setSpacing(6)
self.config_edit = QLineEdit("./config/dd02-ov.json")
config_row.addWidget(self.config_edit, 1)
self.btn_browse_config = QPushButton("…")
self.btn_browse_config.setFixedWidth(32)
self.btn_browse_config.setToolTip("Browse config JSON")
self.btn_browse_config.clicked.connect(self.browse_config_requested.emit)
config_row.addWidget(self.btn_browse_config)
root.addLayout(config_row)
self.cal_edit = QLineEdit("")
self.cal_edit.setPlaceholderText("Optional calibration path")
root.addWidget(self._caption("Calibration"))
root.addWidget(self.cal_edit)
root.addSpacing(8)
rotate_row = QHBoxLayout()
rotate_row.setSpacing(6)
self.free_rotate_check = QCheckBox("Free rotate 3D view")
self.free_rotate_check.toggled.connect(self.free_rotation_changed.emit)
rotate_row.addWidget(self.free_rotate_check, 1)
self.btn_reset_view = QPushButton()
self.btn_reset_view.setObjectName("btnResetView")
self.btn_reset_view.setToolTip("Reset 3D mesh camera")
self.btn_reset_view.setIcon(reset_icon("#999999"))
self.btn_reset_view.setIconSize(QSize(14, 14))
self.btn_reset_view.setFixedSize(28, 28)
self.btn_reset_view.clicked.connect(self.view_reset_requested.emit)
rotate_row.addWidget(self.btn_reset_view)
root.addLayout(rotate_row)
root.addStretch()
@staticmethod
def _caption(text: str) -> QLabel:
label = QLabel(text)
label.setObjectName("settingsCaption")
return label
def set_panel_size(self, width: int, height: int) -> None:
self.setFixedSize(max(int(width), 64), max(int(height), 64))
def set_play_pause_state(self, state: str) -> None:
"""``run`` = green play, ``starting`` = grey disabled play, ``pause`` = orange pause."""
btn = self.btn_play_pause
if state == "run":
btn.setToolTip("Run")
btn.setEnabled(True)
elif state == "starting":
btn.setToolTip("Starting…")
btn.setEnabled(False)
elif state == "pause":
btn.setToolTip("Pause")
btn.setEnabled(True)
else:
raise ValueError(f"unknown play/pause state: {state!r}")
kind = "pause" if state == "pause" else "play"
btn.setText("")
btn.setIcon(transport_icon(kind, _PLAY_PAUSE_ICON_COLORS[state]))
btn.setProperty("playState", state)
style = btn.style()
style.unpolish(btn)
style.polish(btn)
btn.update()
def set_video_source(self, value: str) -> None:
self.video_edit.setText(value)
def set_config_path(self, value: str) -> None:
self.config_edit.setText(value)
def set_calibration_path(self, value: str) -> None:
self.cal_edit.setText(value or "")
def video_source(self) -> str:
return self.video_edit.text().strip()
def config_path(self) -> str:
return self.config_edit.text().strip()
def calibration_path(self) -> str:
return self.cal_edit.text().strip()
+102 -4
View File
@@ -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)
+79
View File
@@ -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)
+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 .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,
*,