Files
yovision/docs/raw/07-事件契约比对-silver_pose.md

13 KiB
Raw Permalink Blame History

事件契约比对:silver_pose 实际输出 vs YoVision 设计

目的:确定 silver_pose(已验证的推理内核)与 YoVision(平台层)之间唯一的那条契约。 比对基准:silver_pose v1/alerts.py + v2/internal/alert/dispatcher.go + v2/internal/store/store.go;YoVision《03-通用场景应用方案》§2.6。 日期:2026-08-03


0. 结论摘要

项 结论
v1 / v2 输出一致性 ✅ Python 与 Go 的 JSONL 字段完全相同,契约已经稳定,可以直接当基线
与平台设计的关系 silver_pose 的事件是平台事件的真子集,没有需要推翻的字段
真正的阻塞 不是字段缺失,是 3 个语义冲突(见 §3),必须先解决
接入点 已经存在——v1/alerts.py 的 AlertSink 就是为注入外部副作用设计的,不需要改动已验收代码
反向吸收 silver_pose 有 5 处设计文档没考虑到的东西,应该写回《03》§2.6

1. silver_pose 当前实际输出

1.1 JSONL(每确认事件一行)

v1/alerts.py:197-208 与 v2/internal/alert/dispatcher.go:104-113,字段一致:

{
  "event_id": "FALL-<session_id>-000001",
  "track_id": "P-0001",
  "config_version": "<配置版本串>",
  "source_id": "<来源标识>",
  "state": "CONFIRMED",
  "confirmed_at_utc": "2026-07-20T10:31:22.417000+00:00",
  "suspected_at_monotonic": 1043.21,
  "confirmed_at_monotonic": 1045.68,
  "latency_seconds": 2.47,
  "screenshot": "20260720/FALL-abc-000001.png"
}

1.2 SQLite(仅 v2,internal/store/store.go:35)

JSONL 之外多出的列:

source_kind (默认 'rtsp')、camera_host、camera_port、camera_channel

注释里写明:记录 host/port/channel,但永不记录密码。

1.3 状态机(v1/fall_state.py)

NORMAL → SUSPECT → CONFIRMED → RECOVERING,只有进入 CONFIRMED 才产出 FallEvent。 RECOVERING 表示「确认摔倒后又自己起来了」,当前不产出任何事件。


2. 字段级比对

silver_pose YoVision《03》§2.6 状态 说明
event_id id ⚠️ 需改造 生成方式不同,见 §3.2
track_id subject.track_id ✅ 直接对应 语义一致
source_id device_id ⚠️ 弱对应 前者是字符串标识,后者是平台设备实体主键,需映射表
state — ⚠️ 语义冲突 见 §3.3
— kind ❌ 缺 事件类型隐含在 event_id 的 FALL- 前缀里,必须显式化为 "fall"
confirmed_at_utc detected_at ✅ 直接对应 判定时刻,用于 SLA
suspected_at_monotonic occurred_at ❌ 不可用 单调时钟,跨进程无意义。见 §3.1
confirmed_at_monotonic — ➖ 可丢弃 有 confirmed_at_utc 即可
latency_seconds (派生) ✅ 保留 见 §4.3,建议平台侧也存
screenshot evidence.snapshot_uris[0] ✅ 直接对应 相对路径 → URI
config_version rule.version ⚠️ 粒度不同 见 §4.2,两者都要留
camera_host/port/channel (由 device_id 引用) ✅ 平台侧更优 平台不该在事件里冗余连接信息
— tenant_id / site_id ❌ 缺 单机演示不需要,平台必须
— rule{id,version,code} ❌ 缺 silver_pose 无规则实体,只有全局配置
— severity ❌ 缺 摔倒恒定 high,可由平台侧按 kind 补
— confidence ❌ 缺 见 §3.4,不是简单补字段
— subject.attributes ❌ 缺 无属性识别
— subject.anon_id ❌ 缺 无 ReID
— identity / identity_status ❌ 缺 可先恒为 null / "not_enabled"
— observation.bbox_seq_uri ❌ 重要缺口 见 §5.1
— observation.keypoint_seq_uri ❌ 重要缺口 见 §5.1
— evidence.clip_uri / clip_range ❌ 重要缺口 只有一张截图,无 pre-roll 视频
— dedup_key / aggregated_into ❌ 缺 见 §3.5
— outcome / outcome_reason ❌ 重要缺口 误报反馈闭环无落点

