edgefirst-codec 0.25.3

Image codec for decoding JPEG/PNG into pre-allocated EdgeFirst tensors
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
// SPDX-FileCopyrightText: Copyright 2026 Au-Zone Technologies
// SPDX-License-Identifier: Apache-2.0

//! Integration tests: JPEG decode into Mem tensors.
//!
//! The codec always emits the JPEG's native format — `Nv12`/`Nv16`/`Nv24` for
//! colour (3-component) JPEGs by subsampling, `Grey` for greyscale. It configures the destination
//! tensor's dims and format itself; callers allocate a tensor with enough
//! capacity. `info.width`/`info.height` are the source's true (unrotated) dims.

use edgefirst_codec::{peek_info, CodecError, ImageDecoder, ImageLoad, UnsupportedFeature};
use edgefirst_tensor::{PixelFormat, Tensor, TensorMemory, TensorTrait};

fn testdata(name: &str) -> Vec<u8> {
    let root = std::env::var("EDGEFIRST_TESTDATA_DIR")
        .map(std::path::PathBuf::from)
        .unwrap_or_else(|_| {
            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
                .parent()
                .unwrap()
                .parent()
                .unwrap()
                .join("testdata")
        });
    let path = root.join(name);
    std::fs::read(&path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()))
}

#[test]
fn decode_zidane_nv12() {
    let jpeg = testdata("zidane.jpg");
    let mut tensor =
        Tensor::<u8>::image(1280, 720, PixelFormat::Nv12, Some(TensorMemory::Mem)).unwrap();
    let mut decoder = ImageDecoder::new();
    let info = tensor.load_image(&mut decoder, &jpeg).unwrap();
    assert_eq!(info.width, 1280);
    assert_eq!(info.height, 720);
    assert_eq!(info.format, PixelFormat::Nv12);

    // The Y plane (first w*h bytes) should have many non-zero bytes.
    let map = tensor.map().unwrap();
    let pixels: &[u8] = &map;
    let nonzero = pixels[..info.width * info.height]
        .iter()
        .filter(|&&v| v != 0)
        .count();
    assert!(
        nonzero > 1000,
        "expected many non-zero Y-plane bytes, got {nonzero}"
    );
}

#[test]
fn decode_grey_jpeg() {
    let jpeg = testdata("grey.jpg");
    // grey.jpg is 1024×681 greyscale.
    let mut tensor =
        Tensor::<u8>::image(1024, 681, PixelFormat::Grey, Some(TensorMemory::Mem)).unwrap();
    let mut decoder = ImageDecoder::new();
    let info = tensor.load_image(&mut decoder, &jpeg).unwrap();
    assert!(info.width > 0 && info.height > 0);
    assert_eq!(info.format, PixelFormat::Grey);

    let map = tensor.map().unwrap();
    let pixels: &[u8] = &map;
    let nonzero = pixels[..info.width * info.height]
        .iter()
        .filter(|&&v| v != 0)
        .count();
    assert!(
        nonzero > 1000,
        "expected non-zero grey pixels, got {nonzero}"
    );
}

#[test]
fn decode_person_rejected() {
    let jpeg = testdata("person.jpg");
    // person.jpg is a progressive JPEG — our baseline-only decoder rejects it.
    // person.jpg is 4256×2532 (even dims) so allocate an Nv12 tensor of that size.
    let mut tensor =
        Tensor::<u8>::image(4256, 2532, PixelFormat::Nv12, Some(TensorMemory::Mem)).unwrap();
    let mut decoder = ImageDecoder::new();
    let result = tensor.load_image(&mut decoder, &jpeg);
    assert!(
        result.is_err(),
        "progressive JPEG should be rejected by baseline decoder"
    );
}

#[test]
fn decode_f32_jpeg_unsupported_dtype() {
    // NV12/GREY are u8 formats; the codec rejects non-u8 tensors for JPEG.
    let jpeg = testdata("zidane.jpg");
    let mut tensor =
        Tensor::<f32>::image(1280, 720, PixelFormat::Nv12, Some(TensorMemory::Mem)).unwrap();
    let mut decoder = ImageDecoder::new();
    let result = tensor.load_image(&mut decoder, &jpeg);
    assert!(
        matches!(result, Err(CodecError::UnsupportedDtype(_))),
        "expected UnsupportedDtype for f32 JPEG decode, got {result:?}"
    );
}

