ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Halcon光流分析实战:optical_flow_bicycle例程详解与参数调优

Halcon光流分析实战:optical_flow_bicycle例程详解与参数调优

光流分析这个词,第一次听的人容易联想到流体力学或者某种光学实验,但在机器视觉圈子里,它其实是一个非常接地气的运动估计工具。Halcon 自带的optical_flow_bicycle.hdev这个例子,名字里带了个 bicycle,讲的就是怎么用光流法去分析一段骑行画面里的运动信息。我最早接触这个例程是在做一个传送带物料跟踪的项目,当时试过帧差法、背景减除,效果都不太理想,后来翻到光流这块才把问题解决掉。这个例程虽然短,但它把光流从概念到落地的整条链路都串起来了,特别适合那些想搞明白“像素怎么变成运动矢量”的人。不管你是刚上手 Halcon 的新人,还是已经写过一些 blob 分析、模板匹配、想做运动分析的老手,这个例子都值得花时间拆一遍。下面我就按自己复现和改造这个例程的实际过程,把里面的设计思路、算子细节、参数调优和踩过的坑完整讲一遍。

1. 光流分析的整体设计与方案选型

1.1 为什么这个场景要选光流而不是帧差或模板匹配

先把这个例程要解决的问题说清楚。optical_flow_bicycle.hdev处理的是一段连续图像序列,画面里有一辆自行车在运动,目标是估计出画面中各个像素点的运动方向和速度。这类需求在工业现场非常常见,比如传送带上的物料位移测量、振动台的微小形变监测、运动机构的轨迹跟踪。摆在面前的技术路线其实有好几条,帧差法、背景减除、模板匹配、光流法,每条路都有自己的适用边界。

帧差法是最容易想到的,两帧相减,非零区域就是运动区域。它的优点是计算量小、实现简单,但缺点也很致命:它只能告诉你“这里动了”,没法告诉你“往哪个方向动、动得多快”。而且帧差法对运动速度很敏感,物体动得太快,两帧之间没有重叠,差出来的是一片空心轮廓;动得太慢,两帧几乎一样,差出来接近零。背景减除需要先建背景模型,对光照变化和背景扰动很敏感,自行车这种非刚性目标加上复杂背景,建模成本很高。模板匹配倒是能给出位移,但它本质上是找整块区域的刚性对应,自行车骑行过程中轮子在转、腿在蹬,形状一直在变,模板匹配的相似度会掉得很厉害。

光流法的思路完全不一样。它不关心“这块区域是不是同一个物体”,而是逐像素去估计瞬时速度矢量。自行车轮子转、腿在蹬,每个像素点的运动方向都不一样,光流恰好能把这种非刚性、密集的运动场刻画出来。这就是选型的核心逻辑:当目标运动是非刚性的、需要逐像素速度信息、且帧间位移较小的时候,光流是比帧差和模板匹配更合适的选择。这个例程选光流,不是因为它高级,而是因为它的输出形态(密集矢量场)和问题的需求(非刚性运动估计)对上了。

1.2 稠密光流与稀疏光流的取舍

光流本身还分两大流派:稠密光流和稀疏光流。稠密光流给画面里每一个像素都算一个速度矢量,输出是一张和原图同尺寸的矢量场;稀疏光流只跟踪一批特征点(比如角点),输出是若干条轨迹。这个例程走的是稠密光流路线,用的是 Halcon 的optical_flow_mg算子。

为什么选稠密?因为自行车这个目标上可跟踪的稳定角点其实不多,轮辐在转、背景纹理也一般,稀疏光流很容易跟丢。稠密光流虽然计算量大,但它不依赖特征点检测的质量,对弱纹理区域也能给出估计,鲁棒性更好。代价就是速度慢,所以这个例程在实现上做了不少工程上的折中,比如用多尺度金字塔来加速收敛,这个后面会细讲。你在实际项目里如果对实时性要求高,可以先降分辨率再算光流,或者只在 ROI 里算,没必要整幅图全铺开。

1.3 例程的整体流程骨架

把例程从头到尾捋一遍,它的流程其实很清晰,分成四段:读图序列、预处理、光流计算、结果可视化。读图部分用read_image配合循环或者open_framegrabber拿连续帧;预处理主要是灰度转换和必要的平滑;核心是optical_flow_mg算矢量场;最后用vector_field_to_real把矢量场拆成幅值和方向两张实值图,再用dev_display叠加显示。

