2 Commits

61 changed files with 187 additions and 14011 deletions
-2
View File
@@ -192,7 +192,6 @@ def _cmd_github(argv: list[str]) -> int:
help="actually mutate the release repo, push, and create the release", 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("--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( parser.add_argument(
"--attach-preflight", "--attach-preflight",
action="store_true", action="store_true",
@@ -219,7 +218,6 @@ def _cmd_github(argv: list[str]) -> int:
version=args.version, version=args.version,
apply=args.apply, apply=args.apply,
notes_file=args.notes_file, notes_file=args.notes_file,
tag=args.tag,
attach_preflight=args.attach_preflight, attach_preflight=args.attach_preflight,
allow_dirty_release=args.allow_dirty_release, allow_dirty_release=args.allow_dirty_release,
skip_fetch=args.skip_fetch, skip_fetch=args.skip_fetch,
+4 -11
View File
@@ -8,7 +8,7 @@
3. sync the open-source subset of this checkout into ``orisys-sdk-release`` 3. sync the open-source subset of this checkout into ``orisys-sdk-release``
(``tools/sync_downstreams.py`` + the manifest's ``checks``) (``tools/sync_downstreams.py`` + the manifest's ``checks``)
4. stage ``SDK/<version>/*.whl`` into ``orisys-sdk-release/release/<version>/`` 4. stage ``SDK/<version>/*.whl`` into ``orisys-sdk-release/release/<version>/``
5. commit, tag (default ``v<version>``), push branch + tag 5. commit, tag ``v<version>``, push branch + tag
6. create the GitHub Release with the wheels attached, then verify the assets 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 Nothing mutates anything unless ``--apply`` is passed: the default is a dry run
@@ -48,7 +48,6 @@ class Options:
skip_fetch: bool = False skip_fetch: bool = False
skip_checks: bool = False skip_checks: bool = False
skip_release_upload: bool = False skip_release_upload: bool = False
tag: str | None = None
@dataclass @dataclass
@@ -132,7 +131,7 @@ def build_github_steps(
tag, tag,
"--verify-tag", "--verify-tag",
"--title", "--title",
title or tag, title or f"v{version}",
"--latest", "--latest",
] ]
if notes_file is not None: if notes_file is not None:
@@ -243,10 +242,6 @@ def _stage_wheels(wheels: list[Path], asset_dir: Path) -> None:
def publish(options: Options) -> int: def publish(options: Options) -> int:
version = options.version 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" mode = "APPLY" if options.apply else "DRY RUN"
print(f"=== release github: orisys {version} ({mode}) ===") print(f"=== release github: orisys {version} ({mode}) ===")
@@ -292,10 +287,8 @@ def publish(options: Options) -> int:
return 1 return 1
print(f"[ok ] release repo: {repo} ({branch}) clean={not dirty}") print(f"[ok ] release repo: {repo} ({branch}) clean={not dirty}")
tag = f"v{version}"
existing = _existing_release_tags(repo, 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 # 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 # as pushing the tag and then failed (or the upload was left manual). That is
# exactly the resumable state, so continue instead of refusing. # exactly the resumable state, so continue instead of refusing.
@@ -390,7 +383,7 @@ def publish(options: Options) -> int:
_git(repo, "add", "-A") _git(repo, "add", "-A")
pending = _git(repo, "status", "--porcelain") pending = _git(repo, "status", "--porcelain")
if pending: if pending:
_git(repo, "commit", "-m", f"发布 SDK {version}") _git(repo, "commit", "-m", f"release {version}")
print(f"[ok ] committed {len(pending.splitlines())} change(s) in {repo}") print(f"[ok ] committed {len(pending.splitlines())} change(s) in {repo}")
else: else:
print("[ok ] nothing new to commit in the release repo") print("[ok ] nothing new to commit in the release repo")
+1 -1
View File
@@ -22,7 +22,7 @@
"dis_preset": "medium", "dis_preset": "medium",
"dis_mode": 1, "dis_mode": 1,
"algorithm_list": ["raft", "dis"], "algorithm_list": ["raft", "dis"],
"hhd_downsample": 2 "hhd_downsample": 5
}, },
"contact": { "contact": {
"min_contour_area": 3000, "min_contour_area": 3000,
+3 -9
View File
@@ -183,7 +183,7 @@ flow = sensor.read_info(sensor.info.VRAW)
释放传感器相关资源并停止后台异步线程。 调用后将关闭视频/摄像头输入源,并销毁所有 OpenCV 窗口。 释放传感器相关资源并停止后台异步线程。 调用后将关闭视频/摄像头输入源,并销毁所有 OpenCV 窗口。
建议退出前调用。 建议在程序退出前调用以确保资源被正确释放。
### `Sensor.configure` 低通滤波设置 ### `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) sensor.configure(lowpass_cutoff_hz=5.0, lowpass_sample_rate_hz=30.0)
``` ```
注意:`lowpass_cutoff_hz` 必须小于 `lowpass_sample_rate_hz / 2`(30 fps 时 <15 Hz)。 注意:`lowpass_cutoff_hz` 必须小于 Nyquist 频率,即 `lowpass_sample_rate_hz / 2`。例如 30 fps 时,截止频率必须小于 15 Hz。
#### 输入参数 #### 输入参数
@@ -228,7 +228,7 @@ fn, fx, fy = sensor.read_info(
对一维、等间隔采样数据执行零相位 Butterworth 低通滤波,适合示例程序或图表中对历史数据做平滑处理。 对一维、等间隔采样数据执行零相位 Butterworth 低通滤波,适合示例程序或图表中对历史数据做平滑处理。
默认参数为 `cutoff_hz=5.0`、`sample_rate_hz=30.0`、`order=2`。 默认参数为 `cutoff_hz=5.0`、`sample_rate_hz=30.0`、`order=2`。这表示在 30 fps 数据上使用 5 Hz 低通滤波。
#### 输入参数 #### 输入参数
@@ -279,12 +279,6 @@ filt = LowpassFilter(cutoff_hz=5.0, sample_rate_hz=30.0, n_channels=3)
filtered = filt.process([fnormal, fshearx, fsheary]) 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 ## Finger Pipeline API
### `orisys.finger` 手指 UV pipeline ### `orisys.finger` 手指 UV pipeline
+41 -16
View File
@@ -1,10 +1,8 @@
# 统一力标定模型契约 # 统一力标定模型契约
Purpose: reference. Scope: SDK 标定文件选择、SN 绑定和推断接口。 Purpose: reference. Scope: SDK 标定文件选择、SN 绑定和推断接口。
状态:2026-10-19 已实现 schema 3 的空间、全局与场加权增益;ML 后端仅保留契约,尚未实现。 状态:2026-10-18 已实现 schema 3 的空间/全局增益;ML 后端仅保留契约,尚未实现。
保持 `cal_path` 与现有单点文件;新增空间模型和机器学习模型使用同一入口。 保持 `cal_path` 与现有单点文件;新增空间模型和机器学习模型使用同一入口。
运行中的 Finger 可调用 `runtime.set_calibration(file, device_id=sn)`;调用者须在处理线程的帧边界执行。
该接口按实际几何、展开域及处理参数重新校验,成功切换后清除会话零点;校验失败保留原模型。
## 现状与边界 ## 现状与边界
@@ -121,12 +119,6 @@ runtime.set_calibration_reference({"fn": measured_zero_raw}, reference_id="unloa
特征定义或单位改变须升级 `feature_version`,配置覆盖后重新计算,不能用配置文件名代替。 特征定义或单位改变须升级 `feature_version`,配置覆盖后重新计算,不能用配置文件名代替。
实现由 `calibration_context.build_context` 统一生成,特征版本为 `orisys_hhd_raw/1`。 实现由 `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`: `spatial_gain/1` 固定输出 `fn`,使用 `fn_delta_autocal_uv/1`:
@@ -154,9 +146,46 @@ FingerRuntime 使用 `artifacts` 中 `role="coordinate_map"` 的只读 NPZ:
输入 profile 为 `force_delta/1`,由 SDK 按同一归零语义准备对应 raw 增量。 输入 profile 为 `force_delta/1`,由 SDK 按同一归零语义准备对应 raw 增量。
这不同于旧曲线的绝对 raw 输入;兼容适配器不得替旧文件额外扣零。 这不同于旧曲线的绝对 raw 输入;兼容适配器不得替旧文件额外扣零。
## 其他模型类型 ## 机器学习扩展
场加权 FN 模型 `field_gain/1`(输入 profile `hhd_normal_l1_field/1`)的字段、映射、空载基线与诊断契约见[场加权模型契约](field-gain-model.zh.md)。ML 扩展(首个示例 `onnx/1`)仍是预留示例,见[机器学习模型契约](ml-model-contract.zh.md);后端实现和输入 profile 验证通过前必须拒绝加载。 同一 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。
附件间依赖也须逐项声明和校验,未声明的外部权重不可加载。
## 每帧推断与有效性 ## 每帧推断与有效性
@@ -203,14 +232,12 @@ schema 3 按声明轴校验,允许 FN-only;旧 SDK 遇 schema 3 必须拒绝
|---|---| |---|---|
| `calibration.py` | schema 分流、身份和附件校验;旧曲线适配器 | | `calibration.py` | schema 分流、身份和附件校验;旧曲线适配器 |
| `sensors/calibration.py` | 统一选择器与指纹;支持 SN 请求 | | `sensors/calibration.py` | 统一选择器与指纹;支持 SN 请求 |
| 新 `calibration_models.py` | 受控类型表、空间/全局/场加权模型与生命周期;ML 后端另行实现 | | 新 `calibration_models.py` | 受控类型表、空间增益与模型生命周期;ML 后端另行实现 |
| `sensor.py`、`finger/runtime.py`、处理器适配层 | 提供同帧位置/零点,缓存逐轴结果和状态 | | `sensor.py`、`finger/runtime.py`、处理器适配层 | 提供同帧位置/零点,缓存逐轴结果和状态 |
| Sensor 与 `ForceReadout` | 复用读数滤波器;schema 3 在推断后逐轴滤波,旧路径不变 | | Sensor 与 `ForceReadout` | 复用读数滤波器;schema 3 在推断后逐轴滤波,旧路径不变 |
| 离线导出器 | 实验模型到 schema 3;SDK 不依赖实验目录 | | 离线导出器 | 实验模型到 schema 3;SDK 不依赖实验目录 |
当前导出入口为 workspace 的 `scripts/finger_multical/export_sdk.py`,输出已有文件时拒绝覆盖。 当前导出入口为 workspace 的 `scripts/finger_multical/export_sdk.py`,输出已有文件时拒绝覆盖。
场加权实验文件 `data/dd02-gl/force_calibration.v3.field.json` 由同目录 `export_field.py`
从上述交付只读转换(不重训、不改排除点、不覆盖原件),契约见[场加权模型契约](field-gain-model.zh.md)。
`data/dd02-gl/force_calibration.v3.json` 与同名 `.coordinates.npz` 是本次实验交付, `data/dd02-gl/force_calibration.v3.json` 与同名 `.coordinates.npz` 是本次实验交付,
`identity.sn="dd02-gl"` 为实验标识,未从硬件读取;原三边缘点与非单调点警告保留于 provenance。 `identity.sn="dd02-gl"` 为实验标识,未从硬件读取;原三边缘点与非单调点警告保留于 provenance。
它不是生产包默认文件,需显式 `cal_path` 选择。 它不是生产包默认文件,需显式 `cal_path` 选择。
@@ -218,6 +245,4 @@ schema 3 按声明轴校验,允许 FN-only;旧 SDK 遇 schema 3 必须拒绝
实现验收必须覆盖:旧结果与单位不变;显式路径/SN/默认/关闭选择;错误 SN 不串用; 实现验收必须覆盖:旧结果与单位不变;显式路径/SN/默认/关闭选择;错误 SN 不串用;
3/14/更多点、退化网格与凸包外;节点和插值预测;零点不重复扣除;缺坐标/无接触; 3/14/更多点、退化网格与凸包外;节点和插值预测;零点不重复扣除;缺坐标/无接触;
FN-only 的 FX/FY 不冒充 N;未知模型、附件损坏和依赖缺失;状态模型每帧只调用一次及 reset。 FN-only 的 FX/FY 不冒充 N;未知模型、附件损坏和依赖缺失;状态模型每帧只调用一次及 reset。
场加权另需常增益等价、两区域贡献、边界投影、映射错误与 30 帧加权基线生命周期,
见 `tests/test_field_calibration.py`。
另以独立录像验证物理准确度,契约或单元测试通过不代表标定合格。 另以独立录像验证物理准确度,契约或单元测试通过不代表标定合格。
-33
View File
@@ -1,33 +0,0 @@
# 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
整合验收确认。
-129
View File
@@ -1,129 +0,0 @@
# 场加权力标定模型契约
用途: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
View File
@@ -40,7 +40,7 @@
延迟换算 @60fps 减半;数字来自 `_perf/`(该一次性目录已从 `orisys-sdk` 移除:幸存的脚本与片源 延迟换算 @60fps 减半;数字来自 `_perf/`(该一次性目录已从 `orisys-sdk` 移除:幸存的脚本与片源
迁到 `D:\orisys_ws\docs\performance\perf-measurements\`,其余已永久丢失;对照表见 迁到 `D:\orisys_ws\docs\performance\perf-measurements\`,其余已永久丢失;对照表见
[历史测量记录](../../docs/archive/performance/orisys-sdk-perf-plan.md),其中仍保留全部实测数字)。 `D:\orisys_ws\docs\performance\orisys-sdk-perf-plan.md`,其中仍保留全部实测数字)。
≥1 帧的延迟与相位差是**因果滤波的代价**:旧显示端曾用零相位 `filtfilt`,那需要"未来", ≥1 帧的延迟与相位差是**因果滤波的代价**:旧显示端曾用零相位 `filtfilt`,那需要"未来",
无法在采集里做,也让"显示 == 记录"不可能——所以现在只有一条因果链。 无法在采集里做,也让"显示 == 记录"不可能——所以现在只有一条因果链。
-166
View File
@@ -1,166 +0,0 @@
# 新力侧会话与单路入口(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` 前先把零点、标定、读数引擎从旧类中
抽出来,避免新会话依赖待删除的类型。
-111
View File
@@ -1,111 +0,0 @@
# 力读数工作流(`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` 不同是唯一允许的管线身份差异,其余差异仍拒绝并报告。
-44
View File
@@ -1,44 +0,0 @@
# 机器学习力标定模型契约
用途: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。
附件间依赖也须逐项声明和校验,未声明的外部权重不可加载。
-4
View File
@@ -106,10 +106,6 @@ 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 exact flags. A published version cannot be overwritten, so do not treat a rerun as
a way to replace bad wheels. 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 The release order is build all candidates, commit source changes, run the clean
preflight, preview both publishers, then execute the authorized publications. preflight, preview both publishers, then execute the authorized publications.
GitHub attaches all certified wheels and generates notes naming their actual GitHub attaches all certified wheels and generates notes naming their actual
+3 -32
View File
@@ -49,15 +49,6 @@ bundle with no manifest still resolves through the compatibility adapter.
## Compatibility for existing bundles ## 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 `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 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: it. That is the only place a folder name is consulted, and only for the built-in set:
@@ -107,16 +98,6 @@ selection.
| `single_camera` | `camera` config | frames only - no deformation field, so no force | | `single_camera` | `camera` config | frames only - no deformation field, so no force |
| `uv_unroll` | `camera` config, the mapped computational asset | unrolled frame, flow, 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 `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 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 assembled together - that is `orisys.finger.FingerRuntime.open`'s shape. `create_processor` says so
@@ -175,16 +156,6 @@ compute the same numbers.
## Resetting the reference ## Resetting the reference
Both backends process externally supplied frames through the same profile. After a gap in the 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 stages that input - a pause, a seek, a scene change - call `session.reset()`. It is queued to the stage that
own the sensor state, so the reference frame is rebased on the frame about to be computed rather owns the tracker, so the reference frame is rebased on the frame about to be computed rather than
than on a buffer the pipeline is still reading: the flow stage resets the image/tracker state and on a buffer the pipeline is still reading.
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).
-255
View File
@@ -1,255 +0,0 @@
# 单路流类型合同(`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=...)` 的在途版本保留。
-1
View File
@@ -13,7 +13,6 @@
"min_area_frac": 0.01, "min_area_frac": 0.01,
"max_area_frac": 0.05, "max_area_frac": 0.05,
"max_aspect_ratio": 50, "max_aspect_ratio": 50,
"exclude_x": [0, 240],
"index": [1,2,3,6,7,8,11,12,13], "index": [1,2,3,6,7,8,11,12,13],
"margin": 10, "margin": 10,
"nominal_ov_pos": 100, "nominal_ov_pos": 100,
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -6,7 +6,7 @@
"resolution_raw": [1280, 720], "resolution_raw": [1280, 720],
"fps": 30, "fps": 30,
"resolution_stitch": [280, 300], "resolution_stitch": [280, 300],
"source_scale": 0.42, "source_scale": 0.4,
"old":[672, 728, 1,0.5], "old":[672, 728, 1,0.5],
"work": [504, 546,1], "work": [504, 546,1],
"work2": [420, 450,0.8], "work2": [420, 450,0.8],
+1 -33
View File
@@ -5,7 +5,7 @@ A Python library for processing and analyzing optical tactile sensor data,
including image stitching, optical flow tracking, and deformation decomposition. including image stitching, optical flow tracking, and deformation decomposition.
""" """
__version__ = "0.7.0" __version__ = "0.5.4"
__author__ = "Orisys" __author__ = "Orisys"
from . import calibration, capture, configs, finger, signal_processing, util from . import calibration, capture, configs, finger, signal_processing, util
@@ -19,23 +19,6 @@ from .pipeline import (
StreamPipeline, StreamPipeline,
) )
from .sensor import Sensor 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 CompoundEyeSensor = Sensor
CameraSensor = Sensor CameraSensor = Sensor
@@ -78,21 +61,6 @@ __all__ = [
# force calibration # force calibration
'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 # configs utilities functions
'list_config', 'list_config',
'export_config', 'export_config',
+4 -53
View File
@@ -6,9 +6,7 @@ curves in ``_apply_force_filter``; ``read_info(FNORMAL, …)`` returns Newtons.
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import math
import re import re
import warnings import warnings
from collections.abc import Iterable, Mapping from collections.abc import Iterable, Mapping
@@ -54,7 +52,7 @@ _KNOWN_TOP_KEYS = frozenset(
#: Filter stages that change **what is measured**: they sit upstream of the #: Filter stages that change **what is measured**: they sit upstream of the
#: force computation, so two recordings with different values are not comparable. #: force computation, so two recordings with different values are not comparable.
#: They belong to the measurement-pipeline identity, for the same reason #: They belong to the measurement-pipeline identity, for the same reason
#: ``dis_mode`` does. HHD resolution is retained in provenance but may differ. #: ``dis_mode`` and ``hhd_downsample`` do.
FILTER_MEASUREMENT_KEYS: tuple[str, ...] = ("flow_ema_alpha",) FILTER_MEASUREMENT_KEYS: tuple[str, ...] = ("flow_ema_alpha",)
#: Filter stages that only smooth the force triple on its way out. They are #: 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 #: recorded so a file explains its own numbers, but a difference between two
@@ -410,53 +408,6 @@ class Calibration:
) )
return out_fn, out_fx, out_fy 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]: def to_dict(self) -> dict[str, Any]:
channels: dict[str, Any] = {} channels: dict[str, Any] = {}
for cid, curve in self.channels.items(): for cid, curve in self.channels.items():
@@ -925,7 +876,7 @@ def validate_for_device(
*, *,
pipeline: Mapping[str, Any] | None = None, pipeline: Mapping[str, Any] | None = None,
) -> Calibration: ) -> Calibration:
"""Reject a different device or pipeline, allowing HHD resolution differences.""" """Reject a calibration for another device or measurement pipeline."""
path = cal.source_path or "<in-memory calibration>" path = cal.source_path or "<in-memory calibration>"
if cal.identity.get("sensor_type") != sensor_type or cal.identity.get("sn") != device_id: if cal.identity.get("sensor_type") != sensor_type or cal.identity.get("sn") != device_id:
raise CalibrationError(f"calibration identity mismatch: {path}") raise CalibrationError(f"calibration identity mismatch: {path}")
@@ -935,7 +886,7 @@ def validate_for_device(
# Full feature/geometry validation happens when the processor binds its # Full feature/geometry validation happens when the processor binds its
# actual context. Device construction only knows this legacy subset. # actual context. Device construction only knows this legacy subset.
if pipeline is not None: if pipeline is not None:
for key in ("stitch_resolution", "dis_mode"): for key in ("stitch_resolution", "dis_mode", "hhd_downsample"):
if pipeline.get(key) is not None and cal.pipeline.get(key) != pipeline[key]: if pipeline.get(key) is not None and cal.pipeline.get(key) != pipeline[key]:
raise CalibrationError(f"calibration pipeline mismatch ({key}): {path}") raise CalibrationError(f"calibration pipeline mismatch ({key}): {path}")
measurement, _ = filter_mismatches(cal.pipeline.get("filters"), pipeline.get("filters")) measurement, _ = filter_mismatches(cal.pipeline.get("filters"), pipeline.get("filters"))
@@ -947,7 +898,7 @@ def validate_for_device(
if set(CHANNEL_IDS) - cal.channels.keys(): if set(CHANNEL_IDS) - cal.channels.keys():
raise CalibrationError(f"calibration requires FN, FX and FY: {path}") raise CalibrationError(f"calibration requires FN, FX and FY: {path}")
if pipeline is not None: if pipeline is not None:
for key in ("stitch_resolution", "dis_mode"): for key in ("stitch_resolution", "dis_mode", "hhd_downsample"):
recorded, current = cal.pipeline.get(key), pipeline.get(key) recorded, current = cal.pipeline.get(key), pipeline.get(key)
if recorded is not None and current is not None and recorded != current: if recorded is not None and current is not None and recorded != current:
raise CalibrationError(f"calibration pipeline mismatch ({key}): {path}") raise CalibrationError(f"calibration pipeline mismatch ({key}): {path}")
+5 -45
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
from copy import deepcopy
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
@@ -12,38 +11,6 @@ import numpy as np
from .calibration import filters_fingerprint 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: def sha256_file(path) -> str:
return hashlib.sha256(Path(path).read_bytes()).hexdigest() return hashlib.sha256(Path(path).read_bytes()).hexdigest()
@@ -62,13 +29,8 @@ def domain_fingerprint(domain) -> str:
def build_context(config, filters=None, *, geometry_sha256=None, source_scale=None, def build_context(config, filters=None, *, geometry_sha256=None, source_scale=None,
coordinates=None, output_domain_sha256=None, processing_backend=None, coordinates=None, output_domain_sha256=None, processing_backend=None,
preprocessing=None, field=None) -> dict: preprocessing=None) -> dict:
"""Build the closed v1 feature identity from effective values, excluding readout filters. """Build the closed v1 feature identity from effective values, excluding readout filters."""
``field`` carries the closed profile identity for field models (shape, dtype,
unit, mask and lifecycle). It is omitted for scalar profiles so their
signatures stay byte-identical to previously exported files.
"""
camera = config.get("camera", {}) camera = config.get("camera", {})
tracker = dict(config.get("tracker", {})) tracker = dict(config.get("tracker", {}))
raw_downsample = tracker.get("hhd_downsample", 1) raw_downsample = tracker.get("hhd_downsample", 1)
@@ -108,8 +70,6 @@ def build_context(config, filters=None, *, geometry_sha256=None, source_scale=No
"source_scale": source_scale, "source_scale": source_scale,
"preprocessing": preprocessing or {}, "preprocessing": preprocessing or {},
} }
if field is not None: encoded = json.dumps(effective, sort_keys=True, separators=(",", ":"), allow_nan=False)
result["field"] = deepcopy(field) result["feature_signature_sha256"] = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
effective["field"] = deepcopy(field) return result
result["feature_signature_sha256"] = _feature_signature(effective)
return CalibrationContext(result, effective)
+22 -301
View File
@@ -7,32 +7,13 @@ import json
import math import math
from collections.abc import Mapping from collections.abc import Mapping
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass, field as dataclass_field from dataclasses import dataclass
from pathlib import Path, PurePosixPath, PureWindowsPath from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import Any from typing import Any
import numpy as np import numpy as np
from scipy.spatial import ConvexHull, QhullError from scipy.spatial import ConvexHull, QhullError
from .field_calibration import (
BASELINE_POLICY,
FIELD_IDENTITY,
FIELD_PROFILE,
MAPPING_POLICY,
ZERO_FRAME_COUNT,
FieldGainMap,
build_field_gain_map,
coerce_field,
mask_sha256,
reduce_field,
)
_PROFILE_FOR_TYPE = {
"spatial_gain/1": "fn_delta_autocal_uv/1",
"global_gain/1": "force_delta/1",
"field_gain/1": FIELD_PROFILE,
}
def _require(condition: bool, message: str) -> None: def _require(condition: bool, message: str) -> None:
if not condition: if not condition:
@@ -117,15 +98,11 @@ def _mesh(params: dict[str, Any]) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
return vertices, np.asarray(gains, dtype=float), np.asarray(triangles, dtype=int) return vertices, np.asarray(gains, dtype=float), np.asarray(triangles, dtype=int)
def _pipeline(data: Any, profile: str) -> None: def _pipeline(data: Any, spatial: bool) -> None:
spatial = profile != "force_delta/1"
required = {"stitch_resolution", "dis_mode", "hhd_downsample", required = {"stitch_resolution", "dis_mode", "hhd_downsample",
"filters", "feature_signature_sha256"} "filters", "feature_signature_sha256"}
if spatial: _keys(data, required | ({"geometry_sha256", "coordinates", "source_scale"} if spatial else set()),
required |= {"geometry_sha256", "coordinates", "source_scale"} {"geometry_sha256", "coordinates", "output_domain_sha256", "source_scale"})
if profile == FIELD_PROFILE:
required.add("field")
_keys(data, required, {"geometry_sha256", "coordinates", "output_domain_sha256", "source_scale"})
size = data["stitch_resolution"] size = data["stitch_resolution"]
_require(isinstance(size, list) and len(size) == 2 and all(type(n) is int and n > 0 for n in size), _require(isinstance(size, list) and len(size) == 2 and all(type(n) is int and n > 0 for n in size),
"invalid stitch_resolution") "invalid stitch_resolution")
@@ -150,49 +127,12 @@ def _pipeline(data: Any, profile: str) -> None:
_require(isinstance(coord["origin_xy"], list) and len(coord["origin_xy"]) == 2 and _require(isinstance(coord["origin_xy"], list) and len(coord["origin_xy"]) == 2 and
all(_number(x) for x in coord["origin_xy"]), "invalid coordinate origin") all(_number(x) for x in coord["origin_xy"]), "invalid coordinate origin")
_require(_sha(coord["map_sha256"]), "invalid map SHA-256") _require(_sha(coord["map_sha256"]), "invalid map SHA-256")
if "field" in required:
_require(data["field"] == FIELD_IDENTITY, "unsupported field identity")
@dataclass(frozen=True) @dataclass(frozen=True)
class CalibrationResult: class CalibrationResult:
values_N: dict[str, float | None] values_N: dict[str, float | None]
status: dict[str, str] status: dict[str, str]
#: Versioned extra evidence (field model: baseline, extrapolation fraction).
#: Consumers must tolerate missing keys so old cached results stay readable.
diagnostics: dict[str, Any] = dataclass_field(default_factory=dict)
@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: class ModelCalibration:
@@ -214,25 +154,16 @@ class ModelCalibration:
self._params = self._document["model"]["params"] self._params = self._document["model"]["params"]
self.coordinate_map = self.coordinate_valid_mask = None self.coordinate_map = self.coordinate_valid_mask = None
self._artifact_hashes: list[str] = [] self._artifact_hashes: list[str] = []
self._points = self._gains = self._triangles = None if self.model_type == "spatial_gain/1":
self._field_maps: dict[tuple[tuple[int, int], str], FieldGainMap] = {}
self._field_reference_id: str | None = None
self._field_zero_values: list[float] = []
self._field_baseline: float | None = None
if self.model_type in ("spatial_gain/1", "field_gain/1"):
self._points, self._gains, self._triangles = _mesh(self._params) self._points, self._gains, self._triangles = _mesh(self._params)
self._load_artifacts(document["model"].get("artifacts", [])) self._load_artifacts(document["model"].get("artifacts", []))
if self.model_type == "field_gain/1":
_require(self.coordinate_map is not None,
"field_gain/1 requires a coordinate_map artifact")
def _load_artifacts(self, artifacts: Any) -> None: def _load_artifacts(self, artifacts: Any) -> None:
_require(isinstance(artifacts, list), "artifacts must be an array") _require(isinstance(artifacts, list), "artifacts must be an array")
_require(len(artifacts) <= 1, "only one coordinate_map artifact is supported") _require(len(artifacts) <= 1, "only one coordinate_map artifact is supported")
for artifact in artifacts: for artifact in artifacts:
_keys(artifact, {"role", "path", "sha256"}) _keys(artifact, {"role", "path", "sha256"})
_require(self.model_type in ("spatial_gain/1", "field_gain/1") _require(self.model_type == "spatial_gain/1" and artifact["role"] == "coordinate_map",
and artifact["role"] == "coordinate_map",
"unsupported artifact role") "unsupported artifact role")
name = artifact["path"] name = artifact["path"]
_require(isinstance(name, str) and bool(name) and ":" not in name and _require(isinstance(name, str) and bool(name) and ":" not in name and
@@ -277,205 +208,17 @@ class ModelCalibration:
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return deepcopy(self._document) return deepcopy(self._document)
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 == "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, {})
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: def validate_for_context(self, pipeline: Mapping[str, Any]) -> None:
"""Refuse incompatible pipeline identities; allow (and report) downsample. for key, value in self.pipeline.items():
if key == "filters":
Kept as the validating entry point for existing callers: the message of a actual = pipeline.get(key, {})
refused identity is unchanged, and a downsample-only difference still _require(isinstance(actual, Mapping) and actual.get("measurement") == value["measurement"],
passes. Consumers that want to display the risk use "calibration pipeline mismatch: filters.measurement")
:meth:`pipeline_compatibility`. else:
""" _require(pipeline.get(key) == value, f"calibration pipeline mismatch: {key}")
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: def reset(self, reference_id: Any = None) -> None:
"""Clear per-stream state; the field model also voids its weighted baseline.""" """Gain backends have no history; preserve the lifecycle for future models."""
del reference_id
self._field_reference_id = None
self._field_zero_values = []
self._field_baseline = None
@property
def field_baseline_N(self) -> float | None:
"""Current weighted unloaded baseline, or ``None`` while it is unavailable."""
return self._field_baseline
def begin_reference(self, reference_id: Any) -> None:
"""Declare a new unloaded window; the next 30 valid frames set the baseline."""
_require(self.model_type == "field_gain/1",
"a weighted reference requires field_gain/1")
_require(isinstance(reference_id, str) and bool(reference_id.strip()),
"reference_id must be a non-empty string")
self._field_reference_id = reference_id
self._field_zero_values = []
self._field_baseline = None
def field_gain_map(self, grid_shape: tuple[int, int],
physical_mask: np.ndarray) -> FieldGainMap:
"""Gain/coverage map for one HHD grid and one output-resolution mask."""
_require(self.model_type == "field_gain/1",
"field gain maps require field_gain/1")
mask = np.asarray(physical_mask, dtype=bool)
_require(mask.ndim == 2 and mask.shape == self.coordinate_map.shape[:2],
"physical mask must match the coordinate map output grid")
return self._field_map((int(grid_shape[0]), int(grid_shape[1])), mask)
def _field_map(self, grid_shape: tuple[int, int], physical_mask: np.ndarray) -> FieldGainMap:
key = (grid_shape, mask_sha256(physical_mask))
cached = self._field_maps.get(key)
if cached is not None:
return cached
gain_map = build_field_gain_map(
self._points, self._gains, self._triangles,
self.coordinate_map, self.coordinate_valid_mask,
grid_shape, physical_mask,
)
if len(self._field_maps) >= 4:
self._field_maps.pop(next(iter(self._field_maps)))
self._field_maps[key] = gain_map
return gain_map
def _break_field_window(self) -> None:
"""An invalid frame breaks the consecutive declared unloaded window."""
if self._field_baseline is None:
self._field_zero_values = []
def _predict_field(self, frame_id: int, features: Mapping[str, Any]) -> CalibrationResult:
def invalid(reason: str, **diagnostics: Any) -> CalibrationResult:
diagnostics.setdefault("frame_id", frame_id)
return CalibrationResult({axis: None for axis in self.outputs},
{axis: reason for axis in self.outputs}, diagnostics)
try:
field = coerce_field(features.get("field"))
except (TypeError, ValueError) as exc:
self._break_field_window()
return invalid("invalid_input", error=str(exc))
mask = features.get("output_valid_mask")
try:
mask = np.asarray(mask, dtype=bool)
_require(mask.ndim == 2 and mask.shape == self.coordinate_map.shape[:2],
"output_valid_mask must match the coordinate map output grid")
except (TypeError, ValueError) as exc:
self._break_field_window()
return invalid("invalid_input", error=str(exc))
try:
gain_map = self._field_map(field.grid_shape, mask)
reduction = reduce_field(field, gain_map)
except ValueError as exc:
self._break_field_window()
return invalid("coordinate_unmapped", error=str(exc))
diagnostics: dict[str, Any] = {
"model_fingerprint": self.fingerprint,
"mapping": dict(MAPPING_POLICY),
"extrapolated_fraction": reduction.extrapolated_fraction,
"coverage_fraction": reduction.coverage_fraction,
"area_scale": field.area_scale,
}
if self._field_baseline is None:
diagnostics["reference_id"] = self._field_reference_id
if self._field_reference_id is None:
diagnostics["pending_reference_frames"] = 0
return invalid("zero_unavailable", **diagnostics)
self._field_zero_values.append(reduction.weighted_N)
if len(self._field_zero_values) < ZERO_FRAME_COUNT:
diagnostics["pending_reference_frames"] = len(self._field_zero_values)
return invalid("zero_unavailable", **diagnostics)
self._field_baseline = float(np.median(self._field_zero_values))
self._field_zero_values = []
diagnostics["baseline_N"] = self._field_baseline
diagnostics["pending_reference_frames"] = 0
return invalid("zero_unavailable", **diagnostics)
diagnostics["baseline_N"] = self._field_baseline
value = reduction.weighted_N - self._field_baseline
low, high = self._params["valid_force_range_N"]
status = "ok" if low <= value <= high else "out_of_range"
return CalibrationResult({"fn": float(value)}, {"fn": status}, diagnostics)
def convert(self, fn: float, fx: float, fy: float) -> None: def convert(self, fn: float, fx: float, fy: float) -> None:
"""Reject legacy scalar calls which lack frame, position and zero features.""" """Reject legacy scalar calls which lack frame, position and zero features."""
@@ -491,8 +234,6 @@ class ModelCalibration:
if (not isinstance(features, Mapping) or type(frame_id) is not int or if (not isinstance(features, Mapping) or type(frame_id) is not int or
(timestamp_s is not None and not _number(timestamp_s))): (timestamp_s is not None and not _number(timestamp_s))):
return invalid("invalid_input") return invalid("invalid_input")
if self.model_type == "field_gain/1":
return self._predict_field(frame_id, features)
if features.get("zero_valid") is not True: if features.get("zero_valid") is not True:
return invalid("zero_unavailable") return invalid("zero_unavailable")
if features.get("contact_found") is False: if features.get("contact_found") is False:
@@ -544,43 +285,23 @@ def parse_model(data: dict[str, Any], source_path: str | Path | None = None) ->
_require(isinstance(data.get("provenance", {}), dict), "provenance must be an object") _require(isinstance(data.get("provenance", {}), dict), "provenance must be an object")
model = data["model"] model = data["model"]
_keys(model, {"type", "input_profile", "output_frame", "outputs", "params"}, {"artifacts"}) _keys(model, {"type", "input_profile", "output_frame", "outputs", "params"}, {"artifacts"})
_require(model["type"] in _PROFILE_FOR_TYPE, _require(model["type"] in ("spatial_gain/1", "global_gain/1"), f"unsupported calibration model: {model['type']}")
f"unsupported calibration model: {model['type']}") spatial = model["type"] == "spatial_gain/1"
model_type = model["type"] _require(model["input_profile"] == ("fn_delta_autocal_uv/1" if spatial else "force_delta/1"),
spatial = model_type == "spatial_gain/1"
field_weighted = model_type == "field_gain/1"
_require(model["input_profile"] == _PROFILE_FOR_TYPE[model_type],
"unsupported input_profile for model") "unsupported input_profile for model")
_require(model["output_frame"] == "sdk_force/1", "unsupported output_frame") _require(model["output_frame"] == "sdk_force/1", "unsupported output_frame")
outputs = model["outputs"] outputs = model["outputs"]
_require(isinstance(outputs, list) and bool(outputs) and all(a in ("fn", "fx", "fy") for a in outputs) and _require(isinstance(outputs, list) and bool(outputs) and all(a in ("fn", "fx", "fy") for a in outputs) and
len(set(outputs)) == len(outputs), "invalid model outputs") len(set(outputs)) == len(outputs), "invalid model outputs")
_require(not (spatial or field_weighted) or outputs == ["fn"], "spatial model supports fn only") _require(not spatial or outputs == ["fn"], "spatial model supports fn only")
_pipeline(data["pipeline"], model["input_profile"]) _pipeline(data["pipeline"], spatial)
params = model["params"] params = model["params"]
required_params = {"gains_N_per_raw", "valid_force_range_N"} _keys(params, {"gains_N_per_raw", "valid_force_range_N"} | ({"points_uv", "triangles"} if spatial else set()))
if spatial or field_weighted:
required_params |= {"points_uv", "triangles"}
if field_weighted:
required_params |= {"mapping", "baseline"}
_keys(params, required_params)
force_range = params["valid_force_range_N"] force_range = params["valid_force_range_N"]
_require(isinstance(force_range, list) and len(force_range) == 2 and _require(isinstance(force_range, list) and len(force_range) == 2 and
all(_number(x) for x in force_range) and force_range[0] < force_range[1], "invalid force range") all(_number(x) for x in force_range) and force_range[0] < force_range[1], "invalid force range")
if not spatial: if not spatial:
gains = params["gains_N_per_raw"] gains = params["gains_N_per_raw"]
if field_weighted: _keys(gains, set(outputs))
_require(isinstance(gains, list) and len(gains) == len(params["points_uv"]), _require(all(_number(g) and g > 0 for g in gains.values()), "gains must be finite positive numbers")
"field gains must have one value per support point")
else:
_keys(gains, set(outputs))
_require(all(_number(g) and g > 0 for g in gains.values()) if isinstance(gains, dict)
else all(_number(g) and g > 0 for g in gains),
"gains must be finite positive numbers")
if field_weighted:
_require(params["mapping"] == MAPPING_POLICY, "unsupported field mapping policy")
_require(params["baseline"] == BASELINE_POLICY, "unsupported field baseline policy")
artifacts = model.get("artifacts")
_require(isinstance(artifacts, list) and len(artifacts) == 1,
"field_gain/1 requires one coordinate_map artifact")
return ModelCalibration(data, source_path) return ModelCalibration(data, source_path)
+19 -50
View File
@@ -283,48 +283,6 @@ def resolve_sensor_config_path(name: str) -> Path | None:
return bundle.sensor_json if bundle is not None else 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: def resolve_sensor_asset_dir(name: str) -> Path | None:
"""Return the default asset directory for a resolved sensor bundle.""" """Return the default asset directory for a resolved sensor bundle."""
bundle = resolve_sensor_bundle(name) bundle = resolve_sensor_bundle(name)
@@ -444,10 +402,6 @@ def load_config(config_name_or_path):
2. Additive sensor bundle ``sensors/<name>/sensor.json`` 2. Additive sensor bundle ``sensors/<name>/sensor.json``
3. File stem under the config directory (e.g. ``box`` -> ``config/box.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) 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) config_path = Path(config_name_or_path)
@@ -459,16 +413,31 @@ def load_config(config_name_or_path):
if config_path.is_absolute(): if config_path.is_absolute():
raise FileNotFoundError(f"Config file not found at absolute path: {config_path}") 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) requested = str(config_name_or_path)
candidates = [requested] candidates = [requested]
alias = CONFIG_ALIASES.get(requested) alias = CONFIG_ALIASES.get(requested)
if alias and alias not in candidates: if alias and alias not in candidates:
candidates.append(alias) 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 = [f.stem for f in _config_files()]
available_str = ", ".join(available) if available else "none" available_str = ", ".join(available) if available else "none"
raise FileNotFoundError( raise FileNotFoundError(
-347
View File
@@ -1,347 +0,0 @@
"""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 -102
View File
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
import numpy as np import numpy as np
@@ -14,7 +13,6 @@ from ..capture import (
open_capture, open_capture,
) )
from ..configs import load_config from ..configs import load_config
from ..field_calibration import FIELD_IDENTITY, FIELD_PROFILE
from ..frame_processors import FrameUnwarper from ..frame_processors import FrameUnwarper
from ..sensor import Sensor from ..sensor import Sensor
from ..unroll import ( from ..unroll import (
@@ -327,10 +325,9 @@ class FingerRuntime:
model = sensor.calibration model = sensor.calibration
uv_map = getattr(model, "coordinate_map", None) uv_map = getattr(model, "coordinate_map", None)
if model.input_profile in ("fn_delta_autocal_uv/1", FIELD_PROFILE): if model.input_profile == "fn_delta_autocal_uv/1":
if uv_map is None or uv_map.shape[:2] != (geometry.out_height, geometry.out_width): if uv_map is None or uv_map.shape[:2] != (geometry.out_height, geometry.out_width):
raise ValueError("calibration coordinate map does not match finger output shape") raise ValueError("calibration coordinate map does not match finger output shape")
field_identity = FIELD_IDENTITY if model.input_profile == FIELD_PROFILE else None
sensor.bind_calibration_context(build_context( sensor.bind_calibration_context(build_context(
sensor._config, sensor.filters_state(), sensor._config, sensor.filters_state(),
geometry_sha256=sha256_file(mesh_path), source_scale=geometry.source_scale, geometry_sha256=sha256_file(mesh_path), source_scale=geometry.source_scale,
@@ -338,7 +335,6 @@ class FingerRuntime:
output_domain_sha256=domain_fingerprint(domain), output_domain_sha256=domain_fingerprint(domain),
processing_backend="uv_unroll", processing_backend="uv_unroll",
preprocessing={"flip_source_vertical": flip_source_vertical, "mirror": mirror}, preprocessing={"flip_source_vertical": flip_source_vertical, "mirror": mirror},
field=field_identity,
)) ))
capture_config = CaptureConfig.from_camera_dict( capture_config = CaptureConfig.from_camera_dict(
@@ -390,37 +386,6 @@ class FingerRuntime:
"""Set an explicitly measured unloaded reference for this finger stream.""" """Set an explicitly measured unloaded reference for this finger stream."""
self.tactile.raw_sensor.set_calibration_reference(raw, reference_id=reference_id) 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 @property
def calibration_status(self): def calibration_status(self):
"""Copy the current per-axis calibration validity state.""" """Copy the current per-axis calibration validity state."""
@@ -454,60 +419,6 @@ class FingerRuntime:
self.tactile.push_frame(unrolled, already_gray=True) self.tactile.push_frame(unrolled, already_gray=True)
return unrolled 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): def grab_and_process(self, *, color=False):
ok, frame = self.read_source() ok, frame = self.read_source()
if not ok: if not ok:
@@ -546,18 +457,6 @@ class FingerRuntime:
return StreamPipeline.for_finger_runtime(self, **kwargs) 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): def close(self):
if self.capture_source is not None: if self.capture_source is not None:
self.capture_source.close() self.capture_source.close()
+2 -334
View File
@@ -2,22 +2,11 @@
Any sensor that produces a raw (fn, fx, fy) triple can use this. Calibration Any sensor that produces a raw (fn, fx, fy) triple can use this. Calibration
coefficients stay outside: pass ``convert`` into :meth:`ForceReadout.apply`. 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 __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, replace
import numpy as np import numpy as np
@@ -25,7 +14,6 @@ from .signal_processing import (
DEFAULT_SAMPLE_RATE_HZ, DEFAULT_SAMPLE_RATE_HZ,
CausalMedianFilter, CausalMedianFilter,
LowpassFilter, LowpassFilter,
MultiAxisCausalFilter,
SampleRateEstimator, SampleRateEstimator,
) )
@@ -374,37 +362,14 @@ class ForceReadout:
) )
return filter_changed return filter_changed
def reset_flow_ema(self) -> None: def reset(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_filter.reset()
self._force_median.reset() self._force_median.reset()
self._ema_flow = None
self.filtered_raw = None self.filtered_raw = None
self.raw_unfiltered = None self.raw_unfiltered = None
self.unfiltered_newton = 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: def state(self, *, dis_mode=None) -> dict:
"""Effective filter chain, for records and audits.""" """Effective filter chain, for records and audits."""
return { return {
@@ -427,300 +392,3 @@ class ForceReadout:
"any_stage_applied": bool(self.any_stage_applied), "any_stage_applied": bool(self.any_stage_applied),
"unfiltered_available": True, "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
-176
View File
@@ -1,176 +0,0 @@
"""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
-676
View File
@@ -1,676 +0,0 @@
"""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
),
)
-31
View File
@@ -155,37 +155,6 @@ class nHHD:
return self.d return self.d
return cp.asnumpy(self._d_gpu) return cp.asnumpy(self._d_gpu)
def get_field(self, valid_mask=None):
"""Curl-free HHD component and its reference-grid area scale for one frame.
The SDK calibration layer owns the field *profile* identity; this module
only publishes the numerical payload. It belongs to the frame that was
just decomposed: the next ``decompose`` call overwrites the solver's
arrays, so callers copy whatever they keep.
Returns:
dict with ``d`` (float32 HxWx2 copy), ``valid_mask`` (HxW bool),
``area_scale`` (reference pixels / current pixels, the same factor
``get_force`` already applies), ``grid_shape`` and ``reference_shape``.
"""
direction = getattr(self, 'd', None)
if direction is None:
raise ValueError("field requested before decompose")
shape = direction.shape[:2]
mask = self._normalize_mask(valid_mask, shape) if valid_mask is not None else None
field = numpy.array(direction, dtype=numpy.float32, copy=True)
if mask is not None:
field[~mask] = 0.0
area_scale, _ = self._force_scale(shape)
reference = self._force_ref_shape or shape
return {
'd': field,
'valid_mask': numpy.ones(shape, dtype=bool) if mask is None else mask.copy(),
'area_scale': float(area_scale),
'grid_shape': (int(shape[0]), int(shape[1])),
'reference_shape': (int(reference[0]), int(reference[1])),
}
def _solve_poisson_backend(self, field, verbose=False, valid_mask=None): def _solve_poisson_backend(self, field, verbose=False, valid_mask=None):
"""Dispatch Poisson solve using the selected backend. """Dispatch Poisson solve using the selected backend.
-592
View File
@@ -1,592 +0,0 @@
"""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),),
)
+21 -116
View File
@@ -84,7 +84,7 @@ __all__ = [
#: Array fields a snapshot can carry. ``image`` is the working grey frame; the #: Array fields a snapshot can carry. ``image`` is the working grey frame; the
#: rest come from ``Sensor.read_info`` (already copied by the SDK). #: rest come from ``Sensor.read_info`` (already copied by the SDK).
_ARRAY_FIELDS = ("image", "flow", "vnormal", "vshear", "depth", "centroid", "contour", "field") _ARRAY_FIELDS = ("image", "flow", "vnormal", "vshear", "depth", "centroid", "contour")
#: How many periods the stages wait on the pause event before re-checking for a #: 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. #: stop. Small enough to resume promptly, large enough not to spin a core.
@@ -156,20 +156,6 @@ class FrameSnapshot:
source_metadata: Any = None source_metadata: Any = None
force_units: tuple[str, str, str] | None = None force_units: tuple[str, str, str] | None = None
calibration_status: dict[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 @dataclass
@@ -241,19 +227,9 @@ class StreamPipeline:
self._threads: list[threading.Thread] = [] self._threads: list[threading.Thread] = []
self._error: BaseException | None = None self._error: BaseException | None = None
self._running = False self._running = False
#: Request allocation and flow-stage claiming share a lock. A request #: Set by :meth:`reset` and consumed by the flow stage, so that resetting sensor state
#: already claimed by flow cannot acknowledge another request made later. #: never races the stage that owns that state.
self._reset_lock = threading.Lock() self._reset_pending = threading.Event()
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; #: Pause state. ``_pause_level`` is what a pause would hold when armed;
#: ``_currently_capturing`` records whether the producer is still #: ``_currently_capturing`` records whether the producer is still
#: reading, which decides whether a resume has to rebase the tracker. #: reading, which decides whether a resume has to rebase the tracker.
@@ -339,50 +315,18 @@ class StreamPipeline:
with self._lock: with self._lock:
return self._latest 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: def reset(self) -> None:
"""Request a reference reset, applied at frame boundaries by its owning stage. """Reset the sensor's tracker and force-filter state, safely.
Call this from the consumer thread (after a key press, on a drift alarm). The reset is Call this from the consumer thread (after a key press, on a drift alarm). The reset is
queued, never inline: ``Sensor.reset_image_state`` rebases ``initial_img`` on the current queued and applied by the flow stage before its next frame, never inline: ``Sensor.reset``
``Sensor.img``, and the flow stage is reading that buffer concurrently, so resetting from rebases ``initial_img`` on the current ``Sensor.img``, and the flow stage is reading that
the caller's thread would capture a half-written reference frame. buffer concurrently, so resetting from the caller's thread would capture a half-written
reference frame.
The two halves are applied by the threads that own their state. The flow stage resets the Idempotent: a second call before the next frame does not queue twice.
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.request_reset() self._reset_pending.set()
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 @property
def paused(self) -> bool: def paused(self) -> bool:
@@ -434,7 +378,7 @@ class StreamPipeline:
# Frames read during the pause were never computed, so the sensor's # 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 # reference frame is stale by however long the hold lasted. Ask for a
# rebase; the flow stage applies it before its next frame. # rebase; the flow stage applies it before its next frame.
self.reset() self._reset_pending.set()
# Arm capture *before* lifting the hold. The producer stops as soon as # 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 # 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 # resume must have `_currently_capturing` true by then or the producer
@@ -620,36 +564,15 @@ class StreamPipeline:
self._drain(self._q1) self._drain(self._q1)
self._drain(self._q2) self._drain(self._q2)
continue continue
# Applied here, on the thread that owns the sensor's image/flow state, before this # Applied here, on the thread that owns the sensor's flow state, before this frame is
# frame is computed: see reset() for why this must not run on the caller's thread. # computed: see reset() for why this must not run on the caller's thread.
with self._reset_lock: if self._reset_pending.is_set():
reset_version = self._pending_reset_version self._reset_pending.clear()
self._pending_reset_version = None self.sensor.reset()
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 frame, t_cap, produce_s, source_metadata = item
t0 = time.perf_counter() t0 = time.perf_counter()
# The frame is passed explicitly: the SDK's self.img slot is shared # The frame is passed explicitly: the SDK's self.img slot is shared
# with the producer, so it must not be the transport. # 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) self.sensor.compute_flow(frame)
flow_s = time.perf_counter() - t0 flow_s = time.perf_counter() - t0
flow = getattr(self.sensor, "flow", None) flow = getattr(self.sensor, "flow", None)
@@ -658,8 +581,7 @@ class StreamPipeline:
flow_copy = flow.copy() if isinstance(flow, np.ndarray) else None flow_copy = flow.copy() if isinstance(flow, np.ndarray) else None
self._put( self._put(
self._q2, 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) self._put(self._q2, None)
@@ -675,15 +597,7 @@ class StreamPipeline:
if not self._wait_out_pause(): if not self._wait_out_pause():
break break
continue continue
frame, flow, t_cap, produce_s, flow_s, source_metadata, reference_version = item frame, flow, t_cap, produce_s, flow_s, source_metadata = 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() t0 = time.perf_counter()
# Explicit flow: compute_hhd reads self.flow, which the flow stage # Explicit flow: compute_hhd reads self.flow, which the flow stage
# overwrites for the next frame, so it cannot be the transport. # overwrites for the next frame, so it cannot be the transport.
@@ -692,8 +606,7 @@ class StreamPipeline:
self.sensor.compute_contact() self.sensor.compute_contact()
hhd_s = time.perf_counter() - t0 hhd_s = time.perf_counter() - t0
snap = self._build( 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) self._publish(snap)
if self._on_frame is not None: if self._on_frame is not None:
@@ -715,8 +628,7 @@ class StreamPipeline:
def _build(self, frame: np.ndarray, flow: np.ndarray | None, produce_s: float, def _build(self, frame: np.ndarray, flow: np.ndarray | None, produce_s: float,
flow_s: float, hhd_s: float, t_cap: float, flow_s: float, hhd_s: float, t_cap: float,
source_metadata: Any = None, source_metadata: Any = None) -> FrameSnapshot:
reference_version: int = 0) -> FrameSnapshot:
sensor = self.sensor sensor = self.sensor
info = sensor.info info = sensor.info
include = self._include include = self._include
@@ -746,7 +658,6 @@ class StreamPipeline:
torque=float(getattr(sensor, "ftor", 0.0)), torque=float(getattr(sensor, "ftor", 0.0)),
force_units=tuple(getattr(sensor, "force_units", (getattr(sensor, "force_unit", "raw"),) * 3)), force_units=tuple(getattr(sensor, "force_units", (getattr(sensor, "force_unit", "raw"),) * 3)),
calibration_status=dict(getattr(sensor, "calibration_status", {})), 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. # Both already private per-frame copies owned by this snapshot.
image=frame if "image" in include else None, image=frame if "image" in include else None,
flow=flow if "flow" in include else None, flow=flow if "flow" in include else None,
@@ -761,12 +672,6 @@ class StreamPipeline:
hhd_ms=hhd_s * 1000.0, hhd_ms=hhd_s * 1000.0,
image_size_raw=image_size_raw, image_size_raw=image_size_raw,
source_metadata=source_metadata, 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: def _fps(self) -> float:
+52 -494
View File
@@ -8,10 +8,8 @@ and Helmholtz-Hodge decomposition.
# import os # only used by commented-out stitch_cal # import os # only used by commented-out stitch_cal
import time import time
from dataclasses import dataclass
from enum import Enum, auto from enum import Enum, auto
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING
import cv2 import cv2
import numpy as np import numpy as np
@@ -19,57 +17,28 @@ import numpy as np
from . import calibration as cal_mod from . import calibration as cal_mod
from . import configs from . import configs
from .backend_availability import check_cupy_available, check_opencl_available from .backend_availability import check_cupy_available, check_opencl_available
# Explicit re-exports: ``calibration.py`` resolves named presets through this from .force_readout import (
# module and the preset tests import the table from here. FILTER_PRESETS,
from .force_readout import ForceReadout ForceReadout,
from .force_readout import FILTER_PRESETS as FILTER_PRESETS available_filter_presets,
from .force_readout import available_filter_presets as available_filter_presets )
from .frame_processors import create_frame_processor from .frame_processors import create_frame_processor
from .field_calibration import FIELD_PROFILE
from .get_depth import Contact3D from .get_depth import Contact3D
from .hhd import nHHD from .hhd import nHHD
from .image_pipeline import ImagePipeline, ImageSettings
# from threading import Thread, Event # from threading import Thread, Event
# from queue import Queue, Empty # from queue import Queue, Empty
from .Stitcher import Stitcher from .Stitcher import Stitcher
from .tracker import Tracker
from .capture import ( from .capture import (
CaptureConfig, CaptureConfig,
CaptureSource, CaptureSource,
CapturedFrame,
ReconnectPolicy, ReconnectPolicy,
is_live_camera, is_live_camera,
normalize_source, normalize_source,
open_capture, 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: class Sensor:
""" """
@@ -79,10 +48,6 @@ class Sensor:
Supports both synchronous and asynchronous (pipelined) processing modes. 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) # Info Enum (for read_info API)
# ========================================================================= # =========================================================================
@@ -234,10 +199,6 @@ class Sensor:
self.bind_calibration_context(build_context( self.bind_calibration_context(build_context(
self._config, self.filters_state(), preprocessing={"mirror": self.mirror}, 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.verbose:
if self.calibration is not None and self.calibration.source_path is not None: if self.calibration is not None and self.calibration.source_path is not None:
@@ -275,10 +236,6 @@ class Sensor:
self.last_log_time = time.perf_counter() self.last_log_time = time.perf_counter()
self._fps = 0 self._fps = 0
self._last_capture_time = 0.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_processing_time = 0.0
self._last_stage_times = { self._last_stage_times = {
# Frame-post-processing roll-ups (seconds). # Frame-post-processing roll-ups (seconds).
@@ -301,38 +258,25 @@ class Sensor:
else None else None
) )
self.frame_processor = create_frame_processor(self.sensor_type, stitcher=self.stitcher) self.frame_processor = create_frame_processor(self.sensor_type, stitcher=self.stitcher)
# The image block (tracker + HHD + flow EMA) lives in ImagePipeline. The self.__tracker = Tracker(adaptive=True, cuda=cuda, verbose=self.verbose, config_tracker=self.__config_tracker)
# 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.__contact3d = Contact3D(config_contact=self.__config_contact, verbose=self.verbose)
self.__hhd = None self.__hhd = None
self._hhd_downsample = self.image_settings.hhd_downsample # 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_grid_shape = None self._hhd_grid_shape = None
self._hhd_ref_shape = None self._hhd_ref_shape = None
self.poisson_backend = self.image_settings.poisson_backend cfg_backend = self.__config_tracker.get("backend", None)
self.masked_poisson_solver = self.image_settings.masked_poisson_solver self.poisson_backend = cfg_backend if cfg_backend is not None else backend
self.masked_poisson_max_boundary = self.image_settings.masked_poisson_max_boundary self.masked_poisson_solver = self.__config_tracker.get("masked_poisson_solver", "sparse")
self.masked_poisson_boundary_stride = ( self.masked_poisson_max_boundary = self.__config_tracker.get("masked_poisson_max_boundary", 96)
self.image_settings.masked_poisson_boundary_stride 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_max_iters = self.image_settings.masked_poisson_max_iters self.masked_poisson_tol = self.__config_tracker.get("masked_poisson_tol", 1e-4)
self.masked_poisson_tol = self.image_settings.masked_poisson_tol self.masked_poisson_reg = self.__config_tracker.get("masked_poisson_reg", 1e-8)
self.masked_poisson_reg = self.image_settings.masked_poisson_reg
# Legacy PyAV capture state (unused after orisys.capture refactor). # Legacy PyAV capture state (unused after orisys.capture refactor).
# self._use_pyav = False # self._use_pyav = False
# self._av_container = None # self._av_container = None
@@ -701,13 +645,6 @@ class Sensor:
if image is None: if image is None:
raise Exception("Warning: Get image first") 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(image, valid_mask=self.valid_mask)
# flow = self.__tracker.track_raft(self.img) # flow = self.__tracker.track_raft(self.img)
if hasattr(flow, 'download'): if hasattr(flow, 'download'):
@@ -726,45 +663,6 @@ class Sensor:
"""``config``, ``measured`` or ``config (file)``.""" """``config``, ``measured`` or ``config (file)``."""
return self._force_readout.sample_rate_source 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): def compute_hhd(self, flow=None):
"""Decompose the optical flow and update the force readout. """Decompose the optical flow and update the force readout.
@@ -772,32 +670,12 @@ class Sensor:
``self.flow``. StreamPipeline passes its own per-frame copy so the flow ``self.flow``. StreamPipeline passes its own per-frame copy so the flow
stage cannot overwrite ``self.flow`` mid-decomposition. Existing stage cannot overwrite ``self.flow`` mid-decomposition. Existing
callers are unaffected. 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 flow = self.flow if flow is None else flow
if flow is None: if flow is None:
raise Exception("Warning: Compute flow first") 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] orig_h, orig_w = flow.shape[:2]
try: try:
k = max(1, int(self._hhd_downsample)) k = max(1, int(self._hhd_downsample))
@@ -864,7 +742,16 @@ class Sensor:
fn, fx, fy, ftor = forces fn, fx, fy, ftor = forces
self._force_raw = (float(fn), float(fx), float(fy)) self._force_raw = (float(fn), float(fx), float(fy))
self.ftor = float(ftor) self.ftor = float(ftor)
self._publish_force_sample() 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()
vnormal_ds = self.__hhd.get_normal() vnormal_ds = self.__hhd.get_normal()
vshear_ds = self.__hhd.get_shear() vshear_ds = self.__hhd.get_shear()
@@ -899,7 +786,15 @@ class Sensor:
np.multiply(self.vshear, m3, out=self.vshear) np.multiply(self.vshear, m3, out=self.vshear)
self.compute_deformation_valid = True self.compute_deformation_valid = True
self._infer_hhd_model(lambda: self.__hhd.get_field(valid_mask=mask_ds))
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)
return True return True
def compute_contact(self, vnormal=None): def compute_contact(self, vnormal=None):
@@ -914,10 +809,7 @@ class Sensor:
""" """
if self._uses_calibration_model() and not self.compute_deformation_valid: if self._uses_calibration_model() and not self.compute_deformation_valid:
raise cal_mod.CalibrationError("compute current deformation before schema 3 contact") raise cal_mod.CalibrationError("compute current deformation before schema 3 contact")
if (self._uses_calibration_model() if (self._uses_calibration_model() and self.compute_contact_valid
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): and self._calibration_result_frame == self._model_frame_id):
return self._model_contact_result return self._model_contact_result
multiple_foe = False multiple_foe = False
@@ -953,9 +845,7 @@ class Sensor:
print("Warning: contact centroid lies outside the valid UV mask.") print("Warning: contact centroid lies outside the valid UV mask.")
self.compute_contact_valid = True self.compute_contact_valid = True
if (self._uses_calibration_model() if self._uses_calibration_model():
and not self._measurement_session
and self.calibration.input_profile == "fn_delta_autocal_uv/1"):
self._model_contact_result = result self._model_contact_result = result
uv = self._calibration_contact_uv() uv = self._calibration_contact_uv()
self.apply_calibration_frame( self.apply_calibration_frame(
@@ -965,208 +855,6 @@ class Sensor:
return result 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: def read_info(self, *args) -> tuple:
""" """
Get sensor data by info type. Get sensor data by info type.
@@ -1220,56 +908,18 @@ class Sensor:
return tuple(mapping.get(arg, lambda: None)() for arg in args if isinstance(arg, self.info)) return tuple(mapping.get(arg, lambda: None)() for arg in args if isinstance(arg, self.info))
def reset_image_state(self) -> None: def reset(self):
"""Reset the image/flow half of the pipeline state (flow thread only). """Reset optical flow tracker and force-filter state."""
self.__tracker.reset()
Rebases the optical-flow tracker on the current ``img`` and drops the flow self._force_readout.reset()
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_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 # The unfiltered pair belongs to the frame it was computed for: a new
# session must not report the previous one's reading. # session must not report the previous one's reading.
self._force_unfiltered = None self._force_unfiltered = None
self._force_raw_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
self._reset_calibration_session() self._reset_calibration_session()
self.compute_deformation_valid = False
def reset(self): if self.img is not None:
"""Reset the image and force state together (single-threaded use). self.initial_img = self.img.copy()
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): def disconnect(self):
@@ -1366,8 +1016,6 @@ class Sensor:
flow_ema_alpha=flow_ema_alpha, flow_ema_alpha=flow_ema_alpha,
force_median_window=force_median_window, 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(): for readout in getattr(self, "_model_readouts", {}).values():
readout.configure( readout.configure(
lowpass_cutoff_hz=lowpass_cutoff_hz, lowpass_cutoff_hz=lowpass_cutoff_hz,
@@ -1523,48 +1171,6 @@ 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 @property
def force_calibrated(self) -> bool: def force_calibrated(self) -> bool:
"""True when at least one channel curve is loaded.""" """True when at least one channel curve is loaded."""
@@ -1605,7 +1211,6 @@ class Sensor:
self._calibration_context = None self._calibration_context = None
self._force_readout.reset() self._force_readout.reset()
self._apply_force_filter() self._apply_force_filter()
self._bind_sensor_context()
return return
if isinstance(cal, cal_mod.Calibration) or getattr(cal, "schema_version", None) == 3: if isinstance(cal, cal_mod.Calibration) or getattr(cal, "schema_version", None) == 3:
selected = cal selected = cal
@@ -1640,9 +1245,6 @@ class Sensor:
if not self._uses_calibration_model(): if not self._uses_calibration_model():
self._force_readout.reset() self._force_readout.reset()
self._apply_force_filter() 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): def _apply_force_filter(self):
convert = self.calibration.convert if self.calibration is not None else None convert = self.calibration.convert if self.calibration is not None else None
@@ -1688,12 +1290,6 @@ class Sensor:
def set_calibration_reference(self, raw, *, reference_id): def set_calibration_reference(self, raw, *, reference_id):
"""Declare a measured unloaded raw reference for this stream, not the model file.""" """Declare a measured unloaded raw reference for this stream, not the model file."""
if (self._uses_calibration_model()
and self.calibration.input_profile == FIELD_PROFILE):
raise cal_mod.CalibrationError(
"field model needs a declared weighted unloaded window, not a scalar zero; "
"use declare_unloaded_reference()"
)
if not isinstance(raw, dict) or not reference_id: if not isinstance(raw, dict) or not reference_id:
raise ValueError("raw must map axes to finite values and reference_id is required") raise ValueError("raw must map axes to finite values and reference_id is required")
values = {} values = {}
@@ -1707,26 +1303,6 @@ class Sensor:
if self._uses_calibration_model(): if self._uses_calibration_model():
self.calibration.reset(self._calibration_reference_id) self.calibration.reset(self._calibration_reference_id)
def declare_unloaded_reference(self, *, reference_id):
"""Start the declared unloaded window for a field model.
The model medians the next 30 consecutive valid weighted field sums;
until then every frame reports ``zero_unavailable``. Contact location is
deliberately not consulted, and the scalar raw reference path stays
rejected for this profile.
"""
if not self._uses_calibration_model():
raise cal_mod.CalibrationError("unloaded reference requires a schema 3 calibration")
if self.calibration.input_profile != FIELD_PROFILE:
raise cal_mod.CalibrationError(
"weighted unloaded reference requires hhd_normal_l1_field/1")
if not isinstance(reference_id, str) or not reference_id.strip():
raise ValueError("reference_id must be a non-empty string")
self._reset_calibration_session()
self._calibration_reference = {}
self._calibration_reference_id = str(reference_id)
self.calibration.begin_reference(str(reference_id))
def _calibration_contact_uv(self): def _calibration_contact_uv(self):
if not self.centroid_found: if not self.centroid_found:
return None return None
@@ -1744,9 +1320,8 @@ class Sensor:
def apply_calibration_frame( def apply_calibration_frame(
self, *, frame_id, timestamp_s=None, raw=None, uv=None, self, *, frame_id, timestamp_s=None, raw=None, uv=None,
contact_found=None, mapping_valid=False, single_contact=True, contact_found=None, mapping_valid=False, single_contact=True,
field=None, output_mask=None,
): ):
"""Infer once from an unfiltered raw triple, contact UV or same-frame field.""" """Infer once from an unfiltered raw triple and same-frame optional contact UV."""
if not self._uses_calibration_model(): if not self._uses_calibration_model():
raise cal_mod.CalibrationError("apply_calibration_frame requires schema 3") raise cal_mod.CalibrationError("apply_calibration_frame requires schema 3")
if self._calibration_context is None: if self._calibration_context is None:
@@ -1778,12 +1353,6 @@ class Sensor:
} }
if contact_found is not None: if contact_found is not None:
features["contact_found"] = bool(contact_found) features["contact_found"] = bool(contact_found)
if field is not None:
features["field"] = field
mask = self.valid_mask if output_mask is None else output_mask
if mask is None:
mask = np.ones(self.calibration.coordinate_map.shape[:2], dtype=bool)
features["output_valid_mask"] = np.asarray(mask, dtype=bool)
result = self.calibration.predict( result = self.calibration.predict(
frame_id=frame_id, timestamp_s=timestamp_s, frame_id=frame_id, timestamp_s=timestamp_s,
features=features, reference_id=self._calibration_reference_id, features=features, reference_id=self._calibration_reference_id,
@@ -2059,20 +1628,9 @@ class Sensor:
# return self.cap is not None and self.cap.isOpened() # return self.cap is not None and self.cap.isOpened()
def _read_frame(self): def _read_frame(self):
captured: CapturedFrame | None = None
if self._capture_source is not None and self._capture_source.running: if self._capture_source is not None and self._capture_source.running:
captured = self._capture_source.read_latest_stamped(copy=True, wait_s=1.0) return self._capture_source.read_latest(copy=True, wait_s=1.0)
elif self._capture_source is not None: return self._read_frame_raw()
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): def _read_frame_raw(self):
if self._capture_source is not None: if self._capture_source is not None:
-922
View File
@@ -1,922 +0,0 @@
"""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
-11
View File
@@ -53,12 +53,6 @@ from .capabilities import (
report, report,
unavailable, unavailable,
) )
from .context import (
SensorAssetBinding,
SensorContext,
build_sensor_context,
fallback_sensor_context,
)
from .descriptor import ( from .descriptor import (
BACKEND_COMPOUND_EYE, BACKEND_COMPOUND_EYE,
BACKEND_SINGLE_CAMERA, BACKEND_SINGLE_CAMERA,
@@ -106,11 +100,6 @@ __all__ = [
"Availability", "Availability",
"AxisAvailability", "AxisAvailability",
"Issue", "Issue",
# read-only per-instance binding (T04)
"SensorContext",
"SensorAssetBinding",
"build_sensor_context",
"fallback_sensor_context",
# descriptor # descriptor
"BundleDescriptor", "BundleDescriptor",
"DescriptorError", "DescriptorError",
-353
View File
@@ -1,353 +0,0 @@
"""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}",),
)
-137
View File
@@ -366,143 +366,6 @@ class CausalMedianFilter:
return np.median(self._buf, axis=0) 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: class SampleRateEstimator:
"""Robust online estimate of the rate at which :meth:`tick` is called. """Robust online estimate of the rate at which :meth:`tick` is called.
-3
View File
@@ -44,8 +44,6 @@ class Stitcher:
self.box_size = config_stitcher["box_size"] self.box_size = config_stitcher["box_size"]
# Physical tile count for box detection (often 15); grid n is the active stitch layout. # 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)) 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._blend_coef_cache = {}
self._feature_stitch_cache = { self._feature_stitch_cache = {
@@ -248,7 +246,6 @@ class Stitcher:
ncol=self.ncol, ncol=self.ncol,
show=show, show=show,
n_boxes_target=self.n_boxes_target, n_boxes_target=self.n_boxes_target,
exclude_x=self.exclude_x,
) )
self.boxes = boxes self.boxes = boxes
# Detection target is physical unit count; active stitch grid is nrow*ncol via index. # Detection target is physical unit count; active stitch grid is nrow*ncol via index.
+1 -15
View File
@@ -72,14 +72,8 @@ def detect_nonblack_regions(
show: bool = False, show: bool = False,
row_tol: float = 0.4, row_tol: float = 0.4,
save_preview_path: str = "./config/compound_eye.png", save_preview_path: str = "./config/compound_eye.png",
exclude_x: Sequence[int] = (0, 0),
) -> tuple[int, list[tuple[int, int, int, int]]]: ) -> 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: if img is None:
raise ValueError("Input image is None.") raise ValueError("Input image is None.")
@@ -87,7 +81,6 @@ def detect_nonblack_regions(
n_boxes = 0 n_boxes = 0
bk_thresh = 0 bk_thresh = 0
boxes: list[tuple[int, int, int, int]] = [] 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) hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
v = hsv[:, :, 2] v = hsv[:, :, 2]
@@ -117,11 +110,6 @@ def detect_nonblack_regions(
max_area_frac=max_area_frac, max_area_frac=max_area_frac,
max_aspect_ratio=max_aspect_ratio, 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) n_boxes = len(boxes)
bk_thresh += bk_thresh_step bk_thresh += bk_thresh_step
@@ -205,7 +193,6 @@ def find_stitch_boxes(
ncol: int, ncol: int,
show: bool = False, show: bool = False,
n_boxes_target: int | None = None, n_boxes_target: int | None = None,
exclude_x: Sequence[int] = (0, 0),
): ):
"""Run bloom correction + detection; return ``(n_boxes, boxes, grid_ok)``. """Run bloom correction + detection; return ``(n_boxes, boxes, grid_ok)``.
@@ -223,7 +210,6 @@ def find_stitch_boxes(
n_boxes_target=target, n_boxes_target=target,
show=show, show=show,
row_tol=row_tol, row_tol=row_tol,
exclude_x=exclude_x,
) )
grid_ok = n_boxes == target grid_ok = n_boxes == target
return n_boxes, boxes, grid_ok return n_boxes, boxes, grid_ok
-578
View File
@@ -1,578 +0,0 @@
"""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),
)
-94
View File
@@ -306,18 +306,6 @@ def test_device_calibration_rejects_wrong_identity_and_pipeline(tmp_path: Path):
load_for_device(root, "box", "BOX-001", pipeline={"dis_mode": 1}) 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): def test_device_calibration_rejects_partial_axes_and_unsafe_id(tmp_path: Path):
root = tmp_path / "calibrations" root = tmp_path / "calibrations"
root.mkdir() root.mkdir()
@@ -628,85 +616,3 @@ def test_every_preset_reports_what_it_does(
assert fp["measurement"]["flow_ema_alpha"] == pytest.approx(alpha) assert fp["measurement"]["flow_ema_alpha"] == pytest.approx(alpha)
assert fp["readout"]["force_median_window"] == median assert fp["readout"]["force_median_window"] == median
assert fp["readout"]["force_lowpass_cutoff_hz"] == pytest.approx(cutoff) 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
-75
View File
@@ -1,7 +1,6 @@
"""Feature identities use effective measurement settings, never display metadata.""" """Feature identities use effective measurement settings, never display metadata."""
from copy import deepcopy from copy import deepcopy
import json
import numpy as np import numpy as np
import pytest import pytest
@@ -58,77 +57,3 @@ 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 changed[key].flat[0] = not changed[key].flat[0] if key == "valid_mask" else 1
assert domain_fingerprint(changed) != original assert domain_fingerprint(changed) != original
assert domain_fingerprint({key: value.copy() for key, value in domain.items()}) == 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))
-97
View File
@@ -1,97 +0,0 @@
"""``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)
-464
View File
@@ -1,464 +0,0 @@
"""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)
-29
View File
@@ -1,29 +0,0 @@
"""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)
-219
View File
@@ -1,219 +0,0 @@
"""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
-396
View File
@@ -1,396 +0,0 @@
"""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
-752
View File
@@ -1,752 +0,0 @@
"""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)
-521
View File
@@ -1,521 +0,0 @@
"""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
-312
View File
@@ -55,45 +55,6 @@ def make_sensor(*, median=1):
return sensor 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): def predict(sensor, frame, fn, *, contact=True, mapped=True):
return sensor.apply_calibration_frame(frame_id=frame, timestamp_s=frame / 30., return sensor.apply_calibration_frame(frame_id=frame, timestamp_s=frame / 30.,
raw=(fn, 20., 30.), uv=(0., 0.), raw=(fn, 20., 30.), uv=(0., 0.),
@@ -345,276 +306,3 @@ def test_bad_zero_reference_is_rejected_without_reset(reference):
with pytest.raises(ValueError): with pytest.raises(ValueError):
sensor.set_calibration_reference(reference, reference_id="bad") sensor.set_calibration_reference(reference, reference_id="bad")
assert sensor._calibration_reference == {"fn": 10.} assert sensor._calibration_reference == {"fn": 10.}
class FieldRecordingModel:
schema_version = 3
channels = {"fn": "N"}
input_profile = "hhd_normal_l1_field/1"
def __init__(self):
self.calls = []
self.resets = []
self.begin_calls = []
self.coordinate_map = np.zeros((2, 2, 2), dtype=np.float32)
self.coordinate_valid_mask = np.ones((2, 2), dtype=bool)
def reset(self, reference_id):
self.resets.append(reference_id)
def begin_reference(self, reference_id):
self.begin_calls.append(reference_id)
def validate_for_context(self, context):
if context != {"signature": "actual"}:
raise CalibrationError("pipeline mismatch")
def predict(self, **kwargs):
self.calls.append(kwargs)
features = kwargs["features"]
if "field" not in features:
return SimpleNamespace(values_N={"fn": None}, status={"fn": "invalid_input"},
diagnostics={})
return SimpleNamespace(values_N={"fn": 1.0}, status={"fn": "ok"}, diagnostics={})
def test_hhd_field_model_infers_from_same_frame_field_without_contact():
sensor = make_sensor()
sensor.calibration = FieldRecordingModel()
sensor._calibration_context = {"signature": "actual"}
sensor.cuda = sensor.verbose = False
sensor.valid_mask = sensor.valid_mask_hhd = None
sensor._hhd_downsample = 1
sensor._hhd_grid_shape = sensor._hhd_ref_shape = (2, 2)
sensor._model_frame_id = 0
field = np.zeros((2, 2, 2), dtype=np.float32)
calls = []
def get_field(valid_mask=None):
calls.append(valid_mask)
return {"d": field, "valid_mask": np.ones((2, 2), dtype=bool), "area_scale": 1.0,
"grid_shape": (2, 2), "reference_shape": (2, 2)}
sensor._Sensor__hhd = SimpleNamespace(
get_force=lambda *args, **kwargs: (None, (10., 0., 0., 0.)),
get_normal=lambda: field, get_shear=lambda: field, get_field=get_field,
)
sensor.compute_hhd(np.zeros((2, 2, 2), dtype=np.float32))
features = sensor.calibration.calls[-1]["features"]
assert features["field"]["grid_shape"] == (2, 2)
assert features["output_valid_mask"].shape == (2, 2)
assert "contact_found" not in features
assert sensor.calibration_status == {"fn": "ok"}
assert sensor.fnormal == pytest.approx(1.0)
assert calls == [None]
def test_weighted_unloaded_reference_is_declared_not_a_scalar_zero():
sensor = make_sensor()
sensor.calibration = FieldRecordingModel()
sensor._calibration_context = {"signature": "actual"}
sensor.declare_unloaded_reference(reference_id="video-unloaded-100")
assert sensor.calibration.begin_calls == ["video-unloaded-100"]
assert sensor._calibration_reference_id == "video-unloaded-100"
with pytest.raises(CalibrationError, match="scalar zero"):
sensor.set_calibration_reference({"fn": 0.}, reference_id="bad")
with pytest.raises(CalibrationError, match="hhd_normal_l1_field"):
make_sensor().declare_unloaded_reference(reference_id="scalar-model")
def test_finger_runtime_binds_field_identity_and_forwards_the_window(monkeypatch, tmp_path):
import orisys.calibration_context as context_mod
from orisys.field_calibration import FIELD_IDENTITY
from orisys.finger.runtime import FingerRuntime
sensor = make_sensor()
sensor.calibration = FieldRecordingModel()
sensor._calibration_context = {"signature": "actual"}
sensor.device_id = None
sensor.mirror = False
runtime = FingerRuntime.__new__(FingerRuntime)
runtime.tactile = SimpleNamespace(raw_sensor=sensor)
mesh = tmp_path / "mesh.npz"
mesh.write_bytes(b"actual geometry")
runtime.geometry = SimpleNamespace(config={}, export_paths=SimpleNamespace(mesh=mesh),
source_scale=.42, out_height=2, out_width=2)
runtime.domain = object()
runtime.frame_processor = SimpleNamespace(flip_source_vertical=True)
monkeypatch.setattr(context_mod, "domain_fingerprint", lambda domain: "domain")
contexts = []
def context(config, filters, **kwargs):
contexts.append(kwargs)
return {"signature": "actual"}
monkeypatch.setattr(context_mod, "build_context", context)
selected = FieldRecordingModel()
selected.pipeline = {"coordinates": {"frame": "autocal_flat"}}
runtime.set_calibration(selected)
assert contexts[0]["field"] == FIELD_IDENTITY
runtime.declare_unloaded_reference("runtime-window")
assert sensor.calibration.begin_calls == ["runtime-window"]
# ------------------------------------------------- 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"]
+1 -346
View File
@@ -8,7 +8,7 @@ import time
import numpy as np import numpy as np
import pytest import pytest
from orisys import FrameSnapshot, PauseLevel, ProducedFrame, StreamPipeline from orisys import FrameSnapshot, ProducedFrame, StreamPipeline
class FakeSensor: class FakeSensor:
@@ -429,348 +429,3 @@ def test_snapshot_raw_capture_size_is_none_when_unreported():
time.sleep(0.001) time.sleep(0.001)
pipe.stop() pipe.stop()
assert frames and frames[0].image_size_raw is None 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()
+3 -11
View File
@@ -338,8 +338,7 @@ def test_publish_dry_run_resumes_when_tag_already_exists(tmp_path, monkeypatch,
assert "gh release create v1.2.3" in output assert "gh release create v1.2.3" in output
@pytest.mark.parametrize("tag", [None, "1.2.4"]) def test_publish_dry_run_creates_tag_when_absent(tmp_path, monkeypatch, capsys):
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) sdk, release, wheel = _synthetic_publish_layout(tmp_path, "1.2.4", tag_exists=False)
monkeypatch.setattr(github, "REPO_ROOT", sdk) monkeypatch.setattr(github, "REPO_ROOT", sdk)
monkeypatch.setattr(github, "MANIFEST_PATH", sdk / "tools" / "downstream_sync_manifest.json") monkeypatch.setattr(github, "MANIFEST_PATH", sdk / "tools" / "downstream_sync_manifest.json")
@@ -347,18 +346,11 @@ def test_publish_dry_run_creates_tag_when_absent(tmp_path, monkeypatch, capsys,
monkeypatch.setattr(github, "_release_exists", lambda repo, tag: False) monkeypatch.setattr(github, "_release_exists", lambda repo, tag: False)
exit_code = github.publish( exit_code = github.publish(
github.Options(version="1.2.4", apply=False, skip_fetch=True, tag=tag) github.Options(version="1.2.4", apply=False, skip_fetch=True)
) )
output = capsys.readouterr().out output = capsys.readouterr().out
assert exit_code == 0 assert exit_code == 0
expected_tag = tag or "v1.2.4" assert "git tag v1.2.4" in output
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): def test_publish_refuses_when_release_already_exists(tmp_path, monkeypatch, capsys):
-292
View File
@@ -1,292 +0,0 @@
"""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
-74
View File
@@ -14,7 +14,6 @@ from orisys.signal_processing import (
NORMAL_FORCE_CAL_OFFSET, NORMAL_FORCE_CAL_OFFSET,
NORMAL_FORCE_CAL_SLOPE, NORMAL_FORCE_CAL_SLOPE,
LowpassFilter, LowpassFilter,
MultiAxisCausalFilter,
calibrate_normal_force, calibrate_normal_force,
calibrate_shear_components, calibrate_shear_components,
calibrate_shear_force, calibrate_shear_force,
@@ -495,76 +494,3 @@ 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 # A dict always keys ``found_root``, so guard its value: an unguarded copy of
# this branch prints "bundle found at None". # this branch prints "bundle found at None".
assert "None" not in note 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))
-42
View File
@@ -166,45 +166,3 @@ def test_compound_eye_processor_calls_stitcher():
def test_unknown_backend_raises(): def test_unknown_backend_raises():
with pytest.raises(ValueError): with pytest.raises(ValueError):
get_blend_backend("torch") 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
-441
View File
@@ -1,441 +0,0 @@
"""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