
最近在调一个视频缺陷检测的项目碰到一个很有意思的问题产品在传送带上有轻微抖动但静态画面里根本看不出异常必须得知道哪个方向动了、动了多少像素。用帧差法试了一轮噪声大得没法看跑了几帧光流之后问题直接清晰了。那会儿我意识到光流模型Optical Flow这东西确实是让机器看懂运动的核心手段之一而且很多看似无关的应用从目标跟踪到动作识别底层都在吃这套技术。这篇东西就把我实际用下来的理解、算法选型经验、调参踩过的坑以及能直接跑通的代码思路一次说清楚。无论你是在做视频分析、想做姿态估计还是仅仅好奇计算机怎么感知运动这篇应该都能给你一个比较完整的抓手。光流本质上是在做一件事估计图像序列中每个像素或特征点在相邻帧之间的运动方向和速度。它不关心画面里有什么只关心画面里哪些东西在动、怎么动的。这个能力在实际项目里相当值钱因为它把时间维度的信息转化成了空间维度的运动场下游无论是做跟踪、分割、识别还是异常检测都有了一个比单纯帧差稳定得多的输入。1. 光流到底在算什么从两帧图到运动场的直觉理解先说一个很多新手容易搞混的点光流算出来的是一个场不是一个数。对于图像里的每一个坐标点光流算法都会给出一个二维向量通常记为(u, v)u表示水平方向的位移v表示垂直方向的位移。整张图的所有这些向量合在一起就构成了运动场Motion Field。所谓光流严格说是指图像亮度模式在时间上的变化它在数学上不等同于真实的物理运动但在绝大多数工程场景里两者高度相关所以我们通常直接把光流当作运动估计来用。为什么这个场这么重要举个很简单的例子。你拍一段街景视频路边有个人在走路背景的树木几乎不动。如果你做逐像素帧差人走过的地方会出现一大团变化的区域但你没法精确知道这团变化是向左飘还是向右飘、速度大概是多少。而光流会给每一个像素一个速度矢量行人有方向一致的箭头背景的箭头长度接近零。这个结构化的信息极大方便了后续处理。光流的输入输出非常明确输入两帧或多帧同一视角的图像灰度图即可彩色图一般会先转灰度输出一个和图像尺寸一致的运动向量场每个点携带(dx, dy)关于稠密光流和稀疏光流这两个方向也在这里一次说清楚。稀疏光流只计算图像中某些特定点通常是角点或特征点的运动速度快、抗干扰强适合跟踪任务。稠密光流则计算每一个像素点的运动信息完整、计算量大适合分割、插帧、运动分析这类需要全局运动场的任务。实际项目里这两个方向都有对应的成熟算法选哪个取决于你下游要什么。一个小直觉光流的结果不是一个动机分类而是一个精确到像素的运动测量。正因为是测量才存在误差、遮挡、光照变化等一大堆坑。这些坑后面细说。2. 经典算法谱系LK、HS、Farneback以及深度学习光流的选型逻辑光流算法发展了几十年大致可以分成两条技术路线传统优化派和深度学习派。传统派里最常被提名的包括Lucas-KanadeLK、Horn-SchunckHS以及OpenCV里常用的Farneback。深度学习派则是以FlowNet、RAFT为代表的一众端到端网络。选型这事没有绝对的谁更好只有谁更适合当前问题。2.1 Lucas-Kanade稀疏跟踪的老牌主力LK的核心假设是在一个足够小的局部窗口内所有像素的运动方向和速度基本一致。基于这个假设它把光流约束方程从单个像素扩展到窗口内的一组像素形成超定方程组再用最小二乘求解。我在项目里最常用的其实是LK的金字塔版本也就是PyramidLK。它通过构建图像金字塔从低分辨率开始估计大位移然后逐层细化到高分辨率从而解决原始LK只能处理小位移的问题。OpenCV里对应的函数是calcOpticalFlowPyrLK。这个函数返回的是特征点的跟踪位置不是全局场特别适合做点跟踪。2.2 Horn-Schunck稠密光流的祖师爷HS引入了全局平滑性约束假设整个运动场是平滑变化的通过最小化能量泛函求解。它的数学框架很优美但实际使用中如果不加改进经常会把运动边界糊掉——因为平滑约束不允许运动场发生突变而真实场景中前景和背景的运动往往就是突变的。现在直接使用HS的项目不多了但理解它的思路对理解后续算法非常有帮助。2.3 Farneback工程上最常用的稠密光流Farneback是目前OpenCV内置、最常被人直接调用的稠密光流算法。它的思路是把每个像素邻域的图像建模成多项式然后通过比较两帧的多项式系数来估计位移。它兼顾了计算速度和稠密输出调参维度多金字塔层数、窗口大小、迭代次数等是传统派里工程落地最方便的一个。下面是我整理的对比表方便大家根据实际情况选型算法输出类型对位移的容忍度计算速度主要适用场景典型缺点Lucas-Kanade稀疏点小金字塔版中等很快特征点跟踪、视觉里程计需要特征点无法输出全局运动场Horn-Schunck稠密场小较慢小位移运动分析边界模糊对参数敏感Farneback稠密场中等中等运动分割、插帧、常规运动分析光照变化敏感大位移易丢FlowNet系列稠密场较大快GPU实时端到端运动估计需要训练数据边缘有平滑倾向RAFT稠密场较大中GPU高精度光流估计计算成本较高依赖训练数据2.4 深度学习光流什么时候值得换赛道FlowNet是第一个把光流交给卷积网络的工作之后RAFT通过迭代细化让精度大幅提升。深度学习光流最大的优势是把遮挡、大位移、复杂纹理这些传统方法的难点在很大程度上通过学习解决掉了。不过我自己的经验是如果你的场景相对稳定、环境可控传统方法反而更省事不需要标注数据、不需要GPU、CPU上就能跑而且可解释性更强。深度学习光流更适合那些传统方法无法搞定的复杂场景前提是你有接近目标域的训练数据或者能直接用到预训练模型的泛化能力。在我的实践中第一版原型永远用Farneback跑通流程只有发现运动估计质量确实成为瓶颈时才会考虑换RAFT这类深度模型。理由很简单先用最简方案验证整体链路再针对瓶颈升级这才是高效的工程节奏。3. 光流的数学内核约束方程、孔径问题以及那些说不清道不明的为什么不少教程一上来就贴公式看得人头皮发麻。我还是尽量用人话把最核心的几个数学概念讲清楚不堆推导只讲物理直觉。3.1 亮度恒定假设与基本约束方程光流的第一个根本假设是同一个点在相邻两帧里的亮度不变。用数学表达就是I(x, y, t) I(x dx, y dy, t dt)也就是说追踪的那个点在t时刻和tdt时刻灰度值应该是一样的。对两边做泰勒展开、忽略高阶项就可以得到光流约束方程Ixu Iyv It 0其中Ix是图像在x方向的梯度Iy是y方向的梯度It是帧间灰度差。u、v就是我们需要求的光流。这个方程看起来很简单但里面藏着一个大问题一个方程有两个未知数u和v解不出来。这就是著名的孔径问题。孔径问题可以用一个很直观的例子理解你透过一个小圆筒看一条匀速运动的黑白条纹窗帘你只能看到条纹垂直于筒轴方向的分量在变化看不到平行方向的运动——因为条纹在这个方向上灰度没有变化眼睛没有任何信息可以锁定它。实际图像中某个像素窗口里如果纹理方向单一那么沿着该方向的光流分量就是不可观测的。这就是为什么光流算法都需要额外的约束要么假设局部窗口内运动一致LK要么假设全局运动场平滑HS。3.2 为什么窗口和金字塔对LK这么关键理解了孔径问题你就能明白LK为什么非要一个窗口。窗口的作用是引入更多空间信息让原本欠定的方程变成超定的。但窗口选大了会模糊运动细节选小了又容易退化成一个方向的约束。更麻烦的是如果物体位移超过窗口尺寸两帧之间特征点对不上算法直接失效。解决办法就是金字塔先在低分辨率层计算大范围位移然后逐层往上细化小位移。这就叫由粗到精Coarse-to-Fine的估计策略。每次做LK的时候我都会提醒自己金字塔层数决定了能处理的最大位移量窗口大小决定了局部运动一致性假设的空间尺度。这两个参数不是随便填的它们共同决定了算法能干什么、不能干什么。3.3 一个看起来矛盾但实际很重要的结论很多人以为光流就是找相似块其实不完全是。光流求的是运动矢量不是匹配得分。哪怕两帧里同一个物体外观发生了细微变化亮度略变、轻微形变只要基本约束仍然大致成立光流依然能给出合理的运动估计。反过来如果画面里有两块长得非常像的区域匹配可能乱跳光流也没法保证你选的是正确的那块。所以光流质量高度依赖场景纹理和运动连续性而不是单纯的相似度。4. 跑通第一版光流从OpenCV调用到可视化代码与实测结果理论讲再多不如直接跑一把。下面我以OpenCV的Farneback稠密光流和PyramidLK稀疏跟踪为例给出实际能跑的代码骨架和我在项目里的调用习惯。4.1 稠密光流Farneback的实际调用import cv2 import numpy as np cap cv2.VideoCapture(sample.mp4) ret, prev_frame cap.read() prev_gray cv2.cvtColor(prev_frame, cv2.COLOR_BGR2GRAY) # 创建HSV可视化底图 hsv np.zeros_like(prev_frame) hsv[..., 1] 255 while True: ret, frame cap.read() if not ret: break curr_gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 计算稠密光流 flow cv2.calcOpticalFlowFarneback( prev_gray, curr_gray, flowNone, pyr_scale0.5, levels3, winsize15, iterations3, poly_n5, poly_sigma1.1, flags0 ) # 提取方向和幅值 mag, ang cv2.cartToPolar(flow[..., 0], flow[..., 1]) # 用HSV的H通道表示方向V通道表示幅值 hsv[..., 0] ang * 180 / np.pi / 2 hsv[..., 2] cv2.normalize(mag, None, 0, 255, cv2.NORM_MINMAX) rgb_flow cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR) cv2.imshow(Farneback Optical Flow, rgb_flow) if cv2.waitKey(1) 0xFF ord(q): break prev_gray curr_gray cap.release() cv2.destroyAllWindows()这段代码本身不复杂但有几个细节值得说道说道。pyr_scale控制金字塔缩放比例通常0.5就是半分辨率levels是金字塔层数层数越多能捕捉的位移范围越大但层数过多会带来明显噪声我一般取3到5。winsize是多项式展开的邻域尺寸它直接影响算法的平滑程度尺寸越大光流场越平滑但细节丢失越严重。poly_n和poly_sigma是多项式展开的阶数和标准差一般保持默认5和1.1表现就比较稳。4.2 稀疏光流PyramidLK的实际调用import cv2 import numpy as np # 读取第一帧并提取角点作为跟踪点 cap cv2.VideoCapture(sample.mp4) ret, prev_frame cap.read() prev_gray cv2.cvtColor(prev_frame, cv2.COLOR_BGR2GRAY) # 用Shi-Tomasi角点检测器选点 prev_pts cv2.goodFeaturesToTrack(prev_gray, maxCorners200, qualityLevel0.01, minDistance30) lk_params dict( winSize(21, 21), maxLevel3, criteria(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 30, 0.01) ) color np.random.randint(0, 255, (1000, 3)) while True: ret, frame cap.read() if not ret: break curr_gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # 用金字塔LK计算新位置 curr_pts, status, err cv2.calcOpticalFlowPyrLK( prev_gray, curr_gray, prev_pts, None, **lk_params ) # 只保留跟踪成功的点 if curr_pts is not None: good_old prev_pts[status 1] good_new curr_pts[status 1] # 画箭头 for i, (new, old) in enumerate(zip(good_new, good_old)): x1, y1 new.ravel() x2, y2 old.ravel() frame cv2.arrowedLine(frame, (int(x1), int(y1)), (int(x2), int(y2)), color[i].tolist(), 2) cv2.imshow(LK Sparse Tracking, frame) if cv2.waitKey(1) 0xFF ord(q): break # 更新为防止点丢失过多可以定期重新检测角点 prev_gray curr_gray.copy() prev_pts good_new.reshape(-1, 1, 2) cap.release() cv2.destroyAllWindows()这里有个很实用的习惯如果跟踪点数量掉到某一阈值以下就用goodFeaturesToTrack重新补点。不然跑几十秒之后点越来越少跟踪效果会肉眼可见地退化。4.3 关于可视化的一个建议光流可视化有很多风格HSV色相表示方向、亮度表示幅值是目前最通用的方案。方向用色相的好处是你可以一眼看出前景和背景的运动方向是否一致。在排查算法效果的时候我会同时打印幅值直方图——大量接近零的值说明画面静止分布广泛说明运动明显。这个直方图在你调阈值时非常有用。5. 调参实战与踩坑记录窗口大小、金字塔层数、光照突变一个都不能省这个部分是整篇里我最想写的光流从能跑到跑得好中间全是调参和踩坑的活。我把最常见的几个问题整理成排查表然后挑几个典型案例展开讲。现象可能原因排查方向光流场噪声大、箭头乱跳winsize过小或图像噪声大先做高斯模糊再增大winsize跟踪点大量丢失金字塔层数不足或运动位移过大增大maxLevel或用更高分辨率输入运动边界糊成一团Farneback平滑过强减小winsize降低poly_n光照变化导致全场漂移亮度恒定假设被破坏改用梯度域特征或先做直方图均衡低纹理区域光流几乎为0孔径问题导致不可观测接受该区域无光流不影响主任务即可5.1 窗口大小的临界效应我有一次在楼道监控视频上跑Farneback画面上有一个行人走过结果行人的轮廓糊成了彗星尾巴。当时winsize设的是30这个值在平滑背景上表现很好但行人边缘这种运动突变区域窗口太大会把周边静止像素也平均进来。后来降到10边缘干净了不少但背景噪声又多了一点。最后的做法是串联处理先把图像做轻微高斯模糊降噪再用winsize12噪声和边缘的平衡一下就找到了。这里想强调一个观念参数不是越大越好也不是越小越好而是要和目标的物理尺寸、运动速度匹配。如果你的目标物体在画面里只有10个像素大winsize30天然就会失败。5.2 金字塔层数与最大可测位移的关系金字塔层数的效果我用一个直观例子解释假设物体在相邻帧间移动了40个像素。如果只做单层光流窗口可能只有15像素宽算法很难建立对应关系。但如果你先下采样到1/4分辨率两层金字塔物体的位移就变成了约10像素刚好落在可测范围内。逐层上采样修正后就可以还原出原始的40像素位移。所以判断金字塔够不够高的经验公式很简单最大可测位移约等于最底层窗口尺寸乘以2的levels次方乘以pyr_scale的累乘。实践里我通常从levels3起步如果特别大的位移仍然跟丢就先升到4或5看看效果。但坦白讲如果物体一帧内位移超过其自身尺寸任何传统光流都很难救这种场景需要提高帧率或者改用深度学习模型。5.3 光照变化亮度恒定假设失效的典型症状有一次做室内场景分析画面里有盏灯是频闪的结果光流场出现了周期性的全局跳动——整个画面都在呼吸。这正是亮度恒定假设被打破的典型案例像素值在变化但物理上并没有运动。Farneback对光照变化相当敏感因为它做多项式展开时直接使用亮度信息。我处理这个问题的办法是在输入光流算法之前先对图像做局部直方图均衡或者差分归一化让帧间的整体亮度变化被削弱。如果场景中光照变化实在太大就需要换思路使用基于梯度的光流算法或者干脆把光流输入换成梯度幅值图。5.4 低纹理区域不要和白墙较劲在实际工程项目里有一件必须认清的事低纹理区域白墙、天空、光滑地面就是算不出光流的。这不是算法不够好而是信息论层面上的边界——没有纹理就没有可追踪的锚点。我早期浪费了不少时间试图让白墙区域的光流也好看后来意识到正确的做法是在评估光流质量时把低梯度区域的输出直接当作无效值忽略只关心感兴趣区域内的光流质量。这也是为什么稀疏光流在很多工业场景里比稠密光流更实用——它从一开始就只挑纹理丰富、适合跟踪的角点。5.5 一个容易被忽略的预处理尺寸与帧率输入分辨率越高光流细节越好但计算量是平方级增长帧率越高帧间位移越小光流越容易算准。很多项目里这两者是矛盾的高帧率相机往往分辨率受限或带宽吃紧。我的建议是先以任务需求为准设定分辨率和帧率然后观察帧间最大位移再决定金字塔层数和窗口大小而不是一上来就追求最高配置。6. 光流在真实项目中的应用规律从目标跟踪到视频插帧的工程落点光流不只是实验室玩具它在很多实际产品里都是核心模块。我挑几个自己接触过或拆解过的典型场景来说说它的工程落点。6.1 视频监控中的运动目标检测与跟踪传统帧差法就是相邻帧直接做差对人影、树叶摆动这些场景极其敏感只要画面里有一点风吹草动就会产生大量误检。而光流输出的是方向一致的运动矢量场这种结构化信息天然支持了按方向聚类——同一目标的运动矢量应该指向同一个大致方向背景噪声矢量则是随机散乱的。很多监控系统里光流就是用来做运动显著性检测的提取高幅值区域在区域内统计主方向再结合时序信息追踪目标。6.2 视频插帧与慢动作生成现在不少手机的视频补帧功能底层就是光流。原理很简单已知前一帧和后一帧的像素运动就可以在时间中间位置生成一张新帧把运动物体移到中间位置。这类应用对光流质量要求很高——光流边缘一旦出错生成的插帧会出现明显的残影或扭曲。工业产品中常用的其实不是Farneback而是基于深度学习的RAFT类网络但仍然可以看到经典光流算法的影子。6.3 车辆自主定位中的视觉里程计视觉里程计是移动机器人、无人驾驶里一个基础能力通过连续帧间图像估计相机的自身运动。光流在这里的作用是提供大量点对应关系然后使用对极几何或者PnPPerspective-n-Point求解相机位姿。这个场景下稀疏光流比稠密光流更常用因为只需要足够多的可靠点不需要整个运动场。而且特征点选在角点处跟踪稳定性远高于背景区域。6.4 医学影像与微观分析有一个我觉得很有潜力的方向是医学影像里的光流应用比如超声序列中的组织运动估计、细胞运动轨迹分析。这些场景的运动幅度通常在1到10个像素之间恰好是稠密光流算法表现不错的区间。这里光流不追求物体级别的理解而是追求亚像素级别的精确测量Horn-Schunck类算法的平滑假设反而成了优势。不过医学数据的灰度变化规律和自然图像差异很大直接用OpenCV默认参数往往效果不佳需要根据成像特性做专门的参数适配。7. 给新手的最后几条建议从哪里开始、怎么评估、什么时候果断放弃光流这个领域入门不难入门之后的路却很长。我给刚开始接触的人几条实在的建议。先别急着读论文。用OpenCV的Farneback跑通一段自己的视频仔细观察不同参数下光流场的变化建立对光流长什么样的直觉。这一步比读十篇论文都重要。然后找一段带ground truth光流的标准数据集比如公开的MPI-Sintel数据集跑几个算法对比一下EPEEnd-Point Error端点误差指标。这个指标衡量的是预测运动向量与真实运动向量之间的平均距离是评估光流算法最常用的量化标准。在评估自己的模型时有一个更贴近工程的做法不要只看光流图美不美要看下游任务的效果。比如你的下游是目标跟踪那光流质量好不好就看跟踪成功率下游是运动分割就看分割的IoU交并比。光流只是中间表示最终为任务服务这个观点带着做项目会少走很多弯路。最后说说什么时候应该放弃传统光流。场景满足下面任意一条直接考虑深度学习光流帧间位移大得连金字塔都救不回来画面里经常出现遮挡导致大面积跟踪失败物体是非刚体运动形态变化剧烈。如果这些条件都没有传统光流完全够用而且它的可解释性和实时性都是优势。回到我开头的那个项目最后我们就是用Farneback加定向阈值解决的运动检测问题。那个项目让我最深的体会是光流模型看起来是纯理论的东西但真正让它发挥价值的是对参数、对约束、对场景边界的理解。希望这篇东西能让你少浪费一些时间在那些我已经踩过的坑上。