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 RAM | 4.75Mb | 存储权重、图像帧缓存、字模ROM |
| DSP Slice | 220个 | 乘累加运算的关键硬件单元 |
| 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 // 设置输出格式为RGB565,QVGA(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 <= 1'b1; 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分辨率320x240,RGB565每像素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 * 8'd77 + g_data * 8'd150 + b_data * 8'd29) >> 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码查表获取每一个字符的像素模式。设计时我生成了一个depth=128、width=64的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,这个量完全扛得住。
另一条路是把数字识别换成其他任务。我后来用同样的OV7725+HDMI链路,把输出层改成4分类,做了一个剪刀石头布的手势识别小项目。除了预处理阶段需要重新调整目标定位逻辑,推理加速器的代码基本没动。这说明这套硬件架构的通用性比我想象中要好得多。
如果你也想在这个方向上做尝试,我的建议是先别急着追求复杂的算法,把链路调通是第一优先级。先实现摄像头采集、HDMI显示,再逐级加入灰度化、缩放、二值化,最后接入神经网络推理。每一步都验证无误后再叠加下一步,排查起来会轻松很多。