Nicksxs's Blog

What hurts more, the pain of hard work or the pain of regret?

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

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

今天在尝试使用llama.cpp在mac下本地运行大模型的时候发现拉取llama.cpp的仓库还是比较慢,这点真的是挺难受,可能我们在玩一项技术的时候,有一大半时间都在克服这个问题,
这个方式也是谷歌搜到的,就是走 https://gh-proxy.com/ 转换镜像链接,
比如刚才的llama.cpp的仓库,https://github.com/ggml-org/llama.cpp.git 这个地址
通过 gh-proxy的转换就变成了

1
https://v4.gh-proxy.org/https://github.com/ggml-org/llama.cpp.git

但是呢有个问题,如果是走git clone我发现这个镜像地址也会是一开始比较快,后面就变慢直到速度很慢然后失败
所以另一个方法是直接用github的打包zip下载功能
一般是这种地址

1
https://github.com/ggml-org/llama.cpp/archive/refs/heads/master.zip

关键是它也可以走gh-proxy的中转,
转换之后就可以变成了

1
https://v4.gh-proxy.org/https://github.com/ggml-org/llama.cpp/archive/refs/heads/master.zip

这里的差别主要是git clone会按文件传,本身仓库会有很多小文件,所以通过压缩包的形式,相对来说通过分块传输会效率更高一些
至于今天本来想玩的本地模型,发现27B在mac上的确是不太跑得起来,甚至有点想买个512g或者256g的mac studio,不知道啥时候会出新款
苹果有没有可能财大气粗不受内存涨价的影响呢

上次我是实地使用llama.cpp跑了本地大模型,这种测试成本比较大,
刚好上次看到有个软件可以运行在本地来评估当前机器的性能可以跑什么样的大模型
它就是llmfit
在mac可以使用

1
brew install llmfit

windows可以

1
scoop install llmfit

安装好就可以直接运行了

可以看到有列了很多模型,包括参数量,token速度,占用磁盘大小,内存占用率,上下文大小等等
这样就很容易能看到我的电脑能跑什么样的模型,另外比较重要的是内存和硬盘的占用,
因为当我们真正使用的时候,除非这个电脑就被定位是只用来跑模型,不然我们一般也还会有同时在电脑上运行的程序
比如要一边写点文章,看会视频等,比如占用个50%的内存那差不多,如果要90%这种,显然是不太实际的
还有上下文长度,如果要运行对应的agent的话,还需要保障基础的上下文长度
它的原理主要是通过硬件驱动接口来识别当前设备的配置情况
比如n卡的话就是nvidia-smi,

1
2
3
4
5
6
llmfit在启动时使用sysinfo(用于RAM和CPU)和特定于供应商的工具组合读取你的系统规格:

NVIDIA:查询nvidia-smi,为多GPU设置聚合所有检测到的GPU的显存
AMD:通过rocm-smi检测
Intel Arc:从sysfs读取独立显存,通过lspci集成
Apple Silicon:通过system_profiler读取统一内存(显存 = 系统RAM,因为是共享池)

它还识别正在使用的加速后端——CUDA、Metal、ROCm、SYCL或CPU(ARM/x86)——,因为这直接影响速度估计。

除了购买各种订阅和api服务,还有一种选择就是本地运行模型,但是这个一般来讲还是只能运行一些参数量比较小的,
随着开源权重模型的发展,这个方向也在慢慢的改变,当然差距肯定是有的,只是本地我们可以用来做些辅助工作
之前很多情况下一般都只是能作为玩具,并且由于ollama和lm studio还是有一些性能损耗
之前有了解到llama.cpp这个开源项目,貌似很多lm studio等都是基于它构建的,那么直接用它是不是可以更充分的压榨我的渣渣显卡性能
首先可以在llama.cpp的github地址下载已经构建好的包
比如我是windows下,然后是在笔记本上的3060(6g)显卡,
cuda版本可以通过nvidia-smi查看,我的是12版本的