#[test]
fn decode_capacity_error() {
    let jpeg = testdata("zidane.jpg"); // 1280×720
                                       // Allocate far too small.
    let mut tensor =
        Tensor::<u8>::image(100, 100, PixelFormat::Nv12, Some(TensorMemory::Mem)).unwrap();
    let mut decoder = ImageDecoder::new();
    let result = tensor.load_image(&mut decoder, &jpeg);
    assert!(result.is_err());
    match result.unwrap_err() {
        CodecError::InsufficientCapacity { image, tensor } => {
            assert_eq!(image, (1280, 720));
            assert_eq!(tensor, (100, 100));
        }
        other => panic!("expected InsufficientCapacity, got {other}"),
    }
}

#[test]
fn decode_reuse_pattern() {
    // The hot-loop reuse pattern: decode multiple different images into the same
    // tensor. Allocate at the max size (giraffe and zidane are both 1280×720
    // colour JPEGs → NV12). jaguar.jpg has a smaller odd width and is excluded
    // here — not because NV12 rejects odd widths (it doesn't), but because a
    // 1280×720 pool reconfigured for a smaller image exercises configure_image.
    let mut tensor =
        Tensor::<u8>::image(1280, 720, PixelFormat::Nv12, Some(TensorMemory::Mem)).unwrap();
    let mut decoder = ImageDecoder::new();

    let images = ["zidane.jpg", "giraffe.jpg"];
    for name in &images {
        let jpeg = testdata(name);
        let info = tensor.load_image(&mut decoder, &jpeg).unwrap();
        assert_eq!(
            info.format,
            PixelFormat::Nv12,
            "{name} should decode to NV12"
        );
        assert!(info.width > 0 && info.height > 0, "failed to decode {name}");
    }
}

/// Compare our greyscale JPEG output against the `image` crate. Greyscale JPEG
/// decodes to a single-plane `Grey` tensor that we can compare directly.
#[test]
fn pixel_accuracy_grey_vs_image_crate() {
    let jpeg_data = testdata("grey.jpg");

    let mut tensor =
        Tensor::<u8>::image(1024, 681, PixelFormat::Grey, Some(TensorMemory::Mem)).unwrap();
    let mut decoder = ImageDecoder::new();
    let info = tensor.load_image(&mut decoder, &jpeg_data).unwrap();
    assert_eq!(info.format, PixelFormat::Grey);

    let ref_img = image::load_from_memory(&jpeg_data).unwrap().to_luma8();
    let (ref_w, ref_h) = ref_img.dimensions();
    assert_eq!(info.width, ref_w as usize);
    assert_eq!(info.height, ref_h as usize);

    let map = tensor.map().unwrap();
    let our_pixels: &[u8] = unsafe { std::slice::from_raw_parts(map.as_ptr(), map.len()) };
    let ref_pixels = ref_img.as_raw();

    let w = info.width;
    let h = info.height;
    let stride = info.row_stride;

    let mut max_diff: u32 = 0;
    let mut total_diff: u64 = 0;

    for y in 0..h {
        for x in 0..w {
            let our_val = our_pixels[y * stride + x] as i32;
            let ref_val = ref_pixels[y * w + x] as i32;
            let diff = (our_val - ref_val).unsigned_abs();
            max_diff = max_diff.max(diff);
            total_diff += diff as u64;
        }
    }

    let pixel_count = (w * h) as f64;
    let mae = total_diff as f64 / pixel_count;
    eprintln!("Grey accuracy: MAE={mae:.3}, max_diff={max_diff}");

    assert!(
        max_diff <= 4,
        "max grey diff {max_diff} exceeds tolerance 4"
    );
    assert!(mae < 1.0, "grey MAE {mae:.3} exceeds tolerance 1.0");
}

/// Test decoding into an oversized tensor with stride padding.
/// The decoded image (1280×720) goes into a 1920×1080 NV12 tensor.
/// Verify the Y plane is written and padding bytes are untouched.
#[test]
fn decode_strided_oversized_tensor() {
    let jpeg = testdata("zidane.jpg");

    // Allocate larger than the image.
    let mut tensor =
        Tensor::<u8>::image(1920, 1080, PixelFormat::Nv12, Some(TensorMemory::Mem)).unwrap();

    // Fill tensor with sentinel value.
    {
        let mut map = tensor.map().unwrap();
        let bytes: &mut [u8] =
            unsafe { std::slice::from_raw_parts_mut(map.as_mut_ptr(), map.len()) };
        bytes.fill(0xAA);
    }

    let mut decoder = ImageDecoder::new();
    let info = tensor.load_image(&mut decoder, &jpeg).unwrap();

    assert_eq!(info.width, 1280);
    assert_eq!(info.height, 720);
    assert_eq!(info.format, PixelFormat::Nv12);
    let stride = info.row_stride;

    let map = tensor.map().unwrap();
    let bytes: &[u8] = unsafe { std::slice::from_raw_parts(map.as_ptr(), map.len()) };

    // Verify decoded Y-plane rows are non-sentinel within the image width.
    let decoded_row_bytes = 1280;
    for y in 0..720 {
        let row = &bytes[y * stride..y * stride + decoded_row_bytes];
        let non_sentinel = row.iter().filter(|&&b| b != 0xAA).count();
        assert!(
            non_sentinel > 0,
            "Y row {y} appears to be all sentinel values"
        );
    }
}

