最近在做低速目标识别相关的东西,翻出了之前赛车识别和颜色分类的项目记录。不少人在问 STM32F4 接 OV7670 无晶振、无 FIFO 摄像头模块能不能做车牌识别,这里统一梳理一下完整思路和可复现的调试记录。
先说结论:完全能做。这套方案的核心是STM32F407 + OV7670 摄像头(无晶振、无 FIFO),实现两类任务:车牌底色识别和车牌字符/底色综合识别。相比带 FIFO 的摄像头模块,无晶振、无 FIFO 版本接线少、成本低、实时性更好,驱动程序写起来反而更直白。而且 OV7725、OV2640 这类同样走 SCCB + DCMI 接口的摄像头,移植思路几乎一模一样,改改寄存器配置就能跑起来。这篇文章会把硬件接线、底层驱动、颜色识别、车牌定位识别、常见坑一次性说透,适合正在做电赛、嵌入式课程设计或者想入门图像采集的同学参考。
1. 方案选型与整体设计思路
1.1 为什么偏偏选无晶振、无 FIFO 的 OV7670
市面上的 OV7670 模块主要有三种形态:带晶振带 FIFO、带晶振无 FIFO、无晶振无 FIFO。很多人一上来就买带 FIFO 的,觉得省心,但实际上 FIFO 版本的问题不少。
带 FIFO 的模块(常见的是 AL422B 芯片)确实能从物理上解决“摄像头数据太快、单片机来不及读”的问题。但代价是你要多学一套 FIFO 的读时序,还要等摄像头把一整帧写进 FIFO 才能读,帧率直接砍半。而 STM32F4 本身就有 DCMI 摄像头数字接口,可以直接接收并行像素数据,压根不需要中间缓冲。所以从外设资源角度看,无 FIFO 版本才是和 STM32F4 最搭的。
无晶振版本则意味着摄像头模块上没有独立的时钟晶振,XCLK 时钟由 STM32 的 MCO 引脚直接供给。这样做的好处有三个:频率可以自己调、模块体积更小、成本更低。对做车牌识别这种“颜色和轮廓主导”的场景来说,XCLK 稍微给低一点完全不影响效果,反而还能降低 PCB 布线难度。
门槛也有。无 FIFO 意味着数据流是连续不断的,你必须用 DMA 接力,并且要把帧同步、行同步、像素时钟的时序理清楚。这套逻辑一旦跑通,后面接 OV7725、OV2640 都是同样套路。
1.2 STM32F4 做图像处理的主控优势
有人可能会问,为什么不用树莓派或者直接用 OpenMV?原因很现实:很多竞赛平台限定了主控,而且 STM32F4 本身做轻量图像处理并不算吃力。
STM32F407 主频 168MHz,片上带有DCMI 接口,支持 8/10/12/14 位并行数据,内置 DMA 请求。这意味着摄像头数据可以绕过 CPU,直接以“满带宽”灌进内存,CPU 可以在 DMA 传输期间去跑其他逻辑。
内存方面,F407 有 192KB SRAM。如果开 QVGA 分辨率(320x240)的 RGB565,一帧数据是 320x240x2 = 153600 字节,大约 150KB,单片缓冲放得下。做颜色识别和车牌定位绰绰有余。想要流畅的双缓冲,需要外扩 SRAM 或者切到 QQVGA,这个后面细说。
另外,STM32F4 的 CPU 跑整数运算很快,像 RGB 转 HSV、颜色阈值分割、模板匹配这类操作,用整数近似和查表优化,完全可以在帧间隔内处理完。
1.3 OV7725、OV2640 的兼容思路
OV7725 同样出自原豪威(OmniVision),传感器约 30 万像素,接口和 OV7670 几乎一致,SCCB 控制寄存器的地址和含义大体相同,但分辨率、默认输出格式、AGC/AEC 参数有些区别。移植时核心工作是重写寄存器初始化数组,DCMI 和 DMA 采集代码可以直接复用。
OV2640 更特殊一些,它最高支持 200 万像素,但寄存器配置复杂不少,需要通过 JPEG 模式或 RGB565 模式输出。做车牌识别时建议把分辨率降到 SVGA(800x600)以下,DCMI 数据宽度配置为 8 位,同样可以复用这套方案。
其中要留意的是各家传感器对HREF/VSYNC/PCLK 的极性定义略有差异。DCMI 初始化中的极性配置必须参考具体传感器数据手册,这也是大多数兼容性 bug 的根源。
2. 硬件连接与底层驱动要点
2.1 引脚分配与硬件接线
先给出一组我实际用过的接线,以 STM32F407ZGT6 为例,摄像头直接接 DCMI 引脚。D0 到 D7 是 8 位并行数据总线,PCLK 是像素时钟,VSYNC 是帧同步,HREF 是行同步。
| OV7670 引脚 | STM32F407 引脚 | 说明 |
|---|---|---|
| D0 ~ D3 | PA0 ~ PA3 | DCMI_D0 ~ DCMI_D3 |
| D4 ~ D7 | PB0 ~ PB3 | DCMI_D4 ~ DCMI_D7 |
| PCLK | PA6 | DCMI_PIXCK |
| VSYNC | PA4 | DCMI_VSYNC |
| HREF | PA7 | DCMI_HSYNC |
| XCLK | PA8 | MCO1 输出时钟 |
| SIO_C / SIO_D | PB6 / PB7 | 软件模拟 SCCB |
| PWDN | 直接拉低或接 GPIO 低电平 | 上电后禁用掉电模式 |
| RESET | 接 RC 复位或 GPIO | 初始化时拉低再拉高 |
注意,PB2 是 BOOT1 引脚,我一般避免在 PB2 上接数据线,所以 D0~D3 用 PA0~PA3,D4~D7 用 PB0~PB3 这种组合可以很好地避开 BOOT 相关引脚冲突。如果开发板引出的 DCMI 数据引脚固定了,也可以查参考手册的复用表确认可用引脚。
SCCB 我建议直接用 GPIO 模拟,不要用硬件 I2C 外设。OV7670 的 SCCB 协议和标准 I2C 大体兼容,但有细节差异,GPIO 模拟可以完全掌控时序,排查问题也方便。
2.2 SCCB 总线配置与寄存器读写
SCCB 有三条核心规则:起始和停止条件与 I2C 类似;设备地址通常是 0x42(写地址);寄存器地址 8 位,数据 8 位。无晶振模块从机时钟不受 XCLK 限制,但 SIO_C 的时钟频率不要超过 400kHz。
软件模拟的时序非常简单:
- 起始:SIO_D 拉低,SIO_C 保持高。
- 发设备地址 0x42。
- 发寄存器地址。
- 发数据字节。
- 停止。
需要注意,OV7670 的 SCCB 读操作要先写寄存器地址,然后重新给起始条件,再发读地址 0x43,才能读出数据。很多第一次写的同学直接在写地址后立刻读,结果读回来全是 0xFF。
调试 SIO 通信时,强烈建议把读写函数封装好之后先回读摄像头 ID 寄存器(PID 寄存器地址 0x0A,应该读回 0x76),验证通路没问题再往下走。
2.3 关键寄存器配置与参数计算
OV7670 寄存器很多,但不是每个都要改。做 RGB565 QVGA 输出时,这几个寄存器必须重点对待:
| 寄存器地址 | 名称 | 作用 |
|---|---|---|
| 0x11 | CLKRC | 内部时钟分频,影响 PCLK |
| 0x12 | COM7 | 分辨率、输出格式、RGB 使能 |
| 0x40 | COM15 | RGB 输出格式,RGB565/555/444 |
| 0x3A | TSLB | 字节顺序、U/V 顺序,错了会红蓝互换 |
| 0x3D | COM12 | 颜色饱和度、延迟等 |
| 0x13 | COM8 | AGC / AEC 开关,自动曝光总控 |
| 0x14 | COM9 | AGC 增益上限 |
| 0x17 | HSTART | 行起始位置(配合窗口水平偏移) |
| 0x18 | HSTOP | 行结束位置 |
| 0x19 | VSTART | 帧起始位置 |
| 0x1A | VSTOP | 帧结束位置 |
| 0x03 | COM3 | 水平/垂直镜像等 |
关于 XCLK 的频率,我实测用 8MHz 完全足够。168MHz 主频下,MCO1 可以配置为 PLL 输出 8MHz,代码上就是HAL_RCC_MCOConfig选RCC_MCO1SOURCE_PLLCLK再配分频系数。如果 XCLK 太高,PCLK 也会跟着变快,DMA 容易丢像素;太低则帧率上不去。QVGA 下 8MHz 输入,内部再分频,PCLK 大约能做到 6MHz,帧率大概 10~15 帧,做车牌识别足够。
分辨率设置要同步改HSTART/HSTOP 和 VSTART/VSTOP。网上很多初始化代码默认是 VGA(640x480),如果 DMA 缓冲区按 QVGA 的宽度配,图像会斜切。我习惯直接参考摄像头数据手册的“Recommended Settings”,把 QVGA 的窗口寄存器整套搬过来,再统一修改。
3. 图像采集与前置处理
3.1 无 FIFO 模式下的 DCMI + DMA 采集
无 FIFO 模式下,摄像头输出的是连续像素流,DCMI 外设负责抓取这些像素,DMA 负责把像素搬到内存。配置思路是这样的:
- DCMI 工作模式:外部同步模式,即由 VSYNC/HREF/PCLK 控制同步。
- 数据宽度:8位。
- 极性配置:VSYNC 高有效、HREF 高有效、PCLK 上升沿采样。
- DMA:使能循环模式,每次传输长度为一帧字节数(例如 QVGA RGB565 就是 320x240x2,约 153600 个半字)。注意外设地址是 DCMI 的数据寄存器,内存地址是帧缓冲区,PSIZE 和 MSIZE 都配成半字(HalfWord)。
帧完成中断里要做的是“关闭这次 DMA 传输,处理这一帧数据,再把 DMA 重新拉起来”。如果不想丢帧,可以在帧中断里只交换缓冲区指针,让 DMA 继续跑。
HAL 库的调用大致是这样:
DCMI_HandleTypeDef hdcmi; hdcmi.Instance = DCMI; hdcmi.Init.SynchroMode = DCMI_SYNCHRO_HALF; hdcmi.Init.PCKPolarity = DCMI_PCKPOLARITY_RISING; hdcmi.Init.VSPolarity = DCMI_VSPOLARITY_HIGH; hdcmi.Init.HSPolarity = DCMI_HSPOLARITY_HIGH; HAL_DCMI_Init(&hdcmi); HAL_DCMI_Start_DMA(&hdcmi, DCMI_MODE_CONTINUOUS, (uint32_t)frame_buffer, IMG_WIDTH * IMG_HEIGHT * 2);有一点必须注意:DMA 配置的传输单元是字节还是半字,直接决定图像是不是错位的。OV7670 在 RGB565 模式下,每个像素由两个字节组成,所以 DMA 内存宽度和外设宽度都建议配成半字。如果配成单字节,帧内数据依然是连续的,只是你后续取像素时要注意字节序;但配成半字能天然保证像素对齐。
3.2 帧数据在内存中的存储与像素读取
RGB565 的每个像素占 2 个字节,内存里高位字节在前(大端字节序,与 TSLB 寄存器设置有关)。我习惯把缓冲区定义成uint16_t数组,这样取像素直接frame[y * IMG_WIDTH + x],不用每次拼字节。
拆出 RGB 分量的位运算是这样的:
uint16_t pixel = frame[y * IMG_WIDTH + x]; uint8_t r = (pixel >> 11) & 0x1F; uint8_t g = (pixel >> 5) & 0x3F; uint8_t b = pixel & 0x1F;然后可以左移分别映射到 8 位空间,方便后续统一处理。实际做颜色识别时,我一般直接在这 5/6/5 位深度上做阈值,最多加一次移位,省掉浮点运算。
如果发现画面整体偏红偏蓝,大概率是 TSLB 寄存器里的字节顺序配置反了。在 OV7670 中,可以通过 0x3A 的 bit0 来交换像素字节顺序。这个问题在 DCMI 直采时尤其常见,FIFO 版本因为先写入再读出,反而会在某一步被掩盖掉,最终图像也可能错。
3.3 颜色识别算法:从 RGB 到 HSV 的整数化
颜色识别这件事,在 RGB 空间直接做阈值非常痛苦。光照一变,R/G/B 三个分量跟着变,阈值怎么调都不理想。车牌识别里最常用的是HSV 颜色空间,把色相(H)、饱和度(S)、亮度(V)分开,这样颜色判断和光照基本无关,只依赖色相。
RGB 转 HSV 的标准公式是:
max = MAX(r, g, b) min = MIN(r, g, b) delta = max - min H = 如果 delta == 0: 0 如果 max == r: (60 * ((g - b) / delta) + 360) % 360 如果 max == g: 60 * ((b - r) / delta) + 120 如果 max == b: 60 * ((r - g) / delta) + 240 S = delta / max (max != 0 时) V = max嵌入式里我不用浮点,全部用定点近似。比如把饱和度映射到 0~255,那么s = (uint8_t)(delta * 255 / max)。色相用查表或者直接几个分支判断。因为车牌底色就那几种(蓝、黄、绿、白),不需要高精度色相,用分段判断完全够。
颜色阈值我建议自己采集样本再统计,而不是抄网上的固定值。把摄像头固定好,对着一张蓝色车牌纸板拍 100 帧,打印每个像素的 HSV 值,看 H、S、V 的分布范围。例如我实际调出来的蓝色阈值大概是:
- H: 200~250
- S: 70~255
- V: 60~230
黄色阈值:
- H: 15~50
- S: 80~255
- V: 90~255
绿色(新能源牌):
- H: 80~160
- S: 60~255
- V: 60~230
注意 V 通道不能太低,太低了基本就是黑色;也不能太高,太高就是高光/白色,很容易误判。
遍历像素做颜色判断时,把阈值写死成整数宏,每个像素只做几次比较,速度非常快。
4. 车牌识别核心实现
4.1 车牌区域定位:先找底色,再找字符
车牌识别第一步不是找字符,而是找底色的“色块”。中国车牌是按底色区分类别的,蓝底白字、黄底黑字、绿底黑字、白底黑字等,底色在整幅画面里是一块颜色均匀的矩形区域。
我用的是“行扫描 + 连续色段统计”的方法,比 OpenCV 里那种连通域分析在单片机上实现起来简单得多:
- 对每一行,从左到右扫描像素。
- 如果像素满足当前要检测的颜色阈值(比如蓝色),就统计连续存在多少个像素。
- 如果连续蓝色像素数量超过一个阈值(比如 50 个),就记录该行的起始列和终止列,并把这个行段并入候选区域。
- 连续多行都有符合要求的色段,说明这是一个高度方向上有一定厚度的色块,记录上下边界。
得到候选区域后,计算宽高比。标准车牌比例大约是 440x140,长宽比约 3.1,但摄像头斜视、透视变形会改变这个值。我把候选区域的长宽比范围放宽到 1.8~4.5,宁可多留候选,等后面字符识别再过滤。
如果一帧里同时出现多个候选色块,还可以用“面积最大”和“位置靠近画面中央”两个规则进一步筛选,通常能去掉大部分干扰。
4.2 车牌底色与类型判定
类型判定其实就是对候选区域内的像素做一次颜色统计。在 HSV 空间里,对候选区域内所有像素按蓝、黄、绿、白四类做投票,得票最多的就是车牌底色。
统计可以用一个简单的循环:
uint32_t cnt_blue = 0, cnt_yellow = 0, cnt_green = 0, cnt_white = 0; for (row = top; row < bottom; row++) { for (col = left; col < right; col++) { pixel = frame[row * IMG_WIDTH + col]; if (is_blue(pixel)) cnt_blue++; else if (is_yellow(pixel)) cnt_yellow++; else if (is_green(pixel)) cnt_green++; else if (is_white(pixel)) cnt_white++; } }统计完之后取最大值,同时计算它占整个区域的比例。如果最高占比连 20% 都不到,说明这个区域大概率不是车牌,直接丢弃。这一步能在字符识别之前就把很多误检排除掉。
实际测试中,蓝色和绿色有时候会互相混淆,因为感光元件对蓝绿通道的响应接近。解决办法是把饱和度阈值提高,减少暗光下蓝色计数的误增。
4.3 字符分割与识别
确定底色后,工作区域缩小到候选区内,接下来就是字符分割和识别。
这里只做二值化。蓝底车牌上字符是白色,黄底/绿底上是黑色,因此二值化的规则不同。比如蓝底车牌,直接取“亮度高于阈值”的像素为字符;黄底/绿底车牌,取“亮度低于阈值”的像素为字符。我这个项目里没有做传统车牌识别里复杂的倾斜校正,一般摄像头尽量摆正,透视只要不太夸张,字符分割和模板匹配都能扛住。
字符分割用垂直投影法。先把候选区每一列的“字符像素”数量统计出来,连续非零区域就是一个字符,连续零区域就是字符间隔。中国车牌一个典型难点是第一个汉字笔画多、容易和边框粘连,处理时我会把上下边缘再各裁掉 3~5 个像素,去掉边框干扰。
分割出的字符图像归一化到固定尺寸,例如高 24、宽 12,然后和模板做逐像素异或累加。模板库包含 31 个省份汉字、24 个大写字母(通常去掉 I 和 O 防止混淆)、10 个数字,一共 65 个模板。对每个模板:
uint16_t diff = 0; for (uint16_t i = 0; i < TEMPLATE_SIZE; i++) { diff += (sample_bits[i] != template_bits[i]) ? 1 : 0; }diff 最小且低于阈值的模板就是识别结果。STM32F4 跑一轮 65 个模板、每个 24x12 像素的匹配,大约只需要几毫秒,完全能接受。
有一点实操建议:分割比识别重要得多。字符一旦分割歪了、粘连了,再好的匹配算法也会翻车。我先保证二值化和投影分割的质量,识别本身用最简单的方法就能跑出很高正确率。
5. 常见问题与调试心得
5.1 图像花屏、雪花、偏色怎么查
花屏是最常见的问题,优先按这个顺序排查:
- DMA 传输长度和分辨率不匹配。检查
width * height * 2是否和 DCMI 实际输出分辨率一致。分辨率改过,DMA 长度没改,画面必定斜切或花。 - DCMI 极性配置反了。PCLK 上升沿/下降沿采样的极性反了,图像会非常诡异甚至雪花。用示波器看 PCLK 和 D0 的关系,或者切换
DCMI_PCKPOLARITY_RISING/DCMI_PCKPOLARITY_FALLING直接试。 - VSYNC/HREF 时序不对。如果图像整体偏移、有大量黑色横条,多半是行同步极性或帧同步极性配置问题。
- 摄像头初始化没生效。SCCB 通信失败或者寄存器没写进去,输出可能是 RAW 格式或者默认 VGA 格式。先读 PID 验明身份,再写关键寄存器。
- 电源纹波太大。摄像头模块的模拟电源对纹波很敏感,如果画面有规律横纹,检查供电是否干净,最好单独用 LDO 给摄像头供电。
偏色问题集中在白平衡和字节序上。OV7670 默认自动白平衡在刚上电时可能需要几百毫秒收敛,初始化完成后不要立刻抓帧,等几帧再开始识别。如果是明显的红蓝互换,查 TSLB 寄存器的字节顺序。
5.2 帧率与实时性优化
车牌识别对帧率要求其实不高,但图像处理过程不能卡到响应迟钝。我常用的优化手段:
- 分辨率砍到 QVGA 甚至 QQVGA。320x240 只做颜色扫描很快;如果跑完整 HSV 全图遍历,降到 160x120 能让处理时间减半,识别率并不会明显下降。
- 不用浮点。HSV 全部用整数宏判断,一个像素只用几十个周期。
- 只处理候选区域。先做一次快扫描定位色块,字符识别只在候选区域里做,避免全图反复遍历。
- DMA 双缓冲。如果内存够,或者外扩 SRAM,双缓冲可以让摄像头采集和 CPU 处理并行,帧率几乎翻倍。没有双缓冲时,处理期间 DMA 要停掉,帧率会掉到 10fps 以下,但车牌静态场景一般够用。
5.3 官方文档里不会写的几个坑
有些问题不是 bug,是硬件和传感器本身的脾气,这里列出来分享一下:
PWDN 和 RESET 时序很关键。上电后 PWDN 必须拉低,否则摄像头进入掉电模式,SCCB 写什么都白搭。RESET 引脚要在初始化前拉低再拉高,至少保持几十微秒,然后再开始写寄存器。很多“摄像头黑屏”其实是 RESET 没彻底复位。
无晶振模块的 XCLK 不能悬空。有的同学从带晶振模块换到无晶振版,忘了给 XCLK 接 MCO 输出,摄像头输出全是乱码。没有时钟,传感器完全无法工作。
OV7670 的自动曝光和自动增益对颜色判断影响很大。车牌反光严重时,整个画面可能过曝,蓝色底变成接近白色。如果场景光照可控,建议把 AGC/AEC 固定下来,用固定曝光值比自动曝光更稳定。如果光照变化大,则保留自动模式,但要给 HSV 阈值留出足够的容差。
低光照下的蓝色识别最容易翻车。我的经验是把蓝色饱和度阈值放宽到 60 左右,同时用亮度下限 60 兜底。宁可误检几个非车牌区域,也不能漏掉真实车牌,因为后续还有长宽比和字符识别二次过滤。
最后分享一个个人习惯:每次改完寄存器配置,都把“当前配置 + 现场照片效果”记到笔记本上,特别是改镜像、改分辨率、改字节序这类易混项。摄像头调试最怕“每次都在试”,有记录才能快速收敛到可用状态。这个项目做完之后,OV7725 的移植我基本只花了半天,就是因为驱动分层把“采集逻辑”和“传感器配置”完全分开了,换摄像头只改寄存器表,DCMI 和颜色识别代码一行没动。这也是做嵌入式视觉项目值得坚持的方向。