返回文章列表

第十三章|基础专题四:PNG 底层原理

从 PNG Signature、Chunk、扫描线、Filter、zlib、DEFLATE、Alpha、颜色管理、Canvas 和 WebCodecs ImageDecoder 理解 PNG 的无损压缩链路。

第十三章|基础专题四:PNG 底层原理

原稿学习节奏:第 4 周。

建议学习时长:8~10 小时 前置知识: 像素与颜色、Packed/Planar、信息熵、预测与残差、Huffman 编码 本周主线: 先把二维像素序列化为扫描线,再用可逆过滤降低相邻字节相关性,最后用 zlib 封装的 DEFLATE 压缩,并通过 Chunk 组织成可扩展、可校验的 PNG 码流。


本周安排

天数主题建议时长当日输出
Day 1PNG 的定位、Signature、Chunk 布局1 小时能手工识别 PNG 文件和 Chunk
Day 2IHDR、颜色类型、位深、扫描线1.5 小时能计算每行字节数并解释像素打包
Day 3None、Sub、Up、Average、Paeth2 小时能手算过滤与反过滤
Day 4zlib、DEFLATE、LZ77、Huffman1.5 小时能画出过滤后数据进入 DEFLATE 的路径
Day 5Alpha、tRNS、颜色管理、Adam7、APNG1.5 小时能解释透明黑边和渐进显示
Day 6浏览器解码、Canvas、Worker、性能与安全1.5 小时完成一个 PNG Chunk 解析器
Day 7面试题、练习与复盘1 小时独立完成本周验收题

一、学习目标

学完本周后,你必须能够:

  1. 准确区分 PNG 图像格式、zlib 数据格式、DEFLATE 压缩算法和具体编码器实现
  2. 从十六进制字节中识别 PNG Signature、IHDR、IDAT 和 IEND。
  3. 解释一个 Chunk 的 Length + Type + Data + CRC 结构。
  4. 说明 PNG 为什么是无损格式,但不同编码器生成的文件大小仍然不同。
  5. 根据 color typebit depth 和宽度计算扫描线字节数。
  6. 手算 None、Sub、Up、Average 和 Paeth 过滤结果,并完成反过滤。
  7. 解释过滤为什么能改善 DEFLATE 的压缩效果,但不会直接减少字节数。
  8. 解释 LZ77 和 Huffman 在 DEFLATE 中各自负责什么。
  9. 区分 Chunk CRC 与 zlib 尾部 Adler-32 的校验范围。
  10. 解释 PLTEtRNS、显式 Alpha 通道和 PNG 的 straight alpha。
  11. 排查透明素材经过 Canvas、GPU 合成或导出后出现黑边、白边的问题。
  12. 设计 PNG 在 AI 视频网页和在线 Web 视频编辑器中的正确使用方式。
  13. 说明为什么连续视频帧不应长期使用 PNG 序列作为正式传输格式。
  14. 编写一个安全的 PNG Chunk 解析器和一个扫描线反过滤器。
  15. 对大尺寸 PNG 的解码内存、压缩炸弹、主线程阻塞和资源释放做工程评估。

二、概念地图

2.1 PNG 位于完整媒体系统的哪一层

原始像素
RGB / RGBA / 灰度 / 调色板索引

扫描线序列化

PNG Filter
None / Sub / Up / Average / Paeth

zlib 数据格式

DEFLATE
LZ77 + Huffman

PNG Chunk
IHDR / PLTE / IDAT / IEND / 辅助 Chunk

PNG 字节流

浏览器或图像库解码

RGBA 像素、纹理或 Canvas 内容

2.2 必须严格区分的层次

层次PNG 场景中的例子关键结论
原始像素RGBA8888、灰度 8-bit尚未压缩,通常占用大量内存
图像编码格式PNG定义文件结构、颜色类型、过滤、压缩与解码规则
压缩算法DEFLATE通用无损压缩,组合 LZ77 与 Huffman
压缩封装zlib在 DEFLATE 外增加头部和 Adler-32
编码器实现libpng、浏览器编码器、图像工具决定过滤器选择、压缩级别和搜索策略
基础码流PNG datastreamSignature 后跟多个 Chunk
容器PNG 本身不是 MP4 式多轨容器它组织一幅静态图或 APNG 动画,不管理音视频轨道
浏览器 API<img>createImageBitmapImageDecoder、Canvas负责加载、解码、渲染和像素访问

2.3 四个常见误区

  • PNG 不是“把 RGBA 直接丢给 ZIP”。 在 DEFLATE 前还有颜色表示、扫描线组织和 Filter。
  • DEFLATE 不等于 zlib。 DEFLATE 是压缩数据格式;zlib 是包含 DEFLATE 数据和校验值的封装。
  • PNG 无损不等于每次保存字节完全相同。 像素可完全恢复,但 Chunk、过滤策略、压缩块和元数据可以不同。
  • PNG 支持透明不等于内部使用预乘 Alpha。 PNG 文件中的颜色样本使用非预乘,也称 straight/unassociated alpha。

三、直觉解释

3.1 PNG 的核心思想

PNG 的核心可以概括为:

保留所有像素信息
        +
把相邻像素改写成更小、更重复的差值
        +
用通用无损压缩器压缩这些差值

假设一行灰度像素是:

100, 101, 102, 103, 104, 105

原始数据中的数值都接近 100,但并不完全相同。若使用 Sub 过滤器,保存“当前值减左边值”:

100, 1, 1, 1, 1, 1

过滤后的字节数没有减少,仍然是 6 个字节;但出现了大量重复的 1,这更容易被 LZ77 找到重复串,也更容易形成偏斜的符号概率供 Huffman 编码。

解码时只需做累加:

100
100 + 1 = 101
101 + 1 = 102
...

因此过滤是完全可逆的。

3.2 为什么 UI、图标和文字截图适合 PNG

这类图像通常具有:

  • 大面积纯色;
  • 重复的水平或垂直边缘;
  • 有限的颜色数量;
  • 需要精确保留文字边缘;
  • 可能需要透明度。

过滤后会产生大量 0、小差值和重复模式,DEFLATE 很容易压缩。

3.3 为什么照片的 PNG 通常很大

照片包含:

  • 传感器噪声;
  • 纹理;
  • 光照渐变;
  • 大量细微颜色变化;
  • 很少完全重复的局部字节序列。

即使过滤能减少相邻差异,PNG 仍必须无损保存所有细节。JPEG 则允许通过量化丢弃视觉不敏感的信息,所以照片通常更适合 JPEG、AVIF 或视频编码,而不是 PNG。

3.4 为什么“同一张图”可以有不同大小的 PNG

像素相同并不意味着编码决策相同。编码器可以选择:

  • 真彩色还是调色板;
  • 每行使用哪一种 Filter;
  • DEFLATE 的块划分方式;
  • 静态、固定或动态 Huffman 表;
  • LZ77 匹配搜索深度;
  • 压缩级别;
  • 是否保留文本、ICC、Exif 等元数据;
  • IDAT 切成多少个 Chunk。

这些选择都不改变最终解码像素,却会改变文件大小和编码时间。


四、数学原理

4.1 扫描线字节数

设:

  • (W):图像宽度,单位为像素;
  • (H):图像高度;
  • (B_{pp}):每像素总 bit 数;
  • (S):每条未过滤扫描线的字节数。

则:

[ S=\left\lceil\frac{W\cdot B_{pp}}{8}\right\rceil ]

非 Adam7 PNG 在进入 DEFLATE 前,每行还会增加 1 个 Filter Type 字节,因此总过滤数据大小为:

[ N_{\text{filtered}}=H\cdot(S+1) ]

手算例子

一张 1920×1080、8-bit RGBA PNG:

[ B_{pp}=4\times 8=32 ]

[ S=\frac{1920\times 32}{8}=7680\text{ bytes} ]

[ N_{\text{filtered}}=1080\times(7680+1)=8{,}295{,}480\text{ bytes} ]

其中 1080 个额外字节是每条扫描线前的 Filter Type。