这个骨架看起来简单,但每一段都有讲究。比如预处理为什么只做灰度转换不做锐化?因为光流算法依赖的是亮度守恒假设,锐化会引入高频噪声,反而破坏这个假设。再比如结果可视化为什么要把幅值和方向分开?因为矢量场直接画箭头在密集区域会糊成一团,拆成幅值图(亮度表示速度大小)和方向图(色相表示方向)才能看清。这些设计选择背后都有明确的理由,不是随手写的。

2. 核心算子与关键细节解析

2.1 optical_flow_mg 算子的参数到底怎么理解

整个例程的核心就一个算子:optical_flow_mg。它的签名大致是这样:输入前后两帧图像、输出一个矢量场 VectorField。但真正决定效果的是一堆可选参数,这些参数在例程里可能只是几个数字,但每个数字背后都对应一个算法行为。我按自己的理解把它们拆开讲。

Sigma是高斯平滑的尺度,作用是在算梯度之前先对图像做平滑,抑制噪声。这个值给大了,细小运动会被抹掉;给小了,噪声会被当成运动。经验上从 1.0 到 2.0 之间试,纹理丰富的场景可以小一点,噪声大的场景要大一点。Rho是平滑项权重,光流算法本质上是在解一个能量泛函,数据项保证亮度守恒,平滑项保证相邻像素的运动矢量不要跳变太剧烈。Rho 越大,结果越平滑但细节越少;Rho 越小,细节多但对噪声敏感。例程里这个值通常设得中等偏大,因为自行车场景里背景占大头,需要平滑来压制背景的伪运动。

Sigma和Rho这两个参数是最需要调的,剩下的MinLevel、MaxLevel、NumLevels控制的是多尺度金字塔。光流在大位移下容易失败,金字塔的思路是先在最粗的尺度上估计大运动,再逐级细化到原始分辨率。NumLevels就是金字塔层数,层数越多能处理的最大位移越大,但计算量也越大。例程里一般设 3 到 4 层,对应能处理十几到几十像素的帧间位移。如果你的场景运动很快,帧率又低,就得加层数或者提高帧率,这是硬约束。

2.2 灰度转换与预处理的隐藏门道

例程里读进来的图如果是彩色的,第一步肯定是rgb1_to_gray。这一步看起来平淡无奇,但有个细节很多人忽略:不同颜色通道对运动的敏感度不一样。自行车场景里如果车身颜色和背景在某个通道上对比度高,转灰度后这个对比度会被平均掉。我实测过,某些场景下用单一通道(比如只取绿色通道)算光流,效果反而比标准灰度好,因为那个通道的纹理更清晰。当然这是进阶玩法,例程用标准灰度是为了通用性。

预处理里还有一个容易被跳过的东西:图像尺寸。光流计算量跟像素数成正比,如果原图是两三千像素宽,直接算会非常慢。例程里的图尺寸适中,所以没做缩放。你在自己项目里如果图很大,建议先zoom_image_factor降到一半或四分之一再算光流,算完把矢量场按比例放大回去。这个操作对精度的影响远小于对速度的提升,是性价比很高的优化。

注意:降分辨率再算光流时,矢量场的坐标和幅值都要按缩放因子换算,幅值要乘以缩放因子的倒数,否则速度估计会系统性偏小。这个换算很容易漏,漏了之后数据全错。

2.3 矢量场的表示与拆分逻辑

optical_flow_mg输出的 VectorField 是一个特殊的数据结构,Halcon 里不能直接当图像显示。例程用vector_field_to_real把它拆成两个 Real 图像:一个存幅值(也就是速度大小),一个存方向(角度)。这个拆分不是为了好看,而是因为后续处理需要的是标量场。

幅值图可以直接做阈值分割,找出运动剧烈的区域;方向图可以做直方图统计,判断整体运动趋势。比如传送带场景,方向图应该集中在某一个角度附近,如果方向分布很散,说明有干扰或者算法没收敛。这个拆分思路在工程上非常实用,等于把矢量问题降维成两个标量问题分别处理。我后来做振动监测就是靠幅值图的时间序列变化来判断异常,方向图基本不看,因为振动方向是固定的。

2.4 多尺度金字塔的收敛机制

