From 943448e7c9fb476567ea436c7d8ca3b4b6e0d794 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Sat, 10 Oct 2026 08:38:41 -0400 Subject: [PATCH] LR-EDIT-OPTICS-PROFILE: apply embedded Fujifilm RAF distortion and vignetting tables Fujifilm RAFs carry per-shot GeometricDistortionParams (0xf00b) and VignettingParams (0xf010) in the raw IFD. Derived by measurement against the cameras' own JPEGs and LightCraft's uncorrected renders: [scale, n knots, n values], scale x n = half diagonal of the recorded image, last knot = farthest corner, distortion in percent indexed by the source radius with the frame-filling zoom, vignetting as 100/V gain. They become a WarpRectilinear and a FixVignetteRadial for 59 validated bodies (same path as the Sony ARW and Panasonic RW2 corrections); unmatched bodies and tables with another scale stay uncorrected. 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. The lateral CA table (0xf00f) is real but is not applied: our demosaic already registers the colour planes and the stored shift over-corrects. See docs/fuji-lens-corrections.md. RENDER_CACHE_VERSION 48. --- ROADMAP.md | 5 +- crates/engine/src/media.rs | 2 +- crates/raw/src/vendor/mod.rs | 1 + crates/raw/src/vendor/raf.rs | 112 +++++++- crates/raw/src/vendor/raf_lens.rs | 452 ++++++++++++++++++++++++++++++ crates/raw/tests/corpus.rs | 72 +++++ docs/fuji-lens-corrections.md | 128 +++++++++ docs/parity.md | 7 +- docs/whats-new.md | 6 + 9 files changed, 764 insertions(+), 21 deletions(-) create mode 100644 crates/raw/src/vendor/raf_lens.rs create mode 100644 docs/fuji-lens-corrections.md diff --git a/ROADMAP.md b/ROADMAP.md index 9bb4744b9..cd11586f5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -58,8 +58,9 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par checked for plausible colour; fix per-model bugs (#85). 4. **Render fidelity suite** (LR-BEHAV-RENDER-FIDELITY, P1): measure our output against Lightroom on the same CC0 raws (references stay in the local `plan/`), then tune against the numbers. -5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1): DNG, Panasonic / Leica RW2 and Sony ILCE-7RM4A Bayer ARW - embedded distortion are supported; broaden validation and decode remaining vignetting / lateral CA metadata. +5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1): DNG, Panasonic / Leica RW2, Sony ILCE-7RM4A Bayer ARW + embedded distortion and Fujifilm RAF distortion + vignetting tables (59 validated bodies) are supported; broaden validation and decode + remaining vignetting / lateral CA metadata (Fujifilm's CA table needs a pre-demosaic correction). 6. **AI model strategy** (maintainer decision): which permissively licensed models (or our own training) for segmentation masks and denoise. Bayer denoise inference is implemented in pure Rust with an opt-in GPL model; release policy and independently licensed weights remain open. Unblocks M12 and Enhance. Face detection and recognition run on our own checked Rust engine. Object / Describe masks now run Meta's SAM 3 in pure Rust (optional, user-consented download under the non-OSI SAM License; CDN mirrors still diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6d5b548f1..f35056850 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 = 42; +pub const RENDER_CACHE_VERSION: u64 = 48; /// 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/mod.rs b/crates/raw/src/vendor/mod.rs index fcab4eabf..3dc3d2621 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -11,6 +11,7 @@ pub mod nefc; pub mod orf; pub mod pef; pub mod raf; +mod raf_lens; mod rafc; pub mod rw2; mod sr2; diff --git a/crates/raw/src/vendor/raf.rs b/crates/raw/src/vendor/raf.rs index 61205a3f7..0ffc644e2 100644 --- a/crates/raw/src/vendor/raf.rs +++ b/crates/raw/src/vendor/raf.rs @@ -92,6 +92,13 @@ fn raw_ifd(raw: &[u8]) -> Result { Ok(ifd) } +/// The camera name in the file header (bytes 28..60, NUL padded): the Exif `Model` of every sample. +fn camera_name(b: &[u8]) -> String { + let name = b.get(28..60).unwrap_or_default(); + let end = name.iter().position(|&c| c == 0).unwrap_or(name.len()); + String::from_utf8_lossy(name.get(..end).unwrap_or_default()).trim().to_string() +} + /// The CFA: X-Trans from the (reversed) layout record, else RGGB. fn cfa(h: &Header) -> Cfa { match record(h, XTRANS_LAYOUT) { @@ -205,7 +212,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, - opcodes: OpcodeLists::default(), + opcodes: OpcodeLists { list3: super::raf_lens::corrections(&camera_name(bytes), &ifd, active), ..Default::default() }, metadata, }; img.validate_for(mode)?; @@ -223,29 +230,57 @@ pub(super) mod tests { /// [`raf`] with the raw IFD's `0xf006` flag set to `words32`. pub(crate) fn raf_flagged(w: u32, h: u32, bits: u32, strip: Vec, layout: Option<[u8; 36]>, jpeg: &[u8], words32: u32) -> Vec { + raf_full(w, h, bits, strip, layout, jpeg, words32, "TEST", &[]) + } + + /// [`raf_flagged`] with the camera name in the header and extra raw-IFD entries of signed rationals. + #[allow(clippy::too_many_arguments)] + pub(crate) fn raf_full( + w: u32, + h: u32, + bits: u32, + strip: Vec, + layout: Option<[u8; 36]>, + jpeg: &[u8], + words32: u32, + model: &str, + extra: &[(u16, Vec<(i32, i32)>)], + ) -> Vec { let mut block = b"II*\0\x08\0\0\0".to_vec(); let sub_off: u32 = 8 + 2 + 12 + 4; block.extend_from_slice(&1u16.to_le_bytes()); block.extend_from_slice(&[0x00, 0xf0, 13, 0, 1, 0, 0, 0]); block.extend_from_slice(&sub_off.to_le_bytes()); block.extend_from_slice(&0u32.to_le_bytes()); - let n = 8u32; + let n = 8u32 + extra.len() as u32; let wb_off = sub_off + 2 + 12 * n + 4; - let strip_off = wb_off + 12; - let entries: [(u16, u32, u32); 8] = [ - (0xf001, 1, w), - (0xf002, 1, h), - (0xf003, 1, bits), - (0xf006, 1, words32), - (0xf007, 1, strip_off), - (0xf008, 1, strip.len() as u32), - (0xf00a, 1, 64), - (0xf00e, 3, wb_off), + // out-of-line data after the white balance: each extra table in turn, then the strip + let mut next = wb_off + 12; + let mut table_at = Vec::new(); + for (_, v) in extra { + table_at.push(next); + next += 8 * v.len() as u32; + } + let strip_off = next; + // (tag, type, count, value or offset), in tag order + let mut entries: Vec<(u16, u16, u32, u32)> = vec![ + (0xf001, 4, 1, w), + (0xf002, 4, 1, h), + (0xf003, 4, 1, bits), + (0xf006, 4, 1, words32), + (0xf007, 4, 1, strip_off), + (0xf008, 4, 1, strip.len() as u32), + (0xf00a, 4, 1, 64), + (0xf00e, 4, 3, wb_off), ]; + for ((tag, v), at) in extra.iter().zip(&table_at) { + entries.push((*tag, 10, v.len() as u32, *at)); + } + entries.sort_by_key(|e| e.0); block.extend_from_slice(&(n as u16).to_le_bytes()); - for (tag, count, v) in entries { + for (tag, ty, count, v) in entries { block.extend_from_slice(&tag.to_le_bytes()); - block.extend_from_slice(&4u16.to_le_bytes()); + block.extend_from_slice(&ty.to_le_bytes()); block.extend_from_slice(&count.to_le_bytes()); block.extend_from_slice(&v.to_le_bytes()); } @@ -253,6 +288,12 @@ pub(super) mod tests { for v in [300u32, 600, 450] { block.extend_from_slice(&v.to_le_bytes()); } + for (_, v) in extra { + for (num, den) in v { + block.extend_from_slice(&num.to_le_bytes()); + block.extend_from_slice(&den.to_le_bytes()); + } + } block.extend_from_slice(&strip); let mut recs: Vec = Vec::new(); let mut nrec = 0u32; @@ -270,7 +311,8 @@ pub(super) mod tests { } let mut dir = nrec.to_be_bytes().to_vec(); dir.extend_from_slice(&recs); - let mut out = b"FUJIFILMCCD-RAW 0201FF000000TEST".to_vec(); + let mut out = b"FUJIFILMCCD-RAW 0201FF000000".to_vec(); + out.extend_from_slice(model.as_bytes()); out.resize(108, 0); if strip.starts_with(b"IS") { let compression = if strip.get(2) == Some(&0) { 3u32 } else { 2u32 }; @@ -374,6 +416,46 @@ pub(super) mod tests { assert_eq!(decode(raf_flagged(w, h, 12, pack(&px, 12), None, b"", 0)), RawData::U16(px)); } + /// The lens tables of the raw IFD reach `Mode::Header` and `Mode::Full` alike, only for validated cameras, + /// and survive a DNG round trip. The frame is 12 × 4 after the crop records (4, 2). + #[test] + fn lens_tables_become_opcodes_for_validated_cameras() { + let (w, h) = (16u32, 6u32); + let half = 12.0f64.hypot(4.0) / 2.0; + let rat = |v: &[f64]| -> Vec<(i32, i32)> { v.iter().map(|x| ((x * 1.0e6).round() as i32, 1_000_000)).collect() }; + let knots = [0.3535211268, 0.5, 0.6126760563, 0.7070422535, 0.7908450704, 0.8661971831, 0.9352112676, 1.0, 1.06056338]; + let table = |values: &[f64]| { + let mut v = vec![half / 9.0]; + v.extend_from_slice(&knots); + v.extend_from_slice(values); + rat(&v) + }; + let dist = [-1.2, -2.4, -3.5, -4.4, -5.3, -5.9, -6.3, -6.4, -6.4]; + let vig = [98.5, 96.9, 94.9, 91.0, 84.7, 78.4, 73.0, 67.5, 67.5]; + // the colour-plane table is present in real files and ignored + let ca = [-0.00003, -0.00009, -0.00015, -0.0002, -0.00027, -0.00034, -0.00046, -0.0006, -0.0006]; + let extra = vec![(0xf00b, table(&dist)), (0xf00f, table(&ca)), (0xf010, table(&vig))]; + for (model, expected) in [("X-T4", 2), ("GFX 100", 2), ("X-T6", 0), ("X-T4 II", 0), ("", 0)] { + let bytes = raf_full(w, h, 16, pack(&[1000; 96], 16), None, b"", 0, model, &extra); + let header = decode(&bytes, Mode::Header).unwrap(); + let full = crate::decode(&bytes).unwrap(); + assert_eq!(header.info(), full.info(), "{model}"); + assert_eq!(full.opcodes.list3.len(), expected, "{model}"); + let Some(crate::Opcode::WarpRectilinear { planes, center }) = full.opcodes.list3.first() else { continue }; + assert_eq!((planes.len(), *center), (1, [0.5, 0.5]), "{model}"); + assert!(matches!(full.opcodes.list3[1], crate::Opcode::FixVignetteRadial { .. })); + let dng = crate::write_dng(&full, &Default::default()).unwrap(); + assert_eq!(crate::decode(&dng).unwrap().opcodes.list3, full.opcodes.list3, "{model}"); + } + // a table in another scale is left alone, and so is a file without tables + let wrong: Vec<(u16, Vec<(i32, i32)>)> = + extra.iter().map(|(t, v)| (*t, v.iter().map(|&(n, d)| if n == v[0].0 { (n * 2, d) } else { (n, d) }).collect())).collect(); + let bytes = raf_full(w, h, 16, pack(&[1000; 96], 16), None, b"", 0, "X-T4", &wrong); + assert!(crate::decode(&bytes).unwrap().opcodes.list3.iter().all(|o| !matches!(o, crate::Opcode::WarpRectilinear { .. }))); + let bytes = raf_full(w, h, 16, pack(&[1000; 96], 16), None, b"", 0, "X-T4", &[]); + assert!(crate::decode(&bytes).unwrap().opcodes.list3.is_empty()); + } + #[test] fn overflowing_row_stride_is_an_error() { let mut bytes = raf(16, 6, 16, vec![0; 192], None, &[]); diff --git a/crates/raw/src/vendor/raf_lens.rs b/crates/raw/src/vendor/raf_lens.rs new file mode 100644 index 000000000..93b668e2f --- /dev/null +++ b/crates/raw/src/vendor/raf_lens.rs @@ -0,0 +1,452 @@ +//! Fujifilm's per-shot lens correction tables from the RAF raw IFD, expressed as DNG opcodes for the shared optics +//! pipeline (the same path as Sony `arw_lens` and Panasonic `rw2`). +//! +//! Tag identity: the ExifTool FujiFilm tag-name documentation names `0xf00b` GeometricDistortionParams and +//! `0xf010` VignettingParams (signed rationals). Layout, units, radius normalisation and geometry were derived from +//! the CC0 samples and the cameras' own JPEGs; see docs/fuji-lens-corrections.md. No lens database and no external +//! decoder code. The lateral chromatic aberration table (`0xf00f`) is deliberately not applied: LightCraft's +//! 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. +//! +//! - 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). +//! - vignetting: relative illumination in percent at the source radius; the correction is the gain `100 / V`. + +use crate::{Opcode, Rect}; +use lightcraft_tiff::Ifd; + +const DISTORTION: u16 = 0xf00b; +const VIGNETTING: u16 = 0xf010; +const SAMPLES: usize = 1024; +/// Largest allowed deviation of the warp polynomial from the table, in half diagonals (median over the 118 sample +/// tables 0.00012, largest 0.0007; about 1 px at 1440 px). +const MAX_WARP_ERROR: f64 = 0.0008; +/// Largest allowed relative deviation of the gain polynomial from the table (median 0.4 %, largest 1.9 %). +const MAX_GAIN_ERROR: f64 = 0.025; +/// Reweighting rounds of the minimax polynomial fit. +const FIT_ROUNDS: usize = 40; +/// `scale × n` must be the recorded image's half diagonal to this relative tolerance (other normalisations are not +/// understood). +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. +const VALIDATED: &[&str] = &[ + "FinePix F770EXR", + "FinePix HS30EXR", + "FinePix HS33EXR", + "FinePix HS50EXR", + "FinePix X100", + "GFX 100", + "GFX 50R", + "GFX 50S", + "GFX100 II", + "GFX100RF", + "GFX100S", + "GFX100S II", + "GFX50S II", + "X-A1", + "X-A10", + "X-A2", + "X-A3", + "X-A5", + "X-A7", + "X-E1", + "X-E2", + "X-E2S", + "X-E3", + "X-E4", + "X-E5", + "X-H1", + "X-H2", + "X-H2S", + "X-M1", + "X-M5", + "X-Pro1", + "X-Pro2", + "X-Pro3", + "X-S1", + "X-S10", + "X-S20", + "X-T1", + "X-T10", + "X-T100", + "X-T2", + "X-T20", + "X-T200", + "X-T3", + "X-T30", + "X-T30 II", + "X-T30 III", + "X-T4", + "X-T5", + "X-T50", + "X100F", + "X100S", + "X100T", + "X100V", + "X100VI", + "X20", + "X30", + "X70", + "XF10", + "XQ2", +]; + +/// One parsed table: knot positions and the value array of the same length. +struct Table { + knots: Vec, + values: Vec, +} + +/// Read and sanity-check a table. `half_diagonal` is the recorded image's half diagonal in pixels, which +/// `scale × n` must reproduce. +fn read(ifd: &Ifd, tag: u16, half_diagonal: f64) -> Option { + let v = ifd.f64s(tag)?; + if v.is_empty() || v.iter().any(|x| !x.is_finite()) { + return None; + } + let n = (v.len() - 1) / 2; + if !(3..=40).contains(&n) || v.len() != 1 + 2 * n { + return None; + } + let scale = v[0]; + if scale <= 0.0 || (scale * n as f64 / half_diagonal - 1.0).abs() > SCALE_TOLERANCE { + return None; + } + let knots = v[1..1 + n].to_vec(); + if knots[0] < 0.0 || knots.windows(2).any(|w| w[1] <= w[0]) || !(0.5..=2.0).contains(&knots[n - 1]) { + return None; + } + Some(Table { knots, values: v[1 + n..].to_vec() }) +} + +impl Table { + /// Value `which` at knot position `x`: linear between knots, flat outside, anchored at `x = 0` to `at_zero` + /// when the first knot lies above it (distortion is 0 and illumination 100 % on the axis). + fn at(&self, x: f64, at_zero: Option) -> f64 { + let (k, y) = (&self.knots, &self.values); + if let (Some(z), true) = (at_zero, k[0] > 0.0) + && x < k[0] + { + return z + (y[0] - z) * (x / k[0]).max(0.0); + } + if x <= k[0] { + return y[0]; + } + match k.iter().position(|&p| p >= x) { + None => y[y.len() - 1], + 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. +struct Geometry<'a> { + table: Option<&'a Table>, + zoom: f64, +} + +impl Geometry<'_> { + 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) + } + fn source(&self, r: f64) -> f64 { + let mut s = self.zoom * r; + for _ in 0..24 { + s = self.zoom * r * self.bracket(s); + } + s + } +} + +/// The frame-filling zoom: the largest radial scale for which every border point of the `w × h` output samples +/// inside the source (the bracket is largest at the nearest border radius or beyond). +fn zoom(table: &Table, nearest: f64) -> Option { + let mut g = Geometry { table: Some(table), zoom: 1.0 }; + for _ in 0..8 { + let m = (0..=512).map(|i| nearest + (1.0 - nearest) * i as f64 / 512.0).map(|r| g.bracket(g.source(r))).fold(f64::MIN, f64::max); + if !(0.5..2.0).contains(&m) { + return None; + } + g.zoom = 1.0 / m; + } + Some(g.zoom) +} + +/// Fit `target(r) ≈ Σ c_i basis_i(r)` on `r ∈ [0, 1]` minimising the largest deviation (iteratively reweighted least +/// squares), returning the coefficients and that deviation. +fn minimax(basis: impl Fn(f64) -> [f64; N], target: impl Fn(f64) -> f64) -> Option<([f64; N], f64)> { + let rows: Vec<([f64; N], f64)> = (0..=SAMPLES).map(|j| j as f64 / SAMPLES as f64).map(|r| (basis(r), target(r))).collect(); + let mut weight = vec![1.0; rows.len()]; + let mut best: Option<([f64; N], f64)> = None; + for _ in 0..FIT_ROUNDS { + let mut matrix = [[0.0; N]; N]; + let mut rhs = [0.0; N]; + for ((terms, y), w) in rows.iter().zip(&weight) { + for (i, &a) in terms.iter().enumerate() { + rhs[i] += w * a * y; + for (k, &b) in terms.iter().enumerate() { + matrix[i][k] += w * a * b; + } + } + } + let c = solve(matrix, rhs)?; + let errors: Vec = rows.iter().map(|(terms, y)| (terms.iter().zip(c).map(|(a, b)| a * b).sum::() - y).abs()).collect(); + let worst = errors.iter().copied().fold(0.0, f64::max); + if best.as_ref().is_none_or(|b| worst < b.1) { + best = Some((c, worst)); + } + let mean = weight.iter().sum::() / weight.len() as f64; + for (w, e) in weight.iter_mut().zip(&errors) { + *w *= 0.3 + e / worst.max(1e-15); + *w /= mean; + } + } + best +} + +/// `source(r) = r (k0 + k1 r² + k2 r⁴ + k3 r⁶)`, rejected when it strays from the table or folds. +fn polynomial(source: impl Fn(f64) -> f64) -> Option<[f64; 4]> { + let mut previous = -1.0; + for j in 0..=SAMPLES { + let s = source(j as f64 / SAMPLES as f64); + if !s.is_finite() || s <= previous { + return None; + } + previous = s; + } + let basis = |r: f64| { + let r2 = r * r; + [r, r * r2, r * r2 * r2, r * r2 * r2 * r2] + }; + let (k, error) = minimax(basis, &source)?; + let folds = (0..=SAMPLES).any(|j| { + let r2 = (j as f64 / SAMPLES as f64).powi(2); + k[0] + r2 * (3.0 * k[1] + r2 * (5.0 * k[2] + r2 * 7.0 * k[3])) <= 0.0 + }); + (error <= MAX_WARP_ERROR && !folds).then_some(k) +} + +/// Gaussian elimination with partial pivoting for the small normal equations. +fn solve(mut a: [[f64; N]; N], mut b: [f64; N]) -> Option<[f64; N]> { + for col in 0..N { + let pivot = (col..N).max_by(|&i, &j| a[i][col].abs().total_cmp(&a[j][col].abs()))?; + if !a[pivot][col].is_finite() || a[pivot][col].abs() < 1e-12 { + return None; + } + a.swap(col, pivot); + b.swap(col, pivot); + for row in col + 1..N { + let factor = a[row][col] / a[col][col]; + for j in col..N { + a[row][j] -= factor * a[col][j]; + } + b[row] -= factor * b[col]; + } + } + let mut x = [0.0; N]; + for i in (0..N).rev() { + x[i] = (b[i] - (i + 1..N).map(|j| a[i][j] * x[j]).sum::()) / a[i][i]; + } + x.iter().all(|v| v.is_finite()).then_some(x) +} + +/// The warp (distortion with the frame-filling zoom) for a recorded image of `w × h` pixels. +fn warp(ifd: &Ifd, w: usize, h: usize) -> Option { + let half = (w as f64).hypot(h as f64) / 2.0; + let table = read(ifd, DISTORTION, half)?; + // Percent: anything beyond a third is not distortion; an all-zero table has nothing to correct. + if table.values.iter().any(|d| d.abs() > 30.0) || table.values.iter().all(|d| *d == 0.0) { + return None; + } + let (cw, ch) = (w as f64 - 1.0, h as f64 - 1.0); + let nearest = cw.min(ch) / cw.hypot(ch); + let g = Geometry { table: Some(&table), zoom: zoom(&table, nearest)? }; + let k = polynomial(|r| g.source(r))?; + // The recorded image is the active area and the tables are centred on it: DNG normalises by the farthest + // corner, which is the half diagonal used here. + Some(Opcode::WarpRectilinear { planes: vec![[k[0], k[1], k[2], k[3], 0.0, 0.0]], center: [0.5, 0.5] }) +} + +/// Radial illumination gain `100 / V` as DNG `FixVignetteRadial` (`1 + k0 r² + … + k4 r¹⁰`). +fn vignette(ifd: &Ifd, w: usize, h: usize) -> Option { + let half = (w as f64).hypot(h as f64) / 2.0; + let t = read(ifd, VIGNETTING, half)?; + 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)); + if (0..=SAMPLES).all(|j| (gain(j as f64 / SAMPLES as f64) - 1.0).abs() < 1e-4) { + return None; + } + let basis = |r: f64| { + let x = r * r; + [x, x * x, x.powi(3), x.powi(4), x.powi(5)] + }; + let (k, error) = minimax(basis, |r| gain(r) - 1.0)?; + // the relative error at the worst point (gain is at least 1 / 1.3 here) + if error > MAX_GAIN_ERROR || (0..=SAMPLES).any(|j| 1.0 + basis(j as f64 / SAMPLES as f64).iter().zip(k).map(|(a, b)| a * b).sum::() < 0.5) { + return None; + } + Some(Opcode::FixVignetteRadial { k, center: [0.5, 0.5] }) +} + +/// The lens corrections of a RAF whose camera `model` was validated (anything else, and any table that is not in +/// the understood layout and scale, is left uncorrected). `active` is the recorded image (the raw crop). +pub(super) fn corrections(model: &str, raw: &Ifd, active: Rect) -> Vec { + if !VALIDATED.contains(&model.trim()) || active.width < 2 || active.height < 2 { + return Vec::new(); + } + warp(raw, active.width, active.height).into_iter().chain(vignette(raw, active.width, active.height)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_tiff::{Entry, Value}; + + pub(crate) const KNOTS9: [f64; 9] = [0.3535211268, 0.5, 0.6126760563, 0.7070422535, 0.7908450704, 0.8661971831, 0.9352112676, 1.0, 1.06056338]; + /// X-T4 with the XF16-55 at 16 mm (3913): the tables as ExifTool prints them. + pub(crate) const XT4_DIST: [f64; 9] = + [-1.172515869, -2.354660034, -3.458114624, -4.434677124, -5.274719238, -5.918792725, -6.328369141, -6.426345825, -6.426345825]; + pub(crate) const XT4_VIG: [f64; 9] = + [98.46142578, 96.87792969, 94.90234375, 90.98144531, 84.73291016, 78.43701172, 72.96044922, 67.45703125, 67.45703125]; + pub(crate) const XT4_AREA: Rect = Rect { x: 0, y: 0, width: 6240, height: 4160 }; + + pub(crate) fn rationals(v: &[f64]) -> Value { + Value::SRational(v.iter().map(|x| ((x * 1.0e6).round() as i32, 1_000_000)).collect()) + } + pub(crate) fn table(scale: f64, knots: &[f64], values: &[f64]) -> Value { + let mut v = vec![scale]; + v.extend_from_slice(knots); + v.extend_from_slice(values); + rationals(&v) + } + pub(crate) fn xt4() -> Ifd { + let scale = 3749.0 / 9.0; + Ifd { + entries: vec![ + Entry { tag: DISTORTION, value: table(scale, &KNOTS9, &XT4_DIST), offset: 0 }, + Entry { tag: VIGNETTING, value: table(scale, &KNOTS9, &XT4_VIG), offset: 0 }, + ], + ..Default::default() + } + } + fn only(tag: u16, value: Value) -> Ifd { + Ifd { entries: vec![Entry { tag, value, offset: 0 }], ..Default::default() } + } + fn evaluate(k: &[f64; 6], r: f64) -> f64 { + r * (k[0] + r * r * (k[1] + r * r * (k[2] + r * r * k[3]))) + } + + #[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) + 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}"); + // 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); + } + + #[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 + // render of pixls 3913 at 600 px (maximum correlation 0.996 of the high-passed luminance), no table or zoom + // model: source = s · r · (1 + k1 r² + k2 r⁴ + k3 r⁶), r in half diagonals. + let measured = [1.0299688, -0.1024252, 0.0195257, 0.0183398]; + let ops = corrections("X-T4", &xt4(), XT4_AREA); + let Opcode::WarpRectilinear { planes, .. } = &ops[0] else { panic!() }; + for i in 2..=20 { + 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)); + } + } + + #[test] + fn only_validated_models_are_corrected() { + for model in ["", "X-T4 ", "X-T6", "X-Pro3", "FinePix S1", "FinePix F550EXR", "X10", "XF1", "XQ1", "GFX 100S", "x-t4"] { + let n = corrections(model, &xt4(), XT4_AREA).len(); + assert_eq!(n, usize::from(VALIDATED.contains(&model.trim())) * 2, "{model:?}"); + } + assert!(corrections("X-T4", &xt4(), Rect::new(0, 0, 1, 1)).is_empty()); + } + + #[test] + fn rejects_unknown_scale_layout_and_values() { + let scale = 3749.0 / 9.0; + let bad = |ifd: Ifd| corrections("X-T4", &ifd, XT4_AREA); + // another normalisation (X-S20 stores the 40 MP half diagonal), non-increasing knots, wrong lengths, garbage + assert!(bad(only(DISTORTION, table(4643.0 / 9.0, &KNOTS9, &XT4_DIST))).is_empty()); + let mut flat = KNOTS9; + flat[3] = flat[2]; + assert!(bad(only(DISTORTION, table(scale, &flat, &XT4_DIST))).is_empty()); + assert!(bad(only(DISTORTION, table(scale, &KNOTS9[..8], &XT4_DIST))).is_empty()); + assert!(bad(only(DISTORTION, table(scale, &KNOTS9, &[40.0; 9]))).is_empty()); + assert!(bad(only(DISTORTION, table(scale, &KNOTS9, &[f64::NAN; 9]))).is_empty()); + assert!(bad(only(DISTORTION, Value::Short(vec![19; 19]))).is_empty()); + assert!(bad(only(DISTORTION, table(0.0, &KNOTS9, &XT4_DIST))).is_empty()); + assert!(bad(only(VIGNETTING, table(scale, &KNOTS9, &[5.0; 9]))).is_empty()); + // flat tables (nothing to correct) produce nothing + let flat_tables = Ifd { + entries: vec![ + Entry { tag: DISTORTION, value: table(scale, &KNOTS9, &[0.0; 9]), offset: 0 }, + Entry { tag: VIGNETTING, value: table(scale, &KNOTS9, &[100.0; 9]), offset: 0 }, + ], + ..Default::default() + }; + assert!(bad(flat_tables).is_empty()); + assert!(bad(Ifd::default()).is_empty()); + // an unusable distortion table leaves the warp off but not the vignette + let mut ifd = xt4(); + ifd.entries[0].value = table(1.0, &KNOTS9, &XT4_DIST); + let ops = bad(ifd); + assert!(matches!(ops.as_slice(), [Opcode::FixVignetteRadial { .. }]), "{ops:?}"); + } + + #[test] + fn old_layout_with_eleven_knots_and_pincushion_zoom() { + // X-T2-style: 0 … 1 in tenths, 11 knots, scale = half diagonal / 11; a 3:2 6000 × 4000 frame. + let knots: Vec = (0..=10).map(|i| i as f64 / 10.0).collect(); + let dist = [0.0, 0.02, 0.05, 0.1, 0.17, 0.27, 0.39, 0.54, 0.69, 0.84, 0.98]; + let scale = 3605.0 / 11.0; + let ifd = only(DISTORTION, table(scale, &knots, &dist)); + let area = Rect::new(0, 0, 6000, 4000); + let ops = corrections("X-T2", &ifd, area); + let [Opcode::WarpRectilinear { planes, .. }] = ops.as_slice() else { panic!("{ops:?}") }; + assert_eq!(planes.len(), 1); + // pincushion bracket > 1 everywhere: the zoom shrinks the sampled radius; the corner samples exactly its edge + assert!(planes[0][0] < 1.0 && planes[0][0] > 0.98); + let nearest = 5999.0f64.min(3999.0) / 5999.0f64.hypot(3999.0); + let at_edge = evaluate(&planes[0], nearest) / nearest; + assert!((at_edge - planes[0][0]).abs() < 0.004); + } + + #[test] + fn a_vignette_table_works_without_a_distortion_table() { + let scale = 3749.0 / 9.0; + let v = corrections("X-T4", &only(VIGNETTING, table(scale, &KNOTS9, &XT4_VIG)), XT4_AREA); + assert!(matches!(v.as_slice(), [Opcode::FixVignetteRadial { .. }])); + let d = corrections("X-T4", &only(DISTORTION, table(scale, &KNOTS9, &XT4_DIST)), XT4_AREA); + assert!(matches!(d.as_slice(), [Opcode::WarpRectilinear { .. }])); + } +} diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 8649dd703..d60aa6588 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -931,3 +931,75 @@ fn sony_embedded_distortion_is_limited_to_validated_models() { } eprintln!("Sony distortion model boundary: {checked} corpus files checked"); } + +/// Fujifilm RAF lens tables (`0xf00b` distortion, `0xf010` vignetting) become a warp and a vignette gain for the +/// cameras whose geometry was checked against the camera JPEG (docs/fuji-lens-corrections.md). Reads `LIGHTCRAFT_FUJI` (a folder +/// of RAFs or of per-model folders, e.g. the CC0 pixls set) or the corpus' `raf-fuji-*` files; skips when absent. +#[test] +fn fuji_embedded_lens_tables_follow_the_validated_models() { + fn collect(dir: &Path, depth: usize, out: &mut Vec) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() && depth < 2 { + collect(&p, depth + 1, out); + } else if p.extension().is_some_and(|x| x.eq_ignore_ascii_case("raf")) { + out.push(p); + } + } + } + let mut files = Vec::new(); + match std::env::var_os("LIGHTCRAFT_FUJI") { + Some(dir) => collect(Path::new(&dir), 0, &mut files), + None => { + collect(&corpus_root().join("raw"), 1, &mut files); + files.retain(|p| p.file_name().is_some_and(|n| n.to_string_lossy().starts_with("raf-fuji-"))); + } + } + files.sort(); + // Bodies whose tables did not match the camera JPEG (or have no usable geometry sample): never corrected. + let unvalidated = ["FinePix F550EXR", "FinePix S1", "FinePix SL1000", "X10", "XF1", "XQ1"]; + let (mut warped, mut vignetted, mut checked) = (0, 0, 0); + for path in &files { + let Ok(bytes) = std::fs::read(path) else { continue }; + if lightcraft_raw::probe(&bytes) != Some(RawFormat::Raf) { + continue; + } + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let Ok(full) = decode(&bytes) else { continue }; + assert_eq!(probe_info(&bytes).unwrap(), full.info(), "{name}: header and full decode agree"); + let model = full.metadata.model.clone().unwrap_or_default(); + let ops = &full.opcodes.list3; + if unvalidated.contains(&model.as_str()) { + assert!(ops.is_empty(), "{name} ({model}) is not a validated model"); + } + for op in ops { + match op { + lightcraft_raw::Opcode::WarpRectilinear { planes, center } => { + assert_eq!(*center, [0.5, 0.5], "{name}"); + assert_eq!(planes.len(), 1, "{name}: lateral CA is not applied"); + // a sane radial mapping: close to 1 at the centre, monotone, never folding + let k = planes[0]; + assert!((0.85..1.15).contains(&k[0]), "{name}: {k:?}"); + let source = |r: f64| r * (k[0] + r * r * (k[1] + r * r * (k[2] + r * r * k[3]))); + assert!((0..100).all(|i| source((i + 1) as f64 / 100.0) > source(i as f64 / 100.0)), "{name}"); + warped += 1; + } + lightcraft_raw::Opcode::FixVignetteRadial { k, center } => { + assert_eq!(*center, [0.5, 0.5], "{name}"); + let gain = 1.0 + k.iter().enumerate().map(|(i, c)| c * 1.0f64.powi(2 * i as i32 + 2)).sum::(); + assert!((1.0..3.0).contains(&gain), "{name}: corner gain {gain}"); + vignetted += 1; + } + other => panic!("{name}: unexpected opcode {other:?}"), + } + } + eprintln!( + "{model}: {} opcode(s) {}", + ops.len(), + if ops.iter().any(|o| matches!(o, lightcraft_raw::Opcode::WarpRectilinear { .. })) { "warp" } else { "no warp" } + ); + checked += 1; + } + eprintln!("Fuji lens tables: {checked} RAFs, {warped} warps, {vignetted} vignette gains"); +} diff --git a/docs/fuji-lens-corrections.md b/docs/fuji-lens-corrections.md new file mode 100644 index 000000000..ca25e53a2 --- /dev/null +++ b/docs/fuji-lens-corrections.md @@ -0,0 +1,128 @@ +# Fujifilm embedded lens corrections + +LightCraft reads the distortion and vignetting tables that Fujifilm cameras store in the raw IFD of every RAF +(`0xf00b` GeometricDistortionParams, `0xf010` VignettingParams, signed rationals) and turns them into the existing +`OpcodeList3` pair, `WarpRectilinear` and `FixVignetteRadial`. The profile-correction control, the CPU / GPU optics +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. + +## Clean-room evidence + +The tag names come from the [ExifTool FujiFilm tag-name documentation](https://exiftool.org/TagNames/FujiFilm.html); +ExifTool was only run as a black box (`exiftool -j -n`, `-v3`) to print the arrays. Everything else was measured: +the 129 CC0 RAFs with these tables under `raw.pixls.us` (Fujifilm, 65 bodies) against the camera's own embedded JPEG, which the +cameras correct in-camera, and against LightCraft's uncorrected raw render. No other decoder's code or data, +lens database or Adobe profile was consulted. Scripts and per-file results: `data/testing/raf-lens/` (local). + +## Layout and units + +Every table is an array of signed rationals with `n` knots: + +``` +[scale, knot_1 … knot_n, value_1 … value_n] +``` + +- `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. +- 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, + the displacement of the red and blue planes relative to green). + +## Geometry + +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) +``` + +The table is indexed by the **source** (sensor) radius, not the output radius, and the zoom `z` keeps the output +rectangle inside the source (as for the Sony table, the bracket is largest at the nearest border radius). Source +radius is solved per radius by fixed-point iteration, then `source(r) = r (k0 + k1 r² + k2 r⁴ + k3 r⁶)` is fitted +(minimax, 1025 samples) for the DNG warp; a fit that strays more than 0.0008 half diagonals (largest over the 118 sample +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²` +(`FixVignetteRadial`); a fit more than 0.025 off the table is rejected. + +## Validation + +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 +radius, (d) the table, lookup at the source radius with the computed zoom (adopted), (e) a free three-term radial +polynomial (the upper bound). 67 body/lens/focal-length combinations from 65 bodies, 26 named lenses and the fixed-lens cameras (129 files). + +| Warp | median NCC | mean NCC | +|---|---:|---:| +| similarity only (nothing corrected) | 0.941 | 0.865 | +| table, opposite sign | 0.493 | 0.530 | +| table, lookup at the output radius | 0.989 | 0.869 | +| **table, lookup at the source radius, computed zoom (adopted)** | **0.991** | 0.872 | +| free radial polynomial | 0.994 | 0.985 | + +The sign is determined (the 48 combinations with more than 1 % distortion reach a median of 0.29, at most 0.91, with the wrong sign), the +source-radius lookup is slightly better where distortion is large (GFX100RF 0.985 vs 0.971, X-S20 0.989 vs 0.971, X-S1 +0.977 vs 0.961). The table reaches the free fit's correlation within 0.02 for 51 of 67 combinations including the +computed zoom, and for 60 of 67 when the zoom is free (shape right). The zoom `z` agrees with the free fit's scale +within 0.0047 on all 51. + +**Where the framing differs:** on five corrected combinations the camera did not zoom (XC16–50 at 16 mm on the X-A10, +XC15–45 on the X-A5 and X-T100, the F770EXR and HS50EXR) and on two more it zoomed about 1 % differently (X-T30 III, XQ2): +the shape matches but the JPEG shows 4 % more of the sensor than the computed zoom does. The same body and table +zoom on other lenses (the X-A1 and X-A2 with the XC16–50), so the rule is not in the table. LightCraft uses the computed zoom +everywhere (no empty border, as for the Sony table). Six bodies whose shape did not match well enough (F550EXR, S1, +SL1000, X10, XF1, XQ1) are not corrected. + +**Through the shipped code path** (`lightcraft-cli render` of the same 67 combinations with the embedded +correction applied, then the free fit of the corrected render against the camera JPEG, global scale removed so +the framing difference does not count): the residual radial shape error falls from a median of +0.72 % of the half diagonal (largest 2.60 %) to a median of 0.05 % (largest 0.42 %), 54 of the 57 corrected combinations land within 0.25 % +(2 px at 1440 px), and the similarity-only correlation rises from a median of 0.957 to 0.993. Per-lens rows are in +`data/testing/raf-lens/out/after_table.md`. + +### Vignetting + +Brightness of the camera JPEG against the aligned uncorrected render, in linear light, modelled per file as +`log Y_jpeg = poly3(log Y_render) + β(radius bin)` (the polynomial absorbs the tone curve): the JPEG's gain β against +`-ln(V/100)` of the table over 42 combinations whose corner gain exceeds 5 %: correlation median 0.987 (37 of 42 above +0.9), proportionality factor 0.98 (interquartile 0.73–1.31), against the tone curve's own slope of about 1.05; flat +tables (X-M5, X-T2, X-T3) give a gain of zero in the JPEG. Through the shipped code path the mean |β| over the outer three radius bins of the corrected render falls from a median of +0.184 to 0.031 (log units; largest 0.81 to 0.15) on the 38 corrected combinations with a corner gain above 5 %, and the corner +β 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. + +### Chromatic aberration (not applied) + +The CA table is real: on the raw mosaics themselves (uncompressed RAFs, colour planes built by normalised convolution so +no demosaic is involved) the radial shift of the red and blue sites against green correlates with the table over 46 +curves from 30 files at a median 0.93 (31 above 0.8), with the table's sign, at about 0.7 of its amplitude (the same +estimator reads 1.0–1.15 on a synthetic mosaic with the table applied). The camera JPEGs show no measurable red/blue +shift, but 4:2:0 chroma shares the luminance edges, so that proves nothing. + +LightCraft's own demosaic, however, already registers the colour planes: its render shows red/blue shifts of only +0.05–0.2 px where the table has 1.5–5 px. Applying the table as per-channel warp planes (tried; the opcode supports +them) shifted the planes the wrong way: the median measured shift rose from 0.10 to 0.13 px and grew by more than 1.5× +on 11 of 28 curves (X-T4 blue 0.16 → 0.58 px, X-T3 red 0.06 → 0.59 px). The table is therefore not applied; a correction +before demosaicing would be needed. + +## Tests + +- `crates/raw/src/vendor/raf_lens.rs`: layout, normalisation, the model list, malformed tables, the zoom and the + vignette gain on the X-T4's real arrays, and the X-T4 geometry against its measured free fit. +- `crates/raw/src/vendor/raf.rs`: a synthetic RAF through header and full decode, only for validated models, and a DNG + round trip. +- `crates/raw/tests/corpus.rs` `fuji_embedded_lens_tables_follow_the_validated_models`: the corpus' `raf-fuji-*` files, + or any folder of RAFs in `LIGHTCRAFT_FUJI` (the pixls set: 129 decoded, 88 warps, 98 vignette gains). diff --git a/docs/parity.md b/docs/parity.md index d0bdc35c2..6456552a5 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -77,8 +77,9 @@ Take the first one nobody is working on. (Before/After has them); check sharpness and the wide-stage margin on real raw photos (only synthetic scenes so far). 5. **LR-EDIT-OPTICS-PROFILE** (P1): a lens-profile database of our own (corrections embedded in DNG files and the distortion correction Panasonic / Leica cameras record in RW2 / RWL files work today; issue #256). Sony ILCE-7RM4A Bayer - ARW 16-sample tables in native 3:2 framing now feed the same distortion path; broader coverage and Sony - vignetting / lateral CA remain (see `docs/sony-lens-corrections.md`). + ARW 16-sample tables in native 3:2 framing now feed the same distortion path, and the distortion and vignetting tables + of Fujifilm RAFs from 59 validated bodies do too (`docs/fuji-lens-corrections.md`); broader coverage, Sony + vignetting and lateral CA (Fujifilm's table over-corrects after our demosaic) remain. 6. **AI masks and Enhance** (LR-MASK-SUBJECT / SKY / PEOPLE / OBJECTS, LR-EDIT-DETAIL-DENOISE, SUPERRES, LENSBLUR): Object and Describe masks run SAM 3 in pure Rust (`crates/segment`); the weights (SAM License) are never bundled: the app offers a consented, verified download, but **LightCraft's CDN mirrors are not configured yet** (the @@ -276,7 +277,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `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; other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); vignetting and lateral CA data of other raw formats (Olympus, Fujifilm, Sony) not read yet; 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); no lens-profile database | +| 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` / `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; other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); 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 and Sony raws not read yet; 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); no lens-profile database | ) | 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/whats-new.md b/docs/whats-new.md index fb1fd3165..d98e86fee 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,12 @@ ## October 2026 +### Fujifilm lens corrections +- Fujifilm RAFs from 59 camera models now get the lens correction the camera stores in the file: distortion (with the + camera's own zoom to fill the frame) and vignetting, as with Panasonic and Sony files. The Optics profile controls + adjust or switch them off, and DNG export keeps them. Lateral chromatic aberration data is not applied: our + demosaic already lines the colour planes up. Details and measurements: `docs/fuji-lens-corrections.md`. + ### Activity stack - Long-running tasks show in one place, top left under the top bar, as in Lightroom Classic (issue #345): imports and folder scans, exports (and the contact sheet PDF), Synchronize Folder, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan,