简介:一套面向2023年全国大学生电子设计竞赛E题的完整工程代码包,主控采用STM32F407VET6,搭配OpenMV作为视觉从机,适用于电子竞赛备赛、毕业设计、课程设计或工程实训等场景。代码涵盖STM32端PID控制、多级菜单调参逻辑、OpenMV图像识别与红绿光斑定位等模块,能够稳定实现基础部分1至3题,并为发挥部分提供初步支撑;描述中同时标注了红外出界、声光报警器误报等已知限制,也给出Release/Debug编译配置的注意点,便于使用者提前规避坑点。压缩包共680个文件,以C/H源码为主,配合IOC工程配置、Python视觉脚本、链接脚本及说明文档,整体约23.19MB,目录结构清晰完整,方便按模块检索。该资源已有3592人学习浏览,适合需要快速搭建赛题环境、对照源码理解系统运行的读者;拿到后可直接烧录验证,也可基于现有PID控制、OpenMV视觉识别与多级菜单架构继续优化改进。
1. 为什么 E 题的关键不是“识别”,而是“系统协同”
2023年全国大学生电子设计竞赛E题是“运动目标控制与追踪系统”,要求参赛者用摄像头识别场地中的运动靶标,再驱动二维云台上的激光笔完成瞄准与持续追踪。很多队伍拿到题的反应是“这不就是一个摄像头加两个舵机吗”,但真联调起来才发现:识别帧率、坐标映射、云台响应、激光光轴偏差,每个环节单独看都能跑,连在一起就变成了激光点在靶标周围画圈甚至追丢目标。这道题真正考察的不是某一项算法,而是从像素到角度、从角度到落点的完整闭环能力。本文从一个可复现的工程路径出发,从选型讲到延迟优化,适合备战国赛、做视觉伺服入门或想快速搭建追踪系统的人参考。
2. 系统拆解:主控、摄像头与云台怎么选才不会踩开发坑
2.1 主控选型:从 OpenMV K210 到树莓派的取舍
E题的规则允许使用任意嵌入式平台,但三天的备赛时间决定了选型不能太极端。常见方案有三类:OpenMV直接控舵机、K210跑视觉识别再配合单片机、树莓派跑OpenCV配合STM32实现双核分工。
| 平台 | 图像处理能力 | 云台控制 | 上手难度 | 适用场景 |
|---|---|---|---|---|
| OpenMV H7 | 色块/圆检测,IDE调试方便 | 可输出PWM | 低 | 快速原型验证 |
| K210 MaixPy | 硬件加速CNN | 串口外接控制 | 中 | 需要分类/检测模型 |
| 树莓派 4/5 | OpenCV全功能 | 串口/GPIO | 低-中 | 算法调参和联调 |
| STM32F103/H7 | 不适合OpenCV | 舵机/步进闭环稳定 | 中-高 | 运动控制核心 |
我个人推荐“视觉板 + 控制板”的双MCU架构。树莓派跑图像识别和坐标映射,通过串口把期望角度发给STM32,STM32以100Hz以上的频率维护舵机角度闭环。这样即使视觉处理只有十几帧,控制层依然能稳定输出PWM,不会因为摄像头丢帧导致云台忽快忽慢。如果只用OpenMV单板处理,图像采集和舵机中断容易互相抢占资源,追踪运动目标时会产生明显的卡顿。
选型还要看竞赛场地的标定需求。E题靶标通常是固定色块或小球,不需要深度学习模型的泛化能力,OpenCV的单应变换和颜色过滤完全够用,因此树莓派的开发效率比K210更高。
2.2 摄像头与云台执行结构:分辨率和舵机/步进的选择
摄像头分辨率不是越高越好。640x480 加上 30fps 是视觉追踪场景里比较平衡的选择,像素越多,图像传感器曝光时间越长,运动目标会出现拖影,反而增加识别误差。我一般会先用 Python 脚本测试不同曝光值下目标边缘的锐度,选定一个固定曝光后就不再开启自动曝光,因为自动曝光会在云台转动时剧烈变化,直接导致二值化阈值失效。
云台执行结构上,舵机比步进电机更适合这个小系统。步进电机重复定位精度高,但需要驱动器且高速扫描容易丢步,会引入额外的机械噪声。大扭矩数字舵机响应快,配合专用轴承云台架,控制简单。选舵机时不要只看“几 KG 扭矩”,要看空载速度是否在 0.1s/60° 以下,只有高速舵机才能在追踪快速目标时消减机械延迟。
2.3 电源设计:给激光笔和舵机独立供电
这是最容易在现场翻车的部分。舵机启动瞬间电流能到 1A 以上,激光笔和摄像头耗电不大,但如果共用 5V 电源,舵机一转,摄像头画面就会出现横纹干扰,严重时图像传输直接断开。
参考接线表如下:
| 设备 | 电压 | 最大电流 | 供电方式 |
|---|---|---|---|
| 树莓派/OpenMV | 5V | 500mA-1A | 独立 USB 或 LM2596 |
| 舵机 ×2 | 6V | 2A-3A | 独立 6V 电源模块 |
| 激光笔 | 5V | 50mA | MOS 管开关控制 |
| 摄像头 | 5V | 150mA | 与主控共用 |
在舵机电源入口并联一个 1000uF 电解电容,能有效吸收瞬时压降。另外,激光笔不能直接用 GPIO 驱动,要用 N-MOS 管做低边开关,避免激光笔的感性负载损坏单片机引脚。
void gimbal_selfcheck(void) { // 上电后让两个舵机从 60° 转到 120°,确认机械零位 for (uint16_t i = 600; i <= 1200; i += 5) { TIM1->CCR1 = i; // 1ms ~ 2ms 对应典型舵机角度 HAL_Delay(10); } printf("gimbal init done\r\n"); }这段初始化代码只做一件事:缓慢驱动舵机走一遍全行程,确保启动时没有堵转。参数说明里,CCR1的值是定时器比较寄存器,600 对应 1.2ms 高电平,1200 对应 2.4ms 高电平,实际角度范围需要根据舵机型号微调。不要在自检时使用 PID 控制,而是直接输出 PPM 脉宽,否则机械零点会累积偏移。
3. 图像识别与坐标映射:把“看到”变成“打到”的关键两步
3.1 靶标检测:从二值化到最小外接圆的稳定性处理
E题的靶标一般是黑色圆点或黑白同心圆,识别目标的关键是让二值化结果在各种光线下都稳定。我通常不直接用固定 RGB 阈值,而是转换到 LAB 颜色空间,用 b 通道来分离黑色目标。这样做的好处是光照变化时,b 通道的响应比灰度图更平稳。
下面是一段在树莓派上可以直接运行的检测代码:
import cv2 import numpy as np cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_EXPOSURE, -5) # 固定曝光,禁用自动曝光 def detect_targets(frame): lab = cv2.cvtColor(frame, cv2.COLOR_BGR2LAB) b = lab[:, :, 2] _, bw = cv2.threshold(b, 160, 255, cv2.THRESH_BINARY_INV) bw = cv2.erode(bw, np.ones((3, 3), np.uint8), iterations=1) cnts, _ = cv2.findContours(bw, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) targets = [] for cnt in cnts: area = cv2.contourArea(cnt) if area < 30: continue (cx, cy), radius = cv2.minEnclosingCircle(cnt) if 2 < radius < 100: targets.append((int(cx), int(cy), int(radius))) return targets while True: ret, frame = cap.read() if not ret: break pts = detect_targets(frame) for cx, cy, r in pts: cv2.circle(frame, (cx, cy), r, (0, 255, 0), 2) cv2.imshow("feed", frame) if cv2.waitKey(1) & 0xFF == ord('q'): break代码逻辑说明:先转 LAB,取 b 通道;然后使用固定阈值反相,让黑色圆点变成白色前景;腐蚀操作去掉图像传感器噪声和白色纸面上的细小杂质;轮廓查找后通过面积和半径过滤掉灯点和反光边缘。参数 160 和 30 不是固定值,需要在现场用一个带滑条的小程序标定。如果发现识别不稳定,优先调整曝光而不是调整阈值。
3.2 透视标定:用四点映射把摄像头像素坐标转成云台角度
摄像头不是垂直对着靶面,所以像素坐标和实际平面之间不是线性缩放,直接用一个比例系数会导致远离图像中心的位置误差变大。常见的做法是在靶面四个角放四个标记点,用单应矩阵把像素坐标映射到物理坐标。
示例代码:
import numpy as np import cv2 img_pts = np.array([[120, 80], [500, 60], [540, 420], [100, 460]], dtype=np.float32) world_pts = np.array([[0, 0], [60, 0], [60, 60], [0, 60]], dtype=np.float32) H, status = cv2.findHomography(img_pts, world_pts, cv2.RANSAC) def pixel_to_world(px, py): p = np.array([px, py, 1.0]) w = H @ p return w[0] / w[2], w[1] / w[2]这段代码的输入是四组红外点或反光贴纸的像素坐标与物理坐标。findHomography返回的矩阵可以把图像平面上任意点映射到靶面平面。单位使用厘米,和云台角度之间的换算通过“角度-平面坐标”标定曲线完成。实际操作时,我会先让云台停在零点,然后在屏幕上点击四个已知点,自动计算 H 矩阵,最后保存到文件,避免每次上电重新标定。
3.3 相机-激光笔同轴误差的补偿方法
即使把摄像头和激光笔并列安装在云台上,两者光轴也不可能完全重合,这会造成激光点相对于识别目标有一个固定偏移。最粗暴的补偿是在代码里加一个固定像素偏移,但不同角度下偏移量并不恒定。
更好的方法是在标定单应矩阵时,直接让激光笔打到靶面上的已知点,摄像头识别激光点位置而不是靶标中心。这样求出的 H 矩阵已经包含了相机和激光笔的机械轴偏差。如果激光点太暗,可以在激光笔前面加一片透明磨砂纸,让光斑半径扩大到 3~4 像素,方便摄像头稳定识别。
4. 云台随动控制:PID 参数、通信协议与平滑移动
4.1 控制回路与通信帧设计
在双 MCU 架构里,视觉板和运动控制板之间的通信协议需要简单可靠。我习惯使用串口 115200 或 921600 波特率,每帧 8 字节,格式如下:帧头 0xAA 0x55、偏航角高字节、偏航角低字节、俯仰角高字节、俯仰角低字节、命令字、校验和。
#include <stdint.h> typedef struct { int16_t yaw_ang; // 偏航角,单位 0.01° int16_t pitch_ang; // 俯仰角,单位 0.01° uint8_t cmd; // 0x01 瞄准 0x02 追踪 0x03 复位 } TrackCmd; int uart_parse_track(const uint8_t *buf, int len, TrackCmd *cmd) { if (len < 8 || buf[0] != 0xAA || buf[1] != 0x55) { return -1; } uint8_t sum = 0; for (int i = 0; i < 7; i++) sum += buf[i]; if (sum != buf[7]) { return -2; } cmd->yaw_ang = (buf[2] << 8) | buf[3]; cmd->pitch_ang = (buf[4] << 8) | buf[5]; cmd->cmd = buf[6]; return 0; }解析函数重点是校验和判断,避免串口半包或丢字节时把错误角度传给 PID。所有角度统一使用整数,0.01 度为单位,例如 1234 表示 12.34 度。浮点只在视觉板和控制板的内部运算中使用,通信链路只用整型,能减少很多隐蔽的字节序问题。
4.2 增量式 PID 在云台角度环上的实现
云台角度控制我推荐增量式 PID,它的输出是 PWM 增量,不需要记忆积分项,天然不会积分饱和。控制环频率放在 100Hz 即可,高于 200Hz 会让数字舵机产生持续啸叫和发热。
typedef struct { float kp, ki, kd; float dt; float prev_err; float out; // 当前增量输出 } IncPID; float inc_pid_update(IncPID *pid, float setpoint, float measure) { float err = setpoint - measure; float derr = (err - pid->prev_err) / pid->dt; pid->out = pid->kp * (err - pid->prev_err) + pid->ki * err * pid->dt + pid->kd * derr; pid->prev_err = err; return pid->out; }执行逻辑说明:主循环获取到目标角度后,调用inc_pid_update得到输出增量,叠加到当前 PWM 占空比上。如果云台已经接近目标角度,误差接近零,增量也会接近零。调试时先设ki=0,只调 P 让系统不振荡;再增加 D 抑制超调;最后加极小 I 消除静差。起步参数可以设为kp=1.2、ki=0.02、kd=0.3,但实际必须根据舵机响应和机械阻尼调整。
4.3 防抖动参数:死区、限幅、滤波
PID 会让云台追逐一个动态目标,但只要目标在激光点周围轻微移动,云台就会不停地修正,产生持续抖动。解决办法是在输出路径上加三个处理:
| 处理方式 | 典型参数 | 作用 |
|---|---|---|
| 误差死区 | 0.3° | 小于该误差不输出控制量 |
| 增量限幅 | PWM 最大步进 5% | 防止大角度跳变 |
| 一阶低通滤波 | alpha=0.6 | 平滑角度突变 |
float angle_filter = 0.6 * new_val + 0.4 * old_val;这个滤波公式不需要额外库,一行就能实现。alpha 越大响应越快但越容易波动,越小越平滑但延迟变长。实际测试中 0.6 是一个合理的折中,能有效过滤摄像头检测中心的抖动,同时不拖慢对运动目标的跟随。
5. 实战调参:出场数据、测试序列与常见坑的定位
5.1 目标定位不准?先用这 3 个实验隔离问题
如果激光点总是打不到目标,最忌讳直接调 PID,因为问题可能根本不在控制层。我通常按以下顺序做三个实验来定位问题:
实验一:摄像头固定不动,手动移动靶标,观察识别中心是否平滑跟随。如果识别点跳动超过 10 像素,问题在图像预处理或曝光设置。
实验二:云台手动输出固定角度,观察激光点是否落在预期的物理位置。如果偏差随角度变化,说明单应矩阵标定不充分。
实验三:给定一个阶跃角度,用示波器或串口记录激光点响应。如果出现剧烈振荡,说明控制参数过强。
5.2 追踪过程丢目标:动态窗口与轨迹预测
靶标运动速度稍快时,连续几帧找不到目标是正常现象。此时不能直接把云台停在原地,而应基于历史轨迹做线性外推。常用公式是:
dt = time_now - time_prev predicted_x = x_prev + (x_prev - x_prev2) * dt / last_dt predicted_y = y_prev + (y_prev - y_prev2) * dt / last_dt这段外推是基于当前速度继续往前预估 1~2 帧的位置,只适合短时间遮挡。如果遮挡超过 5 帧,线性外推会偏离实际轨迹,更好的方案是切换为搜索模式:云台按照上一点位置做小范围扫描,直到重新找到目标。我一般保存最近 5 帧坐标,用最小二乘拟合一条直线,外推时只预测一帧,避免过度依赖长时间趋势。
5.3 从识别到瞄准的延迟量化与减延迟技巧
整个追踪链路中,延迟主要来自摄像头曝光、图像传输、算法处理、串口发送、PID 计算、舵机响应这六部分。量化的方法很简单:用手机慢动作拍摄激光点,对比目标移动瞬间和激光点响应开始的帧数,换算成毫秒。
| 延迟来源 | 常见数值 | 优化手段 |
|---|---|---|
| 摄像头曝光时间 | 5-20ms | 固定曝光到 2ms 以内 |
| 图像传输 | 2-10ms | 使用 MJPG 输出 |
| 算法处理 | 5-40ms | 缩小 ROI 区域 |
| 串口传输 | 0.1-1ms | 波特率提到 921600 |
| 舵机响应 | 30-80ms | 换高速舵机 |
从表中可以看出,舵机响应是最大头。如果预算允许,电机选用响应速度在 0.06s/60° 以下的数字舵机,比优化算法更能降低实际落点滞后。图像处理上的优化则优先裁剪 ROI,只检测目标可能出现的区域,而不是全图。
6. 把测试自动化:用上位机记录坐标与回放
调参阶段最值得做的一件小事,是写一个简单的 Python 上位机,实时记录视觉板发送过来的目标坐标和云台角度。这样每一轮修改后都能通过数据对比判断是变好还是变坏,而不是凭感觉。
import serial import csv import time ser = serial.Serial('/dev/ttyUSB0', 115200, timeout=1) with open('track_log.csv', 'w', newline='') as f: writer = csv.writer(f) writer.writerow(['timestamp_ms', 'yaw_raw', 'pitch_raw']) start = time.time() while True: line = ser.readline().decode(errors='ignore').strip() if line.startswith('T'): _, yaw, pitch = line.split(',') writer.writerow([int((time.time() - start) * 1000), int(yaw), int(pitch)])这段代码从串口读取以T开头的数据行,例如T,1234,5678,把时间戳和角度写入 CSV 文件。回放时用 pandas 读取,直接画出云台角度曲线,能清晰看到每一帧的控制响应、超调量和稳态误差。我通常还会在代码里加一个开始标志,用一台电脑同时记录摄像头的视频和串口数据,方便赛后复盘哪一秒丢了目标。
如果需要快速验证追踪效果,可以做一个离线回放工具:将之前记录的坐标数据按原始时间间隔重新发送给云台控制板,看它能否重现当时的追踪轨迹。这样不需要摄像头和场地也能反复测试算法。注意,回放时必须保持相同的采样时间,否则控制结果不可比,建议用高分辨率计时器保证每次发送间隔一致。
本文还有配套的精品资源,点击获取