浅析一下jpeg图片格式及其来源

JPEG 是怎么压缩图片的?

JPEG 的核心目标是:

在人眼不太容易察觉的地方丢掉一些信息,从而大幅减小照片体积。

所以 JPEG 通常是 有损压缩。它不是简单地把文件“打包变小”,而是会真的改变图像数据。

可以把 JPEG 压缩理解成几个步骤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
RGB 图像

转换成 YCbCr

色度抽样

切成 8×8 小块

DCT 变换

量化

Zigzag 扫描

游程编码

熵编码

生成 JPEG 文件

1. RGB 转成 YCbCr

我们平时看到的图片通常可以理解成 RGB:

1
2
3
R = 红色
G = 绿色
B = 蓝色

但 JPEG 不直接用 RGB 压缩,而是会先转换成 YCbCr:

1
2
3
Y  = 亮度信息
Cb = 蓝色色度信息
Cr = 红色色度信息

这么做是因为人眼对 亮度变化 很敏感,但对 颜色细节 没那么敏感。

比如一张人脸照片:

1
2
轮廓、明暗、边缘变化很重要
颜色细节稍微模糊一点,人眼不一定明显察觉

所以 JPEG 会把图像拆成:

1
2
更重要的亮度通道 Y
没那么重要的颜色通道 Cb / Cr

后面压缩时,颜色通道可以压得更狠一些。


2. 色度抽样

既然人眼对颜色细节没那么敏感,JPEG 就可以减少颜色信息的采样数量。

常见色度抽样有:

1
2
3
4:4:4  不减少颜色信息
4:2:2 横向减少一半颜色信息
4:2:0 横向和纵向都减少颜色信息

最常见的是 4:2:0

它大概意思是:

1
2
亮度信息完整保留
颜色信息减少到原来的一部分

举个简化例子:

1
2
3
4
5
6
原始:
每个像素都有 Y、Cb、Cr

4:2:0 后:
每个像素仍然有 Y
但多个像素共用一组 Cb、Cr

这一步对照片很有效,因为自然照片里颜色通常是连续变化的。
但如果是文字截图、UI 图标、红蓝边缘特别明显的图,色度抽样可能会让边缘发糊、出现彩边。

所以:

1
2
照片适合 JPEG
截图、文字、Logo 不太适合 JPEG

这里再补充一个,就是4:2:0究竟是啥意思

YCbCr 是啥意思

YCbCr 会把一个颜色拆成三部分:

1
2
3
Y  = 亮度,也就是明暗
Cb = 蓝色色度差,大概表示“偏蓝还是不偏蓝”
Cr = 红色色度差,大概表示“偏红还是不偏红”

注意:Cb / Cr 不是直接等于蓝色和红色,而是 颜色差异信息

可以粗略理解为:

1
2
3
Y  管黑白明暗
Cb 管蓝黄方向的颜色变化
Cr 管红绿方向的颜色变化

所以一张 RGB 图片转成 YCbCr 后,可以想象成三张灰度图:

1
2
3
4
5
原图

Y 图:黑白版,保留轮廓、明暗、细节
Cb 图:颜色偏蓝 / 偏黄的信息
Cr 图:颜色偏红 / 偏绿的信息

其中最重要的是 Y 亮度图


为什么可以把亮度和颜色分开?

因为人眼看图时,最敏感的是亮度边缘,而不是颜色边缘。

比如一张白底黑字图片:

1
白底 + 黑字

你能很清楚看见文字,是因为亮度差异很大。

再比如一张人像照片:

1
2
3
4
5
脸部轮廓
鼻梁阴影
眼睛边缘
头发明暗
衣服褶皱

这些主要靠 Y 亮度信息 表达。

但颜色细节稍微粗糙一点,比如皮肤颜色、天空蓝色、墙面颜色,人的感知没那么敏锐。

所以 JPEG 的策略是:

1
2
Y 亮度:尽量保留清楚
Cb / Cr 颜色:可以减少一些

这就是色度抽样的基础。


4. RGB 转 YCbCr 是怎么转

近似公式是:

1
2
3
Y  = 0.299R + 0.587G + 0.114B
Cb = B - Y 的某种缩放
Cr = R - Y 的某种缩放

这里可以看到G是权重最大的,因为人眼对绿色最敏感,所以绿色对亮度感知贡献最大。

例如:

1
2
3
R = 200
G = 120
B = 80

那么亮度 Y 大概是:

1
2
3
Y = 0.299 × 200 + 0.587 × 120 + 0.114 × 80
≈ 59.8 + 70.4 + 9.1
≈ 139.3

所以这个像素的亮度大概是 139。

然后 Cb / Cr 负责记录它相对于这个亮度来说,颜色偏向哪里。

简单理解:

1
2
Y 记录:这个点有多亮
Cb/Cr 记录:在这个亮度基础上,它是什么颜色

刚才说的 4:4:4、4:2:2、4:2:0 是啥意思

色度通道 Cb / Cr 相对于亮度通道 Y,被保留了多少采样点。

先看一个 2×2 像素块。

