简介:本资源是一套面向嵌入式视觉初学者与智能小车开发者的OpenMV视觉巡线完整实践代码,聚焦低成本机器视觉在自动循迹场景中的落地应用。压缩包共5个文件,含3个核心Python脚本(分别实现颜色取样与镜头校准、图像二值化预处理、主控逻辑与Arduino串口通信)及2个.autosave备份文件,总大小仅15KB,轻量易部署。已有8601人学习下载,反映出其在教学实践与竞赛入门中的广泛认可。读者可直接获取分阶段调试思路:从HSV颜色空间标定黑色线条、焦距优化保障成像清晰度,到二值化阈值设定与噪声抑制,最终通过UART协议将识别结果实时传递至Arduino控制底盘运动,配套代码结构清晰、注释充分,是理解视觉识别→图像处理→硬件协同闭环的优质入门范例。 玩OpenMV的同学,十个有八个是从巡线开始的,但能把巡线玩明白的真不多。这个项目说大不大,说小不小,一套稳定的视觉巡线代码,涉及图像处理、色块识别、PID闭环控制,每一环都得吃透。我之前用OpenMV Cam做过的巡线小车,从AD赛道到普通白底黑线,踩了不少坑,也沉淀了一套直接用得上的代码结构。这篇就来拆解一下视觉巡线的完整实现,从思路到代码,从调参到避坑,一次讲清楚。
1. 内容整体设计与思路拆解
1.1 为什么选OpenMV做巡线
视觉巡线这事儿,可选方案其实不少。常见的有红外对管巡线、灰度传感器巡线,再往上就是摄像头视觉巡线。前两种属于“低配方案”,只能感知车底那一小片区域,遇到十字路口、断线、急弯基本抓瞎。摄像头方案则能“看得远”,提前预判路径走向,这是质变。
OpenMV在这类任务里有几个硬件上的天然优势:它自带OV7725摄像头(部分型号是OV5640),能输出RGB565图像;核心是STM32F7系列MCU(OpenMV4是F765,OpenMV4 Plus是H743),主频够跑MicroPython解释器,还能做简单的图像处理;最关键的是它把摄像头、处理器、LCD接口、LED补光灯集成在一块板子上,体积小,功耗低,非常适合小车这种对重量和供电敏感的场景。
但OpenMV也有明显的短板:算力有限,跑不了YOLO这类重量级模型;图像分辨率最高也就320x240(实际处理常用QQVGA 160x120);MicroPython解释执行的效率比不了C/C++裸机。所以,OpenMV上的视觉巡线,核心策略不是“上多复杂的算法”,而是“用最合适、最高效的算法解决问题”,这也是我写巡线代码时一直遵循的原则。
1.2 巡线方案的选型对比
做巡线之前,先想清楚自己要什么。是跑速度赛,还是稳定过各种复杂路况?这两者的代码策略差别很大。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 单点色块追踪 | 代码简单,逻辑清晰 | 容易丢线,抗干扰差 | 固定简单赛道,学习入门 |
| 多段分区检测 | 能感知左右偏离趋势,鲁棒性较好 | 需要调多个阈值,代码量中等 | 普通曲线赛道,综合项目 |
| 全图像二值化+找最大色块 | 能处理较复杂背景,抗干扰较强 | 计算量稍大,需注意帧率 | 有轻微反光或杂色的跑道 |
| 线性回归拟合中线 | 可输出连续偏差值,转为转向量 | 对阈值质量依赖高,跑偏后难恢复 | 高速巡线,进阶玩家 |
我用得最多、最推荐的是多段分区检测。它兼顾了稳定性和计算效率,而且代码结构清晰,方便在此基础上扩展其他功能(比如识别路口、检测终点)。
1.3 代码的整体架构规划
一段好的OpenMV巡线代码,不应该是一坨杂糅的循环。我的习惯是把它拆成几个模块:
- 传感器初始化:摄像头配置、IO口定义、串口参数设置。
- 图像处理函数:二值化、ROI区域划分、色块提取。
- 决策函数:根据检测到的色块位置,计算偏差值、输出控制信号。
- 主循环:编排以上所有模块,控制运行节奏。
这么拆分之后,每段代码的职责都单一,后面调参或者加功能(比如加个红绿灯识别)的时候就非常方便,不用动主逻辑,只改对应的函数就行。
2. 核心细节解析与实操要点
2.1 二值化阈值的关键作用
视觉巡线的第一步,是从图像中把“线”和“背景”区分开。OpenMV里最常用的方法是find_blobs找色块,但它找的是彩色色块,如果你的线是黑色的,背景是白色的,那直接用find_blobs找“黑色”其实是可行的,但容易受光影、浅色阴影干扰。
更稳妥的做法是先用image.binary()做二值化:把符合条件的像素点变成白色,其余的变成黑色。这样后续找色块时,只需要找白色区域,极度稳定。
二值化需要设置颜色阈值(LAB色彩空间下的L、A、B三个通道的阈值范围)。给个我实测比较稳的配置(黑线白底):
# LAB色域阈值,L代表亮度,A/B代表颜色 THRESHOLD_BLACK = (0, 60, -20, 20, -20, 20) # 黑色线阈值搭上二值化,代码长这样:
img.binary([THRESHOLD_BLACK], invert=False)这一行的意思是:图像中符合THRESHOLD_BLACK范围的像素保留原色(白色),其余的全部变成黑色。之后图像就变成了一张黑白图。
2.2 ROI区域划分的实操技巧
我见过很多新手在整幅图像上找线,这样做有两个问题:一是远处的线太细太小,容易漏检;二是图像底部的近处区域会混入车身阴影、光斑等干扰。正确做法是把图像切成几个ROI(Region of Interest,感兴趣区域),分别处理。
典型的做法是将QQVGA(160x120)的图像按高度分为3~5层ROI,每层独立找色块,然后综合判断线的位置。分层的好处是:每一层的阈值可以单独微调,比如离车最近的区域内阈值可以适当收紧,避免阴影干扰;远处区域可以适当放宽,确保线在光照变化下不容易丢。
分层代码示意:
ROIS = [ (0, 90, 160, 30), # 近处区域,y=90到120 (0, 60, 160, 30), # 中间区域 (0, 30, 160, 30), # 远处区域 ]每个ROI就是一个四元组(x, y, w, h),实际使用中我会把这些ROI都调成合适的宽度(通常是整幅图像的宽度),以避免线出画幅时丢失。
2.3 色块提取与最大色块判断
在每一分层ROI里,通过find_blobs找白色区域,然后取最大色块作为这一层的“候选线”。这里有个关键点:find_blobs之后一定要判空,否则代码会直接卡死。
blobs = img.find_blobs([THRESHOLD_WHITE], pixels_threshold=20, area_threshold=20, merge=True) if blobs: max_blob = max(blobs, key=lambda b: b.area()) else: continuepixels_threshold和area_threshold这两个参数很微妙:太小容易导致噪点被当成线,太大则会把细线过滤掉。我实测对于黑线白底、线宽大概12~16像素的场景,pixels_threshold=20, area_threshold=20是个不错的起点。
max_blob.cy()返回色块中心的y坐标(在ROI内),max_blob.cx()返回中心x坐标。这两个值会用于后续的偏差计算。
2.4 PID控制在巡线中的应用
检测到线的位置后,接下来就是决定怎么控制小车转向。我用的是PID控制。
简单说,PID就是根据偏差(目标值与实际值的差)来计算控制量。巡线场景里,偏差 = 目标中线位置 - 当前检测到的线中心位置。目标中线一般取图像中心的x坐标(比如160/2 = 80)。
比例项P:偏差越大,转向越猛;积分项I:消除累积误差,但用多了容易震荡;微分项D:让转向变化更平缓,抑制震荡。
巡线小车最常用的是PD控制,I项一般不用或者取很小的值。因为巡线是动态过程,累积误差意义不大,反而容易引起转向抖动。
# PID参数 KP = 0.6 KD = 1.2 KI = 0.0 # 误差计算 error = 80 - max_blob.cx() derivative = error - last_error output = KP * error + KI * integral + KD * derivative last_error = error然后根据output去控制舵机或差速转向。比如output为正说明线在左边,小车需要左转。
2.5 控制信号输出与电机配置
OpenMV本身不直接驱动电机(它有PWM引脚和IO口,但直接驱动大电流电机很不现实),通常通过串口把偏差量发给单片机(STM32、Arduino等),由单片机解析并控制电机驱动模块(比如TB6612、L298N)。串口通信帧可以自己定义,比如用简单的文本协议:
- 发送格式:"E-23\r\n"(E表示偏差值) - 或者二进制协议:一个包头、一个数据字节、一个校验字节也可以让OpenMV直接接到带I2C/串口的驱动板,一些方案里OpenMV只负责视觉输出偏差,简化开发。我自己的方案里,OpenMV通过串口发偏差值给下面板子的PID算法,也就是OpenMV不做PID控制,只做视觉检测。这样整车的控制逻辑耦合度更低,也更容易调整各自的参数。
3. 实操过程与核心环节实现
3.1 硬件准备
我的标准巡线套件是这样的:
- OpenMV Cam(Any 型号都行,我用的是OpenMV4 Plus)
- 智能小车底盘(两驱或四驱,我用的是两驱+万向轮)
- TB6612双路电机驱动模块
- STM32F103C8T6最小系统板(负责电机控制)
- 一块锂电池组(7.4V 2S,给底盘和驱动模块供电;注意给OpenMV板供电最好用5V降压模块)
- 杜邦线若干
- OpenMV IDE(最新版可以从官网下载)
接线方面,OpenMV的P4(TX)接STM32的RX,OpenMV的P5(RX)接STM32的TX,GND一定要共地。
3.2 OpenMV端代码实现
直接上代码。这是我封装的巡线主程序,带注释,方便理解:
# 入口文件 main.py import sensor import image import time import math from pyb import UART # 摄像头初始化 sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QQVGA) # 160x120 sensor.skip_frames(30) # 跳过预热帧 sensor.set_auto_gain(False) # 关闭自动增益,稳定亮度 sensor.set_auto_whitebal(False) # 关闭白平衡,稳定颜色 # 串口初始化(PA9为TX,PA10为RX) uart = UART(1, 115200, timeout_char=1000) # 颜色阈值(LAB) THRESHOLD_WHITE = (80, 100, -20, 20, -20, 20) # 白底黑线场景下,二值化后白线区域 # 图像处理ROI(从近到远分层) ROIS = [ (0, 90, 160, 30), (0, 60, 160, 30), (0, 30, 160, 30), ] # PID参数及变量 KP = 0.7 KD = 1.5 last_error = 0 def get_line_position(img, roi): """在指定ROI内找最大色块,返回其中心x坐标,未找到则返回-1""" blobs = img.find_blobs([THRESHOLD_WHITE], roi=roi, pixels_threshold=20, area_threshold=20, merge=True) if blobs: max_blob = max(blobs, key=lambda b: b.area()) # 画线便于调试 img.draw_rectangle(max_blob.rect()) img.draw_cross(max_blob.cx(), max_blob.cy()) return max_blob.cx() else: return -1 while True: img = sensor.snapshot() # 二值化:保留白色区域 img.binary([THRESHOLD_WHITE], invert=False) # 计算加权偏差 weighted_error = 0 found_count = 0 for weight, roi in enumerate(ROIS): cx = get_line_position(img, roi) if cx != -1: # 层越高权重越低,底层权重最高 w = 1.0 / (weight + 1) weighted_error += (80 - cx) * w found_count += 1 if found_count > 0: weighted_error /= found_count # 平均偏差 derivative = weighted_error - last_error output = KP * weighted_error + KD * derivative last_error = weighted_error # 将控制量映射到 -100 ~ 100 output = max(-100, min(100, output)) # 发送给下位机:E代表误差,数值是带符号整数 uart.write("E%03d\r\n" % int(weighted_error)) # 也可以在屏幕上显示,便于实时调试 print("error:", weighted_error, "output:", output) else: # 丢线处理:保持上次偏差,发送丢线标志 uart.write("L\r\n") print("lost line")这里有两个需要说明的点:
weighted_error的计算:为什么远近ROI的权重不一样?因为近处ROI里的线像素多、可信度高,远处ROI里的线像素少、容易误判,所以远处给低权重。这个策略在实践中比所有ROI等权更稳。output的计算和发送:我把weighted_error发送给下位机,下位机自己决定如何用这个偏差去驱动电机。如果你想把PID控制直接放OpenMV上跑,那可以直接发output,但要注意给下位机留好协议扩展空间。
3.3 STM32端电机控制代码实现
下位机STM32的部分就简单了,它的任务是收串口、解析协议、输出PWM。这里给出一个最简的框架:
// 串口接收中断 void USART1_IRQHandler(void) { uint8_t ch = 0; if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { ch = USART_ReceiveData(USART1); // 简单协议解析:'E'开头,后跟3位带符号数字,'\r\n'结尾 if (ch == 'E') { rx_state = 1; // 开始接收数据 } else if (ch == 'L') { line_lost_flag = 1; // 丢线标志 } else if (rx_state == 1 && ch >= '0' && ch <= '9') { value = value * 10 + (ch - '0'); value_count++; if (value_count == 3) { rx_state = 0; // 根据符号位处理正负 error_value = is_negative ? -value : value; value_count = 0; } } else if (ch == '-') { is_negative = 1; } else if (ch == '\r' || ch == '\n') { // 一帧结束,将error_value转为PWM输出 MotorControl(error_value); is_negative = 0; value = 0; } } }单片机端的调参逻辑很简单:偏差为正时左转,为负时右转,偏差的绝对值映射到转向PWM。这里不做死板的前进速度限制,让它根据偏差动态调整是巡线速度优化的核心。
3.4 阈值标定的三步法
阈值标定是OpenMV巡线里最劝退新手的环节,也最耗时。分享一个我的三步法,能少走很多弯路:
- 初次估算:把OpenMV对着赛道,运行IDE的“帧缓冲区”工具,切换到LAB色彩空间,手动采样几个关键像素点的L、A、B值,先估一个初始阈值。
- 动态校准:在IDE的“阈值编辑器”面板里,开着实时画面,拖动阈值滑条,直到画面里只有线是白色,其他全是黑色。这个过程要慢慢拖,别急。
- 实车路测微调:把OpenMV装到小车上,跑一圈,看哪段丢失了就用IDE的
img.save()存下当前帧,复盘阈值哪里不合理。我一般是白天强光和晚上灯光各标定一次,分别存一套阈值,防止环境变化导致大范围飘移。
3.5 PID参数调试的实操记录
PID参数调试是另一大坑。我的调试顺序是:P -> D -> I(如果有的话),不一起调。
- 先给P一个比较小的值(比如0.3),P太小车会来回晃,加大P直到车子能勉强沿弯道走,但会有明显摆动。
- 然后加D,D能有效抑制摆动,我会从0.8左右开始加,加到车子摆动明显变小、过弯流畅为止。
- 如果车子还是有一点稳态误差(比如总偏向一边),再加一点I(比如0.01),但I值一大就容易震荡,慎用。
实车调参时注意一个细节:一定要在最高速度下调参,因为低速下参数自适应强,高速下暴露出的问题形态完全不同。低速下很稳的系统,跑起高速很可能甩尾或者冲线。
4. 常见问题与排查技巧实录
4.1 丢线问题
丢线是巡线最烦人的问题,没有之一。我遇到过的情况有几种:
- 阈值不对:反光、阴影、光照变化,让线的颜色超出阈值范围。解决办法:用前面说的三步法重新标定,或者把阈值范围放宽一点,给环境变化留余量。
- 弯道太急:线直接冲出画面。解决办法:ROI分层中近处区域的宽度要留足,同时也检查ROI的高度,别因为ROI太小导致线的边缘被截掉一块。另外可以适当降低车速给视觉系统留出反应时间。
- 结构问题:图像中有其他白色物体混入。解决办法:在
find_blobs时增加area_threshold,或者用find_blobs的invert参数来只保留特定方向的区域。
处理丢线的逻辑也很重要。我的策略是:如果视野里完全找不到线,就发送“L”标志,下位机让它前轮保持上一状态直行一小段,同时升高底盘传感器灵敏度去“找”线。不要原地急停,也不要猛打方向,这两种操作都会让车辆卡死在赛道上。
4.2 图像模糊或绿屏
这个一般都是摄像头排线松动或者供电不足导致的。OpenMV对供电质量很敏感,外接电机驱动模块时如果共用一个电源,电机启动瞬间的电压跌落会让摄像头花屏甚至死机。经验是OpenMV最好用独立的5V/1A以上的供电,或者加一个大电容稳压。还有就是摄像头模块上的排线一定要插紧,特别是FPC软排线,稍微没对齐就接触不良。
4.3 串口通信乱码
串口乱码八成是波特率不匹配,或者GND不共地。连串口之前先用IDE的串口助手主动发一帧测试数据,确保下位机能收到。还有一个细节:OpenMV的UART默认是1个停止位、8位数据、无校验,下位机的配置必须一致。
4.4 帧率太低,转弯反应慢
帧率低的原因通常是二值化处理耗时太久或者每帧的图像尺寸太大。我实测QQVGA下做完二值化和色块查找大概能跑到30~40 FPS,如果低于这个数,先检查是不是在每帧里做了draw_string之类的调试绘制,这些绘制函数很耗时。另外,find_blobs的merge=True会多花一些时间,如果线很粗且连续,可以不开merge。
4.5 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 线在画面上看不清 | 阈值不合理 | 打开阈值编辑器现场调 |
| 车子总是冲出弯道 | 车速过快或PID前馈不足 | 降低车速、调试PD参数、增大D值 |
| 车子来回摇摆 | P值过大 | 减小P或增大D |
| 串口收不到数据 | 波特率不匹配 / 接线错误 | 检查波特率,确认TX/RX交叉接线 |
| 车子完全不走 | 电压不足 / 电机驱动接线错误 | 测电机驱动模块的电源,检查IN1/IN2逻辑 |
| 画面绿屏 | 摄像头模块排线松动或供电不足 | 重新插拔排线,独立供电 |
4.6 自己踩过的坑:不要迷信“完美阈值”
有段时间我为了追求一个无论什么光线都完美的阈值,花了两天时间反复标定,结果还是翻车。后来想通了:OpenMV的RGB565图像天然对光照敏感,与其追求完美阈值,不如让系统具备“自适应”能力。我现在会在程序启动时先跑一段自动曝光校准(sensor.set_auto_gain(True)跑几十帧后再关掉),让摄像头自动适应环境亮度,然后才切入固定阈值模式。实测下来,这个简单的启动校准逻辑比手动调阈值管用得多。
5. 进阶优化与扩展建议
5.1 动态调整曝光参数
巡线过程中,如果赛道经过窗边或者灯光下,环境亮度会突变。固定的曝光值很容易让二值化崩掉。我的习惯是开启自动增益和自动白平衡,但把它们的变动范围限制在一个合理区间:
sensor.set_auto_gain(True, gain_db=10) # 限制增益上限 sensor.set_auto_whitebal(True, rgb_gain_db=6) # 限制白平衡增益这样既保留了自动适应的灵活性,又不会因为突然的强光让画面整体发白。
5.2 多模式策略:正常巡线+路口检测
如果赛事有十字路口或者T字路口,可以在分层ROI里单独加一个“路口检测”逻辑。当最近一层ROI的最大色块宽度接近图像宽度时,基本可以判断车位于路口上方,此时可以发送特殊标志给下位机执行特殊的转向策略。
这一块不算复杂,但必须要在主循环里加一个状态机,让代码有“记忆能力”,不会在路口上反复横跳。
5.3 误差融合与卡尔曼滤波
如果觉得偏差数据抖动比较厉害,可以用简单的低通滤波滤掉高频噪声:
smooth_error = smooth_error * 0.8 + last_error * 0.2这个低通滤波会牺牲一点响应速度,但对于中低速巡线车来说,换来的是更丝滑的转向。想上强度的话可以试试卡尔曼滤波,但OpenMV上跑卡尔曼对性能有影响,性价比不高。
5.4 数据可视化与上位机联调
调试时强烈建议打开IDE的“帧缓冲区”功能,把二值化后的图像和绘制的色块框实时显示。OpenMV还能通过IDE的“串行终端”查看print输出的偏差值。我习惯用UART把偏差值也传到PC端的串口助手画线,这样能直观看到偏差的变化曲线,判断PID参数是否合适。
写在最后
视觉巡线这个项目,看起来只是“找线+跟着线走”,但真正落地时涉及的细节非常多。从图像处理到控制理论,从硬件布局到环境适配,任何一环出了问题,车在地上跑的时候都会立刻暴露。这也是为什么OpenMV巡线至今还是各大赛事的经典任务——它足够简单入门,也足够复杂锻炼人。
如果你刚开始接触OpenMV,我建议别急着抄代码,先把IDE的阈值编辑器玩熟,找一张纸自己写一遍二值化、找色块、算偏差、发串口的流程,然后才去跑实车。只有理解了每一步在干什么,后面遇到问题才知道去哪里找原因。最后分享一个小技巧:在OpenMV IDE里用img.save()把调试现场存下来,每次跑完车回看一帧一帧的图像,培养“视觉直觉”,你会发现自己调参的速度越来越快。
本文还有配套的精品资源,点击获取