☰
灰度图像彩色化实战:基于PyTorch与Lab色彩空间的特征表示与工程实现
2026/10/7 12:47:34 网站建设 项目流程

简介:面向图像处理初学者的Python灰度图像彩色化实练资源,围绕“给灰度图添加真实感色彩”这一典型实验任务,提供可直接运行的源码与三页实验报告,适合课程设计、实验复习或期末大作业借鉴。压缩包内共36个文件,以py源码与pyc编译文件为核心,搭配docx实验报告、jpg/png样例图片、xml配置及必要的依赖清单,整体仅14.38MB;目录将源码、样例图片与输出结果分开存放,便于对照学习和按需查找。目前已有1373人学习下载。资源基于经典彩色化开源工程整理,演示脚本覆盖读取灰度图、调用彩色化算法、输出彩色结果的完整流程;实验报告不仅写明任务要求与实现思路,还给出了参数调整方法和效果对比,便于零基础读者理解图像特征计算与表示在彩色化任务中的实际作用,进而扩展成自己的实验课题。

1. 灰度图像彩色化:一个没有标准答案的特征计算与表示问题

把一张黑白照片变成彩色,看上去是“无中生有”,但仔细观察会发现,真正决定天空该是蓝色、草地该是绿色的,是图像里的边缘、纹理、物体形状这些局部特征。这正是“图像特征计算与表示”的核心作用:灰度图像彩色化,本质上是一个从亮度特征推断颜色特征的学习问题。它的做法是让网络把灰度图各像素点的特征算出来,再映射到 Lab 色彩空间里缺失的 a、b 通道上。这类项目非常适合有 Python 基础、想通过一个小而完整的案例把数据集处理、模型训练和报告撰写串起来的同学,源码结构不复杂,换一张自己的照片就能跑。

2. 为什么灰度图能被“算”出颜色:Lab 空间与特征表示的选型

2.1 为什么选 Lab 空间而不是 RGB/HSV

很多人第一次做彩色化,第一反应是在 RGB 空间里训练一个“灰度图 → 彩色图”的映射。这个思路表面可行,实践时却很难收敛。RGB 三个通道的相关性极高,灰度图丢失的是三个通道整体编码的色彩信息,不是某一个通道。如果你把灰度图复制三次作为 RGB 输入,等于让网络同时学习亮度、色调、饱和度三个纠缠在一起的东西,输出容易偏色、发灰,而且训练时 Loss 波动很大。

HSV 空间也不行。H(色调)通道是一个环形值,0 度和 360 度在数值上差 360,但颜色上完全一致。直接用回归 Loss 去预测这种周期性数值,模型会在色相边界上被“惩罚”错,产生紫绿相间的奇怪条纹。

工程上最稳的选择是 Lab 空间。Lab 把亮度 L 放在一个通道,把颜色信息拆成 a、b 两个通道:a 的正方向偏红、负方向偏绿,b 的正方向偏黄、负方向偏蓝。这样做最大的好处是,灰度图可以被直接当作 L 通道使用,彩色化任务就从“预测三个通道”变成了“预测两个通道”。这也是学术界和工业界做灰度图彩色化的通用做法。

实际使用中要注意 OpenCV 的读取习惯。cv2.imread 读出来是 BGR 顺序,很多人写代码时用了 cv2.COLOR_BGR2LAB,转回时却用了 cv2.COLOR_LAB2RGB,通道顺序一错,出来的图就是蓝绿互换的。下面这段是数据准备阶段最标准的转换流程:

import cv2 import numpy as np bgr = cv2.imread("example.jpg") # 注意:OpenCV 读进来是 BGR lab = cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB) # 转 Lab 也要用 BGR2LAB L = lab[:, :, 0] # 亮度通道,值域 [0, 255],这就是灰度图 a = lab[:, :, 1] # 绿-红通道,值域 [-128, 127] b = lab[:, :, 2] # 蓝-黄通道,值域 [-128, 127] # 把 a/b 归一化到 [-1, 1],方便网络输出层用 tanh 激活 ab = lab[:, :, 1:].astype(np.float32) / 128.0

