ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

YOLOv8姿态检测与关键点规则时序平滑的跌倒识别方案

YOLOv8姿态检测与关键点规则时序平滑的跌倒识别方案

做跌倒识别这类项目,我在几个不同的看护场景里被反复问过同一个问题:能不能不接一堆传感器,就用一个普通摄像头,把“坐着、站着、摔倒”这三种状态分出来。真正落地时你会发现,难点从来不是“能不能检测到人”,而是“怎么把一个人的姿态稳定地映射成一个语义标签”,并且这个标签在光照变化、遮挡、多人交叉、摄像头角度刁钻的情况下还不能乱跳。这套方案我最终定型成YOLOv8 姿态检测 + 关键点特征工程 + 规则判决 + 时序平滑的组合,纯视觉、单模型、边缘设备可跑,源代码结构也很轻,改起来不费劲。

这篇文章我打算把整个推理评估链路拆开讲透:模型为什么选 YOLOv8-pose 而不是端到端分类、17 个关键点怎么变成能判决的数字、坐站跌三态的具体阈值是怎么定出来的、现场标定时该动哪几个参数、以及我踩过的那些“坐姿被判成跌倒”“蹲下就被报警”的坑。适合已经会跑 YOLO 推理、想把它用姿态任务上的朋友,也适合做看护、健身、工位监测这类场景、需要一个能快速出效果的基线方案的人。代码我会给核心片段,思路比代码重要,思路对了换哪个姿态模型都能复用。

1. 方案整体设计与选型思路

1.1 为什么不用端到端的行为分类模型

一开始我也试过端到端路线:把人体框裁出来,送进一个动作识别网络,直接输出 sit / stand / fall 三个类别。跑 demo 很漂亮,一上真实场景就散架。原因很直白——端到端分类模型学的是“图像纹理和整体外观”,它对训练集里的拍摄角度、衣着、光照有很强的记忆性,换个俯角、换把椅子、换个人穿深色衣服,置信度分布就变了。更麻烦的是它不可解释,误报了你没法知道是哪里出的问题,只能继续加数据、加标注、重新训练,迭代成本极高。

关键点方案的好处是把“感知”和“判决”彻底解耦。YOLOv8-pose 只负责一件事:把人体的 17 个关节坐标找出来。至于这个人是坐着还是摔了,交给一层薄薄的几何规则去算。这一层是可以读、可以改、可以针对单个场景微调的。现场发现“蹲下被误报成跌倒”,我不需要重新标几千张图,只要在规则里加一条膝角约束就能压下去。这种可调试性,在真实交付里比多两个点的准确率值钱得多。

还有个体量和延迟的问题。姿态模型本身很小,yolov8n-pose在普通 CPU 上单帧几十毫秒,导出 ONNX 之后边缘盒子上跑 15 到 25 FPS 很轻松。而动作识别网络通常要吃 16 到 32 帧的时序片段,显存和算力门槛直接上一个台阶。看护场景往往是一台设备要盯好几个房间,这种成本差异会被放大。

1.2 YOLOv8-pose 到底吐出了什么

很多人卡在这一步:模型跑通了,results打印出来一堆数字,不知道哪个是哪个。我把它说清楚。YOLOv8-pose 的输出张量形状是(1, 56, 8400),其中 8400 是候选框数量(三个尺度特征图拼起来的),56 这个维度是这样拆的:前 4 个是人框的cx, cy, w, h,第 5 个是框的置信度,剩下 51 个是 17 个关键点,每个关键点 3 个值,依次是x, y, score。

关键点的顺序遵循 COCO 定义,这个顺序必须记牢,后面所有特征计算都依赖它:

索引部位索引部位索引部位
0鼻子6右肩12右髋
1左眼7左肘13左膝
2右眼8右肘14右膝
3左耳9左腕15左踝
4右耳10右腕16右踝
5左肩11左髋

用 Ultralytics 的封装时,取results[0].keypoints.xy拿到的是像素坐标(N, 17, 2),results[0].keypoints.conf拿到的是每个点的置信度(N, 17)。注意一个细节:xy里即使某个点置信度接近 0,坐标依然会有一个值(通常是框内的插值位置),所以绝对不能用坐标是否有值来判断点是否可靠,必须自己拿conf去过滤。这个坑我见过不止一个人踩,直接导致遮挡场景下疯狂误报。

