news 2026/9/9 10:57:16

用树莓派+OpenCV打造机器视觉循迹小车:从图像处理到PID控制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用树莓派+OpenCV打造机器视觉循迹小车:从图像处理到PID控制

1. 项目背景与整体设计思路

1.1 为什么做一台“看得见”的循迹小车

先聊个挺现实的问题:市面上几百块一套的循迹小车,大多用的还是红外对管方案,靠几组传感器检测黑线反射光的强弱来判断路径。这种方案不能说不能用,但调试的痛点谁试谁知道——对环境光极其敏感,LED灯的频闪会让传感器数值飘忽不定;对赛道颜色也挑,深色背景配浅色线就很难调阈值;更麻烦的是,前瞻距离只有两三厘米,车速稍微拉起来就冲出赛道,根本来不及转向。

我在做这个项目之前,已经用51单片机玩过好几版红外循迹小车,每次调完阈值都能用,但总感觉天花板太低。真正让我决定转向机器视觉方案,是有一次在学校体育馆地板上做测试——场地用的是浅黄色木地板,黑线是普通电工胶带,下午四点钟太阳从西侧窗户照进来,红外传感器的ADC读数在200到600之间狂跳,完全没有稳定区间。那一刻我意识到,探测距离太近、信息量太小,是红外循迹的天生缺陷。

机器视觉循迹小车解决的正是这个问题。用摄像头代替红外对管,等于给小车装了一双“眼睛”,能看到前方几十厘米、甚至一两米的赛道信息,提前预判弯道和岔路。摄像头的分辨率再低,输出的也是几百乘几百的像素矩阵,信息量是开关量传感器完全没法比的。整个项目做下来,核心架构是:树莓派(或Jetson Nano)负责图像采集和路径识别,输出转向和速度指令;下位机单片机负责执行底层运动控制。

这个项目适合谁来做?简单分类一下:如果你已经玩过51或STM32小车,想进阶到视觉层面,这是个很自然的跳板;如果你对机器视觉感兴趣但不想一上来就做目标检测、人脸识别那种大而全的任务,循迹是一个边界清晰、反馈即时、出效果快的入门级视觉应用;另外,这套系统里涉及的图像处理、串口通信、PID控制算法,基本都是工业机器人、AGV小车岗位面试的标配考点,做完整个项目的收获远不止“小车会跑”这么简单。

1.2 技术选型背后的取舍逻辑

先说摄像头。这个项目的图像处理任务相对单一,核心是从场景里提取出跑道线,对分辨率的要求并不高。市面上常见的USB摄像头,640x480分辨率30帧就够用,没必要上高分辨率——分辨率越高,单帧图像处理时间越长,控制频率就越低,小车动态响应越差。这是一对儿矛盾:看得清和看得快,在这个场景里应该优先保证后者。

然后是主控。很多教程喜欢用Jetson Nano跑视觉循迹,性能确实过剩,但价格和功耗也高。我的选择是树莓派4B,4GB版本,理由有三:第一,树莓派生态成熟,OpenCV、Python的库支持几乎零障碍;第二,性能对640x480图像的实时处理完全够用,实测单帧灰度图二值化加透视变换大约20到30毫秒;第三,GPIO、串口这些外设接口齐备,和STM32下位机通信非常方便。如果你手头是树莓派3B,性能稍弱但也能跑,建议把图像分辨率降到320x240。

下位机我用的是STM32F103C8T6,也就是传说中的“蓝丸”最小系统板。这块板子在两轮平衡小车、四轴无人机里被用烂了,资料满天飞。主控负责的运动解算、PWM输出、编码器读取这些实时性要求高的任务,放到底层单片机做是合理分工,树莓派只管“看和想”,不用操心“怎么走”,这样即使图像处理偶尔卡顿几十毫秒,小车的电机控制也不会出现明显抖动。

舵机转向方案还是差速转向方案?大多数教程会选两轮差速——两个后轮独立驱动,靠两轮转速差实现转向,结构简单,但转向半径不够灵活。我这次选了四轮小车底盘配舵机前轮转向,动力后置,也就是常见的“RC遥控车”布局。转向舵机响应快,转向半径可以做到很小,视觉循迹时过弯会更流畅。代价是底盘贵一点,控制模型多一个舵机角度输出。两者没有绝对优劣,看你想练哪块——差速方案练的是双轮PID同步,舵机方案练的是转向角度的连续控制。

1.3 整体系统架构:从像素到车轮

整台小车的信号链路,一句话概括就是:摄像头采集图像 → 树莓派识别赛道中线和转向角 → 通过串口发指令给STM32 → STM32输出舵机角度和电机速度。

