基于优化的灰度图像自动着色:YUV空间与稀疏矩阵求解实战
2026/9/9 8:21:52 网站建设 项目流程

简介:这是一套面向图像处理与计算机视觉学习者的MATLAB开源着色项目,聚焦灰度图像自动上色与颜色恢复。资源包约891KB,页面上未列出文件总数与具体类型明细;从项目性质看,内容以MATLAB脚本、示例图像和使用说明为主。目前已有138人学习浏览。项目覆盖灰度图像转换、预处理与多种着色流程:既可用rgb2gray理解灰度化公式,用imsegkmeans做区域聚类,也能借助高斯滤波、Canny边缘检测提取图像结构,并尝试最近邻法、色彩扩散或深度学习CNN完成颜色推断。除经典算法外,还可对比基于纹理、深度和邻域信息的着色策略;同时项目采用开源许可,便于查看、修改和二次分发。开发者既能学习图像分割、特征提取等经典操作,也能实践从灰度到彩色的算法设计与调参,适合作为MATLAB图像处理课程设计或计算机视觉入门项目的参考。 开头先聊点实在的。灰度图着色这个方向,我在 MATLAB 里折腾过很久,最早是被图像处理大作业逼的,后来自己接了好几个相关的小项目,越做越觉得这个“Colorization-project”有点意思。它本质上不是让你用 PS 一笔一笔涂色,而是通过程序把黑白照片自动上色,或者说让用户给几个颜色提示,剩下的颜色由算法自己“脑补”出来。如果你手里正好有一批老照片、灰度图,或者你在做图像处理、计算机视觉相关的课程项目,这个项目完全可以当作一个高质量练手材料,既能深入理解图像邻域结构,又能把稀疏矩阵求解、颜色空间转换这些基本功练扎实。这篇东西会把整个思路、算法细节、实操步骤、踩坑记录全部分享出来,跟着做一遍,你能真正跑出一个能用的着色工具,而不是仅仅看了个概念。

1. 这个着色项目到底在解决什么问题

1.1 为什么灰度图像着色不是“填色”那么简单

先把问题定义清楚。这里的“着色”不是指把某个区域统一涂成纯色,而是给定一张已经丢失颜色信息的灰度图,算法要根据亮度和纹理结构,推测出每个像素原本可能的颜色。你可以尝试用 PS 的笔刷手动上色,一张人脸可能要花几个小时,而且颜色过渡很难自然。而 Colorization-project 这类算法的思路是:用户只需要在图片上随意画几笔颜色(称为 color scribbles,颜色涂鸦或者颜色提示),算法就自动把这些颜色传播到整张图片。

为什么传播是可行的?这里有一个很朴素但极度关键的假设:在图像里,如果两个像素的亮度(灰度值)相近,那么它们的颜色也应该相近。换句话说,颜色不是凭空产生的,它跟亮度信息有很强的空间相关性。这个假设在大多数自然图像上是成立的,比如皮肤的灰度值通常在一个连续范围,对应的肤色也应该连续。基于这个假设,着色问题就变成了一个约束优化问题:已知少量像素的颜色(用户涂鸦处),未知大量像素的颜色,利用“亮度相似则颜色相似”的约束,把所有未知颜色求出来。

当时我第一次看到这个思路时觉得很妙,它把“上色”这个听起来很艺术的事情,转化成了一个数学上完全可解的问题。这也解释了为什么很多经典论文(比如 Levin 等人在 2004 年发表的 Colorization using Optimization)能成为这个领域的基石。

1.2 为什么坚持用优化方案而不是深度学习

现在的趋势是一提到图像上色就用深度学习模型,比如基于卷积神经网络的自动着色。但 Colorization-project 用的是经典的优化方法,很多人会问:为什么不直接上深度学习?

我个人的看法是,这两种方案解决的是不同的问题。深度学习方案擅长“无中生有”,它见过几万张自然图像,能直接预测一个多彩的合理结果,缺点是可控性较差,用户很难精确告诉模型“这个帽子要红色,那个衣服要蓝色”。而优化着色的核心优势恰恰在于交互可控:用户画的每一笔颜色都是硬约束,算法必须严格满足,其余部分顺势传播。这种感觉就像给黑白电影手工修复一样,制作者掌握最终决定权。

