接入力标定 schema 3 与多点空间增益模型

保留旧格式和 cal_path,校验 SN、特征身份及坐标附件,按帧返回逐轴状态。相关测试 263 项通过,已核对存量预测并实测 180 帧录像。
This commit is contained in:
ykcheungab
2026-10-18 12:56:40 +08:00
parent f7f4682ff9
commit ff7614a6a0
13 changed files with 1422 additions and 19 deletions
+61 -8
View File
@@ -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",
+75
View File
@@ -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
+307
View File
@@ -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)
+40
View File
@@ -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)
+4
View File
@@ -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
View File
@@ -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.
+7
View File
@@ -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(
+10 -1
View File
@@ -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")