智能车视觉循迹:边线扫描算法原理与工程实践详解
2026/7/30 7:50:21 网站建设 项目流程

1. 项目概述:从“看见”到“理解”赛道的跨越

在智能汽车竞赛的赛场上,让小车“看见”赛道只是第一步,如何让它“理解”眼前的图像并做出正确的行驶决策,才是真正的核心挑战。摄像头基本循迹,正是连接“视觉感知”与“运动控制”的关键桥梁。这不仅仅是调用一个库函数那么简单,它涉及从原始图像中提取赛道边界、计算可靠的中线,最终为后续的转向控制提供精准的输入。很多新手队伍在拿到摄像头数据后,面对一个二维的图像矩阵往往感到无从下手,要么是提取的边线“跳变”严重,导致小车像醉汉一样左右摇摆;要么是在复杂光照或赛道元素干扰下直接“丢线”,让小车冲出赛道。今天,我们就来彻底拆解这个过程,分享一套经过实战检验、从图像处理到中线计算的完整代码实现逻辑与避坑心得。无论你是刚刚接触OpenMV、K210,还是在使用更复杂的全局快门摄像头,这套底层思路都是相通的。

2. 核心思路与算法选型:为什么是“边线扫描+中线拟合”

在开始写代码之前,我们必须明确目标:算法的核心是稳定、快速地从每一帧图像中找到赛道左右边线,并据此计算出赛道中线。经过多年竞赛的迭代,一种被称为“边线扫描法”或“爬线法”的方案因其鲁棒性和易实现性,成为了基础循迹的首选。

2.1 算法核心:边线扫描法原理剖析

边线扫描法的思想非常直观:它模拟人的阅读习惯,从图像底部(离车最近、最清晰的部分)开始,逐行向上扫描,在每一行寻找赛道与背景(通常是黑色赛道与白色背景)的分界点。其优势在于计算量可控,且从近处开始搜索,成功率最高,为上一行的搜索提供了可靠的“起点”,形成一种跟踪效应。

为什么不用更“高级”的算法,比如边缘检测(Canny)或霍夫变换找直线?原因在于竞赛场景的特殊性:

  1. 实时性要求苛刻:主控芯片(如STM32, i.MX RT)的算力有限,必须将大部分资源留给控制算法。Canny和霍夫变换计算量较大,在低端MCU上难以达到每秒几十帧的处理速度。
  2. 场景相对结构化:竞赛赛道虽有弯道和交叉,但边线在局部范围内可近似为直线或缓变曲线。边线扫描法找到的是离散的边线点集,后期可以通过拟合得到连续的中线,这比直接处理整个图像更高效。
  3. 抗干扰需求:赛道可能存在反光、污渍或阴影。边线扫描法可以结合自适应阈值、搜索窗口限制等策略,有效过滤大部分噪声点,而全局性的边缘检测可能会被这些噪声淹没。

因此,我们的算法流程确定为:图像预处理 -> 底部起始行边线查找 -> 逐行向上扫描跟踪 -> 左右边线点集收集 -> 中线计算

2.2 图像预处理:为扫描创造理想条件

原始摄像头图像(通常是RGB或灰度图)不能直接用于扫描,必须经过预处理来增强赛道特征,抑制干扰。

# 示例代码:基于OpenMV的预处理流程 import sensor, image, time # 初始化摄像头 sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) # 使用灰度图,减少计算量 sensor.set_framesize(sensor.QQVGA) # 160x120分辨率,平衡速度与精度 sensor.skip_frames(time = 2000) # 等待摄像头稳定 while(True): img = sensor.snapshot() # 捕获一帧图像 # 1. 二值化:将灰度图转为黑白,区分赛道和背景 # 关键:阈值需要根据现场光照自适应或手动标定 # 简单固定阈值(适用于光照稳定环境) binary_img = img.binary([(0, 60)]) # 假设赛道为黑色(灰度值0-60) # 更优方案:自适应局部阈值,对抗光照不均 # binary_img = img.binary([(0, 60)], invert=False, zero=True) # 2. 形态学操作(可选):消除细小噪声,连接断线 # 开运算:先腐蚀后膨胀,去除小白点(噪声) # binary_img = binary_img.morph(1, [1,1,1,1,1,1,1,1,1]) # 3x3核的腐蚀 # binary_img = binary_img.morph(2, [1,1,1,1,1,1,1,1,1]) # 3x3核的膨胀 # 后续进行边线扫描...

