简介:面向FPGA开发者和图像处理学习者的JPEG编码器Verilog源代码包,基于Verilog硬件描述语言实现JPEG压缩核心流程,覆盖颜色空间转换、8×8分块、离散余弦变换(DCT)、量化及熵编码等环节,可帮助读者从硬件角度理解JPEG标准的算法映射与实现要点。压缩包共收录52个文件,以.v源文件为主,辅以txt说明、chm帮助文档以及仿真配置与过程记录文件,整体仅166KB,结构清晰,便于直接查阅和仿真验证。已有591人学习下载,适合希望深入理解FPGA并行加速图像算法,或正在搭建图像处理系统中编码模块的开发者。该代码展示了模块化设计思路,包含独立的DCT变换、哈夫曼编码、数据通路控制与测试平台等部分,可用于学习Verilog编程技巧、逻辑综合流程以及算法级验证方法,是一份紧凑且完整的工程参考。
1. 用FPGA做JPEG编码:这份Verilog源码能省掉你几个月的造轮子时间
做工业相机和图像采集的工程师应该都有这种经历:传感器出的是RGB888,直接存BMP到SD卡,一张1080p的图就是6MB,写卡速度根本追不上采集帧率。换MCU跑软解JPEG也难受,DCT那层循环直接吃掉大半个主频,一秒钟处理不了几张图。最后基本都会绕回一套思路:用FPGA硬编码JPEG。JPEG虽然老,但在FPGA上依然是性价比最高的选择——算法成熟、参考实现多、资源占用可控。这份FPGA JPEG Verilog Source Code把完整的基线JPEG编码链路做成了一份可综合的Verilog源码包,从颜色空间转换、8x8分块、DCT、量化、Zigzag排序到Huffman编码和文件头拼装,全部覆盖。对正在做图像采集、视频传输、嵌入式存储的FPGA工程师来说,这份代码最大的价值是让你跳过造轮子的阶段,直接从模块级去改位宽、调时序、换量化表。适合已经有Verilog基本功、但对JPEG标准还不够熟的开发者拿来当跳板。
2. JPEG编码链路拆解:从RGB到Huffman码流,先分清每个模块在干什么
2.1 编码流水线的五个核心环节
基线JPEG(Baseline JPEG)的编码链路,理论上有五个核心环节:RGB转YCbCr、色度下采样、8x8分块DCT、量化、Zigzag加Huffman熵编码。这里不讨论渐进式JPEG和算术编码,FPGA实现基本都走基线,因为基线流程控制简单、硬件开销可控。
先理清数据流向。传感器进来的RGB888先做颜色空间转换,把R/G/B三个分量变成Y/Cb/Cr三个分量。Y是亮度,人眼最敏感,保留全部分辨率;Cb和Cr是色度,人眼不敏感,所以可以做4:2:0下采样——也就是每2x2的像素块只保留一个Cb和一个Cr。这一步直接把色度数据量砍到四分之一。接下来把Y、Cb、Cr各自切成8x8的像素块,每个块做二维DCT。DCT的作用是把空间域的像素值变到频率域,低频分量集中在左上角,高频分量在右下角。人眼对高频不敏感,所以量化表把高频的步长调大,很多高频系数量化完直接变成0。最后Zigzag扫描把这些系数按频率从低到高排成一维序列,再做Huffman编码,DC系数单独做差分编码,AC系数做游程编码。最后拼上SOI、APP0、DQT、SOF0、DHT、SOS这些标记段,就是完整的JPEG文件。
这套流程在FPGA上的实现方式通常是流水线与状态机混合:像素级模块(颜色转换、下采样)走流水线,块级模块(DCT、量化、熵编码)走状态机驱动。核心原因是DCT和Huffman编码都有明显的“块边界”,8x8的像素块处理完才开始下一个块,天然适合状态机控制。
2.2 模块边界与时序契约:valid-ready握手怎么约定
模块分好之后,最大的问题是模块之间怎么传数据。很多初学者喜欢用全局时钟使能或者一个自己定义的“帧有效”信号拉通所有模块,这种做法在小规模验证时能跑通,但到了多时钟域、多模块级联的时候,一旦某个模块需要暂停(比如Huffman编码器在等码字位宽对齐),整个流水线就不知道该怎么办了。
我一般会在模块边界用valid-ready握手。发送方拉高valid表示数据有效,接收方拉高ready表示可以接收,当valid和ready同时为高时,数据在时钟上升沿被采样。这个协议实现简单、可组合性强,任何一个模块都能在不影响全局的情况下插入气泡等待。
// valid-ready 握手示例:RGB888 像素数据跨模块传递 module pixel_channel ( input wire clk, input wire rst_n, input wire [23:0] pixel_data_in, input wire valid_in, output wire ready_out, output reg [23:0] pixel_data_out, output reg valid_out, input wire ready_in ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin valid_out <= 1'b0; end else if (ready_in) begin valid_out <= valid_in; pixel_data_out <= pixel_data_in; end // ready_in 拉低时保持当前输出不变 end // 能接收的条件:接收方空闲,或者当前发送已完成 assign ready_out = ready_in || !valid_out; endmodule这段代码的逻辑是:valid_out在接收方ready时跟随valid_in,否则保持。ready_out的表达式是接收方ready或者当前没有数据在传输。这个写法比直接用“valid_out 与 ready_out 同时拉高才算传输完成”要直观得多,而且不需要额外的fifo。实际项目中我会在这个基础上加两级寄存器做跨时钟域同步,同时避免组合逻辑上的毛刺。
2.3 RGB转YCbCr:用定点系数替代浮点
颜色空间转换的公式是JPEG标准里给定的:
Y = 0.299R + 0.587G + 0.114B Cb = -0.169R - 0.331G + 0.5B + 128 Cr = 0.5R - 0.419G - 0.081B + 128
在Verilog里直接写浮点是不现实的,常见做法是定点化。把系数乘以65536(2的16次方)取整,内部用16bit乘法,最后截位。0.299乘以65536约等于19595,0.587约等于38470,0.114约等于7471。这样RGB三位各做一个16bit乘法,三个乘积相加,取高8位就是Y分量。
// RGB888 -> YCbCr444,系数按 2^16 缩放,内部 16bit 乘法 // Y 范围规范化为 16~235,Cb/Cr 范围 16~240,这里先算后再加偏移 wire [15:0] r_y = {8'b0, R_in} * 16'd19595; // 0.299 * 65536 wire [15:0] g_y = {8'b0, G_in} * 16'd38470; // 0.587 * 65536 wire [15:0] b_y = {8'b0, B_in} * 16'd7471; // 0.114 * 65536 wire [17:0] sum_y = r_y + g_y + b_y; // 18bit 防止溢出 assign Y_out = sum_y[15:8] + 8'd16; // 截高位加偏移 // Cb/Cr 需要带符号计算,先做 9bit 有符号扩展再乘 wire signed [16:0] r_cb = {9'd0, R_in} * 17'd(-11059); // -0.169 * 65536 wire signed [16:0] g_cb = {9'd0, G_in} * 17'd(-21655); // -0.331 * 65536 wire signed [16:0] b_cb = {9'd0, B_in} * 17'd32768; // 0.5 * 65536 wire signed [18:0] sum_cb = r_cb + g_cb + b_cb; assign Cb_out = sum_cb[17:8] + 8'd128;参数说明:R_in、G_in、B_in是8bit输入,乘法器结果位宽是16bit,三个数累加需要18bit防止溢出。Cb和Cr因为有负系数,必须用有符号数处理,所以扩展了一位。输出截取中间8位,相当于除以256,再加偏移量。这种定点方式的误差在1以内,对后续DCT和量化来说完全够用。如果你用的是Xilinx的DSP48E1,两个乘法器可以级联在一个slice里,资源上比分开写省不少。
3. Verilog代码架构:顶层接口、数据通路与状态机读码顺序
3.1 顶层模块与接口信号
拿到这份Verilog源码包之后,建议先看顶层的端口定义,搞清楚输入输出信号是什么,再往内部模块钻。一个典型的JPEG编码器顶层,接口信号大致是这样的:
| 信号 | 方向 | 位宽 | 说明 |
|---|---|---|---|
| clk | input | 1 | 像素时钟,一般为传感器输出的PCLK |
| rst_n | input | 1 | 异步复位,低有效 |
| pixel_data | input | 24 | RGB888,高8位为R |
| pixel_valid | input | 1 | 输入像素有效信号 |
| jpeg_data | output | 8 | 编码输出的JPEG字节流 |
| jpeg_valid | output | 1 | 输出字节有效标志 |
| jpeg_busy | output | 1 | 编码器忙状态,用于反压前级 |
| frame_start | input | 1 | 帧同步信号,开始新一帧编码 |
| quality_sel | input | 2 | 量化表选择,00/01/10对应三档压缩质量 |
顶层内部例化了三个大模块:颜色空间转换模块(包括下采样)、DCT与量化模块、Huffman编码与码流拼接模块。三个模块之间用valid-ready握手连接。顶层做的事情就是把输入像素流按8x8块组织好,送给DCT模块;DCT算完的系数进量化器;量化后的AC系数进Zigzag缓存,等一个块全部处理完后,Huffman编码器开始输出码流。
3.2 数据通路:行缓冲与8x8块缓冲
FPGA处理图像绕不开行缓冲(Line Buffer)。一张1920x1080的图像,宽度也就1920像素,但FPGA内部的BRAM按块算,单块最多36Kbit,放不下一整行RGB888的数据是常态。常见做法是用双口RAM做行缓冲,写入端按像素时钟实时写入,读出端按块处理节奏读取。
这里有个值得注意的细节:8x8分块要求图像宽度和高度是8的整数倍。如果传感器输出的分辨率不是8的倍数,比如1920x1080刚好是,但一些MIPI输出是1920x1080的裁剪窗口,那么编码前就得做边界填充。常见做法是复制边缘像素,或者填0。复制边缘像素能防止DCT在图像边界产生振铃效应。
// 行缓冲读写地址计算:按 8x8 块读取 // 写入侧:pixel_valid 到来时写地址累加,宽度为 image_width // 读取侧:以块为单位,读出地址 = 块行号 * image_width + 块列号 * 8 + 块内行号 reg [10:0] wr_addr; // 写入地址,最大支持 2048 像素宽度 reg [10:0] rd_addr_base; // 读基地址,指向当前块起始位置 reg [2:0] blk_col; // 块内列号 0~7 reg [2:0] blk_row; // 块内行号 0~7 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin wr_addr <= 11'b0; end else if (pixel_valid) begin wr_addr <= wr_addr + 1'b1; end end // 读取地址由块计数器控制,每个 DCT 时钟周期读一个像素 assign rd_addr = rd_addr_base + blk_col + blk_row * image_width;参数说明:wr_addr宽度11bit是因为源程序设定图像宽度最大2048,你可以按自己项目的最大分辨率调。rd_addr_base需要在每个8x8块开始前由状态机更新,指向该块在行缓冲中的首地址。blk_col和blk_row由块控制状态机产生,每读完一行8个像素,blk_col清零、blk_row加1。这套逻辑配合DCT核的流水,能保证数据不间段。
3.3 控制通路:状态机与JPEG标记生成
数据通路知道“怎么把数据算出来”,控制通路负责“什么时候算什么”。JPEG编码器的控制状态机一般包含以下状态:
localparam IDLE = 4'd0; localparam GEN_APP0 = 4'd1; localparam GEN_DQT = 4'd2; localparam GEN_SOF0 = 4'd3; localparam GEN_DHT = 4'd4; localparam GEN_SOS = 4'd5; localparam ENCODE_BLOCK = 4'd6; localparam GEN_EOI = 4'd7;帧开始的流程是:IDLE状态下收到frame_start脉冲,状态机依次进入GEN_APP0、GEN_DQT、GEN_SOF0、GEN_DHT、GEN_SOS,把JPEG文件头各段数据从ROM中读出并输出到字节流接口。文件头长度和内容由量化表和Huffman表决定,所以这些段的内容在初始化时由软件通过寄存器配置写入BRAM,状态机只负责按顺序读出来。
进入ENCODE_BLOCK状态后,控制权交给块处理状态机。每一个8x8块要经历:从行缓冲读64个像素、1D DCT行变换、转置存储、1D DCT列变换、量化、Zigzag排序、Huffman编码。这个过程大约需要几十个时钟周期。等Huffman编码器输出完这个块的所有码字后,状态机跳回ENCODE_BLOCK处理下一个块,直到整帧图像的所有块都处理完,进入GEN_EOI输出文件结束标记。
这里有一个容易出错的点:JPEG文件头里的SOF0段声明了图像宽高,而这个值必须是8的倍数。如果实际图像不是,就得在写文件头时做向上取整,同时把填充的像素编码进去,否则解码器会报错。
4. DCT与量化模块:位宽选择、行列分解与精度控制
4.1 2D-DCT的行列分解法
8x8二维DCT如果直接算,要做64x64=4096次乘加,FPGA资源撑不住。所以工程上都用行列分解法:先对每一行做8点一维DCT,得到8个中间值,再对这8个列向量做一次一维DCT。这样总的乘法次数变成8x8x2=128次,是直接计算的1/32。代价是需要一个8x8的转置缓冲,BRAM存一下就行。
一维8点DCT的公式是:
F(u) = 0.5 * C(u) * Σ f(x) * cos((2x+1)uπ/16),其中C(0)=1/√2,C(u)=1(u≠0)
FPGA实现时一般把cos系数预计算好,存成ROM。输入是8bit像素值,系数是8bit定点数,做8x8位宽的乘法再累加。
// 8点1D DCT 单输出通道计算(u=0 的 DC 分量) // 用查表方式实现 cos 系数,乘累加完成 F(0) reg [15:0] acc; reg [2:0] x; wire signed [7:0] cos_coef; // 查表输出 cos((2x+1)0π/16),固定为 1 wire signed [15:0] mul_out; assign cos_coef = 8'sd1; // u=0 时所有系数为1,简化查表 always @(posedge clk) begin if (state == DCT_ACC) begin if (x == 3'd0) acc <= mul_out; else acc <= acc + mul_out; x <= x + 1'b1; end end逻辑说明:这个例子只算了DC分量,因为u=0时所有cos系数都是1,不需要查表。实际代码中,对于u=1到7,cos系数不同,需要从ROM中查表。每个8点DCT块需要8个这样的计算单元并行,才能在一个时钟周期内算完一个8点向量,或者用一个单元串行跑8个周期。资源紧张时用2个单元串行跑4轮,一般非实时场景够用。
参数说明:acc宽度16bit是因为8bit像素乘8bit系数最大乘积16bit,8次累加最多扩展3bit,要防溢出的话acc得设成19bit。源程序里给的是取中间16bit,也就是说在累加过程中没有做截位,最终结果取高位。这个位宽设计是DCT模块最容易翻车的地方,后面避坑章会细说。
4.2 量化表存储与查表实现
量化是JPEG有损压缩的核心步骤。DCT输出的系数要除以量化表对应的步长,然后取整。标准JPEG给了一个推荐的量化表,实际项目中常说的“质量因子”就是对这个推荐表做缩放得来的。50%质量的亮度量化表长这样:
| 位置 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 0 | 16 | 11 | 10 | 16 | 24 | 40 | 51 | 61 |
| 1 | 12 | 12 | 14 | 19 | 26 | 58 | 60 | 55 |
| 2 | 14 | 13 | 16 | 24 | 40 | 57 | 69 | 56 |
| 3 | 14 | 17 | 22 | 29 | 51 | 87 | 80 | 62 |
| 4 | 18 | 22 | 37 | 56 | 68 | 109 | 103 | 77 |
| 5 | 24 | 35 | 55 | 64 | 81 | 104 | 113 | 92 |
| 6 | 49 | 64 | 78 | 87 | 103 | 121 | 120 | 101 |
| 7 | 72 | 92 | 95 | 98 | 112 | 100 | 103 | 99 |
数值越小量化越精细,左上角频率低,量化步长小,保留细节;右下角频率高,量化步长大,丢弃人眼不敏感的信息。Verilog实现时把量化表存成ROM,DCT结果读出后和量化表值一起送进除法器。
// 量化的近似实现:用乘法替代除法 // 除法器在FPGA里资源太贵,常见做法是把量化步长转成倒数乘法 // 例:除以16等于乘4096再右移12位 wire [19:0] quant_tmp; assign quant_tmp = dct_coef * quant_reciprocal; // quant_reciprocal 由软件配置 assign quant_out = quant_tmp[19:12];逻辑说明:quant_reciprocal的精度决定了量化误差大小。我一般用20bit倒数精度,量化误差控制在0.1%以内,对图像质量影响可以忽略。这个做法的好处是硬件只需要一个乘法器,不需要除法器。
4.3 位宽分配:从8bit输入到DCT输出,每一步截位都要有依据
位宽分配是最能体现工程经验的地方。8bit像素进DCT,中间结果到底用多少bit,直接决定了后续图像有没有色阶断层。粗算一下:8bit输入乘8bit系数得到16bit,8个乘积累加最大扩大8倍,也就是3bit,所以19bit够。但这只是理论值,DCT的中间结果有正有负,还有余弦系数的取值范围,实际仿真可以看到系数范围。谨慎的做法是每级多留2bit裕量,DCT内部用22bit,量化前截到12bit,只取低12位中的有效位。截位的时候要四舍五入而不是直接截断,直接截断会引入直流偏移,画面上会表现为整体亮度偏暗或偏亮。
5. Huffman编码与避坑排查:最容易翻车的几个细节
5.1 基线Huffman编码:DC差分与AC游程的Verilog实现
Huffman编码是JPEG编码链路里最“绕”的部分。标准里DC系数和AC系数用了两套独立的Huffman表,DC系数有12个分类(category),AC系数有162个符号(run/size组合)。DC编码的第一步是差分:当前块的DC值减上一个块的DC值,得到差分值DIFF。DIFF为0时category是0,编码为“00”;DIFF为-1到1时category是1,编码为“010”。第二个DIFF比特是DIFF的实际值。AC系数则是Zigzag扫描之后,把64个系数按“游程+非零值大小”的方式分组。连续16个0用ZRL符号替代,块结束前剩余的0用EOB符号提前结束。
Verilog实现时,我用一个ROM存Huffman表,表的地址就是category或Run/Size组合,输出是码长和码字。码字流不能直接用8bit输出,因为Huffman码字是变长的,从1bit到16bit都有,所以要先拼进一个bit accumulator,凑满8bit再输出。
// Huffman码字拼接:变长码字先入移位寄存器,凑满字节输出 reg [31:0] bit_acc; reg [4:0] bit_cnt; // 当前有效比特数,0~31 always @(posedge clk) begin if (encode_state == HUFF_OUT && huff_valid) begin // huff_code 来自查表,huff_len 是码字长度 1~16 bit_acc <= (bit_acc << huff_len) | huff_code; bit_cnt <= bit_cnt + huff_len; if (bit_cnt + huff_len >= 8) begin // 输出高位一个字节,bit_cnt 减去8 jpeg_data <= bit_acc[31:24]; jpeg_valid <= 1'b1; bit_cnt <= bit_cnt + huff_len - 8; bit_acc <= bit_acc << (8 - huff_len); // 拼接后的剩余bit end end end逻辑说明:bit_acc是32bit,最多可以容纳16bit码字加已有数据仍然不溢出。每次编码完一个符号,把码字左移进累加器。注意移位方向要统一,我这里统一用左移,高位字节先出——JPEG文件头也是大端序,MCU读到字节流后能直接按顺序写入SD卡。
参数说明:bit_cnt是有效bit数,最大31。码字长度从1到16变化,当bit_cnt加上新码字长度大于等于8时,说明可以输出一个字节。这里jpeg_data直接取bit_acc的高8位,剩余bit靠左移保留。这里有个关键边界:如果bit_cnt当前是7,新码字长度是16,拼接后总共23bit,一次只能输出高8位,剩下的15bit还要留在累加器里,所以代码里的移位量要按真实剩余bit数算。源程序里最容易被看漏的也是这个边界条件。
5.2 避坑:从综合报错到图像花屏的五个典型问题
问题1:综合后LUT爆炸,DCT模块资源翻了三倍
现象:Vivado综合报告里LUT用量远超预期,最严重的一次DCT模块用了4万多个LUT,直接把小芯片干爆了。
原因:看代码发现所有8个一维DCT输出通道都并行展开,每个通道都例化了独立的乘法器和加法器,64个乘加全展开,资源当然顶不住。
解决:改用2个乘法器串行轮询,8个输出通道用状态机控制分4轮算完。速度从每块8周期降到32周期,但8x8块处理的周期本身有富余,1080p@30fps按像素时钟74.25MHz算,每像素一个周期,一帧62万像素,DCT那边的块数才1万左右,串行化带来的延迟完全不影响帧率。
问题2:JPEG文件能打开,但图像有明显的8x8网格状块边界
现象:解码出来的图像整体能看,但每8x8块之间的边界颜色有轻微不连续,像马赛克一样。
原因:行缓冲的读地址设计有问题,DCT模块取的像素块和实际图像位置错位了8个像素。
解决:在行缓冲写入口和读出口各加一个计数器,打印波形对比wr_addr和rd_addr。最终定位到是rd_addr_base在状态机里没有在块起始时钟同步更新,块内行号blk_row 加1之后,基地址没有加上一行宽度。修正后读地址从0、1920、3840递增,块边界就完全对齐了。
问题3:码流中间偶尔多出一个字节或少一个字节,文件全花
现象:编码输出的JPEG文件时长不大,但解码器直接报错,或者画面上半部分正常、下半部分雪花。
原因:Huffman比特拼接的移位逻辑在跨字节边界时丢bit。bit_cnt大于等于8时,只输出了一个字节,但剩余bit没有正确前移,导致下一个码字拼接时数据错位。
解决:把bit_acc换成48bit,多留16bit余量,每输出一个字节后,剩余bit数统一按bit_cnt - 8计算,bit_acc左移8位。同时加一个断言:bit_cnt计算值必须等于bit_acc里的实际有效bit数,跑仿真时发现不满足就立刻报错。
问题4:色度通道出现大面积色偏,亮度正常
现象:灰度图像编码后正常,彩色图像编码后,人脸皮肤区域发绿,饱和度明显偏移。
原因:Cb和Cr通道的量化表直接用了一份亮度量化表,色度通道的高频分量被量化得过狠,产生了色度振铃。
解决:JPEG标准里亮度和色度本来就是两张量化表,色度表比亮度表整体数值更大。源程序里虽然有DQT段生成逻辑,但查表时没区分Y通道和Cb/Cr通道。改成按通道索引选表后,色偏问题消失。这也是调试JPEG编码器时最容易被忽略的地方——软件库(比如libjpeg)会自动选表,但自写的Verilog实现不会自动选。
问题5:时序收敛不了,DCT路径上的组合逻辑延迟太大
现象:综合频率最高只能到80MHz,死活跑不到120MHz以上,DCT模块的组合逻辑路径是关键路径。
原因:乘累加器没有插流水,8个乘法的结果在一个周期内全部累加,组合逻辑深度过大。
解决:在乘法和累加之间插入一级寄存器,把累加拆成两段。这样DCT模块延迟多了一个周期,但关键路径从80MHz提升到了150MHz以上。代价是控制状态机多拍,不过只要把DCT开始和结束的握手信号对齐,流水线多一拍不会影响数据正确性。
6. 验证技巧:用BMP转码对拍做端到端自检,再谈帧率余量
6.1 端到端自检:Python脚本对拍DCT中间结果
JPEG编码器的验证不能用“图能打开”当标准,必须对拍中间数据。最有效的方式是:用一张8x8的固定测试图,把像素值写成寄存器初始化文件,用仿真跑一遍,然后把DCT输出和量化后的系数导出成文本,和Python的参考实现逐个比对。
# 用 Python 生成 8x8 灰度测试块,并对比 FPGA DCT 输出 import numpy as np # 构造一个黑白渐变的 8x8 灰度块 block = np.array([ [16, 17, 18, 19, 20, 21, 22, 23], [17, 18, 19, 20, 21, 22, 23, 24], [18, 19, 20, 21, 22, 23, 24, 25], [19, 20, 21, 22, 23, 24, 25, 26], [20, 21, 22, 23, 24, 25, 26, 27], [21, 22, 23, 24, 25, 26, 27, 28], [22, 23, 24, 25, 26, 27, 28, 29], [23, 24, 25, 26, 27, 28, 29, 30], ], dtype=np.float64) # 参考实现:8点1D DCT,先按行变换再按列变换 def dct_1d(x): n = len(x) out = np.zeros(n) for u in range(n): s = 0.0 for i in range(n): s += x[i] * np.cos((2 * i + 1) * u * np.pi / (2 * n)) c = 1.0 / np.sqrt(2) if u == 0 else 1.0 out[u] = 0.5 * c * s return out # 先对每行做DCT,再转置对列做DCT row_dct = np.array([dct_1d(block[i, :]) for i in range(8)]) col_dct = np.array([dct_1d(row_dct[:, j]) for j in range(8)]) dct_2d = col_dct.T # 从仿真波形导出的 FPGA 输出文件 fpga_out = np.loadtxt('fpga_dct_out.txt', dtype=np.int16) mse = np.mean((dct_2d - fpga_out) ** 2) print('MSE:', mse) # MSE 应小于 3,FPGA 定点实现与浮点参考之间的误差在可接受范围逻辑说明:这个脚本直接验证DCT模块,避免问题被后续的Huffman编码掩盖。测试图像选择渐变块,各频率分量都有覆盖,比纯色块更能暴露位宽截断问题。
6.2 帧率余量:时钟频率与DCT周期的换算
DCT周期决定了编码器的帧率上限。一个8x8块,行列分解DCT一轮需要8周期,转置读写16周期,量化8周期,Huffman编码最坏情况下几十个周期。估下来一个块约100周期。1080p@30fps需要一帧1920x1080=207万像素,折算成8x8块是32400块,每块100周期就是324万周期。30fps总共需要30x324万=9720万周期每秒,约97MHz。也就是说,100MHz的像素时钟,DCT和Huffman的余量只剩不到3%。我当时把DCT改成串行2乘法器后,单块周期数变成160,帧率掉到约19fps。后来把量化除法换成乘法近似、Huffman的ROM查表优化成单周期输出,才把单块周期压回110左右。
6.3 从JPEG编码器到实际系统:帧头拼接与码流输出
整帧编码完成后,JPEG文件要以FF D9(EOI标记)结束,并且文件头之后紧跟着熵编码数据。这里有个常见坑:SOS段(FF DA)之后,如果数据里出现FF字节,必须插入一个00字节转义,否则解码器会把它当成标记前缀处理。很多自写的编码器在Huffman拼接时忘了这一步,输出的图片在某些内容下能打开、在某些内容下报错。我一般是在Huffman输出模块加一个状态:检测到输出的字节为FF时,下一个字节强制输出00,同时JPEG长度计数加1。这个逻辑简单但特别容易漏,编码器在没有数据的地方多插一个FF字节会导致解码器错位很久,查错成本极高。
从那以后我每次写完或者拿到一份FPGA JPEG编码器源码,第一件事不是看代码、仿波形,而是先跑一遍“输入单色图、输出全FF字节”的边界测试,再跑“输入渐变图、和Python对拍DCT中间结果”的精度测试——两条走完才谈资源优化和时序收敛。这套验证习惯帮我省掉了大量上板后抓毛刺的时间,希望帮到你。
本文还有配套的精品资源,点击获取