STM32+OV7670车牌识别实战:从图像采集到字符分割的完整避坑指南
STM32+OV7670车牌识别实战:从图像采集到字符分割的完整避坑指南
在嵌入式视觉应用的入门领域,车牌识别项目一直是个充满魅力的“试金石”。它不像人脸识别那样对算力有近乎苛刻的要求,也不像工业质检那样需要极高的精度和稳定性,但它却完整地串联了图像采集、预处理、目标定位、特征提取和模式识别这五大核心环节。对于刚接触STM32和OV7670这类硬件的开发者来说,成功实现一个哪怕识别率不算太高的车牌识别系统,所带来的成就感和技术视野的提升都是巨大的。然而,这条路从硬件选型、图像采集开始,就布满了各种意想不到的“坑”——光照变化导致二值化失效、车牌区域定位不准、字符粘连难以分割……每一个环节都可能让你调试到深夜。本文旨在分享一套经过实战检验的、基于STM32F103和OV7670的完整车牌识别实现方案,并重点剖析那些容易出错的细节和对应的解决方案,希望能帮你少走弯路,更快地看到屏幕上跳出正确的车牌号码。
1. 硬件选型与图像采集:从源头规避问题
很多项目失败的第一步,往往始于硬件搭配的想当然。STM32和OV7670的组合看似经典,但具体型号和连接方式的选择,直接决定了后续图像处理的天花板。
核心控制器:为什么是STM32F103? 对于车牌识别这类需要一定计算量的任务,主频和内存是关键。STM32F103系列中,我们推荐使用STM32F103ZET6或STM32F103VET6这类带有FSMC(灵活的静态存储器控制器)的型号。原因很简单:OV7670输出的图像数据流速度不慢,如果使用普通的GPIO口模拟时序去读取,会大量占用CPU时间,导致系统无法同时处理图像和运行识别算法。FSMC可以将OV7670的FIFO(如AL422B)映射到内存地址空间,实现类似DMA的高速数据搬运,让CPU得以解放。
注意:如果你手头只有不带FSMC的型号(如STM32F103C8T6),也不是不能做,但需要优化代码,将图像采集放在最高优先级的定时器中断中,并大幅简化图像分辨率(例如降至QQVGA 160x120),这会为后续识别带来更多挑战。
摄像头模块:OV7670的“玄学”配置 OV7670是一款性价比极高的30万像素传感器,但其寄存器配置之复杂也“臭名昭著”。市面上常见的模块质量参差不齐,重点关注以下几点:
- 时钟源:确保模块自带了一个稳定的有源晶振(通常为12MHz或24MHz),这是图像稳定的基础。
- FIFO芯片:确认模块是否集成了AL422B这类FIFO芯片。没有FIFO的模块需要主控以精确的时序实时读取数据,对STM32来说几乎是不可能完成的任务。
- 输出格式:配置为
RGB565格式。这是16位的颜色格式,每个像素用两个字节表示(5位红+6位绿+5位蓝),便于在LCD上直接显示和后续处理。
连接与供电:细节决定成败
- 电源:OV7670模块的模拟部分(传感器)和数字部分(FIFO、寄存器)最好使用独立的LDO供电,并加入足够的滤波电容。电源噪声是导致图像出现横条纹、噪点增多的主要原因。
- I2C引脚:用于配置OV7670寄存器的I2C(SCCB)总线,需要接上拉电阻(通常4.7KΩ)。STM32的I2C引脚开漏输出,没有上拉是无法正常通信的。
- 数据与同步引脚:将OV7670的
VSYNC(帧同步)、HREF(行同步)、PCLK(像素时钟)和D[7:0](数据总线)正确连接到STM32的FSMC相关引脚或普通GPIO。务必在原理图上仔细核对。
一个典型的连接示意表格如下:
| STM32引脚 | OV7670/FIFO引脚 | 功能说明 |
|---|---|---|
| PB0 (FSMC_D2) | D2 | 数据位2,连接至FSMC数据线 |
| PB1 (FSMC_D3) | D3 | 数据位3 |
| PE7 (FSMC_D4) | D4 | 数据位4 |
| ... | ... | ... (依此类推连接D0-D7) |
| PD4 (FSMC_NOE) | OE | FIFO输出使能,低电平有效 |
| PD5 (FSMC_NWE) | WE | FIFO写使能,接OV7670的PCLK |
| PG9 (FSMC_NE2) | RST | FIFO复位(可选) |
| PD3 (FSMC_CLK) | VSYNC | 帧同步信号 |
| PG0 | HREF | 行同步信号(可接普通GPIO) |
| PB6 (I2C1_SCL) | SCL | SCCB时钟线 |
| PB7 (I2C1_SDA) | SDA | SCCB数据线 |
配置好硬件后,第一个实战代码就是编写OV7670的初始化序列。这里最大的“坑”是寄存器配置值因模块而异,网上的代码不能直接照搬。建议的做法是,准备一个常用的配置数组,然后通过串口调试助手,实时修改寄存器值并观察图像效果,找到最适合你手上模块的一组参数。
// 示例:OV7670初始化关键寄存器配置(QQVGA, RGB565)
const uint8_t OV7670_InitReg[][2] = {
{0x12, 0x80}, // 复位所有寄存器
{0x11, 0x80}, // 内部时钟分频,影响帧率
{0x3a, 0x04}, // 输出格式控制,04为RGB565
{0x40, 0xd0}, // 色彩矩阵控制
{0x12, 0x14}, // COM7,设置输出格式为RGB,QVGA等
{0x8c, 0x00}, // RGB444控制
{0x17, 0x16}, // HSTART,水平起始位置
{0x18, 0x04}, // HSTOP,水平停止位置
{0x32, 0x80}, // HREF控制
{0x19, 0x03}, // VSTART,垂直起始位置
{0x1a, 0x7b}, // VSTOP,垂直停止位置
{0x03, 0x0a}, // VREF,垂直参考
// ... 更多配置
};
初始化成功后,你应该能在LCD上看到清晰的实时图像。如果图像颜色怪异、撕裂或全黑,请依次检查电源、时钟、同步信号和寄存器配置。
2. 图像预处理:二值化的艺术与阈值抉择
拿到原始RGB565图像后,直接进行处理计算量巨大且效果不佳。预处理的目标是简化图像,突出车牌区域。二值化是这一步的灵魂,但“一刀切”的固定阈值(如127)在变化的光照下会惨败。
动态阈值法:应对光照变化 固定阈值在室内灯光下可能工作良好,但一到窗边或室外,整个图像明暗一变,车牌可能就“消失”了。因此,我们需要一种能自适应图像整体亮度的阈值计算方法。
- 大津法(Otsu‘s Method):这是一种自动确定最佳阈值的经典算法。其原理是寻找一个阈值,使得分割后的前景(车牌字符)与背景(车牌底板)的类间方差最大。虽然计算稍复杂,但对于STM32F103,在降低分辨率的图像上(如80x60)运行是可行的。
- 局部自适应阈值:更高级的方法是为图像中每个像素点根据其邻域亮度单独计算阈值。这种方法对光照不均的鲁棒性极强,但计算量也最大,在STM32上实现需要精巧的优化。
一个在资源受限环境下更实用的策略是结合全局与局部:先计算整个图像的灰度直方图,根据直方图双峰特性确定一个粗略的全局阈值,然后在车牌可能出现的区域(如图像中部偏下)进行小范围的局部阈值微调。
// 简化版的自适应阈值计算示例(基于灰度图像)
uint8_t CalculateAdaptiveThreshold(uint16_t *grayscaleImage, uint32_t width, uint32_t height) {
uint32_t histogram[256] = {0};
uint32_t totalPixels = width * height;
uint32_t sum = 0;
// 计算灰度直方图 (假设已转换为8位灰度)
for(uint32_t i = 0; i < totalPixels; i++) {
histogram[grayscaleImage[i]]++;
}
// 大津法简化实现:寻找最佳阈值
uint32_t sumB = 0; // 背景像素灰度总和
uint32_t wB = 0; // 背景像素数
uint32_t wF = 0; // 前景像素数
float varMax = 0;
uint8_t threshold = 0;
for (uint8_t t = 0; t < 255; t++) {
wB += histogram[t]; // 背景权重增加
if (wB == 0) continue;
wF = totalPixels - wB; // 前景权重
if (wF == 0) break;
sumB += (uint32_t)(t * histogram[t]);
float mB = sumB / (float)wB; // 背景平均灰度
float mF = (sum - sumB) / (float)wF; // 前景平均灰度
// 计算类间方差
float varBetween = (float)wB * (float)wF * (mB - mF) * (mB - mF);
if (varBetween > varMax) {
varMax = varBetween;
threshold = t;
}
}
// 可根据车牌先验知识微调threshold,例如蓝底车牌通常较暗,阈值可适当调低
return threshold - 5;
}
从RGB565到二值化:灰度化是关键一步 OV7670输出的是RGB565数据,我们需要先将其转换为灰度值,再进行二值化。灰度化公式常用的有:
Gray = 0.299*R + 0.587*G + 0.114*B(浮点,精度高但慢)Gray = (R*38 + G*75 + B*15) >> 7(定点整数近似,适合MCU)
在STM32上,我们当然选择整数运算。二值化操作就是简单的比较:
// 假设 grayPixel 是计算出的灰度值,threshold 是计算出的阈值
binaryPixel = (grayPixel > threshold) ? 255 : 0; // 白底黑字或黑底白字取决于需求
对于蓝底白字的车牌,经过合适的阈值处理,我们应该得到一个白字(255)在黑底(0)上的二值图像,为后续定位做好准备。
3. 车牌区域定位:在噪声中寻找“特征矩形”
从整幅二值图像中找到车牌的位置,是项目成功的关键。这里介绍两种在实践中结合使用的方法:跳变点统计法和颜色特征法。
跳变点统计法:利用字符的纹理特征 车牌区域有一个显著特征:在水平方向上,由于字符和底色的交替,黑白跳变(0->255或255->0)非常频繁。而在非车牌区域(如车身、背景),跳变则稀疏得多。
- 水平投影:对二值图像进行逐行扫描,统计每一行中像素值发生变化的次数(跳变点)。将每行的跳变次数绘制成曲线,车牌所在的行区域,这条曲线会呈现一个明显的波峰。
- 确定上下边界:设定一个阈值(例如,平均跳变次数的1.5倍)。连续多行跳变次数超过该阈值的区域,其起始行和结束行即可初步判定为车牌的上下边界。
#define IMAGE_WIDTH 160
#define IMAGE_HEIGHT 120
#define JUMP_THRESHOLD 8 // 跳变阈值,需根据实际情况调整
uint16_t horizontalJumpCount[IMAGE_HEIGHT];
uint16_t topBoundary, bottomBoundary;
void FindHorizontalBoundary(uint8_t binaryImage[IMAGE_HEIGHT][IMAGE_WIDTH]) {
uint16_t i, j;
uint8_t currentPixel, lastPixel;
// 1. 计算每行跳变次数
for(i = 0; i < IMAGE_HEIGHT; i++) {
horizontalJumpCount[i] = 0;
lastPixel = binaryImage[i][0];
for(j = 1; j < IMAGE_WIDTH; j++) {
currentPixel = binaryImage[i][j];
if(currentPixel != lastPixel) {
horizontalJumpCount[i]++;
lastPixel = currentPixel;
}
}
}
// 2. 寻找上边界
for(i = 5; i < IMAGE_HEIGHT - 20; i++) { // 从第5行开始,避免顶部噪声
if(horizontalJumpCount[i] > JUMP_THRESHOLD &&
horizontalJumpCount[i+1] > JUMP_THRESHOLD &&
horizontalJumpCount[i+2] > JUMP_THRESHOLD) { // 连续三行满足条件
topBoundary = (i > 2) ? (i - 2) : 0; // 向上稍微扩展一点
break;
}
}
// 3. 寻找下边界
for(i = topBoundary + 10; i < IMAGE_HEIGHT; i++) { // 从上边界下方开始找
if(horizontalJumpCount[i] < JUMP_THRESHOLD &&
horizontalJumpCount[i+1] < JUMP_THRESHOLD &&
horizontalJumpCount[i+2] < JUMP_THRESHOLD) {
bottomBoundary = (i < IMAGE_HEIGHT - 2) ? (i + 2) : IMAGE_HEIGHT;
break;
}
}
}
颜色特征法:锁定蓝色区域 对于中国车牌,蓝底是一个非常稳定的颜色特征。我们可以在初步确定的水平区域内,利用颜色信息精确定位左右边界。
- 颜色空间转换:将RGB565转换到HSV/HSL颜色空间。HSV中的H(色调)对蓝色非常敏感,受光照亮度变化影响小。
- 蓝色像素筛选:遍历水平区域内的每一列,统计该列中HSV值落在蓝色范围(例如H在200-260度之间)的像素数量。
- 确定左右边界:蓝色像素密度高的连续列区域,即为车牌的左右边界候选。结合车牌的宽高比先验知识(例如长约440mm,宽约140mm,比例约3.14),可以过滤掉一些误检。
| 方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 跳变点统计 | 计算相对简单,对光照不敏感(因已二值化) | 在字符稀疏或背景复杂时易误检 | 作为初步定位,快速缩小范围 |
| 颜色特征 | 特征显著,定位准确率高 | 受光照色温影响,计算量稍大(需颜色转换) | 在水平区域内精确定位,验证车牌 |
在实际代码中,通常先使用跳变点统计快速找到可能的水平区域,然后在该区域内使用颜色特征法进行垂直方向的精确定位,并将两种方法的结果进行融合判断,从而得到一个相对准确的车牌候选框。
4. 字符分割:将连体字“切开”的精细操作
定位到车牌区域后,我们需要将7个字符(1个汉字+1个字母+5个字母/数字)逐个分割出来,才能进行识别。字符分割的核心思想是垂直投影。
垂直投影与分割点查找
- 对车牌区域的二值图像进行垂直投影:统计每一列中白色像素(字符)的数量。
- 分析投影曲线:字符所在的列,白色像素数较多,形成波峰;字符间的间隙所在的列,白色像素数很少甚至为0,形成波谷。
- 寻找分割点:波谷的最低点(或连续多列白色像素数为0的区域)就是字符之间的分割点。
然而,现实很骨感,你会遇到几个典型问题:
- 字符粘连:例如“0”和“D”可能因为笔画接近或二值化过度而连在一起,导致投影波谷不明显。
- 边框干扰:车牌本身的物理边框在图像中可能被误认为是字符的一部分。
- 噪声点:图像中的噪点可能产生细小的波峰,干扰分割。
应对策略与优化技巧
- 先滤波,再投影:在垂直投影前,对二值图像进行简单的形态学滤波。例如使用“开运算”(先腐蚀后膨胀),可以消除细小噪点,分离轻微粘连的字符。在STM32上,可以简化实现为3x1或5x1的滑动窗口滤波。
- 动态阈值分割:不简单地寻找白色像素为0的列,而是设定一个动态阈值,例如该列白色像素数小于整个车牌区域平均白色像素数的10%,且连续若干列都满足此条件,则判定为间隙。
- 利用先验知识:中国车牌字符的宽度和间距大致符合一定比例。分割后,可以检查每个分割出的字符块的宽度。如果某个块宽度异常(过宽可能是两个粘连字符,过窄可能是噪声),则需要进行特殊处理,比如尝试在块内根据投影的局部最小值进行二次分割。
// 简化的字符分割函数示例
#define PLATE_HEIGHT (bottomBoundary - topBoundary)
#define MIN_CHAR_WIDTH 5
#define MAX_CHAR_WIDTH 30
#define GAP_THRESHOLD_RATIO 0.1 // 间隙阈值比例
uint8_t SegmentCharacters(uint8_t plateBin[PLATE_HEIGHT][plateWidth], uint16_t *charLeft, uint16_t *charRight) {
uint16_t verticalProjection[plateWidth] = {0};
uint16_t i, j;
uint16_t totalWhitePixels = 0;
uint8_t charCount = 0;
uint8_t inChar = 0; // 是否处于字符区域内
// 1. 计算垂直投影
for(j = 0; j < plateWidth; j++) {
for(i = 0; i < PLATE_HEIGHT; i++) {
if(plateBin[i][j] == 255) {
verticalProjection[j]++;
totalWhitePixels++;
}
}
}
uint16_t avgWhitePerColumn = totalWhitePixels / plateWidth;
uint16_t gapThreshold = avgWhitePerColumn * GAP_THRESHOLD_RATIO;
// 2. 扫描投影,寻找字符左右边界
for(j = 0; j < plateWidth; j++) {
if(verticalProjection[j] > gapThreshold && !inChar) {
// 进入一个字符区域
inChar = 1;
charLeft[charCount] = j;
} else if(verticalProjection[j] <= gapThreshold && inChar) {
// 离开一个字符区域,且宽度合理
if((j - charLeft[charCount]) >= MIN_CHAR_WIDTH && (j - charLeft[charCount]) <= MAX_CHAR_WIDTH) {
charRight[charCount] = j - 1;
charCount++;
if(charCount >= 7) break; // 最多7个字符
}
inChar = 0;
}
}
// 3. 后处理:检查字符数量,处理可能的粘连
if(charCount != 7) {
// 字符数量不对,可能发生了粘连或断裂
// 这里可以加入更复杂的处理逻辑,如寻找投影局部最小值进行二次分割
// 或者根据字符平均宽度进行估算和调整
HandleAbnormalSegmentation(charLeft, charRight, &charCount, plateWidth, verticalProjection);
}
return charCount; // 返回分割出的字符数
}
分割完成后,你应该得到7个独立的字符图像块。每个块都需要进行尺寸归一化(例如统一缩放到24x50像素),以便与后续的模板库进行匹配。
5. 字符识别:模板匹配的实战与优化
在资源受限的STM32上,复杂的机器学习算法(如CNN)并不现实。模板匹配是最直接有效的方法。其核心思想是:预先建立好一个标准字符模板库(字模),然后将待识别的字符与库中的每一个模板进行相似度比较,取相似度最高的作为识别结果。
构建模板库
- 收集样本:尽可能多地收集各种字体、清晰度的车牌字符图片(包括31个省份简称汉字、24个英文字母、10个数字)。
- 预处理与归一化:对这些样本进行与你系统相同的预处理(灰度化、二值化)、字符分割和尺寸归一化(如24x50)。
- 生成字模数组:将归一化后的二值图像(每个像素0或1)按位打包成字节数组。一个24x50的图像有1200个像素,用150个字节(1200/8)即可表示。
// 示例:字符‘A’的部分字模数据 (24x50, 按行优先,每行3字节) const uint8_t template_A[150] = { 0x00, 0x00, 0x00, // 第1行 0x00, 0x00, 0x00, 0x00, 0x7E, 0x00, 0x01, 0xFF, 0x80, // ... 共50行数据 };
相似度比较算法 最简单的比较方法是逐像素比对,统计相同像素的个数。但这样对噪声和形变非常敏感。更鲁棒的方法是计算归一化互相关(NCC)或汉明距离。
在STM32上,我们可以采用一种兼顾效果和速度的方法:
- 细化与骨架提取(可选但推荐):对归一化后的待识别字符进行细化处理,得到其骨架。这可以消除笔画粗细不一带来的影响。Zhang-Suen并行细化算法在MCU上有优化实现的可能。
- 特征点比较:不比较所有像素,而是比较一些关键特征,如字符的端点、交叉点、凹凸区域等。这需要先提取字符的轮廓或骨架特征。
- 加权比对:字符的中心区域比边缘区域更重要。在比对时,可以给中心区域的像素匹配赋予更高的权重。
// 一种改进的模板匹配函数示例(简化版,包含加权思想)
uint8_t TemplateMatch(const uint8_t* charImage, const uint8_t* templateLib[], uint8_t numTemplates) {
int32_t maxScore = -999999;
uint8_t bestMatchIndex = 0;
const uint8_t* weightMap = getCharacterWeightMap(); // 假设有一个权重映射表,中心权重高
for(uint8_t t = 0; t < numTemplates; t++) {
int32_t score = 0;
const uint8_t* templ = templateLib[t];
for(uint16_t i = 0; i < 150; i++) { // 150 bytes for 24x50 image
uint8_t charByte = charImage[i];
uint8_t tempByte = templ[i];
uint8_t weight = weightMap[i]; // 该字节所在位置的权重
// 逐位比较
for(uint8_t bit = 0; bit < 8; bit++) {
uint8_t charBit = (charByte >> bit) & 0x01;
uint8_t tempBit = (tempByte >> bit) & 0x01;
if(charBit == tempBit) {
score += weight; // 相同,加分
} else {
score -= (weight / 2); // 不同,减分(减分权重可调)
}
}
}
if(score > maxScore) {
maxScore = score;
bestMatchIndex = t;
}
}
// 设置一个最低置信度阈值,低于则认为识别失败
if(maxScore < CONFIDENCE_THRESHOLD) {
return 0xFF; // 表示识别失败
}
return bestMatchIndex;
}
工程优化与调试技巧
- 分阶段识别:先识别汉字(省份),再识别字母,最后识别数字。因为汉字、字母、数字的集合不同,可以缩小每次匹配的模板范围,提高速度和准确率。
- 建立易混淆字符对:像‘0’和‘D’,‘8’和‘B’,‘5’和‘S’等容易误识。针对这些字符对,可以提取更精细的特征(如‘0’通常是封闭的,‘D’右侧是直的)进行二次判别。
- 利用上下文:车牌编码有一定规则(如第二位通常是字母,后面是数字和字母混合)。识别结果出来后,可以用简单的规则进行校验和纠错。
- 离线训练与在线更新:可以在PC上训练和优化模板库,然后以数组形式烧录到STM32的Flash中。甚至可以在系统运行时,将成功识别的高质量字符图像保存下来,用于后期优化模板库。
整个系统调试时,务必使用串口打印关键步骤的中间结果(如二值化图像、定位框坐标、投影曲线、分割位置、匹配得分等),配合LCD实时显示,这样才能快速定位问题所在。车牌识别是一个典型的系统工程,每个环节的微小改进都可能带来整体识别率的提升。从模糊的图像中定位并认出那几个字符的过程,充满了挑战,也充满了嵌入式开发的乐趣。当你第一次看到STM32正确地输出车牌号码时,那种感觉,绝对值得之前所有的折腾。
更多推荐
所有评论(0)