另外要注意keypoints.xy的坐标系是原图像素坐标,不是归一化的。如果你的输入做了 letterbox 缩放,Ultralytics 内部会帮你映射回原图,一般不用自己处理;但如果你是自己拿 ONNX 做前处理,就一定要把坐标反算回去,否则算出来的角度全是错的。

1.3 整条流水线的分层设计

我把整条链路切成五层,每层职责单一,出问题好定位:

第一层,检测与关键点提取。YOLOv8-pose 出人框和 17 点,按conf阈值过滤低质量框。这一层的输出是“原始感知结果”。

第二层,跟踪绑定。用内置的跟踪器(model.track,默认 ByteTrack)给每个人分配一个稳定 ID。没有 ID 的话,时序平滑根本无从谈起——你不知道这一帧的 3 号人和上一帧的 3 号人是不是同一个。

第三层,特征工程。把 17 个点转换成人体的比例、角度、朝向等几何量。这一层是整套方案的核心,转换得好,判决就简单;转换得糙,后面怎么调阈值都别扭。

第四层,判决。用特征去判断状态,输出一个带分数的候选标签。

第五层,时序平滑与状态机。单帧结论不算数,要连续多帧一致才确认状态切换,跌倒还要加“不可自恢复”约束。

这个分层的好处是:你可以单独替换任何一层。比如换更强的姿态模型(RTMPose、MediaPipe),只要保证输出 17 点格式一致,上层完全不用改;或者把规则判决换成一个小型分类器(GBDT、轻量 MLP),输入还是那些特征,迁移成本很低。

2. 关键点特征工程:把 17 个坐标变成可判决的信号

2.1 坐标系归一化:摆脱像素和分辨率的束缚

直接拿像素坐标算角度是可行的,但只要涉及“距离”“高度”这类量,就必须归一化,否则换个分辨率、换个人高矮胖瘦,阈值全得重调。我用的归一化基准是人体自身的尺度,具体是两个量:

一是body_h,取人体框的高度,或者更稳一点,取“鼻子到双脚踝的垂直距离”。用框高在人体被截断时(比如半个身子出画)会严重失真,所以我更倾向于用关键点跨度:body_span = max(y) - min(y)(在所有高置信度点上取),这个值对截断有一定鲁棒性,但截断严重时也会失效,所以还要加一个可用性检查。

二是torso_len,取“肩中点 → 髋中点”的欧氏距离。这个量代表躯干长度,是个很稳定的个人尺度基准,坐姿站立都不怎么变,特别适合用来做距离归一化。

有了这两个基准,所有距离类特征都除以它们,变成无量纲比例。这样一个人离摄像头远近、图像分辨率高低,都不影响判决。角度类特征本身无量纲,直接算就行。

提示:如果你要在一个画面里同时监控多个人,别用画面高度做归一化基准,那样远近的人尺度不一致,必须用人体自身尺度。

2.2 五个真正有用的核心特征

我从十几个候选特征里筛到最后留了五个,这五个覆盖了绝大多数判决需求,算力也低。

特征一:躯干倾角torso_angle。肩中点指向髋中点的向量,与竖直向下方向的夹角,单位度。站立时接近 0 到 20 度,坐着可能 0 到 40 度(取决于靠背角度),摔倒时通常大于 55 度甚至接近 90 度。这是区分“直立类”和“躺倒类”的主特征。

特征二:头部相对髋部的高度比head_hip_dy。(hip_mid_y - nose_y) / torso_len。站着时这个值大概 1.3 到 1.8(因为头在髋上方,像素 y 轴向下,所以 hip_y 减 nose_y 是正的),坐着时大概 0.8 到 1.3,摔倒时可能接近 0 甚至变成负数。这个特征比单纯用框高稳,因为它只依赖两个点,遮挡时还能靠鼻子或者肩膀推算。

特征三:身体包围盒宽高比body_aspect。(x_max - x_min) / (y_max - y_min)。站姿通常在 0.25 到 0.5,坐姿 0.5 到 0.9,躺倒时大于 1.0。这是最直观的一个特征,但单独用很危险——蹲下、弯腰捡东西、盘腿坐都会让它接近 1,所以它只能作为辅助投票项。

特征四:膝角knee_angle。髋-膝-踝三点构成的夹角。直立时接近 180 度,正常坐姿大概 80 到 120 度,深蹲大概 40 到 70 度,躺平伸展时接近 180 度。注意躺平和站立膝角都可能接近 180,所以这个特征必须和躯干角联合使用,不能单独判。

