资讯详情

基于DNN的灰度图像上色实战:从Lab颜色空间到U-Net训练与调优

📅 2026/10/8 19:08:13 | 华诺云谱 👁 阅读
基于DNN的灰度图像上色实战:从Lab颜色空间到U-Net训练与调优
简介本资源面向图像处理与深度学习方向的开发者提供一套基于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-255OpenCV 编码 # 归一化到网络友好的区间 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], axis2) return cv2.cvtColor(lab, cv2.COLOR_LAB2RGB)这段代码里有两个参数容易翻车。一是 OpenCV 的 Lab 编码和标准 Lab 不一样L 是 0-255 而不是 0-100ab 也是 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(weightsmodels.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, stride2) self.up3 nn.ConvTranspose2d(96 32, 32, 2, stride2) self.up2 nn.ConvTranspose2d(32 24, 24, 2, stride2) self.up1 nn.ConvTranspose2d(24 16, 16, 2, stride2) 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], dim1)) d2 self.up2(torch.cat([d3, e3], dim1)) d1 self.up1(torch.cat([d2, e2], dim1)) 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_l11.0, lambda_cls0.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 对上色模型训练需要大量彩色图训练时把彩色图转成 LabL 当输入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, size256): 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:yself.size, x:xself.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 加了一个维度变成 1xHxWab 转成 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, size256) loader DataLoader(dataset, batch_size16, shuffleTrue, num_workers4) optimizer torch.optim.AdamW(model.parameters(), lr2e-4, weight_decay1e-5) criterion ColorLoss(lambda_l11.0, lambda_cls0.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(fepoch {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], axis2) 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/2Windows 上设 0开启混合精度训练torch.cuda.amp能省不少显存。4.5 颜色出现明显色斑或噪点现象输出图上有不自然的彩色斑点尤其在纹理复杂区域。原因分类损失权重过大模型过度追求色块分类而忽略了空间平滑或者 ab 量化 bin 太少颜色分辨率不够。解决降低lambda_cls增加 ab 量化 bin 数量313 是常见值可以试 512在损失里加一个总变差正则项惩罚相邻像素颜色突变。5. 让上色更自然的三个进阶技巧5.1 用感知损失替代部分 L1L1 损失关注像素级差异但人眼对颜色的感知更依赖语义和纹理。把预训练 VGG 的前几层拿出来计算生成图和原图在特征空间的差异就是感知损失。它能让模型输出的颜色在语义上更合理而不是死抠像素值。工程上把感知损失和 L1 按 0.1:1 的比例混合颜色自然度会有明显提升。代价是训练时多跑一个 VGG 前向显存和耗时都会增加适合对质量要求高的场景。5.2 推理时做后处理平滑模型输出 ab 后直接拼回 Lab 可能在物体边界处有轻微跳变。一个简单有效的后处理是对 ab 通道做引导滤波用 L 通道当引导图。引导滤波能在保留边缘的同时平滑颜色过渡代码只有几行效果立竿见影。import cv2 import numpy as np def guided_smooth(L, ab, radius8, eps1e-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), srcab[:, :, i].astype(np.float32), radiusradius, epseps * 255 * 255 ) return ab_smoothradius控制平滑范围设太大颜色会糊一般 4 到 8 之间。eps控制边缘保留强度设太小平滑不够设太大边缘也会被抹掉。这个后处理在推理阶段做不影响训练属于低成本提质的后悔药。5.3 用参考图上色控制色调如果用户想要特定色调比如把老照片调成暖黄复古风可以引入参考图。做法是把参考图也转成 Lab提取它的 ab 统计量均值和方差在推理时把模型输出的 ab 做颜色迁移对齐到参考图的分布。这样不需要重新训练就能控制整体色调。这个技巧在照片修复类应用里很实用实现上就是几行统计计算但能让同一个模型适配多种风格需求。我自己的习惯是每次训练完先跑一批验证图肉眼过一遍把偏灰、溢出、色斑三类问题记下来再回头调损失权重和数据增强。上色这个任务没有一步到位的参数都是看着图一点点磨出来的。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