Skip to main content

ithmb_core/
clcl.rs

1//! CLCL decoder — Cb/Cr per-pixel nibble chroma in separate planes.
2//!
3//! # Payload layout (planar)
4//!
5//! ```text
6//! [Y0, Y1, ..., Y(N-1), Cb0_Cb1, ..., Cb_{N-2}_Cb_{N-1}, Cr0_Cr1, ..., Cr_{N-2}_Cr_{N-1}]
7//! ```
8//!
9//! * **Y** — 1 byte per pixel (full 8-bit luma).
10//! * **Cb** — 1 nibble per pixel, packed 2 nibbles per byte (N/2 bytes total).
11//!   Byte packing: `byte[k] = (Cb_{2k+1} << 4) | Cb_{2k}` (odd pixel in high nibble).
12//! * **Cr** — Same packing scheme as Cb (N/2 bytes).
13//! * Each nibble is upscaled to 8-bit by `<< 4`.
14//!
15//! In total: N (Y) + N/2 (Cb) + N/2 (Cr) = 2N bytes.
16//!
17//! Example for 4 pixels:
18//!
19//! ```text
20//! Byte 0: Y0
21//! Byte 1: Y1
22//! Byte 2: Y2
23//! Byte 3: Y3
24//! Byte 4: (Cb1 << 4) | Cb0   // Cb nibbles for pixels 0,1
25//! Byte 5: (Cb3 << 4) | Cb2   // Cb nibbles for pixels 2,3
26//! Byte 6: (Cr1 << 4) | Cr0   // Cr nibbles for pixels 0,1
27//! Byte 7: (Cr3 << 4) | Cr2   // Cr nibbles for pixels 2,3
28//! ```
29//!
30//! Output is BGRA 8-bit per channel (via BT.601 YUV→RGB conversion).
31
32use crate::error::{DecodeError, DecodedImage};
33use crate::profile::Profile;
34#[allow(unused_imports)] // yuv is used by SIMD dispatch, unused in scalar-only builds
35use crate::yuv;
36use std::sync::atomic::AtomicBool;
37
38/// Decode a CLCL (separate Cb/Cr nibble-plane) frame to BGRA8 output.
39///
40/// # Arguments
41///
42/// * `src` — Raw pixel data: `w × h` Y bytes, then `w × h / 2` packed-Cb bytes,
43///   then `w × h / 2` packed-Cr bytes.
44/// * `profile` — The profile describing this frame's dimensions.
45///
46/// # Returns
47///
48/// `Ok(DecodedImage)` on success, or a [`DecodeError`] on failure.
49///
50/// # Errors
51///
52/// * [`DecodeError::InvalidFormat`] — width or height ≤ 0.
53/// * [`DecodeError::BufferTooShort`] — input too small for the declared dimensions.
54///
55/// # Panics
56///
57/// Never panics.
58#[allow(clippy::similar_names)]
59pub fn decode(src: &[u8], profile: &Profile, canceled: &AtomicBool) -> Result<DecodedImage, DecodeError> {
60    let (w, h) = crate::decoder_helpers::validate_dimensions(src, profile, "CLCL dimensions must be positive", 2)?;
61    let pixel_count = w * h;
62    let chroma_len = pixel_count.div_ceil(2);
63    // CLCL layout needs: pixel_count (Y) + 2 * chroma_len (Cb + Cr).
64    // validate_dimensions only checked src.len() >= pixel_count * 2, which
65    // under-counts by 1 when pixel_count is odd (e.g. 3x1 needs 7 bytes, not 6).
66    if pixel_count % 2 != 0 {
67        let total_needed = pixel_count + 2 * chroma_len;
68        if src.len() < total_needed {
69            return Err(DecodeError::BufferTooShort {
70                expected: total_needed,
71                actual: src.len(),
72            });
73        }
74    }
75    let y_len = pixel_count;
76    let mut dst = vec![0u8; pixel_count * 4];
77
78    let cb_off = y_len;
79    let cr_off = y_len + chroma_len;
80
81    for row in 0..h {
82        let idx = row * w;
83        crate::pixel_utils::check_canceled(canceled, "clcl decode canceled")?;
84        #[cfg(feature = "simd")]
85        {
86            let y_row = &src[idx..idx + w];
87            let cb_row = &src[cb_off + idx / 2..cb_off + idx / 2 + w.div_ceil(2)];
88            let cr_row = &src[cr_off + idx / 2..cr_off + idx / 2 + w.div_ceil(2)];
89            let dst_row = &mut dst[idx * 4..(idx + w) * 4];
90            crate::simd::clcl_row_to_bgra(y_row, cb_row, cr_row, w, dst_row);
91        }
92        #[cfg(not(feature = "simd"))]
93        {
94            for i in idx..idx + w {
95                let y = src[i];
96                let cbi = src[cb_off + i / 2];
97                let cri = src[cr_off + i / 2];
98                let n_cb = if i & 1 == 0 { cbi & 0x0F } else { cbi >> 4 };
99                let n_cr = if i & 1 == 0 { cri & 0x0F } else { cri >> 4 };
100                let pixel = yuv::yuv_to_bgra(y, n_cb << 4, n_cr << 4);
101                let dst_idx = i * 4;
102                dst[dst_idx..dst_idx + 4].copy_from_slice(&pixel);
103            }
104        }
105    }
106
107    #[allow(clippy::cast_possible_truncation)]
108    let out_w = w as u32;
109    #[allow(clippy::cast_possible_truncation)]
110    let out_h = h as u32;
111
112    Ok(DecodedImage {
113        data: dst,
114        width: out_w,
115        height: out_h,
116    })
117}
118
119// ---------------------------------------------------------------------------
120// Tests
121// ---------------------------------------------------------------------------
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::profile::Encoding;
127    use std::sync::atomic::AtomicBool;
128
129    fn make_profile(w: i32, h: i32) -> Profile {
130        Profile {
131            prefix: 0,
132            width: w,
133            height: h,
134            encoding: Encoding::Rgb565,
135            frame_byte_length: (w * h * 2).max(0),
136            clcl_chroma: true,
137            ..Default::default()
138        }
139    }
140
141    // ---- Error paths ----
142
143    #[test]
144    fn zero_width_returns_invalid_format() {
145        let profile = make_profile(0, 100);
146        let result = decode(b"", &profile, &AtomicBool::new(false));
147        assert!(matches!(result, Err(DecodeError::InvalidFormat(..))));
148    }
149
150    #[test]
151    fn zero_height_returns_invalid_format() {
152        let profile = make_profile(100, 0);
153        let result = decode(b"", &profile, &AtomicBool::new(false));
154        assert!(matches!(result, Err(DecodeError::InvalidFormat(..))));
155    }
156
157    #[test]
158    fn negative_width_returns_invalid_format() {
159        let profile = make_profile(-1, 100);
160        let result = decode(b"", &profile, &AtomicBool::new(false));
161        assert!(matches!(result, Err(DecodeError::InvalidFormat(..))));
162    }
163
164    #[test]
165    fn buffer_too_short_returns_error() {
166        let profile = make_profile(10, 10);
167        // 10*10*2 = 200 bytes needed, only 10 provided
168        let result = decode(&[0u8; 10], &profile, &AtomicBool::new(false));
169        assert!(matches!(
170            result,
171            Err(DecodeError::BufferTooShort {
172                expected: 200,
173                actual: 10
174            })
175        ));
176    }
177
178    #[test]
179    fn buffer_too_short_odd_bytes() {
180        // 2×2 = 4 pixels → expected = 4 + 2 + 2 = 8 bytes
181        // Provide only 7 bytes
182        let profile = make_profile(2, 2);
183        let result = decode(&[0u8; 7], &profile, &AtomicBool::new(false));
184        assert!(matches!(
185            result,
186            Err(DecodeError::BufferTooShort { expected: 8, actual: 7 })
187        ));
188    }
189
190    // ---- Neutral chroma (gray output) ----
191
192    #[test]
193    fn gray_pixel_neutral_chroma() {
194        // Y=128, Cb=8 (neutral after <<4 → 128), Cr=8 (neutral after <<4 → 128)
195        // 2 pixels: 2 Y bytes + 1 Cb byte + 1 Cr byte = 4 bytes
196        // Both pixels get Cb=(low nibble=8), Cr=(low nibble=8)
197        let profile = make_profile(2, 1);
198        let img = decode(&[128, 128, 0x88, 0x88], &profile, &AtomicBool::new(false)).unwrap();
199        assert_eq!(img.data[0..4], [128, 128, 128, 255]);
200        assert_eq!(img.data[4..8], [128, 128, 128, 255]);
201        assert_eq!(img.width, 2);
202        assert_eq!(img.height, 1);
203    }
204
205    #[test]
206    fn black_with_neutral_chroma() {
207        let profile = make_profile(2, 1);
208        let img = decode(&[0, 0, 0x88, 0x88], &profile, &AtomicBool::new(false)).unwrap();
209        assert_eq!(img.data[0..4], [0, 0, 0, 255]);
210        assert_eq!(img.data[4..8], [0, 0, 0, 255]);
211    }
212
213    #[test]
214    fn white_with_neutral_chroma() {
215        let profile = make_profile(2, 1);
216        let img = decode(&[255, 255, 0x88, 0x88], &profile, &AtomicBool::new(false)).unwrap();
217        assert_eq!(img.data[0..4], [255, 255, 255, 255]);
218        assert_eq!(img.data[4..8], [255, 255, 255, 255]);
219    }
220
221    // ---- Chroma nibble unpacking ----
222
223    #[test]
224    fn low_nibble_is_first_pixel_cb() {
225        // 2 pixels: Cb byte = 0x0F (low=15, high=0), Cr byte = 0x00
226        // Pixel 0 (even, low nibble): Cb=15, Cr=0
227        //   cb_8bit = 240, cr_8bit = 0
228        //   yuv_to_bgra(128, 240, 0):
229        //     r = clamp(128 + (-128*359>>8)) = clamp(128 - 180) = 0
230        //     g = clamp(128 - (112*88>>8) - (-128*183>>8))
231        //       = clamp(128 - 38 + 92) = clamp(182) = 182
232        //     b = clamp(128 + (112*454>>8)) = clamp(128 + 198) = clamp(326) = 255
233        //   → BGRA [255, 182, 0, 255]
234        // Pixel 1 (odd, high nibble): Cb=0, Cr=0
235        //   cb_8bit = 0, cr_8bit = 0
236        //   yuv_to_bgra(128, 0, 0):
237        //     r = clamp(128 + (-128*359>>8)) = clamp(128 - 180) = 0
238        //     g = clamp(128 - (-128*88>>8) - (-128*183>>8))
239        //       = clamp(128 + 44 + 92) = clamp(264) = 255
240        //     b = clamp(128 + (-128*454>>8)) = clamp(128 - 227) = 0
241        //   → BGRA [0, 255, 0, 255]
242        let profile = make_profile(2, 1);
243        let img = decode(&[128, 128, 0x0F, 0x00], &profile, &AtomicBool::new(false)).unwrap();
244        // Pixel 0: Cb=15(hi), Cr=0(lo) → blue-ish
245        assert_eq!(img.data[0..4], [255, 182, 0, 255], "pixel 0 low nibble Cb=15 Cr=0");
246        // Pixel 1: Cb=0(hi), Cr=0(hi) → green-ish
247        assert_eq!(img.data[4..8], [0, 255, 0, 255], "pixel 1 high nibble Cb=0 Cr=0");
248    }
249
250    #[test]
251    fn high_nibble_is_second_pixel_chroma() {
252        // Cb byte = 0xF0 (low=0, high=15), Cr byte = 0xF0 (low=0, high=15)
253        // Pixel 0 (even, low nibble): Cb=0, Cr=0 → green cast
254        // Pixel 1 (odd, high nibble): Cb=15, Cr=15 → blue+red cast
255        let profile = make_profile(2, 1);
256        let img = decode(&[128, 128, 0xF0, 0xF0], &profile, &AtomicBool::new(false)).unwrap();
257        // Pixel 0: Cb=0, Cr=0 → green
258        assert_eq!(img.data[0..4], [0, 255, 0, 255], "pixel 0 low nibble Cb=0 Cr=0");
259        // Pixel 1: Cb=15, Cr=15 → Cb8bit=240, Cr8bit=240
260        // yuv_to_bgra(128, 240, 240):
261        //   r = clamp(128 + (112*359>>8)) = 128 + 157 = 255
262        //   g = clamp(128 - (112*88>>8) - (112*183>>8)) = 128 - 38 - 80 = 10
263        //   b = clamp(128 + (112*454>>8)) = 128 + 198 = 255
264        // → [255, 10, 255, 255]
265        assert_eq!(img.data[4..8], [255, 10, 255, 255], "pixel 1 high nibble Cb=15 Cr=15");
266    }
267
268    // ---- Multi-pixel decode ----
269
270    #[test]
271    fn two_by_two_grid_all_gray() {
272        // 2×2 image, all Y=128, all Cb=8, Cr=8 → all gray [128,128,128,255]
273        // 4 pixels: 4 Y bytes + 2 Cb bytes + 2 Cr bytes = 8 bytes
274        let profile = make_profile(2, 2);
275        let img = decode(
276            &[
277                128, 128, 128, 128, // Y plane (4 bytes)
278                0x88, 0x88, // Cb plane (2 bytes): each (8<<4)|8 = neutral
279                0x88, 0x88, // Cr plane (2 bytes): neutral
280            ],
281            &profile,
282            &AtomicBool::new(false),
283        )
284        .unwrap();
285        assert_eq!(img.width, 2);
286        assert_eq!(img.height, 2);
287        let expected = [128u8, 128, 128, 255];
288        for y in 0..2 {
289            for x in 0..2 {
290                let off = (y * 2 + x) * 4;
291                assert_eq!(img.data[off..off + 4], expected, "pixel ({x},{y}) mismatch");
292            }
293        }
294    }
295
296    #[test]
297    fn two_by_two_with_varying_y() {
298        // Pixel pattern:
299        // [Y=255] [Y=128]
300        // [Y=64]  [Y=0]
301        // All Cb=8, Cr=8 (neutral)
302        let profile = make_profile(2, 2);
303        let img = decode(
304            &[
305                255, 128, 64, 0, // Y plane (4 bytes)
306                0x88, 0x88, // Cb plane
307                0x88, 0x88, // Cr plane
308            ],
309            &profile,
310            &AtomicBool::new(false),
311        )
312        .unwrap();
313        // Pixel (0,0): Y=255, neutral → white
314        assert_eq!(img.data[0..4], [255, 255, 255, 255]);
315        // Pixel (1,0): Y=128, neutral → gray
316        assert_eq!(img.data[4..8], [128, 128, 128, 255]);
317        // Pixel (0,1): Y=64, neutral → dark gray
318        assert_eq!(img.data[8..12], [64, 64, 64, 255]);
319        // Pixel (1,1): Y=0, neutral → black
320        assert_eq!(img.data[12..16], [0, 0, 0, 255]);
321    }
322
323    // ---- Chroma is shared across pixel pairs ----
324
325    #[test]
326    fn pixel_pair_shares_chroma() {
327        // 4 pixels sharing chroma across pairs:
328        // Pixels 0,1 share Cb=8, Cr=15 → same byte but diff nibbles
329        // Pixels 2,3 share Cb=0, Cr=0
330        //
331        // Cb bytes: [(8<<4)|8, (0<<4)|0] = [0x88, 0x00]
332        // Cr bytes: [(15<<4)|15, (0<<4)|0] = [0xFF, 0x00]
333        //
334        // Pixel 0: Y=255, Cb=128, Cr=240
335        //   yuv_to_bgra(255, 128, 240):
336        //     r = 255 + 112*359/256 = 255 + 157 = 255
337        //     g = 255 - 0 - 80 = 175
338        //     b = 255 + 0 = 255
339        //   → [255, 175, 255, 255]
340        //
341        // Pixel 1: Same chroma as pixel 0, Y=200
342        //   yuv_to_bgra(200, 128, 240):
343        //     r = 200 + 157 = 255
344        //     g = 200 - 80 = 120
345        //     b = 200 + 0 = 200
346        //   → [200, 120, 255, 255]
347        //
348        // Pixel 2: Y=100, Cb=0, Cr=0
349        //   yuv_to_bgra(100, 0, 0):
350        //     r = 100 - 180 = 0
351        //     g = 100 + 44 + 92 = 236
352        //     b = 100 - 227 = 0
353        //   → [0, 236, 0, 255]
354        //
355        // Pixel 3: Y=50, Cb=0, Cr=0
356        //   yuv_to_bgra(50, 0, 0):
357        //     r = 50 - 180 = 0
358        //     g = 50 + 44 + 92 = 186
359        //     b = 50 - 227 = 0
360        //   → [0, 186, 0, 255]
361        let profile = make_profile(4, 1);
362        let img = decode(
363            &[
364                255, 200, 100, 50, // Y plane (4 bytes)
365                0x88, 0x00, // Cb plane (2 bytes)
366                0xFF, 0x00, // Cr plane (2 bytes)
367            ],
368            &profile,
369            &AtomicBool::new(false),
370        )
371        .unwrap();
372        assert_eq!(img.data[0..4], [255, 175, 255, 255], "pixel 0");
373        assert_eq!(img.data[4..8], [200, 120, 255, 255], "pixel 1");
374        assert_eq!(img.data[8..12], [0, 236, 0, 255], "pixel 2");
375        assert_eq!(img.data[12..16], [0, 186, 0, 255], "pixel 3");
376    }
377
378    // ---- Nibble edge values ----
379
380    #[test]
381    fn chroma_nibbles_at_extremes() {
382        // Cb=15, Cr=15 in both nibbles → Cb byte=0xFF, Cr byte=0xFF
383        // Both pixels get Cb_8bit=240, Cr_8bit=240
384        // yuv_to_bgra(255, 240, 240):
385        //   g = 255 - (112*88>>8) - (112*183>>8) = 255 - 38 - 80 = 137
386        let profile = make_profile(2, 1);
387        let img = decode(&[255, 255, 0xFF, 0xFF], &profile, &AtomicBool::new(false)).unwrap();
388        assert_eq!(img.data[0..4], [255, 137, 255, 255]);
389        assert_eq!(img.data[4..8], [255, 137, 255, 255]);
390    }
391
392    #[test]
393    fn chroma_nibbles_at_minimum() {
394        // Cb=0, Cr=0 → Cb byte=0x00, Cr byte=0x00
395        // yuv_to_bgra(128, 0, 0):
396        //   r = clamp(128 - 180) = 0
397        //   g = clamp(128 + 44 + 92) = clamp(264) = 255
398        //   b = clamp(128 - 227) = 0
399        // → [0, 255, 0, 255]
400        let profile = make_profile(2, 1);
401        let img = decode(&[128, 128, 0x00, 0x00], &profile, &AtomicBool::new(false)).unwrap();
402        assert_eq!(img.data[0..4], [0, 255, 0, 255]);
403    }
404
405    // ---- Planar indexing: verify planes don't alias ----
406
407    #[test]
408    fn cb_and_cr_planes_are_separate() {
409        // Pixel 0: Y=128, Cb=15, Cr=0  → Cb byte low nibble=15, Cr byte low nibble=0
410        // Pixel 1: Y=128, Cb=0,  Cr=15 → Cb byte high nibble=0, Cr byte high nibble=15
411        // Cb byte = (0 << 4) | 15 = 0x0F
412        // Cr byte = (15 << 4) | 0 = 0xF0
413        let profile = make_profile(2, 1);
414        let img = decode(&[128, 128, 0x0F, 0xF0], &profile, &AtomicBool::new(false)).unwrap();
415        // Pixel 0: Cb=15, Cr=0 → blue (already tested above)
416        assert_eq!(img.data[0..4], [255, 182, 0, 255], "pixel 0 Cb=15 Cr=0");
417        // Pixel 1: Cb=0, Cr=15 → red
418        // yuv_to_bgra(128, 0, 240):
419        //   r = 128 + (112*359>>8) = 128 + 157 = 255
420        //   g = 128 - (-128*88>>8) - (112*183>>8) = 128 + 44 - 80 = 92
421        //   b = 128 + (-128*454>>8) = 128 - 227 = 0
422        // → [0, 92, 255, 255]
423        assert_eq!(img.data[4..8], [0, 92, 255, 255], "pixel 1 Cb=0 Cr=15");
424    }
425
426    // ---- 2x2 image decode (required acceptance test) ----
427
428    #[test]
429    fn two_by_two_image_decode() {
430        // 2×2 grid with varying Y and chroma
431        // Pixel layout (row-major):
432        //   (0,0): Y=128, Cb=8,  Cr=8  → gray
433        //   (1,0): Y=200, Cb=15, Cr=0  → blue-ish
434        //   (0,1): Y=64,  Cb=0,  Cr=15 → red-ish
435        //   (1,1): Y=255, Cb=15, Cr=15 → magenta-ish
436        //
437        // Y bytes: [128, 200, 64, 255]
438        // Cb bytes: pair0(0,1)=(Cb1<<4)|Cb0=(0<<4)|15=0x0F? No...
439        //   Actually, let me do this more carefully:
440        //   Pixels are indexed 0,1,2,3 (row-major for 2×2).
441        //   Pixel pairs: (0,1) and (2,3)
442        //   Cb_0 for pixels 0,1 = 8, 15 → low nibble = 8 (for pixel 0)
443        //   Cb_1 for pixels 2,3 = 0, 15 → ... wait
444        //
445        // Actually, let me re-index:
446        //   Pixel 0 = (0,0): Y=128, Cb=8, Cr=8
447        //   Pixel 1 = (1,0): Y=200, Cb=15, Cr=0
448        //   Pixel 2 = (0,1): Y=64, Cb=0, Cr=15
449        //   Pixel 3 = (1,1): Y=255, Cb=15, Cr=15
450        //
451        // Pixel pairs: (0,1) and (2,3) — because pairs are (2k, 2k+1)
452        //
453        // Cb byte 0 (for pixel pair 0 = pixels 0,1):
454        //   low nibble = Cb for pixel 0 = 8
455        //   high nibble = Cb for pixel 1 = 15
456        //   = (15 << 4) | 8 = 0xF8
457        //
458        // Cb byte 1 (for pixel pair 1 = pixels 2,3):
459        //   low nibble = Cb for pixel 2 = 0
460        //   high nibble = Cb for pixel 3 = 15
461        //   = (15 << 4) | 0 = 0xF0
462        //
463        // Cr byte 0: low nibble = Cr for pixel 0 = 8, high nibble = Cr for pixel 1 = 0
464        //   = (0 << 4) | 8 = 0x08
465        //
466        // Cr byte 1: low nibble = Cr for pixel 2 = 15, high nibble = Cr for pixel 3 = 15
467        //   = (15 << 4) | 15 = 0xFF
468        let profile = make_profile(2, 2);
469        let img = decode(
470            &[
471                // Y plane
472                128, 200, 64, 255, // Cb plane
473                0xF8, 0xF0, // Cr plane
474                0x08, 0xFF,
475            ],
476            &profile,
477            &AtomicBool::new(false),
478        )
479        .unwrap();
480        assert_eq!(img.width, 2);
481        assert_eq!(img.height, 2);
482
483        // Pixel 0: Y=128, Cb=8, Cr=8 → gray
484        assert_eq!(img.data[0..4], [128, 128, 128, 255]);
485        // Pixel 1: Y=200, Cb=15, Cr=0 → blue cast
486        // yuv_to_bgra(200, 240, 0):
487        //   r = 200 + (-128*359>>8) = 200 - 180 = 20
488        //   g = 200 - (112*88>>8) - (-128*183>>8) = 200 - 38 + 92 = 254
489        //   b = 200 + (112*454>>8) = 200 + 198 = 255
490        // → [255, 254, 20, 255]
491        assert_eq!(img.data[4..8], [255, 254, 20, 255]);
492        // Pixel 2: Y=64, Cb=0, Cr=15 → red cast
493        // yuv_to_bgra(64, 0, 240):
494        //   r = 64 + (112*359>>8) = 64 + 157 = 221
495        //   g = 64 - (-128*88>>8) - (112*183>>8) = 64 + 44 - 80 = 28
496        //   b = 64 + (-128*454>>8) = 64 - 227 = 0
497        // → [0, 28, 221, 255]
498        assert_eq!(img.data[8..12], [0, 28, 221, 255]);
499        // Pixel 3: Y=255, Cb=15, Cr=15 → magenta cast
500        // yuv_to_bgra(255, 240, 240):
501        //   g = 255 - (112*88>>8) - (112*183>>8) = 255 - 38 - 80 = 137
502        // → [255, 137, 255, 255]
503        assert_eq!(img.data[12..16], [255, 137, 255, 255]);
504    }
505}