简介:这是面向电赛无人机赛项的完整工程资料包,聚焦循迹、图形与颜色识别、串口通讯三大核心功能,适合参加电赛或有无人机自动驾驶学习需求的选手与开发者。工程包含19个文件,共15KB,以Python源码为主,辅以XML配置、pyc编译文件及README说明,其中main.py、imgprocess.py、uart.py等分别承担主流程、图像处理和串口通信任务,结构紧凑,便于快速移植到自己的飞控或视觉方案中。已有558人学习下载,具备一定参考价值。资料不仅覆盖SLAM循迹、OpenCV图像处理、HSV颜色阈值筛选、UART通讯以及PID姿态控制等关键知识点,还给出了智能避让规划的设计思路,能够帮助读者梳理从传感器数据到飞行控制的完整链路,适合在备赛阶段对照阅读、二次开发。
1. 电赛第二次积分赛无人机:真正要做的不只是飞起来
在积分赛题目里看到“无人机”三个字,很多人第一反应是先把姿态稳下来、把电机转起来。等你进场熟悉规则后才会发现,裁判真正盯着的是无人机能不能沿着黑线稳定循迹,在目标区识别出指定图形和颜色,再把结果通过串口通讯报给上位机。整个项目因此被拆成三条主线:五路循迹传感器解决“线在哪”,OpenMV 或摄像头解决“图形和颜色是什么”,STM32 串口解决“告诉谁”。这三条线分别涉及不同的调试方法,硬件布局一错、串口协议一乱,赛场上就会连环翻车。我一般会先把架构拆明白,再让每个功能各自跑通,最后才合到一起联调。
2. 无人机循迹与图形识别:系统架构和硬件分工
不要指望一颗摄像头把循迹和图形颜色识别全包。赛场上阳光、反光、地面纹理和曝光变化都会影响摄像头对黑线的判断,而五路灰度传感器对黑白的响应几乎是瞬时的。所以常见的做法是“灰度传感器做运动控制,视觉做事件识别”:STM32 通过 ADC 读循迹传感器,算偏差,控制电机转速;OpenMV 单独负责识别图形和颜色,通过串口把结果报给 STM32。两边互不占用算力,也方便在电赛现场单独调试,哪个环节出了问题就只改哪一块。
2.1 五路循迹传感器在无人机上怎么安装才不飘
循迹小车通常用三路,但无人机由于机架震动和气流扰动会轻微漂移,建议直接用五路。五路循迹传感器的优点在于中间三个传感器可以判断线的粗略走向,外侧两个做保护性修正,即使机身有一点横向漂移,丢线之后也能根据两侧传感器找回黑线。安装时传感器离地高度要控制在 8 到 15 毫米,太近会把地面颗粒误判成黑线,太远则因红外反射光减弱而读数不稳定。传感器方向要严格垂直于前进方向,左右间隔均匀,这正是后面偏差计算的基础。
2.2 图形和颜色识别用 OpenMV 还是普通摄像头
普通摄像头加 OpenCV 在电脑上跑完全可行,但赛场上没人会背着电脑让无人机飞到跟前再回传图像。更稳妥的是 OpenMV H7 或类似的嵌入式视觉模块,它能在器件内部完成图像处理,并直接通过 UART 发送结果。若只有普通摄像头,可以把树莓派 Zero 放在机载端,用 OpenCV 从 CSI 口读图像,不过对积分赛这种时间紧凑的场景,OpenMV 的上手成本明显更低。颜色识别和图形识别的核心是图像分割,不是在原图上逐像素找规律,所以先把思路放在“找到目标颜色区域”,再谈“区域是什么形状”。
2.3 硬件连接和引脚定义:串口通讯之前先理清物理链路
主控选型我一般推荐 STM32F407,带多个 UART 和 ADC,可以同时挂循迹传感器和 OpenMV。OpenMV 占用一个串口,无线图传或串口透传占用另一个串口,四个电机 PWM 再从定时器输出。接线原则是模拟信号尽量远离电机电源线,否则 ADC 采集到的循迹值会随油门变化跳来跳去。
// line_sensor_pins.h // 五路循迹传感器接在 ADC1 的 PC0~PC4 #define LINE_SENSOR_LEFT_OS ADC_CHANNEL_10 // PC0 #define LINE_SENSOR_LEFT_IN ADC_CHANNEL_11 // PC1 #define LINE_SENSOR_CENTER ADC_CHANNEL_12 // PC2 #define LINE_SENSOR_RIGHT_IN ADC_CHANNEL_13 // PC3 #define LINE_SENSOR_RIGHT_OS ADC_CHANNEL_14 // PC4 // OpenMV 串口:USART3,波特率 115200 #define VISUAL_UART USART3 #define VISUAL_UART_BAUD 115200 // 电机 PWM:TIM2 的 PA0~PA3 #define MOTOR_FRONT_LEFT TIM_CHANNEL_1 #define MOTOR_FRONT_RIGHT TIM_CHANNEL_2 #define MOTOR_REAR_LEFT TIM_CHANNEL_3 #define MOTOR_REAR_RIGHT TIM_CHANNEL_4这段代码定义的是一个典型的硬件拓扑:5 个 ADC 通道负责循迹,1 路 UART 接收视觉识别结果,4 路 PWM 构成 X 型四旋翼的电机输出。引脚名字里的 OS 表示外侧,IN 表示内侧。循迹判断不是直接使用原始 ADC 电压,而是先用阈值把每个通道变成 0 或 1:黑线反射红外光弱,数值低;白色地面反射强,数值高。阈值定了之后,再把 5 个通道组合成一个线位置值,这一步放到下一章展开。
在这个硬件布局里,OpenMV 和 STM32 之间最好用 3.3V 电平的 UART 直连,双方电源共地。若现场需要远距离调试,也可以给视觉模块加 RS485 接口,使用 RS485 串口通讯时需要在软件里增加收发切换逻辑,比赛时我建议主场直接用 TTL,减少排错复杂度。
3. 循迹算法:五路传感器的阈值提取与转向控制
循迹不是让无人机“看到线”就能走直线。真正决定场上是否稳定的,是把五路传感器的模拟值变成偏差量,再通过 PID 给出转向量。这一节只讲循迹支路,不牵扯视觉识别。
3.1 动态阈值让循迹不再怕光线变化
每个传感器都要独立设置阈值。最简单的方法是随机上电时读一遍白底值,再在胶带正中读一遍黑线值,取中间数作为阈值。但比赛现场的灯光从上午到下午会变,若只做固定阈值,下午阳光一斜,阈值就失效。常见的做法是每次上电后先让无人机静止在白色地面上,程序自动采集前 100 次 ADC 采样的平均值作为白色基准,再根据黑白对比度生成阈值。
uint16_t white_base[5]; uint16_t threshold[5]; void calib_threshold(void) { for (uint8_t i = 0; i < 5; i++) { white_base[i] = 0; } for (uint8_t n = 0; n < 100; n++) { for (uint8_t i = 0; i < 5; i++) { white_base[i] += adc_read(i); } osDelay(2); } for (uint8_t i = 0; i < 5; i++) { white_base[i] /= 100; threshold[i] = (uint16_t)(white_base[i] * 0.5f); } }这段代码的前提是黑线的 ADC 读数显著小于白底读数,所以阈值取白色基准的一半。若你的传感器输出相反,把比例系数改成 1.2 或 1.5 即可。校准完成后,每个通道用adc_read(i) > threshold[i]转成 0 或 1。如果五个通道全是 0,说明无人机在白色区域,或者机身已经偏离黑线超过两个外侧传感器的距离。
3.2 加权平均法计算线位置
把五个二值化值映射成位置的思路是加权平均。左右两边的权重取正负,中间权重为零,得到的加权和归一化后就是偏差 error。常用的映射是把 5 个传感器排成一条沿横轴的坐标:-2000、-1000、0、1000、2000,线在正下方时 error 接近 0。
int16_t calc_line_error(uint8_t *sensor) { static int16_t weight[5] = {-2000, -1000, 0, 1000, 2000}; int32_t sum = 0; uint8_t cnt = 0; for (uint8_t i = 0; i < 5; i++) { if (sensor[i]) { sum += weight[i]; cnt++; } } if (cnt == 0) { return 0; } return (int16_t)(sum / cnt); }这里 cnt 是被压住的黑线通道数量。比如传感器 2 和传感器 3 同时压线,位置等于 (-1000 + 0) / 2 = -500,表示黑线偏向左侧,误差为负。只压一个传感器时误差就是该通道权重,结果直观。算完误差后,让转向量跟随误差:误差越左,横向修正力越偏左。若是地面巡线模式,只需要左右两侧动力差;若是低空飞行模式,还要把误差映射到前飞和偏航。
3.3 PID 输出限幅与丢线状态机
循迹 PID 的执行周期固定为 10ms,用定时器中断驱动。OpenMV 的串口数据只有几十毫秒一帧,不能让它反客为主去打断循迹控制。很多队伍的翻车点就是在串口中断里做了太多判断,导致 PID 周期抖动。PID 输出要同时做限幅和增量限制,避免无人机在弯道上突然给满油门横摆。
float pid_update(float error, float kp, float ki, float kd) { static float integral = 0.0f; static float last_error = 0.0f; integral += error * 0.01f; if (integral > 300.0f) integral = 300.0f; float out = kp * error + ki * integral + kd * (error - last_error) / 0.01f; last_error = error; if (out > 500) out = 500; if (out < -500) out = -500; return out; }参数上我一般把 kp 设为 12 到 20,ki 控制在 0.5 以下,kd 预估值 2 到 5,具体值要根据机架轴距调整。下面这个表是我联调时判断方向用的:
| 参数 | 推荐范围 | 现象 |
|---|---|---|
| kp | 12~20 | 弯道修不过来时调大,过大会左右摆动 |
| ki | 0~0.5 | 直道有稳态偏差时调大,调大后注意积分饱和 |
| kd | 2~5 | 输出抖动或过冲时调大,过大后会放大噪声 |
判断循迹是否稳定,可以在空地上画一个直径 80cm 的圆弧,让无人机从入弯点开始跟随。若外侧传感器已经压线但仍没有修正,说明 kp 太小;若修正动作呈抖动,则 kd 太大,或者 PID 周期不稳定。
4. 图形与颜色识别:HSV 分割加轮廓筛选
“识别图形及其颜色”在比赛中一般被拆成两步:先找到目标颜色的区域,再判断这块区域是什么形状。颜色识别最好的起点是 HSV,而不是直接用 RGB 像素值。OpenMV 默认的 LAB 色彩空间对光照变化更鲁棒,但 HSV 的语义更直观,网上查到的很多 hex 颜色对照表也都是 RGB 十六进制,你可以先转换成 HSV 再写进程序。
4.1 为什么颜色识别要用 HSV 而不用 RGB
RGB 的三个通道互相牵制,红色 (255,0,0) 和暗红 (100,0,0) 的欧氏距离很远,但 HSV 中它们只是 V 值不同,色调 H 和饱和度 S 保持不变。比赛现场同一张卡纸随着光照角度变化,RGB 读数会漂移,HSV 却容易用一个窄范围框住。实际使用时,先取目标色卡的十组 HSV 值求并集,再给 H 加上正负 6 的裕量,S 和 V 直接使用采样范围。
| 目标颜色 | H 范围 | S 范围 | V 范围 |
|---|---|---|---|
| 红色 | 0~10 以及 170~180 | 100~255 | 60~255 |
| 绿色 | 45~80 | 80~255 | 60~255 |
| 蓝色 | 100~130 | 100~255 | 60~255 |
以红色圆形为例,H 在 0 到 10,S 要在 100 以上,V 要在 60 以上,否则会把暗色阴影误识别成红色。把阈值写进程序后,先不要急着识别形状,先在串口终端里打印色块质心和面积,确认每个目标颜色都能被稳定抓出来。
4.2 OpenMV 下识别圆形、正方形和三角形的具体做法
OpenMV 的find_blobs能得到每个色块的质心、面积、外接矩形宽高和像素数,所以不需要复杂的轮廓算法。图形识别用宽高比加填充密度来区分:圆形包围矩形的宽高比接近 1,密度接近 π/4 约 0.78;正方形密度接近 0.95;三角形密度通常低于 0.65。
import sensor, image, time, ustruct from pyb import UART sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) sensor.set_auto_whitebal(False) sensor.skip_frames(30) uart = UART(1, 115200, timeout_char=1000) RED_TH = (0, 10, 100, 255, 60, 255) GREEN_TH = (45, 80, 80, 255, 60, 255) BLUE_TH = (100, 130, 100, 255, 60, 255) COLOR_ID = {'red': 1, 'green': 2, 'blue': 3} def classify_shape(w, h, density): ratio = float(w) / h if 0.85 < ratio < 1.15: if 0.70 < density < 0.88: return 'circle' if density >= 0.90: return 'square' if density < 0.68 and 0.4 < ratio < 1.6: return 'triangle' return 'unknown' while True: img = sensor.snapshot() for th, name in [(RED_TH, 'red'), (GREEN_TH, 'green'), (BLUE_TH, 'blue')]: blobs = img.find_blobs([th], pixels_threshold=300, area_threshold=300) for b in blobs: shape = classify_shape(b.w(), b.h(), b.density()) if shape != 'unknown': record = ustruct.pack('<HBB', b.cx(), b.cy(), COLOR_ID[name]) uart.write(record) time.sleep_ms(10)这段代码把颜色阈值放进列表,对每个颜色分别查询色块。pixels_threshold=300和area_threshold=300用来过滤孤立的小噪点。b.density()是区域内有效像素与外围矩形面积的比。识别结果里同时带有质心坐标和颜色编号,主控拿到后可以知道目标出现在画面哪个位置,从而决定无人机是否降低高度或悬停。
4.3 视觉识别结果和循迹的时序配合
视觉识别不需要每帧都处理,OpenMV 可以把识别速度控制在 30ms 一帧,但 STM32 循迹是 10ms 一圈。若视觉结果到达时无人机已经越过目标区域,那个结果就是废数据。所以串口帧里要带上图像处理完成时的顺序号或状态位,主控根据当前循迹位置判断是否接收。赛场地图通常在直道后是目标识别区,此时无人机应当降低前飞速度,给摄像头足够曝光,同时暂停循迹 PID 的积分累积,否则机身抖动会直接把画面拍虚。
5. 串口通讯协议设计:让 STM32 和视觉模块讲同一种语言
串口通讯如果只是把整条字符串丢过去,测试时看着没问题,一上赛场就会遇到粘包、丢帧和乱码。原因是无人机电机 PWM 带来的电磁干扰会让 UART 线出现误码,尤其线长超过 10cm 时更明显。因此无论 OpenMV 和 STM32 之间是 TTL 还是 RS485 串口通讯,都要设计帧格式和校验,不能裸发明文。
5.1 一个最小可用的串口帧格式
帧格式要兼顾解析速度和扩展性。我常用的协议是三字节帧头加一字节长度加数据区加一字节校验:0xAA 0x55 0x01 0x04 data0 data1 data2 data3 checksum。高两位固定用来同步,长度字段代表数据区字节数,checksum 为整帧异或值。
| 字段 | 长度 | 值 | 说明 |
|---|---|---|---|
| 帧头1 | 1 | 0xAA | 同步字 |
| 帧头2 | 1 | 0x55 | 同步字 |
| 命令 | 1 | 0x01~0x03 | 循迹结果、图形颜色、状态回传 |
| 长度 | 1 | 变化 | 数据区字节数 |
| 数据区 | N | 变化 | 按命令定义 |
| 校验 | 1 | XOR | 前面所有字节异或 |
命令字段不能省。比赛中给上位机回传的数据可能来自循迹状态,也可能来自视觉识别,没有命令字段,上位机就分不清这帧到底是位置还是识别结果。
5.2 STM32 串口中断里的状态机解析
STM32 接收时不要一字节一字节地送给主逻辑,而是放进缓冲区,在中断里做状态判断。下面这个函数只负责判断当前缓冲区是否收齐一帧:
uint8_t buf[32]; uint8_t rx_index = 0; uint8_t data_len = 0; uint8_t frame_done = 0; void uart_rx_byte(uint8_t byte) { buf[rx_index++] = byte; if (rx_index == 1 && buf[0] != 0xAA) { rx_index = 0; return; } if (rx_index == 2 && buf[1] != 0x55) { rx_index = 0; return; } if (rx_index == 4) { data_len = byte; } if (rx_index == data_len + 5) { uint8_t xor_sum = 0; for (uint8_t i = 0; i < rx_index - 1; i++) { xor_sum ^= buf[i]; } if (xor_sum == buf[rx_index - 1]) { frame_done = 1; } rx_index = 0; } }这段代码用rx_index记录已接收字符数。收到第一个字节不是 0xAA 就丢弃;第二字节不是 0x55 也丢弃;第四字节是数据长度;等到长度加 5 个字节收齐后做异或校验。校验通过时置frame_done,主循环的循迹任务看到这个标志后再取整帧数据。实际工程中还要加最大长度保护,防止干扰字节把缓冲区写满。
5.3 用 FreeRTOS 把串口解析和循迹任务分隔开
无人机上如果跑了 FreeRTOS,常见的做法是建立两个任务:控制任务优先级 7,串口解析优先级 4。串口任务只把完整帧投递到队列,控制任务从队列读取视觉结果并执行,两个任务共享数组会造成数据竞争。连续发送视觉结果时,为了不让旧帧积压,我会用xQueueOverwrite而不是xQueueSend,保证控制任务拿到的始终是最新识别结果。
// FreeRTOS 任务片段:串口解析任务 static void visual_task(void *arg) { visual_msg_t msg; for (;;) { if (xQueueReceive(visual_queue, &msg, pdMS_TO_TICKS(10)) == pdPASS) { control_apply_visual(&msg); } } }这样的任务划分能避免视觉结果积压导致 PID 响应落后半个弯道。调参时通过串口把 PID 的 error 和 output 打印出来,再画成曲线,比盯着无人机猜姿态靠谱得多。下一章就把这套串口联调手段直接落到操作细节上。
6. 赛前串口联调:用虚拟示波器验证 PID 和识别结果
这里直接给赛前最实用的验证手段:把循迹 PID 的误差、PID 输出和视觉识别标志放到同一个串口波形里看。不要只依赖现场跑车的肉眼观察,也不要用 LED 闪烁代替量化数据。常见的做法是在调试串口里周期发送多通道数据给上位机的虚拟示波器,用 Serial_Digital_Scope 一类的工具抓取波形,再对比不同参数的曲线形状。
6.1 串口曲线要发哪些量
最少要发三个变量:误差 error、PID 输出 output、识别标志位 recognition。error 反映黑线相对机身的位置,output 反映修正力度。假如 error 已经回到 0 附近,output 仍持续抖动,说明 kd 过大;假如 error 长期不归零且 output 已顶到上限,说明 kp 不足或丢线状态机没有正确处理。识别标志位可以设成 0 和 1,摄像头看到目标时变 1,方便确认视觉结果和串口命令在同一帧里到达。
# 调试帧复用第5章协议:AA 55 cmd len data check import ustruct from pyb import UART uart = UART(1, 115200, timeout_char=1000) while True: pid_error = 123 pid_output = 56 recogn_flag = 1 data = ustruct.pack('<hhB', pid_error, pid_output, recogn_flag) cmd = 0x06 length = len(data) xor_sum = 0xAA ^ 0x55 ^ cmd ^ length for b in data: xor_sum ^= b frame = bytes([0xAA, 0x55, cmd, length]) + data + bytes([xor_sum]) uart.write(frame) time.sleep_ms(10)注意这是调试专用帧,和 OpenMV 与主控之间的业务帧分开,不要混在同一个串口里。校验和计算要和接收端一致。把调通后的串口包录下来存成 .bin 或 .log,下次赛前用回放功能重新导入,对比不同 PID 参数的曲线走向,比临时去现场试飞快得多。
6.2 赛前快速判定的三个阈值
决赛前我会用下面三个标准做一次快速体检。第一,黑线直道段 error 的平均值应小于满量程的 5%;第二,过弯时 output 的峰值不应超过设定最大输出的 80%,并且要在 0.3 秒内回落到接近 0;第三,视觉识别帧与循迹状态同步后,识别标志位应至少在 200ms 内保持稳定,不能忽 0 忽 1。如果识别结果一闪而过,先把摄像头曝光固定、关掉白平衡,再用连续 3 帧相同的形状才输出。整条串口联调链路走完一遍,再把无人机放到比赛地图上完整跑一圈,循迹、图形颜色识别和串口通讯这三项功能才算真正合到一起。
本文还有配套的精品资源,点击获取