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}