4. PatchMatch去鬼影实战:随机初始化与迭代传播、最佳匹配块搜索、基于颜色与梯度的代价函数设计

去鬼影,说白了就是让HDR合成不再出现「半透明幽灵」。我最早做多帧HDR时,最头疼的就是这个。明明曝光序列拍得好好的,合成出来人物边缘却像分身术一样。后来接触了PatchMatch算法,才算是找到了真正能落地的方案。

这一章,我们直接上手PatchMatch去鬼影的核心流程。我会把随机初始化、迭代传播、最佳匹配块搜索,还有代价函数设计这几个关键环节拆开来讲。嗯,每个环节都有坑,我会把我在项目中踩过的雷一并告诉你。

核心思路: PatchMatch不是暴力搜索所有像素,而是利用图像的空间连续性,通过「随机猜测 + 邻域传播」快速找到每个像素的最佳匹配块。速度比全搜索快两个数量级,效果还更好。

4.1 随机初始化:给算法一个「起点」

PatchMatch的第一步,是为每个像素随机分配一个偏移量。这个偏移量表示:当前像素在参考帧中的最佳匹配块,位于另一帧的什么位置。

我刚开始做的时候,觉得随机初始化随便搞搞就行。后来发现,初始化的质量直接影响收敛速度。如果随机范围太大,算法要多迭代好几轮才能稳定。

初始化策略

  • 全图随机: 每个像素的偏移量在[-S, S]范围内均匀采样。S一般取图像宽高的1/4到1/2。
  • 局部约束随机: 只在小范围内随机,比如[-16, 16]像素。适合运动幅度不大的场景。
  • 光流引导初始化: 先用光流算一个粗略的运动场,然后加上小随机扰动。收敛最快,但多了一步预处理。
我的习惯: 对于手持拍摄的HDR序列,我一般用局部约束随机,范围设[-32, 32]。运动大的场景才用全图随机。这样既保证搜索空间够大,又不会让初始质量太差。

4.2 迭代传播:让好偏移量「传染」给邻居

随机初始化之后,就是迭代传播阶段。这是PatchMatch的精髓——好的偏移量会像病毒一样在图像中扩散。

传播分两个方向:从左到右、从上到下(正向传播),再从右到左、从下到上(反向传播)。每次传播,当前像素会看看它左边和上边(或右边和下边)的邻居,如果邻居的偏移量能让自己的匹配代价更低,就「偷」过来用。

传播流程

  1. 正向传播: 遍历每个像素,检查左邻居和上邻居的偏移量。如果邻居偏移量+1(或-1)后的代价更低,就更新当前偏移量。
  2. 反向传播: 逆序遍历,检查右邻居和下邻居的偏移量。同样,代价更低就更新。
  3. 随机搜索: 在传播之后,以当前最佳偏移量为中心,在逐渐缩小的半径内随机采样。这一步是为了跳出局部最优。
// 伪代码:正向传播
for each pixel (x, y):
    // 检查左邻居
    offset_left = offset[x-1][y] + (1, 0)
    cost_left = compute_cost(x, y, offset_left)
    if cost_left < cost[x][y]:
        offset[x][y] = offset_left
        cost[x][y] = cost_left
    
    // 检查上邻居
    offset_up = offset[x][y-1] + (0, 1)
    cost_up = compute_cost(x, y, offset_up)
    if cost_up < cost[x][y]:
        offset[x][y] = offset_up
        cost[x][y] = cost_up
我曾经踩过的坑: 迭代次数不是越多越好。3-5轮迭代基本就收敛了,再往后边际收益极低,反而增加耗时。我见过有人迭代20轮,效果跟5轮一模一样,白白浪费算力。

4.3 最佳匹配块搜索:代价函数说了算

传播和随机搜索的核心,都是「比较代价」。代价函数设计得好不好,直接决定匹配质量。

我常用的代价函数,包含两个部分:颜色代价和梯度代价。

颜色代价

计算两个块之间每个像素的RGB差值。最常用的是差之和(SAD)或平方差之和(SSD)。

