Compare commits

..

3 Commits

Author SHA1 Message Date
ykcheungab 96f4428230 added calibration data handling functions 2026-09-10 21:47:36 +08:00
ykcheungab 9cbba08d68 tuned sensitivity to max. by default 2026-09-03 15:23:51 +08:00
ykcheungab ad680b77dc 0.5.1 2026-09-02 21:47:51 +08:00
19 changed files with 1416 additions and 192 deletions
+21 -1
View File
@@ -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/<type>/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/<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)
### Q1:为什么必须先调用`get_img()`,再调用`compute_deformation()`?
A:`compute_deformation()`依赖最近一次获取并拼接的图像数据进行形变与力计算。
+5 -5
View File
@@ -5,7 +5,7 @@ description: 安装并运行 Orisys SDK 的基础步骤
# 快速开始
版本:0.5.0
版本:0.5.1
## 简介
本文件介绍如何上手和运行叠动视触觉传感器配套的开发套件。本套件是配合叠动视触觉传感器进行图像处理,计算形变、力以及接触信息。它可以从摄像头或视频文件中读取图像,处理后打印和展示处理后信息。
@@ -84,9 +84,9 @@ pip install --upgrade orisys
### 使用 .whl 文件安装(离线或指定版本)
从官方渠道获取 .whl 文件,文件名格式通常为:
orisys-{版本}-{Python版本}-{Python版本}-{操作系统}_{硬件架构}.whl
例如:orisys-0.5.0-cp310-cp310-win_amd64.whl 表示:
例如:orisys-0.5.1-cp310-cp310-win_amd64.whl 表示:
版本:0.5.0
版本:0.5.1
Python 版本:3.10(cp310)
操作系统:Windows(win)
硬件架构:64位(amd64)
@@ -97,7 +97,7 @@ Python 版本:3.10(cp310)
```bash
# 请将文件名替换为您实际下载的文件名
pip install orisys-0.5.0-cp310-cp310-win_amd64.whl
pip install orisys-0.5.1-cp310-cp310-win_amd64.whl
```
### 安装 CuPy(可选)
@@ -139,7 +139,7 @@ python
import orisys
print(orisys.__version__)
```
如果输出版本号(例如 0.5.0),则说明安装成功。
如果输出版本号(例如 0.5.1),则说明安装成功。
## 使用方法
### 运行示例程序
+4 -3
View File
@@ -7,7 +7,7 @@ ORY-BOX:单传感器基础流程
3. 按需计算接触区域
4. 读取并可视化结果
SDK version: 0.5.0
SDK version: 0.5.1
"""
import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
@@ -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' 重置追踪器。")
+1 -1
View File
@@ -7,7 +7,7 @@ ORY-MINI:单传感器基础流程
3. 按需计算接触区域
4. 读取并可视化结果
SDK version: 0.5.0
SDK version: 0.5.1
"""
import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
+1 -1
View File
@@ -18,7 +18,7 @@
- Windows 下必须在 if __name__ == '__main__' 中调用 mp.freeze_support()
- 每个传感器进程都会占用独立的 GPU 显存
SDK version: 0.5.0
SDK version: 0.5.1
"""
import os
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
+1 -1
View File
@@ -29,7 +29,7 @@
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"dis_mode": 1,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 3
},
+1 -1
View File
@@ -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,
+9 -1
View File
@@ -5,12 +5,13 @@ A Python library for processing and analyzing optical tactile sensor data,
including image stitching, optical flow tracking, and deformation decomposition.
"""
__version__ = "0.5.0"
__version__ = "0.5.1"
__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'
+782
View File
@@ -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)
+80
View File
@@ -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/<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):
"""
Load sensor configuration from a JSON file path or config name.
+70
View File
@@ -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
View File
@@ -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/<name> when force_calibration.json exists; else None.
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).
"""
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/<name>/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
+80 -145
View File
@@ -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/<name>/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:
curve = _channel_from_block(
"fn", block, unit=unit, default_method=default_method
)
if curve is 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(
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:
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)
return cal.convert(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:
+1 -1
View File
@@ -8,7 +8,7 @@ Flow:
4. Feed the rectified frame into Sensor.get_img(..., already_processed=True)
5. Compute deformation/contact and visualize
SDK version: 0.5.0
SDK version: 0.5.1
"""
from __future__ import annotations
+243
View File
@@ -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))
+48
View File
@@ -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)
+1 -1
View File
@@ -35,7 +35,7 @@ def test_pyproject_dynamic_version(packaging_utils):
sys.path.insert(0, str(build_dir))
from common.pyproject_utils import get_version
assert get_version(REPO_ROOT) == "0.5.0"
assert get_version(REPO_ROOT) == "0.5.1"
def test_pyproject_bundle_manifest(packaging_utils):
+3 -2
View File
@@ -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(
+6 -6
View File
@@ -19,25 +19,25 @@ def wheel_package():
def test_compliant_manylinux_tags(wheel_package):
assert wheel_package.is_compliant_manylinux_wheel(
Path("orisys-0.5.0-cp310-cp310-manylinux_2_17_x86_64.whl")
Path("orisys-0.5.1-cp310-cp310-manylinux_2_17_x86_64.whl")
)
assert wheel_package.is_compliant_manylinux_wheel(
Path("orisys-0.5.0-cp310-cp310-manylinux2014_x86_64.whl")
Path("orisys-0.5.1-cp310-cp310-manylinux2014_x86_64.whl")
)
assert wheel_package.is_compliant_manylinux_wheel(
Path("orisys-0.5.0-cp310-cp310-manylinux_2_28_x86_64.whl")
Path("orisys-0.5.1-cp310-cp310-manylinux_2_28_x86_64.whl")
)
def test_rejects_illegal_manylinux_tags(wheel_package):
assert not wheel_package.is_compliant_manylinux_wheel(
Path("orisys-0.5.0-cp310-cp310-manylinux_x86_64.whl")
Path("orisys-0.5.1-cp310-cp310-manylinux_x86_64.whl")
)
assert not wheel_package.is_compliant_manylinux_wheel(
Path("orisys-0.5.0-cp310-cp310-linux_x86_64.whl")
Path("orisys-0.5.1-cp310-cp310-linux_x86_64.whl")
)
assert not wheel_package.is_compliant_manylinux_wheel(
Path("orisys-0.5.0-cp310-cp310-win_amd64.whl")
Path("orisys-0.5.1-cp310-cp310-win_amd64.whl")
)