DensePose 是 Facebook AI 团队提出的密集人体姿态估计框架,它把图像中的每个人体像素映射到 SMPL 模型的三维表面坐标上,输出 IUV 三通道结果:I 表示身体部位分区索引,U 和 V 表示该分区内的二维表面参数。在理想情况下,同一分区内部的 UV 值应该是连续变化的,但实际落地时经常出现手指被标成躯干、左右手臂分区串位、UV 值在分区边界处剧烈跳变等错乱现象。这篇文章围绕 UV 映射贴图与骨骼对齐两个核心方向,系统讲清楚错乱的成因和修复办法。

一、理解 DensePose 的 IUV 表示与错乱的本质
DensePose 的输出并不是关键点坐标,而是每个像素到 SMPL 标准化人体表面(Atlas)的映射。SMPL 表面被人工切分成 24 个分区,例如 3 号分区对应前臂、7 号分区对应小腿、21 号到 24 号对应手部。每个分区在纹理图上占据一块独立的矩形区域,像素被标注为 (I, U, V) 后,理论上可以直接反投影回三维网格。
错乱的本质可以归纳为两类。第一类是分区索引 I 判错:模型把手臂的像素分类成了躯干分区,导致后续 UV 反投影直接落到错误的表面位置。第二类是 U、V 值越界或断裂:I 判断正确,但 UV 值不在该分区的合法区间内,或者在相邻像素间出现剧烈跳变,重建出来的网格会撕裂。前者多数是训练数据或检测框问题,后者多数是后处理缺失导致的。
可以用下面的代码先做一次定量诊断,统计预测结果中越界 UV 和分区错乱的比例:
import numpy as np
def diagnose_iuv(iuv_pred, iuv_gt=None):
I = iuv_pred[..., 0]
U = iuv_pred[..., 1] / 255.0
V = iuv_pred[..., 2] / 255.0
# UV 值合法区间检查
out_of_range = np.mean((U < 0) | (U > 1) | (V < 0) | (V > 1))
# 分区索引合法性检查(24 个身体分区,0 通常代表背景)
invalid_part = np.mean((I < 0) | (I > 24))
print(f"UV 越界比例: {out_of_range:.4f}")
print(f"非法分区比例: {invalid_part:.4f}")
if iuv_gt is not None:
I_gt = iuv_gt[..., 0]
# 只在人体前景内统计分区混淆
fg = I_gt > 0
wrong_part = np.mean(I[fg] != I_gt[fg])
print(f"前景分区错判比例: {wrong_part:.4f}")
这个诊断脚本能快速定位问题属于哪一类。如果分区错判比例高,重点排查检测框和 mask;如果主要是 UV 越界,重点放在后处理和骨骼对齐上。
二、UV 映射贴图错乱的三个典型来源
第一个来源是人体检测框和 mask 质量差。DensePose RCNN 依赖前置的目标检测提供人体候选框,当检测框截掉了手臂末端或包含了大片背景时,模型对边缘像素的分区判断会明显退化。特别是侧身、手臂交叉、持物遮挡这类姿态,检测框稍有偏差,手部像素就容易被分配到躯干分区。解决办法是在推理前用更精确的实例分割(如 PointRend 或高分辨率 mask head)替换粗糙的检测框裁剪,并做 mask 的形态学闭运算填补内部空洞:
import cv2
import numpy as np
def refine_mask(mask):
# 闭运算填补 mask 内部空洞
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
closed = cv2.morphologyEx(mask.astype(np.uint8), cv2.MORPH_CLOSE, kernel)
# 反向 flood fill 找到并填充封闭空洞
h, w = closed.shape
flood = closed.copy()
mask2 = np.zeros((h + 2, w + 2), np.uint8)
cv2.floodFill(flood, mask2, (0, 0), 1)
holes = (flood == 0).astype(np.uint8)
return cv2.bitwise_or(closed, holes)
第二个来源是自遮挡与深度歧义。两条腿交叠时,模型在二维图像上难以判断哪条腿在前,分区索引容易在两条腿之间来回切换。这种情况下不要指望纯视觉模型完美解决,合理做法是引入时序约束(视频场景)或利用骨骼关键点的深度先验做仲裁。
第三个来源是分区边界的 UV 连续性断裂。SMPL 的 24 个分区在纹理图上是彼此独立的矩形,边界像素的 UV 本来就不连续,但如果模型把边界附近的 U、V 回归得不准,就会出现局部跳变。标准做法是对 UV 通道做双边滤波,滤波时以分区索引图为引导图,保证滤波过程不会跨越分区边界:
def smooth_uv_per_part(I, U, V):
"""按分区做引导滤波,避免跨分区模糊"""
smoothed_u = np.zeros_like(U, dtype=np.float32)
smoothed_v = np.zeros_like(V, dtype=np.float32)
for part_id in np.unique(I):
if part_id == 0:
continue
region = (I == part_id)
if region.sum() < 16: # 过小区域跳过
smoothed_u[region] = U[region]
smoothed_v[region] = V[region]
continue
u_part = U.astype(np.float32).copy()
v_part = V.astype(np.float32).copy()
u_part[~region] = 0
v_part[~region] = 0
smoothed_u[region] = cv2.bilateralFilter(u_part, 5, 0.1, 5)[region]
smoothed_v[region] = cv2.bilateralFilter(v_part, 5, 0.1, 5)[region]
return smoothed_u, smoothed_v
三、骨骼对齐:用关键点约束修正分区错位
DensePose 本身的 head 结构里有 keypoints 分支,但在很多部署场景中,人们只用 IUV 分支的输出,把关键点信息丢掉了,这是一个常见误区。骨骼关键点是分区归属的天然仲裁者:前臂分区内的像素在空间上应该靠近肘关节到手腕的连线,如果某个被判为前臂的连通区域在空间上远离这条连线,基本可以断定是分区错乱。
具体的对齐流程分三步。第一步,用同帧的姿态估计模型(或 DensePose 自带的 keypoint head)提取 17 个 COCO 关键点。第二步,根据 SMPL 骨架定义把 24 个分区挂到对应的骨骼线段上,例如分区 4(右上臂)对应右肩到右肘的线段,分区 3(右前臂)对应右肘到右腕的线段。第三步,对每个连通的分区区域计算其到对应骨骼线段的距离分布,距离超过阈值的像素重新判定为背景或者交由次优分区:
import numpy as np
def point_to_segment_dist(px, py, x1, y1, x2, y2):
"""点到线段的最短距离"""
dx, dy = x2 - x1, y2 - y1
if dx == 0 and dy == 0:
return np.hypot(px - x1, py - y1)
t = np.clip(((px - x1) * dx + (py - y1) * dy) / (dx**2 + dy**2), 0, 1)
cx, cy = x1 + t * dx, y1 + t * dy
return np.hypot(px - cx, py - cy)
# 分区到骨骼线段的映射(部分示例)
PART_TO_BONE = {
4: ("right_shoulder", "right_elbow"),
3: ("right_elbow", "right_wrist"),
5: ("left_shoulder", "left_elbow"),
2: ("left_elbow", "left_wrist"),
}
def fix_part_by_skeleton(I, keypoints, dist_thresh=0.08, img_h=480):
I_fixed = I.copy()
for part_id, (j1, j2) in PART_TO_BONE.items():
if j1 not in keypoints or j2 not in keypoints:
continue
x1, y1 = keypoints[j1]
x2, y2 = keypoints[j2]
ys, xs = np.where(I == part_id)
if len(xs) == 0:
continue
d = point_to_segment_dist(xs.astype(float), ys.astype(float),
x1, y1, x2, y2)
bad = d > dist_thresh * img_h
I_fixed[ys[bad], xs[bad]] = 0 # 距离过远的像素判为背景
return I_fixed
距离阈值建议设置为图像高度的 6% 到 10%,太小会误删正常像素,太大则失去过滤作用。对于被判为背景但置信度仍然较高的像素,可以进一步查它们的次优分区索引。DensePose head 输出的分区 logits 保留下来后,取第二大的类别做回填,往往能恢复出被错乱的边缘区域。
四、端到端验证与常见陷阱
修复之后需要一套量化验证手段。最直接的指标是在 DensePose 官方测试集上计算 GPS(Geodesic Point Similarity),它衡量预测表面点与真实表面点在 SMPL 网格上的测地距离。修复脚本如果有效,GPS 通常会有 1 到 3 个百分点的提升。另一个实用指标是分区边界的跳变密度:统计相邻像素 UV 差值超过阈值的像素对数量,修复前后对比即可判断平滑是否到位。
有几个常见陷阱需要注意。第一,不要在 resize 之后的低分辨率 IUV 图上直接做骨骼对齐,关键点坐标和 IUV 图的尺寸必须统一,否则距离计算全部失真。第二,双边滤波的 sigma 不要照搬网上参数,颜色 sigma 是针对 0 到 1 归一化 UV 值设计的,参数过大会把分区边界糊掉,产生新的错乱。第三,手部四个分区(21 到 24)的像素数量本来就少,距离过滤时容易整块被删掉,建议对手部分区单独调低阈值或跳过过滤,改用面积连通性判断。
最后总结一下整体排查顺序:先跑诊断脚本确认错乱类型,再检查检测框与 mask 质量,然后做分区内的 UV 平滑,最后叠加骨骼对齐的距离过滤。这套流程在虚拟试衣和人体重渲染项目中已经过验证,能把绝大多数肉眼可见的部位错乱压到不影响下游渲染的程度。如果数据集本身存在标注噪声(DensePose-COCO 中遮挡严重的样本约占两成),还可以在训练阶段加入分区一致性正则项,从模型层面减少错乱的产生。