资讯动态

基于FPGA的车牌识别系统设计与硬件流水线实现

发布时间:2026/10/22 21:55:07 来源:尧图企业网站定制
那天晚上我把显示器的VGA线接到开发板的输出口CMOS摄像头对着窗外串口终端里开始一个字符一个字符地吐信号先是“定位成功”接着是分割出的字符序号最后整行打印出识别出的车牌。那一瞬间三个星期里写的所有Verilog、改过的几十版约束文件、烧录时看过的十几条时序报告都变得具体了。这篇文章记录的就是“基于FPGA的车牌识别系统”从算法拆解到bit流烧录的完整实验过程。系统不依赖软核不跑操作系统纯粹用逻辑资源把灰度化、滤波、二值化、Sobel边缘检测、投影定位、字符分割、模板匹配这些常规图像处理算法串成一条硬件流水线最终把结果通过UART送出来。内容适合正在学FPGA、想做图像处理方向课程设计或毕设的同学也适合想了解“软件思路是怎么翻译成硬件逻辑”的自学者。我会把每个模块的设计理由、实际参数、上板调试中踩过的坑都写清楚尽量让读者能照着复现。1. 为什么我最终选择纯FPGA而不是直接跑OpenCV1.1 CPU方案的瓶颈在哪里车牌识别最直接的实现方式是OpenCV加一台电脑或者树莓派读视频帧、转灰度、高斯模糊、Canny找边缘、找轮廓、OCR。这条路成熟、资料多、调试方便随便一搜都是现成代码。但如果是把摄像头直接接在FPGA开发板上做一套独立的硬件系统情况就完全不一样。CPU方案的核心瓶颈是“数据搬运”。每帧图像从摄像头进入内存需要拷贝算法在CPU上逐级串行执行一张1080p图像的光流、滤波、边缘检测动辄几十毫秒算上识别可能要上百毫秒。这不代表CPU慢而是通用处理器本质上是一条指令处理一份数据面对每帧上百万像素的图像只能一条一条来。FPGA的方案则是反过来的它不“读”数据而是让数据流经过逻辑电路。像素时钟打一拍灰度化单元处理一个像素下一个时钟同一个像素进入滤波窗口再下一个时钟它已经在Sobel卷积核里了。每个算法阶段都是一个独立的硬件模块模块之间用行缓存和FIFO衔接数据以流水线方式推进延迟是微秒级的。1.2 FPGA方案的真实收益和代价我一开始也没打算纯逻辑实现考虑过Zynq软硬协同ARM核上跑LinuxOpenCV处理图像FPGA部分只做采集加速。这个方案开发效率高但它有一个问题它仍然是“软件方案”瓶颈还是在ARM核的串行执行上而且工程复杂还要配内核、配驱动。纯FPGA方案的收益主要有三块延迟可控且极低。从摄像头输入到识别结果输出整个链路是像素级流水线不经过内存搬移。功耗低。纯逻辑满负荷跑整个图像处理链路的功耗远低于开发板加电脑的组合。不依赖操作系统。上电配置bit流后立即工作没有Linux启动、驱动加载这些环节特别适合作为独立嵌入式设备。代价也很明显开发周期长调试手段有限算法复杂度天花板低。深度学习车牌识别模型在FPGA上也能跑但那是另一个量级的工程不是“常规图像处理算法”能做到的。1.3 方案选择的判断标准我当时的判断标准有四个第一场景是否固定摄像头位置固定、车牌大小范围固定这对后续定位算法极其重要第二是否要求实时性流水线天然满足第三是否允许牺牲部分准确率纯逻辑模板匹配的准确率不如OCR引擎但能接受第四手头板子的资源是否够用。把这四项理清楚答案就很明确在这个固定场景、强调实时性的项目里纯FPGA路线是最合适的。那组方案对比做过之后我基本确定如果只是想做实验验证算法CPU方案当然友好但如果想真正理解图像处理算法在硬件上长什么样纯FPGA是绕不开的必修课。2. 系统整体框架从CMOS像素到串口字符的完整链路2.1 硬件平台与输入输出规划我用的是一块入门级FPGA开发板主芯片是某厂商7系列的中低端型号板上自带CMOS摄像头接口、VGA接口、UART转USB接口、按键和LED。这套硬件做车牌识别实验足够了关键是整条链路都集成在同一块板上不用飞线。图像输入用的是一颗常见500万像素CMOS传感器模块输出RGB565格式分辨率我配置为640×48060这个分辨率对后面的定位算法足够像素时钟大概在25MHz上下逻辑资源压力也小。识别结果通过板载UART以115200波特率输出到PC终端同时把处理后的二值图叠加在VGA输出上方便直观观察每个处理阶段的中间结果。在规划系统架构时我给自己定了一个原则每一级算法的输入输出都必须是标准视频流信号——数据、行有效、场有效这三个信号一起传递。这样每个模块都可以独立仿真、独立替换调试时也能随时把任意中间级引出到VGA观察。2.2 时钟域划分与数据流设计整条链路涉及三个时钟域CMOS传感器的像素时钟域、FPGA内部系统逻辑时钟域、VGA显示时钟域。像素时钟随分辨率变化系统逻辑时钟由PLL产生VGA时钟也是PLL单独输出。三个时钟域之间不能直接互连否则亚稳态问题会在上板后爆发。最核心的衔接点是CMOS像素时钟域与后续算法逻辑的桥接。CMOS输出的每个像素带有同步信号进入FPGA后先经过异步FIFO做缓冲把数据从像素时钟域平滑切换到系统时钟域。我用的FIFO位宽是16位RGB565深度2048足够缓冲一行数据。整体数据流可以这样概括CMOS输出的RGB565先做格式转换和灰度化得到8位灰度值再经过行缓存模块形成3×3窗口进入中值滤波和Sobel边缘检测边缘图经过二值化后得到一张640×480的1位边缘图定位模块在这张图里做水平和垂直方向的投影统计圈出候选车牌区域裁剪出的区域经过字符分割和归一化变成16×8的字符点阵送到模板匹配引擎匹配结果由UART发送模块按ASCII码逐字符输出。2.3 调试架构中间结果可视化纯逻辑设计最大的痛苦是看不见内部信号。我的解决办法是加了一条“调试旁路”VGA显示模块可以从寄存器配置中选择显示源包括原始灰度图、中值滤波后的图、Sobel边缘图、二值图、定位框叠加图。这样在调某个模块时直接切换到对应画面马上能看出算法参数是否合理。同时在关键模块内部预留了ILA调试核的观测点用来抓取行缓存输出、投影积分数组、模板匹配的最小距离等信号。这套“可视化调试板上链路”帮我把很多问题收敛在半小时内如果没有它纯靠串口打印排错效率会低很多。3. 图像预处理链路的RTL实现细节灰度、滤波、二值化、Sobel3.1 灰度化浮点系数转定点移位灰度转换的教科书公式是Y 0.299R 0.587G 0.114B。在FPGA里直接写小数乘法不是不行但浪费DSP资源。常规做法是把系数放大成整数再移位截断Y (R×77 G×150 B×29) 8。这个公式的误差非常小硬件上只需要两个加法器和三个乘法器对DSP切片和LUT的占用都很低。CMOS输出的RGB565不是每通道8位而是R占高5位、G占6位、B占5位。我在做灰度转换前先把RGB565扩展成RGB888R {R5, R5, R5[2:0]}G {G6, G6[5:4]}B {B5, B5, B5[2:0]}。这样保证了后续灰度值的动态范围接近标准8位。一个容易忽略的细节是无效像素的处理。CMOS模块输出数据在帧消隐期间是无意义的如果灰度模块只是按像素时钟机械地计算消隐期的数据会被当作有效像素污染下游的行缓存。我要求每个模块都要随数据携带dedata enable信号计算完成后只在de有效的时钟沿输出结果。3.2 3×3中值滤波的滑动窗口实现中值滤波的作用是去除图像里的椒盐噪声对车牌字符边缘的保持效果比均值滤波好得多。FPGA实现3×3窗口的标准套路是“两行缓存加九个寄存器”。行缓存我用了FPGA内部的移位寄存器原语每个原语可配置为1位×深度或M位×深度。这里每行需要缓存640个像素、8位灰度我配置了两条深度640的8位移位链。数据流进来时第一行缓存的输出进第二行缓存同时三个行位置各抽出一组8位数据与当前像素组合成3×3矩阵。中值计算用的是排序网络。9个像素排中值最简单的做法是三个3输入排序器先分别排序再取中间结果。我一开始写得比较粗糙每个排序器串行比较了三次结果一个像素要6个时钟才算完导致流水线出了气泡。后来改成比较器树第1级3个3输入排序器第2级用逻辑选出跨组的中间值这样中值滤波在一个时钟内就能出结果。关于边界像素我的选择是补零而不是复制边缘像素。图像边缘的噪声对车牌识别影响很小补零逻辑更简单一行开始和结束时窗口内的无效位置自动清零不会多占用额外寄存器。3.3 Sobel边缘检测与梯度幅值近似Sobel算子对后续车牌定位非常关键。车牌的边缘特征明显字符区域在水平和垂直方向都有密集的边缘响应而普通车身表面相对平坦。标准的Sobel有两个3×3卷积核Gx [-1 0 1; -2 0 2; -1 0 1] Gy [-1 -2 -1; 0 0 0; 1 2 1]在Verilog里我先把3×3窗口的每一行分别加权再做列方向累加。这样卷积运算被拆成三级加法树关键路径比直接写一个大表达式短很多。梯度幅值的严格计算是sqrt(Gx² Gy²)在FPGA里开根号要用Cordic IP核有点麻烦。实测用 |Gx| |Gy| 近似定位效果差别很小我就采用了后者。梯度计算完成后还需要一个阈值来生成边缘二值图。阈值太大会把车牌字符的笔画也滤掉太小又会让车身纹理和地面噪点全进来。调试阶段我把阈值做成一个可写寄存器通过上位机串口命令在线修改找到了一个对固定场景比较稳的数值。3.4 二值化阈值固定阈值还是动态阈值项目一开始我用的是固定阈值室内灯光下效果不错。后来把设备搬到走廊窗边下午阳光斜射进镜头同一套固定阈值就开始拉胯强光下的白车牌反光区域被误判成背景字符断裂。这时候才意识到需要动态阈值。动态阈值最经典的是大津法Otsu。在FPGA里完整实现大津法的主要开销在灰度直方图统计需要256个bin的累加器每个bin至少16位总计4Kbit的存储。我的做法是用一块BRAM做直方图存储地址就是灰度值0~255像素每个有效沿做一次“读旧值加1再写回”。这个操作必须在一个时钟内完成所以BRAM要配置为读优先模式否则会有一个周期的读写冲突。一帧图像的直方图统计完成后在帧消隐期间需要用状态机遍历256个bin计算每个可能阈值的类间方差选出最大灰度值作为下一帧的二值化阈值。我把这个计算结果锁存到寄存器下一帧所有像素使用该阈值做比较。实际测试中动态阈值明显提升了室外场景的稳定性代价是帧率会掉一帧因为统计和计算占用了一个消隐期。对于车牌识别这种场景这个代价完全可以接受。// 大津法简化思路的伪代码 // 在帧消隐期间执行 max_val 0 best_th 0 for t 0 to 255 w0 sum(hist[0..t-1]) w1 total - w0 if w0 0 or w1 0: continue mu0 sum(i*hist[i] for i in 0..t-1) / w0 mu1 sum(i*hist[i] for i in t..255) / w1 sigma w0 * w1 * (mu0 - mu1)^2 if sigma max_val: max_val sigma best_th t这个伪代码里的除法在硬件上比较费资源我实际做的时候把均值计算换成了累加器的增量式维护除法只在最后取整除一次把实现成本降了很多。4. 车牌定位、字符分割与模板匹配引擎4.1 基于投影法的车牌定位车牌定位是整个系统里最容易翻车的环节。我方向和垂直方向统计每个方向上边缘像素的数量分布然后找连续高密度区间。这个思路实现简单对“固定位置、固定光照”的场景足够稳健。具体实现分两步第一步水平投影找候选行区间。把二值边缘图逐行累加边缘像素计数若连续N行的计数都超过阈值就把这些行标记为候选带。车牌字符本身有密集的横向边缘这个特征不会被车身光滑面干扰。阈值N我设为5行阈值计数取图像宽度的1/8。第二步在候选带内做垂直投影。统计每列边缘像素数找到连续的高密度列区间再用宽高比常规车牌宽高比约3.14:1过滤明显不合理的区域。这两个步骤在FPGA里都是逐像素流式处理核心资源是两组长度为640的整数累加数组。一个细节是边缘图是逐行串行输入的水平投影可以直接在线累加但垂直投影要等整行统计完所以垂直方向需要一块行缓冲。我用了双缓冲Ping-Pong结构一个缓冲在统计当前行另一个缓冲在上一行的数据中找峰值区间两个角色每帧交替。定位框的最终坐标被锁存到一组寄存器里供字符分割模块使用。这里有个实用技巧如果当前帧定位失败不要清空旧坐标继续沿用上一帧的定位框下一帧再刷新。这样画面抖动不至于导致识别结果频繁丢失我实测这个“保持策略”对稳定性提升很明显。4.2 字符分割与归一化车牌定位框拿到之后下一步是把车牌上的字符分割成单个字符。常规车牌通常是1个汉字、1个字母、5个字母数字的组合字符之间有比较规律的间隙。字符分割的实现基础是垂直投影。对定位框内的二值图做逐列累加字符笔画所在的列累加值高字符间隙的列累加值趋近于零。根据这个投影曲线状态机找出每一段“从非零跳到零再跳回非零”的区间就是候选字符。分割过程中有两个常见坑车牌的边框和上下边缘会有横线垂直投影时容易把边框误判成一个高密度字符。我的对策是对每列投影做形态学开运算先腐蚀掉单列的窄峰再做膨胀恢复宽度。这样孤立边框线的窄响应被消除真正字符的宽响应会保留。汉字内部结构和部分笔画断开可能导致一个汉字被切分成多块。解决办法是限制最小字符宽度如果某个区间宽度小于正常字宽的0.4倍就判定为断裂直接合并到相邻区间。分割出的每个字符区域我归一化成16×8的点阵水平方向均匀抽取8列垂直方向均匀抽取16行。这一步的作用是消除字符大小差异让模板匹配的输入数据维度固定。归一化后的点阵存入一个16×8的寄存器数组同时生成valid信号表示一个完整的字符数据已经准备好。4.3 模板匹配识别引擎模板匹配的目标是把归一化后的字符点阵与预存的字符模板做逐像素比较找到差异最小的一项作为识别结果。模板库我用了一个上位机脚本把标准车牌字体渲染成16×8点阵再转成COE文件由FPGA的ROM初始化。库里包含数字0~9、大写字母除去与数字容易混淆的I和O、以及常见汉字一共几十个模板。匹配核心单元是“异或归约”把待识别字符的16×8点阵与模板ROM输出的点阵逐位异或为1的位表示该位置不一致累加后得到差异数。所有模板在硬件上是并行匹配的每个模板对应一个累加器一个字符的匹配结果在一行时间内就能全部算完。选出最小差异数之后我不能直接把差异最小的模板当作结果而是加了一个拒绝门限如果最小差异数仍然超过总点阵面积的1/4说明当前字符辨识度很低大概率是噪声或分割错误这时输出一个“”而不是强行给结果。这个门限很关键因为分割模块偶尔会把背景噪声当成字符送进来没有拒绝门限的话系统会把垃圾也识别成一个莫名其妙的字符。module match_core( input wire clk, input wire rst_n, input wire [127:0] char_data, // 16x8点阵 input wire tmpl_valid, output wire [7:0] min_dist, output wire [4:0] match_index ); // 每个模板一个比较单元并行计算异或累加 // 选择最小距离的模板索引作为识别结果这段代码只是一个示意。实际工程里我把它拆成了ROM读端口、异或累加阵列、最小值选择器三个子模块这样综合后的关键路径更好收敛。模板匹配的完整周期在一个水平扫描行内完成换算下来延迟大约几十微秒对整体性能没有任何压力。5. bit流烧录实验记录综合、约束与第一次上板5.1 从RTL仿真到bit流生成的固定流程工程做到这一步RTL代码已经全部写完我以为剩下的事情就是把bit流烧进去就行。事实证明从仿真通过到上板正常工作中间隔着一整个“物理问题”的坑。先说一下我固定的流程。每个功能模块先在仿真环境里验证灰度化模块给一组RGB数据检查输出Y值Sobel模块给已知渐变图检查梯度输出定位模块用一段模拟的边缘图序列检查候选框坐标。都通过之后才是集成仿真把串行像素流接起来跑一整帧数据在仿真波形里看最终识别结果。仿真通过后进入综合与实现阶段。在综合前我把所有顶层端口都锁定到物理引脚CMOS的像素时钟、行场同步、数据线VGA的RGB和同步信号UART的TX/RX还有系统时钟和复位按键。引脚约束我直接照开发板的原理图填写这里最需要注意的是IO电平标准CMOS摄像头模块用的是3.3V或2.5V接口VGA通常要求3.3V写错电平标准轻则信号不正常重则可能让IO处于过压状态。实现完成后我第一眼看的是资源报告第二眼一定是时序报告。资源报告过多会提醒我优化逻辑时序报告如果出现setup违例就必须回头改代码而不能直接烧录。时序报告里最关心的参数是最差路径的WNSWorst Negative Slack负数就意味着上电后可能会不定期出现信号采样错误。5.2 约束文件里最容易埋雷的位置约束文件XDC是上板前最容易出错的环节。我总结了自己遇到的几个典型问题时钟约束必须写在综合前还是实现前我的经验是越早越好最好在综合前就把主时钟、像素时钟、VGA时钟全部定义好否则综合器不会为不同时钟域做优化。PLL的locked信号务必连到复位逻辑。第一次我没接导致像素FIFO的复位信号在PLL还没锁定时就被释放上板后第一帧图像是一片乱码。多个时钟域之间的路径要么加set_false_path要么用异步FIFO不能裸奔。我的CMOS输入是外部时钟和内部100MHz系统时钟完全不同源如果不声明异步时序分析器会在这条路径上报出大量违例干扰真正重要的问题定位。5.3 第一次烧录失败从一片黑屏到定位成功第一次烧录bit流后的现象是VGA输出有画面但全黑串口没有任何输出CMOS摄像头的LED亮了说明供电正常。我先把系统分成三个独立故障域图像采集链路、图像处理链路、串口输出链路然后逐个排查。第一步查PLL锁定信号。板上的锁定LED常亮说明时钟没问题。第二步查CMOS是否有数据。用ILA抓像素时钟和行场信号发现行场信号确实在翻转但像素数据一直是0。这说明CMOS在输出“空数据”大概率是寄存器配置没生效。于是我用ILA抓SCCB类I2C接口波形逐位分析配置帧发现起始条件的时序不符合传感器文档要求SCCB总线的起始和停止条件有一个时序门槛我的FPGA状态机写得太快导致传感器没能识别。把起始条件的时钟等待从2个周期延长到5个周期后CMOS开始输出有效像素。第二步的坑在二值化模块。VGA画面从全黑变成了正常的灰度图但二值图彻底白屏边缘图也是一团黑。这个问题只花了半小时定位Sobel模块的输入数据有效信号没对齐我用了行缓存输出打拍后的灰度数据但有效信号还是原始de结果卷积核在一行前已经切换边缘位置全错了。修复方法很简单让de经过和像素数据完全相同的延迟链保证对齐。5.4 时序违例的定位与收敛图像链路跑通后我把系统时钟从50MHz提回100MHz时序报告立刻出现了setup违例最差WNS约-1.1ns。查看最差路径详情起点在Sobel模块的加法树终点在二值化比较器中间经过了一条很长的组合逻辑链。这是因为我把Gx和Gy的卷积运算写成了一个超长表达式综合器虽然尽量优化但三级乘法累加仍然在100MHz下超了一点点。我没有直接降频而是做了两处优化将Sobel卷积表达式拆成两级流水第1级算每行加权和第2级竖向累加中间插入触发器。把梯度幅值计算中的乘法器改用移位和加法实现释放了部分DSP资源也缩短了路径。优化后WNS变成0.4ns留了一点余量。这个余量在上板测试中证明了它的价值连续运行两个小时随机抓取多帧都没有出现像素错位。如果当时只是降频草草收尾问题并不会真正消失高温或外部干扰下仍然会偶发故障。6. 实测数据、资源占用与后续优化空间6.1 不同场景下的识别效果整个系统在固定场景下取得了让我满意的效果。我把摄像头放在一个模拟停车场的环境下对同一批测试车牌做了几轮统计结果如下表所示。测试场景定位成功率字符识别成功率单帧处理延迟室内均匀光照96%93%约5ms室外白天顺光91%89%约5ms室外逆光/强反光72%63%约5ms夜间无补光58%41%约5ms单帧处理延迟是流水线从像素输入到匹配结果输出的纯逻辑延迟不包含显示刷新等待。这个延迟比CPU方案低了一个数量级以上也是FPGA方案最直观的优势。准确率数据说明两个问题一是在可控光照场景下固定模板匹配方案完全够用二是一旦光照模型超出预期常规图像处理算法的天花板就出现了。逆光场景的定位失败主要是因为车牌区域整体过曝边缘梯度被高光淹没夜间失败则是因为字符对比度太低二值化阈值计算失效。6.2 资源与时钟频率汇总整个工程在7系列FPGA上综合实现后的资源占用情况如下资源类型使用量占比LUT约18K32%Flip Flop约11K19%Block RAM18个26%DSP Slice8个10%最高可运行时钟频率约132MHz我实际工作时钟为100MHz留了充足余量。这个资源占用说明即使是一颗中小型FPGA芯片也足够容纳整条车牌识别流水线不需要依赖高端器件。整个设计没有使用软核也没有外挂DDR内存关键是合理利用了Block RAM做行缓存和直方图存储。6.3 如果再让我做一次这些地方一定会调整第一摄像头分辨率我会选到800×600或1024×768而不是640×480。更高分辨率意味着更高像素时钟但投影定位的分辨率越好对远处小车牌识别的容忍度越高。现在的640×480在摄像头距离车牌10米以上时字符宽度经常不足8像素匹配准确率明显下降。第二模板匹配的字符库必须覆盖不同字体和常见磨损状态。第一版库里只有标准样式字体实际测试时遇到笔画粘连或轻微形变的车牌拒识率很高。后来我增加了两个变体字体模板错检率下降了不少。第三分割模块可以做得更聪明。当前的分割逻辑是基于垂直投影的对轻度倾斜的车牌还能硬扛但倾斜角度超过10度后字符投影重叠分割错误很难通过参数调整解决。更稳的方案是先对定位框做一次透视矫正把四边形矫正为矩形再做投影分割这部分逻辑在当前工程里没有做。这个内容还能往哪个方向扩展如果继续改进这套系统最直接的路线是增加颜色辅助定位。经典车牌颜色中蓝底白字占绝大多数而车身颜色多集中在灰、黑、白、红、银等在HSV色域下蓝色像素的分布特征非常明显。FPGA里做HSV转换比RGB复杂一些但也不是不能做。定位模块先检测蓝色像素密集区域来锁定候选框再用边缘投影进一步验证这个组合能显著降低复杂背景下误定位的概率。另一个方向是字符识别的升级。模板匹配在工程上简单但对形变、遮挡、光照变化鲁棒性差。可以考虑用更简单的MLP多层感知机来替代模板库训练好的权重量化后存成ROM像素点阵作为输入逐层做乘加和激活。FPGA上的DSP资源够用延迟也不会增加太多。这条路比直接上卷积网络要可控得多识别准确率却会比纯模板匹配上一个台阶。我个人在实际操作中的体会是这个项目最大的价值不是“识别出车牌”这件事本身而是强迫我完成了一次“软件思维到硬件思维”的转换。每一个看似简单的算法步骤在RTL里都要考虑数据有效信号、时钟域、资源占用、时序收敛。如果你也想做类似的方向我建议从VGA显示中间结果开始先把一条数据通路完全打通再逐步在通路上添加算法模块。这样每次新增逻辑都能立刻从画面上验证效果而不是写完整个系统才后悔某个初级环节出了问题。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