13 KiB
事件契约比对: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 分值。
不要硬凑一个假的置信度。 三个选项:
- 置
null,并允许 schema 里confidence可空(推荐先这样) - 用可解释的替代量:
horizontal_angle_degrees距阈值的余量、visible_joint_count、latency_seconds(越接近确认窗口下限说明证据越"干脆") - 后续换成分类模型时再填
对应到《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. 下一步(按顺序)
- 确认边界:silver_pose 保持独立仓库,是「已验证的推理内核 + 单场景演示」;YoVision 是平台层。两者只通过本文档定义的事件 JSON 通信
- 冻结 v0.1 契约:把 §2 的映射表 + §5 的 P0 五项,写成一份 JSON Schema,两个仓库各放一份(内容相同),改动必须双向同步
- 补 §4 的五处反向吸收到《03》§2.6,特别是把隐私纪律(§4.4、§4.5)升格为硬约束
- P1 第 7 项优先做:keypoint 序列落盘。它在推理侧几乎零成本(数据已在内存),但决定了《02》C9 数据闭环能否启动
confidence保持null,不要造假值(§3.4)