
简介面向计算机相关专业学生及实践者的一套灰度图像彩色化处理与优化实现资源适合毕业设计、课程设计、算法进阶及实际项目借鉴。基于Welsh颜色转移算法完成灰度图自动着色再引入导向滤波进行去噪与边缘保留优化解决传统滤波在降噪时易丢失细节、增强时易放大噪声的问题能直观改善着色结果的自然度与真实感。压缩包内共16个文件以8张JPG测试图像、4个Python脚本为主体另含3个编译缓存文件与1份Markdown项目说明整体大小约533KB结构简洁、便于对照实验。源码经过运行验证包含较为详细的中文注释对Welsh采样匹配、导向滤波参数调整等关键步骤均有说明可供读者从原理到实现完整理解并在此基础上扩展修改。目前已有430人学习对于希望快速上手Welsh颜色转移图像彩色化及相关滤波优化的读者是一份实用的参考。1. 给灰度图像彩色化Welsh算法是性价比最高的起点给灰度图像彩色化最笨的办法是用PS一点一点涂一张人像磨一两个小时还不一定自然。Welsh算法解决的就是这个问题给定一张参考彩色图算法自动把参考图的颜色“迁移”到灰度图上几分钟得到一张观感自然的彩色结果。它不需要训练数据、不需要GPU一台普通笔记本就能跑核心代码量不大特别适合做图像处理课设、老照片修复预处理或者给想理解传统颜色迁移方法的人当入门项目。这篇笔记从算法原理、Python实现、三个实测有效的优化方向一直写到五个高频踩坑点尽量让新手按步骤能跑通让熟手直接避开性价比最低的那几个坑。2. Welsh算法为什么能上色亮度匹配加邻域统计的样本迁移逻辑2.1 从参考图“借”颜色的完整链路Welsh算法的核心思想非常直白灰度图只有亮度信息彩色图有亮度加色度信息那就在参考彩色图里找一块“长得像”目标区域的样本把它的颜色搬过来。整条链路分四步走先在参考图上按固定间隔采样得到一批带有Y、Cb、Cr三通道的候选样本然后计算目标灰度图每个像素邻域的亮度均值和标准差再在参考样本中找出亮度最接近的候选最后把候选的Cb和Cr赋给目标像素。你会发现整个过程中算法完全没有“学习”任何东西它做的是匹配和搬运。这和深度学习彩色化方案有本质区别基于GAN或CNN的方法需要大量成对数据训练而Welsh算法零训练效果完全取决于参考图选得好不好。在做老照片修复这类单张图片任务时传统算法的可控性反而是优势——结果不满意换一张参考图重新跑一次就行不用重新训练模型。2.2 颜色空间为什么选YCbCr而不是RGBRGB空间里三个通道的关联性太强直接对R、G、B做差值和匹配会产生明显的偏色。比如你在RGB空间里把两个像素的欧氏距离算得很小但人眼感知到的颜色可能差得很远。YCbCr把亮度Y和色度Cb、Cr拆开亮度通道正好能和灰度图的亮度直接对应色度通道独立处理也不会互相污染。OpenCV里的cv2.COLOR_BGR2YCrCb就是做这件事的注意它的通道顺序是Y、Cr、Cb和理论文档里常写的YCbCr顺序略有差别。我最早在这里翻过车按Y、Cb、Cr的顺序去拆通道结果cv2.merge出来全是诡异的品红色调排查了半天才发现是通道顺序写反了。如果你看到自己的输出图偏红偏蓝先检查这一行不要急着调算法参数。import cv2 import numpy as np gray cv2.imread(old_photo.png, cv2.IMREAD_GRAYSCALE) ref cv2.imread(reference_color.jpg) # BGR通道顺序 ref_ycrcb cv2.cvtColor(ref, cv2.COLOR_BGR2YCrCb) ref_y, ref_cr, ref_cb cv2.split(ref_ycrcb)逻辑说明ref_y是参考图的亮度通道直接参与匹配ref_cr和ref_cb是后续要“借”给灰度图的色度信息。参数说明IMREAD_GRAYSCALE强制以单通道读入避免某些图片带Alpha通道导致后续维度对不上如果参考图是RGBA格式先用cv2.cvtColor(ref, cv2.COLOR_BGRA2BGR)去掉Alpha通道再转YCbCr。2.3 邻域标准差抑制错误匹配的关键约束如果只按单个像素的亮度匹配会出现大量错误匹配。想象一下一张白墙照片和一片白云单像素亮度值可能完全相同但人眼知道墙是白的、云是蓝灰的。单像素匹配会把云的蓝色搬到墙上。Welsh算法的关键改进在于引入了邻域统计量——对每个像素取一个patch窗口常见是5×5或7×7计算窗口内亮度的均值和标准差。均值描述局部明暗标准差描述局部纹理复杂度。平坦区域标准差小边缘区域标准差大这两个特征联合起来就能很好地区分“墙”和“云”。匹配时的代价函数通常写成score (target_mean - sample_mean)^2 lambda * (target_std - sample_std)^2lambda是标准差项的权重一般取0.1到0.5之间。取太小纹理约束失效容易产生色块取太大亮度匹配被弱化整体色调会偏。我习惯从0.3起步先看输出图有没有明显色斑再决定调大还是调小这个参数比你想的更敏感。2.4 原论文没展开但你绕不开的三个参数第一个是采样间隔sample_step。Welsh原论文的做法是在参考图中按扫描线顺序采样实际工程实现常用固定步长均匀采样。步长取4到16之间参考图如果是高清的取8就能拿到足够多的候选样本步长太小候选样本过多匹配时计算量翻倍但效果提升有限。第二个是亮度分桶bucket。直接把每个目标像素和全部候选样本做匹配速度极慢。常见做法是按亮度值分桶比如桶大小为32就把整个亮度范围分成8段目标像素只在自己的亮度段内找候选。桶太大失去加速意义桶太小容易在某一段找不到样本。我记得某次把桶调到64结果人脸区域全部偏绿因为那个亮度段里恰好全是草地样本这就是“闭着眼睛挑了个最近的”导致的翻车。第三个是邻域窗口patch。窗口太小均值方差都不可靠窗口太大边缘细节被抹平。人像图用5风景图用7超过9之后彩色化结果会明显变糊边缘处理起来也不好看。3. 用Python把Welsh算法跑起来核心代码与最小复现流程3.1 环境准备OpenCV加NumPy就够了先确认环境。Python版本3.8以上就行关键依赖只有两个numpy和opencv-python。如果你是在VSCode里配环境建议直接建一个虚拟环境不要往全局环境装后面换项目版本冲突的时候会头疼。装完验证一下pip install numpy opencv-python python -c import cv2, numpy; print(cv2.__version__, numpy.__version__)逻辑说明这行命令同时验证两个核心库能否正常导入并打印版本号。参数说明OpenCV 4.x以上都支持COLOR_BGR2YCrCb如果打印出版本信息说明环境没问题如果报错ModuleNotFoundError先检查当前终端是不是激活了正确的虚拟环境——这个问题在Windows下尤其常见。3.2 读取灰度图与参考图尺寸和通道对齐这一步看起来简单但尺寸不匹配是后续一切怪问题的源头。灰度图和参考图不需要严格等尺寸但参考图的长宽比最好和目标图接近。原因很实在Welsh算法是逐像素迁移颜色如果参考图是竖构图而目标图是横构图候选样本的邻域统计分布会整体偏移结果就是天空和草地的颜色互相错位。gray cv2.imread(old_photo.png, cv2.IMREAD_GRAYSCALE) if gray is None: raise FileNotFoundError(目标灰度图读取失败检查路径) ref cv2.imread(reference_color.jpg) if ref is None: raise FileNotFoundError(参考图读取失败检查路径) # 参考图尺寸随意但建议不要比目标图小太多 h, w gray.shape ref cv2.resize(ref, (w, h), interpolationcv2.INTER_LINEAR)逻辑说明gray.shape拿到的是(h, w)而cv2.resize需要的是(w, h)这个顺序错了图片会直接变形。把参考图缩放到和目标图完全一样大是为了后面逐像素操作时维度对齐少一个if判断。参数说明插值方式用INTER_LINEAR就行速度适中、质量够用如果参考图是大幅缩小用INTER_AREA能减少锯齿但这一步对最终着色效果影响很小不必纠结。3.3 核心匹配逻辑按亮度分桶加邻域标准差匹配这一节是算法的核心实现。先提前算好参考图和目标图的邻域均值和标准差避免在双循环里重复计算这是能跑和不能跑的分水岭。def welsh_colorize(gray, ref_bgr, sample_step8, patch5, bucket32, std_weight0.3): Welsh灰度图彩色化核心实现。 Args: gray: 单通道uint8灰度图 ref_bgr: 已resize到相同尺寸的BGR彩色参考图 sample_step: 参考图像素采样间隔 patch: 邻域窗口大小必须为奇数 bucket: 亮度分桶大小 std_weight: 邻域标准差在匹配代价中的权重 ref_ycrcb cv2.cvtColor(ref_bgr, cv2.COLOR_BGR2YCrCb) ref_y, ref_cr, ref_cb cv2.split(ref_ycrcb) h, w gray.shape # 1. 计算参考图的邻域均值和标准差 k patch // 2 ref_mean cv2.blur(ref_y.astype(np.float32), (patch, patch)) ref_sq cv2.blur(ref_y.astype(np.float32) ** 2, (patch, patch)) ref_std np.sqrt(np.maximum(ref_sq - ref_mean ** 2, 0)) # 2. 按sample_step采样参考图的候选样本 sample_mask np.zeros_like(ref_y, dtypebool) sample_mask[::sample_step, ::sample_step] True s_y ref_y[sample_mask].astype(np.float32) s_cr ref_cr[sample_mask].astype(np.float32) s_cb ref_cb[sample_mask].astype(np.float32) s_mean ref_mean[sample_mask] s_std ref_std[sample_mask] # 3. 计算目标灰度图的邻域均值和标准差 g_mean cv2.blur(gray.astype(np.float32), (patch, patch)) g_sq cv2.blur(gray.astype(np.float32) ** 2, (patch, patch)) g_std np.sqrt(np.maximum(g_sq - g_mean ** 2, 0)) # 4. 逐像素分桶匹配 out_cr np.zeros_like(gray, dtypenp.float32) out_cb np.zeros_like(gray, dtypenp.float32) for y in range(h): for x in range(w): center_val gray[y, x] low max(0, int(center_val) - bucket // 2) high min(255, int(center_val) bucket // 2) # 在当前亮度桶内找候选 idx np.where((s_y low) (s_y high))[0] if len(idx) 0: idx np.arange(len(s_y)) # 计算代价并取最小 d (g_mean[y, x] - s_mean[idx]) ** 2 \ std_weight * (g_std[y, x] - s_std[idx]) ** 2 best idx[np.argmin(d)] out_cr[y, x] s_cr[best] out_cb[y, x] s_cb[best] # 5. 合并通道并转回BGR result cv2.merge([gray.astype(np.float32), out_cr, out_cb]) result np.clip(result, 0, 255).astype(np.uint8) return cv2.cvtColor(result, cv2.COLOR_YCrCb2BGR) gray cv2.imread(old_photo.png, cv2.IMREAD_GRAYSCALE) ref cv2.imread(reference_color.jpg) ref cv2.resize(ref, (gray.shape[1], gray.shape[0])) colorized welsh_colorize(gray, ref, sample_step8, patch5, bucket32, std_weight0.3) cv2.imwrite(colorized_result.jpg, colorized)逻辑说明分成五步走第一步用cv2.blur计算邻域均值再用平方均值减均值平方的方式算标准差这是图像处理里标准的快速方差计算方法避开显式的双循环卷积。第二步用布尔掩码采样只保留sample_step间隔上的像素作为候选大幅压缩匹配空间。第四步是核心匹配对每个目标像素限制在亮度附近的一个小桶里找候选桶内取代价最小的样本把它的色度赋给目标像素。参数说明sample_step8意味着参考图大约只有1/64的像素参与匹配对一张1080P的图来说仍有约3万个候选足够覆盖大多数情况bucket32把亮度分成8段是一个兼顾速度和质量的起始值std_weight0.3是标准差约束的强度调到0就是纯亮度匹配色斑会明显变多。3.4 跑通后先别急着优化用最朴素的结果评估效果第一次跑通后输出的图片大概率会有明显的色块和噪点这是正常的。Welsh算法在论文里的效果图都经过后处理原始输出几乎不可能直接商用。你先用cv2.imshow或者直接打开输出的JPG看三件事整体色调是否符合参考图的氛围人脸或主体区域是否出现明显的颜色跳变边缘是否“渗色”比如深色衣服边缘染上了背景的颜色。这三点决定了你下一步往哪个方向优化。如果整体色调偏了问题在参考图选择如果局部花斑问题在bucket和std_weight如果边缘渗色你得考虑在匹配代价里加入梯度约束或者对输出色度通道做引导滤波。别一上来就堆优化手段先定位问题比什么都重要。4. 三个亲测有效的优化方向从分钟级到秒级从花脸到自然4.1 优化一图像金字塔把复杂度降一个量级上面那段代码在720P的图上跑一遍大概要一两分钟主要耗时在双循环里的np.where。最有效的加速方式是金字塔先把图缩到1/4甚至1/8尺寸跑匹配再把匹配结果的色度通道放大回原尺寸。亮度细节在放大时从原图直接取因为灰度图本身的边缘信息完全保留。def welsh_colorize_pyramid(gray, ref_bgr, levels2, **kwargs): 金字塔加速版Welsh。先在低分辨率匹配再上采样恢复色度。 h, w gray.shape # 逐级缩小 small_gray gray.copy() small_ref ref_bgr.copy() for _ in range(levels): small_gray cv2.pyrDown(small_gray) small_ref cv2.pyrDown(small_ref) # 在低分辨率上跑匹配 small_ref cv2.resize(small_ref, (small_gray.shape[1], small_gray.shape[0])) small_cr, small_cb welsh_colorize_core(small_gray, small_ref, **kwargs) # 双线性上采样回原尺寸 cr_full cv2.resize(small_cr, (w, h), interpolationcv2.INTER_LINEAR) cb_full cv2.resize(small_cb, (w, h), interpolationcv2.INTER_LINEAR) result cv2.merge([gray.astype(np.float32), cr_full, cb_full]) result np.clip(result, 0, 255).astype(np.uint8) return cv2.cvtColor(result, cv2.COLOR_YCrCb2BGR)逻辑说明pyrDown每次把图像长宽各缩小一半两层金字塔意味着匹配的像素量减少到原来的1/16耗时基本也能降到原来的1/10以上。色度通道是低频信息用双线性插值放大不会造成明显损失。参数说明levels2是推荐值再往上加到3速度提升开始变缓而色度细节的丢失开始肉眼可见尤其在发丝和树叶这类高频区域。4.2 优化二用随机搜索替换全像素匹配第二个优化思路是打破“每个目标像素都要匹配”的惯性。相邻像素的亮度邻域高度相似匹配结果也高度相似没必要逐像素计算。做法是先按sample_step对目标图也做一次采样在这些采样点上跑匹配得到色度后对整个色度通道做插值。配合金字塔使用速度能再翻一倍以上。def welsh_colorize_sampled(gray, ref_bgr, target_step4, **kwargs): h, w gray.shape # 对目标图像素抽样匹配 sampled_gray gray[::target_step, ::target_step] sampled_ref cv2.resize(ref_bgr, (sampled_gray.shape[1], sampled_gray.shape[0])) cr_small, cb_small welsh_colorize_core(sampled_gray, sampled_ref, **kwargs) # 插值回全尺寸 cr_full cv2.resize(cr_small, (w, h), interpolationcv2.INTER_CUBIC) cb_full cv2.resize(cb_small, (w, h), interpolationcv2.INTER_CUBIC) ...逻辑说明target_step4表示每4×4像素块里只匹配一个代表点色度通道天然是低频信息相邻4像素内的颜色差异几乎不可见。这就是“用空间分辨率换时间”的典型取舍。参数说明target_step最大不要超过8超过后头发丝和物体边缘会出现明显的“彩色锯齿”因为色度通道的信息密度跟不上亮度通道的细节。4.3 优化三参考图预处理比调算法参数更有效这是我最想强调的一点。调参半天不如换一张参考图Welsh算法的天花板由参考图决定。我维护过一个小的老照片修复工作流里面最重要的步骤是参考图筛选目标图是室内人像参考图就选室内光照下的同色系人像目标图是室外风景参考图就选相似光线和季节的风景照。光照方向不一致会让整个画面的阴影区域染上奇怪的色调。另外参考图先做一次高斯模糊再参与匹配能有效抑制参考图本身的噪点被迁移到结果里。ref cv2.GaussianBlur(ref, (3, 3), 0) ref_ycrcb cv2.cvtColor(ref, cv2.COLOR_BGR2YCrCb)逻辑说明在转换颜色空间之前先对参考图做轻度平滑代价是参考图略微变糊收益是参考图的高频噪点不会通过样本迁移“复印”到目标图。参数说明核大小(3, 3)和标准差0是轻量平滑不要用(5, 5)以上否则参考图的纹理特征丢失匹配时的邻域标准差约束会失真。4.4 优化后的完整调用与参数建议三个优化叠加后720P图的处理时间能从一两分钟降到3到5秒效果上基本看不出差异。我常用的参数组合是参数推荐值适用场景sample_step8~12参考图越清晰取值越大patch5人像、细节多的图patch7风景、大面积平滑区域bucket16~32越小色斑越少但越慢std_weight0.2~0.5纹理丰富场景取上限levels2通用推荐超过3慎用target_step4配合金字塔使用这套参数下肤色、天空、植被这些常见元素的颜色迁移都比较稳。需要注意patch和bucket联动patch变大时邻域均值更平滑bucket可以适当调小来补回一部分区分度这个规律调多了自然会有手感。5. 避坑指南灰度图彩色化最常见的5个问题与排查方法5.1 问题一输出大片色块像是被人拿油漆桶泼过现象结果图整体色调是对的但天空、墙壁、人脸这些大面积区域出现明显的颜色分块一块浅蓝一块深蓝。原因亮度分桶bucket取值太大比如设成64或128。分桶本质是在亮度维度上聚类桶越粗亮度相近但颜色差异大的参考样本会被归到一起相邻目标像素分别匹配到桶内距离最近但颜色不同的样本色块边界就出来了。解决把bucket从64降到16或32同时把std_weight从0.3提到0.5。如果色块还在检查参考图本身是不是有大量同亮度不同色的区域——比如同一片天空里有蓝天和白云亮度接近但色度差异很大。这时候需要换参考图或者对参考图做颜色聚类预处理而不是继续调参。5.2 问题二换了图效果差别巨大同一套参数不稳定现象同一套代码参考图从A换成B效果从“惊艳”变成“噩梦”人脸发绿、天空发黄。原因Welsh算法严重依赖参考图与目标图的亮度分布相似度。参考图B如果整体偏暗或偏亮它的亮度直方图与目标图的直方图错位匹配时就只能“矮子里拔将军”。解决先对参考图做亮度直方图匹配把参考图的亮度分布掰到和目标图一致。用cv2.calcHist分别统计两张图的亮度直方图然后用cv2.LUT做映射。这一步能在不改变参考图色彩构成的前提下把它的亮度分布对齐到目标图大幅提升匹配质量。我现在的流程里这步是必做的耗时不到50毫秒效果提升却非常明显。5.3 问题三边缘严重渗色深色衣服染上背景颜色现象物体的边缘轮廓外侧出现一圈背景色的光晕尤其在人像的发际线和衣服边缘。原因邻域窗口patch在跨过边缘时窗口内同时包含了前景和背景的像素均值和标准差被“平均”掉了。此时计算的代价分数不再可靠匹配到的样本可能是完全无关的区域。解决在匹配代价中加入梯度惩罚项。对每个像素先算Sobel梯度幅值把梯度差异加到代价函数里。边缘像素的梯度大就会优先匹配参考图中同样具有强边缘的样本避免“平滑的墙”匹配上“锐利的云”。g_grad cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize3) r_grad cv2.Sobel(ref_y, cv2.CV_32F, 1, 0, ksize3) # 代价函数变为 # d (mean_diff)^2 std_weight * (std_diff)^2 grad_weight * (grad_diff)^2逻辑说明Sobel算子计算水平方向梯度ksize3是最常见的核大小。梯度权重grad_weight取值0.1到0.3太大会让亮度匹配失效太小等于没加。加了这行之后边缘渗色的改善是肉眼可见的代价函数从两项变三项的计算开销几乎可以忽略。5.4 问题四720P图跑了好几分钟以为程序死循环了现象运行脚本后终端没有任何输出也没有报错就是卡住不动。原因双重for循环逐像素匹配每个像素内部还有一次np.where全数组扫描。整体时间复杂度是O(H×W×N)N是候选样本数。720P的图有92万个像素每个像素扫描3万个候选这个计算量在纯Python里跑是灾难级的。解决先确认是不是死循环——在循环里加个计数器每处理1万像素打印一次进度。然后必须走第四章的金字塔加速和目标采样加速。我在第一次实现时没有概念直接拿1080P图测试等了三分钟没出结果还以为内存爆了后来加了进度条才发现是计算量问题不是程序问题。5.5 问题五输出全黑或者全灰完全看不到任何颜色现象程序不报错输出的图片却是全黑的或者是一张和原灰度图一模一样的灰图。原因这两种情况原因不同。全黑一般是通道合并时数据范围出了问题YCbCr的通道在cv2.split之后是uint8但经过浮点运算后变成了float32直接cv2.merge再cvtColor时OpenCV会截断导致亮度通道丢失。全灰则是色度通道的数值全部落在128附近——当匹配到的样本色度本身就是中性色或者参考图本身饱和度极低时会出现这种情况。解决确保在cv2.merge前把三个通道统一成float32合并后先np.clip(0, 255)再.astype(np.uint8)。检查参考图的饱和度如果参考图本身就是低饱和风格的输出自然偏灰。我现在的排查顺序是先打印out_cr和out_cb的均值和标准差如果标准差小于10说明色度通道根本没有有效信息问题出在参考图上。6. 验证与进阶用PSNR和闭环实验给结果一个交代这个项目做完后怎么证明它“有效”灰度图本身没有标准彩色答案你没法直接算误差。业界最常见的做法是闭环实验拿一张彩色图转成灰度再跑一遍Welsh算法然后把结果和原始彩色图对比计算PSNR。这个指标能客观地告诉你算法在亮度分布相同的条件下能恢复多少颜色信息。orig_color cv2.imread(original.jpg) gray_version cv2.cvtColor(orig_color, cv2.COLOR_BGR2GRAY) recovered welsh_colorize(gray_version, ref) psnr cv2.PSNR(orig_color, recovered) print(fPSNR {psnr:.2f} dB)逻辑说明PSNR越高说明恢复的彩色图和原图越接近。30dB以上算不错25dB以下说明参考图选得有严重问题。注意PSNR只衡量像素级差异不完全等价于人眼观感所以它只能作为筛选参考图的工具不能替代人眼最终判断。我的习惯是候选参考图先跑一版PSNR低于27直接换高于27再细看主观效果。进阶的话有三个方向值得做一是给算法加一个简单的掩码界面在灰度图上手动标记肤色区域让算法优先从参考图的对应部位匹配——这个改动不大但效果立竿见影适合人物特写二是把金字塔层的色度结果做一次引导滤波让色度边缘贴着亮度边缘走能系统性改善边缘渗色问题三是处理视频帧时加光流约束相邻帧的色度保持一致避免闪烁——这个跨到视频增强领域了但基础还是Welsh算法这套匹配逻辑。最后说一个我自己的教训。最早调试时我为了追求速度把bucket设成128出来的图远看还行放大后全是色块我还以为是算法本身垃圾。后来一档一档往下试直到16才看到干净的结果。从那以后我养成了一个习惯任何参数改动先跑一张小图看效果再跑大图验证速度。这套流程帮我少走了很多弯路希望帮到你。本文还有配套的精品资源点击获取