注意:这是 进入 DEFLATE 前 的数据量,不是最终 PNG 文件大小。


4.2 过滤器中的邻居

对当前待处理字节 (x),定义:

c  b
a  x
  • (a):当前扫描线中,距离 (x) 一个完整像素的左侧字节;
  • (b):上一扫描线同一位置的字节;
  • (c):上一扫描线左上位置的字节;
  • (bpp):Filter 使用的“每完整像素字节数”,不足 1 字节时按 1 处理;
  • 缺失的邻居视为 0。

过滤运算都在字节域内进行,最终结果取模 256。


4.3 None

[ F(x)=x ]

不做预测,直接输出原字节。

适合:

  • 已经难以预测的数据;
  • 低位深调色板图;
  • 编码速度优先的场景。

4.4 Sub

[ F(x)=(x-a)\bmod 256 ]

反过滤:

[ x=(F(x)+a)\bmod 256 ]

手算

原始扫描线:

10, 13, 15, 20

灰度 8-bit 时 (bpp=1)。

第 1 字节:10 - 0  = 10
第 2 字节:13 - 10 = 3
第 3 字节:15 - 13 = 2
第 4 字节:20 - 15 = 5

结果:

10, 3, 2, 5

Sub 擅长利用水平方向相关性。


4.5 Up

[ F(x)=(x-b)\bmod 256 ]

反过滤:

[ x=(F(x)+b)\bmod 256 ]

手算

上一行:

10, 20, 30, 40

当前行:

12, 22, 33, 43

过滤后:

2, 2, 3, 3

Up 擅长利用垂直方向相似性。


4.6 Average

[ F(x)=\left(x-\left\lfloor\frac{a+b}{2}\right\rfloor\right)\bmod 256 ]

反过滤:

[ x=\left(F(x)+\left\lfloor\frac{a+b}{2}\right\rfloor\right)\bmod 256 ]

手算

若:

  • (x=105)
  • (a=100)
  • (b=96)

则:

[ \left\lfloor\frac{100+96}{2}\right\rfloor=98 ]

[ F(x)=105-98=7 ]


4.7 Paeth

先计算线性预测:

[ p=a+b-c ]

再计算:

[ p_a=|p-a|,\quad p_b=|p-b|,\quad p_c=|p-c| ]

选择距离 (p) 最近的 (a)、(b) 或 (c) 作为预测值。相等时按规范规定的顺序优先选择。

手算

若:

  • (a=100)
  • (b=90)
  • (c=80)
  • (x=104)

则:

[ p=100+90-80=110 ]

[ p_a=10,\quad p_b=20,\quad p_c=30 ]

因此预测值为 (a=100):

[ F(x)=104-100=4 ]

Paeth 可以理解为:用左、上、左上估计局部平面趋势,再选择最可信的实际邻居。


4.8 Filter 是“相关性变换”,不是压缩

设原数据长度为 (n),过滤后仍有 (n) 个数据字节,另加每行 1 个 Filter Type 字节。过滤本身甚至略微增加数据量。

它的目标是改变符号分布:

原始:100 101 102 103 104
过滤:100   1   1   1   1

过滤后:

  • 零和小残差更多;
  • 重复串更多;
  • Huffman 符号概率更集中;
  • LZ77 更容易引用历史数据。

真正减少字节数的是后续 DEFLATE。


4.9 LZ77 的“距离 + 长度”

LZ77 在历史窗口中查找重复串。

例如输入:

1, 2, 3, 1, 2, 3, 1, 2, 3

前三个字节可作为 literal 输出。后六个字节可以表示为类似:

距离 = 3
长度 = 6

直觉是:“接下来的 6 个字节,与当前位置向前 3 个字节开始的内容相同。”

DEFLATE 的 LZ77 历史窗口最大为 32 KiB。编码器搜索得越充分,通常压缩率越高,但编码更慢。


4.10 Huffman 编码

若过滤后的符号分布为:

符号概率
060%
120%
25510%
其他10%

Huffman 会给高概率符号较短码字。PNG 编码器可以让 DEFLATE 使用:

  • 不压缩块;
  • 固定 Huffman 表;
  • 动态 Huffman 表。

这是 DEFLATE 的选择,不是 PNG Filter 的选择。


4.11 Adler-32

PNG 的 IDAT 数据使用 zlib 格式。zlib 尾部的 Adler-32 校验 解压后的数据

初始化:

[ s_1=1,\quad s_2=0 ]

对每个输入字节 (d_i):

[ s_1=(s_1+d_i)\bmod 65521 ]

[ s_2=(s_2+s_1)\bmod 65521 ]

最终:

[ \text{Adler32}=(s_2\ll16)+s_1 ]

手算 [1, 2, 3]

步骤(s_1)(s_2)
初始10
读 122
读 246
读 3713

结果:

0x000D0007

4.12 Chunk CRC

每个 PNG Chunk 都有 CRC。CRC 的计算范围是:

Chunk Type + Chunk Data

不包含 Length。

因此:

Length | Type | Data | CRC
          └─────┬─────┘
             CRC 范围

CRC 检查 Chunk 层损坏;Adler-32 检查 zlib 解压数据。二者层次不同,不能混为一谈。


4.13 Alpha 合成

PNG 文件使用 straight alpha。设:

  • 前景颜色为 (C_f);
  • 背景颜色为 (C_b);
  • 归一化 Alpha 为 (\alpha\in[0,1]);
  • 输出颜色为 (C_o)。

简单不透明背景下:

[ C_o=\alpha C_f+(1-\alpha)C_b ]

手算

50% 红色前景覆盖蓝色背景:

[ C_f=(255,0,0),\quad C_b=(0,0,255),\quad \alpha=0.5 ]

[ C_o\approx(128,0,128) ]

工程上,严格颜色合成还应考虑传递函数和颜色空间;不能把所有场景都当成未管理的 sRGB 字节直接线性运算。


五、底层数据结构

5.1 PNG Signature

PNG 字节流固定以 8 字节开始:

89 50 4E 47 0D 0A 1A 0A

含义可以直观理解为:

  • 89:避免被误认为纯 ASCII 文本;
  • 50 4E 47:ASCII "PNG"
  • 0D 0A:CRLF;
  • 1A:历史文本传输环境中的结束标记;
  • 0A:LF。

解析器应先验证完整 Signature,再进入 Chunk 循环。


5.2 Chunk 通用布局

+-------------------+
| Length    4 bytes |
+-------------------+
| Type      4 bytes |
+-------------------+
| Data      N bytes |
+-------------------+
| CRC       4 bytes |
+-------------------+
  • 多字节整数使用网络字节序,即大端序。
  • Length 只表示 Data 长度。
  • TypeData 参与 CRC。
  • 即使 Data 为空,CRC 仍存在。
  • 解析时必须先做整数溢出和剩余长度检查,不能直接信任 Length。

Type 四个字母中的属性位

Chunk Type 的四个 ASCII 字母大小写具有语义:

字节位置大写小写
第 1 字节Critical,关键Ancillary,辅助
第 2 字节Public,公开Private,私有
第 3 字节Reserved 位正确,必须大写保留位被设置,不应出现在标准流中
第 4 字节Unsafe to copySafe to copy

例子:

  • IHDR:关键、公开、保留位正确。
  • tEXt:辅助、公开、保留位正确、安全复制。
  • 未识别的关键 Chunk 通常意味着无法正确解码。
  • 未识别的辅助 Chunk 可按规则跳过,但编辑器是否能保留它还受 safe-to-copy 位影响。

5.3 关键 Chunk

IHDR

必须是第一个 Chunk,Data 长度固定为 13 字节:

Width              4 bytes
Height             4 bytes
Bit depth          1 byte
Color type         1 byte
Compression method 1 byte
Filter method      1 byte
Interlace method   1 byte

目前标准公开定义:

  • Compression method:0
  • Filter method:0
  • Interlace method:01

PLTE

调色板,每项 3 字节:

R G B

最多 256 项。

  • Color type 3 必须有 PLTE。
  • Color type 0、4 不允许有 PLTE。
  • Color type 2、6 可把 PLTE 作为建议调色板,但不是像素真实索引表。

