第二章 图像采集与预处理:多帧图像的采集策略、自动曝光包围(AEB)、图像对齐与去鬼影基础

各位同学,欢迎来到实战进阶的第二站。上一章我们聊了HDR的基本概念,这一章咱们直接上手——从怎么拍,到怎么把拍回来的几帧图对齐、去掉鬼影,把原始素材准备好。

说实话,很多做HDR的朋友把精力全放在后面的融合算法上,结果前面采集和对齐没做好,后面再怎么调也是白费功夫。我早期踩过这个坑,后来才明白:预处理的质量,直接决定了HDR合成的天花板。

2.1 多帧图像的采集策略

先问一个问题:你拍HDR的时候,是手持还是上三脚架?

答案其实不。但我的建议是——能上架就上架,不能上架就靠策略补。

2.1.1 采集的核心原则

  • 曝光覆盖要全:暗部、中间调、亮部都要有至少一帧是“正常曝光”的。我个人习惯用-2EV、0EV、+2EV三帧,复杂场景会加到5帧。
  • 帧间间隔要短:手持拍摄时,帧与帧之间的时间差越短,运动位移越小,对齐压力就越小。
  • 避免过饱和与欠曝死黑:最亮帧的高光区域不要全白(至少保留一点纹理),最暗帧的暗部不要全黑。

2.1.2 常见采集模式对比

模式 适用场景 优点 缺点
三脚架+遥控 风光、建筑、静物 对齐几乎零误差 携带不便,拍摄慢
手持连拍 街拍、日常、旅行 灵活快速 对齐难度大,容易糊
连拍+OIS辅助 手机、运动相机 防抖补偿,成功率较高 依赖硬件,算法复杂
我的经验:手持拍摄时,我习惯用连拍模式一口气拍5-7帧,然后从中选出曝光最合适的3帧。这样即使有几帧抖了,也能靠后处理补救。

2.2 自动曝光包围(AEB)

AEB,说白了就是相机帮你自动拍一组不同曝光的照片。你按一次快门,它咔咔咔连拍三张,每张曝光都不一样。

为什么会需要这个?因为单张照片的动态范围有限,亮的地方过曝、暗的地方死黑,你后期拉不回来。AEB就是解决这个问题的第一步。

2.2.1 AEB的参数设置

  • 步长(EV Step):一般设为1EV或2EV。步长太小,曝光差异不够;步长太大,中间调容易断层。
  • 帧数:3帧是基础,5帧更稳。我做过测试,5帧比3帧在极端高反差场景下能多恢复约15%的细节。
  • 顺序:建议从最暗到最亮(-2EV → 0EV → +2EV),这样相机曝光调整更平滑。
注意:有些相机的AEB模式会锁定白平衡和ISO,但不会锁定对焦。如果你拍的是近景,建议手动对焦,否则每帧焦点可能不一样,后期对齐会非常痛苦。

2.2.2 AEB的局限性

AEB不是万能的。我在项目中遇到过一个问题:拍夜景时,AEB三帧之间的时间差足够让车灯拖出长长的光轨,结果合成后全是鬼影。后来我改用单帧RAW暴力拉曝光,反而效果更好。

所以,AEB适合静态场景,动态场景要谨慎。

2.3 图像对齐(光流法/特征匹配)

拍完多帧之后,第一件事就是对齐。你想想看,手持拍摄时手会抖,风吹树叶会动,这些都会导致帧与帧之间像素位置不一样。不对齐就直接融合,画面会重影、模糊。

对齐方法主要有两种:特征匹配和光流法。

2.3.1 特征匹配(Feature Matching)

这种方法适合大位移、刚性运动的场景。比如你拍建筑,相机平移了,或者旋转了。

基本流程:

  1. 提取每帧的特征点(SIFT、ORB、AKAZE等)
  2. 匹配相邻帧的特征点
  3. 计算单应性矩阵(Homography)或仿射变换
  4. 用变换矩阵将图像对齐
# 用OpenCV做特征匹配对齐的简化示例
import cv2
import numpy as np

def align_features(img_ref, img_target):
    # 提取ORB特征
    orb = cv2.ORB_create()
    kp1, des1 = orb.detectAndCompute(img_ref, None)
    kp2, des2 = orb.detectAndCompute(img_target, None)
    
    # 匹配特征
    bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
    matches = bf.match(des1, des2)
    matches = sorted(matches, key=lambda x: x.distance)[:50]
    
    # 计算变换矩阵
    src_pts = np.float32([kp1[m.queryIdx].pt for m in matches]).reshape(-1, 1, 2)
    dst_pts = np.float32([kp2[m.trainIdx].pt for m in matches]).reshape(-1, 1, 2)
    M, mask = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0)
    
    # 对齐
    h, w = img_ref.shape[:2]
    aligned = cv2.warpPerspective(img_target, M, (w, h))
    return aligned