注意:二值化的阈值是第一个“坑”。固定阈值在实验室灯光下可能工作良好,但一到比赛现场,自然光的变化会使其完全失效。强烈建议实现一个简单的自适应阈值算法,或者预留一个通过上位机或按键动态调整阈值的接口。形态学操作不是必须的,但如果赛道边线有破损或图像噪声大,它能显著提升边线点的连续性。

3. 边线扫描算法的代码实现与细节打磨

预处理后,我们得到了一个纯净的二值图像。接下来就是实现边线扫描的核心逻辑。

3.1 确定扫描起始点与搜索方向

我们从图像底部(img.height() - 1行)开始。首先需要在最底部找到左右边线的初始位置。一个稳健的方法是:从底部行的最左侧和最右侧分别向中间扫描,找到第一个从背景色(白)变为赛道色(黑)的像素点,作为左右边线的起点。

def find_start_points(binary_img): height = binary_img.height() width = binary_img.width() row = height - 1 # 底部行 left_start = -1 right_start = -1 # 从左向右扫描找左边缘(背景白->赛道黑) for x in range(0, width): if binary_img.get_pixel(x, row) == 0: # 0代表黑色(赛道) left_start = x break # 从右向左扫描找右边缘(背景白->赛道黑) for x in range(width-1, -1, -1): if binary_img.get_pixel(x, row) == 0: right_start = x break # 有效性检查 if left_start == -1 or right_start == -1 or (right_start - left_start) < 20: # 没找到有效的起点或赛道宽度太窄,可能图像异常 return None, None, False return left_start, right_start, True

3.2 逐行向上扫描与跟踪

有了底部起点,我们就可以向上逐行(row = height-2, height-3, ...)搜索新的边线点。核心思想是:在当前边线点的正上方及左右邻域内(定义一个搜索窗口)进行搜索,找到最可能是边线的点。这利用了赛道边线连续性的先验知识。

def scan_edge_lines(binary_img, left_start, right_start): height = binary_img.height() width = binary_img.width() left_points = [] right_points = [] last_left_x = left_start last_right_x = right_start # 定义搜索窗口半宽,例如10个像素 search_window = 10 # 从底部倒数第二行开始向上扫描 for row in range(height-2, -1, -1): # 搜索左边缘 left_found = False left_x = -1 # 搜索范围:以上一行的点为中心,左右各扩展search_window start_x = max(0, last_left_x - search_window) end_x = min(width-1, last_left_x + search_window) for x in range(start_x, end_x+1): # 判断是否为边缘点:当前点为黑(赛道),且其左侧邻点为白(背景) # 这是一种简单的梯度判断,更鲁棒的方法是检查多个像素 if binary_img.get_pixel(x, row) == 0 and (x==0 or binary_img.get_pixel(x-1, row) == 255): left_x = x left_found = True break # 找到第一个边缘点即退出 # 搜索右边缘(逻辑对称,找黑到白的过渡,即赛道右边界) right_found = False right_x = -1 start_x = max(0, last_right_x - search_window) end_x = min(width-1, last_right_x + search_window) for x in range(end_x, start_x-1, -1): # 从右向左搜 if binary_img.get_pixel(x, row) == 0 and (x==width-1 or binary_img.get_pixel(x+1, row) == 255): right_x = x right_found = True break # 处理搜索结果 if left_found: left_points.append((left_x, row)) last_left_x = left_x # 更新跟踪点 else: # 丢线处理:可以沿用上一行的x坐标,或标记为无效 # left_points.append((last_left_x, row)) # 策略1:沿用 left_points.append((-1, row)) # 策略2:标记无效 # 此时可以尝试扩大搜索窗口重新搜索,这里简化处理 if right_found: right_points.append((right_x, row)) last_right_x = right_x else: right_points.append((-1, row)) # 列表是从下往上存的,有时需要反转以便于处理 left_points.reverse() right_points.reverse() return left_points, right_points