IDAT

保存 zlib 压缩数据。

多个 IDAT:

  • 必须连续出现;
  • Data 字段按顺序直接拼接;
  • 拼接后形成一个 zlib 数据流;
  • 不能把每个 IDAT 当成独立 zlib 流。

IEND

结束 Chunk,Data 长度为 0。IEND 后不得再有 PNG 内容。


5.4 Color Type 与 Bit Depth

Color type图像类型通道允许 Bit depth
0灰度G1、2、4、8、16
2真彩色RGB8、16
3索引色Palette Index1、2、4、8
4灰度 + AlphaGA8、16
6真彩色 + AlphaRGBA8、16

Bit depth 表示:

  • 非索引图:每个样本的位数;
  • 索引图:每个调色板索引的位数;
  • 不是总的“每像素位数”。

例如:

  • Color type 6、Bit depth 8:每像素 (4\times8=32) bit。
  • Color type 2、Bit depth 16:每像素 (3\times16=48) bit。
  • Color type 3、Bit depth 4:每像素 4 bit,即每字节可存 2 个索引。

5.5 扫描线打包

非交错图像按:

从上到下逐行
每行从左到右

低于 8-bit 的灰度或索引样本会紧密打包,并把最左样本放在高位。

例如 1-bit 索引:

像素:1 0 1 1 0 0 1 0
字节:10110010

每条扫描线独立从字节边界开始,行尾未使用的低位不属于下一行。

过滤后结构:

Filter Type | Filtered Scanline Bytes
Filter Type | Filtered Scanline Bytes
...

5.6 辅助 Chunk

Chunk作用
tRNS调色板 Alpha 表或单一透明颜色
gAMA图像 Gamma 信息
cHRM色度坐标和白点
sRGB声明标准 sRGB 显示意图
iCCP嵌入 ICC Profile
pHYs物理像素尺寸
tEXtLatin-1 文本
zTXt压缩 Latin-1 文本
iTXtUTF-8 国际化文本
eXIfExif Profile
bKGD建议背景色
tIME最后修改时间

不要把辅助 Chunk 理解为“都可以随便丢”。是否保留取决于:

  • 是否影响颜色和显示;
  • 是否正在修改图像像素;
  • safe-to-copy 位;
  • 产品是否要求保留元数据。

5.7 tRNS 与显式 Alpha

透明度可通过多种方式表示:

  • Color type 4:灰度 + 完整 Alpha;
  • Color type 6:RGBA 完整 Alpha;
  • Color type 3 + tRNS:调色板项对应 Alpha;
  • Color type 0 或 2 + tRNS:指定一个完全透明的灰度值或 RGB 值。

Color type 0/2 的 tRNS 不是完整渐变 Alpha,只能表示“某个颜色全透明,其他颜色全不透明”。


5.8 zlib 与 DEFLATE 在 IDAT 中的位置

IDAT Data 拼接

zlib header

DEFLATE blocks

Adler-32

zlib 不等于 gzip:

  • zlib 有自己的头部与 Adler-32;
  • gzip 有另一套头部与 CRC;
  • PNG IDAT 使用的是 zlib 格式,不是 .gz 文件格式。

5.9 Adam7

Interlace method 1 使用 Adam7 七次扫描。它先传少量稀疏像素形成粗略预览,再逐步补全。

优点:

  • 慢速传输时更早看到全局轮廓;
  • 支持渐进显示。

代价:

  • 编解码更复杂;
  • 过滤按每个 reduced image 的扫描线执行;
  • 通常会略微增加文件大小;
  • 随机访问单个像素仍然不方便。

5.10 APNG

PNG 第三版规范包含动画 PNG 相关 Chunk:

  • acTL:动画控制;
  • fcTL:帧控制;
  • fdAT:后续帧数据。

APNG 还涉及:

  • 帧区域;
  • 延时;
  • disposal;
  • blend;
  • 循环次数。

静态 PNG 解码器通常仍可显示默认图像,但不一定提供动画帧级控制。Web 工程中需要分别验证“能显示动画”和“能逐帧解码”这两种能力。


5.11 一个最小结构示意

89 50 4E 47 0D 0A 1A 0A        PNG Signature

00 00 00 0D                    Length = 13
49 48 44 52                    "IHDR"
... 13 bytes ...               IHDR Data
... 4 bytes ...                CRC

00 00 00 xx                    Length
49 44 41 54                    "IDAT"
... zlib bytes ...             IDAT Data
... 4 bytes ...                CRC

00 00 00 00                    Length = 0
49 45 4E 44                    "IEND"
AE 42 60 82                    常见 IEND CRC

六、编码器流程

6.1 总流程

输入像素

确定颜色类型和位深

可选:量化为调色板

组织扫描线

每行选择 Filter

Filter Type + 过滤后字节

zlib / DEFLATE

切分 IDAT

写 Signature、IHDR、辅助 Chunk、IDAT、IEND

计算每个 Chunk CRC

6.2 第一步:选择表示方式

编码器需要判断:

  • 是否真的需要 Alpha;
  • 是否只有二值透明,可否用 tRNS;
  • 颜色数量是否不超过 256,可否使用索引色;
  • 8-bit 是否足够,还是必须保留 16-bit;
  • 是否保留 ICC、Exif、文本等元数据。

这是编码器实现策略,不是 PNG 标准强制的唯一选择。

调色板收益例子

1000×1000、只有 16 种颜色:

  • RGBA8 原始像素:约 4 MB;
  • 4-bit 索引:约 0.5 MB;
  • PLTE:最多几十字节;
  • 再经过 Filter + DEFLATE,通常还能进一步压缩。

但把照片强行量化为 256 色会改变像素,量化步骤本身是有损的。PNG 仍会无损保存“量化后的像素”。


6.3 第二步:序列化扫描线

伪代码:

for (let y = 0; y < height; y++) {
  const row = packPixels(image[y], colorType, bitDepth);
  rows.push(row);
}

16-bit 样本按大端序写入。低位深灰度和索引值按高位优先打包。


6.4 第三步:选择 Filter

一种常见启发式策略:

  1. 为当前行计算五种过滤结果;
  2. 把输出字节解释为有符号残差;
  3. 计算绝对值之和;
  4. 选择分数最小者。
function score(filtered: Uint8Array): number {
  let total = 0;
  for (const byte of filtered) {
    const signed = byte < 128 ? byte : byte - 256;
    total += Math.abs(signed);
  }
  return total;
}

这只是编码器策略,并非规范要求。真正压缩最优的 Filter 组合与后续 DEFLATE 上下文有关;“残差绝对值最小”只是计算成本较低的近似。


6.5 第四步:DEFLATE

编码器在 DEFLATE 中还要决定:

  • 块边界;
  • 是否存储、不压缩、固定 Huffman 或动态 Huffman;
  • LZ77 匹配长度;
  • 搜索深度;
  • lazy matching;
  • 压缩速度与压缩率的权衡。

“压缩等级 9”不是 PNG 标准字段,而是具体压缩器的策略参数。


6.6 第五步:写 Chunk

推荐顺序的核心约束:

Signature
IHDR
颜色管理、PLTE 等前置 Chunk
连续 IDAT
尾部辅助 Chunk
IEND

每个 Chunk:

  1. 写 Data 长度;
  2. 写 Type;
  3. 写 Data;
  4. 对 Type + Data 计算 CRC;
  5. 写 CRC。

IDAT 可以按编码器缓冲区大小切分,切分位置不改变拼接后的 zlib 流。


七、解码器流程

7.1 总流程

读取 Signature

循环读取 Chunk

检查 Length、Type、CRC 和顺序

解析 IHDR、PLTE、tRNS、颜色信息

拼接 IDAT Data

zlib 解封装 + DEFLATE 解压

逐行读取 Filter Type

反过滤

解包低位深样本 / 调色板查表

应用透明度和颜色处理

Adam7 重组(若启用)

输出 RGB/RGBA 或上传 GPU

7.2 安全读取 Chunk