特征五:髋部相对高度hip_height_ratio。(img_h - hip_mid_y) / img_h(注意方向,让数值越大表示越高)。这个特征强依赖摄像头安装高度和角度,不能跨设备通用,但同一设备下它是判断“这个人是不是降到地面了”最直接的信号。我一般把它做成一个可选的加分项,在固定机位的场景里非常好用。

这五个特征之间是有冗余的,冗余不是坏事。判决时用多特征投票,任何单一特征因为遮挡或角度失效,其他特征还能兜底。这就是规则方案比端到端稳的根本原因——它有冗余的“证据链”。

2.3 特征计算的向量化实现

特征计算这块我写得比较克制,尽量向量化,避免逐人循环,多目标场景下性能差别很明显。

import numpy as np def safe_mid(a, b, ca, cb, conf_th=0.35): """两个点求中点,按置信度加权;都不可信时返回 None""" if ca < conf_th and cb < conf_th: return None if ca < conf_th: return b.astype(np.float32) if cb < conf_th: return a.astype(np.float32) w = np.array([ca, cb], dtype=np.float32) w = w / w.sum() return (a * w[0] + b * w[1]).astype(np.float32) def angle_between(v1, v2): """两个向量的夹角,单位度,输入需为非零向量""" n1 = np.linalg.norm(v1) n2 = np.linalg.norm(v2) if n1 < 1e-6 or n2 < 1e-6: return None cos = float(np.dot(v1, v2) / (n1 * n2)) cos = max(-1.0, min(1.0, cos)) return float(np.degrees(np.arccos(cos))) def extract_features(kxy, kconf, img_h, img_w, conf_th=0.35): """kxy: (17,2) 像素坐标, kconf: (17,) 置信度""" L_SH, R_SH = 5, 6 L_HIP, R_HIP = 11, 12 L_KNEE, R_KNEE = 13, 14 L_ANK, R_ANK = 15, 16 NOSE = 0 sh_mid = safe_mid(kxy[L_SH], kxy[R_SH], kconf[L_SH], kconf[R_SH], conf_th) hip_mid = safe_mid(kxy[L_HIP], kxy[R_HIP], kconf[L_HIP], kconf[R_HIP], conf_th) if sh_mid is None or hip_mid is None: return None # 肩髋缺失,本帧不可判 torso_vec = hip_mid - sh_mid torso_len = float(np.linalg.norm(torso_vec)) if torso_len < 8.0: # 太小说明检测框异常或人极远 return None # 特征一:躯干与竖直方向夹角 vertical = np.array([0.0, 1.0], dtype=np.float32) torso_angle = angle_between(torso_vec, vertical) if torso_angle is None: return None # 特征二:头髋高度差(以躯干长为单位) head_hip_dy = None if kconf[NOSE] >= conf_th: head_hip_dy = float((hip_mid[1] - kxy[NOSE][1]) / torso_len) # 特征四:膝角(取左右可信侧里角度更大的那个,代表支撑腿) knee_cands = [] for hip_i, knee_i, ank_i in ((L_HIP, L_KNEE, L_ANK), (R_HIP, R_KNEE, R_ANK)): if min(kconf[hip_i], kconf[knee_i], kconf[ank_i]) >= conf_th: a = angle_between(kxy[hip_i] - kxy[knee_i], kxy[ank_i] - kxy[knee_i]) if a is not None: knee_cands.append(a) knee_angle = max(knee_cands) if knee_cands else None # 特征五:髋部相对高度 hip_height_ratio = float((img_h - hip_mid[1]) / img_h) # 特征三:身体宽高比(只用可信点) valid = kxy[kconf >= conf_th] body_aspect = None if len(valid) >= 4: w = float(valid[:, 0].max() - valid[:, 0].min()) h = float(valid[:, 1].max() - valid[:, 1].min()) if h > 1e-3: body_aspect = w / h return { "torso_angle": torso_angle, "head_hip_dy": head_hip_dy, "knee_angle": knee_angle, "body_aspect": body_aspect, "hip_height_ratio": hip_height_ratio, "torso_len": torso_len, "sh_mid": sh_mid, "hip_mid": hip_mid, }

这段代码有两个设计意图值得说明。第一,safe_mid用置信度加权而不是简单平均,是因为实际场景里经常一侧肩膀被遮挡(侧身、走到画面边缘),这时用加权中点能显著降低抖动。第二,膝角取左右两侧中较大的那个,是因为支撑腿的膝角更接近伸直状态、更能反映站立的意图,而被遮挡或弯曲的腿会引入噪声。

