update exe GUI build
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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(
|
||||||
|
|||||||
+72
-102
@@ -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
|
||||||
|
if slot.id == "charts":
|
||||||
|
h_splitter.addWidget(self.charts_column)
|
||||||
|
else:
|
||||||
h_splitter.addWidget(self._panel_widgets[slot.id])
|
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
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Runtime mesh viewport UI state shared by settings and the 3D mesh panel."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt5.QtCore import QObject, pyqtSignal
|
||||||
|
|
||||||
|
from .layout import DashboardLayout, LAYOUT
|
||||||
|
|
||||||
|
|
||||||
|
class MeshViewState(QObject):
|
||||||
|
"""Mutable runtime toggles for the embedded mesh viewport."""
|
||||||
|
|
||||||
|
flow_arrows_changed = pyqtSignal(bool)
|
||||||
|
|
||||||
|
def __init__(self, *, flow_arrows_enabled: bool = True):
|
||||||
|
super().__init__()
|
||||||
|
self._flow_arrows_enabled = bool(flow_arrows_enabled)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def flow_arrows_enabled(self) -> bool:
|
||||||
|
return self._flow_arrows_enabled
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_layout(cls, layout: DashboardLayout = LAYOUT) -> MeshViewState:
|
||||||
|
return cls(flow_arrows_enabled=layout.mesh_flow_arrow.enabled)
|
||||||
|
|
||||||
|
def set_flow_arrows_enabled(self, enabled: bool) -> None:
|
||||||
|
enabled = bool(enabled)
|
||||||
|
if enabled == self._flow_arrows_enabled:
|
||||||
|
return
|
||||||
|
self._flow_arrows_enabled = enabled
|
||||||
|
self.flow_arrows_changed.emit(enabled)
|
||||||
@@ -16,6 +16,32 @@ QLabel#panelLabel, QLabel#meshViewport {
|
|||||||
background-color: #0b0b0b;
|
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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user