前面提到NumLevels,这里展开说一下金字塔为什么能解决大位移问题。光流的基本假设是帧间位移小,泰勒展开的一阶近似才成立。位移一大,近似就崩了。金字塔的做法是把图像逐级降采样,在最顶层(最小图)上,原本几十像素的位移被缩小到几像素,一阶近似重新成立,算出一个粗略的光流;然后把这个结果上采样作为下一层的初值,逐级修正。

这个过程有点像先看地图找大方向,再放大看细节。层数选多少,取决于你的最大帧间位移。粗略的估算方法是:如果原始分辨率下最大位移是 D 像素,金字塔有 L 层,那么最顶层看到的位移大约是 D 除以 2 的 L 次方。要让这个值落在算法能处理的范围内(通常几个像素),就能反推需要的层数。例程里自行车运动不算剧烈,3 到 4 层足够。你要是拍高速旋转的飞轮,层数得往上加,或者干脆提高采集帧率,后者往往更省事。

3. 完整实操过程与关键环节实现

3.1 环境准备与图像序列的获取

先把运行环境说清楚。Halcon 版本建议 18.11 以上,optical_flow_mg这个算子在老版本里行为有差异,新版本对多尺度的支持更稳。例程自带的图像序列在安装目录的examples/images/下面,文件名类似bicycle_01到bicycle_10这样一串。如果你手上没有这组图,用自己拍的连续帧也完全可以,只要保证帧间位移不要太大。

读图序列有两种写法。一种是预先知道文件名规律,用循环拼字符串:

for Index := 1 to 10 by 1 FileName := 'bicycle_' + Index$'02d' read_image (Image, FileName) * 后续处理 endfor

另一种是用list_files拿到目录下所有文件再遍历,适合文件名不规律的情况。我一般用后者,因为实际项目里图像往往是从相机流里存的,命名不一定规整。读的时候注意图像格式,Halcon 对 png、jpg、tiff 都支持,但 jpg 有压缩伪影,光流对块效应比较敏感,能用无损格式就别用 jpg。

3.2 逐帧光流计算的代码骨架

核心循环的逻辑是:保存上一帧,读当前帧,两帧一起送进optical_flow_mg,得到矢量场,然后拆分显示。代码骨架大概是这样:

read_image (Image1, 'bicycle_01') rgb1_to_gray (Image1, Gray1) for Index := 2 to 10 by 1 read_image (Image2, 'bicycle_' + Index$'02d') rgb1_to_gray (Image2, Gray2) optical_flow_mg (Gray1, Gray2, VectorField, 'default', 'default', \ Sigma, Rho, 3, 1, 5) vector_field_to_real (VectorField, Magnitude, Direction) * 显示与统计 copy_obj (Gray2, Gray1, 1, -1) endfor

这里optical_flow_mg的参数顺序要特别注意,不同 Halcon 版本参数位置可能有微调,写之前查一下当前版本的算子文档。'default'那两个字符串是算法变体和插值方式的占位,一般不用改。后面几个数字分别是 Sigma、Rho、金字塔层数、最小层、最大层,具体值要按场景调,例程给的是通用起点。

3.3 参数调优的实操记录

我拿例程的图跑过几组参数,记录一下对比。Sigma 从 1.0 调到 2.0,幅值图的噪声明显减少,但自行车轮辐的细节开始糊;Rho 从 10 调到 50,背景区域的伪运动被压下去了,但车身边缘的运动矢量变得过于平滑,速度峰值被削低。最后我用的组合是 Sigma=1.5、Rho=20、金字塔 4 层,这个组合在细节和稳定性之间比较平衡。

调参的时候有个技巧:先固定 Rho 调 Sigma,再固定 Sigma 调 Rho。两个一起调你会搞不清是谁在起作用。判断标准看幅值图,理想状态下运动目标的幅值应该连成一片,背景区域接近零。如果背景一片雪花,说明 Sigma 太小或 Rho 太小;如果目标糊成一团没有内部结构,说明 Sigma 太大或 Rho 太大。这个判断方法比看数字直观得多。

3.4 结果可视化与定量统计

可视化部分例程用了dev_display叠加矢量场,但矢量场直接画箭头在密集区域会糊。我的做法是分两张图看:幅值图用伪彩色显示,速度大的地方亮,速度小的地方暗;方向图用色相环映射,不同方向不同颜色。Halcon 里可以用scale_image把幅值拉伸到 0 到 255,再用dev_display配合 colormap 显示。

