简介:基于STM32与OpenMV的视觉云台追踪小车毕业设计项目,适合电子信息、自动化、计算机等专业学生用于毕设、课设或项目初期演示,也适合入门嵌入式视觉的开发者借鉴。项目实现物体识别与二自由度云台追踪,重点讲解AprilTag检测原理、目标坐标归一化处理及PD控制算法,附带源码和详细说明文档,代码均测试通过,答辩平均分94.5分,可放心二次开发。压缩包共246个文件,涵盖C/H源代码、编译中间文件(o/d/crf等)、烧录文件(hex/axf)、Python脚本及图片说明等,整体约9.44MB,目录结构清晰便于查阅。已有2140人学习使用,无论作为课程作业还是工程起步参考,都具备较高参考价值。
1. 视觉云台追踪小车:这不只是把摄像头装在舵机上
做机器人视觉追踪最容易被低估的是“让目标保持在视野中央”这件事。你把 OpenMV 和 STM32 用串口接起来,看起来只是发坐标、转舵机,实际跑起来会发现:摄像头反馈滞后、舵机抖、目标丢失、串口数据错位,任何一个环节都能让小车变成原地转圈的摆设。这套基于 STM32 + OpenMV 的视觉云台追踪小车源码,把目标识别、坐标归一化、云台 PD 控制、串口协议全部串在一起,适合做毕业设计、课程设计或作为机器人视觉入门的完整参照。你的任务不是抄代码,而是搞清楚每个模块为什么这样设计,尤其是 OpenMV 输出的像素偏差如何变成舵机的角度增量。
很多人上来就调 PID,其实应该先从链路的两端验证起。下面按照“OpenMV 识别 → STM32 云台控制 → 串口通信 → 调参排错”的顺序拆开讲,每个环节都有能直接抄来跑的代码和参数,最后再说踩坑点。
2. OpenMV 端目标识别:颜色阈值、AprilTag 与 3D 坐标
2.1 为什么选纯色和 AprilTag,而不是训练神经网络
OpenMV 的本质是“带摄像头和 MicroPython 环境的嵌入式图像处理模块”,算力远不如树莓派或 Jetson。如果在这个场景里跑 YOLO,帧率会掉到 1FPS 以下,云台根本来不及响应。所以项目里选择两条路线:纯色物体识别用于快速验证逻辑,AprilTag 用于需要精确位置和姿态的追踪任务。
纯色识别本质是颜色空间阈值分割:把 RGB 图像转到 LAB 空间,然后根据目标颜色的 L、A、B 分量范围抠出区域,再用连通域算法得到色块的中心和面积。AprilTag 则是视觉基准系统,检测原理是先从图像梯度中提取边缘,在直线边缘上做邻接查找,如果四条边能形成闭环四边形,就尝试解码内部的黑白矩阵,最终输出标签 ID 和相对相机的三维位姿。OpenMV 已经把这两套流程封装成了find_blobs()和find_apriltags(),底层的梯度计算、边缘链跟踪、四边形筛选都在 C 层完成,MicroPython 层只需要做参数配置和结果读取。
2.2 用 find_blobs 识别纯色目标
先看纯色识别的完整代码。这里假设目标是红色小球,实际运行时需要先用 OpenMV IDE 的阈值编辑器采集目标在环境光下的 LAB 范围。
import sensor, image, time sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) # 320x240,识别速度和精度折中 sensor.skip_frames(time=2000) sensor.set_auto_whitebal(False) # 关掉自动白平衡,避免颜色漂移 # LAB 阈值,格式为 (L_min, L_max, A_min, A_max, B_min, B_max) red_threshold = (30, 100, 15, 127, 15, 127) while True: img = sensor.snapshot() blobs = img.find_blobs([red_threshold], pixels_threshold=100, area_threshold=100) if blobs: # 取面积最大的色块,避免环境中的小噪点干扰 blob = max(blobs, key=lambda b: b.area()) img.draw_rectangle(blob.rect()) img.draw_cross(blob.cx(), blob.cy()) # 归一化偏差:把像素坐标映射到 [-0.5, 0.5] x = blob.cx() / img.width() - 0.5 y = blob.cy() / img.height() - 0.5 print(x, y)这里几个参数要理解清楚。pixels_threshold和area_threshold都表示“至少包含多少个像素才认为是有效色块”,值太小会把背景噪声算成目标,值太大则远处的目标会被过滤掉。max(blobs, key=lambda b: b.area())是常见的多目标决策策略,因为云台追踪通常只关注最近的或最大的目标。sensor.set_auto_whitebal(False)必须加,尤其在有日光灯或窗户光源的室内,自动白平衡会让同一块红色在不同光照下颜色值不断变化,阈值就失效了。
2.3 AprilTag 识别:从四边形解码到三维位姿
AprilTag 的项目说明里提到,OpenMV 封装了img.find_apriltags(),返回的 tag 对象可以直接读取 ID 和三维坐标。实际代码比纯色识别更简短,因为它不需要手动配置颜色阈值。
import sensor, image sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) # AprilTag 用灰度图,处理速度更快 sensor.set_framesize(sensor.QQVGA) # 160x120,减少边缘检测计算量 sensor.skip_frames(time=1000) while True: img = sensor.snapshot() tags = img.find_apriltags() # 默认检测所有支持的 tag family for tag in tags: img.draw_rectangle(tag.rect()) img.draw_cross(tag.cx(), tag.cy()) print("id=%d, cx=%d, cy=%d, tx=%.1f, ty=%.1f, tz=%.1f" % ( tag.id(), tag.cx(), tag.cy(), tag.x_translation(), tag.y_translation(), tag.z_translation()))这里需要注意tag.x_translation()、tag.y_translation()、tag.z_translation()返回的是目标在相机坐标系中的相对位置,典型单位是毫米。z_translation可以理解为深度信息,也就是标签到相机的距离,但这个值受镜头畸变和标签尺寸影响,实际项目中不能直接当作测距仪用,更适合用来判断“目标是否在有效追踪范围内”。如果想识别特定的 tag family,可以传入families=image.TAG36H11,这样能减少误识别率。如果画面中同时出现多个 AprilTag,遍历tags时同样可以按面积或z_translation排序,选取最近的一个作为追踪目标。
2.4 识别结果如何组织成上位机需要的数据
OpenMV 端最终需要发给 STM32 的不是整张图片,而是几个有意义的数值。下表总结了云台追踪会用到的字段。
| 数据项 | 来源 | 范围/单位 | 用途 |
|---|---|---|---|
tag_id | tag.id() | 0 ~ 255 | 区分多个标签 |
x_norm | cx / width - 0.5 | -0.5 ~ 0.5 | 目标偏左还是偏右 |
y_norm | cy / height - 0.5 | -0.5 ~ 0.5 | 目标偏上还是偏下 |
z_mm | tag.z_translation() | 毫米 | 距离估算,用于丢失判定 |
lost | 检测不到 tag | 0 / 1 | 目标丢失标志 |
因为 OpenMV 的 MicroPython 浮点数在串口传输时比较麻烦,常见做法是先把x_norm和y_norm乘以 1000,转换成整数再打包,STM32 端收到后再除以 1000 还原。这样既能避免浮点数在内存中的表示差异,也能减少串口传输字节数。下一章要处理的,就是这两个“归一化偏差”如何变成舵机角度增量。
3. STM32 云台追踪:归一化偏差、角度限幅与增量式 PD
3.1 为什么不能直接把像素坐标给舵机
OpenMV 返回的cx和cy是像素坐标,在 QVGA 下范围是 0~320 和 0~240。如果直接把这两个值当作舵机角度目标,分辨率不一致会带来两个问题:一是每个像素对应的角度步进不均匀,二是当 OpenMV 分辨率改到 QQVGA 时,PID 参数全部要重调。
项目里采用的归一化公式是:
x1 = x / width - 0.5 y1 = y / height - 0.5x1和y1的范围都是 -0.5 ~ 0.5。目标在图像左侧时x1为负,右侧时为正;目标在图像上方时y1为负,下方时为正。这样 STM32 端只需要判断x1的正负就能知道云台该往哪个方向转。虽然 OpenMV 的图像坐标系原点是左上角,但归一化处理之后,原点位置只影响符号方向,不影响控制逻辑,只需在最终输出 PWM 时根据舵机安装方向做一次符号翻转。
误差计算也很直接:目标点是画面中心 (0, 0),当前偏差就是error_x = -x1,error_y = -y1。云台需要向相反方向修正,所以控制信号取负号。
3.2 二自由度云台的舵机角度映射
项目说明里写得很明确:下面舵机控制偏航角,机械转角范围 0~180 度,0 度朝向右侧,180 度朝向左侧;上面舵机控制俯仰角,范围 90~180 度,90 度平台水平,180 度垂直。
| 舵机 | 控制轴 | 角度范围 | 角度对应关系 |
|---|---|---|---|
| 下方舵机 | Yaw(偏航) | 0 ~ 180 | 0 度向右,180 度向左 |
| 上方舵机 | Pitch(俯仰) | 90 ~ 180 | 90 度水平,180 度垂直 |
角度映射的坑在于不同舵机的机械限位可能有差异。便宜的 SG90 / MG90 在标称 0 度和 180 度时,实际可能已经堵转或抖动。所以代码里要单独写一个限幅函数,避免 PD 输出越界。
#define YAW_MIN 0.0f #define YAW_MAX 180.0f #define PITCH_MIN 90.0f #define PITCH_MAX 180.0f float clamp_angle(float value, float lo, float hi) { if (value < lo) return lo; if (value > hi) return hi; return value; }clamp_angle是典型的安全保护逻辑,必须放在 PD 输出之后、PWM 比较值写入之前。很多失效问题都是因为角度已经超出物理范围,舵机还在疯狂发脉冲。
3.3 简化增量式 PD 控制算法
云台追踪用增量式 PD 而不是位置式 PID,原因有二:舵机本身是位置型输出,舵机角度已经是积分结果,再用 PID 的 I 项会产生重复积分导致过冲;增量式 PD 天然适合输出“下一步该转多少”,代码里也无需求累计误差。
简化版增量式 PD 可以这样实现:
typedef struct { float kp; float kd; float last_err; float out; } pd_ctrl_t; void pd_reset(pd_ctrl_t *pd) { pd->last_err = 0.0f; pd->out = 0.0f; } float pd_step(pd_ctrl_t *pd, float err) { // 误差差分项:本次误差减上次误差,相当于抑制快速变化 float d_err = err - pd->last_err; pd->last_err = err; // 增量式输出:在原有角度基础上增加一个校正量 pd->out += pd->kp * err + pd->kd * d_err; return pd->out; }这里的逻辑不同于位置式 PID。pd->out保存的是“当前目标角度”,每次调用pd_step()都根据误差和误差变化率计算一个角度增量,然后累加到out上。kp * err负责响应当前偏差,偏差越大转得越快;kd * d_err负责抑制偏差变化率,防止云台来回摆头。因为只有两项,参数整定比 PID 简单很多,适合舵机系统这种惯性较大的对象。
3.4 舵机 PWM 波形生成与角度限幅
STM32 控制舵机需要输出周期 20ms、脉宽 0.5ms ~ 2.5ms 的 PWM。以 STM32F103 的 TIM2 为例,APB1 时钟 72MHz,预分频设置为 71,则计数频率为 1MHz,自动重载设为 19999,得到 50Hz 周期。
void servo_pwm_init(void) { TIM_TimeBaseInitTypeDef time_base; TIM_OCInitTypeDef oc_config; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); time_base.TIM_Prescaler = 72 - 1; time_base.TIM_CounterMode = TIM_CounterMode_Up; time_base.TIM_Period = 20000 - 1; // 20ms 周期 time_base.TIM_ClockDivision = 0; TIM_TimeBaseInit(TIM2, &time_base); oc_config.TIM_OCMode = TIM_OCMode_PWM1; oc_config.TIM_OutputState = TIM_OutputState_Enable; oc_config.TIM_Pulse = 1500; // 默认 1.5ms,舵机处于中位 oc_config.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM2, &oc_config); // CH1 控制 Yaw TIM_OC2Init(TIM2, &oc_config); // CH2 控制 Pitch TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM2, TIM_OCPreload_Enable); TIM_Cmd(TIM2, ENABLE); }角度转脉宽的函数如下,核心是线性映射:
uint16_t angle_to_pulse(float angle) { if (angle < 0.0f) angle = 0.0f; if (angle > 180.0f) angle = 180.0f; // 0 度对应 500us,180 度对应 2500us return (uint16_t)((angle / 180.0f) * 2000.0f + 500.0f); }注意PWM1模式下,比较值越大,高电平时间越长。把angle_to_pulse(yaw_target)传入TIM_SetCompare1(),云台就能转到对应角度。当目标丢失时,不更新 PD 角度,只让云台保持上一次的位置;如果超过 2 秒没有有效帧,再把角度缓慢回中位,避免突然甩头。
4. 串口协议与双 MCU 协作:从 OpenMV 到 STM32 的完整链路
4.1 为什么用 UART 而不是 I2C / SPI
OpenMV 和 STM32 是两个独立的 MCU,它们之间只需要传几十字节的跟踪数据。UART 的优势是连接简单,只用 TX、RX、GND 三根线,波特率 115200 对 10 字节以内的数据帧来说完全够用。I2C 是半双工且需要地址协商,SPI 需要 CS、SCK、MOSI、MISO 四根线,对双 MCU 通信来说都是过度设计。
关键细节是共地。很多串口乱码的“玄学问题”,本质上是因为两块板子的 GND 没有接在一起,TTL 电平没有参考基准。另一个细节是 OpenMV 的 P4/P5 串口和 USB 串口不能同时使用,代码里要用pyb.UART(3, 115200)而不是直接print调试。
4.2 OpenMV 发送端:浮点转整数再打包
这里设计一个简单可靠的帧结构:帧头 2 字节,数据段 6 字节,校验和 1 字节。
| 字节 | 内容 | 说明 |
|---|---|---|
| 0 | 0xAA | 帧头1 |
| 1 | 0x55 | 帧头2 |
| 2 | tag_id | 标签 ID |
| 3 ~ 4 | x_int | 归一化偏差乘以 1000,int16 小端 |
| 5 ~ 6 | y_int | 归一化偏差乘以 1000,int16 小端 |
| 7 | z_mm | 距离,取整到 0~255 |
| 8 | checksum | 前 8 字节累加和取低 8 位 |
OpenMV 端用ustruct.pack打包发送:
import pyb, ustruct uart = pyb.UART(3, 115200, timeout_char=1000) def send_track_frame(tag_id, x_norm, y_norm, z_mm): xi = int(x_norm * 1000) # 归一化偏差转为 int16 yi = int(y_norm * 1000) # '<BhhB' 表示:小端,uint8,int16,int16,uint8 body = ustruct.pack('<BhhB', tag_id, xi, yi, z_mm & 0xFF) frame = b'\xAA\x55' + body frame += bytes([sum(frame) & 0xFF]) # 低 8 位校验和 uart.write(frame)<BhhB是 MicroPythonustruct的标准格式串,B表示无符号 8 位整数,h表示有符号 16 位整数。把x_norm乘以 1000 再转 int16,可以在不传浮点数的情况下保留 0.001 的精度,对云台控制绰绰有余。sum(frame) & 0xFF是累加校验,STM32 端收到后做同样的累加,不一致就整帧丢弃,避免数据错位导致云台乱跳。
4.3 STM32 接收端:状态机解析串口帧
STM32 端在串口中断里用状态机逐字节解析。状态机的目的是在字节流中可靠地找到帧头,然后积累数据段并校验。下面以标准外设库的 USART2 为例:
volatile uint8_t rx_buf[7]; // body 6 字节 + checksum 1 字节 volatile uint8_t rx_idx = 0; volatile uint8_t rx_state = 0; volatile uint8_t rx_frame_ready = 0; volatile uint8_t rx_tag_id; volatile int16_t rx_x_int; volatile int16_t rx_y_int; volatile uint8_t rx_z_mm; void USART2_IRQHandler(void) { if (USART_GetITStatus(USART2, USART_IT_RXNE) != RESET) { uint8_t b = USART_ReceiveData(USART2); switch (rx_state) { case 0: if (b == 0xAA) rx_state = 1; break; case 1: if (b == 0x55) { rx_state = 2; rx_idx = 0; } else { rx_state = 0; // 帧头不匹配,重新等待 } break; case 2: rx_buf[rx_idx++] = b; if (rx_idx >= 7) { uint8_t sum = 0; for (int i = 0; i < 6; i++) sum += rx_buf[i]; if (sum == rx_buf[6]) { rx_tag_id = rx_buf[0]; rx_x_int = rx_buf[1] | (rx_buf[2] << 8); rx_y_int = rx_buf[3] | (rx_buf[4] << 8); rx_z_mm = rx_buf[5]; rx_frame_ready = 1; } rx_state = 0; rx_idx = 0; } break; } } }这段代码的关键点在rx_x_int = rx_buf[1] | (rx_buf[2] << 8)。发送端用ustruct.pack('<BhhB')输出的是小端字节序,低位在前,所以接收端要把第一个字节放到低 8 位,第二个字节左移 8 位放到高 8 位,才能正确还原int16_t。
中断里只做字节收集和校验,不调用 PD 计算,也不更新 PWM。rx_frame_ready是一个标志位,主循环发现它变成 1 后,才把rx_x_int转成浮点运行控制算法。这样做的目的是保证串口中断服务函数尽可能短,避免影响其他中断响应。
4.4 主循环任务划分
主循环的控制流程如下:
while (1) { if (rx_frame_ready) { float x1 = (float)rx_x_int / 1000.0f; float y1 = (float)rx_y_int / 1000.0f; // 偏差为负值,控制量取反,让云台朝目标方向修正 float yaw_target = pd_step(&yaw_pd, -x1); float pitch_target = pd_step(&pitch_pd, -y1); yaw_target = clamp_angle(yaw_target, YAW_MIN, YAW_MAX); pitch_target = clamp_angle(pitch_target, PITCH_MIN, PITCH_MAX); TIM_SetCompare1(TIM2, angle_to_pulse(yaw_target)); TIM_SetCompare2(TIM2, angle_to_pulse(pitch_target)); rx_frame_ready = 0; } }要注意pd_step输出的out是角度目标值,不是 PWM 占空比。先做角度限幅,再转成脉宽,最后写比较寄存器。如果rx_frame_ready长期为 0,说明串口帧没进来,需要检查 OpenMV 的 TX 是否接到了 STM32 的 RX,以及两块板的 GND 是否相连。可以在主循环里加一个超时计数器,超过 200ms 未收到有效帧就把pitch_target缓慢拉回 120 度左右,保证云台不会停留在极限位置。
5. 调参顺序、验证方法与常见坑
5.1 先验证链路,再调 PD
拿到源码后不要急着改 Kp、Kd,先确认“OpenMV 的帧到底有没有到 STM32”。把 OpenMV 的 TX 接 USB 转 TTL 到电脑,波特率 115200,打开串口助手,应该能看到连续AA 55 ... 校验和这样的十六进制帧。如果收不到,先查代码里是不是用了pyb.UART(3),但接线却连到了 P4/P5 之外的引脚。STM32 端同理,可以暂时屏蔽状态机,直接在中断里把收到的第一个字节发回串口,看有没有回显。
5.2 曝光、白平衡与阈值
识别不稳定时,优先检查这两处:
sensor.set_auto_whitebal(False) sensor.set_auto_exposure(False, exposure_us=20000)自动曝光在室内日光灯下频率很高,会导致目标亮度忽明忽暗,颜色阈值经常失效。固定曝光后,再重新用 OpenMV IDE 的阈值编辑器采集目标颜色。AprilTag 对曝光不敏感,但分辨率太高会让单帧处理时间超过 100ms,云台看起来会“抽筋”。QQVGA 160x120 一般是响应速度和识别距离的折中方案。
5.3 PD 参数整定策略
| 现象 | 可能原因 | 调整方向 |
|---|---|---|
| 云台持续快速抖动 | kp过大,或kd过小 | 减小kp,增大kd |
| 目标移动时明显滞后 | kp过小,或目标距离太远 | 增大kp,减小kd |
| 到中心点附近来回过冲 | kd不足以抑制速度变化 | 增大kd |
| 目标丢失后舵机猛甩 | 没有做丢帧超时处理 | 在主循环加 200ms 超时 |
建议从kp = 0.5,kd = 0.1起步,让标签缓慢移动,观察TIM_SetCompare1的脉宽变化是否平滑。参数整定必须一次只调一个变量,否则抖动量变大时你分不清是kp还是kd的锅。
5.4 用角度打印验证控制方向
在 STM32 控制循环中加一条串口打印:
printf("yaw=%.1f pitch=%.1f\r\n", yaw_target, pitch_target);把标签从画面左侧移到右侧,观察yaw_target的变化方向。如果标签向右移动而yaw_target变大,说明控制符号反了,应该在 PD 输入处把-x1改成+x1。这个方向问题在实物上很容易被忽略,程序逻辑再正确,舵机装反了也会表现为“越追越偏”。如果你发现角度已经到了限幅值还在频繁更新 PWM,优先在angle_to_pulse里做限幅,而不是在 PD 输出端截断,因为增量式 PD 的out是累计结果,外部截断会让下一步增量在错误基础上继续累加,云台会在边界附近反复抖动。
本文还有配套的精品资源,点击获取