
1. 项目整体设计与方案选型1.1 为什么放着现成的灰度循迹不用非要折腾视觉做循迹小车的朋友大多有这种体会早期玩的是红外对管或灰度传感器方案几个探头并排铺在车头沿着黑线跑得不亦乐乎。但一旦赛道变成倾斜、有光照变化、或者出现交叉路口灰度方案就非常难受。红外对管的探测范围就那么几厘米车速一快就冲出赛道而视觉方案可以在车前几十厘米外就“看到”赛道走向相当于提前预判弯道。我这次做“基于机器视觉的循迹小车”核心思路就是用摄像头采集赛道图像在OpenCV里做一系列图像处理最终提取出赛道中心线的横向偏差通过PID控制转向让车稳稳地沿赛道中线行驶。整个项目下来图像处理其实占了七成的工作量控制反而简单。因为只要赛道识别得够干净控制只是水到渠成的事。这套方案的定位很清晰想从传统传感器循迹跨到视觉巡线、同时又没有太多图像处理基础的玩家。它不需要你有深厚的机器学习背景也不需要昂贵的计算平台一块树莓派或类似的Linux开发板加普通USB摄像头就能跑得很流畅。车体用常见的四驱底盘就行电机驱动用TB6612或L298N都行。1.2 方案选型树莓派、K210、还是单片机加摄像头很多新手上来就问用STM32能不能做视觉循迹答案是能做但很吃力。STM32F407这类芯片跑OpenCV级别的图像处理基本没戏最多用摄像头模块做简单的色块追踪算法空间非常有限。如果主控性能不足就得把图像处理放在上位机单片机只管接偏差信号这对通信链路和实时性又是一个考验。我这次选择的方案是树莓派Zero 2 W加树莓派官方摄像头作为前端图像处理单元下位机用STM32F103C8T6做电机控制。这么拆是有原因的树莓派跑Python和OpenCV相对轻松图像处理的调试成本低改个参数重跑一下脚本就能看到效果迭代效率高。STM32负责PWM输出、编码器读取、PID运算这些实时性要求高的任务裸机跑一个简单的控制循环响应速度远快于在Linux里做。两者通过串口通信树莓派只发送一个浮点数赛道偏差STM32只需要解析协议不操心图像的事情职责分离调试时哪儿出了问题一眼就能看出来。也有人说K210这类带硬件加速的芯片更轻量确实K210在功耗和价格上有优势还能跑简单的模型推理但是开发环境相对封闭OpenCV的成熟生态用不上很多图像处理函数要自己造轮子。对于想学视觉原理的玩家我建议还是先走树莓派加OpenCV这条路跑通了再考虑移植到嵌入式平台。1.3 整车系统怎么组成各部分如何协同整个系统的数据流我用一句话概括摄像头拍到图像树莓派算出赛道偏差串口发给STM32STM32驱动电机和舵机完成循迹闭环。整个链路中树莓派大约跑20到30帧每秒的图像处理每帧输出一个偏差值STM32那端以更高的频率读取偏差并执行PID控制两者各司其职互不拖累。整车模块清单如下模块型号/规格作用主控视觉端树莓派Zero 2 W图像采集、图像处理、偏差计算、串口发送主控控制端STM32F103C8T6接收偏差、执行PID、输出PWM、读取编码器摄像头树莓派Camera Module V2 或普通USB免驱摄像头采集赛道图像输出1080p/720p视频流电机驱动TB6612FNG双路直流电机驱动效率高发热小电机TT马达带编码器后轮双驱动带编码器便于测速舵机SG90或MG90S控制前轮转向阿克曼结构或保持差速转向电池7.4V 18650两串锂电池给主控和电机分开供电避免干扰显示屏无远程VNC调试在笔记本上实时查看图像处理中间结果车体结构方面我选用的是前轮舵机转向、后轮差速驱动的阿克曼底盘而不是传统四驱差速底盘。原因很简单阿克曼结构的转向模型更接近真实车辆舵机控制信号是标准的PWM逻辑上比双电机差速更容易理解。当然四驱差速底盘也有优势图像处理出来的偏差可以直接映射到左右轮速差容错率更高。两种底盘我都在不同项目上试过这篇博文以舵机转向为主来讲因为转向模型的偏差与舵机角度的映射更直观。注意树莓派和电机驱动必须分开供电共地即可。如果电池直接同时给树莓派和电机供电电机启动瞬间的电压跌落极容易导致树莓派重启这块我踩过坑后面详细说。2. 机器视觉核心图像处理流水线的设计与实现2.1 为什么要用HSV而不是RGB来识别赛道视觉循迹的第一件事是让小车知道“路在哪里”。最直接的思路是识别赛道颜色。很多比赛赛道是白底黑线或者深色地毯配浅色路肩我的场景用的是蓝色胶带贴出赛道边界地面是浅灰色瓷砖。所以赛道识别的核心就是“把蓝色边界从背景里抠出来”。这里我选择HSV颜色空间而不是RGB这是整个视觉方案的基础。RGB三个通道都包含了亮度信息光照一变RGB值波动很大。而HSV把色相Hue、饱和度Saturation、亮度Value拆开了其中H对光照变化相对不敏感。因为我们要识别的是“蓝色胶带”这种颜色特征不是亮的程度所以只用H和S两个通道就能稳定地把蓝色区域分开V基本不用管。用OpenCV做颜色分割就三行代码的事import cv2 import numpy as np img cv2.imread(track.jpg) hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) mask cv2.inRange(hsv, (100, 80, 60), (130, 255, 255))这段代码把色相在100到130之间、饱和度在80以上的像素标为白色其余标为黑色。中间那个(100, 80, 60)和(130, 255, 255)就是颜色阈值不同光线环境下需要微调。蓝色在HSV中大致落在100到130这个区间饱和度低可能出现在浅蓝或灰色区域所以饱和度下限设80能有效过滤掉地面泛白反光的部分。2.2 开闭运算参数原理与实操给二值图“美容”HSV分割得到的是包含大量噪点的二值图。车跑起来之后地面的纹理、光影变化、胶带边缘的反光都会在二值图里生成一堆零散的小白点和小黑点。直接拿去算赛道中心线结果会抖动得非常厉害。这时候就要靠形态学操作开运算、闭运算来清理。很多人对开闭运算的理解停留在“开运算消除小白点、闭运算填补小黑洞”这个口诀上但实际调参时为什么用3×3的核而不是5×5为什么先开再闭而不是先闭再开这些细节才真正影响最终的识别质量。开运算 先腐蚀再膨胀。腐蚀把白色区域的边缘吃掉一圈小块的白色噪点会被直接消掉剩下的白色区域变小膨胀再把剩下的白色区域恢复回来面积接近原状。所以开运算的效果是“去掉小白点大块区域形状基本保留”类比一下就是给图像表面做了一次去毛刺的打磨。闭运算 先膨胀再腐蚀。膨胀把白色区域往外扩一圈黑色小洞会被填上腐蚀再把它缩回来。所以闭运算的效果是“填补小白洞、连接断裂的轮廓”相当于给图像做了一次修复和融合。参数上的核心就是核的大小。核越大运算效果越强但副作用是赛道边缘会被磨圆细节失真。蓝色胶带的宽度大约只有5厘米在640×480分辨率的图像里可能占十几个像素如果核用15×15赛道边缘会被严重侵蚀计算出的中心线就会往里偏。我的经验是去噪点阶段核设3×3做一次开运算就够了不要贪多。修补断裂阶段核设5×5做一次闭运算把因为反光断掉的蓝色边界重新连起来。代码里就是两个cv2.morphologyEx调用kernel_open np.ones((3, 3), np.uint8) kernel_close np.ones((5, 5), np.uint8) mask_open cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel_open) mask_close cv2.morphologyEx(mask_open, cv2.MORPH_CLOSE, kernel_close)注意开闭运算的顺序一定不要反。如果先闭运算会先把小白点和白色赛道“焊”在一起变成一个更大的白色区域开运算就分不开了。正确流程永远是大噪点先开运算消掉再闭运算修补。2.3 透视变换把倾斜的地面图像转成正俯视图摄像头是斜着朝前下方装在小车上的拍到的赛道图像是透视效果——远处的赛道窄、近处的赛道宽。如果直接在这个视角下计算赛道中心线远处的偏差会被压缩近处的偏差会被放大控制效果就不够线性。解决方法是做透视变换也就是鸟瞰图变换把图像从斜视视角投影到一个假设为正俯视视角的平面上。OpenCV提供了cv2.getPerspectiveTransform和cv2.warpPerspective两个函数src_points np.float32([[120, 240], [520, 240], [60, 420], [580, 420]]) dst_points np.float32([[0, 0], [640, 0], [0, 480], [640, 480]]) M cv2.getPerspectiveTransform(src_points, dst_points) bird_view cv2.warpPerspective(mask_close, M, (640, 480))这里最关键的是src_points的选取。我需要先拍一张小车在赛道中的照片然后在原图上手动标出赛道矩形区域的四个顶点。怎么标呢找一段直道让赛道在画面中占满整个梯形区域取赛道左侧边界上下两个点、右侧边界上下两个点。梯形区域选得越大鸟瞰图的范围越大但远处的赛道因为像素太少拉伸后会非常模糊反而干扰判断。实际调试时我建议先把src_points和dst_points打印出来在图像上用cv2.circle把四个源点画出来确认它们确实落在赛道边界上再去做变换。不然每次改参数都要重新烧录跑一遍效率太低。2.4 提取赛道中心线滑窗扫描代替复杂的边缘检测很多教程喜欢讲用Canny边缘检测加Hough变换来找赛道线那套算法本身没问题但在这个场景下有点杀鸡用牛刀——我们面对的赛道是规则、连续的色块而且经过透视变换后赛道边界非常规整用滑窗扫描法反而更稳定、更省算力。滑窗扫描思路很简单把鸟瞰图分成若干行从下往上扫每一行统计这一行里白色像素的横坐标均值这个均值就是该行的赛道中心点。把所有行的中心点连起来就是赛道中心线。def find_centerline(bird_view): height, width bird_view.shape center_points [] step 10 # 每10行采样一次 for row in range(height - 10, 0, -step): row_data bird_view[row, :] indices np.where(row_data 0)[0] if len(indices) 0: center int(np.mean(indices)) center_points.append((row, center)) return center_points这个方法的优势在于它天然抗干扰。假如某一行因反光导致蓝色胶带断了一小段只要该行白色像素总数足够均值依然落在赛道宽度中部不会出现大的跳动。如果某些行因为遮挡完全没有白色像素就跳过只统计有值的行最后用多项式拟合补全。拟合中心线用np.polyfit就能做rows [p[0] for p in center_points] cols [p[1] for p in center_points] coeffs np.polyfit(rows, cols, 2) # 二次拟合二次拟合的好处是能表示直道和弯道两类情况。直道是一次曲线弯道可以用二次曲线的曲率表示。拟合出的coeffs后续会用到一次项系数代表斜率二次项系数代表曲率对预测弯道很有帮助。2.5 从中心线到转向偏差看似简单实则有细节有了中心线的多项式方程后下一步就是计算横向偏差。我采用的公式是deviation (cx_far - cx_near) * k其中cx_near是图像底部离车最近那一行的赛道中心x坐标cx_far是图像中上部预瞄点的赛道中心x坐标k是比例系数用于把像素偏差转换为舵机角度范围。预瞄点的选取是个值得琢磨的地方。如果只看车头正前方的赛道位置转向会非常敏感路径上一丁点波动都会导致车左右扭跑起来就像喝了酒。我在实践中的做法是取两个点的加权偏差——近处点权重0.4远处点权重0.6。近处点决定稳远处点决定准。把远处的趋势也算进来之后车在弯道前就能提前打方向盘过弯非常顺。这个偏差值我通过串口发给STM32import serial ser serial.Serial(/dev/ttyUSB0, 115200) data fD{int(deviation)}\n ser.write(data.encode())STM32那边只需要做协议解析拿到偏差值后跑PID控制舵机。协议格式尽量简短减少解析出错的可能性纯数字加换行符就够了。3. 下位机控制核心从偏差到转向的完整链路3.1 舵机与电机控制的基本原理下位机用的是STM32F103它的任务就两个接收偏差输出PWM。舵机控制信号是50Hz的PWM波高电平脉宽在0.5ms到2.5ms之间对应舵机从0度转到180度。常用的SG90舵机中位是1.5ms脉宽正好对应90度也就是车轮回正的位置。电机驱动用TB6612它接收PWM信号控制转速接收两个GPIO电平控制方向。STM32的定时器输出两路PWM一路给舵机一路给电机频率分别设在50Hz和10kHz。电机PWM频率要足够高否则能听到明显的电流噪声而且低速时扭矩不稳。3.2 PID控制器的实现与参数整定这里的PID控制器是位置式PD因为循迹小车的转向控制对微分项特别依赖。P项负责把偏差纠正回来D项负责抑制振荡。如果只用P控制车会在赛道中心线左右来回振荡而且速度越快振荡越剧烈。加上D项之后偏差变化率大的时候会输出一个反向作用力让车提前减速转向振荡大幅缓解。我在STM32上实现了一个简化版的PD控制器float kp 0.35f; float kd 0.12f; float last_error 0.0f; float pd_control(float error) { float derivative error - last_error; last_error error; return kp * error kd * derivative; }调参的顺序有讲究我从一开始就盯着一个直道和一个90度弯道测。先把kd置0只调kp从小往大加。加到车在直道上开始轻微振荡时记下这个临界值然后乘以0.6作为最终kp。接着才加kd同样从0开始加加到车在过弯时不再有明显超调为止。这样一轮下来基本能落在比较舒服的参数区间。关于积分项我故意没有用。原因很简单循迹小车在正常行驶时偏差始终在0附近波动积分项的累积效应反而会导致转向响应迟钝。如果车因为外力被推离赛道积分项可能会让转向打死半天回不来这在比赛中是致命的。3.3 串口通信协议的设计与抗丢包处理树莓派和STM32之间的串口通信是一对一、距离短、数据量小但依然要考虑异常情况。我用的是最简单可靠的文本协议一帧数据以字母D开头跟着正负号和数字以换行符结尾例如D023\n。STM32端每收到一个换行符就认为一帧结束解析出偏差值。抗丢包的核心思路是校验与超时兜底。校验方面文本格式天然容易判断帧必须以D开头长度固定6字节数字范围限制在-127到127之间任何不匹配的帧直接丢弃。超时兜底更重要如果树莓派卡顿或者程序崩溃串口超过200ms没有新数据STM32就认定视觉端异常自动把舵机回正、电机减速停车。这个小逻辑救了我好几次不然车冲出赛道撞墙是早晚的事。4. 软硬件集成与调试从能跑通到跑得稳4.1 供电系统设计与干扰排查供电是整个小车最容易出问题的地方没有之一。我最开始图省事用一块7.4V电池给树莓派和电机驱动板并联供电结果车一启动树莓派就重启。原因是TT马达启动瞬间电流可以达到2A以上电池电压被瞬间拉到6V以下树莓派接近欠压阈值。后来我把供电改成两路电池正极先接一个DC-DC降压模块稳定输出5V/3A给树莓派再从电池正极单独走线给TB6612供电两组电源只在电池端共地。这样电机怎么折腾都不会影响树莓派供电。舵机的供电也需要注意SG90堵转时电流可能到几百毫安给树莓派供电的5V那路带不动最好从电池端再拉一路5V给舵机。电磁干扰是另一个坑。电机转动时碳刷会产生火花在电源线上感应出尖峰脉冲可能导致串口数据错乱。解决方法是给电机并联0.1μF的陶瓷电容给电源端并联一个大容量的电解电容并且把所有信号线用双绞线形式走线减少环路面积。4.2 摄像头安装角度、高度与曝光策略摄像头安装的高度和角度直接决定了能看多远、能看到什么。我试过装得太高、角度太平画面里大部分是远处的地平线近处赛道反而看不清也试过装得太低、角度太斜视野太浅车速稍微快一点就来不及转弯。最终我选择的安装参数是摄像头离地高度12厘米俯仰角度向下倾斜约15度让画面中赛道区域占图像下三分之二上三分之一留点空间观察远处弯道。这样在640×480分辨率下近处能看到车前20厘米的赛道远处能看到大约1.2米以外的赛道走向20帧的处理速度下1.5m/s的车速也来得及反应。曝光策略上树莓派相机的自动曝光在光照突变时会突然过曝或欠曝直接导致颜色识别失败。我的做法是关闭自动曝光手动固定曝光时间通过试拍确定一个在室内灯光下表现最好的曝光值。如果赛道环境的亮度波动不大手动曝光是最稳的选择。如果确实要在室外跑就调整HSV阈值把V通道的范围放宽一点。4.3 远程调试环境搭建身不离席看图像调试视觉循迹小车最痛苦的事是每改一次参数都要把小车从赛道上拿回来接上显示器键盘鼠标跑一遍脚本然后再放回赛道测试。来来回回一百次也不夸张。我调试到第三天时实在受不了了搭了一套远程调试环境从此效率翻倍。树莓派上跑一个VNC服务端笔记本上用VNC Viewer连过去直接看到树莓派桌面。调试脚本时我开一个OpenCV窗口实时显示处理后的图像同时把树莓派的摄像头画面通过VNC传输到笔记本上。改参数也不需要停脚本我用Python写了一个阈值调节滑条窗口把H、S、V的上下限做成了六个滑条拖动滑条马上能在另一窗口看到二值图的变化找到合适阈值后记下来写进代码。这个调试手段非常推荐省下的时间非常可观。5. 常见问题与排查技巧实录5.1 光照变化导致识别失效这是视觉循迹第一头疼的问题。室内灯光从头顶打下来赛道表面有反光蓝色胶带在某个角度会变成灰白色。处理的办法有几种我按有效程度排序把摄像头曝光调低减少过曝区域。用HSV而不是RGB做颜色分割重点看H通道不看V。在HSV阈值中加入饱和度下限去掉低饱和度的反光点。如果以上还不够可以考虑给胶带换成哑光材质减少镜面反射。我之前一直用亮面蓝色胶带反光问题折腾了两天后来换成哑光蓝色电工胶带困扰一下就没了。5.2 直道上左右扭摆车在直道上左右扭摆是典型的D项参数过小。D项起的是阻尼作用参数小了P项控制下系统就是一个欠阻尼振荡器。增大kd能让车“稳重”起来。但这里有个误区kd不是越大越好。我试过把kd加到0.3结果转弯时车头会出现高频抖动因为微分项放大了偏差的噪声。要在直道平稳和弯道跟手之间找一个平衡点我最终定在0.12左右。另一个导致扭摆的原因是中心线噪声过大。如果形态学处理没做好二值图里的赛道边缘毛毛躁躁每一帧计算出的中心线都在抖控制器再稳也架不住输入信号跳变。这种情况先回图像端调参数别急着调PID。5.3 高速过弯时冲出赛道高速过弯冲出去原因往往是预瞄点不够远。弯道越急、车速越快留给控制系统的反应时间越短。解决办法是把预瞄点往上移让它提前看到弯道趋势。如果预瞄点已经移到图像顶部还不够那就是车速太高了得通过PID限速减小过弯速度。我还试过一个办法根据中心线拟合多项式的二次项系数判断曲率曲率大的时候自动降低目标车速。这个方案在弯道表现很好直道全速、弯道减速每一步都是可预判的。5.4 树莓派处理延迟导致控制卡顿如果树莓派跑到10帧以下整车控制就会明显卡顿。要提高帧率优先考虑降低分辨率而不是降低算法复杂度。因为图像处理的耗时和像素数成正比从640×480降到320×240耗时直接降到四分之一而赛道特征在320分辨率下依然清晰。我最终用320×240跑25帧完全够用。另外一些容易忽略的性能杀手在每帧都调用cv2.waitKey(1)是必须的否则窗口会卡死缓冲imshow窗口如果不需要实时看效果可以不开省下的CPU非常可观。5.5 常见问题速查表现象可能原因解决措施二值图里蓝色区域断断续续光照反光、HSV阈值过窄适当放宽H和S范围加闭运算连接断点白色噪点很多地面纹理干扰加大开运算核大小或提高S阈值下限直道扭摆D项不足、中心线噪声大先检查二值图干净度再增大kd弯道冲线预瞄点太近、车速过快上移预瞄点或根据曲率限速树莓派重启供电不足电机和主控分开供电共地串口数据乱码干扰或波特率不匹配电机加去耦电容信号线双绞确认两端波特率一致车一上电就疯转PWM初始化前GPIO电平不确定初始化时先设置GPIO低电平再使能PWM输出6. 一条延伸路径从循迹到避障再到更复杂的视觉任务做完这个项目后最大的收获不是把车跑起来了而是形成了一整套“从图像到决策”的思维框架。这套框架可以平移到其他视觉任务上比如热词里提到的玻璃划痕检测、平衡车视觉等。玻璃划痕检测的核心也是图像分割加形态学处理——把划痕这种线状缺陷从复杂的背景纹理中分离出来用到的开闭运算、阈值分割方法和循迹小车的处理原理完全一致。区别只是缺陷检测需要更精细的特征提取比如用拉普拉斯算子强化边缘用连通域分析过滤噪点用面积和长宽比来判断是否是真正的缺陷。这套流程对做过视觉循迹的人来说很容易迁移。后续如果想继续深入我建议从两个方向选一个。一个是往控制方向走把现在的PD升级为LQR或MPC让车跑得更快更稳另一个是往视觉方向走比如用YOLO识别不同的赛道元素锥桶、障碍物、终点线把单纯的循迹升级成带感知的自动驾驶小车。无论哪个方向这个项目打下的基础都会非常扎实。我个人在实际操作中最深的体会是图像处理没有银弹没有一套参数能适配所有环境。每次到新场地都要重新标定HSV阈值、重新调节形态学核大小这非常正常。与其追求一次调好不如把调试工具做顺手——把阈值滑条、图像显示窗口、远程修改参数的流程都准备到位到了现场花五分钟就能完成环境适配这才是整个项目里最值得花时间的地方。