CNN人体姿态估计与动作识别:从关键点到热图回归的工程实践
2026/9/10 3:48:20 网站建设 项目流程

简介:基于CNN的人体姿态与动作识别解决方案,以Python源码和配套文档打包呈现,面向计算机视觉初学者、动作识别项目开发者及课程设计人群,适合作为从零理解姿态估计与动作分类的练习实例。压缩包共6个文件,主体为5个Python脚本,另含1个Markdown说明文档;脚本分别对应骨骼点检测、数据采集、CNN训练、模型测试及主程序入口,功能划分清晰。实现上,PoseDetector.py调用mediapipe提取并绘制人体骨骼点,GetActionData.py保存训练图像,TrainModel.py构建卷积神经网络训练流程,ModelTest.py执行模型评估,main.py串联整个识别流程,各模块职责明确、易于改造。资源包整体约7KB,已有470人学习下载,对希望快速上手姿态识别、理解CNN动作分类原理的读者,具备不错的参考价值。

1. 从一张图到一组关键点:CNN 姿态估计真正做的事

光看标题,很多人第一反应是把图片直接分类成“走、跑、跳、招手”,但基于 CNN 的人体姿态和动作识别项目里,第一段管线输出的根本不是动作标签,而是 17 个骨骼关键点的坐标。动作判断是建立在关键点序列之上的第二段任务。先让 CNN 用热图回归(heatmap regression)把骨骼点找准,再对连续帧关键点做时序分析,是目前从源码到论文最常见的落地方案,这也是这个打包项目里最值得读的部分:一个能训练的姿态估计模型,一条从关键点到动作分类的链路。适合有 Python 基础、手头有一批视频或图片、想做演示效果或完成课程设计的人。读完能清楚知道要改哪些配置、参数在哪里调整、训练跑不动先查什么。

2. 数据与标注:人体关键点数据集和热图标签的生成方法

拿到标题里这个压缩包,第一步不是解压后双击训练脚本,而是先看数据代码里用的是哪种关节定义。关节编号一旦错位,损失函数照跑,画出来的骨架是乱的。

2.1 数据集选型:COCO 17 点与 MPII 的区别

人体姿态数据集最常用的两种标注格式是 MPII 和 COCO。MPII 定义 16 个关键点,不区分左右耳与左右眼,也没有鼻子到两眼中心这条中线;COCO 定义 17 个关键点,编号从 0 到 16,顺序为鼻子、左眼、右眼、左耳、右耳、左肩、右肩、左肘、右肘、左腕、右腕、左髋、右髋、左膝、右膝、左踝、右踝。两者的连接关系不同,迁移预训练模型权重时,最后输出层的 17 或 16 通道要按编号重新映射。

如果项目说明文档里写了“COCO 格式”,那么训练标签的目录结构通常是每张图片配一个 JSON 文件,里面包含keypoints数组,长度是 17 乘 3,三个值分别是 x、y、可见性。可见性为 0 表示该点未标注,训练时要通过热图权重掩码把它剔除,否则模型会拼命去学习预测一个不存在的点。

下载好的公开数据集往往是原图分辨率,例如 COCO 原始图片多数在 640x480 以上。模型输入一般取 224x224 或 256x192,原图坐标在送入网络前要按比例缩放到热图尺寸。这个缩放过程如果写在数据加载器里,要确保用的是同一个缩放因子,因为关键点坐标和热图尺寸是对应关系,任何一边单独缩放都会让标签错位。

2.2 高斯热图生成:把坐标变成响应面

姿态估计模型不直接回归坐标数值,而是让网络去预测一张热图。热图的长宽是输入图的四分之一,比如 224x224 的输入对应 56x56 的热图。每个关键点在高斯核中心处响应最大,离中心越远响应衰减越快。这样模型学到一个连续响应面,而不是一个离散的坐标点,训练更容易收敛。

常规做法是在数据预处理阶段把关键点坐标转换成多通道热图,通道数与关键点数相同。以下是完整生成函数:

import numpy as np def make_gaussian_heatmap(heatmap_size, center, sigma=2.0): h, w = heatmap_size cx, cy = center yy, xx = np.mgrid[0:h, 0:w].astype(np.float32) dist2 = (xx - cx) ** 2 + (yy - cy) ** 2 heat = np.exp(-dist2 / (2.0 * sigma * sigma)) # 超出图像边界的点直接置零 if not (0 <= cx < w and 0 <= cy < h): heat = np.zeros((h, w), dtype=np.float32) return heat

