diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 1fc172548..f62d1ee9e 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -79,7 +79,9 @@ use tone::{HighlightCheck, ToneFit, fit_tone, fit_tone_conditional, highlights}; /// a tone-only fallback for near-black scenes with too few colour pairs (#1587); DNG tone fitting uses a /// lens-corrected proxy when the file carries lens opcodes; validated A7CR distortion, including the lens data /// stored with each smart preview. -pub const LOOK_VERSION: u32 = 12; +/// 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. +pub const LOOK_VERSION: u32 = 13; #[derive(Clone, Debug)] pub(crate) struct CameraLook { 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..202fdd632 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). @@ -257,6 +264,22 @@ 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 ([`correct_vignetting`]). 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)> { + let (mut sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, 0.0, lens)?; + correct_vignetting(&mut sensor, embedded, raw.orientation); + Some((sensor, reference, clipped)) +} + /// 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/tests.rs b/crates/engine/src/camera_preview/tests.rs index d8442589d..edc923fe4 100644 --- a/crates/engine/src/camera_preview/tests.rs +++ b/crates/engine/src/camera_preview/tests.rs @@ -928,7 +928,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(); @@ -965,7 +965,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) { @@ -1346,7 +1346,7 @@ 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 profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); @@ -1978,20 +1978,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), @@ -2007,38 +2009,71 @@ 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 black-and-white fit has the partial look's checks: a JPEG of another picture, or one the default curve diff --git a/crates/engine/src/media/source.rs b/crates/engine/src/media/source.rs index 8f6a9c28c..8490d2ee2 100644 --- a/crates/engine/src/media/source.rs +++ b/crates/engine/src/media/source.rs @@ -1,7 +1,7 @@ use super::{Arc, Deserialize, DevelopSettings, MediaKind, Photo, Rgb32f, Rgba8, Serialize, SourceInfo}; /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 70; +pub const RENDER_CACHE_VERSION: u64 = 71; /// 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]; diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 7a1235ab3..7d1468baf 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -127,6 +127,23 @@ 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. + +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 bd17a3d32..e50f358e7 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-18 · **Change:** minor (Minolta MRW decoded, 6 bodies verified; lossy JPEG XL DNG tiles with a non-linear declared transfer curve decode; Canon ColorData Kelvin; X-Trans CMOS 4 bodies take the X-T3's spectral matrices) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** patch (RAF camera look no longer applies the embedded vignetting correction twice) · **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. @@ -328,3 +329,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 |