ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深入PNG编解码算法:从IDAT数据流到像素还原的完整实战

深入PNG编解码算法:从IDAT数据流到像素还原的完整实战 没事别折腾PNG编解码算法一旦折腾起来你就回不去了。先说下写这篇文章的背景。最近我在做一个图像处理工具链需要把一套老旧的图片资源全部转成PNG格式再重新做压缩和打包。项目本身不复杂但过程中踩了不少关于PNG编解码算法的坑——从文件结构解析到IDAT数据流的处理再到过滤算法和位深转换每一步都有值得记录的东西。网上关于PNG的教程很多但多数散落在零碎的代码片段里要么只讲理论不讲实操要么给个库调完就结束了。这篇文章我换个写法把PNG编解码算法从文件头到像素还原的完整链路都拆一遍并结合我实际开发中遇到的报错、性能瓶颈和内存问题来展开方便做嵌入式、做游戏资源工具、写导入导出插件的朋友直接参考。这篇文章适合谁如果你是做底层图像处理的、在ESP32这类小内存芯片上解码PNG的、或者只是想知道PNG和JPG到底差在哪的都能从里面捞到东西。我会先用大白话讲清楚PNG的编码管线然后给出一份可复现的Python解码器核心代码最后逐个分析实战中常见的坑。1. PNG编解码算法到底是什么先别急着查文档1.1 一句话版本PNG是怎么把图片装进字节流的PNG全称Portable Network Graphics是一个无损位图格式。所谓编解码算法本质上就是两条方向相反的流水线编码原始像素数组 → 行过滤filter → zlib压缩DEFLATE → 按IDAT数据块切分打包 → 加上文件头和各种辅助块。解码读取文件头 → 解析数据块 → 把IDAT拼接起来 → zlib解压 → 逐行反过滤unfilter → 还原成原始像素数组。这两个过程不是简单的压缩一下那么随意里面嵌着几个关键设计每一条扫描线在压缩前会先做一次差分变换五种过滤模式压缩算法用的是DEFLATELZ77Huffman最后再用Adler-32校验解压结果。整条管线决定了PNG的无损特性也决定了它的压缩率天花板。很多人在写PNG解码器时最容易忽略的就是过滤这一层。你光调用zlib.decompress把IDAT解出来拿到手的并不是干净的像素数据而是每条扫描线前面多了一个filter type字节的“中间数据”。必须按行做反过滤才能还原出真正的RGB值。1.2 为什么偏偏是PNG和JPEG、GIF的取舍对比不是所有场景都适合用PNG但很多场景你绕不开它。和JPEG比PNG是无损的适合存界面切图、图标、文字截图、科学图表这些边缘锐利的内容。JPEG在低质量参数下会产生振铃效应和色块模糊文字直接糊掉PNG则能逐像素还原。和GIF比PNG支持真彩色color type 2/6GIF只有8位索引色最多256色。PNG还支持8位alpha通道做半透明渐变GIF只能做1比特透明边缘锯齿严重。这也是为什么现在网页上的透明图几乎全是PNG。不过PNG也不是没缺点。它不像JPEG那样可以自由调质量系数来换体积也不像WebP/AVIF那样有先进的预测编码和熵编码。同样是照片PNG压缩率远不如JPEG同样的UI图经过有损压缩的WebP常常能比PNG小一半以上。在设计图片管线时我一般遵循一个简单规则照片、复杂渐变色JPEG或WebP。图标、UI控件、验证码、带alpha的贴图PNG。动图或小动画GIF已淘汰优先考虑WebP/APNG。需要逐像素处理的纹理图集PNG或压缩纹理格式。1.3 应用场景盘点网页、游戏、嵌入式哪里都能看到它PNG的覆盖面非常广。网页端有data:image/png;base64这种内联图片游戏引擎里PNG是纹理导入的通用中间格式嵌入式设备上PNG解码更是家常便饭。做嵌入式开发的同行肯定深有体会一个1024x1024的RGBA PNG解码后的原始数据要占4MB内存这还没算解码器自身的行缓冲和zlib窗口。很多MCU连1MB的RAM都拿不出来所以在ESP32S3上解PNG不能整帧解码必须按扫描线流式处理用完一行丢一行。这些具体做法我在后面第5节展开。2. PNG文件结构拆解从文件头到像素2.1 魔数与IHDR数据块第一眼识别PNG任何PNG文件的开头8个字节是固定的签名89 50 4E 47 0D 0A 1A 0A。这个签名有大用第一个字节0x89把文件标记为二进制避免被当成文本处理中间的PNG三个字母是格式标识末尾的0D 0A 1A 0A是DOS时代的遗留用来防止老系统对文件做换行转换。签名之后是一串chunk数据块。每个chunk的结构非常规整一共四段字段长度说明Length4字节后面数据段的字节数大端序Type4字节块类型ASCII字符如IHDR、IDAT、IENDDataLength字节实际数据CRC4字节对Type和Data算出的CRC-32校验值第一个chunk固定是IHDR长度一定为13字节。IHDR里依次存放width4字节图像宽度height4字节图像高度bit depth1字节每个通道的位数1/2/4/8/16color type1字节颜色类型0灰度2真彩3索引色4灰度alpha6真彩alphacompression method1字节当前规范固定为0表示DEFLATEfilter method1字节当前规范固定为0表示前面提到的五种过滤算法interlace method1字节0为逐行扫描1为Adam7隔行扫描写解码器时IHDR是最先要解析的。位深和颜色类型共同决定了每个像素占几个字节后续反过滤和位深抽取都依赖这个信息。这里我想强调一个容易踩的坑很多人只处理8位RGBA遇到16位深度PNG就懵了。PNG的16位可不是简单的数值翻倍它要求网络序的16位大端且解码后很多场景要降位深到8位用需要做位移而不是直接截断否则会偏色。2.2 IDAT数据流过滤、压缩、分块的三层包装IHDR之后图像的实际像素数据存放在一个或多个IDAT块里。IDAT从外到内是三层嵌套把所有IDAT的Data部分按顺序拼接起来。拼接后的整段数据是一个zlib数据流内部包含2字节的zlib头、一个DEFLATE压缩数据段和4字节的Adler-32校验值。解压后得到的原始字节流才是我们要逐行处理的“过滤后扫描线数据”。每个扫描线在压缩前第一个字节是这条线用到的filter type后面跟着一行像素。宽度为w、每像素字节数为bpp的图每行的长度是1 w * bpp。设计上为什么要把IDAT拆成多个块两个原因一是方便流式传输和渐近显示每拿到一块就能解一块二是回退兼容某些老系统对chunk大小的限制。实际解码时你不需要关心它分了几块全部拼起来再解就行。我看到过有的新手把多个IDAT分别zlib解压然后拼结果结果全是乱码。记住一句话IDAT只有拼接后整体解压才是对的。2.3 Ancillary数据块调色板、透明与文本信息除了关键的IHDR、IDAT、IENDPNG还有一批辅助块PLTE调色板索引色必须要有真彩色图里也能附带一个建议调色板解码时通常忽略。tRNS透明信息。索引色图里指定哪些索引是透明的真彩/灰度图里指定一个颜色值作为全透明色。gAMA、sRGB、iCCP颜色管理信息。这决定了PNG显示时要不要做色域转换。tEXt、zTXt、iTXt文本元数据可以放作者、注释、生成软件等信息。pHYs像素密度单位可以是像素/米Web上经常是3780即96DPI。大多数解码器只在处理索引色和透明时用PLTE、tRNS颜色管理相关的块直接丢弃。但做高保真图像工具时千万不能忽略gAMA和sRGB否则在不同设备上偏色问题会非常明显。2.4 CRC校验为什么PNG能自查完整性每个chunk末尾的CRC-32是对TypeData整体计算的校验值。PNG使用的CRC-32多项式和标准IEEE 802.3一样即0xEDB88320。CRC校验在解码时是防御性的。从文件读取或网络下载的PNG很容易被截断、改动如果在解析IHDR、IDAT前先算一遍CRC能提前发现损坏。SD卡读取偶尔会跳字节尤其是在嵌入式场景没有文件系统校验光靠CRC就能救回不少问题。有两点要注意即使某个IDAT块CRC失败也不一定代表整个图片报废。多数解码库的策略是抛出警告、继续尝试解压靠后面的zlib校验兜底。自己写解码器时必须对每个chunk做CRC检查吗不一定。很多场景比如游戏资源预烘焙源文件可信可以跳过CRC以换取性能。但稳妥起见调试模式下最好打开。3. 核心算法原理过滤与Deflate解码3.1 五种滤波器把像素差变成零PNG的无损压缩靠两步先“去相关”再用DEFLATE压缩。去相关靠的就是扫描线过滤器。所谓过滤并不像PS里那种模糊/锐化滤镜。它是用一个可逆的差分变换把“像素的绝对值”变成“像素和相邻像素的差值”。平滑的图像里相邻像素很接近差值大量为0或很小的数DEFLATE压缩这种数据时能把冗余榨得干干净净。PNG规范定义了五种filter type值名称公式对当前字节X说明0NoneX raw不处理1SubX raw - AA是左边同通道像素2UpX raw - BB是上一行同位置像素3AverageX raw - floor((AB)/2)取左和上的平均4PaethX raw - Paeth(A,B,C)用左、上、左上做线性预测编码器可以根据每行统计结果选一个最优filter也可以全图统一用一个。解码端的核心就是反着算把存进去的差值X加上预测值还原出raw。这里得引入一个概念bpp。bpp不是bit per pixel而是每像素占的字节数除以位深换算后的“每像素通道字节数”。对8位深度color type 0灰度的bpp1color type 2真彩的bpp3color type 6RGBA的bpp4。但对16位深度bpp要乘以2对1/2/4位深bpp按1算因为过滤是按字节做的不是按像素做的。filter计算时左边的像素是指X - bpp位置的那个字节这个非常容易搞错。比如RGBA图R通道的“左边像素”不是前一行的R而是本行前面第4个字节的R。如果bpp算错了整张图直接花掉。3.2 Adam7隔行扫描小图先看的秘密PNG支持interlace method为1的Adam7隔行扫描。它把图像分成7个pass每个pass只处理一部分像素按一定规律填到目标位置。解码器要先把每pass的数据解出来填充到最终像素矩阵的对应坐标。Adam7的详细步进规则如下列优先行列索引从0开始Pass起始行行步长起始列列步长10808208483480440424524026021271201每个pass里的扫描线长度不是原始宽度而是该pass覆盖的像素数。比如宽度为100的图pass 1只处理ceil(100/8)13列。Adam7的好处是网络传输时可以先渲染个1/8大小的草图再逐步变清晰。缺点是压缩率比逐行扫描略低解码也复杂不少。我的建议是如果是自己写的解码器且不需要渐近显示直接拒绝interlace为1的PNG报个unsupported interlace就行。绝大多数PNG导出工具都是逐行的遇到隔行图要么用库处理要么先转换工具搞成非隔行。3.3 解码时如何正确还原像素unfilter流程解码器里最关键的就是unfilter。每个pass或整图逐行处理每行的第一个字节告知filter类型之后才是本行数据。要还原当前行需要两个缓冲当前行line和上一行prev。伪代码思路如下for each scanline: filter_type raw[pos]; pos 1 line raw[pos : pos stride]; pos stride for i in range(stride): a line[i - bpp] if i bpp else 0 b prev[i] c prev[i - bpp] if i bpp else 0 if filter_type 0: value line[i] elif filter_type 1: value line[i] a elif filter_type 2: value line[i] b elif filter_type 3: value line[i] (a b) // 2 elif filter_type 4: value line[i] paeth_predictor(a, b, c) line[i] value 0xFF output line prev linepaeth_predictor的计算逻辑是def paeth_predictor(a, b, c): p a b - c pa abs(p - a) pb abs(p - b) pc abs(p - c) if pa pb and pa pc: return a elif pb pc: return b else: return c注意所有运算都要在 0xFF内进行因为差值可能为负还原时用模256加法正好对应原始字节。写反过滤时最容易犯的错是把当前行的修改值直接影响下一字节的计算。以Sub为例还原line[i]后line[i]就是原始值后面line[ibpp]计算a时用的是已还原的原始值而不是差值。代码里就必须原地修改line数组而不是另存一个raw数组。4. 从零写一个PNG解码器Python实现4.1 解析IHDR并检查关键参数下面给出一个支持8位、非隔行、color type 0/2/6的PNG解码器核心代码。这是我的实操版本为了可读性做了简化但完整可运行。import struct import zlib class PNGDecodeError(Exception): pass def decode_png(data: bytes): signature b\x89PNG\r\n\x1a\n if data[:8] ! signature: raise PNGDecodeError(not a png file) pos 8 width height bit_depth color_type interlace None idat_data b while pos len(data): length struct.unpack(I, data[pos:pos 4])[0] ctype data[pos 4:pos 8] payload data[pos 8:pos 8 length] if ctype bIHDR: (width, height, bit_depth, color_type, _, _, interlace) struct.unpack(IIBBBBB, payload) if bit_depth ! 8: raise PNGDecodeError(only bit_depth8 supported) if color_type not in (0, 2, 6): raise PNGDecodeError(only color_type 0/2/6 supported) if interlace ! 0: raise PNGDecodeError(interlace not supported) elif ctype bIDAT: idat_data payload elif ctype bIEND: break pos 12 length if width is None or height is None: raise PNGDecodeError(missing IHDR) channels {0: 1, 2: 3, 6: 4}[color_type] return width, height, channels, idat_data这段代码只负责把文件结构拆开后面再接解压和反过滤。4.2 提取IDAT并调用zlib解压IDAT拼接后直接交zlib解压def decode_raw(idat_data: bytes) - bytes: return zlib.decompress(idat_data)如果数据流损坏zlib会抛异常。注意zlib.decompress默认就处理zlib头不要用wbits-15那种raw deflate模式除非你清楚自己在干什么。有些库为了省2字节zlib头会存raw deflate但PNG规范强制要求zlib包装所以直接用默认就好。4.3 逐行执行反过滤得到raw数据后按前面说的unfilter流程处理。def unfilter(raw: bytes, width: int, channels: int, height: int) - bytes: stride width * channels bpp channels out bytearray() prev bytearray(stride) pos 0 for _ in range(height): filter_type raw[pos] pos 1 line bytearray(raw[pos:pos stride]) pos stride if filter_type 0: pass elif filter_type 1: for i in range(stride): a line[i - bpp] if i bpp else 0 line[i] (line[i] a) 0xFF elif filter_type 2: for i in range(stride): line[i] (line[i] prev[i]) 0xFF elif filter_type 3: for i in range(stride): a line[i - bpp] if i bpp else 0 line[i] (line[i] ((a prev[i]) 1)) 0xFF elif filter_type 4: for i in range(stride): a line[i - bpp] if i bpp else 0 b prev[i] c prev[i - bpp] if i bpp else 0 p a b - c pa abs(p - a) pb abs(p - b) pc abs(p - c) if pa pb and pa pc: pr a elif pb pc: pr b else: pr c line[i] (line[i] pr) 0xFF else: raise PNGDecodeError(funknown filter type: {filter_type}) out line prev line return bytes(out)关于bpp有个细节当前代码里bpp等于通道数是因为位深固定为8。如果将来支持16位bpp要变成channels * 2。如果支持8位以下的灰度或索引色bpp仍是1因为过滤按字节进行而后续要从字节里拆出多个像素。这里一定要想清楚。4.4 小端/大端、位深与颜色类型处理PNG所有多字节数值都是大端序这在解析IHDR和IDAT块长度时要注意。Python的struct.unpack(I, ...)就是大端。如果从某个单片机读取数据也要优先用大端解析函数。对于16位PNG常见做法是取高8位作为8位结果即value 8而不是value 0xFF。因为16位数值范围0-65535映射到0-255时应该除以257或右移8位右移8位在精度上等价于除以256虽然和标准转换v/257有微小误差但实现简单、肉眼无法分辨很多库直接用右移。索引色color type 3则要先读PLTE获取调色板再按8位索引查表。如果带tRNS还要额外输出alpha。PLTE的每个条目是3字节RGB调色板长度最多256项。5. 解码库选型libpng、stb_image、lodepng怎么选5.1 三款主流解码库的定位差异自己写解码器是一回事实际工程里大多数人用现成库。我的经验是不同场景选不同库别迷信某一个。libpng最正统功能最全支持隔行、16位、颜色管理但也最重。编译需要依赖zlib。适合PC端工具链、需要完整PNG特性的项目。stb_image单头文件库moodycamel的stb全家桶之一。简单粗暴调用一个函数就能得到解码后的像素数组支持JPEG/PNG/WebP等多种格式。缺点是错误信息少、内存分配不太可控。适合做原型验证、小工具、游戏资源导入。lodepng单文件C库用起来也很方便特性偏向PNG本身支持编码和解码解码速度不错不依赖外部库还能自定义内存分配器。嵌入式场景里很流行。库依赖特性完整度适合场景libpngzlib高支持几乎所有PNG特性PC工具、完整PNG处理stb_image无中只解常用格式快速集成、小工具lodepng无中专注PNG可裁剪嵌入式、对体积敏感选型关键还是看目标平台和资源约束。PC上无所谓嵌入式上每多一个依赖flash开销和内存峰值都要重新评估。5.2 嵌入式场景ESP32S3上解码PNG的资源瓶颈现在很多智能设备用ESP32S3做屏幕UIPNG解码是绕不开的话题。S3有大概512KB左右的SRAM部分在PSRAM上。一张320x240的RGBA图解码后就要320x240x4300KB加上zlib窗口和行缓冲直接逼近极限。我常用的做法是改造解码流程为“流式解码”不要把整个IDAT一次性读进内存。从文件系统分块读取喂给inflate。解码器每解出一行立即把这一行的像素交给显示驱动或LVGL的绘图函数。行缓冲可以复用解码下一行时覆盖上一行。需要整帧保存的场景比如转成RGB565的framebuffer只保留最终framebuffer中间不额外存一份RGBA。这样内存峰值基本就是一行的字节数 inflate窗口 framebuffer小图几百字节大图也能用PSRAM兜底。再有一个优化是直接用RGB565的PNG吗不行PNG格式不支持RGB565得解码完再转换。如果你对性能有硬要求建议在工具链阶段把图片转成合适的压缩纹理格式或者raw RGB565而不是在芯片上解码PNG。只把PNG当作“中间格式”最终发布资源时导出为烧录友好的格式这一步对项目稳定性帮助非常大。5.3 库选择之外base64、WebP、APNG等周边问题日常开发里还会遇到几个和PNG关系密切的场景data:image/png;base64这其实是在HTML/CSS里内嵌PNG的一种方式。base64会把二进制体积放大4/3但省去一次HTTP请求。对于小于几KB的小图标很划算大图不要这么干。解码时先base64解码成bytes再按普通PNG处理即可。APNGPNG的动图扩展本质是PNG加了一堆acTL、fcTL、fdAT块。普通PNG解码器会抱怨遇到了未知块但规范要求遇到未知辅助块应该跳过。所以APNG在普通PNG解码器里能显示第一帧这是“优雅降级”设计。WebPGoogle出的有损/无损格式无损模式压缩率通常优于PNG但解码复杂度和CPU占用都在PNG之上。如果只是做Web前端WebP确实香如果做桌面软件和嵌入式PNG的兼容性和生态才是第一位的。6. 实战中遇到的坑问题排查与记录6.1 报错failed to resolve import ../assets/grenade (1024x128)[frames8].png这个报错看起来像某个游戏引擎或打包工具在处理精灵图序列时的报错。括号里的1024x128和frames8非常关键这是一张包含8帧动画的精灵表单帧尺寸是128x1288帧排成一排就是1024x128。这类报错的根因通常有三类文件路径或资源清单没写对引擎解析不到文件。精灵图的尺寸和预期的帧数不匹配。比如代码里写死“8帧”但实际图片只有6帧或分成了8个独立文件导致越界解析。PNG本身损坏或格式不被工具链支持比如带隔行扫描、16位深、带颜色配置文件的PNG某些引擎导入器不认。从PNG编解码的角度这类超宽PNG在解码时并没有特殊限制宽度只要不超过IHDR里的uint32就行。真正要排查的是引擎在把PNG解码成像素后按什么规则切出帧来。如果是按固定尺寸切宽高必须能整除帧尺寸否则解析出来的帧全是错位的。如果你也在做资源导入工具建议在解析PNG后主动校验这些元信息帧宽x帧数是否等于总宽度、图像高度是否匹配单帧高度、每个帧区域是否越界。把校验逻辑放在解码器外面从源头把错误拦截下来。6.2 PNG图片在浏览器中显示出错的常见原因浏览器在渲染PNG时对格式的容错性相对严格。常见问题有误改后缀把JPG改成.png浏览器和多数解码器会通过内容判断真实格式直接拒绝解码。IHDR信息异常图像宽度为0、宽度和实际IDAT数据不匹配都会导致解码失败。截断的IDAT下载不完整或者复制时丢字节浏览器往往不显示图而是显示一个破碎的图标。CRC不匹配多数浏览器在CRC不对时会把图片标记为损坏只有部分解码器选择容忍。调试办法是把文件拖进Python环境按照第4节写的代码解析一遍看在哪一步抛异常。如果zlib.decompress都过不了就是数据不完整或拼接错了如果解压后unfilter报错或图像花屏就检查filter type和bpp。6.3 png转dwg这类特殊需求该怎么理解搜“png转dwg”的人很多是CAD场景想把扫描的图纸转成可编辑的矢量文件。这个需求和PNG编解码本身不太一样它本质上是“位图矢量化”要经过轮廓提取、曲线拟合、几何实体识别等步骤。如果你只是想快速把一张清晰的图纸转dwg建议用专门的矢量化工具比如先用图像处理把PNG转成二值图去除噪点和杂色再用轮廓提取算法如OpenCV的findContours得到线条路径最后把路径导出成DXF文件再转成dwg这个过程涉及的算法和PNG解码关系不大但底层还是要把PNG解码成像素才能做图像分析。所以看懂PNG的解码流程是一切图像处理的地基。给想深入的人几个建议如果你对PNG的理解只停留在“调库解码”我强烈建议你按第4节的思路自己写一个迷你解码器不需要支持16位和隔行先把8位RGBA跑通。这个过程会让你真正理解IHDR、IDAT、过滤和CRC之间的关系。写完之后再去做一个编码器也就是把原始像素数组编码成PNG文件难度比解码更大因为你还要选每行的最优filter策略。常见的启发式是逐行试一遍5种filter算出编码后的字节数选最小的一个。这个“最小化”策略对压缩率影响很明显有空可以折腾一下。我个人在实际编码PNG时最后还有一个小技巧如果图片里大块纯色区域很多filter type 1Sub通常效果最好如果垂直渐变或者带文字type 4Paeth往往更优如果完全不知道选哪个逐行枚举5种再取最小值虽然慢一点但压缩率最稳。后期如果对速度有要求再用启发式替代全枚举。这些是你反复压缩资源、对比不同工具输出大小时才摸得到的门道。
返回列表