光流估计这件事,我在做视频插帧和运动分析的项目里绕不开。早期用过块匹配和Lucas-Kanade那套经典方法,小位移还行,一旦物体动得快一点、边缘纹理弱一点,结果就糊成一片。后来接触到FlowNet系列,尤其是FlowNet2,才算是真正把稠密光流的质量拉到了能用的水平。这篇就聊聊怎么从零把FlowNet2跑起来,生成一张光流图,中间踩过的坑、环境配置的细节、推理参数的取舍,我都会摊开讲。如果你手头有视频处理、运动估计、自动驾驶感知或者视频编辑的需求,想找一个靠谱的稠密光流方案,这篇应该能帮你省下不少折腾的时间。
1. 先搞清楚FlowNet2到底解决了什么问题
1.1 从传统光流到深度学习光流的转折
传统光流方法的核心思路是"找对应点"。以Lucas-Kanade为例,它假设一个小窗口内的像素运动是一致的,然后通过求解亮度恒定约束方程来估计位移。这个假设在纹理丰富、运动平缓的场景下成立,但现实视频里充满了大位移、遮挡、光照变化,窗口一放大假设就崩了。Horn-Schunck加了全局平滑约束,能出稠密光流,但边缘保持得不好,物体边界处经常拖出一条尾巴。
FlowNet是第一批用卷积神经网络直接回归光流的工作。它的思路很直接:把两张连续帧堆叠起来送进网络,让网络自己学像素级的位移。FlowNetS用编码器-解码器结构,FlowNetC引入相关层(correlation layer)显式计算两帧特征之间的匹配代价。但初代FlowNet的精度还是打不过当时最好的传统方法,尤其是小位移和细节区域。
FlowNet2的关键改进在于"堆叠"和"迭代精化"。它把多个FlowNet子网络串起来,第一个网络出粗略光流,后续网络基于前一级的光流和图像特征做残差修正。同时它引入了图像扭曲(warping)操作,把第二帧按照当前估计的光流扭曲到第一帧的坐标系下,这样后续网络只需要学习残差,难度大大降低。这个设计思路和后来很多光流网络(如PWC-Net、RAFT)是一脉相承的。
1.2 FlowNet2的精度和速度到底什么水平
官方在FlyingChairs和FlyingThings3D数据集上训练,在Sintel和KITTI基准上评测。以Sintel Final为例,FlowNet2的端点误差(EPE)大约在1.5到2.0像素之间,而初代FlowNet在3.0以上。传统方法里表现最好的DeepFlow、EpicFlow大概在1.5到2.5之间,FlowNet2已经能持平甚至超越。在KITTI 2015上,FlowNet2的Fl-all指标(光流误差大于3像素且相对误差大于5%的像素占比)能压到10%以内,这个水平对于很多实际应用已经够用了。
速度方面,FlowNet2因为堆叠了多个子网络,参数量大,在1080Ti上处理一对512x384的图像大约需要0.1到0.2秒,也就是5到10 FPS。如果输入分辨率更高,时间线性增长。这个速度不算快,但比很多迭代优化类的传统方法(比如DeepFlow动辄几十秒)已经快了几个数量级。如果你的场景对实时性要求极高,可能需要考虑FlowNet2的轻量版本或者后来的PWC-Net。
1.3 为什么现在还要用FlowNet2
你可能会问,RAFT、PWC-Net这些后起之秀精度更高、速度更快,为什么还要折腾FlowNet2?我的实际体会是:第一,FlowNet2的预训练模型非常成熟,官方提供了多个变体的权重,开箱即用的稳定性好;第二,它的代码结构清晰,依赖相对简单,适合作为学习光流网络的入门跳板;第三,很多工业场景的模型是几年前部署的,FlowNet2仍然是基线,你需要复现和对比;第四,FlowNet2的Caffe实现和PyTorch复现都有,选择灵活。所以即便到了今天,把FlowNet2跑通仍然是一个有价值的技能点。
2. 环境搭建:那些官方文档不会告诉你的细节
2.1 硬件和基础软件的选择
FlowNet2官方代码是基于Caffe的,后来有PyTorch复现版本。如果你走Caffe路线,需要NVIDIA GPU和CUDA。我实测下来,CUDA 9.0或10.0配合cuDNN 7.x比较稳,太新的CUDA版本(比如11.x)在编译Caffe时容易遇到兼容性问题。GPU显存建议至少4GB,因为FlowNet2的堆叠结构在推理时中间特征图占用不小,512x384的输入大概需要2GB左右,如果输入更大就要相应增加。
操作系统方面,Ubuntu 16.04或18.04是官方测试过的环境,CentOS也能跑但依赖库版本需要自己对齐。如果你用Windows,Caffe的编译会比较痛苦,建议直接用PyTorch版本或者WSL。Python版本推荐3.6到3.7,太新的3.9+在装一些老依赖时会有问题。
PyTorch路线的话,PyTorch 1.0到1.4之间的版本兼容性最好,因为很多复现代码是基于这个时期写的。torchvision版本要对应,一般0.2到0.4。如果你用更新的PyTorch 2.x,可能需要手动改一些API调用,比如torch.nn.functional.grid_sample的参数顺序和默认值有变化。
2.2 Caffe版本的编译陷阱
Caffe编译是最大的拦路虎。首先,Makefile.config的配置要仔细。USE_CUDNN := 1要打开,CUDA_ARCH要根据你的GPU计算能力设置,比如1080Ti是6.1,V100是7.0。如果设错了,编译能过但运行时会报"no kernel image is available"。
其次,Boost、Protobuf、Glog、Gflags这些依赖的版本要匹配。我遇到过Protobuf 3.6和Caffe自带代码不兼容的情况,报错是一堆关于RepeatedPtrField的模板错误。解决办法是降级到Protobuf 3.5或者用Caffe自带的./scripts/proto重新生成。Glog在0.4版本之后改了API,google::InitGoogleLogging的调用方式有变,如果编译报错就换0.3.5。
还有一个坑是OpenCV版本。Caffe默认用OpenCV 2.4的API,如果你系统装的是OpenCV 3.x或4.x,cv::imread的返回类型和CV_LOAD_IMAGE_COLOR这些常量名都变了。要么改代码适配,要么在Makefile里指定OpenCV 2.4的路径。我建议直接用OpenCV 3.4,然后手动改几处调用,工作量不大。
2.3 PyTorch版本的依赖安装
PyTorch版本省去了编译Caffe的麻烦,但依赖也不少。核心是numpy、scipy、opencv-python、matplotlib。scipy用来做图像扭曲和插值,opencv-python用来读写图像和可视化光流。版本上,numpy不要超过1.19,因为老代码里有些np.float的用法在新版被移除了。opencv-python用3.4.x或4.1.x都行,但4.5+改了cv2.findContours的返回值,如果代码里用到要注意。
如果你要用官方的预训练权重,需要下载.pth文件。这些文件通常几百MB,下载后放到指定目录。注意权重的键名要和模型定义完全对应,如果是从Caffe转换过来的权重,键名可能有module.前缀或者层名不一致,需要写脚本做键名映射。我一般会先打印模型state_dict的键,再和权重文件的键做对比,找出差异后手动改。
3. 数据准备与输入格式的讲究
3.1 图像对的要求
FlowNet2的输入是两张连续帧,通常命名为frame_0001.png和frame_0002.png。图像格式支持PNG、JPG,但建议用PNG避免压缩伪影,因为光流对高频细节敏感,JPG的块效应会干扰匹配。图像尺寸最好是32的倍数,因为网络里有多次下采样,如果尺寸不是32的倍数,在解码器上采样时会出现尺寸不匹配。我一般会先把图像resize到最近的32倍数,比如1280x720就resize到1280x736,或者直接裁剪。
图像的内容也有讲究。如果两帧之间亮度变化剧烈(比如闪光灯、曝光调整),光流质量会下降,因为亮度恒定假设被破坏了。如果场景里有大面积无纹理区域(比如白墙、天空),光流在这些地方会不可靠,网络只能靠平滑先验去猜。这些是光流方法的固有局限,不是FlowNet2独有的问题,但你在评估结果时要心里有数。
3.2 预处理:归一化和尺寸调整
FlowNet2的输入图像需要归一化到[0,1]或者[-1,1]范围,具体取决于训练时的设置。官方Caffe版本通常用均值减法,均值是ImageNet的均值或者数据集自己的均值。PyTorch版本一般直接除以255。如果你不确定,可以看模型定义里的transform部分,或者试两种方式看哪种输出更合理。
尺寸调整我建议用双线性插值,不要用最近邻,因为最近邻会引入锯齿,影响匹配。如果原始图像很大(比如4K),直接送进网络会爆显存,需要先缩小。缩小的比例要记录,因为输出的光流是在缩小后的尺度上的,如果要映射回原图,需要按比例放大光流值。比如缩小了2倍,光流值也要乘以2。
3.3 批量推理的组织方式
如果你要处理视频,逐帧推理效率低。可以把连续帧组成多个图像对,批量送进网络。但要注意,FlowNet2的堆叠结构对批量大小敏感,批量太大会爆显存,太小则GPU利用率低。我一般用批量大小4到8,具体看显存。另外,图像对之间如果有重叠(比如帧1-2、帧2-3、帧3-4),可以共享一些计算,但FlowNet2的代码通常不直接支持,需要自己改。
数据加载器方面,PyTorch的DataLoader配合num_workers可以加速,但num_workers设太大反而会因为进程间通信开销拖慢。我一般设4到8。如果图像已经预加载到内存,num_workers可以设0,避免多进程拷贝开销。
4. 模型加载与推理的核心步骤
4.1 模型定义的关键参数
FlowNet2的模型定义里有几个关键参数需要理解。rgb_max是输入图像的亮度最大值,通常是255.0,用来做归一化。div_flow是输出光流的缩放因子,官方设置是20.0,意思是网络输出的原始值要除以20才是真实光流。这个参数在训练时确定,推理时必须一致,否则光流值会差20倍。
网络结构上,FlowNet2由FlowNet2C、FlowNet2S、FlowNet2CS、FlowNet2CSS、FlowNet2SD等多个子网络组成。FlowNet2CS是C和S的串联,FlowNet2CSS是C、S、S的串联,FlowNet2SD是S和SD的串联。不同的组合精度和速度不同,FlowNet2默认用CSS组合,精度最高但最慢。如果你追求速度,可以用FlowNet2S或FlowNet2CS。
4.2 权重加载的常见报错与修复
加载权重时最常见的报错是键名不匹配。比如权重文件里的键是module.conv1.weight,而模型定义里的键是conv1.weight,差一个module.前缀。这是因为权重是在DataParallel或DistributedDataParallel下保存的。解决办法是遍历权重字典,把module.前缀去掉再加载。
另一个报错是形状不匹配。比如权重里的卷积核是[64, 3, 7, 7],而模型定义里是[64, 3, 5, 5],说明模型版本和权重版本不对应。这时候要么换权重,要么改模型定义。我一般会先打印两边的键和形状,用集合操作找出差异,然后决定是改代码还是换权重。
还有一种情况是权重文件是Caffe格式(.caffemodel),需要转换成PyTorch格式。转换脚本通常需要Caffe环境,或者用caffe2pytorch这类工具。转换时要注意Caffe的卷积核布局是[out_channels, in_channels, h, w],和PyTorch一致,但全连接层的布局可能不同,需要转置。
4.3 推理流程的代码骨架
推理的核心流程是:读入两张图像,归一化,转成Tensor,送进模型,得到光流输出,乘以div_flow,然后可视化或保存。下面是一个简化的代码骨架:
import torch import cv2 import numpy as np from model import FlowNet2 # 初始化模型 model = FlowNet2(rgb_max=255.0, div_flow=20.0) model = torch.nn.DataParallel(model).cuda() model.load_state_dict(torch.load('FlowNet2_checkpoint.pth.tar')['state_dict']) model.eval() # 读入图像 img1 = cv2.imread('frame_0001.png') img2 = cv2.imread('frame_0002.png') # 预处理 img1 = cv2.resize(img1, (512, 384)) img2 = cv2.resize(img2, (512, 384)) img1 = img1[:, :, ::-1].astype(np.float32) # BGR to RGB img2 = img2[:, :, ::-1].astype(np.float32) # 转Tensor img1 = torch.from_numpy(img1).permute(2, 0, 1).unsqueeze(0).cuda() img2 = torch.from_numpy(img2).permute(2, 0, 1).unsqueeze(0).cuda() # 推理 with torch.no_grad(): flow = model(img1, img2) # 后处理 flow = flow.squeeze(0).permute(1, 2, 0).cpu().numpy()这段代码里,rgb_max和div_flow要和权重训练时一致。permute的顺序是[C, H, W],因为PyTorch的卷积要求通道在前。unsqueeze(0)是加批量维度。推理时用torch.no_grad()关闭梯度,省显存。
4.4 光流可视化:颜色编码的细节
光流的可视化通常用颜色编码:色相(Hue)表示方向,饱和度(Saturation)表示大小,亮度(Value)固定为255。OpenCV的HSV空间里,H范围是0到179,对应角度0到360度。所以要把光流角度映射到H通道,大小映射到S通道。
具体做法是:先计算光流的幅值和角度,角度转成H值,幅值归一化到0到255作为S值,V全设255,然后转成BGR显示。注意角度要映射到0到179,因为OpenCV的H通道是0到179。另外,幅值归一化时可以用最大幅值或者固定阈值,固定阈值的好处是不同图像之间可比,但可能过曝或欠曝。
def flow_to_color(flow): h, w = flow.shape[:2] hsv = np.zeros((h, w, 3), dtype=np.uint8) mag, ang = cv2.cartToPolar(flow[:,:,0], flow[:,:,1]) hsv[:,:,0] = ang * 180 / np.pi / 2 hsv[:,:,1] = cv2.normalize(mag, None, 0, 255, cv2.NORM_MINMAX) hsv[:,:,2] = 255 return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)这段代码里,cartToPolar把x和y方向的光流转成幅值和角度。角度乘以180/pi/2是因为OpenCV的H通道是0到179,而角度是0到360度,所以除以2。normalize把幅值拉到0到255。最后cvtColor转成BGR方便显示。
5. 实测中的意外情况与排查思路
5.1 输出光流全黑或全灰
第一次跑通代码后,我遇到输出光流图全黑的情况。排查下来有几个可能原因。第一,div_flow没乘,网络输出的原始值很小(因为训练时除以了20),不乘回来就接近0。第二,图像归一化不对,如果输入是0到255的原始值,而模型期望0到1,网络会输出异常。第三,权重没加载成功,模型用的是随机初始化,输出自然是噪声。排查时可以先打印光流的数值范围,正常应该在-100到100之间(对于512x384的图像),如果全是0或者NaN,就是上述问题。
5.2 光流方向反了
有一次我发现光流方向整体反了180度,物体向右动,光流却指向左。原因是图像对的顺序搞反了。FlowNet2估计的是从第一帧到第二帧的光流,如果你把img1和img2传反了,光流方向就反了。检查方法是找视频里一个明显向右移动的物体,看光流颜色是不是偏红(假设你的颜色编码里右是红)。如果偏青,就是反了。
5.3 边缘区域光流噪声大
图像边缘的光流经常很乱,这是因为卷积网络在边界处有padding,边界像素的感受野不完整。另外,如果物体移出了画面,边界处没有对应点,光流本身就不定义。缓解办法是在边缘留出一定margin,或者用图像修复把边界补全。我在做视频稳定时,会先把图像边缘裁掉10到20像素,只用中间区域的光流。
5.4 显存溢出与批量大小调整
显存溢出是常见问题。FlowNet2的堆叠结构在推理时中间特征图很多,512x384的输入大概需要2GB,1024x768就要8GB以上。如果显存不够,可以减小输入尺寸、减小批量大小,或者用torch.cuda.empty_cache()手动清理缓存。另外,DataParallel在多卡推理时会复制模型到每张卡,显存占用翻倍,如果只有一张卡就不要用DataParallel。
6. 从光流到实际应用的衔接
6.1 光流用于视频插帧的注意事项
视频插帧是光流最直接的应用。基本思路是:估计帧1到帧2的光流,然后按照时间比例t扭曲帧1和帧2,再融合。但直接扭曲会有遮挡问题:物体移开后露出的背景在帧1里没有,帧2里有;物体移入遮挡的区域在帧1里有,帧2里没有。处理遮挡的常见方法是计算前向和后向光流,做一致性检查,不一致的区域标记为遮挡,然后用图像修复或者只用一个方向的光流。
FlowNet2估计的光流在遮挡边界处会有误差,因为网络没见过遮挡区域的对应关系。我一般会加一个后处理:用前向-后向一致性检查,把不一致的像素的光流置零或者用邻域中值滤波。这样插帧的伪影会少很多。
6.2 光流用于运动检测的阈值选择
运动检测里,光流的幅值超过阈值就认为是运动区域。阈值的选择很关键:太小会把噪声当运动,太大会漏掉慢速运动。我的经验是,先统计光流幅值的分布,取95%分位数作为阈值,或者用Otsu方法自动找。另外,光流的方向一致性也可以用来过滤:如果一片区域的光流方向杂乱,可能是噪声或者非刚性运动,可以进一步用聚类或者连通域分析。
6.3 光流用于视频压缩的运动补偿
视频压缩里,光流可以用来做运动补偿预测。编码器估计当前块相对于参考帧的运动,只编码残差。FlowNet2的稠密光流比块匹配的稀疏运动矢量更精细,但计算量大,通常只在离线编码或者高质量场景用。实际部署时,可以把光流下采样到块级别,或者只用光流做初始搜索,再用传统方法精化。
7. 一些让结果更稳的实操技巧
7.1 多尺度推理与结果融合
FlowNet2本身是多尺度的,但如果你对某些区域的结果不满意,可以尝试多尺度推理:把图像缩放到不同尺寸,分别估计光流,然后上采样到原尺寸做融合。融合时可以用中值滤波或者加权平均,权重根据光流的一致性来定。这个方法对细节区域的光流提升明显,但计算量翻倍。
7.2 前后向一致性检查的实现
前后向一致性检查是提升光流可靠性的常用手段。具体做法是:估计帧1到帧2的光流flow_f,再估计帧2到帧1的光流flow_b,然后把flow_b按照flow_f扭曲到帧1的坐标系,计算flow_f + warp(flow_b)的幅值,如果超过阈值(比如1到3像素),就认为该像素的光流不可靠。不可靠的像素可以用邻域中值替换,或者标记为无效。
def forward_backward_check(flow_f, flow_b, threshold=1.0): # 扭曲flow_b到帧1坐标系 flow_b_warped = warp_flow(flow_b, flow_f) # 计算一致性误差 diff = flow_f + flow_b_warped mag = np.sqrt(diff[:,:,0]**2 + diff[:,:,1]**2) # 标记不可靠区域 mask = mag > threshold return maskwarp_flow函数用cv2.remap实现,根据光流把每个像素映射到新位置。注意映射时坐标要归一化到[-1,1]或者用像素坐标,取决于remap的参数。
7.3 光流平滑与边缘保持的平衡
光流后处理里,平滑可以去掉噪声,但会模糊边缘。我一般用双边滤波或者引导滤波,它们能在平滑的同时保持边缘。双边滤波的参数:空间sigma设2到5,颜色sigma设10到30,具体看图像噪声水平。引导滤波用原图作为引导,半径设4到8,epsilon设0.01到0.1。实测下来,引导滤波的边缘保持比双边滤波更好,但计算稍慢。
7.4 保存光流的格式选择
光流保存格式有.flo、.png、.npy等。.flo是Middlebury标准格式,用二进制存储,包含魔数、宽高和浮点数据,适合和其他光流工具交换。.png只能存可视化结果,不能存原始浮点值。.npy是numpy格式,方便后续处理。我一般同时存.flo和.npy,.flo用于对比,.npy用于自己的流程。
.flo的写入要注意字节序,Middlebury格式是小端。写入时先写魔数PIEH(浮点数202021.0的十六进制),然后写宽高(int32),然后写光流数据(float32,先x后y)。读取时反过来。如果字节序搞错,读出来的宽高会是天文数字。
8. 关于FlowNet2的局限和替代方案
FlowNet2虽然经典,但局限也明显。第一,它假设亮度恒定,对光照变化敏感。第二,它在大位移和遮挡区域表现一般。第三,它的速度在实时场景下不够。如果你需要更高精度,可以看RAFT,它用迭代更新和相关性金字塔,在Sintel和KITTI上都比FlowNet2好,而且代码更简洁。如果你需要实时,可以看PWC-Net或者LiteFlowNet,它们用轻量级结构,速度能到30 FPS以上。
不过,FlowNet2的价值在于它的稳定性和成熟度。很多工业场景的模型是几年前部署的,FlowNet2仍然是基线。而且它的代码结构清晰,适合作为学习光流网络的入门。我建议你先用FlowNet2跑通整个流程,理解光流的输入输出、可视化、后处理,然后再迁移到更新的模型。这样你对光流问题的理解会更扎实,遇到新模型时也能快速上手。
最后分享一个小技巧:如果你在调试光流质量,可以先用合成数据(比如人为平移一张图像)来验证。平移是已知的,光流应该等于平移量。如果估计的光流和真实平移差很多,说明流程有问题。这个方法能快速定位是模型问题还是数据问题。