2.4 置信度处理与不可判帧的代价

置信度阈值conf_th = 0.35是我实测下来比较通用的值,但它在不同模型尺寸上要微调:yolov8n-pose精度低一些,可以降到 0.3;yolov8m-pose或l-pose精度高,可以提到 0.45 甚至 0.5,这样能过滤掉更多背景误检。

这里要强调一个容易被忽略的工程原则:允许输出“不可判”,比强行输出一个错判要好得多。当肩或髋的关键点缺失、torso_len小到不合理、或者有效点数少于 4 个时,我直接返回None,让这一帧不参与判决。状态机看到None就保持上一状态不更新,而不是把"未知"当成"站立"或者"跌倒"。

我在一个项目里吃过反面的教训:当初为了“每一帧都有输出”,把所有不可判的帧默认填成“站立”,结果在夜间低照度下模型经常找不到肩髋,系统疯狂地在“跌倒 → 站立”之间抖动,值班人员被无效报警折腾得直接关掉了提示。后来改成保留上一有效状态,误报率立刻降下来的同时,真实报警一个没漏。

3. 三态判决逻辑:坐、站立、跌倒怎么定阈值

3.1 站立判定的组合条件

站立是我认为最容易判的一类,因为它有强几何约束:躯干接近竖直,髋在膝上方,膝在踝上方。

我用的规则是三个条件同时满足:

  • torso_angle < 32°:躯干基本竖直。为什么不是 20°?因为真实场景里人站着会前倾看手机、弯腰喝水、伸手拿东西,20° 太严,会把这些都判成非站立。32° 能覆盖大部分自然站姿的倾斜,同时把明确的坐姿(靠背椅通常 30° 到 45°)挡在边界之外。
  • head_hip_dy > 1.0:头明显在髋上方。这个条件的作用是拦住“半躺但躯干角不大”的情况,比如躺椅调得很陡的时候。
  • knee_angle > 150°(如果可算):支撑腿基本伸直。这个条件是可选加分项,蹲下时膝角会掉到 100° 以下,直接排除。

三个条件里,躯干角和头髋高度差是必选,膝角是可选的第四票。我给它们不是等权的:躯干角权重 0.5,头髋高度比 0.3,膝角 0.2。加起来过 0.7 就判站立。加权的好处是某个特征处在模糊区间时,其他特征能拉一把。

有一个容易忽略的细节:站姿的判定必须考虑人的朝向。人背对摄像头时,鼻子不可见(kconf[0]会很低),head_hip_dy直接失效。这时候我用耳或眼的中点替代鼻子位置,如果这些也都不行,就退化成只用躯干角加膝角的两票制,权重重新归一化。这个退化逻辑一定要写,否则背身站立的人会一直判不出来。

3.2 坐姿判定:最容易被做成“非站即坐”的一个

坐姿是三类里最难判的,因为它的外观变化最大。办公椅正坐、懒人沙发半躺、地面盘腿、蹲在凳子上、坐轮椅、骑车——这些在关键点层面呈现的几何形态完全不同。如果只用“躯干角在 30 到 60 度之间”这一个条件,漏判率会很高。

我的做法是用“髋部高度下降 + 膝部明显弯曲”这个组合作为主判据,而不是靠躯干角。具体来说:

  • knee_angle落在70° ~ 135°区间(有明显弯膝,但不是深蹲),权重 0.45;
  • hip_height_ratio比该目标的站立基线低 15% 以上,权重 0.35;
  • torso_angle在5° ~ 55°之间,权重 0.2。

注意第二个条件用的是“该目标的基线”而不是固定值。这就是带跟踪 ID 的另一个好处:我可以给每个 ID 维护一个最近 30 秒的hip_height_ratio中位数,作为这个人的站立基线。人坐下来,髋部高度相对自己的基线会明显下降,这个相对变化比绝对高度可靠得多,而且能自动适配不同身高、不同机位。

坐姿和蹲姿的区分,我用hip_height_ratio的下降幅度来划:蹲下时髋部会降得更多(通常超过站立基线的 35%),而坐着时下降幅度在 15% 到 35% 之间。这个区间不是绝对可靠的,蹲姿误判确实会存在,所以我在实际系统里通常把“蹲”这个状态单独放一个类,让它不触发报警,只是不上报为跌倒。宁可多一个中间态,也不要让蹲下触发紧急报警。

