冻结逐帧身份、采集时钟、测量头和事件信封:FrameIdentity、FrameClock、FrameStamp、MeasurementFrame、StreamEvent,并从包根导出。采用严格读取:未知字段和未列出的版本一律拒绝,任何字段变更都升版本,读取方在 SUPPORTED_CONTRACT_VERSIONS 中列出可读版本;未知事件 kind 仍兼容。事件可哈希,kind 为 lower_snake。合同写入 docs/stream-contract.zh.md;不接入现有运行时。窄套件 139 项、lint 通过。
9.6 KiB
单路流类型合同(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() 从组件构造。
{
"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 的事件允许落在同一哈希桶。
{
"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 中显式列出的版本,并校验必填字段、未知字段和字段类型,损坏的记录立即失败而不是被部分接受。下面的例子可以直接运行:
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;同版本内不新增字段。
不包含什么
- 不实现 SensorStream 入口、实时最新选帧、完整回放和背压(T08)。
- 不实现图像块拆分、测量负载、ForceSession 重做、零点与输出滤波(T04–T07)。
- 不实现批次、配组、多流调度(后续多流计划)。
- 不实现录制/回放格式、Studio 迁移和离线重新滤波(T11、T13–T15)。
- 不修改现有
Sensor、StreamPipeline和当前ForceSession;现有ForceSession.FrameStamp在 T06 重做时被本合同的帧头替换。
验证
tests/test_stream_contract.py 覆盖 T03 的四项验收:逐路身份与时钟、组字段可缺但不半组、同 PTS 帧可区分、source_dt_s 与事件 JSON 往返;同时覆盖 frozen 校验、严格版本/未知字段拒绝(政策 A)、SUPPORTED_CONTRACT_VERSIONS、事件可哈希、kind 的 lower_snake 格式和非法负载拒绝。类型本身不启动传感器、流水线或会话。