2026/10/11 2:23:52

Welsh算法灰度图彩色化:原理、实现与参数优化

Welsh算法灰度图彩色化:原理、实现与参数优化 简介基于Welsh算法的灰度图像彩色化处理及优化Python实现项目面向计算机视觉方向的在校生、毕业生及入门开发者解决灰度图像自动上色与后续降噪优化问题。项目选用基于颜色转移的Welsh算法通过颜色匹配将参考彩色图像色彩映射到目标灰度图针对直接上色产生的噪声引入导向滤波Guided Filter进行边缘保留去噪避免高斯、Sobel等传统滤波器抹除细节或产生梯度反转伪影使彩色化结果更自然。压缩包共16个文件含4个Python源码、3个编译缓存、8张测试样图与1份Markdown项目说明源码均带详细注释与测试图像标注了实验运行环境可直接运行验证效果也便于对比分析不同参考图对彩色化结果的影响。整套资源仅533KB轻量易读适合毕业设计、课程设计或算法进阶参考可基于注释修改实现其他功能。已有432人学习下载适合想系统掌握主流彩色化算法及滤波优化技巧的读者。1. 基于Welsh算法的灰度图像彩色化为什么用“找相似”就能上色灰度图着色是个不适定问题同一个浅灰像素可能是白墙、蓝天或银色车身。人脑能猜靠的是语义先验普通程序没有。Welsh算法的思路是绕开语义在彩色参考图上采样再用灰度图的局部亮度、邻域纹理去找“长得最像”的像素把参考图的a、b颜色通道直接搬过来。没有深度学习的智能但对老照片上色、黑白电影修复这类场景它仍然是最容易跑通、最容易解释的基线。源码里常见的“优化”基本集中在两点怎么让匹配更快怎么让颜色过渡更自然。下面按“原理→最小实现→参数优化→踩坑→批量封装”来拆适合要从零复现并自己调参的人。2. 核心前处理先换成Lab颜色空间再用均匀采样锁定参考色如果你一上来就在RGB空间里做最近邻大概率会翻车。RGB三个通道跟光照强耦合两个看起来都是“灰色”的像素在RGB距离上可能差得很远。Welsh算法是在Lab空间操作的。Lab把像素拆成L亮度加a、b色度这样匹配亮度时不被颜色干扰传递颜色时直接复制a、b。OpenCV的cvtColor和skimage.color.rgb2lab都能完成转换区别是OpenCV走BGR顺序skimage走RGB且L范围是0~100。为了跟源码里常见写法保持一致下面统一用OpenCV约定。2.1 为什么RGB不行Welsh非要用LabRGB距离没有感知均匀性。同样是色差2在暗部能感知在亮部却看不出来像素匹配时RGB某通道的噪声稍大最近邻就会跳到颜色完全不对的像素。Lab接近感知均匀L通道与a、b通道相关性低适合把亮度和颜色分开处理。实际复现时我一般只取L通道做匹配特征a、b只用来写回。如果你换成HSVH通道在低饱和度时极为敏感灰度图几乎没有饱和度会导致参考像素乱跳。所以在基于Welsh算法的灰度图像彩色化项目里第一步几乎永远是cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB)而不是在RGB里硬算距离。2.2 灰度图转Lab的OpenCV写法直接转会出现“假三通道”灰度图只有一个通道很多人直接写cv2.cvtColor(gray, cv2.COLOR_GRAY2LAB)OpenCV会报错因为不存在这种枚举。正确做法是先cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)得到三通道BGR每个通道值相同再转LAB。这个“假三通道”不会引入色彩Lab转换后a、b会在128附近对应中性色。代码块如下import cv2 import numpy as np def gray_to_lab_single_channel(gray_path): gray cv2.imread(gray_path, cv2.IMREAD_GRAYSCALE) bgr cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR) # 三个通道全等于gray lab cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB).astype(np.float32) # 如果只想取亮度L直接取第0通道 L lab[:, :, 0] # a、b用于后续写回OpenCV的Lab里中性色是128 return lab, L逻辑说明COLOR_GRAY2BGR只是格式对位把一个通道复制成三个通道不引入彩色信息。astype(np.float32)很关键后续要算局部标准差和KD树uint8容易在滤波时溢出。若从skimage读入注意L范围是0~100而不是0~255匹配前要把目标图和参考图统一到同一量纲。我习惯全部统一到OpenCV的0~255范围差值和滤波更直接调试时看中间结果也直观。2.3 参考图采样均匀网格比纯随机更有效Welsh用参考图的彩色信息建立颜色表逐像素匹配不现实。纯随机采样在颜色分布极不均匀的图上会漏掉罕见色彩而且随机种子会引发不可复现的结果。我一般用均匀网格采样按步长在参考图上取行列索引既保证空间覆盖又天然可复现。def sample_uniform_grid(lab_image, step4): h, w, _ lab_image.shape rows np.arange(0, h, step) cols np.arange(0, w, step) grid_y, grid_x np.meshgrid(rows, cols, indexingij) grid_rows grid_y.ravel() grid_cols grid_x.ravel() sampled_lab lab_image[grid_rows, grid_cols, :] # 同时把行列索引留着排查时能定位颜色来自参考图的哪个区域 return sampled_lab, grid_rows, grid_cols这个函数把参考图压成N×3矩阵N大约等于(h/step)*(w/step)。step越小颜色表越完整匹配越慢。对1920×1080参考图step4大约是十三万个样本KD树还扛得住step2就超过五十万内存和耗时快十倍。我一般把默认步长设在4到8之间按目标图分辨率动态调。再补充一个原则采样前最好先按亮度直方图对参考图做筛选去掉曝光不足或过曝的区域否则这些像素会占用大量样本却几乎没有可用颜色。在Welsh的匹配里亮度动态范围不一致会带来系统性偏色。比如目标图整体偏暗参考图整体偏亮匹配结果会把目标所有像素都拽到参考图的亮部颜色上。所以我会在采样之前做一步亮度重映射把目标灰度图的亮度分布对齐到参考图def match_brightness_distribution(target_L, ref_L, clip_range(5, 95)): p_t np.percentile(target_L, clip_range) p_r np.percentile(ref_L, clip_range) # 线性拉伸目标亮度到参考亮度的百分位区间 scale (p_r[1] - p_r[0]) / (p_t[1] - p_t[0] 1e-6) offset p_r[0] - p_t[0] * scale mapped np.clip(target_L * scale offset, 0, 255) return mapped.astype(np.float32)5和95百分位可以避免个别极端亮点把整体亮度带偏。给不同年代老照片处理时这一步比后面调饱和更能决定成败不做重映射匹配结果会整体偏灰、偏粉颜色浓淡也不对。3. 匹配与颜色传递Welsh算法最小可跑的Python实现到了核心部分。前面所有准备都是为了在参考图里找到每个目标像素的“相似兄弟”把它的颜色搬过来。最直接的实现是两层循环外层遍历目标图内层遍历参考样本算亮度差找最小值。小图还能忍一张640×480的图就有30万个目标像素乘以十几万个参考样本慢到没法用。所以我把scipy.spatial.cKDTree作为标准解法。3.1 用“亮度局部标准差”做匹配特征纹理信息不能省如果只用单点亮度L做匹配会看到一种典型现象天空和白色墙壁颜色被错配因为它们在局部小块里都亮。Welsh强调邻域信息就是为了让纹理相似度参与决策。常用特征有两种当前像素为中心的窗口标准差或窗口内亮度梯度的幅值。我一般用标准差计算快参数少。窗口大小3~7像素太小忽略纹理太大会把边缘模糊。def build_feature(L_channel, window5): kernel np.ones((window, window), np.float32) / (window * window) mean cv2.filter2D(L_channel, -1, kernel, borderTypecv2.BORDER_REFLECT) mean_sq cv2.filter2D(L_channel * L_channel, -1, kernel, borderTypecv2.BORDER_REFLECT) # 方差 E[X^2] - (E[X])^2 variance np.maximum(mean_sq - mean * mean, 0.0) stddev np.sqrt(variance) # 特征向量是2维亮度 局部标准差 feature np.stack([L_channel, stddev], axis2) # H x W x 2 return featurecv2.filter2D做的是盒式滤波BORDER_REFLECT反射边缘避免图像边界出现黑边。np.maximum把浮点误差造成的负方差归零。堆叠后特征向量是2维你还可以加水平梯度和垂直梯度扩展成4维但计算量增加对噪声更敏感。在精致老照片上多一个维度带来的提升常不如手动把参考图选好来得明显。3.2 最小实现KD树最近邻匹配与ab通道写回把参考特征建树目标特征查询一步到位from scipy.spatial import cKDTree def welsh_colorize(target_lab, ref_lab, window5, sample_step4, top_k5): # 参考图取L、ab ref_L ref_lab[:, :, 0] ref_feat_full build_feature(ref_L, window) h, w ref_feat_full.shape[:2] rows np.arange(0, h, sample_step) cols np.arange(0, w, sample_step) grid_y, grid_x np.meshgrid(rows, cols, indexingij) sample_rows grid_y.ravel() sample_cols grid_x.ravel() ref_feat ref_feat_full[sample_rows, sample_cols, :].reshape(-1, 2) ref_ab ref_lab[sample_rows, sample_cols, 1:].reshape(-1, 2) kdt cKDTree(ref_feat) target_L target_lab[:, :, 0] target_feat build_feature(target_L, window) # H x W x 2 target_flat target_feat.reshape(-1, 2) # 查询最近邻top_k1时就是经典Welsh匹配 _, idx kdt.query(target_flat, ktop_k, workers-1) # idx形状是(N, top_k)取参考ab并做平均 matched_ab ref_ab[idx].mean(axis1).reshape(target_lab.shape[0], target_lab.shape[1], 2) out_lab np.zeros_like(target_lab) out_lab[:, :, 0] target_L out_lab[:, :, 1:3] matched_ab out_bgr cv2.cvtColor(out_lab.astype(np.uint8), cv2.COLOR_LAB2BGR) return out_bgr逻辑说明构建参考KD树目标特征target_flat一次性查询返回值idx里每一行是参考样本的索引。ref_ab[idx]形状为(N, top_k, 2)在axis1取平均就是k近邻颜色融合。workers-1让所有CPU核心参与计算一百万个目标像素的查询也就一两秒。最后把目标L和匹配ab合回转BGR输出。astype(np.uint8)会截断浮点但对显示结果影响很小如果你要保存无损中间结果可以保留float32先存npy。这段代码好处是短。坏处是它没有做亮度重映射和后处理所以直接用在普通图上效果可能偏噪。实际工程中我会在调用前先跑match_brightness_distribution输出后再对ab做一些平滑。3.3 参数表window、sample_step、top_k应该怎么配参数调优是Welsh项目最花时间的部分。我把常用范围和经验整理成表参数常用范围影响建议window3~7纹理特征范围人物用5风景用7小图用3sample_step4~8参考样本密度参考图大用6~8小图用3~4top_k1或5颜色平滑度5时ab取均值色块感明显下降亮度重映射百分位(5,95)动态范围匹配老照片用(10,90)更保守ab中值滤波核3~5去除孤立色点先试0再试3强烈色斑时试5window过小纹理相似度退化成像素亮度window过大边缘完全被平滑树叶和头发纹理没法区分。sample_step直接影响参考样本量参考图有丰富色彩渐变时要取小一些否则亮部或暗部颜色被抽稀。top_k不建议超过10超过后颜色会被平均成灰色饱和度反而下降。4. 优化到底优化什么亮度重映射、加速匹配和后处理三步源码包里写“优化”两个字指的不是换算法而是围绕这三件事匹配前让目标图和参考图更“门当户对”匹配时用更少的时间匹配后让颜色更干净。这三件事顺序不能反。先调亮度再加速最后平滑每一步的效果都能单独量化。4.1 先修参考图亮度重映射和直方图匹配第2章给的百分位拉伸是最轻量的做法。如果重映射后效果仍不对可以升级到直方图匹配把目标灰度图的累计分布函数映射到参考图的累计分布函数这样每个亮度区间的像素占比一致。直方图匹配会让对比度变化更明显但也会把灰雾放大。在老化胶片上我通常先用百分位拉伸直方图匹配作为备选。这里要留意一个坑参考图本身颜色偏色时直方图匹配只会让偏色更重所以参考图要先做简单白平衡比如把a、b通道均值移到中性色。4.2 加速降采样到小分辨率匹配再上采样ab通道高分辨率目标图直接跑KD树内存和耗时都不小。做法技巧是先把目标灰度图缩到640或768像素长边跑完匹配得到低分辨率ab通道再用cv2.resize双线性插值放大回原分辨率最后和目标原图L合并。ab通道本身是低频色度信息双线性上采样不会像深度网络那样产生明显的伪影。代码略写成这样def colorize_at_lowres(gray_large, ref_lab, scale0.5): h, w gray_large.shape[:2] small_h, small_w int(h * scale), int(w * scale) small_gray cv2.resize(gray_large, (small_w, small_h), interpolationcv2.INTER_AREA) small_bgr cv2.cvtColor(small_gray, cv2.COLOR_GRAY2BGR) small_lab cv2.cvtColor(small_bgr, cv2.COLOR_BGR2LAB).astype(np.float32) result_small_lab welsh_colorize(small_lab, ref_lab, window5, sample_step5, top_k5) # 取ab通道放大回原尺寸 ab_up cv2.resize(result_small_lab[:, :, 1:3], (w, h), interpolationcv2.INTER_LINEAR) gray_bgr cv2.cvtColor(gray_large, cv2.COLOR_GRAY2BGR) gray_lab cv2.cvtColor(gray_bgr, cv2.COLOR_BGR2LAB).astype(np.float32) gray_lab[:, :, 1:3] ab_up out_bgr cv2.cvtColor(gray_lab.astype(np.uint8), cv2.COLOR_LAB2BGR) return out_bgrINTER_AREA降采样抗锯齿好INTER_LINEAR上采样平滑。降到720p以下时单张图的匹配耗时通常不到一秒。缺点是精细边缘会有点颜色渗边此时可以把缩放系数提高到0.7或在上采样后对ab做3×3中值滤波。4.3 平滑ab通道中值滤波与饱和度自适应逐像素最近邻必然存在颜色不连续。一个像素匹配到参考图的蓝天上旁边像素因为微小的亮度差异匹配到屋顶于是出现孤立色点。修复方法是只对ab通道滤波不能动L通道。cv2.medianBlur不能直接处理双通道浮点我会拆通道分别处理或用cv2.filter2D做核加权。中值滤波天然保边缘比高斯滤波更合适。滤波核大小时机很关键先跑一次不加滤波的输出看色点密集程度再决定用3还是5一上来就上5会把浅色蓝天染成青色斑块。饱和度自适应是最后一步。完成匹配后统计参考图ab通道的标准差如果匹配输出ab标准差只有参考图的一半说明颜色被平均过程稀释可以用一个1.0~1.3的系数拉伸ab。但拉伸系数超过1.3时肤色容易变成“荧光脸”所以我只对自然风景批量处理时开1.1左右人像基本不开。4.4 参数组合建议老照片、黑白电影、灰暗扫描件三类常见输入我总结了三套心得。老照片人像参考图选光线柔和、肤色正常的中年女性或男性正面照window5top_k5输出后对ab中值滤波3×3不做饱和度拉伸。黑白电影按镜头预剪片段每段单独选参考帧window7sample_step6ab饱和度1.05防止整体偏绿。灰暗扫描件先做百分位(10,90)的亮度重映射目标缩到800px输出后再把四周暗角裁掉因为扫描件动态范围窄不重映射的结果色块很重。这些组合不是万能公式但总比在源码里瞎试参数靠谱。原则是一次只改一个参数改一次出一次图拿上次输出并排放着看。Welsh是统计匹配跟“深度玄学”一样效果受参考图影响极大参数调优的过程必须保留版本记录。5. 避坑指南Welsh着色复现中的5个典型踩坑与排查这一节内容来自我自己复现和排查的积累。Welsh这类算法看着简单实际跑起来处处是意料之外的偏色不把现象、原因、解决方法串起来看很容易陷入换一张参考图试一次的循环。5.1 颜色一大片偏蓝或偏黄整体像蒙了一层滤镜现象输出图整体色调偏移到某个奇怪方向比如蓝调或黄调。原因参考图亮度分布与目标图不一致大量目标像素集中匹配到参考图最亮或最暗的那一小批样本上参考图整体偏蓝结果当然偏蓝。解决先做百分位亮度重映射再做采样。条件允许时参考图上色前先做白平衡把a、b通道均值拉回中性色。很多源码包不会自动做这一步只能靠调用方处理。5.2 输出图有拼接感亮暗区域过渡处出现彩色斑块现象物体边缘有彩边平面区域颜色像补丁。原因每个目标像素独立查最近邻没有约束相邻像素颜色一致性这是逐点匹配方法的通病。解决把top_k从1改成5取多个最近邻的ab均值再对ab通道做3×3中值滤波。如果还有斑块把window从3加到7让纹理特征更稳定匹配不会因为单个像素噪声跳变。5.3 明明加了KD树还是慢CPU单核跑满现象KD树查询很快但整体耗时几十秒甚至几分钟。原因耗时通常不在query而在两层Python循环做邻域特征、逐像素取颜色、反复调用cv2.cvtColor。解决先用time.perf_counter分阶段测耗时把循环换成矩阵索引ref_ab[idx]把参考样本压进连续numpy数组。另一个常见坑是cKDTree默认多线程但构建特征时受GIL限制单核跑满很正常优先优化特征构建阶段的向量化。5.4 换一张参考图效果就翻天黑匣子行为明显现象同样的参数和目标图参考图从风景换成建筑输出连天空都变成砖红色。原因Welsh不做语义理解它只匹配纹理和亮度。砖墙和天空在局部方差上可能非常接近错误匹配被放大。解决选择与目标图同域的参考图更进一步的方案是把目标图按物体区域粗略分成上下两半分别匹配不同参考区域也就是“二次匹配”的思路。我在源码里看到的优化也常沿着这个方向做只不过没有公开写进注释。5.5 生成的彩色照片饱和度低得感人现象颜色很淡几乎接近灰度。原因可能是参考图本身不饱和也可能是因为top_k取平均时大量中性的相似像素把色度稀释了。解决检查参考图ab通道标准差若目标输出标准差明显偏低在Lab空间做饱和度拉伸new_a (a - 128) * s 128s在1.0~1.3之间。注意人像不要开超过1.1否则肤色会发红。饱和度低“感人”但至少没有偏色比蒙了一层滤镜更容易通过后处理救回来。6. 进阶用法把Welsh着色封装成可批量处理的模块并顺手验证效果当手上有几十张黑白照片要批量上色代码得从“脚本”变成“可复用模块”。我会把参考图和目标图路径、window、sample_step、top_k、是否做亮度重映射、降采样尺寸、ab中值滤波核这些参数收进一个配置对象里参考图首次加载后把KD树缓存下来后续只查树不重建。接口上可以设计成类似这样的结构class WelshColorizer: def __init__(self, ref_lab, window5, sample_step4): ref_L ref_lab[:, :, 0] ref_feat build_feature(ref_L, window) # 这里抽稀参考样本并建树参考图不变时只建一次 self.kdt cKDTree(...) self.ref_ab ... def colorize(self, target_lab, top_k5): # 查询、取ab、写回L return out_bgr批处理时我会先跑一张基准图保存成base.png之后所有参数改动都跟base.png对比而不是凭记忆“感觉变好了”。这个习惯救过我很多次因为Welsh的效果波动很大眼睛很容易被单张好看结果带偏。验证效果时不能只看主观感受。如果手里有原彩色图当标注我会跑三个指标PSNR看整体重建质量SSIM看结构保持ΔE看色差。若没有标注就把每张黑白图分别结合两张不同参考图比较两次输出在ab通道上的一致性一致性差说明算法方差大那点参数优化就不具统计意义。常用的实现是skimage.metrics.structural_similarity和colormath或OpenCV自带Lab距离。要注意SSIM对L通道变化很敏感所以评估色差时应固定L为原灰度只比较ab通道。Welsh的边界也要认清。单张复杂场景、人脸特写或对语义敏感的老照片我会优先用深度图着色模型当粗上色再用Welsh做局部颜色传递微调。Welsh更像一个可解释、可定制的方案用它做批量预览、给人工上色打草稿比硬扛深度学习模型更省心。我自己的习惯是任何项目都先跑通未优化Welsh拿到基线图再上参数优化连基线图都不看就调参最后容易陷进没有对照的“玄学调参”里。希望帮到你。本文还有配套的精品资源点击获取