interface PngChunk {
  type: string;
  data: Uint8Array;
}

function parseChunks(bytes: Uint8Array): PngChunk[] {
  const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];

  if (bytes.length < signature.length) {
    throw new Error("Truncated PNG signature");
  }
  for (let i = 0; i < signature.length; i++) {
    if (bytes[i] !== signature[i]) {
      throw new Error("Invalid PNG signature");
    }
  }

  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
  const chunks: PngChunk[] = [];
  let offset = 8;

  while (offset < bytes.length) {
    if (offset + 12 > bytes.length) {
      throw new Error("Truncated chunk header");
    }

    const length = view.getUint32(offset, false);
    const dataStart = offset + 8;
    const dataEnd = dataStart + length;
    const crcEnd = dataEnd + 4;

    if (length > 0x7fffffff || dataEnd < dataStart || crcEnd > bytes.length) {
      throw new Error("Invalid or truncated chunk length");
    }

    const typeBytes = bytes.subarray(offset + 4, offset + 8);
    const type = String.fromCharCode(...typeBytes);
    const data = bytes.slice(dataStart, dataEnd);

    // 生产代码应计算并验证 typeBytes + data 的 CRC。
    chunks.push({ type, data });
    offset = crcEnd;

    if (type === "IEND") break;
  }

  return chunks;
}

生产级解析器还必须处理:

  • CRC;
  • Chunk 顺序;
  • 重复 IHDR/IEND;
  • 未识别关键 Chunk;
  • IHDR 合法组合;
  • 解码尺寸上限;
  • 元数据长度上限。

7.3 反过滤

function paeth(a: number, b: number, c: number): number {
  const p = a + b - c;
  const pa = Math.abs(p - a);
  const pb = Math.abs(p - b);
  const pc = Math.abs(p - c);
  if (pa <= pb && pa <= pc) return a;
  if (pb <= pc) return b;
  return c;
}

function unfilterRow(
  type: number,
  filtered: Uint8Array,
  previous: Uint8Array | null,
  bpp: number,
): Uint8Array {
  const out = new Uint8Array(filtered.length);

  for (let i = 0; i < filtered.length; i++) {
    const a = i >= bpp ? out[i - bpp] : 0;
    const b = previous ? previous[i] : 0;
    const c = previous && i >= bpp ? previous[i - bpp] : 0;

    let predictor = 0;
    if (type === 1) predictor = a;
    else if (type === 2) predictor = b;
    else if (type === 3) predictor = Math.floor((a + b) / 2);
    else if (type === 4) predictor = paeth(a, b, c);
    else if (type !== 0) throw new Error("Unknown filter type");

    out[i] = (filtered[i] + predictor) & 0xff;
  }

  return out;
}

注意:a 必须来自 已经重建的当前行,不能来自过滤后的当前行。


7.4 解包与颜色展开

反过滤得到的仍可能不是 RGBA:

  • 1/2/4-bit 灰度要拆位并缩放;
  • 索引色要通过 PLTE 查 RGB;
  • tRNS 可能提供 Alpha;
  • 16-bit 样本可能要保留 16-bit,或按输出目标降到 8-bit;
  • Color type 0/2/4/6 的通道布局不同。

解码器不能把“每像素 4 字节”写死。


7.5 Alpha 和颜色处理

正确流程需要明确:

  1. PNG 文件中的 Alpha 是 straight alpha。
  2. 渲染管线可能内部使用 premultiplied alpha。
  3. 转换时要避免 Alpha 为 0 时除零。
  4. 缩放、模糊和插值应避免把透明像素中的不合理 RGB 扩散到边缘。
  5. 颜色合成最好在正确的颜色空间中执行。
  6. iCCPsRGBgAMAcHRM 等信息会影响最终显示。

7.6 流式解码与内存

反过滤至少通常需要:

  • 当前行;
  • 上一行;
  • DEFLATE 状态;
  • Chunk 解析状态。

因此理论上可以流式解码,而不必先把所有未压缩扫描线保存在内存中。

但最终若调用方需要完整 RGBA 位图,仍需:

[ W\times H\times 4 ]

字节或更多。4K RGBA8 一帧约:

[ 3840\times2160\times4=33{,}177{,}600\text{ bytes} ]

约 31.6 MiB,不包括解码器缓冲、GPU 纹理、原文件和中间副本。


八、复杂度与性能

8.1 时间复杂度

设解压后的扫描线字节总量为 (N)。

阶段典型复杂度
Chunk 解析(O(\text{文件字节数}))
CRC 校验(O(\text{Chunk Type + Data}))
DEFLATE 解压近似 (O(N)),具体常数取决于实现
反过滤(O(N))
调色板展开(O(\text{像素数}))
Alpha 合成(O(\text{像素数}))
Adam7 重组(O(\text{像素数}))

编码端若对每行尝试五种 Filter,过滤计算量约为单一 Filter 的 5 倍;高压缩级别的 LZ77 搜索还会显著增加编码时间。


8.2 编码复杂度高于解码复杂度

解码器只需执行码流指定的:

  • DEFLATE 解码;
  • Filter 反变换;
  • 像素展开。

编码器则需要搜索:

  • 颜色表示;
  • 调色板;
  • 每行 Filter;
  • LZ77 匹配;
  • Huffman 块策略。

因此“同样是无损格式”,高质量 PNG 优化器仍可能很慢。


8.3 CPU 与 GPU 分工

典型浏览器管线:

网络 / 文件

CPU:格式解析、DEFLATE、反过滤、颜色展开

系统图像缓冲

GPU:纹理上传、缩放、混合、滤镜、显示

PNG 不像 H.264/H.265 那样普遍依赖专用视频解码硬件。多数性能收益来自:

  • 浏览器原生解码器;
  • SIMD;
  • 减少像素复制;
  • 异步解码;
  • Worker;
  • 合理缓存;
  • 限制超大图。

8.4 并行化限制

  • 单个 zlib 流具有历史依赖;
  • Up、Average、Paeth 依赖上一扫描线;
  • 当前行内 Sub/Paeth 还依赖已重建的左侧字节;
  • 因此单图内部并非任意分块并行。

可行并行方向:

  • 多张独立 PNG 并行;
  • 解析与网络读取流水化;
  • SIMD 加速 Filter;
  • 解码后 GPU 并行处理像素;
  • 缩略图任务分发到 Worker 池。

8.5 常见瓶颈

  1. 大尺寸 RGBA 输出导致内存峰值。
  2. 在主线程用 JavaScript 自己 inflate 和 unfilter。
  3. getImageData() 把 GPU/内部图像复制回 CPU。
  4. 同一图片重复解码。
  5. 生成大量 Object URL 后未释放。
  6. ImageBitmapVideoFrame 未关闭。
  7. 解码后又创建多个全尺寸 Canvas 副本。
  8. 上传 GPU 前发生颜色格式转换和预乘转换。
  9. 解析器信任恶意宽高或 Chunk 长度。
  10. 把数百张 PNG 当视频帧实时播放。

九、实际场景

A. AI 生成视频网页

9.1 PNG 的正确定位

适合:

  • 人物分割 Mask;
  • 透明贴纸、Logo、字幕底图;
  • 控制图、深度图或姿态图的无损交换;
  • 模型调试时保存中间结果;
  • 封面、缩略图;
  • 需要精确像素的 UI 素材。

不适合:

  • 长时间连续视频帧传输;
  • 高帧率预览;
  • 大量写实 AI 帧的正式交付;
  • 依赖低延迟和低带宽的播放链路。

9.2 边生成边预览

错误设计:

模型每生成一帧

编码一张 PNG

浏览器下载并逐帧替换 <img>

问题:

  • 不利用帧间冗余;
  • 每帧都有 PNG 与 zlib 结构开销;
  • 解码和纹理上传频繁;
  • 网络抖动时难以连续播放;
  • 缺少统一媒体时间戳和音频同步。

更合理:

低频关键预览:JPEG / PNG
连续播放:H.264 / AV1 等视频编码 + MP4/fMP4/WebM
透明蒙版:PNG 或专用纹理/压缩格式