实操心得search_window的大小是个需要调参的关键值。窗口太小,在弯道处容易跟丢;窗口太大,容易跳到错误的边缘(如赛道内的指示线)上。我的经验是,初始值设为图像宽度的1/10到1/8,然后根据小车在弯道上的实际表现进行微调。另外,上面的边缘判断逻辑(黑且左白)是最简单的,在复杂情况下可能不准。一个更鲁棒的方法是计算一个小的水平窗口(比如5个像素)内黑白像素的比例变化。

3.3 边线数据的滤波与修补

扫描得到的边线点集left_pointsright_points难免会有噪声或丢线点(值为-1)。直接使用会导致中线剧烈抖动。

常用滤波与修补策略:

  1. 滑动平均滤波:对连续的边线x坐标进行滑动平均,平滑抖动。
    def moving_average_filter(points, window_size=3): filtered = [] for i in range(len(points)): if points[i][0] == -1: filtered.append(points[i]) # 无效点跳过滤波 continue sum_x = 0 count = 0 for j in range(max(0, i-window_size//2), min(len(points), i+window_size//2 + 1)): if points[j][0] != -1: sum_x += points[j][0] count += 1 if count > 0: filtered.append((sum_x // count, points[i][1])) else: filtered.append(points[i]) return filtered
  2. 无效点插值:对于标记为-1的丢线点,可以用前后有效点的线性插值来估计。
    def interpolate_points(points): # 找到所有有效点的索引 valid_indices = [i for i, p in enumerate(points) if p[0] != -1] if len(valid_indices) < 2: return points # 有效点太少,无法插值 for i in range(len(points)): if points[i][0] == -1: # 找到前一个和后一个有效点 prev_idx = -1 next_idx = -1 for j in range(i-1, -1, -1): if points[j][0] != -1: prev_idx = j break for j in range(i+1, len(points)): if points[j][0] != -1: next_idx = j break if prev_idx != -1 and next_idx != -1: # 线性插值 prev_x, prev_y = points[prev_idx] next_x, next_y = points[next_idx] if next_y != prev_y: # 避免除零 ratio = (points[i][1] - prev_y) / (next_y - prev_y) interp_x = int(prev_x + ratio * (next_x - prev_x)) points[i] = (interp_x, points[i][1]) elif prev_idx != -1: # 只有前点,直接沿用 points[i] = (points[prev_idx][0], points[i][1]) elif next_idx != -1: # 只有后点,直接沿用 points[i] = (points[next_idx][0], points[i][1]) return points
  3. 边界约束:确保边线点不会超出图像物理范围,或者左右边线不会交叉。

4. 赛道中线计算与偏差提取

得到稳定的左右边线点集后,计算中线就水到渠成了。中线的直观定义就是左右边线对应点的横坐标平均值。

def calculate_center_line(left_points, right_points, img_width): center_points = [] valid_center_line = [] # 确保左右点集长度一致(通常是一致的) min_len = min(len(left_points), len(right_points)) for i in range(min_len): lx, y = left_points[i] rx, _ = right_points[i] if lx != -1 and rx != -1: # 左右边线都有效,计算中点 center_x = (lx + rx) // 2 center_points.append((center_x, y)) # 同时,可以计算横向偏差。通常以图像中心列作为期望位置。 deviation = center_x - img_width // 2 valid_center_line.append((center_x, y, deviation)) elif lx != -1: # 只有左边线有效,假设赛道宽度为固定值W,则中线 = lx + W/2 assumed_width = 50 # 根据实际赛道宽度估计 center_x = lx + assumed_width // 2 center_points.append((center_x, y)) elif rx != -1: # 只有右边线有效,中线 = rx - W/2 assumed_width = 50 center_x = rx - assumed_width // 2 center_points.append((center_x, y)) else: # 两边都丢线,无法计算,可以置为无效或沿用上一行 center_points.append((-1, y)) return center_points, valid_center_line

