面部关键点检测全链路:从CSV坐标到CNN回归与掩码MSE

发布时间:2026/11/2 19:19:36
面部关键点检测全链路:从CSV坐标到CNN回归与掩码MSE

简介:CVND首个实战项目,面向已具备Python基础、希望入门计算机视觉与深度学习的学习者,围绕面部关键点检测完成从数据加载、可视化到CNN训练与推理的完整实践,可服务于人脸跟踪、姿态识别与表情分析等场景。压缩包约330.12MB,包含约2000个文件,以jpg人脸图像和ipynb交互式笔记本为主体,辅以csv关键点标注、xml级联检测器、py模型代码及说明文档,便于按数据、训练、检测三个环节查阅。已有201人学习,说明其作为课程项目复现材料具有一定参考价值。四个笔记本分别覆盖数据探索、网络结构定义与训练、Haar级联加CNN的完整检测管线以及趣味滤镜应用,读者可据此理解关键点回归任务的数据组织、模型搭建和端到端预测流程,并借助随附标注与源码复现实验结果、排查训练中的常见问题。

1. 面部关键点检测的知识地图:一条从 CSV 到 30 个数字的链

面部关键点检测(Facial Keypoints)这个项目,本质上就是把一张 96×96 的灰度人脸压成 30 个数字:15 个点,每个点一个 x、一个 y。整份 Jupyter Notebook 实际上是一条流水线——CSV 读坐标 → 丢掉缺点的样本 → 增强时带着坐标一起变换 → CNN 回归 30 维 → 掩码 MSE → 人脸框裁剪后推理。流水线上跑的始终是同一个东西:坐标口径。口径守住了,收敛只是时间问题;口径在哪一步断了,后面所有调参就都成了玄学。这份笔记就按这条流水线拆开,适合已经跑通过一遍、但说不清每个模块为什么这么写的人。

2. 数据层:CSV 里的 30 列坐标怎么变成网络能吃的张量

这个项目最容易让人低估的地方,是它看起来数据很干净——一个 CSV,一行一张图,后面跟着 30 个数。真正动手才会发现,这 30 个数有三种状态:完整、部分缺失、以及整行缺失。数据层要解决的就是把这三种状态翻译成张量能理解的形式,同时保证后面每一层看到的坐标系是同一个。

2.1 一行数据拆成图像、坐标、掩码

CSV 的第一列通常是图像文件名,后面 30 列按(x0, y0, x1, y1, ..., x14, y14)的顺序排开。第一步不是急着读图,而是先把这 30 列 reshape 成(N, 15, 2),让“点”成为一个维度,后面所有按点做的运算(掩码、翻转映射、逐点损失)才有地方落。

import numpy as np import pandas as pd df = pd.read_csv('keypoints.csv') # 第一列是文件名,后面 30 列两两成对:(x0,y0), (x1,y1), ... coord_cols = df.columns[1:] keypoints = df[coord_cols].values.astype(np.float32).reshape(-1, 15, 2) # 一个点只要 x 或 y 有一个是 NaN,这个点就当作没标注 mask = ~np.isnan(keypoints).any(axis=2) # (N, 15) 的布尔矩阵 print(mask.mean(axis=0)) # 每个关键点的标注覆盖率

mask是整个项目里最该早点建出来的东西。它有三个用途:算损失时把缺失点排除掉、统计哪些点天生标注得少、以及在做可视化时决定要不要画这个点。把mask.mean(axis=0)打印出来看一眼,通常会看到嘴角、下唇中点这些点的覆盖率明显低于眼睛中心——这就是为什么训练到后期,眼睛那几个点早早就贴住了,嘴角还在原地晃。不是模型笨,是那些样本根本没告诉它答案。

提示:读完 CSV 先做这一件事,覆盖率表比任何损失曲线都更早告诉你“哪些点注定学不好”。

2.2 15 个关键点的下标顺序要背下来

这 15 个点是有固定顺序的,而这个顺序会被后面所有涉及“点与点交换”的操作引用。不背下来,写翻转增强的时候只能靠猜。

下标名称下标名称
0left_eye_center8right_eyebrow_inner_end
1right_eye_center9right_eyebrow_outer_end
2left_eye_inner_corner10nose_tip
3left_eye_outer_corner11mouth_left_corner
4right_eye_inner_corner12mouth_right_corner
5right_eye_outer_corner13mouth_center_top_lip
6left_eyebrow_inner_end14mouth_center_bottom_lip
7left_eyebrow_outer_end

