光流、HHD 和场从 Sensor 中抽到 ImageSettings/ImagePipeline,Sensor 与 FingerRuntime 改为委托,公开接口与数值不变(222 个基线数组逐位一致,含运行时 flow_ema_alpha 场景)。参考版本只有一个来源:StreamPipeline 在 flow 线程领取的版本逐帧交给图像块。对外数组为只读副本;输出带 stream-contract 的 MeasurementFrame 头。回归 357 项、lint 通过。
16 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;同版本内不新增字段。
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 消费该上下文。
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)。
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=...) 的在途版本保留。