注意:如果你的场景里没有“蹲”这个动作(比如桌面工位监测),可以把蹲的判定直接去掉,把坐的阈放宽,误报会少很多。规则一定要按场景裁剪,别想着写一套通用规则打天下。

3.3 跌倒判定:单帧判断必然误报,必须上时序

跌倒这块是整套方案里唯一一个我不敢用单帧结论的地方。原因很简单:弯腰捡东西、系鞋带、做俯卧撑、在地上做瑜伽、躺在沙发上——这些动作在某一帧的关键点上和跌倒几乎一模一样。任何基于单帧的“躯干角大于 60 度就报警”的方案,上线第一天就会被误报淹没。

我的跌倒判决分两部分:单帧跌倒候选和时序确认。

单帧候选的条件组合:

  • torso_angle > 55°(躺倒倾向),权重 0.4;
  • body_aspect > 0.9(身体横向展开),权重 0.25;
  • head_hip_dy < 0.6(头部明显下沉到髋部附近或更低),权重 0.25;
  • hip_height_ratio相对该目标基线下降超过 40%,权重 0.1。

四项加权和超过 0.65 记为一个“跌倒候选帧”。

时序确认我用了两个并行的判据,两个都满足才升级为“跌倒事件”:

判据一,持续性:连续 8 到 12 帧(按 25 FPS 算大约 0.3 到 0.5 秒)都是候选帧。这个窗口长度需要按帧率换算,我一般写成confirm_frames = int(fps * 0.4)。窗口太短扛不住噪声,太长会拖慢响应。

判据二,变化剧烈度:跌倒的本质是“从高到低的快速转变”,所以我在事件发生前的 1 到 2 秒窗口里,检查hip_height_ratio的下降速度和torso_angle的上升速度。如果髋部高度在 1 秒内下降超过 25%,同时躯干角增加超过 30 度,这个“快速跌落”的信号会大幅提升置信度。反过来,如果一个人是慢慢躺到沙发上,髋部下降是渐进的,这个速度判据就不会触发,从而把“躺下”和“摔倒”区分开。

这个速度判据是我这套方案里最有价值的一块,也是单纯靠单帧规则做不到的。它把“姿态”升级成了“姿态 + 姿态的变化过程”,信息量完全不同。

还有一条兜底规则:如果某个人在跌倒候选帧之后持续 10 秒以上保持低髋部高度且没有恢复到站立/坐姿,即使速度判据没触发,也升级为跌倒事件。这条规则覆盖的是“慢慢滑倒”“先坐着然后侧倒”这类低冲击的跌倒,在真实场景里占比不低,而且往往更危险,因为当事人可能已经失去意识。

3.4 状态机与滑动窗口投票

判决的最后一层是状态机。我用的是一个带迟滞的状态机,状态集合是UNKNOWN / STANDING / SITTING / LYING / FALLEN,FALLEN是事件态,不是姿势态,触发之后会保持直到人工复位或者检测到重新站立。

状态切换用两个机制保证稳定:

一是滑动窗口多数投票。给每个跟踪 ID 维护一个长度 10 的 deque,存最近 10 帧的候选标签,取众数作为当前状态。这样单帧的抖动直接被吃掉。窗口长度和 FPS 挂钩,我把窗口覆盖的时间固定在 0.4 秒左右。

二是进入/退出迟滞。进入某个状态需要连续 N 帧的支持,退出该状态需要连续 M 帧的其他状态支持,且 N > M 或 M > N 视情况定。比如进入 FALLEN 需要 10 帧确认,退出 FALLEN 需要连续 30 帧的站立证据,这个不对称是故意的——宁可晚点解除警报,也不要漏掉一个真的摔倒后躺了一会儿又挣扎起来的人。

