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.
This commit is contained in:
radialmonster
2026-10-10 08:38:41 -04:00
parent b70ae1741e
commit 943448e7c9
9 changed files with 764 additions and 21 deletions
+3 -2
View File
@@ -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
+1 -1
View File
@@ -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];
+1
View File
@@ -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;
+97 -15
View File
@@ -92,6 +92,13 @@ fn raw_ifd(raw: &[u8]) -> Result<Ifd> {
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<RawImage> {
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<u8>, layout: Option<[u8; 36]>, jpeg: &[u8], words32: u32) -> Vec<u8> {
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<u8>,
layout: Option<[u8; 36]>,
jpeg: &[u8],
words32: u32,
model: &str,
extra: &[(u16, Vec<(i32, i32)>)],
) -> Vec<u8> {
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<u8> = 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, &[]);
+452
View File
@@ -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<f64>,
values: Vec<f64>,
}
/// 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<Table> {
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>) -> 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<f64> {
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<const N: usize>(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<f64> = rows.iter().map(|(terms, y)| (terms.iter().zip(c).map(|(a, b)| a * b).sum::<f64>() - 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::<f64>() / 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<const N: usize>(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::<f64>()) / 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<Opcode> {
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<Opcode> {
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::<f64>() < 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<Opcode> {
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::<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);
}
#[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<f64> = (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 { .. }]));
}
}
+72
View File
@@ -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<PathBuf>) {
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::<f64>();
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");
}
+128
View File
@@ -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).
+4 -3
View File
@@ -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` | |
+6
View File
@@ -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,