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 14f75ddaf..371c239ef 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 16c688f5c..63ccd5bc6 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