🦀 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 上圈一块 | 算平面距离 | 绿幕抠图 |
📖 目录
- [一个实验:抹掉颜色 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")
- [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")
- [换一套坐标系: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")
- [操作 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")
- [操作 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")
- [操作 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")
- [操作 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")
- [操作 5:绿幕抠图](#操作 5:绿幕抠图 "#%E5%85%AB%E6%93%8D%E4%BD%9C-5%E7%BB%BF%E5%B9%95%E6%8A%A0%E5%9B%BE")
- 前端效果展示
- 踩坑提醒
- [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 和亮度的关系是线性的,所以整个转换就是一次矩阵 × 向量:
YCbCr = 0.299−0.1687360.50.587−0.331264−0.4186880.1140.5−0.081312 RGB + 0128128
注意第一行 : (0.299,0.587,0.114) ------ 这不就是第 1 节讲过的 BT.601 灰度化系数吗?
所以 Y 通道 = 灰度图。 在第 1 篇就已经算过 YCbCr 的第一个分量了,只是当时不知道它还有两个兄弟。
第二、三行看着乱,其实是 B−Y 和 R−Y 化简后的结果:
B−Y=B−(0.299R+0.587G+0.114B)=−0.299R−0.587G+0.886B
再乘个缩放系数 0.5/0.886≈0.564 把范围压进 ±128,就得到第二行。第三行同理。末尾的 +128 只是把 ±128 的范围平移到 0 ~ 255,好塞进一个 u8。
转回去:不背常数,直接求逆
教科书上的反向公式长这样:
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,一定可逆")
}
实际跑出来的逆矩阵:
1110−0.3441361.7721.402−0.7141360
和教科书一模一样。 注意第一列全是 1 ------ 意思是 Y 对 R、G、B 的贡献完全相同 ,这正是"Y 就是亮度"的数学表述。而第一行的中间是 0(R 完全不受 Cb 影响)、第三行末尾是 0(B 完全不受 Cr 影响),也和 Cb∼B−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 个通道各 n 个数 = 3n。现在亮度 n 个 + 色度 2×n/4 = 1.5n。直接砍掉一半。
这就是 4:2:0 色度抽样 ,JPEG 的默认设置、H.264 / H.265 视频的默认设置、你手机相机的默认设置。你手机里每一张照片的颜色,其实都是 2×2 一块块糊过的------但你从来没看出来。
把块调大到 4×4(4:1:0),数据量降到 1.125n,这时候才开始能看出破绽:红色字体边缘发虚、鲜艳色块边缘出现"颜色渗出"。
| 块大小 | 名称 | 数据量 | 肉眼 |
|---|---|---|---|
| 1×1 | 4:4:4 | 3n | 原图 |
| 2×2 | 4:2:0 | 1.5n | 看不出区别 |
| 4×4 | 4:1:0 | 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<r → 完全透明
- 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)
也就是灰阶轴。而这一篇的 Y 通道系数是人类定的:
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