3、HDR合成基础:多帧曝光原理、图像对齐算法与鬼影去除

HDR合成,说白了就是让一张照片同时保留亮部和暗部的细节。单张照片的动态范围有限,这是物理限制。那怎么办?拍多张不同曝光的照片,把它们合成到一起。这就是多帧曝光HDR的核心思路。

我刚开始做HDR时,觉得不就是把几张图叠在一起吗?后来发现,事情远没那么简单。你手稍微抖一下,或者画面里有东西在动,合成结果就会出现重影、模糊。嗯,今天我们就来拆解这三个核心问题:多帧曝光原理、图像对齐算法、鬼影检测与去除。

3.1 多帧曝光原理

为什么要拍多帧?因为一张照片的传感器,一次只能记录有限范围的亮度。暗的地方没信号,亮的地方过曝。我们拍一张欠曝的(保留亮部细节)、一张正常的、一张过曝的(保留暗部细节),然后通过算法把它们融合。

典型的曝光策略是这样的:

曝光级别 EV值偏移 作用 典型场景
欠曝帧 -2 EV ~ -3 EV 保留高光细节(天空、灯光) 逆光、日落
正常帧 0 EV 保留中间调细节 常规光照
过曝帧 +2 EV ~ +3 EV 保留阴影细节(暗部、背光面) 室内暗角、夜景

我个人习惯用3帧或5帧。3帧是最低配置,5帧能提供更宽的动态范围和更平滑的融合。但帧数越多,对齐难度越大,鬼影也越多。这是个trade-off。

经验之谈: 拍摄时尽量保持相机稳定。三脚架是最好的朋友。手持拍摄的话,快门速度不要低于1/30秒,否则对齐算法会非常吃力。

3.2 图像对齐算法

多帧拍摄时,相机不可能完全不动。哪怕你用了三脚架,按快门时的震动也会导致像素偏移。所以,对齐是HDR合成的第一步,也是最关键的一步。

对齐算法主要分两类:光流法和特征匹配法。我分别说说。

3.2.1 光流法

光流法,说白了就是计算每个像素点在两帧之间的运动矢量。它假设像素的亮度在运动前后保持不变,然后通过求解一个优化问题来得到每个点的位移。

常用的光流算法有:

  • Farneback光流:基于多项式展开,速度快,适合实时处理。
  • Lucas-Kanade光流:基于局部窗口,适合小位移。
  • 深度学习光流(如FlowNet、RAFT):精度高,但计算量大。

我在项目中遇到过一个问题:光流法对纹理丰富的区域效果很好,但在天空、白墙这些平坦区域,它容易跑偏。因为平坦区域没有足够的梯度信息来约束运动估计。

避坑指南: 我曾经在夜景HDR项目里直接用Farneback光流,结果合成出来的天空全是条纹。后来发现是光流在暗部噪声区域产生了错误匹配。解决办法是:先做降噪,再算光流。

下面是一个简单的Farneback光流对齐代码示例:

import cv2
import numpy as np

def align_farneback(ref_img, target_img):
    # 转为灰度图
    ref_gray = cv2.cvtColor(ref_img, cv2.COLOR_BGR2GRAY)
    target_gray = cv2.cvtColor(target_img, cv2.COLOR_BGR2GRAY)
    
    # 计算光流
    flow = cv2.calcOpticalFlowFarneback(
        ref_gray, target_gray, None,
        pyr_scale=0.5, levels=3, winsize=15,
        iterations=3, poly_n=5, poly_sigma=1.2,
        flags=0
    )
    
    # 根据光流重映射
    h, w = ref_gray.shape
    map_x, map_y = np.meshgrid(np.arange(w), np.arange(h))
    map_x = map_x.astype(np.float32) + flow[..., 0]
    map_y = map_y.astype(np.float32) + flow[..., 1]
    
    aligned = cv2.remap(target_img, map_x, map_y, cv2.INTER_LINEAR)
    return aligned

3.2.2 特征匹配法

特征匹配法不关心每个像素,而是先提取图像中的关键点(比如角点、斑点),然后匹配这些点之间的对应关系,最后计算一个全局变换矩阵(通常是单应性矩阵或仿射矩阵)。