from collections import deque, Counter class PostureStateMachine: def __init__(self, fps=25): self.win = deque(maxlen=max(5, int(fps * 0.4))) self.state = "UNKNOWN" self.enter_cnt = Counter() self.fallen_latched = False self.hist_hip = deque(maxlen=int(fps * 2)) # 用于速度判据 def update(self, cand, feats): """cand: 'STANDING'/'SITTING'/'LYING'/'FALL_CAND' 或 None""" if cand is None: return self.state # 不可判帧不更新 self.win.append(cand) if len(self.win) < 3: return self.state vote, cnt = Counter(self.win).most_common(1)[0] ratio = cnt / len(self.win) if vote == "FALL_CAND" and ratio >= 0.7: self.enter_cnt["FALL_CAND"] += 1 if self.enter_cnt["FALL_CAND"] >= 3 and self._fast_drop(): self.fallen_latched = True self.state = "FALLEN" else: self.enter_cnt["FALL_CAND"] = 0 if self.fallen_latched: # 需要长时间站立证据才解除 if vote in ("STANDING", "SITTING") and ratio >= 0.85: self.enter_cnt["recover"] += 1 if self.enter_cnt["recover"] >= 15: self.fallen_latched = False self.state = vote self.enter_cnt["recover"] = 0 else: self.enter_cnt["recover"] = 0 return self.state if vote != "FALL_CAND" and ratio >= 0.6: self.state = vote return self.state def _fast_drop(self): if len(self.hist_hip) < 8: return False vals = list(self.hist_hip) drop = max(vals[:len(vals)//2]) - min(vals[len(vals)//2:]) baseline = max(vals[:len(vals)//2]) return baseline > 1e-6 and (drop / baseline) > 0.25

这段状态机代码不算优雅,但逻辑是清楚的:投票负责抗抖,计数负责迟滞,fallen_latched负责事件的锁存和解除。实际部署时我把FALLEN的触发做成回调,一触发就推送通知并保存前后 10 秒的视频片段——保存证据这个功能在事后复盘时太重要了,没有它你根本无法判断是误报还是真事。

4. 完整实操:从环境到跑通一条视频流

4.1 环境和依赖

环境没什么讲究,Python 3.9 到 3.11 都行,Ultralytics 版本建议固定,别用 latest,它迭代快,接口偶尔会变。

pip install ultralytics==8.2.0 opencv-python numpy

模型文件第一次运行会自动下载。如果部署环境不能联网,提前把yolov8n-pose.pt下载好放到工作目录,代码里用本地路径加载。我用n版本做原型验证,确认逻辑没问题之后再换s或m版本提精度。这个顺序很重要——先用最轻的模型把流程跑通,能立刻知道是逻辑问题还是精度问题,不至于一上来就被算力拖住。

如果是边缘盒子部署,走 ONNX 导出:

yolo export model=yolov8n-pose.pt format=onnx imgsz=640 simplify=True

导出时imgsz要和推理时一致,不一致的话关键点位置会偏移。这个坑很隐蔽,因为框看着还挺准,但算出来的角度会有几度偏差,累加到阈值边界上就变成了误判。我一般会在导出后拿同一张图做一次 PT 和 ONNX 的结果对齐,两边的关键点坐标差应该在 1 像素以内。

4.2 单帧推理到状态输出的最小闭环

先把最小闭环写出来,跑通再谈优化。

import cv2 import numpy as np from ultralytics import YOLO model = YOLO("yolov8n-pose.pt") def judge_single(f, baseline_hip=None): """单帧判决,返回候选标签和分数""" if f is None: return None, 0.0 ta = f["torso_angle"] hhd = f["head_hip_dy"] ka = f["knee_angle"] asp = f["body_aspect"] hr = f["hip_height_ratio"] # 跌倒候选 fall_score = 0.0 if ta > 55: fall_score += 0.40 * min(1.0, (ta - 55) / 30 + 0.5) if asp is not None and asp > 0.9: fall_score += 0.25 * min(1.0, (asp - 0.9) / 0.5 + 0.5) if hhd is not None and hhd < 0.6: fall_score += 0.25 * min(1.0, (0.6 - hhd) / 0.6 + 0.3) if baseline_hip and baseline_hip > 1e-6: drop = (baseline_hip - hr) / baseline_hip if drop > 0.40: fall_score += 0.10 if fall_score >= 0.65: return "FALL_CAND", fall_score # 站立 stand_score = 0.0 if ta < 32: stand_score += 0.5 if hhd is not None and hhd > 1.0: stand_score += 0.3 if ka is not None and ka > 150: stand_score += 0.2 if stand_score >= 0.7: return "STANDING", stand_score # 坐姿 sit_score = 0.0 if ka is not None and 70 <= ka <= 135: sit_score += 0.45 if baseline_hip and baseline_hip > 1e-6: drop = (baseline_hip - hr) / baseline_hip if 0.12 <= drop <= 0.40: sit_score += 0.35 if 5 <= ta <= 55: sit_score += 0.2 if sit_score >= 0.6: return "SITTING", sit_score return None, 0.0

