简介这是一个基于深度神经网络的自动图像着色Python项目包含ECCV16与SIGGRAPH17两套经典预训练模型面向图像处理与深度学习初学者及研究者解决黑白照片快速上色与实时交互引导着色问题。项目提供完整的Python源码与示例图片解压后可直接运行demo_release.py或通过colorizers模块加载模型无需额外训练即可完成Lab空间转换、256×256缩放、着色并恢复全分辨率的全流程处理适合快速上手与效果对比。资源共23个文件包括6个Python脚本、5个pyc预编译文件、8张jpg/jpeg/png示例图以及说明文档和许可文件压缩包整体仅4.47MB轻量易用。该资源已有680人学习下载既能用来直接体验经典着色效果也可作为理解深度先验和图像生成任务的参考实现尤其适合课程设计或论文复现时进行对照学习。1. 深度神经网络自动着色到底能帮我们给什么样的图填上颜色打开一张上世纪的老照片画面里的人物和风景往往只有深浅不一的灰阶。你看到的所有颜色在拍摄那一刻并没有被记录下来现在看到的“彩色老照片”多数是有人对着原片手工一层层填上去的。而“使用深度神经网络的自动着色”这件事简单说就是输入一张黑白图让模型预测每个像素应该是什么颜色直接输出一张彩色图。这个任务在学术圈更常被称为“彩色图像着色”image colorization它天然适合用 python 实现因为 python 在图像处理和深度学习两个环节都有成熟生态。做这个事的人一般有三种诉求。第一种是做老照片、老电影修复的想给修复流程加一道自动化工序第二种是给图像预处理送料的希望模型生成的彩色图能当素材第三种其实只是学深度学习想找一个“网络结构不复杂、效果又直观”的落地练习题。这篇文章我会把选型、训练、避坑和调优整个闭环拆开让你用自己的数据也能跑出一个能用的自动着色模型。2. 为什么自动着色必须用深度神经网络病态问题与三条技术路线的取舍2.1 病态问题一个灰度像素有无穷多种合法颜色如果只看单个像素这个问题根本无解。同样一个灰色值它既可以是白衬衫在阴影里的颜色也可以是灰墙本身的颜色甚至可以是红苹果在特定光照下的观感。给定单像素的亮度值颜色分布是一个极端复杂的多峰分布不存在“唯一正确答案”。所以在传统图像处理时期自动着色只能靠两种思路勉强做一是全图颜色迁移把一张参考图的整体色调映射过来效果很像“滤镜”碰到语义内容差距大的图就翻车二是靠人手工画几十笔颜色线索算法再向周围扩散典型代表是 Levin 等人那套基于优化的抠图式传播。这两种方案本质都没有“理解”图像内容。它们不知道画面里是天空、草地还是人脸也就无从根据语义去猜测颜色。深度神经网络出现后这件事才从“猜颜色”变成“认物体再猜颜色”因为卷积网络在分类任务的迁移下隐层里天然带了“这是天空”、“这是树叶”的语义特征。这个信息才是自动着色真正可用的先验。2.2 三种主流着色方案对比回归、分类、对抗把深度神经网络接到着色任务上之后业界基本上演化出三个大方向我在做选型时会把它们摆在一起看方案核心思想优点落地难点回归式直接预测每个像素的 ab 通道数值loss 用 L1 或 L2训练稳定、收敛快、代码最简单预测结果容易发灰因为模型学到了“平均色”分类式把颜色空间量化成几百个 bin当作多分类问题颜色更鲜艳、能表达多峰分布后处理要查表、反量化训练稍重对抗式引入判别器让生成颜色更“以假乱真”观感最自然纹理和边界更干净训练不稳定需要调 GAN 的平衡新手容易翻车回归式最直观把 ab 看成两个通道的回归目标网络输出两个通道的数值。分类式最有名的是 Caffe 时代那篇 Colorful Image Colorization它把 ab 空间量化成 313 个类别用加权交叉熵训练这样网络可以对“有歧义的像素”输出一个概率分布而不是强行逼出一个均值。对抗式则更接近生成任务除了像素误差还加了“这像不像一张真彩图”的约束。2.3 入门复现为什么选回归式 U-Net而不是先上 GAN我自己的判断是如果你是想跑通整个流程、先看到一个能用的结果第一条路线回归式 U-Net 是最稳的起点。原因有三个。第一依赖少只需要 PyTorch、OpenCV、NumPy不用加载 GAN 那套额外组件第二训练指标直观L1 loss 降到什么程度直接反映重建误差不像 GAN 的 loss 那么像黑匣子第三后续想升级时把预测头改成分类头就能平滑过渡网络骨架不用动。网络选 U-Net 也是经过考虑的。着色任务要同时靠“全局语义”和“局部细节”。深层特征负责告知“这是天空”浅层特征负责保留边缘和纹理U-Net 的跳跃连接正好是两个信息的搬运通道。相比之下纯 encoder-decoder 结构会把高频细节在多次下采样中丢光输出图像边缘发虚上色区域边界像糊了一层水彩。后面第三章的代码就是围绕这个判断展开的。3. 用 PyTorch 从零跑通自动着色U-Net 最小代码闭环3.1 数据准备把 RGB 图拆成 L 通道和 ab 通道着色任务里最常用的颜色空间不是 RGB而是 Lab。在 Lab 空间中L 通道只表示亮度a 和 b 两个通道共同表示颜色。这样刚好把“输入黑白图”和“输出颜色”两种信息分离网络只需要从 L 预测 ab。如果用 RGB 做这个拆分会很别扭因为三个通道都同时带着亮度和颜色信息黑白的定义也不干净。下面的代码实现了一个标准的数据集类读图用的是 OpenCV注意它读出来是 BGR 顺序。import cv2 import numpy as np import torch from torch.utils.data import Dataset class GrayColorDataset(Dataset): def __init__(self, image_paths, size128): self.image_paths image_paths self.size size def __len__(self): return len(self.image_paths) def __getitem__(self, idx): # 用 cv2 读出的图是 BGR 顺序转 LAB 时用 COLOR_BGR2LAB img cv2.imread(self.image_paths[idx]) img cv2.resize(img, (self.size, self.size)) lab cv2.cvtColor(img, cv2.COLOR_BGR2LAB).astype(np.float32) L lab[:, :, 0] / 255.0 # L 范围 0~255缩放到 0~1 ab lab[:, :, 1:] - 128.0 # ab 大致范围 -128~127 ab ab / 128.0 # 缩放到 -1~1方便网络回归 L torch.from_numpy(L).unsqueeze(0) # 加通道维变成 [1,H,W] ab ab.transpose(2, 0, 1) # HWC 转 CHW ab torch.from_numpy(np.ascontiguousarray(ab)) return L, ab这个数据类返回的是两个 tensor一个 1 通道的亮度图一个 2 通道的颜色图。这里有三个参数值得注意。size128 是在压缩训练成本实际推理时可以用更大尺寸L 通道除以 255 是把它归一化到 0~1目的是让网络输入的数值范围和卷积层初始化匹配ab 通道除以 128 则是为了把颜色值压到 -1~1 之间。这里最容易踩的坑是忘了 ascontiguousarraytranspose 之后内存布局不连续后面转 tensor 或送进卷积层时可能出现奇怪的内存报错用 flake8 都查不出来属于典型的运行时玄学。3.2 网络结构一个带跳跃连接的 128×128 着色 U-Net下面的网络结构我刻意写得扁平没有引入复杂的注意力或归一化层因为第一步要把流程跑通模型设计越直接越容易排查问题。整体布局就是三层下采样、三层上采样每一层都配两次卷积上采样时把对应层的编码器输出拼接进来。import torch.nn as nn import torch.nn.functional as F class ColorNet(nn.Module): def __init__(self): super().__init__() self.enc1 self._block(1, 64) self.enc2 self._block(64, 128) self.enc3 self._block(128, 256) self.dec1 self._block(256 128, 128) self.dec2 self._block(128 64, 64) self.dec3 self._block(64 1, 32) self.head nn.Conv2d(32, 2, 1) def _block(self, in_c, out_c): return nn.Sequential( nn.Conv2d(in_c, out_c, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(out_c, out_c, 3, padding1), nn.ReLU(inplaceTrue), ) def forward(self, x): e1 self.enc1(x) p1 F.max_pool2d(e1, 2) e2 self.enc2(p1) p2 F.max_pool2d(e2, 2) e3 self.enc3(p2) d1 F.interpolate(e3, scale_factor2, modebilinear, align_cornersFalse) d1 self.dec1(torch.cat([d1, e2], dim1)) d2 F.interpolate(d1, scale_factor2, modebilinear, align_cornersFalse) d2 self.dec2(torch.cat([d2, e1], dim1)) d3 F.interpolate(d2, scale_factor2, modebilinear, align_cornersFalse) d3 self.dec3(torch.cat([d3, x], dim1)) return self.head(d3)网络输出是 [B, 2, H, W]两个通道分别对应归一化后的 a 和 b。dec3 那层把编码器的原始输入 x 也拼接进来相当于给最终上色阶段补了一份最完整的边缘信息这个细节能让输出轮廓干净不少。_block 里每层都用了 3×3 卷积加 padding1这样特征图尺寸不会因为卷积而缩小尺寸变化只发生在上采样和下采样时刻便于推算每一层的张量维度128 输入三次池化后到 16再三次上采样回到 128skip connection 的尺寸在每一步都对齐。3.3 训练循环L1 损失与 Adam 的参数选择训练循环本身不复杂但有两个选择直接影响效果。第一个是损失函数我选 L1 而不是 L2。L2 对离群值惩罚过重而颜色预测里那些“难以确定的像素”会产生很大的梯度导致模型更多地去迁就模糊区域整体颜色发灰。L1 对这类噪声更鲁棒。第二个是优化器用 Adam 配 2e-4 的初始学习率这个组合在绝大多数生成式任务里都算安全区间。import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model ColorNet().to(device) optimizer optim.Adam(model.parameters(), lr2e-4) def train_one_epoch(model, dataloader, optimizer, device): model.train() total_loss 0.0 for L, ab in dataloader: L, ab L.to(device), ab.to(device) pred model(L) loss F.l1_loss(pred, ab) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(dataloader)学习率 2e-4 是一个“不求最快但求不炸”的起始值。如果你的显存只允许 batch 开到 8 甚至 4可以顺手把学习率降到 1e-4否则小 batch 搭配大学习率容易出现 loss 震荡。模型没有任何正则化手段所以 Normalization 层我刻意没加着色任务不像分类任务那么依赖 BN 的分布校准少一层归一化就少一层推理时 batch size 变化带来的影响踩坑时更容易定位问题。3.4 推理回写从预测 ab 拼回 BGR 彩色图训练完成后推理端要做的就是把预测的 ab 和原始 L 拼回去再转换到 BGR 保存。这一步最容易被忽略的是输入格式。很多人训练时把 RGB 图转 LAB推理时直接把灰度图丢给 cv2.cvtColor但灰度图要先补成三通道再转 LAB否则 OpenCV 会默认把单通道数组当成某种奇怪的排列处理输出颜色完全错乱。def colorize(model, gray_bgr, size128, devicecpu): gray cv2.resize(gray_bgr, (size, size)) # 单通道灰度图先扩成三通道三通道值相同转 LAB 不会引入偏色 gray_3 cv2.merge([gray, gray, gray]) lab cv2.cvtColor(gray_3, cv2.COLOR_BGR2LAB).astype(np.float32) L lab[:, :, 0] / 255.0 L_t torch.from_numpy(L).unsqueeze(0).unsqueeze(0).to(device) model.eval() with torch.no_grad(): ab_pred model(L_t)[0].cpu().numpy() # [2,H,W]范围约 -1~1 ab_pred ab_pred.transpose(1, 2, 0) * 128.0 128.0 # 反归一化 out_lab cv2.merge([lab[:, :, 0], ab_pred[:, :, 0], ab_pred[:, :, 1]]) out_bgr cv2.cvtColor(out_lab.astype(np.uint8), cv2.COLOR_LAB2BGR) return out_bgr推理时的问诊断点在于 L 通道。这里我把 lab[:, :, 0] 直接用回原图亮度而不是用模型自己生成的 L这样可以保证输入图的结构信息不丢失。好比你给了模型一张黑白图它只需要往这张图上“填色”不应该顺便改亮度。如果在你的实际项目里发现输出图的明暗和输入图不一致优先怀疑这里是不是把某个中间变量接错了这个错误非常隐蔽。4. 训练数据与效果提升从 CIFAR 验证到真实图像数据集的迁移4.1 为什么小数据集只能验证流程给不出好颜色网上不少着色 demo 直接拿 CIFAR-10 跑训练CIFAR 图只有 32×32模型训练起来很快两三个小时就能看到 loss 降下去但输出基本没法看。原因很简单32×32 的图像分辨率太低天空、草地的纹理边界都糊成一团网络学不到有效的语义先验只能学到“全局平均色调”输出就是一张偏黄的灰图。我的建议是CIFAR 只用来验证代码流程是否能跑通验证时间控制在半小时以内一旦 loss 正常下降、推理流程能输出图就该换真实图像数据。真实图像也不一定要整个 ImageNet随便找一个照片文件夹里面放几百张风景、人物、街景混合图就比 CIFAR 强非常多。着色任务对数据量其实没那么苛刻关键在多样性只要图里有天空、植被、皮肤、建筑这些常见语义区域一个小几千张的数据集足够训练一个“演示级”模型。4.2 数据集怎么准备普通图片文件夹就能训练不需要特意下载标注数据着色任务的自监督特性决定了原始图片本身就是标注。你只需要把一堆彩色 jpg 放进一个文件夹然后让脚本扫描路径即可。下面这段代码就是把上一章的数据集类接上真实文件夹import os from torch.utils.data import DataLoader def collect_images(root_dir, exts(.jpg, .jpeg, .png)): paths [] for dirpath, _, filenames in os.walk(root_dir): for f in filenames: if f.lower().endswith(exts): paths.append(os.path.join(dirpath, f)) return paths paths collect_images(./my_images) dataset GrayColorDataset(paths, size128) dataloader DataLoader(dataset, batch_size32, shuffleTrue, num_workers4)文件后缀过滤那里建议只保留这三类常见格式BMP、TIFF 这类格式不是不能读而是有的图片色彩空间比较特殊转换 LAB 时会出现异常值排查起来不值当。DataLoader 的 num_workers 在 Linux 下可以设成 4 或者 8Windows 下建议保持默认值 0否则多进程数据加载经常因为 python 的 multiprocessing 机制报错这是最常见的一处环境差异。4.3 三个必调参数损失权重、分辨率、饱和度增强模型训练稳定之后决定最终效果好坏的就剩三个参数。损失权重是第一个。纯 L1 时模型会倾向于保守可选做法是 L1 加 10% 的感知损失perceptual loss感知损失可以用 torchvision 里预训练好的 VGG16 提取特征计算特征图的 L1。加了感知损失后模型会更在意颜色块之间的边界是否自然有点像“不再只盯像素差异还盯观感差异”但代价是训练时间涨 30% 左右显存占用也变大。如果机器紧张可以先跳过后面用别的办法找补。分辨率是第二个。训练时 128×128 是最稳妥的性价比点低于 96 会让草地、树叶这类高频纹理丢失细节高于 256 则显存噌噌往上涨。由于网络是全卷积结构训完 128 后可以直接推理更大尺寸的图但注意感受野是固定的原图超过 600×600 时远处的大块颜色区域会缺少上下文颜色容易出现区域性偏差。饱和度增强是第三个也是最容易出效果的一个。推理时把模型输出的 ab 坐标离中心点拉远一点颜色就会更浓郁saturation 1.6 ab_pred (ab_pred - 128.0) * saturation 128.0saturation 取 1.5~2.0 比较合适再大就会出现过饱和人脸肤色会变成夸张的橘红色。这个参数本质是制造“模型不确定时偏鲜艳”的倾向代价是可能牺牲一点准确度但人类肉眼对鲜艳图像的容忍度远高于对灰图的容忍度所以实践中这个 trick 几乎是必加的。5. 自动着色避坑指南5 个高发问题与排查清单5.1 训练 Loss 挺低输出却是灰色“水泥图”现象训练了十几个 epochloss 稳步下降验证集的数字也很漂亮但推理出来的图像灰蒙蒙的饱和度极低像给整张图蒙了一层雾。原因这是回归式着色最常见的失败模式。L1 loss 的均值优化特性让模型在遇到“多种合法颜色”的像素时学会了输出所有可能颜色的平均值多个颜色平均之后饱和度自然趋近于零。损失函数没有惩罚这种“保守策略”。解决先给推理加饱和度增强把 saturation 调到 2.0 左右如果颜色能拉出来说明模型学到了相对正确的色相只是方差太小这属于理论层面的正常现象拉不出来说明训练数据多样性不足需要补充包含强烈色彩对比的图片重新训练。5.2 颜色漂亮但整张图像蒙了一层黄色滤镜现象草地、天空、皮肤的大致色相都对但所有图都偏暖像是开了护眼模式。原因训练数据集里暖色调图片占比过高。自动着色模型对训练集颜色分布极度敏感它会偷偷记录数据集中颜色分布的先验整体偏黄时模型默认输出就会偏暖。这个现象我们项目里叫它“数据集的中心质心漂移”。解决让你收集的图片集在色调上尽量平衡冷色调的街景、海洋、阴天照片和暖色调的人像、日落、室内图按比例混合。如果数据已经训完了可以在推理后端做一个简单的通道白平衡但那是后悔药治标不治本最有效的手段是数据层面重新配比。5.3 训练正常测试时跑出一张“负片”效果的颜色现象模型训练时 loss 正常测试一张风景照天空变成了紫色草地变成了粉色颜色完全背离语义。原因颜色通道符号搞反了。Lab 空间中 a 通道的正负方向分别对应绿和红b 通道正负对应黄和蓝如果训练时把 ab 当作 [b, a] 输入或者推理回写时把两个通道的顺序弄反就会得到这种“颜色全部反相”的诡异结果。这类 bug 在训练 loss 上下不体现因为 L1 对通道顺序是定义良好的数值运算模型只是学了一个反向映射。解决在训练前先用单张图跑一次完整的前向和逆变换人工确认“输入一张红苹果图输出的 ab 里 a 值为正”再用脚本批量检查。我每次搭新项目都会先画三张纯色图验证通道语义这一步五分钟能省掉后面一整天的排查时间。5.4 GPU 显存不够batch 只能开到 4训练速度像蜗牛现象参数一调大直接 OOM 报错只能被迫用很小的 batch训练资源利用率低。原因128×128 输入看似不大但实际上网络空间维度在整个训练过程中都保持在较高水平加上 Adam 优化器要保存两套动量的状态显存开销约为纯模型参数的 2.5 倍。越到深层的特征图通道数越大显存大头都消耗在这些中间张量上。解决显存不足时优先把训练尺寸从 128 降到 96比降低 batch 更有效如果尺寸不能再降加用 torch.cuda.amp 自动混合精度训练能把显存占用砍掉将近一半而且在这个任务上精度损失几乎可以忽略。5.5 PSnr 指标很高人眼看上去却惨不忍睹现象验证阶段用 PSNR 评估模型每次都有提升但把输出图放大看人脸五官区域有诡异的偏色斑点边界像水彩晕开。原因PSNR 统计的是全图像素差异它对大面积平滑区域的错误不敏感对边缘附近的局部错误却很宽容。着色任务的真实质量评估应该更看重语义颜色是否合理而不是像素级偏差用传统重建指标做唯一衡量标准是个常见的选型错误。解决除了 PSNR加一个让人工目检的固定验证集每次训练结束挑出 8 张有代表性的测试图拼成一张大图包含天空、人像、植物、室内场景各两张。人工扫一眼比任何数字指标都可靠。等你调完 saturation 这类参数之后再去回看 PSNR会发现它可能不升反降但这恰恰说明画面变“浓艳”了不必过度纠结。6. 老照片修复场景的落地技巧降噪、拉伸与饱和度三件套如果你最终想处理的对象是真实的老照片上手还会遇到一个问题老照片不只是没颜色还有颗粒噪点和低对比度。如果直接把这些图送进网络模型会因为输入分布和训练集不一致而输出颜色不均。我一般会在推理前加一个轻量预处理流程统称“三件套”。第一步是降噪用 OpenCV 自带的快速非局部均值去噪强度设小一点只去掉颗粒保留结构。第二步是对 L 通道做 CLAHE 对比度拉伸让衣服和背景的边界更分明这个操作对着色质量的影响比想象中大因为网络判断物体边界越清晰颜色传播越准不会把颜色糊到相邻物体上。第三步才轮到饱和度增强放在推理之后做。gray cv2.fastNlMeansDenoising(gray_bgr, h5) clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) gray clahe.apply(gray)我自己的使用习惯是把这三步封装进一个函数跟 colorize 串成一条流水线输入一张黑白图输出一张彩色图。早期我在做这个方向的时候没做降噪直接着色结果老照片里的噪点全被网络当成了纹理细节生成了大片的黄色颗粒后来才明白着色模型不只对语义敏感对输入纹理同样敏感噪声和边缘在浅层特征上是很像的。后面再遇到任何输入质量一般的老照片我都会先问自己一句这张图的 L 通道是否已经干净、对比度是否够好这个习惯帮我少调了很多冤枉参数也让你在跟别人讨论自动着色的时候能一眼看出问题到底出在模型还是出在输入。希望帮到你。本文还有配套的精品资源点击获取