diff --git a/build/common/verify/wheel.py b/build/common/verify/wheel.py index 1891bf5..a8742f9 100644 --- a/build/common/verify/wheel.py +++ b/build/common/verify/wheel.py @@ -17,6 +17,8 @@ from orisys.finger.mesh import UVSurfaceMesh from orisys.finger.viewer import init_uv_mesh_viewer from orisys.hhd import nHHD from orisys.tracker import Tracker +from orisys import capture +from orisys.Stitcher import Stitcher assert orisys.__version__ assert Sensor is not None @@ -31,6 +33,8 @@ assert UVSurfaceMesh is not None assert init_uv_mesh_viewer is not None assert nHHD is not None assert Tracker is not None +assert capture is not None +assert Stitcher is not None print(f"orisys {orisys.__version__} public API imports OK") """ diff --git a/build/gui/ory_finger/config.py b/build/gui/ory_finger/config.py index b3e804a..39f6ecd 100644 --- a/build/gui/ory_finger/config.py +++ b/build/gui/ory_finger/config.py @@ -43,7 +43,7 @@ EXCLUDE_MODULES = [ DATA_DIRS = [ ("config", "config"), - ("assets", "assets"), + ("sensors", "sensors"), ] README_LINES = [ @@ -56,7 +56,7 @@ README_LINES = [ "", "Bundled folders:", " config/ sensor JSON configs", - " assets/ finger mesh and visualization data", + " sensors/ per-sensor bundles (finger mesh and visualization data)", "", "CPU-only build (no CUDA/CuPy).", ] diff --git a/build/wheel/build.py b/build/wheel/build.py index 2b0a67c..ea2dab0 100644 --- a/build/wheel/build.py +++ b/build/wheel/build.py @@ -114,7 +114,7 @@ def build(*, target: str = "internal", verify: bool | None = None) -> Path: print(f"Copied {copied_counts['configs']} JSON config files") print(f"Copied {copied_counts['examples']} example files to orisys/examples/") print(f"Copied {copied_counts['docs']} doc files to orisys/docs/") - print(f"Copied {copied_counts['assets']} finger asset files to orisys/assets/") + print(f"Copied {copied_counts['sensors']} sensor bundle files to orisys/sensors/") write_wheel_setup( NUITKA_WHEEL_DIR, diff --git a/build/wheel/compile.py b/build/wheel/compile.py index 0a5d626..6b4d525 100644 --- a/build/wheel/compile.py +++ b/build/wheel/compile.py @@ -16,7 +16,10 @@ from common.paths import repo_path NUITKA_INCLUDE_PACKAGES = ( "orisys", + "orisys.capture", "orisys.finger", + "orisys.stitch", + "orisys.stitch.backends", "orisys.unroll", "orisys.hhd", "orisys.hhd.solvers", diff --git a/docs/get_started.md b/docs/get_started.md index b0838da..8514980 100644 --- a/docs/get_started.md +++ b/docs/get_started.md @@ -5,7 +5,7 @@ description: 安装并运行 Orisys SDK 的基础步骤 # 快速开始 -版本:0.4.3 +版本:0.4.4 ## 简介 本文件介绍如何上手和运行叠动视触觉传感器配套的开发套件。本套件是配合叠动视触觉传感器进行图像处理,计算形变、力以及接触信息。它可以从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。 @@ -84,9 +84,9 @@ pip install --upgrade orisys ### 使用 .whl 文件安装(离线或指定版本) 从官方渠道获取 .whl 文件,文件名格式通常为: orisys-{版本}-{Python版本}-{Python版本}-{操作系统}_{硬件架构}.whl -例如:orisys-0.4.3-cp310-cp310-win_amd64.whl 表示: +例如:orisys-0.4.4-cp310-cp310-win_amd64.whl 表示: -版本:0.4.3 +版本:0.4.4 Python 版本:3.10(cp310) 操作系统:Windows(win) 硬件架构:64位(amd64) @@ -97,7 +97,7 @@ Python 版本:3.10(cp310) ```bash # 请将文件名替换为您实际下载的文件名 -pip install orisys-0.4.3-cp310-cp310-win_amd64.whl +pip install orisys-0.4.4-cp310-cp310-win_amd64.whl ``` ### 安装 CuPy(可选) @@ -139,7 +139,7 @@ python import orisys print(orisys.__version__) ``` -如果输出版本号(例如 0.4.3),则说明安装成功。 +如果输出版本号(例如 0.4.4),则说明安装成功。 ## 使用方法 ### 运行示例程序 diff --git a/examples/basic_pipeline.py b/examples/basic_pipeline.py index db2d2f5..43557db 100644 --- a/examples/basic_pipeline.py +++ b/examples/basic_pipeline.py @@ -7,7 +7,7 @@ ORY-BOX:单传感器基础流程 3. 按需计算接触区域 4. 读取并可视化结果 -SDK version: 0.4.3 +SDK version: 0.4.4 """ import os os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" diff --git a/examples/gui/preference.json b/examples/gui/preference.json index 9612335..7d7f916 100644 --- a/examples/gui/preference.json +++ b/examples/gui/preference.json @@ -1,12 +1,12 @@ { - "video_source": "D:/repo/orisys-sdk/data/dd02-0703/1.mp4", + "video_source": "D:/repo/orisys-sdk/data/dd02-0703/tune.mp4", "sensor_bundle_dir": "sensors/finger", - "config_path": "sensors/finger/sensor.json", + "config_path": "finger", "finger_assets_dir": "sensors/finger/assets/30fps-0703", "csv_save_dir": "./outputs/csv", "show_log": true, "show_flow_arrows": false, - "arrow_background": "pure_color", + "arrow_background": "unrolled", "free_rotate": true, "log_display_mode": "hidden" } diff --git a/examples/gui/sensors/adapter.py b/examples/gui/sensors/adapter.py index 6690538..26678e8 100644 --- a/examples/gui/sensors/adapter.py +++ b/examples/gui/sensors/adapter.py @@ -76,11 +76,37 @@ 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: @@ -124,6 +150,9 @@ 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, @@ -134,7 +163,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), + normal_force_calibrated=calibrate_normal_force(fn_raw, sensor_name=cal_name), fps=float(fps), timestamp=time.time(), raw_image=raw_image, @@ -198,6 +227,7 @@ 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: @@ -274,6 +304,7 @@ 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/viewer3d/dashboard.py b/examples/gui/viewer3d/dashboard.py index 13133ec..13f003c 100644 --- a/examples/gui/viewer3d/dashboard.py +++ b/examples/gui/viewer3d/dashboard.py @@ -646,22 +646,42 @@ class FingerDashboardWindow(QMainWindow): self._update_play_enabled_for_source() def _select_config(self) -> None: + from .preferences import ( + default_finger_config, + normalize_config_preference, + packaged_finger_bundle_root, + ) + + start = packaged_finger_bundle_root() + start_dir = str(start) if start is not None else "sensors/finger" path, _ = QFileDialog.getOpenFileName( self, "选择配置文件", - "config", + start_dir, "JSON (*.json);;所有文件 (*)", ) if path: - self.settings_panel.set_config_path(path) - self.config.config = path + config = normalize_config_preference(path) or default_finger_config() + self.settings_panel.set_config_path(config) + self.config.config = config self._save_preferences() 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, + ) + + start = packaged_finger_bundle_root() path = QFileDialog.getExistingDirectory( self, "选择传感器 bundle 目录", - self.settings_panel.sensor_bundle_dir() or "sensors/finger", + str(start) if start is not None else ( + self.settings_panel.sensor_bundle_dir() or "sensors/finger" + ), ) if path: bundle_dir = Path(path) @@ -677,12 +697,13 @@ class FingerDashboardWindow(QMainWindow): 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) + 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.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 9f3f2e5..faf4c2e 100644 --- a/examples/gui/viewer3d/preferences.py +++ b/examples/gui/viewer3d/preferences.py @@ -13,6 +13,17 @@ LEGACY_CAMERA_SELECTION_FILE = "camera_selection.json" # Keep preference.json at examples/gui/ for backward compatibility. _GUI_DIR = Path(__file__).resolve().parents[1] +# Old lab / repo-relative paths that predate packaged sensors//. +_LEGACY_CONFIG_PATHS = { + "dd02", + "dd02-ov", + "config/dd02-ov.json", + "./config/dd02-ov.json", + "config/finger.json", + "./config/finger.json", + "sensors/finger/sensor.json", +} + def preference_path() -> Path: # Keep preference file at examples/gui/preference.json @@ -23,6 +34,92 @@ def legacy_camera_selection_path() -> Path: return resource_path(LEGACY_CAMERA_SELECTION_FILE) +def default_finger_config() -> str: + """Public product config stem resolved via packaged ``sensors/finger``.""" + return "finger" + + +def default_finger_assets_dir() -> str: + try: + from orisys.finger import DEFAULT_FINGER_EXPORT_DIR + + return DEFAULT_FINGER_EXPORT_DIR + except ImportError: + return "sensors/finger/assets/30fps-0703" + + +def packaged_finger_bundle_root() -> Path | None: + """Filesystem root of the installed ``sensors/finger`` bundle when present.""" + try: + from orisys.configs import resolve_sensor_bundle + except ImportError: + return None + bundle = resolve_sensor_bundle("finger") + return bundle.root if bundle is not None else None + + +def packaged_finger_assets_root() -> Path | None: + try: + from orisys.configs import resolve_sensor_asset_dir + except ImportError: + return None + return resolve_sensor_asset_dir("finger") + + +def normalize_config_preference(value: str | None) -> str: + """Map legacy config paths to the packaged ``finger`` sensor name.""" + if value is None or not str(value).strip(): + return default_finger_config() + text = str(value).strip().replace("\\", "/") + if text in _LEGACY_CONFIG_PATHS or text.endswith("/dd02-ov.json"): + return default_finger_config() + + root = packaged_finger_bundle_root() + if root is not None: + try: + if Path(value).resolve() == (root / "sensor.json").resolve(): + return default_finger_config() + except OSError: + pass + return text + + +def normalize_assets_preference(value: str | None) -> str: + """Prefer packaged ``sensors/finger/assets/...`` when saved paths are stale.""" + default = default_finger_assets_dir() + if value is None or not str(value).strip(): + return default + text = str(value).strip() + posix = text.replace("\\", "/") + if posix == default or posix.endswith("/sensors/finger/assets/30fps-0703"): + return default + + path = Path(text) + if path.is_dir(): + packaged = packaged_finger_assets_root() + if packaged is not None: + try: + if path.resolve() == packaged.resolve(): + return default + except OSError: + pass + if "sensors/finger" in posix or path.name == "30fps-0703": + return text if path.is_absolute() else default + return text + + return default + + +def _normalize_preferences(prefs: dict[str, Any]) -> dict[str, Any]: + prefs = dict(prefs) + if "config_path" in prefs or prefs: + prefs["config_path"] = normalize_config_preference(prefs.get("config_path")) + if "finger_assets_dir" in prefs or prefs: + prefs["finger_assets_dir"] = normalize_assets_preference(prefs.get("finger_assets_dir")) + prefs.setdefault("sensor_bundle_dir", "sensors/finger") + return prefs + + def _read_json(path: Path) -> dict[str, Any]: if not path.is_file(): return {} @@ -34,14 +131,14 @@ def _read_json(path: Path) -> dict[str, Any]: def load_preferences() -> dict[str, Any]: - """Load preferences, migrating camera_id from legacy file when needed.""" + """Load preferences, migrating camera_id / legacy config paths when needed.""" prefs = _read_json(preference_path()) if "video_source" not in prefs: legacy = _read_json(legacy_camera_selection_path()) camera_id = legacy.get("camera_id") if camera_id is not None and str(camera_id).strip(): prefs["video_source"] = str(camera_id).strip() - return prefs + return _normalize_preferences(prefs) def save_preferences(prefs: dict[str, Any]) -> None: @@ -49,9 +146,10 @@ def save_preferences(prefs: dict[str, Any]) -> None: if not isinstance(prefs, dict): return path = preference_path() + normalized = _normalize_preferences(prefs) try: path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(prefs, indent=2) + "\n", encoding="utf-8") + path.write_text(json.dumps(normalized, indent=2) + "\n", encoding="utf-8") except OSError: pass diff --git a/examples/gui/viewer3d/presets.py b/examples/gui/viewer3d/presets.py index 650e1d0..712c037 100644 --- a/examples/gui/viewer3d/presets.py +++ b/examples/gui/viewer3d/presets.py @@ -25,6 +25,23 @@ class SensorBundlePreset: export_dir: str +def _packaged_finger_preset() -> SensorBundlePreset | None: + """Use logical names that resolve through the installed wheel sensors/ layout.""" + try: + from orisys.configs import resolve_sensor_bundle + except ImportError: + return None + bundle = resolve_sensor_bundle("finger") + if bundle is None: + return None + return SensorBundlePreset( + label="ORY-FINGER", + bundle_dir="sensors/finger", + config="finger", + export_dir=DEFAULT_FINGER_EXPORT_DIR, + ) + + def _load_manifest(bundle_root: Path) -> dict: path = bundle_root / "manifest.json" if not path.is_file(): @@ -50,6 +67,14 @@ def _bundle_from_root(bundle_root: Path, *, label: str | None = None) -> SensorB break except ValueError: continue + # Packaged / checkout sensors/finger: keep resolvable product names, not absolute paths. + if rel_bundle.replace("\\", "/").endswith("sensors/finger") or bundle_root.name == "finger": + return SensorBundlePreset( + label=label or "ORY-FINGER", + bundle_dir="sensors/finger", + config="finger", + export_dir=DEFAULT_FINGER_EXPORT_DIR, + ) return SensorBundlePreset( label=label or "ORY-FINGER", bundle_dir=rel_bundle, @@ -59,6 +84,9 @@ def _bundle_from_root(bundle_root: Path, *, label: str | None = None) -> SensorB def default_finger_bundle() -> SensorBundlePreset: + packaged = _packaged_finger_preset() + if packaged is not None: + return packaged for root in _repo_root_candidates(): preset = _bundle_from_root(root / "sensors" / "finger", label="ORY-FINGER") if preset is not None: @@ -66,7 +94,7 @@ def default_finger_bundle() -> SensorBundlePreset: return SensorBundlePreset( label="ORY-FINGER", bundle_dir="sensors/finger", - config="sensors/finger/sensor.json", + config="finger", export_dir=DEFAULT_FINGER_EXPORT_DIR, ) @@ -108,6 +136,8 @@ def sensor_bundle_preset_index( for idx, preset in enumerate(presets): if config == preset.config and export_dir == preset.export_dir: return idx + if config in ("finger", "dd02", "dd02-ov") and preset.config == "finger": + 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 c5c421d..310717e 100644 --- a/examples/gui/viewer3d/widgets/settings_panel.py +++ b/examples/gui/viewer3d/widgets/settings_panel.py @@ -16,6 +16,7 @@ from PyQt5.QtWidgets import ( ) from viz.render_2d import ArrowBackground +from orisys.finger import DEFAULT_FINGER_EXPORT_DIR from ..layout import DashboardLayout, LAYOUT from ..mesh_view_state import MeshViewState @@ -157,7 +158,7 @@ class SettingsPanelWidget(QWidget): bundle_row.addWidget(self.btn_browse_finger_assets) root.addLayout(bundle_row) - self.config_edit = QLineEdit("sensors/finger/sensor.json") + self.config_edit = QLineEdit("finger") self.config_edit.hide() self.btn_browse_config = QPushButton("…") self.btn_browse_config.hide() @@ -167,10 +168,9 @@ class SettingsPanelWidget(QWidget): self.cal_edit.setPlaceholderText("Optional calibration path") self.cal_edit.hide() - self.finger_assets_edit = QLineEdit("sensors/finger/assets/30fps-0703") + self.finger_assets_edit = QLineEdit(DEFAULT_FINGER_EXPORT_DIR) self.finger_assets_edit.hide() self._apply_bundle_preset(self._bundle_presets[0]) - root.addWidget(self._caption("CSV save directory")) csv_dir_row = QHBoxLayout() csv_dir_row.setSpacing(6) diff --git a/examples/mini_pipeline.py b/examples/mini_pipeline.py index cb9e7c3..9c32883 100644 --- a/examples/mini_pipeline.py +++ b/examples/mini_pipeline.py @@ -7,7 +7,7 @@ ORY-MINI:单传感器基础流程 3. 按需计算接触区域 4. 读取并可视化结果 -SDK version: 0.4.3 +SDK version: 0.4.4 """ import os os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" diff --git a/examples/two_sensor_pipeline.py b/examples/two_sensor_pipeline.py index 2d912dd..45e2b78 100644 --- a/examples/two_sensor_pipeline.py +++ b/examples/two_sensor_pipeline.py @@ -18,7 +18,7 @@ - Windows 下必须在 if __name__ == '__main__' 中调用 mp.freeze_support() - 每个传感器进程都会占用独立的 GPU 显存 -SDK version: 0.4.3 +SDK version: 0.4.4 """ import os os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" diff --git a/examples/viz/mesh_camera.py b/examples/viz/mesh_camera.py index d1a9856..49ad899 100644 --- a/examples/viz/mesh_camera.py +++ b/examples/viz/mesh_camera.py @@ -7,6 +7,10 @@ from pathlib import Path import numpy as np DEFAULT_FINGER_VIZ_POSE = "sensors/finger/assets/visualization/world_to_cam.npy" +_LEGACY_FINGER_VIZ_POSES = { + "assets/finger/visualization/world_to_cam.npy", + DEFAULT_FINGER_VIZ_POSE, +} def _finger_bundle_viz_pose_candidates() -> list[Path]: @@ -63,8 +67,9 @@ def resolve_viz_pose_path(path: str | Path | None = None) -> Path: here = Path(__file__).resolve() candidates: list[Path] = [] - # Prefer the sensors/finger bundle for the default (and matching relative) path. - if path is None or raw.as_posix() == DEFAULT_FINGER_VIZ_POSE or not raw.is_absolute(): + # Prefer the sensors/finger bundle for default / legacy relative paths. + posix = raw.as_posix() + if path is None or posix in _LEGACY_FINGER_VIZ_POSES or not raw.is_absolute(): candidates.extend(_finger_bundle_viz_pose_candidates()) try: diff --git a/sensors/box/force_calibration.json b/sensors/box/force_calibration.json index 4617082..015fc07 100644 --- a/sensors/box/force_calibration.json +++ b/sensors/box/force_calibration.json @@ -1,8 +1,30 @@ { - "normal_force": { - "model": "linear", - "slope": 3.92685554668795e-05, - "offset": 0.0217596967278535, - "unit": "N" + "schema_version": 1, + "unit": "N", + "channels": { + "normal": { + "method": "piecewise_linear", + "raw_key": "fnormal", + "extrapolate": true, + "clamp_output_min": 0.0, + "points": [ + [1000.0, 0.0], + [10000.0, 0.377], + [30000.0, 1.377], + [62000.0, 2.377] + ] + }, + "shear": { + "method": "piecewise_linear", + "input": "components", + "signed": true, + "extrapolate": true, + "clamp_output_min": 0.0, + "points": [ + [1000.0, 0.0], + [36000.0, 1.19], + [68000.0, 2.06] + ] + } } } diff --git a/sensors/finger/assets/30fps-0703/README.md b/sensors/finger/assets/finger/30fps-0703/README.md similarity index 100% rename from sensors/finger/assets/30fps-0703/README.md rename to sensors/finger/assets/finger/30fps-0703/README.md diff --git a/sensors/finger/assets/30fps-0703/camera_intrinsics.npz b/sensors/finger/assets/finger/30fps-0703/camera_intrinsics.npz similarity index 100% rename from sensors/finger/assets/30fps-0703/camera_intrinsics.npz rename to sensors/finger/assets/finger/30fps-0703/camera_intrinsics.npz diff --git a/sensors/finger/assets/30fps-0703/mesh.npz b/sensors/finger/assets/finger/30fps-0703/mesh.npz similarity index 100% rename from sensors/finger/assets/30fps-0703/mesh.npz rename to sensors/finger/assets/finger/30fps-0703/mesh.npz diff --git a/sensors/finger/assets/30fps-0703/pixel_to_xyz.npz b/sensors/finger/assets/finger/30fps-0703/pixel_to_xyz.npz similarity index 100% rename from sensors/finger/assets/30fps-0703/pixel_to_xyz.npz rename to sensors/finger/assets/finger/30fps-0703/pixel_to_xyz.npz diff --git a/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz b/sensors/finger/assets/finger/30fps-0703/unroll_map_uv_s0.4.npz similarity index 100% rename from sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz rename to sensors/finger/assets/finger/30fps-0703/unroll_map_uv_s0.4.npz diff --git a/sensors/finger/assets/30fps-0703/world_to_cam.npy b/sensors/finger/assets/finger/30fps-0703/world_to_cam.npy similarity index 100% rename from sensors/finger/assets/30fps-0703/world_to_cam.npy rename to sensors/finger/assets/finger/30fps-0703/world_to_cam.npy diff --git a/sensors/finger/assets/visualization/README.md b/sensors/finger/assets/finger/visualization/README.md similarity index 100% rename from sensors/finger/assets/visualization/README.md rename to sensors/finger/assets/finger/visualization/README.md diff --git a/sensors/finger/assets/visualization/camera_intrinsics.npz b/sensors/finger/assets/finger/visualization/camera_intrinsics.npz similarity index 100% rename from sensors/finger/assets/visualization/camera_intrinsics.npz rename to sensors/finger/assets/finger/visualization/camera_intrinsics.npz diff --git a/sensors/finger/assets/visualization/flatten_remap.npz b/sensors/finger/assets/finger/visualization/flatten_remap.npz similarity index 100% rename from sensors/finger/assets/visualization/flatten_remap.npz rename to sensors/finger/assets/finger/visualization/flatten_remap.npz diff --git a/sensors/finger/assets/visualization/mesh.npz b/sensors/finger/assets/finger/visualization/mesh.npz similarity index 100% rename from sensors/finger/assets/visualization/mesh.npz rename to sensors/finger/assets/finger/visualization/mesh.npz diff --git a/sensors/finger/assets/visualization/pixel_to_xyz.npz b/sensors/finger/assets/finger/visualization/pixel_to_xyz.npz similarity index 100% rename from sensors/finger/assets/visualization/pixel_to_xyz.npz rename to sensors/finger/assets/finger/visualization/pixel_to_xyz.npz diff --git a/sensors/finger/assets/visualization/world_to_cam.npy b/sensors/finger/assets/finger/visualization/world_to_cam.npy similarity index 100% rename from sensors/finger/assets/visualization/world_to_cam.npy rename to sensors/finger/assets/finger/visualization/world_to_cam.npy diff --git a/sensors/finger/sensor.json b/sensors/finger/sensor.json index d5f6264..930caeb 100644 --- a/sensors/finger/sensor.json +++ b/sensors/finger/sensor.json @@ -22,7 +22,7 @@ "dis_preset": "medium", "dis_mode": 1, "algorithm_list": ["raft", "dis"], - "hhd_downsample": 5 + "hhd_downsample": 1 }, "contact": { "min_contour_area": 3000, diff --git a/src/orisys/__init__.py b/src/orisys/__init__.py index 310285c..d6d76ad 100644 --- a/src/orisys/__init__.py +++ b/src/orisys/__init__.py @@ -5,7 +5,7 @@ A Python library for processing and analyzing optical tactile sensor data, including image stitching, optical flow tracking, and deformation decomposition. """ -__version__ = "0.4.3" +__version__ = "0.4.4" __author__ = "Orisys" from .sensor import Sensor diff --git a/src/orisys/signal_processing.py b/src/orisys/signal_processing.py index 4bfaa13..7d00345 100644 --- a/src/orisys/signal_processing.py +++ b/src/orisys/signal_processing.py @@ -1,11 +1,18 @@ -"""Signal helpers for force filtering and normal-force calibration. +"""Signal helpers for force filtering and force calibration. Batch ``lowpass_filter`` is the zero-phase Butterworth used by example charts. ``LowpassFilter`` is a causal, stateful filter for per-frame ``Sensor`` outputs. + +Force calibration resolution order (when register support arrives): + 1. Sensor register / EEPROM calibration blob + 2. ``sensors//force_calibration.json`` + 3. Hardcoded linear defaults below """ from __future__ import annotations +from dataclasses import dataclass + import numpy as np from scipy.signal import butter, filtfilt, sosfilt, sosfilt_zi @@ -18,28 +25,241 @@ DEFAULT_SAMPLE_RATE_HZ = 30.0 DEFAULT_LOWPASS_ORDER = 2 -def _load_normal_force_coeffs(sensor_name: str | None = None) -> tuple[float, float]: - if sensor_name: - try: - from .configs import load_force_calibration +def _parse_calibration_point(point) -> tuple[float, float]: + if isinstance(point, dict): + raw = point.get("fnormal", point.get("raw", point.get("x"))) + force_n = point.get("force_n", point.get("n", point.get("y"))) + if raw is None or force_n is None: + raise ValueError(f"invalid calibration point: {point!r}") + return float(raw), float(force_n) + if isinstance(point, (list, tuple)) and len(point) >= 2: + return float(point[0]), float(point[1]) + raise ValueError(f"invalid calibration point: {point!r}") - 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) + +@dataclass(frozen=True) +class ForceChannelCalibration: + """One force channel: linear coeffs and/or piecewise-linear breakpoints.""" + + method: str = "linear" + slope: float | None = None + offset: float | None = None + points: tuple[tuple[float, float], ...] = () + extrapolate: bool = True + clamp_output_min: float | None = 0.0 + input_mode: str = "magnitude" + signed: bool = False + unit: str = "N" + + @classmethod + def linear(cls, slope: float, offset: float, *, unit: str = "N") -> ForceChannelCalibration: + return cls(method="linear", slope=float(slope), offset=float(offset), unit=unit) + + @classmethod + def from_channel_dict( + cls, + block: dict, + *, + unit: str = "N", + default_method: str = "piecewise_linear", + ) -> ForceChannelCalibration | None: + if not isinstance(block, dict): + return None + + method = str(block.get("method", block.get("model", default_method))).lower() + if method in ("linear", "affine"): + slope = block.get("slope") + offset = block.get("offset") + if slope is None or offset is None: + return None + return cls.linear(float(slope), float(offset), unit=str(block.get("unit", unit))) + + if method != "piecewise_linear": + return None + + raw_points = block.get("points", []) + if not isinstance(raw_points, (list, tuple)) or len(raw_points) < 2: + return None + try: + points = tuple(sorted(_parse_calibration_point(p) for p in raw_points)) + except (TypeError, ValueError): + return None + if len(points) < 2: + return None + + return cls( + method="piecewise_linear", + points=points, + extrapolate=bool(block.get("extrapolate", True)), + clamp_output_min=block.get("clamp_output_min", 0.0), + input_mode=str(block.get("input", "magnitude")).lower(), + signed=bool(block.get("signed", False)), + unit=str(block.get("unit", unit)), + ) + + def convert(self, raw: float) -> float: + raw = float(raw) + if self.method == "linear": + slope = NORMAL_FORCE_CAL_SLOPE if self.slope is None else float(self.slope) + offset = NORMAL_FORCE_CAL_OFFSET if self.offset is None else float(self.offset) + return slope * raw + offset + + points = self.points + if len(points) < 2: + return raw + + if raw <= points[0][0]: + segment = (points[0], points[1]) + if not self.extrapolate: + return self._clamp(points[0][1]) + elif raw >= points[-1][0]: + segment = (points[-2], points[-1]) + if not self.extrapolate: + return self._clamp(points[-1][1]) + else: + segment = None + for left, right in zip(points, points[1:]): + if left[0] <= raw <= right[0]: + segment = (left, right) + break + if segment is None: + return self._clamp(points[-1][1]) + + (raw0, n0), (raw1, n1) = segment + if raw1 == raw0: + value = n0 + else: + ratio = (raw - raw0) / (raw1 - raw0) + value = n0 + ratio * (n1 - n0) + return self._clamp(value) + + def convert_component(self, raw: float) -> float: + if not self.signed: + return self.convert(raw) + raw = float(raw) + sign = -1.0 if raw < 0.0 else 1.0 + return sign * self.convert(abs(raw)) + + def _clamp(self, value: float) -> float: + if self.clamp_output_min is not None: + return max(float(self.clamp_output_min), float(value)) + return float(value) + + +_DEFAULT_NORMAL = ForceChannelCalibration.linear( + NORMAL_FORCE_CAL_SLOPE, + NORMAL_FORCE_CAL_OFFSET, +) + + +def _load_force_calibration_data(sensor_name: str | None) -> dict | None: + if not sensor_name: + return None + try: + from .configs import load_force_calibration + + data = load_force_calibration(sensor_name) + except Exception: + return None + return data if isinstance(data, dict) else None + + +def _channel_from_data(data: dict | None, channel: str) -> ForceChannelCalibration | None: + if not isinstance(data, dict): + return None + + unit = str(data.get("unit", "N")) + channels = data.get("channels") + if isinstance(channels, dict): + block = channels.get(channel) + cal = ForceChannelCalibration.from_channel_dict(block, unit=unit) + if cal is not None: + return cal + + # Legacy single-channel file: {"normal_force": {"model": "linear", ...}} + if channel == "normal": + block = data.get("normal_force", data) + cal = ForceChannelCalibration.from_channel_dict( + block, unit=unit, default_method="linear" + ) + if cal is not None: + return cal + if isinstance(block, dict): + slope = block.get("slope") + offset = block.get("offset") + if slope is not None and offset is not None: + return ForceChannelCalibration.linear( + float(slope), + float(offset), + unit=str(block.get("unit", unit)), + ) + return None + + +def _normal_calibration(sensor_name: str | None = None) -> ForceChannelCalibration: + cal = _channel_from_data(_load_force_calibration_data(sensor_name), "normal") + return cal if cal is not None else _DEFAULT_NORMAL + + +def _shear_calibration(sensor_name: str | None = None) -> ForceChannelCalibration | None: + return _channel_from_data(_load_force_calibration_data(sensor_name), "shear") + + +def _load_normal_force_coeffs(sensor_name: str | None = None) -> tuple[float, float]: + """Legacy helper: linear slope/offset only (piecewise returns hardcoded defaults).""" + cal = _normal_calibration(sensor_name) + if cal.method == "linear" and cal.slope is not None and cal.offset is not None: + return float(cal.slope), float(cal.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.""" - slope, offset = _load_normal_force_coeffs(sensor_name) - return slope * float(raw_value) + offset + """Convert a raw normal-force reading to Newtons. + + Uses ``sensors//force_calibration.json`` when present (piecewise or linear). + Falls back to hardcoded linear defaults when missing. + """ + return _normal_calibration(sensor_name).convert(raw_value) + + +def calibrate_shear_force(raw_value: float, *, sensor_name: str | None = None) -> float: + """Convert a raw shear magnitude to Newtons using the shear channel curve. + + Returns ``float(raw_value)`` unchanged when no shear calibration is available. + """ + cal = _shear_calibration(sensor_name) + if cal is None: + return float(raw_value) + return cal.convert(raw_value) + + +def calibrate_shear_components( + shear_x: float, + shear_y: float, + *, + sensor_name: str | None = None, +) -> tuple[float, float]: + """Convert raw shear components to Newtons. + + When the shear channel uses ``input: components`` and ``signed: true``, each + axis is calibrated on absolute magnitude then re-signed. Otherwise the + magnitude is calibrated and components are scaled proportionally. + """ + sx = float(shear_x) + sy = float(shear_y) + cal = _shear_calibration(sensor_name) + if cal is None: + return sx, sy + + if cal.input_mode == "components": + return cal.convert_component(sx), cal.convert_component(sy) + + magnitude = (sx * sx + sy * sy) ** 0.5 + if magnitude <= 0.0: + return 0.0, 0.0 + calibrated = cal.convert(magnitude) + scale = calibrated / magnitude + return sx * scale, sy * scale def _nyquist_hz(sample_rate_hz: float) -> float: diff --git a/tests/dd06.py b/tests/dd06.py index 3774055..ddcae75 100644 --- a/tests/dd06.py +++ b/tests/dd06.py @@ -8,7 +8,7 @@ Flow: 4. Feed the rectified frame into Sensor.get_img(..., already_processed=True) 5. Compute deformation/contact and visualize -SDK version: 0.4.3 +SDK version: 0.4.4 """ from __future__ import annotations diff --git a/tests/test_pyproject.py b/tests/test_pyproject.py index 124844f..37b0dd2 100644 --- a/tests/test_pyproject.py +++ b/tests/test_pyproject.py @@ -35,7 +35,7 @@ def test_pyproject_dynamic_version(packaging_utils): sys.path.insert(0, str(build_dir)) from common.pyproject_utils import get_version - assert get_version(REPO_ROOT) == "0.4.3" + assert get_version(REPO_ROOT) == "0.4.4" def test_pyproject_bundle_manifest(packaging_utils): @@ -48,7 +48,7 @@ def test_pyproject_bundle_manifest(packaging_utils): items = bundle_items(REPO_ROOT) names = {item["name"] for item in items} - assert names == {"configs", "examples", "docs", "assets"} + assert names == {"configs", "examples", "docs", "sensors"} data_files = dict(bundle_data_files(REPO_ROOT)) assert "orisys/configs" in data_files @@ -57,8 +57,9 @@ def test_pyproject_bundle_manifest(packaging_utils): assert all("/" in path and "*" not in path for path in config_files) assert any(path.endswith(".json") for path in config_files) - asset_dirs = [dest for dest in data_files if dest.startswith("orisys/assets/")] - assert asset_dirs + sensor_dirs = [dest for dest in data_files if dest.startswith("orisys/sensors/")] + assert sensor_dirs + assert any("finger/assets" in dest for dest in sensor_dirs) assert all("*" not in path for files in data_files.values() for path in files) assert "examples" not in data_files diff --git a/tests/test_signal_processing.py b/tests/test_signal_processing.py index 0c18e3d..612729a 100644 --- a/tests/test_signal_processing.py +++ b/tests/test_signal_processing.py @@ -12,6 +12,8 @@ from orisys.signal_processing import ( NORMAL_FORCE_CAL_SLOPE, LowpassFilter, calibrate_normal_force, + calibrate_shear_components, + calibrate_shear_force, lowpass_filter, ) @@ -73,6 +75,46 @@ def test_calibrate_normal_force_preserves_linear_curve(): assert calibrate_normal_force(0.0) == pytest.approx(NORMAL_FORCE_CAL_OFFSET) +def test_calibrate_normal_force_mini_uses_legacy_linear_file(): + raw = 10000.0 + expected = NORMAL_FORCE_CAL_SLOPE * raw + NORMAL_FORCE_CAL_OFFSET + assert calibrate_normal_force(raw, sensor_name="mini") == pytest.approx(expected) + + +def test_calibrate_normal_force_box_uses_piecewise_curve(): + assert calibrate_normal_force(10000.0, sensor_name="box") == pytest.approx(0.377) + assert calibrate_normal_force(30000.0, sensor_name="box") == pytest.approx(1.377) + # Mid-segment interpolation between 10000→0.377 and 30000→1.377 + mid = calibrate_normal_force(20000.0, sensor_name="box") + assert mid == pytest.approx(0.877) + # Below first breakpoint with extrapolate=true + assert calibrate_normal_force(0.0, sensor_name="box") == pytest.approx(0.0) + + +def test_calibrate_shear_force_box_piecewise(): + assert calibrate_shear_force(1000.0, sensor_name="box") == pytest.approx(0.0) + assert calibrate_shear_force(36000.0, sensor_name="box") == pytest.approx(1.19) + assert calibrate_shear_force(68000.0, sensor_name="box") == pytest.approx(2.06) + + +def test_calibrate_shear_components_box_signed(): + sx, sy = calibrate_shear_components(36000.0, -36000.0, sensor_name="box") + assert sx == pytest.approx(1.19) + assert sy == pytest.approx(-1.19) + + +def test_calibrate_shear_missing_file_returns_raw(): + assert calibrate_shear_force(1234.5, sensor_name="no-such-sensor") == pytest.approx(1234.5) + sx, sy = calibrate_shear_components(1.0, -2.0, sensor_name="no-such-sensor") + assert (sx, sy) == pytest.approx((1.0, -2.0)) + + +def test_calibrate_normal_force_unknown_sensor_falls_back_to_defaults(): + raw = 5000.0 + expected = NORMAL_FORCE_CAL_SLOPE * raw + NORMAL_FORCE_CAL_OFFSET + assert calibrate_normal_force(raw, sensor_name="no-such-sensor") == pytest.approx(expected) + + def _external_sensor(): return Sensor("external", config_name="box", verbose=False, backend="cpu") diff --git a/tests/test_wheel_build.py b/tests/test_wheel_build.py index 7d0ec63..3a04f2b 100644 --- a/tests/test_wheel_build.py +++ b/tests/test_wheel_build.py @@ -19,25 +19,25 @@ def wheel_package(): def test_compliant_manylinux_tags(wheel_package): assert wheel_package.is_compliant_manylinux_wheel( - Path("orisys-0.4.3-cp310-cp310-manylinux_2_17_x86_64.whl") + Path("orisys-0.4.4-cp310-cp310-manylinux_2_17_x86_64.whl") ) assert wheel_package.is_compliant_manylinux_wheel( - Path("orisys-0.4.3-cp310-cp310-manylinux2014_x86_64.whl") + Path("orisys-0.4.4-cp310-cp310-manylinux2014_x86_64.whl") ) assert wheel_package.is_compliant_manylinux_wheel( - Path("orisys-0.4.3-cp310-cp310-manylinux_2_28_x86_64.whl") + Path("orisys-0.4.4-cp310-cp310-manylinux_2_28_x86_64.whl") ) def test_rejects_illegal_manylinux_tags(wheel_package): assert not wheel_package.is_compliant_manylinux_wheel( - Path("orisys-0.4.3-cp310-cp310-manylinux_x86_64.whl") + Path("orisys-0.4.4-cp310-cp310-manylinux_x86_64.whl") ) assert not wheel_package.is_compliant_manylinux_wheel( - Path("orisys-0.4.3-cp310-cp310-linux_x86_64.whl") + Path("orisys-0.4.4-cp310-cp310-linux_x86_64.whl") ) assert not wheel_package.is_compliant_manylinux_wheel( - Path("orisys-0.4.3-cp310-cp310-win_amd64.whl") + Path("orisys-0.4.4-cp310-cp310-win_amd64.whl") )