mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
commit
d9f63f0b7b
3 files changed
+227
-4
No files matched your search
@@ -88,11 +88,16 @@ const SENSOR_CLIP: f32 = 0.99;
|
||||
/// 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.
|
||||
/// 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.and_then(|u| search_ordered(&u, reference, colour, TONE_FITS)).map(|(look, _)| look);
|
||||
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);
|
||||
@@ -617,6 +622,106 @@ fn fit_pairs_on(
|
||||
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 }
|
||||
}
|
||||
|
||||
/// 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_pairs: Vec<_> =
|
||||
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(matrix.apply(*x).map(|v| v.max(0.0))), luma(*y))).collect();
|
||||
let mut look = CameraLook { matrix, tone: fit_tone(tone_pairs)?, 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)
|
||||
}
|
||||
|
||||
/// 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())
|
||||
@@ -1683,4 +1788,120 @@ mod tests {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 27;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 28;
|
||||
|
||||
/// 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];
|
||||
|
||||
@@ -16,7 +16,9 @@ Matrix-only and matrix + table looks are each completed (tone curve, then chroma
|
||||
|
||||
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.
|
||||
|
||||
One third of the pixels are held out from both fits. The combined model must reduce linear-display squared error by at least 30% and have per-channel RMS error ≤0.10 (0.055 rejected half of the public raw.pixls.us samples tried — ILCE-6400, -6700, -7M4 — whose fits were still 1.5–3× closer to the camera JPEG than the fallback and visibly better). When the fit on all pixels fails these gates, it is tried once more, with the same gates, on the pixels away from edges (where the 3×3 neighbourhood in either proxy spans a luminance ratio above 3). Many camera JPEGs are lens-corrected while the raw is not (Sony "Distortion Comp.: Auto", compacts and kit zooms), and on a detailed scene the misaligned edges alone fail the gate although the colours agree everywhere else (issue #232). The public ILCE-7RM2 samples (raw.pixls.us, one façade shot in seven compression/correction variants, 24–70 mm at 51 mm) are up to 15 px of 1440 off their JPEG; the six with distortion correction on missed the gate at held-out RMS 0.108–0.115 and opened in the grey neutral fallback, 13 L* too dark (ΔE76 16.6–16.9 against the JPEG after registering its geometry). Away from edges they pass at 0.049–0.052 and land at ΔE 3.7–3.8 with lightness within 0.6 L*, like the variant shot with distortion correction off (0.059 on all pixels, ΔE 3.9). The corpus GX80 kit-zoom sample (up to 63 px of 1440 off) passes too: 0.107 → 0.081, ΔE 19.1 → 7.9. Fits accepted on all pixels are unchanged. These are provisional acceptance gates, not a colour-accuracy certification. Monochrome, singular, unrelated, aspect-mismatched and poor-fitting references retain the documented fallback. Both fits use the same fixed proxy at every decode resolution.
|
||||
One third of the pixels are held out from both fits. The combined model must reduce linear-display squared error by at least 30% and have per-channel RMS error ≤0.10 (0.055 rejected half of the public raw.pixls.us samples tried — ILCE-6400, -6700, -7M4 — whose fits were still 1.5–3× closer to the camera JPEG than the fallback and visibly better). When the fit on all pixels fails these gates, it is tried once more, with the same gates, on the pixels away from edges (where the 3×3 neighbourhood in either proxy spans a luminance ratio above 3). Many camera JPEGs are lens-corrected while the raw is not (Sony "Distortion Comp.: Auto", compacts and kit zooms), and on a detailed scene the misaligned edges alone fail the gate although the colours agree everywhere else (issue #232). The public ILCE-7RM2 samples (raw.pixls.us, one façade shot in seven compression/correction variants, 24–70 mm at 51 mm) are up to 15 px of 1440 off their JPEG; the six with distortion correction on missed the gate at held-out RMS 0.108–0.115 and opened in the grey neutral fallback, 13 L* too dark (ΔE76 16.6–16.9 against the JPEG after registering its geometry). Away from edges they pass at 0.049–0.052 and land at ΔE 3.7–3.8 with lightness within 0.6 L*, like the variant shot with distortion correction off (0.059 on all pixels, ΔE 3.9). The corpus GX80 kit-zoom sample (up to 63 px of 1440 off) passes too: 0.107 → 0.081, ΔE 19.1 → 7.9. Fits accepted on all pixels are unchanged. These are provisional acceptance gates, not a colour-accuracy certification. Monochrome, singular, unrelated and aspect-mismatched references retain the documented fallback; poor-fitting ones get the partial look below when it is clearly closer to the camera JPEG. Both fits use the same fixed proxy at every decode resolution.
|
||||
|
||||
**A partial look when the full look misses the gates.** The neutral fallback (camera RGB taken as sRGB, the default tone map) is far from the camera JPEG. Rendered for every photo on this path in the CC0 archive (1,150 files with a camera JPEG), it is 10.7 L\* too dark (median; about one stop), keeps 0.36 of the camera's colourfulness and has hues 7° off on the coloured pixels (median CIEDE2000 13.6). Its white balance is right: on the pixels the camera renders near-neutral the cast is under 1 a\*/b\* (median, every maker). The "blue" fallback on newer Sony bodies (Δb\* −22 over the whole frame on the ILCE-7RM5 sample) is a warm scene losing its colour, not a cast: its near-neutral pixels are within −1.6 b\*. So before falling back, a partial look is fitted (`fit_partial`): the quantile tone curve (registration-free, the largest part of the error), the chroma curve, and, when the scene has enough colour to learn a matrix (the full look's condition), the photo's own colour matrix at half weight, the other half the identity (`PARTIAL_MATRIX_WEIGHT`). No hue/saturation table. Half weight because, on the held-out proxy pixels of the 23 colourful photos without a look, the full matrix left 2 of them more than 2° further from the camera's hues than the neutral fallback and half weight none (median hue error 9.6° with the generic colour, 5.1° at half weight, 4.2° at full weight; on the 1,080 photos with a look 3.9° and 2.7°). Without a matrix (tone and chroma curve alone, or with one global saturation scale) about a third of all photos ended up more than 2° further from the camera's hues than the fallback, as the added colour makes the generic matrix's hue errors visible. The partial look is tried on all pixels, then away from edges, and used only when it cuts the held-out squared error below 0.7 of the neutral fallback's in linear and in gamma-encoded display values, and when the JPEG's luminance ranks the pixels like the scene's (Spearman ≥ 0.5, `PARTIAL_MIN_RANK_CORRELATION`): a curve matched to the brightness of an unrelated picture also beats the dark fallback pixel for pixel. The accepted full looks have 0.55 or more; three old Coolpix files whose raws decode wrongly have 0.07–0.27 and keep the fallback. There is no limit on the partial look's own error. On the archive (1,783 renders): all 1,087 full looks are byte-identical; of the 55 photos that reach the partial fit, 49 get it (40 on all pixels, 9 away from edges; 13 with the damped matrix, 41 with a chroma curve) and 6 keep the fallback (lens-corrected compacts: RX10, TZ80 1:1, D-LUX 4, G10 16:9, GF2 ×2). On the 49, median CIEDE2000 to the camera JPEG 14.0 → 2.1, ΔL\* −11.5 → +0.6, colourfulness 0.36 → 0.87, hue error 10.1° → 7.1°; every one is closer, by at least 1.3. Most of the others without a look never reach a fit: on 48 files (Nikon D4, Df, D300/D300S, D7000, D5100 and others) the camera JPEG proxy comes out a pixel narrower than the sensor proxy and no pairs are collected (the resize in PR #531 fixes this; with it 46 of them pass the full look's gates, CIEDE2000 17.9 → 2.9).
|
||||
|
||||
## Source metadata and white balance
|
||||
|
||||
|
||||
Reference in new issue
Block a user