#[test]
fn decode_truncated_jpeg() {
    let jpeg = testdata("zidane.jpg");
    let truncated = &jpeg[..100];
    let mut tensor =
        Tensor::<u8>::image(1280, 720, PixelFormat::Nv12, Some(TensorMemory::Mem)).unwrap();
    let mut decoder = ImageDecoder::new();
    let result = tensor.load_image(&mut decoder, truncated);
    assert!(matches!(result, Err(CodecError::InvalidData(_))));
}

#[test]
fn decode_not_jpeg() {
    let mut bogus = testdata("zidane.png");
    bogus[..4].copy_from_slice(&[0xFF, 0xD8, 0xFF, 0xE0]);

    let mut tensor =
        Tensor::<u8>::image(1280, 720, PixelFormat::Nv12, Some(TensorMemory::Mem)).unwrap();
    let mut decoder = ImageDecoder::new();
    let result = tensor.load_image(&mut decoder, &bogus);
    assert!(matches!(result, Err(CodecError::InvalidData(_))));
}

#[test]
fn decode_empty_data() {
    let mut tensor =
        Tensor::<u8>::image(1280, 720, PixelFormat::Nv12, Some(TensorMemory::Mem)).unwrap();
    let mut decoder = ImageDecoder::new();
    let result = tensor.load_image(&mut decoder, &[]);
    assert!(matches!(result, Err(CodecError::InvalidData(_))));
}

#[test]
fn decode_corrupt_markers() {
    let corrupt = [0xFF, 0xD8, 0xFF, 0x00];
    let mut tensor =
        Tensor::<u8>::image(1280, 720, PixelFormat::Nv12, Some(TensorMemory::Mem)).unwrap();
    let mut decoder = ImageDecoder::new();
    let result = tensor.load_image(&mut decoder, &corrupt);
    assert!(matches!(result, Err(CodecError::InvalidData(_))));
}

#[test]
fn decode_exact_size_tensor() {
    use image::ImageDecoder as _;

    let jpeg = testdata("giraffe.jpg");
    let header =
        image::codecs::jpeg::JpegDecoder::new(std::io::Cursor::new(jpeg.as_slice())).unwrap();
    let (width, height) = header.dimensions();

    let mut tensor = Tensor::<u8>::image(
        width as usize,
        height as usize,
        PixelFormat::Nv12,
        Some(TensorMemory::Mem),
    )
    .unwrap();
    let mut decoder = ImageDecoder::new();
    let info = tensor.load_image(&mut decoder, &jpeg).unwrap();

    assert_eq!(info.width, width as usize);
    assert_eq!(info.height, height as usize);
    assert_eq!(info.format, PixelFormat::Nv12);
}

#[test]
fn decode_nv12_output() {
    let jpeg = testdata("zidane.jpg");
    let width = 1280usize;
    let height = 720usize;

    let mut tensor =
        Tensor::<u8>::image(width, height, PixelFormat::Nv12, Some(TensorMemory::Mem)).unwrap();
    let mut decoder = ImageDecoder::new();

    let info = tensor.load_image(&mut decoder, &jpeg).unwrap();
    assert_eq!(info.width, width);
    assert_eq!(info.height, height);
    assert_eq!(info.format, PixelFormat::Nv12);

    let map = tensor.map().unwrap();
    let bytes: &[u8] = &map;

    // Y plane should have non-zero data.
    let y_nonzero = bytes[..width * height].iter().filter(|&&v| v != 0).count();
    assert!(
        y_nonzero > 1000,
        "Y plane should have many non-zero pixels, got {y_nonzero}"
    );

    // UV plane should have non-128 data (chrominance varies in real images).
    let uv_start = height * width;
    let uv_size = width * height / 2;
    if uv_start + uv_size <= bytes.len() {
        let uv_non128 = bytes[uv_start..uv_start + uv_size]
            .iter()
            .filter(|&&v| v != 128)
            .count();
        assert!(
            uv_non128 > 100,
            "UV plane should have varied chrominance, non-128 count: {uv_non128}"
        );
    }
}

