Featured image of post 解码图像格式:从像素编码到渲染的底层原理

解码图像格式:从像素编码到渲染的底层原理

本文深入剖析了图像编解码器(如JPEG、AVIF、WebP)的技术原理,包括像素数据结构、色彩空间转换、预测模式、块变换与量化、熵编码等核心算法,揭示了图像从数据压缩到屏幕显示的全链路技术细节。

图像格式:从编码器到解码器的像素数据

在上一篇文章中,我们关注了人眼和设备如何观看与显示图像。现在,是时候深入探讨图像的微小构建块——像素及其背后的字节了。

什么是图像像素?

要显示图像,设备需要从数据源获取图像不同部分的颜色信息。对设备而言,大多数图像仅仅是网格,网格中的每个单元格就是一个像素。像素是一个短暂的值;它没有尺寸或形状。如何表示这些像素由硬件和软件决定。存储像素数据的方式有无限多种,因此设备需要理解图像格式,即数据的组织方式。在网络上,有几种常见的格式:JPEG、AVIF、WebP、JPEG XL、PNG 等。像素有几个重要属性:

  • 它们排列在网格中以形成图像,并且顺序很重要。
  • 每个像素保存颜色和亮度信息。
  • 这些值是离散的,受图像格式定义的比特数限制。

注意: AVIF 可以使用 8、10 或 12 位来存储像素数据。较新的 JPEG 格式 JPEG XL 支持每个颜色通道高达 32 位。最常见的设置是每个通道 8 位,数值范围从 0 到 255。这使得每个颜色分量只有 256 种可能的“色调”,总共约 1600 万种可能颜色。

区分图像像素和设备像素很重要。图像像素是图像数据的一部分,而设备像素是依赖于显示器的物理像素。设备像素的工作方式可能有所不同:例如,在 OLED 屏幕中,每个像素都是一个微小的发光二极管;在 LCD 中,它是由背光控制的晶体;在电子墨水显示器中,它由形成图像的带电粒子组成。

图形卡中的图像

为了让显示器显示图像,显卡需要将该图像作为信号发送到显示器。一个简化的解释是,显卡将图像像素数据存储为一个数字数组。让我们看看如何在 JavaScript 中操作像素数据:

1
2
3
4
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const imgData = ctx.getImageData(0, 0, 1, 1);
// imgData.data 是一个 Uint8ClampedArray

提取的 ImageData 是一个连续的无符号 1 字节整数类型的数组。在示例中,图像不包含任何颜色。相反,每个图像像素有四个分量,所有有趣的信息都存储在第四个分量中——alpha 通道。通过读取每个第四个元素,我们可以通过 alpha 通道扫描整个图像。

注意: Alpha 通道存储有关图像透明度的信息。值为 255 表示像素完全不透明,而 0 表示完全透明。

图像规范

作为一个例子,让我们看一个非常小的图像,尺寸为 100×133 像素:

如果图像没有 alpha 通道——即每个像素只有三个分量——并且我们使用 8 位颜色,图像的原始大小应该在 40 KB 左右。但实际文件小于 3 KB。很快我们就会明白原因。对于更大的图像,这种差异变得更加显著。现在想象一下,不是一个单一的图像,而是需要渲染一个 60 秒的短视频。以每秒 24 帧计算,就是 1,440 帧。每帧 38 KB,总大小将达到 55 MB 左右。显然,需要有效地打包像素。为了处理这个问题,已经开发了许多图像和视频格式:BMP、GIF、JPEG、PNG、JPEG2000、多个版本的 MPEG、H.264、VP8、VP9 和 AV1。因此,数据首先被编码,然后被解码——处理这个过程的东西称为编解码器(编码器,解码器)。编码可以在 CPU 或 GPU 上进行。通常,图像数据驻留在 RAM 中,CPU 对其进行解码,然后将结果发送到 GPU,以便在屏幕上显示。但当负载较重时——比如视频播放——上传压缩的图像数据会更高效。在这种情况下,GPU 自己进行解码。视频由两种类型的帧组成:内部帧(I 帧)和预测帧(P/B 帧)。内部帧基本上是经过压缩的完整图像,而预测帧存储了图像各部分如何从一帧移动到下一帧的信息。

