做边缘视觉的同行大概都接过这类需求:客户想要一个贴在墙上、断电也能跑、视频绝不出户的跌倒检测盒子。这个需求翻译成技术语言,就是边缘智能跌倒检测——把"人摔倒了"这个判断从云端拉到摄像头旁边那颗小芯片上完成。我这两年反复折腾的一套组合是:YOLOv8n-Pose 出 17 个人体关键点,LSTM 吃关键点时序做动作分类,整条链路跑在 K230D 这类 RISC-V 边缘 AI 模组上,本地出结果,对外只丢一个几十字节的事件 JSON。它能同时解决三件事:画面不出户、断网可用、端到端延迟压到 200ms 以内。适合两类人看:一类是练过一点 YOLO、想把时序动作识别真正落到低成本板子上的算法和嵌入式同学;另一类是只想知道"这条路到底通不通、坑埋在哪"的产品同学。下面我按选型、数据、部署、调参、踩坑的顺序,把这条链路拆开讲透,能抄的地方直接抄。
1. 方案选型:两段式架构是怎么定下来的
1.1 从"检测框宽高比"说起,纯检测路线为什么走不通
最早大家做跌倒检测,思路都很朴素:YOLO 检测出人,算一下框的宽高比,宽大于高就判定为倒地。这个方案我实测过,实验室里跑得挺欢,一进真实场景就崩。原因不复杂——检测框的形状是视角的函数,不是姿态的函数。摄像头装在 2.5 米高的墙角俯拍,一个人正常站立,框本身就偏宽;装在床头平视,人躺床上,框反而可能偏窄。你用一套固定的宽高比阈值,等于把摄像头安装位置硬编码进了模型里,换个房间就得重调。
更要命的是它无法区分"躺"和"摔"。老人午睡两小时,框一直是横的,按宽高比逻辑就是连续跌倒两小时。人在沙发上瘫着、蹲着系鞋带、弯腰捡东西、小孩在地上爬,这些动作的检测框形状都和跌倒高度重叠。我做过一次统计,纯宽高比方案在真实家庭录像上的误报率能到每小时 3 到 5 次,这种产品没人愿意用。所以路走到这里就必须换思路:判断依据不能是"框长什么样",而必须是"人体骨架在时间轴上是如何变化的"。
1.2 YOLOv8n-Pose 在这套方案里到底承担什么角色
想清楚一件事,YOLOv8n-Pose 在我的方案里不是分类器,它就是个骨架提取器。它不负责判断摔没摔,只负责把画面里的人变成 17 个带置信度的坐标点。输出张量是1×56×8400(以 640 输入为例),56 的构成是 4 个框参数 + 1 个目标置信度 + 17×3 的关键点(x、y、可见度)。
为什么不走 top-down 路线(先检测人框,再裁出来跑 HRNet 之类的高精度姿态模型)?算力账算不过来。top-down 是两次前向,在 K230D 这种量级的芯片上,等于把推理延迟直接翻倍,而且第二次推理的输入尺寸还得跟着人框大小动态变化,端侧做动态 shape 极其难受。bottom-up 的 OpenPose 系方案虽然一次前向搞定,但它的 PAF 后处理在 CPU 上开销不小,小目标漏检也明显。
nano 版 pose 模型单次前向、整图多人、320 输入,是我在精度和延迟之间找到的那个平衡点。它确实有小模型通病:远距离小目标的关键点抖动大、遮挡时可见度掉得厉害。但这个问题在后面时序环节是可以被"熨平"的——单帧抖 3 个像素不要紧,LSTM 看的是 30 帧的整体走势。
还有一个被很多人忽略的点:为什么不用直接的图像分类模型(ResNet 二分类"摔倒/没摔倒")?因为单帧图像里,"躺在地上"和"躺在床上"在像素层面几乎同构,模型只能去学背景(床、地板、沙发),一换场景立刻失效。分类模型没有时间维度,这是它的死穴。
1.3 LSTM 替代手工阈值的三个理由
有了骨架序列,第二条路是用阈值法:头部 y 坐标低于髋部、躯干与水平面夹角大于 60°、关键点下落速度超过某值,三条同时满足就报警。这套规则我写过,写的时候感觉很聪明,维护的时候很痛苦。
第一,阈值是场景相关的。摄像头高度、俯仰角、人的身高、画面里人的远近,全都会影响归一化后的坐标分布。一个能覆盖 90% 场景的阈值组合,剩下 10% 会让你怀疑人生。
第二,规则越叠越多,最后变成一坨互相打架的 if-else。加了"速度突变"能抓住快摔,但抓不住缓慢滑落式跌倒;加了"躯干角度"能抓住躺地,但把瑜伽下犬式也抓进去了。
第三,规则没法表达"过程"。真摔是一个有明确阶段的过程:失衡、下坠、撞击、静止。这四个阶段之间的时间关系,用阈值描述非常别扭,而 LSTM 天生就是干这个的。它吃的是 30 帧的序列,学的是"从站到倒"这条轨迹的形状,而不是某一个瞬间的截面。
当然阈值法不是完全没用。我在最终方案里保留了一条极简的兜底规则:如果关键点检测整体失效(比如人被大面积遮挡),但画面运动能量突然归零并持续 30 秒,触发一次"低置信度提示"。这条规则不报警,只在日志里打个标,方便回查。这叫"用规则兜底、用模型做主判"。
1.4 K230D 的算力账本与预算分配
选型最后要落到板子上。K230D 这颗芯片的公开规格里,KPU 提供 int8 定点算力,配合双核 RISC-V 主控,属于典型的"算力不大但功耗极低"的类型。具体数字以你手上板子的官方文档为准,我只讲实测口径下我拿到的预算,以及我是怎么分配它的。
| 环节 | 输入规格 | 实测耗时 | 运行单元 | 备注 |
|---|---|---|---|---|
| 摄像头取流 | 640×480 YUV | 3~5 ms | ISP/VPU | 硬件通路,几乎不占 CPU |
| 缩放预处理 | 640×480→320×320 | 2~4 ms | 硬件缩放 | 不要用软件 resize |
| Pose 推理 | 320×320 int8 | 28~35 ms | KPU | 波动主要来自温控降频 |
| Pose 后处理 | 8400 候选解码+NMS | 4~7 ms | CPU | 可优化到 3ms 以内 |
| 关键点归一化 | 17×3 | <1 ms | CPU | 纯算术 |
| LSTM 推理 | 30×34 序列 | 2~3 ms | CPU | 参数量很小 |
| 状态机与上报 | — | <1 ms | CPU | — |
| 合计 | — | 40~55 ms | — | 折算 18~25 fps |
这个账本的关键结论是:姿态模型吃掉了 70% 以上的时间,LSTM 几乎不要钱。所以整套系统的优化重心只有两个——把 pose 推理压下来、把姿态模型的输出接口做干净。LSTM 那边你就算把隐藏层从 64 加到 128,端侧也就多一两毫秒,完全不用纠结。
我见过有人一上来就想在板子上跑 640 输入的 pose 模型,觉得分辨率高精度好。实测下来单帧接近 90ms,加上后处理直接掉到 10fps 以下,时序窗口的覆盖时间被拉长到 3 秒以上,反应就迟钝了。320 是这个算力档位上的甜点,再往下 256 会开始丢远距离小目标的关键点。
1.5 这套方案的能力边界,先说清楚
落地之前必须把边界划出来,不然最后验收一定会扯皮。这套方案能覆盖的:单人或多人的室内场景,摄像头固定安装,人的有效像素高度不低于 120px,光照从白天自然光到夜间补光都能工作(但全黑不行)。它对付得了快摔、慢滑、扶墙倒下、床上滚落这几种典型形态。
它覆盖不了的:严重遮挡(比如人在被子下面摔)、极度拥挤的场景(画面里十几个人互相重叠)、剧烈晃动的摄像头(云台、车载)、以及"人摔倒后被家具挡住一半身体"这种。这些情况 pose 模型本身就会丢点,LSTM 拿不到有效序列,再强的分类器也没用。产品侧最好设计成"检测到疑似跌倒 → 本地语音确认 → 无响应再升级告警"的两级流程,用交互来补偿算法的不确定性,这比死磕模型召回率实在得多。
2. 数据与模型:把 17 个关键点变成可判别的时序特征
2.1 数据采集:三类场景缺一不可
跌倒检测的数据集绝对不能只有"跌倒"一种。我带过的一个小组最开始就犯了这个错:找了两个人各摔了 50 次,凑了 100 条正样本,然后随便剪了点走路的视频当负样本。训出来的模型在测试集上 98%,一上真机,人一坐下就报警。原因是负样本的多样性严重不足,模型学到的不是"摔倒",而是"和那两个人走路姿势不一样"。
我后来的做法是强制三类配比,比例大致 1:3:3。
- 真摔样本:在厚垫子上完成,覆盖向前、向后、侧向、扶墙滑落、从床上滚落。每个受试者至少完成 20 次,总计 10 到 20 人,不同身高体型。摔倒动作要自然,别演得太夸张,真实跌倒往往比演出来的更"软"。
- 近似负样本:这是最关键的一类。蹲下系鞋带、蹲下捡东西、坐沙发、坐椅子、躺床上、躺沙发、做瑜伽、做俯卧撑、爬行、搬重物弯腰。这些动作和跌倒的骨架轨迹局部高度相似,不喂给模型,误报就压不下去。
- 普通负样本:站立、行走、挥手、转身、伸懒腰、打电话。
采集的时候要刻意变换三个变量:摄像头高度(1.5m / 2.2m / 2.8m)、水平距离(2m / 3.5m / 5m)、光照(白天、开灯、只留夜灯)。同一段动作在这九个组合下各采一遍,得到的数据才有泛化性。合成增广也有用——对已有骨架序列做随机旋转 ±15°、整体缩放 0.8~1.2、时间伸缩 0.85~1.15、关键点加高斯噪声(σ=0.01 归一化坐标),能把有效样本量翻三四倍。
注意:增广只在训练集做,验证集必须保留原始分布的样本,否则你测出来的指标全是幻觉。
2.2 关键点序列的清洗与归一化
原始关键点不能直接喂 LSTM,必须做三件事:坐标系归一化、缺失点处理、时间重采样。
坐标系归一化解决的是"人离摄像头远近不同导致坐标尺度不同"的问题。做法是以左右髋关节的中点为原点,以肩中点到髋中点的距离作为尺度因子,把 17 个点全部换算成相对坐标。这样一个人站在 2 米处和 5 米处,只要姿态一样,归一化后的坐标就基本一致。这个设计是整个方案能跨场景复用的核心。
缺失点处理:pose 模型对每个点输出可见度,低于 0.3 的点视为不可靠。我用线性插值补,最多补 5 帧;超过 5 帧连续缺失,直接判定这个窗口无效,不送入 LSTM,宁可少判一次也不要乱判。
时间重采样:摄像头帧率会随负载波动(18~25fps 之间飘),如果直接按帧数切窗口,同一个"2 秒窗口"在不同负载下覆盖的时长不一样,LSTM 学到的时序尺度就乱了。我的做法是按时间戳把所有关键点重采样到固定的 15Hz,窗口长度固定 30 帧,也就是覆盖 2 秒。15Hz 对跌倒这个尺度的动作足够了,人的下坠过程通常在 0.5 到 0.8 秒之间,15Hz 下能采到 8 到 12 个点,足以刻画轨迹。
import numpy as np def normalize_kps(kps, vis, conf_thr=0.3): # kps: (17, 2) 像素坐标; vis: (17,) 可见度 hip = (kps[11] + kps[12]) / 2.0 # 左右髋中点 sho = (kps[5] + kps[6]) / 2.0 # 左右肩中点 scale = np.linalg.norm(sho - hip) if scale < 1e-3: # 防止除零,退回髋宽 scale = np.linalg.norm(kps[11] - kps[12]) + 1e-6 rel = (kps - hip) / scale rel[vis < conf_thr] = np.nan # 标记为缺失 return rel def fill_missing(seq, max_gap=5): # seq: (T, 17, 2),逐点做线性插值 T = seq.shape[0] for j in range(17): mask = ~np.isnan(seq[:, j, 0]) if mask.sum() < T - max_gap: # 缺太多,整窗作废 return None idx = np.arange(T) for c in range(2): seq[:, j, c] = np.interp(idx, idx[mask], seq[mask, j, c]) return seq def add_velocity(seq): # 一阶差分作为附加特征,捕捉下坠速度 vel = np.diff(seq, axis=0, prepend=seq[:1]) return np.concatenate([seq.reshape(len(seq), -1), vel.reshape(len(seq), -1)], axis=1) # (T, 68)加上速度特征之后,输入维度从 34 变成 68。这一步是有收益的:跌倒最显著的特征就是躯干下坠的瞬时速度远大于日常动作,而坐标本身不带这个信息。实测加了一阶差分之后,快摔和慢滑的区分度明显提升。
2.3 LSTM 的训练配置与过拟合处理
网络结构别搞复杂,我用的就是最朴素的两层 LSTM 加一个全连接头:
import torch.nn as nn class FallNet(nn.Module): def __init__(self, feat=68, hidden=64, ncls=4): super().__init__() self.lstm = nn.LSTM(feat, hidden, num_layers=2, batch_first=True, dropout=0.3) self.head = nn.Sequential( nn.LayerNorm(hidden), nn.Linear(hidden, 32), nn.ReLU(), nn.Dropout(0.3), nn.Linear(32, ncls)) def forward(self, x): # x: (B, 30, 68) out, _ = self.lstm(x) return self.head(out[:, -1, :]) # 取最后时刻四个类别:0 正常活动、1 弯腰/蹲下/坐下、2 跌倒、3 躺卧。把"躺卧"单独成一类很重要,因为躺床上、躺沙发上和摔倒后躺地上,骨架形状非常接近,主要靠"进入躺卧状态的过程"来区分。分开建模之后,模型只需要判断"这个躺卧是快进还是慢进",问题就清晰了。
训练配置上我用 AdamW,学习率 1e-3,余弦退火,batch 64,最多 120 轮,验证集跌倒类 recall 连续 10 轮不涨就早停。损失函数用带类别权重的交叉熵,权重按 1 / 类别频率 反比设置,因为跌倒样本天然少。
这里有个取舍必须说清楚:召回优先于精确率。跌倒漏报的代价远大于误报,所以我会把跌倒类的判定阈值压低到 0.6 左右,让模型"宁可错杀"。误报靠后面的状态机和人工确认流程去消化,漏报没有后悔药。
过拟合在小样本时序任务上非常容易发生,我常用的三招:一是 dropout 0.3 起步;二是权重衰减 1e-4;三是按受试者划分数据集,同一个人不能同时出现在训练和验证里,否则模型会记住这个人的体型和穿衣,指标虚高。
2.4 姿态模型的训练与导出注意事项
自标注的数据量我一般控制在 2000 到 5000 张,关键是覆盖度而不是数量。标注 17 个关键点,遮挡住看不到的点标为不可见而不是随便估个位置,这点非常重要,假装标对了反而污染训练。
训练用 416 输入(训练分辨率可以比部署分辨率高,让模型学到更好的特征,部署时降到 320 精度损失可控),100 轮,数据增强开马赛克和随机缩放,但关掉上下翻转——人体关键点有严格的上下语义,翻转之后头在下脚在上,模型会学歪。水平翻转可以开。
导出 ONNX 的时候有两个坑。第一,不要带 NMS 导出,Ultralytics 默认导出是干净的前向图,一旦你勾了nms=True,图里会多出板端不支持的算子。第二,opset版本别贪新,12 或 13 是兼容性最好的档位,simplify=True打开做一次图简化。
yolo export model=pose_best.pt format=onnx imgsz=416 opset=12 simplify=True导出后先用 Python 跑一遍推理,确认输出形状是1×56×8400,再进转换环节。这个检查千万别跳,我踩过一次坑:训练脚本里改过关键点数量做实验,忘了改回来,导出之后变成 1×44×8400,直到板端后处理崩了才发现。
3. 部署实操:从训练脚本到板端可跑
3.1 工具链环境与转换流程
onnx 到板端 kmodel 要过一次量化转换。工具链的核心是nncase,命令行方式最稳,别依赖各种封装脚本。我用的流程分三步:读 onnx、配置量化参数、输出 kmodel。
# 1) 生成转换配置 ncc compile pose.onnx pose.kmodel \ --target k230 \ --input-type uint8 \ --input-shape 1,3,320,320 \ --dataset calib/ \ --quant-type int8 \ --inference-type quant几个参数解释一下。--input-type uint8表示输入张量按 0~255 的 uint8 传,这是端侧最省事的形式,预处理里的归一化会被吸收进量化参数里,板子上不用再做一次 float 除法。--input-shape必须和部署分辨率一致,训练用 416、部署用 320,这一步就要写 320,转换工具会自动做一次输入层的适配。--dataset calib/指向校准图目录,这个目录下面要放 200 到 500 张真实的场景图。
转换完一般会输出一个精度对比报告,重点看余弦相似度和关键点偏移。我实测的经验值是:输出余弦相似度 0.98 以上、关键点平均偏移 5 像素以内,端侧效果基本无损。如果掉到 0.9 以下,先别急着调模型,八成是校准集的问题。
3.2 量化校准集的选择技巧
这一节我要多说两句,因为这是新手最容易糊弄、也是掉点最多的地方。
校准集决定了量化参数(每层的 scale 和 zero point),它的分布必须覆盖你实际运行时见到的输入分布。很多人图省事,直接从训练集里随机抽 100 张,结果训练集全是白天清晰图,实际运行在夜灯下画面噪点大、整体偏暗,量化后的激活值直接饱和,关键点全飘。
我的做法是校准集刻意做成"最差情况采样":三分之一白天,三分之一开灯夜间,三分之一暗光加运动模糊;场景上要包含远景小人、近景大特写、多人同框;再塞几张极端逆光的。数量 200 张起步,场景复杂度上去了可以到 500 张。
一个很实用的小技巧:校准集里一定要放几张"画面里没人"的空场景图。这类图的激活值分布和有人图差异很大,不放进去会导致背景区域的量化误差偏大,进而产生大量低置信度的伪关键点,后面 NMS 就得靠提高阈值来压,容易误伤真目标。
3.3 板端推理框架:双线程 + 双缓冲
板端软件结构我是这么切的:一个线程专职"取流 + pose 推理 + 后处理",产出归一化后的关键点;另一个线程专职"维护时序队列 + LSTM 推理 + 状态机"。中间用固定长度的双端队列传递。
这么切的好处是解耦。pose 推理的耗时是波动的(受温控影响),如果单线程串行,LSTM 的输入时间戳就不均匀,时序特征会失真。用队列之后,pose 线程按时间戳写入,LSTM 线程只要保证队列里的数据是按 15Hz 重采样过的就行。
from collections import deque import _thread, time kps_q = deque(maxlen=64) # 关键点队列,容量留够 state = {"fall": False, "prob": 0.0} def pose_worker(): while True: img = cam.snapshot() # 取一帧 inp = preprocess(img, size=(320, 320)) # 硬件缩放 + 定点化 out = kpu.run(inp) # KPU 前向 kps, vis = decode_pose(out) # DFL 解码 + NMS kps_n = normalize_kps(kps, vis) # 归一化 kps_q.append((time.ticks_ms(), kps_n)) # 带时间戳入队 def lstm_worker(): while True: seq = resample_to_15hz(kps_q, window=30) # 重采样到固定频率 if seq is None: time.sleep_ms(20); continue prob = run_lstm(seq) # 端侧定点或 float 均可 update_state_machine(prob) # 见第 4 章 time.sleep_ms(66) # 15Hz 节奏resample_to_15hz的实现思路是:从队列尾部往回找,找到时间跨度 2 秒的数据,然后按 66.7ms 的间隔做线性插值,凑够 30 个点。不足 2 秒就返回 None,等数据攒够。
后处理里的 DFL 解码值得单独说一下。YOLOv8 pose 的框回归用的是分布焦点损失,输出是每个边 16 个 bin 的 logits,需要做 softmax 后加权求和再乘 stride。这部分如果用框架自带的算子做,在端侧经常跑到 CPU 上还慢,我的做法是直接用原始输出张量,在 CPU 上手写一遍,过程很轻:
def dfl_decode(out, strides=(8, 16, 32), reg_max=16): # out: (1, 56, 8400),前 4 通道是 DFL 距离,后面是 conf 和 51 个关键点值 dist = out[0, :4 * reg_max, :].reshape(4, reg_max, -1) dist = softmax(dist, axis=1) # 对 16 个 bin 归一化 proj = np.arange(reg_max, dtype=np.float32) dfl = (dist * proj[None, :, None]).sum(axis=1) # (4, 8400) # 后面按 anchor 网格中心展开成 xyxy,再乘 stride,再进 NMS3.4 时延、帧率与内存的实测数据
同一块板子,不同配置下我跑出来的数据如下(室温 25℃,加 5mm 铝散热片,连续运行 10 分钟取稳定值):
| 配置 | Pose 单帧耗时 | 端到端帧率 | CPU 占用 | 备注 |
|---|---|---|---|---|
| 320×320 int8,无散热 | 35~48 ms | 15~18 fps | 62% | 5 分钟后明显降频 |
| 320×320 int8,加散热片 | 28~33 ms | 22~25 fps | 58% | 推荐配置 |
| 416×416 int8,加散热片 | 62~75 ms | 11~13 fps | 71% | 精度略好,实时性变差 |
| 256×256 int8,加散热片 | 20~24 ms | 26~29 fps | 52% | 远距离小目标丢点明显 |
| 320×320 fp32(对比用) | 基本跑不动 | — | — | 无实用价值 |
内存占用上,整条链路常驻在 40MB 量级,其中 kmodel 本身、输入输出张量、Python 运行时各占一块。队列长度设 64 是留了余量的,实际只需要 30 个有效点加几个缓冲。如果你的板子内存更紧,可以把 pose 输出在 C 层直接做完 NMS 再交给 Python,避免大张量在脚本层被复制多次。
4. 误报抑制:单帧判定到状态机的三级过滤
4.1 为什么单帧概率不能直接报警
LSTM 输出的是一个 softmax 概率,如果每帧概率超过 0.6 就报警,一天下来能响几十次。原因在于时序模型的输出在状态切换的边界上会剧烈抖动,人从站到蹲的瞬间,模型可能给出 0.7 的跌倒概率,因为下坠的加速度特征出现了。这只是"像",不是"是"。
我的做法是三级过滤。
第一级,滑窗投票。不按单帧判,按窗口判。每次 LSTM 输出后,看最近 5 次(约 0.33 秒)的输出里有多少次判为跌倒类,超过 3 次才算"疑似"。这一步把偶发抖动干掉了。
第二级,状态机确认。疑似之后不立即报警,进入"待确认"状态,要求后续 3 秒内持续维持高概率。真正的跌倒,人倒地后会有明显的静止期,骨架变化很小但保持在躺卧位;而蹲下系鞋带的人,两秒后就站起来了。这个持续性是天然的强特征。
第三级,姿态合理性校验。确认前再算一次几何量:当前帧的头、肩、髋三点的 y 坐标是否满足"头接近或低于髋",躯干是否接近水平。这一步是给模型兜底的,模型再强也可能被特殊体型骗,加一层几何约束能进一步压误报。
三级串下来,我在家庭场景测到的误报率从每小时 3.2 次降到了 0.2 次以内,代价是报警延迟从 0.3 秒增加到 2.5 秒左右。这个代价完全可以接受——跌倒救援的时间窗是按分钟算的,多等两秒换来不扰民,值。
4.2 典型误报场景与对应的特征差异
踩过的坑整理成表,这里每一条都是真实发生过的:
| 误报场景 | 为什么像跌倒 | 用什么特征区分 |
|---|---|---|
| 蹲下系鞋带 | 躯干下坠快、髋部高度骤降 | 静止期短(<2s),随后快速站起,关键点回升 |
| 坐沙发 | 臀部下沉、躯干后仰 | 下沉过程平缓(>0.8s),无撞击式加速度峰值 |
| 躺床上睡觉 | 姿态与跌倒后躺地几乎一致 | 进入过程慢(>3s),无下坠段,通常伴随长时间无位移 |
| 弯腰捡东西 | 头部 y 坐标低于髋部 | 持续时间极短(<1.5s),且下降沿和上升沿对称 |
| 瑜伽下犬式 | 躯干水平、头低位 | 动作平稳无冲击,且常伴随周期性重复 |
| 小孩爬行 | 整体位置很低,关键点分布接近俯卧 | 持续运动而非静止,关键点有周期性位移 |
| 宠物入镜 | pose 模型对动物也输出"人体" | 关键点置信度普遍偏低,且骨架比例异常 |
从表里能看出一个共性:真摔的核心特征是"快下坠 + 长静止",所有相似动作要么缺前半段(慢进入),要么缺后半段(快速恢复)。把这两段特征单独强化出来,误报就能压下去一大截。
我在 LSTM 之后还加了一个轻量的后处理规则,专门抓"静止时长":维护一个low_motion_frames计数器,当归一化后关键点的整体位移小于阈值时累加,超过 15Hz × 3 秒 = 45 帧就认为是长时间静止。这个计数器和 LSTM 概率做逻辑与,是最终报警的触发条件。
4.3 关键参数表与调参思路
调参不要凭感觉,先定指标再定参数。我把几个核心参数和它们的实际影响列出来:
| 参数 | 我用的值 | 调大的后果 | 调小的后果 |
|---|---|---|---|
| LSTM 跌倒阈值 | 0.60 | 漏报增加 | 误报增加 |
| 滑窗投票长度 | 5 次输出 | 反应变慢,抗抖强 | 反应快,易误触 |
| 投票通过数 | 3 | 更保守 | 更敏感 |
| 静止确认时长 | 3 s | 报警更晚,误报更少 | 报警快,蹲下易误触 |
| 时序窗口 | 30 帧 @15Hz | 覆盖动作更全,边界模糊 | 快摔抓得住,慢摔抓不住 |
| 关键点可见度门限 | 0.30 | 丢弃更多点,序列易作废 | 引入噪声点 |
调参顺序我建议这样:先把阈值定在 0.5 看整体召回和误报,然后把静止确认时长往长调压误报,最后微调时序窗口长度。一次只动一个参数,动完跑一遍固定的测试集(提前录好的 50 段视频,含 10 段真摔、40 段干扰动作),记录下来对比。没有固定测试集的调参就是在赌博。
5. 踩坑实录与排查速查表
5.1 模型转换与量化类问题
问题一:转换成功但板端输出全是 NaN 或常数。九成是预处理没对齐。训练时用的是(x/255 - mean)/std的归一化,导出后量化工具默认按x/255处理,如果模型内部的归一化层被优化掉了,输入分布就偏了。解决办法是训练时就采用简单的x/255归一化,把 mean/std 直接烘进 BN 层,别在推理端做复杂运算。
问题二:关键点整体出现固定的偏移。这是量化误差导致的坐标回归偏差,常见于回归头。可以尝试把校准集换得更全面,或者提高校准集里远景小目标的占比。如果偏移量稳定在 5 像素以内,实际上对 LSTM 影响很小,因为归一化之后这个偏差会被部分抵消,不必强求。
问题三:转换时报某个算子不支持。一般出现在 reshape、concat、slice 这类形状操作上。解决办法是在导出前用onnxsim做一次图优化,把冗余的形状操作合并掉;如果还不行,就在自定义后处理里避开这段计算,把张量原样输出到 CPU 端自己处理。
5.2 运行时与工程类问题
问题四:跑十几分钟就卡顿,帧率断崖式下跌。温控降频,几乎百分百是这个。K230D 这类芯片在高负载下温度上得很快,没有散热措施必然降频。加一块小铝片,或者把外壳设计成金属导热,问题就解决了。同时建议在代码里读一下温度传感器,超过阈值主动降帧率而不是等降频,这样行为更可预期。
问题五:Python 层卡顿导致时间戳不均匀。采集阶段如果用 Python 循环不停 append,GC 一触发就是几十毫秒的抖动。做法是把时间戳在取流那一刻就打上,队列用deque并在 C 层做长度控制;或者在关键路径上避免创建临时对象,复用缓冲区。
问题六:摄像头和 KPU 抢带宽。640×480 的取流如果每帧都做软件缩放,耗时会明显上升。必须用硬件缩放通路,直接让 ISP 输出 320×320,或者用硬件 resize 单元。这一改动往往能省下 5 到 8ms。
5.3 效果类问题
问题七:晚上效果明显变差。低照度下噪点大,pose 模型的关键点置信度整体下降。除了换好一点的补光,我还在预处理加了一步轻量的自动亮度拉伸,按整图直方图的 5% 到 95% 分位数做线性映射,能挽回一部分。但根本上还是要有补光硬件。
问题八:一个人都没检测到却报警了。检查是不是队列里残留了旧数据。deque(maxlen=30)在无人时不会清空,旧的最后几帧会被反复用。解决办法是加一条判断:如果连续 1 秒没有检测到任何有效人体,清空队列并重置状态机。
问题九:模型在实验室好用,到现场就失灵。大概率是摄像头安装位置差异太大。建议在现场重新采 5 分钟的空跑数据,把关键点分布打出来看看,确认归一化后的骨盆位置、肩髋距离的分布有没有超出训练集范围。如果分布偏移明显,用几段现场数据做一次微调(只训练分类头,冻结 pose 模型)就能拉回来。
5.4 一张表带走的排查顺序
| 现象 | 先查什么 | 再查什么 | 最后查什么 |
|---|---|---|---|
| 完全不报警 | 后处理有没有解码出关键点 | LSTM 队列是否有数据 | 状态机是否被卡死 |
| 疯狂误报 | 校准集与现场光照是否匹配 | 关键点可见度门限是否太低 | 阈值和投票数是否过松 |
| 只报站立不报跌倒 | 训练集跌倒样本占比 | 类别权重设置 | 验证集是否泄漏 |
| 帧率偏低 | 是否用了软件缩放 | 是否有散热 | 模型输入分辨率是否过冲 |
| 模型加载失败 | kmodel 目标型号 | 输入 shape 配置 | 固件版本兼容性 |
排查的核心原则是自底向上:先确认最底层的数据(关键点)对不对,再看中间的时序特征,最后才怀疑模型和参数。太多人一上来就怀疑模型,折腾半天最后发现是摄像头没对焦。
6. 我个人在这套方案上的几点体会
做了这么久边缘端的动作识别,最深的感受是:模型在整条链路里的重要性,比想象的低。这套系统里,pose 模型决定了上限,LSTM 决定了判别的细腻程度,但真正决定能不能产品化的,是归一化方式、时间戳一致性、校准集质量、状态机设计这些"不上台面"的工程细节。我见过太多人把精力全砸在换更大的模型、堆更多层 LSTM 上,最后卡在端侧时序抖动这个坑里出不来。
另一个体会是要先建测试集再调参。我现在的习惯是项目一开始就先录 50 段固定视频,定好评估口径(误报次数每小时、跌倒召回率、平均报警延迟),后面每改一版就回归一次。没有这个,你改任何参数都是凭直觉,改着改着就不知道自己在优化什么了。
最后分享一个小的延伸方向:这套"骨架提取 + 时序分类"的骨架其实可以复用。换个数据集,同一套端侧框架能做久坐提醒、能做起身困难识别、能做异常步态筛查。把 LSTM 换成 TCN 或者轻量 Transformer,在同样算力预算下还能再榨一点精度出来。不过先把基础版本跑稳,再谈这些——端侧项目里,能稳定跑三个月不出问题的简陋方案,永远好过一个只在实验室惊艳的复杂方案。