#[test]
fn unsupported_progressive_jpeg_returns_typed_variant() {
    // person.jpg is a progressive JPEG — the codec rejects it with a
    // typed Unsupported(ProgressiveJpeg). Verifies callers can pattern-match
    // instead of string-matching.
    let jpeg = testdata("person.jpg");
    match peek_info(&jpeg) {
        Err(CodecError::Unsupported(UnsupportedFeature::ProgressiveJpeg)) => {}
        Err(other) => {
            panic!("expected Unsupported(ProgressiveJpeg), got {other:?}");
        }
        Ok(_) => panic!("progressive JPEG should not decode"),
    }
}

#[test]
fn jpeg_decode_tags_jfif_colorimetry() {
    use edgefirst_tensor::Colorimetry;
    let mut t = Tensor::<u8>::image(1280, 720, PixelFormat::Nv12, Some(TensorMemory::Mem)).unwrap();
    let mut d = ImageDecoder::new();
    t.load_image(&mut d, &testdata("zidane.jpg")).unwrap();
    assert_eq!(t.colorimetry(), Some(Colorimetry::jfif()));
}

#[test]
fn odd_width_nv12_jpeg_decodes_with_logical_width() {
    // jaguar.jpg is 789×384 — odd width.  The tensor carries the LOGICAL width
    // (789); the row_stride is >= even(789)=790 and 64-byte aligned, ensuring
    // the interleaved chroma columns are byte-aligned.
    let jpeg = testdata("jaguar.jpg");
    let info = peek_info(&jpeg).unwrap();
    assert_eq!((info.width, info.height), (789, 384));
    assert_eq!(
        info.format,
        PixelFormat::Nv12,
        "colour JPEG should report native NV12 format"
    );

    // Allocating at the odd width preserves the logical width.
    let mut tensor =
        Tensor::<u8>::image(789, 384, PixelFormat::Nv12, Some(TensorMemory::Mem)).unwrap();
    // Contract: width() reports the LOGICAL visible size, not the even-padded buffer width.
    assert_eq!(tensor.width(), Some(789), "logical width must be preserved");
    assert_eq!(tensor.height(), Some(384));
    // Contract: row_stride is 64-byte aligned AND >= even(789)=790.
    let s = tensor
        .effective_row_stride()
        .expect("semi-planar must always have a stride");
    assert_eq!(s % 64, 0, "stride must be 64-byte aligned, got {s}");
    assert!(s >= 790, "stride must be >= even(width)=790, got {s}");

    let mut decoder = ImageDecoder::new();
    let info = tensor
        .load_image(&mut decoder, &jpeg)
        .expect("odd-width NV12 JPEG should decode into the strided buffer");
    // ImageInfo carries the true odd width; both match now.
    assert_eq!((info.width, info.height), (789, 384));
    assert_eq!(tensor.width(), Some(789));
    assert_eq!(tensor.height(), Some(384));
    // Post-decode stride invariant still holds.
    let s2 = tensor.effective_row_stride().unwrap();
    assert_eq!(s2 % 64, 0, "post-decode stride must be 64-byte aligned");
    assert!(s2 >= 790, "post-decode stride must be >= 790");
}

// ---------------------------------------------------------------------------
// Native NV16 (4:2:2) and NV24 (4:4:4) decode.
//
// `zidane_422.jpg` / `zidane_444.jpg` are baseline JPEGs re-encoded from
// `zidane.jpg` (1280×720) at 4:2:2 and 4:4:4 chroma subsampling. These exercise
// the `write_nv16_nv24_rows` MCU path (full-height chroma), which the 4:2:0
// fixtures never reach. The decoder must select the matching native format and
// place both the luma and the interleaved full-height chroma plane correctly.
// ---------------------------------------------------------------------------

/// BT.601 full-range (JFIF) RGB→CbCr — the colorimetry the codec/`yuv` kernels
/// assume. Returned as `(Cb, Cr)` in `[0, 255]`.
fn rgb_to_cbcr(r: f32, g: f32, b: f32) -> (f32, f32) {
    let cb = 128.0 - 0.168_736 * r - 0.331_264 * g + 0.5 * b;
    let cr = 128.0 + 0.5 * r - 0.418_688 * g - 0.081_312 * b;
    (cb, cr)
}

