diff --git a/docs/calibration-model-contract.zh.md b/docs/calibration-model-contract.zh.md index 49a8060..cc1696c 100644 --- a/docs/calibration-model-contract.zh.md +++ b/docs/calibration-model-contract.zh.md @@ -1,6 +1,7 @@ -# 统一力标定模型契约(草案) +# 统一力标定模型契约 -Purpose: plan. Scope: SDK 标定文件选择、SN 绑定和推断接口。状态:2026-10-18 提案,未实现。 +Purpose: reference. Scope: SDK 标定文件选择、SN 绑定和推断接口。 +状态:2026-10-18 已实现 schema 3 的空间/全局增益;ML 后端仅保留契约,尚未实现。 保持 `cal_path` 与现有单点文件;新增空间模型和机器学习模型使用同一入口。 ## 现状与边界 @@ -38,12 +39,14 @@ profile 创建处理器统一复用内部选择器;现有入口参数只映射 保留 `device_id` 单独传入时的参数错误;目录本身不存在也报错,不能当成 SN 文件缺失。 ```python -# 沿用现有调用参数;schema 3 需未来实现支持后使用。 +# 沿用现有调用参数;cal_path 可以选择 schema 1/2/3 文件。 runtime = FingerRuntime.open( config="finger", video="test.mp4", device_id="DD02-GL-001", cal_path="calibrations/finger/DD02-GL-001.json", ) +# 归零值来自本次无载窗口,不是模型内固定常量。 +runtime.set_calibration_reference({"fn": measured_zero_raw}, reference_id="unloaded-1") # 自动按 SN 选择仍使用 calibration_dir="calibrations",不同时传 cal_path。 ``` @@ -107,12 +110,14 @@ runtime = FingerRuntime.open( 修改输入或算法语义必须升级相应 `/1` 标识。 `geometry_sha256` 是所用计算 mesh 文件字节的 SHA-256;`map_sha256` 是 SDK 坐标映射定义的 -内容摘要,包含坐标变换及其版本,不能只比较图幅。pipeline 的必需键由输入 profile 固定, +附件字节摘要,包含坐标转换结果,不能只比较图幅。pipeline 的必需键由输入 profile 固定, 覆盖该 backend 所有影响测量的设置;只检查示例中的部分配置不算兼容校验。 + 新增 profile 必须定义闭合的签名字段表。首个 profile 的 `feature_signature_sha256` 摘要 对象包含 `feature_version`、`processing_backend`、有效的图像预处理/光流/HHD 配置和计算资产哈希; 不含文件绝对路径、SN、显示资源或力读数滤波。以 UTF-8、键排序、无多余空格的 JSON 计算 SHA-256。 特征定义或单位改变须升级 `feature_version`,配置覆盖后重新计算,不能用配置文件名代替。 +实现由 `calibration_context.build_context` 统一生成,特征版本为 `orisys_hhd_raw/1`。 ## 空间增益模型 @@ -132,6 +137,11 @@ runtime = FingerRuntime.open( 未覆盖、位置失败、归零无效均返回无效,不能偷偷改用 M0。 - `valid_force_range_N` 必填。估计超范围时保留数值并标记 `out_of_range`,不截断。 +FingerRuntime 使用 `artifacts` 中 `role="coordinate_map"` 的只读 NPZ: +`uv_by_output_px` 为 H×W×2 有符号 UV,`valid_mask` 为 H×W bool;可附 `origin_xy` 与 +`flat_mapping_sha256`。`pipeline.output_domain_sha256` 绑定实际展开的 remap、mask 和 world_xyz; +形状、几何或特征身份不同则在打开视频前拒绝。离线直接提供 UV 的模型可以没有映射附件。 + 单点/两点数据不自动切换算法;可导出独立的 `global_gain/1`,参数为各输出轴的增益和有效范围, 输入 profile 为 `force_delta/1`,由 SDK 按同一归零语义准备对应 raw 增量。 这不同于旧曲线的绝对 raw 输入;兼容适配器不得替旧文件额外扣零。 @@ -179,7 +189,7 @@ runtime = FingerRuntime.open( ## 每帧推断与有效性 -内部统一生命周期如下;是拟议接口,不是现有 API。 +内部统一生命周期如下,`calibration.load` 分流到旧曲线或新模型: ```text load(selected_file, device_identity, pipeline) -> CalibrationModel @@ -192,7 +202,8 @@ CalibrationResult: coordinate_unmapped | outside_coverage | out_of_range} ``` -FN 空间模型输入仅允许单一接触;无法确认单接触时不能宣称已标定总力。 +FN 空间模型输入仅允许单一接触;当前定位器只返回一个中心,无法证明不存在多接触, +不能宣称已标定总力。全局 `force_delta/1` 不要求定位,未提供接触状态不等同于无接触。 无接触、失败帧的校准输出为空,raw 另行保留;缺失值不是 0 N。 `out_of_range` 有数值但不在验证范围,其余失败状态无数值;未声明轴不可用、单位仍为 raw。 不把某轴 raw 填进 `values_N`,也不只靠全局 `force_unit` 判断三轴单位。 @@ -206,6 +217,10 @@ schema 1/2 仍保留旧滤波/转换顺序。状态模型不能塞入会调用 加载诊断至少包含路径、来源、SN 校验状态、模型/输入 profile、轴集合、内容指纹和失败原因。 加载不匹配失败与逐帧暂时无效分开;替换时先完成加载校验,失败不得破坏原活动模型。 +Sensor 提供 `bind_calibration_context`、`set_calibration_reference`、`apply_calibration_frame`; +FingerRuntime 自动绑定真实上下文,并转发归零。`FrameSnapshot.force_units` 和 +`calibration_status` 随帧保存,不能用稍后读取的 Sensor 状态解释旧帧。 +归零与换模型须在计算线程或暂停处理时调用,不承诺并发修改安全。 ## 兼容、实现位置与验收 @@ -219,9 +234,14 @@ schema 3 按声明轴校验,允许 FN-only;旧 SDK 遇 schema 3 必须拒绝 | `sensors/calibration.py` | 统一选择器与指纹;支持 SN 请求 | | 新 `calibration_models.py` | 受控类型表、空间增益与模型生命周期;ML 后端另行实现 | | `sensor.py`、`finger/runtime.py`、处理器适配层 | 提供同帧位置/零点,缓存逐轴结果和状态 | -| `force_readout.py` | 保留旧路径,为 schema 3 分离推断与读数滤波 | +| Sensor 与 `ForceReadout` | 复用读数滤波器;schema 3 在推断后逐轴滤波,旧路径不变 | | 离线导出器 | 实验模型到 schema 3;SDK 不依赖实验目录 | +当前导出入口为 workspace 的 `scripts/finger_multical/export_sdk.py`,输出已有文件时拒绝覆盖。 +`data/dd02-gl/force_calibration.v3.json` 与同名 `.coordinates.npz` 是本次实验交付, +`identity.sn="dd02-gl"` 为实验标识,未从硬件读取;原三边缘点与非单调点警告保留于 provenance。 +它不是生产包默认文件,需显式 `cal_path` 选择。 + 实现验收必须覆盖:旧结果与单位不变;显式路径/SN/默认/关闭选择;错误 SN 不串用; 3/14/更多点、退化网格与凸包外;节点和插值预测;零点不重复扣除;缺坐标/无接触; FN-only 的 FX/FY 不冒充 N;未知模型、附件损坏和依赖缺失;状态模型每帧只调用一次及 reset。 diff --git a/src/orisys/calibration.py b/src/orisys/calibration.py index 6291373..73f002f 100644 --- a/src/orisys/calibration.py +++ b/src/orisys/calibration.py @@ -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 ``//.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 "" 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", diff --git a/src/orisys/calibration_context.py b/src/orisys/calibration_context.py new file mode 100644 index 0000000..dbd1791 --- /dev/null +++ b/src/orisys/calibration_context.py @@ -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", " 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 diff --git a/src/orisys/calibration_models.py b/src/orisys/calibration_models.py new file mode 100644 index 0000000..76ec5b6 --- /dev/null +++ b/src/orisys/calibration_models.py @@ -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) diff --git a/src/orisys/finger/runtime.py b/src/orisys/finger/runtime.py index 7b60ed0..47edcde 100644 --- a/src/orisys/finger/runtime.py +++ b/src/orisys/finger/runtime.py @@ -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) diff --git a/src/orisys/pipeline.py b/src/orisys/pipeline.py index 1654ca0..96ef2ff 100644 --- a/src/orisys/pipeline.py +++ b/src/orisys/pipeline.py @@ -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, diff --git a/src/orisys/sensor.py b/src/orisys/sensor.py index 4810003..3960a30 100644 --- a/src/orisys/sensor.py +++ b/src/orisys/sensor.py @@ -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. diff --git a/src/orisys/sensors/backends.py b/src/orisys/sensors/backends.py index d4190c8..7cd91cb 100644 --- a/src/orisys/sensors/backends.py +++ b/src/orisys/sensors/backends.py @@ -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( diff --git a/src/orisys/sensors/calibration.py b/src/orisys/sensors/calibration.py index 164cf45..edfa048 100644 --- a/src/orisys/sensors/calibration.py +++ b/src/orisys/sensors/calibration.py @@ -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") diff --git a/tests/test_calibration_context.py b/tests/test_calibration_context.py new file mode 100644 index 0000000..a6fa5c1 --- /dev/null +++ b/tests/test_calibration_context.py @@ -0,0 +1,59 @@ +"""Feature identities use effective measurement settings, never display metadata.""" + +from copy import deepcopy + +import numpy as np +import pytest + +from orisys.calibration_context import build_context, domain_fingerprint + + +def config(): + return {"camera": {"resolution_raw": [1280, 720], "resolution_stitch": [700, 750]}, + "tracker": {"dis_mode": 4}} + + +@pytest.mark.parametrize("raw,expected", [(None, 1), ("bad", 1), (0, 1), (-3, 1), ("3", 3), (3.7, 3)]) +def test_hhd_uses_actual_sensor_normalization(raw, expected): + data = config() + data["tracker"]["hhd_downsample"] = raw + assert build_context(data)["hhd_downsample"] == expected + + +def test_default_values_equal_explicit_effective_settings(): + implicit = build_context(config()) + data = config() + data["tracker"]["hhd_downsample"] = 1 + explicit = build_context(data, {"flow_ema_alpha": 1.0}) + assert implicit["hhd_downsample"] == 1 + assert implicit["filters"]["measurement"] == {"flow_ema_alpha": 1.0} + assert implicit["feature_signature_sha256"] == explicit["feature_signature_sha256"] + + +def test_measurement_filters_change_identity_but_readout_and_paths_do_not(): + data = config() + original = build_context(data) + assert build_context(data, {"flow_ema_alpha": 0.5})["feature_signature_sha256"] != original["feature_signature_sha256"] + data.update(name="other", video="D:/other.mp4", cal_path="E:/calibration.json") + assert build_context(data, {"force_median_window": 7, "force_lowpass_cutoff_hz": 2}) == original + + +def test_explicit_empty_filter_mapping_overrides_config_defaults(): + data = config() + data["filters"] = {"flow_ema_alpha": 0.5} + assert build_context(data, {})["filters"]["measurement"] == {"flow_ema_alpha": 1.0} + + +def test_source_scale_numeric_types_have_identical_identity(): + assert build_context(config(), source_scale=1) == build_context(config(), source_scale=1.0) + + +def test_domain_fingerprint_binds_map_content_not_only_dimensions(): + domain = {"map_x": np.zeros((2, 3)), "map_y": np.zeros((2, 3)), + "valid_mask": np.ones((2, 3), dtype=bool), "world_xyz": np.zeros((2, 3, 3))} + original = domain_fingerprint(domain) + for key in domain: + changed = deepcopy(domain) + changed[key].flat[0] = not changed[key].flat[0] if key == "valid_mask" else 1 + assert domain_fingerprint(changed) != original + assert domain_fingerprint({key: value.copy() for key, value in domain.items()}) == original diff --git a/tests/test_calibration_models.py b/tests/test_calibration_models.py new file mode 100644 index 0000000..239ae85 --- /dev/null +++ b/tests/test_calibration_models.py @@ -0,0 +1,179 @@ +"""Schema 3 model validation and deterministic inference.""" + +import hashlib +from copy import deepcopy + +import numpy as np +import pytest + +from orisys.calibration import CalibrationError +from orisys.calibration_models import parse_model + + +def document(): + return { + "schema_version": 3, "unit": "N", + "identity": {"sensor_type": "finger", "sn": "DD02-GL"}, + "pipeline": { + "stitch_resolution": [700, 750], "dis_mode": 4, + "hhd_downsample": 3, "source_scale": 0.5, + "filters": {"measurement": {"flow_ema_alpha": 1.0}}, + "feature_signature_sha256": "a" * 64, + "geometry_sha256": "b" * 64, + "coordinates": {"frame": "autocal_flat", "unit": "px", + "origin_xy": [511, 1021], "map_sha256": "c" * 64}, + }, + "model": { + "type": "spatial_gain/1", "input_profile": "fn_delta_autocal_uv/1", + "output_frame": "sdk_force/1", "outputs": ["fn"], + "params": {"points_uv": [[0, 0], [10, 0], [0, 10]], + "gains_N_per_raw": [0.001, 0.002, 0.003], + "triangles": [[0, 1, 2]], "valid_force_range_N": [0, 3]}, + }, + } + + +def features(**overrides): + return dict(delta_raw=1000, u=2.5, v=2.5, contact_found=True, + zero_valid=True, mapping_valid=True, **overrides) + + +def test_interpolates_gain_not_sensitivity_and_roundtrips(): + data = document() + model = parse_model(data) + result = model.predict(1, 0.1, features(), "zero-1") + assert result.values_N == {"fn": pytest.approx(1.75)} + assert result.status == {"fn": "ok"} + assert model.to_dict() == data + assert "fx" not in model.channels + data["model"]["params"]["gains_N_per_raw"][0] = 999 + assert model.predict(2, 0.2, features(), "zero-1").values_N["fn"] == 1.75 + + +def test_legacy_scalar_convert_explicitly_rejected(): + with pytest.raises(CalibrationError, match="schema 3 requires frame features"): + parse_model(document()).convert(1, 2, 3) + + +@pytest.mark.parametrize("change,status", [ + ({"u": 20}, "outside_coverage"), ({"contact_found": False}, "no_contact"), + ({"zero_valid": False}, "zero_unavailable"), + ({"mapping_valid": False}, "coordinate_unmapped"), + ({"delta_raw": float("nan")}, "invalid_input"), + ({"delta_raw": "1000"}, "invalid_input"), + ({"single_contact": False}, "invalid_input"), +]) +def test_frame_invalidity(change, status): + values = features() + values.update(change) + result = parse_model(document()).predict(1, 0.1, values, "zero") + assert result.status == {"fn": status} + assert result.values_N == {"fn": None} + + +def test_out_of_range_retains_value(): + values = features() + values["delta_raw"] = 3000 + result = parse_model(document()).predict(1, 0, values, "zero") + assert result.values_N["fn"] == 5.25 + assert result.status["fn"] == "out_of_range" + + +@pytest.mark.parametrize("mutate", [ + lambda d: d.update(schema_version=True), + lambda d: d.update(extra="ignored"), + lambda d: d["identity"].update(sn=""), + lambda d: d["model"].update(outputs=["fn", "fx"]), + lambda d: d["model"]["params"].update(gains_N_per_raw=[0.001, 0, 0.003]), + lambda d: d["model"]["params"].update(triangles=[[0, 1, 1]]), + lambda d: d["model"]["params"].update(triangles=[[0, 1, 2], [2, 1, 0]]), + lambda d: d["pipeline"].pop("geometry_sha256"), + lambda d: d["pipeline"]["coordinates"].update(map_sha256="bad"), +]) +def test_rejects_bad_documents(mutate): + data = document() + mutate(data) + with pytest.raises(ValueError): + parse_model(data) + + +def test_rejects_gap_and_overlapping_equal_area_meshes(): + data = document() + params = data["model"]["params"] + params.update(points_uv=[[0, 0], [10, 0], [10, 10], [0, 10]], + gains_N_per_raw=[0.001] * 4, triangles=[[0, 1, 2]]) + with pytest.raises(ValueError, match="triang|mesh|hull"): + parse_model(data) + params["triangles"] = [[0, 1, 2], [0, 1, 3]] + with pytest.raises(ValueError, match="overlap"): + parse_model(data) + params["triangles"] = [[0, 1, 2], [0, 2, 3]] + assert parse_model(data).predict(1, 0, features(), "zero").status["fn"] == "ok" + + +def test_global_gain_and_context(): + data = document() + data["pipeline"].pop("coordinates") + data["pipeline"].pop("geometry_sha256") + data["model"].update(type="global_gain/1", input_profile="force_delta/1", + outputs=["fn", "fx"], params={ + "gains_N_per_raw": {"fn": 0.002, "fx": 0.004}, + "valid_force_range_N": [-3, 3]}) + model = parse_model(data) + result = model.predict(1, 0, {"delta_raw_by_axis": {"fn": 100, "fx": -100}, + "zero_valid": True, "contact_found": True}, "z") + assert result.values_N == {"fn": 0.2, "fx": -0.4} + model.validate_for_context(data["pipeline"]) + wrong = deepcopy(data["pipeline"]) + wrong["source_scale"] = 0.42 + with pytest.raises(ValueError, match="source_scale"): + model.validate_for_context(wrong) + model.reset("new-zero") + data["pipeline"].pop("source_scale") + assert parse_model(data).input_profile == "force_delta/1" + assert model.predict(2, None, {"force_delta_raw": {"fn": 100, "fx": -100}, + "zero_valid": True}, "z").values_N == {"fn": 0.2, "fx": -0.4} + assert model.predict(3, None, {"force_delta_raw": {"fn": 100, "fx": -100}, + "zero_valid": True, "contact_found": False}, "z").status == { + "fn": "no_contact", "fx": "no_contact"} + + +def test_unknown_backend_fails_explicitly(): + data = document() + data["model"]["type"] = "onnx/1" + with pytest.raises(ValueError, match="unsupported.*onnx/1"): + parse_model(data) + + +def test_coordinate_artifact_hash_and_paths(tmp_path): + path = tmp_path / "map.npz" + np.savez(path, uv_by_output_px=np.zeros((2, 3, 2)), valid_mask=np.ones((2, 3), dtype=bool), + origin_xy=np.array([511, 1021])) + digest = hashlib.sha256(path.read_bytes()).hexdigest() + data = document() + data["pipeline"]["coordinates"]["map_sha256"] = digest + data["model"]["artifacts"] = [{"role": "coordinate_map", "path": "map.npz", "sha256": digest}] + model = parse_model(data, tmp_path / "calibration.json") + assert model.coordinate_map.shape == (2, 3, 2) + assert len(model.fingerprint) == 64 + assert not model.coordinate_map.flags.writeable + for unsafe in ("../map.npz", "https://example.org/x.npz", "C:\\map.npz", "/map.npz"): + data["model"]["artifacts"][0]["path"] = unsafe + with pytest.raises(ValueError, match="relative"): + parse_model(data, tmp_path / "calibration.json") + data["model"]["artifacts"][0]["path"] = "map.npz" + path.write_bytes(b"corrupt") + with pytest.raises(ValueError, match="SHA-256"): + parse_model(data, tmp_path / "calibration.json") + + +def test_many_points_complete_mesh(): + from scipy.spatial import Delaunay + data = document() + points = np.array([[x, y] for x in range(5) for y in range(5)]) + data["model"]["params"].update(points_uv=points.tolist(), gains_N_per_raw=[0.001] * 25, + triangles=Delaunay(points).simplices.tolist()) + model = parse_model(data) + values = features() + values.update(u=2, v=2) + assert model.predict(1, 0, values, "z").values_N["fn"] == 1.0 diff --git a/tests/test_model_loading.py b/tests/test_model_loading.py new file mode 100644 index 0000000..2a922de --- /dev/null +++ b/tests/test_model_loading.py @@ -0,0 +1,120 @@ +"""File selection, SN isolation and legacy compatibility at the public loader boundary.""" + +import json +import time +from types import SimpleNamespace + +import numpy as np +import pytest + +from orisys import calibration +from orisys.calibration_context import build_context +from orisys.sensors.calibration import resolve_calibration + + +def model_doc(): + config = {"type": "single_camera", "camera": {"resolution_stitch": [64, 64]}, + "tracker": {"dis_mode": 1, "hhd_downsample": 3}} + return {"schema_version": 3, "unit": "N", "identity": {"sensor_type": "finger", "sn": "A1"}, + "pipeline": build_context(config), "model": { + "type": "global_gain/1", "input_profile": "force_delta/1", "output_frame": "sdk_force/1", + "outputs": ["fn"], "params": {"gains_N_per_raw": {"fn": .001}, "valid_force_range_N": [0, 3]}}} + + +def test_schema3_public_load_write_and_fn_only_sn(tmp_path): + path = tmp_path / "finger" / "A1.json" + path.parent.mkdir() + path.write_text(json.dumps(model_doc())) + model = calibration.load_for_device(tmp_path, "finger", "A1") + assert tuple(model.channels) == ("fn",) + assert calibration.check(model).ok + target = tmp_path / "copy.json" + calibration.write(model, target) + assert json.loads(target.read_text())["schema_version"] == 3 + assert calibration.load(target).fingerprint == model.fingerprint + with pytest.raises(calibration.CalibrationError, match="identity mismatch"): + calibration.validate_for_device(model, "finger", "another-SN") + + +def test_missing_sn_does_not_pick_any_neighbor_or_default(tmp_path): + (tmp_path / "finger").mkdir() + (tmp_path / "finger" / "A1.json").write_text(json.dumps(model_doc())) + (tmp_path / "force_calibration.json").write_text(json.dumps(model_doc())) + assert calibration.load_for_device(tmp_path, "finger", "B2") is None + with pytest.raises(calibration.CalibrationError, match="directory"): + calibration.load_for_device(tmp_path / "missing", "finger", "B2") + + +@pytest.mark.parametrize("change", ["unknown_type", "unknown_schema", "malformed"]) +def test_selected_bad_model_never_falls_back(tmp_path, change): + data = model_doc() + if change == "unknown_type": + data["model"]["type"] = "onnx/1" + elif change == "unknown_schema": + data["schema_version"] = 99 + else: + data["model"]["params"]["gains_N_per_raw"] = {} + path = tmp_path / "selected.json" + path.write_text(json.dumps(data)) + with pytest.raises(calibration.CalibrationError): + calibration.load(path, sensor_type="box") + + +def test_invalid_default_schema3_does_not_silently_become_raw(monkeypatch, tmp_path): + from orisys import configs + + data = model_doc() + data["model"]["type"] = "onnx/1" + path = tmp_path / "force_calibration.json" + path.write_text(json.dumps(data)) + monkeypatch.setattr(configs, "resolve_sensor_bundle", lambda name: SimpleNamespace(force_calibration_json=path)) + with pytest.raises(calibration.CalibrationError, match="unsupported calibration model"): + calibration.load(sensor_type="finger") + + +def test_path_selection_keeps_explicit_and_sn_modes_separate(tmp_path): + with pytest.raises(calibration.CalibrationError, match="mutually exclusive"): + calibration.select_calibration_path(cal_path="file.json", calibration_dir=tmp_path) + with pytest.raises(calibration.CalibrationError, match="requires"): + calibration.select_calibration_path(device_id="A1") + assert calibration.select_calibration_path(cal_path=tmp_path / "A1.json") == tmp_path / "A1.json" + + +def test_profile_declares_only_available_axes_and_checks_family(tmp_path): + path = tmp_path / "selected.json" + path.write_text(json.dumps(model_doc())) + options = dict(bundle_root=tmp_path, declared_default=None, profile_id="test", selection=path) + resolved = resolve_calibration(product_family="finger", **options) + assert resolved.axes == ("fn",) and resolved.document is not None + assert resolved.fingerprint == resolved.document.fingerprint + wrong = resolve_calibration(product_family="box", **options) + assert wrong.document is None and wrong.issues[0].code == "calibration.model-family-mismatch" + + +def test_processor_applies_resolved_selection_including_none(monkeypatch): + from orisys.sensors import backends + + monkeypatch.setattr(backends, "validate_profile", lambda profile: SimpleNamespace(usable=True)) + selected = calibration.parse(model_doc()) + calls = [] + sensor = SimpleNamespace(set_calibration=calls.append) + for model in (selected, None): + profile = SimpleNamespace(processing_backend="single_camera", config={"camera": {}}, + calibration=SimpleNamespace(document=model), profile_id="test") + session = backends.create_processor(profile, sensor=sensor) + assert session.sensor is sensor + assert calls == [selected, None] + + +def test_snapshot_keeps_per_frame_units_and_validity_copy(): + from orisys.pipeline import StreamPipeline + + sensor = SimpleNamespace(info=object(), force_unit="N", force_units=("N", "raw", "raw"), + calibration_status={"fn": "outside_coverage"}, fnormal=float("nan"), + fshearx=2., fsheary=3., _force_raw=(1., 2., 3.)) + pipeline = StreamPipeline(sensor, lambda: None, include=()) + snapshot = pipeline._build(np.zeros((2, 2)), None, 0., 0., 0., time.perf_counter()) + assert snapshot.force_units == ("N", "raw", "raw") + assert snapshot.calibration_status == {"fn": "outside_coverage"} + sensor.calibration_status["fn"] = "ok" + assert snapshot.calibration_status["fn"] == "outside_coverage" diff --git a/tests/test_model_runtime.py b/tests/test_model_runtime.py new file mode 100644 index 0000000..37076f5 --- /dev/null +++ b/tests/test_model_runtime.py @@ -0,0 +1,308 @@ +"""Schema 3 lifecycle and readout tests without camera or hardware.""" + +from types import SimpleNamespace + +import numpy as np +import pytest + +from orisys.calibration import CalibrationError +from orisys.force_readout import ForceReadout +from orisys.sensor import Sensor + + +class RecordingModel: + schema_version = 3 + channels = {"fn": None} + input_profile = "fn_delta_autocal_uv/1" + + def __init__(self): + self.calls = [] + self.resets = [] + self.coordinate_map = np.array([[[0., 0.], [1., 0.]], [[0., 1.], [1., 1.]]]) + self.coordinate_valid_mask = np.array([[True, False], [True, True]]) + + def reset(self, reference_id): + self.resets.append(reference_id) + + def validate_for_context(self, context): + if context != {"signature": "actual"}: + raise CalibrationError("pipeline mismatch") + + def predict(self, **kwargs): + self.calls.append(kwargs) + features = kwargs["features"] + status = "ok" + if not features["zero_valid"]: + status = "zero_unavailable" + elif not features["contact_found"]: + status = "no_contact" + elif not features["mapping_valid"]: + status = "coordinate_unmapped" + value = features["delta_raw"] * .1 if status == "ok" else None + return SimpleNamespace(values_N={"fn": value}, status={"fn": status}) + + +def make_sensor(*, median=1): + sensor = Sensor.__new__(Sensor) + sensor.calibration = RecordingModel() + sensor._config = {"filters": {"force_median_window": median}} + sensor._force_readout = ForceReadout(sensor._config["filters"]) + sensor._force_raw = (10., 20., 30.) + sensor._model_readouts = {} + sensor._calibration_context = None + sensor._reset_calibration_session() + sensor.bind_calibration_context({"signature": "actual"}) + return sensor + + +def predict(sensor, frame, fn, *, contact=True, mapped=True): + return sensor.apply_calibration_frame(frame_id=frame, timestamp_s=frame / 30., + raw=(fn, 20., 30.), uv=(0., 0.), + contact_found=contact, mapping_valid=mapped) + + +def test_once_per_frame_and_explicit_zero_subtracted_once(): + sensor = make_sensor() + sensor.set_calibration_reference({"fn": 10.}, reference_id="zero-a") + first = predict(sensor, 1, 25.) + assert first.values_N["fn"] == pytest.approx(1.5) + assert predict(sensor, 1, 99.) is first + assert len(sensor.calibration.calls) == 1 + assert sensor.force_units == ("N", "raw", "raw") + assert (sensor.fnormal, sensor.fshearx, sensor.fsheary) == (1.5, 20., 30.) + assert sensor._force_raw_unfiltered == (25., 20., 30.) + + +def test_unknown_zero_and_invalid_contact_never_become_zero_newtons(): + sensor = make_sensor() + assert predict(sensor, 1, 20.).status["fn"] == "zero_unavailable" + assert np.isnan(sensor.fnormal) + sensor.set_calibration_reference({"fn": 10.}, reference_id="zero-b") + assert predict(sensor, 2, 20., contact=False).status["fn"] == "no_contact" + assert np.isnan(sensor.fnormal) + assert predict(sensor, 3, 20., mapped=False).status["fn"] == "coordinate_unmapped" + assert np.isnan(sensor.fnormal) + + +def test_failed_frame_does_not_enter_newton_median_history(): + sensor = make_sensor(median=3) + sensor.set_calibration_reference({"fn": 0.}, reference_id="zero") + predict(sensor, 1, 10.) + predict(sensor, 2, 1000., contact=False) + predict(sensor, 3, 20.) + predict(sensor, 4, 30.) + assert sensor.fnormal == pytest.approx(2.) + assert sensor._force_unfiltered[0] == 3. + + +def test_new_reference_clears_cached_prediction_and_filter(): + sensor = make_sensor(median=3) + sensor.set_calibration_reference({"fn": 0.}, reference_id="first") + predict(sensor, 1, 10.) + sensor.set_calibration_reference({"fn": 20.}, reference_id="second") + assert sensor.calibration_result is None + assert np.isnan(sensor.fnormal) + predict(sensor, 1, 30.) + assert sensor.fnormal == pytest.approx(1.) + assert sensor.calibration.resets[-1] == "second" + + +def test_requires_actual_pipeline_context(): + sensor = make_sensor() + with pytest.raises(CalibrationError, match="pipeline mismatch"): + sensor.bind_calibration_context({"signature": "different"}) + assert sensor._calibration_context == {"signature": "actual"} + sensor._calibration_context = None + with pytest.raises(CalibrationError, match="actual calibration context"): + predict(sensor, 1, 10.) + + +def test_contact_map_never_reuses_previous_coordinate(): + sensor = make_sensor() + sensor.centroid_found = True + sensor.centroid = (0, 1) + assert sensor._calibration_contact_uv() == (0., 1.) + sensor.centroid = (1, 0) + assert sensor._calibration_contact_uv() is None + sensor.centroid = (10, 10) + assert sensor._calibration_contact_uv() is None + sensor.centroid_found = False + sensor.centroid = (0, 1) + assert sensor._calibration_contact_uv() is None + + +def test_hhd_infers_after_current_contact_only_once_and_clears_old_contact(): + sensor = make_sensor() + sensor.set_calibration_reference({"fn": 10.}, reference_id="zero") + field = np.zeros((2, 2, 2), dtype=np.float32) + sensor._Sensor__hhd = SimpleNamespace( + get_force=lambda *args, **kwargs: (None, (20., 0., 0., 0.)), + get_normal=lambda: field, get_shear=lambda: field, + ) + contact = {"centroid": (0, 1), "centroid_found": True, + "divergence_map": np.zeros((2, 2))} + sensor._Sensor__contact3d = SimpleNamespace(compute_foe=lambda *args, **kwargs: contact) + sensor.cuda = sensor.verbose = False + sensor._hhd_downsample = 1 + sensor._hhd_grid_shape = sensor._hhd_ref_shape = (2, 2) + sensor.valid_mask = sensor.valid_mask_hhd = None + sensor._model_frame_id = 0 + sensor.compute_hhd(field) + assert sensor.calibration.calls[-1]["features"]["v"] == 1. + assert sensor.fnormal == pytest.approx(1.) + sensor.compute_contact() + assert len(sensor.calibration.calls) == 1 + contact["centroid_found"] = False + sensor.compute_hhd(field) + assert len(sensor.calibration.calls) == 2 + assert sensor.calibration_status == {"fn": "no_contact"} + assert sensor.calibration.calls[-1]["features"]["u"] is None + assert np.isnan(sensor.fnormal) + + +def test_real_model_adapter_uses_same_lifecycle_as_stateful_model(): + from orisys.calibration import parse + + data = { + "schema_version": 3, "unit": "N", + "identity": {"sensor_type": "finger", "sn": "test"}, + "pipeline": {"stitch_resolution": [2, 2], "dis_mode": 4, "hhd_downsample": 1, + "filters": {"measurement": {"flow_ema_alpha": 1.}}, + "feature_signature_sha256": "a" * 64}, + "model": {"type": "global_gain/1", "input_profile": "force_delta/1", + "output_frame": "sdk_force/1", "outputs": ["fn"], + "params": {"gains_N_per_raw": {"fn": .1}, "valid_force_range_N": [0., 3.]}}, + } + sensor = make_sensor() + sensor.calibration = parse(data) + sensor.bind_calibration_context(data["pipeline"]) + sensor.set_calibration_reference({"fn": 10.}, reference_id="reference") + result = sensor.apply_calibration_frame(frame_id=1, raw=(20., 30., 40.)) + assert result.values_N == {"fn": 1.} + assert sensor.force_units == ("N", "raw", "raw") + assert sensor.calibration_status == {"fn": "ok"} + result = sensor.apply_calibration_frame(frame_id=2, raw=(20., 30., 40.), contact_found=False) + assert result.status == {"fn": "no_contact"} + + +def test_reversed_frame_requires_a_new_zero_reference(): + sensor = make_sensor() + sensor.set_calibration_reference({"fn": 10.}, reference_id="reference") + predict(sensor, 10, 20.) + assert predict(sensor, 2, 20.).status == {"fn": "zero_unavailable"} + assert sensor.calibration.resets[-1] is None + + +def test_replacement_is_atomic_when_pipeline_cannot_be_validated(): + sensor = make_sensor() + sensor.device_id = None + original = sensor.calibration + replacement = RecordingModel() + with pytest.raises(CalibrationError, match="pipeline mismatch"): + sensor.set_calibration(replacement, context={"signature": "different"}) + assert sensor.calibration is original + sensor._calibration_context = None + with pytest.raises(CalibrationError, match="actual pipeline context"): + sensor.set_calibration(replacement) + assert sensor.calibration is original + sensor.set_calibration(replacement, context={"signature": "actual"}) + assert sensor.calibration is replacement + assert sensor._calibration_reference == {} + + +@pytest.mark.parametrize("use_legacy", [False, True]) +def test_schema3_swap_cannot_relabel_cached_newtons_as_raw_or_legacy(use_legacy): + from orisys.calibration import parse + + sensor = make_sensor() + sensor.device_id = None + sensor.set_calibration_reference({"fn": 10.}, reference_id="reference") + predict(sensor, 1, 25.) + assert sensor.fnormal == 1.5 + legacy = parse({"schema_version": 2, "unit": "N", "channels": { + "normal": {"method": "piecewise_linear", "points": [[0., 0.], [100., 2.]], + "extrapolate": True}, + }}) if use_legacy else None + sensor.set_calibration(legacy) + expected = .5 if use_legacy else 25. + assert sensor.fnormal == pytest.approx(expected) + assert sensor.force_triplets()["unfiltered"]["newton"][0] == pytest.approx(expected) + assert sensor.force_triplets()["filtered"]["raw"][0] == 25. + assert sensor.calibration_status == {} + + +def test_reset_invalidates_contact_cache_and_coordinate(): + sensor = make_sensor() + sensor._Sensor__tracker = SimpleNamespace(reset=lambda: None) + sensor.img = None + sensor.compute_contact_valid = sensor.centroid_found = True + sensor.centroid = (1, 1) + sensor._model_contact_result = {"centroid": (1, 1)} + sensor.compute_deformation_valid = True + sensor.reset() + assert sensor.compute_contact_valid is False + assert sensor.compute_deformation_valid is False + assert sensor.centroid_found is False + assert sensor._model_contact_result is None + assert sensor._calibration_contact_uv() is None + with pytest.raises(CalibrationError, match="current deformation"): + sensor.compute_contact() + + +def test_schema3_rejects_measurement_filter_change_before_mutation(): + sensor = make_sensor() + sensor.compute_deformation_valid = False + with pytest.raises(CalibrationError, match="measurement identity"): + sensor.configure(flow_ema_alpha=.5) + assert sensor._force_readout.flow_ema_alpha == 1. + assert sensor._calibration_context == {"signature": "actual"} + + +def test_readout_configuration_applies_before_and_after_first_model_frame(): + sensor = make_sensor() + sensor.compute_deformation_valid = False + sensor.configure(force_median_window=3, lowpass_sample_rate_hz=20.) + sensor.set_calibration_reference({"fn": 0.}, reference_id="reference") + predict(sensor, 1, 10.) + readout = sensor._model_readouts["fn"] + assert readout.force_median_window == 3 + assert readout.lowpass_sample_rate_hz == 20. + predict(sensor, 2, 20.) + predict(sensor, 3, 30.) + assert sensor.fnormal == 2. + sensor.configure(force_median_window=1) + assert readout.force_median_window == 1 + predict(sensor, 4, 40.) + assert sensor.fnormal == 4. + + +def test_generic_global_fallback_binds_actual_mirror_identity(): + from orisys.calibration import parse + from orisys.calibration_context import build_context + + sensor = make_sensor() + sensor.device_id = None + sensor.mirror = True + sensor._config.update(camera={"resolution_stitch": [2, 2]}, tracker={"dis_mode": 1}) + context = build_context(sensor._config, sensor.filters_state(), preprocessing={"mirror": True}) + data = {"schema_version": 3, "unit": "N", "identity": {"sensor_type": "finger", "sn": "test"}, + "pipeline": context, + "model": {"type": "global_gain/1", "input_profile": "force_delta/1", + "output_frame": "sdk_force/1", "outputs": ["fn"], + "params": {"gains_N_per_raw": {"fn": .1}, "valid_force_range_N": [0., 3.]}}} + sensor._calibration_context = None + sensor.set_calibration(parse(data)) + assert sensor._calibration_context == context + sensor.mirror = False + sensor._calibration_context = None + with pytest.raises(CalibrationError, match="feature_signature"): + sensor.set_calibration(parse(data)) + + +@pytest.mark.parametrize("reference", [{"fn": float("nan")}, {"other": 0.}]) +def test_bad_zero_reference_is_rejected_without_reset(reference): + sensor = make_sensor() + sensor.set_calibration_reference({"fn": 10.}, reference_id="valid") + with pytest.raises(ValueError): + sensor.set_calibration_reference(reference, reference_id="bad") + assert sensor._calibration_reference == {"fn": 10.}