9.3 Backpressure

即使只是缩略图,也要限制:

  • 待下载图片数;
  • 待解码图片数;
  • 活跃 ImageBitmap 数;
  • GPU 纹理数量;
  • 缓存总字节数。

用户滚动离开或切换任务时,应取消旧请求并释放资源。


B. 在线 Web 视频编辑器

9.4 素材导入

PNG 常作为:

  • 透明贴纸;
  • Logo;
  • 水印;
  • UI 截图;
  • 文字卡片;
  • 绿幕替代的透明素材;
  • 时间线缩略图。

导入时建议记录:

Asset ID
原始宽高
颜色类型
是否含 Alpha
ICC / sRGB 信息
文件字节数
解码后预计内存
缩略图缓存

9.5 透明边缘黑边

常见链路:

PNG straight alpha

浏览器或 GPU 转为 premultiplied alpha

缩放 / 模糊 / 滤镜

又按 straight alpha 错误解释

透明边缘黑边

排查清单:

  • 输入是否 straight alpha;
  • GPU 纹理上传是否预乘;
  • Shader 是否按同一约定混合;
  • Canvas 导出时是否发生转换;
  • Alpha 为 0 的像素 RGB 是否被污染;
  • 滤镜是在 premultiplied 还是 unpremultiplied 空间执行;
  • 颜色空间是否一致。

9.6 时间线缩略图

PNG 适合少量需要无损文字边缘的缩略图,但大量视频缩略图通常应:

  • 限制尺寸;
  • 量化颜色;
  • 选择 JPEG/WebP 等更小格式;
  • 使用 Sprite Sheet;
  • 建立 LRU 缓存;
  • 仅缓存可视区域附近。

9.7 导出

最终导出视频时:

PNG 素材

解码为 RGBA

按时间线变换

GPU 合成

得到视频帧

视频编码

MP4 / WebM

PNG 只负责静态图像资产,不负责视频时间戳、音频、GOP 或最终容器。


十、Web 实现视角

10.1 普通显示

优先使用:

<img src="asset.png" alt="">

或 CSS background。浏览器负责:

  • 网络加载;
  • 原生解码;
  • 颜色处理;
  • 缓存;
  • 渲染。

不应为了“掌握底层”而在生产代码中默认自己实现完整 PNG 解码器。


10.2 createImageBitmap

适合异步解码和向 Canvas/GPU 传递位图:

const blob = await fetch(url).then((r) => r.blob());
const bitmap = await createImageBitmap(blob);

ctx.drawImage(bitmap, 0, 0);
bitmap.close();

优势:

  • 避免依赖 DOM <img>
  • 更适合 Worker/OffscreenCanvas 管线;
  • ImageBitmap 可显式释放。

仍需注意:

  • 不是零成本;
  • 解码后仍可能占据完整像素内存;
  • 颜色、Alpha 和方向选项要明确。

10.3 WebCodecs ImageDecoder

运行时探测:

async function canDecodePngWithImageDecoder(): Promise<boolean> {
  if (!("ImageDecoder" in globalThis)) return false;

  const Decoder = (globalThis as typeof globalThis & {
    ImageDecoder: {
      isTypeSupported(type: string): Promise<boolean>;
    };
  }).ImageDecoder;

  return Decoder.isTypeSupported("image/png");
}

典型用途:

  • 需要逐帧控制 APNG;
  • 需要得到 VideoFrame
  • 希望与 WebCodecs 媒体管线统一;
  • 在 Worker 中进行解码。

使用完输出帧后及时:

frame.close();

降级路径:

ImageDecoder
  ↓ 不支持
createImageBitmap
  ↓ 不支持或不适用
HTMLImageElement

服务端预处理

不要仅凭浏览器名称推断支持情况。


10.4 Canvas

const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;

const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas 2D unavailable");

ctx.drawImage(bitmap, 0, 0);

读取像素:

const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

这通常会产生显著复制和同步成本。在线编辑器不应每帧都对全分辨率画布调用 getImageData()


10.5 Canvas 导出 PNG

const blob = await new Promise<Blob>((resolve, reject) => {
  canvas.toBlob(
    (value) => value ? resolve(value) : reject(new Error("PNG export failed")),
    "image/png",
  );
});

注意:

  • quality 参数不应被当成 PNG 的统一“质量等级”;
  • PNG 是无损格式;
  • 不同浏览器或编码器的过滤和 DEFLATE 策略可能不同;
  • 重新导出可能改变元数据、颜色 Chunk 和完全透明像素中的 RGB。

10.6 Worker 与 OffscreenCanvas

推荐把以下任务移出主线程:

  • 大量缩略图解码;
  • 图像缩放;
  • 像素级滤镜;
  • 自定义 Chunk 检查;
  • PNG 编码;
  • 资产预处理。

但不要盲目来回传完整 RGBA 数组。优先传:

  • Blob;
  • ArrayBuffer;
  • 可转移 ImageBitmap;
  • 可转移或可关闭的媒体资源。

10.7 CORS 与 Canvas 污染

跨域图片若没有正确 CORS 响应,通常可以显示,但一旦绘制到 Canvas 后,读取像素或导出可能被安全策略阻止。

工程上应:

  • 配置资源服务器 CORS;
  • 在加载前设置正确的跨域模式;
  • 避免先加载后再设置;
  • 对第三方素材提供服务端代理或转存机制。

10.8 自定义解析的适用边界

适合自己解析 Chunk:

  • 学习格式;
  • 读取宽高和颜色类型;
  • 检查元数据;
  • 安全扫描;
  • 文件优化器;
  • Debug。

不建议默认自己实现完整解码:

  • DEFLATE 容错复杂;
  • 颜色管理复杂;
  • Adam7 和低位深打包容易出错;
  • APNG 增加状态机;
  • 安全边界难以覆盖;
  • 浏览器原生实现通常更快、更成熟。

十一、易错点

  1. PNG 与 DEFLATE 是同一个东西。 错。PNG 定义图像结构;DEFLATE 只是其中的压缩层。

  2. zlib 与 gzip 相同。 错。二者都可承载 DEFLATE,但头部和校验格式不同。

  3. PNG 无损,所以文件字节每次保存都相同。 错。像素可无损恢复,编码选择和元数据可以变化。

  4. Filter 直接压缩数据。 错。Filter 保持数据长度,目的是改善后续可压缩性。

  5. PNG 每张图都使用 Paeth。 错。每行可以选择不同 Filter,编码器策略不同。

  6. Bit depth 就是每像素位数。 错。它通常是每样本位数;索引色时是每索引位数。

  7. 所有 PNG 解码后天然就是 RGBA8。 错。文件可为灰度、索引、16-bit 等,RGBA8 只是常见输出格式。

  8. PLTE 只要出现就表示像素是索引。 错。真彩色图中 PLTE 可以只是建议调色板。

  9. 多个 IDAT 是多个独立压缩流。 错。它们的数据字段拼接后形成同一个 zlib 流。

  10. CRC 包含 Length。 错。Chunk CRC 只覆盖 Type 和 Data。

  11. Adler-32 校验整个 PNG 文件。 错。它属于 zlib 流,校验解压后的 zlib 数据。

  12. tRNS 与完整 Alpha 完全等价。 错。灰度/真彩色 tRNS 只能指定单一透明颜色。

  13. PNG 使用 premultiplied alpha。 错。文件样本使用 straight alpha。

  14. 透明像素的 RGB 无关紧要。 不完全正确。滤镜、插值、错误的预乘转换会让这些 RGB 污染边缘。

  15. PNG 适合所有网页图片。 错。照片常常更适合有损格式。

  16. PNG 适合用作视频帧传输。 通常不适合。它不利用帧间冗余,也没有视频时间轴语义。

  17. Adam7 能让任意区域随机访问。 错。它提供渐进显示,不等于随机访问索引。

  18. Canvas toBlob("image/png", 0.5) 会输出半质量 PNG。 不应这样假设。PNG 是无损,质量参数不是统一压缩级别。

  19. 能显示 APNG 就一定能逐帧读取。 错。显示能力和帧级 API 能力不同。

  20. 文件只有几 MB,解码内存也只有几 MB。 错。压缩后文件大小与完整 RGBA 内存可能相差数十倍。


