无NPU跑姿态检测,我踩了12个坑
RK3399上跌倒检测的选型、接入与踩坑全记录
环境:Android 室内陪护机器人,SoC 为 2016 年的 6 核 ARM 芯片(2 大核 + 4 小核,无 NPU),检测需全程本地推理、画面不出设备。本文记录从"选开源方案"到"跑通并压到硬件天花板"的完整过程,含 12 个真实坑,每个都带定位过程与根因。
一、需求与约束
跌倒检测表面上只是"判断人有没有摔倒",但落到这类设备上有四个硬约束:
- 实时性:不需要视频级流畅,但"人倒下后 2~3 秒内报警"必须做到;
- 本地推理:居家场景,画面不能上云;
- 算力有限:没有 NPU,只有 6 个 CPU 核,其中只有 2 个是性能核;
- 要有可解释的中间量:调参、排障、对客解释,都必须能说清"为什么判为跌倒"。
这四条基本决定了后面所有的技术选择。
二、选型:为什么最终是 YOLOv8n-pose + ncnn
先排除几条路:
| 方案 | 结论 | 原因 |
|---|---|---|
| 毫米波雷达 | 仅作补充 | 遮挡与多人场景误报高,且拿不到视觉证据 |
| MediaPipe Pose (TFLite) | 放弃 | 该 ARM 平台上 XNNPACK 支持一般,构建链更重 |
| MoveNet (TFLite) | 放弃 | 同上;17 点精度对"躺平"识别偏弱 |
| 纯目标检测(只看框宽高比) | 仅作兜底 | 远景下"跪"与"躺"的框形差异不明显,误报难控 |
| YOLOv8n-pose + ncnn | 采用 | 单模型同时输出人体框 + 17 关键点;ncnn 在 ARM 上成熟、体积小、无强依赖 |
关键判断是:跌倒的本质是"躯干由竖直变水平",这需要关键点;而"人在哪"需要检测框。YOLOv8-pose 一个模型同时给出两者,比"检测 + 姿态"两阶段省一半算力——在只有 6 个核的平台上,这一点非常关键。
推理框架选 ncnn,是因为开源生态里有现成的 YOLOv5/7/8 封装库,接入成本最低。事后看,这个"最低成本"的假设只对了一半。
三、接入阶段的三个工程坑
坑 1:开源库只发 APK,不发 AAR
目标库的 Release 里只有一个 90MB+ 的演示 APK,没有预编译 AAR,而 Android 工程要的是 AAR。
更麻烦的是网络受限:常见的代码托管 raw 域名被重置,只有 API 域名可用。于是走了这条链路:
- 用 API 的重定向能力把 APK 下下来(
Accept: application/octet-stream); - 解包抽出封装库的 native 库 与推理框架的
libncnn.so(前者dlopen依赖后者,两个都得带); - 从仓库取两个纯 Java 源,用
javac -cp android.jar编成classes.jar; - 拼成 AAR(manifest + classes.jar + 4 个 ABI 的 so)。
经验:被限制的常常只是 raw 域名,API 与 codeload 往往还通。二进制文件用
git/blobs/{sha}拿 base64 是很可靠的兜底;超大文件走 release asset 的 API 重定向。
坑 2:API 是"看 README 猜的",全错
第一版反射代码是按 README 印象写的,实际全错:包名猜错、构造函数签名多了一个参数、返回值类型也不对。
教训很直白:反射绑定第三方库前,必须先拿到真实源码或 javap 结果,不能靠 README 片段推断。
坑 3:同名配置文件最容易骗人
工程里已存在一份同名 JSON 配置,但它是另一套引擎的 schema(model_type/model_path/input_width/...),而目标库要的是 param/bin/input_size/outputs/nkpt/ver。直接复用,模型加载一直失败,还查了很久。
经验:动手前先确认 schema,别被文件名骗。
四、模型加载:从包内资源到外置存储
最初把模型打进 APK 的资源目录,13MB 的权重会显著增大包体,而且换模型必须重新发版。
改成:
- 原生库与 AAR 随 APK(运行必需);
- 权重放设备外置存储的 models 目录,运行时按绝对路径加载;
- 打包时加兜底过滤,防止误打包:
androidResources { ignoreAssetsPattern = "*.bin:*.param" }
这样换模型只要推送覆盖目录,不必重装应用——后续调试效率提升非常明显。
五、最隐蔽的坑:关键点分数恒为 0
接上模型、能出框之后,遇到一个诡异现象:
boxes=1 keypoints=17 detail=low_kp_conf(sh=0,hip=0,thr=0.25)
人体检出了、17 个关键点也有,但肩/髋的分数始终是 0,导致躯干角度永远算不出来,判定永远 false,框永远不变红。
定位
把 native 的原始输出打出来(n=1 d=57):
[280.766, 3.575, 69.855, 197.067, 0.000, 0.894,
316.339, 21.142, 0.894, 320.953, 18.185, 0.978, ...]
布局是 [x, y, w, h, class, score] + 17×(x, y, score)。关键点分数明明是 0.894 / 0.978,我们读到 0 —— 问题在反射,不在模型。
根因(两层)
关键点类继承自平台自带的 Point,字段分布很刁钻:
static class Point extends android.graphics.Point { // x/y 来自父类(public)
final float p; // 置信度:包级私有!
}
getField("p")只找 public 字段 → 抛异常 → 被 catch 后默认 0;- 改成
getDeclaredField("p")后,分数对了,但x/y又丢了——它们在父类上,getDeclaredField只看当前类。
正确写法是沿继承链逐级 getDeclaredField + isAccessible。
这个 bug 的连带影响
修好之前,姿态判定完全失效,唯一还能工作的是兜底分支:姿态不可用时用人体框宽高比判断(躺下后框会明显变宽)。
于是出现了很有意思的现象:
| 素材 | 框宽高比 | 结果 |
|---|---|---|
| 室内近景(人占画面大) | 站立 0.32~0.55,躺下 2.54 | 判出跌倒 ✔ |
| 走廊远景(人很小) | 始终 0.32~0.41 | 判不出 ✘ |
“能测出来"不等于"算法对了”——它只是恰好被兜底逻辑救了。看日志时一定要区分走的是哪条分支。
六、Native 崩溃:两次误判
现象
第一次推理后约 1 秒,进程 SIGSEGV,崩溃栈里同一个库地址反复出现几十帧。
误判 1:以为是栈溢出
看到"重复地址"就判断是推理引擎图递归太深,还专门做了 16MB 栈的推理线程。没有任何用,照样崩。
后来才想明白:同类模型在 1MB 栈上本来就能跑;重复地址更像是 unwind 失败的表象。
真正的两个原因
- 输入尺寸与模型固化尺寸不匹配:该 ncnn param 的 Reshape 层把 anchor 数写死了(与输入边长强相关)。为了提速把
input_size改小,anchor 数对不上 → 直接段错误。 - 配置里塞了引擎不认识的键:该库的 native
update()只认input_size/input_name/box_thr/iou_thr/output_name/output_stride/output_anchors/nkpt/kp_thr,根本没有num_threads。
结论
- 模型输入尺寸不能"调",只能换模型(要按目标尺寸重新导出);
- 调第三方引擎前,先把它支持的配置键 dump 出来(对
.so做strings就能看到那串键名)。
七、性能:从 1 秒到 250ms,然后撞墙
第一步:把时间拆开
不打日志就调优等于许愿。三处都加计时:
forward cost: prep=5ms infer=226ms post=0ms dims=56x3024
抽帧耗时 avg=45ms(30 次)
运行中 frames=120 fps=3.0 inferMs=247 kpts=17 detail=torso_tilt=52°
prep = 位图转换 + 缩放,infer = 前向计算,post = 解包 + 拷贝。只有拆开才知道该优化谁。
第二步:发现推理在单线程跑
自研引擎最早为了链接简单,编成了 -DNCNN_OPENMP=OFF。结果:4 线程与 1 线程耗时完全一样。
根因:ncnn 的并行全靠 #pragma omp parallel for,编译器没有 -fopenmp 时这些 pragma 会被静默忽略,于是全程串行,opt.num_threads 形同虚设。
改成开启 OpenMP 并静态链接运行时后:
| 阶段 | inferMs | fps |
|---|---|---|
| 单线程 | 340~355 | 2.3 |
| 4 线程 | 225~280 | ~3.0 |
只快了 1.45 倍而不是接近 4 倍——因为只有 2 个性能核,其余是小核,线程调度会被慢核拖住。
第三步:撞到硬件上限
粗算一下就知道到顶了:
yolov8n-pose @384 ≈ 3.1 GFLOPs
该平台可用算力(2 个性能核)≈ 10 GFLOPS
理论下限 ≈ 0.3s → 实测 0.23~0.28s,已贴上限
想再快只有两条路:量化,或者换硬件。
八、int8 量化:模型能加载,但第一帧就崩
按官方工具链做了一轮:先优化图、再用校准集生成量化表、最后转 int8。
结果:13MB → 3.4MB,模型加载耗时反而更快(130ms vs 247ms),但第一次前向直接 SIGSEGV。
排查过程里我还犯了两个错,值得单独说。
错误 1:以为校准集不够
先怀疑"校准集太小导致量化溢出",于是把 180 帧增强到 1620 帧(翻转/亮度/对比度/gamma/缩放/裁剪/噪声)重跑。没有任何改善——崩溃是算子不支持,与精度无关。
官方建议"5000 张以上验证集",这条针对的是精度,不解决"能不能跑"。
错误 2:验证方法本身是错的
我在 PC 上写了个小程序对比 fp32 与 int8 的输出,看到 int8 有大量 NaN,就下结论"量化不可用"。
后来加了一个自检:同一个 fp32 模型、同一张图跑两遍,结果:
[determinism] fp32 run1 vs run2: differing=71142 (共 169344 个元素)
同一个模型跑两遍都不一样。原因是推理框架的 Mat 有 16 字节对齐的 cstep 填充,我按 ptr[0..total-1] 顺序读,读到了未初始化内存。之前所有 NaN 统计全部作废。
教训:任何"跨模型/跨实现对比"之前,先做确定性自检;最终结论要以设备端真实行为为准。
最终结论
pose 头里的 Concat / Reshape / 激活 这一串算子在 ARM 上没有可用的 int8 内核,该模型在这套栈上不能量化。继续只剩"逐层混合精度"(把崩的层退回 fp32),每验证一次要崩一次,收益约 1.5 倍——性价比太低,放弃。
九、判定逻辑:从"每帧都报"到"一次事件报一次"
算法跑通后,业务侧反馈了两个问题,都不是模型问题,是判定语义问题。
问题 1:躺着不动会疯狂上报
实测一次 199 帧的运行里,跌倒回调被触发了 68 次——因为人躺在地上时,每一帧都判定为 true。
改成上升沿触发 + 冷却:
if (result.fallen) {
if (!fallReported && now - lastReportAt >= 8_000L) {
fallReported = true
lastReportAt = now
listener?.onFallDetected(result)
}
} else {
fallReported = false // 人起身后重新武装
}
问题 2:弯腰、蹲下会误报
单看角度,弯腰捡东西也能到 60°。于是加**"角度 + 持续时间"联合判定**:
- 倾角 > 45° 且持续 ≥ 1500ms → 判跌倒;
- 弯腰/系鞋带通常 1~2 秒内恢复,不会触发。
问题 3:关键点抖动导致判定反复重置
单帧关键点会跳,倾角在 3° → 27° → 8° 之间乱蹦,防抖计数被反复清零。
解法是在坐标层做 EMA 平滑(新 = 0.55×当前 + 0.45×上一帧):
- 选坐标层而不是角度层,因为骨架绘制也用同一组点,平滑后骨架也不抖;
- 目标丢失时清空缓存,避免"人走了骨架还停在原地"。
平滑后角度序列变成 8° → 25° → 52° → 76° → 80° 的连续过渡,判定稳定得多。
十、素材与验证:不要相信"跌倒视频"的文件名
中途换测试素材时踩了一个认知坑:某公开数据集里的"跌倒视频",实测倾角最大只有 39°——抽帧一看,人是跪在垫子上爬,不是躺平。
| 素材 | 峰值倾角 | 应判为 |
|---|---|---|
| 室内趴地 | 81° | 跌倒 ✔ |
| 靠墙跪爬 | 39° | 非跌倒(负样本)✔ |
| 正常行走 | 3~22° | 非跌倒 ✔ |
后来把那个视频专门留作负样本。
经验:素材必须人眼核验(抽帧拼成联系图看一遍),否则会花大量时间去调一个"根本不存在的漏报"。
十一、摄像头链路:另一个被忽略的开销
视频源跑顺后切到摄像头,同一模型 inferMs 从 340 涨到 550。
原因不是模型,是取流链路:图像分析用例以 30fps 回调,每帧都做 720p 的整图拷贝 + 缩放到送检尺寸 + 预览刷新,约 110MB/s 的内存搬运,和推理抢同一批 CPU 核。
三处优化:
- 把分析分辨率降到与送检尺寸一致 —— 省掉一次整图缩放;
- 帧限流到约 14fps —— 推理才 3fps,没必要 30fps 全量转换;
- 视频回放改用"按目标尺寸解码"的接口 —— 而不是"解全尺寸再缩"。
结果 inferMs 回落到 340 左右。
另一条设计决定:不用平台提供的预览控件,只用图像分析的帧自绘预览。因为检测框画在位图坐标系里,预览与检测共用同一张位图,框与像素严格对齐;用预览控件会引入裁剪/旋转差异,框会偏。
代价是异步链路天然有延迟:框永远滞后约"一个推理耗时"(250ms 左右)。事件型检测可接受,但要跟业务方讲清楚。
十二、最终架构与数据
调试 HTTP 服务(下发 init/start/stop 命令)
↓
测试管理器(业务层:初始化/开始/结束/状态统计)
↓
SDK 门面(配置/启动/推帧/上报去重)
↓
检测器工厂 ─→ 自研推理实现(可设线程数)
└→ 官方封装库实现(作为回退)
↓
自研推理库(静态链接推理框架 + OpenMP,仅导出 3 个 JNI 符号)
↓
设备外置存储的 models 目录
| 指标 | 数值 |
|---|---|
| 模型 | YOLOv8n-pose @384(13MB) |
| 单帧推理 | 225~280ms(4 线程) |
| 检测帧率 | ≈3fps |
| 判定延迟 | 倒地后约 2~3 秒(含 1.5s 持续确认) |
| 上报次数 | 一次事件 1 次(上升沿 + 8s 冷却) |
| 推理库体积 | 4.1MB(裁剪后,单 ABI) |
十三、如果重来一次,我会怎么做
- 先算 FLOPs 再选模型:
3.1 GFLOPs / 可用 GFLOPS一分钟就能算出"最快能到多少",避免后面做无用功。 - 先打通"能出结果"的最小链路:用一张确定有人的静态图跑通"框 + 关键点 + 角度",再谈视频、摄像头、性能。我一开始直接上视频,导致"没检出人"和"判定错"混在一起。
- 第一行日志就要打中间量:
maxScore、kpts、torso_tilt、box_aspect。这次排障 80% 的时间靠的就是这几个数。 - 一次只改一个变量:曾同时改"输入尺寸 + 线程数",崩了之后无法归因,白跑两轮。
- 反射读第三方库字段前先
javap:字段可见性、父类继承,这两点坑了我两次。 - 兜底逻辑要显式标注:
detail里用→把"走了哪条判定分支"写出来(如low_kp_conf→box_aspect=2.54),否则"能测出来"会掩盖"主逻辑其实是坏的"。 - 上报语义属于设计,不属于模型:上升沿、冷却、持续时间,这三件事必须在业务层明确实现,否则算法再准也没法用。
十四、后续
当前瓶颈已不在算法,而在算力:老平台没有 NPU,CPU 贴着上限。
下一步计划是换带 NPU 的新平台(约 6 TOPS 级),用对应厂商的推理运行时跑同一个 yolov8n-pose,预期 **2060ms**(1530fps)。因为已经把推理层隔离成统一的桥接接口,替换运行时只需换一个 .so 实现,业务层与判定逻辑完全不用动。
这也是这次重构最值的一点:把"能力"与"引擎"解耦之后,换硬件不再是重写,而是替换。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)