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

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

📌 写在前面

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

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

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


🚀 TL;DR

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

📖 目录

  1. [一个实验:抹掉颜色 vs 抹掉亮度](#一个实验:抹掉颜色 vs 抹掉亮度 "#%E4%B8%80%E4%B8%80%E4%B8%AA%E5%AE%9E%E9%AA%8C%E6%8A%B9%E6%8E%89%E9%A2%9C%E8%89%B2-vs-%E6%8A%B9%E6%8E%89%E4%BA%AE%E5%BA%A6")
  2. [RGB 的毛病:三个数都绑着亮度](#RGB 的毛病:三个数都绑着亮度 "#%E4%BA%8Crgb-%E7%9A%84%E6%AF%9B%E7%97%85%E4%B8%89%E4%B8%AA%E6%95%B0%E9%83%BD%E7%BB%91%E7%9D%80%E4%BA%AE%E5%BA%A6")
  3. [换一套坐标系:Y、Cb、Cr 各是什么](#换一套坐标系:Y、Cb、Cr 各是什么 "#%E4%B8%89%E6%8D%A2%E4%B8%80%E5%A5%97%E5%9D%90%E6%A0%87%E7%B3%BBycbcr-%E5%90%84%E6%98%AF%E4%BB%80%E4%B9%88")
  4. [操作 1:单通道可视化](#操作 1:单通道可视化 "#%E5%9B%9B%E6%93%8D%E4%BD%9C-1%E5%8D%95%E9%80%9A%E9%81%93%E5%8F%AF%E8%A7%86%E5%8C%96")
  5. [操作 2:只留亮度 / 只留色度](#操作 2:只留亮度 / 只留色度 "#%E4%BA%94%E6%93%8D%E4%BD%9C-2%E5%8F%AA%E7%95%99%E4%BA%AE%E5%BA%A6--%E5%8F%AA%E7%95%99%E8%89%B2%E5%BA%A6")
  6. [操作 3:色度抽样(照片体积减半的真相)](#操作 3:色度抽样(照片体积减半的真相) "#%E5%85%AD%E6%93%8D%E4%BD%9C-3%E8%89%B2%E5%BA%A6%E6%8A%BD%E6%A0%B7%E7%85%A7%E7%89%87%E4%BD%93%E7%A7%AF%E5%87%8F%E5%8D%8A%E7%9A%84%E7%9C%9F%E7%9B%B8")
  7. [操作 4:只调亮度不偏色](#操作 4:只调亮度不偏色 "#%E4%B8%83%E6%93%8D%E4%BD%9C-4%E5%8F%AA%E8%B0%83%E4%BA%AE%E5%BA%A6%E4%B8%8D%E5%81%8F%E8%89%B2")
  8. [操作 5:绿幕抠图](#操作 5:绿幕抠图 "#%E5%85%AB%E6%93%8D%E4%BD%9C-5%E7%BB%BF%E5%B9%95%E6%8A%A0%E5%9B%BE")
  9. 前端效果展示
  10. 踩坑提醒
  11. [Part 5 收官:谁来决定坐标系](#Part 5 收官:谁来决定坐标系 "#%E5%8D%81%E4%B8%80part-5-%E6%94%B6%E5%AE%98%E8%B0%81%E6%9D%A5%E5%86%B3%E5%AE%9A%E5%9D%90%E6%A0%87%E7%B3%BB")

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

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

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

markdown 复制代码
原图信息量:       亮度 ████████████████  颜色 ████
                        ↑ 人眼几乎全靠它      ↑ 只是"上个色"

只留颜色(实验 B): ░░░░░░░░░░░░░░░░  ████
                    → 认不出来

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

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

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


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

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

看这三个像素:

像素 R G B 什么颜色
A 200 100 100 亮的暗红
B 100 50 50 暗的暗红
C 200 200 200

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

这带来两个实际麻烦:

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

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

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


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

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

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

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

ini 复制代码
Cb 本质 = B - Y   →   蓝色分量比整体亮度高多少
Cr 本质 = R - Y   →   红色分量比整体亮度高多少

灰色像素(R=G=B):B - Y = 0,R - Y = 0
                  → Cb = Cr = 128(中性)

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

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

像素 RGB Y Cb Cr
A 亮暗红 (200, 100, 100) 130 111 178
B 暗暗红 (100, 50, 50) 65 120 153
C 白 (200, 200, 200) 200 128 128

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

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

转换矩阵(BT.601)

Y 和亮度的关系是线性的,所以整个转换就是一次矩阵 × 向量
Y Cb Cr = 0.299 0.587 0.114 −0.168736 −0.331264 0.5 0.5 −0.418688 −0.081312 R G B + 0 128 128 \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} YCbCr = 0.299−0.1687360.50.587−0.331264−0.4186880.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 - Y B−Y 和 R−YR - Y R−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.886B B−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.564 0.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() 直接给你算出来,一个常数都不用背:

rust 复制代码
use nalgebra::{Matrix3, Vector3};

/// RGB → YCbCr 的 3×3 矩阵(BT.601 全范围 / JPEG 版)
fn rgb_to_ycbcr_matrix() -> Matrix3<f32> {
    Matrix3::new(
        0.299,      0.587,      0.114,
        -0.168_736, -0.331_264, 0.5,
        0.5,        -0.418_688, -0.081_312,
    )
}

/// YCbCr → RGB:不手写常数,直接对上面那个矩阵求逆
fn ycbcr_to_rgb_matrix() -> Matrix3<f32> {
    rgb_to_ycbcr_matrix()
        .try_inverse()
        .expect("BT.601 矩阵行列式非 0,一定可逆")
}

实际跑出来的逆矩阵:
1 0 1.402 1 −0.344136 −0.714136 1 1.772 0 \begin{bmatrix} 1 & 0 & 1.402 \\ 1 & -0.344136 & -0.714136 \\ 1 & 1.772 & 0 \end{bmatrix} 1110−0.3441361.7721.402−0.7141360

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

代码(Rust)

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

rust 复制代码
/// 单个像素:RGB → (Y, Cb, Cr),色度已经加好 +128 偏移
#[inline]
fn to_ycbcr(m: &Matrix3<f32>, r: u8, g: u8, b: u8) -> (f32, f32, f32) {
    let v = m * Vector3::new(r as f32, g as f32, b as f32);
    (v[0], v[1] + 128.0, v[2] + 128.0)
}

/// 单个像素:(Y, Cb, Cr) → RGB,先把色度的 128 偏移减掉
#[inline]
fn to_rgb(inv: &Matrix3<f32>, y: f32, cb: f32, cr: f32) -> (u8, u8, u8) {
    let v = inv * Vector3::new(y, cb - 128.0, cr - 128.0);
    (
        v[0].clamp(0.0, 255.0) as u8,
        v[1].clamp(0.0, 255.0) as u8,
        v[2].clamp(0.0, 255.0) as u8,
    )
}

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


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

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

你会看到什么

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

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

效果:

代码(Rust)

rust 复制代码
#[wasm_bindgen]
pub fn ycbcr_channel(pixels: &[u8], width: u32, height: u32, channel: &str) -> Vec<u8> {
    let n = (width * height) as usize;
    let m = rgb_to_ycbcr_matrix();
    let mut out = vec![0u8; pixels.len()];

    for i in 0..n {
        let idx = i * 4;
        let (y, cb, cr) = to_ycbcr(&m, pixels[idx], pixels[idx + 1], pixels[idx + 2]);

        let v = match channel {
            "cb" => cb,
            "cr" => cr,
            _ => y,
        };
        let g = v.clamp(0.0, 255.0) as u8;

        out[idx] = g;          // 单通道值同时写进 R、G、B
        out[idx + 1] = g;      // → 显示成灰度图
        out[idx + 2] = g;
        out[idx + 3] = pixels[idx + 3];
    }

    out
}

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

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

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

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

markdown 复制代码
原图 ──┬─→ 丢 Cb、Cr → 灰度图      → 内容清清楚楚
       └─→ 丢 Y      → 一片色斑    → 完全认不出

两边丢掉的数据量一样多(都是 1 个通道 vs 2 个通道),
但信息价值天差地别。

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

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

代码(Rust)

rust 复制代码
/// 只保留亮度:把 Cb、Cr 全部按成 128(中性灰)
#[wasm_bindgen]
pub fn ycbcr_luma_only(pixels: &[u8], width: u32, height: u32) -> Vec<u8> {
    let n = (width * height) as usize;
    let m = rgb_to_ycbcr_matrix();
    let inv = ycbcr_to_rgb_matrix();
    let mut out = vec![0u8; pixels.len()];

    for i in 0..n {
        let idx = i * 4;
        let (y, _, _) = to_ycbcr(&m, pixels[idx], pixels[idx + 1], pixels[idx + 2]);
        let (r, g, b) = to_rgb(&inv, y, 128.0, 128.0);  // 色度按成中性

        out[idx] = r;
        out[idx + 1] = g;
        out[idx + 2] = b;
        out[idx + 3] = pixels[idx + 3];
    }

    out
}

/// 只保留色度:把 Y 按成常数
#[wasm_bindgen]
pub fn ycbcr_chroma_only(pixels: &[u8], width: u32, height: u32, fixed_luma: f32) -> Vec<u8> {
    let n = (width * height) as usize;
    let m = rgb_to_ycbcr_matrix();
    let inv = ycbcr_to_rgb_matrix();
    let mut out = vec![0u8; pixels.len()];

    for i in 0..n {
        let idx = i * 4;
        let (_, cb, cr) = to_ycbcr(&m, pixels[idx], pixels[idx + 1], pixels[idx + 2]);
        let (r, g, b) = to_rgb(&inv, fixed_luma, cb, cr);  // 亮度按成常数

        out[idx] = r;
        out[idx + 1] = g;
        out[idx + 2] = b;
        out[idx + 3] = pixels[idx + 3];
    }

    out
}

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

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

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

复制代码
亮度 Y(全保留)        色度 Cb(2×2 取平均)
┌──┬──┬──┬──┐          ┌─────┬─────┐
│Y1│Y2│Y3│Y4│          │     │     │
├──┼──┼──┼──┤          │ 平均 │ 平均 │
│Y5│Y6│Y7│Y8│          │     │     │
└──┴──┴──┴──┘          └─────┴─────┘
 8 个数                    2 个数(原来 8 个)

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

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

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

块大小 名称 数据量 肉眼
1×1 4:4:4 3n3n 3n 原图
2×2 4:2:0 1.5n1.5n 1.5n 看不出区别
4×4 4:1:0 1.125n1.125n 1.125n 彩色边缘发虚

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

代码(Rust)

rust 复制代码
#[wasm_bindgen]
pub fn chroma_subsample(pixels: &[u8], width: u32, height: u32, block: u32) -> Vec<u8> {
    let w = width as usize;
    let h = height as usize;
    let b_size = block.max(1) as usize;

    let m = rgb_to_ycbcr_matrix();
    let inv = ycbcr_to_rgb_matrix();

    // 1. 先整幅转到 YCbCr
    let mut ys = vec![0.0f32; w * h];
    let mut cbs = vec![0.0f32; w * h];
    let mut crs = vec![0.0f32; w * h];
    for i in 0..(w * h) {
        let idx = i * 4;
        let (y, cb, cr) = to_ycbcr(&m, pixels[idx], pixels[idx + 1], pixels[idx + 2]);
        ys[i] = y;
        cbs[i] = cb;
        crs[i] = cr;
    }

    // 2. 色度按块平均(亮度一个都不动)
    for by in (0..h).step_by(b_size) {
        for bx in (0..w).step_by(b_size) {
            let mut sum_cb = 0.0;
            let mut sum_cr = 0.0;
            let mut count = 0.0;

            for y in by..(by + b_size).min(h) {
                for x in bx..(bx + b_size).min(w) {
                    sum_cb += cbs[y * w + x];
                    sum_cr += crs[y * w + x];
                    count += 1.0;
                }
            }

            let avg_cb = sum_cb / count;
            let avg_cr = sum_cr / count;

            // 平均值摊回块内每个像素
            for y in by..(by + b_size).min(h) {
                for x in bx..(bx + b_size).min(w) {
                    cbs[y * w + x] = avg_cb;
                    crs[y * w + x] = avg_cr;
                }
            }
        }
    }

    // 3. 转回 RGB
    let mut out = vec![0u8; pixels.len()];
    for i in 0..(w * h) {
        let idx = i * 4;
        let (r, g, b) = to_rgb(&inv, ys[i], cbs[i], crs[i]);
        out[idx] = r;
        out[idx + 1] = g;
        out[idx + 2] = b;
        out[idx + 3] = pixels[idx + 3];
    }

    out
}

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


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

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

ini 复制代码
原像素 (240, 180, 120) 乘 1.3:
  R: 240 × 1.3 = 312 → 截断成 255  ← 只涨了 15
  G: 180 × 1.3 = 234                ← 涨了 54
  B: 120 × 1.3 = 156                ← 涨了 36

R 被"卡住"了,G、B 继续涨 → 三者比例被破坏 → 颜色变了

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

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

复制代码
RGB 调亮:  三个数一起变 → 比例乱 → 偏色
YCbCr 调亮:只有 Y 变    → 色度原封不动 → 不偏色

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

代码(Rust)

rust 复制代码
#[wasm_bindgen]
pub fn ycbcr_adjust_luma(pixels: &[u8], width: u32, height: u32, gain: f32) -> Vec<u8> {
    let n = (width * height) as usize;
    let m = rgb_to_ycbcr_matrix();
    let inv = ycbcr_to_rgb_matrix();
    let mut out = vec![0u8; pixels.len()];

    for i in 0..n {
        let idx = i * 4;
        let (y, cb, cr) = to_ycbcr(&m, pixels[idx], pixels[idx + 1], pixels[idx + 2]);

        // 只动 Y,色度原样传下去
        let (r, g, b) = to_rgb(&inv, (y * gain).clamp(0.0, 255.0), cb, cr);

        out[idx] = r;
        out[idx + 1] = g;
        out[idx + 2] = b;
        out[idx + 3] = pixels[idx + 3];
    }

    out
}

八、操作 5:绿幕抠图

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

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

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

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

scss 复制代码
        Cr(红色度)
         ↑
     128 ┤        ● 肤色 (105, 155)
         │
         │   ○ ← 绿幕 (98, 76),褶皱阴影全都聚在这一小团
      76 ┤  ◯ ← 圈一个半径,圈内的抠掉
         └────┬──────┬────→ Cb(蓝色度)
             98     128

判据就是平面上的一个距离
d= (Cb− Cb,key )2+(Cr− Cr,key )2 d = \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 < r d<r → 完全透明
  • r∼r+softr \sim r + \text{soft} r∼r+soft → 线性过渡(边缘羽化,不加这一段头发边缘会全是锯齿)
  • 更远 → 保留

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

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

代码(Rust)

rust 复制代码
#[wasm_bindgen]
pub fn chroma_key(
    pixels: &[u8],
    width: u32,
    height: u32,
    cb_key: f32,
    cr_key: f32,
    radius: f32,
    softness: f32,
) -> Vec<u8> {
    let n = (width * height) as usize;
    let m = rgb_to_ycbcr_matrix();
    let soft = softness.max(1e-3);
    let mut out = pixels.to_vec();

    for i in 0..n {
        let idx = i * 4;
        // 只取 Cb、Cr,Y 直接丢掉------判据和亮度无关
        let (_, cb, cr) = to_ycbcr(&m, pixels[idx], pixels[idx + 1], pixels[idx + 2]);

        let dist = ((cb - cb_key).powi(2) + (cr - cr_key).powi(2)).sqrt();
        let alpha = ((dist - radius) / soft).clamp(0.0, 1.0);  // 羽化过渡

        out[idx + 3] = (pixels[idx + 3] as f32 * alpha) as u8;
    }

    out
}

九、注意事项

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)

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

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

两条路各有各的赢面

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

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

Part 5 完整内容

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

相关推荐
花褪残红青杏小8 小时前
Rust图像处理第21节-最小二乘回归:用矩阵求逆解"拟合"问题
rust·webassembly·图形学
k4m7v2pz13 小时前
Bevy 0.14.2 玩家精灵不渲染(只有背景在动)排查全记录
macos·rust·bevy
老王生涯15 小时前
rust开发环境配置-Windows & GNU
开发语言·windows·rust
Yeauty15 小时前
自建 HLS 第一问:fMP4 还是 TS?用 Rust 在进程内把两种都跑出来
开发语言·后端·rust
SmalBox15 小时前
【节点】[Comparison节点]原理解析与实际应用
unity3d·游戏开发·图形学
Flynt16 小时前
我用Biome替换ESLint+Prettier在项目上踩了不少坑
rust·eslint·前端工程化
Yeauty17 小时前
2026 年在 Rust 里处理音视频,该走哪条路?
rust·ffmpeg·音视频·视频
TDengine (老段)1 天前
TDengine Go 与 Rust 连接器 — 高性能异步访问
大数据·数据库·物联网·golang·rust·时序数据库·tdengine
魔力女仆2 天前
【RUST AI】把 TTS 搬进浏览器:kokoroi-rs 的 WASM 实践
人工智能·rust·wasm