首页 / 资讯中心 / 文章详情

FPGA车牌识别完整工程:从OV5640采集到模板匹配的Verilog实现

FPGA车牌识别完整工程:从OV5640采集到模板匹配的Verilog实现 ★ FEATURED ARTICLE
简介一套完整的基于FPGA的车牌识别解决方案版本标记为v4面向嵌入式开发与图像处理方向的工程师、高校学生利用OV5640图像传感器采集图像、DDR3高速缓存暂存数据覆盖从像素预处理、车牌定位、字符分割到识别的完整数据通路适用于智能交通、安防门禁等实时场景的算法验证、教学实验以及二次开发定制。压缩包共1072个文件、约115MB其中VHDL/Verilog硬件源码超过300份另辅以XDC管脚约束、XCI IP核定义、TCL/BAT自动仿真综合脚本、VCD仿真波形还包含编译日志、综合报告和大量中间文件各文件类型对应不同的开发阶段便于按模块还原工程的完整构建与仿真过程源码可自由查看修改以适配不同板卡。已有3033人学习适合具备数字电路或Verilog基础、希望深入FPGA图像处理的中高级开发者。通过该工程可掌握OV5640摄像头驱动、DDR3读写控制、LCD显示接口以及车牌识别各算法模块的流水线协同设计理解灰度化、二值化、边缘检测、字符分割等经典算法在硬件中的高效实现工程中已出现综合完成标记表明核心设计可直接加载到FPGA开发板运行可在此基础上替换算法或修改约束进行调优大幅缩短从算法理论到硬件落地的时间。1. 为什么用FPGA做车牌识别这份工程能把硬件加速的账算明白停车场道闸、高速ETC、园区门禁凡是需要现场识别车牌的设备主控板里几乎都有一颗FPGA在扛实时图像处理。这两年FPGA入门的人越来越多但网上能找到的车牌识别工程要么是ARM开源库的软件方案要么是论文级的RTL代码片段真要在一张黑金AX7020或正点原子达芬奇上跑出效果往往差着一整套可综合的工程。这份“基于FPGA的车牌识别工程与源码”给的就是完整Verilog实现从OV5640采集、灰度化、中值滤波、Sobel边缘检测、大津法二值化到蓝牌定位、字符分割、模板匹配识别一条流水线全部在硬件上走完。适合FPGA开发入门12年、想拿图像处理做项目实战的工程师也适合做毕设和竞赛复现。它不依赖神经网络靠经典图像处理的时序可控性把车牌识别做成了吃透FPGA数据流设计的完整案例。2. 顶层架构与数据通路先把时钟、缓存和流水线理顺2.1 工程模块划分从摄像头到串口输出的七级流水线这套工程的核心思想是“流式处理”图像数据像水一样从采集端流到识别端中间不落整帧DDR靠行缓存和状态机完成所有运算。拿到源码后先别急着综合打开顶层文件把模块结构捋一遍。我这边看到的工程划分大致是模块名职责对外接口cmos_cfgI2C配置OV5640寄存器scl/sda初始化完成标志cmos_captureDVP接口采集同步/裁剪pclk、vsync、hsync、data[7:0]rgb2grayRGB888转灰度8bit输入使能输出灰度流median_filter3×3中值滤波去椒盐噪声灰度流行缓存sobel_edge边缘检测输出二值边缘灰度流阈值寄存器otsu_binarize大津法计算阈值并二值化灰度流帧同步信号plate_locate蓝色像素检测 行列投影定位二值流输出候选区坐标char_segment字符分割 归一化到32×16候选区坐标字符点阵template_match模板ROM匹配输出ASCII字符点阵匹配得分uart_debug串口回传中间帧、识别结果AXI-Lite接口或直接寄存器第一梯队是采集和预处理第二梯队是定位分割第三梯队是识别和回传。调试时可以把uart_debug当探针用把任意一级的中间结果按行打包发到串口电脑上用脚本还原成BMP。这个技巧后面专门讲。2.2 带宽与分辨率选型为什么720P要降到VGA再处理摄像头默认输出1920×1080或1280×720但工程里实际处理分辨率往往是VGA640×480。这不是偷懒是算过账的。以720P30为例像素时钟约54MHz每像素3字节未经压缩的数据率是1280×720×30×3≈82.9MB/s。预处理链路由灰度化、中值滤波、Sobel、形态学四个算子级联每个算子在FPGA里都对应至少一拍的组合逻辑延迟54MHz下关键路径稍有抖动就时序违例。VGA60的像素时钟约25.175MHz数据率降到约27.6MB/s组合逻辑有充足余量。而且车牌识别的精度瓶颈不在分辨率——一个标准车牌在VGA画面里宽度约80120像素字符高度约3040像素完全够模板匹配用。工程里通常的做法是让OV5640直接输出720P采集模块只取中心区域的640×480或者由摄像头配置寄存器直接设为VGA输出。我拿到这类工程第一件事就是确认采集模块的裁剪逻辑x_cnt和y_cnt的起始值、有效窗口宽度是不是640×480别到板子上图像错位了才回头查这里。2.3 时钟域与行缓存两个Shift RAM就把3×3窗口凑齐整条流水线有两个时钟域摄像头像素时钟pclk来自DVP接口和逻辑主时钟clkPLL生成常用50MHz。预处理四个算子全部跑在pclk域好处是像素流天然同步不用做跨时钟握手定位和识别模块因为处理的是行列坐标和点阵可以用clk域中间用异步FIFO或双口RAM衔接。工程里最关键的缓存设计是3×3窗口生成器它的本质是两行延迟// 行缓存Shift RAM深度640宽度8bit wire [7:0] shift_out_0, shift_out_1; shift_ram #(.DWIDTH(8), .DEPTH(640)) u_shift0 ( .clk(pclk), .din(gray_in), .dout(shift_out_0) ); shift_ram #(.DWIDTH(8), .DEPTH(640)) u_shift1 ( .clk(pclk), .din(shift_out_0), .dout(shift_out_1) ); // 当前行数据打一拍形成三行并行 reg [7:0] row0_d, row0_d2; // 第n-2行 reg [7:0] row1_d, row1_d2; // 第n-1行 reg [7:0] row2_d, row2_d2; // 第n行 always (posedge pclk) begin row0_d shift_out_1; row0_d2 row0_d; row1_d shift_out_0; row1_d2 row1_d; row2_d gray_in; row2_d2 row2_d; end这段代码的逻辑是gray_in是当前像素经过两级Shift RAM后得到同一列、前两行的像素。再把三行数据各自打一拍就凑齐了以当前像素为中心的3×3邻域命名上我习惯用w11表示中心、w00表示左上角。参数DEPTH640必须和一行有效像素数严格一致如果采集模块把行有效拉宽了窗口就错位图像会出现锯齿状条纹。这也是换分辨率后最常翻车的地方。3. 预处理流水线灰度化到Sobel边缘检测一帧图像只延迟几行3.1 灰度化与二值化移位近似代替乘法器先说灰度化。RGB888转灰度最常用BT.601系数Y 0.299R 0.587G 0.114B。浮点系数在FPGA里没法直接用常见做法是放大256倍取整Y (77R 150G 29B) 8。乘数77、150、29都在255以内一个DSP48就能吃掉三个乘法或者用移位加代替——77 64841150 128164229 16841纯逻辑实现也不费资源wire [7:0] r_d, g_d, b_d; // 对齐后的三通道 wire [15:0] y_t; assign y_t (r_d*77 g_d*150 b_d*29); assign gray_out y_t[15:8]; // 取高8位等价于右移8位逻辑说明乘加结果最大是255×2566528016bit位宽足够不溢出。取y_t[15:8]而不是8综合工具能省掉无效位的翻转。这里有个参数细节如果摄像头输出是RGB565需要先补零扩展到8bit再进灰度模块否则低通道数据会丢位。我见过不少工程把565直接送进来结果灰度图偏绿——就是没做位宽对齐。二值化这步工程用的是大津法Otsu。Otsu的核心是遍历0255每个灰度级计算类间方差取最大者作阈值。软件实现是双层循环硬件实现要换思路双帧策略。第N帧图像只做直方图统计帧结束信号拉高后用组合逻辑或少量周期算出阈值第N1帧用这个阈值做二值化。流程图简化为“统计→计算→应用”三个状态代价是输出延迟一帧对60fps场景完全无所谓。大津法类间方差公式 σ² ω0·ω1·(μ0−μ1)²直接算需要除法。工程里我一般绕开除法ω0和ω1分别是前景背景像素占比等价于比较n0·(sum1·n0 − sum0·n1)²和n1·(sum0·n1 − sum1·n0)²全用累加和计数完成省掉除法器IP。直方图统计用640×480个像素的BRAM数组// 直方图统计帧有效期间累加 always (posedge pclk or negedge rst_n) begin if (!rst_n) hist_cnt[gray_in] 0; else if (frame_valid hist_en) hist_cnt[gray_in] hist_cnt[gray_in] 1; end逻辑说明BRAM是异步读、同步写hist_cnt[gray_in]用gray_in作为地址每个灰度级一个计数器。做完一帧后把所有非零项拉出来计算累计和。hist_en是使能信号防止行消隐期间误统计。调试时可以用串口把计算出的阈值打出来和PC端用Python算的Otsu阈值对比偏差超过2就要怀疑直方图计数位宽或帧有效信号出了问题。3.2 中值滤波22个比较器的组合逻辑树预处理链路上中值滤波的作用是干掉椒盐噪声否则噪声点会在Sobel边缘上形成大量假边缘直接影响车牌定位的投影结果。3×3中值滤波的原理是把9个像素排序取第5个值输出。硬件排序最省资源的写法是三级比较树先对三行分别排序再对三列取中值最后对三个中值排序取中。大约22个比较器纯组合逻辑实现。// 三行排序每组3个像素找最大、中值、最小 wire [7:0] max_a max3(w00, w01, w02); // 最大值 wire [7:0] med_a med3(w00, w01, w02); // 中值 wire [7:0] min_a min3(w00, w01, w02); // ... 对 b、c 组同样处理 wire [7:0] max_med max3(med_a, med_b, med_c); // 中值的中值 wire [7:0] result max_med;逻辑说明这个写法是“最大值的中值”实际效果和完整排序取中值等价但比较器数量少了一半。max3/med3/min3建议写成函数或generate综合工具会自动优化成查找表结构。参数上要注意像素位宽是8bit比较器就按8bit位宽综合如果把行缓存改成了10bit比如去掉高位噪声所有比较器位宽都得跟着改否则最高位参与比较会让结果偏暗。这里有个真实参数中值滤波对3×3窗口边缘的两行两列像素不输出有效值工程里通常把窗口无效区域置为灰色128避免定位模块把边框当成连续跳变。如果你在板子上看到图像四边有一条窄带和中间亮度不一致不是滤波错了是窗口填充策略。3.3 Sobel边缘检测与阈值判决有符号算术的坑在这Sobel是车牌识别里定位的核心依据——车牌字符区域边缘密集而车牌底色区域相对平滑。Sobel用两个3×3卷积核分别算水平梯度和垂直梯度。水平核检测垂直边缘垂直核检测水平边缘wire signed [10:0] gx, gy; assign gx (w2 2*w5 w8) - (w0 2*w3 w6); assign gy (w6 2*w7 w8) - (w0 2*w1 w2); wire signed [11:0] mag (gx[10] ? -gx : gx) (gy[10] ? -gy : gy); wire sobel_out (mag th_sobel) ? 1b1 : 1b0;逻辑说明这里必须用signed声明否则w2 2*w5 w8减w0 2*w3 w6时无符号减法会得到错误的补码结果。这是Sobel模块最常见的翻车点症状是边缘图出现大量亮点或全黑。gx的位宽是11bit因为输入8bit、核内最大系数2中间结果最大是4×255×22040需要11bit表达带符号位就是signed [10:0]。mag是梯度的曼哈顿距离近似精度足够车牌定位用不必要开根号——开根号在FPGA里要调CORDIC IP徒增资源。th_sobel是阈值寄存器我一般挂在开发板的拨码开关或UART命令上在线调参。经验值VGA分辨率、光照正常的白天场景阈值设在4060之间逆光场景要降到25以下否则车牌边缘断裂严重。如果你在板子上看到边缘图断断续续别急着改算法先调这个阈值。预处理链路的整体延迟灰度化1拍、中值滤波约2拍、Sobel约2拍、二值化1拍总计约6拍相当于一行640像素还没扫完边缘图就开始输出了这就是流式处理的实时性优势。4. 车牌定位与字符分割蓝牌区域锁定与垂直投影别被铆钉骗了4.1 蓝色像素检测先圈定候选行再锁候选区车牌定位的第一步是用颜色信息锁定候选区域。国内蓝牌底色是蓝色像素特征明显B分量远大于R和G。在RGB域做近似判定比转HSV省一个色彩空间转换模块wire blue_ok (b_d r_d 8d60) (b_d g_d 8d40);逻辑说明blue_ok拉高代表当前像素是蓝色候选。偏移量60和40是经验值光照偏暖的黄昏场景可以降到50和30晚上高压钠灯下要降到40和25。这个偏移量建议做成寄存器别写死。工程里常见的做法是对每行做计数一行内blue_ok拉高的像素数超过行宽35%记该行为候选行连续候选行数达到阈值比如120行则圈定纵向候选区间[y_top, y_bottom]。这个阈值120不能拍脑袋定。VGA分辨率下车牌纵向高度约5070像素但要留出抖动余量120行可以覆盖两倍车牌高度。如果你把摄像头斜着装在道闸侧面候选区间会更大阈值设置小了会漏检。横向定位用垂直投影在候选区间内对每列统计blue_ok或边缘像素数连续超过阈值的列区间就是车牌的左右边界。这套“行统计列投影”在传统FPGA车牌识别里是绝对主流因为不涉及复杂连通域算法状态机就能实现。4.2 字符分割垂直投影切字符汉字被切两半是常态锁定车牌区域后把区域内图像送进字符分割模块。字符分割的前提是字符区域是白色高亮像素、背景是黑色二值化后的效果。逐列统计白色像素数得到垂直投影曲线。连续的“有值列”构成一个字符块中间投影为0的空隙就是字符间隔。工程里典型的参数配置参数值说明char_min_width4像素小于此宽度的块视为噪点char_max_gap12像素两个字符块间隔大于此值才切开plate_char_num7标准蓝牌7个字符char_norm_w/h32×16归一化后的点阵尺寸这几个参数里最坑的是汉字。汉字“京”“沪”“苏”结构紧凑垂直投影往往在中间某列出现断层一个“苏”字可能被切成两个块。如果按纯投影切分字符计数会变成8个甚至9个。工程里的常见对策是“块合并”相邻两个块宽度都小于8像素、且间隔小于2像素时强制合并为一个字符。另一个对策是“字符数校正”切分完成后如果块数大于7按间隔从小到大依次合并直到7个为止。这套规则在标准蓝牌上准确率相当高但新能源绿牌8位字符会让它失效——如果你要支持绿牌得把plate_char_num改成8并且合并规则要改。切分完成后字符送到归一化模块用最邻近插值缩放到32×16点阵。缩放比例不是整数FPGA里用“累计误差”法实现每读一个原像素累加一个步进值溢出时输出一个目标像素。步进值 原尺寸×256 ÷ 目标尺寸全程整数运算综合出来就是几个加法器。4.3 模板匹配识别XNOR计数取最大得分字符识别用模板匹配这是FPGA上最省资源、时序最好收敛的做法。模板集分成三部分31个汉字省份简称、24个字母去掉I和O、10个数字共65个模板每个模板就是一张32×16的01点阵存在ROM里。匹配时用“与集计数”目标字符点阵与每个模板逐位做与运算结果为1的位数就是匹配得分得分最高的模板即为识别结果。// 匹配核心对每个模板累加得分 integer score_cnt; always (posedge clk) begin if (match_start) begin score_cnt 0; for (i 0; i 32*16; i i 1) begin if (char_buf[i] tmpl_rom[i]) score_cnt score_cnt 1; end end end逻辑说明char_buf是当前待识别字符的RAMtmpl_rom是当前模板的ROM。for循环在综合时会展开成512个两输入与门和一根加法树65个模板顺序匹配每个模板至少需要512拍一组7字符全识别完大约需要65×7×512≈23万拍在主时钟50MHz下约4.6ms一帧60fps的周期16.7ms完全来得及。如果要更快可以例化65个匹配单元并行算代价是BRAM资源暴涨。参数上有个经验模板的粗细直接影响识别率。字太细模板中1的像素少容易漏匹配太粗则铲平差异。我自己提取模板时会把标准车牌照片二值化后缩放32×16再人为腐蚀掉一圈毛刺让笔画粗细稳定在23像素。如果你下载的工程里模板是手画的识别率会很难看优先用真实车牌照片提取。5. 移植踩坑排查换开发板、换摄像头、换Vivado版本时最容易翻车的四件事5.1 现象工程打开后IP核显示红色或空白换Vivado版本打开工程最常见的报错是IP核状态变成红色感叹号或者原理图里IP实例变成了空白框。原因IP核的xci文件版本和当前Vivado不兼容典型的是工程用2018.3建的你用2020.2打开IP没有自动迁移。解决在Flow Navigator里找到IP Catalog选中报错IP右键选择Upgrade IP让它重新生成如果升级失败直接删掉IP实例用RTL原语替代——像时钟PLL这类IP换成MMCME2_BASE原语手写例化反而更可控。我给个习惯拿到任何工程先Project Settings → IP → Repository锁定IP路径再重新生成别双击IP打开乱点。5.2 现象综合后时序违例红灯集中在Sobel和形态学模块WNS最差负余量为负时序报告中关键路径全指向Sobel的梯度计算或形态学的膨胀腐蚀。原因这两个模块是纯组合逻辑的“大肚子”3×3窗口拉出9个像素后乘法、加法、绝对值、比较一条链路下来逻辑深度往往超过15级25MHz下可能勉强过但主时钟改到50MHz就顶不住。解决在这条链路上插流水寄存器。Sobel的gx/gy计算出结果后打一拍mag计算后打一拍再进阈值比较形态学的3×3最大值计算拆成“先算三行各自最大值再算三列最大值”两级中间加寄存器。这样关键路径从15级降到5级WNS轻松转正。改完别忘了对应调整模块的“输出有效”信号流水线延迟多了2拍下游的帧同步计数器要跟着加2否则图像错位。5.3 现象板子上图像整体花掉、错位或者颜色明显偏紫偏绿原因排查顺序先看行场同步极性。OV5640的HREF和VSYNC默认极性是低有效采集模块里hsync_pol和vsync_pol两个参数必须和摄像头输出匹配极性反了图像会左右错位或上下滚动。再看DVP数据位顺序data[7:0]在RGB565模式下高字节和低字节的顺序、以及G分量在高低字节里的排布错一位整个画面的颜色就乱。最隐蔽的是“摄像头初始化时序”OV5640上电后要等至少20ms再发I2C配置配置完成后还要等5帧稳定输出再开始采集工程里cmos_cfg模块如果只跑一次初始化就立刻拉高完成标志经常抓到半帧花屏。解决在采集模块里加一个“稳定帧计数器”VSYNC上升沿连续数5次后才使能frame_valid。这条我每次移植摄像头都会检查血泪经验。5.4 现象串口回传中间帧丢数据一帧图像中间缺行调试时用UART把二值化帧发到PC打开BMP发现中间随机丢行。原因UART波特率太低一帧640×480的数据量是307200字节115200波特率下每秒只有约11520字节发一帧要26秒而FPGA侧按流水线速度把数据推给FIFOFIFO瞬间被打满后面的数据全丢。解决不要实时整帧发送改成“单帧触发 行消隐节流”。检测到VSYNC上升沿后把当前帧的二值化结果写入Block RAM写完后暂停流水线然后按UART实际速度每发一个字节等约86us从RAM里读出来发。另一种做法是在发送前降采样每4×4像素取1个代表点发160×120的缩略帧肉眼验证边缘和投影效果完全够用。我一般两个结合整帧存RAM用于精确定位缩略帧用于实时预览。5.5 现象换国产FPGA后工程编译不过很多国产FPGA在架构上是类Xilinx的但IP核命名、原语名称、引脚约束语法不完全一样。比如安路的TD软件里PLL IP叫EG_PLLXilinx的叫clk_wizSDRAM控制器IP更是完全两家东西。解决拿到工程先建一个“移植清单”把所有Xilinx IP列出来逐个对照替换。PLL和Block RAM这类基础IP国产工具基本都有对应替代DDR控制器最麻烦如果工程用了DDR缓存建议改成Block RAM方案VGA分辨率下整帧307KB用BRAM堆也能堆出来换来的是可移植性。国产FPGA的LUT资源普遍比同档Xilinx少综合前把LUT、FF、BRAM使用报告拉出来看超过60%就要考虑简化Sobel阈值判断逻辑或缩减模板ROM容量。6. 把调试效率提上去用ILA抓中间帧和串口转BMP脚本验证每一级输出图像处理管线的调试难点在于“中间结果看不见”。焊好板子、点亮屏幕发现识别不对到底是灰度没转对、边缘太碎、还是定位偏移我的做法是在预处理链路上挂两把“探针”——ILA在线逻辑分析仪和UART转BMP脚本。ILA负责抓时序细节UART负责抓整帧图像两者配合基本能把问题定位到具体模块。ILA例化时不要抓整帧VGA的PCLK是25MHzILA采样深度设1024只能抓约40行数据够看波形但不够看电影。我的习惯是抓三个探针vsync、sobel_out、blue_ok。触发条件设为VSYNC上升沿这样每次触发抓到的就是一幅图像开头40行的边缘和蓝色分布。从波形上能直接读出Sobel输出是否在行有效期间稳定拉高、蓝色候选像素是否在车牌纵向区间内集中出现。如果blue_ok在整幅画面里密密麻麻说明颜色偏移量设太松如果几乎不拉高说明偏置太紧或灰度化权重错了。这三个探针配合触发位置设置三分钟能定位掉80%的预处理问题。ila_0 u_ila ( .clk(pclk), // 采样时钟像素时钟 .probe0(vsync), // 帧同步触发条件 .probe1(sobel_out), // 边缘二值输出 .probe2(blue_ok) // 蓝色候选像素 );逻辑说明触发条件在Vivado的Hardware Manager里配置选择probe0 上升沿即可。注意probe1和probe2都是1bit信号如果想同时看8bit灰度值可以把探针位宽改成8但ILA存储深度会按位宽倍增1024×8bit没问题别把多个字节拼在一个probe上。整帧验证用串口回传更实用。工程里有个uart_debug模块能把当前帧二值化数据按行打包协议自己定我常用的是每行发一个0xAA帧头、2字节行号、640bit像素80字节、1字节行尾。PC端收到的HEX文件用Python脚本还原BMPimport struct raw open(frame.hex, rb).read() W, H 640, 480 # 解析行头0xAA 行号2字节 80字节像素 0x55 img bytearray() for y in range(H): assert raw[y*83] 0xAA row_bits raw[y*833:y*8383] for byte in row_bits: for bit in range(8): val 255 if (byte (7-bit)) 1 else 0 img bytes([val, val, val]) # BMP头 header bytearray(bBM) header struct.pack(IHHII, 54 len(img), 0, 0, 54, 40) header struct.pack(IiiHHIIiiII, W, H, 1, 8, 0, 0, 0, 0, 0, 0) open(frame.bmp, wb).write(header img)逻辑说明脚本核心是位解析——每行80字节对应640像素每字节8个像素高位在前。BMP头直接手写省去opencv依赖。跑通后从frame.bmp能直观看到中值滤波是否生效、Sobel边缘是否连续、车牌区域是否被正确置白。我拿这套方法调过好几次光照阈值每次都比单纯看ILA波形快得多。从那以后我每次拿到新的图像处理工程都会先强制走一遍“ILA抓边缘 → UART回传二值图 → 对照模板看匹配分数”的三步流程确认每一级输出都符合预期才继续往下调绝不直接跳到最后一级看识别结果。这能让排查范围缩小到一个模块而不是在整条流水线上瞎猜。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站