添加按设备 ID 选择和校验标定文件

避免多台传感器误用默认标定,缺文件时保持 raw。已通过 SDK 回归测试和文档检查。
This commit is contained in:
ykcheungab
2026-10-05 12:21:04 +08:00
parent 230b04a79d
commit 80f7da5a20
6 changed files with 261 additions and 10 deletions
+25 -4
View File
@@ -9,7 +9,8 @@ PyPI: [https://pypi.org/project/orisys/](https://pypi.org/project/orisys/)
## Repository Layout ## Repository Layout
- `src/orisys/`: installable SDK package - `src/orisys/`: installable SDK package
- `config/`: default and lab/runtime JSON configurations - `sensors/`: product bundles containing the current `sensor.json` configurations and assets
- `config/`: legacy JSON configurations retained for compatibility
- `examples/`: runnable demos (`basic_pipeline.py`, `mini_pipeline.py`, `finger_pipeline*.py`) plus shared `gui/`, `pipeline/`, `viz/` - `examples/`: runnable demos (`basic_pipeline.py`, `mini_pipeline.py`, `finger_pipeline*.py`) plus shared `gui/`, `pipeline/`, `viz/`
- `scripts/`: maintenance, calibration, packaging, and demo scripts - `scripts/`: maintenance, calibration, packaging, and demo scripts
- `tools/`: non-package tooling such as patch-generation utilities - `tools/`: non-package tooling such as patch-generation utilities
@@ -73,12 +74,13 @@ Run commands from the repository root.
## Configuration ## Configuration
The canonical configuration files now live in the repo-level `config/` folder. Product configurations live in `sensors/<name>/sensor.json`. The repo-level `config/`
folder remains available for older configurations and aliases.
The package loader `orisys.configs` resolves configs from: The package loader `orisys.configs` resolves configs from:
1. an explicit JSON file path 1. an explicit JSON file path
2. a config file stem under `config/` (for example `box`, `mini`, `finger`) 2. `sensors/<name>/sensor.json` (for example `box`, `mini`, `finger`)
3. a product alias such as `dd02` -> `dd02-ov` 3. a file stem under the legacy `config/` folder, including aliases such as `dd02` -> `dd02-ov`
4. a backward-compatible JSON `"name"` match, such as `"prototype"` 4. a backward-compatible JSON `"name"` match, such as `"prototype"`
Examples: Examples:
@@ -90,6 +92,25 @@ sensor = orisys.Sensor(0, config_name="box")
cfg = orisys.export_config("box", "./config/exported_config.json") cfg = orisys.export_config("box", "./config/exported_config.json")
``` ```
For multiple physical sensors, keep calibration files in a user-selected directory:
`<calibration_dir>/<product>/<device_id>.json` (for example,
`./calibrations/box/BOX-001.json`). Each file uses the existing calibration JSON
format and must contain matching `identity.sensor_type` and `identity.sn`, Newton
curves for FN/FX/FY, and a compatible measurement pipeline. Enter the device ID
manually until hardware identification is available:
```python
sensor = orisys.Sensor(0, config_name="box", calibration_dir="./calibrations", device_id="BOX-001")
```
If that ID has no file, the sensor reports raw force; it never falls back to the
product's default. An invalid or mismatched file raises `CalibrationError`.
`FingerRuntime.open` accepts the same `calibration_dir` and `device_id` arguments.
Existing calls without a device ID still use the product bundle default, and
explicit `cal_path` remains available for one-off use. `cal_path` and
`calibration_dir` cannot be combined. Pass `device_id` with `cal_path` to validate
that explicit file against the selected device.
## Examples and Tools ## Examples and Tools
- Example entry points: `examples/README.md` - Example entry points: `examples/README.md`
+55
View File
@@ -7,6 +7,7 @@ curves in ``_apply_force_filter``; ``read_info(FNORMAL, …)`` returns Newtons.
from __future__ import annotations from __future__ import annotations
import json import json
import re
import warnings import warnings
from collections.abc import Iterable, Mapping from collections.abc import Iterable, Mapping
from copy import deepcopy from copy import deepcopy
@@ -805,6 +806,60 @@ def load(
return None return None
def load_for_device(
calibration_dir: str | Path,
sensor_type: str,
device_id: str,
*,
pipeline: Mapping[str, Any] | None = None,
) -> Calibration | None:
"""Load ``<directory>/<sensor_type>/<device_id>.json``; missing file means raw."""
root = Path(calibration_dir).expanduser()
if not root.is_dir():
raise CalibrationError(f"calibration directory not found: {root}")
for label, value in (("sensor_type", sensor_type), ("device_id", device_id)):
if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", value):
raise CalibrationError(f"invalid {label}: {value!r}")
path = root / sensor_type / f"{device_id}.json"
if not path.exists():
if path.is_symlink():
raise CalibrationError(f"calibration link is broken: {path}")
return None
if not path.is_file():
raise CalibrationError(f"calibration path is not a file: {path}")
cal = load(path, strict=True)
assert cal is not None
return validate_for_device(cal, sensor_type, device_id, pipeline=pipeline)
def validate_for_device(
cal: Calibration,
sensor_type: str,
device_id: str,
*,
pipeline: Mapping[str, Any] | None = None,
) -> Calibration:
"""Reject a calibration for another device or measurement pipeline."""
path = cal.source_path or "<in-memory calibration>"
if cal.identity.get("sensor_type") != sensor_type or cal.identity.get("sn") != device_id:
raise CalibrationError(f"calibration identity mismatch: {path}")
if cal.unit != "N" or any(cal.channels[axis].unit != "N" for axis in cal.channels):
raise CalibrationError(f"calibration unit must be N: {path}")
if set(CHANNEL_IDS) - cal.channels.keys():
raise CalibrationError(f"calibration requires FN, FX and FY: {path}")
if pipeline is not None:
for key in ("stitch_resolution", "dis_mode", "hhd_downsample"):
recorded, current = cal.pipeline.get(key), pipeline.get(key)
if recorded is not None and current is not None and recorded != current:
raise CalibrationError(f"calibration pipeline mismatch ({key}): {path}")
measurement, _ = filter_mismatches(
cal.pipeline.get("filters"), pipeline.get("filters")
)
if measurement:
raise CalibrationError(f"calibration pipeline mismatch ({'; '.join(measurement)}): {path}")
return cal
def pipeline_from_sensor_json( def pipeline_from_sensor_json(
path: str | Path | None, path: str | Path | None,
*, *,
+9 -1
View File
@@ -254,6 +254,9 @@ class FingerRuntime:
map_cache=DEFAULT_FINGER_MAP_CACHE, map_cache=DEFAULT_FINGER_MAP_CACHE,
source_scale=None, source_scale=None,
calib=None, calib=None,
cal_path=None,
calibration_dir=None,
device_id=None,
video=0, video=0,
camera_id=None, camera_id=None,
flip_source_vertical=True, flip_source_vertical=True,
@@ -293,6 +296,12 @@ class FingerRuntime:
) )
domain.remap.ensure_fast_maps() domain.remap.ensure_fast_maps()
sensor = Sensor(
"external", config_name=config, cal_path=cal_path,
calibration_dir=calibration_dir, device_id=device_id,
verbose=verbose, mirror=mirror,
)
capture_config = CaptureConfig.from_camera_dict( capture_config = CaptureConfig.from_camera_dict(
video, video,
geometry.config.get("camera", {}), geometry.config.get("camera", {}),
@@ -307,7 +316,6 @@ class FingerRuntime:
capture_source.start_background() capture_source.start_background()
capture = capture_source.cap capture = capture_source.cap
sensor = Sensor("external", config_name=config, verbose=verbose, mirror=mirror)
if not is_live_camera(video): if not is_live_camera(video):
# File playback: wall-clock cadence is not signal time, so pin the # File playback: wall-clock cadence is not signal time, so pin the
# force-filter design rate to the configured camera fps. # force-filter design rate to the configured camera fps.
+63 -5
View File
@@ -143,6 +143,7 @@ class Sensor:
backend='auto', capture_config: CaptureConfig | None = None, backend='auto', capture_config: CaptureConfig | None = None,
auto_reconnect: bool = False, capture_mode: str = "latest", auto_reconnect: bool = False, capture_mode: str = "latest",
capture_queue_size: int = 1, capture_overflow: str = "drop_oldest", capture_queue_size: int = 1, capture_overflow: str = "drop_oldest",
calibration_dir: str | Path | None = None, device_id: str | None = None,
**_deprecated_capture_kwargs): **_deprecated_capture_kwargs):
""" """
Initialize Sensor with video source and configuration. Initialize Sensor with video source and configuration.
@@ -154,6 +155,8 @@ class Sensor:
verbose: Print initialization logs (default: True) verbose: Print initialization logs (default: True)
config_name: Configuration name (default: "prototype") config_name: Configuration name (default: "prototype")
cal_path: Explicit force_calibration.json path (overrides bundle default) cal_path: Explicit force_calibration.json path (overrides bundle default)
calibration_dir: User-managed root containing <product>/<device_id>.json.
device_id: Manually entered or hardware-reported device identity.
override: Skip auto-detection, use provided box (default: False) override: Skip auto-detection, use provided box (default: False)
box: Manual bounding boxes when override=True (default: None) box: Manual bounding boxes when override=True (default: None)
backend: Poisson backend (default: "auto") backend: Poisson backend (default: "auto")
@@ -184,6 +187,15 @@ class Sensor:
self.debug = debug self.debug = debug
self.config_name = config_name self.config_name = config_name
self.cal_path = cal_path self.cal_path = cal_path
if calibration_dir is not None and device_id is None:
raise cal_mod.CalibrationError("device_id is required with calibration_dir")
if device_id is not None and calibration_dir is None and cal_path is None:
raise cal_mod.CalibrationError("device_id requires calibration_dir or cal_path")
if calibration_dir is not None and cal_path is not None:
raise cal_mod.CalibrationError("cal_path and calibration_dir are mutually exclusive")
self.calibration_dir = calibration_dir
self.device_id = device_id
self.device_calibration_missing = False
self.mirror = mirror self.mirror = mirror
self._capture_config_override = capture_config self._capture_config_override = capture_config
self._auto_reconnect = bool(auto_reconnect) self._auto_reconnect = bool(auto_reconnect)
@@ -1273,6 +1285,11 @@ class Sensor:
bundle from another root and stopped there without ever asking whether bundle from another root and stopped there without ever asking whether
that root had coefficients. that root had coefficients.
""" """
if getattr(self, "device_calibration_missing", False):
return (
f"Force calibration: raw (no file for {self.device_id!r} "
f"under {self.calibration_dir})"
)
attempts = self._force_cal_attempts or [] attempts = self._force_cal_attempts or []
# ``found_root`` is keyed even when nothing was found (valued None), so # ``found_root`` is keyed even when nothing was found (valued None), so
# test its value, not the key. # test its value, not the key.
@@ -1306,14 +1323,30 @@ class Sensor:
return "\n".join(lines) return "\n".join(lines)
def _init_force_calibration(self) -> None: def _init_force_calibration(self) -> None:
"""Load force calibration from ``cal_path`` or the product bundle.""" """Load the selected force calibration before processing frames."""
self.calibration = None self.calibration = None
self._force_cal_sensor_name = None self._force_cal_sensor_name = None
self._force_cal_attempts = None self._force_cal_attempts = None
family = None
pipeline = None
if self.device_id is not None:
family, pipeline = self._device_calibration_expectations()
if self.cal_path is not None and str(self.cal_path).strip(): if self.cal_path is not None and str(self.cal_path).strip():
# Explicit path: missing/invalid raises (no silent fallback). # Explicit path: missing/invalid raises (no silent fallback).
self.calibration = cal_mod.load(self.cal_path, strict=True) self.calibration = cal_mod.load(self.cal_path, strict=True)
if self.device_id is not None:
self.calibration = cal_mod.validate_for_device(
self.calibration, family, self.device_id, pipeline=pipeline
)
return
if self.calibration_dir is not None:
self.calibration = cal_mod.load_for_device(
self.calibration_dir, family, self.device_id, pipeline=pipeline
)
self.device_calibration_missing = self.calibration is None
return return
attempts: list[dict] = [] attempts: list[dict] = []
@@ -1333,6 +1366,24 @@ class Sensor:
# without coefficients is not that. # without coefficients is not that.
self._force_cal_attempts = attempts or None self._force_cal_attempts = attempts or None
def _device_calibration_expectations(self):
family = next(
(name for name in self._bundle_name_candidates() if name in configs.PRODUCT_BUNDLES),
None,
)
if family is None:
raise cal_mod.CalibrationError(
f"cannot determine product family from config_name={self.config_name!r}"
)
camera = self._config.get("camera", {})
tracker = self._config.get("tracker", {})
return family, {
"stitch_resolution": camera.get("resolution_stitch"),
"dis_mode": tracker.get("dis_mode"),
"hhd_downsample": tracker.get("hhd_downsample"),
"filters": cal_mod.filters_fingerprint(self._config.get("filters")),
}
@property @property
def force_calibrated(self) -> bool: def force_calibrated(self) -> bool:
"""True when at least one channel curve is loaded.""" """True when at least one channel curve is loaded."""
@@ -1349,14 +1400,21 @@ class Sensor:
"""Attach or replace the active force calibration (live swap after save).""" """Attach or replace the active force calibration (live swap after save)."""
if cal is None: if cal is None:
self.calibration = None self.calibration = None
self.device_calibration_missing = False
self._force_cal_sensor_name = None self._force_cal_sensor_name = None
self._force_cal_attempts = None self._force_cal_attempts = None
return return
if isinstance(cal, cal_mod.Calibration): if isinstance(cal, cal_mod.Calibration):
self.calibration = cal selected = cal
self._force_cal_attempts = None else:
return selected = cal_mod.load(cal, strict=True)
self.calibration = cal_mod.load(cal, strict=True) if self.device_id is not None:
family, pipeline = self._device_calibration_expectations()
selected = cal_mod.validate_for_device(
selected, family, self.device_id, pipeline=pipeline
)
self.calibration = selected
self.device_calibration_missing = False
self._force_cal_attempts = None self._force_cal_attempts = None
def _apply_force_filter(self): def _apply_force_filter(self):
+78
View File
@@ -17,6 +17,7 @@ from orisys.calibration import (
filter_mismatches, filter_mismatches,
filters_fingerprint, filters_fingerprint,
load, load,
load_for_device,
parse, parse,
pipeline_from_sensor_json, pipeline_from_sensor_json,
write, write,
@@ -241,6 +242,83 @@ def test_missing_cal_path_raises():
) )
def _device_file(root: Path, device_id: str, *, family: str = "box") -> Path:
path = root / family / f"{device_id}.json"
path.parent.mkdir(parents=True, exist_ok=True)
doc = json.loads(_box_cal_path().read_text(encoding="utf-8"))
doc["identity"] = {"sensor_type": family, "sn": device_id}
path.write_text(json.dumps(doc), encoding="utf-8")
return path
def test_device_calibration_selection_and_missing_raw(tmp_path: Path):
root = tmp_path / "calibrations"
root.mkdir()
selected = _device_file(root, "BOX-001")
assert load_for_device(root, "box", "BOX-001").source_path == selected.resolve()
assert load_for_device(root, "box", "BOX-002") is None
sensor = Sensor(
"external", config_name="box", calibration_dir=root,
device_id="BOX-001", verbose=False, backend="cpu",
)
try:
assert sensor.force_unit == "N"
assert sensor.calibration.source_path == selected.resolve()
other = _device_file(root, "BOX-003")
with pytest.raises(CalibrationError, match="identity mismatch"):
sensor.set_calibration(other)
assert sensor.calibration.source_path == selected.resolve()
finally:
sensor.disconnect()
sensor = Sensor(
"external", config_name="box", calibration_dir=root,
device_id="BOX-002", verbose=False, backend="cpu",
)
try:
assert sensor.force_unit == "raw"
assert sensor.device_calibration_missing
assert sensor.calibration is None
finally:
sensor.disconnect()
def test_device_calibration_rejects_wrong_identity_and_pipeline(tmp_path: Path):
root = tmp_path / "calibrations"
root.mkdir()
path = _device_file(root, "BOX-001")
doc = json.loads(path.read_text(encoding="utf-8"))
doc["identity"]["sn"] = "BOX-002"
path.write_text(json.dumps(doc), encoding="utf-8")
with pytest.raises(CalibrationError, match="identity mismatch"):
load_for_device(root, "box", "BOX-001")
with pytest.raises(CalibrationError, match="identity mismatch"):
Sensor(
"external", config_name="box", cal_path=path,
device_id="BOX-001", verbose=False, backend="cpu",
)
doc["identity"]["sn"] = "BOX-001"
doc["pipeline"] = {"dis_mode": 999}
path.write_text(json.dumps(doc), encoding="utf-8")
with pytest.raises(CalibrationError, match="pipeline mismatch"):
load_for_device(root, "box", "BOX-001", pipeline={"dis_mode": 1})
def test_device_calibration_rejects_partial_axes_and_unsafe_id(tmp_path: Path):
root = tmp_path / "calibrations"
root.mkdir()
path = _device_file(root, "BOX-001")
doc = json.loads(path.read_text(encoding="utf-8"))
del doc["channels"]["shear_y"]
path.write_text(json.dumps(doc), encoding="utf-8")
with pytest.raises(CalibrationError, match="requires FN, FX and FY"):
load_for_device(root, "box", "BOX-001")
with pytest.raises(CalibrationError, match="invalid device_id"):
load_for_device(root, "box", "../BOX-001")
def test_parse_shipped_box_file_directly(): def test_parse_shipped_box_file_directly():
cal = parse(json.loads(_box_cal_path().read_text(encoding="utf-8")), source_path=_box_cal_path()) cal = parse(json.loads(_box_cal_path().read_text(encoding="utf-8")), source_path=_box_cal_path())
assert cal.schema_version == 2 assert cal.schema_version == 2
+31
View File
@@ -1,4 +1,5 @@
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
import numpy as np import numpy as np
import pytest import pytest
@@ -159,6 +160,36 @@ def test_finger_runtime_get_img_returns_none_on_capture_failure():
assert runtime.get_img() is None assert runtime.get_img() is None
def test_finger_open_selects_device_calibration_before_capture(monkeypatch, tmp_path):
import orisys.finger.runtime as runtime_mod
selected = {}
geometry = SimpleNamespace(
export_paths=SimpleNamespace(as_tuple=lambda: ("mesh", "intrinsics", "pose")),
map_cache="cache", source_K=np.eye(3), source_width=2, source_height=2,
out_width=2, out_height=2, edge_expand=1.0, source_scale=1.0,
config={"camera": {}},
)
monkeypatch.setattr(runtime_mod, "resolve_finger_geometry", lambda **_: geometry)
monkeypatch.setattr(
runtime_mod, "build_or_load_domain",
lambda *args, **kwargs: SimpleNamespace(
remap=SimpleNamespace(ensure_fast_maps=lambda: None)
),
)
monkeypatch.setattr(runtime_mod, "Sensor", lambda *args, **kwargs: selected.update(kwargs))
monkeypatch.setattr(runtime_mod.CaptureConfig, "from_camera_dict", lambda *args, **kwargs: None)
def stop_before_capture(*args, **kwargs):
assert selected["calibration_dir"] == tmp_path
assert selected["device_id"] == "FINGER-001"
raise RuntimeError("capture reached")
monkeypatch.setattr(runtime_mod, "open_capture", stop_before_capture)
with pytest.raises(RuntimeError, match="capture reached"):
runtime_mod.FingerRuntime.open(calibration_dir=tmp_path, device_id="FINGER-001")
def test_finger_runtime_get_img_returns_unrolled_frame_on_success(): def test_finger_runtime_get_img_returns_unrolled_frame_on_success():
from orisys.finger.runtime import FingerRuntime from orisys.finger.runtime import FingerRuntime