2. 多帧HDR原理:短帧、中帧、长帧的曝光策略,以及如何对齐与合成

多帧HDR,说白了就是让传感器连续拍三张照片——一张欠曝、一张正常、一张过曝,然后把它们合成一张高动态范围的图像。听起来简单,但这里面的门道可不少。我刚开始做HDR时,以为就是简单地把三张图叠在一起,结果出来的图像要么鬼影重重,要么亮度断层,惨不忍睹。

这一章,我就把多帧HDR的核心逻辑拆开来讲。包括曝光策略怎么定、帧间怎么对齐、合成算法怎么选。这些都是我在项目里踩过坑、填过坑之后沉淀下来的经验。

2.1 短帧、中帧、长帧的曝光策略

先说说三帧的曝光时间怎么分配。这直接决定了HDR的动态范围能拉到多宽。

帧类型 曝光时间比例 目标场景亮度 典型应用场景
短帧(Short) 1x(基准) 高亮区域(天空、灯光) 逆光、强光源场景
中帧(Middle) 4x ~ 8x 中间调(人脸、建筑) 室内外过渡场景
长帧(Long) 16x ~ 32x 暗部区域(阴影、夜景) 低照度、大光比场景

我个人习惯把短帧作为基准帧。为什么?因为短帧的曝光时间最短,运动模糊最小,鬼影也最少。中帧和长帧的曝光时间,都是基于短帧来推算的。

举个例子。如果短帧曝光时间是1ms,中帧我通常会设到4ms到8ms,长帧设到16ms到32ms。这样三帧的亮度跨度大概在4到5档EV之间,覆盖大部分真实场景的动态范围。

经验之谈: 曝光比例不是越大越好。比例太大,帧间的亮度差异会超出合成算法的处理能力,导致合成后的图像出现亮度断层或噪声放大。我一般控制在4倍到8倍之间,效果最稳。

2.2 帧间对齐:消除鬼影的关键

三帧图像不是同时拍的,中间有时间差。如果场景里有运动物体——比如行人、车辆、树叶晃动——那三帧里同一个物体的位置就不一样。直接合成,就会出现鬼影。

对齐的目的,就是把中帧和长帧的图像内容,通过运动补偿,对齐到短帧的坐标系上。

2.2.1 全局运动对齐

全局运动,说白了就是相机本身的抖动或平移。这种情况用仿射变换或透视变换就能搞定。

// 全局对齐示例:基于特征点匹配
// 1. 提取短帧和长帧的ORB特征点
// 2. 使用FLANN匹配器找到对应点对
// 3. 通过RANSAC剔除误匹配,计算单应性矩阵H
// 4. 将长帧图像通过H矩阵变换到短帧坐标系

cv::Mat H = cv::findHomography(keypoints_long, keypoints_short, cv::RANSAC, 3.0);
cv::warpPerspective(long_frame, long_aligned, H, short_frame.size());

我在项目中遇到过一个问题:全局对齐对旋转和缩放很敏感。如果相机在拍摄过程中发生了明显的旋转,单应性矩阵的估计会不稳定。后来我加了一个陀螺仪数据辅助,用IMU的角速度来预补偿旋转,对齐精度提升了不少。

2.2.2 局部运动对齐

全局对齐只能解决相机运动。场景里的独立运动物体,比如行人、车辆,必须用局部对齐来处理。

常用的方法是光流法或块匹配法。把图像分成小块,对每个块单独计算运动矢量。

// 局部对齐示例:基于块匹配的运动估计
// 1. 将图像划分为16x16的块
// 2. 对每个块,在参考帧的搜索窗口内寻找最佳匹配
// 3. 使用SAD(差之和)作为匹配代价
// 4. 对运动矢量进行中值滤波,去除异常值

for (int y = 0; y < height; y += 16) {
    for (int x = 0; x < width; x += 16) {
        cv::Point2f mv = blockMatch(short_frame, long_frame, x, y, 16, 32);
        motion_field[y/16][x/16] = mv;
    }
}
注意: 局部对齐的计算量很大。如果每个块都做全搜索,移动端芯片根本扛不住。我建议用分层搜索策略:先在低分辨率图上做粗匹配,再在高分辨率图上做精匹配。这样能省掉70%以上的计算量。

2.3 合成算法:把三帧变成一帧

对齐之后,三帧图像在空间上已经对齐了。接下来就是合成——把短帧的高亮细节、中帧的中间调、长帧的暗部细节融合到一起。

2.3.1 权重融合法

这是最常用的方法。对每个像素,根据它的亮度值,给三帧分配不同的权重。

// 权重融合示例
// 短帧权重:高亮区域权重高
// 长帧权重:暗部区域权重高
// 中帧权重:中间调区域权重高

