新增 HHD 场加权标定模型与加权空载基线
field_gain/1 用坐标增益图对无旋向量场做 L1 场加权,以连续 30 帧加权中位数替代旧 raw 零点,实时入口暂不支持。目标测试 102 项与 lint_gate 通过;契约见 orisys-sdk/docs/field-gain-model.zh.md。
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# 统一力标定模型契约
|
# 统一力标定模型契约
|
||||||
|
|
||||||
Purpose: reference. Scope: SDK 标定文件选择、SN 绑定和推断接口。
|
Purpose: reference. Scope: SDK 标定文件选择、SN 绑定和推断接口。
|
||||||
状态:2026-10-18 已实现 schema 3 的空间/全局增益;ML 后端仅保留契约,尚未实现。
|
状态:2026-10-19 已实现 schema 3 的空间、全局与场加权增益;ML 后端仅保留契约,尚未实现。
|
||||||
保持 `cal_path` 与现有单点文件;新增空间模型和机器学习模型使用同一入口。
|
保持 `cal_path` 与现有单点文件;新增空间模型和机器学习模型使用同一入口。
|
||||||
运行中的 Finger 可调用 `runtime.set_calibration(file, device_id=sn)`;调用者须在处理线程的帧边界执行。
|
运行中的 Finger 可调用 `runtime.set_calibration(file, device_id=sn)`;调用者须在处理线程的帧边界执行。
|
||||||
该接口按实际几何、展开域及处理参数重新校验,成功切换后清除会话零点;校验失败保留原模型。
|
该接口按实际几何、展开域及处理参数重新校验,成功切换后清除会话零点;校验失败保留原模型。
|
||||||
@@ -154,46 +154,9 @@ FingerRuntime 使用 `artifacts` 中 `role="coordinate_map"` 的只读 NPZ:
|
|||||||
输入 profile 为 `force_delta/1`,由 SDK 按同一归零语义准备对应 raw 增量。
|
输入 profile 为 `force_delta/1`,由 SDK 按同一归零语义准备对应 raw 增量。
|
||||||
这不同于旧曲线的绝对 raw 输入;兼容适配器不得替旧文件额外扣零。
|
这不同于旧曲线的绝对 raw 输入;兼容适配器不得替旧文件额外扣零。
|
||||||
|
|
||||||
## 机器学习扩展
|
## 其他模型类型
|
||||||
|
|
||||||
同一 SN、路径和外层字段不变,只替换 `model`。下面只预留 `onnx/1` 示例,
|
场加权 FN 模型 `field_gain/1`(输入 profile `hhd_normal_l1_field/1`)的字段、映射、空载基线与诊断契约见[场加权模型契约](field-gain-model.zh.md)。ML 扩展(首个示例 `onnx/1`)仍是预留示例,见[机器学习模型契约](ml-model-contract.zh.md);后端实现和输入 profile 验证通过前必须拒绝加载。
|
||||||
不表示 SDK 已支持 ONNX;后端实现和输入 profile 验证通过前必须拒绝加载。
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "onnx/1",
|
|
||||||
"input_profile": "fn_delta_autocal_uv/1",
|
|
||||||
"output_frame": "sdk_force/1",
|
|
||||||
"outputs": ["fn"],
|
|
||||||
"params": {
|
|
||||||
"input_tensor": "features",
|
|
||||||
"output_tensor": "force_N",
|
|
||||||
"feature_order": ["delta_raw", "u", "v"],
|
|
||||||
"dtype": "float32",
|
|
||||||
"shape": [1, 3],
|
|
||||||
"valid_force_range_N": [0.0, 3.0]
|
|
||||||
},
|
|
||||||
"artifacts": [
|
|
||||||
{
|
|
||||||
"role": "weights",
|
|
||||||
"path": "DD02-GL-001/model.onnx",
|
|
||||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
此标量 profile 的规范输入即上述顺序、类型、形状;输出 float32 `[1, len(outputs)]`,
|
|
||||||
轴序严格按 `outputs`。归一化及单位转换封装在已验证的模型图内,不由应用另做。
|
|
||||||
`fn_delta_autocal_uv/1` 只允许 `outputs=["fn"]`,不能凭 FN 特征宣称三轴已标定。
|
|
||||||
需要图像、完整光流或历史序列时新增输入 profile,明确 H/W、通道、掩码、单位和时间窗口;
|
|
||||||
没有对应特征的处理器拒绝加载,不能用零张量凑齐输入。
|
|
||||||
|
|
||||||
`artifacts` 只在有外部文件时必填;路径相对所选 JSON 的父目录,禁止绝对路径、URL、
|
|
||||||
`..` 或链接逃逸。校验 SHA-256、角色和文件完整性后才初始化受支持的后端。
|
|
||||||
不加载 pickle、任意 Python 或模型自带扩展库;缺推断依赖须明确报错,不自动安装。
|
|
||||||
文件与所有附件共同构成内容指纹,不能只哈希 JSON。
|
|
||||||
附件间依赖也须逐项声明和校验,未声明的外部权重不可加载。
|
|
||||||
|
|
||||||
## 每帧推断与有效性
|
## 每帧推断与有效性
|
||||||
|
|
||||||
@@ -240,12 +203,14 @@ schema 3 按声明轴校验,允许 FN-only;旧 SDK 遇 schema 3 必须拒绝
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `calibration.py` | schema 分流、身份和附件校验;旧曲线适配器 |
|
| `calibration.py` | schema 分流、身份和附件校验;旧曲线适配器 |
|
||||||
| `sensors/calibration.py` | 统一选择器与指纹;支持 SN 请求 |
|
| `sensors/calibration.py` | 统一选择器与指纹;支持 SN 请求 |
|
||||||
| 新 `calibration_models.py` | 受控类型表、空间增益与模型生命周期;ML 后端另行实现 |
|
| 新 `calibration_models.py` | 受控类型表、空间/全局/场加权模型与生命周期;ML 后端另行实现 |
|
||||||
| `sensor.py`、`finger/runtime.py`、处理器适配层 | 提供同帧位置/零点,缓存逐轴结果和状态 |
|
| `sensor.py`、`finger/runtime.py`、处理器适配层 | 提供同帧位置/零点,缓存逐轴结果和状态 |
|
||||||
| Sensor 与 `ForceReadout` | 复用读数滤波器;schema 3 在推断后逐轴滤波,旧路径不变 |
|
| Sensor 与 `ForceReadout` | 复用读数滤波器;schema 3 在推断后逐轴滤波,旧路径不变 |
|
||||||
| 离线导出器 | 实验模型到 schema 3;SDK 不依赖实验目录 |
|
| 离线导出器 | 实验模型到 schema 3;SDK 不依赖实验目录 |
|
||||||
|
|
||||||
当前导出入口为 workspace 的 `scripts/finger_multical/export_sdk.py`,输出已有文件时拒绝覆盖。
|
当前导出入口为 workspace 的 `scripts/finger_multical/export_sdk.py`,输出已有文件时拒绝覆盖。
|
||||||
|
场加权实验文件 `data/dd02-gl/force_calibration.v3.field.json` 由同目录 `export_field.py`
|
||||||
|
从上述交付只读转换(不重训、不改排除点、不覆盖原件),契约见[场加权模型契约](field-gain-model.zh.md)。
|
||||||
`data/dd02-gl/force_calibration.v3.json` 与同名 `.coordinates.npz` 是本次实验交付,
|
`data/dd02-gl/force_calibration.v3.json` 与同名 `.coordinates.npz` 是本次实验交付,
|
||||||
`identity.sn="dd02-gl"` 为实验标识,未从硬件读取;原三边缘点与非单调点警告保留于 provenance。
|
`identity.sn="dd02-gl"` 为实验标识,未从硬件读取;原三边缘点与非单调点警告保留于 provenance。
|
||||||
它不是生产包默认文件,需显式 `cal_path` 选择。
|
它不是生产包默认文件,需显式 `cal_path` 选择。
|
||||||
@@ -253,4 +218,6 @@ schema 3 按声明轴校验,允许 FN-only;旧 SDK 遇 schema 3 必须拒绝
|
|||||||
实现验收必须覆盖:旧结果与单位不变;显式路径/SN/默认/关闭选择;错误 SN 不串用;
|
实现验收必须覆盖:旧结果与单位不变;显式路径/SN/默认/关闭选择;错误 SN 不串用;
|
||||||
3/14/更多点、退化网格与凸包外;节点和插值预测;零点不重复扣除;缺坐标/无接触;
|
3/14/更多点、退化网格与凸包外;节点和插值预测;零点不重复扣除;缺坐标/无接触;
|
||||||
FN-only 的 FX/FY 不冒充 N;未知模型、附件损坏和依赖缺失;状态模型每帧只调用一次及 reset。
|
FN-only 的 FX/FY 不冒充 N;未知模型、附件损坏和依赖缺失;状态模型每帧只调用一次及 reset。
|
||||||
|
场加权另需常增益等价、两区域贡献、边界投影、映射错误与 30 帧加权基线生命周期,
|
||||||
|
见 `tests/test_field_calibration.py`。
|
||||||
另以独立录像验证物理准确度,契约或单元测试通过不代表标定合格。
|
另以独立录像验证物理准确度,契约或单元测试通过不代表标定合格。
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# 场加权力标定模型契约
|
||||||
|
|
||||||
|
用途:reference。范围:SDK schema 3 `field_gain/1` 的输入、映射、空载基线、诊断与兼容边界。
|
||||||
|
[统一力标定模型契约](calibration-model-contract.zh.md)负责文件选择、身份与逐帧生命周期;
|
||||||
|
本文只补充场加权模型特有语义。状态:2026-10-19 已实现并有回归测试,离线录像对照见
|
||||||
|
[实施计划](../../docs/workflow/field-weighted-calibration-plan.zh.md)。
|
||||||
|
|
||||||
|
## 定位与边界
|
||||||
|
|
||||||
|
从现有多点增益生成空间图,对 HHD 无旋向量场做加权求和,作为 FN 的实验估计。
|
||||||
|
不依赖深度、接触中心、最大光流点或单接触判定;实时入口尚未接入,必须显式拒绝。
|
||||||
|
现有增益对应各加载位置的整场响应,不是已验证的局部系数。交付证明软件契约与可复现估计,
|
||||||
|
不宣称局部压力、多接触力可加性或精度改善。负的基线差保留;显示阈值只由显示层决定。
|
||||||
|
|
||||||
|
## 文件字段
|
||||||
|
|
||||||
|
`model.type` 为 `field_gain/1`,`input_profile` 为 `hhd_normal_l1_field/1`,`outputs` 仅 `["fn"]`。
|
||||||
|
`pipeline` 沿用 schema 3 的测量身份,并增加闭合的 `pipeline.field`(由 SDK
|
||||||
|
`FIELD_IDENTITY` 生成):`grid`、`shape`、`mask_shape`、`vector`、`reduction`、`dtype`、
|
||||||
|
`unit`、`mask`、`lifecycle`、`area_compensation`。它们进入特征签名;改变任一值即需升级 profile。
|
||||||
|
`params` 必需键:
|
||||||
|
|
||||||
|
| 键 | 规则 |
|
||||||
|
|---|---|
|
||||||
|
| `points_uv`、`gains_N_per_raw`、`triangles` | 沿用空间增益的完整凸包三角网校验;点数由数组决定 |
|
||||||
|
| `valid_force_range_N` | 必填 `[low, high]`;超范围保留数值并标 `out_of_range` |
|
||||||
|
| `mapping` | 固定 `{"inside": "barycentric", "outside": "boundary_projection", "tie_break": "boundary_edge_order"}` |
|
||||||
|
| `baseline` | 固定 `{"frames": 30, "statistic": "median"}`,仅实现这一策略 |
|
||||||
|
|
||||||
|
`artifacts` 必须有且仅有一个 `role="coordinate_map"` 附件;`map_sha256` 必须等于附件字节摘要。
|
||||||
|
附件提供输出分辨率 `uv_by_output_px` 与 `valid_mask`,SDK 打开模型时校验形状与有限性。
|
||||||
|
|
||||||
|
## 增益图与加权求和
|
||||||
|
|
||||||
|
先把支撑点增益映射到 HHD 向量场的计算网格,再逐帧归约;缓存身份包含模型指纹、坐标附件、
|
||||||
|
HHD 网格形状与物理有效掩膜,任一变化都重建。映射规则:
|
||||||
|
|
||||||
|
- 三角网内按重心插值取增益;查询落在共享边时按文件中的三角顺序取第一个命中的三角形。
|
||||||
|
- 凸包外投影到最近边界线段,再按投影参数插值两端增益;距离相同时取边界边排序中靠前者。
|
||||||
|
- 输出网格内只在物理有效像素上统计;有效掩膜内缺坐标或增益是模型映射错误,报
|
||||||
|
`coordinate_unmapped`,不当作零或域外增益。
|
||||||
|
- 按块面积降采样到 HHD 网格:增益取覆盖有效像素的均值;`coverage` 是覆盖面积比,
|
||||||
|
`inside` 是凸包内面积比;混合覆盖格点保留比例,不平均无效坐标。
|
||||||
|
|
||||||
|
逐帧对同一帧的 HHD 无旋向量场 `d` 计算
|
||||||
|
`P = A * sum_{HHD 激活格点}(gain * (|d_x| + |d_y|))`。HHD raw 汇总正是
|
||||||
|
`A * sum_{激活格点}(|d_x|+|d_y|)`,所以常增益必须精确等于 `gain * raw`;`coverage` 只用于
|
||||||
|
外推比例与诊断,不得再乘进 `P`,否则掩膜边缘的部分覆盖格点会被重复折扣。
|
||||||
|
|
||||||
|
HHD 激活但 `coverage=0`(没有任何有效像素映射到该格点)是模型映射错误,报
|
||||||
|
`coordinate_unmapped`,不得静默记零。`A` 取自 `hhd/api.py` 现有的
|
||||||
|
参考网格面积补偿(参考网格像素数/当前网格像素数),调用方不得再乘一次或重复归一化。
|
||||||
|
不得改变 FN raw、HHD 分解或剪切分量。空场时 `P=0` 且外推比例为 0;
|
||||||
|
负的 `FN = P - B0` 保留为有效数值(可能超量程)。
|
||||||
|
|
||||||
|
## 空载基线与生命周期
|
||||||
|
|
||||||
|
用户先声明无载窗口,SDK 才对后续帧累计加权 `P`:
|
||||||
|
|
||||||
|
- `Sensor.declare_unloaded_reference(reference_id=...)`(`FingerRuntime` 同名转发)开始窗口;
|
||||||
|
`reference_id` 必填并记录。窗口要求连续 30 个有效帧;任一无效输入或映射错误会中断连续计数,
|
||||||
|
向前 30 帧零点未就绪。
|
||||||
|
- `B0` 为这 30 个 `P` 的中位数;就绪前的帧状态为 `zero_unavailable`,不输出牛顿值。
|
||||||
|
就绪后 `FN = P - B0`。
|
||||||
|
- 空载确认不以接触定位为门槛,但要求输入有限;换模型、重置、倒播或新会话使基线失效。
|
||||||
|
- 场模型拒绝旧标量零点:`Sensor.set_calibration_reference()` 对 `hhd_normal_l1_field/1`
|
||||||
|
抛 `CalibrationError`,调用方必须使用加权窗口接口,不能把旧 FN raw 零点当作 `B0`。
|
||||||
|
|
||||||
|
## 结果诊断
|
||||||
|
|
||||||
|
`CalibrationResult.diagnostics` 逐帧携带版本化诊断;旧结果或缺字段时按未知处理,不得补零:
|
||||||
|
|
||||||
|
| 键 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `model_fingerprint` | 模型文档与附件的内容指纹 |
|
||||||
|
| `mapping` | 生效的边界策略 |
|
||||||
|
| `baseline_N`、`reference_id` | 已就绪基线及其声明标识;未就绪为 `None` |
|
||||||
|
| `pending_reference_frames` | 当前连续窗口已累计的有效帧数 |
|
||||||
|
| `extrapolated_fraction`、`coverage_fraction` | 按 `q` 加权的凸包外比例与覆盖比例 |
|
||||||
|
| `area_scale`、`frame_id` | 本帧面积补偿与帧标识 |
|
||||||
|
|
||||||
|
`extrapolated_fraction` 是估计质量,不是有效性:它不改变 `status`,也不得让上层丢弃牛顿值。
|
||||||
|
零点缺失、输入错误仍无效;超量程沿用现有显式状态。
|
||||||
|
|
||||||
|
## 兼容与实现位置
|
||||||
|
|
||||||
|
旧 `spatial_gain/1`、`global_gain/1` 与旧文件语义不变;schema 3 按声明轴校验,旧 SDK 遇
|
||||||
|
`field_gain/1` 必须拒绝。`hhd_downsample` 仍按既有兼容例外处理:只允许该键数值不同,
|
||||||
|
其余处理参数、几何、展开域、SN 与传感器类型仍须匹配,不能借此跳过签名校验。
|
||||||
|
|
||||||
|
| 位置 | 责任 |
|
||||||
|
|---|---|
|
||||||
|
| `src/orisys/field_calibration.py` | profile/策略常量、增益图构建、加权归约 |
|
||||||
|
| `src/orisys/calibration_models.py` | 类型注册、参数校验、基线状态与诊断 |
|
||||||
|
| `src/orisys/hhd/api.py` | 同帧 `get_field()`:`d` 拷贝、物理掩膜、面积补偿 |
|
||||||
|
| `src/orisys/sensor.py`、`src/orisys/finger/runtime.py` | 同帧场推断、加权零点声明、上下文绑定 |
|
||||||
|
| 工作区 `scripts/finger_multical/export_field.py` | 现有空间模型只读导出为实验文件,不重训、不改排除点 |
|
||||||
|
|
||||||
|
回归证据:`tests/test_field_calibration.py` 覆盖常增益等价、两区域贡献、重叠向量先合成再 L1、
|
||||||
|
边界投影、掩膜/NaN/空场、映射错误与 30 帧基线生命周期;`tests/test_model_runtime.py` 覆盖
|
||||||
|
HHD 同帧场接线、加权零点声明与无接触推断。命令与 run_id 记录在实施计划的执行结果中。
|
||||||
|
实验导出文件不覆盖原件,provenance 声明来源哈希、边界/基线策略和实验假设。
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 机器学习力标定模型契约
|
||||||
|
|
||||||
|
用途:reference。范围:SDK schema 3 中预留的 ML 模型后端(首个示例 `onnx/1`)与输入 profile
|
||||||
|
约束。文件选择、身份、逐帧生命周期与有效性状态由[统一力标定模型契约](calibration-model-contract.zh.md)
|
||||||
|
负责。状态:仅保留契约,SDK 尚未实现 ONNX 后端;实现与验证通过前必须拒绝加载。
|
||||||
|
|
||||||
|
同一 SN、路径和外层字段不变,只替换 `model`。下面只预留 `onnx/1` 示例,
|
||||||
|
不表示 SDK 已支持 ONNX。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "onnx/1",
|
||||||
|
"input_profile": "fn_delta_autocal_uv/1",
|
||||||
|
"output_frame": "sdk_force/1",
|
||||||
|
"outputs": ["fn"],
|
||||||
|
"params": {
|
||||||
|
"input_tensor": "features",
|
||||||
|
"output_tensor": "force_N",
|
||||||
|
"feature_order": ["delta_raw", "u", "v"],
|
||||||
|
"dtype": "float32",
|
||||||
|
"shape": [1, 3],
|
||||||
|
"valid_force_range_N": [0.0, 3.0]
|
||||||
|
},
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"role": "weights",
|
||||||
|
"path": "DD02-GL-001/model.onnx",
|
||||||
|
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
此标量 profile 的规范输入即上述顺序、类型、形状;输出 float32 `[1, len(outputs)]`,
|
||||||
|
轴序严格按 `outputs`。归一化及单位转换封装在已验证的模型图内,不由应用另做。
|
||||||
|
`fn_delta_autocal_uv/1` 只允许 `outputs=["fn"]`,不能凭 FN 特征宣称三轴已标定。
|
||||||
|
需要图像、完整光流或历史序列时新增输入 profile,明确 H/W、通道、掩码、单位和时间窗口;
|
||||||
|
没有对应特征的处理器拒绝加载,不能用零张量凑齐输入。
|
||||||
|
|
||||||
|
`artifacts` 只在有外部文件时必填;路径相对所选 JSON 的父目录,禁止绝对路径、URL、
|
||||||
|
`..` 或链接逃逸。校验 SHA-256、角色和文件完整性后才初始化受支持的后端。
|
||||||
|
不加载 pickle、任意 Python 或模型自带扩展库;缺推断依赖须明确报错,不自动安装。
|
||||||
|
文件与所有附件共同构成内容指纹,不能只哈希 JSON。
|
||||||
|
附件间依赖也须逐项声明和校验,未声明的外部权重不可加载。
|
||||||
@@ -53,8 +53,13 @@ def domain_fingerprint(domain) -> str:
|
|||||||
|
|
||||||
def build_context(config, filters=None, *, geometry_sha256=None, source_scale=None,
|
def build_context(config, filters=None, *, geometry_sha256=None, source_scale=None,
|
||||||
coordinates=None, output_domain_sha256=None, processing_backend=None,
|
coordinates=None, output_domain_sha256=None, processing_backend=None,
|
||||||
preprocessing=None) -> dict:
|
preprocessing=None, field=None) -> dict:
|
||||||
"""Build the closed v1 feature identity from effective values, excluding readout filters."""
|
"""Build the closed v1 feature identity from effective values, excluding readout filters.
|
||||||
|
|
||||||
|
``field`` carries the closed profile identity for field models (shape, dtype,
|
||||||
|
unit, mask and lifecycle). It is omitted for scalar profiles so their
|
||||||
|
signatures stay byte-identical to previously exported files.
|
||||||
|
"""
|
||||||
camera = config.get("camera", {})
|
camera = config.get("camera", {})
|
||||||
tracker = dict(config.get("tracker", {}))
|
tracker = dict(config.get("tracker", {}))
|
||||||
raw_downsample = tracker.get("hhd_downsample", 1)
|
raw_downsample = tracker.get("hhd_downsample", 1)
|
||||||
@@ -94,5 +99,8 @@ def build_context(config, filters=None, *, geometry_sha256=None, source_scale=No
|
|||||||
"source_scale": source_scale,
|
"source_scale": source_scale,
|
||||||
"preprocessing": preprocessing or {},
|
"preprocessing": preprocessing or {},
|
||||||
}
|
}
|
||||||
|
if field is not None:
|
||||||
|
result["field"] = deepcopy(field)
|
||||||
|
effective["field"] = deepcopy(field)
|
||||||
result["feature_signature_sha256"] = _feature_signature(effective)
|
result["feature_signature_sha256"] = _feature_signature(effective)
|
||||||
return CalibrationContext(result, effective)
|
return CalibrationContext(result, effective)
|
||||||
|
|||||||
@@ -7,13 +7,32 @@ import json
|
|||||||
import math
|
import math
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field as dataclass_field
|
||||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from scipy.spatial import ConvexHull, QhullError
|
from scipy.spatial import ConvexHull, QhullError
|
||||||
|
|
||||||
|
from .field_calibration import (
|
||||||
|
BASELINE_POLICY,
|
||||||
|
FIELD_IDENTITY,
|
||||||
|
FIELD_PROFILE,
|
||||||
|
MAPPING_POLICY,
|
||||||
|
ZERO_FRAME_COUNT,
|
||||||
|
FieldGainMap,
|
||||||
|
build_field_gain_map,
|
||||||
|
coerce_field,
|
||||||
|
mask_sha256,
|
||||||
|
reduce_field,
|
||||||
|
)
|
||||||
|
|
||||||
|
_PROFILE_FOR_TYPE = {
|
||||||
|
"spatial_gain/1": "fn_delta_autocal_uv/1",
|
||||||
|
"global_gain/1": "force_delta/1",
|
||||||
|
"field_gain/1": FIELD_PROFILE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _require(condition: bool, message: str) -> None:
|
def _require(condition: bool, message: str) -> None:
|
||||||
if not condition:
|
if not condition:
|
||||||
@@ -98,11 +117,15 @@ def _mesh(params: dict[str, Any]) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|||||||
return vertices, np.asarray(gains, dtype=float), np.asarray(triangles, dtype=int)
|
return vertices, np.asarray(gains, dtype=float), np.asarray(triangles, dtype=int)
|
||||||
|
|
||||||
|
|
||||||
def _pipeline(data: Any, spatial: bool) -> None:
|
def _pipeline(data: Any, profile: str) -> None:
|
||||||
|
spatial = profile != "force_delta/1"
|
||||||
required = {"stitch_resolution", "dis_mode", "hhd_downsample",
|
required = {"stitch_resolution", "dis_mode", "hhd_downsample",
|
||||||
"filters", "feature_signature_sha256"}
|
"filters", "feature_signature_sha256"}
|
||||||
_keys(data, required | ({"geometry_sha256", "coordinates", "source_scale"} if spatial else set()),
|
if spatial:
|
||||||
{"geometry_sha256", "coordinates", "output_domain_sha256", "source_scale"})
|
required |= {"geometry_sha256", "coordinates", "source_scale"}
|
||||||
|
if profile == FIELD_PROFILE:
|
||||||
|
required.add("field")
|
||||||
|
_keys(data, required, {"geometry_sha256", "coordinates", "output_domain_sha256", "source_scale"})
|
||||||
size = data["stitch_resolution"]
|
size = data["stitch_resolution"]
|
||||||
_require(isinstance(size, list) and len(size) == 2 and all(type(n) is int and n > 0 for n in size),
|
_require(isinstance(size, list) and len(size) == 2 and all(type(n) is int and n > 0 for n in size),
|
||||||
"invalid stitch_resolution")
|
"invalid stitch_resolution")
|
||||||
@@ -127,12 +150,17 @@ def _pipeline(data: Any, spatial: bool) -> None:
|
|||||||
_require(isinstance(coord["origin_xy"], list) and len(coord["origin_xy"]) == 2 and
|
_require(isinstance(coord["origin_xy"], list) and len(coord["origin_xy"]) == 2 and
|
||||||
all(_number(x) for x in coord["origin_xy"]), "invalid coordinate origin")
|
all(_number(x) for x in coord["origin_xy"]), "invalid coordinate origin")
|
||||||
_require(_sha(coord["map_sha256"]), "invalid map SHA-256")
|
_require(_sha(coord["map_sha256"]), "invalid map SHA-256")
|
||||||
|
if "field" in required:
|
||||||
|
_require(data["field"] == FIELD_IDENTITY, "unsupported field identity")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class CalibrationResult:
|
class CalibrationResult:
|
||||||
values_N: dict[str, float | None]
|
values_N: dict[str, float | None]
|
||||||
status: dict[str, str]
|
status: dict[str, str]
|
||||||
|
#: Versioned extra evidence (field model: baseline, extrapolation fraction).
|
||||||
|
#: Consumers must tolerate missing keys so old cached results stay readable.
|
||||||
|
diagnostics: dict[str, Any] = dataclass_field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class ModelCalibration:
|
class ModelCalibration:
|
||||||
@@ -154,16 +182,25 @@ class ModelCalibration:
|
|||||||
self._params = self._document["model"]["params"]
|
self._params = self._document["model"]["params"]
|
||||||
self.coordinate_map = self.coordinate_valid_mask = None
|
self.coordinate_map = self.coordinate_valid_mask = None
|
||||||
self._artifact_hashes: list[str] = []
|
self._artifact_hashes: list[str] = []
|
||||||
if self.model_type == "spatial_gain/1":
|
self._points = self._gains = self._triangles = None
|
||||||
|
self._field_maps: dict[tuple[tuple[int, int], str], FieldGainMap] = {}
|
||||||
|
self._field_reference_id: str | None = None
|
||||||
|
self._field_zero_values: list[float] = []
|
||||||
|
self._field_baseline: float | None = None
|
||||||
|
if self.model_type in ("spatial_gain/1", "field_gain/1"):
|
||||||
self._points, self._gains, self._triangles = _mesh(self._params)
|
self._points, self._gains, self._triangles = _mesh(self._params)
|
||||||
self._load_artifacts(document["model"].get("artifacts", []))
|
self._load_artifacts(document["model"].get("artifacts", []))
|
||||||
|
if self.model_type == "field_gain/1":
|
||||||
|
_require(self.coordinate_map is not None,
|
||||||
|
"field_gain/1 requires a coordinate_map artifact")
|
||||||
|
|
||||||
def _load_artifacts(self, artifacts: Any) -> None:
|
def _load_artifacts(self, artifacts: Any) -> None:
|
||||||
_require(isinstance(artifacts, list), "artifacts must be an array")
|
_require(isinstance(artifacts, list), "artifacts must be an array")
|
||||||
_require(len(artifacts) <= 1, "only one coordinate_map artifact is supported")
|
_require(len(artifacts) <= 1, "only one coordinate_map artifact is supported")
|
||||||
for artifact in artifacts:
|
for artifact in artifacts:
|
||||||
_keys(artifact, {"role", "path", "sha256"})
|
_keys(artifact, {"role", "path", "sha256"})
|
||||||
_require(self.model_type == "spatial_gain/1" and artifact["role"] == "coordinate_map",
|
_require(self.model_type in ("spatial_gain/1", "field_gain/1")
|
||||||
|
and artifact["role"] == "coordinate_map",
|
||||||
"unsupported artifact role")
|
"unsupported artifact role")
|
||||||
name = artifact["path"]
|
name = artifact["path"]
|
||||||
_require(isinstance(name, str) and bool(name) and ":" not in name and
|
_require(isinstance(name, str) and bool(name) and ":" not in name and
|
||||||
@@ -229,7 +266,108 @@ class ModelCalibration:
|
|||||||
_require(pipeline.get(key) == value, f"calibration pipeline mismatch: {key}")
|
_require(pipeline.get(key) == value, f"calibration pipeline mismatch: {key}")
|
||||||
|
|
||||||
def reset(self, reference_id: Any = None) -> None:
|
def reset(self, reference_id: Any = None) -> None:
|
||||||
"""Gain backends have no history; preserve the lifecycle for future models."""
|
"""Clear per-stream state; the field model also voids its weighted baseline."""
|
||||||
|
del reference_id
|
||||||
|
self._field_reference_id = None
|
||||||
|
self._field_zero_values = []
|
||||||
|
self._field_baseline = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def field_baseline_N(self) -> float | None:
|
||||||
|
"""Current weighted unloaded baseline, or ``None`` while it is unavailable."""
|
||||||
|
return self._field_baseline
|
||||||
|
|
||||||
|
def begin_reference(self, reference_id: Any) -> None:
|
||||||
|
"""Declare a new unloaded window; the next 30 valid frames set the baseline."""
|
||||||
|
_require(self.model_type == "field_gain/1",
|
||||||
|
"a weighted reference requires field_gain/1")
|
||||||
|
_require(isinstance(reference_id, str) and bool(reference_id.strip()),
|
||||||
|
"reference_id must be a non-empty string")
|
||||||
|
self._field_reference_id = reference_id
|
||||||
|
self._field_zero_values = []
|
||||||
|
self._field_baseline = None
|
||||||
|
|
||||||
|
def field_gain_map(self, grid_shape: tuple[int, int],
|
||||||
|
physical_mask: np.ndarray) -> FieldGainMap:
|
||||||
|
"""Gain/coverage map for one HHD grid and one output-resolution mask."""
|
||||||
|
_require(self.model_type == "field_gain/1",
|
||||||
|
"field gain maps require field_gain/1")
|
||||||
|
mask = np.asarray(physical_mask, dtype=bool)
|
||||||
|
_require(mask.ndim == 2 and mask.shape == self.coordinate_map.shape[:2],
|
||||||
|
"physical mask must match the coordinate map output grid")
|
||||||
|
return self._field_map((int(grid_shape[0]), int(grid_shape[1])), mask)
|
||||||
|
|
||||||
|
def _field_map(self, grid_shape: tuple[int, int], physical_mask: np.ndarray) -> FieldGainMap:
|
||||||
|
key = (grid_shape, mask_sha256(physical_mask))
|
||||||
|
cached = self._field_maps.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
gain_map = build_field_gain_map(
|
||||||
|
self._points, self._gains, self._triangles,
|
||||||
|
self.coordinate_map, self.coordinate_valid_mask,
|
||||||
|
grid_shape, physical_mask,
|
||||||
|
)
|
||||||
|
if len(self._field_maps) >= 4:
|
||||||
|
self._field_maps.pop(next(iter(self._field_maps)))
|
||||||
|
self._field_maps[key] = gain_map
|
||||||
|
return gain_map
|
||||||
|
|
||||||
|
def _break_field_window(self) -> None:
|
||||||
|
"""An invalid frame breaks the consecutive declared unloaded window."""
|
||||||
|
if self._field_baseline is None:
|
||||||
|
self._field_zero_values = []
|
||||||
|
|
||||||
|
def _predict_field(self, frame_id: int, features: Mapping[str, Any]) -> CalibrationResult:
|
||||||
|
def invalid(reason: str, **diagnostics: Any) -> CalibrationResult:
|
||||||
|
diagnostics.setdefault("frame_id", frame_id)
|
||||||
|
return CalibrationResult({axis: None for axis in self.outputs},
|
||||||
|
{axis: reason for axis in self.outputs}, diagnostics)
|
||||||
|
|
||||||
|
try:
|
||||||
|
field = coerce_field(features.get("field"))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
self._break_field_window()
|
||||||
|
return invalid("invalid_input", error=str(exc))
|
||||||
|
mask = features.get("output_valid_mask")
|
||||||
|
try:
|
||||||
|
mask = np.asarray(mask, dtype=bool)
|
||||||
|
_require(mask.ndim == 2 and mask.shape == self.coordinate_map.shape[:2],
|
||||||
|
"output_valid_mask must match the coordinate map output grid")
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
self._break_field_window()
|
||||||
|
return invalid("invalid_input", error=str(exc))
|
||||||
|
try:
|
||||||
|
gain_map = self._field_map(field.grid_shape, mask)
|
||||||
|
reduction = reduce_field(field, gain_map)
|
||||||
|
except ValueError as exc:
|
||||||
|
self._break_field_window()
|
||||||
|
return invalid("coordinate_unmapped", error=str(exc))
|
||||||
|
diagnostics: dict[str, Any] = {
|
||||||
|
"model_fingerprint": self.fingerprint,
|
||||||
|
"mapping": dict(MAPPING_POLICY),
|
||||||
|
"extrapolated_fraction": reduction.extrapolated_fraction,
|
||||||
|
"coverage_fraction": reduction.coverage_fraction,
|
||||||
|
"area_scale": field.area_scale,
|
||||||
|
}
|
||||||
|
if self._field_baseline is None:
|
||||||
|
diagnostics["reference_id"] = self._field_reference_id
|
||||||
|
if self._field_reference_id is None:
|
||||||
|
diagnostics["pending_reference_frames"] = 0
|
||||||
|
return invalid("zero_unavailable", **diagnostics)
|
||||||
|
self._field_zero_values.append(reduction.weighted_N)
|
||||||
|
if len(self._field_zero_values) < ZERO_FRAME_COUNT:
|
||||||
|
diagnostics["pending_reference_frames"] = len(self._field_zero_values)
|
||||||
|
return invalid("zero_unavailable", **diagnostics)
|
||||||
|
self._field_baseline = float(np.median(self._field_zero_values))
|
||||||
|
self._field_zero_values = []
|
||||||
|
diagnostics["baseline_N"] = self._field_baseline
|
||||||
|
diagnostics["pending_reference_frames"] = 0
|
||||||
|
return invalid("zero_unavailable", **diagnostics)
|
||||||
|
diagnostics["baseline_N"] = self._field_baseline
|
||||||
|
value = reduction.weighted_N - self._field_baseline
|
||||||
|
low, high = self._params["valid_force_range_N"]
|
||||||
|
status = "ok" if low <= value <= high else "out_of_range"
|
||||||
|
return CalibrationResult({"fn": float(value)}, {"fn": status}, diagnostics)
|
||||||
|
|
||||||
def convert(self, fn: float, fx: float, fy: float) -> None:
|
def convert(self, fn: float, fx: float, fy: float) -> None:
|
||||||
"""Reject legacy scalar calls which lack frame, position and zero features."""
|
"""Reject legacy scalar calls which lack frame, position and zero features."""
|
||||||
@@ -245,6 +383,8 @@ class ModelCalibration:
|
|||||||
if (not isinstance(features, Mapping) or type(frame_id) is not int or
|
if (not isinstance(features, Mapping) or type(frame_id) is not int or
|
||||||
(timestamp_s is not None and not _number(timestamp_s))):
|
(timestamp_s is not None and not _number(timestamp_s))):
|
||||||
return invalid("invalid_input")
|
return invalid("invalid_input")
|
||||||
|
if self.model_type == "field_gain/1":
|
||||||
|
return self._predict_field(frame_id, features)
|
||||||
if features.get("zero_valid") is not True:
|
if features.get("zero_valid") is not True:
|
||||||
return invalid("zero_unavailable")
|
return invalid("zero_unavailable")
|
||||||
if features.get("contact_found") is False:
|
if features.get("contact_found") is False:
|
||||||
@@ -296,23 +436,43 @@ def parse_model(data: dict[str, Any], source_path: str | Path | None = None) ->
|
|||||||
_require(isinstance(data.get("provenance", {}), dict), "provenance must be an object")
|
_require(isinstance(data.get("provenance", {}), dict), "provenance must be an object")
|
||||||
model = data["model"]
|
model = data["model"]
|
||||||
_keys(model, {"type", "input_profile", "output_frame", "outputs", "params"}, {"artifacts"})
|
_keys(model, {"type", "input_profile", "output_frame", "outputs", "params"}, {"artifacts"})
|
||||||
_require(model["type"] in ("spatial_gain/1", "global_gain/1"), f"unsupported calibration model: {model['type']}")
|
_require(model["type"] in _PROFILE_FOR_TYPE,
|
||||||
spatial = model["type"] == "spatial_gain/1"
|
f"unsupported calibration model: {model['type']}")
|
||||||
_require(model["input_profile"] == ("fn_delta_autocal_uv/1" if spatial else "force_delta/1"),
|
model_type = model["type"]
|
||||||
|
spatial = model_type == "spatial_gain/1"
|
||||||
|
field_weighted = model_type == "field_gain/1"
|
||||||
|
_require(model["input_profile"] == _PROFILE_FOR_TYPE[model_type],
|
||||||
"unsupported input_profile for model")
|
"unsupported input_profile for model")
|
||||||
_require(model["output_frame"] == "sdk_force/1", "unsupported output_frame")
|
_require(model["output_frame"] == "sdk_force/1", "unsupported output_frame")
|
||||||
outputs = model["outputs"]
|
outputs = model["outputs"]
|
||||||
_require(isinstance(outputs, list) and bool(outputs) and all(a in ("fn", "fx", "fy") for a in outputs) and
|
_require(isinstance(outputs, list) and bool(outputs) and all(a in ("fn", "fx", "fy") for a in outputs) and
|
||||||
len(set(outputs)) == len(outputs), "invalid model outputs")
|
len(set(outputs)) == len(outputs), "invalid model outputs")
|
||||||
_require(not spatial or outputs == ["fn"], "spatial model supports fn only")
|
_require(not (spatial or field_weighted) or outputs == ["fn"], "spatial model supports fn only")
|
||||||
_pipeline(data["pipeline"], spatial)
|
_pipeline(data["pipeline"], model["input_profile"])
|
||||||
params = model["params"]
|
params = model["params"]
|
||||||
_keys(params, {"gains_N_per_raw", "valid_force_range_N"} | ({"points_uv", "triangles"} if spatial else set()))
|
required_params = {"gains_N_per_raw", "valid_force_range_N"}
|
||||||
|
if spatial or field_weighted:
|
||||||
|
required_params |= {"points_uv", "triangles"}
|
||||||
|
if field_weighted:
|
||||||
|
required_params |= {"mapping", "baseline"}
|
||||||
|
_keys(params, required_params)
|
||||||
force_range = params["valid_force_range_N"]
|
force_range = params["valid_force_range_N"]
|
||||||
_require(isinstance(force_range, list) and len(force_range) == 2 and
|
_require(isinstance(force_range, list) and len(force_range) == 2 and
|
||||||
all(_number(x) for x in force_range) and force_range[0] < force_range[1], "invalid force range")
|
all(_number(x) for x in force_range) and force_range[0] < force_range[1], "invalid force range")
|
||||||
if not spatial:
|
if not spatial:
|
||||||
gains = params["gains_N_per_raw"]
|
gains = params["gains_N_per_raw"]
|
||||||
_keys(gains, set(outputs))
|
if field_weighted:
|
||||||
_require(all(_number(g) and g > 0 for g in gains.values()), "gains must be finite positive numbers")
|
_require(isinstance(gains, list) and len(gains) == len(params["points_uv"]),
|
||||||
|
"field gains must have one value per support point")
|
||||||
|
else:
|
||||||
|
_keys(gains, set(outputs))
|
||||||
|
_require(all(_number(g) and g > 0 for g in gains.values()) if isinstance(gains, dict)
|
||||||
|
else all(_number(g) and g > 0 for g in gains),
|
||||||
|
"gains must be finite positive numbers")
|
||||||
|
if field_weighted:
|
||||||
|
_require(params["mapping"] == MAPPING_POLICY, "unsupported field mapping policy")
|
||||||
|
_require(params["baseline"] == BASELINE_POLICY, "unsupported field baseline policy")
|
||||||
|
artifacts = model.get("artifacts")
|
||||||
|
_require(isinstance(artifacts, list) and len(artifacts) == 1,
|
||||||
|
"field_gain/1 requires one coordinate_map artifact")
|
||||||
return ModelCalibration(data, source_path)
|
return ModelCalibration(data, source_path)
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
"""Field-weighted force estimation from a validated support mesh and HHD grid.
|
||||||
|
|
||||||
|
The SDK builds a gain map on the finger output grid, downsamples it by valid
|
||||||
|
area onto the HHD computation grid, and reduces the curl-free HHD component
|
||||||
|
with ``q = |d_x| + |d_y|``. The map is a deterministic function of the model
|
||||||
|
mesh, the coordinate artifact and the physical valid mask, so callers cache it
|
||||||
|
by those identities instead of rebuilding it every frame.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from collections import Counter
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
#: Closed identity of the field input profile. It is part of the exported and
|
||||||
|
#: runtime feature signature, so changing any value is a schema/profile change.
|
||||||
|
FIELD_PROFILE = "hhd_normal_l1_field/1"
|
||||||
|
FIELD_IDENTITY: dict[str, Any] = {
|
||||||
|
"grid": "hhd_native",
|
||||||
|
"shape": [None, None, 2],
|
||||||
|
"mask_shape": [None, None],
|
||||||
|
"vector": "irrotational",
|
||||||
|
"reduction": "l1",
|
||||||
|
"dtype": "float32",
|
||||||
|
"unit": "raw",
|
||||||
|
"mask": "physical_valid",
|
||||||
|
"lifecycle": "same_frame",
|
||||||
|
"area_compensation": "reference_grid",
|
||||||
|
}
|
||||||
|
#: How a gain is read inside and outside the support hull. Deterministic on
|
||||||
|
#: purpose: equal-distance points choose the lowest sorted boundary edge.
|
||||||
|
MAPPING_POLICY: dict[str, Any] = {
|
||||||
|
"inside": "barycentric",
|
||||||
|
"outside": "boundary_projection",
|
||||||
|
"tie_break": "boundary_edge_order",
|
||||||
|
}
|
||||||
|
#: Session baseline reduction; the caller declares the unloaded window.
|
||||||
|
BASELINE_POLICY: dict[str, Any] = {"frames": 30, "statistic": "median"}
|
||||||
|
ZERO_FRAME_COUNT = int(BASELINE_POLICY["frames"])
|
||||||
|
|
||||||
|
_INSIDE_TOLERANCE = 1e-10
|
||||||
|
_GAIN_TOLERANCE = 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def _require(condition: bool, message: str) -> None:
|
||||||
|
if not condition:
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FieldFrame:
|
||||||
|
"""One frame of the HHD curl-free field on the native computation grid."""
|
||||||
|
|
||||||
|
d: np.ndarray
|
||||||
|
valid_mask: np.ndarray
|
||||||
|
area_scale: float
|
||||||
|
grid_shape: tuple[int, int]
|
||||||
|
reference_shape: tuple[int, int] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FieldGainMap:
|
||||||
|
"""Area-averaged gains on the HHD grid, with coverage as an area fraction.
|
||||||
|
|
||||||
|
``coverage`` is the fraction of each cell's pixels that carry a mapped
|
||||||
|
coordinate and a gain; ``inside`` is the fraction whose gain came from
|
||||||
|
barycentric interpolation. ``coverage - inside`` is the boundary-projected
|
||||||
|
(extrapolated) fraction used for the per-frame quality diagnostic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
grid_shape: tuple[int, int]
|
||||||
|
gain: np.ndarray
|
||||||
|
coverage: np.ndarray
|
||||||
|
inside: np.ndarray
|
||||||
|
physical_mask_sha256: str
|
||||||
|
|
||||||
|
def diagnostic(self) -> dict[str, float]:
|
||||||
|
return {
|
||||||
|
"coverage": float(self.coverage.mean()),
|
||||||
|
"inside": float(self.inside.mean()),
|
||||||
|
"extrapolated": float(np.clip(self.coverage - self.inside, 0.0, 1.0).mean()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FieldReduction:
|
||||||
|
"""Weighted field sum and its per-frame extrapolation diagnostic."""
|
||||||
|
|
||||||
|
weighted_N: float
|
||||||
|
extrapolated_fraction: float
|
||||||
|
coverage_fraction: float
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_field(value: Any) -> FieldFrame:
|
||||||
|
"""Validate the same-frame field payload published by ``hhd/api.py``."""
|
||||||
|
_require(isinstance(value, Mapping), "field input must be a mapping")
|
||||||
|
d = value.get("d")
|
||||||
|
mask = value.get("valid_mask")
|
||||||
|
area_scale = value.get("area_scale")
|
||||||
|
grid_shape = value.get("grid_shape")
|
||||||
|
reference_shape = value.get("reference_shape")
|
||||||
|
_require(isinstance(d, np.ndarray) and d.ndim == 3 and d.shape[2] == 2,
|
||||||
|
"field d must be an (H, W, 2) array")
|
||||||
|
_require(d.dtype == np.float32, "field d dtype must be float32")
|
||||||
|
_require(isinstance(mask, np.ndarray) and mask.dtype == np.bool_ and mask.shape == d.shape[:2],
|
||||||
|
"field valid_mask must be an (H, W) bool array")
|
||||||
|
_require(isinstance(area_scale, (int, float)) and not isinstance(area_scale, bool)
|
||||||
|
and np.isfinite(area_scale) and area_scale > 0,
|
||||||
|
"field area_scale must be finite and positive")
|
||||||
|
_require(isinstance(grid_shape, tuple) and len(grid_shape) == 2
|
||||||
|
and all(type(size) is int and size > 0 for size in grid_shape),
|
||||||
|
"field grid_shape must be two positive integers")
|
||||||
|
_require(tuple(grid_shape) == tuple(d.shape[:2]), "field grid_shape does not match d")
|
||||||
|
if reference_shape is not None:
|
||||||
|
_require(isinstance(reference_shape, tuple) and len(reference_shape) == 2
|
||||||
|
and all(type(size) is int and size > 0 for size in reference_shape),
|
||||||
|
"field reference_shape must be two positive integers")
|
||||||
|
if not np.isfinite(d[mask]).all():
|
||||||
|
raise ValueError("field contains non-finite active vectors")
|
||||||
|
return FieldFrame(
|
||||||
|
d=d,
|
||||||
|
valid_mask=mask,
|
||||||
|
area_scale=float(area_scale),
|
||||||
|
grid_shape=(int(grid_shape[0]), int(grid_shape[1])),
|
||||||
|
reference_shape=None if reference_shape is None else (int(reference_shape[0]),
|
||||||
|
int(reference_shape[1])),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mask_sha256(mask: np.ndarray) -> str:
|
||||||
|
"""Stable identity of a boolean mask for cache keys."""
|
||||||
|
packed = np.packbits(np.ascontiguousarray(mask, dtype=bool))
|
||||||
|
return hashlib.sha256(packed.tobytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _boundary_edges(triangles: np.ndarray) -> list[tuple[int, int]]:
|
||||||
|
"""Hull edges of a validated complete triangulation, in a fixed order."""
|
||||||
|
counts: Counter[tuple[int, int]] = Counter()
|
||||||
|
for triangle in triangles:
|
||||||
|
indices = [int(index) for index in triangle]
|
||||||
|
for start, end in ((0, 1), (1, 2), (2, 0)):
|
||||||
|
edge = tuple(sorted((indices[start], indices[end])))
|
||||||
|
counts[edge] += 1
|
||||||
|
boundary = [edge for edge, count in counts.items() if count == 1]
|
||||||
|
boundary.sort()
|
||||||
|
_require(bool(boundary), "support mesh has no boundary edges")
|
||||||
|
return boundary
|
||||||
|
|
||||||
|
|
||||||
|
def _inside_gain(points: np.ndarray, gains: np.ndarray, triangles: np.ndarray,
|
||||||
|
uv: np.ndarray, pending: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Barycentric gain for pending pixels, first matching triangle in file order."""
|
||||||
|
gain = np.full(len(uv), np.nan)
|
||||||
|
inside = np.zeros(len(uv), dtype=bool)
|
||||||
|
for triangle in triangles:
|
||||||
|
candidate = ~inside & pending
|
||||||
|
if not candidate.any():
|
||||||
|
break
|
||||||
|
p0, p1, p2 = (points[int(index)] for index in triangle)
|
||||||
|
query = uv[candidate]
|
||||||
|
v0, v1 = p1 - p0, p2 - p0
|
||||||
|
det = float(v0[0] * v1[1] - v1[0] * v0[1])
|
||||||
|
_require(abs(det) > 0, "support mesh has a degenerate triangle")
|
||||||
|
offset = query - p0
|
||||||
|
w1 = (offset[:, 0] * v1[1] - offset[:, 1] * v1[0]) / det
|
||||||
|
w2 = (v0[0] * offset[:, 1] - v0[1] * offset[:, 0]) / det
|
||||||
|
weights = np.stack((1.0 - w1 - w2, w1, w2), axis=1)
|
||||||
|
hit = weights.min(axis=1) >= -_INSIDE_TOLERANCE
|
||||||
|
if not hit.any():
|
||||||
|
continue
|
||||||
|
hit_indices = np.flatnonzero(candidate)[hit]
|
||||||
|
gain[hit_indices] = (
|
||||||
|
weights[hit, 0] * gains[int(triangle[0])]
|
||||||
|
+ weights[hit, 1] * gains[int(triangle[1])]
|
||||||
|
+ weights[hit, 2] * gains[int(triangle[2])]
|
||||||
|
)
|
||||||
|
inside[hit_indices] = True
|
||||||
|
return gain, inside
|
||||||
|
|
||||||
|
|
||||||
|
def _boundary_gain(points: np.ndarray, gains: np.ndarray, triangles: np.ndarray,
|
||||||
|
uv: np.ndarray, pending: np.ndarray) -> np.ndarray:
|
||||||
|
"""Project pending points onto the nearest hull edge and interpolate its gains.
|
||||||
|
|
||||||
|
Equal distances keep the first edge in sorted ``(index, index)`` order, so
|
||||||
|
the result does not depend on triangle storage order. The projection
|
||||||
|
parameter is clamped to the segment; gains stay inside the support range.
|
||||||
|
"""
|
||||||
|
gain = np.full(len(uv), np.nan)
|
||||||
|
indices = np.flatnonzero(pending)
|
||||||
|
if indices.size == 0:
|
||||||
|
return gain
|
||||||
|
query = uv[indices]
|
||||||
|
best_distance = np.full(len(indices), np.inf)
|
||||||
|
for start, end in _boundary_edges(triangles):
|
||||||
|
first, second = points[start], points[end]
|
||||||
|
direction = second - first
|
||||||
|
denominator = float(np.dot(direction, direction))
|
||||||
|
_require(denominator > 0, "support mesh has a zero-length boundary edge")
|
||||||
|
parameter = np.clip(((query - first) @ direction) / denominator, 0.0, 1.0)
|
||||||
|
projected = first + parameter[:, None] * direction
|
||||||
|
distance = np.sum((query - projected) ** 2, axis=1)
|
||||||
|
better = distance < best_distance - 1e-12
|
||||||
|
best_distance[better] = distance[better]
|
||||||
|
gain[indices[better]] = gains[start] + parameter[better] * (gains[end] - gains[start])
|
||||||
|
_require(np.isfinite(gain[indices]).all(), "support mesh boundary projection failed")
|
||||||
|
return gain
|
||||||
|
|
||||||
|
|
||||||
|
def _block_starts(divisions: int, size: int) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
starts = np.array([index * size // divisions for index in range(divisions)], dtype=np.intp)
|
||||||
|
ends = np.array([(index + 1) * size // divisions for index in range(divisions)], dtype=np.intp)
|
||||||
|
return starts, ends
|
||||||
|
|
||||||
|
|
||||||
|
def build_field_gain_map(points: np.ndarray, gains: np.ndarray, triangles: np.ndarray,
|
||||||
|
coordinate_map: np.ndarray, coordinate_valid_mask: np.ndarray,
|
||||||
|
grid_shape: tuple[int, int],
|
||||||
|
physical_mask: np.ndarray) -> FieldGainMap:
|
||||||
|
"""Build the gain/coverage map for one output mask and one HHD grid shape.
|
||||||
|
|
||||||
|
Raises :class:`ValueError` when the physical mask reaches pixels that the
|
||||||
|
model's coordinate artifact does not map; that is a model mapping error and
|
||||||
|
must not silently become a zero or an extrapolated gain.
|
||||||
|
"""
|
||||||
|
_require(isinstance(points, np.ndarray) and points.ndim == 2 and points.shape[1] == 2,
|
||||||
|
"support points must be an (N, 2) array")
|
||||||
|
_require(isinstance(gains, np.ndarray) and gains.shape == (len(points),),
|
||||||
|
"support gains must have one value per point")
|
||||||
|
_require(isinstance(triangles, np.ndarray) and triangles.ndim == 2 and triangles.shape[1] == 3,
|
||||||
|
"support triangles must be an (T, 3) array")
|
||||||
|
_require(isinstance(coordinate_map, np.ndarray) and coordinate_map.ndim == 3
|
||||||
|
and coordinate_map.shape[2] == 2, "coordinate map must be an (H, W, 2) array")
|
||||||
|
_require(isinstance(coordinate_valid_mask, np.ndarray)
|
||||||
|
and coordinate_valid_mask.dtype == np.bool_
|
||||||
|
and coordinate_valid_mask.shape == coordinate_map.shape[:2],
|
||||||
|
"coordinate valid mask must be an (H, W) bool array")
|
||||||
|
output_shape = tuple(int(size) for size in coordinate_map.shape[:2])
|
||||||
|
_require(isinstance(grid_shape, tuple) and len(grid_shape) == 2
|
||||||
|
and all(type(size) is int and size > 0 for size in grid_shape),
|
||||||
|
"grid shape must be two positive integers")
|
||||||
|
_require(grid_shape[0] <= output_shape[0] and grid_shape[1] <= output_shape[1],
|
||||||
|
"HHD grid cannot exceed the output grid")
|
||||||
|
_require(isinstance(physical_mask, np.ndarray) and physical_mask.dtype == np.bool_
|
||||||
|
and physical_mask.shape == output_shape,
|
||||||
|
"physical mask must match the output grid")
|
||||||
|
if np.any(physical_mask & ~coordinate_valid_mask):
|
||||||
|
raise ValueError("model mapping error: physical mask has pixels without mapped coordinates")
|
||||||
|
|
||||||
|
uv = np.asarray(coordinate_map[physical_mask], dtype=np.float64).reshape(-1, 2)
|
||||||
|
_require(np.isfinite(uv).all(), "model mapping error: mapped coordinates are not finite")
|
||||||
|
pixels = int(uv.shape[0])
|
||||||
|
gain_flat = np.full(pixels, np.nan)
|
||||||
|
inside_flat = np.zeros(pixels, dtype=bool)
|
||||||
|
pending = np.ones(pixels, dtype=bool)
|
||||||
|
if pixels:
|
||||||
|
inside_gain, inside_flat = _inside_gain(points, gains, triangles, uv, pending)
|
||||||
|
gain_flat[inside_flat] = inside_gain[inside_flat]
|
||||||
|
pending = ~inside_flat
|
||||||
|
if pending.any():
|
||||||
|
boundary_gain = _boundary_gain(points, gains, triangles, uv, pending)
|
||||||
|
gain_flat[pending] = boundary_gain[pending]
|
||||||
|
low, high = float(gains.min()), float(gains.max())
|
||||||
|
_require(np.isfinite(gain_flat).all(), "gain map contains unmapped valid pixels")
|
||||||
|
_require(np.all(gain_flat >= low - _GAIN_TOLERANCE)
|
||||||
|
and np.all(gain_flat <= high + _GAIN_TOLERANCE),
|
||||||
|
"interpolated gain outside the support range")
|
||||||
|
|
||||||
|
valid_flat = np.ones(pixels, dtype=np.float64)
|
||||||
|
inside_values = inside_flat.astype(np.float64)
|
||||||
|
gain_values = gain_flat
|
||||||
|
|
||||||
|
def _reduce(values: np.ndarray) -> np.ndarray:
|
||||||
|
grid = np.zeros(output_shape, dtype=np.float64)
|
||||||
|
grid[physical_mask] = values
|
||||||
|
rows, _ = _block_starts(grid_shape[0], output_shape[0])
|
||||||
|
row_blocks = np.add.reduceat(grid, rows, axis=0)
|
||||||
|
cols, _ = _block_starts(grid_shape[1], output_shape[1])
|
||||||
|
return np.add.reduceat(row_blocks, cols, axis=1)
|
||||||
|
|
||||||
|
area = _reduce(valid_flat)
|
||||||
|
gain_sum = _reduce(gain_values)
|
||||||
|
inside_area = _reduce(inside_values)
|
||||||
|
row_starts, row_ends = _block_starts(grid_shape[0], output_shape[0])
|
||||||
|
col_starts, col_ends = _block_starts(grid_shape[1], output_shape[1])
|
||||||
|
block_area = np.outer(row_ends - row_starts, col_ends - col_starts).astype(np.float64)
|
||||||
|
_require(np.all(block_area > 0), "HHD grid cells cannot be empty")
|
||||||
|
coverage = area / block_area
|
||||||
|
inside = inside_area / block_area
|
||||||
|
gain = np.divide(gain_sum, area, out=np.zeros_like(gain_sum), where=area > 0)
|
||||||
|
_require(np.isfinite(gain).all() and np.isfinite(coverage).all(),
|
||||||
|
"field gain map reduction failed")
|
||||||
|
coverage.setflags(write=False)
|
||||||
|
inside.setflags(write=False)
|
||||||
|
gain.setflags(write=False)
|
||||||
|
return FieldGainMap(
|
||||||
|
grid_shape=(int(grid_shape[0]), int(grid_shape[1])),
|
||||||
|
gain=gain.astype(np.float64),
|
||||||
|
coverage=coverage,
|
||||||
|
inside=inside,
|
||||||
|
physical_mask_sha256=mask_sha256(physical_mask),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reduce_field(field: FieldFrame, gain_map: FieldGainMap) -> FieldReduction:
|
||||||
|
"""Apply ``P = A * sum(gain * (|d_x| + |d_y|))`` over active HHD cells.
|
||||||
|
|
||||||
|
The HHD raw aggregate is ``A * sum(active * q)``; multiplying the same sum by
|
||||||
|
a constant gain must therefore give exactly ``gain * raw``. ``coverage`` is
|
||||||
|
diagnostic-only here: folding it into ``P`` would discount a partially covered
|
||||||
|
mask-edge cell that the raw aggregate still counts in full. An active cell with
|
||||||
|
no mapped output pixel has no valid gain, so it is reported as a model mapping
|
||||||
|
error instead of silently contributing zero.
|
||||||
|
|
||||||
|
``area_scale`` comes from ``hhd/api.py``'s existing reference-grid
|
||||||
|
compensation, so the caller must not apply another area factor.
|
||||||
|
"""
|
||||||
|
_require(field.grid_shape == gain_map.grid_shape,
|
||||||
|
"field grid shape does not match the gain map; rebuild the cache")
|
||||||
|
d = np.asarray(field.d, dtype=np.float64)
|
||||||
|
active = np.asarray(field.valid_mask, dtype=bool)
|
||||||
|
q = np.where(active, np.abs(d[:, :, 0]) + np.abs(d[:, :, 1]), 0.0)
|
||||||
|
q = np.where(np.isfinite(q), q, 0.0)
|
||||||
|
if np.any(active & (gain_map.coverage <= 0.0)):
|
||||||
|
raise ValueError("model mapping error: active HHD cells have no mapped pixels")
|
||||||
|
total = float(np.sum(gain_map.gain * q) * field.area_scale)
|
||||||
|
coverage_mass = q * gain_map.coverage
|
||||||
|
source_sum = float(np.sum(q))
|
||||||
|
coverage_sum = float(np.sum(coverage_mass))
|
||||||
|
if source_sum > 0.0:
|
||||||
|
extrapolated = float(np.sum(q * np.clip(gain_map.coverage - gain_map.inside, 0.0, 1.0))
|
||||||
|
/ source_sum)
|
||||||
|
extrapolated = float(np.clip(extrapolated, 0.0, 1.0))
|
||||||
|
coverage_fraction = float(np.clip(coverage_sum / source_sum, 0.0, 1.0))
|
||||||
|
else:
|
||||||
|
extrapolated = 0.0
|
||||||
|
coverage_fraction = 0.0
|
||||||
|
_require(np.isfinite(total), "weighted field sum is not finite")
|
||||||
|
return FieldReduction(
|
||||||
|
weighted_N=total,
|
||||||
|
extrapolated_fraction=extrapolated,
|
||||||
|
coverage_fraction=coverage_fraction,
|
||||||
|
)
|
||||||
@@ -14,6 +14,7 @@ from ..capture import (
|
|||||||
open_capture,
|
open_capture,
|
||||||
)
|
)
|
||||||
from ..configs import load_config
|
from ..configs import load_config
|
||||||
|
from ..field_calibration import FIELD_IDENTITY, FIELD_PROFILE
|
||||||
from ..frame_processors import FrameUnwarper
|
from ..frame_processors import FrameUnwarper
|
||||||
from ..sensor import Sensor
|
from ..sensor import Sensor
|
||||||
from ..unroll import (
|
from ..unroll import (
|
||||||
@@ -326,9 +327,10 @@ class FingerRuntime:
|
|||||||
|
|
||||||
model = sensor.calibration
|
model = sensor.calibration
|
||||||
uv_map = getattr(model, "coordinate_map", None)
|
uv_map = getattr(model, "coordinate_map", None)
|
||||||
if model.input_profile == "fn_delta_autocal_uv/1":
|
if model.input_profile in ("fn_delta_autocal_uv/1", FIELD_PROFILE):
|
||||||
if uv_map is None or uv_map.shape[:2] != (geometry.out_height, geometry.out_width):
|
if uv_map is None or uv_map.shape[:2] != (geometry.out_height, geometry.out_width):
|
||||||
raise ValueError("calibration coordinate map does not match finger output shape")
|
raise ValueError("calibration coordinate map does not match finger output shape")
|
||||||
|
field_identity = FIELD_IDENTITY if model.input_profile == FIELD_PROFILE else None
|
||||||
sensor.bind_calibration_context(build_context(
|
sensor.bind_calibration_context(build_context(
|
||||||
sensor._config, sensor.filters_state(),
|
sensor._config, sensor.filters_state(),
|
||||||
geometry_sha256=sha256_file(mesh_path), source_scale=geometry.source_scale,
|
geometry_sha256=sha256_file(mesh_path), source_scale=geometry.source_scale,
|
||||||
@@ -336,6 +338,7 @@ class FingerRuntime:
|
|||||||
output_domain_sha256=domain_fingerprint(domain),
|
output_domain_sha256=domain_fingerprint(domain),
|
||||||
processing_backend="uv_unroll",
|
processing_backend="uv_unroll",
|
||||||
preprocessing={"flip_source_vertical": flip_source_vertical, "mirror": mirror},
|
preprocessing={"flip_source_vertical": flip_source_vertical, "mirror": mirror},
|
||||||
|
field=field_identity,
|
||||||
))
|
))
|
||||||
|
|
||||||
capture_config = CaptureConfig.from_camera_dict(
|
capture_config = CaptureConfig.from_camera_dict(
|
||||||
@@ -399,19 +402,25 @@ class FingerRuntime:
|
|||||||
selected = cal_mod.validate_for_device(selected, "finger", serial)
|
selected = cal_mod.validate_for_device(selected, "finger", serial)
|
||||||
context = None
|
context = None
|
||||||
if getattr(selected, "schema_version", None) == 3:
|
if getattr(selected, "schema_version", None) == 3:
|
||||||
if selected.input_profile == "fn_delta_autocal_uv/1":
|
if selected.input_profile in ("fn_delta_autocal_uv/1", FIELD_PROFILE):
|
||||||
uv_map = selected.coordinate_map
|
uv_map = selected.coordinate_map
|
||||||
if uv_map is None or uv_map.shape[:2] != (self.geometry.out_height, self.geometry.out_width):
|
if uv_map is None or uv_map.shape[:2] != (self.geometry.out_height, self.geometry.out_width):
|
||||||
raise ValueError("calibration coordinate map does not match finger output shape")
|
raise ValueError("calibration coordinate map does not match finger output shape")
|
||||||
|
field_identity = FIELD_IDENTITY if selected.input_profile == FIELD_PROFILE else None
|
||||||
context = build_context(
|
context = build_context(
|
||||||
self.geometry.config, sensor.filters_state(),
|
self.geometry.config, sensor.filters_state(),
|
||||||
geometry_sha256=sha256_file(self.geometry.export_paths.mesh),
|
geometry_sha256=sha256_file(self.geometry.export_paths.mesh),
|
||||||
source_scale=self.geometry.source_scale, coordinates=selected.pipeline.get("coordinates"),
|
source_scale=self.geometry.source_scale, coordinates=selected.pipeline.get("coordinates"),
|
||||||
output_domain_sha256=domain_fingerprint(self.domain), processing_backend="uv_unroll",
|
output_domain_sha256=domain_fingerprint(self.domain), processing_backend="uv_unroll",
|
||||||
preprocessing={"flip_source_vertical": self.frame_processor.flip_source_vertical,
|
preprocessing={"flip_source_vertical": self.frame_processor.flip_source_vertical,
|
||||||
"mirror": sensor.mirror})
|
"mirror": sensor.mirror},
|
||||||
|
field=field_identity)
|
||||||
sensor.set_calibration(selected, context=context)
|
sensor.set_calibration(selected, context=context)
|
||||||
|
|
||||||
|
def declare_unloaded_reference(self, reference_id):
|
||||||
|
"""Declare the unloaded window for a field model; the SDK medians 30 weighted frames."""
|
||||||
|
self.tactile.raw_sensor.declare_unloaded_reference(reference_id=reference_id)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def calibration_status(self):
|
def calibration_status(self):
|
||||||
"""Copy the current per-axis calibration validity state."""
|
"""Copy the current per-axis calibration validity state."""
|
||||||
|
|||||||
@@ -155,6 +155,37 @@ class nHHD:
|
|||||||
return self.d
|
return self.d
|
||||||
return cp.asnumpy(self._d_gpu)
|
return cp.asnumpy(self._d_gpu)
|
||||||
|
|
||||||
|
def get_field(self, valid_mask=None):
|
||||||
|
"""Curl-free HHD component and its reference-grid area scale for one frame.
|
||||||
|
|
||||||
|
The SDK calibration layer owns the field *profile* identity; this module
|
||||||
|
only publishes the numerical payload. It belongs to the frame that was
|
||||||
|
just decomposed: the next ``decompose`` call overwrites the solver's
|
||||||
|
arrays, so callers copy whatever they keep.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with ``d`` (float32 HxWx2 copy), ``valid_mask`` (HxW bool),
|
||||||
|
``area_scale`` (reference pixels / current pixels, the same factor
|
||||||
|
``get_force`` already applies), ``grid_shape`` and ``reference_shape``.
|
||||||
|
"""
|
||||||
|
direction = getattr(self, 'd', None)
|
||||||
|
if direction is None:
|
||||||
|
raise ValueError("field requested before decompose")
|
||||||
|
shape = direction.shape[:2]
|
||||||
|
mask = self._normalize_mask(valid_mask, shape) if valid_mask is not None else None
|
||||||
|
field = numpy.array(direction, dtype=numpy.float32, copy=True)
|
||||||
|
if mask is not None:
|
||||||
|
field[~mask] = 0.0
|
||||||
|
area_scale, _ = self._force_scale(shape)
|
||||||
|
reference = self._force_ref_shape or shape
|
||||||
|
return {
|
||||||
|
'd': field,
|
||||||
|
'valid_mask': numpy.ones(shape, dtype=bool) if mask is None else mask.copy(),
|
||||||
|
'area_scale': float(area_scale),
|
||||||
|
'grid_shape': (int(shape[0]), int(shape[1])),
|
||||||
|
'reference_shape': (int(reference[0]), int(reference[1])),
|
||||||
|
}
|
||||||
|
|
||||||
def _solve_poisson_backend(self, field, verbose=False, valid_mask=None):
|
def _solve_poisson_backend(self, field, verbose=False, valid_mask=None):
|
||||||
"""Dispatch Poisson solve using the selected backend.
|
"""Dispatch Poisson solve using the selected backend.
|
||||||
|
|
||||||
|
|||||||
+52
-9
@@ -17,12 +17,13 @@ import numpy as np
|
|||||||
from . import calibration as cal_mod
|
from . import calibration as cal_mod
|
||||||
from . import configs
|
from . import configs
|
||||||
from .backend_availability import check_cupy_available, check_opencl_available
|
from .backend_availability import check_cupy_available, check_opencl_available
|
||||||
from .force_readout import (
|
# Explicit re-exports: ``calibration.py`` resolves named presets through this
|
||||||
FILTER_PRESETS,
|
# module and the preset tests import the table from here.
|
||||||
ForceReadout,
|
from .force_readout import ForceReadout
|
||||||
available_filter_presets,
|
from .force_readout import FILTER_PRESETS as FILTER_PRESETS
|
||||||
)
|
from .force_readout import available_filter_presets as available_filter_presets
|
||||||
from .frame_processors import create_frame_processor
|
from .frame_processors import create_frame_processor
|
||||||
|
from .field_calibration import FIELD_PROFILE
|
||||||
from .get_depth import Contact3D
|
from .get_depth import Contact3D
|
||||||
from .hhd import nHHD
|
from .hhd import nHHD
|
||||||
|
|
||||||
@@ -790,7 +791,13 @@ class Sensor:
|
|||||||
if self._uses_calibration_model():
|
if self._uses_calibration_model():
|
||||||
# Spatial inference must use this HHD frame's contact, even when the
|
# Spatial inference must use this HHD frame's contact, even when the
|
||||||
# caller did not request contact diagnostics from StreamPipeline.
|
# caller did not request contact diagnostics from StreamPipeline.
|
||||||
if self.calibration.input_profile == "fn_delta_autocal_uv/1":
|
# The field model needs no locator: it consumes this frame's field.
|
||||||
|
if self.calibration.input_profile == FIELD_PROFILE:
|
||||||
|
self.apply_calibration_frame(
|
||||||
|
frame_id=self._model_frame_id,
|
||||||
|
field=self.__hhd.get_field(valid_mask=mask_ds),
|
||||||
|
)
|
||||||
|
elif self.calibration.input_profile == "fn_delta_autocal_uv/1":
|
||||||
self.compute_contact()
|
self.compute_contact()
|
||||||
else:
|
else:
|
||||||
self.apply_calibration_frame(frame_id=self._model_frame_id)
|
self.apply_calibration_frame(frame_id=self._model_frame_id)
|
||||||
@@ -809,7 +816,9 @@ class Sensor:
|
|||||||
"""
|
"""
|
||||||
if self._uses_calibration_model() and not self.compute_deformation_valid:
|
if self._uses_calibration_model() and not self.compute_deformation_valid:
|
||||||
raise cal_mod.CalibrationError("compute current deformation before schema 3 contact")
|
raise cal_mod.CalibrationError("compute current deformation before schema 3 contact")
|
||||||
if (self._uses_calibration_model() and self.compute_contact_valid
|
if (self._uses_calibration_model()
|
||||||
|
and self.calibration.input_profile == "fn_delta_autocal_uv/1"
|
||||||
|
and self.compute_contact_valid
|
||||||
and self._calibration_result_frame == self._model_frame_id):
|
and self._calibration_result_frame == self._model_frame_id):
|
||||||
return self._model_contact_result
|
return self._model_contact_result
|
||||||
multiple_foe = False
|
multiple_foe = False
|
||||||
@@ -845,7 +854,8 @@ class Sensor:
|
|||||||
print("Warning: contact centroid lies outside the valid UV mask.")
|
print("Warning: contact centroid lies outside the valid UV mask.")
|
||||||
|
|
||||||
self.compute_contact_valid = True
|
self.compute_contact_valid = True
|
||||||
if self._uses_calibration_model():
|
if (self._uses_calibration_model()
|
||||||
|
and self.calibration.input_profile == "fn_delta_autocal_uv/1"):
|
||||||
self._model_contact_result = result
|
self._model_contact_result = result
|
||||||
uv = self._calibration_contact_uv()
|
uv = self._calibration_contact_uv()
|
||||||
self.apply_calibration_frame(
|
self.apply_calibration_frame(
|
||||||
@@ -1290,6 +1300,12 @@ class Sensor:
|
|||||||
|
|
||||||
def set_calibration_reference(self, raw, *, reference_id):
|
def set_calibration_reference(self, raw, *, reference_id):
|
||||||
"""Declare a measured unloaded raw reference for this stream, not the model file."""
|
"""Declare a measured unloaded raw reference for this stream, not the model file."""
|
||||||
|
if (self._uses_calibration_model()
|
||||||
|
and self.calibration.input_profile == FIELD_PROFILE):
|
||||||
|
raise cal_mod.CalibrationError(
|
||||||
|
"field model needs a declared weighted unloaded window, not a scalar zero; "
|
||||||
|
"use declare_unloaded_reference()"
|
||||||
|
)
|
||||||
if not isinstance(raw, dict) or not reference_id:
|
if not isinstance(raw, dict) or not reference_id:
|
||||||
raise ValueError("raw must map axes to finite values and reference_id is required")
|
raise ValueError("raw must map axes to finite values and reference_id is required")
|
||||||
values = {}
|
values = {}
|
||||||
@@ -1303,6 +1319,26 @@ class Sensor:
|
|||||||
if self._uses_calibration_model():
|
if self._uses_calibration_model():
|
||||||
self.calibration.reset(self._calibration_reference_id)
|
self.calibration.reset(self._calibration_reference_id)
|
||||||
|
|
||||||
|
def declare_unloaded_reference(self, *, reference_id):
|
||||||
|
"""Start the declared unloaded window for a field model.
|
||||||
|
|
||||||
|
The model medians the next 30 consecutive valid weighted field sums;
|
||||||
|
until then every frame reports ``zero_unavailable``. Contact location is
|
||||||
|
deliberately not consulted, and the scalar raw reference path stays
|
||||||
|
rejected for this profile.
|
||||||
|
"""
|
||||||
|
if not self._uses_calibration_model():
|
||||||
|
raise cal_mod.CalibrationError("unloaded reference requires a schema 3 calibration")
|
||||||
|
if self.calibration.input_profile != FIELD_PROFILE:
|
||||||
|
raise cal_mod.CalibrationError(
|
||||||
|
"weighted unloaded reference requires hhd_normal_l1_field/1")
|
||||||
|
if not isinstance(reference_id, str) or not reference_id.strip():
|
||||||
|
raise ValueError("reference_id must be a non-empty string")
|
||||||
|
self._reset_calibration_session()
|
||||||
|
self._calibration_reference = {}
|
||||||
|
self._calibration_reference_id = str(reference_id)
|
||||||
|
self.calibration.begin_reference(str(reference_id))
|
||||||
|
|
||||||
def _calibration_contact_uv(self):
|
def _calibration_contact_uv(self):
|
||||||
if not self.centroid_found:
|
if not self.centroid_found:
|
||||||
return None
|
return None
|
||||||
@@ -1320,8 +1356,9 @@ class Sensor:
|
|||||||
def apply_calibration_frame(
|
def apply_calibration_frame(
|
||||||
self, *, frame_id, timestamp_s=None, raw=None, uv=None,
|
self, *, frame_id, timestamp_s=None, raw=None, uv=None,
|
||||||
contact_found=None, mapping_valid=False, single_contact=True,
|
contact_found=None, mapping_valid=False, single_contact=True,
|
||||||
|
field=None, output_mask=None,
|
||||||
):
|
):
|
||||||
"""Infer once from an unfiltered raw triple and same-frame optional contact UV."""
|
"""Infer once from an unfiltered raw triple, contact UV or same-frame field."""
|
||||||
if not self._uses_calibration_model():
|
if not self._uses_calibration_model():
|
||||||
raise cal_mod.CalibrationError("apply_calibration_frame requires schema 3")
|
raise cal_mod.CalibrationError("apply_calibration_frame requires schema 3")
|
||||||
if self._calibration_context is None:
|
if self._calibration_context is None:
|
||||||
@@ -1353,6 +1390,12 @@ class Sensor:
|
|||||||
}
|
}
|
||||||
if contact_found is not None:
|
if contact_found is not None:
|
||||||
features["contact_found"] = bool(contact_found)
|
features["contact_found"] = bool(contact_found)
|
||||||
|
if field is not None:
|
||||||
|
features["field"] = field
|
||||||
|
mask = self.valid_mask if output_mask is None else output_mask
|
||||||
|
if mask is None:
|
||||||
|
mask = np.ones(self.calibration.coordinate_map.shape[:2], dtype=bool)
|
||||||
|
features["output_valid_mask"] = np.asarray(mask, dtype=bool)
|
||||||
result = self.calibration.predict(
|
result = self.calibration.predict(
|
||||||
frame_id=frame_id, timestamp_s=timestamp_s,
|
frame_id=frame_id, timestamp_s=timestamp_s,
|
||||||
features=features, reference_id=self._calibration_reference_id,
|
features=features, reference_id=self._calibration_reference_id,
|
||||||
|
|||||||
@@ -0,0 +1,464 @@
|
|||||||
|
"""Field gain model: gain mapping, weighted reduction and unloaded baseline."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from orisys.calibration import CalibrationError, parse, validate_for_device
|
||||||
|
from orisys.calibration_context import build_context
|
||||||
|
from orisys.calibration_models import parse_model
|
||||||
|
from orisys.field_calibration import (
|
||||||
|
BASELINE_POLICY,
|
||||||
|
FIELD_IDENTITY,
|
||||||
|
MAPPING_POLICY,
|
||||||
|
build_field_gain_map,
|
||||||
|
coerce_field,
|
||||||
|
reduce_field,
|
||||||
|
)
|
||||||
|
|
||||||
|
FULL_SQUARE = [[0.0, 0.0], [3.0, 0.0], [0.0, 3.0], [3.0, 3.0]]
|
||||||
|
SMALL_SQUARE = [[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0]]
|
||||||
|
|
||||||
|
|
||||||
|
def _artifact(tmp_path, *, shape=(4, 4), uv=None, valid=None):
|
||||||
|
height, width = shape
|
||||||
|
if uv is None:
|
||||||
|
rows, cols = np.mgrid[0:height, 0:width]
|
||||||
|
uv = np.stack((cols, rows), axis=-1).astype(np.float64)
|
||||||
|
if valid is None:
|
||||||
|
valid = np.ones((height, width), dtype=bool)
|
||||||
|
path = tmp_path / "map.npz"
|
||||||
|
np.savez(path, uv_by_output_px=uv, valid_mask=valid, origin_xy=np.array([0, 0]))
|
||||||
|
return path, hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _pipeline(*, digest):
|
||||||
|
pipeline = {
|
||||||
|
"stitch_resolution": [4, 4],
|
||||||
|
"dis_mode": 1,
|
||||||
|
"hhd_downsample": 1,
|
||||||
|
"filters": {"measurement": {"flow_ema_alpha": 1.0}},
|
||||||
|
"source_scale": 0.5,
|
||||||
|
"geometry_sha256": "b" * 64,
|
||||||
|
"coordinates": {"frame": "autocal_flat", "unit": "px",
|
||||||
|
"origin_xy": [0, 0], "map_sha256": digest},
|
||||||
|
"feature_signature_sha256": "a" * 64,
|
||||||
|
"field": deepcopy(FIELD_IDENTITY),
|
||||||
|
}
|
||||||
|
return pipeline
|
||||||
|
|
||||||
|
|
||||||
|
def _document(tmp_path, *, gains=None, points=None, triangles=None, uv=None,
|
||||||
|
coord_valid=None, artifact=True):
|
||||||
|
height, width = (4, 4) if uv is None else uv.shape[:2]
|
||||||
|
path, digest = _artifact(tmp_path, shape=(height, width), uv=uv, valid=coord_valid)
|
||||||
|
pipeline = _pipeline(digest=digest)
|
||||||
|
params = {
|
||||||
|
"points_uv": points if points is not None else FULL_SQUARE,
|
||||||
|
"gains_N_per_raw": gains if gains is not None else [1.0, 2.0, 3.0, 4.0],
|
||||||
|
"triangles": triangles if triangles is not None else [[0, 1, 2], [1, 3, 2]],
|
||||||
|
"valid_force_range_N": [0.0, 100.0],
|
||||||
|
"mapping": deepcopy(MAPPING_POLICY),
|
||||||
|
"baseline": deepcopy(BASELINE_POLICY),
|
||||||
|
}
|
||||||
|
document = {
|
||||||
|
"schema_version": 3,
|
||||||
|
"unit": "N",
|
||||||
|
"identity": {"sensor_type": "finger", "sn": "DD02-GL"},
|
||||||
|
"pipeline": pipeline,
|
||||||
|
"model": {
|
||||||
|
"type": "field_gain/1",
|
||||||
|
"input_profile": "hhd_normal_l1_field/1",
|
||||||
|
"output_frame": "sdk_force/1",
|
||||||
|
"outputs": ["fn"],
|
||||||
|
"params": params,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if artifact:
|
||||||
|
document["model"]["artifacts"] = [
|
||||||
|
{"role": "coordinate_map", "path": path.name, "sha256": digest}
|
||||||
|
]
|
||||||
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
def _field(dx=0.5, dy=0.5, *, shape=(2, 2), area_scale=4.0, mask=None, dtype=np.float32):
|
||||||
|
d = np.zeros((*shape, 2), dtype=dtype)
|
||||||
|
d[:, :, 0] = dx
|
||||||
|
d[:, :, 1] = dy
|
||||||
|
if mask is None:
|
||||||
|
mask = np.ones(shape, dtype=bool)
|
||||||
|
mask = np.asarray(mask, dtype=bool)
|
||||||
|
d[~mask] = 0.0
|
||||||
|
return {"d": d, "valid_mask": mask, "area_scale": area_scale, "grid_shape": tuple(shape)}
|
||||||
|
|
||||||
|
|
||||||
|
def _features(field, *, mask_shape=(4, 4), output_mask=None):
|
||||||
|
if output_mask is None:
|
||||||
|
output_mask = np.ones(mask_shape, dtype=bool)
|
||||||
|
return {"field": field, "output_valid_mask": output_mask}
|
||||||
|
|
||||||
|
|
||||||
|
def _model_with_gain(tmp_path, gain=1.0):
|
||||||
|
document = _document(tmp_path, gains=[gain] * 4)
|
||||||
|
return parse_model(document, tmp_path / "calibration.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _feed_ready(model, frames=30, field=None):
|
||||||
|
model.begin_reference("unloaded")
|
||||||
|
field = field if field is not None else _field()
|
||||||
|
for _ in range(frames):
|
||||||
|
model.predict(1, 0.0, _features(field))
|
||||||
|
assert model.field_baseline_N is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_accepts_field_model_and_declares_profile(tmp_path):
|
||||||
|
model = parse_model(_document(tmp_path), tmp_path / "calibration.json")
|
||||||
|
assert model.model_type == "field_gain/1"
|
||||||
|
assert model.input_profile == "hhd_normal_l1_field/1"
|
||||||
|
assert model.channels == {"fn": "N"}
|
||||||
|
assert model.to_dict()["model"]["params"]["mapping"] == MAPPING_POLICY
|
||||||
|
assert len(model.fingerprint) == 64
|
||||||
|
gain_map = model.field_gain_map((2, 2), np.ones((4, 4), dtype=bool))
|
||||||
|
assert gain_map.gain.shape == (2, 2)
|
||||||
|
assert np.allclose(gain_map.coverage, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_constant_gain_matches_hand_computed_weighted_sum(tmp_path):
|
||||||
|
model = _model_with_gain(tmp_path, gain=1.0)
|
||||||
|
_feed_ready(model)
|
||||||
|
result = model.predict(31, 0.0, _features(_field()))
|
||||||
|
# q=1 on each of 4 cells, coverage 1, gain 1, A=4: P=16, baseline=16, FN=0.
|
||||||
|
assert result.values_N["fn"] == pytest.approx(0.0)
|
||||||
|
assert result.status["fn"] == "ok"
|
||||||
|
assert result.diagnostics["baseline_N"] == pytest.approx(16.0)
|
||||||
|
result = model.predict(32, 0.0, _features(_field(1.5, 1.5)))
|
||||||
|
# q=3 per cell -> P=48, FN=48-16=32.
|
||||||
|
assert result.values_N["fn"] == pytest.approx(32.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_regions_contribute_and_vectors_combine_before_l1(tmp_path):
|
||||||
|
model = _model_with_gain(tmp_path, gain=1.0)
|
||||||
|
_feed_ready(model)
|
||||||
|
d = np.zeros((2, 2, 2), dtype=np.float32)
|
||||||
|
d[0, 0] = (1.0, 0.0)
|
||||||
|
d[1, 1] = (0.0, 0.5)
|
||||||
|
mask = np.ones((2, 2), dtype=bool)
|
||||||
|
field = {"d": d, "valid_mask": mask, "area_scale": 4.0, "grid_shape": (2, 2)}
|
||||||
|
result = model.predict(31, 0.0, _features(field))
|
||||||
|
# q sums 1.5, two cells -> P=6; baseline 16 -> -10.
|
||||||
|
assert result.values_N["fn"] == pytest.approx(-10.0)
|
||||||
|
|
||||||
|
combined = np.zeros((2, 2, 2), dtype=np.float32)
|
||||||
|
combined[0, 0] = (0.75, 0.0)
|
||||||
|
# A negative and a positive vector at the same cell combine to 0.5, not 1.0.
|
||||||
|
first = combined.copy()
|
||||||
|
second = np.zeros_like(combined)
|
||||||
|
second[0, 0] = (-0.25, 0.0)
|
||||||
|
first_map = {"d": first, "valid_mask": mask, "area_scale": 4.0, "grid_shape": (2, 2)}
|
||||||
|
second_map = {"d": second, "valid_mask": mask, "area_scale": 4.0, "grid_shape": (2, 2)}
|
||||||
|
combined_map = {"d": first + second, "valid_mask": mask, "area_scale": 4.0, "grid_shape": (2, 2)}
|
||||||
|
gain = model.field_gain_map((2, 2), np.ones((4, 4), dtype=bool))
|
||||||
|
reduction = reduce_field(coerce_field(combined_map), gain)
|
||||||
|
assert reduction.weighted_N == pytest.approx(4.0 * 0.5)
|
||||||
|
assert reduction.weighted_N != pytest.approx(
|
||||||
|
reduce_field(coerce_field(first_map), gain).weighted_N
|
||||||
|
+ reduce_field(coerce_field(second_map), gain).weighted_N
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_boundary_projection_and_mask_coverage(tmp_path):
|
||||||
|
model = parse_model(_document(tmp_path, points=SMALL_SQUARE,
|
||||||
|
triangles=[[0, 1, 2], [0, 2, 3]]),
|
||||||
|
tmp_path / "calibration.json")
|
||||||
|
gains = model.field_gain_map((4, 4), np.ones((4, 4), dtype=bool))
|
||||||
|
# (1,1) is inside the small hull; (3,3) projects to the nearest edge.
|
||||||
|
assert gains.gain[1, 1] == pytest.approx(2.0)
|
||||||
|
assert gains.coverage[1, 1] == pytest.approx(1.0)
|
||||||
|
assert gains.inside[1, 1] == pytest.approx(1.0)
|
||||||
|
assert gains.gain[3, 3] == pytest.approx(3.0)
|
||||||
|
assert gains.inside[3, 3] == pytest.approx(0.0)
|
||||||
|
assert gains.coverage[3, 3] == pytest.approx(1.0)
|
||||||
|
|
||||||
|
# A physical mask that keeps only the left half removes the right cells entirely.
|
||||||
|
constant = parse_model(_document(tmp_path, points=SMALL_SQUARE, gains=[1.0] * 4,
|
||||||
|
triangles=[[0, 1, 2], [0, 2, 3]]),
|
||||||
|
tmp_path / "calibration.json")
|
||||||
|
left_half = np.zeros((4, 4), dtype=bool)
|
||||||
|
left_half[:, :2] = True
|
||||||
|
gains = constant.field_gain_map((2, 2), left_half)
|
||||||
|
assert np.allclose(gains.coverage, [[1.0, 0.0], [1.0, 0.0]])
|
||||||
|
field = _field(mask=np.array([[True, False], [True, False]]))
|
||||||
|
reduction = reduce_field(coerce_field(field), gains)
|
||||||
|
# Two active cells with q=1, A=4: P=8. Inactive cells never contribute.
|
||||||
|
assert reduction.weighted_N == pytest.approx(8.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_coverage_constant_gain_still_matches_raw_sum(tmp_path):
|
||||||
|
"""R1: coverage is diagnostic, not a second mask weight.
|
||||||
|
|
||||||
|
HHD raw counts a partially covered mask-edge cell in full; folding coverage
|
||||||
|
into ``P`` would discount it again and break ``P == gain * raw``.
|
||||||
|
"""
|
||||||
|
model = _model_with_gain(tmp_path, gain=0.5)
|
||||||
|
mask = np.ones((4, 4), dtype=bool)
|
||||||
|
mask[0, 0] = False
|
||||||
|
gain_map = model.field_gain_map((2, 2), mask)
|
||||||
|
assert gain_map.coverage[0, 0] == pytest.approx(0.75)
|
||||||
|
field = _field(0.5, 0.5, shape=(2, 2), area_scale=4.0)
|
||||||
|
raw = 4.0 * float(np.sum(np.abs(field["d"])))
|
||||||
|
reduction = reduce_field(coerce_field(field), gain_map)
|
||||||
|
assert reduction.weighted_N == pytest.approx(0.5 * raw)
|
||||||
|
assert reduction.coverage_fraction == pytest.approx((0.75 + 1.0 + 1.0 + 1.0) / 4.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_hhd_cell_without_coverage_is_a_mapping_error(tmp_path):
|
||||||
|
"""R1: an active cell with no mapped pixel must not be silently counted as zero."""
|
||||||
|
model = _model_with_gain(tmp_path, gain=1.0)
|
||||||
|
left_half = np.zeros((4, 4), dtype=bool)
|
||||||
|
left_half[:, :2] = True
|
||||||
|
gain_map = model.field_gain_map((2, 2), left_half)
|
||||||
|
assert np.allclose(gain_map.coverage, [[1.0, 0.0], [1.0, 0.0]])
|
||||||
|
with pytest.raises(ValueError, match="mapping error"):
|
||||||
|
reduce_field(coerce_field(_field(mask=np.ones((2, 2), dtype=bool))), gain_map)
|
||||||
|
result = model.predict(1, 0.0, _features(
|
||||||
|
_field(mask=np.ones((2, 2), dtype=bool)), output_mask=left_half))
|
||||||
|
assert result.status == {"fn": "coordinate_unmapped"}
|
||||||
|
assert result.values_N == {"fn": None}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mapping_and_input_errors_are_explicit(tmp_path):
|
||||||
|
uv = np.stack(np.mgrid[0:4, 0:4][::-1], axis=-1).astype(np.float64)
|
||||||
|
valid = np.ones((4, 4), dtype=bool)
|
||||||
|
valid[0, 0] = False
|
||||||
|
model = parse_model(_document(tmp_path, uv=uv, coord_valid=valid),
|
||||||
|
tmp_path / "calibration.json")
|
||||||
|
with pytest.raises(ValueError, match="mapping error"):
|
||||||
|
model.field_gain_map((2, 2), np.ones((4, 4), dtype=bool))
|
||||||
|
result = model.predict(1, 0.0, _features(_field()))
|
||||||
|
assert result.status == {"fn": "coordinate_unmapped"}
|
||||||
|
assert result.values_N == {"fn": None}
|
||||||
|
|
||||||
|
broken = _field()
|
||||||
|
broken["d"][0, 0, 0] = np.nan
|
||||||
|
result = model.predict(2, 0.0, _features(broken))
|
||||||
|
assert result.status == {"fn": "invalid_input"}
|
||||||
|
assert result.values_N == {"fn": None}
|
||||||
|
|
||||||
|
wrong_dtype = _field(dtype=np.float64)
|
||||||
|
with pytest.raises(ValueError, match="dtype"):
|
||||||
|
coerce_field(wrong_dtype)
|
||||||
|
wrong_shape = _field()
|
||||||
|
wrong_shape["grid_shape"] = (3, 2)
|
||||||
|
with pytest.raises(ValueError, match="grid_shape"):
|
||||||
|
coerce_field(wrong_shape)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_field_reports_zero_and_negative_baseline_difference(tmp_path):
|
||||||
|
model = _model_with_gain(tmp_path, gain=1.0)
|
||||||
|
_feed_ready(model)
|
||||||
|
empty = _field(0.0, 0.0)
|
||||||
|
result = model.predict(31, 0.0, _features(empty))
|
||||||
|
assert result.status["fn"] == "out_of_range"
|
||||||
|
assert result.values_N["fn"] == pytest.approx(-16.0)
|
||||||
|
assert result.diagnostics["extrapolated_fraction"] == pytest.approx(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_weighted_baseline_needs_declaration_and_thirty_consecutive_frames(tmp_path):
|
||||||
|
model = _model_with_gain(tmp_path, gain=1.0)
|
||||||
|
result = model.predict(1, 0.0, _features(_field()))
|
||||||
|
assert result.status == {"fn": "zero_unavailable"}
|
||||||
|
assert result.diagnostics["pending_reference_frames"] == 0
|
||||||
|
model.begin_reference("unloaded")
|
||||||
|
for _ in range(29):
|
||||||
|
result = model.predict(1, 0.0, _features(_field()))
|
||||||
|
assert result.status == {"fn": "zero_unavailable"}
|
||||||
|
result = model.predict(1, 0.0, _features(_field()))
|
||||||
|
assert result.status == {"fn": "zero_unavailable"}
|
||||||
|
assert result.diagnostics["baseline_N"] == pytest.approx(16.0)
|
||||||
|
assert result.diagnostics["reference_id"] == "unloaded"
|
||||||
|
ready = model.predict(2, 0.0, _features(_field()))
|
||||||
|
assert ready.status["fn"] == "ok"
|
||||||
|
|
||||||
|
model.begin_reference("second")
|
||||||
|
invalid = _field()
|
||||||
|
invalid["d"][0, 0, 0] = np.nan
|
||||||
|
for _ in range(29):
|
||||||
|
model.predict(3, 0.0, _features(_field()))
|
||||||
|
assert model.predict(3, 0.0, _features(invalid)).status == {"fn": "invalid_input"}
|
||||||
|
result = model.predict(4, 0.0, _features(_field()))
|
||||||
|
assert result.diagnostics["pending_reference_frames"] == 1
|
||||||
|
assert result.status == {"fn": "zero_unavailable"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_and_model_identity_void_the_weighted_baseline(tmp_path):
|
||||||
|
document = _document(tmp_path, gains=[1.0] * 4)
|
||||||
|
model = parse_model(document, tmp_path / "calibration.json")
|
||||||
|
_feed_ready(model)
|
||||||
|
assert model.field_baseline_N is not None
|
||||||
|
model.reset(None)
|
||||||
|
assert model.field_baseline_N is None
|
||||||
|
result = model.predict(31, 0.0, _features(_field()))
|
||||||
|
assert result.status == {"fn": "zero_unavailable"}
|
||||||
|
fresh = parse_model(document, tmp_path / "calibration.json")
|
||||||
|
assert fresh.field_baseline_N is None
|
||||||
|
assert fresh.predict(31, 0.0, _features(_field())).status == {"fn": "zero_unavailable"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_document_rejections(tmp_path):
|
||||||
|
mutations = [
|
||||||
|
lambda data: data["model"].update(input_profile="fn_delta_autocal_uv/1"),
|
||||||
|
lambda data: data["model"].update(outputs=["fn", "fx"]),
|
||||||
|
lambda data: data["model"].pop("artifacts"),
|
||||||
|
lambda data: data["model"]["params"].update(mapping={"inside": "nearest"}),
|
||||||
|
lambda data: data["model"]["params"].update(baseline={"frames": 29, "statistic": "median"}),
|
||||||
|
lambda data: data["pipeline"].pop("field"),
|
||||||
|
lambda data: data["pipeline"].update(field={"grid": "other"}),
|
||||||
|
lambda data: data["model"]["params"].update(extra=True),
|
||||||
|
lambda data: data["model"]["params"].update(gains_N_per_raw=[1.0, 2.0, 3.0, 0.0]),
|
||||||
|
lambda data: data["pipeline"].pop("geometry_sha256"),
|
||||||
|
]
|
||||||
|
for mutate in mutations:
|
||||||
|
data = _document(tmp_path)
|
||||||
|
mutate(data)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
parse_model(data, tmp_path / "calibration.json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_model_is_loadable_through_calibration_entry_points(tmp_path):
|
||||||
|
document = _document(tmp_path)
|
||||||
|
model = parse(document, source_path=tmp_path / "calibration.json")
|
||||||
|
assert model.schema_version == 3
|
||||||
|
assert parse_model(document, tmp_path / "calibration.json").model_type == "field_gain/1"
|
||||||
|
with pytest.raises(CalibrationError, match="identity mismatch"):
|
||||||
|
validate_for_device(model, "finger", "OTHER-SN")
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_includes_field_identity_and_rejects_scalar_profile(tmp_path):
|
||||||
|
path, digest = _artifact(tmp_path)
|
||||||
|
coordinates = {"frame": "autocal_flat", "unit": "px",
|
||||||
|
"origin_xy": [0, 0], "map_sha256": digest}
|
||||||
|
config = {
|
||||||
|
"type": "compound_eye",
|
||||||
|
"camera": {"resolution_raw": [4, 4], "resolution_stitch": [4, 4]},
|
||||||
|
"tracker": {"dis_mode": 1, "hhd_downsample": 1},
|
||||||
|
"filters": {"measurement": {"flow_ema_alpha": 1.0}},
|
||||||
|
"contact": {},
|
||||||
|
}
|
||||||
|
context = build_context(config, config["filters"], geometry_sha256="b" * 64,
|
||||||
|
source_scale=0.5, coordinates=coordinates,
|
||||||
|
processing_backend="uv_unroll",
|
||||||
|
preprocessing={"flip_source_vertical": True, "mirror": False},
|
||||||
|
field=FIELD_IDENTITY)
|
||||||
|
document = _document(tmp_path)
|
||||||
|
document["pipeline"] = dict(context)
|
||||||
|
model = parse_model(document, tmp_path / "calibration.json")
|
||||||
|
model.validate_for_context(context)
|
||||||
|
scalar_context = build_context(config, config["filters"], geometry_sha256="b" * 64,
|
||||||
|
source_scale=0.5, coordinates=coordinates,
|
||||||
|
processing_backend="uv_unroll")
|
||||||
|
with pytest.raises(ValueError, match="field"):
|
||||||
|
model.validate_for_context(scalar_context)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gain_map_is_deterministic_across_builds(tmp_path):
|
||||||
|
model = _model_with_gain(tmp_path, gain=2.0)
|
||||||
|
first = model.field_gain_map((2, 2), np.ones((4, 4), dtype=bool))
|
||||||
|
model._field_maps.clear()
|
||||||
|
second = model.field_gain_map((2, 2), np.ones((4, 4), dtype=bool))
|
||||||
|
assert np.array_equal(first.gain, second.gain)
|
||||||
|
assert np.array_equal(first.coverage, second.coverage)
|
||||||
|
assert first.physical_mask_sha256 == second.physical_mask_sha256
|
||||||
|
with pytest.raises(ValueError, match="mask"):
|
||||||
|
build_field_gain_map(model._points, model._gains, model._triangles,
|
||||||
|
model.coordinate_map, model.coordinate_valid_mask,
|
||||||
|
(2, 2), np.ones((3, 3), dtype=bool))
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_model_ignores_contact_state(tmp_path):
|
||||||
|
model = _model_with_gain(tmp_path, gain=1.0)
|
||||||
|
model.begin_reference("unloaded")
|
||||||
|
for _ in range(30):
|
||||||
|
model.predict(1, 0.0, _features(_field()))
|
||||||
|
features = _features(_field())
|
||||||
|
features.update(contact_found=False, zero_valid=False)
|
||||||
|
result = model.predict(31, 0.0, features)
|
||||||
|
assert result.status["fn"] == "ok"
|
||||||
|
assert result.values_N["fn"] == pytest.approx(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _hhd(grid_shape, reference_shape, downsample):
|
||||||
|
from orisys.hhd import nHHD
|
||||||
|
|
||||||
|
return nHHD(cuda=False, grid=grid_shape, spacings=(18 * downsample, 18 * downsample),
|
||||||
|
poisson_backend="cpu", force_reference_grid=reference_shape)
|
||||||
|
|
||||||
|
|
||||||
|
def _identity_uv(shape):
|
||||||
|
rows, cols = np.mgrid[0:shape[0], 0:shape[1]]
|
||||||
|
return np.stack((cols, rows), axis=-1).astype(np.float64)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hhd_field_payload_uses_actual_reference_area_scale():
|
||||||
|
reference = (9, 8)
|
||||||
|
for downsample in (1, 2, 3, 4):
|
||||||
|
grid = (reference[0] // downsample, reference[1] // downsample)
|
||||||
|
solver = _hhd(grid, reference, downsample)
|
||||||
|
solver.get_force(np.zeros((*grid, 2), dtype=np.float32))
|
||||||
|
payload = solver.get_field()
|
||||||
|
assert payload["area_scale"] == pytest.approx(
|
||||||
|
reference[0] * reference[1] / (grid[0] * grid[1]))
|
||||||
|
assert payload["grid_shape"] == grid
|
||||||
|
assert payload["reference_shape"] == reference
|
||||||
|
assert payload["d"].dtype == np.float32
|
||||||
|
assert payload["d"].shape == (*grid, 2)
|
||||||
|
payload["d"][0, 0, 0] = 123.0
|
||||||
|
assert solver.d[0, 0, 0] != 123.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_constant_gain_matches_raw_hhd_sum(tmp_path):
|
||||||
|
shape = (6, 5)
|
||||||
|
rng = np.random.default_rng(7)
|
||||||
|
solver = _hhd(shape, shape, 1)
|
||||||
|
_, forces = solver.get_force(rng.normal(size=(*shape, 2)).astype(np.float32))
|
||||||
|
raw_sn = float(forces[0])
|
||||||
|
payload = solver.get_field()
|
||||||
|
uv = _identity_uv(shape)
|
||||||
|
document = _document(tmp_path, gains=[0.5] * 4, uv=uv,
|
||||||
|
points=[[0.0, 0.0], [shape[1] - 1.0, 0.0],
|
||||||
|
[0.0, shape[0] - 1.0], [shape[1] - 1.0, shape[0] - 1.0]],
|
||||||
|
triangles=[[0, 1, 2], [1, 3, 2]])
|
||||||
|
model = parse_model(document, tmp_path / "calibration.json")
|
||||||
|
gain_map = model.field_gain_map(shape, np.ones(shape, dtype=bool))
|
||||||
|
assert np.allclose(gain_map.coverage, 1.0)
|
||||||
|
reduction = reduce_field(coerce_field(payload), gain_map)
|
||||||
|
# Constant gain: the field-weighted sum equals the raw HHD sum times the gain.
|
||||||
|
assert reduction.weighted_N == pytest.approx(0.5 * raw_sn, rel=1e-5, abs=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_constant_field_integral_across_downsample_and_nondivisible_grids(tmp_path):
|
||||||
|
reference = (9, 8)
|
||||||
|
expected = 0.5 * 2.0 * reference[0] * reference[1]
|
||||||
|
for downsample in (1, 2, 3, 4):
|
||||||
|
grid = (reference[0] // downsample, reference[1] // downsample)
|
||||||
|
solver = _hhd(grid, reference, downsample)
|
||||||
|
solver.get_force(np.zeros((*grid, 2), dtype=np.float32))
|
||||||
|
payload = solver.get_field()
|
||||||
|
payload["d"] = np.ones((*grid, 2), dtype=np.float32)
|
||||||
|
payload["valid_mask"] = np.ones(grid, dtype=bool)
|
||||||
|
uv = _identity_uv(reference)
|
||||||
|
document = _document(
|
||||||
|
tmp_path, gains=[0.5] * 4, uv=uv,
|
||||||
|
points=[[0.0, 0.0], [reference[1] - 1.0, 0.0],
|
||||||
|
[0.0, reference[0] - 1.0], [reference[1] - 1.0, reference[0] - 1.0]],
|
||||||
|
triangles=[[0, 1, 2], [1, 3, 2]],
|
||||||
|
)
|
||||||
|
model = parse_model(document, tmp_path / "calibration.json")
|
||||||
|
gain_map = model.field_gain_map(grid, np.ones(reference, dtype=bool))
|
||||||
|
reduction = reduce_field(coerce_field(payload), gain_map)
|
||||||
|
# q=2 per cell; A*cell_count == reference pixel count for every integer
|
||||||
|
# downsample, including grids that do not divide the reference exactly.
|
||||||
|
assert reduction.weighted_N == pytest.approx(expected, rel=1e-5, abs=1e-6)
|
||||||
@@ -345,3 +345,113 @@ def test_bad_zero_reference_is_rejected_without_reset(reference):
|
|||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
sensor.set_calibration_reference(reference, reference_id="bad")
|
sensor.set_calibration_reference(reference, reference_id="bad")
|
||||||
assert sensor._calibration_reference == {"fn": 10.}
|
assert sensor._calibration_reference == {"fn": 10.}
|
||||||
|
|
||||||
|
|
||||||
|
class FieldRecordingModel:
|
||||||
|
schema_version = 3
|
||||||
|
channels = {"fn": "N"}
|
||||||
|
input_profile = "hhd_normal_l1_field/1"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
self.resets = []
|
||||||
|
self.begin_calls = []
|
||||||
|
self.coordinate_map = np.zeros((2, 2, 2), dtype=np.float32)
|
||||||
|
self.coordinate_valid_mask = np.ones((2, 2), dtype=bool)
|
||||||
|
|
||||||
|
def reset(self, reference_id):
|
||||||
|
self.resets.append(reference_id)
|
||||||
|
|
||||||
|
def begin_reference(self, reference_id):
|
||||||
|
self.begin_calls.append(reference_id)
|
||||||
|
|
||||||
|
def validate_for_context(self, context):
|
||||||
|
if context != {"signature": "actual"}:
|
||||||
|
raise CalibrationError("pipeline mismatch")
|
||||||
|
|
||||||
|
def predict(self, **kwargs):
|
||||||
|
self.calls.append(kwargs)
|
||||||
|
features = kwargs["features"]
|
||||||
|
if "field" not in features:
|
||||||
|
return SimpleNamespace(values_N={"fn": None}, status={"fn": "invalid_input"},
|
||||||
|
diagnostics={})
|
||||||
|
return SimpleNamespace(values_N={"fn": 1.0}, status={"fn": "ok"}, diagnostics={})
|
||||||
|
|
||||||
|
|
||||||
|
def test_hhd_field_model_infers_from_same_frame_field_without_contact():
|
||||||
|
sensor = make_sensor()
|
||||||
|
sensor.calibration = FieldRecordingModel()
|
||||||
|
sensor._calibration_context = {"signature": "actual"}
|
||||||
|
sensor.cuda = sensor.verbose = False
|
||||||
|
sensor.valid_mask = sensor.valid_mask_hhd = None
|
||||||
|
sensor._hhd_downsample = 1
|
||||||
|
sensor._hhd_grid_shape = sensor._hhd_ref_shape = (2, 2)
|
||||||
|
sensor._model_frame_id = 0
|
||||||
|
field = np.zeros((2, 2, 2), dtype=np.float32)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def get_field(valid_mask=None):
|
||||||
|
calls.append(valid_mask)
|
||||||
|
return {"d": field, "valid_mask": np.ones((2, 2), dtype=bool), "area_scale": 1.0,
|
||||||
|
"grid_shape": (2, 2), "reference_shape": (2, 2)}
|
||||||
|
|
||||||
|
sensor._Sensor__hhd = SimpleNamespace(
|
||||||
|
get_force=lambda *args, **kwargs: (None, (10., 0., 0., 0.)),
|
||||||
|
get_normal=lambda: field, get_shear=lambda: field, get_field=get_field,
|
||||||
|
)
|
||||||
|
sensor.compute_hhd(np.zeros((2, 2, 2), dtype=np.float32))
|
||||||
|
features = sensor.calibration.calls[-1]["features"]
|
||||||
|
assert features["field"]["grid_shape"] == (2, 2)
|
||||||
|
assert features["output_valid_mask"].shape == (2, 2)
|
||||||
|
assert "contact_found" not in features
|
||||||
|
assert sensor.calibration_status == {"fn": "ok"}
|
||||||
|
assert sensor.fnormal == pytest.approx(1.0)
|
||||||
|
assert calls == [None]
|
||||||
|
|
||||||
|
|
||||||
|
def test_weighted_unloaded_reference_is_declared_not_a_scalar_zero():
|
||||||
|
sensor = make_sensor()
|
||||||
|
sensor.calibration = FieldRecordingModel()
|
||||||
|
sensor._calibration_context = {"signature": "actual"}
|
||||||
|
sensor.declare_unloaded_reference(reference_id="video-unloaded-100")
|
||||||
|
assert sensor.calibration.begin_calls == ["video-unloaded-100"]
|
||||||
|
assert sensor._calibration_reference_id == "video-unloaded-100"
|
||||||
|
with pytest.raises(CalibrationError, match="scalar zero"):
|
||||||
|
sensor.set_calibration_reference({"fn": 0.}, reference_id="bad")
|
||||||
|
with pytest.raises(CalibrationError, match="hhd_normal_l1_field"):
|
||||||
|
make_sensor().declare_unloaded_reference(reference_id="scalar-model")
|
||||||
|
|
||||||
|
|
||||||
|
def test_finger_runtime_binds_field_identity_and_forwards_the_window(monkeypatch, tmp_path):
|
||||||
|
import orisys.calibration_context as context_mod
|
||||||
|
from orisys.field_calibration import FIELD_IDENTITY
|
||||||
|
from orisys.finger.runtime import FingerRuntime
|
||||||
|
|
||||||
|
sensor = make_sensor()
|
||||||
|
sensor.calibration = FieldRecordingModel()
|
||||||
|
sensor._calibration_context = {"signature": "actual"}
|
||||||
|
sensor.device_id = None
|
||||||
|
sensor.mirror = False
|
||||||
|
runtime = FingerRuntime.__new__(FingerRuntime)
|
||||||
|
runtime.tactile = SimpleNamespace(raw_sensor=sensor)
|
||||||
|
mesh = tmp_path / "mesh.npz"
|
||||||
|
mesh.write_bytes(b"actual geometry")
|
||||||
|
runtime.geometry = SimpleNamespace(config={}, export_paths=SimpleNamespace(mesh=mesh),
|
||||||
|
source_scale=.42, out_height=2, out_width=2)
|
||||||
|
runtime.domain = object()
|
||||||
|
runtime.frame_processor = SimpleNamespace(flip_source_vertical=True)
|
||||||
|
monkeypatch.setattr(context_mod, "domain_fingerprint", lambda domain: "domain")
|
||||||
|
contexts = []
|
||||||
|
|
||||||
|
def context(config, filters, **kwargs):
|
||||||
|
contexts.append(kwargs)
|
||||||
|
return {"signature": "actual"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(context_mod, "build_context", context)
|
||||||
|
selected = FieldRecordingModel()
|
||||||
|
selected.pipeline = {"coordinates": {"frame": "autocal_flat"}}
|
||||||
|
runtime.set_calibration(selected)
|
||||||
|
assert contexts[0]["field"] == FIELD_IDENTITY
|
||||||
|
runtime.declare_unloaded_reference("runtime-window")
|
||||||
|
assert sensor.calibration.begin_calls == ["runtime-window"]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user