ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FPGA纯Verilog硬解PNG:Deflate解压与逆滤波流水线实战

FPGA纯Verilog硬解PNG:Deflate解压与逆滤波流水线实战 1. 为什么要在FPGA里硬解PNG做图像处理的朋友大概率都遇到过这个场景上位机或者摄像头给过来一帧图像格式是PNG压缩比高、体积小但FPGA内部要拿它做后续的缩放、滤波、叠加就必须先把这层压缩壳子剥掉。常规做法是丢给ARM或者软核去解跑个zlib库慢慢啃但一旦分辨率上去、帧率要求上来CPU那点算力立刻见底延迟抖动也压不住。这时候纯硬件解码的价值就出来了——用FPGA的并行流水线把Deflate解压和滤波重建全部吃进逻辑里做到逐像素流式输出延迟可控、吞吐稳定。PNG这个格式全称是Portable Network Graphics它本身不是单纯的一种压缩而是预测滤波 Deflate无损压缩两层结构叠起来的。解码要干的事说白了就三步先把压缩数据流用Deflate算法还原成滤波后的原始字节再按行做逆滤波把预测值加回去最后按颜色类型灰度、RGB、调色板、带Alpha等拼成像素。听起来不复杂但真落到Verilog里每一步都有坑。这套工程我前后打磨了挺长时间最终整理出10套可综合、可上板的源码覆盖从纯仿真验证到实际视频通路的不同需求。适合谁看如果你已经写过UART、SPI这类基础模块想往图像处理方向进阶或者手头正好有个项目需要把PNG解码塞进FPGA那这篇内容应该能帮你少走不少弯路。下面我按设计思路、核心细节、实操流程、问题排查几个维度把整套东西拆开讲。2. 整体架构与方案选型拆解2.1 为什么不用现成IP而选择纯Verilog手写市面上确实有厂商提供的图像解码IP但PNG这块尤其是Deflate解压能直接买到的成熟硬核并不多而且授权费不便宜。更关键的是很多IP是黑盒你没法针对自己的数据流特点去优化。比如你的PNG是固定尺寸、固定颜色类型那完全可以把一些通用逻辑裁掉省出大量LUT和BRAM。纯Verilog手写的另一个好处是可移植性。这套代码我在Xilinx 7系列、UltraScale还有国产安路、紫光的器件上都跑过只要改改BRAM的例化原语和时钟约束核心解码逻辑一行不用动。如果用厂商IP换平台基本等于重做。当然代价也有就是开发周期长、调试痛苦。Deflate的Huffman解码涉及变长码流状态机要处理各种边界情况仿真波形能看瞎眼。但一旦跑通这套逻辑就是你自己的后续想加什么特性都自由。2.2 三层流水线架构的设计考量整套解码器我拆成了三级流水Deflate解压层 → 逆滤波层 → 像素重组层。为什么这么分因为这三步的数据依赖关系是严格的顺序关系但每一步内部都有并行空间分层之后可以各自做流水优化层与层之间用FIFO或者乒乓BRAM衔接。Deflate解压层是绝对瓶颈它要处理Huffman变长码一个时钟周期不一定能吐一个字节所以后面必须挂一个弹性缓冲。逆滤波层相对规整每行像素做固定的加减运算可以做成全流水。像素重组层负责把字节流按颜色类型拼成32位RGBA或者16位RGB输出给后续模块。这里有个关键决策中间数据用BRAM还是用FIFO。我最初用异步FIFO衔接后来发现逆滤波需要按行访问而FIFO只能顺序读遇到滤波类型为Paeth或者Average时需要同时拿到左边、上边、左上三个参考像素FIFO根本不够用。所以改成用双口BRAM做行缓存写端口接解压输出读端口给逆滤波这样能同时读出上一行和当前行的数据。2.3 10套工程源码的差异化定位10套源码不是简单复制粘贴而是针对不同应用场景做了裁剪和扩展大致分这么几类工程编号定位特点适用场景01-02纯仿真验证带完整Testbench覆盖各类PNG样本学习算法、验证逻辑03-04基础解码支持灰度/RGB无Alpha简单图像显示05-06全功能解码支持调色板、Alpha、隔行通用图像处理07-08视频通路集成带AXI-Stream输出接VDMA视频叠加、OSD09-10资源优化版裁剪Huffman表固定尺寸低成本器件这样分的好处是新手可以从01开始跑仿真看波形理解每一步有经验的可以直接拿07去搭视频链路。每套工程都配了约束文件和上板说明不是那种只给源码不管死活的。3. Deflate解压的核心细节与实操要点3.1 Huffman变长码的硬件解码策略Deflate里用了两种Huffman编码固定表和动态表。固定表的码长是预设的实现简单动态表需要在码流开头先解析出码长序列再重建码表。这是整个解码器最烧脑的部分。硬件解变长码常规做法是查表法。但Huffman表可能很大直接查表会吃掉大量BRAM。我的做法是构建一棵规范Huffman树用逐位比较的方式解码。具体来说维护一个码字累加器每来一个bit就左移一位并或上当前bit然后跟当前长度的最小码字和最大码字比较落在区间内就命中否则长度加一继续。// 简化的Huffman解码状态机核心逻辑 always (posedge clk) begin if (state DECODE) begin code_reg {code_reg[14:0], bit_in}; code_len code_len 1; if (code_reg min_code[code_len] code_reg max_code[code_len]) begin symbol symbol_table[code_reg - min_code[code_len]]; state SYMBOL_OUT; end end end这里有个坑码字比较要用无符号数而且要注意位宽对齐。我一开始用有符号比较结果遇到高位为1的码字就出错查了两天才发现是符号位的问题。另外min_code和max_code表要在解析动态表时预先算好不能边解边算否则时序根本收敛不了。提示动态Huffman表的码长序列本身也是用Huffman编码的叫码长码。解析它需要先建一棵19个符号的固定表这层嵌套容易绕晕建议先在纸上画清楚再写代码。3.2 LZ77滑动窗口的BRAM实现Deflate的另一半是LZ77它把重复的字符串用距离长度对来表示。硬件实现需要一个滑动窗口来存历史数据窗口大小典型是32KB。32KB用BRAM存没问题但关键是读写冲突解压时既要往窗口里写新数据又要从窗口里读匹配串。我的方案是用真双口BRAM一个端口专门写一个端口专门读地址独立。写地址是循环递增的读地址根据距离计算。这里要注意距离是相对于当前写位置的偏移所以读地址 写地址 - 距离要做模运算。// 滑动窗口地址计算 wire [14:0] read_addr write_addr - match_distance; // 注意match_distance可能大于当前已写入的数据量需要做边界保护边界保护很重要。如果距离超过了已经写入的数据量说明码流有问题这时候要报错而不是读出垃圾数据。我在实际调试中就遇到过因为码流截断导致距离越界结果解出一堆乱码加了保护逻辑后就能准确定位问题。3.3 块类型与边界处理Deflate数据是按块组织的块类型有三种不压缩块、固定Huffman块、动态Huffman块。不压缩块最好处理直接拷贝后两种要走Huffman解码。每个块开头有3个bit的标志位最后一块有结束标志。硬件状态机要能在这三种块之间无缝切换。我的做法是顶层状态机维护一个块类型寄存器解码完一个块后回到块头解析状态读新的块类型。这里容易出错的地方是位流对齐不压缩块要求跳过当前字节剩余位对齐到字节边界这个细节如果漏掉后面全乱。注意Deflate的位序是LSB优先也就是先来的bit是低位。这跟很多人的直觉相反写移位寄存器时千万别搞反否则解出来的全是错的。4. 逆滤波与像素重组的实现细节4.1 五种滤波类型的统一处理PNG的每一行在压缩前会做一次滤波滤波类型有五种None、Sub、Up、Average、Paeth。解码时要按行读取滤波类型字节然后对整行做逆运算。这五种滤波的逆运算公式不同但结构相似都是基于左边、上边、左上三个参考像素做加减。硬件实现时我用了一个可配置的运算单元根据滤波类型选择不同的运算路径。这样比写五个独立模块省资源而且时序好收敛。滤波类型逆运算公式硬件实现要点NoneRaw(x) Filt(x)直通SubRaw(x) Filt(x) Raw(x-bpp)需要行内前向依赖UpRaw(x) Filt(x) Prior(x)需要上一行缓存AverageRaw(x) Filt(x) floor((Raw(x-bpp)Prior(x))/2)需要行内和上行PaethRaw(x) Filt(x) PaethPredictor(...)需要三个参考值Sub和Average有行内前向依赖也就是当前像素的计算依赖左边已经算好的像素。这意味着不能全并行必须按bpp每像素字节数做流水。比如RGB是3字节每像素那就要等前3个字节算完才能算当前字节。我的做法是用一个移位寄存器保存最近bpp个已算好的字节这样每个周期都能出一个结果。4.2 行缓存的乒乓设计逆滤波需要同时访问当前行和上一行所以至少需要两行缓存。我用的是乒乓BRAM写当前行的时候读上一行一行结束后交换角色。这样能保证连续处理不用等整行写完再开始下一行。行缓存的宽度要按最大行字节数来定。比如1920宽的RGB图像一行是1920*35760字节加上滤波类型字节是5761。BRAM深度要取2的幂次所以配8192深度。如果图像更宽就要考虑用多块BRAM拼接。// 乒乓行缓存控制 always (posedge clk) begin if (line_done) begin wr_bank ~wr_bank; // 切换写bank rd_bank wr_bank; // 读bank变成刚才写的那个 end end这里有个时序细节切换bank的时机要精确到行的最后一个字节写完之后早一个周期晚一个周期都会导致读出的数据错位。我是在写使能的下降沿检测行结束实测很稳。4.3 颜色类型与位深的适配PNG支持多种颜色类型灰度、RGB、调色板、灰度Alpha、RGBA。位深也有1、2、4、8、16位。这组合起来情况很多但实际项目里常用的就那几种。我的10套源码里基础版只支持8位RGB和灰度全功能版支持调色板和Alpha。调色板处理需要额外一块BRAM存调色板数据解码时用索引去查。调色板大小最多256项每项3或4字节用一块小BRAM就够。这里要注意调色板数据的字节序PNG里是RGB顺序但有些显示通路要BGR需要在输出级做交换。16位位深的处理比较麻烦因为Deflate解出来是字节流16位样本要两个字节拼。而且16位样本的滤波是按16位为单位做的不是按字节。这个细节如果搞错图像会出现规律性的条纹。我在全功能版里单独做了一个16位重组模块把两个字节拼成16位后再做逆滤波。5. 完整实操流程与上板验证5.1 从仿真到上板的完整步骤拿到源码后建议按这个顺序推进不要跳步跑通仿真先用工程01的Testbench里面预置了几张不同格式的PNG跑一遍看波形确认解压输出的字节流跟预期一致。这一步不用上板纯仿真就能验证算法正确性。综合看资源用Vivado或者安路的工具综合一遍看LUT、BRAM、DSP的占用。如果BRAM超了说明行缓存或者滑动窗口配大了可以按实际图像尺寸裁剪。加约束约束文件里主要配时钟周期和IO引脚。时钟频率先设低一点比如50MHz跑通了再往上超。PNG解码的关键路径通常在Huffman比较器那里频率上不去就先做流水切割。上板验证把解码后的像素数据接到HDMI或者VGA输出或者通过UART打印几个像素值对比。我一般会先用一张纯色图测试确认颜色通道没搞反再换复杂图。性能调优如果帧率不够看瓶颈在哪一级。Deflate解压通常是瓶颈可以考虑把Huffman解码做成两级流水或者用更宽的位宽并行处理。5.2 关键参数的计算与配置行缓存深度和滑动窗口大小是两个最影响资源的参数需要根据实际图像算。行缓存深度取大于等于一行字节数的最小2的幂。比如1280宽RGB一行3840字节加1字节滤波类型是3841向上取整到4096。如果图像宽度不固定就按最大宽度算。滑动窗口大小Deflate标准允许最大32KB但实际PNG压缩时不一定用满。可以统计一下你的PNG样本里最大距离是多少如果都不超过8KB那窗口就可以砍到8KB省一半BRAM。Huffman表大小固定表很小动态表最大也就288个符号。码长表19个符号。这些用分布式RAM或者小BRAM都能存。提示如果资源实在紧张可以把动态Huffman表解析后的码表存到LUTRAM里比BRAM省资源但深度不能太大。5.3 仿真Testbench的写法要点Testbench要能自动读取PNG文件并比对解码结果。我的做法是用Verilog的$readmemh把PNG文件读成字节数组然后逐字节喂给解码器。比对时用一个参考模型可以用Python提前跑一遍生成期望输出存成文件仿真时逐字节比较。// 简化的Testbench结构 initial begin $readmemh(test.png.hex, png_data); // 喂数据 for (i 0; i png_len; i i 1) begin feed_byte(png_data[i]); end // 比对输出 for (i 0; i expected_len; i i 1) begin if (out_byte ! expected[i]) begin $display(Mismatch at %d, i); end end end这里有个实用技巧用Python生成hex文件。PNG是二进制直接读进Verilog不方便先用Python转成hex文本每行一个字节仿真时直接读。期望输出也一样处理。这样整个验证流程就自动化了改图不用改代码。6. 常见问题与排查技巧实录6.1 解码输出乱码的排查思路乱码是最常见的问题原因可能出在好几层。我整理了一个排查顺序从前往后查现象可能原因排查方法全黑或全白像素重组颜色类型配错检查颜色类型寄存器规律性条纹滤波类型判断错或位深处理错抓波形看每行滤波类型字节随机噪点Huffman解码错位对比解压字节流和参考图像偏移行缓存地址算错检查行结束检测逻辑颜色通道互换RGB/BGR顺序问题看输出级是否做了交换我遇到最多的是Huffman解码错位表现是解出来的字节流前面几个对后面全乱。这种通常是码表重建时某个码长算错了导致后续所有码字都偏移。解决办法是在解析动态表时把每个符号的码字打印出来跟Python的zlib库对比很快就能定位。6.2 时序不收敛的优化手段PNG解码器的关键路径通常在Huffman比较器和逆滤波的加法器链上。如果频率上不去可以试这几招切割比较器把码字比较拆成两级第一级比较高位第二级比较低位中间加寄存器。预计算把min_code和max_code在解析阶段就算好存起来解码时直接查不要实时算。逆滤波流水把Paeth预测器的三个候选值并行算出来再用选择器选比串行算快。降低位宽如果图像位深是8位就不要用16位的加法器省一半逻辑。我在7系列上跑不加流水大概能到80MHz加了流水能到150MHz以上。对于1080p60的视频像素时钟148.5MHz所以流水是必须的。6.3 资源占用的优化经验BRAM是PNG解码器的大头滑动窗口32KB加两行缓存轻松吃掉十几块BRAM。如果器件BRAM紧张可以这么省滑动窗口用分布式RAM如果窗口小于4KB用LUTRAM比BRAM划算。行缓存复用如果图像宽度不大两行缓存可以合并成一块BRAM的两个bank。调色板存ROM调色板是只读的综合成ROM比BRAM省。裁剪Huffman表如果确定PNG只用固定表动态表逻辑可以整个删掉。我有一套针对小器件的优化版把32KB窗口砍到8KB行缓存用分布式RAM整体BRAM占用从18块降到4块代价是不支持大距离匹配的PNG但对于摄像头直出的图基本够用。6.4 跨平台移植的注意事项这套代码在Xilinx和安路上都跑过移植时主要改这几个地方BRAM原语Xilinx用RAMB36E1安路用DPB接口不一样要换例化。时钟资源Xilinx用BUFG安路用GCLK约束写法也不同。复位极性有些器件内部复位是高有效有些是低有效要统一。综合属性(* ram_style block *)这种属性Xilinx认安路可能不认要去掉。核心解码逻辑是纯RTL不依赖任何原语所以移植工作量主要在存储和时钟上。我一般会把BRAM例化单独放一个文件移植时只改这个文件。7. 工程源码的使用建议与扩展方向7.1 怎么根据自己的需求选工程10套源码不是让你全跑一遍而是按需选。如果你只是想学习PNG解码算法从01开始仿真跑通波形看懂基本就掌握了核心。如果是要做实际项目直接看07的视频通路版把AXI-Stream输出接到你的后续模块上。选工程时重点看两个参数支持的颜色类型和最大图像尺寸。基础版只支持8位RGB最大宽度按行缓存深度定。如果你的图是调色板格式必须用全功能版。如果图特别宽要改行缓存深度。7.2 后续可以怎么扩展这套解码器跑通之后可以往几个方向扩展。一个是编码把解码反过来做就是PNG编码压缩部分可以用简单的固定Huffman复杂度低很多。另一个是其他图像格式比如BMP、JPEG的无损模式架构类似改改解析层就行。还可以做多路并行如果有多张图要同时解可以例化多个解码核共享滑动窗口BRAM用仲裁器分配。这个在视频墙或者多图层叠加场景很有用。最后再分享一个小技巧调试PNG解码时先用小图。找一张16x16的PNG解出来的字节流很短波形一屏就能看完比拿1080p的图调效率高十倍。等小图跑通了再换大图验证边界情况。这个习惯帮我省了大量调试时间。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进