定量统计更有价值。对幅值图做threshold找出运动区域,用region_features算面积和重心,重心的帧间位移就是目标的整体运动速度。这个数据可以直接喂给后续的跟踪或控制逻辑。方向图可以做gray_histo,看主方向落在哪个角度区间,判断运动是否一致。我在传送带项目里就是靠重心位移做速度闭环,靠方向直方图的集中度做异常检测,方向一散就说明有物料翻滚或者卡料。

4. 常见问题与排查技巧实录

4.1 光流结果全是噪声或全为零

这是最常见的两个极端。全噪声通常是 Sigma 和 Rho 都设太小,算法把噪声当成了运动。解决办法是把 Sigma 提到 2.0 以上,Rho 提到 30 以上,先看能不能压住。全为零则相反,可能是两帧图像完全一样(读图逻辑写错了,重复读了同一帧),也可能是 Sigma 太大把真实运动抹平了。排查时先打印两帧的均值差,差接近零就是读图问题,差正常就是参数问题。

还有一种隐蔽情况:图像是彩色的但没转灰度就送进算子,某些版本会静默失败或者给出无意义结果。养成习惯,送进optical_flow_mg之前确认是单通道图,用count_channels检查一下,一秒钟的事,能省很多排查时间。

4.2 大位移场景下光流跟丢

自行车骑得快的时候,轮子边缘的帧间位移可能超过金字塔能处理的范围,表现为轮子区域的光流方向乱跳或者幅值异常大。解决办法有三条:加金字塔层数、提高采集帧率、降低分辨率。三条里提高帧率最治本,因为光流的根本假设就是帧间位移小,帧率上去了位移自然小。加层数是软件层面的补救,层数太多会让最顶层的图小到没有纹理,反而算不准。降分辨率是折中,牺牲精度换稳定。

我踩过一个坑:加了层数但没改MinLevel和MaxLevel,结果金字塔实际没生效。这两个参数要和NumLevels配合,MaxLevel要大于等于NumLevels,否则层数被截断。这个细节文档里写得不显眼,但错了之后现象很迷惑,光流看起来在算,就是大位移跟不住。

4.3 矢量场坐标与图像坐标对不上

vector_field_to_real拆出来的幅值图和方向图,尺寸和原图一致,但如果你之前做了降分辨率,矢量场的坐标是缩放后的坐标系。直接拿去做区域分析,位置会偏。换算方法是把区域坐标乘以缩放因子,或者把矢量场上采样回原尺寸再分析。我建议后者,虽然多一步vector_field_to_real之后的zoom_image_factor,但逻辑清晰不容易错。

4.4 常见问题速查表

现象可能原因排查与解决
幅值图全是雪花噪声Sigma、Rho 过小Sigma 提到 2.0,Rho 提到 30 以上
幅值图全黑无运动读图重复同一帧,或 Sigma 过大检查帧间均值差,调小 Sigma
大位移区域方向乱跳金字塔层数不足或未生效加 NumLevels,确认 MaxLevel 匹配
运动区域位置偏移降分辨率后坐标未换算矢量场上采样回原尺寸再分析
算子报错或结果异常输入非单通道图用 count_channels 确认灰度图
背景区域伪运动多Rho 偏小,平滑不足提高 Rho,或先做背景抑制

提示:调参时每次只动一个参数,改完立刻看幅值图的变化,不要一次改好几个再一起看,否则出了问题根本定位不到是哪个参数引起的。

5. 从例程到项目的改造经验

5.1 把逐帧光流改成 ROI 内计算

例程是整幅图算光流,实际项目里往往只关心某个区域。整幅图算既慢又容易被无关背景干扰。改造方法是先用gen_rectangle1或者reduce_domain圈出 ROI,把两帧都裁到 ROI 再送进算子。注意 ROI 要留一点余量,因为光流在区域边界处的估计不准,边界效应会往内污染几个像素。我一般把 ROI 往外扩 10 到 20 像素,算完再裁回目标区域。

这个改造对速度的提升非常明显,ROI 占全图四分之一的话,计算时间大致也降到四分之一。而且背景干扰少了,参数可以调得更激进,细节保留更好。这是性价比最高的一处改造,建议所有实际项目都做。

5.2 光流结果与 blob 分析联动

