Rust图像处理第22节-从RGB到YCbCr: 让亮度和颜色分家

作者:花褪残红青杏小日期:2026/7/31

🦀 Rust + WASM 实战系列 第 22 篇 阅读时间:约 8 分钟 | 实战可运行

📌 写在前面

前面 21 篇,所有图像都是拿 R、G、B 三个数表示的。这一篇换一套坐标系:一个亮度 + 两个色度

手机里的每一张照片、你看的每一个视频,存进文件之前都会先做这个转换。原因很简单:人眼对亮度极其敏感,对颜色极其迟钝,把两者分开之后,颜色那部分可以随便砍

数学上,它就是一个 3×3 矩阵(19节的矩阵 × 向量)加它的逆矩阵(21节的 try_inverse


🚀 TL;DR

1RGB 表示法:              YCbCr 表示法:
2┌─────────────┐          ┌─────────────┐
3 R = 220                Y  = 181   多亮(人眼很敏感)
4 G = 170               Cb = 105   偏蓝多少(人眼迟钝)
5 B = 140                Cr = 155   偏红多少(人眼迟钝)
6└─────────────┘          └─────────────┘
7 三个数都带着亮度            亮度只在第一个数里
8 想调亮度→三个都得动         想调亮度→只动 Y
9
操作做法效果
丢掉 Cb、Cr色度按成 128变灰度图,内容照样认得出
丢掉 Y亮度按成常数完全看不出画的是啥
色度块平均2×2 取平均数据少一半,肉眼零差别
只放大 Y色度不动调亮不偏色
在 Cb-Cr 上圈一块算平面距离绿幕抠图

📖 目录

  1. 一个实验:抹掉颜色 vs 抹掉亮度
  2. RGB 的毛病:三个数都绑着亮度
  3. 换一套坐标系:Y、Cb、Cr 各是什么
  4. 操作 1:单通道可视化
  5. 操作 2:只留亮度 / 只留色度
  6. 操作 3:色度抽样(照片体积减半的真相)
  7. 操作 4:只调亮度不偏色
  8. 操作 5:绿幕抠图
  9. 前端效果展示
  10. 踩坑提醒
  11. Part 5 收官:谁来决定坐标系

一、一个实验:抹掉颜色 vs 抹掉亮度

实验 A:把一张照片的颜色信息全部删掉——你得到一张灰度图。人、字、建筑、表情,全都认得出来。这件事你早就知道了,黑白电视和黑白照片就是这么回事。

实验 B:把亮度信息全部删掉,只留颜色——结果是一片模糊的色斑,完全看不出原图是什么

1原图信息量:       亮度 ████████████████  颜色 ████
2                         人眼几乎全靠它       只是"上个色"
3
4只留颜色(实验 B): ░░░░░░░░░░░░░░░░  ████
5                     认不出来
6

结论:一张图片里,亮度承载了绝大部分"内容",颜色只是给内容涂上颜色

这个结论有巨大的工程价值:既然颜色不重要,那颜色这部分就可以少存一点、存粗糙一点,反正人也看不出来。

但要利用这个结论,得先能把"亮度"和"颜色"分开——RGB 做不到这件事。


二、RGB 的毛病:三个数都绑着亮度

RGB 的问题在于:没有任何一个通道单独代表"亮度",也没有任何一个通道单独代表"颜色"

看这三个像素:

像素RGB什么颜色
A200100100亮的暗红
B1005050暗的暗红
C200200200

A 和 B 颜色完全一样(都是同一个红色调),只是一亮一暗——但它们的 RGB 三个数字全都不同

这带来两个实际麻烦:

麻烦 1:想调亮度,必须三个通道一起动。 而且一起乘同一个系数还容易偏色——任务 19 里给 RGB 乘 1.5,R 通道本来就是 200 的会先撞到 255 被截断,G、B 还没到顶,结果高光位置偏向红色。

麻烦 2:想"少存点颜色",无从下手。 三个通道每一个都含着亮度信息,砍哪个都会让画面变暗、变脏。

根源:R、G、B 这套坐标轴是照着显示器怎么发光设计的(三个灯泡各自多亮),不是照着人眼怎么看设计的。


三、换一套坐标系:Y、Cb、Cr 各是什么

那就换一套坐标轴。新的三个数:

分量名字含义范围
YLuma,亮度这个像素多亮0 ~ 255
Cb蓝色度蓝色比亮度多出多少(B − Y 的缩放版)0 ~ 255,128 = 中性
Cr红色度红色比亮度多出多少(R − Y 的缩放版)0 ~ 255,128 = 中性

关键在于理解色度是个差值

1Cb 本质 = B - Y      蓝色分量比整体亮度高多少
2Cr 本质 = R - Y      红色分量比整体亮度高多少
3
4灰色像素(R=G=B):B - Y = 0,R - Y = 0
5                   Cb = Cr = 128(中性)
6

所以一张纯灰度图的 Cb、Cr 全是 128——颜色信息为零,完全符合直觉。

再回头看第二节那三个像素:

像素RGBYCbCr
A 亮暗红(200, 100, 100)130111178
B 暗暗红(100, 50, 50)65120153
C 白(200, 200, 200)200128128

A 和 B 的 Cb、Cr 都在同一侧(Cb < 128 偏黄、Cr > 128 偏红),颜色属性一眼可比;差别主要落在 Y 上(130 vs 65),这才是"一亮一暗"该有的表示

而 C 是纯灰,Cb = Cr = 128,干干净净。

转换矩阵(BT.601)

Y 和亮度的关系是线性的,所以整个转换就是一次矩阵 × 向量

[YCbCr]=[0.2990.5870.114−0.168736−0.3312640.50.5−0.418688−0.081312][RGB]+[0128128]\begin{bmatrix} Y \\ C_b \\ C_r \end{bmatrix} = \begin{bmatrix} 0.299 & 0.587 & 0.114 \\ -0.168736 & -0.331264 & 0.5 \\ 0.5 & -0.418688 & -0.081312 \end{bmatrix} \begin{bmatrix} R \\ G \\ B \end{bmatrix} + \begin{bmatrix} 0 \\ 128 \\ 128 \end{bmatrix}​YCb​Cr​​​=​0.299−0.1687360.5​0.587−0.331264−0.418688​0.1140.5−0.081312​​​RGB​​+​0128128​​

注意第一行:(0.299,0.587,0.114)(0.299, 0.587, 0.114)(0.299,0.587,0.114) —— 这不就是第 1 节讲过的 BT.601 灰度化系数吗?

所以 Y 通道 = 灰度图。 在第 1 篇就已经算过 YCbCr 的第一个分量了,只是当时不知道它还有两个兄弟。

第二、三行看着乱,其实是 B−YB - YB−Y 和 R−YR - YR−Y 化简后的结果:

B−Y=B−(0.299R+0.587G+0.114B)=−0.299R−0.587G+0.886BB - Y = B - (0.299R + 0.587G + 0.114B) = -0.299R - 0.587G + 0.886BB−Y=B−(0.299R+0.587G+0.114B)=−0.299R−0.587G+0.886B

再乘个缩放系数 0.5/0.886≈0.5640.5 / 0.886 \approx 0.5640.5/0.886≈0.564 把范围压进 ±128,就得到第二行。第三行同理。末尾的 +128 只是把 ±128 的范围平移到 0 ~ 255,好塞进一个 u8。

转回去:不背常数,直接求逆

教科书上的反向公式长这样:

R=Y+1.402(Cr−128),B=Y+1.772(Cb−128)R = Y + 1.402(C_r - 128), \quad B = Y + 1.772(C_b - 128)R=Y+1.402(Cr​−128),B=Y+1.772(Cb​−128)

这些数从哪来的?就是上面那个矩阵的逆矩阵——任务 21 用过的 try_inverse() 直接给你算出来,一个常数都不用背:

1use nalgebra::{Matrix3, Vector3};
2
3/// RGB  YCbCr  3×3 矩阵(BT.601 全范围 / JPEG 版)
4fn rgb_to_ycbcr_matrix() -> Matrix3<f32> {
5    Matrix3::new(
6        0.299,      0.587,      0.114,
7        -0.168_736, -0.331_264, 0.5,
8        0.5,        -0.418_688, -0.081_312,
9    )
10}
11
12/// YCbCr  RGB:不手写常数,直接对上面那个矩阵求逆
13fn ycbcr_to_rgb_matrix() -> Matrix3<f32> {
14    rgb_to_ycbcr_matrix()
15        .try_inverse()
16        .expect("BT.601 矩阵行列式非 0,一定可逆")
17}
18

实际跑出来的逆矩阵:

[101.4021−0.344136−0.71413611.7720]\begin{bmatrix} 1 & 0 & 1.402 \\ 1 & -0.344136 & -0.714136 \\ 1 & 1.772 & 0 \end{bmatrix}​111​0−0.3441361.772​1.402−0.7141360​​

和教科书一模一样。 注意第一列全是 1 —— 意思是 Y 对 R、G、B 的贡献完全相同,这正是"Y 就是亮度"的数学表述。而第一行的中间是 0(R 完全不受 Cb 影响)、第三行末尾是 0(B 完全不受 Cr 影响),也和 Cb∼B−YC_b \sim B-YCb​∼B−Y、Cr∼R−YC_r \sim R-YCr​∼R−Y 的定义对得上。

代码(Rust)

单像素的正向、反向转换,色度的 ±128 偏移在这里处理掉:

1/// 单个像素:RGB  (Y, Cb, Cr),色度已经加好 +128 偏移
2#[inline]
3fn to_ycbcr(m: &Matrix3<f32>, r: u8, g: u8, b: u8) -> (f32, f32, f32) {
4    let v = m * Vector3::new(r as f32, g as f32, b as f32);
5    (v[0], v[1] + 128.0, v[2] + 128.0)
6}
7
8/// 单个像素:(Y, Cb, Cr)  RGB,先把色度的 128 偏移减掉
9#[inline]
10fn to_rgb(inv: &Matrix3<f32>, y: f32, cb: f32, cr: f32) -> (u8, u8, u8) {
11    let v = inv * Vector3::new(y, cb - 128.0, cr - 128.0);
12    (
13        v[0].clamp(0.0, 255.0) as u8,
14        v[1].clamp(0.0, 255.0) as u8,
15        v[2].clamp(0.0, 255.0) as u8,
16    )
17}
18

后面 5 个操作全部建立在这两个函数上。


四、操作 1:单通道可视化

把 Y、Cb、Cr 分别当成灰度值画出来,直观看看这三个通道各自长什么样。

你会看到什么

  • Y 通道:一张正常的黑白照片,细节全在
  • Cb 通道:大片中灰(128)。天空、蓝衣服偏白(Cb 高),皮肤、黄色物体偏黑(Cb 低)。几乎没有细节纹理
  • Cr 通道:同样大片中灰。嘴唇、红色物体偏白,绿植偏黑

Cb、Cr 那种"糊成一团"的观感非常关键——它说明色度通道天生就缺少高频细节,这是第六节能大砍色度数据的物理基础。

效果:

代码(Rust)

1#[wasm_bindgen]
2pub fn ycbcr_channel(pixels: &[u8], width: u32, height: u32, channel: &str) -> Vec<u8> {
3    let n = (width * height) as usize;
4    let m = rgb_to_ycbcr_matrix();
5    let mut out = vec![0u8; pixels.len()];
6
7    for i in 0..n {
8        let idx = i * 4;
9        let (y, cb, cr) = to_ycbcr(&m, pixels[idx], pixels[idx + 1], pixels[idx + 2]);
10
11        let v = match channel {
12            "cb" => cb,
13            "cr" => cr,
14            _ => y,
15        };
16        let g = v.clamp(0.0, 255.0) as u8;
17
18        out[idx] = g;          // 单通道值同时写进 R、G、B
19        out[idx + 1] = g;      //  显示成灰度图
20        out[idx + 2] = g;
21        out[idx + 3] = pixels[idx + 3];
22    }
23
24    out
25}
26

五、操作 2:只留亮度 / 只留色度

把第一节那两个实验真正跑出来。

只留亮度:把 Cb、Cr 全部按成 128,再转回 RGB。因为 Cb = Cr = 128 意味着"没有颜色偏向",转回来必然是 R = G = B —— 得到一张灰度图

只留色度:把 Y 按成一个常数(比如 128),Cb、Cr 原样保留。这时候画面里所有明暗对比全部消失,只剩下色相,结果是一团认不出内容的色斑。

1原图 ──┬─→  Cb、Cr  灰度图       内容清清楚楚
2       └─→  Y       一片色斑     完全认不出
3
4两边丢掉的数据量一样多(都是 1 个通道 vs 2 个通道),
5但信息价值天差地别。
6

这就是整套色度压缩技术的立论依据:该省的省在色度上,绝不能省在亮度上

效果:(只留亮度见上一操作

代码(Rust)

1/// 只保留亮度:把 Cb、Cr 全部按成 128(中性灰)
2#[wasm_bindgen]
3pub fn ycbcr_luma_only(pixels: &[u8], width: u32, height: u32) -> Vec<u8> {
4    let n = (width * height) as usize;
5    let m = rgb_to_ycbcr_matrix();
6    let inv = ycbcr_to_rgb_matrix();
7    let mut out = vec![0u8; pixels.len()];
8
9    for i in 0..n {
10        let idx = i * 4;
11        let (y, _, _) = to_ycbcr(&m, pixels[idx], pixels[idx + 1], pixels[idx + 2]);
12        let (r, g, b) = to_rgb(&inv, y, 128.0, 128.0);  // 色度按成中性
13
14        out[idx] = r;
15        out[idx + 1] = g;
16        out[idx + 2] = b;
17        out[idx + 3] = pixels[idx + 3];
18    }
19
20    out
21}
22
23/// 只保留色度:把 Y 按成常数
24#[wasm_bindgen]
25pub fn ycbcr_chroma_only(pixels: &[u8], width: u32, height: u32, fixed_luma: f32) -> Vec<u8> {
26    let n = (width * height) as usize;
27    let m = rgb_to_ycbcr_matrix();
28    let inv = ycbcr_to_rgb_matrix();
29    let mut out = vec![0u8; pixels.len()];
30
31    for i in 0..n {
32        let idx = i * 4;
33        let (_, cb, cr) = to_ycbcr(&m, pixels[idx], pixels[idx + 1], pixels[idx + 2]);
34        let (r, g, b) = to_rgb(&inv, fixed_luma, cb, cr);  // 亮度按成常数
35
36        out[idx] = r;
37        out[idx + 1] = g;
38        out[idx + 2] = b;
39        out[idx + 3] = pixels[idx + 3];
40    }
41
42    out
43}
44

六、操作 3:色度抽样(照片体积减半的真相)

前两节证明了"色度不重要",这一节来兑现收益。

做法:亮度一个像素都不动,色度按 2×2 的块取平均——四个像素共用一组 Cb、Cr。

1亮度 Y(全保留)        色度 Cb(2×2 取平均)
2┌──┬──┬──┬──┐          ┌─────┬─────┐
3│Y1│Y2│Y3│Y4│                    
4├──┼──┼──┼──┤           平均  平均 
5│Y5│Y6│Y7│Y8│                    
6└──┴──┴──┴──┘          └─────┴─────┘
7 8 个数                    2 个数(原来 8 个)
8

数据量账:原来 3 个通道各 nnn 个数 = 3n3n3n。现在亮度 nnn 个 + 色度 2×n/42 \times n/42×n/4 = 1.5n1.5n1.5n。直接砍掉一半

这就是 4:2:0 色度抽样,JPEG 的默认设置、H.264 / H.265 视频的默认设置、你手机相机的默认设置。你手机里每一张照片的颜色,其实都是 2×2 一块块糊过的——但你从来没看出来。

把块调大到 4×4(4:1:0),数据量降到 1.125n1.125n1.125n,这时候才开始能看出破绽:红色字体边缘发虚、鲜艳色块边缘出现"颜色渗出"。

块大小名称数据量肉眼
1×14:4:43n3n3n原图
2×24:2:01.5n1.5n1.5n看不出区别
4×44:1:01.125n1.125n1.125n彩色边缘发虚

为什么专业修图要用 4:4:4:色度抽样对拍照片无害,但如果你要在后期抠图、调色、加特效,糊过的色度边缘会让抠图边缘出现锯齿和色斑——所以影视制作的素材坚持用 4:4:4,成片才降到 4:2:0。

代码(Rust)

1#[wasm_bindgen]
2pub fn chroma_subsample(pixels: &[u8], width: u32, height: u32, block: u32) -> Vec<u8> {
3    let w = width as usize;
4    let h = height as usize;
5    let b_size = block.max(1) as usize;
6
7    let m = rgb_to_ycbcr_matrix();
8    let inv = ycbcr_to_rgb_matrix();
9
10    // 1. 先整幅转到 YCbCr
11    let mut ys = vec![0.0f32; w * h];
12    let mut cbs = vec![0.0f32; w * h];
13    let mut crs = vec![0.0f32; w * h];
14    for i in 0..(w * h) {
15        let idx = i * 4;
16        let (y, cb, cr) = to_ycbcr(&m, pixels[idx], pixels[idx + 1], pixels[idx + 2]);
17        ys[i] = y;
18        cbs[i] = cb;
19        crs[i] = cr;
20    }
21
22    // 2. 色度按块平均(亮度一个都不动)
23    for by in (0..h).step_by(b_size) {
24        for bx in (0..w).step_by(b_size) {
25            let mut sum_cb = 0.0;
26            let mut sum_cr = 0.0;
27            let mut count = 0.0;
28
29            for y in by..(by + b_size).min(h) {
30                for x in bx..(bx + b_size).min(w) {
31                    sum_cb += cbs[y * w + x];
32                    sum_cr += crs[y * w + x];
33                    count += 1.0;
34                }
35            }
36
37            let avg_cb = sum_cb / count;
38            let avg_cr = sum_cr / count;
39
40            // 平均值摊回块内每个像素
41            for y in by..(by + b_size).min(h) {
42                for x in bx..(bx + b_size).min(w) {
43                    cbs[y * w + x] = avg_cb;
44                    crs[y * w + x] = avg_cr;
45                }
46            }
47        }
48    }
49
50    // 3. 转回 RGB
51    let mut out = vec![0u8; pixels.len()];
52    for i in 0..(w * h) {
53        let idx = i * 4;
54        let (r, g, b) = to_rgb(&inv, ys[i], cbs[i], crs[i]);
55        out[idx] = r;
56        out[idx + 1] = g;
57        out[idx + 2] = b;
58        out[idx + 3] = pixels[idx + 3];
59    }
60
61    out
62}
63

真实的 JPEG 会把平均后的色度真的存成 1/4 大小的小图,解码时再插值放大。这里为了能直接和原图对比,选择"平均完立刻摊回去"——视觉效果等价,只是没省下内存


七、操作 4:只调亮度不偏色

任务 19 调亮度的做法是给 RGB 三个通道同乘一个系数。问题在于截断

1原像素 (240, 180, 120)  1.3:
2  R: 240 × 1.3 = 312  截断成 255   只涨了 15
3  G: 180 × 1.3 = 234                 涨了 54
4  B: 120 × 1.3 = 156                 涨了 36
5
6R 被"卡住"了,G、B 继续涨  三者比例被破坏  颜色变了
7

高光部分会往图片的次要色调漂移,暖色照片调亮之后发黄发灰,这是 RGB 调亮度的经典毛病。

YCbCr 的做法:只把 Y 乘上系数,Cb、Cr 一个字节都不动。色度不变 = 色相和饱和度严格不变,画面只是整体变亮。

1RGB 调亮:  三个数一起变  比例乱  偏色
2YCbCr 调亮:只有 Y      色度原封不动  不偏色
3

这就是为什么修图软件里的"曝光"和"饱和度"是两个独立滑块——底层它们动的本来就是不同的分量。

代码(Rust)

1#[wasm_bindgen]
2pub fn ycbcr_adjust_luma(pixels: &[u8], width: u32, height: u32, gain: f32) -> Vec<u8> {
3    let n = (width * height) as usize;
4    let m = rgb_to_ycbcr_matrix();
5    let inv = ycbcr_to_rgb_matrix();
6    let mut out = vec![0u8; pixels.len()];
7
8    for i in 0..n {
9        let idx = i * 4;
10        let (y, cb, cr) = to_ycbcr(&m, pixels[idx], pixels[idx + 1], pixels[idx + 2]);
11
12        // 只动 Y,色度原样传下去
13        let (r, g, b) = to_rgb(&inv, (y * gain).clamp(0.0, 255.0), cb, cr);
14
15        out[idx] = r;
16        out[idx + 1] = g;
17        out[idx + 2] = b;
18        out[idx + 3] = pixels[idx + 3];
19    }
20
21    out
22}
23

八、操作 5:绿幕抠图

最后一个应用,也是最能体现"分家"价值的一个。

朴素做法(在 RGB 里判)G > R && G > B 就算绿色,抠掉。结果一定翻车——绿幕布料有褶皱、有阴影,亮处的绿和暗处的绿 RGB 值差一大截,阈值卡在哪里都不对:卡松了人物的头发和衣服被误伤,卡紧了绿幕的暗部抠不干净。

根源还是那个老问题:RGB 里"是不是绿色"和"有多亮"混在一起了。

YCbCr 做法:绿幕不管亮部暗部,颜色是同一个颜色,所以它们的 Cb、Cr 几乎一样,只有 Y 不同。那就只看 Cb、Cr,完全不看 Y

1        Cr(红色度)
2         
3     128          肤色 (105, 155)
4         
5              绿幕 (98, 76),褶皱阴影全都聚在这一小团
6      76     圈一个半径,圈内的抠掉
7         └────┬──────┬────→ Cb(蓝色度)
8             98     128
9

判据就是平面上的一个距离

d=(Cb−Cb,key)2+(Cr−Cr,key)2d = \sqrt{(C_b - C_{b,\text{key}})^2 + (C_r - C_{r,\text{key}})^2}d=(Cb​−Cb,key​)2+(Cr​−Cr,key​)2​

  • d<rd < rd<r → 完全透明
  • r∼r+softr \sim r + \text{soft}r∼r+soft → 线性过渡(边缘羽化,不加这一段头发边缘会全是锯齿)
  • 更远 → 保留

亮度被彻底排除在判据之外,所以绿幕的高光和阴影会落在同一个点附近,一个阈值全部搞定。

顺带:把圆心挪到 (105, 155) 附近就是肤色检测——最早的人脸检测、美颜里的磨皮选区,用的就是这一招。

代码(Rust)

1#[wasm_bindgen]
2pub fn chroma_key(
3    pixels: &[u8],
4    width: u32,
5    height: u32,
6    cb_key: f32,
7    cr_key: f32,
8    radius: f32,
9    softness: f32,
10) -> Vec<u8> {
11    let n = (width * height) as usize;
12    let m = rgb_to_ycbcr_matrix();
13    let soft = softness.max(1e-3);
14    let mut out = pixels.to_vec();
15
16    for i in 0..n {
17        let idx = i * 4;
18        // 只取 Cb、Cr,Y 直接丢掉——判据和亮度无关
19        let (_, cb, cr) = to_ycbcr(&m, pixels[idx], pixels[idx + 1], pixels[idx + 2]);
20
21        let dist = ((cb - cb_key).powi(2) + (cr - cr_key).powi(2)).sqrt();
22        let alpha = ((dist - radius) / soft).clamp(0.0, 1.0);  // 羽化过渡
23
24        out[idx + 3] = (pixels[idx + 3] as f32 * alpha) as u8;
25    }
26
27    out
28}
29

九、注意事项

1. 别忘了 ±128 偏移

Cb、Cr 的数学范围是 −128 ~ +127,存进 u8 之前要 +128,取出来算之前要 −128。漏掉任何一边,画面立刻变成诡异的荧光色。

判断办法很简单:拿一张纯灰度图去跑,Cb、Cr 应该全是 128。如果跑出来全是 0,说明偏移漏了。

2. 两套系数:BT.601 和 BT.709

标准Y 系数用在哪
BT.601(0.299, 0.587, 0.114)标清视频、JPEG
BT.709(0.2126, 0.7152, 0.0722)高清视频、HDTV / 现在的绝大多数视频

两套混用的后果是色偏——用 601 编码、709 解码,画面会整体偏绿偏暗。本文和 JPEG 保持一致用 601。

3. 还有"全范围"和"视频范围"之分

本文用的是全范围,两头留出余量给信号超调。

这就是有些视频播放器会出现"黑的不够黑、白的发灰"的原因——范围搞错了

4. 转换会有精度损失

RGB → YCbCr → RGB 转一圈回来,因为中途要 clamp 到 u8,会有 ±1 的误差。转一次两次无所谓,但别在循环里反复转,误差会累积。要连续做多个操作,就在 YCbCr 空间里一次做完再转回去

5. 色度抽样不要用在需要后期的素材上

第六节提过:抠图、调色、加字幕之前,色度必须是 4:4:4 的。对已经 4:2:0 的素材做抠图,边缘一定出锯齿和色斑。压缩要放在流水线的最后一步。


十、Part 5 汇总:谁来决定坐标系

这一篇和任务 20 的 PCA,其实在做同一件事给 RGB 换一套更好用的坐标轴。区别只在于——坐标轴是谁定的

任务 20 里,PCA 对着一张真实照片算出来的第一主成分是:

PC1≈(0.577, 0.577, 0.577)PC_1 \approx (0.577,\ 0.577,\ 0.577)PC1​≈(0.577, 0.577, 0.577)

也就是灰阶轴。而这一篇的 Y 通道系数是人类定的:

Y=(0.299, 0.587, 0.114)Y = (0.299,\ 0.587,\ 0.114)Y=(0.299, 0.587, 0.114)

也是一条灰阶轴,只是按人眼对绿色更敏感做了加权。

1PCA:   看完你的数据  算出"这张图变化最大的方向"  每张图的轴都不一样
2YCbCr: 看完人眼的特性  定死一套轴  全世界所有图共用
3

两条路各有各的赢面

PCAYCbCr
谁定坐标轴数据自己(每张图重算)人类定死(BT.601 常数表)
依据方差最大人眼敏感度
压缩率理论最优略差一点
代价每张图都要存自己的轴零成本,全世界通用

PCA 的压缩率理论上更好,但每张图都得把自己那套轴一起存进文件,解码器还得先读轴再解图。YCbCr 用一套定死的常数换来"全世界的编解码器都认识"——所以赢的是 YCbCr,JPEG、H.264、你手机相机,用的全是它。

Part 5 完整内容

章节内容涉及计算部分
19手调 3×3 系数改颜色矩阵 × 向量
20数据自己算出最优坐标系协方差 + SVD
21数据自己拟合出变换参数矩阵求逆
22人类定好的坐标系换掉 RGB3×3 矩阵 + 逆矩阵

四篇一句话:矩阵不是"数字表格",是换一个角度看同一份数据的工具。至于角度谁来选——数据选(20、21)还是人选(19、22)——这是工程决策,不是数学问题。

  • Y 通道就是灰度图——你在第 1 篇就算过它了
  • 人眼几乎只看亮度:丢掉色度还认得出,丢掉亮度就废了
  • 4:2:0 色度抽样让你的每一张照片省了一半数据,而你完全看不出来
  • 只调 Y 不动色度 = 调亮度不偏色
  • 在 Cb-Cr 平面上圈个圆 = 绿幕抠图 / 肤色检测
  • 转回 RGB 不用背常数try_inverse() 就能算出 1.402 和 1.772

下一部分预告:第六部分 概率的图像魔法——高斯噪声、椒盐噪声、直方图均衡化。到时候你会发现,彩色图的直方图均衡化,第一步就是转成 YCbCr 只处理 Y 通道。这一篇的东西马上就要用上。


一句话总结

YCbCr 就是把 RGB 乘上一个 3×3 矩阵,换成"一个亮度 + 两个色度"。

换完之后,人眼看重的和人眼不在乎的被分到了不同的通道里——于是可以放心大胆地砍掉不在乎的那部分。

你手机里每一张照片的颜色都是 2×2 糊过的,而你从来没发现。


📦 项目地址pixel-math-wasm🦀 Rust + WebAssembly 实战系列


🏷️ 标签#Rust #WebAssembly #色彩空间 #YCbCr #色度抽样 #绿幕抠图 #JPEG #nalgebra


Rust图像处理第22节-从RGB到YCbCr: 让亮度和颜色分家》 是转载文章,点击查看原文


相关推荐


🤯 面试被问 AI Workflow 和 Agent 有啥区别?3 张图 + 2 段代码讲清楚!
ReBound2026/7/23

🤯 面试被问 AI Workflow 和 Agent 有啥区别?3 张图 + 2 段代码讲清楚! 摘要:Workflow 和 Agent 到底有啥区别?为什么 Coze 工作流和 ReAct Agent 看起来都在"执行任务",本质却完全不同?本文从面试翻车经历出发,结合 Anthropic 官方定义、ReAct 框架、完整代码示例和实战选型指南,帮你彻底搞懂这对最容易混淆的 AI 概念。 📌 前言 上周面试,面试官笑眯眯地问了一句: "你说说 AI Workflow 和 Agent


大模型入门:从“猜词游戏“到“超级大脑“,一篇读懂 AI 大模型
修己xj2026/7/15

2023 年初,ChatGPT 横空出世,"大模型"三个字一夜之间刷爆了所有人的朋友圈。有人拿它写代码,有人拿它写情书,还有人拿它辅导孩子做数学——而且它居然真的会。 可当你真正想搞懂"大模型到底是什么"时,迎面而来的却是满屏的"Transformer""自注意力""千亿参数",瞬间劝退。 别慌。这篇文章,我们用打比方的方式,把大模型从里到外讲清楚。 一、什么是大模型?先搞懂"大"在哪 今天大家口中的大模型,通常特指大语言模型(LLM,Large Language Model)。ChatGPT


【Java实习面试算法冲刺】双指针
ZenithSourceQuest2026/7/6

第2类题型:双指针 为什么双指针题看起来不难,你一到面试就容易写乱 很多同学第一次刷双指针时,会觉得这类题比哈希表还“直观”。因为代码通常不长,变量也常常只有 left、right、slow、fast 四个名字。但真正到了面试现场,双指针反而很容易暴露出两类问题: 你会套模板,但说不清两个指针各自代表什么。你知道要移动某一边,却解释不出“为什么这样移动不会漏解”。你能把 三数之和 写个大概,却总在去重和边界上翻车。你把“会写代码”当成“理解题型”,结果一换题面就不稳。 如果你


论虚拟线程与 Kotlin IO 协程:资源开销、时长、高并发表现及适用场景与技术选型思考
zimoyin2026/6/28

在现代并发编程中,虚拟线程(由 Java 20+ 引入)和 Kotlin IO 协程(基于 Dispatchers.IO)是两种高效处理异步任务的技术框架。在资源开销、时长(特别是长时间 IO)、高并发场景表现、以及何时选择合适方案等方面各有特点。本文将逐一展开对比分析。 1. 资源开销对比 虚拟线程和协程的核心开销差异源于其底层设计原理: 维度虚拟线程Kotlin IO 协程关键差异内存开销固定栈机制:默认约 1MB1\text{MB}1MB 占⽤[注1]动态内存分配:2KB∼512KB2


当 AI 学会「自己催自己」:对 Loop Engineering 的理解与思考
莫西很trouble2026/6/19

先说一个让我「咯噔」一下的瞬间 前段时间看到 Claude Code 负责人 Boris Cherny 说了句话,大意是:我已经不写 Prompt 了,我只写 Loop 我的第一反应是:啊?Prompt 不是刚学会怎么写好吗?怎么就又过时了? 但仔细想了下这句话背后的意思,突然意识到它不是在讲什么新技术,而是在讲一个我们早就该意识到的问题 ——如果每次用 AI,你都要在它身边喊「继续」「还是报错」「你改了啥」「回滚」,那说明你其实不是在用工具,你是在当监工 而 Loop Engineer


限流:从单机QPS计数器到分布式三层防御体系
程序员小策2026/6/11

大家好,我是程序员小策。 先说一个反直觉的事实:加了限流之后,你系统的成功请求数量反而可能变多。 听起来很荒诞对吧?限流的字面意思就是"拦住一部分请求",拦住了怎么可能变多? 但数据不会骗人: 场景总请求数成功数成功率不限流5000000%加了限流50000500010% 不限流的时候,50000 个请求全部涌入数据库,连接池打满,超时重试又制造了一倍流量,雪崩导致所有接口全部失败——包括那些只想来浏览商品页的正常用户。 加了限流之后,50000 个请求里被拦掉了 45000 个,但这


Java学习笔记之泛型
飞翔网2026/6/4

前言 写 Java 代码时,你一定见过 List<String>、Map<Integer, String> 这种尖括号写法。这就是泛型(Generics)——Java 5 引入的最重要的语言特性之一。在没有泛型的时代,集合里塞什么都可以,取出来必须强制转型,稍不注意就 ClassCastException(类转型异常)。泛型的出现让类型安全从运行时提到了编译期。 但这只是泛型的冰山一角。泛型真正的难点在于类型擦除、通配符、PECS 原则——理解了这些,你才算真正掌握了泛型。 一、概念:什么是泛


【SpringBoot+Elasticsearch 内容搜索系统实战】:架构设计与全流程实现
fengxin_rou2026/5/29

🔥你好我是fengxin_rou这是我的个人主页fengxin_rou的主页 ❄️欢迎查看我的专栏我的专栏 《Java后端学习》、《JAVASE基础》、《JUC并发》、《redis》、《JVM虚拟机》、《MYSQL》、《黑马点评》、《rabbitmq》、《JavaWeb+AI的talis学习系统》、《苍穹外卖》 目录 前言 一、Elasticsearch 索引设计与初始化 1.1 核心概念类比 1.2 索引初始化实现 1.3 字段设计要点 二、搜索索引数据写入与同步机


深入理解 Kotlin 协程 (六):进退有度,解密协程取消响应与异常分发机制
雨白2026/5/7

协程的取消机制 取消协程需要协程内部配合,这点和线程一样,本质上也是协作式的取消,就是将状态设置为取消,协程内部根据状态的变化来响应。 完善 Job 的状态流转与取消通知 我们基于上一篇博客中的代码,来完善协程的取消逻辑。 首先支持协程取消回调的注册: // [AbstractCoroutine.kt] override fun invokeOnCancel(onCancel: OnCancel): Disposable { // 1. 创建回调包装对象,以便后续可以手动解绑 v


Flink+Kafka:数据流处理实战指南
渣渣盟2026/4/27

目录 代码结构 代码解析 (1) 主程序入口 (2) 定义数据流 (3) 使用旧版 Kafka Sink (4) 使用新版 Kafka Sink (5) 将数据写入 Kafka (6) 执行任务 代码优化 交付保证 异常处理 动态 Topic 优化后的代码 这段代码展示了如何使用 Apache Flink 将数据流写入 Kafka,并提供了两种不同的 Kafka Sink 实现方式。以下是对代码的详细解析和说明: 代码结构 包声明:package sink

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 聚合阅读