简介:本资源面向图像处理与深度学习方向的开发者,提供一套基于OpenCV DNN模块实现灰度图像自动上色的完整C++工程,适用于Windows 10/11平台与VS2019开发环境。工程通过加载caffemodel与prototxt模型文件,对灰度图进行色彩预测与还原,并支持调整参数观察不同上色效果,可帮助读者理解DNN推理流程、模型加载方式及图像色彩空间转换等关键环节。压缩包共40个文件,约190.34MB,包含cpp源码、vcxproj工程文件、sln解决方案、caffemodel与prototxt模型、dll运行库、exe可执行程序,以及多张不同参数下的结果对比图,便于直接运行验证与二次开发。目前已有570人学习下载,适合具备一定C++与OpenCV基础、希望快速上手深度学习图像上色实践的读者参考。
1. 灰度图上色这件事,DNN 到底在学什么
给黑白照片上色,很多人第一反应是“填色游戏”,觉得模型只要认出草地就涂绿、认出天空就涂蓝。真跑起来会发现完全不是这么回事:同一张灰度图,草地可以是枯黄也可以是翠绿,天空可以是阴天灰也可以是晚霞红。灰度像素本身不携带色度信息,模型要做的是从亮度和纹理里“猜”出一个合理的颜色分布,而不是还原唯一正确答案。这就是基于深度学习的 DNN 模块给灰度图像上色的核心难点,也是它和传统滤镜、查表上色最本质的区别。
这篇笔记面向两类人:一类是刚接触深度学习、想找一个能跑通、能看到效果的实战项目练手;另一类是做图像处理或老照片修复,想判断这条技术路线值不值得投入。我会把 DNN 上色模块的输入输出、网络结构选型、训练数据准备、损失函数设计、推理部署这条链路拆开讲,中间给出能直接抄的代码和参数,最后落到几个真正影响成色的技巧上。整条路线在普通带 GPU 的机器上就能复现,不需要云平台也能起步。
2. DNN 上色模块的输入输出与网络结构选型
2.1 为什么输入是 L 通道而不是整张灰度 RGB
做上色任务,第一件事是确定颜色空间。常见做法是把图像从 RGB 转到 CIE Lab 空间,取 L 通道作为网络输入,ab 两个通道作为预测目标。原因很直接:Lab 里的 L 表示亮度,ab 表示色度,亮度和颜色在数值上解耦了。如果直接把灰度图复制成三通道 RGB 送进去,网络要同时学亮度重建和色度预测,两个任务互相干扰,收敛慢且容易偏色。
用 L 通道做输入还有一个好处:推理时你手上拿到的灰度图,本身就可以直接当 L 用,不需要额外转换。输出端网络预测 ab 两个通道,最后和输入 L 拼回 Lab,再转回 RGB 保存。这套流程是当前上色任务里最稳的工程约定,绝大多数能跑出效果的开源实现都走这条路。
import cv2 import numpy as np def rgb_to_lab_input(rgb_img): # rgb_img: H x W x 3, uint8, RGB 顺序 lab = cv2.cvtColor(rgb_img, cv2.COLOR_RGB2LAB) L = lab[:, :, 0] # 亮度通道,范围 0-255 ab = lab[:, :, 1:] # 色度通道,范围 0-255(OpenCV 编码) # 归一化到网络友好的区间 L_norm = L.astype(np.float32) / 255.0 ab_norm = (ab.astype(np.float32) - 128.0) / 128.0 return L_norm, ab_norm def lab_to_rgb_output(L_norm, ab_norm): L = (L_norm * 255.0).clip(0, 255).astype(np.uint8) ab = (ab_norm * 128.0 + 128.0).clip(0, 255).astype(np.uint8) lab = np.concatenate([L[:, :, None], ab], axis=2) return cv2.cvtColor(lab, cv2.COLOR_LAB2RGB)这段代码里有两个参数容易翻车。一是 OpenCV 的 Lab 编码和标准 Lab 不一样,L 是 0-255 而不是 0-100,ab 也是 0-255 偏移 128,所以归一化系数要用 255 和 128,不能照搬论文里的 100 和 128。二是cv2.COLOR_RGB2LAB要求输入是 RGB 顺序,而 OpenCV 默认读图是 BGR,读进来要先转一次,否则 L 通道会偏,上色结果整体发灰。
2.2 编码器-解码器结构:U-Net 为什么是默认答案
上色网络的经典结构是编码器-解码器,编码器把 L 通道逐层下采样,提取从边缘到语义的特征;解码器再逐层上采样,恢复出和原图同尺寸的 ab。中间用跳跃连接把编码器的浅层特征直接接到解码器对应层,这就是 U-Net。跳跃连接对上色特别重要,因为颜色边界往往和物体边界重合,浅层的高频边缘信息能帮解码器把颜色涂在正确区域内,不然容易出现颜色溢出到相邻物体的情况。
编码器骨干网络的选择上,从零训练一个 U-Net 也能出效果,但收敛慢。常见做法是用预训练的 ResNet 或 MobileNet 前几层做编码器,解码器随机初始化。预训练权重带来的是通用纹理和形状先验,能让模型更快抓住“这是什么物体”,从而给出更合理的颜色。如果算力有限,MobileNetV2 做编码器是性价比很高的选择,参数量小,推理快,颜色质量下降有限。
import torch import torch.nn as nn import torchvision.models as models class ColorUNet(nn.Module): def __init__(self): super().__init__() # 用 MobileNetV2 的前几层做编码器,取多尺度特征 backbone = models.mobilenet_v2(weights=models.MobileNet_V2_Weights.DEFAULT) self.enc1 = backbone.features[0:2] # 下采样 2 倍 self.enc2 = backbone.features[2:4] # 下采样 4 倍 self.enc3 = backbone.features[4:7] # 下采样 8 倍 self.enc4 = backbone.features[7:14] # 下采样 16 倍 self.enc5 = backbone.features[14:] # 下采样 32 倍 self.up4 = nn.ConvTranspose2d(1280, 96, 2, stride=2) self.up3 = nn.ConvTranspose2d(96 + 32, 32, 2, stride=2) self.up2 = nn.ConvTranspose2d(32 + 24, 24, 2, stride=2) self.up1 = nn.ConvTranspose2d(24 + 16, 16, 2, stride=2) self.head = nn.Conv2d(16, 2, 1) # 输出 ab 两个通道 def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(e1) e3 = self.enc3(e2) e4 = self.enc4(e3) e5 = self.enc5(e4) d4 = self.up4(e5) d3 = self.up3(torch.cat([d4, e4], dim=1)) d2 = self.up2(torch.cat([d3, e3], dim=1)) d1 = self.up1(torch.cat([d2, e2], dim=1)) return torch.tanh(self.head(d1)) # ab 归一化到 [-1, 1]这里输出层用tanh把 ab 压到 [-1, 1],和前面归一化时的(ab-128)/128对应。如果换成 sigmoid 输出 [0, 1],推理时反归一化公式要跟着改,否则颜色会整体偏移。跳跃连接处的通道数拼接要算准,torch.cat的维度是通道维,写错会直接报尺寸不匹配,这是新手最常卡的地方。
2.3 损失函数:L1 打底,分类损失补颜色
只用 L1 或 L2 损失训练上色网络,结果往往偏灰。原因是颜色本身有多模态性,同一个灰度区域可能有多种合理颜色,L1 会让模型输出这些颜色的均值,均值往往接近灰色。这是上色任务里最经典的坑,也是很多人跑完觉得“模型没学会上色”的真正原因。
常见改进是引入分类思想:把 ab 空间量化成若干色块(比如 313 个 bin),让网络预测每个像素落在哪个色块的概率分布,再用交叉熵训练。推理时取概率最大的色块或者做加权平均。这样模型可以在一张图里对不同区域给出不同颜色,而不是全局求均值。工程上更省事的做法是 L1 加一个对抗损失或感知损失,用判别器逼模型输出更饱和、更真实的颜色,但训练复杂度会上去。
class ColorLoss(nn.Module): def __init__(self, lambda_l1=1.0, lambda_cls=0.1): super().__init__() self.lambda_l1 = lambda_l1 self.lambda_cls = lambda_cls self.ce = nn.CrossEntropyLoss() def forward(self, pred_ab, target_ab, pred_logits, target_bin): # pred_ab: B x 2 x H x W, target_ab 同尺寸 l1 = nn.functional.l1_loss(pred_ab, target_ab) # pred_logits: B x 313 x H x W, target_bin: B x H x W cls = self.ce(pred_logits, target_bin) return self.lambda_l1 * l1 + self.lambda_cls * clslambda_cls这个权重需要调。设太小,颜色还是偏灰;设太大,颜色会过饱和、出现色斑。我一般从 0.1 起步,观察验证集上的颜色饱和度,偏灰就往上加,出现明显色块就往下调。这个参数没有万能值,和数据集颜色分布强相关。
3. 数据准备与训练流程:从灰度图到可上色模型
3.1 数据集怎么选、怎么转成 Lab 对
上色模型训练需要大量彩色图,训练时把彩色图转成 Lab,L 当输入,ab 当标签。常用数据集有 ImageNet 子集、COCO、Places365,这些数据量大、场景丰富,适合从零训练。如果只是练手或做特定场景(比如老照片、动漫线稿),用几百到几千张同分布图片微调也能出效果。
数据读取时要注意两点。一是统一尺寸,上色网络对输入尺寸没有硬性要求,但训练时 batch 内尺寸必须一致,常见做法是随机裁剪 256x256 或缩放到 256 短边再随机裁。二是颜色空间转换要用同一套库,训练用 OpenCV 转,推理也用 OpenCV 转,混用 PIL 和 OpenCV 会因为 Lab 编码差异导致颜色偏移。
from torch.utils.data import Dataset import cv2 import numpy as np import os class ColorDataset(Dataset): def __init__(self, img_dir, size=256): self.paths = [os.path.join(img_dir, f) for f in os.listdir(img_dir) if f.lower().endswith(('.jpg', '.png', '.jpeg'))] self.size = size def __len__(self): return len(self.paths) def __getitem__(self, idx): bgr = cv2.imread(self.paths[idx]) rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) # 随机裁剪增强 h, w = rgb.shape[:2] if h < self.size or w < self.size: rgb = cv2.resize(rgb, (max(w, self.size), max(h, self.size))) h, w = rgb.shape[:2] y = np.random.randint(0, h - self.size + 1) x = np.random.randint(0, w - self.size + 1) rgb = rgb[y:y+self.size, x:x+self.size] lab = cv2.cvtColor(rgb, cv2.COLOR_RGB2LAB) L = lab[:, :, 0].astype(np.float32) / 255.0 ab = (lab[:, :, 1:].astype(np.float32) - 128.0) / 128.0 return torch.from_numpy(L[None]), torch.from_numpy(ab.transpose(2, 0, 1))__getitem__返回的 L 加了一个维度变成 1xHxW,ab 转成 2xHxW,这是 PyTorch 卷积要求的通道在前格式。如果忘了 transpose,训练时维度对不上会直接报错。随机裁剪是必要的增强手段,能显著降低过拟合,尤其是数据量不大的时候。
3.2 训练循环与关键超参
训练循环本身不复杂,关键是几个超参的设置。学习率用 1e-4 到 3e-4 起步,配合 Adam 或 AdamW 优化器。batch size 在显存允许范围内尽量大,8 到 32 都常见,batch 太小颜色预测会不稳定。训练轮数看数据量,ImageNet 级别通常几十轮,小数据集微调十几轮就够。
import torch from torch.utils.data import DataLoader device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = ColorUNet().to(device) dataset = ColorDataset('./data/train', size=256) loader = DataLoader(dataset, batch_size=16, shuffle=True, num_workers=4) optimizer = torch.optim.AdamW(model.parameters(), lr=2e-4, weight_decay=1e-5) criterion = ColorLoss(lambda_l1=1.0, lambda_cls=0.1) for epoch in range(30): model.train() total_loss = 0.0 for L, ab in loader: L, ab = L.to(device), ab.to(device) pred_ab = model(L) loss = nn.functional.l1_loss(pred_ab, ab) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() print(f'epoch {epoch}, loss {total_loss / len(loader):.4f}')这段是最小可跑版本,只用了 L1 损失,方便先验证流程通不通。跑通后再把分类损失加进去。num_workers在 Windows 上设 0 更稳,设大了容易卡在数据加载。如果显存不够,把 batch_size 降到 8 或 4,同时把学习率按比例调小一点。
3.3 验证与推理:怎么判断模型真的学会了
训练 loss 下降不代表上色效果好,必须用验证集肉眼看图。推理时把灰度图转成 L,送进模型得到 ab,再拼回 Lab 转 RGB。这里有个细节:推理用的 L 应该来自原始灰度图,而不是从彩色图转出来的 L,否则你是在用“作弊”的输入评估模型。
def colorize(model, gray_path, device): model.eval() gray = cv2.imread(gray_path, cv2.IMREAD_GRAYSCALE) L = gray.astype(np.float32) / 255.0 L_tensor = torch.from_numpy(L[None, None]).to(device) with torch.no_grad(): ab = model(L_tensor).cpu().numpy()[0] # 2 x H x W ab = ab.transpose(1, 2, 0) # H x W x 2 ab = (ab * 128.0 + 128.0).clip(0, 255).astype(np.uint8) L_uint8 = (L * 255.0).astype(np.uint8) lab = np.concatenate([L_uint8[:, :, None], ab], axis=2) return cv2.cvtColor(lab, cv2.COLOR_LAB2RGB)验证时重点看三类问题:整体是否偏灰、颜色是否溢出物体边界、同一物体颜色是否一致。偏灰说明分类损失权重不够或训练不充分;颜色溢出说明跳跃连接没起作用或解码器上采样太粗糙;颜色不一致说明模型对纹理理解不够,需要更多数据或更强编码器。
4. 上色效果翻车的排查清单
4.1 输出整体发灰,几乎没有颜色
现象:推理结果看起来像加了淡色调的灰度图,ab 通道数值接近 0。
原因:最常见是损失函数只用 L1,模型输出了多模态颜色的均值。其次是分类损失权重太小,或者训练轮数不够,模型还没学会区分色块。
解决:把lambda_cls从 0.1 往上调,试 0.5 甚至 1.0;检查 ab 归一化范围是否和输出层激活匹配,tanh 对应 [-1,1],sigmoid 对应 [0,1],不匹配会导致颜色被压扁;增加训练轮数,观察验证集颜色饱和度是否随轮数上升。
4.2 颜色溢出到相邻物体
现象:天空的蓝色涂到了屋顶上,或者人物衣服颜色渗到背景。
原因:解码器上采样太粗糙,或者跳跃连接通道数不对导致浅层边缘信息没传下去。另一个可能是训练时随机裁剪太激进,模型没见过完整物体边界。
解决:检查 U-Net 跳跃连接的torch.cat通道数是否和编码器对应层一致;在解码器最后几层加卷积细化边缘;训练时适当降低裁剪比例,让模型多见完整物体。
4.3 推理颜色和训练时不一致
现象:训练时验证集颜色正常,单独拿一张灰度图推理却偏色。
原因:训练时 L 来自彩色图转换,推理时 L 来自真实灰度图,两者分布有差异。或者训练和推理用了不同的颜色空间转换库。
解决:训练时对 L 做和推理一致的预处理,比如都除以 255;统一用 OpenCV 做 Lab 转换,不要训练用 PIL、推理用 OpenCV;可以在训练时加入灰度图增强,模拟真实灰度输入。
4.4 显存爆了或者训练速度极慢
现象:batch size 设 16 就 OOM,或者每个 epoch 要跑几个小时。
原因:输入尺寸太大,或者编码器用了 ResNet50 以上级别骨干,参数量大。num_workers设置不当也会拖慢数据加载。
解决:把训练尺寸从 256 降到 128 或 192;换 MobileNetV2 做编码器;num_workers设成 CPU 核数的 1/4 到 1/2,Windows 上设 0;开启混合精度训练,torch.cuda.amp能省不少显存。
4.5 颜色出现明显色斑或噪点
现象:输出图上有不自然的彩色斑点,尤其在纹理复杂区域。
原因:分类损失权重过大,模型过度追求色块分类而忽略了空间平滑;或者 ab 量化 bin 太少,颜色分辨率不够。
解决:降低lambda_cls;增加 ab 量化 bin 数量,313 是常见值,可以试 512;在损失里加一个总变差正则项,惩罚相邻像素颜色突变。
5. 让上色更自然的三个进阶技巧
5.1 用感知损失替代部分 L1
L1 损失关注像素级差异,但人眼对颜色的感知更依赖语义和纹理。把预训练 VGG 的前几层拿出来,计算生成图和原图在特征空间的差异,就是感知损失。它能让模型输出的颜色在语义上更合理,而不是死抠像素值。工程上把感知损失和 L1 按 0.1:1 的比例混合,颜色自然度会有明显提升。代价是训练时多跑一个 VGG 前向,显存和耗时都会增加,适合对质量要求高的场景。
5.2 推理时做后处理平滑
模型输出 ab 后,直接拼回 Lab 可能在物体边界处有轻微跳变。一个简单有效的后处理是对 ab 通道做引导滤波,用 L 通道当引导图。引导滤波能在保留边缘的同时平滑颜色过渡,代码只有几行,效果立竿见影。
import cv2 import numpy as np def guided_smooth(L, ab, radius=8, eps=1e-2): # L: H x W, 0-1; ab: H x W x 2, -1 到 1 ab_smooth = np.zeros_like(ab) for i in range(2): ab_smooth[:, :, i] = cv2.ximgproc.guidedFilter( guide=(L * 255).astype(np.uint8), src=ab[:, :, i].astype(np.float32), radius=radius, eps=eps * 255 * 255 ) return ab_smoothradius控制平滑范围,设太大颜色会糊,一般 4 到 8 之间。eps控制边缘保留强度,设太小平滑不够,设太大边缘也会被抹掉。这个后处理在推理阶段做,不影响训练,属于低成本提质的后悔药。
5.3 用参考图上色控制色调
如果用户想要特定色调,比如把老照片调成暖黄复古风,可以引入参考图。做法是把参考图也转成 Lab,提取它的 ab 统计量(均值和方差),在推理时把模型输出的 ab 做颜色迁移,对齐到参考图的分布。这样不需要重新训练,就能控制整体色调。这个技巧在照片修复类应用里很实用,实现上就是几行统计计算,但能让同一个模型适配多种风格需求。
我自己的习惯是:每次训练完先跑一批验证图,肉眼过一遍,把偏灰、溢出、色斑三类问题记下来,再回头调损失权重和数据增强。上色这个任务没有一步到位的参数,都是看着图一点点磨出来的。希望帮到你。
本文还有配套的精品资源,点击获取