在竞赛机器人或者入门级自动驾驶小车的感知栈里,“从灰度图到可爬赛道”这六个字,本质上是在回答一个问题:怎么让机器人在一堆环境噪声中,快速找到自己该走的那条路。上一篇文章我们聊完了相机选型和灰度图是怎么来的,这篇直接进入正题,把二值化和断线补全这两个关键环节掰开揉碎了讲清楚。尤其是断线补全,很多人在二值化调参上花了大把时间,却忽略了真正让赛道“连起来”的后处理步骤。这篇会把我的完整处理链路、参数选择逻辑和踩过的坑都放出来,希望你们看完就能直接用在自己战队的开源视觉框架里。
先说说这条链路的核心逻辑。很多人以为二值化就是把灰度图按一个阈值切成黑白两色,实际上这只是一个开始。真正的难点在于,你切出来的白色区域必须是一个连续、完整、可被上层逻辑直接用的赛道掩码——这就牵扯到噪声抑制、断口修复、误检剔除等一系列操作。所以我不打算只讲一个threshold函数,而是把从灰度图到干净赛道掩码的完整流程拆成四步:灰度预处理、二值化算法选型、形态学清理、断线补全。每一步都有它的“为什么”,也有它的“别乱来”。
1. 从原始图像到灰度图:为什么这一步比你想的更关键
1.1 灰度化不是“丢信息”,而是把信息压到最有用的维度
赛道识别这个场景,核心任务其实是区分“赛道”和“非赛道”。如果赛道是白色,地面是深色,那亮度和对比度天然就是最好的分类特征。RGB三个通道在这个任务里其实是有冗余的——颜色信息本身并不比亮度信息更能区分赛道和地面,反而会把计算量放大三倍。
灰度图的意义,就是把像素从三维的颜色空间投影到一维的亮度空间,让后续所有阈值分割、边缘检测都建立在最简单也最稳定的特征上。我用的是一个标准加权公式:
Gray = 0.299 * R + 0.587 * G + 0.114 * BOpenCV里的cvtColor(img, gray, COLOR_BGR2GRAY)用的就是这个系数组合。这个权重不是随便拍的,它是根据人眼对不同颜色敏感度标定出来的,绿色权重最高,蓝色权重最低。对赛道场景来说,白色赛道的RGB三个通道数值都比较高,转换后灰度值自然高;地形和背景的灰度值通常偏低,这样赛道和背景在灰度直方图上就会形成比较明显的双峰。
1.2 16位灰度图:一个容易被忽略的坑
热词里面出现了“16位灰度图”,这个必须提一下。工业相机或者某些深度相机,输出的原始数据可能是16位的,也就是灰度值范围是0到65535,而不是常见的0到255。
如果你直接把16位图喂给大津法或者固定阈值,那就坏了——阈值尺度完全对不上,二值化结果几乎没法看。正确做法是先把16位数据归一化或截断到8位。注意,这里有个细节:简单的右移8位相当于除以256,会让图像整体偏暗;更好的做法是先看一下直方图的分布,找到灰度值的主要分布区间,做线性拉伸后再映射到0到255。比如:
# 假设 raw 是 16-bit numpy 数组 min_val, max_val = raw.min(), raw.max() norm = ((raw.astype(np.float32) - min_val) / (max_val - min_val) * 255) gray = norm.astype(np.uint8)实测下来,做不做这个直方图拉伸,对后续二值化的稳定性影响非常大。如果你用的是普通的USB摄像头,一般就是8位YUV或RGB,不会遇到这个问题,但如果是自己焊的板子接了MT9V034之类的传感器,这个坑早晚会踩到。
灰度化之后,不要急着二值化。我会先看一眼灰度直方图,确认赛道和地面是否形成了明显的峰。如果双峰不明显,就需要考虑是不是曝光问题、反光问题,或者赛道颜色本身和背景太接近。直方图是二值化能否成功的前置指标,这一步观察能省掉后面大量瞎调参的时间。
2. 二值化:从连续灰度到清晰赛道掩码的核心算法选型
2.1 固定阈值、自适应阈值、大津法怎么选
二值化的本质,是在灰度空间里找一个分界面,把像素划成前景和背景两类。赛道识别的分界面,就是赛道灰度值和背景灰度值之间的那道缝。
固定阈值是最直接的办法,比如灰度值大于200就算赛道。好处是简单粗暴、运行极快,坏处是对光照极其敏感——同一块场地,上午十点和下午三点的光照强度可能差好几倍,你上午调好的阈值200,下午赛道灰度值可能整体跌到180以下,赛道直接变成一片黑色。
自适应阈值(adaptiveThreshold)会给每个像素单独算一个局部阈值,适合光照不均的图。但我个人在赛道场景里用得比较少,因为赛道边缘附近容易产生大量伪前景,且计算量明显大于全局阈值,实时性有压力。
大津法(Otsu)是我在这个项目里的首选。它的思路是:遍历所有可能的灰度阈值,找到一个值,使得分割出的前景和背景两类之间的类间方差最大。直白地讲,大津法会自动去“找那道最明显的缝”,不需要你手动指定,而且它对光照的整体偏移有一定鲁棒性——光照变化如果只是整体平移,类间方差最大的那个阈值往往也会跟着平移。
拿表格说事,三个方案放在一起看更直观:
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 固定阈值 | 室内恒定光照、灯条检测 | 实现简单、速度最快 | 光照一变就废 |
| 自适应阈值 | 光照不均匀、变化剧烈的图 | 局部适应能力强 | 噪声多、计算慢 |
| 大津法 | 赛道识别、目标分割 | 自动选阈值、抗整体亮度偏移 | 双峰不明显时效果差 |
赛道场景下,我的默认配置是大津法。只有在比赛场地光照极其稳定、且场地背景非常干净的情况下,我才会考虑用固定阈值来压榨那一点点性能。
2.2 大津法的数学原理与OpenCV实现细节
大津法底层逻辑可以这样理解:设图像总像素数为N,某一个阈值t把像素分成C1(灰度≤t)和C2(灰度>t)两类,C1的像素占比为w1,灰度均值为μ1,C2的占比为w2,均值是μ2,那么全局灰度均值μ = w1μ1 + w2μ2。类间方差定义为:
σ² = w1 * (μ1 - μ)² + w2 * (μ2 - μ)²大津法做的就是遍历所有可能的t,找到让σ²最大的那个阈值。这个方法的直觉很漂亮:如果两个类的均值差距大,说明分割效果好,类间方差就大;如果分割位置选得差,两个类的均值都会贴近全局均值,方差就小。
OpenCV里用起来极其简单:
thresh, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)这里有个细节必须讲清楚:thresh参数填0只是占位,因为加了THRESH_OTSU标志位之后,OpenCV会忽略你传入的阈值,自动计算最优阈值,并且通过thresh这个返回值把它返回来。所以你可以顺手打印这个值,看看赛道在不同光照下自动选的阈值是多少——这个数据对后面调曝光和判断环境变化非常有用。
另外要注意,大津法有个前提假设:图像的灰度直方图应该是“双峰”的——一个峰是背景,一个峰是前景。如果现场出现大面积阴影或者强反光,直方图可能变成多峰,这时大津法选出的阈值可能落在错误位置。我的处理办法是:在二值化前先做一个轻度的中值滤波或者高斯滤波,把细颗粒噪声压掉,让双峰更干净。
2.3 二值化后的形态学清理:开运算与闭运算的次序不能搞错
二值化之后,图像上通常有两类瑕疵:一类是背景区域里的独立白色噪点,比如地面的石子反射、草地里的亮斑;另一类是赛道内部的黑色小洞,比如轮胎印、阴影斑点。前者需要“开运算”去掉,后者需要“闭运算”补上。
开运算 = 先腐蚀后膨胀。腐蚀会把白色区域边缘向内收缩一圈,所有小于核大小的独立白点直接消失;然后再膨胀,把原本的大块白色区域恢复原尺寸。效果就是“去掉小噪点,不动大区域”。
闭运算 = 先膨胀后腐蚀。膨胀会让白色区域向外扩张一圈,把相邻的碎块黏连起来,断开的小缝隙被填上;然后再腐蚀,把多余扩张的边缘收回来。效果就是“补上小洞,连上小断裂”。
关键点在于顺序:先开运算,后闭运算。如果先闭运算,背景噪点会被膨胀放大,再开运算虽然能去噪,但可能会让赛道边缘变得更加不规则。我实测下来,先开后闭的形态学处理流程,在保持赛道边缘平滑度和清除噪声之间的平衡是最好的。
核的大小也要控制。我的经验是:3x3到5x5的矩形核在大多数场景够用。核太大,赛道边缘会被过度圆滑,宽度信息失真,直接影响后面计算赛道中线和曲率。具体用多大,要看你的图像分辨率——640x480的图像用3x3或5x5,如果分辨率拉到1280x720,可以适当换成5x5。
OpenCV里对应的实现:
# 先开运算去噪 opened = cv2.morphologyEx(binary, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8)) # 后闭运算补洞 closed = cv2.morphologyEx(opened, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))3. 断线补全:让断开的主赛道恢复成完整可行驶区域
3.1 断线是怎么产生的:五种典型来源
形态学处理能解决小洞和微裂缝,但解决不了真正的“大段断线”。你要先理解断线从哪里来,才谈得上怎么补。
第一类是曝光过曝。白色赛道在强光直射下,高光区域灰度值可能超过250甚至逼近255,在这种区域里赛道和背景的对比反而消失了——因为背景也可能被照得很亮。大津法在这种情况下选出的阈值可能过高,导致原本应该是白色的赛道区域被判定成黑色。
第二类是赛道本身的磨损。白色赛道的边缘被轮胎磨得发灰,灰度值下降,和深色地面的差距缩小,二值化时边缘部分可能漏判。
第三类是远场透视。赛道在图像远处会非常窄,可能只有三四个像素宽。这几个像素的灰度值一旦受噪声影响波动,就会整段整段地消失,因为一个像素的波动就足以让那一小截赛道低于阈值。
第四类是逆向光照产生的阴影。赛道边缘被护栏或者场地里的设备挡住,形成一道深色阴影带,把原本连续的赛道切断了。这在大场地比赛中非常常见。
第五类是阈值方向搞反。如果赛道是深蓝色或灰色,地面是浅色,那么赛道灰度值反而低于地面,你需要用THRESH_BINARY_INV,取反之后赛道才是白色。很多人第一次做深色赛道时忘了这一步,结果二值图里赛道是黑的,地面是白的,自然处处断裂。
# 深色赛道场景: thresh, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)3.2 先救急:形态学闭运算的极限在哪里
对于一到几个像素宽的断口、孔洞,闭运算是最快的解决方案。
举个例子,赛道在远场边缘有一处三像素宽的黑洞,用一个5x5的核做闭运算,膨胀会把周围白色像素向外扩张三到四像素,黑洞被填上;随后腐蚀把扩出来的边界缩回原位,原本的赛道轮廓基本不变。整个过程没有任何复杂的几何计算,一次腐蚀一次膨胀就搞定了,在嵌入式平台上只消耗几十微秒。
但闭运算对“宽断口”无能为力。如果赛道中间断开了十个像素甚至更宽,那膨胀之后断裂带可能还是填不上,或者填上了但边缘变形太严重。这个时候就要用更聪明的办法了。
还有一个隐患:闭运算会把赛道和赛道旁边一块不该连的白色噪点连到一起,形成“假赛道”。这种场景下我倾向于放弃大核闭运算,改用下面讲的扫描线补全。
3.3 基于扫描线的断线补全:从掩码到赛道骨架的修复
扫描线补全的核心思想是:赛道在图像坐标系里通常是沿纵向延伸的(车辆向前行驶,赛道从近到远延伸)。那么我们对二值图逐行扫描,每一行上赛道区域的左右边界点就是有效数据。当某一行的赛道区域为空,说明这里可能出现了断层,这时候用相邻行的边界位置做线性插值,就能补出这一行的赛道区间。
这个方法比形态学精准得多,因为你补的不是“像素块”,而是“赛道在每一行的位置”。它对远场窄赛道断裂的效果非常好,因为远场车道只有几像素宽,只要上一行和下一行都检测到了赛道位置,中间那一行的位置几乎一定能用插值算出来。
我写了一个简单的Python实现思路:
def scanline_fill(binary): h, w = binary.shape filled = binary.copy() for y in range(h): # 找到当前行所有白色像素的 x 坐标 xs = np.where(binary[y] > 0)[0] if len(xs) == 0: # 当前行没有赛道像素,尝试从上下相邻行插值 ys_above = max(0, y - 1) ys_below = min(h - 1, y + 1) xs_above = np.where(binary[ys_above] > 0)[0] xs_below = np.where(binary[ys_below] > 0)[0] if len(xs_above) > 0 and len(xs_below) > 0: # 用相邻行赛道中心点做线性插值 left_above, right_above = xs_above[0], xs_above[-1] left_below, right_below = xs_below[0], xs_below[-1] center_x = int((left_above + right_above + left_below + right_below) / 4) half_width = max(2, int((right_above - left_above + right_below - left_below) / 4)) filled[y, max(0, center_x - half_width):min(w, center_x + half_width)] = 255 return filled代码只考虑了上下各一行的插值,实际操作中可以把搜索范围扩展到上下三到五行,效果更好。要注意的是,扫描线补全的前提是赛道在画面中大体呈纵向延伸。如果转弯太急,赛道变成近乎横向,逐行扫描就不适用了,这时要换成按列扫描或者先做图像旋转。
更稳妥的做法是:先对整幅二值图做连通域分析,只对属于主赛道连通域的断裂做插值。否则,背景里一个独立的小白块附近出现断行,会被错误地插值成一个假赛道。
3.4 基于连通域与轮廓距离的断线桥接
扫描线补全解决的是“逐行丢失”型断线,但实际比赛中还有一种情况:赛道被阴影带横向切断,变成一个大的上段和大的下段,两段都是完整的大连通域,中间隔着一条十几像素宽的深渊。闭运算填不上,扫描线插值也因为断口太宽而效果差。
我的做法是用连通域分析找到赛道碎片,然后做邻近桥接。流程分四步:
第一步,connectedComponentsWithStats或者findContours提取所有白色连通域,记录每个连通域的外接矩形、面积和中心点。
第二步,筛选出面积大于某个阈值的连通域作为有效赛道段。这个阈值要根据图像分辨率来定,640x480下我一般取500像素以上,小于这个面积的碎片大概率是噪点。
第三步,对筛选出的连通域两两计算距离。如果两个连通域在图像垂直方向上相邻、水平方向上有重叠区域、且它们之间的距离小于设定阈值,就认定它们原本属于同一条赛道。
第四步,用一条宽度与赛道宽度相近的白色矩形带连接两个连通域。矩形带的位置可以简单取两个连通域中心连线的法向投影,更精细的做法是用两个连通域的端点坐标做样条插值,然后用cv2.line沿插值曲线画线。
这个方法的核心逻辑是“距离与方向同时约束”,只对满足“位置相邻、水平重叠、距离合理”的主赛道碎片做桥接。如果说闭运算是无论好坏一并连上,那么轮廓桥接就是对“该不该连”做了一次判断,误连的概率大幅下降。
我记得有一次在赛道上试车,有一段是被场地边的设备阴影直接切断的,切断宽度大概有15像素。闭运算把周围所有白色噪点扩成了一团,赛道边缘完全糊掉。换成轮廓桥接之后,只用了一根辅助线就把上段和下段接上了,二值图干净利落,后续中线提取基本没延迟。
4. 效果验证与实战排查:不只是调参,更是建验证体系
4.1 用图像序列确认补全效果,而不是单张图
调试二值化和断线补全最容易犯的错是:在单张图片上反复调参,调到这张图完美了就觉得大功告成。但实际上赛道是运动的,光照是连续变化的,你需要的不是一个在单张图上完美表现的参数,而是一组在连续图像序列上稳定工作的参数。
我的做法是这样:把比赛场地录一段两三分钟的视频,包含直道、弯道、逆光、顺光、阴影等各种情况。然后在离线环境里批量跑这段视频,逐帧输出二值化结果和断线补全结果,最后用几个统计指标去衡量整套参数的表现。
| 指标 | 含义 | 计算公式简述 |
|---|---|---|
| 骨架完整度 | 主赛道连通域在视频帧中存在的比例 | 有效连通域帧数 / 总帧数 |
| 有效面积占比 | 主赛道掩码面积在画面中占比稳定性 | 掩码面积方差 |
| 中线跳变度 | 相邻帧之间赛道中线的横向位移大小 | 中线x坐标差分绝对值 |
| 反色误检率 | 二值化结果与人工标注不一致的比例 | 离线标注后对比 |
这些指标不需要做到100%,但在竞速场景里,中线跳变度如果频繁超过图像宽度的10%,说明二值化参数在光照变化下不够稳,转向控制会抖动得非常厉害。
实际调试时,我会把二值化后二值图和一个半透明的原图叠加起来显示,一步到位看清哪些区域被识别成赛道了、哪些真正的赛道被丢了。如果发现远场总是断,我会按3.2和3.3顺序检查闭运算核大小和扫描线搜索范围。
4.2 六条高频问题与对应解法
我把做这个项目以来踩过的坑和群里高频出现的求助问题整理成一张速查表,基本上是排查时最常用的参考:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 二值化后赛道区域大面积变黑 | 阈值取反方向错误 | 检查使用的是THRESH_BINARY还是THRESH_BINARY_INV |
| 远场赛道频繁断裂 | 远场像素较少、噪声影响大 | 加大闭运算核,或开启扫描线补全 |
| 近场二值图边缘抖动 | 相机自动曝光和自动增益 | 固定曝光时间,关闭自动增益 |
| 赛道边缘出现大量毛刺 | 原图噪声大 | 二值化前加中值滤波或高斯滤波 |
| 形态学处理后赛道变宽失真 | 核太大 | 改用3x3核,或改为轮廓桥接 |
| 补全后出现明显横杠 | 扫描线插值未约束连通域方向 | 增加方向约束,只对纵向相邻的赛道段补全 |
第二条值得多说一句。远场断裂很多时候不是二值化阈值的问题,而是物理分辨率的问题。赛道在远场只有三四个像素宽,像素值在白色和灰色之间波动。处理这类断线,我个人的经验是做两层保险:先用一个较大的核(比如5x5的椭圆核)做闭运算把远场断裂粘合,再用扫描线补全兜底。两层组合拳下来,远场的赛道完整性会明显提升。
4.3 从二值图到“可爬赛道”:补全后还要做什么
标题里写的“从灰度图到可爬赛道”,其实还有一个隐含问题:断线补全之后,这些像素怎么变成上层导航要用的赛道模型?这是我自己在战队里负责视觉时,输出给决策层的信息结构,也在开源框架里做成了统一接口。
二值化+补全的输出,我习惯称为赛道掩码(mask)。决策层真正要用的通常是三类信息:
第一类是赛道中线。我在图像上对掩码做逐行扫描,取每行白色区域的中心点,连接成一条折线。近场的中线直接用来计算车辆相对赛道的横向偏差和航向角,这是PID控制的主输入。
第二类是赛道边界。把每行的左右端点提取出来,分别拟合成两道边界线。边界线用于防止车辆跑出赛道,尤其是在弯道处,边界的位置比中线更能反映赛道走向。
第三类是赛道曲率。在EKF或者纯几何方式下,对中线的点集做二次曲线拟合,解出二次项系数即可估算前方赛道曲率,用于提前降速或调整转向增益。
在视觉SLAM这个更大的维度上,二值化与断线补全算是最轻量的“语义分割”方案——它不做特征点匹配、不建稠密地图,但输出的赛道掩码天然就是可行驶语义区域。在很多算力受限的嵌入式平台上,这条路仍然是最可靠、最快速的方案。2025年不少嵌入式开源项目开始把深度学习分割模型往MCU级别硬件上推,但推理帧率和功耗仍然是个硬约束。所以这套传统视觉方案在近期内依然有价值,它不需要GPU、不需要NPU,一块几百块钱的开发板就能跑得很稳。
5. 写在最后:这套视觉链路真正教会我的东西
说实话,二值化和断线补全看起来都是入门级的算法,但真正把它们调到能稳定跟着赛道跑完一圈,要比想象中曲折得多。我在这个项目里最深的体会是:算法本身只是骨架,真正让视觉链路稳定的是对图像数据的理解——你知道灰度直方图该长什么样,知道大津法选出的阈值在什么情况下会失灵,知道断线该用形态学还是扫描线去补,这些判断力才是实打实的积累。
如果各位在自己的开源项目里复刻这套链路,我建议调试顺序这样走:先录一段真实赛道视频,离线把灰度图和直方图看熟;然后跑大津法,把每帧自动算出来的阈值打出来,看看波动有多大;再上形态学,从3x3核开始,一次只调一个参数;最后上断线补全,优先用闭运算,不够再加扫描线。每一步都在图像序列上验证,不要对着一张图死磕。
还有一个小技巧:在比赛前一天的场地适应期,一定要多留时间做光照测试。上午、中午、下午各录一段视频,回放的时候你就会发现,大津法的阈值波动其实是可以预测的——它通常和太阳高度角变化呈某种关系。掌握这个规律之后,你甚至可以提前给阈值加一个时间相关的补偿系数,这算是个比较进阶的玩法了,但效果真的不错。
这条视觉路线接下来我打算整理下赛道边缘拟合和中线提取的部分,那个部分涉及到的数学细节更多,也更接近最终控制效果。希望这套开源方案能帮到正在做同类项目的队伍,少走点弯路。