YCbCr Is a 3×3 Matrix That Halves Your Photo Size Without You Noticing
Rust Image Processing Section 22 - From RGB to YCbCr: Separating Luminance and Color
🦀 Rust + WASM Practical Series, Part 22 Reading time: ~8 minutes | Runnable in practice
📌 Foreword
In the previous 21 sections, all images were represented using three numbers: R, G, B. This section switches to a different coordinate system: one luminance + two chrominance.
Every photo in your phone, every video you watch, undergoes this conversion before being saved to a file. The reason is simple: the human eye is extremely sensitive to luminance and extremely insensitive to color. Once the two are separated, the color part can be arbitrarily cut down.
Mathematically, it is just a 3×3 matrix (matrix × vector from Section 19) plus its inverse matrix (try_inverse from Section 21).
🚀 TL;DR
RGB representation: YCbCr representation:
┌─────────────┐ ┌─────────────┐
│ R = 220 │ │ Y = 181 ← how bright (eye is very sensitive)
│ G = 170 │ ⇄ │ Cb = 105 ← how blue-ish (eye is dull)
│ B = 140 │ │ Cr = 155 ← how red-ish (eye is dull)
└─────────────┘ └─────────────┘
All three numbers carry luminance Luminance is only in the first number
To adjust brightness → change all three To adjust brightness → only change Y
| Operation | Method | Effect |
|---|---|---|
| Discard Cb, Cr | Set chroma to 128 | Becomes grayscale, content still recognizable |
| Discard Y | Set luma to constant | Completely unrecognizable |
| Chroma block average | 2×2 average | Data halved, zero visible difference |
| Amplify Y only | Chroma untouched | Brighten without color shift |
| Circle a region on Cb-Cr | Calculate planar distance | Green screen keying |
📖 Table of Contents
- An Experiment: Erase Color vs Erase Luminance
- RGB's Flaw: All Three Numbers Are Tied to Luminance
- Switching Coordinate Systems: What Y, Cb, Cr Are
- Operation 1: Single-Channel Visualization
- Operation 2: Keep Only Luminance / Keep Only Chrominance
- Operation 3: Chroma Subsampling (The Truth Behind Halving Photo Size)
- Operation 4: Adjust Brightness Without Color Shift
- Operation 5: Green Screen Keying
- Frontend Effect Showcase
- Pitfalls to Avoid
- Part 5 Wrap-Up: Who Decides the Coordinate System
1. An Experiment: Erase Color vs Erase Luminance
Experiment A: Delete all color information from a photo — you get a grayscale image. People, text, buildings, expressions — all still recognizable. You already knew this; black-and-white TV and photos work exactly this way.
Experiment B: Delete all luminance information, keep only color — the result is a blurry patch of color, completely unrecognizable as the original image.
Original info content: Luminance ████████████████ Color ████
↑ eye relies almost entirely on this ↑ just "adds color"
Only color (Experiment B): ░░░░░░░░░░░░░░░░ ████
→ unrecognizable
Conclusion: In an image, luminance carries the vast majority of "content"; color merely paints over that content.
This conclusion has enormous engineering value: since color is unimportant, the color part can be stored less, stored more coarsely — people won't notice anyway.
But to exploit this conclusion, you must first be able to separate "luminance" and "color" — RGB cannot do this.
2. RGB's Flaw: All Three Numbers Are Tied to Luminance
The problem with RGB is: no single channel represents "luminance" alone, and no single channel represents "color" alone.
Look at these three pixels:
| Pixel | R | G | B | What color |
|---|---|---|---|---|
| A | 200 | 100 | 100 | Bright dark red |
| B | 100 | 50 | 50 | Dark dark red |
| C | 200 | 200 | 200 | White |
A and B are exactly the same color (same red hue), just one bright and one dark — yet their RGB three numbers are all different.
This causes two practical problems:
Problem 1: To adjust brightness, you must move all three channels together. And multiplying all three by the same coefficient easily causes color shifts — in Task 19, multiplying RGB by 1.5, the R channel that was already 200 hits 255 and gets clipped first, while G and B haven't peaked yet, causing highlights to shift toward red.
Problem 2: "Store less color" — no way to start. Every one of the three channels contains luminance information; cutting any of them makes the image darker and dirtier.
Root cause: The R, G, B coordinate axes are designed around how a monitor emits light (how bright each of three bulbs is), not around how the human eye perceives.
3. Switching Coordinate Systems: What Y, Cb, Cr Are
So switch to a different set of axes. The new three numbers:
| Component | Name | Meaning | Range |
|---|---|---|---|
| Y | Luma | How bright this pixel is | 0 ~ 255 |
| Cb | Blue-difference chroma | How much more blue than luminance (scaled version of B − Y) | 0 ~ 255, 128 = neutral |
| Cr | Red-difference chroma | How much more red than luminance (scaled version of R − Y) | 0 ~ 255, 128 = neutral |
The key is understanding that chroma is a difference:
Cb essence = B - Y → How much higher the blue component is than overall luminance
Cr essence = R - Y → How much higher the red component is than overall luminance
Gray pixel (R=G=B): B - Y = 0, R - Y = 0
→ Cb = Cr = 128 (neutral)
So a pure grayscale image has Cb, Cr all 128 — zero color information, perfectly intuitive.
Now look back at the three pixels from Section 2:
| Pixel | RGB | Y | Cb | Cr |
|---|---|---|---|---|
| A bright dark red | (200, 100, 100) | 130 | 111 | 178 |
| B dark dark red | (100, 50, 50) | 65 | 120 | 153 |
| C white | (200, 200, 200) | 200 | 128 | 128 |
A and B's Cb, Cr are on the same side (Cb < 128 leans yellow, Cr > 128 leans red), color attributes are comparable at a glance; the difference mainly falls on Y (130 vs 65), exactly the representation "one bright, one dark" should have.
And C is pure gray, Cb = Cr = 128, clean and tidy.
Conversion Matrix (BT.601)
The relationship between Y and luminance is linear, so the entire conversion is one matrix × vector:
$$ \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} $$
Note the first row: $(0.299, 0.587, 0.114)$ — isn't this the BT.601 grayscale coefficient from Section 1?
So the Y channel = grayscale image. The first component of YCbCr was already calculated back in Section 1, we just didn't know it had two siblings.
The second and third rows look messy but are just simplified results of $B - Y$ and $R - Y$:
$$ B - Y = B - (0.299R + 0.587G + 0.114B) = -0.299R - 0.587G + 0.886B $$
Multiply by a scaling factor $0.5 / 0.886 \approx 0.564$ to squeeze the range into ±128, and you get the second row. Same logic for the third row. The trailing +128 simply shifts the ±128 range to 0 ~ 255, so it fits into a u8.
Converting Back: Don't Memorize Constants, Just Invert
The textbook inverse formula looks like this:
$$ R = Y + 1.402(C_r - 128), \quad B = Y + 1.772(C_b - 128) $$
Where do these numbers come from? They are the inverse of the matrix above — the try_inverse() used in Task 21 calculates them directly, no constants to memorize:
use nalgebra::{Matrix3, Vector3};
/// RGB → YCbCr 3×3 matrix (BT.601 full range / JPEG version)
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: don't hand-write constants, just invert the matrix above
fn ycbcr_to_rgb_matrix() -> Matrix3<f32> {
rgb_to_ycbcr_matrix()
.try_inverse()
.expect("BT.601 matrix determinant is non-zero, definitely invertible")
}
The actual computed inverse matrix:
$$ \begin{bmatrix} 1 & 0 & 1.402 \ 1 & -0.344136 & -0.714136 \ 1 & 1.772 & 0 \end{bmatrix} $$
Exactly matches the textbook. Note the first column is all 1s — meaning Y contributes identically to R, G, B, which is the mathematical statement that "Y is luminance." The middle of the first row is 0 (R is completely unaffected by Cb), and the end of the third row is 0 (B is completely unaffected by Cr), matching the definitions $C_b \sim B-Y$, $C_r \sim R-Y$.
Code (Rust)
Single-pixel forward and inverse conversion, handling the chroma ±128 offset here:
/// Single pixel: RGB → (Y, Cb, Cr), chroma already has +128 offset added
#[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)
}
/// Single pixel: (Y, Cb, Cr) → RGB, first subtract the 128 offset from chroma
#[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,
)
}
All five operations below are built on these two functions.
4. Operation 1: Single-Channel Visualization
Render Y, Cb, Cr individually as grayscale values to intuitively see what each channel looks like.
What you'll see:
- Y channel: A normal black-and-white photo, all details present
- Cb channel: Large areas of mid-gray (128). Sky, blue clothes appear white (Cb high); skin, yellow objects appear black (Cb low). Almost no detail texture
- Cr channel: Similarly large areas of mid-gray. Lips, red objects appear white; green plants appear black
The "blurry blob" appearance of Cb and Cr is crucial — it shows that chroma channels inherently lack high-frequency detail, which is the physical basis for heavily cutting chroma data in Section 6.
Effect:
Code (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; // Write single-channel value into R, G, B
out[idx + 1] = g; // → display as grayscale
out[idx + 2] = g;
out[idx + 3] = pixels[idx + 3];
}
out
}
5. Operation 2: Keep Only Luminance / Keep Only Chrominance
Actually run the two experiments from Section 1.
Keep only luminance: Set Cb, Cr all to 128, then convert back to RGB. Since Cb = Cr = 128 means "no color bias," converting back inevitably yields R = G = B — you get a grayscale image.
Keep only chrominance: Set Y to a constant (e.g., 128), keep Cb, Cr as-is. Now all light-dark contrast in the image disappears, only hue remains, resulting in a blob of color where content is unrecognizable.
Original ──┬─→ Discard Cb, Cr → Grayscale → Content crystal clear
└─→ Discard Y → A color blob → Completely unrecognizable
The amount of data discarded is the same on both sides (1 channel vs 2 channels),
but the information value is worlds apart.
This is the foundational argument for all chroma compression techniques: save bits on chroma, never on luma.
Effect: (Keep only luminance see previous operation)
Code (Rust)
/// Keep only luminance: set Cb, Cr all to 128 (neutral gray)
#[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); // Chroma set to neutral
out[idx] = r;
out[idx + 1] = g;
out[idx + 2] = b;
out[idx + 3] = pixels[idx + 3];
}
out
}
/// Keep only chrominance: set Y to a constant
#[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); // Luminance set to constant
out[idx] = r;
out[idx + 1] = g;
out[idx + 2] = b;
out[idx + 3] = pixels[idx + 3];
}
out
}
6. Operation 3: Chroma Subsampling (The Truth Behind Halving Photo Size)
The previous two sections proved "chroma is unimportant"; this section cashes in the benefit.
Method: Leave every luminance pixel untouched, average chroma in 2×2 blocks — four pixels share one set of Cb, Cr.
Luminance Y (fully preserved) Chroma Cb (2×2 averaged)
┌──┬──┬──┬──┐ ┌─────┬─────┐
│Y1│Y2│Y3│Y4│ │ │ │
├──┼──┼──┼──┤ │ avg │ avg │
│Y5│Y6│Y7│Y8│ │ │ │
└──┴──┴──┴──┘ └─────┴─────┘
8 numbers 2 numbers (originally 8)
Data accounting: Originally 3 channels × $n$ numbers each = $3n$. Now luminance $n$ + chroma $2 \times n/4$ = $1.5n$. Directly cut in half.
This is 4:2:0 chroma subsampling, the default for JPEG, the default for H.264 / H.265 video, the default for your phone camera. The color in every photo on your phone is actually blurred in 2×2 blocks — but you've never noticed.
Enlarge the block to 4×4 (4:1:0), data drops to $1.125n$, and only then do flaws start to appear: red text edges look blurry, vivid color block edges show "color bleeding."
| Block Size | Name | Data Volume | Naked Eye |
|---|---|---|---|
| 1×1 | 4:4:4 | $3n$ | Original |
| 2×2 | 4:2:0 | $1.5n$ | Can't tell the difference |
| 4×4 | 4:1:0 | $1.125n$ | Color edges blurry |
Why professional retouching uses 4:4:4: Chroma subsampling is harmless for capturing photos, but if you need to key, grade, or add effects in post, the blurred chroma edges will cause aliasing and color artifacts on keyed edges — that's why film production assets insist on 4:4:4, only dropping to 4:2:0 for the final deliverable.
Code (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. First convert entire image to 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. Average chroma by block (luminance untouched)
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;
// Spread average back to every pixel in the block
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. Convert back to 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
}
Real JPEG actually stores the averaged chroma as a smaller image 1/4 the size, then interpolates back up during decoding. Here, to directly compare with the original, we choose "average then immediately spread back" — visually equivalent, just didn't save memory.
7. Operation 4: Adjust Brightness Without Color Shift
Task 19's brightness adjustment multiplied all three RGB channels by the same coefficient. The problem is clipping:
Original pixel (240, 180, 120) × 1.3:
R: 240 × 1.3 = 312 → clipped to 255 ← only increased by 15
G: 180 × 1.3 = 234 ← increased by 54
B: 120 × 1.3 = 156 ← increased by 36
R got "stuck", G and B kept rising → proportions destroyed → color changed
Highlights drift toward the image's secondary hue; a warm photo brightened this way turns yellow-gray — the classic RGB brightness adjustment flaw.
YCbCr approach: Multiply only Y by the coefficient, leave Cb, Cr completely untouched. Chroma unchanged = hue and saturation strictly unchanged, the image simply gets brighter overall.
RGB brighten: All three numbers change → proportions disrupted → color shift
YCbCr brighten: Only Y changes → chroma untouched → no color shift
This is why "Exposure" and "Saturation" are two independent sliders in photo editing software — underneath, they are manipulating different components.
Code (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]);
// Only touch Y, pass chroma through unchanged
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
}
8. Operation 5: Green Screen Keying
The final application, and the one that best demonstrates the value of "separation."
Naive approach (judging in RGB): G > R && G > B counts as green, key it out. The result is guaranteed to fail — green screen fabric has wrinkles and shadows, bright green and dark green have vastly different RGB values, and no single threshold works: too loose and the subject's hair and clothes get falsely keyed; too tight and the dark parts of the green screen remain.
Root cause is the same old problem: in RGB, "is it green" and "how bright is it" are mixed together.
YCbCr approach: Whether bright or dark, the green screen is the same color, so its Cb, Cr are nearly identical, only Y differs. So look only at Cb, Cr, ignore Y entirely:
Cr (red-difference chroma)
↑
128 ┤ ● skin tone (105, 155)
│
│ ○ ← green screen (98, 76), wrinkles and shadows all cluster in this tiny area
76 ┤ ◯ ← draw a circle with a radius, key out everything inside
└────┬──────┬────→ Cb (blue-difference chroma)
98 128
The criterion is simply a distance on a plane:
$$ d = \sqrt{(C_b - C_{b,\text{key}})^2 + (C_r - C_{r,\text{key}})^2} $$
- $d < r$ → fully transparent
- $r \sim r + \text{soft}$ → linear transition (edge feathering; without this, hair edges are full of jaggies)
- Farther → keep
Luminance is completely excluded from the criterion, so highlights and shadows on the green screen fall near the same point, one threshold handles everything.
As a bonus: Move the center to around (105, 155) and you get skin tone detection — the earliest face detection, and the skin-smoothing selection in beauty filters, used exactly this trick.
Code (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;
// Only take Cb, Cr; Y is directly discarded — criterion is independent of luminance
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); // Feathering transition
out[idx + 3] = (pixels[idx + 3] as f32 * alpha) as u8;
}
out
}
9. Notes
1. Don't Forget the ±128 Offset
Cb, Cr's mathematical range is −128 ~ +127. Add 128 before storing into u8, subtract 128 before computing. Miss either side, and the image instantly turns into eerie fluorescent colors.
The verification is simple: run a pure grayscale image through; Cb, Cr should all be 128. If they all come out 0, the offset was missed.
2. Two Sets of Coefficients: BT.601 and BT.709
| Standard | Y Coefficients | Used Where |
|---|---|---|
| BT.601 | (0.299, 0.587, 0.114) | SD video, JPEG |
| BT.709 | (0.2126, 0.7152, 0.0722) | HD video, HDTV / most videos today |
The consequence of mixing the two is color shift — encode with 601, decode with 709, the image shifts greenish and darker overall. This article uses 601 to match JPEG.
3. There's Also "Full Range" vs "Video Range"
This article uses full range, leaving headroom and footroom for signal overshoot.
This is why some video players show "blacks not black enough, whites grayish" — the range was mismatched.
4. Conversion Has Precision Loss
RGB → YCbCr → RGB round-trip, because of clamping to u8 midway, has ±1 error. Once or twice doesn't matter, but don't convert back and forth in a loop; error accumulates. To perform multiple operations consecutively, do them all in YCbCr space, then convert back once.
5. Don't Use Chroma Subsampling on Assets Needing Post-Production
As mentioned in Section 6: before keying, grading, or adding subtitles, chroma must be 4:4:4. Keying already-4:2:0 assets will definitely produce aliasing and color artifacts on edges. Compression belongs at the very end of the pipeline.
10. Part 5 Summary: Who Decides the Coordinate System
This section and Task 20's PCA are essentially doing the same thing: swapping RGB for a more useful set of coordinate axes. The only difference is — who decides the axes.
In Task 20, PCA computed the first principal component from a real photo as:
$$ PC_1 \approx (0.577,\ 0.577,\ 0.577) $$
That is, the grayscale axis. And this section's Y channel coefficients are human-defined:
$$ Y = (0.299,\ 0.587,\ 0.114) $$
Also a grayscale axis, just weighted for the eye's higher sensitivity to green.
PCA: Look at your data → compute "the direction of greatest variation in this image" → axes differ per image
YCbCr: Look at human eye characteristics → fix a set of axes once → all images worldwide share them
Each path has its own wins:
| PCA | YCbCr | |
|---|---|---|
| Who sets axes | Data itself (recomputed per image) | Humans fixed (BT.601 constant table) |
| Basis | Maximum variance | Human eye sensitivity |
| Compression ratio | Theoretically optimal | Slightly worse |
| Cost | Must store axes per image | Zero cost, universally recognized |
PCA theoretically has better compression, but every image must store its own set of axes inside the file, and the decoder must read the axes before decoding the image. YCbCr trades a fixed set of constants for "every codec on Earth recognizes it" — so YCbCr won, used by JPEG, H.264, your phone camera, all of it.
Part 5 Complete Contents:
| Section | Content | Involved Computation |
|---|---|---|
| 19 | You manually tune 3×3 coefficients to change colors | Matrix × Vector |
| 20 | Let data itself compute the optimal coordinate system | Covariance + SVD |
| 21 | Let data itself fit transformation parameters | Matrix Inversion |
| 22 | Swap RGB for a human-fixed coordinate system | 3×3 Matrix + Inverse Matrix |
Four sections in one sentence: A matrix isn't a "table of numbers"; it's a tool for viewing the same data from a different angle. As for who chooses the angle — data (20, 21) or humans (19, 22) — that's an engineering decision, not a math problem.
- The Y channel is the grayscale image — you already calculated it back in Section 1
- The human eye sees almost only luminance: discard chroma and it's still recognizable; discard luminance and it's ruined
- 4:2:0 chroma subsampling saves half the data in every photo you take, and you completely can't tell
- Adjust only Y, leave chroma alone = brighten without color shift
- Draw a circle on the Cb-Cr plane = green screen keying / skin tone detection
- No need to memorize constants to convert back to RGB,
try_inverse()calculates 1.402 and 1.772 for you
Next Part Preview: Part 6 Probabilistic Image Magic — Gaussian noise, salt-and-pepper noise, histogram equalization. You'll discover that the first step of histogram equalization on color images is converting to YCbCr and processing only the Y channel. This section's material will be used immediately.
One-Sentence Summary
YCbCr is multiplying RGB by a 3×3 matrix, swapping it for "one luminance + two chrominance."
After the swap, what the human eye values and what it ignores are separated into different channels — so you can confidently and boldly cut the ignored part.
The color in every photo on your phone is blurred in 2×2 blocks, and you've never noticed.
📦 Project Repo: pixel-math-wasm 🦀 Rust + WebAssembly Practical Series
🏷️ Tags: #Rust #WebAssembly #Color Space #YCbCr #Chroma Subsampling #Green Screen Keying #JPEG #nalgebra