简介:2023年全国大学生电子设计竞赛E题视觉部分代码,面向参加电赛的选手及嵌入式视觉开发者,基于Sipeed Maix Bit开发板实现视觉识别任务,并与STC32G主控配合完成云台控制。压缩包共352个文件,约19.47MB,其中包含大量c/h源文件、uvproj工程文件、hex固件、lst/obj编译中间文件,另有py脚本、PDF说明及md文档,覆盖完整开发与调试链路。代码提供图像采集、预处理、特征提取、目标检测与跟踪等关键函数,并集成颜色识别、物体分类、距离测量等实战案例,可快速搭建稳定视觉系统。资源同时包含主机与从机云台代码以及主控工程文件,示例工程可编译烧录,目录按视觉模块、云台模块、主控模块划分,便于理解整体系统架构。目前已有616人学习下载,适合需要完整参考电赛E题视觉部分的团队或个人,新手可参照注释与工程组织快速入门,进阶选手也可修改算法参数应对不同赛题变化。 2023年电赛E题(运动目标控制与自动追踪系统)到目前为止依然是很多准备参赛的同学必刷的一道经典题。我当年带队做这套题的时候,视觉部分前前后后改了三版方案,从OpenMV到树莓派再到纯OpenCV处理,踩过的坑比想象中多得多。这篇文章就把视觉部分的核心代码思路、坐标解算逻辑和PID联动完全拆开讲一遍,适合正在备赛、或想搞懂“视觉引导云台追踪”背后原理的同学参考。
整个E题的视觉任务,说穿了就一件事:用摄像头找到红色激光笔打出的光斑,算出它相对画面中心的位置偏移,然后把偏移量转成云台的修正角度,让摄像头始终盯住那个点。听起来简单,做起来涉及颜色空间选取、阈值标定、坐标变换、云台响应特性匹配和PID调参,任何一个环节出问题都会导致“识别到了但追不上”的尴尬局面。
1. 项目概述与核心需求拆解
1.1 E题到底考什么:视觉部分的任务边界
先明确一下题目里视觉部分的完整边界。2023年E题要求设计一个运动目标控制与自动追踪系统,用红色激光笔模拟运动目标,用摄像头和云台构成追踪装置,实现自动识别并追踪激光光斑。视觉部分负责的是“看”和“算”两个环节——识别光斑位置、输出控制量;执行机构是二自由度云台,通常是两个舵机分别控制水平(Yaw)和俯仰(Pitch)方向。
很多第一次参赛的同学容易把精力全放在OpenMV的色块识别上,觉得能框出激光点就算完事了。但真正上电实测后发现,光斑识别只是最基础的一步,后面还有一堆工程问题在等着:画面延迟、云台跟随滞后、阈值随环境光漂移、边缘区域识别跳动等等。视觉代码的真正难点不在“能不能识别”,而在“识别得稳不稳、算得快不快、跟得准不准”。
1.2 镜头选型和方案路线的关键决策
视觉方案的路线选择直接影响代码写法和控制效果,这里有个很重要的判断点。我见过两种主流方案:第一种是OpenMV H7 Plus + 云台直连,全部逻辑写在MicroPython里;第二种是树莓派/电脑 + OpenCV + USB摄像头,通过串口给下位机发指令。两种我都完整跑通过,客观地做个对比:
| 对比维度 | OpenMV方案 | 树莓派+OpenCV方案 |
|---|---|---|
| 开发难度 | 低,IDE自带阈值调试工具 | 高,需要装环境、调驱动 |
| 启动时间 | 秒级,上电即用 | 树莓派需要十几秒启动 |
| 帧率表现 | 640x480下约30fps | 视摄像头而定,通常更高 |
| 功耗 | 极低,移动方便 | 高,需要稳定供电 |
| 抗干扰 | 需自行处理阈值漂移 | 可上复杂算法,容错更强 |
| 比赛现场风险 | 低,出问题好排查 | 高,环境配置容易翻车 |
就E题来说,我的建议是优先选OpenMV。比赛现场时间紧张,OpenMV的IDE里有一个非常关键的阈值调试工具,可以实时调颜色阈值、实时看二值化效果,这个优势在调试阶段实在太宝贵了。树莓派方案更适合做扩展展示,比如加深度学习模型识别更复杂的目标,但对E题这种单色光斑追踪场景属于杀鸡用牛刀。
1.3 影响视觉代码效果的核心因素
在实际动手写代码之前,建议先把影响视觉追踪效果的几个核心因素理清楚。首先是摄像头安装位置,这直接决定视野范围和坐标映射关系。摄像头必须固定安装在云台顶部,与云台同步转动,否则云台一动摄像头画面就飞了,追踪无从谈起。其次是镜头焦距,太长的焦距视野窄但远处看得清,太短则反之。E题场地一般几米范围内,我试过几种镜头后觉得2.8mm到4mm焦距最合适,既能保证看到足够大的范围,近处的光斑也能看得很清楚。
另外还有一个大家容易忽略的因素——摄像头的曝光模式。激光笔光斑亮度极高,普通自动曝光模式会自动压低整体画面亮度,导致原本就不明显的背景细节全部丢失,有时候甚至把光斑周围的红色区域都压没了。我在调试中把OpenMV的自动曝光关掉,固定曝光时间在20~30ms左右,效果比默认设置稳定很多。
2. 视觉识别核心思路与坐标系解算
2.1 激光斑点的识别逻辑与颜色空间选择
视觉识别的核心,本质上是“从图像里找出符合颜色特征且大小合适的连通域”。很多人一开始习惯直接在RGB颜色空间下写阈值,比如红色就写R>200、G<100、B<100。这种做法在小房间里、固定灯光下确实能跑,但只要阳光从窗户照进来一点,或者场地灯光换了一组,阈值马上就废了。
更稳的做法是切到LAB颜色空间。Lab颜色空间的L通道只管亮度,A通道管红绿方向,B通道管黄蓝方向,把亮度和颜色信息拆开后,识别红色光斑时主要盯着A通道看就行了,受环境亮度变化的影响会小很多。OpenMV的find_blobs函数直接支持LAB阈值,阈值配置是一个六元组(L_min, L_max, A_min, A_max, B_min, B_max),其中A通道的值对红色识别最敏感,一般激光笔的红色在A通道上的值远高于其它物体,设置A_min在40~60之间就能取得不错的效果。
识别到色块之后不要直接拿最大色块当目标,还要加额外的过滤条件。激光笔光斑的特点是:像素面积适中(太大会被误判成大块红色物体,太小可能是噪点)、圆形度高(用blob.roundness()判断)、且中心接近光斑能量中心。我在代码里用了一个面积上下限加圆形度的复合过滤条件,实测比单纯取最大色块误识别率低了很多,尤其是场地里如果有别的红色物体时这个过滤非常关键。
2.2 从像素坐标到机身偏转角度的换算方法
这一步是整个视觉代码里最容易被忽视、但又最影响追踪精度的环节。摄像头看到的是一帧图像,光斑在图像里有个像素坐标(cx, cy),但云台转动需要的是一个角度增量,怎么把像素偏移映射成角度偏移是必须做的标定工作。
最粗糙的做法是直接拿像素误差乘以一个常数,相当于把像素坐标到角度的关系当成线性映射。这个方法在光斑靠近画面中心时误差不大,但到了画面边缘就会明显追不准,原因是镜头本身存在畸变,边缘区域的像素-角度映射关系和非线性畸变叠加在一起,线性映射的误差会被放大。
更靠谱的做法是在场地里做一次三点标定:把激光笔固定在场地左侧、中心、右侧三个位置,记下光斑在画面里的像素坐标X1、X2、X3,同时记下云台需要转动的实际角度θ1、θ2、θ3,用这组数据拟合出像素X到角度θ的标定关系,得到一个比例系数k,再结合实际焦距做一次修正。做俯仰方向时用同样的方法,上下取三个点。比赛场景下这个工作一般只需要花半小时,但对追踪精度的提升非常显著。
2.3 为什么很多人卡在“识别到了但追不准”
我在做技术支持和评审答辩时见过太多队伍,代码里的颜色识别没问题,画面上能清晰地看到光斑被框出来,但云台就是追不上。总结下来原因基本逃不出下面三个:
第一是坐标解算没有做标定,直接用固定映射参数,光斑一偏到边缘就超调。第二是PID参数没调好,P过大导致云台左右振荡,过小又跟在后面慢悠悠地追。第三是舵机本身的响应速度不够,某些便宜的模拟舵机响应延迟几十毫秒,加上视觉处理本身二三十毫秒的画面延迟,两级延迟叠加起来云台的动作永远是慢半拍。
要解决“追得准”的问题,只能从标定、PID、舵机三个方向同时下手。标定负责把位置算准,PID负责把速度调稳,舵机负责把动作做实,三件事缺一不可。后面我会对每个环节的代码实现和调试细节展开说明。
3. 代码实现与核心环节拆解
3.1 OpenMV主控下的完整视觉代码框架
先给出一套我实测稳定的OpenMV视觉代码框架,这套框架的基本结构可以直接套用,后面再针对不同环节做优化。代码主要包含三个部分:初始化设置、主循环识别解算、串口发送控制量。
import sensor import image import math import time from pyb import UART # 初始化摄像头 sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) # 320x240,兼顾分辨率和速度 sensor.skip_frames(time=2000) sensor.set_auto_gain(False) # 关闭自动增益 sensor.set_auto_whitebal(False) # 关闭自动白平衡 sensor.set_auto_exposure(False, exposure_us=25000) # 固定曝光 # 串口初始化,波特率115200 uart = UART(3, 115200, timeout_char=1000) # LAB阈值,需要根据实际环境用IDE调试工具获取 red_threshold = (30, 80, 40, 70, 20, 60) # 主循环 while True: img = sensor.snapshot() blobs = img.find_blobs( [red_threshold], pixels_threshold=30, # 面积下限,过滤小噪点 area_threshold=30, # 面积下限,过滤小噪点 merge=True, # 合并重叠区域 margin=5 ) target_blob = None max_area = 0 for blob in blobs: # 过滤条件:面积合适且形状接近圆形 if blob.area() > 800 or blob.area() < 30: continue if blob.roundness() < 0.3: continue if blob.area() > max_area: max_area = blob.area() target_blob = blob if target_blob: cx = target_blob.cx() cy = target_blob.cy() # 将像素坐标映射为角度增量,映射系数需要提前标定 delta_yaw = (cx - img.width() // 2) * 0.12 delta_pitch = (cy - img.height() // 2) * 0.12 # 包装成串口报文,格式 yaw,pitch\n msg = "{:.1f},{:.1f}\n".format(delta_yaw, delta_pitch) uart.write(msg) # 绘制光斑矩形框,方便调试 img.draw_rectangle(target_blob.rect(), color=(0, 255, 0)) img.draw_cross(cx, cy, color=(0, 255, 0))这段代码里的red_threshold必须根据实际场地重新标定,不能照搬。很多开源项目里给了现成的阈值,但场地灯光的色温和亮度不同,同一个阈值换一个地方效果完全不一样,这也是我认为现场调阈值的能力比抄代码重要得多。
3.2 阈值标定的实操方法
OpenMV IDE里自带一个阈值编辑器工具(Tools -> Threshold Editor),用起来很简单但有几个使用窍门。打开后连接摄像头,画面实时显示三通道直方图,把红色激光笔放在画面正中间,调节LAB的六个滑条让二值化视图里“只有光斑是白色”,这个描述很直观,但由于激光笔光斑的核心区域非常亮,高光中心在LAB阈值下容易发白(L值很高),导致识别时只有整个光斑外圈被识别进来,中心反而被当成背景丢掉。
解决这个问题的办法有两种:一种是在阈值编辑器里增加一个“忽略L通道”的选项,只按A和B通道来二值化;另一种是在代码里用两个阈值做联合识别,分别提取光斑中心亮区和外围红色区,然后取两个区域的并集或加权中心。我实际测试下来,后者的识别稳定性更好,特别是光斑在高反光表面(比如白纸、塑料板)上时,中心区域过曝严重,单纯一个阈值很容易把光斑识别成“空心圆”甚至漏检。
标定完阈值之后,最好在场地不同位置多测几组数据,确认同一个阈值在画面四角和中心位置都能稳定识别。如果发现画面边缘的光斑识别不稳定,优先怀疑是镜头暗角问题,这时候可以减小一档光圈或换用更大靶面的镜头,而不是反复调阈值。
3.3 PID控云台的联动逻辑
云台控制大方向上就是经典的位置式PID,Yaw和Pitch两个通道独立控制。视觉部分输出的是期望角度增量,舵机按照PID计算出的PWM量动作。PID的代码不难,难点在于调参和结合视觉延迟做补偿。
class PID: def __init__(self, kp=0.6, ki=0.05, kd=0.1, target=0): self.kp = kp self.ki = ki self.kd = kd self.target = target self.last_error = 0 self.integral = 0 def update(self, feedback, dt=0.02): error = self.target - feedback self.integral += error * dt derivative = (error - self.last_error) / dt output = self.kp * error + self.ki * self.integral + self.kd * derivative self.last_error = error return output在E题的场景下,Yaw和Pitch通道的机械特性、转动惯量不一样,所以最好两个通道分别给一组PID参数。初始值我一般设成kp=0.6,ki=0.05,kd=0.1,然后按“先P后D再I”的顺序调。调P的时候把D和I设成0,从小到大加P值,直到云台能接近目标但没有明显振荡;再加D抑制超调;最后加一点点I来消除因为舵机死区导致的静态误差。
这里有个很关键的坑:视觉处理本身有延迟,从摄像头取图到算出增量再到云台动作,整条链路延迟可能有三四十毫秒。PID在这种纯延迟系统里如果调得太激进,看起来就是持续振荡,怎么调都调不平。保守的做法是适当调低P值,让响应慢一点但稳定;激进的做法是增加“前馈补偿”,在目标速度方向上加一个额外的舵机输出,把云台预先往光斑运动方向推一把。E题要求追踪的是不规则运动的激光笔,前馈补偿做得好可以在动态追踪中拉开明显差距。
4. 实测过程中踩过的坑与排查技巧
4.1 环境光干扰:最难排查的低级错误
比赛现场往往是强灯光环境,激光笔光斑在墙上的亮度很高,但墙壁材质反射率不同,识别效果差异非常大。有次我们调试时发现光斑识别频率突然从30fps掉到10fps,排查了很久发现是因为灯管频闪和摄像头曝光时间不匹配,画面出现滚动亮暗条纹,导致光斑的边界抖动严重。解决方法是把曝光时间设置为灯管频闪周期的整数倍,或者加一块偏振片滤掉部分环境光反射。
还有一次在户外测试时,阳光直射下的红色地砖被误识别成了光斑,因为地砖的A通道值和激光笔的红色区域太接近了。最后是在颜色过滤之外加了形状过滤,地砖形状不规则,圆形度远低于光斑,加上这层过滤之后误识别问题就消失了。这些排查过程看起来琐碎,但每一条都是实际比赛中决定成败的细节。
4.2 云台回差与响应延迟
云台舵机的机械回差是另一个让人头疼的问题。便宜舵机里齿轮间隙大,朝左转和朝右转到达同一位置需要的PWM量可能差好几个单位。这个机械间隙在视觉系统里的表现就是:静止时光斑已经在画面中心了,但云台还在小幅度来回摆动,或者云台稳住后光斑总有一点像素偏差。
解决回差问题有几个办法。最简单的是在软件里做一个“死区”,当像素误差小于某个阈值(比如5个像素)时就不输出PWM调整,防止云台在目标附近反复微调;更进阶的做法是在PID输出上叠加一个方向判断,让系统只在同一个方向上逼近目标,避免来回切向。这个方向判断的实现是记录上一次误差的符号,如果当前误差与上一次同号,说明云台还在往正确的方向逼近,可以继续加大输出;如果符号变了,说明已经过冲了,需要反向修正。
4.3 几个容易被忽略的小细节
最后再说几个容易被忽略但真实影响效果的细节。第一个是OpenMV的固件版本,某些旧版本固件对find_blobs的边缘处理有已知bug,在固件更新日志里明确提到过。建议备赛前统一升级到最新稳定版,别在这种地方白白浪费时间排查。
第二个是串口通信的粘包问题。如果让OpenMV负责识别,下位机(STM32或Arduino)负责云台控制,两边串口通信时偶尔会出现一帧数据被拆成两段的情况。下位机解析串口消息时不要逐字节判断,用状态机或环形缓冲区做缓存,以换行符作为一帧数据的结束标志,能显著减少因为粘包丢包导致的异常抖动。
第三个是摄像头的安装方向和画面坐标系的方向问题。云台左右转动时,画面里光斑是往右移还是往左移,取决于摄像头安装时有没有旋转180度。很多团队调试半天调不通PID,最后发现坐标系方向搞反了,把正反馈当负反馈调,越调越抖。建议在代码里先打印一条识别信息,手动转动云台确认坐标方向,再开始调PID参数。
5. 基于OpenCV的扩展方案与部署建议
5.1 为什么后期我选择切换到OpenCV
虽然OpenMV在初赛阶段足够用,但如果你打算把E题的方案进一步扩展,比如参加更高一级的智能车竞赛,或者想在实际工程场景里部署视觉追踪系统,OpenCV是绕不开的。OpenCV的算法库比OpenMV的MicroPython生态丰富太多,比如可以用Hough检测圆形、用轮廓匹配识别更复杂的图案、用卡尔曼滤波预测目标运动轨迹,这些在OpenMV里实现起来很费劲,在OpenCV里都是现成函数。
E题后期我把识别部分切换到OpenCV后,最大的感受是调试效率高了很多,因为在电脑端可以直接用Matplotlib把识别结果可视化出来,所有中间过程一目了然。OpenMV只能看到一个窗口显示最终结果,想debug中间参数就得靠打印串口数据,效率低一些。
5.2 OpenCV方案下的激光斑点识别代码
用OpenCV做激光斑点识别核心逻辑和OpenMV差不多,但写法上有些区别。注意用HSV色彩空间,用cv2.inRange生成二值掩码,再用cv2.findContours提取轮廓。因为OpenCV能调用的库更多,可以顺手做一下高斯模糊、膨胀腐蚀等预处理,增强抗干扰能力。
import cv2 import numpy as np # 读取摄像头 cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) # HSV阈值,需要根据环境标定 lower_red = np.array([0, 100, 100]) upper_red = np.array([10, 255, 255]) while True: ret, frame = cap.read() if not ret: break hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) mask = cv2.inRange(hsv, lower_red, upper_red) mask = cv2.medianBlur(mask, 5) mask = cv2.dilate(mask, None, iterations=2) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for contour in contours: area = cv2.contourArea(contour) if area < 30: continue (x, y), radius = cv2.minEnclosingCircle(contour) circularity = 4 * np.pi * area / (cv2.arcLength(contour, True) ** 2) if circularity > 0.5: center = (int(x), int(y)) cv2.circle(frame, center, int(radius), (0, 255, 0), 2) cv2.circle(frame, center, 2, (0, 0, 255), -1) # 这里可以继续计算偏移并发送给下位机 cv2.imshow("frame", frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()OpenCV版本的HSV红色阈值要注意一个细节:红色在HSV里横跨0度和180度两端,需要用两个阈值区间合在一起才能完整覆盖。上面代码只用了一个[0,100,100]到[10,255,255]区间,会漏掉偏紫红或品红的激光笔颜色。更好的做法是同时加一个[170,100,100]到[180,255,255]区间,然后把两个掩码用或运算合并。
5.3 OpenCV方案的实际部署建议
如果决定用OpenCV方案,强烈建议在调试机上装好完整的Anaconda环境或虚拟环境,把所有依赖包版本提前冻结,确保比赛现场不会因为环境问题翻车。另外,OpenCV方案对摄像头的自动曝光处理也很重要,可以用cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0)关闭自动曝光,或者用cv2.createBackgroundSubtractorMOG2做背景建模,把静态背景里的红色干扰源消除掉。
我个人在实际操作中发现,OpenCV方案更适合“预测+追踪”的模式,因为可以很方便地引入卡尔曼滤波器。比赛时激光笔的运动是连续的,完全可以用卡尔曼滤波预测光斑下一帧可能出现的位置,这样即便某一帧光斑被遮挡或识别失败,云台也能按照预测轨迹继续运动,不会因为一帧识别失败就失控乱跳。这个功能在OpenMV上实现卡尔曼滤波很麻烦,但OpenCV的cv2.KalmanFilter接口直接用就行。
关于预测控制再多说一句,E题的评分标准里有一项是追踪稳定性,要求云台在追踪不规则运动目标时不能出现剧烈抖动。单纯靠PID硬跟,在目标急转弯时必然会有明显滞后甚至振荡。加了卡尔曼预测之后,相当于给云台提前“打了预防针”,响应会更平滑,这个优化在正式评分时非常占便宜。
最后再分享一个小技巧:不管用OpenMV还是OpenCV方案,记得在比赛前把激光笔的电池电量检查一下。激光笔电压下降之后,光斑亮度会变暗,识别阈值可能就不匹配了。我见过不止一支队伍在比赛现场因为激光笔没电导致识别失败,然后一脸蒙不知道问题出在哪,这属于典型的“代码没问题但硬件掉链子”的情况。备赛时多备几节电池,比多备几个备用代码方案更实在。
本文还有配套的精品资源,点击获取