这个函数的输入center是热图坐标系下的关键点坐标,不是原图像素坐标。从原图坐标换算过来时,需要把 x 和 y 同时乘以热图尺寸与输入尺寸的比值。sigma控制高斯核的宽度,在 56x56 热图上取 2.0 是一个常见起点;sigma 太大会让相邻关键点的响应面互相重叠,太小则监督信号过于稀疏,背景区域全是零,模型容易过拟合。

实际项目中很少直接循环调用上面的函数,因为 Python 循环生成 17 张热图再拼接会拖慢数据加载。常见做法是把单张热图生成改为批量矩阵操作:先构建一个形状为(batch, 17, h, w)的零张量,用高斯核公式整批填充,再送入网络。

2.3 数据增强:平移、旋转和翻转的顺序

人体姿态数据增强与图像分类最大的区别是:图片做几何变换时,关键点坐标也必须同步变换。最常见的增强操作是随机旋转、随机缩放、水平翻转和随机平移。顺序上建议先做缩放和旋转,再做平移,最后做水平翻转,因为翻转只对 x 坐标生效,y 坐标不变。

import cv2 import numpy as np def augment_image_and_joints(image, joints, rot=30, scale_range=(0.8, 1.2)): h, w = image.shape[:2] scale = np.random.uniform(*scale_range) angle = np.random.uniform(-rot, rot) # 旋转围绕图像中心,做一个相似变换矩阵 center = (w / 2, h / 2) mat = cv2.getRotationMatrix2D(center, angle, scale) img_out = cv2.warpAffine(image, mat, (w, h), borderMode=cv2.BORDER_CONSTANT) # 关键点是二维齐次坐标,补一维 1 才能做仿射变换 ones = np.ones((joints.shape[0], 1), dtype=np.float32) pts = np.concatenate([joints[:, :2], ones], axis=1) mapped = mat.dot(pts.T).T # 水平翻转,注意左右关节编号要交换 if np.random.rand() > 0.5: flip_pairs = [(1, 2), (3, 4), (5, 6), (7, 8), (9, 10), (11, 12), (13, 14), (15, 16)] img_out = cv2.flip(img_out, 1) mapped[:, 0] = w - mapped[:, 0] for l, r in flip_pairs: mapped[[l, r]] = mapped[[r, l]] return img_out, mapped

旋转和缩放共用一个变换矩阵,因此只用一次矩阵乘法就能同时处理所有关键点,不需要对每个点单独调用cv2.transform。水平翻转必须同步交换左右对称关节的编号,否则模型会学到“左手在画面右侧”的错误先验。注意随机平移应放在翻转之前做,平移矩阵在翻转坐标后不再适用。

3. 模型搭建与训练:ResNet 主干加反卷积头的 CNN 配置

这个项目的模型部分不会太花哨。论文里大量使用 Hourglass、HRNet,但对于一个带源码和说明文档的工程来说,ResNet 主干加三层反卷积是最常见、最容易复现、训练时最不容易出问题的组合。

3.1 主干网与检测头的组合逻辑

采用预训练的 ResNet-50 作为特征提取器,去掉最后的全连接分类层,只在最后一个卷积输出之后接三层反卷积。这样做的好处是主干参数有 ImageNet 预训练权重,训练初期就能提供有意义的特征,而反卷积头可以随机初始化,随着训练慢慢学习如何把语义特征还原成空间热图。

import torch.nn as nn class PoseCNN(nn.Module): def __init__(self, backbone, num_joints=17, deconv_dim=256): super().__init__() self.backbone = backbone # ResNet50,输出 2048 通道特征图 self.deconv = nn.Sequential( nn.ConvTranspose2d(2048, deconv_dim, kernel_size=4, stride=2, padding=1, bias=False), nn.BatchNorm2d(deconv_dim), nn.ReLU(inplace=True), nn.ConvTranspose2d(deconv_dim, deconv_dim, kernel_size=4, stride=2, padding=1, bias=False), nn.BatchNorm2d(deconv_dim), nn.ReLU(inplace=True), nn.ConvTranspose2d(deconv_dim, deconv_dim, kernel_size=4, stride=2, padding=1, bias=False), nn.BatchNorm2d(deconv_dim), nn.ReLU(inplace=True), ) self.out_conv = nn.Conv2d(deconv_dim, num_joints, kernel_size=1) def forward(self, x): feat = self.backbone(x) out = self.deconv(feat) return self.out_conv(out)

ConvTranspose2dstride=2让输出尺寸翻倍,kernel_size=4, padding=1保证尺寸精确翻倍且不出现棋盘效应。三层反卷积把 7x7 的特征图逐步放大到 56x56,正好是 224x224 输入的四分之一。最后一层1x1卷积不改空间尺寸,只改变通道数到关键点数量,每个通道对应一个关节点的热图。