光流给的是运动信息,blob 分析给的是形状和位置信息,两者结合能做出很实用的东西。比如先对幅值图做阈值分割得到运动区域,再对原图做 blob 分析得到目标轮廓,两者求交集,就能把“正在运动的目标”从“静止的目标”里筛出来。自行车场景里,静止的树和运动的自行车在灰度图上可能混在一起,但幅值图一分割就分开了。

联动的时候注意阈值的选择。幅值图的阈值不能固定,因为不同场景速度差异大。我的做法是用min_max_gray先看幅值范围,取最大值的 20% 到 30% 作为阈值起点,再微调。固定阈值在换场景时必然翻车,这个坑我踩过不止一次。

5.3 时序平滑与异常判定

单帧光流的结果有抖动,直接拿去做控制会抖得厉害。加一个简单的时序平滑,比如对连续几帧的幅值图做平均,或者对重心位移做滑动平均,稳定性提升很明显。滑动窗口长度取 3 到 5 帧,太长了响应迟钝,太短了平滑不够。

异常判定可以基于方向一致性。正常运动时方向直方图集中,出现异常(比如目标翻滚、遮挡)时方向会突然发散。设定一个集中度指标,比如主方向区间占比低于某个阈值就报警。这个思路在无人值守的监测场景里很管用,比单纯看幅值阈值更抗干扰。

6. 光流分析的适用边界与选型建议

6.1 什么场景适合用这个例程的思路

光流分析最适合的场景有几个共同特征:目标运动是非刚性的或者形状在变、需要逐像素的速度信息、帧间位移不大、对实时性要求不是极端苛刻。传送带物料跟踪、振动监测、流体表面运动分析、运动机构轨迹估计,都属于这一类。这些场景里帧差和模板匹配要么给不出方向,要么对形变不鲁棒,光流的密集矢量场正好补上这个缺口。

反过来,如果你的需求只是判断“有没有动”,帧差法又快又简单,没必要上光流。如果目标是刚性的、只需要整体位移,模板匹配或者相关性跟踪更直接。如果实时性要求是毫秒级,稠密光流基本没戏,得考虑稀疏光流或者专用硬件。选型的时候先问自己三个问题:要不要方向信息、目标是不是非刚性、帧间位移大不大,三个答案能帮你快速排除不合适的路线。

6.2 计算成本与实时性的现实考量

稠密光流的计算量摆在那里,optical_flow_mg在普通工控机上跑 640x480 的图,单帧大概几十毫秒量级,具体看参数和硬件。要做到实时(比如 30 帧每秒),要么降分辨率,要么缩 ROI,要么上更强的硬件。我的经验是 ROI 加降分辨率组合拳,能把单帧压到十毫秒以内,勉强够用。如果还不够,就得考虑算法层面的优化,比如只在关键区域算光流,其他区域用帧差粗筛。

这里有个容易忽略的点:光流计算的时间和金字塔层数、迭代次数强相关,而这些参数又跟场景绑定。调参的时候要把时间也纳入考量,不能只看效果不看耗时。我见过为了追求效果把层数和迭代拉满,结果单帧几百毫秒,整个系统直接不可用的案例。效果和速度的平衡点,得在真实硬件上实测出来,纸上算不准。

6.3 从例程学到的工程思维

这个例程最值得学的其实不是某个算子怎么用,而是它把复杂问题拆解的方式。光流本身是个数学上很重的东西,但例程把它包装成“读图、算、拆、显示”四步,每一步都有明确的输入输出,中间不藏黑箱。这种拆解方式让调试变得可行,哪一步出问题就查哪一步,不用面对一个整体黑盒干瞪眼。

另外例程对可视化的重视也值得借鉴。矢量场这种高维数据,直接看数字根本看不出对错,必须转成图看。幅值图和方向图的拆分,本质上是一种降维可视化,把四维信息(x 方向速度、y 方向速度、位置、时间)压到二维图上,人眼才能判断。做视觉项目,可视化做得好,调试效率能翻倍,这个体会我越来越深。

最后分享一个我在实际项目里养成的小习惯:每次调完光流参数,把当前参数和对应的幅值图截图存一份,标注上场景条件。时间长了就攒出一套自己的参数经验库,下次遇到类似场景直接查表起步,比从零试快得多。这个习惯看起来笨,但真的省时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进