简介:这是一份基于YOLOv5的HRnet人体姿态估计完整工程,面向需要快速实现图片、视频及摄像头实时关键点检测的开发者与学生,下载后只需修改本地路径即可直接运行,省去繁琐的环境搭建和调参过程。包内共2000个文件,以1867个Python脚本为主,配合C/C++源码、头文件、txt配置说明与Markdown文档,覆盖目标检测、姿态估计、骨骼绘制及结果演示等模块,压缩包大小约841.53MB。具体内容包括添加SPPF模块、修改yaml文件、获取边界框与关键点、绘制骨骼等完整流程,并整理了functional.py警告、matplotlib后端设置、Upsample属性错误、gbk解码异常等常见报错分析,同时支持照片、视频和实时摄像头三种演示方式。目前已有1748人学习下载,适合希望快速上手YOLOv5姿态估计并深入理解代码细节的实践者。
1. 先开枪再瞄准:为什么人体姿态估计要“YOLOv5+HRnet”绑在一起
一张多人照片摆在你面前,要实时标出每个人的手、脚、肩膀,单一的 YOLOv5 只能画框,单一的 HRnet 在多人场景下会被整张图的背景干扰到崩溃。把两段式检测接起来,才算真正把“人在哪”和“姿势啥样”这两件事解耦——YOLOv5 负责从整图里把人头、人体框挑出来,HRnet 再对每个裁好的框回归 17 个关键点。这个组合在工程上有个明显红利:检测和姿态模型可以分开训练、分开换,检测部分用现成的 COCO 预训练权重,姿态部分单独调参;部署时如果算力紧张,还能先量化姿态网络而不动检测网络。这篇文章就照这条线把数据准备、训练、调参与部署的完整路径拆开讲,适合手里有摄像头、想跑通实时多人姿态估计的工程师。
2. 选型对照:YOLOv5+HRnet的“工作流”在姿态估计里的位置
2.1 自顶向下与自底向上:一条分界线决定你的延迟
人体姿态估计领域有个绕不过去的分叉口:自顶向下(Top-Down)和自底向上(Bottom-Up)。自顶向下的做法就是“先检测后关键点”,先把人从画面里框出来,再对每个框单独估计关键点。自底向上的做法是“先点后分组”,整个画面同时回归所有关节的 heatmap,再用肢体连接算法把人分组。前者精度高,后者在遮挡严重和密集人群里更稳——但如果画面里人少、背景可控,我会直接选自顶向下,因为每个人单独过网络的流程可以用并发和批处理压掉延迟。
YOLOv5+HRnet 就是典型的两段式自顶向下方案。YOLOv5 把检测做到每张图 20ms 以内,HRnet 的裁剪分支只处理一个固定尺寸的小图(通常 256×192 或 384×288),单个人体的推理时间在 GPU 上可以压到 10ms 以下。整体端到端延迟主要瓶颈不在网络推理,而在“检测框到裁剪坐标”的后处理粘合层。这个衔接层的设计精度直接决定了姿态输出的抖动程度,后面避坑章节会重点展开。
代码和原理的对应关系其实很简单。YOLOv5 的输出是[x1, y1, x2, y2, conf, cls],姿态网络拿到的输入是经过仿射变换的[3, H, W]张量。这两个张量之间的转换就隔着一个 resize 和一个坐标映射,不涉及任何复杂的特征对齐。把这个流程想清楚,你就知道调参的着力点分别落在哪段:检测框是否稳定影响关键点的输入分布是否平稳;姿态网络的学习率与损失权重影响关键点回归本身的精度;两者之间的 padding 方式影响边缘姿态的完整性。
2.2 HRnet高分辨率分支,比Swin-T和Hourglass好在哪
HRnet 的核心贡献是全程保持高分辨率特征图,而不是像 Hourglass 那样先降采样再上采样恢复。它在整个网络里并行维护四个分辨率分支(1/4、1/8、1/16、1/32),每个阶段做一次多分辨率融合。这个设计的直接收益是,小目标(比如手指尖、脚踝)的特征不会被下采样信号稀释掉。人体姿态里脚跟和手腕的关节尺寸其实很小,如果在 1/8 分辨率以下做回归,关节坐标偏移会被放大很多倍。
我用过一个直观对比:同样在 256×192 的输入下,用 Hourglass 训练一个人体关键点模型,收敛速度快,但踩到 65 的 PCK@0.2 之后怎么都上不去;切到 HRnet-W32,同样的数据、同样的学习率策略,PCK 缓慢爬到 72 附近。差距来自“跳层连接 vs 并行融合”的差异——HRnet 在每层都做高分辨率与低分辨率之间的信息交换,不是只在最后拼接。这个机制对关节这种强结构、小面积的目标天然友好。
实际选型时要看自己的显存和芯片。HRnet-W32 是默认选择,W48 精度再抬 1~2 个点但参数量接近翻倍。如果部署对象是树莓派或 Jetson Nano 这类边缘设备,我更建议 W32 + 深度可分离卷积替换,或者干脆在导出时做通道剪枝。HRnet 的结构对剪枝的容忍度比 ResNet 高,因为多分支融合会兜住部分被剪掉的信息。
2.3 数据流设计:检测框裁剪、缩放、归一化,喂给姿态网络的真实样子
把整个流水线的张量形状写清楚,代码就不会乱:
| 阶段 | 输入张量形状 | 输出张量形状 | 关键参数 |
|---|---|---|---|
| YOLOv5 检测 | [B, 3, 640, 640] | [B, N, 6](6 代表 x1,y1,x2,y2,conf,cls) | imgsz=640,conf_thres=0.25 |
| 坐标映射 | [N, 4] | [N, 4]裁剪框 | padding=1.2,clip 到原图边界 |
| 裁剪与缩放 | 原图 + 裁剪框 | [N, 3, 256, 192] | 保持长宽比,灰度填充 |
| 归一化 | [N, 3, 256, 192] | [N, 3, 256, 192] | mean=[0.485,0.456,0.406],std=[0.229,0.224,0.225] |
| HRnet 推理 | [N, 3, 256, 192] | [N, 17, 64, 48] | 输出是分辨率 1/4 的 heatmap |
| 坐标解码 | [N, 17, 64, 48] | [N, 17, 2] | argmax + 仿射逆变换 |
注意裁剪阶段不是简单的方框剪下来然后 resize。常见做法是:先按检测框的宽高各扩 20%(padding=1.2),再把扩完的框映射到目标分辨率,最后用仿射变换把原图区域拉成 256×192。这步如果做成了直接拉伸,会让长宽比失衡,导致身体比例在模型输入端横竖各缩一半——姿态网络对这种情况相当敏感,误差能到 3 到 5 个像素。
最后一个要点:输出的 heatmap 是 64×48 的 1/4 分辨率特征图。argmax 找到峰值点之后,如果要亚像素精度,可以在峰值邻域做高斯插值或取 3×3 窗口的加权平均。官方 HRnet 的后处理里常用 “soft-argmax” 思路,把峰值坐标往高响应方向偏移一小段。这一步在你做部署时,对面部关键点或者手部关节的稳定性提升很明显,代价只是每点多算 9 个像素的权重求和。
3. 从COCO到自己的数据集:标注格式与预处理全套脚本
3.1 COCO关键点标注的17个点与可见性标志
YOLOv5+HRnet 训练主路径上跑的是 COCO 格式的标注,17 个关键点的索引顺序是固定的:0 鼻子、1 左眼、2 右眼、3 左耳、4 右耳、5 左肩、6 右肩、7 左肘、8 右肘、9 左腕、10 右腕、11 左髋、12 右髋、13 左膝、14 右膝、15 左踝、16 右踝。
每个关键点除了坐标,还要带一个可见性标志v:0 表示该点在图像里没有标注出来,1 表示遮挡但位置可推测,2 表示完全可见。训练时只有v>0的点参与损失计算。很多人把v=0的点也丢进去训练,结果就是网络把看不见的关节“平均”到背景中间——训练收敛是收敛了,但推理时只要有遮挡就乱飘。标注工具推荐用 Labelme 或 COCO Annotator,前者导出 JSON 后转格式,后者直接在线协作。
如果你用自己的数据做单人场景,可以直接复用 COCO 的 17 点模板;如果是做判别式的特殊场景(如手部 21 点、面部 68 点),需要在这套流程外面套一层自定义关键点解析。我一般建议新手先从 17 点跑通全流程,再改 keypoint 数量和类别字典,避免一次改动太多变量导致问题定位困难。
3.2 坐标转换与裁剪脚本:从 YOLO 检测标注到关键点训练集
YOLOv5 的标签是归一化的中心坐标[cx, cy, w, h],姿态标注则是绝对像素坐标。这两个坐标系之间的换算关系是数据准备阶段最容易出错的点。下面这段脚本把 YOLOv5 格式的检测框和 COCO 格式的关键点标注合并成一个可以直接训练的姿态训练集:
import json import numpy as np import cv2 import os def convert_coco_to_hrnet(data_root, json_path, out_root): with open(json_path, 'r') as f: coco = json.load(f) img_info = {img['id']: img for img in coco['images']} # 建立图片ID到标注的映射 anns = {} for ann in coco['annotations']: img_id = ann['image_id'] anns.setdefault(img_id, []).append(ann) # 关键点索引:COCO 的 17 点顺序 num_keypoints = 17 for img_id, ann_list in anns.items(): img_meta = img_info[img_id] img_path = os.path.join(data_root, img_meta['file_name']) img = cv2.imread(img_path) h, w = img.shape[:2] for ann in ann_list: # ann['keypoints'] 是 [x1,y1,v1, x2,y2,v2, ...] 的扁平列表 kps = np.array(ann['keypoints']).reshape(-1, 3) kps[:, 0] = np.clip(kps[:, 0], 0, w - 1) kps[:, 1] = np.clip(kps[:, 1], 0, h - 1) # 过滤无用的标注(所有点都是 v=0) if (kps[:, 2] == 0).all(): continue # 归一化到 [0,1] 区间,方便后续统一缩放 kps[:, 0] /= w kps[:, 1] /= h # 保存成训练可直接读取的 npz 格式 save_id = f"{img_id}_{ann['id']}" np.savez_compressed( os.path.join(out_root, f"{save_id}.npz"), image=img_path, keypoints=kps.astype(np.float32), bbox=np.array(ann['bbox'], dtype=np.float32) )这段脚本的要点在最后几行——直接保存原图路径和归一化的关键点坐标,而不是把图片抠出来重新存储。这样训练时每次 epoch 动态裁剪,数据增强的随机性不会被提前固化。注意bbox的保存格式是[x, y, w, h],后续做裁剪时要用它计算左上角和右下角,别用 YOLO 的中心坐标格式来算,否则各差半个框的偏移。
还有一个细节:保存的关键点坐标是归一化的,加载时需要乘以当前图片的实际宽高。如果训练脚本里做的是随机缩放,就要把归一化坐标和缩放系数一起传进仿射矩阵,否则坐标会错位。我的习惯是统一在数据加载函数里做“从归一化到像素”的还原,而不是在保存时就锁定像素值,这样数据增强的灵活性大很多。
3.3 训练数据分布检查:裁剪框比例与关键点可见性统计
训练前必须跑一次数据分布检查,不然训练到一半才发现“所有样本的右手全是v=0”之类的问题,返工成本极高。检查两个指标:每个关键点的可见性占比,以及裁剪后的人体框长宽比分布。
import numpy as np import glob npz_files = glob.glob("your_dataset/*.npz") num_kps = 17 vis_count = np.zeros(num_kps) total_count = 0 aspect_ratios = [] for f in npz_files: data = np.load(f) kps = data["keypoints"] # 形状 [17, 3],第三列是可见性 vis = kps[:, 2] > 0 vis_count += vis.astype(np.int32) total_count += 1 bbox = data["bbox"] # [x, y, w, h] if bbox[2] > 0 and bbox[3] > 0: aspect_ratios.append(bbox[2] / bbox[3]) vis_ratio = vis_count / total_count print("每个关键点的可见性占比:", vis_ratio) print("裁剪框宽高比均值:", np.mean(aspect_ratios))如果某个关键点的可见性占比低于 60%,就要人工检查标注是否漏标了。长宽比均值如果偏离 0.75(COCO 的标准人体框比例)太远,说明你的场景里多为横躺或极宽姿势,这会影响裁剪时 padding 的默认参数。数值本身没有绝对标准,但异常分布要在训练前发现。
3.4 数据增强:旋转、缩放、翻转时的对称点映射
关键点数据增强和大物体检测完全不一样,核心区别在于“对称轴”。水平翻转之后,左肩的 x 坐标镜像到了右肩,如果你直接翻转不交换索引,模型会学到左右互搏:输入左肩位置,网络输出“右肩+左肩混合”的特征。
def augment_keypoints(image, kps, flip_indices): # kps 形状 [17, 3],flip_indices 是翻转后的索引映射 if np.random.rand() > 0.5: image = cv2.flip(image, 1) # 水平翻转 h, w = image.shape[:2] # 翻转x坐标 kps[:, 0] = w - 1 - kps[:, 0] # 重新排列关键点索引 kps = kps[flip_indices] # 随机旋转 [-30, 30] 度 angle = np.random.uniform(-30, 30) M = cv2.getRotationMatrix2D((w // 2, h // 2), angle, 1.0) image = cv2.warpAffine(image, M, (w, h), flags=cv2.INTER_LINEAR) # 旋转坐标 ones = np.ones((kps.shape[0], 1)) kps_homo = np.concatenate([kps[:, :2], ones], axis=1) kps_rot = kps_homo @ M.T kps[:, :2] = kps_rot[:, :2] return image, kpsflip_indices的映射必须和 COCO 索引对齐:左眼和右眼互换、左肩和右肩互换、左肘和右肘互换,鼻子和脖子这类中间点保持不变。右边是严格的对称交换:
flip_indices = [0, 2, 1, 4, 3, 6, 5, 8, 7, 10, 9, 12, 11, 14, 13, 16, 15]增强里还有一个常见误区是旋转角度过大。超过 45 度的旋转会让大多数关键点漂出图片边界,生成大量v=0的伪标注。我的经验是取 [-30, 30] 度即可,如果业务本身有倒地、侧躺等极端姿态,应该在标注层面额外补充样本,而不是靠旋转硬转出来。
数据增强的最后一环是遮挡模拟。用随机矩形把部分区域遮挡掉,再把遮挡区域内的关键点的可见性强制置 0。这步对提升遮挡鲁棒性效果明显,但注意不要和真实场景的遮挡语义冲突——比如你是做健身动作识别,器材遮挡是常有的,AI 模拟遮挡的分布尽量与真实相似。
4. 训练配置与超参调节:让损失曲线按预期掉下来
4.1 官方配置文件里三个必须改的参数
HRnet 的官方仓库里提供的是通用配置,直接拿来训练自己的数据集,十个里有八个精度不合格。必须改的是这三处:
第一处是DATASET.NUM_JOINTS,默认是 17,如果你改了关键点数量这里要同步变更。第二处是LOSS.USE_TARGET_WEIGHT,官方默认 True,含义是只对v>0的像素位置计算损失。如果你希望让网络学会“主动忽略遮挡区域”,保持 True;如果想强行让网络去预测被遮挡的点,就改成 False——但一般不建议后者,这会导致遮挡处的输出不可控。第三处是TRAIN.END_EPOCH,COCO 官方跑 210 个 epoch,但自建数据集通常 80 到 120 个 epoch 就收敛。不要全套照搬,否则最后一阶段的学习率衰减会把模型拉向过拟合。
还有一个容易踩的配置项是TRAIN.LR_SCHEDULER。HRnet 默认用MultiStepLR,在 170 和 200 epoch 各降一次学习率。如果你只训 80 个 epoch,衰减点设在 60 和 75 附近,否则还没降到低学习率的精调阶段训练就结束了,关键点精度会停在次优解。
4.2 训练命令行与冻结权重技巧
python tools/train.py \ --cfg experiments/coco/hrnet/w32_256x192_adam_lr1e-3.yaml \ --modelDir output/models \ --logDir output/logs \ --dataDir /path/to/your/dataset训练命令里的--cfg指向 YAML 配置文件,核心参数都在里面。训练日志会输出每个 epoch 的acc和loss,这里的acc实际计算的是关键点命中率(距离阈值内的关键点比例),不是最终评测的 PCK 或 OKS。如果训练初期acc不涨,先检查数据加载是否正常,再看学习率是否过小。
如果是从头训练自己的姿态数据集,建议先冻结 HRnet 的 stem 和前两个 stage,只训练最后两个 stage 的输出头。做法是把MODEL.PRETRAINED设为官方 ImageNet 权重或 COCO 预训练权重,然后在tools/train.py里加一层requires_grad_(False)冻结逻辑。我的经验是冻结前两阶段时,精度几乎没有损失,但训练速度提升 30% 到 50%,显存占用也明显下降。
我在实际项目里还会调整 batch size 与输入分辨率。256×192 分辨率下 batch size 32 是显存和精度的平衡点;384×288 分辨率的精度高约 2 个 OKS,但推理耗时翻倍。如果显存只有 11GB(比如 2080Ti),batch size 降到 16,同时把WORKERS调到 8 来保证 GPU 利用率不掉。
4.3 训练监控:PCK 与 OKS,别只看一个指标
训练过程中,每个 epoch 结束都会打印验证集的表现,但打印的acc用的是简单阈值命中率,即预测点与标注点之间像素距离小于某个固定阈值就算命中。这个指标有一个致命弱点:它没有把目标大小归一化。同一个 10 像素的偏移,在手掌这种小目标上是严重错误,在躯干这种大目标上可能是可接受的误差。所以训练日志里的acc只能看趋势,不能跨数据集比较。
真正部署前要看的指标是 OKS(Object Keypoint Similarity)。它会按每个关键点的尺度sigma做归一化。计算方式为:
def compute_oks(gt_kps, pred_kps, sigma, bbox_area): # gt_kps 和 pred_kps 形状 [17, 2] dist = np.sqrt(np.sum((gt_kps - pred_kps) ** 2, axis=1)) exp_term = dist ** 2 / (2 * (sigma ** 2) * (bbox_area + 1e-6)) oks = np.exp(-exp_term) return np.mean(oks[gt_kps[:, 2] > 0]) # 只看可见点其中sigma是 COCO 官方给定的每个关键点的归一化标准差,比如手腕的 sigma 是 0.072,肩膀是 0.026。实际评测时会把所有样本的 OKS 汇总,按不同阈值(0.5、0.75)计算 AP。AP@OKS0.75 比 AP@OKS0.5 对关键点位置的精细度要求更高,如果 0.75 上不去,说明模型大体位置对但细节飘,需要提高分辨率或增加训练 epoch。
训练时还要盯一个“非官方”但很实用的信号:预测热力图峰值分布。每 20 个 epoch 抽样 20 张验证图片,把预测的 heatmap 可视化叠加在原图上,如果看到热力图有“双峰”或“弥散到整个关节区域”,说明该关节类别之间存在语义模糊或数据量不足。这个可视化习惯能让你提前发现训练数据的问题,而不是等到部署后被业务方拍回来返工。
5. 避坑指南:姿态估计训练与部署里的5个翻车现场
5.1 现象一:验证损失下降,肢体点全糊在一起
现象:训练损失前 30 个 epoch 正常下降,但可视化输出时,肘部和腕部的热力图糊成一片,所有肢体点挤在躯干中央。
原因:裁剪时 padding 太小。人体框边缘的肢体被切掉了,网络只能在剩余可见区域做平均猜测。尤其当检测框由 YOLOv5 输出时,如果置信度阈值设得太低,框偏紧,上手部位直接被截断。
解决:把裁剪时的 padding 从 1.0 提升到 1.2 到 1.5,确保手腕和脚踝有完整上下文;另一个做法是在检测阶段就把置信度阈值从 0.25 提到 0.4,剔除边界错框,让姿态网络吃到的都是完整的人体。
5.2 现象二:换了自建数据集,精度比 COCO 预训练权重低了 10 个点
现象:用 COCO 上训练的权重直接在自己的数据上测试,效果大跌;微调之后虽然回升,但训练集只有 5000 张,精度始终上不去。
原因:领域漂移。COCO 的图片里人体占比大、背景多样,你的场景如果是鱼眼镜头、摄像头装在角角落落,或拍摄距离极近,空间的分布完全不一样。
解决:不需要重新标注 10 万张。常见做法是收集 2000 张未标注的真实场景图片做伪标注,用现有模型跑出关键点,只保留 OKS 高置信度的结果。这个过程等于让模型自己“挑”出最舒服的样本,再用它们做一次微调。5000 张真值标注加上 2000 张伪标注,在我的项目里把 PCK 从 58 抬到了 69。
5.3 现象三:单张图没问题,视频里姿态“跳”
现象:单帧推理完全正常,但视频模式下同一关节点的坐标在相邻帧之间抖动剧烈,帧率波动越大抖动越明显。
原因:不是网络的错,是检测框的后处理没做平滑。YOLOv5 在少量帧里输出的框会上下浮动一个到两个像素,裁剪框的漂移被 HRnet 放大成关键点的大幅抖动。
解决:用指数移动平均(EMA)对检测框坐标做平滑。设定alpha=0.6,每帧的平滑框坐标为smooth = alpha * current_box + (1 - alpha) * previous_smooth。注意 alpha 太小会导致动作跟不上,太大则滤波不干净。另外在姿态解码阶段对关键点也做一次轻量平滑:
def smooth_pose(curr_kps, prev_kps, alpha=0.6): return alpha * curr_kps + (1 - alpha) * prev_kps这个技巧在实时交互项目(比如动作捕捉驱动数字人)里能直接省掉一整套复杂的滤波管线,先跑通再考虑卡尔曼滤波或 One Euro Filter。
5.4 现象四:树莓派5上部署之后,输出坐标全部错位
现象:PC 端验证一切正常,代码原封不动移到树莓派 5 上,关键点位置偏了 20 到 30 像素,网络结果完全对不上画面内容。
原因:输入分辨率或展缩参数不一致。树莓派端如果通过摄像头采集,图片尺寸和 PC 端测试图片的长宽比不一样,resize 时的 scale 参数没同步更新。另一个常见原因是 padding 的颜色值不一致——OpenCV 的BORDER_CONSTANT用的填充值是标量元组,你写成了 0,但在训练时填充的是 ImageNet 均值的 RGB 元组,模型对背景颜色的统计特征变了。
解决:把预处理单独封装成函数,输入输出都固定形状,在 PC 端和树莓派端共用同一份代码;填充值统一用(128, 128, 128)或训练时的均值,而不是 0。部署前用同一张测试图分别跑 PC 端和树莓派端,对比中间张量每个通道的均值,不一致的地方就是错误源头。
5.5 现象五:关键点数量少,但在实际场景里频繁误检
现象:把模型部署到实际监控场景,每帧都输出 17 个点,但很多点明显不合理——手肘的位置跑到头部侧面去了。
原因:没有做肢体结构校验。模型只针对每个点独立回归,它没有“人体关节连接关系”的先验。当遮挡严重时,单点回归会落到视觉上最像的位置,但整体骨头结构就是不对的。
解决:后处理加一个肢体连通性滤波。用预定义的骨骼连接对(如肩到肘、肘到腕),计算每条骨长的历史均值和方差,当某条骨长偏离超过 2 倍标准差时,把对应末端点按历史骨长的方向拉回合理范围。这个滤波不是纯数学约束,而是把人体结构学常识塞进后处理,开销极小,每帧几百条浮点运算。我在手部姿态项目里用这个手段把关键点的奇异值跳变减少了约 40%。
6. 部署落地:把模型压进树莓派和边缘盒子的具体手法
6.1 ONNX导出与TensorRT加速
模型训练完成后,部署的第一步是把 PyTorch 权重转成 ONNX,再根据硬件选择推理引擎。常见做法是先把 HRnet 和 YOLOv5 分别导出:
# YOLOv5 导出 ONNX python export.py --weights yolov5s.pt --include onnx --img 640 # HRnet 导出 ONNX python tools/pytorch2onnx.py \ --cfg experiments/coco/hrnet/w32_256x192_adam_lr1e-3.yaml \ --checkpoint output/models/pose_best.pth \ --output hrnet_w32.onnxONNX 导出时有两个参数要特别注意:--dynamic-axes如果开启,会让 batch 维度和宽高维度可变,这种动态 shape 在 TensorRT 里会走优化不佳的 fallback。我的做法是固化成固定[1, 3, 256, 192]输入,换用批处理的方式解决并发需求。另外 HRnet 的最后一层是取 heatmap 最大值坐标的argmax,这个算子在不同推理引擎里的实现有细微差别。Torch 的argmax在并列最大值时取第一个,ONNX Runtime 的 TopK 可能取最后一个,导致输出坐标偏一个像素。解决方法是把argmax+soft-argmax的偏移计算放在 ONNX 图外,在 Python 或 C++ 后处理里显式做坐标反算。
如果目标平台是树莓派 5,TensorRT 不适用(那是 NVIDIA GPU 专属),推荐用 ONNX Runtime 的 CPU 推理后端,或把模型转成 NCNN 格式。NCNN 对 ARM 平台的 NEON 指令优化明显,实测 256×192 的 HRnet-W32 在树莓派 5 上单推理约 150ms,YOLOv5s 约 200ms,整体帧率能到 3 到 5 FPS。这个速度做实时姿态演示有点吃力,但做离线采集分析完全够用。
6.2 后处理归一化:一个容易被忽略但决定毫秒级差异的步骤
部署时最容易翻车的不是网络推理,而是后处理的坐标反算。YOLOv5 输出的检测框坐标是相对于 640×640 输入图的,而关键点坐标是相对于裁剪图 256×192 的。两层坐标叠加,必须保证“反算”顺序完全一致。
def decode_pose(det_boxes, kps_heatmaps, orig_shape): # det_boxes: [N, 4] 原图中的检测框 # kps_heatmaps: [N, 17, 64, 48] HRnet 输出的原始 heatmap all_kps = [] for i, box in enumerate(det_boxes): x1, y1, x2, y2 = box w, h = x2 - x1, y2 - y1 # 裁剪框带 padding 恢复到原图坐标 pad_x = w * 0.2 pad_y = h * 0.2 crop_x1, crop_y1 = x1 - pad_x, y1 - pad_y crop_w, crop_h = w + 2 * pad_x, h + 2 * pad_y hm = kps_heatmaps[i] # [17, 64, 48] kps = [] for j in range(17): h_idx, w_idx = np.unravel_index(np.argmax(hm[j]), hm[j].shape) # 关键点从 heatmap 坐标系映射到裁剪图 256x192 scale_x = 256 / 48 scale_y = 192 / 64 kp_x = (w_idx + 0.5) * scale_x kp_y = (h_idx + 0.5) * scale_y # 再反向映射到原图 orig_x = crop_x1 + kp_x / 256 * crop_w orig_y = crop_y1 + kp_y / 192 * crop_h kps.append([orig_x, orig_y]) all_kps.append(np.array(kps)) return all_kps这段代码里最容易被忽略的是+0.5的偏移修正。heatmap 的每个像素代表一个响应区域的中心,而不是响应区域左上角。如果直接用整数值索引乘上缩放比,所有关键点会系统性偏移半个像素。批量场景下伤害不大,但在精细动作捕捉里这个误差累计起来就非常明显。在编码时也提醒你一点:如果用了 soft-argmax 做亚像素细化,把偏移量加到这个+0.5之后,别加在索引之前。
6.3 端到端验证:用固定图片和标签跑一次回归测试
部署以后不能靠肉眼看了算完事,要有一组固定的回归测试集。我的习惯是挑 20 张覆盖不同场景的测试图,每张图人工标注关键点,写一个自动化脚本把整条链路跑一遍,输出平均 PCK 和 OKS,再和历史版本对比,任何一次代码修改若导致指标跌破阈值就告警。
def regression_test(model_path, test_dir, threshold=0.65): # 加载模型、预处理、推理、解码、评测 avg_oks = evaluate(model_path, test_dir) if avg_oks < threshold: raise RuntimeError(f"OKS {avg_oks:.3f} 低于阈值 {threshold}") return avg_oks这组回归测试不只是检验功能正确性,更是在每次更新模型权重、改预处理参数、切换推理后端时,快速判断改动是改善还是回退。我见过太多项目在部署阶段反复调“深度学习模型参数”,结果问题出在图像缩放的插值算法上——PyTorch 默认用的是双线性,ONNX Runtime 在 CPU 上可能跑成最近邻,如果回归测试能在切换后端的第一时间抓手这个问题,能省下大量排查时间。
最后说一个习惯:部署版本确定后,把整条预处理和后处理的参数全部固化到一个 JSON 文件(输入尺寸、padding 比例、填充像素值、缩放系数、阈值),与模型文件一起归档。每次部署新设备,先跑一次回归测试再交付。这个习惯帮我避开了至少三次“换了 Python 版本之后图像数组的 dtype 从 float32 变成 float64 导致坐标验算错位”的坑。希望帮到你。
本文还有配套的精品资源,点击获取