这段是核心判决的最简版本。要注意fall_score我写成了分段线性映射而不是硬开关,这样分数本身有梯度,可以用来做置信度排序,也能在阈值附近配合时序平滑。硬开关(满足就加固定分)的问题是分数没有分辨力,0.66 和 0.99 在后续逻辑里完全等价,等于浪费了信息。

4.3 视频流推理与跟踪绑定

跟踪这块直接用 Ultralytics 的model.track,配置persist=True让跟踪器在帧之间保持状态。

cap = cv2.VideoCapture("input.mp4") fps = cap.get(cv2.CAP_PROP_FPS) or 25 machines = {} # tid -> PostureStateMachine hip_hist = {} # tid -> deque, 用于基线估计 while True: ok, frame = cap.read() if not ok: break h, w = frame.shape[:2] res = model.track(frame, persist=True, conf=0.4, tracker="bytetrack.yaml", verbose=False)[0] if res.keypoints is None or res.boxes.id is None: continue ids = res.boxes.id.int().cpu().numpy() kxy = res.keypoints.xy.cpu().numpy() kcf = res.keypoints.conf.cpu().numpy() for i, tid in enumerate(ids): tid = int(tid) f = extract_features(kxy[i], kcf[i], h, w) base = None if tid in hip_hist and len(hip_hist[tid]) >= 20: base = float(np.median(hip_hist[tid])) cand, score = judge_single(f, base) if tid not in machines: machines[tid] = PostureStateMachine(fps) hip_hist[tid] = deque(maxlen=int(fps * 30)) if f is not None: hip_hist[tid].append(f["hip_height_ratio"]) machines[tid].hist_hip.append(f["hip_height_ratio"]) state = machines[tid].update(cand, f) # 画图略

hip_hist我用了 30 秒的窗口来算站立基线。这个长度是个权衡:太短,刚坐下来基线就被拉低了,后面判坐姿会失效;太长,换了姿势(比如从站着换成长期坐着办公)基线老化不更新。30 秒在多数场景下够用,如果场景里人长时间静坐,可以改成用“最近 30 秒内的第 90 百分位”作为基线,这样即使大部分时间坐着,基线也还是站着的高度。

4.4 现场标定的三步流程

规则方案必须现场标定,这一步省不掉。我总结成三步:

第一步,采集标定片段。让目标用户(不是你自己)在真实机位下依次做:自然站立 30 秒、正坐 30 秒、站起坐下 5 次、快速坐下 3 次、缓慢躺下 3 次、蹲下起立 3 次。这段素材同时用来标参数和算评估指标。

第二步,看特征分布,定阈值。把每个动作片段的所有帧特征画成直方图,看站立和坐姿的hip_height_ratio分布有多少重叠,看跌倒和躺下的torso_angle和速度分布。阈值就卡在分布的分界处,而不是拍脑袋定一个数。我一般会把阈值设在两类分布的第 5/95 百分位,宁可多留余量。

第三步,回放验证,只看误报。用标定片段回放,把FALLEN的所有触发点记录下来,逐个看是谁触发的、为什么触发。这一步我通常会跑三到五轮,每轮改一两个参数。经验是:跌倒的阈值永远往“更保守”的方向调,因为漏报和误报的代价不对称——误报多了系统会被关掉,那才是真正的漏报。

一个具体的参数调整心得:torso_angle的跌倒阈值,我一开始设 60 度,误报很多,改成 70 度之后漏掉了“侧倒”。最后我改成了 55 度阈值但要求body_aspect > 0.9同时成立,这样侧倒能抓(横向展开明显),弯腰捡东西不会误报(弯腰时身体还是竖直窄条)。阈值调不下来的时候,往往是缺一个联合条件,而不是阈值不够严。

5. 常见问题与排查实录

5.1 高频问题速查表

现象最可能原因排查动作处理方式
站立的人被判成坐背身时鼻子不可见,head_hip_dy失效,站立分数掉到 0.5打印该 ID 各特征的逐帧值,看head_hip_dy是否为 None加耳/眼中点替代鼻子的降级逻辑,权重归一化
蹲下触发跌倒报警单帧body_aspect和torso_angle都接近阈值观察hip_height_ratio下降幅度加蹲姿中间态,下降 >35% 且膝角 <70 时不上报跌倒
坐姿和站姿来回跳滑动窗口太短,或该 ID 的基线还没建立检查窗口长度是否覆盖 0.4 秒加长窗口,基线需要至少 20 帧才启用
画面边缘的人一直判不出半个身子出画,肩髋缺失看返回特征是否为 None加 ROI 边界检查,出画比例太高时输出未知
远距离小目标误判严重关键点分辨率不足,torso_len只有十几像素打印torso_len分布设下限(如 20 像素),不足直接跳过
多人交叉后状态错乱跟踪 ID 切换,历史串到别人身上检查ids是否在相邻帧突变换更稳的跟踪器,或状态机绑定id + 位置连续性双校验
白天准晚上崩低照度下关键点质量下降统计夜间片段的平均conf夜景下调conf_th,或主动降级为只报警不判姿态
报警延迟明显确认帧数太多算一下confirm_frames / fps缩短确认窗口,用速度判据补精度

