added calibration data handling functions
This commit is contained in:
+21
-1
@@ -17,7 +17,7 @@ description: Orisys SDK Python API 中文参考
|
|||||||
|
|
||||||
* **vid_src** (`int | str`, 必填): 传感器 ID、相机编号或视频路径。默认为 `0`。
|
* **vid_src** (`int | str`, 必填): 传感器 ID、相机编号或视频路径。默认为 `0`。
|
||||||
* **config_name** (`str`,可选): 传感器配置文件名称或 `json` 文件路径,用于加载对应的参数配置;例如 `box` 或 `./config/box.json`。
|
* **config_name** (`str`,可选): 传感器配置文件名称或 `json` 文件路径,用于加载对应的参数配置;例如 `box` 或 `./config/box.json`。
|
||||||
* **cal_path** (`str`,可选,已弃用): 该参数仅为兼容旧代码保留,当前版本中不再使用,传入后也会被忽略。
|
* **cal_path** (`str`,可选): 显式的 `force_calibration.json` 路径;传入后优先于 `sensors/<type>/force_calibration.json`。不传时按 `config_name` 自动加载产品包中的标定文件。
|
||||||
* **backend** (`str`,可选): 光流场解耦计算后端,默认为 `auto`,可选`cupy`,`opencl`,`cpu`。
|
* **backend** (`str`,可选): 光流场解耦计算后端,默认为 `auto`,可选`cupy`,`opencl`,`cpu`。
|
||||||
* **verbose** (`bool`,可选): 是否输出初始化及运行过程中的详细日志信息,默认为 `False`。
|
* **verbose** (`bool`,可选): 是否输出初始化及运行过程中的详细日志信息,默认为 `False`。
|
||||||
|
|
||||||
@@ -577,6 +577,26 @@ finally:
|
|||||||
|
|
||||||
* 导出内置的`config_name`配置参数字典至`path`
|
* 导出内置的`config_name`配置参数字典至`path`
|
||||||
|
|
||||||
|
### `export_sensor_bundle` / `export_sensor_bundles` 方法
|
||||||
|
|
||||||
|
#### 描述
|
||||||
|
将已安装的 `sensors/<name>/` 传感器包复制到目标目录,供下游应用(如桌面 App)做本地可编辑配置,无需硬编码 wheel 路径。解析顺序与 `resolve_sensor_bundle` 相同(含 `importlib.resources` 打包布局)。
|
||||||
|
|
||||||
|
#### 输入参数
|
||||||
|
* **name** / **names**: 单个名字,或名字列表;`export_sensor_bundles` 省略 `names` 时导出所有可解析的产品包(`box` / `mini` / `finger`)
|
||||||
|
* **dest_dir**: 目标根目录;实际写入 `dest_dir/<name>/`
|
||||||
|
* **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)
|
## 常见问题解答 (FAQ)
|
||||||
### Q1:为什么必须先调用`get_img()`,再调用`compute_deformation()`?
|
### Q1:为什么必须先调用`get_img()`,再调用`compute_deformation()`?
|
||||||
A:`compute_deformation()`依赖最近一次获取并拼接的图像数据进行形变与力计算。
|
A:`compute_deformation()`依赖最近一次获取并拼接的图像数据进行形变与力计算。
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ def main():
|
|||||||
parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
|
parser.add_argument("--video","-v", type=str, default="0", help="视频源:摄像头编号或视频文件路径")
|
||||||
parser.add_argument("--config","-c", type=str, default="box", help="配置文件名称或路径")
|
parser.add_argument("--config","-c", type=str, default="box", help="配置文件名称或路径")
|
||||||
parser.add_argument("--verbose","-verbose", type=bool, default=True, 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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# 步骤 1:创建传感器对象(必需)
|
# 步骤 1:创建传感器对象(必需)
|
||||||
@@ -29,9 +30,9 @@ def main():
|
|||||||
|
|
||||||
# 输入既可以是摄像头编号,也可以是视频文件路径
|
# 输入既可以是摄像头编号,也可以是视频文件路径
|
||||||
if args.video.isdigit():
|
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:
|
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' 重置追踪器。")
|
print("\n按键说明:'q' 退出程序,'r' 重置追踪器。")
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
"dis_preset": "medium",
|
"dis_preset": "medium",
|
||||||
"dis_mode": 1,
|
"dis_mode": 1,
|
||||||
"algorithm_list": ["raft", "dis"],
|
"algorithm_list": ["raft", "dis"],
|
||||||
"hhd_downsample": 1
|
"hhd_downsample": 3
|
||||||
},
|
},
|
||||||
"contact": {
|
"contact": {
|
||||||
"min_contour_area": 3000,
|
"min_contour_area": 3000,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"loop_closure_interval": 1,
|
"loop_closure_interval": 1,
|
||||||
"algorithm": "dis",
|
"algorithm": "dis",
|
||||||
"dis_preset": "medium",
|
"dis_preset": "medium",
|
||||||
"dis_mode": 1,
|
"dis_mode": 3,
|
||||||
"algorithm_list": ["raft", "dis"],
|
"algorithm_list": ["raft", "dis"],
|
||||||
"hhd_downsample": 3
|
"hhd_downsample": 3
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ __author__ = "Orisys"
|
|||||||
from .sensor import Sensor
|
from .sensor import Sensor
|
||||||
from . import util
|
from . import util
|
||||||
from . import configs
|
from . import configs
|
||||||
|
from . import calibration
|
||||||
from . import finger
|
from . import finger
|
||||||
from . import signal_processing
|
from . import signal_processing
|
||||||
from . import capture
|
from . import capture
|
||||||
@@ -22,6 +23,8 @@ CameraSensor = Sensor
|
|||||||
# Re-export list_configs as list_config for convenience
|
# Re-export list_configs as list_config for convenience
|
||||||
list_config = configs.list_config
|
list_config = configs.list_config
|
||||||
export_config = configs.export_config
|
export_config = configs.export_config
|
||||||
|
export_sensor_bundle = configs.export_sensor_bundle
|
||||||
|
export_sensor_bundles = configs.export_sensor_bundles
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# class
|
# class
|
||||||
@@ -41,9 +44,14 @@ __all__ = [
|
|||||||
# capture helpers
|
# capture helpers
|
||||||
'capture',
|
'capture',
|
||||||
|
|
||||||
|
# force calibration
|
||||||
|
'calibration',
|
||||||
|
|
||||||
# configs utilities functions
|
# configs utilities functions
|
||||||
'list_config',
|
'list_config',
|
||||||
'export_config',
|
'export_config',
|
||||||
|
'export_sensor_bundle',
|
||||||
|
'export_sensor_bundles',
|
||||||
|
|
||||||
# test
|
# test
|
||||||
'FFCFrameProcessor'
|
'FFCFrameProcessor'
|
||||||
|
|||||||
@@ -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/<sensor_type>/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/<SN>.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)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import shutil
|
||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from importlib.resources import as_file, files
|
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)
|
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/<name>/`` bundle into ``dest_dir/<name>/``.
|
||||||
|
|
||||||
|
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):
|
def load_config(config_name_or_path):
|
||||||
"""
|
"""
|
||||||
Load sensor configuration from a JSON file path or config name.
|
Load sensor configuration from a JSON file path or config name.
|
||||||
|
|||||||
@@ -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/<name>/ bundles to a destination directory"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dest",
|
||||||
|
required=True,
|
||||||
|
type=Path,
|
||||||
|
help="Destination directory (creates <dest>/<name>/ 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())
|
||||||
+58
-22
@@ -19,6 +19,7 @@ from typing import Optional, Tuple
|
|||||||
from .Stitcher import Stitcher
|
from .Stitcher import Stitcher
|
||||||
from .tracker import Tracker
|
from .tracker import Tracker
|
||||||
from .hhd import nHHD
|
from .hhd import nHHD
|
||||||
|
from . import calibration as cal_mod
|
||||||
from . import configs
|
from . import configs
|
||||||
from .get_depth import Contact3D
|
from .get_depth import Contact3D
|
||||||
from .backend_availability import (check_cupy_available, check_opencl_available)
|
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 (
|
from .signal_processing import (
|
||||||
DEFAULT_SAMPLE_RATE_HZ,
|
DEFAULT_SAMPLE_RATE_HZ,
|
||||||
LowpassFilter,
|
LowpassFilter,
|
||||||
calibrate_normal_force,
|
|
||||||
calibrate_shear_components,
|
|
||||||
)
|
)
|
||||||
from .capture import (
|
from .capture import (
|
||||||
CaptureConfig,
|
CaptureConfig,
|
||||||
@@ -87,7 +86,7 @@ class Sensor:
|
|||||||
isstitch: Enable stitching mode for multi-tile sensor (default: False)
|
isstitch: Enable stitching mode for multi-tile sensor (default: False)
|
||||||
verbose: Print initialization logs (default: True)
|
verbose: Print initialization logs (default: True)
|
||||||
config_name: Configuration name (default: "prototype")
|
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)
|
override: Skip auto-detection, use provided box (default: False)
|
||||||
box: Manual bounding boxes when override=True (default: None)
|
box: Manual bounding boxes when override=True (default: None)
|
||||||
backend: Poisson backend (default: "auto")
|
backend: Poisson backend (default: "auto")
|
||||||
@@ -152,7 +151,9 @@ class Sensor:
|
|||||||
self.n_boxes = None
|
self.n_boxes = None
|
||||||
self.fnormal = self.fshearx = self.fsheary = self.ftor = 0.0
|
self.fnormal = self.fshearx = self.fsheary = self.ftor = 0.0
|
||||||
self._force_raw = (0.0, 0.0, 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_enabled = False
|
||||||
self._lowpass_cutoff_hz = 0.0
|
self._lowpass_cutoff_hz = 0.0
|
||||||
self._lowpass_sample_rate_hz = float(DEFAULT_SAMPLE_RATE_HZ)
|
self._lowpass_sample_rate_hz = float(DEFAULT_SAMPLE_RATE_HZ)
|
||||||
@@ -164,7 +165,9 @@ class Sensor:
|
|||||||
n_channels=3,
|
n_channels=3,
|
||||||
)
|
)
|
||||||
if self.verbose:
|
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(
|
print(
|
||||||
f"Force calibration: sensors/{self._force_cal_sensor_name}/force_calibration.json"
|
f"Force calibration: sensors/{self._force_cal_sensor_name}/force_calibration.json"
|
||||||
)
|
)
|
||||||
@@ -802,17 +805,11 @@ class Sensor:
|
|||||||
self._update_hhd_mask_cache()
|
self._update_hhd_mask_cache()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def _resolve_force_calibration_sensor_name(self) -> Optional[str]:
|
def _bundle_name_candidates(self) -> list[str]:
|
||||||
"""Return sensors/<name> when force_calibration.json exists; else None.
|
"""Product bundle names implied by ``config_name``."""
|
||||||
|
|
||||||
Lookup order:
|
|
||||||
1. ``config_name`` stem / product alias (e.g. ``box``)
|
|
||||||
2. ``sensors/<name>/...`` path segment in ``config_name``
|
|
||||||
Missing JSON means ``FNORMAL/FSHEAR*`` stay raw (no hardcoded fallback).
|
|
||||||
"""
|
|
||||||
text = str(self.config_name or "").strip().replace("\\", "/")
|
text = str(self.config_name or "").strip().replace("\\", "/")
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return []
|
||||||
|
|
||||||
candidates: list[str] = []
|
candidates: list[str] = []
|
||||||
stem = Path(text).stem if text else ""
|
stem = Path(text).stem if text else ""
|
||||||
@@ -828,15 +825,55 @@ class Sensor:
|
|||||||
candidates.append(name)
|
candidates.append(name)
|
||||||
|
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
|
ordered: list[str] = []
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
alias = configs.CONFIG_ALIASES.get(candidate, candidate)
|
alias = configs.CONFIG_ALIASES.get(candidate, candidate)
|
||||||
for name in (alias, candidate):
|
for name in (alias, candidate):
|
||||||
if name in seen:
|
if name in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(name)
|
seen.add(name)
|
||||||
if configs.load_force_calibration(name) is not None:
|
ordered.append(name)
|
||||||
return name
|
return ordered
|
||||||
return None
|
|
||||||
|
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):
|
def _apply_force_filter(self):
|
||||||
raw = np.asarray(self._force_raw, dtype=np.float64)
|
raw = np.asarray(self._force_raw, dtype=np.float64)
|
||||||
@@ -846,12 +883,11 @@ class Sensor:
|
|||||||
else:
|
else:
|
||||||
fn, fx, fy = float(raw[0]), float(raw[1]), float(raw[2])
|
fn, fx, fy = float(raw[0]), float(raw[1]), float(raw[2])
|
||||||
|
|
||||||
# Apply sensors/<name>/force_calibration.json when present; otherwise keep raw.
|
# Apply loaded force_calibration.json when present; otherwise keep raw.
|
||||||
name = self._force_cal_sensor_name
|
if self.calibration is not None:
|
||||||
if name is not None:
|
fn, fx, fy = self.calibration.convert(fn, fx, fy)
|
||||||
fn = float(calibrate_normal_force(fn, sensor_name=name))
|
|
||||||
fx, fy = calibrate_shear_components(fx, fy, sensor_name=name)
|
|
||||||
fx, fy = float(fx), float(fy)
|
fx, fy = float(fx), float(fy)
|
||||||
|
fn = float(fn)
|
||||||
|
|
||||||
self.fnormal, self.fshearx, self.fsheary = fn, fx, fy
|
self.fnormal, self.fshearx, self.fsheary = fn, fx, fy
|
||||||
|
|
||||||
|
|||||||
+80
-145
@@ -3,10 +3,14 @@
|
|||||||
Batch ``lowpass_filter`` is the zero-phase Butterworth used by example charts.
|
Batch ``lowpass_filter`` is the zero-phase Butterworth used by example charts.
|
||||||
``LowpassFilter`` is a causal, stateful filter for per-frame ``Sensor`` outputs.
|
``LowpassFilter`` is a causal, stateful filter for per-frame ``Sensor`` outputs.
|
||||||
|
|
||||||
Force calibration resolution order (when register support arrives):
|
Force calibration is owned by ``orisys.calibration``. These helpers remain as
|
||||||
1. Sensor register / EEPROM calibration blob
|
convenience wrappers:
|
||||||
2. ``sensors/<name>/force_calibration.json``
|
|
||||||
3. Hardcoded linear defaults below
|
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
|
from __future__ import annotations
|
||||||
@@ -26,20 +30,15 @@ DEFAULT_LOWPASS_ORDER = 2
|
|||||||
|
|
||||||
|
|
||||||
def _parse_calibration_point(point) -> tuple[float, float]:
|
def _parse_calibration_point(point) -> tuple[float, float]:
|
||||||
if isinstance(point, dict):
|
"""Legacy helper; prefer ``orisys.calibration``."""
|
||||||
raw = point.get("fnormal", point.get("raw", point.get("x")))
|
from .calibration import _parse_point
|
||||||
force_n = point.get("force_n", point.get("n", point.get("y")))
|
|
||||||
if raw is None or force_n is None:
|
return _parse_point(point)
|
||||||
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}")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ForceChannelCalibration:
|
class ForceChannelCalibration:
|
||||||
"""One force channel: linear coeffs and/or piecewise-linear breakpoints."""
|
"""Compatibility wrapper around ``calibration.ChannelCurve`` convert math."""
|
||||||
|
|
||||||
method: str = "linear"
|
method: str = "linear"
|
||||||
slope: float | None = None
|
slope: float | None = None
|
||||||
@@ -63,75 +62,40 @@ class ForceChannelCalibration:
|
|||||||
unit: str = "N",
|
unit: str = "N",
|
||||||
default_method: str = "piecewise_linear",
|
default_method: str = "piecewise_linear",
|
||||||
) -> ForceChannelCalibration | None:
|
) -> ForceChannelCalibration | None:
|
||||||
if not isinstance(block, dict):
|
from .calibration import _channel_from_block
|
||||||
return None
|
|
||||||
|
|
||||||
method = str(block.get("method", block.get("model", default_method))).lower()
|
curve = _channel_from_block(
|
||||||
if method in ("linear", "affine"):
|
"fn", block, unit=unit, default_method=default_method
|
||||||
slope = block.get("slope")
|
)
|
||||||
offset = block.get("offset")
|
if curve is None:
|
||||||
if slope is None or offset is None:
|
|
||||||
return None
|
return None
|
||||||
return cls.linear(float(slope), float(offset), unit=str(block.get("unit", unit)))
|
|
||||||
|
|
||||||
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_calibration_point(p) for p in raw_points))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
if len(points) < 2:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
method="piecewise_linear",
|
method=curve.method,
|
||||||
points=points,
|
slope=curve.slope,
|
||||||
extrapolate=bool(block.get("extrapolate", True)),
|
offset=curve.offset,
|
||||||
clamp_output_min=block.get("clamp_output_min", 0.0),
|
points=curve.points,
|
||||||
input_mode=str(block.get("input", "magnitude")).lower(),
|
extrapolate=curve.extrapolate,
|
||||||
signed=bool(block.get("signed", False)),
|
clamp_output_min=curve.clamp_output_min,
|
||||||
unit=str(block.get("unit", unit)),
|
input_mode=curve.input_mode,
|
||||||
|
signed=curve.signed,
|
||||||
|
unit=curve.unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
def convert(self, raw: float) -> float:
|
def convert(self, raw: float) -> float:
|
||||||
raw = float(raw)
|
from .calibration import ChannelCurve
|
||||||
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
|
|
||||||
|
|
||||||
points = self.points
|
return ChannelCurve(
|
||||||
if len(points) < 2:
|
id="fn",
|
||||||
return raw
|
method=self.method,
|
||||||
|
points=self.points,
|
||||||
if raw <= points[0][0]:
|
slope=self.slope if self.slope is not None else NORMAL_FORCE_CAL_SLOPE,
|
||||||
segment = (points[0], points[1])
|
offset=self.offset if self.offset is not None else NORMAL_FORCE_CAL_OFFSET,
|
||||||
if not self.extrapolate:
|
signed=self.signed,
|
||||||
return self._clamp(points[0][1])
|
extrapolate=self.extrapolate,
|
||||||
elif raw >= points[-1][0]:
|
clamp_output_min=self.clamp_output_min,
|
||||||
segment = (points[-2], points[-1])
|
input_mode=self.input_mode,
|
||||||
if not self.extrapolate:
|
unit=self.unit,
|
||||||
return self._clamp(points[-1][1])
|
).convert(raw)
|
||||||
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:
|
def convert_component(self, raw: float) -> float:
|
||||||
if not self.signed:
|
if not self.signed:
|
||||||
@@ -140,11 +104,6 @@ class ForceChannelCalibration:
|
|||||||
sign = -1.0 if raw < 0.0 else 1.0
|
sign = -1.0 if raw < 0.0 else 1.0
|
||||||
return sign * self.convert(abs(raw))
|
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(
|
_DEFAULT_NORMAL = ForceChannelCalibration.linear(
|
||||||
NORMAL_FORCE_CAL_SLOPE,
|
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:
|
if not sensor_name:
|
||||||
return None
|
return None
|
||||||
try:
|
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:
|
except Exception:
|
||||||
return None
|
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:
|
def _normal_calibration(sensor_name: str | None = None) -> ForceChannelCalibration:
|
||||||
cal = _channel_from_data(_load_force_calibration_data(sensor_name), "normal")
|
cal = _load_calibration(sensor_name)
|
||||||
return cal if cal is not None else _DEFAULT_NORMAL
|
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:
|
def _shear_curve(sensor_name: str | None, channel: str):
|
||||||
return _channel_from_data(_load_force_calibration_data(sensor_name), "shear")
|
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]:
|
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:
|
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.
|
"""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)
|
cal_x = _shear_curve(sensor_name, "fx")
|
||||||
if cal is None:
|
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)
|
return float(raw_value)
|
||||||
return cal.convert(raw_value)
|
|
||||||
|
|
||||||
|
|
||||||
def calibrate_shear_components(
|
def calibrate_shear_components(
|
||||||
@@ -239,27 +190,11 @@ def calibrate_shear_components(
|
|||||||
*,
|
*,
|
||||||
sensor_name: str | None = None,
|
sensor_name: str | None = None,
|
||||||
) -> tuple[float, float]:
|
) -> tuple[float, float]:
|
||||||
"""Convert raw shear components to Newtons.
|
"""Convert raw shear components to Newtons via ``calibration.load`` curves."""
|
||||||
|
cal = _load_calibration(sensor_name)
|
||||||
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)
|
|
||||||
if cal is None:
|
if cal is None:
|
||||||
return sx, sy
|
return float(shear_x), float(shear_y)
|
||||||
|
return cal.convert(0.0, float(shear_x), float(shear_y))[1:]
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _nyquist_hz(sample_rate_hz: float) -> float:
|
def _nyquist_hz(sample_rate_hz: float) -> float:
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
@@ -224,12 +224,13 @@ def test_box_read_info_applies_force_calibration_json():
|
|||||||
|
|
||||||
def test_read_info_force_stays_raw_without_calibration_json(monkeypatch):
|
def test_read_info_force_stays_raw_without_calibration_json(monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"orisys.sensor.configs.load_force_calibration",
|
"orisys.calibration.load",
|
||||||
lambda _name: None,
|
lambda *args, **kwargs: None,
|
||||||
)
|
)
|
||||||
sensor = Sensor("external", config_name="box", verbose=False, backend="cpu")
|
sensor = Sensor("external", config_name="box", verbose=False, backend="cpu")
|
||||||
try:
|
try:
|
||||||
assert sensor._force_cal_sensor_name is None
|
assert sensor._force_cal_sensor_name is None
|
||||||
|
assert sensor.calibration is None
|
||||||
sensor._force_raw = (111.0, -22.0, 33.0)
|
sensor._force_raw = (111.0, -22.0, 33.0)
|
||||||
sensor._apply_force_filter()
|
sensor._apply_force_filter()
|
||||||
fn, fx, fy = sensor.read_info(
|
fn, fx, fy = sensor.read_info(
|
||||||
|
|||||||
Reference in New Issue
Block a user