注意这里要下载两个包,一个是llama的主包,还有是后面跟着的[CUDA 12.4 DLLs]也是得下载的,不然会当成cpu模式在运行
后面的包解压后也放在前面主包解压的目录里,
这里主要是看下怎么设置参数,因为像之前说的,我有在本地运行hermes agent,它最低需要64k的上下文,那么我就是想试试哪个模型可以
看了下模型体积,只能上9B参数量左右的模型,否则都是直接爆显存
所以就试下qwen3.6 的 9B模型,可以在hugging face下载模型

1
.\llama-cli.exe -m F:\models\Qwen3.5-9B-Q4_K_M.gguf --ctx-size 65536 --flash-attn on --cache-type-k q4_0 --cache-type-v q4_0 --n-gpu-layers 28 --parallel 1

用的是 Q4_K_M 量化的,还是相对折中的,
--ctx-size 65536 代表上下文是64k,符合爱马仕的要求
--flash-attn on 是开启 Flash Attention,也是为了降低 attention 计算的显存占用
--cache-type-k q4_0 --cache-type-v q4_0 是设置了kv cache的量化大小,也是进行适当降低,防止显卡扛不住
--n-gpu-layers 26 是加载到显存26层,因为本身64k的上下文已经很占显存,所以只能加载26层,否则就会报显存oom这种
--parallel 1 表示并行度是1,因为我就单个会话使用
llama-cli.exellama-server.exe 的差别是你就在运行的窗口对话还是需要提供对话服务给Open WebUI、Cherry Studio 这些使用
当然也可以是爱马仕
直接cli运行的话,我测了下,能有6.6token/s, 勉强还可以使用的感觉,这样是占了5.2g的显存,并且如果模型用来做一些简单任务的话,还是比较可用的

一段时间都有在用大模型来辅助编程,不过现在很多都叫成vibe coding了,连胡彦斌都在vibe coding了,
现在是个全民皆可做软件开发的时代,包括像之前那个小猫补光灯,有的人的想法是说点子不重要了
我自己想的有点不一样,vibe coding现在是把成熟的开发者和小白或者完全不了解代码的人之间的差距磨平或者说缩小很多了
一旦有点子,很快就可以让大模型来做想法的实现,随着模型能力越来越强,
以前需要一定的开发知识来搞懂怎么建项目环境,构建,打包这些,都变得完全可以由大模型来处理
那么剩下的是什么呢,
一方面我理解还是对于把开发作为工作的软件工程师来说,对项目历史,背景,包袱的理解
这个很大程度也有很多人都在尝试通过SDD等方式来解决,但是目前看下来还是有一定差距,取决于项目背景的复杂度
还有一方面是做的事情是独立的还是需要多方合作,比如有个项目,涉及到四方合作开发,并且是一个流程上的上下游
你只是作为其中一个环节的开发人员,那么这个开发本身可能是问题不大,但是对于上下游的不可控,还是需要人力做比较大的介入和兜底
这里都基于一个是模型已经是比较强的情况下,如果模型是比较弱的
还需要考虑这个基础的上下文能让模型去理解,这里就有个很大的差异点,需要我们把任务,包括它的背景,上下文,项目的结构,代码逻辑都能够
作为prompt和rules等输入给模型,让它能产出质量比较好的交付代码,这个点我理解对使用者本身的要求是比较高的
并且需要进行反复的尝试纠错优化,包括结合模型能力,上下文大小等等
最终就是不同的人能对模型能力的放大或者缩小作用
我前面会用一句话来提出一些需求让大模型去开发
这些其实都是一些类似于todo工具这样网上一大堆代码样例,大模型本身可能已经学到过
但是如果是一些非常复杂包含很多背景知识和条件框架限制的,很重要的就是要把这些表达清楚
有条理,不能再试一句话需求,但也不能赘述太多撑爆上下文
再往深入纠缠下就是人如果能把一个复杂度1000的任务,拆解上12个复杂度100的任务,让模型来完成,然后再结合起来跑通这个复杂度1000的任务
也就是目前很牛的一个状态了,这里想的深入点跟架构师的能力也是类似的,首先要把这个难的任务做分解,那分解的依据就是架构本身
怎么去拆解子模块,子模块本身要完成什么目标,子模块之间要怎么衔接,非常考验人的架构能力
说到最后这都有个隐藏的前提,就是模型还未强大到面对各种复杂的任务都只要一句话就能完成
如果有那么一天,我理解可能大家都要退休了吧

0%