// 颜色代价:SAD
float color_cost = 0.0f;
for (int dy = -R; dy <= R; dy++) {
    for (int dx = -R; dx <= R; dx++) {
        vec3 c1 = get_pixel(img1, x + dx, y + dy);
        vec3 c2 = get_pixel(img2, x + dx + off_x, y + dy + off_y);
        color_cost += abs(c1.r - c2.r) + abs(c1.g - c2.g) + abs(c1.b - c2.b);
    }
}

梯度代价

颜色代价对光照变化很敏感。曝光序列中,不同帧的亮度差异本来就大,纯靠颜色容易匹配错。加上梯度代价,就能更好地捕捉结构信息。

// 梯度代价:Sobel梯度差
float grad_cost = 0.0f;
for (int dy = -R; dy <= R; dy++) {
    for (int dx = -R; dx <= R; dx++) {
        float gx1 = sobel_x(img1, x + dx, y + dy);
        float gy1 = sobel_y(img1, x + dx, y + dy);
        float gx2 = sobel_x(img2, x + dx + off_x, y + dy + off_y);
        float gy2 = sobel_y(img2, x + dx + off_y, y + dy + off_y);
        grad_cost += abs(gx1 - gx2) + abs(gy1 - gy2);
    }
}

总代价

把颜色代价和梯度代价加权求和:

float total_cost = color_cost + lambda * grad_cost;

lambda一般取0.3到0.8之间。我个人习惯设0.5,颜色和梯度各占一半。如果场景纹理丰富,可以加大梯度权重;如果场景平坦,就减小梯度权重。

关键参数: 匹配块半径R一般取3到7像素。R太小,匹配不稳定;R太大,细节被平滑掉。我常用R=5,也就是11x11的块,兼顾速度和精度。

4.4 知识结构总览

下面这张图,把PatchMatch去鬼影的完整流程串起来了。你可以把它当作调试时的路线图。

PatchMatch去鬼影核心流程 多帧曝光序列 随机初始化偏移量 迭代传播(正向+反向) 代价函数计算 随机搜索(跳出局部最优) 迭代3-5轮 最佳匹配偏移量场 代价函数组成 • 颜色代价:SAD/SSD • 梯度代价:Sobel梯度差 • 总代价 = 颜色 + λ × 梯度 关键参数 • 匹配块半径R:3-7像素 • 梯度权重λ:0.3-0.8 • 迭代轮数:3-5轮

4.5 代价函数对比与选择

不同场景下,代价函数的配置差别很大。我整理了一张表,方便你快速对照选择。

场景类型 颜色权重 梯度权重 推荐块半径 说明
静态场景 1.0 0.0 3 没有运动,纯颜色匹配就够
轻微运动 0.7 0.3 5 人物小幅晃动,梯度辅助
中等运动 0.5 0.5 5 行人走动、车辆移动
剧烈运动 0.3 0.7 7 快速挥手、跳跃,梯度主导
低纹理场景 0.8 0.2 7 天空、墙面,梯度信息少
调试技巧: 如果你发现去鬼影后边缘出现「锯齿状」伪影,大概率是梯度权重太高了。把lambda降到0.3试试。如果出现「模糊重影」,说明颜色权重太高,匹配块找错了位置,适当提高梯度权重。

4.6 实战避坑指南

最后,分享几个我在项目中踩过的坑,希望能帮你省点时间。

  • 块半径别太大: 我曾经为了追求稳定性,把R设到15(31x31的块)。结果鬼影是没了,但细节全糊了。后来改成R=5,效果反而更好。
  • 别忘了多尺度: 对于4K以上的大图,直接跑PatchMatch很慢。我习惯先降采样到1/4分辨率跑一遍,再把结果上采样作为初始值,在原始分辨率微调。速度能快4-5倍。
  • 边界处理要小心: 图像边缘的像素,匹配块会超出图像范围。我一般用镜像填充,或者直接跳过边缘像素不处理。直接补零的话,边缘会出现明显的鬼影残留。
  • 迭代轮数设上限: 我见过有人写死循环,直到代价不再下降才停止。但实际中,代价下降曲线很快就平了。设一个最大迭代次数(比如5轮),既保证效果又防止死循环。
我曾经犯过的错: 有一次做夜景HDR,暗部区域噪声很大。纯用颜色代价匹配,结果暗部匹配得乱七八糟,鬼影反而更严重了。后来我在代价函数里加了一个噪声自适应项——噪声大的区域降低颜色权重,提高梯度权重。问题才解决。