4. 局部色调映射:基于引导滤波与双边滤波的局部映射
高光压缩这个事,全局映射做完了,你会发现一个问题——亮的地方压下来了,但暗部细节也跟着变平了。说白了,全局映射是一刀切,它不管像素周围是什么情况。所以我们需要局部映射,让每个像素的映射参数根据它所在的局部区域动态调整。
我个人习惯把局部映射分成两大类:基于引导滤波的和基于双边滤波的。这两种方法我都用过,各有各的脾气。今天咱们就把它俩掰开揉碎了讲清楚。
4.1 为什么需要局部映射?
先看一个实际场景。你拍了一张逆光人像,天空过曝,人脸太暗。全局映射把天空压下来,人脸提亮——但天空的云彩纹理没了,人脸也显得灰蒙蒙的。为什么会这样?
因为全局映射只依赖像素本身的亮度值,不考虑它周围是亮区还是暗区。而人眼对局部对比度极其敏感。你想想看,同一块灰色放在白色背景和黑色背景上,我们看起来亮度完全不同。这就是所谓的“视觉同时对比”现象。
所以局部映射的核心思路是:为每个像素计算一个自适应的映射曲线,这个曲线由该像素的局部邻域特征决定。
核心公式(局部映射通用形式):
L_out(x,y) = L_in(x,y) / L_base(x,y) * L_target
其中 L_base 是局部亮度估计,L_target 是目标亮度水平
说白了,就是用局部基频亮度做归一化,再映射到目标范围。
4.2 基于引导滤波的局部映射
引导滤波是我在项目中用得最多的方法。为什么?因为它速度快,而且能很好地保持边缘。我记得第一次在移动端部署HDR算法时,双边滤波跑一帧要200ms,换成引导滤波直接降到30ms——这个差距太明显了。
4.2.1 引导滤波的原理
引导滤波假设输出图像是引导图像的局部线性变换。听起来有点绕,其实很简单:
输出像素 = a * 引导像素 + b
其中 a 和 b 是在局部窗口内通过最小化重建误差求出的系数
这个线性模型的好处是:如果引导图像有边缘,输出图像也会保留边缘。因为边缘两侧的 a 和 b 系数会自适应变化。
| 特性 | 引导滤波 | 双边滤波 |
|---|---|---|
| 时间复杂度 | O(N),与窗口大小无关 | O(N * r²),随窗口增大而增大 |
| 边缘保持 | 优秀(线性模型保证) | 优秀(权重函数保证) |
| 梯度反转 | 几乎不发生 | 可能发生 |
| 实现难度 | 中等(需要积分图) | 简单(直接卷积) |
4.2.2 引导滤波局部映射流程
我一般按下面几步来做:
- 输入准备:将HDR图像转换到对数域,减少动态范围跨度
- 引导图选择:通常用输入图像的亮度通道作为引导图
- 基频提取:用引导滤波对亮度图做平滑,得到 L_base
- 细节层分离:L_detail = L_in - L_base(对数域减法对应除法)
- 基频压缩:对 L_base 做全局映射,压缩动态范围
- 细节重建:L_out = L_base_compressed + L_detail
我的经验:引导滤波的窗口半径 r 和正则化参数 ε 是关键。r 越大,提取的基频越平滑;ε 越大,平滑程度越高。我通常从 r=16, ε=0.01 开始调,效果不对再微调。
4.2.3 代码实现(核心部分)
import cv2
import numpy as np
def guided_filter_local_tone_mapping(hdr_img, r=16, eps=0.01):
# 转对数域
hdr_log = np.log1p(hdr_img)
# 提取亮度通道
luminance = np.mean(hdr_log, axis=2)
# 引导滤波平滑
base = cv2.ximgproc.guidedFilter(
guide=luminance.astype(np.float32),
src=luminance.astype(np.float32),
radius=r,
eps=eps
)
# 分离细节
detail = luminance - base
# 基频压缩(这里用简单的gamma)
base_compressed = np.power(base / np.max(base), 0.45)
base_compressed = base_compressed * np.max(base)
# 重建
output_log = base_compressed + detail
# 恢复RGB
ratio = np.exp(output_log) / np.exp(luminance)
output = hdr_img * ratio[:, :, np.newaxis]
return np.clip(output, 0, 1)
注意:引导滤波在边缘处可能出现轻微的光晕伪影。如果 r 设置太大,光晕会很明显。我曾经在一个夜景项目里把 r 设到64,结果路灯周围全是光圈——后来改成32就好了。
4.3 基于双边滤波的局部映射
双边滤波是局部映射的另一种经典方法。它比引导滤波更直观,但速度慢一些。不过在某些场景下,双边滤波的效果反而更好。
4.3.1 双边滤波的原理
双边滤波的权重由两部分组成:空间距离和像素值差异。公式长这样:
W(i,j) = exp(-|i-j|² / σ_s²) * exp(-|I(i)-I(j)|² / σ_r²)
其中 σ_s 是空间标准差,σ_r 是值域标准差
这个设计的妙处在于:只有空间上接近且亮度也接近的像素才会互相影响。所以边缘两侧的像素不会互相污染,边缘就保住了。
4.3.2 双边滤波局部映射的坑
我刚开始用双边滤波时踩过一个坑——梯度反转。具体表现是:在强边缘附近,平滑后的基频出现“凹陷”或“凸起”,导致最终结果出现伪影。
为什么会这样?因为双边滤波在边缘处只取了一侧的值,平滑结果会偏向边缘的亮侧或暗侧。如果边缘两侧的像素数量不均衡,就会出现偏差。
避坑指南:
- σ_s 不要太大,一般取图像宽度的 2%-5%
- σ_r 根据图像动态范围调整,HDR图像建议取 0.1-0.3(归一化后)
- 如果出现梯度反转,试试用迭代双边滤波,每次只做轻度平滑
4.3.3 双边滤波的加速技巧
原始双边滤波太慢了。我分享几个加速方法:
| 方法 | 原理 | 加速比 |
|---|---|---|
| 下采样+上采样 | 在低分辨率上做双边滤波,再插值回原分辨率 | 4-16倍 |
| 双边网格 | 将图像量化到3D网格,在网格上做卷积 | 10-50倍 |
| 递归双边滤波 | 用递归方式近似双边滤波 | 100倍以上 |
我个人最常用的是双边网格法。它把空间坐标和亮度值映射到一个三维网格,在网格上做高斯滤波,再插值回来。速度快,效果也接近原始双边滤波。
4.4 两种方法的对比与选择
说了这么多,到底该用哪个?我总结一下:
- 追求速度:选引导滤波。移动端、实时处理场景首选。
- 追求效果:选双边滤波。特别是细节丰富、边缘复杂的图像。
- 避免伪影:选引导滤波。梯度反转问题更少。
- 实现简单:选双边滤波。代码量少,调参直观。
我的建议:先试试引导滤波。如果效果不满意,再换成双边滤波。大多数场景下,引导滤波已经够用了。而且OpenCV从4.3版本开始内置了引导滤波,直接用就行。
4.5 本章知识结构图
下面这张图总结了局部色调映射的核心流程和两种方法的对比:
4.6 小结
局部色调映射的核心就是“因地制宜”——每个像素用不同的映射参数。引导滤波和双边滤波是两种最主流的基频提取方法,各有优劣。
我个人建议:先掌握引导滤波,它速度快、稳定、不容易出问题。等遇到效果瓶颈时,再研究双边滤波的精细调参。毕竟在实际项目中,能跑起来的算法才是好算法。