
简介电赛无人机方向实战源码包面向参加电子设计竞赛飞控类课题及对无人机自主导航感兴趣的开发者。项目围绕循迹飞行、图形与颜色识别、串口通信三大功能展开涉及超声与视觉传感器融合、SLAM即时定位与地图构建、OpenCV图像预处理、CNN图形分类、RGB转HSV颜色筛选、UART协议通信以及PID姿态控制与智能避让规划等关键算法。压缩包共19个文件以Python源程序为主含主控、串口通信、图像识别、飞行栈等核心模块另有PyCharm项目配置xml、编译生成的pyc缓存及README说明文件包体仅15KB代码结构清晰便于直接阅读和二次移植。目前已有558人学习浏览。对照赛题要求逐一梳理各功能模块的实现思路对正在准备电赛无人机赛题、希望快速参考完整代码框架的读者这份资源能提供实用的工程范例。1. 电赛积分赛无人机选题真正难的从来不是飞第二次积分赛的无人机赛题经常会提出一组看起来彼此独立的需求地面循迹、识别图形与颜色、串口上报。很多队伍把这三块拆开各自写最后联调时发现循迹数据是 20ms 一刷新OpenMV 识别结果却不一定按同一个节奏来串口上还可能出现半包和校验失败。于是整个比赛评分点就落在“这三样东西在同一个主控时序里能不能跑通”。这个项目在竞赛环境下通常的做法是用 STM32 系列做控制主控机腹装五路循迹传感器负责地面引导OpenMV 或 K210 做摄像头视觉协处理器再通过两路 UART 分别连接视觉模块和无线调试串口。对 5 年以上从业者来说这套系统不算难但如果第一次做最难补的课反而是串口协议设计因为颜色识别的真值、控制回路的输出最后都要靠串口来证明。2. 系统架构与硬件选型STM32 主控、OpenMV 视觉和五路循迹传感器如何分工2.1 赛题功能边界与整机拓扑“循迹、识别图形/颜色、串口通讯”三个功能放到一架无人机上如果不先画清楚数据流一定会出现“视觉还没回来飞控已经把飞机飞出赛道”的问题。这里的数据流包括三个层次底层控制姿态解算、电机 PWM 输出和视觉功能关系不大。对于积分赛通常用现成飞控或自写四元数姿态环。感知层五路循迹传感器采集地面灰度OpenMV 采集画面并输出颜色/形状编号。决策层STM32 主控读取感知结果维护一个飞行状态机决定是继续循迹还是悬停识别。常见拓扑是 STM32 承担决策和底层控制OpenMV 只做结果输出。两者之间用 UART1 连接循迹传感器按模拟电压接到 ADC多余的 UART 接蓝牙或 2.4G 数传用于调试。一架迷你穿越机或 F450 改良机架都能支撑这个配置重心对识别精度有影响所以视觉模块尽量和电池垂直共线。模块STM32 接口速率/格式作用五路循迹传感器TCRT5000 反射式PC0-PC4 ADC模拟量 0-3.3V获取地面黑白线位置OpenMV CamUSART1 TX/RX115200 8N1输出图形颜色编号蓝牙/数传模块USART2 TX/RX115200无线日志、参数修改电调/舵机TIM1 PWM50-500Hz飞行控制OLED 扩展I2C/SPI400k状态显示这张表最容易被忽略的是“串口资源分配”。如果 OpenMV 占用了 USART1调试占用了 USART2剩下给遥控器和 GPS 的串口就不多了。建议在写工程前先建一个外设接口表上面列出每个外设用的引脚、波特率、流控防止后面调换导致飞线。2.2 视觉协处理器为什么选 OpenMV 而不是树莓派积分赛现场给每位选手的时间很紧所以视觉部分要选上电即用的方案。树莓派启动超过十秒图像处理代码跑起来后 CPU 温度会造成卡顿而且还需要给核心板单独供电。OpenMV 是单片机形态MicroPython 启动时间很短USB 供电 5V 就能从 STM32 直接取电。更关键的是OpenMV 可以直接通过 IDE 调整颜色阈值实时看到阈值覆盖区域这把“颜色传感器调参”从碰运气变成可见过程。K210 板卡的算力比 OpenMV 强但是很多 K210 例程只给到了目标检测没给到颜色与形状的组合推理反倒需要写更多微控制器代码。对第一次参加积分赛的团队OpenMV 是最平衡的选择如果比赛目标中有更复杂的多目标测距则可以把主控升级为树莓派但那样就会失去裸机系统的可预测性。2.3 用 FreeRTOS 任务划分保证三个功能不互相堵塞裸机状态机对这个规模也够用但引入 FreeRTOS 能让功能边界更清晰。我的习惯是划分成 4 个任务循迹采集任务每 5ms 读一次五个 ADC 通道视觉解析任务以队列方式接收 OpenMV 发来的一帧数据控制任务每 10ms 根据视觉解析结果和循迹误差计算 PID调试任务每 20ms 打印状态。视觉解析任务只负责解包并把结果填入共享结构体不直接控制电机这样即使视觉数据有半包错误也不会把电机 PWM 输出带到异常值。// FreeRTOS 任务示例从视觉队列取出一帧解析结果更新控制目标 void VisionTask(void *arg) { stm32_vision_frame_t frame; for (;;) { if (xQueueReceive(vision_queue, frame, pdMS_TO_TICKS(100)) pdTRUE) { if (1 frame.checksum_ok) { control_mode_t *cfg GetControlConfig(); cfg-target_color frame.color_id; cfg-target_shape frame.shape_id; cfg-vision_valid 1; } } } }队列的作用是隔离串口中断和任务。如果任务还没处理完新的串口数据不会直接覆盖旧帧而是排队等待。对于四个任务优先级建议控制任务最高循迹采集其次视觉解析最低。原因是视觉结果晚 20ms 到还能接受控制 PWM 晚 1ms 就会引起电机抖动。3. 无人机循迹实现五路循迹传感器的阈值校准与 PID 参数3.1 五路循迹传感器的原理和架设高度TCRT5000 这类反射式传感器由红外发射管和光电接收管组成。发射管持续发出红外光光线照到地面反射后接收管电流发生变化黑线吸收红外光返回信号弱白底返回信号强。模拟量输出可以将明暗差异量化为电压值比数字量输出更容易做阈值回滞和灵敏度调整。五路循迹传感器的优点在于中间三路用于判断当前机头与引导线的横向偏差最外侧两路可以提前发现弯道、起点和直角交叉口。这对低空飞行的无人机尤其重要因为无人机在悬停时会有横滚和俯仰小漂移单路传感器很容易瞬间丢线。安装高度要控制在 1cm 左右如果高度超过 3cm红外光扩散后黑线边缘变得模糊五路分辨率会迅速下降。无人机大部分时间贴近地面低空循迹所以不能把传感器装得离地太高。另一种做法是让传感器安装在一个固定的前探支架上支架不随起落架震动。硬件装好后还要加一个遮光罩用热缩管或黑色海绵挡住侧向环境光否则在阳光下传感器白底电压会变化阈值完全失效。3.2 阈值校准把模拟量变成 5 位黑/白向量五路循迹传感器接 STM32 ADC 后第一步不是接 PID而是先标定阈值。将飞机放到白底上读五个通道的电压值再把黑色引导线放到传感器正下方读黑线电压值。通常取两者中点作为阈值。为了让结果更稳可以设置上阈值和下阈值白底高于下阈值判定为黑黑底高于上阈值判定为白中间区域保持上一次状态。#define LINE_THRESHOLD_HIGH 1800 // 12位ADC低于该值才认为是黑线 #define LINE_THRESHOLD_LOW 1600 // 高于该值才认为是白底 uint16_t sensor_raw[5]; uint8_t line_state[5]; void ReadLineSensors(void) { for (int i 0; i 5; i) { sensor_raw[i] adc_read(sensor_channel[i]); if (sensor_raw[i] LINE_THRESHOLD_LOW) { line_state[i] 1; // 确认黑线 } else if (sensor_raw[i] LINE_THRESHOLD_HIGH) { line_state[i] 0; // 确认白底 } // 介于两者之间时保持上一次状态避免临界抖动 } }为什么要把黑线设为 1 而不是白线因为黑线在五路向量里是特征偏差计算需要这一位为“有值”。如果引导线是白色且背景为深色则代码需要反转判断但优先级相同。3.3 循迹 PID 与无人机期望速度映射循迹误差用加权和表示从中间到两侧的权重分别为 0、-1、1、-2、2。如果向量为 0,1,1,0,0偏差 -1说明线靠右飞控需要将期望偏航角速度设为负值即向左转。const int8_t weight[5] {-2, -1, 0, 1, 2}; int16_t ComputeLineError(void) { int16_t err 0; uint8_t count 0; for (int i 0; i 5; i) { err line_state[i] * weight[i]; count line_state[i]; } if (count 0) { return last_error; // 丢线时保持上一次误差同时提醒后方逻辑 } last_error err; return err; }用加权和计算的好处是即使两个传感器同时检测到线误差值也能反映线在传感器阵列中的重心位置。对于无人机这个误差不是直接给转向舵而是转化为期望偏航角速度。无人机控制和循迹小车不一样小车轮子可以把 PID 输出叠加到左右轮占空比无人机则要做一次坐标映射。参数典型值单位判据KP0.05-0.10无量纲循迹不振荡过弯不甩尾KI0.0 或 0.001无量纲有静差时再加KD0.01-0.03无量纲减小过弯抖动前向速度0.1-0.25m/s视场地大小PID 输出直接对应期望偏航角速度单位是 deg/s。如果机场地较小KP 从 0.03 开始调观察五路向量是否有来回切换如果切换太快增大 KD优先抑制过冲。3.4 交叉口和掉头逻辑循迹最怕把交叉口判定为“丢线”。当五路都为 1 时这不是丢线而是飞机正对十字或丁字路口。需要一次性记录交叉口状态并执行计时或条件转向。if (line_state[0] line_state[1] line_state[2] line_state[3] line_state[4]) { // 进入交叉口按比赛规则转向 if (路口方向 右转) { target_yaw_rate 0.3f; } else { target_yaw_rate -0.3f; } } else if (no line) { target_yaw_rate 0; // 等待恢复 }如果只有中间一个传感器检测到黑色而两侧全为白说明线很窄或飞机高度偏高。此时不要立刻判断为直线先确认传感器是否受周围反光干扰。红外循迹调头动作的分界线在 500ms 左右调头时间太短会摆不到位太长会冲出下一条直线建议用陀螺仪累计角度结束后再退出调头而不是盲目等固定毫秒。4. 识别图形与其颜色OpenMV 的颜色阈值与形状判定4.1 用 hex 颜色对照表校准 LAB 阈值颜色识别用 RGB 直观但 RGB 对光照敏感。OpenMV 官方推荐使用 LAB 色彩空间。L 表示亮度A 表示品红到绿色B 表示黄色到蓝色。在 OpenMV IDE 的帧缓冲区窗口里用“阈值编辑器”框选目标它会直接把当前区域转成 LAB 阈值并在窗口右上角显示类似“(30, 80, 35, 90, 0, 40)”的区间。网上常见的 hex 颜色对照表只适合做主观估计比如红色对应的 RGB 是 (200, 30, 30)但你无法直接把 RGB 当作阈值使用必须先将它转换成 LAB。所以正确做法是在赛题确定后把红色、绿色、蓝色卡片放在现场灯源下各截一帧存入 OpenMV 的 flash用阈值编辑器逐个提取。颜色现场采样建议 LAB 范围说明红色L 40-80, A 20-127, B 0-60需要关闭自动白平衡绿色L 40-80, A -90-0, B 0-50背景不能有近似绿蓝色L 30-70, A -20-20, B -60-0反光会让 B 变小关键一步是把白平衡和曝光锁死。自动白平衡会在摄像头看到不同颜色的一瞬间改变整个画面的色偏造成同一个红色在目标前和目标后的阈值不匹配。在初始化代码中调用sensor.set_auto_whitebal(False)并设定一个固定曝光时间否则颜色识别会时好时坏。4.2 用 find_blobs 同时拿到颜色和形状特征识别“图形与其颜色”本质是先按颜色把目标区域从画面里切出来再判断这个区域属于哪种形状。OpenMV 的find_blobs返回的是颜色相近的连通域Blob 对象自带中心坐标、外接矩形宽高和像素面积。利用这些属性可以算出一个“填充率”填充率 blob.pixels() / (blob.w() * blob.h())。圆形目标的填充率约 0.785正方形接近 0.9长方形的填充率也比较接近 0.9但宽高比明显大于 1。因此可以把填充率和宽高比两个条件放在一起做形状分类。import sensor, image, ustruct from pyb import UART sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) sensor.set_windowing((160, 120)) sensor.skip_frames(time2000) sensor.set_auto_whitebal(False) uart UART(3, 115200, timeout_char1000) color_thresholds { 1: (40, 80, 20, 127, 0, 60), # 红色 2: (40, 80, -90, 0, 0, 50), # 绿色 3: (30, 70, -20, 20, -60, 0), # 蓝色 } def best_shape(b): fill b.pixels() / (b.w() * b.h()) aspect b.w() / max(1, b.h()) if 0.70 fill 0.85 and 0.8 aspect 1.2: return 1 # 圆形 if fill 0.85 and aspect 1.5: return 2 # 矩形 if 0.55 fill 0.75: return 3 # 三角形近似 return 0 while True: img sensor.snapshot() reports [] for color_id, thr in color_thresholds.items(): for b in img.find_blobs([thr], pixels_threshold30, area_threshold30): if b.area() 150: continue shape_id best_shape(b) if shape_id: reports.append((b.area(), color_id, shape_id, b.cx(), b.cy())) if reports: reports.sort(reverseTrue) area, color_id, shape_id, cx, cy reports[0] # 单字节协议颜色编号高4位形状编号低4位 pay (color_id 4) | shape_id uart.write(ustruct.pack(BBBB, 0xA5, 0x5A, pay, 0xAA))代码里把颜色阈值写成字典键是颜色编号。find_blobs返回一个列表我在这里按面积排序只发送面积最大的目标。这样可避免地面上出现两张同色色卡时主控不知道应该先处理哪一张。如果比赛要求需要上报所有目标则要在代码中对多个目标分别做编号把编号、颜色、形状、坐标打包进数据区而不是只发一组。4.3 用 OpenCV 在电脑上先验证掩码逻辑在把代码烧进 OpenMV 之前我习惯先用电脑摄像头把阈值验证一遍。OpenCV 的cv2.cvtColor(frame, cv2.COLOR_BGR2LAB)和cv2.inRange与 OpenMV 的 LAB 阈值逻辑一致适合快速检查颜色区域分割效果。下面这段代码可以实时看到红色区域的轮廓import cv2 import numpy as np cap cv2.VideoCapture(0) while True: _, frame cap.read() lab cv2.cvtColor(frame, cv2.COLOR_BGR2LAB) mask cv2.inRange(lab, (40, 20, 0), (80, 127, 60)) # 红色 mask cv2.erode(mask, None, iterations1) mask cv2.dilate(mask, None, iterations2) cnts, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for c in cnts: if cv2.contourArea(c) 300: cv2.drawContours(frame, [c], -1, (0, 255, 0), 2) cv2.imshow(mask, mask) cv2.imshow(frame, frame) if cv2.waitKey(1) 0xFF 27: break cap.release() cv2.destroyAllWindows()这段代码的可移植性很好。先膨胀再腐蚀可以去掉细小反光噪点。如果同一副画面在 OpenMV 和 OpenCV 阈值不同优先检查两者图像格式是否一致OpenMV 是 RGB565OpenCV 是 BGR需要先从 BGR 转 RGB再从 RGB 转 LAB。4.4 视觉模块与 STM32 的数据同步OpenMV 的识别结果通过 UART1 到达 STM32。需要注意的是OpenMV 处理一帧的时间不是固定的find_blobs遍历多个阈值时耗时约 30-50ms传送一帧数据又占 1ms。主控不能假设数据会匀速到达否则会把“识别快慢”当成“控制误差”。常见做法是在主控中为视觉结果打时间戳记录距离上一帧视觉数据的时间间隔。如果间隔超过 200ms就让飞机进入悬停等待不能继续执行循迹因为此时图像内容已经不可靠。5. 串口通讯联调协议帧设计、解析状态机和虚拟示波器验证5.1 自定义串口帧格式串口通讯说起来简单但无人机系统里同时存在两类数据一类是 OpenMV 上报的视觉目标一类是飞控发给调试端的 PID 过程量。为了不把两类数据混在一起建议给每帧打一个 type 字段。帧格式如下帧头 0xAA 0x55接着是 type然后是 data length再是 data最后是累加和校验。偏移字段字节数说明0帧头010xAA1帧头110x552type10x01 视觉结果0x02 PID 数据0x03 心跳3length1data 区字节数不包含帧头和校验4..dataN具体业务数据N4checksum1除自身外所有字节累加和的低 8 位例如视觉结果数据区长度为 3颜色编号、形状编号、可信度。发送完数据后主控收到一个完整帧先把前 N 个字节求和和最后一位比较相同则认为这一帧有效。为什么不用更简单的“一次发多个字节加换行”因为 PID 数据帧的二进制数值里可能包含 0xAA 或 0x55如果只按帧头截断数据区出现这些字节就会丢帧。加了 length 和 checksum 后主控收不到长度字段就无法解析不会把数据区里的 0x55 当成下一帧帧头。5.2 STM32 的 UART 中断接收状态机主控串口收包如果放在 while 循环里轮询 USART_FLAG_RXNE会占用大量时间。更可靠的是用一个中断状态机每收到一个字节就切换一次状态uint8_t rx_buf[16]; uint8_t rx_index 0; uint8_t data_len 0; uint8_t parse_done 0; void USART1_IRQHandler(void) { uint8_t byte USART1-DR; if (rx_index 0) { if (byte 0xAA) rx_buf[rx_index] byte; return; } if (rx_index 1) { if (byte 0x55) { rx_buf[rx_index] byte; return; } rx_index 0; return; } if (rx_index 2) { rx_buf[rx_index] byte; // type return; } if (rx_index 3) { rx_buf[rx_index] byte; data_len byte; if (data_len sizeof(rx_buf) - 6) { rx_index 0; return; } return; } if (rx_index 4 rx_index 4 data_len) { rx_buf[rx_index] byte; return; } if (rx_index 4 data_len) { rx_buf[rx_index] byte; uint8_t sum 0; for (uint8_t i 0; i rx_index - 1; i) sum rx_buf[i]; if (sum byte) { parse_done 1; // 在主循环或任务中处理 } rx_index 0; return; } rx_index 0; }解析完成后不要直接在中断里调用CtrlMotor()因为中断里做复杂计算会引发优先级反转导致其他串口数据漏收。一般做法是设一个parse_done标志主循环检测到后把rx_buf拷贝到全局变量再调用状态机处理。串口波特率在 115200 时一个字节耗时约 87us状态机只要执行几十条指令时间完全足够。5.3 用虚拟示波器验证 PID 过程量积分赛里除了功能完成还要看稳定性。调整 PID 时可以通过 USART2 输出三个过程量循迹误差、yaw 输出、yaw 角速度。格式用 CSV 最好100,20,-5\n。把串口接到 PC 后用虚拟示波器或 Serial Digital Scope 打开按行解析可以明显看到误差是否收敛到零。虚拟示波器还允许给每一条曲线设置颜色误差曲线用红色输出曲线用蓝色能在一屏内快速发现过冲。打印频率控制到 50Hz也就是每 20ms 打一次避免串口占用过高。void DebugTask_20ms(void) { static const char fmt[] %d,%d,%d\n; char line[48]; snprintf(line, sizeof(line), fmt, line_err, (int)yaw_out, yaw_rate_raw); HAL_UART_Transmit(huart2, (uint8_t *)line, strlen(line), 10); }5.4 整机验证顺序到了第二次积分赛留给联调的时间通常只有两三个晚上所以验证顺序要按风险大小来排。先验证循迹把飞机放在引导线起点贴近地面低空移动观察 ADC 输出的五路状态是否正确。再验证视觉用一张色卡在镜头前移动看主控串口收到的颜色编号、形状编号是否和色卡一致如果有时对有时错把识别代码里的area_threshold调大过滤小型噪点。最后再验证协议连续发 100 帧统计串口收到的有效帧数丢包率应小于 1%一旦有丢包优先检查校验和算法和波特率误差。完成这三步后再将视觉结果作为循迹的输入测试无人机飞到目标区域后是否悬停并上报。本文还有配套的精品资源点击获取