另外从学习角度来说,优化着色把图像处理里最核心的几个知识点串起来了:颜色空间转换、邻域结构分析、稀疏线性方程组的构造与求解。这些是深度学习方法给不了的底子。所以这个项目不光是做出来一个玩具,它是一块很好的“跳板”——你把优化着色的原理吃透了,后面再看自动着色模型,理解深度是完全不同的。这也是我喜欢把它分享出来的原因。

2. 核心细节解析:算法里的关键假设与工程实现

2.1 YUV颜色空间:为什么不用RGB

很多第一次做这个项目的同学会直接在 RGB 空间里算颜色传播,结果总是不对劲。原因在于 RGB 三个通道是高度相关的,直接把三个通道当成独立变量去求解,会出现颜色溢出、边界模糊等奇怪现象。

这个项目里采用的颜色空间是 YUV(或者 YCbCr)。简单解释一下:Y 通道是亮度信息,就是你从灰度图里看到的东西;U 和 V 两个通道是色度信息,负责颜色。之所以用 YUV,目标非常明确——我们已知的就是 Y 通道(灰度图),需要求解的是 U、V 通道。这样就把问题分离开来:Y 是已知的固定量,U 和 V 是两个未知量,各自独立求解,互不干扰。这比在 RGB 空间里同时约束三个通道、还得考虑通道间关系要干净得多。

具体在 MATLAB 里的实现,可以用 rgb2ycbcr 函数直接把彩色参考图转成 YUV,也可以手动用矩阵变换。代码如下:

% RGB转YUV (YCbCr) img = imread('input.png'); img_yuv = rgb2ycbcr(img); Y = img_yuv(:, :, 1); % 亮度 U = img_yuv(:, :, 2); % 蓝色差 V = img_yuv(:, :, 3); % 红色差

这里要注意一点:如果输入本身就是灰度图,那就直接把它当作 Y 通道,初始化一个全零的 U、V 通道,然后填充用户涂鸦的颜色信息。整个算法只重建 U、V,最后再通过 ycbcr2rgb 转回 RGB 显示。

2.2 邻接关系与权重计算:颜色传播的“通道”

优化着色的核心结构是像素之间的邻接图。把每个像素看成图上的一个节点,两个相邻像素之间有一条边,边的权重表示“它们颜色应该有多接近”。权重不是随便定的,它是根据亮度差计算的。

标准的做法是:对于像素 r,满足某个条件时,需要在求解矩阵里加上一个元素;当两个像素的亮度差值越大,权重越小;亮度越接近,权重越大。典型权重公式是:

w(r,s) = exp( -(Y(r) - Y(s))^2 / (2 * sigma^2) )

其中 sigma 是控制衰减速度的参数。这个公式本质上很像高斯函数,因为自然图像中亮度连续变化的区域占大多数,亮度差小的像素权重高,颜色传播就更顺畅。所以说白了,颜色传播就相当于在图上做一个“加权扩散”:你画一笔红色,这个红色会沿着亮度相似的像素“渗开”,直到被亮度差异大的边界挡住。这就解释了为什么边缘保持效果会好——亮度突变处权重接近 0,颜色过不去。

实现的时候,一个更稳健的做法是不仅考虑当前像素跟正上方、正右方的像素关系,而是把每个像素的 4 邻域(上下左右)甚至 8 邻域都纳入计算。我实测下来,4 邻域已经是基准配置,8 邻域在某些纹理丰富的图上更好,但矩阵规模更大,计算时间长。核心权衡点在于:邻域越广,颜色互相影响的范围越大,就越可能出现“跨界污染”。

还有一个关键细节:权重计算时用的不是原始灰度值,而是加了一点线性拉伸的亮度值。原因是某些图片过暗或过亮,直接算高斯会亮瞎差不多的像素。可以在预处理阶段做一次 imadjust 拉伸,效果会稳定很多。

2.3 稀疏矩阵求解:从公式到代码

