474dbf5a64
构造时一次绑定 SN、配置、资产和当前生效的标定:只接受显式给出的 SN,不做猜测;标定路径只取当前生效的标定,清除后为 None;换标定时整体替换。帧身份复用 stream-contract 的 FrameIdentity。load_config 改为复用 resolve_config_source,使记录的配置来源与实际读取的文件一致,并补一致性测试。已发布 Sensor 数值和资产包解析不变。回归 275 项、lint 通过。
217 lines
13 KiB
Markdown
217 lines
13 KiB
Markdown
# 单路流类型合同(`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 的图像块绑定;需要复现实验时以 T05/记录的身份为准。
|
||
- 资产取应用包内相对路径,不复制、不上传、不按顺序推断;配置指纹取实际加载后的配置内容,而不是文件名。
|
||
- 本任务(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"
|
||
```
|
||
|
||
## 不包含什么
|
||
|
||
- 不实现 SensorStream 入口、实时最新选帧、完整回放和背压(T08)。
|
||
- 不实现图像块拆分、测量负载、ForceSession 重做、零点与输出滤波(T05–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 格式和非法负载拒绝。`tests/test_sensor_context.py` 覆盖 T04 的只读 SensorContext:显式 SN/配置/资产/标定路径绑定、不猜 SN、实例隔离、换标定原子替换、绑定失败只报 diagnostics,以及 config source/资产包绑定。类型与上下文本身不启动传感器、流水线或会话。
|