这里的归一化参数要根据激活函数来定。输出层用 tanh 时,a/b 除以 128 正好落在 [-1, 1];L 通道除以 255 是常规亮度归一化。如果换了激活函数,这两个除数也要跟着调。

2.2 从特征计算到颜色表示:传统方法与深度 CNN 的边界

在深度学习流行之前,灰度图彩色化主要有两条路:基于参考图的颜色迁移,和基于手工特征的最近邻匹配。颜色迁移的做法是找一张色调满意的参考图,把目标灰度图的亮度分布和参考图对齐,再把参考图的 a/b 通道直接搬过来。这个方法速度快、效果稳定,但它要求参考图与目标图的场景内容相似,否则天空会被染成树叶的绿色。

手工特征最近邻的做法是把灰度图切块,提取每个块的方向梯度直方图、局部二值模式等特征,在带颜色的训练图库里检索最相似的块,把对应颜色贴上去。它能处理一些内容不匹配的情况,但块与块之间缺乏全局语义约束,彩色化结果常常在物体边缘出现颜色渗漏。

深度 CNN 能胜出,靠的是特征表示的分层特性。浅层卷积核学到的是梯度方向、边缘极性这些小尺度特征,中层开始组合出纹理周期,深层则可以激活出“眼睛”“轮子”“窗户”这类高级语义。彩色化正好同时需要这两种尺度:天空的蓝色来自大面积平滑区域的亮度统计,而人的肤色需要局部边缘和形状一起判断。网络把两种特征融合起来,就能在“无中生有”时给出合理猜测。

方法是否需要训练是否依赖参考图边缘颜色一致性典型应用场景
颜色迁移否是依赖人工选图单张快速出效果、老照片修复
手工特征匹配是否一般,块边缘易渗漏小规模实验、特征工程练习
深度 CNN 回归是否好,端到端学习批量彩色化、自然图像恢复

这张对比表可以作为报告里“相关工作”部分的素材。

3. 用 PyTorch 把灰度图变彩色:最小可复现的源码流程

3.1 源码骨架怎么组织:文件划分与依赖

这类彩色化项目源码通常不需要复杂工程结构,三四个文件就够。我一般这样组织:

colorization/ ├── dataset.py # 数据读取、Lab 转换、归一化 ├── model.py # 网络结构定义 ├── train.py # 训练循环、Loss、保存权重 ├── infer.py # 加载权重、彩色化单张图片 └── color_net.pth # 训练产物,Git 里一般不提交

依赖方面只需要 torch、torchvision、numpy、opencv-python。Python 版本建议 3.8 或更高,PyTorch 用 1.13 以上即可。这个组合的好处是每个文件都能单独运行验证,写报告时也方便截图展示中间结果。

小白容易犯的错是在 notebook 里把所有代码堆在一个 cell 里,改网络结构时要连数据处理一起重跑。建议从一开始就按上面这个结构拆文件,debug 的时候会省很多时间。

3.2 从图像到训练张量:读取、缩放、Lab 转换与归一化

数据准备阶段要做三件事:把图片统一缩放到固定尺寸,转 Lab 空间,把张量整理成“L 通道为输入、ab 通道为标签”的配对格式。下面是一份可以直接用的自定义 Dataset:

import cv2 import numpy as np import torch from torch.utils.data import Dataset class GrayColorDataset(Dataset): def __init__(self, img_paths, size=256): self.img_paths = img_paths self.size = size def __len__(self): return len(self.img_paths) def __getitem__(self, idx): bgr = cv2.imread(self.img_paths[idx]) # BGR bgr = cv2.resize(bgr, (self.size, self.size)) # 统一尺寸 lab = cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB) L = lab[:, :, 0].astype(np.float32) / 255.0 # 输入,值域 [0, 1] ab = lab[:, :, 1:].astype(np.float32) / 128.0 # 标签,值域约 [-1, 1] L_t = torch.from_numpy(L).unsqueeze(0) # (1, H, W) ab_t = torch.from_numpy(ab).permute(2, 0, 1) # (2, H, W) return L_t, ab_t

