做人体姿态估计的项目久了你会发现一个挺有意思的现象很多人在跑通 Stacked Hourglass 之后第一反应是“哦就是把多个沙漏模块串起来”但真到调优的时候就懵了——为什么堆叠数量增加精度提升却不成比例为什么中间监督一去掉训练曲线立刻开始抖还有明明模型预测的热图看着不错一算最终误差却总差那么零点几个像素这一篇作为“人体姿态估计Stacked Hourglass”系列的第四篇我不想再复述一遍网络结构图了。前三篇我们把整体框架、数据准备、训练流程都过了一遍这篇专门集中在我自己复现和调优过程中收获最深的几个点上堆叠为什么能带来质变、中间监督的真实作用、残差模块里的维度细节、热图后处理里那些容易被忽略的扣分项以及为了部署我做的轻量化尝试。内容更适合已经把这个网络跑通、正在为精度和资源占用头疼的朋友。下面的数据和现象来自我最近几个月在某个常用人体姿态数据集上的复现实验不同机器、不同随机种子下数字会有浮动但规律和思路是通用的。1. 为什么从单模块到堆叠效果会有质的提升这一节想先把原理讲透。很多人把 Stacked Hourglass 理解成“把多个网络串起来加深”这个理解不能算错但漏掉了最关键的一点堆叠不是在单纯地增加深度而是在构造一个“先粗看、再细看、反复修正”的迭代过程。1.1 单模块的局限上下采样一次远远不够先说单个 Hourglass。它的结构是一个完整的编码-解码过程图像先逐级下采样缩小提取越来越抽象的特征然后逐级上采样恢复到原分辨率中间用跳跃连接把同尺度的特征拼回来。这个结构确实能让网络拥有较大的感受野输出对全身关节都有响应的热图。但问题在于一次上下采样意味着网络只“看”了输入图像一遍。在比较简单的姿态场景下够用可一旦遇到遮挡、肢体自重叠、低对比度这类情况单靠一轮特征提取就很难定位准了。我打个比方你要在一张照片里找一个人的手腕。第一次扫过去你可能只敢说“大概在右下角这块区域”如果允许你再仔细看一遍你自然会聚焦到刚才那个模糊区域附近结合手臂走向、袖子边缘、前后遮挡关系重新判断。单 Hourglass 等于只允许看一遍而堆叠结构等于给了网络这种“回头再确认”的机会。从特征层面来说单模块的问题在于网络把特征压缩到低分辨率时空间细节已经损失了一部分上采样只能做插值式的恢复丢失的信息找不回来。所以它的定位精度存在天花板不是训练时间能解决的。1.2 堆叠如何变成“迭代精修”机制Stacked Hourglass 是把多个 Hourglass 模块首尾相接前一个模块输出的热图会作为后一个模块的输入之一参与计算。这样一来网络在第二个、第三个模块里相当于拿着第一轮估计的粗略结果在对应位置附近重新提取特征做修正。这个机制很像我们平时做目标检测里常见的“由粗到精”思路——先做一次粗略定位再在感兴趣区域做精细化回归。只不过在 Stacked Hourglass 里这个过程被直接建模进了网络结构而不是依靠外部后处理。每个 Hourglass 模块的输入里既包含原始图像经过浅层卷积得到的特征也包含上一个模块输出的中间热图。热图本身带有空间位置信息所以下一个模块能知道“上一次我大概认为手腕在哪”从而有意识地调整注意力。实现这种迭代精修还有一个前提残差连接。每个模块内部深层的部分都有 skip connection使得整个堆叠网络在结构上是残差式的几十层堆下来不会出现明显的退化问题。这也是为什么 Stacked Hourglass 能堆到 4 个甚至 8 个模块而不会像普通直连网络那样难以训练。1.3 中间监督为什么不能省这里想重点说一下中间监督。每个 Hourglass 模块都会输出一张中间热图并且这张热图会直接参与损失计算不是只有最后一个模块的输出算 loss。有些同学自己实现时会觉得“反正最后有输出中间层的 loss 不是多余吗”实际上中间监督至少有三个作用。第一个作用是缓解梯度传导问题。堆叠网络很深如果只在最后加 loss浅层模块的梯度需要穿过后面所有模块路径太长信号容易衰减。中间监督让每个模块都能直接收到损失信号训练效率明显更高。我做对比实验时去掉中间监督后平均误差直接上升了 1 到 2 个点而且训练前期的收敛速度明显变慢。第二个作用是给后续模块提供“先验”。中间热图作为输入传给下一个模块时它的数值分布会告诉网络上一阶段我对每个位置的置信度是多少。这比我刚才说的“粗略定位”更进一步——它是带置信度的定位低置信度区域在下一轮会被重点关注。第三个作用算是意外收获。中间监督产生的中间热图其实可以拿来分析网络在哪个阶段开始犯错误。如果第一层的热图就已经把左右关节搞混了那问题通常出在数据或预处理如果第一层还行、后面越修越偏那才要考虑堆叠层数或训练策略的问题。2. 堆叠深度与中间监督的三组对比实验这一节直接放实验数据。我自己在确定本项目最终模型时专门对堆叠数量做了控制变量对比所有实验共用同一份数据集划分、同一套数据增强、同样的优化器配置。2.1 实验配置说明先说清楚环境方便大家参考。输入图像统一缩放到 256×256输出热图分辨率 64×64。优化器用 Adam初始学习率 0.001每训练 60 个 epoch 衰减一次。总共训练 200 个 epochbatch size 16。数据增强包含随机旋转、缩放、水平翻转。显卡就是比较常见的一块消费级 GPU显存 12G。数据集用的是某个公开单人姿态基准我按标准方式划分了训练和验证评价指标用关键点平均误差和正确关键点比例。2.2 堆叠数量对精度和资源的影响堆叠模块数平均误差像素正确关键点比例参数量单个 epoch 训练耗时单张推理耗时18.720.812约 12M约 4 分钟约 35ms27.150.856约 24M约 7 分钟约 60ms46.280.883约 48M约 13 分钟约 115ms85.960.892约 96M约 25 分钟约 210ms从这个表能看出几个关键信息。从 1 个模块增加到 2 个模块正确关键点比例提升了约 4.4 个百分点收益非常显著。从 2 个增加到 4 个又提升了约 2.7 个百分点依然划算。但再往上加到 8 个提升陡然缩小到 0.9 个百分点参数却翻了一倍。我个人的结论是4 个模块是性价比最高的组合也是原论文作者最终采用的配置。有资源、对精度要求极高的情况下可以试试 8 个但你要做好准备迎接训练时间翻倍、部署时显存吃紧这些代价。如果是做实时应用2 个模块是不错的起点。2.3 中间监督的有无对比我还专门做了一组开关实验固定堆叠数为 4一组保留所有中间监督另一组只保留最后的输出计算 loss其余配置完全一致。结果差异非常明显。保留中间监督的那组最终平均误差约 6.28 像素去掉中间监督后平均误差跌到 7.52 像素左右。正确关键点比例也从 0.883 降到 0.861。更直观的差别出现在训练曲线上——去掉中间监督后验证集误差在前 50 个 epoch 内波动特别大经常出现明显回弹保留中间监督的训练过程则平稳得多。这个现象说明中间监督在 Stacked Hourglass 里不是“锦上添花”而是“结构必需品”。它保证了浅层模块能稳定地学习到有效的特征表达否则堆叠得再深前半部分也是瞎忙。2.4 怎么根据场景选堆叠数如果你的任务场景比较简单比如摄像头角度固定、遮挡较少那 2 个模块足够速度快、显存占用低。如果场景复杂需要应付各种自遮挡、多人交错、低分辨率情况建议直接上 4 个。8 个模块我只有在评测指标、冲分数的时候才会开正式项目里基本不用。原因很简单精度提升不到 1 个点但推理延迟翻倍部署时很难跟业务方交代。3. 残差模块里被低估的 1×1 升维细节很多初学同学看 Stacked Hourglass 的网络结构图注意力都被沙漏形状和下采样倍数吸引走了对内部的残差模块反而一笔带过。但我在调参过程中发现残差模块里的通道配置和 1×1 卷积对最终精度的影响比预想中大得多。3.1 残差模块的具体结构项目里最常用的基础残差块内部结构是一个 1×1 卷积先调整通道数接着是 3×3 卷积提取空间特征最后又是一个 1×1 卷积恢复或调整通道数。每个卷积后面都跟着 BN 和 ReLU。这和常见的瓶颈结构有点相似但注意在标准 Stacked Hourglass 里hourglass 内部的通道数全程是统一的。也就是说1×1 卷积并不是为了降低计算量而是为了做通道间的信息混合同时给 3×3 卷积提供更合适的输入通道。1×1 卷积的好处是它只改变通道维度不改变空间分辨率。这非常适合 Hourglass 内部对不同尺度特征做统一处理的需求。你在浅层特征图上有 256 个通道在下采样后的低分辨率特征图上也是 256 个通道尺度变了但通道语义可以对齐。3.2 通道数配置的实测对比我把基础通道数分别设为 128、256、512固定堆叠数为 4做了三组实验。基础通道数平均误差像素参数量单个 epoch 训练耗时推理耗时1287.31约 12M约 5 分钟约 45ms2566.28约 48M约 13 分钟约 115ms5125.91约 192M约 40 分钟约 330ms从 128 升到 256平均误差减少了 1 个像素这个提升相当可观。但从 256 升到 512误差只减少了 0.37 个像素参数却翻了四倍训练时间和推理时间都成了完全不同的量级。综合来看256 通道是多数场景下的甜点值。如果你的运行环境极端受限、必须追求实时性128 通道也能用但要做好在遮挡场景下误差明显增大的心理准备。3.3 修改通道数时的隐藏坑点动手改通道数时有几个细节容易踩坑。第一个是 shortcut 的维度匹配。Hourglass 内部的下采样过程里跳跃连接遇到尺寸减半、通道数保持一致的情况。但如果你自定义修改了通道配置跳跃连接的输出和主干输出的通道数必须严格一致否则拼接或相加时直接报错。第二个是下采样时的 1×1 卷积步长设置。如果你把某个阶段的 stride 从 1 改成 2一定要确认 shortcut 分支也同样做了 stride 为 2 的下采样否则空间分辨率对不上。第三个不太起眼但很实际输出层生成热图的那个卷积我复现时选择不加偏置。原因是热图数值应该只代表该位置的响应强度加了偏置会引入全局偏移影响峰值位置的相对关系。很多开源实现会把biasFalse写在最后一层不是没有原因的。4. 热图后处理里最容易扣分的一环模型输出的是一张 64×64 的热图但最终要返回的是原图坐标系下的关节坐标。这一步做得好不好直接决定最终指标。我在项目前期吃过不少亏明明热图峰值位置看着很准最终误差却总是偏高排查了很久才发现问题出在后处理。4.1 从 argmax 到三分支加权平均最简单的做法是直接对热图取最大值坐标也就是 argmax。这种做法的问题很明显热图的分辨率只有 64×64而输入图像是 256×256一个像素的误差换算到原图就是 4 个像素。更稳的做法是用峰值附近 3×3 邻域内的响应值做加权平均相当于“软化的 argmax”。具体思路是找到最大响应位置后把周围 3×3 范围内每个点的响应值作为权重对这些点的坐标做加权和最后得到亚像素级别的坐标估计。我用一个简单例子说明。假设热图最大值位于坐标 (32, 40)其周围 3×3 邻域内各点的响应值各有高低。把所有响应值相加作为分母每个点的坐标乘以对应响应值再求和作为分子算出来的坐标通常不会正好是整数而是落在 31.4 或 40.7 这种带小数的位置。这个结果比直接取整要准确得多。这一步在我的验证集上平均误差下降了大约 0.4 个像素属于纯赚的优化。4.2 多尺度与翻转融合的收益后处理里提升最大的操作是水平翻转融合。做法很简单把输入图像水平翻转一次再跑一遍模型得到翻转后的热图把这个热图水平翻转回来和原始热图逐像素取平均再用平均后的热图去计算坐标。需要注意翻转后关节点的左右顺序必须交换。比如“左肩”变成了图像的右侧位置热图翻转回来后通道的顺序也要做对应调整。如果不做这一步左右肩膀、左右手腕这些成对关节会被网络搞糊涂误差不降反增。多尺度融合的思路类似。把图像分别缩放到 0.75 倍、1.0 倍、1.25 倍分别预测后把热图插值回统一尺寸再取平均。这个操作能略微提升精度但代价是推理时间成倍增加我一般只在评测阶段使用。以下是我在项目里使用的简化版后处理逻辑供参考import numpy as np def get_preds_from_heatmap(heatmap): # heatmap: [num_joints, h, w] num_joints, h, w heatmap.shape coords np.zeros((num_joints, 2), dtypenp.float32) for i in range(num_joints): hm heatmap[i] idx np.unravel_index(np.argmax(hm), hm.shape) y, x idx if 0 y h - 1 and 0 x w - 1: x (hm[y, x-1] * (x-1) hm[y, x] * x hm[y, x1] * (x1)) / (hm[y, x-1] hm[y, x] hm[y, x1]) y (hm[y-1, x] * (y-1) hm[y, x] * y hm[y1, x] * (y1)) / (hm[y-1, x] hm[y, x] hm[y1, x]) coords[i] [x, y] return coords注意这段代码只是主干的示意实际使用时还需要把坐标从 64×64 热图缩放回原图尺寸并考虑翻转融合时的通道映射。4.3 可见性判断和阈值设定最后一个容易被忽视的细节不是每个关键点在每张图上都是可见的。当某个关节点被遮挡时网络输出的热图往往是平坦的峰值很弱甚至出现多个相近的峰。这时直接取坐标不仅这个点本身是错的还会拖累整体指标。正确的做法是给每个关键点设置一个最小响应阈值。热图最大值低于阈值时把这个点标记为不可见不参与误差计算。阈值取多少需要根据验证集调整我在实践中发现 0.1 到 0.2 之间比较稳妥。还可以看一个辅助指标最大响应值与周围区域平均响应值的比值。如果比值接近 1说明热图没有形成明显的峰值这个点大概率不可靠。5. 从 Hourglass 到轻量骨干的迁移尝试原版 Stacked Hourglass 的精度没得说但它的计算量和参数量放到实际部署场景里确实有点“重”。我这边有个实时交互的项目希望能在不损失太多精度的情况下把模型压到能跑 30 帧以上的水平于是做了一轮轻量化尝试。5.1 轻量化改造的整体思路我没有完全推翻 Hourglass 结构而是保留了“编码-解码-迭代精修”的核心框架把内部的基础残差块做了替换。最主要的一步是把 3×3 标准卷积改成深度可分离卷积也就是先对每个通道单独做 3×3 卷积再用 1×1 卷积做跨通道混合。这一步能把单个残差块的计算量压缩到原来的三分之一左右。同时把堆叠模块数从 4 降到 2基础通道数从 256 降到 128。这样整体参数量和计算量都大幅下降但保留了迭代精修和中间监督的设计思路。5.2 轻量化版本的实测数据模型版本平均误差像素参数量单张推理耗时说明原版4 模块256 通道6.28约 48M约 115ms精度基准轻量版 A2 模块128 通道7.82约 12M约 30ms速度优先轻量版 B2 模块256 通道7.01约 24M约 48ms均衡配置轻量版 B 在参数量只有原版一半的情况下平均误差控制在 7 像素左右推理速度提升了一倍多。对于很多交互场景来说这个精度已经能用而速度优势非常明显。5.3 轻量化后的训练策略调整换上轻量骨干后原来的超参不能直接硬套需要做几个调整。第一是 BN 的 momentum。原版训练时我用默认的 0.1效果正常换轻量网络后发现训练波动变大把 momentum 调到 0.01 之后明显稳定下来。原因是轻量化网络的 batch 内特征分布变化更敏感BN 统计量更新太快反而引入了噪声。第二是学习率。轻量网络的优化空间相对小初始学习率从 0.001 降到 0.0008 左右训练曲线更平滑最终精度反而略高。第三是热图分辨率。有同事建议把输出热图从 64×64 降到 32×32 来加速我试过之后发现平均误差直接增加了接近 2 个像素非常不值。热图分辨率是姿态估计的精度底线不建议轻易压缩。6. 我在实际训练里踩过的三个隐性坑最后这部分写点真实的踩坑记录。这些问题不亲自跑一遍很难注意到但它们对结果的影响都不小。6.1 BN 在 batch size 太小的时候会“翻车”最开始训练模型时因为显存限制我把 batch size 设成了 4。结果发现验证误差一直在高位震荡怎么也降不下来。排查了很久才意识到是 BN 的问题——batch size 太小时每个 batch 内的均值方差估计波动巨大归一化结果不稳定训练根本走不稳。解决方式是在不减小 batch 的前提下用梯度累加模拟更大的 batch。具体做法是把 16 张图分成 4 个 mini-batch 分别前向传播但只在累计到第 4 个 mini-batch 时才反向更新一次参数。这样等效于 batch size 16 的训练显存占用却低得多。6.2 不同关键点的损失贡献不均衡人体不同关节的检测难度差异很大。踝关节和腕关节因为运动范围大、遮挡频繁误差通常比肩关节、髋关节高不少。但默认的 MSELoss 对所有关键点一视同仁网络会把更多“精力”花在容易学的点上难点的误差被忽略了。我在实践中给损失加了一个可见性掩码对不可见的点直接屏蔽梯度同时对踝、腕这类难点额外乘一个 1.2 到 1.5 的权重。这样改进之后难点关节的误差下降了约 4%整体指标也有小幅提升。6.3 翻转增强时关节顺序没换导致左右互搏水平翻转是姿态估计里最常用的数据增强手段但有个细节极其容易出错图像翻转后左肩变成了画面的右边这时候标签里的左肩坐标要跟着变到右边同时通道顺序也要做交换。也就是说如果原图有“左肩”和“右肩”两个通道翻转后左肩的通道要和右肩互换。我最初实现翻转增强时只转了图忘了换标签通道结果训练出来的模型对左右对称关节的预测经常出现系统性偏移验证集上左右肩误差相差很大。后来加了通道映射表问题立刻消失。还有一个相关的小提醒如果做旋转增强热图坐标的旋转中心必须和图像旋转中心保持一致坐标变换要用仿射变换矩阵不能简单地给坐标加减一个偏移量。这个系列的下一篇我打算写一下多人场景下的自顶向下检测流程怎么跟 Stacked Hourglass 配合毕竟单人姿态做得再准落地时总会遇到“图片里有多个人怎么办”这个问题。到时候再详细展开。