fix path errors

This commit is contained in:
ykcheungab
2026-08-26 19:22:37 +08:00
parent 2cb152b76e
commit d59e1cb7fb
35 changed files with 546 additions and 69 deletions
+4
View File
@@ -17,6 +17,8 @@ from orisys.finger.mesh import UVSurfaceMesh
from orisys.finger.viewer import init_uv_mesh_viewer from orisys.finger.viewer import init_uv_mesh_viewer
from orisys.hhd import nHHD from orisys.hhd import nHHD
from orisys.tracker import Tracker from orisys.tracker import Tracker
from orisys import capture
from orisys.Stitcher import Stitcher
assert orisys.__version__ assert orisys.__version__
assert Sensor is not None assert Sensor is not None
@@ -31,6 +33,8 @@ assert UVSurfaceMesh is not None
assert init_uv_mesh_viewer is not None assert init_uv_mesh_viewer is not None
assert nHHD is not None assert nHHD is not None
assert Tracker 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") print(f"orisys {orisys.__version__} public API imports OK")
""" """
+2 -2
View File
@@ -43,7 +43,7 @@ EXCLUDE_MODULES = [
DATA_DIRS = [ DATA_DIRS = [
("config", "config"), ("config", "config"),
("assets", "assets"), ("sensors", "sensors"),
] ]
README_LINES = [ README_LINES = [
@@ -56,7 +56,7 @@ README_LINES = [
"", "",
"Bundled folders:", "Bundled folders:",
" config/ sensor JSON configs", " 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).", "CPU-only build (no CUDA/CuPy).",
] ]
+1 -1
View File
@@ -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['configs']} JSON config files")
print(f"Copied {copied_counts['examples']} example files to orisys/examples/") 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['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( write_wheel_setup(
NUITKA_WHEEL_DIR, NUITKA_WHEEL_DIR,
+3
View File
@@ -16,7 +16,10 @@ from common.paths import repo_path
NUITKA_INCLUDE_PACKAGES = ( NUITKA_INCLUDE_PACKAGES = (
"orisys", "orisys",
"orisys.capture",
"orisys.finger", "orisys.finger",
"orisys.stitch",
"orisys.stitch.backends",
"orisys.unroll", "orisys.unroll",
"orisys.hhd", "orisys.hhd",
"orisys.hhd.solvers", "orisys.hhd.solvers",
+5 -5
View File
@@ -5,7 +5,7 @@ description: 安装并运行 Orisys SDK 的基础步骤
# 快速开始 # 快速开始
版本:0.4.3 版本:0.4.4
## 简介 ## 简介
本文件介绍如何上手和运行叠动视触觉传感器配套的开发套件。本套件是配合叠动视触觉传感器进行图像处理,计算形变、力以及接触信息。它可以从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。 本文件介绍如何上手和运行叠动视触觉传感器配套的开发套件。本套件是配合叠动视触觉传感器进行图像处理,计算形变、力以及接触信息。它可以从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。
@@ -84,9 +84,9 @@ pip install --upgrade orisys
### 使用 .whl 文件安装(离线或指定版本) ### 使用 .whl 文件安装(离线或指定版本)
从官方渠道获取 .whl 文件,文件名格式通常为: 从官方渠道获取 .whl 文件,文件名格式通常为:
orisys-{版本}-{Python版本}-{Python版本}-{操作系统}_{硬件架构}.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) Python 版本:3.10(cp310)
操作系统:Windows(win) 操作系统:Windows(win)
硬件架构:64位(amd64) 硬件架构:64位(amd64)
@@ -97,7 +97,7 @@ Python 版本:3.10(cp310)
```bash ```bash
# 请将文件名替换为您实际下载的文件名 # 请将文件名替换为您实际下载的文件名
pip install orisys-0.4.3-cp310-cp310-win_amd64.whl pip install orisys-0.4.4-cp310-cp310-win_amd64.whl
``` ```
### 安装 CuPy(可选) ### 安装 CuPy(可选)
@@ -139,7 +139,7 @@ python
import orisys import orisys
print(orisys.__version__) print(orisys.__version__)
``` ```
如果输出版本号(例如 0.4.3),则说明安装成功。 如果输出版本号(例如 0.4.4),则说明安装成功。
## 使用方法 ## 使用方法
### 运行示例程序 ### 运行示例程序
+1 -1
View File
@@ -7,7 +7,7 @@ ORY-BOX:单传感器基础流程
3. 按需计算接触区域 3. 按需计算接触区域
4. 读取并可视化结果 4. 读取并可视化结果
SDK version: 0.4.3 SDK version: 0.4.4
""" """
import os import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
+3 -3
View File
@@ -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", "sensor_bundle_dir": "sensors/finger",
"config_path": "sensors/finger/sensor.json", "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",
"show_log": true, "show_log": true,
"show_flow_arrows": false, "show_flow_arrows": false,
"arrow_background": "pure_color", "arrow_background": "unrolled",
"free_rotate": true, "free_rotate": true,
"log_display_mode": "hidden" "log_display_mode": "hidden"
} }
+32 -1
View File
@@ -76,11 +76,37 @@ 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:
@@ -124,6 +150,9 @@ 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,
@@ -134,7 +163,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), normal_force_calibrated=calibrate_normal_force(fn_raw, sensor_name=cal_name),
fps=float(fps), fps=float(fps),
timestamp=time.time(), timestamp=time.time(),
raw_image=raw_image, raw_image=raw_image,
@@ -198,6 +227,7 @@ 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:
@@ -274,6 +304,7 @@ 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:
+30 -9
View File
@@ -646,22 +646,42 @@ class FingerDashboardWindow(QMainWindow):
self._update_play_enabled_for_source() self._update_play_enabled_for_source()
def _select_config(self) -> None: 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( path, _ = QFileDialog.getOpenFileName(
self, self,
"选择配置文件", "选择配置文件",
"config", start_dir,
"JSON (*.json);;所有文件 (*)", "JSON (*.json);;所有文件 (*)",
) )
if path: if path:
self.settings_panel.set_config_path(path) config = normalize_config_preference(path) or default_finger_config()
self.config.config = path self.settings_panel.set_config_path(config)
self.config.config = config
self._save_preferences() self._save_preferences()
def _select_finger_assets(self) -> None: 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( path = QFileDialog.getExistingDirectory(
self, self,
"选择传感器 bundle 目录", "选择传感器 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: if path:
bundle_dir = Path(path) bundle_dir = Path(path)
@@ -677,12 +697,13 @@ class FingerDashboardWindow(QMainWindow):
if isinstance(rel_assets, str) and rel_assets.strip(): if isinstance(rel_assets, str) and rel_assets.strip():
assets_dir = bundle_dir / rel_assets assets_dir = bundle_dir / rel_assets
if config_path.is_file(): if config_path.is_file():
self.settings_panel.set_config_path(str(config_path)) config = normalize_config_preference(str(config_path)) or default_finger_config()
self.config.config = str(config_path) self.settings_panel.set_config_path(config)
self.settings_panel.set_finger_export_dir(str(assets_dir)) self.config.config = config
self.config.export_dir = str(assets_dir) 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() self._save_preferences()
def _select_csv_dir(self) -> None: def _select_csv_dir(self) -> None:
path = QFileDialog.getExistingDirectory( path = QFileDialog.getExistingDirectory(
self, self,
+101 -3
View File
@@ -13,6 +13,17 @@ LEGACY_CAMERA_SELECTION_FILE = "camera_selection.json"
# Keep preference.json at examples/gui/ for backward compatibility. # Keep preference.json at examples/gui/ for backward compatibility.
_GUI_DIR = Path(__file__).resolve().parents[1] _GUI_DIR = Path(__file__).resolve().parents[1]
# Old lab / repo-relative paths that predate packaged sensors/<name>/.
_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: def preference_path() -> Path:
# Keep preference file at examples/gui/preference.json # 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) 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]: def _read_json(path: Path) -> dict[str, Any]:
if not path.is_file(): if not path.is_file():
return {} return {}
@@ -34,14 +131,14 @@ def _read_json(path: Path) -> dict[str, Any]:
def load_preferences() -> 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()) prefs = _read_json(preference_path())
if "video_source" not in prefs: if "video_source" not in prefs:
legacy = _read_json(legacy_camera_selection_path()) legacy = _read_json(legacy_camera_selection_path())
camera_id = legacy.get("camera_id") camera_id = legacy.get("camera_id")
if camera_id is not None and str(camera_id).strip(): if camera_id is not None and str(camera_id).strip():
prefs["video_source"] = 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: 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): if not isinstance(prefs, dict):
return return
path = preference_path() path = preference_path()
normalized = _normalize_preferences(prefs)
try: try:
path.parent.mkdir(parents=True, exist_ok=True) 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: except OSError:
pass pass
+31 -1
View File
@@ -25,6 +25,23 @@ class SensorBundlePreset:
export_dir: str 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: def _load_manifest(bundle_root: Path) -> dict:
path = bundle_root / "manifest.json" path = bundle_root / "manifest.json"
if not path.is_file(): if not path.is_file():
@@ -50,6 +67,14 @@ def _bundle_from_root(bundle_root: Path, *, label: str | None = None) -> SensorB
break break
except ValueError: except ValueError:
continue 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( return SensorBundlePreset(
label=label or "ORY-FINGER", label=label or "ORY-FINGER",
bundle_dir=rel_bundle, 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: def default_finger_bundle() -> SensorBundlePreset:
packaged = _packaged_finger_preset()
if packaged is not None:
return packaged
for root in _repo_root_candidates(): for root in _repo_root_candidates():
preset = _bundle_from_root(root / "sensors" / "finger", label="ORY-FINGER") preset = _bundle_from_root(root / "sensors" / "finger", label="ORY-FINGER")
if preset is not None: if preset is not None:
@@ -66,7 +94,7 @@ def default_finger_bundle() -> SensorBundlePreset:
return SensorBundlePreset( return SensorBundlePreset(
label="ORY-FINGER", label="ORY-FINGER",
bundle_dir="sensors/finger", bundle_dir="sensors/finger",
config="sensors/finger/sensor.json", config="finger",
export_dir=DEFAULT_FINGER_EXPORT_DIR, export_dir=DEFAULT_FINGER_EXPORT_DIR,
) )
@@ -108,6 +136,8 @@ def sensor_bundle_preset_index(
for idx, preset in enumerate(presets): for idx, preset in enumerate(presets):
if config == preset.config and export_dir == preset.export_dir: if config == preset.config and export_dir == preset.export_dir:
return idx 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): if config.endswith("/sensor.json") and export_dir.endswith("/" + Path(preset.export_dir).name):
return idx return idx
return -1 return -1
@@ -16,6 +16,7 @@ from PyQt5.QtWidgets import (
) )
from viz.render_2d import ArrowBackground from viz.render_2d import ArrowBackground
from orisys.finger import DEFAULT_FINGER_EXPORT_DIR
from ..layout import DashboardLayout, LAYOUT from ..layout import DashboardLayout, LAYOUT
from ..mesh_view_state import MeshViewState from ..mesh_view_state import MeshViewState
@@ -157,7 +158,7 @@ class SettingsPanelWidget(QWidget):
bundle_row.addWidget(self.btn_browse_finger_assets) bundle_row.addWidget(self.btn_browse_finger_assets)
root.addLayout(bundle_row) root.addLayout(bundle_row)
self.config_edit = QLineEdit("sensors/finger/sensor.json") self.config_edit = QLineEdit("finger")
self.config_edit.hide() self.config_edit.hide()
self.btn_browse_config = QPushButton("…") self.btn_browse_config = QPushButton("…")
self.btn_browse_config.hide() self.btn_browse_config.hide()
@@ -167,10 +168,9 @@ class SettingsPanelWidget(QWidget):
self.cal_edit.setPlaceholderText("Optional calibration path") self.cal_edit.setPlaceholderText("Optional calibration path")
self.cal_edit.hide() 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.finger_assets_edit.hide()
self._apply_bundle_preset(self._bundle_presets[0]) self._apply_bundle_preset(self._bundle_presets[0])
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)
+1 -1
View File
@@ -7,7 +7,7 @@ ORY-MINI:单传感器基础流程
3. 按需计算接触区域 3. 按需计算接触区域
4. 读取并可视化结果 4. 读取并可视化结果
SDK version: 0.4.3 SDK version: 0.4.4
""" """
import os import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
+1 -1
View File
@@ -18,7 +18,7 @@
- Windows 下必须在 if __name__ == '__main__' 中调用 mp.freeze_support() - Windows 下必须在 if __name__ == '__main__' 中调用 mp.freeze_support()
- 每个传感器进程都会占用独立的 GPU 显存 - 每个传感器进程都会占用独立的 GPU 显存
SDK version: 0.4.3 SDK version: 0.4.4
""" """
import os import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
+7 -2
View File
@@ -7,6 +7,10 @@ from pathlib import Path
import numpy as np import numpy as np
DEFAULT_FINGER_VIZ_POSE = "sensors/finger/assets/visualization/world_to_cam.npy" 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]: 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() here = Path(__file__).resolve()
candidates: list[Path] = [] candidates: list[Path] = []
# Prefer the sensors/finger bundle for the default (and matching relative) path. # Prefer the sensors/finger bundle for default / legacy relative paths.
if path is None or raw.as_posix() == DEFAULT_FINGER_VIZ_POSE or not raw.is_absolute(): 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()) candidates.extend(_finger_bundle_viz_pose_candidates())
try: try:
+27 -5
View File
@@ -1,8 +1,30 @@
{ {
"normal_force": { "schema_version": 1,
"model": "linear", "unit": "N",
"slope": 3.92685554668795e-05, "channels": {
"offset": 0.0217596967278535, "normal": {
"unit": "N" "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]
]
}
} }
} }
+1 -1
View File
@@ -22,7 +22,7 @@
"dis_preset": "medium", "dis_preset": "medium",
"dis_mode": 1, "dis_mode": 1,
"algorithm_list": ["raft", "dis"], "algorithm_list": ["raft", "dis"],
"hhd_downsample": 5 "hhd_downsample": 1
}, },
"contact": { "contact": {
"min_contour_area": 3000, "min_contour_area": 3000,
+1 -1
View File
@@ -5,7 +5,7 @@ A Python library for processing and analyzing optical tactile sensor data,
including image stitching, optical flow tracking, and deformation decomposition. including image stitching, optical flow tracking, and deformation decomposition.
""" """
__version__ = "0.4.3" __version__ = "0.4.4"
__author__ = "Orisys" __author__ = "Orisys"
from .sensor import Sensor from .sensor import Sensor
+238 -18
View File
@@ -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. Batch ``lowpass_filter`` is the zero-phase Butterworth used by example charts.
``LowpassFilter`` is a causal, stateful filter for per-frame ``Sensor`` outputs. ``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/<name>/force_calibration.json``
3. Hardcoded linear defaults below
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
import numpy as np import numpy as np
from scipy.signal import butter, filtfilt, sosfilt, sosfilt_zi from scipy.signal import butter, filtfilt, sosfilt, sosfilt_zi
@@ -18,28 +25,241 @@ DEFAULT_SAMPLE_RATE_HZ = 30.0
DEFAULT_LOWPASS_ORDER = 2 DEFAULT_LOWPASS_ORDER = 2
def _load_normal_force_coeffs(sensor_name: str | None = None) -> tuple[float, float]: def _parse_calibration_point(point) -> tuple[float, float]:
if sensor_name: if isinstance(point, dict):
try: raw = point.get("fnormal", point.get("raw", point.get("x")))
from .configs import load_force_calibration 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: @dataclass(frozen=True)
data = None class ForceChannelCalibration:
if isinstance(data, dict): """One force channel: linear coeffs and/or piecewise-linear breakpoints."""
block = data.get("normal_force", data)
if isinstance(block, dict): method: str = "linear"
slope = block.get("slope") slope: float | None = None
offset = block.get("offset") offset: float | None = None
if slope is not None and offset is not None: points: tuple[tuple[float, float], ...] = ()
return float(slope), float(offset) 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 return NORMAL_FORCE_CAL_SLOPE, NORMAL_FORCE_CAL_OFFSET
def calibrate_normal_force(raw_value: float, *, sensor_name: str | None = None) -> float: 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.""" """Convert a raw normal-force reading to Newtons.
slope, offset = _load_normal_force_coeffs(sensor_name)
return slope * float(raw_value) + offset Uses ``sensors/<name>/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: def _nyquist_hz(sample_rate_hz: float) -> float:
+1 -1
View File
@@ -8,7 +8,7 @@ Flow:
4. Feed the rectified frame into Sensor.get_img(..., already_processed=True) 4. Feed the rectified frame into Sensor.get_img(..., already_processed=True)
5. Compute deformation/contact and visualize 5. Compute deformation/contact and visualize
SDK version: 0.4.3 SDK version: 0.4.4
""" """
from __future__ import annotations from __future__ import annotations
+5 -4
View File
@@ -35,7 +35,7 @@ def test_pyproject_dynamic_version(packaging_utils):
sys.path.insert(0, str(build_dir)) sys.path.insert(0, str(build_dir))
from common.pyproject_utils import get_version 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): def test_pyproject_bundle_manifest(packaging_utils):
@@ -48,7 +48,7 @@ def test_pyproject_bundle_manifest(packaging_utils):
items = bundle_items(REPO_ROOT) items = bundle_items(REPO_ROOT)
names = {item["name"] for item in items} 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)) data_files = dict(bundle_data_files(REPO_ROOT))
assert "orisys/configs" in data_files 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 all("/" in path and "*" not in path for path in config_files)
assert any(path.endswith(".json") 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/")] sensor_dirs = [dest for dest in data_files if dest.startswith("orisys/sensors/")]
assert asset_dirs 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 all("*" not in path for files in data_files.values() for path in files)
assert "examples" not in data_files assert "examples" not in data_files
+42
View File
@@ -12,6 +12,8 @@ from orisys.signal_processing import (
NORMAL_FORCE_CAL_SLOPE, NORMAL_FORCE_CAL_SLOPE,
LowpassFilter, LowpassFilter,
calibrate_normal_force, calibrate_normal_force,
calibrate_shear_components,
calibrate_shear_force,
lowpass_filter, 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) 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(): def _external_sensor():
return Sensor("external", config_name="box", verbose=False, backend="cpu") return Sensor("external", config_name="box", verbose=False, backend="cpu")
+6 -6
View File
@@ -19,25 +19,25 @@ def wheel_package():
def test_compliant_manylinux_tags(wheel_package): def test_compliant_manylinux_tags(wheel_package):
assert wheel_package.is_compliant_manylinux_wheel( 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( 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( 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): def test_rejects_illegal_manylinux_tags(wheel_package):
assert not wheel_package.is_compliant_manylinux_wheel( 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( 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( 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")
) )