Files
lightcraft/crates/engine/src/camera_preview/tests.rs
T

2430 lines
126 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.
use super::*;
fn steep_toe_scene() -> (Rgb32f, Rgb32f) {
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
// A broad scene with a steep camera toe below the old JPEG floor of 0.015.
// Permute brightness so the held-out pixels span the same range independently.
let y = 0.002 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.25;
let rgb = [0.8 + (i % 11) as f32 * 0.04, 1.0, 0.8 + (i % 17) as f32 * 0.025];
*src = rgb.map(|v| v * y / luminance_2020(rgb));
let display = 3.0 * y * (y / (y + 0.02)).powi(2);
*dst = src.map(|v| v * display / y);
}
(sensor, reference)
}
#[test]
fn camera_tone_follows_the_jpegs_deep_shadows() {
let (sensor, reference) = steep_toe_scene();
let look = fit_pairs_with(&sensor, &reference, Some((Mat3::IDENTITY, None))).unwrap();
let dark: Vec<_> = sensor.data.iter().zip(&reference.data).filter(|(_, p)| luminance_2020(**p) < 0.015).collect();
assert!(dark.len() > 256);
let error = dark.iter().map(|(x, y)| (look.tone.apply(luminance_2020(**x)) - luminance_2020(**y)).abs()).sum::<f32>() / dark.len() as f32;
assert!(error < 0.001, "deep-shadow luminance error {error}");
assert_eq!(look.matrix, Mat3::IDENTITY);
}
#[test]
fn colour_holdouts_stay_out_of_the_wider_tone_training() {
let (sensor, reference) = steep_toe_scene();
let fit = |reference: &Rgb32f| {
let (pairs, _) = collect_pairs(&sensor, reference, 0.005, None, false).unwrap();
fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, reference, None, |x| x).unwrap()
};
let expected = fit(&reference);
let mut changed = reference.clone();
let mut index = 0;
for (x, y) in sensor.data.iter().zip(&mut changed.data) {
if colour_pair(*x, *y) {
if index % 3 == 0 {
*y = y.map(|v| v * 1.1);
assert!(colour_pair(*x, *y));
}
index += 1;
}
}
assert_eq!(fit(&changed), expected);
}
#[test]
fn camera_toe_is_robust_to_shadow_noise() {
let (sensor, clean) = steep_toe_scene();
let mut noisy = clean.clone();
for (i, p) in noisy.data.iter_mut().enumerate() {
if luminance_2020(*p) < 0.015 {
// Quantized JPEG levels with bounded noise and occasional compression outliers.
let noise = ((i * 17 % 11) as f32 - 5.0) * 0.00004;
*p = p.map(|v| ((v + noise).max(0.0) / 0.0003).round() * 0.0003);
if i % 23 == 0 {
*p = [0.014; 3];
}
}
}
let look = fit_pairs_with(&sensor, &noisy, Some((Mat3::IDENTITY, None))).unwrap();
let dark: Vec<_> = sensor.data.iter().zip(&clean.data).filter(|(_, p)| luminance_2020(**p) < 0.015).collect();
let error = dark.iter().map(|(x, y)| (look.tone.apply(luminance_2020(**x)) - luminance_2020(**y)).abs()).sum::<f32>() / dark.len() as f32;
assert!(error < 0.001, "noisy shadow luminance error {error}");
assert_eq!(look.tone.apply(0.0), 0.0);
assert_eq!(look.matrix, Mat3::IDENTITY);
for i in 1..1000 {
assert!(look.tone.apply(i as f32 / 1000.0) >= look.tone.apply((i - 1) as f32 / 1000.0));
}
}
#[test]
fn camera_toe_requires_lower_independent_dark_error() {
for holdout_matches_original in [true, false] {
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
let y = if i < 600 { 0.0015 + (i % 200) as f32 * 0.00001 } else { 0.02 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.23 };
*src = [y; 3];
// Midtones teach the original slope of 3. Dark training teaches 0.3,
// but the independent dark holdout can agree with either slope.
let slope = if i < 600 && !(holdout_matches_original && i % 3 == 0) { 0.3 } else { 3.0 };
*dst = [y * slope; 3];
assert_eq!(colour_pair(*src, *dst), i >= 600);
}
let (pairs, _) = collect_pairs(&sensor, &reference, f32::NEG_INFINITY, None, false).unwrap();
let original = fit_tone(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(*x), luma(*y))).collect()).unwrap();
// Both sample sets start at a multiple of three, so this split matches
// the helper's separate colour and extra-sample indices.
let candidate = fit_tone(
sensor
.data
.iter()
.zip(&reference.data)
.enumerate()
.filter(|(i, _)| i % 3 != 0)
.map(|(_, (x, y))| (luma(x.map(f64::from)), luma(y.map(f64::from))))
.collect(),
)
.unwrap();
let held: Pairs = sensor.data.iter().zip(&reference.data).take(600).step_by(3).map(|(x, y)| (x.map(f64::from), y.map(f64::from))).collect();
assert_eq!(held.len(), 200);
assert!(held.iter().all(|(_, y)| (0.0003..0.015).contains(&luma(*y))));
assert!(rank_correlation_ties(&held) > 0.99);
let error = |tone: &CameraTone| {
held.iter().map(|(x, y)| (f64::from(tone.apply(luma(*x) as f32)).powf(1.0 / 2.2) - luma(*y).powf(1.0 / 2.2)).powi(2)).sum::<f64>()
};
assert_ne!(candidate, original);
let selected = fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, &reference, None, |x| x).unwrap();
if holdout_matches_original {
assert!(error(&candidate) > error(&original) + 0.1);
assert_eq!(selected, original, "a correlated but misleading training set must keep the original curve");
} else {
assert!(error(&candidate) < error(&original) * 0.99);
assert_eq!(selected, candidate, "matching dark training and holdout must accept the candidate");
}
let mut previous = 0.0;
assert_eq!(selected.apply(0.0), 0.0);
for i in 0..10_000 {
let y = selected.apply(10f32.powf(-8.0 + i as f32 / 1000.0));
assert!(y.is_finite() && y >= previous && y <= 1.0);
previous = y;
}
assert_eq!(selected.apply(f32::NAN), 0.0);
assert_eq!(selected.apply(f32::INFINITY), 0.0);
}
}
#[test]
fn unreliable_dark_pairs_keep_the_original_tone_and_colour() {
let (sensor, reference) = steep_toe_scene();
let dark: Vec<_> = reference.data.iter().enumerate().filter(|(_, p)| luminance_2020(**p) < 0.015).map(|(i, _)| i).collect();
let mut excluded = sensor.clone();
for &i in &dark {
excluded.data[i] = [f32::NAN; 3];
}
let colour = Some((Mat3::IDENTITY, None));
let original = fit_pairs_with(&excluded, &reference, colour.clone()).unwrap();
for case in 0..6 {
let (mut x, mut y) = (sensor.clone(), reference.clone());
for (j, &i) in dark.iter().enumerate() {
match case {
0 => y.data[i] = [0.0; 3], // JPEG black gives no measured shadow signal
1 => x.data[i] = [0.00001; 3], // below the sensor noise floor
2 if j >= 95 => y.data[i] = [0.0; 3], // too few dark pairs
3 => x.data[i] = [0.003; 3], // no scene luminance spread
4 => y.data[i] = [0.003; 3], // one quantized grey, not a toe
5 => y.data[i] = [0.0004 + ((j * 7919) % 1000) as f32 * 0.000014; 3], // unrelated noise
_ => {}
}
}
let look = fit_pairs_with(&x, &y, colour.clone()).unwrap();
assert_eq!(look.tone, original.tone, "case {case}");
assert_eq!(look.matrix, original.matrix, "case {case}");
assert!(look.hue_sat.is_none());
}
}
#[test]
fn camera_jpeg_matches_the_sensor_proxy_size_exactly() {
// An ILCE-7RM4 ARW (9504×6336 crop) embeds a 1616×1080 preview; for the camera-profile proxy
// it is decoded at 384×257 (`proxies`: twice the proxy edge) next to a 192×128 sensor proxy.
let sensor = fit(&Rgb32f::new(9504, 6336), PROFILE_PROXY, PROFILE_PROXY, Filter::Box);
assert_eq!((sensor.width, sensor.height), (192, 128));
let jpeg = Rgb32f::new(384, 257);
// what fitting gave before: a pixel short, so every pair was rejected
assert_eq!(fit(&jpeg, sensor.width, sensor.height, Filter::Box).width, 191);
let r = reference_at(&jpeg, &sensor);
assert_eq!((r.width, r.height), (192, 128));
// and at the single-photo proxy size, where fitting happened to land on the same size
let sensor = fit(&Rgb32f::new(9504, 6336), PROXY, PROXY, Filter::Box);
let r = reference_at(&Rgb32f::new(192, 129), &sensor);
assert_eq!((r.width, r.height), (sensor.width, sensor.height));
}
#[test]
fn linear_arw_gets_a_sensor_proxy_without_demosaicing() {
use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, RawFormat, Rect};
let mut raw = RawImage {
format: RawFormat::Arw,
width: 32,
height: 32,
cpp: 3,
data: RawData::F32([0.2, 0.3, 0.4].repeat(32 * 32)),
cfa: None,
bits: 16,
black: BlackLevel::uniform(0.0),
white: vec![1.0],
active_area: Rect::new(0, 0, 32, 32),
crop: Rect::new(0, 0, 32, 32),
orientation: Orientation::from_exif(1),
color: ColorData::default(),
wb_multipliers: Some([1.0; 3]),
linearized: true,
pixel_aspect: 1.0,
opcodes: OpcodeLists::default(),
metadata: lightcraft_meta::Metadata::default(),
lens_metadata: Default::default(),
};
let proxy = sensor_proxy(&raw, 2, 384).unwrap();
assert_eq!((proxy.width, proxy.height), (32, 32));
assert_eq!(proxy.data[0], [0.2, 0.3, 0.4]);
raw.data = RawData::F32(Vec::new());
assert!(sensor_proxy(&raw, 2, 384).is_none());
}
#[test]
fn separates_nonlinear_tone_from_colour_and_keeps_sensor_headroom() {
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
let mut sensor = Rgb32f::new(64, 64);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
let ev = 0.05 + (i % 31) as f32 * 0.017;
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
let p = known.apply_f32(*src);
let y = luminance_2020(p);
*dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y);
}
let original = sensor.clone();
let fit = fit_pairs(&sensor, &reference).unwrap();
assert_eq!(sensor.data, original.data);
let tone = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0);
let error: f64 = sensor
.data
.iter()
.zip(&reference.data)
.map(|(x, y)| {
let p = displayed(fit.matrix.apply(x.map(f64::from)), &tone);
(0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::<f64>() / 3.0
})
.sum::<f64>()
/ sensor.data.len() as f64;
assert!(error.sqrt() < 0.025, "{error}");
// The colour transform is homogeneous; tone mapping happens only after exposure.
let p = fit.matrix.apply([2.0, 2.0, 2.0]);
assert!(luma(p) > 1.0);
assert!(tone.apply(0.2) < tone.apply(0.4));
}
#[test]
fn accepts_a_much_better_fit_despite_local_camera_processing() {
// The camera JPEG departs from any global matrix + curve (local tone, vignetting): ±0.12
// per-pixel deviations, ~0.07 RMS. The fit is still far closer than the fallback.
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
let mut sensor = Rgb32f::new(64, 64);
let mut reference = sensor.clone();
let mut seed = 0x2545_f491_u32;
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
let ev = 0.05 + (i % 31) as f32 * 0.017;
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
let p = known.apply_f32(*src);
let y = luminance_2020(p);
*dst = p.map(|v| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
let noise = (seed % 2001) as f32 / 1000.0 - 1.0;
(v * (1.0 - (-2.5 * y).exp()) / y + 0.12 * noise).clamp(0.005, 0.97)
});
}
assert!(fit_pairs(&sensor, &reference).is_some());
}
/// A camera that rotates saturated yellow-greens toward green (like Sony's "Standard" on a lime
/// shirt) can't be followed by a matrix alone: the fitted hue/saturation table must close most
/// of the gap, keep neutrals neutral and keep luminance.
#[test]
fn hue_table_follows_a_hue_dependent_camera_rendering() {
let to = to_prophoto();
let from = to.inverse().unwrap();
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
let mut lime = Vec::new();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
let ev = 0.05 + (i % 29) as f32 * 0.02;
// mostly greys and mild colours, plus a patch of saturated yellow-green
*src = if i % 7 == 0 { [ev * 0.75, ev, ev * 0.2] } else { [ev * (0.85 + (i % 11) as f32 * 0.03), ev, ev * (0.85 + (i % 13) as f32 * 0.025)] };
let (h, s) = hue_saturation(to.apply(src.map(f64::from))).unwrap();
// the camera turns hues in 50°..110° (ProPhoto) by up to +15° and boosts their saturation
let k = (1.0 - ((h - 80.0) / 30.0).powi(2)).max(0.0) * s.min(1.0);
let mut p = to.apply(src.map(f64::from)).map(|v| v as f32);
let table = HsvTable {
hue_divisions: 1,
sat_divisions: 2,
val_divisions: 1,
data: vec![[0.0, 1.0, 1.0], [15.0 * k as f32, 1.0 + 0.3 * k as f32, 1.0]],
srgb_value: false,
};
p = table.apply(p);
let p = from.apply(p.map(f64::from)).map(|v| v as f32);
let scale = luminance_2020(*src) / luminance_2020(p);
let p = p.map(|v| v * scale);
let y = luminance_2020(p);
*dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y);
if i % 7 == 0 {
lime.push(i);
}
}
let fit = fit_pairs(&sensor, &reference).unwrap();
let table = fit.hue_sat.as_ref().expect("a hue/saturation table is fitted");
let hue_sat = HueSat::new(table).unwrap();
let hue_error = |with_table: bool| {
lime.iter()
.map(|&i| {
let p = fit.matrix.apply(sensor.data[i].map(f64::from)).map(|v| v as f32);
let p = if with_table { hue_sat.apply(p) } else { p };
let (h, _) = hue_saturation(to.apply(p.map(f64::from))).unwrap();
let (t, _) = hue_saturation(to.apply(reference.data[i].map(f64::from))).unwrap();
((h - t + 180.0).rem_euclid(360.0) - 180.0).abs()
})
.sum::<f64>()
/ lime.len() as f64
};
let (before, after) = (hue_error(false), hue_error(true));
assert!(after < before * 0.5, "lime hue error {before:.2}° -> {after:.2}°");
// neutrals pass through and luminance is kept
let grey = [0.3, 0.3, 0.3];
assert!(hue_sat.apply(grey).iter().all(|v| (v - 0.3).abs() < 1e-4), "{:?}", hue_sat.apply(grey));
let green = [0.2, 0.4, 0.05];
assert!((luminance_2020(hue_sat.apply(green)) - luminance_2020(green)).abs() < 1e-5);
}
/// A camera that saturates shadows and bleaches highlights toward white (a per-channel curve),
/// which a luminance tone curve can't: the fitted chroma curve must follow it, so bright
/// warm-tinted rock renders white instead of cream.
#[test]
fn chroma_curve_follows_highlight_bleaching() {
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
let camera_chroma = |o: f32| 1.3 - 1.1 * o;
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
let ev = 0.02 + (i % 37) as f32 * 0.03;
*src = [ev * (0.75 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.05)];
let p = known.apply_f32(*src);
let y = luminance_2020(p);
let o = 1.0 - (-2.5 * y).exp();
let k = camera_chroma(o);
*dst = p.map(|v| (o + (v * o / y - o) * k).clamp(0.0, 0.999));
}
let fit = fit_pairs(&sensor, &reference).unwrap();
// relative to the matrix, which already carries the average colourfulness
let chroma = fit.tone.chroma();
assert!(chroma[6] < 0.5 * chroma[1], "highlights bleach relative to shadows: {chroma:?}");
// and the rendered highlights land on the camera's, far closer than without the curve
let with = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0);
let without = ToneMap::camera(&fit.tone.with_chroma([1.0; lightcraft_pipeline::tone::CHROMA_N]).unwrap(), 0.0, 0.0, 0.0);
let highlight_error = |map: &ToneMap| -> f64 {
sensor
.data
.iter()
.zip(&reference.data)
.filter(|(_, y)| luminance_2020(**y) > 0.7)
.map(|(x, y)| {
let p = displayed(fit.matrix.apply(x.map(f64::from)), map);
(0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::<f64>()
})
.sum()
};
let (a, b) = (highlight_error(&without), highlight_error(&with));
assert!(b < 0.5 * a, "highlight error {a:.4} -> {b:.4}");
}
/// Pooled pairs of several "photos" recover the camera's matrix, and a photo given that
/// colour model (a camera profile) keeps it, fitting only its own tone.
#[test]
fn profile_colour_is_pooled_and_then_used_as_given() {
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
let photo = |seed: usize, strength: f32| {
let mut sensor = Rgb32f::new(64, 48);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
let i = i + seed * 7;
let ev = 0.04 + (i % 23) as f32 * 0.02;
*src = [ev * (0.7 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.05)];
let p = known.apply_f32(*src);
let y = luminance_2020(p);
// each photo has its own tone (DRO, picture style)
*dst = p.map(|v| v * (1.0 - (-strength * y).exp()) / y);
}
(sensor, reference)
};
let mut pool = Vec::new();
for seed in 0..4 {
let (sensor, reference) = photo(seed, 2.0 + seed as f32 * 0.5);
pool.extend(collect_pairs(&sensor, &reference, 0.05, None, false).unwrap().0);
}
let (matrix, _) = fit_profile(&pool).unwrap();
// the camera's colours (luminance-normalised: the tone curve sets brightness)
for x in [[0.8, 1.0, 0.75], [1.1, 1.0, 0.8], [0.75, 1.0, 1.2], [1.0; 3]] {
let (a, b) = (matrix.apply(x), known.apply(x));
let (la, lb) = (luma(a), luma(b));
assert!((0..3).all(|c| (a[c] / la - b[c] / lb).abs() < 0.01), "{x:?}: {a:?} vs {b:?}");
}
let (sensor, reference) = photo(9, 3.5);
let look = fit_pairs_with(&sensor, &reference, Some((matrix, None))).unwrap();
assert_eq!(look.matrix, matrix);
assert!(look.hue_sat.is_none());
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let error = sensor
.data
.iter()
.zip(&reference.data)
.map(|(x, y)| {
let p = displayed(look.matrix.apply(x.map(f64::from)), &tone);
(0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::<f64>() / 3.0
})
.sum::<f64>()
/ sensor.data.len() as f64;
assert!(error.sqrt() < 0.02, "this photo's own tone is followed: RMS {}", error.sqrt());
}
#[test]
fn profile_fits_tone_without_learning_colour_from_a_dull_scene() {
let matrix = Mat3([[2.0, -0.02, 0.0], [0.0, 2.0, 0.0], [0.0, -0.03, 2.0]]);
let mut sensor = Rgb32f::new(64, 48);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
*src = [0.02 + (i % 997) as f32 * 0.00045; 3];
let p = matrix.apply_f32(*src);
let y = luminance_2020(p);
*dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y);
}
assert!(fit_pairs(&sensor, &reference).is_none(), "not enough colour to learn a matrix");
let look = fit_pairs_with(&sensor, &reference, Some((matrix, None))).unwrap();
assert_eq!(look.matrix, matrix);
reference.map_in_place(|p| [luminance_2020(p); 3]);
assert!(fit_pairs_with(&sensor, &reference, Some((matrix, None))).is_none(), "monochrome JPEGs remain rejected");
}
#[test]
fn rejected_profile_preserves_a_usable_per_photo_fit() {
let matrix = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
let wrong = Mat3([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]);
let mut sensor = Rgb32f::new(64, 64);
let mut reference = sensor.clone();
let mut seed = 0x2545_f491_u32;
for (src, dst) in sensor.data.iter_mut().zip(&mut reference.data) {
*src = std::array::from_fn(|_| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
0.05 + (seed % 1000) as f32 * 0.0003
});
let p = matrix.apply_f32(*src);
let y = luminance_2020(p);
*dst = p.map(|v| (v * (1.0 - (-2.5 * y).exp()) / y).clamp(0.0, 0.999));
}
let per_photo = fit_pairs(&sensor, &reference).unwrap();
let look = fit_pairs_with(&sensor, &reference, Some((wrong, None))).unwrap();
assert_eq!(look.matrix, per_photo.matrix, "a rejected camera profile must not suppress the per-photo fit");
assert_eq!(look.tone, per_photo.tone);
}
#[test]
fn hue_sat_passes_black_and_non_finite_pixels_through() {
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
let hue_sat = HueSat::new(&table).unwrap();
assert_eq!(hue_sat.apply([0.0; 3]), [0.0; 3]);
let nan = hue_sat.apply([f32::NAN, 0.2, 0.1]);
assert!(nan[0].is_nan() && nan[1] == 0.2 && nan[2] == 0.1);
assert!(fit_hue_sat(&[], &Mat3::IDENTITY).is_none(), "too few samples");
}
#[test]
fn hue_sat_never_turns_near_black_noise_white() {
// a saturating table, as fitted to high-ISO Sony files; near-black noise with mixed-sign channels
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
let hue_sat = HueSat::new(&table).unwrap();
let mut seed = 0x9e37_79b9_7f4a_7c15u64;
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 11) as f32 / (1u64 << 53) as f32 * 2.0 - 1.0
};
let mut worst = 0.0f32;
for _ in 0..200_000 {
let rgb = [0.002 + 0.004 * next(), 0.002 + 0.004 * next(), 0.002 + 0.004 * next()];
if luminance_2020(rgb) <= 0.0 {
continue;
}
let out = hue_sat.apply(rgb);
let scale = rgb.iter().fold(0.0f32, |m, v| m.max(v.abs()));
worst = worst.max(out.iter().fold(0.0f32, |m, v| m.max(v.abs())) / scale);
}
assert!(worst < 20.0, "a near-black pixel came out {worst}× its largest channel");
}
/// `apply_lanes` gives `apply`'s bits for any colour: random, near-black noise (the restore's blend), grey,
/// negative, tiny, above 1, huge, NaN and infinite components; on a fitted look's shape and a saturating table.
#[test]
fn hue_sat_lanes_match_one_by_one_bit_for_bit() {
let mut seed = 0x2545_f491_4f6c_dd1du64;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
let unit = |r: u64, i: usize| ((r >> (16 * i)) & 0xffff) as f32 / 65535.0;
let fitted = (0..72 * 5 * 5)
.map(|_| {
let r = next();
[unit(r, 0) * 60.0 - 30.0, 0.5 + unit(r, 1), 1.0]
})
.collect();
let fitted = HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data: fitted, srgb_value: true };
let saturating = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
let special = [0.0, -0.0, 1e-30, -1e-6, 0.002, 0.5, 1.0, 3.0, 1e30, f32::INFINITY, f32::NEG_INFINITY, f32::NAN];
let mut colours = Vec::new();
for a in special {
for b in special {
colours.extend(special.map(|c| [a, b, c]));
}
}
for _ in 0..60_000 {
let r = next();
let (offset, scale) = [(0.15, 1.3), (0.15e-3, 1.3e-3), (0.6, 5.0), (-0.002, 0.008)][(r >> 62) as usize];
colours.push(std::array::from_fn(|i| unit(r, i) * scale - offset));
}
for table in [fitted, saturating] {
let hue_sat = HueSat::new(&table).unwrap();
for chunk in colours.chunks(16) {
let rgb: [[f32; 16]; 3] = std::array::from_fn(|c| std::array::from_fn(|l| chunk.get(l).map_or(0.0, |p| p[c])));
let out = hue_sat.apply_lanes(rgb);
for (l, p) in chunk.iter().enumerate() {
let (got, want) = ([out[0][l], out[1][l], out[2][l]], hue_sat.apply(*p));
assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "{p:?}: {got:?} vs {want:?}");
}
}
}
}
#[test]
fn hue_sat_restore_weight_blends_smoothly_between_the_thresholds() {
assert_eq!(restore_weight(0.0), 0.0);
assert_eq!(restore_weight(NO_LUMINANCE_KEPT), 0.0);
assert_eq!(restore_weight(MIN_LUMINANCE_KEPT), 1.0);
assert_eq!(restore_weight(1.0), 1.0);
assert_eq!(restore_weight(f32::NAN), 0.0);
// monotonic and without a step: neighbouring shares 0.001 apart differ by little
let mut last = 0.0f32;
for i in 0..=400 {
let w = restore_weight(i as f32 * 0.001);
assert!(w >= last && w - last < 0.02, "step at {}: {last} → {w}", i as f32 * 0.001);
last = w;
}
}
#[test]
fn hue_sat_keeps_colours_the_table_leaves_bright_exactly_as_before() {
// the blend only touches colours whose luminance the table cut below MIN_LUMINANCE_KEPT
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
let hue_sat = HueSat::new(&table).unwrap();
let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) };
for rgb in [[0.4, 0.2, 0.1], [0.05, 0.3, 0.2], [0.18, 0.18, 0.18], [0.6, 0.1, 0.5]] {
let out = mul(&hue_sat.from, table.apply(mul(&hue_sat.to, rgb)));
let (before, after) = (luminance_2020(rgb), luminance_2020(out));
assert!(after >= MIN_LUMINANCE_KEPT * before);
assert_eq!(hue_sat.apply(rgb), out.map(|v| v * before / after));
}
}
fn raw_of(format: RawFormat, make: &str, model: &str, color: lightcraft_raw::ColorData) -> RawImage {
let area = lightcraft_raw::Rect::new(0, 0, 2, 2);
RawImage {
format,
width: 2,
height: 2,
cpp: 1,
data: lightcraft_raw::RawData::U16(vec![0; 4]),
cfa: None,
bits: 14,
black: Default::default(),
white: vec![16383.0],
active_area: area,
crop: area,
orientation: Default::default(),
color,
wb_multipliers: Some([2.0, 1.0, 1.5]),
linearized: false,
pixel_aspect: 1.0,
opcodes: Default::default(),
metadata: lightcraft_raw::Metadata { make: Some(make.to_owned()), model: Some(model.to_owned()), ..Default::default() },
lens_metadata: Default::default(),
}
}
/// The lens warp the colour fit follows is the first `WarpRectilinear`'s green plane; no warp, a DNG 1.6 chain
/// (phone DNGs, which carry colour matrices), a centre outside the area or a non-finite term give none.
#[test]
fn lens_geometry_reads_the_first_rectilinear_warp() {
use lightcraft_raw::Opcode;
let warp = |k0: f64, c: [f64; 2]| Opcode::WarpRectilinear {
planes: vec![[9.0, 0.0, 0.0, 0.0, 0.0, 0.0], [k0, 0.1, -0.02, 0.003, 0.0, 0.0], [9.0, 0.0, 0.0, 0.0, 0.0, 0.0]],
center: c,
};
assert_eq!(lens_geometry(&[warp(0.97, [0.5, 0.4])]), Some(LensGeometry { k: [0.97, 0.1, -0.02, 0.003], center: [0.5, 0.4] }));
let single = Opcode::WarpRectilinear { planes: vec![[1.02, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] };
assert_eq!(lens_geometry(std::slice::from_ref(&single)).map(|g| g.k[0]), Some(1.02));
assert!(lens_geometry(&[]).is_none());
assert!(lens_geometry(&[warp(1.0, [1.5, 0.5])]).is_none());
assert!(lens_geometry(&[warp(f64::NAN, [0.5, 0.5])]).is_none());
let mut p = [0.0; lightcraft_raw::opcodes::WR2_LEN];
p[0] = 1.0;
p[18] = 1e6;
let chain = Opcode::WarpRectilinear2 { planes: vec![p], center: [0.5, 0.5], reciprocal: false, optional: false };
assert!(lens_geometry(&[chain, single]).is_none());
}
/// A binned sensor proxy moved into the camera JPEG's geometry: the identity warp leaves it as it is, a warp
/// whose source radius is 0.8 of the output's samples nearer the centre, output pixels whose source falls outside
/// the proxy are NaN (left out of the pairs), and a proxy of another size is left alone.
#[test]
fn the_sensor_proxy_follows_the_cameras_lens_warp() {
let mut raw = raw_of(RawFormat::Nef, "NIKON CORPORATION", "NIKON Z 6_2", Default::default());
raw.active_area = lightcraft_raw::Rect::new(0, 0, 64, 48);
raw.crop = raw.active_area;
// a 1/4 proxy whose pixels hold their own coordinates
let mut img = Rgb32f::new(16, 12);
for (i, p) in img.data.iter_mut().enumerate() {
*p = [(i % 16) as f32, (i / 16) as f32, 1.0];
}
let geometry = |k0: f64| LensGeometry { k: [k0, 0.0, 0.0, 0.0], center: [0.5, 0.5] };
let same = into_lens_geometry(&img, &raw, 4, &geometry(1.0)).unwrap();
assert!(same.data.iter().zip(&img.data).all(|(a, b)| (0..3).all(|c| (a[c] - b[c]).abs() < 1e-4)));
// the corner pixel's centre (1.5, 1.5) maps to (31.5 - 0.8 * 30, 23.5 - 0.8 * 22) = (7.5, 5.9) on the sensor
let zoomed = into_lens_geometry(&img, &raw, 4, &geometry(0.8)).unwrap();
assert!((zoomed.data[0][0] - 1.5).abs() < 1e-4 && (zoomed.data[0][1] - 1.1).abs() < 1e-4, "{:?}", zoomed.data[0]);
let outside = into_lens_geometry(&img, &raw, 4, &geometry(1.3)).unwrap();
assert!(outside.data[0][0].is_nan() && outside.data[6 * 16 + 8][0].is_finite());
assert!(into_lens_geometry(&Rgb32f::new(15, 12), &raw, 4, &geometry(1.0)).is_none());
}
/// A raw with an in-file lens correction (Fujifilm, Panasonic, the A7R IVA, Nikon) may have a lens-corrected
/// camera JPEG: the proxies the fit pairs line up with it at least as well as the uncorrected sensor, on every
/// corpus file the colour fit uses that carries such a warp, and better on those whose JPEG is corrected.
#[test]
fn corpus_lens_corrected_jpegs_pair_with_the_warped_sensor() {
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw");
let Ok(entries) = std::fs::read_dir(&dir) else {
eprintln!("skip: no corpus at {}", dir.display());
return;
};
let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
paths.sort();
let (mut checked, mut gain) = (0, 0.0);
for path in paths {
let Ok(bytes) = std::fs::read(&path) else { continue };
let Ok(mut raw) = lightcraft_raw::decode(&bytes) else { continue };
let Some(lens) = lens_geometry(&raw.opcodes.list3) else { continue };
if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) {
continue;
}
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
let (Some((plain, reference, _)), Some((chosen, chosen_reference, _))) =
(proxies(&raw, &bytes, &t, PROXY, 0.0, None), proxies(&raw, &bytes, &t, PROXY, 0.0, Some(&lens)))
else {
continue;
};
let (before, after) = (alignment(&plain, &reference), alignment(&chosen, &chosen_reference));
eprintln!("{}: alignment {before:.3} -> {after:.3}", path.display());
// (chosen on camera RGB, measured after the colour transform: a near tie may flip by a little)
assert!(after >= before - 0.01, "{}: {before:.3} -> {after:.3}", path.display());
gain += after - before;
checked += 1;
}
eprintln!("{checked} corpus raws with an in-file lens warp, mean gain {:.3}", gain / checked.max(1) as f64);
assert!(checked == 0 || gain > 0.0);
}
/// Step 1: a file's own colour matrices win over everything, including a spectral row for its model; no JPEG fit
/// is tried.
#[test]
fn a_files_own_matrices_come_first() {
let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]);
let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() };
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", color.clone());
let (_, t, look) = starting_colour_with(
&mut raw,
|_, _| panic!("no JPEG fit for a raw with its own matrices"),
|_, _| panic!("no black-and-white JPEG fit for a raw with its own matrices"),
|_, _| panic!("no dark tone fit for a raw with its own matrices"),
);
assert_eq!(raw.color, color);
assert!(!t.matrix_is_fallback && look.is_none());
}
/// Steps 2 and 3 before step 4: an accepted JPEG fit (with or without a camera profile) is kept for a model that
/// has spectral matrices, and the spectral matrices are not written. Photos that had a fitted look before the
/// spectral table render exactly as they did.
#[test]
fn an_accepted_jpeg_fit_comes_before_the_spectral_matrices() {
for (make, model) in [("SONY", "ILCE-7M4"), ("Canon", "Canon EOS 600D")] {
assert!(lightcraft_raw::spectral::find(Some(make), model).is_some(), "{model}");
let mut raw = raw_of(RawFormat::Cr2, make, model, Default::default());
let fitted = dro_test_look(&std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)), &|x| x.sqrt());
let (xy, t, look) = starting_colour_with(
&mut raw,
|raw, t| {
assert!(t.matrix_is_fallback && !lightcraft_raw::color::has_matrix(&raw.color), "the fit sees the raw without spectral matrices");
(Some(fitted.clone()), false)
},
|_, _| panic!("no black-and-white JPEG fit after an accepted fit"),
|_, _| panic!("no dark tone fit after an accepted fit"),
);
assert_eq!(look.map(|l| l.matrix), Some(fitted.matrix), "{model}");
assert_eq!(raw.color, lightcraft_raw::ColorData::default(), "{model}: spectral matrices left out");
assert!(t.matrix_is_fallback, "{model}");
assert_eq!(xy, lightcraft_raw::color::as_shot_white_xy(&raw));
}
}
/// Step 4: when the JPEG fit fails (here: no camera JPEG at all), a model with a spectral row takes its matrices,
/// whether or not it has a camera profile (the ILCE-7M4 has a bundled one).
#[test]
fn a_failed_jpeg_fit_falls_back_to_the_spectral_matrices() {
assert!(crate::camera_profiles::get("ILCE-7M4").is_some());
let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M4").unwrap();
let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default());
let (_, t, look) = starting_colour(&mut raw, &[], None, None);
assert!(look.is_none());
assert_eq!(raw.color.color_matrix, camera.color_matrix.map(Some));
assert!(!t.matrix_is_fallback);
let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap();
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default());
let (xy, t, look) =
starting_colour_with(&mut raw, |_, _| (None, false), |_, _| panic!("a colour JPEG never takes the black-and-white step"), |_, _| None);
assert!(look.is_none());
assert_eq!(raw.color.illuminant, [17, 21]);
assert_eq!(raw.color.forward_matrix, camera.forward_matrix.map(Some));
assert!(!t.matrix_is_fallback);
assert_eq!(xy, lightcraft_raw::color::as_shot_white_xy(&raw));
// white-balanced white stays white
let w = t.matrix.apply([1.0; 3]);
assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-4), "{w:?}");
}
/// After step 4 or 5: a photo whose JPEG gave no fit and is black and white gets its tone curve on top of the
/// colour it has by then, the spectral matrices for a model in the table, the neutral fallback's otherwise
/// (issue #577). The black-and-white fit sees that transform.
#[test]
fn a_black_and_white_jpeg_adds_its_tone_on_top_of_the_colour_it_gets_otherwise() {
let toned = CameraLook {
matrix: Mat3::IDENTITY,
tone: dro_test_look(&std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)), &|x| x.sqrt()).tone,
hue_sat: None,
};
let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap();
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default());
let (xy, t, look) = starting_colour_with(
&mut raw,
|_, _| (None, true),
|raw, t| {
assert!(!t.matrix_is_fallback && raw.color.forward_matrix == camera.forward_matrix.map(Some), "the spectral matrices are in");
Some(toned.clone())
},
|_, _| panic!("an accepted monochrome look keeps its tone"),
);
assert!(look.as_ref().is_some_and(|l| l.matrix == Mat3::IDENTITY && l.hue_sat.is_none()), "the tone curve on top");
assert!(!t.matrix_is_fallback);
assert_eq!(xy, lightcraft_raw::color::as_shot_white_xy(&raw));
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default());
let (_, t, look) = starting_colour_with(
&mut raw,
|_, _| (None, true),
|_, t| {
assert!(t.matrix_is_fallback, "outside the table: the neutral fallback's colour");
Some(toned.clone())
},
|_, _| panic!("an accepted monochrome look keeps its tone"),
);
assert!(look.is_some() && t.matrix_is_fallback);
assert_eq!(raw.color, lightcraft_raw::ColorData::default());
}
/// Step 5: a camera outside the table whose fit fails keeps the neutral fallback.
#[test]
fn a_camera_outside_the_table_keeps_the_fallback() {
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default());
let (_, t, look) = starting_colour(&mut raw, &[], None, None);
assert!(look.is_none() && t.matrix_is_fallback);
assert_eq!(raw.color, lightcraft_raw::ColorData::default());
// without a model there is no table row
let mut raw = raw_of(RawFormat::Cr2, "Canon", "", Default::default());
raw.metadata.model = None;
assert!(starting_colour(&mut raw, &[], None, None).1.matrix_is_fallback);
}
/// The colour precedence on public raws (each skipped without its corpus file): a photo whose JPEG fit is
/// accepted keeps it and its colour data; one whose fit fails takes its spectral matrices when its model has
/// them; a DNG keeps its own matrices. White balance stays relative to the as-shot look for every raw without
/// matrices of its own.
#[test]
fn corpus_colour_precedence() {
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw");
for name in ["arw-sony-a7m4-14bit.arw", "cr2-canon-5d3.cr2", "nef-nikon-d5100-lossless.nef", "cr2-canon-7d.cr2", "dng-canon-5d3-16bit.dng"] {
let Ok(bytes) = std::fs::read(dir.join(name)) else {
lightcraft_testkit::skip!(Corpus, "{name} absent");
continue;
};
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
let lens = lens_geometry(&raw.opcodes.list3);
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
let own = lightcraft_raw::color::has_matrix(&raw.color);
let (make, model) = (raw.metadata.make.as_deref(), raw.metadata.model.as_deref().unwrap_or_default());
let measured = lightcraft_raw::spectral::find(make, model).is_some();
let covered = measured || lightcraft_raw::kelvin_presets::covers(make, model);
let before = raw.color.clone();
let (_, t, fitted) = starting_colour(&mut raw, &bytes, lens.as_ref(), None);
let spectral = !own && fitted.is_none() && measured;
eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some());
assert_eq!(raw.color != before, spectral, "{name}");
assert_eq!(t.matrix_is_fallback, !own && !spectral, "{name}");
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
assert_eq!(info.camera_tone.is_some(), fitted.is_some(), "{name}");
// white balance: relative only without matrices anywhere (or presets); a covered camera edits in Kelvin,
// through the spectral matrices or its presets, whichever look it got (issue #730)
assert_eq!(info.relative_wb, !own && !covered, "{name}");
assert_eq!(info.legacy_relative_wb, !own && covered, "{name}");
if covered && !own {
assert!(
(2000.0..=50000.0).contains(&info.as_shot_temp) && info.as_shot_temp != 6500.0 && info.as_shot_tint.abs() < 100.0,
"{name}: {info:?}"
);
}
// the same as the catalog decides from the probe (format and camera)
let probe = crate::files::probe_bytes(name, &bytes).unwrap();
let mut p = lightcraft_catalog::Photo::new(
lightcraft_catalog::PhotoId(1),
lightcraft_catalog::Source::File { path: name.into() },
name,
&probe.format,
1,
1,
"",
);
p.kind = probe.kind;
p.meta = probe.meta.clone();
assert_eq!(p.relative_wb(), info.relative_wb, "{name}");
assert_eq!(probe.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}");
}
}
#[test]
fn supported_raws_get_a_file_local_look() {
// every decoded raw without a colour matrix of its own (issue #310: CR2 rendered flat with the fallback)
let local = [
RawFormat::Arw,
RawFormat::Nef,
RawFormat::Nrw,
RawFormat::Rw2,
RawFormat::Raf,
RawFormat::Cr3,
RawFormat::Cr2,
RawFormat::Pef,
RawFormat::Srw,
RawFormat::Orf,
RawFormat::Mrw,
RawFormat::Erf,
];
assert!(local.into_iter().all(file_local_look));
// DNG carries its own colour model
assert!(!file_local_look(RawFormat::Dng));
}
#[test]
fn cr3_camera_framing_is_measured_independently_of_tone_and_rejects_unrelated_edges() {
let (w, h) = (192, 128);
let mut sensor = Rgb32f::new(w, h);
for (i, pixel) in sensor.data.iter_mut().enumerate() {
let (x, y) = ((i % w) as f32, (i / w) as f32);
let value = 0.35 + 0.13 * (x * 0.15 + y * 0.22).sin() + 0.1 * (x * 0.33 - y * 0.14).sin() + 0.07 * (x * 0.09 - y * 0.31).cos();
*pixel = [value; 3];
}
let expected = Cr3Framing { scale: 0.96, offset: [0.01, -0.006] };
let mut reference = Rgb32f::new(w, h);
for (i, pixel) in reference.data.iter_mut().enumerate() {
let (x, y) = expected.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h);
*pixel = sensor.sample_bilinear(x, y).map(|v| v.powf(0.7));
}
let measured = estimate_cr3_framing(&sensor, &reference).expect("coherent held-out edges locate camera framing despite nonlinear tone");
assert!((measured.scale - expected.scale).abs() < 0.006, "{measured:?}");
assert!((measured.offset[0] - expected.offset[0]).abs() < 0.003, "{measured:?}");
assert!((measured.offset[1] - expected.offset[1]).abs() < 0.003, "{measured:?}");
assert!(estimate_cr3_framing(&sensor, &sensor).is_none(), "already aligned pixels stay unchanged");
reference.data.fill([0.2; 3]);
assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "flat previews cannot establish framing");
for (i, pixel) in reference.data.iter_mut().enumerate() {
let (x, y) = ((i % w) as f32, (i / w) as f32);
*pixel = [0.35 + 0.13 * (x * 0.41 - y * 0.07).sin() + 0.1 * (x * 0.04 + y * 0.39).cos(); 3];
}
assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "unrelated edges cannot change the framing");
}
/// The guarded fit is assessed on real sensor data, never on the JPEG fallback. A corpus
/// download or an unfinished codec cannot make this test claim a successful RAW calibration.
#[test]
fn corpus_cr3_gets_a_framing_aligned_camera_look() {
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw/cr3-canon-r100-raw.cr3");
let Ok(bytes) = std::fs::read(&path) else {
lightcraft_testkit::skip!(Corpus, "{} absent", path.display());
return;
};
let raw = match lightcraft_raw::decode(&bytes) {
Ok(raw) => raw,
Err(lightcraft_raw::RawError::Unsupported(why)) => {
lightcraft_testkit::skip!(Feature, "CR3 sensor decoder: {why}");
return;
}
Err(error) => panic!("CR3 corpus failed to decode: {error}"),
};
assert_eq!(raw.format, RawFormat::Cr3);
assert_eq!(raw.info().developed_size(), (6000, 4000));
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
let look = fit_preview(&raw, &bytes, &transform, None).0;
eprintln!("R100 guarded colour fit accepted: {}", look.is_some());
assert!(look.is_some(), "this R100 corpus photo has enough matching colour after camera-framing alignment");
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
assert!(info.raw && info.relative_wb);
assert_eq!((info.as_shot_temp, info.as_shot_tint), (6500.0, 0.0));
assert_eq!(info.camera_tone.is_some(), look.is_some());
}
/// A public sample of `model` (skipped without the corpus) takes its colour from the bundled profile for `model`
/// and passes the acceptance gates with it: the fitted look keeps the profile's matrix and table, which the
/// photo's own fit (the fallback when a profile is rejected) would not.
fn corpus_uses_the_bundled_profile(file: &str, model: &str) {
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw")
.join(file);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
lightcraft_testkit::skip!(Corpus, "{} absent", path.display());
return;
}
Err(e) => panic!("{}: {e}", path.display()),
};
let (_, json) = crate::camera_profiles::BUNDLED.iter().find(|(m, _)| *m == model).expect("the model is built in");
let bundled: crate::camera_profiles::CameraProfile = serde_json::from_str(json).unwrap();
let used = crate::camera_profiles::get(model).expect("a profile for the model");
assert_eq!(*used, bundled, "a local {model} profile overrides the built-in one; remove it to run this test");
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
assert_eq!(raw.metadata.model.as_deref(), Some(model));
// as the app does (`files.rs`): the camera's own lens warp pairs the sensor with its JPEG (Nikon's in-file
// distortion for the Z 6II and Z 8), and the lens corrections leave the opcodes before the proxy is binned
let lens = lens_geometry(&raw.opcodes.list3);
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
let look = fit_preview(&raw, &bytes, &transform, lens.as_ref()).0.expect("fit accepted");
let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap());
for (row, want) in look.matrix.0.iter().zip(expected.0) {
for (got, want) in row.iter().zip(want) {
assert!((got - want).abs() < 1e-12, "the look's matrix is not the profile's: {:?} vs {:?}", look.matrix.0, expected.0);
}
}
assert_eq!(look.hue_sat, bundled.hue_sat, "the look's table is not the profile's");
}
#[test]
fn corpus_a7cr_uses_the_bundled_profile() {
corpus_uses_the_bundled_profile("arw-sony-a7cr-lossless-l.arw", "ILCE-7CR");
}
#[test]
fn corpus_a99m2_uses_the_bundled_profile() {
corpus_uses_the_bundled_profile("arw-sony-a99m2.arw", "ILCA-99M2");
}
/// The public Z 6II NEF wasn't one of the photos its bundled profile was fitted to.
#[test]
fn corpus_z6ii_uses_the_bundled_profile() {
corpus_uses_the_bundled_profile("nef-nikon-z6ii-lossless.nef", "NIKON Z 6_2");
}
/// The public Z 8 NEF (Z 14-24 f/2.8 S) wasn't one of the photos its bundled profile was fitted to.
#[test]
fn corpus_z8_uses_the_bundled_profile() {
corpus_uses_the_bundled_profile("nef-nikon-z8-lossless.nef", "NIKON Z 8");
}
/// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white
/// balance is relative to the as-shot look.
#[test]
fn corpus_nef_gets_a_camera_look() {
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw/nef-nikon-d7500-lossless14.nef");
let Ok(bytes) = std::fs::read(&path) else {
lightcraft_testkit::skip!(Corpus, "{} absent", path.display());
return;
};
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
assert!(info.camera_tone.is_some(), "no camera look fitted");
assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0);
}
/// The public ILCE-7CR samples (61 MP; skipped without the corpus): lossless compressed L and M and compressed
/// ARW2 all get a look fitted to their own JPEG, not the neutral fallback.
#[test]
fn corpus_a7cr_codings_get_a_camera_look() {
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw");
for name in ["arw-sony-a7cr-lossless-l.arw", "arw-sony-a7cr-lossless-m.arw", "arw-sony-a7cr-compressed.arw"] {
let bytes = match std::fs::read(dir.join(name)) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
lightcraft_testkit::skip!(Corpus, "{name} absent");
continue;
}
Err(e) => panic!("{name}: {e}"),
};
let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap();
assert_eq!((img.width, img.height), (400, 267), "{name}: framed in the default crop");
assert!(info.camera_tone.is_some(), "{name}: no camera look fitted");
assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0, "{name}");
}
}
/// A public DC-FZ1000 II RW2 shot at 4:3 on its 3:2 sensor (skipped without the corpus): the default crop is 4:3
/// while the embedded JPEG shows the whole sensor; the look is still fitted, against the matching part of it.
#[test]
fn corpus_rw2_with_an_in_camera_crop_gets_a_camera_look() {
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw/rw2-panasonic-fz1000m2-4x3.rw2");
let Ok(bytes) = std::fs::read(&path) else {
lightcraft_testkit::skip!(Corpus, "{} absent", path.display());
return;
};
let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap();
assert_eq!((img.width, img.height), (400, 300), "framed in the in-camera aspect ratio");
assert!(info.camera_tone.is_some(), "no camera look fitted");
assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0);
}
/// Issue #232 (skipped without the corpus): the public ILCE-7RM2 sample's camera JPEG is
/// distortion-corrected, and on its glass façade the fit on all pixels misses the gate
/// (held-out RMS 0.113): it used to open grey with the neutral fallback.
#[test]
fn corpus_arw_with_a_lens_corrected_preview_gets_a_camera_look() {
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw/arw-sony-a7rm2-12bit-uncompressed.arw");
let Ok(bytes) = std::fs::read(&path) else {
lightcraft_testkit::skip!(Corpus, "{} absent", path.display());
return;
};
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
assert!(info.camera_tone.is_some(), "no camera look fitted");
}
/// A merged DNG of a raw's camera RGB (issue #965) carries the colour the raw opens with: its tags give the same
/// white balance, colour and brightness as the fitted look at the as-shot white, the look's hue/saturation
/// table and its tone curve; without a look, those of the camera transform.
#[test]
fn dng_colour_renders_like_the_raws_starting_colour() {
use lightcraft_raw::color::{as_shot_white_of, camera_transform_of};
let t = camera_transform_of(&ColorData::default(), Xy::new(0.36, 0.37));
assert!(t.wb.iter().any(|w| (w - 1.0).abs() > 0.1), "a white balance to keep: {:?}", t.wb);
let look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1));
let samples = [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6], [0.01, 0.5, 0.03]];
let render = |m: &Mat3, wb: [f32; 3], ev: f64, c: [f64; 3]| m.apply(std::array::from_fn(|i| c[i] * wb[i] as f64)).map(|v| v * ev.exp2());
for look in [Some(&look), None] {
let dng = dng_colour(&ColorData::default(), &crate::files::DngWhite::At(t.white_xy), &t, look).unwrap();
let back = camera_transform_of(&dng, as_shot_white_of(&dng, None));
for i in 0..3 {
assert!((back.wb[i] - t.wb[i]).abs() < 1e-4 * t.wb[i], "{:?} vs {:?}", back.wb, t.wb);
}
let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix));
for c in samples {
let (want, got) = (render(&m, t.wb, t.baseline_exposure, c), render(&back.matrix, back.wb, dng.baseline_exposure, c));
assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-5), "{c:?}: {want:?} vs {got:?}");
}
assert_eq!(dng.profile.hue_sat_for(1.0), look.and_then(|l| l.hue_sat.clone()));
let tone = dng.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve);
match look {
Some(look) => {
let tone = tone.expect("the look's tone curve");
let worst =
(0..=120).map(|i| 2f32.powf(-10.0 + i as f32 / 12.0)).map(|x| (tone.apply(x) - look.tone.apply(x)).abs()).fold(0.0, f32::max);
// (the reader resamples a `ProfileToneCurve` at 32 knots: 0.0031 of white at worst here)
assert!(worst < 4e-3, "{worst}");
}
None => assert!(tone.is_none()),
}
}
}
/// The merged DNG declares the white balance its source shows, also when the look's matrix doesn't keep white
/// neutral, as fitted looks often don't, and renders at As Shot as the source does. Real ARW merges declared their
/// colour matrix from the look: As Shot about 7000 K / +115 where the ARWs show 4800 K / +8.
#[test]
fn dng_colour_declares_the_white_balance_the_source_shows() {
use crate::files::DngWhite;
use lightcraft_raw::color::{as_shot_white_of, camera_transform_of, wb_multipliers};
let tags = ColorData::default();
let as_shot = [2.5039f32, 1.0, 1.5078];
let t = camera_transform_of(&tags, as_shot_white_of(&tags, Some(as_shot)));
let mut look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1));
look.matrix = Mat3([[1.75, -0.4, -0.2], [-0.2, 1.35, -0.2], [0.05, -0.3, 1.3]]);
let tint = look.matrix.apply([1.0; 3]);
assert!((tint[0] - tint[1]).abs() > 0.1, "a look that tints white: {tint:?}");
let renders_as_shot = |dng: &ColorData| {
let r = camera_transform_of(dng, as_shot_white_of(dng, None));
assert!(r.wb.iter().zip(t.wb).all(|(a, b)| (a - b).abs() < 1e-4 * b), "{:?} vs {:?}", r.wb, t.wb);
let m = look.matrix.mul(&t.matrix);
for c in [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6]] {
let want = m.apply(std::array::from_fn(|i| c[i] * t.wb[i] as f64)).map(|v| v * t.baseline_exposure.exp2());
let got = r.matrix.apply(std::array::from_fn(|i| c[i] * r.wb[i] as f64)).map(|v| v * dng.baseline_exposure.exp2());
assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-4), "{c:?}: {want:?} vs {got:?}");
}
};
// a white the source's scale shows (Sony's presets, the relative scale's 6500 K / 0)
for xy in [lightcraft_color::cct::temp_tint_to_xy(5600.0, 3.0), lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0)] {
let dng = dng_colour(&tags, &DngWhite::At(xy), &t, Some(&look)).unwrap();
let back = as_shot_white_of(&dng, None);
assert!((back.x - xy.x).abs() < 1e-6 && (back.y - xy.y).abs() < 1e-6, "as shot {back:?}, shown {xy:?}");
renders_as_shot(&dng);
}
// a camera with measured matrices (the ILCE-7M3): every white read through them, as for the source
let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M3").expect("a covered camera");
let mut model = tags.clone();
assert!(camera.fill(&mut model));
let shown = lightcraft_raw::spectral::as_shot_white_through(camera, &tags, Some(as_shot)).unwrap();
let dng = dng_colour(&tags, &DngWhite::Matrices(Box::new(model.clone())), &t, Some(&look)).unwrap();
let back = as_shot_white_of(&dng, None);
assert!((back.x - shown.x).abs() < 1e-5 && (back.y - shown.y).abs() < 1e-5, "as shot {back:?}, shown {shown:?}");
for w in [Xy::new(0.3127, 0.329), Xy::new(0.4476, 0.4074), Xy::new(0.3457, 0.3585)] {
let (a, b) = (wb_multipliers(&model, w), wb_multipliers(&dng, w));
assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6 * a), "{w:?}: {a:?} vs {b:?}");
}
renders_as_shot(&dng);
}
/// Photo Merge gives a panorama the white balance of its frames' vendor multipliers (issue #1144). A raw without
/// colour matrices (CR2, NEF…) is merged with the tags above, whose neutral is that white balance already: taking
/// it from the multipliers again leaves the merged DNG rendering as the tags do.
#[test]
fn a_merges_white_balance_from_the_multipliers_is_the_one_the_starting_colour_tags_have() {
use lightcraft_raw::color::{as_shot_white_of, camera_transform_of};
for (model, multipliers) in [("Canon EOS 6D Mark II", [2.0, 1.0, 1.5]), ("Canon EOS 600D", [1.9, 1.0, 1.7])] {
let mut raw = raw_of(RawFormat::Cr2, "Canon", model, Default::default());
raw.wb_multipliers = Some(multipliers);
let tags = starting_colour_tags(&mut raw, &[]).expect("tags for a raw without colour matrices");
let neutral = tags.as_shot_neutral.expect("the tags carry the as-shot white balance");
// the reciprocal of the multipliers, up to the scale a neutral is given in
let scale = neutral[1] * multipliers[1] as f64;
for i in 0..3 {
assert!((neutral[i] * multipliers[i] as f64 - scale).abs() < 1e-3, "{model}: {neutral:?} vs 1 / {multipliers:?}");
}
let mut merged = lightcraft_merge::FrameColor::Camera(Box::new(tags.clone()));
merged.set_as_shot_multipliers(multipliers);
let lightcraft_merge::FrameColor::Camera(merged) = merged else { panic!("camera colour") };
let (a, b) = (camera_transform_of(&tags, as_shot_white_of(&tags, None)), camera_transform_of(&merged, as_shot_white_of(&merged, None)));
for i in 0..3 {
assert!((a.wb[i] - b.wb[i]).abs() < 1e-4 * a.wb[i], "{model}: {:?} vs {:?}", a.wb, b.wb);
}
for (ra, rb) in a.matrix.0.iter().zip(b.matrix.0) {
assert!(ra.iter().zip(rb).all(|(x, y)| (x - y).abs() < 1e-4), "{model}: {:?} vs {:?}", a.matrix, b.matrix);
}
assert_eq!(a.baseline_exposure, b.baseline_exposure);
}
}
/// A look with tone curve `f` at knots `xs` and a colour model and chroma curve to keep.
fn dro_test_look(xs: &[f32; 32], f: &dyn Fn(f32) -> f32) -> CameraLook {
CameraLook {
matrix: Mat3([[1.6, -0.4, -0.2], [-0.2, 1.4, -0.2], [0.0, -0.3, 1.3]]),
tone: CameraTone::new(xs.map(|x| [x, f(x)])).unwrap().with_chroma([1.3, 1.2, 1.1, 1.0, 1.0, 0.9, 0.6, 0.3]).unwrap(),
hue_sat: Some(HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[5.0, 1.1, 1.0]; 8], srgb_value: false }),
}
}
/// `fixed` (from `fitted`) is `min(fitted, Sony's DRO-off curve)` at every knot but the
/// second-last, which may stay higher (at most the fitted curve) to keep the shoulder past the
/// last knot below the fitted one; nowhere brighter than `fitted`, colour unchanged.
fn assert_lowered(fitted: &CameraLook, fixed: &CameraLook, what: &str) {
let dro_off = sony_dro_off_tone().unwrap();
assert_eq!((fixed.matrix, &fixed.hue_sat, fixed.tone.chroma()), (fitted.matrix, &fitted.hue_sat, fitted.tone.chroma()), "{what}");
for (i, (k, own)) in fixed.tone.knots().iter().zip(fitted.tone.knots()).enumerate() {
let lowered = own[1].min(dro_off.apply(own[0]));
assert_eq!(k[0], own[0], "{what}: knot {i}");
if i == 30 {
assert!(k[1] >= lowered && k[1] <= own[1], "{what}: knot {i} {} not in {lowered}..={}", k[1], own[1]);
} else {
assert!((k[1] - lowered).abs() < 1e-6, "{what}: knot {i} {} vs {lowered}", k[1]);
}
}
assert!(at_most(&fixed.tone, &fitted.tone), "{what}: brighter than the fitted curve somewhere");
// the same check spelled out, from deep shadows to far past the last knot (the shoulder)
for i in 0..=2400 {
let x = 0.001 * 1.005f32.powi(i);
assert!(fixed.tone.apply(x) <= fitted.tone.apply(x) + 1e-5, "{what} at {x}: {} > {}", fixed.tone.apply(x), fitted.tone.apply(x));
}
}
/// Issue #244: Sony's Dynamic Range Optimizer lifts the camera JPEG's shadows, not the raw's. A
/// look fitted to such a JPEG keeps its colour (matrix, hue/saturation table, chroma curve) and
/// its curve is lowered to Sony's without DRO wherever that is darker, never raised.
#[test]
fn a_dro_look_keeps_its_colour_and_is_lowered_to_the_dro_off_tone() {
let dro_off = sony_dro_off_tone().expect("the built-in curve is valid");
// the curve itself: black stays black, increasing, mid-grey where cameras put it (L* 50–75)
assert_eq!(dro_off.apply(0.0), 0.0);
let mut previous = 0.0;
for i in 1..200 {
let y = dro_off.apply(i as f32 * 0.005);
assert!(y > previous && y < 1.0, "{i}: {y}");
previous = y;
}
assert!((0.18..0.48).contains(&dro_off.apply(0.18)), "{}", dro_off.apply(0.18));
let xs: [f32; 32] = std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32));
// a JPEG with lifted shadows: the curve comes down to Sony's, colour and chroma curve stay
let lifted = dro_test_look(&xs, &|x| dro_off.apply(x).powf(0.8));
let fixed = with_dro_off_tone(lifted.clone());
assert_lowered(&lifted, &fixed, "lifted");
assert!(fixed.tone.apply(0.02) < 0.85 * lifted.tone.apply(0.02));
// a camera that maps raw values darker than Sony's curve keeps its own curve exactly
let darker = dro_test_look(&xs, &|x| 0.7 * dro_off.apply(x));
assert_eq!(with_dro_off_tone(darker.clone()).tone, darker.tone);
// crossing curves: the lower of the two at the knots, still a valid increasing curve
let crossing = dro_test_look(&xs, &|x| (dro_off.apply(x) * (0.6 + x)).min(0.95));
assert_lowered(&crossing, &with_dro_off_tone(crossing.clone()), "crossing");
}
/// Past its last knot a camera curve continues as a shoulder whose rate comes from its last two
/// knots. Lowering the second-last knot more than the last steepened that shoulder: the curve
/// fitted to the CC0 NEX-5T sample (raw.pixls.us, DRO Auto) went from 0.740 to 0.843 at scene
/// luminance 0.63, brighter than before. The lowered curve must stay at or below the fitted one
/// through the shoulder, on that curve and on many others.
#[test]
fn the_lowered_curve_stays_below_the_fitted_one_past_its_last_knot() {
let nex5t: [[f32; 2]; 32] = [
[0.014948029, 0.017424058],
[0.018395446, 0.023981506],
[0.021539066, 0.030581286],
[0.024555191, 0.0374646],
[0.027602654, 0.044408206],
[0.030193308, 0.049894042],
[0.03261828, 0.055907387],
[0.034544908, 0.06156683],
[0.03669959, 0.06814718],
[0.039161213, 0.075263545],
[0.04173663, 0.0830011],
[0.044166446, 0.088166036],
[0.04682434, 0.095398724],
[0.049544845, 0.1027153],
[0.05235826, 0.11173598],
[0.05497513, 0.119017586],
[0.057503678, 0.12616614],
[0.059836045, 0.13521175],
[0.062677935, 0.14601946],
[0.06599234, 0.15573128],
[0.069809504, 0.16883339],
[0.0755404, 0.1854325],
[0.08250754, 0.20684941],
[0.089204684, 0.23466235],
[0.09650368, 0.25805998],
[0.102161564, 0.27849936],
[0.107812546, 0.29706743],
[0.11410169, 0.31379074],
[0.12000221, 0.33262977],
[0.12776275, 0.35407156],
[0.1387929, 0.38362882],
[0.19492775, 0.43935108],
];
let fitted = CameraLook { tone: CameraTone::new(nex5t).unwrap(), ..dro_test_look(&std::array::from_fn(|i| 0.01 + i as f32 * 0.01), &|x| x) };
let fixed = with_dro_off_tone(fitted.clone());
assert_lowered(&fitted, &fixed, "NEX-5T");
assert!((fitted.tone.apply(0.63) - 0.7405).abs() < 1e-3 && fixed.tone.apply(0.63) <= fitted.tone.apply(0.63));
assert_ne!(fixed.tone, fitted.tone, "the lifted mid-tones still come down");
// curves that end at all kinds of scene luminances, slopes and heights relative to Sony's
let dro_off = sony_dro_off_tone().unwrap();
let mut seed = 0x9e37_79b9_u32;
let mut next = move || {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
f32::from((seed >> 16) as u16) / 65536.0
};
for case in 0..300 {
let (start, span) = (0.002 + 0.03 * next(), 1.05 + 0.15 * next());
let xs: [f32; 32] = std::array::from_fn(|i| start * span.powi(i as i32));
let (lift, gain, top) = (0.5 + 0.6 * next(), 0.6 + 0.8 * next(), next());
// brighter or darker than Sony's, with a last knot sometimes lowered less than the one before
let f = |x: f32| (gain * dro_off.apply(x).powf(lift) + top * 0.2 * (x / xs[31]).powi(8)).min(0.995);
let mut ys = xs.map(f);
for i in 1..32 {
ys[i] = ys[i].max(ys[i - 1]);
}
let Some(tone) = CameraTone::new(std::array::from_fn(|i| [xs[i], ys[i]])) else { continue };
let fitted = CameraLook { tone, ..dro_test_look(&std::array::from_fn(|i| 0.01 + i as f32 * 0.01), &|x| x) };
assert_lowered(&fitted, &with_dro_off_tone(fitted.clone()), &format!("case {case}"));
}
}
/// Issue #244 (skipped without the corpus): the public ILCE-7RM2 sample (Standard creative
/// style) was shot with DRO Auto, so its curve comes down to Sony's without DRO in the shadows the
/// camera lifted; the ILCE-7M4 sample (DRO Auto, Vivid) has deeper shadows than that curve and
/// keeps them, only its brighter highlights come down. Both keep the colour fitted to their
/// JPEGs. The ILCE-7M3 sample (DRO off) keeps its own fit.
#[test]
fn corpus_arw_with_dro_is_lowered_to_the_dro_off_tone() {
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw");
let samples = [
("arw-sony-a7rm2-12bit-uncompressed.arw", true, [0.01, 0.05, 0.1]),
("arw-sony-a7m4-14bit.arw", true, [0.3, 0.4, 0.5]),
("arw-sony-a7m3-compressed.arw", false, [0.0; 3]),
];
for (name, dro, lowered) in samples {
let path = dir.join(name);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
lightcraft_testkit::skip!(Corpus, "{} absent", path.display());
continue;
}
Err(e) => panic!("{}: {e}", path.display()),
};
assert_eq!(lightcraft_raw::embedded_preview_dynamic_range_optimized(&bytes), Some(dro), "{name}");
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
let look = fit_preview(&raw, &bytes, &transform, None).0.expect("a camera look");
// the look fitted to the JPEG, as before this change
let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, 0.0, None).unwrap();
let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get);
let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone())));
let fitted = fit_look(&sensor, &reference, &clipped, colour).unwrap();
if dro {
assert_lowered(&fitted, &look, name);
for x in lowered {
assert!(look.tone.apply(x) < 0.95 * fitted.tone.apply(x), "{name} at {x}: {} vs {}", look.tone.apply(x), fitted.tone.apply(x));
}
} else {
assert_eq!((look.matrix, &look.hue_sat, look.tone), (fitted.matrix, &fitted.hue_sat, fitted.tone), "{name}");
}
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
assert_eq!(info.camera_tone, Some(look.tone), "{name}");
}
}
/// Issue #232: an ILCE-7RM2 camera JPEG is lens-corrected ("Distortion Comp.: Auto"), so on a
/// façade of window frames the raw and its JPEG are a pixel or two apart. The colours agree
/// everywhere away from the edges, and the look must still be found there.
#[test]
fn fits_a_slightly_misaligned_camera_jpeg_away_from_edges() {
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
let (w, h, block, shift) = (96, 64, 8, 2);
// Patches of 8×8 pixels, alternately dark and bright (frames and panes), varied in colour.
let scene = |x: usize, y: usize| -> [f32; 3] {
let (bx, by) = (x / block, y / block);
let ev = if (bx + by) % 2 == 0 { 0.04 + (bx % 3) as f32 * 0.01 } else { 0.3 + (by % 4) as f32 * 0.08 };
[ev * (0.7 + ((bx * 7 + by * 3) % 11) as f32 * 0.05), ev, ev * (0.7 + ((bx * 5 + by) % 13) as f32 * 0.04)]
};
let camera = |p: [f32; 3]| {
let q = known.apply_f32(p);
let y = luminance_2020(q);
q.map(|v| v * (1.0 - (-2.5 * y).exp()) / y)
};
let mut sensor = Rgb32f::new(w, h);
let mut reference = sensor.clone();
for y in 0..h {
for x in 0..w {
sensor.data[y * w + x] = scene(x, y);
// The JPEG shows the scene `shift` pixels to the right of where the raw has it.
reference.data[y * w + x] = camera(scene(x.saturating_sub(shift), y));
}
}
let fit = fit_pairs(&sensor, &reference).expect("look fitted away from the misaligned edges");
// The look itself is right: on the aligned scene it reproduces the camera.
let tone = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0);
let (mut error, mut n) = (0.0, 0);
for y in 0..h {
for x in 0..w {
let p = displayed(fit.matrix.apply(scene(x, y).map(f64::from)), &tone);
let t = camera(scene(x, y));
error += (0..3).map(|c| (p[c] - f64::from(t[c])).powi(2)).sum::<f64>();
n += 3;
}
}
assert!((error / n as f64).sqrt() < 0.03, "{}", (error / n as f64).sqrt());
// Unrelated colours stay rejected away from edges too (covered below); a flat JPEG as well.
reference.data.fill([0.2; 3]);
assert!(fit_pairs(&sensor, &reference).is_none());
}
/// X-Trans and 16-bit Bayer RAFs use the same fixed fit at thumbnail and export sizes.
/// The X-T20 fails the gates on all pixels (held-out RMS 0.114 against 0.10) and passes away
/// from edges (0.056, issue #232), so it is fitted too. Skips cleanly without these CC0
/// corpus files.
#[test]
fn corpus_raf_colour_and_white_balance() {
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw");
// (the GFX100S shares the GFX 100's measured sensor, so its white balance is in Kelvin through those
// matrices; the X-T2 and X-T20 have none and stay relative)
for (name, accepted, kelvin) in
[("raf-fuji-xt2-865.raf", true, false), ("raf-fuji-gfx100s-4503.raf", true, true), ("raf-fuji-xt20-compressed.raf", true, false)]
{
let Ok(bytes) = std::fs::read(dir.join(name)) else { continue };
let header = crate::files::probe_bytes(name, &bytes).unwrap();
let (_, small) = crate::files::load_bytes(&bytes, 400).unwrap();
let (_, large) = crate::files::load_bytes(&bytes, 1200).unwrap();
assert_eq!(small.camera_tone.is_some(), accepted, "{name}");
assert_eq!(small.camera_tone, large.camera_tone, "{name}: resolution changed the fit");
assert_eq!(header.as_shot_wb, Some((small.as_shot_temp, small.as_shot_tint)), "{name}");
assert!(small.raw && small.relative_wb != kelvin && small.legacy_relative_wb == kelvin, "{name}: {small:?}");
if kelvin {
assert!(small.as_shot_temp != 6500.0 && (2000.0..50000.0).contains(&small.as_shot_temp), "{name}: {}", small.as_shot_temp);
} else {
assert!(small.as_shot_temp == 6500.0 && small.as_shot_tint == 0.0, "{name}");
}
}
}
/// Sony ARWs without measured matrices read Kelvin from their white-balance presets
/// (`lightcraft_raw::kelvin_presets`): the ILCE-3500 sample was shot at 5600 K on the camera's Kelvin setting and
/// the ILCE-7S sample on its Shade preset (7500 K on Sony's scale); the SLT-A33's Auto white is within 20 K of its
/// Daylight preset. Header and decode agree. Skips cleanly without these CC0 corpus files.
#[test]
fn corpus_arw_white_balance_in_kelvin_through_the_presets() {
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw");
for (name, kelvin) in [("arw-sony-a3500-5600k.arw", 5600.0), ("arw-sony-a7s-shade.arw", 7500.0), ("arw-sony-a33.arw", 5311.0)] {
let Ok(bytes) = std::fs::read(dir.join(name)) else {
eprintln!("skip: {name} absent");
continue;
};
let header = crate::files::probe_bytes(name, &bytes).unwrap();
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
assert_eq!(header.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}");
assert!(info.raw && !info.relative_wb && info.legacy_relative_wb, "{name}: {info:?}");
assert!((info.as_shot_temp - kelvin).abs() <= 20.0 && info.as_shot_tint.abs() <= 2.0, "{name}: {} {}", info.as_shot_temp, info.as_shot_tint);
let camera = format!("SONY {}", lightcraft_meta::extract(&bytes).model.unwrap_or_default());
assert!(!lightcraft_catalog::relative_wb_camera("ARW", &camera), "{camera}");
}
}
#[test]
fn rejects_monochrome_invalid_and_unrelated_previews() {
let mut sensor = Rgb32f::new(32, 32);
let mut reference = sensor.clone();
for (i, p) in sensor.data.iter_mut().enumerate() {
*p = [0.1 + (i % 13) as f32 * 0.02, 0.15, 0.1];
}
reference.data.fill([0.2; 3]);
assert!(fit_pairs(&sensor, &reference).is_none());
reference.data.fill([f32::NAN; 3]);
assert!(fit_pairs(&sensor, &reference).is_none());
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
*src = [0.04 + (i % 11) as f32 * 0.02, 0.05 + (i % 17) as f32 * 0.01, 0.03 + (i % 23) as f32 * 0.01];
*dst = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01];
}
assert!(fit_pairs(&sensor, &reference).is_none());
sensor.data.fill([0.1, 0.15, 0.12]);
assert!(fit_pairs(&sensor, &reference).is_none());
reference.data.truncate(8);
assert!(fit_pairs(&sensor, &reference).is_none());
}
/// A busy scene whose JPEG is the camera curve of the scene two proxy pixels to the right (a
/// lens-corrected preview): the fitted curve follows the camera's and has no flat steps.
#[test]
fn tone_fit_has_no_steps_on_a_misregistered_jpeg() {
let camera = |x: f64| 0.6 * x.powf(0.55) / (1.0 + 0.2 * x);
let (w, h) = (96usize, 64usize);
let mut seed = 0x2545_f491_u32;
let mut scene = Vec::with_capacity(w * h);
for _ in 0..w * h {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
scene.push(0.01 * (5.0 * f64::from(seed >> 8) / f64::from(1u32 << 24)).exp());
}
let pairs: Vec<(f64, f64)> =
(0..h).flat_map(|y| (0..w - 2).map(move |x| (y * w + x, y * w + x + 2))).map(|(i, j)| (scene[i], camera(scene[j]))).collect();
let curve = fit_tone(pairs).unwrap();
let mut sorted = scene.clone();
sorted.sort_by(f64::total_cmp);
let (lo, hi) = (sorted[sorted.len() / 50], sorted[sorted.len() * 49 / 50]);
let mut x = lo;
while x < hi {
let (a, b) = (curve.apply(x as f32), curve.apply((x * 1.02) as f32));
assert!(b > a, "flat step at {x}: {a} -> {b}");
assert!((f64::from(a) / camera(x) - 1.0).abs() < 0.05, "at {x}: {a} vs {}", camera(x));
x *= 1.02;
}
}
/// A synthetic photo whose JPEG is the camera look of the scene scaled per pixel by noise of
/// `spread` (a pseudo-random, registration-free stand-in for local tone mapping): the per-bin
/// median recovers the curve, while the JPEG's broadened distribution stretches the quantile fit.
fn noisy_look(spread: f32) -> (Rgb32f, Rgb32f) {
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
let (w, h) = (96usize, 64usize);
let mut sensor = Rgb32f::new(w, h);
let mut reference = sensor.clone();
let mut seed = 0x9e37_79b9_u32;
let mut next = move || {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
f32::from((seed >> 16) as u16) / 32768.0 - 1.0
};
for (src, dst) in sensor.data.iter_mut().zip(&mut reference.data) {
let ev = 0.05 + (next() + 1.0) * 0.2;
*src = [ev * (0.8 + (next() + 1.0) * 0.1), ev, ev * (0.8 + (next() + 1.0) * 0.1)];
let p = known.apply_f32(*src);
let y = luminance_2020(p);
let scale = (1.0 - (-2.5 * y).exp()) / y * (1.0 + spread * next());
*dst = p.map(|v| v * scale);
}
(sensor, reference)
}
/// A camera rendering with constant colourfulness (no highlight bleaching) of a colourful scene
/// under a bright neutral sky, whose sensor pixels are clipped: one channel held at the clip
/// level turns the white sky magenta in the raw while the camera JPEG keeps it white. Returns
/// the sensor proxy, the JPEG proxy and the clip mask.
fn clipped_sky_scene() -> (Rgb32f, Rgb32f, Vec<bool>) {
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
let (w, h) = (96usize, 64usize);
let mut sensor = Rgb32f::new(w, h);
let mut reference = sensor.clone();
let mut clipped = vec![false; w * h];
let mut seed = 0x2545_f491_u32;
let mut next = move || {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
f32::from((seed >> 16) as u16) / 65536.0
};
let camera = |p: [f32; 3]| {
let y = luminance_2020(p);
let o = 1.0 - (-2.5 * y).exp();
p.map(|v| (v * o / y).clamp(0.0, 0.97))
};
// the sky: a quarter of the frame, white in the scene and in the JPEG
let neutral = known.inverse().unwrap().apply_f32([0.32; 3]);
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
let scene = if i < w * h / 4 {
clipped[i] = true;
let sky = neutral.map(|v| v * (0.8 + 0.4 * next()));
*dst = camera(known.apply_f32(sky));
// the green sample clips, red and blue (after white balance) go on rising
*src = [sky[0] * 1.35, sky[1] * 0.85, sky[2] * 1.4];
continue;
} else {
// colourful foliage, leaves, bark and bright flowers from dark to light
let ev = 0.02 + next() * 0.4;
[ev * (0.6 + next() * 0.8), ev, ev * (0.5 + next() * 0.8)]
};
*src = scene;
*dst = camera(known.apply_f32(scene));
}
(sensor, reference, clipped)
}
/// Mean colourfulness (distance from neutral of luminance-normalised RGB) of the unclipped
/// pixels rendered with `look`, over that of the camera JPEG, per display-luminance band.
fn colourfulness_ratio(look: &CameraLook, sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], band: std::ops::Range<f64>) -> f64 {
let chroma = |p: [f64; 3]| {
let y = luma(p);
p.iter().map(|v| (v / y - 1.0).powi(2)).sum::<f64>().sqrt()
};
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let (mut rendered, mut camera) = (0.0, 0.0);
for ((x, y), c) in sensor.data.iter().zip(&reference.data).zip(clipped) {
let y = y.map(f64::from);
if *c || !band.contains(&luma(y)) {
continue;
}
rendered += chroma(displayed(look.matrix.apply(x.map(f64::from)), &tone));
camera += chroma(y);
}
rendered / camera
}
/// Clipped sensor pixels are left out of the look's training pairs: a clipped white sky no longer
/// dulls the colour matrix or makes the chroma curve remove colour at the sky's brightness.
#[test]
fn clipped_sky_does_not_dull_the_look() {
let (sensor, reference, clipped) = clipped_sky_scene();
let old = fit_pairs(&sensor, &reference).expect("the earlier fit accepts the look");
let new = fit_look(&sensor, &reference, &clipped, None).expect("look without the clipped pixels");
let (mid, bright) = (0.1..0.35, 0.35..0.75);
let old_ratio = (
colourfulness_ratio(&old, &sensor, &reference, &clipped, mid.clone()),
colourfulness_ratio(&old, &sensor, &reference, &clipped, bright.clone()),
);
let new_ratio = (colourfulness_ratio(&new, &sensor, &reference, &clipped, mid), colourfulness_ratio(&new, &sensor, &reference, &clipped, bright));
// with the clipped sky in the pairs, the bright colours lose much of their colour
assert!(old_ratio.1 < 0.8, "earlier fit: colourfulness vs camera {old_ratio:?}, chroma {:?}", old.tone.chroma());
// without it, the render keeps the camera's colourfulness at every brightness
for r in [new_ratio.0, new_ratio.1] {
assert!((0.9..1.1).contains(&r), "colourfulness vs camera {new_ratio:?}, chroma {:?}", new.tone.chroma());
}
assert!(new.tone.chroma().iter().all(|k| *k > 0.85), "no colour cut: {:?}", new.tone.chroma());
}
/// When the accepted attempt misses the gates without the clipped pixels (here: too few pixels
/// left), the look fitted on all pixels is kept unchanged.
#[test]
fn clipped_fit_falls_back_to_all_pixels() {
let (sensor, reference, _) = clipped_sky_scene();
let all = fit_pairs(&sensor, &reference).unwrap();
// nearly everything clipped: too few pairs remain, the earlier look is used unchanged
let mut clipped = vec![true; sensor.data.len()];
clipped[..100].iter_mut().for_each(|c| *c = false);
let fallback = fit_look(&sensor, &reference, &clipped, None).expect("the fallback keeps the look");
assert_eq!((fallback.matrix.0, fallback.tone), (all.matrix.0, all.tone));
// nothing clipped: the same look as before, exactly
let none = fit_look(&sensor, &reference, &vec![false; sensor.data.len()], None).unwrap();
assert_eq!((none.matrix.0, none.tone), (all.matrix.0, all.tone));
// without clipped pixels the refit keeps the accepted attempt (here: all pixels, own colour, quantile tone)
let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false };
let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS, &check).unwrap();
assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false, dark: false });
assert!(without_clipped(&sensor, &[true; 3]).is_none(), "a mask of another size is ignored");
}
/// Quantile matching is tried first; the conditional-median fit is the fallback and is used
/// only where the quantile look misses the acceptance gates, so no photo loses its look.
#[test]
fn tone_fit_order_quantile_first_conditional_median_as_fallback() {
// Clean data: both fits pass, the default order uses the quantile curve (increasing).
let (sensor, reference) = noisy_look(0.0);
let default = fit_pairs(&sensor, &reference).expect("clean look");
let quantile = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::Quantile]).expect("quantile passes");
assert_eq!(default.tone, quantile.tone);
// Noisy data: the quantile fit misses the gates, the conditional median still passes.
let (sensor, reference) = noisy_look(0.33);
assert!(fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::Quantile]).is_none(), "quantile alone misses the gates");
let conditional = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::ConditionalMedian]).expect("conditional median passes");
let default = fit_pairs(&sensor, &reference).expect("the fallback keeps the look");
assert_eq!(default.tone, conditional.tone);
// The order matters only through the first pass; with the fits swapped the conditional one wins even on clean data.
let (sensor, reference) = noisy_look(0.0);
let swapped = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::ConditionalMedian, ToneFit::Quantile]).unwrap();
let conditional = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::ConditionalMedian]).unwrap();
assert_eq!(swapped.tone, conditional.tone);
}
/// The camera look of `scene` (the known matrix of these tests, then a bright camera curve).
fn bright_camera(p: [f32; 3]) -> [f32; 3] {
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
let q = known.apply_f32(p);
let y = luminance_2020(q);
if y <= 0.0 { [0.0; 3] } else { q.map(|v| v * (1.0 - (-4.0 * y).exp()) / y) }
}
/// Mean squared error (all channels, display-linear) of `render` against `target` over `scene`.
fn mean_error(scene: &Rgb32f, target: impl Fn([f32; 3]) -> [f32; 3], render: impl Fn([f64; 3]) -> [f64; 3]) -> f64 {
let error: f64 = scene
.data
.iter()
.map(|x| {
let (p, t) = (render(x.map(f64::from)), target(*x));
(0..3).map(|c| (p[c] - f64::from(t[c])).powi(2)).sum::<f64>()
})
.sum();
error / (3 * scene.data.len()) as f64
}
/// A colourful scene with fine detail everywhere (foliage) whose camera JPEG is lens-corrected:
/// barrel distortion moves the picture by up to about 4 proxy pixels toward the corners, so the
/// detail no longer pairs with itself and no global look passes the gates, on all pixels or
/// away from edges. The partial look still follows the camera's brightness, colourfulness and
/// hues: on the registered scene it is far closer to the camera than the neutral fallback,
/// which renders the scene dark and grey.
#[test]
fn partial_look_when_a_lens_corrected_jpeg_misses_the_gates() {
let (w, h) = (96usize, 64usize);
let scene = |u: f32, v: f32| -> [f32; 3] {
let base = 0.1 * (1.0 + 0.6 * (0.05 * u + 0.04 * v).sin());
let detail = 1.0 + 0.8 * (1.7 * u).sin() * (1.3 * v).sin();
let y = base * detail;
[y * (0.6 + 0.5 * (0.07 * u).sin().abs()), y, y * (0.5 + 0.6 * (0.09 * v + 0.03 * u).cos().abs())]
};
let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0);
let mut sensor = Rgb32f::new(w, h);
let mut reference = sensor.clone();
for y in 0..h {
for x in 0..w {
let (dx, dy) = (x as f32 - cx, y as f32 - cy);
let k = 1.0 + 0.08 * (dx * dx + dy * dy) / (cx * cx);
sensor.data[y * w + x] = scene(x as f32, y as f32);
reference.data[y * w + x] = bright_camera(scene(cx + dx * k, cy + dy * k));
}
}
assert!(fit_pairs(&sensor, &reference).is_none(), "the full look misses the gates");
let look = fit_look(&sensor, &reference, &vec![false; w * h], None).expect("a partial look instead of the neutral fallback");
assert!(look.hue_sat.is_none());
assert_ne!(look.matrix, Mat3::IDENTITY, "a colourful scene gets the damped matrix");
// on the registered scene, against what the camera renders
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let neutral = ToneMap::new(0.0, 0.0, 0.0);
let partial = mean_error(&sensor, bright_camera, |x| displayed(look.matrix.apply(x), &tone));
let fallback = mean_error(&sensor, bright_camera, |x| displayed(x, &neutral));
assert!(partial < fallback * 0.25, "partial {partial:.5}, neutral fallback {fallback:.5}");
assert!(tone.apply(0.05) < tone.apply(0.2) && tone.apply(0.2) < tone.apply(0.8));
}
fn near_black_scene() -> (Rgb32f, Rgb32f) {
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
for (i, (input, output)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
let y = if i % 60 == 0 { 0.3 + (i % 7) as f32 * 0.01 } else { 0.006 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.075 };
let rgb = [1.15, 1.0, 0.75];
*input = rgb.map(|v| v * y / luminance_2020(rgb));
*output = input.map(|v| v * 0.08);
}
(sensor, reference)
}
#[test]
fn a_near_black_scene_gets_tone_without_learning_colour() {
let (sensor, reference) = near_black_scene();
assert!(collect_pairs(&sensor, &reference, 0.005, None, false).is_none(), "too few midtones even for the partial look");
let mut raw = raw_of(RawFormat::Nef, "NIKON CORPORATION", "NIKON D610", Default::default());
let (_, _, look) = starting_colour_with(
&mut raw,
|_, _| (fit_look(&sensor, &reference, &vec![false; sensor.data.len()], None), false),
|_, _| None,
|_, _| fit_dark_tone(&sensor, &reference, &vec![false; sensor.data.len()]),
);
let look = look.expect("tone from the dark pixels");
assert_eq!(look.matrix, Mat3::IDENTITY);
assert!(look.hue_sat.is_none());
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let error = |tone: &ToneMap| {
sensor.data.iter().zip(&reference.data).map(|(x, y)| (luma(displayed(x.map(f64::from), tone)) - luma(y.map(f64::from))).powi(2)).sum::<f64>()
};
assert!(error(&tone) < error(&ToneMap::new(0.0, 0.0, 0.0)) * 0.001);
assert_eq!(look.tone.apply(0.0), 0.0);
let mut previous = 0.0;
for i in 0..10_000 {
let y = look.tone.apply(i as f32 / 10_000.0);
assert!(y.is_finite() && y >= previous && y <= 1.0);
previous = y;
}
// Models with spectral matrices keep them before the dark tone is fitted.
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default());
let (_, t, look) = starting_colour_with(
&mut raw,
|_, _| (None, false),
|_, _| None,
|raw, t| {
assert!(!t.matrix_is_fallback && lightcraft_raw::color::has_matrix(&raw.color));
fit_dark_tone(&sensor, &reference, &vec![false; sensor.data.len()])
},
);
assert!(!t.matrix_is_fallback);
assert_eq!(look.expect("tone on top of spectral colour").matrix, Mat3::IDENTITY);
}
#[test]
fn dark_tone_holdout_never_enters_training() {
let (sensor, mut reference) = near_black_scene();
let none = vec![false; sensor.data.len()];
let original = fit_dark_tone(&sensor, &reference, &none).expect("dark tone");
// Every pixel clears the wider signal floors. Changing only its held-out third must
// change the measured error, but cannot change the fitted curve.
for p in reference.data.iter_mut().step_by(3) {
*p = p.map(|v| v * 1.03);
}
let changed = fit_dark_tone(&sensor, &reference, &none).expect("still clearly better than the fallback");
assert_eq!(changed.tone, original.tone);
}
#[test]
fn dark_tone_requires_signal_correlation_and_unclipped_highlights() {
let (sensor, reference) = near_black_scene();
let none = vec![false; sensor.data.len()];
let mut unrelated = reference.clone();
for (i, p) in unrelated.data.iter_mut().enumerate() {
*p = reference.data[(i * 3571) % reference.data.len()];
}
assert!(fit_dark_tone(&sensor, &unrelated, &none).is_none(), "unrelated dark JPEG");
let mut flat = reference.clone();
flat.data.fill([0.002; 3]);
assert!(fit_dark_tone(&sensor, &flat, &none).is_none(), "a quantized flat JPEG cannot teach tone");
let mut sparse = reference.clone();
sparse.data.iter_mut().skip(80).for_each(|p| *p = [0.0; 3]);
assert!(fit_dark_tone(&sensor, &sparse, &none).is_none(), "fewer than 256 usable pairs");
let mut bright = reference.clone();
bright.data[0] = [0.95; 3];
assert!(fit_dark_tone(&sensor, &bright, &none).is_none(), "dark pairs cannot constrain a bright highlight");
let mut clipped = none;
clipped[0] = true;
let original = fit_dark_tone(&sensor, &reference, &clipped).expect("tone without the clipped pixel");
let mut changed = sensor.clone();
changed.data[0] = [0.18; 3];
let look = fit_dark_tone(&changed, &reference, &clipped).expect("the clipped pixel is still closer to its JPEG");
assert_eq!(look.tone, original.tone, "clipped samples cannot change the fitted curve");
let mut highlight = reference.clone();
highlight.data[0] = [0.8; 3];
assert!(fit_dark_tone(&changed, &highlight, &clipped).is_none(), "the curve cannot darken a clipped highlight away from the JPEG");
assert!(fit_dark_tone(&sensor, &Rgb32f::new(64, 96), &clipped).is_none(), "mismatched dimensions");
assert!(fit_dark_tone(&sensor, &reference, &[]).is_none(), "mismatched clip mask");
}
/// An overcast, nearly colourless scene (under 5% of the pixels coloured, so no colour matrix is
/// learnt and the full look is never tried) whose camera renders it two stops brighter than the
/// neutral fallback: the partial look is the tone curve (and chroma curve) alone.
#[test]
fn partial_look_brightens_a_dull_scene_without_learning_colour() {
let (w, h) = (96usize, 64usize);
let mut sensor = Rgb32f::new(w, h);
for (i, p) in sensor.data.iter_mut().enumerate() {
let ev = 0.01 + (i % 37) as f32 * 0.004;
let tint = if i % 50 == 0 { 1.3 } else { 1.0 + (i % 5) as f32 * 0.004 };
*p = [ev * tint, ev, ev * (2.0 - tint)];
}
let camera = |p: [f32; 3]| {
let y = luminance_2020(p);
p.map(|v| v * (1.0 - (-12.0 * y).exp()) / y)
};
let mut reference = sensor.clone();
reference.map_in_place(camera);
assert!(fit_pairs(&sensor, &reference).is_none(), "too little colour for the full look");
let look = fit_look(&sensor, &reference, &vec![false; w * h], None).expect("a partial look");
assert_eq!(look.matrix, Mat3::IDENTITY, "no colour matrix from a colourless scene");
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let neutral = ToneMap::new(0.0, 0.0, 0.0);
let lightness = |tone: &ToneMap| sensor.data.iter().map(|p| f64::from(tone.apply(luminance_2020(*p)))).sum::<f64>() / sensor.data.len() as f64;
let target = reference.data.iter().map(|p| f64::from(luminance_2020(*p))).sum::<f64>() / reference.data.len() as f64;
assert!((lightness(&tone) / target - 1.0).abs() < 0.05, "{} vs {target}", lightness(&tone));
assert!(lightness(&neutral) < target * 0.6, "the neutral fallback is far darker");
}
/// When the neutral fallback already matches the camera JPEG, or the JPEG is unrelated or
/// monochrome, the partial look isn't clearly better and the photo keeps the neutral fallback.
#[test]
fn partial_look_only_when_clearly_better_than_the_neutral_fallback() {
let (w, h) = (96usize, 64usize);
let mut sensor = Rgb32f::new(w, h);
for (i, p) in sensor.data.iter_mut().enumerate() {
let ev = 0.02 + (i % 41) as f32 * 0.012;
*p = [ev * (0.7 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.045)];
}
let none = vec![false; w * h];
// the camera renders exactly as the neutral fallback: nothing to gain
let neutral = ToneMap::new(0.0, 0.0, 0.0);
let mut reference = sensor.clone();
reference.map_in_place(|p| displayed(p.map(f64::from), &neutral).map(|v| v as f32));
let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false };
assert!(fit_partial(&sensor, &reference, &check).is_none(), "no clear gain over the fallback");
// an unrelated picture
for (i, p) in reference.data.iter_mut().enumerate() {
*p = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01];
}
assert!(fit_look(&sensor, &reference, &none, None).is_none(), "unrelated JPEG");
// a black-and-white JPEG of the scene
reference.data.iter_mut().zip(&sensor.data).for_each(|(r, s)| *r = [0.8 * luminance_2020(*s).sqrt(); 3]);
assert!(fit_look(&sensor, &reference, &none, None).is_none(), "monochrome JPEG");
}
/// A coloured scene whose camera JPEG is black and white: the camera's tone curve applied to the scene's
/// luminance, grey in every pixel.
fn black_and_white_scene() -> (Rgb32f, Rgb32f) {
let (w, h) = (96usize, 64usize);
let mut sensor = Rgb32f::new(w, h);
for (i, p) in sensor.data.iter_mut().enumerate() {
let ev = 0.01 + (i % 53) as f32 * 0.009;
*p = [ev * (0.6 + (i % 11) as f32 * 0.08), ev, ev * (0.6 + (i % 13) as f32 * 0.07)];
}
let mut reference = sensor.clone();
reference.map_in_place(|p| [1.0 - (-3.0 * luminance_2020(p)).exp(); 3]);
(sensor, reference)
}
#[test]
fn black_and_white_jpegs_are_recognised() {
let (sensor, mut reference) = black_and_white_scene();
assert!(monochrome(&reference));
assert!(!monochrome(&sensor), "a colour JPEG");
// a few coloured pixels (JPEG noise) are allowed, one in a hundred is colour
let n = reference.data.len();
reference.data.iter_mut().take(n / 1000).for_each(|p| p[0] += 0.05);
assert!(monochrome(&reference));
reference.data.iter_mut().take(n / 100).for_each(|p| p[0] += 0.05);
assert!(!monochrome(&reference));
// a sepia-toned monochrome
let (_, mut toned) = black_and_white_scene();
toned.map_in_place(|p| [p[0] * 1.08, p[1], p[2] * 0.85]);
assert!(!monochrome(&toned));
assert!(!monochrome(&Rgb32f::new(8, 8)), "too small to tell");
// the check reads pixels, not the picture style: a colour JPEG whose pixels are all grey within 0.002 counts
let (_, mut faint) = black_and_white_scene();
faint.map_in_place(|p| [p[0] + 0.001, p[1], p[2] - 0.0005]);
assert!(monochrome(&faint));
faint.map_in_place(|p| [p[0] + 0.003, p[1], p[2]]);
assert!(!monochrome(&faint));
// pixels that aren't numbers can't vouch for grey
let (_, mut broken) = black_and_white_scene();
broken.data.iter_mut().take(n / 100).for_each(|p| *p = [f32::NAN; 3]);
assert!(!monochrome(&broken));
}
/// Issue #577: a black-and-white JPEG gives no look (no colour to fit), but its tone curve renders the scene's
/// brightness like the camera, with the colour left as it was and the chroma curve neutral.
#[test]
fn a_black_and_white_jpeg_gives_its_tone_curve() {
let (sensor, reference) = black_and_white_scene();
let none = vec![false; sensor.data.len()];
assert!(fit_look(&sensor, &reference, &none, None).is_none(), "no colour to fit");
let look = fit_tone_only(&sensor, &reference, None).expect("a tone curve");
assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "the colour is left as it was");
assert!(look.tone.chroma().iter().all(|k| *k == 1.0), "the chroma curve stays neutral: {:?}", look.tone.chroma());
let n = sensor.data.len() as f64;
let mean = |tone: &ToneMap| sensor.data.iter().map(|p| luma(displayed(p.map(f64::from), tone))).sum::<f64>() / n;
let target = reference.data.iter().map(|p| f64::from(luminance_2020(*p))).sum::<f64>() / n;
let toned = mean(&ToneMap::camera(&look.tone, 0.0, 0.0, 0.0));
assert!((toned / target - 1.0).abs() < 0.05, "{toned} vs {target}");
assert!(mean(&ToneMap::new(0.0, 0.0, 0.0)) < target * 0.8, "the default curve is darker");
// with a camera profile's colour, the look carries it
let matrix = Mat3([[1.2, -0.15, -0.05], [-0.1, 1.15, -0.05], [-0.02, -0.1, 1.12]]);
let look = fit_tone_only(&sensor, &reference, Some((matrix, None))).expect("a tone curve with the profile's colour");
assert_eq!(look.matrix, matrix);
}
/// A camera profile's colour can fail the checks where the photo's own colour passes, so [`monochrome_look`] tries
/// again on the transform the photo has, as [`fit_look`] tries the photo's own colour after a profile's.
#[test]
fn a_rejected_profile_leaves_the_tone_curve_on_the_photos_own_colour() {
// saturated colours, and a profile whose colour gets their luminance wrong (red and blue swapped)
let hues = [[1.0, 0.15, 0.1], [0.1, 1.0, 0.15], [0.15, 0.1, 1.0], [1.0, 0.9, 0.1], [0.1, 0.9, 1.0], [1.0, 0.1, 0.9]];
let mut sensor = Rgb32f::new(96, 64);
for (i, p) in sensor.data.iter_mut().enumerate() {
let ev = 0.02 + (i % 47) as f32 * 0.008;
*p = hues[i % 6].map(|c| c * ev);
}
// a JPEG a little brighter than the default curve, so a curve has to fit closely to be clearly better
let neutral = ToneMap::new(0.0, 0.0, 0.0);
let mut reference = sensor.clone();
reference.map_in_place(|p| [(1.15 * luma(displayed(p.map(f64::from), &neutral))) as f32; 3]);
let swapped = Mat3([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]);
assert!(fit_tone_only(&sensor, &reference, Some((swapped, None))).is_none(), "the profile's colour fails the checks");
let own = fit_tone_only(&sensor, &reference, None);
assert!(own.is_some(), "the photo's own colour passes");
// and the step runs for a model with a bundled profile (the ILCE-7M4 also has spectral matrices)
assert!(crate::camera_profiles::get("ILCE-7M4").is_some());
let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default());
let (_, t, look) = starting_colour_with(
&mut raw,
|_, _| (None, true),
|_, t| {
assert!(!t.matrix_is_fallback, "on the spectral matrices");
own.clone()
},
|_, _| None,
);
assert!(look.is_some() && !t.matrix_is_fallback);
}
/// [`monochrome_look`] on a synthetic raw file: an ILCE-7M4 (it has a bundled camera profile) whose camera JPEG,
/// in a minimal TIFF, is black and white gets the tone curve, on the colour of the transform it is given; the
/// same file with a tinted JPEG gets nothing from this step.
#[test]
fn monochrome_look_reads_the_files_black_and_white_jpeg() {
use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, Rect};
let (w, h) = (192usize, 128usize);
let level = |x: usize, y: usize| 0.01 + 0.5 * (x as f32 / w as f32) * (0.3 + 0.7 * y as f32 / h as f32);
let raw = RawImage {
format: RawFormat::Arw,
width: w,
height: h,
cpp: 3,
data: RawData::F32((0..w * h).flat_map(|i| [level(i % w, i / w); 3]).collect()),
cfa: None,
bits: 16,
black: BlackLevel::uniform(0.0),
white: vec![1.0],
active_area: Rect::new(0, 0, w, h),
crop: Rect::new(0, 0, w, h),
orientation: Orientation::from_exif(1),
color: ColorData::default(),
wb_multipliers: Some([1.0; 3]),
linearized: true,
pixel_aspect: 1.0,
opcodes: OpcodeLists::default(),
metadata: lightcraft_meta::Metadata { make: Some("SONY".into()), model: Some("ILCE-7M4".into()), ..Default::default() },
lens_metadata: Default::default(),
};
assert!(crate::camera_profiles::get("ILCE-7M4").is_some());
// the camera's rendering: a bright curve on the scene's luminance, as a grey (or tinted) 8-bit sRGB JPEG
let file = |tint: [f32; 3]| {
let srgb = |v: f32| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 };
let pixels: Vec<u8> = (0..w * h)
.flat_map(|i| {
let v = 1.0 - (-3.0 * level(i % w, i / w)).exp();
tint.map(|t| (srgb(v * t).clamp(0.0, 1.0) * 255.0).round() as u8)
})
.collect();
let image = lightcraft_codecs::EncodeImage::new(w as u32, h as u32, 3, lightcraft_codecs::Samples::U8(&pixels));
let jpeg = lightcraft_codecs::encode_jpeg(&image, 95, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap();
// II*, IFD0 at 8 with JPEGInterchangeFormat (0x201) and its length (0x202), the JPEG after it
let mut tiff = b"II*\0\x08\0\0\0\x02\0".to_vec();
for (tag, value) in [(0x0201u16, 38u32), (0x0202, jpeg.len() as u32)] {
tiff.extend(tag.to_le_bytes());
tiff.extend(4u16.to_le_bytes());
tiff.extend(1u32.to_le_bytes());
tiff.extend(value.to_le_bytes());
}
tiff.extend(0u32.to_le_bytes());
assert_eq!(tiff.len(), 38);
tiff.extend(jpeg);
tiff
};
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
let look = monochrome_look(&raw, &file([1.0; 3]), &t, None).expect("a tone curve from the black-and-white JPEG");
assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "on the transform's colour");
let curve = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let mid = curve.apply(0.25);
assert!((mid - (1.0 - (-0.75f32).exp())).abs() < 0.05, "the camera's curve at 0.25: {mid}");
assert!(monochrome_look(&raw, &file([1.0, 0.9, 0.8]), &t, None).is_none(), "a tinted JPEG is not black and white");
}
/// The black-and-white fit has the partial look's checks: a JPEG of another picture, or one the default curve
/// already renders, gives no look.
#[test]
fn a_black_and_white_jpeg_gives_a_tone_curve_only_when_clearly_better() {
let (sensor, mut reference) = black_and_white_scene();
let neutral = ToneMap::new(0.0, 0.0, 0.0);
reference.data.iter_mut().zip(&sensor.data).for_each(|(r, s)| *r = [luma(displayed(s.map(f64::from), &neutral)) as f32; 3]);
assert!(monochrome(&reference));
assert!(fit_tone_only(&sensor, &reference, None).is_none(), "no clear gain over the default curve");
for (i, p) in reference.data.iter_mut().enumerate() {
*p = [0.05 + (i % 29) as f32 * 0.02; 3];
}
assert!(fit_tone_only(&sensor, &reference, None).is_none(), "another picture");
// a flat grey JPEG ranks nothing, even when the scene's pixels are stored in brightness order (with ties
// ranked in pixel order it looked perfectly related, and the curve flattened the photo to one grey)
let mut sorted = sensor.clone();
sorted.data.sort_by(|a, b| luminance_2020(*a).total_cmp(&luminance_2020(*b)));
reference.data.iter_mut().for_each(|p| *p = [0.4; 3]);
assert!(fit_tone_only(&sorted, &reference, None).is_none(), "a flat JPEG");
}
/// ILCE-7RM6 full-frame: the 66.5 MP camera JPEG exceeded the old 64 MP cap, so no look was fitted.
#[test]
fn corpus_arw6_full_frame_gets_a_camera_look() {
const { assert!(FIT_PREVIEW_MAX_PIXELS >= 100_000_000) };
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw/arw6-sony-ilce7rm6-ff-land-c.arw");
let Ok(bytes) = std::fs::read(&path) else {
eprintln!("skip: {} absent", path.display());
return;
};
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
assert!(info.camera_tone.is_some(), "no camera look fitted");
}
/// A smooth synthetic scene (sensor proxy) and its camera rendering, `dx` pixels to the right.
fn scene_pair(w: usize, h: usize, dx: usize) -> (Rgb32f, Rgb32f) {
let scene = |x: f32, y: f32| -> [f32; 3] {
let l = 0.03 + 0.4 * (0.5 + 0.5 * (x * 0.21).sin() * (y * 0.17).cos()) + 0.2 * ((x - 30.0).hypot(y - 20.0) < 12.0) as u8 as f32;
[l * (0.8 + 0.3 * (y / h as f32)), l, l * (1.1 - 0.4 * (x / w as f32))]
};
let camera = |p: [f32; 3]| {
let y = luminance_2020(p);
p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y)
};
let mut sensor = Rgb32f::new(w, h);
let mut reference = sensor.clone();
for y in 0..h {
for x in 0..w {
sensor.data[y * w + x] = scene(x as f32, y as f32);
reference.data[y * w + x] = camera(scene(x as f32 - dx as f32, y as f32));
}
}
(sensor, reference)
}
/// The structural gate accepts the same picture whatever its tone, and refuses a shifted, a broken (stripes), a
/// flat and a differently sized preview.
#[test]
fn dng_structure_gate_refuses_shifted_broken_and_unrelated_previews() {
let (sensor, reference) = scene_pair(96, 64, 0);
assert!(structure_agrees(&sensor, &reference));
assert!(structure_agrees(&sensor, &scene_pair(96, 64, 1).1), "one proxy pixel (lens correction) is tolerated");
assert!(!structure_agrees(&sensor, &scene_pair(96, 64, 3).1), "differently framed");
let mut stripes = reference.clone();
for (i, p) in stripes.data.iter_mut().enumerate() {
*p = if (i / 96) % 4 < 2 { [0.6, 0.1, 0.5] } else { [0.05, 0.4, 0.1] };
}
assert!(!structure_agrees(&sensor, &stripes), "a broken preview");
let mut flat = reference.clone();
flat.data.fill([0.2; 3]);
assert!(!structure_agrees(&sensor, &flat));
assert!(!structure_agrees(&sensor, &scene_pair(95, 64, 0).1));
}
/// With the file's colour given as the identity, only the tone is fitted: the colour stays and a camera that
/// renders mid-grey brighter than the default tone map is followed.
#[test]
fn dng_tone_fit_keeps_the_files_colour_and_follows_the_camera_tone() {
let (mut sensor, mut reference) = scene_pair(96, 64, 0);
for image in [&mut sensor, &mut reference] {
for p in image.data.iter_mut().step_by(5) {
*p = [p[0] * 1.6, p[1], p[2] * 0.5];
}
}
let colour = Some((Mat3::IDENTITY, None));
let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false };
let look =
fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }, &check)
.expect("tone fitted");
assert_eq!(look.matrix, Mat3::IDENTITY);
assert!(look.hue_sat.is_none());
let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
let target = 1.0 - (-2.5f32 * 0.18).exp();
assert!((fitted.apply(0.18) - target).abs() < 0.03, "{} vs {target}", fitted.apply(0.18));
assert!(fitted.apply(0.18) > default.apply(0.18) + 0.05, "{} vs default {}", fitted.apply(0.18), default.apply(0.18));
assert!(!clips_more(&sensor, &reference, &fitted, &fitted), "a tone never clips more than itself");
}
/// The highlight gate compares the share of near-white pixels of the fitted tone with the plain render's and the
/// camera preview's, in one direction only: clipping what the camera clips is not refused.
#[test]
fn dng_highlight_gate_compares_near_white_shares() {
let mut sensor = Rgb32f::new(32, 32);
for (i, p) in sensor.data.iter_mut().enumerate() {
*p = [0.02 + 1.5 * i as f32 / 1024.0; 3];
}
let (bright, dark) = (ToneMap::new(0.0, 1.0, 0.0), ToneMap::new(0.0, -1.0, 0.0));
let mut white = Rgb32f::new(32, 32);
white.data.iter_mut().for_each(|p| *p = [1.0; 3]);
let mut black = Rgb32f::new(32, 32);
black.data.iter_mut().for_each(|p| *p = [0.1; 3]);
assert!(clips_more(&sensor, &black, &bright, &dark), "clips more than the plain render and a preview without white");
assert!(!clips_more(&sensor, &black, &dark, &bright));
assert!(!clips_more(&sensor, &black, &bright, &bright));
assert!(!clips_more(&sensor, &white, &bright, &dark), "the camera preview clips as much");
}
/// A dark scene's shadows below the usual training floor still reach the tone-only fit.
#[test]
fn dng_tone_fit_trains_on_dark_pixels() {
let mut sensor = Rgb32f::new(64, 64);
let mut reference = Rgb32f::new(64, 64);
for (i, (s, r)) in sensor.data.iter_mut().zip(reference.data.iter_mut()).enumerate() {
let v = 0.0005 + 0.004 * (i % 64) as f32 / 64.0 + if i % 7 == 0 { 0.2 } else { 0.0 };
*s = [v, v * 1.1, v * 0.9];
*r = [(v * 3.0).min(0.9), (v * 3.3).min(0.9), (v * 2.7).min(0.9)];
}
let usual = collect_pairs(&sensor, &reference, 0.0, None, false).map_or(0, |p| p.0.len());
let dark = collect_pairs(&sensor, &reference, 0.0, None, true).map_or(0, |p| p.0.len());
assert!(dark > usual, "{dark} vs {usual}");
}
/// The embedded vignetting correction reaches the sensor proxy as the pipeline applies it to the render.
#[test]
fn dng_sensor_proxy_gets_the_embedded_vignetting_correction() {
use lightcraft_develop::{EmbeddedLens, EmbeddedVignette};
let lens = EmbeddedLens {
vignette: Some(EmbeddedVignette { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: lightcraft_geom::Point::new(0.5, 0.5), radius: 0.6 }),
..Default::default()
};
let mut proxy = Rgb32f::new(96, 64);
proxy.data.fill([0.2; 3]);
correct_vignetting(&mut proxy, Some(&lens), lightcraft_geom::Orientation::Normal);
let (centre, corner) = (proxy.data[32 * 96 + 48][1] / 0.2, proxy.data[0][1] / 0.2);
assert!((centre - 1.0).abs() < 0.01 && (1.9..2.0).contains(&corner), "centre {centre}, corner {corner}");
let mut plain = Rgb32f::new(96, 64);
plain.data.fill([0.2; 3]);
correct_vignetting(&mut plain, None, lightcraft_geom::Orientation::Normal);
assert!(plain.data.iter().all(|p| *p == [0.2; 3]));
}
/// Lens opcodes prevent mosaic binning. The DNG fit still gets a proxy, with the file's
/// full-resolution corrections applied once rather than silently losing the tone fit.
#[test]
fn dng_sensor_proxy_applies_lens_opcodes_before_downsampling() {
use lightcraft_raw::Opcode;
let bytes = crate::tests_xmp::synthetic_dng_with(None, Default::default());
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
raw.data = lightcraft_raw::RawData::U16(vec![4000; raw.width * raw.height]);
raw.opcodes.list3 = vec![Opcode::FixVignetteRadial { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }];
assert!(raw.develop_binned(2, SENSOR_CLIP).unwrap().is_none());
let proxy = sensor_proxy(&raw, 2, 32).expect("a corrected CFA proxy even when binning is unavailable");
assert_eq!((proxy.width, proxy.height), (32, 24));
let center = proxy.data[12 * 32 + 16][1];
let corner = proxy.data[0][1];
assert!((1.8..2.1).contains(&(corner / center)), "one vignetting gain, not zero or two: {corner} / {center}");
let full = raw.develop(lightcraft_raw::Method::Bilinear).unwrap();
assert_eq!(proxy.data, full.data, "the same opcode interpretation as raw development");
let small = sensor_proxy(&raw, 2, 16).unwrap();
assert_eq!(small.data, fit(&full, 16, 16, Filter::Box).data, "correct first, then reduce");
}
/// Which DNGs get a tone fitted to their camera JPEG: a colour matrix, a tone curve that is absent or the
/// identity, with or without a look table; and no other raw format.
#[test]
fn dng_tone_gates_follow_the_files_own_tags() {
use lightcraft_raw::profile::{HsvTable, ToneCurve};
let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]);
let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() };
let eligible = |format: RawFormat, color: lightcraft_raw::ColorData| {
let raw = raw_of(format, "Make", "Model", color);
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
dng_tone_eligible(&raw, &t)
};
assert!(eligible(RawFormat::Dng, color.clone()));
assert!(!eligible(RawFormat::Cr2, color.clone()), "other formats keep their own path");
assert!(!eligible(RawFormat::Dng, Default::default()), "no matrix, no colour to keep");
let mut identity = color.clone();
identity.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.5, 0.5, 1.0, 1.0]);
assert!(identity.profile.tone_curve.is_some() && eligible(RawFormat::Dng, identity), "an identity ProfileToneCurve is no tone");
let mut curved = color.clone();
curved.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]);
assert!(curved.profile.tone_curve.is_some() && !eligible(RawFormat::Dng, curved), "the file's own tone curve is kept");
let mut looked = color;
looked.profile.look_table = HsvTable::from_tags(&[1, 2, 1], &[0.0, 1.0, 1.0, 0.0, 1.0, 1.0], 0);
assert!(looked.profile.look_table.is_some() && eligible(RawFormat::Dng, looked), "a look table defines colour, not tone");
}
/// The 1024 px preview Lightroom Classic writes into a 7008 px DNG is fitted; a 256 px thumbnail is not.
#[test]
fn dng_tone_fit_takes_lightroom_sized_previews() {
let floor = |raw_long: f64| (MIN_PREVIEW_FRACTION * raw_long).max(384.0);
assert!(1024.0 >= floor(7008.0));
assert!(256.0 < floor(7008.0));
assert!(1024.0 >= floor(9504.0), "a 61 MP raw too");
}
/// Pairs whose scene luminance spans `ratio` from the darkest to the brightest pixel, mapped
/// to the JPEG through a steep camera curve (a hazy scene the camera stretches).
fn low_contrast_pairs(ratio: f64) -> Vec<(f64, f64)> {
(0..4096).map(|i| 0.08 * ratio.powf(i as f64 / 4095.0)).map(|x| (x, (0.2 + (x - 0.08) * 3.0).min(0.95))).collect()
}
/// The ratio the tone fits test: scene luminance at the middle of the brightest 1/32 over that
/// of the darkest 1/32 (their last and first knots).
fn knot_range(mut scene: Vec<f64>) -> f64 {
scene.sort_by(f64::total_cmp);
let n = scene.len();
scene[31 * n / 32 + n / 64] / scene[n / 64]
}
/// Issue #631: a low-contrast scene (its darker and brighter parts under 1.5× apart) still gets
/// a tone curve; only a nearly uniform one doesn't. Both tone fits use the same limit.
#[test]
fn tone_fits_accept_low_contrast_scenes() {
for fit in [fit_tone, fit_tone_conditional] {
let pairs = low_contrast_pairs(1.47);
let range = knot_range(pairs.iter().map(|p| p.0).collect());
assert!((1.4..1.5).contains(&range), "the scene sits just below the old limit of 1.5: {range}");
let curve = fit(pairs).expect("a low-contrast scene, as in the burst of issue #631");
assert!((curve.apply(0.1) - 0.26).abs() < 0.01, "follows the camera curve: {}", curve.apply(0.1));
assert!(fit(low_contrast_pairs(1.1)).is_none(), "a nearly uniform frame");
}
}
/// The same scene end to end: colour varies, luminance spans under 1.5×, and the camera
/// stretches it; the look is accepted and lands on the JPEG instead of the neutral fallback.
#[test]
fn a_low_contrast_scene_gets_a_camera_look() {
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
let ev = 0.06 + (i % 31) as f32 * 0.0008;
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
let p = known.apply_f32(*src);
let y = luminance_2020(p);
*dst = p.map(|v| v * (0.2 + (y - 0.07) * 3.0) / y);
}
let look = fit_pairs(&sensor, &reference).expect("a camera look, not the neutral fallback");
let range = knot_range(sensor.data.iter().map(|x| luma(look.matrix.apply(x.map(f64::from)))).collect());
assert!(range < 1.5, "the scene sits below the old limit of 1.5: {range}");
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let error: f64 = sensor
.data
.iter()
.zip(&reference.data)
.map(|(x, y)| {
let p = displayed(look.matrix.apply(x.map(f64::from)), &tone);
(0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::<f64>() / 3.0
})
.sum::<f64>()
/ sensor.data.len() as f64;
assert!(error.sqrt() < 0.02, "{}", error.sqrt());
}
/// Codex review of #631: a low-contrast scene's curve is fitted to its midtones only, and the
/// camera JPEG's bright areas (luminance 0.85 and up) are in neither the fit nor the held-out
/// gates. With 1.2 alone, this frame's curve passed the gates and rendered its bright sky
/// (camera JPEG 0.93) at about 0.28. The second review found the same through the retries'
/// filters: a clipped sky (left out by the refit without clipped pixels), scattered bright
/// pixels (left out away from edges) and sensor values over 1.5 (out of the pairs' range).
#[test]
fn a_narrow_curve_that_darkens_the_bright_areas_is_refused() {
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
for (case, sky_value, is_sky) in [
("bright sky", 0.9, &(|i: usize| i < 5600) as &dyn Fn(usize) -> bool),
("clipped sky", 1.0, &|i: usize| i < 5600),
("scattered bright pixels", 0.9, &|i: usize| i.is_multiple_of(8)),
("sky over the pairs' range", 1.6, &|i: usize| i < 5600),
] {
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
let mut clipped = vec![false; sensor.data.len()];
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
if is_sky(i) {
// bright, nearly white in the camera JPEG
*src = [sky_value; 3];
*dst = [0.93; 3];
clipped[i] = case == "clipped sky";
continue;
}
let ev = 0.06 + (i % 31) as f32 * 0.0008;
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
let p = known.apply_f32(*src);
let y = luminance_2020(p);
// nearly flat midtones: the fitted curve's extrapolation can't reach the sky
*dst = p.map(|v| v * (0.2 + (y - 0.07) * 0.05) / y);
}
let Some(look) = fit_look(&sensor, &reference, &clipped, None) else { continue };
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let correction = look.hue_sat.as_ref().and_then(HueSat::new);
let p = look.matrix.apply([f64::from(sky_value); 3]);
let p = correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from));
let sky = luma(displayed(p, &tone));
assert!(sky > 0.8, "{case}: an accepted look must not darken what the camera renders at 0.93: {sky}");
}
}
/// Third Codex review of #631: for a Sony photo shot with DRO, the fitted curve is lowered to
/// Sony's without DRO after the fit, so it must be checked as lowered. On this frame the
/// fitted curve keeps the sky (0.81 against the fallback's 0.79), and lowered it rendered the
/// sky at 0.61 where the camera JPEG has 0.93.
#[test]
fn a_narrow_curve_is_checked_as_lowered_for_dro() {
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
if i < 5600 {
*src = [0.9; 3];
*dst = [0.93; 3];
continue;
}
let ev = 0.06 + (i % 31) as f32 * 0.0008;
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
let p = known.apply_f32(*src);
let y = luminance_2020(p);
*dst = p.map(|v| v * (0.6 + (y - 0.07) * 0.25) / y);
}
let neutral = luma(displayed([0.9; 3], &ToneMap::new(0.0, 0.0, 0.0)));
if let Some(look) = fit_look_with(&sensor, &reference, &vec![false; sensor.data.len()], None, true) {
let look = with_dro_off_tone(look);
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let correction = look.hue_sat.as_ref().and_then(HueSat::new);
let p = look.matrix.apply([0.9; 3]);
let p = correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from));
let sky = luma(displayed(p, &tone));
assert!(sky > neutral - 0.02, "the DRO-lowered curve must not darken the sky past the fallback ({neutral}): {sky}");
}
}
/// A black-and-white JPEG of a low-contrast scene (issues #577 and #631): grey pixels, scene luminance spanning
/// under 1.5×, which the old range limit refused before any gate. Its tone curve is fitted.
#[test]
fn a_low_contrast_black_and_white_jpeg_gives_its_tone_curve() {
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
let ev = 0.06 + (i % 31) as f32 * 0.0008;
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
*dst = [0.2 + (luminance_2020(*src) - 0.06) * 3.0; 3];
}
let range = knot_range(sensor.data.iter().map(|x| luma(x.map(f64::from))).collect());
assert!(range < 1.5, "the scene sits below the old limit of 1.5: {range}");
let look = fit_tone_only(&sensor, &reference, None).expect("the JPEG's tone curve");
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let error = sensor.data.iter().zip(&reference.data).map(|(x, y)| (luma(displayed(x.map(f64::from), &tone)) - f64::from(y[0])).powi(2));
let rms = (error.sum::<f64>() / sensor.data.len() as f64).sqrt();
assert!(rms < 0.02, "{rms}");
}
/// [`a_narrow_curve_that_darkens_the_bright_areas_is_refused`] for a black-and-white JPEG: its tone curve is
/// fitted to nearly flat midtones under 1.5×, and the bright sky (camera JPEG 0.93) is left out of the fit.
#[test]
fn a_narrow_black_and_white_curve_that_darkens_the_bright_areas_is_refused() {
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
if i < 5600 {
*src = [0.9; 3];
*dst = [0.93; 3];
continue;
}
let ev = 0.06 + (i % 31) as f32 * 0.0008;
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
*dst = [0.2 + (luminance_2020(*src) - 0.06) * 0.05; 3];
}
if let Some(look) = fit_tone_only(&sensor, &reference, None) {
let sky = luma(displayed(look.matrix.apply([0.9; 3]), &ToneMap::camera(&look.tone, 0.0, 0.0, 0.0)));
assert!(sky > 0.8, "an accepted curve must not darken what the camera renders at 0.93: {sky}");
}
}