3、HDR合成基础:多帧曝光原理、图像对齐算法与鬼影去除
HDR合成,说白了就是让一张照片同时保留亮部和暗部的细节。单张照片的动态范围有限,这是物理限制。那怎么办?拍多张不同曝光的照片,把它们合成到一起。这就是多帧曝光HDR的核心思路。
我刚开始做HDR时,觉得不就是把几张图叠在一起吗?后来发现,事情远没那么简单。你手稍微抖一下,或者画面里有东西在动,合成结果就会出现重影、模糊。嗯,今天我们就来拆解这三个核心问题:多帧曝光原理、图像对齐算法、鬼影检测与去除。
3.1 多帧曝光原理
为什么要拍多帧?因为一张照片的传感器,一次只能记录有限范围的亮度。暗的地方没信号,亮的地方过曝。我们拍一张欠曝的(保留亮部细节)、一张正常的、一张过曝的(保留暗部细节),然后通过算法把它们融合。
典型的曝光策略是这样的:
| 曝光级别 | EV值偏移 | 作用 | 典型场景 |
|---|---|---|---|
| 欠曝帧 | -2 EV ~ -3 EV | 保留高光细节(天空、灯光) | 逆光、日落 |
| 正常帧 | 0 EV | 保留中间调细节 | 常规光照 |
| 过曝帧 | +2 EV ~ +3 EV | 保留阴影细节(暗部、背光面) | 室内暗角、夜景 |
我个人习惯用3帧或5帧。3帧是最低配置,5帧能提供更宽的动态范围和更平滑的融合。但帧数越多,对齐难度越大,鬼影也越多。这是个trade-off。
3.2 图像对齐算法
多帧拍摄时,相机不可能完全不动。哪怕你用了三脚架,按快门时的震动也会导致像素偏移。所以,对齐是HDR合成的第一步,也是最关键的一步。
对齐算法主要分两类:光流法和特征匹配法。我分别说说。
3.2.1 光流法
光流法,说白了就是计算每个像素点在两帧之间的运动矢量。它假设像素的亮度在运动前后保持不变,然后通过求解一个优化问题来得到每个点的位移。
常用的光流算法有:
- Farneback光流:基于多项式展开,速度快,适合实时处理。
- Lucas-Kanade光流:基于局部窗口,适合小位移。
- 深度学习光流(如FlowNet、RAFT):精度高,但计算量大。
我在项目中遇到过一个问题:光流法对纹理丰富的区域效果很好,但在天空、白墙这些平坦区域,它容易跑偏。因为平坦区域没有足够的梯度信息来约束运动估计。
下面是一个简单的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 鬼影检测与去除
鬼影是什么?就是合成结果里出现半透明的、错位的物体轮廓。比如你拍了一张街景,一个人从镜头前走过,欠曝帧里他在左边,过曝帧里他在右边。合成后,这个人就会出现两个半透明的影子。
鬼影检测的核心思路是:找出那些在多帧之间运动不一致的区域,然后把这些区域从融合过程中排除掉,或者只使用其中一帧的信息。
常用的鬼影检测方法:
- 基于方差/标准差:计算多帧图像在每个像素位置的方差。方差大的区域,说明运动剧烈,可能是鬼影。
- 基于中值偏差:计算每个像素与多帧中位数的偏差。偏差大的像素,标记为鬼影。
- 基于光流一致性:计算前向光流和后向光流,如果两者不一致,说明运动不可靠,标记为鬼影。
我个人比较喜欢用中值偏差法,因为它简单、稳定,而且对噪声不敏感。下面是一个实现思路:
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合成的质量就有了基本保证。实际项目中,你还会遇到各种奇葩问题,比如运动模糊、过曝区域的饱和、颜色偏移等等。这些我们后面章节再聊。