4. 运动补偿与对齐

多帧降噪和HDR合成,核心前提是什么?

是帧与帧之间能对上。

你想想看,手持拍摄时手会抖,场景里可能有行人走过,树叶被风吹动。如果直接把这几帧叠在一起,画面就糊了。所以,运动补偿与对齐,就是解决「怎么把多帧图像在像素级对齐」的问题。

我个人习惯把这个问题拆成三层来处理:全局运动估计、局部运动补偿、去鬼影。这三层缺一不可。

核心思路:先粗后精,先全局后局部。先算整张图的偏移(平移/旋转/缩放),再处理局部物体的独立运动,最后把那些「不该出现」的鬼影抹掉。

4.1 全局运动估计:仿射变换与透视变换

全局运动,说白了就是相机本身的运动。平移、旋转、缩放,甚至轻微的视角变化。

我最早做这个的时候,以为用个简单的平移模型就够了。后来发现,手机拍摄时经常有轻微的旋转和缩放,平移模型根本扛不住。所以,工业界最常用的两个模型是:仿射变换和透视变换。

模型 自由度 适用场景 矩阵形式
仿射变换 6参数 平移 + 旋转 + 缩放 + 剪切 2×3矩阵
透视变换 8参数 仿射 + 透视畸变(视角变化) 3×3矩阵

怎么估计这些参数?最经典的方法是特征点匹配 + RANSAC。

  1. 提取特征点:比如ORB、SIFT、AKAZE。我个人偏爱AKAZE,速度快,而且对光照变化鲁棒。
  2. 特征匹配:用暴力匹配或FLANN,找到两帧之间的对应点对。
  3. RANSAC筛选:剔除误匹配,用内点拟合变换矩阵。

嗯,这里要注意。RANSAC的阈值设置很关键。设得太松,会把错误匹配放进来;设得太紧,内点太少,矩阵算不准。我一般设2~3个像素,具体看图像分辨率。

// 伪代码:仿射变换估计
vector<Point2f> src_pts, dst_pts;
// 假设已经通过特征匹配得到了对应点对
Mat affine_matrix = estimateAffinePartial2D(src_pts, dst_pts);
// 或者用findHomography做透视变换
Mat homography_matrix = findHomography(src_pts, dst_pts, RANSAC, 3.0);
我的经验:如果场景纹理很弱(比如白墙),特征点会很少。这时候可以退而求其次,用相位相关法做全局平移估计。虽然精度低,但至少不会崩。

4.2 局部运动补偿:网格化与光流

全局运动搞定了,但问题还没完。

场景里总有独立运动的物体——行人、汽车、飘动的旗帜。这些局部运动,全局变换是管不了的。怎么办?

我的做法是:网格化局部补偿。

把图像分成M×N的网格,每个网格独立计算运动矢量。这样,每个局部区域都能得到自己的偏移量。

  • 网格大小:我一般用32×32或64×64。网格太小,容易受噪声干扰;网格太大,又无法捕捉精细运动。
  • 运动估计方法:可以用光流(如Farneback光流),也可以用块匹配(Block Matching)。
  • 插值:每个像素的运动矢量,由周围网格的运动矢量双线性插值得到。

我曾经在一个项目中,直接用稠密光流做逐像素补偿。效果是好,但计算量太大,手机端根本跑不动。后来改成网格化,速度提升了5倍,效果损失很小。

// 伪代码:网格化运动补偿
int grid_size = 32;
for (int gy = 0; gy < height/grid_size; gy++) {
    for (int gx = 0; gx < width/grid_size; gx++) {
        // 计算当前网格的运动矢量
        Point2f motion = calcBlockMotion(ref_img, cur_img, gx*grid_size, gy*grid_size);
        grid_motion[gy][gx] = motion;
    }
}
// 对每个像素,插值得到运动矢量并补偿
避坑:网格化补偿后,图像边缘可能会出现「接缝」效应。我一般会在网格之间做重叠(overlap),或者用高斯权重融合,让过渡更平滑。

4.3 去鬼影检测与抑制

运动补偿做完了,是不是就能直接融合了?

不行。还有一个大坑——鬼影。

什么是鬼影?比如你拍HDR,三帧曝光不同。第一帧有个人站着,第二帧他走了。如果你直接融合,这个人就会变成一个半透明的「鬼影」残留在画面里。

去鬼影的核心,就是检测出哪些像素是「异常」的,然后抑制它们。

4.3.1 鬼影检测方法

我常用的方法有两种:

  1. 基于亮度差异的检测:把当前帧与参考帧做差,差异超过阈值的区域标记为鬼影区域。
  2. 基于运动一致性的检测:如果某个像素的运动矢量与周围像素不一致,说明它属于独立运动物体,可能是鬼影。

在实际项目中,我更喜欢把两者结合起来。先用亮度差异做粗筛,再用运动一致性做精筛。

// 伪代码:鬼影检测
Mat diff = abs(ref_frame - cur_frame);
Mat ghost_mask = diff > threshold;  // 粗筛

// 运动一致性检查
for each pixel (x, y) {
    if (ghost_mask.at<uchar>(y, x)) {
        Point2f motion = getMotion(x, y);
        Point2f avg_motion = getAverageMotion(x, y, 5); // 5x5邻域
        if (norm(motion - avg_motion) > motion_threshold) {
            ghost_mask.at<uchar>(y, x) = 255; // 确认是鬼影
        }
    }
}

4.3.2 鬼影抑制策略

检测到鬼影后,怎么处理?

  • 直接丢弃:鬼影区域的像素不参与融合。简单粗暴,但可能导致空洞。
  • 权重降低:给鬼影区域的像素一个很低的权重,让它们对最终结果影响最小。
  • 替换为参考帧:用参考帧的像素直接替换鬼影区域的像素。这是最常用的方法。

我个人习惯用第二种——权重降低。因为直接丢弃或替换,可能会在边缘产生不自然的过渡。降低权重,配合后续的融合算法,效果更自然。

关键参数:鬼影检测的阈值。设得太低,会把正常区域误判为鬼影,导致细节丢失;设得太高,鬼影会残留。我一般根据场景动态调整——高动态范围场景,阈值适当放宽;低噪声场景,阈值收紧。

4.4 整体流程与SVG框架图

好了,把上面三个步骤串起来,就是完整的运动补偿与对齐流程。

运动补偿与对齐整体流程 多帧输入图像 全局运动估计 特征匹配 + RANSAC → 仿射/透视变换 局部运动补偿 网格化 + 光流/块匹配 → 逐像素插值 去鬼影检测与抑制 亮度差异 + 运动一致性 → 权重调整 对齐后的多帧图像 注意 全局运动是基础 局部运动是细节 去鬼影是最后防线

你看这个流程,每一步都依赖上一步的结果。全局运动没对齐好,局部补偿就会出错;局部补偿有残留,去鬼影的压力就大。所以,每一步都要做到位。

我的建议:在实际工程中,先跑一遍全局对齐,然后做局部补偿,最后做鬼影检测。如果发现鬼影太多,可以回头调整局部补偿的参数,或者加强全局运动的精度。这是一个迭代优化的过程。