update examples to fix path issues

This commit is contained in:
ykcheungab
2026-08-26 19:38:47 +08:00
parent d59e1cb7fb
commit 32d4164471
8 changed files with 284 additions and 256 deletions
+2 -3
View File
@@ -1,6 +1,5 @@
{ {
"video_source": "D:/repo/orisys-sdk/data/dd02-0703/tune.mp4", "video_source": "D:/repo/orisys-sdk/data/dd02-0703/1.mp4",
"sensor_bundle_dir": "sensors/finger",
"config_path": "finger", "config_path": "finger",
"finger_assets_dir": "sensors/finger/assets/30fps-0703", "finger_assets_dir": "sensors/finger/assets/30fps-0703",
"csv_save_dir": "./outputs/csv", "csv_save_dir": "./outputs/csv",
@@ -8,5 +7,5 @@
"show_flow_arrows": false, "show_flow_arrows": false,
"arrow_background": "unrolled", "arrow_background": "unrolled",
"free_rotate": true, "free_rotate": true,
"log_display_mode": "hidden" "sensor_bundle_dir": "sensors/finger"
} }
+10 -33
View File
@@ -11,7 +11,6 @@ from typing import Any, Optional
import cv2 import cv2
import numpy as np import numpy as np
import orisys import orisys
from orisys.signal_processing import calibrate_normal_force
from .roi_geometry import ( from .roi_geometry import (
build_roi_warp, build_roi_warp,
@@ -23,6 +22,10 @@ from .roi_geometry import (
) )
NORMAL_FORCE_CAL_SLOPE = 3.92685554668795e-05
NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535
@dataclass @dataclass
class FrameSnapshot: class FrameSnapshot:
"""Normalized per-frame payload consumed by the Qt viewer.""" """Normalized per-frame payload consumed by the Qt viewer."""
@@ -46,6 +49,11 @@ class FrameSnapshot:
record_frame: Optional[np.ndarray] = None record_frame: Optional[np.ndarray] = None
def calibrate_normal_force(raw_value: float) -> float:
"""Convert raw normal-force reading to Newtons using y = mx + c."""
return NORMAL_FORCE_CAL_SLOPE * float(raw_value) + NORMAL_FORCE_CAL_OFFSET
class SensorAdapter(ABC): class SensorAdapter(ABC):
"""Common interface between Qt and concrete sensor backends.""" """Common interface between Qt and concrete sensor backends."""
@@ -76,37 +84,11 @@ def _bgr_from_gray_or_bgr(img: np.ndarray) -> np.ndarray:
return img return img
def _resolve_calibration_sensor_name(config_name) -> str | None:
"""Map a config path/name to a sensors/<name> bundle when one exists."""
if config_name is None:
return None
from orisys.configs import resolve_sensor_bundle
text = str(config_name).strip()
if not text:
return None
path = Path(text)
parts = path.parts
if "sensors" in parts:
idx = parts.index("sensors")
if idx + 1 < len(parts):
candidate = parts[idx + 1]
if resolve_sensor_bundle(candidate) is not None:
return candidate
stem = path.stem if path.suffix else text
if resolve_sensor_bundle(stem) is not None:
return stem
return None
def _snapshot_from_sensor( def _snapshot_from_sensor(
sensor, sensor,
*, *,
raw_image=None, raw_image=None,
processed_image=None, processed_image=None,
sensor_name: str | None = None,
) -> Optional[FrameSnapshot]: ) -> Optional[FrameSnapshot]:
"""Build a FrameSnapshot with HHD/forces refreshed every frame.""" """Build a FrameSnapshot with HHD/forces refreshed every frame."""
if sensor is None or sensor.img is None: if sensor is None or sensor.img is None:
@@ -150,9 +132,6 @@ def _snapshot_from_sensor(
below_threshold_color=(160, 160, 160), below_threshold_color=(160, 160, 160),
) )
cal_name = sensor_name or _resolve_calibration_sensor_name(
getattr(sensor, "config_name", None)
)
fn_raw = float(fnormal) fn_raw = float(fnormal)
return FrameSnapshot( return FrameSnapshot(
image=image_bgr, image=image_bgr,
@@ -163,7 +142,7 @@ def _snapshot_from_sensor(
normal_force=fn_raw, normal_force=fn_raw,
shear_x=float(fshearx), shear_x=float(fshearx),
shear_y=float(fsheary), shear_y=float(fsheary),
normal_force_calibrated=calibrate_normal_force(fn_raw, sensor_name=cal_name), normal_force_calibrated=calibrate_normal_force(fn_raw),
fps=float(fps), fps=float(fps),
timestamp=time.time(), timestamp=time.time(),
raw_image=raw_image, raw_image=raw_image,
@@ -227,7 +206,6 @@ class OrisysSensorAdapter(SensorAdapter):
if self.sensor.frame is not None if self.sensor.frame is not None
else None, else None,
processed_image=_bgr_from_gray_or_bgr(self.sensor.img), processed_image=_bgr_from_gray_or_bgr(self.sensor.img),
sensor_name=_resolve_calibration_sensor_name(self.config_name),
) )
def reset(self) -> None: def reset(self) -> None:
@@ -304,7 +282,6 @@ class Dd06RoiAdapter(SensorAdapter):
self.sensor, self.sensor,
raw_image=frame, raw_image=frame,
processed_image=rectified, processed_image=rectified,
sensor_name=_resolve_calibration_sensor_name(self.config_name),
) )
def reset(self) -> None: def reset(self) -> None:
+27 -1
View File
@@ -13,7 +13,7 @@ from typing import Literal, Optional, Sequence, Tuple
import cv2 import cv2
import numpy as np import numpy as np
import orisys import orisys
from orisys.signal_processing import lowpass_filter from scipy.signal import butter, filtfilt
FORCE_SAMPLE_RATE_HZ = 30.0 FORCE_SAMPLE_RATE_HZ = 30.0
PLOT_BUFFER_POINTS = 120 PLOT_BUFFER_POINTS = 120
@@ -61,6 +61,32 @@ class ForcePlotState:
y_ranges: Tuple[Tuple[float, float], Tuple[float, float], Tuple[float, float]] y_ranges: Tuple[Tuple[float, float], Tuple[float, float], Tuple[float, float]]
def lowpass_filter(
data,
cutoff_hz: float,
sample_rate_hz: float = FORCE_SAMPLE_RATE_HZ,
order: int = 2,
) -> np.ndarray:
"""Zero-phase Butterworth low-pass for uniformly sampled 1-D data."""
y = np.asarray(data, dtype=np.float64)
if cutoff_hz == 0:
return y.copy()
if y.size == 0:
return y
nyquist = 0.5 * sample_rate_hz
if cutoff_hz < 0 or cutoff_hz >= nyquist:
raise ValueError(
f"cutoff_hz must be in (0, {nyquist}) for sample_rate_hz={sample_rate_hz}"
)
b, a = butter(order, cutoff_hz / nyquist, btype="low")
padlen = 3 * (max(len(b), len(a)) - 1)
if y.size <= padlen:
return y.copy()
return filtfilt(b, a, y)
def ensure_bgr(img: Optional[np.ndarray]) -> Optional[np.ndarray]: def ensure_bgr(img: Optional[np.ndarray]) -> Optional[np.ndarray]:
if img is None: if img is None:
return None return None
+134 -17
View File
@@ -1,22 +1,139 @@
"""Discover USB cameras for the dashboard. """Discover USB cameras for the dashboard (Windows DSHOW/MSMF, Linux V4L2)."""
Thin compatibility shim over ``orisys.capture`` discovery helpers.
"""
from __future__ import annotations from __future__ import annotations
from orisys.capture import ( import contextlib
DEFAULT_MAX_CAMERA_INDEX, import os
CameraFilter, import sys
CameraInfo, from dataclasses import dataclass
list_cameras,
pick_default_camera, import cv2
DEFAULT_MAX_CAMERA_INDEX = 10
@dataclass(frozen=True)
class CameraInfo:
index: int
backend_name: str
backend_api: int
width: int
height: int
@property
def source(self) -> str:
return str(self.index)
def label(self) -> str:
return f"{self.index} ({self.width}x{self.height}) [{self.backend_name}]"
@contextlib.contextmanager
def _suppress_opencv_noise():
"""Hide OpenCV VideoCapture probe warnings (stderr + OpenCV log)."""
prev_level = None
set_level = None
try:
logging = getattr(getattr(cv2, "utils", None), "logging", None)
if logging is not None:
set_level = getattr(logging, "setLogLevel", None)
get_level = getattr(logging, "getLogLevel", None)
silent = getattr(logging, "LOG_LEVEL_SILENT", 0)
if set_level is not None:
if get_level is not None:
prev_level = get_level()
set_level(silent)
except Exception:
prev_level = None
devnull = open(os.devnull, "w")
try:
with contextlib.redirect_stderr(devnull):
stderr_fd = sys.stderr.fileno()
saved_fd = os.dup(stderr_fd)
try:
os.dup2(devnull.fileno(), stderr_fd)
yield
finally:
os.dup2(saved_fd, stderr_fd)
os.close(saved_fd)
finally:
devnull.close()
if set_level is not None and prev_level is not None:
try:
set_level(prev_level)
except Exception:
pass
def _backend_candidates() -> list[tuple[str, int]]:
candidates: list[tuple[str, int]] = []
if sys.platform == "win32":
if hasattr(cv2, "CAP_DSHOW"):
candidates.append(("DSHOW", cv2.CAP_DSHOW))
if hasattr(cv2, "CAP_MSMF"):
candidates.append(("MSMF", cv2.CAP_MSMF))
elif sys.platform.startswith("linux"):
# Prefer v4l2 for USB camera indices on Linux.
if hasattr(cv2, "CAP_V4L2"):
candidates.append(("V4L2", cv2.CAP_V4L2))
if not candidates:
candidates.append(("ANY", cv2.CAP_ANY))
return candidates
def _try_open(index: int, backend_name: str, backend_api: int) -> CameraInfo | None:
cap = cv2.VideoCapture(index, backend_api)
if not cap.isOpened():
cap.release()
return None
ok, frame = cap.read()
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
if not ok or frame is None:
if width <= 0 or height <= 0:
cap.release()
return None
else:
h, w = frame.shape[:2]
width = width or int(w)
height = height or int(h)
cap.release()
return CameraInfo(
index=index,
backend_name=backend_name,
backend_api=backend_api,
width=width,
height=height,
) )
__all__ = [
"DEFAULT_MAX_CAMERA_INDEX", def list_cameras(max_index: int = DEFAULT_MAX_CAMERA_INDEX) -> list[CameraInfo]:
"CameraFilter", """Probe indices ``0 .. max_index`` and return working cameras."""
"CameraInfo", found: list[CameraInfo] = []
"list_cameras", seen_indices: set[int] = set()
"pick_default_camera", backends = _backend_candidates()
]
with _suppress_opencv_noise():
for index in range(0, max_index + 1):
for backend_name, backend_api in backends:
info = _try_open(index, backend_name, backend_api)
if info is None:
continue
if index in seen_indices:
break
found.append(info)
seen_indices.add(index)
break
return found
def pick_default_camera(cameras: list[CameraInfo], preferred: str | None = None) -> str | None:
if not cameras:
return None
preferred = (preferred or "").strip()
if preferred:
for cam in cameras:
if cam.source == preferred:
return cam.source
return cameras[0].source
+51 -123
View File
@@ -2,19 +2,14 @@
from __future__ import annotations from __future__ import annotations
import json
import math import math
import sys import sys
import time import time
from pathlib import Path
from PyQt5.QtCore import 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 (
QAction,
QActionGroup,
QApplication, QApplication,
QDockWidget,
QFileDialog, QFileDialog,
QMainWindow, QMainWindow,
QSplitter, QSplitter,
@@ -66,7 +61,6 @@ class FingerDashboardWindow(QMainWindow):
self._frame_index = 0 self._frame_index = 0
self._wall_fps = 0.0 self._wall_fps = 0.0
self._wall_fps_alpha = 0.15 self._wall_fps_alpha = 0.15
self._log_display_mode = "hidden"
self.setWindowTitle(layout.window_title) self.setWindowTitle(layout.window_title)
self.setStyleSheet(STYLE) self.setStyleSheet(STYLE)
@@ -90,7 +84,7 @@ class FingerDashboardWindow(QMainWindow):
root_layout.setSpacing(layout.root_spacing_px) root_layout.setSpacing(layout.root_spacing_px)
setup = layout.panel_setup setup = layout.panel_setup
self._log_panel_visible = False self._log_panel_visible = setup.log_panel.enabled
self.mesh_state = MeshViewState.from_layout(layout) self.mesh_state = MeshViewState.from_layout(layout)
self.mesh_viewport = MeshViewportWidget(layout=layout, mesh_state=self.mesh_state) self.mesh_viewport = MeshViewportWidget(layout=layout, mesh_state=self.mesh_state)
arrows_slot = layout.slot("arrows") arrows_slot = layout.slot("arrows")
@@ -117,6 +111,7 @@ class FingerDashboardWindow(QMainWindow):
self._preferences = load_preferences() self._preferences = load_preferences()
self.settings_panel.apply_preference_state(self._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())) self.mesh_viewport.set_free_rotation(bool(self.settings_panel.free_rotate_check.isChecked()))
if self.arrow_panel is not None: if self.arrow_panel is not None:
self.arrow_panel.set_background(self.settings_panel.arrow_background()) self.arrow_panel.set_background(self.settings_panel.arrow_background())
@@ -157,6 +152,11 @@ class FingerDashboardWindow(QMainWindow):
self.charts_column = QSplitter(Qt.Vertical) self.charts_column = QSplitter(Qt.Vertical)
self.charts_column.addWidget(self.force_charts.widget) 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) self.charts_column.setChildrenCollapsible(False)
h_splitter = QSplitter(Qt.Horizontal) h_splitter = QSplitter(Qt.Horizontal)
@@ -172,104 +172,18 @@ class FingerDashboardWindow(QMainWindow):
self.h_splitter = h_splitter self.h_splitter = h_splitter
root_layout.addWidget(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()) self.resize(*self._target_window_size())
QTimer.singleShot(0, self._apply_panel_layout) QTimer.singleShot(0, self._apply_panel_layout)
def _set_log_panel_visible(self, visible: bool) -> None: def _set_log_panel_visible(self, visible: bool) -> None:
self._log_panel_visible = bool(visible) self._log_panel_visible = bool(visible)
if hasattr(self, "log_dock"): self.log_widget.setVisible(self._log_panel_visible)
self.log_dock.setVisible(self._log_panel_visible)
QTimer.singleShot(0, self._apply_panel_layout) 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: def _on_log_visibility_changed(self, visible: bool) -> None:
self._apply_log_display_mode("runtime" if visible else "hidden", save=True) self._set_log_panel_visible(visible)
self._save_preferences()
def _on_free_rotation_changed(self, enabled: bool) -> None: def _on_free_rotation_changed(self, enabled: bool) -> None:
self.mesh_viewport.set_free_rotation(enabled) self.mesh_viewport.set_free_rotation(enabled)
@@ -282,7 +196,6 @@ class FingerDashboardWindow(QMainWindow):
def _save_preferences(self) -> None: def _save_preferences(self) -> None:
prefs = self.settings_panel.preference_state() prefs = self.settings_panel.preference_state()
prefs["log_display_mode"] = self._log_display_mode
self._preferences = prefs self._preferences = prefs
save_preferences(prefs) save_preferences(prefs)
# Keep in-memory pipeline config aligned with panel values. # Keep in-memory pipeline config aligned with panel values.
@@ -304,6 +217,7 @@ class FingerDashboardWindow(QMainWindow):
scale = self._layout_scale_factor() scale = self._layout_scale_factor()
column_height = self._scaled_top_row_height(scale) column_height = self._scaled_top_row_height(scale)
charts_slot = self.layout.top_slot("charts") 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:
@@ -322,11 +236,27 @@ class FingerDashboardWindow(QMainWindow):
settings_height, 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.force_charts.set_panel_size(
self._scaled_px(charts_slot.width_px, scale), self._scaled_px(charts_slot.width_px, scale),
column_height, column_height,
) )
self.charts_column.setSizes([column_height]) self.charts_column.setSizes([column_height, 0])
self.h_splitter.setSizes( self.h_splitter.setSizes(
[self._scaled_px(slot.width_px, scale) for slot in setup.top_row.slots if slot.enabled] [self._scaled_px(slot.width_px, scale) for slot in setup.top_row.slots if slot.enabled]
@@ -349,6 +279,12 @@ class FingerDashboardWindow(QMainWindow):
width += margins.left() + margins.right() width += margins.left() + margins.right()
height = layout.charts_column_height_px() 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() height += margins.top() + margins.bottom()
return max(layout.window_width, width), max(layout.window_height, height) return max(layout.window_width, width), max(layout.window_height, height)
@@ -380,6 +316,12 @@ class FingerDashboardWindow(QMainWindow):
base_width += max(len(enabled_slots) - 1, 0) * self.h_splitter.handleWidth() base_width += max(len(enabled_slots) - 1, 0) * self.h_splitter.handleWidth()
base_height = self.layout.charts_column_height_px() 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: if base_width <= 0 or base_height <= 0:
return 1.0 return 1.0
return min(available_width / base_width, available_height / base_height) return min(available_width / base_width, available_height / base_height)
@@ -669,41 +611,27 @@ class FingerDashboardWindow(QMainWindow):
def _select_finger_assets(self) -> None: def _select_finger_assets(self) -> None:
from .preferences import ( from .preferences import (
default_finger_assets_dir, default_finger_assets_dir,
default_finger_config,
normalize_assets_preference, normalize_assets_preference,
normalize_config_preference, packaged_finger_assets_root,
packaged_finger_bundle_root,
) )
start = packaged_finger_bundle_root() start = packaged_finger_assets_root()
start_dir = (
str(start)
if start is not None
else (self.settings_panel.finger_export_dir() or default_finger_assets_dir())
)
path = QFileDialog.getExistingDirectory( path = QFileDialog.getExistingDirectory(
self, self,
"选择传感器 bundle 目录", "选择 finger 资产目录",
str(start) if start is not None else ( start_dir,
self.settings_panel.sensor_bundle_dir() or "sensors/finger"
),
) )
if path: if path:
bundle_dir = Path(path) assets = normalize_assets_preference(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():
config = normalize_config_preference(str(config_path)) or default_finger_config()
self.settings_panel.set_config_path(config)
self.config.config = config
assets = normalize_assets_preference(str(assets_dir)) or default_finger_assets_dir()
self.settings_panel.set_finger_export_dir(assets) self.settings_panel.set_finger_export_dir(assets)
self.config.export_dir = assets self.config.export_dir = assets
self._save_preferences() self._save_preferences()
def _select_csv_dir(self) -> None: def _select_csv_dir(self) -> None:
path = QFileDialog.getExistingDirectory( path = QFileDialog.getExistingDirectory(
self, self,
+2
View File
@@ -103,10 +103,12 @@ def normalize_assets_preference(value: str | None) -> str:
return default return default
except OSError: except OSError:
pass pass
# Keep a user-selected custom directory that still exists.
if "sensors/finger" in posix or path.name == "30fps-0703": if "sensors/finger" in posix or path.name == "30fps-0703":
return text if path.is_absolute() else default return text if path.is_absolute() else default
return text return text
# Missing old absolute/repo paths (assets/finger, ./config era) -> packaged default.
return default return default
+24 -72
View File
@@ -15,16 +15,11 @@ from PyQt5.QtWidgets import (
QWidget, QWidget,
) )
from viz.render_2d import ArrowBackground
from orisys.finger import DEFAULT_FINGER_EXPORT_DIR from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
from viz.render_2d import ArrowBackground
from ..layout import DashboardLayout, LAYOUT from ..layout import DashboardLayout, LAYOUT
from ..mesh_view_state import MeshViewState 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 from ..transport_icons import reset_icon, transport_icon
_PLAY_PAUSE_BUTTON_NAME = "btnPlayPause" _PLAY_PAUSE_BUTTON_NAME = "btnPlayPause"
@@ -91,7 +86,7 @@ class SettingsPanelWidget(QWidget):
self.show_log_check = QCheckBox("Show log panel") self.show_log_check = QCheckBox("Show log panel")
self.show_log_check.setChecked(show_log) self.show_log_check.setChecked(show_log)
self.show_log_check.toggled.connect(self.log_visibility_changed.emit) self.show_log_check.toggled.connect(self.log_visibility_changed.emit)
self.show_log_check.hide() root.addWidget(self.show_log_check)
self.show_flow_arrows_check = QCheckBox("Show displacement arrows") 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.setChecked(self._mesh_state.flow_arrows_enabled)
@@ -139,38 +134,36 @@ class SettingsPanelWidget(QWidget):
self._video_file_path = "" self._video_file_path = ""
self._updating_camera_options = False self._updating_camera_options = False
root.addWidget(self._caption("Sensor bundle")) root.addWidget(self._caption("Config"))
bundle_row = QHBoxLayout() config_row = QHBoxLayout()
bundle_row.setSpacing(6) config_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("finger") self.config_edit = QLineEdit("finger")
self.config_edit.hide() config_row.addWidget(self.config_edit, 1)
self.btn_browse_config = QPushButton("…") self.btn_browse_config = QPushButton("…")
self.btn_browse_config.hide() 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)
# Optional calibration path is kept for future re-enablement but hidden for now. # Optional calibration path is kept for future re-enablement but hidden for now.
self.cal_edit = QLineEdit("") self.cal_edit = QLineEdit("")
self.cal_edit.setPlaceholderText("Optional calibration path") self.cal_edit.setPlaceholderText("Optional calibration path")
self.cal_edit.hide() 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 = QLineEdit(DEFAULT_FINGER_EXPORT_DIR)
self.finger_assets_edit.hide() self.finger_assets_edit.setPlaceholderText("Blender export bundle directory")
self._apply_bundle_preset(self._bundle_presets[0]) 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)
root.addWidget(self._caption("CSV save directory")) root.addWidget(self._caption("CSV save directory"))
csv_dir_row = QHBoxLayout() csv_dir_row = QHBoxLayout()
csv_dir_row.setSpacing(6) csv_dir_row.setSpacing(6)
@@ -344,18 +337,16 @@ class SettingsPanelWidget(QWidget):
def set_config_path(self, value: str) -> None: def set_config_path(self, value: str) -> None:
self.config_edit.setText(value) self.config_edit.setText(value)
self._sync_bundle_combo_from_paths()
def set_calibration_path(self, value: str) -> None: def set_calibration_path(self, value: str) -> None:
self.cal_edit.setText(value or "") self.cal_edit.setText(value or "")
def set_finger_export_dir(self, value: str) -> None: def set_finger_export_dir(self, value: str) -> None:
self.finger_assets_edit.setText(value) self.finger_assets_edit.setText(value)
self._sync_bundle_combo_from_paths()
def set_finger_export_dir_enabled(self, enabled: bool) -> None: def set_finger_export_dir_enabled(self, enabled: bool) -> None:
self.finger_assets_edit.setEnabled(enabled)
self.btn_browse_finger_assets.setEnabled(enabled) self.btn_browse_finger_assets.setEnabled(enabled)
self.sensor_bundle_combo.setEnabled(enabled)
def set_csv_save_dir(self, value: str) -> None: def set_csv_save_dir(self, value: str) -> None:
self.csv_dir_edit.setText(value) self.csv_dir_edit.setText(value)
@@ -396,33 +387,6 @@ class SettingsPanelWidget(QWidget):
return return
self.csv_record_toggled.emit(not self._csv_recording) 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: def _emit_arrow_background_changed(self, _index: int) -> None:
value = self.arrow_background_combo.currentData() value = self.arrow_background_combo.currentData()
if value: if value:
@@ -449,15 +413,6 @@ class SettingsPanelWidget(QWidget):
def finger_export_dir(self) -> str: def finger_export_dir(self) -> str:
return self.finger_assets_edit.text().strip() 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: def csv_save_dir(self) -> str:
return self.csv_dir_edit.text().strip() return self.csv_dir_edit.text().strip()
@@ -465,7 +420,6 @@ class SettingsPanelWidget(QWidget):
"""Serialize user-facing settings suitable for preference.json.""" """Serialize user-facing settings suitable for preference.json."""
return { return {
"video_source": self.video_source(), "video_source": self.video_source(),
"sensor_bundle_dir": self.sensor_bundle_dir(),
"config_path": self.config_path(), "config_path": self.config_path(),
"finger_assets_dir": self.finger_export_dir(), "finger_assets_dir": self.finger_export_dir(),
"csv_save_dir": self.csv_save_dir(), "csv_save_dir": self.csv_save_dir(),
@@ -511,8 +465,6 @@ class SettingsPanelWidget(QWidget):
if isinstance(assets, str) and assets.strip(): if isinstance(assets, str) and assets.strip():
self.set_finger_export_dir(assets.strip()) self.set_finger_export_dir(assets.strip())
self._sync_bundle_combo_from_paths()
csv_dir = state.get("csv_save_dir") csv_dir = state.get("csv_save_dir")
if isinstance(csv_dir, str) and csv_dir.strip(): if isinstance(csv_dir, str) and csv_dir.strip():
self.set_csv_save_dir(csv_dir.strip()) self.set_csv_save_dir(csv_dir.strip())
+29 -2
View File
@@ -1,5 +1,32 @@
"""Signal filtering helpers for example force charts.""" """Signal filtering helpers for example force charts."""
from orisys.signal_processing import lowpass_filter from __future__ import annotations
__all__ = ["lowpass_filter"] import numpy as np
from scipy.signal import butter, filtfilt
def lowpass_filter(data, cutoff_hz=5, sample_rate_hz=30, order=2) -> np.ndarray:
"""Zero-phase Butterworth low-pass filter for uniformly sampled 1-D data."""
y = np.asarray(data, dtype=np.float64)
if cutoff_hz == 0:
return y.copy()
if y.size == 0:
return y
nyquist = 0.5 * sample_rate_hz
if cutoff_hz < 0 or cutoff_hz >= nyquist:
raise ValueError(
f"cutoff_hz must be in (0, {nyquist}) for sample_rate_hz={sample_rate_hz}"
)
b, a = butter(order, cutoff_hz / nyquist, btype="low")
taplen = max(len(a), len(b))
# scipy.signal.filtfilt requires len(x) > padlen (default 3 * taplen for recent scipy).
padlen = 3 * taplen
if y.size <= padlen:
return y.copy()
try:
return filtfilt(b, a, y)
except ValueError:
return y.copy()