写这个系列写到第九篇,终于要把图像增强里相对“玄”的一支拿出来聊了——Retinex。前八篇我们把灰度变换、直方图均衡化、空域滤波、频域滤波这些基础操作挨个过了一遍,日常的对比度增强、噪声抑制、边缘锐化基本都够用。但如果你手里的图是夜间监控截图、逆光人像、或者室内暗光拍出的产品照片,直方图均衡化很容易把噪声一起放大,拉普拉斯锐化只会让阴影更黑。光在像素值上做文章,已经解决不了“光照不均”这种结构性问题。Retinex消掉光照、保留反射,正是这类场景需要的解法。这篇的核心内容围绕单尺度Retinex(SSR)、多尺度Retinex(MSR)、带颜色恢复的多尺度Retinex(MSRCR)展开,附带MATLAB实现和调参经验,适合已经掌握基础图像处理、想处理低照度或复杂光照图像的读者。
1. 为什么做到第九篇才聊Retinex:先搭好图像增强的知识坐标系
1.1 前八篇走的是“像素值路线”,Retinex换了一条赛道
前八篇的思路本质上都是把像素值当成最终处理对象——直方图均衡化在重新分配灰度级,拉普拉斯算子在做差分,中值滤波在取邻域统计值。这些方法在均匀光照下效果稳定,但光照一旦不均衡,像素值本身就不是单纯的物体属性了。举个例子,同一块白布,暗处拍出来可能是灰度50,亮处能到200,直接做直方图均衡化,暗处的白布和灰布很可能分不开。
Retinex的思路则完全不同。它把图像分解成照度分量和反射分量,认为物体颜色由反射分量决定,照度是外在干扰。先估计光照,再还原反射,这就等于直接把光照不均这个“病因”去掉了,而不是在“症状”上反复调整。这也是为什么我把这个主题放到第九篇——先掌握基础操作,才能理解Retinex是在哪个环节替代了它们。
1.2 Retinex理论的一句话拆解与数学表达
Retinex模型极其简洁:S(x,y) = L(x,y) × R(x,y)。S是传感器看到的图像,L是入射光照,R是物体反射率,取值范围在0到1之间。人眼真正感知的主要是R。Retinex处理就是三步:从S中估计出L,用S除以L,得到R。
为什么通常在对数域计算?一方面乘除运算变成加减,简单很多;另一方面人眼对亮度的感知本身接近对数响应,log域运算更符合视觉模型,还能压缩动态范围。公式写出来就是:
r(x,y) = log(S(x,y)) - log(L_est(x,y))
其中L_est是环绕函数F与S的卷积结果。这里有一个容易踩的坑:log后面通常要加1,防止S为0时出现负无穷。如果后续还要还原到0-255,这个偏移量会影响映射,需要配合拉伸步骤处理,别忽略。
1.3 SSR、MSR、MSRCR选型对比
这三个版本的关系是层层叠加,不是互相替代。
| 算法 | 核心特点 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| SSR | 单一尺度估计光照 | 计算量最小,边缘增强明显 | 光晕明显,动态范围压缩不够,偏色 | 灰度图、快速预览 |
| MSR | 多个尺度加权平均 | 亮暗细节兼顾 | 颜色容易偏灰 | 灰度/彩色增强基础版本 |
| MSRCR | 引入颜色恢复因子 | 颜色自然,接近人眼感知 | 参数多,计算最重 | 彩色图像,视觉质量优先 |
尺度本质是高斯核的宽度。小尺度保留细节但容易产生光晕,大尺度把光照信息压得平整。MSR把多个尺度加权,原理上类似用多个窗口看同一个场景——小窗口看细节,大窗口看全局光照。单独做MSR时,RGB三个通道分别算同样运算,通道间的相对关系会被改变,颜色就漂了。所以MSRCR加上一个颜色恢复因子,效果会自然很多。
2. 核心原理拆解:环绕函数、对数域运算与每一步的“为什么”
2.1 高斯环绕函数为什么必须归一化
环绕函数F(x,y) = λ·exp(-(x²+y²)/c²),它模拟的是一个像素感受到的周围光照。参数c越小,周围像素参与估计的范围越小,估计出的光照越接近细节层;c越大,光照越平坦,更像整体光照。
很多人写代码时忽略了归一化,导致结果整体偏亮或偏暗。因为卷积后得到的L_est应该是S的一个加权平均,如果核的总能量不等于1,L_est就会整体偏大或偏小。log差分之后相当于给图像加了一个常数偏移,虽然最终的拉伸步骤能抵消一部分,但在MSR多尺度叠加时,各尺度的常数偏移不统一,结果会发灰。所以我在MATLAB里用fspecial生成高斯核之后,一定会补一行:g = g / sum(g(:)),确保核和为1。
2.2 对数域先减后加,到底在干什么
很多第一次写Retinex的人会疑惑:log(S) - log(L)之后,为什么不直接exp回去?确实,在理想情况下r≈log(R),exp(r)就是反射率R。但实际估计的L不可能是真实光照的完美拟合,log域差分后得到的值有正有负,动态范围被压缩,直接exp回去会得到对比度很低的灰图。
这一步的处理思路通常是:把r当作一个增强后的对数域图像,做线性映射或分位数截断,再映射回0到1。换句话说,Retinex到这里更像一种特征变换,而不是严格的物理还原。我在实现里用prctile取1%和99%的分位数作为上下界做截断,比min-max全幅度拉伸更能对抗极端亮暗像素。
2.3 从SSR到MSR:多尺度的意义
SSR只用一个尺度,选了小的细节清楚但光照估计不准,选了大的光照平整但细节丢失。MSR的做法是同时跑几个尺度,然后对输出加权求和:
R_MSR = Σ w_n × r_n
常用三个尺度是15、80、250,权重各取1/3。为什么是这三个数?15个像素的窗口看局部纹理,80个像素看中等范围的照明变化,250个像素看全局光照分布。这个组合在很多实践里都验证过,适合自然图像。如果你的图像分辨率特别高或特别低,等比缩放即可,不要死守这三个值。
2.4 Matlab里构建高斯核的细节与边界处理
MATLAB里常见的是fspecial('gaussian', hsize, sigma),但Retinex环绕函数用的是exp(-r²/c²),和fspecial的exp(-r²/(2σ²))差了一个2。所以如果用fspecial,sigma要取c/sqrt(2)。这是非常容易出问题的地方,参数对不上,效果天差地别。
边界处理也是坑。卷积时边界附近可用像素少,默认的zero padding会让图像四周发黑。我建议imfilter指定'replicate',用边缘值向外复制,能明显减少黑边。还有一个更彻底的办法,先用padarray向外扩展一圈,处理完再裁掉,效果更干净,代价是多写两行代码。
3. 实操:在MATLAB里从零实现SSR与MSRCR(附完整代码)
3.1 读图、归一化、转double:预处理三步
MATLAB读图进来可能是uint8,直接做log和卷积会出问题。第一步用imread,第二步转double,第三步归一化到[0,1]。顺序不要反:先转double再除以255。为什么非要转double?一是imfilter对double类型能正确处理小数卷积,二是log(0)的问题可以在double数据上做偏移处理,uint8的取整误差在暗图像上会被放大。
img = imread('low_light.jpg'); img = im2double(img); % 直接把uint8转成[0,1]的double如果图像是灰度图,三通道处理的逻辑也同样适用,只是循环只跑一次。
3.2 SSR函数完整代码与逐段说明
这里给出的SSR实现,输入是double类型的RGB图像,输出是处理后的对数域结果。注意内部用c/sqrt(2)做了高斯核换算,避免前面说的那个坑。
function out = ssr(img, c) % img: double型,范围[0,1] % c: 尺度参数,例如15、80、250 [h, w, ~] = size(img); out = zeros(size(img)); for ch = 1:3 channel = img(:, :, ch); % 构建全图尺寸的高斯环绕核 % Retinex环绕函数是 exp(-r^2/c^2),而fspecial用的是 exp(-r^2/(2*sigma^2)) % 所以 sigma = c / sqrt(2) g = fspecial('gaussian', [h w], c / sqrt(2)); g = g / sum(g(:)); % 归一化,保证核能量为1 % 估计光照分量:空域卷积 L = imfilter(channel, g, 'replicate', 'conv'); % 对数域差分:log(S) - log(L) r = log(channel + 1) - log(L + 1); out(:, :, ch) = r; end end关键点有两个。一个是核大小直接取[h, w],因为Retinex理论要求全图卷积,不能像普通高斯平滑那样取一个小窗口。小窗口卷积只考虑局部邻域,无法估计出整体光照。另一个是imfilter的边界选项'replicate',它能减少黑边,效果比默认的零填充好得多。
3.3 MSRCR完整代码与颜色恢复参数
MSRCR就是在MSR的基础上,每个通道乘一个颜色恢复因子C。这个因子的核心思路大致是:如果某通道像素值在当前像素的总亮度中占比高,就保留甚至增强这个通道,占比低就适当抑制。经典参数α=125、β=46,但这两个参数是面向普通场景的经验值。我自己实测时发现,偏色严重的图需要把β调小到20到30,颜色恢复会更平缓;暗光图适当调小α也有明显改善。
function out = msrcr(img, scales, alpha, beta) % img: double型RGB,范围[0,1] % scales: 三个尺度的数组,例如 [15 80 250] % alpha: 颜色恢复调节参数,经典取125 % beta: 颜色恢复增益,经典取46 n = length(scales); [h, w, ~] = size(img); msr_out = zeros(size(img)); % 第一步:多尺度Retinex for ch = 1:3 channel = img(:, :, ch); msr = zeros(h, w); for k = 1:n g = fspecial('gaussian', [h w], scales(k) / sqrt(2)); g = g / sum(g(:)); L = imfilter(channel, g, 'replicate', 'conv'); msr = msr + (log(channel + 1) - log(L + 1)) / n; end msr_out(:, :, ch) = msr; end % 第二步:颜色恢复因子 out = zeros(size(img)); sum_img = img(:, :, 1) + img(:, :, 2) + img(:, :, 3) + 3; for ch = 1:3 C = beta * (log(alpha * img(:, :, ch) + 1) - log(sum_img)); out(:, :, ch) = C .* msr_out(:, :, ch); end % 第三步:分位数截断拉伸 for ch = 1:3 ch_data = out(:, :, ch); lo = prctile(ch_data(:), 1); hi = prctile(ch_data(:), 99); out(:, :, ch) = (ch_data - lo) / max(hi - lo, 1e-6); out(:, :, ch) = max(0, min(1, out(:, :, ch))); end end第三步的拉伸极其重要。如果不做分位数截断,直接imshow,你大概率看到一张灰蒙蒙的图。1%和99%的分位数截断能拉出有效对比度,又不让极端亮暗像素主导整个映射范围。
3.4 输出图像处理:截断拉伸与对比度矫正
Retinex输出经常是灰蒙蒙的,直接imshow几乎看不清。我的习惯是:先做1%到99%分位数截断,再线性映射到[0,1],最后用imadjust收尾。注意不要上来就histeq,Retinex输出已经改变了直方图形状,再用直方图均衡化会把刚才保留的细节再次推平。
如果是偏暗的结果,我一般会再补一步Gamma校正,gamma取0.8到0.9,能把暗部细节再提一提,而不会把亮部直接打爆。这个组合我用了很多次,比单纯调Retinex参数要可控。
3.5 实测:一张低照度夜景的增强效果观察
说一次实测体会。我拿了一张走廊监控角度的夜景截图,原图暗部接近纯黑,角落里的杂物轮廓完全看不清。直方图均衡化之后,噪声被放大得很厉害,画面发灰。改用MSRCR之后,墙壁纹理和角落轮廓清楚了很多,颜色也没有明显偏色。
对比SSR和MSRCR,SSR出图偏灰且细节略生硬,MSRCR色彩自然很多,但暗部细节反而比MSR少一点。这符合原理——颜色恢复因子在还原色彩的同时,会压缩一部分亮度差异。所以如果只要看细节,MSR就够了;如果要交付给人看,MSRCR更合适。
4. 同态滤波:另一个攻光照不均的经典思路(含MATLAB代码)
4.1 频域怎么看待光照分量
Retinex在空域估计光照,同态滤波则从频率角度切分:光照分量变化缓慢,对应低频;反射分量包含细节和边缘,对应高频。用一个高通形态的滤波器,把低频压下去、高频抬起来,就能抑制光照影响同时增强细节。
这里要理解一个关键点:降低低频不是完全滤除,而是一种非均匀放大。如果直接把低频清零,图像会丢失整体的明暗关系,看起来非常不自然。同态滤波的“高”和“低”是相对的概念,靠两个参数控制放大倍数。
4.2 同态滤波器的构造参数与MATLAB实现
滤波器形式为:
H(u,v) = (γH - γL)·[1 - exp(-c·D²/D0²)] + γL
参数含义:γH > 1控制高频放大倍数,γL < 1控制低频压制,D0是截止频率,c是过渡陡峭度。典型参数γH=2.5、γL=0.5、D0=80、c=4,但D0要根据图像分辨率调整,分辨率翻倍时D0也要相应增加。
function out = homomorphic_filter(img, gamma_hi, gamma_lo, c, D0) % 灰度图同态滤波,彩色图建议先在HSV的V通道上使用 if size(img, 3) == 3 img = rgb2gray(img); end img = im2double(img); % 对数变换,进入频域前先压缩动态范围 z = log(img + 1); [h, w] = size(z); % 生成频率坐标,注意要中心化 u = (0:h-1) - floor(h/2); v = (0:w-1) - floor(w/2); [V, U] = meshgrid(v, u); D = sqrt(U.^2 + V.^2); % 构造同态滤波器 Hf = (gamma_hi - gamma_lo) * (1 - exp(-c * (D.^2) / (D0^2))) + gamma_lo; % FFT、滤波、逆FFT F = fftshift(fft2(z)); G = F .* Hf; s = real(ifft2(ifftshift(G))); % 指数还原,再做对比度拉伸 out = exp(s) - 1; out = mat2gray(out); end代码里有两个细节要注意。fftshift和ifftshift要配对使用,不能用错,否则频率中心对不上,结果会出现“棋盘”状的亮暗条纹。另外,逆变换后会有微小的复数残留误差,取real是必要的,直接用复数结果会报错。
4.3 Retinex与同态滤波的适用范围对比
| 对比项 | Retinex(SSR/MSR/MSRCR) | 同态滤波 |
|---|---|---|
| 光照估计视角 | 空域卷积,局部加权 | 频域滤波,全局频率 |
| 对偏色的影响 | 不做颜色恢复时偏色明显 | 彩色图需转HSV处理 |
| 参数数量 | 尺度、权重、颜色恢复多组 | 4个参数 |
| 运算效率 | 多尺度大核卷积,较慢 | 两次FFT,较快 |
| 光晕伪影 | 亮暗交界处容易出现 | 过渡平滑,光晕少 |
| 典型场景 | 低照度细节恢复 | 均匀光照校正、老照片提亮 |
光照特别不均匀、阴影边界明显的图,Retinex通常效果更直接。同态滤波胜在平滑自然,几乎没有光晕,但参数不熟时容易把图像搞得发灰发暗。两张图都值得跑一遍,挑效果好的用。
5. 常见问题排查与调参实测记录
5.1 结果发灰,暗部还是看不清
最常见的原因是没做分位数截断,让极端亮暗像素拉低了整体对比度。另一个原因是log偏移量过大,r的范围整体偏向负值。解决方式是确保卷积核归一化,做1%到99%截断。我测试时还发现尺度调太大会让结果更灰——比如把尺度从15改成30,光照估计更平滑,细节增强更弱,画面对比度就下来了。
5.2 边缘发黑、出现halo伪影
黑边基本是卷积边界处理不当,用'replicate'能解决。halo伪影则是Retinex的通病,尤其在亮暗交界明显的地方,会出现一圈光晕。我的经验是尺度不要超过图像短边的四分之一,可以明显改善。如果想精雕细节,可以用引导滤波替代大尺度高斯卷积做光照估计,光晕能大幅减少,但代码复杂度会上升不少。
5.3 颜色偏紫偏灰,MSRCR参数怎么救
先检查三通道是否在同一个log偏移下处理。如果每个通道单独拉伸后合并,极容易偏色。正确做法是三通道共享同一个拉伸映射,或者MSRCR输出后再一起拉伸。颜色恢复因子β调小、α调大,可以减少“灰罩子”的感觉。还有一招,彩色图先转到HSV,只对V通道做Retinex,H和S保持不动,颜色保真度很好。缺点是这种方法本质是亮度增强,对色偏修正帮助不大。
5.4 大图跑得慢:三种提速方案
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 先缩放再放大 | 把图缩小到256或512处理,再插值放大 | 实现最简单 | 细节有损 |
| 频域实现高斯滤波 | 用fft2构建频域高斯低通替代imfilter | 速度最快 | 代码改动中等 |
| 只处理V通道 | HSV转换后只增强V通道 | 速度接近单通道 | 对颜色校正不生效 |
MATLAB里imfilter虽然会自动选择算法,但全尺寸高斯核还是慢。用频域乘法做高斯滤波,和imfilter结果几乎一致,大图强烈推荐。另外,三通道循环体用single类型跑,内存和速度都有明显改善,double虽然精确但真没必要。
5.5 排查速查表
整理一个速查表,遇到问题直接对号入座。
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 输出全灰 | 没做分位数截断 | 补1%-99%截断 |
| 图像整体偏暗 | log偏移不够或Gamma问题 | 加Gamma=0.85 |
| 边缘黑框 | 卷积边界用零填充 | 改用'replicate' |
| 亮暗交界光晕 | 尺度太大 | 尺度降到短边1/4 |
| 颜色严重偏紫 | 通道独立拉伸 | 共享拉伸映射 |
| 颜色灰蒙蒙 | 颜色恢复因子参数不合适 | β降到20-30 |
| 运算极慢 | 全尺寸核空域卷积 | 频域实现或先缩图 |
这篇从Retinex的理论模型讲到MATLAB实现,再对比了同态滤波,核心目的就是让大家在遇到光照不均时,不再只会套直方图均衡化。我做了这么多组对比,最深的体会是Retinex不是万能的,但对低照度、逆光这类图确实比传统方法强一个量级,代价是参数调起来确实麻烦。如果你只是为了快速看效果,建议先把SSR跑通,再上MSRCR;如果项目里要批量处理,同态滤波的稳定性和速度反而更适合做成标准流程。最后分享一个小技巧:Retinex输出后再做一次Gamma校正,通常把gamma设到0.8到0.9,高光细节能保下来不少,这是我在处理过曝区域时屡试不爽的收尾步骤。