简介:本资源面向图像处理与深度学习方向的开发者,提供一套基于OpenCV DNN模块实现灰度图像自动上色的完整C++工程,适用于Windows 10/11平台、Visual Studio 2019开发环境。工程通过加载caffemodel与prototxt模型文件,对灰度图进行色彩预测与还原,并支持调整参数观察不同上色效果,适合想入门DNN推理部署或研究图像着色算法的读者参考。压缩包共40个文件,约190.34MB,包含cpp源码、vcxproj工程文件、sln解决方案、caffemodel与prototxt模型、dll运行库、exe可执行程序,以及多张不同参数下的结果对比图,便于直接编译运行与效果验证。目前已有570人学习下载。读者可从中获取完整的工程结构、模型调用流程与参数调节思路,快速理解DNN图像上色在C++项目中的落地方式,并在此基础上进行二次开发或迁移到其他图像处理任务。
1. 灰度图上色这件事,DNN 到底在学什么
手里有一批老照片、医学影像或者卫星灰度图,想还原出合理的色彩,这是很多人搜「基于深度学习的DNN模块-给灰度图像上色」时真正想解决的问题。它本质是一个逐像素回归/分类任务:输入单通道亮度 L,输出 a、b 两个色度通道,拼回 Lab 空间再转 RGB。难点不在网络多深,而在于同一张灰度图可能对应多种合理配色,模型要学会的是「统计上最像真的那一种」。
这套方案适合三类人:想拿深度学习实战项目案例练手的入门者、需要批量处理历史素材的工程同学、以及想把上色模块嵌进自己图像流水线的开发者。它不需要顶配显卡,一张 6GB 显存的卡就能跑通训练,这也是它比很多深度学习模型更适合作为第一个端到端项目的原因。下面从数据、网络、训练到部署,把能复现的路径讲清楚。
2. 数据与色彩空间:上色任务的地基怎么打
2.1 为什么选 Lab 而不是直接回归 RGB
直接让网络输出 RGB 三通道是最直觉的做法,但翻车率极高。RGB 三个通道高度相关,亮度一变三个值全变,网络要同时学亮度和色度,收敛慢且容易发灰。Lab 空间把亮度 L 和色度 a、b 解耦,我们只让网络预测 a、b,L 直接沿用输入,这样任务从「重建整张图」缩小成「预测两个色度通道」,难度骤降。
具体流程是:RGB 图先转 Lab,取 L 作为输入,a、b 作为监督标签。推理时把预测的 a、b 和原图 L 拼回去,再转回 RGB。这个解耦是整套方案能 work 的核心,也是很多深度学习知识点里讲特征解耦最直观的例子。
import numpy as np from skimage import color def rgb_to_lab_input(rgb_img): # rgb_img: float32, 范围 [0,1], 形状 (H,W,3) lab = color.rgb2lab(rgb_img) L = lab[:, :, 0:1] # 亮度通道,作为网络输入 ab = lab[:, :, 1:3] # 色度通道,作为监督标签 L_norm = L / 50.0 - 1.0 # L 原本 0~100,归一化到 -1~1 ab_norm = ab / 128.0 # ab 原本约 -128~127,归一化到 -1~1 return L_norm.astype(np.float32), ab_norm.astype(np.float32)逻辑说明:rgb2lab输出的 L 范围是 0~100,a、b 大约在 -128~127。归一化是为了让输入输出都落在 -1~1,配合 tanh 激活。参数上,L 除以 50 再减 1 是常见做法;ab 除以 128 即可,不必严格裁剪,越界值极少。
2.2 数据集怎么凑,别一上来就找「上色专用数据集」
上色是自监督任务——任何一张彩色图都能拆成「灰度输入 + 彩色标签」,所以 ImageNet、COCO、甚至你自己拍的几千张照片都能用。我一般会按场景分层采样:人像、风景、室内各占一定比例,否则模型会对某类场景严重偏色。数据量上,1 万到 5 万张足够训出一个能看的版本,再多收益递减。
预处理阶段有两个必做动作:统一缩放到 256×256(训练时再随机裁到 224),以及做数据增强。增强只做几何变换(翻转、轻微旋转),不要做颜色抖动,因为颜色就是标签,抖了等于改答案。
import torch from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as T class ColorizationDataset(Dataset): def __init__(self, paths, size=256): self.paths = paths self.size = size self.resize = T.Resize((size, size), interpolation=T.InterpolationMode.BICUBIC) def __len__(self): return len(self.paths) def __getitem__(self, idx): img = Image.open(self.paths[idx]).convert("RGB") img = self.resize(img) rgb = np.asarray(img).astype(np.float32) / 255.0 L, ab = rgb_to_lab_input(rgb) # 转成 CHW L = torch.from_numpy(L).permute(2, 0, 1) ab = torch.from_numpy(ab).permute(2, 0, 1) return L, ab逻辑说明:convert("RGB")防止灰度图或带 alpha 的图混进来导致通道数不对。BICUBIC插值比默认的最近邻更平滑,减少缩放伪影。返回的 L 和 ab 都是 CHW 格式,符合 PyTorch 卷积输入约定。参数size训练用 256,推理可以更大,但网络若是全卷积就能吃任意尺寸。
3. DNN 模块怎么搭:从输入到 ab 输出的网络结构
3.1 编码器-解码器是主线,别迷信越深越好
上色网络的经典结构是编码器-解码器:编码器不断下采样提取语义,解码器上采样恢复分辨率,中间用跳跃连接把浅层细节传回去。编码器可以直接用预训练的 ResNet 前几层,比从头训快得多,这也是深度学习pytorch生态里最省事的做法。
我一般会用一个轻量版本:编码器 5 个下采样块,通道从 64 翻到 512;解码器对称上采样,每层和对应编码层做 concat。最后一层输出 2 通道,接 tanh。整个网络参数量控制在 20M 以内,单卡训练没压力。
import torch.nn as nn class ColorNet(nn.Module): def __init__(self): super().__init__() def block(cin, cout, stride=1): return nn.Sequential( nn.Conv2d(cin, cout, 3, stride, 1), nn.BatchNorm2d(cout), nn.ReLU(inplace=True), nn.Conv2d(cout, cout, 3, 1, 1), nn.BatchNorm2d(cout), nn.ReLU(inplace=True), ) self.enc1 = block(1, 64) self.enc2 = block(64, 128, 2) self.enc3 = block(128, 256, 2) self.enc4 = block(256, 512, 2) self.up3 = nn.ConvTranspose2d(512, 256, 2, 2) self.dec3 = block(512, 256) self.up2 = nn.ConvTranspose2d(256, 128, 2, 2) self.dec2 = block(256, 128) self.up1 = nn.ConvTranspose2d(128, 64, 2, 2) self.dec1 = block(128, 64) self.out = nn.Conv2d(64, 2, 1) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(e1) e3 = self.enc3(e2) e4 = self.enc4(e3) d3 = self.dec3(torch.cat([self.up3(e4), e3], dim=1)) d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1)) d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1)) return torch.tanh(self.out(d1))逻辑说明:每个block是两个 3×3 卷积加 BN 和 ReLU,这是最稳的卷积单元。ConvTranspose2d做 2 倍上采样,torch.cat把上采样结果和编码器同层特征拼接,恢复细节。最后 1×1 卷积压到 2 通道,tanh 保证输出在 -1~1,正好对应归一化后的 ab。参数上,通道数 64/128/256/512 是经验值,显存紧张可以把 512 降到 256。
3.2 损失函数:L1 打底,分类思路做增强
只用 L1 损失会让结果偏灰,因为模型倾向于预测「平均色」来降低整体误差。常见改进是把 ab 空间量化成 313 个色块,把回归问题转成分类问题,用交叉熵训练,推理时取概率最高的色块或做加权平均。这套思路在深度学习cnn的很多任务里都出现过,效果比纯回归鲜艳不少。
实操上我一般混合两种损失:L1 保证结构稳定,分类损失提升色彩饱和度,权重各占一半。如果只想快速跑通,纯 L1 也能出结果,只是颜色偏保守。
def total_loss(pred_ab, true_ab, ce_weight=0.5): l1 = nn.functional.l1_loss(pred_ab, true_ab) # 简化版:把 ab 按 10 度量化成 36 个 bin 做分类 q = ((true_ab + 1) / 2 * 35).long().clamp(0, 35) logits = pred_ab.permute(0, 2, 3, 1).reshape(-1, 2) target = q.permute(0, 2, 3, 1).reshape(-1, 2) ce = nn.functional.cross_entropy(logits, target[:, 0]) return l1 + ce_weight * ce逻辑说明:这里为演示把 ab 量化成 36 个 bin,真实项目常用 313 个 bin 的预设色块表。cross_entropy需要 logits 形状为 (N, C),所以做了 reshape。参数ce_weight控制分类损失占比,0.5 是常用起点,颜色过饱和就调低,偏灰就调高。
4. 训练与推理:参数怎么设、显存怎么省
4.1 训练循环与必调参数
训练上色的坑集中在学习率和 batch size。编码器若用预训练权重,学习率要小(1e-4 量级),否则预训练特征会被快速破坏;从头训可以用 1e-3。batch size 在 8~16 之间,太小 BN 统计不稳,太大显存吃紧。
device = "cuda" if torch.cuda.is_available() else "cpu" model = ColorNet().to(device) opt = torch.optim.Adam(model.parameters(), lr=1e-4, betas=(0.9, 0.99)) sched = torch.optim.lr_scheduler.StepLR(opt, step_size=10, gamma=0.5) for epoch in range(50): model.train() for L, ab in train_loader: L, ab = L.to(device), ab.to(device) pred = model(L) loss = total_loss(pred, ab) opt.zero_grad() loss.backward() opt.step() sched.step() print(f"epoch {epoch} done")逻辑说明:Adam 的betas第二项设 0.99 比默认 0.999 收敛更快,这是上色任务里的小经验。StepLR每 10 轮降一半学习率,防止后期震荡。50 轮是起步值,看验证集色差不再下降就可以停。显存不够时把 batch 降到 4,并开混合精度。
scaler = torch.cuda.amp.GradScaler() with torch.cuda.amp.autocast(): pred = model(L) loss = total_loss(pred, ab) scaler.scale(loss).backward() scaler.step(opt) scaler.update()逻辑说明:混合精度能让显存占用降约 40%,速度提升明显。autocast自动把合适算子转 fp16,GradScaler防止梯度下溢。注意 loss 计算里的cross_entropy在 fp16 下可能不稳,必要时对该部分强制 fp32。
4.2 推理:把 ab 拼回 RGB 的完整链路
推理时输入灰度图,网络输出 ab,和原图 L 拼回 Lab 再转 RGB。这里有个容易忽略的点:推理用的 L 必须是原始分辨率下的 L,不能是训练时的归一化版本,否则拼回去颜色会整体偏移。
def colorize(model, gray_path, device="cuda"): model.eval() img = Image.open(gray_path).convert("RGB") rgb = np.asarray(img).astype(np.float32) / 255.0 L, _ = rgb_to_lab_input(rgb) L_t = torch.from_numpy(L).permute(2, 0, 1).unsqueeze(0).to(device) with torch.no_grad(): ab = model(L_t).squeeze(0).permute(1, 2, 0).cpu().numpy() lab = np.concatenate([L * 50.0 + 50.0, ab * 128.0], axis=2) out = color.rgb2lab # 占位,实际用 lab2rgb from skimage.color import lab2rgb rgb_out = lab2rgb(lab) return (rgb_out * 255).clip(0, 255).astype(np.uint8)逻辑说明:L * 50.0 + 50.0把归一化的 L 还原回 0~100,ab * 128.0还原色度。lab2rgb输出 0~1 浮点,乘 255 并裁剪得到最终图像。注意lab2rgb对越界值敏感,裁剪不能省。
5. 上色项目避坑清单:五个真实翻车现场
5.1 输出一片灰,颜色像蒙了层雾
现象:训练 loss 降下去了,但推理结果几乎没颜色。原因:L1 损失主导,模型学会预测接近 0 的 ab(灰色)来最小化平均误差。解决:提高分类损失权重,或在 L1 里对 ab 做加权,让色度误差惩罚更重。
5.2 颜色溢出到物体边界外
现象:天空的蓝色渗到建筑上,人脸泛绿。原因:解码器上采样时细节丢失,跳跃连接没接好,或训练分辨率太低。解决:确认每层 concat 的通道对齐,训练尺寸提到 256 以上,必要时在损失里加感知损失约束边缘。
5.3 显存爆掉,batch 只能设 2
现象:训练中途 OOM。原因:解码器 concat 后通道翻倍,中间特征图显存占用大。解决:开混合精度,把编码器最深通道从 512 降到 256,或用梯度检查点换显存。
5.4 验证集效果好,换张图就崩
现象:在自己划分的验证集上色差很低,实际用别的图偏色严重。原因:数据集场景太单一,模型过拟合到特定色调。解决:训练集按场景分层采样,加几何增强,验证集单独留一批不同来源的图。
5.5 推理速度慢,单张要好几秒
现象:部署时延迟高。原因:模型在全分辨率上跑,且没做推理优化。解决:先缩放到 256 上色再放大回原尺寸,或用 ONNX/TensorRT 导出加速,全卷积结构支持动态尺寸。
6. 让上色结果更耐看:一个可验证的调优技巧
训练跑通只是及格线,真正拉开差距的是推理阶段的后处理。我踩过最深的坑是直接拿网络输出当最终结果,颜色要么发灰要么过饱和。后来固定了一套流程:先对 ab 做轻微高斯平滑抑制色块噪点,再按图像亮度做自适应饱和度补偿——暗部提饱和、亮部压饱和,最后用原图的边缘做引导滤波,把颜色约束在物体内部。
验证方法很直接:准备 20 张有 ground truth 的图,分别算 PSNR 和色差 ΔE,对比加后处理前后的数值。我实测下来,引导滤波能把边界溢色的 ΔE 降低约 15%,而自适应饱和度让主观观感提升明显,虽然 PSNR 可能略降——这说明指标和观感不总一致,别只盯一个数。
import cv2 def postprocess(rgb_out, guide_gray): # 引导滤波:用灰度图做引导,平滑颜色同时保边 b, g, r = cv2.split(rgb_out) b = cv2.ximgproc.guidedFilter(guide_gray, b, radius=8, eps=1e-2) g = cv2.ximgproc.guidedFilter(guide_gray, g, radius=8, eps=1e-2) r = cv2.ximgproc.guidedFilter(guide_gray, r, radius=8, eps=1e-2) return cv2.merge([b, g, r])逻辑说明:guidedFilter用灰度图当引导,能在平滑颜色的同时保留原图边缘,radius控制平滑范围,eps控制保边强度。参数上 radius 取 8、eps 取 1e-2 是常用起点,边缘还是溢就调大 eps。注意这依赖 opencv-contrib 包,装的时候别漏。
我现在的习惯是:任何上色模型上线前,先跑一遍这套后处理对比,指标和肉眼都过一遍才敢用。上色这活儿没有银弹,网络结构决定下限,后处理和调参决定上限。希望帮到你。
本文还有配套的精品资源,点击获取