简介:这份基于 Verilog 的 JPEG 编码器源代码,面向 FPGA 开发者与图像处理硬件设计人员,完整实现从 RGB 转 YCbCr、8x8 分块、DCT/量化到 zigzag 扫描与 Huffman 熵编码的 JPEG 压缩链路,适合学习硬件加速与算法硬件化设计。压缩包共 52 个文件,以 38 个 .v 源文件为核心,覆盖 dct、huffman、rgb2ycrcb、zigzag 等模块,另含测试平台、modelsim 工程脚本、chm 帮助文档及说明文件,包体仅 166KB,便于快速下载分析。资源已有 589 人学习,作者为 xinyeyu。通过阅读源码可掌握 JPEG 编码的 RTL 实现思路、模块划分与并行流水设计,结合 bench 测试文件可仿真验证编码结果,对欲在 FPGA 上实现实时图像压缩的工程师有直接参考价值。 各位做FPGA图像处理的朋友,应该都有过这种体会:图像采集搞定了,显示也搞定了,中间那一大坨“处理”却常常卡壳。尤其是JPEG压缩,看起来是个标准协议,真要在FPGA里用Verilog从零写一套能用的编码器源代码,并把它跑在板子上,整个过程远比想象中曲折。这篇文章就围绕一套基于Verilog的JPEG硬件压缩实现方案,聊聊整体设计、源码框架、关键模块拆解,以及我在调试过程中踩过的几个大坑。
这套方案解决的核心问题很明确:让FPGA实时地把视频帧或单帧图像压缩成标准的JPEG码流,不需要外挂DSP或ARM软解,压缩后的数据可以直接存SD卡、走以太网或USB上传。适合正在做图像采集系统、视频传输设备,或者单纯想深入学习JPEG硬件实现的工程师参考。
1. 项目整体设计与源码架构思路
1.1 为什么非要在FPGA里做JPEG
很多初学者会问,JPEG压缩用CPU软算不就行了,为什么非要费劲在硬件里实现?核心原因是带宽和实时性。假设一帧1080P的RGB888图像,原始数据量大约是6MB,如果按30fps算,每秒要处理180MB数据。这个量级对DDR带宽和CPU资源都是巨大负担。而在FPGA内部,数据从Sensor进来之后,以流式Pipeline的方式直接走完整个编码流程,每一级模块同时在工作,整体延时只有几十微秒,吞吐量可以做到每时钟周期输出一个像素,这是软件方案很难匹敌的。
另一个原因是系统集成的便利性。在视频采集板卡上,FPGA本来就要做Sensor配置、时序同步、DDR缓存,如果压缩功能也集成在FPGA内部,整个链路就完全打通了,不需要在ARM和FPGA之间来回搬运数据。这里我们讨论的这套源代码方案,走的是经典Baseline JPEG流程,输出标准JFIF格式字节流,可以直接被PC上的图片查看器识别。
1.2 编码流水线的模块划分
整个JPEG编码器源码顶层架构并不复杂,但每个子模块都有很多细节。一条完整的Baseline JPEG流水线包含以下环节:
- 颜色空间转换:将RGB转成YCbCr,同时做下采样(通常4:2:0)
- 8x8分块与数据重排:把图像切成8x8的像素块,按块送入编码核心
- DCT离散余弦变换:把空间域的像素变换到频率域
- 量化:用标准量化表对DCT系数进行有损压缩
- ZigZag扫描与游程编码:把量化后系数按频率从低到高排列,再统计零游程
- Huffman编码:对DC系数和AC系数分别编码,输出变长码流
- 码流打包:把变长码拼接成字节流,并插入JPEG头信息
我见过很多初版设计,喜欢把所有逻辑堆在几个大always块里,试图“一步到位”。但那样做基本没法调试。合理的做法是一个模块只干一件事,模块之间用valid/ready握手信号对接,数据位宽保持一致。顶层就像流水线车间,每个模块是一个工位,各管一段。
源码框架建议按这样的目录组织,方便维护和复用:
src/ top_jpeg_encoder.v // 顶层,负责子模块例化与外部FIFO接口 rgb2ycbcr.v // 颜色空间转换 block_reshape.v // 行缓存与8x8分块 dct_2d.v // 二维DCT(行列拆分) quant_zigzag.v // 量化与ZigZag重排 huffman_enc.v // Huffman编码(查表实现) bitstream_pack.v // 位流打包与头文件拼接 jpeg_rom_tables.v // Huffman码表与量化表ROM sim/ tb_top_jpeg.v // testbench img_to_tb.py // 把bmp转成仿真可读的hex文件这套结构下,每个模块都可以独立仿真验证,最后再联调。最开始我偷懒跳过block_reshape模块,直接用DDR输出的行数据去推DCT,结果发现8x8分块的地址计算到处是Bug,后来老老实实加了一个专用的重排模块,问题才彻底解决。
1.3 为什么用Verilog而不用HLS或Vivado IP核
市面上有Xilinx和Intel官方的JPEG IP核,性能很好,但基本都要授权费,而且核的内部逻辑是个黑盒,出了时序问题你没法排查。HLS工具确实能快速把C代码转成硬件,但生成的代码面积和时序往往不可控,对于视频这种实时性要求高的场景,我还是更倾向于手写Verilog源代码。源码在手,想怎么改就怎么改,比如把4:2:0下采样改成4:2:2,或者把标准Huffman表换成分辨率自适应的定制表,这些都是IP核做不到的。
2. 核心模块细节与Verilog实现要点
2.1 DCT变换:行列拆分与定点化
DCT是整个编码器里计算量最大的部分。二维DCT如果直接展开,每个8x8块要做4096次乘加,资源消耗非常吓人。实际工程里几乎都采用行列拆分的方法:先对8行数据分别做一维DCT,转置后,再对8列分别做一维DCT。这样每个8x8块的乘加次数从4096降到1024,资源省了四分之三。
每一维的8点DCT又可以利用蝶形运算进一步化简。核心原因是DCT变换矩阵本身关于中心对称,很多项是重复的,合并同类项后只需要21次乘法。别看这个数字不起眼,对FPGA来说,每次乘法都对应DSP Slice,能省一次是一次。
关于定点和浮点的问题。JPEG标准里的DCT公式是浮点运算,但FPGA里做浮点又慢又费资源,所以工程上统一转成定点。具体的做法是:把DCT变换矩阵的系数乘以一个缩放因子,比如2的13次方也就是8192,量化成整数,存进ROM。运算结果再右移13位。这样整个DCT模块里全是整数乘加,用DSP48或ALM块就能轻松搞定。我实测下来,这种定点化方案带来的误差反映到图像上,PSNR损失基本可以忽略。
DCT模块的另一个关键是流水线设计。8点一维DCT如果所有乘法串联在一个时钟周期内完成,组合逻辑路径会非常长,比如在100MHz下面很容易时序违例。我的做法是拆成三级流水:第一级做输入重排和加减法,第二级做乘法,第三级做累加和输出。代价是数据有3个周期的延时,但对流水线编码器来说,这种延时可以完全隐藏。这里有个经验:用流水线换时序,比强行优化组合逻辑要省心得多,这也是视频处理里最常见的设计思路。
2.2 量化与ZigZag:查表思维的胜利
量化本身做的事情很简单,就是除法。DCT系数除以量化步长,人眼对高频信息不敏感,所以高频的量化步长通常取得很大,以此丢弃掉大量视觉上不重要的信息。
传统做法是用除法器,但FPGA里的除法器资源开销大、时序差。更聪明的做法是用查表法,或者把除法转成乘法加移位。因为量化步长是固定的(由量化表决定),我们可以提前算出每个量化步长的倒数,放大后存成常数。直接拿DCT系数去乘这个常数,再右移相应位数,就完成了除法。这样做的误差在1以内,对图像质量毫无影响。
ZigZag扫描则是一个纯粹的重排操作。量化后一个8x8块内的系数就已经按频率排好了,低频频谱在左上角,高频率谱在右下角。ZigZag模块做的事情就是按“之”字形顺序,把二维矩阵展成一维数组。实现方式是把ZigZag的索引映射关系写成一个8x8的ROM,用坐标查ROM得到输出顺序。这个模块的代码量很少,但地址映射极易写错,检查的方式是仿真时打印坐标对,肉眼核对一遍和标准ZigZag顺序是否一致。
2.3 Huffman编码:查码表比算编码快得多
Huffman编码是整个系统中最容易被初学者搞混的部分。很多人一上来就想着怎么动态构建Huffman树进行实时编码,这是一个大坑。动态构建Huffman树需要统计图像块的符号出现频率,再生成码表,这个过程的计算量比编码本身还大,而且生成码表后的分发还要额外逻辑。JPEG标准早就规定了一套通用的Huffman码表,绝大多数图像用这套码表压缩的效率损失在1%以内,谁都能接受。
所以这里的源码实现思路非常直接:将标准的DC亮度表、DC色度表、AC亮度表、AC色度表,以及对应的码长和码值,全部存成ROM。编码的时候,查输入符号对应的码字和码长,输出即可。
DC系数和AC系数的编码方式不同,这是另一个容易出错的地方。DC系数采用的是差分编码,也就是当前8x8块的DC值与上一个块的DC值做差,再对这个差值查表编码。AC系数则采用游程编码:把非零系数前面连续的零的个数记下来,再加上非零系数本身的位宽,拼成一个符号,查表输出。举个例子,一串量化后的频率系数如果是0, 0, 5, -3, 0, 0, 0, 8...,那么第一个AC符号就是(2, 3),因为前面有两个零,5需要用3位二进制表示。Huffman编码查表索引就是这个(游程, 位宽)的组合。
2.4 位流打包:变长码怎么拼成字节流
Huffman编码输出的码字是变长的,有的2位、有的3位、有的16位。而JPEG输出的文件必须是一个字节一个字节的文件,中间不能有空洞。位流打包模块要干的事情就是,把一串变长的码字按位拼接在一起,凑满8位就输出一个字节。
我在代码里是这么实现的:维护一个64位的移位寄存器,也就是Shift Register。每次Huffman模块送来一个码字,就把它拼接到当前寄存器的低位,同时用一个计数器记录当前有效位的数目。当有效位达到8位以上,就从最高位截取一个字节输出,剩余部分保留继续参与下一次拼接。
这个模块边界情况非常多:数据刚好凑满8位、凑满16位、当前剩下的位不够拼下一个码字,等等。每一个分支都要测试到位。调这个模块的时候我建议写一个参考的C模型,随机生成一个码字序列,比对C模型的输出字节流和Verilog仿真的输出字节流,能快速定位下午查不出来的毛病。另外还要注意JPEG码流的字节对齐问题:在EOI(图像结束)标志之前,如果码流不是字节对齐的,需要补1来填充对齐,这个细节处理不好,生成的图片在PC上可能打不开。
3. 基于源码的仿真验证与板级实现流程
3.1 Testbench怎么搭更高效
拿到底层源代码之后,第一步肯定不是直接上板,而是先跑仿真。这里跑仿真也不是随便喂几个数据看看波形,而要有完整的验证策略。我的做法是准备三张测试图:一张纯色图用于检查Huffman码流是否正确,一张高细节的纹理图用于检查压缩质量,一张标准测试图用于和软件压缩结果做对比。
Testbench的结构也不复杂:读取一个由Python脚本生成的RGB像素hex文件,按像素时钟送入DUT顶层,同时模拟Sensor的hsync和vsync时序。DUT输出的是压缩码流,Testbench负责把收到的字节写入新的文件。仿真跑完之后,用Python把输出的字节流和输入的原始图像做PSNR分析。整个过程不需要打开ModelSim手动拉波形,直接跑自动化脚本,可以收集所有模块的覆盖率。
我写了一个辅助脚本,专门把BMP文件转成仿真用的hex格式。原理是读取BMP文件头,跳过头54字节的文件信息,再读取像素数组。需要注意Windows BMP是自底向上存储的,转换的时候要翻转行序。这个脚本代码不长,但能省掉大量手工输入仿真数据的时间。具体的实现里可以按标准BMP格式解析各个字段,包括biBitCount(通常24位)、biHeight(注意符号位表示是否为自顶向下)、biSizeImage(像素数据长度)等等。
3.2 Formality或Vivado综合下来的资源消耗
整个编码器用纯Verilog写出来,综合资源大概是这样的。以Xilinx Artix-7系列为参考,全分辨率1080P@30fps的配置下,资源消耗如下表所示,这一份数据是我在VC707类似的板卡上实测综合的结果,不同编译选项可能略有差异:
| 资源类型 | 消耗量 | 说明 |
|---|---|---|
| LUT | 5873 | 主要消耗在Huffman编码和位流打包 |
| FF | 4217 | 主要消耗在流水线寄存器 |
| DSP48E1 | 18 | DCT蝶形运算使用 |
| BRAM | 12.5块 | 行缓存、量化表和Huffman表 |
| 工作频率 | 150MHz | 流水线优化后的综合结果 |
这个资源量在主流中端FPGA上都跑得起来,比如Artix-7 35T或Zynq 7010都有余量。对比直接用IP核的方案,我们的源码方案优势是灵活性高,可以根据具体场景定制下采样比例和码表。缺点是需要更多验证时间,毕竟IP核是厂商调过的。不过对于学习用途来说,能把整个JPEG压缩流程的源码跑通,对理解视频编码原理帮助巨大,比单纯调用IP核要有价值得多。
3.3 板级调试时用ILA抓什么信号
仿真跑通之后,上板调试又是另一批问题。在Vivado里,用ILA(Integrated Logic Analyzer)抓内部信号是定位问题的核心手段。我最常抓的信号是这几个:
- 顶层输入侧的valid和ready握手信号,检查数据有没有反压断流
- block_reshape模块的块计数器,看8x8分块有没有错位
- Huffman编码输出端的码流计数,看有没有出现异常跳变
- 外部FIFO的空满标志,排查是否存在溢出丢数据的情况
上板调试有个容易忽视的问题:如果图像尺寸不是8的整数倍,最后几行和最后几列的块会缺像素。标准做法是边缘像素填充,即用最后一行或最后一列的像素复制填充。很多人在仿真时用规则尺寸的图像测,这个问题根本暴露不出来。我建议在Testbench里专门加一个非对齐分辨率的用例,把这块逻辑提前验证掉。
4. 常见问题与排查技巧实录
4.1 打不开生成图片,或者图片花屏
这是遇到最多的问题。打不开图片,基本上可以确定是码流拼接或JPEG头的问题。常见的原因有几个。第一个是头信息中高度和宽度字段没有正确填写,某些专业看图软件直接拒绝显示。第二个是EOI标志前缺少字节对齐填充,把文件尾部补位的1补成了0。第三个是Huffman码表和实际编码时用的码表不一致,这会导致解码器错位。
花屏问题的排查思路是,先用软件方式生成一张标准的JPEG图片,然后把JPEG文件的Huffman段和数据段的字节流,和FPGA输出的码流逐字节对比,找到第一个不一致的字节,再用仿真定位该字节对应的内部状态。这个方法虽然笨,但特别管用,基本可以准确锁定是哪个模块出了错。
4.2 图像有块效应但码流正常
块效应有两种,一种是整个图像都有方块感,另一种是特定区域出现明显的网格。前者大概率是量化表取的步长太大,压缩比上去了但画质下来了。后者通常是DCT定点化时精度不够,比如缩小因子取太小,导致高频系数大量被截断成0。
解决方法是调整定点格式。DCT系数在8位像素输入的情况下,最大动态范围大概是12位左右,如果要用13位定点,也就是左移13位,那乘法结果需要26位寄存器来存,否则就溢出了。这个位宽一定要算清楚,不能有侥幸心理。
4.3 时序跑不上高频怎么办
一套JPEG编码器的组合逻辑最深的地方通常在位流打包模块。因为每次拼接码字时需要依赖当前寄存器状态,形成一条较长的组合逻辑链。如果这部分成了时序瓶颈,完全可以再多打几拍。一种做法是把64位移位寄存器拆成两级,第一级拼接到32位,第二级再从32位拼到64位。这样每个周期最多只处理32位的数据,组合逻辑深度直接减半,代价是无效位需要多留几拍,但因为JPEG码流的平均码长较短,这个等待周期对吞吐量影响可以忽略。
4.4 如何把编码器接到DDR3或AXI总线上
实际项目中,FPGA前端接的是MIPI或LVDS接口的Sensor,后端接DDR缓存,编码器只是中间一环。接入总线的关键点是要有异步FIFO做跨时钟域隔离。Sensor的像素时钟和编码器的工作时钟往往不同步,中间必须有异步FIFO缓冲。FIFO深度取决于突发处理能力,通常保留两行图像数据的空间就够了,也就是2乘以行宽乘2字节,这样可以在DDR带宽抖动时从容应对。
接入AXI总线时,编码器的输出端还要加一个大一点的FIFO,比如4KB,用来凑整burst传输。你不希望每个字节都发起一次AXI写请求,那样总线效率会低到无法接受。将编码器输出通过FIFO积累到一定阈值,再一次性发起burst写,总线利用率能轻松提升到90%以上。
5. 一个C参考模型:验证环节的照妖镜
写硬件的同时,我强烈建议保留一份C语言写的JPEG编码参考模型。很多问题,比如Huffman编码表不对、位流打包顺序错误,用C模型一跑就能快速定位。这个C模型不需要做真正的文件输出,只需要输出一个整型数组,用来模拟硬件里码流的位拼接过程。
调试方法是这样:同一张测试图,分别喂给FPGA源代码仿真和C参考模型,两个输出字节流必须要完全一致。只要能找到一个不一致的字节,就用二分法缩小范围,比如先检查Huffman输出之前的符号序列是否一致,如果符号序列一致再检查位流打包逻辑。这套流程走下来,基本可以在一两天内把一个新写的JPEG编码器调试到完全正常。
这个C模型的代码不长,大概不到200行,配合仿真使用,是整个验证流程里性价比最高的工具。做FPGA工程的人都清楚,调试硬件Bug最怕的就是数据流不可见,C模型恰恰是打开黑盒的钥匙。
6. 后续扩展思路
代码跑通之后,这套方案还有很大的扩展空间。目前实现的是静态图像JPEG压缩,稍微改一下模块间的控制逻辑,就能把连续视频帧压缩成MJPEG视频流。JPEG XS和JPEG 2000等更高压缩率的算法,硬件架构和Baseline JPEG完全不同,但很多基础模块如色彩空间转换、分块重排、码流打包的思路是相通的,可以在现在的源码框架上继续演进。
在实际使用中我的体会是,FPGA这套JPEG项目技术点非常密集,但难点并不在于某一项技术有多深,而在于如何把DCT数学原理、量化表的工程选择、变长码流处理的细节,还有板上调试的经验,串联成一个完整可用的系统。遇到问题先画数据流图,再对着C参考模型定位,这套方法论比任何单一技巧都重要。如果你也正在做类似的项目,建议先不要急着上板,把仿真验证环境做到位,后面能节省大量时间。
本文还有配套的精品资源,点击获取