diff --git a/build/build_exe.ps1 b/build/build_exe.ps1 index adb3ca7..71c9c04 100644 --- a/build/build_exe.ps1 +++ b/build/build_exe.ps1 @@ -9,6 +9,7 @@ $DistDir = Join-Path $RepoRoot "dist" $WorkDir = Join-Path $RepoRoot "build\pyinstaller_work" $SpecPath = Join-Path $PSScriptRoot "sensor_bench.spec" $RepoConfig = Join-Path $RepoRoot "config" +$RepoSensors = Join-Path $RepoRoot "sensors" if (-not (Get-Command conda -ErrorAction SilentlyContinue)) { throw "conda was not found on PATH. Open an Anaconda Prompt or initialize conda for PowerShell." @@ -42,8 +43,11 @@ try { conda run -n $EnvName python -m PyInstaller --noconfirm --clean --distpath "$DistDir" --workpath "$WorkDir" "$SpecPath" if ($LASTEXITCODE -ne 0) { throw "PyInstaller failed." } - # Public package only: copy exhibition config/. Never ship internal/ operator assets. + # Public package only: copy exhibition config/ and sensors/. Never ship internal/ operator assets. Copy-Item -Recurse -Force $RepoConfig (Join-Path $DistDir "SensorBench") + if (Test-Path $RepoSensors) { + Copy-Item -Recurse -Force $RepoSensors (Join-Path $DistDir "SensorBench") + } $InternalLeak = Join-Path $DistDir "SensorBench\internal" if (Test-Path $InternalLeak) { throw "Public dist must not contain internal/: $InternalLeak" diff --git a/build/sensor_bench.spec b/build/sensor_bench.spec index f81477f..991ccba 100644 --- a/build/sensor_bench.spec +++ b/build/sensor_bench.spec @@ -11,6 +11,7 @@ work_dir = repo_root / "build" / "pyinstaller_work" datas = [ (str(repo_root / "config"), "config"), + (str(repo_root / "sensors"), "sensors"), ] binaries = [] hiddenimports = [ diff --git a/build/sync_wheel_configs.py b/build/sync_wheel_configs.py index 22b2a08..8dfd985 100644 --- a/build/sync_wheel_configs.py +++ b/build/sync_wheel_configs.py @@ -1,4 +1,4 @@ -"""Copy Orisys wheel sensor JSON configs into repo config/ (exclude options.json).""" +"""Copy Orisys wheel sensor JSON configs and sensors/ bundles into the repo.""" from __future__ import annotations @@ -17,6 +17,16 @@ def wheel_config_dir() -> Path: return inner_pkg.parent / "configs" +def wheel_sensors_dir() -> Path | None: + import orisys + + inner_pkg = Path(orisys.__file__).resolve().parent + for candidate in (inner_pkg / "sensors", inner_pkg.parent / "sensors"): + if candidate.is_dir() and any(candidate.iterdir()): + return candidate + return None + + def sync_configs(repo_root: Path) -> int: src = wheel_config_dir() if not src.is_dir(): @@ -37,9 +47,33 @@ def sync_configs(repo_root: Path) -> int: return 0 +def sync_sensor_bundles(repo_root: Path) -> int: + src = wheel_sensors_dir() + if src is None or not src.is_dir(): + print("Orisys wheel sensors/ directory not found; skipping bundle sync") + return 0 + + dst = repo_root / "sensors" + dst.mkdir(parents=True, exist_ok=True) + + copied = 0 + for bundle_dir in sorted(p for p in src.iterdir() if p.is_dir()): + target = dst / bundle_dir.name + if target.exists(): + shutil.rmtree(target) + shutil.copytree(bundle_dir, target) + copied += 1 + + print(f"Synced {copied} sensor bundle(s) from {src} to {dst}") + return 0 + + def main() -> int: repo_root = Path(__file__).resolve().parents[1] - return sync_configs(repo_root) + code = sync_configs(repo_root) + if code != 0: + return code + return sync_sensor_bundles(repo_root) if __name__ == "__main__": diff --git a/build/verify_dist.py b/build/verify_dist.py index 3f57417..b78b159 100644 --- a/build/verify_dist.py +++ b/build/verify_dist.py @@ -99,6 +99,8 @@ def main() -> int: require(DIST / "config" / "locales" / "zh_CN.json") require(DIST / "config" / "locales" / "en_US.json") require(DIST / "config" / "ddjx01.json") + require(DIST / "sensors" / "box" / "sensor.json") + require(DIST / "sensors" / "box" / "force_calibration.json") require(DIST / "_internal") for rel in FORBIDDEN_RELATIVE_PATHS: diff --git a/config/compound_eye.png b/config/compound_eye.png index 4d2160a..b4d46e7 100644 Binary files a/config/compound_eye.png and b/config/compound_eye.png differ diff --git a/config/locales/en_US.json b/config/locales/en_US.json index d876fa1..0c44746 100644 --- a/config/locales/en_US.json +++ b/config/locales/en_US.json @@ -67,6 +67,9 @@ "plot_fn": "Normal Force FN", "plot_fx": "Shear Force FX", "plot_fy": "Shear Force FY", + "plot_with_unit": "{title} ({unit})", + "force_calibrated": "FN={fn:.4f} {unit} (raw={fn_raw:.4f}) | FX={fx:.4f} {unit} | FY={fy:.4f} {unit}", + "force_raw": "FN={fn:.4f} | FX={fx:.4f} | FY={fy:.4f} (raw)", "reset_done": "Tracker reset.", "reset_need_start": "Start the sensor before resetting the tracker.", "reset_failed": "Reset failed: {message}", diff --git a/config/locales/zh_CN.json b/config/locales/zh_CN.json index 42da359..afa20f9 100644 --- a/config/locales/zh_CN.json +++ b/config/locales/zh_CN.json @@ -67,6 +67,9 @@ "plot_fn": "法向力 FN", "plot_fx": "切向力 FX", "plot_fy": "切向力 FY", + "plot_with_unit": "{title}({unit})", + "force_calibrated": "FN={fn:.4f} {unit}(raw={fn_raw:.4f})| FX={fx:.4f} {unit} | FY={fy:.4f} {unit}", + "force_raw": "FN={fn:.4f} | FX={fx:.4f} | FY={fy:.4f}(原始量)", "reset_done": "追踪器已重置。", "reset_need_start": "请先启动传感器后再重置追踪器。", "reset_failed": "重置失败:{message}", diff --git a/sensors/box/force_calibration.json b/sensors/box/force_calibration.json new file mode 100644 index 0000000..015fc07 --- /dev/null +++ b/sensors/box/force_calibration.json @@ -0,0 +1,30 @@ +{ + "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/box/sensor.json b/sensors/box/sensor.json new file mode 100644 index 0000000..af88413 --- /dev/null +++ b/sensors/box/sensor.json @@ -0,0 +1,46 @@ +{ + "name": "prototype", + "version": "1.0", + "camera": { + "resolution_raw": [1920, 1080], + "fps": 30, + "units": 15, + "units_used": 9, + "resolution_stitch": [365, 365], + "stitch_res_list": [365,263,123,63] + }, + "stitcher": { + "min_area_frac": 0.01, + "max_area_frac": 0.05, + "max_aspect_ratio": 50, + "index": [1,2,3,6,7,8,11,12,13], + "margin": 10, + "nominal_ov_pos": 100, + "n": [3,3], + "row_tol": 0.6, + "box_size": [250, 250], + "blend":"numba", + "blend_list": ["numpy", "numba","none"] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 3, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 3 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold":5, + "div_threshold":0.05, + "expansion_threshold": 0.01, + "kernel_size": [5,5], + "denoise_sigma": 12 + } +} + + diff --git a/sensors/finger/assets/30fps-0703/README.md b/sensors/finger/assets/30fps-0703/README.md new file mode 100644 index 0000000..24a039e --- /dev/null +++ b/sensors/finger/assets/30fps-0703/README.md @@ -0,0 +1,9 @@ +Place the ORY-FINGER runtime asset bundle files in this directory before +building release wheels: + +- `mesh.npz` +- `camera_intrinsics.npz` +- `world_to_cam.npy` + +These binary assets are validated by the wheel build and are intentionally not +generated by the package build step. diff --git a/sensors/finger/assets/30fps-0703/camera_intrinsics.npz b/sensors/finger/assets/30fps-0703/camera_intrinsics.npz new file mode 100644 index 0000000..888b1eb Binary files /dev/null and b/sensors/finger/assets/30fps-0703/camera_intrinsics.npz differ diff --git a/sensors/finger/assets/30fps-0703/mesh.npz b/sensors/finger/assets/30fps-0703/mesh.npz new file mode 100644 index 0000000..4da6bb1 Binary files /dev/null and b/sensors/finger/assets/30fps-0703/mesh.npz differ diff --git a/sensors/finger/assets/30fps-0703/pixel_to_xyz.npz b/sensors/finger/assets/30fps-0703/pixel_to_xyz.npz new file mode 100644 index 0000000..15eee73 Binary files /dev/null and b/sensors/finger/assets/30fps-0703/pixel_to_xyz.npz differ diff --git a/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz b/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz new file mode 100644 index 0000000..995cee0 Binary files /dev/null and b/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz differ diff --git a/sensors/finger/assets/30fps-0703/world_to_cam.npy b/sensors/finger/assets/30fps-0703/world_to_cam.npy new file mode 100644 index 0000000..3d6f897 Binary files /dev/null and b/sensors/finger/assets/30fps-0703/world_to_cam.npy differ diff --git a/sensors/finger/assets/visualization/README.md b/sensors/finger/assets/visualization/README.md new file mode 100644 index 0000000..fe08404 --- /dev/null +++ b/sensors/finger/assets/visualization/README.md @@ -0,0 +1,6 @@ +Place visualization camera assets in this directory before building release +wheels: + +- `world_to_cam.npy` + +The 3D dashboard uses this pose as the default view preset when available. diff --git a/sensors/finger/assets/visualization/camera_intrinsics.npz b/sensors/finger/assets/visualization/camera_intrinsics.npz new file mode 100644 index 0000000..1d211d5 Binary files /dev/null and b/sensors/finger/assets/visualization/camera_intrinsics.npz differ diff --git a/sensors/finger/assets/visualization/flatten_remap.npz b/sensors/finger/assets/visualization/flatten_remap.npz new file mode 100644 index 0000000..02ca287 Binary files /dev/null and b/sensors/finger/assets/visualization/flatten_remap.npz differ diff --git a/sensors/finger/assets/visualization/mesh.npz b/sensors/finger/assets/visualization/mesh.npz new file mode 100644 index 0000000..ad81542 Binary files /dev/null and b/sensors/finger/assets/visualization/mesh.npz differ diff --git a/sensors/finger/assets/visualization/pixel_to_xyz.npz b/sensors/finger/assets/visualization/pixel_to_xyz.npz new file mode 100644 index 0000000..4096205 Binary files /dev/null and b/sensors/finger/assets/visualization/pixel_to_xyz.npz differ diff --git a/sensors/finger/assets/visualization/world_to_cam.npy b/sensors/finger/assets/visualization/world_to_cam.npy new file mode 100644 index 0000000..2d5e411 Binary files /dev/null and b/sensors/finger/assets/visualization/world_to_cam.npy differ diff --git a/sensors/finger/manifest.json b/sensors/finger/manifest.json new file mode 100644 index 0000000..27f92c8 --- /dev/null +++ b/sensors/finger/manifest.json @@ -0,0 +1,6 @@ +{ + "name": "finger", + "sensor_json": "sensor.json", + "assets_dir": "assets/30fps-0703", + "viz_pose": "assets/visualization/world_to_cam.npy" +} diff --git a/sensors/finger/sensor.json b/sensors/finger/sensor.json new file mode 100644 index 0000000..930caeb --- /dev/null +++ b/sensors/finger/sensor.json @@ -0,0 +1,35 @@ +{ + "name": "dd02-ov", + "type": "single_camera", + "version": "1.0", + "camera": { + "resolution_raw": [1280, 720], + "fps": 30, + "resolution_stitch": [280, 300], + "source_scale": 0.4, + "old":[672, 728, 1,0.5], + "work": [504, 546,1], + "work2": [420, 450,0.8], + "work3": [350, 375,4,0.7], + "work4": [280, 300,4,0.4,0.2] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 1, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 1 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold": 0.2, + "div_threshold": 0.022, + "expansion_threshold": 0.01, + "kernel_size": [5, 5], + "denoise_sigma": 12 + } +} diff --git a/sensors/mini/force_calibration.json b/sensors/mini/force_calibration.json new file mode 100644 index 0000000..015fc07 --- /dev/null +++ b/sensors/mini/force_calibration.json @@ -0,0 +1,30 @@ +{ + "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/mini/sensor.json b/sensors/mini/sensor.json new file mode 100644 index 0000000..af88413 --- /dev/null +++ b/sensors/mini/sensor.json @@ -0,0 +1,46 @@ +{ + "name": "prototype", + "version": "1.0", + "camera": { + "resolution_raw": [1920, 1080], + "fps": 30, + "units": 15, + "units_used": 9, + "resolution_stitch": [365, 365], + "stitch_res_list": [365,263,123,63] + }, + "stitcher": { + "min_area_frac": 0.01, + "max_area_frac": 0.05, + "max_aspect_ratio": 50, + "index": [1,2,3,6,7,8,11,12,13], + "margin": 10, + "nominal_ov_pos": 100, + "n": [3,3], + "row_tol": 0.6, + "box_size": [250, 250], + "blend":"numba", + "blend_list": ["numpy", "numba","none"] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 3, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 3 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold":5, + "div_threshold":0.05, + "expansion_threshold": 0.01, + "kernel_size": [5,5], + "denoise_sigma": 12 + } +} + + diff --git a/src/sensor_bench/acquisition.py b/src/sensor_bench/acquisition.py index d0fbecc..85d73ad 100644 --- a/src/sensor_bench/acquisition.py +++ b/src/sensor_bench/acquisition.py @@ -18,6 +18,11 @@ import cv2 import numpy as np from .force_calibration import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ +from .paths import ( + bundle_has_force_calibration, + normalize_sensor_config_ref, + resolve_force_bundle_from_config, +) @dataclass(frozen=True) @@ -35,6 +40,21 @@ class FixedCameraMode: return f"{self.width}x{self.height} @{self.fps:g} (DDJX01 / MJPG)" +@dataclass +class ForceReadout: + """Calibrated and raw force values from one Orisys Sensor tick.""" + + normal_force: float = 0.0 + shear_x: float = 0.0 + shear_y: float = 0.0 + normal_force_raw: float = 0.0 + shear_x_raw: float = 0.0 + shear_y_raw: float = 0.0 + force_calibrated: bool = False + force_unit: str = "raw" + flow: Optional[np.ndarray] = None + + @dataclass class SdkPacket: """One acquisition tick from the Orisys Sensor.""" @@ -51,11 +71,109 @@ class SdkPacket: normal_force: float = 0.0 shear_x: float = 0.0 shear_y: float = 0.0 + normal_force_raw: float = 0.0 + shear_x_raw: float = 0.0 + shear_y_raw: float = 0.0 + force_unit: str = "raw" + force_calibrated: bool = False session_id: int = 0 mode: Optional[FixedCameraMode] = None flow: Optional[np.ndarray] = None +def sensor_force_calibrated(sensor, *, config_ref: str | Path | None = None) -> bool: + """True when Orisys resolved sensors//force_calibration.json.""" + if getattr(sensor, "_force_cal_sensor_name", None) is not None: + return True + bundle = resolve_force_bundle_from_config(config_ref) + return bundle is not None and bundle_has_force_calibration(bundle) + + +def read_forces_from_sensor( + sensor, + *, + include_flow: bool = True, + config_ref: str | Path | None = None, +) -> ForceReadout: + """Read FN/FX/FY plus *_RAW from Orisys, with legacy SDK fallback.""" + info = sensor.info + fn = fx = fy = 0.0 + fn_raw = fx_raw = fy_raw = 0.0 + flow = None + read_raw = all( + hasattr(info, name) + for name in ("FNORMAL_RAW", "FSHEARX_RAW", "FSHEARY_RAW") + ) + has_flow = include_flow and hasattr(info, "VRAW") + try: + if read_raw and has_flow: + fn, fx, fy, fn_raw, fx_raw, fy_raw, flow = sensor.read_info( + info.FNORMAL, + info.FSHEARX, + info.FSHEARY, + info.FNORMAL_RAW, + info.FSHEARX_RAW, + info.FSHEARY_RAW, + info.VRAW, + ) + elif read_raw: + fn, fx, fy, fn_raw, fx_raw, fy_raw = sensor.read_info( + info.FNORMAL, + info.FSHEARX, + info.FSHEARY, + info.FNORMAL_RAW, + info.FSHEARX_RAW, + info.FSHEARY_RAW, + ) + elif has_flow: + fn, fx, fy, flow = sensor.read_info( + info.FNORMAL, + info.FSHEARX, + info.FSHEARY, + info.VRAW, + ) + fn_raw, fx_raw, fy_raw = fn, fx, fy + else: + fn, fx, fy = sensor.read_info( + info.FNORMAL, + info.FSHEARX, + info.FSHEARY, + ) + fn_raw, fx_raw, fy_raw = fn, fx, fy + except Exception: # noqa: BLE001 + try: + if has_flow: + fn, fx, fy, flow = sensor.read_info( + info.FNORMAL, + info.FSHEARX, + info.FSHEARY, + info.VRAW, + ) + else: + fn, fx, fy = sensor.read_info( + info.FNORMAL, + info.FSHEARX, + info.FSHEARY, + ) + fn_raw, fx_raw, fy_raw = fn, fx, fy + except Exception: # noqa: BLE001 + pass + + calibrated = sensor_force_calibrated(sensor, config_ref=config_ref) + unit = "N" if calibrated else "raw" + return ForceReadout( + normal_force=float(fn), + shear_x=float(fx), + shear_y=float(fy), + normal_force_raw=float(fn_raw), + shear_x_raw=float(fx_raw), + shear_y_raw=float(fy_raw), + force_calibrated=calibrated, + force_unit=unit, + flow=flow, + ) + + def orisys_importable() -> tuple[bool, str]: try: import orisys # noqa: F401 @@ -255,7 +373,7 @@ class SdkAcquisitionWorker: try: self._status(f"Opening Orisys Sensor ({mode.label()})…") kwargs = { - "config_name": mode.config_ref, + "config_name": normalize_sensor_config_ref(mode.config_ref), "verbose": False, "backend": self._backend, } @@ -297,24 +415,11 @@ class SdkAcquisitionWorker: frame_times = [t for t in frame_times if t >= cutoff] fps = float(len(frame_times)) if frame_times else 0.0 - fn = fx = fy = 0.0 - flow = None - try: - fn, fx, fy, flow = self._sensor.read_info( - self._sensor.info.FNORMAL, - self._sensor.info.FSHEARX, - self._sensor.info.FSHEARY, - self._sensor.info.VRAW, - ) - except Exception: # noqa: BLE001 - try: - fn, fx, fy = self._sensor.read_info( - self._sensor.info.FNORMAL, - self._sensor.info.FSHEARX, - self._sensor.info.FSHEARY, - ) - except Exception: # noqa: BLE001 - pass + forces = read_forces_from_sensor( + self._sensor, + include_flow=True, + config_ref=mode.config_ref, + ) raw_bgr = _ensure_bgr(raw) proc_bgr = _ensure_bgr(processed) @@ -328,12 +433,17 @@ class SdkAcquisitionWorker: height=int(raw_bgr.shape[0]), processed_width=int(proc_bgr.shape[1]), processed_height=int(proc_bgr.shape[0]), - normal_force=float(fn), - shear_x=float(fx), - shear_y=float(fy), + normal_force=forces.normal_force, + shear_x=forces.shear_x, + shear_y=forces.shear_y, + normal_force_raw=forces.normal_force_raw, + shear_x_raw=forces.shear_x_raw, + shear_y_raw=forces.shear_y_raw, + force_unit=forces.force_unit, + force_calibrated=forces.force_calibrated, session_id=session, mode=mode, - flow=flow, + flow=forces.flow, ) with self._lock: self._latest = packet diff --git a/src/sensor_bench/dd06_acquisition.py b/src/sensor_bench/dd06_acquisition.py index 560ada5..e2b160d 100644 --- a/src/sensor_bench/dd06_acquisition.py +++ b/src/sensor_bench/dd06_acquisition.py @@ -7,8 +7,16 @@ import time from pathlib import Path from typing import Callable, Optional -from .acquisition import FixedCameraMode, SdkPacket, _ensure_bgr, load_fixed_mode, orisys_importable +from .acquisition import ( + FixedCameraMode, + SdkPacket, + _ensure_bgr, + load_fixed_mode, + orisys_importable, + read_forces_from_sensor, +) from .force_calibration import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ +from .paths import normalize_sensor_config_ref from .roi_geometry import ( build_roi_warp, load_json_config, @@ -138,6 +146,7 @@ class Dd06AcquisitionWorker: config = load_json_config(config_ref) roi_points, roi_image_size = load_roi_polygon(config) out_size = rectified_output_size(config, roi_points) + config_ref = normalize_sensor_config_ref(config_ref) self._sensor = orisys.Sensor("external", config_name=config_ref, verbose=False) self._sensor.configure( lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ, @@ -190,24 +199,11 @@ class Dd06AcquisitionWorker: frame_times = [t for t in frame_times if t >= cutoff] fps = float(len(frame_times)) if frame_times else 0.0 - fn = fx = fy = 0.0 - flow = None - try: - fn, fx, fy, flow = self._sensor.read_info( - self._sensor.info.FNORMAL, - self._sensor.info.FSHEARX, - self._sensor.info.FSHEARY, - self._sensor.info.VRAW, - ) - except Exception: # noqa: BLE001 - try: - fn, fx, fy = self._sensor.read_info( - self._sensor.info.FNORMAL, - self._sensor.info.FSHEARX, - self._sensor.info.FSHEARY, - ) - except Exception: # noqa: BLE001 - pass + forces = read_forces_from_sensor( + self._sensor, + include_flow=True, + config_ref=self._config_ref, + ) raw_bgr = _ensure_bgr(frame) # Prefer sensor.img / get_img result for processed visualization. @@ -225,12 +221,17 @@ class Dd06AcquisitionWorker: height=int(raw_bgr.shape[0]), processed_width=int(proc_bgr.shape[1]), processed_height=int(proc_bgr.shape[0]), - normal_force=float(fn), - shear_x=float(fx), - shear_y=float(fy), + normal_force=forces.normal_force, + shear_x=forces.shear_x, + shear_y=forces.shear_y, + normal_force_raw=forces.normal_force_raw, + shear_x_raw=forces.shear_x_raw, + shear_y_raw=forces.shear_y_raw, + force_unit=forces.force_unit, + force_calibrated=forces.force_calibrated, session_id=session, mode=mode, - flow=flow, + flow=forces.flow, ) with self._lock: self._latest = packet diff --git a/src/sensor_bench/exhibition_app.py b/src/sensor_bench/exhibition_app.py index 72935ec..cdcc74f 100644 --- a/src/sensor_bench/exhibition_app.py +++ b/src/sensor_bench/exhibition_app.py @@ -47,12 +47,13 @@ from .acquisition import ( from .camera_discovery import list_cameras, pick_default_camera from .dd06_acquisition import Dd06AcquisitionWorker from .force_calibration import ( + CALIBRATED_FORCE_Y_MAX, + CALIBRATED_FORCE_Y_MIN, FORCE_SAMPLE_RATE_HZ, PLOT_BUFFER_POINTS, PLOT_WINDOW_SEC, Y_AXIS_MIN_SPAN, Y_RANGE_ALPHA, - calibrate_normal_force, ) from .i18n import DEFAULT_LOCALE, Translator from .paths import ( @@ -226,6 +227,8 @@ class ExhibitionWindow(QMainWindow): self._view_mode = "pure_color" self._sensor_type = "orisys" self._log_panel_visible = False + self._force_unit = "raw" + self._force_calibrated = False self._fixed_mode: Optional[FixedCameraMode] = None self._orisys_config = str(default_ddjx01_config()) self._dd06_config = str(default_dd06_config()) @@ -448,12 +451,23 @@ class ExhibitionWindow(QMainWindow): self.view_combo.blockSignals(False) for plot, title_key in zip(self.plots, self.plot_titles): - plot.setTitle(self.t(title_key), color="#9aa0a6", size="12pt") + plot.setTitle(self._plot_title(title_key), color="#9aa0a6", size="12pt") if self._source_pixmap is None: self.image_label.setText(self.t("labels.no_preview")) self.status_label.setText(self.t("status.ready")) + def _plot_title(self, title_key: str) -> str: + return self.t( + "exhibition.plot_with_unit", + title=self.t(title_key), + unit=self._force_unit, + ) + + def _update_force_plot_titles(self) -> None: + for plot, title_key in zip(self.plots, self.plot_titles): + plot.setTitle(self._plot_title(title_key), color="#9aa0a6", size="12pt") + def _selected_config(self) -> str: data = self.config_combo.currentData() if data: @@ -646,6 +660,9 @@ class ExhibitionWindow(QMainWindow): return self.force_history.reset() + self._force_unit = "raw" + self._force_calibrated = False + self._update_force_plot_titles() self._awaiting_first_frame = True self._source_pixmap = None self.image_label.clear() @@ -794,12 +811,29 @@ class ExhibitionWindow(QMainWindow): ) self.status_label.setText(info) self._log(info) - self._log( - self.t( - "exhibition.force_fn_n", - value=calibrate_normal_force(packet.normal_force), + self._force_unit = packet.force_unit + self._force_calibrated = packet.force_calibrated + self._update_force_plot_titles() + if packet.force_calibrated: + self._log( + self.t( + "exhibition.force_calibrated", + fn=packet.normal_force, + fn_raw=packet.normal_force_raw, + fx=packet.shear_x, + fy=packet.shear_y, + unit=packet.force_unit, + ) + ) + else: + self._log( + self.t( + "exhibition.force_raw", + fn=packet.normal_force, + fx=packet.shear_x, + fy=packet.shear_y, + ) ) - ) self._last_packet = packet self._last_packet_id = packet_id @@ -870,8 +904,11 @@ class ExhibitionWindow(QMainWindow): x = state.times y = state.values[:, i] curve.setData(x, y) - ymin, ymax = state.y_ranges[i] - self.plots[i].setYRange(ymin, ymax) + if self._force_calibrated: + ymin, ymax = CALIBRATED_FORCE_Y_MIN, CALIBRATED_FORCE_Y_MAX + else: + ymin, ymax = state.y_ranges[i] + self.plots[i].setYRange(ymin, ymax, padding=0) self.dot_cores[i].setData(x[-1:], y[-1:]) x0, x1 = state.x_range for plot in self.plots: diff --git a/src/sensor_bench/force_calibration.py b/src/sensor_bench/force_calibration.py index a4c1068..aee1c58 100644 --- a/src/sensor_bench/force_calibration.py +++ b/src/sensor_bench/force_calibration.py @@ -11,6 +11,8 @@ FORCE_SAMPLE_RATE_HZ = 30.0 PLOT_BUFFER_POINTS = 120 PLOT_WINDOW_SEC = PLOT_BUFFER_POINTS / FORCE_SAMPLE_RATE_HZ Y_AXIS_MIN_SPAN = 20000.0 +CALIBRATED_FORCE_Y_MIN = -20.0 +CALIBRATED_FORCE_Y_MAX = 20.0 Y_RANGE_ALPHA = 0.5 LOWPASS_CUTOFF_HZ = 10.0 diff --git a/src/sensor_bench/paths.py b/src/sensor_bench/paths.py index 680e0d7..3b7bf84 100644 --- a/src/sensor_bench/paths.py +++ b/src/sensor_bench/paths.py @@ -8,6 +8,31 @@ from pathlib import Path NON_SENSOR_CONFIG_STEMS = {"options", "bench_theme"} +# Product bundles with optional force_calibration.json (orisys-sdk layout). +PRODUCT_BUNDLES = ("box", "mini", "finger") + +# Legacy flat config stems mapped to a product bundle for force calibration lookup. +LEGACY_STEM_TO_BUNDLE: dict[str, str] = { + "ddjx01": "box", + "ddjx01_480p": "box", + "ddjx01_q6a": "box", + "ddjx01_wuxi": "box", + "box": "box", + "prototype": "box", + "prototype_of3": "box", + "prototype_of3_480p": "box", + "prototype_of3_720p": "box", + "prototype_raft": "box", + "mini": "mini", + "mini-fast": "mini", + "finger": "finger", + "dd02-ov": "finger", + "dd02": "finger", + "dd06-ov": "finger", + "dd06-ov-0810": "finger", + "x-ov": "finger", +} + def is_frozen() -> bool: return bool(getattr(sys, "frozen", False)) @@ -58,6 +83,11 @@ def local_sensor_configs_dir() -> Path: return app_root() / "config" +def local_sensor_bundles_dir() -> Path: + """Per-sensor bundle root: ``sensors//sensor.json``.""" + return app_root() / "sensors" + + def _is_sensor_config_json(path: Path) -> bool: if path.suffix.lower() != ".json": return False @@ -76,10 +106,114 @@ def list_local_sensor_config_files() -> list[Path]: return sorted(p for p in root.glob("*.json") if _is_sensor_config_json(p)) +def local_sensor_bundle_sensor_json(name: str) -> Path | None: + """Return ``sensors//sensor.json`` beside the app when present.""" + path = local_sensor_bundles_dir() / name / "sensor.json" + return path if path.is_file() else None + + +def local_sensor_bundle_force_calibration(name: str) -> Path | None: + path = local_sensor_bundles_dir() / name / "force_calibration.json" + return path if path.is_file() else None + + +def list_local_sensor_bundle_names() -> list[str]: + """Product bundle folder names under ``sensors/`` with a ``sensor.json``.""" + root = local_sensor_bundles_dir() + if not root.is_dir(): + return [] + found: list[str] = [] + for name in PRODUCT_BUNDLES: + if local_sensor_bundle_sensor_json(name) is not None: + found.append(name) + return found + + +def _orisys_bundle_has_force_calibration(name: str) -> bool: + try: + import orisys + + return orisys.configs.load_force_calibration(name) is not None + except Exception: # noqa: BLE001 + return False + + +def bundle_has_force_calibration(name: str) -> bool: + """True when local or packaged Orisys bundle includes force_calibration.json.""" + if local_sensor_bundle_force_calibration(name) is not None: + return True + return _orisys_bundle_has_force_calibration(name) + + +def resolve_force_bundle_from_config(config_ref: str | Path | None) -> str | None: + """Map a legacy flat config path/stem to a product bundle with calibration.""" + if config_ref is None: + return None + text = str(config_ref).strip().replace("\\", "/") + if not text: + return None + + path = Path(text) + candidates: list[str] = [] + if path.suffix.lower() == ".json": + candidates.append(path.stem.lower()) + stem = path.stem.lower() if path.suffix else text.lower() + if stem: + candidates.append(stem) + if text.lower() in PRODUCT_BUNDLES: + candidates.insert(0, text.lower()) + parts = [p for p in text.split("/") if p] + if "sensors" in parts: + idx = parts.index("sensors") + if idx + 1 < len(parts): + candidates.insert(0, parts[idx + 1].lower()) + + seen: set[str] = set() + for candidate in candidates: + if not candidate or candidate in seen: + continue + seen.add(candidate) + if candidate in PRODUCT_BUNDLES and local_sensor_bundle_sensor_json(candidate): + if bundle_has_force_calibration(candidate): + return candidate + mapped = LEGACY_STEM_TO_BUNDLE.get(candidate) + if mapped and bundle_has_force_calibration(mapped): + return mapped + return None + + +def normalize_sensor_config_ref(config_ref: str | Path) -> str: + """Prefer product bundle names so Orisys loads ``sensors//`` calibration.""" + text = str(config_ref).strip() + if not text: + return text + + path = Path(text) + if path.is_file() and path.name.lower() == "sensor.json": + parent = path.parent.name.lower() + if parent in PRODUCT_BUNDLES: + return parent + + bundle = resolve_force_bundle_from_config(text) + if bundle is not None: + local_json = local_sensor_bundle_sensor_json(bundle) + if local_json is not None: + return str(local_json.resolve()) + return bundle + + if text.lower() in PRODUCT_BUNDLES: + local_json = local_sensor_bundle_sensor_json(text) + if local_json is not None: + return str(local_json.resolve()) + return text + + return text + + def orisys_configs_dir() -> Path | None: """Prefer local sensor configs dir when present; else installed package dir.""" local = local_sensor_configs_dir() - if list_local_sensor_config_files(): + if list_local_sensor_config_files() or list_local_sensor_bundle_names(): return local try: import orisys @@ -93,37 +227,54 @@ def orisys_configs_dir() -> Path | None: def list_orisys_configs() -> list[str]: """Return selectable sensor configs. - Local ``config/*.json`` files (excluding non-sensor stems such as - ``bench_theme``) are listed first as - absolute paths so Start uses the editable copy beside the EXE. Package stems - from the installed Orisys wheel are appended when not already covered. + Product bundles under ``sensors//sensor.json`` and Orisys bundle + names are listed before legacy flat ``config/*.json`` files so force + calibration resolves correctly. """ entries: list[str] = [] seen_stems: set[str] = set() - for path in list_local_sensor_config_files(): - stem = path.stem.lower() - if stem in seen_stems: - continue + for name in list_local_sensor_bundle_names(): + path = local_sensor_bundle_sensor_json(name) + assert path is not None entries.append(str(path.resolve())) - seen_stems.add(stem) + seen_stems.add(name.lower()) try: import orisys - for stem in orisys.configs.list_config(): - key = str(stem).strip() - if not key: - continue - if key.lower() in NON_SENSOR_CONFIG_STEMS: + for name in orisys.configs.list_sensor_bundles(): + key = str(name).strip() + if not key or key.lower() in NON_SENSOR_CONFIG_STEMS: continue if key.lower() in seen_stems: continue entries.append(key) seen_stems.add(key.lower()) + + for stem in orisys.configs.list_config(): + key = str(stem).strip() + if not key or key.lower() in NON_SENSOR_CONFIG_STEMS: + continue + if key.lower() in seen_stems: + continue + if key.lower() in PRODUCT_BUNDLES: + continue + entries.append(key) + seen_stems.add(key.lower()) except Exception: # noqa: BLE001 pass + for path in list_local_sensor_config_files(): + stem = path.stem.lower() + if stem in seen_stems: + continue + mapped = LEGACY_STEM_TO_BUNDLE.get(stem) + if mapped and mapped.lower() in seen_stems: + continue + entries.append(str(path.resolve())) + seen_stems.add(stem) + return entries @@ -133,6 +284,11 @@ def config_display_name(value: str) -> str: if not text: return "" path = Path(text) + parts = [p for p in path.as_posix().split("/") if p] + if "sensors" in parts: + idx = parts.index("sensors") + if idx + 1 < len(parts): + return parts[idx + 1] if path.suffix.lower() == ".json" or "/" in text or "\\" in text: return path.stem return text @@ -143,6 +299,8 @@ def default_config_name(sensor_type: str = "orisys") -> str: kind = str(sensor_type or "orisys").strip().lower() if kind in ("dd06", "dd06-ov", "roi", "polygon_roi"): return "dd06-ov" + if list_local_sensor_bundle_names() or _orisys_bundle_has_force_calibration("box"): + return "box" return "ddjx01" @@ -157,7 +315,7 @@ def _pick_default_config(preferred_stem: str) -> str: def default_ddjx01_config() -> str: - """Prefer local editable config path, else package stem.""" + """Prefer product bundle (box), else local editable config path.""" return _pick_default_config(default_config_name("orisys")) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 7bbadc5..60a4308 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -44,6 +44,45 @@ def test_translator_chinese_default() -> None: assert tr._lookup("en_US", "workflow.title") is None +def test_read_forces_from_sensor() -> None: + from sensor_bench.acquisition import read_forces_from_sensor, sensor_force_calibrated + + class RawSensor: + info = SimpleNamespace( + FNORMAL=1, + FSHEARX=2, + FSHEARY=3, + FNORMAL_RAW=4, + FSHEARX_RAW=5, + FSHEARY_RAW=6, + VRAW=7, + ) + _force_cal_sensor_name = "box" + + def read_info(self, *args): + return 1.1, 2.2, 3.3, 4.4, 5.5, 6.6, None + + forces = read_forces_from_sensor(RawSensor()) + assert sensor_force_calibrated(RawSensor()) + assert forces.force_calibrated is True + assert forces.force_unit == "N" + assert forces.normal_force == 1.1 + assert forces.normal_force_raw == 4.4 + + class LegacySensor: + info = SimpleNamespace(FNORMAL=1, FSHEARX=2, FSHEARY=3, VRAW=4) + + def read_info(self, *args): + if len(args) == 4: + return 10.0, 20.0, 30.0, None + return 10.0, 20.0, 30.0 + + legacy = read_forces_from_sensor(LegacySensor()) + assert legacy.force_calibrated is False + assert legacy.force_unit == "raw" + assert legacy.normal_force_raw == 10.0 + + def test_sdk_worker_packet_mapping() -> None: from sensor_bench.acquisition import SdkAcquisitionWorker @@ -104,6 +143,9 @@ def test_sdk_worker_packet_mapping() -> None: assert packet.normal_force == 1.0 assert packet.shear_x == 2.0 assert packet.shear_y == 3.0 + assert packet.normal_force_raw == 1.0 + assert packet.force_unit == "raw" + assert packet.force_calibrated is False assert packet.raw_frame.shape[:2] == (1080, 1920) assert packet.processed_frame.shape[:2] == (365, 365) @@ -137,6 +179,7 @@ def test_force_calibration_and_plot_min_span() -> None: def test_list_orisys_configs_excludes_options() -> None: from sensor_bench.paths import ( config_display_name, + list_local_sensor_bundle_names, list_local_sensor_config_files, list_orisys_configs, local_sensor_configs_dir, @@ -147,11 +190,76 @@ def test_list_orisys_configs_excludes_options() -> None: assert "options" not in labels assert all(Path(e).stem.lower() != "options" for e in entries if Path(e).suffix.lower() == ".json") + bundles = list_local_sensor_bundle_names() + if bundles: + assert labels[0] in bundles + local_ddjx = local_sensor_configs_dir() / "ddjx01.json" if local_ddjx.exists(): local_files = list_local_sensor_config_files() assert any(p.name == "ddjx01.json" for p in local_files) - assert any(config_display_name(e).lower() == "ddjx01" for e in entries) + if "box" not in bundles: + assert any(config_display_name(e).lower() == "ddjx01" for e in entries) + else: + assert not any(config_display_name(e).lower() == "ddjx01" for e in entries) + + +def test_default_config_prefers_box_bundle() -> None: + from sensor_bench.paths import ( + config_display_name, + default_config_name, + default_ddjx01_config, + list_local_sensor_bundle_names, + ) + + bundles = list_local_sensor_bundle_names() + if not bundles: + return + + assert default_config_name("orisys") == "box" + default = default_ddjx01_config() + assert config_display_name(default).lower() == "box" + + +def test_legacy_config_force_calibration_fallback() -> None: + from sensor_bench.acquisition import sensor_force_calibrated + from sensor_bench.paths import ( + bundle_has_force_calibration, + local_sensor_configs_dir, + resolve_force_bundle_from_config, + ) + + if not bundle_has_force_calibration("box"): + return + + class LegacySensor: + pass + + legacy_path = local_sensor_configs_dir() / "ddjx01.json" + if legacy_path.exists(): + assert resolve_force_bundle_from_config(legacy_path) == "box" + assert sensor_force_calibrated(LegacySensor(), config_ref=legacy_path) is True + + assert resolve_force_bundle_from_config("ddjx01") == "box" + assert resolve_force_bundle_from_config("prototype") == "box" + assert sensor_force_calibrated(LegacySensor(), config_ref="ddjx01") is True + + +def test_normalize_sensor_config_ref_prefers_bundle() -> None: + from sensor_bench.paths import ( + bundle_has_force_calibration, + local_sensor_bundle_sensor_json, + normalize_sensor_config_ref, + ) + + if not bundle_has_force_calibration("box"): + return + + local_json = local_sensor_bundle_sensor_json("box") + assert local_json is not None + normalized = normalize_sensor_config_ref("ddjx01") + assert Path(normalized).resolve() == local_json.resolve() + assert normalize_sensor_config_ref("box") == str(local_json.resolve()) def test_roi_geometry_helpers() -> None: @@ -209,10 +317,13 @@ def test_exhibition_window_offscreen() -> None: assert win.sensor_combo.count() == 2 assert win.config_combo is not None assert win.config_combo.count() >= 1 - from sensor_bench.paths import list_orisys_configs + from sensor_bench.paths import config_display_name, default_ddjx01_config, list_orisys_configs installed = list_orisys_configs() - if "ddjx01" in installed: + default = default_ddjx01_config() + if config_display_name(default).lower() == "box": + assert config_display_name(win._selected_config()).lower() == "box" + elif "ddjx01" in installed: assert win.config_combo.currentData() == "ddjx01" or win._selected_config() == "ddjx01" win.language_combo.setCurrentIndex(1) @@ -222,6 +333,18 @@ def test_exhibition_window_offscreen() -> None: assert win.btn_reset.text() == "Reset" assert win.show_log_check.text() == "Show log panel" assert "About" in win.about_action.text() + title0 = win.plots[0].titleLabel.text + assert "FN" in title0 + assert "raw" in title0 + + from sensor_bench.force_calibration import CALIBRATED_FORCE_Y_MAX, CALIBRATED_FORCE_Y_MIN + + win._force_calibrated = True + win.force_history.push(time.time(), 1.0, -2.0, 3.0) + win._update_plots() + ymin, ymax = win.plots[0].viewRange()[1] + assert abs(ymin - CALIBRATED_FORCE_Y_MIN) < 1e-6 + assert abs(ymax - CALIBRATED_FORCE_Y_MAX) < 1e-6 win.view_combo.setCurrentIndex(1) assert win._view_mode == "captured" @@ -235,9 +358,13 @@ def test_exhibition_window_offscreen() -> None: if __name__ == "__main__": test_load_fixed_mode() test_translator_chinese_default() + test_read_forces_from_sensor() test_sdk_worker_packet_mapping() test_force_calibration_and_plot_min_span() test_list_orisys_configs_excludes_options() + test_default_config_prefers_box_bundle() + test_legacy_config_force_calibration_fallback() + test_normalize_sensor_config_ref_prefers_bundle() test_roi_geometry_helpers() test_exhibition_window_offscreen() print("ok")