树莓派和STM32之间的通信协议,我定义得尽量简单:一帧数据三个字节,帧头0xAA,第二个字节是舵机角度,范围0到180,映射到前轮左右极限;第三个字节是速度档位,0到100。不用校验位,因为串口波特率115200丢包率极低,而且即使偶尔丢一帧,控制周期只有50毫秒,下一帧马上就能纠正过来。

这里要特别强调一个设计原则:视觉处理是慢任务,运动控制是快任务。树莓派处理图像一帧20多毫秒,STM32的PWM控制周期只有1毫秒,两者绝对不能直接同步。下位机必须有一个独立的控制循环,持续接收串口指令、更新目标值,同时保持自身的速度闭环。即使树莓派因为CPU调度偶发卡顿,小车也不会因此突然失控。

2. 硬件搭建与关键部件选型

2.1 摄像头安装:高度和角度决定识别效果

摄像头安装是整个项目中第一个“魔鬼细节”。我最初把摄像头直接固定在车头,平视前方,结果画面里大量背景信息——墙根、桌椅腿、行人——全都拍进来了,赛道线只占画面底部窄窄一条,二值化之后噪点爆炸,处理难度大增。

后来参考了AGV自动导引车的摄像头安装方式,改成前悬支架结构:摄像头装在车头前方伸出约10厘米位置,高度离地约20厘米,俯角大约30度。这样画面里绝大部分区域都是正下方的地面,赛道线占比大幅提升,目标检测极度简化。不要小看这个角度,它决定了后续图像处理的所有难度——装得好,识别就是找个亮线;装得差,识别就是在噪点里找规律。

固定方式也有讲究,别用什么热熔胶一圈糊死。我用的是一个摄像头支架加两颗M3螺丝锁在3D打印的底座上,底座槽位做成可调的,能小范围调整俯仰角。调焦方面,普通免驱USB摄像头大多是定焦镜头,出厂对焦在1米左右,近距清晰度反而一般。实测下来,把摄像头镜头拧松半圈,让焦点落在大约20到30厘米这个工作距离上,图像锐度明显改善。如果你的摄像头不能调焦,也可以把安装高度适当增高,拉大工作距离。

2.2 主控板、电机驱动与电源分配

树莓派的供电是最容易翻车的地方,没有之一。树莓派4B满负载运行时的电流可以到1.2A以上,再加上舵机在转向瞬间的电流冲击,如果用同一个5V电源又带驱动板又带树莓派,电压跌落会导致树莓派降频甚至重启。我的方案是双电源隔离:一块3S锂电池(11.1V)给电机驱动板和舵机供电,一块2000mAh的充电宝专供树莓派,两者只共地不共电。

运动控制部分的选型比较常规:STM32F103C8T6最小系统板,L298N电机驱动板,MG996R舵机(金属齿轮,扭矩够大),两个带霍尔编码器的N20减速电机(1:30减速比),配合TB6612FNG驱动芯片效果更佳,但L298N胜在皮实耐操、便宜大碗,新手先用它跑通逻辑问题不大。编码器是必须的,没有它,STM32无法实时感知车轮速度,所谓PID闭环就是空谈。

电机和编码器接线看起来繁琐,实际规律性很强。N20电机自带6根线,两根粗的接电源,四根细的是编码器输出。编码器AB相分别接STM32的PA0和PA1,开启定时器编码器模式,STM32硬件自带正交解码,一个定时器就能读出转速和方向,不需要外部中断一个个数脉冲。

一个容易忽略的问题是,STM32和树莓派通信要共地。两块板子各用各的电源,如果不把GND连在一起,串口信号的电平参考点不一致,数据会乱码。我在调试早期就吃过这个亏,串口收上来的数据全是0xFF和0x00交替,折腾了半天才发现是地没接。

2.3 底盘组装顺序:从方形铝板到完整底盘

底盘我用的是某宝买的铝合金底板,四轮布局,前轮舵机带动转向拉杆,后轮电机直驱。拧螺丝的顺序有讲究:先把电机座和电机装上底板,装编码器线,再装驱动板和主控板,最后才能装摄像头支架。如果先装支架再装电机,螺丝刀会被支架挡住,有些位置的螺丝死活够不着。

舵机的安装是所有装配里需要点耐心的部分。MG996R需要一组舵机臂和转向拉杆配合,拉杆两端是球头扣,长度可调。调整时先把舵机臂拆下来,通电让舵机回中位,再把舵机臂装上,让前轮处于正前方位置,最后接拉杆。这个顺序错了的话,转向角度会左右不对称,调试时就会发现:“哎,左转最大20度,右转怎么有35度?”