size 参数直接决定显存占用和感受野。256×256 是训练效果和资源消耗的平衡点,小于 128×128 时网络看不到足够的上下文,彩色化结果会很花;大于 384×384 时,VGG 级别的 encoder 在单卡上很难跑大 batch。如果你的显卡只有 6GB 显存,可以先用 128 跑通流程,再换 256 调优。

permute(2, 0, 1) 的作用是把 a/b 两个通道从最后一维移到最前面,符合 PyTorch 的 NCHW 约定。新手经常忘了这一步,训练时就会报维度不匹配的错。

3.3 网络与训练循环:关键参数和 Loss 选择

彩色化任务不需要特别复杂的网络,一个带跳连的小型 Encoder-Decoder 就够入门。我常用的结构是三层编码、两层解码,在解码时把编码器同尺寸特征拼进来,这样能缓解颜色边缘定位不准的问题:

import torch.nn as nn class ColorNet(nn.Module): def __init__(self): super().__init__() # 编码器:下采样时通道数翻倍 self.enc1 = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(32, 32, 3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2)) self.enc2 = nn.Sequential( nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(64, 64, 3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2)) self.enc3 = nn.Sequential( nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(128, 128, 3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2)) # 解码器:先上采样,再与编码器同尺寸特征拼接 self.dec1 = nn.Sequential( nn.Upsample(scale_factor=2, mode='bilinear', align_corners=False), nn.Conv2d(128 + 64, 64, 3, padding=1), nn.ReLU(inplace=True)) self.dec2 = nn.Sequential( nn.Upsample(scale_factor=2, mode='bilinear', align_corners=False), nn.Conv2d(64 + 32, 32, 3, padding=1), nn.ReLU(inplace=True)) self.out = nn.Sequential( nn.Conv2d(32, 2, 3, padding=1), nn.Tanh()) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(e1) e3 = self.enc3(e2) d1 = self.dec1(e3) d1 = torch.cat([d1, e2], dim=1) d1 = self.dec1[-1](d1) # 这里只取最后卷积+ReLU,避免重复上采样 d2 = self.dec2(d1) return self.out(d2)

这里有一个很容易绕晕的细节:dec1 里已经包含了卷积,但我在 forward 里先做 torch.cat 再用 dec1 的卷积层处理拼接后的张量,所以注释里写了“只取最后卷积+ReLU”。更清晰的写法是把上采样和卷积拆成两个层,拼接后再过卷积。上面代码的关键是跳连通道数对应:enc2 输出 64 通道,dec1 上采样后拼接变成 128 + 64 = 192 通道,下一层卷积输入的 in_channels 必须写成 192,否则会报维度错误。

训练循环用 L1 Loss 更合适。ab 通道的标签分布极不均匀,大量像素的 a/b 值接近 0(也就是灰色),如果改用 MSELoss,模型会倾向于把所有像素都预测成灰色来降低大误差项的惩罚,结果就是彩色化效果“发灰、偏淡”。下面是完整的训练循环:

from torch.utils.data import DataLoader from torch.optim import Adam import torch dataset = GrayColorDataset(img_paths, size=256) loader = DataLoader(dataset, batch_size=16, shuffle=True, num_workers=2) model = ColorNet() optimizer = Adam(model.parameters(), lr=1e-3) criterion = nn.L1Loss() for epoch in range(30): model.train() total_loss = 0.0 for L, ab in loader: pred = model(L) loss = criterion(pred, ab) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() print(f"epoch {epoch}, loss: {total_loss / len(loader):.4f}") torch.save(model.state_dict(), "color_net.pth")

batch_size=16 在 256×256 输入下已经比较吃显存了。如果你的显卡只有 6GB,建议 batch_size 减到 8,或者把 size 调成 128 跑通流程,再逐步加大。Learning rate 从 1e-3 起步,epoch 数可以按训练集大小调整,几千张图 30 个 epoch 基本够。

