简介:基于STM32的六轴机械臂控制与OpenMV颜色识别分拣项目,涵盖运动控制与视觉识别两大核心模块,是一套完整的嵌入式视觉分拣方案,适用于毕业设计、课程设计及期末大作业。项目源码均经本地编译验证可运行,评审分达98分,内容通过助教审定,难度适中,适合嵌入式或机器人方向学习者参照实现。压缩包共841个文件,约23.09MB,以C/H源码为主,同时包含Keil工程配置(uvprojx/uvoptx)、链接脚本、Hex/MAP烧录文件、文档说明及少量Python辅助脚本,结构清晰,便于直接导入工程并二次开发。已有213人学习下载。整套资源不仅提供六轴机械臂运动控制核心代码,还给出OpenMV颜色识别与不同物块分类存放的完整逻辑,配合配套文档可快速理解机械臂控制原理与视觉定位流程,是开展同类项目或准备答辩的实用参考。
1. 从“看见”到“抓放”,一套六轴机械臂分拣系统的完整链路
在产线分拣、教学实验和竞赛 demo 这些场景里,“基于 stm32 的六轴机械臂控制 + OpenMV 颜色识别,识别不同物块并分放”是个相当典型的毕业设计级项目。它的核心难点并不在某个单点——机械臂逆解、颜色识别、串口通信、抓放标定,每一项单独拎出来都有现成资料,真正让新手卡住的是联调:OpenMV 把颜色信息发过去了,机械臂却抓偏;物块颜色阈值调好了,换了个光照就全部误判;六轴角度算出来了,舵机抖动导致放置位置偏移。这篇文章就把这条链路拆开讲,从 OpenMV 的颜色识别策略、stm32 与 OpenMV 的串口通信协议、六轴机械臂逆运动学实现,一直写到定位标定与滤波技巧。适合正在做课程设计、毕业设计或打算用 stm32 项目求职作品的人,照着做能跑通,跑通之后也知道每个参数为什么这么设。
2. OpenMV 颜色识别模块:阈值标定、物块分类与干扰抑制
2.1 为什么要选 LAB 色彩空间而非 RGB
OpenMV 的官方 IDE 里,颜色识别的核心函数是find_blobs(),它默认工作在 LAB 色彩空间。RGB 三个通道相互关联,亮度一变三个值一起漂移;LAB 把亮度分量 L 和颜色对立分量 A、B 拆开,识别颜色时主要锁定 A、B 通道,对光照变化的容忍度明显更高。实际调试时的经验是:L 阈值放宽到 20 到 80,A 和 B 收紧到目标颜色的中心区附近,识别稳定性最好。
import sensor, image, time, math, ustruct sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) sensor.skip_frames(time=2000) sensor.set_auto_whitebal(False) # 关闭自动白平衡,稳定色温干扰 red_threshold = (30, 80, 30, 80, 20, 60) # L, A, B 六元组 green_threshold = (30, 80, -60, -20, 15, 50) blue_threshold = (20, 70, -10, 30, -50, -20) def classify_color(blob): # 依次用三种阈值匹配,返回颜色标签 if blob.code() & image.RED: return "red" if blob.code() & image.GREEN: return "green" if blob.code() & image.BLUE: return "blue" return "unknown" while True: img = sensor.snapshot() blobs = img.find_blobs([red_threshold, green_threshold, blue_threshold], pixels_threshold=200, area_threshold=200, merge=True) for b in blobs: img.draw_rectangle(b.rect(), color=(255, 0, 0)) img.draw_cross(b.cx(), b.cy(), color=(255, 0, 0)) label = classify_color(b) img.draw_string(b.cx() - 40, b.cy() - 20, label, color=(255, 255, 255))这段代码里pixels_threshold=200用来过滤掉面积太小的噪点,area_threshold=200过滤不完整的目标区域,merge=True把同一物块被拆开的色块合并成一个。blob.code()返回的是一个位掩码,当它同时命中了红色阈值和绿色阈值时,需要把判决优先级写清楚。我一般建议先做面积排序,再做分类——物块挡住另一块时,面积大的 blob 往往才是当前需要抓取的目标。
2.2 颜色阈值标定的两种方法:手动取色与直方图辅助
手动在 OpenMV IDE 的“阈值编辑器”里框选目标区域,是最直观的方式,但新手容易犯的错是只在理想光照下标定。正确的做法是:把物块放到抓取区域的四个角落和中心,各采集一次颜色,取交集或者取均值,再把阈值适当放宽 10% 到 15%。另一种更稳定的方式是用直方图工具,观察目标物块在 A、B 通道上的分布峰值,把阈值中心对准峰值,半宽取峰值宽度的 1.5 倍。
# 直方图辅助标定脚本,运行时抓取画面中心区域的像素分布 img = sensor.snapshot() hist = img.get_histogram(roi=(80, 60, 160, 120)) # roi 是屏幕中心区域 a_mean = hist.get_percentile(0.5).A_value() b_mean = hist.get_percentile(0.5).B_value() print("A center:", a_mean, "B center:", b_mean)用这个脚本打印出来的 A、B 中心值,去替换阈值六元组的中间四位,L 通道仍按 20 到 80 放宽。这里有个容易踩的细节:OpenMV 的find_blobs返回的坐标是相对于当前帧图像尺寸的,如果后续要把像素坐标换算成机械臂基座坐标,必须先用sensor.set_windowing()固定 ROI 区域,避免识别区域和抓取区域不匹配。
2.3 光照变化与多物块重叠的干扰抑制
颜色识别最怕的是环境光频闪和反光。摄像头视角里如果出现白色桌面反光,红色物块表面的高光区域会在 LAB 空间里被推向低饱和方向,导致 blob 分裂。处理办法有三个层次:第一,OpenMV 的帧率设置到 30fps 以下,让自动曝光收敛;第二,在物块传送区域加遮光罩,这是成本最低、效果最明显的方案;第三,代码层面开启sensor.set_auto_gain(False),固定增益值,防止画面明暗波动导致阈值抖动。
假如两个物块重叠,find_blobs输出的 blob 会是一个大的联合色块,blob.cx()和blob.cy()偏向重心,直接抓取大概率抓偏。我的处理方式是:在 OpenMV 端只输出每个物块的中心坐标,把“是否重叠”的判断交给 stm32——当两个 blob 中心距离小于设定的码盘距离阈值时,机械臂先执行一次预抓取动作,把上层物块拨开再识别。这个逻辑虽然多了一次动作,但抓取成功率能稳定在 95% 以上,比单纯调阈值靠谱得多。
3. stm32 与 OpenMV 通信协议设计:帧格式、校验与缓冲处理
3.1 为什么不能用裸发送坐标的方式
很多入门 Demo 的做法是 OpenMV 直接print坐标,stm32 用串口中断接收字符串后解析。这种方式在实验桌上能跑,但一旦机械臂动作、舵机产生电磁干扰,串口数据就容易丢字节。丢字节后如果协议没有边界,stm32 就会把后半帧当成新帧头,整个数据流错位。所以通信协议要解决三个问题:帧边界、数据校验、粘包处理。我常用的帧格式是:帧头(0xAA 0x55) + 数据长度(1字节) + 数据域 + 校验和(1字节) + 帧尾(0x0D 0x0A)。
3.2 stm32 串口接收的环形缓冲实现
stm32 端使用串口 2 接收 OpenMV 的数据,同时用 DMA 加空闲中断的方式,把接收压力从 CPU 中解放出来。下面给出的是串口空闲中断配合环形缓冲的典型写法。
#define RX_BUF_SIZE 256 uint8_t rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_head = 0; volatile uint16_t rx_tail = 0; void USART2_IRQHandler(void) { if (USART_GetITStatus(USART2, USART_IT_IDLE) != RESET) { USART_ReceiveData(USART2); // 清除空闲中断标志 rx_tail = RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); // 此时 rx_tail 指向 DMA 缓冲区的当前写位置 } } uint16_t ring_buffer_count(void) { return (rx_tail >= rx_head) ? (rx_tail - rx_head) : (RX_BUF_SIZE - rx_head + rx_tail); }空闲中断的触发条件是串口总线空闲一个字节时间,说明一帧数据接收完毕。DMA 持续把数据搬进rx_buf,CPU 只需要在需要时解析rx_head到rx_tail之间的数据。这里最重要的参数是 DMA 缓冲区和环形缓冲的长度必须一致,否则取模计算会错位。我一般把环形缓冲长度设为帧长度最大值的 4 倍,防止一帧解析期间又来了一帧。
3.3 OpenMV 端打包发送与 stm32 端解析校验
OpenMV 端发送时用ustruct.pack把数据压缩成二进制,避免字符串转来转去。
import ustruct def send_target(color_code, x, y): # 帧头 2 字节 + 数据长度 1 字节 + 数据域 5 字节 + 校验和 1 字节 + 帧尾 2 字节 data = ustruct.pack("<BHH", color_code, x, y) checksum = 0 for b in data: checksum += b frame = b'\xAA\x55' + bytes([len(data)]) + data + bytes([checksum & 0xFF]) + b'\x0D\x0A' uart.write(frame) uart = pyb.UART(3, 115200, timeout_char=100)这里的<BHH是小端字节序,B 是颜色码占 1 字节,两个 H 分别是 X 和 Y 坐标占 2 字节。坐标范围是 0 到 320 和 0 到 240,正好在 uint16_t 范围内。stm32 端解析时,我维护一个有限状态机,状态依次为:等待帧头 1、等待帧头 2、读取长度、读取数据、校验、等待帧尾。
uint8_t parse_state = 0; uint8_t frame_len = 0; uint8_t data_buf[16]; uint8_t data_idx = 0; uint8_t checksum_calc = 0; uint8_t parse_byte(uint8_t byte) { switch (parse_state) { case 0: if (byte == 0xAA) parse_state = 1; break; case 1: if (byte == 0x55) parse_state = 2; else parse_state = 0; break; case 2: frame_len = byte; data_idx = 0; checksum_calc = 0; parse_state = 3; break; case 3: data_buf[data_idx++] = byte; checksum_calc += byte; if (data_idx >= frame_len) parse_state = 4; break; case 4: if (checksum_calc != byte) parse_state = 0; // 校验失败,丢弃整帧 else parse_state = 5; break; case 5: if (byte == 0x0D) parse_state = 6; else parse_state = 0; break; case 6: if (byte == 0x0A) { parse_state = 0; return 1; // 一帧完整接收 } parse_state = 0; break; } return 0; }状态机的优势在于不需要等待整帧进缓冲再处理,而是逐字节消费,天然处理了粘包问题——如果来了两个帧连在一起,解析完第一帧后状态回到 0,继续从帧头开始匹配第二帧。同时丢失中间字节导致帧头错位时,状态机会自动丢弃无效字节,直到重新匹配到合法的0xAA 0x55。这个解析逻辑我建议直接放在主循环里调用,不放进串口中断,因为解析本身有分支跳转,放进中断会拉长中断响应时间。
4. 六轴机械臂运动控制:逆运动学、舵机映射与轨迹插补
4.1 六轴机械臂的 D-H 模型与逆解思路
市面上的六轴桌面机械臂,结构和参数大同小异:底座旋转轴、肩部轴、肘部轴、三个腕部轴。控制链路里最关键的是从目标物块坐标求出六个关节角度。这个过程就是逆运动学。对于六轴机械臂,逆解有解析法和数值法两条路。解析法需要根据 D-H 参数表直接推导公式,计算快但推导过程繁琐且容易出错;数值法用雅可比矩阵迭代逼近,通用性强但在奇异点附近会发散。
我推荐的思路是:用解析法求前三个大臂关节角,用数值法或者直接固定角度来处理腕部三个关节。因为分拣任务里物块姿态是固定的——都是平放在桌面上,腕部只需要保持一个固定姿势即可,真正需要解算的只有底座旋转角、肩关节角和肘关节角这三个自由度。
// 简化的三自由度逆解,输入为目标坐标 (x, y, z),输出 base, shoulder, elbow 角度 void inverse_kinematics(float x, float y, float z, float *base, float *shoulder, float *elbow) { float l1 = 120.0f; // 肩部到肘部的长度,单位 mm float l2 = 110.0f; // 肘部到腕部的长度 float r = sqrtf(x * x + y * y); float s = z - 85.0f; // 底座高度补偿,具体值取决于机械臂装配尺寸 *base = atan2f(y, x) * 180.0f / PI; float cos_elbow = (r * r + s * s - l1 * l1 - l2 * l2) / (2.0f * l1 * l2); if (cos_elbow > 1.0f) cos_elbow = 1.0f; if (cos_elbow < -1.0f) cos_elbow = -1.0f; *elbow = acosf(cos_elbow) * 180.0f / PI; float alpha = atan2f(s, r); float beta = atan2f(l2 * sinf(*elbow * PI / 180.0f), l1 + l2 * cosf(*elbow * PI / 180.0f)); *shoulder = (alpha - beta) * 180.0f / PI; }这段代码默认机械臂工作在一个垂直平面内,底座旋转决定了这个平面朝向。注意r是目标点在水平面上的投影长度,如果目标点在机械臂可达半径之外,cos_elbow会超出正负 1 的范围,代码里做了截断处理,但截断之后机械臂会走一个极限姿态,这个姿态下抓取容易抖动。我一般会在调用逆解前先做一次可达性判断,超出范围直接返回一个错误码。
4.2 舵机角度映射与 PWM 波形配置
六轴机械臂使用舵机驱动,控制本质是输出 50Hz 的 PWM 信号,通过脉宽变化控制舵机角度。常见的舵机脉宽范围是 500us 到 2500us,对应 0 到 180 度。stm32 的通用定时器可以输出多路 PWM,配置时要注意定时器的时钟频率和预分频系数。
void servo_init(void) { TIM_TimeBaseInitTypeDef TIM_InitStruct; TIM_OCInitTypeDef OC_InitStruct; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); TIM_InitStruct.TIM_Prescaler = 168 - 1; // 72MHz / 168 = 约 428.5kHz TIM_InitStruct.TIM_Period = 2000 - 1; // 20ms 周期,50Hz TIM_TimeBaseInit(TIM3, &TIM_InitStruct); OC_InitStruct.TIM_OCMode = TIM_OCMode_PWM1; OC_InitStruct.TIM_OutputState = TIM_OutputState_Enable; OC_InitStruct.TIM_Pulse = 50; // 初始脉宽 500us,对应 0 度附近 TIM_OC1Init(TIM3, &OC_InitStruct); TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_Cmd(TIM3, ENABLE); } void set_servo_angle(TIM_TypeDef *tim, uint8_t channel, float angle) { float pulse_us = 500.0f + angle * 2000.0f / 180.0f; uint16_t cnt = (uint16_t)(pulse_us * 2.0f); // 每计数一次对应 0.5us // 根据通道调用 TIM_SetCompare1/2/3/4 }PWM 频率固定为 50Hz,周期 20ms。预分频设置成 168 之后,定时器计数频率是 72MHz / 168 = 428.5kHz,一个计数周期约 2.33us,和舵机脉宽的最小分辨率匹配。TIM_Period = 2000时,一个周期是 2000 次计数,20ms,频率正好是 50Hz。set_servo_angle里的换算关系是:0 度对应 500us 脉宽,180 度对应 2500us,脉宽每增加 2000us 对应 180 度,所以 1 度对应约 11.1us。实际舵机在中位附近线性度最好,两端会有死区,映射时留出 5 度的余量能避免舵机堵转发热。
4.3 轨迹插补:让机械臂运动平滑不抖动
机械臂从上一点直接跳变到目标角度,舵机会受到很大的加速度冲击,表现就是末端抖动,物块在夹爪里被甩飞。解决办法是插补,把一段角度差拆成多个小步。常见的做法是梯形速度规划:加速段、匀速段、减速段。
void move_servo_smooth(TIM_TypeDef *tim, uint8_t channel, float start_angle, float target_angle, uint16_t steps) { for (uint16_t i = 0; i <= steps; i++) { float t = (float)i / (float)steps; float angle = start_angle + (target_angle - start_angle) * t; set_servo_angle(tim, channel, angle); delay_ms(10); } }这种线性插补实现简单,但起点和终点加速度突变仍然存在。稍微优化一点的做法是在首尾各留 10% 的步数做缓启动和缓停止,中间 80% 线性过渡。实际项目里六轴联动时,每个关节的插补步数要一致,否则某一个关节先到位、其他关节还在运动,机械臂轨迹会偏。更精细的做法是每个关节都按同样的总时间约束计算插补表,但这在 8 位或者 M3 内核上计算量偏大。我一般用查表法,把正弦曲线的时间因子预先算好存到数组中,运行时直接查表,代价是几十字节的 Flash 空间,换来的是流畅度明显提升。
5. 物块分放策略与坐标标定:误差补偿、滤波进阶与验证方法
5.1 手眼标定的简化方法:三点标定转坐标映射
OpenMV 返回的坐标是像素坐标,机械臂需要的坐标是基座坐标系下的毫米坐标。在要求不高的桌面场景里,不需要完整的手眼标定矩阵,用三点标定就够:把物块分别放在机械臂可达区域内的三个已知物理坐标点,记录对应的像素坐标,求解一个仿射变换矩阵。
// 三点标定求解 2x3 仿射矩阵的简化实现 void calibration_calc(float pixel[3][2], float world[3][2], float mat[2][3]) { float denom = (pixel[1][0] - pixel[0][0]) * (pixel[2][1] - pixel[0][1]) - (pixel[2][0] - pixel[0][0]) * (pixel[1][1] - pixel[0][1]); mat[0][0] = ((world[1][0] - world[0][0]) * (pixel[2][1] - pixel[0][1]) - (world[2][0] - world[0][0]) * (pixel[1][1] - pixel[0][1])) / denom; mat[0][1] = ((pixel[1][0] - pixel[0][0]) * (world[2][0] - world[0][0]) - (pixel[2][0] - pixel[0][0]) * (world[1][0] - world[0][0])) / denom; mat[0][2] = world[0][0] - mat[0][0] * pixel[0][0] - mat[0][1] * pixel[0][1]; // y 方向同理,这里省略 }三点标定要求三个点不能共线,且尽量覆盖整个抓取区域,这样插值误差最小。标定完成后,OpenMV 识别出的像素坐标经由这个矩阵变换成毫米坐标,直接喂给逆解函数。实际的标定流程是:用上位机手动控制机械臂末端移动到三个已知位置,记下坐标,再在对应像素位置放上物块,读取 OpenMV 的像素坐标,录入标定参数。整个过程约 5 分钟。
这个简化方法忽略了一个事实:物块平放在桌面上,高度固定,所以 2D 映射足够。如果物块有不同高度,或者机械臂需要抓取竖直摆放的物块,就得引入高度维度的标定,这时候需要增加标定点数量,使用最小二乘法求解超定方程组。
5.2 串口数据的滤波与防抖
OpenMV 的识别结果不是每一帧都稳定。物块表面有污渍、光照微小波动时,像素坐标可能产生 ±5 像素的抖动。换算成毫米坐标后放大到 ±2mm 左右,这个误差对舵机驱动的机械臂来说是可以接受的,但有时会出现某一帧误识别,导致机械臂执行一次错误的动作。我使用两个策略:第一,连续 3 帧内坐标变化小于设定阈值才认为稳定;第二,超过 10 帧没有识别到目标物块时,机械臂回到待机位并重新进入扫描状态。
uint8_t stable_count = 0; int16_t last_x = 0, last_y = 0; int8_t require_stable_position(int16_t x, int16_t y) { if (abs(x - last_x) < 8 && abs(y - last_y) < 8) { stable_count++; if (stable_count >= 3) return 1; // 位置稳定,可以执行动作 } else { stable_count = 0; } last_x = x; last_y = y; return 0; }这个逻辑放在 stm32 端执行,因为 OpenMV 自己如果要连续帧判断,代码会更长且影响帧率。stm32 在收到一帧数据后先更新坐标缓冲区,每一帧都在主循环里调用require_stable_position,满足条件才进入逆解和轨迹规划流程。
5.3 放置精度的验证与补偿
机械臂抓取物块后放置到目标分放区,最常见的误差来源有两个:舵机角度映射误差和夹爪中心偏移。舵机角度映射误差可以通过实测校准——让机械臂分别转到 0 度、90 度、180 度,用角度尺测量实际角度,修正set_servo_angle里的斜率。夹爪中心偏移则需要在夹爪上装一个小探针,手动控制机械臂使探针接触已知点,测出探针末端相对腕部坐标系的位置偏移量,在逆解输出末端坐标时把这部分偏移补偿进去。
放置误差验证的方法是分段进行的,先目测,再用游标卡尺测量。我通常分三步调试:第一步是在 OpenMV 识别到物块之后,机械臂先移动到物块正上方 20mm 位置停下来,人眼观察是否对齐;第二步是闭合夹爪,慢速提起 30mm,观察物块是否从夹爪中脱落;第三步才是放行到分放区。如果夹爪闭合后物块有侧偏,优先检查的是夹爪的机械间隙,而不是程序参数,程序补偿治标不治本。
追踪坐标误差时用一组离散数据记录标定结果更高效,比如记录五个已知点的实际到达位置与理论位置的偏差值。最后用这几组偏差值做一次线性回归补偿,得到每个关节的修正系数。经过一轮修正后,放置精度能稳定在 ±3mm 以内。
5.4 一套可以上手的验证流程
写完代码之后,我建议用一个固定顺序跑通全链路:先在桌面上放三个不同颜色的物块,把它们放置到已知坐标,用 OpenMV 识别并输出像素坐标;然后手动执行 stm32 的标定模式,写入对应的世界坐标;完成标定后,打乱物块位置,让系统自行识别、抓取、分放,统计一次分放的成功率。如果某个颜色频繁误判,回到 OpenMV IDE 里查看当前帧图像的实际 LAB 直方图,而不是凭空调阈值。这样的调试闭环,比逐个模块单独验证更快找到问题——毕竟颜色识别和机械臂控制单独都能跑,合在一起的问题大多出在坐标变换和时序配合上。
本文还有配套的精品资源,点击获取