1. 项目概述与背景
最近在整理硬盘里的老项目,翻到了2021年参加全国大学生智能汽车竞赛(我们习惯叫“国赛”)时,为F题“送药小车”写的OpenMv巡线代码。当时为了赶进度,代码写得比较“飞线”,注释也是寥寥几语,现在回头看,很多逻辑自己都得琢磨半天。正好有不少学弟学妹在准备新一年的比赛,经常来问巡线这块的“玄学”怎么调。所以,我决定把这份尘封的代码拿出来,结合当年的实战经验,做一次彻彻底底的、带“人话”解释的注释重写。这不仅仅是为了让代码可读,更是想把我踩过的坑、调参时的心路历程,以及那些在紧张备赛时来不及细想的原理,都摊开来聊聊。
2021年国赛F题的核心场景是一个模拟的医院环境,小车需要沿着铺设的黑色引导线,在“药房”和多个“病房”之间自主往返,完成药品的精准送达。巡线的准确性和鲁棒性,直接决定了小车能否顺利完成任务,避免“送错房”或者“撞墙”的尴尬。我们当时选择了OpenMv Cam H7 Plus作为视觉传感器,就是看中了它内置的MicroPython环境和丰富的图像处理库,能让我们快速搭建原型,把精力集中在算法逻辑上,而不是底层驱动上。
这份代码的核心任务很简单:让OpenMv“看懂”地面上的黑线,并实时计算出小车应该如何调整方向(是左转、右转还是直行)。听起来简单,但实际调起来,光照变化、地面反光、线条磨损、摄像头抖动,每一个都是“拦路虎”。下面的注释,我会尽量还原当时的思考过程,告诉你我们为什么这么写,以及如果换到现在,我可能会怎么做。
2. 代码结构与核心逻辑拆解
我们的巡线代码主要分为几个模块:图像采集与预处理、ROI(感兴趣区域)设置、边缘检测与二值化、中线提取与偏差计算,以及最终的PID控制输出。整个流程像一条流水线,前一个环节的输出质量,直接决定了后一个环节的成败。
2.1 图像采集与预处理:给摄像头戴上一副“滤镜”
OpenMv的图像采集非常直接,但第一步的预处理往往被新手忽略。我们拿到的是原始的RGB图像,但对于巡线来说,颜色信息有时反而是干扰(比如地面有彩色污渍)。更可靠的是利用线条与背景的灰度差异。
import sensor, image, time from pyb import UART import ustruct # 初始化摄像头 sensor.reset() sensor.set_pixformat(sensor.RGB565) # 使用RGB565格式,虽然巡线用灰度,但保留彩色以备他用 sensor.set_framesize(sensor.QVGA) # 320x240分辨率,兼顾处理速度和视野宽度 sensor.skip_frames(time = 2000) # 给摄像头一点时间稳定,跳过最初的几帧 sensor.set_auto_gain(False) # 【关键】关闭自动增益!否则光线变化时画面亮度会跳变,边缘检测会发疯。 sensor.set_auto_whitebal(False) # 【关键】关闭自动白平衡!同上,保持颜色/灰度稳定。 clock = time.clock() uart = UART(3, 115200) # 初始化串口,用于向下位机(STM32等)发送控制指令为什么是RGB565而不是GRAYSCALE?这里有个小技巧。虽然我们后续会转成灰度图处理,但一开始设置为RGB565保留了彩色信息。这在调试阶段非常有用,你可以用彩色框画出检测到的线条,直观地在IDE里看到效果。如果一开始就设为灰度,调试时就只能看黑白图像了。
set_auto_gain(False)和set_auto_whitebal(False)是生命线!这是我当年踩的第一个大坑。默认情况下,OpenMv的自动增益和自动白平衡是打开的,它们会努力让画面看起来“舒服”。但在巡线场景下,这简直是灾难。比如小车从阴影进入阳光区域,自动增益会突然提亮整个画面,导致原本黑线的地方灰度值剧变,二值化阈值瞬间失效,小车直接“失明”乱跑。关闭它们,意味着我们主动接管了图像的环境适应性,虽然需要自己处理光照问题,但换来了算法的稳定性。我们后续会用其他方法(如动态阈值或自适应ROI)来应对光照变化。
2.2 ROI(感兴趣区域)设置:别让无关信息干扰判断
不是整个画面都需要处理。小车前方的远处路面和近处的车头,对当前的方向决策意义不大。我们只关心摄像头正前方、偏下区域的一条“带子”,这里最有可能出现引导线。
# 定义ROI (Region of Interest),格式为 (x, y, w, h) # 我们只关心图像底部偏上的一条水平区域,因为黑线通常出现在这里。 ROI_Y = 120 # ROI区域的起始Y坐标(从顶部算起) ROI_HEIGHT = 40 # ROI区域的高度 ROI = (0, ROI_Y, sensor.width(), ROI_HEIGHT) # 宽度取整个画面宽度ROI的设计哲学:
- ROI_Y=120: 我们的摄像头是向前下方倾斜安装的。如果ROI设得太高(比如Y=50),会看到很远的路面,线条细、容易受透视畸变影响,且容易丢失。设得太低(比如Y=200),看到的是车头前很近的地面,等看到线再反应就晚了。120-160这个区域,是视野、前瞻距离和处理速度的一个平衡点。
- ROI_HEIGHT=40: 高度不能太窄,否则稍微一颠簸,黑线就可能跑出ROI。也不能太宽,否则会包含更多无关背景,增加计算量,也容易引入干扰。40个像素的高度,足以容纳一定宽度的黑线及其两侧的边缘。
- 实战心得: ROI不是固定的。在高级策略中,可以根据当前速度或检测到的线条趋势动态调整ROI的位置和高度。比如高速时,需要看更远(ROI_Y减小);急弯时,需要加宽ROI的横向搜索范围。
2.3 边缘检测与二值化:把黑线“抠”出来
这是巡线算法的核心步骤。我们的目标是将灰度图像转化为一个黑白分明的二值图像,其中白色代表线条,黑色代表背景。
while(True): clock.tick() img = sensor.snapshot() # 捕获一帧图像 # 转换为灰度图,简化处理 gray_img = img.to_grayscale() # 仅对ROI区域进行处理 roi_img = gray_img.copy(roi=ROI) # 使用Canny边缘检测器找到可能的线条边缘 # 参数调节是门艺术:threshold1和threshold2控制边缘连接的强弱。 edges = roi_img.find_edges(image.EDGE_CANNY, threshold1=30, threshold2=60) # 对边缘检测后的图像进行二值化,将边缘强化为白色线条 # 这里用一个固定阈值。更鲁棒的方法是使用大津法(OTSU)或局部自适应阈值。 binary_img = edges.binary([(30, 255)]) # 灰度值大于30的像素变为白色(255)为什么用Canny边缘检测,而不是直接二值化?直接对灰度图进行全局二值化(gray_img.binary([(50, 255)]))在光照均匀时很有效。但比赛现场光照复杂,地面可能有反光、阴影。Canny边缘检测先找到图像中灰度变化剧烈的地方(即边缘),然后再二值化,这样得到的线条更干净,对整体光照变化相对不敏感。它关注的是“变化”,而不是绝对的灰度值。
threshold1和threshold2的“双阈值”魔法:
threshold2是强边缘阈值。梯度大于这里的像素点,被认定为确定的边缘。threshold1是弱边缘阈值。梯度介于threshold1和threshold2之间的像素点,只有当它们连接到强边缘时,才被保留为边缘。- 这就像一个“宁缺毋滥”的过滤器。
(30, 60)这个组合意味着我们只保留那些比较明显的边缘,可以有效过滤掉地面纹理、轻微反光等噪声。调参时,可以先用OpenMv IDE的“阈值编辑器”工具,在实时画面中拖动滑块,观察哪些边缘被保留,找到最适合现场环境的参数。
关于二值化阈值的“坑”:代码中使用了固定阈值(30, 255)。这是为了简化。在实际比赛中,我们后来改用了大津法(OTSU)自动计算阈值。因为早上、中午、晚上,或者赛场不同位置的灯光,其亮度差异很大。固定阈值需要准备多套参数手动切换,而OTSU能根据当前ROI图像的灰度直方图自动计算出一个最佳分割阈值,适应性大大增强。替换代码很简单:
# 替代 binary_img = edges.binary([(30, 255)]) # 计算OTSU阈值 otsu_threshold = roi_img.get_statistics().l_mean() # 可以先试试用均值,但OTSU更准 # 或者使用更复杂的方法,但OpenMv的MicroPython可能没有直接OTSU函数,需要自己实现或利用find_blobs的阈值功能变通。 # 一个实用的变通方案:使用 find_blobs 并设置合适的阈值范围,让库函数帮你做自适应。 blobs = roi_img.find_blobs([(0, 60)], pixels_threshold=20, area_threshold=20, merge=True) # 然后从blob中提取线条信息,这其实跳过了显式的二值化步骤。2.4 中线提取与偏差计算:告诉小车该往哪走
得到二值化的线条图像后,我们需要从中提炼出一个关键数字:偏差(Error)。即小车当前中心线与目标引导线中心线的水平像素距离。
# 方法1:重心法(适用于线条较粗、连续的情况) # 计算二值图像中所有白色像素的质心(重心) stats = binary_img.get_statistics() if stats.l_mean() > 0: # 确保图像中有白色像素(即检测到了线) line_center_x = int(stats.x_center()) # 白色区域质心的x坐标 else: line_center_x = roi_img.width() // 2 # 如果没找到线,假设线在中心(一种容错策略) # 更好的策略是保持上一次的偏差或执行搜索动作 # ROI图像的中心x坐标 roi_center_x = roi_img.width() // 2 # 计算偏差:正数表示线在右边,小车需要右转;负数表示线在左边,小车需要左转。 error = line_center_x - roi_center_x # 方法2:扫描线法(更鲁棒,适用于线条断裂或有多条线的情况) # 我们实际比赛后期采用了这种方法 error = 0 scan_lines = 5 # 在ROI高度方向均匀取5条扫描线 found_line_points = [] for i in range(scan_lines): scan_y = int(ROI_HEIGHT * (i + 0.5) / scan_lines) # 计算每条扫描线的y坐标 line_data = binary_img.get_line(0, scan_y, binary_img.width()-1, scan_y) # 分析line_data,找到线段中心。这里简化处理,寻找最长的连续白色线段。 # ... (具体扫描和分析代码较长,其核心是找到每条扫描线上黑线中心的x坐标) # 假设我们得到了一个中心点x_val # found_line_points.append(x_val) # 如果找到了足够的点,用线性拟合或直接求平均得到 line_center_x # if len(found_line_points) > 2: # line_center_x = int(sum(found_line_points) / len(found_line_points)) # error = line_center_x - roi_center_x # else: # error = 0 # 或执行丢线处理重心法 vs. 扫描线法:
- 重心法:简单粗暴,计算快。但有个致命弱点:如果图像中有大块噪声(比如一块黑色污渍也被二值化为白色),质心会被严重拉偏。它把整个白色区域当成一个整体。
- 扫描线法:这是我们最终采用的方案。它在ROI内等间距取几条水平线,逐条分析。每条线上,我们寻找黑色(0)到白色(255)和白色到黑色的跳变点,从而定位出线条的左右边缘,再算出该行的中心。最后综合所有行的中心点(可以取平均,也可以用线性回归拟合一条直线),得到更精确、抗干扰能力更强的
line_center_x。即使某一行因为噪声检测失败,其他行的数据也能保证整体结果可靠。这相当于进行了多次“民意调查”,而不是一次“全民公投”。
偏差(Error)的意义:这个error值(单位是像素)就是PID控制器的输入。error=0意味着线在正中间,小车应直行。error>0意味着线在右边,小车车头应该向右转(即向左轮减速或右轮加速)以对准线。这个逻辑可以根据你的小车运动模型调整。
2.5 PID控制与串口通信:把决策交给下位机
OpenMv通常作为“视觉传感器”和“决策大脑”,它计算出偏差后,需要通过串口将控制指令发送给负责电机驱动的下位机(如STM32)。
# PID控制器计算(比例-积分-微分) # 这是让小车平滑跟随的关键,避免“画龙”或反应迟钝。 Kp = 0.5 # 比例系数:决定了对当前偏差的反应强度。太大易震荡,太小反应慢。 Ki = 0.01 # 积分系数:用来消除静态误差(比如小车始终偏左一点)。但要防积分饱和。 Kd = 0.2 # 微分系数:预测偏差变化趋势,抑制超调,使运动更平滑。 # 声明为全局变量或在循环外初始化 # integral = 0 # last_error = 0 # 计算比例项 P = error # 计算积分项(并限制积分上限,防止“积分饱和”) integral += error # 【关键】积分限幅!否则长时间单方向偏差会导致integral巨大,一旦回正,小车会剧烈反冲。 integral = max(min(integral, 100), -100) # 将积分项限制在[-100, 100] I = integral # 计算微分项(本次误差与上次误差的差值) D = error - last_error last_error = error # 更新上次误差 # PID输出 pid_output = Kp * P + Ki * I + Kd * D # 将PID输出映射到电机速度差或转向角 # 假设我们发送一个转向指令,范围是 -100(左满舵)到 100(右满舵) steer = int(max(min(pid_output, 100), -100)) # 限制输出范围 # 通过串口发送指令给下位机 # 协议可以自定义,例如发送两个字节的转向值 uart.write(ustruct.pack('h', steer)) # 'h' 表示有符号短整型(2字节) # 调试信息:在图像上画出ROI和检测到的中线,并通过串口打印误差(调试时用) img.draw_rectangle(ROI, color=(255,0,0)) # 用红色矩形框出ROI # 在原始img(非ROI图)上画出计算出的中线位置,需要坐标转换 absolute_line_x = ROI[0] + line_center_x img.draw_line((absolute_line_x, ROI[1]), (absolute_line_x, ROI[1]+ROI[3]), color=(0,255,0)) # 打印误差和输出值到IDE终端(正式比赛时可关闭以节省资源) print("Error:", error, "Steer:", steer)PID调参的“玄学”与“科学”:
- Kp(比例): 这是基础。先从Kp调起,让小车能大致跟着线走,即使有点晃。增大Kp,反应变快,但过大会在直线段来回震荡(“画龙”)。
- Kd(微分): Kp调好后,加入Kd。它的作用是“阻尼”。当小车快速接近中线时(error在快速减小),Kd会产生一个反向力,防止它冲过头。合适的Kd能让过弯更顺滑,直线更稳定。注意:图像处理的噪声会被微分放大,所以如果误差信号本身有抖动,Kd大了反而会引入高频振荡。有时需要对误差进行低通滤波后再计算微分。
- Ki(积分): 最后加Ki。用来修正系统性偏差。比如小车机械结构不对称,导致即使误差为0,也会慢慢跑偏。Ki能慢慢积累这个偏差并修正。但积分饱和是魔鬼!必须限幅。想象小车卡在墙角,error一直很大,integral会累积到巨大值,当小车被救回线上时,这个巨大的integral会瞬间让小车反向冲出去。
- 调参口诀:“先比例,后微分,积分最后慢慢加;震荡调大微分,静差调大积分,反应慢调大比例。”
串口通信协议:我们使用了最简单的打包格式ustruct.pack('h', steer),将整数转为2字节发送。下位机需要以相同的格式解析。更复杂的协议可以包含起始帧、校验和、命令类型等,提高抗干扰能力。务必确保OpenMv和下位机的波特率、数据位、停止位、校验位完全一致!这是无数人栽跟头的地方。
3. 高级策略与抗干扰优化
基础的巡线在理想环境下可以工作,但国赛现场充满挑战。以下是我们在后期迭代中加入的几个关键优化。
3.1 动态ROI与预测跟踪
固定ROI在急弯或上下坡时容易丢线。我们实现了动态ROI:
- 根据历史轨迹预测:记录最近几帧检测到的线条中心点,用线性回归预测下一帧线条可能出现的区域,将ROI设置在该区域附近。
- 根据偏差动态调整ROI宽度:当偏差
error很大时(说明正在过弯),适当加宽ROI的横向搜索范围,防止线跑出视野。
# 伪代码示例:简单的动态ROI predicted_center_x = last_center_x # 初始化为上一帧中心 # 假设根据小车速度模型预测了一个微小偏移 # predicted_center_x += speed * dt * some_factor dynamic_roi_width = 100 # 基础宽度 if abs(error) > 30: # 如果偏差较大,认为在弯道 dynamic_roi_width = 150 # 加宽搜索范围 new_roi_x = max(0, min(predicted_center_x - dynamic_roi_width//2, sensor.width() - dynamic_roi_width)) ROI = (new_roi_x, ROI_Y, dynamic_roi_width, ROI_HEIGHT)3.2 多态巡线:直线、弯道与十字路口的识别
赛道不止有直线。识别出不同的赛道元素,可以切换不同的控制策略。
- 直线: 使用较高的PID参数追求稳定。
- 弯道: 识别到线条曲率变大时,可以适当降低比例系数
Kp,增加微分系数Kd,让过弯更平缓;或者引入前馈控制,提前给一个固定的转向补偿。 - 十字路口: 这是送药小车的关键节点。当扫描线法检测到在ROI范围内出现多条符合条件的线段,或者线条在水平方向出现间断(左右分支)时,可以判定为十字路口。此时,巡线算法应暂停,将控制权交给上层的任务调度逻辑(比如根据路径规划选择左转、右转还是直行),并可能需要一个特定的动作(如停车、旋转)来对准新的路线。
# 十字路口检测伪代码 line_count = 0 for each_scan_line: centers = find_line_centers_in_this_line(binary_img, scan_y) if len(centers) >= 2: # 一条扫描线上找到两个以上的线条中心 line_count += 1 if line_count > 2: # 有多条扫描线都发现了多线条特征 # 进入十字路口处理模式 uart.write(ustruct.pack('s', b'INTERSECTION')) # 发送特殊指令 # 切换状态机,等待上层决策...3.3 图像滤波与降噪处理
在图像预处理阶段加入滤波,能极大提升后续处理的稳定性。
- 中值滤波:
roi_img.median(1)可以有效去除椒盐噪声(图像上的孤立白点或黑点),且能较好保留边缘。核大小通常选1或3,太大图像会模糊。 - 高斯滤波:
roi_img.gaussian(1)让图像稍微模糊,平滑掉细小的纹理噪声,使边缘更干净。同样不宜过度。 - 开运算/闭运算: 在二值图像上,
binary_img.open(1)可以消除小的白色噪点,binary_img.close(1)可以连接断裂的白色线条。这对于处理磨损、反光造成的线条不连续非常有效。
处理顺序建议: 灰度图 -> (高斯/中值滤波)-> Canny边缘检测 -> 二值化 -> (开/闭运算)-> 提取中线。每一步都看效果,按需添加。
4. 调试技巧与实战心得
调试视觉算法,光看代码不行,必须“看见”算法看到了什么。
善用OpenMv IDE的“帧缓冲区”和“工具”:
- 把处理过程中的关键图像(如
gray_img,edges,binary_img)用img.copy()复制出来,并img.draw_image()到主画面进行显示。这样你就能在同一帧画面上看到原始图、边缘图、二值图和中线标记,一目了然。 - 使用“阈值编辑器”实时调整Canny阈值和二值化阈值,找到最适合当前光照的参数范围。
- 使用“直方图”工具查看ROI区域的灰度分布,帮助你理解为什么当前阈值效果不好。
- 把处理过程中的关键图像(如
串口打印调试法:
- 除了在IDE终端打印
error和pid_output,还可以把更详细的信息,如line_center_x、integral值、检测到的线条像素数等打包发送到电脑上的串口助手,甚至绘制成曲线,观察其动态变化。这比单纯看图像更量化。
- 除了在IDE终端打印
分阶段测试:
- 第一阶段(静态测试): 把小车架起来,手动移动赛道纸或摄像头,在IDE里观察巡线是否稳定,中线计算是否准确。这是调参的主要阶段。
- 第二阶段(低速动态测试): 让小车在简单赛道上低速跑,观察其跟随性能。重点调
Kp和Kd。 - 第三阶段(全速与压力测试): 在完整赛道上全速运行,并模拟各种干扰(用手电筒照反光、在赛道旁放彩色物体)。测试十字路口识别、丢线恢复等逻辑。
代码版本管理:
- 每调通一个功能或优化一个参数,都另存为一个版本的代码文件(如
line_follow_v1_p_only.py,line_follow_v2_add_d.py)。比赛时紧张,很容易调乱,能快速回退到上一个稳定版本是救命稻草。
- 每调通一个功能或优化一个参数,都另存为一个版本的代码文件(如
硬件也关键:
- 摄像头固定: 一定要紧固!任何微小的晃动都会导致图像抖动,被微分环节放大。
- 镜头焦距与高度: 焦距影响视野宽度和前瞻距离。太高视野广但线细,太低则前瞻不足。需要反复试验找到平衡点。
- 补光灯: 如果赛场光线不足或严重不均,可以考虑给OpenMv加一个小的LED补光灯,但要避免直射地面造成强烈反光。柔光罩是个好东西。
5. 常见问题排查速查表
遇到问题别慌,按这个清单一步步查:
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 完全找不到线 | 1. 二值化阈值不对。 2. ROI设置错误,线在区域外。 3. 摄像头未对焦或镜头盖未摘。 4. 自动增益/白平衡未关闭。 | 1. 用阈值编辑器工具,在实时画面中调整阈值,直到黑线清晰变为白色。 2. 在IDE中显示ROI矩形框,检查黑线是否在框内。调整ROI_Y。 3. 检查硬件,确保画面清晰。 4. 确认代码中 sensor.set_auto_gain(False)和sensor.set_auto_whitebal(False)已执行。 |
| 小车画龙(直线震荡) | 1. 比例系数Kp过大。2. 微分系数 Kd过小或为0。3. 图像处理延迟大,控制周期不稳定。 | 1. 逐步减小Kp,直到震荡减弱但仍能跟上线。2. 适当增加 Kd,提供阻尼。3. 优化代码,减少不必要的计算和显示。用 clock.tick()打印帧率,确保在20FPS以上。 |
| 过弯反应迟钝,出弯甩尾 | 1. 比例系数Kp过小。2. 微分系数 Kd过大,抑制了转向动作。3. ROI前瞻太近。 | 1. 适当增大Kp。2. 适当减小 Kd。3. 尝试减小 ROI_Y,让摄像头“看”得更远一点(但要注意线会变细)。 |
| 十字路口误识别或漏识别 | 1. 检测阈值设置不当。 2. 扫描线数量或逻辑有问题。 3. 十字路口特征被滤波掉。 | 1. 调整判定为多线条的阈值(如连续扫描线数量)。 2. 增加扫描线数量,并检查每条线的分析逻辑是否健壮。 3. 暂时关闭可能导致线条连接的开运算,或调整Canny阈值保留更多细节。 |
| 串口通信不稳定,小车抽搐 | 1. 波特率等参数不匹配。 2. 数据格式解析错误。 3. 未处理串口缓冲区。 | 1. 确认OpenMv和下位机串口配置完全一致。 2. 用串口助手监听双方收发数据,检查字节顺序、符号位。 3. 在发送前确保串口缓冲区就绪,或使用带确认的协议。 |
| 特定光照下(如夕阳)性能下降 | 固定阈值不适应光照变化。 | 采用动态阈值方法,如大津法(OTSU)或局部自适应阈值。或者在比赛前采集不同光照下的图像,预设几组阈值参数,根据现场光线手动或自动切换。 |
回过头看,这份代码的每一行都凝结着当时通宵调试的汗水。国赛的备赛过程,与其说是在学技术,不如说是在学习如何系统性地定义问题、拆解问题、实验验证和迭代优化。巡线看似是“调参”,实则是对传感器特性、控制理论、图像处理和嵌入式编程的综合考验。希望这份超详细的注释和心得,能帮你少走些我们当年走过的弯路。最后记住一点:没有一劳永逸的参数,只有对原理的深刻理解,才能应对赛场上千变万化的挑战。多动手,多观察,把算法“可视化”出来,调试就会变得直观很多。祝你在接下来的比赛中取得好成绩!