mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-30 04:57:22 -06:00
A custom white balance on a raw with a colour matrix (DNGs, and other raws without a file-local look) re-develops the pixels for the chosen white through the file's own colour model: lightcraft_raw::color::rebalance re-evaluates the camera transform at the new white and maps the as-shot development onto it, so a neutral lit by that white renders neutral and saturated colours move as the camera's matrices say (DNG spec ch. 6, as Lightroom does). The decoder passes the colour tags and the white the pixels were developed for in SourceInfo::camera_color; other photos, and relative white balances, keep the Bradford adaptation. The eyedropper and Auto invert the same model (color::neutral_white), so a picked grey card renders neutral. The Tint sign is main's (#349): unchanged here. Render cache version 15. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
438 lines
20 KiB
Rust
438 lines
20 KiB
Rust
use lightcraft_develop::{DevelopSettings, controls};
|
|
use lightcraft_raster::Rgb32f;
|
|
|
|
use crate::{RenderRequest, SourceInfo, render};
|
|
|
|
fn mean_luma(img: &lightcraft_raster::Rgba8) -> f32 {
|
|
img.data.iter().map(|p| 0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32).sum::<f32>() / img.len() as f32
|
|
}
|
|
|
|
fn scene() -> Rgb32f {
|
|
lightcraft_scenes::demo_library()[0].render(240, 160)
|
|
}
|
|
|
|
#[test]
|
|
fn default_render_is_sane() {
|
|
let r = render(&scene(), &SourceInfo::default(), &DevelopSettings::default(), &RenderRequest::fit(240, 240));
|
|
assert_eq!((r.image.width, r.image.height), (240, 160));
|
|
let m = mean_luma(&r.image);
|
|
assert!((40.0..220.0).contains(&m), "{m}");
|
|
assert!(r.histogram.total > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn grey_ramp_stays_neutral() {
|
|
let src = Rgb32f::from_fn(64, 8, |x, _| [0.002 * 1.12f32.powi(x as i32); 3]);
|
|
let r = render(&src, &SourceInfo::default(), &DevelopSettings::default(), &RenderRequest::fit(64, 8));
|
|
for p in &r.image.data {
|
|
assert!((p[0] as i32 - p[1] as i32).abs() <= 1 && (p[1] as i32 - p[2] as i32).abs() <= 1, "{p:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_slider_changes_or_keeps_output_without_panicking() {
|
|
let src = scene();
|
|
let base = render(&src, &SourceInfo::default(), &DevelopSettings::default(), &RenderRequest::fit(96, 96)).image;
|
|
for c in controls::CONTROLS {
|
|
for v in [c.min, c.max] {
|
|
let mut s = DevelopSettings::default();
|
|
controls::set(&mut s, c.id, v);
|
|
let r = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(96, 96));
|
|
assert!(r.image.width > 0, "{}", c.id);
|
|
let _ = &base;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn exposure_is_monotone() {
|
|
let src = scene();
|
|
let mut prev = -1.0;
|
|
for ev in [-3.0, -1.5, 0.0, 1.0, 2.5] {
|
|
let mut s = DevelopSettings::default();
|
|
s.light.exposure = ev;
|
|
let m = mean_luma(&render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(96, 96)).image);
|
|
assert!(m > prev, "{ev}: {m} <= {prev}");
|
|
prev = m;
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn directional_sliders() {
|
|
let src = scene();
|
|
let at = |id: &str, v: f64| {
|
|
let mut s = DevelopSettings::default();
|
|
controls::set(&mut s, id, v);
|
|
mean_luma(&render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(96, 96)).image)
|
|
};
|
|
let base = at("light.exposure", 0.0);
|
|
assert!(at("light.shadows", 100.0) > base);
|
|
assert!(at("light.highlights", -100.0) < base);
|
|
assert!(at("light.whites", 100.0) > base);
|
|
assert!(at("light.blacks", -100.0) < base);
|
|
assert!(at("vignette.amount", -100.0) < base);
|
|
assert!(at("vignette.amount", 100.0) > base);
|
|
assert!(at("effects.dehaze", -100.0) != base);
|
|
}
|
|
|
|
#[test]
|
|
fn resolution_independence_of_local_contrast() {
|
|
// Clarity at two preview sizes should give similar results after downscaling.
|
|
let src = lightcraft_scenes::demo_library()[3].render(480, 320);
|
|
let mut s = DevelopSettings::default();
|
|
s.effects.clarity = 80.0;
|
|
s.light.shadows = 60.0;
|
|
let small = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(120, 120)).image;
|
|
let big = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(480, 480)).image;
|
|
let bl = big.to_linear();
|
|
let down = lightcraft_raster::resample::resize(&bl, small.width, small.height, lightcraft_raster::resample::Filter::Box).to_srgb8();
|
|
let err: f32 = small.data.iter().zip(&down.data).map(|(a, b)| (a[1] as f32 - b[1] as f32).abs()).sum::<f32>() / small.len() as f32;
|
|
assert!(err < 9.0, "mean abs error {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn crop_and_straighten_output_size() {
|
|
let mut s = DevelopSettings::default();
|
|
s.crop.geometry = lightcraft_geom::crop_fit_angle(240.0, 160.0, 8.0, Some(1.0));
|
|
let r = render(&scene(), &SourceInfo::default(), &s, &RenderRequest::fit(200, 200));
|
|
assert_eq!((r.image.width, r.image.height), (200, 200));
|
|
}
|
|
|
|
#[test]
|
|
fn mask_brightens_only_inside() {
|
|
use lightcraft_develop::{Mask, MaskComponent, MaskOp, MaskShape};
|
|
use lightcraft_geom::Point;
|
|
let src = Rgb32f::filled(100, 100, [0.1, 0.1, 0.1]);
|
|
let mut s = DevelopSettings::default();
|
|
s.masks.push(Mask {
|
|
components: vec![MaskComponent {
|
|
name: None,
|
|
op: MaskOp::Add,
|
|
invert: false,
|
|
shape: MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.2, angle: 0.0, feather: 10.0, invert: false },
|
|
}],
|
|
adjust: lightcraft_develop::LocalAdjustments { exposure: 2.0, ..Default::default() },
|
|
..Default::default()
|
|
});
|
|
let r = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(100, 100)).image;
|
|
assert!(r.get(50, 50)[0] > r.get(5, 5)[0] + 40);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore]
|
|
fn perf_report() {
|
|
let src = lightcraft_scenes::demo_library()[0].render(3000, 2000);
|
|
let mut s = DevelopSettings::default();
|
|
s.light.shadows = 40.0;
|
|
s.effects.clarity = 20.0;
|
|
s.effects.dehaze = 10.0;
|
|
s.color.vibrance = 20.0;
|
|
let t = std::time::Instant::now();
|
|
let n = 5;
|
|
for _ in 0..n {
|
|
let _ = render(&src, &SourceInfo::default(), &s, &RenderRequest::fit(2560, 1440));
|
|
}
|
|
eprintln!("render 2160x1440 from 6 MP: {:.1} ms", t.elapsed().as_secs_f64() * 1000.0 / n as f64);
|
|
}
|
|
|
|
#[test]
|
|
fn unedited_rendered_source_is_passthrough() {
|
|
// An sRGB ramp decoded to linear Rec.2020 must render back to (almost) the same 8-bit values.
|
|
let src8 = lightcraft_raster::Rgba8::from_fn(256, 4, |x, y| [x as u8, (x as u8).wrapping_add(y as u8 * 40), 255 - x as u8, 255]);
|
|
let m = lightcraft_color::SRGB.to_space(&lightcraft_color::REC2020);
|
|
let src = src8.to_linear().map(|c| m.apply_f32(c));
|
|
let r = render(&src, &SourceInfo::default(), &DevelopSettings::default(), &RenderRequest::fit(256, 4)).image;
|
|
let mut worst = 0i32;
|
|
for (a, b) in src8.data.iter().zip(&r.data) {
|
|
for c in 0..3 {
|
|
worst = worst.max((a[c] as i32 - b[c] as i32).abs());
|
|
}
|
|
}
|
|
assert!(worst <= 3, "max error {worst}");
|
|
}
|
|
|
|
fn typical_edits() -> DevelopSettings {
|
|
let mut s = DevelopSettings::default();
|
|
s.light.exposure = 0.4;
|
|
s.light.highlights = -40.0;
|
|
s.light.shadows = 30.0;
|
|
s.effects.clarity = 15.0;
|
|
s.effects.texture = 10.0;
|
|
s.effects.dehaze = 12.0;
|
|
s.detail.nr_luminance = 30.0;
|
|
s.detail.nr_color = 25.0;
|
|
s.detail.sharpen_amount = 40.0;
|
|
s
|
|
}
|
|
|
|
fn max_diff(a: &lightcraft_raster::Rgba8, b: &lightcraft_raster::Rgba8) -> i32 {
|
|
assert_eq!((a.width, a.height), (b.width, b.height));
|
|
a.data.iter().zip(&b.data).map(|(p, q)| (0..3).map(|c| (p[c] as i32 - q[c] as i32).abs()).max().unwrap_or(0)).max().unwrap_or(0)
|
|
}
|
|
|
|
#[test]
|
|
fn stage_cache_matches_uncached_render_through_a_slider_session() {
|
|
use crate::{Quality, StageCache, render_cached};
|
|
use std::sync::Arc;
|
|
let src = Arc::new(scene());
|
|
let info = SourceInfo { raw: true, ..Default::default() };
|
|
let cache = StageCache::default();
|
|
let full = RenderRequest::fit(200, 200);
|
|
let draft = RenderRequest { quality: Quality::Draft, ..RenderRequest::fit(120, 120) };
|
|
let mut s = typical_edits();
|
|
// every kind of edit: tone, exposure, spatial amounts, NR, WB, crop, colour — at two sizes
|
|
let steps: Vec<Box<dyn Fn(&mut DevelopSettings)>> = vec![
|
|
Box::new(|s| s.light.contrast = 20.0),
|
|
Box::new(|s| s.light.exposure = -0.7),
|
|
Box::new(|s| s.light.highlights = -80.0),
|
|
Box::new(|s| s.effects.clarity = -30.0),
|
|
Box::new(|s| s.effects.dehaze = 0.0),
|
|
Box::new(|s| s.effects.dehaze = 25.0),
|
|
Box::new(|s| s.detail.nr_luminance = 60.0),
|
|
Box::new(|s| {
|
|
s.wb.mode = lightcraft_develop::WbMode::Custom;
|
|
s.wb.temp = 4000.0;
|
|
}),
|
|
Box::new(|s| s.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.1, 0.9, 0.8)),
|
|
Box::new(|s| s.color.saturation = 30.0),
|
|
// optics and geometry (merged after the stage cache): defringe lives in the cached linear stage
|
|
Box::new(|s| s.optics.defringe_purple_amount = 10.0),
|
|
Box::new(|s| s.optics.distortion = 30.0),
|
|
Box::new(|s| s.optics.remove_ca = true),
|
|
Box::new(|s| s.geometry.vertical = 20.0),
|
|
];
|
|
for (i, step) in steps.iter().enumerate() {
|
|
step(&mut s);
|
|
for req in [&full, &draft] {
|
|
let a = render_cached(&src, &info, &s, req, &cache).image;
|
|
let b = render(&src, &info, &s, req).image;
|
|
assert_eq!(a, b, "step {i} {:?}", req.quality);
|
|
}
|
|
}
|
|
assert_eq!(cache.len(), 2);
|
|
// both sizes' sampled + linear images at least (12 bytes per pixel each), nothing after clear()
|
|
let b = cache.bytes();
|
|
assert!(b >= 2 * 12 * (200 * 133 + 120 * 80), "{b}");
|
|
cache.clear();
|
|
assert_eq!(cache.bytes(), 0);
|
|
// a different source buffer never hits another source's entries
|
|
let other = Arc::new(Rgb32f::filled(src.width, src.height, [0.3, 0.3, 0.3]));
|
|
assert_eq!(render_cached(&other, &info, &s, &full, &cache).image, render(&other, &info, &s, &full).image);
|
|
}
|
|
|
|
#[test]
|
|
fn exposure_after_spatial_filters_equals_exposing_the_source() {
|
|
// The pipeline filters the image before exposure and applies exposure per pixel; filters on
|
|
// log luminance are shift-equivariant, so this must equal scaling the source.
|
|
let src = scene();
|
|
let info = SourceInfo { raw: true, ..Default::default() };
|
|
let mut s = typical_edits();
|
|
s.light.exposure = 1.3;
|
|
let a = render(&src, &info, &s, &RenderRequest::fit(200, 200)).image;
|
|
let g = 2f32.powf(1.3);
|
|
let scaled = src.map(|c| c.map(|v| v * g));
|
|
s.light.exposure = 0.0;
|
|
let b = render(&scaled, &info, &s, &RenderRequest::fit(200, 200)).image;
|
|
let d = max_diff(&a, &b);
|
|
assert!(d <= 2, "max difference {d}");
|
|
}
|
|
|
|
/// A section whose eye is off renders as if it were at its defaults (issue #316): Light, Color
|
|
/// and Detail, which the pipeline doesn't switch off where it applies them.
|
|
#[test]
|
|
fn sections_switched_off_render_as_defaults() {
|
|
let src = Rgb32f::from_fn(32, 24, |x, y| [0.05 + x as f32 / 40.0, 0.1 + y as f32 / 30.0, 0.3]);
|
|
let info = SourceInfo::default();
|
|
let req = RenderRequest::fit(32, 24);
|
|
let plain = render(&src, &info, &DevelopSettings::default(), &req).image;
|
|
let mut s = DevelopSettings::default();
|
|
s.light.exposure = 1.5;
|
|
s.light.contrast = 40.0;
|
|
s.color.saturation = -60.0;
|
|
s.color.vibrance = 30.0;
|
|
s.detail.sharpen_amount = 120.0;
|
|
let edited = render(&src, &info, &s, &req).image;
|
|
assert_ne!(edited.data, plain.data);
|
|
for section in ["light", "color", "detail"] {
|
|
s.set_section_enabled(section, false);
|
|
}
|
|
assert_eq!(render(&src, &info, &s, &req).image.data, plain.data, "every edited section off = the unedited photo");
|
|
// one section back on brings back only its own adjustments
|
|
s.set_section_enabled("light", true);
|
|
let mut light_only = DevelopSettings::default();
|
|
light_only.light.exposure = 1.5;
|
|
light_only.light.contrast = 40.0;
|
|
assert_eq!(render(&src, &info, &s, &req).image.data, render(&src, &info, &light_only, &req).image.data);
|
|
assert!(matches!(DevelopSettings::default().effective(), std::borrow::Cow::Borrowed(_)), "nothing is copied when every section is on");
|
|
}
|
|
|
|
#[test]
|
|
fn calibration_shifts_colours_but_keeps_greys() {
|
|
let info = SourceInfo { raw: true, ..Default::default() };
|
|
let req = RenderRequest::fit(64, 8);
|
|
// a grey ramp is untouched by the primaries
|
|
let grey = Rgb32f::from_fn(64, 8, |x, _| [0.002 * 1.12f32.powi(x as i32); 3]);
|
|
let mut s = DevelopSettings::default();
|
|
s.calibration.red_hue = 80.0;
|
|
s.calibration.green_sat = -60.0;
|
|
let a = render(&grey, &info, &DevelopSettings::default(), &req).image;
|
|
let b = render(&grey, &info, &s, &req).image;
|
|
assert!(max_diff(&a, &b) <= 1);
|
|
// a red patch rotates towards yellow (green rises relative to blue)
|
|
let red = Rgb32f::filled(16, 16, [0.3, 0.04, 0.03]);
|
|
let r0 = render(&red, &info, &DevelopSettings::default(), &RenderRequest::fit(16, 16)).image.data[0];
|
|
let r1 = render(&red, &info, &s, &RenderRequest::fit(16, 16)).image.data[0];
|
|
assert!(r1[1] as i32 - r1[2] as i32 > r0[1] as i32 - r0[2] as i32 + 5, "{r0:?} -> {r1:?}");
|
|
// shadows tint: magenta (+) lowers green in a dark grey
|
|
let dark = Rgb32f::filled(16, 16, [0.01, 0.01, 0.01]);
|
|
s = DevelopSettings::default();
|
|
s.calibration.shadows_tint = 100.0;
|
|
let d = render(&dark, &info, &s, &RenderRequest::fit(16, 16)).image.data[0];
|
|
assert!(d[1] + 3 < d[0], "{d:?}");
|
|
// the section toggle disables it
|
|
s.set_section_enabled("calibration", false);
|
|
let off = render(&dark, &info, &s, &RenderRequest::fit(16, 16)).image.data[0];
|
|
assert!(off[1].abs_diff(off[0]) <= 1, "{off:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn refine_saturation_tames_a_contrast_curve() {
|
|
let info = SourceInfo { raw: true, ..Default::default() };
|
|
let src = Rgb32f::filled(16, 16, [0.12, 0.05, 0.03]);
|
|
let req = RenderRequest::fit(16, 16);
|
|
let sat = |p: [u8; 4]| p[0] as i32 - p[2] as i32;
|
|
let mut s = DevelopSettings::default();
|
|
let flat = render(&src, &info, &s, &req).image.data[0];
|
|
s.curve.master = vec![
|
|
lightcraft_geom::Point::new(0.0, 0.0),
|
|
lightcraft_geom::Point::new(0.3, 0.15),
|
|
lightcraft_geom::Point::new(0.7, 0.85),
|
|
lightcraft_geom::Point::new(1.0, 1.0),
|
|
];
|
|
let full = render(&src, &info, &s, &req).image.data[0];
|
|
s.curve.refine_saturation = 0.0;
|
|
let refined = render(&src, &info, &s, &req).image.data[0];
|
|
assert!(sat(full) > sat(refined), "{flat:?} {full:?} {refined:?}");
|
|
// the curve's tone change stays
|
|
let y = |p: [u8; 4]| 0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32;
|
|
assert!((y(full) - y(refined)).abs() < 2.0);
|
|
}
|
|
|
|
#[test]
|
|
fn soft_proof_maps_into_the_proof_gamut_and_flags_what_does_not_fit() {
|
|
use crate::{OutputSpace, Proof};
|
|
let src = scene();
|
|
let mut s = DevelopSettings::default();
|
|
s.color.saturation = 100.0;
|
|
s.color.vibrance = 100.0;
|
|
let info = SourceInfo { raw: true, ..Default::default() };
|
|
let req = |space, proof| RenderRequest { space, proof, ..RenderRequest::fit(120, 120) };
|
|
let red = |img: &lightcraft_raster::Rgba8| img.data.iter().filter(|p| p[..3] == [255, 0, 0]).count();
|
|
let blue = |img: &lightcraft_raster::Rgba8| img.data.iter().filter(|p| p[2] == 255 && p[0] == 0 && p[1] < 80).count();
|
|
|
|
// proofing sRGB on an sRGB render changes nothing but the warning
|
|
let plain = render(&src, &info, &s, &req(OutputSpace::Srgb, None)).image;
|
|
let same = render(&src, &info, &s, &req(OutputSpace::Srgb, Some(Proof { space: OutputSpace::Srgb, ..Default::default() }))).image;
|
|
assert_eq!(plain, same);
|
|
let warned =
|
|
render(&src, &info, &s, &req(OutputSpace::Srgb, Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: false }))).image;
|
|
let n = red(&warned);
|
|
assert!(n > 0 && n < warned.len(), "{n}");
|
|
// …and nothing is flagged on an unsaturated edit of a neutral ramp
|
|
let grey = Rgb32f::from_fn(64, 8, |x, _| [0.002 * 1.12f32.powi(x as i32); 3]);
|
|
let g = render(
|
|
&grey,
|
|
&info,
|
|
&DevelopSettings::default(),
|
|
&RenderRequest { proof: Some(Proof { space: OutputSpace::Srgb, dest_warning: true, display_warning: true }), ..RenderRequest::fit(64, 8) },
|
|
);
|
|
assert_eq!(red(&g.image) + blue(&g.image), 0);
|
|
|
|
// a wide-gamut render proofed for sRGB loses the colours sRGB can't hold
|
|
let wide = render(&src, &info, &s, &req(OutputSpace::DisplayP3, None)).image;
|
|
let proofed = render(&src, &info, &s, &req(OutputSpace::DisplayP3, Some(Proof { space: OutputSpace::Srgb, ..Default::default() }))).image;
|
|
assert_ne!(wide, proofed);
|
|
// proofing a wider space than the display: what the display can't show is flagged blue
|
|
let pro =
|
|
render(&src, &info, &s, &req(OutputSpace::Srgb, Some(Proof { space: OutputSpace::ProPhoto, dest_warning: true, display_warning: true })))
|
|
.image;
|
|
assert!(blue(&pro) > 0);
|
|
assert!(red(&pro) < n, "ProPhoto holds more than sRGB");
|
|
}
|
|
|
|
#[test]
|
|
fn tint_negative_is_green_and_positive_is_magenta() {
|
|
use lightcraft_develop::WbMode;
|
|
let src = Rgb32f::filled(16, 16, [0.18; 3]);
|
|
// Rendered images, uncalibrated RAW and calibrated RAW with a nonzero As Shot tint.
|
|
for info in [
|
|
SourceInfo::default(),
|
|
SourceInfo { raw: true, relative_wb: true, ..Default::default() },
|
|
SourceInfo { raw: true, as_shot_temp: 4200.0, as_shot_tint: 15.0, ..Default::default() },
|
|
// camera-space white balance: the same sign through the camera's own colour model
|
|
SourceInfo { raw: true, as_shot_temp: 4200.0, as_shot_tint: 15.0, camera_color: Some(camera_color(4200.0, 15.0)), ..Default::default() },
|
|
] {
|
|
let mut s = DevelopSettings::default();
|
|
let neutral = render(&src, &info, &s, &RenderRequest::fit(16, 16)).image.get(8, 8);
|
|
assert!((neutral[0] as i16 - neutral[1] as i16).abs() <= 1);
|
|
assert!((neutral[2] as i16 - neutral[1] as i16).abs() <= 1);
|
|
s.wb.mode = WbMode::Custom;
|
|
s.wb.temp = info.as_shot_temp;
|
|
for delta in [-50.0, 50.0] {
|
|
s.wb.tint = info.as_shot_tint + delta;
|
|
let p = render(&src, &info, &s, &RenderRequest::fit(16, 16)).image.get(8, 8);
|
|
let magenta = (p[0] as f64 + p[2] as f64) / 2.0 - p[1] as f64;
|
|
assert!(magenta * delta > 100.0, "delta {delta} must follow the green/magenta track, got {p:?}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A dual-illuminant (A / D65) camera colour model, its pixels developed for `temp` / `tint`.
|
|
fn camera_color(temp: f64, tint: f64) -> std::sync::Arc<crate::CameraColor> {
|
|
use lightcraft_color::Mat3;
|
|
let tags = lightcraft_raw::ColorData {
|
|
illuminant: [17, 21],
|
|
color_matrix: [
|
|
Some(Mat3([[0.9, 0.2, -0.15], [-0.3, 1.25, 0.08], [0.02, -0.12, 0.85]])),
|
|
Some(Mat3([[0.7, 0.3, -0.1], [-0.35, 1.3, 0.1], [0.05, -0.2, 1.0]])),
|
|
],
|
|
..Default::default()
|
|
};
|
|
std::sync::Arc::new(crate::CameraColor { tags, developed_for: lightcraft_color::cct::temp_tint_to_xy(temp, tint) })
|
|
}
|
|
|
|
/// Camera-space white balance: a raw source with its colour model is re-developed for the chosen
|
|
/// white, so a grey card lit by that white renders neutral. Without the model, or with a relative
|
|
/// white balance, the developed pixels are adapted (Bradford, neutral luminance kept).
|
|
#[test]
|
|
fn custom_white_balance_redevelops_in_camera_space() {
|
|
use crate::local::wb_matrix_for;
|
|
use lightcraft_color::cct::temp_tint_to_xy;
|
|
use lightcraft_develop::WbMode;
|
|
let cc = camera_color(5500.0, 0.0);
|
|
let info = SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0, camera_color: Some(cc.clone()), ..Default::default() };
|
|
let mut s = DevelopSettings::default();
|
|
(s.wb.mode, s.wb.temp, s.wb.tint) = (WbMode::Custom, 3200.0, 10.0);
|
|
// a grey card lit by 3200 K / +10, as the camera records it, developed for the as-shot white
|
|
let a = lightcraft_raw::color::camera_transform_of(&cc.tags, cc.developed_for);
|
|
let n = lightcraft_raw::color::camera_neutral(&cc.tags, temp_tint_to_xy(3200.0, 10.0));
|
|
let card = a.matrix.apply(std::array::from_fn(|i| n[i] * a.wb[i] as f64)).map(|v| v as f32);
|
|
let apply = |m: [[f32; 3]; 3], c: [f32; 3]| -> [f32; 3] { std::array::from_fn(|r| m[r][0] * c[0] + m[r][1] * c[1] + m[r][2] * c[2]) };
|
|
let m = wb_matrix_for(&info, &s).unwrap();
|
|
let out = apply(m, card);
|
|
assert!(out.iter().all(|v| (v / out[1] - 1.0).abs() < 1e-4), "{card:?} -> {out:?}");
|
|
// the adaptation path for sources without a colour model, and for relative white balance
|
|
let luma = |c: [f32; 3]| lightcraft_color::luminance_2020(c);
|
|
for other in [SourceInfo { camera_color: None, ..info.clone() }, SourceInfo { relative_wb: true, ..info.clone() }] {
|
|
let b = wb_matrix_for(&other, &s).unwrap();
|
|
assert_ne!(b, m);
|
|
assert!((luma(apply(b, [1.0; 3])) - 1.0).abs() < 1e-4);
|
|
}
|
|
// the white-balance picker / auto white balance invert the same model: the card names its white
|
|
let picked = crate::auto::auto_wb(&Rgb32f::filled(4, 4, card), &info);
|
|
assert_eq!(picked, (3200.0, 10.0));
|
|
// the as-shot white needs nothing
|
|
s.wb.mode = WbMode::AsShot;
|
|
assert!(wb_matrix_for(&info, &s).is_none());
|
|
}
|