Merge PR #3402: RAW.colour: fit the per-file look on the vignetting-corrected sensor

Conflicts: crates/engine/src/camera_preview.rs: LOOK_VERSION stays 13, all three version notes kept; docs/raw-parity.md header merged (newer date, both changes)
This commit is contained in:
aiongg
2026-10-31 02:10:02 +00:00
8 changed files with 233 additions and 60 deletions
+3
View File
@@ -81,6 +81,9 @@ use tone::{HighlightCheck, ToneFit, fit_tone, fit_tone_conditional, highlights};
/// stored with each smart preview.
/// 13: tone curves no steeper than a log-log slope of 4 between knots (#1904).
/// 13: the Canon CR3/CR2 camera JPEG read in the colour space of the Canon maker note (#2654).
/// 13: the sensor proxy of every per-file fit gets the embedded vignetting correction the render applies (Fujifilm
/// RAF; other makes with a lens database), so the render no longer applies it twice, at the position a lens warp samples;
/// the warped candidate's alignment leaves out the pixels outside the sensor instead of reading them as black.
pub const LOOK_VERSION: u32 = 13;
#[derive(Clone, Debug)]
+7 -6
View File
@@ -1,6 +1,6 @@
use super::look_fit::{fit_dng_tone, fit_preview};
use super::look_fit::{fit_dng_tone, fit_preview, look_proxies};
use super::mono::{fit_dark_tone, monochrome_look};
use super::proxy::{LensGeometry, proxies};
use super::proxy::LensGeometry;
use super::{CameraLook, CameraTone, CameraTransform, ColorData, D50, D65, Mat3, ProfileLook, REC2020, RawFormat, RawImage, ToneCurve, Xy, bradford};
/// Long edge of the sensor/JPEG proxy a single photo's look is fitted on (the acceptance gates
@@ -42,7 +42,8 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool {
/// as-shot white, the camera transform at it and the fitted look.
/// `lens` is the file's own lens-correction warp ([`lens_geometry`], read before the caller removes it from
/// `OpcodeList3`): the camera applied it to its JPEG, so the sensor proxy is warped the same way before pairing.
/// `embedded` is the file's embedded lens vignetting, for the DNG tone fit.
/// `embedded` is the file's embedded lens correction: its vignetting is applied to the sensor proxy of every fit, as
/// the render applies it.
pub(crate) fn starting_colour(
raw: &mut RawImage,
bytes: &[u8],
@@ -51,13 +52,13 @@ pub(crate) fn starting_colour(
) -> (Xy, CameraTransform, Option<CameraLook>) {
let (xy, t, look) = starting_colour_with(
raw,
|raw, t| fit_preview(raw, bytes, t, lens),
|raw, t| monochrome_look(raw, bytes, t, lens),
|raw, t| fit_preview(raw, bytes, t, lens, embedded),
|raw, t| monochrome_look(raw, bytes, t, lens, embedded),
|raw, t| {
if !file_local_look(raw.format) {
return None;
}
let (sensor, reference, clipped) = proxies(raw, bytes, t, PROXY, 0.0, lens)?;
let (sensor, reference, clipped) = look_proxies(raw, bytes, t, lens, embedded)?;
fit_dark_tone(&sensor, &reference, &clipped)
},
);
+26 -3
View File
@@ -6,11 +6,18 @@ use super::tone::{HighlightCheck, highlights};
use super::{CameraLook, CameraTone, CameraTransform, HsvTable, Mat3, RawImage, Rgb32f, ToneMap, luminance_2020};
/// 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, lens: Option<&LensGeometry>) -> (Option<CameraLook>, bool) {
/// `embedded`: the file's embedded lens correction, whose vignetting the render applies ([`look_proxies`]).
pub(crate) fn fit_preview(
raw: &RawImage,
bytes: &[u8],
transform: &CameraTransform,
lens: Option<&LensGeometry>,
embedded: Option<&lightcraft_develop::EmbeddedLens>,
) -> (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, 0.0, lens) else { return (None, false) };
let Some((sensor, reference, clipped)) = look_proxies(raw, bytes, transform, lens, embedded) 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).
@@ -207,7 +214,9 @@ pub(super) fn fit_dng_tone(
}
None
};
let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION, None) else { return refuse("preview") };
let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION, None, None) else {
return refuse("preview");
};
// As the loader renders it: the profile's tables around the baseline exposure.
let xy = lightcraft_raw::color::as_shot_white_xy(raw);
if let Some(tables) = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)) {
@@ -257,6 +266,20 @@ pub(super) fn clips_more(sensor: &Rgb32f, reference: &Rgb32f, fitted: &ToneMap,
near_white(fitted) > near_white(plain).max(preview) + CLIP_TOLERANCE
}
/// The proxies a photo's look is fitted on ([`proxies`] at [`PROXY`]), the sensor's with the embedded vignetting
/// correction the render applies. Without it the tone curve learns the camera JPEG's corrected
/// brightness from the uncorrected sensor, and the render then corrects it again: 21 X-S10 RAFs rendered up to 0.3 EV
/// brighter in their midtones and 0.6 EV in their shadows than their camera JPEGs, about 0.05 EV once corrected.
pub(super) fn look_proxies(
raw: &RawImage,
bytes: &[u8],
transform: &CameraTransform,
lens: Option<&LensGeometry>,
embedded: Option<&lightcraft_develop::EmbeddedLens>,
) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> {
proxies(raw, bytes, transform, PROXY, 0.0, lens, embedded)
}
/// Multiply the sensor proxy (sensor orientation, default crop) by the embedded vignetting correction, the gain the
/// pipeline applies to the render (`optics::embedded_vignette_gain`; `lens` is in the oriented, cropped frame): the
/// camera JPEG has it applied, so a tone fitted without it brightens the render's corners twice.
+12 -6
View File
@@ -1,11 +1,11 @@
use super::colour::{PROXY, file_local_look};
use super::colour::file_local_look;
use super::hue_sat::HueSat;
use super::look_fit::{with_dro_off_tone, without_clipped};
use super::look_fit::{look_proxies, with_dro_off_tone, without_clipped};
use super::pairs::{
EDGE_CONTRAST, MIN_IMPROVEMENT, PARTIAL_MIN_RANK_CORRELATION, Pairs, collect_pairs, displayed, fit_tone_with_shadows, local_contrast, luma,
rank_correlation_ties,
};
use super::proxy::{LensGeometry, proxies};
use super::proxy::LensGeometry;
use super::tone::{HighlightCheck, ToneFit, fit_tone, highlights, highlights_hold};
use super::{CameraLook, CameraTransform, D50, D65, HsvTable, Mat3, PROPHOTO, REC2020, RawImage, Rgb32f, ToneMap, bradford, luminance_2020};
@@ -33,12 +33,18 @@ pub(super) fn monochrome(reference: &Rgb32f) -> bool {
/// 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_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.
pub(super) fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> Option<CameraLook> {
/// chroma curve stays neutral: the JPEG has no colourfulness to learn. `embedded` as for [`fit_preview`].
pub(super) fn monochrome_look(
raw: &RawImage,
bytes: &[u8],
transform: &CameraTransform,
lens: Option<&LensGeometry>,
embedded: Option<&lightcraft_develop::EmbeddedLens>,
) -> Option<CameraLook> {
if !file_local_look(raw.format) {
return None;
}
let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, 0.0, lens)?;
let (sensor, reference, clipped) = look_proxies(raw, bytes, transform, lens, embedded)?;
if !monochrome(&reference) {
return None;
}
+10 -4
View File
@@ -1,6 +1,6 @@
use super::colour::{PROFILE_PROXY, PROXY, file_local_look};
use super::hue_sat::fit_hue_sat;
use super::look_fit::{FIT_PREVIEW_MAX_PIXELS, SENSOR_CLIP, clip_mask, without_clipped};
use super::look_fit::{FIT_PREVIEW_MAX_PIXELS, SENSOR_CLIP, clip_mask, correct_vignetting, without_clipped};
use super::pairs::{collect_pairs, fit_matrix};
use super::{CameraTransform, Filter, HsvTable, Mat3, RawFormat, RawImage, Rgb32f, fit, luminance_2020, resize};
@@ -82,7 +82,8 @@ pub(super) fn structure_agrees(sensor: &Rgb32f, reference: &Rgb32f) -> bool {
/// 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.
/// and per proxy pixel whether any sensor sample it covers is clipped. `embedded`'s vignetting correction is applied to
/// the sensor ([`correct_vignetting`]) before any `lens` warp, at the source position, as the render applies it.
pub(super) fn proxies(
raw: &RawImage,
bytes: &[u8],
@@ -90,6 +91,7 @@ pub(super) fn proxies(
size: usize,
min_fraction: f64,
lens: Option<&LensGeometry>,
embedded: Option<&lightcraft_develop::EmbeddedLens>,
) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> {
let edge = (2 * size).max(384) as u32;
let decoded = crate::files::decode_raw_preview(
@@ -125,6 +127,7 @@ pub(super) fn proxies(
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);
correct_vignetting(&mut sensor, embedded, raw.orientation);
// The camera may have corrected its JPEG with the lens warp the file carries (Nikon Z lenses always, at up to 9 %
// zoom; other cameras when their correction setting is on): the sensor moved into that geometry is a candidate,
// kept below when it lines up with the JPEG better than the uncorrected one.
@@ -319,7 +322,7 @@ pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8], lens: Option<&LensGeom
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, 0.0, lens)?;
let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, 0.0, lens, None)?;
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);
@@ -369,7 +372,10 @@ pub(super) fn alignment(a: &Rgb32f, b: &Rgb32f) -> f64 {
if (b.width, b.height) != (w, h) || w < 3 || h < 3 {
return -1.0;
}
let lum = |img: &Rgb32f| img.data.iter().map(|p| (luminance_2020(*p).max(1e-4) as f64).ln()).collect::<Vec<f64>>();
// NaN (outside a lens-warped proxy) stays NaN: `max` would turn it into a black border and a false edge
let lum = |img: &Rgb32f| {
img.data.iter().map(|p| luminance_2020(*p)).map(|y| if y.is_nan() { f64::NAN } else { (y.max(1e-4) as f64).ln() }).collect::<Vec<f64>>()
};
let high = |l: &[f64]| -> Vec<f64> {
(0..w * h)
.map(|i| {
+149 -39
View File
@@ -730,7 +730,7 @@ fn corpus_lens_corrected_jpegs_pair_with_the_warped_sensor() {
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
let (Some((plain, reference, _)), Some((chosen, chosen_reference, _))) =
(proxies(&raw, &bytes, &t, PROXY, 0.0, None), proxies(&raw, &bytes, &t, PROXY, 0.0, Some(&lens)))
(proxies(&raw, &bytes, &t, PROXY, 0.0, None, None), proxies(&raw, &bytes, &t, PROXY, 0.0, Some(&lens), None))
else {
continue;
};
@@ -997,7 +997,7 @@ fn corpus_cr3_gets_a_framing_aligned_camera_look() {
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, None).0;
let look = fit_preview(&raw, &bytes, &transform, None, None).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();
@@ -1034,7 +1034,7 @@ fn corpus_uses_the_bundled_profile(file: &str, model: &str) {
let lens = lens_geometry(&raw.opcodes.list3);
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, lens.as_ref()).0.expect("fit accepted");
let look = fit_preview(&raw, &bytes, &transform, lens.as_ref(), None).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) {
@@ -1415,9 +1415,9 @@ fn corpus_arw_with_dro_is_lowered_to_the_dro_off_tone() {
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, None).0.expect("a camera look");
let look = fit_preview(&raw, &bytes, &transform, None, None).0.expect("a camera look");
// the look fitted to the JPEG, as before this change
let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, 0.0, None).unwrap();
let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, 0.0, None, None).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();
@@ -2047,20 +2047,22 @@ fn a_rejected_profile_leaves_the_tone_curve_on_the_photos_own_colour() {
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() {
/// A synthetic ILCE-7M4 raw (it has a bundled camera profile; demosaiced grey `sensor` levels) and its file bytes:
/// a minimal TIFF holding the camera JPEG, a bright camera curve on the `scene` luminance as an 8-bit sRGB JPEG in
/// `tint` (grey for `[1.0; 3]`).
fn grey_raw_with_jpeg(
(w, h): (usize, usize),
sensor: impl Fn(usize, usize) -> f32,
scene: impl Fn(usize, usize) -> f32,
tint: [f32; 3],
) -> (RawImage, Vec<u8>) {
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()),
data: RawData::F32((0..w * h).flat_map(|i| [sensor(i % w, i / w); 3]).collect()),
cfa: None,
bits: 16,
black: BlackLevel::uniform(0.0),
@@ -2076,38 +2078,146 @@ fn monochrome_look_reads_the_files_black_and_white_jpeg() {
metadata: lightcraft_meta::Metadata { make: Some("SONY".into()), model: Some("ILCE-7M4".into()), ..Default::default() },
lens_metadata: Default::default(),
};
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 * scene(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);
(raw, tiff)
}
/// [`monochrome_look`] on a synthetic raw file: an ILCE-7M4 whose camera JPEG 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() {
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, grey) = grey_raw_with_jpeg((w, h), level, level, [1.0; 3]);
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, None).expect("a tone curve from the black-and-white JPEG");
let look = monochrome_look(&raw, &grey, &t, None, None).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, None).is_none(), "a tinted JPEG is not black and white");
let (_, tinted) = grey_raw_with_jpeg((w, h), level, level, [1.0, 0.9, 0.8]);
assert!(monochrome_look(&raw, &tinted, &t, None, None).is_none(), "a tinted JPEG is not black and white");
}
/// The look is fitted on the sensor as the render shows it, with the file's embedded vignetting correction: the
/// camera corrected its JPEG, so a curve fitted to the uncorrected sensor learns that brightening, and the render
/// then applies it twice (X-S10 RAFs up to 0.3 EV brighter than their camera JPEGs).
#[test]
fn the_look_is_fitted_on_the_vignetting_corrected_sensor() {
use lightcraft_develop::{EmbeddedLens, EmbeddedVignette};
let (w, h) = (192usize, 128usize);
let vignette = EmbeddedVignette { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: lightcraft_geom::Point::new(0.5, 0.5), radius: 0.6 };
let gain = |x: usize, y: usize| {
let point = lightcraft_geom::Point::new(x as f64 + 0.5, y as f64 + 0.5);
lightcraft_pipeline::optics::embedded_vignette_gain(&vignette, point, w as f64, h as f64) as f32
};
let scene = |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, bytes) = grey_raw_with_jpeg((w, h), |x, y| scene(x, y) / gain(x, y), scene, [1.0; 3]);
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
let lens = EmbeddedLens { vignette: Some(vignette), ..Default::default() };
let camera = 1.0 - (-0.75f32).exp();
let at_quarter = |embedded: Option<&EmbeddedLens>| {
let look = monochrome_look(&raw, &bytes, &t, None, embedded).expect("a tone curve");
ToneMap::camera(&look.tone, 0.0, 0.0, 0.0).apply(0.25)
};
let (corrected, uncorrected) = (at_quarter(Some(&lens)), at_quarter(None));
assert!((corrected - camera).abs() < 0.03, "the camera's curve at 0.25: {corrected} vs {camera}");
assert!(uncorrected > camera + 0.03, "without the correction the curve brightens: {uncorrected} vs {camera}");
}
/// The colour fit gets the embedded vignetting correction too ([`starting_colour`] forwards it to every fit): the
/// tinted camera JPEG of the vignetted sensor takes the colour path, not the black-and-white one.
#[test]
fn the_colour_look_is_fitted_on_the_vignetting_corrected_sensor() {
use lightcraft_develop::{EmbeddedLens, EmbeddedVignette};
let (w, h) = (192usize, 128usize);
let vignette = EmbeddedVignette { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: lightcraft_geom::Point::new(0.5, 0.5), radius: 0.6 };
let gain = |x: usize, y: usize| {
let point = lightcraft_geom::Point::new(x as f64 + 0.5, y as f64 + 0.5);
lightcraft_pipeline::optics::embedded_vignette_gain(&vignette, point, w as f64, h as f64) as f32
};
let scene = |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, bytes) = grey_raw_with_jpeg((w, h), |x, y| scene(x, y) / gain(x, y), scene, [1.0, 0.9, 0.8]);
let lens = EmbeddedLens { vignette: Some(vignette), ..Default::default() };
let at_quarter = |embedded: Option<&EmbeddedLens>| {
let (_, _, look) = starting_colour(&mut raw.clone(), &bytes, None, embedded);
let look = look.expect("a colour look");
assert!(look.matrix != Mat3::IDENTITY || look.hue_sat.is_some(), "the colour fit, not the black-and-white one");
ToneMap::camera(&look.tone, 0.0, 0.0, 0.0).apply(0.25)
};
let (corrected, uncorrected) = (at_quarter(Some(&lens)), at_quarter(None));
assert!(uncorrected > corrected + 0.03, "without the correction the curve brightens: {uncorrected} vs {corrected}");
}
/// A lens-warped sensor proxy gets the vignetting gain of the position it samples, as the render applies it: the
/// vignetted sensor, corrected, gives the proxy of the same sensor without vignetting.
#[test]
fn the_vignetting_correction_is_applied_before_the_lens_warp() {
use lightcraft_develop::{EmbeddedLens, EmbeddedVignette};
// twice the 384 px proxy edge: the demosaiced proxy of this linear raw is then the size a warp needs (bins of 2)
let (w, h) = (768usize, 512usize);
let vignette = EmbeddedVignette { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: lightcraft_geom::Point::new(0.5, 0.5), radius: 0.6 };
let gain = |x: usize, y: usize| {
let point = lightcraft_geom::Point::new(x as f64 + 0.5, y as f64 + 0.5);
lightcraft_pipeline::optics::embedded_vignette_gain(&vignette, point, w as f64, h as f64) as f32
};
// a texture for the warped candidate to line up with: the JPEG shows the sensor's centre zoomed by 1 / 0.8
let texture = |x: f64, y: f64| 0.2 * (1.0 + 0.3 * ((x / 48.0).floor() + (y / 48.0).floor()).rem_euclid(2.0)) as f32;
let warp = LensGeometry { k: [0.8, 0.0, 0.0, 0.0], center: [0.5, 0.5] };
let (cx, cy) = ((w - 1) as f64 / 2.0, (h - 1) as f64 / 2.0);
let jpeg = |x: usize, y: usize| texture(cx + (x as f64 - cx) * 0.8, cy + (y as f64 - cy) * 0.8);
let sensor_proxy = |vignetted: bool, embedded: Option<&EmbeddedLens>| {
let sensor = |x: usize, y: usize| texture(x as f64, y as f64) / if vignetted { gain(x, y) } else { 1.0 };
let (raw, bytes) = grey_raw_with_jpeg((w, h), sensor, jpeg, [1.0; 3]);
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
let (plain, _, _) = proxies(&raw, &bytes, &t, PROXY, 0.0, None, embedded).unwrap();
let (sensor, _, _) = proxies(&raw, &bytes, &t, PROXY, 0.0, Some(&warp), embedded).unwrap();
assert!(sensor.data.iter().zip(&plain.data).any(|(a, b)| a != b), "the warped candidate is chosen");
sensor
};
let lens = EmbeddedLens { vignette: Some(vignette), ..Default::default() };
let (corrected, expected) = (sensor_proxy(true, Some(&lens)), sensor_proxy(false, None));
let worst = corrected.data.iter().zip(&expected.data).map(|(a, b)| (a[1] / b[1] - 1.0).abs()).filter(|d| d.is_finite()).fold(0.0, f32::max);
assert!(worst < 0.02, "largest deviation {worst}");
}
/// [`alignment`] leaves out the pixels a proxy lacks (NaN outside a lens-warped one): read as black, they made a
/// false edge around the border, and the warped candidate lost against a worse unwarped one.
#[test]
fn alignment_leaves_out_missing_pixels() {
let (w, h) = (48usize, 32usize);
let mut a = Rgb32f::new(w, h);
for (i, p) in a.data.iter_mut().enumerate() {
*p = [0.1 + 0.05 * ((i * 7919) % 13) as f32; 3];
}
let mut b = a.clone();
for (i, p) in b.data.iter_mut().enumerate() {
let (x, y) = (i % w, i / w);
if x < 4 || y < 4 || x >= w - 4 || y >= h - 4 {
*p = [f32::NAN; 3];
}
}
assert!(alignment(&a, &b) > 0.999, "{}", alignment(&a, &b));
}
/// The black-and-white fit has the partial look's checks: a JPEG of another picture, or one the default curve
+22
View File
@@ -129,6 +129,28 @@ still has lens corrections removed, so the pipeline applies them once through En
Corrections. Developing this proxy requires a full-resolution demosaic; files without such
opcodes keep their binned proxy.
## Embedded vignetting correction (Fujifilm RAF)
The RAF decoder turns the maker note's vignetting data into a `FixVignetteRadial` correction, and the render applies
it (Enable Profile Corrections). The camera applied the same correction to its JPEG. Every per-file fit
(`fit_preview`, `monochrome_look`, the near-black tone fallback) therefore takes its sensor proxy through
`look_proxies`, which multiplies it by that gain as `fit_dng_tone` already did (`correct_vignetting`). Before
`LOOK_VERSION` 13 the RAF fit saw the uncorrected sensor: the tone curve learned the JPEG's corrected brightness, and
the render added the correction again. Lens-database corrections (`LIGHTCRAFT_LENSFUN_DIR`) reach the fit the same way.
`proxies` applies the gain before it moves the sensor into the JPEG's lens geometry, so each pixel gets the gain of the
position it samples, as in the render. The choice between the warped and the plain sensor (`alignment`) leaves out
the pixels the warp finds no sensor for. It used to read them as black, a false edge along the border that made the
warped candidate lose on some wide-angle shots with barrel distortion; neither change moved ΔE00 by more than 0.04 on
the photos below.
On 21 X-S10 photos the render was up to 0.3 EV brighter than the camera JPEG in the midtones (a wide-open blue-hour
shot) and up to 0.6 EV in the darkest 3 % of the pixels (ISO 12800 indoors). On 60 other X-S10 photos, picked at
random from one photographer's library and rendered without their sidecars, mean ΔE00 against the camera JPEG went
from 2.71 to 2.55: 32 improved by more than 0.1, none got worse by more than 0.05. The median luminance error per
quantile is now within ±0.04 EV, except +0.09 EV in the darkest 3 %. That rest comes from fitting on a 96 px proxy:
each proxy pixel averages a shadow's texture before the curve, while the render applies the curve per pixel. A fit
on a 4× finer proxy roughly halves it in a simulation, but the acceptance gates were set at 96 px.
## Camera profiles (pooled per model)
One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68° against the JPEG's 82°; a 192-pixel proxy only reached 74°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs, NEFs or RAFs per camera model — each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders — and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos.
+4 -2
View File
@@ -1,6 +1,6 @@
# RAW parity: cameras, formats, demosaic, colour and lens profiles
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** patch (bundled camera profile count 5 → 9) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7)
=======
Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera
does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by
@@ -225,7 +225,8 @@ sample files. A pointer for contributors, not legal advice.
- Against the camera JPEG (what the fit targets): the Sony bulk check (#535) accepted fits on 81 of 87
bodies, median ΔE 5.1, ΔL\* +0.2; rejected fits fall back to a neutral matrix at median ΔE 18.5,
ΔL\* −11, chroma ×0.39. RW2 140 of 174 files accepted, median 5.0. NEF 13 of 13, 2.9–7.8. RAF 23 of
24, pooled X-H2S 1.62 / X-T4 2.34. SRW 2.2–7.6.
24, pooled X-H2S 1.62 / X-T4 2.34 (measured before `LOOK_VERSION` 13 stopped applying the embedded vignetting
correction twice); X-S10, 60 photos not used to find that bug, mean ΔE00 2.71 → 2.55 with it fixed. SRW 2.2–7.6.
- Against Lightroom itself (few): Sony DRO median ΔE76 7.5 → 3.3; Olympus E-1 vs Lightroom Classic
ΔE00 11.25 → 8.30; ProRAW with its gain map ΔE00 19 (why the map is off). No measurement against
Adobe Standard across models exists.
@@ -329,3 +330,4 @@ Beyond decoding:
| 2026-10-14 | minor | Integration batch 36: CC0 per-model verification for 117 Nikon/Fujifilm, 97 Canon CR2/Pentax/Ricoh, 21 Sony/Leica and 23 Olympus/Samsung bodies, 22 more CR3 bodies and 30 more models; Epson ERF and Mamiya MEF decode; measured Nikon image areas (#1053, #1770): 515 verified, 675 unverified, 218 preview, 38 unsupported (re-measured with `coverage.py`) |
| 2026-10-15 | minor | By-maker and by-use tables re-measured with `coverage.py` after batches 36–39 (545 verified, 651 unverified, 212 preview, 38 unsupported; ~94% of photographers' cameras fully work, ~85% on a verified body); rows the per-model PRs had updated are current again |
| 2026-10-18 | minor | Minolta MRW decoded (`vendor/mrw.rs`): DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D verified; 551 verified, 656 unverified, 201 preview (re-measured with `coverage.py`) |
| 2026-10-30 | patch | RAF camera look fitted on the vignetting-corrected sensor (`LOOK_VERSION` 13): renders were up to 0.3 EV too bright in midtones and 0.6 EV in shadows; X-S10 mean ΔE00 vs the camera JPEG 2.71 → 2.55 on 60 photos |