注意: 更准确地说,预测帧包含运动矢量。

内部帧压缩的工作方式与图像压缩相同。像 AVIF、HEIF 和 WebP 这样的格式实际上基于相关视频编解码器中使用的内部帧压缩。通常,编解码器的发展速度比图像格式快,因此图像格式往往成为视频编解码器开发的副产品。例如,在现代图像格式还没有被浏览器广泛支持的时候(现在已经不是这种情况),人们使用单帧视频来代替图像。

图像编码器如何工作

编码器生成的比特序列称为比特流。为了传输或存储这些数据,需要将其分割成块。这些块根据格式不同有不同的名称。例如,在 AV1 视频和 AVIF 图像中,它们被称为 OBU(开放式比特流单元)。每个单元都有自己的结构,以便解码器知道如何处理它。但图像通常不仅仅是一系列比特流单元。如前所述,它还可以包含 ICC 配置文件和其他元数据——如图像尺寸、色彩空间、相机设置、日期、时间和位置。所有这些都需要打包到一个容器中。容器有自己的规范,不同的媒体格式可以重用同一个容器。例如:

  • AVIF 是 HEIF 容器中的 AV1 字节流。
  • WebP 是 RIFF 容器中的 VP8 字节流(或无损 WebP)。

有时,一种图像格式会定义自己的容器。PNG、JPEG 和 JPEG XL 将布局作为文件格式规范的一部分。

图像解码器如何工作

解码器通过读取文件的开头来确定格式,这通常包含一个魔数——几个用于识别格式的字节。对于 AVIF,这个魔数是 ftypavif。然后它遵循容器布局来查找相关数据。通常,元数据在前,然后是实际的字节流。让我们通过一些松鼠图片来看看这是如何工作的 😀

该图像在文件开头包含大量元数据:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
width: 1536
height: 1536
bands: 3
format: uchar
coding: none
interpretation: srgb
xoffset: 0
yoffset: 0
xres: 2.83465
yres: 2.83465
filename: ./squirrel.jpg
vips-loader: jpegload
jpeg-multiscan: 1
interlaced: 1
jpeg-chroma-subsample: 4:2:0

即使其中大部分目前看起来没有意义也没关系——其中一些条目将在后面解释。如您所见,宽度和高度存储在容器的开头。这对于浏览器尤其有用,浏览器可以快速在页面上为图像分配空间。

注意: 不要让浏览器计算图像的高度和宽度——这会导致讨厌的布局偏移。相反,请在 <img> 元素上设置 widthheight 属性。

深入了解编码器

那么,编码器如何能将 38 KB 的图像数据压缩到只有 3 KB?编码器使用一系列技术和算法,这些技术和算法利用了人类的视觉感知方式和图像的结构。让我们逐一探讨。

有损和无损压缩方法

有两种压缩图像的方式:

  • 有损: 编码后的图像会在此过程中改变或移除一些信息,不再与原始图像完全相同。
  • 无损: 图像可以完全重建。

有损和无损压缩方法可以结合使用,有时可以用更少的字节获得更好的结果。例如,控制透明度的 alpha 通道可以进行无损压缩,而颜色通道则使用有损压缩以提高效率。下表总结了几种流行的图像格式:

格式 压缩类型 备注
PNG 无损 始终无损
JPEG 有损 始终有损
WebP 混合 可以是基于 VP8 的有损帧或使用不同算法的无损压缩
AVIF 有损或无损
JPEG XL 有损或无损

色度二次采样

人类对亮度的敏感度远高于对颜色的敏感度。YCbCr 色彩空间利用了这一事实,将亮度与颜色信息分离,从而允许更高效的压缩。编码器不是存储每个像素的完整颜色细节,而是跳过一些颜色数据。解码器然后根据相邻像素的颜色重建它。这种技术称为色度二次采样。色度二次采样使用三个数字来描述,它们对应于一个 4×2 像素网格:

  • 第一个数字始终是 4,代表区域的宽度。
  • 第二个数字显示第一行存储了多少个色度样本(Cb 和 Cr)。
  • 第三个数字显示第二行存储了多少个色度样本。