当把每个像素都列出来,并让每个像素跟邻域之间的颜色关系满足“亮度相似则颜色相似”的约束后,整个问题就变成了解一个大型线性方程组。具体来说,对每个未知颜色的像素 i,我们希望它的颜色值(以 U 通道为例)和邻域颜色加权平均尽可能接近:

U(i) = sum( w(i,j) * U(j) ) / sum( w(i,j) )

对于用户画过颜色的像素,直接把颜色值固定为输入值。把所有这样的式子整理成矩阵形式,就得到了 A * U = b,其中 A 是一个稀疏矩阵,b 是包含用户涂鸦信息的向量。然后只需要在 MATLAB 里用 A\b 求解即可。

这里我必须强调一个非常重要的工程经验:必须用稀疏矩阵,绝对不能用全矩阵。假设图片是 500x500,总共 25 万个像素,如果构造一个 25 万行乘 25 万列的满矩阵,内存直接爆炸,MATLAB 直接卡死。好在 MATLAB 的 sparse 函数就是为这种情况设计的,它只存储非零元素,内存占用大幅下降。代码结构上要注意,先用行索引、列索引、值三个数组收集非零元素,最后再用 sparse 一次性构造,这比循环里赋值要快很多。

3. 实操过程与核心环节实现

3.1 从灰度图到着色结果的完整流程

一个完整可用的 Colorization-project 流程,我整理成了六步:

  1. 读入灰度图,并转换成 double 类型。
  2. 构造用户涂鸦(交互式画笔画颜色,或者在代码里直接指定某些像素的颜色值)。
  3. 计算每个像素的邻域权重,构建稀疏矩阵。
  4. 分别求解 U、V 通道的线性方程组。
  5. 将 Y、U、V 合并,转回 RGB。
  6. 显示结果图,和原灰度图对比。

整个流程最关键、也最容易出错的是第三步。如果权重矩阵算错了,后面所有颜色都白搭。我的建议是:先在一个很小的图上测试(比如缩小到 100x100),手动给几个点的颜色,直接用代码单步调试权重矩阵,确认数值合理,再跑到全尺寸图上。

3.2 关键代码逐段拆解

这一部分把核心代码完整展示出来,并逐段解释,方便直接照抄修改。

第一步,读取图像并初始化:

img_gray = imread('gray_image.png'); if size(img_gray, 3) == 3 img_gray = rgb2gray(img_gray); % 防止输入是RGB图 end [rows, cols] = size(img_gray); Y = double(img_gray); % 亮度,范围0~255 U = zeros(rows, cols); % 色度U,初始为0 V = zeros(rows, cols); % 色度V,初始为0 N = rows * cols; % 像素总数

第二步,设置用户涂鸦。这里我提供一个简单的区域涂鸦方法,比如把图片中心区域设为肤彩色,把天空区域设为蓝色:

% 假设通过鼠标交互标记颜色点,这里示例用矩形区域 mask = false(rows, cols); mask(50:150, 100:200) = true; % 手动标记区域 U_known = zeros(rows, cols); V_known = zeros(rows, cols); % 这两个数组存放已知点颜色,未标记点为0 % 然后在mask区域内填入你期望的U、V值,例如: U_known(mask) = 120; % 这个值来自对应参考色的YCbCr分量 V_known(mask) = 130;

第三步,构建邻接矩阵与权重。这部分是算法核心,我建议分成两个函数来写:一个负责返回像素的标识编号,另一个负责构建稀疏矩阵。如下:

% 像素线性编号 id = @(i,j) (i-1)*cols + j; rows_idx = zeros(1, N*4); % 预留空间 cols_idx = zeros(1, N*4); vals = zeros(1, N*4); cnt = 0; sigma = 10; % 权重衰减参数,可调 for i = 1:rows for j = 1:cols % 只处理4邻域中右边的和下边的像素,避免重复 if j < cols w = exp( -(Y(i,j) - Y(i,j+1))^2 / (2*sigma^2) ); if w > 0.001 % 太小的权重直接丢弃,保证稀疏性 cnt = cnt + 1; rows_idx(cnt) = id(i,j); cols_idx(cnt) = id(i,j+1); vals(cnt) = -w; % 注意矩阵A中非对角元素是负权重 cnt = cnt + 1; rows_idx(cnt) = id(i,j+1); cols_idx(cnt) = id(i,j); vals(cnt) = -w; end end if i < rows w = exp( -(Y(i,j) - Y(i+1,j))^2 / (2*sigma^2) ); if w > 0.001 cnt = cnt + 1; rows_idx(cnt) = id(i,j); cols_idx(cnt) = id(i+1,j); vals(cnt) = -w; cnt = cnt + 1; rows_idx(cnt) = id(i+1,j); cols_idx(cnt) = id(i,j); vals(cnt) = -w; end end end end

