From 96f4428230676ca234605747df2dadb703d5360a Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Thu, 10 Sep 2026 21:47:36 +0800 Subject: [PATCH] added calibration data handling functions --- docs/api.md | 22 +- examples/basic_pipeline.py | 5 +- sensors/finger/sensor.json | 2 +- sensors/mini/sensor.json | 2 +- src/orisys/__init__.py | 8 + src/orisys/calibration.py | 782 +++++++++++++++++++++++++++++ src/orisys/configs.py | 80 +++ src/orisys/export_sensors.py | 70 +++ src/orisys/sensor.py | 80 ++- src/orisys/signal_processing.py | 227 +++------ tests/test_calibration.py | 243 +++++++++ tests/test_export_sensor_bundle.py | 48 ++ tests/test_signal_processing.py | 5 +- 13 files changed, 1399 insertions(+), 175 deletions(-) create mode 100644 src/orisys/calibration.py create mode 100644 src/orisys/export_sensors.py create mode 100644 tests/test_calibration.py create mode 100644 tests/test_export_sensor_bundle.py diff --git a/docs/api.md b/docs/api.md index 5afc69b..352208e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -17,7 +17,7 @@ description: Orisys SDK Python API 中文参考 * **vid_src** (`int | str`, 必填): 传感器 ID、相机编号或视频路径。默认为 `0`。 * **config_name** (`str`,可选): 传感器配置文件名称或 `json` 文件路径,用于加载对应的参数配置;例如 `box` 或 `./config/box.json`。 -* **cal_path** (`str`,可选,已弃用): 该参数仅为兼容旧代码保留,当前版本中不再使用,传入后也会被忽略。 +* **cal_path** (`str`,可选): 显式的 `force_calibration.json` 路径;传入后优先于 `sensors//force_calibration.json`。不传时按 `config_name` 自动加载产品包中的标定文件。 * **backend** (`str`,可选): 光流场解耦计算后端,默认为 `auto`,可选`cupy`,`opencl`,`cpu`。 * **verbose** (`bool`,可选): 是否输出初始化及运行过程中的详细日志信息,默认为 `False`。 @@ -577,6 +577,26 @@ finally: * 导出内置的`config_name`配置参数字典至`path` +### `export_sensor_bundle` / `export_sensor_bundles` 方法 + +#### 描述 +将已安装的 `sensors//` 传感器包复制到目标目录,供下游应用(如桌面 App)做本地可编辑配置,无需硬编码 wheel 路径。解析顺序与 `resolve_sensor_bundle` 相同(含 `importlib.resources` 打包布局)。 + +#### 输入参数 +* **name** / **names**: 单个名字,或名字列表;`export_sensor_bundles` 省略 `names` 时导出所有可解析的产品包(`box` / `mini` / `finger`) +* **dest_dir**: 目标根目录;实际写入 `dest_dir//` +* **include_assets**: 默认 `False`,仅复制 `sensor.json` 以及存在的 `force_calibration.json` / `manifest.json`;为 `True` 时复制完整目录树(含 `assets/`) +* **overwrite**: 默认 `True`;为 `False` 且目标已存在时抛出 `FileExistsError` + +#### 返回 +* `Path` 或 `list[Path]`:导出后的包目录路径 + +#### CLI +```bash +python -m orisys.export_sensors --dest ./out/sensors --names box,mini +python -m orisys.export_sensors --dest ./out/sensors --include-assets +``` + ## 常见问题解答 (FAQ) ### Q1:为什么必须先调用`get_img()`,再调用`compute_deformation()`? A:`compute_deformation()`依赖最近一次获取并拼接的图像数据进行形变与力计算。 diff --git a/examples/basic_pipeline.py b/examples/basic_pipeline.py index 0d0cd59..57daa4e 100644 --- a/examples/basic_pipeline.py +++ b/examples/basic_pipeline.py @@ -21,6 +21,7 @@ def main(): parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径") parser.add_argument("--config","-c", type=str, default="box", help="配置文件名称或路径") parser.add_argument("--verbose","-verbose", type=bool, default=True, help="是否输出详细日志") + parser.add_argument("--cal_path","-cal", type=str, default=None, help="标定文件路径") args = parser.parse_args() # 步骤 1:创建传感器对象(必需) @@ -29,9 +30,9 @@ def main(): # 输入既可以是摄像头编号,也可以是视频文件路径 if args.video.isdigit(): - sensor = orisys.Sensor(int(args.video), config_name=args.config, verbose=args.verbose) + sensor = orisys.Sensor(int(args.video), config_name=args.config, verbose=args.verbose, cal_path=args.cal_path) else: - sensor = orisys.Sensor(args.video, config_name=args.config, verbose=args.verbose) + sensor = orisys.Sensor(args.video, config_name=args.config, verbose=args.verbose, cal_path=args.cal_path) print("\n按键说明:'q' 退出程序,'r' 重置追踪器。") diff --git a/sensors/finger/sensor.json b/sensors/finger/sensor.json index 930caeb..ebdb709 100644 --- a/sensors/finger/sensor.json +++ b/sensors/finger/sensor.json @@ -22,7 +22,7 @@ "dis_preset": "medium", "dis_mode": 1, "algorithm_list": ["raft", "dis"], - "hhd_downsample": 1 + "hhd_downsample": 3 }, "contact": { "min_contour_area": 3000, diff --git a/sensors/mini/sensor.json b/sensors/mini/sensor.json index 1bbf513..af88413 100644 --- a/sensors/mini/sensor.json +++ b/sensors/mini/sensor.json @@ -29,7 +29,7 @@ "loop_closure_interval": 1, "algorithm": "dis", "dis_preset": "medium", - "dis_mode": 1, + "dis_mode": 3, "algorithm_list": ["raft", "dis"], "hhd_downsample": 3 }, diff --git a/src/orisys/__init__.py b/src/orisys/__init__.py index 779003d..0378a92 100644 --- a/src/orisys/__init__.py +++ b/src/orisys/__init__.py @@ -11,6 +11,7 @@ __author__ = "Orisys" from .sensor import Sensor from . import util from . import configs +from . import calibration from . import finger from . import signal_processing from . import capture @@ -22,6 +23,8 @@ CameraSensor = Sensor # Re-export list_configs as list_config for convenience list_config = configs.list_config export_config = configs.export_config +export_sensor_bundle = configs.export_sensor_bundle +export_sensor_bundles = configs.export_sensor_bundles __all__ = [ # class @@ -41,9 +44,14 @@ __all__ = [ # capture helpers 'capture', + # force calibration + 'calibration', + # configs utilities functions 'list_config', 'export_config', + 'export_sensor_bundle', + 'export_sensor_bundles', # test 'FFCFrameProcessor' diff --git a/src/orisys/calibration.py b/src/orisys/calibration.py new file mode 100644 index 0000000..6060d64 --- /dev/null +++ b/src/orisys/calibration.py @@ -0,0 +1,782 @@ +"""Force calibration load, parse, check, build, and write. + +Owns ``force_calibration.json`` schema v1/v2. ``Sensor`` loads once and applies +curves in ``_apply_force_filter``; ``read_info(FNORMAL, …)`` returns Newtons. +""" + +from __future__ import annotations + +import json +import warnings +from copy import deepcopy +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable, Mapping + +CURRENT_SCHEMA = 2 +SUPPORTED_SCHEMAS = (1, 2) +CHANNEL_IDS: tuple[str, ...] = ("fn", "fx", "fy") + +CHANNEL_ALIASES: dict[str, str] = { + "fn": "fn", + "FN": "fn", + "normal": "fn", + "fnormal": "fn", + "fx": "fx", + "FX": "fx", + "shear_x": "fx", + "fshearx": "fx", + "fy": "fy", + "FY": "fy", + "shear_y": "fy", + "fsheary": "fy", +} + +_CANONICAL_TO_JSON = { + "fn": "normal", + "fx": "shear_x", + "fy": "shear_y", +} +_JSON_RAW_KEY = { + "normal": "fnormal", + "shear_x": "fshearx", + "shear_y": "fsheary", +} +_KNOWN_TOP_KEYS = frozenset( + {"schema_version", "unit", "channels", "meta", "identity", "pipeline", "provenance", "measurements", "quality"} +) + + +class CalibrationError(ValueError): + """Invalid or unusable force calibration document / path.""" + + +def canonicalize_channel(name: str) -> str: + """Map FN / normal / shear_x / … to canonical ``fn`` / ``fx`` / ``fy``.""" + key = str(name or "").strip() + if key in CHANNEL_ALIASES: + return CHANNEL_ALIASES[key] + lower = key.lower() + if lower in CHANNEL_ALIASES: + return CHANNEL_ALIASES[lower] + raise CalibrationError(f"unknown calibration channel: {name!r}") + + +def _parse_point(point: Any) -> tuple[float, float]: + if isinstance(point, dict): + raw = point.get("fnormal", point.get("raw", point.get("x"))) + force_n = point.get("force_n", point.get("n", point.get("y"))) + if raw is None or force_n is None: + raise CalibrationError(f"invalid calibration point: {point!r}") + return float(raw), float(force_n) + if isinstance(point, (list, tuple)) and len(point) >= 2: + return float(point[0]), float(point[1]) + raise CalibrationError(f"invalid calibration point: {point!r}") + + +@dataclass(frozen=True) +class ChannelCurve: + """One force channel: linear coeffs and/or piecewise-linear breakpoints.""" + + id: str + method: str = "piecewise_linear" + points: tuple[tuple[float, float], ...] = () + slope: float | None = None + offset: float | None = None + signed: bool = False + extrapolate: bool = True + clamp_output_min: float | None = 0.0 + raw_key: str = "" + input_mode: str = "magnitude" + unit: str = "N" + + def convert(self, raw: float) -> float: + raw = float(raw) + if self.method in ("linear", "affine"): + if self.slope is None or self.offset is None: + return raw + return float(self.slope) * raw + float(self.offset) + + points = self.points + if len(points) < 2: + return raw + + if raw <= points[0][0]: + segment = (points[0], points[1]) + if not self.extrapolate: + return self._clamp(points[0][1]) + elif raw >= points[-1][0]: + segment = (points[-2], points[-1]) + if not self.extrapolate: + return self._clamp(points[-1][1]) + else: + segment = None + for left, right in zip(points, points[1:]): + if left[0] <= raw <= right[0]: + segment = (left, right) + break + if segment is None: + return self._clamp(points[-1][1]) + + (raw0, n0), (raw1, n1) = segment + if raw1 == raw0: + value = n0 + else: + ratio = (raw - raw0) / (raw1 - raw0) + value = n0 + ratio * (n1 - n0) + return self._clamp(value) + + def convert_component(self, raw: float) -> float: + if not self.signed: + return self.convert(raw) + raw = float(raw) + sign = -1.0 if raw < 0.0 else 1.0 + return sign * self.convert(abs(raw)) + + def _clamp(self, value: float) -> float: + if self.clamp_output_min is not None: + return max(float(self.clamp_output_min), float(value)) + return float(value) + + def to_dict(self) -> dict[str, Any]: + json_name = _CANONICAL_TO_JSON.get(self.id, self.id) + if self.method in ("linear", "affine"): + block: dict[str, Any] = { + "method": "linear", + "slope": self.slope, + "offset": self.offset, + } + else: + block = { + "method": "piecewise_linear", + "raw_key": self.raw_key or _JSON_RAW_KEY.get(json_name, ""), + "extrapolate": self.extrapolate, + "clamp_output_min": self.clamp_output_min, + "points": [list(p) for p in self.points], + } + if self.signed: + block["signed"] = True + if self.input_mode and self.input_mode != "magnitude": + block["input"] = self.input_mode + return block + + +@dataclass(frozen=True) +class CalSample: + weight_n: float + raw_mean: float + n: int + std: float = 0.0 + + +@dataclass +class CheckResult: + ok: bool + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class Calibration: + """Parsed force calibration document.""" + + schema_version: int + unit: str + origin: str + identity: dict[str, Any] + pipeline: dict[str, Any] + provenance: dict[str, Any] + channels: dict[str, ChannelCurve] + measurements: dict[str, Any] + extras: dict[str, Any] = field(default_factory=dict) + source_path: Path | None = None + # Legacy shared shear (v1) for magnitude-mode fallback in helpers. + _legacy_shear: ChannelCurve | None = None + + def get(self, channel: str) -> ChannelCurve | None: + try: + cid = canonicalize_channel(channel) + except CalibrationError: + return self.channels.get(str(channel)) + return self.channels.get(cid) + + def has(self, channel: str) -> bool: + return self.get(channel) is not None + + def convert(self, fn: float, fx: float, fy: float) -> tuple[float, float, float]: + """Apply present curves; missing channel stays raw.""" + out_fn = float(fn) + out_fx = float(fx) + out_fy = float(fy) + cal_fn = self.channels.get("fn") + cal_fx = self.channels.get("fx") + cal_fy = self.channels.get("fy") + if cal_fn is not None: + out_fn = cal_fn.convert(out_fn) + if cal_fx is not None: + out_fx = ( + cal_fx.convert_component(out_fx) + if cal_fx.signed + else cal_fx.convert(out_fx) + ) + if cal_fy is not None: + out_fy = ( + cal_fy.convert_component(out_fy) + if cal_fy.signed + else cal_fy.convert(out_fy) + ) + return out_fn, out_fx, out_fy + + def to_dict(self) -> dict[str, Any]: + channels: dict[str, Any] = {} + for cid, curve in self.channels.items(): + json_name = _CANONICAL_TO_JSON.get(cid, cid) + channels[json_name] = curve.to_dict() + # Prefer emitting v2 axis names even when parsed from v1 shared shear. + doc: dict[str, Any] = { + "schema_version": max(int(self.schema_version), 2) if self.channels else int(self.schema_version), + "unit": self.unit, + "channels": channels, + } + meta: dict[str, Any] = {} + meta.update(self.provenance) + meta.update({k: v for k, v in self.identity.items() if k not in meta}) + for key in ("stitch_resolution", "dis_mode", "hhd_downsample", "config_ref"): + if key in self.pipeline and self.pipeline[key] is not None: + meta[key] = self.pipeline[key] + if self.measurements: + meta["samples"] = self.measurements + if self.origin and "origin" not in meta: + meta["origin"] = self.origin + if meta: + doc["meta"] = meta + for key, value in self.extras.items(): + if key not in doc: + doc[key] = deepcopy(value) + return doc + + +def _channel_from_block( + channel_id: str, + block: Any, + *, + unit: str = "N", + default_method: str = "piecewise_linear", +) -> ChannelCurve | None: + if not isinstance(block, dict): + return None + method = str(block.get("method", block.get("model", default_method))).lower() + if method in ("linear", "affine"): + slope = block.get("slope") + offset = block.get("offset") + if slope is None or offset is None: + return None + return ChannelCurve( + id=channel_id, + method="linear", + slope=float(slope), + offset=float(offset), + unit=str(block.get("unit", unit)), + raw_key=str(block.get("raw_key", "")), + ) + if method != "piecewise_linear": + return None + raw_points = block.get("points", []) + if not isinstance(raw_points, (list, tuple)) or len(raw_points) < 2: + return None + try: + points = tuple(sorted(_parse_point(p) for p in raw_points)) + except (TypeError, ValueError, CalibrationError): + return None + if len(points) < 2: + return None + return ChannelCurve( + id=channel_id, + method="piecewise_linear", + points=points, + extrapolate=bool(block.get("extrapolate", True)), + clamp_output_min=block.get("clamp_output_min", 0.0), + input_mode=str(block.get("input", "magnitude")).lower(), + signed=bool(block.get("signed", False)), + unit=str(block.get("unit", unit)), + raw_key=str(block.get("raw_key", _JSON_RAW_KEY.get(_CANONICAL_TO_JSON.get(channel_id, ""), ""))), + ) + + +def check(doc: dict | Calibration, *, pipeline: dict | None = None) -> CheckResult: + """Validate a document or Calibration. Errors refuse apply/write.""" + errors: list[str] = [] + warns: list[str] = [] + + if isinstance(doc, Calibration): + data = doc.to_dict() + channels_obj = doc.channels + schema = doc.schema_version + identity = doc.identity + elif isinstance(doc, dict): + data = doc + schema_raw = data.get("schema_version", 1) + try: + schema = int(schema_raw) + except (TypeError, ValueError): + errors.append(f"invalid schema_version: {schema_raw!r}") + return CheckResult(ok=False, errors=errors, warnings=warns) + if schema not in SUPPORTED_SCHEMAS: + errors.append(f"unsupported schema_version: {schema}") + channels = data.get("channels") + if not isinstance(channels, dict): + errors.append("missing or invalid channels object") + return CheckResult(ok=False, errors=errors, warnings=warns) + channels_obj = {} + unit = str(data.get("unit", "N")) + for json_name, block in channels.items(): + try: + cid = canonicalize_channel(json_name) if json_name != "shear" else "shear" + except CalibrationError: + # Future / unknown channel names: skip strict mapping. + continue + if json_name == "shear": + curve = _channel_from_block("fx", block, unit=unit) + else: + curve = _channel_from_block(cid, block, unit=unit) + if curve is None: + method = None + if isinstance(block, dict): + method = block.get("method", block.get("model")) + if isinstance(block, dict) and method not in ( + None, + "piecewise_linear", + "linear", + "affine", + ): + errors.append(f"unknown method for {json_name}: {method!r}") + elif isinstance(block, dict): + pts = block.get("points", []) + if method in ("piecewise_linear", None) and ( + not isinstance(pts, (list, tuple)) or len(pts) < 2 + ): + errors.append(f"{json_name}: need at least 2 points") + else: + errors.append(f"invalid channel block: {json_name}") + else: + channels_obj[cid if json_name != "shear" else "fx"] = curve + meta = data.get("meta") if isinstance(data.get("meta"), dict) else {} + identity = { + "sensor_type": meta.get("sensor_type", ""), + "sn": meta.get("sn", ""), + } + else: + return CheckResult(ok=False, errors=["document must be dict or Calibration"]) + + if not channels_obj and not errors: + errors.append("no usable channel curves") + + if "fn" not in channels_obj and "fx" not in channels_obj and "fy" not in channels_obj: + if not errors: + errors.append("no FN/FX/FY channels") + + if "fx" not in channels_obj: + warns.append("missing FX channel") + if "fy" not in channels_obj: + warns.append("missing FY channel") + + sn = str(identity.get("sn") or "") + origin = str(identity.get("origin") or data.get("meta", {}).get("origin", "") if isinstance(data, dict) else "") + if not sn and origin in ("factory",): + warns.append("empty sn on factory calibration") + + if pipeline is not None and isinstance(doc, Calibration): + for key in ("stitch_resolution", "dis_mode", "hhd_downsample"): + if key in doc.pipeline and key in pipeline and doc.pipeline[key] != pipeline[key]: + warns.append(f"pipeline mismatch: {key}") + + return CheckResult(ok=not errors, errors=errors, warnings=warns) + + +def parse(data: dict, *, source_path: Path | None = None) -> Calibration: + """Parse a force_calibration dict into a Calibration object.""" + if not isinstance(data, dict): + raise CalibrationError("calibration document must be a JSON object") + + schema_raw = data.get("schema_version", 1) + try: + schema = int(schema_raw) + except (TypeError, ValueError) as exc: + raise CalibrationError(f"invalid schema_version: {schema_raw!r}") from exc + if schema not in SUPPORTED_SCHEMAS: + raise CalibrationError(f"unsupported schema_version: {schema}") + + unit = str(data.get("unit", "N")) + channels_raw = data.get("channels") + if not isinstance(channels_raw, dict): + raise CalibrationError("missing or invalid channels object") + + channels: dict[str, ChannelCurve] = {} + legacy_shear: ChannelCurve | None = None + + # Prefer explicit axis channels (v2). + for json_name, cid in (("normal", "fn"), ("shear_x", "fx"), ("shear_y", "fy")): + if json_name in channels_raw: + curve = _channel_from_block(cid, channels_raw[json_name], unit=unit) + if curve is None: + raise CalibrationError(f"invalid channel block: {json_name}") + channels[cid] = curve + + # v1 shared shear → both fx and fy when axes absent. + if "shear" in channels_raw and ("fx" not in channels or "fy" not in channels): + shear_block = channels_raw["shear"] + legacy_shear = _channel_from_block("fx", shear_block, unit=unit) + if legacy_shear is None: + raise CalibrationError("invalid channel block: shear") + # Copy with correct ids for each axis. + shared = ChannelCurve( + id="fx", + method=legacy_shear.method, + points=legacy_shear.points, + slope=legacy_shear.slope, + offset=legacy_shear.offset, + signed=legacy_shear.signed or legacy_shear.input_mode == "components", + extrapolate=legacy_shear.extrapolate, + clamp_output_min=legacy_shear.clamp_output_min, + raw_key=legacy_shear.raw_key, + input_mode=legacy_shear.input_mode, + unit=legacy_shear.unit, + ) + if "fx" not in channels: + channels["fx"] = shared + if "fy" not in channels: + channels["fy"] = ChannelCurve( + id="fy", + method=shared.method, + points=shared.points, + slope=shared.slope, + offset=shared.offset, + signed=shared.signed, + extrapolate=shared.extrapolate, + clamp_output_min=shared.clamp_output_min, + raw_key=_JSON_RAW_KEY["shear_y"], + input_mode=shared.input_mode, + unit=shared.unit, + ) + legacy_shear = shared + + # Legacy single-channel file: {"normal_force": {"model": "linear", ...}} + if "fn" not in channels: + block = data.get("normal_force", data if "slope" in data else None) + if isinstance(block, dict): + curve = _channel_from_block("fn", block, unit=unit, default_method="linear") + if curve is not None: + channels["fn"] = curve + + if not channels: + raise CalibrationError("no usable channel curves") + + meta = data.get("meta") if isinstance(data.get("meta"), dict) else {} + identity_in = data.get("identity") if isinstance(data.get("identity"), dict) else {} + pipeline_in = data.get("pipeline") if isinstance(data.get("pipeline"), dict) else {} + provenance_in = data.get("provenance") if isinstance(data.get("provenance"), dict) else {} + measurements_in = data.get("measurements") if isinstance(data.get("measurements"), dict) else {} + + identity = { + "sensor_type": identity_in.get("sensor_type", meta.get("sensor_type", "")), + "sn": identity_in.get("sn", meta.get("sn", "")), + "hardware_rev": identity_in.get("hardware_rev", meta.get("hardware_rev", "")), + "gel_id": identity_in.get("gel_id", meta.get("gel_id", "")), + } + pipeline = { + "stitch_resolution": pipeline_in.get( + "stitch_resolution", meta.get("stitch_resolution") + ), + "dis_mode": pipeline_in.get("dis_mode", meta.get("dis_mode")), + "hhd_downsample": pipeline_in.get("hhd_downsample", meta.get("hhd_downsample")), + "config_ref": pipeline_in.get("config_ref", meta.get("config_ref")), + "config_hash": pipeline_in.get("config_hash", meta.get("config_hash")), + } + provenance = { + "origin": provenance_in.get("origin", meta.get("origin", "")), + "created_at": provenance_in.get( + "created_at", meta.get("date", meta.get("created_at", "")) + ), + "calibration_id": provenance_in.get("calibration_id", meta.get("calibration_id", "")), + "tool": provenance_in.get("tool", meta.get("tool")), + "operator": provenance_in.get("operator", meta.get("operator", "")), + "station": provenance_in.get("station", meta.get("station", "")), + "record_duration_s": provenance_in.get( + "record_duration_s", meta.get("record_duration_s") + ), + } + measurements = measurements_in or meta.get("samples") or {} + origin = str(provenance.get("origin") or "default") + + extras = {k: deepcopy(v) for k, v in data.items() if k not in _KNOWN_TOP_KEYS} + + cal = Calibration( + schema_version=schema, + unit=unit, + origin=origin, + identity=identity, + pipeline=pipeline, + provenance=provenance, + channels=channels, + measurements=measurements if isinstance(measurements, dict) else {}, + extras=extras, + source_path=Path(source_path).resolve() if source_path is not None else None, + _legacy_shear=legacy_shear, + ) + result = check(cal) + if not result.ok: + raise CalibrationError("; ".join(result.errors)) + # Missing FX/FY are optional; leave them on CheckResult for callers. + # Only surface non-channel warnings as Python warnings. + for w in result.warnings: + if w.startswith("missing F"): + continue + warnings.warn(f"calibration: {w}", stacklevel=2) + return cal + + +def load( + source: str | Path | None = None, + *, + sensor_type: str | None = None, + sn: str | None = None, # noqa: ARG001 — reserved for SN files + origin: str | None = None, # noqa: ARG001 + strict: bool = False, +) -> Calibration | None: + """Load calibration from an explicit path or product bundle. + + Load order: + 1. ``source`` file path when it exists + 2. ``sensors//force_calibration.json`` + 3. ``None`` (missing bundle file) + + Explicit ``source`` that is missing or invalid raises when the path was + provided (or when ``strict=True``). Missing bundle file returns ``None``. + """ + del sn, origin # reserved + + path: Path | None = None + explicit = source is not None and str(source).strip() != "" + + if explicit: + path = Path(str(source)).expanduser() + if not path.is_file(): + msg = f"calibration file not found: {path}" + if strict or explicit: + raise CalibrationError(msg) + return None + elif sensor_type: + from .configs import resolve_sensor_bundle + + bundle = resolve_sensor_bundle(str(sensor_type)) + if bundle is not None and bundle.force_calibration_json is not None: + path = bundle.force_calibration_json + elif strict: + raise CalibrationError( + f"no force_calibration.json for sensor_type={sensor_type!r}" + ) + else: + return None + else: + if strict: + raise CalibrationError("load() requires source or sensor_type") + return None + + assert path is not None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + msg = f"failed to read calibration JSON: {path}: {exc}" + if strict or explicit: + raise CalibrationError(msg) from exc + return None + + try: + return parse(data, source_path=path) + except CalibrationError: + if strict or explicit: + raise + return None + + +def pipeline_from_sensor_json(path: str | Path | None) -> dict[str, Any]: + """Pull stitch / tracker fields from a sensor.json for export pipeline/meta.""" + pipeline: dict[str, Any] = { + "stitch_resolution": None, + "dis_mode": None, + "hhd_downsample": None, + "config_ref": None, + } + if path is None: + return pipeline + p = Path(path) + pipeline["config_ref"] = str(p) + if not p.is_file(): + return pipeline + try: + data = json.loads(p.read_text(encoding="utf-8")) + except Exception: + return pipeline + if not isinstance(data, dict): + return pipeline + camera = data.get("camera") if isinstance(data.get("camera"), dict) else {} + tracker = data.get("tracker") if isinstance(data.get("tracker"), dict) else {} + stitch = camera.get("resolution_stitch") + if isinstance(stitch, (list, tuple)) and len(stitch) >= 2: + pipeline["stitch_resolution"] = [int(stitch[0]), int(stitch[1])] + if "dis_mode" in tracker: + try: + pipeline["dis_mode"] = int(tracker["dis_mode"]) + except (TypeError, ValueError): + pipeline["dis_mode"] = tracker.get("dis_mode") + if "hhd_downsample" in tracker: + try: + pipeline["hhd_downsample"] = int(tracker["hhd_downsample"]) + except (TypeError, ValueError): + pipeline["hhd_downsample"] = tracker.get("hhd_downsample") + return pipeline + + +def _piecewise_from_samples( + channel_id: str, + samples: Iterable[CalSample], + *, + signed: bool, +) -> ChannelCurve | None: + rows = sorted(samples, key=lambda s: abs(float(s.raw_mean))) + if len(rows) < 2: + return None + points = tuple((abs(float(s.raw_mean)), float(s.weight_n)) for s in rows) + json_name = _CANONICAL_TO_JSON[channel_id] + return ChannelCurve( + id=channel_id, + method="piecewise_linear", + points=points, + signed=signed, + extrapolate=True, + clamp_output_min=0.0, + raw_key=_JSON_RAW_KEY[json_name], + input_mode="components" if signed else "magnitude", + unit="N", + ) + + +def build( + samples: Mapping[str, list[CalSample]], + *, + identity: dict[str, Any] | None = None, + pipeline: dict[str, Any] | None = None, + origin: str = "user", + extra_meta: dict[str, Any] | None = None, +) -> Calibration: + """Fit piecewise curves from samples; omit a channel if fewer than 2 points.""" + channels: dict[str, ChannelCurve] = {} + warnings_list: list[str] = [] + for key in CHANNEL_IDS: + rows = list(samples.get(key, []) or samples.get(key.upper(), []) or []) + # Also accept FN/FX/FY aliases in the mapping keys. + if not rows: + for alias, cid in CHANNEL_ALIASES.items(): + if cid == key and alias in samples: + rows = list(samples[alias] or []) + break + signed = key in ("fx", "fy") + curve = _piecewise_from_samples(key, rows, signed=signed) + if curve is None: + if rows: + warnings_list.append(f"{key}: need at least 2 points (have {len(rows)})") + else: + warnings_list.append(f"{key}: no samples") + continue + channels[key] = curve + + if not channels: + raise CalibrationError( + "No complete channels to build (each needs ≥2 points). " + + "; ".join(warnings_list) + ) + + identity = dict(identity or {}) + pipeline = dict(pipeline or {}) + measurements: dict[str, list[dict[str, float | int]]] = {} + for key in CHANNEL_IDS: + rows = list(samples.get(key, []) or []) + if not rows: + for alias, cid in CHANNEL_ALIASES.items(): + if cid == key and alias in samples: + rows = list(samples[alias] or []) + break + measurements[key] = [asdict(s) for s in rows] + + provenance: dict[str, Any] = { + "origin": origin, + "created_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"), + } + if extra_meta: + for k, v in extra_meta.items(): + if k == "samples": + continue + if k in ("stitch_resolution", "dis_mode", "hhd_downsample", "config_ref"): + if k not in pipeline or pipeline[k] is None: + pipeline[k] = v + elif k in ("sensor_type", "sn", "hardware_rev", "gel_id"): + if k not in identity or not identity[k]: + identity[k] = v + else: + provenance[k] = v + + cal = Calibration( + schema_version=CURRENT_SCHEMA, + unit="N", + origin=origin, + identity=identity, + pipeline=pipeline, + provenance=provenance, + channels=channels, + measurements=measurements, + extras={}, + source_path=None, + ) + for w in warnings_list: + warnings.warn(f"calibration build: {w}", stacklevel=2) + return cal + + +def write( + cal: Calibration, + path: str | Path, + *, + overwrite: bool = False, +) -> Path: + """Write calibration JSON. Runs check() first; raises on errors.""" + result = check(cal) + if not result.ok: + raise CalibrationError("; ".join(result.errors)) + path = Path(path) + if path.exists() and not overwrite: + raise FileExistsError(f"calibration file exists: {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 + path.write_text( + json.dumps(doc, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + return path.resolve() + + +def write_user( + cal: Calibration, + *, + bundle_dir: str | Path, + sn: str, + overwrite: bool = True, +) -> Path: + """Write a per-SN user calibration under ``bundle_dir/calibrations/.json``.""" + sn_clean = str(sn or "").strip() + if not sn_clean: + raise CalibrationError("write_user requires a non-empty sn") + dest = Path(bundle_dir) / "calibrations" / f"{sn_clean}.json" + return write(cal, dest, overwrite=overwrite) diff --git a/src/orisys/configs.py b/src/orisys/configs.py index 27bed95..d3f43d9 100644 --- a/src/orisys/configs.py +++ b/src/orisys/configs.py @@ -1,4 +1,5 @@ import json +import shutil from contextlib import ExitStack from dataclasses import dataclass from importlib.resources import as_file, files @@ -166,6 +167,85 @@ def load_force_calibration(name: str) -> dict | None: return _load_json(bundle.force_calibration_json) +_BUNDLE_JSON_FILES = ("sensor.json", "force_calibration.json", "manifest.json") + + +def export_sensor_bundle( + name: str, + dest_dir: str | Path, + *, + include_assets: bool = False, + overwrite: bool = True, +) -> Path: + """Copy one resolved ``sensors//`` bundle into ``dest_dir//``. + + By default only JSON metadata is copied (``sensor.json``, and when present + ``force_calibration.json`` / ``manifest.json``). Pass ``include_assets=True`` + to copy the full tree (including ``assets/``) for packaging finger meshes. + """ + bundle = resolve_sensor_bundle(name) + if bundle is None: + available = ", ".join(list_sensor_bundles()) or "none" + raise FileNotFoundError( + f"Sensor bundle not found: {name!r}. Available: {available}" + ) + + dest_root = Path(dest_dir).expanduser().resolve() + dest = dest_root / bundle.name + if dest.exists(): + if not overwrite: + raise FileExistsError(f"Destination already exists: {dest}") + if dest.is_dir(): + shutil.rmtree(dest) + else: + dest.unlink() + + if include_assets: + shutil.copytree(bundle.root, dest) + return dest + + dest.mkdir(parents=True, exist_ok=True) + for filename in _BUNDLE_JSON_FILES: + src = bundle.root / filename + if src.is_file(): + shutil.copy2(src, dest / filename) + if not (dest / "sensor.json").is_file(): + raise FileNotFoundError(f"sensor.json missing after export from {bundle.root}") + return dest + + +def export_sensor_bundles( + dest_dir: str | Path, + *, + names: list[str] | tuple[str, ...] | None = None, + include_assets: bool = False, + overwrite: bool = True, +) -> list[Path]: + """Export several sensor bundles into ``dest_dir``. + + When ``names`` is omitted, exports every name in ``PRODUCT_BUNDLES`` that + currently resolves via :func:`resolve_sensor_bundle`. + """ + if names is None: + selected = [name for name in PRODUCT_BUNDLES if resolve_sensor_bundle(name) is not None] + else: + selected = [str(name) for name in names] + if not selected: + raise FileNotFoundError("No sensor bundles selected for export") + + exported: list[Path] = [] + for name in selected: + exported.append( + export_sensor_bundle( + name, + dest_dir, + include_assets=include_assets, + overwrite=overwrite, + ) + ) + return exported + + def load_config(config_name_or_path): """ Load sensor configuration from a JSON file path or config name. diff --git a/src/orisys/export_sensors.py b/src/orisys/export_sensors.py new file mode 100644 index 0000000..251255a --- /dev/null +++ b/src/orisys/export_sensors.py @@ -0,0 +1,70 @@ +"""CLI: export packaged sensor bundles to a destination folder. + +Examples:: + + python -m orisys.export_sensors --dest ./out/sensors --names box,mini + python -m orisys.export_sensors --dest ./out/sensors --include-assets +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from .configs import export_sensor_bundles, list_sensor_bundles + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Export orisys sensors// bundles to a destination directory" + ) + parser.add_argument( + "--dest", + required=True, + type=Path, + help="Destination directory (creates // for each bundle)", + ) + parser.add_argument( + "--names", + default="", + help="Comma-separated bundle names (default: all resolvable product bundles)", + ) + parser.add_argument( + "--include-assets", + action="store_true", + help="Copy full bundle trees including assets/", + ) + parser.add_argument( + "--no-overwrite", + action="store_true", + help="Fail if a destination bundle folder already exists", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(argv) + names = [part.strip() for part in str(args.names).split(",") if part.strip()] or None + try: + exported = export_sensor_bundles( + args.dest, + names=names, + include_assets=bool(args.include_assets), + overwrite=not bool(args.no_overwrite), + ) + except (FileNotFoundError, FileExistsError) as exc: + print(f"error: {exc}", file=sys.stderr) + available = list_sensor_bundles() + if available: + print(f"available bundles: {', '.join(available)}", file=sys.stderr) + return 1 + + for path in exported: + print(f"exported {path}") + print(f"exported {len(exported)} bundle(s) to {Path(args.dest).resolve()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/orisys/sensor.py b/src/orisys/sensor.py index 5632c0a..7755092 100644 --- a/src/orisys/sensor.py +++ b/src/orisys/sensor.py @@ -19,6 +19,7 @@ from typing import Optional, Tuple from .Stitcher import Stitcher from .tracker import Tracker from .hhd import nHHD +from . import calibration as cal_mod from . import configs from .get_depth import Contact3D from .backend_availability import (check_cupy_available, check_opencl_available) @@ -26,8 +27,6 @@ from .frame_processors import create_frame_processor from .signal_processing import ( DEFAULT_SAMPLE_RATE_HZ, LowpassFilter, - calibrate_normal_force, - calibrate_shear_components, ) from .capture import ( CaptureConfig, @@ -87,7 +86,7 @@ class Sensor: isstitch: Enable stitching mode for multi-tile sensor (default: False) verbose: Print initialization logs (default: True) config_name: Configuration name (default: "prototype") - cal_path: Path to calibration file (default: None) + cal_path: Explicit force_calibration.json path (overrides bundle default) override: Skip auto-detection, use provided box (default: False) box: Manual bounding boxes when override=True (default: None) backend: Poisson backend (default: "auto") @@ -152,7 +151,9 @@ class Sensor: self.n_boxes = None self.fnormal = self.fshearx = self.fsheary = self.ftor = 0.0 self._force_raw = (0.0, 0.0, 0.0) - self._force_cal_sensor_name = self._resolve_force_calibration_sensor_name() + self.calibration: Optional[cal_mod.Calibration] = None + self._force_cal_sensor_name: Optional[str] = None + self._init_force_calibration() self._lowpass_enabled = False self._lowpass_cutoff_hz = 0.0 self._lowpass_sample_rate_hz = float(DEFAULT_SAMPLE_RATE_HZ) @@ -164,7 +165,9 @@ class Sensor: n_channels=3, ) if self.verbose: - if self._force_cal_sensor_name: + if self.calibration is not None and self.calibration.source_path is not None: + print(f"Force calibration: {self.calibration.source_path}") + elif self._force_cal_sensor_name: print( f"Force calibration: sensors/{self._force_cal_sensor_name}/force_calibration.json" ) @@ -802,17 +805,11 @@ class Sensor: self._update_hhd_mask_cache() return self - def _resolve_force_calibration_sensor_name(self) -> Optional[str]: - """Return sensors/ when force_calibration.json exists; else None. - - Lookup order: - 1. ``config_name`` stem / product alias (e.g. ``box``) - 2. ``sensors//...`` path segment in ``config_name`` - Missing JSON means ``FNORMAL/FSHEAR*`` stay raw (no hardcoded fallback). - """ + def _bundle_name_candidates(self) -> list[str]: + """Product bundle names implied by ``config_name``.""" text = str(self.config_name or "").strip().replace("\\", "/") if not text: - return None + return [] candidates: list[str] = [] stem = Path(text).stem if text else "" @@ -828,15 +825,55 @@ class Sensor: candidates.append(name) seen: set[str] = set() + ordered: list[str] = [] for candidate in candidates: alias = configs.CONFIG_ALIASES.get(candidate, candidate) for name in (alias, candidate): if name in seen: continue seen.add(name) - if configs.load_force_calibration(name) is not None: - return name - return None + ordered.append(name) + return ordered + + def _init_force_calibration(self) -> None: + """Load force calibration from ``cal_path`` or the product bundle.""" + self.calibration = None + self._force_cal_sensor_name = None + + if self.cal_path is not None and str(self.cal_path).strip(): + # Explicit path: missing/invalid raises (no silent fallback). + self.calibration = cal_mod.load(self.cal_path, strict=True) + return + + for name in self._bundle_name_candidates(): + cal = cal_mod.load(sensor_type=name) + if cal is not None: + self.calibration = cal + self._force_cal_sensor_name = name + return + + @property + def force_calibrated(self) -> bool: + """True when at least one channel curve is loaded.""" + return self.calibration is not None and bool(self.calibration.channels) + + @property + def force_unit(self) -> str: + return "N" if self.force_calibrated else "raw" + + def set_calibration( + self, + cal: "cal_mod.Calibration | str | Path | None", + ) -> None: + """Attach or replace the active force calibration (live swap after save).""" + if cal is None: + self.calibration = None + self._force_cal_sensor_name = None + return + if isinstance(cal, cal_mod.Calibration): + self.calibration = cal + return + self.calibration = cal_mod.load(cal, strict=True) def _apply_force_filter(self): raw = np.asarray(self._force_raw, dtype=np.float64) @@ -846,12 +883,11 @@ class Sensor: else: fn, fx, fy = float(raw[0]), float(raw[1]), float(raw[2]) - # Apply sensors//force_calibration.json when present; otherwise keep raw. - name = self._force_cal_sensor_name - if name is not None: - fn = float(calibrate_normal_force(fn, sensor_name=name)) - fx, fy = calibrate_shear_components(fx, fy, sensor_name=name) + # Apply loaded force_calibration.json when present; otherwise keep raw. + if self.calibration is not None: + fn, fx, fy = self.calibration.convert(fn, fx, fy) fx, fy = float(fx), float(fy) + fn = float(fn) self.fnormal, self.fshearx, self.fsheary = fn, fx, fy diff --git a/src/orisys/signal_processing.py b/src/orisys/signal_processing.py index 7d00345..3df46d9 100644 --- a/src/orisys/signal_processing.py +++ b/src/orisys/signal_processing.py @@ -3,10 +3,14 @@ Batch ``lowpass_filter`` is the zero-phase Butterworth used by example charts. ``LowpassFilter`` is a causal, stateful filter for per-frame ``Sensor`` outputs. -Force calibration resolution order (when register support arrives): - 1. Sensor register / EEPROM calibration blob - 2. ``sensors//force_calibration.json`` - 3. Hardcoded linear defaults below +Force calibration is owned by ``orisys.calibration``. These helpers remain as +convenience wrappers: + + 1. Bundle JSON via ``calibration.load(sensor_type=...)`` + 2. Hardcoded linear defaults for normal force when no file is present + 3. Shear helpers return raw when no curve is available + +Prefer ``Sensor.calibration`` / ``read_info(FNORMAL, …)`` in new code. """ from __future__ import annotations @@ -26,20 +30,15 @@ DEFAULT_LOWPASS_ORDER = 2 def _parse_calibration_point(point) -> tuple[float, float]: - if isinstance(point, dict): - raw = point.get("fnormal", point.get("raw", point.get("x"))) - force_n = point.get("force_n", point.get("n", point.get("y"))) - if raw is None or force_n is None: - raise ValueError(f"invalid calibration point: {point!r}") - return float(raw), float(force_n) - if isinstance(point, (list, tuple)) and len(point) >= 2: - return float(point[0]), float(point[1]) - raise ValueError(f"invalid calibration point: {point!r}") + """Legacy helper; prefer ``orisys.calibration``.""" + from .calibration import _parse_point + + return _parse_point(point) @dataclass(frozen=True) class ForceChannelCalibration: - """One force channel: linear coeffs and/or piecewise-linear breakpoints.""" + """Compatibility wrapper around ``calibration.ChannelCurve`` convert math.""" method: str = "linear" slope: float | None = None @@ -63,75 +62,40 @@ class ForceChannelCalibration: unit: str = "N", default_method: str = "piecewise_linear", ) -> ForceChannelCalibration | None: - if not isinstance(block, dict): - return None + from .calibration import _channel_from_block - method = str(block.get("method", block.get("model", default_method))).lower() - if method in ("linear", "affine"): - slope = block.get("slope") - offset = block.get("offset") - if slope is None or offset is None: - return None - return cls.linear(float(slope), float(offset), unit=str(block.get("unit", unit))) - - if method != "piecewise_linear": + curve = _channel_from_block( + "fn", block, unit=unit, default_method=default_method + ) + if curve is None: return None - - raw_points = block.get("points", []) - if not isinstance(raw_points, (list, tuple)) or len(raw_points) < 2: - return None - try: - points = tuple(sorted(_parse_calibration_point(p) for p in raw_points)) - except (TypeError, ValueError): - return None - if len(points) < 2: - return None - return cls( - method="piecewise_linear", - points=points, - extrapolate=bool(block.get("extrapolate", True)), - clamp_output_min=block.get("clamp_output_min", 0.0), - input_mode=str(block.get("input", "magnitude")).lower(), - signed=bool(block.get("signed", False)), - unit=str(block.get("unit", unit)), + method=curve.method, + slope=curve.slope, + offset=curve.offset, + points=curve.points, + extrapolate=curve.extrapolate, + clamp_output_min=curve.clamp_output_min, + input_mode=curve.input_mode, + signed=curve.signed, + unit=curve.unit, ) def convert(self, raw: float) -> float: - raw = float(raw) - if self.method == "linear": - slope = NORMAL_FORCE_CAL_SLOPE if self.slope is None else float(self.slope) - offset = NORMAL_FORCE_CAL_OFFSET if self.offset is None else float(self.offset) - return slope * raw + offset + from .calibration import ChannelCurve - points = self.points - if len(points) < 2: - return raw - - if raw <= points[0][0]: - segment = (points[0], points[1]) - if not self.extrapolate: - return self._clamp(points[0][1]) - elif raw >= points[-1][0]: - segment = (points[-2], points[-1]) - if not self.extrapolate: - return self._clamp(points[-1][1]) - else: - segment = None - for left, right in zip(points, points[1:]): - if left[0] <= raw <= right[0]: - segment = (left, right) - break - if segment is None: - return self._clamp(points[-1][1]) - - (raw0, n0), (raw1, n1) = segment - if raw1 == raw0: - value = n0 - else: - ratio = (raw - raw0) / (raw1 - raw0) - value = n0 + ratio * (n1 - n0) - return self._clamp(value) + return ChannelCurve( + id="fn", + method=self.method, + points=self.points, + slope=self.slope if self.slope is not None else NORMAL_FORCE_CAL_SLOPE, + offset=self.offset if self.offset is not None else NORMAL_FORCE_CAL_OFFSET, + signed=self.signed, + extrapolate=self.extrapolate, + clamp_output_min=self.clamp_output_min, + input_mode=self.input_mode, + unit=self.unit, + ).convert(raw) def convert_component(self, raw: float) -> float: if not self.signed: @@ -140,11 +104,6 @@ class ForceChannelCalibration: sign = -1.0 if raw < 0.0 else 1.0 return sign * self.convert(abs(raw)) - def _clamp(self, value: float) -> float: - if self.clamp_output_min is not None: - return max(float(self.clamp_output_min), float(value)) - return float(value) - _DEFAULT_NORMAL = ForceChannelCalibration.linear( NORMAL_FORCE_CAL_SLOPE, @@ -152,57 +111,41 @@ _DEFAULT_NORMAL = ForceChannelCalibration.linear( ) -def _load_force_calibration_data(sensor_name: str | None) -> dict | None: +def _load_calibration(sensor_name: str | None): if not sensor_name: return None try: - from .configs import load_force_calibration + from .calibration import load - data = load_force_calibration(sensor_name) + return load(sensor_type=sensor_name) except Exception: return None - return data if isinstance(data, dict) else None - - -def _channel_from_data(data: dict | None, channel: str) -> ForceChannelCalibration | None: - if not isinstance(data, dict): - return None - - unit = str(data.get("unit", "N")) - channels = data.get("channels") - if isinstance(channels, dict): - block = channels.get(channel) - cal = ForceChannelCalibration.from_channel_dict(block, unit=unit) - if cal is not None: - return cal - - # Legacy single-channel file: {"normal_force": {"model": "linear", ...}} - if channel == "normal": - block = data.get("normal_force", data) - cal = ForceChannelCalibration.from_channel_dict( - block, unit=unit, default_method="linear" - ) - if cal is not None: - return cal - if isinstance(block, dict): - slope = block.get("slope") - offset = block.get("offset") - if slope is not None and offset is not None: - return ForceChannelCalibration.linear( - float(slope), - float(offset), - unit=str(block.get("unit", unit)), - ) - return None def _normal_calibration(sensor_name: str | None = None) -> ForceChannelCalibration: - cal = _channel_from_data(_load_force_calibration_data(sensor_name), "normal") - return cal if cal is not None else _DEFAULT_NORMAL + cal = _load_calibration(sensor_name) + if cal is not None: + curve = cal.get("fn") + if curve is not None: + return ForceChannelCalibration( + method=curve.method, + slope=curve.slope, + offset=curve.offset, + points=curve.points, + extrapolate=curve.extrapolate, + clamp_output_min=curve.clamp_output_min, + input_mode=curve.input_mode, + signed=curve.signed, + unit=curve.unit, + ) + return _DEFAULT_NORMAL -def _shear_calibration(sensor_name: str | None = None) -> ForceChannelCalibration | None: - return _channel_from_data(_load_force_calibration_data(sensor_name), "shear") +def _shear_curve(sensor_name: str | None, channel: str): + cal = _load_calibration(sensor_name) + if cal is None: + return None + return cal.get(channel) def _load_normal_force_coeffs(sensor_name: str | None = None) -> tuple[float, float]: @@ -225,12 +168,20 @@ def calibrate_normal_force(raw_value: float, *, sensor_name: str | None = None) def calibrate_shear_force(raw_value: float, *, sensor_name: str | None = None) -> float: """Convert a raw shear magnitude to Newtons using the shear channel curve. - Returns ``float(raw_value)`` unchanged when no shear calibration is available. + Prefers the average of FX/FY magnitude maps when present. + Returns ``float(raw_value)`` when unavailable. """ - cal = _shear_calibration(sensor_name) - if cal is None: - return float(raw_value) - return cal.convert(raw_value) + cal_x = _shear_curve(sensor_name, "fx") + cal_y = _shear_curve(sensor_name, "fy") + if cal_x is not None and cal_y is not None: + return 0.5 * ( + cal_x.convert(abs(float(raw_value))) + cal_y.convert(abs(float(raw_value))) + ) + if cal_x is not None: + return cal_x.convert(abs(float(raw_value))) + if cal_y is not None: + return cal_y.convert(abs(float(raw_value))) + return float(raw_value) def calibrate_shear_components( @@ -239,27 +190,11 @@ def calibrate_shear_components( *, sensor_name: str | None = None, ) -> tuple[float, float]: - """Convert raw shear components to Newtons. - - When the shear channel uses ``input: components`` and ``signed: true``, each - axis is calibrated on absolute magnitude then re-signed. Otherwise the - magnitude is calibrated and components are scaled proportionally. - """ - sx = float(shear_x) - sy = float(shear_y) - cal = _shear_calibration(sensor_name) + """Convert raw shear components to Newtons via ``calibration.load`` curves.""" + cal = _load_calibration(sensor_name) if cal is None: - return sx, sy - - if cal.input_mode == "components": - return cal.convert_component(sx), cal.convert_component(sy) - - magnitude = (sx * sx + sy * sy) ** 0.5 - if magnitude <= 0.0: - return 0.0, 0.0 - calibrated = cal.convert(magnitude) - scale = calibrated / magnitude - return sx * scale, sy * scale + return float(shear_x), float(shear_y) + return cal.convert(0.0, float(shear_x), float(shear_y))[1:] def _nyquist_hz(sample_rate_hz: float) -> float: diff --git a/tests/test_calibration.py b/tests/test_calibration.py new file mode 100644 index 0000000..0c8c93d --- /dev/null +++ b/tests/test_calibration.py @@ -0,0 +1,243 @@ +"""Tests for orisys.calibration load/parse/write and Sensor apply.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from orisys import Sensor +from orisys.calibration import ( + CalSample, + CalibrationError, + build, + check, + load, + parse, + write, +) + + +def _box_cal_path() -> Path: + # Editable checkout: repo_root/sensors/box/force_calibration.json + here = Path(__file__).resolve() + candidate = here.parents[1] / "sensors" / "box" / "force_calibration.json" + assert candidate.is_file(), f"missing shipped cal: {candidate}" + return candidate + + +def test_parse_box_v1_piecewise(): + cal = load(sensor_type="box") + assert cal is not None + assert cal.get("FN") is not None + assert cal.get("FN").convert(10000.0) == pytest.approx(0.377) + assert cal.get("FX").convert_component(36000.0) == pytest.approx(1.19) + assert cal.get("FX").convert_component(-36000.0) == pytest.approx(-1.19) + assert cal.get("FY").convert_component(36000.0) == pytest.approx(1.19) + + +def test_parse_v2_tmp_file(tmp_path: Path): + doc = { + "schema_version": 2, + "unit": "N", + "channels": { + "normal": { + "method": "piecewise_linear", + "points": [[0.0, 0.0], [1000.0, 1.0]], + "extrapolate": True, + "clamp_output_min": 0.0, + }, + "shear_x": { + "method": "piecewise_linear", + "signed": True, + "points": [[0.0, 0.0], [500.0, 2.0]], + "extrapolate": True, + "clamp_output_min": 0.0, + }, + "shear_y": { + "method": "piecewise_linear", + "signed": True, + "points": [[0.0, 0.0], [500.0, 3.0]], + "extrapolate": True, + "clamp_output_min": 0.0, + }, + }, + "meta": {"sensor_type": "box", "sn": "TEST-001"}, + } + path = tmp_path / "force_calibration_v2.json" + path.write_text(json.dumps(doc), encoding="utf-8") + cal = load(path) + assert cal is not None + assert check(cal).ok + assert cal.convert(1000.0, 500.0, -500.0) == pytest.approx((1.0, 2.0, -3.0)) + + +def test_build_write_load_roundtrip(tmp_path: Path): + samples = { + "fn": [ + CalSample(weight_n=0.0, raw_mean=0.0, n=10), + CalSample(weight_n=1.0, raw_mean=1000.0, n=10), + ], + "fx": [ + CalSample(weight_n=0.0, raw_mean=0.0, n=10), + CalSample(weight_n=2.0, raw_mean=500.0, n=10), + ], + "fy": [ + CalSample(weight_n=0.0, raw_mean=0.0, n=10), + CalSample(weight_n=3.0, raw_mean=500.0, n=10), + ], + } + cal = build( + samples, + identity={"sensor_type": "box", "sn": "RT-1"}, + pipeline={"dis_mode": 1}, + origin="user", + ) + path = tmp_path / "out.json" + write(cal, path, overwrite=True) + loaded = load(path) + assert loaded is not None + assert loaded.convert(1000.0, 500.0, -500.0) == pytest.approx((1.0, 2.0, -3.0)) + + +def test_check_rejects_bad_schema_and_short_points(): + bad_schema = {"schema_version": 99, "channels": {"normal": {"method": "linear", "slope": 1, "offset": 0}}} + result = check(bad_schema) + assert not result.ok + assert any("schema_version" in e for e in result.errors) + + short = { + "schema_version": 2, + "channels": { + "normal": { + "method": "piecewise_linear", + "points": [[0.0, 0.0]], + } + }, + } + result2 = check(short) + assert not result2.ok + + +def test_sensor_cal_path_v2_applies_to_read_info(tmp_path: Path): + doc = { + "schema_version": 2, + "unit": "N", + "channels": { + "normal": { + "method": "piecewise_linear", + "points": [[0.0, 0.0], [100.0, 5.0]], + "extrapolate": True, + "clamp_output_min": 0.0, + }, + "shear_x": { + "method": "piecewise_linear", + "signed": True, + "points": [[0.0, 0.0], [100.0, 7.0]], + "extrapolate": True, + "clamp_output_min": 0.0, + }, + "shear_y": { + "method": "piecewise_linear", + "signed": True, + "points": [[0.0, 0.0], [100.0, 9.0]], + "extrapolate": True, + "clamp_output_min": 0.0, + }, + }, + } + path = tmp_path / "v2.json" + path.write_text(json.dumps(doc), encoding="utf-8") + sensor = Sensor( + "external", + config_name="box", + cal_path=str(path), + verbose=False, + backend="cpu", + ) + try: + assert sensor.force_calibrated + assert sensor.force_unit == "N" + sensor._force_raw = (100.0, 100.0, -100.0) + sensor._apply_force_filter() + fn, fx, fy = sensor.read_info( + sensor.info.FNORMAL, + sensor.info.FSHEARX, + sensor.info.FSHEARY, + ) + fn_raw, fx_raw, fy_raw = sensor.read_info( + sensor.info.FNORMAL_RAW, + sensor.info.FSHEARX_RAW, + sensor.info.FSHEARY_RAW, + ) + assert (fn_raw, fx_raw, fy_raw) == pytest.approx((100.0, 100.0, -100.0)) + assert (fn, fx, fy) == pytest.approx((5.0, 7.0, -9.0)) + finally: + sensor.disconnect() + + +def test_set_calibration_swaps_curve(tmp_path: Path): + path_a = tmp_path / "a.json" + path_b = tmp_path / "b.json" + path_a.write_text( + json.dumps( + { + "schema_version": 2, + "channels": { + "normal": { + "method": "piecewise_linear", + "points": [[0.0, 0.0], [10.0, 1.0]], + } + }, + } + ), + encoding="utf-8", + ) + path_b.write_text( + json.dumps( + { + "schema_version": 2, + "channels": { + "normal": { + "method": "piecewise_linear", + "points": [[0.0, 0.0], [10.0, 2.0]], + } + }, + } + ), + encoding="utf-8", + ) + sensor = Sensor( + "external", + config_name="box", + cal_path=str(path_a), + verbose=False, + backend="cpu", + ) + try: + sensor._force_raw = (10.0, 0.0, 0.0) + sensor._apply_force_filter() + assert sensor.fnormal == pytest.approx(1.0) + sensor.set_calibration(path_b) + sensor._apply_force_filter() + assert sensor.fnormal == pytest.approx(2.0) + finally: + sensor.disconnect() + + +def test_missing_cal_path_raises(): + with pytest.raises(CalibrationError): + Sensor( + "external", + config_name="box", + cal_path="D:/no/such/force_calibration.json", + verbose=False, + backend="cpu", + ) + + +def test_parse_shipped_box_file_directly(): + cal = parse(json.loads(_box_cal_path().read_text(encoding="utf-8")), source_path=_box_cal_path()) + assert cal.schema_version == 1 + assert cal.convert(10000.0, 36000.0, -36000.0) == pytest.approx((0.377, 1.19, -1.19)) diff --git a/tests/test_export_sensor_bundle.py b/tests/test_export_sensor_bundle.py new file mode 100644 index 0000000..80e9aa6 --- /dev/null +++ b/tests/test_export_sensor_bundle.py @@ -0,0 +1,48 @@ +"""Tests for sensor-bundle export helpers.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from orisys.configs import ( + export_sensor_bundle, + export_sensor_bundles, + list_sensor_bundles, + resolve_sensor_bundle, +) + + +def test_export_sensor_bundle_json_only(tmp_path: Path) -> None: + assert resolve_sensor_bundle("box") is not None + + dest = export_sensor_bundle("box", tmp_path, include_assets=False) + assert dest == tmp_path / "box" + assert (dest / "sensor.json").is_file() + assert (dest / "force_calibration.json").is_file() + assert not (dest / "assets").exists() + + +def test_export_sensor_bundles_default_names(tmp_path: Path) -> None: + available = list_sensor_bundles() + assert "box" in available + + exported = export_sensor_bundles(tmp_path, include_assets=False) + names = {path.name for path in exported} + assert "box" in names + assert "mini" in names + for path in exported: + assert (path / "sensor.json").is_file() + assert not (path / "assets").exists() + + +def test_export_sensor_bundle_no_overwrite(tmp_path: Path) -> None: + export_sensor_bundle("box", tmp_path, overwrite=True) + with pytest.raises(FileExistsError): + export_sensor_bundle("box", tmp_path, overwrite=False) + + +def test_export_sensor_bundle_missing_name(tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + export_sensor_bundle("not-a-real-sensor", tmp_path) diff --git a/tests/test_signal_processing.py b/tests/test_signal_processing.py index cdc0fb3..b2e3a59 100644 --- a/tests/test_signal_processing.py +++ b/tests/test_signal_processing.py @@ -224,12 +224,13 @@ def test_box_read_info_applies_force_calibration_json(): def test_read_info_force_stays_raw_without_calibration_json(monkeypatch): monkeypatch.setattr( - "orisys.sensor.configs.load_force_calibration", - lambda _name: None, + "orisys.calibration.load", + lambda *args, **kwargs: None, ) sensor = Sensor("external", config_name="box", verbose=False, backend="cpu") try: assert sensor._force_cal_sensor_name is None + assert sensor.calibration is None sensor._force_raw = (111.0, -22.0, 33.0) sensor._apply_force_filter() fn, fx, fy = sensor.read_info(