
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_Y120 我们的摄像头是向前下方倾斜安装的。如果ROI设得太高比如Y50会看到很远的路面线条细、容易受透视畸变影响且容易丢失。设得太低比如Y200看到的是车头前很近的地面等看到线再反应就晚了。120-160这个区域是视野、前瞻距离和处理速度的一个平衡点。ROI_HEIGHT40 高度不能太窄否则稍微一颠簸黑线就可能跑出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(roiROI) # 使用Canny边缘检测器找到可能的线条边缘 # 参数调节是门艺术threshold1和threshold2控制边缘连接的强弱。 edges roi_img.find_edges(image.EDGE_CANNY, threshold130, threshold260) # 对边缘检测后的图像进行二值化将边缘强化为白色线条 # 这里用一个固定阈值。更鲁棒的方法是使用大津法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_threshold20, area_threshold20, mergeTrue) # 然后从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控制器的输入。error0意味着线在正中间小车应直行。error0意味着线在右边小车车头应该向右转即向左轮减速或右轮加速以对准线。这个逻辑可以根据你的小车运动模型调整。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, bINTERSECTION)) # 发送特殊指令 # 切换状态机等待上层决策...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里观察巡线是否稳定中线计算是否准确。这是调参的主要阶段。第二阶段低速动态测试 让小车在简单赛道上低速跑观察其跟随性能。重点调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)或局部自适应阈值。或者在比赛前采集不同光照下的图像预设几组阈值参数根据现场光线手动或自动切换。回过头看这份代码的每一行都凝结着当时通宵调试的汗水。国赛的备赛过程与其说是在学技术不如说是在学习如何系统性地定义问题、拆解问题、实验验证和迭代优化。巡线看似是“调参”实则是对传感器特性、控制理论、图像处理和嵌入式编程的综合考验。希望这份超详细的注释和心得能帮你少走些我们当年走过的弯路。最后记住一点没有一劳永逸的参数只有对原理的深刻理解才能应对赛场上千变万化的挑战。多动手多观察把算法“可视化”出来调试就会变得直观很多。祝你在接下来的比赛中取得好成绩