mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Fitted with `lightcraft-cli calibrate` to 144 usable ILCA-99M2 ARWs (of 150, seven shooting days, Standard creative style, DRO Auto) and their in-camera JPEGs; aggregate colour statistics only. On 30 withheld photos (profile fitted to the other 120) mean dE76 against the camera JPEG goes 5.11 -> 4.87; 25 of 30 improve, the worst regression is 0.24. All 150 photos stay accepted. The public raw.pixls.us ILCA-99M2 file, not used for fitting, stays accepted (5.57 -> 5.73). A corpus test checks that this public sample's fit keeps exactly the built-in profile's matrix and table; the ILCE-7CR test now shares its body. LOOK_VERSION 5 -> 6, as the profile changes the fit of this model.
3213 lines
167 KiB
Rust
3213 lines
167 KiB
Rust
//! Estimate the starting look of a raw without a camera colour matrix (Sony ARW, Nikon NEF, Panasonic
|
||
//! RW2, Fujifilm RAF, Canon CR2/CR3, Pentax PEF, Samsung SRW, Olympus ORF) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only,
|
||
//! never output pixels or a replacement for RAW editing.
|
||
//! A global matrix can't follow the camera's hue-dependent rendering (the best matrix rendered a
|
||
//! lime shirt olive that the camera kept lime): a hue/saturation table fitted to the residuals
|
||
//! (applied like a DNG `ProfileHueSatMap`) corrects that when it also improves the held-out pixels.
|
||
use lightcraft_color::{D50, D65, Mat3, PROPHOTO, REC2020, Xy, bradford, luminance_2020};
|
||
use lightcraft_pipeline::tone::{CameraTone, ToneMap};
|
||
use lightcraft_raster::{
|
||
Rgb32f,
|
||
resample::{Filter, fit, resize},
|
||
};
|
||
use lightcraft_raw::{
|
||
ColorData, RawFormat, RawImage,
|
||
color::CameraTransform,
|
||
profile::{HsvTable, ProfileLook, ToneCurve},
|
||
};
|
||
|
||
/// The version of the camera-look fit: how a raw's colour matrix, hue/saturation table and tone
|
||
/// and chroma curves are chosen. Smart previews record it in their header (`look_version`), because
|
||
/// they keep the curve and the colour the fit produced; one written by an older version is rebuilt
|
||
/// from its original when that is online, and kept as it is while it is offline.
|
||
///
|
||
/// **Bump this whenever a change makes the fit give a different result for the same file** (the
|
||
/// fit, its gates, the fallbacks, the camera profiles' effect on it). Do not bump it for changes
|
||
/// that leave the fitted look alone. `RENDER_CACHE_VERSION` covers renders; this covers proxies.
|
||
///
|
||
/// 1: the fit as of #499's follow-up; 2: Sony DRO (tone curve lowered to Sony's curve without DRO)
|
||
/// and the ILCE-7CR profile (#528, #583, #568, #616); 3: the camera JPEG read in the colour space the raw's EXIF
|
||
/// states, for every make (#778).
|
||
/// 4: a tone curve from black-and-white camera JPEGs (#577).
|
||
/// 5: dark JPEG luminance pairs for the camera toe (#1307).
|
||
/// 6: the ILCA-99M2 profile.
|
||
pub const LOOK_VERSION: u32 = 6;
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub(crate) struct CameraLook {
|
||
pub matrix: Mat3,
|
||
pub tone: CameraTone,
|
||
/// Hue/saturation correction after `matrix`, in linear ProPhoto RGB (DNG `ProfileHueSatMap`).
|
||
pub hue_sat: Option<HsvTable>,
|
||
}
|
||
|
||
/// Long edge of the sensor/JPEG proxy a single photo's look is fitted on (the acceptance gates
|
||
/// below were set at this size).
|
||
const PROXY: usize = 96;
|
||
/// Long edge of the proxies pooled for a camera profile: small objects (a shirt) get 4× the samples.
|
||
pub(crate) const PROFILE_PROXY: usize = 192;
|
||
|
||
/// Raw formats whose decoder supplies vendor white-balance multipliers but no camera colour matrix:
|
||
/// their starting look is fitted to the file's own JPEG, and white balance is relative to the
|
||
/// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the
|
||
/// same formats by file extension.
|
||
pub(crate) fn file_local_look(format: RawFormat) -> bool {
|
||
matches!(
|
||
format,
|
||
RawFormat::Arw
|
||
| RawFormat::Nef
|
||
| RawFormat::Nrw
|
||
| RawFormat::Rw2
|
||
| RawFormat::Raf
|
||
| RawFormat::Cr3
|
||
| RawFormat::Cr2
|
||
| RawFormat::Pef
|
||
| RawFormat::Srw
|
||
| RawFormat::Orf
|
||
)
|
||
}
|
||
|
||
/// The colour a raw starts from, first available first: the file's own colour matrices (DNG); a look fitted to the
|
||
/// file's JPEG ([`fit_preview`]: with the camera profile for its model when there is one, else with colour fitted to
|
||
/// this photo alone); the model's colour matrices from measured spectral sensitivities (`lightcraft_raw::spectral`);
|
||
/// the neutral fallback. The spectral matrices only stand in for the neutral fallback, so a photo whose JPEG fit is
|
||
/// accepted renders as without them; when used they are written into `raw.color`. A photo whose JPEG is black and
|
||
/// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's
|
||
/// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the
|
||
/// as-shot white, the camera transform at it and the fitted look.
|
||
pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8]) -> (Xy, CameraTransform, Option<CameraLook>) {
|
||
starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t), |raw, t| monochrome_look(raw, bytes, t))
|
||
}
|
||
|
||
/// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white
|
||
/// JPEG's tone fit passed in (tests pin the order with them).
|
||
fn starting_colour_with(
|
||
raw: &mut RawImage,
|
||
fit: impl FnOnce(&RawImage, &CameraTransform) -> (Option<CameraLook>, bool),
|
||
monochrome: impl FnOnce(&RawImage, &CameraTransform) -> Option<CameraLook>,
|
||
) -> (Xy, CameraTransform, Option<CameraLook>) {
|
||
let start = |raw: &RawImage| {
|
||
let xy = lightcraft_raw::color::as_shot_white_xy(raw);
|
||
(xy, lightcraft_raw::color::camera_transform(raw, xy))
|
||
};
|
||
let (xy, t) = start(raw);
|
||
if !t.matrix_is_fallback {
|
||
return (xy, t, None);
|
||
}
|
||
let (look, black_and_white) = fit(raw, &t);
|
||
if let Some(look) = look {
|
||
return (xy, t, Some(look));
|
||
}
|
||
let (xy, t) = match raw.metadata.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), model)) {
|
||
Some(camera) => {
|
||
camera.fill(&mut raw.color);
|
||
if lightcraft_pipeline::profiling() {
|
||
eprintln!("[profile] {:?} camera colour from spectral sensitivities: {} {}", raw.format, camera.make, camera.model);
|
||
}
|
||
start(raw)
|
||
}
|
||
None => (xy, t),
|
||
};
|
||
// only a black-and-white JPEG: a colour one that gave no look pays nothing more
|
||
let look = if black_and_white { monochrome(raw, &t) } else { None };
|
||
(xy, t, look)
|
||
}
|
||
|
||
/// The colour a raw starts from ([`starting_colour`]) as DNG colour tags, for a DNG of the raw's camera RGB (Photo
|
||
/// Merge, issue #965): without them a merge of CR2s, whose colour comes from a look fitted to their JPEG, rendered
|
||
/// green and dark. `None` when the file has colour matrices of its own (a DNG keeps them).
|
||
pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option<ColorData> {
|
||
if lightcraft_raw::color::has_matrix(&raw.color) {
|
||
return None;
|
||
}
|
||
let (_, t, look) = starting_colour(raw, bytes);
|
||
dng_colour(&raw.color, &t, look.as_ref())
|
||
}
|
||
|
||
/// DNG tags that render white-balanced camera RGB at its as-shot white as `t` with `look` on top does: one forward
|
||
/// matrix (with a colour matrix and as-shot neutral that agree with it at that white), the look's hue/saturation
|
||
/// table as `ProfileHueSatMap` and its tone curve as `ProfileToneCurve`. Not quite the same render: a DNG table
|
||
/// doesn't keep each pixel's luminance as the fitted one does ([`HueSat`]), and the chroma curve has no tag; on two
|
||
/// CC0 CR2s (EOS 550D, 40D) an HDR merge of three copies renders within mean CIE76 ΔE 1.07 and 0.47 of the photo
|
||
/// (1.47 and 0.66 without the table; 26 and 23 with no colour tags at all).
|
||
/// `None` when the white balance or the matrix can't give tags.
|
||
fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>) -> Option<ColorData> {
|
||
let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix));
|
||
// the DNG model renders the camera's white at unit brightness: what a look does to white's brightness goes
|
||
// into the baseline exposure
|
||
let white = m.apply([1.0; 3]);
|
||
let k = (white[0] + white[1] + white[2]) / 3.0;
|
||
if !(k.is_finite() && k > 1e-6) || !t.wb.iter().all(|w| w.is_finite() && *w > 0.0) {
|
||
return None;
|
||
}
|
||
let m = Mat3(m.0.map(|r| r.map(|v| v / k)));
|
||
let forward = bradford(D65, D50).mul(&REC2020.to_xyz()).mul(&m);
|
||
let wb = t.wb.map(f64::from);
|
||
let neutral = wb.map(|w| wb.iter().cloned().fold(f64::MAX, f64::min) / w);
|
||
let camera_to_xyz = bradford(D50, t.white_xy).mul(&forward).mul(&Mat3::diag(1.0 / neutral[0], 1.0 / neutral[1], 1.0 / neutral[2]));
|
||
Some(ColorData {
|
||
illuminant: [21, 0],
|
||
color_matrix: [Some(camera_to_xyz.inverse()?), None],
|
||
forward_matrix: [Some(forward), None],
|
||
camera_calibration: [None, None],
|
||
analog_balance: None,
|
||
as_shot_neutral: Some(neutral),
|
||
as_shot_white_xy: None,
|
||
baseline_exposure: t.baseline_exposure + k.log2(),
|
||
baseline_sharpness: tags.baseline_sharpness,
|
||
kelvin_presets: None,
|
||
profile: ProfileLook {
|
||
hue_sat_map: [look.and_then(|l| l.hue_sat.clone()), None],
|
||
tone_curve: look.and_then(|l| dng_tone_curve(&l.tone)),
|
||
..Default::default()
|
||
},
|
||
})
|
||
}
|
||
|
||
/// A camera tone curve as a DNG `ProfileToneCurve` (linear in, linear out, 1 = white): sampled densely over the
|
||
/// 12 stops below white, the range the curve is read back over (`files::dng_tone_curve`).
|
||
fn dng_tone_curve(tone: &CameraTone) -> Option<ToneCurve> {
|
||
const N: usize = 256;
|
||
let samples = (0..=N).map(|i| {
|
||
let x = 2f32.powf(-12.0 + 12.0 * i as f32 / N as f32);
|
||
[x, tone.apply(x).min(1.0)]
|
||
});
|
||
let flat: Vec<f64> = std::iter::once([0.0, 0.0]).chain(samples).flatten().map(f64::from).collect();
|
||
ToneCurve::from_tag(&flat)
|
||
}
|
||
|
||
/// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]).
|
||
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> (Option<CameraLook>, bool) {
|
||
if !transform.matrix_is_fallback || !file_local_look(raw.format) {
|
||
return (None, false);
|
||
}
|
||
let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY) else { return (None, false) };
|
||
let black_and_white = monochrome(&reference);
|
||
// A camera profile pooled from many photos knows colours this photo shows too little of;
|
||
// try its colour first and fit tone/chroma per photo (picture styles vary).
|
||
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 look = match fit_look(&sensor, &reference, &clipped, colour.clone()) {
|
||
Some(look) => look,
|
||
// A black-and-white JPEG has no colour to fit: the profile's colour and the JPEG's tone curve
|
||
// (`monochrome_look` does it without a profile).
|
||
None if colour.is_some() && black_and_white => {
|
||
let unclipped = without_clipped(&sensor, &clipped);
|
||
let Some(look) = fit_tone_only(unclipped.as_ref().unwrap_or(&sensor), &reference, colour) else { return (None, true) };
|
||
look
|
||
}
|
||
None => return (None, black_and_white),
|
||
};
|
||
// Sony's Dynamic Range Optimizer (on by default) brightens the camera JPEG's darker regions,
|
||
// not the raw: keep the colour fitted to the JPEG, take the tone curve without DRO.
|
||
let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true);
|
||
let look = if dro { with_dro_off_tone(look) } else { look };
|
||
if lightcraft_pipeline::profiling() {
|
||
eprintln!(
|
||
"[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile available {}, DRO tone replaced {dro}",
|
||
raw.format,
|
||
look.matrix.0,
|
||
look.tone,
|
||
look.hue_sat.is_some(),
|
||
profile.is_some()
|
||
);
|
||
}
|
||
(Some(look), black_and_white)
|
||
}
|
||
|
||
/// Sensor level (normalised, white = 1) at or above which a raw channel counts as clipped, as in
|
||
/// the render's highlight reconstruction.
|
||
const SENSOR_CLIP: f32 = 0.99;
|
||
|
||
/// The photo's look, refitted without the proxy pixels whose sensor values are clipped.
|
||
///
|
||
/// A clipped raw pixel no longer records the scene's colour: with one channel held at the clip
|
||
/// level while the others keep rising, a white overcast sky comes out magenta or blue through the
|
||
/// camera matrix, while the camera JPEG shows it white. As correspondences such pixels teach the
|
||
/// colour matrix to pull colours toward grey, and the chroma curve to remove colour at their
|
||
/// display luminance (on a Z 6II forest under a clipped sky the curve cut colourfulness above
|
||
/// display luminance 0.4 to 13–19%, and every one of its samples there was clipped). The render
|
||
/// rebuilds clipped highlights separately (`lightcraft_raw::highlight`), so the look is fitted on
|
||
/// what the sensor measured.
|
||
///
|
||
/// The search on all pixels decides, as before, whether the photo gets a look and which attempt
|
||
/// of [`search_ordered`] gives it; only that attempt is refitted without the clipped pixels, and
|
||
/// when the refit misses the acceptance gates the look on all pixels is kept. So no photo loses
|
||
/// its look and none switches attempt (a refit that just clears the gate on all pixels must not
|
||
/// replace a look fitted away from edges). A photo that had no look gets one when the search
|
||
/// without the clipped pixels finds it, and otherwise the [`fit_partial`] look when that is clearly
|
||
/// closer to the camera JPEG than the neutral fallback.
|
||
fn fit_look(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
|
||
let unclipped = without_clipped(sensor, clipped);
|
||
let Some((look, attempt)) = search_ordered(sensor, reference, colour.clone(), TONE_FITS) else {
|
||
return unclipped
|
||
.as_ref()
|
||
.and_then(|u| search_ordered(u, reference, colour, TONE_FITS))
|
||
.map(|(look, _)| look)
|
||
.or_else(|| fit_partial(unclipped.as_ref().unwrap_or(sensor), reference));
|
||
};
|
||
let Some(unclipped) = unclipped else { return Some(look) };
|
||
let refit = fit_attempt(&unclipped, reference, &colour, attempt);
|
||
if refit.is_none() && lightcraft_pipeline::profiling() {
|
||
eprintln!("[profile] camera look: no fit without the clipped pixels on the accepted attempt, keeping the fit on all pixels");
|
||
}
|
||
Some(refit.unwrap_or(look))
|
||
}
|
||
|
||
/// `sensor` with its clipped pixels set to NaN (left out of every training pair), or `None` when
|
||
/// none is clipped.
|
||
fn without_clipped(sensor: &Rgb32f, clipped: &[bool]) -> Option<Rgb32f> {
|
||
if clipped.len() != sensor.data.len() || !clipped.iter().any(|c| *c) {
|
||
return None;
|
||
}
|
||
let mut out = sensor.clone();
|
||
for (p, c) in out.data.iter_mut().zip(clipped) {
|
||
if *c {
|
||
*p = [f32::NAN; 3];
|
||
}
|
||
}
|
||
if lightcraft_pipeline::profiling() {
|
||
eprintln!(
|
||
"[profile] camera look: {} of {} proxy pixels have a clipped sensor channel",
|
||
clipped.iter().filter(|c| **c).count(),
|
||
clipped.len()
|
||
);
|
||
}
|
||
Some(out)
|
||
}
|
||
|
||
/// 1 where any channel of a camera-RGB pixel is at or above [`SENSOR_CLIP`], else 0.
|
||
fn clip_mask(camera: &Rgb32f) -> Rgb32f {
|
||
let mut mask = camera.clone();
|
||
mask.map_in_place(|p| if p.iter().any(|v| *v >= SENSOR_CLIP) { [1.0; 3] } else { [0.0; 3] });
|
||
mask
|
||
}
|
||
|
||
/// Sony's global tone curve with the Dynamic Range Optimizer off (Standard creative style), pooled
|
||
/// from 12 CC0 raw.pixls.us ARWs of 10 interchangeable-lens bodies and their camera JPEGs; the file
|
||
/// lists them and how the curve was derived (`assets/ATTRIBUTION.md`). Pooled the same way from 55
|
||
/// private ILCE-7CR photos with DRO off, the curve differs by at most 1.2 L* between scene luminance
|
||
/// 0.01 and 0.5. Issue #244, `docs/camera-preview-colour.md`.
|
||
const SONY_DRO_OFF_TONE: &str = include_str!("../../../assets/camera-tone/sony-dro-off.json");
|
||
|
||
/// [`SONY_DRO_OFF_TONE`]'s curve (`None` only if the built-in file were invalid; a test checks it).
|
||
fn sony_dro_off_tone() -> Option<CameraTone> {
|
||
#[derive(serde::Deserialize)]
|
||
struct File {
|
||
knots: [[f32; 2]; 32],
|
||
}
|
||
static TONE: std::sync::OnceLock<Option<CameraTone>> = std::sync::OnceLock::new();
|
||
*TONE.get_or_init(|| serde_json::from_str::<File>(SONY_DRO_OFF_TONE).ok().and_then(|f| CameraTone::new(f.knots)))
|
||
}
|
||
|
||
/// `look`, fitted to a camera JPEG brightened by Sony's Dynamic Range Optimizer, with its tone
|
||
/// curve lowered to Sony's without DRO ([`SONY_DRO_OFF_TONE`]) wherever that is darker. DRO only
|
||
/// brightens, so the fitted curve bounds the curve without it from above, everywhere: where Sony's
|
||
/// curve is brighter than the photo's own (the 1″ compacts map raw values darker), the photo keeps
|
||
/// its own. Its colour (matrix, hue/saturation table and chroma curve) stays as fitted.
|
||
fn with_dro_off_tone(look: CameraLook) -> CameraLook {
|
||
let Some(dro_off) = sony_dro_off_tone() else { return look };
|
||
let own = look.tone.knots();
|
||
let mut knots = own.map(|[x, y]| [x, y.min(dro_off.apply(x))]);
|
||
// Past its last knot a curve goes on as a shoulder whose rate comes from its last two knots
|
||
// (`CameraTone::apply`): lowering the second-last knot more than the last steepens it, and the
|
||
// extended highlights would end up above the fitted curve. Raise the second-last knot (never
|
||
// above the fitted curve) until the rate is at most the fitted curve's.
|
||
let ([a, b], last) = ([own[30], own[31]], knots[31]);
|
||
let rate = ((b[1] - a[1]) / (b[0] - a[0])).clamp(0.1, 16.0) / (1.0 - b[1]).max(0.01);
|
||
let floor = last[1] - rate * (1.0 - last[1]).max(0.01) * (b[0] - a[0]);
|
||
knots[30][1] = knots[30][1].max(floor).min(last[1]);
|
||
let Some(tone) = CameraTone::new(knots).and_then(|tone| tone.with_chroma(*look.tone.chroma())) else { return look };
|
||
if !at_most(&tone, &look.tone) {
|
||
return look;
|
||
}
|
||
CameraLook { tone, ..look }
|
||
}
|
||
|
||
/// Whether curve `a` is nowhere brighter than `b` over the scene luminances the finish stage's tone
|
||
/// table covers ([`ToneMap::camera`]).
|
||
fn at_most(a: &CameraTone, b: &CameraTone) -> bool {
|
||
use lightcraft_pipeline::tone::{GREY, LUT_MAX_EV, LUT_MIN_EV, LUT_N};
|
||
(0..LUT_N).all(|i| {
|
||
let x = GREY * 2f32.powf(LUT_MIN_EV + (LUT_MAX_EV - LUT_MIN_EV) * i as f32 / (LUT_N - 1) as f32);
|
||
a.apply(x) <= b.apply(x) + 1e-5
|
||
})
|
||
}
|
||
|
||
/// Pixel cap for the embedded JPEG decoded for the fit. The codec checks it before decoding and
|
||
/// decodes at full size, so a 66.5 MP full-frame ILCE-7RM6 JPEG transiently needs ~200 MB (3 B/px);
|
||
/// 128 MP also admits 100 MP medium-format previews.
|
||
const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000;
|
||
/// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s
|
||
/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020),
|
||
/// and per proxy pixel whether any sensor sample it covers is clipped.
|
||
fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> {
|
||
let edge = (2 * size).max(384) as u32;
|
||
let decoded = crate::files::decode_raw_preview(
|
||
bytes,
|
||
lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: FIT_PREVIEW_MAX_PIXELS },
|
||
)?;
|
||
let mut reference = decoded.to_working();
|
||
let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height));
|
||
if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 {
|
||
return None;
|
||
}
|
||
let matches = |w: usize, h: usize, rw: usize, rh: usize| (rw as f64 / rh as f64 / (w as f64 / h as f64) - 1.0).abs() <= 0.02;
|
||
if !matches(crop.width, crop.height, reference.width, reference.height) {
|
||
// Panasonic previews show the whole active area while the default crop is the in-camera aspect ratio
|
||
if !matches(a.width, a.height, reference.width, reference.height) {
|
||
return None;
|
||
}
|
||
let (sx, sy) = (reference.width as f64 / a.width as f64, reference.height as f64 / a.height as f64);
|
||
let (x, y) = ((crop.x as f64 * sx).round() as usize, (crop.y as f64 * sy).round() as usize);
|
||
let (w, h) = ((crop.width as f64 * sx).round() as usize, (crop.height as f64 * sy).round() as usize);
|
||
if w < 16 || h < 16 || x + w > reference.width || y + h > reference.height {
|
||
return None;
|
||
}
|
||
reference = reference.into_crop(x, y, w, h);
|
||
}
|
||
// Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution.
|
||
let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2;
|
||
let mut sensor = sensor_proxy(raw, k, edge as usize)?;
|
||
let mut clipped = clip_mask(&sensor);
|
||
let gain = 2f32.powf(transform.baseline_exposure as f32);
|
||
let to_working = |p: [f32; 3]| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain));
|
||
if raw.format == RawFormat::Cr3 {
|
||
// Orient the camera JPEG and the sensor identically before collecting pixel correspondences.
|
||
sensor = sensor.into_oriented(raw.orientation);
|
||
clipped = clipped.into_oriented(raw.orientation);
|
||
reference = reference.into_oriented(raw.orientation);
|
||
sensor.map_in_place(to_working);
|
||
// Canon's JPEG can be cropped differently from the sensor. Estimate only that common
|
||
// framing from edge directions, independently of the subsequent colour fit.
|
||
(sensor, clipped) = align_cr3_framing(sensor, clipped, &reference);
|
||
}
|
||
let mut sensor = fit(&sensor, size, size, Filter::Box);
|
||
let reference = reference_at(&reference, &sensor);
|
||
// A proxy pixel is clipped when any sample it averages is (outside the CR3 framing the mask is
|
||
// NaN: those pixels are left out of the pairs anyway). Resized to exactly the proxy's size, like
|
||
// the reference, so the mask pairs pixel for pixel.
|
||
let clipped: Vec<bool> = resize(&clipped, sensor.width, sensor.height, Filter::Box).data.iter().map(|p| p[0] > 0.0).collect();
|
||
if raw.format != RawFormat::Cr3 {
|
||
sensor.map_in_place(to_working);
|
||
}
|
||
Some((sensor, reference, clipped))
|
||
}
|
||
|
||
/// The camera JPEG at exactly the sensor proxy's size, so the two pair pixel for pixel. Fitting it
|
||
/// into the proxy's box instead keeps its own aspect, which can round a pixel short: an ILCE-7RM4's
|
||
/// 1616×1080 preview, decoded at 384×257, fits a 192×128 proxy as 191×128, and `collect_pairs`
|
||
/// then rejected every file `lightcraft-cli calibrate` was given. `proxies` has already checked
|
||
/// that the aspects agree within 2 %.
|
||
fn reference_at(reference: &Rgb32f, sensor: &Rgb32f) -> Rgb32f {
|
||
resize(reference, sensor.width, sensor.height, Filter::Box)
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug)]
|
||
struct Cr3Framing {
|
||
scale: f32,
|
||
/// Translation as a fraction of the oriented frame's width and height.
|
||
offset: [f32; 2],
|
||
}
|
||
|
||
impl Cr3Framing {
|
||
const IDENTITY: Self = Self { scale: 1.0, offset: [0.0; 2] };
|
||
|
||
fn source(self, x: f32, y: f32, w: usize, h: usize) -> (f32, f32) {
|
||
let (w, h) = (w as f32, h as f32);
|
||
((x - w * 0.5) * self.scale + w * (0.5 + self.offset[0]), (y - h * 0.5) * self.scale + h * (0.5 + self.offset[1]))
|
||
}
|
||
}
|
||
|
||
/// Register the JPEG's remaining framing on a fixed proxy. Edge directions survive different
|
||
/// white balance and monotone camera tone; no target RGB values train this three-parameter fit.
|
||
/// A separate third of the edges must confirm a substantial improvement and a close match.
|
||
/// A weak, flat or unrelated preview leaves the sensor framing unchanged.
|
||
fn estimate_cr3_framing(sensor: &Rgb32f, reference: &Rgb32f) -> Option<Cr3Framing> {
|
||
if sensor.width.checked_mul(sensor.height)? != sensor.data.len() || reference.width.checked_mul(reference.height)? != reference.data.len() {
|
||
return None;
|
||
}
|
||
let sensor = fit(sensor, PROXY, PROXY, Filter::Box);
|
||
let (w, h) = (sensor.width, sensor.height);
|
||
if w < 16 || h < 16 {
|
||
return None;
|
||
}
|
||
let reference = fit(reference, w, h, Filter::Box);
|
||
if (reference.width, reference.height) != (w, h) {
|
||
return None;
|
||
}
|
||
let mut gradients = Rgb32f::new(w, h);
|
||
let mut edges = Vec::new();
|
||
for y in 1..h - 1 {
|
||
for x in 1..w - 1 {
|
||
let derivative = |image: &Rgb32f| {
|
||
[
|
||
luminance_2020(image.data[y * w + x + 1]) - luminance_2020(image.data[y * w + x - 1]),
|
||
luminance_2020(image.data[(y + 1) * w + x]) - luminance_2020(image.data[(y - 1) * w + x]),
|
||
]
|
||
};
|
||
let d = derivative(&sensor);
|
||
gradients.data[y * w + x] = [d[0], d[1], 0.0];
|
||
let d = derivative(&reference);
|
||
let magnitude = d[0].hypot(d[1]);
|
||
let value = luminance_2020(reference.data[y * w + x]);
|
||
if (0.02..0.9).contains(&value) && magnitude.is_finite() && magnitude > 0.03 {
|
||
edges.push((x as f32 + 0.5, y as f32 + 0.5, d, magnitude, (x * 17 + y * 11) % 3 == 0));
|
||
}
|
||
}
|
||
}
|
||
if edges.len() < 192 {
|
||
return None;
|
||
}
|
||
let score = |framing: Cr3Framing, held_out: bool| -> Option<f64> {
|
||
let (mut agreement, mut weight, mut valid, mut total) = (0.0, 0.0, 0usize, 0usize);
|
||
for &(x, y, target, magnitude, holdout) in &edges {
|
||
if holdout != held_out {
|
||
continue;
|
||
}
|
||
total += 1;
|
||
let (sx, sy) = framing.source(x, y, w, h);
|
||
if sx < 1.5 || sy < 1.5 || sx > w as f32 - 1.5 || sy > h as f32 - 1.5 {
|
||
continue;
|
||
}
|
||
let sample = gradients.sample_bilinear(sx, sy);
|
||
let length = sample[0].hypot(sample[1]);
|
||
if !length.is_finite() || length <= 0.01 {
|
||
continue;
|
||
}
|
||
let k = f64::from(magnitude.min(0.25));
|
||
agreement += k * f64::from((sample[0] * target[0] + sample[1] * target[1]) / (length * magnitude));
|
||
weight += k;
|
||
valid += 1;
|
||
}
|
||
(valid >= 64 && valid * 5 >= total * 4 && weight > 0.0).then_some(agreement / weight)
|
||
};
|
||
let before = score(Cr3Framing::IDENTITY, false)?;
|
||
let mut best = (before, Cr3Framing::IDENTITY);
|
||
// At most 10% zoom and two proxy pixels of translation; a coarse search followed by one
|
||
// local refinement prevents unbounded optimisation on the photograph's contents.
|
||
for s in -10..=10 {
|
||
for tx in -4..=4 {
|
||
for ty in -4..=4 {
|
||
let framing = Cr3Framing { scale: 1.0 + s as f32 * 0.01, offset: [tx as f32 * 0.5 / w as f32, ty as f32 * 0.5 / h as f32] };
|
||
if let Some(value) = score(framing, false)
|
||
&& value > best.0
|
||
{
|
||
best = (value, framing);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let coarse = best.1;
|
||
for s in -5..=5 {
|
||
for tx in -4..=4 {
|
||
for ty in -4..=4 {
|
||
let framing = Cr3Framing {
|
||
scale: coarse.scale + s as f32 * 0.002,
|
||
offset: [coarse.offset[0] + tx as f32 * 0.125 / w as f32, coarse.offset[1] + ty as f32 * 0.125 / h as f32],
|
||
};
|
||
if !(0.9..=1.1).contains(&framing.scale) || framing.offset[0].abs() > 2.0 / w as f32 || framing.offset[1].abs() > 2.0 / h as f32 {
|
||
continue;
|
||
}
|
||
if let Some(value) = score(framing, false)
|
||
&& value > best.0
|
||
{
|
||
best = (value, framing);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let (held_before, held_after) = (score(Cr3Framing::IDENTITY, true)?, score(best.1, true)?);
|
||
if best.0 < 0.9 || best.0 < before + 0.04 || held_after < 0.9 || held_after < held_before + 0.04 {
|
||
return None;
|
||
}
|
||
if lightcraft_pipeline::profiling() {
|
||
eprintln!("[profile] CR3 JPEG framing {:?}, held-out edge agreement {held_before:.4} -> {held_after:.4}", best.1);
|
||
}
|
||
Some(best.1)
|
||
}
|
||
|
||
/// The sensor proxy and its clip mask (same size), both moved to the camera JPEG's framing.
|
||
fn align_cr3_framing(sensor: Rgb32f, clipped: Rgb32f, reference: &Rgb32f) -> (Rgb32f, Rgb32f) {
|
||
let Some(framing) = estimate_cr3_framing(&sensor, reference) else { return (sensor, clipped) };
|
||
let apply = |image: &Rgb32f| {
|
||
let (w, h) = (image.width, image.height);
|
||
let mut aligned = Rgb32f::new(w, h);
|
||
for (i, pixel) in aligned.data.iter_mut().enumerate() {
|
||
let (x, y) = framing.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h);
|
||
*pixel = if x >= 0.5 && y >= 0.5 && x <= w as f32 - 0.5 && y <= h as f32 - 0.5 { image.sample_bilinear(x, y) } else { [f32::NAN; 3] };
|
||
}
|
||
aligned
|
||
};
|
||
(apply(&sensor), apply(&clipped))
|
||
}
|
||
|
||
/// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the
|
||
/// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for formats with their own
|
||
/// colour matrices, other files or unusable previews.
|
||
pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option<Vec<([f64; 3], [f64; 3])>> {
|
||
if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) {
|
||
return None;
|
||
}
|
||
let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw));
|
||
let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY)?;
|
||
let to_camera = transform.matrix.inverse()?;
|
||
// clipped sensor pixels don't record the scene's colour (see `fit_look`)
|
||
let sensor = without_clipped(&sensor, &clipped).unwrap_or(sensor);
|
||
let (pairs, _) = collect_pairs(&sensor, &reference, 0.05, None)?;
|
||
Some(pairs.into_iter().map(|(x, y)| (to_camera.apply(x), y)).collect())
|
||
}
|
||
|
||
/// A camera profile's colour model (matrix from white-balanced camera RGB, hue/saturation table)
|
||
/// fitted to pairs pooled from many photos.
|
||
pub(crate) fn fit_profile(pairs: &[([f64; 3], [f64; 3])]) -> Option<(Mat3, Option<HsvTable>)> {
|
||
let matrix = fit_matrix(pairs)?;
|
||
Some((matrix, fit_hue_sat(pairs, &matrix)))
|
||
}
|
||
|
||
fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option<Rgb32f> {
|
||
Some(match raw.develop_binned(k, SENSOR_CLIP).ok()? {
|
||
Some(sensor) => sensor,
|
||
None if raw.cpp == 3 && raw.cfa.is_none() => fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box),
|
||
None => return None,
|
||
})
|
||
}
|
||
|
||
/// A fit must cut the held-out squared error to below this share of the fallback's.
|
||
const MIN_IMPROVEMENT: f64 = 0.7;
|
||
/// ...and stay within this per-channel RMS of the camera JPEG (linear display values). On public
|
||
/// raw.pixls.us samples (eight Sony bodies) good fits that still beat the fallback 1.5–3× landed
|
||
/// at 0.065–0.093 (camera local tone, vignetting and lens processing that a global matrix + curve
|
||
/// cannot follow): visibly better renders that 0.055 rejected.
|
||
const MAX_HOLDOUT_RMS: f64 = 0.10;
|
||
|
||
fn luma(p: [f64; 3]) -> f64 {
|
||
p[0] * 0.2627 + p[1] * 0.6780 + p[2] * 0.0593
|
||
}
|
||
|
||
/// The finish stage's tone map and chroma curve (`lightcraft_pipeline::finish`).
|
||
fn displayed(scene: [f64; 3], tone: &ToneMap) -> [f64; 3] {
|
||
let scene = scene.map(|v| v.max(0.0));
|
||
let y = luma(scene);
|
||
if y <= 0.0 {
|
||
return [0.0; 3];
|
||
}
|
||
let o = f64::from(tone.apply(y as f32));
|
||
let k = f64::from(tone.chroma_scale(o as f32));
|
||
scene.map(|v| o + (v * o / y - o) * k)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
|
||
fit_pairs_with(sensor, reference, None)
|
||
}
|
||
|
||
/// Largest luminance ratio within the 3×3 neighbourhood of pixel `i` (1 on flat areas, large on edges).
|
||
fn local_contrast(image: &Rgb32f, i: usize) -> f32 {
|
||
let (w, h) = (image.width, image.height);
|
||
if w == 0 {
|
||
return f32::INFINITY;
|
||
}
|
||
let (x, y) = (i % w, i / w);
|
||
let (mut lo, mut hi) = (f32::INFINITY, 0f32);
|
||
for row in y.saturating_sub(1)..(y + 2).min(h) {
|
||
for col in x.saturating_sub(1)..(x + 2).min(w) {
|
||
let l = image.data.get(row * w + col).map_or(0.0, |p| luminance_2020(*p));
|
||
lo = lo.min(l);
|
||
hi = hi.max(l);
|
||
}
|
||
}
|
||
if hi.is_finite() { hi / lo.max(1e-4) } else { f32::INFINITY }
|
||
}
|
||
|
||
/// Training pairs (sensor → JPEG, unclipped midtones) and the wider set including highlights
|
||
/// (for the chroma curve); `None` when too few, or the photo has too little colour. With
|
||
/// `edge_limit`, pixels whose 3×3 neighbourhood in either proxy spans a larger luminance ratio
|
||
/// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated
|
||
/// colours (see [`EDGE_CONTRAST`]).
|
||
type Pairs = Vec<([f64; 3], [f64; 3])>;
|
||
fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option<f32>) -> Option<(Pairs, Pairs)> {
|
||
if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() {
|
||
return None;
|
||
}
|
||
let mut pairs = Vec::new();
|
||
// Highlights too (camera JPEGs bleach colours toward white there), for the chroma curve only.
|
||
let mut bright = Vec::new();
|
||
let mut colour = 0;
|
||
for (i, (input, output)) in sensor.data.iter().zip(&reference.data).enumerate() {
|
||
if edge_limit.is_some_and(|limit| local_contrast(sensor, i) > limit || local_contrast(reference, i) > limit) {
|
||
continue;
|
||
}
|
||
let y = luminance_2020(*output);
|
||
if !input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5) || !output.iter().all(|v| v.is_finite() && *v >= 0.0) {
|
||
continue;
|
||
}
|
||
if (0.015..=1.0).contains(&y) {
|
||
bright.push((input.map(f64::from), output.map(f64::from)));
|
||
}
|
||
if !colour_pair(*input, *output) {
|
||
continue;
|
||
}
|
||
let min = output.iter().copied().fold(f32::INFINITY, f32::min);
|
||
let max = output.iter().copied().fold(0.0, f32::max);
|
||
colour += usize::from(max - min > min_chroma);
|
||
pairs.push((input.map(f64::from), output.map(f64::from)));
|
||
}
|
||
(pairs.len() >= 256 && colour >= pairs.len() / 20).then_some((pairs, bright))
|
||
}
|
||
|
||
/// The original colour-pair eligibility, also used to preserve its held-out split in tone fitting.
|
||
fn colour_pair(input: [f32; 3], output: [f32; 3]) -> bool {
|
||
input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5)
|
||
&& output.iter().all(|v| *v > 0.004 && *v < 0.98)
|
||
&& (0.015..0.85).contains(&luminance_2020(output))
|
||
}
|
||
|
||
/// Fit tone independently of colour eligibility when enough dark JPEG signal is available.
|
||
/// The original pairs and their split still own the colour fit and its acceptance gates.
|
||
/// Luminance needs no channel ratios, so the binned proxy can use a lower sensor floor and
|
||
/// keep JPEG pixels with a channel near black. Quantile medians limit the effect of noise.
|
||
fn fit_tone_with_shadows(
|
||
fit: ToneFit,
|
||
pairs: &[([f64; 3], [f64; 3])],
|
||
sensor: &Rgb32f,
|
||
reference: &Rgb32f,
|
||
edge_limit: Option<f32>,
|
||
colour: impl Fn([f64; 3]) -> [f64; 3],
|
||
) -> Option<CameraTone> {
|
||
let luminances = |x: [f64; 3], y: [f64; 3]| (luma(colour(x).map(|v| v.max(0.0))), luma(y));
|
||
let original = fit.fit(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| luminances(*x, *y)).collect());
|
||
let mut eligible = Vec::new();
|
||
let (mut colour_index, mut extra_index) = (0, 0);
|
||
for (i, (input, output)) in sensor.data.iter().zip(&reference.data).enumerate() {
|
||
if edge_limit.is_some_and(|limit| local_contrast(sensor, i) > limit || local_contrast(reference, i) > limit) {
|
||
continue;
|
||
}
|
||
// About one 8-bit sRGB code above black: don't learn a flat black segment from
|
||
// JPEG compression. The sensor proxy averages many samples before this test.
|
||
if !input.iter().all(|v| v.is_finite() && *v > 0.0001 && *v < 1.5)
|
||
|| !output.iter().all(|v| v.is_finite() && *v >= 0.0)
|
||
|| !(0.0003..0.85).contains(&luminance_2020(*output))
|
||
{
|
||
continue;
|
||
}
|
||
// A colour holdout must never enter tone training just because the wider sample set
|
||
// changes its index. Split the extra samples separately, keeping every colour index.
|
||
let index = if colour_pair(*input, *output) { &mut colour_index } else { &mut extra_index };
|
||
let held = *index % 3 == 0;
|
||
*index += 1;
|
||
let (x, y) = luminances(input.map(f64::from), output.map(f64::from));
|
||
if x.is_finite() && x > 0.0 {
|
||
eligible.push((x, y, held));
|
||
}
|
||
}
|
||
let dark = eligible.iter().filter(|(_, y, _)| *y < 0.015).count();
|
||
let held: Pairs = eligible.iter().filter(|(_, y, held)| *held && *y < 0.015).map(|(x, y, _)| ([*x; 3], [*y; 3])).collect();
|
||
// Sparse or uncorrelated shadow noise cannot teach a toe. Tied JPEG levels share a rank,
|
||
// so a single quantized grey also keeps the original curve.
|
||
if dark < 96 || held.len() < 32 || rank_correlation_ties(&held) < PARTIAL_MIN_RANK_CORRELATION {
|
||
return original;
|
||
}
|
||
let training = eligible.iter().filter(|(_, _, held)| !held).map(|(x, y, _)| (*x, *y)).collect();
|
||
let Some(candidate) = fit.fit(training) else { return original };
|
||
let error = |tone: &CameraTone| -> f64 {
|
||
held.iter().map(|(x, y)| (f64::from(tone.apply(x[0] as f32)).powf(1.0 / 2.2) - y[0].powf(1.0 / 2.2)).powi(2)).sum()
|
||
};
|
||
// A wider histogram must also help independent dark pixels, not just redistribute the
|
||
// knots. The usual held-out gates still check the completed look on the colour pairs.
|
||
if let Some(old) = original {
|
||
let (before, after) = (error(&old), error(&candidate));
|
||
if lightcraft_pipeline::profiling() {
|
||
eprintln!("[profile] camera toe: {dark} dark pairs, held-out error {before:.5} -> {after:.5}");
|
||
}
|
||
if !after.is_finite() || after >= before * 0.99 {
|
||
return original;
|
||
}
|
||
}
|
||
Some(candidate)
|
||
}
|
||
|
||
/// Ridge-regularised 3×3 chromaticity matrix (luminance-normalised RGB) on the training pairs.
|
||
fn fit_matrix(pairs: &[([f64; 3], [f64; 3])]) -> Option<Mat3> {
|
||
let mut gram = [[0.0; 3]; 3];
|
||
let mut cross = [[0.0; 3]; 3];
|
||
for (i, (x, y)) in pairs.iter().enumerate() {
|
||
if i % 3 == 0 {
|
||
continue;
|
||
}
|
||
let (lx, ly) = (luma(*x), luma(*y));
|
||
if lx <= 0.0 || ly <= 0.0 {
|
||
continue;
|
||
}
|
||
for row in 0..3 {
|
||
for col in 0..3 {
|
||
// Normalising by luminance prevents a camera S-curve from corrupting colour.
|
||
gram[row][col] += x[row] * x[col] / (lx * lx);
|
||
cross[row][col] += y[row] * x[col] / (ly * lx);
|
||
}
|
||
}
|
||
}
|
||
let trace: f64 = (0..3).map(|i| gram[i][i]).sum();
|
||
let inverse = Mat3(gram).inverse()?;
|
||
let condition = trace * (0..3).map(|i| inverse.0[i][i].abs()).sum::<f64>();
|
||
if trace <= 0.0 || !condition.is_finite() || condition > 1e6 {
|
||
return None;
|
||
}
|
||
let regularization = trace * 1e-4;
|
||
for i in 0..3 {
|
||
gram[i][i] += regularization;
|
||
cross[i][i] += regularization;
|
||
}
|
||
let matrix = Mat3(cross).mul(&Mat3(gram).inverse()?);
|
||
matrix.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0).then_some(matrix)
|
||
}
|
||
|
||
/// Neighbourhood luminance ratio above which a pixel counts as an edge for the second attempt
|
||
/// of [`search_ordered`]. Camera JPEGs are often lens-corrected (Sony "Distortion Comp.: Auto",
|
||
/// compacts and kit zooms): on a 51 mm ILCE-7RM2 shot of a glass façade the JPEG is up to 15 px
|
||
/// of 1440 (about one proxy pixel) off the raw, and the fit failed the gate on the mismatched
|
||
/// window frames alone (held-out RMS 0.111; 0.059 for the same shot with distortion correction
|
||
/// off). Away from edges: 0.049. Issue #232.
|
||
const EDGE_CONTRAST: f32 = 3.0;
|
||
|
||
/// The look on all pixels (the search of [`search_ordered`] with [`TONE_FITS`]), as before
|
||
/// clipped pixels were left out.
|
||
#[cfg(test)]
|
||
fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
|
||
fit_pairs_ordered(sensor, reference, colour, TONE_FITS)
|
||
}
|
||
|
||
/// The look from [`search_ordered`] without its attempt.
|
||
#[cfg(test)]
|
||
fn fit_pairs_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>, order: &[ToneFit]) -> Option<CameraLook> {
|
||
search_ordered(sensor, reference, colour, order).map(|(look, _)| look)
|
||
}
|
||
|
||
/// The tone fits the search tries, in order: the first whose look passes the acceptance gates is
|
||
/// used. Quantile matching comes first because it cannot produce flat steps; the
|
||
/// conditional-median fit follows because on a few photos (compacts, early Micro Four Thirds) its
|
||
/// slightly lower held-out error is what clears the gates, and a photo must not lose its look to
|
||
/// the change of fit.
|
||
const TONE_FITS: &[ToneFit] = &[ToneFit::Quantile, ToneFit::ConditionalMedian];
|
||
|
||
/// One attempt of the search: tone fit, the camera profile's colour or the photo's own, all pixels
|
||
/// or only those away from edges.
|
||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||
struct Attempt {
|
||
tone: ToneFit,
|
||
profile: bool,
|
||
away_from_edges: bool,
|
||
}
|
||
|
||
/// The photo's look and the attempt that produced it: its own matrix (with and without a
|
||
/// hue/saturation table), or the given colour model (a camera profile's, in the sensor proxy's
|
||
/// space), each completed with a tone and chroma curve fitted to this photo. Per tone fit in
|
||
/// `order`: the camera profile's colour (when there is one), then the photo's own, as a different
|
||
/// picture style can make a camera profile fail the gates; each fitted on all pixels and, when that
|
||
/// fails the gates, once more on the pixels away from edges (same gates): there colour pairs stay
|
||
/// valid when the camera JPEG's geometry differs slightly from the raw's (issue #232). The whole
|
||
/// search runs with one tone fit before the next is tried, so the conditional-median fallback
|
||
/// accepts exactly what the previous releases accepted.
|
||
fn search_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>, order: &[ToneFit]) -> Option<(CameraLook, Attempt)> {
|
||
let profiles: &[bool] = if colour.is_some() { &[true, false] } else { &[false] };
|
||
order.iter().find_map(|&tone| {
|
||
profiles.iter().find_map(|&profile| {
|
||
[false, true].into_iter().find_map(|away_from_edges| {
|
||
let attempt = Attempt { tone, profile, away_from_edges };
|
||
fit_attempt(sensor, reference, &colour, attempt).map(|look| (look, attempt))
|
||
})
|
||
})
|
||
})
|
||
}
|
||
|
||
fn fit_attempt(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option<HsvTable>)>, attempt: Attempt) -> Option<CameraLook> {
|
||
let colour = if attempt.profile { colour.clone() } else { None };
|
||
fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone)
|
||
}
|
||
|
||
fn fit_pairs_on(
|
||
sensor: &Rgb32f,
|
||
reference: &Rgb32f,
|
||
colour: Option<(Mat3, Option<HsvTable>)>,
|
||
edge_limit: Option<f32>,
|
||
tone_fit: ToneFit,
|
||
) -> Option<CameraLook> {
|
||
// A known colour model needs enough signal for tone fitting, not a scene rich enough to
|
||
// learn a new colour matrix. Still reject monochrome references.
|
||
let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit)?;
|
||
let candidates = match colour {
|
||
Some(given) => vec![given],
|
||
None => {
|
||
let matrix = fit_matrix(&pairs)?;
|
||
vec![(matrix, fit_hue_sat(&pairs, &matrix)), (matrix, None)]
|
||
}
|
||
};
|
||
// Matrix + table when the table helps the held-out pixels, else the matrix alone. Chosen by
|
||
// error in gamma-encoded display values (closer to what is seen: in linear values a slightly
|
||
// missed bright rock outweighs a clearly wrong dark shirt); the acceptance gates below stay linear.
|
||
let mut best: Option<(f64, f64, usize, CameraLook)> = None;
|
||
for (matrix, hue_sat) in candidates {
|
||
let correction = hue_sat.as_ref().and_then(HueSat::new);
|
||
let colour = |x: [f64; 3]| {
|
||
let p = matrix.apply(x);
|
||
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
|
||
};
|
||
let Some(curve) = fit_tone_with_shadows(tone_fit, &pairs, sensor, reference, edge_limit, colour) else { continue };
|
||
let mut look = CameraLook { matrix, tone: curve, hue_sat: hue_sat.clone() };
|
||
if let Some(tone) = fit_chroma(&bright, &look) {
|
||
look.tone = tone;
|
||
}
|
||
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
|
||
let (mut linear, mut perceptual, mut samples) = (0.0, 0.0, 0);
|
||
for (x, target) in pairs.iter().step_by(3) {
|
||
let corrected = displayed(colour(*x), &tone);
|
||
for c in 0..3 {
|
||
linear += (corrected[c] - target[c]).powi(2);
|
||
perceptual += (corrected[c].max(0.0).powf(1.0 / 2.2) - target[c].max(0.0).powf(1.0 / 2.2)).powi(2);
|
||
samples += 1;
|
||
}
|
||
}
|
||
if linear.is_finite() && perceptual.is_finite() && best.as_ref().is_none_or(|b| perceptual < b.0) {
|
||
best = Some((perceptual, linear, samples, look));
|
||
}
|
||
}
|
||
let (_, after, samples, look) = best?;
|
||
let original_tone = ToneMap::new(0.0, 0.0, 0.0);
|
||
let before: f64 = pairs
|
||
.iter()
|
||
.step_by(3)
|
||
.map(|(x, target)| {
|
||
let original = displayed(*x, &original_tone);
|
||
(0..3).map(|c| (original[c] - target[c]).powi(2)).sum::<f64>()
|
||
})
|
||
.sum();
|
||
if lightcraft_pipeline::profiling() {
|
||
eprintln!(
|
||
"[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels{}{})",
|
||
(before / samples as f64).sqrt(),
|
||
(after / samples as f64).sqrt(),
|
||
if edge_limit.is_some() { ", away from edges" } else { "" },
|
||
if tone_fit == ToneFit::ConditionalMedian { ", conditional-median tone" } else { "" }
|
||
);
|
||
}
|
||
if samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) {
|
||
return None;
|
||
}
|
||
Some(look)
|
||
}
|
||
|
||
/// Weight of the photo's own colour matrix in a [`fit_partial`] look; the rest is the identity (the
|
||
/// neutral fallback's generic camera ≈ sRGB colour). On the CC0 archive's proxies, with the
|
||
/// matrix at full weight 2 of the 23 colourful photos without a look came out more than 2° further
|
||
/// from the camera's hues than the neutral fallback, at half weight none (median hue error 9.6° with
|
||
/// the generic colour, which the tone and chroma curves make visible, 5.1° at half weight).
|
||
const PARTIAL_MATRIX_WEIGHT: f64 = 0.5;
|
||
|
||
/// Smallest rank correlation of scene and camera JPEG luminance over a [`fit_partial`] look's pairs:
|
||
/// the JPEG must show the same picture. A look that only matches the brightness distribution of an
|
||
/// unrelated picture can still beat the dark neutral fallback pixel for pixel. On the CC0 archive's
|
||
/// proxies the accepted full looks have 0.55 or more, lens-corrected compacts without one 0.53–0.77,
|
||
/// three old Coolpix files whose raw decodes wrongly 0.07–0.27.
|
||
const PARTIAL_MIN_RANK_CORRELATION: f64 = 0.5;
|
||
|
||
/// Spearman rank correlation of the scene's and the JPEG's luminance over `pairs`.
|
||
fn rank_correlation(pairs: &[([f64; 3], [f64; 3])]) -> f64 {
|
||
let ranks = |values: Vec<f64>| {
|
||
let mut order: Vec<usize> = (0..values.len()).collect();
|
||
order.sort_by(|a, b| values[*a].total_cmp(&values[*b]));
|
||
let mut ranks = vec![0.0; values.len()];
|
||
for (rank, i) in order.into_iter().enumerate() {
|
||
ranks[i] = rank as f64;
|
||
}
|
||
ranks
|
||
};
|
||
let scene = ranks(pairs.iter().map(|(x, _)| luma(*x)).collect());
|
||
let jpeg = ranks(pairs.iter().map(|(_, y)| luma(*y)).collect());
|
||
let mean = (pairs.len() as f64 - 1.0) / 2.0;
|
||
let (covariance, variance) =
|
||
scene.iter().zip(&jpeg).fold((0.0, 0.0), |(c, v), (a, b)| (c + (a - mean) * (b - mean), v + (a - mean) * (a - mean)));
|
||
if variance > 0.0 { covariance / variance } else { 0.0 }
|
||
}
|
||
|
||
/// [`rank_correlation`] with tied luminances sharing their mean rank, and 0 when either side has no spread. Without
|
||
/// colour to require, a JPEG may be flat (one grey for every pixel): [`rank_correlation`] ranks ties in pixel order,
|
||
/// so a flat JPEG of a scene whose pixels are stored in brightness order looks perfectly related.
|
||
fn rank_correlation_ties(pairs: &[([f64; 3], [f64; 3])]) -> f64 {
|
||
let ranks = |values: Vec<f64>| {
|
||
let mut order: Vec<usize> = (0..values.len()).collect();
|
||
order.sort_by(|a, b| values[*a].total_cmp(&values[*b]));
|
||
let mut ranks = vec![0.0; values.len()];
|
||
let mut start = 0;
|
||
while start < order.len() {
|
||
let mut end = start + 1;
|
||
while end < order.len() && values[order[end]] == values[order[start]] {
|
||
end += 1;
|
||
}
|
||
let mean = (start + end - 1) as f64 / 2.0;
|
||
for &i in &order[start..end] {
|
||
ranks[i] = mean;
|
||
}
|
||
start = end;
|
||
}
|
||
ranks
|
||
};
|
||
let scene = ranks(pairs.iter().map(|(x, _)| luma(*x)).collect());
|
||
let jpeg = ranks(pairs.iter().map(|(_, y)| luma(*y)).collect());
|
||
let mean = (pairs.len() as f64 - 1.0) / 2.0;
|
||
let (covariance, scene_variance, jpeg_variance) = scene
|
||
.iter()
|
||
.zip(&jpeg)
|
||
.fold((0.0, 0.0, 0.0), |(c, va, vb), (a, b)| (c + (a - mean) * (b - mean), va + (a - mean) * (a - mean), vb + (b - mean) * (b - mean)));
|
||
if scene_variance > 0.0 && jpeg_variance > 0.0 { covariance / (scene_variance * jpeg_variance).sqrt() } else { 0.0 }
|
||
}
|
||
|
||
/// A partial look for a photo whose full look misses the acceptance gates (lens-corrected JPEGs of
|
||
/// compacts, dull or nearly colourless scenes): the tone curve by quantile matching, which needs
|
||
/// no pixel-exact registration and was the largest part of the neutral fallback's error (rendered
|
||
/// 9–22 L* too dark), the chroma curve (the fallback kept about a third of the camera's
|
||
/// colourfulness), and, when the scene has enough colour to learn one (the full look's condition),
|
||
/// the photo's own colour matrix at [`PARTIAL_MATRIX_WEIGHT`]. No hue/saturation table. Tried on
|
||
/// all pixels and then, like the full look, on the pixels away from edges; used only when it cuts
|
||
/// the held-out squared error to below [`MIN_IMPROVEMENT`] of the neutral fallback's, in linear
|
||
/// and in gamma-encoded display values, and when the JPEG shows the same picture
|
||
/// ([`PARTIAL_MIN_RANK_CORRELATION`]). There is no limit on its own error: it only has to be clearly
|
||
/// closer to the camera JPEG than what the photo would get otherwise.
|
||
fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
|
||
[None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_partial_on(sensor, reference, edge_limit))
|
||
}
|
||
|
||
fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>) -> Option<CameraLook> {
|
||
// as for a known colour model: enough signal for the tone, but no monochrome reference
|
||
let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit)?;
|
||
let related = rank_correlation(&pairs);
|
||
if related < PARTIAL_MIN_RANK_CORRELATION {
|
||
if lightcraft_pipeline::profiling() {
|
||
eprintln!("[profile] camera look partial: luminance rank correlation {related:.3}, not the same picture");
|
||
}
|
||
return None;
|
||
}
|
||
let spread = |y: &[f64; 3]| y.iter().copied().fold(f64::NEG_INFINITY, f64::max) - y.iter().copied().fold(f64::INFINITY, f64::min);
|
||
let colourful = pairs.iter().filter(|(_, y)| spread(y) > 0.05).count() >= pairs.len() / 20;
|
||
let matrix = match colourful.then(|| fit_matrix(&pairs)).flatten() {
|
||
Some(own) => Mat3(std::array::from_fn(|r| {
|
||
std::array::from_fn(|c| Mat3::IDENTITY.0[r][c] + PARTIAL_MATRIX_WEIGHT * (own.0[r][c] - Mat3::IDENTITY.0[r][c]))
|
||
})),
|
||
None => Mat3::IDENTITY,
|
||
};
|
||
let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, |x| matrix.apply(x))?;
|
||
let mut look = CameraLook { matrix, tone, hue_sat: None };
|
||
if let Some(tone) = fit_chroma(&bright, &look) {
|
||
look.tone = tone;
|
||
}
|
||
let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
|
||
let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2);
|
||
let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0);
|
||
for (x, target) in pairs.iter().step_by(3) {
|
||
let (fallback, partial) = (displayed(*x, &neutral), displayed(matrix.apply(*x), &tone));
|
||
for c in 0..3 {
|
||
before += (fallback[c] - target[c]).powi(2);
|
||
after += (partial[c] - target[c]).powi(2);
|
||
before_encoded += (encoded(fallback[c]) - encoded(target[c])).powi(2);
|
||
after_encoded += (encoded(partial[c]) - encoded(target[c])).powi(2);
|
||
samples += 1;
|
||
}
|
||
}
|
||
if lightcraft_pipeline::profiling() {
|
||
eprintln!(
|
||
"[profile] camera look partial (tone{}{}) holdout RMS {:.5} -> {:.5} ({samples} channels{})",
|
||
if look.tone.chroma().iter().any(|k| *k != 1.0) { ", chroma" } else { "" },
|
||
if matrix != Mat3::IDENTITY { ", damped matrix" } else { "" },
|
||
(before / samples.max(1) as f64).sqrt(),
|
||
(after / samples.max(1) as f64).sqrt(),
|
||
if edge_limit.is_some() { ", away from edges" } else { "" }
|
||
);
|
||
}
|
||
let better =
|
||
after.is_finite() && after_encoded.is_finite() && after < before * MIN_IMPROVEMENT && after_encoded < before_encoded * MIN_IMPROVEMENT;
|
||
(samples > 0 && better).then_some(look)
|
||
}
|
||
|
||
/// A camera JPEG pixel's channels may differ by this much (linear) and still count as grey.
|
||
const GREY_SPREAD: f32 = 0.002;
|
||
|
||
/// Whether the camera JPEG is black and white: a monochrome picture style (Canon Monochrome, Sony B/W, Nikon
|
||
/// Monochrome), shot from a full-colour raw. All but one proxy pixel in a thousand are grey within
|
||
/// [`GREY_SPREAD`]. A toned monochrome (sepia) has colour, so it isn't one.
|
||
fn monochrome(reference: &Rgb32f) -> bool {
|
||
let coloured = reference
|
||
.data
|
||
.iter()
|
||
.filter(|p| {
|
||
let (min, max) = p.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
|
||
!p.iter().all(|v| v.is_finite()) || max - min > GREY_SPREAD
|
||
})
|
||
.count();
|
||
reference.data.len() >= 256 && coloured.saturating_mul(1000) <= reference.data.len()
|
||
}
|
||
|
||
/// The look of a raw whose camera JPEG is black and white ([`monochrome`]; issue #577) and gave no look with a camera
|
||
/// profile's colour in [`fit_preview`] (or whose model has no profile). Such a JPEG has no colour to fit, so the photo
|
||
/// would start from the spectral matrices or the neutral fallback with the default tone curve, about 1.3 EV darker
|
||
/// and flatter than the same body's photos shot in colour. But the JPEG still shows the camera's tone curve.
|
||
/// `transform` is the colour the photo gets otherwise (the spectral matrices or the fallback's); the look keeps it and
|
||
/// adds the tone curve alone ([`fit_tone_only`]), as [`fit_look`] tries the photo's own colour after a profile's. The
|
||
/// chroma curve stays neutral: the JPEG has no colourfulness to learn.
|
||
fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
|
||
if !file_local_look(raw.format) {
|
||
return None;
|
||
}
|
||
let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY)?;
|
||
if !monochrome(&reference) {
|
||
return None;
|
||
}
|
||
let unclipped = without_clipped(&sensor, &clipped);
|
||
let look = fit_tone_only(unclipped.as_ref().unwrap_or(&sensor), &reference, None)?;
|
||
// Sony's Dynamic Range Optimizer brightens a B/W JPEG as much as a colour one
|
||
let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true);
|
||
let look = if dro { with_dro_off_tone(look) } else { look };
|
||
if lightcraft_pipeline::profiling() {
|
||
eprintln!(
|
||
"[profile] {:?} camera look from a black-and-white JPEG: tone {:?}, {} colour, DRO tone replaced {dro}",
|
||
raw.format,
|
||
look.tone,
|
||
if transform.matrix_is_fallback { "fallback" } else { "spectral" }
|
||
);
|
||
}
|
||
Some(look)
|
||
}
|
||
|
||
/// The tone curve of a black-and-white camera JPEG, on top of `colour` (a camera profile's, in the sensor proxy's
|
||
/// space) or, without one, of the sensor proxy's own colour. Fitted like [`fit_partial`]'s, by quantile matching of
|
||
/// the luminances, on all pixels and then away from edges, and used only when the JPEG shows the same picture
|
||
/// ([`PARTIAL_MIN_RANK_CORRELATION`]) and the look cuts the held-out squared luminance error to below
|
||
/// [`MIN_IMPROVEMENT`] of the default curve's, in linear and in gamma-encoded display values.
|
||
fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
|
||
[None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_tone_only_on(sensor, reference, &colour, edge_limit))
|
||
}
|
||
|
||
fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option<HsvTable>)>, edge_limit: Option<f32>) -> Option<CameraLook> {
|
||
// every pair counts: the JPEG has no colour to require
|
||
let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit)?;
|
||
let related = rank_correlation_ties(&pairs);
|
||
if related < PARTIAL_MIN_RANK_CORRELATION {
|
||
if lightcraft_pipeline::profiling() {
|
||
eprintln!("[profile] camera look from a black-and-white JPEG: luminance rank correlation {related:.3}, not the same picture");
|
||
}
|
||
return None;
|
||
}
|
||
let (matrix, hue_sat) = colour.clone().unwrap_or((Mat3::IDENTITY, None));
|
||
let correction = hue_sat.as_ref().and_then(HueSat::new);
|
||
let coloured = |x: [f64; 3]| {
|
||
let p = matrix.apply(x);
|
||
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
|
||
};
|
||
let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, coloured)?;
|
||
let look = CameraLook { matrix, tone, hue_sat: hue_sat.clone() };
|
||
let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
|
||
let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2);
|
||
let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0);
|
||
for (x, target) in pairs.iter().step_by(3) {
|
||
let (fallback, toned, target) = (luma(displayed(*x, &neutral)), luma(displayed(coloured(*x), &tone)), luma(*target));
|
||
before += (fallback - target).powi(2);
|
||
after += (toned - target).powi(2);
|
||
before_encoded += (encoded(fallback) - encoded(target)).powi(2);
|
||
after_encoded += (encoded(toned) - encoded(target)).powi(2);
|
||
samples += 1;
|
||
}
|
||
if lightcraft_pipeline::profiling() {
|
||
eprintln!(
|
||
"[profile] camera look from a black-and-white JPEG: holdout luminance RMS {:.5} -> {:.5} ({samples} pixels{})",
|
||
(before / samples.max(1) as f64).sqrt(),
|
||
(after / samples.max(1) as f64).sqrt(),
|
||
if edge_limit.is_some() { ", away from edges" } else { "" }
|
||
);
|
||
}
|
||
let better =
|
||
after.is_finite() && after_encoded.is_finite() && after < before * MIN_IMPROVEMENT && after_encoded < before_encoded * MIN_IMPROVEMENT;
|
||
(samples > 0 && better).then_some(look)
|
||
}
|
||
|
||
/// Linear Rec.2020 D65 → linear ProPhoto RGB D50, the space DNG hue/saturation tables work in.
|
||
fn to_prophoto() -> Mat3 {
|
||
PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz())
|
||
}
|
||
|
||
/// [`HueSat::apply`] restores the luminance in full only if the table kept at least this share of it. A colour
|
||
/// the table darkens to [`NO_LUMINANCE_KEPT`] or less (only near-black noise, whose channels go negative) keeps
|
||
/// its colour; in between the two are blended, so noisy shadows show no step.
|
||
const MIN_LUMINANCE_KEPT: f32 = 0.25;
|
||
const NO_LUMINANCE_KEPT: f32 = 0.15;
|
||
|
||
/// How much of the table's colour a pixel gets when the table kept `share` of its luminance: 0 up to
|
||
/// [`NO_LUMINANCE_KEPT`], 1 from [`MIN_LUMINANCE_KEPT`], a smoothstep between.
|
||
fn restore_weight(share: f32) -> f32 {
|
||
if !share.is_finite() {
|
||
return 0.0;
|
||
}
|
||
let t = ((share - NO_LUMINANCE_KEPT) / (MIN_LUMINANCE_KEPT - NO_LUMINANCE_KEPT)).clamp(0.0, 1.0);
|
||
t * t * (3.0 - 2.0 * t)
|
||
}
|
||
|
||
/// A fitted [`CameraLook::hue_sat`] table, ready to apply to linear Rec.2020 pixels. It changes
|
||
/// hue and saturation only: luminance is restored, the camera tone curve owns it.
|
||
pub(crate) struct HueSat<'a> {
|
||
table: &'a HsvTable,
|
||
to: [[f32; 3]; 3],
|
||
from: [[f32; 3]; 3],
|
||
}
|
||
|
||
impl<'a> HueSat<'a> {
|
||
pub fn new(table: &'a HsvTable) -> Option<HueSat<'a>> {
|
||
let to = to_prophoto();
|
||
Some(HueSat { table, to: to.to_f32(), from: to.inverse()?.to_f32() })
|
||
}
|
||
|
||
#[inline]
|
||
pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
|
||
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]) };
|
||
let out = mul(&self.from, self.table.apply(mul(&self.to, rgb)));
|
||
let (before, after) = (luminance_2020(rgb), luminance_2020(out));
|
||
// restoring the luminance divides by the table's: near black, where noise makes some channels
|
||
// negative, the table can bring it close to 0, and the gain would turn the pixel white (#820)
|
||
if before <= 0.0 || after <= 0.0 || !out.iter().all(|v| v.is_finite()) {
|
||
return rgb;
|
||
}
|
||
let w = restore_weight(after / before);
|
||
if w >= 1.0 {
|
||
return out.map(|v| v * before / after);
|
||
}
|
||
if w <= 0.0 {
|
||
return rgb;
|
||
}
|
||
std::array::from_fn(|i| rgb[i] + (out[i] * before / after - rgb[i]) * w)
|
||
}
|
||
}
|
||
|
||
/// HSV hue (degrees) and saturation; `None` for black or non-finite colours.
|
||
fn hue_saturation(p: [f64; 3]) -> Option<(f64, f64)> {
|
||
let max = p[0].max(p[1]).max(p[2]);
|
||
let min = p[0].min(p[1]).min(p[2]);
|
||
if !(max.is_finite() && min.is_finite()) || max <= 0.0 {
|
||
return None;
|
||
}
|
||
let d = max - min;
|
||
if d <= 0.0 {
|
||
return Some((0.0, 0.0));
|
||
}
|
||
let h = if max == p[0] {
|
||
((p[1] - p[2]) / d).rem_euclid(6.0)
|
||
} else if max == p[1] {
|
||
(p[2] - p[0]) / d + 2.0
|
||
} else {
|
||
(p[0] - p[1]) / d + 4.0
|
||
};
|
||
Some((h * 60.0, d / max))
|
||
}
|
||
|
||
/// Table resolution: 5° hue steps (a coarser grid blurred the lime shirt into neighbouring browns
|
||
/// that want the opposite shift), saturation 0, 0.25 … 1. Value is not an axis: tone is fitted separately.
|
||
const TABLE_HUES: usize = 72;
|
||
const TABLE_SATS: usize = 5;
|
||
/// Value levels (sRGB-encoded, as DNG tables with encoding 1): cameras turn dark yellows toward
|
||
/// orange but bright yellow-greens toward green, which one shift per hue averages away.
|
||
const TABLE_VALS: usize = 5;
|
||
/// Kernel widths around each table node (hue in degrees, saturation, encoded value).
|
||
const KERNEL_HUE: f64 = 2.5;
|
||
const KERNEL_SAT: f64 = 0.15;
|
||
const KERNEL_VAL: f64 = 0.12;
|
||
/// Kernel weight at which a node keeps half of its estimate; sparse nodes shrink to identity.
|
||
const SHRINK_WEIGHT: f64 = 5.0;
|
||
|
||
/// Fit hue shifts and saturation scales of the training pairs left after `matrix`, per (hue,
|
||
/// saturation) node, kernel-weighted and shrunk toward identity where the photo has few samples.
|
||
/// Saturation 0 stays identity, so neutrals are never tinted.
|
||
fn fit_hue_sat(pairs: &[([f64; 3], [f64; 3])], matrix: &Mat3) -> Option<HsvTable> {
|
||
let to = to_prophoto();
|
||
let samples: Vec<(f64, f64, f64, f64, f64)> = pairs
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(i, _)| i % 3 != 0)
|
||
.filter_map(|(_, (x, y))| {
|
||
let p = to.apply(matrix.apply(*x));
|
||
let (hp, sp) = hue_saturation(p)?;
|
||
let value = f64::from(lightcraft_color::transfer::linear_to_srgb(p[0].max(p[1]).max(p[2]).min(1.0) as f32));
|
||
let (ht, st) = hue_saturation(to.apply(*y))?;
|
||
// hue is meaningless near neutral
|
||
if sp < 0.08 || st < 0.02 {
|
||
return None;
|
||
}
|
||
let shift = ((ht - hp + 180.0).rem_euclid(360.0) - 180.0).clamp(-30.0, 30.0);
|
||
let log_scale = (st / sp).ln().clamp(-0.7, 0.7);
|
||
Some((hp, sp.min(1.0), value, shift, log_scale))
|
||
})
|
||
.collect();
|
||
if samples.len() < 64 {
|
||
return None;
|
||
}
|
||
// Samples by table hue step: a node only looks at hues within its kernel's reach.
|
||
let step = 360.0 / TABLE_HUES as f64;
|
||
let mut by_hue: Vec<Vec<(f64, f64, f64, f64, f64)>> = vec![Vec::new(); TABLE_HUES];
|
||
for sample in samples {
|
||
if let Some(bin) = by_hue.get_mut(((sample.0.rem_euclid(360.0) / step) as usize).min(TABLE_HUES - 1)) {
|
||
bin.push(sample);
|
||
}
|
||
}
|
||
let reach = (4.0 * KERNEL_HUE / step).ceil() as usize + 1;
|
||
use rayon::prelude::*;
|
||
let data: Vec<[f32; 3]> = (0..TABLE_VALS * TABLE_HUES * TABLE_SATS)
|
||
.into_par_iter()
|
||
.map(|index| {
|
||
let (v, h, s) = (index / (TABLE_HUES * TABLE_SATS), index / TABLE_SATS % TABLE_HUES, index % TABLE_SATS);
|
||
if s == 0 {
|
||
return [0.0, 1.0, 1.0];
|
||
}
|
||
let (val, hue, sat) = (v as f64 / (TABLE_VALS - 1) as f64, h as f64 * step, s as f64 / (TABLE_SATS - 1) as f64);
|
||
let (mut weight, mut shift, mut log_scale) = (0.0, 0.0, 0.0);
|
||
for offset in 0..=2 * reach {
|
||
let Some(bin) = by_hue.get((h + TABLE_HUES * 2 + offset - reach) % TABLE_HUES) else { continue };
|
||
for &(hp, sp, vp, dh, ls) in bin {
|
||
let dhue = (hp - hue + 180.0).rem_euclid(360.0) - 180.0;
|
||
if dhue.abs() > 4.0 * KERNEL_HUE {
|
||
continue;
|
||
}
|
||
let d2 = (dhue / KERNEL_HUE).powi(2) + ((sp - sat) / KERNEL_SAT).powi(2) + ((vp - val) / KERNEL_VAL).powi(2);
|
||
let k = (-0.5 * d2).exp() * sp;
|
||
weight += k;
|
||
shift += k * dh;
|
||
log_scale += k * ls;
|
||
}
|
||
}
|
||
if weight <= 0.0 {
|
||
return [0.0, 1.0, 1.0];
|
||
}
|
||
let shrink = 1.0 / (weight + SHRINK_WEIGHT);
|
||
[(shift * shrink) as f32, (log_scale * shrink).exp() as f32, 1.0]
|
||
})
|
||
.collect();
|
||
data.iter().all(|e| e.iter().all(|v| v.is_finite())).then_some(HsvTable {
|
||
hue_divisions: TABLE_HUES,
|
||
sat_divisions: TABLE_SATS,
|
||
val_divisions: TABLE_VALS,
|
||
data,
|
||
srgb_value: true,
|
||
})
|
||
}
|
||
|
||
/// Kernel width of the chroma curve's nodes (display luminance) and the weight at which a node
|
||
/// keeps half of its estimate.
|
||
const CHROMA_KERNEL: f64 = 0.08;
|
||
const CHROMA_SHRINK: f64 = 2.0;
|
||
|
||
/// The look's tone curve with a chroma-by-display-luminance curve fitted to `pairs` (two thirds
|
||
/// train, one third held out), when that lowers the held-out error. Colourfulness is compared as
|
||
/// the distance from neutral of luminance-normalised RGB.
|
||
fn fit_chroma(pairs: &[([f64; 3], [f64; 3])], look: &CameraLook) -> Option<CameraTone> {
|
||
let correction = look.hue_sat.as_ref().and_then(HueSat::new);
|
||
let scene = |x: &[f64; 3]| {
|
||
let p = look.matrix.apply(*x);
|
||
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
|
||
};
|
||
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
|
||
let predict = |x: &[f64; 3]| displayed(scene(x), &tone);
|
||
let chroma = |p: [f64; 3]| {
|
||
let y = luma(p);
|
||
(y > 0.0).then(|| p.iter().map(|v| (v / y - 1.0).powi(2)).sum::<f64>().sqrt())
|
||
};
|
||
let samples: Vec<(f64, f64, f64)> = pairs
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(i, _)| i % 3 != 0)
|
||
.filter_map(|(_, (x, y))| {
|
||
let p = predict(x);
|
||
let (cp, cy) = (chroma(p)?, chroma(*y)?);
|
||
(cp > 0.05).then(|| (luma(p), cp, (cy.max(1e-3) / cp).ln().clamp(0.05f64.ln(), 2.5f64.ln())))
|
||
})
|
||
.collect();
|
||
if samples.len() < 64 {
|
||
return None;
|
||
}
|
||
let mut curve = [1.0f32; lightcraft_pipeline::tone::CHROMA_N];
|
||
for (j, node) in curve.iter_mut().enumerate() {
|
||
let at = j as f64 / (lightcraft_pipeline::tone::CHROMA_N - 1) as f64;
|
||
let (mut weight, mut sum) = (0.0, 0.0);
|
||
for &(o, cp, log_ratio) in &samples {
|
||
let k = (-0.5 * ((o - at) / CHROMA_KERNEL).powi(2)).exp() * cp;
|
||
weight += k;
|
||
sum += k * log_ratio;
|
||
}
|
||
*node = (sum / (weight + CHROMA_SHRINK)).exp() as f32;
|
||
}
|
||
let fitted = look.tone.with_chroma(curve)?;
|
||
let with = ToneMap::camera(&fitted, 0.0, 0.0, 0.0);
|
||
let error = |map: &ToneMap| -> f64 {
|
||
pairs
|
||
.iter()
|
||
.step_by(3)
|
||
.map(|(x, y)| {
|
||
let p = displayed(scene(x), map);
|
||
(0..3).map(|c| (p[c] - y[c]).powi(2)).sum::<f64>()
|
||
})
|
||
.sum()
|
||
};
|
||
let (before, after) = (error(&tone), error(&with));
|
||
if lightcraft_pipeline::profiling() {
|
||
eprintln!("[profile] ARW chroma curve {curve:?}: held-out error {before:.4} -> {after:.4}");
|
||
}
|
||
(after.is_finite() && after < before).then_some(fitted)
|
||
}
|
||
|
||
/// How the per-photo tone curve is fitted ([`fit_pairs_ordered`] tries them in order).
|
||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||
enum ToneFit {
|
||
/// [`fit_tone`]: no flat steps, no pixel-exact registration needed.
|
||
Quantile,
|
||
/// [`fit_tone_conditional`]: the earlier fit, kept as the fallback.
|
||
ConditionalMedian,
|
||
}
|
||
|
||
impl ToneFit {
|
||
fn fit(self, pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
|
||
match self {
|
||
ToneFit::Quantile => fit_tone(pairs),
|
||
ToneFit::ConditionalMedian => fit_tone_conditional(pairs),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The camera's tone curve from scene/JPEG luminance pairs, by quantile matching: knot `i` pairs
|
||
/// the median of the `i`-th 1/32 of the sorted scene luminances with the median of the `i`-th
|
||
/// 1/32 of the sorted JPEG luminances. A tone curve is monotone, so it maps each quantile of the
|
||
/// scene to the same quantile of the JPEG; matching the two distributions needs no pixel-exact
|
||
/// registration and is increasing by construction.
|
||
///
|
||
/// Binning the pairs by scene luminance and taking the JPEG's median per bin instead (followed by
|
||
/// isotonic pooling) fails on real files: camera JPEGs are lens-corrected and sharpened, so on a
|
||
/// 96 px proxy of a busy scene neighbouring pairs disagree by more than the gap between bins.
|
||
/// The conditional medians then zig-zag, pooling turns them into flat steps (13 of 31 segments
|
||
/// on a Z 6II forest, issue #475) that render as posterised patches, and regression toward the
|
||
/// mean flattens both ends of the curve (lifted blacks, dull highlights).
|
||
fn fit_tone(pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
|
||
if pairs.len() < 128 || !pairs.iter().all(|(x, y)| x.is_finite() && *x > 0.0 && y.is_finite()) {
|
||
return None;
|
||
}
|
||
let mut xs: Vec<f64> = pairs.iter().map(|p| p.0).collect();
|
||
let mut ys: Vec<f64> = pairs.iter().map(|p| p.1).collect();
|
||
xs.sort_by(f64::total_cmp);
|
||
ys.sort_by(f64::total_cmp);
|
||
let n = xs.len();
|
||
let mut knots = [[0.0; 2]; 32];
|
||
for (i, knot) in knots.iter_mut().enumerate() {
|
||
let (a, b) = (i * n / 32, (i + 1) * n / 32);
|
||
let mid = a + (b - a) / 2;
|
||
*knot = [*xs.get(mid)? as f32, *ys.get(mid)? as f32];
|
||
}
|
||
if knots[31][0] < knots[0][0] * 1.5 {
|
||
return None;
|
||
}
|
||
CameraTone::new(knots)
|
||
}
|
||
|
||
fn median(values: &mut [f64]) -> Option<f64> {
|
||
values.sort_by(f64::total_cmp);
|
||
values.get(values.len() / 2).copied()
|
||
}
|
||
|
||
/// The earlier tone fit, kept as the fallback of [`ToneFit`]: the JPEG's median per equal-population
|
||
/// bin of scene luminance, then pooled-adjacent-violators isotonic regression. It follows the
|
||
/// per-pixel pairs closely when the two images are registered, which is why it can clear the
|
||
/// acceptance gates where [`fit_tone`] just misses them, but on lens-corrected JPEGs of busy scenes
|
||
/// the pooling leaves flat steps (issue #475).
|
||
fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
|
||
if pairs.len() < 128 || !pairs.iter().all(|(x, y)| x.is_finite() && *x > 0.0 && y.is_finite()) {
|
||
return None;
|
||
}
|
||
pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||
let mut knots = [[0.0; 2]; 32];
|
||
for (i, knot) in knots.iter_mut().enumerate() {
|
||
let bin = pairs.get(i * pairs.len() / 32..(i + 1) * pairs.len() / 32)?;
|
||
let mut xs: Vec<_> = bin.iter().map(|p| p.0).collect();
|
||
let mut ys: Vec<_> = bin.iter().map(|p| p.1).collect();
|
||
*knot = [median(&mut xs)? as f32, median(&mut ys)? as f32];
|
||
}
|
||
if knots[31][0] < knots[0][0] * 1.5 {
|
||
return None;
|
||
}
|
||
// Pool adjacent violating bins (isotonic regression): no reversals or arbitrary polynomial.
|
||
let mut blocks: Vec<(f32, usize)> = Vec::new();
|
||
for knot in knots {
|
||
blocks.push((knot[1], 1));
|
||
while blocks.len() >= 2 {
|
||
let (a, an) = *blocks.get(blocks.len() - 2)?;
|
||
let (b, bn) = *blocks.last()?;
|
||
if a <= b {
|
||
break;
|
||
}
|
||
blocks.truncate(blocks.len() - 2);
|
||
blocks.push(((a * an as f32 + b * bn as f32) / (an + bn) as f32, an + bn));
|
||
}
|
||
}
|
||
let mut i = 0;
|
||
for (y, n) in blocks {
|
||
for knot in knots.get_mut(i..i + n)? {
|
||
knot[1] = y;
|
||
}
|
||
i += n;
|
||
}
|
||
CameraTone::new(knots)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
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).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).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,
|
||
opcodes: OpcodeLists::default(),
|
||
metadata: lightcraft_meta::Metadata::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).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");
|
||
}
|
||
|
||
#[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,
|
||
opcodes: Default::default(),
|
||
metadata: lightcraft_raw::Metadata { make: Some(make.to_owned()), model: Some(model.to_owned()), ..Default::default() },
|
||
}
|
||
}
|
||
|
||
/// 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"),
|
||
);
|
||
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"),
|
||
);
|
||
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, &[]);
|
||
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"));
|
||
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())
|
||
},
|
||
);
|
||
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())
|
||
},
|
||
);
|
||
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, &[]);
|
||
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, &[]).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 {
|
||
eprintln!("skip: {name} absent");
|
||
continue;
|
||
};
|
||
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
|
||
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);
|
||
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,
|
||
];
|
||
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 {
|
||
eprintln!("skip: {} absent", path.display());
|
||
return;
|
||
};
|
||
let raw = match lightcraft_raw::decode(&bytes) {
|
||
Ok(raw) => raw,
|
||
Err(lightcraft_raw::RawError::Unsupported(why)) => {
|
||
eprintln!("skip 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).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 => {
|
||
eprintln!("skip: {} 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 raw = lightcraft_raw::decode(&bytes).unwrap();
|
||
assert_eq!(raw.metadata.model.as_deref(), Some(model));
|
||
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||
let look = fit_preview(&raw, &bytes, &transform).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");
|
||
}
|
||
|
||
/// 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 {
|
||
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");
|
||
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 => {
|
||
eprintln!("skip: {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 {
|
||
eprintln!("skip: {} 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 {
|
||
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 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(), &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()),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 => {
|
||
eprintln!("skip: {} 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).0.expect("a camera look");
|
||
// the look fitted to the JPEG, as before this change
|
||
let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY).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 (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS).unwrap();
|
||
assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: 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));
|
||
}
|
||
|
||
/// 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));
|
||
assert!(fit_partial(&sensor, &reference).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()
|
||
},
|
||
);
|
||
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,
|
||
opcodes: OpcodeLists::default(),
|
||
metadata: lightcraft_meta::Metadata { make: Some("SONY".into()), model: Some("ILCE-7M4".into()), ..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).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).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");
|
||
}
|
||
}
|