电源线有个容易被忽略的安全细节:电机驱动板上的电源线建议用14AWG以上的粗线,电流大时细线发热明显。我摸过一次,导线表面温度大概有60度,手能感觉到烫。有条件的话在电源输入端加一个470uF电解电容,吸收电机换向时的尖峰电压,能显著减少驱动板复位现象。

3. 视觉处理管线设计与核心实现

3.1 从BGR到二值化:不是所有颜色信息都需要

OpenCV读进来的图像默认是BGR三通道,但对于循迹任务,我们需要的信息只有“哪里是赛道线”,颜色本身不重要。这种情况下,直接把三通道彩色图转成灰度图,再做二值化,是性价比最高的路径——灰度图数据量是彩色图的三分之一,处理速度大幅提升,后续所有算法都建立在“亮/暗”两个状态上。

赛道线的颜色选择会直接影响二值化效果。我用的是白色电工胶带贴灰色地面,亮暗对比强烈,二值化阈值非常好找。如果你只能用深色线,就要考虑反色处理或者改用固定的浅色场地,否则算法和阈值都要重写。

灰度变换之后是二值化。OpenCV里最常用的是cv2.threshold的THRESH_BINARY,关键在于阈值。固定阈值能跑,但光线稍微变化就废,更稳妥的是OTSU自适应阈值。OTSU的原理一句话能讲清楚:遍历所有可能的阈值,找到某两个值,使得“前景类内方差加背景类内方差”最小。这个算法在光照均匀且前景背景灰度差异明显时,效果极好。我用的是cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU),一行代码搞定。

如果你用HSV色彩空间,稳定性会更好。HSV把颜色的色调H、饱和度S、明度V分开,你只需要提取H通道的范围就能锁定特定颜色,不受明暗影响。比如白色线在V通道很亮,提取V通道重建掩码,再叠加灰度二值化做“与”运算,能扛住一定程度的光照波动。这个方法留给有余力的读者尝试,篇幅有限就不展开代码了。

3.2 透视变换:把“近大远小”变成“俯视图”

摄像头是斜向下装的,拍到的赛道是梯形的——近处宽远处窄,这对后续中线拟合不友好。解决办法是透视变换,也就是把原始图像中赛道所在的梯形区域,映射成一个固定大小的矩形,相当于把相机“抬”到赛道正上方,得到俯视效果。

实现步骤分为三步:

第一步,选取四个坐标点。在原图中框出一个梯形,四个点分别对应赛道远端两个角、近端两个角。这个选取纯靠肉眼,用鼠标回调函数在窗口里点就行,保存成配置文件,不需要每次启动重新标定。

第二步,定义目标矩形的四个点。我惯用输出320x240尺寸,四个目标点分别是(0,0)、(320,0)、(0,240)、(320,240)。

第三步,调用cv2.getPerspectiveTransform(src, dst)算出变换矩阵M,再调用cv2.warpPerspective把原图投影过去。

这里有一个经验值分享给第一次做透视变换的朋友。不是梯形区域选得越大越好,实际测试发现,梯形区域近端宽度占画面总宽的80%左右,远端占30%左右时,变换后赛道线在图像中占比最均匀。选得太宽,变换后赛道容易超出视野两侧;选得太窄,会丢掉近处信息,转向反应迟钝。这个比例不是圣旨,但第一次调试不知道从哪下手的话,照着这个数来能少走弯路。

3.3 赛道中线提取:滑动窗口还是直接重心法

透视变换之后,画面里就是一张俯视的、以赛道线为主体的二值图。提取赛道中线的思路很多,按复杂程度从低到高排,有单纯的行像素平均法、滑动窗口法、以及基于边缘检测的拟合方法。

项目实战我建议你从行像素平均法起步,原理极为简单:对图像每一行分别遍历所有列,找到白色像素点的列坐标,取平均值,得到“这一行的赛道中线位置”。全部行做完,就得到了一条从画面底部延伸到顶部的曲线。这个方法在赛道干净、没有岔路和遮挡时稳定可靠,代码不到十行,运行速度极快。

如果场地复杂一些,会出现线间断、岔路等情况,单纯平均法会把中线拉到错误方向上。此时升级为滑动窗口法,也就是把图像按行分成若干层,从最下方已知赛道位置开始,每一层只在上一层面位置附近的一个窗口内寻找白色像素中心,窗口宽度我通常设为80像素。这样做的好处是即使赛道有个小断点,窗口机制也能强行续上,不会因为某一行的信息缺失导致全面崩溃。