/// Decode a colour JPEG fixture and verify (1) the native format and dims,
/// (2) the luma plane matches the `image` crate's luma tightly, and (3) the
/// interleaved chroma plane reconstructs the `image` crate's colour within a
/// modest tolerance — proving the chroma layout (offset, pitch, Cb/Cr order)
/// is correct, not merely present.
fn check_native_decode(fixture: &str, expect_fmt: PixelFormat) {
    check_native_decode_dims(fixture, expect_fmt, 1280, 720);
}

fn check_native_decode_dims(
    fixture: &str,
    expect_fmt: PixelFormat,
    expect_w: usize,
    expect_h: usize,
) {
    let jpeg = testdata(fixture);
    // Over-allocate generously (NV24 needs 3·H rows); the decoder configures the
    // real shape/format from the bitstream.
    let alloc_w = expect_w.max(1280);
    let alloc_h = expect_h.max(720);
    let mut tensor =
        Tensor::<u8>::image(alloc_w, alloc_h, expect_fmt, Some(TensorMemory::Mem)).unwrap();
    let mut decoder = ImageDecoder::new();
    let info = tensor.load_image(&mut decoder, &jpeg).unwrap();
    assert_eq!(info.format, expect_fmt, "{fixture}: native format");
    assert_eq!(
        (info.width, info.height),
        (expect_w, expect_h),
        "{fixture}: dims"
    );

    let ref_rgb = image::load_from_memory(&jpeg).unwrap().to_rgb8();
    assert_eq!(ref_rgb.dimensions(), (expect_w as u32, expect_h as u32));

    let w = info.width;
    let h = info.height;
    let stride = info.row_stride;
    let map = tensor.map().unwrap();
    let buf: &[u8] = &map;

    // (2) Luma accuracy against the image crate's luma.
    let ref_luma = image::load_from_memory(&jpeg).unwrap().to_luma8();
    let mut y_max: u32 = 0;
    let mut y_total: u64 = 0;
    for y in 0..h {
        for x in 0..w {
            let ours = buf[y * stride + x] as i32;
            let refv = ref_luma.get_pixel(x as u32, y as u32)[0] as i32;
            let d = (ours - refv).unsigned_abs();
            y_max = y_max.max(d);
            y_total += d as u64;
        }
    }
    let y_mae = y_total as f64 / (w * h) as f64;
    eprintln!("{fixture} luma: MAE={y_mae:.3}, max={y_max}");
    // Fixtures are re-encoded from a 4:2:0 source, so luma sees two lossy passes
    // plus IDCT-rounding divergence from the image crate; the small MAE is the
    // real signal (a layout bug would push it into the tens), max is edge-noise.
    assert!(y_max <= 24, "{fixture}: luma max diff {y_max} > 24");
    assert!(y_mae < 2.0, "{fixture}: luma MAE {y_mae:.3} > 2.0");

    // (3) Chroma reconstruction. The chroma plane lives in buffer rows [H, …)
    // using the documented per-format layout (NV16: full-height, half-width,
    // one buffer row per image row; NV24: full-res, two buffer rows per image
    // row). Decode (Cb, Cr) for a sampled grid and compare to CbCr derived from
    // the image crate's RGB. A transposed/mis-offset plane shows up as a large
    // diff; smooth chroma keeps the honest tolerance small.
    //
    // Index directly from `stride` (the physical row pitch) rather than from
    // `even_w` so the formula is correct for both even and odd widths. The
    // `even_w`-based `pos` helper is only correct when `stride == even_w`
    // (i.e., un-padded Mem tensors at even widths).
    let uv_off = h * stride;
    let chroma_at = |x: usize, y: usize| -> (i32, i32) {
        let byte_off = match expect_fmt {
            // NV16 4:2:2: one chroma row per image row, chroma column x/2.
            PixelFormat::Nv16 => uv_off + y * stride + (x / 2) * 2,
            // NV24 4:4:4: two buffer rows per image row (UV pitch = 2 * stride),
            // full-resolution chroma columns.
            _ => uv_off + y * 2 * stride + x * 2,
        };
        (buf[byte_off] as i32, buf[byte_off + 1] as i32)
    };

    let mut c_max: u32 = 0;
    let mut c_total: u64 = 0;
    let mut samples: u64 = 0;
    for y in (0..h).step_by(2) {
        for x in (0..w).step_by(2) {
            let p = ref_rgb.get_pixel(x as u32, y as u32);
            let (ecb, ecr) = rgb_to_cbcr(p[0] as f32, p[1] as f32, p[2] as f32);
            let (cb, cr) = chroma_at(x, y);
            let dcb = (cb - ecb.round() as i32).unsigned_abs();
            let dcr = (cr - ecr.round() as i32).unsigned_abs();
            c_max = c_max.max(dcb).max(dcr);
            c_total += (dcb + dcr) as u64;
            samples += 2;
        }
    }
    let c_mae = c_total as f64 / samples as f64;
    eprintln!("{fixture} chroma: MAE={c_mae:.3}, max={c_max}");
    // JPEG chroma is lossy and the image crate upsamples NV16's half-width
    // chroma, so allow a modest spread; a wrong layout would be ~50+ MAE.
    assert!(
        c_mae < 6.0,
        "{fixture}: chroma MAE {c_mae:.3} > 6.0 (layout?)"
    );
    assert!(
        c_max <= 40,
        "{fixture}: chroma max diff {c_max} > 40 (layout?)"
    );
}