这里只是记录了非对角线上的权重。对角线上的元素,需要在最后加一个矩阵,让每行的非对角元素之和的相反数落在对角线上。完整构造语句如下:

A_sp = sparse(rows_idx(1:cnt), cols_idx(1:cnt), vals(1:cnt), N, N); % 修正为每行对角元素 = 该行所有非对角元素负值之和 A_sp = A_sp - diag(sum(A_sp, 2)); % 这一步让每行和为零

为什么每行和必须为零?因为你要求解的本质是拉普拉斯方程:未知点的颜色等于周围颜色的加权平均。反映在矩阵上就是该行的行和必须等于 0,这是很多代码不一定提的细节。如果忘了这步,解出来会整体偏色。

然后添加奇异方程:把已知颜色的像素固定住。方法是在对角线对应位置加上一个很大的 C(比如 1e8),并在 b 向量里写成已知颜色乘 C:

b_U = zeros(N, 1); b_V = zeros(N, 1); for i = 1:rows for j = 1:cols if mask(i,j) idx = id(i,j); A_sp(idx, idx) = A_sp(idx, idx) + 1e8; b_U(idx) = U_known(i,j) * 1e8; b_V(idx) = V_known(i,j) * 1e8; end end end

最后一步:求解和重建:

U_vec = A_sp \ b_U; V_vec = A_sp \ b_V; U = reshape(U_vec, rows, cols); V = reshape(V_vec, rows, cols); YUV_final = cat(3, Y, U, V); rgb_final = ycbcr2rgb(uint8(YUV_final)); figure, imshow(rgb_final);

3.3 参数调节与效果优化

这个项目里影响最明显的参数有三个:sigma、网格大小和用户涂鸦的密度。

sigma 控制权重衰减速度。太小(比如 1),只有亮度几乎一样的像素才会互相传递颜色,结果会出现大量孤立色块;太大(比如 100),亮度差很远的像素也互相影响,颜色会“糊成一团”且跨边界溢出。我自己的经验是先取 5 到 15 之间,根据图像噪音程度调节。图像噪音大,亮度差值本来就不稳定,sigma 要适当增大来提高抗噪性。

网格大小直接影响计算量。如果原图很大(超过 1000x1000),建议先降到 300x400 左右做实验,参数确定后再跑原图。甚至可以做一个由粗到细的多分辨率策略:先在低分辨率图求出 U、V 结果,再放大当作高分辨率图的初值,这样能大幅减少迭代次数。

还有一个比较容易被忽略的点:灰度图如果是 uint8 类型的,直接参与计算会溢出。一定要先转成 double 再处理,最后结果再转回 uint8 显示。我见过不止一个同学因为忘记转换,出来的图全是黑白条纹。

4. 常见问题与排查技巧实录

4.1 运行慢、内存爆掉怎么办

这个项目的核心计算瓶颈在稀疏矩阵构造和线性方程组求解。如果你发现 MATLAB 卡死,大概率不是算法问题而是代码写法问题。首先确认矩阵用的是 sparse 类型,可以在命令行输入 whos 查看变量内存占用。其次,尽量不要在 for 循环里一行一行添加 sparse 矩阵,正确做法是先收集三个数组(行索引、列索引、值),一次调用 sparse 构造。这一点我可以说是踩过大坑的:第一次用 1000x1000 的图,逐像素循环赋值,直接跑了一个小时还没跑完;改成收集数组一次性构造后,几秒钟就完成了。

