From 24427912cb71725325c320e15af3f41ae8ab1495 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Fri, 9 Oct 2026 05:41:24 -0400 Subject: [PATCH 1/4] LR-PROF-CAMERACOLOR: fit the camera tone curve by quantile matching (no flat steps) The per-photo look fitted its tone curve as the camera JPEG's median per scene-luminance bin followed by isotonic pooling. On lens-corrected, sharpened JPEGs of busy scenes neighbouring bins disagree by more than the gap between them, so pooling produced flat steps: 13 of 31 segments on a CC0 Z 6II sample, flat segments in 9 of 13 fitted Nikon Z / ILCE-7 samples. Each step maps a range of scene tones to one output tone, which renders as posterised patches with hard edges (issue #475, also #211, #454), and the conditional medians flatten both ends of the curve. Quantile matching (sorted scene quantiles against sorted JPEG quantiles) is increasing by construction and insensitive to misregistration. On the same 13 files no fit has a flat segment, all still pass the gates, held-out RMS within 0.005 of before. Regression test: a misregistered synthetic JPEG. --- crates/engine/src/camera_preview.rs | 80 +++++++++++++++++------------ docs/camera-preview-colour.md | 2 +- 2 files changed, 49 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 55ea2d808..0ce4b8d71 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -681,47 +681,36 @@ fn fit_chroma(pairs: &[([f64; 3], [f64; 3])], look: &CameraLook) -> Option Option { - values.sort_by(f64::total_cmp); - values.get(values.len() / 2).copied() -} - -fn fit_tone(mut pairs: Vec<(f64, f64)>) -> Option { +/// 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 { 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 xs: Vec = pairs.iter().map(|p| p.0).collect(); + let mut ys: Vec = 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 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]; + 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; } - // 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) } @@ -1228,4 +1217,31 @@ mod tests { 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; + } + } } diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 31be10f59..c1c219b69 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -10,7 +10,7 @@ Two thirds of the usable pixels train a ridge-regularised 3×3 **chromaticity** The JPEG's colourfulness also depends on brightness, as a camera's per-channel tone curve makes it: relative to a luminance-only curve, shadows were up to 1.4× more colourful and highlights above 0.85 luminance bleached to 0.1–0.3×, so warm sunlit rock rendered cream instead of white (+4 b* in the brightest fifth of the picture). A **chroma curve** (8 nodes over display luminance, `CameraTone::chroma`) is fitted to that ratio, including the bright pixels the colour and tone fits leave out, and applied in the finish stage right after the tone map (CPU and GPU), scaling chroma toward luminance so luminance is kept. It is kept only when it lowers the held-out error; smart previews carry it in their header, and older headers load without one. -Matrix-only and matrix + table looks are each completed (tone curve, then chroma curve) and compared on the held-out pixels in gamma-encoded display values: in linear values a slightly missed bright rock outweighed a clearly wrong dark shirt. The acceptance gates below still use linear values. Luminance is fitted separately: 32 equal-population bins with robust median knots and pooled-adjacent-violators isotonic regression. The curve preserves black, cannot reverse, and extends beyond observed highlights with a bounded shoulder. +Matrix-only and matrix + table looks are each completed (tone curve, then chroma curve) and compared on the held-out pixels in gamma-encoded display values: in linear values a slightly missed bright rock outweighed a clearly wrong dark shirt. The acceptance gates below still use linear values. Luminance is fitted separately, 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 scene quantile to the same JPEG quantile; this needs no pixel-exact registration and is increasing by construction. The earlier fit (the JPEG's median per scene-luminance bin, then pooled-adjacent-violators isotonic regression) broke on lens-corrected, sharpened JPEGs of busy scenes: neighbouring bins disagreed by more than the gap between them, pooling turned them into flat steps (13 of 31 segments on a CC0 Z 6II forest shot, flat segments in 9 of 13 fitted Nikon Z / ILCE-7 samples) that rendered as posterised patches with hard edges, and regression toward the mean flattened both ends of the curve (issue #475, also #211, #454). With quantile matching none of those 13 fits has a flat segment and the held-out RMS is within 0.005 of before. The curve preserves black, cannot reverse, and extends beyond observed highlights with a bounded shoulder. The matrix is applied to scene-linear sensor pixels. The curve is carried in `SourceInfo` and applied in the pipeline finish stage, after exposure/WB/local edits. It is never baked into decoded luminance or clipped to JPEG precision. CPU and GPU use the same tone LUT. Contrast, whites and blacks adjust the starting look through the display-domain controls. JPEG neutral rendering and the existing DNG colour model are unchanged. From 0778874d137c4cfa1bcf15fed775feddc3140c5a Mon Sep 17 00:00:00 2001 From: radialmonster Date: Fri, 9 Oct 2026 05:45:01 -0400 Subject: [PATCH 2/4] LR-PROF-CAMERACOLOR: bump the render cache version for the new tone fit Renders change for files with a fitted camera look. Open PRs already claim versions 17, 18 and 19. --- crates/engine/src/media.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index b0439390d..1b8e8b15f 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 16; +pub const RENDER_CACHE_VERSION: u64 = 20; /// 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]; From c691bcfa02c9247045c7e62160af5e403772d6c3 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Fri, 9 Oct 2026 07:08:01 -0400 Subject: [PATCH 3/4] LR-PROF-CAMERACOLOR: fall back to the conditional-median tone fit when the quantile look misses the gates Quantile matching fits the held-out pixels slightly worse than the conditional-median fit it replaced. On 7 of 1023 archive files (compacts, bridge cameras, early Micro Four Thirds) the look passed the acceptance gates only with the old curve, and the quantile curve lost it. The search now runs with the quantile fit first (all pixels, away from edges, with and without the camera profile) and, only when no look passes, runs again with the earlier conditional-median plus isotonic fit, restored as fit_tone_conditional. The gates are unchanged, so no file loses its look relative to the previous fit, and the quantile curve is used wherever it passes. Tests: the flat-step test stays; a synthetic photo whose JPEG carries per-pixel noise misses the gates with the quantile fit and passes with the fallback, and the order of the two is checked. --- crates/engine/src/camera_preview.rs | 151 ++++++++++++++++++++++++++-- 1 file changed, 142 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 0ce4b8d71..d6fdda14a 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -402,19 +402,38 @@ const EDGE_CONTRAST: f32 = 3.0; /// it is tried 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. fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option { + fit_pairs_ordered(sensor, reference, colour, &[ToneFit::Quantile, ToneFit::ConditionalMedian]) +} + +/// [`fit_pairs_with`] with the tone fits to try, 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. The whole search (all pixels, then away from edges, with and without the +/// camera profile's colour) runs with one tone fit before the next is tried, so the fallback +/// accepts exactly what the previous releases accepted. +fn fit_pairs_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>, order: &[ToneFit]) -> Option { let has_profile = colour.is_some(); - // A different picture style can make a camera profile fail the gates. Preserve the - // photo's own colour fit before resorting to the neutral fallback. - fit_candidate(sensor, reference, colour).or_else(|| has_profile.then(|| fit_candidate(sensor, reference, None)).flatten()) + order.iter().find_map(|&tone| { + // A different picture style can make a camera profile fail the gates. Preserve the + // photo's own colour fit before resorting to the neutral fallback. + fit_candidate(sensor, reference, colour.clone(), tone).or_else(|| has_profile.then(|| fit_candidate(sensor, reference, None, tone)).flatten()) + }) } /// One candidate colour model, fitted on all pixels and, when that fails the gates, once more on /// the pixels away from edges (issue #232). -fn fit_candidate(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option { - fit_pairs_on(sensor, reference, colour.clone(), None).or_else(|| fit_pairs_on(sensor, reference, colour, Some(EDGE_CONTRAST))) +fn fit_candidate(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>, tone: ToneFit) -> Option { + fit_pairs_on(sensor, reference, colour.clone(), None, tone).or_else(|| fit_pairs_on(sensor, reference, colour, Some(EDGE_CONTRAST), tone)) } -fn fit_pairs_on(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>, edge_limit: Option) -> Option { +fn fit_pairs_on( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: Option<(Mat3, Option)>, + edge_limit: Option, + tone_fit: ToneFit, +) -> Option { // 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)?; @@ -437,7 +456,7 @@ fn fit_pairs_on(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Optio }; let tone_pairs: Vec<_> = pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(colour(*x).map(|v| v.max(0.0))), luma(*y))).collect(); - let Some(curve) = fit_tone(tone_pairs) else { continue }; + let Some(curve) = tone_fit.fit(tone_pairs) 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; @@ -468,10 +487,11 @@ fn fit_pairs_on(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Optio .sum(); if lightcraft_pipeline::profiling() { eprintln!( - "[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels{})", + "[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 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) { @@ -681,6 +701,24 @@ fn fit_chroma(pairs: &[([f64; 3], [f64; 3])], look: &CameraLook) -> Option) -> Option { + 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 @@ -714,6 +752,55 @@ fn fit_tone(pairs: Vec<(f64, f64)>) -> Option { CameraTone::new(knots) } +fn median(values: &mut [f64]) -> Option { + 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 { + 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::*; @@ -1244,4 +1331,50 @@ mod tests { 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) + } + + /// 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); + } } From cdc2e577764f6f43abd15b770a36d62073c26927 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Fri, 9 Oct 2026 07:31:46 -0400 Subject: [PATCH 4/4] LR-PROF-CAMERACOLOR: docs: archive-wide numbers for the tone fit and its fallback The earlier text quoted the 13 investigation files (held-out RMS within 0.005); the whole CC0 archive shows a worst increase of 0.013 and 7 files that need the conditional-median fallback. --- docs/camera-preview-colour.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index c1c219b69..47aacb644 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -10,7 +10,7 @@ Two thirds of the usable pixels train a ridge-regularised 3×3 **chromaticity** The JPEG's colourfulness also depends on brightness, as a camera's per-channel tone curve makes it: relative to a luminance-only curve, shadows were up to 1.4× more colourful and highlights above 0.85 luminance bleached to 0.1–0.3×, so warm sunlit rock rendered cream instead of white (+4 b* in the brightest fifth of the picture). A **chroma curve** (8 nodes over display luminance, `CameraTone::chroma`) is fitted to that ratio, including the bright pixels the colour and tone fits leave out, and applied in the finish stage right after the tone map (CPU and GPU), scaling chroma toward luminance so luminance is kept. It is kept only when it lowers the held-out error; smart previews carry it in their header, and older headers load without one. -Matrix-only and matrix + table looks are each completed (tone curve, then chroma curve) and compared on the held-out pixels in gamma-encoded display values: in linear values a slightly missed bright rock outweighed a clearly wrong dark shirt. The acceptance gates below still use linear values. Luminance is fitted separately, 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 scene quantile to the same JPEG quantile; this needs no pixel-exact registration and is increasing by construction. The earlier fit (the JPEG's median per scene-luminance bin, then pooled-adjacent-violators isotonic regression) broke on lens-corrected, sharpened JPEGs of busy scenes: neighbouring bins disagreed by more than the gap between them, pooling turned them into flat steps (13 of 31 segments on a CC0 Z 6II forest shot, flat segments in 9 of 13 fitted Nikon Z / ILCE-7 samples) that rendered as posterised patches with hard edges, and regression toward the mean flattened both ends of the curve (issue #475, also #211, #454). With quantile matching none of those 13 fits has a flat segment and the held-out RMS is within 0.005 of before. The curve preserves black, cannot reverse, and extends beyond observed highlights with a bounded shoulder. +Matrix-only and matrix + table looks are each completed (tone curve, then chroma curve) and compared on the held-out pixels in gamma-encoded display values: in linear values a slightly missed bright rock outweighed a clearly wrong dark shirt. The acceptance gates below still use linear values. Luminance is fitted separately, 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 scene quantile to the same JPEG quantile; this needs no pixel-exact registration and is increasing by construction. The earlier fit (the JPEG's median per scene-luminance bin, then pooled-adjacent-violators isotonic regression) broke on lens-corrected, sharpened JPEGs of busy scenes: neighbouring bins disagreed by more than the gap between them, pooling turned them into flat steps (13 of 31 segments on a CC0 Z 6II forest shot) that rendered as posterised patches with hard edges, and regression toward the mean flattened both ends of the curve (issue #475, also #211, #454). On the 1,781 files of the CC0 archive that render, 384 of the 1,023 accepted fits had at least one flat segment (1,073 in total, up to 14 of 31); with quantile matching 1,017 of the 1,023 have none. The quantile fit is slightly worse by least squares than the conditional medians (median held-out RMS 0.0331 against 0.0330, worst +0.013), and on 7 files (compacts, bridge cameras, early Micro Four Thirds) the look cleared the acceptance gates only with the old curve. The search therefore runs once with the quantile fit (all pixels, away from edges, with and without the camera profile's colour) and, only when no look passes, once more with the earlier conditional-median fit, kept as `fit_tone_conditional`; the gates are the same, so no file loses its look. Those 7 files render exactly as before, and 6 of them still have flat segments (35 in total). The curve preserves black, cannot reverse, and extends beyond observed highlights with a bounded shoulder. The matrix is applied to scene-linear sensor pixels. The curve is carried in `SourceInfo` and applied in the pipeline finish stage, after exposure/WB/local edits. It is never baked into decoded luminance or clipped to JPEG precision. CPU and GPU use the same tone LUT. Contrast, whites and blacks adjust the starting look through the display-domain controls. JPEG neutral rendering and the existing DNG colour model are unchanged.