#[test]
fn decode_zidane_nv16() {
    check_native_decode("zidane_422.jpg", PixelFormat::Nv16);
}

#[test]
fn decode_zidane_nv24() {
    check_native_decode("zidane_444.jpg", PixelFormat::Nv24);
}

/// Odd-width (789×384) NV16 decode — exercises the chroma layout and
/// stride alignment for 4:2:2 at a non-even width.
#[test]
fn decode_jaguar_nv16() {
    check_native_decode_dims("jaguar_422.jpg", PixelFormat::Nv16, 789, 384);
}

/// Odd-width (789×384) NV24 decode — exercises the chroma layout and
/// stride alignment for 4:4:4 at a non-even width.
#[test]
fn decode_jaguar_nv24() {
    check_native_decode_dims("jaguar_444.jpg", PixelFormat::Nv24, 789, 384);
}

// ---------------------------------------------------------------------------
// Real-world COCO odd-width greyscale fixture. The end-to-end odd-dimension
// decode+convert coverage for the colour formats (NV12/NV16/NV24) lives in the
// Python suite (`test_decode_native_odd_dimensions`), which compares the
// converted RGB to PIL with a robust RMS metric across CPU and GPU backends.
// This codec-level case guards the specific odd-WIDTH greyscale decode path:
// the luma-only output decodes into a tight odd-width buffer (stride == img_w),
// which must NOT trip the even-width grid-stride assertion. Luma is exact, so
// a tight byte comparison is robust here (unlike point-sampled chroma).
// ---------------------------------------------------------------------------

#[test]
fn decode_coco_grey_odd_width() {
    // 595×438 — odd-width greyscale. Verify the native Grey format, the odd
    // logical dims, and that the luma plane matches the image crate tightly.
    let jpeg = testdata("coco_grey_odd.jpg");
    let (w, h) = (595usize, 438usize);
    let mut tensor = Tensor::<u8>::image(w, h, PixelFormat::Grey, Some(TensorMemory::Mem)).unwrap();
    let mut decoder = ImageDecoder::new();
    let info = tensor.load_image(&mut decoder, &jpeg).unwrap();
    assert_eq!(info.format, PixelFormat::Grey, "native greyscale format");
    assert_eq!((info.width, info.height), (w, h), "odd dims preserved");

    let stride = info.row_stride;
    let ref_luma = image::load_from_memory(&jpeg).unwrap().to_luma8();
    assert_eq!(ref_luma.dimensions(), (w as u32, h as u32));
    let map = tensor.map().unwrap();
    let buf: &[u8] = &map;
    let mut y_max: u32 = 0;
    let mut y_total: u64 = 0;
    for y in 0..h {
        for x in 0..w {
            let ours = buf[y * stride + x] as i32;
            let refv = ref_luma.get_pixel(x as u32, y as u32)[0] as i32;
            let d = (ours - refv).unsigned_abs();
            y_max = y_max.max(d);
            y_total += d as u64;
        }
    }
    let y_mae = y_total as f64 / (w * h) as f64;
    eprintln!("coco_grey_odd luma: MAE={y_mae:.3}, max={y_max}");
    assert!(y_max <= 4, "grey luma max diff {y_max} > 4");
    assert!(y_mae < 1.0, "grey luma MAE {y_mae:.3} > 1.0");
}