Files
yovision/docs/其他项目的文档/跌倒检测视频接入系统 — 线上生产开发方案.md
T

730 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🏗️ 跌倒检测视频接入系统 — 线上生产开发方案
> 来源:Notion · https://app.notion.com/p/3af401dc38a681c29985ffdbd0e9f3d1
> 导出时间:2026-08-01
> 版本 v1 · 面向 200 户 / 250 路 IP 摄像头的跌倒检测视频接入系统。本文档描述**生产环境**架构与开发规范。演示与验证阶段见配套文档《演示与验证开发方案》。
# 1. 目标与约束
| 项 | 值 |
| --- | --- |
| 接入规模 | 约 200 个家庭,250 路 IP 摄像头 |
| 业务目标 | 视频流分析实现摔倒动作检测 |
| 关键约束 | 摄像头位于家庭路由器 NAT 之后,中心机房无法主动访问 |
| 未来扩展 | 毫米波雷达等异构传感器(架构必须预留) |
| 技术栈 | Go 为主,媒体层复用成熟开源组件 |
# 2. 架构总览
系统分为三个平面,接缝要尽可能少。
| 平面 | 组件 | 职责 |
| --- | --- | --- |
| 流控制面 | mediamtx + 自研 Go 编排服务 | path 生命周期、按需拉流、录制、对账 |
| 设备业务面 | 自研 Go 服务(单 exe)+ Postgres | 家庭/摄像头台账、凭据、在线状态、开通与停用 |
| 感知面 | 自研推理服务 | 抽帧、姿态模型、跌倒判定 |
## 2.1 控制面与数据面分离(核心决策)
这是解决 NAT 问题的关键:
- **控制面走隧道**:每户边缘盒子跑 WireGuard,中心服务通过隧道访问摄像头的 ONVIF 端口。ONVIF 控制流量极小,只在配置和探活时产生。
- **数据面走推流**:视频不走隧道。边缘盒子主动把流推到中心 mediamtx(RTSP announce / SRT / RTMP)。
### IP 规划
每户分配一个不冲突的内网段,摄像头地址确定化:
```
10.<户号高位>.<户号低位>.0/24
摄像头固定从 .10 起分配
```
这样设备地址可预测,不依赖自动发现,也便于对账。
# 3. 组件选型
| 组件 | 仓库 | 角色 | 说明 |
| --- | --- | --- | --- |
| mediamtx | bluenviron/mediamtx | 媒体路由 | MIT,Go,单二进制。协议互转、按需拉流、录制、鉴权回调、Prometheus |
| gortsplib | bluenviron/gortsplib | RTSP 库 | mediamtx 作者同款。用于自研 worker 拉流并做帧级丢弃 |
| ONVIF 库 | IOTechSystems/onvif 或 use-go/onvif | 设备控制 | 前者是 EdgeX device-onvif-camera 内部使用的增强版 |
| API 客户端 | 自行用 oapi-codegen 从 mediamtx OpenAPI 生成 | mediamtx 控制 | 见 3.1 |
| 边缘推流 | mediamtx 或 go2rtc | 边缘盒子 | 杂牌摄像头兼容性差时用 go2rtc 兜底 |
## 3.1 关于 mediamtx-sdk-go
zbiljic/mediamtx-sdk-go 是个人项目,用 ogen 从官方 OpenAPI 规范生成。**建议自行生成客户端**,避免依赖第三方更新节奏:
```bash
# 从 mediamtx 仓库取 apidocs/openapi.yaml
oapi-codegen -package mtxapi -generate types,client openapi.yaml > mtxapi/client.go
```
好处是版本跟随实际部署的 mediamtx,不会因上游停更而受阻。
# 4. 关键设计决策
## 4.1 mediamtx 只管流,不管设备
mediamtx 的数据模型里没有 camera 实体,只有 path(名字 + source URL + 配置)。它不知道 URL 背后是哪个品牌、是否同一台设备的两条码流、这户是否欠费。
必须自建设备业务面。三条硬约束:
1. **API 改的配置不落盘** — mediamtx 重启后通过 API 添加的 path 全部消失。设备台账必须在自己的数据库,服务启动时 replay。
2. **改配置会踢掉当前发布者** — 任何通过 API 的 path 配置变更都会触发 path reload,断开正在推流的 source,即使改动本身与它无关。因此改配置前必须先判断「真的变了吗」,没变绝对不调 API。
3. **没有 enable/disable 开关** — 只能删除 path,不能停用。
## 4.2 按需拉流(sourceOnDemand)
```yaml
pathDefaults:
sourceOnDemand: yes
sourceOnDemandStartTimeout: 10s
sourceOnDemandCloseAfter: 30s
```
语义:只有 reader 连上来时才去连摄像头,没人看就断开。对本系统等价于「AI worker 订阅 = 开始分析,取消订阅 = 自动停止拉流」,是最省带宽和算力的开关方式。
## 4.3 停用某路摄像头的四种粒度
按副作用从轻到重:
| 需求 | 做法 | 效果 |
| --- | --- | --- |
| 暂停分析,流保留 | worker 取消订阅 | 配合 sourceOnDemand 自动停止拉流,随时恢复 |
| 立即断开,允许重连 | `POST /v3/rtspsessions/kick/{id}`(RTMP/SRT/WebRTC 各有对应接口) | 断一次,客户端会重连 |
| 永久拒绝 | 鉴权服务对该 path 返回 401 + kick | 推不上来,且无需改 mediamtx 配置 |
| 彻底移除 | `DELETE /v3/config/paths/delete/{name}` | path 消失,重启后靠自有库恢复 |
标准组合:先 kick 断开、再让鉴权拒绝 = 立刻生效且不会重连。
## 4.4 鉴权用 HTTP 回调
```yaml
authMethod: http
authHTTPAddress: http://control-plane:8080/mtx-auth
paths:
"~^home-.*$":
source: publisher
```
每次边缘端推流,mediamtx POST 到自有鉴权服务,带 user/password/path/IP/action。返回 200 或 401 即控制了「哪些摄像头允许接入」。注销、欠费、隐私模式全部在自有业务库判断,mediamtx 侧零配置变更。
## 4.5 为雷达预留
未来接入毫米波雷达后,架构会发生一次反转:雷达 7x24 常开做一级触发,视频做二级确认。
- 算力:常态 GPU 占用接近零,只在事件时唤醒推理。250 路常态推理需 2-4 张卡,改为触发式后一张即可。
- 隐私:卫生间、卧室只放雷达不放摄像头;客厅摄像头平时不解码不存储。这是养老场景能否落地的分水岭。
- 误报:纯视觉对遮挡/逆光/坐下误报高,纯雷达对快速坐下和弯腰误判,两级串联是行业标准做法。
当前阶段的设计要求:设备表结构不要写死为「摄像头表」,预留 device_type;编排服务的触发入口要独立成一个 handler,将来接雷达告警时不用改动流控制逻辑。
# 5. 数据模型
```sql
-- 家庭
CREATE TABLE households (
id BIGSERIAL PRIMARY KEY,
code TEXT UNIQUE NOT NULL,
subnet CIDR NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 设备(不要命名为 cameras,为雷达预留)
CREATE TABLE devices (
id BIGSERIAL PRIMARY KEY,
household_id BIGINT NOT NULL REFERENCES households(id),
device_type TEXT NOT NULL,
serial TEXT UNIQUE NOT NULL,
vendor TEXT,
model TEXT,
onvif_addr TEXT,
onvif_user TEXT,
onvif_secret TEXT,
state TEXT NOT NULL,
last_seen_at TIMESTAMPTZ
);
-- 流绑定
CREATE TABLE stream_bindings (
id BIGSERIAL PRIMARY KEY,
device_id BIGINT NOT NULL REFERENCES devices(id),
mtx_instance TEXT NOT NULL,
path_name TEXT NOT NULL,
rtsp_url TEXT,
profile_token TEXT,
enabled BOOLEAN NOT NULL DEFAULT true,
UNIQUE (mtx_instance, path_name)
);
-- 对账状态
CREATE TABLE sync_state (
binding_id BIGINT PRIMARY KEY REFERENCES stream_bindings(id),
desired_hash TEXT,
applied_hash TEXT,
last_error TEXT,
retry_after TIMESTAMPTZ
);
```
字段说明:device_type 取 camera / radar / button;state 取 pending_activation / active / disabled;onvif_secret 需加密存储;rtsp_url 存 ONVIF GetStreamUri 返回的子码流地址。
**设计要点**:以序列号而非 IP 作为设备身份。家庭 Wi-Fi 漫游或 DHCP 续租会导致 IP 变化,按序列号重定位是唯一可靠的做法。
# 6. 对账器(Reconciler)
系统最容易出竞态的地方,也是自研工作量的核心。
## 6.1 职责
数据库是唯一真相源,**mediamtx 和 Savant 都是被驱动的从属状态**。对账器持续把三者拉齐。
关键原则:**水平触发(level-triggered)而非边沿触发(edge-triggered)**。对账器不处理「事件」,只不断比对期望状态与实际状态并向期望收敛。这是处理部分失败的唯一可行方案(见 6.5)。
```go
func (r *Reconciler) Sync(ctx context.Context) error {
desired := r.db.EnabledBindings() // 真相源
actual := r.mtx.ListPaths(ctx) // 生成的 API 客户端
add, del, update := diff(desired, actual)
sem := semaphore.NewWeighted(8) // 限流,别打爆 API
for _, b := range add {
// 幂等:已存在则跳过,绝不盲目 add(会踢掉 publisher)
// 重试:指数退避,失败写回 sync_state.retry_after
}
for _, b := range update {
// 关键:先比对 desired_hash 与 applied_hash
// 没变就绝对不调 API
}
// del 同理
}
```
## 6.2 必须处理的边界情况
| 情况 | 处理 |
| --- | --- |
| mediamtx 重启 | path 数量骤降触发全量重建;也要有定时全量对账兜底 |
| 配置变更踢 publisher | 变更前比对哈希;确需变更时先标记为维护窗口 |
| API 批量失败 | 无事务。第 137 个失败时前 136 个已生效,必须幂等重试而非回滚 |
| 并发打满 | 信号量限流到 8 并发 |
| path 漂移 | 定时全量 diff,处理人工在 mediamtx 侧的手改 |
| 设备离线 vs 无人订阅 | 两者在 mediamtx 侧表现相同,靠自有探活区分 |
## 6.3 探活
独立 goroutine,对每个 active 设备定期做 ONVIF 轻量调用(如 GetSystemDateAndTime),指数退避,更新 devices.last_seen_at。**不要用 path 在线状态代替设备在线状态**。
## 6.4 双系统对账:mediamtx + Savant
引入 Savant(第 9 节)后,一个设备的完整开通需要在**两个独立系统**上各做一次操作,而两者之间**没有分布式事务**。
### 数据模型调整
第 5 节的 `sync_state` 拆成每系统一行:
```sql
DROP TABLE sync_state;
CREATE TABLE sync_state (
binding_id BIGINT NOT NULL REFERENCES stream_bindings(id),
system TEXT NOT NULL, -- 'mediamtx' | 'savant'
desired_hash TEXT,
applied_hash TEXT, -- NULL = 尚未生效
last_error TEXT,
attempt INT NOT NULL DEFAULT 0,
retry_after TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (binding_id, system)
);
-- 便于运维发现中间态
CREATE VIEW binding_health AS
SELECT b.id, b.path_name,
bool_and(s.desired_hash = s.applied_hash) AS converged,
max(s.attempt) AS max_attempt
FROM stream_bindings b JOIN sync_state s ON s.binding_id = b.id
WHERE b.enabled
GROUP BY b.id, b.path_name;
```
一个 binding 只有当**两行都收敛**时才算真正开通。`converged = false` 的就是中间态,必须在 Grafana 上可见。
### 操作顺序(不对称,必须遵守)
| 动作 | 顺序 | 理由 |
| --- | --- | --- |
| **开通** | mediamtx 先 → Savant 后 | 流必须先存在,Savant 才能挂上去;反之 Savant 会反复重试连不存在的源 |
| **停用** | Savant 先 → mediamtx 后 | 先停消费再断流;反之 Savant 会看到源断开并报错告警 |
建立和拆除是逆序的,这一点要写进代码注释,很容易被后来人改错。
### ⚠️ 关键耦合:Savant source = mediamtx reader
`sourceOnDemand: yes` 的语义是「有 reader 才拉流」。**Savant 挂上 source 就是一个 reader**,也就是说:
- 挂载 Savant source → mediamtx 立即开始拉流 → 产生带宽和连接开销
- 卸载 Savant source → 30s 后(`sourceOnDemandCloseAfter`)mediamtx 自动断流
因此:**「暂停分析」必须通过卸载 Savant source 实现,不能只在 Python 插件里 `return`**。后者会让 mediamtx 持续拉流、带宽白烧,而监控上看起来一切正常。
这也意味着第 4.3 节「停用粒度」表格里的第一行(暂停分析)具体实现就是卸载 Savant source。
## 6.5 部分失败:不回滚,只收敛
典型场景:mediamtx path 建好了,Savant source 挂载失败。
**错误做法**:回滚删掉 mediamtx path。回滚本身也会失败,那时候你需要回滚的回滚,无限递归。
**正确做法**:保留已成功的部分,把失败的那行 `sync_state` 标为未收敛 + 退避重试。下一轮对账会看到 mediamtx 已收敛(跳过)、Savant 未收敛(重试)。
代价:**会有一个无人消费的 mediamtx path 短暂存在**。因为 `sourceOnDemand` 的存在,这个 path 没有 reader 就不会拉流,**成本接近零**。这是 `sourceOnDemand` 给双系统对账带来的额外好处——中间态是安全的。
### 重试预算
| 参数 | 值 |
| --- | --- |
| 初始退避 | 5s |
| 退避倍率 | 2x,上限 5min |
| 告警阈值 | `attempt > 5` 且持续 > 15min → 告警 |
| 放弃阈值 | 不放弃。持续重试,靠告警引入人工 |
不设放弃阈值是故意的:对账器放弃了,设备就静默地不工作了,而这是养老监护场景不可接受的失败模式。
## 6.6 孤儿资源清理
对账不只是「把缺的补上」,还要「把多的删掉」。三类孤儿:
| 类型 | 成因 | 处理 |
| --- | --- | --- |
| mediamtx 有 path,DB 无 | 人工手改、旧版本残留 | 删除(但先日志,不静默) |
| Savant 有 source,DB 无 | 同上 | 卸载 |
| DB 有 binding,两边都无且长期未收敛 | 开通失败后无人处理 | 告警,不自动删 DB |
**清理动作必须有安全闸**:单轮对账删除量超过总量的 10%(如 25 路)时**停止并告警**,不执行。这道闸是防数据库连接异常或查询 bug 导致的「期望集合意外为空」——那会把 250 路全部拆掉。
## 6.7 完整循环
```go
func (r *Reconciler) Sync(ctx context.Context) error {
desired := r.db.EnabledBindings() // 真相源
if len(desired) == 0 && r.lastDesiredCount > 0 {
return ErrSuspiciousEmptyDesired // 6.6 安全闸
}
mtxActual, err1 := r.mtx.ListPaths(ctx) // 分片内所有实例
svActual, err2 := r.savant.ListSources(ctx)
if err1 != nil || err2 != nil {
return errors.Join(err1, err2) // 拿不到实际状态就不动作
}
plan := buildPlan(desired, mtxActual, svActual)
// plan 内部已按 6.4 的顺序约束排序:
// teardown: savant 先 → mtx 后
// setup: mtx 先 → savant 后
for _, step := range plan {
if !step.DueForRetry(now) { continue } // 退避窗口未到
if step.DesiredHash == step.AppliedHash { continue } // 没变,绝不调 API
sem.Acquire(ctx, 1) // 每系统独立限流
go func() {
defer sem.Release(1)
if err := step.Apply(ctx); err != nil {
r.db.MarkFailed(step, err) // 退避,不回滚
return
}
r.db.MarkApplied(step) // applied_hash = desired_hash
}()
}
}
```
**两个独立的限流预算**:mediamtx API 和 Savant API 各自 8 并发,不共享信号量。否则一个系统变慢会饿死另一个。
**循环周期**:正常 30s;有未收敛项时降到 5s;另有每 10min 一次的全量对账(含孤儿扫描)。
## 6.8 必须暴露的指标
| 指标 | 用途 |
| --- | --- |
| `reconciler_unconverged_bindings{system}` | 核心健康指标,持续 > 0 即异常 |
| `reconciler_apply_duration_seconds{system,action}` | 发现 API 变慢 |
| `reconciler_orphans_detected{type}` | 有人手改了生产系统 |
| `reconciler_safety_brake_triggered` | 安全闸触发,**应该直接呼叫**人 |
| `reconciler_loop_duration_seconds` | 循环超时 = 规模到顶,该分片了 |
# 7. mediamtx 配置要点
```yaml
api: yes
apiAddress: :9997
metrics: yes
metricsAddress: :9998
authMethod: http
authHTTPAddress: http://control-plane:8080/mtx-auth
pathDefaults:
sourceOnDemand: yes
sourceOnDemandCloseAfter: 30s
rtspTransport: tcp # 家庭网络下 UDP 丢包严重
record: no
recordDeleteAfter: 7d
runOnOnline: curl -s -X POST http://control-plane:8080/hooks/online?path=$MTX_PATH
runOnOffline: curl -s -X POST http://control-plane:8080/hooks/offline?path=$MTX_PATH
paths:
"~^home-(\\d+)-cam(\\d+)$":
source: publisher
```
用一条正则 path 兜底,不要写 250 条静态配置。
> 注意:v1.19.x 的钩子命名为 runOnAvailable / runOnUnavailable / runOnOnline / runOnOffline,与旧版 runOnReady / runOnNotReady 不同。部署前核对所用版本的配置参考。
# 8. 容量与分片
## 8.1 带宽
| 项 | 估算 |
| --- | --- |
| 250 路子码流 640x360 @ 512 kbps | 约 125 Mbps 常态入向 |
| 250 路主码流 | 1-1.5 Gbps(不可行,必须用子码流做检测) |
## 8.2 算力
- 抽帧到 5 fps:250 x 5 = 1250 帧/秒解码
- CPU 软解不可行,需 NVDEC 或边缘解码
- 用 gortsplib 在 Go 侧做帧级丢弃(只保留关键帧/按抽帧间隔),比起用 ffmpeg 子进程可省约 70% 解码开销
## 8.3 分片
- 单 mediamtx 实例建议承载 60-80 路,250 路分 3-4 个实例
- 分片键用家庭 ID,映射存在 stream_bindings.mtx_instance
- 不要单实例硬扛:进程挂掉影响面过大,且默认 writeQueueSize: 512 下 250 路的内存与 GC 压力不小
# 9. 感知面:Savant 推理流水线
> 本节取代第 2 节表格中「感知面 = 自研推理服务」的占位描述。
## 9.1 为什么用框架而非完全自研
开源社区没有「批量摄像头摔倒检测」的成品(算法是差异化,不会有人开源),但有成熟的**多路视频分析流水线框架**。算法是自己的,工程化用现成的。
选定 **Savant**(insight-platform/Savant),基于 NVIDIA DeepStream 的高层框架,声明式 YAML + Python 插件函数。三个决定性理由:
1. **故障隔离** — 原生 DeepStream 中数据流与 pipeline 紧耦合,任一源崩溃则整个 pipeline 崩溃,重启加载模型要数秒且期间丢数据。Savant 的 adapter 通过 ZeroMQ 与框架通信,adapter 挂掉不影响 pipeline。200 户家庭网络掉线是常态,**这是 250 路规模的生死线**。
2. **可配置降级策略** — 可设为实时模式(算力不足时主动丢数据)或高吞吐模式(保证处理全部数据)。本系统选**实时模式**,丢帧优于积压崩溃。
3. **Replay(按需与非线性处理)** — REST 控制的录制/回放/转推服务,直接对应阶段二的雷达触发式二级确认。
同一套代码可跑在 Jetson(Orin Nano/NX/AGX)和数据中心 GPU 上,与本方案的边缘/中心混合部署契合。
**代价**:必须 NVIDIA 硬件(Turing/Ampere/Ada/Hopper/Blackwell 或 Jetson),与 DeepStream 版本强绑定(需查对照表),学习曲线陡,无企业支持。
**备选**:Pipeless(Rust,不绑 NVIDIA,支持 ONNX Runtime/OpenVINO/TensorRT,HTTP 动态增删流)——若硬件选型变更或规模缩小时启用。
## 9.2 模型选型与许可警告
> ⚠️ **Ultralytics YOLO 是 AGPL-3.0**。YOLOv8/v11 系列仓库默认对所有用户采用 AGPL-3.0;要集成进商业产品和服务而不遵守其开源要求,必须购买 Enterprise License。本项目是面向付费用户的网络服务,落在 AGPL 第 13 条覆盖范围内,严格执行则需开源整个服务端。**必须在 M3 前做出决定:买授权,或换模型。**
推荐替代:**RTMPose / RTMO**(MMPose 项目,Apache-2.0)
- RTMPose-m:COCO 75.8% AP,GTX 1660 Ti + TensorRT 下 430+ FPS,Intel i7-11700 CPU + ONNXRuntime 下 90+ FPS
- 支持 ONNXRuntime、TensorRT、ncnn、OpenVINO、RKNN 多种部署后端(通过 MMDeploy)
- 关键点定义同为 COCO 17 点,**现有判定算法基本不用改**
- 430 FPS 的单卡吞吐意味着第 8 节估算的 1250 fps 需求可用 2-3 张中端卡满足
## 9.3 Pipeline 架构
```
边缘推流 → mediamtx(250 路汇聚,按需拉流)
↓ RTSP
Savant RTSP source adapter(多路复用 + 故障隔离)
↓ ZeroMQ / Protobuf
Buffer Adapter(磁盘持久化缓冲,抗突发)
↓
Savant module
├─ RTMPose / TensorRT(姿态关键点)
└─ Python 插件:时序窗口 + 跌倒判定(**自研部分**)
↓ ZeroMQ / Protobuf
Go 控制面(告警、去重、防抖、通知)
```
需要自己写的只有中间那个 Python 插件,前后两段是现成的。
## 9.4 与 Go 控制面的对接契约
一条 pipeline 是自给自足的服务,通过高性能流式 API 与外界通信,视频和元数据统一使用 Protocol Buffers 协议。
| 需求 | 通道 | 语言无关 |
| --- | --- | --- |
| 接收检测结果 | 直接订阅 sink 的 ZeroMQ 端点,解 protobuf | ✅ |
| 动态增删流 | 动态挂载/卸载 source 与 sink,无需重载 pipeline | ✅ |
| 回放/回捞 | Replay 服务的 REST 接口 | ✅ |
| 程序化灌数据/调试 | Client SDK(**仅 Python**,集成 OpenTelemetry) | ❌ |
**设计约束:优先走 ZeroMQ 和 REST 两条语言无关通道,Go 服务不得依赖 Python 中间层。** Client SDK 仅用于算法调试和测试。
因为两边都是 API 驱动,**第 6 节的对账器可以同时驱动 mediamtx 和 Savant**:开通 = 建 path + 挂 source;停用 = kick + 卸 source。逻辑写在同一个 reconciler 里。
## 9.5 关键配置决策
**必须开启 Buffer Adapter**。它实现了 adapter 与 module 之间的持久化事务性磁盘缓冲,用于支撑资源消耗不可预测的 pipeline、抗住流量突发,并把自身条目数和大小导出到 Prometheus。
> 本场景天然有突发:夜里安静无事,早上起床时段几十户同时有活动。**一开始就加上,别等出问题再补。**
**AO-RTSP Sink 仅用于调试**。它把处理后、带元数据标注的视频通过 RTSP 和 LL-HLS 播出去,用来直观确认某一路的处理效果。
> ⚠️ **硬件限制**:GeForce 系列显卡同时硬件编码的流数上限为 **3**。若给多路挂 AO-RTSP 可视化,消费级卡直接卡在 3 路,需 Quadro/Tesla/L4 类专业卡。**不要做成常态功能。**
**开发期开 Development Server**:支持改代码动态重载、不重启 pipeline,还能从 IDE 远程开发跑在 Jetson 或服务器上的代码。原生 DeepStream 改一行要重启加载模型等几十秒,这个工具能省大量时间。
## 9.6 可观测
Savant 没有管理界面(与 mediamtx 同构),但可观测完整:
- **Prometheus + Grafana**:pipeline 和 buffer adapter 导出运行指标,开发者可声明自定义指标与系统指标一起导出。**把每路的人体检出率、跌倒候选数、推理延迟声明成自定义指标** → Grafana 上就是现成运营看板
- **OpenTelemetry → Jaeger**:排查单帧处理延迟
- mediamtx 也出 Prometheus 指标,**一个 Grafana 全收**,前期不用写一行前端代码
## 9.7 算法分层
| 层 | 内容 | 位置 |
| --- | --- | --- |
| 姿态提取 | RTMPose,COCO 17 关键点 | Savant 推理单元 |
| 单帧启发式 | 包围框宽高比、重心高度、躯干角度 | Python 插件(现有算法) |
| 时序判定 | 30 帧滑窗,静态位置 + 动态速度特征 | Python 插件(待建) |
| 事件后处理 | 去重、防抖、多源融合 | Go 控制面 |
参考实现:
- `GajuuzZ/Human-Falling-Detect-Tracks` — ST-GCN 对每个人轨迹的每 30 帧预测动作,支持站立/行走/坐下/躺下/起身/坐下动作/跌倒共 7 类。**七分类而非二分类是降误报的关键**
- `kelsonbatista/fall-detection-ai-project` — YOLO-pose 逐帧抽 128 维特征(静态位置 + 动态速度),30 帧窗口喂 LSTM。注意 GPL-3.0
**负面经验**:`Y-B-Class-Projects/Human-Fall-Detection` 用约 500 张网络图片训 LSTM 完全学不会,最后退回 if-else 规则。**小数据量下时序模型学不动,先解决数据再上模型。**
## 9.8 本节风险
| 风险 | 缓解 |
| --- | --- |
| Ultralytics AGPL-3.0 污染商业代码 | M3 前决策:买授权或换 RTMPose |
| 公开数据集与真实场景脱节(Le2i/UR Fall 均为演员摆拍的表演性跌倒,真实老人常为缓慢滑落、部分遮挡) | M3 首要产出是**真实误报案例库**,不是模型指标 |
| Savant 学习曲线陡 | M1 阶段先用 5 路跑通一条 pipeline,测出单路真实吞吐再反推卡数 |
| 绑定 NVIDIA + DeepStream 版本 | 预留 Pipeless 作为备选;推理代码与框架解耦 |
| 常态全量推理成本 | **Savant 不降低算力需求**,只解决「怎么高效跑 250 路」。雷达一级触发仍是唯一的数量级优化,与 Replay 配套使用 |
**无雷达时的过渡方案**:参考 Frigate 的「先用运动侦测过滤、有变化才跑推理」思路,与雷达触发是同一逻辑。
# 10. 预警信息管理系统
> 接住第 9 节的输出:检测出来之后怎么办。**本节描述的是产品本身,不是辅助设施。**
## 10.1 两类告警必须彻底分开
| | 运维告警 | 业务预警 |
| --- | --- | --- |
| 触发源 | 设备离线、binding 未收敛、GPU 过载、缓冲积压 | 跌倒判定 |
| 收件人 | 运维团队 | 家属、护理员、呼叫中心 |
| 延迟容忍 | 分钟级 | **秒级** |
| 漏报后果 | 服务降级 | 人身伤害 + 法律责任 |
| 方案 | Prometheus + Alertmanager(现成) | **自研状态机 + 复用投递通道** |
运维那侧接 Alertmanager 就完事,一两天工作量,本节不再展开。**两套系统不得共用投递通道和值班配置**,避免运维噪声淡化业务预警。
## 10.2 告警生命周期状态机
```
[触发] ─→ [投递中] ─→ [已送达] ─→ [已确认 ack] ─→ [处置中] ─→ [关闭]
│ │ │ │
│ │ └─超时未 ack─→ [升级] ─→ 下一级投递 │
│ └─投递失败─→ [升级] │
└─防抖命中─→ [抑制] [标记误报] ←─┘
```
**没有 ack 环节的告警系统等于没有。** 发出去和被人看到是两回事,整个设计围绕「确认或升级」这一对概念展开。
## 10.3 分级升级链
```
家属 App 推送
│ 30s 无 ack
▼
家属短信 + 语音呼叫
│ 60s 无 ack
▼
备用联系人(邻居/物业/护理员)
│ 90s 无 ack
▼
呼叫中心人工介入
```
每户的联系人链、时段策略(白天找子女、夜间找同住者)、优先级都要可配。升级超时值需支持按户覆盖——独居老人和有同住家属的超时策略应该不同。
## 10.4 数据模型
```sql
CREATE TABLE alerts (
id BIGSERIAL PRIMARY KEY,
household_id BIGINT NOT NULL REFERENCES households(id),
device_id BIGINT REFERENCES devices(id),
kind TEXT NOT NULL, -- 'fall' | 'no_motion' | 'device_offline_business'
confidence REAL,
triggered_at TIMESTAMPTZ NOT NULL,
state TEXT NOT NULL, -- triggered/delivering/delivered/acked/handling/closed/suppressed
acked_by BIGINT,
acked_at TIMESTAMPTZ,
closed_at TIMESTAMPTZ,
outcome TEXT, -- true_positive / false_positive / unknown
evidence_uri TEXT, -- 回捞的视频片段 + 姿态序列
escalation_level INT NOT NULL DEFAULT 0
);
CREATE TABLE contact_chains (
id BIGSERIAL PRIMARY KEY,
household_id BIGINT NOT NULL REFERENCES households(id),
level INT NOT NULL, -- 0,1,2,3
contact_name TEXT NOT NULL,
channels JSONB NOT NULL, -- [{type:'push',token:...},{type:'sms',no:...}]
active_hours TEXT, -- '08:00-22:00' 或 NULL 表示全天
timeout_sec INT NOT NULL DEFAULT 30,
UNIQUE (household_id, level)
);
CREATE TABLE alert_deliveries (
id BIGSERIAL PRIMARY KEY,
alert_id BIGINT NOT NULL REFERENCES alerts(id),
level INT NOT NULL,
channel TEXT NOT NULL, -- push / sms / voice / webhook
provider TEXT NOT NULL, -- 便于多供应商冗余
sent_at TIMESTAMPTZ,
receipt_at TIMESTAMPTZ, -- 回执:NULL = 未确认送达
failed_reason TEXT
);
CREATE INDEX ON alerts (state) WHERE state NOT IN ('closed','suppressed');
```
关键设计:**投递是独立实体而非 alerts 的字段**。一次告警会产生多条投递记录(多级×多通道),每条各自有回执状态。
## 10.5 投递回执:三个不同的事实
| 事实 | 含义 | 字段 |
| --- | --- | --- |
| 发出去了 | 调用了上游 API | `sent_at` |
| 送达了 | 上游回执确认到达终端 | `receipt_at` |
| 被看到了 | 用户主动 ack | `alerts.acked_at` |
**没有回执就当失败并升级**,不要乐观假设。推送可能被系统杀掉、短信可能延迟、电话可能占线。
## 10.6 通道选型与冗余
**必须至少两条独立路径,且电话通道要能绕过互联网。** 推送依赖 APNs/FCM,短信依赖运营商,任何单一通道都可能整体故障。
| 层 | 选型 | 说明 |
| --- | --- | --- |
| 状态机、升级链、ack、审计 | **自研**(Go) | 业务定制度高,通用工具都要大改 |
| 多通道投递 | Novu / Apprise / ntfy,或直接对接云厂商 | 只解决「怎么发」 |
| 短信 + 语音呼叫 | 云厂商(双供应商) | `provider` 字段就是为切换准备的 |
> ⚠️ **不要用 Grafana OnCall**。OSS 版已于 2026-03-24 归档,仓库只读,仅修 CVSS 7.0+ 漏洞。更关键的是移动推送、短信和电话在归档当日停止工作(那些通道依赖 Grafana Cloud 中转)。调度和升级链还能跑,但**送不出去的告警系统等于没有**。
若阶段二引入 ThingsBoard(接雷达时),其告警子系统 + 规则链可覆盖状态机的一大半,届时自研部分可收缩到只做升级链和电话对接。
## 10.7 去重与防抖
| 规则 | 参数 | 理由 |
| --- | --- | --- |
| 同设备冷却期 | 5min 内不重复告警 | 避免同一事件多次触发 |
| 同家庭聚合 | 多设备同时触发合并为一条 | 客厅两个机位拍到同一次跌倒 |
| 已处置抑制 | 已 ack 的告警期间不再新建 | 护理员已到现场 |
| 维护窗口 | 住户主动静默(如家庭聚会) | **需限时且到期自动恢复** |
静默功能必须强制限时(建议最长 4h)并在到期前提醒。永久静默是这类系统最常见的事故成因。
## 10.8 误报反馈闭环(产品飞轮)
家属点「误报」这个动作,要自动把 `evidence_uri` 对应的视频片段和姿态序列推进标注队列。
这条链路直接解决 9.8 节的数据集问题:公开数据集全是演员摆拍,而**真实误报案例是你最稀缺的训练数据**。没有这个闭环,模型不会随运营时间变好。
同理,`outcome = true_positive` 的样本是正例金矿。**从 M3 第一天就要存,不要等到想训模型时才发现没数据。**
## 10.9 审计与合规
每条告警的完整生命周期需**不可篡改地**留存:
- 什么时间触发、基于什么证据
- 向谁发过、走的什么通道、是否送达
- 谁在什么时间确认、处置结果
- 升级到第几级、每级耗时
一旦出事,「系统有没有报、几点报的、谁接的、多久响应」是法律问题而非技术问题。建议 `alerts` 和 `alert_deliveries` **只追加不修改**(状态变更写事件表),并独立备份。
另需确认的合规项(开工前法务确认):视频证据的保存期限、家属查看权限范围、被监护者本人的知情同意。
## 10.10 SLA 目标(待 M3 实测校准)
| 指标 | 目标 |
| --- | --- |
| 跌倒发生 → 首次投递 | < 10s |
| 首次投递 → 送达回执 | < 5s(推送)/ < 30s(短信) |
| 全链路无人响应 → 呼叫中心 | < 3min |
| 告警丢失率 | 0(持久化后才确认触发) |
最后一行是硬约束:**告警先落库再投递**。进程崩溃后重启,未完成的升级链要能从数据库恢复继续——这跟第 6 节对账器的「水平触发」是同一个思路,启动时扫描所有未终态告警并接续处理。
# 11. EdgeX 升级路径(可选,阶段二)
当前阶段不引入 EdgeX。理由:它白送的能力(设备注册表、探活、命令抽象)约值一两周开发量,但代价是长期多背 4 个服务、跨服务追日志,且它假设设备在同一局域网、与 NAT 场景冲突。
## 何时值得切换
当确定要接入毫米波雷达、门磁、紧急按钮等异构设备时。EdgeX 的 device-mqtt / device-modbus / device-snmp 能复用同一套设备模型,这是它唯一压倒性的优势。
## 切换成本低的原因
ONVIF 调用那部分代码两边几乎一样(EdgeX 的 device-onvif-camera 内部就是用 IOTechSystems/onvif),对账器也不用改。
## 若切换,最小服务集
```
core-metadata 设备注册表
core-command 命令下发入口
device-onvif-camera ONVIF <-> REST
redis 消息总线
(consul 可用 -cp=none 省掉)
```
砍掉 core-data、app-service-*、security 全套。注意 discovery 在本场景基本不可用:netscan 对 200 个 /24 段是 5 万次探测,不现实;应使用预定义 deviceList 或 REST 批量录入,但保留 EnableStatusCheck / CheckStatusInterval 做探活。
另注意:EdgeX 3.x 起 API 路径是 /api/v3,网上大量 2.x 文档写的是 /api/v2。
# 12. 风险清单
| 风险 | 影响 | 缓解 |
| --- | --- | --- |
| 摄像头 ONVIF 实现差异 | 部分型号无法接入 | 采购前用真机验证,见演示方案文档 |
| 摄像头时钟漂移 | 事件时间戳全乱 | 开通时调 SetSystemDateAndTime,定期校时 |
| NAT 隧道不稳 | 控制面失联 | 控制面失联不影响视频推流(已解耦);告警但不阻断 |
| path reload 踢流 | 用户看到卡顿 | 配置变更前比对哈希;变更集中在维护窗口 |
| H.265 兼容性 | 浏览器无法预览 | 摄像头统一设为 H.264 |
| 隐私合规 | 项目无法落地 | 尽早引入雷达做一级触发,减少视频常态采集 |
# 13. 里程碑
| 阶段 | 内容 | 出口标准 |
| --- | --- | --- |
| M0 | 摄像头兼容性验证 | 3-5 个候选型号跑通 GetProfiles / GetStreamUri / SetSystemDateAndTime |
| M1 | 单 exe 骨架 + mediamtx | 5 路摄像头自动建 path、探活、断线重建 |
| M2 | 对账器 + Postgres + 隧道 | 10 户试点,含开通/停用全流程 |
| M3 | 抽帧 worker + 推理接入 | 端到端跌倒告警跑通,拿到误报率基线 |
| M4 | 分片 + 灰度扩容 | 250 路稳定运行 |
| M5(阶段二) | 雷达接入 + 两级判定 | 误报率显著下降,视频常态采集下降 |