十二、面试题

12.1 基础题 10 道

1. PNG 为什么是无损,但不同软件导出的大小不同?

  • 标准回答: Filter、DEFLATE 搜索、Huffman 块、调色板和元数据策略不同,但解码像素相同。
  • 追问: 哪些选择属于 PNG 标准,哪些属于编码器策略?
  • 常见错误: “压缩等级越高,像素损失越多。”
  • 高级回答: 还应讨论颜色类型转换、无损调色板化、IDAT 切分和透明像素 RGB 规范化。

2. JPEG 与 PNG 的核心差异是什么?

  • 标准回答: JPEG 常用 DCT、量化和熵编码,通常有损;PNG 使用可逆过滤和 DEFLATE,无损并支持 Alpha。
  • 追问: 为什么文字截图用 JPEG 容易出现噪声?
  • 常见错误: “PNG 一定比 JPEG 清晰,所以永远更好。”
  • 高级回答: 应从内容统计特性、目标码率和透明需求判断。

3. PNG 为什么先 Filter 再 DEFLATE?

  • 标准回答: Filter 把高度相关的像素转换为小残差和重复模式,提升 LZ77 与 Huffman 的效果。
  • 追问: Filter 后字节数是否减少?
  • 常见错误: “Filter 会删除重复像素。”
  • 高级回答: Filter 是可逆 decorrelation,优化的是符号分布和重复串。

4. 一个 PNG Chunk 有哪些字段?

  • 标准回答: Length、Type、Data、CRC。
  • 追问: CRC 是否覆盖 Length?
  • 常见错误: “CRC 覆盖整个文件。”
  • 高级回答: 说明 Chunk Type 大小写位的语义。

5. PNG 有哪些颜色类型?

  • 标准回答: 灰度、真彩色、索引色、灰度 Alpha、真彩色 Alpha,对应 0、2、3、4、6。
  • 追问: 为什么没有 Color type 1、5?
  • 常见错误: “Color type 等于通道数。”
  • 高级回答: Color type 是 palette、truecolor、alpha 标志位组合。

6. PNG 如何表示透明度?

  • 标准回答: 显式 Alpha 通道或 tRNS。
  • 追问: Color type 2 的 tRNS 能否表示半透明?
  • 常见错误: “tRNS 就是每像素 Alpha。”
  • 高级回答: 区分调色板 Alpha 表和单一透明样本值。

7. 多个 IDAT 如何解码?

  • 标准回答: 连续拼接所有 IDAT Data,再作为一个 zlib 流解压。
  • 追问: 能否逐个 IDAT 独立 inflate?
  • 常见错误: “每个 IDAT 都有独立 zlib 头。”
  • 高级回答: IDAT 切分只是 Chunk 层分段,便于流式和固定缓冲。

8. CRC 与 Adler-32 有什么区别?

  • 标准回答: CRC 位于每个 Chunk,覆盖 Type+Data;Adler-32 位于 zlib 流尾部,校验解压数据。
  • 追问: 为什么两个都要有?
  • 常见错误: “二者算法完全相同。”
  • 高级回答: 说明其分属 PNG 结构层和 zlib 压缩层。

9. Adam7 的作用是什么?

  • 标准回答: 七遍交错传输,支持渐进显示。
  • 追问: 是否一定更小?
  • 常见错误: “Adam7 是压缩算法。”
  • 高级回答: 它通常略增体积,并改变 reduced image 的扫描线与过滤过程。

10. 为什么 PNG 不适合连续视频?

  • 标准回答: 每帧独立,无帧间预测,带宽、解码和纹理上传成本高,也缺少媒体时间轴。
  • 追问: 哪些例外场景可接受?
  • 常见错误: “因为 PNG 不能动画。”
  • 高级回答: 低帧率调试、透明序列和离线中间帧可使用,但正式播放应使用视频编码。

12.2 底层实现题 10 道

1. 如何安全解析 Chunk?

  • 标准回答: 验证 Signature、剩余长度、Length 上限、整数溢出、CRC、顺序和关键 Chunk。
  • 追问: 恶意 Length 会造成什么问题?
  • 常见错误: 直接 offset += length + 12
  • 高级回答: 增加总元数据预算、尺寸预算和流式读取状态机。

2. Sub 反过滤为什么要读已经重建的左侧字节?

  • 标准回答: 编码预测基于原始左侧字节,解码时只有重建后的左侧字节与之相同。
  • 追问: RGBA8 的 bpp 是多少?
  • 常见错误: 从 filtered row 读取左侧。
  • 高级回答: 讨论低位深图时 bpp 至少取 1。

3. Paeth 如何处理距离相等?

  • 标准回答: 按规范优先顺序选择,常见实现先 a,再 b,再 c。
  • 追问: 为什么不能随意改变?
  • 常见错误: 随机选择任意一个。
  • 高级回答: 编码和解码必须确定性一致,否则像素会错误。

4. 如何处理 1-bit 索引图?

  • 标准回答: 按扫描线高位优先拆 bit,再通过 PLTE 查 RGB。
  • 追问: 行尾剩余 bit 如何处理?
  • 常见错误: 跨扫描线继续使用剩余 bit。
  • 高级回答: 每条扫描线从字节边界重新开始。

5. 如何合并 PLTE 和 tRNS?

  • 标准回答: PLTE 给 RGB,tRNS 依次给部分调色板项 Alpha,未提供项默认为 255。
  • 追问: tRNS 可以比 PLTE 长吗?
  • 常见错误: 未提供 Alpha 的项默认为 0。
  • 高级回答: 先验证索引范围,再输出 RGBA。

6. PNG 能否只保留一行缓冲解码?

  • 标准回答: 反过滤至少需当前行和上一行;若调用方流式消费,可不保留所有扫描线。
  • 追问: 为什么最终仍可能需要完整位图?
  • 常见错误: “解码 PNG 必须一次性分配全部中间数据。”
  • 高级回答: 区分解码状态、输出缓冲和 GPU 上传策略。

7. 遇到未知 Chunk 怎么办?

  • 标准回答: 未知关键 Chunk 不能忽略;未知辅助 Chunk 可按规范规则跳过。
  • 追问: 编辑后能否原样复制未知辅助 Chunk?
  • 常见错误: 所有未知 Chunk 都丢弃。
  • 高级回答: 结合第四字节 safe-to-copy 位和是否修改关键图像数据。

8. 如何选择最佳 Filter?

  • 标准回答: 可尝试五种并以残差绝对值等启发式评分。
  • 追问: 局部评分最优是否等于最终 DEFLATE 最小?
  • 常见错误: 固定 Paeth 必然最优。
  • 高级回答: 全局压缩最优需考虑 DEFLATE 历史, exhaustive search 成本很高。

9. 如何防御 PNG 解压炸弹?

  • 标准回答: 在解压前用宽高、位深、通道数计算理论输出并设上限;限制 Chunk、元数据和总 CPU 时间。
  • 追问: 只限制文件大小够不够?
  • 常见错误: “有 CRC 就安全。”
  • 高级回答: 使用 checked arithmetic、取消信号、隔离 Worker/进程和像素预算。

10. APNG 解码器需要哪些额外状态?

  • 标准回答: 帧区域、延时、序号、dispose、blend、输出缓冲和上一帧备份。
  • 追问: PREVIOUS disposal 为什么昂贵?
  • 常见错误: 把每个 fdAT 当成完整 PNG。
  • 高级回答: 讨论局部帧合成、备份区域而非整帧和时间调度。

12.3 数学题 10 道

1. 800×600 RGBA8 的未过滤扫描线数据多大?

  • 标准回答: 每行 3200 字节;含 Filter Type 为 3201;总计 1,920,600 字节。
  • 追问: 为什么不是最终文件大小?
  • 常见错误: 忽略每行 Filter Type。
  • 高级回答: 再估算 RGBA 输出、压缩文件和 GPU 纹理三份内存。

