mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
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:
+3
-2
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
Vendored
+1
@@ -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;
|
||||
|
||||
Vendored
+97
-15
@@ -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, &[]);
|
||||
|
||||
Vendored
+452
@@ -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 { .. }]));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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` | |
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user