AVIF 支持以下色度二次采样格式:

  • 4:4:4 – 无二次采样;保留所有颜色信息。
  • 4:2:2 – 丢弃一半的颜色信息。
  • 4:2:0 – 仅保留原始颜色数据的四分之一。

这是一个有损变换的例子。一旦应用,原始图像就无法完全恢复,但对于观看者来说,它看起来仍然很好,同时节省了存储空间和带宽。

利用空间局部性

让我们看一个代码示例来演示下一种技术。这里有两个画布:一个带有随机像素,另一个是纯色。

1
// 示例:创建两个具有不同模式的 canvas 以比较文件大小

尝试分别保存这两个图像:右键单击每个图像并选择“另存为”。即使使用效率不高的浏览器编解码器,第一个图像的大小也将在 35 KB 左右,而第二个图像只有 500 字节。大小相差 70 倍!原因是第一个图像中的像素之间没有任何关系。在真实图像中,相邻像素通常以某种方式相关。它们可能共享相同的颜色,形成渐变,或遵循某种模式。这种属性称为空间局部性。

图像包含的信息越少,就越能被更好地压缩。编解码器充分利用这一属性,将图像划分为区域,并分别压缩每个区域。例如,JPEG 总是将图像分割成 8×8 的块。更现代的图像格式允许灵活的块大小。例如,AVIF 使用的块大小从 4×4 到 128×128 不等。编解码器通常坚持使用 2 的幂次方的块大小,以保持计算简单高效。对于一片纯蓝天空的图像,将该区域划分为更大的块会更有效,从而减少存储不必要细节的需要。同样的规则适用于任何纯色背景。但是编码器如何决定在哪里以及如何分割图像呢?有几种技术可以实现这一点,我们接下来可以探讨。AVIF 使用递归方法将图像分割成块。它首先将图像分割成称为超级块(superblocks)的较大块。然后,它分析每个块的内容,并决定是否进一步分割或保持原样。为了管理这种分割,AVIF 使用四叉树数据结构。四叉树也常用于地图应用程序中,以存储位置并高效地查找附近的对象。您可以通过递归地将图像分割成四个正方形来自己构建一个四叉树。对于每个正方形,计算平均颜色和与平均值的偏差。如果偏差太大,则再次分割正方形。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 伪代码:构建图像四叉树的递归函数示例
function buildQuadtree(image, x, y, size, threshold) {
    let mean = calculateMean(image, x, y, size);
    let deviation = calculateDeviation(image, x, y, size, mean);
    if (deviation > threshold && size > 1) {
        // 偏差大,继续分割
        let half = size / 2;
        buildQuadtree(image, x, y, half, threshold);
        buildQuadtree(image, x + half, y, half, threshold);
        buildQuadtree(image, x, y + half, half, threshold);
        buildQuadtree(image, x + half, y + half, half, threshold);
    } else {
        // 偏差小或达到最小尺寸,存储均值
        storeBlock(x, y, size, mean);
    }
}

预测模式

一个块包含的数据越少,就越能被更好地压缩。尝试通过点击下面画布上的不同单元格来操作一个 8×8 像素块。点击几次后,绿色标记的部分图案将被复制。与其存储网格中的每个像素,不如仅存储实际图像与最接近的匹配图案之间的差异,从而节省空间。

1
<!-- 交互式示例:预测模式 -->

现代图像编解码器使用相同的思路。它们为每个块预测一个基本结构,然后仅压缩实际块内容与预测之间的差异,这些差异称为残差。大多数图像都有一些底层结构,编解码器通过分析像渐变或重复纹理这样的模式来利用这一点,以做出更好的预测。

块内的像素通常形成与其相邻值相关的模式。因此,编解码器不是使用静态模式,而是使用块的顶行和左列作为参考来进行预测。以图像中的左侧示例为例:正方形内的像素与周围的一些像素非常相似。不同的编解码器使用各种预测模式来找到表示这种结构并最小化误差的最佳方式。例如:

  • 可以将该块预测为其相邻像素的平均值。
  • 它可以按特定方向重复像素(如图所示)。
  • 它可能会组合多个相邻像素以创建更准确的预测。
  • 更高级的预测器考虑块内每个像素的坐标来优化结果。
  • 甚至更高级的模式通过递归地预测之前行和列的像素来更进一步。
  • 编码器不是只依赖参考像素,而是使用先前预测的像素生成补丁。

