Skip to main content

ithmb_core/
cl.rs

1//! CL (per-pixel nibble chroma) decoder — also called "chunky" or "per-pixel" chroma.
2//!
3//! # Payload layout (planar)
4//!
5//! ```text
6//! [Y0, Y1, ..., Y(N-1), CbCr0, CbCr1, ..., CbCr(N-1)]
7//! ```
8//!
9//! * **Y** — 1 byte per pixel (full 8-bit luma).
10//! * **`CbCr`** — 1 byte per pixel, packed nibbles: high nibble = Cr, low nibble = Cb.
11//!   Each chroma nibble is 4-bit (0–15) and upscaled to 8-bit by `<< 4`.
12//!
13//! In total: 2 bytes per pixel × N pixels.
14//!
15//! Example for 2 pixels:
16//!
17//! ```text
18//! Byte 0: Y0
19//! Byte 1: Y1
20//! Byte 2: (Cr0 << 4) | Cb0
21//! Byte 3: (Cr1 << 4) | Cb1
22//! ```
23//!
24//! Output is BGRA 8-bit per channel (via BT.601 YUV→RGB conversion).
25
26use crate::error::{DecodeError, DecodedImage};
27use crate::profile::Profile;
28use std::sync::atomic::AtomicBool;
29
30/// Decode a CL (per-pixel nibble chroma) frame to BGRA8 output.
31///
32/// # Arguments
33///
34/// * `src` — Raw pixel data: `w × h` Y bytes followed by `w × h` `CbCr` bytes.
35/// * `profile` — The profile describing this frame's dimensions.
36///
37/// # Returns
38///
39/// `Ok(DecodedImage)` on success, or a [`DecodeError`] on failure.
40///
41/// # Errors
42///
43/// * [`DecodeError::InvalidFormat`] — width or height ≤ 0.
44/// * [`DecodeError::BufferTooShort`] — input too small for the declared dimensions.
45///
46/// # Panics
47///
48/// Never panics.
49pub fn decode(src: &[u8], profile: &Profile, canceled: &AtomicBool) -> Result<DecodedImage, DecodeError> {
50    let (w, h) = crate::decoder_helpers::validate_dimensions(src, profile, "CL dimensions must be positive", 2)?;
51    let pixel_count = w * h;
52    let expected = pixel_count * 2;
53    let mut dst = vec![0u8; pixel_count * 4];
54
55    // Call row-level SIMD dispatch on the full planar buffer.
56    // cl_row_to_bgra expects src layout: [Y0..Yn, CbCr0..CbCrn] = `expected` bytes,
57    // and writes `pixel_count * 4` BGRA bytes to dst.
58    // The "row" in the name is historical — it processes any N pixels.
59    crate::pixel_utils::check_canceled(canceled, "cl decode canceled")?;
60    crate::simd::cl_row_to_bgra(&src[..expected], &mut dst);
61
62    #[allow(clippy::cast_possible_truncation)]
63    let out_w = w as u32;
64    #[allow(clippy::cast_possible_truncation)]
65    let out_h = h as u32;
66
67    Ok(DecodedImage {
68        data: dst,
69        width: out_w,
70        height: out_h,
71    })
72}
73
74// ---------------------------------------------------------------------------
75// Tests
76// ---------------------------------------------------------------------------
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use crate::profile::Encoding;
82    use std::sync::atomic::AtomicBool;
83
84    fn make_profile(w: i32, h: i32) -> Profile {
85        Profile {
86            prefix: 0,
87            width: w,
88            height: h,
89            encoding: Encoding::Rgb565,
90            frame_byte_length: w * h * 2,
91            cl_chroma: true,
92            ..Default::default()
93        }
94    }
95
96    // ---- Error paths ----
97
98    #[test]
99    fn zero_width_returns_invalid_format() {
100        let profile = make_profile(0, 100);
101        let result = decode(b"", &profile, &AtomicBool::new(false));
102        assert!(matches!(result, Err(DecodeError::InvalidFormat(..))));
103    }
104
105    #[test]
106    fn zero_height_returns_invalid_format() {
107        let profile = make_profile(100, 0);
108        let result = decode(b"", &profile, &AtomicBool::new(false));
109        assert!(matches!(result, Err(DecodeError::InvalidFormat(..))));
110    }
111
112    #[test]
113    fn negative_width_returns_invalid_format() {
114        let profile = make_profile(-1, 100);
115        let result = decode(b"", &profile, &AtomicBool::new(false));
116        assert!(matches!(result, Err(DecodeError::InvalidFormat(..))));
117    }
118
119    #[test]
120    fn buffer_too_short_returns_error() {
121        let profile = make_profile(10, 10);
122        // 10*10*2 = 200 bytes needed, only 10 provided
123        let result = decode(&[0u8; 10], &profile, &AtomicBool::new(false));
124        assert!(matches!(
125            result,
126            Err(DecodeError::BufferTooShort {
127                expected: 200,
128                actual: 10
129            })
130        ));
131    }
132
133    // ---- Neutral chroma (gray output) ----
134
135    #[test]
136    fn gray_pixel_neutral_chroma() {
137        // Y=128, Cb=8 (neutral after <<4 → 128), Cr=8 (neutral after <<4 → 128)
138        // chroma_byte = (Cr << 4) | Cb = (8 << 4) | 8 = 0x88
139        let profile = make_profile(1, 1);
140        let img = decode(&[128, 0x88], &profile, &AtomicBool::new(false)).unwrap();
141        assert_eq!(img.data, [128, 128, 128, 255]);
142        assert_eq!(img.width, 1);
143        assert_eq!(img.height, 1);
144    }
145
146    #[test]
147    fn black_with_neutral_chroma() {
148        // Y=0, Cb=8, Cr=8 → BGRA [0, 0, 0, 255]
149        let profile = make_profile(1, 1);
150        let img = decode(&[0, 0x88], &profile, &AtomicBool::new(false)).unwrap();
151        assert_eq!(img.data, [0, 0, 0, 255]);
152    }
153
154    #[test]
155    fn white_with_neutral_chroma() {
156        // Y=255, Cb=8, Cr=8 → BGRA [255, 255, 255, 255]
157        let profile = make_profile(1, 1);
158        let img = decode(&[255, 0x88], &profile, &AtomicBool::new(false)).unwrap();
159        assert_eq!(img.data, [255, 255, 255, 255]);
160    }
161
162    // ---- Chroma nibble unpacking ----
163
164    #[test]
165    fn chroma_nibble_high_is_cr_low_is_cb() {
166        // chroma_byte = 0xF0 → cr=15, cb=0
167        // cr_8bit = 240, cb_8bit = 0
168        // yuv_to_bgra(128, 0, 240) where cb=-128, cr=112
169        //   r = clamp(128 + (112*359>>8)) = clamp(128 + 157) = clamp(285) = 255
170        //   g = clamp(128 - (-128*88>>8) - (112*183>>8))
171        //     = clamp(128 - (-44) - 80) = clamp(92) = 92
172        //   b = clamp(128 + (-128*454>>8)) = clamp(128 + (-227)) = clamp(-99) = 0
173        // → BGRA [0, 92, 255, 255]
174        let profile = make_profile(1, 1);
175        let img = decode(&[128, 0xF0], &profile, &AtomicBool::new(false)).unwrap();
176        assert_eq!(img.data, [0, 92, 255, 255], "high nibble CB=0, CR=15");
177    }
178
179    #[test]
180    fn chroma_nibble_low_is_cb_high_is_cr() {
181        // chroma_byte = 0x0F → cr=0, cb=15
182        // cb_8bit = 240, cr_8bit = 0
183        // yuv_to_bgra(128, 240, 0) where cb=112, cr=-128
184        //   r = clamp(128 + (-128*359>>8)) = clamp(128 - 180) = 0
185        //   g = clamp(128 - (112*88>>8) - (-128*183>>8))
186        //     = clamp(128 - 38 - (-92)) = clamp(182) = 182
187        //   b = clamp(128 + (112*454>>8)) = clamp(128 + 198) = clamp(326) = 255
188        // → BGRA [255, 182, 0, 255]
189        let profile = make_profile(1, 1);
190        let img = decode(&[128, 0x0F], &profile, &AtomicBool::new(false)).unwrap();
191        assert_eq!(img.data, [255, 182, 0, 255], "low nibble CB=15, CR=0");
192    }
193
194    // ---- Multi-pixel decode ----
195
196    #[test]
197    fn two_pixels_planar_layout() {
198        // Pixel 0: Y=128, Cb=8, Cr=8 → gray [128,128,128,255]
199        // Pixel 1: Y=0,   Cb=8, Cr=8 → black [0,0,0,255]
200        // Planar: [Y0=128, Y1=0, CbCr0=0x88, CbCr1=0x88]
201        let profile = make_profile(2, 1);
202        let img = decode(&[128, 0, 0x88, 0x88], &profile, &AtomicBool::new(false)).unwrap();
203        assert_eq!(img.width, 2);
204        assert_eq!(img.height, 1);
205        assert_eq!(
206            img.data,
207            [
208                128, 128, 128, 255, // pixel 0
209                0, 0, 0, 255, // pixel 1
210            ]
211        );
212    }
213
214    #[test]
215    fn two_by_two_grid() {
216        // 2×2 image, all Y=128, all Cb=8, Cr=8 → all gray
217        // Planar: [128,128,128,128, 0x88,0x88,0x88,0x88]
218        let profile = make_profile(2, 2);
219        let img = decode(
220            &[128, 128, 128, 128, 0x88, 0x88, 0x88, 0x88],
221            &profile,
222            &AtomicBool::new(false),
223        )
224        .unwrap();
225        assert_eq!(img.width, 2);
226        assert_eq!(img.height, 2);
227        let expected = vec![128u8, 128, 128, 255]; // one gray pixel
228        for y in 0..2 {
229            for x in 0..2 {
230                let off = (y * 2 + x) * 4;
231                assert_eq!(img.data[off..off + 4], expected, "pixel ({x},{y}) mismatch");
232            }
233        }
234    }
235
236    // ---- Nibble edge values ----
237
238    #[test]
239    fn chroma_nibbles_at_extremes() {
240        // Full crayon-mode: Cb=15 (max), Cr=15 (max)
241        // chroma_byte = (15 << 4) | 15 = 0xFF
242        // cb_8bit = 240, cr_8bit = 240
243        // yuv_to_bgra(255, 240, 240):
244        //   g = 255 - (112*88>>8) - (112*183>>8) = 255 - 38 - 80 = 137
245        let profile = make_profile(1, 1);
246        let img = decode(&[255, 0xFF], &profile, &AtomicBool::new(false)).unwrap();
247        assert_eq!(img.data, [255, 137, 255, 255]);
248    }
249
250    // ---- Planar indexing: verify Y and chroma planes don't alias ----
251
252    #[test]
253    fn different_y_and_chroma_planes() {
254        // Pixel 0: Y=100, CbCr=0x88 (Cb=8, Cr=8, neutral)
255        // Pixel 1: Y=200, CbCr=0x88 (Cb=8, Cr=8, neutral)
256        // The chroma plane starts at pixel_count = 2, so CbCr0 = src[2], CbCr1 = src[3]
257        let profile = make_profile(2, 1);
258        let img = decode(&[100, 200, 0x88, 0x88], &profile, &AtomicBool::new(false)).unwrap();
259        assert_eq!(img.data[0..4], [100, 100, 100, 255], "pixel 0 uses Y=100");
260        assert_eq!(img.data[4..8], [200, 200, 200, 255], "pixel 1 uses Y=200");
261    }
262
263    // ---- Odd-width decode (exercises SIMD remainder path) ----
264
265    #[test]
266    #[allow(clippy::cast_possible_truncation)]
267    fn odd_width_3x3_decode_correct() {
268        // 3×3 = 9 pixels. Both dimensions are odd — anything not a multiple
269        // of 4 hits the remainder path in cl_row_to_bgra_sse41/sse2.
270        let mut state: u32 = 0x9ABC_DEF0;
271        let n = 9;
272        let mut src = vec![0u8; n * 2];
273        for b in &mut src {
274            state = state.wrapping_mul(1_103_515_245).wrapping_add(12_345);
275            *b = (state >> 16) as u8;
276        }
277        let profile = make_profile(3, 3);
278        let img = decode(&src, &profile, &AtomicBool::new(false)).unwrap();
279        // Compute expected via per-pixel scalar.
280        let (y, chroma) = src.split_at(n);
281        let mut expected = vec![0u8; n * 4];
282        for i in 0..n {
283            let cr = chroma[i] & 0xF0;
284            let cb = (chroma[i] & 0x0F) << 4;
285            let px = crate::yuv::yuv_to_bgra(y[i], cb, cr);
286            let o = i * 4;
287            expected[o..o + 4].copy_from_slice(&px);
288        }
289        assert_eq!(img.data, expected, "3×3 CL decode mismatch");
290        assert_eq!(img.width, 3);
291        assert_eq!(img.height, 3);
292    }
293
294    #[test]
295    #[allow(clippy::cast_possible_truncation)]
296    fn odd_width_7x3_decode_correct() {
297        // 7×3 = 21 pixels. The SSE2 path processes 8 pixels per batch loop
298        // iteration, so 21 = 2×8 + 5 remainder pixels — exercises both the
299        // 2-quad batch and the single-quad + scalar remainder steps.
300        let mut state: u32 = 0xFEDC_BA09;
301        let n = 21;
302        let mut src = vec![0u8; n * 2];
303        for b in &mut src {
304            state = state.wrapping_mul(1_103_515_245).wrapping_add(12_345);
305            *b = (state >> 16) as u8;
306        }
307        let profile = make_profile(7, 3);
308        let img = decode(&src, &profile, &AtomicBool::new(false)).unwrap();
309        let (y, chroma) = src.split_at(n);
310        let mut expected = vec![0u8; n * 4];
311        for i in 0..n {
312            let cr = chroma[i] & 0xF0;
313            let cb = (chroma[i] & 0x0F) << 4;
314            let px = crate::yuv::yuv_to_bgra(y[i], cb, cr);
315            let o = i * 4;
316            expected[o..o + 4].copy_from_slice(&px);
317        }
318        assert_eq!(img.data, expected, "7×3 CL decode mismatch");
319    }
320}