Skip to main content

ithmb_core/
yuv.rs

1//! BT.601 YUV-to-BGRA conversion.
2//!
3//! Fixed-point integer math matching the C# `IthmbCodecPlugin.YuvUtils`
4//! implementation bit-exactly.
5//!
6//! ## Math (BT.601-7)
7//!
8//! ```text
9//! R = Y + (Cr - 128) ×  359 ÷ 256
10//! G = Y - (Cb - 128) ×   88 ÷ 256 - (Cr - 128) × 183 ÷ 256
11//! B = Y + (Cb - 128) ×  454 ÷ 256
12//! ```
13//!
14//! Division uses arithmetic right-shift (`>> 8`) to match C# semantics exactly:
15//! negative intermediates round toward negative infinity, *not* toward zero.
16
17// ---- BT.601 fixed-point coefficients (ITU-R BT.601-7) ----
18
19/// Cr → R coefficient: 1.402 × 256 = 359 (truncated).
20pub const R_COEF: i32 = 359;
21
22/// Cb → G coefficient: −0.344 × 256 = −88 (truncated magnitude, sign handled
23/// in the expression as `- ((cb_s * G_COEF_CB) >> 8)` per the C# source).
24pub const G_COEF_CB: i32 = 88;
25
26/// Cr → G coefficient: −0.714 × 256 = −183 (truncated magnitude).
27pub const G_COEF_CR: i32 = 183;
28
29/// Cb → B coefficient: 1.772 × 256 = 454 (truncated).
30pub const B_COEF: i32 = 454;
31
32/// Clamp an [`i32`] to the inclusive `[0, 255]` range.
33///
34/// # Panics
35///
36/// Never panics.
37#[inline]
38#[must_use]
39#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
40pub fn clamp(v: i32) -> u8 {
41    crate::pixel_utils::clamp_u8(v)
42}
43
44/// Convert a single BT.601 Y′CbCr triad to BGRA 8-bit.
45///
46/// # Arguments
47///
48/// * `y`  — Luma component (0–255).
49/// * `cb` — Blue-difference chroma (0–255).
50/// * `cr` — Red-difference chroma (0–255).
51///
52/// # Returns
53///
54/// `[b, g, r, 255]` — BGRA pixel data.
55///
56/// # Panics
57///
58/// Never panics.
59///
60/// # Bit-exactness
61///
62/// Every arithmetic step matches the C# reference:
63///
64/// ```csharp
65/// int r = Clamp(luma + ((YuvRCoef * cr) >> 8));
66/// // … etc.
67/// ```
68#[inline]
69#[must_use]
70pub fn yuv_to_bgra(y: u8, cb: u8, cr: u8) -> [u8; 4] {
71    let y = i32::from(y);
72    let cb = i32::from(cb) - 128;
73    let cr = i32::from(cr) - 128;
74
75    let r = clamp(y + ((cr * R_COEF) >> 8));
76    let g = clamp(y - ((cb * G_COEF_CB) >> 8) - ((cr * G_COEF_CR) >> 8));
77    let b = clamp(y + ((cb * B_COEF) >> 8));
78
79    [b, g, r, 255]
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    // ---- Gray / neutral-chroma cases ----
87
88    #[test]
89    fn gray_mid() {
90        // yuv_to_bgra(128, 128, 128) → [128, 128, 128, 255]
91        assert_eq!(yuv_to_bgra(128, 128, 128), [128, 128, 128, 255]);
92    }
93
94    #[test]
95    fn white_full() {
96        // yuv_to_bgra(255, 128, 128) → [255, 255, 255, 255]
97        assert_eq!(yuv_to_bgra(255, 128, 128), [255, 255, 255, 255]);
98    }
99
100    #[test]
101    fn black_full() {
102        // yuv_to_bgra(0, 128, 128) → [0, 0, 0, 255]
103        assert_eq!(yuv_to_bgra(0, 128, 128), [0, 0, 0, 255]);
104    }
105
106    // ---- Chroma-driven cases ----
107
108    #[test]
109    fn saturated_blue_positive() {
110        // Cb = 255 (max blue excursion), Cr = 128 (neutral).
111        //
112        //   r = 128 + 0          = 128
113        //   g = 128 - 127·88/256 = 128 - 43 = 85
114        //   b = 128 + 127·454/256 = 128 + 225 = 353 → clamp 255
115        assert_eq!(yuv_to_bgra(128, 255, 128), [255, 85, 128, 255]);
116    }
117
118    #[test]
119    fn saturated_red_positive() {
120        // Cr = 255 (max red excursion), Cb = 128 (neutral).
121        //
122        //   r = 128 + 127·359/256 = 128 + 178 = 306 → clamp 255
123        //   g = 128 - 0 - 127·183/256 = 128 - 90 = 38
124        //   b = 128 + 0 = 128
125        assert_eq!(yuv_to_bgra(128, 128, 255), [128, 38, 255, 255]);
126    }
127
128    // ---- Clamping edge cases ----
129
130    #[test]
131    fn clamp_negative_yields_zero() {
132        // Y = 0, Cb = 0 (Cr-128 = -128 → pushes R/G negative).
133        // r = 0 + (-128)·359/256 = 0 + (-180) = -180 → clamp 0
134        // g = 0 - (-128)·88/256 - (-128)·183/256 = 0 - (-44) - (-92) = 136
135        // b = 0 + (-128)·454/256 = -227 → clamp 0
136        let pixel = yuv_to_bgra(0, 0, 0);
137        assert_eq!(pixel[0], 0, "b channel must clamp to 0"); // b
138        assert_eq!(pixel[2], 0, "r channel must clamp to 0"); // r
139    }
140
141    #[test]
142    fn max_chroma_does_not_overflow_green() {
143        // Y = 255, Cb = 255, Cr = 255.
144        // G channel: 255 - 127·88/256 - 127·183/256 = 255 - 43 - 90 = 122.
145        // R and B saturate at 255.
146        assert_eq!(yuv_to_bgra(255, 255, 255), [255, 122, 255, 255]);
147    }
148
149    // ---- Boundary / corner cases ----
150
151    #[test]
152    fn neutral_chroma_various_luma() {
153        for y in [0u8, 16, 128, 235, 255] {
154            let pixel = yuv_to_bgra(y, 128, 128);
155            assert_eq!(pixel, [y, y, y, 255], "neutral chroma must yield gray");
156        }
157    }
158
159    #[test]
160    fn clamp_single_values() {
161        assert_eq!(clamp(-1), 0);
162        assert_eq!(clamp(0), 0);
163        assert_eq!(clamp(128), 128);
164        assert_eq!(clamp(255), 255);
165        assert_eq!(clamp(256), 255);
166        assert_eq!(clamp(i32::MIN), 0);
167        assert_eq!(clamp(i32::MAX), 255);
168    }
169
170    #[test]
171    fn bgra_output_alpha_is_always_255() {
172        for y in [0u8, 128, 255] {
173            for cb in [0u8, 128, 255] {
174                for cr in [0u8, 128, 255] {
175                    let pixel = yuv_to_bgra(y, cb, cr);
176                    assert_eq!(
177                        pixel[3], 255,
178                        "alpha channel must always be 255 (y={y}, cb={cb}, cr={cr})"
179                    );
180                }
181            }
182        }
183    }
184
185    // ---- BT.601 white-point sanity ----
186
187    #[test]
188    fn broadcast_white() {
189        // Y = 235 (broadcast white), Cb = Cr = 128 (neutral).
190        // R = G = B = 235.
191        assert_eq!(yuv_to_bgra(235, 128, 128), [235, 235, 235, 255]);
192    }
193
194    // ---- Known mid-scale values ----
195
196    /// Returns `true` when values compare equal.
197    fn yuv_roundtrip_gray(y: u8) -> bool {
198        yuv_to_bgra(y, 128, 128)[..3] == [y, y, y]
199    }
200
201    #[test]
202    fn every_gray_value_roundtrips() {
203        // For every luma value with neutral chroma, the RGB output must be
204        // the neutral gray [y, y, y].
205        for y in 0..=255u8 {
206            assert!(yuv_roundtrip_gray(y), "gray roundtrip failed at y={y}");
207        }
208    }
209}