通过改进这些预测,编解码器可以显著减少需要存储或传输的数据量。例如,AVIF 的一种预测模式基于左侧邻居参考像素、右上角参考像素以及块内像素的坐标。预测不仅限于空间结构——颜色通道也可以相互关联。这一想法在 AVIF 和 JPEG XL 中都有使用。相关的预测模式,从亮度预测色度,根据对应的 Y(亮度)通道的信息来预测颜色通道(UV)。一些编解码器不是预测像素,而是存储复制另一个块内容的指令。这对于屏幕截图或具有重复图案的图像特别有用,因为它减少了冗余。预测模式的数量因编解码器而异。AVIF 有很多(71 种),WebP 较少(10 种),而 JPEG 根本不使用预测。

块变换

编解码器进行预测,然后找到与该预测的像素差异。接下来,编解码器需要高效地存储这些差异,这时候就需要丢弃一些数据,即应用有损变换。你可以用相同的图案类比来思考。现在有一组固定的图案,目标是找出组合它们的最佳方式,以获得接近原始块的东西。这就像通过叠加多个图层来构建图像,每个图层具有不同的不透明度——每个图层都对最终结果有贡献,诀窍是猜出每个图层的正确不透明度。实现这一点的数学方法称为离散余弦变换(DCT)。为什么这有助于减少文件大小?人类对高频分量(如微小点或精细纹理)不太敏感。这种变换分离了这些高频和低频部分,使得更容易决定哪些细节可以安全地丢弃。一种广为人知的可视化方式是通过 DCT 基函数,它显示了不同频率分量如何对图像做出贡献。

注意: 虽然这是一个有用的说明,但请记住,现代编解码器将变换应用于残差,而不是原始图像。

让我们通过一个例子看看这是如何工作的。想象一个 8×8 的块,其中心有一条水平的 2 像素粗线:

1
// 示例:计算一个简单块图案的 DCT 系数

当然,实际的实现是高效且优化的:

  • 编解码器不是一次性对整个块应用 DCT,而是首先对行应用两次一维 DCT,然后对列应用。
  • 一些编解码器支持替代变换。例如,AVIF 可以使用正弦变换代替 DCT。
  • 编解码器使用像基于蝶形算法的快速 DCT 这样的快速算法。
  • 变换步骤使用 SIMD 指令针对向量操作进行了优化,这使得 CPU 可以用一条指令对多个值应用相同的操作。
  • DCT 也可以直接在 GPU 上执行。AV1 编解码器(AVIF 基于此)支持这一点。

在更先进的编解码器中,变换可以应用于块的一部分,而不是整个块。例如,AVIF 允许将一个块细分(最多两次)成更小的块,并对每个子块分别应用变换。在这个阶段,由于浮点计算中的舍入,会引入微小的有损误差。但仅这一步不会显著减少文件大小。

量化

为了准备图像进行压缩,编解码器将浮点系数转换为整数,并将每个系数除以一个特定的数字。目标是在除法后得到尽可能多的零——数据越少意味着压缩越好。这些数字被称为量化参数。JPEG 中使用的一组流行的量化值如下所示:

亮度量化表 QY

1
2
3
4
5
6
7
8
[ 16 11 10 16 24 40 51 61 ]
[ 12 12 14 19 26 58 60 55 ]
[ 14 13 16 24 40 57 69 56 ]
[ 14 17 22 29 51 87 80 62 ]
[ 18 22 37 56 68 109 103 77 ]
[ 24 35 55 64 81 104 113 92 ]
[ 49 64 78 87 103 121 120 101 ]
[ 72 92 95 98 112 100 103 99 ]

色度量化表 QC

1
2
3
4
5
6
7
8
[ 17 18 24 47 99 99 99 99 ]
[ 18 21 26 66 99 99 99 99 ]
[ 24 26 56 99 99 99 99 99 ]
[ 47 66 99 99 99 99 99 99 ]
[ 99 99 99 99 99 99 99 99 ]
[ 99 99 99 99 99 99 99 99 ]
[ 99 99 99 99 99 99 99 99 ]
[ 99 99 99 99 99 99 99 99 ]

