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 引导滤波局部映射流程

我一般按下面几步来做:

  1. 输入准备:将HDR图像转换到对数域,减少动态范围跨度
  2. 引导图选择:通常用输入图像的亮度通道作为引导图
  3. 基频提取:用引导滤波对亮度图做平滑,得到 L_base
  4. 细节层分离:L_detail = L_in - L_base(对数域减法对应除法)
  5. 基频压缩:对 L_base 做全局映射,压缩动态范围
  6. 细节重建: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 本章知识结构图

下面这张图总结了局部色调映射的核心流程和两种方法的对比:

局部色调映射知识结构图 局部色调映射:引导滤波 vs 双边滤波 HDR输入图像 对数域转换 引导滤波平滑 参数: r, ε 双边滤波平滑 参数: σ_s, σ_r 细节层 = 原图 - 基频 细节层 = 原图 - 基频 基频压缩(Gamma映射) 基频压缩(Gamma映射) 输出 = 压缩基频 + 细节 引导滤波:O(N) 速度快,无梯度反转 双边滤波:效果好,但需注意参数选择

4.6 小结

局部色调映射的核心就是“因地制宜”——每个像素用不同的映射参数。引导滤波和双边滤波是两种最主流的基频提取方法,各有优劣。

我个人建议:先掌握引导滤波,它速度快、稳定、不容易出问题。等遇到效果瓶颈时,再研究双边滤波的精细调参。毕竟在实际项目中,能跑起来的算法才是好算法。