这里有个绕不开的歧义:名字里的 left / right 到底是“看照片的人的左边”还是“被拍的人的左边”。不同数据集的约定并不一致,笔记里也不该替你拍板。真正重要的是——不管你按哪种理解,配对关系必须自洽:0 和 1 是一对,2 和 4 是一对,3 和 5 是一对,6 和 8、7 和 9、11 和 12 各是一对。把这张配对表写在代码旁边,比记名字可靠得多。

2.3 坐标归一化:除以 95 还是除以 96

原始坐标是像素值,范围 0 到 95。如果直接拿去回归,损失的量级会在几百到几千,学习率稍微大一点就炸,小一点又几乎不动。所以坐标必须先缩放。

IMG_SIZE = 96 def normalize_keypoints(kp, size=IMG_SIZE): # 像素坐标 [0, size-1] -> [0, 1] return kp / (size - 1) def denormalize_keypoints(kp, size=IMG_SIZE): # 推理后一定要还原,否则画出来的点全挤在左上角 return kp * (size - 1)

除以size - 1而不是size,是因为像素下标的最大值就是 95。这个差别在 96 的尺度下只有 1%,肉眼看不出,但它保证了 0 和 95 严格映射到 0 和 1,边界样本不会出现 1.01 这种越界值。除以 96 也不是不能用,只要你训练和推理两边用的是同一个函数——真正致命的是一边除 95 一边除 96,模型学到的“眼睛在 0.42”在推理时被解释成另一个位置,整张脸的点会系统性偏移,而这种偏移在小图上极难察觉。

图像侧的归一化同理。灰度图读进来是 0 到 255 的 uint8,转 float 后除以 255 是最常见的做法。如果你用了Normalize(mean, std),那这组均值和方差必须写进配置、在推理时原样复用。写在两个不同 cell 里的魔法数字,是这个项目里最经典的“血泪经验”来源。

还有一个容易被跳过的小坑:CSV 里的文件名可能是不带前导零的,而磁盘上的文件是000001.jpg这种补零格式。批量读图时如果直接拼接路径,会得到一堆“文件不存在”,然后误以为是数据集下载不全。

3. 增强管线:图像动了,坐标必须一起动

增强是整条链上唯一一个“同时改两类数据”的环节。图像变换本身是现成的,难的是让 30 个坐标跟着做同一件事。分类任务里增强写错了顶多掉几个点,回归任务里增强写错了,模型会学出一个永远贴着图像中心的“平均脸”。

3.1 为什么这条管线里最容易翻车的是增强

普通的数据增强只关心像素,所以可以随便组合、随便调顺序。关键点回归不行,因为每一次空间变换都对应一个坐标变换,而且这个对应关系必须精确到像素下标。更麻烦的是,增强管线的执行顺序会影响结果:先裁剪再翻转,和先翻转再裁剪,坐标的中间值不一样,虽然理论上最终结果一致,但如果某一步忘了同步坐标,你很难从最终的图上判断是哪一步出的问题。

比较稳的做法是把“图像 + 坐标 + 掩码”打包成一个样本单元,让每个变换函数同时吃这三样、同时吐这三样。这样任何一步漏改都写不出来,因为函数的签名就不允许。

import numpy as np import cv2 import torch from torch.utils.data import Dataset class KeypointDataset(Dataset): def __init__(self, df, img_dir, transform=None): self.df = df.reset_index(drop=True) self.img_dir = img_dir self.transform = transform def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] # 必须按灰度读,和推理时的口径保持一致 img = cv2.imread(f"{self.img_dir}/{row['image_name']}", cv2.IMREAD_GRAYSCALE) if img is None: raise FileNotFoundError(row['image_name']) kp = row.iloc[1:].values.astype(np.float32).reshape(15, 2) mask = ~np.isnan(kp).any(axis=1) sample = {'image': img, 'keypoints': kp, 'mask': mask} if self.transform: sample = self.transform(sample) return sample

__getitem__里那个raise不是多余的。数据集里混进一张损坏图或者文件名对不上,静默跳过会让训练集悄悄少几十个样本,而你只会看到损失曲线有点抖。让它早点炸,比后面查半天强。

3.2 水平翻转:x 镜像之外还要做下标配对

