简介:面向高校电子设计竞赛实训场景,以南航电赛校赛自动泊车为背景,基于STM32与OpenMV实现车位识别与自动泊车控制,整体项目包含完整源码、工程文件与说明文档,适合毕业设计、课程设计、工程实训及各类学科竞赛直接复用或二次扩展。资源共收纳201个文件,压缩包仅6.45MB,涵盖C语言源码(36个头文件、34个源文件)、Keil工程配置、编译生成文件、OpenMV视觉识别的Python脚本与模型文件,另有PDF说明文档,目录结构完整便于检索。单片机端包含定时器、闪存、系统时钟、模数转换、I2C通信、串口、FSMC等常用外设驱动,是学习标准外设库工程组织的良好范例。目前已有205人学习下载,资料经过实测验证,按文档连线烧录即可复现,适合想快速搭建自动泊车原型或深入理解机器视觉与单片机协同工作的读者。
1. 从模拟车库到实车:自动泊车项目为什么用STM32+OpenMV
赛场上一块黑色地胶贴出几道白色边线,小车开到车位入口前又冲出去半米,再倒回来,车尾压线。问题不是电机太慢,而是OpenMV已经把车位识别出来了,STM32却不知道“该往哪打方向盘”。这个自动泊车系统项目,就是解决“看到了却没停进去”的最后一环:OpenMV做图像识别与车位坐标计算,STM32F103做底层控制与泊车决策,两边通过串口交换数据。资源包内包含完整源码、工程文件和接线说明,代码按STM32标准库组织,适合毕业设计、课程设计、电子设计竞赛、工程实训和初期项目立项复刻。只要手头有STM32F103最小板、OpenMV摄像头、一块电机驱动和两个直流电机,再配上杜邦线就能搭出一台能自动找车位、侧方位入库的小车。
2. 系统架构与硬件链路:串口协议决定上下位机协作方式
2.1 视觉与运动控制分离的选型逻辑
既然是自动泊车,为什么不让OpenMV直接驱动电机?OpenMV内部虽然也是STM32加外置摄像头,但它的MicroPython环境把大部分算力花在图像采集、颜色识别和线段提取上,线程调度和实时性不够稳定。而车模控制需要微秒级定时的PWM输出和中断式串口接收,用标准库直接操作寄存器更可控。资源包源码对应的是STM32F103标准库文件:stm32f10x_tim.c用于输出PWM和定时器计数,stm32f10x_usart.c负责与OpenMV通信,stm32f10x_adc.c可采集电池电压或灰度传感器,stm32f10x_fsmc.c用来驱动LCD屏显示参数。任务边界清晰:OpenMV只回答“车位在哪、车正不正”,STM32回答“给多少PWM、何时打死方向”。
2.2 硬件接线与引脚规划
常见做法是用STM32F103C8T6最小系统板,配合OpenMV3及以上型号。如果手头没有扩展板,就按引脚定义用面包板和杜邦线连接。接线表如下:
| STM32F103引脚 | 外设引脚 | 说明 |
|---|---|---|
| PA9 (USART1_TX) | OpenMV P4 (RX) | 下发调试命令与配置参数 |
| PA10 (USART1_RX) | OpenMV P5 (TX) | 接收车位坐标帧 |
| 5V | VIN 或 5V | 给OpenMV供电 |
| GND | GND | 必须共地,否则串口乱码 |
| PA6 / PA7 | 电机驱动IN1 / IN2 | 左轮方向 |
| PB0 / PB1 | 电机驱动IN3 / IN4 | 右轮方向 |
| PA8 | 舵机PWM信号 | 转向舵机(可选) |
注意:如果使用电池底盘,STM32和OpenMV的电源建议都从主电源稳压后引出,避免两路GND电位不一致。信号线较长时,在TX与RX之间串联一个1kΩ电阻,能降低电机启动瞬间的干扰。
2.3 通信帧格式:坐标数据怎么承载
OpenMV与STM32通信最常见的坑是直接用print发送字符串坐标。STM32端无论用scanf还是自行解析,遇到断包、负号、小数点和粘包都要写一堆容错代码。项目里建议下沉协议,固定帧结构:
#define FRAME_HEAD1 0xAA #define FRAME_HEAD2 0x55 #define FRAME_TAIL1 0x0D #define FRAME_TAIL2 0x0A typedef struct { uint8_t head[2]; // 0xAA 0x55 uint8_t msg_id; // 0x01: 车位中心坐标; 0x02: 超声波距离; 0x03: 系统状态 uint8_t length; // 后面数据的字节数 int16_t data[8]; // 连续存放 x_center, y_center, x_offset, angle 等 uint8_t crc; // 对 msg_id 到 data 末尾做累加校验 uint8_t tail[2]; // 0x0D 0x0A } ParkingFrame_t;CRC采用简单累加:发送端和接收端都对msg_id、length和data每个字节求和,取低8位。接收端计算结果与收到的crc不相等就丢弃整帧。这种设计下,即使0xAA出现在数据区,也不会误判,因为后面还有帧尾确认符。实际运行时,OpenMV每20ms发送一帧坐标,STM32收到后设置活动标志位;连续500ms没收到帧就停车,防止摄像头卡死导致小车冲出场地。
3. OpenMV端视野处理:线提取、角点计算与停车位判断
3.1 降低环境干扰的预处理流程
OpenMV的镜头在实验室日光灯下和室外阳光下反差极大,直接对彩图找线,阈值需要反复改。项目里应先转成灰度图,再做二值化,把停车线变成白色像素,地面变成黑色。实际调试时,我会让OpenMV侧保留“原灰度”和“二值图”两条输出路径,用IDE窗口同时观察,比只盯着数值更直观。预处理的目标是强化白和黑的边界,让后续霍夫变换找到的线段更稳定。
3.2 用find_lines找线段,再组装出缺失的车位边框
OpenMV自带的find_rects()可以找出四边形轮廓,但自动泊车场景的车位一般不是完整矩形——对应倒车入库,车位前方是开口的,缺一条边。直接调用find_rects()往往只找到三条边,矩形置信度低。更稳的做法是先用find_lines()把轮廓线段全部找出来,再按线段角度和端点距离组装成车位边框。
| 线段角度范围 | 类别 | 用途 |
|---|---|---|
| theta < 20° 或 theta > 160° | 垂直 | 车位左右边界 |
| 70° < theta < 110° | 水平 | 车位底线/顶线 |
import sensor, image, time, ustruct from pyb import UART sensor.reset() sensor.set_pixformat(sensor.GRAYSCALE) sensor.set_framesize(sensor.QQVGA) # 160x120,提高帧率 sensor.skip_frames(30) sensor.set_auto_gain(False) sensor.set_auto_whitebal(False) uart = UART(3, 115200, timeout_char=1000) gray_threshold = (170, 255) # 停车线是白色,灰度值高 def lines_to_parking(lines): vertical_lines = [] horizontal_lines = [] for l in lines: if l.theta() < 20 or l.theta() > 160: vertical_lines.append(l) elif 70 < l.theta() < 110: horizontal_lines.append(l) if len(vertical_lines) >= 2 and len(horizontal_lines) >= 2: left = min(l.x1() for l in vertical_lines) right = max(l.x2() for l in vertical_lines) top = min(l.y1() for l in horizontal_lines) bottom = max(l.y2() for l in horizontal_lines) return (left, top, right, bottom) return None clock = time.clock() while True: clock.tick() img = sensor.snapshot() img.binary([gray_threshold]) img.erode(1) # 去除孤立噪点 lines = img.find_lines(threshold=2500, theta_margin=15, rho_margin=15) rect = lines_to_parking(lines) if rect: img.draw_rectangle(rect, color=200) x_center = (rect[0] + rect[2]) // 2 y_center = (rect[1] + rect[3]) // 2 # 按第2章帧协议发送: AA 55 01 04 x_hi x_lo y_hi y_lo crc 0D 0A data = ustruct.pack("<hh", x_center, y_center) crc = (0x01 + len(data) + data[0] + data[1]) & 0xFF uart.write(b'\xAA\x55' + bytes([0x01, len(data)]) + data + bytes([crc, 0x0D, 0x0A])) print(clock.fps())逻辑说明:lines_to_parking把线段按角度分成垂直和水平两类,分别取最小和最大值组成车位包围框。img.erode(1)在二值图上做一次腐蚀,能去掉细小杂点,但车位线本身很细时腐蚀后可能消失,所以核大小设置为1。find_lines的threshold对应梯度强度阈值,越大则只保留对比度强的线段;室内灯光建议在2000~3000,室外强光可以提高到4000以上。
3.3 把车位的可用状态编码成状态机
识别到矩形之后,还要判断这个车位能不能停。我在项目里维护三个状态:NO_PARKING、PARKING_EMPTY、PARKING_OCCUPIED。判断空位的条件是矩形内部灰度均值接近地面背景,且矩形宽度与像素距离的比值落在合理范围。如果矩形内部有大片高亮物体,说明有障碍物占位,仍发送PARKING_OCCUPIED,STM32继续寻找下一个空位。状态机可以避免小车停在已经被占用的车位前反复折腾。
4. STM32端数据处理与运动控制:串口中断、状态机与PID
4.1 标准库串口初始化与DMA接收
STM32标准库新建工程时,多数人习惯用轮询方式接收串口。但OpenMV每20ms发一包数据,主循环如果同时处理PWM、传感器和LCD刷新,轮询很容易漏掉末尾两个字节。这里改用USART1空闲中断加DMA接收,一次性把不定长帧搬到缓冲区。
void USART1_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = 115200; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_RX | USART_Mode_TX; USART_Init(USART1, &USART_InitStructure); USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); USART_Cmd(USART1, ENABLE); } void DMA1_Channel5_Config(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel5); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)usart1_rx_buf; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = BUFFER_SIZE; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel5, &DMA_InitStructure); USART_DMACmd(USART1, USART_DMAReq_RX, ENABLE); DMA_Cmd(DMA1_Channel5, ENABLE); }参数说明:波特率必须与OpenMV端一致,这里用115200。DMA_Mode_Circular循环模式省去手动清零,但缓冲区满了会自动覆盖,所以需要在空闲中断里判断当前写入位置。USART_ITConfig开启IDLE空闲中断,串口一帧结束后触发,读取DMA1_Channel5->CNDTR就能知道本帧字节数,随后把整个缓冲区交给解析状态机。
4.2 帧解析状态机与防粘包
状态机解析是嵌入式可靠通信的兜底手段。每次串口空闲中断触发后,把DMA收到的原始字节依次喂给下面这个函数:
uint8_t parser_step(uint8_t byte, ParkingFrame_t *frame) { static uint8_t state = 0; static uint8_t idx = 0; static uint8_t expected_len = 0; static uint8_t sum = 0; switch (state) { case 0: if (byte == 0xAA) state = 1; break; case 1: if (byte == 0x55) { state = 2; sum = 0; } else state = 0; break; case 2: frame->msg_id = byte; sum += byte; state = 3; break; case 3: frame->length = byte; sum += byte; expected_len = byte; idx = 0; state = expected_len ? 4 : 5; break; case 4: ((uint8_t *)frame->data)[idx++] = byte; sum += byte; if (idx >= expected_len) state = 5; break; case 5: state = 0; return (sum == byte) && (frame->msg_id == 0x01 || frame->msg_id == 0x02 || frame->msg_id == 0x03); } return 0; }这个状态机的关键在于:只要一个字节不匹配帧头,就退回状态0;数据长度只信任帧内length字段,不会因为粘包把下一帧数据错误消化。如果车内环境电磁干扰较强,把累加校验换成CRC8可以进一步提升可靠性,代价是发送端计算量稍大。帧解析成功后,把frame->data[0]和frame->data[1]作为车位中心像素坐标,换算成横向偏差送入控制模块。
4.3 差速转向与增量式PID
转向控制我习惯在STM32上实现两级:底层是速度环,顶层是角度环。OpenMV给的是图像坐标偏差,经过比例缩放变成目标角速度差量,再用增量式PID补偿,避免车位线抖动导致方向来回甩。
typedef struct { int16_t prev_err; float integral; float Kp, Ki, Kd; int16_t max_integral; int16_t max_output; } PID_t; int16_t pid_calculate(PID_t *pid, int16_t target, int16_t current) { int16_t err = target - current; int16_t output; pid->integral += err; if (pid->integral > pid->max_integral) pid->integral = pid->max_integral; if (pid->integral < -pid->max_integral) pid->integral = -pid->max_integral; output = pid->Kp * err + pid->Ki * pid->integral + pid->Kd * (err - pid->prev_err); pid->prev_err = err; if (output > pid->max_output) output = pid->max_output; if (output < -pid->max_output) output = -pid->max_output; return output; }增量式PID比位置式好在对误差累积不那么敏感,不会长期饱和。参数初值我一般取Kp=18,Ki=0.02,Kd=8,积分限幅100,输出限幅200。配合串口调试PID波形,观察车位中心偏差曲线:如果曲线在零线附近来回震荡,降低Kd或Kp;如果到达目标位置后回正慢,增大Ki。两轮差速小车控制时,左轮PWM等于基础速度加输出量,右轮PWM等于基础速度减输出量,差速越大转弯半径越小。基础速度建议设在30到60的占空比百分比之间,太快会导致图像帧率不足时冲出车位。
4.4 泊车动作决策表
| 场景 | 图像特征 | 控制动作 |
|---|---|---|
| 未发现空车位 | 车位矩形宽度小于阈值 | 沿车道直行,保持车位搜索 |
| 空车位在车身右侧 | x_center > 画面中心+15px | 减速并右转,保持车位在中心 |
| 车位对齐车身 | x_center在中心±15px内 | 回正方向,挂倒挡 |
| 车尾进入车位 | 矩形高度到达设定阈值 | 原地修正方向,保持车身平行 |
| 到位停车 | 超声波距离小于15cm | 刹车,进入锁定状态 |
动作表只是顶层逻辑,实际泊车效果依赖最后几步的阈值。阈值太大,车身没完全进去就停;太小,车尾可能撞到障碍物。调试时建议把超声波距离通过另一个串口打印出来,对比实际车身位置确定停车点。
5. 联调排错与动态ROI自适应扩展技巧
5.1 Keil烧录与虚拟串口驱动常见问题
拿到资源后先不用改代码,把工程编译烧录进STM32,再单独给OpenMV烧录main.py。OpenMV IDE下载安装后连接板子,如果设备管理器里虚拟串口出现黄色感叹号,说明驱动没装好,这就是STM32 Virtual COM Port的典型问题,换一根USB线也可能解决。Keil5安装完要确认芯片包安装,否则编译会报找不到stm32f10x.h。连接ST-Link下载时如果提示error: no STM32 target found,先检查SWD接线是否接反,再确认目标板供电正常。
5.2 动态ROI与曝光参数自动适配
很多复刻项目死在换场地后识别不到车位线。与其反复改阈值,不如在OpenMV里留一组串口命令,让STM32在启动时把当前场地的ROI和曝光时间发给OpenMV。具体做法是在帧协议里扩展msg_id=0x04的配置帧:
if msg_id == 0x04: roi_x = unpack("<h", data[0:2]) roi_y = unpack("<h", data[2:4]) sensor.set_auto_exposure(False, exposure_us=(data[4] << 8) | data[5])这套方法的核心是:把场地适配从“重新编译”变成“运行时配置”。比赛现场可以用STM32端的一个按键循环切换预设的ROI和曝光参数,甚至在stm32f10x_flash.c操作的Flash区域里保存最近一次成功配置。这样既避免频繁拔线烧录,也保留了OpenMV的快速原型优势。后续扩展多车位记忆、语音播报时,只需要在帧类型里继续增加字段,控制逻辑不用推倒重写。
本文还有配套的精品资源,点击获取