lightcraft/crates/raw/tests/dng.rs

1188 lines
56 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 round trips: every storage variant we decode is written with our own TIFF writer (and LJ92
//! encoder) and must decode bit-exactly.
use lightcraft_color::{D65, Mat3, Xy};
use lightcraft_raw::opcodes::{Area, Opcode};
use lightcraft_raw::{
BlackLevel, Cfa, ColorData, DngCompression, DngWriteOptions, Method, OpcodeLists, Orientation, RawData, RawFormat, RawImage, Rect, decode,
embedded_preview, probe, write_dng,
};
use lightcraft_tiff::tags::{self as t, compression, photometric};
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value};
fn scene_value(x: usize, y: usize, c: u8, max: f32) -> f32 {
let base = [0.55, 0.8, 0.4][c as usize];
let v = base * (0.2 + 0.7 * ((x as f32 * 0.13).sin() * 0.5 + 0.5) * ((y as f32 * 0.07).cos() * 0.3 + 0.7));
v * max
}
fn synthetic(w: usize, h: usize, cfa: Option<Cfa>, cpp: usize) -> RawImage {
let black = 256.0;
let white = 16000.0;
let mut data = Vec::with_capacity(w * h * cpp);
for y in 0..h {
for x in 0..w {
for s in 0..cpp {
let c = match &cfa {
Some(p) => p.color_at(x, y),
None => s as u8,
};
data.push((black + scene_value(x, y, c, white - black)).round() as u16);
}
}
}
let color = ColorData {
illuminant: [17, 21],
color_matrix: [
Some(Mat3([[0.9, 0.2, -0.15], [-0.3, 1.25, 0.08], [0.02, -0.12, 0.85]])),
Some(Mat3([[0.7, 0.3, -0.1], [-0.35, 1.3, 0.1], [0.05, -0.2, 1.0]])),
],
as_shot_neutral: Some([0.5, 1.0, 0.7]),
baseline_exposure: 0.35,
baseline_sharpness: Some(1.5),
..Default::default()
};
let metadata = lightcraft_meta::Metadata { make: Some("Synth".into()), model: Some("Cam 1".into()), rating: Some(3), ..Default::default() };
RawImage {
format: RawFormat::Dng,
width: w,
height: h,
cpp,
data: RawData::U16(data),
cfa,
bits: 14,
black: BlackLevel::uniform(black),
white: vec![white],
active_area: Rect::new(2, 1, w - 4, h - 2),
crop: Rect::new(3, 2, w - 12, h - 8),
orientation: Orientation::Rotate90,
color,
wb_multipliers: None,
linearized: false,
opcodes: OpcodeLists::default(),
metadata,
}
}
fn assert_same(a: &RawImage, b: &RawImage) {
assert_eq!((a.width, a.height, a.cpp), (b.width, b.height, b.cpp));
assert_eq!(a.data, b.data);
assert_eq!(a.cfa, b.cfa);
assert_eq!(a.active_area, b.active_area);
assert_eq!(a.crop, b.crop);
assert_eq!(a.orientation, b.orientation);
assert!((0..a.cpp).all(|s| a.white_at(s) == b.white_at(s)));
assert_eq!(a.black.values, b.black.values.iter().take(a.black.values.len()).copied().collect::<Vec<_>>());
for i in 0..2 {
let (x, y) = (a.color.color_matrix[i].unwrap(), b.color.color_matrix[i].unwrap());
for r in 0..3 {
for c in 0..3 {
assert!((x.0[r][c] - y.0[r][c]).abs() < 1e-6);
}
}
}
assert_eq!(a.color.illuminant, b.color.illuminant);
let (n1, n2) = (a.color.as_shot_neutral.unwrap(), b.color.as_shot_neutral.unwrap());
assert!((0..3).all(|i| (n1[i] - n2[i]).abs() < 1e-6));
assert!((a.color.baseline_exposure - b.color.baseline_exposure).abs() < 1e-6);
assert!(b.color.baseline_sharpness.is_some_and(|v| (v - 1.5).abs() < 1e-6), "{:?}", b.color.baseline_sharpness);
assert_eq!(b.metadata.make.as_deref(), Some("Synth"));
assert_eq!(b.metadata.rating, Some(3));
}
#[test]
fn writer_roundtrip_all_layouts() {
for (w, h) in [(64, 48), (70, 45), (33, 17)] {
for cfa in [Some(Cfa::bayer("RGGB").unwrap()), Some(Cfa::bayer("GBRG").unwrap()), Some(Cfa::xtrans()), None] {
let cpp = if cfa.is_some() { 1 } else { 3 };
let raw = synthetic(w, h, cfa, cpp);
for comp in [DngCompression::Uncompressed, DngCompression::Lj92 { tile: 16 }, DngCompression::Lj92 { tile: 256 }] {
for order in [ByteOrder::Little, ByteOrder::Big] {
let bytes = write_dng(&raw, &DngWriteOptions { compression: comp, order, xmp: None }).unwrap();
assert_eq!(probe(&bytes), Some(RawFormat::Dng));
let back = decode(&bytes).unwrap();
assert_same(&raw, &back);
// the header-only probe describes the same image
assert_eq!(lightcraft_raw::probe_info(&bytes).unwrap(), back.info());
}
}
}
}
}
#[test]
fn deflate_roundtrips_integer_and_float() {
for (w, h) in [(64, 48), (70, 45)] {
for cfa in [Some(Cfa::bayer("RGGB").unwrap()), None] {
let cpp = if cfa.is_some() { 1 } else { 3 };
let raw = synthetic(w, h, cfa, cpp);
for order in [ByteOrder::Little, ByteOrder::Big] {
let bytes =
write_dng(&raw, &DngWriteOptions { compression: DngCompression::Deflate { tile: 32, half: false }, order, xmp: None }).unwrap();
assert_same(&raw, &decode(&bytes).unwrap());
}
}
}
// linear float data: 32-bit exact, 16-bit within half precision
let mut raw = synthetic(70, 45, None, 3);
let vals: Vec<f32> = (0..70 * 45 * 3).map(|i| ((i % 97) as f32 / 97.0).powi(3) * 0.9 + 1e-4).collect();
raw.data = RawData::F32(vals.clone());
raw.black = BlackLevel::uniform(0.0);
raw.white = vec![1.0];
for half in [false, true] {
let bytes = write_dng(&raw, &DngWriteOptions { compression: DngCompression::Deflate { tile: 32, half }, ..Default::default() }).unwrap();
let back = decode(&bytes).unwrap();
let RawData::F32(b) = &back.data else { panic!("float data expected") };
for (a, b) in vals.iter().zip(b) {
let tol = if half { a * 1e-3 + 1e-7 } else { 0.0 };
assert!((a - b).abs() <= tol, "{a} vs {b} (half: {half})");
}
}
assert_eq!(lightcraft_raw::dngwrite::f32_to_f16(1.0), 0x3c00);
assert_eq!(lightcraft_raw::dngwrite::f32_to_f16(-2.0), 0xc000);
assert_eq!(lightcraft_raw::dngwrite::f32_to_f16(1e9), 0x7c00);
assert_eq!(lightcraft_raw::dngwrite::f32_to_f16(2f32.powi(-24)), 0x0001);
}
#[test]
fn normalized_and_develop() {
let mut raw = synthetic(40, 30, Some(Cfa::bayer("RGGB").unwrap()), 1);
let n = raw.normalized().unwrap();
assert_eq!((n.width, n.height), (36, 28));
// the CFA is re-anchored at the active area origin (2, 1): RGGB shifted by (2,1) → GBRG
assert_eq!(n.cfa.as_ref().unwrap().name(), "GBRG");
let RawData::U16(d) = &raw.data else { unreachable!() };
let expect = (d[40 + 2] as f32 - 256.0) / (16000.0 - 256.0);
assert!((n.data[0] - expect).abs() < 1e-6);
let rgb = raw.develop(Method::Ahd).unwrap();
assert_eq!((rgb.width, rgb.height), (28, 22));
// constant scene → constant output, exactly 0.5 after normalisation
if let RawData::U16(d) = &mut raw.data {
d.iter_mut().for_each(|v| *v = 256 + (16000 - 256) / 2);
}
for m in [Method::Bilinear, Method::Ppg, Method::Ahd] {
let rgb = raw.develop(m).unwrap();
assert!(rgb.data.iter().all(|p| p.iter().all(|v| (v - 0.5).abs() < 1e-4)), "{m:?}");
}
}
/// Linear DNG sample planes have independent black/white endpoints, even without a CFA.
#[test]
fn normalization_uses_each_linear_sample_planes_black_level() {
let (w, h) = (8usize, 6usize);
let mut ifd = base_ifd(w, h, 16, 3, false);
ifd.set(t::BLACK_LEVEL, Value::Rational(vec![(64, 1), (256, 1), (512, 1)]));
ifd.set(t::WHITE_LEVEL, Value::Long(vec![1024, 2048, 4096]));
let samples: Vec<u8> = (0..w * h).flat_map(|_| [1024u16, 2048, 4096].into_iter().flat_map(u16::to_le_bytes)).collect();
ifd.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![samples] });
let raw = decode(&dng(ifd, ByteOrder::Little)).unwrap();
let n = raw.normalized().unwrap();
for (i, &v) in n.data.iter().enumerate() {
assert!((v - 1.0).abs() < 1e-6, "white in plane {} mapped to {v}", i % 3);
}
}
/// The maximum computed black is per sample plane, including row/column deltas and their phases.
#[test]
fn normalization_uses_maximum_black_in_the_active_area() {
let mut raw = synthetic(12, 10, Cfa::bayer("RGGB"), 1);
raw.white = vec![1024.0];
raw.black = BlackLevel {
repeat_rows: 2,
repeat_cols: 2,
values: vec![64.0, 128.0, 192.0, 256.0],
delta_h: vec![100.0, -10.0, 0.0, -20.0, 0.0, -10.0, 0.0, -20.0, 900.0],
delta_v: vec![80.0, -5.0, 0.0, -10.0, 0.0, -5.0, 0.0, -10.0, 900.0],
};
// Only the first 8 columns/rows of the deltas belong to the active area. The maximum is
// 192 + 100 - 5 = 287, not max(base) + max(horizontal) + max(vertical) = 436.
assert_eq!((raw.active_area.width, raw.active_area.height), (8, 8));
let max_black = 287.0;
let (a, w) = (raw.active_area, raw.width);
let mut data = vec![0.0; w * raw.height];
for y in 0..a.height {
for x in 0..a.width {
data[(a.y + y) * w + a.x + x] = raw.black.at(x, y, 0, 1) + (1024.0 - max_black) * 0.5;
}
}
// Preserve negative shadow noise and linear headroom, as the existing Normalized API promises.
data[a.y * w + a.x] = raw.black.at(0, 0, 0, 1) - 4.0;
data[(a.y + 7) * w + a.x + 7] = raw.black.at(7, 7, 0, 1) + (1024.0 - max_black) * 1.25;
raw.data = RawData::F32(data);
let n = raw.normalized().unwrap();
assert!((n.data[0] + 4.0 / 737.0).abs() < 1e-6);
for &v in &n.data[1..63] {
assert!((v - 0.5).abs() < 1e-6, "mid-grey mapped to {v}");
}
assert!((n.data[63] - 1.25).abs() < 1e-6);
}
#[test]
fn normalization_and_binning_share_dng_scaling_and_clipping() {
let mut raw = synthetic(20, 16, Cfa::bayer("RGGB"), 1);
raw.white = vec![1024.0];
raw.black = BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![64.0, 128.0, 192.0, 256.0], ..Default::default() };
let (a, w) = (raw.active_area, raw.width);
let mut data = vec![0.0; w * raw.height];
for y in 0..a.height {
for x in 0..a.width {
data[(a.y + y) * w + a.x + x] = raw.black.at(x, y, 0, 1) + 768.0 * 0.995;
}
}
raw.data = RawData::F32(data);
let direct = raw.develop_binned_masked(2, 0.99).unwrap().unwrap();
// An identity opcode forces the binner through RawImage::normalized instead of its fast path.
raw.opcodes.list2.push(Opcode::MapPolynomial {
area: Area { top: 0, left: 0, bottom: a.height as u32, right: a.width as u32, plane: 0, planes: 1, row_pitch: 1, col_pitch: 1 },
coefficients: vec![0.0, 1.0],
});
let via_normalized = raw.develop_binned_masked(2, 0.99).unwrap().unwrap();
assert_eq!(direct, via_normalized);
for p in &direct.0.data {
assert!(p.iter().all(|v| (*v - 0.995).abs() < 1e-6), "{p:?}");
}
assert!(direct.1.iter().all(|&m| m == 7), "all three channels must be marked clipped");
let full = raw.develop(Method::Bilinear).unwrap();
assert!(full.data.iter().all(|p| p.iter().all(|v| (*v - 0.995).abs() < 1e-6)));
}
proptest::proptest! {
#[test]
fn normalization_matches_a_brute_force_black_maximum(
aw in 1usize..10, ah in 1usize..10,
br in 1usize..5, bc in 1usize..5, cpp in 1usize..4,
dh in proptest::collection::vec(-50i16..50, 0..12),
dv in proptest::collection::vec(-50i16..50, 0..12),
) {
let mut raw = synthetic(20, 16, None, cpp);
raw.active_area = Rect::new(2, 1, aw, ah);
raw.black = BlackLevel {
repeat_rows: br,
repeat_cols: bc,
values: (0..br * bc * cpp).map(|i| 100.0 + (i % 7) as f32 * 16.0).collect(),
delta_h: dh.iter().map(|&v| v as f32).collect(),
delta_v: dv.iter().map(|&v| v as f32).collect(),
};
raw.white = (0..cpp).map(|s| 2048.0 + s as f32 * 256.0).collect();
let maximum: Vec<f32> = (0..cpp).map(|s| {
let mut max = f32::NEG_INFINITY;
for y in 0..ah {
for x in 0..aw {
max = max.max(raw.black.at(x, y, s, cpp));
}
}
max
}).collect();
let mut data = vec![0.0; raw.width * raw.height * cpp];
for y in 0..ah {
for x in 0..aw {
for s in 0..cpp {
data[((y + 1) * raw.width + x + 2) * cpp + s] =
raw.black.at(x, y, s, cpp) + 0.25 * (raw.white_at(s) - maximum[s]);
}
}
}
raw.data = RawData::F32(data);
let n = raw.normalized().unwrap();
proptest::prop_assert!(n.data.iter().all(|v| (*v - 0.25).abs() < 1e-6));
}
}
#[test]
fn non_dng_normalization_keeps_the_existing_vendor_scale() {
let mut raw = synthetic(20, 16, Cfa::bayer("RGGB"), 1);
raw.format = RawFormat::Cr2;
raw.black = BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![64.0, 128.0, 192.0, 256.0], ..Default::default() };
let RawData::U16(d) = &raw.data else { unreachable!() };
let n = raw.normalized().unwrap();
for y in 0..n.height {
for x in 0..n.width {
let v = d[(y + raw.active_area.y) * raw.width + x + raw.active_area.x] as f32;
let expected = (v - raw.black.at(x, y, 0, 1)) / (raw.white_at(0) - 160.0);
assert!((n.data[y * n.width + x] - expected).abs() < 1e-6);
}
}
}
// ---------------------------------------------------------------- hand-built variants
fn base_ifd(w: usize, h: usize, bits: u16, cpp: usize, cfa: bool) -> IfdBuilder {
let mut ifd = IfdBuilder::new();
ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w as u32]));
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h as u32]));
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits; cpp]));
ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![cpp as u16]));
ifd.set(t::PHOTOMETRIC, Value::Short(vec![if cfa { photometric::CFA } else { photometric::LINEAR_RAW }]));
if cfa {
ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2]));
ifd.set(t::CFA_PATTERN_EP, Value::Byte(vec![1, 0, 2, 1]));
}
ifd
}
fn dng(raw: IfdBuilder, order: ByteOrder) -> Vec<u8> {
dng_with(raw, order, |_| {})
}
/// [`dng`] with extra IFD 0 tags (where DNG writers put camera-profile tags).
fn dng_with(raw: IfdBuilder, order: ByteOrder, extra: impl FnOnce(&mut IfdBuilder)) -> Vec<u8> {
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1]));
ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 6, 0, 0]));
ifd0.set(t::MAKE, Value::Ascii("Hand".into()));
ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)]));
ifd0.set(t::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21]));
ifd0.set(t::AS_SHOT_WHITE_XY, Value::Rational(vec![(3127, 10000), (3290, 10000)]));
// an 8-bit RGB thumbnail in IFD0 (must not be mistaken for the raw)
ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![4]));
ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![2]));
ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8]));
ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
ifd0.set(t::PHOTOMETRIC, Value::Short(vec![photometric::RGB]));
ifd0.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![vec![128; 24]] });
let mut exif = IfdBuilder::new();
exif.set(t::ISO_SPEED, Value::Short(vec![200]));
ifd0.set_child(t::EXIF_IFD, exif);
ifd0.add_sub_ifd(raw);
extra(&mut ifd0);
TiffWriter::new(order, false).write(&[ifd0]).unwrap()
}
fn pattern(w: usize, h: usize, cpp: usize, bits: u32) -> Vec<u16> {
let max = (1u32 << bits) - 1;
(0..w * h * cpp).map(|i| ((i as u32).wrapping_mul(2654435761u32) >> 7) % (max + 1)).map(|v| v as u16).collect()
}
fn pack_msb(v: &[u16], bits: u32, w: usize) -> Vec<u8> {
let mut out = Vec::new();
for row in v.chunks(w) {
let mut acc = 0u64;
let mut n = 0;
for &s in row {
acc = (acc << bits) | s as u64;
n += bits;
while n >= 8 {
out.push((acc >> (n - 8)) as u8);
n -= 8;
}
}
if n > 0 {
out.push((acc << (8 - n)) as u8);
}
}
out
}
fn u16_bytes(v: &[u16], order: ByteOrder) -> Vec<u8> {
let mut b = Vec::new();
v.iter().for_each(|&x| order.put_u16(&mut b, x));
b
}
#[test]
fn packed_bit_depths() {
let (w, h) = (13, 9);
for bits in [10u32, 12, 14] {
let px = pattern(w, h, 1, bits);
let mut raw = base_ifd(w, h, bits as u16, 1, true);
raw.set(t::COMPRESSION, Value::Short(vec![1]));
let strips: Vec<Vec<u8>> = px.chunks(w * 4).map(|c| pack_msb(c, bits, w)).collect();
raw.set_image(ImageData::Strips { rows_per_strip: 4, strips });
let img = decode(&dng(raw, ByteOrder::Big)).unwrap();
assert_eq!(img.data, RawData::U16(px), "{bits} bits");
assert_eq!(img.white, vec![((1u32 << bits) - 1) as f32]);
assert_eq!(img.cfa.unwrap().name(), "GRBG");
assert_eq!(img.metadata.iso, Some(200));
assert_eq!(img.color.as_shot_white_xy, Some(Xy::new(0.3127, 0.329)));
}
}
#[test]
fn deflate_with_integer_predictors() {
let (w, h, cpp) = (10, 6, 3);
let px = pattern(w, h, cpp, 16);
for (pred, factor) in [(1u16, 0usize), (2, 1), (34892, 2), (34893, 4)] {
for order in [ByteOrder::Little, ByteOrder::Big] {
let mut raw = base_ifd(w, h, 16, cpp, false);
raw.set(t::COMPRESSION, Value::Short(vec![compression::ADOBE_DEFLATE]));
raw.set(t::PREDICTOR, Value::Short(vec![pred]));
raw.set_image(ImageData::Tiles {
tile_width: 16,
tile_height: 16,
tiles: vec![{
// one 16×16 tile: re-lay the 10×6 image into the tile grid
let mut tile = vec![0u16; 16 * 16 * cpp];
for y in 0..h {
for x in 0..w * cpp {
tile[y * 16 * cpp + x] = px[y * w * cpp + x];
}
}
let mut te = tile.clone();
if factor > 0 {
for row in te.chunks_mut(16 * cpp) {
for i in (cpp * factor..row.len()).rev() {
row[i] = row[i].wrapping_sub(row[i - cpp * factor]);
}
}
}
miniz_oxide::deflate::compress_to_vec_zlib(&u16_bytes(&te, order), 6)
}],
});
let img = decode(&dng(raw, order)).unwrap();
assert_eq!(img.data, RawData::U16(px.clone()), "predictor {pred} {order:?}");
assert!(img.cfa.is_none());
assert_eq!(img.cpp, 3);
}
}
}
fn fp_encode(vals: &[f32], bytes_per: usize, stride: usize, w_samples: usize) -> Vec<u8> {
// big-endian byte planes per row, then byte differencing
let mut out = Vec::new();
for row in vals.chunks(w_samples) {
let be: Vec<[u8; 4]> = row
.iter()
.map(|&v| match bytes_per {
4 => v.to_be_bytes(),
2 => {
let h = f32_to_f16(v).to_be_bytes();
[h[0], h[1], 0, 0]
}
_ => {
let b = f32_to_f24(v).to_be_bytes();
[b[1], b[2], b[3], 0]
}
})
.collect();
let mut planes = vec![0u8; row.len() * bytes_per];
for (i, b) in be.iter().enumerate() {
for k in 0..bytes_per {
planes[k * row.len() + i] = b[k];
}
}
for i in (stride..planes.len()).rev() {
planes[i] = planes[i].wrapping_sub(planes[i - stride]);
}
out.extend(planes);
}
out
}
fn f32_to_f16(v: f32) -> u16 {
// exact for the test values (multiples of 1/64 in [0, 4))
let b = v.to_bits();
let sign = ((b >> 16) & 0x8000) as u16;
if v == 0.0 {
return sign;
}
let exp = ((b >> 23) & 0xff) as i32 - 127 + 15;
let man = ((b >> 13) & 0x3ff) as u16;
sign | ((exp as u16) << 10) | man
}
fn f32_to_f24(v: f32) -> u32 {
let b = v.to_bits();
if v == 0.0 {
return 0;
}
let sign = (b >> 31) << 23;
let exp = (((b >> 23) & 0xff) as i32 - 127 + 63) as u32;
sign | (exp << 16) | ((b >> 7) & 0xffff)
}
#[test]
fn floating_point_dngs() {
let (w, h) = (12, 5);
let vals: Vec<f32> = (0..w * h).map(|i| ((i * 37) % 200) as f32 / 64.0).collect();
for (bits, pred, stride_factor) in [(32u16, 3u16, 1usize), (32, 34894, 2), (32, 34895, 4), (16, 3, 1), (24, 3, 1), (24, 34894, 2)] {
let bp = bits as usize / 8;
let mut raw = base_ifd(w, h, bits, 1, true);
raw.set(t::SAMPLE_FORMAT, Value::Short(vec![3]));
raw.set(t::COMPRESSION, Value::Short(vec![compression::DEFLATE]));
raw.set(t::PREDICTOR, Value::Short(vec![pred]));
let z = miniz_oxide::deflate::compress_to_vec_zlib(&fp_encode(&vals, bp, stride_factor, w), 6);
raw.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![z] });
let img = decode(&dng(raw, ByteOrder::Little)).unwrap();
assert_eq!(img.data, RawData::F32(vals.clone()), "{bits}-bit predictor {pred}");
assert_eq!(img.white, vec![1.0]);
}
// uncompressed half floats, big-endian file
let mut raw = base_ifd(w, h, 16, 1, true);
raw.set(t::SAMPLE_FORMAT, Value::Short(vec![3]));
raw.set(t::COMPRESSION, Value::Short(vec![1]));
let bytes: Vec<u8> = vals.iter().flat_map(|&v| f32_to_f16(v).to_be_bytes()).collect();
raw.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![bytes] });
assert_eq!(decode(&dng(raw, ByteOrder::Big)).unwrap().data, RawData::F32(vals));
}
#[test]
fn non_finite_float_samples_decode_and_develop() {
// regression: NaN samples in a float CFA DNG panicked AHD and PPG (`f32::clamp` with NaN bounds)
let (w, h) = (24, 20);
let mut raw = synthetic(w, h, Some(Cfa::bayer("RGGB").unwrap()), 1);
let mut vals: Vec<f32> = (0..w * h).map(|i| ((i * 37) % 200) as f32 / 256.0).collect();
for (i, v) in vals.iter_mut().enumerate() {
let (x, y) = (i % w, i / w);
if (8..14).contains(&x) && (6..12).contains(&y) {
*v = f32::NAN;
} else if i % 11 == 0 {
*v = if i % 2 == 0 { f32::INFINITY } else { f32::NEG_INFINITY };
}
}
raw.data = RawData::F32(vals.clone());
raw.black = BlackLevel::uniform(0.0);
raw.white = vec![0.75];
for compression in [DngCompression::Uncompressed, DngCompression::Deflate { tile: 16, half: false }] {
let bytes = write_dng(&raw, &DngWriteOptions { compression, ..Default::default() }).unwrap();
let img = decode(&bytes).unwrap();
// NaN and −∞ read as black, +∞ as the white level; finite samples are kept bit-exactly
let RawData::F32(got) = &img.data else { panic!("float data expected") };
for (a, b) in vals.iter().zip(got) {
let want = match *a {
f32::INFINITY => 0.75,
v if v.is_finite() => v,
_ => 0.0,
};
assert_eq!(want.to_bits(), b.to_bits(), "{a} read as {b}");
}
for m in [Method::Bilinear, Method::Ppg, Method::Ahd] {
let rgb = img.develop(m).unwrap();
assert!(rgb.data.iter().all(|p| p.iter().all(|v| v.is_finite())), "{m:?}");
}
}
}
#[test]
fn lj92_strips_and_levels_and_linearization() {
let (w, h) = (20, 12);
let px = pattern(w, h, 1, 12);
let mut raw = base_ifd(w, h, 12, 1, true);
raw.set(t::COMPRESSION, Value::Short(vec![compression::JPEG]));
let strips: Vec<Vec<u8>> = px.chunks(w * 5).map(|c| lightcraft_raw::ljpeg::encode(c, w, c.len() / w, 1, 12, 6, 0)).collect();
raw.set_image(ImageData::Strips { rows_per_strip: 5, strips });
let table: Vec<u16> = (0..4096u32).map(|i| (i * i / 4096) as u16).collect();
raw.set(t::LINEARIZATION_TABLE, Value::Short(table.clone()));
raw.set(t::BLACK_LEVEL_REPEAT_DIM, Value::Short(vec![2, 2]));
raw.set(t::BLACK_LEVEL, Value::Rational(vec![(10, 1), (11, 1), (12, 1), (13, 1)]));
raw.set(t::BLACK_LEVEL_DELTA_H, Value::SRational(vec![(1, 2); 16]));
raw.set(t::BLACK_LEVEL_DELTA_V, Value::SRational(vec![(-1, 1); 10]));
raw.set(t::WHITE_LEVEL, Value::Short(vec![4000]));
raw.set(t::ACTIVE_AREA, Value::Long(vec![1, 2, 11, 18]));
raw.set(t::DEFAULT_CROP_ORIGIN, Value::Rational(vec![(1, 1), (2, 1)]));
raw.set(t::DEFAULT_CROP_SIZE, Value::Rational(vec![(12, 1), (6, 1)]));
let img = decode(&dng(raw, ByteOrder::Little)).unwrap();
let expect: Vec<u16> = px.iter().map(|&v| table[v as usize]).collect();
assert_eq!(img.data, RawData::U16(expect.clone()));
assert!(img.linearized);
assert_eq!(img.active_area, Rect::new(2, 1, 16, 10));
assert_eq!(img.crop, Rect::new(1, 2, 12, 6));
assert_eq!(img.black.at(1, 1, 0, 1), 13.0 + 0.5 - 1.0);
let n = img.normalized().unwrap();
let v = expect[(1 + 1) * w + 2 + 1] as f32;
let b = 13.0 + 0.5 - 1.0;
assert!((n.data[16 + 1] - (v - b) / (4000.0 - 11.5)).abs() < 1e-5);
let rgb = img.develop(Method::Ppg).unwrap();
assert_eq!((rgb.width, rgb.height), (12, 6));
}
#[test]
fn opcodes_are_parsed_and_applied() {
let mut raw = synthetic(32, 24, Some(Cfa::bayer("RGGB").unwrap()), 1);
let area = Area { top: 0, left: 0, bottom: 24, right: 32, plane: 0, planes: 1, row_pitch: 1, col_pitch: 1 };
raw.opcodes.list2 = vec![Opcode::ScalePerRow { area, scales: vec![2.0; 24] }];
raw.opcodes.list3 = vec![Opcode::FixVignetteRadial { k: [0.0; 5], center: [0.5, 0.5] }, Opcode::Unknown { id: 99, flags: 1, params: vec![] }];
let bytes = write_dng(&raw, &DngWriteOptions::default()).unwrap();
let back = decode(&bytes).unwrap();
assert_eq!(back.opcodes, raw.opcodes);
let plain = {
let mut r = raw.clone();
r.opcodes = Default::default();
r.normalized().unwrap()
};
let scaled = back.normalized().unwrap();
for (a, b) in plain.data.iter().zip(&scaled.data) {
assert!((b - 2.0 * a).abs() < 1e-5);
}
}
#[test]
fn preview_and_probe() {
let raw = synthetic(16, 16, Some(Cfa::bayer("RGGB").unwrap()), 1);
let bytes = write_dng(&raw, &DngWriteOptions::default()).unwrap();
// the raw tiles are lossless JPEG: never offered as a preview
assert!(embedded_preview(&bytes).is_none());
assert_eq!(probe(&bytes), Some(RawFormat::Dng));
}
#[test]
fn colour_transform_from_decoded_dng() {
let raw = decode(&write_dng(&synthetic(16, 16, Some(Cfa::bayer("RGGB").unwrap()), 1), &DngWriteOptions::default()).unwrap()).unwrap();
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
let t = lightcraft_raw::color::camera_transform(&raw, xy);
assert!(!t.matrix_is_fallback);
// as-shot neutral (0.5, 1, 0.7) → multipliers (2, 1, 1/0.7) up to normalisation
assert!((t.wb[0] / t.wb[1] - 2.0).abs() < 1e-3, "{:?}", t.wb);
assert!((t.wb[2] / t.wb[1] - 1.0 / 0.7).abs() < 1e-3);
assert!((t.baseline_exposure - 0.35).abs() < 1e-6);
let (m, _) = lightcraft_raw::color::camera_to_rec2020(&raw, D65);
let o = m.apply([1.0; 3]);
assert!(o.iter().all(|v| (v - 1.0).abs() < 1e-9));
}
/// Issue #212: a NEF converted to DNG came out dark and green. NEF white balance is vendor multipliers
/// (no colour matrix, no `AsShotNeutral`); the DNG must carry them as its as-shot neutral.
#[test]
fn vendor_white_balance_survives_dng_conversion() {
let mut raw = synthetic(16, 16, Some(Cfa::bayer("RGGB").unwrap()), 1);
raw.color = ColorData::default();
raw.wb_multipliers = Some([2.25, 1.0, 1.7421875]);
let before = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
let back = decode(&write_dng(&raw, &DngWriteOptions::default()).unwrap()).unwrap();
let after = lightcraft_raw::color::camera_transform(&back, lightcraft_raw::color::as_shot_white_xy(&back));
for c in 0..3 {
assert!((before.wb[c] - after.wb[c]).abs() < 1e-3, "{:?} vs {:?}", before.wb, after.wb);
}
assert!((after.wb[0] - 2.25).abs() < 1e-3 && (after.wb[2] - 1.7421875).abs() < 1e-3, "{:?}", after.wb);
}
#[test]
fn rejects_unsupported_and_broken() {
let (w, h) = (8, 8);
let mut raw = base_ifd(w, h, 8, 1, true);
raw.set(t::COMPRESSION, Value::Short(vec![compression::LOSSY_JPEG]));
raw.set_image(ImageData::Strips { rows_per_strip: 8, strips: vec![vec![0; 10]] });
// lossy JPEG DNGs decode now; a strip that isn't a JPEG is reported as corrupt
assert!(matches!(decode(&dng(raw, ByteOrder::Little)), Err(lightcraft_raw::RawError::Corrupt(_))));
let mut raw = base_ifd(w, h, 16, 1, true);
raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 3, 1]));
raw.set(t::COMPRESSION, Value::Short(vec![1]));
raw.set_image(ImageData::Strips { rows_per_strip: 8, strips: vec![vec![0; 128]] });
assert!(decode(&dng(raw, ByteOrder::Little)).is_err());
}
/// A Lightroom-style DNG: profile look tags in IFD 0, LinearRaw data in a SubIFD (issue #138).
fn profile_dng(order: ByteOrder, extra: impl FnOnce(&mut IfdBuilder)) -> Vec<u8> {
let (w, h) = (8, 6);
let px = pattern(w, h, 3, 16);
let mut raw = base_ifd(w, h, 16, 3, false);
raw.set(t::COMPRESSION, Value::Short(vec![1]));
raw.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![u16_bytes(&px, order)] });
dng_with(raw, order, extra)
}
fn hsv_floats(h: usize, s: usize, v: usize, f: impl Fn(usize, usize, usize) -> [f32; 3]) -> Vec<f32> {
(0..v).flat_map(|vi| (0..h).flat_map(move |hi| (0..s).map(move |si| (vi, hi, si)))).flat_map(|(vi, hi, si)| f(vi, hi, si)).collect()
}
#[test]
fn profile_look_tags_are_read_from_ifd0() {
for order in [ByteOrder::Little, ByteOrder::Big] {
let bytes = profile_dng(order, |ifd0| {
ifd0.set(t::PROFILE_HUE_SAT_MAP_DIMS, Value::Long(vec![6, 3, 1]));
ifd0.set(t::PROFILE_HUE_SAT_MAP_DATA_1, Value::Float(hsv_floats(6, 3, 1, |_, h, s| [h as f32, 1.0 + s as f32 * 0.1, 1.0])));
ifd0.set(t::PROFILE_HUE_SAT_MAP_DATA_2, Value::Float(hsv_floats(6, 3, 1, |_, _, _| [0.0, 1.0, 1.0])));
ifd0.set(t::PROFILE_LOOK_TABLE_DIMS, Value::Long(vec![4, 2, 3]));
ifd0.set(t::PROFILE_LOOK_TABLE_DATA, Value::Float(hsv_floats(4, 2, 3, |v, _, _| [0.0, 1.0, 1.0 - v as f32 * 0.05])));
ifd0.set(t::PROFILE_LOOK_TABLE_ENCODING, Value::Long(vec![1]));
ifd0.set(t::PROFILE_TONE_CURVE, Value::Float(vec![0.0, 0.0, 0.25, 0.15, 0.5, 0.55, 1.0, 1.0]));
});
let img = decode(&bytes).unwrap();
assert_eq!(img.cpp, 3);
let p = &img.color.profile;
let hsm = p.hue_sat_map[0].as_ref().unwrap();
assert_eq!((hsm.hue_divisions, hsm.sat_divisions, hsm.val_divisions), (6, 3, 1));
// value-major, hue-middle, saturation-minor: entry (h = 2, s = 1) is at 2·3 + 1
assert_eq!(hsm.data[7], [2.0, 1.1, 1.0]);
assert!(p.hue_sat_map[1].is_some());
let look = p.look_table.as_ref().unwrap();
assert_eq!((look.hue_divisions, look.sat_divisions, look.val_divisions, look.srgb_value), (4, 2, 3, true));
assert_eq!(look.data[2 * 4 * 2], [0.0, 1.0, 0.9]);
assert_eq!(p.tone_curve.as_ref().unwrap().points.len(), 4);
// the header-only probe sees the same profile
assert_eq!(lightcraft_raw::probe_info(&bytes).unwrap().color.profile, *p);
// our DNG writer keeps the look with the data (conversions, smart previews)
let again = decode(&write_dng(&img, &DngWriteOptions::default()).unwrap()).unwrap();
assert_eq!(again.color.profile, *p);
}
}
#[test]
fn malformed_profile_look_tags_are_ignored() {
let bytes = profile_dng(ByteOrder::Little, |ifd0| {
// data count doesn't match the dimensions
ifd0.set(t::PROFILE_HUE_SAT_MAP_DIMS, Value::Long(vec![6, 3, 1]));
ifd0.set(t::PROFILE_HUE_SAT_MAP_DATA_1, Value::Float(vec![0.0, 1.0, 1.0]));
// one saturation division is not allowed
ifd0.set(t::PROFILE_LOOK_TABLE_DIMS, Value::Long(vec![2, 1, 1]));
ifd0.set(t::PROFILE_LOOK_TABLE_DATA, Value::Float(vec![0.0, 1.0, 1.0, 0.0, 1.0, 1.0]));
// decreasing inputs and a NaN
ifd0.set(t::PROFILE_TONE_CURVE, Value::Float(vec![0.0, 0.0, 0.6, 0.5, 0.4, f32::NAN, 1.0, 1.0]));
});
let img = decode(&bytes).unwrap();
assert!(img.color.profile.is_empty(), "{:?}", img.color.profile);
// and without any of the tags there is nothing to apply
let plain = decode(&profile_dng(ByteOrder::Little, |_| {})).unwrap();
assert!(plain.color.profile.is_empty());
}
/// Lossless JPEG XL codestream of `px` (`w × h × cpp` interleaved), 16-bit unless `eight`.
fn jxl_encode(px: &[u16], w: usize, h: usize, cpp: usize, eight: bool) -> Vec<u8> {
use zune_core::bit_depth::BitDepth;
use zune_core::colorspace::ColorSpace;
use zune_core::options::EncoderOptions;
let cs = if cpp == 1 { ColorSpace::Luma } else { ColorSpace::RGB };
let (bytes, depth): (Vec<u8>, _) = if eight {
(px.iter().map(|&v| v as u8).collect(), BitDepth::Eight)
} else {
(px.iter().flat_map(|v| v.to_ne_bytes()).collect(), BitDepth::Sixteen)
};
let mut out = Vec::new();
zune_jpegxl::JxlSimpleEncoder::new(&bytes, EncoderOptions::new(w, h, cs, depth)).encode(&mut out).unwrap();
out
}
/// A bare codestream wrapped in the ISO-BMFF container (signature, `ftyp`, `jxlc`).
fn jxl_container(cs: &[u8]) -> Vec<u8> {
let mut b = vec![0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a, 0, 0, 0, 0x14];
b.extend_from_slice(b"ftypjxl \0\0\0\0jxl ");
b.extend_from_slice(&((cs.len() + 8) as u32).to_be_bytes());
b.extend_from_slice(b"jxlc");
b.extend_from_slice(cs);
b
}
/// A tiled JPEG XL raw IFD of `px`. Edge tiles alternate between being coded at their in-image size
/// and padded to the nominal tile size (with junk the decoder must drop); tiles alternate between bare
/// codestreams and containers.
fn jxl_tiled(px: &[u16], w: usize, h: usize, cpp: usize, bits: u16, cfa: bool, tile: (usize, usize)) -> IfdBuilder {
let (tw, th) = tile;
let mut tiles = Vec::new();
for ty in 0..h.div_ceil(th) {
for tx in 0..w.div_ceil(tw) {
let i = tiles.len();
let (vw, vh) = (tw.min(w - tx * tw), th.min(h - ty * th));
let (cw, ch) = if i % 2 == 0 { (tw, th) } else { (vw, vh) };
let mut t = vec![bits.min(8) * 7; cw * ch * cpp];
for y in 0..vh {
for x in 0..vw {
for s in 0..cpp {
t[(y * cw + x) * cpp + s] = px[((ty * th + y) * w + tx * tw + x) * cpp + s];
}
}
}
let cs = jxl_encode(&t, cw, ch, cpp, bits == 8);
tiles.push(if i % 3 == 1 { jxl_container(&cs) } else { cs });
}
}
let mut raw = base_ifd(w, h, bits, cpp, cfa);
raw.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
raw.set_image(ImageData::Tiles { tile_width: tw as u32, tile_height: th as u32, tiles });
raw
}
#[test]
fn jpeg_xl_tiles_decode_bit_exactly() {
let (w, h) = (37, 29);
// LinearRaw 3 × 16-bit, the full code range
let px = pattern(w, h, 3, 16);
let bytes = dng(jxl_tiled(&px, w, h, 3, 16, false, (16, 16)), ByteOrder::Big);
let img = decode(&bytes).unwrap();
assert_eq!((img.width, img.height, img.cpp), (w, h, 3));
assert_eq!(img.data, RawData::U16(px));
assert_eq!(lightcraft_raw::probe_info(&bytes).unwrap(), img.info());
// CFA 1 × 8/12/14-bit
for bits in [8u32, 12, 14] {
let px = pattern(w, h, 1, bits);
let img = decode(&dng(jxl_tiled(&px, w, h, 1, bits as u16, true, (16, 8)), ByteOrder::Little)).unwrap();
assert_eq!(img.data, RawData::U16(px), "{bits} bits");
assert_eq!(img.white, vec![((1u32 << bits) - 1) as f32]);
assert_eq!(img.cfa.unwrap().name(), "GRBG");
}
// strips: a single JPEG XL strip per 8 rows (the last one shorter)
let px = pattern(w, h, 1, 12);
let strips = px.chunks(w * 8).map(|c| jxl_encode(c, w, c.len() / w, 1, false)).collect();
let mut raw = base_ifd(w, h, 12, 1, true);
raw.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
raw.set_image(ImageData::Strips { rows_per_strip: 8, strips });
assert_eq!(decode(&dng(raw, ByteOrder::Big)).unwrap().data, RawData::U16(px));
}
#[test]
fn malformed_jpeg_xl_tiles_are_errors() {
use lightcraft_raw::RawError;
let (w, h) = (16, 16);
let px = pattern(w, h, 1, 12);
let good = jxl_encode(&px, w, h, 1, false);
let one = |tile: Vec<u8>, cpp: usize| {
let mut raw = base_ifd(w, h, 12, cpp, cpp == 1);
raw.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
raw.set_image(ImageData::Tiles { tile_width: w as u32, tile_height: h as u32, tiles: vec![tile] });
dng(raw, ByteOrder::Little)
};
// garbage, every truncation, and flipped bytes: errors (or, for a truncation that only drops
// trailing padding, the exact samples), never panics
let mut garbage = vec![0xff, 0x0a];
garbage.extend((0..400u32).map(|i| (i.wrapping_mul(2654435761) >> 13) as u8));
assert!(matches!(decode(&one(garbage, 1)), Err(RawError::Corrupt(_))));
for n in 0..good.len() {
let bytes = one(good[..n].to_vec(), 1);
if let Ok(img) = decode(&bytes) {
assert_eq!(img.data, RawData::U16(px.clone()), "truncated to {n}");
}
let _ = lightcraft_raw::probe_info(&bytes);
}
for i in 0..good.len() {
let mut b = good.clone();
b[i] ^= 0x5a;
let _ = decode(&one(b, 1));
}
// a tile of the wrong size, or the wrong plane count for the IFD
let small = jxl_encode(&px[..w * 8], w, 8, 1, false);
assert!(matches!(decode(&one(small, 1)), Err(RawError::Corrupt(_))));
assert!(matches!(decode(&one(good.clone(), 3)), Err(RawError::Corrupt(_))));
assert!(matches!(lightcraft_raw::probe_info(&one(good, 3)), Err(RawError::Corrupt(_))));
}
/// One undecodable tile makes the whole image an error: it is never returned with a black tile.
#[test]
fn a_jpeg_xl_tile_that_fails_is_an_error_for_the_image() {
use lightcraft_raw::RawError;
let (w, h) = (32, 16);
let px = pattern(w, h, 1, 12);
let tile = |x0: usize| -> Vec<u16> { (0..16).flat_map(|y| px[y * w + x0..y * w + x0 + 16].to_vec()).collect() };
let two = |second: Vec<u8>| {
let mut raw = base_ifd(w, h, 12, 1, true);
raw.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
raw.set_image(ImageData::Tiles { tile_width: 16, tile_height: 16, tiles: vec![jxl_encode(&tile(0), 16, 16, 1, false), second] });
dng(raw, ByteOrder::Little)
};
assert_eq!(decode(&two(jxl_encode(&tile(16), 16, 16, 1, false))).unwrap().data, RawData::U16(px.clone()));
// the second tile is cut short; the first one alone is not an image
let good = jxl_encode(&tile(16), 16, 16, 1, false);
assert!(matches!(decode(&two(good[..good.len() / 2].to_vec())), Err(RawError::Corrupt(_))));
assert!(matches!(decode(&two(vec![0xff, 0x0a, 1, 2, 3])), Err(RawError::Corrupt(_))));
}
/// The decoder's memory is bounded by the tile: a grey tile that also codes an alpha channel needs
/// about twice what one plane may use, and is an error rather than an allocation the header asks
/// for. The same samples without the alpha channel decode.
#[test]
fn jpeg_xl_tiles_beyond_their_decode_budget_are_errors() {
use lightcraft_raw::RawError;
use zune_core::bit_depth::BitDepth;
use zune_core::colorspace::ColorSpace;
use zune_core::options::EncoderOptions;
let (w, h) = (512, 512);
let grey: Vec<u16> = (0..w * h).map(|i| ((i % w) * 97 + (i / w) * 31) as u16 & 0x0fff).collect();
let with_alpha: Vec<u8> = grey.iter().flat_map(|&v| [v, 4095 - v]).flat_map(u16::to_ne_bytes).collect();
let mut tile = Vec::new();
zune_jpegxl::JxlSimpleEncoder::new(&with_alpha, EncoderOptions::new(w, h, ColorSpace::LumaA, BitDepth::Sixteen)).encode(&mut tile).unwrap();
let one = |tile: Vec<u8>| {
let mut raw = base_ifd(w, h, 12, 1, true);
raw.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
raw.set_image(ImageData::Tiles { tile_width: w as u32, tile_height: h as u32, tiles: vec![tile] });
dng(raw, ByteOrder::Little)
};
assert!(matches!(decode(&one(tile)), Err(RawError::Corrupt(_))));
assert_eq!(decode(&one(jxl_encode(&grey, w, h, 1, false))).unwrap().data, RawData::U16(grey));
}
#[test]
fn jpeg_xl_preview_ifd_is_offered() {
let raw = synthetic(16, 16, Some(Cfa::bayer("RGGB").unwrap()), 1);
let RawData::U16(px) = &raw.data else { unreachable!() };
let mut sub = base_ifd(16, 16, 14, 1, true);
sub.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
sub.set_image(ImageData::Tiles { tile_width: 16, tile_height: 16, tiles: vec![jxl_encode(px, 16, 16, 1, false)] });
let preview = jxl_container(&jxl_encode(&[200; 4 * 2 * 3], 4, 2, 3, true));
let bytes = dng_with(sub, ByteOrder::Big, |ifd0| {
ifd0.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
ifd0.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![preview.clone()] });
});
// the rendered RGB preview is offered whole; the (single-tile) CFA raw never is
assert_eq!(embedded_preview(&bytes).unwrap(), preview);
assert_eq!(decode(&bytes).unwrap().data, raw.data);
}
/// `ProfileGainTableMap` / `ProfileGainTableMap2` bytes in `order`: a `dims` = (V, H, N) grid with
/// spacing 0.5 and origin 0.25, green-only input weights, `v2` = (data type, gamma, gain min,
/// gain max), then `data` (the stored gains, already in `order`).
fn gain_map_tag(order: ByteOrder, dims: [u32; 3], v2: Option<(u32, f32, f32, f32)>, data: &[u8]) -> Vec<u8> {
let be = order == ByteOrder::Big;
let u = |v: u32| if be { v.to_be_bytes() } else { v.to_le_bytes() };
let mut b = Vec::new();
b.extend_from_slice(&u(dims[0]));
b.extend_from_slice(&u(dims[1]));
for v in [0.5f64, 0.5, 0.25, 0.25] {
b.extend_from_slice(&if be { v.to_be_bytes() } else { v.to_le_bytes() });
}
b.extend_from_slice(&u(dims[2]));
for w in [0.0f32, 1.0, 0.0, 0.0, 0.0] {
b.extend_from_slice(&u(w.to_bits()));
}
if let Some((dt, gamma, min, max)) = v2 {
for v in [dt, gamma.to_bits(), min.to_bits(), max.to_bits()] {
b.extend_from_slice(&u(v));
}
}
b.extend_from_slice(data);
b
}
fn f32_bytes(v: &[f32], order: ByteOrder) -> Vec<u8> {
v.iter().flat_map(|f| if order == ByteOrder::Big { f.to_be_bytes() } else { f.to_le_bytes() }).collect()
}
/// [`profile_dng`] with binary tags in the raw IFD (where Apple writes `ProfileGainTableMap`) and
/// in IFD 0.
fn gain_map_dng(order: ByteOrder, raw_tags: &[(u16, &[u8])], ifd0_tags: &[(u16, &[u8])]) -> Vec<u8> {
let (w, h) = (8, 6);
let px = pattern(w, h, 3, 16);
let mut raw = base_ifd(w, h, 16, 3, false);
raw.set(t::COMPRESSION, Value::Short(vec![1]));
raw.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![u16_bytes(&px, order)] });
for (tag, b) in raw_tags {
raw.set(*tag, Value::Undefined(b.to_vec()));
}
dng_with(raw, order, |ifd0| {
for (tag, b) in ifd0_tags {
ifd0.set(*tag, Value::Undefined(b.to_vec()));
}
})
}
#[test]
fn gain_table_maps_follow_dng_precedence() {
const V1: u16 = t::PROFILE_GAIN_TABLE_MAP;
const V2: u16 = t::PROFILE_GAIN_TABLE_MAP_2;
for order in [ByteOrder::Big, ByteOrder::Little] {
let v1 = gain_map_tag(order, [2, 2, 4], None, &f32_bytes(&(1..=16).map(|i| i as f32 * 0.25).collect::<Vec<_>>(), order));
// Apple's layout: version 1 in the raw IFD, in the file's byte order
let apple = gain_map_dng(order, &[(V1, &v1[..])], &[]);
let img = decode(&apple).unwrap();
let map = img.color.profile.gain_table_map.clone().expect("gain table map");
assert_eq!((map.points_v, map.points_h, map.points_n, map.gamma), (2, 2, 4, 1.0));
assert_eq!((map.spacing_v, map.origin_h, map.weights[1]), (0.5, 0.25, 1.0));
assert_eq!(map.gains[5], 1.5);
assert_eq!(lightcraft_raw::probe_info(&apple).unwrap().color.profile, img.color.profile);
// our DNG writer keeps it
let again = decode(&write_dng(&img, &DngWriteOptions::default()).unwrap()).unwrap();
assert_eq!(again.color.profile.gain_table_map.as_ref(), Some(&map));
// version 1 in IFD 0 (where DNG 1.7 says it was meant to go) is read too
let in_ifd0 = decode(&gain_map_dng(order, &[], &[(V1, &v1[..])])).unwrap();
assert_eq!(in_ifd0.color.profile.gain_table_map.as_ref(), Some(&map));
// version 2 in IFD 0 supersedes version 1: 8-bit gains over 0.5..=1.5, gamma 2
let v2 = gain_map_tag(order, [1, 1, 2], Some((0, 2.0, 0.5, 1.5)), &[0, 255]);
let img = decode(&gain_map_dng(order, &[(V1, &v1[..])], &[(V2, &v2[..])])).unwrap();
let map2 = img.color.profile.gain_table_map.clone().unwrap();
assert_eq!((map2.points_n, map2.gamma, map2.gains.clone()), (2, 2.0, vec![0.5, 1.5]));
// a gamma ≠ 1 survives our writer (as version 2)
let again = decode(&write_dng(&img, &DngWriteOptions::default()).unwrap()).unwrap();
assert_eq!(again.color.profile.gain_table_map, Some(map2));
// a malformed version 2 (gamma outside 0.25..=4) falls back to version 1
let bad_v2 = gain_map_tag(order, [1, 1, 2], Some((0, 9.0, 0.5, 1.5)), &[0, 255]);
assert_eq!(decode(&gain_map_dng(order, &[(V1, &v1[..])], &[(V2, &bad_v2[..])])).unwrap().color.profile.gain_table_map, Some(map));
}
}
#[test]
fn malformed_gain_table_maps_are_ignored() {
let order = ByteOrder::Little;
let ok = gain_map_tag(order, [1, 1, 2], None, &f32_bytes(&[1.0, 2.0], order));
let cases: Vec<(&str, Vec<u8>, bool)> = vec![
("valid", ok.clone(), false),
("empty", vec![], false),
("header only", ok[..64].to_vec(), false),
("one byte short", ok[..ok.len() - 1].to_vec(), false),
("NaN gain", gain_map_tag(order, [1, 1, 2], None, &f32_bytes(&[1.0, f32::NAN], order)), false),
("negative gain", gain_map_tag(order, [1, 1, 2], None, &f32_bytes(&[1.0, -2.0], order)), false),
("huge dimensions", gain_map_tag(order, [u32::MAX, u32::MAX, u32::MAX], None, &f32_bytes(&[1.0, 2.0], order)), false),
("zero points", gain_map_tag(order, [1, 0, 2], None, &[]), false),
("unknown data type", gain_map_tag(order, [1, 1, 2], Some((9, 1.0, 0.0, 1.0)), &[0, 0]), true),
("NaN gain range", gain_map_tag(order, [1, 1, 2], Some((0, 1.0, f32::NAN, 1.0)), &[0, 255]), true),
];
for (what, bytes, v2) in cases {
let tag = if v2 { t::PROFILE_GAIN_TABLE_MAP_2 } else { t::PROFILE_GAIN_TABLE_MAP };
let img = decode(&gain_map_dng(order, &[(tag, &bytes[..])], &[])).unwrap_or_else(|e| panic!("{what}: {e}"));
assert_eq!(img.color.profile.gain_table_map.is_some(), what == "valid", "{what}");
}
}
/// The map is read in the file's byte order, else big-endian (`lightcraft_raw::gaintable`): a
/// big-endian payload in a little-endian file is read, a little-endian one in a big-endian file
/// is not.
#[test]
fn gain_table_map_byte_orders() {
use ByteOrder::{Big, Little};
let payload = |o: ByteOrder| gain_map_tag(o, [1, 1, 2], None, &f32_bytes(&[1.0, 2.0], o));
for (file, data, read) in [(Little, Little, true), (Little, Big, true), (Big, Big, true), (Big, Little, false)] {
let img = decode(&gain_map_dng(file, &[(t::PROFILE_GAIN_TABLE_MAP, &payload(data)[..])], &[])).unwrap();
assert_eq!(img.color.profile.gain_table_map.map(|m| m.gains), read.then(|| vec![1.0, 2.0]), "{data:?} payload, {file:?} file");
}
}
/// A semantic mask IFD (8-bit, uncompressed) named `name`.
fn mask_ifd(w: usize, h: usize, name: &str, px: Vec<u8>) -> IfdBuilder {
let mut m = IfdBuilder::new();
m.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![t::SUBFILE_SEMANTIC_MASK]));
m.set(t::IMAGE_WIDTH, Value::Long(vec![w as u32]));
m.set(t::IMAGE_LENGTH, Value::Long(vec![h as u32]));
m.set(t::BITS_PER_SAMPLE, Value::Short(vec![8]));
m.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
m.set(t::PHOTOMETRIC, Value::Short(vec![photometric::MASK]));
m.set(t::COMPRESSION, Value::Short(vec![1]));
if !name.is_empty() {
m.set(t::SEMANTIC_NAME, Value::Ascii(name.into()));
}
m.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![px] });
m
}
/// Semantic masks: a cropped mask is placed by its `MaskSubArea` (zero outside it), mapped onto the
/// developed image (default crop of the active area), and doesn't disturb the raw decode.
#[test]
fn semantic_masks_are_read_and_placed() {
use lightcraft_raw::semantic::MaskSubArea;
for order in [ByteOrder::Little, ByteOrder::Big] {
let bytes = profile_dng(order, |ifd0| {
// a 4 × 2 crop at (top 1, left 2) of an 8 × 4 mask over the 8 × 6 image
let mut sky = mask_ifd(4, 2, "sky", vec![10, 20, 30, 40, 50, 60, 70, 255]);
sky.set(t::MASK_SUB_AREA, Value::Long(vec![1, 2, 8, 4]));
ifd0.add_sub_ifd(sky);
let mut skin = mask_ifd(2, 2, "skin", vec![0, 255, 255, 0]);
skin.set(t::SEMANTIC_INSTANCE_ID, Value::Ascii("person_a".into()));
ifd0.add_sub_ifd(skin);
});
let raw = decode(&bytes).unwrap();
assert_eq!((raw.width, raw.height, raw.cpp), (8, 6, 3), "the masks are not the raw image");
let masks = lightcraft_raw::semantic_masks(&bytes);
assert_eq!(masks.len(), 2);
let (sky, skin) = (&masks[0], &masks[1]);
assert_eq!((sky.name.as_str(), sky.instance_id.as_deref(), sky.width, sky.height), ("sky", None, 4, 2));
assert_eq!(sky.sub_area, Some(MaskSubArea { top: 1, left: 2, full_width: 8, full_height: 4 }));
assert_eq!((sky.data[0], sky.data[7]), (10 * 257, 65535));
assert_eq!((skin.name.as_str(), skin.instance_id.as_deref(), skin.sub_area), ("skin", Some("person_a"), None));
// positions are in uncropped-mask pixels: zero outside the stored crop
assert!((sky.value_at(2.5, 1.5) - 10.0 / 255.0).abs() < 1e-4);
assert!((sky.value_at(5.5, 2.5) - 1.0).abs() < 1e-4);
assert_eq!((sky.value_at(1.5, 1.5), sky.value_at(2.5, 0.5), sky.value_at(6.5, 1.5)), (0.0, 0.0, 0.0));
// over the whole image: the uncropped mask's 8 × 4 grid
let full = sky.developed(Rect::new(0, 0, 8, 6), Rect::new(0, 0, 8, 6)).unwrap();
assert_eq!((full.width, full.height), (8, 4));
assert_eq!((full.get(2, 1), full.get(5, 2), full.get(1, 1), full.get(6, 2), full.get(2, 0)), (10, 255, 0, 0, 0));
// a default crop keeping columns 2..6 of the image: the stored crop fills it
let cropped = sky.developed(Rect::new(0, 0, 8, 6), Rect::new(2, 0, 4, 6)).unwrap();
assert_eq!((cropped.width, cropped.height), (4, 4));
assert_eq!((cropped.get(0, 1), cropped.get(3, 2), cropped.get(0, 0)), (10, 255, 0));
}
}
/// Malformed semantic masks are skipped (never a panic), and so is the `MaskSubArea` of a mask it
/// doesn't fit; the raw image still decodes.
#[test]
fn malformed_semantic_masks_are_skipped() {
let bytes = profile_dng(ByteOrder::Little, |ifd0| {
ifd0.add_sub_ifd(mask_ifd(2, 2, "", vec![1, 2, 3, 4])); // no SemanticName
let mut rgb = mask_ifd(2, 2, "rgb", vec![0; 12]);
rgb.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
rgb.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8]));
ifd0.add_sub_ifd(rgb);
let mut huge = mask_ifd(2, 2, "huge", vec![0; 4]);
huge.set(t::IMAGE_WIDTH, Value::Long(vec![100_000]));
huge.set(t::IMAGE_LENGTH, Value::Long(vec![100_000]));
ifd0.add_sub_ifd(huge);
let mut tiny = mask_ifd(2, 2, "tiny data", vec![0; 4]);
tiny.set(t::IMAGE_WIDTH, Value::Long(vec![4000]));
tiny.set(t::IMAGE_LENGTH, Value::Long(vec![3000]));
ifd0.add_sub_ifd(tiny);
let mut jxl = mask_ifd(2, 2, "unknown coding", vec![0; 4]);
jxl.set(t::COMPRESSION, Value::Short(vec![60_000]));
ifd0.add_sub_ifd(jxl);
let mut zero = mask_ifd(2, 2, "zero size", vec![0; 4]);
zero.set(t::IMAGE_WIDTH, Value::Long(vec![0]));
ifd0.add_sub_ifd(zero);
let mut short = mask_ifd(4, 4, "short strip", vec![200; 3]);
short.set(t::MASK_SUB_AREA, Value::Long(vec![u32::MAX, u32::MAX, u32::MAX, u32::MAX]));
ifd0.add_sub_ifd(short);
let mut outside = mask_ifd(2, 2, "sub area too small", vec![255; 4]);
outside.set(t::MASK_SUB_AREA, Value::Long(vec![3, 0, 2, 4]));
ifd0.add_sub_ifd(outside);
});
assert_eq!(decode(&bytes).unwrap().cpp, 3);
let masks = lightcraft_raw::semantic_masks(&bytes);
let names: Vec<&str> = masks.iter().map(|m| m.name.as_str()).collect();
assert_eq!(names, ["short strip", "sub area too small"]);
assert!(masks.iter().all(|m| m.sub_area.is_none()), "sub areas the mask doesn't fit are ignored");
for m in &masks {
let d = m.developed(Rect::new(0, 0, 8, 6), Rect::new(0, 0, 8, 6)).unwrap();
assert_eq!((d.width, d.height), (m.width, m.height));
assert!(m.developed(Rect::new(0, 0, 8, 6), Rect::new(8, 6, 0, 0)).is_none());
}
// not a TIFF at all
assert!(lightcraft_raw::semantic_masks(b"not a dng").is_empty());
}
/// Only the first `MAX_MASKS` mask IFDs are decoded, malformed ones included, so a file full of
/// mask IFDs costs a bounded number of decodes: here the first `MAX_MASKS` can't be decoded, and
/// the valid masks after them are never looked at.
#[test]
fn semantic_mask_ifds_past_the_cap_are_not_decoded() {
use lightcraft_raw::semantic::MAX_MASKS;
let with = |undecodable: usize, valid: usize| {
profile_dng(ByteOrder::Little, |ifd0| {
for i in 0..undecodable {
let mut m = mask_ifd(2, 2, &format!("undecodable {i}"), vec![0; 4]);
m.set(t::COMPRESSION, Value::Short(vec![60_000]));
ifd0.add_sub_ifd(m);
}
for i in 0..valid {
ifd0.add_sub_ifd(mask_ifd(2, 2, &format!("valid {i}"), vec![255; 4]));
}
})
};
let bytes = with(MAX_MASKS, 3 * MAX_MASKS);
assert_eq!(decode(&bytes).unwrap().cpp, 3);
assert!(lightcraft_raw::semantic_masks(&bytes).is_empty());
// one fewer undecodable mask leaves room for exactly one valid one
let names: Vec<String> = lightcraft_raw::semantic_masks(&with(MAX_MASKS - 1, 3 * MAX_MASKS)).into_iter().map(|m| m.name).collect();
assert_eq!(names, ["valid 0"]);
}
/// `BaselineSharpness` is read from IFD 0; a zero, negative or absurd value is ignored.
#[test]
fn baseline_sharpness_is_read_when_plausible() {
for (value, want) in [((3, 2), Some(1.5)), ((0, 1), None), ((1, 0), None), ((100, 1), None)] {
let bytes = profile_dng(ByteOrder::Big, |ifd0| {
ifd0.set(t::BASELINE_SHARPNESS, Value::Rational(vec![value]));
});
assert_eq!(decode(&bytes).unwrap().color.baseline_sharpness, want, "{value:?}");
}
let bytes = profile_dng(ByteOrder::Big, |ifd0| {
ifd0.set(t::BASELINE_SHARPNESS, Value::SRational(vec![(-1, 1)]));
});
assert_eq!(decode(&bytes).unwrap().color.baseline_sharpness, None);
assert_eq!(decode(&profile_dng(ByteOrder::Big, |_| {})).unwrap().color.baseline_sharpness, None);
}
/// The DNG CFAPattern is anchored at the top-left of the ActiveArea: a DNG whose area starts on an odd row or
/// column still demosaics with the pattern the tag states (#1086), and the writer states it back the same way.
#[test]
fn cfa_pattern_is_relative_to_the_active_area() {
let (w, h) = (20, 12);
for (top, left, sensor_name) in [(0u32, 0u32, "GRBG"), (1, 0, "BGGR"), (0, 1, "RGGB"), (1, 1, "GBRG"), (3, 5, "GBRG"), (2, 4, "GRBG")] {
let mut raw = base_ifd(w, h, 16, 1, true);
raw.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![u16_bytes(&pattern(w, h, 1, 16), ByteOrder::Little)] });
raw.set(t::WHITE_LEVEL, Value::Short(vec![65535]));
raw.set(t::ACTIVE_AREA, Value::Long(vec![top, left, h as u32 - 1, w as u32 - 1]));
let img = decode(&dng(raw, ByteOrder::Little)).unwrap();
assert_eq!(img.cfa.as_ref().unwrap().name(), sensor_name, "sensor anchor for area at ({left}, {top})");
assert_eq!(img.normalized().unwrap().cfa.unwrap().name(), "GRBG", "tag pattern for area at ({left}, {top})");
let back =
decode(&write_dng(&img, &DngWriteOptions { compression: DngCompression::Uncompressed, order: ByteOrder::Little, xmp: None }).unwrap())
.unwrap();
assert_eq!(back.cfa.as_ref().unwrap().name(), sensor_name);
assert_eq!(back.normalized().unwrap().cfa.unwrap().name(), "GRBG");
}
}