2023电赛E题视觉追踪系统:像素坐标、PID与串口协议全解析
2026/9/16 16:42:15 网站建设 项目流程

简介:2023年电赛E题完整资源包,面向电子信息类竞赛选手及嵌入式开发学习者,尤其适合课程设计、毕设或工程实训参考。资源共5个文件:两个zip压缩包分别存放主控程序与硬件驱动库,两个py脚本覆盖OpenMV视觉处理的初始化及主循环,便于理解摄像头采集、图像识别与串口通信的协作;一份PDF设计报告则系统说明总体方案、模块划分、关键代码和测试数据,方便完整复盘整个开发流程。压缩包总大小约23.67MB,代码、脚本、文档齐全,结构清晰,下载后可直接解压运行。目前已有412人学习浏览,所有源码均经博主测试验证,运行稳定;借助该资源,既可快速上手电赛E题中视觉识别与伺服控制的开发链路,也可在现有基础上扩展功能,满足不同阶段的创新需求。如有使用问题,可与博主直接沟通。

1. 2023电赛E题:把一套视觉追踪系统从 zip 包变成能跑、能调、能证明的工程

备赛 2023 年电赛 E 题的队伍,网盘里大概率都存过一份这样的 zip:解压出来见不到统一工程,只有 OpenMV 脚本、STM32 源码、几版阈值文档、零散的调参截图和答辩 PPT 半成品。E 题表面考的是“视觉追踪”,真正拉开差距的是从摄像头到云台这一整条链路怎么串起来:像素坐标能不能稳定换算成云台角度,角度能不能被舵机快速执行,丢帧时云台是保持还是乱扫。这三段里任何一段拍脑袋,最终激光落点都会在目标周围画圈。这篇按“先立原理、再跑最小代码、再调参数、最后排现场坑”的顺序,把 2023 电赛 E 题追踪系统的落地路径讲透,适合正在备赛的参赛队,也适合想快速复用一套视觉云台模板的嵌入式工程师。

2. 先从原理拆开E题:识别、坐标映射与云台执行的完整链路

2.1 为什么E题的追踪系统要拆成三层而不是两层

最直觉的写法是“看到目标偏右就向右打一点舵机”,这个回路能跑,但只能对付静止目标。E 题的目标运动路径是直线往返、变速甚至折线,纯像素差的跳跃式驱动会带来两个必然问题:第一,像素偏差和舵机角速度之间没有确定映射,速度没法控;第二,识别一旦丢帧,执行端不知道该惯性继续还是停在原地。所以常见工程拆法是把系统分成三层——视觉识别层、坐标映射层、执行控制层。识别层只回答“目标在哪”,输出目标中心的像素坐标、目标存在标志和一个置信度;映射层负责把像素偏差换算成云台应转到的角度;执行层只按角度驱动舵机,内部再叠 PID。

这个拆法也和赛题评分方式对得上。E 题最终看的是激光落点能否持续压住运动目标,本质是“角度准确度+动态响应”的叠加。三层之间用固定协议串口帧通信,每层都能单独调试:OpenMV 单独标定阈值,STM32 单独调舵机舵量,两边改完互不影响。后续几年只要赛题里出现目标追踪,这套三段式结构基本不会变,这也是我建议新手直接按这个框架搭而不是写裸循环的原因。

2.2 坐标映射的常见简化:像素偏差转角度增量

映射层要做的事不复杂,先看一个具体换算。假设摄像头画面中心为(cx, cy),目标中心为(tx, ty),得到像素偏差dx = tx - cxdy = ty - cy。镜头水平视场角记为FOV_H_DEG,图像宽度为IMG_W,那么每个像素对应的角度分辨率为:

# 像素偏差到目标角度的换算 FOV_H_DEG = 70.0 # 实际镜头的水平视场角,用尺子量或查镜头手册 IMG_W = 160 # QQVGA 宽度 deg_per_pixel = FOV_H_DEG / IMG_W k = 1.05 # 边缘畸变补偿系数,画面中心取 1.0,边缘略大于 1 target_yaw = current_yaw + dx * deg_per_pixel * k target_pitch = current_pitch + dy * deg_per_pixel * k

