简介:基于OpenMV视觉的运输小车设计源码是一套完整的智能运输小车实现方案,面向嵌入式开发、机器视觉与智能物流方向的爱好者与学生。系统以C语言为主,结合Python与MATLAB,覆盖路径识别、障碍物检测、运动控制等关键环节,适合用于课程设计、毕业设计或竞赛原型开发。资源包共206个文件,包含36个头文件、34个C源文件、3个Python脚本及大量调试配置与编译中间文件,压缩包大小27.52MB,目录结构清晰,便于按模块学习。已有315人学习下载。文件涵盖stm32f10x系列驱动、OpenMV视觉处理脚本、系统接线与启动脚本等,读者可借此理解视觉模块与STM32底层控制的配合方式,快速搭建并验证运输小车功能,节省从零移植与调试的时间。
1. 当视觉小车不满足于"循迹":OpenMV给运输场景带来了什么
传统运输小车大多靠灰度传感器循迹或红外避障,一旦地面反光、货物遮挡标线,小车就会像无头苍蝇一样乱跑。而基于OpenMV视觉的运输小车,把摄像头当成"眼睛",先看到目标物是什么颜色、什么形状、在什么位置,再决定抓取和运送的路线。这套方案的工程意义在于:识别逻辑从"有没有线"升级为"是什么物体",这让小车能完成自动分拣、定点投递、颜色分类这类真实仓储场景的任务。
现在市面上主流的做法是用MicroPython在OpenMV上跑视觉算法,再通过串口把识别结果发给STM32或Arduino控制板执行运动。最核心的问题从来不是摄像头怎么拍照,而是目标物体在不同光照下怎么稳定识别、识别结果怎么可靠地传给下位机。这篇文章会把硬件选型、图像处理代码、通信协议、参数调优这些环节全部串起来讲清楚,每一段代码都是可以抄下来直接改的。
2. 运输小车视觉系统的硬件选型与架构
2.1 为什么选OpenMV而不是树莓派或手机摄像头
做运输小车视觉系统时,首先要回答的问题是:嵌入式设备上做视觉,用哪块板子?树莓派性能确实更强,但它跑Linux系统,上电启动要几十秒,而且依赖Python生态里OpenCV等库的编译安装,供电和体积对小车底盘来说都是负担。OpenMV的定位恰好卡在中间——它是一块自带摄像头接口、LCD接口、LED补光灯的微控制器,跑Micropython固件,主打在MCU上能跑的轻量视觉算法。
对运输小车这个场景来说,OpenMV有三个无法替代的优势:一是帧率和功耗的平衡,OpenMV Cam H7 Plus在320x240分辨率下能跑到50FPS,功耗不超过200mA,用18650电池就能直接喂饱;二是固件内置了find_blobs、find_apriltags这些现成算法,省掉自己在MCU上写特征提取的功夫;三是IDE调试体验好,OpenMV IDE里能看到实时画面、直方图和阈值调整滑块,这让现场的工程师能快速看清算法到底看到了什么。如果你的项目需要识别复杂物体或者跑深度学习模型,那OpenMV确实力不从心,但运输小车场景里的颜色分拣、二维码识别、形状匹配,OpenMV足够了。
2.2 小车底盘的完整电气链路
运输小车整套系统至少包含这几部分:OpenMV摄像头模块负责"感知",主控MCU负责"决策",电机驱动板负责"执行",再加上电源管理模块给各器件供电。常见搭配是OpenMV Cam H7 Plus + STM32F103C8T6 + TB6612FNG电机驱动 + 4路红外对管做辅助避障。
红外避障模块在这里起到的是"兜底"作用。视觉识别受光线干扰时,小车至少不会直接撞墙。典型接线方式是:OpenMV的UART3(TX对应P1、RX对应P0,采用跳线帽设置)连接到STM32的USART2;STM32的PB8/PB9接TB6612的AIN1/AIN2,PB10/PB11接BIN1/BIN2控制左右电机。电源用两节18650串联得到7.4V,一端接降压模块到5V给OpenMV,另一端接到TB6612的VM引脚,逻辑电源VCC也接5V。这样整套系统的电压轨是清晰分层的,不会出现电机启停把摄像头复位的问题。
STM32F103C8T6 TB6612FNG OpenMV Cam H7 Plus ------------- --------- ---------------- PB8 -> AIN1 AO1 -> 左电机+ PB9 -> AIN2 AO2 -> 左电机- PB10 -> BIN1 BO1 -> 右电机+ PB11 -> BIN2 BO2 -> 右电机- PA2/USART2_TX <- OpenMV UART3_TX (P1) PA3/USART2_RX -> OpenMV UART3_RX (P0)提示:OpenMV的串口电平是3.3V,STM32也是3.3V逻辑,可以直连。千万别把OpenMV引脚直接接到5V单片机的TX/RX上,会让芯片永久烧毁。如果下位机是Arduino Uno这类5V设备,中间一定要加电平转换模块。
2.3 镜头、补光灯与安装位置对识别的影响
硬件里最容易被忽视的是镜头选择和安装角度。OpenMV标配的是标准2.8mm焦距镜头,视场角约70度,这个参数决定了小车能"看到"多宽的输送带区域。如果你的运输场景里目标物离车头比较近(10-30厘米),2.8mm就够用;如果目标物体积小、距离远,建议换上4.5mm以上的长焦镜头,代价是视场角变窄,识别范围缩小。总之先测距离再选镜头,不要图省事一直用默认镜头。
补光灯也不是标配就万事大吉。OpenMV板上有一颗白光LED用于照明,但它照射范围有限,而且会导致高光反光,反而让颜色阈值漂移。实际项目中建议在摄像头旁边装一盏扩散角60度左右的匀光LED灯板,用独立开关控制——白天环境光足够就关掉,傍晚或光线暗再开。至于安装位置,摄像头离地高度建议在8-15厘米之间,俯视角度30-45度。角度太平会把远处和近处的物体压缩在同一画面里,阈值就不容易稳定了。
3. OpenMV图像处理的三个核心算法与源码实现
3.1 颜色识别:从RGB阈值到LAB色彩空间
OpenMV的颜色识别之所以要用LAB色彩空间,是因为LAB把亮度和色度分开存放在不同通道上。RGB三个通道互相干扰明显——同一个红色物体,光照强一点,RGB三个值同时变大,你就很难判断它还是不是"红色"。LAB中L通道管亮度,A通道管红绿,B通道管黄蓝,我们只需要锁定A和B的取值范围,亮度变化就不会对颜色判定产生决定性影响。
在OpenMV IDE里打开工具-机器视觉-阈值编辑器,选择帧缓冲区里的图片,拖动LAB阈值滑块,直到目标物体在画面中被完整地用红色蒙版框选出来。眼睛看到的结果直接可以导出成一组阈值,粘贴到代码里。下面这段是最基础的颜色识别代码,用于检测画面中的红色物体:
import sensor, image, time, math from pyb import UART # 初始化传感器 sensor.reset() sensor.set_pixformat(sensor.RGB565) # RGB565格式,色彩信息完整 sensor.set_framesize(sensor.QVGA) # 320x240分辨率,速度和精度兼顾 sensor.skip_frames(30) # 跳过前30帧,让自动曝光收敛 sensor.set_auto_whitebal(False) # 关闭白平衡,防止颜色抖动 sensor.set_auto_exposure(False) # 关闭自动曝光,固定曝光值 sensor.set_auto_gain(False) # 关闭自动增益,稳定亮度 # 红色目标物体的LAB阈值,从阈值编辑器导入 red_threshold = (30, 100, 15, 127, 15, 127) uart = UART(3, 115200, timeout_char=1000) # 串口3,波特率115200 def find_target(): img = sensor.snapshot() blobs = img.find_blobs([red_threshold], pixels_threshold=200, area_threshold=200, merge=True) if blobs: largest_blob = max(blobs, key=lambda b: b.pixels()) return largest_blob return None while True: blob = find_target() if blob: x_center = blob.cx() # 目标中心x坐标 area = blob.pixels() # 目标面积 # 将识别结果通过串口发送,格式:X坐标,面积\n uart.write(("x:%d,a:%d\n" % (x_center, area)).encode()) time.sleep(10) # 每个循环间隔10ms,让出CPU给图像采集代码逻辑并不复杂:先固定摄像头所有自动参数,然后每一帧去寻找符合红色阈值的色块,取最大的一块作为目标。这里有几个值得注意的参数:pixels_threshold=200是过滤掉噪点的最小像素数,如果画面里出现了很多小碎块,就调大这个值,比如500;merge=True表示相邻色块会合并成一个整体,处理被遮挡的物体时很有用;area字段代表色块面积,这个值在后续判断"物体距离远近"时很有用——物体靠近摄像头,面积自然变大。
3.2 AprilTag识别:运输场景里精度最高的定位方案
颜色识别有个天然缺陷:一旦环境里有多个相同颜色的物体,你分不清哪个才是要抓的那个。更可靠的方式是给每个目标物贴一个AprilTag二维码,它携带唯一的ID编号,相当于给物体做了身份标记。AprilTag甚至可以计算出目标在三维空间中的相对位置,这正是运输小车做精准对接时需要的。
OpenMV对AprilTag的底层支持已经封得很完善了,你不需要管它内部是怎么做四边形检测和编码解码的,只要保证画面里Tag足够大、足够清晰。为了让系统识别更稳定,建议把Tag的实际边长(size字段)写进find_apriltags的参数里,这样就能直接计算出到目标的距离,用于后续的抓取时机判断。
import sensor, image, time from pyb import UART sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) # AprilTag识别用灰度图就够,速度更快 sensor.set_framesize(sensor.QQVGA) # 160x120分辨率,跑得更快 sensor.skip_frames(30) sensor.set_auto_gain(False) # 必须关闭增益,否则Tag识别率降低 uart = UART(3, 115200, timeout_char=1000) TAG_SIZE_CM = 5 # AprilTag的实际边长,单位厘米 TAG_FAMILIES = image.APRILTAG_TAG36H11 # 使用TAG36H11家族,支持587个独立ID while True: img = sensor.snapshot() tags = img.find_apriltags(families=TAG_FAMILIES, fx=945.0, fy=945.0, cx=80.0, cy=60.0) for tag in tags: # tag.rotation是旋转角度,tag.id是Tag编号 # 计算目标相对摄像头的三维坐标 x_trans = tag.x_translation() * TAG_SIZE_CM y_trans = tag.y_translation() * TAG_SIZE_CM z_trans = tag.z_translation() * TAG_SIZE_CM # 输出数据格式:id,x平移,y平移,z平移,旋转角 msg = "id:%d,x:%.1f,y:%.1f,z:%.1f,r:%.1f\n" % ( tag.id, x_trans, y_trans, z_trans, math.degrees(tag.rotation())) uart.write(msg.encode()) # 在图像中画出Tag的边框,方便现场观察 img.draw_rectangle(tag.rect(), color=(255, 0, 0)) img.draw_cross(tag.cx(), tag.cy(), color=(0, 255, 0)) time.sleep(10)fx和fy是摄像头焦距的像素表示,分别对应x和y方向。它们的作用是把图像坐标映射成空间坐标,默认值是从OpenMV官方标定数据中拿到的。如果你换了不同焦距的镜头,这两个值就不再准确,需要用棋盘格做一次相机标定,得到新的fx、fy、cx、cy。cx和cy是光心在图像中的坐标,正常情况下就是画面中心点,对应QQVGA分辨率下的(80, 60)。注意,tag.x_translation()返回的是归一化单位(对应焦距为1时的坐标),所以要乘以Tag的真实边长,才能得到物理世界中的厘米数值。
3.3 形状识别:不依赖外部标记的补充方案
有些场景不允许给货物贴AprilTag,比如原始外包装的纸箱、编织袋,这种情况下可以用形状匹配作为补充方案。OpenMV支持find_circles()找圆形和find_rects()找矩形,它们都是基于图像边缘梯度和霍夫变换原理实现的。
import sensor, image, time sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) sensor.skip_frames(30) sensor.set_auto_whitebal(False) while True: img = sensor.snapshot() # 检测圆形,threshold越大,圆越"完美"才会被接受 circles = img.find_circles(threshold=3500, x_margin=10, y_margin=10, r_margin=10, r_min=20, r_max=80) for c in circles: # c是圆的参数对象,c.x()和c.y()是圆心坐标 img.draw_circle(c.x(), c.y(), c.r(), color=(255, 0, 0)) print("circle at x=%d, y=%d, radius=%d" % (c.x(), c.y(), c.r())) # 检测矩形,不限制旋转角度 rects = img.find_rects(threshold=10000) for r in rects: img.draw_rectangle(r.rect(), color=(0, 255, 0)) # r.magnitude()返回置信度,数值越高代表越像标准矩形 print("rect corners: %s" % str(r.corners())) time.sleep(50)find_circles的threshold参数表示累加器阈值,类似霍夫变换里的投票数,数值越大,找到的圆越标准,但也越容易漏检。实际调试时,如果真实圆形的边缘因为光照产生残缺,可以把threshold从3500降低到2000左右。find_rects返回的结果包含四个角点坐标(corners),可以通过角点之间的几何关系计算出矩形的旋转角度和中心点,然后用这些参数来引导小车对准目标。
4. 视觉信息如何驱动运输小车运动
4.1 OpenMV与STM32的串口通信协议设计
视觉处理的结果必须可靠地传到运动控制层。首选方案是UART串口,因为它实现简单、协议透明,不牵扯I2C地址配置或SPI片选逻辑。涉及通信协议设计,核心原则是"上行发状态、下行发指令",每一步都要有明确的帧头、载荷和校验。
以下代码是OpenMV端的串口就绪判断和消息发送封装。实际项目中不建议把裸字符串直接发给STM32,因为字符串解析在C语言里要写一堆坑人的分隔逻辑。更稳的做法是定义一个有固定帧头的字节格式,比如帧头是0xA5,后面依次跟上目标的坐标高低字节和状态码,最后加一个校验字节。
from pyb import UART import sensor, image, time, struct sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) sensor.set_framesize(sensor.QQVGA) sensor.skip_frames(30) uart = UART(3, 115200, timeout_char=500) # 定义通信协议: # [0]帧头 0xA5 # [1]识别状态 0x01=找到目标 0x00=无目标 # [2]目标X坐标 低字节 # [3]目标X坐标 高字节 # [4]目标Y坐标 低字节 # [5]目标Y坐标 高字节 # [6]校验和 (0xA5+状态+X低+X高+Y低+Y高) & 0xFF FRAME_HEADER = 0xA5 CHECKSUM_MASK = 0xFF def send_target_status(found, x_coord, y_coord): x_lo = x_coord & 0xFF x_hi = (x_coord >> 8) & 0xFF y_lo = y_coord & 0xFF y_hi = (y_coord >> 8) & 0xFF checksum = (FRAME_HEADER + found + x_lo + x_hi + y_lo + y_hi) & CHECKSUM_MASK # 按顺序打包成字节流发送,struct.pack保证小端字节序 frame = struct.pack('<BBBHHB', FRAME_HEADER, found, 0, x_coord, y_coord, checksum) uart.write(frame)提示:
struct.pack('<BBBHHB')里的<表示小端字节序,B是无符号字节,H是无符号短整型(占2字节)。用struct打包而不是手动拼接字节字符串,可以把可读性和可维护性都提上去,下位机解析时只要按同样的格式解包就行。
在运输小车的实际使用场景里,坐标的分辨率值得思考。QQVGA分辨率是160x120,用16位表示坐标已经绰绰有余,将来如果换成VGA分辨率也不用改协议。发送频率不用太激进,10Hz足够——小车运动控制的响应时间是毫秒级,视觉帧率是瓶颈,10Hz的更新率已经能保证80ms以内的视觉延迟,这比大多数底盘机械结构的响应时间还快。太频繁的串口发送反而会占用MCU的宝贵时间,挤占图像处理资源。
4.2 STM32端的数据解析与运动决策
STM32端的重点不是串口接收本身,而是怎么保证数据不丢失、不粘包、不因误码导致小车乱撞。经典的解决方案是使用状态机解析。只要接收缓冲区里读到0xA5帧头,就进入"载荷接收"状态,确认收够6个字节后再做校验和比对。校验通过才把坐标值更新到全局变量里,否则全部丢弃并回到等待帧头状态。
// STM32端串口接收状态机 typedef enum { FRAME_IDLE, // 空闲状态,等待帧头 FRAME_DATA, // 已收到帧头,接收载荷 FRAME_CHECK // 接收完成,校验数据 } FrameState; uint8_t rx_buffer[7]; // 7字节缓冲区:帧头+6字节载荷 uint8_t state = FRAME_IDLE; uint8_t rx_index = 0; // 串口中断处理函数(USART2_IRQHandler中调用) void process_rx_byte(uint8_t data) { if (state == FRAME_IDLE) { if (data == 0xA5) { rx_buffer[0] = data; rx_index = 1; state = FRAME_DATA; } } else if (state == FRAME_DATA) { rx_buffer[rx_index++] = data; if (rx_index >= 7) { state = FRAME_CHECK; } } else if (state == FRAME_CHECK) { // 计算校验和,这里rx_buffer[6]是收到的最末字节 uint8_t sum = 0; for (int i = 0; i < 6; i++) { sum += rx_buffer[i]; } if (sum == rx_buffer[6]) { // 校验通过,解包坐标 int16_t x = rx_buffer[2] | (rx_buffer[3] << 8); int16_t y = rx_buffer[4] | (rx_buffer[5] << 8); update_tracking_target(x, y, rx_buffer[1]); } state = FRAME_IDLE; rx_index = 0; } }在运动决策上要引入一个关键概念:死区。摄像头画面中心只有160像素宽,如果把画面中心点当作目标"对准"的参考,那中心点左右±5像素范围内的偏移应该视为已对准,否则小车会一直左右微调、电机嗡嗡响而车子不走。把死区设计成可配置的宏,现场调试时可以根据底盘的惯性大小调整。底盘惯性大,死区就加大到±10像素;底盘轻灵,死区缩小到±3像素。
运动策略还应该加上"目标丢失保护"。如果连续500ms都没有从串口收到有效目标坐标,说明视觉算法跟丢或者目标移出视野。此时小车必须按预先定义的安全策略动作:立即刹车停在原地,不要盲目前进。很多搬运意外发生在自动模式下突然失去目标,小车不知道是该继续走还是停下来,最后撞上了货架。
4.3 PID控制在输送过程中的作用
运输小车的运动控制核心通常是PID控制器,它的作用不是让小车本身跑得多精准,而是让小车在负载变化(比如装上货物后重量增加)的情况下还能匀速前进。如果不控制速度,空载时电机转速忽高忽低,摄像头拍摄到的画面就会产生运动模糊,直接拖累视觉识别。
闭环控制在STM32里最朴素也最有效的形式是增量式PID。轮子上的霍尔编码器测得实际速度,目标速度由视觉算法给出,误差经过比例、积分、微分三个环节计算后,输出PWM占空比的增量。增量式的优点是计算过程中不累加历史误差的绝对值,不会出现积分饱和失控。
// 增量式PID的核心计算,调用周期建议5ms typedef struct { float Kp; // 比例系数 float Ki; // 积分系数 float Kd; // 微分系数 float target; // 目标速度 float last_error; // 上一次误差 float integral; // 积分累加值 }PID_Handle; int32_t pid_update(PID_Handle *pid, float measure) { float error = pid->target - measure; pid->integral += error; float delta = pid->Kp * (error - pid->last_error) + pid->Ki * error + pid->Kd * (error - 2*pid->last_error + pid->last_last_error); // 这里使用了二阶差分近似微分,抗噪声能力更好 pid->last_last_error = pid->last_error; pid->last_error = error; return (int32_t)delta; }提示:实际整定PID参数时,不要一上来就三个参数一起调。遵循先P后I再D的顺序:把Ki和Kd设为0,慢慢加大Kp直到车子开始轻微震荡,此时取Kp的50%作为设定值;然后加入Ki消除稳态误差;最后用极小值的Kd抑制超调。没经验就用试凑法,经验值范围是Kp在3~15之间,Ki在0.1~0.5之间,Kd在0.01~0.1之间,具体看你的电机齿比和负载。
5. 让视觉小车在实际光线下稳定运行的三个进阶技巧
运输小车从实验室走向收货区路面,最大的拦路虎是光照。同一种颜色在日光灯下、窗户边、阴影里的LAB值可以差出两个数量级。我们在调OpenMV的色块识别时常常陷入一个误区:不断调阈值,让当前画面变得完美,但换一个位置就失灵了。解决思路不是调阈值,而是改处理流程。
先亮出第一个技巧:最有效的办法是关闭自动曝光、自动白平衡、自动增益,这会让画面在局部光照变化时保持稳定,但并发的问题是全黑环境下画面会彻底黑掉。所以不要直接关闭它们,而是用scene_detection()或者定时读取画面的平均亮度做一个粗判断,再决定是否切换到LED补光灯。在OpenMV里可以检测画面的L通道均值,如果低于某个值就打开补光灯。但不要全部依赖灯光,合理做法是补光灯只做夜间补偿,正常光照下保持关闭,否则会出现灯照区域亮、边缘暗的强对比,识别结果更差。
第二个进阶技巧是建立一套视野中的"区域映射"。让OpenMV识别出的视野坐标和小车的实际物理坐标的对应关系,通过四角标定法计算出来。方法是在小车的工作台面上放一张A4纸,用四个角点作为标定参照,把图像坐标映射到世界坐标。这比直接用比例系数缩放要准得多,因为摄像头是斜着安装的,同样距离的图像误差在远处会放大好几倍。标定完成后,OpenMV输出的坐标就是世界坐标系的厘米值,下位机可以直接把它作为目标位置。
第三个技巧是针对运输小车的实际流程:把视觉任务拆成"寻找阶段"和"对准阶段"。寻找阶段只用低分辨率灰度图,把帧率拉到最快,尽快发现目标区域;发现目标后切换成高分辨率彩色识别来做精确对准。OpenMV的set_framesize()是可以在运行时反复调用的,但要注意切换后需要skip_frames(5)让感光元件稳定下来,否则切换后的前几帧画面会花屏。这个动态分辨率切换的方案,比一直跑高分辨率模式要快40%以上,而且在大范围运输场景里更加贴合需求——移动过程中不需要精细识别,停下来抓取时才需要高精度。
最后建议在串口通信里加一个看门狗式的握手逻辑:OpenMV每隔1秒主动发送一个心跳包,STM32在超过2秒没有收到任何有效数据时,自动切换成安全模式(低速行驶并鸣笛提醒)。这个改动只需20行代码,却能够把视觉模块死机和底盘乱跑彻底隔离开,让小车在真正进入仓储运行前就拦住一大堆隐性故障。
本文还有配套的精品资源,点击获取