FPGA低延迟车牌识别:自研脉动卷积阵列全流程实战

发布时间:2026/9/7 4:45:29
FPGA低延迟车牌识别:自研脉动卷积阵列全流程实战 做这个项目的起因其实和“炫技”没关系。我的一位朋友做智慧停车道闸车牌识别盒子用的是ARM方案端到端延迟在一两百毫秒车多的时候总让人觉得抬杆慢半拍。他问我能不能把从摄像头出帧到车牌号上报这一整条链路压到十几毫秒以内于是就有了这个项目——用纯Verilog写一个脉动卷积阵列加速器完成车牌检测与识别并且同时部署到Xilinx和紫光同创两套FPGA平台。老实说这个需求放在GPU上很容易实现但客户现场不允许上机箱功耗和成本都卡得很死单片FPGA是现实选择。脉动阵列这个概念在学术界提了几十年但真正落到中小规模FPGA上做车牌识别很多资料写得云里雾里。这次我把从RTL设计到两套平台移植、从仿真到板上延迟实测的完整记录整理出来希望能帮到正在做FPGA图像处理或打算尝试脉动阵列加速器的朋友。无论你是刚开始接触Verilog还是已经在用Vivado/PDS做项目这套方案都可以直接参考改用到自己的场景里。1. 整体设计与方案选型为什么要自己写脉动阵列1.1 先算一笔账延迟到底浪费在哪做低延迟系统第一步不是写代码而是把预算算清楚。以常见的停车场道闸场景为例摄像头输出1080p30从车辆进入识别区到道闸抬起用户能接受的极限大概是300毫秒。ARM方案里单是读取一帧1920x1080的YUV图像并通过CPU搬进内存就要消耗不少带宽接着跑图像预处理、车牌检测网络、字符识别网络每一步都有等待。如果把帧率、内存拷贝、CPU调度、算法计算时间全部叠加一两百毫秒是常态。FPGA的思路完全不同传感器数据按行输出我按行处理。图像从进入FPGA到输出车牌结果走的是一条深度流水线不存在“先存完整帧再算”这种结构。唯一需要的额外开销是处理完第一行后等两个行缓存以及在检测到车牌区域时缓存一块很小的局部图像。只要把这条流水线的每一级延迟控制在固定拍数端到端延迟就是一个可以精确计算的常数。在项目启动前我给自己定了几个硬指标端到端延迟小于20毫秒按1080p30输入计算整板功耗不超过10瓦不使用DDR3/DDR4全部数据缓存用BRAM解决。这三条限制基本决定了方案走向必须用专用加速器不能靠软核CPU必须流式处理不能整帧缓存网络结构必须小量化必须到位。1.2 脉动阵列把“访存”变成“流动”脉动阵列Systolic Array的核心思想是把计算密集操作中的数据搬移尽量从寄存器堆和缓存里“解放”出来让数据在PEProcessing Element处理单元阵列中像血液在血管里一样按规则流动。以矩阵乘法为例传统做法是反复从内存取A矩阵元素和B矩阵元素乘完再写回而脉动阵列让数据从阵列边界输入每个PE只和相邻PE交换数据数据每拍只移动一步计算和搬运天然重叠。这个特性对FPGA极其友好因为FPGA上的BRAM带宽有限但LUT和寄存器之间的布线带宽很高。脉动阵列把“访存密集型”运算改造成“数据流驱动型”运算每个PE内部只存少量权重输入数据流经所有PE所有乘累加同时进行。理论上说阵列里PE的数量就是单拍能完成的乘加次数。我这次用的是经典的权重驻留Weight Stationary方式卷积核权重先预装载到每个PE的寄存器组中特征图按扫描顺序从阵列左端流入逐级通过寄存器链向右传递。每个PE在收到一个输入像素后立即和自己的权重做一次乘累加并把输入像素继续传给下一个PE。这样做的好处是同一份输入数据被所有PE复用外部访存压力只剩下“每拍送一个像素进来”。1.3 双平台部署Xilinx与紫光同创的选择选Xilinx是因为Vivado的生态最成熟IP核、调试工具、文档都是行业标准适合前期快速验证算法和RTL。选紫光同创则是为了国产化替代落地不少项目对国产平台有硬性要求而且紫光同创的Logos系列在中小规模逻辑资源上性价比不错。两套平台的RTL设计我用的是同一套源码没有做厂商绑定。关键存储资源RAM、时钟资源PLL/MMCM、DSP乘法器资源都通过统一的封装模块例化通过宏定义和文件列表切换平台。这样在Vivado上验证过的逻辑搬到紫光同创PDS环境后只需要替换底层原语封装和约束文件。这个做法在后面部署时省了非常多事。2. 脉动卷积阵列核心模块实现2.1 PE单元整个阵列的最小齿轮PE单元是整个加速器的基础它做的事很简单接收一个输入特征值和一个权重做一次乘法把结果累加到一个部分和寄存器里同时把输入特征值继续传给下一个PE。但就是这几十行Verilog决定了整个阵列的最高频率和资源消耗。我在PE里做了三点设计。第一乘法和加法之间插入一级流水寄存器避免乘法器输出直接进加法器导致关键路径过长。第二累加器位宽做到24位防止中间结果溢出。第三权重输入和特征输入都有独立的使能信号加载权重阶段和计算阶段可以复用同一套数据链路不需要额外增加端口。module pe #( parameter DATA_W 8, parameter ACC_W 24 )( input wire clk, input wire rst_n, input wire load_w, input wire valid, input wire [DATA_W-1:0] wgt_in, input wire [DATA_W-1:0] feat_in, output reg [DATA_W-1:0] feat_out, output wire [ACC_W-1:0] acc_out ); reg [DATA_W-1:0] wreg; reg [DATA_W-1:0] feat_d; reg [ACC_W-1:0] acc; reg [ACC_W-1:0] acc_d; wire [DATA_WACC_W-1:0] mul_result; wire [ACC_W-1:0] mul_sat; assign mul_result $signed(wreg) * $signed(feat_d); assign mul_sat (mul_result[ACC_W-1] 1b1) ? {ACC_W{1b1}} : mul_result[ACC_W-1:0]; always (posedge clk or negedge rst_n) begin if (!rst_n) begin wreg {DATA_W{1b0}}; feat_d {DATA_W{1b0}}; acc {ACC_W{1b0}}; acc_d {ACC_W{1b0}}; end else begin feat_d feat_in; feat_out feat_d; if (load_w) begin wreg wgt_in; end if (valid) begin acc_d acc mul_sat; acc acc_d; end else begin acc_d acc; end end end assign acc_out acc_d; endmodule这段代码的关键点在于valid信号。一个卷积窗口需要连续送入9C个输入元素C为输入通道数每收到一个元素就累加一次。当第9C个元素进入后PE内部的累加值才是这个输出通道的真实卷积结果。所以在顶层控制中我会用一个计数器判断当前窗口是否收满收满后把acc_out同时打入输出FIFO并把累加器清零开始下一个窗口的计算。乘法结果只取了低24位并做了正饱和处理这是量化策略的一部分。车牌识别网络输入是8位灰度权重量化到8位卷积中间结果用24位累加最后统一截断到8位输出。实测下来这种精度配置和浮点模型相比识别精度损失在1%以内。2.2 卷积滑动窗口与数据流调度PE本身只负责乘累加真正让阵列跑起来的是数据调度模块。对一个3x3卷积来说最基本的任务是把连续三行图像中对应的3x3像素窗口组织成一条9拍的数据流。由于像素是逐行扫描进来的我先用两个行缓存Line Buffer把当前像素和它的前两行数据对齐然后通过三级移位寄存器把同一列的三个像素变成同时有效的3x3窗口。行缓存我用的是厂家RAM原语宽度与输入数据位宽一致深度等于图像一行像素数。在1080p分辨率下一个行缓存需要大约1920字节两个就是3840字节在Artix-7 XC7A35T上只占不到一块18K BRAM非常宽裕。窗口数据准备好之后要把它映射成脉动阵列的输入序列。对输入通道数为C的卷积层一个3x3窗口实际包含9C个元素。调度模块按照“行优先、通道优先”的顺序每拍输出一个元素进入PE链。也就是说窗口内第一个通道的9个元素先进入接着第二个通道的9个元素直到9C个元素全部送入然后切换下一个窗口位置。这个过程用Verilog描述时核心是一个计数器加一个状态机。计数器需要同时跟踪三个尺度当前窗口内的元素序号0到9C-1、当前像素的行位置和列位置以及当前是否处于行尾/行首的边界区域。边界区域需要跳过卷积计算或将边界像素填补为0。我用了一个localparam定义窗口状态配合三个reg计数器逻辑控制在150行以内避免了复杂的嵌套判断。2.3 存储与带宽优化让加速器吃饱而不是饿死脉动阵列最怕数据供不上PE一旦空转延迟就会成倍增加。图像数据是逐像素流式进入的这部分天然饱合但卷积层的中间特征图就不是线性的了。尤其到了网络深层输入通道数增加如果还按单像素流式进入一个输出像素要等很长时间才能凑齐所有通道的部分和。我的做法是在每个卷积层输出端加一个小型特征图缓存深度等于输出特征图一行像素数宽度等于输出通道数。每处理完一个窗口位置就把所有输出通道的结果打包成一组数据写入缓存下一次卷积计算时从缓存读出的是“同一位置、所有通道”的特征值直接作为下一层窗口的输入。这样既解决了通道对齐问题又天然实现了层间流水。PLD里层间流转也借助了一个非常朴素但有效的技巧对中间特征图做8位量化截断后通路宽度就锁定在64位左右一行数据可以同时搬运8个通道显著减少了搬移拍数。这个思路和CPU里的cache行扩展异曲同工核心都是“一次搬多点少搬几次”。3. 车牌检测识别网络的FPGA化部署3.1 网络结构与定点量化车牌检测识别一体化的算法链路我拆成了两段第一段是给出车牌候选框的轻量检测子网络第二段是识别7位车牌字符的分类子网络。两段共享同一个脉动阵列硬件只是运行时加载的权重不同所以FPGA上不需要准备两套加速器。检测网络设计得比较小输入128x96灰度图经过三个3x3卷积层输出通道分别是16、32、32层间穿插池化最后用一个1x1卷积输出检测结果。识别网络同样很小输入是检测截取并缩放到64x24的车牌灰度图经过两个卷积层加一个全连接层输出7组字符分类结果每组包含38类汉字10类、数字10类、字母18类实际工程按项目车牌规则调整。网络在训练时是浮点的落到FPGA必须量化。我采用常见的对称均匀量化方式将权重和激活值都量化到int8。量化参数的确定方法是用一小批验证集统计每层激活的数值范围取绝对值最大值作为量化尺度权重则按层统计。推理公式简化为q_out saturate(q_weight * q_feat q_bias) shift这里的shift每层不同计算时直接用Verilog的右移实现比浮点换算节省大量资源。3.2 检测子网络与“简化NMS”脉动阵列完成卷积计算后检测网络会输出每个网格坐标对应的置信度以及车牌框的中心点偏移和宽高。严格意义上的NMS非极大值抑制需要排序、IOU计算和循环抑制在FPGA上用状态机实现代价高而且延迟不可控。我这里做了一个工程简化只保留置信度大于阈值的候选框然后按照“优先取置信度最高”的规则每行只保留一个框最后再按中心距离做一次去重。这个简化在道闸场景完全够用因为车牌在画面中的尺度和位置变化有限不可能出现大量互相重叠的候选框。实测下来简化NMS的处理延迟比窗口数据流短很多几乎可以在一个行扫描周期内完成。3.3 识别子网络直接输出车牌字符识别网络在检测框确定后启动。因为车牌属于明显带左右结构的固定区域我直接对检测到的车牌区域做矩形裁剪缩放到64x24然后送入识别网络。识别网络输出7组softmax结果每组取最大值索引映射成ASCII码或自定义字符编码通过UART发送给上位机或直接驱动道闸控制器。为了减少全连接层的计算量我在全连接之前加了全局平均池化将特征图从多通道压缩到一维。这步操作在FPGA上实现非常简单本质上就是把每张特征图的所有像素累加起来再除总像素数。除法用移位近似替代例如64x24的特征图总像素数为1536近似除以2048然后做一次修正误差小于1%对分类结果没有影响。4. Xilinx与紫光同创双平台部署实战4.1 Vivado下的工程搭建与调试Xilinx平台我选用Artix-7 XC7A35T目标频率100MHz。工程结构上没有用Block Design而是纯RTL方式组织代码顶层例化了三个部分图像采集与预处理、脉动卷积阵列及控制状态机、字符识别与通信输出。图像接口用的是OV5640摄像头通过SPI配置寄存器输出YUV422数据流在FPGA内部取Y分量作为灰度图。Vivado工程里我把脉动阵列单独放在一个子模块目录方便之后移植。时序约束方面除了主时钟还针对像素时钟域和系统时钟域之间的异步FIFO做了时序例外。调试时用ILAIntegrated Logic Analyzer抓取阵列输入输出信号。不过ILA会占用额外BRAM抓取深度不要设太高我一般设2048深度用来观察几个关键窗口的计算结果就够用。4.2 移植到紫光同创PDS的关键动作紫光同创用的是PDS开发环境逻辑资源和Xilinx类似但底层原语命名和配置方式有差异。我移植时遇到的第一件事是RAM原语不同Xilinx的Block Memory Generator生成的RAM在紫光平台上要替换成Simple Dual Port RAM或True Dual Port RAM。如果像我一开始那样在代码里直接用厂商原语例化移植就得改代码所以我前面说统一封装模块的原因就在这。PLL的配置也要改。Xilinx用Clocking Wizard紫光用PLL IP两者的锁定信号时序稍有差异。我的做法是封装一个clk_gen模块内部用宏切换厂商原语实例化对外统一输出clk_sys和pll_locked。复位逻辑统一使用“异步复位、同步释放”并且依赖pll_locked来门控复位避免FPGA配置完成后时钟不稳定导致状态机跑飞。DSP资源方面Xilinx的DSP48E1有预加器、流水线寄存器等丰富配置紫光同创的DSP单元功能也能完成乘法累加但流水级数如果不匹配时序就会变差。我的解决方法是把乘法器从PE里抽出来在综合属性里手动指定使用DSP单元并统一设成两级流水。这样两套平台都能在100MHz下收敛。4.3 两套平台资源与性能对比项目最终在两套平台都跑通了资源消耗和性能对比如下项目Xilinx Artix-7 XC7A35T紫光同创 Logos-2 PGL22GLUT82139037Flip-Flop1078611420DSP乘法器1616Block RAM18K x 1218K x 14最高工作频率126MHz108MHz端到端延迟100MHz约 9.8ms约 10.2ms整板功耗约 5.6W约 6.1W紫光平台LUT和Flip-Flop比Xilinx多出约10%主要原因是厂家综合工具对通用逻辑的映射效率略低以及部分BRAM输出没有寄存器选项导致需要额外用FF打拍。端到端延迟差异很小因为整个系统受限于行扫描时间计算路径并不是瓶颈。5. 从仿真到板上实测验证与问题排查5.1 仿真环境与testbench思路验证阶段我用的仿真工具是ModelSim配合Vivado仿真库。testbench主要干三件事初始化脉动阵列权重、灌入测试图像、比对输出结果。权重初始化我把Python端量化好的权重数组通过$readmemh读入。图像输入也采用同样方式先把一张实际拍摄的车牌图预处理成128x96灰度数组存成文本文件仿真时按像素时钟逐点送入。输出比对是自动化的仿真结果导出后与Python脚本用同样的量化定点方式计算的结果比对误差不超过1个LSB则认为通过。这个自动化比对脚本帮了大忙因为在调试初期卷积结果偶发出现个别像素错误肉眼根本看不出来只有逐点比对才能发现是窗口对齐问题还是PE累加时序问题。5.2 板上延迟测量与结果板上延迟我通过一个计数器精确测量。摄像头输出帧同步信号作为起点车牌识别完成信号作为终点用系统时钟计数。实测1080p30输入时从帧同步开始到UART发出完整车牌字符端到端延迟约10毫秒其中UART按115200波特率发送7字节字符消耗约0.6毫秒真正计算部分不到9.5毫秒。这个延迟比ARM方案少了近一个数量级朋友测试后很惊讶车辆在画面里刚出现车牌区域道闸就抬起来了基本感受不到等待。而且中间没有DDR搬运功耗只有5-6瓦完全是嵌入式设备可以接受的量级。5.3 常见问题速查表现象可能原因排查方法卷积输出整体偏大或偏小量化尺度不对或偏置未对齐用浮点模型对比单层输出检查量化参数生成脚本输出图像出现规律的错位行缓存深度或读写时序不一致打印行缓存读写地址核对行同步信号两套平台同一个测试向量结果不同BRAM输出寄存器选项或复位时序差异检查厂商RAM配置统一添加输出寄存器频率上不去、时序违例乘法器没有打拍直接连加法器在PE的乘法输出和加法器之间插入一级流水寄存器UART输出乱码字符索引和编码表没对齐打印原始argmax索引和模拟输出比对5.4 几个值得保留的调试技巧调试中我发现一个很实用的技巧在RTL里给卷积窗口加一个“旁路测试模式”。通过寄存器使能可以让输入像素不经过脉动阵列直接写到输出FIFO里。这样在调试初期可以快速确认行缓存、数据链路和输出通路是否正常等链路通了再打开脉动阵列计算。这个旁路开关几乎不占资源关键时刻能帮你把问题快速切成“数据通路问题”还是“计算问题”。另一个心得是对PE阵列的每个PE输出我都引出了一个可选的调试总线通过一个多路选择器在测试模式下把指定PE的累加值送到ILA观测。这样在定位某个输出通道错误时不用挨个信号去扒波形改一个寄存器值就能切换观察对象。最后再分享一个踩过的坑摄像头刚上电时PCLK可能不稳定如果直接用PCLK驱动行缓存写入很容易出现首帧错位的现象。我在摄像头初始化完成后连续丢弃前30帧并在帧同步有效后再开始写入行缓存。这个“丢帧热身”操作虽然简单但大大降低了现场调试的偶发故障率。