
简介这是一份基于STM32与OpenMV的自动泊车赛题完整实现方案源自南航电赛校赛题目并与青岛2021市电赛控制题一致适合正在备战电子设计竞赛、需要整套控制与视觉识别代码参考的参赛队伍。工程基于hjduino开源项目二次开发整体包含OpenMV端Python代码与STM32端Keil5工程可直接拷贝烧录降低从零搭建的难度。压缩包共201个文件约6.46MB主要涵盖STM32标准外设库的h/c源文件、Keil工程配置文件、OpenMV的py脚本、TFLite模型文件以及一份技术文档PDF覆盖底层驱动、编译链接、模型部署与方案说明等环节。已有181人学习下载。借助这份资料读者可拿到从硬件配置到视觉识别再到控制流程的完整代码还能通过技术文档理解泊车路径规划与循迹思路尤其适合快速搭建原型并迁移至其他泊车或寻迹赛题。1. 自动泊车赛题到底考什么一对耦合环节两个系统的分工赛场上灯光一变暗不少队伍手里的OpenMV立刻把白色停车线丢了大半车直接冲着库位斜角冲过去这时候STM32那边再好的PID也救不回来。自动泊车这道题看上去是“识别倒库”实际考的是两个子系统之间的信息链路OpenMV负责把“我看到了什么”压缩成几字节的帧STM32负责把“我该往哪走”拆成电机能执行的PWM和方向信号。哪一头做重了都容易翻车——让视觉芯片去算路径帧率会掉到让车一顿一顿让单片机去买视觉原始图像串口带宽和内存又扛不住。所以整道题的架构就一句话视觉只给结论控制只认指令。这道题适合两类人。一类是刚做完循迹小车、想往视觉方向迈一步的OpenMV相当于把摄像头和图像处理封装成现成的Python环境不需要懂底层ISP另一类是已经在用STM32做运动控制的想补上“传感器给的是语义而非距离”这一课。下面按我自己搭这套系统时的顺序写从硬件拓扑到联调避坑每一步都直接可复现。2. 架构先行OpenMV负责“看”STM32负责“开”2.1 为什么把视觉和控制分开延迟与职责边界自动泊车最忌讳把控制回路和视觉处理揉在同一颗芯片里。OpenMV的MicroPython环境跑图像算法时一帧720p灰度图的线性拟合耗时大概在8到15毫秒如果再把PID和电机逻辑塞进去中断优先级会互相打架结果就是摄像头采集丢帧、串口发送卡顿、电机响应滞后。常见的分工方式是OpenMV只做灰度图像处理输出车位状态、车辆相对库位中线的横向偏差angle、距离标志flag。频率控制在20到30Hz即可太快的串口流反而让STM32的接收缓冲被灌满。STM32做泊车状态机、电机速度闭环、舵机/差速转向。视觉数据只作为外部输入像传感器一样被消费不参与内部实时中断。这个“视觉给语义、控制给动作”的分层还有一个实际好处赛场上可以用一块亚克力板把OpenMV和STM32隔开OpenMV的排线走线再乱也不影响单片机这边的PWM输出。电机的换向火花和电池的瞬态跌落是两套地视觉板用独立LDO供电控制板用电池直接供电避免视觉帧率波动时电流尖峰把MCU复位。2.2 最小可用硬件清单与连接拓扑按成本从低到高一套能跑通校赛的硬件是这样的器件型号参考作用备注主控STM32F103C8T6最小系统板电机控制、状态机、串口接收淘宝蓝板即可不需要F4视觉模块OpenMV Cam H7或M7图像识别与坐标输出H7的帧率比M7高一倍预算允许优先H7电机驱动TB6612FNG双路直流电机驱动比L298N轻压降小电机N20减速电机带编码器差速转向编码器用于速度闭环电池2S锂电 7.4V 1000mAh动力电源不要用4节AA电压跌落会让你误以为代码有bug降压AMS1117-3.3或DCDCOpenMV和串口电平匹配注意共地连接拓扑我一般这样做STM32的PA9/PA10作为USART1连接OpenMV的P4/P5波特率设1152008位数据、无校验、1停止位。TB6612的PWMA/PWMB接STM32定时器输出通道比如TIM2的CH1/CH2AIN1/AIN2控制方向。编码器A/B相接TIM4的CH1/CH2做正交解码。OpenMV那边不用额外接舵机把PWM输出留给STM32统一管理这样联调时只需要盯一个MCU。2.3 通讯协议设计一帧数据包含什么很多队伍在这里栽跟头直接把OpenMV的识别结果用print打出来靠USB串口助手看然后说“数据能收到”。但STM32和OpenMV之间走的是UART不是USB虚拟串口。USB串口助手能收到的数据单片机的USART不一定能正确解析。两片之间要有一套自洽的帧协议。我用的协议格式是固定长度8字节帧头(0xAA 0x55) 数据类型(1Byte) 数据A(1Byte) 数据B(1Byte) 数据C(1Byte) 校验(1Byte) 帧尾(0x0D)数据类型字段说明当前帧是什么0x01表示“泊车引导”数据A是横向偏差角度带符号单位1度数据B是距离档位0远1近2到位数据C保留0x02表示“紧急停止”。校验字节简单做法是把前五个字节累加后取低8位不要用CRC16——在20Hz的帧率下CRU开销不大但调试时手工算太麻烦。协议里要包含“数据类型”字段的原因是泊车过程中OpenMV可能会在“寻库状态”和“倒库状态”之间切换不同状态下发送的数据语义不同。如果协议里没有类型字段STM32只能靠猜一旦丢帧就直接误判。帧长固定也让接收端可以用状态机解析不需要处理粘包。3. OpenMV端阈值调参、线性拟合与串口发送的完整代码3.1 阈值标定不用IDE手调用直方图选ROIOpenMV IDE的阈值编辑器确实方便但直接整帧调阈值有个问题赛场地板往往有反光、有拼接缝整帧里总有一部分像素落在目标阈值之外。我一般先手动框出画面下方三分之一作为感兴趣区域ROI只在这个区域内统计灰度直方图。import sensor, image, time from pyb import UART sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) # 灰度图速度比RGB565快 sensor.set_framesize(sensor.QVGA) # 320x240够用且帧率高 sensor.set_windowing((0, 80, 320, 160)) # 只处理画面下半部分避开远处干扰 sensor.skip_frames(30) sensor.set_auto_gain(False) sensor.set_auto_exposure(False) sensor.set_vflip(True) # 根据安装方式调整 # 灰度阈值低于该值为白色停车线高于为深色地板 # 在光线不稳定的赛场上建议阈值设在35到55之间 threshold 45这段代码里有两个关键参数set_windowing把图像裁剪到下半部分既减少计算量也把远处看台、评委席的人影排除在外set_auto_gain和set_auto_exposure必须关掉否则OpenMV会自动调整曝光同一个阈值在赛前和开赛后的识别结果完全不同。这两个关闭动作是很多队伍忽略的——自动曝光在室内日光灯下会周期性波动导致泊车线忽粗忽细。阈值标定不建议一次到位。我的做法是赛前用手机录像拍一段赛场光照环境在OpenMV IDE里对截图反复调阈值记住“线能在画面上连续出现但背景噪点最少”的那个区间。通常阈值在35到55之间如果地板是深灰色阈值可以降到25如果是浅色地板阈值要升到60甚至更高。3.2 泊车线识别逻辑从“找到线”到“给出角度”识别逻辑分三步。第一步在ROI区域内找所有低于阈值的像素连通域第二步过滤掉面积过小或长宽比不合理的连通域只保留接近直线的候选第三步对候选区域做线性拟合得到直线与图像竖直方向的夹角以及直线的横向位置。为什么要做线性拟合而不是直接用find_blobs的最大外接矩形因为停车线在画面里是倾斜的外接矩形会给出行宽度和高度但给不出“车头相对库位中线偏了多少”这个量。线性拟合得到的rho和theta经过极坐标转换能直接映射到车的横向偏差。OpenMV自带的find_lines用霍夫变换参数不好调而且对短线段特别敏感。我更倾向于用find_blobs找出点集再自己做最小二乘拟合def detect_line(img): blobs img.find_blobs([threshold], pixels_threshold30, area_threshold30, mergeTrue) pts [] for b in blobs: # 取连通域中所有满足阈值的像素坐标 for p in b.pixels(): pts.append(p) if len(pts) 20: return None, None # 点数太少不足以拟合直线 # 最小二乘拟合x a*y b以y为自变量 # 因为停车线在画面上近似垂直用x对y回归比y对x回归稳定 n len(pts) sum_y sum(p[1] for p in pts) sum_x sum(p[0] for p in pts) sum_yy sum(p[1]*p[1] for p in pts) sum_xy sum(p[0]*p[1] for p in pts) denom n*sum_yy - sum_y*sum_y if abs(denom) 1e-6: return None, None a (n*sum_xy - sum_x*sum_y) / denom b (sum_x - a*sum_y) / n # 计算偏差角直线xa*yb的斜率a对应角度 angle math.atan(a) * 180 / math.pi return angle, b这里拟合用的是x a*y b而不是y a*x b原因在于停车线在画面里接近竖直如果用y对x回归竖直线的斜率无穷大数值极不稳定。换自变量之后a的数值范围基本落在-2到2之间PID的输入也平稳。拟合后还要算“横向偏差”。常见做法是取直线在画面底部y240处的x值与画面中心x160做差归一化到-1到1区间。这个归一化后的值就是发给STM32的核心数据。注意角度方向OpenMV画面里x轴向右如果拟合线的x值在中心右侧说明车头偏左需要向右打方向——这个符号关系要在联调时确认光看代码很容易搞反。3.3 OpenMV串口发送组帧、清缓冲、防粘包uart UART(3, 115200, timeout_char100) # TXP4, RXP5 uart.init(115200, bits8, parityNone, stop1) def pack_and_send(data_type, data_a, data_b, data_c): frame bytearray(8) frame[0] 0xAA frame[1] 0x55 frame[2] data_type frame[3] data_a 0xFF frame[4] data_b 0xFF frame[5] data_c 0xFF frame[6] (frame[0] frame[1] frame[2] frame[3] frame[4] frame[5]) 0xFF frame[7] 0x0D uart.write(frame) while True: img sensor.snapshot() angle, offset detect_line(img) if angle is not None: # angle转为有符号字节-90~90映射到-128~127 angle_int int(angle) offset_int int(offset * 10) # 扩大10倍保留一位小数精度 pack_and_send(0x01, angle_int 0xFF, offset_int 0xFF, 0) else: # 没有识别到线时发0x02紧急帧而不是不发数据 pack_and_send(0x02, 0, 0, 0) time.sleep_ms(50)这段代码有两个容易踩坑的地方。第一angle_int 0xFF这行把负数截断成无符号字节了STM32端解析时必须先判断最高位再还原成有符号数。C语言里直接int8_t val (int8_t)buf[3]就能自动处理但如果用的是uint8_t数组就要手动做符号扩展。第二识别不到线时不要什么都不发——我见过队伍在这时候让OpenMV保持静默结果STM32还拿上上次的转角数据继续跑车直直冲出去。发0x02紧急帧STM32收到后立即刹车。time.sleep_ms(50)把发送频率限制在20Hz。理论上OpenMV可以跑到30到40Hz但STM32的串口接收中断在高负载下会丢数据20Hz是我试下来最稳的折中。如果你要用更高频率务必在STM32端把环形缓冲区开大到256字节以上否则USART的ORE中断会让你怀疑人生。4. STM32端串口解析、PID闭环与泊车状态机的实现4.1 串口接收中断方式加强行缓冲禁止在中断里做业务STM32接收OpenMV的数据最常见也最蠢的做法是在主循环里轮询USART_GetFlagStatus一旦数据量大了或者主循环被PID占用就会丢字节。正确做法是开接收中断把字节塞进环形缓冲主循环里再去解析帧。// ring_buffer.c 核心逻辑 #define RING_SIZE 256 volatile uint8_t ring_buf[RING_SIZE]; volatile uint16_t head 0, tail 0; // USART1中断服务函数 void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE)) { uint8_t data USART_ReceiveData(USART1); // 环形缓冲写入注意覆盖老数据的处理 uint16_t next (head 1) % RING_SIZE; if (next ! tail) { // 缓冲未满 ring_buf[head] data; head next; } else { // 缓冲满丢弃最老的数据保证新数据优先 tail (tail 1) % RING_SIZE; ring_buf[head] data; head next; } } } // 主循环里调用返回0表示还没凑齐一帧返回1表示解析完成 // 解析出的数据存入frame_output结构体 int parse_frame(uint8_t *out_type, int8_t *out_a, int8_t *out_b) { static uint8_t state 0; static uint8_t buf[8]; static uint8_t idx 0; while (tail ! head) { uint8_t d ring_buf[tail]; tail (tail 1) % RING_SIZE; if (state 0) { if (d 0xAA) state 1; } else if (state 1) { if (d 0x55) state 2; else state 0; // 帧头错误重新同步 } else if (state 2) { buf[idx] d; if (idx 6) { // 校验和 uint8_t sum 0xAA 0x55; for (int i 0; i 5; i) sum buf[i]; if ((sum 0xFF) buf[5]) { *out_type buf[0]; *out_a (int8_t)buf[1]; *out_b (int8_t)buf[2]; state 0; idx 0; return 1; } else { state 0; idx 0; // 校验失败重新找帧头 } } } } return 0; }这段解析里最关键的是状态机回到state0的时机。在state1时如果第二次收到的不是0x55我选择回到state0而不是继续等待0x55这是为了防止数据流中出现连续的0xAA导致状态机错乱。校验失败后也是回到state0让下一帧的0xAA重新触发同步。实际使用中还有一个细节USART_ReceiveData在RXNE中断里读一次就自动清标志位不需要手动清。如果你在调试时发现中断频繁触发但数据是错的检查是不是在中断里调用了printf这类重函数——耗时会拉长中断响应下一个字符就把RXNE覆盖了。4.2 电机驱动与PID速度闭环先让车走直再谈泊车自动泊车对低速下的直线性要求极高。如果用开环PWM驱动两个N20电机哪怕两个电机型号一样同占空比下的转速差也能让车在1米内偏出10厘米以上。所以必须在STM32上做速度闭环我用的是增量式PID控制量为两个电机的PWM占空比差值。// 增量式PID返回PWM调整量 int incremental_pid(int target_speed, int current_speed, PID_t *pid) { int error target_speed - current_speed; pid-integral error; if (pid-integral 100) pid-integral 100; if (pid-integral -100) pid-integral -100; int delta pid-kp * (error - pid-last_error) pid-ki * error pid-kd * (error - 2*pid-last_error pid-last_last_error); pid-last_last_error pid-last_error; pid-last_error error; return delta; } // 主循环中每10ms调用一次speed_l/speed_r由编码器测出 int target_base 60; // 基础速度占空比单位约30% int diff incremental_pid(0, speed_l - speed_r, pid_diff); // 两轮速度差趋零 int pwm_l target_base diff; int pwm_r target_base - diff; // 限制PWM范围在-100到100之间负数表示反转这里的PID分两层外层PID控制“左轮速度和右轮速度之差”趋近于0让车走直线内层不做PID直接用编码器测出的速度和目标速度的偏差去修正PWM。如果速度差PID的kp给得太大车会左右摆头给太小直线性又不够。我调参的经验是先给kp1.0、ki0.1、kd0在平整地面上跑2米直道看偏差能否控制在2厘米内。若左右摆头把kp减半若总体偏向一侧把ki加到0.2以上。编码器读数用的是TIM4的正交解码模式。N20电机编码器一般是13线或17线减速比1:30输出到单片机的脉冲频率在低速时只有几百赫兹完全够TIM4处理。接线时注意A相B相不要接反接反了速度是负值PID会把车越调越偏。我在调试时遇到过编码器数值跳变在正负几百之间来回跳查了半天是杜邦线松动——编码器的信号是推挽输出杜邦线接触不良时电平处于不确定区TIM4会误判成两倍的脉冲。后来我把编码器接线直接焊在最小系统板上再也没有这个问题。4.3 泊车状态机进场、对齐、倒车、入库自动泊车的控制逻辑本质是一个有限状态机。每个状态有独立的进入条件、执行动作和退出条件。下面是我用的状态定义状态触发条件动作退出条件FIND_LINE上电复位直行搜索停车线收到0x01帧且横向偏差在±1以内ALIGN找到线且居中小角度修正方向保持低速距离flag近即已接近库位边缘REVERSEflag近反向PWM一边倒车一边用角度数据修正收到0x02紧急帧或flag到位STOPflag到位或超时刹车、抱死手动复位// 状态机主循环每20ms执行一次对齐OpenMV的20Hz帧率 void parking_state_machine(int8_t angle, int8_t offset, uint8_t flag) { switch (g_state) { case STATE_FIND_LINE: // 寻找阶段低速直行不做转向修正 set_motor(50, 50); if (flag 1 abs(angle) 10) { g_state STATE_ALIGN; g_tick 0; } break; case STATE_ALIGN: g_tick; // 用角度做比例控制角度为正线在右侧则右转 int turn (int)(angle * 1.5); if (turn 20) turn 20; if (turn -20) turn -20; set_motor(40 turn, 40 - turn); // 对齐完成后进入倒车 if (flag 2) { g_state STATE_REVERSE; g_tick 0; } break; case STATE_REVERSE: // 倒车时方向修正要反过来角度正意味着线偏右车应左转 int rev_turn (int)(-angle * 2.0); set_motor(-45 rev_turn, -45 - rev_turn); if (flag 3 || g_tick 150) { g_state STATE_STOP; } break; case STATE_STOP: set_motor(0, 0); break; } }倒车时方向修正的符号取反是这套代码里最阴间的部分。前进时如果线在右边你得右转去靠近倒车时如果线在右边你得左转才能让车尾往左靠。这个“转向方向跟着车速方向变号”的逻辑新手上车第一版代码十有八九会搞反车在库门口画出S形轨迹。如果调试时发现倒车轨迹是S形先检查这里不要动PID参数。状态机里还要加超时保护原因很现实赛场上OpenMV大概率会丢帧或者识别错乱如果一直等不到flag变化车就会顶着库位边缘往前蹭。我一般给每个状态设一个最长持续时间倒车超过3秒强制刹车哪怕没进库也先停下来总比冲出去扣分少。5. 联调避坑串口乱码、视觉震荡、掉电复位那些事5.1 串口乱码与TTL电平不匹配现象OpenMV接上串口后在电脑助手能看到数据但接STM32后全是乱码或者偶尔正确偶尔乱码。原因OpenMV的UART是3.3V TTL电平STM32F103的USART也是3.3V TTL两者其实可以直接连。但有些队伍把OpenMV的P4/P5接到了STM32的PA2/PA3复用映射的USART2上软件却把USART1的时钟打开了——配置的串口号和引脚映射完全对不上。另一个常见原因是波特率不匹配OpenMV和STM32两侧都设了115200但分频上有误差导致偶尔收到0xAA 0x55后校验失败。解决用逻辑分析仪抓TX引脚的波形直接读波特率。不用逻辑分析仪的话可以让OpenMV每秒发一帧固定0x01开头的数据STM32在接收到0xAA后点亮一个LED如果LED不亮或乱闪说明物理路径上有问题先拿杜邦线短接TX/RX回环测试。5.2 OpenMV帧率抖动导致控制震荡现象车在直线行驶时轻微左右摆头但PID参数已经调得很小了依然摆。原因OpenMV的串口发送间隔是time.sleep_ms(50)但实际间隔受图像处理耗时影响在60到85毫秒之间波动。STM32每20ms读一次最新帧有时连续三帧都是旧数据有时突然来一帧新数据相当于给PID输入加了一个随机延迟。PID在这个延迟下容易产生超调表现为高频小幅振荡。解决在OpenMV端把发送和采集解耦用一个定时器中断来发送上一帧的结果而不是处理完就立刻发。同时在STM32端做一阶低通滤波filtered (old * 7 new * 3) / 10这一步能吃掉大部分帧率抖动。这个滤波是控制算法级别的不能省——我见过队伍在这里反复调PID耗时一整天实际问题是数据传输的抖动。5.3 电池电压跌落导致系统复位现象车一加速或转向STM32就复位OpenMV的IDE串口提示断开连接。原因2S锂电的标称电压是7.4V满电8.4VN20电机启动瞬间电流可达1A以上电池内阻和杜邦线电阻合计会让电压跌到4V以下。STM32最小系统板上的稳压芯片如果输入低于5V输出电压垮掉MCU直接掉电复位。解决把电机电源和逻辑电源分开走线。电池正极先给TB6612的VM再通过一个二极管降压後给AMS1117供电。或者在电池输出端并联一个470uF的电解电容吸收瞬态跌落。最省事的方案是直接换成2S 18650电池组内阻比聚合物锂电小得多电压跌落能控制在0.5V以内。5.4 PID调参的“玄学”先从纯P开始现象网上抄了一堆PID参数装上之后车不是原地抖就是猛打方向。原因PID参数的耦合性让新手摸不着头脑。很多人抄来的是平衡小车的参数而小车是差速转向运动模型惯性参数完全不同。直接上去调三个参数根本无法判断是哪个环节出了问题。解决先把kd和ki设成0只保留kp一点点加到车刚好能稳定跟随而不振荡。这个“刚好”的值记为kp0。然后把kp降到kp0的70%开始加ki直到静态偏差消失。最后才加kd用来抑制超调。顺序不要乱顺序乱了就是玄学顺序对了就是工程。5.5 编码器读数跳变导致速度闭环失效现象编码器速度在某个占空比附近突然翻倍或者静止时读数在正负10之间跳。原因TIM4的正交解码模式要求A相B相有严格的相位差杜邦线或面包板引入的寄生电容会让边沿抖动TIM4把一次跳变识别成两次。另一种原因是编码器电源纹波太大信号线上出现类噪声的毛刺。解决用示波器看编码器A相B相的波形确认边沿是否干净。不干净就加100欧姆串联电阻和10nF对地电容做RC滤波。静止时的跳动是正常的——定时器捕获的脉冲数可能是“半个”产生的计数错乱在软件里做死区处理if (abs(speed) 10) speed 0这一行能省掉你两小时调PID的时间。6. 验证方法用逻辑分析仪和示波器做赛前体检赛前最后一个晚上不要再去改代码参数了做三件事给系统做体检。第一件用逻辑分析仪同时抓OpenMV的TX和STM32的RX确认每帧8字节的帧间隔在40到60毫秒之间且帧头0xAA 0x55稳定出现。如果出现长延时或帧头错位说明OpenMV端的图像处理有偶发卡顿要降低分辨率或裁剪ROI。第二件把两个电机悬空用示波器测TB6612输出的PWM波形确认频率在10kHz到20kHz之间。频率太低电机噪音大频率太高MOS管开关损耗上升。用手堵住电机轴观察PWM占空比是否自动增大——这验证了编码器反馈和PID是否形成闭环。如果堵住后占空比不变检查编码器接线和TIM配置。第三件做一次完整的泊车流程测试用一个秒表记总时间。要让车从启动到入库尽量控制在25秒内——很多校赛有单次运行时间上限超过会被判未完成。如果发现自己常常超时问题往往不在速度而在ALIGN阶段浪费太久——OpenMV给了flag信号但STM32的状态机要求角度偏差小于某个阈值才切换状态这个阈值设太严会卡住。我自己有次比赛前夜因为贪心把OpenMV的ROI扩大了一圈想让识别更稳结果曝光波动让阈值失效赛场上第一轮直接没识别到线。后来我养成一个习惯比赛用的代码定稿后ROI、阈值、PID三个参数全部写在源码注释最上方赛前只动这三个数字不做任何结构性修改。这套系统从硬件到协议到控制要改的地方其实就那么几个剩下的都是调参。先从纯P调起再逐步加I和D顺序千万别反。这句教训是从两轮车上换来的希望帮到你。本文还有配套的精品资源点击获取