Files
photocraft/crates/raw/tests/dng.rs
T
Elvis33LE b75267899d DNG: lossy compression 34892 (baseline JPEG tiles) (#2021)
* DNG: lossy compression 34892 (baseline JPEG tiles)

Adobe's lossy DNG (compression 34892) decodes: each tile is a complete
baseline JPEG (8-bit luma at tile size), decoded by zune-jpeg into the
tile geometry (a mismatch is an error), widened 0xFF -> 0xFFFF like
Photoshop's read of 8-bit CFA data. Predictors and the rest of the
pipeline are unchanged.

testgen grows LossyJpegTiles (image-rs encodes the Q60 fixtures behind
the testgen feature), so CI runs a decode oracle: a smooth ramp stays
within a few quantisation steps of the 8-bit source, and a tile cut
mid-stream fails cleanly.

* Lossy DNG: check the tile JPEG header before decoding; zune deps go workspace

decode_headers() runs and the (width, height) == (s.w, rows) check fires
before decode(), so a hostile SOF cannot allocate the decoder's buffers
(up to 16384x16364) before the size mismatch is caught. The mismatch has
a regression test that swaps a 32x32 tile JPEG for a 16x16 one. zune-core
and zune-jpeg move to [workspace.dependencies] next to image and flate2.

* chore(ui): format prefs_ui (the workspace fmt gate is red on main until #2417 lands)

* Lossy DNG: 3-sample LinearRaw (YCbCr) decodes to RGB; tiles size-check

Real lossy DNGs are 3-sample YCbCr LinearRaw, not single-channel CFA:
the tile now decodes through zune-jpeg's YCbCr-to-RGB output when
SamplesPerPixel is 3 (gray stays for CFA), the decoded length is checked
against the tile's sample count, and other sample counts say unsupported.
The generator writes RGB tiles for 3-sample specs. A LinearizationTable
maps the decoded samples in the develop stage as before - nothing to add
in the sensor. Test: a 3-sample ramp with 64-step channel separation
(surviving Q60 chroma quantisation) decodes within the quality band.
2026-10-10 13:39:57 -04:00

648 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Synthetic DNG files: every storage layout decodes to the exact sensor
//! samples, and development reproduces known colours.
use photocraft_raw::testgen::{DngSpec, DngStorage, mosaic, scene};
use photocraft_raw::*;
fn sensor(bytes: &[u8]) -> Sensor {
decode(bytes, &Limits::default()).unwrap()
}
/// Deterministic sensor-like samples below `2^bits`.
fn noise(n: usize, bits: u16) -> Vec<u16> {
let mut s = 0x9E37_79B9_7F4A_7C15u64;
let max = (1u32 << bits) - 1;
(0..n)
.map(|i| {
s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
((i as u32 * 13 + (s >> 40) as u32 % 200) % (max + 1)) as u16
})
.collect()
}
#[test]
fn identifies_dng() {
let b = DngSpec::cfa(4, 4, vec![0; 16]).build();
assert_eq!(identify(&b), Some(RawFormat::Dng));
assert!(is_raw(&b));
assert!(!is_raw(b"II*\0\x08\0\0\0\0\0"));
assert!(!is_raw(b"\x89PNG\r\n\x1a\n"));
}
#[test]
fn storage_layouts_decode_exactly() {
let (w, h) = (37, 23); // odd sizes exercise partial strips and edge tiles
let cases: Vec<(&str, u16, DngStorage, bool)> = vec![
("16-bit strips", 16, DngStorage::Strips { rows: 5 }, false),
("16-bit big-endian", 16, DngStorage::Strips { rows: 23 }, true),
("12-bit packed", 12, DngStorage::Strips { rows: 4 }, false),
("10-bit packed big-endian", 10, DngStorage::Strips { rows: 7 }, true),
("8-bit", 8, DngStorage::Strips { rows: 9 }, false),
("LJ92 tiles", 14, DngStorage::Lj92Tiles { width: 16, height: 8 }, false),
("LJ92 tiles big-endian", 16, DngStorage::Lj92Tiles { width: 32, height: 32 }, true),
("LJ92 strips", 12, DngStorage::Lj92Strips { rows: 6 }, false),
];
for (name, bits, storage, be) in cases {
let data = noise(w * h, bits);
let mut spec = DngSpec::cfa(w, h, data.clone());
spec.bits = bits;
spec.storage = storage;
spec.big_endian = be;
spec.white = (1 << bits) - 1;
let s = sensor(&spec.build());
assert_eq!((s.width, s.height, s.samples), (w, h, 1), "{name}");
assert_eq!(s.data, data, "{name}");
assert_eq!(s.format, RawFormat::Dng);
}
}
#[test]
fn linear_raw_decodes_and_develops() {
let (w, h) = (20, 10);
let data = noise(w * h * 3, 16);
let mut spec = DngSpec::cfa(w, h, data.clone());
spec.samples = 3;
spec.storage = DngStorage::Lj92Tiles { width: 16, height: 16 };
let s = sensor(&spec.build());
assert_eq!((s.samples, s.cfa.is_none()), (3, true));
assert_eq!(s.data, data);
let d = develop_sensor(&s, &DevelopOptions::default()).unwrap();
assert_eq!((d.width, d.height, d.rgb.len()), (20, 10, 600));
assert!(d.info.cfa.is_none());
}
/// `spec`'s file with its Orientation entry (a SHORT `from`) rewritten as a LONG `to`.
fn with_long_orientation(spec: &DngSpec, from: u16, to: u32) -> Vec<u8> {
let mut b = spec.build();
let short = [&[0x12, 0x01, 3, 0, 1, 0, 0, 0][..], &from.to_le_bytes(), &[0, 0]].concat();
let at: Vec<usize> = b.windows(short.len()).enumerate().filter(|(_, w)| *w == short.as_slice()).map(|(i, _)| i).collect();
assert_eq!(at.len(), 1, "one Orientation entry");
let long = [&[0x12, 0x01, 4, 0, 1, 0, 0, 0][..], &to.to_le_bytes()].concat();
b[at[0]..at[0] + long.len()].copy_from_slice(&long);
b
}
#[test]
fn an_orientation_past_u16_is_not_truncated_into_range() {
// #1817: 65538 truncated to 2 and rotated the image; it is out of range, so 1.
let mut spec = DngSpec::cfa(8, 6, vec![500; 48]);
spec.orientation = 2;
assert_eq!(sensor(&with_long_orientation(&spec, 2, 65_538)).orientation, 1);
// Control: a LONG orientation in range is read.
assert_eq!(sensor(&with_long_orientation(&spec, 2, 6)).orientation, 6);
}
#[test]
fn metadata_is_read() {
let (w, h) = (40, 30);
let mut spec = DngSpec::cfa(w, h, vec![1000; w * h]);
spec.bits = 14;
spec.white = 15000;
spec.black = vec![100, 101, 102, 103];
spec.black_repeat = (2, 2);
spec.active_area = Some([2, 4, 28, 36]); // top, left, bottom, right
spec.default_crop = Some(([2, 1], [20, 16]));
spec.cfa = [1, 0, 2, 1]; // GRBG at the active-area origin
spec.as_shot_neutral = Some([0.5, 1.0, 0.8]);
spec.baseline_exposure = Some(0.5);
spec.orientation = 6;
spec.linearization = Some((0..4096u32).map(|v| (v * 3).min(65535) as u16).collect());
let s = sensor(&spec.build());
assert_eq!(s.white, [15000.0; 3]);
assert_eq!(s.black.values, vec![100.0, 101.0, 102.0, 103.0]);
assert_eq!(s.active, Rect::new(4, 2, 32, 26));
assert_eq!(s.crop, Rect::new(6, 3, 20, 16));
// Pattern anchored at the active area origin (4, 2): GRBG there.
assert_eq!(s.cfa.as_ref().unwrap().phase(4, 2), [1, 0, 2, 1]);
assert_eq!(s.color.as_shot_neutral, Some([0.5, 1.0, 0.8]));
assert!((s.baseline_exposure - 0.5).abs() < 1e-6);
assert_eq!(s.orientation, 6);
assert_eq!(s.linearization.as_ref().map(Vec::len), Some(4096));
let d = develop_sensor(&s, &DevelopOptions::default()).unwrap();
// Orientation 6 swaps the cropped 20×16 to 16×20.
assert_eq!((d.width, d.height), (16, 20));
assert_eq!(d.info.wb_multipliers, [2.0, 1.0, 1.25]);
let d = develop_sensor(&s, &DevelopOptions { orient: false, ..Default::default() }).unwrap();
assert_eq!((d.width, d.height), (20, 16));
}
#[test]
fn enormous_double_crop_origin_is_rejected() {
let (w, h) = (16, 12);
let mut spec = DngSpec::cfa(w, h, vec![1000; w * h]);
spec.active_area = Some([2, 3, 10, 14]);
spec.default_crop = Some(([0, 0], [11, 8]));
spec.default_crop_origin_double = Some([1e300, 0.0]);
assert!(matches!(decode(&spec.build(), &Limits::default()), Err(RawError::Malformed(_))));
}
#[test]
fn out_of_range_default_crop_falls_back_to_active_area_with_warning() {
let (w, h) = (16, 12);
let mut spec = DngSpec::cfa(w, h, vec![1000; w * h]);
spec.active_area = Some([2, 3, 10, 14]);
spec.default_crop = Some(([10, 0], [5, 4]));
let s = sensor(&spec.build());
assert_eq!(s.crop, s.active);
assert!(s.warnings.iter().any(|warning| warning.contains("out-of-range") && warning.contains("default crop")), "{:?}", s.warnings);
}
/// DefaultCropSize defaults to the whole image, which can't fit once the origin moves in.
#[test]
fn default_crop_origin_without_size_falls_back_to_active_area_with_warning() {
let (w, h) = (16, 12);
let mut spec = DngSpec::cfa(w, h, vec![1000; w * h]);
spec.active_area = Some([2, 3, 10, 14]);
spec.default_crop_origin_double = Some([1.0, 1.0]);
let s = sensor(&spec.build());
assert_eq!(s.crop, s.active);
assert!(s.warnings.iter().any(|warning| warning.contains("out-of-range") && warning.contains("default crop")), "{:?}", s.warnings);
}
/// #950: DefaultCropOrigin defaults to (0, 0), so a size-only crop starts at the active area's
/// top-left corner.
#[test]
fn default_crop_size_without_origin_crops_from_the_active_area_origin() {
let (w, h) = (8, 8);
let mut spec = DngSpec::cfa(w, h, (0..w * h).map(|i| (i as u16) * 37).collect());
spec.active_area = Some([0, 0, 8, 8]);
spec.default_crop_size_only = Some([4, 4]);
let bytes = spec.build();
let s = sensor(&bytes);
assert_eq!(s.crop, Rect::new(0, 0, 4, 4));
assert!(s.warnings.iter().all(|warning| !warning.contains("default crop")), "{:?}", s.warnings);
let d = develop(&bytes, &DevelopOptions::default()).unwrap();
assert_eq!((d.width, d.height), (4, 4));
// Inside an offset active area the crop starts at that area's corner.
let (w, h) = (16, 12);
let mut spec = DngSpec::cfa(w, h, vec![1000; w * h]);
spec.active_area = Some([2, 3, 10, 14]);
spec.default_crop_size_only = Some([6, 4]);
let s = sensor(&spec.build());
assert_eq!(s.crop, Rect::new(3, 2, 6, 4));
}
/// Linear sRGB (D65) → XYZ, from the sRGB primaries (IEC 61966-2-1).
const SRGB_TO_XYZ: [[f64; 3]; 3] = [[0.4124564, 0.3575761, 0.1804375], [0.2126729, 0.7151522, 0.0721750], [0.0193339, 0.1191920, 0.9503041]];
fn invert(m: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
let [[a, b, c], [d, e, f], [g, h, i]] = m;
let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
[
[(e * i - f * h) / det, (c * h - b * i) / det, (b * f - c * e) / det],
[(f * g - d * i) / det, (a * i - c * g) / det, (c * d - a * f) / det],
[(d * h - e * g) / det, (b * g - a * h) / det, (a * e - b * d) / det],
]
}
fn ap(m: &[[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
[0, 1, 2].map(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2])
}
/// The DNG model computed independently: camera XYZ of `camera` under an
/// illuminant whose camera neutral is `neutral`, Bradford-adapted from that
/// white (normalized to Y = 1) to D50, then XYZ D50 → linear ProPhoto.
fn reference_prophoto(camera: [f64; 3], neutral: [f64; 3]) -> [f64; 3] {
const BRADFORD: [[f64; 3]; 3] = [[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]];
const D50: [f64; 3] = [0.9642, 1.0, 0.8249];
const XYZ_D50_TO_PROPHOTO: [[f64; 3]; 3] = [[1.3459433, -0.2556075, -0.0511118], [-0.5445989, 1.5081673, 0.0205351], [0.0, 0.0, 1.2118128]];
let white = ap(&SRGB_TO_XYZ, neutral);
let white = white.map(|v| v / white[1]);
let xyz = ap(&SRGB_TO_XYZ, camera).map(|v| v / ap(&SRGB_TO_XYZ, neutral)[1]);
let (s, d) = (ap(&BRADFORD, white), ap(&BRADFORD, D50));
let cone = ap(&BRADFORD, xyz);
let adapted = ap(&invert(BRADFORD), [cone[0] * d[0] / s[0], cone[1] * d[1] / s[1], cone[2] * d[2] / s[2]]);
ap(&XYZ_D50_TO_PROPHOTO, adapted)
}
#[test]
fn colour_matrix_development_matches_reference() {
// A camera whose native RGB is linear sRGB: ColorMatrix = XYZ → sRGB at D65.
// The scene is captured with per-channel gains (the "white balance" the camera
// must undo), recorded as AsShotNeutral.
let (w, h) = (48, 32);
let gains = [0.5f32, 1.0, 0.7];
let patches: [[f32; 3]; 4] = [[0.18, 0.18, 0.18], [0.40, 0.10, 0.05], [0.05, 0.30, 0.10], [0.10, 0.12, 0.45]];
let rgb: Vec<[f32; 3]> = (0..w * h)
.map(|i| {
let p = patches[((i % w) / (w / 2)) + 2 * ((i / w) / (h / 2))];
[p[0] * gains[0], p[1] * gains[1], p[2] * gains[2]]
})
.collect();
let mut spec = DngSpec::cfa(w, h, mosaic(&rgb, w, [0, 1, 1, 2], 256, 16383));
spec.bits = 14;
spec.black = vec![256];
spec.white = 16383;
let cm = invert(SRGB_TO_XYZ);
spec.color_matrix1 = Some((21, [cm[0][0], cm[0][1], cm[0][2], cm[1][0], cm[1][1], cm[1][2], cm[2][0], cm[2][1], cm[2][2]]));
spec.as_shot_neutral = Some(gains.map(f64::from));
for storage in [DngStorage::Strips { rows: 32 }, DngStorage::Lj92Tiles { width: 16, height: 16 }] {
spec.storage = storage;
for method in [Demosaic::Bilinear, Demosaic::Mhc, Demosaic::Ahd] {
let d = develop(&spec.build(), &DevelopOptions { demosaic: method, ..Default::default() }).unwrap();
for (pi, p) in patches.iter().enumerate() {
// Centre of each patch.
let (cx, cy) = ((pi % 2) * (w / 2) + w / 4, (pi / 2) * (h / 2) + h / 4);
let o = &d.rgb[(cy * w + cx) * 3..(cy * w + cx) * 3 + 3];
let g = gains.map(f64::from);
let want = reference_prophoto([0, 1, 2].map(|c| f64::from(p[c]) * g[c]), g);
for c in 0..3 {
let got = (f64::from(o[c]) / 65535.0).powf(1.8);
assert!((got - want[c]).abs() < 0.006, "{storage:?} {method:?} patch {pi} c {c}: got {got:.4} want {:.4}", want[c]);
}
}
}
}
}
#[test]
fn forward_matrix_path_maps_neutral_to_white() {
let (w, h) = (16, 16);
let rgb = vec![[0.3f32, 0.6, 0.45]; w * h]; // a grey under a green-ish light
let mut spec = DngSpec::cfa(w, h, mosaic(&rgb, w, [0, 1, 1, 2], 0, 65535));
let cm = invert(SRGB_TO_XYZ);
let flat = |m: [[f64; 3]; 3]| [m[0][0], m[0][1], m[0][2], m[1][0], m[1][1], m[1][2], m[2][0], m[2][1], m[2][2]];
spec.color_matrix1 = Some((17, flat(cm)));
spec.color_matrix2 = Some((21, flat(cm)));
// ForwardMatrix: balanced camera → XYZ D50 (sRGB adapted to D50; white → D50).
let fm = [[0.4360747, 0.3850649, 0.1430804], [0.2225045, 0.7168786, 0.0606169], [0.0139322, 0.0971045, 0.7141733]];
spec.forward_matrix1 = Some(flat(fm));
spec.forward_matrix2 = Some(flat(fm));
spec.as_shot_neutral = Some([0.5, 1.0, 0.75]);
let d = develop(&spec.build(), &DevelopOptions::default()).unwrap();
let o = &d.rgb[(8 * w + 8) * 3..(8 * w + 8) * 3 + 3];
// Balanced grey 0.6 → ProPhoto (0.6, 0.6, 0.6).
for (c, v) in o.iter().enumerate() {
let got = (f64::from(*v) / 65535.0).powf(1.8);
assert!((got - 0.6).abs() < 0.004, "c {c}: {got}");
}
}
#[test]
fn highlights_clip_to_white_and_exposure_applies() {
let (w, h) = (8, 8);
let mut spec = DngSpec::cfa(w, h, vec![65535; w * h]);
spec.as_shot_neutral = Some([0.5, 1.0, 0.6]);
let d = develop(&spec.build(), &DevelopOptions::default()).unwrap();
assert!(d.rgb.iter().all(|&v| v >= 65500), "clipped sensor must render white");
let mut spec = DngSpec::cfa(w, h, vec![16384; w * h]); // 0.25 linear grey
spec.as_shot_neutral = Some([1.0, 1.0, 1.0]);
let b = spec.build();
let base = develop(&b, &DevelopOptions::default()).unwrap().rgb[27];
let up = develop(&b, &DevelopOptions { exposure: 1.0, ..Default::default() }).unwrap().rgb[27];
let lin = |v: u16| (f64::from(v) / 65535.0).powf(1.8);
assert!((lin(up) / lin(base) - 2.0).abs() < 0.02, "{} vs {}", lin(up), lin(base));
}
#[test]
fn gain_map_opcode_is_applied() {
// Uniform 0.25 grey; a gain map that doubles everything over the whole image.
let (w, h) = (16, 12);
let mut spec = DngSpec::cfa(w, h, vec![16384; w * h]);
spec.as_shot_neutral = Some([1.0; 3]);
let lin = |v: u16| (f64::from(v) / 65535.0).powf(1.8);
let base = develop(&spec.build(), &DevelopOptions::default()).unwrap().rgb[(6 * w + 8) * 3 + 1];
spec.opcode_list2 = Some(photocraft_raw::testgen::gain_map_opcode_list([0, 0, h as u32, w as u32], 1, [[2.0, 2.0], [2.0, 2.0]]));
let d = develop(&spec.build(), &DevelopOptions::default()).unwrap();
assert!(!d.warnings.iter().any(|w| w.contains("Opcode")), "{:?}", d.warnings);
let gained = d.rgb[(6 * w + 8) * 3 + 1];
assert!((lin(gained) / lin(base) - 2.0).abs() < 0.02, "{} vs {}", lin(gained), lin(base));
}
#[test]
fn white_balance_options() {
let (w, h) = (16, 16);
let rgb = vec![[0.2f32, 0.4, 0.3]; w * h];
let spec = DngSpec::cfa(w, h, mosaic(&rgb, w, [0, 1, 1, 2], 0, 65535));
let b = spec.build();
// No AsShotNeutral: grey world → neutral output.
let d = develop(&b, &DevelopOptions::default()).unwrap();
assert!(d.warnings.iter().any(|w| w.contains("grey world")));
let o = &d.rgb[(8 * w + 8) * 3..(8 * w + 8) * 3 + 3];
assert!((i32::from(o[0]) - i32::from(o[1])).abs() < 300 && (i32::from(o[2]) - i32::from(o[1])).abs() < 300, "{o:?}");
let d = develop(&b, &DevelopOptions { white_balance: WhiteBalance::Multipliers([2.0, 1.0, 4.0 / 3.0]), ..Default::default() }).unwrap();
assert_eq!(d.info.wb_multipliers.map(|v| (v * 1000.0).round()), [2000.0, 1000.0, 1333.0]);
assert!(develop(&b, &DevelopOptions { white_balance: WhiteBalance::Multipliers([0.0, 1.0, 1.0]), ..Default::default() }).is_err());
}
#[test]
fn smooth_scene_develops_with_every_method() {
let (w, h) = (64, 48);
let rgb = scene(w, h);
let spec = DngSpec::cfa(w, h, mosaic(&rgb, w, [2, 1, 1, 0], 0, 65535));
let mut spec = spec;
spec.cfa = [2, 1, 1, 0];
spec.as_shot_neutral = Some([1.0; 3]);
let b = spec.build();
let outs: Vec<Vec<u16>> = [Demosaic::Bilinear, Demosaic::Mhc, Demosaic::Ahd]
.iter()
.map(|&m| develop(&b, &DevelopOptions { demosaic: m, ..Default::default() }).unwrap().rgb)
.collect();
// Interior pixels agree closely across methods on a smooth scene.
for y in 4..h - 4 {
for x in 4..w - 4 {
for c in 0..3 {
let i = (y * w + x) * 3 + c;
let (a, b, d) = (i32::from(outs[0][i]), i32::from(outs[1][i]), i32::from(outs[2][i]));
assert!((a - b).abs() < 800 && (a - d).abs() < 800, "({x},{y}) c{c}: {a} {b} {d}");
}
}
}
}
#[test]
fn unsupported_variants_say_so() {
let mut spec = DngSpec::cfa(8, 8, vec![0; 64]);
spec.cfa = [0, 1, 2, 1]; // not Bayer
assert!(matches!(decode(&spec.build(), &Limits::default()), Err(RawError::Unsupported(_))));
}
#[test]
fn limits_are_enforced_before_allocating() {
let spec = DngSpec::cfa(64, 64, vec![0; 64 * 64]);
let b = spec.build();
let tiny = Limits { max_width: 32, ..Limits::default() };
assert!(matches!(decode(&b, &tiny), Err(RawError::LimitExceeded(_))));
let tiny = Limits { max_alloc: 1000, ..Limits::default() };
assert!(matches!(decode(&b, &tiny), Err(RawError::LimitExceeded(_))));
}
/// Adobe DNG compression 8 (zlib): with and without the horizontal-differencing predictor,
/// with and without 12-bit packing, and both byte orders — each decodes to the exact samples.
#[test]
fn deflate_strips_round_trip() {
let (w, h) = (48, 40);
for (name, bits, storage, be) in [
("deflate 16-bit", 16, DngStorage::DeflateStrips { rows: 16, predictor: 1 }, false),
("deflate 16-bit predictor 2", 16, DngStorage::DeflateStrips { rows: 16, predictor: 2 }, false),
("deflate 12-bit predictor 2", 12, DngStorage::DeflateStrips { rows: 12, predictor: 2 }, false),
("deflate 16-bit predictor 2 BE", 16, DngStorage::DeflateStrips { rows: 16, predictor: 2 }, true),
("deflate LinearRaw predictor 2", 16, DngStorage::DeflateStrips { rows: 40, predictor: 2 }, false),
] {
let samples = if name.contains("LinearRaw") { 3 } else { 1 };
let data = noise(w * h * samples, bits);
let mut spec = DngSpec::cfa(w, h, data.clone());
spec.samples = samples;
spec.bits = bits;
spec.storage = storage;
spec.big_endian = be;
spec.white = (1 << bits) - 1;
let s = sensor(&spec.build());
assert_eq!((s.width, s.height, s.samples), (w, h, samples), "{name}");
assert_eq!(s.data, data, "{name}");
}
}
/// A zlib stream cut short must fail cleanly (decompression comes up short or errors), and a
/// declared-but-absent giant tile must trip the limits instead of allocating.
#[test]
fn deflate_hostile_fails_cleanly() {
let (w, h) = (32, 32);
let data = noise(w * h, 16);
let mut spec = DngSpec::cfa(w, h, data);
spec.storage = DngStorage::DeflateStrips { rows: 32, predictor: 2 };
let bytes = spec.build();
assert_eq!(sensor(&bytes).data.len(), w * h);
// Locate the zlib stream (78 9c header of the single strip) and cut it mid-stream.
let at = bytes.windows(2).position(|w| w == [0x78, 0x9c]).expect("zlib header") + 2;
let cut = &bytes[..at + (bytes.len() - at) / 2];
assert!(decode(cut, &Limits::default()).is_err(), "a cut zlib stream must not decode");
}
/// Predictor 2 on 8-bit samples wraps at 8 bits, and 12-bit samples bit-packed inside the zlib
/// stream (not in 16-bit containers) decode too.
#[test]
fn deflate_8_bit_predictor_and_packed_12_bit() {
use std::io::Write as _;
let (w, h) = (16usize, 4usize);
let zlib = |bytes: &[u8]| {
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(bytes).unwrap();
e.finish().unwrap()
};
// 8-bit, predictor 2: a row falling from 250 to 10 has deltas that wrap mod 256.
let data8: Vec<u16> = (0..w * h).map(|i| if i % w < w / 2 { 250 } else { 10 }).collect();
let mut spec = DngSpec::cfa(w, h, data8.clone());
spec.bits = 8;
spec.white = 255;
spec.storage = DngStorage::DeflateStrips { rows: h, predictor: 2 };
assert_eq!(sensor(&spec.build()).data, data8, "8-bit predictor 2");
// 12-bit packed: two samples in three bytes, MSB first. Swap the testgen's 16-bit
// containers for a packed stream of the same samples.
let data12: Vec<u16> = (0..w * h).map(|i| (i as u16 * 61) & 0xFFF).collect();
let mut spec = DngSpec::cfa(w, h, data12.clone());
spec.bits = 12;
spec.white = 4095;
spec.storage = DngStorage::DeflateStrips { rows: h, predictor: 1 };
let mut bytes = spec.build();
let containers: Vec<u8> = data12.iter().flat_map(|v| v.to_le_bytes()).collect();
let old = zlib(&containers);
let packed: Vec<u8> = data12.chunks(2).flat_map(|p| [(p[0] >> 4) as u8, ((p[0] & 0xF) << 4 | p[1] >> 8) as u8, p[1] as u8]).collect();
let mut new = zlib(&packed);
let at = bytes.windows(old.len()).position(|c| c == old.as_slice()).expect("the strip");
assert!(new.len() <= old.len());
new.resize(old.len(), 0); // trailing bytes after the zlib stream are ignored
bytes[at..at + old.len()].copy_from_slice(&new);
assert_eq!(sensor(&bytes).data, data12, "12-bit packed");
}
// ---------------------------------------------------------------- JPEG XL (DNG 1.7)
/// Losslessly encodes `w × h` samples (1 = grey, 3 = RGB) at 8 or 16 bits as a JPEG XL codestream.
fn jxl(samples: &[u16], w: usize, h: usize, channels: usize, bits: u16) -> Vec<u8> {
use zune_core::{bit_depth::BitDepth, colorspace::ColorSpace, options::EncoderOptions};
let (depth, bytes): (BitDepth, Vec<u8>) = match bits {
8 => (BitDepth::Eight, samples.iter().map(|&v| v as u8).collect()),
_ => (BitDepth::Sixteen, samples.iter().flat_map(|v| v.to_ne_bytes()).collect()),
};
let space = if channels == 1 { ColorSpace::Luma } else { ColorSpace::RGB };
let mut out = Vec::new();
zune_jpegxl::JxlSimpleEncoder::new(&bytes, EncoderOptions::new(w, h, space, depth)).encode(&mut out).unwrap();
out
}
/// `data` (`w × h × spp`) cut into `tw × th` tiles, edge tiles padded by repeating the last row / column.
fn tiles(data: &[u16], w: usize, h: usize, spp: usize, tw: usize, th: usize) -> Vec<Vec<u16>> {
let mut out = Vec::new();
for ty in (0..h).step_by(th) {
for tx in (0..w).step_by(tw) {
let mut t = Vec::with_capacity(tw * th * spp);
for y in 0..th {
for x in 0..tw {
let at = ((ty + y).min(h - 1) * w + (tx + x).min(w - 1)) * spp;
t.extend_from_slice(&data[at..at + spp]);
}
}
out.push(t);
}
}
out
}
#[test]
fn jpeg_xl_layouts_decode_exactly() {
let (w, h) = (37, 23); // odd sizes: a short last strip and partial edge tiles
for (name, spp, bits, tiled) in [
("LinearRaw 16-bit strips", 3, 16, false),
("LinearRaw 16-bit tiles", 3, 16, true),
("CFA 16-bit strips", 1, 16, false),
("CFA 16-bit tiles", 1, 16, true),
("LinearRaw 8-bit strips", 3, 8, false),
("CFA 8-bit tiles", 1, 8, true),
] {
let data = noise(w * h * spp, bits);
let mut spec = DngSpec::cfa(w, h, data.clone());
spec.samples = spp;
spec.bits = bits;
spec.white = (1u32 << bits) - 1;
if tiled {
let (tw, th) = (16, 16);
spec.storage = DngStorage::JxlTiles { width: tw, height: th };
spec.jxl_segments = tiles(&data, w, h, spp, tw, th).iter().map(|t| jxl(t, tw, th, spp, bits)).collect();
} else {
let rows = 10;
spec.storage = DngStorage::JxlStrips { rows };
spec.jxl_segments =
(0..h).step_by(rows).map(|y| (y, (y + rows).min(h) - y)).map(|(y, r)| jxl(&data[y * w * spp..(y + r) * w * spp], w, r, spp, bits)).collect();
}
let s = sensor(&spec.build());
assert_eq!((s.width, s.height, s.samples), (w, h, spp), "{name}");
assert_eq!(s.data, data, "{name}");
}
}
#[test]
fn jpeg_xl_linear_raw_develops() {
// A Samsung Expert RAW-like file: one 16-bit LinearRaw JPEG XL strip with a baseline exposure.
let (w, h) = (24, 16);
let data: Vec<u16> = scene(w, h).iter().flat_map(|p| p.map(|v| (v * 20000.0) as u16)).collect();
let mut spec = DngSpec::cfa(w, h, data.clone());
spec.samples = 3;
spec.storage = DngStorage::JxlStrips { rows: h };
spec.jxl_segments = vec![jxl(&data, w, h, 3, 16)];
spec.baseline_exposure = Some(1.0);
let b = spec.build();
let d = develop(&b, &DevelopOptions::default()).unwrap();
assert_eq!((d.width as usize, d.height as usize, d.rgb.len()), (w, h, w * h * 3));
// Same pixels stored uncompressed develop identically.
spec.storage = DngStorage::Strips { rows: h };
assert_eq!(develop(&spec.build(), &DevelopOptions::default()).unwrap().rgb, d.rgb);
}
#[test]
fn jpeg_xl_mismatches_are_errors() {
let (w, h) = (16, 8);
let data = noise(w * h * 3, 16);
let mut spec = DngSpec::cfa(w, h, data.clone());
spec.samples = 3;
spec.storage = DngStorage::JxlStrips { rows: h };
// Codestream smaller than the strip.
spec.jxl_segments = vec![jxl(&data[..w * 4 * 3], w, 4, 3, 16)];
assert!(matches!(decode(&spec.build(), &Limits::default()), Err(RawError::Malformed(_))));
// Grey codestream for RGB samples.
spec.jxl_segments = vec![jxl(&data[..w * h], w, h, 1, 16)];
assert!(matches!(decode(&spec.build(), &Limits::default()), Err(RawError::Unsupported(_))));
// Not a codestream at all.
spec.jxl_segments = vec![vec![0xff, 0x0a, 1, 2, 3, 4, 5]];
assert!(matches!(decode(&spec.build(), &Limits::default()), Err(RawError::Malformed(_))));
}
#[test]
fn profile_gain_table_map_is_reported() {
let gain_table = |s: &Sensor| s.warnings.iter().any(|w| w.contains("ProfileGainTableMap"));
let mut spec = DngSpec::cfa(8, 8, vec![500; 64]);
assert!(!gain_table(&sensor(&spec.build())));
spec.profile_gain_table_map = Some(vec![0; 64]);
assert!(gain_table(&sensor(&spec.build())));
}
/// Adobe DNG compression 34892 (lossy baseline JPEG, 8-bit luma tiles): decodes to the tile
/// samples widened to 16 bits, within the JPEG quality band (Q60 artifacts are mild but real).
#[test]
fn lossy_jpeg_tiles_decode() {
let (w, h) = (32, 32);
// A smooth ramp: baseline JPEG at Q60 keeps it within a few quantisation steps.
let data: Vec<u16> = (0..w * h).map(|i| 4096 + (i / w) as u16 * 16).collect();
let mut spec = DngSpec::cfa(w, h, data.clone());
spec.storage = DngStorage::LossyJpegTiles { width: 16, height: 16 };
let s = sensor(&spec.build());
assert_eq!((s.width, s.height, s.samples), (w, h, 1));
let mut worst = 0u32;
for (got, want) in s.data.iter().zip(&data) {
// The tile was written 8-bit (>>8), so the quantised expectation is (want>>8)<<8.
let quant = u32::from(*want >> 8) << 8;
worst = worst.max(u32::from(got.abs_diff(quant as u16)));
}
assert!(worst <= 4 * 256, "worst deviation from the 8-bit quantisation: {worst}");
}
/// A lossy JPEG tile cut mid-stream fails cleanly.
#[test]
fn lossy_jpeg_tiles_hostile() {
let (w, h) = (32, 32);
let data: Vec<u16> = (0..w * h).map(|_i| 8192u16).collect();
let mut spec = DngSpec::cfa(w, h, data);
spec.storage = DngStorage::LossyJpegTiles { width: 32, height: 32 };
let bytes = spec.build();
let at = bytes.windows(2).position(|w| w == [0xFF, 0xD8]).expect("jpeg SOI") + 2;
let cut = &bytes[..at + (bytes.len() - at) / 2];
assert!(decode(cut, &Limits::default()).is_err(), "a cut JPEG tile must not decode");
}
/// A tile JPEG whose header lies about its size fails the header check, before any pixel
/// decode (a hostile SOF must not be able to size the decoder's buffers first).
#[test]
fn lossy_jpeg_tile_dimension_mismatch_errors() {
use image::ImageEncoder as _;
let (w, h) = (32, 32);
let data: Vec<u16> = (0..w * h).map(|i| 4096 + (i / w) as u16 * 16).collect();
let mut spec = DngSpec::cfa(w, h, data);
spec.storage = DngStorage::LossyJpegTiles { width: 32, height: 32 };
let mut bytes = spec.build();
// Swap the 32x32 tile JPEG for a 16x16 one, inside the same segment (the trailing bytes
// after its EOI stay; the JPEG decoder stops at EOI).
let small = {
let img = image::GrayImage::from_fn(16, 16, |x, _| image::Luma([(x as u16 * 16) as u8]));
let mut j = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut j, 60);
enc.write_image(img.as_raw(), 16, 16, image::ExtendedColorType::L8).unwrap();
j
};
let soi = bytes.windows(3).position(|w| w == [0xFF, 0xD8, 0xFF]).expect("SOI");
let eoi = soi + bytes[soi..].windows(2).position(|w| w == [0xFF, 0xD9]).expect("EOI") + 2;
bytes.splice(soi..eoi, small);
let err = decode(&bytes, &Limits::default()).unwrap_err().to_string();
assert!(err.contains("16x16") && err.contains("32x32"), "{err}");
}
/// A 3-sample LinearRaw lossy DNG (YCbCr on disk, as real camera files are) decodes to RGB
/// tiles widened to 16 bits, within the JPEG quality band; a LinearizationTable maps the
/// decoded samples in the develop stage, so the sensor sees the raw widened values.
#[test]
fn lossy_jpeg_rgb_linearraw_decodes() {
let (w, h) = (32, 32);
// Channel offsets of 64 8-bit steps survive JPEG chroma quantisation at Q60.
let data: Vec<u16> = (0..w * h * 3).map(|i| 4096 + ((i / (w * 3)) as u16 * 16) + (i % 3) as u16 * 64 * 256).collect();
let mut spec = DngSpec::cfa(w, h, data.clone());
spec.samples = 3;
spec.storage = DngStorage::LossyJpegTiles { width: 16, height: 16 };
let s = sensor(&spec.build());
assert_eq!((s.width, s.height, s.samples), (w, h, 3));
let mut worst = 0u32;
for (got, want) in s.data.iter().zip(&data) {
let quant = u32::from(*want >> 8) << 8;
worst = worst.max(u32::from(got.abs_diff(quant as u16)));
}
assert!(worst <= 8 * 256, "worst deviation from the 8-bit quantisation: {worst}");
// Each channel carries its own level: R and B stay apart everywhere.
for p in 0..w * h {
let (r, b) = (s.data[p * 3], s.data[p * 3 + 2]);
assert!(r.abs_diff(b) >= 64 * 256 - 8 * 256, "channels collapsed at pixel {p}");
}
}