水平翻转是所有增强里性价比最高的一个——人脸左右近似对称,翻一倍数据量几乎不花成本。但它也是唯一一个同时改坐标值和下标顺序的操作,漏掉任何一半都会翻车。

# 15 个关键点在水平翻转后的下标映射 FLIP_INDEX = np.array([1, 0, 4, 5, 2, 3, 8, 9, 6, 7, 10, 12, 11, 13, 14]) def flip_keypoints(kp, width): """kp: (15, 2),坐标单位为像素;width: 图像宽度""" kp = kp.copy() kp[:, 0] = (width - 1) - kp[:, 0] # x 轴镜像,注意是 width-1 return kp[FLIP_INDEX] # 再按下标重排,两步缺一不可

这段代码里有两个细节值得单独说。

第一个是(width - 1) - x。像素下标从 0 开始,宽度为 96 的图,下标范围是 0 到 95,所以镜像公式是95 - x。写成width - x会让所有点整体右移一格,15 个点全偏,但偏差只有 1 像素,在 96×96 的图上做可视化几乎看不出来——这就是它危险的地方。

第二个是映射表的作用。以眼睛中心为例:翻转后,原来的左眼中心跑到了右边,它现在的位置在语义上应该由“右眼中心”这个槽位来承载,所以 0 号槽和 1 号槽的值要互换。同理 2↔4、3↔5、6↔8、7↔9、11↔12,而鼻子(10)和上下唇中点(13、14)在垂直中线上,映射到自己。

如果只做 x 镜像、不做下标重排,会发生什么?坐标位置是对的,但语义全乱了:模型被要求用 0 号槽去预测鼻子左边的眼睛、用 1 号槽去预测右边。一半样本说 0 号在左、一半说 0 号在右,模型唯一能学到的解释就是把两个槽都预测到两眼中间。表现出来就是“眼睛的两个点黏在一起”,很多人会误判成模型容量不够。

提示:写完翻转后,务必在 Notebook 里画一张三联图——原图、翻图、翻图+重排后的点。三张图的点位置应该完全对称,缺这一步基本等于没验证。

3.3 随机裁剪:越界的关键点该丢还是该置 NaN

随机裁剪比翻转更麻烦,因为它会让一部分关键点跑到裁剪框外面。这时候没有“正确答案”,只有三种处理方式,每种都有代价。

处理方式做法代价
整样本丢弃只要有点出界就换一张实现最简单,但裁剪比例稍大就废掉大量样本
置 NaN 并更新掩码出界的点标记为缺失利用率高,但要求损失函数必须支持掩码
坐标截断到边界clamp 到 [0, size-1]千万不要用,会给模型灌入“嘴角在角落”的错误监督

第三种是纯粹的坑。截断后的坐标看起来合法,损失也算得出来,模型会认真地去学一个根本不存在的位置。表现出来是嘴角的点在推理时总是被拉到人脸框边缘。

选框的写法也要留意:裁剪窗口不能纯随机,得保证人脸主体还在框内。工程上常见的做法是先算出一个包含所有有效关键点的外接矩形,再在这个矩形附近做小范围抖动,而不是在整张图上随机开窗。

def random_crop(sample, out_size=96, jitter=12): img, kp, mask = sample['image'], sample['keypoints'].copy(), sample['mask'].copy() h, w = img.shape[:2] valid = kp[mask] x0, y0 = valid.min(axis=0).astype(int) x1, y1 = valid.max(axis=0).astype(int) # 在关键点外接框上加抖动,保证人脸主体一定在裁剪窗口内 cx, cy = (x0 + x1) // 2, (y0 + y1) // 2 cx += np.random.randint(-jitter, jitter + 1) cy += np.random.randint(-jitter, jitter + 1) left = np.clip(cx - out_size // 2, 0, max(w - out_size, 0)) top = np.clip(cy - out_size // 2, 0, max(h - out_size, 0)) crop = img[top:top + out_size, left:left + out_size] kp = kp - np.array([left, top], dtype=np.float32) # 出界的点在掩码里抹掉,交给损失函数忽略 inside = (kp[:, 0] >= 0) & (kp[:, 0] < out_size) & \ (kp[:, 1] >= 0) & (kp[:, 1] < out_size) mask = mask & inside return {'image': crop, 'keypoints': kp, 'mask': mask}

