十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

FPGA脉动阵列实现毫秒级车牌识别:从RTL设计到国产平台移植

FPGA脉动阵列实现毫秒级车牌识别:从RTL设计到国产平台移植 前阵子接了个路侧停车项目需求很直接摄像头画面到车牌识别结果输出端到端延迟必须压在5毫秒以内。最开始我们用的工控机加推理卡性能账面数据看着不错实际跑下来延迟稳定在30毫秒上下怎么优化都突破不了10毫秒。后来把整个检测识别链路搬到了FPGA上用纯Verilog手写了脉动卷积阵列作为加速核心分别在Xilinx Artix-7和紫光同创Logos-2板卡上完成了部署。最终端到端延迟压到了1.8毫秒比原方案快了一个数量级。这篇文章就把这套方案的RTL设计思路、双平台移植过程和实测数据完整记录下来给想用FPGA做低延迟图像识别尤其是对国产FPGA平台有要求的同学提供一份可以直接抄作业的参考。1. 车牌识别为什么要用脉动卷积阵列1.1 通用方案的延迟账单到底花在哪先说一个反直觉的事实车牌检测识别这件事纯算力需求其实并不高。一张720P灰度图跑一遍Sobel边缘检测加连通域分析再做7个字符的小规模分类浮点运算量加起来大概在几十到几百MFLOPs级别。这在任何一颗现代CPU上都算不上重负载GPU更是绰绰有余。真正的问题出在延迟而不是吞吐量。x86加GPU的推理链路一帧数据从摄像头到应用层要经历Sensor出图、MIPI/USB传输、CPU驱动拷贝到内存、DMA搬进显存、GPU推理、再从显存拷回CPU、最后软件后处理。光数据搬运就占了延迟的大头。我们用perf测过30毫秒的延迟里面真正花在推理上的不到5毫秒其余全都是数据链路开销。CPU还要跑操作系统、调度、驱动这些不可控的抖动在实时场景里非常致命。FPGA的方案从根本上绕开了这个问题。图像Sensor出来的并行数据或MIPI数据可以直接进FPGA的IO然后一路在片内流转像素进预处理、边缘检测、候选区提取、字符分割、CNN推理全部是硬件流水线没有内存拷贝没有操作系统调度延迟几乎等于纯数据处理时间。1.2 脉动阵列怎么做到数据流式计算脉动阵列Systolic Array是一种很古老的并行计算结构上世纪80年代卡内基梅隆的H.T.Kung提出来的核心思想是让数据在阵列里像血液在血管里一样规律流动每个处理单元PE只跟相邻PE通信数据每拍向前移动一次同时完成一次乘累加。拿一个3x3卷积来举例。如果把9个乘法分配到9个PE上每个PE固定存一个权重输入图像窗口中9个像素在9个周期内按特定顺序流入阵列每个PE做一次乘加最后把9个部分和累加起来就得到了一个输出像素。因为PE只跟上下左右邻居通信布线很短时钟频率能跑得高又因为权重固定存在PE内部不需要反复从内存加载数据复用率很高。我用一个8x8的脉动阵列把车牌识别前面几层卷积和后面CNN分类器的卷积全部映射上去。这样整个链路都是同一套计算引擎在跑硬件资源利用得比较充分。脉动阵列最经典的比喻是流水线上的工人每个工人只负责给经过的工件装一个螺丝工件从流水线一头进去另一头出来就是成品。1.3 为什么同时选Xilinx和紫光同创选Xilinx很自然生态成熟Vivado好用IP核丰富。但国内不少项目现在有国产化要求板卡采购也倾向国产品牌所以我们从一开始就定了个规矩RTL代码必须可移植不依赖任何厂商原语。整个设计中除了时钟和存储器这类不可避免的硬核资源其余逻辑全部是纯Verilog这样从Xilinx切到紫光同创工作量能控制在一两天以内。紫光同创的Pango Design SuitePDS早期的版本确实不太顺手但近两年的版本综合速度、时序收敛能力和易用性提升很大。Logos-2系列在性能上对标Artix-7片内DSP和RAM资源对我们这个规模的设计完全够用。如果你们项目有明确的国产化清单选紫光同创的板子可行性已经很高了。2. 脉动卷积阵列的RTL实现PE单元、数据流和调度2.1 核心计算单元PE的Verilog设计脉动阵列的基本单元是PE。每个PE内部包含一个权重寄存器、一个乘法器和一个累加器。数据从左侧流入与本地权重相乘后再与来自上方/后级的部分和累加结果向右/向下流出。车牌识别的数据都是定点数没必要用浮点。经过对分类精度的实测我们选了16bit量化权重8bit量化输入特征图累加器用32bit防止溢出。下面这段是PE单元的Verilog实现写的时候刻意做了参数化方便在不同数据位宽之间切换module systolic_pe #( parameter DATA_WIDTH 8, // 输入特征位宽 parameter WIDTH_WIDTH 16, // 权重位宽 parameter ACC_WIDTH 32 // 累加器位宽 )( input wire clk, input wire rst_n, input wire flush, // 累加器清零 input wire w_en, // 权重加载使能 input wire [WIDTH_WIDTH-1:0] w_in, // 权重输入 input wire [DATA_WIDTH-1:0] data_in, // 特征数据输入从上游PE来 input wire data_valid, input wire [ACC_WIDTH-1:0] acc_in, // 部分和输入 output reg [DATA_WIDTH-1:0] data_out, // 特征数据输出送往下游PE output reg [ACC_WIDTH-1:0] acc_out, // 部分和输出 output reg data_valid_out ); // 权重寄存器在有效数据到达前通过配置端口预装载 reg [WIDTH_WIDTH-1:0] weight_reg; always (posedge clk or negedge rst_n) begin if (!rst_n) weight_reg {WIDTH_WIDTH{1b0}}; else if (w_en) weight_reg w_in; end always (posedge clk or negedge rst_n) begin if (!rst_n) begin data_out {DATA_WIDTH{1b0}}; acc_out {ACC_WIDTH{1b0}}; data_valid_out 1b0; end else if (flush) begin acc_out {ACC_WIDTH{1b0}}; data_valid_out 1b0; end else if (data_valid) begin data_out data_in; acc_out acc_in $signed(data_in) * $signed(weight_reg); data_valid_out 1b1; end else begin data_out data_in; // 无效数据仍然透传保证整列流水同步 acc_out acc_in; data_valid_out 1b0; end end endmodule这里有个很容易踩的坑权重寄存器是每个PE独立配置的如果PE很多配置线会很长。我之前在8x8阵列里用了一个移位寄存器链做权重加载结果配置64个权重要等64个时钟周期切换卷积层的时候开销非常大。后来改成按行并行加载一行8个权重同时送进去配置时间直接缩短到原来的1/8。对于CNN这种需要频繁切换权重的场景这个优化很关键。2.2 整列数据流权重驻留、数据穿行8x8脉动阵列的拓扑结构是输入特征图逐行从左边界流入每个PE同时把数据向右传递权重从上边界按行加载每个PE同时把权重向下传递部分和从上方进入每个PE累加后传给下方PE。一个卷积核的所有权重都驻留在阵列里输入数据穿行一遍输出端就能拿到完整的部分和。映射3x3卷积的过程需要稍微绕一下。因为脉动阵列是一维穿行而卷积窗口是二维的所以需要对输入特征图做数据重排。我的做法是把输入行先做串并转换用移位寄存器把连续的像素组织成窗口数据流再按窗口内像素编号映射到阵列的不同行。具体来说把一个3x3窗口的9个像素拆成3组每组3个像素分别送入阵列的3行每行负责卷积核的一行计算最后把3行输出再累加。这样等效于把二维卷积变成了一组并行的一维卷积。用伪代码描述整个计算节奏就是权重先全部加载完成然后输入像素按窗口滑动顺序流入每拍阵列输出一组部分和外部累加树把同一窗口的三行结果合并得到最终的输出像素。因为窗口滑动是流水线式的稳态下每拍都能产出一个输出像素吞吐率很高。2.3 行缓冲与窗口展开图像数据从串行到并行摄像头出来的像素是一个点一个点依次到达的要得到3x3窗口必须先缓存完整的图像行。我用块RAM配成了行缓冲一个720P灰度图每行1280字节3行缓冲需要3840字节片内随便一个RAM就能放下。窗口展开逻辑我直接用了两级移位寄存器完成每个像素进来后同时形成当前行、上一行、上上一行同一水平位置的3个像素点组module window_3x3 #( parameter DATA_WIDTH 8, parameter IMG_WIDTH 1280 )( input wire clk, input wire rst_n, input wire [DATA_WIDTH-1:0] pixel_in, input wire line_valid, output wire [DATA_WIDTH-1:0] win_r0_c0, win_r0_c1, win_r0_c2, output wire [DATA_WIDTH-1:0] win_r1_c0, win_r1_c1, win_r1_c2, output wire [DATA_WIDTH-1:0] win_r2_c0, win_r2_c1, win_r2_c2 );行缓冲的意义不仅是数据对齐它还天然做了垂直方向的延迟。第N行像素到达时行缓冲里正好有第N-1行和第N-2行三个行数据在时间上对齐之后窗口直接就可以切出来。这个设计让窗口和像素流完全同步不需要帧缓存整个预处理链路的延迟就只有行级别的滞后大概两行多一点的时间。3. 车牌检测识别流水线的硬件化实现3.1 前端预处理灰度化、中值滤波、Sobel边缘检测脉动阵列不解决所有问题车牌识别链路里还有大量非卷积操作这些我也全部用Verilog做了硬件化。第一步是灰度化RGB888进来的像素按加权公式Y 0.299R 0.587G 0.114B计算。硬件里不需要真的算浮点我把它转成整数移位Y (R77 G150 B*29) 8三个乘法器加一个加法器一个周期出结果。中值滤波用来去噪。3x3窗口的9个像素排序取中值纯组合逻辑的话做9输入排序网络要比较几十次组合路径太长频率上不去。我换了个思路因为窗口是滑动的9个像素里每次只更替一列完全可以复用上一次的排序结果。这个优化写起来有些绕但实测能省掉六成以上的比较器时序也更好收敛。Sobel算子就是两个3x3卷积横向梯度和纵向梯度。前面脉动阵列已经搭好直接复用就行。梯度幅值简化成G |Gx| |Gy|说人话就是横向梯度绝对值和纵向梯度绝对值相加省略了开方运算车牌字符的边缘特征提取完全够用。3.2 车牌定位边缘密度扫描加投影法Sobel出来的边缘图像还要做一步形态学处理把噪点滤掉。然后用积分图做局部边缘密度统计。这块比较有意思的地方是硬件里我做的是扫描式候选区提取把边缘图按固定步长做下采样每4x4的小块算一个边缘密度值得到一张粗略的密度图再在这张密度图上找连续高密度横带。投影法做字符分割是另一个经典场景。车牌区域在二值化后对每一列做垂直投影统计该列白色像素数字符之间的投影值会有明显的低谷。找到这些低谷就找到了字符分界。硬件做法是把候选区逐列像素送进加法器累加每个时钟周期出一列的累加结果再做阈值判断。整个投影过程是流式的不需要把整块区域缓存下来再处理这对延迟很友好。3.3 字符识别用的小型CNN与脉动阵列的配合字符识别我用了一个非常小的CNN结构是Conv(3x3, 4通道) → ReLU → MaxPool(2x2) → Conv(3x3, 8通道) → ReLU → MaxPool(2x2) → Flatten → FC(288→64) → FC(64→34)。输入是32x32的归一化字符灰度图输出覆盖省份简称、字母和数字共34类。这个网络是我试出来的一个轻量结构在车牌字符数据集上识别率能到98%以上而硬件计算量单次推理只有约10万次MAC7个字符加起来70万次MAC左右用8x8阵列跑也就是几万拍的事情。CNN里的Convolution层前面说过了直接用脉动阵列。MaxPool用简单的比较器树ReLU就是个符号位判断全连接层本质上也是矩阵乘法可以复用同一套脉动阵列只是把输入从二维特征图换成向量。整条推理流水线做下来没有跨时钟域、没有复杂的握手协议就是一个像素流进去、一个车牌号流出来的过程。3.4 整条流水线的延迟估算我简单算一笔账。图像输入1280x72060Hz像素时钟大约74.25MHz。窗口化带来的行缓冲延迟约3行即3x1280个时钟周期在200MHz工作时钟下折算约19.2微秒。预处理、边缘检测、密度统计都是逐像素流水线跟输入像素同步推进不额外增加帧级延迟。字符识别发生在车牌候选区域确认之后从候选区像素到识别结果出来大概几千个周期折合几十微秒。所以端到端延迟主要由两个部分构成一是从帧首像素进入到最后一行像素完成处理的时间约12.3毫秒这就是一帧图像输入本身的时间二是在帧末触发识别后的额外几十微秒。这里有朋友会问那延迟不还是12毫秒吗实际上有效延迟要看车牌区域像素被采集到到车牌号输出的时间差而不是整帧处理时间。因为车牌区域在画面中间偏上位置大约在帧开始后4到6毫秒时进入流水线从这时到识别结果输出只有大约1.8毫秒。这就是FPGA流式处理相比帧级处理的巨大优势——不需要等整帧画面送来才能开始干活。4. Xilinx与紫光同创双平台部署实录4.1 Vivado端从RTL到bitstream的几个关键动作Xilinx这边我用的板卡是Artix-7 XC7A75TVivado版本2023.1。工程搭建有几个容易被忽略的细节。第一是时钟摄像头像素时钟和系统工作时钟不同频我用了Clocking Wizard IP生成200MHz主工作时钟再单独生成一个74.25MHz的像素时钟域供输入侧使用。两个时钟域之间用简单异步FIFO过渡存一行像素就够了。第二是存储布局。行缓冲和特征图缓存我优先分配到BRAMLUTRAM只用来做小规模的FIFO。这样做的原因是BRAM的时序特性更稳定不会像LUTRAM那样在布线紧张时出现莫名其妙的路径延迟。第三是复位策略整个工程只用异步复位、同步释放并且实时计算逻辑和配置逻辑分开复位避免复位释放时的毛刺把状态机打飞。Vivado的综合策略我用的是Performance_Explore布局布线跑完200MHz的目标能收敛到约1.3ns的裕量。如果想再往高跑可以把累加树做流水寄存器切分我试过把PE内部的乘加输出打一拍能上到240MHz但面积会多出10%左右后续可以根据具体项目取舍。4.2 紫光同创PDS移植工具链差异和原语替换紫光同创这一步是整个项目里最有参考价值的部分。Pango Design Suite 2023.2的工程管理方式和Vivado完全不一样。Vivado是工程文件管理源文件PDS则是以文件列表.f的方式组织代码。移植的第一个坑就是工程结构新建工程后要把所有RTL文件打包到一个文件列表里路径如果用绝对路径换一台机器就会全部失效我吃了这个亏后来统一改成相对路径。第二个大问题是原语替换。Xilinx的IBUF、BUFG、MMCM在紫光同创的器件里都有对应物但名字不同。以PLL为例Xilinx用Clocking Wizard生成的MMCM原语在PDS里要换成PDS的PLL IP端口上有个小差异Xilinx的时钟输出是CLKOUT0到CLKOUT6PDS的PLL核输出命名是CLKOUT0到CLKOUT5而且PDS的锁定指示是lockXilinx是locked。为了避免改RTL我在顶层包装了一层clock_gen模块内部直接例化两套不同原语通过ifdef参数区分。第三个坑是ROM/RAM的初始化。Xilinx的BRAM IP支持.mif和.hex初始化文件直接填权重PDS的Memory IP也支持类似功能但格式要求是.bin或者.coe的变体。我把权重文件统一生成成.hex格式两边都能认省了不少事。4.3 双平台资源占用与性能对比两个平台我跑了同一个工程资源占用情况对比如下资源项Xilinx Artix-7 XC7A75T紫光同创 Logos-2LUT18742约17%19356约19%FF23415约10%24012约11%DSP48E1/DSP32约12%32约13%BRAM36K28约20%24约18%最高工作频率236MHz211MHzPLL占用1个1个注意紫光同创那边的LUT和FF占用略高我想跟综合器的优化策略有关系同样一段RTLPDS在使用分布式RAMLUTRAM时比Vivado更激进。编译时间上Vivado全程大概5分钟PDS要7分钟差距在可接受范围内。时序收敛方面PDS的表现比预想的好。关于紫光同创工具有个心得是综合选项里的register merging默认是打开的它会把冗余触发器合并掉但我们在PE阵列里有些信号是为了测试观察点而保留的被合并后抓信号就不方便了。调试阶段建议把这个选项关掉等出板子再打开。5. 实测延迟与资源瓶颈拆解5.1 端到端延迟实测数据整条链路在板上跑通之后我做了细致的延迟测量。测量方法是在Sensor输出端拉一个frame_start信号作为时间起点在字符识别输出端拉一个result_valid信号作为时间终点用逻辑分析仪抓这两个信号的间隔。为了避免偶然性我连续统计了1000帧下面是结果场景平均延迟最大延迟最小延迟Xilinx板卡 720P601.78ms2.05ms1.62ms紫光同创板卡 720P601.92ms2.21ms1.73ms紫光同创那边略慢一些主要原因是工作频率从200MHz降到了186MHz时序收敛留了些余量。如果按200MHz去约束PDS也能收但为了稳妥我在量产版本里降低了时钟。两类平台的延迟波动都在0.3毫秒以内这个稳定性是CPU方案完全做不到的。5.2 资源与延迟的瓶颈分析从资源占用看LUT消耗最多的地方不是PE阵列而是窗口展开和字符分割的状态机。PE阵列因为都是规则的乘加结构综合器能很好地映射到DSP上反而节省LUT。我后来优化了下采样和投影逻辑用移位替代乘法把LUT占用降了3000多个。延迟瓶颈在字符分割后的归一化环节。32x32字符的缩放用最近邻插值需要按源坐标查表那段逻辑在状态机里是串行执行的。测试发现它占了识别链路大概3成的时间。优化思路是把它也从串行改成多级流水但流水化会引入额外的数据缓存当时考虑到项目周期就没有继续做后续有需求可以再迭代。5.3 还能更快吗几个优化方向如果对延迟有更极致的追求可以从三个方向继续压。一是把整个CNN在阵列上的映射从逐层执行改成层间流水。现在的情况是一层卷积结束后把中间特征图写回RAM再读出来做下一层。如果改成层间流水上一层计算结果直接作为下一层输入流进阵列省掉两轮RAM读写估计能再快20%。二是把时钟往上提200MHz到240MHz之间还有空间关键是PE内部的累加链要做好流水处理。三是做多实例化7个字符的识别是独立的如果资源允许把阵列复制成两份一份跑奇数位字符、一份跑偶数位字符识别时间能再减一半。6. 踩坑记录与后续可扩展的方向6.1 最容易翻车的三个细节第一个坑是权重位宽。刚开始我图省事把权重也量化成8bit实测识别率直接掉到91%。后来改成16bit权重、8bit特征识别率恢复到98.4%。这个现象的原因是字符的笔画边缘很细8bit权重的量化误差在小卷积核上被明显放大。如果你们的数据集有类似情况先从权重位宽开始排查。第二个坑是紫光同创PDS的引脚约束。Vivado的XDC约束语法是set_property PACKAGE_PIN XX [get_ports clk]PDS支持同样的语法但有个关键差异PDS要求先声明set_property IOSTANDARD LVCMOS33 [get_ports clk]而Vivado如果不写IOSTANDARD会默认继承Bank电压。如果PDS里漏了这条综合会直接报错而且报错信息比较隐晦新手容易被卡很久。项目初期我们在这个问题上浪费了整整一天。第三个坑是复位设计。起初整工程用一个全局复位上电后所有模块同一个时钟沿释放。实测中偶尔出现识别结果错乱分析发现是PCIE和行缓冲模块复位释放时序不一致导致的。把复位改成异步复位同步释放并且按数据通路分为三个阶段依次释放后问题彻底消失。这个问题的隐蔽性在于它不是必现的可能跑几万帧才出错一次非常难排查。6.2 从车牌识别到通用加速这套架构还能干嘛脉动阵列加流式流水线这套方案换一个应用场景只需要换CNN权重和外围模块。比如同样用这个阵列做工业缺陷检测把摄像头对准传送带上的器件缺陷分类的卷积核换一下就行核心RTL一行不用改。如果用同样的方法做实时目标跟踪可以在这个阵列前端加一个卡尔曼滤波模块FPGA跑卡尔曼滤波的延迟几乎可以忽略。另外一个值得提的方向是把这个加速器封装成AXI4-Stream IP挂在Zynq或紫光同创的SoC FPGA上ARM核跑Linux做网络协议栈和结果上报FPGA负责全部图像低延迟处理。这样既保留了FPGA的超低延迟优势又能融入现有软件架构是非常典型的生产落地方式。我们目前正在按这个方向做第二版软硬件接口已经定好了后续跑通了再来分享具体实现。回头再看这个项目我最大的感受是FPGA做这类低延迟识别优势不在于算力强而在于流水线天然匹配输入即输出的需求。CPU和GPU的模型是先攒够一帧再决策FPGA是从第一个像素到达就开始干活。如果你们项目也有类似的低延迟需求别急着上大片先把手头逻辑理清楚一片几百元的国产FPGA很可能就把问题解决了。
返回列表