
简介本资源是一套面向嵌入式视觉初学者与智能小车开发者的OpenMV视觉巡线完整实践代码聚焦低成本机器视觉在自动循迹场景中的落地应用。资源共5个文件含3个核心Python脚本分别实现镜头焦距校准与颜色取样、图像二值化预处理、主控逻辑及Arduino串口通信和2个.autosave备份文件总大小仅15KB轻量易读便于理解各模块分工与调试流程。已有8601人学习下载反映出其在高校课程设计、创客竞赛与课设项目中的广泛参考价值。读者可直接复现从图像采集、HSV颜色空间识别黑色路径、自适应二值化处理到霍夫直线检测与实时PID纠偏控制的全链路逻辑并通过UART协议将识别结果稳定传输至Arduino底盘配套代码结构清晰、注释充分是掌握OpenMV视觉开发与跨平台协同控制的优质入门范例。 前些天有个学生搬来一辆老款智能车车底装了三路红外对管赛道稍微反光就开始左右“抽风”。我说这事该交给摄像头了他顺手换了块OpenMV。OpenMV视觉巡线看起来是个老话题但每次帮人调车都会遇到同样几个问题阈值不会取、线明明找到了却不跟踪、过弯甩尾、十字路口直接冲出场。这篇文章把我这几年调OpenMV巡线车的代码和经验一次性写清楚主要围绕OpenMV视觉识别巡线的基本链路图像预处理、找线、偏差计算、转向控制、边界情况处理。适合刚入门OpenMV的参赛学生、DIY智能车玩家也适合想从红外巡线升级到视觉巡线的开发者。1. 用OpenMV巡线前先想清楚这三件事1.1 它和传统光电巡线的本质区别红外对管巡线其实是在看“有没有线”光电管输出高/低电平逻辑就是阈值开关OpenMV是在看“线在哪”一张320x240的灰度图能给出线的位置、宽度、方向和曲率。这个区别决定了策略的上限。OpenMV视觉巡线本质上是用“图像里的几何信息”取代“单点电平”能应付更复杂的赛道比如大角度弯、连续S弯、断线记忆、入口选择。但代价是处理链路变长采图、阈值化、找连通域、算偏差、控制输出每一个环节都可能出问题。很多人一上来就复制代码跑不起来就以为是代码问题实际上根因往往是镜头安装高度不对、曝光没锁、阈值取的是室内灯下的值一到阳光下全飘。所以动手之前先接受一个事实视觉巡线的核心工作不是写代码而是把“场景条件”稳定住。场景稳定了代码基本就是模板化的三板斧。1.2 摄像头怎么装才不容易跑飞OpenMV巡线一般有两种装法俯视和前视。俯视是摄像头朝下30到45度画面里只有一小段地面信息最纯粹适合低速稳定巡线前视是装在车头朝前方看能提前看到远处赛道高速过弯更从容但容易把赛道外的背景、窗户光、人影收进画面干扰源成倍增加。我给大多数人的建议是黑色电工胶带贴浅色地面用俯视加灰度模式ROI取画面中下方三分之一到二分之一。这个配置最抗干扰也最容易调。摄像头离地高度建议15到20厘米让黑色引导线在画面底部占6到10个像素宽度。太细了find_blobs会把一条线切成很多碎片太粗了镜头稍微歪一点线的质心就会偏向画面一侧产生一个很大的固定偏差。安装还有一个容易被忽略的点摄像头要正对车体前进方向左右角度偏差别超过2度。很多车跑起来总是在某个弯道一边倒排查半天发现是镜头歪了白调了半小时PID。1.3 先定灰度还是彩色先用灰度还是彩色不是拍脑袋选的取决于赛道引导线颜色和地面颜色的对比度。深色线配浅色地用灰度模式阈值是一个一维区间处理快稳定。彩色线红、绿、蓝或彩色地面用RGB565模式阈值是LAB六元组。LAB颜色空间比RGB更接近人类视觉感知OpenMV IDE的阈值编辑器也是基于LAB能更精准地框出目标色。我的习惯是只要能靠亮度差分开一律用灰度。灰度模式下OpenMV的帧率更高代码更少调起来也更直观。只有在黑色线和阴影区分不开、必须靠颜色信息切割时才切到RGB565。别一上来就上彩色彩色要调的阈值参数多一倍对光照变化也更敏感。2. 图像处理的第一道关ROI、曝光锁定与阈值提取2.1 为什么不要在全图范围找线新手最容易犯的错就是把整帧图像扔给find_blobs。这样做的坏处有两个一是画面顶部往往是墙面、窗口、远处环境这些区域偶尔会出现和引导线颜色相近的物体产生误检二是全图检测会导致算出来的质心被顶部区域干扰偏差波动大车就会抖。正确做法是用ROI把搜索区域限制在引导线必然出现的带状区域。以QQVGA160x120分辨率为例我会把ROI设为(0, 60, 160, 40)从第60行开始高度40像素正好覆盖画面下三分之一。这样既滤掉了背景干扰又大幅减少了find_blobs的运算量帧率能明显提升。ROI也不是越大越好。ROI太高会把远处的小线段纳入计算这些点在画面里只有几个像素质心抖动很大ROI太低过弯时线直接跑出ROI底部车子会瞬间丢线。实战中我一般把ROI底边贴紧画面底部让线永远先进入视野下方。2.2 阈值不是猜出来的是看直方图看出来的灰度模式下取阈值最蠢的做法是随便写一个(10, 80)然后试。正确姿势是用OpenMV IDE的“阈值编辑器”工具打开一张实际赛道照片框出你要跟踪的线工具会自动显示目标区域的灰度直方图。取直方图波峰的范围再留出30%左右的余量才是靠谱的阈值。以黑色电工胶带为例它在室内白纸地面上灰度值通常在10到40之间地面在150到220之间阈值写(0, 50)就很合适。但注意这个值会随光照变化太阳光直射时地面可能到240黑色胶带也可能反光到60以上。所以阈值编辑器里的照片最好在你实际比赛的光照条件下拍。LAB六元组的道理一样只是多几个维度。我在调彩色线时会先用阈值编辑器“锁定”当前颜色再人工把三个通道的上下限各放宽5到10避免光线小波动时目标被滤掉。2.3 不锁曝光和增益阈值等于白调这是OpenMV巡线最经典的一个坑代码里明明没动任何参数换了个位置运行车突然找不到线了。原因通常是sensor.set_auto_gain()和sensor.set_auto_exposure()没有关闭。OpenMV默认会自动调整增益和曝光来适应环境亮度这本身是好事但对于巡线场景是灾难系统每帧都在调整光电参数阈值却是固定的二者永远对不上。初始化里必须显式关闭自动增益和自动曝光让画面亮度和颜色保持恒定import sensor sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) sensor.set_framesize(sensor.QQVGA) sensor.skip_frames(30) sensor.set_auto_gain(False) # 关闭自动增益 sensor.set_auto_exposure(False) # 关闭自动曝光 sensor.skip_frames(30)只有先固定了“相机本身的响应曲线”后面所有阈值参数才有意义。下面这段是我常用的初始化模板额外加了一个曝光值手动设定import sensor, image, time sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) sensor.set_framesize(sensor.QQVGA) sensor.skip_frames(time100) sensor.set_auto_gain(False) sensor.set_auto_exposure(False, exposure_us20000) # 手动曝光20ms比自动更稳 sensor.skip_frames(time100) clock time.clock()曝光值选多大取决于环境室内灯光下20ms左右够用室外强光可以压到5到10ms能有效减少动态模糊。跑得快的车需要更短的曝光时间不然过弯时画面拖影线会变得又宽又虚偏差计算会滞后。2.4 验证阈值是否合理的小技巧在IDE里跑起来以后别急着上车。先在“工具-机器视觉-阈值编辑器”里确认一下目标区域在整个ROI范围内是否高亮再写一行img.binary([threshold])把二值化结果显示出来。如果画面上线是连续的白线、背景是干净的黑这帧图像才是可用的。这一步我很推荐用OpenMV的img.get_histogram()接口做一次自动验证脚本示例如下img sensor.snapshot() stats img.get_statistics(roi(0, 60, 160, 40)) print(mean:, stats.mean(), min:, stats.min(), max:, stats.max())看一下ROI区域直方图的最值区间如果线的灰度和地面的灰度区间有重叠说明光照或曝光有问题先别写控制逻辑回头调相机参数。3. 巡线主循环找色块、算偏差、控转向3.1 find_blobs的参数到底该怎么给find_blobs是OpenMV视觉巡线的核心函数它做的事是在ROI里找满足阈值条件的连通区域。关键在于参数怎么配thresholds前面调好的阈值灰度模式传一个[(0, 50)]这样的列表。roi搜索区域传入前面确定的ROI。pixels_threshold小于这个像素数的色块会被丢弃用来过滤噪点。area_threshold色块面积阈值同样用于去噪。merge合并相邻色块我建议设为True否则一条线被阴影切成多段时find_blobs会返回很多碎片。margin合并色块时允许的最大间隔像素通常设5到10。下面的代码能直接感受这些参数的效果import sensor, image, time THRESHOLD (0, 50) # 黑色线的灰度阈值按自己赛道调整 ROI (0, 60, 160, 40) # 只取画面下方1/3 sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) sensor.set_framesize(sensor.QQVGA) sensor.skip_frames(30) sensor.set_auto_gain(False) sensor.set_auto_exposure(False, exposure_us20000) clock time.clock() while True: clock.tick() img sensor.snapshot() blobs img.find_blobs([THRESHOLD], roiROI, pixels_threshold20, area_threshold20, mergeTrue, margin8) if blobs: # 取最大色块作为“主线” max_blob max(blobs, keylambda b: b.pixels()) img.draw_rectangle(max_blob.rect(), color127) img.draw_cross(max_blob.cx(), max_blob.cy(), color127) print(center_x:, max_blob.cx()) else: print(lost) print(FPS:, clock.fps())色块对象的pixels()是这个连通区域的像素总数cx()和cy()是质心坐标w()和h()是外接框宽高。取最大色块作为主线是巡线最简单的策略如果一条线上有多个区域说明图像预处理还不够干净优先回头调阈值而不是在blobs列表里做过多逻辑。3.2 从色块质心到偏差量只有一行代码跟踪线的目标不是“找到线”而是“给自己一个转向参考”。OpenMV社区最常用的方法是计算线的质心坐标偏离画面中心的程度error max_blob.cx() - img.width() // 2这个error就是偏差量线在画面左侧error为负车要向左转线在右侧error为正车要向右转。之所以用质心而不是色块的某个边缘是因为质心对线的宽度变化不敏感抗干扰更好。不过这个偏差量有两个隐藏问题一是质心会被ROI里的大块误检区域拉偏所以前面pixels_threshold要设得足够高把小噪点滤掉。二是当线处于大弯道时画面里可能同时出现“当前线”和“下一段线”质心会被下一段线拉得偏离当前方向。这时可以引入“只看色块下半部分”的思路把ROI的高度再压缩或者用色块相对画面底部的交点来计算偏差。简单做法是把ROI切得更窄只保留车头前方一小段稳定性会立竿见影。3.3 转向和油门分开处理很多入门代码是拿到偏差量后直接控制左右轮left base_speed error。这样在直线段还能凑合一进弯道就出问题偏差大的时候内侧轮可能反转甚至导致整车抖动。正确思路是先把偏差量转换成“转向输出”再把这个转向输出叠加到底速上。例如steering pid.get_result(error) # 转向输出 left_speed base_speed steering right_speed base_speed - steering其中base_speed是直行时的基础速度steering是控制器给出的修正量。转向和速度分离让你可以先单独调稳定效果再提速度。如果车用的是舵机转向加电机的结构逻辑更简单servo_angle mid_angle steering。舵机一般在90度是中间位置steering负值左转、正值右转限幅到舵机允许范围内就行。servo.angle(90 steering) # 以90度为中位steering单位是度这种“偏差-转向-速度”的链路就是巡线控制的主干。剩下的就是让偏差量算得更稳、转向控制更平滑也就是下一节PID要做的事。4. PID转向控制让车“顺滑过弯”的关键4.1 为什么用PID而不是直接比例控制把偏差量直接乘一个系数Kp映射成转向是最常见的比例控制公式就是steering Kp * error。P控制已经在很多简单巡线车上能跑但它有两个天生缺陷一是过弯时会有稳态误差。当线的走向不断偏出画面中心时比例控制输出的转向角永远“差一口气”车会贴着一侧过弯。二是静态抖动。偏差突变时P项会瞬间给出很大输出对低速车来说就是猛地一甩头。PID里的I项负责消除长期偏差只要偏差一直存在积分项会持续累积让输出逐渐加大D项负责预测偏差的趋势偏差快速变大的时候D项先拉一把减小超调。巡线场景最合适的是一个“偏重P和D、I用小值”的PID。I项不能给大否则过弯时间稍长积分累积会过头出弯后车会继续朝弯的方向跑很长一段。4.2 让PID代码跑起来OpenMV本身带一个pid.py模块官方巡线例程里也有直接导入就能用。但为了让你搞清楚里面发生了什么我先给一份自包含的PID实现class PID: def __init__(self, kp0.35, ki0.01, kd0.18, dt0.02): self.kp kp self.ki ki self.kd kd self.dt dt self.integral 0 self.last_error 0 def get_result(self, error): self.integral error * self.dt # 积分限幅防止过弯时间长导致积分饱和 if self.integral 100: self.integral 100 elif self.integral -100: self.integral -100 derivative (error - self.last_error) / self.dt output self.kp * error self.ki * self.integral self.kd * derivative self.last_error error return output主循环里的用法pid PID(kp0.35, ki0.01, kd0.18) base_speed 50 left_motor None right_motor None # 假设你通过PWM控制两个直流电机 from pyb import Pin, Timer # 初始化PWM的代码省略按你的驱动板接线自行配置 while True: img sensor.snapshot() blobs img.find_blobs([THRESHOLD], roiROI, pixels_threshold20, area_threshold20, mergeTrue, margin8) if blobs: max_blob max(blobs, keylambda b: b.pixels()) error max_blob.cx() - img.width() // 2 else: error last_error * 0.5 # 丢线时向最后方向衰减后面细说 steering pid.get_result(error) left min(max(base_speed steering, 20), 100) right min(max(base_speed - steering, 20), 100) # 写入电机驱动函数自行实现 motor_drive(left, right) last_error error代码里加了两个细节输出限幅20到100防止车在急弯时一侧电机反转或速度突变造成甩尾积分限幅100避免长弯道的积分饱和。4.3 PID参数整定的顺序PID参数的调整顺序我总结为四步第一步先只保留P项把I和D设为0。从小到大加Kp直到车在直线段稳定、在弯道“跟得上”但会出现轻微左右摆动。Kp太小车在弯道会冲出赛道太大会在直线段蛇形走位。第二步加入D项从0.1开始慢慢加。D项会让车在偏差快速变化时提前减速转向直观表现是弯道里的“甩头感”减轻。D大了之后直线段会因为地面噪点产生高频抖动这时需要调低一点。第三步加入少量I项从0.005到0.02试。I项的作用在长时间贴线过弯时最明显它能把车拉回“正确路径”而不是一直贴着弯道内侧过。I大了之后出弯道会出现明显的“过冲掉头”那是积分过量了。第四步把基础速度提高一档再从第一步开始微调一遍。每提一次速PID参数基本都要重新整定这是我反复验证过的规律别指望一套参数吃遍所有速度档。5. 四条最容易翻车的边界情况处理5.1 十字路口和斑马线别让车“乱认路”十字路口在灰度图上的表现是色块突然变得很宽甚至横贯整个ROI底部。如果你只是追踪最大色块十字路口的质心可能正好在画面中心车会直行冲过去这在大多数比赛里不是坏事但如果你的路线要求转弯就必须检测十字路口。检测方法很简单当最大色块的宽度blob.w()超过ROI宽度的一定比例比如90%时判定为路口。进入路口后根据任务需求让车做转弯、掉头或直行。注意要在进入路口前触发判定我一般取ROI底部色块宽度做判断if blobs: max_blob max(blobs, keylambda b: b.pixels()) if max_blob.w() ROI[2] * 0.85: print(cross detected) # 执行路口策略比如强制右转 steering 30 else: steering pid.get_result(max_blob.cx() - img.width() // 2)斑马线是一组连续横穿赛道的短线它的特点是色块会在短时间内反复出现又消失或者ROI内出现多个横向色块。处理斑马线的思路是把它看成“连续路口”通过判断ROI内色块数量大于等于2或者色块宽度反复变大变小进入斑马线模式期间继续保持当前方向不做大幅修正。5.2 断线和虚线丢线时不慌才是真稳赛道上的虚线或者阴影造成的临时断线是巡线最大的干扰源之一。if blobs:分支走不到很多代码就直接把速度置零车停在线前这是最要命的。我的处理策略是“记忆最后有效偏差逐步衰减”丢线时把上一次的last_error乘以一个衰减系数作为当前误差不让车骤停而是沿着最后的方向继续滑行一小段。如果超过一定时间仍然丢线才采取更激进的搜线策略。lost_frames 0 last_error 0 while True: img sensor.snapshot() blobs img.find_blobs([THRESHOLD], roiROI) if blobs: max_blob max(blobs, keylambda b: b.pixels()) error max_blob.cx() - img.width() // 2 last_error error lost_frames 0 else: lost_frames 1 if lost_frames 10: error last_error * 0.7 # 衰减记忆 else: # 扩大搜索范围找线比如把ROI临时扩大到全图 blobs img.find_blobs([THRESHOLD], roi(0, 0, 160, 120)) if blobs: max_blob max(blobs, keylambda b: b.pixels()) error max_blob.cx() - img.width() // 2 last_error error lost_frames 0 else: error last_error # 彻底丢线保持上次方向低速试探last_error这个变量我还会传递给电机控制丢线时如果速度还是全速车照样会冲出去所以丢线帧数越多基础速度要降得越低。我把这个逻辑叫“丢线分级降速”前3帧保持80%速度3到10帧降到50%超过10帧降到20%继续搜。5.3 反光和阴影不是阈值能一劳永逸的事反光最常出现在两种场景一是黑色胶带表面老化后被灯光直射变成灰色甚至白色二是地面是高光泽材质灯管倒影看起来像一条白线。排查这类问题我建议走完整链路先用阈值编辑器看实时直方图确认反光区域的灰度值和真实线是否重叠。如果重叠说明单纯调阈值无法分离需要从物理上解决改变摄像头角度、加偏振片或换雾面胶带。如果只是偶尔反光可以在find_blobs的色块筛选里加入最小面积的限制因为反光通常是从一小块区域开始扩散的。阴影问题正好相反阴影的灰度可能和黑线极像。处理手段是在ROI设置时避开阴影区域或者把赛道铺成平整的浅色材质让阴影边界清晰。还有一个不常被人提到的小技巧用两个光源从不同方向打光阴影就不会形成大面积低灰度区域。5.4 圆环赛道等待“变宽”的信号再转向圆环赛道是很多参赛车的噩梦。实际上圆的特征很明确当车接近圆环入口时引导线在ROI里会同时出现两段一段是当前直道一段是圆弧入口两段线连起来形成一个“Y”形色块总宽度会比普通直道宽很多。正确做法是监测色块宽度变化趋势当blob.w()在连续几帧内变大、且ROI底部出现两个连续点就判定进入圆环然后强制朝环内方向打转向。等出环时色块宽度突然变小就恢复PID控制。last_width 0 in_circle False while True: img sensor.snapshot() blobs img.find_blobs([THRESHOLD], roiROI) if not blobs: continue max_blob max(blobs, keylambda b: b.pixels()) width max_blob.w() if width ROI[2] * 0.55 and last_width ROI[2] * 0.4: in_circle True # 宽度持续变大进入圆环 if in_circle: # 环内强制转向方向根据比赛规则设定 steering turn_direction * 25 # 检测出口:宽度骤降 if width ROI[2] * 0.3: in_circle False else: steering pid.get_result(max_blob.cx() - img.width() // 2) last_width width圆环的转向强度不要写死可以先用PID输出然后叠加一个固定偏置这样车的过环速度能更快也不会在入口处猛地打方向。6. 从IDE调参到稳定跑完一圈的实战复盘6.1 排查链路跑飞问题一例最后分享一个最近帮人调的案例完整走一遍排查思路。现象车在傍晚室内灯光下能正常巡线到了白天窗边跑每次经过靠窗那一段就跑飞。一开始怀疑阈值在IDE里实时看直方图发现黑色胶带的灰度值在阳光直射下已经升到70左右而室内阈值是(0, 50)线根本没被识别。于是把阈值改成(0, 80)在室内测实际地面灰度也能到160以上依然能分开但在靠窗处地面反光灰度能到230而黑色胶带灰度到80还是能分开。改完阈值后靠窗那段依然偶发抖动仔细跟踪发现是自动曝光还在起作用窗边光线强OpenMV自动把曝光时间压短图像整体变暗黑色胶带灰度又降回40以下但阈值已经被改成80于是黑色线和部分阴影全被当成了前景。解决办法是把自动曝光彻底关掉手动固定一个中等曝光值再重新标定阈值。问题解决一圈跑完没有再飞线。这个案例说明很多巡线毛病的根因不在算法而在“图像采集参数是否恒定”。排查时先确认画面上每一帧的明暗和颜色是否一致再往下查阈值、查控制能省掉大量冤枉时间。6.2 调参的正确顺序和常用工具我调巡线车的顺序永远是先调图像再调逻辑最后调速度。对应到具体操作是第一步用阈值编辑器确认线能被稳定提取第二步在IDE里打印色块质心观察直线段和弯道处的偏差值是否合理第三步固定一个很低的基础速度调PID让车沿中线走第四步逐步提高速度每提一档都重新微调PID和曝光时间。常用调试工具上除了OpenMV IDE自带的帧缓冲查看器和阈值编辑器外我还会用串口打印关键变量print(err:, error, steer:, steering, lost:, lost_frames)在工具-串口终端里实时看数据变化比盯着车跑拍视频高效得多。排查时可以把车架空用手移动赛道或者把摄像头对着屏幕上的模拟线看串口输出是否符合预期这一步能把“车的问题”和“代码的问题”分离开。6.3 几个被反复问到的代码细节第一个是img.width() // 2为什么要用整除。QQVGA宽160中线是80用整除可以避免浮点运算引入的精度差异而且OpenMV的像素坐标本来就是整数没必要用浮点。第二个是max(blobs, keylambda b: b.pixels())能不能改成max(blobs, keylambda b: b.area())。面积和像素数不完全等价pixels()是按像素统计的实际前景点数area()是外接矩形的宽高乘积外接矩形可能包含很多背景像素。巡线场景用pixels()更贴近真实“线的粗细”。第三个是mergeTrue和margin的关系。margin设置得越大距离越远的色块会被强行合并如果你的ROI里同时出现两条赛道线margin太大会导致两条线被当成一个块质心落在两条线中间。所以margin通常设置在5到10就可以了不要为了合并碎片而给得过大。第四个是灰度模式画线显示。调试时我喜欢用img.draw_line(max_blob.cx(), max_blob.cy(), img.width()//2, img.height()-1, color127)把从质心到画面底部中线的连线画出来能直观看到“车现在想往哪转”。这行代码在实车调试时很有用看IDE画面就能判断当前控制方向是否符合直觉。6.4 几种常见故障定位对照这里整理一份我平时故障排查的速查表遇到问题先对号入座现象最可能原因优先检查项直线段蛇形走位P过大或D不足降低Kp增加Kd弯道冲出赛道P太小或曝光太长导致拖影增加Kp缩短曝光时间某个固定位置丢线局部反光或阴影检查该位置直方图调整光照全屏都找不到线自动增益/曝光未关或阈值偏离固定曝光重新取阈值过弯后继续偏转I项过大减小Ki检查积分限幅高帧率下电机抖动异常PID输出更新次数过多导致电机响应跟不上降低巡线逻辑执行频率对输出做限幅滤波十字路口不按预设转弯路口判定宽度阈值不匹配打印blob.w()和ROI宽度调整判定比例这些故障大多不是单一原因但按这个方向排查通常能很快收敛到根因。我自己的习惯是一辆新车到手先在固定亮度环境里把阈值和PID调到“闭眼能跑”再去应对各种边界情况这比一开始就追求满赛道条件覆盖要可靠得多。OpenMV视觉巡线的代码量其实不大真正有价值的部分全在于你对“画面这条线”的理解有多深。把图像预处理、偏差计算、控制策略和边界情况分开想每一块都能单独验证整车的表现自然会上来。本文还有配套的精品资源点击获取