260 lines
13 KiB
Markdown
260 lines
13 KiB
Markdown
# 事件契约比对: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`,字段一致:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"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` 都是可注入的桌面适配器):
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
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)
|