常用的特征有:

  • SIFT:尺度不变,旋转不变,精度高,但专利限制。
  • ORB:速度快,免费,适合移动端。
  • AKAZE:比SIFT快,比ORB稳定。

特征匹配法适合场景变化较大的情况,比如你手持相机拍了三张不同曝光的照片,视角有轻微旋转和缩放。光流法处理这种全局运动就比较吃力。

但特征匹配法有个缺点:它假设整个图像的运动可以用一个全局变换来描述。如果画面里有局部运动(比如一个人走过),那这个假设就不成立了。这时候就需要结合鬼影检测。

核心对比:
  • 光流法:逐像素运动,适合局部运动、小位移、纹理丰富场景。
  • 特征匹配法:全局变换,适合大位移、旋转、缩放、纹理稀疏场景。
实际项目中,我经常把两者结合:先用特征匹配做粗对齐,再用光流做精对齐。

3.3 鬼影检测与去除

鬼影是什么?就是合成结果里出现半透明的、错位的物体轮廓。比如你拍了一张街景,一个人从镜头前走过,欠曝帧里他在左边,过曝帧里他在右边。合成后,这个人就会出现两个半透明的影子。

鬼影检测的核心思路是:找出那些在多帧之间运动不一致的区域,然后把这些区域从融合过程中排除掉,或者只使用其中一帧的信息。

常用的鬼影检测方法:

  1. 基于方差/标准差:计算多帧图像在每个像素位置的方差。方差大的区域,说明运动剧烈,可能是鬼影。
  2. 基于中值偏差:计算每个像素与多帧中位数的偏差。偏差大的像素,标记为鬼影。
  3. 基于光流一致性:计算前向光流和后向光流,如果两者不一致,说明运动不可靠,标记为鬼影。

我个人比较喜欢用中值偏差法,因为它简单、稳定,而且对噪声不敏感。下面是一个实现思路:

def detect_ghost(images, threshold=0.1):
    # images: 对齐后的多帧图像列表,shape = (N, H, W, 3)
    # 计算中值图像
    median_img = np.median(images, axis=0)
    
    # 计算每帧与中值的偏差
    diff = np.abs(images - median_img)
    
    # 归一化到 [0, 1]
    diff_norm = diff / (np.max(diff) + 1e-8)
    
    # 生成鬼影掩码:偏差大于阈值的像素标记为1
    ghost_mask = (diff_norm > threshold).astype(np.float32)
    
    return ghost_mask

有了鬼影掩码之后,怎么去除?有两种策略:

  • 直接排除法:在融合时,鬼影区域的像素只从某一帧(通常是参考帧)取,其他帧的对应像素不参与融合。
  • 权重衰减法:鬼影区域的像素权重降低,但不会完全排除。这样能避免边缘出现硬边界。

我曾经在一个项目里遇到过一个棘手的问题:树叶在风中摇晃,鬼影检测把整片树叶都标记成了鬼影,结果合成出来的树叶区域全是参考帧的,失去了HDR的动态范围优势。后来我调整了阈值,并且对鬼影掩码做了形态学膨胀和腐蚀,才解决了这个问题。

小技巧: 鬼影检测的阈值不要设得太死。我一般会设一个软阈值:低于阈值的像素正常融合,高于阈值的像素权重线性衰减。这样能避免硬切换带来的不自然感。

3.4 本章知识体系

下面这张图总结了HDR合成的核心流程:

HDR合成核心流程 HDR合成核心流程 多帧输入(-2EV, 0EV, +2EV) 图像对齐 光流法 / 特征匹配法 鬼影检测与去除 中值偏差 / 光流一致性 多帧融合 权重映射 / 色调映射 HDR输出图像 关键参数 • 曝光帧数:3~5帧 • 对齐精度:亚像素级 • 鬼影阈值:0.05~0.15

嗯,以上就是HDR合成基础的核心内容。多帧曝光是前提,图像对齐是保障,鬼影检测是细节。这三步做好了,HDR合成的质量就有了基本保证。实际项目中,你还会遇到各种奇葩问题,比如运动模糊、过曝区域的饱和、颜色偏移等等。这些我们后面章节再聊。