Files
orisys-sdk/docs/stream-contract.zh.md
T
ykcheungab 474dbf5a64 feat: 为每个 Sensor 绑定只读 SensorContext
构造时一次绑定 SN、配置、资产和当前生效的标定:只接受显式给出的 SN,不做猜测;标定路径只取当前生效的标定,清除后为 None;换标定时整体替换。帧身份复用 stream-contract 的 FrameIdentity。load_config 改为复用 resolve_config_source,使记录的配置来源与实际读取的文件一致,并补一致性测试。已发布 Sensor 数值和资产包解析不变。回归 275 项、lint 通过。
2026-10-21 11:04:02 +08:00

13 KiB
Raw Blame History

单路流类型合同(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 的图像块绑定;需要复现实验时以 T05/记录的身份为准。
  • 资产取应用包内相对路径,不复制、不上传、不按顺序推断;配置指纹取实际加载后的配置内容,而不是文件名。
  • 本任务(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"

不包含什么

  • 不实现 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/资产包绑定。类型与上下文本身不启动传感器、流水线或会话。