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

34 KiB
Raw Permalink Blame History

🏗️ 跌倒检测视频接入系统 — 线上生产开发方案

来源: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 规范生成。建议自行生成客户端,避免依赖第三方更新节奏:

# 从 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)

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 回调

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. 数据模型

-- 家庭
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)。

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 拆成每系统一行:

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 完整循环

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 配置要点

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 数据模型

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(阶段二) 雷达接入 + 两级判定 误报率显著下降,视频常态采集下降