3. 五个语义冲突(不是补字段能解决的)

3.1 单调时钟 vs 墙钟 —— P0

suspected_at_monotonic / confirmed_at_monotonic 用的是 time.monotonic()。这在状态机内部是正确选择(不受系统对时影响,fall_state.py:78 还强制了单调递增校验),但它:

  • 进程重启后归零,跨进程无意义
  • 无法和平台的 occurred_at 对齐
  • 无法用于证据回捞(回捞需要墙钟去定位录像位置)

解决:在事件产出处补一个墙钟事发时刻。

occurred_at = confirmed_at_utc - (confirmed_at_monotonic - suspected_at_monotonic)
            = confirmed_at_utc - latency_seconds

这个换算精度足够(误差是单帧级别)。保留 monotonic 字段做内部诊断,但契约上以 occurred_at 为准。

3.2 event_id 生成方式 —— P0

当前是 FALL-{session_id}-{序号:06d}(fall_state.py:146-154),会话内自增。

  • 单机单路演示:没问题
  • 平台扩展到 128 路、多分片/多节点:序号会撞。session_id 若不是全局唯一,两台边缘节点会产出同一个 event_id,而 AlertDispatcher 的去重恰好是按 event_id 做的 → 平台侧会静默丢弃真事件

解决:平台契约用 ULID(evt_01J8X...)。silver_pose 侧保留原 event_id 作为 source_event_id 字段,便于回溯本地文件名(截图就是按它命名的)。

3.3 state 字段语义被占用 —— P0

silver_pose 的 state 是状态机状态(NORMAL/SUSPECT/CONFIRMED/RECOVERING);平台事件里没有这个概念——平台事件恒等于「已确认的客观发生」,其后续流转是告警的状态机(《03》§2.7),两者不能混。

解决:silver_pose 的 state 不进平台事件顶层。映射为:

  • state == "CONFIRMED" → 产出事件,kind: "fall"
  • state == "RECOVERING" → 不是新事件,是对已有事件的 outcome 输入(见 §4.1)

3.4 confidence 在这条链路上没有天然来源 —— P1

平台设计里 confidence: 0.87 隐含假设「有一个模型给出置信度」。但 silver_pose 的判定是几何证据 + 时间窗状态机(evidence.py 算躯干角度与髋部下坠比,fall_state.py 要求连续 1–3s 不中断),最终是布尔判定,没有一个自然的 0–1 分值。

不要硬凑一个假的置信度。 三个选项:

  1. 置 null,并允许 schema 里 confidence 可空(推荐先这样)
  2. 用可解释的替代量:horizontal_angle_degrees 距阈值的余量、visible_joint_count、latency_seconds(越接近确认窗口下限说明证据越"干脆")
  3. 后续换成分类模型时再填

对应到《02》里「七类动作模型优于二分类」的结论——真正的 confidence 要等模型换代才有意义。

3.5 去重只在进程内存里 —— P1

AlertDispatcher._seen_event_ids(Python)/ Dispatcher.seen(Go,dispatcher.go:34)都是内存 set/map:

  • 进程重启即丢失
  • 两个机位拍到同一次跌倒 → 两条独立事件,无合并
  • 无冷却期概念

这与《03》§2.6「同设备冷却期 / 同站点聚合 / 已处置抑制」是四个不同层次的去重。

解决:进程内去重保留(它防的是同帧重复写盘,是正确的);跨机位/跨时间的去重归平台侧,由 dedup_key 承担。silver_pose 只需在事件里带上足以构造 dedup_key 的原料(site + 位置 + kind + 时间桶)。


4. 反向吸收:silver_pose 比设计文档考虑得更周到的 5 处

这些应该写回《03》§2.6,是实战沉淀,设计文档里没有。

4.1 RECOVERING 状态是免费的 outcome 信号

《03》§2.6 的 outcome 只设计了人工来源(值班员标记误报)。但 silver_pose 的状态机已经能识别「确认摔倒后又自己站起来了」——这是自动的、及时的处置结果输入,比等人工标记快得多,而且正是家属/值班员最想知道的一件事。

建议:平台事件增加 outcome 的自动来源:

subject_recovered  —— 由推理侧在 RECOVERING 稳定后回传

并在《03》§2.6 的 outcome 取值里显式列出「自动/人工」两种来源。

4.2 config_version 进事件

设计文档只有 rule.version(单条规则版本)。silver_pose 把整个判定配置的版本钉进每条事件(fall_state.py 构造时强制非空校验)。

