diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index c56e3dc31..014a74408 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -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)] diff --git a/crates/engine/src/camera_preview/colour.rs b/crates/engine/src/camera_preview/colour.rs index 2681466a9..6befe7ce1 100644 --- a/crates/engine/src/camera_preview/colour.rs +++ b/crates/engine/src/camera_preview/colour.rs @@ -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) { 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) }, ); diff --git a/crates/engine/src/camera_preview/look_fit.rs b/crates/engine/src/camera_preview/look_fit.rs index 544db5f5f..d93cbffe9 100644 --- a/crates/engine/src/camera_preview/look_fit.rs +++ b/crates/engine/src/camera_preview/look_fit.rs @@ -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, 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, 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)> { + 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. diff --git a/crates/engine/src/camera_preview/mono.rs b/crates/engine/src/camera_preview/mono.rs index 3c0bd5736..6939c39ad 100644 --- a/crates/engine/src/camera_preview/mono.rs +++ b/crates/engine/src/camera_preview/mono.rs @@ -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 { +/// 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 { 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; } diff --git a/crates/engine/src/camera_preview/proxy.rs b/crates/engine/src/camera_preview/proxy.rs index b3db0a160..e5a9cf06c 100644 --- a/crates/engine/src/camera_preview/proxy.rs +++ b/crates/engine/src/camera_preview/proxy.rs @@ -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)> { 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::>(); + // 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::>() + }; let high = |l: &[f64]| -> Vec { (0..w * h) .map(|i| { diff --git a/crates/engine/src/camera_preview/tests.rs b/crates/engine/src/camera_preview/tests.rs index 06cadcef3..d8673da4e 100644 --- a/crates/engine/src/camera_preview/tests.rs +++ b/crates/engine/src/camera_preview/tests.rs @@ -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) { 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 = (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 = (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 diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index c9f0a4bad..f671404f1 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -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. diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 74d27d72c..f23e34db1 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -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 |