Soberup战队今年的视觉开源路线我们打算按比赛里实际落地的顺序来写。第一稿大家都盯着灰度图怎么调教这一篇继续往前推灰度图拿到手之后怎么变成一张能直接被控制逻辑使用的干净赛道图也就是二值化以及二值化之后那堆躲不掉的断线怎么补。这个环节在RoboMaster的视觉里看着不起眼但恰恰是它决定了你的车到底是在“跟着线跑”还是“在瞎猜”。这篇文章不讲花活只讲我们在调试赛季中反复验证过的链路、参数思路和踩坑记录。1. 处理链路整体拆解灰度图到可爬赛道的中间环节1.1 从相机到控制指令的完整视觉管线很多人刚开始写视觉拿到相机第一件事就是调二值化调了半天发现车还是乱跑。我建议先把整条管线画出来哪怕画在草稿纸上都行。我们今年用的是这样一个顺序相机采集原始图像做镜头畸变校正转灰度图做一次轻量滤波然后进二值化再做形态学处理接着是断线补全最后提取赛道边缘或者赛道中线把偏差值发给控制。这条链路每多一个环节就多一个参数要调也多一个出bug的窗口期。为什么要这样排核心逻辑是逐级降低信息维度。相机出来的是三通道彩色图一个640乘480的帧就有将近一百万字节的信息直接扔给后续任何算法都要消耗大量算力和时间。灰度化把三个通道压成一个计算量立刻降到三分之一以下。二值化再进一步把像素变成只有零和255两个值这时候很多算法可以做得非常快尤其是RoboMaster比赛里在NUC或者Jetson这类嵌入式平台上跑的时候这种降维带来的优势是实打实的帧率提升。灰度化和二值化之间我强烈建议保留一个轻量降噪步骤。我们用的是3乘3的高斯滤波别看核小它对传感器暗电流噪声和图像压缩产生的块状噪声都有肉眼可见的压制效果而且耗时几乎可以忽略。直接拿原始灰度图做二值化会发现阈值附近有不少零星跳变的像素点后面形态学清洗就要花更大的代价去抹掉。1.2 二值化为什么几乎所有方案都绕不开这一步二值化的本质是把一张灰度连续变化的图像压缩成“背景”和“目标”两类。在赛道识别的场景里目标可能是白色引导线也可能是红色或蓝色装甲板边缘二值化之后一切都是基于“像素是否为我想要的”这个布尔逻辑来运算这在工程调试上的意义非常大。调试过视觉的人都知道最痛苦的事情是“结果不对但说不清哪里不对”。灰度图上你能看到的是一堆连续的灰阶变化很难指出来“这一段处理错了”二值化之后图像只剩黑白两色哪里被误判成一团白、哪里有裂缝、哪里有不该出现的孤立点一眼就能看到。我们队内定了个小规矩谁调的视觉参数必须能一眼说出当前二值图上每个白色区域对应的是赛道上的什么东西说不出来就回去继续调。这套“人肉可解释”的标准比任何评测指标都实用。另一个被忽略的点是二值化对后续算法的稳定性影响。基于连通域和边缘提取的算法一般都假设输入是二值图因为二值图的梯度是理想的脉冲信号寻边、求质心一类的操作非常干净。如果你把灰度图直接丢给这些算法光照稍微一变灰度波动就会让边缘位置抖来抖去而二值化之后只要阈值不是太糟糕边缘位置基本是稳定的——它只会在几个像素范围内挪动不会出现滑动几个几十个像素的抖动。1.3 断线补全在系统中的实际位置先说结论断线补全不是可选项而是必做项。我们第一次拿样车去场地实验的时候赛道边缘在阳光下出现了一道十几厘米长的反光带二值化之后引导线在中段直接断掉。控制那边拿到的是一个只有半截的赛道中线车在高速接近断口的地方开始左右瞎晃因为中线跳变导致转向量突变差一点就冲出赛道。断线补全之所以放在二值化和形态学之后是因为它建立在“已经知道什么是有效目标”的前提上。二值图告诉你哪里有白形态学负责把细碎的毛刺和小孔处理掉补全则是处理那些结构上断裂、但语义上应该连续的区域。这个顺序不能反了如果你在灰度图上做补全根本不知道哪些区域是需要补的赛道哪些是正常的阴影变化——只会把整个图像搞得一团糟。补全策略要根据场地特征来定。我们比赛用的赛道白色引导线宽度在图中大概占30到60个像素断口一般不超过整条线长度的五分之一。这些先验信息非常宝贵可以让你在补全的时候有足够强的约束太窄的边缘不补、太长的缺口不硬接、垂直方向不产生新的结构。没有这些约束的补全很容易在断口处造出一堆毫无依据的形状反而把原本还行的二值图搞得更复杂。2. 二值化核心细节从灰度图到黑白图的算法选择与参数推导2.1 灰度图处理的前置准备二值化处理的是灰度图但很多问题其实在灰度化之前就已经埋下了。我们用的工业相机支持输出16位灰度图刚开始没注意直接把16位的数据丢给OpenCV处理所有阈值全部失效画面要么全黑要么全白。后来才意识到16位灰度图的像素值范围是0到65535而我们脑子里习惯的阈值概念都是基于8位图的0到255。16位转8位不是简单除256就完事还要考虑有效动态范围。我们惯用的做法是在相机驱动的采集回调里完成一次线性映射先统计当前帧的灰度直方图找到5%和95%分位点然后把50%分位左右的范围线性拉伸到0到255。这个方法说白了就是自动增强对比度它能让白色赛道和深色地面的区分度在进入算法前就拉到最大。代价是算法必须处理动态变化的映射关系但我们实测下来这种方式在光照波动比较大的室外场地表现远好于固定映射而且这个预处理本身几乎不耗时。曝光控制是另一个前置关键。RoboMaster的比赛场地强光区域和阴影区域对比极大如果曝光时间设置成固定值二值化阈值在天上地下反复横跳。我建议至少在视觉线程里做一个“亮度锚点”统计——算灰度图中心区域的平均亮度如果它偏离目标亮度超过一个范围就微调曝光时间和增益。这不是自动曝光厂商实现的复杂版本只是用一个中心区域的统计量做反馈但在赛道这个场景里已经够用而且非常好调试。2.2 三种主流阈值方案怎么选二值化的核心就是阈值选择。这里有三条路固定阈值、大津法OTSU、自适应阈值。我见过很多战队一上来就固定阈值然后赛季过半还在为早上和下午的光照差一点而痛苦这就是当年我干过的事。固定阈值的优点是极致的稳定和可解释性。你写死阈值为120那么在一切光照条件不变的场地里它的表现就是确定的。问题在于“光照条件不变”这个前提在真实赛场上几乎不存在。白天阳光斜射、晚上灯光明亮、对面操作间门开了一条缝都会让你的阈值失效。除非你的赛区光源完全可控比如室内无窗场地否则我不推荐把固定阈值作为主方案。大津法是在无数场景里被验证过的经典方法它的思想很漂亮遍历所有可能阈值找到让背景类和白类之间方差最大的那个阈值。用大白话说它自动帮你找了一个“能把两类像素分得最开”的切分点。这个方案对光照变化的适应能力很强因为它是根据当前帧的直方图数据动态计算的阳光强一点、弱一点阈值会跟着挪动。自适应阈值我放在最后说不是因为它不好而是它在赛道场景里容易矫枉过正。它每个像素都比较局部邻域的均值来定阈值对光照渐变很有效但对大面积明暗突变反应过度会把本来一致的大块地面切出一堆碎斑。如果场地里没有特别极端的光照分区我建议优先尝试大津法不要急着上自适应阈值。三者对比我整理成一个表格方便你平时快速决策方法适用场景优点缺点参数数量固定阈值光照完全可控的室内场地稳定、确定、易调试光照变化即失效1个阈值大津法OTSU整体光照变化、场景简单自适应、零参数易受直方图异常干扰0个参数自适应阈值局部光照严重不均局部适应能力强易碎斑、计算开销最大2个以上2.3 大津法的原理推演与实现细节大津法的核心公式并不复杂。假设图像灰度级是0到L-1总像素数N每个灰度级i出现的概率是p_i。如果当前考虑的阈值是k把像素分成两类C0是灰度小于k的像素集合C1是灰度大于等于k的像素集合。计算这两类分别的像素占比ω0和ω1再算它们各自的灰度均值μ0和μ1那么类间方差定义是σ_B ω0 * ω1 * (μ0 - μ1)的平方。这个公式要拆开理解。ω0乘ω1这一项可以看作一个“均衡系数”——你当然希望两类像素数量都比较多如果某个阈值把99.9%像素都分到一边去了即使两类均值差很大也没意义。然后乘以均值差的平方意思是你希望分出来的两类在灰度上离得越远越好。把从0到L-1所有可能的k都扫一遍找到使σ_B最大的那个k就是大津法的输出阈值。实际实现时有个优化手段。直方图只需要扫描一遍所有ω和μ都可以用累积和迭代更新不需要每次重新遍历全图。我们用OpenCV的threshold函数指定cv2.THRESH_BINARY | cv2.THRESH_OTSU标志底层已经做完了这一切优化你传进去的阈值参数会被忽略返回的是计算出来的最优阈值。这个返回的阈值一定要打印出来观察因为它能告诉你当前帧的分类难度——如果最优阈值一直在50到200之间剧烈跳动说明曝光或场地光照出了问题不是算法抽风。大津法有个致命前提就是图像直方图应该呈现明显的双峰分布。赛道场景里深色地面加白色引导线天然是双峰的所以效果很好。但如果你的场景里出现大面积的灰白色区域比如阳光直射的浅色水泥地直方图可能变成单峰大津法就会崩溃式地给出一个偏移严重的阈值。这时候你需要先做ROI裁剪把无关区域排除掉再交给大津法处理。2.4 实际标定中的阈值调试经验我们对大津法的使用做了一点增强在大津法算出的阈值附近再叠加一个可调整的偏移量。因为这个偏移量可以手动微调用来补偿镜头透光率差异和膜贴膜后带来的少量偏色。比如大津法算出来是130你加一个偏移量10实际用140做阈值可以更偏向保留白线边缘。这个偏移量就是我们视觉代码里最常改的参数之一调试起来非常直白——数值加大白区变瘦数值减小白区变胖。调试时有个习惯不要只看最终效果图要看二值化和灰度图的叠加对比。我们在调试界面里同时显示灰度图、二值图、以及一个半透明的二值图叠在灰度图上的复合图。这个复合图能直观看出阈值边界在原始图像上的位置。边界如果沿着白线和地面的交接处切得很准说明当前参数优秀如果边界切在白线内部或者地面上赶紧调整偏移量。还有一点是关于ROI区域设置的。整个画面并非都是有效赛道画面上方可能是远处场地、观众看台甚至天花板上的灯这些区域一旦进入二值化计算范围就会严重污染大津法的直方图。我们惯用的操作是把ROI限制在画面底部三分之二区域并且根据透视关系动态调整——近处是全宽远处收窄成梯形。这个ROI不仅让二值化更干净同时减少了图像I/O和计算量是性价比极高的优化。3. 断线补全的算法解析与工程策略3.1 断线是怎么产生的断线不是算法问题它首先是物理问题。二值化之后白色引导线突然断掉大概率是下面四种原因。第一种是反光白色线表面在某个角度下把光源直接反射进镜头那个区域的灰度值瞬间顶到255但如果反射过强导致传感器曝光过饱和像素反而会变成一条亮斑周围暗部的对比度上升线本身被“淹没”在白色区域里。第二种是阴影当对方机器人或者裁判系统飞过来一个遮挡物线被阴影盖住灰度掉到阈值以下。第三种是曝光全局调整自动曝光在画面里突然出现大面积高亮区域时会把全局曝光压下去导致原本清晰的线变暗直接跌破阈值。第四种是物理磨损真实比赛场地跑久了赛道贴纸上的白色引导线会被轮胎磨花边缘破碎看上去就跟断线一样。不同原因的断线处理策略完全不同。反光造成的断线通常伴随附近一大片发白的区域你不能简单把它补成线因为那会把白斑也当成赛道的一部分。阴影造成的断线则是典型的结构性断裂附近区域灰度正常只是这一段变暗了这种是最适合用形态学或者拟合补全的。所以补全之前先学会看断口特征是个非常关键的工程意识。3.2 形态学闭运算最简单的持续性补全断线补全的第一板斧是形态学闭运算。它的定义是“先膨胀再腐蚀”。膨胀的意思是把白色区域向外扩张一圈腐蚀是向内收缩一圈。膨胀会让小的断裂缺口黏合在一起腐蚀再把扩张出去的多余边界收回来。两次操作结合之后整体形状和面积基本不变但断口被填上了。关键是内核大小怎么选。我们用的经验公式是闭运算内核的直径大约是赛道宽度在图像中的像素数的三分之一到二分之一。如果引导线在图里宽约40个像素那么闭运算内核就取15到20像素。核太小补不了大断口核太大会把附近的地面噪声也一并“焊接”进来造成区域粘连。而且核太大会明显圆角化直线边缘让拟合出来的赛道线变弯进而影响控制量这是个隐蔽问题我踩过。闭运算还有一个隐藏优点它可以把独立的小噪点区域一并消掉。因为腐蚀步骤会先把细碎的孤立白点剥掉。这也是为什么我坚持做形态学要放在断线补全之前——先闭运算把大多数小缺口补掉剩下的断口往往才是真正需要先验信息的大缺口后处理压力小很多。但要清楚闭运算的极限。它本质上是在补“窄缝”如果断口长度超过内核半径闭运算就无能为力了。它的工作更像是把一块饼干上的裂痕捏合而不是把两段分开的面团拼起来。所以闭运算补不了的缺口要交给下面的连通域分析和拟合方法。3.3 连通域分析与最大区域提取闭运算之后二值图上通常会剩下几个白色连通域大部分情况下最大的那个就是你要的赛道引导线。RoboMaster赛道设计有时候会故意用一些色块和装饰图案干扰视觉这些干扰在二值化后很可能形成和引导线不相上下的大区块这时候就需要用连通域的特征做筛选。OpenCV里connectedComponentsWithStats这个函数非常好用它返回每个连通域的包围盒、面积、质心等统计量。我们写了一个筛选函数按优先级过滤几个特征首先是面积面积低于阈值的不考虑然后是宽高比赛道引导线通常是细长的宽高比明显偏离正常值的排除掉最后是质心位置离上一帧赛道中线距离超过一定阈值的直接弃了。这三条规则叠加起来基本能锁定真正的赛道区域。找到目标连通域之后可以做的操作就不止补断了。我们顺便把它的最小外接矩形拿来做透视矫正或者提取轮廓后做多边形近似生成一个干净的掩膜区域。用这个掩膜去和原始二值图做与运算可以直接把那些干扰区域扔掉。这一步我认为在RoboMaster视觉里是性价比最高的一行代码因为它把问题从“如何在复杂的图中找赛道”直接简化成“如何在干净掩膜里描边”后续所有算法的稳定性都上了一个台阶。3.4 基于上一帧先验的智能断线补全单帧静态算法的能力是有限的但RoboMaster比赛是流式视频前后帧之间存在天然的关联。基于上一帧信息的断线补全是我们在实际比赛中表现最好的方案没有之一。核心思路很简单维护一个赛道的预测中线位置这个位置由上一帧的拟合结果经过运动模型外推得到。外推可以用一阶模型就是用上一帧和上上一帧的位置差作为速度预测当前帧中线应该在哪儿。在当前帧里我们沿着预测中线位置左右各扩展一个搜索窗口在这个窗口内扫描白色像素。窗口外出现的碎片断线不理会窗口内出现的白色块无论它跟当前连通域是不是一个整体都当作赛道的一部分参与拟合。这样哪怕二值图上引导线整体从中间断开只要两端仍然在搜索窗口内拟合就能把它们连起来效果非常自然。这个方案的参数就两个搜索窗口宽度外推速度平滑系数。窗口宽度取赛道在图中宽度的1.5到2倍比较合理太窄跟不上横移太宽又容易把干扰物吸进来。平滑系数的调法有点像低通滤波数值接近1表示完全信任上一帧预测数值接近0表示完全只看当前帧。我们用的0.7到0.85之间既保持了对快速变化的响应又滤掉了大部分跳动噪声。3.5 拟合补全直线、曲线和透视变换前面把白色区域确定好了最后一步是拟合赛道线本身。RoboMaster赛道近处一段基本是直线远处可能有弯道。我们的做法是分两段拟合近处1米内用直线拟合远处用二阶多项式拟合。直线拟合用的是最小二乘OpenCV的cv2.fitLine函数一步搞定曲线拟合则用numpy的polyfit对采样点坐标做二次多项式回归。断线区域的拟合有个技巧在断口附近做拟合时不要用断口里的残缺点而要用断口两侧的完好点。我们先把所有白色像素点收集起来然后用上一帧中线的位置把这些点按距离分成左右两簇在每一簇内部沿着中线方向做分段排序剔除掉距离中线超过阈值的离群点之后再做拟合。这样拟合出的参数不会因为断口处残缺的像素分布而扭曲。再说一个进阶做法透视变换法。我们把赛道近处的四边形区域透视矫正成一个俯视图在俯视图里断线补全变成了水平方向上的填充问题处理起来要简单得多。透视变换需要标定固定的四点我们是在调试时用鼠标在画面里选取赛道近处的矩形四角。矫正后图像里赛道线是竖着的或者接近竖直的寻找断口位置直接扫描列统计白色像素数量就行了完全不需要复杂的几何推理。这段代码写起来短但效果非常稳强烈推荐给有固定机位或者云台视角稳定的队伍。4. 实操过程跑通一条完整的灰度图到可爬赛道管线4.1 开发和测试环境准备先列一下我们队的标准开发环境。软件方面OpenCV版本4.5以上Python版本3.8以上numpy也是必备的。硬件方面开发阶段直接用笔记本电脑接USB工业相机比赛部署阶段再用Intel NUC或者Jetson Orin。为什么强推OpenCV因为它几乎所有视觉基础功能都覆盖了而且这些算法的CPU优化做得非常到位跑640乘480图像在NUC上能轻松保持100帧以上完全够用。开发之前有个小建议把相机标定参数先存成一个json文件每次读取都从这个文件加载。不要每次启动都重新标定因为赛场时间宝贵到了现场几十秒的调参时间都会影响你的心态。畸变校正系数我们用的是标准棋盘格标定法这一步必须在灰度化和二值化之前做否则画面边缘扭曲的引导线会让后续所有直线拟合失真。4.2 一份精简但完整的实现代码下面这份代码是把我们生产环境里的逻辑简化之后的核心版本保留了二值化、闭运算、最大连通域提取、拟合和补全这几个关键环节。直接用Python跑就行输入一张含赛道的图像输出处理后的可视化图和控制偏差值。import cv2 import numpy as np def preprocess_gray(img): gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray cv2.GaussianBlur(gray, (3, 3), 0) return gray def binarize(gray, use_otsuTrue, fixed_thresh120): if use_otsu: _, binary cv2.threshold( gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU ) else: _, binary cv2.threshold(gray, fixed_thresh, 255, cv2.THRESH_BINARY) return binary def morphology_close(binary, close_size15): kernel cv2.getStructuringElement( cv2.MORPH_ELLIPSE, (close_size, close_size) ) return cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) def keep_largest_connected_component(binary): num_labels, labels, stats, _ cv2.connectedComponentsWithStats( binary, connectivity8 ) if num_labels 1: return np.zeros_like(binary) areas stats[1:, cv2.CC_STAT_AREA] largest_idx np.argmax(areas) 1 mask np.zeros_like(binary) mask[labels largest_idx] 255 return mask def find_center_points(binary): ys, xs np.nonzero(binary) if len(xs) 20: return None # 按行分组取每行的质心作为赛道中心点 row_groups {} for x, y in zip(xs, ys): row_groups.setdefault(y // 4, []).append(x) center_points [] for row_key, col_list in row_groups.items(): center_points.append((int(np.mean(col_list)), row_key * 4)) center_points np.array(center_points, dtypenp.float32) return center_points def fit_guide_line(center_points): if center_points is None or len(center_points) 5: return None coeff np.polyfit(center_points[:, 1], center_points[:, 0], 2) return coeff def draw_line(img, coeff, max_row): if coeff is None: return img rows np.linspace(0, max_row, 50) xs np.polyval(coeff, rows).astype(int) for x, y in zip(xs, rows): cv2.circle(img, (int(x), int(y)), 2, (0, 0, 255), -1) return img def run_pipeline(img): gray preprocess_gray(img) binary binarize(gray, use_otsuTrue) closed morphology_close(binary, close_size15) mask keep_largest_connected_component(closed) center_points find_center_points(mask) coeff fit_guide_line(center_points) result img.copy() result draw_line(result, coeff, img.shape[0]) return result, gray, binary, mask if __name__ __main__: img cv2.imread(field_test.jpg) result, gray, binary, mask run_pipeline(img) cv2.imshow(result, result) cv2.imshow(mask, mask) cv2.waitKey(0)这段代码里最值得注意的就是find_center_points这个函数。它把掩膜区域按行分组成块每四行取一个块然后求块内所有白色像素的横向质心把这些质心作为赛道中心点序列。为什么要分组而不是直接取每行唯一的白色像素因为实际画面里引导线是有宽度的直接取会遇到一行里有多个白色像素无法判断该选哪个。分组求质心本质上是做了一次降采样平滑效果比取每一行最左或最右像素稳定得多。4.3 关键参数的标定方法与流程接到一张新场地图片或者到了一个新比赛场地第一个要调的绝对不是形态学内核而是ROI。把ROI圈好后面的参数标定才有意义。我们的标定流程走下来基本不超过二十分钟。第一步调整ROI大小让画面里只保留赛道区域排除看台、灯光等干扰。第二步在use_otsu选项下运行观察大津法返回的最优阈值并打印出来巡检如果这个值在连续几帧内稳定在某个区间说明二值化基本可靠。第三步调整close_size从5开始每5个像素往上试直到断口被补上且地面噪声没有被粘连进来为止。第四步观察拟合线的弯曲程度如果拟合线在直线赛道上明显弯曲多半是close_size过大导致边缘圆角化了往回调一档。还有一个独特的标定窍门用同一张场地图连续修改阈值偏移量我们代码里没写但生产版本里有每次偏移增加5观察二值图的变化。这个过程可以做成一个简单的键盘交互demo按上键和下键实时调整偏移量画面立刻刷新。这种交互式的标定方式比改代码、重运行、看输出的循环要快出好几倍强烈建议做成调试工具。4.4 部署性能优化帧率不够一切白搭RoboMaster赛场上的决策速度直接影响胜率视觉算法不建议超过10到15毫秒否则控制那边明显感觉到延迟。我们做过的三个最有效的性能优化按效果排序分别是ROI裁剪、降采样、多线程分工。ROI裁剪不用多说直接从源头减少像素处理量。降采样是指把输入图像在送入算法前缩小一定比例比如640乘480缩到480乘360整个处理时间能降到原来的六成左右。虽然图像分辨率低了但赛道引导线通常很宽缩到这个程度丝毫不影响拟合精度。我们是先降采样再做大津法因为大津法只需要直方图降采样后的直方图已经足够反映了全局分布不会损失太多精度。多线程分工指的是把图像采集、算法处理、结果发送放在三个线程里。采集线程只管拿帧算法线程单独跑视觉管线发送线程把偏差值通过串口或者共享内存给控制板。这样相机曝光等待时间不会阻塞算法计算总吞吐率可以提升很多。需要注意共享数据的锁或者无锁队列Python里推荐用queue.QueueC里用std::mutex加条件变量避免脏读脏写。5. 常见问题与排查技巧实录5.1 问题速查表我把我们调试过程中遇到的典型问题整理成了速查表方便你按症状直接找对策。现象可能原因排查顺序解决方案二值图一直变来变去曝光或自动增益未固定1. 关闭自动曝光 2. 检查环境光固定曝光时间中心亮度锚点反馈大津法阈值剧烈跳动ROI包含大量天花板/观众区检查直方图高灰度段来源收紧ROI只留赛道区域引导线断口始终补不上close_size设置过小测量断口宽度按断口宽度1.5倍增大内核闭运算后赛道严重变形close_size过大对比原图边缘减小内核改为预测拟合补全拟合线在直线赛道上弯曲边缘圆角化或离群点干扰观察掩膜边缘使用polyfit前先剔除离群点补全后出现一条假直线搜索窗口太宽吞入干扰物检查窗口内白色区块收紧预测窗口宽度加置信度判定夜间场地下大津法失效直方图单峰观察灰度直方图改用固定阈值或亮度归一化5.2 反光白斑闭运算解决不了的问题前面提过的反光白斑是比赛中杀伤力最大的干扰之一。你要特别注意这种白斑不是“断线”而是一整片发亮的区域闭运算不但补不了反而会把白斑和引导线“焊接”成更严重的大面积白色。我们处理反光的思路是提前预防在灰度图阶段对图像做一次局部对比度增强也就是分块的CLAHE让过曝区域的细节能重新显现出来。但CLAHE参数要控制好核太大容易出噪声核太小效果不够。更有效的做法是在二值化之后加一个面积约束。如果某个连通域的面积超过引导线连通域的几倍多半是反光白斑或者干扰直接把它当背景滤掉。比赛模式下对实时性要求高我们最终选择在掩膜提取阶段直接把过大面积连通域丢弃效果立竿见影。至于那些漏网的白斑残留就交给前面说的上一帧先验搜索窗口因为窗口外的白斑根本不会参与拟合。5.3 大津法偶尔抽风怎么办大津法不是银弹偶尔它会在某一帧突然给出一个荒谬的阈值导致二值图瞬间全黑或者全白那一帧的偏差值会剧烈跳变。这种“抽风”在连续视频流里表现为控制信号突然抖动一下。怎么排查第一件事是确认不是曝光跳变导致的把大津法返回的阈值实时打印出来画成曲线如果它在某个时刻突然从130掉到40再确认那个时刻画面里是否出现了大面积高亮物体。大津法抽风的根源在于直方图被污染。污染源一般是高亮的远处看台灯光、反光的裁判系统或者场外穿白衣服的人。解决思路是给大津法加上一个合理的阈值范围限制比如只允许它在80到180之间寻找最优阈值超出这个范围就沿用上一帧的阈值。这个“阈值限位”思路很朴素但效果极好它能防止单帧的异常直方图对整个控制链造成冲击。5.4 补全过度一条你根本不需要的“假线”如何出现补全算法最大的风险是“画蛇添足”。预测搜索窗口如果开得太宽比如横向范围超过了赛道本身的宽度那么赛道旁边的一截明显的白色地标、一块反光区域甚至对方机器人轮胎的白边都有可能被当成候选点参与拟合产生一条根本不存在的假赛道线。我们的解决办法是给候选点加上置信度筛选。凡是被搜索窗口捕获的白色块必须满足两个条件才参与拟合其一它在上一帧中线两侧的宽度不超过赛道宽度的1.5倍其二它和当前已经确认的赛道连通域之间的间隙不超过断口最大容许长度。两个条件同时满足才认为是真断线待补全。如果候选点条件不满足宁可这段赛道保持断开也绝不硬拟合出一段假线因为控制上一段假线的代价比一段真缺口大得多——至少真缺口只是偏差值跳变假线会直接把车往错误方向拉走。5.5 调试时候的一个私藏技巧最后分享一个我们调试时超好用的私藏技巧给保存的调试图片命名时把关键参数一起写进文件名。比如frame_otsu130_close15_poly.jpg这样当你回头翻一堆历史测试图的时候一眼就能看出哪张图用了什么参数而不需要反复对比日志。我们队里还约定每次赛道测试必须保存三张图原始图、二值图、叠加拟合线的效果图三个文件同名不同后缀。这个习惯一开始看起来多余但连续调试了几周之后它帮我节省了大量回看和复盘的时间强烈推荐你也试试。Soberup战队的视觉管线里二值化与断线补全只是中间一环但这一环不稳后面所有跑道识别、视觉定位都会跟着翻车。我们在这条路上踩过的坑不少尤其是那些“参数在某块场地有效、换个场地就失效”的玄学问题最后基本都回到了两个根源ROI圈得不够干净、或者缺少足够的先验约束。写这篇文章时我一直在想要是当初刚开始做视觉时有人能把这两点讲明白我们至少能少走一个月的弯路。希望这份经验和代码能帮你把这一环快速夯实把更多时间花在真正的提速和策略上。