滑动窗口的缺点是它的“记忆”特性——一旦跟错左侧的分叉线,后面所有窗口都会沿着左边一直走,无法跳回正确赛道。解决方法是增加一个置信度判据:窗口内白色像素数低于某阈值时,认为该层识别不可靠,中线位置取上一层位置加一个固定偏移,保持前进直到重新找到赛道。这个逻辑简单但有效,防丢线的效果明显。

3.4 转向角计算:前轮转角和偏差的映射关系

拿到当前行的赛道中线偏差之后,怎么把它变成舵机角度?这是整个视觉循迹链路里最“控制理论”的环节,也是不少人栽跟头的地方。

我说一下我的做法。先选一个“参考行”,一般取画面垂直方向约2/3处的那一行,这个位置的赛道信息代表车前约10到15厘米处的路径,是前瞻距离的折中选择。取得这一行的中线x坐标,和图像中心x坐标(320/2=160)做差,就得到了横向偏差。

但是这个偏差的量纲是像素,舵机的量纲是角度,需要建立映射。最简单的线性映射:

舵机输出 = 中位角度 + 横向偏差 × 比例系数K

中位角度实验中我标定在约78度,比例系数K我一开始拍脑袋设成0.2,意味着100像素偏差就是20度转向——实测结果是过弯直接冲出赛道,响应太快。后来逐步减小,最终稳定在0.08左右,即大约125像素的偏差对应10度转向。不同底盘和舵机特性不同,这个系数必须实车调试,别指望抄作业,抄不了。

线性映射够用一段时间,但在大弯道和直线衔接处有明显瑕疵——直线区偏差小,转向太灵敏,车子蛇行;大弯时偏差大,转向角度却因为比例常数不变而显得不足。改进方案是分段映射或引入非线性函数,比如偏差小时用较小的K,偏差大时用更大的K,本质上是给转向控制器加了一个“死区”和一个“饱和区”。代码实现就是一个if-else,但效果立竿见影,小车直线行驶的稳定性大幅提升。

4. 下位机控制:串口数据解析与PID调速

4.1 串口接收协议解析与状态机

树莓派通过串口发出三字节指令,STM32要把它安全地解析出来。解析最忌讳简单粗暴地一个字节一个字节往变量里赋值——没有帧头校验,一旦串口数据错位,后面所有帧都会错乱,且永远无法自行恢复。

正确姿势是状态机。定义三种状态:STATE_IDLE(空闲等帧头)、STATE_HEADER(收到帧头,等角度字节)、STATE_ANGLE(收到角度,等速度字节)。代码骨架如下:

