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}