适用场景:静态场景、相机运动为主、有重复纹理(如砖墙、窗户)时效果很好。但遇到大面积纯色区域(如天空、白墙),特征点不够,容易失败。

2.3.2 光流法(Optical Flow)

光流法适合小位移、非刚性运动的场景。比如树叶在风中摇摆,或者人脸上的表情变化。

它的核心思想是:假设像素亮度不变,计算每个像素的运动矢量。我常用的方法是Farneback光流和深度学习光流(如RAFT)。

# Farneback光流对齐示例
def align_optical_flow(img_ref, img_target):
    # 转灰度
    gray_ref = cv2.cvtColor(img_ref, cv2.COLOR_BGR2GRAY)
    gray_target = cv2.cvtColor(img_target, cv2.COLOR_BGR2GRAY)
    
    # 计算光流
    flow = cv2.calcOpticalFlowFarneback(
        gray_ref, gray_target, None,
        0.5, 3, 15, 3, 5, 1.2, 0
    )
    
    # 根据光流重映射
    h, w = gray_ref.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(img_target, map_x, map_y, cv2.INTER_LINEAR)
    return aligned
我的建议:实际项目中,我通常先用特征匹配做粗对齐(校正旋转和缩放),再用光流法做精对齐(校正局部运动)。两阶段配合,效果比单独用一种好得多。

2.4 去鬼影(Deghosting)基础

对齐只能解决相机运动,但解决不了场景中物体的运动。比如一个人从画面中走过,或者汽车开过。这些运动物体在不同帧里的位置不一样,直接融合就会产生半透明的“鬼影”。

去鬼影,就是把这些运动区域找出来,然后只取其中一帧的信息,或者用特殊方式融合。

2.4.1 鬼影是怎么产生的?

我画了一张图,帮你理解鬼影的形成过程:

鬼影形成示意图 第一帧(-2EV) 人 车 第二帧(0EV) 人 车 第三帧(+2EV) 人 车 直接融合后的结果 ← 人和车在不同帧位置不同,融合后出现半透明重影 →

2.4.2 去鬼影的基本思路

去鬼影的方法有很多,但核心就两步:

  1. 检测运动区域:找出哪些像素/区域在不同帧之间有显著变化。
  2. 选择最佳帧:对运动区域,只取其中一帧的数据,其他帧的对应区域丢弃。

常用的检测方法包括:

  • 帧差法:直接计算两帧的像素差,超过阈值就认为是运动区域。简单但容易受噪声影响。
  • 中值偏差法:计算每帧与中值帧的偏差,鲁棒性更好。我早期做手机HDR时用的就是这种方法。
  • 深度学习法:用CNN直接预测运动掩膜,精度最高,但需要训练数据和算力。
避坑指南:我曾经在项目中遇到一个问题——去鬼影算法把静止的树叶也当成运动区域去掉了,结果画面出现空洞。后来发现是因为风太大,树叶在几帧之间亮度变化明显,被误判为运动。解决办法是增加一个“纹理一致性”检查:如果区域纹理复杂但亮度变化大,很可能是光照变化而非物体运动。

2.4.3 一个简单的去鬼影流程

def simple_deghost(images, ref_idx=1):
    """
    简单的去鬼影实现:基于中值偏差检测运动区域
    images: 对齐后的多帧图像列表
    ref_idx: 参考帧索引
    """
    # 计算中值图像
    median_img = np.median(images, axis=0).astype(np.uint8)
    
    # 计算每帧与中值帧的差
    diffs = [cv2.absdiff(img, median_img) for img in images]
    
    # 对每帧生成运动掩膜(阈值法)
    masks = []
    for diff in diffs:
        gray_diff = cv2.cvtColor(diff, cv2.COLOR_BGR2GRAY)
        _, mask = cv2.threshold(gray_diff, 30, 255, cv2.THRESH_BINARY)
        # 形态学操作去除小噪点
        mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((5,5), np.uint8))
        masks.append(mask)
    
    # 对运动区域,只取参考帧的数据
    result = images[ref_idx].copy()
    for i in range(len(images)):
        if i == ref_idx:
            continue
        # 非运动区域用其他帧融合
        non_motion = masks[i] == 0
        result[non_motion] = (result[non_motion] + images[i][non_motion]) // 2
    
    return result
进阶提示:上面的代码只是教学演示。实际产品中,我会用多尺度方法:先在低分辨率下检测大运动,再在高分辨率下精修边缘。这样既快又准。

本章小结

这一章我们走完了HDR合成的“前端”流程:从怎么拍(采集策略和AEB),到怎么对齐(特征匹配和光流法),再到怎么去鬼影。每一步都有坑,但每一步也都有成熟的解法。

我个人觉得,预处理阶段花的时间,至少应该占整个HDR流程的40%。因为后面所有的融合算法,都建立在预处理的结果之上。预处理做得好,后面事半功倍;做得不好,后面再怎么调也是事倍功半。