uint8_t rx_state = STATE_IDLE; uint8_t rx_angle = 0; uint8_t rx_speed = 0; void USART1_IRQHandler(void) { uint8_t byte = USART_ReceiveData(USART1); switch(rx_state) { case STATE_IDLE: if(byte == 0xAA) rx_state = STATE_HEADER; break; case STATE_HEADER: rx_angle = byte; rx_state = STATE_ANGLE; break; case STATE_ANGLE: rx_speed = byte; rx_state = STATE_IDLE; // 到这里说明完整收到一帧指令 servo_set_angle(rx_angle); target_speed = rx_speed; break; default: rx_state = STATE_IDLE; break; } }

状态机的好处是,即使中间丢字节,也知道丢在哪一步,不会长期处于错乱状态。我实际测试中,长时间运行后偶尔会因为树莓派侧的串口缓冲被系统抢占导致一帧数据残缺,但下一帧到来时状态机自动复位,控制输出只在极短时间内保持旧值,不产生明显影响。

4.2 编码器数据读取与速度PID

速度控制我用的经典增量式PID。先解释一下编码器的使用:N20电机的霍尔编码器一般每转输出几十个脉冲,经过1:30减速箱后对应到轮子转速。STM32的定时器编码器模式可以直接对AB相脉冲进行4倍频计数,精度更高。

我设定的控制目标是——让左右轮的实际转速尽量逼近同一个目标值。这个目标值是根据树莓派传来的速度档位换算出来的,换算公式是:目标转速(RPM)= 速度档位 * 0.3,也就是说档位100对应30RPM。PID三个参数我调试了很多轮,最终的整定值跟多数常规电机接近:Kp=12,Ki=0.1,Kd=0。是的,Kd直接设成0,多数电机调速场景微分项容易放大噪声,去掉反而更稳。

整定的方法说句大实话,别去背一堆“临界比例度法”的条条框框,先用Kp从0开始慢慢加,直到电机转速出现持续、等幅的轻微振荡,记下这个Kp作为临界值,再把Kp取这个值的40%到60%,Ki从0逐步加到静态误差消除,Kd默认给0,需要抑制超调再加一点点。这个经验流程比任何书本都实用,二十分钟能完成一轮整定。

4.3 转向舵机的PWM信号与限幅保护

MG996R舵机的PWM控制频率是50Hz,也就是周期20毫秒,脉宽0.5毫秒到2.5毫秒分别对应0度和180度。STM32用定时器的PWM输出模式,比较寄存器值换算成角度即可。我为了省事,写了个通用函数:

void servo_set_angle(uint8_t angle) { // 角度范围 0~180, 对应 PWM 比较值 500~2500 uint16_t pulse = 500 + (uint16_t)angle * 2000 / 180; TIM_SetCompare3(TIM2, pulse); }

这里必须点名一个新手常踩的坑。STM32定时器的ARR值要设置成20000,预分频值要设置成72,才能得到20毫秒周期和1微秒精度的组合。如果ARR写成了200,PWM频率变成5kHz,那舵机会发出尖锐的啸叫声,舵机臂疯狂抖动,怎么调角度都没用。这个坑我当年至少卡了一个下午,最后拿示波器看波形才醒悟过来。

另一个注意事项是舵机限幅。保护机制必须在下位机做,不能在树莓派端做。万一树莓派视觉程序因为某种bug算出一个极端角度,比如18度,如果不做限幅,舵机就会硬顶到机械极限位置,轻则舵机齿轮崩裂,重则拉杆弯掉。我的做法是:

if(angle < 40) angle = 40; if(angle > 140) angle = 140;

40到140这100度的范围对应安全机械行程,留出至少20度的余量,这样舵机即使收到极端指令也不会撞墙。

5. 联调过程、常见问题与避坑技巧

5.1 巡检调试的顺序和节奏:先静态后动态

联调阶段最忌讳的是“一步到位”——树莓派、STM32、电机、摄像头四样东西同时上电跑起来,出问题了根本没法定位是哪个环节的锅。我的调试顺序固定为五步:

第一步,单独调摄像头。装好OpenCV,打开窗口看实时画面,确认图像分辨率、帧率正常,对焦清晰,没有花屏和延迟。这一步不写任何算法,就是看画面干不干净。

第二步,单独调透视变换。用鼠标标定梯形区域,确认变换后的俯视图赛道线笔直,没有畸变和变形。

第三步,单独调二值化和中线提取。在画面中把识别出的中线用红线画出来,确认在没有动力的情况下,赛道中线提取效果稳定。

第四步,把树莓派和STM32通过串口连起来,树莓派定时发送固定的角度、速度数据,看STM32是否收到、舵机是否转到指定角度、轮子转速是否稳定。这一步完全不涉及视觉,纯粹验证通信链路。

第五步,才是视觉与控制合流。把小车架起来,四个轮子悬空,观察它“看着”赛道时舵机和轮子的响应方向是否正确——左偏时前轮是否左转?然后放到地面,先用低速(速度档位30)跑直线,慢慢加速,再试弯道,逐步调整PID和转向比例系数。

很多初次做这个项目的朋友上来就把小车放到地上全速跑,结果撞墙、翻车、冲出赛道,然后陷入“改参数-再跑-再撞”的无限循环。整个下午下来毫无进展。老老实实按这个顺序来,大部分问题能在低速度、低风险环境下暴露并解决。

5.2 常见问题速查表:3类典型故障与处理办法

Q1:二值化后的图像全是噪点,或者赛道线断成一截一截的。

检查优先级:先看光源环境,是不是有窗外直射光在赛道表面形成反光?反光区域会被误判为白色。对策是调整摄像头位置,让它避免直接面对光源方向;或者把白色电工胶带换成哑光材质,减少镜面反射。如果光源没问题,再考虑阈值问题,OTSU也不是万能的,你把OTSU多跑几帧看直方图,如果前景背景峰不够分离,说明对比度本身就不够,得换赛道贴纸颜色。在较暗走廊做测试时,我的经验是多加一盏匀光LED灯板作为辅助照明,效果直接提升一个档次——机器视觉这行,灯光比算法还重要,这句话是真金白银换来的。

Q2:舵机转向时好时坏,有时根本不转。

用手去摸舵机外壳,如果发烫到烫手,大概率是舵机堵转了——转向拉杆卡在极限位置,舵机一直在发力但转不动。解决办法是先断电,手动把前轮掰到居中位置,重新上电,看舵机是否正常接受指令转动,如果还是堵转,检查舵机臂是否卡到底盘边缘。另外还有一个电气层面的原因:MG996R舵机在堵转时电流可到2A,如果电源线太细或者电源容量不够,电压跌落会导致舵机复位,表现就是“突然死了又突然活了”。量一下舵机电源端电压是否稳定在5V以上。

Q3:小车走直线时像喝醉了一样画龙。

这是典型的转向过度。原因一般是转向比例系数K太大,加上速度PID存在滞后。处理办法:先把K降下来四分之一,看蛇行幅度是否有明显减小;如果减小了但还不够,继续降。如果降K后直线稳了但弯道转不过来,说明不是K的问题,而是前瞻距离太远,转弯响应太慢。此时缩短参考行的位置,或者减小透视变换中的远端距离。我发现多数蛇行问题的根源是期望用比例转向解决所有问题,其实低速起步时转向更从容,写代码时加一个“起步限速”逻辑会舒服很多。

5.3 提高稳定性的几条“野路子”经验

下面几条内容,很多正规教程不会写,但都是实测有效、成本极低的做法。

摄像头镜头用UV滤镜或者普通透明片挡住灰尘。地面环境跑久了,镜片上全是灰,图像清晰度下降30%以上不说,关键是二值化的阈值会被灰尘投影干扰。清理镜片是日常维护里最容易被忽略但效果最明显的动作。

赛道两侧贴上约2厘米宽的低反光黑色电工胶带,相当于给赛道加了“路肩”。视觉算法的鲁棒性立刻提升,因为二值化后黑色路肩和白色赛道线之间多了一层高对比缓冲区,中线的提取更稳定。这个方法是我在一次比赛中偷师学来的,效果立竿见影。

运行环境的光照如果没法保证恒定,就写一个跟踪阈值的自适应逻辑。每隔一段时间(比如20帧),在画面角落里取一块认为不会出现赛道线的区域,计算该区域的平均灰度作为基线,再在这个基线上做偏移得到二值化阈值。这个方案能扛住一部分缓慢的光照变化,比如太阳在傍晚逐渐变暗的情况。

最后,关于调试时的数据可视化,不要小看“把程序跑的中间过程用窗口显示出来”这件事。我在树莓派上开了一个名为debug的窗口,左上角小图显示原始灰度图,右上角显示二值化结果图,左下角显示透视变换后的俯视图,右下角显示画了赛道中线和参考行的最终结果图。四个小窗口同时展示,什么环节出了问题,一目了然。树莓派的HDMI接一个小显示器或者用VNC远程看,都非常方便。

6. 一步到位的完整代码:树莓派视觉主程序

树莓派端的代码是整个项目的大脑,放在这里的是我实测可运行的核心版本。依赖库只有OpenCV、NumPy和serial,安装命令:

pip install opencv-python numpy pyserial

坐标标定部分有些机器相关的常数,会以变量形式给出标注,请根据自己的场地重新标定。

import cv2 import numpy as np import serial import time # 串口初始化,根据实际设备号修改 ser = serial.Serial('/dev/ttyUSB0', 115200, timeout=0.01) # 透视变换的4个源点,需要根据实际标定修改 SRC_POINTS = np.float32([[130, 40], [190, 40], [320, 240], [0, 240]]) DST_POINTS = np.float32([[0, 0], [320, 0], [320, 240], [0, 240]]) M = cv2.getPerspectiveTransform(SRC_POINTS, DST_POINTS) # 前视参考行:取高度240图像的160行附近 REF_ROW = 160 # 转向比例系数 STEER_K = 0.08 # 舵机中位角度(需实测标定) SERVO_MID = 78 cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) servo_angle = SERVO_MID speed_level = 30 while True: ret, frame = cap.read() if not ret: continue # 1. 裁剪感兴趣区域,减少计算量 roi = frame[240:480, 0:640] # 2. 灰度化 + 高斯模糊去噪 gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) gray = cv2.GaussianBlur(gray, (5, 5), 0) # 3. OTSU自适应二值化 _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU) # 4. 透视变换 warped = cv2.warpPerspective(binary, M, (320, 240)) # 5. 提取赛道中线(行平均法) center_line = [] for row in range(0, 240, 10): row_data = warped[row, :] white_indices = np.where(row_data > 0)[0] if len(white_indices) > 5: center = int(np.mean(white_indices)) center_line.append((row, center)) # 6. 找到参考行的赛道中心坐标 error = 0 found = False for row, center in center_line: if row >= REF_ROW: error = center - 160 found = True break # 7. 未找到赛道时保持上次角度 if found: delta_angle = error * STEER_K servo_angle = int(SERVO_MID + delta_angle) # 角度限幅 servo_angle = max(40, min(140, servo_angle)) # 8. 串口发送指令 cmd = bytes([0xAA, servo_angle, speed_level]) ser.write(cmd) # 9. 调试可视化 vis = cv2.cvtColor(warped, cv2.COLOR_GRAY2BGR) for row, center in center_line: cv2.circle(vis, (center, row), 2, (0, 0, 255), -1) cv2.line(vis, (0, REF_ROW), (320, REF_ROW), (0, 255, 0), 1) cv2.imshow('debug', vis) # q键退出 if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()

代码里值得提几个细节。一是裁剪ROI,我只取原始帧的下半部分,因为赛道基本只出现在车前方下部,上半部分全是墙壁和远景,处理了只会增加噪声和计算量。二是参考行选160,这个位置的赛道信息大约对应车前10到15厘米处,配合舵机转向的响应速度,效果比较均衡。三是未找到赛道时的处理,很多新手直接跳过发送旧角度,实际上这个“保持”逻辑必须有,否则一旦连续几帧找不到赛道,小车就会因为收不到指令而停在原地。

串口指令发送频率由主循环自然决定,大约30到50帧每秒。实测中,树莓派4B跑这段代码的CPU占用率在30%到40%之间,不会过热降频,连续运转一小时以上稳定性有保障。值得注意的是,树莓派的串口默认分配给系统控制台,需要先用raspi-config把串口功能改为可用状态,否则serial模块打不开串口设备。

7. 从“能跑”到“跑得好”:确定性的进阶路线图

7.1 第一阶段:稳定直线与缓弯

目标:小车在干净的直线赛道上保持居中行驶,不会画龙,弯道半径大于1米时能基本顺利过弯。

这个阶段要解决的核心问题是基础控制逻辑是否正常。我的经验是把速度固定在最低档(20到30),把转向比例系数调到一个较小值,追求“稳”而非“快”。此阶段用时大约半天到一天。

7.2 第二阶段:直角弯、U形弯和连续S弯

直角弯的难点在于——当参考行已经压在弯道内部时,转向已经晚了。解决方法是动态调节参考行的位置:根据赛道最近几帧的平均曲率(可以用当前帧中线各列位置的离散程度估算),如果曲率变大说明进弯了,参考行下移,看更近处的赛道信息,及早转向。U形弯则需要配合降速逻辑,检测到前方近距离没有赛道线时,速度降一半,同时舵机打死方向,否则循迹必漂。

7.3 第三阶段:岔路选择与路口识别

这属于拓展玩法了。在岔路口,行平均法会把两条分支的平均值当成中线,小车直接冲着路口中间去了,空中转向。处理思路是加一个判断:某一行的白色像素如果分成两个明显的聚类(用直方图/连通域分析判断),且两个聚类中心距离超过某阈值,说明遇到岔路。此时可以根据预设策略选择左分支或右分支——选择方式是设置一个“偏好标记”,在下一帧强制只看偏好侧的窗口区域。这个实现有意思但工作量不小,适合在基础循迹跑顺后当进阶课题去练。

7.4 第四阶段:极端环境适应性

到了这个阶段,可以挑战晚上灯光条件差的环境、反光严重的瓷砖地面、有落叶和纸屑杂物的赛道。每一步都是对二值化阈值自适应、连通域去噪和目标跟踪算法的强化训练。这一阶段做完一遍,你对“为什么会误检”“为什么会有噪点”的理解,会超过很多只调过参数的人。

8. PID参数整定全记录:从震荡到稳定的详细过程

既然文章里反复提到PID参数,这片从现场记录整理出来的整定过程,直接看会更直观。

第一次上电,我用的是Kp=5、Ki=0、Kd=0的初始猜测。电机转速在空载时听起来就像呼吸一样忽快忽慢,声音规律性波动,轮子转速在大部分时间围绕目标值上下摆动。这个现象说明比例项太小,系统处于欠阻尼状态。

把Kp从5提到12后,声音的波动频率变快,振幅减小,但仍然可见。这已经接近临界状态,我继续每次加2,Kp到18时,出现了一个新现象:电机轻微的高频“嗡嗡”声,转速开始以极小幅度持续振荡。根据齐格勒-尼科尔斯经验法,这就是临界振荡,Kp临界值记作18。

然后我把Kp减到临界值的一半,Kp=9,再加Ki。Ki从小到大,从0.01起步,逐步到0.1,发现转速的静态误差在一两秒内能收敛到设定值附近,响应足够快。继续加大Ki到0.3,电机出现了低频“喘振”——速度周期性大幅波动,这是积分项过大的典型症状,果断退回0.1。

最终参数定在Kp=9、Ki=0.1、Kd=0。小车上实际跑出来的效果是,直线段轮速平稳,声音均匀,转弯时因为负载变化引起的短暂转速跌落能在半秒内恢复。这组参数模板可以直接套用,但不同底盘、电池电压、轮胎磨损状态下会有差异,最终还是要实车微调。

9. 写在最后:项目做完之后的一点体会

这个项目做完前后改了七个版本,从最初摄像头平视、透视变换标不准、串口乱码,到最后稳定跑完S形赛道的全过程,收获最大的是对“系统思维”有了明确概念。循迹小车看着简单,但由于涉及的环节多——任何一环出问题都会导致最终表现大打折扣。树莓派系统供电不稳定会导致画面卡顿,排查半天发现问题是充电宝功率不够;摄像头俯仰角拧歪了1度,结果二值化噪声大了几倍;串口地线没接好,视觉再准控制信号也传不下去。每修一个问题,对整套系统的理解就深一层。

如果这个文章给了你一些启发,想动手试试,我的建议是不要一上来就追求高配置——一台树莓派4B、一个60块的USB摄像头、一套蓝丸STM32板子加N20电机底盘,足够完成全部环节的开发和调试。控制在四五百块钱以内的预算,就能把整个技术链路完整打通。真把这条链路里的每一个环节都弄明白,后面去做JSON数据解析也好、做图像分类也好、做更复杂的运动控制也好,你会发现自己看问题的视角都变得更全局了。

最后再分享一个小经验:调试时备一张纸和一支笔,每改一次参数、每调一次阈值,都记录当时的现象和结果。项目做到后期,你会疯狂感谢这些实验记录,因为同一个问题可能在不同阶段以不同形式重复出现,有记录才能在十分钟内定位到旧坑,而不是重新踩一遍。循迹小车的魅力就在于,它给每个人的反馈极其真实直接——调得好不好,跑一圈赛道就知道了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 10:56:24

终端AI编程助手opencode实战:安装配置、模型接入与Skills/LSP应用

最近这段时间&#xff0c;AI编程Agent是真的热闹&#xff0c;Claude Code、Codex、Gemini CLI轮番刷屏&#xff0c;而opencode这个名字在开发者社区里出现的频率越来越高&#xff0c;GitHub上的Star涨得飞快&#xff0c;VSCode和JetBrains插件商店里也到处能看到它。简单说&…

作者头像 李华
网站建设 2026/9/9 10:56:00

族谱关系建模:用图数据结构与BFS实现血缘路径查询

家谱做到第三代&#xff0c;Excel里那种层级缩进的表格基本就崩了。尤其是要把"我妻子的弟弟的儿子"这种关系也画进树里的时候&#xff0c;你会发现树形结构根本没法表达——一个人只能有一个父节点&#xff0c;挂不进去。后来我把族谱数据换成图来建模&#xff0c;核…

作者头像 李华
网站建设 2026/9/9 10:55:37

ruflo:本地AI Agent调试的Codex协议桥接范式

1. “ruflo”不是工具名&#xff0c;而是开发者社区里一个正在成型的AI Agent开发范式代号最近在几个技术社区和私有协作频道里&#xff0c;“ruflo”这个词频繁出现在讨论Claude Code、Codex、npx本地Agent调试流程的上下文中。它不是某个开源项目仓库名&#xff0c;也不是npm…

作者头像 李华
网站建设 2026/9/9 10:55:26

【单片机毕业设计】基于 STM32 的计时计费停车场模拟实验平台设计 基于 STM32 的语音提示车位引导停车管理系统设计(016507)

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于嵌入式单片机&#xff0c;Java、小程序技术领域和毕业项目实战 ✌️…

作者头像 李华
网站建设 2026/9/9 10:55:25

论文查重过了AIGC检测却标红?完整降AIGC流程与工具搭配

本科最后一次提交论文前&#xff0c;我把稿子反复改了四遍。查重率一路从22%压到了6%&#xff0c;心里想着这回总该稳了。结果学校用AIGC检测一查&#xff0c;直接标红35%。那一刻我才明白&#xff0c;知网和维普这类系统现在看的已经不只是"抄没抄"&#xff0c;还要…

作者头像 李华
网站建设 2026/9/9 10:54:42

Java并发编程核心要点解析:从JMM到线程池与锁实践

1. Java并发要解决的本质问题 1.1 为什么并发问题这么难&#xff1a;先聊聊JMM 先说一个很多新人容易踩的误区&#xff1a;并发问题不是“多线程同时跑”这么简单&#xff0c;真正的难点在于 共享内存的可见性 和 操作的有序性 。Java为了解决跨平台的内存访问差异&#x…

作者头像 李华