添加按设备 ID 选择和校验标定文件
避免多台传感器误用默认标定,缺文件时保持 raw。已通过 SDK 回归测试和文档检查。
This commit is contained in:
@@ -7,6 +7,7 @@ curves in ``_apply_force_filter``; ``read_info(FNORMAL, …)`` returns Newtons.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import warnings
|
||||
from collections.abc import Iterable, Mapping
|
||||
from copy import deepcopy
|
||||
@@ -805,6 +806,60 @@ def load(
|
||||
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(
|
||||
path: str | Path | None,
|
||||
*,
|
||||
|
||||
@@ -254,6 +254,9 @@ class FingerRuntime:
|
||||
map_cache=DEFAULT_FINGER_MAP_CACHE,
|
||||
source_scale=None,
|
||||
calib=None,
|
||||
cal_path=None,
|
||||
calibration_dir=None,
|
||||
device_id=None,
|
||||
video=0,
|
||||
camera_id=None,
|
||||
flip_source_vertical=True,
|
||||
@@ -293,6 +296,12 @@ class FingerRuntime:
|
||||
)
|
||||
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(
|
||||
video,
|
||||
geometry.config.get("camera", {}),
|
||||
@@ -307,7 +316,6 @@ class FingerRuntime:
|
||||
capture_source.start_background()
|
||||
capture = capture_source.cap
|
||||
|
||||
sensor = Sensor("external", config_name=config, verbose=verbose, mirror=mirror)
|
||||
if not is_live_camera(video):
|
||||
# File playback: wall-clock cadence is not signal time, so pin the
|
||||
# force-filter design rate to the configured camera fps.
|
||||
|
||||
+63
-5
@@ -143,6 +143,7 @@ class Sensor:
|
||||
backend='auto', capture_config: CaptureConfig | None = None,
|
||||
auto_reconnect: bool = False, capture_mode: str = "latest",
|
||||
capture_queue_size: int = 1, capture_overflow: str = "drop_oldest",
|
||||
calibration_dir: str | Path | None = None, device_id: str | None = None,
|
||||
**_deprecated_capture_kwargs):
|
||||
"""
|
||||
Initialize Sensor with video source and configuration.
|
||||
@@ -154,6 +155,8 @@ class Sensor:
|
||||
verbose: Print initialization logs (default: True)
|
||||
config_name: Configuration name (default: "prototype")
|
||||
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)
|
||||
box: Manual bounding boxes when override=True (default: None)
|
||||
backend: Poisson backend (default: "auto")
|
||||
@@ -184,6 +187,15 @@ class Sensor:
|
||||
self.debug = debug
|
||||
self.config_name = config_name
|
||||
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._capture_config_override = capture_config
|
||||
self._auto_reconnect = bool(auto_reconnect)
|
||||
@@ -1273,6 +1285,11 @@ class Sensor:
|
||||
bundle from another root and stopped there without ever asking whether
|
||||
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 []
|
||||
# ``found_root`` is keyed even when nothing was found (valued None), so
|
||||
# test its value, not the key.
|
||||
@@ -1306,14 +1323,30 @@ class Sensor:
|
||||
return "\n".join(lines)
|
||||
|
||||
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._force_cal_sensor_name = 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():
|
||||
# Explicit path: missing/invalid raises (no silent fallback).
|
||||
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
|
||||
|
||||
attempts: list[dict] = []
|
||||
@@ -1333,6 +1366,24 @@ class Sensor:
|
||||
# without coefficients is not that.
|
||||
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
|
||||
def force_calibrated(self) -> bool:
|
||||
"""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)."""
|
||||
if cal is None:
|
||||
self.calibration = None
|
||||
self.device_calibration_missing = False
|
||||
self._force_cal_sensor_name = None
|
||||
self._force_cal_attempts = None
|
||||
return
|
||||
if isinstance(cal, cal_mod.Calibration):
|
||||
self.calibration = cal
|
||||
self._force_cal_attempts = None
|
||||
return
|
||||
self.calibration = cal_mod.load(cal, strict=True)
|
||||
selected = cal
|
||||
else:
|
||||
selected = 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
|
||||
|
||||
def _apply_force_filter(self):
|
||||
|
||||
Reference in New Issue
Block a user