这段代码里的current_yawcurrent_pitch在多数队伍里没有真实角度反馈,直接取上一次的目标角度作为程序内当前角度,形成“程序内闭环”。这个假设很关键:舵机真实位置可能会滞后,但 E 题云台负载轻、速度要求不高,程序内角度闭环足够用。dx的符号要特别注意,OpenMV 图像坐标系原点在左上角,dx为正说明目标在画面中心右侧,云台应向右转,也就是yaw增大;dy为正说明目标偏下,pitch应向减小方向调整。

2.3 识别层到执行层的数据流与串口协议

层与层之间传递的数据不能只在内存里搬,E 题大多用串口把 OpenMV 和主控分开,所以帧格式要固定。推荐一帧 6 字节的简单协议,现场调试好用又不至于解析出错。

字段长度说明
帧头1 字节固定 0xA5
模式1 字节0=丢帧保持 1=正常跟随 2=低速巡扫
dx1 字节目标中心相对画面中心横向偏差,有符号
dy1 字节纵向偏差,有符号
置信度1 字节0~255,越大可信度越高

置信度这一段最容易被省略,它是整个系统稳定性的保险。光照变化、目标被遮挡时识别层要主动告诉执行层“我没把握”,执行层此时保持上一拍角度而不是追着噪声跑。少了这 1 个字节,丢帧时云台会朝错误方向猛转,赛后复盘几乎没法定位是识别问题还是执行问题。主控侧解析按帧头对齐,收到 0xA5 后再收 5 字节,不合法数据直接丢弃,避免串口错位导致整帧乱掉。

3. 解包工程后的最小代码骨架,先让识别跑起来

3.1 E题资料包最常见的目录组织

从各种渠道流出的 2023 电赛 E 题资料包,解压后目录结构大同小异,一般包含这几块:OpenMV 侧的入口脚本、阈值配置文件、串口服务文件,以及主控侧的 PID 和舵机驱动。拿到 zip 先不要急着改代码,先把阈值配置从主脚本里独立出来。

文件/目录常见作用
main.pyOpenMV 主循环,采集图像并调用识别函数
thresholds.jsonLAB 阈值,现场标定后单独保存
uart_service.py帧封装、发送、接收应答
pid/STM32 工程下的 PID 与舵机驱动
README.md记录接线、串口协议和调参结论

把阈值单独放文件的原因很实际:比赛现场灯光从白炽灯换成日光灯,阈值全变,重写脚本不如改一个 JSON 再软重启。多支队伍共用一份代码时,也能避免把个人现场阈值误提交进主工程。

3.2 OpenMV侧:色块识别与坐标输出

识别层最小可用代码不用上深度学习,E 题目标颜色单一,LAB 颜色阈值加最大色块筛选就够了。下面这段是 OpenMV 侧的完整循环:

import sensor, image, time from pyb import UART sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QQVGA) # 160x120,帧率优先 sensor.skip_frames(time=2000) sensor.set_auto_gain(False) # 关自动增益,防止阈值漂移 sensor.set_auto_whitebal(False) # 关自动白平衡 uart = UART(3, 115200) # 典型室内红球阈值,现场务必用 IDE 阈值编辑器重新标定 THRESHOLD_RED = (30, 70, 25, 70, 10, 60) while True: img = sensor.snapshot() blobs = img.find_blobs([THRESHOLD_RED], pixels_threshold=20, area_threshold=20, merge=True) if blobs: b = max(blobs, key=lambda b: b.area()) dx = b.cx() - sensor.width() // 2 dy = b.cy() - sensor.height() // 2 conf = min(1.0, b.area() / 800) # 面积越大可信度越高 # 有符号偏差直接取低字节,STM32 侧再转回有符号数 data = "A5 01 01 %d %d %d\n" % ( dx & 0xFF, dy & 0xFF, int(conf * 255) & 0xFF) else: data = "A5 01 00 00 00 00\n" # 丢帧保持 uart.write(data) print(data)