2. 宽 13 的 1-bit 灰度图每行多少字节?

  • 标准回答: (\lceil13/8\rceil=2) 字节,过滤前每行 2 字节。
  • 追问: 含 Filter Type 呢?
  • 常见错误: 13 字节。
  • 高级回答: 说明最后 3 个低位未使用。

3. 对 [10,13,15,20] 做 Sub。

  • 标准回答: [10,3,2,5]
  • 追问: 若发生负数怎么办?
  • 常见错误: 保留负数。
  • 高级回答: 字节结果按模 256 保存。

4. 上一行 [10,20,30],当前 [12,18,35] 做 Up。

  • 标准回答: [2,254,5]
  • 追问: 254 反过滤为何能得到 18?
  • 常见错误: 把 -2 存成有符号整数。
  • 高级回答: ((254+20)\bmod256=18)。

5. (a=90,b=100,c=80,x=95) 的 Paeth 残差是多少?

  • 标准回答: (p=110),距离分别 20、10、30,选 b=100,残差为 251。
  • 追问: 为什么 251 表示 -5?
  • 常见错误: 输出 -5。
  • 高级回答: DEFLATE 看到的是无符号字节,但评分时可解释为有符号残差。

6. 25% 白色覆盖黑色,输出通道值约多少?

  • 标准回答: (0.25\times255\approx64)。
  • 追问: 在 sRGB 编码值上直接算是否严格正确?
  • 常见错误: 255。
  • 高级回答: 正确光学合成要考虑线性光和传递函数。

7. [1,2,3] 的 Adler-32 是多少?

  • 标准回答: 0x000D0007
  • 追问: 初始 (s_1) 为什么是 1?
  • 常见错误: 两个和都从 0 开始。
  • 高级回答: 解释长度和全零序列的区分。

8. 文件 200 KB,解压 RGBA 为 8 MB,压缩比是多少?

  • 标准回答: 约 40:1。
  • 追问: 这是否一定是攻击?
  • 常见错误: 用 200/8000。
  • 高级回答: 压缩比需结合尺寸、内容和资源预算判断。

9. 256 色 1920×1080 索引图的索引原始数据约多大?

  • 标准回答: 8-bit 索引约 2,073,600 字节,另有调色板和每行 Filter Type。
  • 追问: RGBA8 是多少?
  • 常见错误: 仍按 4 字节每像素。
  • 高级回答: 对比约 8.29 MB 的 RGBA8。

10. 同时保留原 Blob、RGBA、两个 Canvas 和一个 GPU 纹理,如何估算峰值?

  • 标准回答: Blob + 至少四份像素级资源,4K 时可能超过 130 MB。
  • 追问: 哪些资源可能共享?
  • 常见错误: 只算文件大小。
  • 高级回答: 强调实现相关、对齐、双缓冲和临时转换会继续增加峰值。

12.4 Web 实战题 10 道

1. 网页只需显示 PNG,优先用什么?

  • 标准回答: <img> 或 CSS,让浏览器原生加载解码。
  • 追问: 何时用 createImageBitmap?
  • 常见错误: 默认 fetch ArrayBuffer 后自己解码。
  • 高级回答: 结合缓存、Worker、Canvas 和生命周期选择。

2. 需要逐帧控制 APNG 怎么做?

  • 标准回答: 先探测 WebCodecs ImageDecoder 与 image/png 支持,否则降级到普通显示或服务端拆帧。
  • 追问: 能播放 APNG 是否等于能逐帧解码?
  • 常见错误: 按浏览器品牌硬编码。
  • 高级回答: 设计能力探测和统一帧调度接口。

3. Canvas 合成后透明边缘发黑,如何排查?

  • 标准回答: 检查 straight/premultiplied alpha、滤镜顺序、透明像素 RGB 和颜色空间。
  • 追问: 为什么缩放会暴露问题?
  • 常见错误: 只调 CSS 背景色。
  • 高级回答: 在预乘空间做插值,边界处避免无效 RGB 污染。

4. 图片能显示但 getImageData 报安全错误,为什么?

  • 标准回答: 跨域图片使 Canvas 被污染。
  • 追问: 如何修复?
  • 常见错误: 捕获异常后继续。
  • 高级回答: 资源端 CORS、加载模式、代理转存和可信源策略。

5. canvas.toBlob("image/png", 0.2) 是否会降低画质?

  • 标准回答: 不应把质量参数理解为 PNG 有损质量;PNG 输出仍是无损。
  • 追问: 如何减小文件?
  • 常见错误: 认为会像 JPEG quality 20。
  • 高级回答: 缩尺寸、调色板化、移除元数据、优化 Filter/DEFLATE。

6. <img> 与 createImageBitmap 如何选择?

  • 标准回答: DOM 展示用 <img>;绘制和 Worker 管线可用 ImageBitmap。
  • 追问: ImageBitmap 用完要做什么?
  • 常见错误: 认为 ImageBitmap 永远零复制。
  • 高级回答: 讨论转移、关闭、内部资源和缓存策略。

7. 如何避免大量缩略图阻塞主线程?

  • 标准回答: Worker、OffscreenCanvas、并发限制、可视区域加载、取消和 LRU。
  • 追问: 并发越高越好吗?
  • 常见错误: 一次 Promise.all 解码全部。
  • 高级回答: 以像素预算而不是文件数做调度。

8. ImageDecoder 输出为什么要 close?

  • 标准回答: 输出帧可能持有大块系统或 GPU 媒体资源。
  • 追问: ImageBitmap 呢?
  • 常见错误: 依赖 GC 立即回收。
  • 高级回答: 统一资源句柄、引用计数和取消路径。

9. 如何把 PNG 序列导出为 MP4?

  • 标准回答: 解码每张 PNG 为帧,按时间戳送入视频编码器,再 mux 为 MP4。
  • 追问: VideoEncoder 输出就是 MP4 吗?
  • 常见错误: 把 PNG 字节直接塞进 MP4 视频轨。
  • 高级回答: 说明帧率、时间基、颜色、Alpha 降级和 Backpressure。

10. 浏览器不支持所需解码 API 时如何降级?

  • 标准回答: ImageDecoder → createImageBitmap → <img> → 服务端转换。
  • 追问: 如何保持业务层接口稳定?
  • 常见错误: 只弹“不支持”。
  • 高级回答: 抽象 DecodeProvider,统一输出尺寸、时间戳、颜色和释放接口。

12.5 系统设计题 5 道

1. 设计 AI 视频网页的 Mask 资产管线

  • 标准回答: 原始 Mask → 选择灰度/索引 PNG → 对象存储 → CDN → 按需解码 → GPU 纹理 → LRU 释放。
  • 追问: 何时不用 PNG?
  • 常见错误: 每帧全尺寸 RGBA PNG。
  • 高级回答: 二值 Mask 可位打包;连续 Mask 可用视频或专用压缩;建立版本和颜色语义。

2. 设计 Web 编辑器的透明素材系统

  • 标准回答: 导入探测 → 元数据与安全校验 → 缩略图 → 原图缓存 → ImageBitmap/纹理 → 统一预乘约定 → 导出重用。
  • 追问: 如何处理 16-bit 或 ICC?
  • 常见错误: 所有素材立即转 RGBA8 并永久缓存。
  • 高级回答: 按编辑质量、预览质量和最终导出质量分层。

3. 设计十万张 PNG 缩略图服务

  • 标准回答: 服务端预生成多级尺寸、内容哈希、CDN、现代格式协商、客户端虚拟列表和 LRU。
  • 追问: 为什么不让浏览器下载原图缩放?
  • 常见错误: 客户端无限并发。
  • 高级回答: 像素成本计费、缓存键包含色彩与裁剪参数、异步任务去重。

4. 设计在线 PNG 优化器

  • 标准回答: 解析 → 安全限制 → 可选无损调色板化 → 多 Filter/DEFLATE 策略 → 元数据策略 → 像素哈希验证。
  • 追问: 如何证明无损?
  • 常见错误: 只比较文件 CRC。
  • 高级回答: 解码前后比较规范化像素、Alpha、颜色语义和需保留元数据。

