diff --git a/examples/gui/preference.json b/examples/gui/preference.json index 7d7f916..331fe3a 100644 --- a/examples/gui/preference.json +++ b/examples/gui/preference.json @@ -1,6 +1,5 @@ { - "video_source": "D:/repo/orisys-sdk/data/dd02-0703/tune.mp4", - "sensor_bundle_dir": "sensors/finger", + "video_source": "D:/repo/orisys-sdk/data/dd02-0703/1.mp4", "config_path": "finger", "finger_assets_dir": "sensors/finger/assets/30fps-0703", "csv_save_dir": "./outputs/csv", @@ -8,5 +7,5 @@ "show_flow_arrows": false, "arrow_background": "unrolled", "free_rotate": true, - "log_display_mode": "hidden" + "sensor_bundle_dir": "sensors/finger" } diff --git a/examples/gui/sensors/adapter.py b/examples/gui/sensors/adapter.py index 26678e8..8386494 100644 --- a/examples/gui/sensors/adapter.py +++ b/examples/gui/sensors/adapter.py @@ -11,7 +11,6 @@ from typing import Any, Optional import cv2 import numpy as np import orisys -from orisys.signal_processing import calibrate_normal_force from .roi_geometry import ( 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 class FrameSnapshot: """Normalized per-frame payload consumed by the Qt viewer.""" @@ -46,6 +49,11 @@ class FrameSnapshot: 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): """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 -def _resolve_calibration_sensor_name(config_name) -> str | None: - """Map a config path/name to a sensors/ 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( sensor, *, raw_image=None, processed_image=None, - sensor_name: str | None = None, ) -> Optional[FrameSnapshot]: """Build a FrameSnapshot with HHD/forces refreshed every frame.""" if sensor is None or sensor.img is None: @@ -150,9 +132,6 @@ def _snapshot_from_sensor( below_threshold_color=(160, 160, 160), ) - cal_name = sensor_name or _resolve_calibration_sensor_name( - getattr(sensor, "config_name", None) - ) fn_raw = float(fnormal) return FrameSnapshot( image=image_bgr, @@ -163,7 +142,7 @@ def _snapshot_from_sensor( normal_force=fn_raw, shear_x=float(fshearx), 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), timestamp=time.time(), raw_image=raw_image, @@ -227,7 +206,6 @@ class OrisysSensorAdapter(SensorAdapter): if self.sensor.frame is not None else None, processed_image=_bgr_from_gray_or_bgr(self.sensor.img), - sensor_name=_resolve_calibration_sensor_name(self.config_name), ) def reset(self) -> None: @@ -304,7 +282,6 @@ class Dd06RoiAdapter(SensorAdapter): self.sensor, raw_image=frame, processed_image=rectified, - sensor_name=_resolve_calibration_sensor_name(self.config_name), ) def reset(self) -> None: diff --git a/examples/gui/viewer2d/backend.py b/examples/gui/viewer2d/backend.py index 44948b1..06d92c1 100644 --- a/examples/gui/viewer2d/backend.py +++ b/examples/gui/viewer2d/backend.py @@ -13,7 +13,7 @@ from typing import Literal, Optional, Sequence, Tuple import cv2 import numpy as np import orisys -from orisys.signal_processing import lowpass_filter +from scipy.signal import butter, filtfilt FORCE_SAMPLE_RATE_HZ = 30.0 PLOT_BUFFER_POINTS = 120 @@ -61,6 +61,32 @@ class ForcePlotState: 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]: if img is None: return None diff --git a/examples/gui/viewer3d/camera_discovery.py b/examples/gui/viewer3d/camera_discovery.py index f43ef13..b161abe 100644 --- a/examples/gui/viewer3d/camera_discovery.py +++ b/examples/gui/viewer3d/camera_discovery.py @@ -1,22 +1,139 @@ -"""Discover USB cameras for the dashboard. - -Thin compatibility shim over ``orisys.capture`` discovery helpers. -""" +"""Discover USB cameras for the dashboard (Windows DSHOW/MSMF, Linux V4L2).""" from __future__ import annotations -from orisys.capture import ( - DEFAULT_MAX_CAMERA_INDEX, - CameraFilter, - CameraInfo, - list_cameras, - pick_default_camera, -) +import contextlib +import os +import sys +from dataclasses import dataclass -__all__ = [ - "DEFAULT_MAX_CAMERA_INDEX", - "CameraFilter", - "CameraInfo", - "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, + ) + + +def list_cameras(max_index: int = DEFAULT_MAX_CAMERA_INDEX) -> list[CameraInfo]: + """Probe indices ``0 .. max_index`` and return working cameras.""" + found: list[CameraInfo] = [] + seen_indices: set[int] = set() + 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 diff --git a/examples/gui/viewer3d/dashboard.py b/examples/gui/viewer3d/dashboard.py index 13f003c..985885f 100644 --- a/examples/gui/viewer3d/dashboard.py +++ b/examples/gui/viewer3d/dashboard.py @@ -2,19 +2,14 @@ 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, @@ -66,7 +61,6 @@ 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) @@ -90,7 +84,7 @@ class FingerDashboardWindow(QMainWindow): root_layout.setSpacing(layout.root_spacing_px) 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_viewport = MeshViewportWidget(layout=layout, mesh_state=self.mesh_state) arrows_slot = layout.slot("arrows") @@ -117,6 +111,7 @@ 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()) @@ -157,6 +152,11 @@ 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,104 +172,18 @@ 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) - if hasattr(self, "log_dock"): - self.log_dock.setVisible(self._log_panel_visible) + self.log_widget.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._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: self.mesh_viewport.set_free_rotation(enabled) @@ -282,7 +196,6 @@ 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. @@ -304,6 +217,7 @@ 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: @@ -322,11 +236,27 @@ class FingerDashboardWindow(QMainWindow): settings_height, ) - self.force_charts.set_panel_size( - self._scaled_px(charts_slot.width_px, scale), - column_height, - ) - self.charts_column.setSizes([column_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.h_splitter.setSizes( [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() 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) @@ -380,6 +316,12 @@ 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) @@ -669,41 +611,27 @@ class FingerDashboardWindow(QMainWindow): def _select_finger_assets(self) -> None: from .preferences import ( default_finger_assets_dir, - default_finger_config, normalize_assets_preference, - normalize_config_preference, - packaged_finger_bundle_root, + packaged_finger_assets_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( self, - "选择传感器 bundle 目录", - str(start) if start is not None else ( - self.settings_panel.sensor_bundle_dir() or "sensors/finger" - ), + "选择 finger 资产目录", + start_dir, ) if 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(): - 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() + assets = normalize_assets_preference(path) self.settings_panel.set_finger_export_dir(assets) self.config.export_dir = assets self._save_preferences() + def _select_csv_dir(self) -> None: path = QFileDialog.getExistingDirectory( self, diff --git a/examples/gui/viewer3d/preferences.py b/examples/gui/viewer3d/preferences.py index faf4c2e..3d57f11 100644 --- a/examples/gui/viewer3d/preferences.py +++ b/examples/gui/viewer3d/preferences.py @@ -103,10 +103,12 @@ def normalize_assets_preference(value: str | None) -> str: return default except OSError: pass + # Keep a user-selected custom directory that still exists. if "sensors/finger" in posix or path.name == "30fps-0703": return text if path.is_absolute() else default return text + # Missing old absolute/repo paths (assets/finger, ./config era) -> packaged default. return default diff --git a/examples/gui/viewer3d/widgets/settings_panel.py b/examples/gui/viewer3d/widgets/settings_panel.py index 310717e..2cfb022 100644 --- a/examples/gui/viewer3d/widgets/settings_panel.py +++ b/examples/gui/viewer3d/widgets/settings_panel.py @@ -15,16 +15,11 @@ from PyQt5.QtWidgets import ( QWidget, ) -from viz.render_2d import ArrowBackground 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" @@ -91,7 +86,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) - 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.setChecked(self._mesh_state.flow_arrows_enabled) @@ -139,38 +134,36 @@ class SettingsPanelWidget(QWidget): self._video_file_path = "" self._updating_camera_options = False - 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) - + root.addWidget(self._caption("Config")) + config_row = QHBoxLayout() + config_row.setSpacing(6) 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.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. 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.hide() - self._apply_bundle_preset(self._bundle_presets[0]) + 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) + root.addWidget(self._caption("CSV save directory")) csv_dir_row = QHBoxLayout() csv_dir_row.setSpacing(6) @@ -344,18 +337,16 @@ 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) @@ -396,33 +387,6 @@ 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: @@ -449,15 +413,6 @@ 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() @@ -465,7 +420,6 @@ 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(), @@ -511,8 +465,6 @@ 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/examples/viz/signal_filters.py b/examples/viz/signal_filters.py index f142242..ff58d53 100644 --- a/examples/viz/signal_filters.py +++ b/examples/viz/signal_filters.py @@ -1,5 +1,32 @@ """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()