4. PatchMatch去鬼影实战:随机初始化与迭代传播、最佳匹配块搜索、基于颜色与梯度的代价函数设计
去鬼影,说白了就是让HDR合成不再出现「半透明幽灵」。我最早做多帧HDR时,最头疼的就是这个。明明曝光序列拍得好好的,合成出来人物边缘却像分身术一样。后来接触了PatchMatch算法,才算是找到了真正能落地的方案。
这一章,我们直接上手PatchMatch去鬼影的核心流程。我会把随机初始化、迭代传播、最佳匹配块搜索,还有代价函数设计这几个关键环节拆开来讲。嗯,每个环节都有坑,我会把我在项目中踩过的雷一并告诉你。
4.1 随机初始化:给算法一个「起点」
PatchMatch的第一步,是为每个像素随机分配一个偏移量。这个偏移量表示:当前像素在参考帧中的最佳匹配块,位于另一帧的什么位置。
我刚开始做的时候,觉得随机初始化随便搞搞就行。后来发现,初始化的质量直接影响收敛速度。如果随机范围太大,算法要多迭代好几轮才能稳定。
初始化策略
- 全图随机: 每个像素的偏移量在[-S, S]范围内均匀采样。S一般取图像宽高的1/4到1/2。
- 局部约束随机: 只在小范围内随机,比如[-16, 16]像素。适合运动幅度不大的场景。
- 光流引导初始化: 先用光流算一个粗略的运动场,然后加上小随机扰动。收敛最快,但多了一步预处理。
4.2 迭代传播:让好偏移量「传染」给邻居
随机初始化之后,就是迭代传播阶段。这是PatchMatch的精髓——好的偏移量会像病毒一样在图像中扩散。
传播分两个方向:从左到右、从上到下(正向传播),再从右到左、从下到上(反向传播)。每次传播,当前像素会看看它左边和上边(或右边和下边)的邻居,如果邻居的偏移量能让自己的匹配代价更低,就「偷」过来用。
传播流程
- 正向传播: 遍历每个像素,检查左邻居和上邻居的偏移量。如果邻居偏移量+1(或-1)后的代价更低,就更新当前偏移量。
- 反向传播: 逆序遍历,检查右邻居和下邻居的偏移量。同样,代价更低就更新。
- 随机搜索: 在传播之后,以当前最佳偏移量为中心,在逐渐缩小的半径内随机采样。这一步是为了跳出局部最优。
// 伪代码:正向传播
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
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,颜色和梯度各占一半。如果场景纹理丰富,可以加大梯度权重;如果场景平坦,就减小梯度权重。
4.4 知识结构总览
下面这张图,把PatchMatch去鬼影的完整流程串起来了。你可以把它当作调试时的路线图。
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 | 天空、墙面,梯度信息少 |
4.6 实战避坑指南
最后,分享几个我在项目中踩过的坑,希望能帮你省点时间。
- 块半径别太大: 我曾经为了追求稳定性,把R设到15(31x31的块)。结果鬼影是没了,但细节全糊了。后来改成R=5,效果反而更好。
- 别忘了多尺度: 对于4K以上的大图,直接跑PatchMatch很慢。我习惯先降采样到1/4分辨率跑一遍,再把结果上采样作为初始值,在原始分辨率微调。速度能快4-5倍。
- 边界处理要小心: 图像边缘的像素,匹配块会超出图像范围。我一般用镜像填充,或者直接跳过边缘像素不处理。直接补零的话,边缘会出现明显的鬼影残留。
- 迭代轮数设上限: 我见过有人写死循环,直到代价不再下降才停止。但实际中,代价下降曲线很快就平了。设一个最大迭代次数(比如5轮),既保证效果又防止死循环。