OpenMV+STM32+YOLO11+PaddleOCR车牌识别系统实战

发布时间:2026/9/28 20:31:32
OpenMV+STM32+YOLO11+PaddleOCR车牌识别系统实战 去年底帮一个做毕业设计的学弟救急他买的OpenMV板子跑不动现成的YOLO车牌识别模型识别一帧要卡好几秒车都快开出画面了车牌还没认出来。我重新给他拆了方案OpenMV只负责图像采集和前端触发STM32做控制中枢真正的车牌检测和字符识别全部放到PC上处理用YOLO11社区里常叫YOLOv11检测车牌区域再用PaddleOCR读字符识别结果通过串口回传给STM32控制道闸动作。这套方案实测小场景下单帧识别稳定在300ms以内做毕设或者入门嵌入式AI完全够用。这篇文章就把整套环境配置、代码联调和踩过的坑完整写下来适合正在做OpenMVSTM32项目、或者想把深度学习模型塞进嵌入式流程里的朋友直接抄作业。1. 系统整体设计与方案选型1.1 为什么不让OpenMV直接跑YOLO先说一个很多人刚入坑时的误解OpenMV看起来是个带摄像头的“AI开发板”官方也放了一些人脸检测、数字识别的示例但它的主控本质上是单片机级别的算力。拿OpenMV Cam H7 Plus来说用的是STM32H7系列芯片虽然有双核但跑一个轻量级神经网络做分类还可以真要跑YOLO系列的目标检测每帧要几百毫秒到几秒而且内存经常溢出。车牌识别又是个级联任务——先检测车牌在哪再识别里面的字符两个模型叠在一起在OpenMV上根本跑不动。所以我在设计时明确了三段式分工OpenMV只做“眼睛”负责采集图像、按需压缩、发送图像STM32当“手和脚”负责接收数据、控制道闸舵机、OLED显示、协调整个流程PC才是“大脑”运行YOLO11检测车牌位置再用PaddleOCR识别车牌号码最后把结果通过串口回传给STM32。这套方案把实时性要求不高的识别计算放到PC上嵌入式端只做轻量任务性能和开发成本都能兼顾。1.2 数据链路与串口帧协议设计整个系统的数据流是这样的OpenMV上电后持续采集图像当检测到触发信号比如按键、定时器或外部传感器时将当前帧缩放到合适尺寸并编码为JPEG通过UART发送给STM32STM32收到图像数据后一方面在OLED上显示状态另一方面将数据通过另一个串口通道原样透传给PCPC端Python程序收到图像后先跑YOLO11检测车牌区域把车牌区域裁剪出来再喂给PaddleOCR识别最终把识别出的车牌号和置信度通过串口回传给STM32STM32收到结果后控制舵机模拟道闸抬起并在OLED上显示车牌号。这里最关键的链路是OpenMV到STM32的通信。直接用裸图像数据的话一张320x240的RGB565图像就有150KB左右OpenMV和STM32之间的串口一般用921600波特率传一张图就要好几秒体验极差。所以我在OpenMV端把图像压缩成JPEG格式同样分辨率下通常只有10~20KB速度能快一个数量级。实测在115200波特率下传一张压缩图需要1秒多921600波特率下能压到200ms以内基本可接受。通信帧格式我用的是自定义的简易协议帧头固定为0xAA 0x55接着是1字节数据类型0x01表示图像数据0x02表示识别结果0x03表示心跳包然后是2字节数据长度小端模式低字节在前最后是数据内容尾部跟1字节累加和校验。累加和就是从数据类型开始到数据内容最后一个字节所有字节的和只取低8位。这个协议虽然简单但足以应对本项目的数据传输而且用状态机解析也非常方便。2. 软硬件准备与开发环境搭建2.1 硬件清单与选购避坑硬件清单我直接列一下都是比较常见的型号新手照着买不会出错OpenMV Cam H7 Plus建议买带SD卡槽和RGB LED的版本后期调试会方便很多。原厂价格偏高国产兼容版也能用但要注意看固件版本和OpenMV IDE是否兼容我遇到过某个兼容版刷了原厂固件后USB握手不稳定最后换了线才好。STM32开发板我用的是STM32F103C8T6最小系统板蓝色圆片那种便宜且资料多。如果后续要跑更复杂的控制逻辑或者接更多外设可以上F407价格贵一点但性能余量大。注意F103的串口要选USART1或USART2这两个引脚引出比较方便。USB转TTL模块CH340或者CP2102都可以用于STM32和PC之间的串口通信。注意有些模块供电能力不足如果STM32板上还有其他传感器建议单独用USB供电。舵机SG90就行用来模拟道闸的抬起和落下接线简单PWM控制周期为50Hz0.5ms~2.5ms脉宽对应0°~180°。OLED显示屏0.96寸I2C接口的SSD1306即可用来显示识别结果调试时能看到信息流转到哪一步。其他杜邦线若干、面包板、一个按键用于触发拍照识别有条件可以加一个HC-SR501人体红外传感器做自动触发。选购时最该注意的是供电。OpenMV和STM32刚上电时瞬间电流不小尤其是OpenMV给传感器供电那一下如果用电脑USB直接供电可能会出现电压跌落导致复位我的经验是准备一个输出能力在1A以上的5V适配器通过面包板统一供电GND一定要共地否则串口通信会乱码。2.2 OpenMV端IDE与固件烧录OpenMV的开发环境是OpenMV IDE到官网下载对应系统的版本安装即可。连接时用一根数据线把OpenMV接到电脑上IDE会自动识别端口然后点击左下角的连接图标绿色说明连接正常。第一次连接可能会提示固件版本过旧这时候需要烧录新版固件先下载对应板型的固件文件.dfu格式然后在IDE里点击“工具→运行Bootloader”OpenMV会进入固件升级模式选择固件文件并烧录。这里有几个坑必须要提第一烧录固件时千万不要断电OpenMV的Bootloader一旦刷到一半断电是有可能变砖的虽然可以用串口救回来但折腾起来很麻烦。第二如果IDE识别不到OpenMV先检查数据线是不是只有充电功能的数据线换一根能传数据的线再试。第三如果是兼容版板子建议先找商家要对应的固件版本不要盲目刷原厂最新版否则可能出现摄像头驱动不匹配、画面黑屏的问题。OpenMV和STM32通信这块OpenMV端只需要初始化UART然后把JPEG图像数据按帧协议发出去。要注意OpenMV的UART默认使用的是P4TX和P5RX引脚对应STM32的USART1或USART2接线时记得TX接RX、RX接TX。2.3 STM32端Keil5芯片包、ST-Link与串口驱动STM32端的开发环境我用的Keil MDK5这也是大多数人最常用的。安装Keil后如果新建工程时找不到STM32F103C8说明芯片包还没装。现在Keil MDK5默认从包管理器安装芯片支持包打开Pack Installer在搜索框输入STM32F1找到Keil::STM32F1xx_DFP那个包安装即可。如果网络不稳定装不上可以直接去Keil官网下载离线包双击安装后重新打开Keil就能识别到了。然后是ST-Link驱动。如果你用的是ST-Link V2仿真器插上电脑后如果设备管理器里出现黄色的感叹号说明驱动没装。去ST官网下载STM32 ST-LINK Utility或者STSW-LINK009驱动包安装后重新插拔设备管理器里能识别出ST-Link就正常了。如果你用USB转TTL模块下载程序那需要的是串口驱动CH340就装CH340驱动CP2102就装CP2102驱动装好后在设备管理器里能看到对应COM口。调试时还经常遇到一个情况ST-Link能识别到但Keil下载时报“No target connected”或者“RDDI-DAP Error”。这个大部分时候是因为板子上的BOOT0跳线或者复位电路没设计好。F103最小系统板一般有BOOT0和BOOT1跳线正常烧录程序要把BOOT0跳线拨到0低电平。另外用ST-Link连接时SWDIO、SWCLK、GND三根线必须接好最好再补一根3.3V给仿真器做参考电平减少烧录失败的概率。2.4 上位机Python环境Torch、YOLO11与PaddleOCR GPU版PC端是整个识别系统的核心计算平台我强烈建议用NVIDIA显卡跑GPU加速。我的环境配置如下Windows 11 / Ubuntu 22.04都可以我这次用的是Windows 11。Python 3.10或3.11建议用Anaconda建一个独立环境避免和系统Python冲突。NVIDIA驱动版本不低于535CUDA 12.4cuDNN 8.9。PyTorch 2.3从PyTorch官网选择对应的安装命令。Ultralytics库统一提供YOLO11支持。PaddlePaddle GPU版 PaddleOCR 3.x。创建虚拟环境的命令conda create -n plate python3.10 conda activate plate pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu124 pip install ultralyticsPyTorch装好之后先验证一下CUDA是否可用import torch print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))输出True说明GPU正常。接下来装PaddlePaddle这个很容易踩坑。PaddlePaddle的CUDA版本要和PyTorch的CUDA版本匹配我用的是CUDA 12.4所以安装命令是pip install paddlepaddle-gpu3.0.0注意PaddlePaddle 3.x版本对Python版本有要求Python 3.10没问题但如果用Python 3.12可能会遇到没有对应预编译包的问题装起来会比较痛苦。装完后跑一句python -c import paddle; paddle.utils.run_check()验证是否安装成功。最后安装PaddleOCR 3.xpip install paddleocrPaddleOCR 3.x和之前的2.x版本API有一些变化不推荐再用老文章的ocr PaddleOCR(use_angle_clsTrue)这种写法3.x版本直接初始化PaddleOCR实例然后调用predict或者ocr方法即可。我会在后面的章节给出当前版本可用的代码。3. OpenMV采集端与STM32通信实现3.1 OpenMV的JPEG图像压缩与发送OpenMV采集图像的代码很简单核心在于压缩比的控制和发送时机。默认的sensor.snapshot()返回的是RGB565图像内存占用大我使用img.to_jpeg(quality80)把图像编码成JPEG字节流质量因子80在清晰度和体积之间比较平衡。实验下来把分辨率设为QVGA320x240就够了车牌在画面里占一定面积太高分辨率只会增加传输压力识别精度提升有限。触发方式我做了两种一种是用按键连一个引脚到OpenMV上检测到低电平就拍一张另一种是定时触发每2秒自动采集一帧。实际测试时建议用按键不然串口会一直被图像数据占满不方便看日志。OpenMV端发送代码的核心逻辑是这样的import sensor import ustruct import time from machine import UART sensor.reset() sensor.set_pixformat(sensor.RGB565) sensor.set_framesize(sensor.QVGA) sensor.set_vflip(True) sensor.set_hmirror(True) sensor.skip_frames(time2000) uart UART(3, 921600, timeout_char2000) # 注意引脚下对应 uart.init(921600, bits8, parityNone, stop1) KEY_PIN Pin(P0, Pin.IN, Pin.PULL_UP) def checksum(data): return sum(data) 0xFF while True: if KEY_PIN.value() 0: # 按键按下 img sensor.snapshot() jpeg img.to_jpeg(quality80) data_len len(jpeg) header bytes([0xAA, 0x55, 0x01, data_len 0xFF, (data_len 8) 0xFF]) body jpeg cks checksum(body) frame header body bytes([cks]) uart.write(frame) time.sleep_ms(500) # 防抖注意OpenMV的UART构造函数参数不同的板子引脚对应的UART编号不一样H7 Plus如果不确定就把UART(3)换成UART(1)之类的试一下或者查一下板子的引脚图。这里我用了921600波特率如果PC端STM32透传过来不稳定可以降到460800。还有一个细节uart.write()是一次性把整个字节串写出去但OpenMV内部发送缓冲区不是无限大的如果图像数据超过缓冲区写操作可能会超时或者丢弃部分数据。稳妥的做法是分片写入每512字节一个包包之间加一个极小的延时。我在测试中直接整段写给STM32也没出问题但如果你发现图像传输到PC端总是解不出完整JPEG优先考虑分片发送。3.2 STM32串口接收与数据帧解析STM32这边的任务是接收OpenMV发来的JPEG帧解析校验再转发给PC。我用的是STM32F103C8T6的USART1PA9/PA10接OpenMVUSART2PA2/PA3接USB转TTL模块两个串口波特率都设为921600。如果波特率太高导致数据错乱可以两个串口都用115200只是传输会慢一些。接收端我用了串口中断逐字节接收状态机解析帧头、长度、数据和校验。虽然DMA空闲中断效率更高但对于一帧10~20KB的数据逐字节中断在921600波特率下CPU占用率很高容易影响其他任务。所以如果后续要加传感器或显示建议改成DMA接收。这里先给一个简洁的状态机判断版本#define FRAME_HEAD1 0xAA #define FRAME_HEAD2 0x55 #define MAX_FRAME 65535 uint8_t rx_buffer[MAX_FRAME]; uint16_t rx_index 0; uint8_t rx_state 0; uint8_t rx_type 0; uint16_t rx_len 0; uint16_t rx_data_len 0; void USART1_IRQHandler(void) { uint8_t byte 0; if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { byte USART_ReceiveData(USART1); switch (rx_state) { case 0: if (byte 0xAA) rx_state 1; break; case 1: if (byte 0x55) { rx_state 2; rx_index 0; } else { rx_state 0; } break; case 2: rx_type byte; rx_state 3; break; case 3: rx_len byte; rx_state 4; break; case 4: rx_len | (byte 8); if (rx_len MAX_FRAME - 1) { rx_state 0; } else { rx_state 5; rx_data_len 0; } break; case 5: rx_buffer[rx_index] byte; rx_data_len; if (rx_data_len rx_len) rx_state 6; break; case 6: // byte 是校验和 uint8_t sum 0; for (uint16_t i 0; i rx_len; i) sum rx_buffer[i]; if (sum byte) { // 校验成功转发到PC // 这里先清空状态再调用处理函数 frame_ready 1; } rx_state 0; frame_ready 1; break; default: rx_state 0; break; } } }当frame_ready置1后主循环就读取rx_buffer里的JPEG数据通过USART2原样发给PC同时开始等待识别结果。这里有个需要注意的地方JPEG数据本身是二进制里面完全可能出现0xAA 0x55这样的字节组合但由于我的状态机在接收到帧头后才开始解析数据段内部不会再重新检测帧头所以不会误判。风险在于数据段如果丢了一个字节后面的数据全部错位这时累加和校验也过不了整帧会被丢弃程序不会死锁只是图像丢了一帧。实际测试中在921600波特率下丢帧概率很低但如果加了过长杜邦线或者供电不稳丢帧就会明显增加。3.3 STM32控制外设PWM舵机与OLED显示STM32识别到结果后要做两件事一是控制舵机模拟道闸抬起二是在OLED上显示车牌号。这两块逻辑都不复杂但涉及STM32的定时器外设顺便把很多人问的“定时器PWM控制舵机”说清楚。SG90舵机的控制信号是50Hz的PWM即周期20ms高电平脉宽在0.5ms到2.5ms之间对应0°到180°。STM32的定时器输出PWM时需要设置预分频系数和自动重载值。以72MHz主频的F103为例如果要得到50Hz的PWM可以设置定时器预分频为71即72分频计数频率变成1MHz每1us计一次数自动重载值设为19999这样计数器从0数到19999正好是20ms。此时脉宽就是比较值舵机角度和比较值的换算关系是0°对应比较值500180°对应比较值250090°对应1500。初始化代码如下TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure; TIM_OCInitTypeDef TIM_OCInitStructure; // 假设用TIM2的CH1 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); TIM_TimeBaseInitStructure.TIM_Period 19999; TIM_TimeBaseInitStructure.TIM_Prescaler 71; TIM_TimeBaseInitStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInitStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseInitStructure); TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse 1500; TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM2, TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable); TIM_Cmd(TIM2, ENABLE);后面想抬起道闸就设置TIM_SetCompare1(TIM2, 2000)落下就是TIM_SetCompare1(TIM2, 1000)一个平滑斜坡函数可以做开机自检的往返运动。OLED显示部分用的是I2C接口的SSD1306驱动网上标准库代码很多移植到自己的工程时主要注意改I2C引脚和初始化时把屏幕的地址设为0x787位地址0x3C即可。4. 上位机YOLO11车牌检测模型训练与部署4.1 数据集准备与标注格式转换车牌检测模型用的是YOLO格式的目标检测必须准备一张张带车牌标注框的图片和对应的txt标注文件。公开数据集里最适合中文车牌的是CCPDChinese City Parking Dataset有20万张左右的车牌图标注包含车牌框和四个角点。不过CCPD的标注格式比较特殊需要转换成YOLO的格式每行是“class x_center y_center width height”坐标是归一化后的值。如果不想折腾转换脚本也可以自己拍100~200张照片然后手动标注。标注工具我推荐LabelImg安装后直接把图片目录打开用矩形框框出车牌区域保存为YOLO格式。这里有个经验车牌检测的难点不在环境复杂而在车牌本身可能倾斜、反光、被遮挡所以自己采集数据时一定要覆盖不同角度、不同光照、近景和远景。训练集里至少要包含各种常见车牌颜色蓝牌、绿牌新能源、黄牌否则模型很容易只认识蓝牌。数据集目录结构按YOLO标准格式plate_dataset/ images/ train/ val/ labels/ train/ val/train和val的比例一般按8:2或9:1划分每张图片的标注txt和图片同名。写一个简单的划分脚本随机把图片和对应txt移到train或者val目录里注意千万不要把同一张图既放训练又放验证否则模型效果虚高。4.2 训练YOLO11车牌检测模型训练使用的是Ultralytics的YOLO11模型权重文件从官方仓库下载yolo11n.ptnano版或yolo11s.ptsmall版。车牌检测目标不算特别小nano版在精度和速度之间比较均衡所以我第一次训练用的yolo11n.pt。创建一个plate.yaml配置文件path: D:/plate_dataset train: images/train val: images/val nc: 1 names: [plate]然后执行训练yolo detect train dataplate.yaml modelyolo11n.pt epochs100 imgsz640 batch16 device0如果是纯CPU环境把device0改成devicecpu但训练速度会慢很多一个epoch可能就要十几分钟。训练过程中需要注意看loss曲线正常情况下训练集和验证集的box_loss、cls_loss都在下降。如果loss明显不降先检查标注文件有没有问题用脚本可视化一下标注框是否准确贴合车牌。训练结束后模型会保存在runs/detect/train/weights/目录下best.pt是验证集效果最好的权重last.pt是最后一轮的权重。做推理建议用best.pt。4.3 模型推理、筛选结果与保存裁剪区域模型训练好之后上位机Python程序里加载权重对OpenMV传来的图像做一次检测。这里有个容易踩的坑检测结果是相对坐标0~1之间要恢复成像素坐标需要乘上图像的宽和高。然后还要做一个置信度筛选把conf低于0.5的框丢弃否则背景区域很容易被误判成车牌。同时可以用NMS参数控制重叠框的数量Ultralytics默认已经做了NMS一般不用额外调。推理代码核心部分from ultralytics import YOLO import cv2 import numpy as np model YOLO(best.pt) def detect_plate(img_bgr): results model.predict(img_bgr, conf0.5, imgsz640, verboseFalse) boxes [] for r in results: for box in r.boxes: x1, y1, x2, y2 box.xyxy[0].cpu().numpy().astype(int) score float(box.conf[0]) boxes.append((x1, y1, x2, y2, score)) return boxes拿到车牌区域后我会把这块区域裁剪出来稍微向外扩一点一般是10像素再送给OCR。为什么要外扩因为YOLO检测出来的框可能紧贴着车牌边缘如果车牌字符有倾斜直接裁剪容易把边缘字符切掉OCR识别就会漏字或错字。外扩之后OCR还能借助车牌边框的蓝色/绿色来判断字符边界识别率会高一些。关于“保存推理结果”Ultralytics自带results.save()可以把检测框画在图上保存但车牌识别场景我更推荐把裁剪出来的车牌区域保存成单独文件方便后续人工核查。比如for j, (x1, y1, x2, y2, score) in enumerate(boxes): crop img_bgr[y1-10:y210, x1-10:x210] cv2.imwrite(fplate_crops/{time.time()}_{j}.jpg, crop)这样即使OCR偶尔识别出错也可以从保存的车牌图去核对是检测框偏了还是OCR本身的问题。4.4 小目标检测优化输入尺寸、Mosaic与损失函数车牌在整张图中的占比通常不大尤其是OpenMV传回来的是320x240的JPEG在640x640的推理输入下原图里的车牌可能只有20~30个像素宽属于典型的小目标问题。我实际测试中发现直接拿原图跑YOLO11n小尺寸车牌的召回率不高经常漏检。优化手段有几种但不用一上来全上按性价比排序。第一是提高推理输入尺寸把imgsz从640改成960或者1280小目标对应的特征图尺寸变大了检测效果会有明显提升代价是推理时间变长。车牌识别是离线任务300ms和600ms差别不大所以我直接用了imgsz960。第二是训练时开启Mosaic增强Ultralytics默认开启了它会将多张图拼接成一张变相增加了小目标样本的比例让模型对小目标更敏感。第三是修改损失函数YOLO11默认使用CIoU损失有人用PIoUv2等方式替代对小目标检测的定位精度会有改善但需要改源码新手不建议一上来就在这一层折腾先调输入尺寸和训练轮数把基础效果拉起来再考虑损失函数的改进。我自己测试的一个对比数据在自采的1000张图片上imgsz640时验证集mAP50只有0.82改成imgsz960后mAP50升到了0.89推理单帧从260ms增加到了450ms但准确率提升非常明显。对于车牌这种要求高召回的场景这个时间成本完全可以接受。5. PaddleOCR车牌识别与全链路联动5.1 安装PaddlePaddle与PaddleOCR 3.x的关键细节PaddleOCR 3.x的安装比2.x顺利很多但仍然有几个关键点需要注意。第一PaddlePaddle GPU版的版本号要和CUDA版本匹配比如CUDA 12.4对应的是paddlepaddle-gpu3.0.0系列你可以用pip install paddlepaddle-gpu3.0.0按官方索引安装。第二PaddleOCR当前版本对Python版本有要求3.10或3.11比较稳Python 3.12某些依赖可能编译不过去。第三如果遇到libiomp5md.dll报错或者MKL相关的冲突很可能是机器上同时装了其他深度学习框架比如PyTorch带了自己的MKL和PaddlePaddle的MKL冲突。Linux下可以在运行前设置KMP_DUPLICATE_LIB_OKTRUEWindows下则建议在环境变量里加上这个变量名。GPU版装好之后用paddle.utils.run_check()验证输出类似PaddlePaddle is installed successfully! Lets start deep learning with PaddlePaddle.就是正常的。验证CUDA有没有真正生效可以跑一句paddle.device.is_compiled_with_cuda()返回True说明编译时带上了CUDA。5.2 用PaddleOCR识别车牌字符PaddleOCR 3.x的使用方式和2.x有区别最直观的变化是调用接口简化了不再需要单独指定det、rec、cls三个模型目录直接初始化就能自动下载通用的中英文检测和识别模型。但车牌识别里有个优化点我们只需要识别车牌字符不需要通用场景里的行级文字识别所以我在初始化时只保留最必要的组件关闭不用的方向分类器车牌是水平文字用不到角度分类from paddleocr import PaddleOCR ocr PaddleOCR(use_angle_clsFalse, langch, show_logFalse)在3.x版本中use_angle_clsFalse这个参数的名称有没有变化不同的小版本处理不太一样如果初始化时报错说不认识这个参数直接删掉默认就是关闭的。调用识别时result ocr.ocr(crop_bgr, clsFalse)result是一个嵌套列表每个元素对应一行文字result[0][0]形如[[box坐标], (识别文本, 置信度)]。注意这里传入的图片必须是BGR格式的numpy数组如果用OpenCV读取JPEG默认就是BGR直接传即可。如果传了RGB颜色通道反了识别率会大幅下降。实际测试中PaddleOCR对标准蓝底白字车牌的识别效果很好但有两个问题很常见。第一个是识别结果出现乱码比如把“京”识别成“京”加一个空格或者把字母“O”识别成数字“0”。这个和训练数据、字符集限制有关通用OCR模型本身没有针对车牌做字符集约束。解决办法是把识别结果里的字符先统一转成大写再过滤掉中文汉字之外的混乱字符或者自己训练一个车牌字符分类器但后者工作量偏大。我的处理方式是只保留省份简称字母数字这个模式用正则表达式过滤import re plate_pattern re.compile(r^[\u4e00-\u9fa5][A-Z0-9]{5,6}$) def clean_plate(text): text text.upper().strip() text re.sub(r[^A-Z0-9\u4e00-\u9fa5], , text) if plate_pattern.match(text): return text return None第二个问题是低分辨率或倾斜车牌识别不准。OpenMV传过来的图本身是320x240裁剪出的车牌区域可能只有40x15像素直接识别几乎不可能。所以我在传给OCR之前先做了两倍到三倍的双线性插值放大让车牌区域的长边达到100像素以上。放大后识别率提升显著代价是OCR耗时从20ms涨到50ms左右完全可接受。5.3 串口回传结果与道闸联动PC端的识别结果需要通过串口发回给STM32STM32才能控制道闸抬起。我用pySerial发送串口号和波特率要和STM32那边对应好。发送的帧格式沿用自定义协议类型字节填0x02表示识别结果数据内容就是车牌号字符串。这里重要的一点是文本编码建议用UTF-8编码因为车牌里有中文如果直接bytes发送STM32那边接收后需要转成GBK或者直接在OLED库中映射。我为了省事回传时把中文省份缩写映射成了拼音首字母比如“京A12345”变成“J-A12345”这样OLED上显示不会乱码代码也简单很多。Python端发送示例import serial import time ser serial.Serial(COM5, 115200, timeout1) plate_text 京A12345 encoded plate_text.encode(utf-8) data bytes([0xAA, 0x55, 0x02, len(encoded) 0xFF, (len(encoded) 8) 0xFF]) encoded data bytes([sum(encoded) 0xFF]) ser.write(data)STM32端收到类型为0x02的帧后解析出车牌字符串如果非空就通过PWM控制舵机抬起道闸持续3秒后落杆同时在OLED上显示车牌号。整个联动逻辑听起来简单但第一次联调时特别容易出问题尤其是PC串口发送后STM32没反应多半是波特率不匹配或者GND没共地。5.4 全链路测试步骤与效果验证测试时我建议按下面的顺序分步验证不要一上来就跑全流程否则出了问题根本定位不到是哪一环。第一步用OpenMV IDE的串口终端工具单独验证OpenMV能否把图像帧发出来。在IDE里打开串口终端选择OpenMV对应的COM口然后按下按键如果能收到一大段乱码raw JPEG说明OpenMV端没问题。第二步把OpenMV和STM32连起来STM32再接一根USB转TTL到电脑。在电脑上打开串口助手选择USB转TTL对应的COM口波特率设为921600按下触发按键看能否收到完整的JPEG数据流。这一步能验证OpenMV到STM32到PC的链路是否通畅。第三步用Python脚本直接读取USB转TTL串口把收到的数据解析成JPEG保存成文件或者用OpenCV显示出来。如果能正常显示图像说明协议解析正确。第四步把YOLO11和PaddleOCR接进来用一张真实的监控图像测试识别效果先用本地图片测试不用串口等识别稳定后再接串口数据流。第五步全链路联调按下OpenMV按键观察STM32 OLED上是否出现车牌号舵机是否抬起。如果一切正常整个系统就算跑通了。我在测试时遇到过一个很隐蔽的问题OpenMV发送的JPEG数据传送到PC后Python的串口读取如果一次性read(4096)读到的可能不是完整的一帧因为串口是流式的帧边界必须靠协议自己解析。所以Python端也要写状态机来组帧不能简单假设一次read就是一帧。这个问题在串口助手测试时看不出来因为串口助手只是把收到的字节流原样显示但Python程序如果按帧解析就会失败。6. 常见问题与排查技巧实录6.1 串口通信乱码、丢包与连接失败串口问题在这个项目里占了我调试时间的一半以上我把典型问题整理成一个速查表现象可能原因解决办法电脑收不到任何数据接线错误检查TX/RX是否交叉GND是否共地收到大量乱码波特率不匹配统一所有串口波特率建议460800或115200图像数据不完整发送端缓冲区溢出分片写入每512字节延时1ms偶发校验失败供电电压不足使用独立5V电源减少杜邦线长度STM32无故复位共地不良检查电源模块地和串口地是否连通OpenMV无法识别数据线供电不足换带数据传输的双绞线串口波特率这个坑我的建议是如果你用921600和115200都试过还是乱码那就提高容错度改用460800。实际测试中460800和921600在短距离下速度差不多但460800对电路寄生电容和线缆长度更宽容。如果项目环境允许甚至可以把识别链路改成异步消息驱动——OpenMV发送一张图后等PC返回识别结果再继续发送下一张这样就算偶尔丢帧也不会出现多帧堆积导致协议错乱的问题。6.2 YOLO11训练loss不降与识别精度低模型训练阶段最常见的坑有两个。第一是数据标注质量差标注框不贴合车牌或者一张图里漏标了多个车牌。YOLO系列对标注质量非常敏感一个坏标注会把整个方向的梯度带偏。检查方法是把训练集里随机抽几十张图可视化标注框确认每个框都紧贴着目标边缘。第二是数据分布不均衡训练集里全是蓝色车牌没有绿色和黄色车牌那么绿色车牌识别率几乎为零。解决方法是扩充数据或者用图像增强把蓝牌的色调随机偏移到绿色/黄色来模拟其他类型。推理阶段精度低还有一个容易被忽略的原因摄像头拍摄的图像和训练集图像风格差异大。OpenMV的摄像头色彩饱和度和手机摄像头不太一样在CCPD上训练好的模型直接拿到OpenMV图像上测试精度通常会掉一些。解决方法是采集一部分OpenMV实际拍摄的图像加入训练集或者对测试图像做一次简单的颜色校正。我自己在做毕业设计时最后是把OpenMV拍的500张真实图像补充到训练集里测试集mAP一下子从0.84涨到了0.91。6.3 OpenMV连接失败与固件恢复OpenMV IDE报“无法连接设备”时不要急着刷固件先按顺序排查。第一步设备管理器里看有没有识别到端口没有就换数据线、换USB口。第二步如果有端口但IDE连接失败可能是端口被占用把上次运行过的Python脚本关掉或者重启IDE。第三步如果是兼容版板子IDE里可能会显示一个未识别设备需要去官网找对应的驱动。最后才考虑刷固件。刷固件时如果中途失败OpenMV会进入DFU模式可以用dfu-util或者官方工具恢复但这个过程比较折腾建议搜一下对应板型的救砖教程。6.4 STM32烧录失败与芯片识别不到STM32烧录失败的问题我在帮几个学弟调试时发现超过一半都是ST-Link接线或者BOOT跳线的问题。F103最小系统板用ST-Link烧录时必须把BOOT0跳线拨到0如果板子之前烧过会跑的程序复位后可能一直停在原程序里ST-Link连接会失败。另外看看使用的ST-Link是不是盗版盗版ST-Link在Keil MDK5新版本里可能驱动不兼容一种变通办法是换成DAP-LinkCMSIS-DAP或者USB转TTL串口下载。用串口ISP下载时BOOT0要拉高下载完再拨回低。很多人卡在这一步。6.5 PyInstaller打包PaddleOCR的巨坑如果你最后想把这个上位机程序发给别人不要求对方装Python环境那就得用PyInstaller打包。这里我想说一句扎心的话PyInstaller打包PaddleOCR是我做过最折磨的事情之一因为PaddleOCR引入了Paddle推理库打包出来的exe动辄几个GB而且不处理依赖的话运行时报一堆错。我的建议是尽量用conda环境打包确保环境干净再用pyinstaller --onedir而不是--onefile打包。--onefile虽然看起来清爽但Paddle这种体积大、动态库多的库用onefile启动时会先把一堆文件解压到临时目录启动慢一倍不止而且容易被杀毒软件拦截。打包命令大致是pyinstaller -D -w main.py --collect-all paddleocr --collect-all paddle如果你运行打包后的exe报找不到paddle模块尝试在代码开头手动指定paddle库路径。实在搞不定可以选择用Nuitka替代PyInstaller来做编译打包兼容性好一些但配置复杂度也高。如果只是自己学习、毕设演示我更推荐用绿色版的conda环境直接运行省去打包的麻烦。6.6 整体性能优化与稳定性建议最后说几个关于系统稳定性的实用技巧。OpenMV端在每次发送JPEG后串口缓冲区内可能会残留上一帧的数据可以在发送前清空一次串口接收缓冲避免上一帧的尾部干扰下一帧的解析。STM32端在frame_ready置位但没有及时处理的情况下下一帧可能已经到达所以处理数据的逻辑要尽量放在主循环而不是中断函数里中断只负责收数和置标志位。PC端建议把串口读取放到一个独立线程里识别主循环用队列接收图像这样即使OCR偶尔耗时较长串口数据也不会堆积丢失。供电方面我遇到过最诡异的一次问题是当舵机转动时OLED屏幕闪烁识别结果偶尔回传失败。后来用示波器一量才发现舵机转动瞬间电流尖峰导致5V电压跌落STM32直接掉电复位了。解决方案是在舵机电源线上并联一个大电容或者在舵机和MCU之间用独立电源隔离这个经验在“STM32控制舵机”相关项目里非常通用。最后再分享一个经验我在整个项目里体会最深的不是YOLO11训练得有多好也不是PaddleOCR识别得有多准而是“方案拆分”这件事本身。一开始学弟想把所有模型都塞进OpenMV结果性能完全失控后来把识别放到PC上整个系统顿时顺了。做嵌入式视觉项目很多时候不是算法不够强而是计算资源摆错了位置。如果你也卡在“板子性能不够”的死胡同里不妨想想能不能把重计算交给上位机让单片机专心做控制。这套OpenMVSTM32YOLO11PaddleOCR的架构既兼顾了嵌入式开发的完整链路又让深度学习模型可以被真正使用起来也是个很不错的毕设框架。后面如果还想继续扩展可以试着把道闸替换成真实的路侧停车计时逻辑或者给系统加上车辆品牌的识别都是在这个架构上很容易长出来的功能。