diff --git a/assets/finger/finger/camera_intrinsics.npz b/assets/finger/finger/camera_intrinsics.npz new file mode 100644 index 0000000..888b1eb Binary files /dev/null and b/assets/finger/finger/camera_intrinsics.npz differ diff --git a/assets/finger/finger/mesh.npz b/assets/finger/finger/mesh.npz new file mode 100644 index 0000000..4da6bb1 Binary files /dev/null and b/assets/finger/finger/mesh.npz differ diff --git a/assets/finger/finger/pixel_to_xyz.npz b/assets/finger/finger/pixel_to_xyz.npz new file mode 100644 index 0000000..15eee73 Binary files /dev/null and b/assets/finger/finger/pixel_to_xyz.npz differ diff --git a/assets/finger/finger/unroll_map_uv_s0.4.npz b/assets/finger/finger/unroll_map_uv_s0.4.npz new file mode 100644 index 0000000..7cbb8cb Binary files /dev/null and b/assets/finger/finger/unroll_map_uv_s0.4.npz differ diff --git a/assets/finger/finger/world_to_cam.npy b/assets/finger/finger/world_to_cam.npy new file mode 100644 index 0000000..3d6f897 Binary files /dev/null and b/assets/finger/finger/world_to_cam.npy differ diff --git a/build/wheel/build.py b/build/wheel/build.py index 09dca0a..2b0a67c 100644 --- a/build/wheel/build.py +++ b/build/wheel/build.py @@ -33,7 +33,11 @@ from common.paths import NUITKA_COMPILE_DIR, NUITKA_WHEEL_DIR, REPO_ROOT, repair from common.pyproject_utils import bundle_items, extras_require, get_version, install_requires from common.verify.wheel import verify_built_wheel from wheel.compile import compile_orisys, copy_compiled_orisys, install_nuitka -from wheel.layout import copy_bundle_items, copy_orisys_blend, package_data_from_bundle +from wheel.layout import ( + copy_bundle_items, + copy_orisys_blend, + package_data_from_bundle, +) from wheel.package import ( is_compliant_manylinux_wheel, publish_wheel_to_sdk, diff --git a/build/wheel/layout.py b/build/wheel/layout.py index 66549e6..f9718e5 100644 --- a/build/wheel/layout.py +++ b/build/wheel/layout.py @@ -5,6 +5,14 @@ from __future__ import annotations import shutil from pathlib import Path +# Required files that must ship with the wheel for ORY-FINGER runtime/GUI. +REQUIRED_ASSET_RELPATHS = ( + "finger/assets/30fps-0703/mesh.npz", + "finger/assets/30fps-0703/camera_intrinsics.npz", + "finger/assets/30fps-0703/world_to_cam.npy", + "finger/assets/visualization/world_to_cam.npy", +) + def resolve_bundle_source_files(repo_root: Path, item: dict) -> list[Path]: """Resolve explicit files and/or glob patterns for one bundle item.""" @@ -37,6 +45,32 @@ def resolve_bundle_source_files(repo_root: Path, item: dict) -> list[Path]: return resolved +def verify_required_assets(repo_root: Path, items: list[dict]) -> None: + """Fail early when required finger assets are missing from the bundle.""" + asset_items = [item for item in items if item.get("name") == "sensors"] + if not asset_items: + return + + resolved: set[str] = set() + for item in asset_items: + src_dir = (repo_root / item["source"]).resolve() + for path in resolve_bundle_source_files(repo_root, item): + try: + rel = path.resolve().relative_to(src_dir).as_posix() + except ValueError: + continue + resolved.add(rel) + + missing = [rel for rel in REQUIRED_ASSET_RELPATHS if rel not in resolved] + if missing: + joined = ", ".join(missing) + raise FileNotFoundError( + "Required finger assets are missing from the wheel bundle. " + f"Missing: {joined}. Update [[tool.orisys.bundle.items]] patterns " + "or restore assets under sensors/finger/assets/ before building." + ) + + def package_data_paths_for_item(item: dict) -> list[str]: """Return setuptools package_data paths relative to the orisys package.""" target = str(item["target"]).replace("\\", "/") @@ -54,6 +88,7 @@ def data_file_entries_for_item(item: dict) -> list[str]: def copy_bundle_items(repo_root: Path, pkg_dir: Path, items: list[dict]) -> dict[str, int]: + verify_required_assets(repo_root, items) copied_counts: dict[str, int] = {} for item in items: name = item["name"] diff --git a/config/finger.json b/config/finger.json index 545a74a..d5f6264 100644 --- a/config/finger.json +++ b/config/finger.json @@ -5,7 +5,7 @@ "camera": { "resolution_raw": [1280, 720], "fps": 30, - "resolution_stitch": [420, 450], + "resolution_stitch": [280, 300], "source_scale": 0.4, "old":[672, 728, 1,0.5], "work": [504, 546,1], @@ -20,7 +20,7 @@ "loop_closure_interval": 1, "algorithm": "dis", "dis_preset": "medium", - "dis_mode": 3, + "dis_mode": 1, "algorithm_list": ["raft", "dis"], "hhd_downsample": 5 }, diff --git a/examples/gui/preference.json b/examples/gui/preference.json index 3599411..c81d1e7 100644 --- a/examples/gui/preference.json +++ b/examples/gui/preference.json @@ -1,10 +1,12 @@ { - "video_source": "D:/repo/orisys-sdk/data/dd02/0607_1.mp4", - "config_path": "./config/dd02-ov.json", - "finger_assets_dir": "D:/repo/orisys-sdk/assets/finger/30fps-0703", + "video_source": "D:/repo/orisys-sdk/data/dd02-0703/1.mp4", + "sensor_bundle_dir": "sensors/finger", + "config_path": "sensors/finger/sensor.json", + "finger_assets_dir": "sensors/finger/assets/30fps-0703", "csv_save_dir": "./outputs/csv", "show_log": true, "show_flow_arrows": false, - "arrow_background": "pure_color", - "free_rotate": true + "arrow_background": "unrolled", + "free_rotate": true, + "log_display_mode": "hidden" } diff --git a/examples/gui/viewer3d/dashboard.py b/examples/gui/viewer3d/dashboard.py index b85828b..13133ec 100644 --- a/examples/gui/viewer3d/dashboard.py +++ b/examples/gui/viewer3d/dashboard.py @@ -2,14 +2,19 @@ from __future__ import annotations +import json import math import sys import time +from pathlib import Path from PyQt5.QtCore import Qt, QTimer from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import ( + QAction, + QActionGroup, QApplication, + QDockWidget, QFileDialog, QMainWindow, QSplitter, @@ -61,6 +66,7 @@ class FingerDashboardWindow(QMainWindow): self._frame_index = 0 self._wall_fps = 0.0 self._wall_fps_alpha = 0.15 + self._log_display_mode = "hidden" self.setWindowTitle(layout.window_title) self.setStyleSheet(STYLE) @@ -84,7 +90,7 @@ class FingerDashboardWindow(QMainWindow): root_layout.setSpacing(layout.root_spacing_px) setup = layout.panel_setup - self._log_panel_visible = setup.log_panel.enabled + self._log_panel_visible = False self.mesh_state = MeshViewState.from_layout(layout) self.mesh_viewport = MeshViewportWidget(layout=layout, mesh_state=self.mesh_state) arrows_slot = layout.slot("arrows") @@ -111,7 +117,6 @@ class FingerDashboardWindow(QMainWindow): self._preferences = load_preferences() self.settings_panel.apply_preference_state(self._preferences) - self._log_panel_visible = bool(self.settings_panel.show_log_check.isChecked()) self.mesh_viewport.set_free_rotation(bool(self.settings_panel.free_rotate_check.isChecked())) if self.arrow_panel is not None: self.arrow_panel.set_background(self.settings_panel.arrow_background()) @@ -152,11 +157,6 @@ class FingerDashboardWindow(QMainWindow): 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) @@ -172,18 +172,104 @@ class FingerDashboardWindow(QMainWindow): self.h_splitter = h_splitter root_layout.addWidget(h_splitter) + self._setup_log_dock() + self._apply_log_display_mode(self._preferences.get("log_display_mode", "hidden")) self.resize(*self._target_window_size()) 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) + if hasattr(self, "log_dock"): + self.log_dock.setVisible(self._log_panel_visible) QTimer.singleShot(0, self._apply_panel_layout) + def _setup_log_dock(self) -> None: + self.log_dock = QDockWidget("Log", self) + self.log_dock.setObjectName("logDock") + self.log_dock.setWidget(self.log_widget) + self.log_dock.setAllowedAreas(Qt.BottomDockWidgetArea | Qt.RightDockWidgetArea) + self.addDockWidget(Qt.BottomDockWidgetArea, self.log_dock) + self.log_dock.hide() + self.log_dock.visibilityChanged.connect(self._on_log_dock_visibility_changed) + + view_menu = self.menuBar().addMenu("View") + self.log_toggle_action = QAction("Log Panel", self) + self.log_toggle_action.setCheckable(True) + self.log_toggle_action.triggered.connect(self._on_log_toggle_action) + view_menu.addAction(self.log_toggle_action) + + log_content_menu = view_menu.addMenu("Log Content") + self.log_content_actions = QActionGroup(self) + self.log_content_actions.setExclusive(False) + for label, mode in ( + ("Runtime log", "runtime"), + ("Bundle info", "bundle"), + ("Performance/profile", "profile"), + ): + action = QAction(label, self) + action.setCheckable(True) + action.setData(mode) + action.triggered.connect(lambda _checked=False, m=mode: self._apply_log_display_mode(m, save=True)) + self.log_content_actions.addAction(action) + log_content_menu.addAction(action) + + def _on_log_toggle_action(self, checked: bool) -> None: + if checked: + mode = self._log_display_mode if self._log_display_mode != "hidden" else "runtime" + self._apply_log_display_mode(mode, save=True) + else: + self._apply_log_display_mode("hidden", save=True) + + def _on_log_dock_visibility_changed(self, visible: bool) -> None: + self._log_panel_visible = bool(visible) + if hasattr(self, "log_toggle_action"): + self.log_toggle_action.blockSignals(True) + self.log_toggle_action.setChecked(visible) + self.log_toggle_action.blockSignals(False) + if not visible and self._log_display_mode != "hidden": + self._log_display_mode = "hidden" + if hasattr(self, "log_content_actions"): + for action in self.log_content_actions.actions(): + action.blockSignals(True) + action.setChecked(False) + action.blockSignals(False) + self._save_preferences() + + def _apply_log_display_mode(self, mode: str | None, *, save: bool = False) -> None: + mode = mode or "hidden" + self._log_display_mode = mode + if hasattr(self, "log_toggle_action"): + self.log_toggle_action.blockSignals(True) + self.log_toggle_action.setChecked(mode != "hidden") + self.log_toggle_action.blockSignals(False) + if hasattr(self, "log_content_actions"): + for action in self.log_content_actions.actions(): + action.blockSignals(True) + action.setChecked(action.data() == mode) + action.blockSignals(False) + if mode == "hidden": + self._set_log_panel_visible(False) + if save: + self._save_preferences() + return + if mode == "bundle": + self.log_widget.setPlainText( + "Sensor bundle\n" + f"Config: {self.settings_panel.config_path()}\n" + f"Assets: {self.settings_panel.finger_export_dir()}\n" + ) + elif mode == "profile": + self.log_widget.setPlainText( + "Performance/profile\n" + "Run with --profile to print per-stage timings in the runtime log.\n" + ) + self._set_log_panel_visible(True) + if save: + self._save_preferences() + def _on_log_visibility_changed(self, visible: bool) -> None: - self._set_log_panel_visible(visible) - self._save_preferences() + self._apply_log_display_mode("runtime" if visible else "hidden", save=True) def _on_free_rotation_changed(self, enabled: bool) -> None: self.mesh_viewport.set_free_rotation(enabled) @@ -196,6 +282,7 @@ class FingerDashboardWindow(QMainWindow): def _save_preferences(self) -> None: prefs = self.settings_panel.preference_state() + prefs["log_display_mode"] = self._log_display_mode self._preferences = prefs save_preferences(prefs) # Keep in-memory pipeline config aligned with panel values. @@ -217,7 +304,6 @@ class FingerDashboardWindow(QMainWindow): scale = self._layout_scale_factor() column_height = self._scaled_top_row_height(scale) charts_slot = self.layout.top_slot("charts") - log_slot = setup.log_panel for slot in setup.top_row.slots: if not slot.enabled: @@ -236,27 +322,11 @@ class FingerDashboardWindow(QMainWindow): settings_height, ) - if ( - self._log_panel_visible - and log_slot.enabled - and log_slot.height_px > 0 - ): - self.log_widget.show() - log_height = self._scaled_px(log_slot.height_px, scale) - charts_height = self._scaled_px(charts_slot.height_px, scale) - self.log_widget.setFixedHeight(log_height) - self.force_charts.set_panel_size( - self._scaled_px(charts_slot.width_px, scale), - charts_height, - ) - self.charts_column.setSizes([charts_height, log_height]) - else: - self.log_widget.hide() - self.force_charts.set_panel_size( - self._scaled_px(charts_slot.width_px, scale), - column_height, - ) - self.charts_column.setSizes([column_height, 0]) + self.force_charts.set_panel_size( + self._scaled_px(charts_slot.width_px, scale), + column_height, + ) + self.charts_column.setSizes([column_height]) self.h_splitter.setSizes( [self._scaled_px(slot.width_px, scale) for slot in setup.top_row.slots if slot.enabled] @@ -279,12 +349,6 @@ class FingerDashboardWindow(QMainWindow): width += margins.left() + margins.right() height = layout.charts_column_height_px() - if ( - self._log_panel_visible - and layout.panel_setup.log_panel.enabled - and layout.panel_setup.log_panel.height_px > 0 - ): - height += self.charts_column.handleWidth() height += margins.top() + margins.bottom() return max(layout.window_width, width), max(layout.window_height, height) @@ -316,12 +380,6 @@ class FingerDashboardWindow(QMainWindow): base_width += max(len(enabled_slots) - 1, 0) * self.h_splitter.handleWidth() base_height = self.layout.charts_column_height_px() - if ( - self._log_panel_visible - and self.layout.panel_setup.log_panel.enabled - and self.layout.panel_setup.log_panel.height_px > 0 - ): - base_height += self.charts_column.handleWidth() if base_width <= 0 or base_height <= 0: return 1.0 return min(available_width / base_width, available_height / base_height) @@ -602,12 +660,27 @@ class FingerDashboardWindow(QMainWindow): def _select_finger_assets(self) -> None: path = QFileDialog.getExistingDirectory( self, - "选择 finger 资产目录", - self.settings_panel.finger_export_dir() or "assets/finger", + "选择传感器 bundle 目录", + self.settings_panel.sensor_bundle_dir() or "sensors/finger", ) if path: - self.settings_panel.set_finger_export_dir(path) - self.config.export_dir = path + bundle_dir = Path(path) + config_path = bundle_dir / "sensor.json" + manifest_path = bundle_dir / "manifest.json" + assets_dir = bundle_dir / "assets" / "30fps-0703" + if manifest_path.is_file(): + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except Exception: + manifest = {} + rel_assets = manifest.get("assets_dir") + if isinstance(rel_assets, str) and rel_assets.strip(): + assets_dir = bundle_dir / rel_assets + if config_path.is_file(): + self.settings_panel.set_config_path(str(config_path)) + self.config.config = str(config_path) + self.settings_panel.set_finger_export_dir(str(assets_dir)) + self.config.export_dir = str(assets_dir) self._save_preferences() def _select_csv_dir(self) -> None: diff --git a/examples/gui/viewer3d/presets.py b/examples/gui/viewer3d/presets.py new file mode 100644 index 0000000..650e1d0 --- /dev/null +++ b/examples/gui/viewer3d/presets.py @@ -0,0 +1,113 @@ +"""Finger-compatible sensor-bundle presets for the 3D dashboard.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +from orisys.finger import DEFAULT_FINGER_EXPORT_DIR + + +def _repo_root_candidates() -> list[Path]: + here = Path(__file__).resolve() + return [ + Path.cwd(), + here.parents[3], # examples/gui/viewer3d -> repo root + ] + + +@dataclass(frozen=True) +class SensorBundlePreset: + label: str + bundle_dir: str + config: str + export_dir: str + + +def _load_manifest(bundle_root: Path) -> dict: + path = bundle_root / "manifest.json" + if not path.is_file(): + return {} + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + + +def _bundle_from_root(bundle_root: Path, *, label: str | None = None) -> SensorBundlePreset | None: + sensor_json = bundle_root / "sensor.json" + if not sensor_json.is_file(): + return None + manifest = _load_manifest(bundle_root) + assets_dir = manifest.get("assets_dir", "assets/30fps-0703") + if not isinstance(assets_dir, str) or not assets_dir.strip(): + assets_dir = "assets/30fps-0703" + rel_bundle = bundle_root.as_posix() + for root in _repo_root_candidates(): + try: + rel_bundle = bundle_root.relative_to(root).as_posix() + break + except ValueError: + continue + return SensorBundlePreset( + label=label or "ORY-FINGER", + bundle_dir=rel_bundle, + config=f"{rel_bundle}/sensor.json", + export_dir=f"{rel_bundle}/{assets_dir}".replace("\\", "/"), + ) + + +def default_finger_bundle() -> SensorBundlePreset: + for root in _repo_root_candidates(): + preset = _bundle_from_root(root / "sensors" / "finger", label="ORY-FINGER") + if preset is not None: + return preset + return SensorBundlePreset( + label="ORY-FINGER", + bundle_dir="sensors/finger", + config="sensors/finger/sensor.json", + export_dir=DEFAULT_FINGER_EXPORT_DIR, + ) + + +def discover_sensor_bundle_presets() -> list[SensorBundlePreset]: + """Return finger-compatible sensor bundle presets.""" + presets = [default_finger_bundle()] + known_dirs = {presets[0].bundle_dir.replace("\\", "/")} + + for root in _repo_root_candidates(): + sensors_root = root / "sensors" + if not sensors_root.is_dir(): + continue + for child in sorted(sensors_root.iterdir()): + if not child.is_dir(): + continue + if child.name != "finger": + continue + preset = _bundle_from_root(child, label="ORY-FINGER") + if preset is None: + continue + key = preset.bundle_dir.replace("\\", "/") + if key in known_dirs: + continue + presets.append(preset) + known_dirs.add(key) + break + + return presets + + +def sensor_bundle_preset_index( + config: str, + export_dir: str, + presets: list[SensorBundlePreset], +) -> int: + config = (config or "").strip().replace("\\", "/") + export_dir = (export_dir or "").strip().replace("\\", "/") + for idx, preset in enumerate(presets): + if config == preset.config and export_dir == preset.export_dir: + return idx + if config.endswith("/sensor.json") and export_dir.endswith("/" + Path(preset.export_dir).name): + return idx + return -1 diff --git a/examples/gui/viewer3d/widgets/settings_panel.py b/examples/gui/viewer3d/widgets/settings_panel.py index 2cfb022..c5c421d 100644 --- a/examples/gui/viewer3d/widgets/settings_panel.py +++ b/examples/gui/viewer3d/widgets/settings_panel.py @@ -15,11 +15,15 @@ from PyQt5.QtWidgets import ( QWidget, ) -from orisys.finger import DEFAULT_FINGER_EXPORT_DIR from viz.render_2d import ArrowBackground from ..layout import DashboardLayout, LAYOUT from ..mesh_view_state import MeshViewState +from ..presets import ( + SensorBundlePreset, + discover_sensor_bundle_presets, + sensor_bundle_preset_index, +) from ..transport_icons import reset_icon, transport_icon _PLAY_PAUSE_BUTTON_NAME = "btnPlayPause" @@ -86,7 +90,7 @@ class SettingsPanelWidget(QWidget): 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_log_check.hide() self.show_flow_arrows_check = QCheckBox("Show displacement arrows") self.show_flow_arrows_check.setChecked(self._mesh_state.flow_arrows_enabled) @@ -134,35 +138,38 @@ class SettingsPanelWidget(QWidget): self._video_file_path = "" self._updating_camera_options = False - root.addWidget(self._caption("Config")) - config_row = QHBoxLayout() - config_row.setSpacing(6) - self.config_edit = QLineEdit("finger") - config_row.addWidget(self.config_edit, 1) + root.addWidget(self._caption("Sensor bundle")) + bundle_row = QHBoxLayout() + bundle_row.setSpacing(6) + self.sensor_bundle_combo = QComboBox() + self.sensor_bundle_combo.setToolTip("Select a finger sensor bundle") + self._bundle_presets = discover_sensor_bundle_presets() + for preset in self._bundle_presets: + self.sensor_bundle_combo.addItem(preset.label, preset) + self.sensor_bundle_combo.addItem("Custom…", None) + self._updating_bundle_combo = False + self.sensor_bundle_combo.currentIndexChanged.connect(self._on_bundle_combo_changed) + bundle_row.addWidget(self.sensor_bundle_combo, 1) + self.btn_browse_finger_assets = QPushButton("…") + self.btn_browse_finger_assets.setFixedWidth(32) + self.btn_browse_finger_assets.setToolTip("Browse sensor bundle folder") + self.btn_browse_finger_assets.clicked.connect(self.browse_finger_assets_requested.emit) + bundle_row.addWidget(self.btn_browse_finger_assets) + root.addLayout(bundle_row) + + self.config_edit = QLineEdit("sensors/finger/sensor.json") + self.config_edit.hide() 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.btn_browse_config.hide() # Optional calibration path is kept for future re-enablement but hidden for now. self.cal_edit = QLineEdit("") self.cal_edit.setPlaceholderText("Optional calibration path") self.cal_edit.hide() - root.addWidget(self._caption("Finger assets")) - finger_assets_row = QHBoxLayout() - finger_assets_row.setSpacing(6) - self.finger_assets_edit = QLineEdit(DEFAULT_FINGER_EXPORT_DIR) - self.finger_assets_edit.setPlaceholderText("Blender export bundle directory") - finger_assets_row.addWidget(self.finger_assets_edit, 1) - self.btn_browse_finger_assets = QPushButton("…") - self.btn_browse_finger_assets.setFixedWidth(32) - self.btn_browse_finger_assets.setToolTip("Browse finger asset directory") - self.btn_browse_finger_assets.clicked.connect(self.browse_finger_assets_requested.emit) - finger_assets_row.addWidget(self.btn_browse_finger_assets) - root.addLayout(finger_assets_row) + self.finger_assets_edit = QLineEdit("sensors/finger/assets/30fps-0703") + self.finger_assets_edit.hide() + self._apply_bundle_preset(self._bundle_presets[0]) root.addWidget(self._caption("CSV save directory")) csv_dir_row = QHBoxLayout() @@ -337,16 +344,18 @@ class SettingsPanelWidget(QWidget): def set_config_path(self, value: str) -> None: self.config_edit.setText(value) + self._sync_bundle_combo_from_paths() def set_calibration_path(self, value: str) -> None: self.cal_edit.setText(value or "") def set_finger_export_dir(self, value: str) -> None: self.finger_assets_edit.setText(value) + self._sync_bundle_combo_from_paths() def set_finger_export_dir_enabled(self, enabled: bool) -> None: - self.finger_assets_edit.setEnabled(enabled) self.btn_browse_finger_assets.setEnabled(enabled) + self.sensor_bundle_combo.setEnabled(enabled) def set_csv_save_dir(self, value: str) -> None: self.csv_dir_edit.setText(value) @@ -387,6 +396,33 @@ class SettingsPanelWidget(QWidget): return self.csv_record_toggled.emit(not self._csv_recording) + def _apply_bundle_preset(self, preset: SensorBundlePreset) -> None: + self.config_edit.setText(preset.config) + self.finger_assets_edit.setText(preset.export_dir) + + def _on_bundle_combo_changed(self, _index: int) -> None: + if self._updating_bundle_combo: + return + preset = self.sensor_bundle_combo.currentData() + if isinstance(preset, SensorBundlePreset): + self._apply_bundle_preset(preset) + + def _sync_bundle_combo_from_paths(self) -> None: + idx = sensor_bundle_preset_index( + self.config_edit.text(), + self.finger_assets_edit.text(), + self._bundle_presets, + ) + if idx < 0: + idx = self.sensor_bundle_combo.findData(None) + if idx < 0: + return + self._updating_bundle_combo = True + self.sensor_bundle_combo.blockSignals(True) + self.sensor_bundle_combo.setCurrentIndex(idx) + self.sensor_bundle_combo.blockSignals(False) + self._updating_bundle_combo = False + def _emit_arrow_background_changed(self, _index: int) -> None: value = self.arrow_background_combo.currentData() if value: @@ -413,6 +449,15 @@ class SettingsPanelWidget(QWidget): def finger_export_dir(self) -> str: return self.finger_assets_edit.text().strip() + def sensor_bundle_dir(self) -> str: + preset = self.sensor_bundle_combo.currentData() + if isinstance(preset, SensorBundlePreset): + return preset.bundle_dir + config = self.config_path() + if config.endswith("/sensor.json") or config.endswith("\\sensor.json"): + return config.rsplit("/", 1)[0].rsplit("\\", 1)[0] + return "" + def csv_save_dir(self) -> str: return self.csv_dir_edit.text().strip() @@ -420,6 +465,7 @@ class SettingsPanelWidget(QWidget): """Serialize user-facing settings suitable for preference.json.""" return { "video_source": self.video_source(), + "sensor_bundle_dir": self.sensor_bundle_dir(), "config_path": self.config_path(), "finger_assets_dir": self.finger_export_dir(), "csv_save_dir": self.csv_save_dir(), @@ -465,6 +511,8 @@ class SettingsPanelWidget(QWidget): if isinstance(assets, str) and assets.strip(): self.set_finger_export_dir(assets.strip()) + self._sync_bundle_combo_from_paths() + csv_dir = state.get("csv_save_dir") if isinstance(csv_dir, str) and csv_dir.strip(): self.set_csv_save_dir(csv_dir.strip()) diff --git a/pyproject.toml b/pyproject.toml index 5bafe47..7517307 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,14 @@ patterns = ["*.json"] # files = ["dd02-ov.json", "ddjx01.json"] dev_install = true +[[tool.orisys.bundle.items]] +name = "sensors" +source = "sensors" +target = "sensors" +patterns = ["**/*.json", "finger/assets/30fps*/*", "finger/assets/visualization/*"] +preserve_tree = true +dev_install = true + [[tool.orisys.bundle.items]] name = "examples" source = "examples" @@ -88,11 +96,3 @@ source = "docs" target = "docs" patterns = ["*.md", "*.pdf"] -[[tool.orisys.bundle.items]] -name = "assets" -source = "assets" -target = "assets" -patterns = ["finger/30fps/*", "finger/visualization/*"] -preserve_tree = true -dev_install = true - diff --git a/sensors/box/force_calibration.json b/sensors/box/force_calibration.json new file mode 100644 index 0000000..4617082 --- /dev/null +++ b/sensors/box/force_calibration.json @@ -0,0 +1,8 @@ +{ + "normal_force": { + "model": "linear", + "slope": 3.92685554668795e-05, + "offset": 0.0217596967278535, + "unit": "N" + } +} diff --git a/sensors/box/sensor.json b/sensors/box/sensor.json new file mode 100644 index 0000000..af88413 --- /dev/null +++ b/sensors/box/sensor.json @@ -0,0 +1,46 @@ +{ + "name": "prototype", + "version": "1.0", + "camera": { + "resolution_raw": [1920, 1080], + "fps": 30, + "units": 15, + "units_used": 9, + "resolution_stitch": [365, 365], + "stitch_res_list": [365,263,123,63] + }, + "stitcher": { + "min_area_frac": 0.01, + "max_area_frac": 0.05, + "max_aspect_ratio": 50, + "index": [1,2,3,6,7,8,11,12,13], + "margin": 10, + "nominal_ov_pos": 100, + "n": [3,3], + "row_tol": 0.6, + "box_size": [250, 250], + "blend":"numba", + "blend_list": ["numpy", "numba","none"] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 3, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 3 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold":5, + "div_threshold":0.05, + "expansion_threshold": 0.01, + "kernel_size": [5,5], + "denoise_sigma": 12 + } +} + + diff --git a/sensors/finger/assets/30fps-0703/README.md b/sensors/finger/assets/30fps-0703/README.md new file mode 100644 index 0000000..24a039e --- /dev/null +++ b/sensors/finger/assets/30fps-0703/README.md @@ -0,0 +1,9 @@ +Place the ORY-FINGER runtime asset bundle files in this directory before +building release wheels: + +- `mesh.npz` +- `camera_intrinsics.npz` +- `world_to_cam.npy` + +These binary assets are validated by the wheel build and are intentionally not +generated by the package build step. diff --git a/sensors/finger/assets/30fps-0703/camera_intrinsics.npz b/sensors/finger/assets/30fps-0703/camera_intrinsics.npz new file mode 100644 index 0000000..888b1eb Binary files /dev/null and b/sensors/finger/assets/30fps-0703/camera_intrinsics.npz differ diff --git a/sensors/finger/assets/30fps-0703/mesh.npz b/sensors/finger/assets/30fps-0703/mesh.npz new file mode 100644 index 0000000..4da6bb1 Binary files /dev/null and b/sensors/finger/assets/30fps-0703/mesh.npz differ diff --git a/sensors/finger/assets/30fps-0703/pixel_to_xyz.npz b/sensors/finger/assets/30fps-0703/pixel_to_xyz.npz new file mode 100644 index 0000000..15eee73 Binary files /dev/null and b/sensors/finger/assets/30fps-0703/pixel_to_xyz.npz differ diff --git a/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz b/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz new file mode 100644 index 0000000..995cee0 Binary files /dev/null and b/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz differ diff --git a/sensors/finger/assets/30fps-0703/world_to_cam.npy b/sensors/finger/assets/30fps-0703/world_to_cam.npy new file mode 100644 index 0000000..3d6f897 Binary files /dev/null and b/sensors/finger/assets/30fps-0703/world_to_cam.npy differ diff --git a/sensors/finger/assets/visualization/README.md b/sensors/finger/assets/visualization/README.md new file mode 100644 index 0000000..fe08404 --- /dev/null +++ b/sensors/finger/assets/visualization/README.md @@ -0,0 +1,6 @@ +Place visualization camera assets in this directory before building release +wheels: + +- `world_to_cam.npy` + +The 3D dashboard uses this pose as the default view preset when available. diff --git a/sensors/finger/assets/visualization/camera_intrinsics.npz b/sensors/finger/assets/visualization/camera_intrinsics.npz new file mode 100644 index 0000000..1d211d5 Binary files /dev/null and b/sensors/finger/assets/visualization/camera_intrinsics.npz differ diff --git a/sensors/finger/assets/visualization/flatten_remap.npz b/sensors/finger/assets/visualization/flatten_remap.npz new file mode 100644 index 0000000..02ca287 Binary files /dev/null and b/sensors/finger/assets/visualization/flatten_remap.npz differ diff --git a/sensors/finger/assets/visualization/mesh.npz b/sensors/finger/assets/visualization/mesh.npz new file mode 100644 index 0000000..ad81542 Binary files /dev/null and b/sensors/finger/assets/visualization/mesh.npz differ diff --git a/sensors/finger/assets/visualization/pixel_to_xyz.npz b/sensors/finger/assets/visualization/pixel_to_xyz.npz new file mode 100644 index 0000000..4096205 Binary files /dev/null and b/sensors/finger/assets/visualization/pixel_to_xyz.npz differ diff --git a/sensors/finger/assets/visualization/world_to_cam.npy b/sensors/finger/assets/visualization/world_to_cam.npy new file mode 100644 index 0000000..2d5e411 Binary files /dev/null and b/sensors/finger/assets/visualization/world_to_cam.npy differ diff --git a/sensors/finger/manifest.json b/sensors/finger/manifest.json new file mode 100644 index 0000000..27f92c8 --- /dev/null +++ b/sensors/finger/manifest.json @@ -0,0 +1,6 @@ +{ + "name": "finger", + "sensor_json": "sensor.json", + "assets_dir": "assets/30fps-0703", + "viz_pose": "assets/visualization/world_to_cam.npy" +} diff --git a/sensors/finger/sensor.json b/sensors/finger/sensor.json new file mode 100644 index 0000000..d5f6264 --- /dev/null +++ b/sensors/finger/sensor.json @@ -0,0 +1,35 @@ +{ + "name": "dd02-ov", + "type": "single_camera", + "version": "1.0", + "camera": { + "resolution_raw": [1280, 720], + "fps": 30, + "resolution_stitch": [280, 300], + "source_scale": 0.4, + "old":[672, 728, 1,0.5], + "work": [504, 546,1], + "work2": [420, 450,0.8], + "work3": [350, 375,4,0.7], + "work4": [280, 300,4,0.4,0.2] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 1, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 5 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold": 0.2, + "div_threshold": 0.022, + "expansion_threshold": 0.01, + "kernel_size": [5, 5], + "denoise_sigma": 12 + } +} diff --git a/sensors/mini/force_calibration.json b/sensors/mini/force_calibration.json new file mode 100644 index 0000000..4617082 --- /dev/null +++ b/sensors/mini/force_calibration.json @@ -0,0 +1,8 @@ +{ + "normal_force": { + "model": "linear", + "slope": 3.92685554668795e-05, + "offset": 0.0217596967278535, + "unit": "N" + } +} diff --git a/sensors/mini/sensor.json b/sensors/mini/sensor.json new file mode 100644 index 0000000..af88413 --- /dev/null +++ b/sensors/mini/sensor.json @@ -0,0 +1,46 @@ +{ + "name": "prototype", + "version": "1.0", + "camera": { + "resolution_raw": [1920, 1080], + "fps": 30, + "units": 15, + "units_used": 9, + "resolution_stitch": [365, 365], + "stitch_res_list": [365,263,123,63] + }, + "stitcher": { + "min_area_frac": 0.01, + "max_area_frac": 0.05, + "max_aspect_ratio": 50, + "index": [1,2,3,6,7,8,11,12,13], + "margin": 10, + "nominal_ov_pos": 100, + "n": [3,3], + "row_tol": 0.6, + "box_size": [250, 250], + "blend":"numba", + "blend_list": ["numpy", "numba","none"] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 3, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 3 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold":5, + "div_threshold":0.05, + "expansion_threshold": 0.01, + "kernel_size": [5,5], + "denoise_sigma": 12 + } +} + + diff --git a/src/orisys/configs.py b/src/orisys/configs.py index 120574b..27bed95 100644 --- a/src/orisys/configs.py +++ b/src/orisys/configs.py @@ -1,4 +1,7 @@ import json +from contextlib import ExitStack +from dataclasses import dataclass +from importlib.resources import as_file, files from pathlib import Path # Product-facing names that map to on-disk config stems. @@ -8,6 +11,18 @@ CONFIG_ALIASES = { "dd02": "dd02-ov", } +PRODUCT_BUNDLES = ("box", "mini", "finger") +_RESOURCE_STACK = ExitStack() + + +def _resource_path(*parts: str) -> Path | None: + """Resolve a package resource to a filesystem path for wheel-safe loaders.""" + try: + resource = files("orisys").joinpath(*parts) + return _RESOURCE_STACK.enter_context(as_file(resource)) + except Exception: + return None + def _candidate_config_dirs() -> list[Path]: """ @@ -28,6 +43,26 @@ def _candidate_config_dirs() -> list[Path]: return unique +def _candidate_sensor_roots() -> list[Path]: + """Candidate roots that contain per-sensor bundle folders.""" + here = Path(__file__).resolve().parent + candidates = [] + packaged = _resource_path("sensors") + if packaged is not None: + candidates.append(packaged) # packaged wheel layout: orisys/sensors/ + candidates.extend([ + here / "sensors", + here.parent.parent / "sensors", # editable install: repo_root/sensors/ + here.parent / "sensors", + Path.cwd() / "sensors", + ]) + unique: list[Path] = [] + for path in candidates: + if path not in unique: + unique.append(path) + return unique + + def _get_config_dir() -> Path: for candidate in _candidate_config_dirs(): if candidate.exists() and any(candidate.glob("*.json")): @@ -50,14 +85,96 @@ def _load_json(path: Path) -> dict: return json.load(f) +@dataclass(frozen=True) +class SensorBundle: + """Resolved paths for one additive per-sensor package folder.""" + + name: str + root: Path + sensor_json: Path + force_calibration_json: Path | None = None + manifest_json: Path | None = None + assets_dir: Path | None = None + + +def resolve_sensor_bundle(name: str) -> SensorBundle | None: + """Resolve ``sensors//`` when present; otherwise return ``None``.""" + requested = CONFIG_ALIASES.get(str(name), str(name)) + for root in _candidate_sensor_roots(): + bundle_root = root / requested + sensor_json = bundle_root / "sensor.json" + if not sensor_json.is_file(): + continue + force_path = bundle_root / "force_calibration.json" + manifest_path = bundle_root / "manifest.json" + assets_dir = None + if manifest_path.is_file(): + try: + manifest = _load_json(manifest_path) + except Exception: + manifest = {} + rel_assets = manifest.get("assets_dir") + if isinstance(rel_assets, str) and rel_assets.strip(): + candidate = (bundle_root / rel_assets).resolve() + if candidate.is_dir(): + assets_dir = candidate + return SensorBundle( + name=requested, + root=bundle_root.resolve(), + sensor_json=sensor_json.resolve(), + force_calibration_json=force_path.resolve() if force_path.is_file() else None, + manifest_json=manifest_path.resolve() if manifest_path.is_file() else None, + assets_dir=assets_dir, + ) + return None + + +def resolve_sensor_config_path(name: str) -> Path | None: + """Return ``sensor.json`` for a resolved sensor bundle.""" + bundle = resolve_sensor_bundle(name) + return bundle.sensor_json if bundle is not None else None + + +def resolve_sensor_asset_dir(name: str) -> Path | None: + """Return the default asset directory for a resolved sensor bundle.""" + bundle = resolve_sensor_bundle(name) + return bundle.assets_dir if bundle is not None else None + + +def resolve_sensor_resource(name: str, *relative_parts: str) -> Path | None: + """Return a path inside one sensor bundle when present.""" + bundle = resolve_sensor_bundle(name) + if bundle is None: + return None + return bundle.root.joinpath(*relative_parts) + + +def list_sensor_bundles() -> list[str]: + """Return product names that have an additive ``sensors//`` folder.""" + found: list[str] = [] + for name in PRODUCT_BUNDLES: + if resolve_sensor_bundle(name) is not None: + found.append(name) + return found + + +def load_force_calibration(name: str) -> dict | None: + """Load ``force_calibration.json`` from a sensor bundle when available.""" + bundle = resolve_sensor_bundle(name) + if bundle is None or bundle.force_calibration_json is None: + return None + return _load_json(bundle.force_calibration_json) + + def load_config(config_name_or_path): """ Load sensor configuration from a JSON file path or config name. Config lookup order: 1. Exact file path (absolute or relative) - 2. File stem under the config directory (e.g. ``box`` -> ``config/box.json``) - 3. JSON field ``"name"`` matching the requested name (keeps ``prototype`` compatible) + 2. Additive sensor bundle ``sensors//sensor.json`` + 3. File stem under the config directory (e.g. ``box`` -> ``config/box.json``) + 4. JSON field ``"name"`` matching the requested name (keeps ``prototype`` compatible) """ config_path = Path(config_name_or_path) @@ -75,6 +192,11 @@ def load_config(config_name_or_path): if alias and alias not in candidates: candidates.append(alias) + for name in candidates: + bundle = resolve_sensor_bundle(name) + if bundle is not None: + return _load_json(bundle.sensor_json) + for name in candidates: direct = CONFIG_DIR / f"{name}.json" if direct.exists(): @@ -103,7 +225,11 @@ def list_config(): """ List all available JSON configuration files by file stem. """ - return [f.stem for f in _config_files()] + stems = [f.stem for f in _config_files()] + for name in list_sensor_bundles(): + if name not in stems: + stems.append(name) + return sorted(stems) def export_config(config_name=None, path="./config/exported_config.json"): diff --git a/src/orisys/finger/exports.py b/src/orisys/finger/exports.py index d451df7..82b7e8a 100644 --- a/src/orisys/finger/exports.py +++ b/src/orisys/finger/exports.py @@ -4,6 +4,7 @@ import sys from dataclasses import dataclass from pathlib import Path +from ..configs import resolve_sensor_asset_dir from ..unroll import ( compute_uv_distortion_metrics, load_exported_data, @@ -11,7 +12,7 @@ from ..unroll import ( scaled_map_cache_path, ) -DEFAULT_FINGER_EXPORT_DIR = "assets/finger/30fps-0703" +DEFAULT_FINGER_EXPORT_DIR = "sensors/finger/assets/30fps-0703" DEFAULT_FINGER_MAP_CACHE = f"{DEFAULT_FINGER_EXPORT_DIR}/unroll_map_uv.npz" @@ -44,11 +45,15 @@ def _candidate_export_dirs(export_dir) -> list[Path]: return [raw] here = Path(__file__).resolve() - candidates = [ + candidates = [] + bundled_assets = resolve_sensor_asset_dir("finger") + if bundled_assets is not None and raw.as_posix() == DEFAULT_FINGER_EXPORT_DIR: + candidates.append(bundled_assets) + candidates.extend([ Path.cwd() / raw, - here.parents[1] / raw, # packaged wheel: orisys/assets/... + here.parents[1] / raw, # packaged wheel filesystem layout 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] = [] diff --git a/src/orisys/signal_processing.py b/src/orisys/signal_processing.py index c020d94..4bfaa13 100644 --- a/src/orisys/signal_processing.py +++ b/src/orisys/signal_processing.py @@ -10,6 +10,7 @@ import numpy as np from scipy.signal import butter, filtfilt, sosfilt, sosfilt_zi # Linear calibration from raw normal-force units to Newtons (ORY-BOX / ORY-MINI). +# Prefer sensors//force_calibration.json when available. NORMAL_FORCE_CAL_SLOPE = 3.92685554668795e-05 NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535 @@ -17,9 +18,28 @@ DEFAULT_SAMPLE_RATE_HZ = 30.0 DEFAULT_LOWPASS_ORDER = 2 -def calibrate_normal_force(raw_value: float) -> float: +def _load_normal_force_coeffs(sensor_name: str | None = None) -> tuple[float, float]: + if sensor_name: + try: + from .configs import load_force_calibration + + data = load_force_calibration(sensor_name) + except Exception: + data = None + if isinstance(data, dict): + block = data.get("normal_force", data) + if isinstance(block, dict): + slope = block.get("slope") + offset = block.get("offset") + if slope is not None and offset is not None: + return float(slope), float(offset) + return NORMAL_FORCE_CAL_SLOPE, NORMAL_FORCE_CAL_OFFSET + + +def calibrate_normal_force(raw_value: float, *, sensor_name: str | None = None) -> float: """Convert a raw normal-force reading to Newtons using y = mx + c.""" - return NORMAL_FORCE_CAL_SLOPE * float(raw_value) + NORMAL_FORCE_CAL_OFFSET + slope, offset = _load_normal_force_coeffs(sensor_name) + return slope * float(raw_value) + offset def _nyquist_hz(sample_rate_hz: float) -> float: