☰
ZYNQ7020上的FPGA手写数字识别:从摄像头到HDMI的完整链路
2026/10/7 9:07:11 网站建设 项目流程

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存储权重、图像帧缓存、字模ROM
DSP 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 // 设置输出格式为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 endmodule

16个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显示,再逐级加入灰度化、缩放、二值化,最后接入神经网络推理。每一步都验证无误后再叠加下一步,排查起来会轻松很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询