简介面向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_channels1, output_channels2, base_channels64): super().__init__() # 下采样路径 self.down1 nn.Sequential( nn.Conv2d(input_channels, base_channels, 3, 2, 1), nn.ReLU(inplaceTrue) ) self.down2 nn.Sequential( nn.Conv2d(base_channels, base_channels * 2, 3, 2, 1), nn.ReLU(inplaceTrue) ) self.down3 nn.Sequential( nn.Conv2d(base_channels * 2, base_channels * 4, 3, 2, 1), nn.ReLU(inplaceTrue) ) # 中间残差块 self.res nn.Sequential( nn.Conv2d(base_channels * 4, base_channels * 4, 3, 1, 1), nn.ReLU(inplaceTrue), 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(inplaceTrue) ) self.up2 nn.Sequential( nn.ConvTranspose2d(base_channels * 2, base_channels, 4, 2, 1), nn.ReLU(inplaceTrue) ) 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_channels64 控制模型宽度显存不够就调成 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 float0 到 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, size256, augTrue): 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)), modeedge) 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, size256, augTrue) 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_size16, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_ds, batch_size8, shuffleFalse, num_workers2, pin_memoryTrue)batch_size16 在单卡 12GB 显存左右是安全值如果输入分辨率改成 128batch 可以提到 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最大值接近 1ab 的范围也在 [-1, 1] 附近说明转换基本正常。如果 ab.std() 低于 0.1你要检查是不是在加载灰度图时就直接转 Lab导致 ab 几乎为 0。4. 训练与调参从灰度图上色到人眼满意的 8 个关键参数数据管道没问题后训练阶段的参数决定了输出是“灰蒙蒙”还是“像老彩色照片”。我整理了 8 个关键参数按优先级排列输入分辨率、批大小、学习率、优化器、损失权重、判别器更新频率、验证频率、饱和度增益。下面逐个说明。4.1 学习率、批大小和图像分辨率先定规格再调模型参数设置可以参考这张表参数推荐值调整原则输入分辨率256x256想提细节调到 384但显存翻倍批大小16显存不够减半效果差先查数据而非堆 batch学习率1e-4Adam 用 1e-4 起步RMSprop 用 2e-4优化器Adamweight_decay 设 0上色任务不需要强正则损失权重L11.0, 感知0.1, GAN0.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_weight1.0, gan_weight0.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.1GAN 项 0.05是我常用的起点。如果输出太灰把 GAN 权重从 0.05 提到 0.1 或 0.2如果出现彩色斑块就把 GAN 权重降回去。感知损失不一定非要复杂网络用一个简单的边缘梯度差也能起到稳定结构的作用。4.3 用验证集做“假上色检查”只看 PSNR 会翻车训练循环里除了记录 loss还要记录验证集上 ab 通道的标准差。如果这个标准差长期低于 0.2说明模型在输出灰色。下面是一个最小训练循环框架。optimizer torch.optim.Adam(model.parameters(), lr1e-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(fepoch {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, d5, sigmaColor20, sigmaSpace20)注意 d 不要太大否则细节会丢。这个操作能明显减轻人物轮廓边的彩色光晕。5.3 现象训练 loss 一直在降但输出全是棕褐色原因训练集里棕色系图片偏多模型学会了“把一切变棕”或者 ab 通道归一化写错把 a、b 压缩到了非常小的范围导致无论怎么训练都接近 0。解决先检查验证集预测的 ab 分布。如果 a 通道的均值接近 0b 通道也接近 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, devicecuda): 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 权重或增加训练步数。如果三次结果稳定但颜色明显偏色优先检查训练集分布而不是盲目调模型。颜色生成这件事机器给不了绝对答案但稳定性和可解释性是可以量化的。我自己每次跑完都留一张“灰度原图 三次上色结果”的对比拼图放在训练日志旁边。看着简单却比任何指标都更能提前暴露问题。希望帮到你。本文还有配套的精品资源点击获取