资讯动态

300W人脸关键点数据集整理指南:从原始数据到训练管线

发布时间:2026/10/16 7:22:26 来源:尧图企业网站定制
简介面向人脸对齐、关键点检测与面部几何研究的 300W 数据集整理包共 3 个文件由 2 个 NumPy 数组文件与 1 个 Python 脚本组成压缩包约 120.49MB。脚本负责图像归一化、关键点坐标标准化与训练/验证/测试集划分便于直接接入深度学习流程两个数组文件分别存放人脸图像与 68 点关键点坐标加载后即可用于卷积神经网络等模型训练与评估。数据集覆盖眼睛、眉毛、鼻子、嘴巴及脸部轮廓等标注适用于人脸检测、表情识别、姿态估计和 3D 重建等方向也可结合常见检测模型如 MTCNN、HRNet开展关键点检测实验并利用平均误差、平均精度均值等指标评估效果。目前已有 2651 人学习下载适合需要标准人脸关键点数据、希望快速启动模型训练与消融实验的研究者或开发者。1. 300W 人脸关键点数据集整理包把散落在四个子集里的数据收进一套能直接训练的规范目录第一次在项目里做人脸关键点检测我用了一个下午下载、解压然后花了一周在跟数据格式搏斗。300W 人脸关键点数据集整理包解决的就是这个问题它把 AFW、LFPW、HELEN、IBUG 四个来源、三种以上目录结构、两套标注格式的数据收成一套目录清晰、格式统一、划分明确的训练数据。有了它你不需要再逐个子集去读文档、猜字段、处理异常路径数据加载器可以只依赖一份索引文件。这篇文章写给两类人刚接触关键点检测的新手可以照着步骤把数据管线搭起来已经在 300W 上跑过模型但被数据坑过的熟手可以对着避坑清单复查自己的整理流程。2. 先搞清 300W 的原生构成与坐标约定动手整理前先避开数据源的三个暗坑整理任何数据集之前先回答三个问题数据从哪来、标注长什么样、怎么划分才对得上评测口径。300W 的原始发布是几个独立压缩包很多人在第一步就翻车——把 LFPW 原版标点和 300W 官方重标点混在一起训练出来 NME 怎么调都下不去最后发现是点序问题。这一章先把这些硬约束讲清楚后面写脚本才有依据。2.1 300W 由四个子集拼成AFW、LFPW、HELEN、IBUG 的来路与规模300W 不是一个单一发布包而是把四个来源的标注统一成同一套 68 点体系的「规范数据集」。它的价值在于「统一」图像全部来自真实野外场景姿态、光照、遮挡都不受控同时四个子集各有各的难点组合起来能覆盖从简单到极端的分布。下表是整理包里最常见的官方规模口径子集训练张数测试张数特点AFW3370姿态多样图像来源杂LFPW811224人种、年龄、光照分布广HELEN2000330分辨率较高标注精细IBUG0135姿态夸张、表情剧烈、遮挡严重四个子集加起来是 3148 张训练图、689 张测试图。这个数字是论文和后续工作里最常见的口径整理包应该以此为默认划分。有一点容易被忽略AFW、LFPW、HELEN 这三个名字在原始发布里各有各的标注体系LFPW 原版是 35 点、HELEN 原版是 194 点300W 官方把它们统一重标成 68 点。所以整理时只能以官方重标版本为准不要手痒去合并子集原版标注点序和点数都不一样强行合并只会污染训练数据。2.2 68 点标注的顺序约定与 pts 文件格式第 0 点到第 67 点68 点不是随机撒在脸上的它有严格的分组顺序第 0 到 16 点是下巴轮廓从左侧绕到右侧17 到 21 是左眉22 到 26 是右眉27 到 30 是鼻梁31 到 35 是鼻翼到鼻底36 到 41 是左眼轮廓42 到 47 是右眼轮廓48 到 59 是外嘴唇60 到 67 是内嘴唇。这里说的左右是图像坐标系里的左右也就是被拍摄者自己的右侧脸在图像左侧时对应的是 36 到 41 这一组。300W 官方标注最常见的载体是 pts 文件格式长这样version: 1 n_points: 68 { 0.000000 0.000000 1.311236 1.081354 ... }坐标原点在图像左上角x 向右、y 向下单位是像素。解析时最稳的做法不是信任n_points字段而是按花括号内部的坐标行数来读因为整理多个来源时经常遇到行数与声明不一致的文件。这个细节我会在第 5 章展开但你现在就应该习惯这种「防御式解析」的写法def parse_pts(path): coords [] in_block False for line in path.read_text(encodingutf-8, errorsignore).splitlines(): line line.strip() if line {: in_block True continue if line }: break if in_block: parts line.replace(\t, ).split() if len(parts) 2: coords.append([float(parts[0]), float(parts[1])]) return np.asarray(coords, dtypenp.float32)这段代码的关键是不管n_points写的是 68 还是别的只管花括号里实际有多少行坐标。解析完再统一断言shape (68, 2)不满足就记日志跳过而不是让它静默带错进训练。很多人把 Loss 不收敛当玄学其实根因常常是点序乱了——眉毛的点画到了眼睛上模型学到的映射全部错位。2.3 划分边界官方 3148/689 划分与 Common/Challenging 评测口径训练集、测试集的边界在 300W 里是约定俗成的AFW 全量、LFPW 训练子集、HELEN 训练子集进训练LFPW 测试子集、HELEN 测试子集、IBUG 全量进测试。整理包必须保留每个样本的「来源子集」字段因为论文报告 NME 时常常会把 689 张测试图拆成两个口径Common 是 LFPW 测试加 HELEN 测试共 554 张Challenging 是 IBUG 的 135 张。两个口径分开报是有道理的IBUG 姿态太极端混在一起会拉低整体数字看不出模型真实水平。另一个容易踩的划分坑是有人把 HELEN 全量 2330 张都拿去做训练理由是「反正 HELEN 都是同一批数据」。但 300W 的测试集里明确包含 HELEN 的 330 张测试子集这么做等于把测试数据渗进了训练集最后报告的数字虚高得没法复现。整理包在设计时就要把这些划分规则写死在索引文件里而不是靠人肉记。提示训练、验证、测试三份名单必须彼此不相交并且在 README 里注明用的是哪个口径。后面所有实验报告都引用这份划分文件避免换台机器就对不上。3. 目录重建与格式统一把四个子集收进一套能加载的目录结构原生态 300W 数据摆在面前时目录层级千奇百怪有的是图像和标注混在一个目录有的是按 train/test 分好了但命名规则不一致还有的带一堆无关的 readme 和 license。整理包的第一步就是把这些全部拍平成一套标准目录让后续脚本只在一个约定下工作。3.1 整理包的最终目录结构images、pts、npz 三层的设计我一般会把整理包根目录设计成下面这样。images 放原始图像pts 放官方标注原文npz 放转换后的 numpy 数组meta 放划分和索引。保留 pts 这一层很重要任何时候想回溯「npz 里的坐标到底从哪来」都能找到对应原文而不是拿着一个没法溯源的二进制文件。300W/ ├── images/ │ ├── train/ │ │ ├── AFW_0001.jpg │ │ └── ... │ ├── val/ │ └── test/ ├── pts/ │ ├── train/ │ └── test/ ├── npz/ │ ├── train/ │ └── test/ └── meta/ ├── dataset.json └── readme.md四个子集的图像格式也不统一有 jpg、JPG、png 混着来。整理时我会统一把后缀改成小写 jpg并在拷贝过程中顺便过滤掉损坏文件和重复文件。图像不做 resize、不做归一化原样保存因为数据增强应该在训练管线里做预处理阶段动像素只会让你后面想换一种增强策略时重新整理一遍数据。3.2 批量归并脚本命名与拷贝在同一个脚本里完成源目录假设是官方解压后的常见形态afw、lfpw、helen、ibug四个子目录其中 lfpw 和 helen 内部有 train/test 分层。下面的脚本负责把四个子集的文件统一复制到目标目录并重新命名避免不同子集里出现同名文件互相覆盖# merge_300w.py import argparse import shutil from pathlib import Path IMG_EXTS (.jpg, .JPG, .jpeg, .png) def resolve_split(subset: str, rel_dir: str) - str: 根据源目录路径判断这条记录属于 train 还是 test。 rel rel_dir.lower() if subset ibug or test in rel: return test return train def merge_one(img_path: Path, dst_root: Path, subset: str, label: str): split resolve_split(subset, str(img_path.parent)) stem f{subset.upper()}_{img_path.stem[:20]} target dst_root / images / split / f{stem}.jpg if target.exists(): target target.with_name(f{stem}_dup.jpg) shutil.copy2(img_path, target) def main(src_root: Path, dst_root: Path): for dst in [images/train, images/test, pts/train, pts/test]: (dst_root / dst).mkdir(parentsTrue, exist_okTrue) for subset in [afw, lfpw, helen, ibug]: sub_src src_root / subset if not sub_src.exists(): raise FileNotFoundError(f缺少子集目录: {sub_src}) for img_path in sorted(sub_src.rglob(*)): if img_path.suffix in IMG_EXTS and not img_path.name.startswith(.): merge_one(img_path, dst_root, subset, f{subset}_{img_path.stem}) if __name__ __main__: parser argparse.ArgumentParser(description300W 原始数据归并) parser.add_argument(--src_root, typePath, requiredTrue) parser.add_argument(--dst_root, typePath, requiredTrue) args parser.parse_args() main(args.src_root, args.dst_root)这个脚本的核心逻辑是resolve_split通过路径判断训练测试归属IBUG 强制进 teststem[:20]截断文件名防止某些子集文件名过长同时加上AFW_、LFPW_这样的前缀_dup后缀处理重名。运行前先确认源目录形态如果你的包是另一种层级改resolve_split的规则即可不要动其它部分。3.3 pts 转 npz把 68×2 坐标一次性转成 numpy 数组图归并完之后标注也要跟着进整理包。我习惯把 pts 转成 npz一个 npz 文件里放 landmarks、bbox、source 三个字段训练时直接np.load就能拿 float32 数组比逐行解析 pts 快一个量级。转换脚本长这样# pts_to_npz.py import numpy as np from pathlib import Path def parse_pts(path: Path) - np.ndarray: coords [] in_block False for line in path.read_text(encodingutf-8, errorsignore).splitlines(): line line.strip() if line {: in_block True continue if line }: break if in_block: parts line.replace(\t, ).split() if len(parts) 2: coords.append([float(parts[0]), float(parts[1])]) pts np.asarray(coords, dtypenp.float32) if pts.shape ! (68, 2): raise ValueError(f解析异常: {path}, shape{pts.shape}) return pts def compute_bbox(pts: np.ndarray, margin_ratio: float 0.1) - np.ndarray: x1, y1 pts.min(axis0) x2, y2 pts.max(axis0) w, h x2 - x1, y2 - y1 return np.array([x1 - w * margin_ratio, y1 - h * margin_ratio, x2 w * margin_ratio, y2 h * margin_ratio]) def convert_all(pts_root: Path, npz_root: Path): for pts_path in sorted(pts_root.rglob(*.pts)): try: pts parse_pts(pts_path) except ValueError as e: print(f[skip] {e}) continue bbox compute_bbox(pts, margin_ratio0.1) out_path npz_root / f{pts_path.stem}.npz np.savez(out_path, landmarkspts, bboxbbox, sourcenp.array(pts_path.name))参数说明margin_ratio0.1表示在关键点包围盒基础上向外扩 10%得到的 bbox 是训练裁剪用的初始框source字段记录原始 pts 文件名方便回溯。转换失败只跳过单个文件并打印日志不中断整个流程——这是整理 300W 这类多来源数据时最重要的工程习惯。4. 训练/验证划分与人脸框归一化让数据进得去网络也出得来评估数据进了标准目录下一步是让它在训练和评估两个场景下都成立。这一章解决两个问题怎么把 689 张官方测试图拆成验证集和测试集以及怎么把人脸框裁剪和关键点归一化做对。很多人在这两步图省事结果训练指标和论文指标对不上。4.1 生成划分索引官方划分与自定义划分并存我推荐的默认划分是训练集沿用官方的 3148 张验证集用 LFPW 测试 224 张加 HELEN 测试 330 张测试集用 IBUG 的 135 张。这样好处很明显——验证集对应论文里的 Common 口径测试集对应 Challenging 口径你在调参时看到的是 Common NME最后报结果时两个口径都拿得出来。如果你想把 IBUG 也拿来做验证可以在 meta 里新增一个val_ibug字段但千万别动test名单不然复现时说不清楚。# build_index.py import json from pathlib import Path def build_index(root: Path, out_path: Path): index {train: [], val: [], test: []} for split in index.keys(): img_dir root / images / split npz_dir root / npz / split for img in sorted(img_dir.glob(*.jpg)): stem img.stem npz npz_dir / f{stem}.npz if not npz.exists(): continue index[split].append({ id: stem, image: str(img.relative_to(root)), npz: str(npz.relative_to(root)), subset: stem.split(_)[0], }) out_path.write_text(json.dumps(index, indent2)) if __name__ __main__: build_index(Path(300W), Path(300W/meta/dataset.json))这份索引把id、image、npz、subset四件事绑在一起所有训练脚本只读这个 JSON。subset字段直接由文件名前缀推导比在代码里写死规则健壮得多。如果你要自定义划分就在这个脚本的输出上叠加另一个splits_custom.json两份文件并存不要覆盖默认索引。4.2 bbox 裁剪与关键点归一化中心对齐和尺度不变一次到位人脸关键点训练最常见的输入是「以人脸为中心的方形裁剪图」。判断一块裁剪是否合格看三件事人脸在图中是否居中、尺度是否被归一化、关键点坐标是否和图像坐标在同一个空间。下面的函数把 bbox 变成方形裁剪再把关键点映射到裁剪后的像素坐标# align.py import cv2 import numpy as np def crop_and_align(img: np.ndarray, landmarks: np.ndarray, bbox: np.ndarray, target: int 256, expand: float 1.2): x1, y1, x2, y2 bbox cx, cy (x1 x2) / 2.0, (y1 y2) / 2.0 side max(x2 - x1, y2 - y1) * expand half side / 2.0 left, top int(max(cx - half, 0)), int(max(cy - half, 0)) right, bottom int(left side), int(top side) crop img[top:bottom, left:right] crop_h, crop_w crop.shape[:2] scale_x, scale_y target / crop_w, target / crop_h crop cv2.resize(crop, (target, target)) pts_shift landmarks - np.array([left, top]) pts_scale pts_shift * np.array([scale_x, scale_y]) return crop, pts_scale关键点是side max(w, h) * expand这一步把人脸短边补足到和长边一样避免裁剪图里人脸被压扁。expand1.2表示在 bbox 基础上再扩 20%留出下巴和额头附近的空间这个值太小会把嘴唇顶到图像边缘太大会引入过多背景干扰。裁剪靠近图像边界时left/top被 clamp 到 0此时crop的实际尺寸小于side所以缩放系数必须用crop_w、crop_h重新算不能用原始side——这是最容易错的一行。归一化建议在数据加载器里做pts_scale / target得到 0 到 1 的坐标网络输出后乘以target还原。但落盘的 npz 里永远存原始像素坐标不要存归一化结果因为评估 NME 时需要原始坐标和原图分辨率对齐。4.3 dataset.json 生成训练时只读一个文件第 4.1 节已经生成了 dataset.json这里补一句它实际长什么样。一个典型的训练条目是这样的{ id: LFPW_0045, image: images/train/LFPW_0045.jpg, npz: npz/train/LFPW_0045.npz, subset: LFPW }训练脚本只需要根目录路径加这个 JSON就能定位图像和标注。npz 里已经包含了 landmarks 和 bbox训练脚本不必再解析任何原始格式。把 landmarks 直接塞进 JSON 而不是放进 npz 的做法我不推荐——npz 是二进制读取几百 KB 的关键点数组重复出现在 JSON 里会显著拖慢加载速度而且 JSON 变大后 diff 和版本管理都很痛苦。5. 300W 整理避坑指南最容易翻车的 5 个细节这一章是整理 300W 时最容易踩的五个坑每一条都是真实发生过的翻车经历。现象、原因、解决方式一条条说清楚你可以对照自己的整理包逐项排查。5.1 坑一HELEN 里的灰度图让通道处理在训练中途才暴露现象训练脚本用 PIL 加载图像跑了几百个 step 后突然报shape mismatch一看是某张图只有单通道。原因HELEN 子集里混有灰度 JPEGPIL 按实际通道数返回modeL而你的输入 pipeline 默认三通道。解决在数据加载器里统一转 RGB不要依赖某个库的默认行为。cv2.imread即使遇到灰度图也会复制成三通道这个特性会掩盖问题所以用 PIL 做数据加载时务必显式写convert(RGB)。整理包在 merge 阶段就可以顺带检查通道数把异常图单独列一个channels.log。5.2 坑二LFPW 下载源失效整理包必须带校验而不是只给脚本现象按别人给的脚本下载 LFPW链接 404换一个镜像下回来的图有几张是损坏的解压时静默跳过训练时才发现样本少了一部分。原因LFPW 发布页年久失修外链频繁跳转且官方包没有提供文件级校验。解决整理包必须内置完整数据不只是脚本数据落盘后计算每张图的 SHA256生成checksums.txt之后每次改动数据都能用sha256sum -c验证完整性。没有校验的整理包本质上是一份没法复现的半成品。5.3 坑三pts 文件里的 n_points 与实际行数不一致现象按n_points: 68读坐标某张图解析出来的数组只有 66 行有的文件行尾是\r\n字符串切分时混进空行坐标整体错位。原因多来源标注文件格式不严格人工编辑过的手工文件尤其容易出现这类问题。解决像第 3.3 节那样只认花括号块内的实际坐标行数然后统一断言(68, 2)。这条规则要写死在解析函数里任何不满足的样本直接跳过并记录不能靠try/except吞掉异常否则数据缺失会潜伏到训练中段才暴露。5.4 坑四训练用的扩大框和评估用的归一化口径不一致现象训练时裁剪正常Loss 下降也正常但最后报告的 NME 比论文高出一大截。原因训练使用的 bbox 是自算的关键点外扩框而评估时如果换回官方检测框或用整图缩放两次坐标空间的尺度基准不一样NME 分母也不一样数字自然对不上。解决在整理包里明确区分bbox和bbox_expand。bbox是自算的紧凑框bbox_expand是训练裁剪框评估时统一用同一个bbox并在 README 里写清 NME 的归一化分母是人脸框对角线还是两眼距离否则论文可复现性无从谈起。5.5 坑五只存归一化坐标丢掉原始坐标评估时没有后悔药现象整理时为了省事把关键点全部转成 0 到 1原图也裁成了 256×256。后来想换一种评估协议、想画错误样本的对比图发现原始坐标已经找不回来了。原因预处理阶段做完归一化就覆盖了原始信息没有设计留底字段。解决npz 里永远同时保存landmarks原始像素坐标和bbox归一化只发生在数据加载器返回给网络的瞬间。这个习惯的价值在调试时才会体现——当你需要复现某张图的预测结果时能随时回到原始分辨率重新计算而不是对着打包后的数据后悔。6. 让整理包跑起来数据加载器与「画点验证」这两个落地技巧整理包的终点不是目录整齐而是能接进训练循环。最后这一章给两个实用技巧一个约四十行的数据加载器和一个画点验证脚本。后者是我每次整理数据后必做的一步它能在一分钟内告诉你整理包到底对不对。6.1 从 dataset.json 到训练 Tensor一个紧凑的 PyTorch 数据加载器import json import cv2 import numpy as np import torch from torch.utils.data import Dataset class FaceLandmarkDataset(Dataset): def __init__(self, root: str, split: str train, target: int 256): with open(f{root}/meta/dataset.json, encodingutf-8) as f: self.records json.load(f)[split] self.root, self.target root, target def __getitem__(self, idx): r self.records[idx] img cv2.imread(f{self.root}/{r[image]})[:, :, ::-1] # BGR - RGB d np.load(f{self.root}/{r[npz]}) crop, pts crop_and_align(img, d[landmarks], d[bbox], self.target) pts_norm pts / self.target tensor torch.from_numpy(crop.transpose(2, 0, 1)).float() / 255.0 return tensor, torch.from_numpy(pts_norm.astype(np.float32)) def __len__(self): return len(self.records)这个加载器把第 4 章的索引和归一化逻辑接起来了crop_and_align直接用前面定义的那个函数。target256是默认输入尺寸关键点被归一化到 0 到 1 返回给网络。数据增强可以放在__getitem__里对crop和pts做同步随机变换比如随机平移几个像素、随机旋转几度但要保证变换矩阵同时作用在图像和关键点上否则点就飞了。6.2 一分钟画点验证整理包有没有整理对一眼就能看出来import matplotlib.pyplot as plt def visualize(root: str, idx_path: str, save_path: str check.png): with open(f{root}/meta/dataset.json, encodingutf-8) as f: recs json.load(f)[train] r recs[0] img cv2.imread(f{root}/{r[image]})[:, :, ::-1] pts np.load(f{root}/{r[npz]})[landmarks] plt.imshow(img) for a, b, color in [(0, 17, C0), (17, 27, C1), (27, 36, C2), (36, 48, C3), (48, 68, C4)]: plt.plot(pts[a:b, 0], pts[a:b, 1], o, colorcolor, markersize3) plt.axis(off) plt.savefig(save_path, dpi200)画点验证的价值在于点序错误、坐标偏移、标注错位这些问题任何数值统计都很难发现但画在图上立刻原形毕露。你不需要每张图画从训练集和测试集各抽 30 张按五组颜色检查轮廓、眉、眼、鼻、嘴是否落在正确位置。我第一次整理 300W 时跳过这一步训练到第三天 Loss 降不下去才发现眉毛和眼睛的点序反了最后推倒重来。从那以后画点检查成了我数据管线的固定环节每次换数据源或改整理脚本都要重新跑一遍。这个习惯看起来笨却是我踩过坑之后最想保留的后悔药。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