跪拜 Guibai
← Back to the summary

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

  1. An Experiment: Erase Color vs Erase Luminance
  2. RGB's Flaw: All Three Numbers Are Tied to Luminance
  3. Switching Coordinate Systems: What Y, Cb, Cr Are
  4. Operation 1: Single-Channel Visualization
  5. Operation 2: Keep Only Luminance / Keep Only Chrominance
  6. Operation 3: Chroma Subsampling (The Truth Behind Halving Photo Size)
  7. Operation 4: Adjust Brightness Without Color Shift
  8. Operation 5: Green Screen Keying
  9. Frontend Effect Showcase
  10. Pitfalls to Avoid
  11. 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:

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:

fd90112c-0c3e-4169-b1a4-1f9a1cd0a948.png

6895f733-6e6c-40a7-ab9b-22aa1ef6bab3.png

994bec50-720e-4522-b101-b5b2afea8330.png

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)

bc3a2c39-7c04-4dd6-9533-62d34086fa17.png

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.

92603d2f-5762-4706-ab56-e8f494e952c2.png

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.

19a43905-dc37-4765-91e2-182462cd4cb6.png

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} $$

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.

9a435d4a-120d-4a4d-85b2-5c4663ac7062.png

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.

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