From d6c29aaf254bb26c8bb3451c18d2de59cf9e946f Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Mon, 12 Oct 2026 04:18:59 +1100 Subject: [PATCH 01/36] LR-IMP-FORMATS: decode Minolta MRW (packed 12-bit and 16-bit words, RGGB / GBRG) The \0MRM block container: PRD sensor size, storage and Bayer cell; WBG as-shot gains in cell order; TTW TIFF for Exif and orientation. Black 0, white from the saturation plateau. 4 CC0 samples pinned in RAW_SAMPLES; DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D verified. Also fixes clippy::chunks_exact_to_as_chunks in pipeline/spots.rs. Sources: public prose description of the MRW layout (Dalibor Jelinek's MRW notes) and own black-box analysis of the CC0 samples. Co-Authored-By: Claude Opus 5.5 (1M context) --- ROADMAP.md | 8 +- crates/engine/src/files.rs | 4 +- crates/meta/src/container.rs | 9 ++ crates/meta/src/lib.rs | 1 + crates/meta/src/mrw.rs | 93 ++++++++++++++ crates/pipeline/src/spots.rs | 4 +- crates/raw/src/lib.rs | 8 +- crates/raw/src/preview.rs | 3 +- crates/raw/src/vendor/mod.rs | 1 + crates/raw/src/vendor/mrw.rs | 230 +++++++++++++++++++++++++++++++++++ crates/raw/tests/corpus.rs | 48 ++++++++ docs/gaps.md | 7 +- docs/parity-checklist.md | 5 +- docs/raw-parity.md | 22 ++-- docs/raw/coverage.py | 4 +- docs/raw/coverage.tsv | 22 ++-- docs/raw/evidence.tsv | 6 + docs/target-app-parity.md | 5 +- xtask/src/main.rs | 20 +++ 19 files changed, 461 insertions(+), 39 deletions(-) create mode 100644 crates/meta/src/mrw.rs create mode 100644 crates/raw/src/vendor/mrw.rs diff --git a/ROADMAP.md b/ROADMAP.md index c804c36fc..909113f9e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Minolta MRW decoded, 6 bodies verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -45,7 +45,7 @@ Hours are calibrated on this repo's PR history; see | Dimension | Ready | Breadth | Remaining | Doc | |---|---:|---:|---:|---| | Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) | -| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | +| ↳ RAW cameras, colour, lens profiles | ~56% | 82% of 1,446 Adobe models decode, 17% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | | UI/UX fidelity | ~70% | ~85% | 40–70 h | [ui-parity](docs/ui-parity.md) | | File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) | | Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) | @@ -114,6 +114,9 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ ## Progress log +- 2026-10-12: Minolta / Konica Minolta MRW decodes from sensor data (12-bit packed and 16-bit words, + RGGB and GBRG, as-shot WB from `WBG`); DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D verified on + CC0 files. 241 models verified, 222 preview-only; headline numbers unchanged. - 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files pinned, the QP maps of 33,665 private C-RAW files decode exactly ([cr3.md](docs/cr3.md)). 235 models @@ -161,6 +164,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Minolta MRW decoded: 6 more verified models (241), 11 fewer preview-only; headline numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | | 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away | diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index c48aaff77..a5f03e476 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -1825,8 +1825,8 @@ mod tests { } } - /// Recognised raw containers we don't decode (Minolta MRW here: the preview's first byte is - /// overwritten in the file) import as preview only with the JPEG's size, instead of failing. + /// Recognised raw containers we can't decode (an MRW whose block header is damaged here; its preview's first + /// byte is overwritten, as in real MRW files) import as preview only with the JPEG's size, instead of failing. #[test] fn undecodable_container_with_a_preview_imports_as_preview_only() { let px: Vec = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); diff --git a/crates/meta/src/container.rs b/crates/meta/src/container.rs index 8dda6b55d..3ac60907f 100644 --- a/crates/meta/src/container.rs +++ b/crates/meta/src/container.rs @@ -7,6 +7,7 @@ //! - WebP (RIFF container spec): `EXIF`, `XMP `, `ICCP` chunks. //! - TIFF-based files (TIFF, DNG and most raws): the whole file is the Exif block; XMP (700), ICC (34675) and //! IPTC (33723) from IFD0. +//! - Minolta MRW (see [`crate::mrw`]): the `TTW` block is the Exif block. //! - Canon CR3 (ISO base media file, see [`crate::cr3`]): `CMT1`/`CMT2`/`CMT4` merged into one Exif block; the //! XMP `uuid` box. @@ -36,6 +37,14 @@ pub fn embedded(bytes: &[u8]) -> Embedded { _ => Embedded::default(), }; } + if let Some(m) = crate::mrw::parse(bytes) { + // Minolta MRW: the `TTW` block is a complete TIFF with the Exif data + return m + .block(b"TTW") + .filter(|t| Tiff::parse(t).is_ok()) + .map(|t| Embedded { exif: Some(t.to_vec()), ..Default::default() }) + .unwrap_or_default(); + } if let Some(c) = crate::cr3::parse_cr3(bytes) { return Embedded { exif: crate::cr3::merged_exif(&c), xmp: c.xmp.map(|b| String::from_utf8_lossy(b).into_owned()), ..Default::default() }; } diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 31a35cc65..52bf211fe 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -17,6 +17,7 @@ mod datetime; mod exif; mod gpx; mod iptc; +pub mod mrw; pub mod tags; mod xmp; mod xmp_merge; diff --git a/crates/meta/src/mrw.rs b/crates/meta/src/mrw.rs new file mode 100644 index 000000000..ef641c44d --- /dev/null +++ b/crates/meta/src/mrw.rs @@ -0,0 +1,93 @@ +//! Minolta / Konica Minolta MRW block container. +//! +//! Sources: the public prose description of the MRW layout (Dalibor Jelínek's MRW notes) and our own black-box +//! analysis of the CC0 samples on raw.pixls.us. The file starts with `\0MRM` and a big-endian `u32` length; the +//! sensor data begins right after that many bytes (at `8 + length`). In between is a sequence of blocks, each a +//! four-byte name (`\0PRD`, `\0TTW`, `\0WBG`, `\0RIF`, `\0PAD`), a big-endian `u32` length and that many bytes. +//! `\0TTW` holds a complete big-endian TIFF (IFD0, Exif, maker note) whose offsets are relative to the block. + +/// The blocks and the offset of the sensor data in an MRW file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Mrw<'a> { + /// `(name without the leading NUL, contents)`, in file order. + pub blocks: Vec<([u8; 3], &'a [u8])>, + /// Absolute offset of the sensor data. + pub data_offset: usize, +} + +impl<'a> Mrw<'a> { + /// The first block called `name` (e.g. `b"PRD"`). + pub fn block(&self, name: &[u8; 3]) -> Option<&'a [u8]> { + self.blocks.iter().find(|(n, _)| n == name).map(|(_, d)| *d) + } +} + +/// At most this many blocks are walked (real files have four or five). +const MAX_BLOCKS: usize = 64; + +fn be32(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(4)?)?; + Some(u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as usize) +} + +/// Parse the block list of an MRW file. `None` when the magic is missing or the header length points past the +/// file; a block that runs past the header is dropped together with everything after it. +pub fn parse(bytes: &[u8]) -> Option> { + if !bytes.starts_with(b"\0MRM") { + return None; + } + let data_offset = be32(bytes, 4)?.checked_add(8).filter(|&o| o <= bytes.len())?; + let mut blocks = Vec::new(); + let mut at = 8usize; + while blocks.len() < MAX_BLOCKS && at.saturating_add(8) <= data_offset { + let (Some(name), Some(len)) = (bytes.get(at..at + 4), be32(bytes, at + 4)) else { break }; + let start = at + 8; + let Some(end) = start.checked_add(len).filter(|&e| e <= data_offset) else { break }; + let Some(data) = bytes.get(start..end) else { break }; + if name[0] == 0 { + blocks.push(([name[1], name[2], name[3]], data)); + } + at = end; + } + Some(Mrw { blocks, data_offset }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn block(name: &[u8; 3], data: &[u8]) -> Vec { + let mut v = vec![0]; + v.extend_from_slice(name); + v.extend_from_slice(&(data.len() as u32).to_be_bytes()); + v.extend_from_slice(data); + v + } + + #[test] + fn walks_blocks_and_finds_the_data() { + let mut body = block(b"PRD", &[1, 2, 3]); + body.extend(block(b"WBG", &[4; 12])); + let mut f = b"\0MRM".to_vec(); + f.extend_from_slice(&(body.len() as u32).to_be_bytes()); + f.extend_from_slice(&body); + f.extend_from_slice(&[9, 9]); + let m = parse(&f).unwrap(); + assert_eq!(m.block(b"PRD"), Some(&[1u8, 2, 3][..])); + assert_eq!(m.block(b"WBG").map(<[u8]>::len), Some(12)); + assert_eq!(m.block(b"TTW"), None); + assert_eq!(m.data_offset, f.len() - 2); + } + + #[test] + fn hostile_lengths_never_panic() { + assert_eq!(parse(b"\0MRM"), None); + assert_eq!(parse(b"\0MRM\xff\xff\xff\xff"), None); + // a block claiming more than the header holds is dropped + let f = b"\0MRM\0\0\0\x0c\0PRD\xff\xff\xff\xff\0\0\0\0"; + assert_eq!(parse(f).map(|m| m.blocks.len()), Some(0)); + for n in 0..f.len() { + let _ = parse(&f[..n]); + } + } +} diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3c..f8b3fbbdf 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,8 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + for (d, v) in src.iter_mut().zip(out.as_chunks::<3>().0) { + *d = *v; } } diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 8a09a9edb..fb7ac3c35 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -100,7 +100,7 @@ pub enum RawFormat { Srw, /// Canon CRW (CIFF heap), the bodies before the CR2 era. Crw, - /// Minolta MRW (`\0MRM` block container). + /// Minolta MRW (`\0MRM` block container, `vendor::mrw`). Mrw, /// Sigma / Foveon X3F (`FOVb`). X3f, @@ -131,6 +131,7 @@ impl RawFormat { | RawFormat::CfaTiff | RawFormat::Srw | RawFormat::KodakDcs + | RawFormat::Mrw ) } } @@ -370,6 +371,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result { RawFormat::CfaTiff => dng::decode_as(bytes, mode, RawFormat::CfaTiff).map(lift_clipped), RawFormat::Srw => vendor::srw::decode(bytes, mode), RawFormat::KodakDcs => vendor::kodak::decode(bytes, mode), + RawFormat::Mrw => vendor::mrw::decode(bytes, mode), RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))), other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))), } @@ -1002,10 +1004,10 @@ mod tests { assert_eq!(probe(b"II\x1a\0\0\0HEAPCCDR\0\0"), Some(RawFormat::Crw)); assert_eq!(probe(b"\0MRM\0\x01\0\0"), Some(RawFormat::Mrw)); assert_eq!(probe(b"FOVb\x02\0\x02\0"), Some(RawFormat::X3f)); - for f in [RawFormat::Crw, RawFormat::Mrw, RawFormat::X3f] { + for f in [RawFormat::Crw, RawFormat::X3f] { assert!(!f.is_supported()); } - assert!(matches!(decode(b"\0MRM\0\x01\0\0"), Err(RawError::Unsupported(w)) if w.contains("Mrw"))); + assert!(matches!(decode(b"FOVb\x02\0\x02\0"), Err(RawError::Unsupported(w)) if w.contains("X3f"))); // too short for the CIFF signature assert_eq!(probe(b"II\x1a\0\0\0HEAP"), None); } diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index 4660b737f..e3e882690 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -616,7 +616,8 @@ mod tests { f.extend_from_slice(part); f.extend_from_slice(&[0u8; 7]); } - assert!(crate::probe(&f).is_some_and(|p| !p.is_supported()), "{head:?}"); + // MRW decodes; its preview is still found by the scan + assert!(crate::probe(&f).is_some_and(|p| !p.is_supported() || p == crate::RawFormat::Mrw), "{head:?}"); assert_eq!(embedded_preview(&f).as_deref(), Some(&big[..]), "{head:?}"); for n in (0..f.len()).step_by(11) { let _ = embedded_preview(&f[..n]); diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index b7bef1471..62c1b2161 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -11,6 +11,7 @@ pub mod cr3; pub mod crx; pub(crate) mod crx_wavelet; pub mod kodak; +pub mod mrw; pub mod nef; pub mod nefc; pub mod orf; diff --git a/crates/raw/src/vendor/mrw.rs b/crates/raw/src/vendor/mrw.rs new file mode 100644 index 000000000..25d19204e --- /dev/null +++ b/crates/raw/src/vendor/mrw.rs @@ -0,0 +1,230 @@ +//! Minolta / Konica Minolta MRW: a block container (see [`lightcraft_meta::mrw`]) around a 12-bit Bayer mosaic. +//! +//! Sources: the public prose description of the MRW layout (Dalibor Jelínek's MRW notes) and our own black-box +//! analysis of the eight CC0 MRW samples on raw.pixls.us (DiMAGE 7, 7Hi, A1, A2, A200, Dynax / Maxxum / ALPHA-7 +//! Digital); every number below was measured on them. +//! +//! - `PRD` (24 bytes, big-endian): 8 ASCII version bytes, sensor rows and columns (`u16` each), image rows and +//! columns, data size (12 or 16 bits per stored sample), pixel size (12), storage method (`0x59` = 12-bit +//! packed: two samples in three bytes, most significant bits first; `0x52` = one 12-bit sample per big-endian +//! 16-bit word), two unknown bytes and the Bayer layout as a `u16` (`1` = RGGB, `4` = GBRG). The sensor data +//! starts after the header and is exactly sensor rows x columns samples in every sample. +//! - The sensor frame is 8 columns and 6 to 8 rows larger than the image size, but every edge holds picture (no +//! masked border), so the whole frame is the active area and the image size is the default crop. +//! - Black is 0: the darkest samples are 0 in every packed file and 13..34 in the unpacked ones, with no offset +//! left to subtract. White is the saturation plateau where the frame has one (3964 on the A1, 3965 on a clipped +//! DiMAGE 7, 4020 on the A200, 4076 on the 7D), else 4095. +//! - The layout code was checked against the embedded preview: with `1` the two greens are the equal (0,1) and +//! (1,0) positions; in the A200 (`4`) the greens are (0,0) and (1,1), and only red at (1,0) reproduces the +//! preview's red-dominated colour. +//! - `WBG` (12 bytes): four scale bytes, then four big-endian `u16` coefficients in the order of the Bayer cell +//! read row by row (RGGB: R G G B, GBRG: G B R G). Red over green and blue over green are the as-shot gains; +//! applied to the raw channel means they reproduce the preview's colour balance within the colour matrix's +//! reach on all eight samples. +//! - `TTW` is a TIFF with IFD0 (orientation), Exif and the maker note; the preview JPEG sits in it with its first +//! byte overwritten (see `preview::scan_for_jpeg`). +use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, unpack}; +use lightcraft_geom::Orientation; +use lightcraft_tiff::{Tiff, tags as t}; + +/// Storage method codes in `PRD`. +const PACKED_12: u8 = 0x59; +const WORDS_16: u8 = 0x52; + +/// What `PRD` says about the sensor data. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Prd { + sensor: (usize, usize), + image: (usize, usize), + packed: bool, + cfa: &'static str, +} + +fn be16(b: &[u8], at: usize) -> Option { + b.get(at..at + 2).map(|s| u16::from_be_bytes([s[0], s[1]])) +} + +fn prd(b: &[u8]) -> Result { + let u = |at| be16(b, at).map(usize::from).ok_or_else(|| RawError::Corrupt("Mrw PRD block too short".into())); + let (sh, sw, ih, iw) = (u(8)?, u(10)?, u(12)?, u(14)?); + let (size, storage) = (b.get(16).copied(), b.get(18).copied()); + let packed = match (size, storage) { + (Some(12), Some(PACKED_12)) => true, + (Some(16), Some(WORDS_16)) => false, + _ => return Err(RawError::Unsupported(format!("Mrw storage {size:?}/{storage:?} is not decoded yet"))), + }; + let cfa = match u(22)? { + 1 => "RGGB", + 4 => "GBRG", + other => return Err(RawError::Unsupported(format!("Mrw Bayer layout {other}"))), + }; + if sw == 0 || sh == 0 || (packed && sw % 2 != 0) { + return Err(RawError::Corrupt("Mrw sensor size".into())); + } + Ok(Prd { sensor: (sw, sh), image: (iw.min(sw), ih.min(sh)), packed, cfa }) +} + +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { + let m = lightcraft_meta::mrw::parse(bytes).ok_or_else(|| RawError::Corrupt("Mrw block header".into()))?; + let p = prd(m.block(b"PRD").ok_or_else(|| RawError::Unsupported("Mrw file without a PRD block".into()))?)?; + let (w, h) = p.sensor; + let n = w.checked_mul(h).filter(|n| *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; + let need = if p.packed { n / 2 * 3 } else { n * 2 }; + let src = bytes + .get(m.data_offset..) + .and_then(|s| s.get(..need)) + .ok_or_else(|| RawError::Corrupt(format!("Mrw sensor data truncated (needs {need} bytes)")))?; + let cfa = Cfa::bayer_static(p.cfa); + let (data, white) = if mode == Mode::Full { + let mut d = vec![0u16; n]; + if p.packed { + unpack::unpack_msb(src, 12, &mut d); + } else { + unpack::read_u16s(src, lightcraft_tiff::ByteOrder::Big, &mut d); + for v in &mut d { + *v = (*v).min(4095); + } + } + let white = super::white_from_data(&d, 12); + (RawData::U16(d), white) + } else { + (RawData::U16(Vec::new()), 4095.0) + }; + let wb = m.block(b"WBG").and_then(|b| wb_from(b, &cfa)); + let tiff = m.block(b"TTW").and_then(|b| Tiff::parse(b).ok()); + let mut metadata = tiff.as_ref().map(lightcraft_meta::from_tiff).unwrap_or_default(); + metadata.width = Some(p.image.0 as u32); + metadata.height = Some(p.image.1 as u32); + let orientation = tiff.as_ref().and_then(|t| t.ifds.first()).and_then(|i| i.u16(t::ORIENTATION)).unwrap_or(1); + let img = RawImage { + format: RawFormat::Mrw, + width: w, + height: h, + cpp: 1, + data, + cfa: Some(cfa), + bits: 12, + black: BlackLevel::uniform(0.0), + white: vec![white], + active_area: Rect::new(0, 0, w, h), + crop: Rect::new(0, 0, p.image.0, p.image.1), + orientation: Orientation::from_exif(orientation), + color: ColorData::default(), + wb_multipliers: wb, + linearized: false, + opcodes: OpcodeLists::default(), + metadata, + }; + img.validate_for(mode)?; + Ok(img) +} + +/// As-shot gains from `WBG`: four coefficients in Bayer-cell order after four scale bytes. +fn wb_from(b: &[u8], cfa: &Cfa) -> Option<[f32; 3]> { + let mut sum = [0f32; 3]; + let mut count = [0f32; 3]; + for (i, &c) in cfa.pattern.iter().enumerate().take(4) { + let v = f32::from(be16(b, 4 + 2 * i)?); + let k = usize::from(c).min(2); + sum[k] += v; + count[k] += 1.0; + } + if count.contains(&0.0) || sum.iter().any(|&s| s <= 0.0) { + return None; + } + let mean = |k: usize| sum[k] / count[k]; + Some([mean(0) / mean(1), 1.0, mean(2) / mean(1)]) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + /// A synthetic MRW: `PRD` with the given storage, `WBG`, a `TTW` TIFF with orientation 6, then the samples. + pub(crate) fn sample(w: u16, h: u16, packed: bool, layout: u16, wbg: [u16; 4], samples: &[u16]) -> Vec { + let mut prd = b"21810002".to_vec(); + for v in [h, w, h - 2, w - 2] { + prd.extend_from_slice(&v.to_be_bytes()); + } + prd.extend_from_slice(&[if packed { 12 } else { 16 }, 12, if packed { PACKED_12 } else { WORDS_16 }, 0, 0, 0]); + prd.extend_from_slice(&layout.to_be_bytes()); + let mut wb = vec![2u8; 4]; + for v in wbg { + wb.extend_from_slice(&v.to_be_bytes()); + } + let mut ifd0 = lightcraft_tiff::IfdBuilder::new(); + ifd0.set(t::MAKE, lightcraft_tiff::Value::Ascii("Minolta Co., Ltd.".into())); + ifd0.set(t::ORIENTATION, lightcraft_tiff::Value::Short(vec![6])); + let ttw = lightcraft_tiff::TiffWriter::default().write(&[ifd0]).unwrap(); + let mut body = Vec::new(); + for (name, data) in [(b"PRD", &prd), (b"WBG", &wb), (b"TTW", &ttw)] { + body.push(0); + body.extend_from_slice(name); + body.extend_from_slice(&(data.len() as u32).to_be_bytes()); + body.extend_from_slice(data); + } + let mut f = b"\0MRM".to_vec(); + f.extend_from_slice(&(body.len() as u32).to_be_bytes()); + f.extend_from_slice(&body); + if packed { + for p in samples.chunks(2) { + let (a, b) = (p[0], p.get(1).copied().unwrap_or(0)); + f.extend_from_slice(&[(a >> 4) as u8, ((a & 15) << 4) as u8 | (b >> 8) as u8, b as u8]); + } + } else { + for v in samples { + f.extend_from_slice(&v.to_be_bytes()); + } + } + f + } + + #[test] + fn decodes_packed_and_word_storage() { + let (w, h) = (8u16, 6u16); + let px: Vec = (0..48).map(|i| (i * 85) as u16 % 4096).collect(); + for packed in [true, false] { + let f = sample(w, h, packed, 1, [408, 256, 256, 450], &px); + assert_eq!(crate::probe(&f), Some(RawFormat::Mrw)); + let r = crate::decode(&f).unwrap(); + assert_eq!((r.width, r.height, r.bits), (8, 6, 12)); + assert_eq!(r.data, RawData::U16(px.clone()), "packed {packed}"); + assert_eq!(r.cfa.as_ref().map(Cfa::name).as_deref(), Some("RGGB")); + assert_eq!(r.crop, Rect::new(0, 0, 6, 4)); + assert_eq!(r.orientation, Orientation::from_exif(6)); + assert_eq!(r.metadata.make.as_deref(), Some("Minolta Co., Ltd.")); + let wb = r.wb_multipliers.unwrap(); + assert!((wb[0] - 408.0 / 256.0).abs() < 1e-6 && (wb[2] - 450.0 / 256.0).abs() < 1e-6, "{wb:?}"); + assert_eq!(crate::probe_info(&f).unwrap(), r.info()); + } + } + + /// GBRG (layout 4): the coefficients follow the cell, so the second is blue and the third red. + #[test] + fn gbrg_white_balance_follows_the_cell() { + let f = sample(4, 4, true, 4, [255, 416, 480, 255], &[100; 16]); + let r = crate::decode(&f).unwrap(); + assert_eq!(r.cfa.as_ref().map(Cfa::name).as_deref(), Some("GBRG")); + let wb = r.wb_multipliers.unwrap(); + assert!((wb[0] - 480.0 / 255.0).abs() < 1e-6 && (wb[2] - 416.0 / 255.0).abs() < 1e-6, "{wb:?}"); + } + + /// Truncations and corrupted header bytes give errors, never panics. + #[test] + fn malformed_files_never_panic() { + let f = sample(8, 6, true, 1, [408, 256, 256, 450], &[7; 48]); + for n in 0..f.len() { + assert!(crate::decode(&f[..n]).is_err() || n == f.len()); + } + for i in 8..f.len().min(120) { + for v in [0u8, 0xff, 0x52, 0x59] { + let mut g = f.clone(); + g[i] = v; + let _ = crate::decode(&g); + let _ = crate::probe_info(&g); + } + } + assert!(matches!(crate::decode(b"\0MRM\0\x01\0\0"), Err(RawError::Corrupt(_)))); + assert!(matches!(crate::decode(b"\0MRM\0\0\0\0"), Err(RawError::Unsupported(w)) if w.contains("Mrw"))); + } +} diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 5ca4777b7..7d21d72b0 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -1133,3 +1133,51 @@ fn fuji_embedded_lens_tables_follow_the_validated_models() { } eprintln!("Fuji lens tables: {checked} RAFs, {warped} warps, {vignetted} vignette gains"); } + +/// Minolta MRW (`crates/raw/src/vendor/mrw.rs`): packed and 16-bit-word storage, RGGB and GBRG cells, the image +/// size as the crop, the `WBG` gains in cell order and an embedded preview. Skips files that are absent. +#[test] +fn corpus_minolta_mrw() { + let root = corpus_root().join("raw"); + let mut checked = 0; + // (file, sensor size, crop size, cell, white, red gain, blue gain) + for (name, sensor, crop, cell, white, r, b) in [ + ("mrw-minolta-dimage7hi-words16.mrw", (2568, 1928), (2560, 1920), "RGGB", 4095.0, 411.0 / 256.0, 343.0 / 256.0), + ("mrw-minolta-dimage-a1.mrw", (2568, 1928), (2560, 1920), "RGGB", 3964.0, 341.0 / 256.0, 539.0 / 256.0), + ("mrw-minolta-dimage-a200-gbrg.mrw", (3280, 2454), (3264, 2448), "GBRG", 4020.0, 480.0 / 255.0, 416.0 / 255.0), + ("mrw-minolta-dynax7d.mrw", (3016, 2008), (3008, 2000), "RGGB", 4076.0, 435.0 / 256.0, 435.0 / 256.0), + ] { + let Ok(bytes) = std::fs::read(root.join(name)) else { + eprintln!("skip: {name} absent"); + continue; + }; + assert_eq!(probe(&bytes), Some(RawFormat::Mrw), "{name}"); + let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(((img.width, img.height), img.bits), (sensor, 12), "{name}"); + assert_eq!((img.crop.width, img.crop.height), crop, "{name}"); + assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some(cell), "{name}"); + assert!((img.white_at(0) - white).abs() <= 8.0, "{name}: white {}", img.white_at(0)); + assert_eq!(img.black.mean(), 0.0, "{name}"); + let wb = img.wb_multipliers.unwrap_or_else(|| panic!("{name}: no white balance")); + assert!((wb[0] - r).abs() < 1e-4 && (wb[2] - b).abs() < 1e-4, "{name}: {wb:?}"); + assert!(img.metadata.make.as_deref().is_some_and(|m| m.to_ascii_uppercase().contains("MINOLTA")), "{name}: {:?}", img.metadata.make); + // the two greens of the cell agree (a wrong cell or a packing slip would split them) + let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") }; + let (w, h) = sensor; + let mean = |y0: usize, x0: usize| { + let (mut s, mut n) = (0f64, 0f64); + for y in (y0..h).step_by(2) { + for x in (x0..w).step_by(2) { + s += f64::from(d[y * w + x]); + n += 1.0; + } + } + s / n + }; + let (g1, g2) = if cell == "RGGB" { (mean(0, 1), mean(1, 0)) } else { (mean(0, 0), mean(1, 1)) }; + assert!((g1 - g2).abs() < 0.02 * g1, "{name}: greens {g1} vs {g2}"); + assert!(embedded_preview(&bytes).is_some(), "{name}: no preview"); + checked += 1; + } + eprintln!("MRW: {checked} corpus files checked"); +} diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..1b229d6bf 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 20: Minolta MRW decodes; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -24,7 +24,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | | 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony ILCE-7RM4A distortion | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | -| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 235 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 241 of 1,446 Adobe models verified (17%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | @@ -34,7 +34,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) | | 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) | | 19 | **Plug-in ecosystem**: no Lightroom SDK equivalent (export / publish plug-ins, metadata plug-ins) | ⬜ | pro workflows built on plug-ins (LR/Enfuse, Jeffrey's exporters, Negative Lab Pro) | 40–80 + owner decision | [target-app-parity](target-app-parity.md#by-dimension) | -| 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, ERF, KDC, DCR, MEF, GPR, old FinePix | raw-parity.md | small user share each | 45–85 | [raw-parity](raw-parity.md) | +| 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, ERF, KDC, DCR, MEF, GPR, old FinePix (MRW decodes since 2026-10-12) | raw-parity.md | small user share each | 40–80 | [raw-parity](raw-parity.md) | | 21 | **Localization**: 9 of 15 shipped languages partial (74–99% of catalog messages), lower-layer errors English; Hindi, Arabic, Indonesian, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 55–90 (shipped to full); 145–255 (five new) | [localization-parity](localization-parity.md) | | 22 | **Pen pressure**, OS monitor profiles, follow-window colour | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) | | 23 | **Accessibility** audit with screen readers; canvas tools keyboard-reachable | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 10–20 | [ui-parity](ui-parity.md) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 20 shrinks: Minolta MRW decodes (`vendor/mrw.rs`), 6 bodies verified; gap 10 count 235 → 241 | | 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | | 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | | 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..3848901d3 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-IMP-FORMATS: Minolta MRW decodes; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -80,7 +80,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Minolta / Konica Minolta MRW (the `\0MRM` block container: `PRD` sensor size, 12-bit packed or 16-bit-word storage and the RGGB / GBRG cell, `WBG` gains in cell order, the `TTW` TIFF for Exif and orientation, black 0 and white from the saturation plateau; `crates/raw/src/vendor/mrw.rs`, DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D checked on CC0 files against their previews), Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md` | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, formats whose preview only the whole file reveals (CR3 boxes, TIFF pointer tags past the prefix, CRW/MRW/X3F scans) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-IMP-FORMATS: Minolta MRW decodes from sensor data (`vendor/mrw.rs`), no longer preview-only | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 2b4b9efb5..49cfa73d1 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-11 · **Change:** minor (EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Minolta MRW decoded: 6 bodies verified) · **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 @@ -14,9 +14,9 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), | Measure | Value | Kind | |---|---:|---| | Adobe-listed camera models | **1,446** | measured: [raw/adobe-cameras.tsv](raw/adobe-cameras.tsv) | -| Decoded from sensor data and **verified** on a real file | **235** (16%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | -| Decoder path should handle it, **unverified** | **942** (65%) | measured from the format rules (632 of them DNG, mostly phones) | -| **Preview-only** (embedded JPEG) | **233** (16%) | measured | +| Decoded from sensor data and **verified** on a real file | **241** (17%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | +| Decoder path should handle it, **unverified** | **947** (65%) | measured from the format rules (632 of them DNG, mostly phones) | +| **Preview-only** (embedded JPEG) | **222** (15%) | measured | | **Unsupported** (not imported, refused or decodes wrongly) | **36** (2%) | measured | | Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 82 / 640 / 24 / 8 of 754 | measured | | **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~85%** | estimated: measured classes × written usage weights × per-format confidence | @@ -41,7 +41,7 @@ committed table is stale. `lightcraft_engine::import::EXTENSIONS`, and `KNOWN_UNSUPPORTED` in `crates/raw/tests/corpus.rs`. Doc claims were not used. - **Verified** means a real file from that body decoded from sensor data in: the corpus test over the - `RAW_SAMPLES` manifest (`xtask/src/main.rs`, 101 CC0 files; bodies whose sample is in + `RAW_SAMPLES` manifest (`xtask/src/main.rs`, 105 CC0 files; bodies whose sample is in `KNOWN_UNSUPPORTED` excluded), `crates/raw/tests/cr3_corpus.rs` (3 bodies exact against an external decoder; the EOS R5 Mark II pinned, with a QP-map check of 33,665 private files), the RAF reference check ([raf-compression.md](raf-compression.md)), and the raw.pixls.us bulk checks recorded in issue @@ -80,8 +80,8 @@ committed table is stale. | Casio | 26 | 0 | 26 | 0 | 0 | — (DNG) | | DJI | 21 | 0 | 21 | 0 | 0 | — (DNG) | | OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** | -| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 0 | 14 | 27 | 15 | DCR, GPR not imported; KDC, MRW, ERF, X3F preview | -| **All** | **1,446** | **235 (16%)** | **942 (65%)** | **233 (16%)** | **36 (2%)** | | +| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 6 | 19 | 16 | 15 | DCR, GPR not imported; KDC, ERF, X3F preview (MRW decodes) | +| **All** | **1,446** | **241 (17%)** | **947 (65%)** | **222 (15%)** | **36 (2%)** | | ## By use (estimated) @@ -97,7 +97,7 @@ committed table is stale. | Pentax / Ricoh | 1.5% | 94% | 14% | | | Leica | 1% | 95% | 5% | | | DJI | 1% | 95% | 0% | | -| Medium format and others | 0.5% | 23% | 0% | IIQ, 3FR, X3F | +| Medium format and others | 0.5% | 28% | 3% | IIQ, 3FR, X3F | | **Weighted** | 100% | **85%** | **42%** | | About 5 in 6 raw photographers' cameras decode, but only about 2 in 5 use a body we have actually @@ -144,7 +144,8 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | DNG | GoPro GPR (VC-5) | ⬜ | refused | | 3FR / FFF | DNG-like layouts | 🟡 | decoded when uncompressed or LJ92 | | 3FR / FFF | Hasselblad compression | ⬜ | preview | -| IIQ, MOS, MRW, ERF, KDC, X3F | all | ⬜ | preview | +| MRW | 12-bit packed, 16-bit words; RGGB and GBRG | ✅ | 4 CC0 corpus files (DiMAGE 7Hi, A1, A200, Dynax 7D) plus DiMAGE 7 and A2 checked against their previews; `vendor/mrw.rs` | +| IIQ, MOS, ERF, KDC, X3F | all | ⬜ | preview | | DCR, MEF, MFW, DXO, GPR, TIFF raws | all | ⬜ | not imported | ### Compressed ORF: a clean-room starting point @@ -251,7 +252,7 @@ works, from [raw/usage-weights.tsv](raw/usage-weights.tsv). | 7 | Sony A100 and DSC-F828 regression (#702) | +0.1 | 1–2 | ~0.07 | samples on raw.pixls.us | | 8 | Canon CRW (2000–2004 bodies) | +1.2 by the uniform tail weight, far less by real use | 6–12 | ≤ 0.1 | samples | | 9 | Nikon HE / HE★ NEF (Z 8, Z 9, Z 6III, Z f, Z50II, Z5II set to HE) | +1.1 | 20–40 | ~0.04 | a pure-Rust JPEG XS decoder | -| 10 | Compressed SRW, Hasselblad 3FR, IIQ, X3F, MRW, KDC, ERF, DCR, MEF, GPR | +0.6 together | 45–85 | ~0.01 | samples; low use | +| 10 | Compressed SRW, Hasselblad 3FR, IIQ, X3F, KDC, ERF, DCR, MEF, GPR (MRW done) | +0.6 together | 45–85 | ~0.01 | samples; low use | Beyond decoding: @@ -266,6 +267,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Minolta MRW decoded (`vendor/mrw.rs`): DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D verified; 241 verified, 947 unverified, 222 preview | | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | | 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | diff --git a/docs/raw/coverage.py b/docs/raw/coverage.py index c635c993a..c267a2573 100644 --- a/docs/raw/coverage.py +++ b/docs/raw/coverage.py @@ -83,7 +83,9 @@ def classify(maker, camera, ext_field): return "preview", "SRW", "compressed SRW is not decoded" return "unverified", "SRW", "uncompressed / packed SRW" e = es[0] if es else "" - if e in ("CRW", "3FR", "FFF", "IIQ", "MOS", "ERF", "KDC", "MRW", "X3F", "RAW", "ORI"): + if e == "MRW": + return "unverified", "MRW", "MRW decoder (12-bit packed, 16-bit words)" + if e in ("CRW", "3FR", "FFF", "IIQ", "MOS", "ERF", "KDC", "X3F", "RAW", "ORI"): return "preview", e, f"{e} is opened from its embedded JPEG only" return "unsupported", e, f"{e or 'unknown'} is not imported" diff --git a/docs/raw/coverage.tsv b/docs/raw/coverage.tsv index cc0dc1e66..3d7e28d5d 100644 --- a/docs/raw/coverage.tsv +++ b/docs/raw/coverage.tsv @@ -552,17 +552,17 @@ Kodak EasyShare Z1015 IS KDC 2008 preview KDC is opened from its embedded JPEG o Kodak EasyShare Z980 KDC 2008 preview KDC is opened from its embedded JPEG only Kodak EasyShare Z981 RAW 2010 preview RAW is opened from its embedded JPEG only Kodak EasyShare Z990 RAW 2010 preview RAW is opened from its embedded JPEG only -Konica Minolta Alpha-5 Digital (China) MRW 2005 preview MRW is opened from its embedded JPEG only -Konica Minolta Alpha Sweet Digital (Japan) MRW 2005 preview MRW is opened from its embedded JPEG only -Konica Minolta DiMAGE 5 MRW 2003 preview MRW is opened from its embedded JPEG only -Konica Minolta DiMAGE 7 MRW 2003 preview MRW is opened from its embedded JPEG only -Konica Minolta DiMAGE 7i MRW 2003 preview MRW is opened from its embedded JPEG only -Konica Minolta DiMAGE 7Hi MRW 2003 preview MRW is opened from its embedded JPEG only -Konica Minolta DiMAGE A1 MRW 2003 preview MRW is opened from its embedded JPEG only -Konica Minolta DiMAGE A2 MRW 2003 preview MRW is opened from its embedded JPEG only -Konica Minolta DiMAGE A200 MRW 2003 preview MRW is opened from its embedded JPEG only -Konica Minolta Maxxum 5D Dynax 5D MRW 2005 preview MRW is opened from its embedded JPEG only -Konica Minolta Maxxum 7D Dynax 7D MRW 2003 preview MRW is opened from its embedded JPEG only +Konica Minolta Alpha-5 Digital (China) MRW 2005 unverified MRW decoder (12-bit packed, 16-bit words) +Konica Minolta Alpha Sweet Digital (Japan) MRW 2005 unverified MRW decoder (12-bit packed, 16-bit words) +Konica Minolta DiMAGE 5 MRW 2003 unverified MRW decoder (12-bit packed, 16-bit words) +Konica Minolta DiMAGE 7 MRW 2003 verified raw.pixls.us check: CC0 sample decoded and its WB / CFA matched to the embedded preview (vendor/mrw.rs) +Konica Minolta DiMAGE 7i MRW 2003 unverified MRW decoder (12-bit packed, 16-bit words) +Konica Minolta DiMAGE 7Hi MRW 2003 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Konica Minolta DiMAGE A1 MRW 2003 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Konica Minolta DiMAGE A2 MRW 2003 verified raw.pixls.us check: CC0 sample decoded and its WB / CFA matched to the embedded preview (vendor/mrw.rs) +Konica Minolta DiMAGE A200 MRW 2003 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Konica Minolta Maxxum 5D Dynax 5D MRW 2005 unverified MRW decoder (12-bit packed, 16-bit words) +Konica Minolta Maxxum 7D Dynax 7D MRW 2003 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Leaf AFi II 6 MOS 2008 preview MOS is opened from its embedded JPEG only Leaf AFi II 7 MOS 2008 preview MOS is opened from its embedded JPEG only Leaf Aptus 17 MOS 2007 preview MOS is opened from its embedded JPEG only diff --git a/docs/raw/evidence.tsv b/docs/raw/evidence.tsv index 74340fcf7..8272405c9 100644 --- a/docs/raw/evidence.tsv +++ b/docs/raw/evidence.tsv @@ -248,3 +248,9 @@ Sony RX1R II (DSC-RX1RM2) verified issue #535: raw.pixls.us bulk check of all 10 Sony ZV-1 (DCZV1/B) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony ZV-E1 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony ZV-E10 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Konica Minolta DiMAGE 7Hi verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Konica Minolta DiMAGE A1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Konica Minolta DiMAGE A200 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Konica Minolta Maxxum 7D Dynax 7D verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Konica Minolta DiMAGE 7 verified raw.pixls.us check: CC0 sample decoded and its WB / CFA matched to the embedded preview (vendor/mrw.rs) +Konica Minolta DiMAGE A2 verified raw.pixls.us check: CC0 sample decoded and its WB / CFA matched to the embedded preview (vendor/mrw.rs) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c90c1ead1..ecf3f3f93 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: LightCraft vs Adobe Lightroom -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified: RAW decoding 76% → 77%; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Minolta MRW decoded: 6 models verified; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is [parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas @@ -47,7 +47,7 @@ phase #85, duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | Dimension | Breadth | Ready for real work | Remaining | Doc | |---|---:|---:|---:|---| | Features (weighted areas below) | ~77% (checklist 81.1%, measured) | ~61% | 600–1,100 h | [Features](#features) | -| ↳ RAW cameras, colour and lens profiles (part of features) | 81% of Adobe's 1,446 models decode (16% verified), measured | ~56% | 255–480 h | [raw-parity.md](raw-parity.md) | +| ↳ RAW cameras, colour and lens profiles (part of features) | 82% of Adobe's 1,446 models decode (17% verified), measured | ~56% | 255–480 h | [raw-parity.md](raw-parity.md) | | UI/UX fidelity | ~85% | ~70% | 40–70 h | [ui-parity.md](ui-parity.md) | | File formats (non-raw: images, sidecars, presets, catalogs) | ~85% | ~70% | 30–55 h | [file-format-parity.md](file-format-parity.md) | | Hardware | ~50% | ~40% | 40–80 h (+ tethering and printing, counted under Classic modules) | [hardware-parity.md](hardware-parity.md) | @@ -297,6 +297,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Minolta MRW decoded (raw-parity.md): 241 models verified, 222 preview-only; use-weighted shares and the rounded numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified (raw-parity.md): use-weighted share whose camera decodes 84% → 85%, RAW decoding 76% → 77%; full 60.6% → 60.7%, mainstream depth 71.8% → 72.1%, essentials depth 77.4% → 77.5%; the rounded numbers (~61%, ~49%, ~64%) are unchanged | | 2026-10-10 | minor | RAW measured model by model (raw-parity.md): RAW decoding (76%) and colour (35%) are explicit rows of the full sum (59% → 61%); mainstream uses the use-weighted 84% plus a ×0.85 colour discount (51% → 49%); essentials uses 84% × colour 0.75 (63% → 64%); beta ~14 points | | 2026-10-10 | minor | Full number restored to an additive weighted sum over the dimensions (~59%; method aligned with the standard, no new evidence); hours to ~95% for each of the three audiences; beta distance ~16 points | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 08ef435c5..e330f4e18 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -885,6 +885,26 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/getfile.php/4204/nice/Leica%20-%20D-Lux%207%20-%204:3.RWL", "4f6db6492da83f7f2a5012591e370557249d1ed44d4c948784016605b799327c", ), + ( + "mrw-minolta-dimage7hi-words16.mrw", + "https://raw.pixls.us/data/Minolta/DiMAGE%207Hi/RAW_MINOLTA_DIMAGE_7HI.MRW", + "2919a05757197a5424e02970c016893bca5aa29363a67b62b1a4b36326a10f6a", + ), + ( + "mrw-minolta-dimage-a1.mrw", + "https://raw.pixls.us/data/Minolta/DiMAGE%20A1/RAW_MINOLTA_A1.MRW", + "bde1e2cd788e49c9bef20dfdd41be948fb02c2ee198c6b0959a67a3f79fd41c9", + ), + ( + "mrw-minolta-dimage-a200-gbrg.mrw", + "https://raw.pixls.us/data/Minolta/DiMAGE%20A200/RAW_MINOLTA_DIMAGE_A200.MRW", + "2a95f6af2168e536dfb6d02b47e3b6119b35b497eebf179d7c20a6e496c26641", + ), + ( + "mrw-minolta-dynax7d.mrw", + "https://raw.pixls.us/data/Minolta/Dynax%207D/PICT3410.MRW", + "713819e33e5302ec864d576d57223cfc8dcf46e8d45e0ae744d1fec40290a89f", + ), ]; fn cmd_corpus(download: bool) -> Result<(), String> { From 7361cb222cfb373f5254e956fa93a1711c2dfa72 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Mon, 12 Oct 2026 06:15:53 +1100 Subject: [PATCH 02/36] LR-IMP-FORMATS: MRW gets the file-local look fitted to its preview Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/catalog/src/model.rs | 3 ++- crates/engine/src/camera_preview.rs | 2 ++ docs/camera-preview-colour.md | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index abeaadad4..83f5c3262 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -313,7 +313,7 @@ pub struct Analysis { /// file's white balance is edited relative to its as-shot look or in Kelvin depends on the camera: /// [`relative_wb_camera`]. pub fn relative_wb_format(format: &str) -> bool { - ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF"].iter().any(|f| format.eq_ignore_ascii_case(f)) + ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF", "MRW"].iter().any(|f| format.eq_ignore_ascii_case(f)) } /// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed @@ -564,6 +564,7 @@ mod edited_tests { ("a.cr2", "CR2", true), ("a.pef", "pef", true), ("a.orf", "ORF", true), + ("a.mrw", "MRW", true), ("a.dng", "DNG", false), ] { let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00"); diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 858f3b4cf..a6d5312db 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -58,6 +58,7 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool { | RawFormat::Pef | RawFormat::Srw | RawFormat::Orf + | RawFormat::Mrw ) } @@ -1885,6 +1886,7 @@ mod tests { RawFormat::Pef, RawFormat::Srw, RawFormat::Orf, + RawFormat::Mrw, ]; assert!(local.into_iter().all(file_local_look)); // DNG carries its own colour model diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index b24d9bf75..db7637d21 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -1,6 +1,6 @@ -# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2, Pentax PEF, Samsung SRW and Olympus ORF starting look +# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2, Pentax PEF, Samsung SRW, Olympus ORF and Minolta MRW starting look -ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW and ORF decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost. +ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW, ORF and MRW decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost. ## Colour and tone are separate From 71fb91daebbd3e6d5f699f4ff71b10bb084e7554 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Mon, 12 Oct 2026 12:30:55 -0400 Subject: [PATCH 03/36] LR-IMP-FORMATS: RW2 format 8 decodes tiled images (DC-S1RM2 high-resolution mode, tag 0x0043 = 2) (#1276) The high-resolution mode of the DC-S1RM2 (16296 x 10856) tiles the image in two rows of four. Tag 0x0045 holds (x, y) u16 pairs per tile (y was always 0 in single-row files, where the old 32-bit reading gave the same numbers), and tag 0x0043 is the number of tile rows. The decoder now takes each tile's position from the lists, checks that the tiles lie inside the image, do not overlap and cover it exactly, and that 0x0043 equals the number of distinct tile tops. Tiles are decoded like strips. Other tag values are still refused. --- crates/raw/src/vendor/rw2.rs | 145 +++++++++++++++++++++++++++-------- 1 file changed, 112 insertions(+), 33 deletions(-) diff --git a/crates/raw/src/vendor/rw2.rs b/crates/raw/src/vendor/rw2.rs index 0a64c9e44..d7e872253 100644 --- a/crates/raw/src/vendor/rw2.rs +++ b/crates/raw/src/vendor/rw2.rs @@ -48,8 +48,12 @@ //! apart with red and blue between them]; each is //! predicted from the same site of the previous cell, the first cell of a row pair from the first cell of the //! previous pair, 0 at the top [the strips decode to the camera JPEG's image without seams]. The streams end a -//! few padding rows before their stated length. Tags `0x0039`–`0x003f`, `0x0041` and `0x0043` carry the same -//! values in every sample; files with other values are reported as unsupported rather than guessed at. +//! few padding rows before their stated length. Tags `0x0039`–`0x003f` and `0x0041` carry the same +//! values in every sample; files with other values are reported as unsupported rather than guessed at. The high-resolution +//! mode of the DC-S1RM2 (16296 × 10856, `0x0043` = 2) tiles the image in two rows of four: `0x0045` then lists (x, y) +//! `u16` pairs (y is 0 in single-row files, so the 32-bit reading used for them is the same numbers), the tiles are +//! 5632 and 5224 rows high and are decoded exactly like strips [the tiles' areas add up to the sensor, and `0x0043` equals +//! the number of distinct tile tops in every sample]; the decoder takes the geometry from the lists, not from the body. //! //! Levels and geometry: //! @@ -130,7 +134,7 @@ const STRIP_BITS: u16 = 0x0046; const STRIP_WIDTHS: u16 = 0x0047; const STRIP_HEIGHTS: u16 = 0x0048; /// Format-8 tags of unknown meaning and the values every sample carries (as `u16` arrays). -const FORMAT8_FIXED: [(u16, &[u16]); 9] = [ +const FORMAT8_FIXED: [(u16, &[u16]); 8] = [ (0x0039, &[6, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1]), (0x003a, &[6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), (0x003b, &[65535]), @@ -139,8 +143,9 @@ const FORMAT8_FIXED: [(u16, &[u16]); 9] = [ (0x003e, &[0]), (0x003f, &[0]), (0x0041, &[17, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), - (0x0043, &[1]), ]; +/// Format-8 tile rows (ExifTool: no name): 1 for a single row of strips, 2 for the 16296 × 10856 high-resolution mode. +const TILE_ROWS: u16 = 0x0043; /// The camera's lens distortion correction (ExifTool name: DistortionInfo); see the module docs. const DISTORTION_INFO: u16 = 0x0119; /// Fixed-point one of the distortion coefficients and zoom. @@ -149,7 +154,7 @@ const DISTORTION_ONE: f64 = 32768.0; const DISTORTION_SAMPLES: usize = 64; /// Largest error of the fitted inverse (relative to the half diagonal) accepted: real lenses fit within 1.2e-4. const DISTORTION_MAX_FIT_ERROR: f64 = 1e-3; -/// Most strips seen in a file is 3 (the 12008 × 8008 high-resolution mode). +/// Most strips seen in a file is 8 (the 16296 × 10856 high-resolution mode: 4 columns by 2 rows of tiles). const MAX_STRIPS: usize = 64; const CHUNK: usize = 0x4000; @@ -348,6 +353,15 @@ fn u16_array(ifd: &Ifd, tag: u16, order: ByteOrder) -> Option> { } } +/// The format-8 tile origins (tag `0x0045`): a count, then (x, y) pairs of `u16`. +fn origins(ifd: &Ifd, order: ByteOrder) -> Result> { + let a = u16_array(ifd, STRIP_LEFT, order).ok_or_else(|| RawError::Corrupt("RW2 format 8 without tag 0x0045".into()))?; + let n = a.first().map_or(0, |&n| n as usize); + let at = |i: usize| a.get(i).map(|&v| v as usize); + let values: Option> = (0..n).map(|i| Some((at(1 + 2 * i)?, at(2 + 2 * i)?))).collect(); + values.filter(|v| !v.is_empty() && v.len() <= MAX_STRIPS).ok_or_else(|| RawError::Corrupt("RW2 format 8: bad list in tag 0x0045".into())) +} + /// A format-8 `u16` list (count, then the values; `wide`: 32-bit values as low/high pairs). fn list(ifd: &Ifd, tag: u16, order: ByteOrder, wide: bool) -> Result> { let a = u16_array(ifd, tag, order).ok_or_else(|| RawError::Corrupt(format!("RW2 format 8 without tag {tag:#06x}")))?; @@ -357,15 +371,17 @@ fn list(ifd: &Ifd, tag: u16, order: ByteOrder, wide: bool) -> Result> { values.filter(|v| !v.is_empty() && v.len() <= MAX_STRIPS).ok_or_else(|| RawError::Corrupt(format!("RW2 format 8: bad list in tag {tag:#06x}"))) } -/// One format-8 strip: its bit stream and placement. +/// One format-8 strip or tile: its bit stream and placement. struct Strip<'a> { data: &'a [u8], + left: usize, + top: usize, width: usize, height: usize, bits: u64, } -/// The format-8 strips (tiling the full width, top to bottom) and code table. +/// The format-8 strips or tiles (together covering the whole image exactly once) and code table. fn format8<'a>(ifd: &Ifd, order: ByteOrder, bytes: &'a [u8], w: usize, h: usize) -> Result<(Vec>, Huffman)> { for (tag, want) in FORMAT8_FIXED { if let Some(v) = u16_array(ifd, tag, order) @@ -374,26 +390,40 @@ fn format8<'a>(ifd: &Ifd, order: ByteOrder, bytes: &'a [u8], w: usize, h: usize) return Err(RawError::Unsupported(format!("Panasonic raw format 8 with tag {tag:#06x} = {v:?}"))); } } - let (offsets, lefts, bits) = (list(ifd, STRIP_OFFSETS, order, true)?, list(ifd, STRIP_LEFT, order, true)?, list(ifd, STRIP_BITS, order, true)?); + let (offsets, origins, bits) = (list(ifd, STRIP_OFFSETS, order, true)?, origins(ifd, order)?, list(ifd, STRIP_BITS, order, true)?); let (widths, heights) = (list(ifd, STRIP_WIDTHS, order, false)?, list(ifd, STRIP_HEIGHTS, order, false)?); let n = offsets.len(); - if [lefts.len(), bits.len(), widths.len(), heights.len()].iter().any(|&l| l != n) { + if [origins.len(), bits.len(), widths.len(), heights.len()].iter().any(|&l| l != n) { return Err(RawError::Corrupt("RW2 format 8: strip lists of different lengths".into())); } let mut strips = Vec::with_capacity(n); - let mut x = 0usize; + let mut area = 0usize; for i in 0..n { - let (left, width, height) = (lefts[i] as usize, widths[i] as usize, heights[i] as usize); + let ((left, top), width, height) = (origins[i], widths[i] as usize, heights[i] as usize); let len = usize::try_from(bits[i].div_ceil(8)).unwrap_or(usize::MAX); let data = usize::try_from(offsets[i]).ok().and_then(|o| bytes.get(o..o.checked_add(len)?)); - let Some(data) = data.filter(|_| left == x && width > 0 && height == h) else { + let inside = width > 0 && height > 0 && left + width <= w && top + height <= h; + let Some(data) = data.filter(|_| inside) else { return Err(RawError::Corrupt(format!("RW2 format 8: strip {i} outside the image or file"))); }; - x += width; - strips.push(Strip { data, width, height, bits: bits[i] }); + area += width * height; + strips.push(Strip { data, left, top, width, height, bits: bits[i] }); } - if x != w { - return Err(RawError::Corrupt(format!("RW2 format 8: strips cover {x} of {w} columns"))); + // inside the image, no two overlapping and the areas adding up to the image: every sample has exactly one strip + let overlap = strips.iter().enumerate().any(|(i, a)| { + strips[i + 1..].iter().any(|b| a.left < b.left + b.width && b.left < a.left + a.width && a.top < b.top + b.height && b.top < a.top + a.height) + }); + if overlap || area != w * h { + return Err(RawError::Corrupt(format!("RW2 format 8: strips cover {area} of {} samples", w * h))); + } + // tag 0x0043 counts the rows of tiles; a file that says otherwise is not one we understand + let mut tops: Vec = strips.iter().map(|s| s.top).collect(); + tops.sort_unstable(); + tops.dedup(); + if let Some(v) = u16_array(ifd, TILE_ROWS, order) + && v != [tops.len() as u16] + { + return Err(RawError::Unsupported(format!("Panasonic raw format 8 with tag {TILE_ROWS:#06x} = {v:?} for {} tile rows", tops.len()))); } let codes = u16_array(ifd, HUFFMAN_CODES, order).unwrap_or_default(); let pairs: Vec<(u32, u32)> = match (codes.first(), codes.get(1..35)) { @@ -454,12 +484,21 @@ fn decode_strip(s: &Strip, codes: &Huffman, mut rows: Vec<&mut [u16]>) -> Result fn decode_strips(strips: &[Strip], codes: &Huffman, w: usize, out: &mut [u16]) -> Result<()> { let mut parts: Vec> = strips.iter().map(|s| Vec::with_capacity(s.height)).collect(); - for row in out.chunks_mut(w) { - let mut rest = row; - for (s, rows) in strips.iter().zip(parts.iter_mut()) { - let (part, tail) = rest.split_at_mut(s.width.min(rest.len())); - rows.push(part); + for (y, row) in out.chunks_mut(w).enumerate() { + // the strips crossing this row, left to right, each take their columns off its front + let mut here: Vec = (0..strips.len()).filter(|&i| strips.get(i).is_some_and(|s| s.top <= y && y < s.top + s.height)).collect(); + here.sort_by_key(|&i| strips.get(i).map_or(0, |s| s.left)); + let (mut rest, mut x) = (row, 0usize); + for i in here { + let Some(s) = strips.get(i) else { continue }; + let skip = s.left.saturating_sub(x).min(rest.len()); + let (_, tail) = rest.split_at_mut(skip); + let (part, tail) = tail.split_at_mut(s.width.min(tail.len())); + if let Some(rows) = parts.get_mut(i) { + rows.push(part); + } rest = tail; + x = s.left + s.width; } } strips.par_iter().zip(parts).try_for_each(|(s, rows)| decode_strip(s, codes, rows)) @@ -1196,16 +1235,33 @@ mod tests { /// A format-8 file with the given strip widths; `tweak` edits the tags before writing. fn format8_file(w: usize, h: usize, px: &[u16], widths: &[usize], table: &[(u32, u32); 17], tweak: &dyn Fn(&mut Vec<(u16, Value)>)) -> Vec { + format8_tiled_file(w, px, widths, &[h], table, tweak) + } + + /// A format-8 file tiled in rows of the given heights, each cut into the given widths. + fn format8_tiled_file( + w: usize, + px: &[u16], + widths: &[usize], + rows: &[usize], + table: &[(u32, u32); 17], + tweak: &dyn Fn(&mut Vec<(u16, Value)>), + ) -> Vec { + let h: usize = rows.iter().sum(); let mut payload = vec![]; - let mut strips = vec![]; // (offset in payload, bits, left, width) - let mut left = 0; - for &sw in widths { - let sub: Vec = (0..h).flat_map(|y| px[y * w + left..y * w + left + sw].to_vec()).collect(); - let (data, bits) = encode_strip(&sub, sw, h, table, 2); - strips.push((payload.len(), bits, left, sw)); - payload.extend(data); - payload.resize(payload.len().div_ceil(16) * 16, 0); - left += sw; + let mut strips = vec![]; // (offset in payload, bits, left, width, top, height) + let mut top = 0; + for &th in rows { + let mut left = 0; + for &sw in widths { + let sub: Vec = (top..top + th).flat_map(|y| px[y * w + left..y * w + left + sw].to_vec()).collect(); + let (data, bits) = encode_strip(&sub, sw, th, table, 2); + strips.push((payload.len(), bits, left, sw, top, th)); + payload.extend(data); + payload.resize(payload.len().div_ceil(16) * 16, 0); + left += sw; + } + top += th; } let u16s = |v: Vec| undefined(&v); let wide = |v: Vec| u16s(std::iter::once(v.len() as u16).chain(v.iter().flat_map(|&x| [x as u16, (x >> 16) as u16])).collect()); @@ -1216,10 +1272,11 @@ mod tests { for (tag, v) in FORMAT8_FIXED { tags.push(if v.len() == 1 { short(tag, v[0]) } else { (tag, u16s(v.to_vec())) }); } - tags.push((STRIP_LEFT, wide(strips.iter().map(|s| s.2 as u64).collect()))); + tags.push(short(TILE_ROWS, rows.len() as u16)); + tags.push((STRIP_LEFT, u16s(std::iter::once(strips.len() as u16).chain(strips.iter().flat_map(|s| [s.2 as u16, s.4 as u16])).collect()))); tags.push((STRIP_BITS, wide(strips.iter().map(|s| s.1).collect()))); tags.push((STRIP_WIDTHS, narrow(strips.iter().map(|s| s.3 as u64).collect()))); - tags.push((STRIP_HEIGHTS, narrow(vec![h as u64; strips.len()]))); + tags.push((STRIP_HEIGHTS, narrow(strips.iter().map(|s| s.5 as u64).collect()))); tweak(&mut tags); let offsets: Vec = strips.iter().map(|s| s.0).collect(); file(w as u16, h as u16, &tags, &payload, &|off| { @@ -1240,6 +1297,23 @@ mod tests { } } + #[test] + fn format8_tiles_in_two_rows_round_trip() { + // the DC-S1RM2 high-resolution mode: four columns by two rows of tiles, tag 0x0043 = 2 + let (w, h) = (96usize, 40usize); + let px = image(w, h, 520, 16000); + for (widths, rows) in [(vec![24, 24, 24, 24], vec![24, 16]), (vec![40, 56], vec![20, 20]), (vec![96], vec![8, 32])] { + let bytes = format8_tiled_file(w, &px, &widths, &rows, &TABLE14, &|_| {}); + let r = crate::decode(&bytes).unwrap_or_else(|e| panic!("{widths:?} x {rows:?}: {e}")); + assert_eq!(samples(&r), px, "{widths:?} x {rows:?}"); + assert_eq!(crate::probe_info(&bytes).unwrap(), r.info()); + } + // a tile row count that disagrees with the tiles, or tiles that leave a gap, are refused + let case = |tag: u16, v: Value| crate::decode(&format8_tiled_file(w, &px, &[48, 48], &[20, 20], &TABLE14, &|t| replace(t, tag, v.clone()))); + assert!(case(TILE_ROWS, Value::Short(vec![1])).is_err_and(|e| matches!(e, RawError::Unsupported(_)))); + assert!(case(STRIP_HEIGHTS, undefined(&[4, 20, 20, 20, 12])).is_err_and(|e| matches!(e, RawError::Corrupt(_)))); + } + #[test] fn format8_cell_is_coded_column_by_column() { // One 2×2 cell coded by hand (not with `encode_strip`, which shares the decoder's site order): the values @@ -1256,7 +1330,12 @@ mod tests { let mut data: Vec = bits.chunks(8).map(|b| b.iter().enumerate().fold(0u8, |a, (i, &x)| a | (x as u8) << i)).collect(); data.extend([0; 8]); let (mut top, mut bottom) = ([0u16; 2], [0u16; 2]); - decode_strip(&Strip { data: &data, width: 2, height: 2, bits: n }, &code_table(&TABLE14).unwrap(), vec![&mut top, &mut bottom]).unwrap(); + decode_strip( + &Strip { data: &data, left: 0, top: 0, width: 2, height: 2, bits: n }, + &code_table(&TABLE14).unwrap(), + vec![&mut top, &mut bottom], + ) + .unwrap(); assert_eq!((top, bottom), ([1, 3], [2, 4]), "top-left, bottom-left, top-right, bottom-right"); } From 8a8fe7e098c0545d654397636f212fb7cb085a0f Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:42:03 +0300 Subject: [PATCH 04/36] engine: keep the decoder facts a smart preview's offline edits need MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A proxy stored the pixels and the camera tone curve only. An edit that reads the file's own facts — a Kelvin white balance through the camera colour model, Camera local tone mapping's gain table map, an AI mask's semantic matte — rendered differently once the original went offline, because the catalog's header facts cannot carry them. The facts now ride in a tail after the JPEG (a length-prefixed JSON blob, then one byte plane per matte), counted from the file's end so a header rewritten in place keeps pointing at it; is_valid requires the JPEG to end right before the tail. ENCODE_VERSION becomes 3, so proxies an older build wrote are rebuilt when their original is readable (offline ones keep their older look, as before). smart::encode now takes the source's SourceInfo and decode returns the restored one. Tests: - tests_import::smart_previews_keep_the_decoder_facts_offline_edits_need: a proxy's own pixels and facts render bit-identically offline for a Kelvin white balance (6144/6144 pixels differ before), a stored sky matte, and a gain table map - smart::tests::facts_and_mattes_travel_with_the_proxy: the facts and mattes round-trip; a proxy cut inside its tail is invalid; an old proxy, and one whose header lies about the tail, read as no facts --- crates/engine/src/cmd/previews.rs | 5 +- crates/engine/src/smart.rs | 210 ++++++++++++++++++++++++++---- crates/engine/src/tests_import.rs | 123 +++++++++++++++++ crates/pipeline/src/lib.rs | 2 +- crates/pipeline/src/local_tone.rs | 2 +- crates/pipeline/src/masks.rs | 33 +++++ 6 files changed, 347 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index 4fc7ababa..d4969db80 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -268,7 +268,10 @@ fn smart_run( let r = source .ok_or_else(|| "nothing to build from".to_string()) .and_then(|s| s.load_source()) - .and_then(|src| crate::smart::encode(&src.image, src.info_or(Default::default()).camera_tone.as_ref())) + .and_then(|src| { + let info = src.info_or(Default::default()); + crate::smart::encode(&src.image, Some(&info)) + }) .and_then(|b| { let parent = path.parent().ok_or_else(|| format!("{}: no parent folder", path.display()))?; // Only create the leaf shard: do not recreate a missing external drive. diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs index 46b18e0c9..5a610aa52 100644 --- a/crates/engine/src/smart.rs +++ b/crates/engine/src/smart.rs @@ -3,15 +3,19 @@ //! originals are offline (an unplugged drive). The proxy is the scene-linear source scaled into //! 0..1 by a stored factor, sRGB-encoded and saved as a JPEG after a one-line header. The header //! also keeps the decoder's file-local camera tone curve (Sony ARW camera look), which is applied -//! at render time and is not baked into the pixels. +//! at render time and is not baked into the pixels, and the file's other decoder facts (its colour +//! model, gain table map and semantic mattes) ride in a tail after the JPEG, so offline edits +//! interpret the stored pixels the same way the online source did. use std::path::{Path, PathBuf}; use std::sync::Arc; use lightcraft_catalog::Photo; use lightcraft_color::transfer::linear_to_srgb; +use lightcraft_pipeline::SourceInfo; +use lightcraft_pipeline::masks::{MatteKind, Mattes}; use lightcraft_pipeline::tone::CameraTone; -use lightcraft_raster::{Rgb32f, Rgba8}; +use lightcraft_raster::{Image, Rgb32f, Rgba8}; const MAGIC: &[u8] = b"LCSP1\n"; @@ -70,15 +74,73 @@ const JPEG_QUALITY: u8 = 97; /// tracks the camera-look fit that is baked into those pixels. const ENC_FIELD: &str = "enc"; -/// The value of [`ENC_FIELD`] this build writes. Bump it whenever [`encode`]'s mapping or JPEG -/// settings change, so proxies an older build wrote are rebuilt: a missing field reads as 0, which -/// makes every proxy from before this version stale. Version 2 is the first to record the field at -/// all (a version 1 proxy is stored at JPEG quality 92, which this build raises to [`JPEG_QUALITY`]). +/// The value of [`ENC_FIELD`] this build writes. Bump it whenever [`encode`]'s mapping, JPEG +/// settings or stored facts change, so proxies an older build wrote are rebuilt: a missing field +/// reads as 0, which makes every proxy from before this version stale. Version 2 is the first to +/// record the field at all (a version 1 proxy is stored at JPEG quality 92, which this build +/// raises to [`JPEG_QUALITY`]); version 3 is the first to store the decoder facts in the tail. A +/// stale proxy whose original is offline is kept as it is (see [`is_stale`]). /// Crate-visible so the look-marker test pins the marker format to it instead of to a literal. -pub(crate) const ENCODE_VERSION: u64 = 2; +pub(crate) const ENCODE_VERSION: u64 = 3; -/// Encode a source image (and the decoder's camera tone curve, if any) as a smart preview. -pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result, String> { +/// The decoder facts a render interprets its settings against, stored with the proxy's pixels so +/// an offline original edits like it did online (the catalog's header facts cannot carry them). +/// In the proxy's tail, not its header: a real file's gain table map runs far past any header +/// budget (up to 12 336 gains). +#[derive(serde::Serialize, serde::Deserialize)] +struct SmartFacts { + raw: bool, + as_shot_temp: f64, + as_shot_tint: f64, + relative_wb: bool, + legacy_relative_wb: bool, + lens: Option, + camera_color: Option, + local_tone: Option, + /// The semantic mattes whose bytes follow the facts, in this order. + #[serde(default)] + mattes: Vec, +} + +/// One stored matte's shape; its `len` bytes of 0..255 alpha follow the facts. +#[derive(serde::Serialize, serde::Deserialize)] +struct StoredMatte { + kind: u8, + w: usize, + h: usize, + len: usize, +} + +/// The facts and mattes a proxy's tail holds: the length-prefixed JSON, then one byte plane per +/// stored matte. `None` for an old proxy or a malformed tail; like a missing curve, the offline +/// render then keeps its header facts. +fn stored_facts(head: &serde_json::Value, bytes: &[u8]) -> Option<(SmartFacts, Mattes)> { + let tail = usize::try_from(head.get("tail")?.as_u64()?).ok()?; + if tail == 0 || tail > bytes.len() { + return None; + } + let t = bytes.get(bytes.len() - tail..)?; + let n = u32::from_le_bytes(t.get(..4)?.try_into().ok()?) as usize; + let facts: SmartFacts = serde_json::from_slice(t.get(4..4 + n)?).ok()?; + let mut mattes = Mattes::default(); + let mut at = 4 + n; + for m in &facts.mattes { + let kind = MatteKind::from_code(m.kind)?; + if m.w == 0 || m.h == 0 || m.w.checked_mul(m.h)? != m.len { + return None; + } + mattes.push(kind, Image { width: m.w, height: m.h, data: t.get(at..at.checked_add(m.len)?)?.to_vec() }); + at += m.len; + } + if at != t.len() { + return None; + } + Some((facts, mattes)) +} + +/// Encode a source image and its decoder facts (the camera tone curve and the [`SourceInfo`] the +/// render interprets settings against, if given) as a smart preview. +pub fn encode(img: &Rgb32f, info: Option<&SourceInfo>) -> Result, String> { // scale so all but the brightest 0.05 % fit into 0..1 let mut lum: Vec = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect(); let scale = if lum.is_empty() { @@ -113,28 +175,79 @@ pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result, String LOOK_FIELD: crate::camera_preview::LOOK_VERSION, ENC_FIELD: ENCODE_VERSION, }); - if let Some(t) = tone { - head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?; + // The tail: the decoder facts (and the file's semantic mattes) after the JPEG, so the header + // stays small. `tail` records its byte count from the end of the file, so a header rewritten + // in place (an edited look version) keeps pointing at it. + let mut tail: Vec = Vec::new(); + if let Some(i) = info { + if let Some(t) = &i.camera_tone { + head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?; + } + let matte_list: Vec = i + .mattes + .as_deref() + .map(|m| m.iter().map(|(kind, a)| StoredMatte { kind: kind.code(), w: a.width, h: a.height, len: a.data.len() }).collect()) + .unwrap_or_default(); + let facts = SmartFacts { + raw: i.raw, + as_shot_temp: i.as_shot_temp, + as_shot_tint: i.as_shot_tint, + relative_wb: i.relative_wb, + legacy_relative_wb: i.legacy_relative_wb, + lens: i.lens, + camera_color: i.camera_color.as_deref().cloned(), + local_tone: i.local_tone.as_deref().cloned(), + mattes: matte_list, + }; + let facts = serde_json::to_vec(&facts).map_err(|e| e.to_string())?; + let n = u32::try_from(facts.len()).map_err(|_| "smart preview facts too large")?; + tail.extend_from_slice(&n.to_le_bytes()); + tail.extend_from_slice(&facts); + if let Some(m) = i.mattes.as_deref() { + for (_, a) in m.iter() { + tail.extend_from_slice(&a.data); + } + } + head["tail"] = serde_json::json!(tail.len()); } out.extend_from_slice(head.to_string().as_bytes()); out.push(b'\n'); out.extend_from_slice(&jpg); + out.extend_from_slice(&tail); Ok(out) } -/// Decode a smart preview back into a source image and its stored camera tone curve (an invalid -/// curve is ignored, like a missing one). -pub fn decode(bytes: &[u8]) -> Result<(Rgb32f, Option), String> { +/// Decode a smart preview back into a source image, its stored camera tone curve and the stored +/// decoder facts (an invalid curve or tail is ignored, like a missing one). +pub fn decode(bytes: &[u8]) -> Result<(Rgb32f, Option, Option), String> { let rest = bytes.strip_prefix(MAGIC).ok_or("not a smart preview")?; let nl = rest.iter().position(|b| *b == b'\n').ok_or("bad smart preview")?; let head: serde_json::Value = serde_json::from_slice(&rest[..nl]).map_err(|e| e.to_string())?; let scale = head["scale"].as_f64().unwrap_or(1.0) as f32; let tone = head.get("tone").and_then(|t| serde_json::from_value::(t.clone()).ok()); - let d = lightcraft_codecs::decode(&rest[nl + 1..], Default::default()).map_err(|e| e.to_string())?; + let tail = head.get("tail").and_then(serde_json::Value::as_u64).and_then(|t| usize::try_from(t).ok()).unwrap_or(0); + let start = MAGIC.len() + nl + 1; + let end = bytes.len().saturating_sub(tail); + if end <= start { + return Err("bad smart preview".into()); + } + let d = lightcraft_codecs::decode(bytes.get(start..end).ok_or("bad smart preview")?, Default::default()).map_err(|e| e.to_string())?; // the decoder undoes the sRGB encoding; the values are the source's own primaries let mut img = d.image; img.data.iter_mut().for_each(|c| *c = c.map(|v| v * scale)); - Ok((img, tone)) + let info = stored_facts(&head, bytes).map(|(f, mattes)| SourceInfo { + raw: f.raw, + as_shot_temp: f.as_shot_temp, + as_shot_tint: f.as_shot_tint, + relative_wb: f.relative_wb, + legacy_relative_wb: f.legacy_relative_wb, + lens: f.lens, + camera_color: f.camera_color.map(Arc::new), + camera_tone: tone, + mattes: (!mattes.is_empty()).then(|| Arc::new(mattes)), + local_tone: f.local_tone.map(Arc::new), + }); + Ok((img, tone, info)) } /// Where a library keeps its smart previews. @@ -276,15 +389,26 @@ pub fn is_valid(path: &Path) -> bool { } let Some(rest) = head.strip_prefix(MAGIC) else { return false }; let Some(nl) = rest.iter().position(|b| *b == b'\n') else { return false }; - let size_ok = rest - .get(..nl) - .and_then(|h| serde_json::from_slice::(h).ok()) - .is_some_and(|h| h["w"].as_u64().is_some_and(|w| w > 0) && h["h"].as_u64().is_some_and(|h| h > 0)); + let json = rest.get(..nl).and_then(|h| serde_json::from_slice::(h).ok()); + let size_ok = json.as_ref().is_some_and(|h| h["w"].as_u64().is_some_and(|w| w > 0) && h["h"].as_u64().is_some_and(|h| h > 0)); if !size_ok || rest.get(nl + 1..nl + 3) != Some(&[0xFF, 0xD8][..]) { return false; } + let tail = json.and_then(|h| h.get("tail").and_then(serde_json::Value::as_u64)).and_then(|t| usize::try_from(t).ok()).unwrap_or(0); + if tail == 0 { + // no tail: the file ends at the JPEG's end marker + let mut end = [0u8; 2]; + return f.seek(SeekFrom::End(-2)).is_ok() && f.read_exact(&mut end).is_ok() && end == [0xFF, 0xD9]; + } + // with a tail, the JPEG must end right before it and the tail must be there in full + let Ok(meta) = f.metadata() else { return false }; + let Ok(file_len) = usize::try_from(meta.len()) else { return false }; + let Some(jpeg_end) = file_len.checked_sub(tail) else { return false }; + if jpeg_end <= MAGIC.len() + nl + 3 { + return false; + } let mut end = [0u8; 2]; - f.seek(SeekFrom::End(-2)).is_ok() && f.read_exact(&mut end).is_ok() && end == [0xFF, 0xD9] + f.seek(SeekFrom::Start((jpeg_end - 2) as u64)).is_ok() && f.read_exact(&mut end).is_ok() && end == [0xFF, 0xD9] } /// The fit version a header records: a missing, negative, fractional or otherwise malformed value @@ -356,7 +480,7 @@ pub fn mark_look_checked(dir: &Path) -> Result<(), String> { /// Load the proxy at `path`. pub fn load(path: &Path) -> Result { let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; - decode(&b).map(|(image, camera_tone)| crate::media::DecodedSource { image: Arc::new(image), info: None, camera_tone, denoised: None }) + decode(&b).map(|(image, camera_tone, info)| crate::media::DecodedSource { image: Arc::new(image), info, camera_tone, denoised: None }) } #[cfg(test)] @@ -453,7 +577,8 @@ mod tests { })) .unwrap(); let img = lightcraft_scenes::demo_library()[0].render(64, 40); - let bytes = encode(&img, Some(&tone)).unwrap(); + let info = lightcraft_pipeline::SourceInfo { raw: true, camera_tone: Some(tone), ..Default::default() }; + let bytes = encode(&img, Some(&info)).unwrap(); assert_eq!(decode(&bytes).unwrap().1, Some(tone)); let dir = temp("tone"); let path = dir.join("t.lcsp"); @@ -473,15 +598,50 @@ mod tests { let mut hostile = MAGIC.to_vec(); hostile.extend_from_slice(head.to_string().as_bytes()); hostile.extend_from_slice(&bytes[nl..]); - let (pixels, tone) = decode(&hostile).unwrap(); + let (pixels, tone, _) = decode(&hostile).unwrap(); assert_eq!((pixels.width, tone), (img.width, None)); let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn facts_and_mattes_travel_with_the_proxy() { + let img = lightcraft_scenes::demo_library()[0].render(32, 20); + let mut mattes = Mattes::default(); + mattes.push(MatteKind::Sky, Image { width: 16, height: 10, data: vec![255; 160] }); + let info = SourceInfo { + raw: true, + as_shot_temp: 4200.0, + as_shot_tint: -5.0, + legacy_relative_wb: true, + mattes: Some(std::sync::Arc::new(mattes)), + ..Default::default() + }; + let bytes = encode(&img, Some(&info)).unwrap(); + let (_, _, back) = decode(&bytes).unwrap(); + let back = back.expect("the facts are read back"); + assert_eq!((back.as_shot_temp, back.as_shot_tint, back.legacy_relative_wb), (4200.0, -5.0, true)); + assert_eq!(back.mattes.expect("the mattes are read back").bytes(), 160); + // a proxy cut inside its tail is incomplete (rebuilt, not taken for a source of facts) + let dir = temp("facts"); + let path = dir.join("f.lcsp"); + std::fs::write(&path, &bytes[..bytes.len() - 8]).unwrap(); + assert!(!is_valid(&path)); + let _ = std::fs::remove_dir_all(&dir); + // an old proxy (no tail) has no facts, and neither has one with a malformed tail + assert!(decode(&encode(&img, None).unwrap()).unwrap().2.is_none()); + let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap(); + let mut head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap(); + head["tail"] = serde_json::json!(bytes.len()); + let mut lying = MAGIC.to_vec(); + lying.extend_from_slice(head.to_string().as_bytes()); + lying.extend_from_slice(&bytes[nl..]); + assert!(decode(&lying).map(|(_, _, i)| i.is_none()).unwrap_or(true), "an over-large tail is not trusted"); + } + #[test] fn roundtrip_keeps_the_picture() { let img = lightcraft_scenes::demo_library()[0].render(160, 100); - let (back, tone) = decode(&encode(&img, None).unwrap()).unwrap(); + let (back, tone, _) = decode(&encode(&img, None).unwrap()).unwrap(); assert_eq!((back.width, back.height), (img.width, img.height)); assert!(tone.is_none()); let mut err = 0.0f64; diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index ed4cc9162..f0b50db37 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -479,6 +479,129 @@ fn smart_previews_stand_in_for_offline_originals() { let _ = std::fs::remove_dir_all(&lib); } +/// Issue #1534: the decoder facts a render interprets its settings against (a camera colour model, +/// the file's local tone map, its semantic mattes) must be stored with the smart preview, so an +/// edit made while the original is offline renders the same picture as the online source instead +/// of the header-facts fallback. +#[test] +fn smart_previews_keep_the_decoder_facts_offline_edits_need() { + use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape, WbMode}; + use lightcraft_pipeline::SourceInfo; + use lightcraft_pipeline::masks::{MatteKind, Mattes}; + use lightcraft_raster::Rgb32f; + + // A smooth scene: the proxy's 8-bit sRGB round trip stays within a code step, so a mismatch + // left between the offline and the online render is metadata the proxy failed to keep. + let pixels = Rgb32f::from_fn(96, 64, |x, y| { + let t = ((x * 7 + y * 3) % 96) as f32 / 96.0; + [0.02 + t * 1.2, 0.02 + t * 0.6, 0.02 + t * 0.25] + }); + let camera_color = lightcraft_pipeline::CameraColor { + tags: lightcraft_raw::ColorData { + illuminant: [21, 17], + color_matrix: [Some(lightcraft_color::Mat3([[0.9, 0.2, -0.15], [-0.3, 1.25, 0.08], [0.02, -0.12, 0.85]])), None], + as_shot_neutral: Some([0.5, 1.0, 0.7]), + baseline_exposure: 0.3, + ..Default::default() + }, + developed_for: lightcraft_color::Xy { x: 0.32, y: 0.35 }, + }; + let mut mattes = Mattes::default(); + mattes.push( + MatteKind::Sky, + lightcraft_raster::Image { width: 96, height: 64, data: (0..96 * 64usize).map(|i| if (i % 96) / 24 == 1 { 255 } else { 0 }).collect() }, + ); + let local_tone = lightcraft_pipeline::local_tone::LocalTone { + map: lightcraft_raw::gaintable::GainTableMap { + points_v: 1, + points_h: 2, + points_n: 1, + spacing_v: 1.0, + spacing_h: 1.0, + origin_v: 0.0, + origin_h: 0.0, + weights: [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0, 0.0, 0.0], + gamma: 1.0, + gains: vec![1.0, 4.0], + }, + placement: lightcraft_raw::gaintable::SourcePlacement { rect: [0.0, 0.0, 1.0, 1.0], orientation: lightcraft_geom::Orientation::Normal }, + }; + // the facts differ per case; the rest is the raw source's usual shape + let info = |camera_color: Option, + local_tone: Option, + mattes: Option| SourceInfo { + raw: true, + as_shot_temp: 5200.0, + as_shot_tint: 8.0, + camera_color: camera_color.map(std::sync::Arc::new), + local_tone: local_tone.map(std::sync::Arc::new), + mattes: mattes.map(std::sync::Arc::new), + ..Default::default() + }; + + /// Build the proxy of `pixels`+`info`, apply `edit`, then render the proxy's own pixels and + /// stored facts online, and the same proxy offline (the original renamed away): how many + /// pixels differ. Identical stored pixels and identical edits must render identically; what + /// the proxy failed to keep shows up as a difference. + fn offline_diff(tag: &str, pixels: &Rgb32f, info: &SourceInfo, edit: DevelopSettings) -> usize { + let src = temp_dir(&format!("smartmeta-src-{tag}")); + let lib = temp_dir(&format!("smartmeta-lib-{tag}")); + write_png(&src.join("a.png"), 3); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let (p, i) = (pixels.clone(), info.clone()); + s.media.file_loader = Some(std::sync::Arc::new(move |_path, _max_edge| Ok((p.clone(), i.clone())))); + s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!(r["built"], 1, "{r}"); + s.set_develop(id, edit, "reproduction").unwrap(); + let dir = s.media.smart_dir.clone().unwrap(); + let stored = crate::smart::load(&crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap())).unwrap(); + let mut online = s.render_job(id, 96, 96, false, true).unwrap(); + // the proxy's own (post-JPEG) pixels, with the facts the original source had: the offline + // render must reach the same picture through the facts the proxy stored + online.source = crate::media::SourceRef::Loaded(Box::new(crate::media::DecodedSource::new(stored.image, Some(info.clone())))); + let online = online.run().rendered.unwrap().image; + // the drive goes away + std::fs::rename(&src, src.with_extension("offline")).unwrap(); + s.media.rendered.clear(); + s.media.clear_sources(); + s.media.forget(id); + let offline = s.render_now(id, 96, 96).expect("renders from the smart preview").image; + assert_eq!(online.data.len(), offline.data.len(), "{tag}: same grid"); + let differing = online.data.iter().zip(&offline.data).filter(|(a, b)| a != b).count(); + let _ = std::fs::remove_dir_all(src.with_extension("offline")); + let _ = std::fs::remove_dir_all(&lib); + differing + } + + // a Kelvin white balance is evaluated through the camera's colour model + let mut wb = DevelopSettings::default(); + wb.wb.mode = WbMode::Custom; + wb.wb.temp = 3000.0; + wb.wb.tint = 20.0; + let differing = offline_diff("wb", &pixels, &info(Some(camera_color), None, None), wb); + assert_eq!(differing, 0, "the camera colour model is kept offline"); + + // an AI mask uses the mattes that came with the file + let mut sky = DevelopSettings::default(); + sky.masks.push(Mask { + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Sky }], + adjust: LocalAdjustments { exposure: 2.0, ..Default::default() }, + ..Default::default() + }); + let differing = offline_diff("sky", &pixels, &info(None, None, Some(mattes)), sky); + assert_eq!(differing, 0, "the file's mattes are kept offline"); + + // Camera local tone mapping reads the file's gain table map + let mut tone = DevelopSettings::default(); + tone.profile.camera_local_tone = true; + tone.light.exposure = -3.0; + let differing = offline_diff("tone", &pixels, &info(None, Some(local_tone), None), tone); + assert_eq!(differing, 0, "the local tone map is kept offline"); +} + /// Issue #106: a smart preview cut short (crash, full drive) counted as built forever and failed /// exactly when the original went offline. Writes are atomic now, and a damaged proxy is not /// reported as present and is rebuilt. diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index d94d3a2fc..70d4dd05a 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -59,7 +59,7 @@ pub use tone::ToneMap; /// A raw source's own colour model, for white balance: like Lightroom, a white balance /// re-evaluates the camera's colour matrices at the chosen white (camera-space white balance, DNG /// spec ch. 6) instead of adapting the as-shot rendering ([`local::wb_matrix_for`]). -#[derive(Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct CameraColor { /// The file's colour tags (its profile look left out: it is applied at load). pub tags: lightcraft_raw::ColorData, diff --git a/crates/pipeline/src/local_tone.rs b/crates/pipeline/src/local_tone.rs index c38a25c9c..bb7adfe51 100644 --- a/crates/pipeline/src/local_tone.rs +++ b/crates/pipeline/src/local_tone.rs @@ -19,7 +19,7 @@ use crate::{Plan, SourceInfo}; use lightcraft_develop::DevelopSettings; /// A raw's gain table map and where its developed source sits in the active area. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct LocalTone { pub map: GainTableMap, pub placement: SourcePlacement, diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 14fa558f8..fc7ac1772 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -25,6 +25,33 @@ pub enum MatteKind { Glasses, } +impl MatteKind { + /// A stable code for serialization (e.g. a smart preview's stored mattes); never renumber. + pub fn code(self) -> u8 { + match self { + MatteKind::Sky => 0, + MatteKind::Person => 1, + MatteKind::Skin => 2, + MatteKind::Hair => 3, + MatteKind::Teeth => 4, + MatteKind::Glasses => 5, + } + } + + /// [`MatteKind::code`]'s inverse (`None` for a code this build doesn't know). + pub fn from_code(code: u8) -> Option { + match code { + 0 => Some(MatteKind::Sky), + 1 => Some(MatteKind::Person), + 2 => Some(MatteKind::Skin), + 3 => Some(MatteKind::Hair), + 4 => Some(MatteKind::Teeth), + 5 => Some(MatteKind::Glasses), + _ => None, + } + } +} + /// Segmentation mattes that came with the source: 0..255 alpha planes, each covering the whole /// (EXIF-oriented) source image at its own resolution. Several mattes of one kind (e.g. one per /// person) are combined with max. @@ -56,6 +83,12 @@ impl Mattes { self.mattes.iter().map(|(_, m)| m.data.len()).sum() } + /// The stored mattes, in insertion order (kind and plane), e.g. for storing them with a + /// photo's proxy source. + pub fn iter(&self) -> impl Iterator)> { + self.mattes.iter().map(|(k, m)| (*k, m)) + } + fn of(&self, kinds: &[MatteKind]) -> Vec<&Image> { self.mattes.iter().filter(|(k, _)| kinds.contains(k)).map(|(_, m)| m).collect() } From d5a6d6c509df1d9c4d6ce1ce936e95554c08306d Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Sat, 10 Oct 2026 10:58:38 +0200 Subject: [PATCH 05/36] LR-PROF-CAMERACOLOR: fit the tone curve of low-contrast scenes (range limit 1.5 to 1.2) Both tone fits refused a curve when the brightest 1/32 of the training pairs was less than 1.5x the darkest 1/32, a hard cliff before any acceptance gate: a hazy ILCE-7RM4 burst (issue #631) opened with the neutral fallback on frames just under it (DSC01630: range 1.489, dE76 vs the camera JPEG 23.8) and with a camera look on the frame just over it. MIN_TONE_RANGE = 1.2 still refuses nearly uniform pairs. A curve under the old 1.5 must also keep the bright areas (camera JPEG luminance 0.85 and up), which the fit and the held-out gates leave out, no further from the camera JPEG than the neutral fallback (highlights_hold, full and partial looks): fitted to a narrow range it extrapolates them, and reviews found synthetic frames whose accepted curve rendered a 0.93 sky at 0.28 to 0.61. The bright pixels come from the proxies before any training filter, and for DRO photos the curve is checked lowered as it will be used, inside the search. The tone curve of a black-and-white camera JPEG (#577) comes from the same fit, so it gets the same check, by luminance as its own gates compare: a synthetic frame's 0.93 sky rendered at 0.28 without it. DSC01630 (DRO Auto): look accepted, dE76 1.94. Every other file of a 309-file sweep (135 public Sony) renders pixel-identical, also with the camera JPEGs made grey. RENDER_CACHE_VERSION 63, LOOK_VERSION 6 (smart previews refit). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/camera_preview.rs | 408 +++++++++++++++++++++++++--- crates/engine/src/media.rs | 2 +- docs/camera-preview-colour.md | 3 +- 3 files changed, 368 insertions(+), 45 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 614c58c60..3eeba8c8e 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -34,7 +34,8 @@ use lightcraft_raw::{ /// 7: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected. /// 8: the bundled NIKON Z 6_2 profile. /// 9: the bundled NIKON Z 8 and NIKON D5300 profiles. -pub const LOOK_VERSION: u32 = 9; +/// 12: tone curves of low-contrast scenes (range limit 1.2, with the bright-area check for curves under 1.5, #699). +pub const LOOK_VERSION: u32 = 12; #[derive(Clone, Debug)] pub(crate) struct CameraLook { @@ -270,20 +271,21 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf // try its colour first and fit tone/chroma per photo (picture styles vary). 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 look = match fit_look(&sensor, &reference, &clipped, colour.clone()) { + // Sony's Dynamic Range Optimizer (on by default) brightens the camera JPEG's darker regions, + // not the raw: keep the colour fitted to the JPEG, take the tone curve without DRO. + let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true); + let look = match fit_look_with(&sensor, &reference, &clipped, colour.clone(), dro) { Some(look) => look, // A black-and-white JPEG has no colour to fit: the profile's colour and the JPEG's tone curve // (`monochrome_look` does it without a profile). None if colour.is_some() && black_and_white => { let unclipped = without_clipped(&sensor, &clipped); - let Some(look) = fit_tone_only(unclipped.as_ref().unwrap_or(&sensor), &reference, colour) else { return (None, true) }; + let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro, grey: true }; + let Some(look) = fit_tone_only_with(unclipped.as_ref().unwrap_or(&sensor), &reference, colour, &bright) else { return (None, true) }; look } None => return (None, black_and_white), }; - // Sony's Dynamic Range Optimizer (on by default) brightens the camera JPEG's darker regions, - // not the raw: keep the colour fitted to the JPEG, take the tone curve without DRO. - let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true); let look = if dro { with_dro_off_tone(look) } else { look }; if lightcraft_pipeline::profiling() { eprintln!( @@ -302,6 +304,12 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf /// the render's highlight reconstruction. const SENSOR_CLIP: f32 = 0.99; +/// [`fit_look_with`] for a photo without Sony's DRO. +#[cfg(test)] +fn fit_look(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option)>) -> Option { + fit_look_with(sensor, reference, clipped, colour, false) +} + /// The photo's look, refitted without the proxy pixels whose sensor values are clipped. /// /// A clipped raw pixel no longer records the scene's colour: with one channel held at the clip @@ -320,17 +328,19 @@ const SENSOR_CLIP: f32 = 0.99; /// replace a look fitted away from edges). A photo that had no look gets one when the search /// without the clipped pixels finds it, and otherwise the [`fit_partial`] look when that is clearly /// closer to the camera JPEG than the neutral fallback. -fn fit_look(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option)>) -> Option { +fn fit_look_with(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option)>, dro: bool) -> Option { let unclipped = without_clipped(sensor, clipped); - let Some((look, attempt)) = search_ordered(sensor, reference, colour.clone(), TONE_FITS) else { + // from the proxies as they are: the retries' filters (clipped pixels, edges) don't remove them + let bright = HighlightCheck { pairs: highlights(sensor, reference), dro, grey: false }; + let Some((look, attempt)) = search_ordered(sensor, reference, colour.clone(), TONE_FITS, &bright) else { return unclipped .as_ref() - .and_then(|u| search_ordered(u, reference, colour, TONE_FITS)) + .and_then(|u| search_ordered(u, reference, colour, TONE_FITS, &bright)) .map(|(look, _)| look) - .or_else(|| fit_partial(unclipped.as_ref().unwrap_or(sensor), reference)); + .or_else(|| fit_partial(unclipped.as_ref().unwrap_or(sensor), reference, &bright)); }; let Some(unclipped) = unclipped else { return Some(look) }; - let refit = fit_attempt(&unclipped, reference, &colour, attempt); + let refit = fit_attempt(&unclipped, reference, &colour, attempt, &bright); if refit.is_none() && lightcraft_pipeline::profiling() { eprintln!("[profile] camera look: no fit without the clipped pixels on the accepted attempt, keeping the fit on all pixels"); } @@ -389,8 +399,14 @@ fn sony_dro_off_tone() -> Option { /// curve is brighter than the photo's own (the 1″ compacts map raw values darker), the photo keeps /// its own. Its colour (matrix, hue/saturation table and chroma curve) stays as fitted. fn with_dro_off_tone(look: CameraLook) -> CameraLook { - let Some(dro_off) = sony_dro_off_tone() else { return look }; - let own = look.tone.knots(); + CameraLook { tone: dro_off(&look.tone), ..look } +} + +/// The curve of [`with_dro_off_tone`]: `own` lowered to Sony's without DRO wherever that is darker, +/// or `own` unchanged when it can't be. +fn dro_off(own_tone: &CameraTone) -> CameraTone { + let Some(dro_off) = sony_dro_off_tone() else { return *own_tone }; + let own = own_tone.knots(); let mut knots = own.map(|[x, y]| [x, y.min(dro_off.apply(x))]); // Past its last knot a curve goes on as a shoulder whose rate comes from its last two knots // (`CameraTone::apply`): lowering the second-last knot more than the last steepens it, and the @@ -400,11 +416,11 @@ fn with_dro_off_tone(look: CameraLook) -> CameraLook { let rate = ((b[1] - a[1]) / (b[0] - a[0])).clamp(0.1, 16.0) / (1.0 - b[1]).max(0.01); let floor = last[1] - rate * (1.0 - last[1]).max(0.01) * (b[0] - a[0]); knots[30][1] = knots[30][1].max(floor).min(last[1]); - let Some(tone) = CameraTone::new(knots).and_then(|tone| tone.with_chroma(*look.tone.chroma())) else { return look }; - if !at_most(&tone, &look.tone) { - return look; + let Some(tone) = CameraTone::new(knots).and_then(|tone| tone.with_chroma(*own_tone.chroma())) else { return *own_tone }; + if !at_most(&tone, own_tone) { + return *own_tone; } - CameraLook { tone, ..look } + tone } /// Whether curve `a` is nowhere brighter than `b` over the scene luminances the finish stage's tone @@ -452,10 +468,11 @@ fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: let unclipped = without_clipped(&sensor, &clipped); let fit_on = unclipped.as_ref().unwrap_or(&sensor); let colour = Some((Mat3::IDENTITY, None)); + let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; let look = TONE_FITS.iter().find_map(|&tone| { - [false, true] - .into_iter() - .find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true })) + [false, true].into_iter().find_map(|away_from_edges| { + fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true }, &bright) + }) }); let Some(look) = look else { return refuse("fit") }; let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) { @@ -984,7 +1001,8 @@ fn local_contrast(image: &Rgb32f, i: usize) -> f32 { /// `edge_limit`, pixels whose 3×3 neighbourhood in either proxy spans a larger luminance ratio /// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated /// colours (see [`EDGE_CONTRAST`]). -type Pairs = Vec<([f64; 3], [f64; 3])>; +type Pair = ([f64; 3], [f64; 3]); +type Pairs = Vec; fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option, dark: bool) -> Option<(Pairs, Pairs)> { if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() { return None; @@ -1146,7 +1164,8 @@ fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt /// The look from [`search_ordered`] without its attempt. #[cfg(test)] fn fit_pairs_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>, order: &[ToneFit]) -> Option { - search_ordered(sensor, reference, colour, order).map(|(look, _)| look) + search_ordered(sensor, reference, colour, order, &HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: false }) + .map(|(look, _)| look) } /// The tone fits the search tries, in order: the first whose look passes the acceptance gates is @@ -1176,21 +1195,33 @@ struct Attempt { /// valid when the camera JPEG's geometry differs slightly from the raw's (issue #232). The whole /// search runs with one tone fit before the next is tried, so the conditional-median fallback /// accepts exactly what the previous releases accepted. -fn search_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>, order: &[ToneFit]) -> Option<(CameraLook, Attempt)> { +fn search_ordered( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: Option<(Mat3, Option)>, + order: &[ToneFit], + bright: &HighlightCheck, +) -> Option<(CameraLook, Attempt)> { let profiles: &[bool] = if colour.is_some() { &[true, false] } else { &[false] }; order.iter().find_map(|&tone| { profiles.iter().find_map(|&profile| { [false, true].into_iter().find_map(|away_from_edges| { let attempt = Attempt { tone, profile, away_from_edges, dark: false }; - fit_attempt(sensor, reference, &colour, attempt).map(|look| (look, attempt)) + fit_attempt(sensor, reference, &colour, attempt, bright).map(|look| (look, attempt)) }) }) }) } -fn fit_attempt(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option)>, attempt: Attempt) -> Option { +fn fit_attempt( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: &Option<(Mat3, Option)>, + attempt: Attempt, + bright: &HighlightCheck, +) -> Option { let colour = if attempt.profile { colour.clone() } else { None }; - fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone, attempt.dark) + fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone, attempt.dark, bright) } fn fit_pairs_on( @@ -1200,6 +1231,7 @@ fn fit_pairs_on( edge_limit: Option, tone_fit: ToneFit, dark: bool, + highlights: &HighlightCheck, ) -> Option { // A known colour model needs enough signal for tone fitting, not a scene rich enough to // learn a new colour matrix. Still reject monochrome references. @@ -1227,6 +1259,9 @@ fn fit_pairs_on( look.tone = tone; } let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + if !highlights_hold(&look.tone, highlights, colour) { + continue; + } let (mut linear, mut perceptual, mut samples) = (0.0, 0.0, 0); for (x, target) in pairs.iter().step_by(3) { let corrected = displayed(colour(*x), &tone); @@ -1341,11 +1376,11 @@ fn rank_correlation_ties(pairs: &[([f64; 3], [f64; 3])]) -> f64 { /// and in gamma-encoded display values, and when the JPEG shows the same picture /// ([`PARTIAL_MIN_RANK_CORRELATION`]). There is no limit on its own error: it only has to be clearly /// closer to the camera JPEG than what the photo would get otherwise. -fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f) -> Option { - [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_partial_on(sensor, reference, edge_limit)) +fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f, highlights: &HighlightCheck) -> Option { + [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_partial_on(sensor, reference, edge_limit, highlights)) } -fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option) -> Option { +fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option, highlights: &HighlightCheck) -> Option { // as for a known colour model: enough signal for the tone, but no monochrome reference let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit, false)?; let related = rank_correlation(&pairs); @@ -1369,6 +1404,9 @@ fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option) look.tone = tone; } let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + if !highlights_hold(&look.tone, highlights, |x| matrix.apply(x)) { + return None; + } let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2); let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0); for (x, target) in pairs.iter().step_by(3) { @@ -1419,7 +1457,7 @@ fn monochrome(reference: &Rgb32f) -> bool { /// would start from the spectral matrices or the neutral fallback with the default tone curve, about 1.3 EV darker /// 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`]), as [`fit_look`] tries the photo's own colour after a profile's. The +/// 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. fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> Option { if !file_local_look(raw.format) { @@ -1429,10 +1467,11 @@ fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, le if !monochrome(&reference) { return None; } - let unclipped = without_clipped(&sensor, &clipped); - let look = fit_tone_only(unclipped.as_ref().unwrap_or(&sensor), &reference, None)?; // Sony's Dynamic Range Optimizer brightens a B/W JPEG as much as a colour one let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true); + let unclipped = without_clipped(&sensor, &clipped); + let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro, grey: true }; + let look = fit_tone_only_with(unclipped.as_ref().unwrap_or(&sensor), &reference, None, &bright)?; let look = if dro { with_dro_off_tone(look) } else { look }; if lightcraft_pipeline::profiling() { eprintln!( @@ -1449,12 +1488,30 @@ fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, le /// space) or, without one, of the sensor proxy's own colour. Fitted like [`fit_partial`]'s, by quantile matching of /// the luminances, on all pixels and then away from edges, and used only when the JPEG shows the same picture /// ([`PARTIAL_MIN_RANK_CORRELATION`]) and the look cuts the held-out squared luminance error to below -/// [`MIN_IMPROVEMENT`] of the default curve's, in linear and in gamma-encoded display values. -fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option { - [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_tone_only_on(sensor, reference, &colour, edge_limit)) +/// [`MIN_IMPROVEMENT`] of the default curve's, in linear and in gamma-encoded display values. A curve narrower than +/// [`NARROW_TONE_RANGE`] must also keep the JPEG's bright areas ([`highlights_hold`], by luminance). +fn fit_tone_only_with( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: Option<(Mat3, Option)>, + highlights: &HighlightCheck, +) -> Option { + [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_tone_only_on(sensor, reference, &colour, edge_limit, highlights)) } -fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option)>, edge_limit: Option) -> Option { +/// [`fit_tone_only_with`] for a photo without Sony's DRO, checked against its own bright areas. +#[cfg(test)] +fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option { + fit_tone_only_with(sensor, reference, colour, &HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: true }) +} + +fn fit_tone_only_on( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: &Option<(Mat3, Option)>, + edge_limit: Option, + highlights: &HighlightCheck, +) -> Option { // every pair counts: the JPEG has no colour to require let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit, false)?; let related = rank_correlation_ties(&pairs); @@ -1472,6 +1529,9 @@ fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, }; let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, coloured)?; let look = CameraLook { matrix, tone, hue_sat: hue_sat.clone() }; + if !highlights_hold(&look.tone, highlights, coloured) { + return None; + } let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2); let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0); @@ -1779,6 +1839,89 @@ impl ToneFit { } } +/// The bright areas a narrow tone curve is checked against ([`highlights_hold`]): every proxy pixel +/// whose camera JPEG luminance is 0.85 or more, with its sensor value as developed (for a clipped +/// pixel a lower bound of the scene). Taken from the proxies before any training filter (clipped +/// pixels, edges, the sensor value range of [`collect_pairs`]), so no retry of the search can +/// leave them out. +fn highlights(sensor: &Rgb32f, reference: &Rgb32f) -> Pairs { + if sensor.data.len() != reference.data.len() { + return Vec::new(); + } + sensor + .data + .iter() + .zip(&reference.data) + .filter(|(x, y)| x.iter().chain(y.iter()).all(|v| v.is_finite() && *v >= 0.0) && luminance_2020(**y) >= 0.85) + .map(|(x, y)| (x.map(f64::from), y.map(f64::from))) + .collect() +} + +/// Smallest ratio between the scene luminance of the brightest and of the darkest 1/32 of the +/// pairs a tone curve is fitted to (its last and first knot). Below it the training pairs are +/// nearly uniform (under 0.26 EV from their darker to their brighter parts): the curve would be +/// fitted to a sliver of the tone range and the rest of it extrapolated. Above it the acceptance +/// gates judge the curve. This was 1.5, which rejected low-contrast scenes the curve fits very +/// well: in a hazy ILCE-7RM4 burst the frames at 1.46–1.49 opened with the neutral fallback (no full or partial +/// look, 18 L* darker than the camera JPEG) while the frame at 1.51 fitted at a held-out RMS of +/// 0.013, as the others do once allowed (issue #631). Across 135 public Sony files the lowest is 4.0. +/// A curve under [`NARROW_TONE_RANGE`] must also pass [`highlights_hold`]. +const MIN_TONE_RANGE: f32 = 1.2; + +/// [`MIN_TONE_RANGE`] before issue #631. A curve fitted to a narrower range than this must also +/// render the photo's bright areas no further from the camera JPEG than the neutral fallback does +/// ([`highlights_hold`]). Neither the fit nor the held-out gates see those areas (camera JPEG +/// luminance 0.85 and up, left out of the training pairs by [`collect_pairs`]), and a curve fitted +/// to a sliver of the tone range extrapolates them: in review, a synthetic frame whose curve passed +/// the gates at a held-out RMS of 0.0004 rendered its bright sky at 0.29 where the camera JPEG has +/// 0.93. +const NARROW_TONE_RANGE: f32 = 1.5; + +/// What a narrow tone curve is checked against ([`highlights_hold`]). +struct HighlightCheck { + /// The frame's [`highlights`]. + pairs: Pairs, + /// The photo's curve will be lowered to Sony's without DRO ([`with_dro_off_tone`]) after the + /// fit, so the check looks at it lowered. + dro: bool, + /// The camera JPEG is black and white ([`fit_tone_only_with`]): the check compares luminance, as that + /// fit's gates do, since the JPEG has no colour to match. + grey: bool, +} + +/// Whether a look with this `tone` and `colour` (sensor value to the finish stage's input) keeps +/// the frame's [`highlights`] (camera JPEG luminance 0.85 and up, left out of the fit) no further +/// from the camera JPEG than the neutral fallback does, with the tone curve as the photo will get +/// it (lowered for DRO), in luminance alone for a black-and-white JPEG. Only asked of curves narrower than +/// [`NARROW_TONE_RANGE`]: true for wider ones, and for frames without such pixels. +fn highlights_hold(tone: &CameraTone, check: &HighlightCheck, colour: impl Fn([f64; 3]) -> [f64; 3]) -> bool { + let knots = tone.knots(); + if knots[31][0] >= knots[0][0] * NARROW_TONE_RANGE { + return true; + } + // the curve as the photo will get it + let used = if check.dro { dro_off(tone) } else { *tone }; + let (map, neutral) = (ToneMap::camera(&used, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + let (mut look, mut fallback) = (0.0, 0.0); + for (x, target) in &check.pairs { + let (rendered, base) = (displayed(colour(*x), &map), displayed(*x, &neutral)); + if check.grey { + look += (luma(rendered) - luma(*target)).powi(2); + fallback += (luma(base) - luma(*target)).powi(2); + continue; + } + for c in 0..3 { + look += (rendered[c] - target[c]).powi(2); + fallback += (base[c] - target[c]).powi(2); + } + } + let hold = look.is_finite() && look <= fallback; + if !hold && lightcraft_pipeline::profiling() { + eprintln!("[profile] camera look: narrow tone curve renders the bright areas worse than the fallback ({look:.3} > {fallback:.3})"); + } + hold +} + /// The camera's tone curve from scene/JPEG luminance pairs, by quantile matching: knot `i` pairs /// the median of the `i`-th 1/32 of the sorted scene luminances with the median of the `i`-th /// 1/32 of the sorted JPEG luminances. A tone curve is monotone, so it maps each quantile of the @@ -1806,7 +1949,7 @@ fn fit_tone(pairs: Vec<(f64, f64)>) -> Option { let mid = a + (b - a) / 2; *knot = [*xs.get(mid)? as f32, *ys.get(mid)? as f32]; } - if knots[31][0] < knots[0][0] * 1.5 { + if knots[31][0] < knots[0][0] * MIN_TONE_RANGE { return None; } CameraTone::new(knots) @@ -1834,7 +1977,7 @@ fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option { let mut ys: Vec<_> = bin.iter().map(|p| p.1).collect(); *knot = [median(&mut xs)? as f32, median(&mut ys)? as f32]; } - if knots[31][0] < knots[0][0] * 1.5 { + if knots[31][0] < knots[0][0] * MIN_TONE_RANGE { return None; } // Pool adjacent violating bins (isotonic regression): no reversals or arbitrary polynomial. @@ -3503,7 +3646,8 @@ mod tests { let none = fit_look(&sensor, &reference, &vec![false; sensor.data.len()], None).unwrap(); assert_eq!((none.matrix.0, none.tone), (all.matrix.0, all.tone)); // without clipped pixels the refit keeps the accepted attempt (here: all pixels, own colour, quantile tone) - let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS).unwrap(); + let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; + let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS, &check).unwrap(); assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false, dark: false }); assert!(without_clipped(&sensor, &[true; 3]).is_none(), "a mask of another size is ignored"); } @@ -3635,7 +3779,8 @@ mod tests { let neutral = ToneMap::new(0.0, 0.0, 0.0); let mut reference = sensor.clone(); reference.map_in_place(|p| displayed(p.map(f64::from), &neutral).map(|v| v as f32)); - assert!(fit_partial(&sensor, &reference).is_none(), "no clear gain over the fallback"); + let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; + assert!(fit_partial(&sensor, &reference, &check).is_none(), "no clear gain over the fallback"); // an unrelated picture for (i, p) in reference.data.iter_mut().enumerate() { *p = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01]; @@ -3894,8 +4039,10 @@ mod tests { } } let colour = Some((Mat3::IDENTITY, None)); - let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }) - .expect("tone fitted"); + let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false }; + let look = + fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }, &check) + .expect("tone fitted"); assert_eq!(look.matrix, Mat3::IDENTITY); assert!(look.hue_sat.is_none()); let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); @@ -3983,4 +4130,181 @@ mod tests { looked.profile.look_table = HsvTable::from_tags(&[1, 2, 1], &[0.0, 1.0, 1.0, 0.0, 1.0, 1.0], 0); assert!(looked.profile.look_table.is_some() && !eligible(RawFormat::Dng, looked), "a look table is the file's own rendering"); } + + /// Pairs whose scene luminance spans `ratio` from the darkest to the brightest pixel, mapped + /// to the JPEG through a steep camera curve (a hazy scene the camera stretches). + fn low_contrast_pairs(ratio: f64) -> Vec<(f64, f64)> { + (0..4096).map(|i| 0.08 * ratio.powf(i as f64 / 4095.0)).map(|x| (x, (0.2 + (x - 0.08) * 3.0).min(0.95))).collect() + } + + /// The ratio the tone fits test: scene luminance at the middle of the brightest 1/32 over that + /// of the darkest 1/32 (their last and first knots). + fn knot_range(mut scene: Vec) -> f64 { + scene.sort_by(f64::total_cmp); + let n = scene.len(); + scene[31 * n / 32 + n / 64] / scene[n / 64] + } + + /// Issue #631: a low-contrast scene (its darker and brighter parts under 1.5× apart) still gets + /// a tone curve; only a nearly uniform one doesn't. Both tone fits use the same limit. + #[test] + fn tone_fits_accept_low_contrast_scenes() { + for fit in [fit_tone, fit_tone_conditional] { + let pairs = low_contrast_pairs(1.47); + let range = knot_range(pairs.iter().map(|p| p.0).collect()); + assert!((1.4..1.5).contains(&range), "the scene sits just below the old limit of 1.5: {range}"); + let curve = fit(pairs).expect("a low-contrast scene, as in the burst of issue #631"); + assert!((curve.apply(0.1) - 0.26).abs() < 0.01, "follows the camera curve: {}", curve.apply(0.1)); + assert!(fit(low_contrast_pairs(1.1)).is_none(), "a nearly uniform frame"); + } + } + + /// The same scene end to end: colour varies, luminance spans under 1.5×, and the camera + /// stretches it; the look is accepted and lands on the JPEG instead of the neutral fallback. + #[test] + fn a_low_contrast_scene_gets_a_camera_look() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let ev = 0.06 + (i % 31) as f32 * 0.0008; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + *dst = p.map(|v| v * (0.2 + (y - 0.07) * 3.0) / y); + } + let look = fit_pairs(&sensor, &reference).expect("a camera look, not the neutral fallback"); + let range = knot_range(sensor.data.iter().map(|x| luma(look.matrix.apply(x.map(f64::from)))).collect()); + assert!(range < 1.5, "the scene sits below the old limit of 1.5: {range}"); + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let error: f64 = sensor + .data + .iter() + .zip(&reference.data) + .map(|(x, y)| { + let p = displayed(look.matrix.apply(x.map(f64::from)), &tone); + (0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::() / 3.0 + }) + .sum::() + / sensor.data.len() as f64; + assert!(error.sqrt() < 0.02, "{}", error.sqrt()); + } + + /// Codex review of #631: a low-contrast scene's curve is fitted to its midtones only, and the + /// camera JPEG's bright areas (luminance 0.85 and up) are in neither the fit nor the held-out + /// gates. With 1.2 alone, this frame's curve passed the gates and rendered its bright sky + /// (camera JPEG 0.93) at about 0.28. The second review found the same through the retries' + /// filters: a clipped sky (left out by the refit without clipped pixels), scattered bright + /// pixels (left out away from edges) and sensor values over 1.5 (out of the pairs' range). + #[test] + fn a_narrow_curve_that_darkens_the_bright_areas_is_refused() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + for (case, sky_value, is_sky) in [ + ("bright sky", 0.9, &(|i: usize| i < 5600) as &dyn Fn(usize) -> bool), + ("clipped sky", 1.0, &|i: usize| i < 5600), + ("scattered bright pixels", 0.9, &|i: usize| i.is_multiple_of(8)), + ("sky over the pairs' range", 1.6, &|i: usize| i < 5600), + ] { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + let mut clipped = vec![false; sensor.data.len()]; + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + if is_sky(i) { + // bright, nearly white in the camera JPEG + *src = [sky_value; 3]; + *dst = [0.93; 3]; + clipped[i] = case == "clipped sky"; + continue; + } + let ev = 0.06 + (i % 31) as f32 * 0.0008; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + // nearly flat midtones: the fitted curve's extrapolation can't reach the sky + *dst = p.map(|v| v * (0.2 + (y - 0.07) * 0.05) / y); + } + let Some(look) = fit_look(&sensor, &reference, &clipped, None) else { continue }; + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let correction = look.hue_sat.as_ref().and_then(HueSat::new); + let p = look.matrix.apply([f64::from(sky_value); 3]); + let p = correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)); + let sky = luma(displayed(p, &tone)); + assert!(sky > 0.8, "{case}: an accepted look must not darken what the camera renders at 0.93: {sky}"); + } + } + + /// Third Codex review of #631: for a Sony photo shot with DRO, the fitted curve is lowered to + /// Sony's without DRO after the fit, so it must be checked as lowered. On this frame the + /// fitted curve keeps the sky (0.81 against the fallback's 0.79), and lowered it rendered the + /// sky at 0.61 where the camera JPEG has 0.93. + #[test] + fn a_narrow_curve_is_checked_as_lowered_for_dro() { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + if i < 5600 { + *src = [0.9; 3]; + *dst = [0.93; 3]; + continue; + } + let ev = 0.06 + (i % 31) as f32 * 0.0008; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + let p = known.apply_f32(*src); + let y = luminance_2020(p); + *dst = p.map(|v| v * (0.6 + (y - 0.07) * 0.25) / y); + } + let neutral = luma(displayed([0.9; 3], &ToneMap::new(0.0, 0.0, 0.0))); + if let Some(look) = fit_look_with(&sensor, &reference, &vec![false; sensor.data.len()], None, true) { + let look = with_dro_off_tone(look); + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let correction = look.hue_sat.as_ref().and_then(HueSat::new); + let p = look.matrix.apply([0.9; 3]); + let p = correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)); + let sky = luma(displayed(p, &tone)); + assert!(sky > neutral - 0.02, "the DRO-lowered curve must not darken the sky past the fallback ({neutral}): {sky}"); + } + } + + /// A black-and-white JPEG of a low-contrast scene (issues #577 and #631): grey pixels, scene luminance spanning + /// under 1.5×, which the old range limit refused before any gate. Its tone curve is fitted. + #[test] + fn a_low_contrast_black_and_white_jpeg_gives_its_tone_curve() { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let ev = 0.06 + (i % 31) as f32 * 0.0008; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + *dst = [0.2 + (luminance_2020(*src) - 0.06) * 3.0; 3]; + } + let range = knot_range(sensor.data.iter().map(|x| luma(x.map(f64::from))).collect()); + assert!(range < 1.5, "the scene sits below the old limit of 1.5: {range}"); + let look = fit_tone_only(&sensor, &reference, None).expect("the JPEG's tone curve"); + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let error = sensor.data.iter().zip(&reference.data).map(|(x, y)| (luma(displayed(x.map(f64::from), &tone)) - f64::from(y[0])).powi(2)); + let rms = (error.sum::() / sensor.data.len() as f64).sqrt(); + assert!(rms < 0.02, "{rms}"); + } + + /// [`a_narrow_curve_that_darkens_the_bright_areas_is_refused`] for a black-and-white JPEG: its tone curve is + /// fitted to nearly flat midtones under 1.5×, and the bright sky (camera JPEG 0.93) is left out of the fit. + #[test] + fn a_narrow_black_and_white_curve_that_darkens_the_bright_areas_is_refused() { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + if i < 5600 { + *src = [0.9; 3]; + *dst = [0.93; 3]; + continue; + } + let ev = 0.06 + (i % 31) as f32 * 0.0008; + *src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)]; + *dst = [0.2 + (luminance_2020(*src) - 0.06) * 0.05; 3]; + } + if let Some(look) = fit_tone_only(&sensor, &reference, None) { + let sky = luma(displayed(look.matrix.apply([0.9; 3]), &ToneMap::camera(&look.tone, 0.0, 0.0, 0.0))); + assert!(sky > 0.8, "an accepted curve must not darken what the camera renders at 0.93: {sky}"); + } + } } diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 22d76528d..524446e7d 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 64; +pub const RENDER_CACHE_VERSION: u64 = 69; /// 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 9a259e9ed..dc888d459 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -4,8 +4,7 @@ ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW, ORF and ERF decoding supply sensor data ## Colour and tone are separate -A fixed sensor proxy of at most 96×96 is developed with the file's WB, black/white levels and active crop. The embedded JPEG is decoded with DCT scaling and a 64-million-pixel input limit, converted through the codec's colour management, and reduced to the same size. Aspect ratios must agree within 2%. For colour fitting, near-black, clipped and nonfinite samples are excluded. Learning a colour matrix requires at least 256 paired pixels, 5% coloured samples (linear channel spread >0.05) and a nonsingular input covariance with bounded condition. An existing camera profile supplies the colour model: fitting only tone still requires 256 pairs and 5% non-monochrome samples, but permits channel spread >0.005 so dark or dull scenes can use the known colours. -A fixed sensor proxy of at most 96×96 is developed with the file's WB, black/white levels and active crop. The embedded JPEG is decoded with DCT scaling and a 128-million-pixel input limit (61 MP bodies' JPEGs, e.g. the ILCE-7RM6 full frame at 66.5 MP, exceed 64 MP; the codec checks the cap before decoding at full size, about 200 MB transient), converted through the codec's colour management, and reduced to the same size. Aspect ratios must agree within 2%. Near-black, clipped and nonfinite samples are excluded. Learning a colour matrix requires at least 256 paired pixels, 5% coloured samples (linear channel spread >0.05) and a nonsingular input covariance with bounded condition. An existing camera profile supplies the colour model: fitting only tone still requires 256 pairs and 5% non-monochrome samples, but permits channel spread >0.005 so dark or dull scenes can use the known colours. +A fixed sensor proxy of at most 96×96 is developed with the file's WB, black/white levels and active crop. The embedded JPEG is decoded with DCT scaling and a 128-million-pixel input limit (61 MP bodies' JPEGs, e.g. the ILCE-7RM6 full frame at 66.5 MP, exceed 64 MP; the codec checks the cap before decoding at full size, about 200 MB transient), converted through the codec's colour management, and reduced to the same size. Aspect ratios must agree within 2%. For colour fitting, near-black, clipped and nonfinite samples are excluded. Learning a colour matrix requires at least 256 paired pixels, 5% coloured samples (linear channel spread >0.05) and a nonsingular input covariance with bounded condition. An existing camera profile supplies the colour model: fitting only tone still requires 256 pairs and 5% non-monochrome samples, but permits channel spread >0.005 so dark or dull scenes can use the known colours. Any tone curve (full, partial or black-and-white look) also needs some contrast: the scene luminance of the brightest 1/32 of the pairs must be at least 1.2× that of the darkest 1/32 (`MIN_TONE_RANGE`); below that, the range check refuses the curve, which leaves only nearly uniform frames. A curve under 1.5× (the limit until issue #631) must also render the frame's bright areas, which the fit and the held-out gates leave out (camera JPEG luminance 0.85 and up), no further from the camera JPEG than the neutral fallback does (in luminance for a black-and-white JPEG): fitted to a narrow range, it extrapolates them. A hazy ILCE-7RM4 burst at 1.46 to 1.49 opened with the neutral fallback, 18 L\* darker than its camera JPEG, while the frame at 1.51 got a look at a held-out RMS of 0.013; with 1.2 they all do. Across 135 public Sony files the lowest range is 4.0, so none of them changes. Two thirds of the usable pixels train a ridge-regularised 3×3 **chromaticity** correction (luminance-normalised RGB). This avoids trying to encode the camera's nonlinear tone curve in a matrix, which left skin grey in the earlier matrix-only estimate. Coefficients are bounded by 8. A matrix can't follow the camera's hue-dependent rendering, though: on a photo of a lime shirt among grey rocks the best matrix rendered the shirt olive (16° hue error), because the few saturated pixels have little weight against the many dull ones, and a matrix fitted to the shirt alone breaks everything else. So a **hue/saturation table** (5 values × 72 hues × 5 saturations, the DNG `ProfileHueSatMap` layout with an sRGB-encoded value axis, applied in linear ProPhoto RGB after the baseline exposure) is fitted to the residual hue shifts and saturation ratios after the matrix: Gaussian kernels around each node (2.5° hue, 0.15 saturation, 0.12 encoded value), weighted by saturation, shrunk toward identity where the photo has few samples, saturation 0 left as identity so neutrals stay neutral. The value axis matters: on a sunny waterfall scene dark yellows had to turn ~12° toward orange while brighter yellow-greens turned ~9° toward green, and one shift per hue made both worse. The table restores each pixel's luminance (the tone curve owns luminance; without that, saturation boosts darkened colours and the table lost on held-out pixels). From 1be3b3d743d3e77689adf321489ccc6077aa628e Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Wed, 14 Oct 2026 02:58:13 +0200 Subject: [PATCH 06/36] LR-EDIT-OPTICS-PROFILE: apply A7CR embedded distortion for corrected full-frame raws Use the corrected-radius table convention for full-frame Bayer ILCE-7CR files with distortion Auto or forced Auto. Preserve Off files, unvalidated modes and the existing ILCE-7RM4A conversion. Add mapping, setting, scope, padding, orientation, resizing and DNG checks. Record camera-JPEG geometry and color measurements, including the remaining wide-end edge residual. Refresh stored lens data with LOOK_VERSION 11 and rendered results with RENDER_CACHE_VERSION 68. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/camera_preview.rs | 3 +- crates/engine/src/files.rs | 43 +++++ crates/engine/src/media.rs | 2 +- crates/raw/src/lens_metadata.rs | 4 +- crates/raw/src/vendor/arw_lens.rs | 250 +++++++++++++++++++++++++++- crates/raw/tests/corpus.rs | 38 ++++- docs/gaps.md | 2 +- docs/parity-checklist.md | 2 +- docs/raw-parity.md | 2 +- docs/sony-lens-corrections.md | 103 ++++++++++-- docs/whats-new.md | 5 + 11 files changed, 418 insertions(+), 36 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 614c58c60..ef50dac1a 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -34,7 +34,8 @@ use lightcraft_raw::{ /// 7: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected. /// 8: the bundled NIKON Z 6_2 profile. /// 9: the bundled NIKON Z 8 and NIKON D5300 profiles. -pub const LOOK_VERSION: u32 = 9; +/// 11: validated A7CR distortion, including the lens data stored with each smart preview. +pub const LOOK_VERSION: u32 = 11; #[derive(Clone, Debug)] pub(crate) struct CameraLook { diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index dd246cf84..c4a6892fa 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -1658,6 +1658,49 @@ pub(crate) mod tests { Some((best.1, best.0)) } + #[test] + fn corpus_a7cr_warp_keeps_coordinates_after_orientation_and_resizing() { + use lightcraft_geom::{Orientation, Point}; + use lightcraft_pipeline::optics::Warp; + let root = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")); + for name in ["arw-sony-a7cr-compressed.arw", "arw-sony-a7cr-lossless-l.arw"] { + let Ok(mut bytes) = std::fs::read(root.join("raw").join(name)) else { continue }; + let off = lightcraft_raw::probe_info(&bytes).unwrap(); + assert!(embedded_lens(&off).is_none()); + let tiff = lightcraft_tiff::Tiff::parse(&bytes).unwrap(); + let at = tiff.find(0x7036).unwrap().offset as usize; + let mut enabled = Vec::new(); + tiff.order.put_u16(&mut enabled, 17); + bytes[at..at + 2].copy_from_slice(&enabled); + let mut raw = lightcraft_raw::probe_info(&bytes).unwrap(); + raw.orientation = Orientation::Normal; + let mut settings = lightcraft_develop::DevelopSettings::default(); + settings.optics.lens_profile = true; + let base_lens = embedded_lens(&raw).unwrap(); + let base = Warp::from_settings(9504.0, 6336.0, &settings, Some(&base_lens)); + for orientation in 1..=8 { + let o = Orientation::from_exif(orientation); + raw.orientation = o; + let lens = embedded_lens(&raw).unwrap(); + for edge in [1200.0, 2560.0, 9504.0] { + let scale = edge / 9504.0; + let (w, h) = if o.swaps_axes() { (6336.0 * scale, edge) } else { (edge, 6336.0 * scale) }; + let oriented = Warp::from_settings(w, h, &settings, Some(&lens)); + for (x, y) in [(0.05, 0.1), (0.5, 0.5), (0.9, 0.8)] { + let point = Point::new(x * 9504.0, y * 6336.0); + let source = base.corrected_to_source(point, 1); + let (px, py) = o.map(point.x, point.y, 9504.0, 6336.0); + let (sx, sy) = o.map(source.x, source.y, 9504.0, 6336.0); + let got = oriented.corrected_to_source(Point::new(px * scale, py * scale), 1); + assert!((got.x - sx * scale).hypot(got.y - sy * scale) < 1e-8, "{name} orientation {orientation} edge {edge}"); + } + } + } + } + } + /// Issue #256: public Panasonic / Leica raws (skipped without the corpus) are corrected for distortion the way the /// camera corrected its own JPEG (tag 0x0119): at the corners of the frame, the render with "Enable Profile /// Corrections" lines up with the embedded JPEG within 2 px at 640 px, while the uncorrected render of the wide diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 22d76528d..3b0bfa0ff 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 64; +pub const RENDER_CACHE_VERSION: u64 = 68; /// 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/crates/raw/src/lens_metadata.rs b/crates/raw/src/lens_metadata.rs index 4529950c8..08aecc9e5 100644 --- a/crates/raw/src/lens_metadata.rs +++ b/crates/raw/src/lens_metadata.rs @@ -137,7 +137,7 @@ pub enum SonySettingMeaning { /// Whether a component is applied, and if not, why. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum SonyLensStatus { - /// Converted into the image's `OpcodeList3` (today: ILCE-7RM4A and ILCE-7RM6 Bayer distortion in native 3:2 framing). + /// Converted into the image's `OpcodeList3` (ILCE-7RM4A, ILCE-7RM6 or eligible full-frame ILCE-7CR Bayer distortion). Applied, /// No usable table. NoTable, @@ -149,6 +149,8 @@ pub enum SonyLensStatus { /// Decoded only: the model is validated, but not this coding or framing (linear YCbCr, a non-3:2 crop) or a /// table found only in the `SR2SubIFD`. NotValidatedMode, + /// A7CR distortion is Off, unavailable, missing or unknown. Its uncorrected JPEG and render keep the same geometry. + CameraCorrectionDisabled, /// Validated scope, but the table is all zeros: a valid no-op, nothing to apply. Neutral, /// Validated scope, but the conversion refused this table (values outside the validated range, a degenerate diff --git a/crates/raw/src/vendor/arw_lens.rs b/crates/raw/src/vendor/arw_lens.rs index aa7313e23..473f5d9ff 100644 --- a/crates/raw/src/vendor/arw_lens.rs +++ b/crates/raw/src/vendor/arw_lens.rs @@ -1,6 +1,6 @@ //! Sony's in-file lens-correction records (shading, lateral chromatic aberration, distortion): bounded decoding of -//! the raw image IFD's fields and their encrypted `SR2SubIFD` copies into [`SonyLensCorrections`], and the one -//! validated conversion, the ILCE-7RM4A's distortion table, into a DNG warp for the shared optics pipeline. +//! the raw image IFD's fields and their encrypted `SR2SubIFD` copies into [`SonyLensCorrections`], and validated +//! ILCE-7RM4A and ILCE-7CR distortion tables into DNG warps for the shared optics pipeline. //! //! Tag identity/layout: https://exiftool.org/TagNames/EXIF.html (`0x7031` to `0x7037`, signed shorts) and //! https://exiftool.org/TagNames/Sony.html#SR2SubIFD (`0x797d`, `0x7980`, `0x7982`). The `SR2SubIFD` setting tags @@ -34,13 +34,18 @@ const DISTORTION: Tags = Tags { setting: (0x7036, 0x7981), table: (0x7037, 0x798 /// TIFF field types. const SHORT: u16 = 3; const SSHORT: u16 = 8; -/// Camera models whose raw-IFD distortion table was independently validated. Exact names: ILCE-7RM4 and ILCE-7RM4A -/// are distinct models, not interchangeable aliases. +/// Models using the validated A7R IVA conversion. The A7CR uses a separate conversion. +/// ILCE-7RM4 and ILCE-7RM4A are distinct models, not interchangeable aliases. const DISTORTION_MODELS: &[&str] = &["ILCE-7RM4A"]; /// Most directory entries scanned: a classic TIFF directory's largest count, also bounding a BigTIFF one. const MAX_ENTRIES: usize = 1 << 16; const SAMPLES: usize = 1024; const MAX_ERROR: f64 = 0.0005; +/// Empirical A7CR unit, fixed on four FE 24-50mm files before checking the other files and lenses. +/// The camera-JPEG measurements and remaining coverage limits are in docs/sony-lens-corrections.md. +const A7CR_UNIT: f64 = 15559.300457825264; +const A7CR_FRAME: (usize, usize) = (9504, 6336); +const A7CR_APPROXIMATION_PX: f64 = 0.25; #[derive(Clone, Copy)] struct Tags { @@ -307,11 +312,17 @@ pub(super) fn corrections( // Only the validated scope reaches the conversion: the raw IFD's table, as the TIFF reader parsed it, of a Bayer // image whose image crop and reference are native 3:2 (DefaultCrop can keep native geometry while Sony's tags // select an aspect crop). - let validated_model = DISTORTION_MODELS.contains(&model); - let in_mode = !geometry.linear_ycbcr && geometry.native_3_2 && distortion_table.source == Some(SonySource::RawIfd); + let a7cr = model == "ILCE-7CR"; + let validated_model = DISTORTION_MODELS.contains(&model) || a7cr; + let in_mode = !geometry.linear_ycbcr + && geometry.native_3_2 + && distortion_table.source == Some(SonySource::RawIfd) + && (!a7cr || ((geometry.reference.width, geometry.reference.height) == A7CR_FRAME && geometry.image_crop == geometry.reference)); + let a7cr_auto = distortion_setting.value.is_some_and(|s| matches!(s.meaning, SonySettingMeaning::Auto | SonySettingMeaning::ForcedAuto)); let (w, h) = (geometry.storage_width, geometry.storage_height); let warp = match &distortion_table.value { - Some(table) if in_mode => distortion(model, table, Rect::new(0, 0, w, h), geometry.reference), + Some(table) if in_mode && a7cr && a7cr_auto => a7cr_distortion(table, Rect::new(0, 0, w, h), geometry.reference), + Some(table) if in_mode && !a7cr => distortion(model, table, Rect::new(0, 0, w, h), geometry.reference), _ => None, }; let distortion_status = match &distortion_table.value { @@ -319,6 +330,7 @@ pub(super) fn corrections( Some(_) if warp.is_some() => SonyLensStatus::Applied, Some(_) if !validated_model => SonyLensStatus::NotValidatedModel, Some(_) if !in_mode => SonyLensStatus::NotValidatedMode, + Some(_) if a7cr && !a7cr_auto => SonyLensStatus::CameraCorrectionDisabled, Some(table) if table.iter().all(|v| *v == 0) => SonyLensStatus::Neutral, Some(_) => SonyLensStatus::Rejected, }; @@ -334,6 +346,94 @@ pub(super) fn corrections( (Some(record), warp) } +/// A7CR camera-JPEG convention, measured independently of the ILCE-7RM4A conversion below. +/// For output radius rho, the corrected radius is rho / zoom. The table gives source radius at that +/// corrected radius. Its knots use the full-frame diagonal, not storage padding. Off/unknown settings +/// and unvalidated capture modes are excluded by the caller. +fn a7cr_distortion(table: &[i16; 16], active: Rect, crop: Rect) -> Option { + if (crop.width, crop.height) != A7CR_FRAME + || crop != crop.clipped(active.width, active.height) + || table.iter().all(|v| *v == 0) + || table.iter().any(|v| !(-4096..=4096).contains(v)) + { + return None; + } + let (cw, ch) = (crop.width as f64, crop.height as f64); + let spacing = cw.hypot(ch) / 30.0; + let source = |r: f64| { + let at = r / spacing; + let i = (at.floor() as usize).min(14); + let (a, b) = (f64::from(table[i]), f64::from(table[i + 1])); + r * (1.0 + (a + (b - a) * (at - i as f64)) / A7CR_UNIT) + }; + let border_min = cw.min(ch) / 2.0; + let border_max = cw.hypot(ch) / 2.0; + let border_ratio = |zoom: f64| { + (0..=4096) + .map(|j| { + let r = border_min + (border_max - border_min) * j as f64 / 4096.0; + source(r / zoom) / r + }) + .fold(0.0, f64::max) + }; + let (mut lo, mut hi) = (0.5, 2.0); + if border_ratio(lo) <= 1.0 || border_ratio(hi) > 1.0 { + return None; + } + for _ in 0..60 { + let mid = (lo + hi) / 2.0; + if border_ratio(mid) > 1.0 { + lo = mid; + } else { + hi = mid; + } + } + let zoom = (lo + hi) / 2.0; + let radius = (cw - 1.0).hypot(ch - 1.0) / 2.0; + let basis = |r: f64| { + let r2 = r * r; + [r, r * r2, r * r2 * r2, r * r2 * r2 * r2] + }; + let mut matrix = [[0.0; 4]; 4]; + let mut rhs = [0.0; 4]; + let mut previous = -1.0; + for j in 0..=SAMPLES { + let r = j as f64 / SAMPLES as f64; + let mapped = source(r * radius / zoom) / radius; + if !mapped.is_finite() || mapped <= previous { + return None; + } + previous = mapped; + let terms = basis(r); + for (i, &a) in terms.iter().enumerate() { + rhs[i] += a * mapped; + for (k, &b) in terms.iter().enumerate() { + matrix[i][k] += a * b; + } + } + } + let mut k = solve4(matrix, rhs)?; + // Check every displayed radius, separately from the empirical camera-JPEG validation. + for j in 0..=32768 { + let r = j as f64 / 32768.0; + let fit = basis(r).iter().zip(k).map(|(a, b)| a * b).sum::(); + let r2 = r * r; + let derivative = k[0] + r2 * (3.0 * k[1] + r2 * (5.0 * k[2] + r2 * 7.0 * k[3])); + let error_px = (fit * radius - source(r * radius / zoom)).abs() * 1440.0 / cw; + if !error_px.is_finite() || error_px > A7CR_APPROXIMATION_PX || derivative <= 0.0 { + return None; + } + } + let (ax, ay) = (active.width as f64 - 1.0, active.height as f64 - 1.0); + let (cx, cy) = (crop.x as f64 + (cw - 1.0) / 2.0, crop.y as f64 + (ch - 1.0) / 2.0); + let farthest = cx.max(ax - cx).hypot(cy.max(ay - cy)); + let ratio2 = (farthest / radius).powi(2); + for (i, coefficient) in k.iter_mut().enumerate() { + *coefficient *= ratio2.powi(i as i32); + } + Some(Opcode::WarpRectilinear { planes: vec![[k[0], k[1], k[2], k[3], 0.0, 0.0]], center: [cx / ax, cy / ay] }) +} + /// The validated conversion of a raw-IFD distortion table into a `WarpRectilinear`. Only independently validated /// camera models and the 16-entry layout; unknown models and implausible or non-smooth maps are left uncorrected. /// See docs/sony-lens-corrections.md. A camera setting of Off does not remove the lens data: the user can enable it @@ -454,6 +554,8 @@ mod tests { /// The shading samples of the public ILCE-7CR samples (raw.pixls.us DSC00795/6/8). const SHADING_T: [i16; 16] = [0, 64, 128, 224, 320, 480, 672, 896, 1184, 1472, 1824, 2240, 2816, 3552, 4512, 7328]; const KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; + /// Public issue #1300, FE 16-25mm F2.8 G at 16 mm, raw-IFD samples. + const A7CR_WIDE: [i16; 16] = [0, -14, -59, -130, -226, -341, -472, -615, -767, -924, -1084, -1242, -1397, -1549, -1696, -1839]; /// A CA table with negative and positive samples and two different curves. fn ca() -> [i16; 32] { @@ -630,7 +732,7 @@ mod tests { for order in [Little, Big] { for bigtiff in [false, true] { for (model, warps, distortion_status) in - [("ILCE-7CR", 0, SonyLensStatus::NotValidatedModel), ("ILCE-7RM4A", 1, SonyLensStatus::Applied)] + [("ILCE-7CR", 0, SonyLensStatus::NotValidatedMode), ("ILCE-7RM4A", 1, SonyLensStatus::Applied)] { let case = format!("{model} {order:?} bigtiff={bigtiff}"); let mut arw = Arw::new(model, order); @@ -1030,7 +1132,7 @@ mod tests { ("ILCE-7RM4A", [i16::MIN; 16], Rejected), ("ILCE-7RM4A", [0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], Rejected), ("ILCE-7RM4", TELE, NotValidatedModel), - ("ILCE-7CR", [0; 16], NotValidatedModel), + ("ILCE-7CR", [0; 16], NotValidatedMode), ("", TELE, NotValidatedModel), ] { let mut arw = Arw::new(model, order); @@ -1073,6 +1175,136 @@ mod tests { } } + fn a7cr_geometry(storage: (usize, usize)) -> SonyLensGeometry { + let crop = Rect::new(32, 20, 9504, 6336); + SonyLensGeometry { + storage_width: storage.0, + storage_height: storage.1, + sony_crop: Some(crop), + default_crop: Some(crop), + image_crop: crop, + reference: crop, + compression: 7, + linear_ycbcr: false, + native_3_2: true, + } + } + + #[test] + fn a7cr_requires_an_enabled_setting_and_validated_full_frame_mode() { + use SonyLensStatus::*; + for order in [Little, Big] { + for (setting, status) in + [(0, CameraCorrectionDisabled), (1, Applied), (17, Applied), (255, CameraCorrectionDisabled), (2, CameraCorrectionDisabled)] + { + let mut arw = Arw::new("ILCE-7CR", order); + arw.plain = plain(&A7CR_WIDE, setting); + arw.sr2 = None; + let bytes = arw.build(); + let tiff = Tiff::parse(&bytes).unwrap(); + let run = |model: &str, geometry: SonyLensGeometry| { + let (record, warp) = corrections(&bytes, order, (&tiff.ifds[0], false), None, model, geometry); + (record.unwrap().distortion.status, warp.is_some()) + }; + let geometry = a7cr_geometry((9728, 6656)); + assert_eq!(run("ILCE-7CR", geometry.clone()), (status, status == Applied)); + for model in ["ILCE-7CR ", "ILCE-7CR unknown", "ILCE-7C", "ILCE-7CM2", "ILCE-7RM4", "ILCE-7RM6"] { + assert_eq!(run(model, geometry.clone()), (NotValidatedModel, false), "{model}"); + } + let mut linear = geometry.clone(); + linear.linear_ycbcr = true; + let mut apsc = geometry.clone(); + apsc.reference = Rect::new(32, 20, 6240, 4160); + apsc.image_crop = apsc.reference; + let mut aspect = geometry.clone(); + aspect.image_crop.height = 5346; + aspect.native_3_2 = false; + let mut offset = geometry.clone(); + offset.image_crop.x = 0; + for mode in [linear, apsc, aspect, offset] { + assert_eq!(run("ILCE-7CR", mode), (NotValidatedMode, false)); + } + // Missing or malformed settings cannot enable the correction. + for value in [None, Some(Value::SShort(vec![-1])), Some(Value::Long(vec![17]))] { + arw.plain.retain(|(tag, _)| *tag != DISTORTION.setting.0); + if let Some(value) = value { + arw.plain.push((DISTORTION.setting.0, value)); + } + let bytes = arw.build(); + let tiff = Tiff::parse(&bytes).unwrap(); + let (record, warp) = corrections(&bytes, order, (&tiff.ifds[0], false), None, "ILCE-7CR", geometry.clone()); + assert_eq!(record.unwrap().distortion.status, CameraCorrectionDisabled); + assert!(warp.is_none()); + } + } + } + } + + #[test] + fn a7cr_storage_padding_does_not_change_the_optical_mapping() { + let crop = Rect::new(32, 20, 9504, 6336); + let mut maps = Vec::new(); + for (w, h) in [(9600, 6376), (9728, 6656)] { + let Opcode::WarpRectilinear { planes, center } = a7cr_distortion(&A7CR_WIDE, Rect::new(0, 0, w, h), crop).unwrap() else { + panic!("warp") + }; + let (cx, cy) = (center[0] * (w - 1) as f64, center[1] * (h - 1) as f64); + assert!((cx - 4783.5).abs() < 1e-10 && (cy - 3187.5).abs() < 1e-10); + let radius = cx.max((w - 1) as f64 - cx).hypot(cy.max((h - 1) as f64 - cy)); + let k = planes[0]; + maps.push( + (0..=100) + .map(|j| { + let r = j as f64 / 100.0 * 5700.0; + let r2 = (r / radius).powi(2); + r * (k[0] + r2 * (k[1] + r2 * (k[2] + r2 * k[3]))) + }) + .collect::>(), + ); + } + for (a, b) in maps[0].iter().zip(&maps[1]) { + assert!((a - b).abs() < 1e-9); + } + for bad in [[0; 16], [i16::MIN; 16], [0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]] { + assert!(a7cr_distortion(&bad, Rect::new(0, 0, 9728, 6656), crop).is_none()); + } + assert!(a7cr_distortion(&A7CR_WIDE, AREA, crop).is_none(), "crop outside storage"); + assert!(a7cr_distortion(&A7CR_WIDE, AREA, Rect::new(0, 0, 6240, 4160)).is_none(), "APS-C"); + } + + #[test] + fn a7cr_matches_independently_measured_public_jpeg_geometry() { + // Green-channel feature fits, independent of the table, on the public FE 16-25mm (issue #1300) + // and FE 50mm files. Check only the supported radii, not extrapolated corners. + let prime = [-4, -1, 3, 6, 12, 17, 23, 30, 37, 43, 50, 58, 65, 71, 76, 84]; + let cases = [ + (A7CR_WIDE, [1.0606515126386147, -0.24282593993968085, 0.1667903749262344, -0.059882313562606454], 0.93), + (prime, [0.9951622479344596, 0.00741813858793482, -0.002474290073462703, -0.000235147000260114], 0.96), + ]; + let crop = Rect::new(32, 20, 9504, 6336); + let radius = 9503f64.hypot(6335.0) / 2.0; + for (table, reference, supported) in cases { + let Opcode::WarpRectilinear { planes, center } = a7cr_distortion(&table, Rect::new(0, 0, 9728, 6656), crop).unwrap() else { + panic!("warp") + }; + let (cx, cy) = (center[0] * 9727.0, center[1] * 6655.0); + let normalization = cx.max(9727.0 - cx).hypot(cy.max(6655.0 - cy)); + let k = planes[0]; + let mut worst = 0.0f64; + for j in 0..=1000 { + let r = supported * j as f64 / 1000.0; + let rho = r * radius; + let r2 = (rho / normalization).powi(2); + let mapped = rho * (k[0] + r2 * (k[1] + r2 * (k[2] + r2 * k[3]))); + let expected = rho * (reference[0] + r * r * (reference[1] + r * r * (reference[2] + r * r * reference[3]))); + worst = worst.max((mapped - expected).abs() * 1440.0 / 9504.0); + } + // This smooth feature fit has localization noise. The combined budget allows the + // 0.75 px camera-table residual plus the separately bounded 0.25 px polynomial error. + assert!(worst < 1.0, "camera-JPEG shape residual {worst} px at 1440"); + } + } + #[test] fn linear_ycbcr_files_are_decoded_but_not_corrected() { // a downsized lossless (M/S) coding, as in arw.rs's test, from the validated model with 3:2 crop tags diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 31e62f7dc..bab0bd96d 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -1253,7 +1253,7 @@ fn sony_lens_records_of_the_public_samples() { "arw-sony-a7cr-compressed.arw", [272, 1, 0], (a7cr_shading, a7cr_ca, a7cr_distortion), - SonyLensStatus::NotValidatedModel, + SonyLensStatus::CameraCorrectionDisabled, (9600, 6376), 32767, false, @@ -1264,7 +1264,7 @@ fn sony_lens_records_of_the_public_samples() { "arw-sony-a7cr-lossless-l.arw", [272, 1, 0], (a7cr_shading, a7cr_ca, a7cr_distortion), - SonyLensStatus::NotValidatedModel, + SonyLensStatus::CameraCorrectionDisabled, (9728, 6656), 7, false, @@ -1275,7 +1275,7 @@ fn sony_lens_records_of_the_public_samples() { "arw-sony-a7cr-lossless-m.arw", [272, 1, 0], (a7cr_shading, a7cr_ca, a7cr_distortion), - SonyLensStatus::NotValidatedModel, + SonyLensStatus::NotValidatedMode, (6656, 4608), 7, true, @@ -1362,6 +1362,38 @@ fn sony_lens_records_of_the_public_samples() { eprintln!("Sony lens records: {checked} checks on corpus files"); } +/// These pinned JPEGs have correction Off. Changing only the raw-IFD setting tests decoder wiring, +/// not agreement with those JPEGs. The empirical corrected references are documented separately. +#[test] +fn a7cr_enabled_distortion_agrees_in_header_full_and_dng() { + use lightcraft_raw::lens_metadata::SonyLensStatus; + let root = corpus_root().join("raw"); + let mut checked = 0; + for name in ["arw-sony-a7cr-compressed.arw", "arw-sony-a7cr-lossless-l.arw", "arw-sony-a7cr-lossless-m.arw"] { + let Some(mut bytes) = pinned_sony(&root, name) else { continue }; + let original = probe_info(&bytes).unwrap(); + assert!(original.opcodes.list3.is_empty(), "Off files must remain uncorrected"); + let tiff = lightcraft_tiff::Tiff::parse(&bytes).unwrap(); + let at = tiff.find(0x7036).unwrap().offset as usize; + let mut enabled = Vec::new(); + tiff.order.put_u16(&mut enabled, 17); + bytes[at..at + 2].copy_from_slice(&enabled); + let header = probe_info(&bytes).unwrap(); + let full = decode(&bytes).unwrap(); + assert_eq!(header, full.info(), "{name}"); + let s = full.lens_metadata.sony.as_ref().unwrap(); + let linear = s.geometry.linear_ycbcr; + assert_eq!(s.distortion.status, if linear { SonyLensStatus::NotValidatedMode } else { SonyLensStatus::Applied }); + assert_eq!(full.opcodes.list3.len(), usize::from(!linear)); + // DNG export keeps the normalized map, including the different lossless storage padding. + let dng = lightcraft_raw::write_dng(&full, &Default::default()).unwrap(); + let back = probe_info(&dng).unwrap(); + assert_eq!(back.opcodes.list3, full.opcodes.list3, "{name}"); + checked += 1; + } + assert!(checked == 0 || checked == 3, "run with all three pinned A7CR codings"); +} + /// Kodak DCS520C (`crates/raw/src/vendor/kodak.rs`): the two-component lossless JPEG strip decodes through the /// file's response curve to a 1736 x 1160 GRBG mosaic with 12-bit white, per-position black levels and the /// white balance of the vendor IFD. Looks for the corpus copy, then the raw.pixls.us folder; skips without either. diff --git a/docs/gaps.md b/docs/gaps.md index f11a74f7a..b841460cb 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -22,7 +22,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 5 B | **AI masks: Subject, Sky, People, Background, Landscape** are heuristics; Object / Describe run SAM 3 but its download has no mirror configured | `crates/segment`; ai-masks.md; MASK rows 67% | masking is how most Lightroom users edit today | 40–70 + model decision | [target-app-parity](target-app-parity.md#ai) | | 6 B | **Compressed ORF** (every Olympus / OM body since ~2008) | measured: 66 of 78 Olympus / OM models preview-only; `orf.rs` refuses; OM bucket 3% of photographers, 3% of them fully work | +2.6 points; best gain per hour of any raw gap (~0.2) | 8–15 | [raw-parity](raw-parity.md#compressed-orf-a-clean-room-starting-point) (the two Olympus patents that describe the method) | | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | -| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A and ILCE-7RM6 distortion (`docs/sony-ilce7rm6-lens-corrections.md`) and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | +| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A, full-frame ILCE-7CR distortion (Auto only) and ILCE-7RM6 distortion (`docs/sony-ilce7rm6-lens-corrections.md`) and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | | 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 545 of 1,446 Adobe models verified (38%), ~85% of photographers on a verified body; Nikon 5 bodies verified, Canon 34, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..bbafdff35 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -214,7 +214,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | | | LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | | | | LR-EDIT-OPTICS-CA | Remove chromatic aberration | P1 | ✅ | `crates/ui-egui/src/panels/edit.rs` (checkbox), `ctl:optics.caRed`, `ctl:optics.caBlue` | | -| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled only for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses; ILCE-7RM6 verified against the camera JPEG on 54 photos with three lenses (`docs/sony-ilce7rm6-lens-corrections.md`); other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Nikon NEF distortion from NikonNEFInfo (`0xc7d5`: polynomial, frame-filling zoom and the optical-centre offset left by in-body stabilisation; `crates/raw/src/vendor/nef_lens.rs`, `docs/nikon-lens-corrections.md`) for the Z 6II, Z 8 and Z f, full-frame image area only: F-mount lenses on the FTZ match the camera JPEG within 0.2 px median, Z lenses within 0.5–1.5 px except the 24-70 f/4's wide end, where the camera applies a further correction that is not in the readable data (7 px median at 24 mm, from 16 px / 106 px p95 uncorrected); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus raws not read yet; Sony ARW shading, lateral CA and distortion tables (raw IFD `0x7031`–`0x7037`, with the encrypted `SR2SubIFD` copies as a checked fallback) are decoded with their camera settings, provenance and framing evidence for every body that records them (`crates/raw/src/lens_metadata.rs`), but only the ILCE-7RM4A and ILCE-7RM6 distortion above is applied: on the ILCE-7CR the unchanged distortion interpretation misses the camera JPEG at 24 mm (median 1.6 px, p95 6.2 px at 2376 px), and Sony shading and CA semantics are not validated beyond the ILCE-7RM6's (lateral CA off) (`docs/sony-lens-corrections.md`); Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | +| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses, and ILCE-7CR full-frame Bayer 3:2 files with correction Auto or forced Auto (a separate corrected-radius conversion validated against camera JPEGs with FE 24-50mm, FE 16-25mm and FE 50mm lenses); A7CR Off, APS-C, aspect crops and lossless M/S remain uncorrected; ILCE-7RM6 verified against the camera JPEG on 54 photos with three lenses (`docs/sony-ilce7rm6-lens-corrections.md`); other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Nikon NEF distortion from NikonNEFInfo (`0xc7d5`: polynomial, frame-filling zoom and the optical-centre offset left by in-body stabilisation; `crates/raw/src/vendor/nef_lens.rs`, `docs/nikon-lens-corrections.md`) for the Z 6II, Z 8 and Z f, full-frame image area only: F-mount lenses on the FTZ match the camera JPEG within 0.2 px median, Z lenses within 0.5–1.5 px except the 24-70 f/4's wide end, where the camera applies a further correction that is not in the readable data (7 px median at 24 mm, from 16 px / 106 px p95 uncorrected); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus raws not read yet; Sony ARW shading, lateral CA and distortion tables (raw IFD `0x7031`–`0x7037`, with the encrypted `SR2SubIFD` copies as a checked fallback) are decoded with their camera settings, provenance and framing evidence for every body that records them (`crates/raw/src/lens_metadata.rs`), but only the ILCE-7RM4A, ILCE-7RM6 and ILCE-7CR use the separate distortion conversions above; Sony shading and CA semantics are not validated beyond the ILCE-7RM6's (lateral CA off) (`docs/sony-lens-corrections.md`); Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | ) | LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper | | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index d9cc06a93..af07a9e45 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -234,7 +234,7 @@ essentials user (the default look is what they keep). | Item | Adobe | LightCraft | |---|---|---| | Profile database | 3,635 profile files, 56 makers, auto-selected | **0** (Adobe LCPs never used; no database of our own) | -| In-file corrections | ✅ | DNG opcodes (WarpRectilinear, FixVignetteRadial, GainMap…) applied; Panasonic / Leica RW2 distortion (79 photos, 13 bodies, 11 lenses); Sony tag 0x7037 distortion for the ILCE-7RM4A (2 lenses) and the ILCE-7RM6 (3 lenses, 54 photos; `sony-ilce7rm6-lens-corrections.md`); Nikon NikonNEFInfo distortion (tag `0xc7d5`, about the sensor-shifted optical centre) for the Z 6II and Z f: F-mount lenses on the FTZ within 0.2 px of the camera JPEG, Z lenses within 0.5–1.5 px except the 24-70's wide end (7 px median; `docs/nikon-lens-corrections.md`) | +| In-file corrections | ✅ | DNG opcodes (WarpRectilinear, FixVignetteRadial, GainMap…) applied; Panasonic / Leica RW2 distortion (79 photos, 13 bodies, 11 lenses); Sony tag 0x7037 distortion for the ILCE-7RM4A (2 lenses), full-frame Bayer 3:2 ILCE-7CR files with correction Auto or forced Auto (3 lenses; see sony-lens-corrections.md for residuals and limits) and the ILCE-7RM6 (3 lenses, 54 photos; `sony-ilce7rm6-lens-corrections.md`); Nikon NikonNEFInfo distortion (tag `0xc7d5`, about the sensor-shifted optical centre) for the Z 6II and Z f: F-mount lenses on the FTZ within 0.2 px of the camera JPEG, Z lenses within 0.5–1.5 px except the 24-70's wide end (7 px median; `docs/nikon-lens-corrections.md`) | | Vignetting / lateral CA from maker notes | ✅ | ⬜ | | Manual distortion, vignetting, CA, defringe | ✅ | ✅ | diff --git a/docs/sony-lens-corrections.md b/docs/sony-lens-corrections.md index ed32fc85d..3bd40a020 100644 --- a/docs/sony-lens-corrections.md +++ b/docs/sony-lens-corrections.md @@ -7,11 +7,11 @@ convention; that page's *Two conventions* section has the comparison and what wo Sony ARWs record three lens corrections per shot in the raw image IFD: shading (vignetting), lateral chromatic aberration (CA) and distortion, each as a camera setting and a radial table, with copies in the encrypted -`SR2SubIFD`. LightCraft **decodes all three** wherever a file carries them and **applies one**: the distortion table -of the **ILCE-7RM4A (A7R IVA)** in Bayer ARWs with native 3:2 framing, independently validated with the -FE 24–105mm F4 G OSS and FE 200–600mm F5.6–6.3 G OSS (the ILCE-7RM6's distortion is applied too, by its own -conversion above). Every other model, -and shading and CA on every model, are decoded only: no warp, vignetting or CA correction is added for them. The decoded record stays with the image +`SR2SubIFD`. LightCraft **decodes all three** wherever a file carries them and applies distortion here on two validated +bodies: **ILCE-7RM4A (A7R IVA)** and **ILCE-7CR (A7CR)**. The A7R IVA keeps its existing Bayer 3:2 conversion, +validated with the FE 24-105mm F4 G OSS and FE 200-600mm F5.6-6.3 G OSS. The A7CR uses a separate conversion, +limited to full-frame Bayer 3:2 files with distortion Auto or forced Auto. Apart from the separate ILCE-7RM6 converter, other models and shading and CA on every model are +decoded only: no warp, vignetting or CA correction is added for them. The decoded record stays with the image (`RawInfo::lens_metadata` / `RawImage::lens_metadata`, types in `crates/raw/src/lens_metadata.rs`, decoder in `crates/raw/src/vendor/arw_lens.rs`) for diagnostics and for the validation work described below. @@ -20,7 +20,8 @@ control, CPU/GPU optics path, EXIF orientation handling, and DNG export use the and non-3:2 camera crops are excluded pending validation. An aspect crop may retain the full-frame radial normalization; the decoder does not guess that relationship. Tables of other lengths and rejected tables remain uncorrected, and a table found only in the encrypted `SR2SubIFD` is never applied. The camera's distortion Off -setting does not erase its table. Newly imported photos use LightCraft's existing embedded-profile default +setting does not erase its table. A7CR Off, missing, unavailable and unknown settings produce no warp. The A7R IVA's +existing behavior is unchanged. Newly imported photos use LightCraft's existing embedded-profile default (enabled); the profile correction control can disable or adjust it. ## What is decoded @@ -67,7 +68,10 @@ setting does not erase its table. Newly imported photos use LightCraft's existin conversion refused the table). A readable table never implies a usable correction; only `Applied` components become opcodes. -The applied path is unchanged: the validated conversion still receives the raw IFD's `0x7037` table exactly as the +For the A7CR, `CameraCorrectionDisabled` records an Off, missing, unavailable or unknown setting. APS-C, +aspect crops and lossless M/S remain `NotValidatedMode`. The raw IFD's correction table is required. + +The A7R IVA's applied path is unchanged: its conversion still receives the raw IFD's `0x7037` table exactly as the TIFF reader parsed it, with the same model, framing and fit checks, and produces the same warp. A repeated tag keeps the reader's choice and is only flagged, and a table the reader never reads (past its entry limit) is reported but never applied, so already-catalogued photos, their previews and their stored lens data stay consistent with a fresh @@ -85,17 +89,8 @@ part of this repository. (Auto) everywhere; distortion is 17 (Auto, fixed by the lens) on the nine FE 24–50 files and 0 (Off) on the rest. Every image crop is 9504 × 6336 at (32, 20), in uncompressed, compressed and lossless-L (padded to 9728 × 6656) storage. The decoder reproduces this inventory exactly (see *Local inventory check*). -- **Distortion.** The table layout and crop tags match the A7R IVA's, but its interpretation applied unchanged does - not reproduce the A7CR camera JPEG at 24 mm: at 2376 px wide, median / 95th-percentile feature residuals were - 1.61 / 6.17 px (12.98 / 51.19 px uncorrected, 6,539 matches), with a signed radial pattern (about +1.7 px at - radii 0.25–0.5, −7.4 px at 0.8–1) that no translation removes. At 50 mm it agrees (0.23 / 1.05 px, from - 2.21 / 12.25 px). Scaling the raw table by about 10/9, or using the MakerNote's distortion array instead, brings - 24 mm to about 0.37 / 1.10 and 0.33 / 0.97 px. Those are exploratory comparisons on two files against the camera - JPEG, not a model: the MakerNote values are roughly 1.11 times the raw table without one exact ratio, and the - A7R IVA was validated against a different reference renderer (Imaging Edge exports). Needed: A7CR Imaging Edge - On/Off exports at 24 and 50 mm and with another lens, compared with the raw table at its fixed scale; a decision on - the reference (camera JPEG or Imaging Edge); then ILCE-7CR added to the validated models for the full-frame Bayer - 3:2 mode only. Neither a 10/9 multiplier nor the MakerNote table is used. +- **Distortion.** The A7R IVA interpretation misses the A7CR camera JPEG at the wide end. The A7CR now uses the + separately measured corrected-radius convention below. It uses the raw IFD table, not the MakerNote array. - **Shading.** The layout decodes, but no public specification gives the sample-to-gain function, its radius convention on the A7CR, or whether the raw samples already contain part of the correction. Brightness ratios on six natural scenes show no consistent missing peripheral brightening. Needed: evenly lit flat fields with shading @@ -112,6 +107,78 @@ part of this repository. - **Other modes.** APS-C, aspect crops and lossless M/S (linear YCbCr) captures were not measured and stay excluded. +## A7CR camera-JPEG validation + +The A7CR conversion substantially reduces the corrected camera JPEG's geometry mismatch. Its empirical table unit is +`15559.300457825264`, fixed by the earlier four-file FE 24-50mm fit before checking other files and lenses. +This is a measured approximation for the tested scope, not a Sony specification or a universal Sony constant. + +For output radius `rho` in native crop pixels: + +``` +spacing = hypot(9504, 6336) / 30 +S(r) = r * (1 + interpolate(table, r / spacing) / 15559.300457825264) +source_radius(rho) = S(rho / zoom) +``` + +Interpolation is linear. The final segment extends beyond the last knot. Positive samples increase the source +radius before the fill zoom; negative samples reduce it. + +The fill zoom makes the maximum of +`S(rho / zoom) / rho` on the rectangular crop border equal to one. The optical center is the default crop's +pixel-center midpoint. The measured crop is 9504 by 6336 at (32, 20). + +Storage padding affects DNG normalization +only, so 9600 by 6376 and 9728 by 6656 storage produce the same physical warp. + +A four-term odd polynomial represents the map as `WarpRectilinear`. A dense 32769-point check rejects a polynomial +that folds or differs from the table by more than 0.25 px at a 1440 px long edge. This approximation check is +separate from agreement with the camera JPEG. The converter applies no shading or lateral CA. + +Validation uses all 131 owner files numerically, nine public raw.pixls.us codings, one independent public FE 50mm +file and the public issue #1300 FE 16-25mm file. Private pixels stay in memory. No private images, names or source +identities are saved in the new measurement artifacts. + +The nine private correction-on files cover the FE 24-50mm +at 24, 49 and 50 mm. The other 122 private files have correction Off and cover the Tamron 35-150mm and the +Voigtlander APO-LANTHAR 28mm and 50mm. Off references validate preservation, not correction-on optics for those lenses. + +Green-channel SIFT (Scale-Invariant Feature Transform) matches at 2376 px are filtered by an independent radial fit. Residuals remove median translation +only. They fit no scale, center or lens coefficient to the candidate. + +High-pass correlation uses held-out pixels +at 1200 px. File checks hold out the second file and then every fifth file in each lens/focal/setting group, and +both public correction-on files. The private checks were also examined in the earlier study, so they are file +checks rather than a fresh independent capture campaign. + +The public second-lens checks do not tune the unit. + +The A7R VI study uses a 0.75 px camera validation budget at 1440 px. The reporter has support to 0.933 half +diagonals. Its direct table's largest radial-bin residual is 0.590 px at 1200 px (0.708 px at 1440 px). + +The implemented polynomial reaches 0.638 px in a radial bin and 0.692 px in a supported edge region at 1200 px +in source coordinates. Actual corrected-raster matches give 0.173/0.605 px median/p95, a 0.763 px radial-bin +maximum and a 0.969 px edge-region maximum at 1200 px. That edge region is 1.162 px at 1440 px, so this does not +pass a strict 0.75 px budget everywhere measured. + +The unit test against the independently fitted smooth feature curve uses the combined 1.0 px table and +polynomial approximation budget; its observed maximum is 0.895 px at 1440 px. These are different metrics. + +The held-out private 24 mm file has weak outer-radius support. No unsupported ideal corner is counted as measured. + +APS-C, aspect crops and lossless M/S are excluded. Portrait captures use the same sensor-coordinate map and the +existing EXIF orientation conversion. Other lenses and the 16-25mm's tele end need more correction-on references. + +No Imaging Edge On/Off pairs were measured for this body. Camera JPEGs are the reference for this change. + +This branch supplies the warp for camera-look pairing in PR #1586. It does not duplicate that pairing code. + +`LOOK_VERSION` 11 refreshes smart previews that stored no A7CR lens data; `RENDER_CACHE_VERSION` 68 redraws cached +renders. + +Existing edits are preserved. Reload from Disk reads the new lens data for old imports. Enable the +profile correction control when an existing edit has it disabled. + ## Local inventory check `crates/raw/tests/sony_lens_inventory.rs` is opt-in and does nothing in CI. With `LIGHTCRAFT_SONY_LENS_DIR` naming a diff --git a/docs/whats-new.md b/docs/whats-new.md index 97e1367a8..04c046636 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## 2026-10-14 +### Sony A7CR distortion correction + +- Full-frame 3:2 Bayer A7CR raws with distortion Auto or forced Auto now use their embedded distortion table. The corrected render is closer to the camera JPEG geometry. Off files keep their original geometry. APS-C, aspect crops and lossless M/S remain excluded. +- The warp also supplies the geometry needed by the lens-aligned camera-look fit in PR #1586. The pairing code is a separate change. Details and numeric validation are in `docs/sony-lens-corrections.md`. + ### Lightroom radial masks on the right side - Radial masks brought over from Lightroom Classic (catalog import, XMP sidecars, edits inside DNGs and presets) now apply their effect inside the circle, as Lightroom does, instead of outside it (issue #1815). A mask inverted in From aecdb99a7a72082ea7db7cfae5a55bbbdba24d63 Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Tue, 13 Oct 2026 18:52:22 +0200 Subject: [PATCH 07/36] LR-PROF-CAMERACOLOR: fit tone for near-black NEFs (#1587) Use luminance pairs after the existing camera looks fail their colour-pair minimum. Keep the source colour, exclude clipped pixels from training, and check held-out luminance and each clipped pixel before accepting the curve. The darkest supplied D610 sample changes from +2.81 to -0.09 stops at the median. The other 15 renders are byte-identical. Add synthetic coverage for source-colour precedence, independent holdout and invalid or clipped input. Bump LOOK_VERSION to 10 and RENDER_CACHE_VERSION to 67 after checking open PRs. Engine tests pass. Workspace tests pass with only the known #1457 overlay test excluded. Formatting, Clippy, HEIC, parity, layers, assets and WASM pass. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/camera_preview.rs | 215 ++++++++++++++++++++++++++-- crates/engine/src/media.rs | 2 +- docs/camera-preview-colour.md | 6 +- docs/parity-checklist.md | 2 +- 4 files changed, 211 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 614c58c60..2a62f667a 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -34,7 +34,8 @@ use lightcraft_raw::{ /// 7: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected. /// 8: the bundled NIKON Z 6_2 profile. /// 9: the bundled NIKON Z 8 and NIKON D5300 profiles. -pub const LOOK_VERSION: u32 = 9; +/// 10: tone-only fallback for near-black scenes with too few colour pairs (#1587). +pub const LOOK_VERSION: u32 = 10; #[derive(Clone, Debug)] pub(crate) struct CameraLook { @@ -88,7 +89,18 @@ pub(crate) fn starting_colour( lens: Option<&LensGeometry>, embedded: Option<&lightcraft_develop::EmbeddedLens>, ) -> (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)); + 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| { + if !file_local_look(raw.format) { + return None; + } + let (sensor, reference, clipped) = proxies(raw, bytes, t, PROXY, 0.0, lens)?; + fit_dark_tone(&sensor, &reference, &clipped) + }, + ); if look.is_none() && dng_tone_eligible(raw, &t) { let look = fit_dng_tone(raw, bytes, &t, embedded); return (xy, t, look); @@ -118,11 +130,12 @@ pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool { } /// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white -/// JPEG's tone fit passed in (tests pin the order with them). +/// JPEG's tone fit and the near-black tone fallback passed in (tests pin the order with them). fn starting_colour_with( raw: &mut RawImage, fit: impl FnOnce(&RawImage, &CameraTransform) -> (Option, bool), monochrome: impl FnOnce(&RawImage, &CameraTransform) -> Option, + dark: impl FnOnce(&RawImage, &CameraTransform) -> Option, ) -> (Xy, CameraTransform, Option) { let start = |raw: &RawImage| { let xy = lightcraft_raw::color::as_shot_white_xy(raw); @@ -146,9 +159,9 @@ fn starting_colour_with( } None => (xy, t), }; - // only a black-and-white JPEG: a colour one that gave no look pays nothing more + // A black-and-white JPEG first tries its existing tone fit on the chosen colour. let look = if black_and_white { monochrome(raw, &t) } else { None }; - (xy, t, look) + (xy, t, look.or_else(|| dark(raw, &t))) } /// The colour a raw starts from ([`starting_colour`]) as DNG colour tags, for a DNG of the raw's camera RGB (Photo @@ -1457,10 +1470,74 @@ fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opti fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option)>, edge_limit: Option) -> Option { // every pair counts: the JPEG has no colour to require let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit, false)?; - let related = rank_correlation_ties(&pairs); + fit_tone_only_pairs(sensor, reference, colour, edge_limit, &pairs, false) +} + +/// A near-black frame may have enough luminance signal for tone but fewer than 256 colour pairs. +/// Try only after every existing look fails. Keep the colour of the source transform, +/// exclude clipped pixels from training, and keep their luminance at least as close to the JPEG +/// as the default curve does. Bright JPEGs need the existing fits to constrain their highlights. +fn fit_dark_tone(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool]) -> Option { + if (sensor.width, sensor.height) != (reference.width, reference.height) + || sensor.data.len() != reference.data.len() + || clipped.len() != sensor.data.len() + || sensor.data.is_empty() + || sensor.data.iter().any(|p| !p.iter().all(|v| v.is_finite())) + || reference.data.iter().any(|p| !p.iter().all(|v| v.is_finite() && *v >= 0.0) || luminance_2020(*p) >= 0.85) + { + return None; + } + let dark = reference.data.iter().filter(|p| luminance_2020(**p) < 0.015).count(); + if (dark as f64) < reference.data.len() as f64 * 0.9 { + return None; + } + [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| { + let pairs: Pairs = sensor + .data + .iter() + .zip(&reference.data) + .enumerate() + .filter(|(i, (input, output))| { + // The same signal floors as the camera toe, without colour ratios or counts. + !clipped.get(*i).copied().unwrap_or(true) + && input.iter().all(|v| *v > 0.0001 && *v < 1.5) + && (0.0003..0.85).contains(&luminance_2020(**output)) + && edge_limit.is_none_or(|limit| local_contrast(sensor, *i) <= limit && local_contrast(reference, *i) <= limit) + }) + .map(|(_, (input, output))| (input.map(f64::from), output.map(f64::from))) + .collect(); + if pairs.len() < 256 { + return None; + } + let look = fit_tone_only_pairs(sensor, reference, &None, edge_limit, &pairs, true)?; + let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + for ((input, target), clipped) in sensor.data.iter().zip(&reference.data).zip(clipped) { + if *clipped { + let target = luma(target.map(f64::from)); + let before = luma(displayed(input.map(f64::from), &neutral)); + let after = luma(displayed(input.map(f64::from), &tone)); + if !after.is_finite() || (after - target).abs() > (before - target).abs() { + return None; + } + } + } + Some(look) + }) +} + +fn fit_tone_only_pairs( + sensor: &Rgb32f, + reference: &Rgb32f, + colour: &Option<(Mat3, Option)>, + edge_limit: Option, + pairs: &Pairs, + dark: bool, +) -> Option { + let kind = if dark { "near-black scene" } else { "black-and-white JPEG" }; + let related = rank_correlation_ties(pairs); if related < PARTIAL_MIN_RANK_CORRELATION { if lightcraft_pipeline::profiling() { - eprintln!("[profile] camera look from a black-and-white JPEG: luminance rank correlation {related:.3}, not the same picture"); + eprintln!("[profile] camera look from a {kind}: luminance rank correlation {related:.3}, not the same picture"); } return None; } @@ -1470,7 +1547,15 @@ fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, let p = matrix.apply(x); correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from)) }; - let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, coloured)?; + let tone = if dark { + // Split this wider set once. The camera toe's separate colour/extra split belongs to + // the original colour pairs and would put some of these held-out pixels into training. + fit_tone( + pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(coloured(*x).map(|v| v.max(0.0))), luma(*y))).collect(), + )? + } else { + fit_tone_with_shadows(ToneFit::Quantile, pairs, sensor, reference, edge_limit, coloured)? + }; let look = CameraLook { matrix, tone, hue_sat: hue_sat.clone() }; let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2); @@ -1485,7 +1570,7 @@ fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, } if lightcraft_pipeline::profiling() { eprintln!( - "[profile] camera look from a black-and-white JPEG: holdout luminance RMS {:.5} -> {:.5} ({samples} pixels{})", + "[profile] camera look from a {kind}: holdout luminance RMS {:.5} -> {:.5} ({samples} pixels{})", (before / samples.max(1) as f64).sqrt(), (after / samples.max(1) as f64).sqrt(), if edge_limit.is_some() { ", away from edges" } else { "" } @@ -2557,6 +2642,7 @@ mod tests { &mut raw, |_, _| panic!("no JPEG fit for a raw with its own matrices"), |_, _| panic!("no black-and-white JPEG fit for a raw with its own matrices"), + |_, _| panic!("no dark tone fit for a raw with its own matrices"), ); assert_eq!(raw.color, color); assert!(!t.matrix_is_fallback && look.is_none()); @@ -2578,6 +2664,7 @@ mod tests { (Some(fitted.clone()), false) }, |_, _| panic!("no black-and-white JPEG fit after an accepted fit"), + |_, _| panic!("no dark tone fit after an accepted fit"), ); assert_eq!(look.map(|l| l.matrix), Some(fitted.matrix), "{model}"); assert_eq!(raw.color, lightcraft_raw::ColorData::default(), "{model}: spectral matrices left out"); @@ -2600,7 +2687,8 @@ mod tests { let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap(); let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default()); - let (xy, t, look) = starting_colour_with(&mut raw, |_, _| (None, false), |_, _| panic!("a colour JPEG never takes the black-and-white step")); + let (xy, t, look) = + starting_colour_with(&mut raw, |_, _| (None, false), |_, _| panic!("a colour JPEG never takes the black-and-white step"), |_, _| None); assert!(look.is_none()); assert_eq!(raw.color.illuminant, [17, 21]); assert_eq!(raw.color.forward_matrix, camera.forward_matrix.map(Some)); @@ -2630,6 +2718,7 @@ mod tests { assert!(!t.matrix_is_fallback && raw.color.forward_matrix == camera.forward_matrix.map(Some), "the spectral matrices are in"); Some(toned.clone()) }, + |_, _| panic!("an accepted monochrome look keeps its tone"), ); assert!(look.as_ref().is_some_and(|l| l.matrix == Mat3::IDENTITY && l.hue_sat.is_none()), "the tone curve on top"); assert!(!t.matrix_is_fallback); @@ -2643,6 +2732,7 @@ mod tests { assert!(t.matrix_is_fallback, "outside the table: the neutral fallback's colour"); Some(toned.clone()) }, + |_, _| panic!("an accepted monochrome look keeps its tone"), ); assert!(look.is_some() && t.matrix_is_fallback); assert_eq!(raw.color, lightcraft_raw::ColorData::default()); @@ -3593,6 +3683,110 @@ mod tests { /// An overcast, nearly colourless scene (under 5% of the pixels coloured, so no colour matrix is /// learnt and the full look is never tried) whose camera renders it two stops brighter than the /// neutral fallback: the partial look is the tone curve (and chroma curve) alone. + fn near_black_scene() -> (Rgb32f, Rgb32f) { + let mut sensor = Rgb32f::new(96, 64); + let mut reference = sensor.clone(); + for (i, (input, output)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let y = if i % 60 == 0 { 0.3 + (i % 7) as f32 * 0.01 } else { 0.006 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.075 }; + let rgb = [1.15, 1.0, 0.75]; + *input = rgb.map(|v| v * y / luminance_2020(rgb)); + *output = input.map(|v| v * 0.08); + } + (sensor, reference) + } + + #[test] + fn a_near_black_scene_gets_tone_without_learning_colour() { + let (sensor, reference) = near_black_scene(); + assert!(collect_pairs(&sensor, &reference, 0.005, None, false).is_none(), "too few midtones even for the partial look"); + let mut raw = raw_of(RawFormat::Nef, "NIKON CORPORATION", "NIKON D610", Default::default()); + let (_, _, look) = starting_colour_with( + &mut raw, + |_, _| (fit_look(&sensor, &reference, &vec![false; sensor.data.len()], None), false), + |_, _| None, + |_, _| fit_dark_tone(&sensor, &reference, &vec![false; sensor.data.len()]), + ); + let look = look.expect("tone from the dark pixels"); + assert_eq!(look.matrix, Mat3::IDENTITY); + assert!(look.hue_sat.is_none()); + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let error = |tone: &ToneMap| { + sensor + .data + .iter() + .zip(&reference.data) + .map(|(x, y)| (luma(displayed(x.map(f64::from), tone)) - luma(y.map(f64::from))).powi(2)) + .sum::() + }; + assert!(error(&tone) < error(&ToneMap::new(0.0, 0.0, 0.0)) * 0.001); + assert_eq!(look.tone.apply(0.0), 0.0); + let mut previous = 0.0; + for i in 0..10_000 { + let y = look.tone.apply(i as f32 / 10_000.0); + assert!(y.is_finite() && y >= previous && y <= 1.0); + previous = y; + } + // Models with spectral matrices keep them before the dark tone is fitted. + let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default()); + let (_, t, look) = starting_colour_with( + &mut raw, + |_, _| (None, false), + |_, _| None, + |raw, t| { + assert!(!t.matrix_is_fallback && lightcraft_raw::color::has_matrix(&raw.color)); + fit_dark_tone(&sensor, &reference, &vec![false; sensor.data.len()]) + }, + ); + assert!(!t.matrix_is_fallback); + assert_eq!(look.expect("tone on top of spectral colour").matrix, Mat3::IDENTITY); + } + + #[test] + fn dark_tone_holdout_never_enters_training() { + let (sensor, mut reference) = near_black_scene(); + let none = vec![false; sensor.data.len()]; + let original = fit_dark_tone(&sensor, &reference, &none).expect("dark tone"); + // Every pixel clears the wider signal floors. Changing only its held-out third must + // change the measured error, but cannot change the fitted curve. + for p in reference.data.iter_mut().step_by(3) { + *p = p.map(|v| v * 1.03); + } + let changed = fit_dark_tone(&sensor, &reference, &none).expect("still clearly better than the fallback"); + assert_eq!(changed.tone, original.tone); + } + + #[test] + fn dark_tone_requires_signal_correlation_and_unclipped_highlights() { + let (sensor, reference) = near_black_scene(); + let none = vec![false; sensor.data.len()]; + let mut unrelated = reference.clone(); + for (i, p) in unrelated.data.iter_mut().enumerate() { + *p = reference.data[(i * 3571) % reference.data.len()]; + } + assert!(fit_dark_tone(&sensor, &unrelated, &none).is_none(), "unrelated dark JPEG"); + let mut flat = reference.clone(); + flat.data.fill([0.002; 3]); + assert!(fit_dark_tone(&sensor, &flat, &none).is_none(), "a quantized flat JPEG cannot teach tone"); + let mut sparse = reference.clone(); + sparse.data.iter_mut().skip(80).for_each(|p| *p = [0.0; 3]); + assert!(fit_dark_tone(&sensor, &sparse, &none).is_none(), "fewer than 256 usable pairs"); + let mut bright = reference.clone(); + bright.data[0] = [0.95; 3]; + assert!(fit_dark_tone(&sensor, &bright, &none).is_none(), "dark pairs cannot constrain a bright highlight"); + let mut clipped = none; + clipped[0] = true; + let original = fit_dark_tone(&sensor, &reference, &clipped).expect("tone without the clipped pixel"); + let mut changed = sensor.clone(); + changed.data[0] = [0.18; 3]; + let look = fit_dark_tone(&changed, &reference, &clipped).expect("the clipped pixel is still closer to its JPEG"); + assert_eq!(look.tone, original.tone, "clipped samples cannot change the fitted curve"); + let mut highlight = reference.clone(); + highlight.data[0] = [0.8; 3]; + assert!(fit_dark_tone(&changed, &highlight, &clipped).is_none(), "the curve cannot darken a clipped highlight away from the JPEG"); + assert!(fit_dark_tone(&sensor, &Rgb32f::new(64, 96), &clipped).is_none(), "mismatched dimensions"); + assert!(fit_dark_tone(&sensor, &reference, &[]).is_none(), "mismatched clip mask"); + } + #[test] fn partial_look_brightens_a_dull_scene_without_learning_colour() { let (w, h) = (96usize, 64usize); @@ -3739,6 +3933,7 @@ mod tests { assert!(!t.matrix_is_fallback, "on the spectral matrices"); own.clone() }, + |_, _| None, ); assert!(look.is_some() && !t.matrix_is_fallback); } diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 22d76528d..b911f7d2d 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 64; +pub const RENDER_CACHE_VERSION: u64 = 67; /// 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 9a259e9ed..7d7582a62 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -35,7 +35,9 @@ The wider set needs at least 96 dark samples (JPEG luminance below 0.015), inclu If the original curve cannot be fitted, the wider curve can still be accepted after the sample-count and rank checks. In either case, the completed look must pass its existing held-out gates: a full look needs finite errors, linear-display squared error below 70% of the neutral fallback's and per-channel RMS no greater than 0.10. Partial and monochrome tone-only looks need luminance rank correlation of at least 0.5 and finite squared errors below 70% of the fallback's in both linear and gamma-encoded values. The monochrome comparison uses luminance; the full and partial comparisons use colour channels. Colour-pair selection, colour fitting and the original colour training split are unchanged. -The extra dark knots move the straight-line extension down toward black without a new serialized curve format or a different CPU/GPU interpolation rule. `LOOK_VERSION` 5 refreshes older fitted smart previews when their originals are online; `RENDER_CACHE_VERSION` 62 redraws cached renders. +A near-black frame can have fewer than 256 colour pairs, so none of those attempts reaches tone fitting (#1587). After all existing looks fail, a tone-only fallback uses the wider signal floors above. At least 90% of the JPEG proxy must be below luminance 0.015. It excludes clipped sensor pixels from training and refuses invalid samples and JPEG highlights at or above 0.85. Every clipped pixel must stay at least as close to its JPEG luminance as the default curve leaves it. At least 256 usable pairs must show luminance rank correlation of 0.5 or more. Two thirds train a quantile curve; the held-out third must reduce squared luminance error by 30% in both linear and gamma-encoded values. This fallback keeps the source's spectral or neutral colour and learns no matrix, hue/saturation table or chroma curve. Earlier accepted looks keep their result. + +The extra dark knots move the straight-line extension down toward black without a new serialized curve format or a different CPU/GPU interpolation rule. `LOOK_VERSION` 10 refreshes older fitted smart previews when their originals are online; `RENDER_CACHE_VERSION` 67 redraws cached renders. No process-version bump is needed under the [rule for changes to source data](process-versions.md#the-rule). The decoded source carries the fitted camera tone in `SourceInfo`; both V1 and V2 use it in `finish::base_tone`. Existing raws edited under V1 or V2 can therefore change when decoded again. Offline smart previews keep their stored pixels and tone until rebuilt from the original. The scan on library opening runs once per look version for each smart previews folder. If an original was offline at that scan, run Build Smart Previews after reconnecting it. @@ -187,7 +189,7 @@ A raw starts from the first of these that is available (`camera_preview::startin 4. the model's spectral matrices, with LightCraft's default tone curve and no JPEG fit; 5. the neutral fallback. -When the file's JPEG is black and white, step 2 fits the tone curve alone with the profile's colour; without a profile, or when that fit fails, 4 and 5 take the tone curve fitted to it (see the black-and-white JPEG paragraph above). +When the file's JPEG is black and white, step 2 fits the tone curve alone with the profile's colour; without a profile, or when that fit fails, 4 and 5 take the tone curve fitted to it (see the black-and-white JPEG paragraph above). If every existing JPEG fit fails, a near-black frame may take the guarded tone-only fallback described above, after its spectral or neutral colour has been chosen. The spectral matrices only replace the neutral fallback: a photo whose JPEG fit is accepted (with or without a profile) renders exactly as before, and a covered model takes its spectral matrices only when that fit fails the diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..156eb14a8 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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); 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); bundled NIKON Z 6_2 profile (296 photos; 59 held-back photos mean ΔE 2.73 → 2.54) NIKON Z 8 profile (261 photos; 55 held back 2.55 → 2.33) and NIKON D5300 profile (264 photos; 53 held back 2.86 → 2.66) | +| 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); near-black frames with too few colour pairs can take a guarded tone-only fallback on their existing source colour (#1587); 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); bundled NIKON Z 6_2 profile (296 photos; 59 held-back photos mean ΔE 2.73 → 2.54) NIKON Z 8 profile (261 photos; 55 held back 2.55 → 2.33) and NIKON D5300 profile (264 photos; 53 held back 2.86 → 2.66) | | 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 | From 749636fb2ec82518b4de2eb985cc419212eae4cf Mon Sep 17 00:00:00 2001 From: radialmonster Date: Fri, 9 Oct 2026 23:04:23 -0400 Subject: [PATCH 08/36] LR-IMP-FORMATS: decode table-less Nikon 34713 NEFs stored as packed 12/14-bit data --- crates/engine/src/media.rs | 2 +- crates/raw/src/vendor/nef.rs | 223 ++++++++++++++++++++++++++++++++++- 2 files changed, 218 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 22d76528d..e6c438ad7 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 64; +pub const RENDER_CACHE_VERSION: u64 = 65; /// 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/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 5076c6df9..5fbf83414 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -7,6 +7,9 @@ //! rule documented there; files it can't decode are reported as [`RawError::Unsupported`] and their embedded previews still work. So are High Efficiency NEFs //! (HE / HE★, Z 8, Z 9, Z 6III, Z f): they keep compression 34713 but carry a wavelet codestream that starts with //! the JPEG XS markers SOC + CAP (ISO/IEC 21122-1, `FF10 FF50`) and have no `0x0096` table (issue #193). +//! Strips labelled 34713 without a `0x0096` +//! linearization table are packed uncompressed data in several measured layouts ([`PackedLayout`]); Nikon small raw +//! (sRAW) and the D1X's genuinely Huffman-coded strip stay refused. use super::{nefc, white_from_data}; use crate::tiffraw::{Packing, check_image, read_image}; @@ -342,8 +345,83 @@ fn uncompressed(bytes: &[u8], info: &ImageInfo, order: ByteOrder, mn: Option<&ma read_image(bytes, &top, order, packing) } -/// Nikon Huffman-compressed strip (see [`super::nefc`]); the decode table is maker note `0x0096`. Decodes the first -/// `rows` rows of the `info.height`. +/// Packed layouts of strips that are labelled compression 34713 but have no linearization table (maker note +/// `0x0096`): they hold plain packed samples, not Huffman codes. The layouts were measured from CC0 raw.pixls.us +/// files by a clean-room black-box analysis (strip byte-count identity, same-colour smoothness and correlation with +/// the camera JPEG against at least eight alternative packings). Every layout is a single strip with +/// `stride = StripByteCounts / height`, selected only when `stride * height` equals the strip size exactly. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PackedLayout { + /// LSB-first bit stream of `bits` per sample (12: 3 bytes per 2 samples, 14: 7 bytes per 4), rows byte aligned. + /// 12-bit rows are `3w/2` bytes (older bodies) or padded to 16 bytes (Z 6, D6); 14-bit rows are `7w/4` bytes + /// padded to 16 bytes. + Lsb { stride: usize }, + /// D100: 16-byte chunks of fifteen data bytes holding ten MSB-first 12-bit samples, plus one zero pad byte. The + /// rows carry more samples than the IFD width (3040 against 3034); the extra columns are not image. + Chunked16 { stride: usize }, +} + +fn packed_layout(w: usize, h: usize, bits: u32, strip: u64) -> Option { + if w == 0 || h == 0 || !matches!(bits, 12 | 14) { + return None; + } + let strip = usize::try_from(strip).ok()?; + if !strip.is_multiple_of(h) { + return None; + } + let stride = strip / h; + let tight = w.checked_mul(bits as usize)?.div_ceil(8); + let tight = if bits == 12 && w.is_multiple_of(2) || bits == 14 && w.is_multiple_of(4) { tight } else { return None }; + let padded = tight.checked_next_multiple_of(16)?; + let lsb_ok = match bits { + 12 => stride == tight || stride == padded, + _ => stride == padded, + }; + if lsb_ok { + return Some(PackedLayout::Lsb { stride }); + } + None +} + +fn d100_layout(w: usize, h: usize, bits: u32, strip: u64) -> Option { + let strip = usize::try_from(strip).ok()?; + if bits != 12 || h == 0 || !strip.is_multiple_of(h) { + return None; + } + let stride = strip / h; + let per_row = stride / 16 * 10; + (stride.is_multiple_of(16) && per_row >= w && per_row < w.checked_add(10)?).then_some(PackedLayout::Chunked16 { stride }) +} + +/// Unpack a [`PackedLayout`] strip into `w * h` samples. Short input leaves the missing samples zero. +fn unpack_packed(src: &[u8], w: usize, h: usize, bits: u32, layout: PackedLayout) -> Vec { + use rayon::prelude::*; + let mut out = vec![0u16; w * h]; + let stride = match layout { + PackedLayout::Lsb { stride } | PackedLayout::Chunked16 { stride } => stride, + }; + out.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let Some(line) = src.get(y * stride..) else { return }; + let line = &line[..stride.min(line.len())]; + match layout { + PackedLayout::Lsb { .. } => crate::unpack::unpack_lsb(line, bits, row), + PackedLayout::Chunked16 { .. } => { + let mut samples = vec![0u16; line.len() / 16 * 10]; + for (i, dst) in samples.chunks_mut(10).enumerate() { + if let Some(chunk) = line.get(i * 16..i * 16 + 15) { + crate::unpack::unpack_msb(chunk, 12, dst); + } + } + let n = row.len().min(samples.len()); + row[..n].copy_from_slice(&samples[..n]); + } + } + }); + out +} + +/// Nikon Huffman-compressed strip (see [`super::nefc`]); the decode table is maker note `0x0096`. Without a table the +/// strip may be packed uncompressed data (see [`PackedLayout`]). fn compressed(bytes: &[u8], info: &ImageInfo, mn: Option<&makernote::MakerNote>, rows: usize) -> Result { let starts_with = |magic: &[u8]| { let start = info.chunks(bytes.len() as u64).first().and_then(|c| usize::try_from(c.offset).ok()); @@ -352,10 +430,9 @@ fn compressed(bytes: &[u8], info: &ImageInfo, mn: Option<&makernote::MakerNote>, if starts_with(&JPEG_XS_START) { return Err(RawError::Unsupported(HIGH_EFFICIENCY.into())); } - // (some Z bodies label uncompressed, row-padded data 34713 without a table: not handled here yet) - let table = mn - .and_then(|m| Some((m.ifd.bytes(LINEARIZATION_TABLE)?, m.order))) - .ok_or_else(|| RawError::Unsupported("Nikon compressed NEF without a linearization table (maker note 0x96)".into()))?; + let Some(table) = mn.and_then(|m| Some((m.ifd.bytes(LINEARIZATION_TABLE)?, m.order))) else { + return packed(bytes, info, rows); + }; if info.samples_per_pixel != 1 { return Err(RawError::Unsupported(format!("Nikon compressed NEF with {} samples per pixel", info.samples_per_pixel))); } @@ -370,6 +447,32 @@ fn compressed(bytes: &[u8], info: &ImageInfo, mn: Option<&makernote::MakerNote>, Ok(RawData::U16(nefc::decode_rows(src, info.width as usize, info.height as usize, rows, bits, &table)?)) } +/// A table-less "compressed" strip: packed samples when the strip size matches a measured layout exactly. +fn packed(bytes: &[u8], info: &ImageInfo, rows: usize) -> Result { + let (w, h) = (info.width as usize, info.height as usize); + let bits = info.bits() as u32; + let chunks = info.chunks(bytes.len() as u64); + let layout = match chunks.as_slice() { + [c] if info.samples_per_pixel == 1 => packed_layout(w, h, bits, c.len).or_else(|| d100_layout(w, h, bits, c.len)), + _ => None, + }; + let (Some(layout), [c]) = (layout, chunks.as_slice()) else { + let small = bits == 12 && chunks.len() == 1 && w.checked_mul(h).and_then(|n| n.checked_mul(3)).is_some_and(|n| u64::try_from(n).ok() == Some(chunks[0].len)); + return Err(RawError::Unsupported(if small { + "Nikon small raw (sRAW) not decoded yet".into() + } else { + "Nikon compressed NEF without a linearization table (maker note 0x96)".into() + })); + }; + let end = c.offset.saturating_add(c.len).min(bytes.len() as u64); + let src = usize::try_from(c.offset).ok().zip(usize::try_from(end).ok()).and_then(|(a, b)| bytes.get(a..b)); + let src = src.ok_or_else(|| RawError::Corrupt("NEF: image data outside the file".into()))?; + if (w as u64).saturating_mul(h as u64) > crate::MAX_SAMPLES as u64 { + return Err(RawError::Limit("image too large")); + } + Ok(RawData::U16(unpack_packed(src, w, rows.min(h), bits, layout))) +} + #[cfg(test)] mod tests { use super::*; @@ -819,4 +922,112 @@ mod tests { let short = nef(34713, 14, vec![vec![0xff, 0x10]], 1, 1, 1); assert!(matches!(crate::decode(&short), Err(RawError::Unsupported(w)) if w.contains("linearization table"))); } + + fn samples(n: usize, bits: u32) -> Vec { + (0..n).map(|i| (((i * 2654435761usize) >> 7) as u32 % (1 << bits)) as u16).collect() + } + + fn pack_lsb(row: &[u16], bits: u32) -> Vec { + let (mut out, mut acc, mut n) = (Vec::new(), 0u64, 0u32); + for &v in row { + acc |= (v as u64) << n; + n += bits; + while n >= 8 { + out.push(acc as u8); + acc >>= 8; + n -= 8; + } + } + if n > 0 { + out.push(acc as u8); + } + out + } + + fn pack_msb12(row: &[u16]) -> Vec { + let mut out = Vec::new(); + for pair in row.chunks(2) { + let (a, b) = (pair[0], pair.get(1).copied().unwrap_or(0)); + out.extend([(a >> 4) as u8, ((a & 15) << 4) as u8 | (b >> 8) as u8, b as u8]); + } + out + } + + /// Rows of `samples` packed by `row`, each padded with `pad` bytes of 0xA5 (never decoded). + fn strip(data: &[u16], w: usize, pad: usize, row: impl Fn(&[u16]) -> Vec) -> Vec { + data.chunks(w).flat_map(|r| row(r).into_iter().chain(std::iter::repeat_n(0xA5, pad))).collect() + } + + fn decoded(strip: Vec, bits: u16, w: u32, h: u32) -> Result> { + let bytes = nef(34713, bits, vec![strip], w, h, h); + match crate::decode(&bytes)?.data { + RawData::U16(d) => Ok(d), + _ => Err(RawError::Unsupported("float".into())), + } + } + + #[test] + fn packed_34713_layouts_round_trip() { + let (w, h) = (40usize, 6usize); + let d12 = samples(w * h, 12); + let d14 = samples(w * h, 14); + // group A: 12-bit, 3w/2 bytes per row + assert_eq!(decoded(strip(&d12, w, 0, |r| pack_lsb(r, 12)), 12, 40, 6).unwrap(), d12); + // group B: 12-bit, rows padded to 16 bytes (60 -> 64) + assert_eq!(decoded(strip(&d12, w, 4, |r| pack_lsb(r, 12)), 12, 40, 6).unwrap(), d12); + // group C: 14-bit, 70 bytes -> 80 + assert_eq!(decoded(strip(&d14, w, 10, |r| pack_lsb(r, 14)), 14, 40, 6).unwrap(), d14); + // group E: 16-byte chunks, 15 data bytes (ten samples) + a zero byte; 16 chunks = 160 samples (at 40 wide the + // 64-byte stride would be group B's) + let (w, h) = (160usize, 3usize); + let d12 = samples(w * h, 12); + let e = strip(&d12, w, 0, |r| r.chunks(10).flat_map(|c| pack_msb12(c).into_iter().chain([0u8])).collect()); + assert_eq!(e.len(), 256 * h); + assert_eq!(decoded(e, 12, 160, 3).unwrap(), d12); + } + + #[test] + fn packed_d100_rows_carry_extra_columns() { + // IFD width 154, rows hold 160 samples (16 chunks); the last 6 are dropped + let (w, wide, h) = (154usize, 160usize, 3usize); + let wide_data = samples(wide * h, 12); + let e = strip(&wide_data, wide, 0, |r| r.chunks(10).flat_map(|c| pack_msb12(c).into_iter().chain([0u8])).collect()); + let got = decoded(e, 12, w as u32, h as u32).unwrap(); + let want: Vec = wide_data.chunks(wide).flat_map(|r| r[..w].iter().copied()).collect(); + assert_eq!(got, want); + } + + #[test] + fn packed_34713_wrong_sizes_stay_refused() { + let d12 = samples(40 * 6, 12); + let good = strip(&d12, 40, 0, |r| pack_lsb(r, 12)); + // one byte more or fewer, a stride that is neither tight nor 16-aligned, a 13-bit file + for len in [good.len() - 1, good.len() + 1, good.len() + 6] { + let mut s = good.clone(); + s.resize(len, 0); + assert!(matches!(decoded(s, 12, 40, 6), Err(RawError::Unsupported(_))), "len {len}"); + } + assert!(matches!(decoded(good.clone(), 13, 40, 6), Err(RawError::Unsupported(_)))); + // 14-bit rows must be padded to 16 bytes + let d14 = samples(40 * 6, 14); + assert!(matches!(decoded(strip(&d14, 40, 0, |r| pack_lsb(r, 14)), 14, 40, 6), Err(RawError::Unsupported(_)))); + // 3 bytes per IFD-width pixel is a Nikon small raw: still refused, with its own reason + let Err(RawError::Unsupported(why)) = decoded(vec![0; 3 * 40 * 6], 12, 40, 6) else { panic!("small raw decoded") }; + assert!(why.contains("sRAW"), "{why}"); + } + + #[test] + fn packed_34713_short_input_does_not_panic() { + let (w, h) = (40usize, 6usize); + for layout in [PackedLayout::Lsb { stride: 64 }, PackedLayout::Lsb { stride: 80 }, PackedLayout::Chunked16 { stride: 64 }] { + for len in [0, 1, 15, 16, 17, 63, 64, 65, 200] { + let bits = if matches!(layout, PackedLayout::Lsb { stride: 80 }) { 14 } else { 12 }; + assert_eq!(unpack_packed(&vec![0xFF; len], w, h, bits, layout).len(), w * h); + } + } + // a strip that claims more bytes than the file has + let mut bytes = nef(34713, 12, vec![vec![0x55; 60 * 6]], 40, 6, 6); + bytes.truncate(bytes.len() - 100); + let _ = crate::decode(&bytes); + } } From 37fea78c14c4935c35b406355ec3645bfaff1840 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Sat, 10 Oct 2026 13:42:42 -0400 Subject: [PATCH 09/36] LR-IMP-FORMATS: decode Coolpix NRWs stored as 16-bit big-endian words under 34713 The Coolpix P330, P7700 and P7800 NRWs carry compression 34713 with no maker note 0x0096 table, 12 bits, one sample per pixel and a single strip of exactly width * height * 2 bytes. The strip is one big-endian 16-bit word per pixel, row-major with no header or padding; the sample is the word >> 4. The layout was found by black-box analysis of the CC0 raw.pixls.us files (strip byte-count identity, same-colour smoothness, correlation with the embedded JPEG), not from any other converter's code. The group is tried after the other packed layouts, so it cannot take a strip they handle. Black level: these files have no 0x003d and no NRW block and no masked pixels, so it is 200 (the floor of the P7800 frame is 201; the other Coolpix NRWs state 200), never above the darkest sample. White comes from the data. Other Nikon NEF/NRW files decode identically (278 checked). Bumps RENDER_CACHE_VERSION to 52. --- crates/raw/src/vendor/nef.rs | 97 ++++++++++++++++++++++++++++++++++-- crates/raw/tests/nrw_be16.rs | 49 ++++++++++++++++++ 2 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 crates/raw/tests/nrw_be16.rs diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 5fbf83414..1665afe65 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -256,7 +256,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { values: [a, b, c, d].iter().map(|v| (**v * black_scale) as f32).collect(), ..Default::default() }, - _ => BlackLevel::uniform(nrw_black(mn.as_ref(), bits).unwrap_or(0.0)), + _ => BlackLevel::uniform(nrw_black(mn.as_ref(), bits).unwrap_or_else(|| be16_black(&info, bytes.len() as u64, mn.as_ref(), samples))), }; let wb = mn .as_ref() @@ -359,6 +359,9 @@ enum PackedLayout { /// D100: 16-byte chunks of fifteen data bytes holding ten MSB-first 12-bit samples, plus one zero pad byte. The /// rows carry more samples than the IFD width (3040 against 3034); the extra columns are not image. Chunked16 { stride: usize }, + /// Coolpix NRW: one big-endian 16-bit word per pixel, rows `2w` bytes with no header or padding; the 12-bit sample + /// is the upper 12 bits of the word (the low nibble is zero, rarely 8). + Be16, } fn packed_layout(w: usize, h: usize, bits: u32, strip: u64) -> Option { @@ -393,18 +396,50 @@ fn d100_layout(w: usize, h: usize, bits: u32, strip: u64) -> Option= w && per_row < w.checked_add(10)?).then_some(PackedLayout::Chunked16 { stride }) } +/// 12-bit strips of exactly `2 * w * h` bytes: big-endian 16-bit words (Coolpix P330, P7700, P7800 NRW, found by a +/// clean-room black-box analysis of the CC0 files). Tried after the other layouts, whose strides (`1.5w` to +/// `1.5w + 15`, or ten samples per 16 bytes) only reach `2w` for rows narrower than 40 samples, so it can't take a +/// strip they handle. +fn be16_layout(w: usize, h: usize, bits: u32, strip: u64) -> Option { + let want = w.checked_mul(h)?.checked_mul(2)?; + (bits == 12 && w > 0 && h > 0 && usize::try_from(strip).ok()? == want).then_some(PackedLayout::Be16) +} + +/// Black level of the big-endian 16-bit layout, which carries neither `0x003d` nor an NRW block and has no masked +/// pixels (the borders of the frame are image). The darkest samples of the three measured bodies are 214 (P330), +/// 217 (P7700) and 201 (P7800, the only one with a dark enough frame: its per-channel 1st percentile is 209 / 225 / +/// 223), and the other 12-bit Coolpix NRWs state 200 in their NRW block; so 200, never above the darkest sample. +const BE16_BLACK: f32 = 200.0; + +fn be16_black(info: &ImageInfo, file_len: u64, mn: Option<&makernote::MakerNote>, samples: &[u16]) -> f32 { + let has_table = mn.is_some_and(|m| m.ifd.bytes(LINEARIZATION_TABLE).is_some()); + let single = matches!(info.chunks(file_len).as_slice(), [c] if be16_layout(info.width as usize, info.height as usize, info.bits() as u32, c.len).is_some()); + if info.compression != t::compression::NIKON || has_table || info.samples_per_pixel != 1 || !single { + return 0.0; + } + let darkest = samples.iter().copied().min().unwrap_or(0); + BE16_BLACK.min(f32::from(darkest)) +} + /// Unpack a [`PackedLayout`] strip into `w * h` samples. Short input leaves the missing samples zero. fn unpack_packed(src: &[u8], w: usize, h: usize, bits: u32, layout: PackedLayout) -> Vec { use rayon::prelude::*; let mut out = vec![0u16; w * h]; let stride = match layout { PackedLayout::Lsb { stride } | PackedLayout::Chunked16 { stride } => stride, + PackedLayout::Be16 => w.saturating_mul(2), }; out.par_chunks_mut(w).enumerate().for_each(|(y, row)| { let Some(line) = src.get(y * stride..) else { return }; let line = &line[..stride.min(line.len())]; match layout { PackedLayout::Lsb { .. } => crate::unpack::unpack_lsb(line, bits, row), + PackedLayout::Be16 => { + let (words, _) = line.as_chunks::<2>(); + for (dst, word) in row.iter_mut().zip(words) { + *dst = u16::from_be_bytes(*word) >> 4; + } + } PackedLayout::Chunked16 { .. } => { let mut samples = vec![0u16; line.len() / 16 * 10]; for (i, dst) in samples.chunks_mut(10).enumerate() { @@ -453,7 +488,9 @@ fn packed(bytes: &[u8], info: &ImageInfo, rows: usize) -> Result { let bits = info.bits() as u32; let chunks = info.chunks(bytes.len() as u64); let layout = match chunks.as_slice() { - [c] if info.samples_per_pixel == 1 => packed_layout(w, h, bits, c.len).or_else(|| d100_layout(w, h, bits, c.len)), + [c] if info.samples_per_pixel == 1 => { + packed_layout(w, h, bits, c.len).or_else(|| d100_layout(w, h, bits, c.len)).or_else(|| be16_layout(w, h, bits, c.len)) + } _ => None, }; let (Some(layout), [c]) = (layout, chunks.as_slice()) else { @@ -1016,10 +1053,64 @@ mod tests { assert!(why.contains("sRAW"), "{why}"); } + /// Big-endian 16-bit words holding the 12-bit samples in their upper bits. + fn be16(data: &[u16]) -> Vec { + data.iter().flat_map(|v| (v << 4).to_be_bytes()).collect() + } + + #[test] + fn coolpix_be16_words_decode_and_wrong_sizes_are_refused() { + let (w, h) = (40usize, 6usize); + let d12 = samples(w * h, 12); + let good = be16(&d12); + assert_eq!(good.len(), 2 * w * h); + assert_eq!(decoded(good.clone(), 12, 40, 6).unwrap(), d12); + // a half-LSB in the low nibble does not change the sample + let mut half = good.clone(); + for b in half.iter_mut().skip(1).step_by(2) { + *b |= 8; + } + assert_eq!(decoded(half, 12, 40, 6).unwrap(), d12); + // any other strip size, or another bit depth, is not this layout + for len in [good.len() - 2, good.len() - 1, good.len() + 1, good.len() + 2, good.len() + 16] { + let mut s = good.clone(); + s.resize(len, 0); + assert!(matches!(decoded(s, 12, 40, 6), Err(RawError::Unsupported(_))), "len {len}"); + } + assert!(matches!(decoded(good.clone(), 13, 40, 6), Err(RawError::Unsupported(_)))); + // the black level is 200, but never above the darkest sample + let black = |data: &[u16]| crate::decode(&nef(34713, 12, vec![be16(data)], 40, 6, 6)).unwrap().black.values[0]; + let mut d = d12.clone(); + for v in &mut d { + *v = (*v).max(300); + } + assert_eq!(black(&d), 200.0); + d[5] = 150; + assert_eq!(black(&d), 150.0); + } + + #[test] + fn coolpix_be16_does_not_take_other_layouts() { + // every width where another group's stride equals 2w must still go to that group (the narrow rows where they + // coincide are below the real-world widths; the other groups are tried first) + for w in (2..=400usize).step_by(2) { + let strip = 2 * w as u64 * 6; + let other = packed_layout(w, 6, 12, strip).or_else(|| d100_layout(w, 6, 12, strip)); + if other.is_some() { + assert_eq!(packed_layout(w, 6, 12, strip).or_else(|| d100_layout(w, 6, 12, strip)).or_else(|| be16_layout(w, 6, 12, strip)), other); + } + } + assert_eq!(be16_layout(4032, 3024, 12, 24385536), Some(PackedLayout::Be16)); + assert!(packed_layout(4032, 3024, 12, 24385536).is_none() && d100_layout(4032, 3024, 12, 24385536).is_none()); + assert!(be16_layout(4032, 3024, 14, 24385536).is_none()); + assert!(be16_layout(usize::MAX, usize::MAX, 12, u64::MAX).is_none()); + } + #[test] fn packed_34713_short_input_does_not_panic() { let (w, h) = (40usize, 6usize); - for layout in [PackedLayout::Lsb { stride: 64 }, PackedLayout::Lsb { stride: 80 }, PackedLayout::Chunked16 { stride: 64 }] { + for layout in [PackedLayout::Lsb { stride: 64 }, PackedLayout::Lsb { stride: 80 }, PackedLayout::Chunked16 { stride: 64 }, PackedLayout::Be16] + { for len in [0, 1, 15, 16, 17, 63, 64, 65, 200] { let bits = if matches!(layout, PackedLayout::Lsb { stride: 80 }) { 14 } else { 12 }; assert_eq!(unpack_packed(&vec![0xFF; len], w, h, bits, layout).len(), w * h); diff --git a/crates/raw/tests/nrw_be16.rs b/crates/raw/tests/nrw_be16.rs new file mode 100644 index 000000000..73bf3dcc5 --- /dev/null +++ b/crates/raw/tests/nrw_be16.rs @@ -0,0 +1,49 @@ +//! Coolpix NRW files stored as big-endian 16-bit words under compression 34713 (P330, P7700, P7800; CC0 samples +//! from raw.pixls.us). Reads `*.nrw` below `LIGHTCRAFT_NRW_DIR` (default `corpus/raw`), recursively; skips cleanly +//! when no such file is there. + +use lightcraft_raw::{RawData, decode}; +use std::path::{Path, PathBuf}; + +fn nrw_files(dir: &Path, depth: usize, out: &mut Vec) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for p in rd.flatten().map(|e| e.path()) { + if p.is_dir() && depth < 3 { + nrw_files(&p, depth + 1, out); + } else if p.extension().is_some_and(|e| e.eq_ignore_ascii_case("nrw")) { + out.push(p); + } + } +} + +#[test] +fn coolpix_nrw_be16_decodes() { + let root = + std::env::var_os("LIGHTCRAFT_NRW_DIR").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/raw")); + let mut files = Vec::new(); + nrw_files(&root, 0, &mut files); + let mut checked = 0; + for p in files { + let Ok(bytes) = std::fs::read(&p) else { continue }; + let Ok(img) = decode(&bytes) else { continue }; + let model = img.metadata.model.clone().unwrap_or_default(); + if !["P330", "P7700", "P7800"].iter().any(|m| model.contains(m)) { + continue; + } + let RawData::U16(d) = &img.data else { panic!("{model}: not integer data") }; + assert_eq!((img.width, img.height, img.bits), (4032, 3024, 12), "{model}"); + assert!(d.iter().all(|&v| v < 4096), "{model}: sample above 12 bits"); + assert_eq!(img.black.values.first().copied(), Some(200.0), "{model}"); + // same-colour neighbours are smooth: the upper 12 bits of the word, not the lower byte + let (mut sum, mut n) = (0u64, 0u64); + for row in d.chunks(img.width).step_by(7) { + for x in 0..img.width - 2 { + sum += u64::from(row[x].abs_diff(row[x + 2])); + n += 1; + } + } + assert!(sum / n.max(1) < 120, "{model}: mean same-colour step {}", sum / n.max(1)); + checked += 1; + } + eprintln!("coolpix_nrw_be16: {checked} files checked"); +} From 6cbf6c7a9cc81bcde24273f8e714518f0cf4346f Mon Sep 17 00:00:00 2001 From: radialmonster Date: Sat, 10 Oct 2026 17:46:10 -0400 Subject: [PATCH 10/36] LR-IMP-FORMATS: test Z 6II Packed 14 bits geometry (6064x4040, 10624-byte rows) --- crates/raw/src/vendor/nef.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 1665afe65..0ac94f5ba 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -1023,6 +1023,18 @@ mod tests { assert_eq!(decoded(e, 12, 160, 3).unwrap(), d12); } + /// Z 6II "Packed 14 bits" (maker note 0x93 = 10, issue #769): 6064x4040, 14-bit, one strip of 4040 rows of 10624 + /// bytes (6064 * 14 / 8 = 10612, padded to a multiple of 16). Four rows keep the real stride. + #[test] + fn packed_14_bit_z6ii_geometry_with_row_padding() { + let (w, h) = (6064usize, 4usize); + let d14 = samples(w * h, 14); + let s = strip(&d14, w, 12, |r| pack_lsb(r, 14)); + assert_eq!(s.len(), 10624 * h); + assert_eq!(decoded(s, 14, w as u32, h as u32).unwrap(), d14); + assert_eq!(packed_layout(6064, 4040, 14, 42_920_960), Some(PackedLayout::Lsb { stride: 10624 })); + } + #[test] fn packed_d100_rows_carry_extra_columns() { // IFD width 154, rows hold 160 samples (16 chunks); the last 6 are dropped From cc75f0b765e42737fa18eb78bfd7019333964926 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Tue, 13 Oct 2026 07:23:23 -0400 Subject: [PATCH 11/36] LR-RAW-DNG: preserve lens corrections in the existing DNG tone-fit proxy --- crates/engine/src/camera_preview.rs | 29 +++++++- crates/engine/src/cmd/previews.rs | 16 +++-- crates/engine/src/files.rs | 107 +++++++++++++++++++++++++++- crates/engine/src/media.rs | 2 +- crates/engine/src/tests_import.rs | 15 ++-- docs/camera-preview-colour.md | 15 ++++ 6 files changed, 171 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 614c58c60..c08cb96a9 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -34,7 +34,8 @@ use lightcraft_raw::{ /// 7: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected. /// 8: the bundled NIKON Z 6_2 profile. /// 9: the bundled NIKON Z 8 and NIKON D5300 profiles. -pub const LOOK_VERSION: u32 = 9; +/// 10: DNG tone fitting uses a lens-corrected proxy when the file carries lens opcodes. +pub const LOOK_VERSION: u32 = 10; #[derive(Clone, Debug)] pub(crate) struct CameraLook { @@ -927,7 +928,10 @@ fn into_lens_geometry(img: &Rgb32f, raw: &RawImage, k: usize, lens: &LensGeometr fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option { Some(match raw.develop_binned(k, SENSOR_CLIP).ok()? { Some(sensor) => sensor, - None if raw.cpp == 3 && raw.cfa.is_none() => fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box), + // Lens opcodes cannot be applied to a binned mosaic: they are defined at full resolution. + None if (raw.cpp == 3 && raw.cfa.is_none()) || (raw.format == RawFormat::Dng && !raw.opcodes.list3.is_empty()) => { + fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box) + } None => return None, }) } @@ -3958,6 +3962,27 @@ mod tests { assert!(plain.data.iter().all(|p| *p == [0.2; 3])); } + /// Lens opcodes prevent mosaic binning. The DNG fit still gets a proxy, with the file's + /// full-resolution corrections applied once rather than silently losing the tone fit. + #[test] + fn dng_sensor_proxy_applies_lens_opcodes_before_downsampling() { + use lightcraft_raw::Opcode; + let bytes = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + raw.data = lightcraft_raw::RawData::U16(vec![4000; raw.width * raw.height]); + raw.opcodes.list3 = vec![Opcode::FixVignetteRadial { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }]; + assert!(raw.develop_binned(2, SENSOR_CLIP).unwrap().is_none()); + let proxy = sensor_proxy(&raw, 2, 32).expect("a corrected CFA proxy even when binning is unavailable"); + assert_eq!((proxy.width, proxy.height), (32, 24)); + let center = proxy.data[12 * 32 + 16][1]; + let corner = proxy.data[0][1]; + assert!((1.8..2.1).contains(&(corner / center)), "one vignetting gain, not zero or two: {corner} / {center}"); + let full = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); + assert_eq!(proxy.data, full.data, "the same opcode interpretation as raw development"); + let small = sensor_proxy(&raw, 2, 16).unwrap(); + assert_eq!(small.data, fit(&full, 16, 16, Filter::Box).data, "correct first, then reduce"); + } + /// Which DNGs get a tone fitted to their camera JPEG: a colour matrix, a tone curve that is absent or the /// identity, no look table; and no other raw format. #[test] diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index b63ccc2b4..0d8999989 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -176,12 +176,17 @@ fn build(s: &mut Session, p: &Value) -> Result { } /// One photo's smart preview to build or discard: (photo, its file in the smart previews folder, -/// how to load the original at preview size, whether the photo takes the per-file camera look — -/// `camera_preview::file_local_look`, [`lightcraft_catalog::Photo::fitted_look`]: only those -/// proxies go stale when the fit changes). +/// how to load the original at preview size, whether the photo can take a preview-fitted look: +/// vendor raws and DNGs (whose tone can come from their preview). #[cfg(not(target_arch = "wasm32"))] type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option, bool); +/// DNG tone fits also go stale, although DNGs keep their own matrix and Kelvin white-balance scale. +#[cfg(not(target_arch = "wasm32"))] +fn preview_fitted_look(photo: &lightcraft_catalog::Photo) -> bool { + photo.fitted_look() || (photo.develops_raw() && photo.format.eq_ignore_ascii_case("DNG")) +} + /// Held while one proxy is checked and (re)written, so Build Smart Previews and the opening look /// refresh ([`refresh_stale_smart_previews`]), on their own threads, never write the same file at once. #[cfg(not(target_arch = "wasm32"))] @@ -333,7 +338,7 @@ fn smart(s: &mut Session, p: &Value) -> Result { } let path = crate::smart::sharded_file_path(&dir, &ph); let source = (!discard).then(|| s.media.source_ref(&ph, crate::media::SourceLevel::Preview)); - jobs.push((id, path, source, ph.fitted_look())); + jobs.push((id, path, source, preview_fitted_look(&ph))); } let custom = s.smart_previews_dir.is_some(); if background { @@ -375,7 +380,8 @@ pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && p.fitted_look()).collect(); + let photos: Vec<_> = + s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && preview_fitted_look(p)).collect(); let jobs: Vec = photos.iter().map(|p| (p.id, crate::smart::sharded_file_path(&dir, p), Some(s.media.origin_ref(&p.source, max_edge)), true)).collect(); std::thread::Builder::new() diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index dd246cf84..644e08e1f 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -426,13 +426,26 @@ fn load_bytes_now( 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); + // A DNG's preview already has its lens opcodes applied. Keep them for its single tone fit, + // then remove them again: the rendered source leaves those corrections to the pipeline. + let dng_lens_ops = (raw.format == lightcraft_raw::RawFormat::Dng && raw.opcodes.list3.iter().any(|op| op.is_lens_correction())) + .then(|| raw.opcodes.list3.clone()); 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_geometry.as_ref(), lens.as_ref()), + || { + let removed = dng_lens_ops.map(|ops| std::mem::replace(&mut raw.opcodes.list3, ops)); + // With the opcodes restored, the proxy already includes vignetting; do not apply it twice. + let embedded = if removed.is_some() { None } else { lens.as_ref() }; + let colour = crate::camera_preview::starting_colour(&mut raw, &bytes, lens_geometry.as_ref(), embedded); + if let Some(ops) = removed { + raw.opcodes.list3 = ops; + } + colour + }, || dng_mattes(&bytes, &info), ); drop(bytes); @@ -1830,6 +1843,98 @@ pub(crate) mod tests { assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before)); } + /// Corrections used while fitting a DNG's preview must not be baked into its editable source. + /// The pipeline applies them once from SourceInfo, and a file-defined tone stays authoritative. + #[test] + fn dng_preview_corrections_leave_the_render_source_uncorrected() { + let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default()); + let mut raw = lightcraft_raw::decode(&plain).unwrap(); + raw.color.profile.tone_curve = lightcraft_raw::profile::ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]); + let before = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + raw.opcodes.list3 = vec![lightcraft_raw::Opcode::FixVignetteRadial { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }]; + let corrected = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + let (before, expected) = load_bytes(&before, 64).unwrap(); + let (after, info) = load_bytes(&corrected, 64).unwrap(); + assert_eq!(before.data, after.data, "lens correction belongs to the pipeline"); + assert!(info.lens.as_ref().is_some_and(|l| l.vignette.is_some())); + assert_eq!(info.camera_tone, expected.camera_tone, "the file's own tone is preserved"); + } + + /// A camera JPEG made from the corrected scene teaches one tone to the DNG's existing fit. + /// The source pixels stay uncorrected, including when the file is rotated. + #[test] + fn dng_lens_corrected_preview_teaches_one_tone_without_baking_in_the_lens() { + use lightcraft_color::{REC2020, SRGB, luminance_2020, transfer::linear_to_srgb}; + use lightcraft_raw::{Opcode, Orientation}; + let (w, h) = (384usize, 256usize); + let data = (0..w * h) + .map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let v = 0.02 + 0.2 * (0.5 + 0.5 * (x * 0.045).sin() * (y * 0.06).cos()) + 0.1 * x / w as f32; + let colour = match (i / w % 2, i % w % 2) { + (0, 0) => 1.2, + (1, 1) => 0.8, + _ => 1.0, + }; + 256 + (v * colour * 15744.0) as u16 + }) + .collect(); + let bytes = crate::tests_xmp::synthetic_dng_of(w, h, "RGGB", data, None, Default::default()); + let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + raw.color = lightcraft_raw::ColorData { + illuminant: [21, 0], + color_matrix: [Some(REC2020.from_xyz()), None], + as_shot_neutral: Some([1.0; 3]), + ..Default::default() + }; + let plain = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + raw.opcodes.list3 = vec![ + Opcode::FixVignetteRadial { k: [0.8, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }, + Opcode::WarpRectilinear { planes: vec![[0.7, 0.2, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }, + ]; + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let corrected = raw.develop(lightcraft_raw::Method::Bilinear).unwrap(); + let to_srgb = REC2020.to_space(&SRGB); + let pixels: Vec = corrected + .data + .iter() + .flat_map(|p| { + let p = t.matrix.apply_f32(std::array::from_fn(|i| p[i] * t.wb[i])); + let y = luminance_2020(p).max(1e-6); + let gain = (1.0 - (-3.0 * y).exp()) / y; + to_srgb.apply_f32(p.map(|v| v * gain)).map(|v| (255.0 * linear_to_srgb(v).clamp(0.0, 1.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, 98, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap(); + for orientation in [Orientation::Normal, Orientation::Rotate90] { + raw.orientation = orientation; + let mut file = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + // Append a preview IFD to the little-endian DNG written above. + let root = u32::from_le_bytes(file[4..8].try_into().unwrap()) as usize; + let count = u16::from_le_bytes(file[root..root + 2].try_into().unwrap()) as usize; + let next = root + 2 + 12 * count; + let start = file.len() as u32; + file[next..next + 4].copy_from_slice(&start.to_le_bytes()); + let jpeg_at = start + 2 + 4 * 12 + 4; + file.extend(4u16.to_le_bytes()); + for (tag, value) in [(256u16, w as u32), (257, h as u32), (0x201, jpeg_at), (0x202, jpeg.len() as u32)] { + file.extend(tag.to_le_bytes()); + file.extend(4u16.to_le_bytes()); + file.extend(1u32.to_le_bytes()); + file.extend(value.to_le_bytes()); + } + file.extend(0u32.to_le_bytes()); + file.extend(&jpeg); + let (source, info) = load_bytes(&file, 96).unwrap(); + let tone = info.camera_tone.expect("one DNG look fitted to the lens-corrected preview"); + let target = 1.0 - (-3.0f32 * 0.18).exp(); + assert!((tone.apply(0.18) - target).abs() < 0.04, "one tone: {} vs {target}", tone.apply(0.18)); + let expected = load_bytes(&plain, 96).unwrap().0.into_oriented(orientation); + assert_eq!(source.data, expected.data, "the editable source is not lens-corrected"); + } + } + /// A look fitted to a camera's JPEG, as `camera_preview` makes them: its rows don't sum to one, so the camera's /// own white-balanced neutral comes out yellow (blue 12 % low). fn yellowing_look() -> lightcraft_color::Mat3 { diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 22d76528d..e6c438ad7 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 64; +pub const RENDER_CACHE_VERSION: u64 = 65; /// 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/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 3d6469deb..4797e7a81 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -1348,9 +1348,15 @@ fn look_loader( /// camera look go stale (a PNG's proxy never does), and the opening scan runs once per fit version. #[test] fn smart_previews_from_an_older_look_fit_are_refreshed() { + for format in ["ARW", "DNG"] { + smart_look_refresh_for(format); + } +} + +fn smart_look_refresh_for(format: &str) { use std::sync::atomic::{AtomicUsize, Ordering}; - let src = temp_dir("smartlook-src"); - let lib = temp_dir("smartlook-lib"); + let src = temp_dir(&format!("smartlook-src-{format}")); + let lib = temp_dir(&format!("smartlook-lib-{format}")); write_png(&src.join("a.png"), 3); write_png(&src.join("b.png"), 9); let mut s = Session::new().with_fs(); @@ -1360,11 +1366,12 @@ fn smart_previews_from_an_older_look_fit_are_refreshed() { let (id, png) = (find(&s, "a.png"), find(&s, "b.png")); // `a` stands for a raw that takes the per-file camera look (the test loader decodes its pixels) let mut raw = (**s.catalog.photo(id).unwrap()).clone(); - raw.format = "ARW".into(); + raw.format = format.into(); raw.kind = lightcraft_catalog::MediaKind::Raw; s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap(); s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap(); - assert!(s.catalog.photo(id).unwrap().relative_wb() && !s.catalog.photo(png).unwrap().relative_wb()); + assert_eq!(s.catalog.photo(id).unwrap().relative_wb(), format == "ARW"); + assert!(!s.catalog.photo(png).unwrap().relative_wb()); s.execute("library.select", &json!({"ids": [id.0, png.0], "active": id.0})).unwrap(); let dir = s.media.smart_dir.clone().unwrap(); let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap()); diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 9a259e9ed..fa577f61b 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -109,6 +109,21 @@ On a local set of 54 ARW6 and lossless ARW photos (the maintainer's own shots pl A pooled profile was tried and is not bundled. Pooling the 46 photos (158,549 colour pairs) and validating on withheld photos (every third photo, N = 15, trained on the other 31) took mean ΔE from 5.71 with per-file fitting to 6.40 with the profile: 11 of 15 photos regressed, 3 improved and 1 was unchanged, the largest by +4.68, and 2 of the 8 rejected files were newly accepted. Per-file fitting therefore ships for this body. The 42 private photos are one photographer's scenes, 38 of them in the Standard creative style (the 12 RawDB files were not inventoried); revisit when a varied multi-photographer set exists. +## DNG previews with embedded lens corrections + +A DNG with its own colour model and no non-identity profile tone curve uses the existing +`camera_preview::fit_dng_tone` path, including its eligibility and acceptance checks. There is +one preview fit. Files with a look table, a gain table map or an unusable preview keep their +existing behaviour. + +When the file carries lens opcodes, that fit develops its sensor proxy through those opcodes +before reducing it: its camera preview already contains their distortion and vignetting +corrections. This uses the raw decoder's existing opcode implementation, including chained +warps. The separate proxy vignetting multiplier is skipped in this case. The editable source +still has lens corrections removed, so the pipeline applies them once through Enable Profile +Corrections. Developing this proxy requires a full-resolution demosaic; files without such +opcodes keep their binned proxy. + ## 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. From 781e6126fd51518d3463c4799e3fc2146452e797 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Thu, 15 Oct 2026 08:45:11 -0400 Subject: [PATCH 12/36] LR-IMP-FORMATS: bound packed NEF header probes and validate dimensions and strip bounds --- crates/raw/src/vendor/nef.rs | 37 +++++++++++++++++++++++++++++++----- docs/parity-checklist.md | 2 +- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 0ac94f5ba..79f9e1fc7 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -494,16 +494,16 @@ fn packed(bytes: &[u8], info: &ImageInfo, rows: usize) -> Result { _ => None, }; let (Some(layout), [c]) = (layout, chunks.as_slice()) else { - let small = bits == 12 && chunks.len() == 1 && w.checked_mul(h).and_then(|n| n.checked_mul(3)).is_some_and(|n| u64::try_from(n).ok() == Some(chunks[0].len)); + let small = bits == 12 + && chunks.len() == 1 + && w.checked_mul(h).and_then(|n| n.checked_mul(3)).is_some_and(|n| u64::try_from(n).ok() == Some(chunks[0].len)); return Err(RawError::Unsupported(if small { "Nikon small raw (sRAW) not decoded yet".into() } else { "Nikon compressed NEF without a linearization table (maker note 0x96)".into() })); }; - let end = c.offset.saturating_add(c.len).min(bytes.len() as u64); - let src = usize::try_from(c.offset).ok().zip(usize::try_from(end).ok()).and_then(|(a, b)| bytes.get(a..b)); - let src = src.ok_or_else(|| RawError::Corrupt("NEF: image data outside the file".into()))?; + let src = lightcraft_tiff::image::chunk_bytes(bytes, c).ok_or_else(|| RawError::Corrupt("NEF: image data outside the file".into()))?; if (w as u64).saturating_mul(h as u64) > crate::MAX_SAMPLES as u64 { return Err(RawError::Limit("image too large")); } @@ -1035,6 +1035,33 @@ mod tests { assert_eq!(packed_layout(6064, 4040, 14, 42_920_960), Some(PackedLayout::Lsb { stride: 10624 })); } + #[test] + fn packed_layout_refuses_overflowing_dimensions() { + for bits in [12, 14] { + assert!(packed_layout(usize::MAX - 3, 1, bits, u64::MAX).is_none()); + } + assert!(d100_layout(usize::MAX, 1, 12, u64::MAX).is_none()); + } + + /// Header probes inspect only the same top rows that the masked-column test uses. Routing + /// still validates the complete strip's layout, so header and full decode keep the same crop. + #[test] + fn packed_header_probe_reads_only_the_mask_rows() { + let (w, h) = (160usize, MASK_ROWS + 7); + let data = samples(w * h, 12); + for image in [strip(&data, w, 0, |r| pack_lsb(r, 12)), be16(&data)] { + let bytes = nef(34713, 12, vec![image], w as u32, h as u32, h as u32); + let tiff = Tiff::parse(&bytes).unwrap(); + let info = raw_ifd(&tiff).unwrap().image().unwrap(); + let RawData::U16(top) = packed(&bytes, &info, MASK_ROWS).unwrap() else { panic!("integer samples") }; + assert_eq!(top, data[..w * MASK_ROWS]); + let header = decode(&bytes, Mode::Header).unwrap(); + let full = decode(&bytes, Mode::Full).unwrap(); + assert_eq!((header.active_area, header.crop), (full.active_area, full.crop)); + assert!(matches!(header.data, RawData::U16(ref samples) if samples.is_empty())); + } + } + #[test] fn packed_d100_rows_carry_extra_columns() { // IFD width 154, rows hold 160 samples (16 chunks); the last 6 are dropped @@ -1131,6 +1158,6 @@ mod tests { // a strip that claims more bytes than the file has let mut bytes = nef(34713, 12, vec![vec![0x55; 60 * 6]], 40, 6, 6); bytes.truncate(bytes.len() - 100); - let _ = crate::decode(&bytes); + assert!(crate::decode(&bytes).is_err(), "a truncated packed strip is corrupt"); } } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..26c2ce9b1 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -82,7 +82,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate (local adjustment templates become mask effects, LR-MASK-EFFECTS), photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); a preset that names a profile (`crs:Look`) applies the imported profile of that name, imported before or after it, and Lightroom's basic profiles map to ours (`crates/engine/src/profile_refs.rs`, issue #1001); Adobe profile formats deliberately unsupported (so `.xmp` creative profiles' colour tables aren't read) | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/evidence.tsv`, `docs/raw/coverage-sweep.tsv`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Public raw.pixls.us coverage sweep (#1327): 30 more models verified on 40 CC0 files, with SHA-256 and FNV-1a sensor hashes pinned (`crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/coverage-sweep.tsv`; `docs/raw/coverage.py`: 515 → 545 verified rows). Render/reference luminance correlation is 0.6801 to 0.9998; eleven candidates excluded for identity, provenance or missing/mismatched reference images. Nikon D6 / Z 6 / Z 7 uncompressed and Z 8 HE variants remain preview-only. Own-matrix DNG tone differs from the camera JPEG (#1277); sensor verification is not colour calibration. | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`, `embedded_preview_covering`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for. Stand-ins and grid thumbnails decode the smallest embedded JPEG that covers the requested size and is a copy of the largest (aspect ratio within 1 %, same component count), not always the largest, on the whole-file and the plan route alike: an A7R VI's 1616 × 1080 JPEG (0.3 MB) instead of its 9984 × 6656 one (5.6 MB), and mid-sized NEF / RW2 JPEGs | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | From 001943f01a8bcf3db95168ea4cef8bc576bf905c Mon Sep 17 00:00:00 2001 From: radialmonster Date: Thu, 15 Oct 2026 12:23:37 -0400 Subject: [PATCH 13/36] LR-IMP-FORMATS: reject incomplete declared packed NEF strips --- crates/raw/src/vendor/nef.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 79f9e1fc7..af77366aa 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -503,7 +503,9 @@ fn packed(bytes: &[u8], info: &ImageInfo, rows: usize) -> Result { "Nikon compressed NEF without a linearization table (maker note 0x96)".into() })); }; - let src = lightcraft_tiff::image::chunk_bytes(bytes, c).ok_or_else(|| RawError::Corrupt("NEF: image data outside the file".into()))?; + let src = lightcraft_tiff::image::chunk_bytes(bytes, c) + .filter(|src| u64::try_from(src.len()).ok() == Some(c.len)) + .ok_or_else(|| RawError::Corrupt("NEF: incomplete image strip".into()))?; if (w as u64).saturating_mul(h as u64) > crate::MAX_SAMPLES as u64 { return Err(RawError::Limit("image too large")); } @@ -1160,4 +1162,28 @@ mod tests { bytes.truncate(bytes.len() - 100); assert!(crate::decode(&bytes).is_err(), "a truncated packed strip is corrupt"); } + /// Keep every directory intact before a relocated tail strip, then remove its last rows. + fn truncated_tail_strip(mut bytes: Vec, keep: usize) -> Vec { + let tiff = Tiff::parse(&bytes).unwrap(); + let raw = raw_ifd(&tiff).unwrap(); + let info = raw.image().unwrap(); + let chunk = info.chunks(bytes.len() as u64)[0]; + let data = lightcraft_tiff::image::chunk_bytes(&bytes, &chunk).unwrap().to_vec(); + let offset_entry = raw.get(t::STRIP_OFFSETS).unwrap().offset as usize; + let start = bytes.len() as u32; + bytes[offset_entry..offset_entry + 4].copy_from_slice(&start.to_be_bytes()); + bytes.extend_from_slice(&data[..keep]); + bytes + } + + #[test] + fn packed_rejects_tail_truncation_with_intact_directories() { + let bytes = nef(34713, 12, vec![vec![0x55; 360]], 40, 6, 6); + for keep in [0, 60, 359] { + let short = truncated_tail_strip(bytes.clone(), keep); + for mode in [Mode::Header, Mode::Full] { + assert!(matches!(decode(&short, mode), Err(RawError::Corrupt(_)))); + } + } + } } From 389a2cdd6581bcf4157cfc5391bba8bc184c0a91 Mon Sep 17 00:00:00 2001 From: lincleejun Date: Thu, 15 Oct 2026 21:29:11 -0700 Subject: [PATCH 14/36] GPU: a lost device is not polled again, and a poll that panics fails the render (#1858) Once a Vulkan or DX12 device is lost, wgpu 30 panics in `Device::poll` instead of returning an error: the device error is not a `PollError`, so `wgpu_core.rs` hands it to `handle_error_fatal`. The readback assumed the wait would fail. A render whose readback found the device lost panicked (caught by `try_render`), and while it unwound, a buffer going back to the pool polled again from a destructor (`Gpu::trim`). That second panic aborted the app ("panic in a destructor during cleanup"), as in the reporter's logs on an Intel UHD 620 under Vulkan, each time a merge lost the device. The render device's three polls (the readback, `trim`, `sync`) now go through `Gpu::poll`, which stops polling a lost device (the device-lost callback, or a poll that panicked) and turns a panicking poll into an error that retires the GPU. The render then fails over to the CPU and the app keeps running. A GPU retired for another reason (a validation error) keeps a live device and is still polled, so its pooled buffers are freed. Regression tests run in a child process (they lose the device): - `a_poll_on_a_lost_device_neither_panics_nor_aborts`: polls panic as wgpu's do on a lost Vulkan device; a readback, then an unwinding render whose destructor trims the pool. Before the fix the child aborts with SIGABRT and the log line above. - `a_really_lost_device_is_not_polled_again`: a Metal device really lost (4200 unsubmitted command buffers, past wgpu-hal's 4096) is not polled again. Before the fix the poll still reaches wgpu. - `a_retired_but_live_device_is_still_polled`: a GPU retired by a validation error still polls (and releases its buffers). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RbXK4njy12cpm4PG12Xrqt --- crates/gpu/src/ctx.rs | 190 +++++++++++++++++++++++++++++++++++++-- crates/gpu/src/render.rs | 2 +- 2 files changed, 185 insertions(+), 7 deletions(-) diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index fb000c031..0c3ab8f05 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -1,7 +1,7 @@ //! The wgpu device, the compiled kernels, buffers, dispatch and readback. use std::collections::HashMap; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use wgpu::util::DeviceExt; @@ -191,6 +191,12 @@ impl Drop for Tracked { } } +#[cfg(test)] +thread_local! { + /// Polls on this thread panic as wgpu's do once a Vulkan or DX12 device is lost (#1858). + static SIMULATED_LOST_POLL: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + thread_local! { static RETIRED: Retired = const { Retired(std::cell::RefCell::new(Vec::new())) }; } @@ -199,6 +205,9 @@ thread_local! { static ALLOCATED: AtomicU64 = AtomicU64::new(0); /// Bytes waiting in per-thread retired lists. static RETIRED_BYTES: AtomicU64 = AtomicU64::new(0); +/// The render device is lost: wgpu's `poll` panics from then on, so [`Gpu::poll`] stops calling it. +/// Other failures that retire the GPU leave the device usable for releasing its buffers. +static DEVICE_LOST: AtomicBool = AtomicBool::new(false); /// Bytes in the free pool. static POOLED: AtomicU64 = AtomicU64::new(0); /// Most bytes kept in the free pool (see [`crate::set_pool_limit`]). @@ -324,6 +333,7 @@ impl Gpu { // wgpu reports a lost device (driver reset, GPU hang, watchdog) only here — never as an // error — so without this callback a lost device went unnoticed. device.set_device_lost_callback(|reason, msg| { + DEVICE_LOST.store(true, Ordering::Relaxed); log::error!("gpu: device lost ({reason:?}): {msg}"); crate::mark_broken(&format!("device lost ({reason:?}): {msg}")); }); @@ -446,7 +456,33 @@ impl Gpu { if !dropped.is_empty() { drop(dropped); // let wgpu release the memory now rather than at its next maintenance - let _ = self.device.poll(wgpu::PollType::Poll); + let _ = self.poll(wgpu::PollType::Poll); + } + } + + /// Process finished GPU work: `device.poll`, with its error as text. Never panics (#1858): once a + /// device is lost, wgpu 30 panics in `poll` rather than returning an error (a device error is + /// not a `PollError`), and the buffer pool polls from destructors ([`Gpu::trim`]), where a + /// second panic while a failed render unwinds aborts the process. A lost device is not polled + /// again; a poll that panics counts as one and stops the GPU. + pub(crate) fn poll(&self, t: wgpu::PollType) -> Result { + if DEVICE_LOST.load(Ordering::Relaxed) { + return Err("the device is lost".into()); + } + let polled = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + #[cfg(test)] + if SIMULATED_LOST_POLL.with(std::cell::Cell::get) { + panic!("Error in Device::poll: Validation Error\n\nCaused by:\n Parent device is lost (simulated)"); + } + self.device.poll(t) + })); + match polled { + Ok(r) => r.map_err(|e| e.to_string()), + Err(_) => { + DEVICE_LOST.store(true, Ordering::Relaxed); + crate::mark_broken("device lost: Device::poll failed"); + Err("the device is lost (Device::poll failed)".into()) + } } } @@ -558,10 +594,11 @@ impl Gpu { fail(FailKind::Fatal, format!("readback failed: {why}")); zeros() }; - // A lost device makes the wait fail and the map callback never run: checking both keeps - // this from blocking forever (or handing back an unwritten buffer as the image). - if let Err(e) = self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(READBACK_TIMEOUT) }) { - return failed(format!("{e}")); + // A lost device makes the wait fail ([`Gpu::poll`] turns wgpu's panic into the error) and + // the map callback never run: checking both keeps this from blocking forever (or handing + // back an unwritten buffer as the image). + if let Err(e) = self.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(READBACK_TIMEOUT) }) { + return failed(e); } // another thread's poll may be the one that runs the callback: allow it a moment match rx.recv_timeout(std::time::Duration::from_secs(10)) { @@ -734,6 +771,147 @@ mod tests { assert_eq!(super::parse_limits("lots"), None); } + /// Run `child` (an ignored test in this module) in its own process: it loses the device, which + /// no other test running beside it may see. Returns its stdout, or `None` without a GPU. + fn run_child(child: &str) -> Option { + let exe = std::env::current_exe().unwrap(); + // output goes to files: a pipe nobody reads while we wait could fill and stall the child + let log = |stream: &str| std::env::temp_dir().join(format!("lc-gpu-{child}-{}.{stream}", std::process::id())); + let (out_path, err_path) = (log("out"), log("err")); + let mut proc = std::process::Command::new(exe) + .args(["--exact", &format!("ctx::tests::{child}"), "--ignored", "--nocapture", "--test-threads=1"]) + .stdout(std::fs::File::create(&out_path).unwrap()) + .stderr(std::fs::File::create(&err_path).unwrap()) + .spawn() + .unwrap(); + // a driver that stalls must fail the test, not hang CI + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); + let status = loop { + if let Some(status) = proc.try_wait().unwrap() { + break status; + } + if std::time::Instant::now() > deadline { + let _ = proc.kill(); + let _ = proc.wait(); + panic!("{child}: still running after 120 s"); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + }; + let stdout = std::fs::read_to_string(&out_path).unwrap_or_default(); + let stderr = std::fs::read_to_string(&err_path).unwrap_or_default(); + let _ = (std::fs::remove_file(&out_path), std::fs::remove_file(&err_path)); + if stdout.contains("CHILD: no GPU") { + return None; + } + // an abort (SIGABRT, "panic in a destructor during cleanup") has no exit code + assert!(status.success(), "{child}: {status:?}\n{stdout}\n{stderr}"); + assert!(stdout.contains("CHILD: survived"), "{child}: {stdout}"); + Some(stdout) + } + + /// Issue #1858: once a Vulkan or DX12 device is lost, wgpu 30 panics in `Device::poll` instead of + /// returning an error. A render's readback panicked; while it unwound, a buffer's way back to + /// the pool polls again from a destructor, and that second panic aborted the app. + #[test] + fn a_poll_on_a_lost_device_neither_panics_nor_aborts() { + if run_child("lost_poll_child").is_none() { + lightcraft_testkit::skip!(Gpu, "no GPU device"); + } + } + + /// A Metal device really lost (its command buffers exhausted) is never polled again. + #[test] + fn a_really_lost_device_is_not_polled_again() { + if run_child("really_lost_child").is_none() { + lightcraft_testkit::skip!(Gpu, "no GPU device, or one that can't be lost on purpose here"); + } + } + + /// A GPU retired by another failure (a validation error) keeps a live device: its buffers are + /// still released, which takes a poll. + #[test] + fn a_retired_but_live_device_is_still_polled() { + if run_child("retired_child").is_none() { + lightcraft_testkit::skip!(Gpu, "no GPU device"); + } + } + + #[test] + #[ignore = "run in its own process by a_retired_but_live_device_is_still_polled"] + fn retired_child() { + let Some(g) = crate::device() else { + println!("CHILD: no GPU"); + return; + }; + crate::mark_broken("device error: simulated validation error"); + assert!(!crate::available()); + drop(g.upload(&[1u32; 1024])); + g.poll(wgpu::PollType::Poll).unwrap(); + println!("CHILD: survived"); + } + + /// Empties the buffer pool when dropped, as the UI does (`trim_pool(0)`) and as returning a + /// buffer to a full pool does. + struct TrimOnDrop(&'static super::Gpu); + + impl Drop for TrimOnDrop { + fn drop(&mut self) { + self.0.trim(0); + } + } + + #[test] + #[ignore = "run in its own process by a_poll_on_a_lost_device_neither_panics_nor_aborts"] + fn lost_poll_child() { + let Some(g) = crate::device() else { + println!("CHILD: no GPU"); + return; + }; + let src = g.upload(&[1u32; 1024]); + drop(g.upload(&[2u32; 1024])); // pooled: trimming it polls + super::SIMULATED_LOST_POLL.with(|c| c.set(true)); + // a render (caught as `try_render` catches it) whose readback finds the device lost + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _trim = TrimOnDrop(g); + let v: Vec = g.finish_and_read(g.encoder(), &src, 1024); + assert!(v.iter().all(|x| *x == 0), "a failed readback hands back zeros"); + })); + assert!(r.is_ok(), "the lost device fails the render without a panic"); + assert!(super::failed(), "the render failed, so it is redone on the CPU"); + assert!(!crate::available(), "the GPU is not used again"); + // a render that panics for another reason still unwinds through a destructor that polls + drop(g.upload(&[3u32; 1024])); + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _trim = TrimOnDrop(g); + panic!("a render panics (expected in this test)"); + })); + assert!(r.is_err()); + println!("CHILD: survived"); + } + + #[test] + #[ignore = "run in its own process by a_really_lost_device_is_not_polled_again"] + fn really_lost_child() { + let Some(g) = crate::device().filter(|g| g.info.backend == wgpu::Backend::Metal) else { + println!("CHILD: no GPU"); + return; + }; + // wgpu-hal's Metal backend treats a command buffer past its 4096 unsubmitted as a lost device + let b = g.upload(&[1u32; 16]); + let held: Vec = (0..4200) + .map(|_| { + let mut e = g.encoder(); + e.clear_buffer(b.raw(), 0, None); + e.finish() + }) + .collect(); + assert!(crate::unavailable_reason().is_some_and(|r| r.contains("device lost")), "{:?}", crate::unavailable_reason()); + let e = g.poll(wgpu::PollType::Poll).unwrap_err(); + assert!(e.contains("lost"), "{e}"); + drop(held); + println!("CHILD: survived"); + } + #[test] fn dispatches_stay_within_the_workgroup_limit() { // 1-D kernels over a 100 MP image and 2-D ones over 16k × 16k stay under 65535 per axis diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index b747e0d32..0c5bd19f0 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -180,7 +180,7 @@ impl<'a> Cx<'a> { if let Some(enc) = self.enc.take() { self.gpu.submit([enc.finish()]); } - let _ = self.gpu.device.poll(wgpu::PollType::wait_indefinitely()); + let _ = self.gpu.poll(wgpu::PollType::wait_indefinitely()); } /// A zero-filled buffer (the clear is recorded). From 38973dba3a7ab5d762b11d3e88553b35cda83558 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 06:45:33 +0200 Subject: [PATCH 15/36] Imported LUTs no longer leave a copy in the temp folder without a library on disk (part of #1952) --- crates/engine/src/cmd/lut_profiles.rs | 11 ++++------- crates/engine/src/tests_import.rs | 28 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/lut_profiles.rs b/crates/engine/src/cmd/lut_profiles.rs index f03809b62..b7a0d4992 100644 --- a/crates/engine/src/cmd/lut_profiles.rs +++ b/crates/engine/src/cmd/lut_profiles.rs @@ -17,7 +17,7 @@ pub struct LutProfile { pub id: String, pub name: String, pub group: String, - /// The `.cube` file (the library's copy when there is a library on disk). + /// The library's copy of the `.cube` file (empty without a library on disk). pub file: String, } @@ -123,12 +123,9 @@ fn import(s: &mut Session, p: &Value) -> Result { std::fs::write(&f, &text).map_err(|e| bad(C, format!("{}: {e}", f.display())))?; f.to_string_lossy().to_string() } - None => { - // no library on disk: remember the original (the in-memory session ends with the app) - let f = std::env::temp_dir().join(format!("lightcraft-{}.cube", id.trim_start_matches("lut:"))); - std::fs::write(&f, &text).map_err(|e| bad(C, e.to_string()))?; - f.to_string_lossy().to_string() - } + // no library on disk: the registered LUT is all this session needs (a copy would only + // be read back when a library opens, and that takes its own list from its prefs) + None => String::new(), }; lightcraft_pipeline::lut::register(&id, lut); s.lut_profiles.push(LutProfile { id: id.clone(), name: display.clone(), group: group.clone(), file }); diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 3d6469deb..f00ecc557 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -611,6 +611,34 @@ fn cube_luts_become_profiles() { /// Folder rename / move on disk: files and sidecars go along, the photos are relinked and /// still render; clashes and moving into itself are refused. +/// Without a library on disk an imported LUT lives only in this session: no copy of it is +/// left in the temp folder (#1952). +#[test] +fn cube_import_without_a_library_leaves_no_temp_copy() { + let src = temp_dir("cubemem"); + let mut cube = String::from("TITLE \"Temp Copy Check\"\nLUT_3D_SIZE 2\n"); + for b in 0..2 { + for g in 0..2 { + for r in 0..2 { + cube.push_str(&format!("{r} {g} {b}\n")); + } + } + } + std::fs::write(src.join("mem.cube"), &cube).unwrap(); + let leak = std::env::temp_dir().join("lightcraft-imported-temp-copy-check.cube"); + let _ = std::fs::remove_file(&leak); + let mut s = Session::new().with_fs(); + let r = s.execute("profile.import", &json!({"paths": [src.join("mem.cube").to_string_lossy()]})).unwrap(); + let id = r["imported"][0]["id"].as_str().unwrap().to_string(); + assert!(lightcraft_pipeline::lut::get(&id).is_some(), "usable in this session"); + let file = s.lut_profiles.iter().find(|p| p.id == id).unwrap().file.clone(); + assert!(file.is_empty() || Path::new(&file).starts_with(&src), "no copy written: {file}"); + drop(s); + assert!(!leak.exists(), "{} left behind", leak.display()); + lightcraft_pipeline::lut::unregister(&id); + let _ = std::fs::remove_dir_all(&src); +} + #[test] fn folders_rename_and_move_with_their_photos() { let root = temp_dir("folders"); From b03f1dd3969b8b1fb4c8526d37fa75020ca9da86 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Fri, 16 Oct 2026 21:13:35 +1100 Subject: [PATCH 16/36] LR-EDIT-COLOR-TEMP: Kelvin white balance for the Nikon Z5II from built-in presets The Z5II has no colour matrices and its NEFs carry no preset table, so its Temp slider showed the relative -100..+100 scale. A small per-model table (crates/raw/src/kelvin_builtin.rs) gives five (Kelvin, multipliers) points that feed the existing KelvinPresets curve: the NEF decoder attaches them to ColorData::kelvin_presets and kelvin_presets::covers accepts the model. Points: as-shot WB_RBLevels of the body's own shots at Incandescent, Direct sunlight, Kelvin 5500 K, Cloudy and Shade, placed at the temperature the camera itself writes for each preset (maker note ColorTemperatureAuto: 3000, 5210, 5500, 5990, 8000 K). Fluorescent and flash are left out. Natural Auto (camera: 5030 K) reads back at 5036 K. Sources: own design on the existing kelvin_presets model; the camera's own stated temperatures; ExifTool Nikon tag-name documentation (prose), exiftool used as a black-box tool. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/raw/src/kelvin_builtin.rs | 92 ++++++++++++++++++++++++++++++++ crates/raw/src/kelvin_presets.rs | 6 ++- crates/raw/src/lib.rs | 1 + crates/raw/src/vendor/nef.rs | 2 +- docs/parity-checklist.md | 2 +- 5 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 crates/raw/src/kelvin_builtin.rs diff --git a/crates/raw/src/kelvin_builtin.rs b/crates/raw/src/kelvin_builtin.rs new file mode 100644 index 000000000..96a503d81 --- /dev/null +++ b/crates/raw/src/kelvin_builtin.rs @@ -0,0 +1,92 @@ +//! Built-in white-balance presets for cameras whose raws carry neither colour matrices nor a preset table, feeding +//! the Kelvin scale of [`crate::kelvin_presets`]. +//! +//! Nikon NEFs store only the as-shot multipliers (`WB_RBLevels`) and, in the maker note, the colour temperature the +//! camera states for the white-balance setting it shot with (`ColorTemperatureAuto`, `0x0045`: tag name from the +//! ExifTool Nikon tag documentation, read with exiftool run as a black-box tool on the files). A body's points are +//! the as-shot multipliers of its own photos shot at one preset each, placed at the temperature the camera itself +//! wrote for that preset. Presets off the daylight locus (fluorescent, flash) and shots with a fine-tune shift are +//! left out. +//! +//! Sources: own photos of each body listed (a reporter's samples, never committed), the camera's own stated +//! temperatures, and the ExifTool Nikon tag-name documentation (prose). No matrices or third-party data. +use crate::kelvin_presets::KelvinPresets; + +/// One body: its make's first word, its Exif model without the make, and five (Kelvin, R/G, B/G) points. +struct Builtin { + make: &'static str, + model: &'static str, + points: [(f32, f32, f32); 5], +} + +const BUILTINS: &[Builtin] = &[ + // Nikon Z5II (issue emmertex/lightcraft-classic#157): Incandescent 3000 K, Direct sunlight 5210 K, the Kelvin + // setting at 5500 K, Cloudy 5990 K, Shade 8000 K (the temperatures the camera writes in `ColorTemperatureAuto`). + // Natural Auto (the camera wrote 5030 K) reads back at 5036 K; Flash (5410 K nominal) at about 6500 K. + Builtin { + make: "NIKON", + model: "Z5_2", + points: [ + (3000.0, 1.138_671_9, 2.083_984_4), + (5210.0, 1.726_562_5, 1.335_937_5), + (5500.0, 1.771_484_4, 1.281_25), + (5990.0, 1.839_843_8, 1.212_890_6), + (8000.0, 2.097_656_3, 1.089_843_8), + ], + }, +]; + +fn find_builtin(make: Option<&str>, model: &str) -> Option<&'static Builtin> { + let model = model.trim(); + BUILTINS.iter().find(|b| { + let n = b.make.len(); + let bare = match (model.get(..n), model.get(n..)) { + (Some(prefix), Some(rest)) if prefix.eq_ignore_ascii_case(b.make) => rest.trim_start(), + _ => model, + }; + make.is_none_or(|m| m.split_whitespace().next().is_some_and(|w| w.eq_ignore_ascii_case(b.make))) && bare.eq_ignore_ascii_case(b.model) + }) +} + +/// Whether this Exif make and model has built-in presets. +pub fn covers(make: Option<&str>, model: &str) -> bool { + find_builtin(make, model).is_some() +} + +/// The built-in presets of this Exif make and model, if any. +pub fn find(make: Option<&str>, model: &str) -> Option { + let b = find_builtin(make, model)?; + KelvinPresets::new(b.points.map(|(k, r, b)| (k, [r, 1.0, b]))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_builtin_makes_a_curve() { + for b in BUILTINS { + assert!(find(Some(b.make), b.model).is_some(), "{} {}", b.make, b.model); + } + } + + #[test] + fn z5ii_reads_its_own_shots_near_the_camera_temperatures() { + let p = find(Some("NIKON CORPORATION"), "NIKON Z5_2").unwrap(); + // Natural Auto, not on the curve: the camera wrote 5030 K for it + let (t, tint) = p.temp_tint([1.695_312_5, 1.0, 1.373_046_9]).unwrap(); + assert!((1e6 / t - 1e6 / 5030.0).abs() < 10.0 && tint.abs() < 5.0, "{t} {tint}"); + // Flash (nominally 5410 K) sits near the curve on the daylight side + let (t, tint) = p.temp_tint([1.916_015_6, 1.0, 1.164_062_5]).unwrap(); + assert!((5000.0..7500.0).contains(&t) && tint.abs() < 10.0, "{t} {tint}"); + // the 5990 K Kelvin shot with a green-magenta fine-tune shift: daylight, clearly tinted + let (t, tint) = p.temp_tint([1.531_25, 1.0, 1.074_218_8]).unwrap(); + assert!((4000.0..7500.0).contains(&t) && tint.abs() > 20.0, "{t} {tint}"); + } + + #[test] + fn matching() { + assert!(covers(Some("NIKON CORPORATION"), "NIKON Z5_2") && covers(None, "Z5_2") && covers(Some("Nikon"), "nikon z5_2")); + assert!(!covers(Some("NIKON CORPORATION"), "NIKON Z5") && !covers(Some("SONY"), "Z5_2") && !covers(None, "") && !covers(None, "NIK")); + } +} diff --git a/crates/raw/src/kelvin_presets.rs b/crates/raw/src/kelvin_presets.rs index d4e6f33f3..975e578c9 100644 --- a/crates/raw/src/kelvin_presets.rs +++ b/crates/raw/src/kelvin_presets.rs @@ -41,8 +41,12 @@ const MAX_TINT: f64 = 150.0; const MODELS: &[&str] = &["DSC-RX10", "ILCA-77M2", "ILCA-99M2", "ILCE-3500", "ILCE-7S", "SLT-A33"]; /// Whether a raw of this Exif make and model is edited in Kelvin through its presets (when it has no colour -/// matrices of its own or measured: see [`crate::spectral::find`], which comes first). +/// matrices of its own or measured: see [`crate::spectral::find`], which comes first), from the file's presets or +/// built-in ones ([`crate::kelvin_builtin`]). pub fn covers(make: Option<&str>, model: &str) -> bool { + if crate::kelvin_builtin::covers(make, model) { + return true; + } let model = model.trim(); let model = model.strip_prefix("SONY ").or_else(|| model.strip_prefix("Sony ")).unwrap_or(model); make.is_none_or(|m| m.split_whitespace().next().is_some_and(|b| b.eq_ignore_ascii_case("SONY"))) diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 202983331..fe62e87a5 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -34,6 +34,7 @@ pub mod gaintable; pub mod highlight; #[cfg(feature = "jxl")] mod jxl; +pub mod kelvin_builtin; pub mod kelvin_presets; pub mod lens_metadata; pub mod ljpeg; diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 5076c6df9..47f866447 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -298,7 +298,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { active_area: Rect::new(0, 0, active_w, h), crop, orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)), - color: ColorData::default(), + color: ColorData { kelvin_presets: crate::kelvin_builtin::find(Some(&make), &model), ..Default::default() }, wb_multipliers: wb, linearized: false, pixel_aspect, diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..e2a096d3b 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -193,7 +193,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | | | LR-EDIT-COLOR-WB-PRESET | White-balance presets | P0 | ✅ | `cmd:develop.wb` | | | LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick`, `crates/ui-egui/src/tests_wb_selector.rs` | the Edit panel's selector button does what W does (from Compare, Survey or the grid it opens the photo in Detail); no magnified loupe while picking | -| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`), `crates/develop/src/controls.rs` (`ControlSpec::position`), `crates/raw/src/kelvin_presets.rs` | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford), or read from the camera's own white-balance presets (Sony ARW `SR2SubIFD`, Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S: Sony's Kelvin dial reads back within 0.3 % on 5 settings of 4 bodies); raws without matrices anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). The Kelvin slider's track is Lightroom's: 7000 K in the middle, 2000–7000 K evenly on the left half, logarithmic up to 50000 K on the right (within 2 % of positions measured in Lightroom Classic), for drawing, clicks and drags; wheel / arrow-key nudges and Quick Develop still step a fixed 50 / 100 / 500 K; the relative slider stays linear. Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open; raws without matrices or presets anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open | +| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`), `crates/develop/src/controls.rs` (`ControlSpec::position`), `crates/raw/src/kelvin_presets.rs` | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford), or read from the camera's own white-balance presets (Sony ARW `SR2SubIFD`, Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S: Sony's Kelvin dial reads back within 0.3 % on 5 settings of 4 bodies; or built-in presets from the camera's own stated temperatures, `crates/raw/src/kelvin_builtin.rs`: Nikon Z5II, whose Natural Auto shot reads back at 5036 K against the 5030 K the camera wrote); raws without matrices anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). The Kelvin slider's track is Lightroom's: 7000 K in the middle, 2000–7000 K evenly on the left half, logarithmic up to 50000 K on the right (within 2 % of positions measured in Lightroom Classic), for drawing, clicks and drags; wheel / arrow-key nudges and Quick Develop still step a fixed 50 / 100 / 500 K; the relative slider stays linear. Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open; raws without matrices or presets anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open | | LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint`, `docs/tint-direction.md` | DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP `crs:Tint` values render as in Lightroom (issue #188); CPU/GPU regression tests | | LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | | | LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | `ctl:color.saturation` | | From 340ed4c2d87210745b7ef9cc709bd052575c4715 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Fri, 16 Oct 2026 21:19:18 +1100 Subject: [PATCH 17/36] LR-EDIT-COLOR-TEMP: Kelvin white balance from Canon CR2 ColorData presets Canon's maker-note ColorData lists the levels of each white-balance preset with Canon's colour temperature for it. For versions -4 and 13-15 the Daylight, Shade, Cloudy, Tungsten and Flash rows now give a Kelvin curve (kelvin_presets, generalised from five fixed points to any number, with a from_unordered constructor that sorts and drops points that would turn the curve back). Fluorescent (off the locus) and the Kelvin row (the dial's last setting) are left out. Enabled for the PowerShot G1 X Mark III, which has no colour matrices: its nine as-shot white balances read within 4 % of the camera's own ColorTempAsShot. Sources: own design on the existing kelvin_presets model; ColorData row positions from the ExifTool Canon tag-name documentation, confirmed with ExifTool run as a black-box tool on sample files. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/files.rs | 15 ++++ crates/raw/src/kelvin_presets.rs | 135 +++++++++++++++++++++++++------ crates/raw/src/lib.rs | 2 +- crates/raw/src/vendor/cr2.rs | 119 +++++++++++++++++++++++++-- docs/camera-preview-colour.md | 2 + docs/parity-checklist.md | 2 +- docs/raw-parity.md | 5 +- 7 files changed, 247 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index dd246cf84..8b06a69d7 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -1182,6 +1182,21 @@ pub(crate) mod tests { let (t, tint) = a99m2.as_shot(d65); assert!((5000.0..6000.0).contains(&t) && tint.abs() < 10.0 && t.fract() == 0.0, "{t} {tint}"); assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCA-99M2")); + // a Canon body without matrices, through the presets its ColorData lists (PowerShot G1 X Mark III, a + // Daylight shot with the camera's white balance shifted towards blue: below Daylight's 5651 K) + let canon = lightcraft_raw::kelvin_presets::KelvinPresets::from_unordered([ + (5651.0, g(1418.0, 1357.0)), + (7505.0, g(1653.0, 1171.0)), + (6594.0, g(1533.0, 1259.0)), + (3174.0, g(963.0, 2016.0)), + (8529.0, g(1807.0, 1080.0)), + ]); + let with_canon = ColorData { kelvin_presets: canon, ..Default::default() }; + let g1x3 = WbScaleOf::new(RawFormat::Cr2, &with_canon, Some(g(1264.0, 1537.0)), &meta("Canon", "Canon PowerShot G1 X Mark III")); + assert!(!g1x3.relative && g1x3.scale_white.is_some()); + let (t, tint) = g1x3.as_shot(d65); + assert!((4300.0..5200.0).contains(&t) && tint.abs() < 10.0, "{t} {tint}"); + assert!(!lightcraft_catalog::relative_wb_camera("CR2", "Canon Canon PowerShot G1 X Mark III")); let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCA-99M2")); assert!(bare.legacy_relative && bare.scale_white.is_none()); // presets in a file of a camera not listed: relative, as the catalog decides from the camera alone diff --git a/crates/raw/src/kelvin_presets.rs b/crates/raw/src/kelvin_presets.rs index d4e6f33f3..b728e6b1d 100644 --- a/crates/raw/src/kelvin_presets.rs +++ b/crates/raw/src/kelvin_presets.rs @@ -8,8 +8,13 @@ //! white. That is all a Kelvin slider needs; the look stays the file's JPEG fit, and the picture is adapted from the //! as-shot white with Bradford, as for a camera read through measured matrices (`crate::spectral`, issue #730). //! +//! Canon CR2s list theirs by name in the maker note's `ColorData`, each with Canon's temperature for it (see +//! `vendor::cr2`): five planckian ones (Daylight, Shade, Cloudy, Tungsten, Flash) make the curve the same way, +//! taken in Kelvin order ([`KelvinPresets::from_unordered`]). On the PowerShot G1 X Mark III's nine settings the +//! curve reads the as-shot levels within 4 % of the camera's own `ColorTempAsShot`. +//! //! The model, in `(ln R/G, ln B/G)` of the multipliers ([`KelvinPresets::temp_tint`]): -//! - The five presets are joined piecewise linearly, with mired linear along each segment and the end segments +//! - The presets (three or more) are joined piecewise linearly, with mired linear along each segment and the end segments //! extended. On every Sony body measured, mired is nearly linear in arc length along the whole curve (2500 K to //! 8500 K within ±3 %), so the segments add no visible kinks. //! - A white off the curve is placed by its perpendicular offset from the nearest segment. Tint is that offset scaled @@ -35,18 +40,34 @@ const MIN_K: f64 = 2000.0; const MAX_K: f64 = 50000.0; const MAX_TINT: f64 = 150.0; -/// Models whose white balance is edited in Kelvin through their presets, checked to carry them (on 80–150 own -/// photos each, or the public corpus samples for the ILCE-3500 and ILCE-7S): the Exif model as Sony writes it. A -/// camera with measured matrices (`crate::spectral`) is read through those instead. -const MODELS: &[&str] = &["DSC-RX10", "ILCA-77M2", "ILCA-99M2", "ILCE-3500", "ILCE-7S", "SLT-A33"]; +/// Models whose white balance is edited in Kelvin through their presets, as (brand, Exif model without the brand), +/// each checked to carry them: the Sony bodies on 80–150 own photos each, or the public corpus samples for the +/// ILCE-3500 and ILCE-7S; the Canon PowerShot G1 X Mark III on a reporter's nine photos, one per white-balance +/// setting (issue #157 of a downstream fork). A camera with measured matrices (`crate::spectral`) is read through +/// those instead. +const MODELS: &[(&str, &str)] = &[ + ("SONY", "DSC-RX10"), + ("SONY", "ILCA-77M2"), + ("SONY", "ILCA-99M2"), + ("SONY", "ILCE-3500"), + ("SONY", "ILCE-7S"), + ("SONY", "SLT-A33"), + ("CANON", "PowerShot G1 X Mark III"), +]; /// Whether a raw of this Exif make and model is edited in Kelvin through its presets (when it has no colour /// matrices of its own or measured: see [`crate::spectral::find`], which comes first). pub fn covers(make: Option<&str>, model: &str) -> bool { let model = model.trim(); - let model = model.strip_prefix("SONY ").or_else(|| model.strip_prefix("Sony ")).unwrap_or(model); - make.is_none_or(|m| m.split_whitespace().next().is_some_and(|b| b.eq_ignore_ascii_case("SONY"))) - && MODELS.iter().any(|m| m.eq_ignore_ascii_case(model)) + let brand = make.and_then(|m| m.split_whitespace().next()); + MODELS.iter().any(|(b, m)| { + let bare = model + .get(..b.len()) + .zip(model.get(b.len()..)) + .filter(|(p, rest)| p.eq_ignore_ascii_case(b) && rest.starts_with(' ')) + .map_or(model, |(_, rest)| rest.trim_start()); + brand.is_none_or(|x| x.eq_ignore_ascii_case(b)) && bare.eq_ignore_ascii_case(m) + }) } /// [`covers`] for make and model joined by a space, as the catalog keeps them (`"SONY ILCA-99M2"`). @@ -57,10 +78,13 @@ pub fn covers_camera(camera: &str) -> bool { } } -/// A camera's multipliers at five stated temperatures, ascending, green = 1. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +/// Most points a curve keeps (Canon writes about seven at a stated temperature, Sony five). +const MAX_POINTS: usize = 32; + +/// A camera's multipliers at stated temperatures (at least three), ascending, green = 1. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct KelvinPresets { - points: [(f32, [f32; 3]); 5], + points: Vec<(f32, [f32; 3])>, } fn usable(m: [f32; 3]) -> bool { @@ -76,15 +100,36 @@ fn locus_uv(t: f64) -> (f64, f64) { } impl KelvinPresets { - /// `points` as (Kelvin, multipliers), ascending in Kelvin; `None` unless they make a curve: positive - /// multipliers, red rising and blue falling from one preset to the next. - pub fn new(points: [(f32, [f32; 3]); 5]) -> Option { - let ok = points.iter().all(|(k, m)| *k > 0.0 && k.is_finite() && usable(*m)) + /// `points` as (Kelvin, multipliers), ascending in Kelvin; `None` unless they make a curve: three to + /// [`MAX_POINTS`] of them, positive multipliers, red rising and blue falling from one preset to the next. + pub fn new(points: impl IntoIterator) -> Option { + let points: Vec<_> = points.into_iter().take(MAX_POINTS + 1).collect(); + let ok = (3..=MAX_POINTS).contains(&points.len()) + && points.iter().all(|(k, m)| *k > 0.0 && k.is_finite() && usable(*m)) && points.windows(2).all(|w| { let (a, b) = (log_point(w[0].1), log_point(w[1].1)); w[0].0 < w[1].0 && b[0] > a[0] && b[1] < a[1] }); - ok.then(|| KelvinPresets { points: points.map(|(k, m)| (k, [m[0] / m[1], 1.0, m[2] / m[1]])) }) + ok.then(|| KelvinPresets { points: points.into_iter().map(|(k, m)| (k, [m[0] / m[1], 1.0, m[2] / m[1]])).collect() }) + } + + /// A curve from presets in any order, as a camera lists them by name (Canon's Daylight, Shade, Cloudy, …): + /// sorted by Kelvin, a repeated temperature kept once, unusable points and any that would turn the curve back + /// (red not rising or blue not falling from the warmer neighbour kept) dropped; then [`Self::new`]. + pub fn from_unordered(points: impl IntoIterator) -> Option { + let mut pts: Vec<_> = points.into_iter().take(MAX_POINTS).filter(|(k, m)| *k > 0.0 && k.is_finite() && usable(*m)).collect(); + pts.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut kept: Vec<(f32, [f32; 3])> = Vec::with_capacity(pts.len()); + for p in pts { + let fits = kept.last().is_none_or(|q| { + let (a, b) = (log_point(q.1), log_point(p.1)); + q.0 < p.0 && b[0] > a[0] && b[1] < a[1] + }); + if fits { + kept.push(p); + } + } + KelvinPresets::new(kept) } /// Temperature (K) and tint of the white these multipliers balance (see the module docs); `None` for unusable @@ -94,13 +139,17 @@ impl KelvinPresets { return None; } let q = log_point(wb_multipliers); - let last = self.points.len() - 2; + let last = self.points.len().saturating_sub(2); let mut best: Option<(f64, f64, f64)> = None; for (i, w) in self.points.windows(2).enumerate() { let ((k0, m0), (k1, m1)) = (w[0], w[1]); let (p0, p1) = (log_point(m0), log_point(m1)); let d = [p1[0] - p0[0], p1[1] - p0[1]]; let len = d[0].hypot(d[1]); + // only a deserialized curve can repeat a point + if !(len > 0.0 && len.is_finite()) { + continue; + } let t = [d[0] / len, d[1] / len]; // the normal towards higher red and blue: a green white let n = if t[0] - t[1] < 0.0 { [t[1], -t[0]] } else { [-t[1], t[0]] }; @@ -158,7 +207,7 @@ mod tests { #[test] fn a_preset_reads_as_its_own_temperature_with_no_tint() { let p = a99m2(); - for (k, m) in p.points { + for &(k, m) in &p.points { let (t, tint) = p.temp_tint(m).unwrap(); assert!((t - k as f64).abs() < 0.5 && tint.abs() < 1e-6, "{k}: {t} {tint}"); // the scale of the multipliers doesn't matter, only their ratios to green @@ -200,26 +249,66 @@ mod tests { assert_eq!(p.temp_tint([0.0, 1.0, 1.0]), None); assert_eq!(p.temp_tint([f32::NAN, 1.0, 1.0]), None); assert_eq!(p.as_shot_white(None), None); - let mut pts = p.points; + let mut pts = p.points.clone(); pts.swap(1, 2); assert_eq!(KelvinPresets::new(pts), None, "out of order"); - let mut pts = p.points; + let mut pts = p.points.clone(); pts[4].1 = pts[3].1; assert_eq!(KelvinPresets::new(pts), None, "no step"); - let mut pts = p.points; + let mut pts = p.points.clone(); pts[0].1[0] = 0.0; assert_eq!(KelvinPresets::new(pts), None); assert_eq!(as_shot_white(&ColorData::default(), Some([2.0, 1.0, 1.5])), None); + assert_eq!(KelvinPresets::new(p.points[..2].to_vec()), None, "too few"); let with = ColorData { kelvin_presets: Some(p), ..Default::default() }; assert!(as_shot_white(&with, Some([2.0, 1.0, 1.5])).is_some()); } + /// Canon lists its presets by name, not by temperature, with repeats and an off-curve one among them: the + /// curve takes them in Kelvin order, each temperature once, and leaves out a point that would turn it back. + #[test] + fn a_curve_from_presets_in_any_order() { + let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; + let p = KelvinPresets::from_unordered([ + (5651.0, g(1418.0, 1357.0)), + (7505.0, g(1653.0, 1171.0)), + (6594.0, g(1533.0, 1259.0)), + (3174.0, g(963.0, 2016.0)), + (5651.0, g(1418.0, 1357.0)), + (8000.0, g(1400.0, 1400.0)), + (8529.0, g(1807.0, 1080.0)), + (f32::NAN, g(1000.0, 1000.0)), + ]) + .unwrap(); + assert_eq!(p.points.len(), 5); + assert!(p.points.windows(2).all(|w| w[0].0 < w[1].0)); + let (t, tint) = p.temp_tint(g(1653.0, 1171.0)).unwrap(); + assert!((t - 7505.0).abs() < 1.0 && tint.abs() < 0.1, "{t} {tint}"); + assert_eq!(KelvinPresets::from_unordered([(5000.0, g(1400.0, 1400.0)), (6000.0, g(1500.0, 1300.0))]), None); + assert_eq!(KelvinPresets::from_unordered(std::iter::empty()), None); + // a stored curve with a repeated point (only deserializing can make one) reads without dividing by zero + let bad = KelvinPresets { points: vec![(5000.0, g(1400.0, 1400.0)); 3] }; + assert_eq!(bad.temp_tint(g(1400.0, 1400.0)), None); + assert_eq!(KelvinPresets { points: vec![] }.temp_tint(g(1400.0, 1400.0)), None); + } + #[test] fn covered_models() { - for c in ["SONY ILCA-99M2", "SONY DSC-RX10", "SONY ILCA-77M2", "SONY SLT-A33", "ILCA-99M2", "Sony ilca-99m2"] { + for c in [ + "SONY ILCA-99M2", + "SONY DSC-RX10", + "SONY ILCA-77M2", + "SONY SLT-A33", + "ILCA-99M2", + "Sony ilca-99m2", + "Canon Canon PowerShot G1 X Mark III", + "Canon PowerShot G1 X Mark III", + ] { assert!(covers_camera(c), "{c}"); } - for c in ["SONY ILCE-7M3", "SONY DSC-RX10M4", "Canon SLT-A33", "", "SONY"] { + for c in + ["SONY ILCE-7M3", "SONY DSC-RX10M4", "Canon SLT-A33", "", "SONY", "Canon Canon PowerShot G1 X Mark II", "SONY PowerShot G1 X Mark III"] + { assert!(!covers_camera(c), "{c}"); } assert!(covers(Some("SONY"), "ILCA-99M2") && covers(None, "ILCA-99M2") && !covers(Some("NIKON CORPORATION"), "ILCA-99M2")); diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 202983331..de0a3d9a0 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -647,7 +647,7 @@ pub struct ColorData { /// "Camera local tone mapping" option asks for it (`lightcraft_pipeline::local_tone`). #[serde(default)] pub profile: profile::ProfileLook, - /// The camera's white-balance presets at stated temperatures (Sony ARW), which give a raw without colour + /// The camera's white-balance presets at stated temperatures (Sony ARW, Canon CR2), which give a raw without colour /// matrices a Kelvin scale ([`kelvin_presets`]). Not a DNG tag: nothing else reads it. #[serde(default)] pub kelvin_presets: Option, diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index 3fe173e09..6b4b9f93e 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -19,6 +19,8 @@ //! - `ColorBalance` (maker note `0x4001`): as-shot `RGGB` levels at a model-dependent offset; we probe the known //! offsets and accept the first plausible quadruple. The array is 16-bit words; some models store it as UNDEFINED //! bytes (ColorData versions -3 and -4), which are paired up in the maker note's byte order first. +//! - The same array lists the levels of the camera's white-balance presets, each with the colour temperature Canon +//! gives it ([`kelvin_presets_from_color_data`]): a Kelvin scale for a body without colour matrices. //! - sRAW / mRAW (raw IFD tag `0xc6c5` = 4) hold subsampled YCbCr instead of a mosaic: see [`sraw`]. mod sraw; @@ -90,6 +92,41 @@ fn wb_from_color_balance(v: &[u64]) -> Option<[f32; 3]> { None } +/// Where the planckian white-balance presets sit in `ColorData`, by `ColorDataVersion` (word 0, signed): the word +/// index of each preset's `R G G B` levels and of its colour temperature. Daylight, Shade, Cloudy, Tungsten and +/// Flash, in that order. Fluorescent is left out (a green light off the locus) and so is the Kelvin row (it holds the +/// dial's last setting, and on some bodies a temperature that doesn't match its own levels: 6036 K with the camera at +/// 5500 K on the PowerShot G1 X Mark III, Daylight's levels at 5213 K on the EOS 2000D). Positions from the ExifTool +/// Canon tag-name documentation's `ColorData5` / `ColorData8` tables, confirmed with ExifTool run as a black-box tool +/// on files of each version: -4 on a PowerShot G1 X Mark III (`ColorData5`: 8-word records, the second record after +/// Daylight a repeat of it), 13, 14 and 15 on the CC0 corpus samples of the EOS 5D Mark IV, 2000D and 6D Mark II +/// (`ColorData8`: 5-word records). Other versions give no presets. +fn preset_rows(version: i16) -> Option<[(usize, usize); 5]> { + match version { + -4 => Some([(95, 102), (111, 118), (119, 126), (127, 134), (151, 158)]), + 13..=15 => Some([(133, 137), (138, 142), (143, 147), (148, 152), (163, 167)]), + _ => None, + } +} + +/// The camera's planckian white-balance presets from the `ColorData` words ([`preset_rows`]) as a Kelvin curve; +/// `None` for an unknown version, a short array or presets that don't make a curve. +fn kelvin_presets_from_color_data(v: &[u64]) -> Option { + let version = *v.first()? as u16 as i16; + let rows = preset_rows(version)?; + let mut points = Vec::with_capacity(rows.len()); + for (at, temp_at) in rows { + let q = v.get(at..at + 4)?; + let temp = *v.get(temp_at)? as f32; + if let Some(wb) = wb_from_levels(q[0] as f32, q[1] as f32, q[2] as f32, q[3] as f32) + && (1000.0..=20000.0).contains(&temp) + { + points.push((temp, wb)); + } + } + crate::kelvin_presets::KelvinPresets::from_unordered(points) +} + /// As-shot WB multipliers of a body without `ColorData` (the PowerShot G9): the WBInfo row of the preset that /// ShotInfo's white-balance value names. WBInfo is int32 values in the maker note's byte order, four per preset in /// G R B G order, at the rows the ExifTool Canon tag-name documentation gives (2 Auto, 10 Daylight, 18 Cloudy, @@ -219,11 +256,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { active = Rect::new(l, tp, r - l + 1, b - tp + 1); } } - let wb = mn - .as_ref() - .and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order))) - .and_then(|v| wb_from_color_balance(&v)) - .or_else(|| wb_from_wb_info(mn.as_ref()?)); + let color_data = mn.as_ref().and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order))); + let kelvin_presets = color_data.as_deref().and_then(kelvin_presets_from_color_data); + let wb = color_data.as_deref().and_then(wb_from_color_balance).or_else(|| wb_from_wb_info(mn.as_ref()?)); let cfa = cfa_from_tag(raw.u64(CR2_CFA_PATTERN)).unwrap_or_else(|| Cfa::bayer_static("RGGB")); let white = white_from_data(&active_rows(&data, width, active), prec as u32); let black = match masked_columns(&data, width, active, white) { @@ -246,7 +281,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { active_area: active, crop: Rect::new(0, 0, active.width, active.height), orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)), - color: ColorData::default(), + color: ColorData { kelvin_presets, ..Default::default() }, wb_multipliers: wb, linearized: false, pixel_aspect: 1.0, @@ -497,6 +532,78 @@ pub(crate) mod tests { } } + /// `ColorData` words of `version` with the planckian presets (R, B over G = 1024, Kelvin) at its + /// [`preset_rows`], a Fluorescent-like green row between them, and the as-shot levels at word 71 / 63. + fn color_data_with_presets(version: i16, presets: &[(u16, u16, u16)]) -> Vec { + let mut v = vec![0u64; 400]; + v[0] = u64::from(version as u16); + for ((at, temp_at), &(r, b, k)) in preset_rows(version).unwrap().into_iter().zip(presets) { + v[at..at + 4].copy_from_slice(&[r.into(), 1024, 1024, b.into()]); + v[temp_at] = k.into(); + } + v + } + + /// Issue #157 of a downstream fork: the PowerShot G1 X Mark III (`ColorData` -4) gets a Kelvin curve from its + /// Daylight, Shade, Cloudy, Tungsten and Flash presets (levels and temperatures as its files carry them), which + /// puts a preset's own levels at the preset's temperature; the EOS bodies of versions 13–15 likewise. + #[test] + fn kelvin_presets_from_color_data_presets() { + let g1x3 = [(1418, 1357, 5651), (1653, 1171, 7505), (1533, 1259, 6594), (963, 2016, 3174), (1807, 1080, 8529)]; + let v = color_data_with_presets(-4, &g1x3); + let p = kelvin_presets_from_color_data(&v).unwrap(); + for (r, b, k) in g1x3 { + let (t, tint) = p.temp_tint([r as f32 / 1024.0, 1.0, b as f32 / 1024.0]).unwrap(); + assert!((t - k as f64).abs() < 1.0 && tint.abs() < 0.1, "{k}: {t} {tint}"); + } + // levels between Tungsten and Daylight: a temperature between them + let (t, _) = p.temp_tint([1264.0 / 1024.0, 1.0, 1537.0 / 1024.0]).unwrap(); + assert!((3174.0..5651.0).contains(&t), "{t}"); + let eos = [(2013, 1479, 5200), (2300, 1280, 7000), (2153, 1374, 6000), (1442, 2089, 3200), (2203, 1371, 6156)]; + for version in [13, 14, 15] { + assert!(kelvin_presets_from_color_data(&color_data_with_presets(version, &eos)).is_some(), "{version}"); + } + // a preset that would turn the curve back (Flash cooler than Shade's levels say) is dropped, not fatal + let mut odd = g1x3; + odd[4] = (1500, 1300, 9000); + assert!(kelvin_presets_from_color_data(&color_data_with_presets(-4, &odd)).is_some()); + } + + /// Unknown versions, short or garbled arrays give no presets and never panic. + #[test] + fn kelvin_presets_from_hostile_color_data() { + let g1x3 = [(1418, 1357, 5651), (1653, 1171, 7505), (1533, 1259, 6594), (963, 2016, 3174), (1807, 1080, 8529)]; + let good = color_data_with_presets(-4, &g1x3); + let mut other = good.clone(); + other[0] = u64::from(-3i16 as u16); + assert!(kelvin_presets_from_color_data(&other).is_none(), "unverified version"); + assert!(kelvin_presets_from_color_data(&[]).is_none()); + for len in [1, 96, 120, 157, 158] { + assert!(kelvin_presets_from_color_data(&good[..len]).is_none(), "{len}"); + } + let mut zeros = good.clone(); + for at in [102, 118, 126] { + zeros[at] = 0; + } + assert!(kelvin_presets_from_color_data(&zeros).is_none(), "two points left"); + let mut seed = 0x2545_f491_4f6c_dd1du64; + for _ in 0..2000 { + let v: Vec = (0..200) + .map(|i| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + if i == 0 { [0xfffc, 13, 14, 15][(seed % 4) as usize] } else { seed % 70000 } + }) + .collect(); + let _ = kelvin_presets_from_color_data(&v); + } + // through a whole decode, ColorData as UNDEFINED bytes + let bytes: Vec = good.iter().flat_map(|&w| (w as u16).to_le_bytes()).collect(); + assert!(decode_with_color_data(7, &bytes).color.kelvin_presets.is_some()); + assert!(decode_with_color_data(7, &bytes[..200]).color.kelvin_presets.is_none()); + } + /// Masked rows above the image full of samples at 16383 (EOS 50D, issue #1272): the white level is the image's /// own saturation plateau, not the edge of that strip, so clipped highlights still reach 1.0. #[test] diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 9a259e9ed..eae582ead 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -51,6 +51,8 @@ Vendor RGB WB multipliers alone do **not** identify an absolute illuminant witho **Kelvin through the camera's white-balance presets.** Sony ARWs store, in the encrypted `SR2SubIFD`, the multipliers of every white-balance preset, five of them at a stated temperature (2500, 3200, 4500, 6000 and 8500 K). For a camera without measured matrices whose files carry them (`lightcraft_raw::kelvin_presets`: Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S) they take the matrices' place in everything above: the as-shot multipliers are placed on the curve through the five presets (mired linear along it, in log red/green and blue/green), an offset from the curve is the tint, and the decision, the Bradford adaptation, the pixel-identical upgrade and `Op::SetAsShotWb` are the same. The scale is Sony's own: files shot on the Kelvin setting read back what the dial said (ILCA-77M2 3400 K → 3396 K and 5200 K → 5193 K, SLT-A33 4700 K → 4708 K, the public ILCE-3500 sample's 5600 K → 5594 K; with the colour filter one step towards magenta, the SLT-A33's 3400 K reads 3473 K / +11), and the Shade preset reads 7493 K on the public ILCE-7S sample. Off the curve, two constants come from the 14 Sony bodies that have both measured matrices and presets: the tint is the offset scaled by the locus's own spacing (the matrices give 0.90–1.04 of it), and the isotherms lean against the curve's normal (`ISOTHERM_TILT` 0.74, measured 0.59–0.84). On 150 ILCE-7SM2 photos read both ways, the presets' temperature tracks the matrices' to ±4 mired around Sony's 8 % higher scale, and the tint to −9 ± 4. A listed camera whose file has no presets keeps the Kelvin scale with As Shot at D65, as a covered camera without multipliers does. Bodies with measured matrices keep reading through them. +Canon CR2s list their presets by name in the maker note's `ColorData` (`0x4001`), each with Canon's colour temperature for it: for versions -4 and 13–15 the curve is built the same way from the Daylight, Shade, Cloudy, Tungsten and Flash rows (sorted by temperature; Fluorescent, a green light off the locus, and the Kelvin row, which holds the dial's last setting, are left out). Only the PowerShot G1 X Mark III is enabled, checked on a reporter's nine photos, one per white-balance setting: the as-shot levels read 4160 / 4580 / 5995 / 5215 / 2816 / 3703 / 7162 / 5368 / 4813 K (Auto, Daylight, Shade, Cloudy, Tungsten, Fluorescent at tint +31, Flash, Underwater, Kelvin 5500) where the camera's own `ColorTempAsShot` says 4306 / 4703 / 6005 / 5272 / 2770 / 3533 / 7100 / 5347 / 4925 K. Those photos were shot with the white-balance shift 8 steps (of 9) towards blue (`WBShiftAB` −8), which is why a Daylight shot reads about 4600 K and not the Daylight preset's 5651 K: the levels the camera applied are bluer than the preset's, and the camera's own number agrees. + What the matrices say is not verified against Lightroom's Kelvin. On the ILCE-7M3 a Color Temp. series reads 7–10 % below Sony's dial through them, smoothly and monotonically, with tint near the locus (issue #730); whether the matrices or the dial are off is open (a grey card under an incandescent bulb would tell, since the Illuminant A matrix is exact there by construction). Public samples: ILCE-7M3 AWB 4268 K / +7, ILCE-7M4 AWB 4344 K / +26, GFX100S on its Daylight preset 4659 K / +9 (Fujifilm's nominal daylight is about 5300 K: the same direction and size of gap). Sony's proprietary `ColorMatrix` is not assumed to be a DNG XYZ-to-camera matrix. No Adobe profile/matrix or GPL decoder/calibration source is used. The product remains pure Rust. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..86c0c8bb0 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -193,7 +193,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | | | LR-EDIT-COLOR-WB-PRESET | White-balance presets | P0 | ✅ | `cmd:develop.wb` | | | LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick`, `crates/ui-egui/src/tests_wb_selector.rs` | the Edit panel's selector button does what W does (from Compare, Survey or the grid it opens the photo in Detail); no magnified loupe while picking | -| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`), `crates/develop/src/controls.rs` (`ControlSpec::position`), `crates/raw/src/kelvin_presets.rs` | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford), or read from the camera's own white-balance presets (Sony ARW `SR2SubIFD`, Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S: Sony's Kelvin dial reads back within 0.3 % on 5 settings of 4 bodies); raws without matrices anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). The Kelvin slider's track is Lightroom's: 7000 K in the middle, 2000–7000 K evenly on the left half, logarithmic up to 50000 K on the right (within 2 % of positions measured in Lightroom Classic), for drawing, clicks and drags; wheel / arrow-key nudges and Quick Develop still step a fixed 50 / 100 / 500 K; the relative slider stays linear. Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open; raws without matrices or presets anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open | +| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`), `crates/develop/src/controls.rs` (`ControlSpec::position`), `crates/raw/src/kelvin_presets.rs` | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford), or read from the camera's own white-balance presets (Sony ARW `SR2SubIFD`, Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S: Sony's Kelvin dial reads back within 0.3 % on 5 settings of 4 bodies; Canon CR2 `ColorData` presets, PowerShot G1 X Mark III: the camera's own As Shot temperature reads back within 4 % on its 9 white-balance settings); raws without matrices anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). The Kelvin slider's track is Lightroom's: 7000 K in the middle, 2000–7000 K evenly on the left half, logarithmic up to 50000 K on the right (within 2 % of positions measured in Lightroom Classic), for drawing, clicks and drags; wheel / arrow-key nudges and Quick Develop still step a fixed 50 / 100 / 500 K; the relative slider stays linear. Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open; raws without matrices or presets anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open | | LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint`, `docs/tint-direction.md` | DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP `crs:Tint` values render as in Lightroom (issue #188); CPU/GPU regression tests | | LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | | | LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | `ctl:color.saturation` | | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index d9cc06a93..858f465ea 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-14 · **Change:** minor (integration batch 36: 515 models verified after the CC0 per-model checks; Epson ERF and Mamiya MEF decode; measured Nikon image areas) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Canon ColorData Kelvin; integration batch 36: 515 models verified after the CC0 per-model checks; Epson ERF and Mamiya MEF decode; measured Nikon image areas) · **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 @@ -209,7 +209,7 @@ sample files. A pointer for contributors, not legal advice. | Camera Matching looks | 481 models (plus phone-maker looks) | none (the per-file JPEG fit imitates the camera's own look) | | Creative profiles | Adobe Color, Portrait, Landscape, Vivid, Monochrome… | our own looks (Color, Neutral, Vivid, Landscape, Portrait, Monochrome) | | DCP / dual-illuminant model | ✅ | dual-illuminant DNG colour (ColorMatrix / ForwardMatrix 1-2, CameraCalibration, AnalogBalance), HueSatMap, LookTable, ProfileToneCurve; external `.dcp` never loaded (clean-room) | -| White balance in Kelvin | ✅ for every model | absolute Kelvin where the camera has colour matrices (its own, or one of the 52 spectral rows), also for files whose look is a per-file fit (#730), or where Sony's white-balance presets in the file give the scale (6 bodies, `kelvin_presets`); other raws keep the relative scale (#296) | +| White balance in Kelvin | ✅ for every model | absolute Kelvin where the camera has colour matrices (its own, or one of the 52 spectral rows), also for files whose look is a per-file fit (#730), or where the camera's white-balance presets in the file give the scale (6 Sony bodies and the Canon PowerShot G1 X Mark III, `kelvin_presets`); other raws keep the relative scale (#296) | **How far the default rendering is from Adobe's** (CIE ΔE; measured where stated): @@ -287,6 +287,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Kelvin white balance from Canon CR2 `ColorData` presets (PowerShot G1 X Mark III) | | 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277) | | 2026-10-13 | minor | Bundled camera profiles 4 → 5 (ILCA-99M2) | | 2026-10-14 | minor | 21 Sony and Leica models verified (one CC0 raw.pixls.us file each, pinned; `sony_leica_corpus.rs`): 258 verified, 920 unverified; Leica on a verified body 21% → 72%, Sony 91% → 92%, use-weighted 85% and 43% | From 14ede0b7d968e6029e1a786727e1c367ed768836 Mon Sep 17 00:00:00 2001 From: Aleksandr Batyrev Date: Fri, 16 Oct 2026 19:58:40 +0500 Subject: [PATCH 18/36] fix(camera-profiles): fingerprint subsecond profile modification times --- crates/engine/src/camera_profiles.rs | 80 ++++++++++++++++++++-------- 1 file changed, 57 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs index d13023b6e..c7b6c437b 100644 --- a/crates/engine/src/camera_profiles.rs +++ b/crates/engine/src/camera_profiles.rs @@ -159,29 +159,31 @@ pub fn get(model: &str) -> Option> { /// keys, so thumbnails rendered before a profile existed are not reused after. pub fn cache_key() -> u64 { static KEY: OnceLock = OnceLock::new(); - *KEY.get_or_init(|| { - let entries = dir().and_then(|d| std::fs::read_dir(d).ok()); - let mut files: Vec<(String, u64, u64)> = entries - .into_iter() - .flatten() - .flatten() - .filter(|e| e.path().extension().is_some_and(|x| x == "json")) - .filter_map(|e| { - let meta = e.metadata().ok()?; - let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(); - Some((e.file_name().to_string_lossy().into_owned(), meta.len(), modified)) - }) - .collect(); - files.sort(); - let mut h = lightcraft_preview::Hasher128::new(); - for (name, len, modified) in &files { - h.str(name).u64(*len).u64(*modified); - } - for (model, json) in BUNDLED { - h.str(model).str(json); - } - h.finish().0 as u64 - }) + *KEY.get_or_init(|| cache_key_for(dir().as_deref())) +} + +fn cache_key_for(directory: Option<&Path>) -> u64 { + let entries = directory.and_then(|d| std::fs::read_dir(d).ok()); + let mut files: Vec<(String, u64, std::time::Duration)> = entries + .into_iter() + .flatten() + .flatten() + .filter(|e| e.path().extension().is_some_and(|x| x == "json")) + .filter_map(|e| { + let meta = e.metadata().ok()?; + let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?; + Some((e.file_name().to_string_lossy().into_owned(), meta.len(), modified)) + }) + .collect(); + files.sort(); + let mut h = lightcraft_preview::Hasher128::new(); + for (name, len, modified) in &files { + h.str(name).u64(*len).u64(modified.as_secs()).u64(u64::from(modified.subsec_nanos())); + } + for (model, json) in BUNDLED { + h.str(model).str(json); + } + h.finish().0 as u64 } /// Per camera model: the photos read and their pooled colour pairs. @@ -294,6 +296,38 @@ mod tests { assert!(bundled("No Such Camera").is_none()); } + #[test] + fn same_second_profile_replacement_changes_render_cache_key() { + let dir = std::env::temp_dir().join(format!("lc-camera-profile-cache-{}", std::process::id())); + let mut first = profile(); + first.model = "ILCE-7CR".into(); + let path = save(&first, &dir).unwrap(); + let set_modified = |millis| { + let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000) + std::time::Duration::from_millis(millis); + let file = std::fs::OpenOptions::new().write(true).open(&path).unwrap(); + file.set_times(std::fs::FileTimes::new().set_modified(time)).unwrap(); + file.metadata().unwrap() + }; + let before = set_modified(100); + assert_eq!(load(&path).unwrap(), first); + let before_key = cache_key_for(Some(&dir)); + + let mut second = first; + second.matrix[0][0] = 1.7; + save(&second, &dir).unwrap(); + let after = set_modified(350); + assert_eq!(load(&path).unwrap(), second); + assert_eq!(before.len(), after.len()); + let before_time = before.modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap(); + let after_time = after.modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap(); + assert_eq!(before_time.as_secs(), after_time.as_secs()); + assert_ne!(before_time.subsec_nanos(), after_time.subsec_nanos()); + let after_key = cache_key_for(Some(&dir)); + let _ = std::fs::remove_dir_all(dir); + + assert_ne!(before_key, after_key, "a changed profile must not reuse the previous render cache"); + } + #[test] fn file_names_are_safe() { assert_eq!(file_name("ILCE-7M4"), "ILCE-7M4.json"); From 2e23875ed39e42c63daa7c359714d9eec649458e Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:12:36 +0300 Subject: [PATCH 19/36] Make encoding staleness reach every smart preview and keep refreshes off the proxy cache A proxy whose stored pixels are an older encoding is stale for every photo, not only the ones that take the per-file camera look, so DNG and JPEG proxies migrate to the current encoding like the raws do. And a source decoded from a smart preview now knows it came from the cache: Build Smart Previews refuses it as the original, so an offline refresh can no longer rebuild a stale proxy from itself. --- crates/engine/src/cmd/previews.rs | 44 ++++++++------- crates/engine/src/media.rs | 6 +- crates/engine/src/smart.rs | 34 +++++++---- crates/engine/src/tests_import.rs | 93 +++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index d4969db80..a3b56dee0 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -178,7 +178,7 @@ fn build(s: &mut Session, p: &Value) -> Result { /// One photo's smart preview to build or discard: (photo, its file in the smart previews folder, /// how to load the original at preview size, whether the photo takes the per-file camera look — /// `camera_preview::file_local_look`, [`lightcraft_catalog::Photo::fitted_look`]: only those -/// proxies go stale when the fit changes). +/// proxies go stale when the fit changes; an older encoding makes any proxy stale). #[cfg(not(target_arch = "wasm32"))] type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option, bool); @@ -205,15 +205,16 @@ struct SmartCounts { failed: Vec, } -/// `source` if it reads the photo's original, else `None`: a cached source decoded from a smart -/// preview (it has no decoder facts), a proxy standing in for an offline original, and the proxy -/// fallback of a file source are not the original. +/// `source` if it reads the photo's original, else `None`. A source decoded from a smart preview +/// (it now carries the proxy's stored facts), a proxy standing in for an offline original, and the +/// proxy fallback of a file source are not the original: refreshing a stale proxy from one of them +/// would stamp its older look as current. #[cfg(not(target_arch = "wasm32"))] fn original_only(source: crate::media::SourceRef) -> Option { use crate::media::SourceRef; match source { SourceRef::File { path, max_edge, loader, denoise, .. } => Some(SourceRef::File { path, max_edge, loader, fallback: None, denoise }), - SourceRef::Loaded(d) if d.info.is_some() => Some(SourceRef::Loaded(d)), + SourceRef::Loaded(d) if d.info.is_some() && !d.from_proxy => Some(SourceRef::Loaded(d)), _ => None, } } @@ -252,10 +253,11 @@ fn smart_run( } } else { // a complete, current proxy is kept; a missing or damaged one (cut short by a crash or a - // full drive) is (re)built, and so is one written by an older camera-look fit (see - // `camera_preview::LOOK_VERSION`) when its original can be read + // full drive) is (re)built, and so is one written by an older encoding or camera-look + // fit (see `smart::ENCODE_VERSION` and `camera_preview::LOOK_VERSION`) when its + // original can be read let damaged = path.exists(); - let stale = look && damaged && crate::smart::is_stale(&path); + let stale = damaged && crate::smart::is_stale_for(&path, look); if damaged && !stale && crate::smart::is_valid(&path) { n.built += 1; } else { @@ -363,14 +365,14 @@ fn smart(s: &mut Session, p: &Value) -> Result { ) } -/// Bring smart previews written by an older camera-look fit up to date, once per fit version: when -/// the smart previews folder has not been checked at this `LOOK_VERSION` (its marker, -/// [`crate::smart::look_checked`]), a worker thread (the UI never waits on the drives) looks at the -/// proxies of the raws that take the per-file camera look, the only ones the fit reaches (JPEG, -/// PNG… proxies are never rebuilt), and rebuilds the stale ones from their originals. One whose -/// original can't be read (offline) is kept as it is, still marked stale in its header: Build -/// Smart Previews rebuilds it once the original is back. Only existing proxies are touched: none -/// is built, and a missing folder is never created. Returns the thread, if started. +/// Bring smart previews up to date, once per fit-and-encoding version: when the smart previews +/// folder has not been checked at this build's versions (its marker, [`crate::smart::look_checked`]), +/// a worker thread (the UI never waits on the drives) looks at the proxies of the photos on disk +/// and rebuilds the stale ones from their originals — one whose stored pixels are an older encoding +/// (every photo), or, for a raw that takes the per-file camera look, one written by an older fit. +/// One whose original can't be read (offline) is kept as it is, still marked stale in its header: +/// Build Smart Previews rebuilds it once the original is back. Only existing proxies are touched: +/// none is built, and a missing folder is never created. Returns the thread, if started. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option> { let dir = s.media.smart_dir.clone()?; @@ -378,9 +380,11 @@ pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && p.fitted_look()).collect(); - let jobs: Vec = - photos.iter().map(|p| (p.id, crate::smart::sharded_file_path(&dir, p), Some(s.media.origin_ref(&p.source, max_edge)), true)).collect(); + let photos: Vec<_> = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. })).collect(); + let jobs: Vec = photos + .iter() + .map(|p| (p.id, crate::smart::sharded_file_path(&dir, p), Some(s.media.origin_ref(&p.source, max_edge)), p.fitted_look())) + .collect(); std::thread::Builder::new() .name("lc-smart-look".into()) .spawn(move || refresh_stale(&dir, jobs)) @@ -394,7 +398,7 @@ fn refresh_stale(dir: &std::path::Path, jobs: Vec) { let stale: Vec = jobs .into_iter() .map(|(id, path, source, look)| (id, crate::smart::existing_file_path(dir, path), source, look)) - .filter(|(_, path, _, _)| crate::smart::is_stale(path)) + .filter(|(_, path, _, look)| crate::smart::is_stale_for(path, *look)) .collect(); if !stale.is_empty() { // `custom`: the folder is there already, and must not be recreated if it went away since diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 0688410ed..bed1a9835 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -183,11 +183,14 @@ pub struct DecodedSource { pub camera_tone: Option, /// The same picture developed from the AI-denoised mosaic, when the photo has one cached. pub denoised: Option>, + /// These pixels came from the photo's smart preview (an offline original), not from its file. + /// A refresh must never rebuild a proxy from itself: that would stamp its older look as current. + pub from_proxy: bool, } impl DecodedSource { pub fn new(image: Arc, info: Option) -> Self { - DecodedSource { image, info, camera_tone: None, denoised: None } + DecodedSource { image, info, camera_tone: None, denoised: None, from_proxy: false } } /// The picture to develop for a Denoise `amount` (0 to 1, see [`DevelopSettings::denoise_amount`]): the plain one, @@ -302,6 +305,7 @@ impl SourceRef { info: Some(info), camera_tone: None, denoised: twin.map(|t| Arc::new(Twin::new(Arc::new(t)))), + from_proxy: false, }) }) } else { diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs index 5a610aa52..d3e042a55 100644 --- a/crates/engine/src/smart.rs +++ b/crates/engine/src/smart.rs @@ -423,12 +423,15 @@ fn header_encode_version(head: &serde_json::Value) -> u64 { head.get(ENC_FIELD).and_then(serde_json::Value::as_u64).unwrap_or(0) } -/// Whether the complete proxy at `path` is out of date for this build: written by an older -/// camera-look fit than this build's (so its stored curve, and the colour baked into its pixels, are -/// out of date), or by an older encoding of its own pixels ([`ENCODE_VERSION`], i.e. a lower JPEG -/// quality). A proxy from a newer build is left alone (rebuilding it here would only undo that -/// build's work), as is one that can't be read: those are [`is_valid`]'s business. -pub fn is_stale(path: &Path) -> bool { +/// Whether the complete proxy at `path` is out of date for a photo and must be rebuilt: written by +/// an older encoding of its own pixels ([`ENCODE_VERSION`], i.e. a lower JPEG quality) — this +/// reaches every proxy — or, when the photo takes the per-file camera look (`look`, +/// [`lightcraft_catalog::Photo::fitted_look`]), by an older camera-look fit than this build's (so +/// its stored curve, and the colour baked into its pixels, are out of date; the fit only reaches +/// those raws, so a JPEG's or DNG's proxy is never stale for it). A proxy from a newer build is +/// left alone (rebuilding it here would only undo that build's work), as is one that can't be +/// read: those are [`is_valid`]'s business. +pub fn is_stale_for(path: &Path, look: bool) -> bool { use std::io::Read; if !is_valid(path) { return false; @@ -440,9 +443,14 @@ pub fn is_stale(path: &Path) -> bool { } let Some(rest) = head.strip_prefix(MAGIC) else { return false }; let Some(nl) = rest.iter().position(|b| *b == b'\n') else { return false }; - rest.get(..nl) - .and_then(|h| serde_json::from_slice::(h).ok()) - .is_some_and(|h| header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION) || header_encode_version(&h) < ENCODE_VERSION) + rest.get(..nl).and_then(|h| serde_json::from_slice::(h).ok()).is_some_and(|h| { + header_encode_version(&h) < ENCODE_VERSION || (look && header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION)) + }) +} + +/// [`is_stale_for`] for a photo that takes the per-file camera look: both conditions apply. +pub fn is_stale(path: &Path) -> bool { + is_stale_for(path, true) } /// The marker file in a smart previews folder: the camera-look fit version and encode version its @@ -480,7 +488,13 @@ pub fn mark_look_checked(dir: &Path) -> Result<(), String> { /// Load the proxy at `path`. pub fn load(path: &Path) -> Result { let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; - decode(&b).map(|(image, camera_tone, info)| crate::media::DecodedSource { image: Arc::new(image), info, camera_tone, denoised: None }) + decode(&b).map(|(image, camera_tone, info)| crate::media::DecodedSource { + image: Arc::new(image), + info, + camera_tone, + denoised: None, + from_proxy: true, + }) } #[cfg(test)] diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index f0b50db37..be34b6f23 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -1555,6 +1555,99 @@ fn smart_previews_from_an_older_look_fit_are_refreshed() { let _ = std::fs::remove_dir_all(&lib); } +/// Issue #1534 (review): a proxy's encoding version is migrated independently of the per-file +/// camera look. A DNG keeps an absolute white balance, so it never takes that look; at head its +/// existing proxy was kept at an older encoding even with its original online, so the decoder +/// facts the newer encoding carries never reached it through a normal rebuild. +#[test] +fn a_proxy_with_an_older_encoding_is_refreshed_without_the_camera_look() { + let src = temp_dir("smartenc-src"); + let lib = temp_dir("smartenc-lib"); + write_png(&src.join("a.png"), 3); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + // the test loader decodes the catalogue photo's "DNG" pixels + let pixels = lightcraft_raster::Rgb32f::from_fn(48, 32, |x, y| [0.02 + (x % 9) as f32 * 0.05, 0.03 + (y % 7) as f32 * 0.04, 0.06]); + let info = lightcraft_pipeline::SourceInfo { raw: true, as_shot_temp: 5200.0, as_shot_tint: 6.0, ..Default::default() }; + let (p, i) = (pixels.clone(), info.clone()); + s.media.file_loader = Some(std::sync::Arc::new(move |_path, _max_edge| Ok((p.clone(), i.clone())))); + s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + let mut raw = (**s.catalog.photo(id).unwrap()).clone(); + raw.format = "DNG".into(); + raw.kind = lightcraft_catalog::MediaKind::Raw; + s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap(); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap(); + assert!(s.catalog.photo(id).unwrap().develops_raw() && !s.catalog.photo(id).unwrap().fitted_look(), "a DNG does not take the camera look"); + s.execute("library.select", &json!({"ids": [id.0], "active": id.0})).unwrap(); + let dir = s.media.smart_dir.clone().unwrap(); + let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap()); + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(1), Some(0)), "{r}"); + assert_eq!(smart_header(&file)["enc"], json!(crate::smart::ENCODE_VERSION)); + + // an existing proxy from the previous encoding, its original online: the migration must reach + // it through a normal rebuild even though the photo never takes the camera look + edit_smart_header(&file, |h| h["enc"] = json!(crate::smart::ENCODE_VERSION - 1)); + let stale_bytes = std::fs::read(&file).unwrap(); + assert!(crate::smart::is_stale(&file), "the older encoding is stale even without the camera look"); + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64(), r["staleKept"].as_u64()), (Some(1), Some(1), Some(0)), "{r}"); + assert_eq!(smart_header(&file)["enc"], json!(crate::smart::ENCODE_VERSION), "migrated to the current encoding"); + assert!(!crate::smart::is_stale(&file)); + assert_ne!(std::fs::read(&file).unwrap(), stale_bytes, "the proxy was rebuilt"); + let _ = std::fs::remove_dir_all(&src); + let _ = std::fs::remove_dir_all(&lib); +} + +/// Issue #1534 (review): a cached proxy source is not the original. After an offline edit rendered +/// from a stale proxy, Build Smart Previews read that cached source back and refreshed the proxy +/// from itself, stamping its older look as current. A stale refresh reads the original only, so an +/// offline original leaves the stored look — and the bytes — as they are. +#[test] +fn a_stale_refresh_never_rebuilds_a_proxy_from_itself() { + let src = temp_dir("smartself-src"); + let lib = temp_dir("smartself-lib"); + write_png(&src.join("a.png"), 3); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let pixels = lightcraft_raster::Rgb32f::from_fn(48, 32, |x, y| [0.02 + (x % 9) as f32 * 0.05, 0.03 + (y % 7) as f32 * 0.04, 0.06]); + let info = lightcraft_pipeline::SourceInfo { raw: true, as_shot_temp: 5200.0, as_shot_tint: 6.0, ..Default::default() }; + let (p, i) = (pixels.clone(), info.clone()); + s.media.file_loader = Some(std::sync::Arc::new(move |_path, _max_edge| Ok((p.clone(), i.clone())))); + s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + // a raw that takes the per-file camera look, so an older fit makes its proxy stale + let mut raw = (**s.catalog.photo(id).unwrap()).clone(); + raw.format = "ARW".into(); + raw.kind = lightcraft_catalog::MediaKind::Raw; + s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap(); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap(); + assert!(s.catalog.photo(id).unwrap().fitted_look()); + s.execute("library.select", &json!({"ids": [id.0], "active": id.0})).unwrap(); + let dir = s.media.smart_dir.clone().unwrap(); + let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap()); + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!(r["built"], 1, "{r}"); + edit_smart_header(&file, |h| h["look_version"] = json!(0)); + let stale_bytes = std::fs::read(&file).unwrap(); + assert!(crate::smart::is_stale(&file), "the stored look is behind this build's fit"); + + // the original goes offline, then a preview-size render loads the proxy into the source cache + std::fs::rename(&src, src.with_extension("offline")).unwrap(); + s.media.forget(id); + let _ = s.render_now(id, 600, 600).expect("renders from the smart preview"); + + // Build Smart Previews must not take that cached proxy for the original: it can't refresh a + // stale proxy from itself + let r = s.execute("library.smartPreviews", &json!({})).unwrap(); + assert_eq!((r["refreshed"].as_u64(), r["staleKept"].as_u64()), (Some(0), Some(1)), "{r}"); + assert_eq!(std::fs::read(&file).unwrap(), stale_bytes, "the offline proxy is preserved"); + assert!(crate::smart::is_stale(&file), "still stale: the original was not read"); + let _ = std::fs::remove_dir_all(src.with_extension("offline")); + let _ = std::fs::remove_dir_all(&lib); +} + /// The look marker: missing, damaged or older than this build's fit version counts as unchecked. #[test] fn smart_preview_look_marker_is_read_safely() { From 7f97bdbb5f65a630f035c164436f84ef0c3458d8 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Sun, 18 Oct 2026 18:47:23 +0200 Subject: [PATCH 20/36] LR-IMP-PREVIEW-READ: thumbnails of Lightroom HDR/panorama DNGs develop from the file's reduced raw copies A DNG that keeps reduced-resolution copies of its LinearRaw image (Lightroom merges keep 256, 512 and 2048 px ones) now develops a thumbnail or preview from the smallest copy that covers it, with the main image's levels and colour and its crop scaled. A 23836 x 7078 drone panorama's 512 px render went from 38 s to 1.1 s. Copies are skipped when the main image has opcode lists, a linearization table or black-level deltas, and for denoised photos. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/files.rs | 9 +++- crates/raw/src/dng.rs | 108 ++++++++++++++++++++++++++++++++----- crates/raw/src/lib.rs | 74 +++++++++++++++++++++++++ docs/parity-checklist.md | 2 +- docs/whats-new.md | 8 +++ 5 files changed, 186 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 31bb930fb..1f273f7ff 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -410,7 +410,14 @@ fn load_bytes_now( denoise: Option<&DenoiseSpec>, ) -> Result<(Rgb32f, Option, SourceInfo), String> { if lightcraft_raw::probe(&bytes).is_some() { - let mut raw = match lightcraft_raw::decode(&bytes).map_err(|e| preview_reason(&bytes, e)) { + // a thumbnail or preview of a DNG that keeps smaller copies of its raw image (Lightroom's merges) develops + // the smallest that covers it; a denoised photo's twin needs the full mosaic + let reduced = (denoise.is_none() && max_edge < usize::MAX).then(|| lightcraft_raw::decode_reduced(&bytes, max_edge).ok().flatten()).flatten(); + let decoded = match reduced { + Some(r) => Ok(r), + None => lightcraft_raw::decode(&bytes), + }; + let mut raw = match decoded.map_err(|e| preview_reason(&bytes, e)) { Ok(r) => r, Err(Ok(why)) => { // show the camera's embedded JPEG (rendered, not raw) until the variant is supported diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index e9fc0999d..bbea2c8c1 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -194,18 +194,74 @@ pub(crate) fn is_plain_cfa_tiff(tiff: &Tiff, bytes: &[u8]) -> bool { /// anchored at the sensor origin. pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result { let tiff = Tiff::parse(bytes)?; - let ifd0 = &tiff.ifds[0]; let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Corrupt("DNG without a raw image IFD".into()))?; + decode_ifd(bytes, &tiff, raw, raw, mode, format) +} + +/// The smallest reduced-resolution copy of a DNG's LinearRaw image whose default crop still has a long edge of +/// `min_long` px: Lightroom keeps 256, 512 and 2048 px copies beside the full image of its HDR and panorama +/// merges, so a thumbnail decodes a 512 px copy instead of a 24k × 7k image (about 19 s). Only a copy that the main +/// image's tags describe as well: same samples per pixel and aspect ratio (within 1 %), and a main image without +/// opcode lists, a linearization table or black-level deltas (all in full-size pixel coordinates). +fn reduced_ifd<'a>(tiff: &'a Tiff, main: &Ifd, min_long: usize) -> Option<&'a Ifd> { + if main.u16(t::PHOTOMETRIC) != Some(photometric::LINEAR_RAW) { + return None; + } + let full_size_tags = + [t::OPCODE_LIST_1, t::OPCODE_LIST_2, t::OPCODE_LIST_3, t::LINEARIZATION_TABLE, t::BLACK_LEVEL_DELTA_H, t::BLACK_LEVEL_DELTA_V]; + if full_size_tags.iter().any(|&tag| main.contains(tag)) { + return None; + } + let (mw, mh) = (main.u64(t::IMAGE_WIDTH)?, main.u64(t::IMAGE_LENGTH)?); + if mw == 0 || mh == 0 { + return None; + } + let crop_long = match main.f64s(t::DEFAULT_CROP_SIZE).as_deref() { + Some([w, h]) if w.is_finite() && h.is_finite() && *w >= 1.0 && *h >= 1.0 => w.max(*h).min(mw.max(mh) as f64), + _ => mw.max(mh) as f64, + }; + let cpp = main.u16(t::SAMPLES_PER_PIXEL).unwrap_or(1); + tiff.all_ifds() + .into_iter() + .filter(|i| i.u32(t::NEW_SUBFILE_TYPE) == Some(1) && i.u16(t::PHOTOMETRIC) == Some(photometric::LINEAR_RAW)) + .filter(|i| i.u16(t::SAMPLES_PER_PIXEL).unwrap_or(1) == cpp) + .filter_map(|i| { + let (w, h) = (i.u64(t::IMAGE_WIDTH)?, i.u64(t::IMAGE_LENGTH)?); + let (a, b) = (w.checked_mul(mh)?, h.checked_mul(mw)?); + let same_aspect = w > 0 && h > 0 && a.abs_diff(b).saturating_mul(100) <= a; + let covers = crop_long * w as f64 / mw as f64 >= min_long as f64; + (same_aspect && covers && w < mw && h < mh).then_some((i, w)) + }) + .min_by_key(|&(_, w)| w) + .map(|(i, _)| i) +} + +/// [`decode`] from the smallest reduced-resolution copy of the raw image that covers `min_long` ([`reduced_ifd`]): +/// the main image's levels, colour and metadata, its active area and default crop scaled to the copy. `None` when the +/// file keeps no such copy. +pub(crate) fn decode_reduced(bytes: &[u8], min_long: usize) -> Result> { + let tiff = Tiff::parse(bytes)?; + let Some(main) = raw_ifd(&tiff) else { return Ok(None) }; + let Some(small) = reduced_ifd(&tiff, main, min_long) else { return Ok(None) }; + decode_ifd(bytes, &tiff, small, main, Mode::Full, RawFormat::Dng).map(Some) +} + +/// [`decode_as`] of the samples in `pixels`, described by the raw IFD `main`: the same IFD, or (see +/// [`decode_reduced`]) a reduced-resolution copy of it, whose own level tags win and whose geometry is `main`'s +/// scaled to its size. +fn decode_ifd(bytes: &[u8], tiff: &Tiff, pixels: &Ifd, main: &Ifd, mode: Mode, format: RawFormat) -> Result { + let ifd0 = &tiff.ifds[0]; + let raw = main; // GoPro's GPR: a DNG whose raw data is VC-5 coded, which is not decoded (yet). Named here, before // the generic "TIFF compression 9" of the chunk decoder. Header mode still describes the file // (a GPR has no embedded preview, and a library keeps the photo with this reason shown, as it // does for any file whose pixels fail to decode, rather than refusing to import it). if mode == Mode::Full - && let Some(c) = raw.u16(t::COMPRESSION).filter(|&c| c == t::compression::VC5) + && let Some(c) = pixels.u16(t::COMPRESSION).filter(|&c| c == t::compression::VC5) { return Err(RawError::Unsupported(format!("DNG compression {c} (GoPro VC-5) is not decoded yet"))); } - let info = raw.image()?; + let info = pixels.image()?; let (w, h, cpp) = (info.width as usize, info.height as usize, info.samples_per_pixel as usize); // one sample (CFA or monochrome), RGB or four colours; two are neither and can't be developed (issue #1511) if !matches!(cpp, 1 | 3 | 4) { @@ -217,22 +273,25 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result = table.iter().map(|&x| x.min(65535) as u16).collect(); let last = table.len() - 1; v.iter_mut().for_each(|s| *s = table[(*s as usize).min(last)]); linearized = true; } + // a reduced copy's own levels, where it states them + let levels = |tag| if pixels.contains(tag) { pixels } else { raw }; let float = matches!(data, RawData::F32(_)); let default_white = if float { 1.0 } else { ((1u64 << bits.min(16)) - 1) as f32 }; - let mut white: Vec = raw.f64s(t::WHITE_LEVEL).unwrap_or_default().into_iter().map(|v| v as f32).filter(|v| *v > 0.0).collect(); + let mut white: Vec = + levels(t::WHITE_LEVEL).f64s(t::WHITE_LEVEL).unwrap_or_default().into_iter().map(|v| v as f32).filter(|v| *v > 0.0).collect(); if white.is_empty() { white = vec![default_white]; } @@ -247,14 +306,14 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result (*r as usize, *c as usize), _ => (1, 1), }; // (a FLOAT / DOUBLE tag can hold NaN or infinity: such a level reads as 0, in its place) let level = |v: f64| Some(v as f32).filter(|v| v.is_finite()).unwrap_or(0.0); - let mut values: Vec = raw.f64s(t::BLACK_LEVEL).unwrap_or_default().into_iter().map(level).collect(); + let mut values: Vec = levels(t::BLACK_LEVEL).f64s(t::BLACK_LEVEL).unwrap_or_default().into_iter().map(level).collect(); if values.len() != br * bc * cpp { values = vec![values.first().copied().unwrap_or(0.0)]; } @@ -263,11 +322,20 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result top && right > left && (*bottom as usize) <= h && (*right as usize) <= w => { + Some([top, left, bottom, right]) if bottom > top && right > left && (*bottom as usize) <= full_h && (*right as usize) <= full_w => { Rect::new(*left as usize, *top as usize, (*right - *left) as usize, (*bottom - *top) as usize) } - _ => Rect::new(0, 0, w, h), + _ => Rect::new(0, 0, full_w, full_h), }; let crop = match (raw.f64s(t::DEFAULT_CROP_ORIGIN).as_deref(), raw.f64s(t::DEFAULT_CROP_SIZE).as_deref()) { (Some([x, y]), Some([cw, ch])) if *cw >= 1.0 && *ch >= 1.0 && *x >= 0.0 && *y >= 0.0 => { @@ -275,6 +343,20 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result Rect::new(0, 0, active_area.width, active_area.height), }; + // the developed size, which the metadata reports whatever copy the samples come from + let developed = (crop.width, crop.height); + let (active_area, crop) = if (full_w, full_h) == (w, h) { + (active_area, crop) + } else { + let (sx, sy) = (w as f64 / full_w as f64, h as f64 / full_h as f64); + let scale = |r: Rect| { + let (x, y) = ((r.x as f64 * sx).round() as usize, (r.y as f64 * sy).round() as usize); + let (x1, y1) = (((r.x + r.width) as f64 * sx).round() as usize, ((r.y + r.height) as f64 * sy).round() as usize); + Rect::new(x, y, x1.saturating_sub(x).max(1), y1.saturating_sub(y).max(1)) + }; + let area = scale(active_area).clipped(w, h); + (area, scale(crop).clipped(area.width, area.height)) + }; let cfa = if info.photometric == photometric::CFA { if raw.u16(t::CFA_LAYOUT).unwrap_or(1) != 1 { @@ -319,9 +401,9 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result Result { decode_with(bytes, Mode::Full) } +/// [`decode`] at a smaller size, for a picture whose default crop needs a long edge of only `min_long` px: a DNG +/// that keeps reduced-resolution copies of its LinearRaw image (Lightroom's HDR and panorama merges keep 256 to 2048 +/// px ones) decodes the smallest that covers it. The image has the full one's levels, colour and metadata; its +/// active area and crop are scaled to its size. `Ok(None)` when the file keeps no such copy: decode it whole. +pub fn decode_reduced(bytes: &[u8], min_long: usize) -> Result> { + if probe(bytes) != Some(RawFormat::Dng) { + return Ok(None); + } + Ok(dng::decode_reduced(bytes, min_long)?.map(lift_clipped)) +} + /// Everything [`decode`] learns about a raw file except its samples: geometry, orientation, CFA, /// colour data, as-shot white balance, opcode lists (embedded lens corrections), metadata — read /// from the headers without decompressing the pixel data, for imports. Equal to @@ -1182,6 +1193,69 @@ mod tests { assert_eq!((info.width, info.height), (32, 16)); } + /// A LinearRaw DNG in the layout of Lightroom's merges: a `w` × `h` main image (samples 1000) with a default + /// crop, and reduced copies (samples 2000 + their width) of the given sizes beside it. + fn linear_dng_with_copies(w: u32, h: u32, copies: &[(u32, u32)], main_tags: &[(u16, Value)]) -> Vec { + let linear = |subfile: u32, w: u32, h: u32, sample: u16| { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![subfile])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::LINEAR_RAW])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set(t::WHITE_LEVEL, Value::Long(vec![32768])); + let samples: Vec = std::iter::repeat_n(sample.to_le_bytes(), (w * h * 3) as usize).flatten().collect(); + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![samples] }); + ifd + }; + let mut main = linear(0, w, h, 1000); + main.set(t::BLACK_LEVEL, Value::Short(vec![100])); + main.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![4, 2])); + main.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![w - 8, h - 4])); + for (tag, v) in main_tags { + main.set(*tag, v.clone()); + } + let mut ifd0 = rgb_ifd(4, 2); + ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 7, 0, 0])); + ifd0.set(t::MAKE, Value::Ascii("DJI".into())); + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); + ifd0.add_sub_ifd(main); + for &(cw, ch) in copies { + ifd0.add_sub_ifd(linear(1, cw, ch, 2000 + cw as u16)); + } + write(&[ifd0]) + } + + /// A thumbnail of a Lightroom merge decodes the smallest reduced copy of its raw image that covers it, with the + /// main image's levels and its crop scaled; nothing covers it, or the main image has full-size opcodes: `None`. + #[test] + fn reduced_copies_of_a_linear_raw_dng_decode_at_their_size() { + let file = linear_dng_with_copies(64, 32, &[(32, 16), (16, 8)], &[]); + let full = decode(&file).unwrap(); + assert_eq!((full.width, full.height, full.crop), (64, 32, Rect::new(4, 2, 56, 28))); + // a 12 px picture: the 16 px copy (its crop is 14 px long) + let small = decode_reduced(&file, 12).unwrap().expect("a copy covers 12 px"); + assert_eq!((small.width, small.height), (16, 8)); + assert_eq!(small.crop, Rect::new(1, 1, 14, 7)); + let RawData::U16(v) = &small.data else { panic!("16-bit samples") }; + assert!(v.iter().all(|&s| s == 2016), "the 16 px copy's samples"); + assert_eq!((small.white.clone(), small.black.values.clone()), (vec![32768.0], vec![100.0]), "its own white, the main black"); + assert_eq!((small.metadata.width, small.metadata.height), (Some(56), Some(28)), "the developed size is the photo's"); + // 20 px: the 32 px copy (28 px); 40 px: none covers it + assert_eq!(decode_reduced(&file, 20).unwrap().map(|r| r.width), Some(32)); + assert!(decode_reduced(&file, 40).unwrap().is_none()); + // a different aspect ratio is not a copy of the image + assert!(decode_reduced(&linear_dng_with_copies(64, 32, &[(16, 16)], &[]), 8).unwrap().is_none()); + // opcodes are in full-size pixels + let opcodes = linear_dng_with_copies(64, 32, &[(16, 8)], &[(t::OPCODE_LIST_3, Value::Undefined(vec![0, 0, 0, 0]))]); + assert!(decode_reduced(&opcodes, 8).unwrap().is_none()); + // not a DNG + assert!(decode_reduced(&write(&[rgb_ifd(16, 8)]), 8).unwrap().is_none()); + } + /// Compression 99 (not a registered TIFF value; Leaf MOS tiles) marks a raw even without a CFA tag. #[test] fn private_compression_99_is_a_raw() { diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 4e0531793..2eb910ea1 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -84,7 +84,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`, checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; an FFF has no colour matrix of its own and takes the camera's from the original 3FR header it embeds in tag `0xC51B`; neither carries a JPEG, so their uncompressed 8-bit RGB preview (1217 × 913 in the H4D-40 FFF, 218 × 218 in the CFV 3FR) is the embedded preview, handed on as a baseline TIFF, `crates/raw/src/vendor/hasselblad.rs`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/evidence.tsv`, `docs/raw/coverage-sweep.tsv`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Public raw.pixls.us coverage sweep (#1327): 30 more models verified on 40 CC0 files, with SHA-256 and FNV-1a sensor hashes pinned (`crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/coverage-sweep.tsv`; `docs/raw/coverage.py`: 515 → 545 verified rows). Render/reference luminance correlation is 0.6801 to 0.9998; eleven candidates excluded for identity, provenance or missing/mismatched reference images. Nikon D6 / Z 6 / Z 7 uncompressed and Z 8 HE variants remain preview-only. Own-matrix DNG tone differs from the camera JPEG (#1277); sensor verification is not colour calibration. | -| LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`, `embedded_preview_covering`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for. Stand-ins and grid thumbnails decode the smallest embedded JPEG that covers the requested size and is a copy of the largest (aspect ratio within 1 %, same component count), not always the largest, on the whole-file and the plan route alike: an A7R VI's 1616 × 1080 JPEG (0.3 MB) instead of its 9984 × 6656 one (5.6 MB), and mid-sized NEF / RW2 JPEGs | +| LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`, `embedded_preview_covering`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`), `crates/raw/src/dng.rs` (`decode_reduced`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for. Stand-ins and grid thumbnails decode the smallest embedded JPEG that covers the requested size and is a copy of the largest (aspect ratio within 1 %, same component count), not always the largest, on the whole-file and the plan route alike: an A7R VI's 1616 × 1080 JPEG (0.3 MB) instead of its 9984 × 6656 one (5.6 MB), and mid-sized NEF / RW2 JPEGs; a DNG that keeps reduced-resolution copies of its LinearRaw image (Lightroom HDR/panorama merges) develops a thumbnail or preview from the smallest copy that covers it (512 px of a 23836 × 7078 pano: 38 s → 1.1 s) | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | | LR-IMP-DNG-CONVERT | Convert to DNG on import [Classic] | P2 | ✅ | `cmd:library.import` (`dng`), `crates/ui-egui/src/import.rs` | copy imports: Raw files ▸ Copy as DNG (lossless; the card is untouched); the raw copy is removed only once its DNG is verified and on disk (issue #106) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 2f02ffb91..1fb73fc09 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,13 @@ # What's new in LightCraft +## 2026-10-18 + +### Thumbnails of Lightroom HDR and panorama merges in a second +- Grid and filmstrip thumbnails of DNGs that Lightroom merged (HDR, Panorama, HDR Panorama) now develop from the + small copies of the raw image Lightroom keeps inside the file (256 to 2048 px), not from the whole merge. A 23836 × + 7078 drone panorama's 512 px thumbnail took 38 s and now takes about a second, with the same look; your edits still + apply. Loupe previews larger than the biggest copy, 1:1 views and exports still read the full image. + ## 2026-10-14 ### Model downloads time out on a name that doesn't resolve From 041f91d1ccda6785808442c8d55f5189f64b2931 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Tue, 20 Oct 2026 09:20:10 +1100 Subject: [PATCH 21/36] Fix raw-parity merge markers Co-Authored-By: Claude Sonnet 5.5 --- docs/raw-parity.md | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 2473ed3f3..9cfb75647 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,10 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -<<<<<<< HEAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Canon ColorData Kelvin; integration batch 36: 515 models verified after the CC0 per-model checks; Epson ERF and Mamiya MEF decode; measured Nikon image areas) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) -======= -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** patch (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) ->>>>>>> upstream/main +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (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) 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 @@ -291,13 +287,10 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| -<<<<<<< HEAD -| 2026-10-16 | minor | Kelvin white balance from Canon CR2 `ColorData` presets (PowerShot G1 X Mark III) | -======= | 2026-10-18 | patch | X-T30, X-T30 II, X-Pro3, X100V, X-S10 and X-E4 take the X-T3's spectral matrices (same 26.1 MP X-Trans CMOS 4 sensor): an X-T30 raw with a black-and-white (Acros) camera JPEG no longer opens in the muted neutral fallback | +| 2026-10-16 | minor | Kelvin white balance from Canon CR2 `ColorData` presets (PowerShot G1 X Mark III) | | 2026-10-15 | minor | Hasselblad 3FR / FFF embedded previews: the uncompressed 8-bit RGB image, handed on as a baseline TIFF (checked on the CC0 H4D-40 FFF, 1217 × 913, and CFV 3FR, 218 × 218, upright through the file's orientation); counts unchanged | | 2026-10-15 | minor | Hasselblad 3FR / FFF get their own reader (`crates/raw/src/vendor/hasselblad.rs`): an FFF takes its colour matrix from the original 3FR header in tag `0xC51B` instead of developing with the neutral matrix; RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; counts unchanged | ->>>>>>> upstream/main | 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277) | | 2026-10-13 | minor | Bundled camera profiles 4 → 5 (ILCA-99M2) | | 2026-10-14 | minor | 21 Sony and Leica models verified (one CC0 raw.pixls.us file each, pinned; `sony_leica_corpus.rs`): 258 verified, 920 unverified; Leica on a verified body 21% → 72%, Sony 91% → 92%, use-weighted 85% and 43% | From d8f1b30bcc1af4f216b2a4d01c307f6a2adc1cff Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Tue, 20 Oct 2026 10:04:25 +1100 Subject: [PATCH 22/36] Kelvin presets: match multiword makes in the catalog's joined camera lookup covers_camera tried only the first-space split, so NIKON CORPORATION NIKON Z5_2 stayed on the relative scale in the catalog and editor. Try every split, as spectral::find_camera does. Co-Authored-By: Claude Sonnet 5.5 --- crates/catalog/src/model.rs | 1 + crates/raw/src/kelvin_presets.rs | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 6ac3544ee..a3fe36aba 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -653,6 +653,7 @@ mod edited_tests { ("SONY ILCE-1", "ARW", true), ("", "ARW", true), ("SONY ILCE-7M3", "DNG", false), + ("NIKON CORPORATION NIKON Z5_2", "NEF", false), ] { let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.raw", format, 10, 10, "2026-10-01T00:00:00"); p.kind = MediaKind::Raw; diff --git a/crates/raw/src/kelvin_presets.rs b/crates/raw/src/kelvin_presets.rs index 975e578c9..f9465a94c 100644 --- a/crates/raw/src/kelvin_presets.rs +++ b/crates/raw/src/kelvin_presets.rs @@ -55,10 +55,9 @@ pub fn covers(make: Option<&str>, model: &str) -> bool { /// [`covers`] for make and model joined by a space, as the catalog keeps them (`"SONY ILCA-99M2"`). pub fn covers_camera(camera: &str) -> bool { - match camera.split_once(char::is_whitespace) { - Some((make, model)) => covers(Some(make), model), - None => covers(None, camera), - } + let words: Vec<&str> = camera.split_whitespace().collect(); + // the make can be several words ("NIKON CORPORATION"), so try every split, as `spectral::find_camera` does + (1..words.len()).any(|i| covers(Some(&words[..i].join(" ")), &words[i..].join(" "))) || covers(None, camera) } /// A camera's multipliers at five stated temperatures, ascending, green = 1. @@ -220,6 +219,7 @@ mod tests { #[test] fn covered_models() { + assert!(covers_camera("NIKON CORPORATION NIKON Z5_2") && covers_camera("NIKON Z5_2") && !covers_camera("NIKON CORPORATION NIKON Z6")); for c in ["SONY ILCA-99M2", "SONY DSC-RX10", "SONY ILCA-77M2", "SONY SLT-A33", "ILCA-99M2", "Sony ilca-99m2"] { assert!(covers_camera(c), "{c}"); } From de8e4c415c5f80260e87510271189f9fbed01583 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Tue, 20 Oct 2026 10:56:25 +1100 Subject: [PATCH 23/36] MRW: RawImage fields added upstream (lens_metadata, pixel_aspect) Co-Authored-By: Claude Sonnet 5.5 --- crates/raw/src/vendor/mod.rs | 2 +- crates/raw/src/vendor/mrw.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index a1a9566f9..b0f65f49e 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -14,8 +14,8 @@ pub(crate) mod crx_wavelet; pub mod erf; pub(crate) mod hasselblad; pub mod kodak; -pub mod mrw; pub mod mef; +pub mod mrw; pub mod nef; mod nef_lens; pub mod nefc; diff --git a/crates/raw/src/vendor/mrw.rs b/crates/raw/src/vendor/mrw.rs index 25d19204e..6404ec9aa 100644 --- a/crates/raw/src/vendor/mrw.rs +++ b/crates/raw/src/vendor/mrw.rs @@ -114,6 +114,8 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { linearized: false, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), + pixel_aspect: 1.0, }; img.validate_for(mode)?; Ok(img) From a5a4b64ec6b489482a3846aeef81910023a30b76 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Tue, 20 Oct 2026 12:03:32 +1100 Subject: [PATCH 24/36] Review: fluorescent accuracy bound 5 %, drop stale relative-scale text Co-Authored-By: Claude Sonnet 5.5 --- crates/raw/src/kelvin_presets.rs | 2 +- docs/parity-checklist.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/raw/src/kelvin_presets.rs b/crates/raw/src/kelvin_presets.rs index b728e6b1d..2d78151d5 100644 --- a/crates/raw/src/kelvin_presets.rs +++ b/crates/raw/src/kelvin_presets.rs @@ -11,7 +11,7 @@ //! Canon CR2s list theirs by name in the maker note's `ColorData`, each with Canon's temperature for it (see //! `vendor::cr2`): five planckian ones (Daylight, Shade, Cloudy, Tungsten, Flash) make the curve the same way, //! taken in Kelvin order ([`KelvinPresets::from_unordered`]). On the PowerShot G1 X Mark III's nine settings the -//! curve reads the as-shot levels within 4 % of the camera's own `ColorTempAsShot`. +//! curve reads the as-shot levels within 5 % of the camera's own `ColorTempAsShot`. //! //! The model, in `(ln R/G, ln B/G)` of the multipliers ([`KelvinPresets::temp_tint`]): //! - The presets (three or more) are joined piecewise linearly, with mired linear along each segment and the end segments diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index b87ac1515..1ab9a03ef 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -193,7 +193,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | | | LR-EDIT-COLOR-WB-PRESET | White-balance presets | P0 | ✅ | `cmd:develop.wb` | | | LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick`, `crates/ui-egui/src/tests_wb_selector.rs` | the Edit panel's selector button does what W does (from Compare, Survey or the grid it opens the photo in Detail); no magnified loupe while picking | -| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`), `crates/develop/src/controls.rs` (`ControlSpec::position`), `crates/raw/src/kelvin_presets.rs` | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford), or read from the camera's own white-balance presets (Sony ARW `SR2SubIFD`, Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S: Sony's Kelvin dial reads back within 0.3 % on 5 settings of 4 bodies; Canon CR2 `ColorData` presets, PowerShot G1 X Mark III: the camera's own As Shot temperature reads back within 4 % on its 9 white-balance settings); raws without matrices anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). The Kelvin slider's track is Lightroom's: 7000 K in the middle, 2000–7000 K evenly on the left half, logarithmic up to 50000 K on the right (within 2 % of positions measured in Lightroom Classic), for drawing, clicks and drags; wheel / arrow-key nudges and Quick Develop still step a fixed 50 / 100 / 500 K; the relative slider stays linear. Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open; raws without matrices or presets anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open | +| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`), `crates/develop/src/controls.rs` (`ControlSpec::position`), `crates/raw/src/kelvin_presets.rs` | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford), or read from the camera's own white-balance presets (Sony ARW `SR2SubIFD`, Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S: Sony's Kelvin dial reads back within 0.3 % on 5 settings of 4 bodies; Canon CR2 `ColorData` presets, PowerShot G1 X Mark III: the camera's own As Shot temperature reads back within 5 % on its 9 white-balance settings). The Kelvin slider's track is Lightroom's: 7000 K in the middle, 2000–7000 K evenly on the left half, logarithmic up to 50000 K on the right (within 2 % of positions measured in Lightroom Classic), for drawing, clicks and drags; wheel / arrow-key nudges and Quick Develop still step a fixed 50 / 100 / 500 K; the relative slider stays linear. Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open; raws without matrices or presets anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). | | LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint`, `docs/tint-direction.md` | DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP `crs:Tint` values render as in Lightroom (issue #188); CPU/GPU regression tests | | LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | | | LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | `ctl:color.saturation` | | From 376ad8177d9bc1bc8973b4bd38c456e280f1076c Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Tue, 20 Oct 2026 12:03:36 +1100 Subject: [PATCH 25/36] Review: correct ColorTemperatureAuto tag id, dedupe relative-scale text Co-Authored-By: Claude Sonnet 5.5 --- crates/raw/src/kelvin_builtin.rs | 2 +- docs/parity-checklist.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/raw/src/kelvin_builtin.rs b/crates/raw/src/kelvin_builtin.rs index 96a503d81..1ce4ddfc4 100644 --- a/crates/raw/src/kelvin_builtin.rs +++ b/crates/raw/src/kelvin_builtin.rs @@ -2,7 +2,7 @@ //! the Kelvin scale of [`crate::kelvin_presets`]. //! //! Nikon NEFs store only the as-shot multipliers (`WB_RBLevels`) and, in the maker note, the colour temperature the -//! camera states for the white-balance setting it shot with (`ColorTemperatureAuto`, `0x0045`: tag name from the +//! camera states for the white-balance setting it shot with (`ColorTemperatureAuto`, `0x004f`: tag name from the //! ExifTool Nikon tag documentation, read with exiftool run as a black-box tool on the files). A body's points are //! the as-shot multipliers of its own photos shot at one preset each, placed at the temperature the camera itself //! wrote for that preset. Presets off the daylight locus (fluorescent, flash) and shots with a fine-tune shift are diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 0184af93c..8fd05399c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -193,7 +193,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | | | LR-EDIT-COLOR-WB-PRESET | White-balance presets | P0 | ✅ | `cmd:develop.wb` | | | LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick`, `crates/ui-egui/src/tests_wb_selector.rs` | the Edit panel's selector button does what W does (from Compare, Survey or the grid it opens the photo in Detail); no magnified loupe while picking | -| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`), `crates/develop/src/controls.rs` (`ControlSpec::position`), `crates/raw/src/kelvin_presets.rs` | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford), or read from the camera's own white-balance presets (Sony ARW `SR2SubIFD`, Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S: Sony's Kelvin dial reads back within 0.3 % on 5 settings of 4 bodies; or built-in presets from the camera's own stated temperatures, `crates/raw/src/kelvin_builtin.rs`: Nikon Z5II, whose Natural Auto shot reads back at 5036 K against the 5030 K the camera wrote); raws without matrices anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). The Kelvin slider's track is Lightroom's: 7000 K in the middle, 2000–7000 K evenly on the left half, logarithmic up to 50000 K on the right (within 2 % of positions measured in Lightroom Classic), for drawing, clicks and drags; wheel / arrow-key nudges and Quick Develop still step a fixed 50 / 100 / 500 K; the relative slider stays linear. Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open; raws without matrices or presets anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open | +| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`), `crates/develop/src/controls.rs` (`ControlSpec::position`), `crates/raw/src/kelvin_presets.rs` | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford), or read from the camera's own white-balance presets (Sony ARW `SR2SubIFD`, Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500, ILCE-7S: Sony's Kelvin dial reads back within 0.3 % on 5 settings of 4 bodies; or built-in presets from the camera's own stated temperatures, `crates/raw/src/kelvin_builtin.rs`: Nikon Z5II, whose Natural Auto shot reads back at 5036 K against the 5030 K the camera wrote). The Kelvin slider's track is Lightroom's: 7000 K in the middle, 2000–7000 K evenly on the left half, logarithmic up to 50000 K on the right (within 2 % of positions measured in Lightroom Classic), for drawing, clicks and drags; wheel / arrow-key nudges and Quick Develop still step a fixed 50 / 100 / 500 K; the relative slider stays linear. Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open; raws without matrices or presets anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). | | LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint`, `docs/tint-direction.md` | DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP `crs:Tint` values render as in Lightroom (issue #188); CPU/GPU regression tests | | LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | | | LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | `ctl:color.saturation` | | From e09e91f8905ab0168f908f4b4d47677235983383 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Tue, 20 Oct 2026 12:06:04 +1100 Subject: [PATCH 26/36] Move the dated LR-IMP-FORMATS entries from the checklist table to the revision history Co-Authored-By: Claude Sonnet 5.5 --- docs/parity-checklist.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 3b82f36d2..41cb032c6 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -69,9 +69,6 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| 2026-10-12 | minor | LR-IMP-FORMATS: Mamiya MEF decodes from sensor data and is imported (`vendor/mef.rs`) | -| 2026-10-18 | minor | LR-IMP-FORMATS: Minolta / Konica Minolta MRW decodes from sensor data (DiMAGE 7, 7Hi, A1, A2, A200, Dynax / Maxxum 7D verified on CC0 files); status unchanged (🟡) | -| 2026-10-12 | minor | LR-IMP-FORMATS: Epson ERF decodes from sensor data (`vendor/erf.rs`), no longer preview-only | | LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342; 0.4.0 hid `.JPG` / `.NEF`, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (`crates/engine/src/tests_import.rs`) | | LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → `cmd:library.import`) | dropping onto a specific album not supported | | LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | | @@ -892,3 +889,6 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | minor | LRC-LIB-CATALOG-IMPORT review: offline Local promotion and result dismissal across library switches covered; redundant operation/settings copies removed. Status and summary counts unchanged. | | 2026-10-14 | patch | LRC-LIB-CATALOG-IMPORT: reconciled with upstream phased progress, cancellation kinds, WAL-only import, translations and current progress tracking. Status and summary counts unchanged. | | 2026-10-13 | minor | KEYC-COMPARE: `=` / `-` (also `+`) increase / decrease the grid thumbnail size (`view.thumbSizeUp` / `view.thumbSizeDown`, View menu), grids only; status unchanged (🟡) | +| 2026-10-12 | minor | LR-IMP-FORMATS: Mamiya MEF decodes from sensor data and is imported (`vendor/mef.rs`) | +| 2026-10-18 | minor | LR-IMP-FORMATS: Minolta / Konica Minolta MRW decodes from sensor data (DiMAGE 7, 7Hi, A1, A2, A200, Dynax / Maxxum 7D verified on CC0 files); status unchanged (🟡) | +| 2026-10-12 | minor | LR-IMP-FORMATS: Epson ERF decodes from sensor data (`vendor/erf.rs`), no longer preview-only | From 75292874a3dd58d9caeb4035c6bb4a78d8fd7a4a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 18:54:24 -0700 Subject: [PATCH 27/36] LR-EDIT-DETAIL-DENOISE: GPU denoise gives the right result on DX12 with NVIDIA Pascal On DX12, FXC merged the conv kernel's four output stores (modes 0 and 1) into one 16-byte store_raw. The DXBC is valid and aligned, but NVIDIA's driver ran it wrong on a GTX 1070: every second pixel's channels came out shifted, so AI Denoise on the GPU was silently wrong on Windows. DXC and Vulkan were right on the same card. The stores now step by `1 + p.zero` (the always-0 uniform, renamed from `pad`), which FXC cannot fold, so it keeps them apart. A two-layer channel-copy network checks the stores exactly; it fails on that card without the fix. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/nn.rs | 25 ++++++++++++++++++++++--- crates/gpu/src/wgsl/nn_conv.wgsl | 19 ++++++++++++------- docs/gpu-pipeline.md | 6 ++++++ docs/whats-new.md | 7 +++++++ 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/crates/gpu/src/nn.rs b/crates/gpu/src/nn.rs index 5872bfaa9..b5a0f39f6 100644 --- a/crates/gpu/src/nn.rs +++ b/crates/gpu/src/nn.rs @@ -161,7 +161,8 @@ struct ConvParams { cout: u32, leaky: u32, alpha: f32, - pad: u32, + /// Always 0: the kernel steps its output stores by `1 + zero`, so FXC cannot merge them (`nn_conv.wgsl`). + zero: u32, } #[repr(C)] @@ -349,7 +350,7 @@ fn plan(net: &Net, tile: usize, max_binding: u64) -> Result<(Layout, Vec) cout: c.cout as u32, leaky: u32::from(c.leaky.is_some()), alpha: c.leaky.unwrap_or(0.0), - pad: 0, + zero: 0, }; let src = get(&place, c.src)?; let src2 = c.src2.map(|t| get(&place, t)).transpose()?; @@ -416,7 +417,7 @@ fn plan(net: &Net, tile: usize, max_binding: u64) -> Result<(Layout, Vec) cout: c.cout as u32, leaky: u32::from(c.leaky.is_some()), alpha: c.leaky.unwrap_or(0.0), - pad: 0, + zero: 0, }; let src = get(&place, c.src)?; let slot = alloc((4 * m * c.cout * 4) as u64, &mut slots, &mut free); @@ -920,6 +921,24 @@ mod tests { a.iter().zip(b).fold(0f32, |m, (x, y)| m.max((x - y).abs())) / scale } + /// Two 1 × 1 layers that only copy channels, so the right answer is exact: the first layer's stores (mode 0) went + /// wrong on DX12 with FXC on an NVIDIA GTX 1070, where every second pixel came out with its channels shifted. + #[test] + fn a_layer_stores_every_pixels_channels_where_the_next_reads_them() { + use lightcraft_denoise::net::{Conv, Op}; + let copy = |src: usize, cout: usize| { + let mut weight = vec![0f32; cout * 4]; + for co in 0..cout { + weight[co * 4 + co % 4] = 1.0; + } + Op::Conv(Conv { src, src2: None, cin: 4, cout, k: 1, weight, bias: vec![0.0; cout], leaky: None }) + }; + let n = Net::new(4, vec![copy(0, 4), copy(1, 12), Op::DepthToSpace2 { src: 2 }]).unwrap(); + let Some(g) = gpu(&n, 3) else { return }; + let x: Vec = (1..=36).map(|i| i as f32).collect(); + assert_eq!(g.run(&x).unwrap(), reference::run(&n, 3, &x).unwrap()); + } + #[test] fn the_gpu_gives_what_the_reference_gives() { // (tile, channels, depth): a tile that is not a multiple of the block size, odd channel counts, a deeper net diff --git a/crates/gpu/src/wgsl/nn_conv.wgsl b/crates/gpu/src/wgsl/nn_conv.wgsl index 20a0ce996..75389b517 100644 --- a/crates/gpu/src/wgsl/nn_conv.wgsl +++ b/crates/gpu/src/wgsl/nn_conv.wgsl @@ -27,7 +27,8 @@ struct P { cout: u32, leaky: u32, alpha: f32, - pad: u32, + // always 0: the output stores step by 1u + zero, see the end of main + zero: u32, } @group(0) @binding(0) var p: P; @@ -122,6 +123,10 @@ fn main(@builtin(workgroup_id) wg: vec3, @builtin(local_invocation_index) t return; } let bv = bias[n / 4u]; + // Four channels that follow each other are stored one by one, `one` apart. With a literal 1u, FXC + // (DX12) merges them into one 16-byte store, and NVIDIA's DX12 driver ran that wrong on a GTX + // 1070: every second pixel's channels came out shifted. FXC cannot see that `one` is 1. + let one = 1u + p.zero; for (var i = 0u; i < 4u; i++) { let m = m0 + ty * 4u + i; if (m >= hw) { @@ -135,9 +140,9 @@ fn main(@builtin(workgroup_id) wg: vec3, @builtin(local_invocation_index) t if (p.mode == 0u) { let o = m * p.n + n; dst[o] = v.x; - dst[o + 1u] = v.y; - dst[o + 2u] = v.z; - dst[o + 3u] = v.w; + dst[o + one] = v.y; + dst[o + 2u * one] = v.z; + dst[o + 3u * one] = v.w; } else if (p.mode == 1u) { let q = n / p.cout; let co = n % p.cout; @@ -146,9 +151,9 @@ fn main(@builtin(workgroup_id) wg: vec3, @builtin(local_invocation_index) t let opix = (2u * y + q / 2u) * (2u * p.w) + 2u * x + q % 2u; let o = opix * p.cout + co; dst[o] = v.x; - dst[o + 1u] = v.y; - dst[o + 2u] = v.z; - dst[o + 3u] = v.w; + dst[o + one] = v.y; + dst[o + 2u * one] = v.z; + dst[o + 3u * one] = v.w; } else { let c = n / 4u; let y = m / p.w; diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index b74af85a2..246b4c158 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -55,6 +55,12 @@ chooses; with `dxc`, put `dxcompiler.dll` *and* `dxil.dll` (DXC ≥ 1.8.2502) ne FXC builds the compute kernels somewhat slower than DXC (≈ 9 s vs 7.5 s for the GPU test suite on an RTX 4080), off the startup path. +FXC merges stores to neighbouring elements of a storage array into one wider store. NVIDIA's DX12 driver ran +the 16-byte stores it made of the denoise convolution's output wrong on a GTX 1070 (every second pixel's channels +shifted; DXC and Vulkan were right), so `nn_conv.wgsl` steps those stores by `1 + zero` from its uniform and FXC +leaves them apart. `nn::tests::a_layer_stores_every_pixels_channels_where_the_next_reads_them` catches it on such +a card. + **Off the startup path.** The compute device is created on a background thread once the window is up (`gpu::warm_up` from the first frame's settings), and never while GPU rendering is off: with Settings ▸ Performance ▸ *Use the GPU for rendering* unchecked (applied before the window opens), diff --git a/docs/whats-new.md b/docs/whats-new.md index 2f02ffb91..81d7498aa 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,12 @@ # What's new in LightCraft +## 2026-10-19 + +### AI Denoise on DirectX 12 with NVIDIA GTX 10-series cards +- AI Denoise on the GPU under DirectX 12 (the Windows default) gives the same result as on the CPU again on an NVIDIA + GeForce GTX 1070. The driver ran the denoise network's output stores wrong, so every second pixel's colour + channels came out shifted and the denoised photo was wrong without any error. Vulkan was not affected. + ## 2026-10-14 ### Model downloads time out on a name that doesn't resolve From 2c085e0ae58c479187cbd70fdc1a5ad785949adc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:34:56 -0700 Subject: [PATCH 28/36] LR-EDIT-DETAIL-DENOISE: check the transposed convolution's stores exactly too A max-pool, a channel-copy 2x2 transposed convolution (mode 1) and the channel-copy 1x1 layer give an exact answer, so a store fault in mode 1 can't hide in tolerance (CodeRabbit review of #2380). On the GTX 1070 this test passes even without mode 1's stepped stores: FXC merges them the same way, but the driver runs that store right, so only mode 0 was shown wrong. Mode 1 keeps the stepped stores as a precaution; the shader comment and docs/gpu-pipeline.md now say so. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/nn.rs | 36 +++++++++++++++++++++++++------- crates/gpu/src/wgsl/nn_conv.wgsl | 5 +++-- docs/gpu-pipeline.md | 7 ++++--- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/crates/gpu/src/nn.rs b/crates/gpu/src/nn.rs index b5a0f39f6..55420598b 100644 --- a/crates/gpu/src/nn.rs +++ b/crates/gpu/src/nn.rs @@ -921,24 +921,44 @@ mod tests { a.iter().zip(b).fold(0f32, |m, (x, y)| m.max((x - y).abs())) / scale } + /// A 1 × 1 convolution from 4 channels to `cout` that copies input channel `co % 4` to each output channel `co`. + fn copy(src: usize, cout: usize) -> Op { + let mut weight = vec![0f32; cout * 4]; + for co in 0..cout { + weight[co * 4 + co % 4] = 1.0; + } + Op::Conv(lightcraft_denoise::net::Conv { src, src2: None, cin: 4, cout, k: 1, weight, bias: vec![0.0; cout], leaky: None }) + } + /// Two 1 × 1 layers that only copy channels, so the right answer is exact: the first layer's stores (mode 0) went /// wrong on DX12 with FXC on an NVIDIA GTX 1070, where every second pixel came out with its channels shifted. #[test] fn a_layer_stores_every_pixels_channels_where_the_next_reads_them() { - use lightcraft_denoise::net::{Conv, Op}; - let copy = |src: usize, cout: usize| { - let mut weight = vec![0f32; cout * 4]; - for co in 0..cout { - weight[co * 4 + co % 4] = 1.0; - } - Op::Conv(Conv { src, src2: None, cin: 4, cout, k: 1, weight, bias: vec![0.0; cout], leaky: None }) - }; let n = Net::new(4, vec![copy(0, 4), copy(1, 12), Op::DepthToSpace2 { src: 2 }]).unwrap(); let Some(g) = gpu(&n, 3) else { return }; let x: Vec = (1..=36).map(|i| i as f32).collect(); assert_eq!(g.run(&x).unwrap(), reference::run(&n, 3, &x).unwrap()); } + /// The same for a transposed convolution's stores (mode 1): a max-pool halves the tile, and the 2 × 2 transposed + /// convolution copies each pooled pixel's channels to the four pixels it becomes. + #[test] + fn a_transposed_convolution_stores_every_pixels_channels_where_the_next_reads_them() { + use lightcraft_denoise::net::ConvT2; + // [cin][cout][2][2]: channel c to channel c at all four places + let mut weight = vec![0f32; 4 * 4 * 4]; + for c in 0..4 { + for q in 0..4 { + weight[(c * 4 + c) * 4 + q] = 1.0; + } + } + let up = Op::ConvT2(ConvT2 { src: 1, cin: 4, cout: 4, weight, bias: vec![0.0; 4], leaky: None }); + let n = Net::new(4, vec![Op::MaxPool2 { src: 0 }, up, copy(2, 12), Op::DepthToSpace2 { src: 3 }]).unwrap(); + let Some(g) = gpu(&n, 6) else { return }; + let x: Vec = (1..=144).map(|i| i as f32).collect(); + assert_eq!(g.run(&x).unwrap(), reference::run(&n, 6, &x).unwrap()); + } + #[test] fn the_gpu_gives_what_the_reference_gives() { // (tile, channels, depth): a tile that is not a multiple of the block size, odd channel counts, a deeper net diff --git a/crates/gpu/src/wgsl/nn_conv.wgsl b/crates/gpu/src/wgsl/nn_conv.wgsl index 75389b517..ee35e88ef 100644 --- a/crates/gpu/src/wgsl/nn_conv.wgsl +++ b/crates/gpu/src/wgsl/nn_conv.wgsl @@ -124,8 +124,9 @@ fn main(@builtin(workgroup_id) wg: vec3, @builtin(local_invocation_index) t } let bv = bias[n / 4u]; // Four channels that follow each other are stored one by one, `one` apart. With a literal 1u, FXC - // (DX12) merges them into one 16-byte store, and NVIDIA's DX12 driver ran that wrong on a GTX - // 1070: every second pixel's channels came out shifted. FXC cannot see that `one` is 1. + // (DX12) merges them into one 16-byte store, and NVIDIA's DX12 driver ran mode 0's wrong on a + // GTX 1070: every second pixel's channels came out shifted. Mode 1's merged store ran right + // there, but it is the same instruction, so it steps the same way. FXC cannot see that `one` is 1. let one = 1u + p.zero; for (var i = 0u; i < 4u; i++) { let m = m0 + ty * 4u + i; diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 246b4c158..2e9d0db3b 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -56,10 +56,11 @@ FXC builds the compute kernels somewhat slower than DXC (≈ 9 s vs 7.5 s for th RTX 4080), off the startup path. FXC merges stores to neighbouring elements of a storage array into one wider store. NVIDIA's DX12 driver ran -the 16-byte stores it made of the denoise convolution's output wrong on a GTX 1070 (every second pixel's channels +the 16-byte store it made of a denoise convolution's output wrong on a GTX 1070 (every second pixel's channels shifted; DXC and Vulkan were right), so `nn_conv.wgsl` steps those stores by `1 + zero` from its uniform and FXC -leaves them apart. `nn::tests::a_layer_stores_every_pixels_channels_where_the_next_reads_them` catches it on such -a card. +leaves them apart. The transposed convolution's merged store ran right on that card, but it steps the same way. +`nn::tests::a_layer_stores_every_pixels_channels_where_the_next_reads_them` catches the fault on such a card; +`a_transposed_convolution_stores_every_pixels_channels_where_the_next_reads_them` checks the other store exactly. **Off the startup path.** The compute device is created on a background thread once the window is up (`gpu::warm_up` from the first frame's settings), and never while GPU rendering is off: with From 9ed971149baca73e4fae6eb7f0804c0215feff61 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Tue, 20 Oct 2026 13:08:03 +0200 Subject: [PATCH 29/36] LR-IMP-PREVIEW-READ: exports never decode a DNG's reduced copy Reduced decoding is now opt-in: `files::load_vec_reduced` and the session's `reduced_loader` are used by grid thumbnail jobs only; `load_bytes` / `load_vec` / `load_vec_pair` always decode the full image. A source developed from a copy is marked (`DecodedSource::reduced`), so an export at a thumbnail's size reads the file again instead of the cached thumbnail source, and Build Smart Previews never rebuilds a stale proxy from one. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/previews.rs | 9 +- crates/engine/src/files.rs | 141 +++++++++++++++++++++++++----- crates/engine/src/media.rs | 51 +++++++++-- crates/engine/src/smart.rs | 8 +- 4 files changed, 176 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index b63ccc2b4..fc7bef6b0 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -212,8 +212,10 @@ struct SmartCounts { fn original_only(source: crate::media::SourceRef) -> Option { use crate::media::SourceRef; match source { - SourceRef::File { path, max_edge, loader, denoise, .. } => Some(SourceRef::File { path, max_edge, loader, fallback: None, denoise }), - SourceRef::Loaded(d) if d.info.is_some() => Some(SourceRef::Loaded(d)), + SourceRef::File { path, max_edge, loader, denoise, .. } => { + Some(SourceRef::File { path, max_edge, loader, fallback: None, denoise, reduced: None }) + } + SourceRef::Loaded(d) if d.info.is_some() && !d.reduced => Some(SourceRef::Loaded(d)), _ => None, } } @@ -548,7 +550,8 @@ mod tests { let mut jobs = jobs_for(&mut s, id, Some(512)); assert_eq!(jobs.len(), 2, "view and thumbnail"); assert!(jobs[0].view_cache.is_some(), "the view renders first"); - jobs[1].source = SourceRef::File { path: "/nonexistent/photo.arw".into(), max_edge: 512, loader: None, fallback: None, denoise: None }; + jobs[1].source = + SourceRef::File { path: "/nonexistent/photo.arw".into(), max_edge: 512, loader: None, fallback: None, denoise: None, reduced: None }; assert!(run_photo(jobs), "the thumbnail rendered from the view's decoded source"); } diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 1f273f7ff..48e4ec20a 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -11,7 +11,7 @@ use lightcraft_pipeline::SourceInfo; use lightcraft_raster::Rgb32f; use lightcraft_raster::resample::{Filter, fit}; -use crate::media::{DenoiseSpec, FileLoader, FileProbe, PairLoader, PreviewLoader, ProbeInfo}; +use crate::media::{DenoiseSpec, FileLoader, FileProbe, PairLoader, PreviewLoader, ProbeInfo, ReducedLoader}; fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { let shutter = m.exposure_time.map(|t| if t >= 1.0 { format!("{t:.0}") } else { format!("1/{:.0}", 1.0 / t) }).unwrap_or_default(); @@ -366,20 +366,27 @@ pub fn bin_factor(raw: &lightcraft_raw::RawImage, max_edge: usize) -> Option Result<(Rgb32f, SourceInfo), String> { - rayon::scope(|_| load_bytes_now(std::borrow::Cow::Borrowed(bytes), max_edge, None).map(|(img, _, info)| (img, info))) + rayon::scope(|_| load_bytes_now(std::borrow::Cow::Borrowed(bytes), max_edge, None, false).map(|(img, _, info, _)| (img, info))) } /// [`load_bytes`] taking the file's bytes: a raw file's bytes are freed as soon as it is decoded /// (less memory held while it is developed). pub fn load_vec(bytes: Vec, max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> { - rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None).map(|(img, _, info)| (img, info))) + rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None, false).map(|(img, _, info, _)| (img, info))) +} + +/// [`load_vec`] for a thumbnail: a DNG that keeps reduced-resolution copies of its raw image (Lightroom's HDR and +/// panorama merges) develops the smallest that covers `max_edge` instead of the whole image. The flag says whether +/// it did: such a picture is not the full image, and an export never renders from it. +pub fn load_vec_reduced(bytes: Vec, max_edge: usize) -> Result<(Rgb32f, SourceInfo, bool), String> { + rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None, true).map(|(img, _, info, reduced)| (img, info, reduced))) } /// [`load_vec`] that also develops the photo's cached denoised picture (`spec`) from the same decode: the plain /// picture, the denoised one (`None` when it cannot be read or does not fit this raw: the plain one is then all /// there is) and the source facts. Both pictures are the same size. pub fn load_vec_pair(bytes: Vec, max_edge: usize, spec: &DenoiseSpec) -> Result<(Rgb32f, Option, SourceInfo), String> { - rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, Some(spec))) + rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, Some(spec), false).map(|(img, twin, info, _)| (img, twin, info))) } /// The denoised camera RGB of `raw` from its cached product, as the plain development would have it: the default @@ -408,11 +415,15 @@ fn load_bytes_now( bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize, denoise: Option<&DenoiseSpec>, -) -> Result<(Rgb32f, Option, SourceInfo), String> { + allow_reduced: bool, +) -> Result<(Rgb32f, Option, SourceInfo, bool), String> { if lightcraft_raw::probe(&bytes).is_some() { - // a thumbnail or preview of a DNG that keeps smaller copies of its raw image (Lightroom's merges) develops - // the smallest that covers it; a denoised photo's twin needs the full mosaic - let reduced = (denoise.is_none() && max_edge < usize::MAX).then(|| lightcraft_raw::decode_reduced(&bytes, max_edge).ok().flatten()).flatten(); + // a thumbnail (`allow_reduced`, never an export) of a DNG that keeps smaller copies of its raw image + // (Lightroom's merges) develops the smallest that covers it; a denoised photo's twin needs the full mosaic + let reduced = (allow_reduced && denoise.is_none() && max_edge < usize::MAX) + .then(|| lightcraft_raw::decode_reduced(&bytes, max_edge).ok().flatten()) + .flatten(); + let used_reduced = reduced.is_some(); let decoded = match reduced { Some(r) => Ok(r), None => lightcraft_raw::decode(&bytes), @@ -422,7 +433,7 @@ fn load_bytes_now( Err(Ok(why)) => { // show the camera's embedded JPEG (rendered, not raw) until the variant is supported return load_embedded_preview(&bytes, max_edge) - .map(|(img, info)| (img, None, info)) + .map(|(img, info)| (img, None, info, false)) .ok_or(format!("unsupported raw ({why}) without an embedded preview")); } Err(Err(e)) => return Err(e), @@ -536,13 +547,14 @@ fn load_bytes_now( // (the catalog knows the original's size: `media::source_info`) native_long: None, }, + used_reduced, )); } let d = lightcraft_codecs::decode(&bytes, fit_box(max_edge)).map_err(|e| e.to_string())?; drop(bytes); let img = d.to_working(); let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img }; - Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default())) + Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default(), false)) } /// Which white-balance scale a raw is edited on, decided from the file alone, the way the catalog decides it from @@ -1067,17 +1079,7 @@ pub fn fs_pair_loader() -> PairLoader { /// interactive loads (the loupe, exports) never wait — they are counted, so background work /// yields to them. pub fn fs_hooks() -> (FileLoader, FileProbe) { - let loader: FileLoader = Arc::new(|path: &str, max_edge: usize| { - let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); - let weight = len * if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 3 } else { 6 }; - let gate = crate::memory::work_gate(); - let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) }; - let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; - let r = load_vec(bytes, max_edge); - // the file, the samples and the intermediate images are gone: give their pages back - crate::memory::release(); - r - }); + let loader: FileLoader = Arc::new(|path: &str, max_edge: usize| fs_load(path, max_edge, |bytes| load_vec(bytes, max_edge))); let probe: FileProbe = Arc::new(|path: &str| { let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); let _permit = crate::memory::work_gate().acquire(len); @@ -1089,6 +1091,24 @@ pub fn fs_hooks() -> (FileLoader, FileProbe) { (loader, probe) } +/// The filesystem [`ReducedLoader`]: [`load_vec_reduced`] of the file, gated like [`fs_hooks`]' loader. +pub fn fs_reduced_loader() -> ReducedLoader { + Arc::new(|path: &str, max_edge: usize| fs_load(path, max_edge, |bytes| load_vec_reduced(bytes, max_edge))) +} + +/// Read `path` and `load` it, waiting on [`crate::memory::work_gate`] as [`fs_hooks`] explains. +fn fs_load(path: &str, max_edge: usize, load: impl FnOnce(Vec) -> Result) -> Result { + let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0); + let weight = len.saturating_mul(if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 3 } else { 6 }); + let gate = crate::memory::work_gate(); + let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) }; + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + let r = load(bytes); + // the file, the samples and the intermediate images are gone: give their pages back + crate::memory::release(); + r +} + /// `path`'s modification time as ISO 8601 (see [`ProbeInfo::file_modified`]); `None` when the /// filesystem doesn't say or the time is before 1970. fn file_modified(path: &str) -> Option { @@ -1103,6 +1123,7 @@ impl crate::Session { pub fn with_fs(mut self) -> Self { let (l, p) = fs_hooks(); self.media.file_loader = Some(l); + self.media.reduced_loader = Some(fs_reduced_loader()); self.media.file_probe = Some(p); self.media.preview_loader = Some(fs_preview_loader()); self.media.denoise.loader = Some(fs_pair_loader()); @@ -2097,6 +2118,84 @@ pub(crate) mod tests { } } + /// A Lightroom merge (a LinearRaw DNG keeping a reduced copy of its raw image): its grid thumbnail develops the + /// copy, but an export never does, whatever its size, and not from the thumbnail's source in memory either. The + /// copy's samples are much brighter than the image's here, so a picture says which one it came from. + #[test] + fn exports_never_develop_a_dngs_reduced_copy() { + use lightcraft_tiff::tags::{self as t, photometric}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; + use serde_json::json; + let linear = |subfile: u32, w: u32, h: u32, sample: u16| { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![subfile])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16, 16, 16])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![photometric::LINEAR_RAW])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set(t::WHITE_LEVEL, Value::Long(vec![32768])); + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![sample.to_le_bytes().repeat((w * h * 3) as usize)] }); + ifd + }; + // (the copy must cover a thumbnail source's 512 px) + let (w, h) = (1200u32, 600u32); + let mut main = linear(0, w, h, 1500); + main.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![4, 2])); + main.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![w - 8, h - 4])); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 7, 0, 0])); + ifd0.set(t::MAKE, Value::Ascii("DJI".into())); + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); + ifd0.set(t::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21])); + ifd0.set(t::PHOTOMETRIC, Value::Short(vec![photometric::RGB])); + ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![4])); + ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![2])); + ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd0.set(t::COMPRESSION, Value::Short(vec![1])); + ifd0.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![vec![128; 24]] }); + ifd0.add_sub_ifd(main); + ifd0.add_sub_ifd(linear(1, w / 2, h / 2, 24000)); + let bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + let g = |img: &Rgb32f| img.data.iter().map(|p| p[1] as f64).sum::() / img.data.len().max(1) as f64; + let mean = |img: &lightcraft_raster::Rgba8| img.data.iter().map(|p| p[1] as f64).sum::() / img.data.len().max(1) as f64; + + // the loaders: only the thumbnail's develops the copy, and only when it covers the size + let (copy, _, used) = load_vec_reduced(bytes.clone(), 512).unwrap(); + assert!(used, "a thumbnail develops the copy"); + assert!(!load_vec_reduced(bytes.clone(), 1024).unwrap().2, "no copy covers 1024 px"); + let (full, _) = load_vec(bytes.clone(), 512).unwrap(); + assert!(g(©) > 4.0 * g(&full), "the fixture's copy is much brighter: {} vs {}", g(©), g(&full)); + + let dir = std::env::temp_dir().join(format!("lc-reduced-export-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("merge-pano.dng"); + std::fs::write(&path, &bytes).unwrap(); + let mut s = crate::Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap()); + let (space, depth) = (lightcraft_pipeline::OutputSpace::Srgb, lightcraft_pipeline::OutputDepth::U8); + // an export at a thumbnail's size, before any thumbnail + assert_eq!(s.export_job(id, 64, 64, space, depth).unwrap().level, crate::media::SourceLevel::Thumb); + let export = mean(&s.render_export(id, 64, 64, space, depth).unwrap().image); + // the thumbnail develops the copy and its source stays in memory… + s.media.clear_sources(); + let thumb = s.thumb_job(id, 64).unwrap().run(); + assert!(thumb.loaded.as_ref().is_some_and(|l| l.reduced)); + let thumb_mean = mean(&thumb.rendered.as_ref().unwrap().image); + s.accept(&thumb); + assert!(thumb_mean > export + 20.0, "the thumbnail shows the copy: {thumb_mean} vs {export}"); + // …and an export after it still reads the full image + let job = s.export_job(id, 64, 64, space, depth).unwrap(); + assert!(matches!(&job.source, crate::media::SourceRef::File { reduced: None, .. }), "the file, not the copy"); + let again = mean(&s.render_export(id, 64, 64, space, depth).unwrap().image); + assert!((again - export).abs() < 1.0, "{again} vs {export}"); + let _ = std::fs::remove_dir_all(&dir); + } + /// iPhone ProRAW-style semantic masks drive the Sky mask: Apple's sky matte (here the right /// half of the sensor, i.e. the bottom of the photo once turned 90° clockwise — where the sky /// heuristic would never look) is used; a matte with an unknown name is ignored. diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 22d76528d..5de0cc8b5 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -132,6 +132,11 @@ fn source_edge_needed(p: &Photo, settings: &DevelopSettings, apply_crop: bool, o /// Decodes a file into a linear Rec.2020 image no larger than `max_edge` (set by the app). pub type FileLoader = Arc Result<(Rgb32f, SourceInfo), String> + Send + Sync>; +/// [`FileLoader`] for a thumbnail: a raw file that keeps smaller copies of its raw image (Lightroom's merged DNGs) +/// develops the smallest that covers `max_edge` (set by the app); the flag says it did. Never used for an export, +/// which must render the full image. +pub type ReducedLoader = Arc Result<(Rgb32f, SourceInfo, bool), String> + Send + Sync>; + /// A raw file's embedded (camera-rendered) preview as display sRGB, oriented, no larger than /// `max_edge` (set by the app). `None`: no usable preview. pub type PreviewLoader = Arc Option + Send + Sync>; @@ -183,11 +188,14 @@ pub struct DecodedSource { pub camera_tone: Option, /// The same picture developed from the AI-denoised mosaic, when the photo has one cached. pub denoised: Option>, + /// Developed from a smaller copy of the raw image that the file keeps ([`ReducedLoader`]), not from the image + /// itself: good for a thumbnail, never for an export. + pub reduced: bool, } impl DecodedSource { pub fn new(image: Arc, info: Option) -> Self { - DecodedSource { image, info, camera_tone: None, denoised: None } + DecodedSource { image, info, camera_tone: None, denoised: None, reduced: false } } /// The picture to develop for a Denoise `amount` (0 to 1, see [`DevelopSettings::denoise_amount`]): the plain one, @@ -258,6 +266,8 @@ pub enum SourceRef { fallback: Option, /// The photo has a cached denoised picture: load it with the plain one (see [`PairLoader`]). denoise: Option<(DenoiseSpec, PairLoader)>, + /// A thumbnail's loader, used instead of `loader` (see [`ReducedLoader`]); `None` for every other job. + reduced: Option, }, /// A smart preview standing in for a missing original. Smart { @@ -281,10 +291,13 @@ impl SourceRef { return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() }); } SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?, - SourceRef::File { path, max_edge, loader, fallback, denoise } => { - let plain = || match loader { - Some(l) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))), - None => Err(format!("no decoder available for {path}")), + SourceRef::File { path, max_edge, loader, fallback, denoise, reduced } => { + let plain = || match (reduced, loader) { + (Some(l), _) => { + l(path, *max_edge).map(|(image, info, reduced)| DecodedSource { reduced, ..DecodedSource::new(Arc::new(image), Some(info)) }) + } + (None, Some(l)) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))), + (None, None) => Err(format!("no decoder available for {path}")), }; // the denoised picture comes with the plain one from a single decode; when it cannot be read the // pair loader gives the plain picture alone (the cached file is never a reason to fail a render) @@ -306,6 +319,7 @@ impl SourceRef { info: Some(info), camera_tone: None, denoised: twin.map(|t| Arc::new(Twin::new(Arc::new(t)))), + reduced: false, }) }) } else { @@ -351,6 +365,8 @@ pub struct MediaCache { /// share of [`crate::memory::budget`]); the least recently used entry of any of them goes first. budget: usize, pub file_loader: Option, + /// What grid thumbnails load files with (see [`ReducedLoader`]); `None`: [`Self::file_loader`]. + pub reduced_loader: Option, pub file_probe: Option, pub preview_loader: Option, /// Reads a photo file's bytes (Photo Merge); `None` = the local file system. @@ -426,6 +442,7 @@ impl Default for MediaCache { preview_capacity: 0, budget, file_loader: None, + reduced_loader: None, file_probe: None, preview_loader: None, file_bytes: None, @@ -718,11 +735,11 @@ impl MediaCache { } match self.scenes.iter().find(|s| s.id == *scene) { Some(s) => SourceRef::Demo { scene: Box::new(s.clone()), max_edge }, - None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None, fallback: None, denoise: None }, + None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None, fallback: None, denoise: None, reduced: None }, } } Source::File { path } => { - SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone(), fallback: None, denoise: None } + SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone(), fallback: None, denoise: None, reduced: None } } } } @@ -1201,7 +1218,12 @@ impl crate::Session { /// (≥ `long_edge`) and the result comes from / goes to the thumbnail cache (memory + disk). pub fn thumb_job(&mut self, id: PhotoId, long_edge: usize) -> Option { let b = thumb_bucket(long_edge); - self.build_job(id, b, b, false, true, Some(b)) + let mut job = self.build_job(id, b, b, false, true, Some(b))?; + // (a thumbnail may develop a smaller copy of the raw image that the file keeps) + if let SourceRef::File { reduced, .. } = &mut job.source { + reduced.clone_from(&self.media.reduced_loader); + } + Some(job) } fn build_job( @@ -1610,6 +1632,17 @@ impl crate::Session { depth: lightcraft_pipeline::OutputDepth, ) -> Result { let mut job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?; + // An export renders the full image: never a thumbnail's source developed from a smaller copy of the raw + // image that the file keeps (Lightroom's merged DNGs) + let reduced = match &job.source { + SourceRef::Loaded(s) => s.reduced, + SourceRef::Fit { from, .. } => from.reduced, + _ => false, + }; + if reduced { + let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); + job.source = self.media.source_ref_uncached(&p, job.level); + } // An export is made from the denoised picture its Denoise amount asks for, made when the job runs if the // background queue has not got to it. (A source kept in memory may lack the picture, so the job reads the file.) if let Some(wanted) = self.denoise_for_export(id, &job.settings)? { @@ -1866,6 +1899,7 @@ mod tests { })), fallback: None, denoise: None, + reduced: None, }; job.info = SourceInfo::default(); // Header facts cannot override decoder facts. job.settings = Arc::new(DevelopSettings::default()); @@ -1921,6 +1955,7 @@ mod tests { loader: Some(Arc::new(move |_, _| Ok((loaded_pixels.clone(), loaded_info.clone())))), fallback: None, denoise: None, + reduced: None, }; let undo_before = s.undo.len(); let request = job.request; diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs index 46b18e0c9..d26b8207e 100644 --- a/crates/engine/src/smart.rs +++ b/crates/engine/src/smart.rs @@ -356,7 +356,13 @@ pub fn mark_look_checked(dir: &Path) -> Result<(), String> { /// Load the proxy at `path`. pub fn load(path: &Path) -> Result { let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; - decode(&b).map(|(image, camera_tone)| crate::media::DecodedSource { image: Arc::new(image), info: None, camera_tone, denoised: None }) + decode(&b).map(|(image, camera_tone)| crate::media::DecodedSource { + image: Arc::new(image), + info: None, + camera_tone, + denoised: None, + reduced: false, + }) } #[cfg(test)] From 4a82c0774ee8d997d25fca391cfc6b873a7fce67 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 21 Oct 2026 10:17:57 +0200 Subject: [PATCH 30/36] tests_import: keep the folder rename comment with its own test --- crates/engine/src/tests_import.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index f00ecc557..1ac68c7bd 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -609,8 +609,6 @@ fn cube_luts_become_profiles() { let _ = std::fs::remove_dir_all(&lib); } -/// Folder rename / move on disk: files and sidecars go along, the photos are relinked and -/// still render; clashes and moving into itself are refused. /// Without a library on disk an imported LUT lives only in this session: no copy of it is /// left in the temp folder (#1952). #[test] @@ -639,6 +637,8 @@ fn cube_import_without_a_library_leaves_no_temp_copy() { let _ = std::fs::remove_dir_all(&src); } +/// Folder rename / move on disk: files and sidecars go along, the photos are relinked and +/// still render; clashes and moving into itself are refused. #[test] fn folders_rename_and_move_with_their_photos() { let root = temp_dir("folders"); From bcef909d40f790c53ffc6faec2652f8a1acac96b Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 21 Oct 2026 10:28:39 +0200 Subject: [PATCH 31/36] tests_import: without a library the imported profile keeps no file path at all --- crates/engine/src/tests_import.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 1ac68c7bd..7d1984746 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -630,7 +630,7 @@ fn cube_import_without_a_library_leaves_no_temp_copy() { let id = r["imported"][0]["id"].as_str().unwrap().to_string(); assert!(lightcraft_pipeline::lut::get(&id).is_some(), "usable in this session"); let file = s.lut_profiles.iter().find(|p| p.id == id).unwrap().file.clone(); - assert!(file.is_empty() || Path::new(&file).starts_with(&src), "no copy written: {file}"); + assert!(file.is_empty(), "no file kept without a library: {file}"); drop(s); assert!(!leak.exists(), "{} left behind", leak.display()); lightcraft_pipeline::lut::unregister(&id); From a3c1d6c0f556e164873f2f4d800aac876a77e119 Mon Sep 17 00:00:00 2001 From: Matthew James Sivak Date: Wed, 21 Oct 2026 19:00:52 +0200 Subject: [PATCH 32/36] LR-IMP-PREVIEW-READ: UI thumbnail tests stub the reduced loader too with_fs() now sets media.reduced_loader, which thumbnail jobs prefer over file_loader, so the render fixture's stub file_loader was bypassed: the job read the nonexistent audit.jpg and resizing_the_disk_cache_keeps_textures_and_skips_rebuilds got no texture (on every OS, not a flake). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/render.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 64ab067b7..d04a764c2 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -1128,6 +1128,8 @@ mod thumbnail_tests { image.data.fill([0.3, 0.2, 0.1]); Ok((image, Default::default())) })); + // (`with_fs` sets the file system's thumbnail loader, which wins over `file_loader`: thumbnails use the stub too) + s.media.reduced_loader = None; s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); let mut app = crate::LightcraftApp::new(s, crate::Services::default()); app.renderer.keep_pixels = true; From 4771ae0ea5cdb1a9a8cee9661f90903f7c591de5 Mon Sep 17 00:00:00 2001 From: JackalCOB <128789484+JackalCOB@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:52:57 +1300 Subject: [PATCH 33/36] LR-EDIT-OPTICS-PROFILE: read Fujifilm's nine-knot lens tables at the plain radius Fujifilm's nine-knot distortion and vignetting tables (knots at sqrt(k/8)) end at the corner on the eighth knot; the ninth, sqrt(9/8), lies past it with the eighth's value repeated. Reading them at rho = knot_last * r took every value from 6 % further out. On the X-T4 the warp polynomial then failed its fit checks on 253 of 425 zoom-lens raws, which got no distortion correction at all, and corners came out up to 6.5 % brighter than the camera's. Read at rho = r, the tables match the camera's measured geometry and corner gain (docs/fuji-lens-corrections.md, "Knot radius"). Nothing changes for the 11-knot and 8-knot tables, whose last knot is 1. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/media.rs | 2 +- crates/raw/src/vendor/raf_lens.rs | 34 ++++++++++-------- docs/fuji-lens-corrections.md | 57 +++++++++++++++++++++++++------ docs/parity-checklist.md | 3 +- docs/whats-new.md | 9 +++++ 5 files changed, 79 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6805f8537..6318a8859 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 65; +pub const RENDER_CACHE_VERSION: u64 = 66; /// 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/crates/raw/src/vendor/raf_lens.rs b/crates/raw/src/vendor/raf_lens.rs index 93b668e2f..a976751d2 100644 --- a/crates/raw/src/vendor/raf_lens.rs +++ b/crates/raw/src/vendor/raf_lens.rs @@ -8,12 +8,13 @@ //! demosaic already registers the colour planes, so the stored shift over-corrects (see the document). //! //! Layout (`n` knots): `[scale, knot × n, value × n]`. `scale × n` is the half diagonal of the recorded image in -//! pixels. Knots are positions along the radius, the last one being the farthest -//! corner; they are evenly spaced in `r` (`0, 0.1 … 1`) or in `r²` (`√(k/8)`, last `√(9/8)`) depending on the body. +//! pixels. Knots are radii in half diagonals, evenly spaced in `r` (`0, 0.1 … 1`) or in `r²` (`√(k/8)`) depending +//! on the body; the corner is at 1. The nine-knot tables run one knot past it, to `√(9/8)` (on the X-T4 the ninth +//! repeats the eighth's value). //! //! - distortion: percent, the bracket `1 + D/100` multiplies the radius; the camera's JPEG samples the raw at -//! `source = z · r · (1 + D(ρ(source))/100)`, `ρ = knot_last · radius / half diagonal`, with the zoom `z` that -//! keeps the output rectangle inside the source (`1 / max` of the bracket over the frame's border radii). +//! `source = z · r · (1 + D(source)/100)`, radii in half diagonals, with the zoom `z` that keeps the output +//! rectangle inside the source (`1 / max` of the bracket over the frame's border radii). //! - vignetting: relative illumination in percent at the source radius; the correction is the gain `100 / V`. use crate::{Opcode, Rect}; @@ -35,6 +36,8 @@ const SCALE_TOLERANCE: f64 = 0.005; /// Camera models whose tables were checked against the camera's own JPEG (distortion within 0.01 of the best free /// radial fit's correlation on at least one lens, see docs/fuji-lens-corrections.md). Other bodies stay uncorrected. +/// The nine-knot bodies other than the X-T4 were checked with their tables read at `knot_last · r`; the plain radius +/// they are now read at was measured on the X-T4 only ("Knot radius" in that doc). const VALIDATED: &[&str] = &[ "FinePix F770EXR", "FinePix HS30EXR", @@ -143,9 +146,6 @@ impl Table { Some(i) => y[i - 1] + (y[i] - y[i - 1]) * (x - k[i - 1]) / (k[i] - k[i - 1]), } } - fn last(&self) -> f64 { - self.knots[self.knots.len() - 1] - } } /// Source radius of the output radius `r` (both in half diagonals) with the distortion zoom. @@ -155,8 +155,9 @@ struct Geometry<'a> { } impl Geometry<'_> { + /// The radius bracket `1 + D/100` at source radius `source` (half diagonals, the table's own unit); 1 without a table. fn bracket(&self, source: f64) -> f64 { - self.table.map_or(1.0, |t| 1.0 + t.at(source * t.last(), Some(0.0)) / 100.0) + self.table.map_or(1.0, |t| 1.0 + t.at(source, Some(0.0)) / 100.0) } fn source(&self, r: f64) -> f64 { let mut s = self.zoom * r; @@ -283,7 +284,7 @@ fn vignette(ifd: &Ifd, w: usize, h: usize) -> Option { if t.values.iter().any(|v| !(10.0..=130.0).contains(v)) { return None; } - let gain = |r: f64| 100.0 / t.at(r * t.last(), Some(100.0)); + let gain = |r: f64| 100.0 / t.at(r, Some(100.0)); if (0..=SAMPLES).all(|j| (gain(j as f64 / SAMPLES as f64) - 1.0).abs() < 1e-4) { return None; } @@ -347,25 +348,30 @@ mod tests { r * (k[0] + r * r * (k[1] + r * r * (k[2] + r * r * k[3]))) } + /// The X-T4's arrays become a one-plane warp with the frame-filling zoom and a vignette gain read at the plain radius. #[test] fn x_t4_tables_become_a_warp_and_a_vignette_gain() { let ops = corrections("X-T4", &xt4(), XT4_AREA); let [Opcode::WarpRectilinear { planes, center }, Opcode::FixVignetteRadial { k, center: vc }] = ops.as_slice() else { panic!("{ops:?}") }; assert_eq!((*center, *vc), ([0.5, 0.5], [0.5, 0.5])); assert_eq!(planes.len(), 1, "no colour planes: lateral CA is not applied"); - // barrel distortion: zoomed to fill the frame (measured against the camera JPEG: 1.030..1.033), the - // corner samples inside the raw (-6.4 % bracket) + // barrel distortion: zoomed to fill the frame, the corner samples inside the raw (-6.4 % bracket; the measured + // free fit below gives 0.9635 there) let green = &planes[0]; assert!((1.02..1.04).contains(&green[0]), "{}", green[0]); let corner = evaluate(green, 1.0); - assert!((corner - 0.9668).abs() < 0.002, "{corner}"); + assert!((corner - 0.9641).abs() < 0.002, "{corner}"); // vignette: 67.457 % at the corner, 100 % on the axis let gain = |r: f64| 1.0 + (0..5).map(|i| k[i] * r.powi(2 * i as i32 + 2)).sum::(); assert!((gain(1.0) - 100.0 / 67.45703125).abs() < 0.01, "{}", gain(1.0)); assert!((gain(0.0) - 1.0).abs() < 1e-12); - assert!((gain(0.5) - 100.0 / (96.87792969 + (94.90234375 - 96.87792969) * (0.5 * 1.06056338 - 0.5) / (0.6126760563 - 0.5))).abs() < 0.01); + // read at the plain radius: r = 0.5 is the second knot, r = 0.85 lies between the fifth and sixth + assert!((gain(0.5) - 100.0 / 96.87792969).abs() < 0.01, "{}", gain(0.5)); + let v = 84.73291016 + (78.43701172 - 84.73291016) * (0.85 - 0.7908450704) / (0.8661971831 - 0.7908450704); + assert!((gain(0.85) - 100.0 / v).abs() < 0.01, "{}", gain(0.85)); } + /// The warp follows a free radial fit of the camera's own JPEG of pixls 3913 to within 0.0012 half diagonals. #[test] fn matches_the_measured_x_t4_geometry() { // Free radial fit (data/testing/raf-lens/tools/fit_model.py) of the camera JPEG against LightCraft's uncorrected @@ -378,7 +384,7 @@ mod tests { let r = i as f64 / 20.0; let r2 = r * r; let reference = measured[0] * r * (1.0 + measured[1] * r2 + measured[2] * r2 * r2 + measured[3] * r2 * r2 * r2); - assert!((evaluate(&planes[0], r) - reference).abs() < 0.004, "{r}: {} vs {reference}", evaluate(&planes[0], r)); + assert!((evaluate(&planes[0], r) - reference).abs() < 0.0012, "{r}: {} vs {reference}", evaluate(&planes[0], r)); } } diff --git a/docs/fuji-lens-corrections.md b/docs/fuji-lens-corrections.md index ca25e53a2..365c98e98 100644 --- a/docs/fuji-lens-corrections.md +++ b/docs/fuji-lens-corrections.md @@ -6,9 +6,10 @@ LightCraft reads the distortion and vignetting tables that Fujifilm cameras stor path, EXIF orientation handling and DNG export use them like any other embedded correction. Corrected: **distortion** (with the camera's frame-filling zoom) and **vignetting**, for the 59 bodies listed in -`crates/raw/src/vendor/raf_lens.rs` (`VALIDATED`). **Not corrected: lateral chromatic aberration** (the third table, -`0xf00f`); see "Chromatic aberration" below. Bodies without a checked sample, and tables in a layout or normalisation -that was not understood, stay uncorrected. +`crates/raw/src/vendor/raf_lens.rs` (`VALIDATED`); the nine-knot bodies among them other than the X-T4 were checked +with an earlier reading of their tables than the one they now get (see "Knot radius"). **Not corrected: lateral +chromatic aberration** (the third table, `0xf00f`); see "Chromatic aberration" below. Bodies without a checked sample, +and tables in a layout or normalisation that was not understood, stay uncorrected. ## Clean-room evidence @@ -29,10 +30,12 @@ Every table is an array of signed rationals with `n` knots: - `scale × n` is the half diagonal of the recorded image (the RAF crop) in pixels: 3749 for 6240×4160, 4643 for 7728×5152, 2942 for 4896×3264, 7280 for 11648×8736, on 61 of the 65 bodies. Files that store another number (X-S20, X-T30 III and X-M5 write the 40 MP value, 4643; an X-T5 in an in-camera crop) are left uncorrected. -- Knots are positions along the radius. The **last knot is the farthest corner**: `ρ = knot_last · r`, `r` the - distance from the centre in half diagonals. Older bodies use 11 knots `0, 0.1 … 1`, newer ones 9 knots evenly - spaced in `r²` (`√(k/8)`, last `√(9/8) = 1.0607`), GFX 50 bodies 8 (`√(k/8)`, last 1). The distortion zero and the 100 % - illumination at the axis are anchored at `ρ = 0` when the first knot lies above it. +- Knots are **radii in half diagonals**: the farthest corner is at 1, and a table is read at `ρ = r`, the distance + from the centre in half diagonals. Older bodies use 11 knots `0, 0.1 … 1`, newer ones 9 knots evenly spaced in `r²` + (`√(k/8)`), GFX 50 bodies 8 (`√(k/8)`, last 1). In the nine-knot tables the eighth knot is the corner and the ninth, + `√(9/8) = 1.0607`, lies beyond it. In all 1,140 tables of 570 X-T4 raws, taken with three lenses at every focal + length they were shot at, the ninth repeats the eighth's value. The distortion zero and the 100 % illumination at the axis + are anchored at `ρ = 0` when the first knot lies above it. - Distortion values are percent: the bracket `1 + D/100` multiplies the radius. - Vignetting values are percent of the axial illumination at the (source) radius. - The chromatic-aberration table has the layout `[scale, knots, red × n, blue × n, scale]` (fractions of the radius, @@ -43,8 +46,8 @@ Every table is an array of signed rationals with `n` knots: The camera's JPEG samples the raw at ``` -source = z · r · (1 + D(ρ(source)) / 100) ρ = knot_last · source -z = 1 / max over the output border radii of (1 + D(ρ(source)) / 100) +source = z · r · (1 + D(source) / 100) +z = 1 / max over the output border radii of (1 + D(source) / 100) ``` The table is indexed by the **source** (sensor) radius, not the output radius, and the zoom `z` keeps the output @@ -54,11 +57,16 @@ radius is solved per radius by fixed-point iteration, then `source(r) = r (k0 + tables 0.0007, median 0.00012) or folds is rejected. The correction itself is the warp's bracket; the camera's rounding and interpolation between knots are not reproduced beyond linear interpolation between knots. -Vignetting is the gain `100 / V(ρ(r))` at the source radius, fitted by a 5-term minimax polynomial in `r²` +Vignetting is the gain `100 / V(r)` at the source radius, fitted by a 5-term minimax polynomial in `r²` (`FixVignetteRadial`); a fit more than 0.025 off the table is rejected. ## Validation +The tables were first read with the last knot at the corner (`ρ = knot_last · r`). For the 11-knot and GFX 50 tables, +whose last knot is 1, that is the same reading. For the nine-knot tables it reads every value from 6 % further out; +"Knot radius" below has the evidence that changed it. The figures in the rest of this section were measured with the +earlier reading. + Method: LightCraft's uncorrected render of each RAF (1200 px long edge) against the camera JPEG (`camera-jpeg/`, resized to the same size). Per file the high-passed luminance is correlated (NCC) after a warp with a free global scale and shift; the warp is (a) similarity only, (b) the table with its sign flipped, (c) the table, lookup at the output @@ -104,6 +112,35 @@ tables (X-M5, X-T2, X-T3) give a gain of zero in the JPEG. Through the shipped c β at least halves on 30 of them. Seven combinations show only a fraction of the amplitude (S1, SL1000, X-A2, X-A3, X-S1, X-T1, X-Pro3); the camera setting that governs them is not recorded. +### Knot radius + +On the X-T4, the nine-knot tables read at the plain radius match the camera and the earlier reading does not: + +- **Geometry, against the free fit measured above** (`matches_the_measured_x_t4_geometry`, pixls 3913, XF16-55 at 16 mm): + the camera's scale is 1.0300. Read at the plain radius the table gives a zoom of 1.0298 and stays within 0.0008 half + diagonals of the fit at every radius; the earlier reading gave 1.0333 and strayed up to 0.0034 +- **Geometry and gain, against synthetic charts the camera converted.** Copies of the same raw with patches of known + colour, converted by the camera in its USB raw conversion mode. Against the camera's geometry from 445 chart markers, + the distortion table read at the plain radius is at most 1.2 pixels off at 3120 × 2080 out to 0.92 of the half + diagonal (earlier reading 4.2), and against its corner gain from 46,818 flat windows the vignetting table is within + 1.2 % out to 0.9 of the half diagonal (earlier reading up to 6.5 % too bright, 5.9 % at 0.8) +- **On photographs**, 500 of the contributor's own X-T4 raws (XF16-55, XF70-300 and a 13 mm lens), shown only as + aggregates. With the earlier reading the table bends inside the frame, where the ninth knot's repeated value begins + (`1 / 1.0607 = 0.943` of the half diagonal), and on 253 of the 425 zoom-lens raws the warp polynomial failed its fit + checks, so they got no distortion correction at all. Read at the plain radius the bend is at the corner and 424 of + the 425 get their warp. The 75 raws from the 13 mm lens record an all-zero distortion table, so they have nothing to + correct either way. Against the camera's own conversion of each raw (LightCraft's render at 1,200 pixels, compared + at 780 × 520), the correlation of the high-passed luminance went from a median of 0.03 to 0.90 on the XF16-55 (327 + raws) and from 0.02 to 0.83 on the XF70-300 (91), and stayed at 0.96 on the 13 mm. Over flat areas, LightCraft's + corners less its centre, against the camera's, went from +0.56 to +0.21 L* on the XF16-55 and from +0.49 to +0.21 on + the XF70-300. A further 292 raws held back from all of this gave the same: correlation 0.06 to 0.91 and 0.21 to + 0.85, corners +0.62 to +0.26 and +0.35 to +0.14 L* + +The other bodies with nine-knot tables are read at the plain radius too, because their knots have the same layout +(`√(k/8)`, the eighth at the corner), but none of them was measured at it: their place in `VALIDATED` rests on the +figures under "Validation" above, taken with the earlier reading, and is unconfirmed for the plain radius until a camera +JPEG from each is checked. Render cache version 66 redraws thumbnails rendered with the earlier reading. + ### Chromatic aberration (not applied) The CA table is real: on the raw mosaics themselves (uncompressed RAFs, colour planes built by normalised convolution so diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 02f5bf08f..04dd78520 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** patch (LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -896,3 +896,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-14 | patch | LRC-LIB-CATALOG-IMPORT: reconciled with upstream phased progress, cancellation kinds, WAL-only import, translations and current progress tracking. Status and summary counts unchanged. | | 2026-10-13 | minor | KEYC-COMPARE: `=` / `-` (also `+`) increase / decrease the grid thumbnail size (`view.thumbSizeUp` / `view.thumbSizeDown`, View menu), grids only; status unchanged (🟡) | | 2026-10-16 | patch | LR-META-XMP: a TIFF or JPEG imported beside a raw of its stem reads its own `.xmp` and takes nothing from the raw's `IMG_0001.xmp`, whether it arrives with the raw or after it (a stem sidecar belongs to one file: a raw first, then the first by name); status unchanged (✅) | +| 2026-10-21 | patch | LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables (X-T4 and other newer bodies) read at the plain radius, the corner at the eighth knot; X-T4 zoom raws now get their distortion correction (253 of 425 checked had none) and corners no longer brighten up to 6.5 % more than the camera's; status unchanged (🟡) | diff --git a/docs/whats-new.md b/docs/whats-new.md index cfeed33ed..417ad4138 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,14 @@ # What's new in LightCraft +## 2026-10-21 + +### Fujifilm lens corrections that match the camera +- X-T4 raws now read the lens's own distortion and vignetting tables the way the camera does. Many photos from zoom + lenses got no distortion correction at all (more than half of those checked from the XF16-55 and XF70-300), and + corners came out up to 6.5 % brighter than in the camera's JPEG. The corrected photo now lines up with the camera's + framing. Other recent Fujifilm bodies whose tables have nine knots are read the same way, but only the X-T4 was + measured. Thumbnails are redrawn when the library next shows them. + ## 2026-10-19 ### Hidden Local locations come back one at a time From 53ce89fc116edd393e58439e213a15636f2da8de Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:37:51 +0000 Subject: [PATCH 34/36] Integration: #1616 move the overcast-scene doc comment back to its test --- crates/engine/src/camera_preview.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 7cf137dd0..7cd336de1 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -3828,9 +3828,6 @@ mod tests { assert!(tone.apply(0.05) < tone.apply(0.2) && tone.apply(0.2) < tone.apply(0.8)); } - /// An overcast, nearly colourless scene (under 5% of the pixels coloured, so no colour matrix is - /// learnt and the full look is never tried) whose camera renders it two stops brighter than the - /// neutral fallback: the partial look is the tone curve (and chroma curve) alone. fn near_black_scene() -> (Rgb32f, Rgb32f) { let mut sensor = Rgb32f::new(96, 64); let mut reference = sensor.clone(); @@ -3935,6 +3932,9 @@ mod tests { assert!(fit_dark_tone(&sensor, &reference, &[]).is_none(), "mismatched clip mask"); } + /// An overcast, nearly colourless scene (under 5% of the pixels coloured, so no colour matrix is + /// learnt and the full look is never tried) whose camera renders it two stops brighter than the + /// neutral fallback: the partial look is the tone curve (and chroma curve) alone. #[test] fn partial_look_brightens_a_dull_scene_without_learning_colour() { let (w, h) = (96usize, 64usize); From fa6f5b285b5f5a65dcd9d09c86b48a83f686264c Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:42:38 +0000 Subject: [PATCH 35/36] Integration: one LOOK_VERSION (12) and RENDER_CACHE_VERSION (69) bump for the merged camera-look changes --- crates/engine/src/camera_preview.rs | 8 ++++---- docs/camera-preview-colour.md | 2 +- docs/sony-lens-corrections.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 155629830..d074567a7 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -35,10 +35,10 @@ use lightcraft_raw::{ /// 8: the bundled NIKON Z 6_2 profile. /// 9: the bundled NIKON Z 8 and NIKON D5300 profiles. /// 10: DNG tone fit for profiles with a look table and for 1024 px (Lightroom) previews. -/// 12: tone curves of low-contrast scenes (range limit 1.2, with the bright-area check for curves under 1.5, #699). -/// 10: tone-only fallback for near-black scenes with too few colour pairs (#1587). -/// 10: DNG tone fitting uses a lens-corrected proxy when the file carries lens opcodes. -/// 11: validated A7CR distortion, including the lens data stored with each smart preview. +/// 12: tone curves of low-contrast scenes (range limit 1.2, with the bright-area check for curves under 1.5, #699); +/// 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; #[derive(Clone, Debug)] diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index d7f3ace33..7a1235ab3 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -36,7 +36,7 @@ If the original curve cannot be fitted, the wider curve can still be accepted af A near-black frame can have fewer than 256 colour pairs, so none of those attempts reaches tone fitting (#1587). After all existing looks fail, a tone-only fallback uses the wider signal floors above. At least 90% of the JPEG proxy must be below luminance 0.015. It excludes clipped sensor pixels from training and refuses invalid samples and JPEG highlights at or above 0.85. Every clipped pixel must stay at least as close to its JPEG luminance as the default curve leaves it. At least 256 usable pairs must show luminance rank correlation of 0.5 or more. Two thirds train a quantile curve; the held-out third must reduce squared luminance error by 30% in both linear and gamma-encoded values. This fallback keeps the source's spectral or neutral colour and learns no matrix, hue/saturation table or chroma curve. Earlier accepted looks keep their result. -The extra dark knots move the straight-line extension down toward black without a new serialized curve format or a different CPU/GPU interpolation rule. `LOOK_VERSION` 10 refreshes older fitted smart previews when their originals are online; `RENDER_CACHE_VERSION` 67 redraws cached renders. +The extra dark knots move the straight-line extension down toward black without a new serialized curve format or a different CPU/GPU interpolation rule. `LOOK_VERSION` 12 refreshes older fitted smart previews when their originals are online; `RENDER_CACHE_VERSION` 69 redraws cached renders. No process-version bump is needed under the [rule for changes to source data](process-versions.md#the-rule). The decoded source carries the fitted camera tone in `SourceInfo`; both V1 and V2 use it in `finish::base_tone`. Existing raws edited under V1 or V2 can therefore change when decoded again. Offline smart previews keep their stored pixels and tone until rebuilt from the original. The scan on library opening runs once per look version for each smart previews folder. If an original was offline at that scan, run Build Smart Previews after reconnecting it. diff --git a/docs/sony-lens-corrections.md b/docs/sony-lens-corrections.md index 3bd40a020..dff886b13 100644 --- a/docs/sony-lens-corrections.md +++ b/docs/sony-lens-corrections.md @@ -173,7 +173,7 @@ No Imaging Edge On/Off pairs were measured for this body. Camera JPEGs are the r This branch supplies the warp for camera-look pairing in PR #1586. It does not duplicate that pairing code. -`LOOK_VERSION` 11 refreshes smart previews that stored no A7CR lens data; `RENDER_CACHE_VERSION` 68 redraws cached +`LOOK_VERSION` 12 refreshes smart previews that stored no A7CR lens data; `RENDER_CACHE_VERSION` 69 redraws cached renders. Existing edits are preserved. Reload from Disk reads the new lens data for old imports. Enable the From 5991f9de61ee7cce17a5443e0388aa9747c48f08 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 02:47:31 +0000 Subject: [PATCH 36/36] Integration: #1809 smart preview SourceInfo takes native_long (None; the catalog fills it) --- crates/engine/src/smart.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs index e71eccf00..9f72e0f48 100644 --- a/crates/engine/src/smart.rs +++ b/crates/engine/src/smart.rs @@ -246,6 +246,7 @@ pub fn decode(bytes: &[u8]) -> Result<(Rgb32f, Option, Option