OpenMV与STM32F103C8T6物料搬运机器人:视觉识别与运动控制实战
简介:这份资源面向参加工程训练比赛或学习嵌入式机器人开发的学生与开发者,提供一套基于OpenMV视觉模块与STM32F103C8T6微控制器的物料搬运机器人完整代码方案,用C语言实现视觉识别与运动控制的协同。压缩包共287个文件、约6.62MB,以C源码、头文件、工程配置与编译中间文件为主,包含Keil工程文件、启动汇编、链接脚本及hex烧录文件,可直接导入编译或按需修改参数移植到其他32位单片机。内容覆盖OpenMV图像采集与物料颜色形状识别、STM32外设驱动、串口通信、循迹与抓取动作控制等环节,工程结构清晰,便于对照学习底层驱动编写与系统集成思路。目前已有1648人学习下载,适合作为赛题复现、课程设计参考与嵌入式视觉入门实践素材。
1. 从一堆散装代码到能跑起来的物料搬运机器人
去年帮一个做实训的朋友收拾摊子,他桌上摆着一台四轮小车,OpenMV 摄像头歪着脖子,STM32F103C8T6 最小系统板用杜邦线吊在半空,地上散着七八个.c文件和一个不知道能不能编译的工程。他说代码是网上东拼西凑的,能识别红色方块,但小车一抓就冲过头,问我能不能救。这个场景基本就是这份openmv+stm32f103c8t6实现物料搬运机器人代码.zip的真实使用现场:它不是某个成熟产品,而是一套视觉识别加运动控制的完整工程骨架,用 C 语言写成,OpenMV 负责看,STM32 负责动,中间靠串口把坐标传过去。适合正在做课程设计、学科竞赛或者想搞明白「摄像头怎么指挥轮子」的开发者,尤其是手里已经有这两块板子、缺一份能对照着改的代码的人。
2. 系统架构拆解:视觉端与运动端怎么分工
2.1 双 MCU 架构的职责边界
这套代码最核心的设计决策是把系统切成两半:OpenMV 端只做图像采集、色块识别和坐标解算,STM32 端只做串口解析、运动学计算和电机驱动。两边通过 UART 通信,OpenMV 是发送方,STM32 是接收方。为什么不让 STM32 直接接摄像头?因为 F103C8T6 只有 64KB Flash、20KB RAM,跑 DCMI 加图像缓冲基本没戏,而 OpenMV 自带 STM32H7 级别的算力和 MicroPython 环境,图像处理是它的主场。这种分工在物料搬运场景里很自然:视觉端输出的是「目标在画面里的位置」,运动端关心的是「底盘该往哪个方向走多少」。
从代码组织上看,压缩包里通常包含两个独立工程目录,一个给 OpenMV IDE 打开(.py文件),一个给 Keil MDK 打开(.uvprojx加一堆.c/.h)。两边不共享头文件,协议靠约定,这是嵌入式里常见的松耦合做法,改一边不影响另一边编译。
2.2 目录结构与文件职责
拿到压缩包解压后,典型的目录长这样:
物料搬运机器人/ ├── openmv/ │ ├── main.py # 视觉主循环 │ ├── color_track.py # 色块识别与坐标输出 │ └── pid.py # 云台/舵机简易 PID(可选) ├── stm32/ │ ├── Core/ │ │ ├── Src/ │ │ │ ├── main.c # 主循环、串口接收 │ │ │ ├── usart.c # UART 初始化与中断 │ │ │ ├── motor.c # 电机 PWM 与方向控制 │ │ │ └── servo.c # 机械爪/升降舵机 │ │ └── Inc/ │ │ ├── motor.h │ │ └── protocol.h # 帧格式定义 │ └── MDK-ARM/ │ └── project.uvprojx └── README.mdmain.c是 STM32 的入口,里面有一个while(1)主循环和一个 UART 接收中断回调;motor.c封装了左右轮的差速控制;protocol.h定义帧头、帧尾和坐标字段。OpenMV 端的main.py通常是一个while(True)里做find_blobs,找到目标后通过uart.write()发出去。这个结构不复杂,但每一层都有值得抠的细节,后面几章会逐个拆。
2.3 通信协议设计:为什么不能直接发字符串
新手最容易翻车的地方就是协议。我见过有人直接在 OpenMV 里uart.write("x=120,y=80\n"),STM32 端用strtok解析,结果丢包、粘包、解析错位全来了。这套代码里比较稳妥的做法是定长二进制帧,常见格式是0xAA 0x55 | x_h x_l | y_h y_l | flag | 0x0D 0x0A,帧头两个字节用于同步,x/y 各两字节小端序,flag 表示是否检测到目标,帧尾做校验。定长帧的好处是接收端可以按状态机逐字节吃,不依赖字符串结束符。
// protocol.h —— 帧格式定义 #define FRAME_HEAD1 0xAA #define FRAME_HEAD2 0x55 #define FRAME_TAIL 0x0A #define FRAME_LEN 8 typedef struct { int16_t x; // 目标中心 x 坐标,范围 0~320 int16_t y; // 目标中心 y 坐标,范围 0~240 uint8_t flag; // 1=检测到目标,0=丢失 } VisionFrame;这里x和y用int16_t而不是uint8_t,是因为 OpenMV 的 QVGA 分辨率下坐标能到 320,单字节装不下。flag单独留一个字节,方便后续扩展成「目标颜色编号」或者「抓取状态」。帧长固定 8 字节,接收端状态机写起来很直接,不需要动态缓冲。
2.4 运动控制链路:从坐标到轮速
STM32 收到坐标后,不能直接拿 x 去驱动电机,中间要过一层映射。常见做法是把画面中心(160, 120)当作期望位置,计算偏差err_x = x - 160,再用一个比例系数把偏差转成左右轮的差速。如果目标偏左,左轮减速右轮加速,车头就往左转。这套代码里通常有一个track_target()函数干这件事,参数包括基础速度base_speed和比例系数Kp。
// motor.c —— 根据视觉偏差计算左右轮 PWM void track_target(int16_t x, int16_t y, uint8_t flag) { if (!flag) { // 没检测到目标,停车 motor_set(0, 0); return; } int16_t err = x - 160; // 画面中心为 160 int16_t turn = err * KP / 100; // KP 需按实车调,常见 30~80 int16_t base = BASE_SPEED; // 基础速度,占空比 0~1000 int16_t left = base + turn; int16_t right = base - turn; motor_set(clamp(left, -1000, 1000), clamp(right, -1000, 1000)); }KP这个系数是整台车手感的关键,给大了车会左右画龙,给小了追不上目标。clamp防止计算溢出导致 PWM 寄存器写入非法值。y坐标在这版代码里通常只用来判断距离远近,比如y > 180认为目标太近就减速,避免撞上去。
3. 跑通第一版:环境搭建与烧录顺序
3.1 OpenMV 端环境与脚本上传
OpenMV 端需要装 OpenMV IDE,连上摄像头模块后,IDE 右下角会显示固件版本。把openmv/目录下的.py文件拖进 IDE,点左下角的运行按钮可以先在线跑,看串口终端有没有坐标输出。确认识别正常后,把main.py保存到 OpenMV 的 SD 卡或者内部 Flash,命名为main.py,这样脱机上电就会自动执行。
一个容易忽略的点是串口波特率必须和 STM32 端一致。OpenMV 的UART(3, 115200)里 3 是引脚编号,对应 P4/P5,接线时 TX 接 STM32 的 RX,RX 接 STM32 的 TX,地线必须共地。我见过有人只接了两根信号线没接地,数据时有时无,查了一下午。
# openmv/main.py —— 视觉主循环精简版 import sensor, image, time from pyb import UART sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) sensor.skip_frames(time=2000) uart = UART(3, 115200) red_threshold = (30, 100, 15, 127, 15, 127) # L*A*B* 阈值,需现场调 while True: img = sensor.snapshot() blobs = img.find_blobs([red_threshold], pixels_threshold=200, area_threshold=200) if blobs: b = max(blobs, key=lambda x: x.pixels()) # 取最大色块 frame = bytearray([0xAA, 0x55, b.cx() & 0xFF, (b.cx() >> 8) & 0xFF, b.cy() & 0xFF, (b.cy() >> 8) & 0xFF, 1, 0x0A]) else: frame = bytearray([0xAA, 0x55, 0, 0, 0, 0, 0, 0x0A]) uart.write(frame)red_threshold是 LAB 色彩空间的六个值,分别对应 L、A、B 的下限和上限。这个阈值跟现场光照强相关,室内日光灯和窗边自然光下能差出一大截,必须拿着实际物料在现场调。pixels_threshold过滤掉噪点小色块,max(blobs, key=...)保证画面里有多个红色物体时只追最大的那个。
3.2 STM32 端 Keil 工程配置
STM32 端用 Keil MDK 打开MDK-ARM/project.uvprojx,先确认器件型号选的是 STM32F103C8。如果打开时报缺器件包,去 Pack Installer 里装Keil.STM32F1xx_DFP。编译前检查三个地方:一是Options for Target里晶振频率设成 8MHz(外部晶振)还是 72MHz 系统时钟,代码里SystemInit会按这个配 PLL;二是 Debug 选项卡选 ST-Link 或 DAP-Link,跟手里的下载器对应;三是 C/C++ 选项卡里的宏定义,常见有USE_HAL_DRIVER, STM32F103xB。
编译通过后,用 ST-Link 接 SWDIO、SWCLK、GND、3.3V 四根线烧录。烧录前把 BOOT0 跳线接 0,烧完复位。如果 Keil 报No target connected,先查杜邦线是不是松了,再查目标板有没有独立供电——F103C8T6 最小系统板有时候只靠下载器的 3.3V 带不动电机驱动。
3.3 联调顺序:先看串口再动车
血泪经验是千万别一上电就让车跑。正确的联调顺序是:第一步,只给 STM32 供电,用 USB-TTL 接它的串口,在电脑上看 OpenMV 发过来的原始字节对不对;第二步,遮住摄像头再放开,观察 flag 位有没有从 0 变 1;第三步,把车架空,轮子离地,看左右轮转向是否跟目标偏移方向一致;第四步才落地跑。这套流程能把「协议错」和「控制错」分开,不然车一冲出去你根本不知道是坐标解析错了还是 KP 给大了。
提示:架空测试时用手在摄像头前左右移动红色物块,观察两个轮子的转速差。如果物块左移时左轮反而加速,说明电机方向接反了,改
motor.c里的方向引脚电平或者直接调换电机线。
4. 核心代码逐段讲:识别、解析、驱动
4.1 色块识别中的阈值与 ROI 取舍
OpenMV 的find_blobs默认全画面搜索,QVGA 下每帧耗时大概十几毫秒,对物料搬运够用。但如果现场背景里有红色杂物,全画面搜索会频繁误触发。比较实用的改法是加 ROI(感兴趣区域),只搜画面下半部分或者传送带所在的那一条带子。
# 限定搜索区域,减少误识别和计算量 roi = (0, 80, 320, 160) # x, y, w, h blobs = img.find_blobs([red_threshold], roi=roi, pixels_threshold=200, area_threshold=200, merge=True)roi的四个参数是左上角坐标加宽高,(0, 80, 320, 160)表示从 y=80 开始往下 160 像素。merge=True会把相邻的色块合并,避免一个物料被识别成两块导致坐标跳变。这个参数在物料表面有反光或者图案时特别有用,不开的话 cx 会在两个色块之间来回跳,车就跟着抖。
4.2 串口接收状态机:逐字节吃帧
STM32 端如果用 HAL 库,串口接收中断里不能做耗时解析,正确做法是中断里只把字节塞进环形缓冲,主循环里跑状态机。这套代码里常见的是一个uart_rx_byte()函数配合parse_frame()。
// usart.c —— 接收状态机 typedef enum { WAIT_H1, WAIT_H2, RECV_DATA, WAIT_TAIL } ParseState; static ParseState state = WAIT_H1; static uint8_t buf[FRAME_LEN]; static uint8_t idx = 0; void uart_rx_byte(uint8_t byte) { switch (state) { case WAIT_H1: if (byte == FRAME_HEAD1) state = WAIT_H2; break; case WAIT_H2: state = (byte == FRAME_HEAD2) ? RECV_DATA : WAIT_H1; idx = 0; break; case RECV_DATA: buf[idx++] = byte; if (idx >= FRAME_LEN - 2) state = WAIT_TAIL; break; case WAIT_TAIL: if (byte == FRAME_TAIL) { int16_t x = (int16_t)(buf[0] | (buf[1] << 8)); int16_t y = (int16_t)(buf[2] | (buf[3] << 8)); uint8_t flag = buf[4]; track_target(x, y, flag); // 解析成功直接驱动 } state = WAIT_H1; break; } }状态机的关键是任何一步不匹配都回到WAIT_H1,这样即使中间丢了几个字节,下一帧的帧头还能重新同步。buf只存数据段不存帧头帧尾,所以长度是FRAME_LEN - 2。解析出来的 x/y 用小端序拼装,跟 OpenMV 端b.cx() & 0xFF先发低字节的顺序对应,两边必须一致,改协议时这是第一个要对齐的地方。
4.3 差速驱动与死区补偿
motor_set()最终落到 TIM 的 PWM 通道上,但真实电机有个死区:占空比低于某个值(比如 15%)时轮子根本不转。如果代码里不做补偿,小车在低速微调时会「顿一下走一下」。常见做法是在motor_set里加一个死区映射。
// motor.c —— 带死区补偿的 PWM 输出 #define DEAD_ZONE 150 // 占空比 0~1000 下的死区值 static int16_t apply_deadzone(int16_t duty) { if (duty == 0) return 0; if (duty > 0) return duty + DEAD_ZONE; return duty - DEAD_ZONE; } void motor_set(int16_t left, int16_t right) { left = apply_deadzone(left); right = apply_deadzone(right); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, abs(left)); __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_2, abs(right)); // 方向引脚按正负设置,略 }DEAD_ZONE这个值因电机而异,减速比大的电机死区小,普通 TT 马达死区能到 200。调法是把车架空,从 0 慢慢加占空比,记下轮子刚开始转的那个值,填进来。不做补偿的话,PID 在零附近会反复震荡,看起来就是车在原地哆嗦。
4.4 机械爪时序:抓取动作和底盘运动的互锁
物料搬运不只是走位,还有抓。这套代码里舵机控制通常是一个简单的角度设定加延时,但坑在于抓取时底盘不能还在动,否则爪子对不准。稳妥的做法是加一个状态机,检测到目标进入抓取距离(y 坐标超过阈值)后先停车,再执行舵机动作序列。
// servo.c —— 抓取动作序列 void grab_sequence(void) { motor_set(0, 0); // 先停底盘 HAL_Delay(200); // 等车停稳 servo_set(GRAB_OPEN); // 张开爪 HAL_Delay(300); servo_set(GRAB_CLOSE); // 闭合抓取 HAL_Delay(500); // 等夹紧 servo_set(LIFT_UP); // 抬升 }每个HAL_Delay都不是随便写的:200ms 等车停稳,300ms 等爪子张到位,500ms 等夹紧。这些值要按实际舵机速度调,舵机慢的型号 500ms 可能还不够。HAL_Delay会阻塞主循环,期间串口数据还在中断里收,但不会解析,所以抓取动作期间视觉端最好也暂停发送或者发 flag=0,避免解析出过期坐标。
5. 避坑与排查:那些让车原地打转的坑
5.1 现象:串口能收到数据但车不动
原因通常是解析状态机卡在某个状态没复位,或者track_target里flag判断写反了。排查时先在parse_frame成功分支里点个 LED,看 LED 闪不闪。如果 LED 闪但车不动,问题在motor_set或者 PWM 通道没使能;如果 LED 不闪,用逻辑分析仪或者 USB-TTL 抓原始字节,看帧头是不是0xAA 0x55。还有一种可能是波特率对不上但恰好能收到乱码,表现为 LED 偶尔闪一下,这时候量一下两端的实际波特率配置。
5.2 现象:车追着目标画龙,左右猛打
这是 KP 给大了的典型表现。比例控制没有阻尼,偏差一反向就过冲。解决办法先把KP砍一半,看震荡是否消失,再慢慢加回来。如果砍到很小还是抖,检查 OpenMV 端 cx 是不是在跳——merge=True没开或者阈值太宽都会导致坐标抖动。另外base_speed太低时转向分量占比过大也会画龙,适当提高基础速度能让车走得更稳。
5.3 现象:OpenMV 脱机后不工作,连电脑正常
九成是main.py没存对位置。OpenMV 脱机执行的是 Flash 根目录下的main.py,如果你在 IDE 里只是点运行没点保存,断电就没了。正确操作是Tools -> Save open script to OpenMV Cam,存成main.py。另外脱机时如果脚本里有print大量输出而串口没接,某些固件版本会变慢,把调试打印注释掉再存。
5.4 现象:电机一转 STM32 就复位
电源问题。F103C8T6 最小系统板的 3.3V LDO 带不动电机,电机启动瞬间电流把电压拉低,MCU 欠压复位。解决方法是电机驱动(比如 L298N 或 TB6612)单独供电,和 MCU 共地但不共用 LDO。如果用的是 TB6612,注意 VM 和 VCC 是两路电源,VM 接电池,VCC 接 3.3V 逻辑电平,接反了芯片直接冒烟。
5.5 现象:抓取时爪子抖或者不动
舵机供电不足或者 PWM 频率不对。SG90 这类舵机堵转电流能到 700mA,从 MCU 板取电必挂。单独给舵机 5V 供电,信号线接 STM32 的 TIM 通道。PWM 频率设 50Hz,脉宽 0.5ms~2.5ms 对应 0~180 度,如果设成 1kHz 舵机会嗡嗡响但不转。代码里servo_set的角度到脉宽换算要按实际舵机行程标定,不同批次舵机的中位脉宽能差 0.1ms。
6. 二次开发:把追色块改成认物料、加状态机
这套代码跑通之后,大多数人下一步想干的事是「别只追红色了,我要认不同颜色的物料,按顺序搬」。改造的核心不在视觉端加多少判断,而在 STM32 端引入一个任务状态机,把「找目标 → 对准 → 抓取 → 搬运 → 放置」拆成明确的状态,每个状态只关心自己那一段逻辑。视觉端则把flag字节扩展成「颜色编号 + 是否检测到」,比如高 4 位存颜色 ID,低 4 位存有效标志。
// task.c —— 搬运任务状态机骨架 typedef enum { S_IDLE, S_SEARCH, S_ALIGN, S_GRAB, S_DELIVER, S_RELEASE } TaskState; static TaskState task = S_SEARCH; void task_tick(int16_t x, int16_t y, uint8_t flag) { uint8_t color_id = flag >> 4; uint8_t valid = flag & 0x0F; switch (task) { case S_SEARCH: if (valid && color_id == TARGET_COLOR) task = S_ALIGN; break; case S_ALIGN: if (!valid) { task = S_SEARCH; break; } track_target(x, y, 1); if (abs(x - 160) < 15 && y > 170) { // 对准且够近 motor_set(0, 0); task = S_GRAB; } break; case S_GRAB: grab_sequence(); task = S_DELIVER; break; case S_DELIVER: // 走固定路径或者巡线到放置区,略 task = S_RELEASE; break; case S_RELEASE: servo_set(GRAB_OPEN); HAL_Delay(400); task = S_SEARCH; break; } }这个骨架里abs(x - 160) < 15是对准容差,给太小车会反复微调对不上,给太大抓取偏位,15 像素在 QVGA 下大概对应几厘米,按爪子开口调。y > 170是距离阈值,表示目标已经进入抓取范围。状态机的价值在于每个状态都有明确的进入和退出条件,调试时可以单独把task强制设成某个值来测那一段,不用每次从头跑。
验证方法上,我一般会先关掉S_DELIVER之后的逻辑,让车只做「找 → 对准 → 抓」,确认抓取成功率,再补搬运路径。路径部分如果场地固定,用编码器里程计加固定转向比纯视觉稳;如果场地会变,就在地面贴线做巡线,视觉只管物料识别。两种方案在这套代码上都能接,motor.c的接口不用动,只换S_DELIVER里的控制逻辑。
还有一个容易被忽略的改造点是通信带宽。如果视觉端要传颜色 ID、坐标、距离三个量,8 字节帧就不够了,得扩到 12 字节或者加校验和。扩帧的时候两边必须同时改,我习惯在protocol.h里把FRAME_LEN定义成宏,OpenMV 端也写一个同名常量,改的时候搜一遍全工程,避免一边改了另一边没改导致解析错位。从那以后我每次动协议都先把两端帧长打印出来对一遍,再上电跑车,这个习惯救过我不止一次。希望帮到你。
本文还有配套的精品资源,点击获取