5.2 几个真踩过的坑

坑一:把“躺着”和“跌倒”混为一谈。我在第一个版本里,只要进入LYING就报警。结果一个用户习惯午休时躺在地上午睡,系统一天报十几次。后来把LYING和FALLEN彻底分开:LYING是姿态,FALLEN是事件,事件需要“从高到低的快速变化”来触发。一个人慢慢躺下,永远不会触发FALLEN;摔倒才会。这个区分是整套方案从“能用”到“可交付”的分水岭。

坑二:跟踪 ID 被背景物体抢占。有一回画面里有把椅子,模型偶尔把椅子腿的图案识别成一个极小的人形(置信度 0.4 左右),分配了一个 ID,然后这个 ID 的“人”永远躺着不动,稳定触发报警。解决办法是在框级别加一个最小面积和最小torso_len过滤,尺寸异常的检测直接丢弃。低置信度 + 极小尺寸的检测,九成是噪声,别舍不得扔。

坑三:摄像头俯角太大导致躯干角失真。顶装摄像头(俯视 60 度以上)拍出来的人,即使是站着,肩中点和髋中点在图像上的纵向距离也被严重压缩,torso_angle会偏大,站坐几乎分不开。这类机位我最后放弃了角度特征,只用hip_height_ratio和body_aspect两个特征,准确率虽然下降一点,但比疯狂误报强。机位决定特征可用性,先看机位再定特征,别反过来。

坑四:多人场景下的基线污染。早期我把hip_hist存在一个全局字典里没按 ID 分,结果房间里有两个人,一个站着办公一个长期坐着,基线被拉成中间值,两个人都判不准。按 ID 分桶之后问题消失。这个 bug 很傻但很典型,多目标场景里任何“历史统计量”都必须按 ID 隔离。

5.3 评估方式:别只看准确率

姿态判决的评估不能只报一个“准确率 92%”,那个数字毫无意义,因为正负样本极度不平衡——一天里 99% 的时间都是站立或坐着,跌倒可能一次都没有。我用的评估方式是这样:

按事件评估,而不是按帧。定义一个跌倒事件从触发到解除为一条记录,统计三类指标:正确触发数、误触发数(每个误触发要归因到具体动作类型,比如“蹲下 3 次”“躺下 2 次”)、漏触发数。用这三个数算检出率和误报率,比帧级准确率有意义得多。

分开统计响应延迟。从动作发生到系统触发的时间差,我要求在中位数 1 秒以内、P95 在 2 秒以内。响应太慢的话,即使检出了也没有实用价值。

单独统计“不可判”比例。如果某段素材里有 40% 的帧被判为不可判,那说明模型在这个场景下根本没工作,准确率的数字再好也是虚的。我一般要求不可判比例低于 15%,超过就回头查是光照问题、机位问题还是模型尺寸不够。

实际测试时我会刻意构造一批“困难负样本”:弯腰系鞋带、蹲下捡东西、坐在低矮的凳子上、躺在地板上看书、在地板上做伸展、快跑后突然停下坐下。这批样本的误报率,比正常场景的准确率更能反映系统能不能上线。我自己的经验值是,困难负样本上的误报率控制在 5% 以下,正常场景的表现就已经足够稳定了。

最后分享一个我觉得最值钱的实践经验:这套方案的真实精度,八成取决于关键点质量,两成取决于判决规则。所以当效果不理想时,先别急着调阈值,去看你的关键点图——把检测出的骨架画在原图上,一帧一帧看。如果骨架本身就画歪了、抖了、丢了半边,那再怎么调规则都是白费劲,该做的是换更大的模型、调输入尺寸、改善光照或者调整机位。规则只能修正“关键点对但判据模糊”的情况,救不了“关键点本身就是错的”。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进