Merge PR #1586 (rebased)

# Conflicts:
#	crates/engine/src/camera_preview.rs
#	crates/engine/src/files.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
This commit is contained in:
Brandon Thomas
2026-10-15 01:45:53 -04:00
7 changed files with 256 additions and 30 deletions
+236 -24
View File
@@ -31,7 +31,8 @@ use lightcraft_raw::{
/// 4: a tone curve from black-and-white camera JPEGs (#577).
/// 5: dark JPEG luminance pairs for the camera toe (#1307).
/// 6: the ILCA-99M2 profile.
pub const LOOK_VERSION: u32 = 6;
/// 7: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected.
pub const LOOK_VERSION: u32 = 7;
#[derive(Clone, Debug)]
pub(crate) struct CameraLook {
@@ -76,14 +77,19 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool {
/// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's
/// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the
/// 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.
pub(crate) fn starting_colour(
raw: &mut RawImage,
bytes: &[u8],
lens: Option<&lightcraft_develop::EmbeddedLens>,
lens: Option<&LensGeometry>,
embedded: Option<&lightcraft_develop::EmbeddedLens>,
) -> (Xy, CameraTransform, Option<CameraLook>) {
let (xy, t, look) = starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t), |raw, t| monochrome_look(raw, bytes, t));
let (xy, t, look) =
starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t, lens), |raw, t| monochrome_look(raw, bytes, t, lens));
if look.is_none() && dng_tone_eligible(raw, &t) {
let look = fit_dng_tone(raw, bytes, &t, lens);
let look = fit_dng_tone(raw, bytes, &t, embedded);
return (xy, t, look);
}
(xy, t, look)
@@ -154,7 +160,8 @@ pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option<C
// the white the source shows, decided before `starting_colour` may fill in measured matrices
let scale = crate::files::WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata);
let white = scale.dng_white(&raw.color, lightcraft_raw::color::as_shot_white_xy(raw));
let (_, t, look) = starting_colour(raw, bytes, None);
// (a merge frame arrives without its lens warp: `lightcraft_merge` removes it before calling)
let (_, t, look) = starting_colour(raw, bytes, None, None);
dng_colour(&raw.color, &white, &t, look.as_ref())
}
@@ -252,11 +259,11 @@ fn dng_tone_curve(tone: &CameraTone) -> Option<ToneCurve> {
}
/// 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) -> (Option<CameraLook>, bool) {
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> (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) else { return (None, false) };
let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, 0.0, lens) 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).
@@ -427,7 +434,7 @@ fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens:
}
None
};
let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION) else { return refuse("preview") };
let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION, 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)) {
@@ -569,7 +576,14 @@ 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.
fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize, min_fraction: f64) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> {
fn proxies(
raw: &RawImage,
bytes: &[u8],
transform: &CameraTransform,
size: usize,
min_fraction: f64,
lens: Option<&LensGeometry>,
) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> {
let edge = (2 * size).max(384) as u32;
let decoded = crate::files::decode_raw_preview(
bytes,
@@ -604,6 +618,12 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz
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);
// 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.
let warped = lens
.filter(|_| raw.format != RawFormat::Cr3)
.and_then(|l| Some((into_lens_geometry(&sensor, raw, k, l)?, into_lens_geometry(&clipped, raw, k, l)?)));
let gain = 2f32.powf(transform.baseline_exposure as f32);
let to_working = |p: [f32; 3]| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain));
if raw.format == RawFormat::Cr3 {
@@ -617,7 +637,15 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz
(sensor, clipped) = align_cr3_framing(sensor, clipped, &reference);
}
let mut sensor = fit(&sensor, size, size, Filter::Box);
let reference = reference_at(&reference, &sensor);
let mut reference_small = reference_at(&reference, &sensor);
if let Some((warped, warped_clipped)) = warped {
let candidate = fit(&warped, size, size, Filter::Box);
let candidate_reference = reference_at(&reference, &candidate);
if alignment(&candidate, &candidate_reference) > alignment(&sensor, &reference_small) {
(sensor, reference_small, clipped) = (candidate, candidate_reference, warped_clipped);
}
}
let reference = reference_small;
// A proxy pixel is clipped when any sample it averages is (outside the CR3 framing the mask is
// NaN: those pixels are left out of the pairs anyway). Resized to exactly the proxy's size, like
// the reference, so the mask pairs pixel for pixel.
@@ -779,12 +807,12 @@ fn align_cr3_framing(sensor: Rgb32f, clipped: Rgb32f, reference: &Rgb32f) -> (Rg
/// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the
/// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for formats with their own
/// colour matrices, other files or unusable previews.
pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option<Vec<([f64; 3], [f64; 3])>> {
pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8], lens: Option<&LensGeometry>) -> Option<Vec<([f64; 3], [f64; 3])>> {
if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) {
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)?;
let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, 0.0, lens)?;
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);
@@ -799,6 +827,102 @@ pub(crate) fn fit_profile(pairs: &[([f64; 3], [f64; 3])]) -> Option<(Mat3, Optio
Some((matrix, fit_hue_sat(pairs, &matrix)))
}
/// A raw's lens-correction warp, as its camera applied it to the embedded JPEG: the first `WarpRectilinear` of
/// `OpcodeList3` (its green plane; the in-file corrections LightCraft reads have no tangential terms). `None` without
/// one, or for a DNG 1.6 `WarpRectilinear2` chain (phone DNGs, which carry colour matrices and are not fitted).
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct LensGeometry {
/// `kr0..kr3`: source radius = r * (kr0 + kr1 r^2 + kr2 r^4 + kr3 r^6), r over the farthest corner distance.
k: [f64; 4],
/// The optical centre, relative to the active area (as the opcode has it).
center: [f64; 2],
}
pub(crate) fn lens_geometry(list3: &[lightcraft_raw::Opcode]) -> Option<LensGeometry> {
use lightcraft_raw::Opcode;
let ops: Vec<&Opcode> = lightcraft_raw::opcodes::applied_ops(list3).collect();
if ops.iter().any(|op| matches!(op, Opcode::WarpRectilinear2 { .. })) {
return None;
}
ops.into_iter().find_map(|op| match op {
Opcode::WarpRectilinear { planes, center } => {
let p = planes.get(1).or(planes.first())?;
let k = [p[0], p[1], p[2], p[3]];
(k.iter().chain(center).all(|v| v.is_finite()) && (0.0..=1.0).contains(&center[0]) && (0.0..=1.0).contains(&center[1]))
.then_some(LensGeometry { k, center: *center })
}
_ => None,
})
}
/// How well two same-size proxies line up: the correlation of their high-passed log luminance over the pixels both
/// have (a tone curve or colour change leaves it alone, a geometric offset lowers it). `-1` without enough pixels.
fn alignment(a: &Rgb32f, b: &Rgb32f) -> f64 {
let (w, h) = (a.width, a.height);
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>>();
let high = |l: &[f64]| -> Vec<f64> {
(0..w * h)
.map(|i| {
let (x, y) = (i % w, i / w);
if x == 0 || y == 0 || x + 1 == w || y + 1 == h {
return f64::NAN;
}
let around = [i - w - 1, i - w, i - w + 1, i - 1, i + 1, i + w - 1, i + w, i + w + 1];
l[i] - around.iter().map(|&j| l[j]).sum::<f64>() / 8.0
})
.collect()
};
let (ha, hb) = (high(&lum(a)), high(&lum(b)));
let pairs: Vec<(f64, f64)> = ha.into_iter().zip(hb).filter(|(x, y)| x.is_finite() && y.is_finite()).collect();
if pairs.len() < 64 {
return -1.0;
}
let n = pairs.len() as f64;
let (ma, mb) = (pairs.iter().map(|p| p.0).sum::<f64>() / n, pairs.iter().map(|p| p.1).sum::<f64>() / n);
let cov: f64 = pairs.iter().map(|p| (p.0 - ma) * (p.1 - mb)).sum();
let (va, vb): (f64, f64) = (pairs.iter().map(|p| (p.0 - ma).powi(2)).sum(), pairs.iter().map(|p| (p.1 - mb).powi(2)).sum());
let r = cov / (va * vb).sqrt().max(1e-12);
if r.is_finite() { r } else { -1.0 }
}
/// A `k`-binned sensor proxy of `raw` ([`sensor_proxy`]: the default crop, or the active area when the crop is
/// smaller than a block, at `1/k`) moved into the camera JPEG's geometry: each pixel samples the proxy where `lens`
/// maps it, and is NaN where that falls outside the proxy (the pairs leave it out). `None` for a proxy of another
/// size (the demosaiced fallback of linear raws).
fn into_lens_geometry(img: &Rgb32f, raw: &RawImage, k: usize, lens: &LensGeometry) -> Option<Rgb32f> {
let a = raw.active_area;
let c = raw.crop.clipped(a.width, a.height);
let c = if c.width < k || c.height < k { lightcraft_raw::Rect::new(0, 0, a.width, a.height) } else { c };
let (w, h) = (img.width, img.height);
if k == 0 || w != c.width / k || h != c.height / k || w == 0 || h == 0 || a.width < 2 || a.height < 2 {
return None;
}
let (aw, ah) = (a.width as f64 - 1.0, a.height as f64 - 1.0);
let (cx, cy) = (lens.center[0] * aw, lens.center[1] * ah);
let m = [(0.0, 0.0), (aw, 0.0), (0.0, ah), (aw, ah)].iter().map(|&(x, y)| (x - cx).hypot(y - cy)).fold(0.0, f64::max).max(1e-9);
let [k0, k1, k2, k3] = lens.k;
let kf = k as f64;
let (x0, y0) = (c.x as f64, c.y as f64);
let mut out = Rgb32f::new(w, h);
for (i, px) in out.data.iter_mut().enumerate() {
// the proxy pixel's centre in active-area pixel-index coordinates, mapped to the sensor
let (ox, oy) = (x0 + ((i % w) as f64 + 0.5) * kf - 0.5, y0 + ((i / w) as f64 + 0.5) * kf - 0.5);
let (dx, dy) = ((ox - cx) / m, (oy - cy) / m);
let r2 = dx * dx + dy * dy;
let f = k0 + r2 * (k1 + r2 * (k2 + r2 * k3));
let (sx, sy) = ((cx + m * dx * f + 0.5 - x0) / kf, (cy + m * dy * f + 0.5 - y0) / kf);
*px = if sx >= 0.5 && sy >= 0.5 && sx <= w as f64 - 0.5 && sy <= h as f64 - 0.5 {
img.sample_bilinear(sx as f32, sy as f32)
} else {
[f32::NAN; 3]
};
}
Some(out)
}
fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option<Rgb32f> {
Some(match raw.develop_binned(k, SENSOR_CLIP).ok()? {
Some(sensor) => sensor,
@@ -1296,11 +1420,11 @@ fn monochrome(reference: &Rgb32f) -> bool {
/// `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`]), as [`fit_look`] tries the photo's own colour after a profile's. The
/// chroma curve stays neutral: the JPEG has no colourfulness to learn.
fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> Option<CameraLook> {
if !file_local_look(raw.format) {
return None;
}
let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, 0.0)?;
let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, 0.0, lens)?;
if !monochrome(&reference) {
return None;
}
@@ -2334,6 +2458,93 @@ mod tests {
}
}
/// The lens warp the colour fit follows is the first `WarpRectilinear`'s green plane; no warp, a DNG 1.6 chain
/// (phone DNGs, which carry colour matrices), a centre outside the area or a non-finite term give none.
#[test]
fn lens_geometry_reads_the_first_rectilinear_warp() {
use lightcraft_raw::Opcode;
let warp = |k0: f64, c: [f64; 2]| Opcode::WarpRectilinear {
planes: vec![[9.0, 0.0, 0.0, 0.0, 0.0, 0.0], [k0, 0.1, -0.02, 0.003, 0.0, 0.0], [9.0, 0.0, 0.0, 0.0, 0.0, 0.0]],
center: c,
};
assert_eq!(lens_geometry(&[warp(0.97, [0.5, 0.4])]), Some(LensGeometry { k: [0.97, 0.1, -0.02, 0.003], center: [0.5, 0.4] }));
let single = Opcode::WarpRectilinear { planes: vec![[1.02, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] };
assert_eq!(lens_geometry(std::slice::from_ref(&single)).map(|g| g.k[0]), Some(1.02));
assert!(lens_geometry(&[]).is_none());
assert!(lens_geometry(&[warp(1.0, [1.5, 0.5])]).is_none());
assert!(lens_geometry(&[warp(f64::NAN, [0.5, 0.5])]).is_none());
let mut p = [0.0; lightcraft_raw::opcodes::WR2_LEN];
p[0] = 1.0;
p[18] = 1e6;
let chain = Opcode::WarpRectilinear2 { planes: vec![p], center: [0.5, 0.5], reciprocal: false, optional: false };
assert!(lens_geometry(&[chain, single]).is_none());
}
/// A binned sensor proxy moved into the camera JPEG's geometry: the identity warp leaves it as it is, a warp
/// whose source radius is 0.8 of the output's samples nearer the centre, output pixels whose source falls outside
/// the proxy are NaN (left out of the pairs), and a proxy of another size is left alone.
#[test]
fn the_sensor_proxy_follows_the_cameras_lens_warp() {
let mut raw = raw_of(RawFormat::Nef, "NIKON CORPORATION", "NIKON Z 6_2", Default::default());
raw.active_area = lightcraft_raw::Rect::new(0, 0, 64, 48);
raw.crop = raw.active_area;
// a 1/4 proxy whose pixels hold their own coordinates
let mut img = Rgb32f::new(16, 12);
for (i, p) in img.data.iter_mut().enumerate() {
*p = [(i % 16) as f32, (i / 16) as f32, 1.0];
}
let geometry = |k0: f64| LensGeometry { k: [k0, 0.0, 0.0, 0.0], center: [0.5, 0.5] };
let same = into_lens_geometry(&img, &raw, 4, &geometry(1.0)).unwrap();
assert!(same.data.iter().zip(&img.data).all(|(a, b)| (0..3).all(|c| (a[c] - b[c]).abs() < 1e-4)));
// the corner pixel's centre (1.5, 1.5) maps to (31.5 - 0.8 * 30, 23.5 - 0.8 * 22) = (7.5, 5.9) on the sensor
let zoomed = into_lens_geometry(&img, &raw, 4, &geometry(0.8)).unwrap();
assert!((zoomed.data[0][0] - 1.5).abs() < 1e-4 && (zoomed.data[0][1] - 1.1).abs() < 1e-4, "{:?}", zoomed.data[0]);
let outside = into_lens_geometry(&img, &raw, 4, &geometry(1.3)).unwrap();
assert!(outside.data[0][0].is_nan() && outside.data[6 * 16 + 8][0].is_finite());
assert!(into_lens_geometry(&Rgb32f::new(15, 12), &raw, 4, &geometry(1.0)).is_none());
}
/// A raw with an in-file lens correction (Fujifilm, Panasonic, the A7R IVA, Nikon) may have a lens-corrected
/// camera JPEG: the proxies the fit pairs line up with it at least as well as the uncorrected sensor, on every
/// corpus file the colour fit uses that carries such a warp, and better on those whose JPEG is corrected.
#[test]
fn corpus_lens_corrected_jpegs_pair_with_the_warped_sensor() {
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw");
let Ok(entries) = std::fs::read_dir(&dir) else {
eprintln!("skip: no corpus at {}", dir.display());
return;
};
let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
paths.sort();
let (mut checked, mut gain) = (0, 0.0);
for path in paths {
let Ok(bytes) = std::fs::read(&path) else { continue };
let Ok(mut raw) = lightcraft_raw::decode(&bytes) else { continue };
let Some(lens) = lens_geometry(&raw.opcodes.list3) else { continue };
if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) {
continue;
}
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)))
else {
continue;
};
let (before, after) = (alignment(&plain, &reference), alignment(&chosen, &chosen_reference));
eprintln!("{}: alignment {before:.3} -> {after:.3}", path.display());
// (chosen on camera RGB, measured after the colour transform: a near tie may flip by a little)
assert!(after >= before - 0.01, "{}: {before:.3} -> {after:.3}", path.display());
gain += after - before;
checked += 1;
}
eprintln!("{checked} corpus raws with an in-file lens warp, mean gain {:.3}", gain / checked.max(1) as f64);
assert!(checked == 0 || gain > 0.0);
}
/// Step 1: a file's own colour matrices win over everything, including a spectral row for its model; no JPEG fit
/// is tried.
#[test]
@@ -2381,7 +2592,7 @@ mod tests {
assert!(crate::camera_profiles::get("ILCE-7M4").is_some());
let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M4").unwrap();
let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default());
let (_, t, look) = starting_colour(&mut raw, &[], None);
let (_, t, look) = starting_colour(&mut raw, &[], None, None);
assert!(look.is_none());
assert_eq!(raw.color.color_matrix, camera.color_matrix.map(Some));
assert!(!t.matrix_is_fallback);
@@ -2440,13 +2651,13 @@ mod tests {
#[test]
fn a_camera_outside_the_table_keeps_the_fallback() {
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default());
let (_, t, look) = starting_colour(&mut raw, &[], None);
let (_, t, look) = starting_colour(&mut raw, &[], None, None);
assert!(look.is_none() && t.matrix_is_fallback);
assert_eq!(raw.color, lightcraft_raw::ColorData::default());
// without a model there is no table row
let mut raw = raw_of(RawFormat::Cr2, "Canon", "", Default::default());
raw.metadata.model = None;
assert!(starting_colour(&mut raw, &[], None).1.matrix_is_fallback);
assert!(starting_colour(&mut raw, &[], None, None).1.matrix_is_fallback);
}
/// The colour precedence on public raws (each skipped without its corpus file): a photo whose JPEG fit is
@@ -2465,13 +2676,14 @@ mod tests {
continue;
};
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
let lens = lens_geometry(&raw.opcodes.list3);
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
let own = lightcraft_raw::color::has_matrix(&raw.color);
let (make, model) = (raw.metadata.make.as_deref(), raw.metadata.model.as_deref().unwrap_or_default());
let measured = lightcraft_raw::spectral::find(make, model).is_some();
let covered = measured || lightcraft_raw::kelvin_presets::covers(make, model);
let before = raw.color.clone();
let (_, t, fitted) = starting_colour(&mut raw, &bytes, None);
let (_, t, fitted) = starting_colour(&mut raw, &bytes, lens.as_ref(), None);
let spectral = !own && fitted.is_none() && measured;
eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some());
assert_eq!(raw.color != before, spectral, "{name}");
@@ -2579,7 +2791,7 @@ mod tests {
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).0;
let look = fit_preview(&raw, &bytes, &transform, 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();
@@ -2612,7 +2824,7 @@ mod tests {
let raw = lightcraft_raw::decode(&bytes).unwrap();
assert_eq!(raw.metadata.model.as_deref(), Some(model));
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
let look = fit_preview(&raw, &bytes, &transform).0.expect("fit accepted");
let look = fit_preview(&raw, &bytes, &transform, 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) {
@@ -2981,9 +3193,9 @@ mod tests {
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).0.expect("a camera look");
let look = fit_preview(&raw, &bytes, &transform, 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).unwrap();
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);
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();
@@ -3573,12 +3785,12 @@ mod tests {
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).expect("a tone curve from the black-and-white JPEG");
let look = monochrome_look(&raw, &file([1.0; 3]), &t, 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).is_none(), "a tinted JPEG is not black and white");
assert!(monochrome_look(&raw, &file([1.0, 0.9, 0.8]), &t, None).is_none(), "a tinted JPEG is not black and white");
}
/// The black-and-white fit has the partial look's checks: a JPEG of another picture, or one the default curve
+4 -2
View File
@@ -195,10 +195,12 @@ impl Pool {
/// NEF or RAF without colour matrices, no usable camera JPEG, too little colour).
pub fn add(&mut self, bytes: &[u8]) -> Result<Option<String>, String> {
let mut raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
// colour only: geometric lens corrections would stop the sensor proxy from binning
// colour only: geometric lens corrections would stop the sensor proxy from binning (the pairs still follow
// the warp the camera applied to its JPEG)
let lens = crate::camera_preview::lens_geometry(&raw.opcodes.list3);
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
let Some(model) = raw.metadata.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) else { return Ok(None) };
let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes) else { return Ok(None) };
let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes, lens.as_ref()) else { return Ok(None) };
let step = pairs.len().div_ceil(PAIRS_PER_FILE).max(1);
let entry = self.models.entry(model.to_owned()).or_default();
entry.0 += 1;
+6 -2
View File
@@ -424,13 +424,17 @@ fn load_bytes_now(
// (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`).
let info = raw.info();
let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes));
// (the camera look still pairs the sensor with the camera JPEG through the warp the camera applied to it)
let lens_geometry = crate::camera_preview::lens_geometry(&raw.opcodes.list3);
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
// Which scale white balance is edited on, and the as-shot white on it, from the file's own colour tags or
// the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera)
let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata);
// the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted
let ((xy, t, camera_look), mattes) =
rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens.as_ref()), || dng_mattes(&bytes, &info));
let ((xy, t, camera_look), mattes) = rayon::join(
|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens_geometry.as_ref(), lens.as_ref()),
|| dng_mattes(&bytes, &info),
);
drop(bytes);
// Previews and thumbnails bin the mosaic straight to (about) the size they need; only
// larger levels (exports, 1:1) demosaic the whole sensor.
+1 -1
View File
@@ -975,7 +975,7 @@ fn dngs_of_raws_without_colour_matrices_carry_the_colour_they_open_with() {
std::fs::write(&path, &bytes).unwrap();
let mut opened = lightcraft_raw::decode(&bytes).unwrap();
assert!(!has_matrix(&opened.color));
let (_, want, look) = crate::camera_preview::starting_colour(&mut opened, &bytes, None);
let (_, want, look) = crate::camera_preview::starting_colour(&mut opened, &bytes, None, None);
assert_eq!((want.matrix_is_fallback, look.is_none()), (!measured, true), "{model}");
let mut s = Session::new().with_fs();
+2
View File
@@ -19,6 +19,8 @@ The matrix is applied to scene-linear sensor pixels. The curve is carried in `So
One third of the pixels are held out from both fits. The combined model must reduce linear-display squared error by at least 30% and have per-channel RMS error ≤0.10 (0.055 rejected half of the public raw.pixls.us samples tried — ILCE-6400, -6700, -7M4 — whose fits were still 1.5–3× closer to the camera JPEG than the fallback and visibly better). When the fit on all pixels fails these gates, it is tried once more, with the same gates, on the pixels away from edges (where the 3×3 neighbourhood in either proxy spans a luminance ratio above 3). Many camera JPEGs are lens-corrected while the raw is not (Sony "Distortion Comp.: Auto", compacts and kit zooms), and on a detailed scene the misaligned edges alone fail the gate although the colours agree everywhere else (issue #232). Sony's embedded lens tables now correct ILCE-7RM6 renders under Enable Lens Corrections ([Sony ARW lens corrections](sony-ilce7rm6-lens-corrections.md)), but this fit still matches its pairs without them (the sensor proxy is uncorrected); aligning the pairs first is a follow-up. The public ILCE-7RM2 samples (raw.pixls.us, one façade shot in seven compression/correction variants, 24–70 mm at 51 mm) are up to 15 px of 1440 off their JPEG; the six with distortion correction on missed the gate at held-out RMS 0.108–0.115 and opened in the grey neutral fallback, 13 L* too dark (ΔE76 16.6–16.9 against the JPEG after registering its geometry). Away from edges they pass at 0.049–0.052 and land at ΔE 3.7–3.8 with lightness within 0.6 L*, like the variant shot with distortion correction off (0.059 on all pixels, ΔE 3.9). The corpus GX80 kit-zoom sample (up to 63 px of 1440 off) passes too: 0.107 → 0.081, ΔE 19.1 → 7.9. Fits accepted on all pixels are unchanged. These are provisional acceptance gates, not a colour-accuracy certification. Singular, unrelated and aspect-mismatched references retain the documented fallback; poor-fitting ones get the partial look below when it is clearly closer to the camera JPEG, and black-and-white ones only their tone curve (below). Both fits use the same fixed proxy at every decode resolution.
**Lens-corrected camera JPEGs.** Some raws carry the camera's own lens correction (Fujifilm and Panasonic tables, the ILCE-7RM4A, Nikon's NikonNEFInfo) and the camera often applies it to its JPEG. Nikon Z lenses always do, and that JPEG can be zoomed in by up to 9% to fill the frame. The fit used to pair the uncorrected sensor with the corrected JPEG, so near the edges it compared pixels from different parts of the scene. Now the sensor proxy is also warped with the file's correction (`into_lens_geometry`, pixels that land outside the picture are dropped), and the fit uses whichever of the two lines up better with the JPEG, going by the correlation of their high-passed log luminance. Files whose JPEG isn't corrected keep the plain proxy. On the 29 CC0 corpus raws with a warp the alignment goes up by 0.13 on average (Panasonic GX80 0.41 to 0.83, Fujifilm X-T20 0.44 to 0.95). Mean ΔE against the camera JPEG over the 35 corpus raws of those formats goes from 3.73 to 3.61, and on 299 private Z 6II photos with Nikon's correction read it goes from 3.22 to 2.68.
**A partial look when the full look misses the gates.** The neutral fallback (camera RGB taken as sRGB, the default tone map) is far from the camera JPEG. Rendered for every photo on this path in the CC0 archive (1,150 files with a camera JPEG), it is 10.7 L\* too dark (median; about one stop), keeps 0.36 of the camera's colourfulness and has hues 7° off on the coloured pixels (median CIEDE2000 13.6). Its white balance is right: on the pixels the camera renders near-neutral the cast is under 1 a\*/b\* (median, every maker). The "blue" fallback on newer Sony bodies (Δb\* −22 over the whole frame on the ILCE-7RM5 sample) is a warm scene losing its colour, not a cast: its near-neutral pixels are within −1.6 b\*. So before falling back, a partial look is fitted (`fit_partial`): the quantile tone curve (registration-free, the largest part of the error), the chroma curve, and, when the scene has enough colour to learn a matrix (the full look's condition), the photo's own colour matrix at half weight, the other half the identity (`PARTIAL_MATRIX_WEIGHT`). No hue/saturation table. Half weight because, on the held-out proxy pixels of the 23 colourful photos without a look, the full matrix left 2 of them more than 2° further from the camera's hues than the neutral fallback and half weight none (median hue error 9.6° with the generic colour, 5.1° at half weight, 4.2° at full weight; on the 1,080 photos with a look 3.9° and 2.7°). Without a matrix (tone and chroma curve alone, or with one global saturation scale) about a third of all photos ended up more than 2° further from the camera's hues than the fallback, as the added colour makes the generic matrix's hue errors visible. The partial look is tried on all pixels, then away from edges, and used only when it cuts the held-out squared error below 0.7 of the neutral fallback's in linear and in gamma-encoded display values, and when the JPEG's luminance ranks the pixels like the scene's (Spearman ≥ 0.5, `PARTIAL_MIN_RANK_CORRELATION`): a curve matched to the brightness of an unrelated picture also beats the dark fallback pixel for pixel. The accepted full looks have 0.55 or more; three old Coolpix files whose raws decode wrongly have 0.07–0.27 and keep the fallback. There is no limit on the partial look's own error. On the archive (1,783 renders): all 1,087 full looks are byte-identical; of the 55 photos that reach the partial fit, 49 get it (40 on all pixels, 9 away from edges; 13 with the damped matrix, 41 with a chroma curve) and 6 keep the fallback (lens-corrected compacts: RX10, TZ80 1:1, D-LUX 4, G10 16:9, GF2 ×2). On the 49, median CIEDE2000 to the camera JPEG 14.0 → 2.1, ΔL\* −11.5 → +0.6, colourfulness 0.36 → 0.87, hue error 10.1° → 7.1°; every one is closer, by at least 1.3. Most of the others without a look never reach a fit: on 48 files (Nikon D4, Df, D300/D300S, D7000, D5100 and others) the camera JPEG proxy comes out a pixel narrower than the sensor proxy and no pairs are collected (the resize in PR #531 fixes this; with it 46 of them pass the full look's gates, CIEDE2000 17.9 → 2.9).
**A tone curve from a black-and-white JPEG.** A raw shot in a monochrome picture style (Canon Monochrome, Sony B/W, Nikon Monochrome) records full colour, but its camera JPEG is grey, so neither the full look nor the partial look has colour to fit. Such a photo started from the spectral matrices or the neutral fallback with the default tone curve: on a Canon EOS RP, about 1.3 EV darker and flatter than the same body's photos shot in colour (issue #577). The grey JPEG still shows the camera's tone curve. When no look was fitted and every pixel of the JPEG proxy but one in a thousand has its channels within 0.002 of each other (`monochrome`; a sepia-toned JPEG has colour and isn't one), the quantile tone curve is fitted alone, luminance against luminance, on top of the colour the photo gets otherwise: the model's camera profile when it has one (in `fit_preview`, like the profile's colour looks), else its spectral matrices or the neutral fallback's (`monochrome_look`). The chroma curve stays neutral. It has the partial look's checks: the JPEG must rank the pixels like the scene (Spearman ≥ 0.5, with tied values sharing their rank, so a flat grey JPEG relates to nothing), and the curve must cut the held-out squared luminance error below 0.7 of the default curve's, in linear and gamma-encoded values, on all pixels or away from edges. Sony's DRO is handled as for colour JPEGs, and white balance stays relative to the as-shot look. The check reads pixels, not the picture style: a colour JPEG of a scene so colourless that every pixel is grey within 0.002 counts too (such a photo gets no look today either). Photos whose JPEG has colour render as before.
+2 -1
View File
@@ -238,7 +238,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome |
| LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | |
| LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | |
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it; bundled ILCA-99M2 from 144 photos: 30 held-out 5.11 → 4.87, 25 of them closer); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md) |
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it; bundled ILCA-99M2 from 144 photos: 30 held-out 5.11 → 4.87, 25 of them closer); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); the fit pairs the sensor moved into the file's lens-warp geometry when that lines up better with a lens-corrected camera JPEG (29 CC0 raws: alignment +0.13, mean ΔE 3.73 → 3.61) |
| LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched |
| LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | |
| LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF |
@@ -854,6 +854,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-13 | minor | LR-EDIT-OPTICS-PROFILE: Nikon Z 6II / Z 8 / Z f NEFs use the camera's in-file distortion correction (NikonNEFInfo); status unchanged (🟡) |
| 2026-10-12 | minor | LR-IMP-FORMATS and LR-IMP-CAMERA-COVERAGE: 22 more CR3 bodies verified on one CC0 file each (all 110 CC0 CR3s decode); EOS R6 Mark III `ColorData` 66 white balance; status unchanged (🟡: CRX variants without public samples remain refused) |
| 2026-10-11 | minor | Model setup folder action, opener error and retry (LR-MASK-OBJECTS). |
| 2026-10-13 | minor | LR-PROF-CAMERACOLOR: the camera look follows the lens warp of lens-corrected camera JPEGs; status unchanged (🟡) |
| 2026-10-12 | minor | LR-VIEW-ZOOM: slider-drag drafts are timed with the frame that shows them, drop to 0.6 mid-drag when far over budget, stay 0.6 under a zoom window; workers convert texture pixels (issue #330); status unchanged |
| 2026-10-11 | minor | LR-VIEW-ZOOM: a slider drag keeps the loupe's full resolution where the GPU draws the view and keeps up (0.6 drafts on the CPU, with soft proofing or the HDR range, or after over-budget drags; issue #330); status unchanged |
| 2026-10-11 | minor | LR-EXP-LOCATION: Export to "Same folder as original photo" (`destinationMode`), issue #956; status unchanged (✅) |
+5
View File
@@ -20,6 +20,11 @@
#1447), or keeps the photo you had when the folder holds it. Before, the photo from the previous view stayed active:
Photo Grid marked nothing, and Detail, the histogram and the photo info showed a photo that isn't in the folder.
### Closer camera colour for lens-corrected raws
- Raws from cameras that correct their own JPEG for lens distortion (Panasonic and Fujifilm with that setting on, the
Sony A7R IVA) now start from colours closer to the camera's. The starting look was being fitted against a JPEG that
didn't line up with the raw near the edges. Photos you already imported pick this up when their previews are rebuilt.
### HDR merges of moving lights, and many brackets at once
- A star, the moon or a light that moves between the photos of a bracket (a night bracket on a tripod) no longer
leaves a black hole where the middle photo had it clipped. With Deghost, the brightest photo's copy of it in a