# 🏗️ 跌倒检测视频接入系统 — 线上生产开发方案 > 来源: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(阶段二) | 雷达接入 + 两级判定 | 误报率显著下降,视频常态采集下降 |