偏差(Deviation)是控制小车转向的核心输入。通常我们关注图像中某一行(例如,图像下方1/3处,代表车前方一段距离)的中线偏差。deviation = center_x - image_center_xdeviation > 0意味着中线偏右,小车需要向左转;反之亦然。

关键技巧:不要只用最底部一行的偏差!因为底部行对应车头正下方,对转向决策来说太“滞后”了。一个更优的策略是使用多行偏差进行融合,例如取图像下方若干行(如第30行到第80行)有效偏差的加权平均或中值。近处的行权重高(反应快速),远处的行权重低(用于预见性控制)。这能显著提升过弯的平滑性和前瞻性。

5. 完整流程集成与性能优化

将上述模块整合,并加入状态机管理,形成一个健壮的循迹图像处理流程。

class TrackFollower: def __init__(self, img_width, img_height): self.img_width = img_width self.img_height = img_height self.last_left_x = img_width // 4 # 初始猜测位置 self.last_right_x = img_width * 3 // 4 self.search_window = 15 self.lost_counter = 0 self.track_width_estimate = 60 # 估计的赛道像素宽度 def process_frame(self, binary_img): """处理一帧二值图像,返回中线点集和关键偏差""" # 步骤1:检查底部起始点(可每帧都做,或丢线时做) if self.lost_counter > 5: # 连续丢线多帧,重新初始化 l_start, r_start, ok = find_start_points(binary_img) if ok: self.last_left_x, self.last_right_x = l_start, r_start self.lost_counter = 0 else: # 彻底丢线,进入搜索模式或保持上一帧状态 return None, 0 # 步骤2:从上一帧的边线位置开始扫描 left_pts, right_pts = self._scan_from_last_position(binary_img) # 步骤3:滤波与修补 left_pts_f = moving_average_filter(left_pts, 5) right_pts_f = moving_average_filter(right_pts, 5) left_pts_f = interpolate_points(left_pts_f) right_pts_f = interpolate_points(right_pts_f) # 步骤4:计算中线与偏差 center_pts, valid_center = calculate_center_line(left_pts_f, right_pts_f, self.img_width) # 步骤5:更新状态 if valid_center: # 取图像下半部分多行的偏差进行融合(例如,从第40行到第80行) deviations = [d for _, _, d in valid_center if 40 <= _ < 80] if deviations: # 使用中值滤波抗野值 import statistics current_deviation = int(statistics.median(deviations)) self.lost_counter = 0 # 更新最后的边线位置,用于下一帧扫描 if left_pts_f[-1][0] != -1: self.last_left_x = left_pts_f[-1][0] if right_pts_f[-1][0] != -1: self.last_right_x = right_pts_f[-1][0] return center_pts, current_deviation else: self.lost_counter += 1 return center_pts, 0 # 返回中线但无有效偏差 else: self.lost_counter += 1 return None, 0 def _scan_from_last_position(self, binary_img): # 基于self.last_left_x和self.last_right_x进行扫描的封装函数 # 实现类似前面scan_edge_lines的逻辑 pass

性能优化点

  1. ROI(Region of Interest):不需要处理整幅图像。通常只关注图像下半部分(比如从第60行开始到底部),这能直接减少近一半的计算量。
  2. 跳行扫描:不必逐行扫描,可以每隔2行或3行扫描一次,然后用插值补全。这对速度提升明显,且对中线精度影响不大。
  3. 定点数运算:在STM32等MCU上,尽量使用整数运算,避免浮点数。
  4. 编译器优化:开启编译器的优化选项(如-O2, -O3)。

6. 调试技巧与常见问题排查

理论实现后,调试是让算法真正跑起来的关键。

6.1 可视化调试