float w_short = exp(-pow(pixel_short - 0.8, 2) / (2 * sigma_s * sigma_s));
float w_mid   = exp(-pow(pixel_mid   - 0.5, 2) / (2 * sigma_m * sigma_m));
float w_long  = exp(-pow(pixel_long  - 0.2, 2) / (2 * sigma_l * sigma_l));

// 归一化权重
float sum = w_short + w_mid + w_long;
float result = (w_short * pixel_short + w_mid * pixel_mid + w_long * pixel_long) / sum;

权重融合的好处是平滑、自然。但有个坑:如果三帧的亮度差异太大,权重过渡区会出现亮度断层。我踩过这个坑,后来加了一个多尺度融合策略——先在拉普拉斯金字塔的不同层级上分别融合,再重建回原图。这样过渡就自然多了。

2.3.2 基于运动检测的融合

如果场景里有运动物体,权重融合会把运动物体的鬼影也融合进去。这时候需要引入运动检测。

基本思路是:先计算短帧和长帧之间的差异图,差异大的区域说明有运动。在这些区域,只使用短帧的数据,放弃长帧和中帧。

// 运动检测融合示例
// 1. 计算短帧和长帧的差
// 2. 对差异图做阈值分割,生成运动掩膜
// 3. 在运动区域,只使用短帧数据
// 4. 在静止区域,使用权重融合

cv::Mat diff = cv::abs(short_frame - long_aligned);
cv::Mat motion_mask = diff > threshold;

cv::Mat result;
if (motion_mask.at<uchar>(y, x) > 0) {
    result.at<Vec3b>(y, x) = short_frame.at<Vec3b>(y, x);
} else {
    result.at<Vec3b>(y, x) = weightedFusion(short_frame, mid_frame, long_aligned, y, x);
}
核心要点: 运动检测的阈值很关键。阈值设得太低,会把静止区域的纹理误判为运动,导致细节丢失。阈值设得太高,又会有鬼影残留。我一般用自适应阈值,根据局部区域的纹理复杂度动态调整。

2.4 多帧HDR的完整流程

把上面的知识点串起来,多帧HDR的完整流程是这样的:

  1. 曝光控制: 根据场景亮度,确定短帧、中帧、长帧的曝光时间。
  2. 图像采集: 连续拍摄三帧图像,确保帧间间隔尽可能短。
  3. 全局对齐: 基于特征点匹配,补偿相机运动。
  4. 局部对齐: 基于块匹配或光流,补偿物体运动。
  5. 运动检测: 生成运动掩膜,标记运动区域。
  6. 权重融合: 在静止区域使用多尺度权重融合,在运动区域使用短帧数据。
  7. 色调映射: 将融合后的高动态范围图像映射到8bit显示空间。

嗯,这里要注意一点:色调映射虽然不在HDR融合的核心流程里,但它直接决定了最终图像看起来舒不舒服。我后面会专门用一章来讲色调映射的实战技巧。

多帧HDR核心流程 多帧HDR核心流程 曝光控制 图像采集(三帧) 全局对齐(特征点匹配) 局部对齐(块匹配/光流) 运动检测(生成掩膜) 权重融合(多尺度) 色调映射 → 8bit输出 关键参数 短帧:1x 基准曝光 中帧:4x~8x 短帧 长帧:16x~32x 短帧 避坑提示 运动区域只使用短帧 避免亮度断层 自适应阈值更稳

2.5 实战中的避坑指南

最后,分享几个我在项目中踩过的坑,希望能帮你少走弯路。

坑1:帧间间隔太长
我曾经在一个项目中,三帧的帧间间隔设到了30ms。结果拍快速移动的物体时,三帧里的物体位置差异太大,对齐算法根本救不回来。后来我把帧间间隔压缩到5ms以内,鬼影问题大幅改善。
坑2:曝光比例过大
有一次为了追求极致动态范围,我把长帧的曝光时间设到了短帧的64倍。结果长帧的暗部噪声被放大到无法接受的程度,合成后的图像噪点满天飞。后来我控制在16倍以内,信噪比才回到可接受范围。
坑3:忽略运动物体的边缘处理
运动检测生成的掩膜,边缘往往是锯齿状的。直接用它来切换数据源,合成后的图像在运动物体边缘会出现明显的接缝。我的解决方案是对掩膜做高斯模糊,让过渡区域平滑渐变。

多帧HDR的核心,说白了就是三件事:曝光策略定好、帧间对齐做准、合成算法选对。这三件事做好了,HDR的效果就稳了八成。剩下的两成,靠的是色调映射和3A算法的配合——这部分,我们后面慢慢聊。