另外一个实用的替代方案是换求解器。默认的 A\b 对对称正定矩阵会走 Cholesky 分解,对稀疏矩阵效果不错。如果你的 MATLAB 版本支持,可以尝试 pcg(预条件共轭梯度法)配合不完全 Cholesky 预条件子,对大图内存友好很多。

4.2 着色结果颜色溢出或者边界发糊

颜色溢出的根源在于权重计算时只考虑了亮度差异,没有考虑空间距离。如果你发现平滑区域的边界也被“染色”了,可以加上空间距离因素,比如把权重改成:

w = exp( -(亮度差^2) / (2*sigma^2) ) * exp( -(空间距离^2) / (2*sigma_d^2) )

不过对于 4 邻域来说空间距离只有 1 个像素,加不加其实区别很小。真正导致溢出的是涂鸦太靠近边界,或者涂鸦覆盖了不同物体的边缘。解决方式是把涂鸦画在目标区域内部偏中央的位置,不要贪心画太大。

边界发糊还有一个容易被忽略的原因:YUV 转换时,uint8 和 double 混用。ycbcr2rgb 内部会做一些偏移,如果输入输出类型不匹配,可能导致色度通道像素值被截断,表现出来就是边缘模糊。我的习惯是统一使用 double 参与计算,只在最后一步转 uint8。

4.3 求解结果出现 NaN 或全黑图

这个情况我遇到过一次,排查了很久,最后发现是稀疏矩阵里有孤立的行——某个像素和它的所有邻域权重都被阈值裁掉了(w <= 0.001),导致矩阵该行只有对角元素,求解出的结果就是 NaN。解决办法包括:降低权重阈值(比如 0.0001),或者保证每个像素至少在两个方向上有连接。更稳妥的做法是在构造矩阵后检查一下每一行非零元素的数量,如果发现有行为空,就强制给它连上最近的邻域。

另外,全黑图大概率是 ycbcr2rgb 之前的 Y、U、V 取值范围不对。YCbCr 空间中 Y 范围是 16~235,Cb、Cr 范围是 16~240。如果你直接把 double 型的 Y 从 0~255 送入 ycbcr2rgb,转换结果可能被截断。解决办法是先把 Y 做 clamp 到 0~255,再转到 uint8。

4.4 用户交互涂鸦的体验优化

如果项目不是纯代码,而是要求做成交互式界面(比如用到的 App Designer),涂鸦的方式很影响使用体验。我建议用以下策略:用户按住鼠标左键在图上拖动,把鼠标经过路径上的像素记录为涂鸦点,同时打开一个调色板让用户选择颜色。在代码层面,可以把涂鸦点分成两类:一类是“硬约束”,严格固定颜色(权重加大到 1e8);另一类是“软约束”,只是引导色(权重设为 1 到 100)。软约束的好处是允许算法在局部细节上偏离用户选择,让结果更自然。

还有一个交互技巧:涂鸦的颜色值要用 YUV 空间的值。用户从调色板选的是 RGB 值,必须先用 rgb2ycbcr 转换后再赋值给 U_known 和 V_known。这一步千万别用 RGB 直接赋值,否则最后颜色会跑偏。我第一次做界面时就是直接给了 RGB,结果涂鸦区域的颜色跟预期差了十万八千里。

个人实操里的一点延伸

这个项目做完后,可以非常自然地向三个方向扩展。第一是自动生成初始涂鸦:用显著性检测找到画面中的主体区域,自动赋予一个颜色,剩下的区域靠算法传播,这就变成了半自动着色。第二是加入深度信息辅助,比如用双目匹配得到的深度图来约束权重,同一深度的区域互相传播颜色更可靠。第三是结合深度学习做后处理:先自动着色出一个结果,再让用户在上面修正涂鸦,继续优化传播,也算是一种“人机协同”。我个人在复现这个经典算法的过程里,最大的收获不是写出了一堆能跑的代码,而是真正理解了图像中颜色与亮度的内在关系,这种底层的体会是调包深度学习模型给不了的。如果你也把整个项目从头到尾跑通一遍,你一定会跟我有同样的感觉。

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

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

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

立即咨询