基于OpenMV视觉的运输小车设计与源码解析
简介基于OpenMV视觉的运输小车设计源码是一套面向嵌入式视觉与智能物流场景的完整实战项目适合学习OpenMV图像识别、STM32控制、串口通信与小车运动规划的开发者、学生或参赛团队。资源共206个文件压缩包约27.52MB除36个头文件、34个C源文件、36个目标文件外还包含3个Python辅助脚本、1个MATLAB脚本、Keil工程配置文件、调试配置、HEX烧录文件等覆盖从芯片底层驱动、视觉算法到电机控制的完整代码链。已有315人学习下载源码目录按功能模块划分头文件、源文件与脚本层级清晰便于对照学习、快速定位核心代码和二次开发。通过该项目可以获得OpenMV与STM32通信、路径识别与避障处理、电机驱动及嵌入式调试的完整思路符合仓库自动搬运、车间物料配送等智能运输场景的课程设计或工程参考需求。1. 基于OpenMV视觉的运输小车从“能跑”到“认得出”的关键分水岭运输小车不是新鲜东西连小学生都在做。但绝大多数“能跑”的小车只是撞到东西就拐弯、看到黑线就转向——那不是视觉是开关量。真正要“认得出”就得让摄像头参与闭环。“基于OpenMV视觉的运输小车设计源码”这个标题有两层含义第一视觉模块选OpenMV而不是树莓派也不是在STM32上硬怼图像算法第二重点落在“设计源码”上也就是感知、决策、执行三层的代码组织方式。整个项目里你会看到一个带MicroPython解释器的摄像头模块负责图像识别一块MCU负责电机与逻辑两者靠串口协议对话。这个组合适合有单片机基础、想在机器视觉方向快速落地的工程师也适合拿来当毕设或竞赛底盘二次开发。2. 从硬件接收到协议定稿OpenMV运输小车的最小系统2.1 运输小车视觉方案的硬件选型与接线规划很多人在第一步就栽跟头把视觉识别丢给STM32去做。STM32不是不能跑视觉但跑一个QVGA分辨率的颜色识别就要吃掉大量主频和内存更不要说二维码解码。OpenMV的价值在于它把摄像头、图像缓存、MicroPython解释器集成在一块板子上算法在板内跑完对外只输出结论——比如目标物中心坐标、色块面积、二维码内容。常见做法是感知层交给OpenMV决策层与驱动层交给STM32两者用UART建立双向通信。接线方案我一般这样规划以OpenMV4 H7 Plus和STM32F103C8T6为例OpenMV引脚STM32引脚说明VCC3.3V必须稳压供电不能直接从电机电源抽GNDGND两地必须共地P4UART3_TXPA3USART2_RXOpenMV发送数据到STM32P5UART3_RXPA2USART2_TXSTM32发送指令到OpenMVP0PB0可选备用IO可触发拍照或复位提示OpenMV的瞬时电流在15到50mA之间电机堵转时电压跌落会导致OpenMV直接重启。常见做法是给OpenMV一个独立的LDO供电并在电机驱动电源端加一个大容量电解电容。这里有个容易忽略的点OpenMV和STM32的串口电平都是3.3V可以直接连接。但如果你的小车用了5V供电的Arduino就必须加电平转换板。接线完成后应该先跑一个回环测试——把P4和P5短接在OpenMV里发一串数据再收回来排除杜邦线接触不良的问题再进行下一步。2.2 OpenMV IDE下载方法与固件准备先让图像出现在电脑上OpenMV IDE是官方唯一推荐的工具链。官网openmv.io首页就有Download入口Windows版本下载后一路安装即可。安装完插入USB线如果没有识别到设备多半是驱动问题可以在设备管理器里检查有没有出现“OpenMV Camera”或带感叹号的未知设备。IDE安装包自带了常见系统的驱动不用额外去第三方网站找。连接上IDE之后先不要急着写识别代码。我每次拿到新板子的第一步是先烧一段最基础的工具代码确认摄像头、显示链路、固件版本都正常import sensor, image, time sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QQVGA) # 160x120帧率优先 sensor.skip_frames(time2000) sensor.set_auto_whitebal(False) # 关闭自动白平衡颜色才能稳定 clock time.clock() while True: clock.tick() img sensor.snapshot() print(FPS:, clock.fps())这段代码的作用是初始化摄像头并输出当前帧率。set_pixformat决定采集格式RGB565适合颜色识别如果后续只做巡线改成GRAYSCALE能获得更高的帧率。set_auto_whitebal(False)非常关键默认开启自动白平衡会让颜色阈值随环境光不断漂移关闭后颜色判断才稳定。如果IDE右下角显示已连接但代码无法运行先看是不是串口被占用。很多人同时开着串口助手和OpenMV IDE导致IDE抢不到设备。另一个常见问题是固件与IDE版本不匹配表现为IDE能识别设备但上传固件失败。在IDE菜单Tools里选择Run Bootloader即可进入固件升级模式升级过程大概几十秒期间不要拔线。2.3 OpenMV与STM32通信串口协议帧与解析状态机串口通信是这套系统里最容易出bug的地方。最常见的错误是OpenMV直接print(cx, cx)STM32那边用字符串解析——传输效率低不说一旦数据长度变化单片机解析就会错位。我一般会定一个固定格式的二进制协议帧两端按字节解析字段长度字节说明帧头11固定0xAA帧头21固定0x55长度1命令字 数据区 校验和的字节数命令字10x01识别结果0x02巡线偏差0x11握手请求数据区0~16坐标、宽度、高度等校验和1前面所有字节求和后取低8位OpenMV端发送识别结果的代码import ustruct from machine import UART uart UART(3, 115200, timeout_char200) def send_blob_report(cmd, cx, cy, w, h): data ustruct.pack(BHHHH, cmd, cx, cy, w, h) payload bytes([cmd]) data length len(payload) 1 frame b\xAA\x55 bytes([length]) payload checksum sum(frame) 0xFF uart.write(frame bytes([checksum]))这里ustruct.pack(BHHHH, ...)把命令字、x坐标、y坐标、宽度、高度打包成小端序二进制数据比转字符串再拼接省一半字节。校验和用累加取低8位不追求很强的检错能力但要能挡住串口上最常见的单字节跳变。帧头用0xAA55而不是单字节是为了减少数据区里随机出现帧头的概率。STM32端对应的解析状态机核心思路是每收一个字节推进一次状态uint8_t rx_state 0; uint8_t frame_len 0; uint8_t frame_buf[32]; uint8_t frame_idx 0; void parse_byte(uint8_t byte) { switch (rx_state) { case 0: if (byte 0xAA) rx_state 1; break; case 1: if (byte 0x55) rx_state 2; else rx_state 0; break; case 2: frame_len byte; frame_idx 0; rx_state 3; break; case 3: frame_buf[frame_idx] byte; if (frame_idx frame_len 1) { // 1是校验和 rx_state 0; if (verify_checksum(frame_buf, frame_len 1)) { process_frame(frame_buf); } } break; } }状态机的优势在于不需要等待“完整一帧”才处理每来一个字节都能推进不会因为串口中断唤醒不及时而丢帧。注意帧头之后紧接着的“长度”字段只代表命令字、数据区、校验和的长度这样STM32就知道还要收多少字节才算一帧完整数据。process_frame里再做命令字分发根据0x01、0x02走不同的业务逻辑。3. OpenMV图形识别源码解析颜色阈值、巡线与定位三合一3.1 颜色阈值选LAB空间不选RGBOpenMV的IDE里提供阈值编辑器但很多人一上来就在RGB颜色空间里调阈值结果在室内灯光下好好的换到窗边就全乱了。原因是RGB三个通道高度耦合亮度一变三个值一起变阈值范围很难框住目标。LAB色彩空间把亮度L和颜色分量A、B分开A通道表示红绿方向B通道表示黄蓝方向。调阈值时只需要管A和B亮度变化对颜色判断的干扰大幅降低。OpenMV使用的LAB阈值是六元组依次是(L_min, L_max, A_min, A_max, B_min, B_max)。以识别一个亮黄色乒乓球为例我调试后的阈值通常是目标L范围A范围B范围六元组示例黄色乒乓球40~90-10~3030~90(40, 90, -10, 30, 30, 90)红色胶带20~8020~12710~100(20, 80, 20, 127, 10, 100)黑色巡线0~400~1270~127(0, 40, 0, 127, 0, 127)实际调阈值时打开IDE的“工具-阈值编辑器”从图像窗口里框选目标区域编辑器会直接算出当前区域的LAB范围。然后手动把范围向外扩10到15个单位别完全照抄计算值否则换一个光线条件就识别不到。3.2 色块识别目标并上报坐标最小可跑源码一旦阈值确定识别代码就变得非常简单。以识别黄色目标并上报坐标和面积为例import sensor, image, time from machine import UART import ustruct sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QQVGA) sensor.skip_frames(time2000) sensor.set_auto_whitebal(False) uart UART(3, 115200, timeout_char200) yellow_threshold (40, 90, -10, 30, 30, 90) while True: img sensor.snapshot() blobs img.find_blobs([yellow_threshold], pixels_threshold200, area_threshold200, mergeTrue) if blobs: b max(blobs, keylambda x: x.area()) # 取最大目标 img.draw_rectangle(b.rect(), color(255, 0, 0)) img.draw_cross(b.cx(), b.cy(), color(255, 0, 0)) data ustruct.pack(BHHHH, 0x01, b.cx(), b.cy(), b.w(), b.h()) payload bytes([0x01]) data frame b\xAA\x55 bytes([len(payload) 1]) payload uart.write(frame bytes([sum(frame) 0xFF]))pixels_threshold和area_threshold的作用是过滤掉图像噪点形成的小色块。mergeTrue会把分散在同一目标上的多个色块合并成一个大的Blob比单独处理一堆碎片要省事。max(blobs, keylambda x: x.area())选面积最大的目标这在小车上很实用因为摄像头视野里可能同时出现多个同色物体默认抓最近的、也就是面积最大的那个。这段代码里b.cx()和b.cy()返回的就是色块的中心像素坐标b.w()和b.h()是宽高。上报给STM32之后STM32可以根据坐标值和画面中心线的偏差计算转向量。如果目标离开视野blobs列表为空此时应该向上位机发一个“视觉丢失”命令而不是保持最后一帧的坐标不更新。3.3 巡线与目标识别并存分时复用不拖帧率运输小车通常同时有两个任务沿地面黑线或色带行驶以及识别货架上的目标物。有人把两个find_blobs写在同一帧里发现帧率掉了一半。原因是一次全图搜索就要遍历数万个像素点做两轮搜索消耗自然是双倍。我一般用的方案是按帧交替奇数帧巡线偶数帧识别目标frame_count 0 while True: img sensor.snapshot() frame_count 1 if frame_count % 2 1: # 巡线ROI画面下半部分只扫描贴近车头的区域 line_blobs img.find_blobs([black_threshold], roi(0, 80, 160, 40), pixels_threshold50, mergeTrue) if line_blobs: line max(line_blobs, keylambda x: x.area()) delta line.cx() - 80 # 与画面中线的偏差 uart.write(pack_frame(0x02, delta)) else: # 目标识别ROI画面上半部分 obj_blobs img.find_blobs([yellow_threshold], roi(0, 0, 160, 80), pixels_threshold200, mergeTrue) if obj_blobs: b max(obj_blobs, keylambda x: x.area()) uart.write(pack_frame(0x01, b.cx(), b.cy(), b.w(), b.h()))roi参数是这里的关键。OpenMV的ROI格式是(x, y, w, h)只搜索画面中指定的矩形区域。巡线只看画面下半部分目标识别只看上半部分这样两个任务都不会被对方区域内的无关颜色干扰。相比缩小全图搜索范围这样处理还有一个好处小车行驶时地面和目标通常不会同时出现在画面的同一个半区ROI天然做了场景隔离。3.4 二维码与AprilTag识别给小车一个“绝对位置参考”色块识别容易受光照和遮挡影响如果要让小车在指定站点停车或往固定位置卸货更稳的方案是识别二维码或AprilTag。OpenMV对这两种标签都有内置支持二维码返回的是一个DataMatrix码或QR码的内容AprilTag返回的是ID和三维姿态信息。sensor.set_framesize(sensor.QVGA) # 二维码需要更高分辨率 for tag in img.find_apriltags(): img.draw_rectangle(tag.rect(), color(255, 0, 0)) img.draw_string(tag.cx(), tag.cy(), str(tag.id()), color(255, 0, 0)) x_error tag.cx() - img.width() // 2 z_distance tag.z_translation() uart.write(pack_frame(0x03, tag.id(), int(x_error), int(z_distance)))注意二维码识别对分辨率很敏感QQVGA下远距离基本识别不了我建议至少用QVGA。tag.z_translation()返回的是标签相对于摄像头的距离单位是毫米级估算值可以用它判断“是否到达停靠点”。AprilTag比普通二维码更稳因为它的定位图案自带容错但AprilTag家族中TAG36H11这种密集型的标签在低分辨率下容易漏检场地允许的话优先用TAG25H9。4. STM32端驱动源码串口收帧、差速转向与决策容错4.1 串口中断与环形缓冲区收帧不丢字节的底层保障OpenMV发送数据的频率通常在每秒20到30帧每帧不超过20个字节。STM32在115200波特率下接收没有任何压力但如果把解析代码放在中断里一边收一边处理一旦处理逻辑里有延时操作后续数据就会覆盖硬件接收寄存器。我一般先在中断里把字节存进环形缓冲区主循环里再取出来解析#define RX_BUF_SIZE 256 static uint8_t rx_buf[RX_BUF_SIZE]; static volatile uint16_t rx_head 0; static volatile uint16_t rx_tail 0; void USART2_IRQHandler(void) { if (USART_GetITStatus(USART2, USART_IT_RXNE)) { uint8_t byte USART_ReceiveData(USART2); uint16_t next (rx_head 1) % RX_BUF_SIZE; if (next ! rx_tail) { // 缓冲区未满 rx_buf[rx_head] byte; rx_head next; } // 缓冲区满则丢弃最旧数据这里选择静默丢弃 } }主循环里每次取一个字节喂给上一章写过的状态机。环形缓冲区的好处是中断处理时间极短只有一次数组写入不会因为解析逻辑卡住中断导致丢字节。缓冲区大小的选择要匹配OpenMV的发送节奏115200波特率下10ms能接收约110字节256字节足够缓冲十几帧数据。如果发现解析出的帧校验错误率超过5%先检查缓冲区是否溢出再看两边的波特率配置是否一致。4.2 差速转向的PD控制视觉给偏差驱动给差速视觉识别的最终目的是换算成左右轮的差速。以两轮差速底盘为例OpenMV上报目标中心横坐标STM32要做的是让目标保持在画面中线附近。这里我用一个纯PD控制器就够用不加积分项因为视觉目标是移动的积分容易累积超调static int16_t last_error 0; int16_t kp 8; int16_t kd 2; int16_t base_speed 400; // PWM比较值 void update_steering(int16_t cx) { int16_t error cx - 80; // QQVGA宽度160中线是80 int16_t diff kp * error kd * (error - last_error); last_error error; // 输出限幅防止PWM超过100% if (diff 200) diff 200; if (diff -200) diff -200; motor_set_speed(LEFT_MOTOR, base_speed - diff); motor_set_speed(RIGHT_MOTOR, base_speed diff); }kp乘以当前偏差决定转向力度kd乘以偏差的变化率防止小车来回摆头。这两个参数不能拍脑袋定要用阶跃响应测先只给kp从小往大调看到小车摆动时就退回一半再慢慢加kd直到摆动收敛。如果电机自带编码器可以把开环PWM替换成增量式PID速度环但要注意视觉反馈的周期只有30Hz左右PID控制周期不要超过20ms否则速度调节的反馈速度会远超目标物的位置更新速度系统反而更容易震荡。4.3 视觉超时、看门狗与刹车优先丢失目标时保证安全跑视觉小车最容易忽略的状态是“视觉丢失”。OpenMV可能因为强光、遮挡、或者目标驶出视野而不再上报数据如果STM32还按最后一帧的偏差继续走小车会直直冲向错误方向。我一般在STM32里对视觉数据做超时监控uint32_t last_vision_time 0; void vision_timeout_check(void) { if (systick_ms() - last_vision_time 200) { // 200ms无新数据 motor_set_speed(LEFT_MOTOR, 0); motor_set_speed(RIGHT_MOTOR, 0); vision_lost_count; } }每次收到完整的视觉帧就刷新last_vision_time。这里200ms的阈值对应大约5帧数据的时间间隔正常情况不会误触发一旦触发就立即刹车。注意运输小车在运动状态下急刹容易翻车或打滑所以更稳妥的做法是先把速度降到最低然后再判断下一步是倒退寻找目标还是原地转向搜索。如果OpenMV长时间不上报数据还可以通过一个GPIO给OpenMV发送复位信号让视觉模块软重启。5. 三招搞定OpenMV误识别镜头校正、曝光锁定与多帧投票第一招是镜头畸变校正。OpenMV默认镜头在画面边缘有明显畸变物体靠近边缘时cx()坐标会偏移十几个像素。对控制精度要求高的场景用img.lens_corr(1.8)做径向畸变校正。强度系数1.8到2.0之间数值越大校正越强。校正会裁剪掉一部分边缘画面如果你的ROI本身靠近中心区域这个影响可以忽略。第二招是锁定相机曝光和增益。这一招在竞赛场地和工厂环境下特别有用环境光固定时把自动曝光关掉锁定固定曝光值颜色识别误判率能下降一个数量级sensor.set_auto_exposure(False, exposure_us20000) sensor.set_auto_gain(False, gain_db20)曝光时间设置和帧率直接相关20毫秒曝光对应对应最高约30fps室内光线充足时可以降到5毫秒换取更高帧率。如果做了这一步就不要再依赖自动白平衡否则白平衡算法会持续修正色偏让颜色阈值重新漂移。第三招是多帧投票。在STM32端拿到识别坐标后不用每一帧都直接响应而是连续5帧里目标坐标变化不超过一个阈值才确认是稳定识别。我经常用这样一个简单的滑动窗口uint8_t stable_count 0; if (abs(new_cx - last_cx) 5 abs(new_cy - last_cy) 5) { stable_count; } else { stable_count 0; last_cx new_cx; last_cy new_cy; } if (stable_count 3) { apply_vision_target(last_cx, last_cy); }目标静止时振动和噪声会让坐标在小范围内抖动目标真正移动时坐标变化会超过5像素阈值。用3次确认来过滤静态抖动又不至于反应太慢。实际产线上如果遇到反光干扰导致误识别我会把投票窗口拉长到7帧稳定性优先于响应速度。对于运输小车这种低速场景200ms的确认延迟完全可接受。本文还有配套的精品资源点击获取