简介:面向Python开发者和图像处理爱好者,这份压缩包提供了一套基于深度学习的黑白图片上色实现方案,核心围绕卷积神经网络完成从数据准备、模型训练到色彩预测的完整流程,适合作为图像色彩化项目的入门参考。包体压缩后仅8KB,共9个文件,其中6个Python脚本构成主体,分别覆盖数据预处理、图像裁剪、网络结构定义、训练与评估等环节;另有shell脚本可辅助视频抽帧,Markdown文档提供使用说明。已有2726人学习下载,适合希望快速上手图像色彩化项目的中级开发者。通过这一方案可了解CNN模型在图像上色中的完整落地思路:先准备彩色训练集并转为灰度图,再设计并训练模型,最后对输入黑白图像进行颜色预测。脚本保留修改空间,可自行调整数据集或网络参数,应用于历史照片复原、艺术创作等场景。
1. 黑白图上色不是“滤镜调色”:Python-ImageColor 要解决的真实问题
我最早接触黑白图上色,是在一批历史影像修复需求里。手动给一张带人物的老照片上色,至少需要半天,肤色、衣服材质、环境光全靠猜。后来我用深度学习方法做自动上色,用 Python 把这套流程串成了一个可复用的方案,取了个代号叫 Python-ImageColor。它的核心不是把黑白图“刷”成彩色,而是在照片缺失真实颜色信息的前提下,模型要生成一个合理、稳定、能交付的彩色结果。适合需要批量处理老照片、灰度监控图、历史影像的开发者。下面这套方法从模型选型、数据准备、训练调参到推理验证逐步展开,并单独把最容易翻车的几个坑拎出来讲。顺着做,两三天能跑通第一版。
2. 上色模型怎么选:先搞懂色彩空间、损失函数和网络结构
很多人上来就搭很深的网络,忽略了一个前置问题:上色任务的学习目标是什么。如果直接在 RGB 空间做回归,R、G、B 三个通道高度相关,模型很容易输出一张“整体平均色”的灰蒙蒙图片。所以大部分上色方案都会先把图像转换到 Lab 色彩空间,把亮度 L 和颜色 a、b 分开。下面先解释为什么 Lab 是首选,再给出一个可运行的最小网络。
2.1 为什么不能直接在 RGB 上回归:Lab 色彩空间的三个理由
第一个理由:RGB 通道相关性太高。自然图像里相邻像素的 R、G、B 往往一起变化,模型很难判断哪个通道真正携带颜色信息,结果就是把三通道都推向中间值。第二个理由:Lab 更接近感知均匀,L 通道代表明度,a 和 b 近似人类视觉里的红绿/黄蓝对立通道,这让“给黑白图上色”变成一个条件生成问题:亮度结构已经给定,模型只需要预测两个颜色通道。第三个理由:把 RGB 转成 Lab 后,网络不必同时预测明度和结构,只负责生成 a、b,学习难度显著下降。
实际操作中,用视觉库里的转换函数就可以。我一般用 scikit-image 的 rgb2lab,因为它能直接处理 float 数据,输出范围稳定。需要提醒的是,Lab 的 L 通道不是灰度图直接归一化得到的,它本身是“从 RGB 计算出来的感知亮度值”。因此训练和推理时,原始图像必须走同一条转换链路,否则输入分布一旦偏移,输出颜色会整体漂移。
2.2 从 U-Net 到生成对抗:不同上色方案的选型对比
上色是一个“一对多映射”问题:一件黑西装,可以是纯黑,也可以在高光处带一点蓝灰;一件衣服,红色和蓝色都是合理答案。回归模型倾向于输出所有可能颜色的平均值,结果往往是灰色。这也是为什么早期用纯 L2 损失的上色效果很差。
常见做法有三类。U-Net 回归模型:速度快、训练稳定,但颜色饱和度低,适合做基线。生成对抗网络(GAN):判别器迫使输出更“像真实彩色照片”,颜色鲜艳,但训练不稳定,而且容易出彩色斑块。扩散模型:质量最高,但推理慢、显存占用大,不适合快速批量处理。我自己的选型判断是:如果只是老照片修复,U-Net 加一点感知损失就够了;如果要让画面看起来更像数码彩照,再把 GAN 加进来。所以 Python-ImageColor 里主干固定为 U-Net,判别器做成可插拔模块,想在提升饱和度时再开启。扩散模型我留给后续版本,等推理速度优化好再说。
下面是三类方案的直观对比。
| 方案 | 颜色饱和度 | 训练稳定性 | 推理速度 | 落地难度 |
|---|---|---|---|---|
| U-Net 回归 | 低 | 高 | 快 | 低 |
| GAN | 高 | 中 | 中 | 中 |
| 扩散模型 | 高 | 低 | 慢 | 高 |
2.3 最小可行模型:自己训练一个轻量上色网络的结构设计
直接给一个能在单卡上跑起来的小网络。输入是 1 通道的 L,经过三层下采样进入瓶颈,再对称上采样到 2 通道的 a、b,中间用跳跃连接保留边缘。用跳跃连接的原因是:上采样过程容易丢失细小纹理,而老照片修复恰恰最看重人物轮廓和衣服边缘。
import torch import torch.nn as nn class ColorNet(nn.Module): def __init__(self, input_channels=1, output_channels=2, base_channels=64): super().__init__() # 下采样路径 self.down1 = nn.Sequential( nn.Conv2d(input_channels, base_channels, 3, 2, 1), nn.ReLU(inplace=True) ) self.down2 = nn.Sequential( nn.Conv2d(base_channels, base_channels * 2, 3, 2, 1), nn.ReLU(inplace=True) ) self.down3 = nn.Sequential( nn.Conv2d(base_channels * 2, base_channels * 4, 3, 2, 1), nn.ReLU(inplace=True) ) # 中间残差块 self.res = nn.Sequential( nn.Conv2d(base_channels * 4, base_channels * 4, 3, 1, 1), nn.ReLU(inplace=True), nn.Conv2d(base_channels * 4, base_channels * 4, 3, 1, 1) ) # 上采样路径 self.up1 = nn.Sequential( nn.ConvTranspose2d(base_channels * 4, base_channels * 2, 4, 2, 1), nn.ReLU(inplace=True) ) self.up2 = nn.Sequential( nn.ConvTranspose2d(base_channels * 2, base_channels, 4, 2, 1), nn.ReLU(inplace=True) ) self.up3 = nn.ConvTranspose2d(base_channels, output_channels, 4, 2, 1) def forward(self, x): d1 = self.down1(x) d2 = self.down2(d1) d3 = self.down3(d2) r = self.res(d3) + d3 u1 = self.up1(r) u2 = self.up2(u1) out = torch.tanh(self.up3(u2)) return out这个网络输入是[B, 1, H, W],输出[B, 2, H, W]的 ab 通道。关键参数:base_channels=64 控制模型宽度,显存不够就调成 32。下采样步长全部用 2,所以输入尺寸必须能被 8 整除。输出用 tanh 把 ab 限制在 [-1, 1],对应训练时的归一化范围。残差块里先卷积后加回输入,能让梯度在深层更稳定。推理时不需要判别器,主干承担全部计算量。
搭建完模型后,我会这样快速检查前向是否正常:
model = ColorNet() x = torch.randn(1, 1, 256, 256) out = model(x) print(out.shape) # 期望 torch.Size([1, 2, 256, 256])如果输出形状不对,多半是输入尺寸不能被 8 整除,或者卷积参数写错。第一版网络足够跑通流程,别一上来就堆 ResNet101,先跑通再谈效果。
3. 搭好数据管道:灰度化、图像对生成与增强的完整脚本
模型结构定下来以后,最影响结果的是数据管道。上色任务和分类不一样,它需要“彩色原图作为监督信号”,而不是手工标注。常见做法是拿一批彩色照片,在加载时转成 Lab,把 L 通道当输入,ab 通道当预测目标。这样每一张彩色图都能自然生成一对训练样本。
3.1 用彩色图像集生成 L 通道和 ab 通道的配对样本
具体转换方式:读一张彩色图,转成 RGB float(0 到 1),用 rgb2lab 得到 Lab 数组,然后切出 L 和 ab。不需要额外标注,这一点让上色任务的数据集非常容易获得。我第一版用的是某个开源场景理解数据集的子集,大概五千张图,覆盖室内、户外、人物、建筑,效果就够用。如果你手边有大量老照片,反而不能直接当训练数据——老照片本身就是黑白,没有真实颜色作为监督。训练数据必须用现代彩色图,让模型学习“彩色自然照片的分布”。
这里有一个新手常犯的错误:把彩色图先转成灰度,再把灰度当输入、彩色当输出来训练。在普通图像恢复任务里这样没问题,但上色任务里灰度化方式需要和模型输入的 L 通道对齐。如果你用 cv2 转灰度,再把灰度图复制三通道转 Lab,你会发现 a、b 通道几乎全是 0,因为灰度 RGB 根本不含颜色信息。正确做法是直接对彩色原图做 rgb2lab,把得到的 L 当输入,a、b 当目标,而不是先人为灰度化。
3.2 数据增强别乱来:翻转、裁剪和色彩噪声的边界
上色模型对翻转很敏感。水平翻转通常可以用,因为左右镜像不改变自然分布;垂直翻转会把“天空在画面中上方”这类先验破坏,模型会把地面当成天空去上色。所以我的增强里只用水平翻转和随机裁剪,不做垂直翻转。
还有一个容易翻车的点:不要在 ab 通道上做色彩抖动。很多图像任务会用随机亮度、对比度、饱和度增强,但上色任务的标签就是颜色信息,任何对 ab 通道的随机扰动,都等于给监督信号加噪声。正确做法是把亮度、对比度抖动只作用在 L 通道上,ab 通道保持原值。我在第一版里干脆没做色彩抖动,只做随机裁剪和水平翻转,因为上色模型的泛化主要靠多样本,而不是靠增强。
3.3 第一个可运行的训练入口:dataset.py 与训练参数设置
下面是一个完整的 Dataset 实现,已经包含裁剪、翻转和 Lab 分离。
import torch from torch.utils.data import Dataset import numpy as np from skimage import color, io class GrayColorDataset(Dataset): def __init__(self, image_paths, size=256, aug=True): self.image_paths = image_paths self.size = size self.aug = aug def __len__(self): return len(self.image_paths) def __getitem__(self, idx): rgb = io.imread(self.image_paths[idx]) # 灰度图复制成三通道,避免维度报错 if rgb.ndim == 2: rgb = np.stack([rgb] * 3, axis=-1) rgb = rgb.astype(np.float32) / 255.0 # 随机裁剪到 size x size h, w = rgb.shape[:2] if h < self.size or w < self.size: rgb = np.pad(rgb, ((0, max(0, self.size - h)), (0, max(0, self.size - w)), (0, 0)), mode='edge') h, w = rgb.shape[:2] y = np.random.randint(0, h - self.size + 1) x = np.random.randint(0, w - self.size + 1) rgb_crop = rgb[y:y + self.size, x:x + self.size] # 水平翻转增强 if self.aug and np.random.rand() > 0.5: rgb_crop = rgb_crop[:, ::-1, :] # RGB 转 Lab lab = color.rgb2lab(rgb_crop) L = lab[:, :, 0] # 0~100 ab = lab[:, :, 1:] # 约 -127~127 # 归一化到约 [-1, 1],与模型 tanh 输出对齐 L_norm = (L / 50.0 - 1.0).astype(np.float32) ab_norm = (ab / 128.0).astype(np.float32) L_t = torch.from_numpy(L_norm).unsqueeze(0) # [1, H, W] ab_t = torch.from_numpy(ab_norm).transpose(2, 0, 1) # [2, H, W] return L_t, ab_t代码里几个关键点。第一,io.imread 对灰度图会返回二维数组,这里先堆叠成三通道,保证后续转换不报错。第二,随机裁剪前如果图太小,用边缘补齐到目标尺寸,避免越界。第三,L 和 ab 分别归一化到 [-1, 1],因为模型输出用了 tanh。第四,transpose 把 HxWx2 变成 2xHxW,符合 PyTorch 的 CHW 约定。
数据加载时,我一般这么配:
from torch.utils.data import DataLoader, random_split dataset = GrayColorDataset(image_paths, size=256, aug=True) train_len = int(len(dataset) * 0.9) val_len = len(dataset) - train_len train_ds, val_ds = random_split(dataset, [train_len, val_len]) train_loader = DataLoader(train_ds, batch_size=16, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_ds, batch_size=8, shuffle=False, num_workers=2, pin_memory=True)batch_size=16 在单卡 12GB 显存左右是安全值;如果输入分辨率改成 128,batch 可以提到 32。num_workers 根据 CPU 核心数调,一般 4 到 8 都可以。pin_memory 在 GPU 训练时能减少数据搬移时间。验证集不需要 shuffle,这样每次输出顺序一致,方便对比。
训练前,我通常会跑一次下面的检查,确认数据链路没有问题:
for i, (L, ab) in enumerate(train_loader): print(L.min(), L.max(), ab.min(), ab.max()) print(ab.std()) break如果 L 的最小值接近 -1,最大值接近 1,ab 的范围也在 [-1, 1] 附近,说明转换基本正常。如果 ab.std() 低于 0.1,你要检查是不是在加载灰度图时就直接转 Lab,导致 ab 几乎为 0。
4. 训练与调参:从灰度图上色到人眼满意的 8 个关键参数
数据管道没问题后,训练阶段的参数决定了输出是“灰蒙蒙”还是“像老彩色照片”。我整理了 8 个关键参数,按优先级排列:输入分辨率、批大小、学习率、优化器、损失权重、判别器更新频率、验证频率、饱和度增益。下面逐个说明。
4.1 学习率、批大小和图像分辨率:先定规格再调模型
参数设置可以参考这张表:
| 参数 | 推荐值 | 调整原则 |
|---|---|---|
| 输入分辨率 | 256x256 | 想提细节调到 384,但显存翻倍 |
| 批大小 | 16 | 显存不够减半;效果差先查数据而非堆 batch |
| 学习率 | 1e-4 | Adam 用 1e-4 起步,RMSprop 用 2e-4 |
| 优化器 | Adam | weight_decay 设 0,上色任务不需要强正则 |
| 损失权重 | L1=1.0, 感知=0.1, GAN=0.05 | 饱和度过低就加 GAN 权重 |
| 判别器更新频率 | 每 2 步生成器更新 1 次判别器 | 防止判别器太强,把生成器梯度压没 |
| 验证频率 | 每 5 个 epoch | 每次存样本图,别只看 loss |
| 饱和度增益 | 1.2 | 推理时乘在 ab 通道上,但要保持边缘稳定 |
学习率过大会导致颜色振荡,过小则收敛很慢。批大小主要被显存限制,一般取 4 到 32。分辨率从 256 开始,不建议一上来就用 512,训练时间剧增且收益有限。第一版的目标是跑通整个链路,而不是刷最高精度。
4.2 损失权重怎么设:L1、感知损失和 GAN 损失的组合
纯 L1 损失意味着模型对每个像素预测中位数,在“一对多”的颜色任务里,中位就变成了灰色。所以需要辅助损失。常见组合是用 L1 保色彩距离,用感知损失保结构,用 GAN 损失提鲜艳度。
import torch.nn.functional as F def color_loss(pred_ab, target_ab, l1_weight=1.0, gan_weight=0.05): l1_loss = F.l1_loss(pred_ab, target_ab) # 感知损失简化成梯度差,实际可以用预训练分类网络的特征 grad_loss = torch.abs(pred_ab - target_ab).mean() # gan_loss 由判别器给出,这里省略细节 return l1_weight * l1_loss + 0.1 * grad_loss + gan_weight * gan_lossL1 权重设为 1.0,感知项 0.1,GAN 项 0.05,是我常用的起点。如果输出太灰,把 GAN 权重从 0.05 提到 0.1 或 0.2;如果出现彩色斑块,就把 GAN 权重降回去。感知损失不一定非要复杂网络,用一个简单的边缘梯度差也能起到稳定结构的作用。
4.3 用验证集做“假上色检查”:只看 PSNR 会翻车
训练循环里,除了记录 loss,还要记录验证集上 ab 通道的标准差。如果这个标准差长期低于 0.2,说明模型在输出灰色。下面是一个最小训练循环框架。
optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) criterion = nn.L1Loss() for epoch in range(30): model.train() total_loss = 0 for L, ab in train_loader: L, ab = L.to(device), ab.to(device) pred = model(L) loss = criterion(pred, ab) + 0.1 * gradient_penalty(pred, ab) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() if epoch % 5 == 0: model.eval() with torch.no_grad(): std_sum = 0 for L_val, _ in val_loader: pred_val = model(L_val.to(device)) std_sum += pred_val.std(dim=(2, 3)).mean().item() avg_std = std_sum / len(val_loader) print(f"epoch {epoch}: loss={total_loss:.4f}, ab_std={avg_std:.4f}") # 保存几张验证图供人工检查 save_sample_images(model, val_loader, epoch)代码里 gradient_penalty 是示意函数,表示对 ab 通道求梯度以保持平滑。重点在于 ab_std 指标,它比 PSNR 更能反映上色的丰富度。我见过某开发者在一次模拟项目中,loss 降得很低,但所有输出都是褐色的,就是因为没有看这个指标。正常彩色照片的 ab 通道标准差一般在 0.2 到 0.4 之间,低于 0.15 基本可以判定模型在“偷懒”输出灰色。
训练时长方面,这个小网络在 5000 张图、256 分辨率、batch 16 的情况下,30 个 epoch 大约需要 12 小时。前 10 个 epoch ab_std 会快速上升,后面趋于平缓。如果 10 个 epoch 后 ab_std 还低于 0.15,建议先停下来查数据和损失,不要盲目往下跑。
5. 避坑指南:黑白图上色最常见 5 个翻车点与排查手段
训练和推理过程中,有几个坑非常隐蔽。下面 5 条是我自己在实际项目里反复遇到过的,也出现在模拟项目X的复现过程里。每条按“现象 → 原因 → 解决”来写。
5.1 现象:整张图偏灰黄,彩色饱和度上不去
原因:L1 损失占比太高,模型学到的 ab 通道回归到均值附近;另一个常见原因是训练集里地面、墙面、泥土等低饱和图占比太大,模型被带偏了。
解决:把 L1 权重降到 0.5,增加 GAN 或感知损失;再统计训练集 ab 通道的标准差,如果本身过低,需要增加高饱和度的图片。检查方法很简单:
for _, ab in train_loader: print(ab.std().item()) break如果这个值小于 0.15,说明训练集色彩不够丰富。另外,推理时可以把输出 ab 通道乘 1.2 再转 RGB,但不能乘太多,否则边缘会发红。
5.2 现象:物体边缘出现彩色溢出和光晕
原因:U-Net 的跳跃连接让边缘信息直接传到输出层,但 ab 通道缺少光滑约束,模型在高频区域产生了颜色震荡。
解决:增加边缘平滑损失,或推理时对 ab 通道做双边滤波。一个常用的后处理是单独处理 ab 通道,而不是模糊整张 RGB 图:
ab = cv2.bilateralFilter(ab, d=5, sigmaColor=20, sigmaSpace=20)注意 d 不要太大,否则细节会丢。这个操作能明显减轻人物轮廓边的彩色光晕。
5.3 现象:训练 loss 一直在降,但输出全是棕褐色
原因:训练集里棕色系图片偏多,模型学会了“把一切变棕”;或者 ab 通道归一化写错,把 a、b 压缩到了非常小的范围,导致无论怎么训练都接近 0。
解决:先检查验证集预测的 ab 分布。如果 a 通道的均值接近 0,b 通道也接近 0,就是归一化问题;如果分布并不枯竭,则要给数据集做色彩类别重采样,比如用视觉库把图转到 HSV 空间,过滤掉棕色占比过高的图片。
5.4 现象:彩色斑块成片,像水彩没涂匀
原因:输出 ab 通道的空间连续性不够,模型没有足够的感受野去判断整体颜色;或 GAN 判别器只看整图,导致局部区域没有约束。
解决:在损失里加入全变分正则,让相邻像素的颜色变化不要太突兀。代码可以这样加:
tv = torch.mean(torch.abs(pred_ab[:, :, :-1, :] - pred_ab[:, :, 1:, :])) + \ torch.mean(torch.abs(pred_ab[:, :, :, :-1] - pred_ab[:, :, :, 1:])) loss = base_loss + 0.01 * tv这个正则项会惩罚颜色突变,能有效减少斑块。推理时还可以对 ab 通道做轻微中值滤波,但注意不要过度。
5.5 现象:推理时显存不够,只能用小图,出来模糊
原因:模型在整张大图上直接前向,特征图占用显存太大。很多人为了塞进显存,直接降低分辨率,结果细节全丢。
解决:用 patch-based 推理。把大图切成重叠块,逐块预测再融合。核心思路是:每个 patch 预测后只取中心区域,边缘区域丢弃或用权重叠加。我常用的块大小是 256,重叠 32 像素,输出拼接后再做一次轻微平滑。这样显存占用稳定,细节也保得住。
6. 把模型接到真实老照片上:上色推理管线与效果验证技巧
6.1 完整推理脚本:读灰度图、转 Lab、模型上色、后处理
当模型训练好后,真正要用到老照片上时,需要把灰度图走一遍与训练完全一致的数据链路。下面是一个可直接套用的推理脚本。
import cv2 import numpy as np import torch from skimage import color def colorize_gray(gray_path, model, device='cuda'): img = cv2.imread(gray_path, cv2.IMREAD_GRAYSCALE) # 灰度图复制成三通道,再转 Lab,保证 L 定义和训练一致 rgb = np.stack([img] * 3, axis=-1) / 255.0 lab = color.rgb2lab(rgb) L = lab[:, :, 0] # 归一化并构造 [1, 1, H, W] 输入 L_norm = (L / 50.0 - 1.0).astype(np.float32) L_t = torch.from_numpy(L_norm).unsqueeze(0).unsqueeze(0).to(device) model.eval() with torch.no_grad(): ab_norm = model(L_t)[0].cpu().numpy().transpose(1, 2, 0) ab = ab_norm * 128.0 # 饱和度增益,默认 1.2 ab *= 1.2 lab_out = np.stack([L, ab[:, :, 0], ab[:, :, 1]], axis=-1) rgb_out = (color.lab2rgb(lab_out) * 255).astype(np.uint8) return rgb_out灰度图先复制成三通道再转 Lab,是为了让 L 通道的定义和训练时完全一致。ab 乘 1.2 是给颜色“加一点劲”,具体系数可以在验证集上试。如果原图分辨率很大,建议先按 5.5 的做法切块推理,避免显存不足。
6.2 效果验证:同一张图试三次,看颜色稳定性
上色模型带有随机性,尤其是使用了 GAN 之后。我的验证习惯是:同一张灰度图连续推理三次,分别记录 ab 通道的标准差和主要色相。如果三次结果差异很大,说明模型对这张图的约束不够,需要降低 GAN 权重或增加训练步数。如果三次结果稳定但颜色明显偏色,优先检查训练集分布,而不是盲目调模型。颜色生成这件事,机器给不了绝对答案,但稳定性和可解释性是可以量化的。我自己每次跑完都留一张“灰度原图 + 三次上色结果”的对比拼图,放在训练日志旁边。看着简单,却比任何指标都更能提前暴露问题。希望帮到你。
本文还有配套的精品资源,点击获取