简介:这份资源面向计算机相关专业的在校学生、教师及企业员工,提供一套基于深度学习CNN网络实现图像着色的完整Python源码,可用于课程设计、毕业设计、大作业或初期项目立项演示。压缩包共40个文件,约17.52MB,包含14个py源码文件、12张png效果图、10个pyc编译文件以及txt说明和pdf参考文献,源码涵盖eccv16、siggraph17等经典着色模型及工具模块,结构清晰便于按模块学习。目前已有50人学习下载。读者可借此掌握CNN图像着色从数据到推理的完整流程,理解模型组织与调用方式,并参考效果图与文献进行二次开发或功能扩展,适合入门进阶与项目借鉴。
1. 从一张泛黄老照片说起:CNN图像着色到底在做什么
手里有一张上世纪的黑白照片,人脸模糊、天空灰白、衣服看不出颜色。你想把它变成彩色,但手工上色一张要几个小时,还未必自然。基于深度学习CNN网络实现图像着色,解决的就是这件事:输入一张灰度图(或L通道),输出对应的a、b两个色度通道,再和原L通道拼回RGB彩色图。它适合三类人:想入门深度学习但不想只跑MNIST的Python开发者、需要批量处理老照片的影像从业者、以及想拿一个完整源码项目练手CNN回归任务的学生。这个方向不算新,但它是极好的练手项目——输入输出都是图像,损失函数直观,训练过程肉眼可见地在变好。更关键的是,它把CNN从“分类”拉到了“回归+生成”的领域,你会第一次认真思考:为什么预测颜色这么难?因为同一张灰度图,天空可以是蓝色也可以是橙色,模型必须学会“合理”而不是“唯一”。
2. 图像着色的CNN架构选型:从编码器-解码器到U-Net
2.1 为什么着色任务不适合直接套用分类网络
图像着色本质上是一个逐像素回归问题。输入是H×W×1的灰度图,输出是H×W×2的ab色度图。分类网络(如ResNet、VGG)最后接全连接层输出类别概率,这个结构对着色毫无用处——你需要的是和输入同分辨率的空间输出,而不是一个类别标签。
常见做法是采用编码器-解码器结构:编码器用卷积+池化逐步降采样,提取语义特征;解码器用转置卷积或上采样逐步恢复分辨率。但朴素编码器-解码器有个致命问题:降采样过程中空间细节丢失,上色结果边缘模糊、颜色溢出。我一般会直接上U-Net,因为它的跳跃连接(skip connection)把编码器的高分辨率特征直接拼到解码器对应层,边缘和纹理信息得以保留。对于着色任务,这个改进不是锦上添花,是刚需。
另一个选型点是输入输出空间。不要在RGB空间直接回归,因为RGB三个通道高度相关,模型容易学成“灰色平均”。标准做法是转到Lab色彩空间:L是亮度,a和b是色度。输入只给L,让模型预测a和b,任务解耦更干净。这也是学术界和工业界最通用的方案。
2.2 用PyTorch搭一个最小可用的着色U-Net
下面这段代码是一个可以直接跑通的U-Net着色网络定义。我刻意把通道数压小,方便你在单卡甚至CPU上先验证流程。
import torch import torch.nn as nn import torch.nn.functional as F class ColorizationUNet(nn.Module): def __init__(self): super().__init__() # 编码器:输入1通道(L),逐层升到256通道 self.enc1 = self._block(1, 64) self.enc2 = self._block(64, 128) self.enc3 = self._block(128, 256) self.enc4 = self._block(256, 256) self.pool = nn.MaxPool2d(2) # 瓶颈层 self.bottleneck = self._block(256, 512) # 解码器:转置卷积 + 跳跃连接拼接 self.up4 = nn.ConvTranspose2d(512, 256, 2, stride=2) self.dec4 = self._block(512, 256) # 256(上采样)+256(skip)=512 self.up3 = nn.ConvTranspose2d(256, 128, 2, stride=2) self.dec3 = self._block(256, 128) self.up2 = nn.ConvTranspose2d(128, 64, 2, stride=2) self.dec2 = self._block(128, 64) self.up1 = nn.ConvTranspose2d(64, 32, 2, stride=2) self.dec1 = self._block(64, 32) # 输出层:2通道对应a、b,用tanh限制到[-1,1] self.out_conv = nn.Conv2d(32, 2, 1) self.tanh = nn.Tanh() def _block(self, in_ch, out_ch): return 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): # 编码路径 e1 = self.enc1(x) # [B,64,H,W] e2 = self.enc2(self.pool(e1)) # [B,128,H/2,W/2] e3 = self.enc3(self.pool(e2)) # [B,256,H/4,W/4] e4 = self.enc4(self.pool(e3)) # [B,256,H/8,W/8] # 瓶颈 b = self.bottleneck(self.pool(e4)) # [B,512,H/16,W/16] # 解码路径 + 跳跃连接 d4 = self.up4(b) d4 = self.dec4(torch.cat([d4, e4], dim=1)) d3 = self.up3(d4) d3 = self.dec3(torch.cat([d3, e3], dim=1)) d2 = self.up2(d3) d2 = self.dec2(torch.cat([d2, e2], dim=1)) d1 = self.up1(d2) d1 = self.dec1(torch.cat([d1, e1], dim=1)) return self.tanh(self.out_conv(d1)) # [B,2,H,W]逻辑说明:编码器每经过一个_block后做一次2倍下采样,特征图尺寸减半、通道翻倍。瓶颈层在H/16分辨率上提取全局语义。解码器每一步先转置卷积放大2倍,再和编码器同分辨率的特征在通道维拼接,最后过卷积块融合。输出层用1×1卷积压到2通道,tanh把值域限制在[-1,1],和Lab空间中a、b的归一化范围对齐。
参数说明:_block里的两个3×3卷积是标准配置,padding=1保证尺寸不变。BatchNorm加速收敛,如果显存吃紧可以换成InstanceNorm。转置卷积的kernel_size=2、stride=2是精确2倍上采样的常用组合。输出通道数必须是2,这是Lab空间决定的,不要改成3。
2.3 损失函数怎么选:L1、L2还是分类式
着色任务最常用的损失是L1损失(平均绝对误差),直接对预测的a、b和真实的a、b做逐像素比较。L2(MSE)也可以,但L2对异常值更敏感,容易让模型偏向“安全”的灰色。实践中L1收敛更稳,颜色更饱和。
但纯L1有个问题:它假设每个像素的颜色是确定的,而实际上同一灰度值可能对应多种合理颜色。进阶做法是把a、b空间量化成313个色块(bin),把回归问题转成分类问题,用交叉熵训练,推理时取概率最高的bin或做软编码。这个方案来自2016年的一篇经典工作,效果比纯回归好,但实现复杂度高不少。我的建议是:先用L1跑通全流程,确认数据管道和网络结构没问题,再考虑升级到分类式。
# L1损失:直接比较预测ab和真实ab criterion = nn.L1Loss() # 训练循环中的关键一行 pred_ab = model(L) # [B,2,H,W] loss = criterion(pred_ab, true_ab)参数说明:L1Loss没有超参数,直接调用即可。如果发现颜色偏灰,可以尝试对损失加权,比如对a、b通道分别给不同权重,或者加入梯度惩罚项让边缘更锐利。
3. 数据管道与训练流程:从ImageNet到自定义照片
3.1 数据从哪来、怎么转Lab、怎么归一化
着色模型的训练数据就是彩色图像本身。你不需要标注,任何彩色图都可以:ImageNet、COCO、你自己的相册。流程是:读入RGB图 → 转Lab → 取L作为输入、ab作为标签 → 归一化。
import numpy as np from PIL import Image import torch from torch.utils.data import Dataset, DataLoader import torchvision.transforms as T class ColorizationDataset(Dataset): def __init__(self, image_paths, size=256): self.paths = image_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) img = np.array(img).astype(np.float32) / 255.0 # RGB -> Lab,注意skimage的Lab范围 from skimage.color import rgb2lab lab = rgb2lab(img) # L:[0,100], a:[-128,127], b:[-128,127] L = lab[:, :, 0] / 50.0 - 1.0 # 归一化到[-1,1] ab = lab[:, :, 1:] / 128.0 # 归一化到约[-1,1] L = torch.from_numpy(L).unsqueeze(0).float() # [1,H,W] ab = torch.from_numpy(ab).permute(2,0,1).float() # [2,H,W] return L, ab # 使用示例 dataset = ColorizationDataset(['photo1.jpg', 'photo2.jpg']) loader = DataLoader(dataset, batch_size=16, shuffle=True, num_workers=4)逻辑说明:rgb2lab把RGB转到Lab空间。L通道除以50再减1,映射到[-1,1];ab通道除以128,也大致落在[-1,1]。这样输入输出都在同一量级,训练更稳定。
参数说明:size=256是常用训练分辨率,太小丢失细节,太大显存吃不消。batch_size=16是8GB显存下的保守值,可以按需调整。num_workers=4加速数据加载,Windows下如果报错就改成0。
3.2 训练循环与关键超参数
import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = ColorizationUNet().to(device) optimizer = optim.Adam(model.parameters(), lr=1e-4, betas=(0.5, 0.999)) criterion = nn.L1Loss() for epoch in range(50): model.train() total_loss = 0 for L, ab in loader: L, ab = L.to(device), ab.to(device) pred = model(L) loss = criterion(pred, ab) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() print(f'Epoch {epoch+1}, Loss: {total_loss/len(loader):.4f}')参数说明:Adam的lr=1e-4是着色任务的常用起点,太大颜色会震荡,太小收敛慢。betas=(0.5, 0.999)是GAN训练中的经典设置,纯回归任务用默认(0.9, 0.999)也行。训练50轮是起步,实际到100轮以上颜色才比较自然。每轮打印平均损失,如果损失卡在0.05左右不降,说明模型容量或数据量到瓶颈了。
3.3 推理:把预测的ab拼回彩色图
def colorize(model, img_path, device): model.eval() img = Image.open(img_path).convert('RGB') img = img.resize((256, 256)) arr = np.array(img).astype(np.float32) / 255.0 from skimage.color import rgb2lab, lab2rgb lab = rgb2lab(arr) L = lab[:, :, 0] / 50.0 - 1.0 L_tensor = torch.from_numpy(L).unsqueeze(0).unsqueeze(0).float().to(device) with torch.no_grad(): pred_ab = model(L_tensor).cpu().numpy()[0] # [2,H,W] # 反归一化 pred_ab = pred_ab.transpose(1, 2, 0) * 128.0 lab_out = np.zeros((256, 256, 3)) lab_out[:, :, 0] = (L + 1.0) * 50.0 lab_out[:, :, 1:] = pred_ab rgb_out = lab2rgb(lab_out) return (rgb_out * 255).astype(np.uint8)逻辑说明:推理时只取L通道送入模型,得到预测的ab后反归一化,和原始L拼成完整Lab,再转回RGB。注意lab2rgb输出是[0,1]浮点,乘255转成uint8才能保存。
参数说明:推理分辨率必须和训练一致,否则跳跃连接的尺寸对不上。如果要用不同尺寸,需要把网络改成全卷积结构并处理padding。
4. 避坑与排查:着色模型翻车的五个血泪现场
4.1 输出全是灰色,颜色去哪了
现象:训练几十轮后,推理结果几乎还是灰度图,只有极淡的色偏。
原因:最常见的是损失函数用错。如果用MSE且学习率偏大,模型很快学会“预测ab接近0”这个安全策略,因为灰色在所有样本上平均损失最低。另一个原因是ab归一化范围不对,导致梯度消失。
解决:换L1损失,检查ab是否真的落在[-1,1]。如果还不行,在损失里给ab通道加权,比如loss = 1.0*L1(pred, target) + 0.5*L1(pred[:,0], target[:,0]),强制模型关注色度。
4.2 颜色溢出到不该有的区域
现象:天空的蓝色渗到建筑上,人脸的颜色糊到背景。
原因:编码器降采样太狠,空间信息丢失严重。或者跳跃连接没加,解码器只能靠低分辨率特征猜颜色。
解决:确认U-Net的skip connection正确拼接。如果还溢出,减少下采样次数,比如从4次降到3次。另一个技巧是在损失里加入感知损失,用预训练VGG提取特征做比较,能显著改善边缘。
4.3 训练损失降了但视觉效果没变好
现象:Loss从0.1降到0.03,但生成的彩色图看起来还是脏脏的。
原因:L1/L2损失优化的是像素平均误差,和人类视觉感知不一致。模型可能学会了“平均颜色”,但缺乏饱和度和对比度。
解决:引入对抗损失,加一个判别器判断生成图是真是假。这就是着色GAN的思路。判别器用PatchGAN,输出N×N的patch真假概率。对抗损失让颜色更鲜艳、更真实。代价是训练不稳定,需要调GAN的权重。
4.4 显存爆炸,batch size只能设1
现象:训练时OOM,只能把batch size降到1,训练极慢。
原因:U-Net在256×256分辨率下,第一层64通道的特征图占大量显存。加上跳跃连接保留所有中间特征,显存占用是普通CNN的好几倍。
解决:用混合精度训练(AMP),显存直接减半。代码里加torch.cuda.amp即可。另一个办法是把训练分辨率降到128,或者用梯度累积模拟大batch。
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() with autocast(): pred = model(L) loss = criterion(pred, ab) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()4.5 推理速度慢,一张图要好几秒
现象:模型在GPU上推理一张256×256的图要2-3秒,批量处理老照片时等不起。
原因:U-Net的转置卷积和跳跃连接拼接在推理时也有大量计算。如果没开torch.no_grad(),还会额外占用显存和计算。
解决:推理时务必加with torch.no_grad():。导出ONNX或TorchScript做图优化。如果还慢,把模型通道数减半,或者用MobileNet作为编码器骨干。
5. 进阶技巧:让着色结果从“能看”到“好看”
跑通基础版本后,你大概会发现:颜色是有了,但总差一口气。天空不够蓝,草地不够绿,人脸偏黄。这不是模型不够大,而是损失函数和训练策略的问题。我自己的经验是,从L1升级到L1+对抗损失+感知损失,视觉质量会有质的飞跃。
具体做法:保留U-Net作为生成器,加一个PatchGAN判别器。判别器输入是ab通道(或Lab三通道),输出是N×N的真假概率图。生成器损失 = L1损失 + 0.1×对抗损失 + 0.01×感知损失。感知损失用预训练VGG16的relu3_3层特征做比较。这个组合在多个公开数据集上都被验证有效。
另一个技巧是训练数据增强。着色模型对数据量很敏感,ImageNet的128万张图是底线。如果只有几千张照片,必须做增强:随机裁剪、水平翻转、颜色抖动(对输入L做轻微亮度扰动)。注意不要对ab做颜色抖动,那会破坏标签。
还有一个容易被忽略的点:推理时的后处理。模型输出的ab可能有噪声,可以用双边滤波或导向滤波平滑一下,边缘保持的同时去掉色斑。如果做视频着色,还要在时间维度上做一致性约束,否则相邻帧颜色会跳。
最后说一个我踩过的坑:不要用太小的数据集硬训。我曾经用500张风景照训了200轮,结果模型把所有天空都涂成紫色。后来换成ImageNet子集(5万张),同样的网络结构,颜色立刻正常了。着色任务对数据多样性的要求比分类任务还高,因为模型要学的不是“这是什么”,而是“这应该是什么颜色”——后者需要见过足够多的场景组合。
希望帮到你。
本文还有配套的精品资源,点击获取