3.4 推理还原:Lab 转回 RGB 的完整过程

训练完成后,推理阶段最大的坑是图像格式处理顺序。网络输入必须是归一化后的 L 通道,输出是 tanh 压缩到 [-1, 1] 的 ab 通道。把 ab 乘回 128 后,要先把输入灰度图还原成 0~255 的 L 通道,再和 ab 拼成完整 Lab,最后转 BGR 保存:

import cv2 import numpy as np import torch model.load_state_dict(torch.load("color_net.pth", map_location="cpu")) model.eval() gray = cv2.imread("test.jpg", cv2.IMREAD_GRAYSCALE) gray = cv2.resize(gray, (256, 256)) L_input = gray.astype(np.float32) / 255.0 L_t = torch.from_numpy(L_input).unsqueeze(0).unsqueeze(0) with torch.no_grad(): ab = model(L_t)[0] # (2, H, W) ab_np = ab.permute(1, 2, 0).numpy() * 128.0 # 还原到 [-128, 127] 区间 # 用原灰度图做 L 通道,网络只负责生成 a/b lab = np.zeros((256, 256, 3), dtype=np.float32) lab[:, :, 0] = gray.astype(np.float32) lab[:, :, 1:] = ab_np lab[:, :, 0] = np.clip(lab[:, :, 0], 0, 255) # L 通道安全截断 lab[:, :, 1:] = np.clip(lab[:, :, 1:], -128, 127) # a/b 范围截断 bgr = cv2.cvtColor(lab.astype(np.uint8), cv2.COLOR_LAB2BGR) cv2.imwrite("colorized.jpg", bgr)

这里强调一下:L 通道必须用输入灰度图,不能用网络预测的 L。网络只训练了 ab 通道的输出,它的能力边界就在颜色预测上,强行让它输出 L 只会引入额外偏差。最终效果好坏,一部分取决于你训练数据里有没有和 test.jpg 相似的内容。数据里全是人脸,模型的风景彩色化效果就会很随机。

4. 灰度图彩色化的 5 个常见坑:现象、原因与排查方法

4.1 训练 Loss 降了,输出却灰蒙蒙一片

很多同学训练完第一个 epoch 就迫不及待去推理,发现输出的图色彩饱和度极低,整张图像蒙了一层灰。这不是训练没收敛,而是 Loss 函数与标签分布不匹配。

原因在于 ab 通道的标签分布极其不均匀。自然图像中,大面积区域都是接近灰色的低饱和像素,纯红、纯蓝这类高饱和像素占比很小。L1 Loss 会使模型通过“预测灰色”来最小化大多数像素的误差。

解决方法是给 Loss 加权重,让高饱和像素贡献更大的梯度。常见做法是按像素 ab 值距离中心的欧氏距离加权:距离越远,权重越大。实现时可以预先算一张权重图,训练时对每个像素的 Loss 乘上对应权重。如果嫌麻烦,至少要把 MSELoss 换成 L1Loss,这能让输出饱和度提升不少。

4.2 颜色边缘溢出,像水彩画

彩色化结果整体颜色是对的,但物体的轮廓周围总有一圈颜色溢出来,尤其是人物头发和背景的交界处。这个问题出在空间定位精度上。

原因是网络连续两次 MaxPooling 下采样后,特征图大小从 256×256 降到 64×64,再进行上采样恢复尺寸时,细节空间位置已经被压缩模糊了。ab 通道是逐像素预测任务,对位置精度要求很高,简单的 bilinear 上采样做不到像素级对齐。

解决思路是加跳连,这样深层语义特征和浅层边缘特征能在解码阶段融合。如果加了跳连还是有色偏,可以把 nn.Upsample 换成 PixelShuffle 上采样方式,让网络自己学习如何恢复空间细节。实际项目中我两者都用过,跳连解决 80% 的溢出问题,PixelShuffle 则是锦上添花。

4.3 输出的图像色调完全离谱

训练正常、Loss 正常,但彩色化结果整体偏紫或偏绿,像加了奇怪的滤镜。这种问题几乎总是出在数据预处理或后处理阶段,而不是模型本身。

