接入力标定 schema 3 与多点空间增益模型
保留旧格式和 cal_path,校验 SN、特征身份及坐标附件,按帧返回逐轴状态。相关测试 263 项通过,已核对存量预测并实测 180 帧录像。
This commit is contained in:
@@ -17,7 +17,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
CURRENT_SCHEMA = 2
|
||||
SUPPORTED_SCHEMAS = (1, 2)
|
||||
SUPPORTED_SCHEMAS = (1, 2, 3)
|
||||
CHANNEL_IDS: tuple[str, ...] = ("fn", "fx", "fy")
|
||||
|
||||
CHANNEL_ALIASES: dict[str, str] = {
|
||||
@@ -500,6 +500,17 @@ def check(doc: dict | Calibration, *, pipeline: dict | None = None) -> CheckResu
|
||||
warns: list[str] = []
|
||||
notes: list[str] = []
|
||||
|
||||
if getattr(doc, "schema_version", None) == 3 or (isinstance(doc, dict) and doc.get("schema_version") == 3):
|
||||
from .calibration_models import parse_model
|
||||
|
||||
try:
|
||||
selected = parse_model(doc) if isinstance(doc, dict) else doc
|
||||
if pipeline is not None:
|
||||
selected.validate_for_context(pipeline)
|
||||
except (CalibrationError, ValueError, TypeError) as exc:
|
||||
return CheckResult(ok=False, errors=[str(exc)])
|
||||
return CheckResult(ok=True, errors=[], warnings=[])
|
||||
|
||||
if isinstance(doc, Calibration):
|
||||
data = doc.to_dict()
|
||||
channels_obj = doc.channels
|
||||
@@ -605,6 +616,14 @@ def parse(data: dict, *, source_path: Path | None = None) -> Calibration:
|
||||
if schema not in SUPPORTED_SCHEMAS:
|
||||
raise CalibrationError(f"unsupported schema_version: {schema}")
|
||||
|
||||
if schema == 3:
|
||||
from .calibration_models import parse_model
|
||||
|
||||
try:
|
||||
return parse_model(data, source_path=source_path)
|
||||
except (ValueError, TypeError, KeyError) as exc:
|
||||
raise CalibrationError(f"invalid schema 3 calibration: {exc}") from exc
|
||||
|
||||
unit = str(data.get("unit", "N"))
|
||||
channels_raw = data.get("channels")
|
||||
if not isinstance(channels_raw, dict):
|
||||
@@ -766,7 +785,7 @@ def load(
|
||||
explicit = source is not None and str(source).strip() != ""
|
||||
|
||||
if explicit:
|
||||
path = Path(str(source)).expanduser()
|
||||
path = select_calibration_path(cal_path=source)
|
||||
if not path.is_file():
|
||||
msg = f"calibration file not found: {path}"
|
||||
if strict or explicit:
|
||||
@@ -801,7 +820,7 @@ def load(
|
||||
try:
|
||||
return parse(data, source_path=path)
|
||||
except CalibrationError:
|
||||
if strict or explicit:
|
||||
if strict or explicit or (isinstance(data, dict) and (data.get("schema_version") == 3 or "model" in data)):
|
||||
raise
|
||||
return None
|
||||
|
||||
@@ -814,6 +833,26 @@ def load_for_device(
|
||||
pipeline: Mapping[str, Any] | None = None,
|
||||
) -> Calibration | None:
|
||||
"""Load ``<directory>/<sensor_type>/<device_id>.json``; missing file means raw."""
|
||||
path = select_calibration_path(calibration_dir=calibration_dir, sensor_type=sensor_type, device_id=device_id)
|
||||
if path is None:
|
||||
return None
|
||||
cal = load(path, strict=True)
|
||||
assert cal is not None
|
||||
return validate_for_device(cal, sensor_type, device_id, pipeline=pipeline)
|
||||
|
||||
|
||||
def select_calibration_path(*, cal_path=None, calibration_dir=None, sensor_type=None,
|
||||
device_id=None, default_path=None) -> Path | None:
|
||||
"""Select a file without guessing a model or falling back across device identities."""
|
||||
explicit = cal_path is not None and bool(str(cal_path).strip())
|
||||
if explicit and calibration_dir is not None:
|
||||
raise CalibrationError("cal_path and calibration_dir are mutually exclusive")
|
||||
if device_id is not None and not explicit and calibration_dir is None:
|
||||
raise CalibrationError("device_id requires calibration_dir or cal_path")
|
||||
if explicit:
|
||||
return Path(cal_path).expanduser()
|
||||
if calibration_dir is None:
|
||||
return Path(default_path) if default_path is not None else None
|
||||
root = Path(calibration_dir).expanduser()
|
||||
if not root.is_dir():
|
||||
raise CalibrationError(f"calibration directory not found: {root}")
|
||||
@@ -827,9 +866,7 @@ def load_for_device(
|
||||
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)
|
||||
return path
|
||||
|
||||
|
||||
def validate_for_device(
|
||||
@@ -843,6 +880,19 @@ def validate_for_device(
|
||||
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.schema_version == 3:
|
||||
if cal.unit != "N" or not cal.channels:
|
||||
raise CalibrationError(f"calibration requires declared force axes in N: {path}")
|
||||
# Full feature/geometry validation happens when the processor binds its
|
||||
# actual context. Device construction only knows this legacy subset.
|
||||
if pipeline is not None:
|
||||
for key in ("stitch_resolution", "dis_mode", "hhd_downsample"):
|
||||
if pipeline.get(key) is not None and cal.pipeline.get(key) != pipeline[key]:
|
||||
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
|
||||
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():
|
||||
@@ -1050,10 +1100,13 @@ def write(
|
||||
path = Path(path)
|
||||
if path.exists() and not overwrite:
|
||||
raise FileExistsError(f"calibration file exists: {path}")
|
||||
if cal.schema_version == 3:
|
||||
# A moved manifest must not acquire unresolved or different attachments.
|
||||
parse(cal.to_dict(), source_path=path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
doc = cal.to_dict()
|
||||
# Always emit schema 2 for new writes.
|
||||
doc["schema_version"] = CURRENT_SCHEMA
|
||||
# Preserve an explicitly selected model format; legacy builders still emit v2.
|
||||
doc["schema_version"] = 3 if cal.schema_version == 3 else CURRENT_SCHEMA
|
||||
path.write_text(
|
||||
json.dumps(doc, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Measurement identities shared by SDK runtime and offline calibration exporters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .calibration import filters_fingerprint
|
||||
|
||||
|
||||
def sha256_file(path) -> str:
|
||||
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def domain_fingerprint(domain) -> str:
|
||||
"""Bind a coordinate lookup to the actual unroll, not just its dimensions."""
|
||||
data = domain.to_dict() if hasattr(domain, "to_dict") else domain
|
||||
digest = hashlib.sha256()
|
||||
for key, dtype in (("map_x", "<f4"), ("map_y", "<f4"), ("valid_mask", "u1"), ("world_xyz", "<f4")):
|
||||
array = np.ascontiguousarray(data[key], dtype=dtype)
|
||||
digest.update(key.encode("ascii"))
|
||||
digest.update(str(array.shape).encode("ascii"))
|
||||
digest.update(array.tobytes())
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def build_context(config, filters=None, *, geometry_sha256=None, source_scale=None,
|
||||
coordinates=None, output_domain_sha256=None, processing_backend=None,
|
||||
preprocessing=None) -> dict:
|
||||
"""Build the closed v1 feature identity from effective values, excluding readout filters."""
|
||||
camera = config.get("camera", {})
|
||||
tracker = dict(config.get("tracker", {}))
|
||||
raw_downsample = tracker.get("hhd_downsample", 1)
|
||||
try:
|
||||
downsample = max(1, int(raw_downsample)) if raw_downsample is not None else 1
|
||||
except (TypeError, ValueError):
|
||||
downsample = 1
|
||||
tracker["hhd_downsample"] = downsample
|
||||
if source_scale is not None:
|
||||
source_scale = float(source_scale)
|
||||
measurement = filters_fingerprint(config.get("filters", {}) if filters is None else filters).get("measurement", {})
|
||||
# A missing EMA setting has the same effective meaning as the SDK default.
|
||||
measurement.setdefault("flow_ema_alpha", 1.0)
|
||||
result = {
|
||||
"stitch_resolution": camera.get("resolution_stitch"),
|
||||
"dis_mode": tracker.get("dis_mode"),
|
||||
"hhd_downsample": downsample,
|
||||
"filters": {"measurement": measurement},
|
||||
}
|
||||
if geometry_sha256 is not None:
|
||||
result["geometry_sha256"] = geometry_sha256
|
||||
if source_scale is not None:
|
||||
result["source_scale"] = float(source_scale)
|
||||
if coordinates is not None:
|
||||
result["coordinates"] = coordinates
|
||||
if output_domain_sha256 is not None:
|
||||
result["output_domain_sha256"] = output_domain_sha256
|
||||
effective = {
|
||||
"feature_version": "orisys_hhd_raw/1",
|
||||
"processing_backend": processing_backend or config.get("type", "compound_eye"),
|
||||
"camera": {key: camera.get(key) for key in ("resolution_raw", "resolution_stitch")},
|
||||
"tracker": tracker,
|
||||
"contact": config.get("contact", {}),
|
||||
"measurement": measurement,
|
||||
"geometry_sha256": geometry_sha256,
|
||||
"output_domain_sha256": output_domain_sha256,
|
||||
"source_scale": source_scale,
|
||||
"preprocessing": preprocessing or {},
|
||||
}
|
||||
encoded = json.dumps(effective, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
result["feature_signature_sha256"] = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
return result
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Validated, versioned force models; acquisition and training stay outside here."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from scipy.spatial import ConvexHull, QhullError
|
||||
|
||||
|
||||
def _require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def _keys(value: Any, required: set[str], optional: set[str] | None = None) -> None:
|
||||
_require(isinstance(value, dict), "expected a JSON object")
|
||||
_require(required <= value.keys(), f"missing fields: {required - value.keys()}")
|
||||
_require(value.keys() <= required | (optional or set()), "unknown semantic fields")
|
||||
|
||||
|
||||
def _number(value: Any) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
||||
|
||||
|
||||
def _sha(value: Any) -> bool:
|
||||
return isinstance(value, str) and len(value) == 64 and all(c in "0123456789abcdef" for c in value)
|
||||
|
||||
|
||||
def _area(polygon: np.ndarray) -> float:
|
||||
if len(polygon) < 3:
|
||||
return 0.0
|
||||
return abs(float(np.dot(polygon[:, 0], np.roll(polygon[:, 1], 1)) -
|
||||
np.dot(polygon[:, 1], np.roll(polygon[:, 0], 1)))) / 2
|
||||
|
||||
|
||||
def _cross(a: np.ndarray, b: np.ndarray) -> float:
|
||||
return float(a[0] * b[1] - a[1] * b[0])
|
||||
|
||||
|
||||
def _intersection_area(first: np.ndarray, second: np.ndarray) -> float:
|
||||
"""Clip two counterclockwise convex polygons, including shared edges."""
|
||||
polygon = list(first)
|
||||
for a, b in zip(second, np.roll(second, -1, axis=0)):
|
||||
previous = polygon
|
||||
polygon = []
|
||||
if not previous:
|
||||
break
|
||||
for start, end in zip(previous, previous[1:] + previous[:1]):
|
||||
ds, de = _cross(b - a, start - a), _cross(b - a, end - a)
|
||||
if (ds >= 0) != (de >= 0):
|
||||
polygon.append(start + (end - start) * ds / (ds - de))
|
||||
if de >= 0:
|
||||
polygon.append(end)
|
||||
return _area(np.asarray(polygon))
|
||||
|
||||
|
||||
def _mesh(params: dict[str, Any]) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
points, gains, triangles = (params[k] for k in ("points_uv", "gains_N_per_raw", "triangles"))
|
||||
_require(isinstance(points, list) and len(points) >= 3, "spatial mesh needs at least 3 points")
|
||||
_require(all(isinstance(p, list) and len(p) == 2 and all(_number(x) for x in p) for p in points),
|
||||
"points_uv must be finite Nx2 numbers")
|
||||
_require(len({tuple(p) for p in points}) == len(points), "duplicate spatial points")
|
||||
_require(isinstance(gains, list) and len(gains) == len(points) and
|
||||
all(_number(g) and g > 0 for g in gains), "gains must be finite positive numbers")
|
||||
_require(isinstance(triangles, list) and bool(triangles), "triangles are required")
|
||||
_require(all(isinstance(t, list) and len(t) == 3 and
|
||||
all(type(i) is int and 0 <= i < len(points) for i in t) and len(set(t)) == 3
|
||||
for t in triangles),
|
||||
"invalid triangle indices")
|
||||
vertices = np.asarray(points, dtype=float)
|
||||
try:
|
||||
hull_area = ConvexHull(vertices).volume
|
||||
except QhullError as exc:
|
||||
raise ValueError("spatial mesh points are collinear") from exc
|
||||
tolerance = max(1.0, hull_area) * 1e-9
|
||||
polygons = []
|
||||
for indices in triangles:
|
||||
polygon = vertices[indices]
|
||||
signed = _cross(polygon[1] - polygon[0], polygon[2] - polygon[0])
|
||||
_require(abs(signed) / 2 > tolerance, "degenerate triangle")
|
||||
if signed < 0:
|
||||
polygon = polygon[::-1]
|
||||
_require(all(_intersection_area(polygon, other) <= tolerance for other in polygons),
|
||||
"overlapping spatial triangles")
|
||||
polygons.append(polygon)
|
||||
_require({i for triangle in triangles for i in triangle} == set(range(len(points))),
|
||||
"spatial mesh must use all support points")
|
||||
_require(abs(sum(_area(p) for p in polygons) - hull_area) <= tolerance,
|
||||
"triangulation must cover the complete convex hull")
|
||||
return vertices, np.asarray(gains, dtype=float), np.asarray(triangles, dtype=int)
|
||||
|
||||
|
||||
def _pipeline(data: Any, spatial: bool) -> None:
|
||||
required = {"stitch_resolution", "dis_mode", "hhd_downsample",
|
||||
"filters", "feature_signature_sha256"}
|
||||
_keys(data, required | ({"geometry_sha256", "coordinates", "source_scale"} if spatial else set()),
|
||||
{"geometry_sha256", "coordinates", "output_domain_sha256", "source_scale"})
|
||||
size = data["stitch_resolution"]
|
||||
_require(isinstance(size, list) and len(size) == 2 and all(type(n) is int and n > 0 for n in size),
|
||||
"invalid stitch_resolution")
|
||||
for key in ("dis_mode", "hhd_downsample"):
|
||||
_require(type(data[key]) is int and data[key] >= (1 if key == "hhd_downsample" else 0),
|
||||
f"invalid {key}")
|
||||
if "source_scale" in data:
|
||||
_require(_number(data["source_scale"]) and data["source_scale"] > 0, "invalid source_scale")
|
||||
_require(_sha(data["feature_signature_sha256"]), "invalid feature signature SHA-256")
|
||||
_keys(data["filters"], {"measurement"}, {"readout"})
|
||||
_keys(data["filters"]["measurement"], {"flow_ema_alpha"})
|
||||
alpha = data["filters"]["measurement"]["flow_ema_alpha"]
|
||||
_require(_number(alpha) and 0 < alpha <= 1, "invalid flow_ema_alpha")
|
||||
if "geometry_sha256" in data:
|
||||
_require(_sha(data["geometry_sha256"]), "invalid geometry SHA-256")
|
||||
if "output_domain_sha256" in data:
|
||||
_require(_sha(data["output_domain_sha256"]), "invalid output domain SHA-256")
|
||||
if "coordinates" in data:
|
||||
coord = data["coordinates"]
|
||||
_keys(coord, {"frame", "unit", "origin_xy", "map_sha256"})
|
||||
_require(coord["frame"] == "autocal_flat" and coord["unit"] == "px", "unsupported coordinates")
|
||||
_require(isinstance(coord["origin_xy"], list) and len(coord["origin_xy"]) == 2 and
|
||||
all(_number(x) for x in coord["origin_xy"]), "invalid coordinate origin")
|
||||
_require(_sha(coord["map_sha256"]), "invalid map SHA-256")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CalibrationResult:
|
||||
values_N: dict[str, float | None]
|
||||
status: dict[str, str]
|
||||
|
||||
|
||||
class ModelCalibration:
|
||||
"""One validated model per stream. Current gain backends are stateless."""
|
||||
|
||||
schema_version = 3
|
||||
unit = "N"
|
||||
|
||||
def __init__(self, document: dict[str, Any], source_path: str | Path | None = None):
|
||||
self._document = deepcopy(document)
|
||||
self.source_path = Path(source_path).resolve() if source_path is not None else None
|
||||
self.identity = deepcopy(document["identity"])
|
||||
self.pipeline = deepcopy(document["pipeline"])
|
||||
self.provenance = deepcopy(document.get("provenance", {}))
|
||||
self.model_type = document["model"]["type"]
|
||||
self.input_profile = document["model"]["input_profile"]
|
||||
self.outputs = tuple(document["model"]["outputs"])
|
||||
self.channels = {axis: "N" for axis in self.outputs}
|
||||
self._params = self._document["model"]["params"]
|
||||
self.coordinate_map = self.coordinate_valid_mask = None
|
||||
self._artifact_hashes: list[str] = []
|
||||
if self.model_type == "spatial_gain/1":
|
||||
self._points, self._gains, self._triangles = _mesh(self._params)
|
||||
self._load_artifacts(document["model"].get("artifacts", []))
|
||||
|
||||
def _load_artifacts(self, artifacts: Any) -> None:
|
||||
_require(isinstance(artifacts, list), "artifacts must be an array")
|
||||
_require(len(artifacts) <= 1, "only one coordinate_map artifact is supported")
|
||||
for artifact in artifacts:
|
||||
_keys(artifact, {"role", "path", "sha256"})
|
||||
_require(self.model_type == "spatial_gain/1" and artifact["role"] == "coordinate_map",
|
||||
"unsupported artifact role")
|
||||
name = artifact["path"]
|
||||
_require(isinstance(name, str) and bool(name) and ":" not in name and
|
||||
not PureWindowsPath(name).is_absolute() and not PurePosixPath(name).is_absolute() and
|
||||
".." not in PurePosixPath(name.replace("\\", "/")).parts,
|
||||
"artifact path must be relative without parent traversal")
|
||||
_require(self.source_path is not None, "artifact requires calibration source_path")
|
||||
path = (self.source_path.parent / name).resolve()
|
||||
_require(path.is_relative_to(self.source_path.parent) and path.is_file(),
|
||||
"artifact missing or escapes calibration directory")
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
_require(_sha(artifact["sha256"]) and digest == artifact["sha256"], "artifact SHA-256 mismatch")
|
||||
_require(self.pipeline["coordinates"]["map_sha256"] == digest,
|
||||
"coordinate map identity does not match artifact SHA-256")
|
||||
self._artifact_hashes.append(digest)
|
||||
try:
|
||||
with np.load(path, allow_pickle=False) as archive:
|
||||
keys = set(archive.files)
|
||||
_require({"uv_by_output_px", "valid_mask"} <= keys <=
|
||||
{"uv_by_output_px", "valid_mask", "origin_xy", "flat_mapping_sha256"},
|
||||
"invalid coordinate_map keys")
|
||||
uv, mask = archive["uv_by_output_px"], archive["valid_mask"]
|
||||
_require(uv.ndim == 3 and uv.shape[2] == 2 and mask.shape == uv.shape[:2],
|
||||
"invalid coordinate_map shape")
|
||||
_require(mask.dtype == np.bool_ and np.issubdtype(uv.dtype, np.floating),
|
||||
"invalid coordinate_map dtype")
|
||||
_require(np.isfinite(uv[mask]).all(), "non-finite mapped coordinates")
|
||||
if "origin_xy" in keys:
|
||||
_require(np.array_equal(archive["origin_xy"], self.pipeline["coordinates"]["origin_xy"]),
|
||||
"coordinate_map origin mismatch")
|
||||
self.coordinate_map, self.coordinate_valid_mask = uv.copy(), mask.copy()
|
||||
self.coordinate_map.flags.writeable = False
|
||||
self.coordinate_valid_mask.flags.writeable = False
|
||||
except (OSError, TypeError) as exc:
|
||||
raise ValueError("invalid coordinate_map artifact") from exc
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
content = json.dumps(self._document, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
return hashlib.sha256((content + "".join(self._artifact_hashes)).encode("utf-8")).hexdigest()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return deepcopy(self._document)
|
||||
|
||||
def validate_for_context(self, pipeline: Mapping[str, Any]) -> None:
|
||||
for key, value in self.pipeline.items():
|
||||
if key == "filters":
|
||||
actual = pipeline.get(key, {})
|
||||
_require(isinstance(actual, Mapping) and actual.get("measurement") == value["measurement"],
|
||||
"calibration pipeline mismatch: filters.measurement")
|
||||
else:
|
||||
_require(pipeline.get(key) == value, f"calibration pipeline mismatch: {key}")
|
||||
|
||||
def reset(self, reference_id: Any = None) -> None:
|
||||
"""Gain backends have no history; preserve the lifecycle for future models."""
|
||||
|
||||
def convert(self, fn: float, fx: float, fy: float) -> None:
|
||||
"""Reject legacy scalar calls which lack frame, position and zero features."""
|
||||
from .calibration import CalibrationError
|
||||
|
||||
raise CalibrationError("schema 3 requires frame features; use predict/apply_calibration_frame")
|
||||
|
||||
def predict(self, frame_id: int, timestamp_s: float | None, features: Mapping[str, Any],
|
||||
reference_id: Any = None) -> CalibrationResult:
|
||||
def invalid(reason: str) -> CalibrationResult:
|
||||
return CalibrationResult({a: None for a in self.outputs}, {a: reason for a in self.outputs})
|
||||
|
||||
if (not isinstance(features, Mapping) or type(frame_id) is not int or
|
||||
(timestamp_s is not None and not _number(timestamp_s))):
|
||||
return invalid("invalid_input")
|
||||
if features.get("zero_valid") is not True:
|
||||
return invalid("zero_unavailable")
|
||||
if features.get("contact_found") is False:
|
||||
return invalid("no_contact")
|
||||
values = {}
|
||||
if self.model_type == "spatial_gain/1":
|
||||
if features.get("contact_found") is not True:
|
||||
return invalid("invalid_input")
|
||||
if features.get("single_contact", True) is not True or features.get("contact_count", 1) != 1:
|
||||
return invalid("invalid_input")
|
||||
if features.get("mapping_valid") is not True:
|
||||
return invalid("coordinate_unmapped")
|
||||
if not all(_number(features.get(k)) for k in ("delta_raw", "u", "v")):
|
||||
return invalid("invalid_input")
|
||||
query = np.asarray([features["u"], features["v"]])
|
||||
for indices in self._triangles:
|
||||
triangle = self._points[indices]
|
||||
weights = np.linalg.solve((triangle[1:] - triangle[0]).T, query - triangle[0])
|
||||
weights = np.asarray([1 - sum(weights), *weights])
|
||||
if np.min(weights) >= -1e-10:
|
||||
values["fn"] = float(np.dot(weights, self._gains[indices]) * features["delta_raw"])
|
||||
break
|
||||
if not values:
|
||||
return invalid("outside_coverage")
|
||||
else:
|
||||
deltas = features.get("delta_raw_by_axis", features.get("force_delta_raw", {}))
|
||||
if not isinstance(deltas, Mapping):
|
||||
return invalid("invalid_input")
|
||||
for axis in self.outputs:
|
||||
delta = deltas.get(axis, features.get("delta_raw") if axis == "fn" else None)
|
||||
values[axis] = delta * self._params["gains_N_per_raw"][axis] if _number(delta) else None
|
||||
low, high = self._params["valid_force_range_N"]
|
||||
statuses = {}
|
||||
for axis, value in values.items():
|
||||
if value is None or not _number(value):
|
||||
values[axis], statuses[axis] = None, "invalid_input"
|
||||
else:
|
||||
statuses[axis] = "ok" if low <= value <= high else "out_of_range"
|
||||
return CalibrationResult(values, statuses)
|
||||
|
||||
|
||||
def parse_model(data: dict[str, Any], source_path: str | Path | None = None) -> ModelCalibration:
|
||||
"""Parse schema 3 without executing document-specified code or installing backends."""
|
||||
_keys(data, {"schema_version", "unit", "identity", "pipeline", "model"}, {"provenance"})
|
||||
_require(type(data["schema_version"]) is int and data["schema_version"] == 3, "expected schema_version 3")
|
||||
_require(data["unit"] == "N", "schema 3 unit must be N")
|
||||
_keys(data["identity"], {"sensor_type", "sn"})
|
||||
_require(all(isinstance(v, str) and bool(v.strip()) for v in data["identity"].values()), "invalid identity")
|
||||
_require(isinstance(data.get("provenance", {}), dict), "provenance must be an object")
|
||||
model = data["model"]
|
||||
_keys(model, {"type", "input_profile", "output_frame", "outputs", "params"}, {"artifacts"})
|
||||
_require(model["type"] in ("spatial_gain/1", "global_gain/1"), f"unsupported calibration model: {model['type']}")
|
||||
spatial = model["type"] == "spatial_gain/1"
|
||||
_require(model["input_profile"] == ("fn_delta_autocal_uv/1" if spatial else "force_delta/1"),
|
||||
"unsupported input_profile for model")
|
||||
_require(model["output_frame"] == "sdk_force/1", "unsupported output_frame")
|
||||
outputs = model["outputs"]
|
||||
_require(isinstance(outputs, list) and bool(outputs) and all(a in ("fn", "fx", "fy") for a in outputs) and
|
||||
len(set(outputs)) == len(outputs), "invalid model outputs")
|
||||
_require(not spatial or outputs == ["fn"], "spatial model supports fn only")
|
||||
_pipeline(data["pipeline"], spatial)
|
||||
params = model["params"]
|
||||
_keys(params, {"gains_N_per_raw", "valid_force_range_N"} | ({"points_uv", "triangles"} if spatial else set()))
|
||||
force_range = params["valid_force_range_N"]
|
||||
_require(isinstance(force_range, list) and len(force_range) == 2 and
|
||||
all(_number(x) for x in force_range) and force_range[0] < force_range[1], "invalid force range")
|
||||
if not spatial:
|
||||
gains = params["gains_N_per_raw"]
|
||||
_keys(gains, set(outputs))
|
||||
_require(all(_number(g) and g > 0 for g in gains.values()), "gains must be finite positive numbers")
|
||||
return ModelCalibration(data, source_path)
|
||||
@@ -105,6 +105,17 @@ class TactileProcessor:
|
||||
def force_units(self) -> tuple[str, str, str]:
|
||||
return self._sensor.force_units
|
||||
|
||||
@property
|
||||
def calibration_status(self):
|
||||
return self._sensor.calibration_status
|
||||
|
||||
@property
|
||||
def calibration_result(self):
|
||||
return self._sensor.calibration_result
|
||||
|
||||
def set_calibration_reference(self, raw, *, reference_id):
|
||||
return self._sensor.set_calibration_reference(raw, reference_id=reference_id)
|
||||
|
||||
def push_frame(self, frame, *, already_gray=False, valid_mask=None):
|
||||
"""Feed one processed tactile frame into the analysis pipeline."""
|
||||
mask = self._default_valid_mask if valid_mask is None else valid_mask
|
||||
@@ -305,6 +316,26 @@ class FingerRuntime:
|
||||
calibration_dir=calibration_dir, device_id=device_id,
|
||||
verbose=verbose, mirror=mirror,
|
||||
)
|
||||
if getattr(getattr(sensor, "calibration", None), "schema_version", None) == 3:
|
||||
from ..calibration_context import (
|
||||
build_context,
|
||||
domain_fingerprint,
|
||||
sha256_file,
|
||||
)
|
||||
|
||||
model = sensor.calibration
|
||||
uv_map = getattr(model, "coordinate_map", None)
|
||||
if model.input_profile == "fn_delta_autocal_uv/1":
|
||||
if uv_map is None or uv_map.shape[:2] != (geometry.out_height, geometry.out_width):
|
||||
raise ValueError("calibration coordinate map does not match finger output shape")
|
||||
sensor.bind_calibration_context(build_context(
|
||||
sensor._config, sensor.filters_state(),
|
||||
geometry_sha256=sha256_file(mesh_path), source_scale=geometry.source_scale,
|
||||
coordinates=model.pipeline.get("coordinates"),
|
||||
output_domain_sha256=domain_fingerprint(domain),
|
||||
processing_backend="uv_unroll",
|
||||
preprocessing={"flip_source_vertical": flip_source_vertical, "mirror": mirror},
|
||||
))
|
||||
|
||||
capture_config = CaptureConfig.from_camera_dict(
|
||||
video,
|
||||
@@ -351,6 +382,15 @@ class FingerRuntime:
|
||||
def valid_mask(self):
|
||||
return self.frame_processor.valid_mask
|
||||
|
||||
def set_calibration_reference(self, raw, *, reference_id):
|
||||
"""Set an explicitly measured unloaded reference for this finger stream."""
|
||||
self.tactile.raw_sensor.set_calibration_reference(raw, reference_id=reference_id)
|
||||
|
||||
@property
|
||||
def calibration_status(self):
|
||||
"""Copy the current per-axis calibration validity state."""
|
||||
return dict(self.tactile.raw_sensor.calibration_status)
|
||||
|
||||
def read_source(self):
|
||||
if self.capture_source is not None and self.capture_source.running:
|
||||
return self.capture_source.read(copy=True, wait_s=1.0)
|
||||
|
||||
@@ -154,6 +154,8 @@ class FrameSnapshot:
|
||||
#: compare it with ``image.shape`` to see how much the frame was reduced.
|
||||
image_size_raw: tuple[int, int] | None = None
|
||||
source_metadata: Any = None
|
||||
force_units: tuple[str, str, str] | None = None
|
||||
calibration_status: dict[str, str] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -654,6 +656,8 @@ class StreamPipeline:
|
||||
shear_x_raw=float(sensor._force_raw[1]),
|
||||
shear_y_raw=float(sensor._force_raw[2]),
|
||||
torque=float(getattr(sensor, "ftor", 0.0)),
|
||||
force_units=tuple(getattr(sensor, "force_units", (getattr(sensor, "force_unit", "raw"),) * 3)),
|
||||
calibration_status=dict(getattr(sensor, "calibration_status", {})),
|
||||
# Both already private per-frame copies owned by this snapshot.
|
||||
image=frame if "image" in include else None,
|
||||
flow=flow if "flow" in include else None,
|
||||
|
||||
+225
-3
@@ -182,6 +182,23 @@ class Sensor:
|
||||
config_fps=config_fps,
|
||||
verbose=self.verbose,
|
||||
)
|
||||
self._model_frame_id = 0
|
||||
self._calibration_result_frame = None
|
||||
self._calibration_reference = {}
|
||||
self._calibration_reference_id = None
|
||||
self._model_readouts = {}
|
||||
self.calibration_result = None
|
||||
self.calibration_status = {}
|
||||
self._calibration_context = None
|
||||
if self._uses_calibration_model():
|
||||
self._reset_calibration_session()
|
||||
if (self._uses_calibration_model() and self.calibration.input_profile == "force_delta/1"
|
||||
and not self._external_only):
|
||||
from .calibration_context import build_context
|
||||
|
||||
self.bind_calibration_context(build_context(
|
||||
self._config, self.filters_state(), preprocessing={"mirror": self.mirror},
|
||||
))
|
||||
|
||||
if self.verbose:
|
||||
if self.calibration is not None and self.calibration.source_path is not None:
|
||||
@@ -726,7 +743,15 @@ class Sensor:
|
||||
self._force_raw = (float(fn), float(fx), float(fy))
|
||||
self.ftor = float(ftor)
|
||||
self._force_readout.update_sample_rate()
|
||||
self._apply_force_filter()
|
||||
if self._uses_calibration_model():
|
||||
self._model_frame_id += 1
|
||||
self.compute_contact_valid = False
|
||||
self.centroid_found = False
|
||||
self._force_readout.apply(self._force_raw)
|
||||
self._force_filtered_raw = self._force_readout.filtered_raw
|
||||
self._force_raw_unfiltered = self._force_raw
|
||||
else:
|
||||
self._apply_force_filter()
|
||||
|
||||
vnormal_ds = self.__hhd.get_normal()
|
||||
vshear_ds = self.__hhd.get_shear()
|
||||
@@ -762,6 +787,14 @@ class Sensor:
|
||||
|
||||
self.compute_deformation_valid = True
|
||||
|
||||
if self._uses_calibration_model():
|
||||
# Spatial inference must use this HHD frame's contact, even when the
|
||||
# caller did not request contact diagnostics from StreamPipeline.
|
||||
if self.calibration.input_profile == "fn_delta_autocal_uv/1":
|
||||
self.compute_contact()
|
||||
else:
|
||||
self.apply_calibration_frame(frame_id=self._model_frame_id)
|
||||
|
||||
return True
|
||||
|
||||
def compute_contact(self, vnormal=None):
|
||||
@@ -774,6 +807,11 @@ class Sensor:
|
||||
Returns:
|
||||
dict: Contact result with centroid, contour, and depth_map
|
||||
"""
|
||||
if self._uses_calibration_model() and not self.compute_deformation_valid:
|
||||
raise cal_mod.CalibrationError("compute current deformation before schema 3 contact")
|
||||
if (self._uses_calibration_model() and self.compute_contact_valid
|
||||
and self._calibration_result_frame == self._model_frame_id):
|
||||
return self._model_contact_result
|
||||
multiple_foe = False
|
||||
|
||||
# Use provided vnormal or fall back to internal state
|
||||
@@ -807,6 +845,13 @@ class Sensor:
|
||||
print("Warning: contact centroid lies outside the valid UV mask.")
|
||||
|
||||
self.compute_contact_valid = True
|
||||
if self._uses_calibration_model():
|
||||
self._model_contact_result = result
|
||||
uv = self._calibration_contact_uv()
|
||||
self.apply_calibration_frame(
|
||||
frame_id=self._model_frame_id, contact_found=bool(self.centroid_found),
|
||||
uv=uv, mapping_valid=uv is not None,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -871,6 +916,8 @@ class Sensor:
|
||||
# session must not report the previous one's reading.
|
||||
self._force_unfiltered = None
|
||||
self._force_raw_unfiltered = None
|
||||
self._reset_calibration_session()
|
||||
self.compute_deformation_valid = False
|
||||
if self.img is not None:
|
||||
self.initial_img = self.img.copy()
|
||||
|
||||
@@ -950,6 +997,10 @@ class Sensor:
|
||||
alpha = float(flow_ema_alpha)
|
||||
if not (0.0 < alpha <= 1.0):
|
||||
raise ValueError("flow_ema_alpha must be in (0, 1]")
|
||||
if self._uses_calibration_model() and alpha != self._force_readout.flow_ema_alpha:
|
||||
raise cal_mod.CalibrationError(
|
||||
"flow_ema_alpha changes schema 3 measurement identity; load a matching pipeline"
|
||||
)
|
||||
if self.calibration is not None:
|
||||
mismatches, _ = cal_mod.filter_mismatches(
|
||||
self.calibration.pipeline.get("filters"),
|
||||
@@ -965,8 +1016,17 @@ class Sensor:
|
||||
flow_ema_alpha=flow_ema_alpha,
|
||||
force_median_window=force_median_window,
|
||||
)
|
||||
for readout in getattr(self, "_model_readouts", {}).values():
|
||||
readout.configure(
|
||||
lowpass_cutoff_hz=lowpass_cutoff_hz,
|
||||
lowpass_sample_rate_hz=lowpass_sample_rate_hz,
|
||||
lowpass_order=lowpass_order,
|
||||
lowpass_enabled=lowpass_enabled,
|
||||
force_median_window=force_median_window,
|
||||
)
|
||||
if filter_changed and self.compute_deformation_valid:
|
||||
self._apply_force_filter()
|
||||
if not self._uses_calibration_model():
|
||||
self._apply_force_filter()
|
||||
|
||||
return self
|
||||
|
||||
@@ -1139,6 +1199,7 @@ class Sensor:
|
||||
def set_calibration(
|
||||
self,
|
||||
cal: "cal_mod.Calibration | str | Path | None",
|
||||
*, context=None,
|
||||
) -> None:
|
||||
"""Attach or replace the active force calibration (live swap after save)."""
|
||||
if cal is None:
|
||||
@@ -1146,8 +1207,12 @@ class Sensor:
|
||||
self.device_calibration_missing = False
|
||||
self._force_cal_sensor_name = None
|
||||
self._force_cal_attempts = None
|
||||
self._reset_calibration_session()
|
||||
self._calibration_context = None
|
||||
self._force_readout.reset()
|
||||
self._apply_force_filter()
|
||||
return
|
||||
if isinstance(cal, cal_mod.Calibration):
|
||||
if isinstance(cal, cal_mod.Calibration) or getattr(cal, "schema_version", None) == 3:
|
||||
selected = cal
|
||||
else:
|
||||
selected = cal_mod.load(cal, strict=True)
|
||||
@@ -1156,9 +1221,30 @@ class Sensor:
|
||||
selected = cal_mod.validate_for_device(
|
||||
selected, family, self.device_id, pipeline=pipeline
|
||||
)
|
||||
if getattr(selected, "schema_version", None) == 3:
|
||||
context = context if context is not None else getattr(self, "_calibration_context", None)
|
||||
if context is None and selected.input_profile == "force_delta/1":
|
||||
from .calibration_context import build_context
|
||||
|
||||
context = build_context(
|
||||
self._config, self.filters_state(), preprocessing={"mirror": self.mirror},
|
||||
)
|
||||
if context is None:
|
||||
raise cal_mod.CalibrationError("actual pipeline context required before replacing model")
|
||||
try:
|
||||
selected.validate_for_context(context)
|
||||
except ValueError as exc:
|
||||
raise cal_mod.CalibrationError(str(exc)) from exc
|
||||
else:
|
||||
context = None
|
||||
self.calibration = selected
|
||||
self._calibration_context = context
|
||||
self.device_calibration_missing = False
|
||||
self._force_cal_attempts = None
|
||||
self._reset_calibration_session()
|
||||
if not self._uses_calibration_model():
|
||||
self._force_readout.reset()
|
||||
self._apply_force_filter()
|
||||
|
||||
def _apply_force_filter(self):
|
||||
convert = self.calibration.convert if self.calibration is not None else None
|
||||
@@ -1169,6 +1255,142 @@ class Sensor:
|
||||
self._force_raw_unfiltered = readout.raw_unfiltered
|
||||
self._force_unfiltered = readout.unfiltered_newton
|
||||
|
||||
def _uses_calibration_model(self):
|
||||
return getattr(self.calibration, "schema_version", None) == 3
|
||||
|
||||
def bind_calibration_context(self, context):
|
||||
"""Verify the actual feature pipeline before allowing schema 3 inference."""
|
||||
if not self._uses_calibration_model():
|
||||
raise cal_mod.CalibrationError("calibration context requires schema 3")
|
||||
try:
|
||||
self.calibration.validate_for_context(context)
|
||||
except ValueError as exc:
|
||||
raise cal_mod.CalibrationError(str(exc)) from exc
|
||||
self._calibration_context = context
|
||||
|
||||
def _reset_calibration_session(self):
|
||||
self._calibration_reference = {}
|
||||
self._calibration_reference_id = None
|
||||
self._calibration_result_frame = None
|
||||
self.calibration_result = None
|
||||
self.calibration_status = {}
|
||||
self.compute_contact_valid = False
|
||||
self.centroid_found = False
|
||||
self.centroid = (0, 0)
|
||||
self._model_contact_result = None
|
||||
for readout in getattr(self, "_model_readouts", {}).values():
|
||||
readout.reset()
|
||||
if self._uses_calibration_model():
|
||||
self.calibration.reset(None)
|
||||
self.calibration_status = {axis: "zero_unavailable" for axis in self.calibrated_axes}
|
||||
values = [float("nan") if axis in self.calibrated_axes else raw
|
||||
for axis, raw in zip(cal_mod.CHANNEL_IDS, self._force_raw)]
|
||||
self.fnormal, self.fshearx, self.fsheary = values
|
||||
self._force_unfiltered = tuple(values)
|
||||
|
||||
def set_calibration_reference(self, raw, *, reference_id):
|
||||
"""Declare a measured unloaded raw reference for this stream, not the model file."""
|
||||
if not isinstance(raw, dict) or not reference_id:
|
||||
raise ValueError("raw must map axes to finite values and reference_id is required")
|
||||
values = {}
|
||||
for axis, value in raw.items():
|
||||
if axis not in cal_mod.CHANNEL_IDS or not np.isfinite(float(value)):
|
||||
raise ValueError("reference axes and values must be valid and finite")
|
||||
values[axis] = float(value)
|
||||
self._reset_calibration_session()
|
||||
self._calibration_reference = values
|
||||
self._calibration_reference_id = str(reference_id)
|
||||
if self._uses_calibration_model():
|
||||
self.calibration.reset(self._calibration_reference_id)
|
||||
|
||||
def _calibration_contact_uv(self):
|
||||
if not self.centroid_found:
|
||||
return None
|
||||
uv_map = getattr(self.calibration, "coordinate_map", None)
|
||||
valid = getattr(self.calibration, "coordinate_valid_mask", None)
|
||||
if uv_map is None or valid is None:
|
||||
return None
|
||||
if not np.all(np.isfinite(self.centroid)):
|
||||
return None
|
||||
x, y = np.rint(self.centroid).astype(int)
|
||||
if not (0 <= y < valid.shape[0] and 0 <= x < valid.shape[1] and valid[y, x]):
|
||||
return None
|
||||
return tuple(float(value) for value in uv_map[y, x])
|
||||
|
||||
def apply_calibration_frame(
|
||||
self, *, frame_id, timestamp_s=None, raw=None, uv=None,
|
||||
contact_found=None, mapping_valid=False, single_contact=True,
|
||||
):
|
||||
"""Infer once from an unfiltered raw triple and same-frame optional contact UV."""
|
||||
if not self._uses_calibration_model():
|
||||
raise cal_mod.CalibrationError("apply_calibration_frame requires schema 3")
|
||||
if self._calibration_context is None:
|
||||
raise cal_mod.CalibrationError("bind the actual calibration context before inference")
|
||||
if type(frame_id) is not int:
|
||||
raise ValueError("frame_id must be an integer")
|
||||
if frame_id == self._calibration_result_frame:
|
||||
return self.calibration_result
|
||||
if self._calibration_result_frame is not None and frame_id < self._calibration_result_frame:
|
||||
self._reset_calibration_session()
|
||||
raw_values = tuple(self._force_raw if raw is None else raw)
|
||||
if len(raw_values) != 3:
|
||||
raise ValueError("raw must contain FN, FX and FY")
|
||||
if raw is not None:
|
||||
self._force_raw = tuple(float(value) for value in raw_values)
|
||||
if np.all(np.isfinite(self._force_raw)):
|
||||
self._force_readout.apply(self._force_raw)
|
||||
self._force_filtered_raw = self._force_readout.filtered_raw
|
||||
raw_by_axis = dict(zip(cal_mod.CHANNEL_IDS, raw_values))
|
||||
reference = self._calibration_reference
|
||||
deltas = {axis: float(value) - reference.get(axis, float("nan"))
|
||||
for axis, value in raw_by_axis.items()}
|
||||
axes = self.calibrated_axes
|
||||
features = {
|
||||
"delta_raw": deltas["fn"], "force_delta_raw": deltas,
|
||||
"zero_valid": all(axis in reference for axis in axes),
|
||||
"mapping_valid": bool(mapping_valid), "single_contact": single_contact,
|
||||
"u": None if uv is None else uv[0], "v": None if uv is None else uv[1],
|
||||
}
|
||||
if contact_found is not None:
|
||||
features["contact_found"] = bool(contact_found)
|
||||
result = self.calibration.predict(
|
||||
frame_id=frame_id, timestamp_s=timestamp_s,
|
||||
features=features, reference_id=self._calibration_reference_id,
|
||||
)
|
||||
filtered = list(self._force_readout.filtered_raw or raw_values)
|
||||
unfiltered = list(raw_values)
|
||||
for index, axis in enumerate(cal_mod.CHANNEL_IDS):
|
||||
if axis not in axes:
|
||||
continue
|
||||
value = result.values_N.get(axis)
|
||||
unfiltered[index] = float("nan") if value is None else float(value)
|
||||
filtered[index] = unfiltered[index]
|
||||
if value is not None and np.isfinite(value):
|
||||
readout = self._model_readouts.get(axis)
|
||||
if readout is None:
|
||||
readout = ForceReadout(self._config.get("filters"),
|
||||
config_fps=self._force_readout.config_fps)
|
||||
base = self._force_readout
|
||||
readout.configure(
|
||||
lowpass_sample_rate_hz=base.lowpass_sample_rate_hz,
|
||||
lowpass_cutoff_hz=base.lowpass_cutoff_hz,
|
||||
lowpass_order=base.lowpass_order,
|
||||
lowpass_enabled=base.lowpass_enabled,
|
||||
force_median_window=base.force_median_window,
|
||||
)
|
||||
self._model_readouts[axis] = readout
|
||||
base = self._force_readout
|
||||
if readout.lowpass_sample_rate_hz != base.lowpass_sample_rate_hz:
|
||||
readout.configure(lowpass_sample_rate_hz=base.lowpass_sample_rate_hz)
|
||||
filtered[index] = readout.apply((value, value, value))[0]
|
||||
self.fnormal, self.fshearx, self.fsheary = filtered
|
||||
self._force_raw_unfiltered = tuple(float(value) for value in raw_values)
|
||||
self._force_unfiltered = tuple(unfiltered)
|
||||
self.calibration_result = result
|
||||
self.calibration_status = dict(result.status)
|
||||
self._calibration_result_frame = frame_id
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------- filter contract
|
||||
def filters_state(self) -> dict:
|
||||
"""What the filter chain is **actually** doing, for records and audits.
|
||||
|
||||
@@ -253,6 +253,13 @@ def create_processor(
|
||||
elif verbose:
|
||||
print(f"[processor] using the supplied sensor for {profile.profile_id}")
|
||||
|
||||
# A resolved selection (including explicit NONE) is authoritative. Otherwise
|
||||
# Sensor's bundle auto-load can silently defeat the caller's selected file.
|
||||
if hasattr(sensor, "set_calibration"):
|
||||
sensor.set_calibration(profile.calibration.document)
|
||||
elif profile.calibration.document is not None:
|
||||
raise ProcessorCreationError("supplied sensor cannot apply the selected calibration")
|
||||
|
||||
if backend_id == BACKEND_COMPOUND_EYE:
|
||||
config_stitcher = profile.config.get("stitcher")
|
||||
stitcher = Stitcher(
|
||||
|
||||
@@ -119,12 +119,13 @@ def resolve_calibration(
|
||||
)
|
||||
|
||||
if mode is CalibrationSelection.EXPLICIT:
|
||||
path = explicit
|
||||
path = cal_mod.select_calibration_path(cal_path=explicit)
|
||||
else:
|
||||
path = declared_default
|
||||
if path is None:
|
||||
fallback = bundle_root / "force_calibration.json"
|
||||
path = fallback if fallback.is_file() else None
|
||||
path = cal_mod.select_calibration_path(default_path=path)
|
||||
|
||||
if path is None:
|
||||
# Not an error: a profile may legitimately ship no calibration. Calibrated force is
|
||||
@@ -197,6 +198,12 @@ def resolve_calibration(
|
||||
|
||||
identity = _identity_of(document)
|
||||
declared_type = str(identity.get("sensor_type") or "")
|
||||
if document.schema_version == 3 and declared_type != product_family:
|
||||
return ResolvedCalibration(
|
||||
selection=mode, path=path,
|
||||
issues=(Issue(code="calibration.model-family-mismatch",
|
||||
message="selected model belongs to another product family", path=path),),
|
||||
)
|
||||
if declared_type:
|
||||
applicability = "declared"
|
||||
if declared_type != product_family:
|
||||
@@ -273,6 +280,8 @@ def calibration_fingerprint(document: "cal_mod.Calibration", path: Path) -> str:
|
||||
a different directory must produce the same fingerprint.
|
||||
"""
|
||||
try:
|
||||
if getattr(document, "schema_version", None) == 3:
|
||||
return document.fingerprint
|
||||
payload = document.to_dict()
|
||||
except Exception: # noqa: BLE001 - fall back to the raw bytes rather than fail
|
||||
payload = path.read_bytes().decode("utf-8", errors="replace")
|
||||
|
||||
Reference in New Issue
Block a user