这些参数对于亮度和颜色通道是不同的。量化参数存储在图像容器中,对于 JPEG,质量取决于我们如何缩放这些参数。系数越大,应用后得到的零就越多。在更先进的编解码器中,这个过程要复杂得多。使用专门的算法来正确舍入量化的结果。此外,不同的量化参数可以应用于不同的块集,允许图像不同部分的质量有所变化。

额外内容:隔行扫描

由于不同的系数代表不同层次的细节,几乎可以免费获得隔行扫描(也称为渐进式渲染)。为此,系数被重新排序,使得具有粗糙(基本)细节的系数首先被存储(和加载)。因此,图像以多个细节层构建,称为扫描。第一次扫描显示图像的一个粗略、通常是模糊的版本。每个后续扫描逐步添加更多细节。对于大多数图像格式,这种重新排序不会增加文件大小,所以是一个有用的技巧。但对于隔行扫描的 PNG 要小心——它们使用不同的方法,这会增加文件大小。

熵编码

现在我们接近最后一步,是时候解释为什么纯色图像与随机噪声相比如此之小了。原因是熵编码。在编解码器获得量化系数之后,下一步是尽可能地缩小二进制大小。块的系数按 Zigzag 顺序扫描,生成的序列使用熵编码家族的算法进行编码。想法很简单:对出现频率更高的值使用更少的比特。让我们尝试自己编码一个基本版本。假设我们有这个数字字符串: 124 124 124 124 10 123 123 1 1 2

第一步是统计每个数字在序列中出现的频率。

1
2
// 示例:频率统计
const freq = { 124: 4, 123: 2, 1: 2, 10: 1, 2: 1 };

现在我们可以将最短的二进制代码分配给最频繁出现的值,像这样: 124 - 0 1 - 10 123 - 110 2 - 1110 10 - 1111

这样我们就可以只用 24 位而不是 88 位来编码这个字符串:000011111101101010101110 🎉。这就是 JPEG 实现压缩的方式。所使用的算法称为霍夫曼编码。一些编解码器使用更先进的算法,如算术编码或非对称数字系统。算术编码允许压缩更接近理论极限,与霍夫曼编码相比实现更高的压缩率。

注意: 这个极限是由数据的香农熵定义的。它代表了编码该数据所需的最小比特数。

后处理

有损压缩引入了各种伪影,因为它移除了高频分量。这可能导致几种可见的效果,例如:

  • 块效应 – 明显的方形图案,尤其是在平坦区域
  • 模糊的边缘
  • 颜色失真 – 不准确或偏移的颜色
  • 振铃效应 – 边缘周围的光晕或类似回声的图案
  • 蚊式噪声 – 边缘周围闪烁的小点或嗡嗡声

为了减少这些伪影,一些编码器添加后处理滤波器。这些滤波器由解码器在重建图像像素时应用。滤波器设置存储在比特流中,因此解码器可以在解码期间读取并应用它们。您可能记得应用高斯模糊来减少图像噪声的老技巧。这是类似的想法,但现代编解码器使用更先进的算法。

  • 去块效应滤波器 – 例如,JPEG XL 中使用的 Gabor 滤波器
  • 维纳滤波器 – 在 AV1 中用于减少噪声
  • CDEF(约束方向性增强滤波器) – 有助于减少振铃伪影

通常,如果使用多个滤波器,它们会形成一个具有精确应用顺序的管道。例如,CDEF 在去块效应滤波器之后应用。

总结

在这篇文章中,我们探讨了图像编码如何工作,它如何与人类感知相关联,以及解码如何从比特重建图像。大多数编解码器遵循相同的核心思想,但它们使用不同的方法和技术。它们支持更多的块大小、额外的预测模式、各种类型的变换、不同的编码算法,并且通常包括一个后处理管道来优化最终图像。显而易见的结论是,现代编解码器更复杂(有时更慢),但它们通常能产生更好的结果。然而,“更好”并不总是容易定义的。一些问题仍然存在:编解码器复杂性和输出质量之间的权衡是什么?以及在不同情况下哪种编解码器效果最好?

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计