你有没有翻到过家里的老照片?黑白照片里,爷爷的头发、外婆的裙子、街角那扇门是什么颜色,往往只能靠想象。我一直觉得,给灰度图像做彩色复原是一件"看着简单、做起来极麻烦"的事——它不是填色游戏,而是一个典型的"一对多"映射问题:同一张灰度图,被还原成晨光下的样子和黄昏下的样子,都是合理的。这两年我断断续续做了一个灰度图像彩色复原的小项目,场景限定在人脸。选人脸不是因为它简单,恰恰相反:人脸结构固定、肤色唇色发色有很强的统计规律,但这些规律一旦用错,一丁点颜色偏差都会被人的眼睛瞬间识破。这篇文章就围绕"灰度人脸图如何上色"这条线,把我从数据、网络设计到训练调参踩过的坑完整拆开讲一遍,希望能给做图像复原、老照片修复或者GAN方向的朋友一些能直接落地的参考。
1. 人脸着色到底难在哪里,为什么值得单独做
1.1 灰度图像的彩色复原,本质是什么问题
灰度图像只有亮度信息,没有颜色信息。从信息论角度看,原始彩色图经过灰度化,是做了一个极端的降维:RGB三个通道的信息被压成一个Y通道或Lab空间的L通道。这个过程是不可逆的,你无法从单通道亮度反推出三个通道的原始数值。因此任何着色算法本质上都是在"猜",是在利用先验知识做推理。
这跟图像超分辨率完全不同。超分辨率里,低分辨率和高分辨率之间的映射虽然复杂,但信息是同一个模态;着色则是跨模态的推断——模型必须"记住"天空通常是蓝色、草地通常是绿色、嘴唇通常是偏红的,然后根据灰度图中纹理、上下文、语义关系去做推测。一个灰色像素,可能是白墙在阴影里,也可能是深灰色的布在强光下。没有语义理解,光靠像素邻域关系,不可能得到稳定合理的颜色。
这也是为什么早期基于局部颜色传播的传统方法效果有限。它假设颜色在空间上是平滑连续的,但真实世界不是这样的:一件红衣服旁边可以站着一个背绿色包的人,边界处颜色突变非常常见。所以着色系统必须做到两件事:一是理解"这是一个什么东西",二是理解"这个东西和周围东西的颜色边界在哪里"。前者靠语义特征,后者靠边缘与细节保持。
1.2 人脸场景的特殊性:先验明显,但容错率极低
我选择人脸作为研究对象,是因为它把"先验强烈"和"评估严苛"这两件事同时拉满了。
先验强烈体现在数据面上。所有人脸的肤色都落在一个相对集中的颜色分布区间里,唇色、瞳孔颜色、发色虽然因人种和个体有差异,但整体可控。鼻子、眼睛、嘴巴、眉毛的空间位置高度固定,经过人脸对齐后,每个人脸上的器官大致出现在同一个位置附近。这意味着模型不需要从零开始探索颜色空间,它只需要在人脸流形附近做精细的估计,理论上比通用的自然图像着色要容易。
但代价是容错率极低。人对人脸非常敏感,脖子颜色偏绿、嘴唇发紫、眼白发黄,任何一处异常都会瞬间被注意到。这跟给风景照着色完全不是一个难度量级——风景照里某棵树颜色奇怪,观感最多打个折扣;人脸色调不对,直接会觉得"恐怖"。这也是为什么市面上很多通用着色的开源模型放到人脸上后效果崩坏,因为通用模型优化的目标是整体颜色分布接近自然图像,没有专门约束人脸部件的颜色合理性。
另一个推动我做人脸着色的原因是应用价值。家里泛黄的老照片、文化遗产中的历史人像、影视资料里的黑白片段,大部分都涉及到人脸。如果能做出一套稳定的人脸着色流程,直接可以套进老照片修复管线里,做成一个实打实的工具,而不是只在论文里打转。
1.3 技术路线选型:Lab空间、U-Net、颜色分类的组合逻辑
我的方案主线是"Lab颜色空间 + U-Net结构 + 颜色离散化分类"。这个组合不是拍脑袋定的,而是基于着色问题的三个特性逐一选出来的。
先说Lab空间。Lab把亮度(L通道)和颜色(a、b两个通道)完全分离。灰度图天然就是L通道,所以任务就变成了"预测a和b"。相比在RGB空间直接预测三个通道,Lab空间的好处是输出维度和颜色相关性都被简化了:RGB三个通道高度耦合,灰蒙蒙的图也可能预测出奇怪的高饱和组合;而在Lab中,模型只需要专注预测两个颜色通道,且这两个通道的颜色语义直观(a大致是从绿到红,b大致是从蓝到黄),方便做后处理和颜色约束。
然后是U-Net。着色需要同时在两个尺度上做对:全局尺度上要知道"这是人脸,肤色应该偏暖",局部尺度上要知道"这里是眼角,颜色应该和眼白一致"。U-Net的编码器逐层下采样,逐步提炼语义;解码器再逐层上采样恢复空间细节;中间的跳跃连接把编码器的浅层细节直接拼到解码器,保住边缘和纹理。这个结构天然贴合着色的需求,在图像翻译任务里几乎成了标配。
最后是颜色分类。着色是个典型的一对多问题,一个灰像素对应多种合理颜色。如果把它当回归问题做,用L2损失直接预测ab值,模型会为了降低整体误差而"平均"所有可能颜色,最终倾向于输出灰暗的、饱和度低的结果——这就是大家常看到的"模型把图变灰"的根因。把连续的ab值空间离散化成几百个颜色类别,让模型去预测"每个像素最可能属于哪个颜色类别",本质上是把多模态分布交给了分类分布去表达。模型可以对一个灰色像素同时给出"有40%可能是深棕色、30%可能是暗红色"这样的分布,而不是被迫选一个平均值。这个改动是着色效果质的飞跃的关键。
2. 核心设计细节:颜色量化、人脸先验与损失函数
2.1 为什么不直接回归颜色,而要先把颜色离散化
颜色离散化是整套方法里第一个要决策的点。做法是这样的:a通道和b通道的取值范围大约是-128到127,在这两个维度组成的平面上划分网格,比如每隔10个灰度级取一个点,会得到大约26乘26的候选颜色中心。但这些中心里,很多对应的颜色在自然图像中几乎不会出现(比如荧光绿、极端紫),直接全保留会增加分类难度。经典的做法是在大量自然图像上统计每个颜色中心的像素频次,丢掉出现概率太低的格子,最终保留大约313个有代表性的颜色类别。
这里要解释一个容易被忽略的动机:分类比回归更擅长表达不确定性。假设有个像素是灰度图里的一个高光点,它的真实颜色可能是极淡的粉色,也可能是极淡的蓝色,回归模型被逼着输出一个介于两者之间的均值,结果就是灰白色;分类模型则可以输出一个同时包含粉色和蓝色概率的分布,最后用温度采样去决定具体取哪个,或者直接取概率最大的那个类别。分布表达能力完全不一样。
不过分类也有代价:颜色是离散的,恢复出来可能会有轻微色带感。解决方法是推理阶段不直接取argmax,而是用softmax加一个温度系数,把概率分布平滑一下,然后对类别中心做加权求和。这个操作相当于在离散类别之间做了插值,既保留了分类表达多模态的能力,又让输出颜色是连续的,能有效避免色块效应。
2.2 人脸先验怎么"注入"到网络里
通用着色网络把人脸当普通物体处理,效果往往不稳定。为了让模型理解人脸结构,我做了三个层面的先验注入,每一层都有它的作用。
第一层是几何对齐。训练前先对所有人脸图做人脸检测和关键点定位,然后用相似变换把两眼和嘴巴的位置对齐到一个标准模板上。这一步直接标准化了输入,让后续网络不需要花大量参数去学习"脸可能出现在图里任何位置"这种无关的几何变化,可以把容量集中在学习部件颜色上。推理的时候对输入图做同样的对齐,上色完成后再变换回原图坐标。
第二层是语义通道。对齐之后,我再给每个训练样本配了一张语义mask的通道,标注出皮肤、左眼、右眼、眉毛、鼻子、嘴巴、头发、背景这几类区域。这个mask不是必须的标注文件,而是可以直接用现成的开源人脸解析模型生成,然后离线存好。训练时把mask作为额外输入通道拼到L通道后面一起进网络。这样模型在解码时能显式知道"这个位置是嘴唇,应该往红色方向走""这个位置是背景,不能用肤色逻辑去约束"。加了语义通道之后,肤色边界处的颜色晕染问题明显减轻,因为网络不再是靠猜来区分眼睛和皮肤。
第三层是统计约束。我在训练集上统计了肤色区域的ab均值向量和协方差矩阵,然后砍掉那些距离肤色流形太远的预测。具体做法是在损失函数里加一项肤色正则:计算预测结果在皮肤mask区域内的平均ab值,让它向统计均值靠近。这一项权重不需要太大,起到"拉住"的作用就行,避免模型偶尔把整张脸生成成青绿色调的灾难性输出。
2.3 损失函数怎么组合,才能让结果不发灰、不怪异
损失函数直接决定了训练出来的模型是"平庸但安全"还是"大胆但不靠谱"。我只用了L1或者L2回归损失的时候,模型稳稳地倾向于输出颜色平均值,整体画面泛灰。换成分类交叉熵之后,灰色问题解决了,但新问题来了:模型开始大量使用高频次颜色类别,导致结果"过于常见",比如把所有头发都染成纯黑,把所有皮肤都染成标准粉。这也不对。
最终我用了三部分损失加在一起。
第一部分就是颜色分类的交叉熵,这是基本盘。但要对类别做重加权,频率高的颜色类别权重调低,频率低的颜色类别权重调高,否则低概率但关键的颜色(比如金发、红唇)永远学不出来。
第二部分是感知损失。我拿一个在ImageNet上预训练过的图像分类网络(VGG系列这种结构)当特征提取器,把预测出的彩色图和原图分别送进去,在多个层上比较特征图的差异。感知损失关注的是整体纹理和结构感知,不是像素级一致,它能逼着模型在高层语义上"像原图",而不是在低层像素上"凑数"。这非常有用,尤其在发丝、衣服纹理这些区域,感知损失能保住细节锐利度。
第三部分是一个轻量的对抗判别器。我把判别器接在输出端,让它区分"预测彩色图"和"真彩色图",生成器网络则在被判别器"挑刺"的压力下,把颜色的自然度往上提。对抗损失在通用上色里容易不稳定,但在人脸场景里相对可控,因为人脸颜色分布本身就聚集,判别器很容易学出信号。加了这个之后,视觉效果上的"塑料感"会降低不少,颜色层次更丰富。
三部分损失返回的梯度加在一起训练同一个生成器,权重分配大概是分类损失占主导、感知损失次之、对抗损失做微调。我试过调整这部分比例,发现对抗损失权重一旦超过感知损失,训练就容易出现颜色抖动,输出开始花花绿绿。所以对抗损失只是"调味料",不能当主菜。
3. 完整实操:从数据准备到推理后处理
3.1 数据集准备与预处理管线
数据是着色项目的基石,模型再好,数据拉了胯也没用。我用的是一套开源的、覆盖多姿态多光照条件的人脸图片集,大概清洗出五万张高质量正脸图。清洗规则很简单但很重要:分辨率低于某个阈值的删掉、有严重遮挡的删掉、有明显水印和伪影的删掉、重复图去重。别小看这些步骤,脏数据会让整个训练过程反复震荡,浪费大量时间。
预处理管线按顺序是:人脸检测与对齐、中心裁剪到256乘256、转Lab空间、提取L通道和ab通道、用现成的人脸解析模型生成语义mask、最后把所有结果离线缓存成二进制文件。为什么离线缓存?因为每次训练都重新跑检测和对齐太慢了,而且如果预处理结果不一致,训练过程会莫名其妙地不稳定。缓存之后再训练,每个epoch读取的都是完全一致的样本,问题定位起来也方便。
数据增强方面做了几项:随机水平翻转、轻微旋转、随机缩放裁剪。还有一个细节:ab通道做轻微的随机色彩空间抖动,给模型的输出增加一点容错空间。这种增强相当于在告诉模型"同一个亮度图,颜色可以有小幅度的变化",有利于提升泛化能力。灰度化这一步不要用简单的加权平均公式直接转,而是用彩色图训练,实时把原图的L通道取出来作为模型输入,这样标签是天然对齐的,省去了手工配对的麻烦。
3.2 网络结构与核心代码
网络主体我采用了一个标准U-Net的变体。编码器有四层下采样,从64通道逐步增加到512通道,每层由两个卷积加批归一化和ReLU组成。解码器四层上采样,对称恢复到256乘256分辨率,每层把编码器对应层的特征拼接进来。在编码器最深层,我加了一个简化的自注意力模块,让网络能在全局范围内捕捉肤色、光照的一致性。输出头再接一个1乘1卷积,把特征图投影到颜色类别数,比如313维。
给出一段简化但能跑通的PyTorch核心代码,方便理解整体结构:
import torch import torch.nn as nn class ConvBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.block = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): return self.block(x) class UNetColor(nn.Module): def __init__(self, num_classes=313, extra_channels=1): super().__init__() self.inc = nn.Conv2d(1 + extra_channels, 64, 3, padding=1) self.down1 = nn.Sequential(ConvBlock(64, 128), nn.MaxPool2d(2)) self.down2 = nn.Sequential(ConvBlock(128, 256), nn.MaxPool2d(2)) self.down3 = nn.Sequential(ConvBlock(256, 512), nn.MaxPool2d(2)) self.up1 = nn.ConvTranspose2d(512, 256, 2, stride=2) self.conv1 = ConvBlock(512, 256) self.up2 = nn.ConvTranspose2d(256, 128, 2, stride=2) self.conv2 = ConvBlock(256, 128) self.up3 = nn.ConvTranspose2d(128, 64, 2, stride=2) self.conv3 = ConvBlock(128, 64) self.head = nn.Conv2d(64, num_classes, 1) def forward(self, gray, mask): # gray: 灰度L通道, mask: 语义mask通道 x = torch.cat([gray, mask], dim=1) x0 = self.inc(x) x1 = self.down1(x0) x2 = self.down2(x1) x3 = self.down3(x2) x = torch.cat([self.up1(x3), x2], dim=1) x = self.conv1(x) x = torch.cat([self.up2(x), x1], dim=1) x = self.conv2(x) x = torch.cat([self.up3(x), x0], dim=1) x = self.conv3(x) return self.head(x)这只是一个可用于实验的骨架。实际训练时还需要在编码器最深层加一个自注意力模块,以及把分类头的输出reshape成类别概率。代码里没有写注意力部分,是为了保持核心逻辑清晰。加注意力的时候,建议加在下采样最深层的特征图上,因为那层分辨率最低,注意力计算量可控。
3.3 训练策略与参数解析
训练配置直接给一份我实测过比较稳的参数组合。优化器用Adam,初始学习率1e-4,batch size设32,输入256乘256。训练总数大约30个epoch,在单张GPU上跑大概一天出头。学习率采用余弦退火,最后几个epoch降到一个极低值让损失收敛得更干净。混合精度训练开启,能省不少显存,实测对收敛速度没有负面影响。
类别权重这块是必须处理的。直接在五万张图的ab统计上算每个类别的像素占比,然后对占比取倒数再归一化。如果不加这个权重,训练在几分钟内就会陷入"只输出黑白色调"的局部陷阱,因为黑色、灰色、白色这些低饱和类别的像素数量碾压高饱和类别,模型根本不需要学复杂颜色就能把交叉熵降得很低。加了类别权重之后,低概率类别被强行拉起来参与训练,模型才会开始产生各种颜色。
训练过程中我每隔几个epoch保存一次模型,并用一小批验证集图做可视化输出。这里有个惨痛教训:不要只在训练结束时看一次结果,一定要训练过程中持续看中间输出。有一次模型前20个epoch看起来正常,到第25个epoch开始出现颜色漂移,如果不是提前保存了checkpoint,整个训练就得推倒重来。中间可视化就像安全带,花几分钟写入代码,能救你一次下机崩溃。
3.4 推理流程与后处理技巧
推理阶段,输入一张灰度图,先走人脸检测和关键点对齐,裁剪到256乘256,然后取L通道,同时生成对应的语义mask。L通道和mask拼起来送进网络,得到313维的概率输出。
推理时我不用argmax,而是对概率分布做温度缩放。温度T设成0.4左右时,效果最好:既保持了分类的多模态选择能力,又让颜色过渡更平滑,减少量化色带。具体做法是计算softmax(z / T),然后对所有类别中心按概率加权求和,得到一个连续的ab值。再把预测出的ab通道和原始L通道拼在一起,转回RGB。
后处理有两个关键步骤。第一步是饱和度调整,模型容易低估颜色浓度,特别是皮肤区域,我会在RGB空间做一个轻微的饱和度增强,幅度在1.15倍左右。注意不要太强,否则嘴唇会红得发假。第二步是肤色安全校正:用语义mask检测皮肤区域,计算皮肤区域的平均色调,如果偏离训练集统计肤色中心太远,就把ab通道向肤色中心做一个变权重偏移。这个校正只作用于皮肤区域,不影响头发和背景,能有效兜底。
4. 实验效果、常见问题与排查实录
4.1 客观指标与肉眼观感为什么容易出现偏差
评测着色结果,最容易被误导的就是客观指标。PSNR和SSIM是图像复原任务的老牌指标,但它们的前提是"有一张确定的真值图可以比"。着色任务里,同一个灰度图可以有多个合理真值,如果你把目标定为"像素级接近原图",那么最安全的输出永远是"低饱和度且贴近亮度相关颜色"的平庸结果——因为它和大多数合理答案的差距都不大。所以纯粹刷PSNR,模型会自动变灰。
我在项目里采用了一套组合评估:PSNR和SSIM只做参考,主要看语义正确性和主观自然度。语义正确性是指,把输出图做语义分割,看每个区域的颜色是否符合该区域的统计分布,比如天空区域不能出现大片草地绿,皮肤区域不能出现高饱和蓝色。主观自然度则靠一批志愿者盲测打分。这个方法虽然没有论文里那么严格,但对我来说实用性强,能真实反映模型能不能用。
4.2 常见病根:发灰、晕染、肤色怪异、不收敛
整个项目里遇到最多的问题,把它们写成一张排查表,方便大家对照处理。
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 输出整体灰暗,颜色很淡 | 损失函数以L2/L1回归为主,模型倾向输出平均颜色 | 改成颜色分类损失,推理时适当调低温度系数 |
| 颜色从皮肤溢出到背景 | 语义信息缺失,解码器无法区分边界 | 加入语义mask输入通道,或使用注意力机制强化边界 |
| 皮肤偏绿、偏蓝、偏紫 | 训练数据中肤色分布不均衡,模型学到错误偏移 | 增加肤色正则项,推理时做肤色中心校正 |
| 嘴唇颜色发灰,没有血色 | 唇色类别在重加权后权重不够,网络学不到细节 | 提高感知损失权重,在损失里对唇部mask区域单独加权 |
| 训练loss震荡不降 | 对抗损失权重太高,或类别权重计算错误 | 调低对抗损失,检查类别权重是否归一化 |
| 老照片修复时颜色过度饱和 | 原始黑白照片本身有泛黄降解,模型补偿过头 | 在推理管线中加入曝光/色偏估计,先做白平衡再着色 |
4.3 我踩过的几个坑和对应的处理方案
第一个坑是颜色模糊的"伪边缘"。有一段时间结果图放大后,在鼻子和脸颊的分界处出现了一条细的灰白色纹线,像是塑料玩具的分模线。后来定位到是跳跃连接的问题:编码器浅层特征保持了强烈的亮度边界,而解码器在融合颜色信息时,把亮度边界当成了颜色边界,导致着色结果在边界两侧都往保守方向收缩。解决方式是在跳跃连接拼接后加一层注意力门控,动态调节特征权重,让网络知道哪些边界只是亮度变化、哪些边界是语义边界。加了门控之后,这种伪边缘基本消失。
第二个坑是处理侧脸和极端姿态时,对齐模型经常失败。人脸关键点检测在侧脸、低头、抬头的情况下不可靠,一旦对齐错了,后续着色效果直接崩盘。我最后的处理方案是:训练时把人脸图分两路,能对齐的走对齐路径,对齐失败但五官可见的走"中心裁剪"路径,让网络同时见过这两种输入分布。推理时也一样做双路径预测,然后根据对齐置信度选择输出。这个策略虽然土,但很有效。
第三个坑非常隐蔽:测试集上效果不错,但拿到真实老照片上全翻车。原因在于老照片的灰度分布和现代高清人脸图不一样——老照片有严重的颗粒噪声、动态范围压缩、甚至局部褪色。解决方法是推理前对输入做一次对比度归一化,把L通道的直方图匹配到训练集的平均直方图上去。这个预处理只要一行库函数的调用,效果却很显著,强烈建议在真实老照片上试一下。
5. 这个项目后续还能怎么扩展
5.1 从单张图到视频
单张人脸着色稳定之后,最自然的扩展就是视频。视频着色的第一个痛点是时间一致性:逐帧独立着色,相邻帧的颜色会闪烁抖动,因为模型每一帧都在做独立的概率采样,哪怕输入几乎一样,输出也可能在两个合理颜色之间跳变。解决办法有两个流派:一是把光流信息加进网络,让模型知道当前帧的颜色要和前一帧的光流对齐;二是在推理阶段加入时序平滑后处理,比如指数滑动均值。我个人的建议是先做后处理平滑,因为改动小、见效快,光流方案留给真正需要实时处理的场景。
5.2 引入交互式提示
自动着色的上限受限于模型见过的颜色分布,用户想要某种特定风格的着色(比如把女主角的裙子还原成特定的红色),自动模型给不了。下一步可以做成交互式着色:用户用画笔在灰度图上画几个色块,模型把颜色从提示区域传播到全图。这在技术上可以复用一个类似的U-Net,只是输入多一个用户提示通道。交互式最考验的是传播边界,处理不好会把颜色涂到不该涂的地方,得把语义mask和提示通道联合起来一起喂给网络。
5.3 高分辨率与移动端
有人会问,为什么不能直接拿模型处理高清老照片?因为现有训练数据是低分辨率对齐图,模型在高分辨率推理时,细节会出现不自然的重复纹理。推荐做法是粗粒度先着色,再用引导上采样或超分辨率网络把细节补回来,而不是直接在高清图上跑。移动端落地则主要是模型压缩问题,U-Net的参数量在移动端还是偏大,可以考虑量化到8bit、或者把解码器改用轻量模块,精度会损失一点,但人脸上的容忍度其实还可以。
就我个人体验来说,着色项目最大的收获不是指标涨了几个点,而是彻底理解了"多模态映射"和"统计先验"在视觉任务里有多重要。很多人开始做这个方向时,会习惯性套用超分模型的结构,然后发现输出灰蒙蒙一片,其实就是没想清楚回归和分类、结构和颜色之间的区别。如果这篇文章能帮你少走一点弯路,那这些踩过的坑也算值了。最后再分享一个小技巧:无论网络结构怎么改,先拿一百张图的小数据跑通整个训练到推理流程,再上全量数据。这个习惯帮我省下的调试时间,绝对比我写所有网络代码的时间还多。