原始情况下,每个像素都有:

1
Y + Cb + Cr

也就是:

1
2
3
4
像素 A: Y1 Cb1 Cr1
像素 B: Y2 Cb2 Cr2
像素 C: Y3 Cb3 Cr3
像素 D: Y4 Cb4 Cr4

6. 4:4:4:完全不减少颜色信息

4:4:4 表示:

1
2
每个像素都有自己的 Y
每个像素也都有自己的 Cb / Cr

用 2×2 看:

1
2
3
4
5
6
7
8
9
10
11
Y:
Y1 Y2
Y3 Y4

Cb:
Cb1 Cb2
Cb3 Cb4

Cr:
Cr1 Cr2
Cr3 Cr4

也就是亮度和颜色分辨率一样高。

所以:

1
4:4:4 = 不做色度抽样

质量最好,体积也更大。


7. 4:2:2:横向减少颜色信息

4:2:2 表示:

水平方向的颜色采样减半,垂直方向不减。

可以理解成:横向相邻两个像素共用一组 Cb / Cr。

比如 2×2 像素:

1
2
3
4
5
6
7
8
9
10
11
Y:
Y1 Y2
Y3 Y4

Cb:
Cb1 Cb1
Cb2 Cb2

Cr:
Cr1 Cr1
Cr2 Cr2

也就是:

1
2
第一行:两个像素共用一组颜色
第二行:两个像素共用一组颜色

亮度 Y 仍然每个像素都有。

所以:

1
4:2:2 = 横向颜色分辨率减半,纵向颜色分辨率不变

如果原来颜色采样是:

1
宽 1000,高 1000

4:2:2 的颜色通道大概变成:

1
宽 500,高 1000

8. 4:2:0:横向和纵向都减少颜色信息

4:2:0 最容易误解。它不是说“颜色没有了”,也不是说“第二行没有颜色”。

它的意思是:

色度信息在横向减半,纵向也减半。

也就是一个 2×2 像素块共用一组 Cb / Cr。

看 2×2:

1
2
3
4
5
6
7
8
9
10
11
Y:
Y1 Y2
Y3 Y4

Cb:
Cb1 Cb1
Cb1 Cb1

Cr:
Cr1 Cr1
Cr1 Cr1

也就是说:

1
2
4 个像素各自有自己的亮度 Y
但 4 个像素共用同一组颜色 Cb / Cr

所以颜色通道的分辨率变成原来的:

1
2
3
宽度 1/2
高度 1/2
总采样点 = 1/4

假设原图是:

1
1920 × 1080

Y 通道仍然是:

1
1920 × 1080

但 Cb 通道变成:

1
960 × 540

Cr 通道也变成:

1
960 × 540

这就是“横向纵向都减少颜色信息”。


这是一个前置的理解,那么关于图片格式,也想聊下怎么识别这些格式,光是后缀好像是可以被随意改动,比如这个jpeg的格式,那么我们可以从文件头来看

1
FF D8 FF E0 ... 4A 46 49 46

后面就代表的是 JFIF 这是文件头

我们也可以用一个小工具来识别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<?php

function detectImageType(string $path): string
{
$h = file_get_contents($path, false, null, 0, 32);

if ($h === false || $h === '') {
return 'unknown';
}

if (strncmp($h, "\xFF\xD8\xFF", 3) === 0) {
return 'image/jpeg';
}

if (strncmp($h, "\x89PNG\x0D\x0A\x1A\x0A", 8) === 0) {
return 'image/png';
}

if (strncmp($h, "GIF87a", 6) === 0 || strncmp($h, "GIF89a", 6) === 0) {
return 'image/gif';
}

if (strlen($h) >= 12 && substr($h, 0, 4) === "RIFF" && substr($h, 8, 4) === "WEBP") {
return 'image/webp';
}

if (strlen($h) >= 12 && substr($h, 4, 4) === "ftyp") {
$brands = substr($h, 8);

if (str_contains($brands, "avif")) {
return 'image/avif';
}

if (
str_contains($brands, "heic") ||
str_contains($brands, "heix") ||
str_contains($brands, "mif1")
) {
return 'image/heif';
}
}

return 'unknown';
}

echo detectImageType($argv[1] ?? '') . PHP_EOL;

这是通过文件头和brand来一起识别,brand是针对那些跟视频格式一致的可能包含容器的格式
比如HEIC / AVIF,对于JPEG、PNG、GIF、WebP这类常规格式,是一般从文件头识别
而后面的容器型则需要结合容器的brand标识,
比如一个AVIF的文件头是

1
00 00 00 20 66 74 79 70 61 76 69 66 00 00 00 00 61 76 69 66 6D 69 66 31

拆开以后就是

1
2
3
4
5
6
00 00 00 20 = box size
66 74 79 70 = ftyp
61 76 69 66 = avif
00 00 00 00 = minor_version
61 76 69 66 = avif
6D 69 66 31 = mif1

也就是

1
2
3
box type: ftyp
major_brand: avif
compatible_brands: avif, mif1

这样就大致知道了图片格式的识别