简介:面向计算机视觉与机器学习开发者,这份 Human3.6M 3D 人体姿态数据集获取工具包聚焦数据下载、解压、元数据读取与批量处理流程,适合正在搭建姿态估计实验环境、需要减少手工整理成本的学习者与研究人员。压缩包共 14 个文件,约 34KB,以 4 个 Python 脚本为核心,辅以 2 个文本文件及 Docker 容器配置、依赖清单、说明文档、许可证与校验文件等,兼顾脚本执行、依赖安装与容器化复现。已有 3638 人学习,说明其在数据集准备环节具有一定参考价值。使用者可借助批量下载、抽取和处理脚本梳理数据管线,结合配置样例与校验信息检查文件完整性,并通过容器配置快速复现运行环境,为后续 3D 姿态模型训练、评估和结果可视化打好数据基础。
1. 拿到 Human3.6M 之前先想清楚:这份 3D 姿态数据到底能喂给什么模型
有个读者把 2D 到 3D 的提升网络跑通了,训练 loss 一路下降,测试 MPJPE 却停在 190mm 左右下不去,查了两天,最后发现是训练集和测试集的受试者编号写反了——模型在用 S9 学、用 S1 测。3D 人体姿态这类任务,数据一错,后面所有调参都是白费。
Human3.6M 是目前公开的、带动作捕捉真值的室内 3D 人体姿态数据集里仍被当作基准的那一份:11 位受试者、15 类日常动作、数百万帧、四个同步相机视角,配套 2D 关键点、3D 关节真值和完整的相机标定参数。Python 这一侧,读取和预处理基本围绕 h5py 展开,拿到手的是 hdf5 文件而不是现成的张量,中间隔着坐标变换、关节裁剪、协议划分三道工序。
这份资源适合三类人:正在复现 2D 到 3D 提升类工作、需要一份能对得上论文数字的评测集;做动作识别或人体建模、想拿关节点当输入特征;以及要训练自己的姿态估计模型、需要真值监督信号。如果你只是想做单人单帧的实时姿态演示,它有大量冗余帧和标注噪声,会拖慢你。
2. 数据到手的第一步:把授权、分卷和目录结构一次理清
2.1 授权申请与下载:你拿到的为什么是一堆分卷
Human3.6M 不是点开就能下的那种公开直链。它走的是申请制:在数据集官方站点填一张表,写清楚姓名、所属单位、用途说明和聯繫邮箱,提交后等人工审核,通过之后会收到一封带下载账号和地址的邮件。审核周期通常在一到三个工作日,写用途时把“3D 人体姿态估计研究”这类描述写清楚,比空着通过得快。
拿到账号后才是真正消耗时间的地方。完整数据集是 TB 级别的,官方按受试者分目录、按视频分文件提供,你可以只下需要的那部分。常见的做法是:
- 先只下 3D 关节和相机标定(体积小,够跑通读取流程);
- 再用 S1 和 S11 两个受试者做一次端到端验证,确认自己的解析、划分、评测三件事都对;
- 最后才批量补齐 S5、S6、S7、S8、S9。
这个顺序能省掉大量返工。我见过太多人一口气把全量拖下来,结果在读文件那一步卡住,硬盘先满了。
提示:下载完成后先算一遍校验,分卷传输中断是常态,缺字节的 hdf5 打开时会直接抛异常而不是静默出错,这一点还算友好,但一个坏文件放在批次中间,排查起来很烦。
2.2 目录命名的三段式与 hdf5 的实际命名
解开压缩包之后,目录层级一般是受试者编号/动作名_相机号.受试者序列号.hdf5。举个例子,形如S1/directions_1.54138969.hdf5这样的文件,拆开看是三段信息:
| 位置 | 字段 | 含义 | 用途 |
|---|---|---|---|
S1/ | 受试者 | 编号 S1 到 S11,其中 7 位有 3D 标注 | 训练与测试划分依据 |
directions | 动作名 | 15 类动作之一 | 动作级筛选与分动作汇报 |
_1 | 相机号 | 对应文件内的camera_1组 | 多视角融合时的索引 |
.54138969 | 受试者序列号 | 每个人的固定编号 | 与目录名互为校验 |
这里有个容易被忽略的点:有 3D 标注的受试者是 S1、S5、S6、S7、S8、S9、S11 七位,S2、S3、S4、S10 只有部分数据或标注不全。很多教程直接说“11 位受试者”,你按 11 位去切分训练集,会拿到一堆空标签。
写成解析函数更省事:
import os import re # 文件名三段式:动作名_相机号.受试者序列号.hdf5 PATTERN = re.compile(r'(?P<action>[a-z]+)_(?P<cam>\d)\.(?P<subj_id>\d+)\.hdf5$') def build_index(root): """扫一遍根目录,返回 (受试者, 动作, 相机, 路径) 四元组列表""" records = [] for subject in sorted(os.listdir(root)): sub_dir = os.path.join(root, subject) if not os.path.isdir(sub_dir) or not subject.startswith('S'): continue for fname in sorted(os.listdir(sub_dir)): m = PATTERN.search(fname) if not m: continue # 跳过说明文件、缩略图等非数据文件 records.append({ 'subject': subject, # 如 'S1' 'action': m.group('action'), # 如 'directions' 'camera': int(m.group('cam')), # 0~3 'path': os.path.join(sub_dir, fname), }) return records这段代码的逻辑很简单:不做任何硬编码的受试者列表,完全由磁盘内容驱动,这样即使你只下了一部分数据也能正常建索引。PATTERN里的[a-z]+匹配动作名(都是纯小写单词),(?P<cam>\d)只取一位数字,是因为相机号固定是 0 到 3,如果写成\d+会把后面的受试者编号也吞进去。
建立完索引后,第一件该做的事是统计——每个受试者有多少个动作、每个动作覆盖几个相机。正常情况下每个受试者应该是 15 个动作 × 2 个标注视角,数量对不上就说明下载不完整。
2.3 写一个结构探针,先看清每一层键
在动手写解析逻辑之前,务必先把 hdf5 的内部结构打印出来。不同来源、不同年份拿到的副本,键名会有一点点出入(3d_joints可能是3d_joints也可能是嵌套在别的位置),照抄别人的代码很容易在第一步就 KeyError。
import h5py def probe(path, max_depth=2): """把一个 hdf5 文件的键、形状、dtype 全打出来""" with h5py.File(path, 'r') as f: def walk(name, obj): depth = name.count('/') if depth > max_depth: return pad = ' ' * depth if isinstance(obj, h5py.Dataset): print(f"{pad}{name:35s} shape={str(obj.shape):14s} dtype={obj.dtype}") else: print(f"{pad}{name}/") f.visititems(walk) probe('S1/directions_1.54138969.hdf5')visititems会递归遍历所有子节点,比f.keys()只看第一层靠谱得多。max_depth控制打印深度,因为相机组下面还有七八个子数据集,全打出来会把终端刷屏。跑完之后你会看到类似这样的结构:顶层是3d_joints,形状(帧数, 32, 3);下面是camera_0到camera_3四个组,每个组里装着R、T、t、f、c、k、p,以及可能存在的2d_joints。
这一步的产出应该是一张你自己记录的对照表,而不是靠记忆。后面所有代码的键名都从这张表来。
2.4 批量体检:把“能打开”升级成“能用”
单个文件能读不等于整批能用。写一个体检函数,对每个文件检查四件事:3D 关节的形状是否与帧数一致、有没有 NaN 或全零帧、相机组是否齐全、2D 关键点是否存在。
import numpy as np import h5py def health_check(path): """返回一个字典,记录该文件的可用性指标""" with h5py.File(path, 'r') as f: j3 = f['3d_joints'][:] # (N, 32, 3),单位毫米 cams = [k for k in f.keys() if k.startswith('camera_')] n_frames = j3.shape[0] report = { 'frames': n_frames, 'n_cameras': len(cams), 'nan_frames': int(np.isnan(j3).any(axis=(1, 2)).sum()), 'zero_frames': int((np.abs(j3).sum(axis=(1, 2)) < 1e-6).sum()), 'has_2d': {c: ('2d_joints' in f[c]) for c in cams}, } # 光心一致性校验:t 应等于 -R^T @ T,偏差大说明标定字段被改过 if 'camera_1' in f: g = f['camera_1'] R, T, t = g['R'][:], g['T'][:].reshape(3), g['t'][:].reshape(3) report['center_err'] = float(np.linalg.norm(-R.T @ T - t)) return reportnan_frames和zero_frames是两道保险,动捕在遮挡时会补零,补零帧进训练集就是纯噪声。center_err那一段是被很多人忽略的自检:R和T描述的是世界到相机的刚体变换,而t是相机光心在世界坐标下的位置,理论上满足t = -R^T·T。这个值正常应该在1e-3量级(毫米单位下就是千分之一毫米),如果算出几百,说明这个文件里的标定字段被某个中间脚本改过,别用。
注意:体检脚本跑一遍全量数据可能要十几分钟,建议把结果落成 CSV 存下来,后面换机器、换批次都能对照。
3. 从 hdf5 到训练张量:h5py 逐层拆解与坐标系换算
3.1 一个 hdf5 里到底装了什么
一个典型文件的顶层只有两类东西:3d_joints和四个camera_i组。
3d_joints是整个世界坐标下的关节序列,形状(N, 32, 3),单位是毫米,N 是这段视频的帧数。32 这个数字是第一道坎:它包含了主关节和一批细节点(脚趾、头顶等),绝大多数论文评测用的是 17 关节版本,你得自己做裁剪,而不是直接喂 32 个进去。
camera_i组是标定参数,字段含义如下:
| 字段 | 形状 | 含义 | 单位 |
|---|---|---|---|
R | 3×3 | 世界坐标到相机坐标的旋转矩阵 | 无量纲 |
T | 3 | 世界坐标到相机坐标的平移 | 毫米 |
t | 3 | 相机光心在世界坐标下的位置 | 毫米 |
f | 标量 | 焦距 | 像素 |
c | 2 | 主点坐标 | 像素 |
k | 3 | 径向畸变系数 | 无量纲 |
p | 2 | 切向畸变系数 | 无量纲 |
这些字段凑齐了,你才具备“把 3D 点投影回图像”的能力。没有标定,3d_joints只是一组孤立的坐标;有了标定,它才能和 RGB 帧、2D 关键点对齐,做弱监督、做可视化验证、做多视角融合。
3.2 世界坐标、相机坐标、像素坐标:转换链条上的四个矩阵
从世界坐标到像素坐标是一条固定的链路,每一步都能出错:
第一步,世界到相机:X_cam = R · X_world + T。注意R是左乘,如果你的实现写成X_world @ R + T,等价于用了R^T,投影结果会整体翻转。
第二步,透视除法:u = X_cam[0] / X_cam[2],v = X_cam[1] / X_cam[2]。除之前要判X_cam[2] > 0,落在相机背后的点会被投影到画面上,看起来像“骨骼飘到天上”,其实是符号问题。
第三步,加畸变:用k和p做布朗-康拉迪模型的畸变修正。
第四步,乘焦距加主点:x_pix = f · u_d + c[0],y_pix = f · v_d + c[1]。
写成函数是这样:
import numpy as np def project(Xw, R, T, f, c, k, p): """世界坐标 (N,3) 毫米 -> 像素坐标 (N,2) R:(3,3) T:(3,) f:标量 c:(2,) k:(3,) p:(2,)""" Xc = Xw @ R.T + T.reshape(1, 3) # (N,3),世界 -> 相机 front = Xc[:, 2] > 1e-6 # 剔除相机背后的点 u = Xc[:, 0] / np.where(front, Xc[:, 2], 1.0) v = Xc[:, 1] / np.where(front, Xc[:, 2], 1.0) r2 = u * u + v * v radial = 1 + k[0] * r2 + k[1] * r2**2 + k[2] * r2**3 u_d = u * radial + 2 * p[0] * u * v + p[1] * (r2 + 2 * u * u) v_d = v * radial + p[0] * (r2 + 2 * v * v) + 2 * p[1] * u * v xy = np.stack([f * u_d + c[0], f * v_d + c[1]], axis=-1) xy[~front] = np.nan return xyXw @ R.T是(R · X^T)^T的写法,避免在批量计算时写循环。np.where那一行是为了防止除零,背后点统一置 NaN,后面统计误差时用nanmean自动跳过,比用 0 或者-1更干净。radial那一项里k[2]是六阶项,很多实现只用到四阶,和数据集自带的 2D 标注对齐时会有几个像素的残差,别急着怀疑自己的代码。
验证方法很直接:取一帧的 3D 关节,用某个相机的参数投影,把结果画到对应的 RGB 帧上。如果骨骼和人对得上,这条链路就是通的;如果整体偏移,查c;如果边缘拉伸,查畸变;如果上下颠倒,查R的转置。
3.3 统一成 (T, 17, 3) 与去根关节的落地写法
训练张量的形状定成(帧数, 17, 3),是业界最通行的口径,因为绝大多数 2D 检测器输出的是 17 个 COCO 关键点,维度对得上才能直接对接。
从 32 裁到 17 需要一个索引表。下面的映射在社区里流传较广,但不同来源的 h36m 副本关节排列存在出入,第一次用必须拿可视化逐个点亮核对:
import numpy as np # 32 -> 17 的常用索引:骨盆、左右腿、脊柱链、左右臂 JOINT17 = np.array([0, 1, 2, 3, 6, 7, 8, 12, 13, 14, 15, 17, 18, 19, 25, 26, 27]) # 对应的关节名字,调试可视化时打印用 NAMES17 = ['pelvis', 'r_hip', 'r_knee', 'r_ankle', 'l_hip', 'l_knee', 'l_ankle', 'spine', 'thorax', 'neck', 'head', 'l_shoulder', 'l_elbow', 'l_wrist', 'r_shoulder', 'r_elbow', 'r_wrist'] def load_sequence(path, cam=1): """读一个 hdf5,返回 (T,17,3) 的 3D 与 (T,17,2) 的 2D(可能为 None)""" with h5py.File(path, 'r') as f: j3 = f['3d_joints'][:, JOINT17, :].astype(np.float32) # (N,17,3) 毫米 j2 = None g = f[f'camera_{cam}'] if '2d_joints' in g: j2 = g['2d_joints'][:, JOINT17, :].astype(np.float32) # 根关节去中心:减去骨盆,消掉每段视频各自的世界系原点 j3 = j3 - j3[:, :1, :] return j3, j2关键在最后一行。数据集里的3d_joints虽然是世界坐标,但每一段视频的世界系原点互不相同(取决于录制时的标定布置),如果不去根关节,模型会先花大量容量去学“这个人站在房间的哪个位置”,而不是学姿态本身。减去骨盆(索引 0)之后,所有序列被搬到同一个以骨盆为原点的局部坐标系,这才是姿态模型该看到的输入。
注意:如果后面要做绝对位置相关的任务(比如人体与场景交互),这一步不能做,得改成保留全局平移、单独喂一个平移分支。
另外,j2有可能不存在。数据集里不是每个文件的每个相机都带 2D 标注,用.get()风格判断比直接索引安全。拿到 2D 之后还要检查它的坐标系是原始像素还是裁剪后的像素——有些预处理脚本会把图像裁到人框再记录相对坐标,混用会导致 2D 到 3D 提升的训练彻底发散。
4. 协议划分与评测口径:MPJPE 差 30mm 往往不是模型的锅
4.1 Protocol 1 与 Protocol 2 到底差在哪一步对齐
这是 3D 姿态领域最容易出分歧的地方。同一个模型,两份代码跑出两个差 30mm 的 MPJPE,十有八九是对齐方式不同。
Protocol 1(MPJPE):先做平移对齐,把预测和真值的根关节重合,再逐关节算欧氏距离,最后对所有关节、所有帧取平均。单位毫米。这是最严格的指标,惩罚一切旋转和尺度误差。
Protocol 2(P-MPJPE):先做相似变换对齐,允许旋转、平移和尺度三个自由度,对齐完再算 MPJPE。因为放开了一个旋转自由度,数值通常比 Protocol 1 低 30% 到 40%。
N-MPJPE:只放开尺度,不放开旋转,介于两者之间,常用于分析模型的尺度估计能力。
三个口径不能混着比。看论文数字时先确认它报的是哪个,很多表格里 P-MPJPE 和 MPJPE 混排,你不看脚注就会以为自己的实现有问题。
P-MPJPE 里的相似变换用 SVD 闭式解,别用迭代优化:
import numpy as np def mpjpe(pred, gt): """pred/gt: (T,17,3),已去根关节""" return float(np.linalg.norm(pred - gt, axis=-1).mean()) def p_mpjpe(pred, gt): """对每一帧单独求最优相似变换后再算误差""" errs = [] for p, g in zip(pred, gt): mu_p, mu_g = p.mean(0), g.mean(0) # 各自质心 pc, gc = p - mu_p, g - mu_g # 尺度:两组去心点云的 RMS 之比 s = np.linalg.norm(gc) / (np.linalg.norm(pc) + 1e-12) # 旋转:对协方差矩阵做 SVD H = pc.T @ gc U, _, Vt = np.linalg.svd(H) d = np.sign(np.linalg.det(Vt.T @ U.T)) # 防止出现镜像解 R = Vt.T @ np.diag([1, 1, d]) @ U.T aligned = s * (pc @ R.T) + mu_g errs.append(np.linalg.norm(aligned - g, axis=-1).mean()) return float(np.mean(errs))d那一行是必备的:不检查行列式符号,SVD 会解出镜像变换,误差会低得离谱。s用范数之比而不是逐维度的标准差,是因为姿态点云是各向异性的,逐维估计会把尺度算歪。
4.2 受试者划分表与动作级切片的写法
标准划分是按受试者切的,不是按帧随机切。按帧切会让同一个人的同一段动作同时出现在训练和测试里,指标虚高十几个点,这是新手最容易踩的坑。
通行划分如下:
| 集合 | 受试者 | 说明 |
|---|---|---|
| 训练 | S1, S5, S6, S7, S8 | 五位,覆盖全部 15 类动作 |
| 测试 | S9, S11 | 两位,与训练集受试者完全不重叠 |
| 排除 | S2, S3, S4, S10 | 3D 标注不完整,不进任何集合 |
写成常量加过滤函数:
TRAIN_SUBJECTS = ('S1', 'S5', 'S6', 'S7', 'S8') TEST_SUBJECTS = ('S9', 'S11') ALL_ACTIONS = ('directions', 'discussion', 'eating', 'greeting', 'phoning', 'photo', 'posing', 'purchases', 'sitting', 'sittingdown', 'smoking', 'waiting', 'walking', 'walkingdog', 'walkingtogether') def split(records, subjects, actions=None): """records 是 build_index 的输出,按受试者和动作过滤""" actions = actions or ALL_ACTIONS return [r for r in records if r['subject'] in subjects and r['action'] in actions] train_set = split(records, TRAIN_SUBJECTS) test_set = split(records, TEST_SUBJECTS)用元组而不是列表,是为了防止后续代码不小心append改了全局划分。actions参数留出来,方便做“留一动作”实验——比如只用 14 个动作训练、在photo上测泛化,这类实验在跨动作研究中很常见。
划分完之后一定要打印统计:训练集多少段、测试集多少段、每个动作在两边各有几段。数量对不上就说明某次下载不完整,或者某个动作名拼错了(sittingdown是一个词,不是sitting_down,拼错就被静默过滤掉)。
4.3 用一份“作弊基线”给评测脚本做体检
评测脚本写完之后,别急着跑模型。先造一份作弊预测,验证指标实现是对的:
# 作弊基线一:直接用真值当预测,MPJPE 必须是 0.0 assert abs(mpjpe(gt_batch, gt_batch)) < 1e-6 # 作弊基线二:真值加一个已知的、全局一致的小旋转 # P-MPJPE 应该接近 0(旋转被对齐掉了),MPJPE 应该明显大于 0 theta = np.deg2rad(10) Rz = np.array([[np.cos(theta), -np.sin(theta), 0], [np.sin(theta), np.cos(theta), 0], [0, 0, 1]], dtype=np.float32) rot_batch = gt_batch @ Rz.T print('MPJPE :', mpjpe(rot_batch, gt_batch)) # 应显著 > 0 print('P-MPJPE :', p_mpjpe(rot_batch, gt_batch)) # 应接近 0这两个断言能挡掉绝大多数实现错误。如果第一个不过,说明有广播或者形状问题;如果第二个的 P-MPJPE 不接近零,说明 SVD 那段的旋转方向搞反了,或者镜像解没处理。
再补一个规模检查:随机打乱帧序之后,MPJPE 应该基本不变(因为指标是逐帧独立算的)。如果变了,说明代码里混进了跨帧的时间对齐,那就不叫 MPJPE 了。
5. 避坑清单:五个把复现拖成两周的现场
5.1 测试 MPJPE 卡在 200mm 上下不动
现象:训练 loss 稳定下降,验证 loss 也降,但测试集 MPJPE 一直在 180 到 220mm 之间晃,调学习率、换 backbone 都没用。
原因:三种可能,按概率排——第一,训练集和测试集有受试者重叠,模型在背答案,测试时背不出来;第二,预测和真值的根关节对齐方式不一致,一边去了根关节一边没去;第三,评估时把毫米当成米,或者反过来,导致数值差了 1000 倍后又被某个 clip 操作压回合理区间。
解决:先把划分打印出来,人眼确认两边受试者编号没有交集;再拿一条样本手算一次 MPJPE,用numpy在终端里逐关节算,和函数结果对;最后检查3d_joints的数值范围,正常人体关节坐标绝对值在几百到两千之间(毫米),如果看到 0.5 到 2.0 这种数,单位是米,得先乘 1000。
5.2 2D 投影和图上骨骼对不上,差几十像素
现象:用标定参数把 3D 关节投到 RGB 帧上,骨骼整体偏移一段固定距离,或者画面边缘的关节明显外扩。
原因:固定偏移通常是主点c用错了——有的副本给的是图像中心,有的是标定得到的真实主点,两者能差十几个像素。边缘外扩是畸变模型的问题:数据集给的 2D 标注是已经过畸变的像素坐标,你自己投影时必须正着走一遍畸变,跳过这一步,画面中心还行,越往边缘误差越大。
解决:先只投中心区域的关节,确认R、T、f、c四个字段对;再投边缘关节,把k和p加上去对比。如果加了畸变反而更差,检查k的符号约定,有实现用负号。最后拿数据集自带的2d_joints和你的投影结果算一次平均像素误差,正常应该在 5 像素以内。
5.3 跨序列拼接后模型只学到“人站在不同位置”
现象:把多个受试者的序列拼成一个大的数组直接训练,loss 下降到某个值就卡住,可视化预测结果发现每个人的姿态都往同一个方向偏。
原因:3d_joints是世界坐标,但每段视频的世界系原点不同,拼接之后数据里混进了一个与姿态无关的、按序列变化的全局平移。模型很快就学会用这个平移去“作弊”降低 loss,真正的姿态信息反而被忽略。
解决:在加载阶段统一减去根关节,把每个序列搬到以骨盆为原点的局部坐标系。做完之后可以再验证一次:对去根之后的全体数据算每个关节的均值,骨盆那个点的均值应该是精确的零向量,其余关节的均值应该落在人体解剖上合理的位置(比如头部在骨盆上方约 500mm)。如果某个关节的均值飘到几米外,说明那个序列的裁剪索引错了。
5.4 多进程读 hdf5 报错或速度极慢
现象:DataLoader开num_workers=8,训练直接卡死,或者报HDF5 error、Can't open file。
原因:h5py 的文件句柄不是进程安全的,父进程打开的File对象被子进程继承后,多个 worker 同时读同一个句柄就会冲突。另一个隐性问题是每次__getitem__都重新打开文件,几万次打开关闭能把 IO 打满。
解决:不要在__init__里打开文件并保存句柄,改成在__getitem__里按需打开,或者用worker_init_fn给每个 worker 单独建句柄。更彻底的做法是离线转成内存映射格式:
import numpy as np # 一次性把所有序列写成 .npy,训练时用 mmap_mode 直接映射,无句柄问题 np.save('h36m_3d_train.npy', all_joints) # (总帧数, 17, 3) float32 # 训练侧 data = np.load('h36m_3d_train.npy', mmap_mode='r')mmap_mode='r'让操作系统管页面缓存,多个 worker 读同一份文件不会冲突,而且比 hdf5 的小块随机读快一个量级。代价是首次要花十几分钟做一次转换,以及磁盘上多一份数据。
5.5 单位是米还是毫米,前后不一致
现象:训练能跑,指标看着也像那么回事,但和论文数字差了三个数量级;或者可视化时骨骼要么缩成一个点,要么撑满整个画面。
原因:hdf5 里的3d_joints单位是毫米,但T、t是毫米、f是像素、c是像素,混着用的时候很容易在某一步做了/1000,另一处没做。更麻烦的是有些第三方预处理脚本会把输出统一成米,你混用两份来源的数据就会撞车。
解决:在数据加载的第一行就把单位钉死,写进注释和变量名里,比如j3_mm,后面所有函数都只接受毫米输入,需要米的地方显式除。再加一个断言:人体任意两关节间距不应该超过 2000mm,也不应该小于 1mm,越界直接抛异常。这个断言我放在预处理脚本里,比事后查三天强。
6. 从能读到能训:一条可复现的预处理流水线与三个验收指标
把前面几章串起来,最终的流水线是四步:建索引、体检、转换、缓存。我一般会把它写成一个脚本,跑完产出两个文件——h36m_3d_{split}.npy和一份meta.json,记录每段序列在数组里的起止位置、受试者、动作、相机号。训练侧只认这两个文件,不再碰原始 hdf5。
缓存格式选float32而不是float16。姿态坐标单位是毫米,数值在几百到两千之间,float16的有效精度只有三位十进制,量化误差能达到几个毫米,直接吃掉模型零点几个点的提升。通道顺序统一成(T, 17, 3),不要用(T, 3, 17),后者在可视化时每次都要转置,转着转着就忘了。
验收指标我固定卡三条:
| 指标 | 期望值 | 不满足说明 |
|---|---|---|
| 每关节均值距离骨盆 | 头部 400~600mm,脚踝 700~900mm | 裁剪索引错或单位错 |
| 单序列内相邻帧位移中位数 | 5~60mm | 帧序错乱或做了错误的平滑 |
| 2D 重投影平均像素误差 | 小于 5 像素 | 标定字段或畸变模型错 |
第三条最值钱,因为它是唯一一个能把 3D 和 2D 两边同时验证到的指标。写法就是拿第 3 章的project函数,对每帧的 3D 关节投影,和数据集自带的2d_joints求 L2 距离,取nanmean。如果这个数在 5 以内,说明坐标变换、单位、畸变模型、关节裁剪四件事全对;任何一个地方错了,这个数都会跳到几十甚至几百。
最后一件事:把上面这套流程封成一个prepare.py,只在数据变化时跑一次,把输出目录的哈希写进实验记录的配置里。
我之前吃过一次亏,中途改了关节裁剪的索引表,忘了重跑预处理,用旧的缓存训了一周,最后发现所有实验的基线都是错的,两周的调参记录全部作废。从那以后我每次开新实验,都强制走一遍这三条验收,跑不过就不许开训练脚本。希望帮到你。
本文还有配套的精品资源,点击获取