mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
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:
Vendored
+20
-14
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (🟡) |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user