
简介基于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(time2000) 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_threshold100, area_threshold100) if blobs: # 取面积最大的色块避免环境中的小噪点干扰 blob max(blobs, keylambda 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, keylambda 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(time1000) 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可以传入familiesimage.TAG36H11这样能减少误识别率。如果画面中同时出现多个 AprilTag遍历tags时同样可以按面积或z_translation排序选取最近的一个作为追踪目标。2.4 识别结果如何组织成上位机需要的数据OpenMV 端最终需要发给 STM32 的不是整张图片而是几个有意义的数值。下表总结了云台追踪会用到的字段。数据项来源范围/单位用途tag_idtag.id()0 255区分多个标签x_normcx / width - 0.5-0.5 0.5目标偏左还是偏右y_normcy / height - 0.5-0.5 0.5目标偏上还是偏下z_mmtag.z_translation()毫米距离估算用于丢失判定lost检测不到 tag0 / 1目标丢失标志因为 OpenMV 的 MicroPython 浮点数在串口传输时比较麻烦常见做法是先把x_norm和y_norm乘以 1000转换成整数再打包STM32 端收到后再除以 1000 还原。这样既能避免浮点数在内存中的表示差异也能减少串口传输字节数。下一章要处理的就是这两个“归一化偏差”如何变成舵机角度增量。3. STM32 云台追踪归一化偏差、角度限幅与增量式 PD3.1 为什么不能直接把像素坐标给舵机OpenMV 返回的cx和cy是像素坐标在 QVGA 下范围是 0320 和 0240。如果直接把这两个值当作舵机角度目标分辨率不一致会带来两个问题一是每个像素对应的角度步进不均匀二是当 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 -x1error_y -y1。云台需要向相反方向修正所以控制信号取负号。3.2 二自由度云台的舵机角度映射项目说明里写得很明确下面舵机控制偏航角机械转角范围 0180 度0 度朝向右侧180 度朝向左侧上面舵机控制俯仰角范围 90180 度90 度平台水平180 度垂直。舵机控制轴角度范围角度对应关系下方舵机Yaw偏航0 1800 度向右180 度向左上方舵机Pitch俯仰90 18090 度水平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 度对应 500us180 度对应 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 / SPIOpenMV 和 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 字节。字节内容说明00xAA帧头110x55帧头22tag_id标签 ID3 ~ 4x_int归一化偏差乘以 1000int16 小端5 ~ 6y_int归一化偏差乘以 1000int16 小端7z_mm距离取整到 0~2558checksum前 8 字节累加和取低 8 位OpenMV 端用ustruct.pack打包发送import pyb, ustruct uart pyb.UART(3, 115200, timeout_char1000) def send_track_frame(tag_id, x_norm, y_norm, z_mm): xi int(x_norm * 1000) # 归一化偏差转为 int16 yi int(y_norm * 1000) # BhhB 表示小端uint8int16int16uint8 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_us20000)自动曝光在室内日光灯下频率很高会导致目标亮度忽明忽暗颜色阈值经常失效。固定曝光后再重新用 OpenMV IDE 的阈值编辑器采集目标颜色。AprilTag 对曝光不敏感但分辨率太高会让单帧处理时间超过 100ms云台看起来会“抽筋”。QQVGA 160x120 一般是响应速度和识别距离的折中方案。5.3 PD 参数整定策略现象可能原因调整方向云台持续快速抖动kp过大或kd过小减小kp增大kd目标移动时明显滞后kp过小或目标距离太远增大kp减小kd到中心点附近来回过冲kd不足以抑制速度变化增大kd目标丢失后舵机猛甩没有做丢帧超时处理在主循环加 200ms 超时建议从kp 0.5kd 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是累计结果外部截断会让下一步增量在错误基础上继续累加云台会在边界附近反复抖动。本文还有配套的精品资源点击获取