多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

ZYNQ7020上的FPGA手写数字识别:从摄像头到HDMI的完整链路

ZYNQ7020上的FPGA手写数字识别:从摄像头到HDMI的完整链路 1. 为什么要在ZYNQ7020这板上做手写数字识别1.1 项目的由来与核心痛点拿到领航者ZYNQ7020这块开发板之后我最初的想法很简单摄像头采集画面直接在HDMI显示器上显示出来。但是这种裸的视频通路玩过一遍之后就没什么新鲜感了。真正让我决定做手写数字识别工程是因为想验证一个问题——FPGA这种硬件逻辑到底适不适合跑神经网络推理。很多人一提到MNIST手写数字识别第一反应就是Python、TensorFlow、GPU训练。但到了ZYNQ7020这种平台情况完全不一样。它虽然有双核ARM Cortex-A9但主频只有667MHz靠纯软件跑一个完整的CNN推理帧率会非常难看而PL端虽然主频一般只有100MHz到200MHz但它可以并行计算一拍时钟同时算几十个乘法加法是没有问题的。手写数字识别这种输入数据量小、网络规模可控的任务恰恰是FPGA加速的理想场景。我更想验证的其实是整条链路的工程问题OV7725摄像头采集到的RGB565图像怎么在FPGA里完成灰度化、缩放、二值化、归一化的全套预处理怎么设计一个不复杂的硬件推理单元把28x28的像素矩阵变成10个分类得分识别结果又如何叠加到HDMI输出画面上这些问题比单纯调一个AI模型要复杂得多也更有意思。1.2 领航者ZYNQ7020的资源盘点先说说这块板子。领航者ZYNQ7020使用的芯片型号是XC7Z020它内部是PS和PL两套系统资源参数对这个项目的作用PS侧双核ARM Cortex-A9 667MHz跑SCCB配置、识别判决、系统控制PL侧Artix-7架构85K逻辑单元图像处理流水线、加速推理Block RAM4.75Mb存储权重、图像帧缓存、字模ROMDSP Slice220个乘累加运算的关键硬件单元DDR3板载1GB视频帧缓存、神经网络参数暂存在做方案设计的时候我专门算了一笔DSP资源的账。如果只做全连接层网络输入层784个神经元隐藏层64个神经元输出层10个神经元单次推理的乘法次数大约是784×64 64×10 50816次。这个计算量在FPGA里如果用32个并行乘法器每个乘法器跑100MHz一次推理的时间大概是50816/(32×100M) ≈ 15.9微秒。再加上层间的激活函数、结果处理整体推理时间远小于1毫秒完全满足实时识别的要求。1.3 整体数据流设计这个项目的完整链路是这样设计的OV7725摄像头 → RGB565图像数据 → PL端灰度化 → 数字区域提取 → 缩放裁剪到28x28 → 二值化归一化 → 推理加速器 → 识别结果 → HDMI字符叠加显示这套链路里PL端几乎做了所有图像处理的重活PS端的ARM只负责三件事上电时通过SCCB接口配置OV7725的寄存器、在推理完成后接收得分向量做最终判决、控制系统的启动和复位流程。2. OV7725摄像头采集链路的搭建细节2.1 OV7725的关键参数配置OV7725是一颗很经典的CMOS图像传感器输出格式可以配置为RGB565、YUV422或者RAW Bayer分辨率最大支持640x480。对于手写数字识别来说640x480完全够用而且这个分辨率下OV7725能稳定跑30fps。通过SCCB总线其实就是简化的I2C去写寄存器需要注意几个关键的寄存器配置// OV7725 关键寄存器初始化配置 // 寄存器地址: 配置值 0x12: 0x80 // 软复位 0x12: 0x01 // 设置输出格式为RGB565QVGA(320x240) 0x40: 0x10 // RGB565输出模式 0x15: 0x02 // 输出尺寸设置 0x17: 0x22 // 水平同步信号设置 0x32: 0x00 // 行/场同步信号极性配置 0x2A: 0x00 // 曝光控制这里最容易出错的是0x12寄存器。我之前有次配置完输出图像整体偏绿还带着奇怪的条纹排查了很久才发现是0x12寄存器的高两位被写成了RGB444的格式。OV7725的RGB输出有RGB444、RGB555、RGB565好几种数据格式不对后面的颜色还原全是乱的。2.2 SCCB读写的时序坑SCCB协议和I2C非常相似但也有它自己的特点SCCB不支持连续读而且从设备地址是8位的OV7725的写地址是0x42读地址是0x43。很多从I2C转过来的开发者会习惯性地用7位地址0x21结果设备根本不应答。2.3 采集模块的时序控制OV7725输出的同步信号包括PCLK像素时钟、HSYNC行同步、VSYNC帧同步数据在PCLK上升沿有效。PL端采集模块的核心逻辑很简单// 图像采集模块核心逻辑 always (posedge pclk) begin if (vsync) begin // 帧同步信号有效开始采集新一帧 frame_valid 1b1; pixel_cnt 0; line_cnt 0; end else if (href) begin // 行有效信号为高时采集像素数据 if (pixel_cnt H_ACTIVE) begin pixel_data {camera_data[11:8], camera_data[3:0]}; pixel_cnt pixel_cnt 1; end end end这里要注意的一个细节是OV7725在RGB565模式下数据线D[9:2]上输出的是高8位D[1:0]和D[11:10]实际上没用到。很多新手会直接用camera_data的所有位去拼接RGB结果发现颜色完全不对。正确做法是把D[9:2]的高8位作为RGB的高字节再配合HREF信号控制采样时机。2.4 数据存入DDR3的带宽预算摄像头数据进来之后不能直接送到识别模块因为识别模块处理速度跟摄像头帧率不匹配。常用的做法是先写到DDR3做帧缓存。QVGA分辨率320x240RGB565每像素2字节一帧数据量是320×240×2 153600字节。按30fps算每秒数据量约4.5MB。这个带宽对于ZYNQ7020的DDR3来说非常轻松理论上DDR3能提供数GB/s的带宽所以完全不用担心带宽瓶颈。3. 图像预处理从彩色画面到28x28灰度矩阵3.1 灰度化的硬件实现RGB565转灰度标准公式是Y 0.299R 0.587G 0.114B在FPGA里做浮点运算很浪费资源通常的做法是用移位加法的近似方式实现// RGB565转灰度基于加法和移位的近似实现 // Y (R*77 G*150 B*29) 8 assign gray_data (r_data * 8d77 g_data * 8d150 b_data * 8d29) 8;这个近似方案的误差在1%以内人眼完全分辨不出来。对于后续的数字识别来说这种精度损失对最终结果的影响几乎可以忽略不计。3.2 数字区域的定位与提取这是整个预处理链路里我认为最影响识别效果的一步。手写数字一般只占画面中央的一小部分如果直接把整个320x240画面缩放成28x28送去识别背景的干扰会让准确率直线下降。在FPGA里做连通域分析不太划算因为资源占用太高。我最终采用的是简单但有效的策略先对整帧灰度图做边缘检测或阈值分割然后统计每个像素行、每个像素列的有效像素数量找到数字所在的水平和垂直投影范围。假设二值化之后数字区域内的像素值为1背景为0。对每一行求和得到行投影向量row_sum[0..H-1]对每一列求和得到列投影向量col_sum[0..W-1]。然后找到连续非零段中最长的区间就是数字区域的边界。这个算法在FPGA里实现起来很直接只需要一组加法器和比较器。3.3 双线性插值缩放得到数字区域之后需要把它归一化成28x28。这里我使用双线性插值效果比最近邻要好不少。最直接的做法是把缩放系数预先计算好存到ROM里然后在FPGA中查表完成坐标映射// 双线性插值计算目标像素值 // 源坐标 目标坐标 * 缩放比例 // src_x dst_x * (src_width / 28)实际实现时我用了定点数来表示缩放比例。比如数字区域宽度是120像素对应缩放系数是120/28≈4.2857用Q8格式表示就是4.2857×256≈1097。查表得到源坐标后取出相邻四个像素做加权平均。3.4 二值化与归一化的选择关于二值化我试过两种方案。第一种是固定阈值法阈值取128简单但受光照影响大第二种是自适应阈值法比如Otsu算法效果好但计算复杂度高。因为我的实验环境光照相对稳定最终用了固定阈值加手动校准的方式通过OV7725的曝光参数来补偿光照变化。在训练模型的时候MNIST数据集本身是黑底白字、白底黑字的都有。但我从摄像头采集到的数字是写在白纸上的黑字所以在送入网络前还需要做一次极性判断。具体做法是统计28x28图像中白色像素和黑色像素的比例如果白色像素占优说明是黑字白底需要取反。4. FPGA推理加速器的设计思路4.1 网络结构的选择考虑到DSP资源和BRAM资源的限制我没有用复杂的CNN而是选择了三层全连接网络输入层784个神经元对应28x28像素隐藏层64个神经元ReLU激活输出层10个神经元对应数字0-9这个网络结构在MNIST测试集上准确率大概96%左右对于摄像头实时场景来说够用了。我最初也尝试过在PL端实现一个LeNet-5的小型CNN但5x5卷积核的滑动窗口逻辑在硬件里调度起来很费劲BRAM也要多占用不少最后放弃了。4.2 权重的存储与组织网络训练是在电脑上用PyTorch完成的训练完之后把权重导出成coe文件Xilinx的初始化文件格式然后在FPGA里初始化ROM。权重总量784×64 64×10 50816个权重参数。如果用16bit定点数表示每个权重总容量约1MB。ZYNQ7020的BRAM只有4.75Mb约0.6MB单靠BRAM存不下所有权重。我采用的折中方案是将权重存储放入DDR3推理时通过AXI总线搬运到PL侧进行计算。为了避免频繁搬运我在推理前把权重全部预取到BRAM里一个batch一个batch地计算。好在这是全连接网络权重在层内是顺序存储的预取和计算可以很好地流水化。4.3 乘累加单元的并行化设计推理加速器的核心是乘累加单元。我设计了16个MAC单元并行工作每个MAC单元完成一个输入像素与对应权重的乘加运算。// MAC单元核心逻辑 module mac_unit( input wire [15:0] data_in, input wire [15:0] weight_in, output reg [31:0] acc_out ); always (posedge clk) begin acc_out acc_out data_in * weight_in; end endmodule16个MAC单元并行每个时钟周期完成16次乘加运算。完成第一层784×64的运算需要784×64/16 3136个时钟周期按100MHz计算只需约31微秒性能远高于ARM软核。4.4 推理结果的软硬件协作PL端完成最后一层计算后会得到一个长度为10的得分向量。PL端直接把向量写入一个自定义的AXI-Lite寄存器然后产生中断通知PS端。PS端的ARM收到中断后读取寄存器用简单的softmax或者直接取最大值的方式判断最终识别结果。我选择让PS做最终判决的原因是因为判决逻辑非常简单没必要占用PL资源而且这样方便后续扩展——比如连续多帧投票提高稳定性。5. HDMI显示通路与识别结果叠加5.1 领航者HDMI接口的实现方式领航者ZYNQ7020板上的HDMI接口是通过PL端的IO直接驱动HDMI差分信号实现的。相比使用外置HDMI编码芯片的方案这种直驱方式省成本但代价是必须在FPGA里自己生成TMDS编码和并串转换逻辑。HDMI视频时序的核心是Pixel Clock。对于720P分辨率像素时钟是74.25MHz对于1080P则是148.5MHz。受限于开发板的晶振和PLL配置我最终选了720P输出这样时序参数更标准PL端逻辑跑起来也更稳。5.2 720P时序参数720P的完整时序参数如下参数数值水平有效像素1280水平消隐前肩110水平同步脉冲40水平消隐后肩220垂直有效行数720垂直消隐前肩5垂直同步脉冲5垂直消隐后肩20这些参数必须在FPGA里通过行计数器hsync和场计数器vsync精确生成。我的实现方式是用两个always块分别产生行同步信号和场同步信号然后通过line_valid和frame_valid信号控制数据输出的时机。5.3 识别结果的字符叠加要在HDMI画面上显示识别出的数字需要在FPGA里放一个ASCII字模ROM。我用了两种方式做数字叠加一种是把识别结果显示在画面左上角用一个8x8的像素字模表示0到9的数字另一种是直接在摄像头画面中央画一个矩形框提示用户把数字写在框里。字模来自FPGA ROM通过字符的ASCII码查表获取每一个字符的像素模式。设计时我生成了一个depth128、width64的ROM存储了0-9共10个字符的字模。显示时先把识别结果转成ASCII码然后从ROM中读出对应字模逐行逐列写入HDMI数据通路。5.4 摄像头画面与UI的像素仲裁HDMI显示的内容有两部分摄像头实时画面和识别结果叠加层。五五开是不可能的因为摄像头画面是RGB565格式而汉字/数字叠加是单色的两者格式不同。我采用的方案是在HDMI输出端加一个简单的像素仲裁器// HDMI输出像素仲裁 always (posedge pixel_clk) begin if (ui_show_enable) begin // 显示UI叠加层 hdmi_data ui_color; end else begin // 显示摄像头画面 hdmi_data camera_frame_data; end end叠加层通过一个标志位ui_show_enable来控制。当扫描位置处于预定的识别框区域时输出UI颜色其他位置显示摄像头画面。这样设计的好处是逻辑简单、时序清晰。6. 实测过程与问题排查6.1 整体实测表现整套系统跑起来之后我做了几组测试。在光线充足的室内环境下用黑色记号笔在白纸上写数字摄像头对准后识别准确率大概在95%左右识别帧率能稳定在15fps左右。准确率没有达到训练集上的96%以上主要原因是摄像头拍摄的数字和MNIST的标准化格式还有差距。比如写的数字在纸上的位置不居中、字体粗细不一致、倾斜角度等。为了提升效果我在预处理里加了居中裁剪的逻辑效果有所改善。6.2 排查过的几个典型问题问题一图像显示出来是花屏的第一次把摄像头和HDMI打通的时候屏幕上全是雪花和彩色条纹完全看不出图像内容。排查逻辑是先排除HDMI侧的问题用测试pattern彩条输出到HDMI确认显示正常。然后再查摄像头侧最后定位到是PCLK采样的边沿问题。OV7725的PCLK上升沿数据稳定但我用了负沿采样导致数据错位。把采样沿改成正沿后画面正常了。问题二识别率忽高忽低这其实不是模型的问题而是预处理的问题。某一帧数字区域定位不准导致缩放后的数字偏移、甚至截断。后来我在二值化之后加了一步形态学膨胀操作让数字区域更紧凑显著提升了定位的稳定性。问题三推理结果偶尔出现乱码数字排查发现是DDR3读取权重时存在时序问题。我用的AXI接口时钟是150MHz但推理模块的时钟是100MHz两个时钟域之间没有做异步FIFO导致数据偶发丢失。在中间加了一级FIFO做时钟域转换后问题解决。问题四HDMI画面整体偏暗这个属于OV7725的曝光和自动增益配置问题。通过SCCB调整了AGC寄存器0x13把自动增益的上限放开了一些画面亮度提升明显。6.3 对系统性能的进一步分析说完这些坑我觉得这套系统的瓶颈主要在预处理环节而不是推理加速器。推理部分理论上可以在1毫秒内完成但图像预处理中的数字区域定位、双线性缩放等操作都是逐像素处理的整个链路跑下来大概需要20毫秒一帧对应大概50fps的处理能力。由于摄像头本身输出30fps所以实际最终瓶颈是摄像头帧率。如果想要进一步提升系统吞吐量可以考虑把预处理流水线做进一步优化比如将灰度化、二值化、投影统计合并到一个流水线级中减少SDRAM读写次数。7. 后续可以扩展的方向这个工程做完之后我觉得有两条路可以继续走。一条是把网络模型升级为CNN比如LeNet-5或MobileNet的简化版这样可以提升复杂场景下的识别精度。代价是需要重新设计卷积计算单元消耗的DSP数量会从16个上升到50个以上但ZYNQ7020有220个DSP这个量完全扛得住。另一条路是把数字识别换成其他任务。我后来用同样的OV7725HDMI链路把输出层改成4分类做了一个剪刀石头布的手势识别小项目。除了预处理阶段需要重新调整目标定位逻辑推理加速器的代码基本没动。这说明这套硬件架构的通用性比我想象中要好得多。如果你也想在这个方向上做尝试我的建议是先别急着追求复杂的算法把链路调通是第一优先级。先实现摄像头采集、HDMI显示再逐级加入灰度化、缩放、二值化最后接入神经网络推理。每一步都验证无误后再叠加下一步排查起来会轻松很多。
返回列表