广角人像矫正这个题目,我在做移动端影像算法的时候被反复问过:为什么用广角镜头拍合影,站在画面两边的人脸会明显变宽、变扁、往外拽,而画面中间的人却好好的?很多人第一反应是"镜头畸变没校正好",于是去拧径向畸变参数,结果中间的人脸被压成了锥子脸,背景的直线还弯了。CVPR 2022 有一篇讲半监督广角人像矫正的文章,用的是多尺度 Transformer的骨架,把这个问题从"几何校正"重新定义成了"人像感知的形变回归"。这篇笔记不讲论文翻译,讲的是我读完、复现过一遍之后,对这套方案的理解、关键实现细节、以及踩过的坑。如果你在做手机影像、直播美颜、相机预览、或者任何需要处理广角人像的产品,这篇应该能帮你省掉不少试错时间;如果你只是想搞懂 Transformer 怎么做稠密形变预测,里面的多尺度结构和损失设计也能直接借鉴。
1. 把问题定义清楚:广角人像矫正到底在修什么
1.1 三种"畸变"被混着说,是方案翻车的起点
我见过太多项目在这里就偏了。摄像头出来的画面,至少有三种不同的形变叠在一起:第一种是光学畸变,来自镜片组,表现为桶形或枕形,它的数学形式是径向的,只跟像素到光心的距离有关;第二种是透视形变,来自近大远小的投影关系,广角镜头视场角大,画面边缘的物体在成像平面上被横向拉伸,人脸会被拉宽;第三种是人脸自身的三维形变,近距离拍摄时鼻子大、脸颊小,这是透视投影的必然结果,跟镜头好坏无关。
传统畸变矫正算法只处理第一种,它的目标函数是"把直线还原成直线",也就是所谓的直线保持。这个目标在建筑摄影里非常好用,但在人像上会出事:为了让边缘的直线变直,算法必须把边缘区域整体横向压缩,而边缘恰好就是人脸所在的位置,于是人脸被压扁,五官挤在一起。论文要解决的是第二种和第三种叠加起来的、跟人脸语义强相关的形变,它需要的不是"直线变直",而是"人脸看起来自然"。这个定义上的差别,直接决定了后面网络结构、损失函数、数据构造全都不一样。
1.2 直线保持为什么会把人脸压扁
拿最常见的柱面投影举例。柱面投影把成像平面卷到一个圆柱面上,本质是把画面边缘的内容按 1/cos 的规律往中间挤。假设视场角是 100 度,边缘位置对应的角度是 50 度,cos50° 大约是 0.64,也就是说边缘内容要被压缩掉三成多的宽度。一条直线确实变直了,但站在边缘的那张脸也被横向压掉了三成,从视觉上看就是个瘦长脸。人的视觉系统对人脸比例极其敏感,横向压 5% 就能被察觉,压 30% 就是"这谁啊"的程度。
所以人像矫正必须放弃全局统一的几何约束,改成空间自适应的形变场:在背景区域尽量保持直线的笔直,在人脸区域按人脸的自然比例做局部拉伸或压缩,两者之间平滑过渡。这个形变场没法用解析公式写出来,只能让网络去学。这也是为什么这篇论文最后回归的是一个稠密的偏移场,而不是一组镜头参数。用一组 k1、k2 去拟合,表达能力根本不够,因为同一个画面里,背景要的形变和人脸要的形变是互相矛盾的。
1.3 半监督这个设定不是噱头,是被数据逼出来的
这里有个很现实的困难:没有 ground truth。你要训练一个网络把畸变人像修正好,那"修正后应该长什么样"谁来定?找专业修图师一张张 P?成本高且主观,十个人能修出十个版本。用传统算法生成参考图?那传统算法本身就有人脸压扁的问题,等于把错误当成了监督信号。
论文选择的路子是半监督:合成数据提供像素级监督,真实数据提供分布上的约束。合成数据的做法是拿正常的人像照片,人为施加一个已知的广角畸变,把它变成"病态样本",原来的正常照片就是监督目标。这样配对数据是自动生成的,要多少有多少,而且监督信号精确到像素。但合成数据和真实拍摄之间存在明显的域差:真实的广角照片有镜头暗角、有噪声、有压缩伪影、有复杂的背景结构,合成数据太干净了。所以还需要真实的无标注人像照片参与训练,用对抗损失或者先验约束把模型往真实分布上拉。这个思路在图像复原领域很常见,但用在几何形变上要注意:形变任务的域差主要体现在局部纹理和边缘锐度上,而不是颜色分布,所以判别器的设计要偏结构而不是偏色彩。
提示:如果你打算复现,一定要先把"我要修的是哪一种形变"写清楚。三种形变混在一起调,永远调不出结果。
2. 方案骨架:为什么是"多尺度 + Transformer + 半监督"
2.1 形变场回归本质上是个长距离建模任务
先说为什么不能用普通的 U-Net。形变场回归看起来是个稠密预测任务,跟语义分割、深度估计很像,卷积网络似乎够用。但这里有一个隐藏的依赖关系:画面边缘某个像素该往哪个方向偏移,取决于整幅图的构图。同样一张脸,放在画面正中间不需要任何形变,放在边缘就需要大幅度的横向收缩,而这个"它在边缘"的信息,距离该像素可能有几百个像素远。
卷积的感受野是靠堆层数堆出来的。一个 3x3 卷积堆 20 层,理论感受野也就 41 个像素,要覆盖 1000 像素宽的画面,得堆上百层,参数量和显存都受不了,而且深层特征的分辨率已经被降得很低,回到原分辨率时细节全丢了。Transformer 的自注意力机制天生就是全局的,每个 token 都能直接看到其他所有 token,一次就能建立"我在边缘"和"我该收缩"之间的联系。这就是论文选 Transformer 的核心动机,不是什么"Transformer 更先进",而是这个任务的依赖关系确实跨度太大。
2.2 单一尺度不够:大形变要全局视野,细节要局部精度
纯 Transformer 的另一个问题是分辨率。自注意力的计算量跟 token 数量的平方成正比,在原始分辨率上做全局注意力,显存直接爆。常规做法是下采样到 1/16 甚至 1/32 再算注意力,但这样输出的形变场就非常粗糙,人脸五官的精细位置对不上,眼睛会被拉花。
论文的解法是多尺度并行:低分辨率分支负责估计大范围的、低频的形变趋势,比如"整个左半边需要往右收 8%";高分辨率分支负责高频细节,比如"眼角这里要往回推 2 个像素";中间再加一个尺度做过渡,最后通过跨尺度融合把几路结果拼起来。这个设计思路其实跟图像金字塔、拉普拉斯金字塔一脉相承,只不过这里的"频率"指的是形变场的频率,不是图像的频率。我在实现的时候发现,融合方式很关键:简单的逐元素相加会让高分辨率分支的细节被低频信号淹没,用拼接加卷积、或者带门控的加权融合,效果明显更好。
2.3 半监督的两条数据流怎么并到一次反向传播里
训练管线是这样的:一个 batch 里同时放合成配对样本和真实无标注样本。合成样本走有监督分支,前向得到矫正结果,直接跟监督目标算像素损失和感知损失;真实样本走无监督分支,也前向得到矫正结果,但不跟任何目标算像素损失,而是送进判别器判断"这看起来像不像真实拍摄的、未畸变的人像",同时加上人脸关键点先验和背景直线先验作为约束。
关键在于这两条流要共享同一个生成网络和同一次反向传播,不能分开训两个模型再合并。分开训会导致网络在两批数据上学到互相冲突的形变模式,推理时在真实照片上表现急剧下降。我在早期版本里就犯过这个错,合成数据训到 PSNR 三十多,一上真实照片就糊成一片。
注意:半监督里那个"无监督"分支不是完全没约束。人脸关键点、直线结构这些先验,本质上是用先验知识替代了标注,设计得好不好,直接决定真实数据上的效果。
3. 数据合成:这一步决定模型的上限
3.1 用一个参数化畸变模型反向造"病态样本"
合成数据的核心是:拿一张正常照片,施加广角畸变,得到病态输入,正常照片作为监督目标。施加畸变最常用的还是径向模型,公式是
$$ s(r) = 1 + k_1 r^2 + k_2 r^4 $$
其中 r 是归一化半径,s 是缩放系数。k1 取正对应桶形(广角),取负对应枕形。实际操作里我用 OpenCV 的 remap 做逆向采样:
import numpy as np import cv2 def wide_angle_warp(img, k1=-0.28, k2=0.06, center=None): h, w = img.shape[:2] if center is None: center = (w / 2.0, h / 2.0) cx, cy = center ys, xs = np.meshgrid(np.arange(h), np.arange(w), indexing="ij") # 归一化到 [-1, 1] x = (xs - cx) / (w / 2.0) y = (ys - cy) / (h / 2.0) r2 = x * x + y * y s = 1.0 + k1 * r2 + k2 * r2 * r2 # 为了防止 remap 采到图外,这里做一次缩放保护 map_x = (x * s) * (w / 2.0) + cx map_y = (y * s) * (h / 2.0) + cy return cv2.remap(img, map_x.astype(np.float32), map_y.astype(np.float32), interpolation=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REFLECT_101)方向要注意:如果你是想让正常图变成畸变图,就要在 remap 时反过来采样,也就是说映射函数要么对源图做逆变换,要么对映射坐标取反。我第一版就是因为方向搞反了,训出来的模型在干什么完全看不懂——它把本来正常的图又畸变了一次,然后还觉得自己学得很好。
参数范围怎么选?我的经验是:k1 在 -0.35 到 -0.15 之间,k2 在 0 到 0.1 之间,基本能覆盖主流广角镜头的表现。k1 绝对值太大的话,采样点会大量落到图外,画面四角全是拉伸出来的假内容,模型会学成一堆伪影。可以先用一张带网格线的测试图跑一遍,肉眼看四角的拉伸程度,调到和你目标设备的实际表现接近为止。
3.2 人脸区域要特殊处理,否则监督信号是错的
上面这个全局径向畸变有个问题:它把人脸和背景一起畸变了。但真实的广角人像畸变,在画面中心和边缘的强度差异极大,而且人脸的畸变模式跟背景不太一样——背景是整体的横向拉伸,人脸是被"拽"宽并且五官比例变化。
我在合成时加了一步人脸感知的调制:用一个人脸检测加关键点的模型,先定位每张图里的人脸框,然后根据人脸中心到画面中心的归一化距离,对畸变强度做一个加权。距离越远,畸变越强;同时在人脸框内部,使用略微不同的 k 值组合。这不是精确的物理模型,但从"数据多样性"的角度讲,能让模型见到更多样的畸变模式,泛化会好一些。
论文里对这部分的具体做法,从我读到的描述看,是对局部区域做了可控的形变扰动,而不只是一个全局径向模型。这个思路是对的:如果合成数据只有全局径向畸变,模型学到的就是一个"去径向畸变"的算子,遇到真实照片里那种人脸被单独拽宽的情况就无能为力了。
3.3 域差是真实数据上掉点的最大凶手
合成数据最常见的三个坑,我按踩坑的痛感排序:
第一个是太干净。合成本身不引入噪声,模型会把"无噪声"当成一个隐含特征,一上真实照片遇到噪点就开始出伪影。解决办法是在合成流程末尾加随机的高斯噪声、JPEG 压缩、轻微的运动模糊、亮度对比度抖动,把数据分布拉宽。
第二个是分辨率分布单一。如果所有合成数据都 resize 到 512×512,模型就只在 512 上有效,推理时喂 1080P 的图,形变场的幅度会全错。做法是合成时随机缩放,让短边在 384 到 1024 之间浮动。
第三个是背景结构不足。人像矫正的一个硬指标是背景直线不能弯,如果你的训练数据背景全是虚化的人像棚拍,模型根本不知道直线为什么重要。我的做法是从建筑、室内、街景里额外捞一批图,把检测到的人脸贴进去(注意做光照匹配),专门用来教模型"哪里不能动"。
| 数据问题 | 表现 | 处理方式 |
|---|---|---|
| 合成数据过于干净 | 真实图出现伪影、亮点 | 加噪声、JPEG 压缩、模糊抖动 |
| 分辨率单一 | 换分辨率后形变幅度失准 | 短边随机 384~1024 |
| 背景缺少直线 | 背景直线被拉弯 | 混入建筑、室内场景 |
| 人脸位置集中 | 边缘人脸矫正不足 | 强制随机平移人脸到画面边缘 |
4. 网络结构与关键实现细节
4.1 输出为什么是形变场而不是直接输出图像
这是我很想强调的一点。图像到图像的模型有两种:直接回归 RGB,或者回归一个形变场再对原图做采样。前者看起来更简单,但在这个任务上有致命的缺陷——它没有几何约束。直接回归 RGB 的网络很容易学成"局部磨皮 + 轻微重绘",因为从像素损失的角度看,把一张图往另一张图上靠,最简单的办法是把人脸纹理重画一遍,而不是真的移动像素。重画的结果就是人脸虽然"正"了,但身份信息丢了,眼睛嘴巴都是糊的。
回归形变场的好处是:约束了输出必须来自输入的重采样,像素内容是保真的,身份信息不会丢;同时形变场本身可以被可视化、被正则化、被先验约束。代价是形变场的表示要设计好,采样要处理好边界。
具体形式我用的是稠密偏移场 + 基础网格:基础网格是固定在 [-1, 1] 范围的规则坐标,模型输出每个位置的 2 通道偏移量,两者相加得到采样坐标,再丢给 grid_sample。偏移量最好用一个小的系数缩放一下(比如乘以 0.1),不然训练初期输出乱跳,采样点全跑到图外,梯度就断了。
import torch import torch.nn.functional as F def warp_by_flow(x, flow, scale=0.1): b, _, h, w = x.shape ys = torch.linspace(-1, 1, h, device=x.device) xs = torch.linspace(-1, 1, w, device=x.device) grid_y, grid_x = torch.meshgrid(ys, xs, indexing="ij") base = torch.stack([grid_x, grid_y], dim=0).unsqueeze(0).repeat(b, 1, 1, 1) grid = base + flow * scale # flow: [B, 2, H, W], 通道 0 对应 x grid = grid.permute(0, 2, 3, 1) # [B, H, W, 2] return F.grid_sample(x, grid, mode="bilinear", padding_mode="border", align_corners=True)padding_mode这个参数一定要用border。用zeros的话,画面边缘被推出去的部分会变成黑边,模型会学成"把边缘内容留在原地不推",因为推出的那一块会带来巨大的像素误差。align_corners要跟你的归一化方式一致,一旦这里的坐标约定错了,整个形变场会系统性偏移半个像素,看起来就是"怎么都对不齐"。
4.2 多尺度编码器怎么搭
编码器我按三个尺度搭,分别对应原图的 1/4、1/8、1/16。这里有个取舍:Transformer 放在哪个尺度上。全放在最深层,全局建模能力强但细节全丢;全放在浅层,token 数量爆炸。我的做法是深层用 Transformer 块做全局注意力,浅层用卷积块做局部细化,中间层混合。这个结构上的取舍,我觉得是这类任务比较通行的平衡点。
class MultiScaleEncoder(torch.nn.Module): def __init__(self, dims=(96, 192, 384)): super().__init__() # 1/4 尺度:卷积为主,保细节 self.stem = torch.nn.Sequential( torch.nn.Conv2d(3, dims[0], 3, 2, 1), torch.nn.GELU(), torch.nn.Conv2d(dims[0], dims[0], 3, 1, 1), ) self.down1 = torch.nn.Conv2d(dims[0], dims[1], 3, 2, 1) self.down2 = torch.nn.Conv2d(dims[1], dims[2], 3, 2, 1) # 1/8 与 1/16 各接若干 Transformer 块(实现略) self.trans_mid = torch.nn.Identity() self.trans_deep = torch.nn.Identity() def forward(self, x): f1 = self.stem(x) # 1/4 f2 = self.trans_mid(self.down1(f1)) # 1/8 f3 = self.trans_deep(self.down2(f2)) # 1/16 return f1, f2, f3Transformer 块本身我就不贴完整实现了,标准的窗口注意力加移位窗口那一套。这里要提醒的是窗口大小和输入分辨率的匹配:如果输入尺寸不是窗口大小的整数倍,窗口切分会自动 pad,pad 出来的位置在反序列化时容易出错,表现为输出图像规则网格状的接缝。我当初排查了两天才发现是 padding 没对齐,当时还以为是形变场的问题。
4.3 跨尺度融合与解码
解码阶段的顺序是:最深层特征上采样两次,跟 1/8 特征拼接,过一次卷积;再上采样两次,跟 1/4 特征拼接,再过卷积;最后上采样到原分辨率,输出 2 通道。
融合方式我试过三种:逐元素相加、通道拼接后 1x1 卷积、以及带门控的加权。相加最简单但高低频互相干扰;拼接效果最好,代价是通道数翻倍;门控融合在参数量上最省,但要额外学一个权重图,小数据集上容易学歪。我在这个项目里最终用的是拼接,因为形变场的低频和高频信息确实是互补的,硬压成一路很浪费。
还有一个细节:输出层初始化。形变场回归的最后卷积层,我把它初始化为全零,同时加上前面说的 scale 系数。这样训练第零步的时候,网络输出的形变是恒等映射,输出图等于输入图,损失从一个合理的起点开始降。如果不做这个,初始几轮形变场乱跳,采样点跑到图外,梯度直接爆炸。
4.4 边界和分辨率的两处隐坑
第一处是画面外推。真实广角照片在矫正后人脸会被往内收,画面四周大概率会露出没见过的地方。这是几何上无法避免的,因为畸变图里本来就没有拍到那么多内容。常见的处理是先做一个轻微的外扩(比如四周 pad 5% 再裁回来),或者干脆接受黑边,后处理阶段再裁。如果你的业务场景是相机预览,那么外扩系数需要按形变场最大幅度来算,而不是拍脑袋给一个数。
第二处是推理分辨率不匹配。训练在 512,推理喂 1080P,很多人以为直接把图 resize 到 512 推理再放大回来就行,但形变场是有尺度语义的,缩放回去之后幅度会对不上。正确做法是:把网络做成全卷积,推理时直接在原分辨率上前向(显存不够就切块),或者训练时就混入多分辨率。前者更准但更慢,后者更快但精度略降,看你场景。
5. 损失函数逐项拆解:每一项都在管一件事
5.1 像素损失和感知损失管的是"像不像"
有监督分支的基础是像素损失,我用的是 L1 而不是 L2。原因是 L2 对大误差惩罚重,模型会倾向于生成"平均解",也就是模糊的、丢掉高频细节的结果;L1 的梯度是常数,对边缘更友好。权重上 L1 给 1.0,够用。
但光有像素损失不够。形变场只要有半个像素的系统性偏差,整幅图就整体重影,PSNR 掉得很快,可视觉上看起来只是"有点糊"。所以需要感知损失:把输出和监督目标分别送进一个固定的预训练分类网络,取中间几层的特征算 L1 距离。感知损失对结构、纹理的敏感度远高于像素损失,能有效抑制重影和伪影。我用的是 VGG 风格的浅层加中层特征,权重给 0.05 到 0.1 之间,给太大容易让颜色漂移。
5.2 人脸先验损失:这才是"人像矫正"的灵魂
没有这一项,模型就只是个去畸变算子。人脸先验的做法是:对输出的矫正图和监督目标分别跑一次关键点检测,计算对应关键点的距离。但这个做法有个循环依赖问题——监督目标本身就是真实的正常照片,它的关键点可以直接检测;输出图的关键点检测器在矫正早期会失效,因为图还是畸变的,检测器抖得厉害。
我的处理是分阶段:前若干轮只用像素损失和感知损失把形变场大致拉对,等输出稳定了再开人脸损失;或者用一个对畸变比较鲁棒的关键点模型。另一种更直接的做法是在形变场层面加约束:已知畸变图中某个人脸关键点的位置,以及它在监督目标中对应的位置,就能反推出该点应有的形变偏移量,直接监督形变场在该点的取值。这个做法的好处是不依赖检测器反复跑,坏处是关键点太稀疏,只能约束少数位置,需要配合一个平滑正则把稀疏约束扩散到周围。
提示:人脸损失权重不能给太高。给太高的话模型会为了对齐五官而把整张脸硬拽,鼻翼、脸颊这些没有关键点的地方就被拉变形,实际观感反而更假。
5.3 直线与结构损失:管住背景别乱动
背景直线的约束有两种实现。一种是检测原图中的长直线段,然后在矫正结果上采样这条线段上的若干点,计算这些点到拟合直线的距离,惩罚它不共线。另一种更省事:直接在形变场上加平滑正则,比如偏移量的二阶差分要小,避免出现局部剧烈扭曲。
第一种更准确但依赖直线检测的质量,人脸旁边经常有衣服褶皱、头发丝被误检成直线,导致不该约束的地方被强行拉直。第二种更通用但会在人脸区域过度平滑,因为人脸的形变本来就是不连续的。我的做法是两种都用,但用一个语义掩码把两者分隔开:背景区域走直线约束,人脸区域走平滑约束,边界处做软过渡。这个掩码可以来自人脸分割,也可以用关键点膨胀得到。
5.4 对抗损失:把真实数据用起来的那一环
半监督里的无监督分支,主要靠判别器。判别器的任务是区分"矫正后的真实照片"和"真实的、未畸变的人像照片"。生成器希望自己输出的图被判定为后者,于是被迫把输出的分布往真实照片的分布上推。
判别器的设计上我只强调一点:偏结构,别偏色彩。如果用普通的 PatchGAN,判别器很容易通过颜色统计、噪声模式就把真假分出来,生成器学到的是"调色"而不是"修几何"。我的改进是把判别器输入的通道做一次高通滤波,或者用灰度图加梯度图作为输入,逼它关注结构。另外,条件判别器(把畸变图也作为输入拼进去)在这个任务上比无条件判别器靠谱得多,因为畸变图的构图本身就是重要的上下文。
5.5 权重表和训练日程
损失权重没有普适值,但我把我这套跑通的配置列出来,供你作为起点:
| 损失项 | 符号 | 权重 | 说明 |
|---|---|---|---|
| 像素损失 | L1 | 1.0 | 基础监督,全程开启 |
| 感知损失 | VGG | 0.08 | 抑制重影,全程开启 |
| 人脸关键点 | L2 | 0.3 | 第 10 轮后开启,防止早期检测噪声 |
| 直线结构 | 共线距离 | 0.15 | 全程开启,仅作用于背景掩码 |
| 形变场平滑 | 二阶差分 | 0.02 | 全场,人脸区域权重降为 1/5 |
| 对抗损失 | hinge | 0.05 | 第 5 轮后开启,判别器每 2 步更新一次 |
学习率我用的 1e-4 加余弦退火,batch 是合成 8 加真实 8。别用太小的 batch,对抗损失在小 batch 下方差极大,训练会一直抖。
6. 从零跑通一版的完整流程
6.1 环境与目录组织
环境上没有特别要求,PyTorch 1.10 以上,一张 16G 显存的卡跑 512 分辨率的训练够用。目录我习惯这么分:
project/ ├── data/ │ ├── paired/ # 合成配对:distorted/ 和 target/ 同名 │ ├── real/ # 真实无标注人像 │ └── test/ # 评测集,人工挑选 ├── models/ │ ├── encoder.py │ ├── decoder.py │ └── discriminator.py ├── losses/ ├── train.py └── infer.py评测集一定要人工挑,而且必须包含人脸位于画面四个角和正中心这两类极端样本,还要包含有大面积直线背景的图。我自己最初用随机抽的测试集,指标很好看,实际一用全是问题,就是因为测试集里人脸都集中在画面中间。
6.2 训练循环的关键顺序
训练循环本身不复杂,但有三个顺序上的讲究。
第一个是判别器的更新频率。生成器和判别器同步更新,在训练早期判别器会迅速压制生成器,梯度消失。我的做法是判别器每两步更新一次,并且对判别器加梯度惩罚或者谱归一化。
第二个是真实样本和合成样本的前向顺序。不要把它们拼成一个 batch 一起前向,因为合成样本要走有监督损失,真实样本要走对抗损失,混在一起代码容易写乱。分开前向,分开算损失,最后把 loss 相加再 backward 一次。这样梯度是同时回传的,共享参数更新是同步的。
第三个是梯度裁剪。形变场回归在早期会输出较大的偏移,采样点出了边界,grid_sample 的梯度会变成零或异常值。加一个clip_grad_norm_(params, 1.0),能挡掉很多莫名其妙的 NaN。
for step, (syn, real) in enumerate(loader): # 合成分支 out_syn = model(syn["input"]) loss_sup = l1(out_syn, syn["target"]) + 0.08 * percep(out_syn, syn["target"]) # 真实分支 out_real = model(real["input"]) loss_adv = adv_weight * hinge_g(out_real) loss_prior = 0.3 * landmark_loss(out_real, real) + 0.15 * line_loss(out_real, real) loss = loss_sup + loss_adv + loss_prior opt_g.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) opt_g.step() # 判别器延迟更新 if step % 2 == 0: d_loss = hinge_d(out_real.detach(), real["target_like"]) opt_d.zero_grad(); d_loss.backward(); opt_d.step()6.3 推理阶段的工程优化
推理比训练更容易出问题,因为分辨率不可控。三个建议:
一是用全卷积而不是固定尺寸。如果网络里有位置编码或者固定大小的窗口切分,要改成相对位置或者动态窗口,否则换分辨率必然出问题。
二是分块推理加重叠融合。1080P 的图直接前向显存吃不消的话,切成 512 的块,块间重叠 64 像素,形变场在重叠区域做加权平均。注意要融合形变场而不是融合输出图,融合输出图会在块的边界处产生明显的接缝。
三是预分配输出缓冲。形变场在原分辨率下是 2 通道 float,1080P 也就 16MB,不算大,但每帧都新建 tensor 会有内存碎片,长时间跑的视频场景下会出现帧率抖动。
6.4 评测怎么做才不骗自己
自动指标只能当参考,我列一下我用的一套组合:
| 指标 | 计算方式 | 关注点 |
|---|---|---|
| 关键点距离 | 输出与目标的人脸 68 点平均 L2 | 人脸形状是否归位 |
| 直线偏离度 | 背景长直线采样点到拟合线距离 | 背景是否被拉弯 |
| 感知距离 LPIPS | 预训练网络特征距离 | 纹理保真度 |
| 身份相似度 | 人脸识别特征的余弦相似度 | 有没有把人修成别人 |
| 主观评分 | 5 人盲评,1~5 分 | 最终观感 |
身份相似度这一项特别重要,因为它能立刻暴露"网络在重绘而不是在搬像素"的问题。如果你的 LPIPS 很好但身份相似度掉了,说明网络在骗指标。
7. 踩坑记录与排查速查
7.1 人脸还是歪的
这是最高频的问题,原因通常有三类。第一类是监督信号本身有问题:合成的畸变模型和真实设备的畸变对不上,模型学到的逆变换自然不对。排查方法是拿一张标定板,用你的目标设备实拍一张,然后跟合成的畸变图放一起对比,看四角的拉伸幅度是否一致。
第二类是人脸损失权重不够或者开启太晚。如果前 10 轮完全靠像素损失,模型会先学到一个全局的径向反变换,等人脸损失开启时,它已经陷在局部极小里了。可以让像素损失和人脸损失从一开始就同时开,只是人脸损失用更鲁棒的关键点模型。
第三类是形变场的平滑正则过强,把人脸区域本该有的局部差异给抹平了。这个问题的表现是"整张脸被整体平移但内部比例没改",很容易识别。
7.2 背景直线弯了
这个通常是人脸损失压过直线损失的后果。因为在受约束的区域有重叠时,模型会优先满足权重大的那一项。解法是加语义掩码做区域隔离,而不是简单调权重。另外检查一下直线损失的点采样方式,如果你是在矫正后的图上重新检测直线再约束,那约束是无效的(检测不到就不罚),必须在原图上检测、在原图坐标上采样、再到输出图上算距离。
7.3 训练不稳定、loss 抖动
形变回归任务的抖动来源比分类任务多。按排查顺序:先看 grid_sample 的 padding 模式对不对,zeros会导致边界区域梯度异常;再看输出层有没有做零初始化;再看学习率是不是太大,形变场任务的合适学习率通常比分类任务小一个量级;最后看判别器是不是太强,加谱归一化或者降低更新频率。
| 现象 | 最可能的原因 | 快速验证 |
|---|---|---|
| 输出有规则网格接缝 | 窗口切分 padding 未对齐 | 换输入尺寸看接缝是否移动 |
| 输出整体偏移半像素 | grid 坐标约定不一致 | 输入一张棋盘格,看是否对齐 |
| 边缘出现黑边后收缩 | padding_mode 用了 zeros | 改成 border 重训前 100 步 |
| 人脸被重绘、身份丢失 | 直接回归 RGB 而非形变场 | 检查输出是否来自输入采样 |
| 真实图效果远差于合成图 | 域差过大,无监督分支没起作用 | 关掉对抗损失对比 |
| loss 突然 NaN | 形变过大导致采样越界 | 加梯度裁剪 + 限制 flow 幅度 |
7.4 部署端的实际约束
手机端部署的话,多尺度 Transformer 的算力是个现实问题。我实测下来,把最深层换成轻量注意力或者干脆用深度可分离卷积替代,精度损失在可接受范围内,速度能快一倍多。另一个技巧是把形变场预测和图像重采样分离:形变场可以在 1/4 分辨率预测再双线性上采样,重采样在原分辨率做。因为形变场本身是低频的,1/4 上采样带来的误差极小,但计算量省了十几倍。
8. 这套思路还能往哪里挪
8.1 视频和实时预览场景
视频场景跟单帧最大的区别是时序一致性。单帧独立推理,形变场会逐帧轻微跳动,观感上就是人脸在"呼吸"。解法有两种:一是对形变场做时序平滑,但要注意平滑窗口不能太大,否则快速转动镜头时会拖影;二是把上一帧的形变场作为当前帧的初始化或者额外输入通道,让网络自己去学连续性。后者的效果更好但需要改网络结构,前者更容易落地。
实时的另一个约束是延迟。从采集到显示,整条链路如果超过两帧的延迟,用户自拍时会有明显的滞后感。所以网络必须做得很浅,或者用降分辨率加超分的组合。
8.2 其他广角内容类型的迁移
同样的框架,换个损失设计就能用在别的场景上。比如拍文档,把直线损失权重拉满、人脸损失去掉,就变成了一个文档去畸变模型;拍建筑,重点放在长直线的全局约束和垂直线的保持;拍美食或者商品,可以换成显著性区域的比例约束。核心思路是一致的:先定义"什么必须保持,什么必须修正",再把这个定义写成损失函数。这句话我觉得是这篇论文给我最大的启发,比具体的网络结构更有价值。
还有一个我觉得挺有意思的延伸:把这套形变场预测当成一个通用的"局部几何编辑"工具,用户可以圈一个区域说"这里往内收一点",网络就输出对应的形变场。这个方向实际上已经有类似的产品形态了,只不过大多数是靠网格拖拽手动做的。
8.3 我自己的几点体会
最后分享几个我觉得比论文本身更有用的经验。
第一个是,先做可视化工具再做模型。形变场是二维向量场,靠指标根本看不出它在干什么。我最开始写了个脚本,把形变场画成箭头图叠在原图上,一眼就能看出"哦原来模型只在画面中间乱动,边缘完全没管",这个问题光看 loss 曲线永远发现不了。
第二个是,合成数据的质量比网络结构的改进重要得多。我在这上面吃过亏,花了两周改网络结构,涨了不到 0.5dB;后来花一天把合成流程的噪声和分辨率分布改好,涨了接近 2dB。这个领域的瓶颈通常在数据侧,不在模型侧。
第三个是,评测集一定要自己看。自动指标会骗人,尤其是当你的监督目标和评测目标都来自同一个合成流程时,指标会虚高得离谱。我现在的习惯是每次训练完随机抽 20 张真实照片,自己一张张看,看有没有那种"指标很好但一看就不对"的样本,那些样本才是真正要解决的问题。
第四个是,约束要加在能解释的地方。我见过有人为了提升指标,往损失里加了一堆自己都说不清楚为什么的项,训练是收敛了,但换个场景就崩。加每一项损失之前,最好能回答"它在物理上或者视觉上对应什么",回答不上来的,大概率是过拟合到测试集了。
这个方向后面我觉得还能继续做的是把形变预测和镜头参数估计结合起来——如果能从一张图里同时估计出大致的焦距和主体距离,形变场的先验就能强很多,小样本下的泛化也会好不少。