将中间过程可视化是最高效的调试手段。

  • 在图像上画点:用不同颜色将扫描到的左右边线点、计算出的中线点画在图像上,通过串口发送到上位机(如OpenMV IDE、山外上位机、自己写的Python上位机)显示。一眼就能看出边线跟踪是否准确、平滑。
  • 输出关键数据:将底部起始点坐标、搜索窗口大小、计算出的偏差等关键变量通过串口打印出来,绘制成实时曲线,观察其变化是否合理。

6.2 常见问题与解决方案速查表

问题现象可能原因排查与解决思路
边线点跳动剧烈1. 二值化阈值不准。
2. 搜索窗口太小,在弯道边缘梯度不显著处找到多个候选点。
3. 未进行滤波。
1. 检查二值化图像,确保赛道与背景对比分明。改用自适应阈值或动态调参。
2. 适当增大搜索窗口。改进边线判断逻辑,例如要求连续多个像素满足黑白过渡。
3. 加入滑动平均或中值滤波。
弯道处容易丢线1. 搜索窗口太小,跟不上弯道曲率。
2. 边线判断逻辑在弯道内侧(黑-黑)失效。
3. 赛道反光或颜色不均。
1. 增大搜索窗口,或根据弯道曲率动态调整(需判断弯道)。
2. 采用更鲁棒的判断,如寻找像素灰度梯度最大的点。
3. 优化光源,或使用抗反光滤镜。在图像预处理阶段加强滤波。
十字路口或环岛处理混乱算法只识别两条边线,在赛道特征消失或增多时失效。这是基础扫线法的局限。需要引入特殊元素识别状态机。当检测到边线突然大幅发散或消失时,切换到预设的“通过十字路口”或“环岛处理”策略,例如保持原方向直行一段距离或寻找新的边线。
计算速度慢,帧率低图像分辨率太高,或算法未优化。1. 降低摄像头分辨率(QQVGA或更低)。
2. 设置ROI,只处理感兴趣区域。
3. 采用跳行扫描。
4. 检查代码中是否有低效循环,尽量使用硬件支持的功能(如DMA传输图像、硬件二值化)。
偏差输出延迟大算法处理耗时过长,或控制周期不固定。1. 优化代码,确保单帧处理时间稳定。
2. 使用定时器中断严格触发图像采集和处理流程,保证控制周期固定。
3. 考虑使用更快的MCU或带硬件加速的摄像头模块。
光照变化导致全天候性能差依赖固定的二值化阈值。实现动态阈值。例如,统计图像某一块背景区域的灰度均值,根据此均值动态计算阈值。或者使用灰度梯度法直接找边缘,对绝对灰度值不敏感。

6.3 现场调参心得

  1. 先静态,后动态:先把车放在赛道上不同位置(直道、弯道、十字),静态观察算法输出的边线和中线是否准确。再推着车慢速移动观察。
  2. 分模块调试:先确保二值化效果完美,再调试边线扫描,最后调试中线计算和偏差输出。不要试图一次性调通所有环节。
  3. 参数记录:将阈值、搜索窗口等关键参数做成宏定义或全局变量,方便通过按键或上位机实时调整,并记录下不同光照条件下的最优值。
  4. 准备“应急预案”:代码中一定要有丢线处理机制。当连续多帧找不到有效边线时,是保持上一次的转向指令,还是进入减速搜索模式,必须有明确的逻辑。否则小车一旦丢线就会彻底失控。

摄像头基本循迹的代码实现,是智能车视觉导航的基石。它没有用到高深的深度学习模型,但其稳定性和效率直接决定了小车的上限。理解并掌握这套从像素到决策的完整链条,不仅能让你在竞赛中取得好成绩,更是对“嵌入式机器视觉”最扎实的一次实践。当你看到小车沿着自己编写的代码画出的中线平稳飞驰时,那种成就感是无与伦比的。记住,所有复杂的控制(PID、模糊、最优)都依赖于一个干净、可靠的偏差输入,而这就是我们今天所构建的一切的意义所在。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询