Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06ba8ef7b0 | |||
| ad4c3ba6b9 | |||
| ad7b0d3e26 | |||
| f5738375fd | |||
| d31503330d | |||
| 26382b359f | |||
| 11f8dc93e5 | |||
| 14e73d743c | |||
| e3b60f3d6d | |||
| 474dbf5a64 | |||
| f6f3c45285 | |||
| cbccbd14b5 | |||
| c256870ee2 | |||
| ee830d8505 | |||
| dc8a630df3 | |||
| 0a042c7fbf | |||
| c78f7a304f | |||
| 5f79c8c7f6 | |||
| e6f4228940 | |||
| c0c91ad3f1 | |||
| fb72dabecd | |||
| 92d86051f5 |
@@ -192,6 +192,7 @@ def _cmd_github(argv: list[str]) -> int:
|
||||
help="actually mutate the release repo, push, and create the release",
|
||||
)
|
||||
parser.add_argument("--notes-file", type=Path, default=None, help="release notes markdown")
|
||||
parser.add_argument("--tag", default=None, help="release tag: <version> or v<version> (default)")
|
||||
parser.add_argument(
|
||||
"--attach-preflight",
|
||||
action="store_true",
|
||||
@@ -218,6 +219,7 @@ def _cmd_github(argv: list[str]) -> int:
|
||||
version=args.version,
|
||||
apply=args.apply,
|
||||
notes_file=args.notes_file,
|
||||
tag=args.tag,
|
||||
attach_preflight=args.attach_preflight,
|
||||
allow_dirty_release=args.allow_dirty_release,
|
||||
skip_fetch=args.skip_fetch,
|
||||
|
||||
+11
-4
@@ -8,7 +8,7 @@
|
||||
3. sync the open-source subset of this checkout into ``orisys-sdk-release``
|
||||
(``tools/sync_downstreams.py`` + the manifest's ``checks``)
|
||||
4. stage ``SDK/<version>/*.whl`` into ``orisys-sdk-release/release/<version>/``
|
||||
5. commit, tag ``v<version>``, push branch + tag
|
||||
5. commit, tag (default ``v<version>``), push branch + tag
|
||||
6. create the GitHub Release with the wheels attached, then verify the assets
|
||||
|
||||
Nothing mutates anything unless ``--apply`` is passed: the default is a dry run
|
||||
@@ -48,6 +48,7 @@ class Options:
|
||||
skip_fetch: bool = False
|
||||
skip_checks: bool = False
|
||||
skip_release_upload: bool = False
|
||||
tag: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -131,7 +132,7 @@ def build_github_steps(
|
||||
tag,
|
||||
"--verify-tag",
|
||||
"--title",
|
||||
title or f"v{version}",
|
||||
title or tag,
|
||||
"--latest",
|
||||
]
|
||||
if notes_file is not None:
|
||||
@@ -242,6 +243,10 @@ def _stage_wheels(wheels: list[Path], asset_dir: Path) -> None:
|
||||
|
||||
def publish(options: Options) -> int:
|
||||
version = options.version
|
||||
tag = options.tag or f"v{version}"
|
||||
if tag not in {version, f"v{version}"}:
|
||||
print(f"[FAIL] tag must be {version!r} or {'v' + version!r}")
|
||||
return 1
|
||||
mode = "APPLY" if options.apply else "DRY RUN"
|
||||
print(f"=== release github: orisys {version} ({mode}) ===")
|
||||
|
||||
@@ -287,8 +292,10 @@ def publish(options: Options) -> int:
|
||||
return 1
|
||||
print(f"[ok ] release repo: {repo} ({branch}) clean={not dirty}")
|
||||
|
||||
tag = f"v{version}"
|
||||
existing = _existing_release_tags(repo, version)
|
||||
if existing and tag not in existing:
|
||||
print(f"[FAIL] version already has tag(s) {existing}; refusing a second tag {tag}")
|
||||
return 1
|
||||
# A tag that exists without a GitHub Release means a previous run got as far
|
||||
# as pushing the tag and then failed (or the upload was left manual). That is
|
||||
# exactly the resumable state, so continue instead of refusing.
|
||||
@@ -383,7 +390,7 @@ def publish(options: Options) -> int:
|
||||
_git(repo, "add", "-A")
|
||||
pending = _git(repo, "status", "--porcelain")
|
||||
if pending:
|
||||
_git(repo, "commit", "-m", f"release {version}")
|
||||
_git(repo, "commit", "-m", f"发布 SDK {version}")
|
||||
print(f"[ok ] committed {len(pending.splitlines())} change(s) in {repo}")
|
||||
else:
|
||||
print("[ok ] nothing new to commit in the release repo")
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@
|
||||
"dis_preset": "medium",
|
||||
"dis_mode": 1,
|
||||
"algorithm_list": ["raft", "dis"],
|
||||
"hhd_downsample": 5
|
||||
"hhd_downsample": 2
|
||||
},
|
||||
"contact": {
|
||||
"min_contour_area": 3000,
|
||||
|
||||
+9
-3
@@ -183,7 +183,7 @@ flow = sensor.read_info(sensor.info.VRAW)
|
||||
|
||||
释放传感器相关资源并停止后台异步线程。 调用后将关闭视频/摄像头输入源,并销毁所有 OpenCV 窗口。
|
||||
|
||||
建议在程序退出前调用以确保资源被正确释放。
|
||||
建议退出前调用。
|
||||
|
||||
### `Sensor.configure` 低通滤波设置
|
||||
|
||||
@@ -197,7 +197,7 @@ flow = sensor.read_info(sensor.info.VRAW)
|
||||
sensor.configure(lowpass_cutoff_hz=5.0, lowpass_sample_rate_hz=30.0)
|
||||
```
|
||||
|
||||
注意:`lowpass_cutoff_hz` 必须小于 Nyquist 频率,即 `lowpass_sample_rate_hz / 2`。例如 30 fps 时,截止频率必须小于 15 Hz。
|
||||
注意:`lowpass_cutoff_hz` 必须小于 `lowpass_sample_rate_hz / 2`(30 fps 时 <15 Hz)。
|
||||
|
||||
#### 输入参数
|
||||
|
||||
@@ -228,7 +228,7 @@ fn, fx, fy = sensor.read_info(
|
||||
|
||||
对一维、等间隔采样数据执行零相位 Butterworth 低通滤波,适合示例程序或图表中对历史数据做平滑处理。
|
||||
|
||||
默认参数为 `cutoff_hz=5.0`、`sample_rate_hz=30.0`、`order=2`。这表示在 30 fps 数据上使用 5 Hz 低通滤波。
|
||||
默认参数为 `cutoff_hz=5.0`、`sample_rate_hz=30.0`、`order=2`。
|
||||
|
||||
#### 输入参数
|
||||
|
||||
@@ -279,6 +279,12 @@ filt = LowpassFilter(cutoff_hz=5.0, sample_rate_hz=30.0, n_channels=3)
|
||||
filtered = filt.process([fnormal, fshearx, fsheary])
|
||||
```
|
||||
|
||||
## 力读数工作流
|
||||
|
||||
显式会话入口 `ForceSession`(`orisys.force_session`,`WORKFLOW_VERSION="studio-force/1"`):
|
||||
同帧测量、空载归零、每帧一次模型推断、raw/N 共用后处理;旧 `Sensor` 接口语义不变。
|
||||
接口与语义见 [force-workflow](force-workflow.zh.md)。
|
||||
|
||||
## Finger Pipeline API
|
||||
|
||||
### `orisys.finger` 手指 UV pipeline
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# 统一力标定模型契约
|
||||
|
||||
Purpose: reference. Scope: SDK 标定文件选择、SN 绑定和推断接口。
|
||||
状态:2026-10-18 已实现 schema 3 的空间/全局增益;ML 后端仅保留契约,尚未实现。
|
||||
状态:2026-10-19 已实现 schema 3 的空间、全局与场加权增益;ML 后端仅保留契约,尚未实现。
|
||||
保持 `cal_path` 与现有单点文件;新增空间模型和机器学习模型使用同一入口。
|
||||
运行中的 Finger 可调用 `runtime.set_calibration(file, device_id=sn)`;调用者须在处理线程的帧边界执行。
|
||||
该接口按实际几何、展开域及处理参数重新校验,成功切换后清除会话零点;校验失败保留原模型。
|
||||
|
||||
## 现状与边界
|
||||
|
||||
@@ -119,6 +121,12 @@ runtime.set_calibration_reference({"fn": measured_zero_raw}, reference_id="unloa
|
||||
特征定义或单位改变须升级 `feature_version`,配置覆盖后重新计算,不能用配置文件名代替。
|
||||
实现由 `calibration_context.build_context` 统一生成,特征版本为 `orisys_hhd_raw/1`。
|
||||
|
||||
`hhd_downsample` 是 schema 1/2/3 的兼容例外,允许数值偏差;实际参数、系数和文件指纹不变。
|
||||
schema 3 的 `build_context` 运行时上下文仅替换下采样率重算比对签名;
|
||||
其余处理参数、几何、展开域、SN 和传感器类型仍须匹配。
|
||||
序列化与普通映射仍校验实际指纹。
|
||||
验证:`test_calibration_context.py`、`test_calibration.py`。
|
||||
|
||||
## 空间增益模型
|
||||
|
||||
`spatial_gain/1` 固定输出 `fn`,使用 `fn_delta_autocal_uv/1`:
|
||||
@@ -146,46 +154,9 @@ FingerRuntime 使用 `artifacts` 中 `role="coordinate_map"` 的只读 NPZ:
|
||||
输入 profile 为 `force_delta/1`,由 SDK 按同一归零语义准备对应 raw 增量。
|
||||
这不同于旧曲线的绝对 raw 输入;兼容适配器不得替旧文件额外扣零。
|
||||
|
||||
## 机器学习扩展
|
||||
## 其他模型类型
|
||||
|
||||
同一 SN、路径和外层字段不变,只替换 `model`。下面只预留 `onnx/1` 示例,
|
||||
不表示 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。
|
||||
附件间依赖也须逐项声明和校验,未声明的外部权重不可加载。
|
||||
场加权 FN 模型 `field_gain/1`(输入 profile `hhd_normal_l1_field/1`)的字段、映射、空载基线与诊断契约见[场加权模型契约](field-gain-model.zh.md)。ML 扩展(首个示例 `onnx/1`)仍是预留示例,见[机器学习模型契约](ml-model-contract.zh.md);后端实现和输入 profile 验证通过前必须拒绝加载。
|
||||
|
||||
## 每帧推断与有效性
|
||||
|
||||
@@ -232,12 +203,14 @@ schema 3 按声明轴校验,允许 FN-only;旧 SDK 遇 schema 3 必须拒绝
|
||||
|---|---|
|
||||
| `calibration.py` | schema 分流、身份和附件校验;旧曲线适配器 |
|
||||
| `sensors/calibration.py` | 统一选择器与指纹;支持 SN 请求 |
|
||||
| 新 `calibration_models.py` | 受控类型表、空间增益与模型生命周期;ML 后端另行实现 |
|
||||
| 新 `calibration_models.py` | 受控类型表、空间/全局/场加权模型与生命周期;ML 后端另行实现 |
|
||||
| `sensor.py`、`finger/runtime.py`、处理器适配层 | 提供同帧位置/零点,缓存逐轴结果和状态 |
|
||||
| Sensor 与 `ForceReadout` | 复用读数滤波器;schema 3 在推断后逐轴滤波,旧路径不变 |
|
||||
| 离线导出器 | 实验模型到 schema 3;SDK 不依赖实验目录 |
|
||||
|
||||
当前导出入口为 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` 是本次实验交付,
|
||||
`identity.sn="dd02-gl"` 为实验标识,未从硬件读取;原三边缘点与非单调点警告保留于 provenance。
|
||||
它不是生产包默认文件,需显式 `cal_path` 选择。
|
||||
@@ -245,4 +218,6 @@ schema 3 按声明轴校验,允许 FN-only;旧 SDK 遇 schema 3 必须拒绝
|
||||
实现验收必须覆盖:旧结果与单位不变;显式路径/SN/默认/关闭选择;错误 SN 不串用;
|
||||
3/14/更多点、退化网格与凸包外;节点和插值预测;零点不重复扣除;缺坐标/无接触;
|
||||
FN-only 的 FX/FY 不冒充 N;未知模型、附件损坏和依赖缺失;状态模型每帧只调用一次及 reset。
|
||||
场加权另需常增益等价、两区域贡献、边界投影、映射错误与 30 帧加权基线生命周期,
|
||||
见 `tests/test_field_calibration.py`。
|
||||
另以独立录像验证物理准确度,契约或单元测试通过不代表标定合格。
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# orisys 0.7.0 变更说明
|
||||
|
||||
本版是单路传感器流(SensorStream)与力会话的整合版本。0.x 的不兼容变更按语义版本规则提升次版本号;
|
||||
App 需要 `orisys>=0.7.0`。完整字段与时序契约见
|
||||
[stream-contract.zh.md](stream-contract.zh.md)、[force-stream-session.zh.md](force-stream-session.zh.md)。
|
||||
|
||||
## 用户可见的变化
|
||||
|
||||
1. **单路流合同(T03)**:公开 `FrameStamp`、`MeasurementFrame` 等流身份与时间契约。
|
||||
`source_dt_s` 定义为与同一 Sensor 上一条实际处理帧之间的源时间差(第一帧为 `None`,其余 `>= 0`)。
|
||||
2. **新 `ForceSession` 与 `ForceStreamSession`**:`ForceStreamSession.process()` 消费 T05
|
||||
`ImageMeasurement` 并逐帧产生 `ForceStreamOutput`;`ForceSession` 的新签名是
|
||||
`process_frame(source_frame, stamp)`。late、duplicate、invalid 帧不再被丢弃,仍会产生诊断输出。
|
||||
3. **固定设计率输出滤波(T07)**:会话、回放与重滤使用同一个 `FixedRateOutputFilter`;
|
||||
设计频率固定,不随实测处理速度重新设计。
|
||||
4. **单路流入口(T08)**:SDK 的 `orisys.sensor_stream.SensorStream` 提供单路入口,App 的
|
||||
Studio 控制路径与回放都走同一个 SDK 会话。入口示例见
|
||||
[force-stream-session.zh.md](force-stream-session.zh.md) 的「SensorStream 入口」一节。
|
||||
5. **未滤波测量字段(T09c)**:`ForceStreamOutput` 新增 `measurement_raw`、`measurement_newton`,
|
||||
分别是输出滤波前的同帧测量;`raw`、`newton` 仍为最终输出。测量与输出从此可区分,
|
||||
late、duplicate 的诊断输出也能如实保存。
|
||||
|
||||
## 迁移要点
|
||||
|
||||
- 使用串流会话的调用方改用 `ForceStreamSession.process()` 与 T05 `ImageMeasurement`;
|
||||
字段搬移与最小示例见 [force-stream-session.zh.md](force-stream-session.zh.md)。
|
||||
- 直接使用 `ForceSession` 的调用方改用新签名 `process_frame(source_frame, stamp)`。
|
||||
- 需要未滤波测量的消费者请读 `measurement_raw`、`measurement_newton`;显示与需要最终值的
|
||||
消费者继续读 `raw`、`newton`。
|
||||
- 录制与分析的旧结果若没有 `measurement_source`,其“未滤波”字段可能其实保存的是滤波后的值;
|
||||
判读方式见 App 的 `record-channel-stream-2.1.md`。
|
||||
- 固定设计率滤波只应用于新会话与重滤;旧 `ForceReadout`、`Sensor` 数值预期不变,由 T15b
|
||||
整合验收确认。
|
||||
@@ -0,0 +1,129 @@
|
||||
# 场加权力标定模型契约
|
||||
|
||||
用途: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 的实验估计。
|
||||
不依赖深度、接触中心、最大光流点或单接触判定。SDK 的 `FingerRuntime` 可直接加载模型、
|
||||
声明加权空载窗口并逐帧推断;处理来源可以是录像或相机。尚未接入的是 Studio 实时采集界面,
|
||||
其应用入口仍显式拒绝场模型,不是 SDK 的来源限制。
|
||||
现有增益对应各加载位置的整场响应,不是已验证的局部系数。交付证明软件契约与可复现估计,
|
||||
不宣称局部压力、多接触力可加性或精度改善。负的基线差保留;显示阈值只由显示层决定。
|
||||
|
||||
SDK 使用示例(配置、资产、模型和设备身份须匹配):
|
||||
|
||||
```python
|
||||
from orisys.finger import FingerRuntime
|
||||
|
||||
runtime = FingerRuntime.open(
|
||||
config="sensor.json", export_dir="assets", video="input.mp4",
|
||||
cal_path="force_calibration.field.json", device_id="YOUR-SENSOR-SN",
|
||||
)
|
||||
try:
|
||||
# 仅在传感器无载时声明;video 也可以传相机编号。
|
||||
runtime.declare_unloaded_reference("unloaded-1")
|
||||
with runtime.pipeline(contact=False, include=()) as pipe:
|
||||
while (snapshot := pipe.wait()) is not None:
|
||||
if snapshot.calibration_status.get("fn") == "ok":
|
||||
print(snapshot.normal_force)
|
||||
finally:
|
||||
runtime.close()
|
||||
```
|
||||
|
||||
模型前 30 个有效处理帧用于建立零点;订阅者可能跳过中间快照,不能以收到的快照数判断就绪。
|
||||
运行中换模型必须由调用方在处理线程的帧边界执行,随后重新声明空载窗口;不能与运行中的
|
||||
pipeline 并发调用切换接口。当前独立验证覆盖录像的同步处理和 pipeline 软件链,
|
||||
不等于已验证相机实机精度、吞吐或延迟。
|
||||
|
||||
## 文件字段
|
||||
|
||||
`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 声明来源哈希、边界/基线策略和实验假设。
|
||||
+1
-1
@@ -40,7 +40,7 @@
|
||||
|
||||
延迟换算 @60fps 减半;数字来自 `_perf/`(该一次性目录已从 `orisys-sdk` 移除:幸存的脚本与片源
|
||||
迁到 `D:\orisys_ws\docs\performance\perf-measurements\`,其余已永久丢失;对照表见
|
||||
`D:\orisys_ws\docs\performance\orisys-sdk-perf-plan.md`,其中仍保留全部实测数字)。
|
||||
[历史测量记录](../../docs/archive/performance/orisys-sdk-perf-plan.md),其中仍保留全部实测数字)。
|
||||
≥1 帧的延迟与相位差是**因果滤波的代价**:旧显示端曾用零相位 `filtfilt`,那需要"未来",
|
||||
无法在采集里做,也让"显示 == 记录"不可能——所以现在只有一条因果链。
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# 新力侧会话与单路入口(T06a/T06b、T07 输出滤波、T08a/T08b)
|
||||
|
||||
本文件是单路 SensorStream 计划 T06a/T06b/T07 的 SDK 合同:新的力侧会话直接消费 T05 的
|
||||
`ImageMeasurement`(T03 `MeasurementFrame` 头 + 同帧 raw/field),参考版本取帧上的
|
||||
`reference_generation`。旧 `ForceSession` 仍为 App 保留到 T09/T10 迁移,本会话不提供
|
||||
`process_measurement`、`request_zero_external_reset`、`tracker_reset_seq` 等旧外部复位接口。
|
||||
|
||||
## 接口
|
||||
|
||||
- `ForceStreamSession(sensor_family=..., calibration=None, settings=None)`:构造。T06a 支持
|
||||
无标定与 schema2 标量曲线;T06b 补充 schema3 field 模型(`hhd_normal_l1_field/1`,需要
|
||||
`context` 与同帧 field)和 FN-only 全局增益模型(`outputs=["fn"]`);`settings` 为
|
||||
`OutputSettings`(None 用旧默认)。
|
||||
- `measurement_requirements`:`(want_field, want_contact)`,告诉 T05 调用方是否需要把
|
||||
`field` include 进 `ImageMeasurement`(field 模型为 `(True, False)`,标量/FN-only 为 `(False, False)`)。
|
||||
- `request_zero(reference_id=..., declared_unloaded=True, expected_reference_generation=版本, max_gap_s=None, max_window_s=None)`:
|
||||
为指定参考版本确认无载归零;可选窗口上限。
|
||||
- `process(measurement) -> ForceStreamOutput`:消费一个 T05 `ImageMeasurement`,返回逐帧结果
|
||||
(`frame` 携带 T03 帧头与 `reference_generation`,以及 `raw/newton/status/quality/zero`)。
|
||||
`measurement_raw`/`measurement_newton` 是同帧、**未经输出滤波**的测量三元组(T09c):接受帧取
|
||||
`ForceOutput.measurement` 的未滤波 raw/N;诊断帧的 `measurement_raw` 等于该帧 `force_raw`、
|
||||
`measurement_newton` 为 NaN。直通设置下它们与 `raw`/`newton` 逐位相同。
|
||||
|
||||
## 规则
|
||||
|
||||
- **版本/会话变化即废旧零点(K8.3)。** `reference_generation` 或 `session_id` 一旦变化,
|
||||
resetting、collecting、ready 任一阶段的旧零点立即失败(`zero.phase == "failed"`),
|
||||
必须对新的版本显式调用 `request_zero(expected_reference_generation=新版本)` 才能恢复有效 N。
|
||||
正常顺序(会话在处理 v0 → 复位得到目标 v1 → v1 到达)中,等于**请求目标**的新版本会被采用
|
||||
并开始/继续同版本窗口,不会作废这次请求;只有未请求的版本变化或版本超过目标才失败。
|
||||
旧的 `generation` 在采样开始后不再检查的缺陷(阶段 0 P3-2)由本会话修复。
|
||||
- **基线只取请求版本的帧(K8.3/K8.5)。** 归零请求绑定一个版本。目标到达前、仍属当前版本的
|
||||
在途帧记为 `zero_pending`(不是迟到,也绝不进入窗口);比当前版本更旧的帧才是 `late`;
|
||||
超过目标的版本使请求失败,而不是静默改绑。基线固定 30 帧无载输入;
|
||||
可选 `max_gap_s`/`max_window_s` 超限时失败并把原因写进 `zero.failure_reason`。
|
||||
- **重复、迟到、同时间戳(K4.2)。** 以 `sensor_id + session_id + source_frame_id`
|
||||
(无源编号时用 `frame_id`)去重,凡是送入引擎的帧都会登记身份。重复帧只产生诊断结果;
|
||||
旧版本和同时间戳/乱序帧只标记 `late`/`zero_pending`,不推进零点或滤波,也不作废旧零点。
|
||||
不同源帧时间戳相同不算重复。
|
||||
- **逐帧结果流(K6.1)。** 每个输入帧都返回一个冻结 `ForceStreamOutput`,携带 T03 帧头与
|
||||
`reference_generation`;`quality` 为 `ok` / `uncalibrated` / `zero_pending` / `duplicate` /
|
||||
`late` / `session_changed` / `version_changed` / `window_failed` / `invalid`。
|
||||
迟到、无效和带质量标记的结果不会被丢弃;拒绝帧仍保留 raw 特征。
|
||||
- **数值等价与滤波。** 零点/标定换算/读数滤波复用已发布算法;同一输入、无复位、同一标定时
|
||||
与旧 `ForceSession` 逐帧相等(T06a 覆盖标量曲线、无标定、median+lowpass 滤波器;T06b 覆盖
|
||||
field 模型与 FN-only 全局增益模型,三类标定都在同一平台同一次运行中对照旧会话)。
|
||||
- **三类标定与不匹配。** 标量曲线、field、FN-only 各有有效测试;无标定会话保持 raw 与
|
||||
`uncalibrated` 状态;field 模型缺 `context` 时构造报 `CalibrationError`,基线帧缺 field 或
|
||||
模型拒绝帧时零点失败并给出 `invalid_input`/原因;FN-only 的 FX/FY 永远保持 NaN +
|
||||
`uncalibrated`,不冒充三轴标定。
|
||||
- **输出滤波(T07)。** 设计帧率在构造时固定(`design_sample_rate_hz`),实测帧率漂移和长
|
||||
间隔只作为 `measured_sample_rate_hz`/`sample_rate_irregular` 诊断,不重新设计链路、不清空
|
||||
历史。`request_output_settings(settings)` 可从任意线程发出,只在力线程下一帧边界生效:只
|
||||
重建滤波链并清空其历史,图像参考与已完成的零点不变;相同设置是空操作。每次实际切换发出
|
||||
一个 `output_filter_changed` StreamEvent(规范化设置、指纹、算法版本、生效帧、设计帧率、
|
||||
预热信息,可 JSON 往返),由 `drain_events()` 取出。预热帧数为
|
||||
`max(1, median_window) + 2×lowpass_order(低通开启时)`;结果行提供当前设置身份与预热计数。
|
||||
全阶段关闭时输出与输入完全相同(含 NaN 与带符号的零)。已发布 `ForceOutputFilter`/旧会话
|
||||
的自适应调频默认行为不变。
|
||||
- **线程与只读。** 一个实例只属于一个力侧线程;输入数组只读,输出只含标量/状态/不可变进度。
|
||||
|
||||
## 输出滤波示例
|
||||
|
||||
```python
|
||||
from orisys.force_readout import OutputSettings
|
||||
from orisys.force_stream_session import ForceStreamSession
|
||||
|
||||
session = ForceStreamSession(
|
||||
sensor_family="box",
|
||||
settings=OutputSettings(sample_rate_hz=30.0, median_window=3, lowpass_hz=5.0),
|
||||
design_sample_rate_hz=30.0,
|
||||
)
|
||||
new_settings = OutputSettings(sample_rate_hz=30.0, median_window=5, lowpass_hz=10.0)
|
||||
session.request_output_settings(new_settings)
|
||||
assert session.output_settings.median_window == 3, "switch waits for a frame boundary"
|
||||
assert session.design_sample_rate_hz == 30.0
|
||||
```
|
||||
|
||||
## SensorStream 入口(T08a/T08b)
|
||||
|
||||
`SensorStream` 把 T04–T07 串成一个运行中的单路入口:
|
||||
|
||||
- **所有权。** `SensorStream.open(source, ...)` 自己打开 Sensor 并在 `stop()` 关闭;调用方传入
|
||||
已打开的 Sensor 或外部喂帧时只借用,`stop()` 不关闭借用源。运行中只有一个采集所有者;
|
||||
流在 `start()` 启用 measurement session,`stop()`/启动失败时恢复借用 Sensor 原来的模式
|
||||
(原本已启用的保持启用)。
|
||||
- **实时 latest(T08a)。** `push_frame(...)` 的未领取候选帧会被最新输入覆盖并计入
|
||||
`overwritten_frames`;已经交给流水线的帧照常完成。`wait_output()` 是 latest-wins 结果流,
|
||||
`drain_events()` 取会话事件。
|
||||
- **文件/debug 完整(T08b)。** `realtime=False` 时逐帧处理每个被接受的输入:外部帧进入
|
||||
有上限的 FIFO(`depth`),计算跟不上时 `push_frame` 阻塞并计入 `backpressure_waits`,不丢帧;
|
||||
`end_input()` 结束序列,`wait_outputs(n)`/`drain_outputs()` 按发布顺序返回结果。内置文件源由
|
||||
`SensorStream.open(path, realtime=False, nominal_fps=...)` 打开,同样逐帧处理。文件模式下,
|
||||
只有在**所有已接受帧都已通过力阶段**(`published >= accepted`)且零点仍未完成时,
|
||||
`reset_and_zero` 才以 `SensorStreamError`(“input ended before unloaded zero completed”)
|
||||
结束;生产者刚读到结束标记、管线中还有已接受帧时不会提前判失败,足够的已接受帧仍会正常完成零点。
|
||||
- **线程/版本。** 复用 `StreamPipeline` 的线程与原子 `request_reset()` 版本;力侧只在 HHD
|
||||
线程的 `on_frame` 中调用 `ForceStreamSession`,因此 T07 的 `drain_events` 线程约束由门面满足,
|
||||
没有第二套版本或复位记账。
|
||||
- **组合归零。** `reset_and_zero(reference_id=...)` 执行“图像复位确认 → 同版本 30 帧无载基线 →
|
||||
力 ready”;用户不管理版本号。在途旧版本帧不会进入新基线(T06a 的规则)。每次调用只等待
|
||||
本条命令的结果:力线程把该命令的 `ZeroProgress`/错误写回命令对象并通知,调用方线程不读取
|
||||
`session.zero`,因此连续两次调用时第二次不会复用第一次的零点。**超时后命令仍在力线程进行**
|
||||
(仍可能稍后 ready),调用方不应假设它被取消;在下一条命令完成前再次调用会被拒绝。
|
||||
- **时间来源(T08b)。** 实时相机 `capture_monotonic`,内置采集用 `CapturedFrame.host_mono_ns`
|
||||
和 `sequence`;Orisys 自己录像(文件内保存信号时间)`recorded_signal`;外部调用方提供可证明
|
||||
的容器 PTS 才 `video_pts`(`source_pts` 原值保留);内置 OpenCV 普通视频用
|
||||
`estimated_source`,`capture_time_ns = source_frame_id ÷ nominal_fps`,`CAP_PROP_POS_MSEC`
|
||||
只作为 `diagnostics` 的 `pos_msec`,不填 `source_pts`。没有时间的输入按帧号推算并标
|
||||
`estimated_source`。同一 PTS 的不同源帧是两帧;入口按源身份忽略重复/迟到输入并计数。
|
||||
- **诊断与 field(T08b)。** 结果行的 `diagnostics` 含覆盖/重复/迟到计数、处理延迟、
|
||||
`long_gap`、`source_dt_s`、`source_time_estimated` 和 `pos_msec`(有则);`source_dt_s` 取同路
|
||||
上一条实际处理帧的源时间差。`include=("field",)` 让 `StreamPipeline` 在 HHD 线程 `_build`
|
||||
中复制同帧 field;门面在标定模型需要 field 时自动启用。默认快照不含 field,已发布场不会被
|
||||
后续帧改写。
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
from orisys.sensor import Sensor
|
||||
from orisys.sensor_stream import SensorStream
|
||||
|
||||
sensor = Sensor("external", config_name="box", verbose=False, backend="cpu")
|
||||
stream = SensorStream(sensor, sensor_id="BOX-001", session_id="run-1")
|
||||
stream.start()
|
||||
try:
|
||||
stream.push_frame(
|
||||
np.full((365, 365), 100, dtype=np.uint8),
|
||||
source_frame_id=0,
|
||||
capture_time_ns=1_000_000_000,
|
||||
)
|
||||
output = stream.wait_output(timeout=5.0)
|
||||
assert output is not None and output.frame.stamp.sensor_id == "BOX-001"
|
||||
finally:
|
||||
stream.stop()
|
||||
```
|
||||
|
||||
## 示例
|
||||
|
||||
```python
|
||||
from orisys.force_stream_session import ForceStreamSession
|
||||
from orisys.image_pipeline import ImageMeasurement
|
||||
from orisys.stream_contract import FrameStamp, MeasurementFrame
|
||||
|
||||
session = ForceStreamSession(sensor_family="box")
|
||||
session.request_zero(reference_id="run-1", declared_unloaded=True,
|
||||
expected_reference_generation=0)
|
||||
frame = MeasurementFrame(
|
||||
stamp=FrameStamp(sensor_id="BOX-001", session_id="run-1", frame_id=1,
|
||||
source_frame_id=1, capture_time_ns=33_333_333,
|
||||
clock_id="host", capture_time_basis="capture_monotonic"),
|
||||
source_dt_s=None, reference_generation=0,
|
||||
)
|
||||
row = session.process(ImageMeasurement(frame=frame, force_raw=(0.0, 0.0, 0.0),
|
||||
torque=0.0, flow=None))
|
||||
assert row.frame.reference_generation == 0
|
||||
assert row.zero.phase in ("resetting", "collecting")
|
||||
```
|
||||
|
||||
## 后续
|
||||
|
||||
T06b 已交付标量/field/FN-only 三类标定与旧 ForceSession 的完整数值等价;T07 已交付固定
|
||||
设计帧率、显式切换事件与历史/直通合同;T08a 已交付 SensorStream 实时入口、所有权与组合归零;
|
||||
T08b 已交付文件/debug 完整逐帧、有界背压、时间来源/PTS 标注、`include="field"` 与诊断。
|
||||
spatial_gain/接触映射模型不在 T06/T07 验收范围,需要时另立任务。
|
||||
下一步进入 T09/T10(App 迁移);删除旧 `ForceSession` 前先把零点、标定、读数引擎从旧类中
|
||||
抽出来,避免新会话依赖待删除的类型。
|
||||
@@ -0,0 +1,111 @@
|
||||
# 力读数工作流(`ForceSession`)
|
||||
|
||||
用途:SDK 显式力读数会话(`studio-force/1`)的公共接口参考。状态:已实现并接入 Studio,2026-10-20;
|
||||
硬件、真实相机与真实载荷精度未验证。需求与验收矩阵见
|
||||
[流程规格](../../docs/workflow/studio-force-workflow.zh.md),实施状态见
|
||||
[重写计划](../../docs/workflow/studio-force-rewrite-plan.zh.md)。
|
||||
|
||||
测量、标定模型、空载基线与输出后处理全部由本会话拥有;调用方只提供源帧与帧身份,
|
||||
发送归零/换模型请求,并分发返回的 `ForceOutput`。这样同一帧只测量、推断、后处理各一次,
|
||||
显示与记录拿到同一个不可变输出。
|
||||
|
||||
## 入口与版本
|
||||
|
||||
```python
|
||||
from orisys import ForceSession, WORKFLOW_VERSION
|
||||
```
|
||||
|
||||
`WORKFLOW_VERSION = "studio-force/1"` 是处理顺序与语义的稳定身份,随每个输出返回,
|
||||
记录、缓存键与诊断引用它;改变顺序或语义时必须提升该版本。
|
||||
|
||||
构造显式启用测量源(`Sensor` 或 `FingerRuntime`)的会话接缝:
|
||||
|
||||
```python
|
||||
session = ForceSession(
|
||||
source=runtime, # 已打开、由调用方负责 close
|
||||
sensor_family="finger", # box | mini | finger | finger-mems
|
||||
device_id="FINGER-001", # 可选,用于标定身份校验
|
||||
context=context, # schema 3 模型需要的实际管线身份
|
||||
calibration=model, # Calibration(schema 1/2)、ModelCalibration(schema 3)或 None
|
||||
settings=output_settings, # 可选;默认 OutputSettings(sample_rate_hz=30.0)
|
||||
)
|
||||
```
|
||||
|
||||
构造失败不会留下“半切换”的会话;旧 `Sensor.read_info` / `ForceReadout.apply` 路径在
|
||||
未构造 `ForceSession` 时保持原语义,本模块不与旧路径并行运行。
|
||||
|
||||
## 主要方法与属性
|
||||
|
||||
| 成员 | 说明 |
|
||||
|---|---|
|
||||
| `process_frame(source_frame, stamp) -> ForceOutput` | 唯一逐帧入口:测量 → 归零状态 → 标定 → 后处理。同一 `(session_id, frame_id)` 重复调用返回同一缓存对象且不推进状态;乱序帧显式失败并清除完成标志 |
|
||||
| `read_sdk_frame(source_frame, stamp) -> SdkReading` | 只做同帧测量(图像准备、光流/HHD、可选接触/场),不推断、不读数滤波 |
|
||||
| `request_zero(*, reference_id, declared_unloaded, replay_window=None) -> ZeroProgress` | 启动唯一空载归零操作;`declared_unloaded` 必须为空载确认;拒绝带载相对置零 |
|
||||
| `update_zero(reading) -> ZeroProgress` | 推进归零状态机并执行本帧一次模型推断;迟到代次的结果被忽略 |
|
||||
| `invalidate(*, reason) -> ZeroProgress` | 断流、取消、换模型或测量配置变化时作废基线/完成状态与滤波历史 |
|
||||
| `apply_calibration(reading, zero) -> CalibratedReading` | 不复用旧结果、不重复推断,给出未做读数滤波的 raw/N 与逐轴状态 |
|
||||
| `apply_filter(measurement) -> ForceOutput` | 唯一后处理:raw 与 N 各自独立地 median → low-pass → 可关闭逐轴阈值 |
|
||||
| `set_calibration(calibration, *, context=None)` | 帧边界原子换模型;校验失败保留原模型、基线与完成状态 |
|
||||
| `zero`、`calibration_fingerprint`、`output_settings`、`last_output` | 只读状态/身份;`output_settings` 是实际生效参数 |
|
||||
|
||||
## 帧身份
|
||||
|
||||
`FrameStamp(session_id, frame_id, source_frame_id, signal_time_s, time_basis)`;后处理低通的
|
||||
采样率来自信号时间,不取离线处理耗时。实时与顺序录像只替换源适配器,其余流程相同。
|
||||
|
||||
## 归零(空载基线)
|
||||
|
||||
1. `request_zero(declared_unloaded=True, reference_id=...)` 只登记请求并递增参考代次;
|
||||
2. 处理线程在下一个帧边界重置 tracker,然后才测量该帧;
|
||||
3. 采集重置后连续 30 个有效处理帧,按模型类型得到基线:标量模型取 raw 中位数,
|
||||
场模型委托 `ModelCalibration` 用同一增益图加权后的 30 帧中位数;
|
||||
4. 第 30 帧仍为基线帧;第 31 帧起 `phase="ready"`、`completed=True` 并输出 N,
|
||||
`effective_from_frame_id` 指向该帧。
|
||||
|
||||
基线未完成、无效帧、窗口不足或已取消时 N 为 `NaN`、状态显式;窗口不足不越界补帧。
|
||||
录像重算用 `replay_window=(start, end)` 在声明区间执行同一操作。
|
||||
|
||||
三级流水线已有同帧测量时,用 `process_measurement(row, stamp, tracker_reset_seq=...)`
|
||||
传入快照自带的参考版本,不读流水线的实时计数。外部归零先调用
|
||||
`target = pipeline.request_reset()`,再调用
|
||||
`request_zero_external_reset(reference_id=..., declared_unloaded=True, target_reference_version=target)`。
|
||||
`target` 为正整数,帧版本达到它才开始采样;较早复位后已在途的帧不能确认本次请求。
|
||||
未被 flow 领取的请求合并为同一目标,领取后的新请求另分配目标;共用目标的复位与归零只确认一次。
|
||||
该接缝尚未发布,本轮将原 `tracker_reset_seq` 请求参数改为明确的目标版本参数;
|
||||
逐帧输入仍用 `tracker_reset_seq`。已发布 `Sensor.reset()` / `StreamPipeline.reset()` 用法不变。
|
||||
实现见 `pipeline.py`、`force_session.py`,回归见 `tests/test_pipeline.py`、`tests/test_force_session.py`。
|
||||
|
||||
## 后处理与有效设置
|
||||
|
||||
`OutputSettings(sample_rate_hz, median_window=1, lowpass_hz=0.0, lowpass_order=2,
|
||||
raw_threshold=0.0, newton_threshold=0.0)`。每阶段由自身值关闭(median=1、lowpass=0、
|
||||
threshold=0),全关时对有效值严格直通(NaN 保留);无效轴不进入滤波历史。
|
||||
`ForceOutput.settings` 返回实际生效的参数(例如截止频率达到 Nyquist 时解析为 off)。
|
||||
|
||||
## 离线重滤波
|
||||
|
||||
```python
|
||||
from orisys.force_readout import process_output_sequence
|
||||
|
||||
raw_out, newton_out, effective = process_output_sequence(
|
||||
settings, raw_samples, newton_samples,
|
||||
raw_valid=None, newton_valid=None, signal_times_s=times,
|
||||
)
|
||||
```
|
||||
|
||||
该函数只重放输出级(带完整 warm-up),用于从已存未滤波序列生成新参数下的最终输出;
|
||||
不重新测量、不打开模型链。
|
||||
|
||||
## 状态与诊断
|
||||
|
||||
逐轴状态:`ok`、`out_of_range`、`zero_unavailable`、`no_contact`、`coordinate_unmapped`、
|
||||
`outside_coverage`、`invalid_input`、`uncalibrated`、`measurement_failed`、`frame_order`、
|
||||
`signal_time_order`。仅 `ok`/`out_of_range` 携带可用牛顿值;模型不输出的轴(例如 FN-only
|
||||
模型的 FX/FY)恒为 `NaN` + `uncalibrated`。缺失值与有效零分开。
|
||||
|
||||
`CalibratedReading.diagnostics` 提供 `reference_id`、`baseline_N`、`baseline_raw_fn/fx/fy`、
|
||||
`model_fingerprint`、`mapping`、`extrapolated_fraction`、`coverage_fraction`、`area_scale`、
|
||||
`pending_reference_frames` 等;调用方存储或展示,不据此改写逐轴有效性。
|
||||
|
||||
场模型质量(外推、覆盖)与 downsample 差异属于诊断,不替代有效性判断;
|
||||
`hhd_downsample` 不同是唯一允许的管线身份差异,其余差异仍拒绝并报告。
|
||||
@@ -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。
|
||||
附件间依赖也须逐项声明和校验,未声明的外部权重不可加载。
|
||||
@@ -106,6 +106,10 @@ Release. See the launcher help and [build/README.md](../build/README.md) for the
|
||||
exact flags. A published version cannot be overwritten, so do not treat a rerun as
|
||||
a way to replace bad wheels.
|
||||
|
||||
GitHub tags default to `v<version>`. Pass `--tag <version>` for a tag without the
|
||||
prefix; both forms must match the certified version. Use `--notes-file <path>`
|
||||
to publish reviewed release notes.
|
||||
|
||||
The release order is build all candidates, commit source changes, run the clean
|
||||
preflight, preview both publishers, then execute the authorized publications.
|
||||
GitHub attaches all certified wheels and generates notes naming their actual
|
||||
|
||||
+32
-3
@@ -49,6 +49,15 @@ bundle with no manifest still resolves through the compatibility adapter.
|
||||
|
||||
## Compatibility for existing bundles
|
||||
|
||||
The shipped Finger compute assets under `sensors/finger/assets/30fps-0703/` mirror the
|
||||
[Studio calibration baseline](../../orisys-app/apps/studio/config/sensors/finger/assets/30fps-0703/).
|
||||
Synchronize the complete mesh, camera intrinsics, camera pose and UV cache together; the current
|
||||
profile uses `source_scale=0.42`. The SDK retains its own copy and has no runtime dependency on a
|
||||
Studio checkout. The obsolete `0.4` cache from the previous geometry is not shipped.
|
||||
`tests/test_finger_asset_identity.py` pins the verified calibration geometry and domain identity.
|
||||
Changing SHA fields alone does not migrate a calibration; regenerate and validate its coordinate
|
||||
artifact against the selected bundle before using its gain map.
|
||||
|
||||
`resolve_bundle` reads a new-shape descriptor when `schema_version` is present. Otherwise - no
|
||||
manifest, or the older `name`/`sensor_json`/`assets_dir`/`viz_pose` shape - the adapter describes
|
||||
it. That is the only place a folder name is consulted, and only for the built-in set:
|
||||
@@ -98,6 +107,16 @@ selection.
|
||||
| `single_camera` | `camera` config | frames only - no deformation field, so no force |
|
||||
| `uv_unroll` | `camera` config, the mapped computational asset | unrolled frame, flow, force |
|
||||
|
||||
### Tile detection margin
|
||||
|
||||
`compound_eye` detects `n_boxes_target` tiles (15 by default) when it opens. A module mounted off
|
||||
centre can show an extra column at one edge, and detection then never settles on 15.
|
||||
`stitcher.exclude_x` is `[left, right]` in raw-frame pixels: a detected region whose centre lies
|
||||
within that distance of the edge is not counted. It changes only which tiles are chosen; the frames
|
||||
used afterwards are untouched. The `box` bundle ships `[0, 240]`, which drops an extra right column
|
||||
(about 170 px from the edge) and keeps the outer column of an aligned module (about 320 px). A
|
||||
module whose extra column is on the left needs `[240, 0]` in its own copy of `sensor.json`.
|
||||
|
||||
`known_backends()` and `spec_for(...)` are the registry. `uv_unroll` is not yet materialised from a
|
||||
profile alone, because it needs geometry, a UV domain, a tactile processor and a capture object
|
||||
assembled together - that is `orisys.finger.FingerRuntime.open`'s shape. `create_processor` says so
|
||||
@@ -156,6 +175,16 @@ compute the same numbers.
|
||||
## Resetting the reference
|
||||
|
||||
Both backends process externally supplied frames through the same profile. After a gap in the
|
||||
input - a pause, a seek, a scene change - call `session.reset()`. It is queued to the stage that
|
||||
owns the tracker, so the reference frame is rebased on the frame about to be computed rather than
|
||||
on a buffer the pipeline is still reading.
|
||||
input - a pause, a seek, a scene change - call `session.reset()`. It is queued to the stages that
|
||||
own the sensor state, so the reference frame is rebased on the frame about to be computed rather
|
||||
than on a buffer the pipeline is still reading: the flow stage resets the image/tracker state and
|
||||
bumps the reference version, and the HHD stage resets the force/readout state (filters, contact
|
||||
flags, calibration session) on the first frame carrying the new version, before decomposing it.
|
||||
`FrameSnapshot.tracker_reset_seq` is the version that frame was measured with.
|
||||
|
||||
When driving `StreamPipeline` directly, call `target = pipeline.request_reset()` to obtain the
|
||||
future reference version reserved by that request. Confirm it only when a snapshot carries
|
||||
`tracker_reset_seq >= target`; the current output frame can trail an earlier reset and cannot
|
||||
identify a new request. Requests share a target until flow claims it; a later request gets a new
|
||||
target even while the earlier reset is still being applied. The existing `reset()` returns `None`.
|
||||
Regression coverage lives in `tests/test_pipeline.py` (request coalescing and reset-in-progress).
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
# 单路流类型合同(`stream-contract/1`)
|
||||
|
||||
用途:reference。范围:`orisys.stream_contract` 冻结的逐帧身份、采集时钟、测量头和事件信封。
|
||||
|
||||
状态:T03 已验收(done),2026-10-21。只冻结类型、校验和 JSON 往返,不接入现有 Sensor、StreamPipeline 或 ForceSession;T05、T06、T08、T11、T13–T15 复用这些头,不再各自另立字段。
|
||||
|
||||
## 为什么先冻结共享头
|
||||
|
||||
阶段 0 修复的是参考版本传递和复位线程归属;阶段 1 要让图像块、力会话、单路入口以及 Studio 的记录与分析使用同一份逐帧身份和时标。若每层各写一套字段,就会重新出现“同一 PTS 的两个源帧被当成重复”“文件名代替标定版本”“处理耗时冒充源时间”这类旧缺陷。本任务把这些共享字段冻结成可 JSON 往返的 frozen 数据类;后续任务只增加各自负载,不改已有字段含义。
|
||||
|
||||
## 稳定性规则
|
||||
|
||||
- `STREAM_CONTRACT_VERSION = "stream-contract/1"` 写入每个可持久化对象的 `to_dict()`。采用严格读取(政策 A):**任何新增、改名、删除或改类型的字段都升版本**,不存在同版本新增字段。
|
||||
- 读取方只读 `SUPPORTED_CONTRACT_VERSIONS` 中显式列出的版本,并拒绝未列出的版本和未知字段;写方要加字段时必须升版本,并把新版本加入读取方列表。
|
||||
- 增加新事件 `kind` 属于兼容变化:消费者忽略不认识的 `kind`,不因未知事件失败;但 `kind` 本身的名称格式受信封合同约束(见下)。
|
||||
- 构造即校验;非法数据立即失败,不生成“看起来合理”的默认值。缺失组身份不伪造同步,缺失时钟不补假时间。
|
||||
- 本任务不改变已发布接口:`Sensor.reset()`、`StreamPipeline` 快照和当前 `ForceSession` 行为不变;T06 重做会话时才改为直接消费这里定义的测量头。
|
||||
|
||||
## 帧身份
|
||||
|
||||
`FrameIdentity` 表达“哪一路的哪一帧”,字段如下。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `sensor_id` | `str` | 逻辑 Sensor 身份,非空 |
|
||||
| `session_id` | `str` | 一次运行/录制的会话身份,非空 |
|
||||
| `frame_id` | `int` | 该路连续输出序号,`>= 0` |
|
||||
| `source_frame_id` | `int \| None` | 源自己的帧号;源没有编号时为 `None`,有编号时必须保留原值 |
|
||||
| `acquisition_id` | `str \| None` | 可选组身份之一,与 `sync_group_id` 同时出现或同时缺失 |
|
||||
| `sync_group_id` | `str \| None` | 可选组身份之一,不得只给一半 |
|
||||
|
||||
规则:
|
||||
|
||||
- 去重、缓存和迟到判断以源身份为准;时间戳不是身份。两个源帧可以有相同的 PTS,它们仍是两帧。
|
||||
- 相同时间戳不触发去重、清零、复位或缓存替换;入口如何忽略重复输入属于 T08,本任务只保证类型层面可区分。
|
||||
- 组身份缺失表示本路独立,不表示已同步;不伪造 acquisition/sync 值。
|
||||
- 组身份即使存在,也只说明采集组归属;跨时钟映射和同步容差仍需后续计划实测,不能由字段存在推断。
|
||||
|
||||
## 采集时钟
|
||||
|
||||
`FrameClock` 保存一帧的采集时间和它的时间来源。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `capture_time_ns` | `int` | 采集时间,整数纳秒,`>= 0` |
|
||||
| `clock_id` | `str` | 时钟域标识,例如 `host_monotonic`、`video:recording-4` |
|
||||
| `capture_time_basis` | `str` 枚举 | 见下表 |
|
||||
| `source_pts` | `int \| float \| None` | 源时间戳原值(单位由源声明),与处理时间无关 |
|
||||
|
||||
`capture_time_basis` 的取值:
|
||||
|
||||
| 取值 | 含义 |
|
||||
|---|---|
|
||||
| `capture_monotonic` | 实时源的主机单调采集/接收时间 |
|
||||
| `recorded_signal` | 录像保存的信号时间(回放/debug) |
|
||||
| `video_pts` | 容器真实 PTS 换算成纳秒 |
|
||||
| `estimated_source` | 无可靠 PTS,按源帧号和名义帧率推算;消费方必须当作估算值 |
|
||||
|
||||
规则:
|
||||
|
||||
- 不能用“处理计数 × 名义周期”冒充 `video_pts`,不用离线处理耗时作为信号时间,也不伪造递增 PTS。没有可信 PTS 时明确标为 `estimated_source`。
|
||||
- `source_pts` 保留原值,不因换算成 `capture_time_ns` 而丢弃;发布日期、写入时间由结果信封另记,不写进采集时间。
|
||||
- 时钟域之间不隐式换算;需要换算时由上层显式声明来源。
|
||||
|
||||
## 帧头 FrameStamp
|
||||
|
||||
`FrameStamp` 是身份加时钟的扁平帧头,可持久化形式如下;`identity` 和 `clock` 属性返回对应的两个组件,`from_parts()` 从组件构造。
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "stream-contract/1",
|
||||
"sensor_id": "FINGER-001",
|
||||
"session_id": "run-7",
|
||||
"frame_id": 12,
|
||||
"source_frame_id": 41,
|
||||
"capture_time_ns": 1600000000000000000,
|
||||
"clock_id": "host_monotonic",
|
||||
"capture_time_basis": "capture_monotonic",
|
||||
"source_pts": 41.5,
|
||||
"acquisition_id": null,
|
||||
"sync_group_id": null
|
||||
}
|
||||
```
|
||||
|
||||
## 测量头 MeasurementFrame
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `stamp` | `FrameStamp` | 本帧身份与采集时钟 |
|
||||
| `source_dt_s` | `float \| None` | 与同一 Sensor 上一条实际处理帧之间的源时间差,单位秒;第一帧为 `None`,其余 `>= 0` |
|
||||
| `reference_generation` | `int` | 计算该帧光流时生效的参考版本,`>= 0` |
|
||||
|
||||
`source_dt_s` 的定义和边界:
|
||||
|
||||
- 用声明的采集时钟计算源时间差,不是处理速度,也不是相邻输出序号的固定周期。
|
||||
- 相同源时间(`0.0`)是合法值,表示两帧时间相等;不能因此把两帧合并。
|
||||
- 长间隔只产生警告和质量标记,不自动复位、清零或改滤波设计帧率;阈值由后续实测确定(K4.5、K9)。
|
||||
- `reference_generation` 由图像线程在该帧计算光流时写入,随后沿用到结果;不能在 HHD 输出或 Studio 消费时读取全局最新计数补标签(K8.1)。迟到旧版本的样本不能进入新零点基线。
|
||||
|
||||
测量负载(flow、HHD 场、接触、场标定特征)以及力结果、批次和记录格式不在本任务冻结;T05、T06 定义负载并在本头上扩展,T11、T13–T15 复用同一身份和时标。
|
||||
|
||||
## 事件信封 StreamEvent
|
||||
|
||||
`StreamEvent` 是状态变化事件的公共信封:参考复位、归零进度、滤波切换、长间隔、跳帧等都由后续任务登记具体 `kind` 与 `payload` 字段。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `kind` | `str` | 稳定的 lower_snake 事件名,必须匹配 `^[a-z][a-z0-9_]*$`;未知 kind 必须被消费者忽略而不是报错 |
|
||||
| `stamp` | `FrameStamp \| None` | 事件所属帧;无帧事件为 `None` |
|
||||
| `payload` | `Mapping[str, Any]` | 只允许 JSON 类型,构造时校验并冻结为只读深拷贝 |
|
||||
| `event_version` | `int` | 信封版本,`>= 1`;信封结构不兼容变化时递增 |
|
||||
|
||||
`payload` 拒绝 `NaN`、无穷、`bytes`、集合和非字符串键,因为这些值无法无损通过 `json.dumps`/`json.loads` 往返。事件序列化后必须能原样读回,记录才能真正复现在线状态。`StreamEvent` 可哈希:`payload` 冻结但不参与哈希,哈希只由 `kind`、`stamp` 和 `event_version` 决定,因此相等事件哈希相同;不同 payload 的事件允许落在同一哈希桶。
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "stream-contract/1",
|
||||
"kind": "source_gap",
|
||||
"event_version": 1,
|
||||
"stamp": {
|
||||
"schema": "stream-contract/1",
|
||||
"sensor_id": "BOX-3",
|
||||
"session_id": "run-7",
|
||||
"frame_id": 3,
|
||||
"source_frame_id": 12,
|
||||
"capture_time_ns": 1600000000000000000,
|
||||
"clock_id": "host_monotonic",
|
||||
"capture_time_basis": "capture_monotonic",
|
||||
"source_pts": null,
|
||||
"acquisition_id": null,
|
||||
"sync_group_id": null
|
||||
},
|
||||
"payload": {"source_dt_s": 0.5, "dropped_frames": 3}
|
||||
}
|
||||
```
|
||||
|
||||
## JSON 往返与严格校验
|
||||
|
||||
每个可持久化类型提供 `to_dict()` 和 `from_dict()`。`to_dict()` 带 `schema`;`from_dict()` 只接受 `SUPPORTED_CONTRACT_VERSIONS` 中显式列出的版本,并校验必填字段、未知字段和字段类型,损坏的记录立即失败而不是被部分接受。下面的例子可以直接运行:
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
from orisys import FrameClock, FrameIdentity, FrameStamp, MeasurementFrame
|
||||
|
||||
stamp = FrameStamp.from_parts(
|
||||
FrameIdentity(
|
||||
sensor_id="FINGER-001",
|
||||
session_id="run-7",
|
||||
frame_id=12,
|
||||
source_frame_id=41,
|
||||
),
|
||||
FrameClock(
|
||||
capture_time_ns=1_600_000_000_000_000_000,
|
||||
clock_id="host_monotonic",
|
||||
capture_time_basis="capture_monotonic",
|
||||
),
|
||||
)
|
||||
text = json.dumps(MeasurementFrame(stamp, source_dt_s=0.033).to_dict())
|
||||
measurement = MeasurementFrame.from_dict(json.loads(text))
|
||||
assert measurement.stamp == stamp
|
||||
```
|
||||
|
||||
未知字段被拒绝,是为了让不同版本的记录不会静默错配。采用政策 A:字段变更(包括新增字段)一律升 `STREAM_CONTRACT_VERSION`,同时把新版本加入读取方的 `SUPPORTED_CONTRACT_VERSIONS`;同版本内不新增字段。
|
||||
|
||||
## Sensor 上下文(只读绑定)
|
||||
|
||||
`Sensor.context` 返回一个 frozen `SensorContext`(`from orisys.sensors import SensorContext`),在构造时为本实例绑定一次以下内容,并在显式换标定时整体替换:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `sensor_id` | 仅显式提供的设备 SN(`device_id`);未显式绑定为 `None`,绝不由枚举顺序、文件夹名或相机编号猜测 |
|
||||
| `product_family` / `processing_backend` | 由配置/资产包确定的产品家族与图像处理后端 |
|
||||
| `config_name` / `config_source` / `config_fingerprint` | 配置名、实际读取的 JSON 文件、加载后配置的规范化 SHA-256 |
|
||||
| `bundle_root` / `manifest_digest` / `assets` | 资产包根目录、manifest 内容摘要、manifest 声明的资产角色与包内相对路径(legacy manifest 与 descriptor manifest 都支持;运行时实际加载路径可能不同) |
|
||||
| `calibration_path` / `calibration_fingerprint` | 当前**正在生效**的力标定文件与内容指纹;未标定或 `set_calibration(None)` 后为 `None`/`"none"` |
|
||||
| `diagnostics` | 绑定过程中可容忍的资产/配置说明,不改变 Sensor 的构造结果 |
|
||||
|
||||
规则:
|
||||
|
||||
- 帧身份/时钟字段不在这里重复:`context.frame_identity(session_id=..., frame_id=..., source_frame_id=..., acquisition_id=..., sync_group_id=...)` 直接构造 T03 的 `FrameIdentity`。没有显式 SN 时它抛 `ValueError`,不伪造身份。
|
||||
- 每个 Sensor 实例独立绑定,没有模块级共享缓存;创建或使用另一个 Sensor 不会改变本实例的上下文。
|
||||
- 显式 `Sensor.set_calibration()` 换标定时原子替换上下文:SN/配置/资产绑定保持不变,标定路径与指纹更新;`set_calibration(None)` 后 `calibration_path` 立即为 `None`、指纹为 `"none"`,不保留构造时请求过的旧路径。
|
||||
- 绑定异常只写入 `diagnostics`,不把原本可构造的 Sensor 变成构造失败。
|
||||
- `assets`/`manifest_digest` 是 **manifest 声明的绑定**,不是运行时实际加载的路径:Finger 的导出目录由 `finger/exports.py` 在 cwd、打包目录、可编辑安装目录等候选中另行查找;T05 的 ImagePipeline 消费已准备帧,不重新选择资产,需要复现实验时以记录的身份为准。
|
||||
- 资产取应用包内相对路径,不复制、不上传、不按顺序推断;配置指纹取实际加载后的配置内容,而不是文件名。
|
||||
- 本任务(T04)不改变 Sensor 的数值与既有资产包解析行为,也不把上下文接入流入口;T05/T08 消费该上下文。
|
||||
|
||||
```python
|
||||
from orisys import configs
|
||||
from orisys.sensors import build_sensor_context
|
||||
from orisys.stream_contract import FrameIdentity
|
||||
|
||||
context = build_sensor_context(
|
||||
config_name="box",
|
||||
config=configs.load_config("box"),
|
||||
device_id="BOX-001",
|
||||
bundle_candidates=["box"],
|
||||
)
|
||||
identity: FrameIdentity = context.frame_identity(
|
||||
session_id="run-7", frame_id=12, source_frame_id=41,
|
||||
)
|
||||
assert identity.sensor_id == "BOX-001"
|
||||
```
|
||||
|
||||
## ImagePipeline(T05)
|
||||
|
||||
图像块(ImageSettings → 光流 / HHD / 场)已从 `Sensor` 抽成可独立运行的对象;`Sensor`、`FingerRuntime` 与 `StreamPipeline` 委托同一实现(Finger 的 UV 展开与采集仍留在运行时侧,`runtime.image_pipeline` 暴露该对象)。
|
||||
|
||||
| 组成 | 说明 |
|
||||
|---|---|
|
||||
| `ImageSettings` | 一次图像参数的声明式身份:tracker 配置、HHD 下采样/后端与 masked-Poisson 参数、光流 EMA 初值、cuda;`from_config(sensor.json)` 读取与 Sensor 相同的键。 |
|
||||
| `ImagePipeline` | 拥有 tracker、nHHD、EMA 与 scratch 状态;`compute_flow()`、`decompose()`、`current_field()`;光流 EMA 系数与历史由传入的 `ForceReadout` 单一持有(独立使用时自建一个)。 |
|
||||
| `ImageMeasurement` | T03 `MeasurementFrame` 头 + 光流/HHD/场/force 的只读副本;`frame.reference_generation` 由计算该帧的图像线程写入。 |
|
||||
|
||||
规则:
|
||||
|
||||
- 输入是**已准备**的帧(采集层完成 stitch/unroll/resize/mirror);独立实验直接喂灰度帧即可,不必构造 Sensor:`SensorContext` 给 SN/身份,`ImageSettings` 给图像参数。
|
||||
- `include=` 选择要复制的数组(image/flow/vnormal/vshear/field/valid_mask);返回数组是独立副本且 `writeable=False`,发布后不会被后续帧覆写(K11.3)。
|
||||
- 参考版本只有一个来源:驱动它的 `StreamPipeline` 领取版本后经 `set_stream_reference_generation()` 交给图像块(逐帧、计算前);独立实验把版本直接传给 `measure(reference_generation=...)`。`ImagePipeline` 不自己计数,`reset_image_state()`/`reset_tracker()` 只重置状态。
|
||||
- 光流 EMA 的系数与历史属于 `ForceReadout`,因此已发布的 `Sensor.configure(flow_ema_alpha=...)` 从下一帧起生效(与拆分前一致);运行时修改后 `ImageSettings.flow_ema_alpha` 仍只表示构造时声明。
|
||||
- 帧身份复用 T03 `FrameIdentity`/`FrameClock`,`measure()` 构造 `MeasurementFrame`;`Sensor` 的公开方法与数值不变(流入口 T08 才接 MeasurementFrame)。
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
from orisys import configs
|
||||
from orisys.image_pipeline import ImagePipeline, ImageSettings, sensor_clock
|
||||
from orisys.sensors import build_sensor_context
|
||||
|
||||
context = build_sensor_context(config_name="box", config=configs.load_config("box"),
|
||||
device_id="BOX-001", bundle_candidates=["box"])
|
||||
pipeline = ImagePipeline(ImageSettings.from_config(configs.load_config("box")), context=context)
|
||||
prepared_grey_frame = np.full((365, 365), 120, dtype=np.uint8)
|
||||
measurement = pipeline.measure(
|
||||
prepared_grey_frame,
|
||||
identity=context.frame_identity(session_id="run-1", frame_id=0, source_frame_id=0),
|
||||
clock=sensor_clock(1_000_000, clock_id="host"),
|
||||
include=("flow", "field"),
|
||||
)
|
||||
assert measurement.frame.reference_generation == 0
|
||||
```
|
||||
|
||||
## 不包含什么
|
||||
|
||||
- 不实现 SensorStream 入口、实时最新选帧、完整回放和背压(T08)。
|
||||
- 不实现测量负载、ForceSession 重做、零点与输出滤波(T06–T07)。
|
||||
- 不实现批次、配组、多流调度(后续多流计划)。
|
||||
- 不实现录制/回放格式、Studio 迁移和离线重新滤波(T11、T13–T15)。
|
||||
- T05 只把 flow/HHD/场移到 `ImagePipeline`,不改变 `Sensor` 的公开接口与数值;当前 `ForceSession` 在 T06 重做时改用本合同的帧头。
|
||||
|
||||
## 验证
|
||||
|
||||
`tests/test_stream_contract.py` 覆盖 T03 的四项验收:逐路身份与时钟、组字段可缺但不半组、同 PTS 帧可区分、`source_dt_s` 与事件 JSON 往返;同时覆盖 frozen 校验、严格版本/未知字段拒绝(政策 A)、`SUPPORTED_CONTRACT_VERSIONS`、事件可哈希、`kind` 的 lower_snake 格式和非法负载拒绝。`tests/test_sensor_context.py` 覆盖 T04 的只读 SensorContext:显式 SN/配置/资产/标定路径绑定、不猜 SN、实例隔离、换标定原子替换、绑定失败只报 diagnostics,以及 config source/资产包绑定。
|
||||
|
||||
`tests/test_image_pipeline.py` 覆盖 T05:独立流水线与 Sensor 委托路径逐帧逐位一致、只读副本不被后续帧覆写、`include` 选择、`Sensor.configure(flow_ema_alpha=...)` 的运行时生效与 EMA 单属主、由 StreamPipeline 驱动时图像块版本逐帧等于快照 `tracker_reset_seq`(含复用 Sensor、直接 `reset()` 后),以及独立 `measure(reference_generation=...)` 的在途版本保留。
|
||||
@@ -13,6 +13,7 @@
|
||||
"min_area_frac": 0.01,
|
||||
"max_area_frac": 0.05,
|
||||
"max_aspect_ratio": 50,
|
||||
"exclude_x": [0, 240],
|
||||
"index": [1,2,3,6,7,8,11,12,13],
|
||||
"margin": 10,
|
||||
"nominal_ov_pos": 100,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -6,7 +6,7 @@
|
||||
"resolution_raw": [1280, 720],
|
||||
"fps": 30,
|
||||
"resolution_stitch": [280, 300],
|
||||
"source_scale": 0.4,
|
||||
"source_scale": 0.42,
|
||||
"old":[672, 728, 1,0.5],
|
||||
"work": [504, 546,1],
|
||||
"work2": [420, 450,0.8],
|
||||
|
||||
+33
-1
@@ -5,7 +5,7 @@ A Python library for processing and analyzing optical tactile sensor data,
|
||||
including image stitching, optical flow tracking, and deformation decomposition.
|
||||
"""
|
||||
|
||||
__version__ = "0.5.4"
|
||||
__version__ = "0.7.0"
|
||||
__author__ = "Orisys"
|
||||
|
||||
from . import calibration, capture, configs, finger, signal_processing, util
|
||||
@@ -19,6 +19,23 @@ from .pipeline import (
|
||||
StreamPipeline,
|
||||
)
|
||||
from .sensor import Sensor
|
||||
from .stream_contract import (
|
||||
STREAM_CONTRACT_VERSION,
|
||||
SUPPORTED_CONTRACT_VERSIONS,
|
||||
TIME_BASES,
|
||||
FrameClock,
|
||||
TimeBasis,
|
||||
FrameIdentity,
|
||||
FrameStamp,
|
||||
MeasurementFrame,
|
||||
StreamEvent,
|
||||
)
|
||||
|
||||
#: The explicit force-readout session (``studio-force/1``) used by the Studio
|
||||
#: workflow. Imported after ``Sensor`` because it composes the sensor, model and
|
||||
#: output blocks; the historical ``Sensor.read_info``/``ForceReadout.apply``
|
||||
#: entry points keep their semantics for every caller that does not construct it.
|
||||
from .force_session import ForceSession, WORKFLOW_VERSION
|
||||
|
||||
CompoundEyeSensor = Sensor
|
||||
CameraSensor = Sensor
|
||||
@@ -61,6 +78,21 @@ __all__ = [
|
||||
# force calibration
|
||||
'calibration',
|
||||
|
||||
# explicit force workflow session (studio-force/1)
|
||||
'ForceSession',
|
||||
'WORKFLOW_VERSION',
|
||||
|
||||
# frozen single-stream contract (stream-contract/1)
|
||||
'STREAM_CONTRACT_VERSION',
|
||||
'SUPPORTED_CONTRACT_VERSIONS',
|
||||
'TIME_BASES',
|
||||
'TimeBasis',
|
||||
'FrameIdentity',
|
||||
'FrameClock',
|
||||
'FrameStamp',
|
||||
'MeasurementFrame',
|
||||
'StreamEvent',
|
||||
|
||||
# configs utilities functions
|
||||
'list_config',
|
||||
'export_config',
|
||||
|
||||
@@ -6,7 +6,9 @@ curves in ``_apply_force_filter``; ``read_info(FNORMAL, …)`` returns Newtons.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import warnings
|
||||
from collections.abc import Iterable, Mapping
|
||||
@@ -52,7 +54,7 @@ _KNOWN_TOP_KEYS = frozenset(
|
||||
#: Filter stages that change **what is measured**: they sit upstream of the
|
||||
#: force computation, so two recordings with different values are not comparable.
|
||||
#: They belong to the measurement-pipeline identity, for the same reason
|
||||
#: ``dis_mode`` and ``hhd_downsample`` do.
|
||||
#: ``dis_mode`` does. HHD resolution is retained in provenance but may differ.
|
||||
FILTER_MEASUREMENT_KEYS: tuple[str, ...] = ("flow_ema_alpha",)
|
||||
#: Filter stages that only smooth the force triple on its way out. They are
|
||||
#: recorded so a file explains its own numbers, but a difference between two
|
||||
@@ -408,6 +410,53 @@ class Calibration:
|
||||
)
|
||||
return out_fn, out_fx, out_fy
|
||||
|
||||
def convert_referenced(
|
||||
self,
|
||||
raw: tuple[float, float, float],
|
||||
reference: Mapping[str, float] | None,
|
||||
) -> tuple[float | None, float | None, float | None]:
|
||||
"""Per-axis newtons against a measured unloaded reference, subtracted once.
|
||||
|
||||
Schema 1/2 curves were calibrated without a session zero: each curve maps
|
||||
a raw feature to newtons and carries its own zero point internally. A
|
||||
session baseline is an *additional* per-session raw offset, so the
|
||||
correction belongs in the force domain: an axis reports
|
||||
``curve(raw) - curve(reference_raw)``. The curve's internal offset is
|
||||
therefore part of both terms and is never subtracted a second time, and
|
||||
this does not assume the curve is linear (subtracting the baseline in the
|
||||
raw domain would).
|
||||
|
||||
An axis without a curve, without a reference, or with a non-finite raw
|
||||
value reports ``None``: unavailable is not a newtons value and must never
|
||||
be filled with zero. The historical :meth:`convert` path is unchanged.
|
||||
"""
|
||||
out: list[float | None] = []
|
||||
for index, axis in enumerate(CHANNEL_IDS):
|
||||
curve = self.channels.get(axis)
|
||||
if curve is None or reference is None or axis not in reference:
|
||||
out.append(None)
|
||||
continue
|
||||
try:
|
||||
raw_value = float(raw[index])
|
||||
reference_value = float(reference[axis])
|
||||
except (TypeError, ValueError, IndexError):
|
||||
out.append(None)
|
||||
continue
|
||||
if not (math.isfinite(raw_value) and math.isfinite(reference_value)):
|
||||
out.append(None)
|
||||
continue
|
||||
convert = curve.convert_component if curve.signed else curve.convert
|
||||
out.append(float(convert(raw_value)) - float(convert(reference_value)))
|
||||
return (out[0], out[1], out[2])
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
"""Content identity of this document: no path, no mtime, stable across reloads."""
|
||||
content = json.dumps(
|
||||
self.to_dict(), sort_keys=True, separators=(",", ":"), allow_nan=False
|
||||
)
|
||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
channels: dict[str, Any] = {}
|
||||
for cid, curve in self.channels.items():
|
||||
@@ -876,7 +925,7 @@ def validate_for_device(
|
||||
*,
|
||||
pipeline: Mapping[str, Any] | None = None,
|
||||
) -> Calibration:
|
||||
"""Reject a calibration for another device or measurement pipeline."""
|
||||
"""Reject a different device or pipeline, allowing HHD resolution differences."""
|
||||
path = cal.source_path or "<in-memory calibration>"
|
||||
if cal.identity.get("sensor_type") != sensor_type or cal.identity.get("sn") != device_id:
|
||||
raise CalibrationError(f"calibration identity mismatch: {path}")
|
||||
@@ -886,7 +935,7 @@ def validate_for_device(
|
||||
# Full feature/geometry validation happens when the processor binds its
|
||||
# actual context. Device construction only knows this legacy subset.
|
||||
if pipeline is not None:
|
||||
for key in ("stitch_resolution", "dis_mode", "hhd_downsample"):
|
||||
for key in ("stitch_resolution", "dis_mode"):
|
||||
if pipeline.get(key) is not None and cal.pipeline.get(key) != pipeline[key]:
|
||||
raise CalibrationError(f"calibration pipeline mismatch ({key}): {path}")
|
||||
measurement, _ = filter_mismatches(cal.pipeline.get("filters"), pipeline.get("filters"))
|
||||
@@ -898,7 +947,7 @@ def validate_for_device(
|
||||
if set(CHANNEL_IDS) - cal.channels.keys():
|
||||
raise CalibrationError(f"calibration requires FN, FX and FY: {path}")
|
||||
if pipeline is not None:
|
||||
for key in ("stitch_resolution", "dis_mode", "hhd_downsample"):
|
||||
for key in ("stitch_resolution", "dis_mode"):
|
||||
recorded, current = cal.pipeline.get(key), pipeline.get(key)
|
||||
if recorded is not None and current is not None and recorded != current:
|
||||
raise CalibrationError(f"calibration pipeline mismatch ({key}): {path}")
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
@@ -11,6 +12,38 @@ import numpy as np
|
||||
from .calibration import filters_fingerprint
|
||||
|
||||
|
||||
def _feature_signature(identity) -> str:
|
||||
encoded = json.dumps(identity, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class CalibrationContext(dict):
|
||||
"""Actual pipeline fields plus the runtime inputs needed to compare HHD settings.
|
||||
|
||||
JSON serialization contains only the actual fields. Retaining the signature
|
||||
inputs lets old files differ in downsample without ignoring other hashed settings.
|
||||
"""
|
||||
|
||||
def __init__(self, fields, feature_identity):
|
||||
super().__init__(fields)
|
||||
self._feature_identity = deepcopy(feature_identity)
|
||||
|
||||
def signature_for_downsample(self, downsample: int) -> str:
|
||||
"""Recompute the runtime identity with only the requested HHD resolution."""
|
||||
identity = deepcopy(self._feature_identity)
|
||||
identity["tracker"]["hhd_downsample"] = downsample
|
||||
return _feature_signature(identity)
|
||||
|
||||
@property
|
||||
def actual_downsample(self) -> int:
|
||||
"""The HHD downsample this runtime context was actually built with."""
|
||||
raw = self.get("hhd_downsample", 1)
|
||||
try:
|
||||
return max(1, int(raw)) if raw is not None else 1
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
|
||||
def sha256_file(path) -> str:
|
||||
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||
|
||||
@@ -29,8 +62,13 @@ def domain_fingerprint(domain) -> str:
|
||||
|
||||
def build_context(config, filters=None, *, geometry_sha256=None, source_scale=None,
|
||||
coordinates=None, output_domain_sha256=None, processing_backend=None,
|
||||
preprocessing=None) -> dict:
|
||||
"""Build the closed v1 feature identity from effective values, excluding readout filters."""
|
||||
preprocessing=None, field=None) -> dict:
|
||||
"""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", {})
|
||||
tracker = dict(config.get("tracker", {}))
|
||||
raw_downsample = tracker.get("hhd_downsample", 1)
|
||||
@@ -70,6 +108,8 @@ def build_context(config, filters=None, *, geometry_sha256=None, source_scale=No
|
||||
"source_scale": source_scale,
|
||||
"preprocessing": preprocessing or {},
|
||||
}
|
||||
encoded = json.dumps(effective, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
result["feature_signature_sha256"] = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
return result
|
||||
if field is not None:
|
||||
result["field"] = deepcopy(field)
|
||||
effective["field"] = deepcopy(field)
|
||||
result["feature_signature_sha256"] = _feature_signature(effective)
|
||||
return CalibrationContext(result, effective)
|
||||
|
||||
@@ -7,13 +7,32 @@ import json
|
||||
import math
|
||||
from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field as dataclass_field
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
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",
|
||||
"filters", "feature_signature_sha256"}
|
||||
_keys(data, required | ({"geometry_sha256", "coordinates", "source_scale"} if spatial else set()),
|
||||
{"geometry_sha256", "coordinates", "output_domain_sha256", "source_scale"})
|
||||
if spatial:
|
||||
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"]
|
||||
_require(isinstance(size, list) and len(size) == 2 and all(type(n) is int and n > 0 for n in size),
|
||||
"invalid stitch_resolution")
|
||||
@@ -127,12 +150,49 @@ def _pipeline(data: Any, spatial: bool) -> None:
|
||||
_require(isinstance(coord["origin_xy"], list) and len(coord["origin_xy"]) == 2 and
|
||||
all(_number(x) for x in coord["origin_xy"]), "invalid coordinate origin")
|
||||
_require(_sha(coord["map_sha256"]), "invalid map SHA-256")
|
||||
if "field" in required:
|
||||
_require(data["field"] == FIELD_IDENTITY, "unsupported field identity")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CalibrationResult:
|
||||
values_N: dict[str, float | None]
|
||||
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)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DownsampleReport:
|
||||
"""How the HHD resolution a model was calibrated with compares to the runtime.
|
||||
|
||||
A ``hhd_downsample`` difference is allowed by the pipeline contract (HHD
|
||||
magnitudes are normalized to the full flow grid), but it is an accuracy risk
|
||||
the operator and the record must be able to see. This report is pure data:
|
||||
``validate_for_context`` decides whether a difference is acceptable, the App
|
||||
decides where to display it.
|
||||
"""
|
||||
|
||||
declared: int
|
||||
actual: int | None
|
||||
match: bool
|
||||
allowed: bool
|
||||
accuracy_risk: str
|
||||
|
||||
@property
|
||||
def known(self) -> bool:
|
||||
"""Whether the runtime value was actually available."""
|
||||
return self.actual is not None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"declared": self.declared,
|
||||
"actual": self.actual,
|
||||
"match": self.match,
|
||||
"allowed": self.allowed,
|
||||
"accuracy_risk": self.accuracy_risk,
|
||||
}
|
||||
|
||||
|
||||
class ModelCalibration:
|
||||
@@ -154,16 +214,25 @@ class ModelCalibration:
|
||||
self._params = self._document["model"]["params"]
|
||||
self.coordinate_map = self.coordinate_valid_mask = None
|
||||
self._artifact_hashes: list[str] = []
|
||||
if self.model_type == "spatial_gain/1":
|
||||
self._points = self._gains = self._triangles = 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._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:
|
||||
_require(isinstance(artifacts, list), "artifacts must be an array")
|
||||
_require(len(artifacts) <= 1, "only one coordinate_map artifact is supported")
|
||||
for artifact in artifacts:
|
||||
_keys(artifact, {"role", "path", "sha256"})
|
||||
_require(self.model_type == "spatial_gain/1" and artifact["role"] == "coordinate_map",
|
||||
_require(self.model_type in ("spatial_gain/1", "field_gain/1")
|
||||
and artifact["role"] == "coordinate_map",
|
||||
"unsupported artifact role")
|
||||
name = artifact["path"]
|
||||
_require(isinstance(name, str) and bool(name) and ":" not in name and
|
||||
@@ -208,17 +277,205 @@ class ModelCalibration:
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return deepcopy(self._document)
|
||||
|
||||
def validate_for_context(self, pipeline: Mapping[str, Any]) -> None:
|
||||
def downsample_report(self, pipeline: Mapping[str, Any]) -> DownsampleReport:
|
||||
"""The calibrated-vs-runtime HHD resolution, as data (never raises).
|
||||
|
||||
``actual`` is ``None`` when the caller did not declare it; that is
|
||||
reported as unknown instead of being treated as a match.
|
||||
"""
|
||||
declared = max(1, int(self.pipeline.get("hhd_downsample", 1)))
|
||||
actual: int | None = None
|
||||
raw: Any = None
|
||||
if isinstance(pipeline, Mapping):
|
||||
raw = pipeline.get("hhd_downsample")
|
||||
if raw is not None:
|
||||
try:
|
||||
actual = max(1, int(raw))
|
||||
except (TypeError, ValueError):
|
||||
actual = None
|
||||
match = bool(actual is not None and actual == declared)
|
||||
risk = ""
|
||||
if actual is None:
|
||||
risk = (
|
||||
f"HHD downsample is {declared} in the calibration but unknown at "
|
||||
"runtime; only the calibrated resolution was verified"
|
||||
)
|
||||
elif not match:
|
||||
risk = (
|
||||
f"HHD downsample differs: calibration {declared}, runtime {actual}; "
|
||||
"HHD magnitudes are normalized to the full flow grid, so results are "
|
||||
"compatible in unit but only relative accuracy was verified"
|
||||
)
|
||||
return DownsampleReport(
|
||||
declared=declared,
|
||||
actual=actual,
|
||||
match=match,
|
||||
allowed=True,
|
||||
accuracy_risk=risk,
|
||||
)
|
||||
|
||||
def pipeline_compatibility(self, pipeline: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Every pipeline identity check, as one report instead of an exception.
|
||||
|
||||
``mismatches`` holds the same messages ``validate_for_context`` raises, in
|
||||
pipeline order. A ``hhd_downsample`` difference is reported (and allowed)
|
||||
through :meth:`downsample_report` rather than being a mismatch.
|
||||
"""
|
||||
from .calibration_context import CalibrationContext
|
||||
|
||||
mismatches: dict[str, str] = {}
|
||||
for key, value in self.pipeline.items():
|
||||
if key == "filters":
|
||||
if key == "hhd_downsample":
|
||||
# Allowed, but reported: HHD magnitudes are normalized to the full
|
||||
# flow grid, so resolution can introduce numerical deviations
|
||||
# without changing the force unit.
|
||||
continue
|
||||
if key == "feature_signature_sha256" and isinstance(
|
||||
pipeline, CalibrationContext
|
||||
):
|
||||
if pipeline.signature_for_downsample(
|
||||
self.pipeline["hhd_downsample"]
|
||||
) != value:
|
||||
mismatches[key] = (
|
||||
"calibration pipeline mismatch: feature_signature_sha256"
|
||||
)
|
||||
elif key == "filters":
|
||||
actual = pipeline.get(key, {})
|
||||
_require(isinstance(actual, Mapping) and actual.get("measurement") == value["measurement"],
|
||||
"calibration pipeline mismatch: filters.measurement")
|
||||
else:
|
||||
_require(pipeline.get(key) == value, f"calibration pipeline mismatch: {key}")
|
||||
if not (
|
||||
isinstance(actual, Mapping)
|
||||
and actual.get("measurement") == value["measurement"]
|
||||
):
|
||||
mismatches[key] = (
|
||||
"calibration pipeline mismatch: filters.measurement"
|
||||
)
|
||||
elif pipeline.get(key) != value:
|
||||
mismatches[key] = f"calibration pipeline mismatch: {key}"
|
||||
downsample = self.downsample_report(pipeline)
|
||||
return {
|
||||
"ok": not mismatches,
|
||||
"mismatches": mismatches,
|
||||
"downsample": downsample.as_dict(),
|
||||
}
|
||||
|
||||
def validate_for_context(self, pipeline: Mapping[str, Any]) -> None:
|
||||
"""Refuse incompatible pipeline identities; allow (and report) downsample.
|
||||
|
||||
Kept as the validating entry point for existing callers: the message of a
|
||||
refused identity is unchanged, and a downsample-only difference still
|
||||
passes. Consumers that want to display the risk use
|
||||
:meth:`pipeline_compatibility`.
|
||||
"""
|
||||
report = self.pipeline_compatibility(pipeline)
|
||||
mismatches = report["mismatches"]
|
||||
if mismatches:
|
||||
from .calibration import CalibrationError
|
||||
|
||||
first = next(iter(mismatches.values()))
|
||||
raise CalibrationError(first)
|
||||
|
||||
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:
|
||||
"""Reject legacy scalar calls which lack frame, position and zero features."""
|
||||
@@ -234,6 +491,8 @@ class ModelCalibration:
|
||||
if (not isinstance(features, Mapping) or type(frame_id) is not int or
|
||||
(timestamp_s is not None and not _number(timestamp_s))):
|
||||
return invalid("invalid_input")
|
||||
if self.model_type == "field_gain/1":
|
||||
return self._predict_field(frame_id, features)
|
||||
if features.get("zero_valid") is not True:
|
||||
return invalid("zero_unavailable")
|
||||
if features.get("contact_found") is False:
|
||||
@@ -285,23 +544,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")
|
||||
model = data["model"]
|
||||
_keys(model, {"type", "input_profile", "output_frame", "outputs", "params"}, {"artifacts"})
|
||||
_require(model["type"] in ("spatial_gain/1", "global_gain/1"), f"unsupported calibration model: {model['type']}")
|
||||
spatial = model["type"] == "spatial_gain/1"
|
||||
_require(model["input_profile"] == ("fn_delta_autocal_uv/1" if spatial else "force_delta/1"),
|
||||
_require(model["type"] in _PROFILE_FOR_TYPE,
|
||||
f"unsupported calibration model: {model['type']}")
|
||||
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")
|
||||
_require(model["output_frame"] == "sdk_force/1", "unsupported output_frame")
|
||||
outputs = model["outputs"]
|
||||
_require(isinstance(outputs, list) and bool(outputs) and all(a in ("fn", "fx", "fy") for a in outputs) and
|
||||
len(set(outputs)) == len(outputs), "invalid model outputs")
|
||||
_require(not spatial or outputs == ["fn"], "spatial model supports fn only")
|
||||
_pipeline(data["pipeline"], spatial)
|
||||
_require(not (spatial or field_weighted) or outputs == ["fn"], "spatial model supports fn only")
|
||||
_pipeline(data["pipeline"], model["input_profile"])
|
||||
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"]
|
||||
_require(isinstance(force_range, list) and len(force_range) == 2 and
|
||||
all(_number(x) for x in force_range) and force_range[0] < force_range[1], "invalid force range")
|
||||
if not spatial:
|
||||
gains = params["gains_N_per_raw"]
|
||||
if field_weighted:
|
||||
_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()), "gains must be finite positive numbers")
|
||||
_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)
|
||||
|
||||
+50
-19
@@ -283,6 +283,48 @@ def resolve_sensor_config_path(name: str) -> Path | None:
|
||||
return bundle.sensor_json if bundle is not None else None
|
||||
|
||||
|
||||
def resolve_config_source(config_name_or_path) -> Path | None:
|
||||
"""Return the JSON file :func:`load_config` would read for this name/path.
|
||||
|
||||
Uses the same lookup order as ``load_config``: exact file, additive sensor
|
||||
bundle, config directory stem, then the legacy ``"name"`` field scan. Returns
|
||||
``None`` when the name has no file (never inventing a path), and follows
|
||||
aliases exactly like loading does so a recorded path is the one that was
|
||||
actually read.
|
||||
"""
|
||||
config_path = Path(config_name_or_path)
|
||||
if config_path.exists() and config_path.is_file():
|
||||
return config_path.resolve()
|
||||
|
||||
if config_path.is_absolute():
|
||||
return None
|
||||
|
||||
requested = str(config_name_or_path)
|
||||
candidates = [requested]
|
||||
alias = CONFIG_ALIASES.get(requested)
|
||||
if alias and alias not in candidates:
|
||||
candidates.append(alias)
|
||||
|
||||
for name in candidates:
|
||||
bundle = resolve_sensor_bundle(name)
|
||||
if bundle is not None:
|
||||
return bundle.sensor_json
|
||||
|
||||
for name in candidates:
|
||||
direct = CONFIG_DIR / f"{name}.json"
|
||||
if direct.exists():
|
||||
return direct.resolve()
|
||||
|
||||
for candidate in _config_files():
|
||||
try:
|
||||
data = _load_json(candidate)
|
||||
except Exception: # noqa: BLE001, S112 - unreadable configs are skipped during the scan
|
||||
continue
|
||||
if data.get("name") in candidates:
|
||||
return candidate.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def resolve_sensor_asset_dir(name: str) -> Path | None:
|
||||
"""Return the default asset directory for a resolved sensor bundle."""
|
||||
bundle = resolve_sensor_bundle(name)
|
||||
@@ -402,6 +444,10 @@ def load_config(config_name_or_path):
|
||||
2. Additive sensor bundle ``sensors/<name>/sensor.json``
|
||||
3. File stem under the config directory (e.g. ``box`` -> ``config/box.json``)
|
||||
4. JSON field ``"name"`` matching the requested name (keeps ``prototype`` compatible)
|
||||
|
||||
The lookup itself lives in :func:`resolve_config_source`, so a caller that
|
||||
records which file was read (for example ``SensorContext.config_source``)
|
||||
cannot drift from what was actually loaded here.
|
||||
"""
|
||||
config_path = Path(config_name_or_path)
|
||||
|
||||
@@ -413,31 +459,16 @@ def load_config(config_name_or_path):
|
||||
if config_path.is_absolute():
|
||||
raise FileNotFoundError(f"Config file not found at absolute path: {config_path}")
|
||||
|
||||
source = resolve_config_source(config_name_or_path)
|
||||
if source is not None:
|
||||
return _load_json(source)
|
||||
|
||||
requested = str(config_name_or_path)
|
||||
candidates = [requested]
|
||||
alias = CONFIG_ALIASES.get(requested)
|
||||
if alias and alias not in candidates:
|
||||
candidates.append(alias)
|
||||
|
||||
for name in candidates:
|
||||
bundle = resolve_sensor_bundle(name)
|
||||
if bundle is not None:
|
||||
return _load_json(bundle.sensor_json)
|
||||
|
||||
for name in candidates:
|
||||
direct = CONFIG_DIR / f"{name}.json"
|
||||
if direct.exists():
|
||||
return _load_json(direct)
|
||||
|
||||
# Backward-compatible alias resolution: support old names via JSON "name" field.
|
||||
for candidate in _config_files():
|
||||
try:
|
||||
data = _load_json(candidate)
|
||||
except Exception: # noqa: S112 - skip unreadable config during alias scan
|
||||
continue
|
||||
if data.get("name") in candidates:
|
||||
return data
|
||||
|
||||
available = [f.stem for f in _config_files()]
|
||||
available_str = ", ".join(available) if available else "none"
|
||||
raise FileNotFoundError(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -13,6 +14,7 @@ from ..capture import (
|
||||
open_capture,
|
||||
)
|
||||
from ..configs import load_config
|
||||
from ..field_calibration import FIELD_IDENTITY, FIELD_PROFILE
|
||||
from ..frame_processors import FrameUnwarper
|
||||
from ..sensor import Sensor
|
||||
from ..unroll import (
|
||||
@@ -325,9 +327,10 @@ class FingerRuntime:
|
||||
|
||||
model = sensor.calibration
|
||||
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):
|
||||
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._config, sensor.filters_state(),
|
||||
geometry_sha256=sha256_file(mesh_path), source_scale=geometry.source_scale,
|
||||
@@ -335,6 +338,7 @@ class FingerRuntime:
|
||||
output_domain_sha256=domain_fingerprint(domain),
|
||||
processing_backend="uv_unroll",
|
||||
preprocessing={"flip_source_vertical": flip_source_vertical, "mirror": mirror},
|
||||
field=field_identity,
|
||||
))
|
||||
|
||||
capture_config = CaptureConfig.from_camera_dict(
|
||||
@@ -386,6 +390,37 @@ class FingerRuntime:
|
||||
"""Set an explicitly measured unloaded reference for this finger stream."""
|
||||
self.tactile.raw_sensor.set_calibration_reference(raw, reference_id=reference_id)
|
||||
|
||||
def set_calibration(self, calibration, *, device_id=None):
|
||||
"""Replace the model at a caller-owned frame boundary, validating actual geometry."""
|
||||
from .. import calibration as cal_mod
|
||||
from ..calibration_context import build_context, domain_fingerprint, sha256_file
|
||||
|
||||
sensor = self.tactile.raw_sensor
|
||||
selected = cal_mod.load(calibration, strict=True) if isinstance(calibration, (str, Path)) else calibration
|
||||
serial = device_id or sensor.device_id
|
||||
if selected is not None and serial:
|
||||
selected = cal_mod.validate_for_device(selected, "finger", serial)
|
||||
context = None
|
||||
if getattr(selected, "schema_version", None) == 3:
|
||||
if selected.input_profile in ("fn_delta_autocal_uv/1", FIELD_PROFILE):
|
||||
uv_map = selected.coordinate_map
|
||||
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")
|
||||
field_identity = FIELD_IDENTITY if selected.input_profile == FIELD_PROFILE else None
|
||||
context = build_context(
|
||||
self.geometry.config, sensor.filters_state(),
|
||||
geometry_sha256=sha256_file(self.geometry.export_paths.mesh),
|
||||
source_scale=self.geometry.source_scale, coordinates=selected.pipeline.get("coordinates"),
|
||||
output_domain_sha256=domain_fingerprint(self.domain), processing_backend="uv_unroll",
|
||||
preprocessing={"flip_source_vertical": self.frame_processor.flip_source_vertical,
|
||||
"mirror": sensor.mirror},
|
||||
field=field_identity)
|
||||
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
|
||||
def calibration_status(self):
|
||||
"""Copy the current per-axis calibration validity state."""
|
||||
@@ -419,6 +454,60 @@ class FingerRuntime:
|
||||
self.tactile.push_frame(unrolled, already_gray=True)
|
||||
return unrolled
|
||||
|
||||
# ------------------------------------------- session measurement seam
|
||||
def enable_measurement_session(self) -> None:
|
||||
"""Hand force readout to a ``ForceSession`` (the explicit new entry)."""
|
||||
self.tactile.raw_sensor.enable_measurement_session()
|
||||
|
||||
def disable_measurement_session(self) -> None:
|
||||
"""Return to the historical automatic model/readout path."""
|
||||
self.tactile.raw_sensor.disable_measurement_session()
|
||||
|
||||
def reset_tracker(self) -> None:
|
||||
"""Reset the tactile tracker at a frame boundary (session zeroing half)."""
|
||||
self.tactile.raw_sensor.reset_tracker()
|
||||
|
||||
def read_measurement_frame(
|
||||
self,
|
||||
source_frame,
|
||||
*,
|
||||
frame_id: int,
|
||||
timestamp_s: float | None = None,
|
||||
want_field: bool = False,
|
||||
want_contact: bool = False,
|
||||
):
|
||||
"""Unroll one source frame and measure it once, without calibration."""
|
||||
del timestamp_s # signal time is carried by the caller's FrameStamp
|
||||
self.process_frame(source_frame)
|
||||
return self.tactile.raw_sensor.measure_current_frame(
|
||||
frame_id=frame_id,
|
||||
want_field=want_field,
|
||||
want_contact=want_contact,
|
||||
)
|
||||
|
||||
def current_measurement(
|
||||
self, *, want_field: bool = False, want_contact: bool = False
|
||||
):
|
||||
"""Same-frame measurement features already produced by the pipeline (no recompute).
|
||||
|
||||
Additive: forwards to the tactile sensor's session seam so a pipeline
|
||||
consumer can hand the frame to ``ForceSession`` without a second
|
||||
unroll/flow/HHD pass.
|
||||
"""
|
||||
return self.tactile.raw_sensor.current_measurement(
|
||||
want_field=want_field, want_contact=want_contact
|
||||
)
|
||||
|
||||
@property
|
||||
def calibration_context(self):
|
||||
"""The pipeline context the open model was validated against, if any."""
|
||||
return self.tactile.raw_sensor.calibration_context
|
||||
|
||||
@property
|
||||
def calibration(self):
|
||||
"""The force calibration the open runtime holds (additive read-only accessor)."""
|
||||
return getattr(self.tactile.raw_sensor, "calibration", None)
|
||||
|
||||
def grab_and_process(self, *, color=False):
|
||||
ok, frame = self.read_source()
|
||||
if not ok:
|
||||
@@ -457,6 +546,18 @@ class FingerRuntime:
|
||||
|
||||
return StreamPipeline.for_finger_runtime(self, **kwargs)
|
||||
|
||||
@property
|
||||
def image_pipeline(self):
|
||||
"""The ImagePipeline the tactile sensor delegates its image block to.
|
||||
|
||||
Capture and UV unrolling stay in this runtime; flow/HHD/field come from
|
||||
the tactile sensor's single ``ImagePipeline``, so an offline experiment
|
||||
can inspect or reuse the same ``ImageSettings``/``SensorContext`` without
|
||||
a running stream.
|
||||
"""
|
||||
raw = getattr(self.tactile, "raw_sensor", None)
|
||||
return None if raw is None else getattr(raw, "image_pipeline", None)
|
||||
|
||||
def close(self):
|
||||
if self.capture_source is not None:
|
||||
self.capture_source.close()
|
||||
|
||||
+334
-2
@@ -2,11 +2,22 @@
|
||||
|
||||
Any sensor that produces a raw (fn, fx, fy) triple can use this. Calibration
|
||||
coefficients stay outside: pass ``convert`` into :meth:`ForceReadout.apply`.
|
||||
|
||||
Two things live here, deliberately apart:
|
||||
|
||||
* :class:`ForceReadout` — the historical measurement-chain readout. It filters,
|
||||
then converts (median → low-pass → convert), which is not interchangeable with
|
||||
a nonlinear conversion; existing consumers keep it unchanged.
|
||||
* :class:`ForceOutputFilter` — the shared *output* post-processing for the new
|
||||
session path: causal median → low-pass → optional per-axis threshold, applied
|
||||
to the raw features and to the unfiltered newtons as two independent states.
|
||||
Conversion happens upstream of it, so it can run on both domains.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -14,6 +25,7 @@ from .signal_processing import (
|
||||
DEFAULT_SAMPLE_RATE_HZ,
|
||||
CausalMedianFilter,
|
||||
LowpassFilter,
|
||||
MultiAxisCausalFilter,
|
||||
SampleRateEstimator,
|
||||
)
|
||||
|
||||
@@ -362,14 +374,37 @@ class ForceReadout:
|
||||
)
|
||||
return filter_changed
|
||||
|
||||
def reset(self) -> None:
|
||||
def reset_flow_ema(self) -> None:
|
||||
"""Drop the optical-flow EMA history (image side).
|
||||
|
||||
The EMA smooths the flow field before HHD, so it belongs to the stage
|
||||
that computes flow. A streaming pipeline resets this half on its flow
|
||||
thread, separately from the readout history the HHD stage owns.
|
||||
"""
|
||||
self._ema_flow = None
|
||||
|
||||
def reset_readout(self) -> None:
|
||||
"""Drop the force-filter history and the last reading (force side).
|
||||
|
||||
The median/low-pass memory and the last reading belong to the stage that
|
||||
filters each measurement; this half does not touch the flow EMA.
|
||||
"""
|
||||
self._force_filter.reset()
|
||||
self._force_median.reset()
|
||||
self._ema_flow = None
|
||||
self.filtered_raw = None
|
||||
self.raw_unfiltered = None
|
||||
self.unfiltered_newton = None
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset both halves at once (single-threaded or frame-boundary use).
|
||||
|
||||
A streaming pipeline applies :meth:`reset_flow_ema` on the flow thread
|
||||
and :meth:`reset_readout` on the HHD thread; calling this keeps the
|
||||
historical behaviour for callers that own the whole sensor.
|
||||
"""
|
||||
self.reset_readout()
|
||||
self.reset_flow_ema()
|
||||
|
||||
def state(self, *, dis_mode=None) -> dict:
|
||||
"""Effective filter chain, for records and audits."""
|
||||
return {
|
||||
@@ -392,3 +427,300 @@ class ForceReadout:
|
||||
"any_stage_applied": bool(self.any_stage_applied),
|
||||
"unfiltered_available": True,
|
||||
}
|
||||
|
||||
|
||||
FORCE_AXES: tuple[str, str, str] = ("fn", "fx", "fy")
|
||||
|
||||
|
||||
def _positive_float(value, default: float) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return float(default)
|
||||
if not np.isfinite(number) or number <= 0:
|
||||
return float(default)
|
||||
return number
|
||||
|
||||
|
||||
def _non_negative_float(value, default: float = 0.0) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return float(default)
|
||||
if not np.isfinite(number) or number < 0:
|
||||
return float(default)
|
||||
return number
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutputSettings:
|
||||
"""One resolved setting group for BOTH display and recording.
|
||||
|
||||
Every stage is switched by its own value: ``median_window=1`` and
|
||||
``lowpass_hz=0`` each mean "off", and a threshold of 0 disables that unit's
|
||||
threshold. All stages off is an exact pass-through (a NaN axis stays NaN).
|
||||
This does not switch off the upstream flow EMA; that belongs to the
|
||||
measurement context and is part of the calibration identity.
|
||||
|
||||
``sample_rate_hz`` describes the *signal* time of the input frames, declared
|
||||
by the caller (camera config or recorded signal clock). The offline
|
||||
processing speed never designs the low-pass. A cutoff at or above Nyquist is
|
||||
unusable and resolves to "off" rather than to a silent re-tune.
|
||||
"""
|
||||
|
||||
sample_rate_hz: float
|
||||
median_window: int = 1
|
||||
lowpass_hz: float = 0.0
|
||||
lowpass_order: int = 2
|
||||
raw_threshold: float = 0.0
|
||||
newton_threshold: float = 0.0
|
||||
|
||||
def normalised(self) -> OutputSettings:
|
||||
"""Clamp to values the chain can actually run; unusable values are "off"."""
|
||||
rate = _positive_float(self.sample_rate_hz, DEFAULT_SAMPLE_RATE_HZ)
|
||||
try:
|
||||
window = max(1, int(self.median_window))
|
||||
except (TypeError, ValueError):
|
||||
window = 1
|
||||
cutoff = _non_negative_float(self.lowpass_hz)
|
||||
if cutoff >= 0.5 * rate:
|
||||
cutoff = 0.0
|
||||
try:
|
||||
order = max(1, int(self.lowpass_order))
|
||||
except (TypeError, ValueError):
|
||||
order = 2
|
||||
return replace(
|
||||
self,
|
||||
sample_rate_hz=rate,
|
||||
median_window=window,
|
||||
lowpass_hz=cutoff,
|
||||
lowpass_order=order,
|
||||
raw_threshold=_non_negative_float(self.raw_threshold),
|
||||
newton_threshold=_non_negative_float(self.newton_threshold),
|
||||
)
|
||||
|
||||
@property
|
||||
def median_on(self) -> bool:
|
||||
return int(self.median_window) > 1
|
||||
|
||||
@property
|
||||
def lowpass_on(self) -> bool:
|
||||
return float(self.lowpass_hz) > 0
|
||||
|
||||
def state(self) -> dict:
|
||||
"""Effective parameters, for a record or an audit."""
|
||||
return {
|
||||
"sample_rate_hz": float(self.sample_rate_hz),
|
||||
"median_window": int(self.median_window),
|
||||
"median_applied": bool(self.median_on),
|
||||
"lowpass_hz": float(self.lowpass_hz),
|
||||
"lowpass_applied": bool(self.lowpass_on),
|
||||
"lowpass_order": int(self.lowpass_order),
|
||||
"raw_threshold": float(self.raw_threshold),
|
||||
"newton_threshold": float(self.newton_threshold),
|
||||
}
|
||||
|
||||
|
||||
class ForceOutputFilter:
|
||||
"""Median → low-pass → optional thresholds, once, for raw and N separately.
|
||||
|
||||
One state per domain: two :class:`MultiAxisCausalFilter` instances with the
|
||||
same settings. The session hands each frame's features here exactly once, so
|
||||
display and recording read the same processed sample; there is no second
|
||||
filter state for a consumer to drift from.
|
||||
|
||||
Invalid axes (NaN or an undeclared valid flag) never enter the filter
|
||||
history and stay NaN, which keeps "missing" distinct from a valid zero. With
|
||||
every stage off the input tuples are returned unchanged, NaN included.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: OutputSettings):
|
||||
self._settings = settings.normalised()
|
||||
self._chain_settings = self._settings
|
||||
self._raw_chain = self._build_chain()
|
||||
self._newton_chain = self._build_chain()
|
||||
self._rate = SampleRateEstimator(initial_hz=self._settings.sample_rate_hz)
|
||||
|
||||
# ------------------------------------------------------------- settings
|
||||
def _build_chain(self) -> MultiAxisCausalFilter:
|
||||
settings = self._settings
|
||||
return MultiAxisCausalFilter(
|
||||
median_window=settings.median_window,
|
||||
lowpass_cutoff_hz=settings.lowpass_hz,
|
||||
sample_rate_hz=settings.sample_rate_hz,
|
||||
lowpass_order=settings.lowpass_order,
|
||||
)
|
||||
|
||||
@property
|
||||
def settings(self) -> OutputSettings:
|
||||
"""The effective settings currently applied (rate included)."""
|
||||
return self._settings
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
"""Any stage would change a valid reading."""
|
||||
return bool(
|
||||
self._raw_chain.enabled
|
||||
or self._settings.raw_threshold > 0
|
||||
or self._settings.newton_threshold > 0
|
||||
)
|
||||
|
||||
def configure(self, settings: OutputSettings) -> bool:
|
||||
"""Replace the setting group. State is dropped; the caller resets the stream."""
|
||||
resolved = settings.normalised()
|
||||
if resolved == self._settings:
|
||||
return False
|
||||
self._settings = resolved
|
||||
self._raw_chain = self._build_chain()
|
||||
self._newton_chain = self._build_chain()
|
||||
self._rate.reset(resolved.sample_rate_hz)
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Drop all filter memory (new zero, model change, discontinuity)."""
|
||||
self._raw_chain.reset()
|
||||
self._newton_chain.reset()
|
||||
self._rate.reset(self._settings.sample_rate_hz)
|
||||
|
||||
# ---------------------------------------------------------- processing
|
||||
def _track_signal_rate(self, signal_time_s: float | None) -> None:
|
||||
"""Design the low-pass from signal time, never from processing speed."""
|
||||
if signal_time_s is None:
|
||||
return
|
||||
if not np.isfinite(float(signal_time_s)):
|
||||
return
|
||||
rate = self._rate.tick(float(signal_time_s))
|
||||
if not self._rate.measured:
|
||||
return
|
||||
current = self._settings.sample_rate_hz
|
||||
if current > 0 and abs(rate - current) <= 0.1 * current:
|
||||
return
|
||||
self._settings = replace(self._settings, sample_rate_hz=rate)
|
||||
self._raw_chain.configure(sample_rate_hz=rate)
|
||||
self._newton_chain.configure(sample_rate_hz=rate)
|
||||
|
||||
def _apply_chain(
|
||||
self,
|
||||
chain: MultiAxisCausalFilter,
|
||||
values,
|
||||
valid,
|
||||
*,
|
||||
threshold: float,
|
||||
) -> tuple[float, float, float]:
|
||||
raw = tuple(float(value) for value in values)
|
||||
if valid is None:
|
||||
usable = np.isfinite(np.asarray(raw, dtype=np.float64))
|
||||
else:
|
||||
usable = np.asarray(valid, dtype=bool).reshape(-1)
|
||||
if usable.size != 3:
|
||||
raise ValueError(f"expected 3 validity flags, got {usable.size}")
|
||||
processed = chain.process(raw, valid=usable)
|
||||
out: list[float] = []
|
||||
for index in range(3):
|
||||
value = float(processed[index])
|
||||
if not usable[index] or not np.isfinite(value):
|
||||
out.append(float("nan"))
|
||||
continue
|
||||
if threshold > 0 and abs(value) <= threshold:
|
||||
out.append(0.0)
|
||||
else:
|
||||
out.append(value)
|
||||
return (out[0], out[1], out[2])
|
||||
|
||||
def process(
|
||||
self,
|
||||
raw,
|
||||
newton,
|
||||
*,
|
||||
raw_valid=None,
|
||||
newton_valid=None,
|
||||
signal_time_s: float | None = None,
|
||||
) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
|
||||
"""Process one frame's raw and newton triplets; returns both outputs."""
|
||||
self._track_signal_rate(signal_time_s)
|
||||
if not self.active:
|
||||
# Exact pass-through, NaN and signed zero included.
|
||||
return (
|
||||
tuple(float(value) for value in raw),
|
||||
tuple(float(value) for value in newton),
|
||||
)
|
||||
raw_out = self._apply_chain(
|
||||
self._raw_chain,
|
||||
raw,
|
||||
raw_valid,
|
||||
threshold=self._settings.raw_threshold,
|
||||
)
|
||||
newton_out = self._apply_chain(
|
||||
self._newton_chain,
|
||||
newton,
|
||||
newton_valid,
|
||||
threshold=self._settings.newton_threshold,
|
||||
)
|
||||
return raw_out, newton_out
|
||||
|
||||
def state(self) -> dict:
|
||||
return {
|
||||
"settings": self._settings.state(),
|
||||
"raw_chain": self._raw_chain.state(),
|
||||
"newton_chain": self._newton_chain.state(),
|
||||
"sample_rate_source": (
|
||||
"signal_time" if self._rate.measured else "declared_signal_rate"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def process_output_sequence(
|
||||
settings: OutputSettings,
|
||||
raw_samples,
|
||||
newton_samples,
|
||||
*,
|
||||
raw_valid=None,
|
||||
newton_valid=None,
|
||||
signal_times_s=None,
|
||||
) -> tuple[list[tuple[float, float, float]], list[tuple[float, float, float]], OutputSettings]:
|
||||
"""Re-process stored unfiltered raw/N sequences through the SAME output chain.
|
||||
|
||||
This is the offline entry a replay/analysis pass uses to re-filter saved
|
||||
unfiltered samples without a second median/low-pass implementation: it runs
|
||||
the live chain in order from a clean state, including warm-up, so the result
|
||||
equals what the session would have produced for that exact sample sequence.
|
||||
|
||||
``signal_times_s`` should carry the recorded signal times (the low-pass is
|
||||
designed from signal time, never from how fast the re-analysis runs). When it
|
||||
is omitted the declared ``settings.sample_rate_hz`` stays in effect. Returns
|
||||
one output triplet per input sample, plus the effective settings.
|
||||
"""
|
||||
raw_rows = list(raw_samples)
|
||||
newton_rows = list(newton_samples)
|
||||
if len(raw_rows) != len(newton_rows):
|
||||
raise ValueError(
|
||||
"raw_samples and newton_samples must have the same length: "
|
||||
f"{len(raw_rows)} != {len(newton_rows)}"
|
||||
)
|
||||
count = len(raw_rows)
|
||||
raw_flags = None if raw_valid is None else list(raw_valid)
|
||||
newton_flags = None if newton_valid is None else list(newton_valid)
|
||||
times = None if signal_times_s is None else list(signal_times_s)
|
||||
for name, rows in (
|
||||
("raw_valid", raw_flags),
|
||||
("newton_valid", newton_flags),
|
||||
("signal_times_s", times),
|
||||
):
|
||||
if rows is not None and len(rows) != count:
|
||||
raise ValueError(
|
||||
f"{name} must have one entry per sample: {len(rows)} != {count}"
|
||||
)
|
||||
chain = ForceOutputFilter(settings)
|
||||
raw_out: list[tuple[float, float, float]] = []
|
||||
newton_out: list[tuple[float, float, float]] = []
|
||||
for index in range(count):
|
||||
processed_raw, processed_newton = chain.process(
|
||||
raw_rows[index],
|
||||
newton_rows[index],
|
||||
raw_valid=None if raw_flags is None else raw_flags[index],
|
||||
newton_valid=None if newton_flags is None else newton_flags[index],
|
||||
signal_time_s=None if times is None else float(times[index]),
|
||||
)
|
||||
raw_out.append(processed_raw)
|
||||
newton_out.append(processed_newton)
|
||||
return raw_out, newton_out, chain.settings
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,176 @@
|
||||
"""Fixed-frame-rate output filter for the new force stream session (T07).
|
||||
|
||||
The published :class:`~orisys.force_readout.ForceOutputFilter` re-designs its
|
||||
low-pass whenever the measured signal rate drifts more than 10% from the current
|
||||
design (``ForceOutputFilter._track_signal_rate``). The stage-1 contract (K3.3,
|
||||
K3.4) instead fixes the design rate when the session starts: later measured-rate
|
||||
drift and long gaps are *reported*, never used to re-tune or clear the filter.
|
||||
|
||||
:class:`FixedRateOutputFilter` subclasses the published filter, keeping its
|
||||
median → low-pass → threshold chain (and its exact pass-through/NaN behaviour),
|
||||
and overrides only:
|
||||
|
||||
* rate tracking: measure and report, never reconfigure;
|
||||
* :meth:`switch`: explicit, frame-boundary settings replacement that returns
|
||||
whether the chain changed and starts a documented warm-up counter;
|
||||
* warm-up and measured-rate diagnostics used by ``ForceStreamOutput``.
|
||||
|
||||
The algorithm version is ``output-filter/1``: its number is part of the settings
|
||||
identity emitted in ``StreamEvent`` payloads, so T14 can rebuild the same chain
|
||||
from an event.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import asdict, replace
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .force_readout import ForceOutputFilter, OutputSettings
|
||||
|
||||
__all__ = [
|
||||
"FIXED_RATE_ALGORITHM",
|
||||
"FixedRateOutputFilter",
|
||||
"output_settings_fingerprint",
|
||||
"warmup_frames_for",
|
||||
]
|
||||
|
||||
#: Identity of this filter's algorithm; recorded with every settings switch.
|
||||
FIXED_RATE_ALGORITHM = "output-filter/1"
|
||||
|
||||
|
||||
def output_settings_fingerprint(settings: OutputSettings) -> str:
|
||||
"""Content identity of a normalised output-setting group."""
|
||||
payload = {
|
||||
"algorithm": FIXED_RATE_ALGORITHM,
|
||||
"settings": asdict(settings.normalised()),
|
||||
}
|
||||
text = json.dumps(payload, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def warmup_frames_for(settings: OutputSettings) -> int:
|
||||
"""Frames needed before a rebuilt chain is reported as warmed up.
|
||||
|
||||
Median: one full window. Low-pass: two orders of magnitude of settling is a
|
||||
conservative small count (each order costs about one sample time constant at
|
||||
the configured cutoff); thresholds are memory-less and add nothing. The value
|
||||
is an explicit contract, not a measured optimum.
|
||||
"""
|
||||
resolved = settings.normalised()
|
||||
frames = max(1, int(resolved.median_window))
|
||||
if resolved.lowpass_hz > 0:
|
||||
frames += 2 * max(1, int(resolved.lowpass_order))
|
||||
return int(frames)
|
||||
|
||||
|
||||
class FixedRateOutputFilter(ForceOutputFilter):
|
||||
"""Published filter chain with a fixed design rate and explicit switching."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: OutputSettings,
|
||||
*,
|
||||
design_sample_rate_hz: float | None = None,
|
||||
) -> None:
|
||||
resolved = settings.normalised()
|
||||
design = (
|
||||
float(resolved.sample_rate_hz)
|
||||
if design_sample_rate_hz is None
|
||||
else float(design_sample_rate_hz)
|
||||
)
|
||||
if not np.isfinite(design) or design <= 0.0:
|
||||
raise ValueError("design_sample_rate_hz must be a positive finite number")
|
||||
# The chain itself is designed once, at the fixed rate.
|
||||
super().__init__(replace(resolved, sample_rate_hz=design))
|
||||
self.design_sample_rate_hz = design
|
||||
self._measured_sample_rate_hz: float | None = None
|
||||
self._sample_rate_irregular = False
|
||||
self._warmup_elapsed_frames = 0
|
||||
|
||||
# ------------------------------------------------------------ diagnostics
|
||||
@property
|
||||
def measured_sample_rate_hz(self) -> float | None:
|
||||
"""Measured signal rate (diagnostic only; never re-tunes the chain)."""
|
||||
return self._measured_sample_rate_hz
|
||||
|
||||
@property
|
||||
def sample_rate_irregular(self) -> bool:
|
||||
"""True when the measured rate differs from the design by more than 10%."""
|
||||
return bool(self._sample_rate_irregular)
|
||||
|
||||
@property
|
||||
def settings_fingerprint(self) -> str:
|
||||
return output_settings_fingerprint(self._settings)
|
||||
|
||||
@property
|
||||
def warmup_required_frames(self) -> int:
|
||||
return warmup_frames_for(self._settings)
|
||||
|
||||
@property
|
||||
def warmup_elapsed_frames(self) -> int:
|
||||
return int(self._warmup_elapsed_frames)
|
||||
|
||||
@property
|
||||
def warming_up(self) -> bool:
|
||||
return self._warmup_elapsed_frames < self.warmup_required_frames
|
||||
|
||||
# ------------------------------------------------------- rate observation
|
||||
def _track_signal_rate(self, signal_time_s: float | None) -> None:
|
||||
"""Measure the achieved rate for diagnostics only; never re-design."""
|
||||
if signal_time_s is None:
|
||||
return
|
||||
try:
|
||||
now = float(signal_time_s)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if not np.isfinite(now):
|
||||
return
|
||||
rate = float(self._rate.tick(now))
|
||||
if not self._rate.measured:
|
||||
return
|
||||
self._measured_sample_rate_hz = rate
|
||||
self._sample_rate_irregular = bool(
|
||||
abs(rate - self.design_sample_rate_hz) > 0.1 * self.design_sample_rate_hz
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------- processing
|
||||
def process(
|
||||
self, raw, newton, *, raw_valid=None, newton_valid=None, signal_time_s=None
|
||||
):
|
||||
result = super().process(
|
||||
raw,
|
||||
newton,
|
||||
raw_valid=raw_valid,
|
||||
newton_valid=newton_valid,
|
||||
signal_time_s=signal_time_s,
|
||||
)
|
||||
self._warmup_elapsed_frames += 1
|
||||
return result
|
||||
|
||||
# ----------------------------------------------------------------- switching
|
||||
def switch(self, settings: OutputSettings) -> bool:
|
||||
"""Replace the settings at a caller-controlled frame boundary.
|
||||
|
||||
Returns ``False`` when the normalised settings are unchanged (no chain
|
||||
rebuild, no history loss, no warm-up restart). An explicit switch is the
|
||||
only way to change the design: the new group's ``sample_rate_hz`` becomes
|
||||
the new fixed design rate (the caller only switches when that is intended).
|
||||
"""
|
||||
resolved = settings.normalised()
|
||||
if resolved == self._settings:
|
||||
return False
|
||||
self.design_sample_rate_hz = float(resolved.sample_rate_hz)
|
||||
self._warmup_elapsed_frames = 0
|
||||
self._measured_sample_rate_hz = None
|
||||
self._sample_rate_irregular = False
|
||||
self.configure(resolved)
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
super().reset()
|
||||
self._warmup_elapsed_frames = 0
|
||||
self._measured_sample_rate_hz = None
|
||||
self._sample_rate_irregular = False
|
||||
@@ -0,0 +1,676 @@
|
||||
"""New force-side session for the single-stream contract (T06a core).
|
||||
|
||||
The stage-1 architecture moves the force side from "the pipeline performs the
|
||||
reset" to "the image block stamps each frame with ``reference_generation``, and
|
||||
the force session invalidates the old zero whenever that version (or the source
|
||||
session) changes". :class:`ForceStreamSession` is that new entry:
|
||||
|
||||
* it consumes the T05 :class:`~orisys.image_pipeline.ImageMeasurement` - a T03
|
||||
``MeasurementFrame`` header plus the same-frame raw HHD feature/field;
|
||||
* it invalidates a completed or in-flight unloaded zero on a reference-version or
|
||||
session change **in every phase** (the stage-0 P3-2 gap is fixed: sampling
|
||||
started does not freeze the version check);
|
||||
* it collects the unloaded baseline only from frames of the requested version,
|
||||
with optional ``max_gap_s`` / ``max_window_s`` window limits;
|
||||
* duplicate / late / out-of-order frames produce diagnostics-only results that
|
||||
advance no zero or filter state and never invalidate a zero;
|
||||
* every input frame produces one :class:`ForceStreamOutput`, including duplicate,
|
||||
late and invalid frames, carrying the T03 frame identity and reference version.
|
||||
|
||||
The new session deliberately does **not** offer the old external-reset
|
||||
book-keeping (``process_measurement`` / ``request_zero_external_reset`` /
|
||||
``tracker_reset_seq``): those belong to the stage-0 class, which still serves the
|
||||
App until T09/T10. For T06a the zero/window/calibration/readout algorithms are
|
||||
reused through the published :class:`~orisys.force_session.ForceSession` engine
|
||||
behind this class's own state machine; T06b extends the calibration coverage
|
||||
(field/FN-only models) and the full numeric-equivalence suite.
|
||||
|
||||
One instance belongs to one force-side thread. Input arrays are read, never
|
||||
mutated; outputs carry scalars/statuses/immutable progress, not internal arrays.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .force_readout import OutputSettings
|
||||
from .force_session import (
|
||||
WORKFLOW_VERSION,
|
||||
ForceOutput,
|
||||
ForceSession,
|
||||
ZeroProgress,
|
||||
)
|
||||
from .force_session import (
|
||||
FrameStamp as ForceFrameStamp,
|
||||
)
|
||||
from .force_stream_filter import (
|
||||
FIXED_RATE_ALGORITHM,
|
||||
FixedRateOutputFilter,
|
||||
)
|
||||
from .sensor import FrameMeasurement
|
||||
from .stream_contract import MeasurementFrame, StreamEvent
|
||||
|
||||
__all__ = ["ForceStreamOutput", "ForceStreamSession"]
|
||||
|
||||
|
||||
def _status_triple(status: str) -> tuple[str, str, str]:
|
||||
return (status, status, status)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ForceStreamOutput:
|
||||
"""One result-stream row: T03 header/version plus this frame's force result.
|
||||
|
||||
``quality`` is the frame's place in the contract, not a filter setting:
|
||||
``ok`` / ``uncalibrated`` / ``zero_pending`` / ``duplicate`` / ``late`` /
|
||||
``session_changed`` / ``version_changed`` / ``window_failed`` / ``invalid``.
|
||||
Rejected frames still carry their raw feature and the T03 frame header, so a
|
||||
recorder can keep late invalidity visible instead of dropping it.
|
||||
"""
|
||||
|
||||
frame: MeasurementFrame
|
||||
raw: tuple[float, float, float]
|
||||
raw_status: tuple[str, str, str]
|
||||
newton: tuple[float, float, float]
|
||||
newton_status: tuple[str, str, str]
|
||||
zero: ZeroProgress
|
||||
quality: str
|
||||
diagnostics: tuple[tuple[str, str | float], ...] = ()
|
||||
accepted: bool = True
|
||||
duplicate: bool = False
|
||||
late: bool = False
|
||||
calibration_fingerprint: str = ""
|
||||
workflow_version: str = WORKFLOW_VERSION
|
||||
# T07: the output-filter identity used for this frame and its warm-up state.
|
||||
output_settings: OutputSettings | None = None
|
||||
output_settings_fingerprint: str = ""
|
||||
design_sample_rate_hz: float | None = None
|
||||
measured_sample_rate_hz: float | None = None
|
||||
sample_rate_irregular: bool = False
|
||||
warmup_required_frames: int = 0
|
||||
warmup_elapsed_frames: int = 0
|
||||
warming_up: bool = False
|
||||
#: T09c: the same frame's unfiltered measurement, before the output chain.
|
||||
measurement_raw: tuple[float, float, float] = (float("nan"),) * 3
|
||||
measurement_newton: tuple[float, float, float] = (float("nan"),) * 3
|
||||
|
||||
@property
|
||||
def reference_generation(self) -> int:
|
||||
return int(self.frame.reference_generation)
|
||||
|
||||
|
||||
class _PendingMeasurementSource:
|
||||
"""The reused ForceSession engine asks for the frame this session just set."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pending: FrameMeasurement | None = None
|
||||
self.session_enabled = False
|
||||
self.reset_count = 0
|
||||
|
||||
def enable_measurement_session(self) -> None:
|
||||
self.session_enabled = True
|
||||
|
||||
def disable_measurement_session(self) -> None:
|
||||
self.session_enabled = False
|
||||
|
||||
def reset_tracker(self) -> None:
|
||||
# The image side owns the tracker/reference reset; the session receives
|
||||
# already-measured frames and must not reshape image state.
|
||||
self.reset_count += 1
|
||||
|
||||
def read_measurement_frame(
|
||||
self,
|
||||
source_frame,
|
||||
*,
|
||||
frame_id: int,
|
||||
timestamp_s: float | None = None,
|
||||
want_field: bool = False,
|
||||
want_contact: bool = False,
|
||||
) -> FrameMeasurement:
|
||||
del source_frame, frame_id, timestamp_s, want_field, want_contact
|
||||
row = self._pending
|
||||
if row is None:
|
||||
raise RuntimeError("ForceStreamSession has no pending measurement")
|
||||
self._pending = None
|
||||
return row
|
||||
|
||||
|
||||
def _finite_triple(values) -> tuple[bool, bool, bool]:
|
||||
return tuple(bool(np.isfinite(float(value))) for value in values) # type: ignore[return-value]
|
||||
|
||||
|
||||
class ForceStreamSession:
|
||||
"""Force-side session consuming T05 measurements (T06a core).
|
||||
|
||||
See the module docstring for the contract. Calibration/readout reuse the
|
||||
published engine; this class owns the reference-version zero/window and the
|
||||
result stream.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
sensor_family: str,
|
||||
device_id: str | None = None,
|
||||
context=None,
|
||||
calibration=None,
|
||||
settings=None,
|
||||
design_sample_rate_hz: float | None = None,
|
||||
) -> None:
|
||||
self._source = _PendingMeasurementSource()
|
||||
self._engine = ForceSession(
|
||||
source=self._source,
|
||||
sensor_family=sensor_family,
|
||||
device_id=device_id,
|
||||
context=context,
|
||||
calibration=calibration,
|
||||
settings=settings,
|
||||
)
|
||||
# T07: the session owns the output filter so the design rate is fixed once
|
||||
# and explicit switches can be applied at a frame boundary. The engine's
|
||||
# zero/calibration code keeps calling the same filter interface.
|
||||
self._filter = FixedRateOutputFilter(
|
||||
self._engine.output_settings,
|
||||
design_sample_rate_hz=design_sample_rate_hz,
|
||||
)
|
||||
self._engine._filter = self._filter
|
||||
self._pending_filter_settings: OutputSettings | None = None
|
||||
self._filter_lock = threading.Lock()
|
||||
self._events: list[StreamEvent] = []
|
||||
self._family = sensor_family
|
||||
self._device_id = device_id
|
||||
self._version: int | None = None
|
||||
self._session_id: str | None = None
|
||||
self._last_time_s: float | None = None
|
||||
self._seen: dict[tuple[str, str, int], None] = {}
|
||||
self._seen_limit = 4096
|
||||
# Active unloaded-zero request (None when no reset is being confirmed).
|
||||
self._zero_target: int | None = None
|
||||
self._max_gap_s: float | None = None
|
||||
self._max_window_s: float | None = None
|
||||
self._window_first_s: float | None = None
|
||||
self._window_last_s: float | None = None
|
||||
self._last_output: ForceStreamOutput | None = None
|
||||
|
||||
# ------------------------------------------------------------- properties
|
||||
@property
|
||||
def workflow_version(self) -> str:
|
||||
return WORKFLOW_VERSION
|
||||
|
||||
@property
|
||||
def sensor_family(self) -> str:
|
||||
return self._family
|
||||
|
||||
@property
|
||||
def calibration(self):
|
||||
return self._engine.calibration
|
||||
|
||||
@property
|
||||
def calibration_fingerprint(self) -> str:
|
||||
return self._engine.calibration_fingerprint
|
||||
|
||||
@property
|
||||
def zero(self) -> ZeroProgress:
|
||||
return self._engine.zero
|
||||
|
||||
@property
|
||||
def output_settings(self):
|
||||
return self._engine.output_settings
|
||||
|
||||
@property
|
||||
def design_sample_rate_hz(self) -> float:
|
||||
"""The fixed filter design rate chosen when this session was created."""
|
||||
return float(self._filter.design_sample_rate_hz)
|
||||
|
||||
@property
|
||||
def output_filter(self) -> FixedRateOutputFilter:
|
||||
"""The session-owned filter (read-only diagnostic access)."""
|
||||
return self._filter
|
||||
|
||||
@property
|
||||
def last_output(self) -> ForceStreamOutput | None:
|
||||
return self._last_output
|
||||
|
||||
def request_output_settings(self, settings: OutputSettings) -> None:
|
||||
"""Queue an explicit output-filter switch for the next frame boundary.
|
||||
|
||||
Safe to call from another thread: the request only records the latest
|
||||
setting group. The force thread applies it at the start of the next
|
||||
:meth:`process` call (never in the middle of a frame), rebuilds the
|
||||
filter chain, clears its history and emits one ``output_filter_changed``
|
||||
event. Requesting the currently active settings is a no-op.
|
||||
"""
|
||||
if not isinstance(settings, OutputSettings):
|
||||
raise TypeError("settings must be an OutputSettings")
|
||||
with self._filter_lock:
|
||||
self._pending_filter_settings = settings.normalised()
|
||||
|
||||
def drain_events(self) -> tuple[StreamEvent, ...]:
|
||||
"""Return the events emitted since the previous drain, in order."""
|
||||
events = tuple(self._events)
|
||||
self._events.clear()
|
||||
return events
|
||||
|
||||
@property
|
||||
def measurement_requirements(self) -> tuple[bool, bool]:
|
||||
"""``(want_field, want_contact)`` the installed calibration model asks for.
|
||||
|
||||
A field model (``hhd_normal_l1_field/1``) asks for the same-frame field, so
|
||||
a T05 caller must include ``field`` in the ``ImageMeasurement``; a
|
||||
scalar/FN-only model asks for neither. The engine's answer is reused
|
||||
instead of a second interpretation of the model.
|
||||
"""
|
||||
return self._engine.measurement_requirements
|
||||
|
||||
@property
|
||||
def reference_generation(self) -> int | None:
|
||||
"""The reference generation this session currently accepts frames for."""
|
||||
return self._version
|
||||
|
||||
# ------------------------------------------------------------ zero request
|
||||
def request_zero(
|
||||
self,
|
||||
*,
|
||||
reference_id: str,
|
||||
declared_unloaded: bool,
|
||||
expected_reference_generation: int,
|
||||
max_gap_s: float | None = None,
|
||||
max_window_s: float | None = None,
|
||||
) -> ZeroProgress:
|
||||
"""Confirm the unloaded state for exactly ``expected_reference_generation``.
|
||||
|
||||
A reset confirmation names the version it belongs to (K8.3: image reset
|
||||
confirmation, then same-version unloaded frames). Frames of the still-active
|
||||
version that arrive while the image reset is in flight are ``zero_pending``
|
||||
diagnostics, never baseline frames. The requested version is adopted when
|
||||
its first frame arrives (the request is not invalidated by its own target);
|
||||
only a version beyond the request fails it.
|
||||
"""
|
||||
if not declared_unloaded:
|
||||
raise ValueError(
|
||||
"unloaded zero requires the operator to confirm there is no load"
|
||||
)
|
||||
if isinstance(expected_reference_generation, bool) or not isinstance(
|
||||
expected_reference_generation, int
|
||||
):
|
||||
raise TypeError("expected_reference_generation must be an integer")
|
||||
if expected_reference_generation < 0:
|
||||
raise ValueError("expected_reference_generation must be >= 0")
|
||||
if self._version is not None and expected_reference_generation < self._version:
|
||||
raise ValueError(
|
||||
"expected_reference_generation is older than the active version "
|
||||
f"{self._version}"
|
||||
)
|
||||
for name, value in (("max_gap_s", max_gap_s), ("max_window_s", max_window_s)):
|
||||
if value is not None:
|
||||
value = float(value)
|
||||
if not np.isfinite(value) or value <= 0.0:
|
||||
raise ValueError(f"{name} must be a positive finite number or None")
|
||||
self._zero_target = int(expected_reference_generation)
|
||||
self._max_gap_s = None if max_gap_s is None else float(max_gap_s)
|
||||
self._max_window_s = None if max_window_s is None else float(max_window_s)
|
||||
self._window_first_s = None
|
||||
self._window_last_s = None
|
||||
return self._engine.request_zero(
|
||||
reference_id=reference_id, declared_unloaded=True
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------- result rows
|
||||
def _apply_pending_output_filter(self, frame: MeasurementFrame) -> None:
|
||||
"""Apply a queued settings switch at this frame boundary (force thread)."""
|
||||
with self._filter_lock:
|
||||
pending = self._pending_filter_settings
|
||||
self._pending_filter_settings = None
|
||||
if pending is None:
|
||||
return
|
||||
if not self._filter.switch(pending):
|
||||
return
|
||||
settings = self._filter.settings
|
||||
self._events.append(
|
||||
StreamEvent(
|
||||
kind="output_filter_changed",
|
||||
stamp=frame.stamp,
|
||||
payload={
|
||||
"algorithm": FIXED_RATE_ALGORITHM,
|
||||
"settings": asdict(settings),
|
||||
"fingerprint": self._filter.settings_fingerprint,
|
||||
"design_sample_rate_hz": float(self._filter.design_sample_rate_hz),
|
||||
"effective_frame_id": int(frame.stamp.frame_id),
|
||||
"effective_source_frame_id": frame.stamp.source_frame_id,
|
||||
"effective_stamp": frame.stamp.to_dict(),
|
||||
"warmup": {
|
||||
"required_frames": int(self._filter.warmup_required_frames),
|
||||
"elapsed_frames": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def _row(
|
||||
self,
|
||||
frame: MeasurementFrame,
|
||||
*,
|
||||
raw: tuple[float, float, float],
|
||||
raw_status: tuple[str, str, str],
|
||||
newton: tuple[float, float, float],
|
||||
newton_status: tuple[str, str, str],
|
||||
quality: str,
|
||||
diagnostics: tuple[tuple[str, str | float], ...] = (),
|
||||
accepted: bool = True,
|
||||
duplicate: bool = False,
|
||||
late: bool = False,
|
||||
measurement_raw: tuple[float, float, float] | None = None,
|
||||
measurement_newton: tuple[float, float, float] | None = None,
|
||||
) -> ForceStreamOutput:
|
||||
output = ForceStreamOutput(
|
||||
frame=frame,
|
||||
raw=raw,
|
||||
raw_status=raw_status,
|
||||
newton=newton,
|
||||
newton_status=newton_status,
|
||||
zero=self._engine.zero,
|
||||
quality=quality,
|
||||
diagnostics=diagnostics,
|
||||
accepted=accepted,
|
||||
duplicate=duplicate,
|
||||
late=late,
|
||||
calibration_fingerprint=self._engine.calibration_fingerprint,
|
||||
output_settings=self._filter.settings,
|
||||
output_settings_fingerprint=self._filter.settings_fingerprint,
|
||||
design_sample_rate_hz=self._filter.design_sample_rate_hz,
|
||||
measured_sample_rate_hz=self._filter.measured_sample_rate_hz,
|
||||
sample_rate_irregular=self._filter.sample_rate_irregular,
|
||||
warmup_required_frames=self._filter.warmup_required_frames,
|
||||
warmup_elapsed_frames=self._filter.warmup_elapsed_frames,
|
||||
warming_up=self._filter.warming_up,
|
||||
measurement_raw=(
|
||||
(float("nan"),) * 3
|
||||
if measurement_raw is None
|
||||
else tuple(float(value) for value in measurement_raw)
|
||||
),
|
||||
measurement_newton=(
|
||||
(float("nan"),) * 3
|
||||
if measurement_newton is None
|
||||
else tuple(float(value) for value in measurement_newton)
|
||||
),
|
||||
)
|
||||
self._last_output = output
|
||||
return output
|
||||
|
||||
def _diagnostic_row(
|
||||
self,
|
||||
frame: MeasurementFrame,
|
||||
measurement,
|
||||
*,
|
||||
quality: str,
|
||||
reason: str,
|
||||
duplicate: bool = False,
|
||||
late: bool = False,
|
||||
) -> ForceStreamOutput:
|
||||
raw = tuple(float(value) for value in measurement.force_raw)
|
||||
return self._row(
|
||||
frame,
|
||||
raw=raw,
|
||||
raw_status=tuple(
|
||||
"ok" if finite else "unavailable" for finite in _finite_triple(raw)
|
||||
),
|
||||
newton=(float("nan"),) * 3,
|
||||
newton_status=_status_triple(quality),
|
||||
quality=quality,
|
||||
diagnostics=(("reason", reason),),
|
||||
accepted=False,
|
||||
duplicate=duplicate,
|
||||
late=late,
|
||||
measurement_raw=raw,
|
||||
measurement_newton=(float("nan"),) * 3,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _identity(frame: MeasurementFrame) -> tuple[str, str, int]:
|
||||
stamp = frame.stamp
|
||||
source = (
|
||||
stamp.source_frame_id
|
||||
if stamp.source_frame_id is not None
|
||||
else stamp.frame_id
|
||||
)
|
||||
return (str(stamp.sensor_id), str(stamp.session_id), int(source))
|
||||
|
||||
def _source_time_s(self, frame: MeasurementFrame) -> float:
|
||||
return float(frame.stamp.capture_time_ns) / 1e9
|
||||
|
||||
def _invalidate(self, reason: str) -> None:
|
||||
self._engine.invalidate(reason=reason)
|
||||
self._zero_target = None
|
||||
self._window_first_s = None
|
||||
self._window_last_s = None
|
||||
|
||||
def _check_window_limits(self, frame: MeasurementFrame) -> str | None:
|
||||
"""Return a failure reason when the next same-version frame would exceed a limit."""
|
||||
if self._engine.zero.phase != "collecting":
|
||||
return None
|
||||
now = self._source_time_s(frame)
|
||||
if self._max_gap_s is not None and self._window_last_s is not None:
|
||||
gap = now - self._window_last_s
|
||||
if gap > self._max_gap_s:
|
||||
return (
|
||||
f"zero baseline gap {gap:.6f}s exceeds max_gap_s="
|
||||
f"{self._max_gap_s:.6f}s"
|
||||
)
|
||||
if self._max_window_s is not None and self._window_first_s is not None:
|
||||
span = now - self._window_first_s
|
||||
if span > self._max_window_s:
|
||||
return (
|
||||
f"zero baseline window {span:.6f}s exceeds max_window_s="
|
||||
f"{self._max_window_s:.6f}s"
|
||||
)
|
||||
return None
|
||||
|
||||
def _feed_engine(self, measurement, frame: MeasurementFrame) -> ForceOutput:
|
||||
row = FrameMeasurement(
|
||||
raw=tuple(float(value) for value in measurement.force_raw),
|
||||
raw_valid=_finite_triple(measurement.force_raw),
|
||||
field=measurement.field,
|
||||
output_valid_mask=measurement.valid_mask,
|
||||
diagnostics=(("frame_id", int(frame.stamp.frame_id)),),
|
||||
)
|
||||
self._source._pending = row
|
||||
stamp = ForceFrameStamp(
|
||||
session_id=str(frame.stamp.session_id),
|
||||
frame_id=int(frame.stamp.frame_id),
|
||||
source_frame_id=frame.stamp.source_frame_id,
|
||||
signal_time_s=self._source_time_s(frame),
|
||||
time_basis=frame.stamp.capture_time_basis,
|
||||
)
|
||||
# process_frame applies a pending zero reset before measuring this frame,
|
||||
# so the first frame after request_zero() is baseline frame 1 (same order
|
||||
# as the published session). The adapter hands it the already-measured row.
|
||||
return self._engine.process_frame(None, stamp)
|
||||
|
||||
def _quality_for(self, output: ForceOutput) -> str:
|
||||
zero_phase = self._engine.zero.phase
|
||||
if zero_phase in ("resetting", "collecting"):
|
||||
return "zero_pending"
|
||||
if zero_phase == "failed":
|
||||
return "invalid"
|
||||
if self._engine.calibration is None:
|
||||
return "uncalibrated"
|
||||
if all(status == "uncalibrated" for status in output.measurement.newton_status):
|
||||
return "uncalibrated"
|
||||
if any(status == "ok" for status in output.measurement.newton_status):
|
||||
return "ok"
|
||||
return "invalid"
|
||||
|
||||
def process(self, measurement) -> ForceStreamOutput:
|
||||
"""Consume one T05 ``ImageMeasurement`` and return this frame's result row.
|
||||
|
||||
A duplicate / late / out-of-order frame is reported without advancing the
|
||||
zero window or readout filter, and without invalidating a completed zero.
|
||||
A reference-version or session change fails the old zero immediately (in
|
||||
any phase) and requires an explicit :meth:`request_zero` for the new one.
|
||||
An explicit output-filter switch queued by
|
||||
:meth:`request_output_settings` takes effect at this frame boundary.
|
||||
"""
|
||||
frame = getattr(measurement, "frame", None)
|
||||
if not isinstance(frame, MeasurementFrame):
|
||||
raise TypeError(
|
||||
"ForceStreamSession consumes an ImageMeasurement-like input"
|
||||
)
|
||||
self._apply_pending_output_filter(frame)
|
||||
identity = self._identity(frame)
|
||||
version = int(frame.reference_generation)
|
||||
session_id = str(frame.stamp.session_id)
|
||||
signal_time = self._source_time_s(frame)
|
||||
target = self._zero_target
|
||||
|
||||
# 1) Duplicates: same source frame identity, diagnostics only.
|
||||
if identity in self._seen:
|
||||
return self._diagnostic_row(
|
||||
frame,
|
||||
measurement,
|
||||
quality="duplicate",
|
||||
reason="duplicate source frame identity",
|
||||
duplicate=True,
|
||||
)
|
||||
|
||||
# 2) Session change fails any zero (all phases), then this frame is a raw row.
|
||||
if self._session_id is not None and session_id != self._session_id:
|
||||
self._invalidate(
|
||||
f"source session changed {self._session_id!r} -> {session_id!r}"
|
||||
)
|
||||
self._session_id = session_id
|
||||
self._version = version
|
||||
self._last_time_s = signal_time
|
||||
self._remember(identity)
|
||||
output = self._feed_engine(measurement, frame)
|
||||
return self._accepted_row(frame, output, quality="session_changed")
|
||||
|
||||
# 3) The active version changed. The requested zero target is the one
|
||||
# version that may be adopted without invalidating the request: the
|
||||
# normal order is v0 frames in flight -> request_zero(v1) -> v1 arrives.
|
||||
if self._version is not None and version != self._version:
|
||||
if version < self._version:
|
||||
return self._diagnostic_row(
|
||||
frame,
|
||||
measurement,
|
||||
quality="late",
|
||||
reason=(
|
||||
f"reference generation {version} is older than the active "
|
||||
f"version {self._version}"
|
||||
),
|
||||
late=True,
|
||||
)
|
||||
if target is not None and version == target:
|
||||
# Adopt the requested version: start/continue the same-version
|
||||
# baseline window; the request must not be invalidated by its own
|
||||
# first frame.
|
||||
self._version = version
|
||||
self._session_id = session_id
|
||||
self._last_time_s = signal_time
|
||||
self._remember(identity)
|
||||
output = self._feed_engine(measurement, frame)
|
||||
self._track_window(signal_time)
|
||||
return self._accepted_row(
|
||||
frame, output, quality=self._quality_for(output)
|
||||
)
|
||||
self._invalidate(
|
||||
f"reference generation changed {self._version} -> {version}"
|
||||
)
|
||||
self._version = version
|
||||
self._session_id = session_id
|
||||
self._last_time_s = signal_time
|
||||
self._remember(identity)
|
||||
output = self._feed_engine(measurement, frame)
|
||||
return self._accepted_row(frame, output, quality="version_changed")
|
||||
|
||||
# 4) Reset confirmation: only the requested version may fill the window.
|
||||
if target is not None:
|
||||
if version < target:
|
||||
# Frames of the still-active version that arrive while the image
|
||||
# reset is in flight: waiting for the target, not late. They must
|
||||
# not enter the baseline window.
|
||||
return self._diagnostic_row(
|
||||
frame,
|
||||
measurement,
|
||||
quality="zero_pending",
|
||||
reason=(
|
||||
f"waiting for reference generation {target}; frame carries "
|
||||
f"the still-active version {version}"
|
||||
),
|
||||
)
|
||||
if version > target:
|
||||
self._invalidate(
|
||||
f"reference generation {version} advanced past the requested "
|
||||
f"zero version {target}"
|
||||
)
|
||||
self._version = version
|
||||
self._session_id = session_id
|
||||
self._last_time_s = signal_time
|
||||
self._remember(identity)
|
||||
output = self._feed_engine(measurement, frame)
|
||||
return self._accepted_row(frame, output, quality="version_changed")
|
||||
|
||||
# 5) Same timestamp / out-of-order: diagnostics only, never invalidates.
|
||||
if self._last_time_s is not None and signal_time <= self._last_time_s:
|
||||
return self._diagnostic_row(
|
||||
frame,
|
||||
measurement,
|
||||
quality="late",
|
||||
reason="frame signal time is not after the last accepted frame",
|
||||
late=True,
|
||||
)
|
||||
|
||||
# 6) Baseline-window limits fail the request before the frame enters it.
|
||||
window_problem = self._check_window_limits(frame)
|
||||
if window_problem is not None:
|
||||
self._invalidate(window_problem)
|
||||
self._version = version
|
||||
self._session_id = session_id
|
||||
self._last_time_s = signal_time
|
||||
self._remember(identity)
|
||||
output = self._feed_engine(measurement, frame)
|
||||
return self._accepted_row(frame, output, quality="window_failed")
|
||||
|
||||
# Accepted: this frame is measured once by the reused engine.
|
||||
self._version = version if self._version is None else self._version
|
||||
self._session_id = session_id
|
||||
self._last_time_s = signal_time
|
||||
self._remember(identity)
|
||||
output = self._feed_engine(measurement, frame)
|
||||
self._track_window(signal_time)
|
||||
return self._accepted_row(frame, output, quality=self._quality_for(output))
|
||||
|
||||
def _remember(self, identity: tuple[str, str, int]) -> None:
|
||||
"""Register every frame handed to the engine (duplicate detection)."""
|
||||
self._seen[identity] = None
|
||||
if len(self._seen) > self._seen_limit:
|
||||
self._seen.clear()
|
||||
self._seen[identity] = None
|
||||
|
||||
def _track_window(self, signal_time: float) -> None:
|
||||
if self._engine.zero.phase == "collecting":
|
||||
if self._window_first_s is None:
|
||||
self._window_first_s = signal_time
|
||||
self._window_last_s = signal_time
|
||||
|
||||
def _accepted_row(
|
||||
self, frame: MeasurementFrame, output: ForceOutput, *, quality: str
|
||||
) -> ForceStreamOutput:
|
||||
return self._row(
|
||||
frame,
|
||||
raw=tuple(float(value) for value in output.raw),
|
||||
raw_status=tuple(str(value) for value in output.measurement.sdk.raw_status),
|
||||
newton=tuple(float(value) for value in output.newton),
|
||||
newton_status=tuple(
|
||||
str(value) for value in output.measurement.newton_status
|
||||
),
|
||||
quality=quality,
|
||||
diagnostics=tuple(output.measurement.diagnostics),
|
||||
accepted=True,
|
||||
measurement_raw=tuple(float(value) for value in output.measurement.sdk.raw),
|
||||
measurement_newton=tuple(
|
||||
float(value) for value in output.measurement.newton
|
||||
),
|
||||
)
|
||||
@@ -155,6 +155,37 @@ class nHHD:
|
||||
return self.d
|
||||
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):
|
||||
"""Dispatch Poisson solve using the selected backend.
|
||||
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
"""Image block: ImageSettings and an independently runnable ImagePipeline.
|
||||
|
||||
Stage-1 architecture (K8.1, K11.1, K11.3, task T05) separates the image side of
|
||||
measurement from :class:`~orisys.sensor.Sensor`:
|
||||
|
||||
* :class:`ImageSettings` is the immutable description of one image
|
||||
configuration (optical-flow tracker settings, HHD grid/backend, flow EMA).
|
||||
* :class:`ImagePipeline` owns the tracker, the HHD solver and their scratch
|
||||
state. It turns a prepared grey frame into flow/HHD/field results, writes the
|
||||
optical-flow reference generation at the moment it computes a frame, and
|
||||
publishes measurement arrays as read-only copies.
|
||||
|
||||
``Sensor`` delegates to an ``ImagePipeline``; ``FingerRuntime`` reaches the same
|
||||
object through its tactile sensor, so all three published paths keep one image
|
||||
implementation. The class can also be used without a Sensor at all::
|
||||
|
||||
from orisys import configs
|
||||
from orisys.image_pipeline import ImagePipeline, ImageSettings, sensor_clock
|
||||
|
||||
context = sensor.context # or build_sensor_context(...)
|
||||
settings = ImageSettings.from_config(configs.load_config("box"))
|
||||
pipeline = ImagePipeline(settings, context=context)
|
||||
measurement = pipeline.measure(
|
||||
prepared_grey_frame,
|
||||
identity=context.frame_identity(session_id="run-1", frame_id=0, source_frame_id=0),
|
||||
clock=sensor_clock(capture_time_ns, clock_id="host", basis="capture_monotonic"),
|
||||
)
|
||||
print(measurement.force_raw, measurement.flow.shape, measurement.frame.reference_generation)
|
||||
|
||||
Image preparation (stitch/unroll/resize/mirror) remains in the acquisition layer
|
||||
for now; the pipeline starts at the prepared grey frame, which is exactly what
|
||||
``Sensor.compute_flow``/``compute_hhd`` have always consumed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field as dataclass_field
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from .hhd import nHHD
|
||||
from .stream_contract import (
|
||||
FrameClock,
|
||||
FrameIdentity,
|
||||
FrameStamp,
|
||||
MeasurementFrame,
|
||||
TimeBasis,
|
||||
)
|
||||
from .tracker import Tracker
|
||||
|
||||
__all__ = [
|
||||
"ImageDecomposition",
|
||||
"ImageMeasurement",
|
||||
"ImagePipeline",
|
||||
"ImageSettings",
|
||||
"sensor_clock",
|
||||
]
|
||||
|
||||
#: Arrays an independent measurement may request with ``include``.
|
||||
_INCLUDE_FIELDS = frozenset(
|
||||
{"image", "flow", "vnormal", "vshear", "field", "valid_mask"}
|
||||
)
|
||||
|
||||
|
||||
def sensor_clock(
|
||||
capture_time_ns: int,
|
||||
*,
|
||||
clock_id: str,
|
||||
basis: TimeBasis = "capture_monotonic",
|
||||
) -> FrameClock:
|
||||
"""Convenience constructor for the capture clock of a prepared frame.
|
||||
|
||||
``basis`` is one of :data:`~orisys.stream_contract.TIME_BASES`; the value is
|
||||
validated by :class:`~orisys.stream_contract.FrameClock`.
|
||||
"""
|
||||
return FrameClock(
|
||||
capture_time_ns=int(capture_time_ns),
|
||||
clock_id=str(clock_id),
|
||||
capture_time_basis=basis,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImageSettings:
|
||||
"""One image configuration: tracker, HHD and flow-EMA settings.
|
||||
|
||||
``from_config`` reads the same config keys ``Sensor`` has always read. The
|
||||
flow EMA alpha is resolved through :class:`~orisys.force_readout.ForceReadout`
|
||||
so the existing filter-preset interpretation has exactly one implementation.
|
||||
"""
|
||||
|
||||
tracker_config: Mapping[str, Any]
|
||||
cuda: bool
|
||||
verbose: bool
|
||||
hhd_downsample: int
|
||||
poisson_backend: str
|
||||
masked_poisson_solver: str
|
||||
masked_poisson_max_boundary: int
|
||||
masked_poisson_boundary_stride: int
|
||||
masked_poisson_max_iters: int
|
||||
masked_poisson_tol: float
|
||||
masked_poisson_reg: float
|
||||
flow_ema_alpha: float = 1.0
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
cls,
|
||||
config: Mapping[str, Any] | None,
|
||||
*,
|
||||
cuda: bool = True,
|
||||
backend: str = "auto",
|
||||
verbose: bool = False,
|
||||
flow_ema_alpha: float | None = None,
|
||||
) -> ImageSettings:
|
||||
"""Build settings from ``sensor.json``-shaped configuration."""
|
||||
cfg: Mapping[str, Any] = config if isinstance(config, Mapping) else {}
|
||||
tracker = dict(cfg.get("tracker") or {})
|
||||
raw_ds = tracker.get("hhd_downsample", 1)
|
||||
try:
|
||||
hhd_downsample = max(1, int(raw_ds)) if raw_ds is not None else 1
|
||||
except (TypeError, ValueError):
|
||||
hhd_downsample = 1
|
||||
if flow_ema_alpha is None:
|
||||
# Reuse the existing filter-preset resolution instead of a second
|
||||
# interpretation of the filters block (that block owns the EMA).
|
||||
from .force_readout import ForceReadout
|
||||
|
||||
flow_ema_alpha = ForceReadout(cfg.get("filters")).flow_ema_alpha
|
||||
backend_from_config = tracker.get("backend", None)
|
||||
poisson_backend = str(
|
||||
backend_from_config if backend_from_config is not None else backend
|
||||
)
|
||||
return cls(
|
||||
tracker_config=tracker,
|
||||
cuda=bool(cuda),
|
||||
verbose=bool(verbose),
|
||||
hhd_downsample=hhd_downsample,
|
||||
poisson_backend=poisson_backend,
|
||||
masked_poisson_solver=str(tracker.get("masked_poisson_solver", "sparse")),
|
||||
masked_poisson_max_boundary=int(
|
||||
tracker.get("masked_poisson_max_boundary", 96)
|
||||
),
|
||||
masked_poisson_boundary_stride=int(
|
||||
tracker.get("masked_poisson_boundary_stride", 1)
|
||||
),
|
||||
masked_poisson_max_iters=int(tracker.get("masked_poisson_max_iters", 3)),
|
||||
masked_poisson_tol=float(tracker.get("masked_poisson_tol", 1e-4)),
|
||||
masked_poisson_reg=float(tracker.get("masked_poisson_reg", 1e-8)),
|
||||
flow_ema_alpha=float(flow_ema_alpha),
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Stable identity of the image settings, for records and comparisons."""
|
||||
return {
|
||||
"tracker": dict(self.tracker_config),
|
||||
"cuda": self.cuda,
|
||||
"hhd_downsample": self.hhd_downsample,
|
||||
"poisson_backend": self.poisson_backend,
|
||||
"masked_poisson_solver": self.masked_poisson_solver,
|
||||
"masked_poisson_max_boundary": self.masked_poisson_max_boundary,
|
||||
"masked_poisson_boundary_stride": self.masked_poisson_boundary_stride,
|
||||
"masked_poisson_max_iters": self.masked_poisson_max_iters,
|
||||
"masked_poisson_tol": self.masked_poisson_tol,
|
||||
"masked_poisson_reg": self.masked_poisson_reg,
|
||||
"flow_ema_alpha": self.flow_ema_alpha,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImageDecomposition:
|
||||
"""Flow/HHD result for one prepared frame (internal state objects, not copies)."""
|
||||
|
||||
flow: np.ndarray
|
||||
vnormal: np.ndarray
|
||||
vshear: np.ndarray
|
||||
force_raw: tuple[float, float, float]
|
||||
torque: float
|
||||
valid_mask_hhd: np.ndarray | None
|
||||
|
||||
|
||||
def _readonly_copy(value: Any) -> Any:
|
||||
"""Copy arrays (recursively for the field payload) and freeze them."""
|
||||
if isinstance(value, np.ndarray):
|
||||
out = np.array(value, copy=True)
|
||||
out.setflags(write=False)
|
||||
return out
|
||||
if isinstance(value, dict):
|
||||
return {key: _readonly_copy(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return type(value)(_readonly_copy(item) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImageMeasurement:
|
||||
"""One image measurement: T03 header plus read-only flow/HHD/field payload."""
|
||||
|
||||
frame: MeasurementFrame
|
||||
force_raw: tuple[float, float, float]
|
||||
torque: float
|
||||
flow: np.ndarray | None = None
|
||||
vnormal: np.ndarray | None = None
|
||||
vshear: np.ndarray | None = None
|
||||
valid_mask: np.ndarray | None = None
|
||||
field: dict | None = None
|
||||
image: np.ndarray | None = None
|
||||
elapsed_s: float = 0.0
|
||||
diagnostics: tuple[tuple[str, str | float], ...] = dataclass_field(
|
||||
default_factory=tuple
|
||||
)
|
||||
|
||||
|
||||
class ImagePipeline:
|
||||
"""Own the image half of measurement: tracker, HHD, field and reference version.
|
||||
|
||||
Thread ownership mirrors stage 0: one instance is used by one image thread.
|
||||
The reference version is **not counted here**: the stream that owns it (the
|
||||
stage-0 ``StreamPipeline`` reset protocol) adopts it through
|
||||
:meth:`set_reference_generation` or passes it to :meth:`measure`; the image
|
||||
block only stamps what it was told. :meth:`reset_image_state` rebases the
|
||||
tracker/flow EMA and never invents a version. The force/contact/model half
|
||||
stays in ``Sensor``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: ImageSettings,
|
||||
*,
|
||||
context: Any = None,
|
||||
verbose: bool = False,
|
||||
flow_ema: Any = None,
|
||||
) -> None:
|
||||
from .force_readout import ForceReadout
|
||||
|
||||
self.settings = settings
|
||||
self.context = context
|
||||
self.verbose = bool(verbose)
|
||||
# One owner for the flow-EMA coefficient *and* its history: the published
|
||||
# ForceReadout when a Sensor binds this pipeline, otherwise a private one.
|
||||
# Sensor.configure(flow_ema_alpha=...) therefore keeps working on the next
|
||||
# frame, exactly as it did before the image block was split out.
|
||||
self._flow_ema = (
|
||||
flow_ema
|
||||
if flow_ema is not None
|
||||
else ForceReadout({"flow_ema_alpha": settings.flow_ema_alpha})
|
||||
)
|
||||
self._tracker = Tracker(
|
||||
adaptive=True,
|
||||
cuda=settings.cuda,
|
||||
verbose=settings.verbose,
|
||||
config_tracker=dict(settings.tracker_config),
|
||||
)
|
||||
self._hhd: nHHD | None = None
|
||||
self._hhd_grid_shape: tuple[int, int] | None = None
|
||||
self._hhd_ref_shape: tuple[int, int] | None = None
|
||||
self._valid_mask_hhd: np.ndarray | None = None
|
||||
self._valid_mask: np.ndarray | None = None
|
||||
self._flow_ds_buf: np.ndarray | None = None
|
||||
self._vn_buf: np.ndarray | None = None
|
||||
self._vs_buf: np.ndarray | None = None
|
||||
self._mask_f32: np.ndarray | None = None
|
||||
self._flow: np.ndarray | None = None
|
||||
self._vnormal: np.ndarray | None = None
|
||||
self._vshear: np.ndarray | None = None
|
||||
self._force_raw: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
self._torque = 0.0
|
||||
self._reference_generation = 0
|
||||
|
||||
# ------------------------------------------------------------- read access
|
||||
@property
|
||||
def flow(self) -> np.ndarray | None:
|
||||
return self._flow
|
||||
|
||||
@property
|
||||
def vnormal(self) -> np.ndarray | None:
|
||||
return self._vnormal
|
||||
|
||||
@property
|
||||
def vshear(self) -> np.ndarray | None:
|
||||
return self._vshear
|
||||
|
||||
@property
|
||||
def valid_mask_hhd(self) -> np.ndarray | None:
|
||||
return self._valid_mask_hhd
|
||||
|
||||
@property
|
||||
def force_raw(self) -> tuple[float, float, float]:
|
||||
return self._force_raw
|
||||
|
||||
@property
|
||||
def torque(self) -> float:
|
||||
return self._torque
|
||||
|
||||
@property
|
||||
def reference_generation(self) -> int:
|
||||
"""Reference version most recently adopted from the owning stream."""
|
||||
return int(self._reference_generation)
|
||||
|
||||
def set_reference_generation(self, generation: int) -> None:
|
||||
"""Adopt the stream's reference version (called on the image thread).
|
||||
|
||||
The stage-0 ``StreamPipeline`` claims the version and passes it here
|
||||
before computing the frame's flow; ``measure`` may stamp the same value
|
||||
explicitly for an independent experiment. This class never increments it.
|
||||
"""
|
||||
if isinstance(generation, bool) or not isinstance(generation, int):
|
||||
raise TypeError("reference_generation must be an integer")
|
||||
if generation < 0:
|
||||
raise ValueError("reference_generation must be >= 0")
|
||||
self._reference_generation = int(generation)
|
||||
|
||||
@property
|
||||
def flow_ema_alpha(self) -> float:
|
||||
"""Runtime flow-EMA coefficient (owned by the readout, not this pipeline)."""
|
||||
return float(self._flow_ema.flow_ema_alpha)
|
||||
|
||||
# ----------------------------------------------------------- reset seams
|
||||
def reset_image_state(self) -> None:
|
||||
"""Rebase the tracker and drop the flow-EMA history (image thread only).
|
||||
|
||||
A reference version is not claimed here: the owning stream sets it with
|
||||
:meth:`set_reference_generation`; this method only resets image state.
|
||||
"""
|
||||
self._tracker.reset()
|
||||
self._flow_ema.reset_flow_ema()
|
||||
|
||||
def reset_tracker(self) -> None:
|
||||
"""Drop tracker/EMA history without touching the reference identity.
|
||||
|
||||
This is the frame-boundary zeroing half used by a force session before a
|
||||
baseline window: the reference frame itself is unchanged.
|
||||
"""
|
||||
self._tracker.reset()
|
||||
self._flow_ema.reset_flow_ema()
|
||||
|
||||
# ------------------------------------------------------------- flow / HHD
|
||||
def _apply_flow_ema(self, flow: np.ndarray | None) -> np.ndarray | None:
|
||||
"""Temporal flow smoothing, delegated to its single owner (ForceReadout)."""
|
||||
return self._flow_ema.apply_flow_ema(flow)
|
||||
|
||||
def _compute_flow_state(
|
||||
self, image: np.ndarray, *, valid_mask: np.ndarray | None = None
|
||||
) -> np.ndarray:
|
||||
"""Flow for one frame (the caller has already adopted the version)."""
|
||||
flow = self._tracker.track(image, valid_mask=valid_mask)
|
||||
if hasattr(flow, "download"):
|
||||
flow = flow.download()
|
||||
flow = self._apply_flow_ema(flow)
|
||||
self._flow = flow
|
||||
return flow
|
||||
|
||||
def compute_flow(
|
||||
self,
|
||||
image: np.ndarray,
|
||||
*,
|
||||
valid_mask: np.ndarray | None = None,
|
||||
reference_generation: int | None = None,
|
||||
) -> np.ndarray:
|
||||
"""Compute and return this frame's flow (one call per frame)."""
|
||||
if image is None:
|
||||
raise Exception("Warning: Get image first")
|
||||
if reference_generation is not None:
|
||||
self.set_reference_generation(reference_generation)
|
||||
return self._compute_flow_state(image, valid_mask=valid_mask)
|
||||
|
||||
def _resize_mask(
|
||||
self, mask: np.ndarray | None, size: tuple[int, int]
|
||||
) -> np.ndarray | None:
|
||||
if mask is None:
|
||||
return None
|
||||
mask_u8 = np.asarray(mask, dtype=np.uint8)
|
||||
resized = cv2.resize(mask_u8, size, interpolation=cv2.INTER_NEAREST)
|
||||
return resized.astype(bool)
|
||||
|
||||
def _hhd_grid_size(self) -> tuple[int, int] | None:
|
||||
mask = self._valid_mask
|
||||
if mask is None:
|
||||
return None
|
||||
h, w = mask.shape[:2]
|
||||
k = max(1, int(self.settings.hhd_downsample))
|
||||
if k <= 1:
|
||||
return (w, h)
|
||||
return (max(1, w // k), max(1, h // k))
|
||||
|
||||
def decompose(
|
||||
self, flow: np.ndarray | None = None, *, valid_mask: np.ndarray | None = None
|
||||
) -> ImageDecomposition:
|
||||
"""Run HHD on one flow field and publish normal/shear/force results."""
|
||||
flow = self._flow if flow is None else flow
|
||||
if flow is None:
|
||||
raise Exception("Warning: Compute flow first")
|
||||
self._valid_mask = valid_mask
|
||||
|
||||
orig_h, orig_w = flow.shape[:2]
|
||||
try:
|
||||
k = max(1, int(self.settings.hhd_downsample))
|
||||
except (TypeError, ValueError):
|
||||
k = 1
|
||||
|
||||
mask_ds = None
|
||||
if valid_mask is not None:
|
||||
ds_size = self._hhd_grid_size()
|
||||
if (
|
||||
self._valid_mask_hhd is not None
|
||||
and ds_size is not None
|
||||
and self._valid_mask_hhd.shape[1] == ds_size[0]
|
||||
and self._valid_mask_hhd.shape[0] == ds_size[1]
|
||||
):
|
||||
mask_ds = self._valid_mask_hhd
|
||||
else:
|
||||
mask_ds = np.asarray(valid_mask, dtype=bool)
|
||||
|
||||
if k > 1:
|
||||
ds_w = max(1, orig_w // k)
|
||||
ds_h = max(1, orig_h // k)
|
||||
flow_ds = self._flow_ds_buf
|
||||
if (
|
||||
flow_ds is None
|
||||
or flow_ds.shape != (ds_h, ds_w, 2)
|
||||
or flow_ds.dtype != np.float32
|
||||
):
|
||||
flow_ds = np.empty((ds_h, ds_w, 2), dtype=np.float32)
|
||||
self._flow_ds_buf = flow_ds
|
||||
cv2.resize(flow, (ds_w, ds_h), dst=flow_ds, interpolation=cv2.INTER_AREA)
|
||||
if mask_ds is not None and (
|
||||
mask_ds.shape[1] != ds_w or mask_ds.shape[0] != ds_h
|
||||
):
|
||||
mask_ds = self._resize_mask(mask_ds, (ds_w, ds_h))
|
||||
else:
|
||||
flow_ds = flow
|
||||
|
||||
ds_shape = flow_ds.shape[:2]
|
||||
ref_shape = (orig_h, orig_w)
|
||||
self._valid_mask_hhd = mask_ds
|
||||
if (
|
||||
self._hhd is None
|
||||
or self._hhd_grid_shape != ds_shape
|
||||
or self._hhd_ref_shape != ref_shape
|
||||
):
|
||||
spacings = (18 * k, 18 * k)
|
||||
self._hhd = nHHD(
|
||||
cuda=self.settings.cuda,
|
||||
grid=ds_shape,
|
||||
spacings=spacings,
|
||||
poisson_backend=self.settings.poisson_backend,
|
||||
debug=self.settings.verbose,
|
||||
force_reference_grid=ref_shape,
|
||||
masked_poisson_solver=self.settings.masked_poisson_solver,
|
||||
masked_poisson_max_boundary=self.settings.masked_poisson_max_boundary,
|
||||
masked_poisson_boundary_stride=self.settings.masked_poisson_boundary_stride,
|
||||
masked_poisson_max_iters=self.settings.masked_poisson_max_iters,
|
||||
masked_poisson_tol=self.settings.masked_poisson_tol,
|
||||
masked_poisson_reg=self.settings.masked_poisson_reg,
|
||||
)
|
||||
self._hhd_grid_shape = ds_shape
|
||||
self._hhd_ref_shape = ref_shape
|
||||
|
||||
_, forces = self._hhd.get_force(flow_ds, valid_mask=mask_ds)
|
||||
fn, fx, fy, ftor = forces
|
||||
self._force_raw = (float(fn), float(fx), float(fy))
|
||||
self._torque = float(ftor)
|
||||
|
||||
vnormal_ds = self._hhd.get_normal()
|
||||
vshear_ds = self._hhd.get_shear()
|
||||
if k > 1:
|
||||
vn = self._vn_buf
|
||||
vs = self._vs_buf
|
||||
if vn is None or vn.shape != (orig_h, orig_w, 2) or vn.dtype != np.float32:
|
||||
vn = np.empty((orig_h, orig_w, 2), dtype=np.float32)
|
||||
vs = np.empty((orig_h, orig_w, 2), dtype=np.float32)
|
||||
self._vn_buf = vn
|
||||
self._vs_buf = vs
|
||||
cv2.resize(
|
||||
vnormal_ds, (orig_w, orig_h), dst=vn, interpolation=cv2.INTER_LINEAR
|
||||
)
|
||||
cv2.resize(
|
||||
vshear_ds, (orig_w, orig_h), dst=vs, interpolation=cv2.INTER_LINEAR
|
||||
)
|
||||
self._vnormal = vn
|
||||
self._vshear = vs
|
||||
else:
|
||||
self._vnormal = vnormal_ds.copy()
|
||||
self._vshear = vshear_ds.copy()
|
||||
|
||||
if valid_mask is not None:
|
||||
m3 = self._mask_f32
|
||||
if m3 is None or m3.shape != (orig_h, orig_w, 1) or m3.dtype != np.float32:
|
||||
m3 = np.empty((orig_h, orig_w, 1), dtype=np.float32)
|
||||
self._mask_f32 = m3
|
||||
np.copyto(m3[:, :, 0], valid_mask, casting="unsafe")
|
||||
np.multiply(self._vnormal, m3, out=self._vnormal)
|
||||
np.multiply(self._vshear, m3, out=self._vshear)
|
||||
|
||||
return ImageDecomposition(
|
||||
flow=self._flow,
|
||||
vnormal=self._vnormal,
|
||||
vshear=self._vshear,
|
||||
force_raw=self._force_raw,
|
||||
torque=self._torque,
|
||||
valid_mask_hhd=self._valid_mask_hhd,
|
||||
)
|
||||
|
||||
def current_field(self) -> dict:
|
||||
"""Same-frame HHD field payload (the solver returns a private copy)."""
|
||||
if self._hhd is None:
|
||||
raise RuntimeError("compute_hhd() before reading the field")
|
||||
return self._hhd.get_field(valid_mask=self._valid_mask_hhd)
|
||||
|
||||
# ------------------------------------------------------------ measurement
|
||||
def measure(
|
||||
self,
|
||||
image: np.ndarray,
|
||||
*,
|
||||
identity: FrameIdentity,
|
||||
clock: FrameClock,
|
||||
source_dt_s: float | None = None,
|
||||
valid_mask: np.ndarray | None = None,
|
||||
include: Sequence[str] = ("flow",),
|
||||
want_field: bool = False,
|
||||
reference_generation: int | None = None,
|
||||
) -> ImageMeasurement:
|
||||
"""Measure one prepared frame once and return read-only payload + T03 header.
|
||||
|
||||
``reference_generation`` is the version the owning stream claimed for this
|
||||
frame; when omitted the last adopted version is used. The version is fixed
|
||||
before flow is computed: a newer version adopted while this frame is in
|
||||
flight does not relabel it.
|
||||
"""
|
||||
unknown = set(include) - _INCLUDE_FIELDS
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"unknown include field(s) {sorted(unknown)}; "
|
||||
f"expected a subset of {sorted(_INCLUDE_FIELDS)}"
|
||||
)
|
||||
started = time.perf_counter()
|
||||
if reference_generation is not None:
|
||||
self.set_reference_generation(reference_generation)
|
||||
generated = int(self._reference_generation)
|
||||
self._compute_flow_state(image, valid_mask=valid_mask)
|
||||
decomposition = self.decompose(self._flow, valid_mask=valid_mask)
|
||||
stamp = FrameStamp(
|
||||
sensor_id=identity.sensor_id,
|
||||
session_id=identity.session_id,
|
||||
frame_id=identity.frame_id,
|
||||
source_frame_id=identity.source_frame_id,
|
||||
capture_time_ns=clock.capture_time_ns,
|
||||
clock_id=clock.clock_id,
|
||||
capture_time_basis=clock.capture_time_basis,
|
||||
source_pts=clock.source_pts,
|
||||
acquisition_id=identity.acquisition_id,
|
||||
sync_group_id=identity.sync_group_id,
|
||||
)
|
||||
frame = MeasurementFrame(
|
||||
stamp=stamp, source_dt_s=source_dt_s, reference_generation=generated
|
||||
)
|
||||
include_set = set(include)
|
||||
payload = {
|
||||
"flow": _readonly_copy(decomposition.flow)
|
||||
if "flow" in include_set
|
||||
else None,
|
||||
"vnormal": _readonly_copy(decomposition.vnormal)
|
||||
if "vnormal" in include_set
|
||||
else None,
|
||||
"vshear": _readonly_copy(decomposition.vshear)
|
||||
if "vshear" in include_set
|
||||
else None,
|
||||
"valid_mask": _readonly_copy(decomposition.valid_mask_hhd)
|
||||
if "valid_mask" in include_set
|
||||
else None,
|
||||
"field": _readonly_copy(self.current_field())
|
||||
if ("field" in include_set or want_field)
|
||||
else None,
|
||||
"image": _readonly_copy(image) if "image" in include_set else None,
|
||||
}
|
||||
return ImageMeasurement(
|
||||
frame=frame,
|
||||
force_raw=decomposition.force_raw,
|
||||
torque=decomposition.torque,
|
||||
flow=payload["flow"],
|
||||
vnormal=payload["vnormal"],
|
||||
vshear=payload["vshear"],
|
||||
valid_mask=payload["valid_mask"],
|
||||
field=payload["field"],
|
||||
image=payload["image"],
|
||||
elapsed_s=time.perf_counter() - started,
|
||||
diagnostics=(("reference_generation", generated),),
|
||||
)
|
||||
+115
-20
@@ -84,7 +84,7 @@ __all__ = [
|
||||
|
||||
#: Array fields a snapshot can carry. ``image`` is the working grey frame; the
|
||||
#: rest come from ``Sensor.read_info`` (already copied by the SDK).
|
||||
_ARRAY_FIELDS = ("image", "flow", "vnormal", "vshear", "depth", "centroid", "contour")
|
||||
_ARRAY_FIELDS = ("image", "flow", "vnormal", "vshear", "depth", "centroid", "contour", "field")
|
||||
|
||||
#: How many periods the stages wait on the pause event before re-checking for a
|
||||
#: stop. Small enough to resume promptly, large enough not to spin a core.
|
||||
@@ -156,6 +156,20 @@ class FrameSnapshot:
|
||||
source_metadata: Any = None
|
||||
force_units: tuple[str, str, str] | None = None
|
||||
calibration_status: dict[str, str] | None = None
|
||||
contact_found: bool | None = None
|
||||
#: Reference version this frame was measured with: the count of image-side
|
||||
#: tracker resets the flow stage had applied when it computed this frame's
|
||||
#: flow. A consumer that delegates a frame-boundary reset to
|
||||
#: :meth:`StreamPipeline.request_reset` compares it with the returned target to
|
||||
#: tell frames measured before that reset from frames measured after it (the
|
||||
#: reset is queued, not synchronous). Additive field: producers that do not
|
||||
#: use it leave it at 0.
|
||||
tracker_reset_seq: int = 0
|
||||
#: Same-frame HHD field payload, copied on the HHD thread during ``_build``
|
||||
#: when requested through ``StreamPipeline(include=("field",))``. ``None`` on
|
||||
#: the default path; the copy belongs to this snapshot and later frames cannot
|
||||
#: rewrite it. Additive field for T08b field-calibration handoff.
|
||||
field: dict | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -227,9 +241,19 @@ class StreamPipeline:
|
||||
self._threads: list[threading.Thread] = []
|
||||
self._error: BaseException | None = None
|
||||
self._running = False
|
||||
#: Set by :meth:`reset` and consumed by the flow stage, so that resetting sensor state
|
||||
#: never races the stage that owns that state.
|
||||
self._reset_pending = threading.Event()
|
||||
#: Request allocation and flow-stage claiming share a lock. A request
|
||||
#: already claimed by flow cannot acknowledge another request made later.
|
||||
self._reset_lock = threading.Lock()
|
||||
self._requested_reset_version = 0
|
||||
self._pending_reset_version: int | None = None
|
||||
#: How many image-side resets the flow stage has actually applied. A frame
|
||||
#: carries the value in effect when its flow was computed (through the flow
|
||||
#: queue); consumers read :attr:`FrameSnapshot.tracker_reset_seq`, not this.
|
||||
self._tracker_reset_seq = 0
|
||||
#: The reference version whose force-side reset the HHD stage has applied.
|
||||
#: The flow stage bumps the version; the HHD stage owns the force state and
|
||||
#: applies its half on the first frame that carries a newer version.
|
||||
self._force_reset_applied_version = 0
|
||||
#: Pause state. ``_pause_level`` is what a pause would hold when armed;
|
||||
#: ``_currently_capturing`` records whether the producer is still
|
||||
#: reading, which decides whether a resume has to rebase the tracker.
|
||||
@@ -315,18 +339,50 @@ class StreamPipeline:
|
||||
with self._lock:
|
||||
return self._latest
|
||||
|
||||
@property
|
||||
def tracker_reset_seq(self) -> int:
|
||||
"""Tracker resets the flow stage has applied so far (queued, not immediate).
|
||||
|
||||
This is the live diagnostic count, not a frame's identity: a reset queued
|
||||
now is only applied before a later frame is measured. Read
|
||||
:attr:`FrameSnapshot.tracker_reset_seq` for the version a frame carries.
|
||||
"""
|
||||
return int(self._tracker_reset_seq)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the sensor's tracker and force-filter state, safely.
|
||||
"""Request a reference reset, applied at frame boundaries by its owning stage.
|
||||
|
||||
Call this from the consumer thread (after a key press, on a drift alarm). The reset is
|
||||
queued and applied by the flow stage before its next frame, never inline: ``Sensor.reset``
|
||||
rebases ``initial_img`` on the current ``Sensor.img``, and the flow stage is reading that
|
||||
buffer concurrently, so resetting from the caller's thread would capture a half-written
|
||||
reference frame.
|
||||
queued, never inline: ``Sensor.reset_image_state`` rebases ``initial_img`` on the current
|
||||
``Sensor.img``, and the flow stage is reading that buffer concurrently, so resetting from
|
||||
the caller's thread would capture a half-written reference frame.
|
||||
|
||||
Idempotent: a second call before the next frame does not queue twice.
|
||||
The two halves are applied by the threads that own their state. The flow stage resets the
|
||||
image/tracker state and bumps the reference version before its next frame; the HHD stage
|
||||
resets the force/contact state on the first frame that carries the new version, before
|
||||
decomposing it. A sensor that implements only ``reset()`` keeps the historical whole reset
|
||||
on the flow stage, and then no force-side reset is applied by the HHD stage.
|
||||
|
||||
Requests coalesce until the flow stage claims them. Use :meth:`request_reset`
|
||||
when the caller needs a target version to confirm completion.
|
||||
"""
|
||||
self._reset_pending.set()
|
||||
self.request_reset()
|
||||
|
||||
def request_reset(self) -> int:
|
||||
"""Queue a reset and return the reference version that will confirm it.
|
||||
|
||||
Requests coalesce only until the flow stage claims them. A request made
|
||||
while an earlier reset is being applied gets a new target, even though
|
||||
:attr:`tracker_reset_seq` still reports the previous applied version.
|
||||
Wait for a snapshot with ``tracker_reset_seq >= target``; neither the
|
||||
current snapshot nor a separately read live counter identifies a request.
|
||||
Sensor state is always reset by its owning stages, outside this lock.
|
||||
"""
|
||||
with self._reset_lock:
|
||||
if self._pending_reset_version is None:
|
||||
self._requested_reset_version += 1
|
||||
self._pending_reset_version = self._requested_reset_version
|
||||
return self._pending_reset_version
|
||||
|
||||
@property
|
||||
def paused(self) -> bool:
|
||||
@@ -378,7 +434,7 @@ class StreamPipeline:
|
||||
# Frames read during the pause were never computed, so the sensor's
|
||||
# reference frame is stale by however long the hold lasted. Ask for a
|
||||
# rebase; the flow stage applies it before its next frame.
|
||||
self._reset_pending.set()
|
||||
self.reset()
|
||||
# Arm capture *before* lifting the hold. The producer stops as soon as
|
||||
# the pause flag is set and only returns once it is clear, so a level-1
|
||||
# resume must have `_currently_capturing` true by then or the producer
|
||||
@@ -564,15 +620,36 @@ class StreamPipeline:
|
||||
self._drain(self._q1)
|
||||
self._drain(self._q2)
|
||||
continue
|
||||
# Applied here, on the thread that owns the sensor's flow state, before this frame is
|
||||
# computed: see reset() for why this must not run on the caller's thread.
|
||||
if self._reset_pending.is_set():
|
||||
self._reset_pending.clear()
|
||||
# Applied here, on the thread that owns the sensor's image/flow state, before this
|
||||
# frame is computed: see reset() for why this must not run on the caller's thread.
|
||||
with self._reset_lock:
|
||||
reset_version = self._pending_reset_version
|
||||
self._pending_reset_version = None
|
||||
if reset_version is not None:
|
||||
reset_image_state = getattr(self.sensor, "reset_image_state", None)
|
||||
if reset_image_state is not None:
|
||||
reset_image_state()
|
||||
else:
|
||||
# A sensor without the split seam keeps the historical whole reset on this
|
||||
# thread; its force state is then not reset by the HHD stage.
|
||||
self.sensor.reset()
|
||||
self._tracker_reset_seq = reset_version
|
||||
# The version travels with the frame: the HHD stage publishes the snapshot later and
|
||||
# must not read the live counter at that point.
|
||||
reference_version = self._tracker_reset_seq
|
||||
frame, t_cap, produce_s, source_metadata = item
|
||||
t0 = time.perf_counter()
|
||||
# The frame is passed explicitly: the SDK's self.img slot is shared
|
||||
# with the producer, so it must not be the transport.
|
||||
# One reference-version source: the version claimed by this stage is
|
||||
# handed to an image block that supports adopting it, so the block
|
||||
# stamps the same number it will publish on the snapshot. Sensors
|
||||
# without the optional capability keep the historical call.
|
||||
adopt_reference = getattr(
|
||||
self.sensor, "set_stream_reference_generation", None
|
||||
)
|
||||
if adopt_reference is not None:
|
||||
adopt_reference(reference_version)
|
||||
self.sensor.compute_flow(frame)
|
||||
flow_s = time.perf_counter() - t0
|
||||
flow = getattr(self.sensor, "flow", None)
|
||||
@@ -581,7 +658,8 @@ class StreamPipeline:
|
||||
flow_copy = flow.copy() if isinstance(flow, np.ndarray) else None
|
||||
self._put(
|
||||
self._q2,
|
||||
(frame, flow_copy, t_cap, produce_s, flow_s, source_metadata),
|
||||
(frame, flow_copy, t_cap, produce_s, flow_s, source_metadata,
|
||||
reference_version),
|
||||
)
|
||||
self._put(self._q2, None)
|
||||
|
||||
@@ -597,7 +675,15 @@ class StreamPipeline:
|
||||
if not self._wait_out_pause():
|
||||
break
|
||||
continue
|
||||
frame, flow, t_cap, produce_s, flow_s, source_metadata = item
|
||||
frame, flow, t_cap, produce_s, flow_s, source_metadata, reference_version = item
|
||||
# The force/contact state belongs to this stage, so its half of a
|
||||
# queued reset is applied here, on the first frame of the new version,
|
||||
# before the decomposition that would consume the stale model state.
|
||||
if reference_version > self._force_reset_applied_version:
|
||||
self._force_reset_applied_version = reference_version
|
||||
reset_force_state = getattr(self.sensor, "reset_force_state", None)
|
||||
if reset_force_state is not None:
|
||||
reset_force_state()
|
||||
t0 = time.perf_counter()
|
||||
# Explicit flow: compute_hhd reads self.flow, which the flow stage
|
||||
# overwrites for the next frame, so it cannot be the transport.
|
||||
@@ -606,7 +692,8 @@ class StreamPipeline:
|
||||
self.sensor.compute_contact()
|
||||
hhd_s = time.perf_counter() - t0
|
||||
snap = self._build(
|
||||
frame, flow, produce_s, flow_s, hhd_s, t_cap, source_metadata
|
||||
frame, flow, produce_s, flow_s, hhd_s, t_cap, source_metadata,
|
||||
reference_version,
|
||||
)
|
||||
self._publish(snap)
|
||||
if self._on_frame is not None:
|
||||
@@ -628,7 +715,8 @@ class StreamPipeline:
|
||||
|
||||
def _build(self, frame: np.ndarray, flow: np.ndarray | None, produce_s: float,
|
||||
flow_s: float, hhd_s: float, t_cap: float,
|
||||
source_metadata: Any = None) -> FrameSnapshot:
|
||||
source_metadata: Any = None,
|
||||
reference_version: int = 0) -> FrameSnapshot:
|
||||
sensor = self.sensor
|
||||
info = sensor.info
|
||||
include = self._include
|
||||
@@ -658,6 +746,7 @@ class StreamPipeline:
|
||||
torque=float(getattr(sensor, "ftor", 0.0)),
|
||||
force_units=tuple(getattr(sensor, "force_units", (getattr(sensor, "force_unit", "raw"),) * 3)),
|
||||
calibration_status=dict(getattr(sensor, "calibration_status", {})),
|
||||
contact_found=bool(getattr(sensor, "centroid_found", False)),
|
||||
# Both already private per-frame copies owned by this snapshot.
|
||||
image=frame if "image" in include else None,
|
||||
flow=flow if "flow" in include else None,
|
||||
@@ -672,6 +761,12 @@ class StreamPipeline:
|
||||
hhd_ms=hhd_s * 1000.0,
|
||||
image_size_raw=image_size_raw,
|
||||
source_metadata=source_metadata,
|
||||
# This frame's own measurement version, carried through the flow queue:
|
||||
# never the live counter, which a later frame may already have bumped.
|
||||
tracker_reset_seq=int(reference_version),
|
||||
# Same-frame field, copied here on the HHD thread that computed it;
|
||||
# current_field() already returns a private payload copy.
|
||||
field=dict(sensor.current_field()) if "field" in include else None,
|
||||
)
|
||||
|
||||
def _fps(self) -> float:
|
||||
|
||||
+493
-51
@@ -8,8 +8,10 @@ and Helmholtz-Hodge decomposition.
|
||||
|
||||
# import os # only used by commented-out stitch_cal
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -17,28 +19,57 @@ import numpy as np
|
||||
from . import calibration as cal_mod
|
||||
from . import configs
|
||||
from .backend_availability import check_cupy_available, check_opencl_available
|
||||
from .force_readout import (
|
||||
FILTER_PRESETS,
|
||||
ForceReadout,
|
||||
available_filter_presets,
|
||||
)
|
||||
# Explicit re-exports: ``calibration.py`` resolves named presets through this
|
||||
# module and the preset tests import the table from here.
|
||||
from .force_readout import ForceReadout
|
||||
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 .field_calibration import FIELD_PROFILE
|
||||
from .get_depth import Contact3D
|
||||
from .hhd import nHHD
|
||||
from .image_pipeline import ImagePipeline, ImageSettings
|
||||
|
||||
# from threading import Thread, Event
|
||||
# from queue import Queue, Empty
|
||||
from .Stitcher import Stitcher
|
||||
from .tracker import Tracker
|
||||
from .capture import (
|
||||
CaptureConfig,
|
||||
CaptureSource,
|
||||
CapturedFrame,
|
||||
ReconnectPolicy,
|
||||
is_live_camera,
|
||||
normalize_source,
|
||||
open_capture,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .sensors.context import SensorContext
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrameMeasurement:
|
||||
"""Same-frame unfiltered measurement features, before model and readout.
|
||||
|
||||
Produced by :meth:`Sensor.read_measurement_frame` and
|
||||
:meth:`Sensor.measure_current_frame`, consumed by ``orisys.force_session``.
|
||||
``raw`` is the HHD force feature triple in raw counts (NaN when unavailable);
|
||||
``field`` is the native HHD field payload (the SDK ``FieldFrame`` profile)
|
||||
only when the caller asked for it; ``output_valid_mask`` is the physical
|
||||
valid mask in output coordinates. ``centroid`` is the same-frame contact
|
||||
location in output pixels — not a calibration coordinate: mapping through a
|
||||
model's coordinate artifact belongs to the session that owns the model.
|
||||
"""
|
||||
|
||||
raw: tuple[float, float, float]
|
||||
raw_valid: tuple[bool, bool, bool]
|
||||
field: dict | None = None
|
||||
output_valid_mask: np.ndarray | None = None
|
||||
contact_found: bool | None = None
|
||||
single_contact: bool | None = None
|
||||
centroid: tuple[float, float] | None = None
|
||||
diagnostics: tuple[tuple[str, str | float], ...] = ()
|
||||
|
||||
|
||||
class Sensor:
|
||||
"""
|
||||
@@ -48,6 +79,10 @@ class Sensor:
|
||||
Supports both synchronous and asynchronous (pipelined) processing modes.
|
||||
"""
|
||||
|
||||
#: Explicit opt-in for the session-owned force readout (``orisys.force_session``).
|
||||
#: While False the historical automatic model/readout path runs unchanged.
|
||||
_measurement_session = False
|
||||
|
||||
# =========================================================================
|
||||
# Info Enum (for read_info API)
|
||||
# =========================================================================
|
||||
@@ -199,6 +234,10 @@ class Sensor:
|
||||
self.bind_calibration_context(build_context(
|
||||
self._config, self.filters_state(), preprocessing={"mirror": self.mirror},
|
||||
))
|
||||
# One read-only binding of SN/config/assets/cal_path per instance. The
|
||||
# later read-only SensorContext contract (T04) is additive; a binding
|
||||
# problem is reported on the context, never by changing construction.
|
||||
self._bind_sensor_context()
|
||||
|
||||
if self.verbose:
|
||||
if self.calibration is not None and self.calibration.source_path is not None:
|
||||
@@ -236,6 +275,10 @@ class Sensor:
|
||||
self.last_log_time = time.perf_counter()
|
||||
self._fps = 0
|
||||
self._last_capture_time = 0.0
|
||||
#: Metadata of the last frame the built-in capture path read (additive,
|
||||
#: for streams that need the source identity and host clocks). Stays
|
||||
#: None for externally supplied frames and for bare test doubles.
|
||||
self._last_captured_frame: CapturedFrame | None = None
|
||||
self._last_processing_time = 0.0
|
||||
self._last_stage_times = {
|
||||
# Frame-post-processing roll-ups (seconds).
|
||||
@@ -258,25 +301,38 @@ class Sensor:
|
||||
else None
|
||||
)
|
||||
self.frame_processor = create_frame_processor(self.sensor_type, stitcher=self.stitcher)
|
||||
self.__tracker = Tracker(adaptive=True, cuda=cuda, verbose=self.verbose, config_tracker=self.__config_tracker)
|
||||
# The image block (tracker + HHD + flow EMA) lives in ImagePipeline. The
|
||||
# attributes below stay as compatibility mirrors for code that reads
|
||||
# them; ``__tracker``/``__hhd`` are only used by the direct (bare
|
||||
# Sensor/test-double) fallback path below.
|
||||
self.image_settings = ImageSettings.from_config(
|
||||
self._config,
|
||||
cuda=bool(cuda),
|
||||
backend=backend,
|
||||
verbose=self.verbose,
|
||||
flow_ema_alpha=self._force_readout.flow_ema_alpha,
|
||||
)
|
||||
self.image_pipeline = ImagePipeline(
|
||||
self.image_settings,
|
||||
context=getattr(self, "context", None),
|
||||
verbose=self.verbose,
|
||||
flow_ema=self._force_readout,
|
||||
)
|
||||
self.__tracker = None
|
||||
self.__contact3d = Contact3D(config_contact=self.__config_contact, verbose=self.verbose)
|
||||
self.__hhd = None
|
||||
# Integer >= 1: run HHD on flow downsampled by this factor for speed (1 = full res).
|
||||
_raw_ds = self.__config_tracker.get("hhd_downsample", 1)
|
||||
try:
|
||||
self._hhd_downsample = max(1, int(_raw_ds)) if _raw_ds is not None else 1
|
||||
except (TypeError, ValueError):
|
||||
self._hhd_downsample = 1
|
||||
self._hhd_downsample = self.image_settings.hhd_downsample
|
||||
self._hhd_grid_shape = None
|
||||
self._hhd_ref_shape = None
|
||||
cfg_backend = self.__config_tracker.get("backend", None)
|
||||
self.poisson_backend = cfg_backend if cfg_backend is not None else backend
|
||||
self.masked_poisson_solver = self.__config_tracker.get("masked_poisson_solver", "sparse")
|
||||
self.masked_poisson_max_boundary = self.__config_tracker.get("masked_poisson_max_boundary", 96)
|
||||
self.masked_poisson_boundary_stride = self.__config_tracker.get("masked_poisson_boundary_stride", 1)
|
||||
self.masked_poisson_max_iters = self.__config_tracker.get("masked_poisson_max_iters", 3)
|
||||
self.masked_poisson_tol = self.__config_tracker.get("masked_poisson_tol", 1e-4)
|
||||
self.masked_poisson_reg = self.__config_tracker.get("masked_poisson_reg", 1e-8)
|
||||
self.poisson_backend = self.image_settings.poisson_backend
|
||||
self.masked_poisson_solver = self.image_settings.masked_poisson_solver
|
||||
self.masked_poisson_max_boundary = self.image_settings.masked_poisson_max_boundary
|
||||
self.masked_poisson_boundary_stride = (
|
||||
self.image_settings.masked_poisson_boundary_stride
|
||||
)
|
||||
self.masked_poisson_max_iters = self.image_settings.masked_poisson_max_iters
|
||||
self.masked_poisson_tol = self.image_settings.masked_poisson_tol
|
||||
self.masked_poisson_reg = self.image_settings.masked_poisson_reg
|
||||
# Legacy PyAV capture state (unused after orisys.capture refactor).
|
||||
# self._use_pyav = False
|
||||
# self._av_container = None
|
||||
@@ -645,6 +701,13 @@ class Sensor:
|
||||
if image is None:
|
||||
raise Exception("Warning: Get image first")
|
||||
|
||||
pipeline = getattr(self, "image_pipeline", None)
|
||||
if pipeline is not None:
|
||||
self.flow = pipeline.compute_flow(image, valid_mask=self.valid_mask)
|
||||
self._compute_flow_means(self.flow)
|
||||
return
|
||||
|
||||
# Bare Sensor/test-double fallback: no ImagePipeline was constructed.
|
||||
flow = self.__tracker.track(image, valid_mask=self.valid_mask)
|
||||
# flow = self.__tracker.track_raft(self.img)
|
||||
if hasattr(flow, 'download'):
|
||||
@@ -663,6 +726,45 @@ class Sensor:
|
||||
"""``config``, ``measured`` or ``config (file)``."""
|
||||
return self._force_readout.sample_rate_source
|
||||
|
||||
def _publish_force_sample(self) -> None:
|
||||
"""Publish one HHD frame's raw feature to the force side (no image work)."""
|
||||
if self._measurement_session:
|
||||
# Session path: publish the same-frame unfiltered feature only. Model
|
||||
# inference and readout filtering belong to orisys.force_session, so
|
||||
# the old Newton accessors must not report a value the session did not
|
||||
# produce (they would be stale or half-processed).
|
||||
self.fnormal = self.fshearx = self.fsheary = float("nan")
|
||||
self._force_filtered_raw = None
|
||||
self._force_unfiltered = None
|
||||
self._force_raw_unfiltered = self._force_raw
|
||||
else:
|
||||
self._force_readout.update_sample_rate()
|
||||
if self._uses_calibration_model():
|
||||
self._model_frame_id += 1
|
||||
self.compute_contact_valid = False
|
||||
self.centroid_found = False
|
||||
self._force_readout.apply(self._force_raw)
|
||||
self._force_filtered_raw = self._force_readout.filtered_raw
|
||||
self._force_raw_unfiltered = self._force_raw
|
||||
else:
|
||||
self._apply_force_filter()
|
||||
|
||||
def _infer_hhd_model(self, field_source) -> None:
|
||||
"""Run the model/contact side on this frame's already computed field."""
|
||||
if self._uses_calibration_model() and not self._measurement_session:
|
||||
# Spatial inference must use this HHD frame's contact, even when the
|
||||
# caller did not request contact diagnostics from StreamPipeline.
|
||||
# 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=field_source(),
|
||||
)
|
||||
elif self.calibration.input_profile == "fn_delta_autocal_uv/1":
|
||||
self.compute_contact()
|
||||
else:
|
||||
self.apply_calibration_frame(frame_id=self._model_frame_id)
|
||||
|
||||
def compute_hhd(self, flow=None):
|
||||
"""Decompose the optical flow and update the force readout.
|
||||
|
||||
@@ -670,12 +772,32 @@ class Sensor:
|
||||
``self.flow``. StreamPipeline passes its own per-frame copy so the flow
|
||||
stage cannot overwrite ``self.flow`` mid-decomposition. Existing
|
||||
callers are unaffected.
|
||||
|
||||
The image half (HHD solver, grid buffers, mask handling, upsampling) is
|
||||
delegated to :attr:`image_pipeline` when a real Sensor was constructed;
|
||||
the force half (readout/model/contact) stays here. Bare Sensor and
|
||||
test-double instances keep the historical direct path because they carry
|
||||
their own solver through ``_Sensor__hhd``.
|
||||
"""
|
||||
|
||||
flow = self.flow if flow is None else flow
|
||||
if flow is None:
|
||||
raise Exception("Warning: Compute flow first")
|
||||
|
||||
pipeline = getattr(self, "image_pipeline", None)
|
||||
if pipeline is not None:
|
||||
decomposition = pipeline.decompose(flow, valid_mask=self.valid_mask)
|
||||
self._force_raw = decomposition.force_raw
|
||||
self.ftor = decomposition.torque
|
||||
self.vnormal = decomposition.vnormal
|
||||
self.vshear = decomposition.vshear
|
||||
self.valid_mask_hhd = decomposition.valid_mask_hhd
|
||||
self.compute_deformation_valid = True
|
||||
self._publish_force_sample()
|
||||
self._infer_hhd_model(self.current_field)
|
||||
return True
|
||||
|
||||
# ---- direct fallback: the historical implementation, unchanged
|
||||
orig_h, orig_w = flow.shape[:2]
|
||||
try:
|
||||
k = max(1, int(self._hhd_downsample))
|
||||
@@ -742,16 +864,7 @@ class Sensor:
|
||||
fn, fx, fy, ftor = forces
|
||||
self._force_raw = (float(fn), float(fx), float(fy))
|
||||
self.ftor = float(ftor)
|
||||
self._force_readout.update_sample_rate()
|
||||
if self._uses_calibration_model():
|
||||
self._model_frame_id += 1
|
||||
self.compute_contact_valid = False
|
||||
self.centroid_found = False
|
||||
self._force_readout.apply(self._force_raw)
|
||||
self._force_filtered_raw = self._force_readout.filtered_raw
|
||||
self._force_raw_unfiltered = self._force_raw
|
||||
else:
|
||||
self._apply_force_filter()
|
||||
self._publish_force_sample()
|
||||
|
||||
vnormal_ds = self.__hhd.get_normal()
|
||||
vshear_ds = self.__hhd.get_shear()
|
||||
@@ -786,15 +899,7 @@ class Sensor:
|
||||
np.multiply(self.vshear, m3, out=self.vshear)
|
||||
|
||||
self.compute_deformation_valid = True
|
||||
|
||||
if self._uses_calibration_model():
|
||||
# Spatial inference must use this HHD frame's contact, even when the
|
||||
# caller did not request contact diagnostics from StreamPipeline.
|
||||
if self.calibration.input_profile == "fn_delta_autocal_uv/1":
|
||||
self.compute_contact()
|
||||
else:
|
||||
self.apply_calibration_frame(frame_id=self._model_frame_id)
|
||||
|
||||
self._infer_hhd_model(lambda: self.__hhd.get_field(valid_mask=mask_ds))
|
||||
return True
|
||||
|
||||
def compute_contact(self, vnormal=None):
|
||||
@@ -809,7 +914,10 @@ class Sensor:
|
||||
"""
|
||||
if self._uses_calibration_model() and not self.compute_deformation_valid:
|
||||
raise cal_mod.CalibrationError("compute current deformation before schema 3 contact")
|
||||
if (self._uses_calibration_model() and self.compute_contact_valid
|
||||
if (self._uses_calibration_model()
|
||||
and not self._measurement_session
|
||||
and self.calibration.input_profile == "fn_delta_autocal_uv/1"
|
||||
and self.compute_contact_valid
|
||||
and self._calibration_result_frame == self._model_frame_id):
|
||||
return self._model_contact_result
|
||||
multiple_foe = False
|
||||
@@ -845,7 +953,9 @@ class Sensor:
|
||||
print("Warning: contact centroid lies outside the valid UV mask.")
|
||||
|
||||
self.compute_contact_valid = True
|
||||
if self._uses_calibration_model():
|
||||
if (self._uses_calibration_model()
|
||||
and not self._measurement_session
|
||||
and self.calibration.input_profile == "fn_delta_autocal_uv/1"):
|
||||
self._model_contact_result = result
|
||||
uv = self._calibration_contact_uv()
|
||||
self.apply_calibration_frame(
|
||||
@@ -855,6 +965,208 @@ class Sensor:
|
||||
return result
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Session measurement seam (explicit opt-in for orisys.force_session)
|
||||
# =========================================================================
|
||||
def enable_measurement_session(self) -> None:
|
||||
"""Hand force readout to a ``ForceSession`` (Studio's explicit new entry).
|
||||
|
||||
While enabled, ``compute_hhd`` / ``compute_contact`` publish this frame's
|
||||
raw feature, contact statistics and field payload and skip the automatic
|
||||
model inference and readout filtering. The Newton accessors report NaN
|
||||
rather than a stale value: the session owns the one model call and the one
|
||||
output. Nothing changes for callers that never call this method.
|
||||
"""
|
||||
self._measurement_session = True
|
||||
self.fnormal = self.fshearx = self.fsheary = float("nan")
|
||||
self.calibration_status = {}
|
||||
self._force_filtered_raw = None
|
||||
self._force_unfiltered = None
|
||||
self._force_raw_unfiltered = None
|
||||
|
||||
def disable_measurement_session(self) -> None:
|
||||
"""Return to the historical automatic model/readout path."""
|
||||
self._measurement_session = False
|
||||
|
||||
@property
|
||||
def measurement_session(self) -> bool:
|
||||
return bool(self._measurement_session)
|
||||
|
||||
def set_stream_reference_generation(self, generation: int) -> None:
|
||||
"""Adopt the driving stream's reference version for the next frame.
|
||||
|
||||
Stage-0 ``StreamPipeline`` claims reference versions; the image block must
|
||||
stamp that same version instead of counting its own. The pipeline calls
|
||||
this on its flow thread immediately before ``compute_flow``, so each
|
||||
frame keeps the version it was measured with. Calling it directly outside
|
||||
a stream is allowed (the value is what ``ImagePipeline.measure`` will
|
||||
stamp); it never changes tracker/EMA state.
|
||||
"""
|
||||
pipeline = getattr(self, "image_pipeline", None)
|
||||
if pipeline is not None:
|
||||
pipeline.set_reference_generation(int(generation))
|
||||
|
||||
def reset_tracker(self) -> None:
|
||||
"""Reset the optical-flow tracker at a frame boundary (session zeroing).
|
||||
|
||||
The narrow half of :meth:`reset`: tracker and readout memory only, never
|
||||
the calibration baseline. ``ForceSession`` calls this on its processing
|
||||
thread between two frames, before the first baseline frame is measured.
|
||||
The tracker/EMA history belongs to the ImagePipeline when one is bound;
|
||||
bare Sensor/test-double instances keep the historical attribute path.
|
||||
"""
|
||||
pipeline = getattr(self, "image_pipeline", None)
|
||||
if pipeline is not None:
|
||||
pipeline.reset_tracker()
|
||||
else:
|
||||
self.__tracker.reset()
|
||||
self._force_readout.reset()
|
||||
self._force_unfiltered = None
|
||||
self._force_raw_unfiltered = None
|
||||
self._force_filtered_raw = None
|
||||
self.compute_deformation_valid = False
|
||||
self.compute_contact_valid = False
|
||||
self.centroid_found = False
|
||||
|
||||
def read_measurement_frame(
|
||||
self,
|
||||
source_frame,
|
||||
*,
|
||||
frame_id: int,
|
||||
timestamp_s: float | None = None,
|
||||
want_field: bool = False,
|
||||
want_contact: bool = False,
|
||||
) -> FrameMeasurement:
|
||||
"""Prepare one source frame and measure it, once, without calibration.
|
||||
|
||||
This is the measurement-block entry ``ForceSession`` calls for a bare
|
||||
Sensor: image preparation/stitch, flow, HHD and the optional contact and
|
||||
field features run exactly once and nothing is inferred or filtered
|
||||
afterwards.
|
||||
"""
|
||||
del timestamp_s # signal time is carried by the caller's FrameStamp
|
||||
if not self._measurement_session:
|
||||
raise RuntimeError(
|
||||
"enable_measurement_session() before reading session measurements"
|
||||
)
|
||||
self.get_img(frame=source_frame)
|
||||
return self.measure_current_frame(
|
||||
frame_id=frame_id,
|
||||
want_field=want_field,
|
||||
want_contact=want_contact,
|
||||
)
|
||||
|
||||
def measure_current_frame(
|
||||
self,
|
||||
*,
|
||||
frame_id: int,
|
||||
want_field: bool = False,
|
||||
want_contact: bool = False,
|
||||
) -> FrameMeasurement:
|
||||
"""Run flow/HHD (and optional contact) on the already prepared frame once."""
|
||||
if not self._measurement_session:
|
||||
raise RuntimeError(
|
||||
"enable_measurement_session() before reading session measurements"
|
||||
)
|
||||
image = self.img
|
||||
if image is None:
|
||||
raise RuntimeError("no prepared frame to measure; provide a frame first")
|
||||
self.compute_flow(image)
|
||||
self.compute_hhd(self.flow)
|
||||
raw = tuple(float(value) for value in self._force_raw)
|
||||
raw_valid = tuple(bool(np.isfinite(value)) for value in raw)
|
||||
field = None
|
||||
if want_field:
|
||||
field = self.current_field()
|
||||
output_mask = (
|
||||
None
|
||||
if self.valid_mask is None
|
||||
else np.array(self.valid_mask, dtype=bool, copy=True)
|
||||
)
|
||||
contact_found: bool | None = None
|
||||
single_contact: bool | None = None
|
||||
centroid: tuple[float, float] | None = None
|
||||
if want_contact:
|
||||
self.compute_contact()
|
||||
contact_found = bool(self.centroid_found)
|
||||
if contact_found and self.centroid is not None:
|
||||
centroid = (float(self.centroid[0]), float(self.centroid[1]))
|
||||
single_contact = True if contact_found else None
|
||||
return FrameMeasurement(
|
||||
raw=raw,
|
||||
raw_valid=raw_valid,
|
||||
field=field,
|
||||
output_valid_mask=output_mask,
|
||||
contact_found=contact_found,
|
||||
single_contact=single_contact,
|
||||
centroid=centroid,
|
||||
diagnostics=(("frame_id", int(frame_id)),),
|
||||
)
|
||||
|
||||
def current_measurement(
|
||||
self, *, want_field: bool = False, want_contact: bool = False
|
||||
) -> FrameMeasurement:
|
||||
"""Read the current frame's measurement features without recomputing anything.
|
||||
|
||||
Additive session seam for a pipeline consumer: ``compute_hhd`` (and, when
|
||||
requested, ``compute_contact``) have already produced this frame's state,
|
||||
so this only snapshots it — no second ``get_img``/``compute_flow``/
|
||||
``compute_hhd``. Arrays are copied and made read-only because the caller
|
||||
hands the row to another thread (the force session).
|
||||
"""
|
||||
if not self._measurement_session:
|
||||
raise RuntimeError(
|
||||
"enable_measurement_session() before reading session measurements"
|
||||
)
|
||||
raw = tuple(float(value) for value in self._force_raw)
|
||||
raw_valid = tuple(bool(np.isfinite(value)) for value in raw)
|
||||
field = None
|
||||
if want_field:
|
||||
field = self.current_field()
|
||||
output_mask = (
|
||||
None
|
||||
if self.valid_mask is None
|
||||
else np.array(self.valid_mask, dtype=bool, copy=True)
|
||||
)
|
||||
if output_mask is not None:
|
||||
output_mask.setflags(write=False)
|
||||
contact_found: bool | None = None
|
||||
single_contact: bool | None = None
|
||||
centroid: tuple[float, float] | None = None
|
||||
if want_contact:
|
||||
contact_found = bool(self.centroid_found)
|
||||
if contact_found and self.centroid is not None:
|
||||
centroid = (float(self.centroid[0]), float(self.centroid[1]))
|
||||
single_contact = True if contact_found else None
|
||||
return FrameMeasurement(
|
||||
raw=raw,
|
||||
raw_valid=raw_valid,
|
||||
field=field,
|
||||
output_valid_mask=output_mask,
|
||||
contact_found=contact_found,
|
||||
single_contact=single_contact,
|
||||
centroid=centroid,
|
||||
)
|
||||
|
||||
@property
|
||||
def calibration_context(self):
|
||||
"""The pipeline context this sensor's model was validated against, if any.
|
||||
|
||||
Additive read-only accessor: ``ForceSession`` can adopt an already bound
|
||||
context when its caller does not pass one explicitly.
|
||||
"""
|
||||
return self._calibration_context
|
||||
|
||||
def current_field(self) -> dict:
|
||||
"""Same-frame HHD field payload of the last decomposition (a private copy)."""
|
||||
pipeline = getattr(self, "image_pipeline", None)
|
||||
if pipeline is not None:
|
||||
return pipeline.current_field()
|
||||
if self.__hhd is None:
|
||||
raise RuntimeError("compute_hhd() before reading the field")
|
||||
mask = self.valid_mask_hhd
|
||||
return self.__hhd.get_field(valid_mask=mask)
|
||||
|
||||
def read_info(self, *args) -> tuple:
|
||||
"""
|
||||
Get sensor data by info type.
|
||||
@@ -908,18 +1220,56 @@ class Sensor:
|
||||
|
||||
return tuple(mapping.get(arg, lambda: None)() for arg in args if isinstance(arg, self.info))
|
||||
|
||||
def reset(self):
|
||||
"""Reset optical flow tracker and force-filter state."""
|
||||
def reset_image_state(self) -> None:
|
||||
"""Reset the image/flow half of the pipeline state (flow thread only).
|
||||
|
||||
Rebases the optical-flow tracker on the current ``img`` and drops the flow
|
||||
EMA. :class:`StreamPipeline` calls this on its flow thread before computing
|
||||
the first frame of a new reference version, because that thread owns the
|
||||
image buffers. The force half belongs to the HHD thread and is reset
|
||||
separately by :meth:`reset_force_state`.
|
||||
"""
|
||||
pipeline = getattr(self, "image_pipeline", None)
|
||||
if pipeline is not None:
|
||||
pipeline.reset_image_state()
|
||||
else:
|
||||
self.__tracker.reset()
|
||||
self._force_readout.reset()
|
||||
self._force_readout.reset_flow_ema()
|
||||
if self.img is not None:
|
||||
self.initial_img = self.img.copy()
|
||||
|
||||
def reset_force_state(self) -> None:
|
||||
"""Reset the force/contact half of the pipeline state (HHD thread only).
|
||||
|
||||
Drops the readout filters and the last reading, the contact flags and the
|
||||
calibration session (model zero/reference), so the first frame of a new
|
||||
reference version is measured from a clean force state. The image half
|
||||
belongs to the flow thread and is reset by :meth:`reset_image_state`.
|
||||
"""
|
||||
self._force_readout.reset_readout()
|
||||
# The unfiltered pair belongs to the frame it was computed for: a new
|
||||
# session must not report the previous one's reading.
|
||||
self._force_unfiltered = None
|
||||
self._force_raw_unfiltered = None
|
||||
self._reset_calibration_session()
|
||||
self._force_filtered_raw = None
|
||||
self.compute_deformation_valid = False
|
||||
if self.img is not None:
|
||||
self.initial_img = self.img.copy()
|
||||
self.compute_contact_valid = False
|
||||
self.centroid_found = False
|
||||
self._reset_calibration_session()
|
||||
|
||||
def reset(self):
|
||||
"""Reset the image and force state together (single-threaded use).
|
||||
|
||||
Kept for callers that own the whole sensor without the streaming
|
||||
pipeline, and for a third-party sensor/stage that has no split seam: the
|
||||
final state is unchanged from before the split. :class:`StreamPipeline`
|
||||
applies the two halves on the threads that own them instead -
|
||||
:meth:`reset_image_state` on the flow stage and
|
||||
:meth:`reset_force_state` on the HHD stage, before the first frame of the
|
||||
new reference version.
|
||||
"""
|
||||
self.reset_image_state()
|
||||
self.reset_force_state()
|
||||
|
||||
|
||||
def disconnect(self):
|
||||
@@ -1016,6 +1366,8 @@ class Sensor:
|
||||
flow_ema_alpha=flow_ema_alpha,
|
||||
force_median_window=force_median_window,
|
||||
)
|
||||
# The flow-EMA coefficient/history has one owner (this readout); the image
|
||||
# pipeline reads it on the next frame, so runtime configure stays live.
|
||||
for readout in getattr(self, "_model_readouts", {}).values():
|
||||
readout.configure(
|
||||
lowpass_cutoff_hz=lowpass_cutoff_hz,
|
||||
@@ -1171,6 +1523,48 @@ class Sensor:
|
||||
),
|
||||
}
|
||||
|
||||
def _bind_sensor_context(self) -> None:
|
||||
"""Bind this instance's read-only SN/config/assets/cal_path context.
|
||||
|
||||
Called once during construction and again only when the force
|
||||
calibration is explicitly replaced. The binding is local to this
|
||||
instance; no state is cached or shared between sensors, and an
|
||||
unexpected binding problem is reported on the context instead of
|
||||
changing whether a published Sensor can be constructed. Partially
|
||||
initialised Sensor doubles (tests build some with ``__new__``) are
|
||||
tolerated: their missing fields bind as ``None``.
|
||||
"""
|
||||
from .sensors.context import build_sensor_context, fallback_sensor_context
|
||||
|
||||
config_name = getattr(self, "config_name", "")
|
||||
config = getattr(self, "_config", None)
|
||||
device_id = getattr(self, "device_id", None)
|
||||
calibration = getattr(self, "calibration", None)
|
||||
try:
|
||||
bundle_candidates = self._bundle_name_candidates()
|
||||
except Exception: # noqa: BLE001 - a test double may not carry the config
|
||||
bundle_candidates = []
|
||||
try:
|
||||
self._sensor_context = build_sensor_context(
|
||||
config_name=config_name,
|
||||
config=config if isinstance(config, dict) else {},
|
||||
device_id=device_id,
|
||||
calibration=calibration,
|
||||
bundle_candidates=bundle_candidates,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - context binding must not break a Sensor
|
||||
self._sensor_context = fallback_sensor_context(
|
||||
config_name=config_name,
|
||||
config=config,
|
||||
device_id=device_id,
|
||||
reason=exc,
|
||||
)
|
||||
|
||||
@property
|
||||
def context(self) -> "SensorContext":
|
||||
"""Read-only SN/config/assets/calibration binding of this Sensor instance."""
|
||||
return self._sensor_context
|
||||
|
||||
@property
|
||||
def force_calibrated(self) -> bool:
|
||||
"""True when at least one channel curve is loaded."""
|
||||
@@ -1211,6 +1605,7 @@ class Sensor:
|
||||
self._calibration_context = None
|
||||
self._force_readout.reset()
|
||||
self._apply_force_filter()
|
||||
self._bind_sensor_context()
|
||||
return
|
||||
if isinstance(cal, cal_mod.Calibration) or getattr(cal, "schema_version", None) == 3:
|
||||
selected = cal
|
||||
@@ -1245,6 +1640,9 @@ class Sensor:
|
||||
if not self._uses_calibration_model():
|
||||
self._force_readout.reset()
|
||||
self._apply_force_filter()
|
||||
# The context is a value object: an explicit calibration swap atomically
|
||||
# replaces it (SN/config/assets stay the same; cal path/fingerprint update).
|
||||
self._bind_sensor_context()
|
||||
|
||||
def _apply_force_filter(self):
|
||||
convert = self.calibration.convert if self.calibration is not None else None
|
||||
@@ -1290,6 +1688,12 @@ class Sensor:
|
||||
|
||||
def set_calibration_reference(self, raw, *, reference_id):
|
||||
"""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:
|
||||
raise ValueError("raw must map axes to finite values and reference_id is required")
|
||||
values = {}
|
||||
@@ -1303,6 +1707,26 @@ class Sensor:
|
||||
if self._uses_calibration_model():
|
||||
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):
|
||||
if not self.centroid_found:
|
||||
return None
|
||||
@@ -1320,8 +1744,9 @@ class Sensor:
|
||||
def apply_calibration_frame(
|
||||
self, *, frame_id, timestamp_s=None, raw=None, uv=None,
|
||||
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():
|
||||
raise cal_mod.CalibrationError("apply_calibration_frame requires schema 3")
|
||||
if self._calibration_context is None:
|
||||
@@ -1353,6 +1778,12 @@ class Sensor:
|
||||
}
|
||||
if contact_found is not None:
|
||||
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(
|
||||
frame_id=frame_id, timestamp_s=timestamp_s,
|
||||
features=features, reference_id=self._calibration_reference_id,
|
||||
@@ -1628,9 +2059,20 @@ class Sensor:
|
||||
# return self.cap is not None and self.cap.isOpened()
|
||||
|
||||
def _read_frame(self):
|
||||
captured: CapturedFrame | None = None
|
||||
if self._capture_source is not None and self._capture_source.running:
|
||||
return self._capture_source.read_latest(copy=True, wait_s=1.0)
|
||||
return self._read_frame_raw()
|
||||
captured = self._capture_source.read_latest_stamped(copy=True, wait_s=1.0)
|
||||
elif self._capture_source is not None:
|
||||
captured = self._capture_source.read_raw_stamped()
|
||||
self._last_captured_frame = captured
|
||||
if captured is None:
|
||||
return False, None
|
||||
return True, captured.image
|
||||
|
||||
@property
|
||||
def last_captured_frame(self) -> CapturedFrame | None:
|
||||
"""Source identity/clocks of the frame the built-in capture path read last."""
|
||||
return self._last_captured_frame
|
||||
|
||||
def _read_frame_raw(self):
|
||||
if self._capture_source is not None:
|
||||
|
||||
@@ -0,0 +1,922 @@
|
||||
"""Single-stream entry point: SensorStream (T08a + T08b).
|
||||
|
||||
``SensorStream`` ties the accepted T04–T08a blocks into one place:
|
||||
|
||||
* one capture owner per running stream: either it creates the ``Sensor``
|
||||
(:meth:`SensorStream.open`, closes it on stop) or the caller lends an opened
|
||||
Sensor/feeds prepared frames (:meth:`push_frame`; the frame source is the
|
||||
caller's and stays open);
|
||||
* ``StreamPipeline`` remains the threading/reset engine (bounded queues, atomic
|
||||
``request_reset`` version, flow/hhd ownership), so no second version or reset
|
||||
bookkeeping is introduced;
|
||||
* the HHD stage's ``on_frame`` callback is the single force-side thread. It
|
||||
builds the T03 ``MeasurementFrame`` for a finished snapshot and calls
|
||||
``ForceStreamSession.process`` exactly once per published snapshot. The
|
||||
facade also applies queued ``reset_and_zero`` commands there, so the force
|
||||
session is never touched from a consumer thread;
|
||||
* **real-time** mode keeps only the newest candidate input (a new push overwrites
|
||||
an unclaimed candidate and counts the overwrite); a frame already handed to
|
||||
the pipeline finishes normally;
|
||||
* **file/debug** mode (``realtime=False``) processes every accepted input in
|
||||
order: external frames go through a bounded input queue whose producer blocks
|
||||
when the computation is behind (backpressure), and built-in capture reads every
|
||||
file frame; no frame is dropped;
|
||||
* source timing is explicit (K4.2/K4.3):
|
||||
- ``capture_monotonic`` for live capture (``CapturedFrame.host_mono_ns``);
|
||||
- ``recorded_signal`` for an Orisys recording that stores its own signal time;
|
||||
- ``video_pts`` only when the caller supplies a provable container PTS
|
||||
(``source_pts`` preserved verbatim);
|
||||
- ``estimated_source`` for a built-in OpenCV video: ``capture_time_ns`` is
|
||||
computed from ``source_frame_id / nominal_fps`` and ``CAP_PROP_POS_MSEC`` is
|
||||
reported in diagnostics only, never as ``source_pts``;
|
||||
* the entry layer ignores duplicate and late source identities and counts them;
|
||||
a duplicate/late input never advances or invalidates a zero;
|
||||
* ``reset_and_zero`` combines the image reset (via ``StreamPipeline.request_reset``
|
||||
atomic version) with the unloaded-zero request (via the force thread), and
|
||||
returns once that command reports ready. Users do not manage version numbers.
|
||||
If the caller times out, the command is still in progress in the force thread;
|
||||
another ``reset_and_zero`` is rejected until it finishes.
|
||||
|
||||
``include=("field",)`` asks the pipeline to attach the same-frame HHD field
|
||||
payload; the facade adds it automatically when the calibration model needs it.
|
||||
The field is copied in the HHD stage's ``_build`` (never read from a consumer
|
||||
thread), and the default snapshot path is unchanged.
|
||||
|
||||
One instance belongs to one running stream; one thread owns the force session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .force_readout import OutputSettings
|
||||
from .force_session import ZeroProgress
|
||||
from .force_stream_session import ForceStreamOutput, ForceStreamSession
|
||||
from .pipeline import ProducedFrame, StreamPipeline
|
||||
from .sensor import Sensor
|
||||
from .stream_contract import TIME_BASES, FrameStamp, MeasurementFrame, StreamEvent
|
||||
|
||||
__all__ = ["SensorStream", "SensorStreamError"]
|
||||
|
||||
#: How long reset_and_zero waits for the force session to report ready.
|
||||
DEFAULT_ZERO_TIMEOUT_S = 30.0
|
||||
#: Source-time gap above which a processed frame is flagged as a long gap.
|
||||
DEFAULT_LONG_GAP_S = 0.5
|
||||
#: Upper bound on retained output rows (the latest-view row is always kept).
|
||||
_HISTORY_LIMIT = 4096
|
||||
#: Upper bound on remembered source identities for duplicate detection.
|
||||
_SEEN_LIMIT = 4096
|
||||
|
||||
|
||||
class SensorStreamError(RuntimeError):
|
||||
"""A stream stage, command or zero operation failed; the caller sees it."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _QueuedFrame:
|
||||
image: np.ndarray
|
||||
source_frame_id: int
|
||||
capture_time_ns: int
|
||||
time_basis: str
|
||||
clock_id: str
|
||||
source_pts: int | float | None = None
|
||||
media_time_ms: float | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ZeroCommand:
|
||||
reference_id: str
|
||||
declared_unloaded: bool
|
||||
max_gap_s: float | None
|
||||
max_window_s: float | None
|
||||
target_reference_generation: int
|
||||
done: threading.Event = field(default_factory=threading.Event)
|
||||
result: ZeroProgress | None = None
|
||||
error: BaseException | None = None
|
||||
|
||||
|
||||
class SensorStream:
|
||||
"""One single-sensor stream: capture → ImagePipeline → ForceStreamSession."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sensor: Sensor,
|
||||
*,
|
||||
sensor_family: str | None = None,
|
||||
calibration=None,
|
||||
context=None,
|
||||
settings: OutputSettings | None = None,
|
||||
design_sample_rate_hz: float | None = None,
|
||||
sensor_id: str | None = None,
|
||||
session_id: str = "stream",
|
||||
realtime: bool = True,
|
||||
depth: int = 1,
|
||||
nominal_fps: float | None = None,
|
||||
include: Iterable[str] = (),
|
||||
long_gap_s: float = DEFAULT_LONG_GAP_S,
|
||||
owns_sensor: bool = False,
|
||||
verbose: bool = False,
|
||||
external_input: bool | None = None,
|
||||
) -> None:
|
||||
self.sensor = sensor
|
||||
self._owns_sensor = bool(owns_sensor)
|
||||
self._verbose = bool(verbose)
|
||||
self._external_input = (
|
||||
bool(getattr(sensor, "_external_only", False))
|
||||
if external_input is None
|
||||
else bool(external_input)
|
||||
)
|
||||
self._realtime = bool(realtime)
|
||||
self._session_id = str(session_id)
|
||||
self._depth = max(1, int(depth))
|
||||
self._long_gap_s = float(long_gap_s)
|
||||
if self._long_gap_s <= 0:
|
||||
raise ValueError("long_gap_s must be positive")
|
||||
self._sensor_id = (
|
||||
str(sensor_id)
|
||||
if sensor_id is not None
|
||||
else getattr(getattr(sensor, "context", None), "sensor_id", None)
|
||||
)
|
||||
if not self._sensor_id:
|
||||
raise ValueError(
|
||||
"SensorStream needs an explicit sensor_id or a SensorContext with a bound SN"
|
||||
)
|
||||
if calibration is None:
|
||||
calibration = getattr(sensor, "calibration", None)
|
||||
family = sensor_family or self._derive_family(sensor)
|
||||
self.session = ForceStreamSession(
|
||||
sensor_family=family,
|
||||
device_id=getattr(sensor, "device_id", None),
|
||||
context=context
|
||||
if context is not None
|
||||
else getattr(sensor, "context", None),
|
||||
calibration=calibration,
|
||||
settings=settings,
|
||||
design_sample_rate_hz=design_sample_rate_hz,
|
||||
)
|
||||
self._nominal_fps = self._resolve_nominal_fps(sensor, nominal_fps)
|
||||
requested = set(include)
|
||||
unknown = requested - {"field"}
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"unknown include fields {sorted(unknown)}; SensorStream supports only 'field'"
|
||||
)
|
||||
want_field = "field" in requested or bool(
|
||||
self.session.measurement_requirements[0]
|
||||
)
|
||||
self._include = ("field",) if want_field else ()
|
||||
enable = getattr(sensor, "enable_measurement_session", None)
|
||||
self._has_measurement_session = callable(enable) and hasattr(
|
||||
sensor, "disable_measurement_session"
|
||||
)
|
||||
self._sensor_session_was_enabled = bool(
|
||||
getattr(
|
||||
sensor,
|
||||
"measurement_session",
|
||||
getattr(sensor, "_measurement_session", False),
|
||||
)
|
||||
)
|
||||
self._sensor_session_enabled_by_stream = False
|
||||
self._cond = threading.Condition()
|
||||
self._candidate: _QueuedFrame | None = None
|
||||
self._input_queue: queue.Queue | None = (
|
||||
None if self._realtime else queue.Queue(maxsize=self._depth)
|
||||
)
|
||||
self._overwritten = 0
|
||||
self._duplicate_inputs = 0
|
||||
self._late_inputs = 0
|
||||
self._backpressure_waits = 0
|
||||
self._seen_source_ids: dict[int, None] = {}
|
||||
self._last_input_source_frame_id: int | None = None
|
||||
self._input_counter = 0
|
||||
self._accepted_inputs = 0
|
||||
self._stop = threading.Event()
|
||||
self._end_of_input = False
|
||||
self._end_of_input_seen = False
|
||||
self._latest_output: ForceStreamOutput | None = None
|
||||
self._outputs_seen = 0
|
||||
self._published_outputs = 0
|
||||
self._history: list[ForceStreamOutput] = []
|
||||
self._delivered = 0
|
||||
self._events: list[StreamEvent] = []
|
||||
self._pending_zero: _ZeroCommand | None = None
|
||||
self._active_zero: _ZeroCommand | None = None
|
||||
self._last_source_time_ns: int | None = None
|
||||
self._last_source_dt_s: float | None = None
|
||||
self._last_long_gap = False
|
||||
self._pipeline = StreamPipeline(
|
||||
sensor,
|
||||
self._produce,
|
||||
depth=self._depth,
|
||||
include=self._include,
|
||||
on_frame=self._on_snapshot,
|
||||
verbose=self._verbose,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ ctors
|
||||
@classmethod
|
||||
def open(
|
||||
cls,
|
||||
source,
|
||||
*,
|
||||
config_name: str = "box",
|
||||
calibration=None,
|
||||
context=None,
|
||||
settings: OutputSettings | None = None,
|
||||
design_sample_rate_hz: float | None = None,
|
||||
sensor_id: str | None = None,
|
||||
session_id: str = "stream",
|
||||
realtime: bool = True,
|
||||
depth: int = 1,
|
||||
nominal_fps: float | None = None,
|
||||
include: Iterable[str] = (),
|
||||
long_gap_s: float = DEFAULT_LONG_GAP_S,
|
||||
verbose: bool = False,
|
||||
sensor_options: Mapping[str, Any] | None = None,
|
||||
) -> SensorStream:
|
||||
"""Open a built-in source; the stream owns and closes the Sensor.
|
||||
|
||||
``realtime=False`` uses file/debug complete processing (every file frame).
|
||||
"""
|
||||
sensor = Sensor(
|
||||
source,
|
||||
config_name=config_name,
|
||||
verbose=verbose,
|
||||
**(dict(sensor_options or {})),
|
||||
)
|
||||
return cls(
|
||||
sensor,
|
||||
sensor_family=None,
|
||||
calibration=calibration,
|
||||
context=context,
|
||||
settings=settings,
|
||||
design_sample_rate_hz=design_sample_rate_hz,
|
||||
sensor_id=sensor_id,
|
||||
session_id=session_id,
|
||||
realtime=realtime,
|
||||
depth=depth,
|
||||
nominal_fps=nominal_fps,
|
||||
include=include,
|
||||
long_gap_s=long_gap_s,
|
||||
owns_sensor=True,
|
||||
verbose=verbose,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _derive_family(sensor: Sensor) -> str:
|
||||
context = getattr(sensor, "context", None)
|
||||
family = getattr(context, "product_family", None)
|
||||
if family:
|
||||
return str(family)
|
||||
text = str(getattr(sensor, "config_name", "") or "").replace("\\", "/")
|
||||
for name in ("box", "mini", "finger"):
|
||||
if name in text:
|
||||
return name
|
||||
return "box"
|
||||
|
||||
@staticmethod
|
||||
def _resolve_nominal_fps(sensor: Sensor, nominal_fps: float | None) -> float:
|
||||
if nominal_fps is not None:
|
||||
value = float(nominal_fps)
|
||||
else:
|
||||
readout = getattr(sensor, "_force_readout", None)
|
||||
value = float(getattr(readout, "config_fps", 0.0) or 0.0)
|
||||
if value <= 0:
|
||||
value = 30.0
|
||||
if not np.isfinite(value) or value <= 0:
|
||||
raise ValueError("nominal_fps must be a positive finite number")
|
||||
return value
|
||||
|
||||
# --------------------------------------------------------------- lifecycle
|
||||
def start(self) -> SensorStream:
|
||||
if self._pipeline.running:
|
||||
return self
|
||||
self._stop.clear()
|
||||
if self._has_measurement_session:
|
||||
self._sensor_session_was_enabled = bool(
|
||||
getattr(
|
||||
self.sensor,
|
||||
"measurement_session",
|
||||
getattr(self.sensor, "_measurement_session", False),
|
||||
)
|
||||
)
|
||||
if not self._sensor_session_was_enabled:
|
||||
self.sensor.enable_measurement_session()
|
||||
self._sensor_session_enabled_by_stream = True
|
||||
try:
|
||||
self._pipeline.start()
|
||||
except BaseException:
|
||||
self._restore_sensor_session()
|
||||
raise
|
||||
return self
|
||||
|
||||
def _restore_sensor_session(self) -> None:
|
||||
if not self._sensor_session_enabled_by_stream:
|
||||
return
|
||||
self._sensor_session_enabled_by_stream = False
|
||||
disable = getattr(self.sensor, "disable_measurement_session", None)
|
||||
if callable(disable):
|
||||
disable()
|
||||
|
||||
def stop(self, timeout: float = 5.0) -> None:
|
||||
"""Stop all stages; the borrowed source stays open, the owned one closes."""
|
||||
self._stop.set()
|
||||
with self._cond:
|
||||
self._candidate = None
|
||||
for command in (self._pending_zero, self._active_zero):
|
||||
if command is not None and not command.done.is_set():
|
||||
command.error = SensorStreamError(
|
||||
"stream stopped before zero completed"
|
||||
)
|
||||
command.done.set()
|
||||
self._pending_zero = None
|
||||
self._active_zero = None
|
||||
self._cond.notify_all()
|
||||
try:
|
||||
try:
|
||||
self._pipeline.stop(timeout=timeout)
|
||||
finally:
|
||||
self._restore_sensor_session()
|
||||
if self._owns_sensor:
|
||||
self.sensor.disconnect()
|
||||
except SensorStreamError:
|
||||
raise
|
||||
except BaseException as exc:
|
||||
raise SensorStreamError(f"stream stage failed: {exc}") from exc
|
||||
error = self._pipeline.error
|
||||
if error is not None:
|
||||
raise SensorStreamError(f"stream stage failed: {error}") from error
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._pipeline.running
|
||||
|
||||
@property
|
||||
def error(self) -> BaseException | None:
|
||||
return self._pipeline.error
|
||||
|
||||
@property
|
||||
def pipeline(self) -> StreamPipeline:
|
||||
return self._pipeline
|
||||
|
||||
@property
|
||||
def latest_output(self) -> ForceStreamOutput | None:
|
||||
return self._latest_output
|
||||
|
||||
@property
|
||||
def overwritten_frames(self) -> int:
|
||||
"""Real-time candidate frames replaced before the pipeline claimed them."""
|
||||
return int(self._overwritten)
|
||||
|
||||
@property
|
||||
def duplicate_inputs(self) -> int:
|
||||
return int(self._duplicate_inputs)
|
||||
|
||||
@property
|
||||
def late_inputs(self) -> int:
|
||||
return int(self._late_inputs)
|
||||
|
||||
@property
|
||||
def backpressure_waits(self) -> int:
|
||||
"""Number of producer waits caused by the bounded file/debug input queue."""
|
||||
return int(self._backpressure_waits)
|
||||
|
||||
@property
|
||||
def input_queue_depth(self) -> int:
|
||||
return 0 if self._input_queue is None else int(self._input_queue.qsize())
|
||||
|
||||
@property
|
||||
def input_queue_maxsize(self) -> int:
|
||||
return self._depth
|
||||
|
||||
@property
|
||||
def nominal_fps(self) -> float:
|
||||
return float(self._nominal_fps)
|
||||
|
||||
@property
|
||||
def realtime(self) -> bool:
|
||||
return bool(self._realtime)
|
||||
|
||||
def diagnostics(self) -> dict[str, Any]:
|
||||
"""Stream-level diagnostics that accompany the per-frame result rows."""
|
||||
return {
|
||||
"realtime": self._realtime,
|
||||
"overwritten_frames": self._overwritten,
|
||||
"duplicate_inputs": self._duplicate_inputs,
|
||||
"late_inputs": self._late_inputs,
|
||||
"backpressure_waits": self._backpressure_waits,
|
||||
"input_queue_depth": self.input_queue_depth,
|
||||
"input_queue_maxsize": self._depth,
|
||||
"published": self._published_outputs,
|
||||
"last_source_dt_s": self._last_source_dt_s,
|
||||
"last_long_gap": self._last_long_gap,
|
||||
"design_sample_rate_hz": self.session.design_sample_rate_hz,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------ external input
|
||||
def push_frame(
|
||||
self,
|
||||
frame: np.ndarray,
|
||||
*,
|
||||
source_frame_id: int | None = None,
|
||||
capture_time_ns: int | None = None,
|
||||
time_basis: str | None = None,
|
||||
clock_id: str = "host",
|
||||
source_pts: int | float | None = None,
|
||||
media_time_ms: float | None = None,
|
||||
) -> bool:
|
||||
"""Offer one prepared frame from a borrowed source.
|
||||
|
||||
Real-time candidate semantics: if the producer has not claimed the
|
||||
previous candidate yet, this frame replaces it and increments
|
||||
:attr:`overwritten_frames`; a claimed frame is never interrupted.
|
||||
File/debug complete semantics: the frame enters a bounded FIFO; this call
|
||||
blocks while the queue is full (observed through
|
||||
:attr:`backpressure_waits`) and never drops an accepted frame.
|
||||
|
||||
``time_basis=None`` chooses ``capture_monotonic`` for real-time and
|
||||
``estimated_source`` (frame id ÷ nominal fps) for file/debug. A
|
||||
``video_pts`` frame must carry ``source_pts``; POS_MSEC is only a
|
||||
diagnostic and never becomes ``source_pts``.
|
||||
"""
|
||||
if not self._external_input:
|
||||
raise RuntimeError(
|
||||
"push_frame() is for external-frame streams; built-in capture produces frames itself"
|
||||
)
|
||||
if not isinstance(frame, np.ndarray) or frame.size == 0:
|
||||
raise ValueError("frame must be a non-empty numpy array")
|
||||
if self._stop.is_set():
|
||||
raise SensorStreamError("stream is stopped")
|
||||
if source_frame_id is None:
|
||||
with self._cond:
|
||||
source_frame_id = self._input_counter
|
||||
source_frame_id = int(source_frame_id)
|
||||
if source_frame_id < 0:
|
||||
raise ValueError("source_frame_id must be non-negative")
|
||||
with self._cond:
|
||||
if source_frame_id in self._seen_source_ids:
|
||||
self._duplicate_inputs += 1
|
||||
return False
|
||||
if (
|
||||
self._last_input_source_frame_id is not None
|
||||
and source_frame_id <= self._last_input_source_frame_id
|
||||
):
|
||||
self._late_inputs += 1
|
||||
return False
|
||||
self._seen_source_ids[source_frame_id] = None
|
||||
if len(self._seen_source_ids) > _SEEN_LIMIT:
|
||||
dropped = next(iter(self._seen_source_ids))
|
||||
del self._seen_source_ids[dropped]
|
||||
self._last_input_source_frame_id = source_frame_id
|
||||
self._input_counter += 1
|
||||
self._accepted_inputs += 1
|
||||
item = self._resolve_input_frame(
|
||||
frame,
|
||||
source_frame_id=source_frame_id,
|
||||
capture_time_ns=capture_time_ns,
|
||||
time_basis=time_basis,
|
||||
clock_id=clock_id,
|
||||
source_pts=source_pts,
|
||||
media_time_ms=media_time_ms,
|
||||
)
|
||||
if self._realtime:
|
||||
with self._cond:
|
||||
if self._candidate is not None:
|
||||
self._overwritten += 1
|
||||
self._candidate = item
|
||||
self._cond.notify_all()
|
||||
return True
|
||||
|
||||
assert self._input_queue is not None
|
||||
while True:
|
||||
try:
|
||||
self._input_queue.put(item, timeout=0.05)
|
||||
return True
|
||||
except queue.Full:
|
||||
if self._stop.is_set():
|
||||
raise SensorStreamError(
|
||||
"stream stopped while waiting for backpressure"
|
||||
)
|
||||
with self._cond:
|
||||
self._backpressure_waits += 1
|
||||
|
||||
def end_input(self) -> None:
|
||||
"""Signal end of an external file/debug input sequence (complete mode)."""
|
||||
if self._realtime:
|
||||
return
|
||||
assert self._input_queue is not None
|
||||
self._input_queue.put(None)
|
||||
|
||||
def _resolve_input_frame(
|
||||
self,
|
||||
frame: np.ndarray,
|
||||
*,
|
||||
source_frame_id: int,
|
||||
capture_time_ns: int | None,
|
||||
time_basis: str | None,
|
||||
clock_id: str,
|
||||
source_pts: int | float | None,
|
||||
media_time_ms: float | None,
|
||||
) -> _QueuedFrame:
|
||||
basis = time_basis
|
||||
if basis is None:
|
||||
basis = "capture_monotonic" if self._realtime else "estimated_source"
|
||||
if basis not in TIME_BASES:
|
||||
raise ValueError(
|
||||
f"unknown time_basis {basis!r}; expected one of {TIME_BASES}"
|
||||
)
|
||||
if basis == "capture_monotonic":
|
||||
if source_pts is not None:
|
||||
raise ValueError("capture_monotonic frames cannot carry source_pts")
|
||||
stamp_ns = (
|
||||
time.perf_counter_ns()
|
||||
if capture_time_ns is None
|
||||
else int(capture_time_ns)
|
||||
)
|
||||
elif basis == "recorded_signal":
|
||||
if capture_time_ns is None:
|
||||
raise ValueError("recorded_signal frames need capture_time_ns")
|
||||
stamp_ns = int(capture_time_ns)
|
||||
elif basis == "video_pts":
|
||||
if source_pts is None:
|
||||
raise ValueError(
|
||||
"video_pts requires a provable source_pts from the container"
|
||||
)
|
||||
stamp_ns = (
|
||||
int(source_pts) if capture_time_ns is None else int(capture_time_ns)
|
||||
)
|
||||
else: # estimated_source
|
||||
stamp_ns = (
|
||||
self._estimated_source_ns(source_frame_id)
|
||||
if capture_time_ns is None
|
||||
else int(capture_time_ns)
|
||||
)
|
||||
return _QueuedFrame(
|
||||
image=frame,
|
||||
source_frame_id=source_frame_id,
|
||||
capture_time_ns=stamp_ns,
|
||||
time_basis=basis,
|
||||
clock_id=str(clock_id),
|
||||
source_pts=None if source_pts is None else source_pts,
|
||||
media_time_ms=None if media_time_ms is None else float(media_time_ms),
|
||||
)
|
||||
|
||||
def _estimated_source_ns(self, source_frame_id: int) -> int:
|
||||
return int(round(source_frame_id / self._nominal_fps * 1e9))
|
||||
|
||||
# ----------------------------------------------------------------- producer
|
||||
def _produce(self):
|
||||
if self._external_input:
|
||||
item = self._next_external_frame()
|
||||
if item is None:
|
||||
return None
|
||||
prepared = self.sensor.get_img(
|
||||
frame=item.image,
|
||||
already_processed=True,
|
||||
already_gray=item.image.ndim == 2,
|
||||
)
|
||||
if prepared is None:
|
||||
return None
|
||||
return ProducedFrame(prepared, metadata=item)
|
||||
|
||||
# Built-in capture: identity and clocks come from CapturedFrame. The
|
||||
# Sensor's own capture path keeps the source's latest/next frame.
|
||||
image = self.sensor.get_img()
|
||||
captured = getattr(self.sensor, "last_captured_frame", None)
|
||||
if image is None:
|
||||
self._end_of_input_seen = True
|
||||
self._maybe_fail_zero_after_input_end()
|
||||
return None
|
||||
self._input_counter += 1
|
||||
with self._cond:
|
||||
self._accepted_inputs += 1
|
||||
source_frame_id = (
|
||||
int(captured.sequence)
|
||||
if captured is not None
|
||||
else max(0, self._input_counter - 1)
|
||||
)
|
||||
if self._realtime:
|
||||
basis = "capture_monotonic"
|
||||
stamp_ns = (
|
||||
int(captured.host_mono_ns)
|
||||
if captured is not None
|
||||
else time.perf_counter_ns()
|
||||
)
|
||||
media_time_ms = None
|
||||
else:
|
||||
basis = "estimated_source"
|
||||
stamp_ns = self._estimated_source_ns(source_frame_id)
|
||||
media_time_ms = None if captured is None else captured.media_time_ms
|
||||
item = _QueuedFrame(
|
||||
image=image,
|
||||
source_frame_id=source_frame_id,
|
||||
capture_time_ns=stamp_ns,
|
||||
time_basis=basis,
|
||||
clock_id="host",
|
||||
source_pts=None,
|
||||
media_time_ms=media_time_ms,
|
||||
)
|
||||
return ProducedFrame(image, metadata=item)
|
||||
|
||||
def _fail_incomplete_zero_after_input_end(self) -> None:
|
||||
"""Complete an in-progress reset_and_zero with a clear input-end error."""
|
||||
with self._cond:
|
||||
failed = False
|
||||
for command in (self._pending_zero, self._active_zero):
|
||||
if command is not None and not command.done.is_set():
|
||||
command.error = SensorStreamError(
|
||||
"input ended before unloaded zero completed"
|
||||
)
|
||||
command.done.set()
|
||||
failed = True
|
||||
self._pending_zero = None
|
||||
self._active_zero = None
|
||||
if failed:
|
||||
self._cond.notify_all()
|
||||
|
||||
def _maybe_fail_zero_after_input_end(self) -> None:
|
||||
"""Fail an incomplete zero only after every accepted frame was processed.
|
||||
|
||||
The producer sees the end marker possibly long before the flow/HHD stages
|
||||
have drained the frames already accepted; those frames can still complete
|
||||
the 30-frame baseline. The input-end judgment is therefore deferred until
|
||||
the published output count reaches the accepted input count.
|
||||
"""
|
||||
with self._cond:
|
||||
drained = (
|
||||
self._end_of_input_seen
|
||||
and self._published_outputs >= self._accepted_inputs
|
||||
)
|
||||
if drained:
|
||||
self._fail_incomplete_zero_after_input_end()
|
||||
|
||||
def _next_external_frame(self) -> _QueuedFrame | None:
|
||||
if self._realtime:
|
||||
with self._cond:
|
||||
while self._candidate is None and not self._stop.is_set():
|
||||
self._cond.wait(timeout=0.05)
|
||||
if self._stop.is_set() or self._candidate is None:
|
||||
return None
|
||||
item = self._candidate
|
||||
self._candidate = None
|
||||
return item
|
||||
assert self._input_queue is not None
|
||||
while True:
|
||||
try:
|
||||
item = self._input_queue.get(timeout=0.05)
|
||||
except queue.Empty:
|
||||
if self._stop.is_set():
|
||||
return None
|
||||
continue
|
||||
if item is None:
|
||||
self._end_of_input = True
|
||||
self._end_of_input_seen = True
|
||||
self._maybe_fail_zero_after_input_end()
|
||||
return None
|
||||
if self._stop.is_set():
|
||||
return None
|
||||
return item
|
||||
|
||||
# --------------------------------------------------------------- force stage
|
||||
def _on_snapshot(self, snapshot) -> None:
|
||||
"""Runs on the pipeline's HHD thread: the only place touching the session."""
|
||||
metadata = snapshot.source_metadata
|
||||
if not isinstance(metadata, _QueuedFrame):
|
||||
self._pipeline._error = SensorStreamError(
|
||||
"snapshot has no source metadata frame contract"
|
||||
)
|
||||
return
|
||||
command_to_start: _ZeroCommand | None = None
|
||||
with self._cond:
|
||||
if self._active_zero is None and self._pending_zero is not None:
|
||||
pending = self._pending_zero
|
||||
version = int(snapshot.tracker_reset_seq)
|
||||
if version >= pending.target_reference_generation:
|
||||
self._pending_zero = None
|
||||
if version > pending.target_reference_generation:
|
||||
pending.error = SensorStreamError(
|
||||
f"reference generation {version} advanced past the "
|
||||
f"requested zero target {pending.target_reference_generation}"
|
||||
)
|
||||
pending.done.set()
|
||||
self._cond.notify_all()
|
||||
else:
|
||||
self._active_zero = pending
|
||||
command_to_start = pending
|
||||
if command_to_start is not None:
|
||||
try:
|
||||
self.session.request_zero(
|
||||
reference_id=command_to_start.reference_id,
|
||||
declared_unloaded=command_to_start.declared_unloaded,
|
||||
expected_reference_generation=command_to_start.target_reference_generation,
|
||||
max_gap_s=command_to_start.max_gap_s,
|
||||
max_window_s=command_to_start.max_window_s,
|
||||
)
|
||||
except BaseException as exc:
|
||||
with self._cond:
|
||||
command_to_start.error = exc
|
||||
command_to_start.done.set()
|
||||
self._active_zero = None
|
||||
self._cond.notify_all()
|
||||
source_dt_s = None
|
||||
long_gap = False
|
||||
if self._last_source_time_ns is not None:
|
||||
source_dt_s = (metadata.capture_time_ns - self._last_source_time_ns) / 1e9
|
||||
long_gap = source_dt_s > self._long_gap_s
|
||||
stamp = FrameStamp(
|
||||
sensor_id=self._sensor_id,
|
||||
session_id=self._session_id,
|
||||
frame_id=int(snapshot.seq),
|
||||
source_frame_id=metadata.source_frame_id,
|
||||
capture_time_ns=metadata.capture_time_ns,
|
||||
clock_id=metadata.clock_id,
|
||||
capture_time_basis=metadata.time_basis,
|
||||
source_pts=metadata.source_pts,
|
||||
)
|
||||
frame = MeasurementFrame(
|
||||
stamp=stamp,
|
||||
source_dt_s=source_dt_s,
|
||||
reference_generation=int(snapshot.tracker_reset_seq),
|
||||
)
|
||||
from .image_pipeline import ImageMeasurement
|
||||
|
||||
measurement = ImageMeasurement(
|
||||
frame=frame,
|
||||
force_raw=(
|
||||
float(snapshot.normal_force_raw),
|
||||
float(snapshot.shear_x_raw),
|
||||
float(snapshot.shear_y_raw),
|
||||
),
|
||||
torque=float(snapshot.torque),
|
||||
flow=None,
|
||||
field=snapshot.field if "field" in self._include else None,
|
||||
valid_mask=None,
|
||||
)
|
||||
try:
|
||||
output = self.session.process(measurement)
|
||||
events = self.session.drain_events()
|
||||
except BaseException as exc:
|
||||
with self._cond:
|
||||
if self._active_zero is not None:
|
||||
self._active_zero.error = exc
|
||||
self._active_zero.done.set()
|
||||
self._active_zero = None
|
||||
self._cond.notify_all()
|
||||
self._pipeline._error = exc
|
||||
return
|
||||
extra: list[tuple[str, str | float]] = [
|
||||
("overwritten_frames", float(self._overwritten)),
|
||||
("duplicate_inputs", float(self._duplicate_inputs)),
|
||||
("late_inputs", float(self._late_inputs)),
|
||||
("processing_latency_s", round(float(snapshot.age_ms) / 1000.0, 6)),
|
||||
("long_gap", "true" if long_gap else "false"),
|
||||
(
|
||||
"source_time_estimated",
|
||||
"true" if metadata.time_basis == "estimated_source" else "false",
|
||||
),
|
||||
]
|
||||
if metadata.media_time_ms is not None:
|
||||
extra.append(("pos_msec", float(metadata.media_time_ms)))
|
||||
if source_dt_s is not None:
|
||||
extra.append(("source_dt_s", float(source_dt_s)))
|
||||
output = replace(output, diagnostics=tuple(output.diagnostics) + tuple(extra))
|
||||
self._last_source_time_ns = metadata.capture_time_ns
|
||||
self._last_source_dt_s = source_dt_s
|
||||
self._last_long_gap = long_gap
|
||||
with self._cond:
|
||||
self._latest_output = output
|
||||
self._outputs_seen += 1
|
||||
self._published_outputs += 1
|
||||
self._history.append(output)
|
||||
if len(self._history) > _HISTORY_LIMIT:
|
||||
del self._history[: len(self._history) - _HISTORY_LIMIT]
|
||||
self._events.extend(events)
|
||||
command = self._active_zero
|
||||
if command is not None:
|
||||
command.result = self.session.zero
|
||||
if command.result.phase in ("ready", "failed"):
|
||||
command.done.set()
|
||||
self._active_zero = None
|
||||
self._cond.notify_all()
|
||||
self._maybe_fail_zero_after_input_end()
|
||||
|
||||
# ------------------------------------------------------------------ outputs
|
||||
def wait_output(self, timeout: float | None = None) -> ForceStreamOutput | None:
|
||||
"""Latest-wins output view: wait for the next-published row, return newest."""
|
||||
deadline = None if timeout is None else time.perf_counter() + timeout
|
||||
with self._cond:
|
||||
start = self._outputs_seen
|
||||
while self._outputs_seen == start:
|
||||
remaining = None
|
||||
if deadline is not None:
|
||||
remaining = deadline - time.perf_counter()
|
||||
if remaining <= 0:
|
||||
return None
|
||||
self._cond.wait(timeout=remaining)
|
||||
self._delivered = self._outputs_seen
|
||||
return self._latest_output
|
||||
|
||||
def wait_outputs(
|
||||
self, count: int, timeout: float | None = None
|
||||
) -> tuple[ForceStreamOutput, ...]:
|
||||
"""Wait until at least ``count`` rows exist, then return all retained rows.
|
||||
|
||||
File/debug complete mode uses this to check that N inputs produced N
|
||||
results; real-time users normally use :meth:`wait_output` instead.
|
||||
"""
|
||||
want = max(0, int(count))
|
||||
deadline = None if timeout is None else time.perf_counter() + timeout
|
||||
with self._cond:
|
||||
while self._published_outputs < want:
|
||||
remaining = None
|
||||
if deadline is not None:
|
||||
remaining = deadline - time.perf_counter()
|
||||
if remaining <= 0:
|
||||
return self.drain_outputs()
|
||||
self._cond.wait(timeout=remaining)
|
||||
return self.drain_outputs()
|
||||
|
||||
def drain_outputs(self) -> tuple[ForceStreamOutput, ...]:
|
||||
"""All output rows since the previous drain (in publication order)."""
|
||||
with self._cond:
|
||||
rows = tuple(self._history)
|
||||
self._history.clear()
|
||||
return rows
|
||||
|
||||
def drain_events(self) -> tuple[StreamEvent, ...]:
|
||||
"""Session events emitted by the force thread since the previous drain."""
|
||||
with self._cond:
|
||||
events = tuple(self._events)
|
||||
self._events.clear()
|
||||
return events
|
||||
|
||||
# ------------------------------------------------------------ combined zero
|
||||
def reset_and_zero(
|
||||
self,
|
||||
*,
|
||||
reference_id: str,
|
||||
declared_unloaded: bool = True,
|
||||
timeout: float = DEFAULT_ZERO_TIMEOUT_S,
|
||||
max_gap_s: float | None = None,
|
||||
max_window_s: float | None = None,
|
||||
):
|
||||
"""Image reset → same-version unloaded baseline → force ready (K8.5).
|
||||
|
||||
The image version is claimed atomically by StreamPipeline; the force
|
||||
session receives it on its own thread. The caller only names the zero,
|
||||
never a version number. Each call owns one command object: the force
|
||||
thread writes that command's ZeroProgress/error and signals it, so a
|
||||
second call can never observe the previous zero's state. The caller
|
||||
thread never reads ``session.zero``.
|
||||
|
||||
If the call times out, the command is **still in progress** in the force
|
||||
thread: the zero can still become ready later and another
|
||||
``reset_and_zero`` is rejected until that command finishes. Callers that
|
||||
time out should stop the stream (or keep waiting via the stream's zero
|
||||
state on the force side) rather than assuming the zero was cancelled.
|
||||
"""
|
||||
if not self._pipeline.running:
|
||||
raise SensorStreamError("start the stream before reset_and_zero()")
|
||||
with self._cond:
|
||||
if self._pending_zero is not None or self._active_zero is not None:
|
||||
raise SensorStreamError("another reset_and_zero is already in progress")
|
||||
target = int(self._pipeline.request_reset())
|
||||
command = _ZeroCommand(
|
||||
reference_id=str(reference_id),
|
||||
declared_unloaded=bool(declared_unloaded),
|
||||
max_gap_s=max_gap_s,
|
||||
max_window_s=max_window_s,
|
||||
target_reference_generation=target,
|
||||
)
|
||||
self._pending_zero = command
|
||||
self._maybe_fail_zero_after_input_end()
|
||||
if not command.done.wait(float(timeout)):
|
||||
with self._cond:
|
||||
if self._pending_zero is command:
|
||||
self._pending_zero = None
|
||||
raise TimeoutError(
|
||||
f"reset_and_zero({reference_id!r}) did not finish within {timeout}s "
|
||||
f"(target={target}); the command is still in progress in the force thread"
|
||||
)
|
||||
if command.error is not None:
|
||||
raise SensorStreamError(str(command.error)) from command.error
|
||||
progress = command.result
|
||||
if progress is None:
|
||||
raise SensorStreamError(
|
||||
f"reset_and_zero({reference_id!r}) finished without progress"
|
||||
)
|
||||
if progress.phase == "failed":
|
||||
raise SensorStreamError(f"unloaded zero failed: {progress.failure_reason}")
|
||||
if progress.phase != "ready":
|
||||
raise SensorStreamError(
|
||||
f"reset_and_zero({reference_id!r}) finished in phase "
|
||||
f"{progress.phase!r}, not ready"
|
||||
)
|
||||
return progress
|
||||
@@ -53,6 +53,12 @@ from .capabilities import (
|
||||
report,
|
||||
unavailable,
|
||||
)
|
||||
from .context import (
|
||||
SensorAssetBinding,
|
||||
SensorContext,
|
||||
build_sensor_context,
|
||||
fallback_sensor_context,
|
||||
)
|
||||
from .descriptor import (
|
||||
BACKEND_COMPOUND_EYE,
|
||||
BACKEND_SINGLE_CAMERA,
|
||||
@@ -100,6 +106,11 @@ __all__ = [
|
||||
"Availability",
|
||||
"AxisAvailability",
|
||||
"Issue",
|
||||
# read-only per-instance binding (T04)
|
||||
"SensorContext",
|
||||
"SensorAssetBinding",
|
||||
"build_sensor_context",
|
||||
"fallback_sensor_context",
|
||||
# descriptor
|
||||
"BundleDescriptor",
|
||||
"DescriptorError",
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
"""Read-only ``SensorContext``: the SN, configuration, assets and calibration path
|
||||
bound once for one Sensor instance.
|
||||
|
||||
Stage-1 architecture (K11.1, sensor-context task T04) makes one object own what
|
||||
must not be re-guessed per frame: which physical sensor this is, which
|
||||
``sensor.json``/configuration it runs, which bundle assets were selected, and
|
||||
which force calibration was chosen. The context is a frozen value object; a
|
||||
``Sensor`` binds it once during construction (and atomically replaces it only
|
||||
when its calibration is explicitly swapped), so two Sensor instances cannot
|
||||
share or overwrite each other's identity.
|
||||
|
||||
Identity fields are not duplicated here: :meth:`SensorContext.frame_identity`
|
||||
builds the frozen :class:`~orisys.stream_contract.FrameIdentity` from stage 1,
|
||||
so the per-frame stream contract remains the single home for frame identity and
|
||||
clock fields.
|
||||
|
||||
The context is plain data (paths as strings, hashes, tuples): no Qt, no device
|
||||
handle, no mutable mapping. ``build_sensor_context`` is tolerant by design - a
|
||||
bundle that cannot be described is reported in ``diagnostics`` instead of
|
||||
changing a published Sensor's construction behaviour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .. import configs as sdk_configs
|
||||
from ..stream_contract import FrameIdentity
|
||||
from .descriptor import parse_descriptor
|
||||
from .legacy import backend_from_config
|
||||
|
||||
__all__ = [
|
||||
"SensorAssetBinding",
|
||||
"SensorContext",
|
||||
"build_sensor_context",
|
||||
"fallback_sensor_context",
|
||||
]
|
||||
|
||||
#: Manifest keys that describe the bundle itself rather than an asset file.
|
||||
_MANIFEST_META_KEYS = frozenset({"name", "sensor_json", "version", "schema_version"})
|
||||
|
||||
|
||||
def _json_digest(value: Any) -> tuple[str, str | None]:
|
||||
"""Canonical SHA-256 of a JSON value plus an optional diagnostic."""
|
||||
try:
|
||||
text = json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
except (TypeError, ValueError) as exc: # a config that is not plain JSON
|
||||
return (
|
||||
hashlib.sha256(str(value).encode("utf-8")).hexdigest(),
|
||||
f"config is not plain JSON for fingerprinting: {exc}",
|
||||
)
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest(), None
|
||||
|
||||
|
||||
def _file_digest(path: Path) -> str | None:
|
||||
try:
|
||||
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _relative_posix(path: Path, root: Path) -> str | None:
|
||||
try:
|
||||
return path.resolve().relative_to(root.resolve()).as_posix()
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _manifest_assets(
|
||||
bundle: sdk_configs.SensorBundle,
|
||||
manifest_raw: Mapping[str, Any] | None,
|
||||
descriptor,
|
||||
diagnostics: list[str],
|
||||
) -> tuple[SensorAssetBinding, ...]:
|
||||
if manifest_raw is None:
|
||||
return ()
|
||||
|
||||
if descriptor is not None:
|
||||
# Descriptor-shaped manifest: bind the declared computational assets.
|
||||
bindings = []
|
||||
for declared in (descriptor.computational_assets, descriptor.assets):
|
||||
for role, value in sorted(declared.items()):
|
||||
rel = _relative_posix(Path(value), bundle.root)
|
||||
if rel is None:
|
||||
diagnostics.append(f"asset {role!r} escapes the bundle folder")
|
||||
continue
|
||||
bindings.append(SensorAssetBinding(str(role), rel))
|
||||
return tuple(bindings)
|
||||
|
||||
# Legacy manifest (for example ``sensors/finger/manifest.json``): bind the
|
||||
# declared asset directory and any single-file entries it names.
|
||||
bindings = []
|
||||
for role, value in sorted(manifest_raw.items()):
|
||||
if (
|
||||
role in _MANIFEST_META_KEYS
|
||||
or not isinstance(value, str)
|
||||
or not value.strip()
|
||||
):
|
||||
continue
|
||||
candidate = (bundle.root / Path(value)).resolve()
|
||||
rel = _relative_posix(candidate, bundle.root)
|
||||
if rel is None:
|
||||
diagnostics.append(f"manifest entry {role!r} escapes the bundle folder")
|
||||
continue
|
||||
bindings.append(SensorAssetBinding(str(role), rel))
|
||||
return tuple(bindings)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SensorAssetBinding:
|
||||
"""One bundle asset the instance was bound to: a role and a bundle-relative path."""
|
||||
|
||||
role: str
|
||||
path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SensorContext:
|
||||
"""The immutable identity/config/assets/calibration binding of one Sensor instance.
|
||||
|
||||
``sensor_id`` is only the **explicitly supplied** SN (``device_id``); it is
|
||||
``None`` when the caller did not bind one. The context never guesses an SN
|
||||
from enumeration order, bundle names or reserved camera indices - a frame
|
||||
that needs a stream identity therefore fails loudly instead of being
|
||||
attributed to the wrong device. ``calibration_path`` and
|
||||
``calibration_fingerprint`` describe the calibration currently in effect:
|
||||
``set_calibration(None)`` leaves them ``None``/``"none"`` even when the
|
||||
Sensor was constructed with an explicit path.
|
||||
"""
|
||||
|
||||
sensor_id: str | None
|
||||
product_family: str | None
|
||||
processing_backend: str
|
||||
config_name: str
|
||||
config_source: str | None
|
||||
config_fingerprint: str
|
||||
bundle_root: str | None
|
||||
manifest_digest: str | None
|
||||
assets: tuple[SensorAssetBinding, ...]
|
||||
calibration_path: str | None
|
||||
calibration_fingerprint: str
|
||||
diagnostics: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
def frame_identity(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
frame_id: int,
|
||||
source_frame_id: int | None,
|
||||
acquisition_id: str | None = None,
|
||||
sync_group_id: str | None = None,
|
||||
) -> FrameIdentity:
|
||||
"""Build this stream's frozen frame identity from the bound SN.
|
||||
|
||||
The capture clock is not part of ``SensorContext``: it belongs to the
|
||||
frame header and is produced with the frame. This method reuses
|
||||
:class:`~orisys.stream_contract.FrameIdentity` rather than defining a
|
||||
second identity shape.
|
||||
"""
|
||||
if self.sensor_id is None:
|
||||
raise ValueError(
|
||||
"SensorContext has no explicit sensor_id (SN); construct the Sensor "
|
||||
"with device_id (or bind one) before building a frame identity"
|
||||
)
|
||||
return FrameIdentity(
|
||||
sensor_id=self.sensor_id,
|
||||
session_id=session_id,
|
||||
frame_id=frame_id,
|
||||
source_frame_id=source_frame_id,
|
||||
acquisition_id=acquisition_id,
|
||||
sync_group_id=sync_group_id,
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""JSON-serialisable snapshot for records and diagnostics."""
|
||||
return {
|
||||
"sensor_id": self.sensor_id,
|
||||
"product_family": self.product_family,
|
||||
"processing_backend": self.processing_backend,
|
||||
"config_name": self.config_name,
|
||||
"config_source": self.config_source,
|
||||
"config_fingerprint": self.config_fingerprint,
|
||||
"bundle_root": self.bundle_root,
|
||||
"manifest_digest": self.manifest_digest,
|
||||
"assets": [
|
||||
{"role": asset.role, "path": asset.path} for asset in self.assets
|
||||
],
|
||||
"calibration_path": self.calibration_path,
|
||||
"calibration_fingerprint": self.calibration_fingerprint,
|
||||
"diagnostics": list(self.diagnostics),
|
||||
}
|
||||
|
||||
|
||||
def build_sensor_context(
|
||||
*,
|
||||
config_name: str | Path,
|
||||
config: Mapping[str, Any],
|
||||
device_id: str | None = None,
|
||||
calibration: Any = None,
|
||||
bundle_candidates: tuple[str, ...] | list[str] = (),
|
||||
) -> SensorContext:
|
||||
"""Bind one Sensor's SN, configuration identity, assets and calibration path.
|
||||
|
||||
Every lookup is explicit and local to this call; nothing is shared between
|
||||
instances. Optional bundle description problems are collected in
|
||||
``diagnostics`` and never raised, so adding a context cannot change whether a
|
||||
published Sensor can be constructed.
|
||||
"""
|
||||
diagnostics: list[str] = []
|
||||
cfg = config if isinstance(config, Mapping) else {}
|
||||
config_fingerprint, digest_problem = _json_digest(dict(cfg))
|
||||
if digest_problem:
|
||||
diagnostics.append(digest_problem)
|
||||
|
||||
config_source: str | None = None
|
||||
try:
|
||||
source = sdk_configs.resolve_config_source(config_name)
|
||||
except Exception as exc: # noqa: BLE001 - diagnostics only
|
||||
source = None
|
||||
diagnostics.append(f"config source could not be resolved: {exc}")
|
||||
if source is not None:
|
||||
config_source = str(Path(source).resolve())
|
||||
|
||||
names = [str(candidate) for candidate in bundle_candidates]
|
||||
product_family = next(
|
||||
(name for name in names if name in sdk_configs.PRODUCT_BUNDLES), None
|
||||
)
|
||||
bundle = None
|
||||
for name in names:
|
||||
try:
|
||||
bundle = sdk_configs.resolve_sensor_bundle(name)
|
||||
except Exception as exc: # noqa: BLE001 - diagnostics only
|
||||
diagnostics.append(f"bundle {name!r} could not be resolved: {exc}")
|
||||
continue
|
||||
if bundle is not None:
|
||||
break
|
||||
|
||||
processing_backend = backend_from_config(cfg)
|
||||
bundle_root: str | None = None
|
||||
manifest_digest: str | None = None
|
||||
assets: tuple[SensorAssetBinding, ...] = ()
|
||||
if bundle is not None:
|
||||
bundle_root = str(bundle.root)
|
||||
if product_family is None and bundle.name in sdk_configs.PRODUCT_BUNDLES:
|
||||
product_family = bundle.name
|
||||
manifest_raw: Mapping[str, Any] | None = None
|
||||
descriptor = None
|
||||
if bundle.manifest_json is not None:
|
||||
manifest_digest = _file_digest(bundle.manifest_json)
|
||||
if manifest_digest is None:
|
||||
diagnostics.append("bundle manifest.json is unreadable")
|
||||
try:
|
||||
loaded = json.loads(
|
||||
Path(bundle.manifest_json).read_text(encoding="utf-8-sig")
|
||||
)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
diagnostics.append(f"bundle manifest.json could not be read: {exc}")
|
||||
else:
|
||||
if isinstance(loaded, Mapping):
|
||||
manifest_raw = loaded
|
||||
if loaded.get("schema_version") is not None:
|
||||
try:
|
||||
descriptor = parse_descriptor(bundle.root)
|
||||
except Exception as exc: # noqa: BLE001 - reported, never fatal
|
||||
diagnostics.append(
|
||||
f"bundle manifest.json is not a valid descriptor: {exc}"
|
||||
)
|
||||
# Legacy manifests have no backend field; the config rule that
|
||||
# already drives create_frame_processor stays in force.
|
||||
else:
|
||||
diagnostics.append("bundle manifest.json is not a JSON object")
|
||||
if descriptor is not None:
|
||||
processing_backend = descriptor.processing_backend
|
||||
assets = _manifest_assets(bundle, manifest_raw, descriptor, diagnostics)
|
||||
else:
|
||||
diagnostics.append("no additive sensor bundle resolved for this config")
|
||||
|
||||
calibration_path: str | None = None
|
||||
calibration_fingerprint = "none"
|
||||
if calibration is not None:
|
||||
source_path = getattr(calibration, "source_path", None)
|
||||
if source_path is not None:
|
||||
calibration_path = str(Path(source_path).resolve())
|
||||
fingerprint = getattr(calibration, "fingerprint", None)
|
||||
if isinstance(fingerprint, str) and fingerprint:
|
||||
calibration_fingerprint = fingerprint
|
||||
else:
|
||||
calibration_fingerprint = "unknown"
|
||||
diagnostics.append(
|
||||
f"{type(calibration).__name__} exposes no content fingerprint"
|
||||
)
|
||||
|
||||
sensor_id = None
|
||||
if device_id is not None:
|
||||
text = str(device_id).strip()
|
||||
sensor_id = text or None
|
||||
if sensor_id is None:
|
||||
diagnostics.append("device_id was empty; no SN is bound")
|
||||
|
||||
return SensorContext(
|
||||
sensor_id=sensor_id,
|
||||
product_family=product_family,
|
||||
processing_backend=processing_backend,
|
||||
config_name=str(config_name),
|
||||
config_source=config_source,
|
||||
config_fingerprint=config_fingerprint,
|
||||
bundle_root=bundle_root,
|
||||
manifest_digest=manifest_digest,
|
||||
assets=assets,
|
||||
calibration_path=calibration_path,
|
||||
calibration_fingerprint=calibration_fingerprint,
|
||||
diagnostics=tuple(diagnostics),
|
||||
)
|
||||
|
||||
|
||||
def fallback_sensor_context(
|
||||
*,
|
||||
config_name: str | Path,
|
||||
config: Mapping[str, Any] | None,
|
||||
device_id: str | None,
|
||||
reason: BaseException,
|
||||
) -> SensorContext:
|
||||
"""A minimal context used when binding itself fails unexpectedly.
|
||||
|
||||
The Sensor keeps working; the failure and its cause are visible on the
|
||||
context instead of crashing a published construction path.
|
||||
"""
|
||||
config_fingerprint, _ = _json_digest(
|
||||
dict(config) if isinstance(config, Mapping) else {}
|
||||
)
|
||||
backend = (
|
||||
backend_from_config(config) if isinstance(config, Mapping) else "single_camera"
|
||||
)
|
||||
sensor_id = str(device_id).strip() if device_id is not None else None
|
||||
return SensorContext(
|
||||
sensor_id=sensor_id or None,
|
||||
product_family=None,
|
||||
processing_backend=backend,
|
||||
config_name=str(config_name),
|
||||
config_source=None,
|
||||
config_fingerprint=config_fingerprint,
|
||||
bundle_root=None,
|
||||
manifest_digest=None,
|
||||
assets=(),
|
||||
calibration_path=None,
|
||||
calibration_fingerprint="none",
|
||||
diagnostics=(f"context binding failed: {type(reason).__name__}: {reason}",),
|
||||
)
|
||||
@@ -366,6 +366,143 @@ class CausalMedianFilter:
|
||||
return np.median(self._buf, axis=0)
|
||||
|
||||
|
||||
class MultiAxisCausalFilter:
|
||||
"""Causal median → low-pass over a vector, one independent state per axis.
|
||||
|
||||
The force output needs the same chain twice (raw features and newtons), and
|
||||
an unavailable axis (NaN, or an axis a model never calibrated) must neither
|
||||
enter the filter history nor pull a valid axis towards zero. One
|
||||
:class:`CausalMedianFilter` and one :class:`LowpassFilter` per axis give that
|
||||
without a second implementation of either operator: callers keep one instance
|
||||
per processed triplet and feed a per-axis validity mask.
|
||||
|
||||
``median_window <= 1`` and ``lowpass_cutoff_hz <= 0`` each switch their own
|
||||
stage off; with both off the input is returned unchanged, NaN included.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
median_window: int = 1,
|
||||
lowpass_cutoff_hz: float = 0.0,
|
||||
sample_rate_hz: float = DEFAULT_SAMPLE_RATE_HZ,
|
||||
lowpass_order: int = DEFAULT_LOWPASS_ORDER,
|
||||
n_channels: int = 3,
|
||||
):
|
||||
self.n_channels = max(1, int(n_channels))
|
||||
try:
|
||||
self._median_window = max(1, int(median_window))
|
||||
except (TypeError, ValueError):
|
||||
self._median_window = 1
|
||||
self._lowpass_cutoff_hz = float(lowpass_cutoff_hz or 0.0)
|
||||
self._sample_rate_hz = float(sample_rate_hz or DEFAULT_SAMPLE_RATE_HZ)
|
||||
try:
|
||||
self._lowpass_order = max(1, int(lowpass_order))
|
||||
except (TypeError, ValueError):
|
||||
self._lowpass_order = DEFAULT_LOWPASS_ORDER
|
||||
self._medians: list[CausalMedianFilter] = []
|
||||
self._lowpasses: list[LowpassFilter] = []
|
||||
self._build()
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self._medians and self._medians[0].enabled) or bool(
|
||||
self._lowpasses and self._lowpasses[0].enabled
|
||||
)
|
||||
|
||||
@property
|
||||
def median_window(self) -> int:
|
||||
return int(self._medians[0].window) if self._medians else 1
|
||||
|
||||
@property
|
||||
def lowpass_cutoff_hz(self) -> float:
|
||||
return float(self._lowpasses[0].cutoff_hz) if self._lowpasses else 0.0
|
||||
|
||||
def configure(
|
||||
self,
|
||||
*,
|
||||
median_window: int | None = None,
|
||||
lowpass_cutoff_hz: float | None = None,
|
||||
sample_rate_hz: float | None = None,
|
||||
lowpass_order: int | None = None,
|
||||
) -> None:
|
||||
"""Re-tune the chain; every axis state is dropped (callers re-warm it)."""
|
||||
if median_window is not None:
|
||||
try:
|
||||
self._median_window = max(1, int(median_window))
|
||||
except (TypeError, ValueError):
|
||||
self._median_window = 1
|
||||
if lowpass_cutoff_hz is not None:
|
||||
self._lowpass_cutoff_hz = float(lowpass_cutoff_hz)
|
||||
if sample_rate_hz is not None:
|
||||
self._sample_rate_hz = float(sample_rate_hz)
|
||||
if lowpass_order is not None:
|
||||
try:
|
||||
self._lowpass_order = max(1, int(lowpass_order))
|
||||
except (TypeError, ValueError):
|
||||
self._lowpass_order = DEFAULT_LOWPASS_ORDER
|
||||
self._build()
|
||||
|
||||
def _build(self) -> None:
|
||||
self._medians = [
|
||||
CausalMedianFilter(window=self._median_window, n_channels=1)
|
||||
for _ in range(self.n_channels)
|
||||
]
|
||||
self._lowpasses = [
|
||||
LowpassFilter(
|
||||
cutoff_hz=self._lowpass_cutoff_hz,
|
||||
sample_rate_hz=self._sample_rate_hz,
|
||||
order=self._lowpass_order,
|
||||
n_channels=1,
|
||||
)
|
||||
for _ in range(self.n_channels)
|
||||
]
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Drop every axis' filter memory (new zero, model change, discontinuity)."""
|
||||
for median in self._medians:
|
||||
median.reset()
|
||||
for lowpass in self._lowpasses:
|
||||
lowpass.reset()
|
||||
|
||||
def process(self, values, valid=None) -> np.ndarray:
|
||||
"""Filter one vector; invalid axes stay NaN and do not advance their state."""
|
||||
x = np.asarray(values, dtype=np.float64).reshape(-1)
|
||||
if x.size != self.n_channels:
|
||||
raise ValueError(f"expected {self.n_channels} channels, got {x.size}")
|
||||
if valid is None:
|
||||
usable = np.isfinite(x)
|
||||
else:
|
||||
usable = np.asarray(valid, dtype=bool).reshape(-1) & np.isfinite(x)
|
||||
if usable.size != self.n_channels:
|
||||
raise ValueError(
|
||||
f"expected {self.n_channels} validity flags, got {usable.size}"
|
||||
)
|
||||
out = np.full(self.n_channels, np.nan, dtype=np.float64)
|
||||
for index in range(self.n_channels):
|
||||
if not usable[index]:
|
||||
continue
|
||||
value = self._medians[index].process(x[index : index + 1])
|
||||
out[index] = self._lowpasses[index].process(value)[0]
|
||||
return out
|
||||
|
||||
def state(self) -> dict:
|
||||
return {
|
||||
"median_window": self.median_window,
|
||||
"median_applied": bool(self._medians and self._medians[0].enabled),
|
||||
"lowpass_cutoff_hz": self.lowpass_cutoff_hz,
|
||||
"lowpass_applied": bool(self._lowpasses and self._lowpasses[0].enabled),
|
||||
"lowpass_order": (
|
||||
int(self._lowpasses[0].order) if self._lowpasses else DEFAULT_LOWPASS_ORDER
|
||||
),
|
||||
"lowpass_sample_rate_hz": (
|
||||
float(self._lowpasses[0].sample_rate_hz)
|
||||
if self._lowpasses
|
||||
else DEFAULT_SAMPLE_RATE_HZ
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class SampleRateEstimator:
|
||||
"""Robust online estimate of the rate at which :meth:`tick` is called.
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ class Stitcher:
|
||||
self.box_size = config_stitcher["box_size"]
|
||||
# Physical tile count for box detection (often 15); grid n is the active stitch layout.
|
||||
self.n_boxes_target = int(config_stitcher.get("n_boxes_target", 15))
|
||||
# (left, right) raw-frame pixels whose tiles are not counted (extra column).
|
||||
self.exclude_x = tuple(int(v) for v in config_stitcher.get("exclude_x", (0, 0)))
|
||||
|
||||
self._blend_coef_cache = {}
|
||||
self._feature_stitch_cache = {
|
||||
@@ -246,6 +248,7 @@ class Stitcher:
|
||||
ncol=self.ncol,
|
||||
show=show,
|
||||
n_boxes_target=self.n_boxes_target,
|
||||
exclude_x=self.exclude_x,
|
||||
)
|
||||
self.boxes = boxes
|
||||
# Detection target is physical unit count; active stitch grid is nrow*ncol via index.
|
||||
|
||||
@@ -72,8 +72,14 @@ def detect_nonblack_regions(
|
||||
show: bool = False,
|
||||
row_tol: float = 0.4,
|
||||
save_preview_path: str = "./config/compound_eye.png",
|
||||
exclude_x: Sequence[int] = (0, 0),
|
||||
) -> tuple[int, list[tuple[int, int, int, int]]]:
|
||||
"""Detect tile boxes; returns ``(n_boxes, boxes as x0,y0,x1,y1)``."""
|
||||
"""Detect tile boxes; returns ``(n_boxes, boxes as x0,y0,x1,y1)``.
|
||||
|
||||
``exclude_x`` is ``(left, right)`` in raw-frame pixels: a region whose centre
|
||||
lies that close to the left or right edge is ignored. A misaligned module can
|
||||
show an extra column of tiles; this keeps it out of the count.
|
||||
"""
|
||||
if img is None:
|
||||
raise ValueError("Input image is None.")
|
||||
|
||||
@@ -81,6 +87,7 @@ def detect_nonblack_regions(
|
||||
n_boxes = 0
|
||||
bk_thresh = 0
|
||||
boxes: list[tuple[int, int, int, int]] = []
|
||||
exclude_left, exclude_right = (int(value) for value in exclude_x)
|
||||
|
||||
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
|
||||
v = hsv[:, :, 2]
|
||||
@@ -110,6 +117,11 @@ def detect_nonblack_regions(
|
||||
max_area_frac=max_area_frac,
|
||||
max_aspect_ratio=max_aspect_ratio,
|
||||
)
|
||||
boxes = [
|
||||
box
|
||||
for box in boxes
|
||||
if exclude_left <= box[0] + box[2] / 2 <= w - exclude_right
|
||||
]
|
||||
n_boxes = len(boxes)
|
||||
bk_thresh += bk_thresh_step
|
||||
|
||||
@@ -193,6 +205,7 @@ def find_stitch_boxes(
|
||||
ncol: int,
|
||||
show: bool = False,
|
||||
n_boxes_target: int | None = None,
|
||||
exclude_x: Sequence[int] = (0, 0),
|
||||
):
|
||||
"""Run bloom correction + detection; return ``(n_boxes, boxes, grid_ok)``.
|
||||
|
||||
@@ -210,6 +223,7 @@ def find_stitch_boxes(
|
||||
n_boxes_target=target,
|
||||
show=show,
|
||||
row_tol=row_tol,
|
||||
exclude_x=exclude_x,
|
||||
)
|
||||
grid_ok = n_boxes == target
|
||||
return n_boxes, boxes, grid_ok
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
"""Frozen public types for the single-stream SensorStream contract.
|
||||
|
||||
This module is the stage-1A **type contract** (task T03): the frame identity,
|
||||
capture clock, measurement header and event envelope that the per-sensor stream
|
||||
(T08), the rebuilt force session (T06) and the recording/analysis consumers
|
||||
(T11, T13-T15) share. It deliberately does not change any existing class:
|
||||
``Sensor``, ``StreamPipeline`` and the current ``ForceSession`` keep their
|
||||
published behavior, and the later tasks adopt these headers instead of growing
|
||||
per-layer variants.
|
||||
|
||||
The contract rules are documented in ``docs/stream-contract.zh.md``. In short:
|
||||
|
||||
* identity is per stream - ``sensor_id`` + ``session_id`` + source/original
|
||||
frame number and a separate contiguous output number;
|
||||
* a group identity (``acquisition_id`` + ``sync_group_id``) is optional and is
|
||||
never fabricated: both parts are present or neither is;
|
||||
* a capture time is a number of nanoseconds plus the clock domain and the basis
|
||||
it was derived from; a raw source PTS is carried separately and is never
|
||||
replaced by processing time;
|
||||
* two frames with the same timestamp are still different frames (identity, not
|
||||
the timestamp, is the deduplication key);
|
||||
* ``source_dt_s`` is the source-time interval to the previous *actually
|
||||
processed* frame of the same stream (``None`` for the first) and never a
|
||||
processing-speed measurement;
|
||||
* events round-trip through JSON with a JSON-safe payload.
|
||||
|
||||
Versioning follows one strict rule (policy A): **any new, renamed, removed or
|
||||
re-typed field bumps ``STREAM_CONTRACT_VERSION``**. A reader rejects unknown
|
||||
fields and only reads the versions it lists in
|
||||
``SUPPORTED_CONTRACT_VERSIONS``; a writer that needs a field must bump the
|
||||
version and both sides must list it. Event ``kind`` values are not schema
|
||||
fields: consumers must ignore kinds they do not know, so new kinds stay
|
||||
additive within the same version.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Literal
|
||||
|
||||
__all__ = [
|
||||
"STREAM_CONTRACT_VERSION",
|
||||
"SUPPORTED_CONTRACT_VERSIONS",
|
||||
"TIME_BASES",
|
||||
"FrameClock",
|
||||
"FrameIdentity",
|
||||
"FrameStamp",
|
||||
"MeasurementFrame",
|
||||
"StreamEvent",
|
||||
"TimeBasis",
|
||||
]
|
||||
|
||||
#: Version of the serialized contract (``to_dict()``/``from_dict()``).
|
||||
#: Policy A: bump it on every field addition, removal, rename or meaning
|
||||
#: change; there are no same-version field additions.
|
||||
STREAM_CONTRACT_VERSION = "stream-contract/1"
|
||||
|
||||
#: Serialized schemas this reader accepts, and the only ones it may read.
|
||||
#: A writer that bumps ``STREAM_CONTRACT_VERSION`` must add the new version
|
||||
#: here (and in every reader that must read it); old versions stay listed as
|
||||
#: long as the loader supports them.
|
||||
SUPPORTED_CONTRACT_VERSIONS: tuple[str, ...] = (STREAM_CONTRACT_VERSION,)
|
||||
|
||||
#: How ``FrameClock.capture_time_ns`` was derived.
|
||||
#:
|
||||
#: ``capture_monotonic`` - host monotonic capture/receive time of a live source.
|
||||
#: ``recorded_signal`` - signal time stored by the recording (playback/debug).
|
||||
#: ``video_pts`` - a real container PTS converted to nanoseconds.
|
||||
#: ``estimated_source`` - derived from the source frame number and a nominal
|
||||
#: rate because no trustworthy PTS exists; consumers must treat it as estimated.
|
||||
TimeBasis = Literal[
|
||||
"capture_monotonic",
|
||||
"recorded_signal",
|
||||
"video_pts",
|
||||
"estimated_source",
|
||||
]
|
||||
|
||||
TIME_BASES: tuple[TimeBasis, ...] = (
|
||||
"capture_monotonic",
|
||||
"recorded_signal",
|
||||
"video_pts",
|
||||
"estimated_source",
|
||||
)
|
||||
|
||||
#: Event kinds are a stable lower_snake identifier; this grammar is part of the
|
||||
#: frozen envelope contract (unknown *values* stay compatible, malformed ones do
|
||||
#: not).
|
||||
_EVENT_KIND_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
||||
|
||||
|
||||
# --------------------------------------------------------------- validation
|
||||
def _require_str(value: Any, name: str) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"{name} must be a string")
|
||||
if not value:
|
||||
raise ValueError(f"{name} must not be empty")
|
||||
return value
|
||||
|
||||
|
||||
def _require_int(value: Any, name: str, *, minimum: int | None = None) -> int:
|
||||
# ``type`` rather than ``isinstance``: a bool is an int in Python but never a
|
||||
# frame number or generation.
|
||||
if type(value) is not int:
|
||||
raise TypeError(f"{name} must be an integer")
|
||||
if minimum is not None and value < minimum:
|
||||
raise ValueError(f"{name} must be >= {minimum}")
|
||||
return value
|
||||
|
||||
|
||||
def _require_optional_int(
|
||||
value: Any, name: str, *, minimum: int | None = None
|
||||
) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
return _require_int(value, name, minimum=minimum)
|
||||
|
||||
|
||||
def _require_finite_number(value: Any, name: str) -> int | float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise TypeError(f"{name} must be a real number")
|
||||
if not math.isfinite(float(value)):
|
||||
raise ValueError(f"{name} must be finite")
|
||||
return value
|
||||
|
||||
|
||||
def _require_time_basis(value: Any) -> TimeBasis:
|
||||
if value not in TIME_BASES:
|
||||
raise ValueError(
|
||||
f"capture_time_basis must be one of {list(TIME_BASES)}, got {value!r}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _require_source_dt(value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise TypeError("source_dt_s must be a real number or None")
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
raise ValueError("source_dt_s must be finite")
|
||||
if number < 0.0:
|
||||
raise ValueError("source_dt_s must be >= 0")
|
||||
return number
|
||||
|
||||
|
||||
# ----------------------------------------------------------- JSON payloads
|
||||
def _freeze_json(value: Any, path: str) -> Any:
|
||||
"""Validate a JSON-serializable value and return an immutable deep copy.
|
||||
|
||||
Only JSON types are accepted (``None``/bool/int/finite float/str/mapping
|
||||
with string keys/list/tuple). ``NaN`` and infinities are rejected because
|
||||
they do not survive ``json.dumps``/``json.loads`` unchanged.
|
||||
"""
|
||||
if value is None or isinstance(value, (bool, str)):
|
||||
return value
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
if not math.isfinite(value):
|
||||
raise ValueError(f"{path} contains a non-finite float")
|
||||
return value
|
||||
if isinstance(value, Mapping):
|
||||
frozen: dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
if not isinstance(key, str):
|
||||
raise TypeError(
|
||||
f"{path} keys must be strings, got {type(key).__name__}"
|
||||
)
|
||||
frozen[key] = _freeze_json(item, f"{path}.{key}")
|
||||
return MappingProxyType(frozen)
|
||||
if isinstance(value, (list, tuple)):
|
||||
return tuple(
|
||||
_freeze_json(item, f"{path}[{index}]") for index, item in enumerate(value)
|
||||
)
|
||||
raise TypeError(f"{path} contains non-JSON value {type(value).__name__}")
|
||||
|
||||
|
||||
def _thaw_json(value: Any) -> Any:
|
||||
if isinstance(value, Mapping):
|
||||
return {key: _thaw_json(item) for key, item in value.items()}
|
||||
if isinstance(value, tuple):
|
||||
return [_thaw_json(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _mapping(
|
||||
data: Any, name: str, allowed: frozenset[str], required: frozenset[str]
|
||||
) -> Mapping:
|
||||
"""Read one serialized object under policy A (strict field set).
|
||||
|
||||
Unknown fields are rejected, not ignored: a same-version record may only
|
||||
contain the fields of that frozen version. A new field therefore requires a
|
||||
new ``STREAM_CONTRACT_VERSION`` and an entry in
|
||||
``SUPPORTED_CONTRACT_VERSIONS``; components without a schema (identity and
|
||||
clock) follow the same allow-list rule through their owners.
|
||||
"""
|
||||
if not isinstance(data, Mapping):
|
||||
raise TypeError(f"{name} must be a mapping")
|
||||
unknown = set(data) - allowed
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"{name} has unknown field(s) {sorted(unknown)}; adding a field "
|
||||
"requires a new STREAM_CONTRACT_VERSION"
|
||||
)
|
||||
missing = required - set(data)
|
||||
if missing:
|
||||
raise ValueError(f"{name} is missing field(s) {sorted(missing)}")
|
||||
return data
|
||||
|
||||
|
||||
def _require_schema(value: Any, name: str) -> str:
|
||||
"""Accept only the serialized versions this reader explicitly lists."""
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"{name}.schema must be a string")
|
||||
if value not in SUPPORTED_CONTRACT_VERSIONS:
|
||||
raise ValueError(
|
||||
f"{name}.schema must be one of {list(SUPPORTED_CONTRACT_VERSIONS)}, "
|
||||
f"got {value!r}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
# ------------------------------------------------------------- frame types
|
||||
@dataclass(frozen=True)
|
||||
class FrameIdentity:
|
||||
"""Identity of one frame in one stream.
|
||||
|
||||
``source_frame_id`` keeps the source's own frame number (``None`` when the
|
||||
source does not provide one); ``frame_id`` is the contiguous output number
|
||||
of this stream. Both are needed: the same timestamp does not identify a
|
||||
frame, and two different source frames may share a PTS.
|
||||
|
||||
``acquisition_id``/``sync_group_id`` describe an optional capture group
|
||||
(for example one hardware trigger spanning several sensors). They are
|
||||
both present or both ``None``; a missing group is not replaced with a
|
||||
fabricated value and does not imply synchronization.
|
||||
"""
|
||||
|
||||
sensor_id: str
|
||||
session_id: str
|
||||
frame_id: int
|
||||
source_frame_id: int | None
|
||||
acquisition_id: str | None = None
|
||||
sync_group_id: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_str(self.sensor_id, "sensor_id")
|
||||
_require_str(self.session_id, "session_id")
|
||||
_require_int(self.frame_id, "frame_id", minimum=0)
|
||||
_require_optional_int(self.source_frame_id, "source_frame_id", minimum=0)
|
||||
if (self.acquisition_id is None) != (self.sync_group_id is None):
|
||||
raise ValueError(
|
||||
"group identity is optional but must be complete: set both "
|
||||
"acquisition_id and sync_group_id, or leave both None"
|
||||
)
|
||||
if self.acquisition_id is not None:
|
||||
_require_str(self.acquisition_id, "acquisition_id")
|
||||
_require_str(self.sync_group_id, "sync_group_id")
|
||||
|
||||
@property
|
||||
def has_group(self) -> bool:
|
||||
"""Whether this frame carries a capture-group identity."""
|
||||
return self.acquisition_id is not None
|
||||
|
||||
@property
|
||||
def group_identity(self) -> tuple[str, str] | None:
|
||||
"""``(acquisition_id, sync_group_id)``, or ``None`` when absent."""
|
||||
if self.acquisition_id is None:
|
||||
return None
|
||||
return (str(self.acquisition_id), str(self.sync_group_id))
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"sensor_id": self.sensor_id,
|
||||
"session_id": self.session_id,
|
||||
"frame_id": self.frame_id,
|
||||
"source_frame_id": self.source_frame_id,
|
||||
"acquisition_id": self.acquisition_id,
|
||||
"sync_group_id": self.sync_group_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping) -> FrameIdentity:
|
||||
allowed = frozenset(
|
||||
{
|
||||
"sensor_id",
|
||||
"session_id",
|
||||
"frame_id",
|
||||
"source_frame_id",
|
||||
"acquisition_id",
|
||||
"sync_group_id",
|
||||
}
|
||||
)
|
||||
required = frozenset({"sensor_id", "session_id", "frame_id", "source_frame_id"})
|
||||
fields = _mapping(data, "FrameIdentity", allowed, required)
|
||||
return cls(
|
||||
sensor_id=fields["sensor_id"],
|
||||
session_id=fields["session_id"],
|
||||
frame_id=fields["frame_id"],
|
||||
source_frame_id=fields["source_frame_id"],
|
||||
acquisition_id=fields.get("acquisition_id"),
|
||||
sync_group_id=fields.get("sync_group_id"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrameClock:
|
||||
"""Capture time of one frame, with its clock domain and time basis.
|
||||
|
||||
``capture_time_ns`` is always nanoseconds, but what it means is declared by
|
||||
``capture_time_basis``; ``clock_id`` names the clock domain (for example
|
||||
``host_monotonic`` or a source/video clock identity). ``source_pts`` keeps
|
||||
the source's own presentation timestamp untouched (its unit is the source's
|
||||
declared timebase); it is never replaced by processing time or by an
|
||||
estimated value. A result's *published* time is recorded separately by the
|
||||
result envelope, not here.
|
||||
"""
|
||||
|
||||
capture_time_ns: int
|
||||
clock_id: str
|
||||
capture_time_basis: TimeBasis
|
||||
source_pts: int | float | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_int(self.capture_time_ns, "capture_time_ns", minimum=0)
|
||||
_require_str(self.clock_id, "clock_id")
|
||||
_require_time_basis(self.capture_time_basis)
|
||||
if self.source_pts is not None:
|
||||
_require_finite_number(self.source_pts, "source_pts")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"capture_time_ns": self.capture_time_ns,
|
||||
"clock_id": self.clock_id,
|
||||
"capture_time_basis": self.capture_time_basis,
|
||||
"source_pts": self.source_pts,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping) -> FrameClock:
|
||||
allowed = frozenset(
|
||||
{"capture_time_ns", "clock_id", "capture_time_basis", "source_pts"}
|
||||
)
|
||||
required = frozenset({"capture_time_ns", "clock_id", "capture_time_basis"})
|
||||
fields = _mapping(data, "FrameClock", allowed, required)
|
||||
return cls(
|
||||
capture_time_ns=fields["capture_time_ns"],
|
||||
clock_id=fields["clock_id"],
|
||||
capture_time_basis=fields["capture_time_basis"],
|
||||
source_pts=fields.get("source_pts"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrameStamp:
|
||||
"""Per-stream frame identity plus capture clock; the shared frame header.
|
||||
|
||||
The flat fields are the serialized contract. ``identity`` and ``clock``
|
||||
expose the two components for code that wants to carry them independently.
|
||||
"""
|
||||
|
||||
sensor_id: str
|
||||
session_id: str
|
||||
frame_id: int
|
||||
source_frame_id: int | None
|
||||
capture_time_ns: int
|
||||
clock_id: str
|
||||
capture_time_basis: TimeBasis
|
||||
source_pts: int | float | None = None
|
||||
acquisition_id: str | None = None
|
||||
sync_group_id: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Validate through the components so every frame header obeys the same
|
||||
# rules as a separately supplied identity/clock pair.
|
||||
self.identity
|
||||
self.clock
|
||||
|
||||
@classmethod
|
||||
def from_parts(cls, identity: FrameIdentity, clock: FrameClock) -> FrameStamp:
|
||||
"""Build a stamp from its two component types."""
|
||||
if not isinstance(identity, FrameIdentity):
|
||||
raise TypeError("identity must be a FrameIdentity")
|
||||
if not isinstance(clock, FrameClock):
|
||||
raise TypeError("clock must be a FrameClock")
|
||||
return cls(
|
||||
sensor_id=identity.sensor_id,
|
||||
session_id=identity.session_id,
|
||||
frame_id=identity.frame_id,
|
||||
source_frame_id=identity.source_frame_id,
|
||||
capture_time_ns=clock.capture_time_ns,
|
||||
clock_id=clock.clock_id,
|
||||
capture_time_basis=clock.capture_time_basis,
|
||||
source_pts=clock.source_pts,
|
||||
acquisition_id=identity.acquisition_id,
|
||||
sync_group_id=identity.sync_group_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def identity(self) -> FrameIdentity:
|
||||
return FrameIdentity(
|
||||
sensor_id=self.sensor_id,
|
||||
session_id=self.session_id,
|
||||
frame_id=self.frame_id,
|
||||
source_frame_id=self.source_frame_id,
|
||||
acquisition_id=self.acquisition_id,
|
||||
sync_group_id=self.sync_group_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def clock(self) -> FrameClock:
|
||||
return FrameClock(
|
||||
capture_time_ns=self.capture_time_ns,
|
||||
clock_id=self.clock_id,
|
||||
capture_time_basis=self.capture_time_basis,
|
||||
source_pts=self.source_pts,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": STREAM_CONTRACT_VERSION,
|
||||
**self.identity.to_dict(),
|
||||
**self.clock.to_dict(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping) -> FrameStamp:
|
||||
allowed = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"sensor_id",
|
||||
"session_id",
|
||||
"frame_id",
|
||||
"source_frame_id",
|
||||
"capture_time_ns",
|
||||
"clock_id",
|
||||
"capture_time_basis",
|
||||
"source_pts",
|
||||
"acquisition_id",
|
||||
"sync_group_id",
|
||||
}
|
||||
)
|
||||
required = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"sensor_id",
|
||||
"session_id",
|
||||
"frame_id",
|
||||
"source_frame_id",
|
||||
"capture_time_ns",
|
||||
"clock_id",
|
||||
"capture_time_basis",
|
||||
}
|
||||
)
|
||||
fields = _mapping(data, "FrameStamp", allowed, required)
|
||||
_require_schema(fields["schema"], "FrameStamp")
|
||||
return cls(
|
||||
sensor_id=fields["sensor_id"],
|
||||
session_id=fields["session_id"],
|
||||
frame_id=fields["frame_id"],
|
||||
source_frame_id=fields["source_frame_id"],
|
||||
capture_time_ns=fields["capture_time_ns"],
|
||||
clock_id=fields["clock_id"],
|
||||
capture_time_basis=fields["capture_time_basis"],
|
||||
source_pts=fields.get("source_pts"),
|
||||
acquisition_id=fields.get("acquisition_id"),
|
||||
sync_group_id=fields.get("sync_group_id"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MeasurementFrame:
|
||||
"""Minimal measurement header handed from the image block to force session.
|
||||
|
||||
``reference_generation`` is the optical-flow reference version that was in
|
||||
effect when this frame's flow was computed (the image thread writes it; it
|
||||
is the ``FrameSnapshot.tracker_reset_seq``/``request_reset`` target concept
|
||||
from stage 0). ``source_dt_s`` is the source-time distance to the previous
|
||||
*actually processed* frame of this stream, or ``None`` for the first one.
|
||||
A long interval is data for a quality marker and a warning; it never resets
|
||||
a reference, filter or zero by itself.
|
||||
|
||||
The measurement payload (flow/HHD/field/contact) and the force result are
|
||||
defined by T05/T06 and the result envelope; this header is the part frozen
|
||||
now.
|
||||
"""
|
||||
|
||||
stamp: FrameStamp
|
||||
source_dt_s: float | None
|
||||
reference_generation: int = 0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.stamp, FrameStamp):
|
||||
raise TypeError("stamp must be a FrameStamp")
|
||||
_require_source_dt(self.source_dt_s)
|
||||
_require_int(self.reference_generation, "reference_generation", minimum=0)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": STREAM_CONTRACT_VERSION,
|
||||
"stamp": self.stamp.to_dict(),
|
||||
"source_dt_s": None
|
||||
if self.source_dt_s is None
|
||||
else float(self.source_dt_s),
|
||||
"reference_generation": self.reference_generation,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping) -> MeasurementFrame:
|
||||
allowed = frozenset({"schema", "stamp", "source_dt_s", "reference_generation"})
|
||||
required = frozenset({"schema", "stamp", "source_dt_s"})
|
||||
fields = _mapping(data, "MeasurementFrame", allowed, required)
|
||||
_require_schema(fields["schema"], "MeasurementFrame")
|
||||
return cls(
|
||||
stamp=FrameStamp.from_dict(fields["stamp"]),
|
||||
source_dt_s=fields["source_dt_s"],
|
||||
reference_generation=fields.get("reference_generation", 0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StreamEvent:
|
||||
"""One serializable event of a stream's lifecycle.
|
||||
|
||||
``kind`` is a stable lower_snake identifier matching
|
||||
``^[a-z][a-z0-9_]*$`` (for example ``reference_reset``,
|
||||
``zero_requested``, ``source_gap``); consumers must ignore kinds they do not
|
||||
know instead of failing, so new kinds are additive. ``stamp`` is the frame
|
||||
the event belongs to when there is one. ``payload`` must be JSON-safe and is
|
||||
frozen/validated on construction; the frozen payload is excluded from the
|
||||
hash so equal events hash equally while ``hash(event)`` stays usable.
|
||||
``event_version`` versions the envelope itself (``STREAM_CONTRACT_VERSION``
|
||||
versions the field contract).
|
||||
"""
|
||||
|
||||
kind: str
|
||||
stamp: FrameStamp | None = None
|
||||
payload: Mapping[str, Any] = field(default_factory=dict, hash=False)
|
||||
event_version: int = 1
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_str(self.kind, "kind")
|
||||
if _EVENT_KIND_RE.fullmatch(self.kind) is None:
|
||||
raise ValueError(f"kind must match ^[a-z][a-z0-9_]*$, got {self.kind!r}")
|
||||
if self.stamp is not None and not isinstance(self.stamp, FrameStamp):
|
||||
raise TypeError("stamp must be a FrameStamp or None")
|
||||
_require_int(self.event_version, "event_version", minimum=1)
|
||||
if not isinstance(self.payload, Mapping):
|
||||
raise TypeError("payload must be a mapping")
|
||||
object.__setattr__(self, "payload", _freeze_json(self.payload, "payload"))
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": STREAM_CONTRACT_VERSION,
|
||||
"kind": self.kind,
|
||||
"event_version": self.event_version,
|
||||
"stamp": None if self.stamp is None else self.stamp.to_dict(),
|
||||
"payload": _thaw_json(self.payload),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping) -> StreamEvent:
|
||||
allowed = frozenset({"schema", "kind", "event_version", "stamp", "payload"})
|
||||
required = frozenset({"schema", "kind"})
|
||||
fields = _mapping(data, "StreamEvent", allowed, required)
|
||||
_require_schema(fields["schema"], "StreamEvent")
|
||||
stamp = fields.get("stamp")
|
||||
return cls(
|
||||
kind=fields["kind"],
|
||||
stamp=None if stamp is None else FrameStamp.from_dict(stamp),
|
||||
payload=fields.get("payload") or {},
|
||||
event_version=fields.get("event_version", 1),
|
||||
)
|
||||
@@ -306,6 +306,18 @@ def test_device_calibration_rejects_wrong_identity_and_pipeline(tmp_path: Path):
|
||||
load_for_device(root, "box", "BOX-001", pipeline={"dis_mode": 1})
|
||||
|
||||
|
||||
def test_device_calibration_accepts_only_downsample_difference(tmp_path: Path):
|
||||
path = _device_file(tmp_path, "BOX-001")
|
||||
doc = json.loads(path.read_text(encoding="utf-8"))
|
||||
doc["pipeline"] = {"dis_mode": 1, "hhd_downsample": 3}
|
||||
path.write_text(json.dumps(doc), encoding="utf-8")
|
||||
assert load_for_device(
|
||||
tmp_path, "box", "BOX-001", pipeline={"dis_mode": 1, "hhd_downsample": 2},
|
||||
) is not None
|
||||
with pytest.raises(CalibrationError, match="dis_mode"):
|
||||
load_for_device(tmp_path, "box", "BOX-001", pipeline={"dis_mode": 9, "hhd_downsample": 2})
|
||||
|
||||
|
||||
def test_device_calibration_rejects_partial_axes_and_unsafe_id(tmp_path: Path):
|
||||
root = tmp_path / "calibrations"
|
||||
root.mkdir()
|
||||
@@ -616,3 +628,85 @@ def test_every_preset_reports_what_it_does(
|
||||
assert fp["measurement"]["flow_ema_alpha"] == pytest.approx(alpha)
|
||||
assert fp["readout"]["force_median_window"] == median
|
||||
assert fp["readout"]["force_lowpass_cutoff_hz"] == pytest.approx(cutoff)
|
||||
|
||||
|
||||
# ------------------------------------------------- session reference adapter
|
||||
def _nonlinear_scalar_calibration(schema_version: int = 2):
|
||||
return parse(
|
||||
{
|
||||
"schema_version": schema_version,
|
||||
"unit": "N",
|
||||
"channels": {
|
||||
"normal": {
|
||||
"method": "piecewise_linear",
|
||||
"points": [[0.0, 0.0], [100.0, 1.0], [200.0, 3.0]],
|
||||
"extrapolate": True,
|
||||
"clamp_output_min": None,
|
||||
},
|
||||
"shear_x": {
|
||||
"method": "piecewise_linear",
|
||||
"signed": True,
|
||||
"input": "components",
|
||||
"points": [[0.0, 0.0], [100.0, 2.0]],
|
||||
"extrapolate": True,
|
||||
"clamp_output_min": None,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("schema_version", [1, 2])
|
||||
def test_convert_referenced_subtracts_the_session_baseline_once(schema_version: int):
|
||||
calibration = _nonlinear_scalar_calibration(schema_version)
|
||||
curve = calibration.get("fn")
|
||||
assert curve is not None
|
||||
reference = {"fn": 150.0}
|
||||
converted = calibration.convert_referenced((250.0, 0.0, 0.0), reference)
|
||||
# curve(250) - curve(150) = 4.0 - 2.0: one subtraction, and the curve's own
|
||||
# offset/zero point is not removed a second time.
|
||||
assert converted[0] == pytest.approx(4.0 - 2.0)
|
||||
assert converted[0] != pytest.approx(4.0 - 2.0 * 2.0)
|
||||
# A raw-domain delta would convert 250-150 = 100 (1.0) instead of 4.0-2.0.
|
||||
assert converted[0] != pytest.approx(1.0)
|
||||
# The historical entry point is unchanged.
|
||||
assert calibration.convert(250.0, 0.0, 0.0)[0] == pytest.approx(4.0)
|
||||
|
||||
|
||||
def test_convert_referenced_keeps_signed_shear_and_missing_axes_unavailable():
|
||||
calibration = _nonlinear_scalar_calibration()
|
||||
converted = calibration.convert_referenced(
|
||||
(250.0, -50.0, 12.0), {"fn": 150.0, "fx": 10.0}
|
||||
)
|
||||
assert converted[0] == pytest.approx(2.0)
|
||||
# signed curve: convert_component(-50) - convert_component(10) = -1.0 - 0.2.
|
||||
assert converted[1] == pytest.approx(-1.2)
|
||||
# FY has no curve, no reference: unavailable, never a zero.
|
||||
assert converted[2] is None
|
||||
missing_reference = calibration.convert_referenced((250.0, 0.0, 0.0), None)
|
||||
assert missing_reference == (None, None, None)
|
||||
invalid_raw = calibration.convert_referenced(
|
||||
(float("nan"), 0.0, 0.0), {"fn": 150.0}
|
||||
)
|
||||
assert invalid_raw[0] is None
|
||||
|
||||
|
||||
def test_legacy_fingerprint_is_content_identity():
|
||||
calibration = _nonlinear_scalar_calibration()
|
||||
assert len(calibration.fingerprint) == 64
|
||||
assert calibration.fingerprint == _nonlinear_scalar_calibration().fingerprint
|
||||
changed = parse(
|
||||
{
|
||||
"schema_version": 2,
|
||||
"unit": "N",
|
||||
"channels": {
|
||||
"normal": {
|
||||
"method": "piecewise_linear",
|
||||
"points": [[0.0, 0.0], [100.0, 1.0], [200.0, 4.0]],
|
||||
"extrapolate": True,
|
||||
"clamp_output_min": None,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
assert changed.fingerprint != calibration.fingerprint
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Feature identities use effective measurement settings, never display metadata."""
|
||||
|
||||
from copy import deepcopy
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
@@ -57,3 +58,77 @@ def test_domain_fingerprint_binds_map_content_not_only_dimensions():
|
||||
changed[key].flat[0] = not changed[key].flat[0] if key == "valid_mask" else 1
|
||||
assert domain_fingerprint(changed) != original
|
||||
assert domain_fingerprint({key: value.copy() for key, value in domain.items()}) == original
|
||||
|
||||
|
||||
def _model(data):
|
||||
from orisys.calibration import parse
|
||||
|
||||
return parse({
|
||||
"schema_version": 3, "unit": "N",
|
||||
"identity": {"sensor_type": "box", "sn": "A1"},
|
||||
"pipeline": build_context(data),
|
||||
"model": {
|
||||
"type": "global_gain/1", "input_profile": "force_delta/1",
|
||||
"output_frame": "sdk_force/1", "outputs": ["fn"],
|
||||
"params": {"gains_N_per_raw": {"fn": .001}, "valid_force_range_N": [0, 3]},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("downsample", [1, 2, 4])
|
||||
def test_downsample_only_difference_accepts_existing_model_without_rewriting_it(downsample):
|
||||
from orisys.calibration import validate_for_device
|
||||
|
||||
data = config()
|
||||
data["tracker"]["hhd_downsample"] = 3
|
||||
model = _model(data)
|
||||
original = model.to_dict()
|
||||
fingerprint = model.fingerprint
|
||||
data["tracker"]["hhd_downsample"] = downsample
|
||||
actual = build_context(data)
|
||||
assert json.loads(json.dumps(actual)) == actual
|
||||
assert actual["feature_signature_sha256"] != model.pipeline["feature_signature_sha256"]
|
||||
validate_for_device(model, "box", "A1", pipeline=actual)
|
||||
model.validate_for_context(actual)
|
||||
assert model.to_dict() == original
|
||||
assert model.fingerprint == fingerprint
|
||||
assert actual["hhd_downsample"] == downsample
|
||||
|
||||
|
||||
def test_runtime_context_binds_a_different_downsample_model_and_predicts():
|
||||
from orisys.force_readout import ForceReadout
|
||||
from orisys.sensor import Sensor
|
||||
|
||||
data = config()
|
||||
data["tracker"]["hhd_downsample"] = 3
|
||||
sensor = Sensor.__new__(Sensor)
|
||||
sensor._config = data
|
||||
sensor.calibration = _model(data)
|
||||
sensor._force_raw = (100.0, 0.0, 0.0)
|
||||
sensor._force_readout = ForceReadout({})
|
||||
sensor._model_readouts = {}
|
||||
sensor._reset_calibration_session()
|
||||
data["tracker"]["hhd_downsample"] = 2
|
||||
sensor.bind_calibration_context(build_context(data))
|
||||
sensor.set_calibration_reference({"fn": 100.0}, reference_id="unloaded")
|
||||
result = sensor.apply_calibration_frame(
|
||||
frame_id=1, timestamp_s=0.1, raw=(120.0, 0.0, 0.0), contact_found=True,
|
||||
)
|
||||
assert result.values_N["fn"] == pytest.approx(.02)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("section,key,value", [
|
||||
("camera", "resolution_raw", [640, 480]),
|
||||
("tracker", "dis_mode", 1),
|
||||
("tracker", "flow_iterations", 99),
|
||||
("contact", "threshold", .9),
|
||||
("filters", "flow_ema_alpha", .5),
|
||||
])
|
||||
def test_downsample_tolerance_does_not_hide_other_pipeline_changes(section, key, value):
|
||||
data = config()
|
||||
data["tracker"]["hhd_downsample"] = 3
|
||||
model = _model(data)
|
||||
data["tracker"]["hhd_downsample"] = 2
|
||||
data.setdefault(section, {})[key] = value
|
||||
with pytest.raises(ValueError, match="pipeline mismatch"):
|
||||
model.validate_for_context(build_context(data))
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""``resolve_config_source`` and ``load_config`` must agree on what is read.
|
||||
|
||||
``SensorContext.config_source`` promises the JSON file that was actually loaded.
|
||||
That promise only holds while the recorded lookup and the loading lookup are the
|
||||
same implementation, so this module checks it against every installed config
|
||||
name, the alias table, an explicit path and the legacy ``"name"`` field, and
|
||||
pins the preserved error types/messages for a missing or non-JSON input.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys import configs
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SENSORS = REPO_ROOT / "sensors"
|
||||
|
||||
|
||||
def _read(path: str | Path) -> dict:
|
||||
return json.loads(Path(path).read_text(encoding="utf-8-sig"))
|
||||
|
||||
|
||||
def test_every_listed_name_resolves_to_the_file_load_config_reads() -> None:
|
||||
names = configs.list_config()
|
||||
assert names
|
||||
for name in names:
|
||||
source = configs.resolve_config_source(name)
|
||||
assert source is not None, name
|
||||
assert _read(source) == configs.load_config(name), name
|
||||
# The recorded source can be loaded directly with the same result.
|
||||
assert configs.load_config(source) == configs.load_config(name), name
|
||||
|
||||
|
||||
def test_aliases_resolve_like_loading() -> None:
|
||||
aliases = dict(configs.CONFIG_ALIASES)
|
||||
assert aliases
|
||||
for alias, canonical in aliases.items():
|
||||
source = configs.resolve_config_source(alias)
|
||||
assert source is not None, alias
|
||||
assert _read(source) == configs.load_config(alias), alias
|
||||
canonical_source = configs.resolve_config_source(canonical)
|
||||
if canonical_source is not None:
|
||||
assert source == canonical_source, alias
|
||||
|
||||
|
||||
def test_explicit_paths_resolve_to_themselves(tmp_path: Path) -> None:
|
||||
bundle_config = SENSORS / "box" / "sensor.json"
|
||||
assert configs.resolve_config_source(bundle_config) == bundle_config.resolve()
|
||||
assert configs.load_config(bundle_config) == configs.load_config("box")
|
||||
|
||||
copied = tmp_path / "copied-config.json"
|
||||
copied.write_text(bundle_config.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
assert configs.resolve_config_source(copied) == copied.resolve()
|
||||
assert configs.load_config(copied) == configs.load_config(bundle_config)
|
||||
|
||||
|
||||
def test_legacy_name_field_scan_agrees_with_loading() -> None:
|
||||
"""A config's JSON ``"name"`` may differ from its file stem (``prototype``)."""
|
||||
found = False
|
||||
for name in configs.list_config():
|
||||
source = configs.resolve_config_source(name)
|
||||
if source is None:
|
||||
continue
|
||||
field = _read(source).get("name")
|
||||
if (
|
||||
isinstance(field, str)
|
||||
and field
|
||||
and field not in configs.list_config()
|
||||
):
|
||||
resolved = configs.resolve_config_source(field)
|
||||
assert resolved is not None, field
|
||||
assert _read(resolved) == configs.load_config(field), field
|
||||
found = True
|
||||
assert found, "expected at least one legacy name field in the installed configs"
|
||||
|
||||
|
||||
def test_missing_and_non_json_inputs_keep_their_error_contract(tmp_path: Path) -> None:
|
||||
assert configs.resolve_config_source("no-such-config-name") is None
|
||||
with pytest.raises(FileNotFoundError, match="Input config file not found"):
|
||||
configs.load_config("no-such-config-name")
|
||||
|
||||
absolute_missing = tmp_path / "missing.json"
|
||||
assert configs.resolve_config_source(absolute_missing) is None
|
||||
with pytest.raises(FileNotFoundError, match="not found at absolute path"):
|
||||
configs.load_config(absolute_missing)
|
||||
|
||||
not_json = tmp_path / "config.txt"
|
||||
not_json.write_text("{}", encoding="utf-8")
|
||||
# The source lookup names the existing file; loading still refuses a non-JSON
|
||||
# file with the historical ValueError instead of silently reading it.
|
||||
assert configs.resolve_config_source(not_json) == not_json.resolve()
|
||||
with pytest.raises(ValueError, match="must be a JSON file"):
|
||||
configs.load_config(not_json)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Keep the shipped Finger compute assets aligned with the verified Studio calibration."""
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys.calibration_context import domain_fingerprint
|
||||
from orisys.finger.runtime import resolve_finger_geometry
|
||||
|
||||
|
||||
def test_shipped_finger_assets_match_the_verified_calibration_geometry():
|
||||
root = Path(__file__).resolve().parents[1] / "sensors" / "finger"
|
||||
assets = root / "assets" / "30fps-0703"
|
||||
# Recorded by the 2026-10-17 calibration run; changing this bundle requires revalidation.
|
||||
expected = {
|
||||
"mesh.npz": "fc47c438c745ada2c27775a0437fcbe8bfe8fbab354abdae390a07f7a26b78ac",
|
||||
"camera_intrinsics.npz": "c8e2b1fbd89c1cf514186dd69b10f38ee73d0de1d41f0d1b40018e2650f939e3",
|
||||
"world_to_cam.npy": "f8cb4109b5a1585833da0845c3644a9554eea55cc38854bf2e6b196e011a5506",
|
||||
"unroll_map_uv_s0.42.npz": "397fada8a584b03327ec62eebc179f7b6406b7f9cc35248e8f3d0a068873f0c8",
|
||||
}
|
||||
for name, digest in expected.items():
|
||||
assert hashlib.sha256((assets / name).read_bytes()).hexdigest() == digest, name
|
||||
with np.load(assets / "unroll_map_uv_s0.42.npz", allow_pickle=False) as archive:
|
||||
domain = {key: archive[key] for key in archive.files}
|
||||
assert domain_fingerprint(domain) == "2a76d8e797c7400f734d141b21fec5d9e65a02db418e0acacc36faf7c5ac8dbc"
|
||||
geometry = resolve_finger_geometry(export_dir=assets, config=str(root / "sensor.json"))
|
||||
assert geometry.source_scale == .42
|
||||
assert (geometry.out_width, geometry.out_height) == (280, 300)
|
||||
@@ -0,0 +1,219 @@
|
||||
"""HHD downsample compatibility diagnostics and non-divisible grid integration.
|
||||
|
||||
The pipeline contract allows a model calibrated at one ``hhd_downsample`` to run at
|
||||
another, but the difference is an accuracy risk that must be visible instead of a
|
||||
silent pass. These tests pin the report, the unchanged refusal of every other
|
||||
identity mismatch, and the analytic block integration on grids that do not divide
|
||||
the output image evenly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys.calibration import CalibrationError, parse
|
||||
from orisys.calibration_context import build_context
|
||||
from orisys.calibration_models import ModelCalibration
|
||||
from orisys.field_calibration import FieldFrame, _block_starts, build_field_gain_map, reduce_field
|
||||
from orisys.force_readout import OutputSettings
|
||||
from orisys.force_session import ForceSession, FrameStamp
|
||||
from orisys.sensor import FrameMeasurement
|
||||
|
||||
FRAME = np.zeros((2, 2, 3), dtype=np.uint8)
|
||||
|
||||
|
||||
def _config(downsample=1, *, dis_mode=4):
|
||||
return {
|
||||
"camera": {"resolution_raw": [1280, 720], "resolution_stitch": [700, 750]},
|
||||
"tracker": {"dis_mode": dis_mode, "hhd_downsample": downsample},
|
||||
}
|
||||
|
||||
|
||||
def _document(config) -> dict:
|
||||
return {
|
||||
"schema_version": 3,
|
||||
"unit": "N",
|
||||
"identity": {"sensor_type": "box", "sn": "A1"},
|
||||
"pipeline": dict(build_context(config)),
|
||||
"model": {
|
||||
"type": "global_gain/1",
|
||||
"input_profile": "force_delta/1",
|
||||
"output_frame": "sdk_force/1",
|
||||
"outputs": ["fn"],
|
||||
"params": {
|
||||
"gains_N_per_raw": {"fn": 0.001},
|
||||
"valid_force_range_N": [0, 3],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _model(config) -> ModelCalibration:
|
||||
loaded = parse(_document(config))
|
||||
assert isinstance(loaded, ModelCalibration)
|
||||
return loaded
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ the report
|
||||
def test_downsample_only_difference_runs_and_is_reported():
|
||||
model = _model(_config(2))
|
||||
runtime = build_context(_config(3))
|
||||
|
||||
model.validate_for_context(runtime) # allowed: no exception
|
||||
|
||||
report = model.pipeline_compatibility(runtime)
|
||||
assert report["ok"] is True
|
||||
downsample = report["downsample"]
|
||||
assert downsample["declared"] == 2
|
||||
assert downsample["actual"] == 3
|
||||
assert downsample["match"] is False
|
||||
assert downsample["allowed"] is True
|
||||
assert downsample["accuracy_risk"]
|
||||
assert model.downsample_report(runtime).known is True
|
||||
|
||||
|
||||
def test_a_matching_downsample_has_no_accuracy_risk():
|
||||
model = _model(_config(3))
|
||||
report = model.pipeline_compatibility(build_context(_config(3)))
|
||||
assert report["downsample"]["match"] is True
|
||||
assert report["downsample"]["accuracy_risk"] == ""
|
||||
|
||||
|
||||
def test_an_unknown_runtime_downsample_is_unknown_not_a_match():
|
||||
model = _model(_config(2))
|
||||
report = model.downsample_report({})
|
||||
assert report.actual is None
|
||||
assert report.match is False
|
||||
assert report.allowed is True
|
||||
assert "unknown" in report.accuracy_risk
|
||||
|
||||
|
||||
def test_every_other_identity_mismatch_is_still_refused():
|
||||
model = _model(_config(2))
|
||||
runtime = build_context(_config(2, dis_mode=5))
|
||||
report = model.pipeline_compatibility(runtime)
|
||||
assert report["ok"] is False
|
||||
assert "calibration pipeline mismatch: dis_mode" in report["mismatches"].values()
|
||||
with pytest.raises(CalibrationError, match="calibration pipeline mismatch: dis_mode"):
|
||||
model.validate_for_context(runtime)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw, expected", [(None, 1), ("bad", 1), (0, 1), (-3, 1), ("3", 3), (3.7, 3)])
|
||||
def test_context_reports_its_actual_downsample(raw, expected):
|
||||
config = _config(1)
|
||||
config["tracker"]["hhd_downsample"] = raw
|
||||
assert build_context(config).actual_downsample == expected
|
||||
|
||||
|
||||
# ------------------------------------------------- non-divisible grid integration
|
||||
def _identity_coordinates(rows: int, cols: int) -> np.ndarray:
|
||||
uv = np.empty((rows, cols, 2), dtype=np.float64)
|
||||
uv[..., 0] = np.arange(cols)[None, :]
|
||||
uv[..., 1] = np.arange(rows)[:, None]
|
||||
return uv
|
||||
|
||||
|
||||
@pytest.mark.parametrize("grid_shape", [(1, 1), (2, 3), (3, 4), (4, 4)])
|
||||
def test_a_constant_field_integrates_exactly_on_a_non_divisible_grid(grid_shape):
|
||||
rows, cols = 37, 53
|
||||
uv = _identity_coordinates(rows, cols)
|
||||
mask = np.ones((rows, cols), dtype=bool)
|
||||
points = np.array(
|
||||
[
|
||||
[0.0, 0.0],
|
||||
[cols - 1.0, 0.0],
|
||||
[cols - 1.0, rows - 1.0],
|
||||
[0.0, rows - 1.0],
|
||||
]
|
||||
)
|
||||
gains = np.ones(4, dtype=np.float64)
|
||||
triangles = np.array([[0, 1, 2], [0, 2, 3]], dtype=int)
|
||||
|
||||
# The block layout is an exact partition of the output image, for every grid
|
||||
# size that does not divide it: no lost pixels and no double counting.
|
||||
for divisions, size in ((grid_shape[0], rows), (grid_shape[1], cols)):
|
||||
starts, ends = _block_starts(divisions, size)
|
||||
assert starts[0] == 0
|
||||
assert ends[-1] == size
|
||||
assert np.array_equal(starts[1:], ends[:-1])
|
||||
assert np.all(ends > starts)
|
||||
|
||||
gain_map = build_field_gain_map(points, gains, triangles, uv, mask, grid_shape, mask)
|
||||
row_starts, row_ends = _block_starts(grid_shape[0], rows)
|
||||
col_starts, col_ends = _block_starts(grid_shape[1], cols)
|
||||
block_area = np.outer(row_ends - row_starts, col_ends - col_starts)
|
||||
# A constant unit gain and a fully mapped mask: the area-weighted coverage is
|
||||
# the whole image, exactly (analytic area, not a sampled sum).
|
||||
assert float(np.sum(gain_map.coverage * block_area)) == pytest.approx(rows * cols)
|
||||
assert np.allclose(gain_map.gain, 1.0)
|
||||
assert np.allclose(gain_map.inside, 1.0)
|
||||
|
||||
field = FieldFrame(
|
||||
d=np.ones((grid_shape[0], grid_shape[1], 2), dtype=np.float32),
|
||||
valid_mask=np.ones(grid_shape, dtype=bool),
|
||||
area_scale=1.0,
|
||||
grid_shape=grid_shape,
|
||||
reference_shape=None,
|
||||
)
|
||||
reduction = reduce_field(field, gain_map)
|
||||
# q = |d_x| + |d_y| = 2 per active HHD cell; the gain map's block sums are exact
|
||||
# whatever the block boundaries are, so the integral is exactly 2 * grid cells.
|
||||
cells = grid_shape[0] * grid_shape[1]
|
||||
assert reduction.weighted_N == pytest.approx(2.0 * cells)
|
||||
assert reduction.extrapolated_fraction == 0.0
|
||||
assert reduction.coverage_fraction == pytest.approx(1.0)
|
||||
|
||||
|
||||
# ------------------------------------------------------- session output diagnostics
|
||||
class _Source:
|
||||
"""The measurement-source surface ForceSession needs; no camera involved."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.session_enabled = False
|
||||
self.reset_count = 0
|
||||
|
||||
def enable_measurement_session(self) -> None:
|
||||
self.session_enabled = True
|
||||
|
||||
def disable_measurement_session(self) -> None:
|
||||
self.session_enabled = False
|
||||
|
||||
def reset_tracker(self) -> None:
|
||||
self.reset_count += 1
|
||||
|
||||
def read_measurement_frame(
|
||||
self, source_frame, *, frame_id, timestamp_s=None, want_field=False, want_contact=False
|
||||
) -> FrameMeasurement:
|
||||
del source_frame, timestamp_s, want_field, want_contact
|
||||
return FrameMeasurement(
|
||||
raw=(10.0, 20.0, 30.0),
|
||||
raw_valid=(True, True, True),
|
||||
)
|
||||
|
||||
|
||||
def test_the_session_output_states_the_downsample_risk():
|
||||
model = _model(_config(2))
|
||||
session = ForceSession(
|
||||
source=_Source(),
|
||||
sensor_family="box",
|
||||
context=build_context(_config(3)),
|
||||
calibration=model,
|
||||
settings=OutputSettings(sample_rate_hz=30.0),
|
||||
)
|
||||
output = session.process_frame(
|
||||
FRAME,
|
||||
FrameStamp(
|
||||
session_id="s1",
|
||||
frame_id=0,
|
||||
source_frame_id=0,
|
||||
signal_time_s=0.0,
|
||||
time_basis="recorded_signal",
|
||||
),
|
||||
)
|
||||
diagnostics = dict(output.measurement.diagnostics)
|
||||
assert diagnostics["hhd_downsample_calibrated"] == 2.0
|
||||
assert diagnostics["hhd_downsample_actual"] == 3.0
|
||||
assert diagnostics["hhd_downsample_mismatch"] == 1.0
|
||||
assert diagnostics["downsample_accuracy_risk"]
|
||||
assert session.calibration_compatibility["downsample"]["actual"] == 3
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,396 @@
|
||||
"""T07: fixed-rate output filtering, explicit switching and events.
|
||||
|
||||
The new session's filter is designed once (``design_sample_rate_hz``), reports
|
||||
measured-rate drift without re-tuning, applies a switch only at a frame boundary,
|
||||
emits exactly one ``output_filter_changed`` StreamEvent per change, and keeps the
|
||||
published ``ForceOutputFilter``/old session defaults untouched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys.force_readout import ForceOutputFilter, OutputSettings
|
||||
from orisys.force_stream_filter import (
|
||||
FixedRateOutputFilter,
|
||||
output_settings_fingerprint,
|
||||
warmup_frames_for,
|
||||
)
|
||||
from orisys.force_stream_session import ForceStreamOutput, ForceStreamSession
|
||||
from orisys.image_pipeline import ImageMeasurement
|
||||
from orisys.stream_contract import FrameStamp, MeasurementFrame, StreamEvent
|
||||
|
||||
FRAME_NS_30 = 33_333_333
|
||||
|
||||
|
||||
def _load_helpers():
|
||||
path = Path(__file__).with_name("test_force_session.py")
|
||||
spec = importlib.util.spec_from_file_location("_t07_helpers", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
helpers = _load_helpers()
|
||||
|
||||
|
||||
def _settings(**overrides) -> OutputSettings:
|
||||
values = {
|
||||
"sample_rate_hz": 30.0,
|
||||
"median_window": 3,
|
||||
"lowpass_hz": 5.0,
|
||||
"lowpass_order": 2,
|
||||
}
|
||||
values.update(overrides)
|
||||
return OutputSettings(**values)
|
||||
|
||||
|
||||
def _measurement(
|
||||
frame_id: int,
|
||||
raw: tuple[float, float, float],
|
||||
*,
|
||||
version: int = 0,
|
||||
time_ns: int | None = None,
|
||||
) -> ImageMeasurement:
|
||||
stamp = FrameStamp(
|
||||
sensor_id="TEST-1",
|
||||
session_id="s1",
|
||||
frame_id=frame_id,
|
||||
source_frame_id=frame_id,
|
||||
capture_time_ns=(frame_id * FRAME_NS_30 if time_ns is None else int(time_ns)),
|
||||
clock_id="host",
|
||||
capture_time_basis="recorded_signal",
|
||||
)
|
||||
frame = MeasurementFrame(
|
||||
stamp=stamp,
|
||||
source_dt_s=None,
|
||||
reference_generation=version,
|
||||
)
|
||||
return ImageMeasurement(frame=frame, force_raw=raw, torque=0.0, flow=None)
|
||||
|
||||
|
||||
def _raw(frame_id: int) -> tuple[float, float, float]:
|
||||
return (1000.0 + frame_id, 2000.0 + 0.5 * frame_id, 3000.0 - 0.25 * frame_id)
|
||||
|
||||
|
||||
def _session(settings: OutputSettings | None = None, **kwargs) -> ForceStreamSession:
|
||||
return ForceStreamSession(
|
||||
sensor_family="box",
|
||||
settings=settings if settings is not None else _settings(),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _ready(session: ForceStreamSession, *, count: int = 31) -> ForceStreamOutput:
|
||||
session.request_zero(
|
||||
reference_id="ref", declared_unloaded=True, expected_reference_generation=0
|
||||
)
|
||||
results = [
|
||||
session.process(_measurement(frame_id, _raw(frame_id)))
|
||||
for frame_id in range(1, count + 1)
|
||||
]
|
||||
assert results[-1].zero.phase == "ready"
|
||||
return results[-1]
|
||||
|
||||
|
||||
# --------------------------------------------------------------- fixed design rate
|
||||
def test_fixed_rate_reports_drift_without_redesigning_the_chain() -> None:
|
||||
settings = _settings(median_window=1, lowpass_hz=5.0)
|
||||
session = _session(settings, design_sample_rate_hz=30.0)
|
||||
reference = FixedRateOutputFilter(settings, design_sample_rate_hz=30.0)
|
||||
actual_hz = 20.0 # -33% from the design rate
|
||||
old = ForceOutputFilter(settings)
|
||||
|
||||
for frame_id in range(1, 26):
|
||||
time_ns = int(round(frame_id * 1e9 / actual_hz))
|
||||
raw = (1000.0 + frame_id, 2000.0, 3000.0)
|
||||
newton = (1.0, 2.0, 3.0)
|
||||
new_out = session.process(_measurement(frame_id, raw, time_ns=time_ns))
|
||||
expected_raw, _ = reference.process(
|
||||
raw,
|
||||
newton,
|
||||
raw_valid=(True, True, True),
|
||||
newton_valid=(True, True, True),
|
||||
signal_time_s=time_ns / 1e9,
|
||||
)
|
||||
old.process(
|
||||
raw,
|
||||
newton,
|
||||
raw_valid=(True, True, True),
|
||||
newton_valid=(True, True, True),
|
||||
signal_time_s=time_ns / 1e9,
|
||||
)
|
||||
assert new_out.raw == pytest.approx(expected_raw), frame_id
|
||||
|
||||
filter_ = session.output_filter
|
||||
assert session.design_sample_rate_hz == 30.0
|
||||
assert filter_.settings.sample_rate_hz == 30.0, "fixed design rate must not change"
|
||||
assert filter_.measured_sample_rate_hz == pytest.approx(actual_hz, rel=0.02)
|
||||
assert filter_.sample_rate_irregular is True
|
||||
assert new_out.design_sample_rate_hz == 30.0
|
||||
assert new_out.measured_sample_rate_hz == pytest.approx(actual_hz, rel=0.02)
|
||||
assert new_out.sample_rate_irregular is True
|
||||
# The published filter keeps its adaptive behaviour: this is what the new
|
||||
# fixed-rate contract deliberately does not do.
|
||||
assert old.settings.sample_rate_hz == pytest.approx(actual_hz, rel=0.02)
|
||||
assert old.settings.sample_rate_hz != pytest.approx(30.0, rel=0.01)
|
||||
|
||||
|
||||
def test_long_gap_does_not_clear_history_or_retune() -> None:
|
||||
session = _session(
|
||||
_settings(median_window=3, lowpass_hz=5.0), design_sample_rate_hz=30.0
|
||||
)
|
||||
reference = FixedRateOutputFilter(
|
||||
_settings(median_window=3, lowpass_hz=5.0), design_sample_rate_hz=30.0
|
||||
)
|
||||
outputs = []
|
||||
for frame_id in range(1, 16):
|
||||
# Regular frames, then a 0.5 s gap at frame 10.
|
||||
time_ns = frame_id * FRAME_NS_30 + (500_000_000 if frame_id >= 10 else 0)
|
||||
out = session.process(_measurement(frame_id, _raw(frame_id), time_ns=time_ns))
|
||||
expected, _ = reference.process(
|
||||
_raw(frame_id),
|
||||
(0.0, 0.0, 0.0),
|
||||
raw_valid=(True, True, True),
|
||||
newton_valid=(False, False, False),
|
||||
signal_time_s=time_ns / 1e9,
|
||||
)
|
||||
assert out.raw == pytest.approx(expected), frame_id
|
||||
outputs.append(out.raw)
|
||||
assert session.output_filter.settings.sample_rate_hz == 30.0
|
||||
assert session.output_filter.measured_sample_rate_hz is not None
|
||||
assert any(a != b for a, b in zip(outputs[:9], outputs[9:])), (
|
||||
"filter history was used"
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------------------------------------- equality at design
|
||||
def test_matches_old_session_filter_at_the_design_rate() -> None:
|
||||
settings = _settings(median_window=3, lowpass_hz=5.0)
|
||||
source = helpers.FakeMeasurementSource(_raw)
|
||||
old = helpers.ForceSession(
|
||||
source=source,
|
||||
sensor_family="box",
|
||||
settings=settings,
|
||||
)
|
||||
new = _session(settings)
|
||||
old.request_zero(reference_id="ref", declared_unloaded=True)
|
||||
new.request_zero(
|
||||
reference_id="ref", declared_unloaded=True, expected_reference_generation=0
|
||||
)
|
||||
old_raw, new_raw = [], []
|
||||
for frame_id in range(1, 41):
|
||||
old_out = helpers.process(old, source, frame_id)
|
||||
new_out = new.process(_measurement(frame_id, _raw(frame_id)))
|
||||
old_raw.append(np.asarray(old_out.raw))
|
||||
new_raw.append(np.asarray(new_out.raw))
|
||||
assert np.array_equal(np.stack(old_raw), np.stack(new_raw), equal_nan=True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- explicit switching
|
||||
def test_switch_at_frame_boundary_keeps_zero_and_emits_one_event() -> None:
|
||||
session = _session(_settings(median_window=3, lowpass_hz=5.0))
|
||||
_ready(session)
|
||||
before_zero = session.zero
|
||||
before_version = session.reference_generation
|
||||
before_settings = session.output_settings
|
||||
|
||||
session.request_output_settings(_settings(median_window=5, lowpass_hz=10.0))
|
||||
assert session.output_settings == before_settings, "not applied before a frame"
|
||||
assert session.drain_events() == (), "no event before the boundary frame"
|
||||
|
||||
frame = session.process(_measurement(40, _raw(40)))
|
||||
events = session.drain_events()
|
||||
assert len(events) == 1
|
||||
event = events[0]
|
||||
assert event.kind == "output_filter_changed"
|
||||
assert isinstance(event, StreamEvent)
|
||||
assert session.zero.phase == "ready", "a filter switch must not invalidate the zero"
|
||||
assert session.reference_generation == before_version
|
||||
assert session.zero == before_zero
|
||||
assert frame.output_settings == _settings(median_window=5, lowpass_hz=10.0)
|
||||
assert frame.output_settings_fingerprint == output_settings_fingerprint(
|
||||
frame.output_settings
|
||||
)
|
||||
assert event.payload["fingerprint"] == frame.output_settings_fingerprint
|
||||
assert event.payload["effective_frame_id"] == 40
|
||||
assert event.payload["warmup"]["required_frames"] == warmup_frames_for(
|
||||
frame.output_settings
|
||||
)
|
||||
assert event.payload["warmup"]["elapsed_frames"] == 0
|
||||
assert event.stamp.frame_id == 40
|
||||
|
||||
# The event is a T03 StreamEvent and survives JSON round-trip (T14 input).
|
||||
encoded = json.loads(json.dumps(event.to_dict()))
|
||||
assert StreamEvent.from_dict(encoded) == event
|
||||
|
||||
# History starts from the effective frame: the first post-switch outputs match
|
||||
# a fresh chain fed only the post-switch raw sequence (frame 40 included).
|
||||
reference = FixedRateOutputFilter(
|
||||
_settings(median_window=5, lowpass_hz=10.0), design_sample_rate_hz=30.0
|
||||
)
|
||||
expected, _ = reference.process(
|
||||
_raw(40),
|
||||
(0.0, 0.0, 0.0),
|
||||
raw_valid=(True, True, True),
|
||||
newton_valid=(False, False, False),
|
||||
signal_time_s=40 * FRAME_NS_30 / 1e9,
|
||||
)
|
||||
assert frame.raw == pytest.approx(expected)
|
||||
for frame_id in range(41, 46):
|
||||
out = session.process(_measurement(frame_id, _raw(frame_id)))
|
||||
expected, _ = reference.process(
|
||||
_raw(frame_id),
|
||||
(0.0, 0.0, 0.0),
|
||||
raw_valid=(True, True, True),
|
||||
newton_valid=(False, False, False),
|
||||
signal_time_s=frame_id * FRAME_NS_30 / 1e9,
|
||||
)
|
||||
assert out.raw == pytest.approx(expected), (
|
||||
"post-switch history must start from the effective frame"
|
||||
)
|
||||
|
||||
|
||||
def test_requesting_the_same_settings_is_a_no_op() -> None:
|
||||
session = _session(_settings(median_window=3, lowpass_hz=5.0))
|
||||
ready = _ready(session)
|
||||
same = session.output_settings
|
||||
session.request_output_settings(same)
|
||||
frame = session.process(_measurement(40, _raw(40)))
|
||||
assert session.drain_events() == ()
|
||||
assert frame.output_settings == same
|
||||
# Zero completion already restarted the filter history; a no-op request must
|
||||
# not restart it again: the warm-up counter simply continues.
|
||||
assert frame.warmup_elapsed_frames == ready.warmup_elapsed_frames + 1
|
||||
|
||||
|
||||
def test_switch_coalesces_to_the_latest_request_before_the_boundary() -> None:
|
||||
session = _session(_settings(median_window=3, lowpass_hz=5.0))
|
||||
_ready(session)
|
||||
first = _settings(median_window=5, lowpass_hz=10.0)
|
||||
latest = _settings(median_window=5, lowpass_hz=12.0)
|
||||
session.request_output_settings(first)
|
||||
session.request_output_settings(latest)
|
||||
frame = session.process(_measurement(40, _raw(40)))
|
||||
events = session.drain_events()
|
||||
assert len(events) == 1
|
||||
assert frame.output_settings == latest
|
||||
assert events[0].payload["settings"] == latest.__dict__
|
||||
|
||||
|
||||
def test_warmup_counts_frames_after_a_switch() -> None:
|
||||
session = _session(_settings(median_window=3, lowpass_hz=5.0))
|
||||
_ready(session)
|
||||
new_settings = _settings(median_window=5, lowpass_hz=10.0)
|
||||
required = warmup_frames_for(new_settings)
|
||||
session.request_output_settings(new_settings)
|
||||
rows = [
|
||||
session.process(_measurement(40 + index, _raw(40 + index)))
|
||||
for index in range(required + 2)
|
||||
]
|
||||
assert rows[0].warmup_elapsed_frames == 1
|
||||
assert rows[0].warming_up is True
|
||||
assert rows[0].warmup_required_frames == required
|
||||
assert rows[required - 1].warming_up is False
|
||||
assert rows[required - 1].warmup_elapsed_frames == required
|
||||
assert rows[-1].warming_up is False
|
||||
|
||||
|
||||
# -------------------------------------------------------------- thread boundary
|
||||
class _GatedSession(ForceStreamSession):
|
||||
"""Hold one frame inside the engine to prove switching waits for a boundary."""
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.entered = threading.Event()
|
||||
self.release = threading.Event()
|
||||
self.gate_frame = 40
|
||||
|
||||
def _feed_engine(self, measurement, frame):
|
||||
if int(frame.stamp.frame_id) == self.gate_frame:
|
||||
self.entered.set()
|
||||
assert self.release.wait(timeout=5.0)
|
||||
return super()._feed_engine(measurement, frame)
|
||||
|
||||
|
||||
def test_threaded_switch_waits_for_the_next_frame_boundary() -> None:
|
||||
session = _GatedSession(
|
||||
sensor_family="box", settings=_settings(median_window=3, lowpass_hz=5.0)
|
||||
)
|
||||
_ready(session)
|
||||
old_settings = session.output_settings
|
||||
new_settings = _settings(median_window=5, lowpass_hz=10.0)
|
||||
results: list[ForceStreamOutput] = []
|
||||
|
||||
def run_frame() -> None:
|
||||
results.append(session.process(_measurement(40, _raw(40))))
|
||||
|
||||
worker = threading.Thread(target=run_frame)
|
||||
worker.start()
|
||||
try:
|
||||
assert session.entered.wait(timeout=5.0), "frame never reached the engine gate"
|
||||
# The frame is provably in flight: the switch request must not touch it.
|
||||
session.request_output_settings(new_settings)
|
||||
assert session.output_settings == old_settings, "no mid-frame reconfiguration"
|
||||
assert session.drain_events() == ()
|
||||
finally:
|
||||
session.release.set()
|
||||
worker.join(timeout=5.0)
|
||||
assert not worker.is_alive()
|
||||
assert results[0].output_settings == old_settings
|
||||
|
||||
next_frame = session.process(_measurement(41, _raw(41)))
|
||||
events = session.drain_events()
|
||||
assert len(events) == 1
|
||||
assert next_frame.output_settings == new_settings
|
||||
assert events[0].payload["effective_frame_id"] == 41
|
||||
|
||||
|
||||
# ---------------------------------------------------------- pass-through and history
|
||||
def test_all_stages_off_is_an_exact_passthrough_including_nan_and_signed_zero() -> None:
|
||||
passthrough = OutputSettings(sample_rate_hz=30.0, median_window=1, lowpass_hz=0.0)
|
||||
filter_ = FixedRateOutputFilter(passthrough, design_sample_rate_hz=30.0)
|
||||
raw = (0.0, -0.0, float("nan"))
|
||||
newton = (-0.0, 1.5, float("nan"))
|
||||
out_raw, out_newton = filter_.process(
|
||||
raw,
|
||||
newton,
|
||||
raw_valid=(True, True, True),
|
||||
newton_valid=(True, True, True),
|
||||
signal_time_s=1.0,
|
||||
)
|
||||
assert out_raw[0] == raw[0] and out_raw[1] == raw[1]
|
||||
assert np.isnan(out_raw[2])
|
||||
assert bool(np.signbit(out_raw[1])) is True
|
||||
assert out_newton[0] == newton[0] and out_newton[1] == newton[1]
|
||||
assert np.isnan(out_newton[2])
|
||||
assert bool(np.signbit(out_newton[0])) is True
|
||||
|
||||
session = _session(passthrough)
|
||||
_ready(session)
|
||||
out = session.process(_measurement(40, (0.0, -0.0, float("nan"))))
|
||||
assert out.raw[0] == 0.0 and bool(np.signbit(out.raw[1])) is True
|
||||
assert np.isnan(out.raw[2])
|
||||
|
||||
|
||||
def test_published_force_output_filter_default_behaviour_is_unchanged() -> None:
|
||||
"""The old filter still re-designs on rate drift; the new one is opt-in."""
|
||||
settings = _settings(median_window=1, lowpass_hz=5.0)
|
||||
old = ForceOutputFilter(settings)
|
||||
for frame_id in range(1, 15):
|
||||
old.process(
|
||||
(float(frame_id), 0.0, 0.0),
|
||||
(0.0, 0.0, 0.0),
|
||||
raw_valid=(True, True, True),
|
||||
newton_valid=(False, False, False),
|
||||
signal_time_s=frame_id * 0.05,
|
||||
)
|
||||
assert old.settings.sample_rate_hz == pytest.approx(20.0, rel=0.02)
|
||||
assert old.settings.sample_rate_hz != settings.sample_rate_hz
|
||||
@@ -0,0 +1,752 @@
|
||||
"""New force-side session (T06a): version/zero contract and old-session numerics.
|
||||
|
||||
The tests drive :class:`~orisys.force_stream_session.ForceStreamSession` with
|
||||
T05-style measurements (T03 ``MeasurementFrame`` + same-frame raw feature) and
|
||||
compare the scalar/uncalibrated numerical chain with the published old
|
||||
``ForceSession`` on the same input sequence. They also pin the new contract:
|
||||
reference/session changes invalidate the zero in every phase, only same-version
|
||||
frames fill the baseline window, and duplicate/late/same-timestamp frames stay
|
||||
diagnostics-only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys.force_stream_session import ForceStreamOutput, ForceStreamSession
|
||||
from orisys.image_pipeline import ImageMeasurement
|
||||
from orisys.stream_contract import FrameStamp, MeasurementFrame
|
||||
|
||||
FRAME_NS = 33_333_333
|
||||
|
||||
|
||||
def _load_helpers():
|
||||
path = Path(__file__).with_name("test_force_session.py")
|
||||
spec = importlib.util.spec_from_file_location("_force_session_test_helpers", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
helpers = _load_helpers()
|
||||
|
||||
|
||||
def _measurement(
|
||||
frame_id: int,
|
||||
raw: tuple[float, float, float],
|
||||
*,
|
||||
version: int = 0,
|
||||
session_id: str = "s1",
|
||||
sensor_id: str = "TEST-1",
|
||||
capture_time_ns: int | None = None,
|
||||
source_frame_id: int | None = None,
|
||||
field: dict | None = None,
|
||||
valid_mask: np.ndarray | None = None,
|
||||
) -> ImageMeasurement:
|
||||
stamp = FrameStamp(
|
||||
sensor_id=sensor_id,
|
||||
session_id=session_id,
|
||||
frame_id=frame_id,
|
||||
source_frame_id=frame_id if source_frame_id is None else source_frame_id,
|
||||
capture_time_ns=(
|
||||
frame_id * FRAME_NS if capture_time_ns is None else capture_time_ns
|
||||
),
|
||||
clock_id="host",
|
||||
capture_time_basis="recorded_signal",
|
||||
)
|
||||
frame = MeasurementFrame(
|
||||
stamp=stamp,
|
||||
source_dt_s=None if frame_id == 1 else FRAME_NS / 1e9,
|
||||
reference_generation=version,
|
||||
)
|
||||
return ImageMeasurement(
|
||||
frame=frame,
|
||||
force_raw=raw,
|
||||
torque=0.0,
|
||||
flow=None,
|
||||
field=field,
|
||||
valid_mask=valid_mask,
|
||||
)
|
||||
|
||||
|
||||
def _raw_for(frame_id: int) -> tuple[float, float, float]:
|
||||
return (
|
||||
15000.0 + 500.0 * frame_id,
|
||||
8000.0 + 200.0 * frame_id,
|
||||
7000.0 - 100.0 * frame_id,
|
||||
)
|
||||
|
||||
|
||||
def _old_chain(calibration=None, settings=None):
|
||||
source = helpers.FakeMeasurementSource(_raw_for)
|
||||
session = helpers.make_session(source, calibration, settings=settings)
|
||||
session.request_zero(reference_id="old-ref", declared_unloaded=True)
|
||||
return session, source
|
||||
|
||||
|
||||
def _new_chain(calibration=None, settings=None):
|
||||
return ForceStreamSession(
|
||||
sensor_family="box",
|
||||
calibration=calibration,
|
||||
settings=settings if settings is not None else helpers.legacy_settings(),
|
||||
)
|
||||
|
||||
|
||||
def _compare_with_old(calibration=None, settings=None) -> None:
|
||||
old, source = _old_chain(calibration, settings)
|
||||
new = _new_chain(calibration, settings)
|
||||
new.request_zero(
|
||||
reference_id="new-ref", declared_unloaded=True, expected_reference_generation=0
|
||||
)
|
||||
old_raw, old_newton, old_raw_status, old_newton_status = [], [], [], []
|
||||
new_raw, new_newton, new_raw_status, new_newton_status = [], [], [], []
|
||||
new_zero_phases = []
|
||||
for frame_id in range(1, 41):
|
||||
old_output = helpers.process(old, source, frame_id)
|
||||
old_raw.append(np.asarray(old_output.raw))
|
||||
old_newton.append(np.asarray(old_output.newton))
|
||||
old_raw_status.append(list(old_output.measurement.sdk.raw_status))
|
||||
old_newton_status.append(list(old_output.measurement.newton_status))
|
||||
|
||||
result = new.process(_measurement(frame_id, _raw_for(frame_id)))
|
||||
assert isinstance(result, ForceStreamOutput)
|
||||
new_raw.append(np.asarray(result.raw))
|
||||
new_newton.append(np.asarray(result.newton))
|
||||
new_raw_status.append(list(result.raw_status))
|
||||
new_newton_status.append(list(result.newton_status))
|
||||
new_zero_phases.append(result.zero.phase)
|
||||
|
||||
assert np.array_equal(np.stack(old_raw), np.stack(new_raw), equal_nan=True)
|
||||
assert np.array_equal(np.stack(old_newton), np.stack(new_newton), equal_nan=True)
|
||||
assert old_raw_status == new_raw_status
|
||||
assert old_newton_status == new_newton_status
|
||||
assert new.zero.phase == "ready"
|
||||
assert new_zero_phases[:30] == ["collecting"] * 30
|
||||
assert new_zero_phases[30] == "ready"
|
||||
|
||||
|
||||
# ------------------------------------------------------------ numeric equivalence
|
||||
def test_scalar_curve_session_matches_old_force_session() -> None:
|
||||
_compare_with_old(helpers.box_cal())
|
||||
|
||||
|
||||
def test_uncalibrated_session_matches_old_force_session() -> None:
|
||||
_compare_with_old(None)
|
||||
|
||||
|
||||
def test_filtered_scalar_session_matches_old_force_session() -> None:
|
||||
settings = helpers.OutputSettings(
|
||||
sample_rate_hz=30.0, median_window=3, lowpass_hz=5.0
|
||||
)
|
||||
_compare_with_old(helpers.box_cal(), settings)
|
||||
|
||||
|
||||
# ----------------------------------------------------- version / session contract
|
||||
def _baseline(new: ForceStreamSession, frame_ids, *, version=0, session_id="s1"):
|
||||
return [
|
||||
new.process(
|
||||
_measurement(fid, _raw_for(fid), version=version, session_id=session_id)
|
||||
)
|
||||
for fid in frame_ids
|
||||
]
|
||||
|
||||
|
||||
def test_version_change_during_collecting_invalidates_the_zero() -> None:
|
||||
new = _new_chain(helpers.box_cal())
|
||||
new.request_zero(
|
||||
reference_id="ref", declared_unloaded=True, expected_reference_generation=0
|
||||
)
|
||||
collected = _baseline(new, range(1, 11))
|
||||
assert collected[-1].zero.phase == "collecting"
|
||||
|
||||
changed = new.process(_measurement(11, _raw_for(11), version=2))
|
||||
assert changed.quality == "version_changed"
|
||||
assert changed.zero.phase == "failed"
|
||||
assert "reference generation changed" in changed.zero.failure_reason
|
||||
assert all(np.isnan(value) for value in changed.newton)
|
||||
|
||||
# Only an explicit re-zero for the new version restores a valid N.
|
||||
new.request_zero(
|
||||
reference_id="ref-2", declared_unloaded=True, expected_reference_generation=2
|
||||
)
|
||||
ready = _baseline(new, range(12, 43), version=2)
|
||||
assert ready[-1].zero.phase == "ready"
|
||||
assert np.isfinite(ready[-1].newton[0])
|
||||
assert ready[-1].reference_generation == 2
|
||||
|
||||
|
||||
def test_version_change_after_ready_invalidates_and_requires_explicit_reset() -> None:
|
||||
new = _new_chain()
|
||||
new.request_zero(
|
||||
reference_id="ref", declared_unloaded=True, expected_reference_generation=0
|
||||
)
|
||||
baseline = _baseline(new, range(1, 31))
|
||||
ready = _baseline(new, [31])
|
||||
assert ready[-1].zero.phase == "ready"
|
||||
assert np.isfinite(baseline[-1].raw[0])
|
||||
|
||||
changed = new.process(_measurement(32, _raw_for(32), version=1))
|
||||
assert changed.quality == "version_changed"
|
||||
assert changed.zero.phase == "failed"
|
||||
assert all(np.isnan(value) for value in changed.newton)
|
||||
# A raw row is still in the result stream; the zero is not silently relabelled.
|
||||
assert all(np.isfinite(value) for value in changed.raw)
|
||||
|
||||
still_failed = new.process(_measurement(33, _raw_for(33), version=1))
|
||||
assert still_failed.zero.phase == "failed"
|
||||
assert all(np.isnan(value) for value in still_failed.newton)
|
||||
|
||||
|
||||
def test_session_change_invalidates_the_zero_and_emits_results() -> None:
|
||||
new = _new_chain()
|
||||
new.request_zero(
|
||||
reference_id="ref", declared_unloaded=True, expected_reference_generation=0
|
||||
)
|
||||
_baseline(new, range(1, 31))
|
||||
assert _baseline(new, [31])[-1].zero.phase == "ready"
|
||||
|
||||
changed = new.process(_measurement(32, _raw_for(32), session_id="s2", version=0))
|
||||
assert changed.quality == "session_changed"
|
||||
assert changed.zero.phase == "failed"
|
||||
assert "source session changed" in changed.zero.failure_reason
|
||||
assert all(np.isfinite(value) for value in changed.raw)
|
||||
|
||||
|
||||
def test_late_frame_cannot_fill_the_new_version_window() -> None:
|
||||
# Corrected in the Y2 rework: a frame older than the requested reset target is
|
||||
# "zero_pending", not "late" (it belongs to the still-active version and is
|
||||
# waiting for the reset confirmation). It still cannot fill the window.
|
||||
new = _new_chain()
|
||||
new.request_zero(
|
||||
reference_id="ref", declared_unloaded=True, expected_reference_generation=1
|
||||
)
|
||||
late = new.process(_measurement(1, _raw_for(1), version=0))
|
||||
assert late.quality == "zero_pending"
|
||||
assert late.late is False
|
||||
assert late.accepted is False
|
||||
assert late.zero.phase == "resetting", "pending frame must not advance the zero"
|
||||
|
||||
accepted = _baseline(new, range(2, 12), version=1)
|
||||
assert accepted[-1].zero.phase == "collecting"
|
||||
|
||||
|
||||
# ------------------------------------------- duplicate / late / same timestamp
|
||||
def test_duplicate_and_same_timestamp_frames_are_diagnostics_only() -> None:
|
||||
new = _new_chain()
|
||||
new.request_zero(
|
||||
reference_id="ref", declared_unloaded=True, expected_reference_generation=0
|
||||
)
|
||||
_baseline(new, range(1, 31))
|
||||
ready = _baseline(new, [31])[-1]
|
||||
assert ready.zero.phase == "ready"
|
||||
before = new.zero
|
||||
|
||||
duplicate = new.process(_measurement(20, _raw_for(20)))
|
||||
assert duplicate.duplicate is True
|
||||
assert duplicate.quality == "duplicate"
|
||||
assert new.zero == before, "duplicate must not advance or invalidate the zero"
|
||||
|
||||
# Same timestamp, different source frame: not a duplicate, but it is reported
|
||||
# as a late/order diagnostic and cannot advance the filter or invalidate.
|
||||
same_time = new.process(
|
||||
_measurement(32, _raw_for(32), capture_time_ns=31 * FRAME_NS)
|
||||
)
|
||||
assert same_time.duplicate is False
|
||||
assert same_time.quality == "late"
|
||||
assert same_time.late is True
|
||||
assert new.zero == before, "order diagnostic must not invalidate the zero"
|
||||
|
||||
duplicate_again = new.process(_measurement(20, _raw_for(20)))
|
||||
assert duplicate_again.duplicate is True
|
||||
|
||||
|
||||
# ------------------------------------------------------------ window limit gates
|
||||
def test_max_gap_s_fails_the_baseline_window_with_reason() -> None:
|
||||
new = _new_chain()
|
||||
new.request_zero(
|
||||
reference_id="ref",
|
||||
declared_unloaded=True,
|
||||
expected_reference_generation=0,
|
||||
max_gap_s=0.05,
|
||||
)
|
||||
_baseline(new, range(1, 6))
|
||||
jumped = new.process(_measurement(6, _raw_for(6), capture_time_ns=6 * 500_000_000))
|
||||
assert jumped.quality == "window_failed"
|
||||
assert jumped.zero.phase == "failed"
|
||||
assert "max_gap_s" in jumped.zero.failure_reason
|
||||
|
||||
|
||||
def test_max_window_s_fails_the_baseline_window_with_reason() -> None:
|
||||
new = _new_chain()
|
||||
new.request_zero(
|
||||
reference_id="ref",
|
||||
declared_unloaded=True,
|
||||
expected_reference_generation=0,
|
||||
max_window_s=0.5,
|
||||
)
|
||||
_baseline(new, range(1, 10))
|
||||
late_limit = new.process(
|
||||
_measurement(10, _raw_for(10), capture_time_ns=1_000_000_000)
|
||||
)
|
||||
assert late_limit.quality == "window_failed"
|
||||
assert late_limit.zero.phase == "failed"
|
||||
assert "max_window_s" in late_limit.zero.failure_reason
|
||||
|
||||
|
||||
# -------------------------------------------------------------- stream contract
|
||||
def test_every_frame_produces_a_header_carrying_result() -> None:
|
||||
new = _new_chain()
|
||||
new.request_zero(
|
||||
reference_id="ref", declared_unloaded=True, expected_reference_generation=0
|
||||
)
|
||||
rows = _baseline(new, range(1, 31)) + [
|
||||
new.process(_measurement(31, (float("nan"), 1.0, 2.0))),
|
||||
]
|
||||
assert len(rows) == 31
|
||||
for row in rows:
|
||||
assert isinstance(row, ForceStreamOutput)
|
||||
assert isinstance(row.frame, MeasurementFrame)
|
||||
assert row.frame.stamp.session_id == "s1"
|
||||
assert isinstance(row.reference_generation, int)
|
||||
invalid = rows[-1]
|
||||
assert invalid.accepted is True
|
||||
assert invalid.zero.phase == "ready", (
|
||||
"an invalid frame must not invent a zero failure"
|
||||
)
|
||||
assert np.isnan(invalid.raw[0]), (
|
||||
"the failed axis stays NaN; other raw axes remain visible"
|
||||
)
|
||||
assert all(np.isnan(value) for value in invalid.newton)
|
||||
assert invalid.quality in ("invalid", "uncalibrated")
|
||||
|
||||
|
||||
def test_new_session_does_not_expose_the_old_external_reset_api() -> None:
|
||||
new = _new_chain()
|
||||
for name in (
|
||||
"process_measurement",
|
||||
"request_zero_external_reset",
|
||||
"tracker_reset_seq",
|
||||
"process_frame",
|
||||
):
|
||||
assert not hasattr(new, name), name
|
||||
|
||||
|
||||
def test_inputs_are_not_mutated_and_outputs_hold_no_internal_arrays() -> None:
|
||||
new = _new_chain()
|
||||
raw = _raw_for(1)
|
||||
flow = np.zeros((2, 2, 2), dtype=np.float32)
|
||||
measurement = _measurement(1, raw)
|
||||
sentinel = np.array(flow, copy=True)
|
||||
measurement.force_raw # tuple attribute, no array to copy
|
||||
new.request_zero(
|
||||
reference_id="ref", declared_unloaded=True, expected_reference_generation=0
|
||||
)
|
||||
result = new.process(measurement)
|
||||
assert np.array_equal(flow, sentinel), "input arrays are read-only to the session"
|
||||
assert not isinstance(result.raw, np.ndarray)
|
||||
assert not isinstance(result.newton, np.ndarray)
|
||||
assert all(value == value for value in result.raw) # finite tuple values
|
||||
|
||||
|
||||
# --------------------------------------- normal reset order (Y1) and pending rows
|
||||
def test_normal_reset_order_adopts_the_requested_version_and_completes_zero() -> None:
|
||||
"""v0 frames in flight -> request_zero(v1) -> first v1 frame starts collecting.
|
||||
|
||||
This is the order Studio uses. The request must not be invalidated by its own
|
||||
target version, and the still-in-flight v0 frames are pending, not late.
|
||||
"""
|
||||
new = _new_chain(helpers.box_cal())
|
||||
seed = _baseline(new, range(1, 4), version=0)
|
||||
assert seed[-1].zero.phase != "ready"
|
||||
new.request_zero(
|
||||
reference_id="z", declared_unloaded=True, expected_reference_generation=1
|
||||
)
|
||||
|
||||
in_flight = _baseline(new, range(4, 6), version=0)
|
||||
for row in in_flight:
|
||||
assert row.quality == "zero_pending", row.quality
|
||||
assert row.late is False, "in-flight current-version frames are not late"
|
||||
assert row.accepted is False
|
||||
assert row.zero.phase == "resetting", "pending frame must not advance the zero"
|
||||
|
||||
version_one = _baseline(new, range(6, 37), version=1)
|
||||
assert version_one[0].zero.phase == "collecting"
|
||||
assert version_one[29].zero.phase == "collecting"
|
||||
assert version_one[30].zero.phase == "ready"
|
||||
assert np.isfinite(version_one[30].newton[0])
|
||||
assert version_one[0].reference_generation == 1
|
||||
|
||||
|
||||
def test_request_arriving_after_the_first_new_version_frame_still_completes() -> None:
|
||||
new = _new_chain(helpers.box_cal())
|
||||
_baseline(new, range(1, 4), version=0)
|
||||
|
||||
first_v1 = new.process(_measurement(4, _raw_for(4), version=1))
|
||||
assert first_v1.quality == "version_changed"
|
||||
assert first_v1.zero.phase == "failed"
|
||||
|
||||
new.request_zero(
|
||||
reference_id="z-late", declared_unloaded=True, expected_reference_generation=1
|
||||
)
|
||||
frames = _baseline(new, range(5, 36), version=1)
|
||||
assert frames[0].zero.phase == "collecting"
|
||||
assert frames[30].zero.phase == "ready"
|
||||
assert np.isfinite(frames[30].newton[0])
|
||||
|
||||
|
||||
def test_target_after_v1_waits_through_v1_inflight_then_adopts_v2() -> None:
|
||||
new = _new_chain(helpers.box_cal())
|
||||
new.request_zero(
|
||||
reference_id="v1", declared_unloaded=True, expected_reference_generation=1
|
||||
)
|
||||
_baseline(new, range(1, 32), version=1)
|
||||
assert new.zero.phase == "ready"
|
||||
new.request_zero(
|
||||
reference_id="v2", declared_unloaded=True, expected_reference_generation=2
|
||||
)
|
||||
pending = _baseline(new, range(32, 34), version=1)
|
||||
assert all(row.quality == "zero_pending" and not row.late for row in pending)
|
||||
assert all(row.zero.phase == "resetting" for row in pending)
|
||||
|
||||
v2 = _baseline(new, range(34, 65), version=2)
|
||||
assert v2[0].zero.phase == "collecting"
|
||||
assert v2[30].zero.phase == "ready"
|
||||
|
||||
|
||||
# ------------------------------------------------ branch frames register identity
|
||||
def test_version_change_branch_frame_is_registered_for_duplicate_detection() -> None:
|
||||
new = _new_chain()
|
||||
_baseline(new, range(1, 3), version=0)
|
||||
changed = new.process(_measurement(3, _raw_for(3), version=1))
|
||||
assert changed.quality == "version_changed"
|
||||
after_change = new.zero
|
||||
repeat = new.process(_measurement(3, _raw_for(3), version=1))
|
||||
assert repeat.duplicate is True
|
||||
assert repeat.quality == "duplicate"
|
||||
assert new.zero == after_change, "duplicate must not be processed again"
|
||||
|
||||
|
||||
# ------------------------------------------- T06b: field and FN-only calibration
|
||||
def _field_payload_for(frame_id: int):
|
||||
return helpers._field_payload(0.5 + 0.05 * frame_id, 0.5)
|
||||
|
||||
|
||||
def _field_models(tmp_path):
|
||||
document, context = helpers._field_document(tmp_path)
|
||||
old_model = helpers.parse_model(document, tmp_path / "field-calibration-old.json")
|
||||
new_model = helpers.parse_model(document, tmp_path / "field-calibration-new.json")
|
||||
return old_model, new_model, context
|
||||
|
||||
|
||||
def _field_measurement(frame_id: int, *, include_field: bool = True, mask=None):
|
||||
return _measurement(
|
||||
frame_id,
|
||||
(0.0, 0.0, 0.0),
|
||||
field=_field_payload_for(frame_id) if include_field else None,
|
||||
valid_mask=np.ones((4, 4), dtype=bool) if mask is None else mask,
|
||||
)
|
||||
|
||||
|
||||
def _field_old(tmp_path):
|
||||
old_model, new_model, context = _field_models(tmp_path)
|
||||
source = helpers.FakeMeasurementSource(
|
||||
lambda frame_id: (0.0, 0.0, 0.0),
|
||||
field_for=_field_payload_for,
|
||||
output_mask=np.ones((4, 4), dtype=bool),
|
||||
)
|
||||
session = helpers.ForceSession(
|
||||
source=source,
|
||||
sensor_family="finger",
|
||||
context=context,
|
||||
calibration=old_model,
|
||||
settings=helpers.legacy_settings(),
|
||||
)
|
||||
return session, source, new_model, context
|
||||
|
||||
|
||||
def _compare_field_with_old(tmp_path) -> None:
|
||||
old, source, new_model, context = _field_old(tmp_path)
|
||||
new = ForceStreamSession(
|
||||
sensor_family="finger",
|
||||
context=context,
|
||||
calibration=new_model,
|
||||
settings=helpers.legacy_settings(),
|
||||
)
|
||||
assert new.measurement_requirements == (True, False)
|
||||
old.request_zero(reference_id="field-ref", declared_unloaded=True)
|
||||
new.request_zero(
|
||||
reference_id="field-ref",
|
||||
declared_unloaded=True,
|
||||
expected_reference_generation=0,
|
||||
)
|
||||
old_raw, old_newton, old_status = [], [], []
|
||||
new_raw, new_newton, new_status = [], [], []
|
||||
for frame_id in range(1, 41):
|
||||
old_out = helpers.process(old, source, frame_id)
|
||||
new_out = new.process(_field_measurement(frame_id))
|
||||
old_raw.append(np.asarray(old_out.raw))
|
||||
old_newton.append(np.asarray(old_out.newton))
|
||||
old_status.append(list(old_out.measurement.newton_status))
|
||||
new_raw.append(np.asarray(new_out.raw))
|
||||
new_newton.append(np.asarray(new_out.newton))
|
||||
new_status.append(list(new_out.newton_status))
|
||||
assert np.array_equal(np.stack(old_raw), np.stack(new_raw), equal_nan=True)
|
||||
assert np.array_equal(np.stack(old_newton), np.stack(new_newton), equal_nan=True)
|
||||
assert old_status == new_status
|
||||
assert new.zero.phase == "ready"
|
||||
# The field model outputs FN only; shear axes stay explicitly unavailable.
|
||||
assert np.isfinite(new_newton[-1][0])
|
||||
assert np.isnan(new_newton[-1][1]) and np.isnan(new_newton[-1][2])
|
||||
assert new_status[-1][1:] == ["uncalibrated", "uncalibrated"]
|
||||
|
||||
|
||||
def _fn_only_models():
|
||||
context = helpers.build_context(
|
||||
{
|
||||
"camera": {"resolution_stitch": [4, 4]},
|
||||
"tracker": {"dis_mode": 1, "hhd_downsample": 1},
|
||||
}
|
||||
)
|
||||
document = helpers._global_gain_document(context, gains={"fn": 0.01})
|
||||
old_model = helpers.parse_model(document)
|
||||
new_model = helpers.parse_model(document)
|
||||
return old_model, new_model, context
|
||||
|
||||
|
||||
def _fn_raw(frame_id: int) -> tuple[float, float, float]:
|
||||
return (100.0 + frame_id, 7.0, -7.0)
|
||||
|
||||
|
||||
def _compare_fn_only_with_old() -> None:
|
||||
old_model, new_model, context = _fn_only_models()
|
||||
source = helpers.FakeMeasurementSource(_fn_raw)
|
||||
old = helpers.ForceSession(
|
||||
source=source,
|
||||
sensor_family="box",
|
||||
context=context,
|
||||
calibration=old_model,
|
||||
settings=helpers.legacy_settings(),
|
||||
)
|
||||
new = ForceStreamSession(
|
||||
sensor_family="box",
|
||||
context=context,
|
||||
calibration=new_model,
|
||||
settings=helpers.legacy_settings(),
|
||||
)
|
||||
assert new.measurement_requirements == (False, False)
|
||||
old.request_zero(reference_id="fn-ref", declared_unloaded=True)
|
||||
new.request_zero(
|
||||
reference_id="fn-ref",
|
||||
declared_unloaded=True,
|
||||
expected_reference_generation=0,
|
||||
)
|
||||
old_raw, old_newton, old_status = [], [], []
|
||||
new_raw, new_newton, new_status = [], [], []
|
||||
for frame_id in range(1, 41):
|
||||
old_out = helpers.process(old, source, frame_id)
|
||||
new_out = new.process(_measurement(frame_id, _fn_raw(frame_id)))
|
||||
old_raw.append(np.asarray(old_out.raw))
|
||||
old_newton.append(np.asarray(old_out.newton))
|
||||
old_status.append(list(old_out.measurement.newton_status))
|
||||
new_raw.append(np.asarray(new_out.raw))
|
||||
new_newton.append(np.asarray(new_out.newton))
|
||||
new_status.append(list(new_out.newton_status))
|
||||
assert np.array_equal(np.stack(old_raw), np.stack(new_raw), equal_nan=True)
|
||||
assert np.array_equal(np.stack(old_newton), np.stack(new_newton), equal_nan=True)
|
||||
assert old_status == new_status
|
||||
assert new.zero.phase == "ready"
|
||||
assert np.isfinite(new_newton[-1][0])
|
||||
assert np.isnan(new_newton[-1][1]) and np.isnan(new_newton[-1][2])
|
||||
assert new_status[-1][1:] == ["uncalibrated", "uncalibrated"]
|
||||
|
||||
|
||||
def test_field_model_matches_old_session_on_this_platform(tmp_path) -> None:
|
||||
_compare_field_with_old(tmp_path)
|
||||
|
||||
|
||||
def test_fn_only_model_matches_old_session_on_this_platform() -> None:
|
||||
_compare_fn_only_with_old()
|
||||
|
||||
|
||||
def test_three_calibration_types_match_the_old_session_on_this_platform(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
# Same interpreter/platform for both chains: no cross-OS golden comparison.
|
||||
_compare_with_old(helpers.box_cal()) # schema2 scalar curves
|
||||
_compare_field_with_old(tmp_path) # schema3 field model (FN-only)
|
||||
_compare_fn_only_with_old() # schema3 global gain (FN-only)
|
||||
|
||||
|
||||
def test_field_model_requires_context_and_field_frame(tmp_path) -> None:
|
||||
from orisys.calibration import CalibrationError
|
||||
|
||||
old_model, new_model, context = _field_models(tmp_path)
|
||||
with pytest.raises(CalibrationError, match="pipeline context"):
|
||||
ForceStreamSession(sensor_family="finger", calibration=old_model)
|
||||
|
||||
session = ForceStreamSession(
|
||||
sensor_family="finger",
|
||||
context=context,
|
||||
calibration=new_model,
|
||||
settings=helpers.legacy_settings(),
|
||||
)
|
||||
session.request_zero(
|
||||
reference_id="field-ref",
|
||||
declared_unloaded=True,
|
||||
expected_reference_generation=0,
|
||||
)
|
||||
missing = session.process(_field_measurement(1, include_field=False))
|
||||
assert missing.zero.phase == "failed"
|
||||
assert "invalid_input" in missing.zero.failure_reason
|
||||
assert all(np.isnan(value) for value in missing.newton)
|
||||
assert missing.newton_status[0] == "invalid_input"
|
||||
|
||||
|
||||
def test_field_model_uncalibrated_session_stays_explicit(tmp_path) -> None:
|
||||
_, _, context = _field_models(tmp_path)
|
||||
new = ForceStreamSession(
|
||||
sensor_family="finger",
|
||||
context=context,
|
||||
calibration=None,
|
||||
settings=helpers.legacy_settings(),
|
||||
)
|
||||
new.request_zero(
|
||||
reference_id="field-raw",
|
||||
declared_unloaded=True,
|
||||
expected_reference_generation=0,
|
||||
)
|
||||
rows = [_field_measurement(frame_id) for frame_id in range(1, 32)]
|
||||
results = [new.process(row) for row in rows]
|
||||
assert results[-1].zero.phase == "ready"
|
||||
assert all(np.isnan(value) for value in results[-1].newton)
|
||||
assert set(results[-1].newton_status) == {"uncalibrated"}
|
||||
assert results[-1].quality == "uncalibrated"
|
||||
|
||||
|
||||
def test_fn_only_model_requires_context_and_rejects_invalid_frames() -> None:
|
||||
from orisys.calibration import CalibrationError
|
||||
|
||||
old_model, new_model, context = _fn_only_models()
|
||||
with pytest.raises(CalibrationError, match="pipeline context"):
|
||||
ForceStreamSession(sensor_family="box", calibration=old_model)
|
||||
|
||||
new = ForceStreamSession(
|
||||
sensor_family="box",
|
||||
context=context,
|
||||
calibration=new_model,
|
||||
settings=helpers.legacy_settings(),
|
||||
)
|
||||
new.request_zero(
|
||||
reference_id="fn-ref", declared_unloaded=True, expected_reference_generation=0
|
||||
)
|
||||
invalid = new.process(_measurement(1, (float("nan"), 7.0, -7.0)))
|
||||
assert invalid.zero.phase == "failed"
|
||||
assert "invalid input frame" in invalid.zero.failure_reason
|
||||
assert all(np.isnan(value) for value in invalid.newton)
|
||||
|
||||
|
||||
def test_fn_only_model_uncalibrated_session_stays_explicit() -> None:
|
||||
_, _, context = _fn_only_models()
|
||||
new = ForceStreamSession(
|
||||
sensor_family="box",
|
||||
context=context,
|
||||
calibration=None,
|
||||
settings=helpers.legacy_settings(),
|
||||
)
|
||||
new.request_zero(
|
||||
reference_id="fn-raw", declared_unloaded=True, expected_reference_generation=0
|
||||
)
|
||||
results = [
|
||||
new.process(_measurement(frame_id, _fn_raw(frame_id)))
|
||||
for frame_id in range(1, 32)
|
||||
]
|
||||
assert results[-1].zero.phase == "ready"
|
||||
assert all(np.isnan(value) for value in results[-1].newton)
|
||||
assert set(results[-1].newton_status) == {"uncalibrated"}
|
||||
assert results[-1].quality == "uncalibrated"
|
||||
|
||||
|
||||
def test_unfiltered_measurement_fields_survive_the_output_filter() -> None:
|
||||
inputs = [100.0, 100.0, 100.0, 900.0, 100.0, 120.0, 140.0, 160.0]
|
||||
filtered = _new_chain(settings=helpers.legacy_settings(median_window=5))
|
||||
passthrough = _new_chain(settings=helpers.legacy_settings(median_window=1))
|
||||
for frame_id, value in enumerate(inputs, start=1):
|
||||
one = filtered.process(_measurement(frame_id, (value, 0.0, 0.0)))
|
||||
two = passthrough.process(_measurement(frame_id, (value, 0.0, 0.0)))
|
||||
assert one.measurement_raw == (value, 0.0, 0.0)
|
||||
assert two.raw == (value, 0.0, 0.0)
|
||||
assert two.measurement_raw == one.measurement_raw
|
||||
assert one.measurement_newton == two.measurement_newton
|
||||
assert filtered.process(_measurement(9, (900.0, 0.0, 0.0))).raw[0] != 900.0
|
||||
assert passthrough.process(_measurement(9, (900.0, 0.0, 0.0))).raw[0] == 900.0
|
||||
|
||||
|
||||
def test_diagnostic_rows_keep_the_unfiltered_measurement() -> None:
|
||||
session = _new_chain(settings=helpers.legacy_settings(median_window=5))
|
||||
session.process(_measurement(20, _raw_for(20)))
|
||||
duplicate = session.process(_measurement(20, _raw_for(20)))
|
||||
assert duplicate.quality == "duplicate"
|
||||
assert duplicate.measurement_raw == tuple(float(value) for value in _raw_for(20))
|
||||
assert duplicate.raw == duplicate.measurement_raw
|
||||
assert all(np.isnan(value) for value in duplicate.measurement_newton)
|
||||
assert all(np.isnan(value) for value in duplicate.newton)
|
||||
|
||||
|
||||
def test_calibrated_measurement_newton_is_unfiltered() -> None:
|
||||
filt = _new_chain(
|
||||
helpers.box_cal(),
|
||||
helpers.legacy_settings(
|
||||
median_window=5, lowpass_hz=3.0, lowpass_order=2, sample_rate_hz=30.0
|
||||
),
|
||||
)
|
||||
passthrough = _new_chain(
|
||||
helpers.box_cal(), helpers.legacy_settings(median_window=1)
|
||||
)
|
||||
for session in (filt, passthrough):
|
||||
session.request_zero(
|
||||
reference_id="r",
|
||||
declared_unloaded=True,
|
||||
expected_reference_generation=0,
|
||||
)
|
||||
rows = []
|
||||
pass_rows = []
|
||||
for frame_id in range(1, 80):
|
||||
raw = list(_raw_for(1))
|
||||
if frame_id == 60:
|
||||
raw[0] += 20000.0
|
||||
rows.append(filt.process(_measurement(frame_id, tuple(raw))))
|
||||
pass_rows.append(passthrough.process(_measurement(frame_id, tuple(raw))))
|
||||
for filtered, plain in zip(rows, pass_rows):
|
||||
for measured, reference in zip(
|
||||
filtered.measurement_newton, plain.newton
|
||||
):
|
||||
assert (np.isnan(measured) and np.isnan(reference)) or measured == reference
|
||||
assert filtered.measurement_raw == plain.raw
|
||||
spike = rows[59]
|
||||
assert spike.newton[0] != spike.measurement_newton[0]
|
||||
|
||||
|
||||
def test_late_frame_carries_unfiltered_measurement_fields() -> None:
|
||||
session = _new_chain(
|
||||
helpers.box_cal(),
|
||||
helpers.legacy_settings(
|
||||
median_window=5, lowpass_hz=3.0, lowpass_order=2, sample_rate_hz=30.0
|
||||
),
|
||||
)
|
||||
session.request_zero(
|
||||
reference_id="r", declared_unloaded=True, expected_reference_generation=0
|
||||
)
|
||||
for frame_id in range(1, 32):
|
||||
session.process(_measurement(frame_id, _raw_for(frame_id)))
|
||||
raw = _raw_for(32)
|
||||
late = session.process(
|
||||
_measurement(32, raw, capture_time_ns=31 * FRAME_NS)
|
||||
)
|
||||
assert late.late is True
|
||||
assert late.measurement_raw == tuple(float(value) for value in raw)
|
||||
assert all(np.isnan(value) for value in late.measurement_newton)
|
||||
@@ -0,0 +1,521 @@
|
||||
"""ImagePipeline (T05): independent image block, delegation, read-only results.
|
||||
|
||||
The image block is verified three ways: the same prepared frames through the
|
||||
published Sensor path and through a standalone ImagePipeline must produce
|
||||
bit-identical flow/HHD/force/field results; measured arrays must be read-only
|
||||
copies that later frames cannot overwrite; and the optical-flow reference
|
||||
generation must be written when the image thread computes a frame (a reset
|
||||
requested while a frame is in flight applies to the next frame only).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys import Sensor, configs
|
||||
from orisys.image_pipeline import (
|
||||
ImageMeasurement,
|
||||
ImagePipeline,
|
||||
ImageSettings,
|
||||
sensor_clock,
|
||||
)
|
||||
from orisys.sensors import build_sensor_context
|
||||
from orisys.stream_contract import FrameIdentity, MeasurementFrame
|
||||
|
||||
|
||||
def _frames(count: int = 4, size: int = 365) -> list[np.ndarray]:
|
||||
"""Deterministic grey frames whose pattern translates a few pixels."""
|
||||
height = width = size
|
||||
yy, xx = np.mgrid[0:height, 0:width]
|
||||
frames = []
|
||||
for index in range(count):
|
||||
cy = 130.0 + 2.0 * index
|
||||
cx = 210.0 + 3.0 * index
|
||||
blob = 120.0 * np.exp(-(((xx - cx) / 25.0) ** 2 + ((yy - cy) / 21.0) ** 2))
|
||||
gradient = 40.0 + 30.0 * (xx / width) + 20.0 * (yy / height)
|
||||
frames.append(np.clip(gradient + blob, 0.0, 255.0).astype(np.uint8))
|
||||
return frames
|
||||
|
||||
|
||||
def _config(name: str = "box") -> dict:
|
||||
return configs.load_config(name)
|
||||
|
||||
|
||||
def _context():
|
||||
cfg = _config()
|
||||
return build_sensor_context(
|
||||
config_name="box",
|
||||
config=cfg,
|
||||
device_id="BOX-001",
|
||||
bundle_candidates=["box"],
|
||||
)
|
||||
|
||||
|
||||
def _identity(context, frame_id: int = 0) -> FrameIdentity:
|
||||
return context.frame_identity(
|
||||
session_id="run-1", frame_id=frame_id, source_frame_id=frame_id
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- same-run parity
|
||||
def test_standalone_pipeline_matches_the_published_sensor_path() -> None:
|
||||
"""Old (Sensor delegation) and new (standalone) paths, same frames, same run."""
|
||||
sensor = Sensor(
|
||||
"external", config_name="box", verbose=False, backend="cpu", cuda=False, mirror=False
|
||||
)
|
||||
try:
|
||||
settings = ImageSettings.from_config(
|
||||
_config(),
|
||||
cuda=False,
|
||||
backend="cpu",
|
||||
flow_ema_alpha=sensor._force_readout.flow_ema_alpha,
|
||||
)
|
||||
assert settings.as_dict() == sensor.image_settings.as_dict()
|
||||
pipeline = ImagePipeline(settings)
|
||||
context = _context()
|
||||
|
||||
for index, raw in enumerate(_frames()):
|
||||
prepared = sensor.get_img(
|
||||
frame=raw, already_processed=True, already_gray=True
|
||||
)
|
||||
assert np.array_equal(prepared, raw), "same-size resize must be identity"
|
||||
|
||||
sensor.compute_flow()
|
||||
sensor.compute_hhd()
|
||||
sensor_flow = np.array(sensor.flow, copy=True)
|
||||
sensor_vnormal = np.array(sensor.vnormal, copy=True)
|
||||
sensor_vshear = np.array(sensor.vshear, copy=True)
|
||||
sensor_field = sensor.current_field()
|
||||
sensor_force = tuple(
|
||||
sensor.read_info(
|
||||
sensor.info.FNORMAL_RAW,
|
||||
sensor.info.FSHEARX_RAW,
|
||||
sensor.info.FSHEARY_RAW,
|
||||
)
|
||||
)
|
||||
|
||||
measurement = pipeline.measure(
|
||||
raw,
|
||||
identity=_identity(context, index),
|
||||
clock=sensor_clock(1_000_000 + index, clock_id="host"),
|
||||
include=("flow", "vnormal", "vshear", "field", "valid_mask"),
|
||||
)
|
||||
assert np.array_equal(measurement.flow, sensor_flow), index
|
||||
assert np.array_equal(measurement.vnormal, sensor_vnormal), index
|
||||
assert np.array_equal(measurement.vshear, sensor_vshear), index
|
||||
assert measurement.force_raw == pytest.approx(sensor_force), index
|
||||
for key, value in sensor_field.items():
|
||||
if isinstance(value, np.ndarray):
|
||||
assert np.array_equal(measurement.field[key], value), (index, key)
|
||||
else:
|
||||
assert measurement.field[key] == value, (index, key)
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
|
||||
def test_standalone_pipeline_runs_without_constructing_a_sensor() -> None:
|
||||
context = _context()
|
||||
pipeline = ImagePipeline(
|
||||
ImageSettings.from_config(_config(), cuda=False, backend="cpu"), context=context
|
||||
)
|
||||
measurement = pipeline.measure(
|
||||
_frames()[0],
|
||||
identity=_identity(context),
|
||||
clock=sensor_clock(123, clock_id="host"),
|
||||
source_dt_s=None,
|
||||
include=("flow",),
|
||||
)
|
||||
assert isinstance(measurement, ImageMeasurement)
|
||||
assert isinstance(measurement.frame, MeasurementFrame)
|
||||
assert measurement.frame.stamp.sensor_id == "BOX-001"
|
||||
assert measurement.frame.stamp.session_id == "run-1"
|
||||
assert measurement.frame.reference_generation == 0
|
||||
assert measurement.frame.source_dt_s is None
|
||||
# The T03 header is the serialised contract: it round-trips unchanged.
|
||||
assert (
|
||||
MeasurementFrame.from_dict(json.loads(json.dumps(measurement.frame.to_dict())))
|
||||
== measurement.frame
|
||||
)
|
||||
assert pipeline.context is context
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- read-only data
|
||||
def test_measured_arrays_are_readonly_copies_that_later_frames_do_not_rewrite() -> None:
|
||||
context = _context()
|
||||
pipeline = ImagePipeline(
|
||||
ImageSettings.from_config(_config(), cuda=False, backend="cpu"), context=context
|
||||
)
|
||||
frames = _frames()
|
||||
first = pipeline.measure(
|
||||
frames[0],
|
||||
identity=_identity(context, 0),
|
||||
clock=sensor_clock(10, clock_id="host"),
|
||||
include=("flow", "vnormal", "vshear", "field", "valid_mask"),
|
||||
)
|
||||
seen = {
|
||||
"flow": np.array(first.flow, copy=True),
|
||||
"vnormal": np.array(first.vnormal, copy=True),
|
||||
"field": np.array(first.field["d"], copy=True),
|
||||
}
|
||||
for name, array in (
|
||||
("flow", first.flow),
|
||||
("vnormal", first.vnormal),
|
||||
("field", first.field["d"]),
|
||||
):
|
||||
assert array.flags.writeable is False, name
|
||||
with pytest.raises(ValueError):
|
||||
array[0, 0] = 1.0
|
||||
|
||||
# Computing the next frame must not overwrite what was already published.
|
||||
second = pipeline.measure(
|
||||
frames[1],
|
||||
identity=_identity(context, 1),
|
||||
clock=sensor_clock(20, clock_id="host"),
|
||||
include=("flow", "field"),
|
||||
)
|
||||
assert np.array_equal(first.flow, seen["flow"])
|
||||
assert np.array_equal(first.vnormal, seen["vnormal"])
|
||||
assert np.array_equal(first.field["d"], seen["field"])
|
||||
assert not np.array_equal(second.flow, first.flow)
|
||||
|
||||
|
||||
def test_include_selects_only_requested_arrays() -> None:
|
||||
context = _context()
|
||||
pipeline = ImagePipeline(
|
||||
ImageSettings.from_config(_config(), cuda=False, backend="cpu"), context=context
|
||||
)
|
||||
measurement = pipeline.measure(
|
||||
_frames()[0],
|
||||
identity=_identity(context),
|
||||
clock=sensor_clock(1, clock_id="host"),
|
||||
include=("flow",),
|
||||
)
|
||||
assert measurement.flow is not None
|
||||
assert measurement.vnormal is None and measurement.vshear is None
|
||||
assert measurement.field is None and measurement.valid_mask is None
|
||||
with pytest.raises(ValueError, match="unknown include"):
|
||||
pipeline.measure(
|
||||
_frames()[0],
|
||||
identity=_identity(context),
|
||||
clock=sensor_clock(1, clock_id="host"),
|
||||
include=("not-a-field",),
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------ reference-generation seam
|
||||
class _GatedPipeline(ImagePipeline):
|
||||
"""Barrier-style gate: hold a frame after its reference version was captured."""
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.entered = threading.Event()
|
||||
self.release = threading.Event()
|
||||
self.gate_once = True
|
||||
|
||||
def _compute_flow_state(self, image, *, valid_mask=None):
|
||||
if self.gate_once:
|
||||
self.gate_once = False
|
||||
self.entered.set()
|
||||
assert self.release.wait(timeout=5.0)
|
||||
return super()._compute_flow_state(image, valid_mask=valid_mask)
|
||||
|
||||
|
||||
def test_inflight_frame_keeps_its_own_reference_version() -> None:
|
||||
"""The stream can adopt a new version while an older frame is still in flight."""
|
||||
context = _context()
|
||||
pipeline = _GatedPipeline(
|
||||
ImageSettings.from_config(_config(), cuda=False, backend="cpu"), context=context
|
||||
)
|
||||
frames = _frames()
|
||||
results: list[ImageMeasurement] = []
|
||||
|
||||
def compute_first() -> None:
|
||||
results.append(
|
||||
pipeline.measure(
|
||||
frames[0],
|
||||
identity=_identity(context, 0),
|
||||
clock=sensor_clock(100, clock_id="host"),
|
||||
reference_generation=0,
|
||||
)
|
||||
)
|
||||
|
||||
worker = threading.Thread(target=compute_first)
|
||||
worker.start()
|
||||
try:
|
||||
assert pipeline.entered.wait(timeout=5.0), "first frame never reached the gate"
|
||||
# The stream adopts version 1 while frame 0 is provably in flight.
|
||||
pipeline.set_reference_generation(1)
|
||||
assert pipeline.reference_generation == 1
|
||||
finally:
|
||||
pipeline.release.set()
|
||||
worker.join(timeout=5.0)
|
||||
assert not worker.is_alive()
|
||||
in_flight = results[0]
|
||||
assert in_flight.frame.reference_generation == 0, "old frame was relabelled"
|
||||
|
||||
next_measurement = pipeline.measure(
|
||||
frames[1],
|
||||
identity=_identity(context, 1),
|
||||
clock=sensor_clock(200, clock_id="host"),
|
||||
reference_generation=1,
|
||||
)
|
||||
assert next_measurement.frame.reference_generation == 1
|
||||
assert in_flight.frame.reference_generation == 0
|
||||
|
||||
|
||||
def test_reference_version_is_adopted_not_counted_by_the_image_block() -> None:
|
||||
""":class:`ImagePipeline` stamps the stream's version; a reset does not bump it."""
|
||||
context = _context()
|
||||
pipeline = ImagePipeline(
|
||||
ImageSettings.from_config(_config(), cuda=False, backend="cpu"), context=context
|
||||
)
|
||||
frames = _frames()
|
||||
first = pipeline.measure(
|
||||
frames[0],
|
||||
identity=_identity(context, 0),
|
||||
clock=sensor_clock(1, clock_id="host"),
|
||||
reference_generation=0,
|
||||
)
|
||||
assert first.frame.reference_generation == 0
|
||||
pipeline.reset_image_state()
|
||||
zeroed = pipeline.measure(
|
||||
frames[1],
|
||||
identity=_identity(context, 1),
|
||||
clock=sensor_clock(2, clock_id="host"),
|
||||
reference_generation=0,
|
||||
)
|
||||
assert zeroed.frame.reference_generation == 0, "reset must not invent a version"
|
||||
pipeline.set_reference_generation(3)
|
||||
stamped = pipeline.measure(
|
||||
frames[2],
|
||||
identity=_identity(context, 2),
|
||||
clock=sensor_clock(3, clock_id="host"),
|
||||
reference_generation=3,
|
||||
)
|
||||
assert stamped.frame.reference_generation == 3
|
||||
assert pipeline.reference_generation == 3
|
||||
|
||||
|
||||
def _drive_pipeline(sensor: Sensor, frames: list[np.ndarray], *, request_reset: bool):
|
||||
"""Run one StreamPipeline; record per-flow-frame adopted versions and snapshots."""
|
||||
from orisys.pipeline import StreamPipeline
|
||||
|
||||
flow_versions: list[int] = []
|
||||
snapshots: list = []
|
||||
original = sensor.compute_flow
|
||||
|
||||
def recording_flow(*args, **kwargs):
|
||||
flow_versions.append(int(sensor.image_pipeline.reference_generation))
|
||||
return original(*args, **kwargs)
|
||||
|
||||
sensor.compute_flow = recording_flow
|
||||
queue = iter(frames)
|
||||
|
||||
def produce():
|
||||
try:
|
||||
raw = next(queue)
|
||||
except StopIteration:
|
||||
return None
|
||||
return sensor.get_img(frame=raw, already_processed=True, already_gray=True)
|
||||
|
||||
pipeline = StreamPipeline(
|
||||
sensor, produce, include=(), on_frame=snapshots.append, verbose=False
|
||||
)
|
||||
if request_reset:
|
||||
pipeline.request_reset()
|
||||
pipeline.start()
|
||||
try:
|
||||
deadline = time.perf_counter() + 10.0
|
||||
while pipeline.running and time.perf_counter() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert pipeline.error is None
|
||||
finally:
|
||||
pipeline.stop()
|
||||
sensor.compute_flow = original
|
||||
snapshot_versions = [int(snapshot.tracker_reset_seq) for snapshot in snapshots]
|
||||
return flow_versions, snapshot_versions
|
||||
|
||||
|
||||
def test_pipeline_driven_reference_versions_match_every_snapshot_on_a_reused_sensor() -> None:
|
||||
"""One version source: StreamPipeline's claimed version reaches the image block."""
|
||||
sensor = Sensor(
|
||||
"external", config_name="box", verbose=False, backend="cpu", cuda=False, mirror=False
|
||||
)
|
||||
frames = _frames()
|
||||
try:
|
||||
sensor.reset()
|
||||
flow_versions, snapshot_versions = _drive_pipeline(sensor, frames, request_reset=False)
|
||||
assert flow_versions == snapshot_versions == [0, 0, 0, 0]
|
||||
|
||||
flow_versions, snapshot_versions = _drive_pipeline(sensor, frames, request_reset=True)
|
||||
assert flow_versions == snapshot_versions == [1, 1, 1, 1]
|
||||
|
||||
# A direct Sensor.reset() between two runs must not leave the image block
|
||||
# on an old private counter: the next stream's version 0 wins.
|
||||
sensor.reset()
|
||||
flow_versions, snapshot_versions = _drive_pipeline(sensor, frames, request_reset=False)
|
||||
assert flow_versions == snapshot_versions == [0, 0, 0, 0]
|
||||
assert sensor.image_pipeline.reference_generation == 0
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
|
||||
# ------------------------------------------------------ published flow-EMA binding
|
||||
def test_runtime_flow_ema_configuration_keeps_head_behaviour() -> None:
|
||||
"""``Sensor.configure(flow_ema_alpha=...)`` must affect the next frame (HEAD)."""
|
||||
from orisys.force_readout import ForceReadout
|
||||
|
||||
context = _context()
|
||||
sensor = Sensor(
|
||||
"external", config_name="box", verbose=False, backend="cpu", cuda=False, mirror=False
|
||||
)
|
||||
# HEAD-equivalent reference: the published Tracker/ForceReadout composition.
|
||||
reference_readout = ForceReadout({"flow_ema_alpha": 1.0})
|
||||
reference = ImagePipeline(
|
||||
ImageSettings.from_config(
|
||||
_config(), cuda=False, backend="cpu", flow_ema_alpha=1.0
|
||||
),
|
||||
flow_ema=reference_readout,
|
||||
)
|
||||
try:
|
||||
for index, raw in enumerate(_frames()):
|
||||
if index == 2:
|
||||
sensor.configure(flow_ema_alpha=0.5)
|
||||
reference_readout.configure(flow_ema_alpha=0.5)
|
||||
prepared = sensor.get_img(
|
||||
frame=raw, already_processed=True, already_gray=True
|
||||
)
|
||||
sensor.compute_flow()
|
||||
sensor.compute_hhd()
|
||||
measurement = reference.measure(
|
||||
prepared,
|
||||
identity=_identity(context, index),
|
||||
clock=sensor_clock(1000 + index, clock_id="host"),
|
||||
include=("flow", "vnormal"),
|
||||
reference_generation=0,
|
||||
)
|
||||
assert sensor.image_pipeline.flow_ema_alpha == 0.5 if index >= 2 else True
|
||||
assert np.array_equal(np.array(sensor.flow, copy=True), measurement.flow), index
|
||||
assert np.array_equal(
|
||||
np.array(sensor.vnormal, copy=True), measurement.vnormal
|
||||
), index
|
||||
sensor_force = tuple(
|
||||
sensor.read_info(
|
||||
sensor.info.FNORMAL_RAW,
|
||||
sensor.info.FSHEARX_RAW,
|
||||
sensor.info.FSHEARY_RAW,
|
||||
)
|
||||
)
|
||||
assert sensor_force == measurement.force_raw, index
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
|
||||
def test_static_flow_ema_config_is_bound_once(tmp_path) -> None:
|
||||
"""A config-declared alpha<1 keeps the same flow through Sensor and a standalone pipeline."""
|
||||
config = _config()
|
||||
config["filters"] = dict(config.get("filters") or {}, flow_ema_alpha=0.5)
|
||||
config_path = tmp_path / "box-ema05.json"
|
||||
config_path.write_text(json.dumps(config), encoding="utf-8")
|
||||
|
||||
context = _context()
|
||||
pipeline = ImagePipeline(
|
||||
ImageSettings.from_config(config, cuda=False, backend="cpu")
|
||||
)
|
||||
sensor = Sensor(
|
||||
"external",
|
||||
config_name=str(config_path),
|
||||
verbose=False,
|
||||
backend="cpu",
|
||||
cuda=False,
|
||||
mirror=False,
|
||||
)
|
||||
try:
|
||||
assert pipeline.settings.flow_ema_alpha == 0.5
|
||||
assert sensor.image_pipeline.flow_ema_alpha == 0.5
|
||||
for index, raw in enumerate(_frames()):
|
||||
prepared = sensor.get_img(
|
||||
frame=raw, already_processed=True, already_gray=True
|
||||
)
|
||||
sensor.compute_flow()
|
||||
sensor.compute_hhd()
|
||||
measurement = pipeline.measure(
|
||||
prepared,
|
||||
identity=_identity(context, index),
|
||||
clock=sensor_clock(index, clock_id="host"),
|
||||
include=("flow",),
|
||||
reference_generation=0,
|
||||
)
|
||||
assert np.array_equal(np.array(sensor.flow, copy=True), measurement.flow), index
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
|
||||
def test_flow_ema_matches_the_force_readout_recurrence() -> None:
|
||||
from orisys.force_readout import ForceReadout
|
||||
|
||||
settings = ImageSettings.from_config(
|
||||
_config(), cuda=False, backend="cpu", flow_ema_alpha=0.5
|
||||
)
|
||||
pipeline = ImagePipeline(settings)
|
||||
readout = ForceReadout({"flow_ema_alpha": 0.5})
|
||||
rng = np.random.default_rng(7)
|
||||
for _ in range(4):
|
||||
flow = rng.normal(0.0, 1.0, (6, 5, 2)).astype(np.float32)
|
||||
pipeline_flow = pipeline._apply_flow_ema(flow.copy())
|
||||
readout_flow = readout.apply_flow_ema(flow.copy())
|
||||
assert np.array_equal(pipeline_flow, readout_flow)
|
||||
pipeline.reset_image_state()
|
||||
readout.reset_flow_ema()
|
||||
flow = rng.normal(0.0, 1.0, (6, 5, 2)).astype(np.float32)
|
||||
assert np.array_equal(
|
||||
pipeline._apply_flow_ema(flow.copy()), readout.apply_flow_ema(flow.copy())
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------------------------------------- delegation paths
|
||||
def test_sensor_delegates_its_image_block_to_one_pipeline() -> None:
|
||||
sensor = Sensor(
|
||||
"external", config_name="box", verbose=False, backend="cpu", cuda=False, mirror=False
|
||||
)
|
||||
try:
|
||||
assert isinstance(sensor.image_pipeline, ImagePipeline)
|
||||
assert (
|
||||
sensor.image_pipeline.settings.as_dict() == sensor.image_settings.as_dict()
|
||||
)
|
||||
sensor.get_img(frame=_frames()[0], already_processed=True, already_gray=True)
|
||||
sensor.compute_flow()
|
||||
assert sensor.flow is sensor.image_pipeline.flow
|
||||
sensor.compute_hhd()
|
||||
assert sensor.vnormal is sensor.image_pipeline.vnormal
|
||||
assert sensor.vshear is sensor.image_pipeline.vshear
|
||||
before = sensor.image_pipeline.reference_generation
|
||||
sensor.reset_image_state()
|
||||
# The reference version belongs to the stream; a local reset must not
|
||||
# invent one. Only an adopted stream version changes it.
|
||||
assert sensor.image_pipeline.reference_generation == before
|
||||
sensor.set_stream_reference_generation(7)
|
||||
assert sensor.image_pipeline.reference_generation == 7
|
||||
# The published Newton/readout accessors keep working.
|
||||
assert sensor.force_unit in ("N", "raw")
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
|
||||
def test_finger_runtime_exposes_the_same_image_pipeline() -> None:
|
||||
from orisys.finger.runtime import FingerRuntime
|
||||
|
||||
pipeline = object()
|
||||
runtime = FingerRuntime.__new__(FingerRuntime)
|
||||
runtime.tactile = type(
|
||||
"Tactile", (), {"raw_sensor": type("Raw", (), {"image_pipeline": pipeline})()}
|
||||
)()
|
||||
assert runtime.image_pipeline is pipeline
|
||||
|
||||
runtime.tactile = type("Tactile", (), {"raw_sensor": object()})()
|
||||
assert runtime.image_pipeline is None
|
||||
@@ -55,6 +55,45 @@ def make_sensor(*, median=1):
|
||||
return sensor
|
||||
|
||||
|
||||
def test_finger_model_swap_validates_actual_runtime_and_invalidates_zero(monkeypatch, tmp_path):
|
||||
import orisys.calibration_context as context_mod
|
||||
from orisys.finger.runtime import FingerRuntime
|
||||
|
||||
sensor = make_sensor()
|
||||
sensor.device_id = None
|
||||
sensor.mirror = False
|
||||
sensor.set_calibration_reference({"fn": 10}, reference_id="old-zero")
|
||||
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 = RecordingModel()
|
||||
selected.pipeline = {"coordinates": {"frame": "autocal_flat"}}
|
||||
runtime.set_calibration(selected)
|
||||
assert sensor.calibration is selected
|
||||
assert sensor.calibration_status == {"fn": "zero_unavailable"}
|
||||
assert contexts[0]["geometry_sha256"] == context_mod.sha256_file(mesh)
|
||||
assert contexts[0]["output_domain_sha256"] == "domain"
|
||||
wrong_shape = RecordingModel()
|
||||
wrong_shape.pipeline = selected.pipeline
|
||||
wrong_shape.coordinate_map = np.zeros((1, 1, 2))
|
||||
with pytest.raises(ValueError, match="shape"):
|
||||
runtime.set_calibration(wrong_shape)
|
||||
assert sensor.calibration is selected
|
||||
|
||||
|
||||
def predict(sensor, frame, fn, *, contact=True, mapped=True):
|
||||
return sensor.apply_calibration_frame(frame_id=frame, timestamp_s=frame / 30.,
|
||||
raw=(fn, 20., 30.), uv=(0., 0.),
|
||||
@@ -306,3 +345,276 @@ def test_bad_zero_reference_is_rejected_without_reset(reference):
|
||||
with pytest.raises(ValueError):
|
||||
sensor.set_calibration_reference(reference, reference_id="bad")
|
||||
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"]
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------- session measurement seam
|
||||
def test_measurement_session_skips_old_inference_and_readout():
|
||||
"""The new entry publishes same-frame raw only: no model call, no readout."""
|
||||
sensor = make_sensor()
|
||||
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)
|
||||
sensor._Sensor__hhd = SimpleNamespace(
|
||||
get_force=lambda *args, **kwargs: (None, (10., 0., 0., 0.)),
|
||||
get_normal=lambda: field,
|
||||
get_shear=lambda: field,
|
||||
)
|
||||
sensor.enable_measurement_session()
|
||||
assert sensor.measurement_session is True
|
||||
sensor.compute_hhd(field)
|
||||
assert sensor.calibration.calls == [], "the session owns the one model call"
|
||||
assert sensor._force_readout.filtered_raw is None
|
||||
assert sensor._force_raw == (10., 0., 0.)
|
||||
assert np.isnan(sensor.fnormal)
|
||||
assert sensor.calibration_status == {}
|
||||
|
||||
# Contact statistics stay available, still without a model inference.
|
||||
sensor._Sensor__contact3d = SimpleNamespace(
|
||||
compute_foe=lambda *args, **kwargs: {
|
||||
"centroid": (0, 1), "centroid_found": True,
|
||||
"divergence_map": np.zeros((2, 2)),
|
||||
}
|
||||
)
|
||||
sensor.compute_contact()
|
||||
assert sensor.calibration.calls == []
|
||||
assert sensor.centroid_found is True
|
||||
|
||||
sensor.disable_measurement_session()
|
||||
assert sensor.measurement_session is False
|
||||
|
||||
|
||||
def test_reset_tracker_confirms_without_touching_the_calibration_reference():
|
||||
sensor = make_sensor()
|
||||
resets = []
|
||||
sensor._Sensor__tracker = SimpleNamespace(reset=lambda: resets.append("tracker"))
|
||||
sensor.set_calibration_reference({"fn": 10.}, reference_id="reference")
|
||||
sensor._force_unfiltered = (1., 2., 3.)
|
||||
sensor.compute_deformation_valid = True
|
||||
sensor.reset_tracker()
|
||||
assert resets == ["tracker"]
|
||||
assert sensor._force_unfiltered is None
|
||||
assert sensor._calibration_reference == {"fn": 10.}
|
||||
assert sensor._calibration_reference_id == "reference"
|
||||
assert sensor.compute_deformation_valid is False
|
||||
sensor.enable_measurement_session()
|
||||
sensor.reset_tracker()
|
||||
assert sensor.measurement_session is True, "the seam survives a frame-boundary reset"
|
||||
|
||||
|
||||
def test_measure_current_frame_returns_this_frames_features_once():
|
||||
sensor = make_sensor()
|
||||
sensor.cuda = sensor.verbose = False
|
||||
sensor.valid_mask = np.ones((2, 2), dtype=bool)
|
||||
sensor.valid_mask_hhd = np.ones((2, 2), dtype=bool)
|
||||
sensor._hhd_mask_f32 = 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)
|
||||
sensor._Sensor__hhd = SimpleNamespace(
|
||||
get_force=lambda *args, **kwargs: (None, (10., 20., 30., 0.)),
|
||||
get_normal=lambda: field,
|
||||
get_shear=lambda: field,
|
||||
get_field=lambda valid_mask=None: {
|
||||
"d": field, "valid_mask": np.ones((2, 2), dtype=bool),
|
||||
"area_scale": 4.0, "grid_shape": (2, 2), "reference_shape": (2, 2),
|
||||
},
|
||||
)
|
||||
sensor._Sensor__contact3d = SimpleNamespace(
|
||||
compute_foe=lambda *args, **kwargs: {
|
||||
"centroid": (0.4, 1.2), "centroid_found": True,
|
||||
"divergence_map": np.zeros((2, 2)),
|
||||
}
|
||||
)
|
||||
flow_calls = []
|
||||
sensor.compute_flow = lambda image=None: (
|
||||
flow_calls.append(image), setattr(sensor, "flow", field)
|
||||
)
|
||||
sensor.enable_measurement_session()
|
||||
sensor.img = np.zeros((2, 2), dtype=np.uint8)
|
||||
|
||||
measurement = sensor.measure_current_frame(
|
||||
frame_id=5, want_field=True, want_contact=True
|
||||
)
|
||||
assert flow_calls == [sensor.img]
|
||||
assert measurement.raw == (10., 20., 30.)
|
||||
assert measurement.raw_valid == (True, True, True)
|
||||
assert measurement.field["grid_shape"] == (2, 2)
|
||||
assert measurement.output_valid_mask.shape == (2, 2)
|
||||
assert measurement.contact_found is True
|
||||
assert measurement.centroid == (0.4, 1.2)
|
||||
assert measurement.single_contact is True
|
||||
|
||||
|
||||
def test_read_measurement_frame_prepares_the_source_frame_once():
|
||||
sensor = make_sensor()
|
||||
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)
|
||||
sensor._Sensor__hhd = SimpleNamespace(
|
||||
get_force=lambda *args, **kwargs: (None, (1., 2., 3., 0.)),
|
||||
get_normal=lambda: field,
|
||||
get_shear=lambda: field,
|
||||
)
|
||||
prepared = []
|
||||
sensor.get_img = lambda frame=None: (prepared.append(frame), setattr(sensor, "img", frame))
|
||||
sensor.compute_flow = lambda image=None: setattr(sensor, "flow", field)
|
||||
sensor.enable_measurement_session()
|
||||
frame = np.ones((2, 2, 3), dtype=np.uint8)
|
||||
measurement = sensor.read_measurement_frame(frame, frame_id=7)
|
||||
assert prepared == [frame]
|
||||
assert measurement.raw == (1., 2., 3.)
|
||||
assert measurement.field is None, "no field unless the model asked for it"
|
||||
assert measurement.contact_found is None, "no contact unless it was requested"
|
||||
|
||||
|
||||
def test_finger_runtime_session_seam_delegates_to_its_sensor():
|
||||
from orisys.finger.runtime import FingerRuntime
|
||||
|
||||
events = []
|
||||
|
||||
class FakeSensor:
|
||||
def enable_measurement_session(self):
|
||||
events.append("enable")
|
||||
|
||||
def disable_measurement_session(self):
|
||||
events.append("disable")
|
||||
|
||||
def reset_tracker(self):
|
||||
events.append("reset")
|
||||
|
||||
def measure_current_frame(self, *, frame_id, want_field=False, want_contact=False):
|
||||
events.append(("measure", frame_id, want_field, want_contact))
|
||||
return "measurement"
|
||||
|
||||
runtime = object.__new__(FingerRuntime)
|
||||
runtime.tactile = SimpleNamespace(raw_sensor=FakeSensor())
|
||||
processed = []
|
||||
runtime.process_frame = lambda frame, color=False: processed.append((frame, color))
|
||||
frame = np.zeros((2, 2, 3), dtype=np.uint8)
|
||||
result = runtime.read_measurement_frame(
|
||||
frame, frame_id=9, want_field=True, want_contact=False
|
||||
)
|
||||
assert processed == [(frame, False)]
|
||||
assert result == "measurement"
|
||||
assert events[-1] == ("measure", 9, True, False)
|
||||
runtime.enable_measurement_session()
|
||||
runtime.reset_tracker()
|
||||
runtime.disable_measurement_session()
|
||||
assert events[1:] == ["enable", "reset", "disable"]
|
||||
|
||||
+346
-1
@@ -8,7 +8,7 @@ import time
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys import FrameSnapshot, ProducedFrame, StreamPipeline
|
||||
from orisys import FrameSnapshot, PauseLevel, ProducedFrame, StreamPipeline
|
||||
|
||||
|
||||
class FakeSensor:
|
||||
@@ -429,3 +429,348 @@ def test_snapshot_raw_capture_size_is_none_when_unreported():
|
||||
time.sleep(0.001)
|
||||
pipe.stop()
|
||||
assert frames and frames[0].image_size_raw is None
|
||||
|
||||
|
||||
class SplitResetFakeSensor(FakeSensor):
|
||||
"""A fake with the split reset seam and event-gated flow/HHD stages.
|
||||
|
||||
The two halves of a reset belong to different pipeline threads, and the
|
||||
version a frame is stamped with depends on which half has already run. The
|
||||
events pin both stages to a known frame so the regression orders the race
|
||||
instead of hoping for it: the flow stage parks at the end of its second
|
||||
frame and the HHD stage parks inside its first decomposition.
|
||||
"""
|
||||
|
||||
FLOW_GATE_CALL = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.flow_gate_reached = threading.Event()
|
||||
self.release_flow_gate = threading.Event()
|
||||
self.hhd_gate_reached = threading.Event()
|
||||
self.release_hhd_gate = threading.Event()
|
||||
self.hhd_calls = 0
|
||||
self.image_reset_threads: list[str] = []
|
||||
self.force_reset_threads: list[str] = []
|
||||
self.force_reset_at_hhd_calls: list[int] = []
|
||||
|
||||
def compute_flow(self, img=None, **kwargs) -> None:
|
||||
super().compute_flow(img, **kwargs)
|
||||
# Park after frame 2's flow. The next frame that reaches the flow stage
|
||||
# is then provably the first one that can observe a reset queued now.
|
||||
if self._flow_calls == self.FLOW_GATE_CALL:
|
||||
self.flow_gate_reached.set()
|
||||
self.release_flow_gate.wait(timeout=5.0)
|
||||
|
||||
def compute_hhd(self, flow=None, **kwargs) -> None:
|
||||
self.hhd_calls += 1
|
||||
if self.hhd_calls == 1:
|
||||
self.hhd_gate_reached.set()
|
||||
self.release_hhd_gate.wait(timeout=5.0)
|
||||
super().compute_hhd(flow, **kwargs)
|
||||
|
||||
def reset_image_state(self) -> None:
|
||||
self.image_reset_threads.append(threading.current_thread().name)
|
||||
|
||||
def reset_force_state(self) -> None:
|
||||
self.force_reset_threads.append(threading.current_thread().name)
|
||||
self.force_reset_at_hhd_calls.append(self.hhd_calls)
|
||||
|
||||
def reset(self) -> None:
|
||||
# Mirrors Sensor.reset(): both halves, immediately, on the caller's thread.
|
||||
self.reset_calls += 1
|
||||
self.reset_at_frame.append(self._flow_calls)
|
||||
self.reset_image_state()
|
||||
self.reset_force_state()
|
||||
|
||||
|
||||
class LegacyResetFakeSensor(FakeSensor):
|
||||
"""A stand-in with only the historical ``reset()``, like a third-party sensor."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.reset_threads: list[str] = []
|
||||
|
||||
def reset(self) -> None:
|
||||
self.reset_calls += 1
|
||||
self.reset_at_frame.append(self._flow_calls)
|
||||
self.reset_threads.append(threading.current_thread().name)
|
||||
|
||||
|
||||
def test_reset_request_target_is_shared_until_flow_claims_it():
|
||||
sensor = FakeSensor()
|
||||
frames = []
|
||||
pipe = StreamPipeline(sensor, make_producer(sensor, 2), on_frame=frames.append)
|
||||
assert pipe.request_reset() == 1
|
||||
assert pipe.reset() is None # The published entry keeps its return contract.
|
||||
assert pipe.request_reset() == 1
|
||||
assert pipe.tracker_reset_seq == 0
|
||||
pipe.start()
|
||||
try:
|
||||
assert _wait_until(lambda: len(frames) == 2)
|
||||
assert [snap.tracker_reset_seq for snap in frames] == [1, 1]
|
||||
assert sensor.reset_calls == 1
|
||||
assert pipe.request_reset() == 2
|
||||
finally:
|
||||
pipe.stop()
|
||||
|
||||
|
||||
def test_reset_requested_while_previous_reset_is_running_gets_a_new_target():
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
class Sensor(LegacyResetFakeSensor):
|
||||
def reset(self):
|
||||
super().reset()
|
||||
if self.reset_calls == 1:
|
||||
entered.set()
|
||||
assert release.wait(timeout=5.0)
|
||||
|
||||
sensor = Sensor()
|
||||
frames = []
|
||||
pipe = StreamPipeline(sensor, make_producer(sensor, 2), on_frame=frames.append)
|
||||
assert pipe.request_reset() == 1
|
||||
pipe.start()
|
||||
try:
|
||||
assert entered.wait(timeout=5.0)
|
||||
# Flow has claimed request 1, but has not finished applying it. The live
|
||||
# counter is still 0; request 2 must not be merged into request 1.
|
||||
assert pipe.tracker_reset_seq == 0
|
||||
assert pipe.request_reset() == 2
|
||||
assert pipe.request_reset() == 2
|
||||
release.set()
|
||||
assert _wait_until(lambda: len(frames) == 2)
|
||||
assert [snap.tracker_reset_seq for snap in frames] == [1, 2]
|
||||
assert sensor.reset_calls == 2
|
||||
finally:
|
||||
release.set()
|
||||
pipe.stop()
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout: float = 5.0) -> bool:
|
||||
"""Poll a predicate; the events, not this poll, order the race."""
|
||||
deadline = time.perf_counter() + timeout
|
||||
while time.perf_counter() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.001)
|
||||
return predicate()
|
||||
|
||||
|
||||
def _run_gated_reset(sensor: SplitResetFakeSensor, frames: list, count: int = 3) -> None:
|
||||
"""Drive two pre-reset frames and one post-reset frame through the real loops."""
|
||||
pipe = StreamPipeline(sensor, make_producer(sensor, count),
|
||||
on_frame=frames.append, depth=2)
|
||||
pipe.start()
|
||||
try:
|
||||
assert sensor.flow_gate_reached.wait(timeout=5.0), "flow stage never reached its gate"
|
||||
assert sensor.hhd_gate_reached.wait(timeout=5.0), "HHD stage never reached its gate"
|
||||
# Both stages are provably parked: no frame has been published, and the
|
||||
# frame whose flow would observe this reset has not been computed yet.
|
||||
pipe.reset()
|
||||
sensor.release_flow_gate.set()
|
||||
assert _wait_until(lambda: sensor._flow_calls >= 3), (
|
||||
"flow stage did not compute the first post-reset frame"
|
||||
)
|
||||
sensor.release_hhd_gate.set()
|
||||
deadline = time.perf_counter() + 5.0
|
||||
while len(frames) < count and time.perf_counter() < deadline:
|
||||
time.sleep(0.001)
|
||||
finally:
|
||||
sensor.release_flow_gate.set()
|
||||
sensor.release_hhd_gate.set()
|
||||
pipe.stop()
|
||||
assert len(frames) == count, f"expected {count} published frames, got {len(frames)}"
|
||||
|
||||
|
||||
def test_frames_keep_the_reference_version_they_were_measured_with():
|
||||
"""A reset queued after a frame's flow must not relabel that frame.
|
||||
|
||||
Frames 0 and 1 are measured while the HHD stage is held on the first
|
||||
snapshot. The caller then queues a reset, and frame 2 is measured with the
|
||||
new version. Each snapshot must carry the version its own flow was computed
|
||||
with; reading the live counter at publish time would stamp the two older
|
||||
frames with the newer version.
|
||||
"""
|
||||
sensor = SplitResetFakeSensor()
|
||||
frames: list[FrameSnapshot] = []
|
||||
_run_gated_reset(sensor, frames)
|
||||
versions = [snap.tracker_reset_seq for snap in frames]
|
||||
assert versions == [0, 0, 1], f"expected per-frame versions [0, 0, 1], got {versions}"
|
||||
|
||||
|
||||
def test_reset_halves_run_on_the_threads_that_own_their_state():
|
||||
"""Image state resets on the flow stage, force state on the HHD stage.
|
||||
|
||||
The force half must land on the HHD stage before it decomposes the first
|
||||
frame of the new version. Resetting it from the flow thread - the old
|
||||
behaviour - mutates model/zero state that the HHD stage is using.
|
||||
"""
|
||||
sensor = SplitResetFakeSensor()
|
||||
frames: list[FrameSnapshot] = []
|
||||
_run_gated_reset(sensor, frames)
|
||||
assert sensor.image_reset_threads == ["orisys-pipeline-flow"]
|
||||
assert sensor.force_reset_threads == ["orisys-pipeline-hhd"]
|
||||
# Two decompositions (frames 0 and 1) had completed when the force reset ran,
|
||||
# so it preceded the third one - the first frame of the new version.
|
||||
assert sensor.force_reset_at_hhd_calls == [2]
|
||||
assert sensor.hhd_calls == 3
|
||||
assert [snap.tracker_reset_seq for snap in frames] == [0, 0, 1]
|
||||
|
||||
|
||||
def test_legacy_sensor_with_only_reset_is_reset_on_the_flow_stage():
|
||||
"""A sensor without the split methods keeps the historical fallback."""
|
||||
sensor = LegacyResetFakeSensor()
|
||||
ready = threading.Event()
|
||||
release = threading.Event()
|
||||
state = {"produced": 0}
|
||||
|
||||
def gated_produce():
|
||||
ready.set()
|
||||
release.wait(timeout=5.0)
|
||||
if state["produced"]:
|
||||
return None
|
||||
state["produced"] = 1
|
||||
return sensor.make_frame(0)
|
||||
|
||||
frames: list = []
|
||||
pipe = StreamPipeline(sensor, gated_produce, on_frame=frames.append)
|
||||
pipe.start()
|
||||
try:
|
||||
assert ready.wait(timeout=5.0)
|
||||
pipe.reset()
|
||||
assert sensor.reset_calls == 0, "reset must not run on the caller's thread"
|
||||
release.set()
|
||||
deadline = time.perf_counter() + 5.0
|
||||
while not frames and time.perf_counter() < deadline:
|
||||
time.sleep(0.001)
|
||||
finally:
|
||||
release.set()
|
||||
pipe.stop()
|
||||
assert len(frames) == 1
|
||||
assert sensor.reset_calls == 1
|
||||
assert sensor.reset_threads == ["orisys-pipeline-flow"]
|
||||
assert sensor.reset_at_frame == [0], "the fallback reset must precede compute_flow"
|
||||
|
||||
|
||||
class PauseResetFakeSensor(FakeSensor):
|
||||
"""A split-reset fake whose flow stage parks at chosen frame numbers.
|
||||
|
||||
Parking inside ``compute_flow`` is what makes a pause deterministic: the
|
||||
flow stage has already decided the current frame's version and cannot claim
|
||||
a new request until the gate is released.
|
||||
"""
|
||||
|
||||
def __init__(self, *, gates=(1,)) -> None:
|
||||
super().__init__()
|
||||
self._gate_calls = {int(gate) for gate in gates}
|
||||
self.gate_reached = {call: threading.Event() for call in self._gate_calls}
|
||||
self.release_gate = {call: threading.Event() for call in self._gate_calls}
|
||||
self.hhd_calls = 0
|
||||
self.image_reset_threads: list[str] = []
|
||||
self.force_reset_threads: list[str] = []
|
||||
self.force_reset_at_hhd_calls: list[int] = []
|
||||
|
||||
def compute_flow(self, img=None, **kwargs) -> None:
|
||||
FakeSensor.compute_flow(self, img, **kwargs)
|
||||
call = self._flow_calls
|
||||
if call in self._gate_calls:
|
||||
self.gate_reached[call].set()
|
||||
self.release_gate[call].wait(timeout=5.0)
|
||||
|
||||
def compute_hhd(self, flow=None, **kwargs) -> None:
|
||||
self.hhd_calls += 1
|
||||
FakeSensor.compute_hhd(self, flow, **kwargs)
|
||||
|
||||
def reset_image_state(self) -> None:
|
||||
self.image_reset_threads.append(threading.current_thread().name)
|
||||
|
||||
def reset_force_state(self) -> None:
|
||||
self.force_reset_threads.append(threading.current_thread().name)
|
||||
self.force_reset_at_hhd_calls.append(self.hhd_calls)
|
||||
|
||||
def reset(self) -> None:
|
||||
# Mirrors Sensor.reset(): both halves immediately, on the caller's thread.
|
||||
self.reset_calls += 1
|
||||
self.reset_at_frame.append(self._flow_calls)
|
||||
self.reset_image_state()
|
||||
self.reset_force_state()
|
||||
|
||||
|
||||
def test_resume_coalesces_with_a_reset_request_queued_while_paused():
|
||||
"""A request queued while flow cannot claim it shares the resume's target."""
|
||||
sensor = PauseResetFakeSensor(gates=(1,))
|
||||
frames: list[FrameSnapshot] = []
|
||||
pipe = StreamPipeline(
|
||||
sensor, make_producer(sensor, 8), on_frame=frames.append, depth=2
|
||||
)
|
||||
pipe.start()
|
||||
try:
|
||||
assert sensor.gate_reached[1].wait(timeout=5.0), "flow never reached its gate"
|
||||
pipe.pause(PauseLevel.CAPTURE_ONLY)
|
||||
assert pipe.tracker_reset_seq == 0
|
||||
request = pipe.request_reset()
|
||||
assert request == 1
|
||||
pipe.resume(reset=True)
|
||||
# The flow stage is still parked, so resume's own request coalesces.
|
||||
assert pipe.request_reset() == request, (
|
||||
"resume allocated a new target although the flow stage had not claimed "
|
||||
"the queued request"
|
||||
)
|
||||
sensor.release_gate[1].set()
|
||||
assert _wait_until(
|
||||
lambda: any(snap.tracker_reset_seq >= request for snap in frames)
|
||||
), "the pipeline never published the resumed reference version"
|
||||
versions = [snap.tracker_reset_seq for snap in frames]
|
||||
assert 0 in versions, versions
|
||||
assert max(versions) == request, versions
|
||||
assert sensor.image_reset_threads == ["orisys-pipeline-flow"]
|
||||
assert sensor.force_reset_threads == ["orisys-pipeline-hhd"]
|
||||
finally:
|
||||
sensor.release_gate[1].set()
|
||||
pipe.stop()
|
||||
|
||||
|
||||
def test_resume_after_a_claimed_reset_gets_a_new_target():
|
||||
"""Once flow has claimed a request, resume's reset must not merge into it."""
|
||||
sensor = PauseResetFakeSensor(gates=(1, 3))
|
||||
frames: list[FrameSnapshot] = []
|
||||
pipe = StreamPipeline(
|
||||
sensor, make_producer(sensor, 10), on_frame=frames.append, depth=2
|
||||
)
|
||||
pipe.start()
|
||||
try:
|
||||
assert sensor.gate_reached[1].wait(timeout=5.0), "flow never reached gate 1"
|
||||
first = pipe.request_reset()
|
||||
assert first == 1
|
||||
sensor.release_gate[1].set()
|
||||
assert _wait_until(
|
||||
lambda: any(snap.tracker_reset_seq >= first for snap in frames)
|
||||
), "the first requested version was never published"
|
||||
assert sensor.gate_reached[3].wait(timeout=5.0), "flow never reached gate 3"
|
||||
assert pipe.tracker_reset_seq == first
|
||||
|
||||
pipe.pause(PauseLevel.CAPTURE_ONLY)
|
||||
second = pipe.request_reset()
|
||||
assert second == first + 1
|
||||
pipe.resume(reset=True)
|
||||
assert pipe.request_reset() == second, (
|
||||
"resume merged into a target the flow stage had already claimed"
|
||||
)
|
||||
sensor.release_gate[3].set()
|
||||
assert _wait_until(
|
||||
lambda: any(snap.tracker_reset_seq >= second for snap in frames)
|
||||
), "the resumed version was never published"
|
||||
versions = [snap.tracker_reset_seq for snap in frames]
|
||||
assert {first, second}.issubset(set(versions)), versions
|
||||
assert max(versions) == second, versions
|
||||
# Each new reference version resets the image and the force state once.
|
||||
assert len(sensor.image_reset_threads) == 2, sensor.image_reset_threads
|
||||
assert len(sensor.force_reset_threads) == 2, sensor.force_reset_threads
|
||||
assert sensor.force_reset_at_hhd_calls == sorted(
|
||||
sensor.force_reset_at_hhd_calls
|
||||
), sensor.force_reset_at_hhd_calls
|
||||
finally:
|
||||
sensor.release_gate[1].set()
|
||||
sensor.release_gate[3].set()
|
||||
pipe.stop()
|
||||
|
||||
@@ -338,7 +338,8 @@ def test_publish_dry_run_resumes_when_tag_already_exists(tmp_path, monkeypatch,
|
||||
assert "gh release create v1.2.3" in output
|
||||
|
||||
|
||||
def test_publish_dry_run_creates_tag_when_absent(tmp_path, monkeypatch, capsys):
|
||||
@pytest.mark.parametrize("tag", [None, "1.2.4"])
|
||||
def test_publish_dry_run_creates_tag_when_absent(tmp_path, monkeypatch, capsys, tag):
|
||||
sdk, release, wheel = _synthetic_publish_layout(tmp_path, "1.2.4", tag_exists=False)
|
||||
monkeypatch.setattr(github, "REPO_ROOT", sdk)
|
||||
monkeypatch.setattr(github, "MANIFEST_PATH", sdk / "tools" / "downstream_sync_manifest.json")
|
||||
@@ -346,11 +347,18 @@ def test_publish_dry_run_creates_tag_when_absent(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setattr(github, "_release_exists", lambda repo, tag: False)
|
||||
|
||||
exit_code = github.publish(
|
||||
github.Options(version="1.2.4", apply=False, skip_fetch=True)
|
||||
github.Options(version="1.2.4", apply=False, skip_fetch=True, tag=tag)
|
||||
)
|
||||
output = capsys.readouterr().out
|
||||
assert exit_code == 0
|
||||
assert "git tag v1.2.4" in output
|
||||
expected_tag = tag or "v1.2.4"
|
||||
assert f"git tag {expected_tag}" in output
|
||||
assert f"gh release create {expected_tag} --verify-tag --title {expected_tag}" in output
|
||||
|
||||
|
||||
def test_publish_rejects_tag_for_another_version(capsys):
|
||||
assert github.publish(github.Options(version="1.2.4", tag="1.2.5")) == 1
|
||||
assert "tag must be" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_publish_refuses_when_release_already_exists(tmp_path, monkeypatch, capsys):
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Read-only SensorContext (T04): SN/config/assets/cal_path bound once per instance.
|
||||
|
||||
The context is the stage-1 answer to "which physical sensor is this, which
|
||||
configuration and assets does it run, and which force calibration was selected".
|
||||
These tests protect the two properties the task names: the binding happens once
|
||||
from explicit inputs (no SN guessed from enumeration order), and two Sensor
|
||||
instances do not share or overwrite each other's context. The identity itself is
|
||||
built through the frozen T03 ``FrameIdentity``, not a second shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys import Sensor, configs
|
||||
from orisys.sensors import SensorContext, build_sensor_context
|
||||
from orisys.stream_contract import FrameIdentity
|
||||
|
||||
# The shipped box calibration has no declared identity; the device tests add one,
|
||||
# exactly like an operator-managed <product>/<sn>.json file.
|
||||
BOX_CALIBRATION = Path(configs.resolve_sensor_bundle("box").force_calibration_json)
|
||||
BOX_CONFIG = Path(configs.resolve_sensor_bundle("box").sensor_json)
|
||||
|
||||
|
||||
def _device_calibration(root: Path, sn: str, *, family: str = "box") -> Path:
|
||||
"""Write a device calibration whose declared SN is ``sn``."""
|
||||
document = json.loads(BOX_CALIBRATION.read_text(encoding="utf-8-sig"))
|
||||
document["identity"] = {"sensor_type": family, "sn": sn}
|
||||
path = root / family / f"{sn}.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(document), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _external_sensor(**kwargs) -> Sensor:
|
||||
options = {"verbose": False, "backend": "cpu"}
|
||||
options.update(kwargs)
|
||||
return Sensor("external", **options)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ explicit binding
|
||||
def test_context_binds_the_explicit_sn_config_and_calibration_path(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = tmp_path / "calibrations"
|
||||
calibration = _device_calibration(root, "BOX-001")
|
||||
sensor = _external_sensor(
|
||||
config_name="box", calibration_dir=root, device_id="BOX-001"
|
||||
)
|
||||
try:
|
||||
context = sensor.context
|
||||
assert isinstance(context, SensorContext)
|
||||
assert context.sensor_id == "BOX-001"
|
||||
assert sensor.device_id == "BOX-001"
|
||||
assert context.product_family == "box"
|
||||
assert context.config_name == "box"
|
||||
assert context.config_source == str(BOX_CONFIG.resolve())
|
||||
assert len(context.config_fingerprint) == 64
|
||||
assert context.bundle_root == str(BOX_CONFIG.parent.resolve())
|
||||
assert context.calibration_path == str(calibration.resolve())
|
||||
assert context.calibration_fingerprint == sensor.calibration.fingerprint
|
||||
assert context.diagnostics == ()
|
||||
# The published attributes still say what they said before T04.
|
||||
assert sensor.calibration.source_path == calibration.resolve()
|
||||
assert sensor.force_unit == "N"
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
|
||||
def test_context_never_guesses_an_sn_from_enumeration_order(tmp_path: Path) -> None:
|
||||
"""No explicit SN means no identity; the stream header refuses to invent one."""
|
||||
first = _external_sensor(config_name="box")
|
||||
second = _external_sensor(config_name="box")
|
||||
try:
|
||||
assert first.context.sensor_id is None
|
||||
assert second.context.sensor_id is None
|
||||
# Same configuration and bundle, so the non-identity binding is stable.
|
||||
assert first.context.config_fingerprint == second.context.config_fingerprint
|
||||
assert first.context.bundle_root == second.context.bundle_root
|
||||
with pytest.raises(ValueError, match="explicit sensor_id"):
|
||||
first.context.frame_identity(
|
||||
session_id="run-1", frame_id=0, source_frame_id=None
|
||||
)
|
||||
finally:
|
||||
first.disconnect()
|
||||
second.disconnect()
|
||||
|
||||
|
||||
def test_two_instances_keep_separate_contexts(tmp_path: Path) -> None:
|
||||
root_one = tmp_path / "one"
|
||||
root_two = tmp_path / "two"
|
||||
_device_calibration(root_one, "BOX-001")
|
||||
_device_calibration(root_two, "BOX-002")
|
||||
first = _external_sensor(
|
||||
config_name="box", calibration_dir=root_one, device_id="BOX-001"
|
||||
)
|
||||
second = _external_sensor(
|
||||
config_name="box", calibration_dir=root_two, device_id="BOX-002"
|
||||
)
|
||||
try:
|
||||
first_context = first.context
|
||||
first_identity = first_context.frame_identity(
|
||||
session_id="run-1", frame_id=0, source_frame_id=None
|
||||
)
|
||||
second_context = second.context
|
||||
second_identity = second_context.frame_identity(
|
||||
session_id="run-1", frame_id=0, source_frame_id=None
|
||||
)
|
||||
assert first_context != second_context
|
||||
assert first_identity != second_identity
|
||||
assert first_identity.sensor_id == "BOX-001"
|
||||
assert second_identity.sensor_id == "BOX-002"
|
||||
# Building the second Sensor did not mutate the first context.
|
||||
assert first.context == first_context
|
||||
assert first.context.calibration_path != second_context.calibration_path
|
||||
assert isinstance(first_identity, FrameIdentity)
|
||||
finally:
|
||||
first.disconnect()
|
||||
second.disconnect()
|
||||
|
||||
|
||||
def test_context_is_frozen_read_only_value_data(tmp_path: Path) -> None:
|
||||
root = tmp_path / "calibrations"
|
||||
_device_calibration(root, "BOX-001")
|
||||
sensor = _external_sensor(
|
||||
config_name="box", calibration_dir=root, device_id="BOX-001"
|
||||
)
|
||||
try:
|
||||
context = sensor.context
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
context.sensor_id = "BOX-999" # type: ignore[misc]
|
||||
payload = context.as_dict()
|
||||
assert payload["sensor_id"] == "BOX-001"
|
||||
assert isinstance(payload["assets"], list)
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
|
||||
def test_calibration_swap_replaces_the_context_atomically(tmp_path: Path) -> None:
|
||||
root = tmp_path / "calibrations"
|
||||
first_cal = _device_calibration(root, "BOX-001")
|
||||
# The same device SN, a different calibration document: a live save always
|
||||
# keeps the device identity, which is what a real recalibration does.
|
||||
variant = root / "box" / "BOX-001-variant.json"
|
||||
document = json.loads(BOX_CALIBRATION.read_text(encoding="utf-8-sig"))
|
||||
document["identity"] = {"sensor_type": "box", "sn": "BOX-001"}
|
||||
# A real recalibration is a different document; changing one point keeps the
|
||||
# curve valid while making the content fingerprint different.
|
||||
document["channels"]["normal"]["points"][1][1] = 0.3771
|
||||
variant.write_text(json.dumps(document), encoding="utf-8")
|
||||
sensor = _external_sensor(
|
||||
config_name="box", calibration_dir=root, device_id="BOX-001"
|
||||
)
|
||||
try:
|
||||
before = sensor.context
|
||||
assert before.calibration_path == str(first_cal.resolve())
|
||||
|
||||
# The published live-swap entry stays available; the calibration identity
|
||||
# must still match the bound SN.
|
||||
sensor.set_calibration(variant)
|
||||
after = sensor.context
|
||||
assert after is not before
|
||||
assert after.sensor_id == before.sensor_id == "BOX-001"
|
||||
assert after.config_fingerprint == before.config_fingerprint
|
||||
assert after.bundle_root == before.bundle_root
|
||||
assert after.calibration_path == str(variant.resolve())
|
||||
assert after.calibration_fingerprint == sensor.calibration.fingerprint
|
||||
assert after.calibration_fingerprint != before.calibration_fingerprint
|
||||
|
||||
sensor.set_calibration(None)
|
||||
assert sensor.context.calibration_path is None
|
||||
assert sensor.context.calibration_fingerprint == "none"
|
||||
assert sensor.force_unit == "raw"
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
|
||||
def test_clearing_a_calibration_loaded_from_a_path_reports_no_path() -> None:
|
||||
"""The context describes the calibration in effect, not the path ever requested.
|
||||
|
||||
Constructing with an explicit ``cal_path`` then clearing the calibration must
|
||||
leave no calibration path behind: the old path would otherwise be recorded as
|
||||
if that calibration still shaped the measurements.
|
||||
"""
|
||||
sensor = _external_sensor(config_name="box", cal_path=str(BOX_CALIBRATION))
|
||||
try:
|
||||
assert sensor.context.calibration_path == str(BOX_CALIBRATION.resolve())
|
||||
assert sensor.force_unit == "N"
|
||||
|
||||
sensor.set_calibration(None)
|
||||
assert sensor.calibration is None
|
||||
assert sensor.context.calibration_path is None
|
||||
assert sensor.context.calibration_fingerprint == "none"
|
||||
assert sensor.force_unit == "raw"
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
|
||||
def test_binding_failure_is_reported_without_breaking_construction(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""A context problem must not turn a previously constructible Sensor into an error."""
|
||||
import orisys.sensors.context as context_module
|
||||
|
||||
def broken(**kwargs):
|
||||
raise RuntimeError("bundle exploded")
|
||||
|
||||
monkeypatch.setattr(context_module, "build_sensor_context", broken)
|
||||
sensor = _external_sensor(config_name="box")
|
||||
try:
|
||||
assert sensor.context.sensor_id is None
|
||||
assert sensor.context.config_name == "box"
|
||||
assert sensor.context.diagnostics == (
|
||||
"context binding failed: RuntimeError: bundle exploded",
|
||||
)
|
||||
assert sensor.context.calibration_fingerprint == "none"
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
|
||||
# --------------------------------------------------- configuration by path/name
|
||||
def test_config_binding_follows_the_actual_loaded_file(tmp_path: Path) -> None:
|
||||
custom = tmp_path / "custom-box.json"
|
||||
custom.write_text(BOX_CONFIG.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
sensor = _external_sensor(config_name=str(custom))
|
||||
try:
|
||||
context = sensor.context
|
||||
assert context.config_source == str(custom.resolve())
|
||||
assert context.config_name == str(custom)
|
||||
assert context.product_family is None
|
||||
assert context.bundle_root is None
|
||||
assert context.assets == ()
|
||||
assert any("no additive sensor bundle" in line for line in context.diagnostics)
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
# The recorded source is the file load_config() actually reads.
|
||||
assert configs.resolve_config_source("box") == BOX_CONFIG.resolve()
|
||||
assert configs.resolve_config_source(custom) == custom.resolve()
|
||||
assert configs.resolve_config_source("no-such-config-name") is None
|
||||
|
||||
|
||||
def test_same_configuration_has_the_same_config_fingerprint(tmp_path: Path) -> None:
|
||||
root = tmp_path / "calibrations"
|
||||
_device_calibration(root, "BOX-001")
|
||||
first = _external_sensor(config_name="box")
|
||||
second = _external_sensor(
|
||||
config_name="box", calibration_dir=root, device_id="BOX-001"
|
||||
)
|
||||
try:
|
||||
assert first.context.config_fingerprint == second.context.config_fingerprint
|
||||
assert (
|
||||
first.context.processing_backend
|
||||
== second.context.processing_backend
|
||||
== "compound_eye"
|
||||
)
|
||||
finally:
|
||||
first.disconnect()
|
||||
second.disconnect()
|
||||
|
||||
|
||||
# --------------------------------------------------------------- asset binding
|
||||
def test_bundle_assets_are_bound_as_relative_paths_once() -> None:
|
||||
finger_config = configs.load_config("finger")
|
||||
context = build_sensor_context(
|
||||
config_name="finger",
|
||||
config=finger_config,
|
||||
device_id="FINGER-001",
|
||||
bundle_candidates=["finger"],
|
||||
)
|
||||
assert context.sensor_id == "FINGER-001"
|
||||
assert context.product_family == "finger"
|
||||
assert context.bundle_root is not None
|
||||
assert context.manifest_digest is not None and len(context.manifest_digest) == 64
|
||||
roles = {asset.role for asset in context.assets}
|
||||
assert "assets_dir" in roles
|
||||
root = Path(context.bundle_root)
|
||||
for asset in context.assets:
|
||||
assert not Path(asset.path).is_absolute()
|
||||
assert (root / asset.path).parent.is_dir()
|
||||
|
||||
# Two bindings of the same bundle describe the same immutable assets.
|
||||
again = build_sensor_context(
|
||||
config_name="finger", config=finger_config, bundle_candidates=["finger"]
|
||||
)
|
||||
assert again.assets == context.assets
|
||||
assert again.manifest_digest == context.manifest_digest
|
||||
assert again.config_fingerprint == context.config_fingerprint
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ from orisys.signal_processing import (
|
||||
NORMAL_FORCE_CAL_OFFSET,
|
||||
NORMAL_FORCE_CAL_SLOPE,
|
||||
LowpassFilter,
|
||||
MultiAxisCausalFilter,
|
||||
calibrate_normal_force,
|
||||
calibrate_shear_components,
|
||||
calibrate_shear_force,
|
||||
@@ -494,3 +495,76 @@ def test_the_calibration_note_names_the_bundle_that_was_found(tmp_path, monkeypa
|
||||
# A dict always keys ``found_root``, so guard its value: an unguarded copy of
|
||||
# this branch prints "bundle found at None".
|
||||
assert "None" not in note
|
||||
|
||||
|
||||
# --------------------------------------------- multi-axis causal force chain
|
||||
class _ReferenceMedian:
|
||||
"""Independent median window, so the operator test has its own expectation."""
|
||||
|
||||
def __init__(self, window):
|
||||
self.window = window
|
||||
self.history = []
|
||||
|
||||
def process(self, value):
|
||||
self.history.append(float(value))
|
||||
self.history = self.history[-self.window :]
|
||||
return float(np.median(self.history))
|
||||
|
||||
|
||||
def test_multi_axis_median_matches_a_plain_window_per_axis():
|
||||
chain = MultiAxisCausalFilter(median_window=3)
|
||||
reference = [_ReferenceMedian(3) for _ in range(3)]
|
||||
samples = [(10.0, 20.0, 30.0), (90.0, 20.0, 30.0), (10.0, 20.0, 30.0),
|
||||
(10.0, 20.0, 30.0)]
|
||||
for sample in samples:
|
||||
actual = chain.process(sample)
|
||||
expected = [reference[index].process(value) for index, value in enumerate(sample)]
|
||||
np.testing.assert_allclose(actual, expected)
|
||||
|
||||
|
||||
def test_multi_axis_nan_axis_does_not_enter_history_or_pull_the_others():
|
||||
chain = MultiAxisCausalFilter(median_window=3)
|
||||
chain.process((10.0, 20.0, 30.0))
|
||||
skipped = chain.process(
|
||||
(float("nan"), 20.0, 30.0), valid=(False, True, True)
|
||||
)
|
||||
assert np.isnan(skipped[0])
|
||||
assert skipped[1] == pytest.approx(20.0)
|
||||
# Valid axes keep advancing while the missing axis is skipped.
|
||||
resumed = chain.process((10.0, 20.0, 30.0))
|
||||
assert resumed[0] == pytest.approx(10.0)
|
||||
# A NaN arriving without an explicit validity flag is treated as unavailable.
|
||||
assert np.isnan(chain.process((float("nan"), 20.0, 30.0))[0])
|
||||
|
||||
|
||||
def test_multi_axis_all_stages_off_is_exact_passthrough_including_nan():
|
||||
chain = MultiAxisCausalFilter(median_window=1, lowpass_cutoff_hz=0.0)
|
||||
assert chain.enabled is False
|
||||
sample = (1.5, -2.0, float("nan"))
|
||||
first = chain.process(sample)
|
||||
second = chain.process(sample)
|
||||
assert first[0] == 1.5 and first[1] == -2.0 and np.isnan(first[2])
|
||||
assert second[0] == 1.5 and second[1] == -2.0 and np.isnan(second[2])
|
||||
|
||||
|
||||
def test_multi_axis_lowpass_ignores_missing_axes_and_resets():
|
||||
chain = MultiAxisCausalFilter(
|
||||
median_window=1, lowpass_cutoff_hz=5.0, sample_rate_hz=30.0, n_channels=3
|
||||
)
|
||||
values = []
|
||||
for index in range(20):
|
||||
values.append(
|
||||
chain.process(
|
||||
(float(index), float(index), float(index)),
|
||||
valid=(True, True, index % 2 == 0),
|
||||
)
|
||||
)
|
||||
for index, value in enumerate(values):
|
||||
assert np.isfinite(value[0])
|
||||
assert np.isfinite(value[1])
|
||||
# The third axis is only fed on even frames, so it is missing on odd ones.
|
||||
assert np.isnan(value[2]) == bool(index % 2)
|
||||
chain.reset()
|
||||
after = chain.process((1.0, 2.0, 3.0))
|
||||
# A reset IIR starts from its first sample's steady state.
|
||||
np.testing.assert_allclose(after, (1.0, 2.0, 3.0))
|
||||
|
||||
@@ -166,3 +166,45 @@ def test_compound_eye_processor_calls_stitcher():
|
||||
def test_unknown_backend_raises():
|
||||
with pytest.raises(ValueError):
|
||||
get_blend_backend("torch")
|
||||
|
||||
|
||||
def _six_by_three_frame():
|
||||
"""A 1920x1080 frame showing 6x3 tiles: one column more than the 15 detected."""
|
||||
img = np.zeros((1080, 1920, 3), dtype=np.uint8)
|
||||
for cx in (160, 470, 790, 1110, 1430, 1750):
|
||||
for cy in (180, 520, 860):
|
||||
img[cy - 120:cy + 120, cx - 120:cx + 120] = 200
|
||||
return img
|
||||
|
||||
|
||||
def _detect(img, **kwargs):
|
||||
from orisys.stitch.boxes import detect_nonblack_regions
|
||||
|
||||
return detect_nonblack_regions(
|
||||
img, box_size=(250, 250), min_area_frac=0.01, max_area_frac=0.05,
|
||||
max_aspect_ratio=50, save_preview_path="", **kwargs,
|
||||
)
|
||||
|
||||
|
||||
def test_an_extra_tile_column_is_counted_without_an_exclusion():
|
||||
n_boxes, _boxes = _detect(_six_by_three_frame())
|
||||
assert n_boxes == 18
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_x", [(330, 0), (0, 330)])
|
||||
def test_exclude_x_masks_out_the_extra_column(exclude_x):
|
||||
n_boxes, boxes = _detect(_six_by_three_frame(), exclude_x=exclude_x)
|
||||
assert n_boxes == 15
|
||||
centres = sorted({(x0 + x1) // 2 for x0, _y0, x1, _y1 in boxes})
|
||||
assert (160 not in centres) == (exclude_x[0] > 0)
|
||||
assert (1750 not in centres) == (exclude_x[1] > 0)
|
||||
|
||||
|
||||
def test_the_shipped_right_margin_keeps_an_aligned_module():
|
||||
"""[0, 240] drops an extra edge column but not the outer column of a 5x3 module."""
|
||||
img = np.zeros((1080, 1920, 3), dtype=np.uint8)
|
||||
for cx in (324, 642, 960, 1278, 1596):
|
||||
for cy in (180, 520, 860):
|
||||
img[cy - 120:cy + 120, cx - 120:cx + 120] = 200
|
||||
assert _detect(img, exclude_x=(0, 240))[0] == 15
|
||||
assert _detect(_six_by_three_frame(), exclude_x=(0, 240))[0] == 15
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
"""The frozen single-stream type contract: identity, clock, source_dt_s, events.
|
||||
|
||||
The contract is defined in ``orisys.stream_contract`` and documented in
|
||||
``docs/stream-contract.zh.md``. These tests pin the four acceptance rules of
|
||||
T03: per-stream identity and clock, an optional-but-never-partial group
|
||||
identity, distinguishability of frames that share a timestamp, and JSON
|
||||
round-trips for ``source_dt_s`` and events. They are pure type tests: nothing
|
||||
here starts a sensor, a pipeline or a session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys import (
|
||||
STREAM_CONTRACT_VERSION,
|
||||
SUPPORTED_CONTRACT_VERSIONS,
|
||||
FrameClock,
|
||||
FrameIdentity,
|
||||
FrameStamp,
|
||||
MeasurementFrame,
|
||||
StreamEvent,
|
||||
)
|
||||
|
||||
|
||||
def _round_trip(value):
|
||||
"""JSON round-trip exactly as a recording would read the contract back."""
|
||||
payload = json.loads(json.dumps(value.to_dict()))
|
||||
return type(value).from_dict(payload)
|
||||
|
||||
|
||||
def _stamp(**overrides) -> FrameStamp:
|
||||
fields = {
|
||||
"sensor_id": "FINGER-001",
|
||||
"session_id": "run-7",
|
||||
"frame_id": 12,
|
||||
"source_frame_id": 41,
|
||||
"capture_time_ns": 1_600_000_000_000_000_000,
|
||||
"clock_id": "host_monotonic",
|
||||
"capture_time_basis": "capture_monotonic",
|
||||
}
|
||||
fields.update(overrides)
|
||||
return FrameStamp(**fields)
|
||||
|
||||
|
||||
def test_the_contract_version_is_exported_and_explicit() -> None:
|
||||
assert STREAM_CONTRACT_VERSION == "stream-contract/1"
|
||||
# Every frozen type and the version are importable from the package root,
|
||||
# which is the public boundary downstream repositories import.
|
||||
import orisys
|
||||
|
||||
for name in (
|
||||
"FrameIdentity",
|
||||
"FrameClock",
|
||||
"FrameStamp",
|
||||
"MeasurementFrame",
|
||||
"StreamEvent",
|
||||
"TIME_BASES",
|
||||
"STREAM_CONTRACT_VERSION",
|
||||
):
|
||||
assert hasattr(orisys, name), name
|
||||
|
||||
|
||||
# --------------------------------------------------------------- identity
|
||||
def test_identity_keeps_a_per_stream_output_number_and_the_source_number() -> None:
|
||||
identity = FrameIdentity(
|
||||
sensor_id="BOX-3",
|
||||
session_id="run-7",
|
||||
frame_id=12,
|
||||
source_frame_id=41,
|
||||
)
|
||||
assert (identity.sensor_id, identity.session_id) == ("BOX-3", "run-7")
|
||||
assert identity.frame_id == 12
|
||||
assert identity.source_frame_id == 41
|
||||
# A source without its own numbering may say so; the output number still exists.
|
||||
identity = FrameIdentity(
|
||||
sensor_id="BOX-3", session_id="run-7", frame_id=0, source_frame_id=None
|
||||
)
|
||||
assert identity.frame_id == 0 and identity.source_frame_id is None
|
||||
with pytest.raises(ValueError):
|
||||
FrameIdentity(
|
||||
sensor_id="", session_id="run-7", frame_id=0, source_frame_id=None
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
FrameIdentity(
|
||||
sensor_id="BOX-3", session_id="run-7", frame_id=-1, source_frame_id=None
|
||||
)
|
||||
with pytest.raises(TypeError):
|
||||
FrameIdentity(
|
||||
sensor_id="BOX-3", session_id="run-7", frame_id=True, source_frame_id=None
|
||||
)
|
||||
|
||||
|
||||
def test_group_identity_is_optional_but_never_partial() -> None:
|
||||
without_group = FrameIdentity(
|
||||
sensor_id="BOX-3", session_id="run-7", frame_id=0, source_frame_id=0
|
||||
)
|
||||
assert without_group.has_group is False
|
||||
assert without_group.group_identity is None
|
||||
assert without_group.to_dict()["acquisition_id"] is None
|
||||
assert without_group.to_dict()["sync_group_id"] is None
|
||||
|
||||
grouped = FrameIdentity(
|
||||
sensor_id="BOX-3",
|
||||
session_id="run-7",
|
||||
frame_id=0,
|
||||
source_frame_id=0,
|
||||
acquisition_id="acq-1",
|
||||
sync_group_id="group-1",
|
||||
)
|
||||
assert grouped.has_group is True
|
||||
assert grouped.group_identity == ("acq-1", "group-1")
|
||||
assert _round_trip(grouped) == grouped
|
||||
|
||||
# A half group would look like synchronization without an owner.
|
||||
with pytest.raises(ValueError, match="both acquisition_id and sync_group_id"):
|
||||
FrameIdentity(
|
||||
sensor_id="BOX-3",
|
||||
session_id="run-7",
|
||||
frame_id=0,
|
||||
source_frame_id=0,
|
||||
acquisition_id="acq-1",
|
||||
)
|
||||
with pytest.raises(ValueError, match="both acquisition_id and sync_group_id"):
|
||||
FrameIdentity(
|
||||
sensor_id="BOX-3",
|
||||
session_id="run-7",
|
||||
frame_id=0,
|
||||
source_frame_id=0,
|
||||
sync_group_id="group-1",
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
FrameIdentity(
|
||||
sensor_id="BOX-3",
|
||||
session_id="run-7",
|
||||
frame_id=0,
|
||||
source_frame_id=0,
|
||||
acquisition_id="",
|
||||
sync_group_id="group-1",
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ clock
|
||||
def test_clock_keeps_the_time_domain_basis_and_the_raw_source_pts() -> None:
|
||||
clock = FrameClock(
|
||||
capture_time_ns=1_600_000_000_000_000_000,
|
||||
clock_id="video:recording-4",
|
||||
capture_time_basis="video_pts",
|
||||
source_pts=90000,
|
||||
)
|
||||
assert clock.capture_time_basis == "video_pts"
|
||||
assert clock.source_pts == 90000
|
||||
# A derived file timestamp is explicitly labelled as estimated.
|
||||
assert FrameClock(
|
||||
0, "source:frame-numbers", "estimated_source"
|
||||
).capture_time_basis == ("estimated_source")
|
||||
with pytest.raises(ValueError):
|
||||
FrameClock(0, "host", "wall_clock")
|
||||
with pytest.raises(ValueError):
|
||||
FrameClock(-1, "host", "capture_monotonic")
|
||||
with pytest.raises(ValueError):
|
||||
FrameClock(0, "", "capture_monotonic")
|
||||
with pytest.raises(ValueError):
|
||||
FrameClock(0, "host", "capture_monotonic", source_pts=float("nan"))
|
||||
with pytest.raises(TypeError):
|
||||
FrameClock(0, "host", "capture_monotonic", source_pts=True)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ stamp
|
||||
def test_stamp_composes_identity_and_clock_and_round_trips() -> None:
|
||||
identity = FrameIdentity(
|
||||
sensor_id="FINGER-001",
|
||||
session_id="run-7",
|
||||
frame_id=12,
|
||||
source_frame_id=41,
|
||||
acquisition_id="acq-1",
|
||||
sync_group_id="group-1",
|
||||
)
|
||||
clock = FrameClock(
|
||||
capture_time_ns=1_600_000_000_000_000_000,
|
||||
clock_id="host_monotonic",
|
||||
capture_time_basis="capture_monotonic",
|
||||
source_pts=41.5,
|
||||
)
|
||||
stamp = FrameStamp.from_parts(identity, clock)
|
||||
assert stamp.identity == identity
|
||||
assert stamp.clock == clock
|
||||
|
||||
payload = stamp.to_dict()
|
||||
assert payload["schema"] == STREAM_CONTRACT_VERSION
|
||||
assert payload["sensor_id"] == "FINGER-001"
|
||||
assert payload["source_frame_id"] == 41
|
||||
assert payload["capture_time_ns"] == clock.capture_time_ns
|
||||
assert _round_trip(stamp) == stamp
|
||||
|
||||
# The flattened fields are the same objects the components report.
|
||||
assert stamp.sensor_id == identity.sensor_id
|
||||
assert stamp.session_id == identity.session_id
|
||||
assert stamp.frame_id == identity.frame_id
|
||||
assert stamp.source_frame_id == identity.source_frame_id
|
||||
assert stamp.capture_time_ns == clock.capture_time_ns
|
||||
assert stamp.clock_id == clock.clock_id
|
||||
assert stamp.capture_time_basis == clock.capture_time_basis
|
||||
assert stamp.source_pts == clock.source_pts
|
||||
assert (stamp.acquisition_id, stamp.sync_group_id) == ("acq-1", "group-1")
|
||||
|
||||
|
||||
def test_frames_that_share_a_pts_are_still_distinct_frames() -> None:
|
||||
"""Timestamps are not identity: a recording must keep both frames."""
|
||||
first = _stamp(source_frame_id=41, frame_id=10)
|
||||
second = _stamp(source_frame_id=42, frame_id=11)
|
||||
assert first.capture_time_ns == second.capture_time_ns
|
||||
assert first.source_pts == second.source_pts
|
||||
assert first != second
|
||||
assert first.to_dict() != second.to_dict()
|
||||
assert _round_trip(first) != _round_trip(second)
|
||||
|
||||
# A source without its own numbering can still emit repeated timestamps;
|
||||
# the contiguous output number keeps the frames apart.
|
||||
first = _stamp(source_frame_id=None, frame_id=10)
|
||||
second = _stamp(source_frame_id=None, frame_id=11)
|
||||
assert first != second
|
||||
assert (first.capture_time_ns, first.source_pts) == (
|
||||
second.capture_time_ns,
|
||||
second.source_pts,
|
||||
)
|
||||
|
||||
# Different sessions are different streams even with identical numbers.
|
||||
other_session = _stamp(session_id="run-8")
|
||||
assert other_session != first and other_session != second
|
||||
|
||||
|
||||
# -------------------------------------------------------- measurement frame
|
||||
def test_measurement_frame_carries_source_dt_s_and_round_trips() -> None:
|
||||
stamp = _stamp()
|
||||
first = MeasurementFrame(stamp=stamp, source_dt_s=None)
|
||||
assert first.source_dt_s is None
|
||||
assert first.reference_generation == 0
|
||||
assert _round_trip(first) == first
|
||||
|
||||
following = MeasurementFrame(
|
||||
stamp=_stamp(frame_id=13, source_frame_id=42),
|
||||
source_dt_s=0.033,
|
||||
reference_generation=3,
|
||||
)
|
||||
payload = following.to_dict()
|
||||
assert payload["schema"] == STREAM_CONTRACT_VERSION
|
||||
assert payload["source_dt_s"] == pytest.approx(0.033)
|
||||
assert payload["reference_generation"] == 3
|
||||
assert _round_trip(following) == following
|
||||
|
||||
# Equal source timestamps are valid data; they do not collapse frames and do
|
||||
# not produce a negative/None interval.
|
||||
same_time = MeasurementFrame(stamp=_stamp(), source_dt_s=0.0)
|
||||
assert same_time.source_dt_s == 0.0
|
||||
|
||||
|
||||
def test_measurement_frame_rejects_invalid_source_dt_and_generation() -> None:
|
||||
stamp = _stamp()
|
||||
with pytest.raises(ValueError):
|
||||
MeasurementFrame(stamp=stamp, source_dt_s=-0.001)
|
||||
with pytest.raises(ValueError):
|
||||
MeasurementFrame(stamp=stamp, source_dt_s=float("nan"))
|
||||
with pytest.raises(ValueError):
|
||||
MeasurementFrame(stamp=stamp, source_dt_s=float("inf"))
|
||||
with pytest.raises(TypeError):
|
||||
MeasurementFrame(stamp=stamp, source_dt_s=True)
|
||||
with pytest.raises(ValueError):
|
||||
MeasurementFrame(stamp=stamp, source_dt_s=None, reference_generation=-1)
|
||||
with pytest.raises(TypeError):
|
||||
MeasurementFrame(stamp=stamp, source_dt_s=None, reference_generation=1.5)
|
||||
with pytest.raises(TypeError):
|
||||
MeasurementFrame(stamp={}, source_dt_s=None)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ events
|
||||
def test_events_round_trip_through_json_with_a_frozen_payload() -> None:
|
||||
stamp = _stamp()
|
||||
event = StreamEvent(
|
||||
kind="source_gap",
|
||||
stamp=stamp,
|
||||
payload={
|
||||
"dropped_frames": 3,
|
||||
"source_dt_s": 0.5,
|
||||
"proportion": 0.25,
|
||||
"flags": [True, False, None],
|
||||
"context": {"reason": "overload", "counts": [1, 2]},
|
||||
},
|
||||
event_version=1,
|
||||
)
|
||||
assert event.payload["dropped_frames"] == 3
|
||||
# The payload is read-only after construction.
|
||||
with pytest.raises(TypeError):
|
||||
event.payload["dropped_frames"] = 4 # type: ignore[index]
|
||||
|
||||
payload = event.to_dict()
|
||||
assert payload["schema"] == STREAM_CONTRACT_VERSION
|
||||
assert payload["event_version"] == 1
|
||||
assert _round_trip(event) == event
|
||||
|
||||
# A lifecycle event may have no frame, and unknown kinds stay additive.
|
||||
event = StreamEvent(kind="stream_started", payload={})
|
||||
assert event.stamp is None
|
||||
assert _round_trip(event) == event
|
||||
assert _round_trip(StreamEvent(kind="some_future_kind", payload={"x": 1})).kind == (
|
||||
"some_future_kind"
|
||||
)
|
||||
|
||||
|
||||
def test_events_reject_payloads_that_cannot_round_trip() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
StreamEvent(kind="bad", payload={"nan": float("nan")})
|
||||
with pytest.raises(ValueError):
|
||||
StreamEvent(kind="bad", payload={"inf": float("inf")})
|
||||
with pytest.raises(TypeError):
|
||||
StreamEvent(kind="bad", payload={1: "non-string key"})
|
||||
with pytest.raises(TypeError):
|
||||
StreamEvent(kind="bad", payload={"bytes": b"raw"})
|
||||
with pytest.raises(TypeError):
|
||||
StreamEvent(kind="bad", payload={"set": {1, 2}})
|
||||
with pytest.raises(ValueError):
|
||||
StreamEvent(kind="", payload={})
|
||||
with pytest.raises(ValueError):
|
||||
StreamEvent(kind="bad", payload={}, event_version=0)
|
||||
|
||||
|
||||
# -------------------------------------------------------------- read-back
|
||||
@pytest.mark.parametrize(
|
||||
"factory",
|
||||
[
|
||||
lambda: _stamp(),
|
||||
lambda: _stamp(acquisition_id="acq-1", sync_group_id="group-1"),
|
||||
lambda: MeasurementFrame(_stamp(), None),
|
||||
lambda: MeasurementFrame(_stamp(), 0.033, 2),
|
||||
lambda: StreamEvent("reference_reset", _stamp(), {"from": 1, "to": 2}),
|
||||
],
|
||||
)
|
||||
def test_every_frozen_type_round_trips_through_its_dictionary(factory) -> None:
|
||||
value = factory()
|
||||
assert _round_trip(value) == value
|
||||
|
||||
|
||||
def test_unknown_fields_and_wrong_schema_are_rejected() -> None:
|
||||
stamp_payload = _stamp().to_dict()
|
||||
# Policy A: no same-version field additions - an unknown field names the
|
||||
# versioning rule instead of being silently dropped.
|
||||
with pytest.raises(ValueError, match="new STREAM_CONTRACT_VERSION"):
|
||||
FrameStamp.from_dict({**stamp_payload, "unexpected": 1})
|
||||
with pytest.raises(ValueError, match="schema"):
|
||||
FrameStamp.from_dict({**stamp_payload, "schema": "stream-contract/2"})
|
||||
with pytest.raises(ValueError, match="missing field"):
|
||||
FrameStamp.from_dict(
|
||||
{key: value for key, value in stamp_payload.items() if key != "sensor_id"}
|
||||
)
|
||||
with pytest.raises(ValueError, match="unknown field"):
|
||||
StreamEvent.from_dict(
|
||||
{"schema": STREAM_CONTRACT_VERSION, "kind": "x", "extra": 1}
|
||||
)
|
||||
with pytest.raises(ValueError, match="unknown field"):
|
||||
# A nested frame header follows the same policy.
|
||||
MeasurementFrame.from_dict(
|
||||
{
|
||||
"schema": STREAM_CONTRACT_VERSION,
|
||||
"stamp": {**stamp_payload, "unexpected": 1},
|
||||
"source_dt_s": None,
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValueError, match="schema"):
|
||||
MeasurementFrame.from_dict(
|
||||
{
|
||||
"schema": "stream-contract/0",
|
||||
"stamp": stamp_payload,
|
||||
"source_dt_s": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_the_reader_lists_the_versions_it_accepts() -> None:
|
||||
"""Policy A: strict reading, and only explicitly listed versions are read."""
|
||||
assert SUPPORTED_CONTRACT_VERSIONS == (STREAM_CONTRACT_VERSION,)
|
||||
payload = _stamp().to_dict()
|
||||
# The listed version is the one this reader was built for and it reads.
|
||||
assert FrameStamp.from_dict(payload) == _stamp()
|
||||
|
||||
# A different version is a different field contract: it is rejected rather
|
||||
# than guessed at. Bumping STREAM_CONTRACT_VERSION and adding the new value
|
||||
# to SUPPORTED_CONTRACT_VERSIONS is what makes a new version readable.
|
||||
for schema in ("stream-contract/0", "stream-contract/2", "other/1"):
|
||||
with pytest.raises(ValueError, match="must be one of"):
|
||||
FrameStamp.from_dict({**payload, "schema": schema})
|
||||
with pytest.raises(ValueError, match="must be one of"):
|
||||
MeasurementFrame.from_dict(
|
||||
{"schema": schema, "stamp": payload, "source_dt_s": None}
|
||||
)
|
||||
with pytest.raises(ValueError, match="must be one of"):
|
||||
StreamEvent.from_dict({"schema": schema, "kind": "x"})
|
||||
|
||||
|
||||
# --------------------------------------------------------------- event extras
|
||||
def test_events_are_hashable_and_equal_events_share_a_hash() -> None:
|
||||
"""The frozen event is usable as a dict/set key; equality and hash agree."""
|
||||
first = StreamEvent(
|
||||
kind="reference_reset", stamp=_stamp(), payload={"from": 1, "to": 2}
|
||||
)
|
||||
second = StreamEvent(
|
||||
kind="reference_reset",
|
||||
stamp=_stamp(),
|
||||
payload={"to": 2, "from": 1},
|
||||
)
|
||||
assert first == second
|
||||
assert hash(first) == hash(second)
|
||||
# A JSON read-back is still the same value, so it must hash the same too.
|
||||
assert _round_trip(first) == first
|
||||
assert hash(_round_trip(first)) == hash(first)
|
||||
# The frozen payload is excluded from the hash, so it cannot make hashing
|
||||
# fail; events differing only in payload may still share a hash bucket.
|
||||
assert hash(StreamEvent(kind="source_gap", stamp=_stamp())) == hash(
|
||||
StreamEvent(kind="source_gap", stamp=_stamp(), payload={"dropped": 1})
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind",
|
||||
["NotSnake", "bad kind", "1leading", "with-dash", "_leading", ""],
|
||||
)
|
||||
def test_event_kind_rejects_names_outside_lower_snake(kind) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
StreamEvent(kind=kind)
|
||||
with pytest.raises(ValueError):
|
||||
StreamEvent.from_dict({"schema": STREAM_CONTRACT_VERSION, "kind": kind})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind", ["a", "source_gap", "reference_reset", "zero2", "a_b_c_1"]
|
||||
)
|
||||
def test_event_kind_accepts_lower_snake_and_round_trips(kind) -> None:
|
||||
event = StreamEvent(kind=kind, payload={"x": 1})
|
||||
assert event.kind == kind
|
||||
assert _round_trip(event) == event
|
||||
Reference in New Issue
Block a user