这段代码里最值得解释的是set_auto_gain(False)。OpenMV 默认自动增益会让每一帧图像的亮度不同,导致同一个目标的 LAB 值在亮帧和暗帧里差别很大,阈值现场标定完,过两分钟就识别不稳。关掉之后再手动固定曝光,阈值才有一致性。pixels_threshold=20用来滤掉散点噪点,如果现场有反光碎片,可以调到 40。merge=True会把相邻色块合并成一个大块,防止目标被拆成两块导致中心偏移。在合理光照下,QQVGA 分辨率实测帧率大多在 35~50fps 之间,这个帧率足够支撑后面的 PID 控制。

提示:阈值不要直接在代码里改。用 IDE 的“阈值编辑器”打开一张现场截图,拖动六个滑条把目标单独抠出来,再把结果回填进THRESHOLD_RED。现场换光源后重新走一遍这个流程,比重刷固件快得多。

3.3 STM32侧:串口解析帧并映射到舵机角度

主控侧接收串口帧并做初步处理的 C 代码,按 2.3 节的协议实现:

uint8_t buf[6]; // 帧头对齐 + 连续收满 6 字节 if (HAL_UART_Receive(&huart3, &buf[0], 1, 10) == HAL_OK && buf[0] == 0xA5) { if (HAL_UART_Receive(&huart3, &buf[1], 5, 10) == HAL_OK) { uint8_t mode = buf[1]; int8_t dx = (int8_t)buf[3]; // 低字节转有符号 int8_t dy = (int8_t)buf[4]; uint8_t conf = buf[5]; if (mode == 0 || conf < 50) { // 丢帧或置信度过低 servo_hold(last_yaw, last_pitch); } else { target_yaw = last_yaw + dx * deg_per_pixel * k; target_pitch = last_pitch + dy * deg_per_pixel * k; last_yaw = target_yaw; // 程序内角度闭环 last_pitch = target_pitch; servo_move_to(target_yaw, target_pitch); } } }

(int8_t)buf[3]的写法把无符号低字节解释为有符号数,对应 OpenMV 侧dx & 0xFF,两边符号语义一致。conf < 50的阈值来自 2.3 节的置信度字段,相当于要求识别层面积极大约占全画面的 6% 以上才执行动作。这里有个容易踩的坑:主控判断“丢帧保持”时,舵机要保持的是最后一次有效目标对应的角度,而不是当前 PWM 输出值,否则保持的是滞后的旧角度。另外如果舵机是 MG996R 这类模拟舵机,机械行程有限,target_yaw超过限位后要提前CLAMP,不然舵机堵转会发热并烧坏舵机驱动板。

4. 云台跟得上的关键:PID调参与三档速度响应

4.1 为什么识别准了云台仍然抖

识别输出每帧刷新,但把每个新目标角度直接砸给舵机,输入信号就是阶跃串。机械结构对阶跃输入的响应天然是过冲加振荡,表现在激光点上就是左右横摆,看起来像识别在抖,实际是执行层没有平滑。常见做法是在角度环上叠位置式 PID,但要注意一个原则:不要对像素偏差做 PID,要对角度目标做 PID。对像素做 PID,OpenMV 帧率波动会原样进控制环;对角度做 PID,识别帧率和控制频率解耦,主控可以按固定周期 20ms 跑一次 PID,识别多少帧都不影响控制稳定性。

4.2 位置式PID的最小实现

通用的位置式 PID 增量形式,入库前做个积分衰减和输出限幅就够了:

typedef struct { float kp, ki, kd; float integral, last_err; } PID; float pid_update(PID *pid, float target, float current, float dt) { float err = target - current; // 0.95 为积分衰减系数,防长时间丢帧导致积分饱和 pid->integral = pid->integral * 0.95f + err * dt; float deriv = (err - pid->last_err) / dt; pid->last_err = err; float out = pid->kp * err + pid->ki * pid->integral + pid->kd * deriv; // 输出限幅,防止角度指令超出舵机行程 if (out > PWM_LIMIT_HIGH) out = PWM_LIMIT_HIGH; if (out < PWM_LIMIT_LOW) out = PWM_LIMIT_LOW; return out; }

积分衰减系数0.95是这里最关键的一行。E 题现场目标可能会短暂离开视野,此时角度目标保持不变,普通积分项会一直累加,目标重新出现时积分已经冲到饱和,云台会有一个明显窜动。加了衰减后,积分项每拍缩水 5%,长时间保持同一角度时积分自动回落。调参顺序固定:先只给kp,从小到大试到云台出现轻微来回振荡,此时kp约为临界值;再给kd把振荡压住;最后给少量ki消除静态误差,ki通常是kp的 1/20 起步,不要一上来就按相等量级给。

4.3 PID起点参数表与三档速度切换

不同舵机和供电条件下参数绝对值没有意义,下面的起点参考表按目标运动速度分为三档,能覆盖 E 题绝大多数场景。

模式触发条件KpKiKd适用场景
低速保持目标像素位移 < 3px/帧0.600.1目标静止或极慢移动
正常跟随位移 3~10px/帧1.20.050.25直线匀速往返段
快速响应位移 > 10px/帧1.80.080.4启停瞬间或变速段

三档切换的执行代码放在主控侧,切换依据是相邻两帧目标中心像素位移估计的速度。OpenMV 端只需要在串口帧的置信度字段之后附带一个速度等级位,或者在 main.py 里直接按位移阈值设置模式字段:

speed = abs(dx - last_dx) / dt if speed < 3: mode = 0 # 低速保持 elif speed < 10: mode = 1 # 正常跟随 else: mode = 2 # 快速响应

速度档位切换时要注意dt必须和实际帧间隔一致。如果你在 OpenMV 里用time.clock()测到帧率只有 25fps,那么dt就按 0.04s 算,而不是想当然的 0.02s。档位切换那一拍 PID 的last_err要从当前误差重新初始化,否则上一档的巨大误差会被微分项带进来,产生一次跳变。

5. zip里看不到的坑:过曝、帧率与工程版本管理的验证技巧

5.1 过曝识别“融化”的现场验证

激光发射器打在白纸上过曝时,画面中心会整片泛白,RGB 三个通道同时饱和,LAB 阈值看到的目标不再是圆斑而是“融化”的大色块。验证办法是打印最大色块的面积,正常红球目标在 QQVGA 下大约 200~600 像素,过曝后直接上千。处理方式是固定曝光而不是继续调阈值:

sensor.set_auto_exposure(False, exposure_us=10000) # 从 5000 到 20000 试

曝光时间从 5000us 起调,每档加 1000us,直到目标轮廓清晰且不至于丢失暗色边缘。固定曝光后,昼夜光线变化只能靠重新标阈值兜底,这就是阈值独立成文件的意义。

5.2 帧率与串口波特率的匹配验证

160x120 RGB565 单帧图像 38.4KB,而串口每帧只有 6 字节,115200 波特率下 6 字节约 0.5ms 发送耗时,完全不是瓶颈。真正的限制在 OpenMV 内部处理时间。验证帧率的做法是在循环里用time.ticks_ms()计算实际帧间隔,打印出来,如果低于 30fps,后续 PID 所有动态参数都不可信。此时优先把pixels_threshold调大减少候选色块数量,再考虑降分辨率到 QVGA。

5.3 把 zip 解压出来的工程挂成 git,变基失败怎么处理

从网上下载的 E 题资料包基本都不是 git 仓库,改完代码想推到自己的远程仓库,常遇到failed to push some refs,原因是远程已有提交而本地历史不相关。正确的关联顺序是这样:

git init git add -A && git commit -m "import from zip" git remote add origin <仓库地址> git fetch origin git branch --set-upstream-to=origin/main main git pull --rebase origin main git push origin main

fetch先把远端提交拉到本地但不合并,--set-upstream-to建立跟踪关系,pull --rebase把本地那一次 import 提交重放到远端最新提交之上,历史是一条直线而不是两个分叉。出现冲突时,多半是远端已经有人改过同名文件,保留自己改动的那几个源文件,其余以远端为准。

5.4 加密 zip 先看注释再动手

部分资料包发布时会设密码,密码通常写在下载页或包内 README 注释里,先用 7-Zip 打开看右侧注释栏,或者直接联系发布者,不要急着找暴力破解类工具,E 题资料的核心价值在源码结构和调参记录,为一份可能过时的赛题资料花几个小时跑字典破解完全不值。

跑通上面这些验证后,再回到比赛中,你最该关心的只剩一个变量:thresholds.json 里的三组 LAB 阈值。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询