f5738375fd
T08b 补齐文件/debug 完整处理与背压、源帧号和时间基、同帧 field 快照,输入结束时未完成归零明确报错。独立验收见 docs/workflow/sdk-sensorstream-implementation-plan.zh.md 第 44 轮。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
164 lines
11 KiB
Markdown
164 lines
11 KiB
Markdown
# 新力侧会话与单路入口(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`)。
|
||
|
||
## 规则
|
||
|
||
- **版本/会话变化即废旧零点(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` 前先把零点、标定、读数引擎从旧类中
|
||
抽出来,避免新会话依赖待删除的类型。
|