这里有个经常被忽略的参数:反卷积输出通道数deconv_dim取 256 是显存与精度的折中。把它调到 512 精度略微提升,但显存占用几乎翻倍;对课程设计或小 demo 来说 256 已经足够。

3.2 损失函数与训练循环

训练时损失函数直接施加在热图张量上,常用的有两种:MSE 和带权重掩码的 MSE。由于标注中部分关键点不可见,需要为每个样本生成一个权重张量,可见点的权重为 1,不可见点的权重为 0。

criterion = nn.MSELoss(reduction='none') optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) model.train() for epoch in range(cfg.epochs): for images, heatmaps, vis_mask in train_loader: preds = model(images) # 逐样本逐关键点乘权重,再求平均 loss = criterion(preds, heatmaps) loss = loss * vis_mask loss = loss.mean() optimizer.zero_grad() loss.backward() optimizer.step()

vis_mask的形状是(batch, num_joints, 1, 1),通过广播机制作用于每个关键点的整张热图。该设计的核心在于:不可见关键点的热图标签全部为零,如果不加权重,模型会把所有未标注点都预测成背景,导致后续可见点的注意力被稀释。

计算损失时不要先解码坐标再算距离,那样梯度只能传回坐标,无法充分利用热图上的空间平滑性。直接在热图上算 MSE,模型会对局部小偏移产生连续性惩罚,收敛更平稳。

3.3 训练参数:先照抄再微调

训练配置建议把需要改的参数集中放在一个config.py中,而不是散落在训练脚本各处。以下是一份常见配置表,可以直接照抄:

参数取值说明
input_size224x224输入图片裁剪尺寸
heatmap_size56x56热图尺寸,输入的四分之一
sigma2.0高斯核宽度
batch_size16单卡不够时降到 8
learning_rate1e-3Adam 的初始学习率
lr_decay0.1每 30 个 epoch 衰减一次
epochs60小数据集建议 30-40 即停
主干resnet50用 ImageNet 预训练权重

batch_sizelearning_rate是相对锁死的两个参数。使用 Adam 时,batch_size 降到一半,学习率也应跟着减半,否则边界像素的梯度会抖动。如果显存只能跑 batch 4,初始学习率建议降到 2.5e-4 左右,并关闭主干网络前两层的梯度,只训练反卷积层和 ResNet 最后一层,这样训练速度和稳定性都能接受。

损失曲线正常收敛的标志是前 5 个 epoch 内 loss 从 2 左右降到一个明显下降的拐点,之后进入缓慢下降通道。如果 loss 一直停在零点几附近不变化,先检查热图生成代码,把标签可视化出来,看关键点高斯核是否真的落在目标位置。

4. 推理与可视化:从热图解码坐标并绘制骨架

网络输出的是一个(17, 56, 56)的热图张量,要和最终画在图上的人体骨架连线对接,必须把热图解码成像素坐标。这部分代码虽然短,却是定位精度高低的分水岭。

4.1 热图解码:argmax 之外的亚像素精度

最简单的解码方式是直接取热图最大响应点的整数坐标,np.unravel_index(np.argmax(heat), heat.shape)。这样做在检测精度达到熟练水平后,会出现明显的量化误差,因为热图尺寸只有 56x56,一个像素的误差对应原图约 4 个像素,骨架会看到明显的锯齿抖动。

一个低成本改进是用最大响应区域内的一阶矩做加权修正,等价于把峰位置从整数坐标推进到亚像素精度:

def decode_heatmap(heat, scale_x, scale_y): k, h, w = heat.shape joints = [] for kk in range(k): resp = heat[kk] idx = np.argmax(resp) y, x = np.unravel_index(idx, (h, w)) # 只有在峰值离边界至少 1 像素时才做修正 if 1 < x < w - 2 and 1 < y < h - 2: patch = resp[y-1:y+2, x-1:x+2] xs = np.arange(-1, 2) ys = np.arange(-1, 2) xx, yy = np.meshgrid(xs, ys) gx = x + np.sum(patch * xx) / np.sum(patch) gy = y + np.sum(patch * yy) / np.sum(patch) else: gx, gy = x, y joints.append((gx * scale_x, gy * scale_y)) return joints

这段代码的直观理解是用峰值周围 3x3 邻域的响应值做加权平均,响应强的方向会把估计结果向那边拉扯。注意np.sum(patch)不会是零,因为峰值点自身响应必然大于零。关键是scale_xscale_y必须分别计算:原图宽除以热图宽、原图高除以热图高,不能混用一个值。

