mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 21:48:53 -06:00
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:
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user