STM32+OV7670车牌识别实战:从图像采集到字符分割的完整避坑指南

在嵌入式视觉应用的入门领域,车牌识别项目一直是个充满魅力的“试金石”。它不像人脸识别那样对算力有近乎苛刻的要求,也不像工业质检那样需要极高的精度和稳定性,但它却完整地串联了图像采集、预处理、目标定位、特征提取和模式识别这五大核心环节。对于刚接触STM32和OV7670这类硬件的开发者来说,成功实现一个哪怕识别率不算太高的车牌识别系统,所带来的成就感和技术视野的提升都是巨大的。然而,这条路从硬件选型、图像采集开始,就布满了各种意想不到的“坑”——光照变化导致二值化失效、车牌区域定位不准、字符粘连难以分割……每一个环节都可能让你调试到深夜。本文旨在分享一套经过实战检验的、基于STM32F103和OV7670的完整车牌识别实现方案,并重点剖析那些容易出错的细节和对应的解决方案,希望能帮你少走弯路,更快地看到屏幕上跳出正确的车牌号码。

1. 硬件选型与图像采集:从源头规避问题

很多项目失败的第一步,往往始于硬件搭配的想当然。STM32和OV7670的组合看似经典,但具体型号和连接方式的选择,直接决定了后续图像处理的天花板。

核心控制器:为什么是STM32F103? 对于车牌识别这类需要一定计算量的任务,主频和内存是关键。STM32F103系列中,我们推荐使用STM32F103ZET6STM32F103VET6这类带有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上直接显示和后续处理。

连接与供电:细节决定成败

  1. 电源:OV7670模块的模拟部分(传感器)和数字部分(FIFO、寄存器)最好使用独立的LDO供电,并加入足够的滤波电容。电源噪声是导致图像出现横条纹、噪点增多的主要原因。
  2. I2C引脚:用于配置OV7670寄存器的I2C(SCCB)总线,需要接上拉电阻(通常4.7KΩ)。STM32的I2C引脚开漏输出,没有上拉是无法正常通信的。
  3. 数据与同步引脚:将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. 水平投影:对二值图像进行逐行扫描,统计每一行中像素值发生变化的次数(跳变点)。将每行的跳变次数绘制成曲线,车牌所在的行区域,这条曲线会呈现一个明显的波峰。
  2. 确定上下边界:设定一个阈值(例如,平均跳变次数的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;
        }
    }
}

颜色特征法:锁定蓝色区域 对于中国车牌,蓝底是一个非常稳定的颜色特征。我们可以在初步确定的水平区域内,利用颜色信息精确定位左右边界。

  1. 颜色空间转换:将RGB565转换到HSV/HSL颜色空间。HSV中的H(色调)对蓝色非常敏感,受光照亮度变化影响小。
  2. 蓝色像素筛选:遍历水平区域内的每一列,统计该列中HSV值落在蓝色范围(例如H在200-260度之间)的像素数量。
  3. 确定左右边界:蓝色像素密度高的连续列区域,即为车牌的左右边界候选。结合车牌的宽高比先验知识(例如长约440mm,宽约140mm,比例约3.14),可以过滤掉一些误检。
方法 优点 缺点 适用场景
跳变点统计 计算相对简单,对光照不敏感(因已二值化) 在字符稀疏或背景复杂时易误检 作为初步定位,快速缩小范围
颜色特征 特征显著,定位准确率高 受光照色温影响,计算量稍大(需颜色转换) 在水平区域内精确定位,验证车牌

在实际代码中,通常先使用跳变点统计快速找到可能的水平区域,然后在该区域内使用颜色特征法进行垂直方向的精确定位,并将两种方法的结果进行融合判断,从而得到一个相对准确的车牌候选框

4. 字符分割:将连体字“切开”的精细操作

定位到车牌区域后,我们需要将7个字符(1个汉字+1个字母+5个字母/数字)逐个分割出来,才能进行识别。字符分割的核心思想是垂直投影

垂直投影与分割点查找

  1. 对车牌区域的二值图像进行垂直投影:统计每一列中白色像素(字符)的数量。
  2. 分析投影曲线:字符所在的列,白色像素数较多,形成波峰;字符间的间隙所在的列,白色像素数很少甚至为0,形成波谷。
  3. 寻找分割点:波谷的最低点(或连续多列白色像素数为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)并不现实。模板匹配是最直接有效的方法。其核心思想是:预先建立好一个标准字符模板库(字模),然后将待识别的字符与库中的每一个模板进行相似度比较,取相似度最高的作为识别结果。

构建模板库

  1. 收集样本:尽可能多地收集各种字体、清晰度的车牌字符图片(包括31个省份简称汉字、24个英文字母、10个数字)。
  2. 预处理与归一化:对这些样本进行与你系统相同的预处理(灰度化、二值化)、字符分割和尺寸归一化(如24x50)。
  3. 生成字模数组:将归一化后的二值图像(每个像素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上,我们可以采用一种兼顾效果和速度的方法:

  1. 细化与骨架提取(可选但推荐):对归一化后的待识别字符进行细化处理,得到其骨架。这可以消除笔画粗细不一带来的影响。Zhang-Suen并行细化算法在MCU上有优化实现的可能。
  2. 特征点比较:不比较所有像素,而是比较一些关键特征,如字符的端点、交叉点、凹凸区域等。这需要先提取字符的轮廓或骨架特征。
  3. 加权比对:字符的中心区域比边缘区域更重要。在比对时,可以给中心区域的像素匹配赋予更高的权重。
// 一种改进的模板匹配函数示例(简化版,包含加权思想)
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正确地输出车牌号码时,那种感觉,绝对值得之前所有的折腾。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