
简介面向深度学习与图像处理开发者这份资源提供基于OpenCV DNN模块的图像自动着色实现方案。项目使用C编写配合预训练的caffemodel模型及其prototxt结构定义可直接加载并完成灰度图像的彩色化推理适合需要快速上手图像着色实践的读者。压缩包共9个文件大小约115.61MB涵盖Visual Studio工程文件.sln/.vcxproj/.filters等、C主程序、模型文件与两张测试用JPG图片同时附带项目用户配置文件解压后即可在VS环境中打开运行。目前已有710人学习使用资源设计完整适合作为OpenCV与深度学习结合的入门或参考项目。通过阅读及运行测试代码读者可理解缩放预处理、模型前向传播、输出后处理等关键环节还可替换自己的黑白图片验证效果并将该技术思路迁移到视频着色等延伸场景。1. 图像着色不是修图OpenCV 的 DNN 模块为什么能搞定这件事拿一张黑白老照片去着色滤镜和 PS 都没法真正解决因为同样一块墙面可能是灰、米白或淡黄——灰度图里根本没有颜色信息所谓着色本质是从明暗和语义里“猜”颜色。传统算子做不了这种预测只有深度学习能从海量彩色图中学到颜色先验。OpenCV 从 3.4 起内置的 DNN 模块刚好能加载预训练模型做前向推理不用装 PyTorch、不用跑训练CPU 就能把 512×512 的灰度图在几秒内出结果。下面按“选模型→跑通代码→调参数→踩坑”的顺序把整条落地路径走一遍。2. 为什么着色几乎只能用深度学习从 Lab 空间到模型选型2.1 一个灰度值对应无数种颜色着色是个“开集”问题图像处理里最常见的彩色空间是 RGB但做着色时几乎所有模型都选 Lab 空间原因很直接Lab 把“亮不亮”和“什么颜色”拆开了。L 通道只管亮度a 通道管绿到品红b 通道管蓝到黄。一张灰度图等于只剩 L模型的任务就是预测每个像素的 a 和 b 两个值。听起来像回归问题但同一个 L 值可能对应无数种合理的 ab 组合——黑西装可能是黑、深蓝甚至墨绿草地和森林的灰度也接近。这就不是像素到像素的映射而是需要模型“理解”物体。深度学习能接住这个问题靠的是语义先验。用卷积神经网络在大规模彩色图像上训练后网络内部会隐式记住天空通常在图片上部、肤色在某个色域、树木偏绿这类规律。所以着色模型本质上是把“对世界的统计认知”压缩进权重里。具体到 Zhang 那篇 ECCV 2016 的经典工作它把 ab 空间量化成 313 个格子把着色当成分类问题网络对每个像素在 313 类上给一个概率分布而不是直接回归一个连续色值。这个设计让训练更稳也让模型天然带有多样性——同一张灰图可以采样出多种配色。这也是它后来成为 OpenCV DNN 里最容易跑通的着色模型的原因之一。2.2 OpenCV DNN 适合当部署环境不只识别物体也能跑生成式模型多数人接触 OpenCV 的 DNN 模块是从“用 OpenCV 识别物体”这类检测 demo 开始的加载一个 MobileNet-SSD 或 YOLO 的权重forward 一次拿到框和类别。着色任务很多人第一反应是必须回到 PyTorch 里跑但 OpenCV DNN 本来就是通用推理引擎不是只能跑分类和检测——只要是能导出成 Caffe、TensorFlow、ONNX、Torch 格式的模型它都管。图像着色这种生成式任务前向传播和分类网络没有本质区别所以完全能塞进 DNN 模块。这也意味着你在做深度学习模型部署时可以少搭一套 Python 推理环境。PyTorch 部署要装 torch、要考虑 GPU 驱动和 CUDA 版本而 OpenCV DNN 只认模型文件。我一般把 OpenCV 的 DNN 模块当作一个“模型运行时”它不在意模型当初用什么深度学习框架训练只要格式能读进去就行。对一个把 OpenCV 当主力的图像处理项目来说少一个依赖就少一类运维问题。DNN 后端还支持 CPU、OpenCL、CUDA 切换笔记本上默认 CPU 也能跑出能用的速度。2.3 三类常见着色模型怎么选Caffe 原版、deOldify 与全局-局部模型选模型是第一步不同模型的输入输出差别很大选错了后面的代码全要重写。我按落地难度排了个序模型输入输出OpenCV 加载方式适合场景主要坑Zhang ECCV 2016Caffe 版Lab 的 L 通道复制成 3 通道224×224Lab 的 ab 通道的 313 类概率readNetFromCaffe老照片修复、批处理、CPU 环境颜色偏灰后处理要增强饱和度deOldifyGAN 系列RGB 三通道短边 resize归一化到 [-1,1] 或 [0,1]RGB 三通道和输入同尺寸需先导出 ONNXreadNetFromONNX追求鲜艳自然、人像和风景导出麻烦颜色容易炸Iizuka SIGGRAPH 2016全局分支 局部分支多输入ab 通道readNetFromTorcht7学术复现双输入结构在 DNN 里喂数据麻烦我实际项目里首选 Zhang 的 Caffe 版。它权重是公开的、prototxt 和 caffemodel 配套齐全OpenCV 原生支持读 Caffe不需要做任何格式转换。deOldify 效果确实好但它是 GAN 系列模型生成器通常要自己从 PyTorch 导出成 ONNX导出时归一化方式没对齐OpenCV 里出来的图就发灰。Iizuka 的模型结构更老双输入分支在 OpenCV DNN 里要分别 setInput除非做学术对比否则不建议第一版就上。3. 用 OpenCV DNN 跑通着色最小复现代码与每一步的说明3.1 准备三份关键文件和一张灰度图动手前先确认文件齐不齐。如果你做过 opencv 图像处理项目会知道常规流程是灰度化、滤波、形态学那套东西在着色任务里几乎用不上这里需要的是一组模型文件。目录里至少要有这些东西文件作用说明colorization_deploy_v2.prototxt网络结构描述和 caffemodel 配套不能拿别的版本混用colorization_release_v2.caffemodel训练好的权重输入输出层定义在 prototxt 里pts_in_hull.npy313 个 ab 量化中心的质心表形状是 (313, 2)后处理算颜色靠它gray.jpg测试灰度图没有的话可以用彩色图转灰度但要知道输入本身不该带颜色信息pts_in_hull.npy 这张表别尝试自己算它是训练阶段量化 ab 空间时生成的网上流传的模型包里通常附带。拿到后先np.load看一下形状不是 (313, 2) 的话后续矩阵乘法会直接报错。prototxt 和 caffemodel 的版本要一致最典型的问题是输入维度一个是 224 一个是 256加载不报错forward 时才翻车。另外先确认环境python -c import cv2; print(cv2.__version__)。如果你遇到过 opencv 安装成功却找不到 cv2 的情况先解决环境再继续这跟模型无关。OpenCV 版本建议 4.xDNN 模块在 3.4 之后才够稳。3.2 加载模型、构造输入 blob注意 L 通道而不是 RGB核心代码如下。我直接给完整可跑的脚本从读图到前向推理一次到位import numpy as np import cv2 PROTO colorization_deploy_v2.prototxt WEIGHTS colorization_release_v2.caffemodel PTS np.load(pts_in_hull.npy) # (313, 2) net cv2.dnn.readNetFromCaffe(PROTO, WEIGHTS) img cv2.imread(gray.jpg) if img is None: raise FileNotFoundError(gray.jpg 没读到先检查路径) # 如果输入本身就是单通道灰度图先补成三通道 BGR if len(img.shape) 2: img cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) # 转 Lab 并取 L 通道。L 在 OpenCV 里范围是 0~255 lab cv2.cvtColor(img, cv2.COLOR_BGR2Lab).astype(np.float32) L lab[:, :, 0] side 224 L_resized cv2.resize(L, (side, side)) # 网络骨架来自 ImageNet 预训练分类网络输入是 3 通道 # 所以把 L 复制成 3 份拼成 H,W,3 net_in np.stack([L_resized] * 3, axis2) blob cv2.dnn.blobFromImage( net_in, scalefactor0.01, # 等价于除以 100 size(side, side), mean(50.0, 50.0, 50.0), # 先减 50 再乘 0.01即 (L-50)/100 swapRBFalse ) net.setInput(blob) out net.forward() print(out.shape) # 预期 (1, 313, 56, 56)这里最容易理解错的是输入不是灰度图 RGB而是 Lab 的 L 通道。灰度图 RGB 的三通道数值等于亮度和 L 通道在视觉上几乎一样但严格说不等价。既然模型训练时喂的是 L部署时就应该按训练时的预处理来。blobFromImage 的公式我提醒一下OpenCV 是“先减 mean 再乘 scalefactor”所以scalefactor0.01, mean50得到的是(L - 50) / 100这是原版 Caffe demo 的归一化惯例。不要套用 ImageNet 分类模型那套mean(104, 117, 123), scalefactor1/255不是同一个预处理协议。swapRB 设 False 同样重要。我们手工拼的三通道不是 BGR 也不是 RGB只是同一个亮度复制三份OpenCV 默认 swapRBTrue 会把通道换位虽然三份一模一样时没影响但一旦你换成 RGB 输入的模型这里就会出大问题。setInput 不指定输入名也没关系这个网络只有一个输入层名字是 data_l 还是 data 无所谓DNN 会自动喂到第一个输入。3.3 把 313 维概率输出还原成 ab 通道彩色图forward 拿到的 out 是一个 NCHW 的 blobshape 是 (1, 313, 56, 56)。313 是分类数56×56 是输入 224×224 的四分之一因为网络里做了下采样。这一节把概率变成真正的颜色class8_ab out[0] # (313, 56, 56) prob class8_ab.transpose(1, 2, 0) # (56, 56, 313) # 温度退火概率先在 log 空间除以 T再归一化 T 0.38 prob np.exp(np.log(prob 1e-8) / T) prob prob / prob.sum(axis2, keepdimsTrue) # 用 313 个量化中心的质心求期望得到每个像素的 ab 值 ab prob PTS # (56, 56, 2)范围约 [-127, 127] H, W img.shape[:2] ab cv2.resize(ab, (W, H)) # 网络输出是 1/4 尺寸必须缩放回原图 L_full cv2.resize(L, (W, H)) # OpenCV 对 float32 的 Lab 输入L 按 0~100 解释所以要缩放到 0~100 L_scaled L_full / 255.0 * 100.0 out_lab np.concatenate([L_scaled[:, :, None], ab], axis2).astype(np.float32) out_bgr cv2.cvtColor(out_lab, cv2.COLOR_Lab2BGR) out_bgr np.clip(out_bgr, 0, 255).astype(np.uint8) cv2.imwrite(colorized.jpg, out_bgr)prob PTS是矩阵乘法把每个像素在 313 类上的概率和 313 个质心坐标做加权平均得到的就是连续 ab 值。为什么要做温度退火而不是直接argmax直接取最大概率会得到离散的类中心颜色一块一块的而且容易出现紫边用 0.38 这个温度把分布稍微抹平再求期望颜色更平滑。ab 的值域是 -127 到 127正好落在 OpenCV Lab 的合理范围所以不用额外缩放。这中间最容易忽略的是 resize。很多人拿到 56×56 的 ab 图直接和原图 L 拼接结果输出只有左上角一块有颜色。模型输出尺寸固定是输入的 1/4后处理必须先用cv2.resize把 ab 恢复到原图尺寸再和 L 合并。最后一步astype(np.uint8)之前一定要 clip否则 Lab2BGR 转换时溢出值会产生奇怪的色斑。提示如果你的 prototxt 里输入定义是 256×256那输出就是 64×64把 side 改成 256 即可。模型内部结构决定输出是输入的 1/4不是硬编码 56。4. 调参边界同样的模型结果好坏全看这几个参数4.1 blobFromImage 的 4 个参数归一化错了结果全灰图像着色模型的预处理没有统一标准不同训练框架出来的模型差很多。我把常见参数组合整理成一张表照着选就行模型来源scalefactormeanswapRBsizeCaffe Lab 版本文的 Zhang 模型0.01(50, 50, 50)False跟随 prototxt通用 RGB 版输入是灰度图转三通道1/255(0, 0, 0)False跟随模型定义[-1,1] 归一化很多 ONNX 导出模型1/127.5(1.0, 1.0, 1.0)看模型训练时是不是 BGR固定输入尺寸ImageNet 分类网络惯例1/255(104, 117, 123)True224 或 299scalefactor 和 mean 是配套出现的本质是把像素值变换到模型期望的分布。着色模型里最坑的是“看着像灰度图就直接用灰度的归一化”结果模型输入分布完全偏掉输出全灰。我的习惯是拿到模型先找它的官方 demo看 demo 里怎么预处理OpenCV 这边就照抄。没有 demo 的话先打印一张输入图的像素均值再想它的归一化目标是 0~1 还是 -1~1 还是别的区间。swapRB 这个参数在着色任务里容易被忽略。Zhang 模型里我们输入是三份 Lswap 不 swap 没区别但如果你换了一个 RGB 输入的模型OpenCV 读图默认是 BGR而模型训练时大概率用 RGB这时必须swapRBTrue否则颜色通道错位着色结果会整体偏色。size 参数直接影响显存和速度也影响模型输出分辨率。224×224 是经典尺寸512 以上的大图建议先缩小到 224 跑模型后处理再放大回原图效果差异很小速度能快一个量级。4.2 温度参数 T全图偏灰和出现噪点的天平上一节代码里的T 0.38不是随手写的。论文里作者发现直接对概率分布求期望结果会偏向灰色——因为分类分布覆盖了太多可能颜色平均之后就往中间收。他们用退火温度把分布压尖锐一些再求期望0.38 是论文里给的经验值。T 的作用你可以这样理解T 越大概率分布越平加权平均后颜色越灰但越平滑T 越小分布越尖锐越接近 argmax颜色越艳但噪点越多。调参时我一般先跑三组对比同一张图分别用 T0.2、0.38、0.5输出后放一起看。0.2 通常会出现明显的彩色噪点和色块0.5 会偏灰偏淡。如果你追求“稳”就一直用 0.38如果模型输出太灰优先动后处理饱和度而不是把 T 降太低。另外要注意温度作用在 log 概率上所以代码里要先加1e-8再取 log否则概率里有 0 时log(0)直接出 inf后面的归一化全乱。这个细节我第一次跑的时候没注意输出一堆 NaN查了半天。4.3 后处理三件套饱和度、对比度与 Lab 增益深度学习着色模型的输出普遍偏“素”这是分类期望的副作用不是模型坏了。我一般在 Lab 空间直接给 ab 通道乘一个增益系数这比在 RGB 里调饱和度干净——Lab 里调 ab 不会影响亮度不会把暗部提亮或高光压暗out_lab cv2.cvtColor(out_bgr, cv2.COLOR_BGR2Lab).astype(np.float32) out_lab[:, :, 1:] * 1.15 # 1.1 ~ 1.3 区间试探 out_bgr cv2.cvtColor(np.clip(out_lab, 0, 255).astype(np.uint8), cv2.COLOR_Lab2BGR)这个 1.15 是经验值。增益太大颜色会溢出特别是高饱和的红色和蓝色区域容易出现色阶断层。对比度方面我建议只提亮度通道Lab 里 L 通道调整后转回 BGR不会引入色偏。如果原图是严重褪色的老照片可以先对 L 做一次 CLAHE再走模型和后处理整体效果会比直接着色好很多。注意Lab 空间调完饱和度后一定要np.clipOpenCV 的 Lab2BGR 不会帮你处理越界值ab 超过 127 之后颜色会变得不可控。5. 避坑着色模型部署时最容易翻车的 5 个现场5.1 现象forward 报错或输出维度对不上现象是代码在net.forward()抛cv2.error或者输出 shape 不是预期的(1, 313, H/4, W/4)。原因基本是 prototxt 和 caffemodel 不配套或者输入尺寸和 prototxt 里的input_dim不一致。解决先打印net.getLayerNames()看网络是否正常加载然后检查 prototxt 开头的input_dim把side改成和它一致。还有一个血泪经验是不要拿不同来源的 prototxt 和 caffemodel 拼凑很多网上的模型包混装过加载不报错但 forward 的中间层维度对不上这种问题最难查。5.2 现象输出的图全灰或者只有边缘有一点点颜色现象是跑完代码输出的 colorized.jpg 几乎全灰放大看只有物体边缘有一丝颜色。原因有三个层次第一blob 归一化不对模型输入分布偏了分类结果趋近均匀概率第二L 通道拼接时没缩放到 0~100OpenCV 对 float32 Lab 输入的 L 按 0~100 解释你喂了 0~255 等于亮度翻倍转换结果发白第三ab 热值没有 resize 回原图尺寸。解决先检查输出像素分布把out_lab的 min/max 打出来。ab 应该在 -127~127 之间L 应该在 0~100 之间。哪怕你在《动手深度学习》里把原理读得很透部署时也一样会被颜色空间坑这类问题只能靠打印数值定位。5.3 现象肤色偏紫、天空发绿颜色一块一块的现象是整体有色偏局部区域出现不自然的紫色和绿色块。原因基本是概率后处理偷懒直接np.argmax或者温度参数 T 设太小。argmax 把每个像素强行归到 313 类中概率最高的那一类类与类之间的连续性全丢了所以出现色块。解决用期望值替代 argmax代码就是 3.3 里那段prob PTS。T 不要低于 0.2。如果色偏依然存在检查是不是swapRB设错了RGB 和 BGR 通道互换造成的偏色和 argmax 造成的色块在视觉上很像。5.4 现象OpenCV 读不了 .pth / .ptdeOldify 导出的 ONNX 跑出来发灰现象是cv2.dnn.readNet直接报错或者读进去了输出全灰。原因分两段DNN 模块不支持 PyTorch 原生权重格式必须先转 ONNX转换时如果dynamic_axes设了动态尺寸OpenCV 读 ONNX 时形状推断容易出问题。输出发灰则是反归一化没对齐deOldify 这类 GAN 模型输出可能是 [-1,1]也可能已经过 sigmoid 变成 [0,1]不同导出方式差很多。解决导出时固定输入尺寸比如torch.onnx.export里给一个假的固定输入不要开 dynamic axes。转完先用 onnxruntime 跑一遍打印输出的 min/max确认数值范围和预期一致后再交给 OpenCV。这个检查两步必须做每跳一步都是在给自己埋雷。5.5 现象CPU 推理很慢一张大图要几十秒现象是一张 1024×1024 的图跑了几十秒完全没法批处理。原因是分类头有 313 个输出通道计算量集中在最后几层输入尺寸越大这个开销越线性放大。解决先cv2.resize把短边降到 256 以内再喂模型后处理再放大回原图。然后切换 DNN 的后端net.setPreferableTarget(cv2.dnn.DNN_TARGET_OPENCL)在核显上能快 2~3 倍。如果 OpenCV 是带 CUDA 编译的可以改成DNN_TARGET_CUDA但要注意这个 DNN 后端的算子覆盖不是 100%某些层不支持 CUDA 反而会回退到 CPU。6. 进阶把着色封装成“任意尺寸都能跑”的函数模型跑通之后下一步是让它变成能复用的一等公民。我一般封装成下面这样的函数核心逻辑就一件事无论输入多大内部都缩放到模型期望尺寸输出再还原def colorize(net, pts, gray_bgr, side224, T0.38): h, w gray_bgr.shape[:2] lab cv2.cvtColor(gray_bgr, cv2.COLOR_BGR2Lab).astype(np.float32) L lab[:, :, 0] L_rs cv2.resize(L, (side, side)) net_in np.stack([L_rs] * 3, axis2) blob cv2.dnn.blobFromImage(net_in, 0.01, (side, side), (50, 50, 50), swapRBFalse) net.setInput(blob) out net.forward()[0] # (313, side/4, side/4) prob out.transpose(1, 2, 0) prob np.exp(np.log(prob 1e-8) / T) prob prob / prob.sum(axis2, keepdimsTrue) ab cv2.resize(prob pts, (w, h)) L_full cv2.resize(L, (w, h)) lab_out np.concatenate([(L_full / 255.0 * 100.0)[:, :, None], ab], axis2) return np.clip(cv2.cvtColor(lab_out.astype(np.float32), cv2.COLOR_Lab2BGR), 0, 255).astype(np.uint8)后端选择放到函数外面因为模型加载和 backend 设置只需要做一次。服务里我习惯写成全局变量初始化时readNetFromCaffe一次之后每张图只调forward不要每帧重新加载权重。批量处理时如果脚本同时接了识别和着色两个模型记得开一个预热拿一张全 128 的图先跑一次 forward省得第一张真实图片因为缓存未命中特别慢。最后说一个我自己的翻车经历。一开始我把 deOldify 导出成 ONNX 接进 OpenCV输出全灰排查半天发现是输出层的激活函数没处理好模型期望的是 tanh 输出然后反归一化到 [0,1]我当成 sigmoid 处理了。后来定了个规矩任何模型接进 OpenCV 之前先用 Python 跑一遍原始框架的推理打印输出 min/max再对比 DNN 的输出两边一致才继续。这个习惯帮我省了很多次查颜色空间的冤枉路。着色模型这东西效果好坏很大程度是“预处理对齐”的问题框架和后端只是工具。希望这些经验帮到你。本文还有配套的精品资源点击获取