Merge PR #699: LR-PROF-CAMERACOLOR: fit the tone curve of low-contrast scenes (range limit 1.5 to 1.2)

crates/engine/src/camera_preview.rs: LOOK_VERSION keeps both history lines and the higher value.
crates/engine/src/media.rs: RENDER_CACHE_VERSION keeps the higher value.
This commit is contained in:
aiongg
2026-10-22 02:37:09 +00:00
3 changed files with 368 additions and 45 deletions
+366 -42
View File
@@ -35,7 +35,8 @@ use lightcraft_raw::{
/// 8: the bundled NIKON Z 6_2 profile.
/// 9: the bundled NIKON Z 8 and NIKON D5300 profiles.
/// 10: DNG tone fit for profiles with a look table and for 1024 px (Lightroom) previews.
pub const LOOK_VERSION: u32 = 10;
/// 12: tone curves of low-contrast scenes (range limit 1.2, with the bright-area check for curves under 1.5, #699).
pub const LOOK_VERSION: u32 = 12;
#[derive(Clone, Debug)]
pub(crate) struct CameraLook {
@@ -270,20 +271,21 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
// 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()) {
// 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 = match fit_look_with(&sensor, &reference, &clipped, colour.clone(), dro) {
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) };
let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro, grey: true };
let Some(look) = fit_tone_only_with(unclipped.as_ref().unwrap_or(&sensor), &reference, colour, &bright) 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!(
@@ -302,6 +304,12 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
/// the render's highlight reconstruction.
const SENSOR_CLIP: f32 = 0.99;
/// [`fit_look_with`] for a photo without Sony's DRO.
#[cfg(test)]
fn fit_look(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
fit_look_with(sensor, reference, clipped, colour, false)
}
/// 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
@@ -320,17 +328,19 @@ const SENSOR_CLIP: f32 = 0.99;
/// 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> {
fn fit_look_with(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option<HsvTable>)>, dro: bool) -> Option<CameraLook> {
let unclipped = without_clipped(sensor, clipped);
let Some((look, attempt)) = search_ordered(sensor, reference, colour.clone(), TONE_FITS) else {
// from the proxies as they are: the retries' filters (clipped pixels, edges) don't remove them
let bright = HighlightCheck { pairs: highlights(sensor, reference), dro, grey: false };
let Some((look, attempt)) = search_ordered(sensor, reference, colour.clone(), TONE_FITS, &bright) else {
return unclipped
.as_ref()
.and_then(|u| search_ordered(u, reference, colour, TONE_FITS))
.and_then(|u| search_ordered(u, reference, colour, TONE_FITS, &bright))
.map(|(look, _)| look)
.or_else(|| fit_partial(unclipped.as_ref().unwrap_or(sensor), reference));
.or_else(|| fit_partial(unclipped.as_ref().unwrap_or(sensor), reference, &bright));
};
let Some(unclipped) = unclipped else { return Some(look) };
let refit = fit_attempt(&unclipped, reference, &colour, attempt);
let refit = fit_attempt(&unclipped, reference, &colour, attempt, &bright);
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");
}
@@ -389,8 +399,14 @@ fn sony_dro_off_tone() -> Option<CameraTone> {
/// 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();
CameraLook { tone: dro_off(&look.tone), ..look }
}
/// The curve of [`with_dro_off_tone`]: `own` lowered to Sony's without DRO wherever that is darker,
/// or `own` unchanged when it can't be.
fn dro_off(own_tone: &CameraTone) -> CameraTone {
let Some(dro_off) = sony_dro_off_tone() else { return *own_tone };
let own = own_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
@@ -400,11 +416,11 @@ fn with_dro_off_tone(look: CameraLook) -> CameraLook {
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;
let Some(tone) = CameraTone::new(knots).and_then(|tone| tone.with_chroma(*own_tone.chroma())) else { return *own_tone };
if !at_most(&tone, own_tone) {
return *own_tone;
}
CameraLook { tone, ..look }
tone
}
/// Whether curve `a` is nowhere brighter than `b` over the scene luminances the finish stage's tone
@@ -452,10 +468,11 @@ fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens:
let unclipped = without_clipped(&sensor, &clipped);
let fit_on = unclipped.as_ref().unwrap_or(&sensor);
let colour = Some((Mat3::IDENTITY, None));
let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false };
let look = TONE_FITS.iter().find_map(|&tone| {
[false, true]
.into_iter()
.find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true }))
[false, true].into_iter().find_map(|away_from_edges| {
fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true }, &bright)
})
});
let Some(look) = look else { return refuse("fit") };
let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) {
@@ -985,7 +1002,8 @@ fn local_contrast(image: &Rgb32f, i: usize) -> f32 {
/// `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])>;
type Pair = ([f64; 3], [f64; 3]);
type Pairs = Vec<Pair>;
fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option<f32>, dark: bool) -> Option<(Pairs, Pairs)> {
if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() {
return None;
@@ -1147,7 +1165,8 @@ fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt
/// 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)
search_ordered(sensor, reference, colour, order, &HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: false })
.map(|(look, _)| look)
}
/// The tone fits the search tries, in order: the first whose look passes the acceptance gates is
@@ -1177,21 +1196,33 @@ struct Attempt {
/// 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)> {
fn search_ordered(
sensor: &Rgb32f,
reference: &Rgb32f,
colour: Option<(Mat3, Option<HsvTable>)>,
order: &[ToneFit],
bright: &HighlightCheck,
) -> 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, dark: false };
fit_attempt(sensor, reference, &colour, attempt).map(|look| (look, attempt))
fit_attempt(sensor, reference, &colour, attempt, bright).map(|look| (look, attempt))
})
})
})
}
fn fit_attempt(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option<HsvTable>)>, attempt: Attempt) -> Option<CameraLook> {
fn fit_attempt(
sensor: &Rgb32f,
reference: &Rgb32f,
colour: &Option<(Mat3, Option<HsvTable>)>,
attempt: Attempt,
bright: &HighlightCheck,
) -> 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, attempt.dark)
fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone, attempt.dark, bright)
}
fn fit_pairs_on(
@@ -1201,6 +1232,7 @@ fn fit_pairs_on(
edge_limit: Option<f32>,
tone_fit: ToneFit,
dark: bool,
highlights: &HighlightCheck,
) -> 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.
@@ -1228,6 +1260,9 @@ fn fit_pairs_on(
look.tone = tone;
}
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
if !highlights_hold(&look.tone, highlights, colour) {
continue;
}
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);
@@ -1342,11 +1377,11 @@ fn rank_correlation_ties(pairs: &[([f64; 3], [f64; 3])]) -> f64 {
/// 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(sensor: &Rgb32f, reference: &Rgb32f, highlights: &HighlightCheck) -> Option<CameraLook> {
[None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_partial_on(sensor, reference, edge_limit, highlights))
}
fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>) -> Option<CameraLook> {
fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>, highlights: &HighlightCheck) -> 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, false)?;
let related = rank_correlation(&pairs);
@@ -1370,6 +1405,9 @@ fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>)
look.tone = tone;
}
let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
if !highlights_hold(&look.tone, highlights, |x| matrix.apply(x)) {
return None;
}
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) {
@@ -1420,7 +1458,7 @@ fn monochrome(reference: &Rgb32f) -> bool {
/// 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
/// adds the tone curve alone ([`fit_tone_only_with`]), as [`fit_look_with`] 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, lens: Option<&LensGeometry>) -> Option<CameraLook> {
if !file_local_look(raw.format) {
@@ -1430,10 +1468,11 @@ fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, le
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 unclipped = without_clipped(&sensor, &clipped);
let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro, grey: true };
let look = fit_tone_only_with(unclipped.as_ref().unwrap_or(&sensor), &reference, None, &bright)?;
let look = if dro { with_dro_off_tone(look) } else { look };
if lightcraft_pipeline::profiling() {
eprintln!(
@@ -1450,12 +1489,30 @@ fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, le
/// 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))
/// [`MIN_IMPROVEMENT`] of the default curve's, in linear and in gamma-encoded display values. A curve narrower than
/// [`NARROW_TONE_RANGE`] must also keep the JPEG's bright areas ([`highlights_hold`], by luminance).
fn fit_tone_only_with(
sensor: &Rgb32f,
reference: &Rgb32f,
colour: Option<(Mat3, Option<HsvTable>)>,
highlights: &HighlightCheck,
) -> Option<CameraLook> {
[None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_tone_only_on(sensor, reference, &colour, edge_limit, highlights))
}
fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option<HsvTable>)>, edge_limit: Option<f32>) -> Option<CameraLook> {
/// [`fit_tone_only_with`] for a photo without Sony's DRO, checked against its own bright areas.
#[cfg(test)]
fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
fit_tone_only_with(sensor, reference, colour, &HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: true })
}
fn fit_tone_only_on(
sensor: &Rgb32f,
reference: &Rgb32f,
colour: &Option<(Mat3, Option<HsvTable>)>,
edge_limit: Option<f32>,
highlights: &HighlightCheck,
) -> Option<CameraLook> {
// every pair counts: the JPEG has no colour to require
let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit, false)?;
let related = rank_correlation_ties(&pairs);
@@ -1473,6 +1530,9 @@ fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3,
};
let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, coloured)?;
let look = CameraLook { matrix, tone, hue_sat: hue_sat.clone() };
if !highlights_hold(&look.tone, highlights, coloured) {
return None;
}
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);
@@ -1780,6 +1840,89 @@ impl ToneFit {
}
}
/// The bright areas a narrow tone curve is checked against ([`highlights_hold`]): every proxy pixel
/// whose camera JPEG luminance is 0.85 or more, with its sensor value as developed (for a clipped
/// pixel a lower bound of the scene). Taken from the proxies before any training filter (clipped
/// pixels, edges, the sensor value range of [`collect_pairs`]), so no retry of the search can
/// leave them out.
fn highlights(sensor: &Rgb32f, reference: &Rgb32f) -> Pairs {
if sensor.data.len() != reference.data.len() {
return Vec::new();
}
sensor
.data
.iter()
.zip(&reference.data)
.filter(|(x, y)| x.iter().chain(y.iter()).all(|v| v.is_finite() && *v >= 0.0) && luminance_2020(**y) >= 0.85)
.map(|(x, y)| (x.map(f64::from), y.map(f64::from)))
.collect()
}
/// Smallest ratio between the scene luminance of the brightest and of the darkest 1/32 of the
/// pairs a tone curve is fitted to (its last and first knot). Below it the training pairs are
/// nearly uniform (under 0.26 EV from their darker to their brighter parts): the curve would be
/// fitted to a sliver of the tone range and the rest of it extrapolated. Above it the acceptance
/// gates judge the curve. This was 1.5, which rejected low-contrast scenes the curve fits very
/// well: in a hazy ILCE-7RM4 burst the frames at 1.46–1.49 opened with the neutral fallback (no full or partial
/// look, 18 L* darker than the camera JPEG) while the frame at 1.51 fitted at a held-out RMS of
/// 0.013, as the others do once allowed (issue #631). Across 135 public Sony files the lowest is 4.0.
/// A curve under [`NARROW_TONE_RANGE`] must also pass [`highlights_hold`].
const MIN_TONE_RANGE: f32 = 1.2;
/// [`MIN_TONE_RANGE`] before issue #631. A curve fitted to a narrower range than this must also
/// render the photo's bright areas no further from the camera JPEG than the neutral fallback does
/// ([`highlights_hold`]). Neither the fit nor the held-out gates see those areas (camera JPEG
/// luminance 0.85 and up, left out of the training pairs by [`collect_pairs`]), and a curve fitted
/// to a sliver of the tone range extrapolates them: in review, a synthetic frame whose curve passed
/// the gates at a held-out RMS of 0.0004 rendered its bright sky at 0.29 where the camera JPEG has
/// 0.93.
const NARROW_TONE_RANGE: f32 = 1.5;
/// What a narrow tone curve is checked against ([`highlights_hold`]).
struct HighlightCheck {
/// The frame's [`highlights`].
pairs: Pairs,
/// The photo's curve will be lowered to Sony's without DRO ([`with_dro_off_tone`]) after the
/// fit, so the check looks at it lowered.
dro: bool,
/// The camera JPEG is black and white ([`fit_tone_only_with`]): the check compares luminance, as that
/// fit's gates do, since the JPEG has no colour to match.
grey: bool,
}
/// Whether a look with this `tone` and `colour` (sensor value to the finish stage's input) keeps
/// the frame's [`highlights`] (camera JPEG luminance 0.85 and up, left out of the fit) no further
/// from the camera JPEG than the neutral fallback does, with the tone curve as the photo will get
/// it (lowered for DRO), in luminance alone for a black-and-white JPEG. Only asked of curves narrower than
/// [`NARROW_TONE_RANGE`]: true for wider ones, and for frames without such pixels.
fn highlights_hold(tone: &CameraTone, check: &HighlightCheck, colour: impl Fn([f64; 3]) -> [f64; 3]) -> bool {
let knots = tone.knots();
if knots[31][0] >= knots[0][0] * NARROW_TONE_RANGE {
return true;
}
// the curve as the photo will get it
let used = if check.dro { dro_off(tone) } else { *tone };
let (map, neutral) = (ToneMap::camera(&used, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
let (mut look, mut fallback) = (0.0, 0.0);
for (x, target) in &check.pairs {
let (rendered, base) = (displayed(colour(*x), &map), displayed(*x, &neutral));
if check.grey {
look += (luma(rendered) - luma(*target)).powi(2);
fallback += (luma(base) - luma(*target)).powi(2);
continue;
}
for c in 0..3 {
look += (rendered[c] - target[c]).powi(2);
fallback += (base[c] - target[c]).powi(2);
}
}
let hold = look.is_finite() && look <= fallback;
if !hold && lightcraft_pipeline::profiling() {
eprintln!("[profile] camera look: narrow tone curve renders the bright areas worse than the fallback ({look:.3} > {fallback:.3})");
}
hold
}
/// 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
@@ -1807,7 +1950,7 @@ fn fit_tone(pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
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 {
if knots[31][0] < knots[0][0] * MIN_TONE_RANGE {
return None;
}
CameraTone::new(knots)
@@ -1835,7 +1978,7 @@ fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
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 {
if knots[31][0] < knots[0][0] * MIN_TONE_RANGE {
return None;
}
// Pool adjacent violating bins (isotonic regression): no reversals or arbitrary polynomial.
@@ -3504,7 +3647,8 @@ mod tests {
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();
let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false };
let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS, &check).unwrap();
assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false, dark: false });
assert!(without_clipped(&sensor, &[true; 3]).is_none(), "a mask of another size is ignored");
}
@@ -3636,7 +3780,8 @@ mod tests {
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");
let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false };
assert!(fit_partial(&sensor, &reference, &check).is_none(), "no clear gain over the fallback");
// an unrelated picture
for (i, p) in reference.data.iter_mut().enumerate() {
*p = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01];
@@ -3895,8 +4040,10 @@ mod tests {
}
}
let colour = Some((Mat3::IDENTITY, None));
let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true })
.expect("tone fitted");
let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false };
let look =
fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }, &check)
.expect("tone fitted");
assert_eq!(look.matrix, Mat3::IDENTITY);
assert!(look.hue_sat.is_none());
let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
@@ -3993,4 +4140,181 @@ mod tests {
assert!(256.0 < floor(7008.0));
assert!(1024.0 >= floor(9504.0), "a 61 MP raw too");
}
/// Pairs whose scene luminance spans `ratio` from the darkest to the brightest pixel, mapped
/// to the JPEG through a steep camera curve (a hazy scene the camera stretches).
fn low_contrast_pairs(ratio: f64) -> Vec<(f64, f64)> {
(0..4096).map(|i| 0.08 * ratio.powf(i as f64 / 4095.0)).map(|x| (x, (0.2 + (x - 0.08) * 3.0).min(0.95))).collect()
}
/// The ratio the tone fits test: scene luminance at the middle of the brightest 1/32 over that
/// of the darkest 1/32 (their last and first knots).
fn knot_range(mut scene: Vec<f64>) -> f64 {
scene.sort_by(f64::total_cmp);
let n = scene.len();
scene[31 * n / 32 + n / 64] / scene[n / 64]
}
/// Issue #631: a low-contrast scene (its darker and brighter parts under 1.5× apart) still gets
/// a tone curve; only a nearly uniform one doesn't. Both tone fits use the same limit.
#[test]
fn tone_fits_accept_low_contrast_scenes() {
for fit in [fit_tone, fit_tone_conditional] {
let pairs = low_contrast_pairs(1.47);
let range = knot_range(pairs.iter().map(|p| p.0).collect());
assert!((1.4..1.5).contains(&range), "the scene sits just below the old limit of 1.5: {range}");
let curve = fit(pairs).expect("a low-contrast scene, as in the burst of issue #631");
assert!((curve.apply(0.1) - 0.26).abs() < 0.01, "follows the camera curve: {}", curve.apply(0.1));
assert!(fit(low_contrast_pairs(1.1)).is_none(), "a nearly uniform frame");
}
}
/// The same scene end to end: colour varies, luminance spans under 1.5×, and the camera
/// stretches it; the look is accepted and lands on the JPEG instead of the neutral fallback.
#[test]
fn a_low_contrast_scene_gets_a_camera_look() {
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
let ev = 0.06 + (i % 31) as f32 * 0.0008;
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
let p = known.apply_f32(*src);
let y = luminance_2020(p);
*dst = p.map(|v| v * (0.2 + (y - 0.07) * 3.0) / y);
}
let look = fit_pairs(&sensor, &reference).expect("a camera look, not the neutral fallback");
let range = knot_range(sensor.data.iter().map(|x| luma(look.matrix.apply(x.map(f64::from)))).collect());
assert!(range < 1.5, "the scene sits below the old limit of 1.5: {range}");
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let error: f64 = sensor
.data
.iter()
.zip(&reference.data)
.map(|(x, y)| {
let p = displayed(look.matrix.apply(x.map(f64::from)), &tone);
(0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::<f64>() / 3.0
})
.sum::<f64>()
/ sensor.data.len() as f64;
assert!(error.sqrt() < 0.02, "{}", error.sqrt());
}
/// Codex review of #631: a low-contrast scene's curve is fitted to its midtones only, and the
/// camera JPEG's bright areas (luminance 0.85 and up) are in neither the fit nor the held-out
/// gates. With 1.2 alone, this frame's curve passed the gates and rendered its bright sky
/// (camera JPEG 0.93) at about 0.28. The second review found the same through the retries'
/// filters: a clipped sky (left out by the refit without clipped pixels), scattered bright
/// pixels (left out away from edges) and sensor values over 1.5 (out of the pairs' range).
#[test]
fn a_narrow_curve_that_darkens_the_bright_areas_is_refused() {
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
for (case, sky_value, is_sky) in [
("bright sky", 0.9, &(|i: usize| i < 5600) as &dyn Fn(usize) -> bool),
("clipped sky", 1.0, &|i: usize| i < 5600),
("scattered bright pixels", 0.9, &|i: usize| i.is_multiple_of(8)),
("sky over the pairs' range", 1.6, &|i: usize| i < 5600),
] {
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
let mut clipped = vec![false; sensor.data.len()];
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
if is_sky(i) {
// bright, nearly white in the camera JPEG
*src = [sky_value; 3];
*dst = [0.93; 3];
clipped[i] = case == "clipped sky";
continue;
}
let ev = 0.06 + (i % 31) as f32 * 0.0008;
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
let p = known.apply_f32(*src);
let y = luminance_2020(p);
// nearly flat midtones: the fitted curve's extrapolation can't reach the sky
*dst = p.map(|v| v * (0.2 + (y - 0.07) * 0.05) / y);
}
let Some(look) = fit_look(&sensor, &reference, &clipped, None) else { continue };
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let correction = look.hue_sat.as_ref().and_then(HueSat::new);
let p = look.matrix.apply([f64::from(sky_value); 3]);
let p = correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from));
let sky = luma(displayed(p, &tone));
assert!(sky > 0.8, "{case}: an accepted look must not darken what the camera renders at 0.93: {sky}");
}
}
/// Third Codex review of #631: for a Sony photo shot with DRO, the fitted curve is lowered to
/// Sony's without DRO after the fit, so it must be checked as lowered. On this frame the
/// fitted curve keeps the sky (0.81 against the fallback's 0.79), and lowered it rendered the
/// sky at 0.61 where the camera JPEG has 0.93.
#[test]
fn a_narrow_curve_is_checked_as_lowered_for_dro() {
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
if i < 5600 {
*src = [0.9; 3];
*dst = [0.93; 3];
continue;
}
let ev = 0.06 + (i % 31) as f32 * 0.0008;
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
let p = known.apply_f32(*src);
let y = luminance_2020(p);
*dst = p.map(|v| v * (0.6 + (y - 0.07) * 0.25) / y);
}
let neutral = luma(displayed([0.9; 3], &ToneMap::new(0.0, 0.0, 0.0)));
if let Some(look) = fit_look_with(&sensor, &reference, &vec![false; sensor.data.len()], None, true) {
let look = with_dro_off_tone(look);
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let correction = look.hue_sat.as_ref().and_then(HueSat::new);
let p = look.matrix.apply([0.9; 3]);
let p = correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from));
let sky = luma(displayed(p, &tone));
assert!(sky > neutral - 0.02, "the DRO-lowered curve must not darken the sky past the fallback ({neutral}): {sky}");
}
}
/// A black-and-white JPEG of a low-contrast scene (issues #577 and #631): grey pixels, scene luminance spanning
/// under 1.5×, which the old range limit refused before any gate. Its tone curve is fitted.
#[test]
fn a_low_contrast_black_and_white_jpeg_gives_its_tone_curve() {
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
let ev = 0.06 + (i % 31) as f32 * 0.0008;
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
*dst = [0.2 + (luminance_2020(*src) - 0.06) * 3.0; 3];
}
let range = knot_range(sensor.data.iter().map(|x| luma(x.map(f64::from))).collect());
assert!(range < 1.5, "the scene sits below the old limit of 1.5: {range}");
let look = fit_tone_only(&sensor, &reference, None).expect("the JPEG's tone curve");
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
let error = sensor.data.iter().zip(&reference.data).map(|(x, y)| (luma(displayed(x.map(f64::from), &tone)) - f64::from(y[0])).powi(2));
let rms = (error.sum::<f64>() / sensor.data.len() as f64).sqrt();
assert!(rms < 0.02, "{rms}");
}
/// [`a_narrow_curve_that_darkens_the_bright_areas_is_refused`] for a black-and-white JPEG: its tone curve is
/// fitted to nearly flat midtones under 1.5×, and the bright sky (camera JPEG 0.93) is left out of the fit.
#[test]
fn a_narrow_black_and_white_curve_that_darkens_the_bright_areas_is_refused() {
let mut sensor = Rgb32f::new(96, 64);
let mut reference = sensor.clone();
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
if i < 5600 {
*src = [0.9; 3];
*dst = [0.93; 3];
continue;
}
let ev = 0.06 + (i % 31) as f32 * 0.0008;
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
*dst = [0.2 + (luminance_2020(*src) - 0.06) * 0.05; 3];
}
if let Some(look) = fit_tone_only(&sensor, &reference, None) {
let sky = luma(displayed(look.matrix.apply([0.9; 3]), &ToneMap::camera(&look.tone, 0.0, 0.0, 0.0)));
assert!(sky > 0.8, "an accepted curve must not darken what the camera renders at 0.93: {sky}");
}
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ impl SettingsHashes {
}
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 65;
pub const RENDER_CACHE_VERSION: u64 = 69;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];