5. 设计不可信 PNG 的浏览器预览沙箱

  • 标准回答: 尺寸和字节预算、Worker 隔离、超时取消、原生解码优先、禁止无限元数据、失败回退。
  • 追问: 为什么 CRC 不能替代安全校验?
  • 常见错误: 只检查扩展名。
  • 高级回答: 内容嗅探、CSP、对象 URL 生命周期、进程隔离和服务端扫描。

十三、练习

13.1 手算题

练习 1:五种 Filter

上一行:

10, 20, 30, 40, 50

当前行:

12, 22, 31, 44, 55

灰度 8-bit,分别计算:

  • None;
  • Sub;
  • Up;
  • Average;
  • Paeth。

再对结果执行反过滤,确认恢复原行。

练习 2:RGBA 的 bpp

给出 RGBA8 当前扫描线前 12 个字节,解释为什么 Sub 的左侧参考不是前 1 字节,而是前 4 字节。

练习 3:扫描线尺寸

计算:

  1. 1920×1080、RGB8;
  2. 1920×1080、RGBA16;
  3. 641×480、1-bit 灰度;
  4. 1000×1000、4-bit 索引。

分别给出每行字节数和进入 DEFLATE 前的数据量。


13.2 码流分析题

使用十六进制查看器或 xxd

xxd -g 1 input.png | head -n 30

完成:

  1. 找到 Signature;
  2. 找到第一个 IHDR;
  3. 读取 IHDR 长度;
  4. 手工解析宽高;
  5. 记录 color type、bit depth、interlace;
  6. 找到全部 IDAT;
  7. 确认 IEND;
  8. 验证至少一个 Chunk CRC。

13.3 伪代码题

实现:

parsePngHeader(bytes): {
  width: number;
  height: number;
  bitDepth: number;
  colorType: number;
  interlace: number;
}

要求:

  • 不分配整图像素;
  • 检查 Signature;
  • IHDR 必须首先出现;
  • 检查 IHDR Data 长度为 13;
  • 使用大端读取;
  • 拒绝宽高为 0;
  • 拒绝非法 color type 与 bit depth 组合。

13.4 架构设计题

设计一个“在线贴纸素材库”:

  • 10000 个 PNG;
  • 支持透明预览;
  • 时间线上最多同时显示 50 个;
  • 支持缩放、旋转、模糊;
  • 移动端内存预算 300 MB。

输出:

  • 服务端预处理格式;
  • 缩略图策略;
  • 原图加载时机;
  • 解码并发;
  • GPU 纹理缓存;
  • 取消机制;
  • LRU;
  • 导出时如何拿到高质量原图。

13.5 性能估算题

一张 4096×4096 RGBA8 PNG,文件大小 12 MB。

估算至少包含:

  • 原始 Blob;
  • 解码 RGBA;
  • Canvas backing store;
  • 一份 GPU 纹理;
  • 一个缩小预览;
  • 编码导出临时缓冲。

说明为什么实际峰值可能显著高于简单相加。


13.6 Debug 题

症状 A:透明边缘有黑线

逐项排查:

  • 输入 Alpha 表示;
  • 透明像素 RGB;
  • Canvas;
  • WebGL blending;
  • Shader;
  • 缩放;
  • 导出;
  • 颜色空间。

症状 B:某些 PNG 解码失败

逐项排查:

  • Signature;
  • IHDR;
  • color type/bit depth;
  • Chunk 顺序;
  • CRC;
  • IDAT 是否连续;
  • zlib header;
  • Filter Type;
  • Adam7;
  • 文件截断。

症状 C:滚动素材库后内存持续上升

逐项排查:

  • Object URL;
  • ImageBitmap;
  • VideoFrame;
  • Canvas;
  • GPU texture;
  • DOM 引用;
  • Promise 队列;
  • 缓存上限;
  • 请求取消;
  • Worker 消息中的 ArrayBuffer。

13.7 本周验收

不看资料完成:

  1. 画出 PNG 编码与解码流程。
  2. 写出 Signature。
  3. 写出 Chunk 四字段。
  4. 解释五种 Filter。
  5. 手算一个 Paeth。
  6. 区分 zlib、DEFLATE、CRC、Adler-32。
  7. 写出五种 color type。
  8. 解释 tRNS。
  9. 解释 PNG straight alpha 与渲染 premultiplied alpha。
  10. 设计 PNG 在 Web 视频编辑器中的资产管线。

达到 8/10 才进入第 5 周。


十四、本章速查表

14.1 名词

名词一句话解释
PNG基于 Chunk、可逆过滤和 DEFLATE 的无损图像格式
ChunkLength、Type、Data、CRC 组成的数据单元
Scanline从左到右的一行像素序列
Filter把像素字节变成可逆残差
DEFLATELZ77 + Huffman 的无损压缩格式
zlibDEFLATE 外层头部与 Adler-32 封装
PLTERGB 调色板
tRNS调色板 Alpha 或单一透明颜色
Adam7七遍交错渐进显示
straight alphaRGB 未乘 Alpha 的表示

14.2 五种 Filter

Type名称Predictor
0None0
1Sub(a)
2Up(b)
3Average(\lfloor(a+b)/2\rfloor)
4PaethPaethPredictor((a,b,c))

统一形式:

[ F(x)=(x-\text{Predictor})\bmod256 ]

反过滤:

[ x=(F(x)+\text{Predictor})\bmod256 ]

14.3 关键码流

Signature
IHDR
[PLTE]
[Ancillary Chunks]
IDAT
IDAT
...
IEND

14.4 核心公式

[ S=\left\lceil\frac{W\cdot B_{pp}}8\right\rceil ]

[ N_{\text{filtered}}=H(S+1) ]

[ p=a+b-c ]

[ C_o=\alpha C_f+(1-\alpha)C_b ]

14.5 面试结论

  • PNG 的无损来自所有步骤可逆,不是因为“没有压缩”。
  • Filter 不减少长度,而是提升 DEFLATE 可压缩性。
  • IDAT 使用 zlib 格式承载 DEFLATE。
  • 多个 IDAT 拼接为一个 zlib 流。
  • PNG 文件保存 straight alpha,渲染管线常使用 premultiplied alpha。
  • 文件小不代表解码内存小。
  • PNG 适合 UI、透明素材和无损中间图,不适合连续视频传输。
  • 浏览器生产代码优先使用原生解码,并对 ImageDecoder 做运行时探测。
  • 自定义解析器必须防御尺寸、长度、解压和元数据攻击。
  • 编码器搜索复杂,解码器流程相对确定。

十五、参考资料

  1. W3C, Portable Network Graphics (PNG) Specification (Third Edition) https://www.w3.org/TR/png-3/

  2. RFC 1950, ZLIB Compressed Data Format Specification version 3.3 https://www.rfc-editor.org/info/rfc1950

  3. RFC 1951, DEFLATE Compressed Data Format Specification version 1.3 https://www.rfc-editor.org/info/rfc1951

  4. W3C, WebCodecs https://www.w3.org/TR/webcodecs/

  5. WHATWG, HTML Living Standard — Canvas https://html.spec.whatwg.org/multipage/canvas.html

  6. zlib Manual https://zlib.net/manual.html

  7. libpng http://www.libpng.org/pub/png/libpng.html


本周总结

PNG 的完整知识链不是“无损图片”四个字,而是:

颜色表示

像素打包

扫描线

可逆 Filter

zlib / DEFLATE

Chunk 与 CRC

Alpha、颜色管理和浏览器输出

真正需要记住的工程结论是:

PNG 用空间预测改善通用无损压缩,用 Chunk 提供结构和扩展能力;它非常适合 UI、透明素材和精确像素,但不应被误用为连续视频格式。

下一周将从单帧图像进入时间维度,学习视频编码的共同框架:帧内预测、帧间预测、运动估计、残差、变换、量化、熵编码、参考帧和 GOP。