第二章 图像采集与预处理:多帧图像的采集策略、自动曝光包围(AEB)、图像对齐与去鬼影基础
各位同学,欢迎来到实战进阶的第二站。上一章我们聊了HDR的基本概念,这一章咱们直接上手——从怎么拍,到怎么把拍回来的几帧图对齐、去掉鬼影,把原始素材准备好。
说实话,很多做HDR的朋友把精力全放在后面的融合算法上,结果前面采集和对齐没做好,后面再怎么调也是白费功夫。我早期踩过这个坑,后来才明白:预处理的质量,直接决定了HDR合成的天花板。
2.1 多帧图像的采集策略
先问一个问题:你拍HDR的时候,是手持还是上三脚架?
答案其实不。但我的建议是——能上架就上架,不能上架就靠策略补。
2.1.1 采集的核心原则
- 曝光覆盖要全:暗部、中间调、亮部都要有至少一帧是“正常曝光”的。我个人习惯用-2EV、0EV、+2EV三帧,复杂场景会加到5帧。
- 帧间间隔要短:手持拍摄时,帧与帧之间的时间差越短,运动位移越小,对齐压力就越小。
- 避免过饱和与欠曝死黑:最亮帧的高光区域不要全白(至少保留一点纹理),最暗帧的暗部不要全黑。
2.1.2 常见采集模式对比
| 模式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 三脚架+遥控 | 风光、建筑、静物 | 对齐几乎零误差 | 携带不便,拍摄慢 |
| 手持连拍 | 街拍、日常、旅行 | 灵活快速 | 对齐难度大,容易糊 |
| 连拍+OIS辅助 | 手机、运动相机 | 防抖补偿,成功率较高 | 依赖硬件,算法复杂 |
2.2 自动曝光包围(AEB)
AEB,说白了就是相机帮你自动拍一组不同曝光的照片。你按一次快门,它咔咔咔连拍三张,每张曝光都不一样。
为什么会需要这个?因为单张照片的动态范围有限,亮的地方过曝、暗的地方死黑,你后期拉不回来。AEB就是解决这个问题的第一步。
2.2.1 AEB的参数设置
- 步长(EV Step):一般设为1EV或2EV。步长太小,曝光差异不够;步长太大,中间调容易断层。
- 帧数:3帧是基础,5帧更稳。我做过测试,5帧比3帧在极端高反差场景下能多恢复约15%的细节。
- 顺序:建议从最暗到最亮(-2EV → 0EV → +2EV),这样相机曝光调整更平滑。
2.2.2 AEB的局限性
AEB不是万能的。我在项目中遇到过一个问题:拍夜景时,AEB三帧之间的时间差足够让车灯拖出长长的光轨,结果合成后全是鬼影。后来我改用单帧RAW暴力拉曝光,反而效果更好。
所以,AEB适合静态场景,动态场景要谨慎。
2.3 图像对齐(光流法/特征匹配)
拍完多帧之后,第一件事就是对齐。你想想看,手持拍摄时手会抖,风吹树叶会动,这些都会导致帧与帧之间像素位置不一样。不对齐就直接融合,画面会重影、模糊。
对齐方法主要有两种:特征匹配和光流法。
2.3.1 特征匹配(Feature Matching)
这种方法适合大位移、刚性运动的场景。比如你拍建筑,相机平移了,或者旋转了。
基本流程:
- 提取每帧的特征点(SIFT、ORB、AKAZE等)
- 匹配相邻帧的特征点
- 计算单应性矩阵(Homography)或仿射变换
- 用变换矩阵将图像对齐
# 用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 鬼影是怎么产生的?
我画了一张图,帮你理解鬼影的形成过程:
2.4.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%。因为后面所有的融合算法,都建立在预处理的结果之上。预处理做得好,后面事半功倍;做得不好,后面再怎么调也是事倍功半。