最常见的翻车点是 OpenCV 的通道顺序。用 cv2.imread 读取彩色图是 BGR,如果你训练时用了 cv2.COLOR_BGR2LAB,推理转回时必须用 cv2.COLOR_LAB2BGR,混用 RGB 转换函数会让 a/b 通道的符号互换,红色变绿色、蓝色变黄色。

排查方法很简单:不要直接跳到模型推理,先写一段代码读一张彩色图,转 Lab 再转回 BGR,保存后和原图对比,如果颜色有偏差,问题在转换逻辑;如果一致,才继续检查网络输出。

4.4 显存不够,训练直接中断

在 256×256 输入下跑上面的 ColorNet,batch_size 开到 32 很容易触发 CUDA out of memory。这种问题不一定是显卡太差,更多是参数配置不合理。

解决有几个方向:第一,把输入尺寸降到 128×128 跑通全流程,彩色化质量不会下降太多;第二,batch_size 减半,配合梯度累积模拟大 batch;第三,检查 DataLoader 的 num_workers,这个参数不会减少显存占用,但会明显拖慢数据加载,让人误以为程序卡死。

如果你的显卡只有 4GB 显存,还可以把编码器的初始通道从 32 减到 16。这样模型参数量减少四倍,训练速度更快,彩色化效果在缩略图上差距不大。

4.5 复现开源源码,效果和报告里不一致

很多彩色化项目的主页展示效果惊艳,但自己复现时怎么调都达不到那个水平。这不一定是你代码写错了,而是报告里的效果图往往是精心挑选的。

彩色化结果对训练集内容极其敏感。如果用 ImageNet 训练,模型对场景理解很强;换成自定义数据集,效果会立刻回落。另外一个隐藏变量是随机种子,数据 shuffle 顺序不同,最终模型权重差异明显。建议固定 torch.manual_seed 和 numpy.random.seed 再训练。

复现不重要,更重要是理解实验逻辑。我一般会把注意力放在“模型 A 比模型 B 的 PSNR 高多少”这类相对比较上,而不是纠结单张图的视觉质量。

5. 想让结果更像原图色调:推理阶段做一次颜色迁移微调

纯靠网络预测的彩色化结果,在观感上经常差一口气,尤其训练数据不充足时,输出颜色会偏灰偏淡。有个不增加训练成本的技巧,能在推理阶段直接把结果拉回来:拿一张同风格的参考图,对预测的 ab 通道做一次统计颜色匹配。

这个思路来自经典的颜色迁移方法,核心是让预测图的 ab 通道均值和标准差向参考图靠拢。实现很简短,30 行以内搞定:

def color_transfer(ab_pred, ab_ref): # ab_pred: (H, W, 2) 预测的 ab 通道 # ab_ref: (H, W, 2) 参考图的 ab 通道 mean_p = ab_pred.reshape(-1, 2).mean(axis=0) std_p = ab_pred.reshape(-1, 2).std(axis=0) mean_r = ab_ref.reshape(-1, 2).mean(axis=0) std_r = ab_ref.reshape(-1, 2).std(axis=0) ab_new = (ab_pred - mean_p) / (np.maximum(std_p, 1e-5)) * std_r + mean_r return np.clip(ab_new, -128, 127)

用法是在保存最终结果前,先用参考图计算 mean_r 和 std_r,再对网络输出的 ab 做上述变换。参考图的选择很关键,风景图配风景图、人像配人像,风格越接近,结果越自然。这个后处理不会改变图像的空间结构,只调整色调分布,所以不用担心产生伪影。

我实际做这类项目时,习惯先看网络裸输出,再决定要不要做颜色迁移。如果裸输出已经接近预期,就不用画蛇添足;如果颜色发灰,先用这招救急,再回头检查训练集的色彩分布是否有问题。这个技巧也适合写进报告的实验部分,作为“后处理对彩色化效果的影响”一节的数据支撑。希望这个从原理到避坑的流程能帮到你,少走我之前走过的弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询