Merge PR #2444: LR-EDIT-OPTICS-PROFILE: read Fujifilm's nine-knot lens tables at the plain radius

crates/engine/src/media.rs: RENDER_CACHE_VERSION keeps the higher value.
This commit is contained in:
aiongg
2026-10-22 02:40:15 +00:00
4 changed files with 78 additions and 25 deletions
+20 -14
View File
@@ -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<Opcode> {
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::<f64>();
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));
}
}
+47 -10
View File
@@ -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
+2 -1
View File
@@ -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 `<file>.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 (🟡) |
+9
View File
@@ -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