注意最后一步是先减偏移、再判断是否在框内,顺序反了会得到一批“坐标已经出界但掩码还是 True”的样本,损失里就混进了负坐标。这种错误不会报异常,只会让训练慢一点、结果差一点。

4. 模型与损失:回归头、掩码 MSE 与训练循环

数据通了之后,模型部分反而是这个项目里最“标准”的一段。一个三四层的卷积堆叠接两个全连接,就足以在这个数据集上得到肉眼可用的结果。真正需要想清楚的是三件事:输出怎么组织、损失怎么屏蔽缺失点、训练时盯什么。

4.1 输出 30 维向量还是 15×2 张量

两种写法在数学上等价,在工程上手感差别很大。

  • 30 维平铺:全连接层直接输出 30 个数,和 CSV 的列顺序天然对齐,写损失时pred.view(-1)一把梭。代价是任何按点做的操作都要 reshape。
  • 15×2 张量:最后一层输出 30 维后.view(-1, 15, 2),好处是掩码可以直接广播到点上,逐点误差、逐点权重都写得很自然。

推荐第二种,因为掩码是这个项目的刚需。

import torch.nn as nn class KeypointNet(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 96 -> 48 nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 48 -> 24 nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 24 -> 12 nn.Dropout(0.3), ) self.regressor = nn.Sequential( nn.Linear(128 * 12 * 12, 512), nn.ReLU(), nn.Dropout(0.3), nn.Linear(512, 30), # 15 个点 × 2 个坐标 ) def forward(self, x): x = self.features(x) return self.regressor(x.flatten(1)).view(-1, 15, 2)

第一层Conv2d(1, ...)的输入通道数写 1,是因为前面统一按灰度读了。如果数据集里混着彩色图而你只对部分图做了灰度转换,这里就会因为批次内通道数不一致直接报错——这算幸运的,不幸的版本是全部被 resize 成三通道,然后模型用三倍的参数学一份重复信息。

卷积堆叠里没有加 BatchNorm,是因为这个项目通常在较小的显存上跑,批量不会太大,BN 的统计量会抖。真要用,放在激活之前、并且在推理时记得model.eval(),两个地方漏一个都会让验证损失莫名其妙地高于训练损失。

4.2 掩码 MSE:让 NaN 不参与梯度

这是整个项目最核心的一行逻辑。数据里有点是 NaN,直接算 MSE 会得到 NaN,反传一下权重全变成 NaN,之后所有的参数都是 NaN,损失曲线画出来是一条断掉的空白。这是初学者遇到的第一个“黑匣子”。

常见的错误修法有两种,都不能用:

  • 用torch.nan_to_num把 NaN 替换成 0。这等于告诉模型“这个点在人脸左上角”,模型会认真地去拟合,最后学出的嘴角位置会被强行拉向原点。
  • 用masked_select从预测里挑出有效的点。这能跑通,但每个批次的张量形状不固定,如果有哪个批次刚好全缺失,直接报错。

正确的做法是让掩码参与算术,而不是参与索引。

def masked_mse(pred, target, mask): """ pred, target: (B, 15, 2) mask: (B, 15) 的 0/1 张量 """ diff = (pred - target) ** 2 # (B, 15, 2) diff = diff.mean(dim=2) # 每个点合成一个标量误差 (B, 15) diff = diff * mask.float() # 缺失点的误差乘 0,梯度自然为 0 return diff.sum() / (mask.float().sum() + 1e-8)

分母那个1e-8是后悔药。理论上一个批次里不可能所有点都缺失,但当你为了调试把批量调成 1、又恰好抽到一张全缺失的图时,除以 0 会得到 inf,训练直接卡死。加个极小值,代价可以忽略。

还有一处需要在__getitem__里先处理:target里的 NaN 必须在进损失之前就被替换成 0(因为NaN - x还是 NaN,乘 0 也还是 NaN)。掩码的作用是让这些位置不贡献损失,不是让它们不参与计算。

kp = np.nan_to_num(kp, nan=0.0) # 放在 dataset 里,配合 mask 使用

顺序很关键:先补零、再乘掩码。反过来就白做了。

4.3 训练循环里必须盯的三个数

训练循环本身没什么花样,但有三类数值需要每轮打印出来,否则出问题时你只能靠猜。

第一类是训练损失和验证损失的比值。这个项目的数据量不大,过拟合来得很快。如果训练损失还在稳步下降、验证损失已经抬头,那就该加 dropout 或者提前停,而不是继续加轮数。

第二类是逐点平均误差,最好按 15 个点分别打印。

@torch.no_grad() def per_point_error(model, loader, device): model.eval() total = torch.zeros(15, device=device) count = torch.zeros(15, device=device) for batch in loader: img = batch['image'].to(device) kp = batch['keypoints'].to(device) mask = batch['mask'].to(device).float() pred = model(img) # 还原到像素尺度,误差才有直观意义 err = (pred - kp).norm(dim=2) * (IMG_SIZE - 1) # (B, 15) total += (err * mask).sum(dim=0) count += mask.sum(dim=0) return (total / (count + 1e-8)).cpu().numpy()

打印出来通常会看到一个明显的高低分布:眼睛那几个点误差最小,嘴角和下颌相关的点误差最大。这和前面 CSV 覆盖率表是对得上的——不是网络结构的问题,是监督信号本来就少。看清这一点,就不会盲目去加深网络了。

第三类是学习率与损失量级。归一化到 [0,1] 之后,初始阶段的 MSE 大致在 0.0x 这个量级,用1e-3的 Adam 比较稳。如果你发现损失第一天就停在 0.2 不动,先回头检查坐标是不是没归一化——这是最常见的原因。

4.4 推理链路:检测框、灰度与反归一化

训练完只是拿到了一组权重,从“一张自拍照”到“脸上画好 15 个点”还差一整段链路。这段链路上每一步都必须和训练时完全对齐,否则前面所有的努力都会在最后一步打水漂。

import cv2, torch def predict_image(img_path, model, detector, device='cpu'): img = cv2.imread(img_path) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 和训练一致:灰度 faces = detector.detectMultiScale( gray, scaleFactor=1.1, minNeighbors=5, minSize=(64, 64) ) results = [] for (x, y, w, h) in faces: roi = gray[y:y+h, x:x+w] # 紧贴人脸裁剪 roi = cv2.resize(roi, (96, 96)).astype('float32') / 255.0 tensor = torch.from_numpy(roi).unsqueeze(0).unsqueeze(0).to(device) with torch.no_grad(): kp = model(tensor).cpu().numpy()[0] # (15, 2),范围 [0, 1] # 反变换:先放大回裁剪框尺寸,再整体加偏移回到原图 kp[:, 0] = kp[:, 0] * (w - 1) + x kp[:, 1] = kp[:, 1] * (h - 1) + y results.append((x, y, w, h, kp)) return img, results

detectMultiScale的三个参数值得记一下:scaleFactor控制金字塔缩放步长,越小越慢越不容易漏检;minNeighbors控制候选框的合并阈值,调大能减少误检但会漏掉侧脸;minSize是下限,低于这个尺寸的框直接不看,能省掉大量在小噪声上的误检。

反变换那两行是整段代码里最容易写错的地方。顺序必须是“先乘尺寸、再加偏移”,写成(kp + offset) * size或者kp * size(漏了偏移)都会让点整体跑偏。而且这里用的是w - 1/h - 1,和前面归一化时的size - 1是同一个约定,不能一边 95 一边 96。

还有一个隐蔽的问题:训练时的裁剪是“按关键点外接框 + 抖动”,推理时是“人脸检测框”,这两个框的松紧程度不一样。检测框通常比关键点外接框大一圈,把更多的头发和背景带进来。模型在训练时没见过这种构图,预测会偏。缓解办法是在训练时故意把裁剪框放宽一点,让分布更接近推理时的输入。

5. 易错点排查:五处坐标口径不一致,损失曲线先崩

这个项目报错很少,但“跑得通却不对”的状态特别多。下面五处是按踩坑频率排的,每一条都按“症状 → 为什么容易漏 → 怎么补”写。

5.1 数据与增强侧的三处

易漏点一:NaN 没有在进损失前补零。

为什么漏:很多人只做了掩码,以为乘 0 就能屏蔽。但NaN * 0的结果仍是 NaN,不是 0。这个错误不会报错,只会让损失从第一个批次开始就是 NaN,然后所有权重变成 NaN,训练看起来在跑,实际一行有效梯度都没有。

怎么补:在 dataset 的__getitem__里统一做一次np.nan_to_num(kp, nan=0.0),然后在损失里再乘掩码。两件事都做,顺序不能反。写完顺手在损失函数里加一句断言,assert not torch.isnan(loss),把问题挡在第一次迭代。

易漏点二:水平翻转只翻图像、没换下标。

为什么漏:翻完之后点的位置看起来是镜像的,肉眼看不出问题。只有把原图和翻图的点叠在一起,才会发现“左眼的点落到了右眼位置”——但因为人脸对称,这个错位在视觉上极具欺骗性。

怎么补:翻完立刻画三联图验证:原图、纯镜像图、镜像+重排图。第三张的点应该和第二张完全重合。另外,如果模型训练到后期眼睛的两个点始终黏在一起,优先怀疑这里。

易漏点三:随机裁剪后没有更新掩码。

为什么漏:坐标减了偏移,看起来还在合理范围内,只是变成了负数或者大于 95。网络照样能训,损失照样会降,只是降得慢、最后精度差。因为一部分样本在教模型“嘴角在图像外面”。

怎么补:裁剪后立刻算一遍inside,和原掩码做逻辑与。更保险的做法是在裁剪函数里打印一次“平均每张图保留多少个点”,正常应该在 12 到 15 之间,掉到 10 以下说明裁剪太激进。

5.2 训练与推理侧的两处

易漏点四:训练归一化和推理归一化不一致。

为什么漏:训练管线被封装在Compose里,推理代码是手写的,两边的/255.0、Normalize均值方差、甚至 resize 的插值方式都可能不一样。这种不一致不会报错,只会让预测点整体偏移或者尺度缩放。

怎么补:把归一化参数抽成一个常量或者配置文件,训练和推理都引用同一份。更彻底的做法是把预处理写成可复用函数,推理时直接调用训练时那份,而不是重新写一遍。

易漏点五:模型停在 train 模式或者忘了no_grad。

为什么漏:model.eval()影响的是 dropout 和 BN。这个项目的网络里有 dropout,训练时随机丢 30% 的神经元,推理时如果没切 eval,每次预测结果都不一样,画出来的点会轻微抖动。而且no_grad忘了加,显存会一路涨到爆。

怎么补:推理函数的第一行固定写model.eval(),第二行固定写with torch.no_grad():。养成习惯之后就不用每次想。

提示:这五条里前三条属于“静默错误”,不会抛出任何异常;建议在 Notebook 里单独留一个 cell,把这五项各写一行断言,每次重启内核先跑一遍。

6. 复习清单:从空白 Notebook 到跑通,按这个顺序自查

如果要把这个项目重做一遍,我大概会按下面这个顺序推进,每一步都留下可验证的产物,而不是一口气写完再调试。

阶段要产出的东西验证方式
数据读取(N,15,2)的坐标数组 +(N,15)掩码打印逐点覆盖率,确认和肉眼观察一致
可视化随机 4 张图叠点点必须落在鼻尖、眼角上,不能整体偏移
归一化坐标/95、图像/255打印 min/max,确认落在 [0,1]
增强翻转、裁剪各自独立测一遍三联图对比,点位置严格对称
模型输出(B,15,2)前向一次,打印形状和初始损失量级
损失掩码 MSE人为把某个点置 NaN,损失不应变成 NaN
训练损失曲线 + 逐点误差逐点误差分布和覆盖率分布方向一致
推理检测框 + 反变换在原图上画点,点要跟着人脸走

几个具体的检查动作,比表格更管用:

把一张图的坐标手动画出来,和原图对上。这一步能一次性抓出归一化错误、偏移错误、下标错位三类问题,而这三类占了所有翻车原因的八成。

把训练集的一批数据过一遍model,看初始损失。归一化正确的情况下,初始 MSE 应该在 0.0x 量级;如果看到 200、3000 这种数,说明坐标没归一化;如果看到nan,回去查掩码。

训练 5 个 epoch 就停下来看结果,不要一口气跑 50 轮。5 轮之后如果点还全部挤在图像中心,说明学习率太大或者坐标尺度不对,继续跑只是浪费时间。

最后,把推理链路单独当成一个“新任务”来做,不要假设它能自动复用训练代码。我自己第一次做的时候,训练侧一切正常,验证损失降到了很低的水平,结果推理出来的点全都贴在检测框的左上角——查了半天才发现反变换里漏了加偏移。从那以后,我每次写完反变换都会立刻反向验算一遍:把预测出来的归一化坐标手动乘回尺寸,看看第一个点是不是落在检测框内靠近鼻子的位置,不是就不往下走。希望这份清单能帮你少走两遍这条路。

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