边界条件别忽略。峰值落在热图边缘时,邻域会越界,使用resp[y-1:y+2, x-1:x+2]取出的 patch 尺寸不完整,此时直接返回整数坐标是最稳妥的做法,不要强行做加权平均。

4.2 绘制骨架:边连接表与画线逻辑

得到关键点坐标后,需要按关节连接关系画线。COCO 17 点骨架的连接边是一条固定的数组,每个元素是一对关节编号:

SKELETON = [ (0, 1), (0, 2), (1, 3), (2, 4), (0, 5), (0, 6), (5, 7), (7, 9), (6, 8), (8, 10), (5, 6), (5, 11), (6, 12), (11, 12), (11, 13), (13, 15), (12, 14), (14, 16) ] def draw_skeleton(image, joints): for a, b in SKELETON: if joints[a][2] > 0.3 and joints[b][2] > 0.3: cv2.line(image, (int(joints[a][0]), int(joints[a][1])), (int(joints[b][0]), int(joints[b][1])), (0, 255, 0), 2) return image

joints[a][2]是来自模型输出的置信度分数,一般由热图最大响应值归一化到 0-1 得到。画线时用阈值过滤低置信度关键点能有效避免骨架飞出画面这些情况。两条不同的连接边如果共用同一个端点,直接重复绘制即可,不需要特殊处理。注意连接边顺序不要写错,比如把左肩连接到右肘,画出来手臂会交叉成一个不自然的形状,这是检查代码时最先要排除的问题。

4.3 一个常见坑:单人模型在多人场景的表现

标题里的项目和大多数开源的姿态估计 demo 一样,训练时假设画面里只有一个人。模型用单人的检测框裁剪图片作为输入,如果直接对多人合影做大图推理,模型会试图用一个骨架去拟合多个人的局部特征,结果往往把两个人的肢体拼接到同一条骨架线上。

处理多人场景有两条成熟路径。一条是自顶向下:先用目标检测模型找出每个人的框,再对每个框单独裁剪并送入姿态模型;另一条是自底向上:先用热图找所有关键点,再匹配聚类成个体。对 5 年以上工程经验的人来说,自顶向下方案改动量最小,因为可以直接复用现有的检测模型权重,不需要重新训练姿态模型。只需要把目标检测框按固定长宽比扩展一下,扩大到 1.25 倍,避免切掉四肢。

如果只是做演示,不必上多人方案,直接用 OpenCV 的人脸检测器确定主体位置,或者使用视频流场景中画面里只有一人的设定,把项目范围控制住。

5. 动作识别:把关键点序列变成行为标签

姿态估计解决的是“关节在哪”的问题,动作识别要解决“人在做什么”。这一步有两种常见做法,要不要引入时序模型取决于你的数据量。

5.1 规则法:用关节角度和时间阈值判动作

不需要训练模型的方案是对每个关键点计算关节角度,例如肘关节角度由肩、肘、腕三点构成。角度值比坐标更稳健,因为它是旋转不变的。

def angle_between(p1, p2, p3): v1 = p1 - p2 v2 = p3 - p2 cos_theta = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + 1e-6) return np.degrees(np.arccos(np.clip(cos_theta, -1.0, 1.0)))

判断“举手”动作的逻辑是:腕关节的 y 坐标持续高于肩关节 y 坐标,并且肘关节角度大于 160 度,连续 5 帧满足才判定动作命中。时间窗口能剔除单帧抖动带来的误判。这种方法的优点是可解释性强,出现误判能直接看到是哪条规则被满足或违背。

5.2 时序模型:LSTM 特征输入与窗口长度

当动作种类多且存在相似姿态时,需要用数据驱动的方式。最常见的是把每帧的 17 个关键点坐标拼成特征向量,形状为(17, 3)表示 x、y、置信度,送入一个两层 LSTM 或一维卷积网络做分类。

这里有一个比网络结构更重要的细节:输入特征要做归一化。直接把图像坐标输入 LSTM,会因画面中人体大小和位置不同导致特征分布漂移。我一般在预处理里做两步处理:先以髋关节中点作为锚点,把所有关键点坐标减去锚点坐标,再除以肩宽做尺度归一化。这样同一个动作不管人物在画面左侧还是右侧、离镜头远还是近,特征向量都是稳定的。

如果样本量只有几百段,与其费时间训练分类网络,不如先尝试把归一化后的关键点序列差分后直接喂给一个随机森林分类器,效果经常能追平 LSTM,而且训练时间从小时级降到秒级。最后一招:把窗口长度从 25 帧往 15 帧降,同时观察每个动作类别的召回率变化,很多时候窗口过长反而把相邻动作边界磨平了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询