3. 光流法去鬼影实战:Farneback光流计算、基于光流的warping对齐、遮挡区域检测与修复
光流法去鬼影,说白了就是让算法自己学会“看”运动。
你想想看,鬼影的本质是什么?是运动物体在不同曝光帧里的位置不一样。如果我们能精确算出每个像素从一帧到另一帧的运动矢量,然后按照这个矢量把图像“拽”回来对齐,鬼影自然就消失了。
我个人习惯把光流法去鬼影分成三个步骤:算光流 → 做warping → 修遮挡。每一步都有坑,咱们一个一个来填。
3.1 Farneback光流计算:稠密光流的工程选型
光流算法分两类:稀疏光流(如Lucas-Kanade)和稠密光流(如Farneback)。去鬼影必须用稠密光流,因为我们需要每个像素的运动信息。
核心结论:Farneback光流是目前工程上性价比最高的稠密光流算法。它不像深度学习光流那样吃算力,精度在HDR场景下完全够用。
我在项目中遇到过一个问题:直接用OpenCV的calcOpticalFlowFarneback默认参数,结果运动边缘全是噪点。后来调参才发现,参数对结果影响非常大。
| 参数 | 默认值 | HDR推荐值 | 说明 |
|---|---|---|---|
| pyr_scale | 0.5 | 0.5 | 图像金字塔缩放比例,一般不动 |
| levels | 3 | 4~5 | 层数越多,能处理的大运动越大,但耗时增加 |
| winsize | 15 | 21~31 | 窗口大小,HDR场景建议大一点,平滑运动场 |
| iterations | 3 | 5 | 迭代次数,多一些更稳定 |
| poly_n | 5 | 7 | 多项式展开邻域大小,越大越平滑 |
| poly_sigma | 1.1 | 1.5 | 高斯标准差,配合poly_n调整 |
| flags | 0 | OPTFLOW_FARNEBACK_GAUSSIAN | 使用高斯窗口代替均匀窗口,边缘更干净 |
我的调参口诀:大运动用高层数+大窗口,小运动用低层数+小窗口。HDR场景通常曝光差异大,我一般从levels=4, winsize=25开始试。
代码实现其实很简单,但要注意输入图像要转成灰度图,而且最好做一下直方图均衡化——曝光差异大的两帧,直接算光流容易跑偏。
import cv2
import numpy as np
def compute_farneback_flow(img_ref, img_target):
"""
计算参考帧到目标帧的稠密光流
img_ref, img_target: 0~1 float32 或 uint8 图像
返回: flow (h, w, 2), 通道0是x方向, 通道1是y方向
"""
# 转灰度
gray_ref = cv2.cvtColor(img_ref, cv2.COLOR_BGR2GRAY)
gray_target = cv2.cvtColor(img_target, cv2.COLOR_BGR2GRAY)
# 直方图均衡化,减少曝光差异的影响
gray_ref = cv2.equalizeHist(gray_ref)
gray_target = cv2.equalizeHist(gray_target)
# Farneback光流
flow = cv2.calcOpticalFlowFarneback(
gray_ref, gray_target,
None,
pyr_scale=0.5,
levels=4,
winsize=25,
iterations=5,
poly_n=7,
poly_sigma=1.5,
flags=cv2.OPTFLOW_FARNEBACK_GAUSSIAN
)
return flow
注意:光流计算的是从img_ref到img_target的运动。如果你想把img_target对齐到img_ref,需要用flow做反向warping,或者交换输入顺序。
3.2 基于光流的Warping对齐:把像素“拽”回原位
有了光流场,下一步就是把目标帧的每个像素按照运动矢量“拽”回到参考帧的位置。这个操作叫warping,OpenCV提供了remap函数。
嗯,这里要注意:remap需要的是映射坐标,不是运动矢量。映射坐标 = 原始坐标 + 运动矢量。
def warp_with_flow(img_target, flow):
"""
根据光流场对目标帧进行warping对齐
flow: 从ref到target的光流
返回: 对齐到ref坐标系的目标帧
"""
h, w = flow.shape[:2]
# 生成网格坐标
map_x, map_y = np.meshgrid(np.arange(w), np.arange(h))
# 映射坐标 = 原始坐标 + 光流
map_x = (map_x + flow[..., 0]).astype(np.float32)
map_y = (map_y + flow[..., 1]).astype(np.float32)
# remap插值
warped = cv2.remap(
img_target, map_x, map_y,
interpolation=cv2.INTER_LINEAR,
border_mode=cv2.BORDER_REPLICATE
)
return warped
关键点:插值方式用INTER_LINEAR就够了,INTER_CUBIC虽然更平滑但慢很多。边界处理用BORDER_REPLICATE,避免边缘出现黑边。
我曾经犯过一个低级错误:直接用flow的x和y分量作为remap的输入,结果图像完全扭曲了。后来才反应过来,remap要的是目标坐标,不是偏移量。
3.3 遮挡区域检测与修复:光流法最大的坑
光流法有个致命问题:遮挡。
为什么会这样?你想想看,物体从A位置移动到B位置,在A位置原本被物体挡住的背景,在B位置露出来了。光流只能算出“从A到B”的运动,但露出来的背景在参考帧里根本没有对应像素——这就是遮挡区域。
遮挡区域如果不处理,warping之后会出现撕裂、空洞、鬼影残留。我在项目中踩过这个坑,第一次跑通光流去鬼影时,运动物体边缘全是奇怪的条纹,排查了两天才发现是遮挡问题。
3.3.1 遮挡检测:前向-后向一致性校验
最可靠的遮挡检测方法是双向光流校验。简单说就是:
- 计算前向光流:从参考帧到目标帧
- 计算后向光流:从目标帧到参考帧
- 对每个像素,检查前向光流和后向光流是否“互逆”
如果前向光流指向位置p,后向光流从p指回来,两个矢量之和接近0,说明这个像素是可靠的。如果和很大,说明是遮挡区域。
def detect_occlusion(flow_fwd, flow_bwd, threshold=1.0):
"""
前向-后向一致性校验检测遮挡
flow_fwd: 从ref到target的光流
flow_bwd: 从target到ref的光流
threshold: 一致性阈值,像素单位
返回: occlusion_mask, 1表示遮挡
"""
# 用前向光流warp后向光流
h, w = flow_fwd.shape[:2]
map_x, map_y = np.meshgrid(np.arange(w), np.arange(h))
map_x = (map_x + flow_fwd[..., 0]).astype(np.float32)
map_y = (map_y + flow_fwd[..., 1]).astype(np.float32)
flow_bwd_warped = cv2.remap(
flow_bwd, map_x, map_y,
interpolation=cv2.INTER_LINEAR,
border_mode=cv2.BORDER_REPLICATE
)
# 计算一致性误差
diff = np.sqrt(
(flow_fwd[..., 0] + flow_bwd_warped[..., 0])**2 +
(flow_fwd[..., 1] + flow_bwd_warped[..., 1])**2
)
occlusion_mask = (diff > threshold).astype(np.uint8) * 255
return occlusion_mask
阈值怎么设?我一般设1~2个像素。如果场景运动比较剧烈,可以放宽到3~5个像素。注意阈值太小会把可靠区域也误判为遮挡。
3.3.2 遮挡修复:多帧融合与inpainting
检测出遮挡区域后,有两种修复思路:
| 方法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 多帧融合 | 遮挡区域用其他非遮挡帧的数据填充 | 信息真实,不引入伪影 | 需要3帧以上HDR,且其他帧也可能有遮挡 |
| 图像inpainting | 用周围像素推断遮挡区域的内容 | 只需2帧就能处理 | 可能产生模糊或纹理错误 |
| 混合策略 | 优先用多帧融合,失败区域用inpainting | 效果最好 | 实现复杂,计算量大 |
我个人推荐混合策略。在项目中,我通常先尝试从其他曝光帧找对应像素,如果找不到(比如所有帧都被遮挡),再用OpenCV的inpaint做修补。
def repair_occlusion(warped_img, occlusion_mask, other_frames):
"""
遮挡区域修复
warped_img: warping后的目标帧
occlusion_mask: 遮挡掩膜
other_frames: 其他可用帧列表
返回: 修复后的图像
"""
result = warped_img.copy()
# 策略1: 尝试从其他帧获取数据
for frame in other_frames:
# 假设frame已经对齐到参考帧坐标系
valid_mask = (occlusion_mask > 0) & (frame > 0)
result[valid_mask] = frame[valid_mask]
occlusion_mask[valid_mask] = 0
# 策略2: 剩余遮挡区域用inpainting
if np.any(occlusion_mask > 0):
result = cv2.inpaint(
result, occlusion_mask,
inpaintRadius=3,
flags=cv2.INPAINT_TELEA
)
return result
避坑指南:inpainting的半径不要设太大,3~5个像素就够了。半径太大,运动物体边缘会被“抹平”,失去细节。我曾经设了10个像素,结果运动物体边缘全糊了。
3.4 光流法去鬼影完整流程
把上面三个步骤串起来,就是一套完整的光流法去鬼影流程。我画了一张流程图,方便你理解整体架构。
整个流程看起来步骤多,但实际跑起来很快。我优化过的版本,在1080p图像上,光流计算+Warping+遮挡修复总共耗时约200ms(单线程),完全能满足实时HDR合成的需求。
最后说一句:光流法不是万能的。遇到大面积的纯色区域(比如天空、白墙),光流会失效,因为根本没有纹理可追踪。这时候需要结合其他去鬼影方法,比如权重法或基于深度学习的方案。但80%的HDR场景,光流法已经能处理得很好了。