价值:调完阈值后跑回归,能精确区分「哪些事件是旧配置产出的」。rule.version 粒度不够——阈值往往是全局的。

建议:《03》§2.6 事件结构中,rule 之外增加顶层 config_version。

4.3 latency_seconds 显式存储

可以由两个时间戳相减,但显式存有两个好处:一是 SLA 报表不用每次算,二是它是判定质量的直接指标(贴近确认窗口下限 = 证据干脆;贴近上限 = 勉强通过,是误报高发区)。

建议:保留为顶层字段,并作为误报排查的首选排序键。

4.4 证据文件命名的隐私纪律

v1/alerts.py 文件头注释:

Artifact names use only the event ID and a date folder — never an RTSP address, credential, or client name.

这条纪律设计文档里没有写。截图/片段的文件名会出现在日志、URL、客服工单里,是最容易泄露客户身份和摄像头地址的地方。

建议:写入《03》§2.6「三条硬约束」表(升级为四条)。

4.5 存连接信息但绝不存密码

store.go 顶部注释明确:记录 camera_host / port / channel,永不记录密码。同样应写入约束表。


5. 缺口分档

P0 —— 不补则平台无法工作(M1 前)

# 缺口 动作
1 墙钟 occurred_at 由 confirmed_at_utc - latency_seconds 换算
2 全局唯一 id 平台侧生成 ULID,原 id 降为 source_event_id
3 显式 kind 恒为 "fall",从前缀提升为字段
4 tenant_id / site_id / device_id 由 source_id 经映射表补全,映射表在平台侧
5 state 不进事件顶层 按 §3.3 映射

P1 —— 接入后很快会缺(M2–M3)

# 缺口 说明
6 evidence.clip_uri + clip_range 现在只有一张截图。一张截图不足以让值班员判断真假,这是误报反馈闭环的前置条件
7 observation.bbox_seq_uri / keypoint_seq_uri 《02》C9 数据闭环的唯一原料。只有截图无法训练。silver_pose 内部已经有逐帧 keypoints,只是没落盘——这是最低成本的高价值补齐
8 outcome 回写通道 平台 → 推理侧的反向通道,目前完全没有
9 dedup_key 原料 见 §3.5

P2 —— 后续(M4+)

severity / confidence / subject.attributes / anon_id / identity / aggregated_into。 其中 identity_status 建议立刻恒填 "not_enabled",成本为零,避免以后区分不了「没开」和「比对失败」。


6. 接入方案:不改已验收代码

关键发现:接入点已经存在。

v1/alerts.py 的 AlertSink 本来就是为注入外部副作用设计的(play_sound / show_popup 都是可注入的桌面适配器):

class AlertSink:
    """Injectable output for sound and popup. Default implementation is silent."""
    def play_sound(self, event: FallEvent) -> None: ...
    def show_popup(self, record: AlertRecord) -> None: ...

所以平台接入 = 新增一个 sink 实现,而不是改 EventArtifactWriter(它已通过单元测试与 T-000 验收):

FallEvent ──→ EventArtifactWriter ──→ 本地截图 + JSONL   (现状,不动)
          └─→ PlatformSink        ──→ mapper ──→ YoVision event JSON ──→ 平台

PlatformSink 的职责只有三件:字段映射(§2 的表)、P0 补全(§5)、投递(HTTP POST 起步,后续换 ZeroMQ)。投递失败必须落本地队列重试,不能阻塞主链路——这与《03》§2.7「告警先落库再投递」是同一原则。

v2 Go 侧结构相同,Dispatcher.Dispatch 返回 Record 后加一个 publisher 即可,同样不动 write。


7. 下一步(按顺序)

  1. 确认边界:silver_pose 保持独立仓库,是「已验证的推理内核 + 单场景演示」;YoVision 是平台层。两者只通过本文档定义的事件 JSON 通信
  2. 冻结 v0.1 契约:把 §2 的映射表 + §5 的 P0 五项,写成一份 JSON Schema,两个仓库各放一份(内容相同),改动必须双向同步
  3. 补 §4 的五处反向吸收到《03》§2.6,特别是把隐私纪律(§4.4、§4.5)升格为硬约束
  4. P1 第 7 项优先做:keypoint 序列落盘。它在推理侧几乎零成本(数据已在内存),但决定了《02》C9 数据闭环能否启动
  5. confidence 保持 null,不要造假值(§3.4)