From 17a68863f6baf023f778cb3b254c3cfd826cc460 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Sat, 10 Oct 2026 11:55:03 +0200 Subject: [PATCH 1/6] LR-EDIT-OPTICS-PROFILE: share the radial warp fit between RW2 and Sony Move the forward-map inversion, least-squares fit and solve4 out of the RW2 decoder into vendor::warp_from_forward_map; RW2 calls it in DNG-normalised units. Co-Authored-By: Claude --- crates/raw/src/vendor/mod.rs | 121 +++++++++++++++++++++++++++++++++++ crates/raw/src/vendor/rw2.rs | 83 +----------------------- 2 files changed, 124 insertions(+), 80 deletions(-) diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index 0d59fd6ed..e5218f390 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -111,6 +111,99 @@ pub(crate) fn white_from_data(data: &[u16], bits: u32) -> f32 { } } +/// Output radii sampled to fit the inverse mapping. +const DISTORTION_SAMPLES: usize = 64; + +/// Least-squares radial warp from a camera's forward distortion map. +/// +/// `corrected` maps a source radius to the output radius it lands at; it must rise monotonically up to `t_max`, the +/// output radius of the image corners. Radii use the caller's unit; pass `WarpRectilinear`'s normalisation (distance +/// from the centre to the **farthest** corner = 1) so the result is directly a `WarpRectilinear` plane. The map is +/// scanned at 4096 points up to a source radius of `3 * t_max`: it must reach `t_max` within that range and rise at +/// every scan point (monotonicity is checked only there), otherwise `None`; a NaN at a scan point is rejected the +/// same way, other non-finite values are caught by the fit-error gate. The map is inverted by bisection and +/// `source = u·(k0 + k1u² + k2u⁴ + k3u⁶)` is fitted over output radii `u·t_max`, `u` in [0, 1]: the returned +/// `[k0, k1, k2, k3]` give the source radius in the caller's unit as a function of `u`. `None` too when the worst +/// fit error exceeds `max_fit_error`. +pub(crate) fn warp_from_forward_map(corrected: &dyn Fn(f64) -> f64, t_max: f64, max_fit_error: f64) -> Option<[f64; 4]> { + if !(t_max.is_finite() && t_max > 0.0) { + return None; + } + let mut top = None; + let mut prev = 0.0; + for i in 1..=4096 { + let r = 3.0 * t_max * i as f64 / 4096.0; + let v = corrected(r); + if v.is_nan() || v <= prev { + return None; + } + prev = v; + if v >= t_max { + top = Some(r); + break; + } + } + let top = top?; + let source = |t: f64| { + let (mut lo, mut hi) = (0.0, top); + for _ in 0..60 { + let m = 0.5 * (lo + hi); + if corrected(m) < t { lo = m } else { hi = m } + } + 0.5 * (lo + hi) + }; + let samples: Vec<(f64, f64)> = (0..=DISTORTION_SAMPLES) + .map(|i| { + let u = i as f64 / DISTORTION_SAMPLES as f64; + (u, source(u * t_max)) + }) + .collect(); + let basis = |u: f64| { + let u2 = u * u; + [u, u * u2, u * u2 * u2, u * u2 * u2 * u2] + }; + let mut a = [[0.0; 4]; 4]; + let mut rhs = [0.0; 4]; + for &(u, v) in &samples { + let f = basis(u); + for (i, fi) in f.iter().enumerate() { + for (j, fj) in f.iter().enumerate() { + a[i][j] += fi * fj; + } + rhs[i] += fi * v; + } + } + let k = solve4(a, rhs)?; + let fit = |u: f64| basis(u).iter().zip(&k).map(|(f, c)| f * c).sum::(); + let worst = samples.iter().map(|&(u, v)| (fit(u) - v).abs()).fold(0.0, f64::max); + (worst <= max_fit_error).then_some(k) +} + +/// Solve the 4 × 4 system `a·x = b` (Gaussian elimination with partial pivoting); `None` when singular or not finite. +fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> { + for c in 0..4 { + let p = (c..4).max_by(|&i, &j| a[i][c].abs().total_cmp(&a[j][c].abs()))?; + if a[p][c].is_nan() || a[p][c].abs() <= 1e-300 { + return None; + } + a.swap(c, p); + b.swap(c, p); + for r in c + 1..4 { + let f = a[r][c] / a[c][c]; + for k in c..4 { + a[r][k] -= f * a[c][k]; + } + b[r] -= f * b[c]; + } + } + let mut x = [0.0; 4]; + for c in (0..4).rev() { + let s: f64 = (c + 1..4).map(|k| a[c][k] * x[k]).sum(); + x[c] = (b[c] - s) / a[c][c]; + } + x.iter().all(|v| v.is_finite()).then_some(x) +} + #[cfg(test)] mod tests { use super::*; @@ -160,4 +253,32 @@ mod tests { let w = white_from_data(&d, 14); assert!((13500.0..=13583.0).contains(&w), "{w}"); } + + #[test] + fn warp_from_forward_map_recovers_identity_and_rejects_folds() { + let k = warp_from_forward_map(&|r| r, 1.0, 1e-9).unwrap(); + assert!((k[0] - 1.0).abs() < 1e-9 && k[1].abs() < 1e-9 && k[2].abs() < 1e-9 && k[3].abs() < 1e-9); + let barrel = |r: f64| r * (1.0 + 0.05 * r * r); // mild barrel: source < output radius + let t_max = 0.9; + let k = warp_from_forward_map(&barrel, t_max, 1e-3).unwrap(); + assert!(k[0] < t_max && k[0] > 0.8 && k[1] < 0.0); // source radius at u = 1 is below t_max + for i in 0..=20 { + let u = i as f64 / 20.0; + let s = u * (k[0] + u * u * (k[1] + u * u * (k[2] + u * u * k[3]))); + assert!((barrel(s) - u * t_max).abs() < 1e-5, "u {u}"); + } + assert!(warp_from_forward_map(&|r| if r < 0.5 { r } else { 0.5 - (r - 0.5) }, 1.0, 1e-3).is_none()); // fold, never reaches t_max + // a tiny dip (two scan points wide, 1e-4 deep) that still reaches t_max and fits well: only the monotonicity check rejects it + let dip = |r: f64| { + if r < 0.5 { + r + } else if r < 0.5015 { + 0.5 - 0.05 * (r - 0.5) + } else { + r - 0.001575 + } + }; + assert!(warp_from_forward_map(&dip, 1.0, 1e-2).is_none()); + assert!(warp_from_forward_map(&|_| f64::NAN, 1.0, 1e-3).is_none()); + } } diff --git a/crates/raw/src/vendor/rw2.rs b/crates/raw/src/vendor/rw2.rs index b856fbd52..62a8f1130 100644 --- a/crates/raw/src/vendor/rw2.rs +++ b/crates/raw/src/vendor/rw2.rs @@ -145,8 +145,6 @@ const FORMAT8_FIXED: [(u16, &[u16]); 9] = [ const DISTORTION_INFO: u16 = 0x0119; /// Fixed-point one of the distortion coefficients and zoom. const DISTORTION_ONE: f64 = 32768.0; -/// Output radii sampled to fit the inverse mapping. -const DISTORTION_SAMPLES: usize = 64; /// Largest error of the fitted inverse (relative to the half diagonal) accepted: real lenses fit within 1.2e-4. const DISTORTION_MAX_FIT_ERROR: f64 = 1e-3; /// Most strips seen in a file is 3 (the 12008 × 8008 high-resolution mode). @@ -650,87 +648,12 @@ fn distortion(ifd: &Ifd, active: Rect) -> Option { let r2 = r * r; r * (1.0 + r2 * (k2 + r2 * (k4 + r2 * k6))) }; - // the output corners show corrected radius `t_max`; the map must rise monotonically to it (a fold is no lens) - let t_max = half_diag * zoom / radius; - let mut top = None; - let mut prev = 0.0; - for i in 1..=4096 { - let r = 3.0 * t_max * i as f64 / 4096.0; - let v = corrected(r); - if v.is_nan() || v <= prev { - return None; - } - prev = v; - if v >= t_max { - top = Some(r); - break; - } - } - let top = top?; - let source = |t: f64| { - let (mut lo, mut hi) = (0.0, top); - for _ in 0..60 { - let m = 0.5 * (lo + hi); - if corrected(m) < t { lo = m } else { hi = m } - } - 0.5 * (lo + hi) - }; - // least-squares fit of source radius = u·(kr0 + kr1·u² + kr2·u⁴ + kr3·u⁶) over the output radii u ∈ [0, 1] - let samples: Vec<(f64, f64)> = (0..=DISTORTION_SAMPLES) - .map(|i| { - let u = i as f64 / DISTORTION_SAMPLES as f64; - (u, source(u * t_max) * radius / half_diag) - }) - .collect(); - let basis = |u: f64| { - let u2 = u * u; - [u, u * u2, u * u2 * u2, u * u2 * u2 * u2] - }; - let mut a = [[0.0; 4]; 4]; - let mut rhs = [0.0; 4]; - for &(u, v) in &samples { - let f = basis(u); - for (i, fi) in f.iter().enumerate() { - for (j, fj) in f.iter().enumerate() { - a[i][j] += fi * fj; - } - rhs[i] += fi * v; - } - } - let k = solve4(a, rhs)?; - let fit = |u: f64| basis(u).iter().zip(&k).map(|(f, c)| f * c).sum::(); - let worst = samples.iter().map(|&(u, v)| (fit(u) - v).abs()).fold(0.0, f64::max); - if worst.is_nan() || worst > DISTORTION_MAX_FIT_ERROR { - return None; - } + // work in DNG-normalised units (corner distance = 1), where the output corners sit at radius `zoom` + let scale = radius / half_diag; + let k = super::warp_from_forward_map(&|s| corrected(s / scale) * scale, zoom, DISTORTION_MAX_FIT_ERROR)?; Some(Opcode::WarpRectilinear { planes: vec![[k[0], k[1], k[2], k[3], 0.0, 0.0]], center: [0.5, 0.5] }) } -/// Solve the 4 × 4 system `a·x = b` (Gaussian elimination with partial pivoting); `None` when singular or not finite. -fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> { - for c in 0..4 { - let p = (c..4).max_by(|&i, &j| a[i][c].abs().total_cmp(&a[j][c].abs()))?; - if a[p][c].is_nan() || a[p][c].abs() <= 1e-300 { - return None; - } - a.swap(c, p); - b.swap(c, p); - for r in c + 1..4 { - let f = a[r][c] / a[c][c]; - for k in c..4 { - a[r][k] -= f * a[c][k]; - } - b[r] -= f * b[c]; - } - } - let mut x = [0.0; 4]; - for c in (0..4).rev() { - let s: f64 = (c + 1..4).map(|k| a[c][k] * x[k]).sum(); - x[c] = (b[c] - s) / a[c][c]; - } - x.iter().all(|v| v.is_finite()).then_some(x) -} - #[cfg(test)] mod tests { use super::*; From 85cc4273be9d62ba486a2d643b668660f06ee2bf Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Sat, 10 Oct 2026 22:29:41 +0200 Subject: [PATCH 2/6] LR-EDIT-OPTICS-PROFILE: Sony ARW distortion correction from the embedded table New vendor::arw_lens_rm6 reads the raw IFD's lens tables (0x7037 distortion, 0x7032 vignetting, 0x7035 lateral CA, their flags) and turns the distortion table into a one-plane WarpRectilinear in OpcodeList3, with the conventions measured on 54 ILCE-7RM6 photos: knots at i/15 of the full-frame crop half diagonal, S(r) = r (1 + v(r)/15000), frame-filling zoom, centred on the default crop. Only the ILCE-7RM6 is enabled; vignetting is read, not applied. The radial least-squares fit is split out as vendor::fit_radial_warp (RW2 results unchanged); its error gate can be limited to the radii the crop shows, so a padded active area (lossless files) doesn't refuse a fit over pixels that are never rendered. It sits next to main's vendor::arw_lens (#498), which holds the ILCE-7RM4A conventions measured against Sony's exports (unit 16384, table indexed at the output radius); the two are selected by camera model (disjoint allowlists). arw::decode chains this module's opcodes after arw_lens::distortion, with the same active area and the same crop (the DNG-style default crop when the file has one, which on the ILCE-7RM6 equals Sony's crop tags), and like it only for CFA data (not the downsized linear-RGB lossless sizes, where the knot spacing in full-frame pixels would not hold). The corpus test checks the conversion on the two lossless RawDB files (CC0). The APS-C one is pinned in xtask RAW_SAMPLES with its sha256; the full-frame one is not (at most two pins per PR, and #625 pins two Compressed ARW 6.0 files), so its case runs only where the file is present. RENDER_CACHE_VERSION 62 -> 63. Co-Authored-By: Claude --- crates/engine/src/media.rs | 2 +- crates/raw/src/opcodes.rs | 2 +- crates/raw/src/vendor/arw.rs | 43 ++- crates/raw/src/vendor/arw_lens_rm6.rs | 371 ++++++++++++++++++++++++++ crates/raw/src/vendor/mod.rs | 40 ++- crates/raw/tests/corpus.rs | 63 +++++ xtask/src/main.rs | 7 + 7 files changed, 516 insertions(+), 12 deletions(-) create mode 100644 crates/raw/src/vendor/arw_lens_rm6.rs diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index cf17c3264..5755a2a43 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 62; +pub const RENDER_CACHE_VERSION: u64 = 63; /// 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]; diff --git a/crates/raw/src/opcodes.rs b/crates/raw/src/opcodes.rs index 1d3a2c546..483ac5c4a 100644 --- a/crates/raw/src/opcodes.rs +++ b/crates/raw/src/opcodes.rs @@ -454,7 +454,7 @@ fn gain_at(points_v: u32, points_h: u32, spacing: [f64; 2], origin: [f64; 2], ma } /// Radius normaliser: distance from the centre to the farthest image corner. -fn max_radius(cx: f64, cy: f64, w: f64, h: f64) -> f64 { +pub(crate) fn max_radius(cx: f64, cy: f64, w: f64, h: f64) -> f64 { [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|&(x, y)| (x - cx).hypot(y - cy)).fold(0.0, f64::max).max(1e-9) } diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index 59a9cf59e..5a23218c0 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -2,7 +2,8 @@ //! //! Sources: H. Dietz, "Sony ARW2 Compression: Artifacts And Credible Repair" (IS&T Electronic Imaging 2016) for //! the ARW2 ("cRAW") scheme, and the ExifTool Sony tag-name documentation for the meaning of the raw-IFD tags -//! (`0x7010` tone-curve thresholds, `0x7310` black levels, `0x7313` WB levels, `0x74c7/0x74c8` crop). +//! (`0x7010` tone-curve thresholds, `0x7310` black levels, `0x7313` WB levels, `0x74c7/0x74c8` crop, `0x7031`–`0x7037` +//! lens-correction tables: see [`super::arw_lens`] for the ILCE-7RM4A and [`super::arw_lens_rm6`] for the ILCE-7RM6). //! //! ARW2, as described in the paper: //! 1. Sensor values are tone-mapped to 11-bit codes by a five-segment piecewise-linear curve whose step doubles at @@ -770,7 +771,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { // The distortion table is centred on the DNG-style default crop when the file has one: on the ILCE-7RM4A // it starts at x = 32 where Sony's crop tags (the image crop, unchanged) start at 0, and the warp was // validated against Sony's exports with that centre. The opcode's centre is in active-area coordinates, so - // it stays on the optical centre whichever crop frames the image. + // it stays on the optical centre whichever crop frames the image. The ILCE-7RM6's tables follow other + // conventions, measured against its camera JPEGs ([`super::arw_lens_rm6`]); the two allowlists are + // disjoint, so at most one of them produces an opcode. opcodes: OpcodeLists { // DefaultCrop can retain native geometry while Sony's tags select an aspect crop. // Both the image framing and the table geometry must stay within the validated 3:2 scope. @@ -778,7 +781,11 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { Vec::new() } else { let geometry = dng_default_crop(raw, w, h).unwrap_or(crop); - super::arw_lens::distortion(&model, raw, Rect::new(0, 0, w, h), geometry).into_iter().collect() + let active = Rect::new(0, 0, w, h); + super::arw_lens::distortion(&model, raw, active, geometry) + .into_iter() + .chain(super::arw_lens_rm6::lens_opcodes(raw, &model, active, geometry)) + .collect() }, ..Default::default() }, @@ -867,6 +874,36 @@ mod tests { TiffWriter::default().write(&[raw]).unwrap() } + /// The ILCE-7RM6's distortion table reaches `OpcodeList3`, in the headers-only probe and in the full decode; + /// another body's table doesn't (see [`super::super::arw_lens_rm6`]). + #[test] + fn lens_tables_reach_opcode_list3() { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + let file = |model: &str| { + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii(model.into())); + // 3:2, the aspect the opcode is limited to + raw.set(t::IMAGE_WIDTH, Value::Long(vec![96])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![64])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![32767])); + raw.set(TONE_CURVE, Value::Short(vec![8000, 10400, 12900, 14100])); + let table = vec![16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479]; + raw.set(super::super::arw_lens_rm6::DISTORTION_PARAMS, Value::SShort(table)); + raw.set_image(ImageData::Strips { rows_per_strip: 64, strips: vec![vec![0u8; 96 * 64]] }); + TiffWriter::default().write(&[raw]).unwrap() + }; + for mode in [Mode::Header, Mode::Full] { + let list3 = |model| decode(&file(model), mode).unwrap().opcodes.list3; + let ops = list3("ILCE-7RM6"); + assert!(matches!(ops.as_slice(), [crate::Opcode::WarpRectilinear { planes, .. }] if planes.len() == 1), "{mode:?}: {ops:?}"); + assert!(list3("ILCE-7M4").is_empty(), "{mode:?}"); + } + } + #[test] fn strip_larger_or_smaller_than_one_byte_per_pixel_is_the_packed_variant() { for strip in [32 * 4 + 8, 32 * 4 * 5 / 4 - 1, 32 * 4 - 1] { diff --git a/crates/raw/src/vendor/arw_lens_rm6.rs b/crates/raw/src/vendor/arw_lens_rm6.rs new file mode 100644 index 000000000..ba9aaaa9d --- /dev/null +++ b/crates/raw/src/vendor/arw_lens_rm6.rs @@ -0,0 +1,371 @@ +//! Sony ARW lens corrections from the tables in the raw IFD, as the equivalent DNG `OpcodeList3` opcodes, so they +//! reach the same "profile corrections" (`optics.lens_profile`) as a DNG's embedded warp. +//! +//! These are the ILCE-7RM6 conventions, measured against the camera JPEG. [`super::arw_lens`] holds the ILCE-7RM4A +//! conventions, measured against Sony's Imaging Edge exports (unit 16384, the table indexed at the output radius). +//! Each is selected by camera model; no single unit fits every lens under either. See both docs: +//! docs/sony-ilce7rm6-lens-corrections.md (→ Two conventions) and docs/sony-lens-corrections.md. +//! +//! Sony records three count-prefixed signed 16-bit tables in the raw IFD, each with an in-camera on/off tag (ExifTool +//! Sony tag names): distortion [`DISTORTION_PARAMS`] (flag [`DISTORTION_FLAG`]), vignetting [`VIGNETTING_PARAMS`] +//! ([`VIGNETTING_FLAG`]) and lateral chromatic aberration [`CA_PARAMS`] ([`CA_FLAG`]). The first value is the knot +//! count: 16 / 16 / 32 in full-frame files of the ILCE-7RM6, 11 / 11 / 22 in its APS-C crop mode, the rest padding; +//! the CA table holds red's knots, then blue's. +//! +//! Their meaning was measured by registering blocks of the camera JPEG in each file on LightCraft's uncorrected render +//! (and raw data, for CA) over 54 ILCE-7RM6 photos with four lenses, 41 of them shot with in-camera distortion +//! correction on; nothing else (clean room). The conventions, with their worst residuals at a 1440 px long edge: +//! - knot `i` sits at sensor radius `i · s` from the default-crop centre, `s` = the body's full-frame default-crop +//! half diagonal / 15 (the same pixel spacing in APS-C mode: the knots are fixed on the sensor); linear in between, +//! the last segment extended beyond the last knot. +//! - distortion: a corrected radius `r` (px) reads the source radius `S(r) = r · (1 + v(r) / 15000)` (positive: +//! pincushion), and the camera then zooms (output radius = `z` · corrected radius) so the output fills the default +//! crop and reads only inside it: the largest `S(ρ/z)/ρ` over the crop border radii `ρ` is 1. 0.596 px over the 34 +//! measurable corrected photos (budget 0.75 px; corners and edge midpoints 0.570 px). +//! - `WarpRectilinear`'s 4-term odd polynomial fits that map within 2.35·10⁻⁴ of the DNG radius (0.220 px) on every +//! photo, at its 65 fit samples. A table it fits worse than 0.25 px at the samples the crop shows is refused (the +//! photo stays uncorrected rather than wrongly warped). Between samples the error is somewhat larger: 0.071 against +//! 0.049 px for the full-frame 16–35 mm example in the tests. +//! - vignetting: read but never applied. The camera JPEGs keep the uncorrected render's radial falloff (median +//! corner/centre ratio 0.996 over 39 photos) and no radial gain model fits the tables. +//! - lateral CA: red and blue relative to green on the same knots (unit about 1/3.36·10⁶; 0.399 px, borderline); +//! not applied yet. +//! +//! The correction is produced whatever the in-camera flag says. Only bodies listed in [`FULL_FRAME_CROP`] get it: the +//! unit and the knot spacing are verified on the ILCE-7RM6 alone. Another body is added by repeating the measurement +//! on its photos shot with distortion correction on and listing its full-frame default crop. The method and the +//! numbers: docs/sony-ilce7rm6-lens-corrections.md. + +use super::fit_radial_warp; +use crate::opcodes::max_radius; +use crate::{Opcode, Rect}; +use lightcraft_tiff::Ifd; +use std::ops::RangeInclusive; + +pub(crate) const DISTORTION_PARAMS: u16 = 0x7037; +pub(crate) const VIGNETTING_PARAMS: u16 = 0x7032; +pub(crate) const CA_PARAMS: u16 = 0x7035; +pub(crate) const DISTORTION_FLAG: u16 = 0x7036; +pub(crate) const VIGNETTING_FLAG: u16 = 0x7031; +pub(crate) const CA_FLAG: u16 = 0x7034; + +/// The bodies whose tables are verified (Exif `Model`), with their full-frame default crop (width, height) in px. +const FULL_FRAME_CROP: &[(&str, (f64, f64))] = &[("ILCE-7RM6", (9984.0, 6656.0))]; +/// The distortion table's value of 1 (relative radius); a global least-squares fit over the 34 photos gives 15000.7. +const DISTORTION_UNIT: f64 = 15000.0; +/// Crop border radii scanned by the frame-filling zoom. +const ZOOM_SCAN: usize = 4096; +/// Largest polynomial fit error accepted at the fit samples the crop shows, px at a 1440 px long edge. +const FIT_ERROR_PX_AT_1440: f64 = 0.25; + +/// A file's lens tables: the first `count` values of each (CA: red, blue); `None` when absent or malformed. +/// `vignetting` and `flags` are parsed for the tests and the docs only: don't build an opcode from them (the camera +/// applies no vignetting correction to its JPEGs, and the flags don't decide anything). +#[derive(Debug)] +// only the distortion table becomes an opcode so far +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) struct LensTables { + pub distortion: Option>, + pub vignetting: Option>, + pub ca: Option<(Vec, Vec)>, + /// In-camera on/off of distortion, vignetting and CA correction, as recorded. + pub flags: [Option; 3], +} + +/// The values of a count-prefixed integer table whose count is in `counts` and fits the array. +fn table(raw: &Ifd, tag: u16, counts: RangeInclusive) -> Option> { + let v = raw.value(tag)?; + let count = usize::try_from(v.get_i64(0)?).ok().filter(|n| counts.contains(n) && *n < v.count())?; + (1..=count).map(|i| v.get_i64(i).map(|x| x as f64)).collect() +} + +/// The lens tables of the raw IFD `raw`. +pub(crate) fn read_tables(raw: &Ifd) -> LensTables { + let ca = table(raw, CA_PARAMS, 4..=32).filter(|v| v.len().is_multiple_of(2)).map(|v| { + let (red, blue) = v.split_at(v.len() / 2); + (red.to_vec(), blue.to_vec()) + }); + LensTables { + distortion: table(raw, DISTORTION_PARAMS, 2..=16), + vignetting: table(raw, VIGNETTING_PARAMS, 2..=16), + ca, + flags: [raw.i64(DISTORTION_FLAG), raw.i64(VIGNETTING_FLAG), raw.i64(CA_FLAG)], + } +} + +/// The knot spacing (px) of a verified body: its full-frame default-crop half diagonal / 15. +fn knot_spacing(model: &str) -> Option { + let model = model.trim(); + FULL_FRAME_CROP.iter().find(|(m, _)| *m == model).map(|&(_, (w, h))| w.hypot(h) / 30.0) +} + +/// The knot table at radius `r` px: knot `i` at `i · spacing`, linear in between, the last segment extended beyond +/// the last knot. NaN with fewer than two knots. +fn interpolate(knots: &[f64], spacing: f64, r: f64) -> f64 { + let t = r / spacing; + // `as` saturates, so a huge `t` lands on the last segment; NaN fails `t > 0` + let i = if t > 0.0 { (t as usize).min(knots.len().saturating_sub(2)) } else { 0 }; + match (knots.get(i), knots.get(i + 1)) { + (Some(a), Some(b)) => a + (b - a) * (t - i as f64), + _ => f64::NAN, + } +} + +/// The camera's frame-filling zoom `z` for the source radius map `source` and a crop of half size `x × y`: the +/// largest `source(ρ/z)/ρ` over the border radii `ρ` in [min(x, y), hypot(x, y)] is 1, so the output crop reads +/// only inside the source crop and touches it (at the corner for pincushion, the short-edge midpoint for barrel). +/// Bisection over [0.5, 2]; `None` when that range holds no solution or a ratio is not finite. +fn fill_zoom(source: &dyn Fn(f64) -> f64, x: f64, y: f64) -> Option { + let (near, far) = (x.min(y), x.hypot(y)); + let overshoots = |z: f64| -> Option { + let mut worst = f64::NEG_INFINITY; + for k in 0..=ZOOM_SCAN { + let rb = near + k as f64 * (far - near) / ZOOM_SCAN as f64; + let ratio = source(rb / z) / rb; + if !ratio.is_finite() { + return None; + } + worst = worst.max(ratio); + } + Some(worst > 1.0) + }; + let (mut lo, mut hi) = (0.5, 2.0); + if !overshoots(lo)? || overshoots(hi)? { + return None; + } + for _ in 0..100 { + let z = 0.5 * (lo + hi); + if overshoots(z)? { lo = z } else { hi = z } + } + Some(0.5 * (lo + hi)) +} + +/// The distortion table as a one-plane `WarpRectilinear` relative to the active area `active` (centred on the +/// default crop `crop`, as the camera corrects), with knots `knot_spacing` px apart. `None` when the table is absent +/// or all zero, the zoom or the fit fails, or the fit is worse than 0.25 px at a 1440 px long edge at one of the fit +/// samples within the crop (a padded active area, as in lossless files, reaches radii that are never shown). +pub(crate) fn distortion_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: Rect) -> Option { + let v = t.distortion.as_deref().filter(|v| v.iter().any(|&x| x != 0.0))?; + let (aw, ah) = (active.width as f64, active.height as f64); + if aw < 2.0 || ah < 2.0 || crop.width == 0 || crop.height == 0 { + return None; + } + let (cw, ch) = (crop.width as f64, crop.height as f64); + let source = |r: f64| r * (1.0 + interpolate(v, knot_spacing, r) / DISTORTION_UNIT); + let z = fill_zoom(&source, cw / 2.0, ch / 2.0)?; + // DNG's normalisation: pixel-index units, radius 1 = the farthest active-area corner + let (cx, cy) = (crop.x as f64 + (cw - 1.0) / 2.0, crop.y as f64 + (ch - 1.0) / 2.0); + let m = max_radius(cx, cy, aw - 1.0, ah - 1.0); + let max_fit_error = FIT_ERROR_PX_AT_1440 * (cw.max(ch) / 1440.0) / m; + let shown = (cw / 2.0).hypot(ch / 2.0) / m; + let [k0, k1, k2, k3] = fit_radial_warp(&|u| source(u * m / z) / m, max_fit_error, shown)?; + Some(Opcode::WarpRectilinear { planes: vec![[k0, k1, k2, k3, 0.0, 0.0]], center: [cx / (aw - 1.0), cy / (ah - 1.0)] }) +} + +/// The `OpcodeList3` lens corrections of a Sony raw IFD for the body `model` (Exif `Model`); empty for a body whose +/// tables are not verified. +pub(crate) fn lens_opcodes(raw: &Ifd, model: &str, active: Rect, crop: Rect) -> Vec { + let Some(spacing) = knot_spacing(model) else { return Vec::new() }; + distortion_opcode(&read_tables(raw), spacing, active, crop).into_iter().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_tiff::{Entry, Value}; + + /// The FF 16–35 mm example (corpus `arw-sony-ilce7rm6-ff-land-lossless.arw`, docs/sony-ilce7rm6-lens-corrections.md). + const FF_DISTORTION: [i16; 17] = [16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479]; + const FF_VIGNETTING: [i16; 17] = [16, 0, 80, 336, 672, 1120, 1648, 2240, 2880, 3600, 4336, 5104, 5872, 6640, 7392, 8160, 8976]; + const FF_CA: [i16; 33] = [ + 32, 242, 234, 224, 212, 196, 176, 154, 132, 108, 86, 66, 46, 30, 16, 8, 4, -276, -258, -238, -212, -184, -148, -114, -74, -40, -6, 20, 42, + 56, 60, 54, 32, + ]; + /// The APS-C example (corpus `arw6-sony-ilce7rm6-apsc-land-hq.arw`): counts 11 / 11 / 22, then padding. + const APSC_DISTORTION: [i16; 17] = [11, 0, -10, -36, -78, -134, -202, -277, -356, -435, -512, -583, -583, -583, -583, -583, -583]; + const APSC_VIGNETTING: [i16; 17] = [11, 0, 48, 208, 496, 880, 1360, 1952, 2624, 3568, 4832, 6176, 0, 0, 0, 0, 0]; + const APSC_CA: [i16; 33] = [ + 22, 790, 764, 734, 702, 666, 624, 582, 544, 510, 484, 470, -432, -404, -372, -330, -286, -230, -174, -114, -58, -6, 34, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, + ]; + /// ARW6 geometry (the decoder's raw image size and default crop). + const FF_ACTIVE: Rect = Rect { x: 0, y: 0, width: 10016, height: 6672 }; + const FF_CROP: Rect = Rect { x: 12, y: 8, width: 9984, height: 6656 }; + const APSC_ACTIVE: Rect = Rect { x: 0, y: 0, width: 6592, height: 4372 }; + const APSC_CROP: Rect = Rect { x: 16, y: 10, width: 6528, height: 4352 }; + + fn knots() -> f64 { + knot_spacing("ILCE-7RM6").unwrap() + } + + fn ifd(tags: Vec<(u16, Value)>) -> Ifd { + let mut entries: Vec = tags.into_iter().map(|(tag, value)| Entry { tag, value, offset: 0 }).collect(); + entries.sort_by_key(|e| e.tag); + Ifd { entries, ..Default::default() } + } + + fn sony_lens_ifd(distortion: &[i16], vignetting: &[i16], ca: &[i16], flags: [u16; 3]) -> Ifd { + ifd(vec![ + (DISTORTION_PARAMS, Value::SShort(distortion.to_vec())), + (VIGNETTING_PARAMS, Value::SShort(vignetting.to_vec())), + (CA_PARAMS, Value::SShort(ca.to_vec())), + (DISTORTION_FLAG, Value::Short(vec![flags[0]])), + (VIGNETTING_FLAG, Value::Short(vec![flags[1]])), + (CA_FLAG, Value::Short(vec![flags[2]])), + ]) + } + + fn tables_with_distortion(v: &[f64]) -> LensTables { + LensTables { distortion: Some(v.to_vec()), vignetting: None, ca: None, flags: [None; 3] } + } + + fn values(table: &[i16]) -> Vec { + table[1..=table[0] as usize].iter().map(|&v| v as f64).collect() + } + + /// The warp's one plane and centre against the recipe's numbers (docs/sony-ilce7rm6-lens-corrections.md). + fn assert_warp(op: Option, center: [f64; 2], k: [f64; 4]) { + let Some(Opcode::WarpRectilinear { planes, center: c }) = op else { panic!("no warp: {op:?}") }; + assert_eq!(planes.len(), 1); + assert!((c[0] - center[0]).abs() < 1e-9 && (c[1] - center[1]).abs() < 1e-9, "centre {c:?}, want {center:?}"); + let p = planes[0]; + for (i, want) in k.iter().enumerate() { + assert!((p[i] - want).abs() < 1e-6, "k{i} {}, want {want} ({p:?})", p[i]); + } + assert_eq!((p[4], p[5]), (0.0, 0.0), "radial only"); + } + + #[test] + fn reads_the_three_tables_and_flags() { + let t = read_tables(&sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_CA, [0, 2, 1])); + let d = t.distortion.unwrap(); + assert_eq!((d.len(), d[0], d[15]), (16, 0.0, 479.0)); + let v = t.vignetting.unwrap(); + assert_eq!((v.len(), v[1], v[15]), (16, 80.0, 8976.0)); + let (red, blue) = t.ca.unwrap(); + assert_eq!((red.len(), red[0], red[15]), (16, 242.0, 4.0)); + assert_eq!((blue.len(), blue[0], blue[15]), (16, -276.0, 32.0)); + assert_eq!(t.flags, [Some(0), Some(2), Some(1)]); + } + + #[test] + fn apsc_tables_use_only_the_first_count_values() { + let t = read_tables(&sony_lens_ifd(&APSC_DISTORTION, &APSC_VIGNETTING, &APSC_CA, [0, 2, 1])); + let d = t.distortion.unwrap(); + assert_eq!((d.len(), d[10]), (11, -583.0)); + assert_eq!(t.vignetting.unwrap().len(), 11); + let (red, blue) = t.ca.unwrap(); + assert_eq!((red.len(), red[10]), (11, 470.0)); + assert_eq!((blue.len(), blue[0], blue[10]), (11, -432.0, 34.0)); + } + + #[test] + fn missing_or_malformed_tables_yield_no_opcode() { + let mut short = FF_DISTORTION.to_vec(); + short.pop(); + let mut count_17 = FF_DISTORTION.to_vec(); + count_17[0] = 17; + count_17.push(500); + let mut too_large = FF_DISTORTION.to_vec(); + too_large[0] = 600; + for (what, table) in [ + ("count 15 with 3 values", vec![15, 0, 2, 7]), + ("count 17", count_17), + ("empty", vec![]), + ("count 16 with 15 values", short), + ("count 1", vec![1, 5]), + ("count 0", vec![0, 5, 6]), + ("negative count", vec![-16, 0, 2, 7]), + ("count larger than the array", too_large), + ] { + let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(table))]); + let t = read_tables(&ifd); + assert!(t.distortion.is_none(), "{what}"); + assert!(distortion_opcode(&t, knots(), FF_ACTIVE, FF_CROP).is_none(), "{what}"); + assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty(), "{what}"); + } + // not an integer array, or absent + for value in [Value::Ascii("16 0 2 7".into()), Value::Double(vec![2.0, 0.0, 1.0])] { + assert!(read_tables(&ifd(vec![(DISTORTION_PARAMS, value)])).distortion.is_none()); + } + let none = read_tables(&ifd(vec![])); + assert!(none.distortion.is_none() && none.vignetting.is_none() && none.ca.is_none() && none.flags == [None; 3]); + // the CA table needs an even count of 4..=32 + for ca in [vec![3, 1, 2, 3], vec![34; 35], vec![2, 1, 2]] { + assert!(read_tables(&ifd(vec![(CA_PARAMS, Value::SShort(ca))])).ca.is_none()); + } + } + + #[test] + fn hostile_tables_are_rejected() { + let folded = [0.0, 2000.0, 1000.0, 3000.0, 2000.0, 4000.0, 3000.0, 5000.0, 4000.0, 6000.0, 5000.0, 7000.0, 6000.0, 8000.0, 7000.0, 9000.0]; + for (what, table) in [ + ("huge", vec![(i64::MAX / 4) as f64; 16]), + ("most negative", vec![i64::MIN as f64; 16]), + ("zig-zag", folded.to_vec()), + ("all zero", vec![0.0; 16]), + ("one knot", vec![100.0]), + ("no knots", vec![]), + ("not finite", vec![0.0, f64::NAN, 20.0]), + ("infinite", vec![0.0, f64::INFINITY, 20.0]), + ] { + assert!(distortion_opcode(&tables_with_distortion(&table), knots(), FF_ACTIVE, FF_CROP).is_none(), "{what}"); + } + // the same through the IFD, as 64-bit values + for v in [i64::MAX / 4, i64::MIN, i64::MAX] { + let mut table = vec![v; 17]; + table[0] = 16; + let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SLong8(table))]); + assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty(), "{v}"); + } + // degenerate geometry + let ff = tables_with_distortion(&values(&FF_DISTORTION)); + for (what, knots, active, crop) in [ + ("empty crop", knots(), FF_ACTIVE, Rect::new(12, 8, 0, 6656)), + ("1 × 1 active area", knots(), Rect::new(0, 0, 1, 1), Rect::new(0, 0, 1, 1)), + ("knot spacing NaN", f64::NAN, FF_ACTIVE, FF_CROP), + ("knot spacing 0", 0.0, FF_ACTIVE, FF_CROP), + ] { + assert!(distortion_opcode(&ff, knots, active, crop).is_none(), "{what}"); + } + } + + #[test] + fn ff_example_table_becomes_the_measured_warp() { + let t = tables_with_distortion(&values(&FF_DISTORTION)); + assert_warp( + distortion_opcode(&t, knots(), FF_ACTIVE, FF_CROP), + [0.4996005991, 0.5], + [0.9714495823, 0.022508032, 0.0006090085237, 0.005683284548], + ); + } + + #[test] + fn apsc_example_table_becomes_the_measured_warp() { + let t = tables_with_distortion(&values(&APSC_DISTORTION)); + let op = distortion_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP); + assert_warp(op, [0.4975724473, 0.5], [1.01627551, -0.0618606692, 0.02768349621, -0.005595798468]); + } + + /// The lossless files' padded active area (10240 × 7168) puts the farthest active-area corner (DNG radius 1) well + /// beyond the crop's (u 0.925): the gate looks only at the radii the crop shows. The FF example × 1.5 fits within + /// 0.12 px at 1440 at the samples there, 0.27 px at all 65. + #[test] + fn padded_active_area_gates_only_the_displayed_radii() { + let strong: Vec = values(&FF_DISTORTION).iter().map(|v| v * 1.5).collect(); + let padded = Rect { x: 0, y: 0, width: 10240, height: 7168 }; + let op = distortion_opcode(&tables_with_distortion(&strong), knots(), padded, FF_CROP); + assert!(matches!(op, Some(Opcode::WarpRectilinear { .. })), "{op:?}"); + } + + #[test] + fn unknown_model_gets_no_opcodes() { + let ifd = sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_CA, [0, 2, 1]); + assert!(lens_opcodes(&ifd, "ILCE-7M4", FF_ACTIVE, FF_CROP).is_empty()); + assert!(lens_opcodes(&ifd, "", FF_ACTIVE, FF_CROP).is_empty()); + // the camera's in-camera flag (0 = off here) doesn't matter: the table is applied + let ops = lens_opcodes(&ifd, " ILCE-7RM6 ", FF_ACTIVE, FF_CROP); + assert_eq!(ops, distortion_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP).into_iter().collect::>()); + assert_eq!(ops.len(), 1); + } +} diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index e5218f390..5512a1169 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -6,6 +6,7 @@ mod arw6_curve; #[cfg(test)] pub(crate) mod arw6_testenc; mod arw_lens; +pub(crate) mod arw_lens_rm6; pub mod cr2; pub mod cr3; pub mod crx; @@ -111,7 +112,7 @@ pub(crate) fn white_from_data(data: &[u16], bits: u32) -> f32 { } } -/// Output radii sampled to fit the inverse mapping. +/// Output radii sampled to fit a radial warp. const DISTORTION_SAMPLES: usize = 64; /// Least-squares radial warp from a camera's forward distortion map. @@ -121,10 +122,10 @@ const DISTORTION_SAMPLES: usize = 64; /// from the centre to the **farthest** corner = 1) so the result is directly a `WarpRectilinear` plane. The map is /// scanned at 4096 points up to a source radius of `3 * t_max`: it must reach `t_max` within that range and rise at /// every scan point (monotonicity is checked only there), otherwise `None`; a NaN at a scan point is rejected the -/// same way, other non-finite values are caught by the fit-error gate. The map is inverted by bisection and -/// `source = u·(k0 + k1u² + k2u⁴ + k3u⁶)` is fitted over output radii `u·t_max`, `u` in [0, 1]: the returned -/// `[k0, k1, k2, k3]` give the source radius in the caller's unit as a function of `u`. `None` too when the worst -/// fit error exceeds `max_fit_error`. +/// same way, other non-finite values by the fit. The map is inverted by bisection and +/// `source = u·(k0 + k1u² + k2u⁴ + k3u⁶)` is fitted over output radii `u·t_max`, `u` in [0, 1] ([`fit_radial_warp`]): +/// the returned `[k0, k1, k2, k3]` give the source radius in the caller's unit as a function of `u`. `None` too when +/// the worst fit error exceeds `max_fit_error`. pub(crate) fn warp_from_forward_map(corrected: &dyn Fn(f64) -> f64, t_max: f64, max_fit_error: f64) -> Option<[f64; 4]> { if !(t_max.is_finite() && t_max > 0.0) { return None; @@ -152,12 +153,23 @@ pub(crate) fn warp_from_forward_map(corrected: &dyn Fn(f64) -> f64, t_max: f64, } 0.5 * (lo + hi) }; + fit_radial_warp(&|u| source(u * t_max), max_fit_error, 1.0) +} + +/// Least-squares `source = u·(k0 + k1u² + k2u⁴ + k3u⁶)` through `source_of_u` sampled at `u = i/64`, i = 0..=64 +/// (`u` = output radius, `WarpRectilinear` units, so the result is directly a plane's `[k0, k1, k2, k3]`). `None` when +/// a sample is not finite, the normal equations are singular, or the worst fit error at the samples with +/// `u ≤ gate_until` (the radii the image shows; 1 = all) exceeds `max_fit_error`. +pub(crate) fn fit_radial_warp(source_of_u: &dyn Fn(f64) -> f64, max_fit_error: f64, gate_until: f64) -> Option<[f64; 4]> { let samples: Vec<(f64, f64)> = (0..=DISTORTION_SAMPLES) .map(|i| { let u = i as f64 / DISTORTION_SAMPLES as f64; - (u, source(u * t_max)) + (u, source_of_u(u)) }) .collect(); + if samples.iter().any(|&(_, v)| !v.is_finite()) { + return None; + } let basis = |u: f64| { let u2 = u * u; [u, u * u2, u * u2 * u2, u * u2 * u2 * u2] @@ -175,7 +187,7 @@ pub(crate) fn warp_from_forward_map(corrected: &dyn Fn(f64) -> f64, t_max: f64, } let k = solve4(a, rhs)?; let fit = |u: f64| basis(u).iter().zip(&k).map(|(f, c)| f * c).sum::(); - let worst = samples.iter().map(|&(u, v)| (fit(u) - v).abs()).fold(0.0, f64::max); + let worst = samples.iter().filter(|&&(u, _)| u <= gate_until).map(|&(u, v)| (fit(u) - v).abs()).fold(0.0, f64::max); (worst <= max_fit_error).then_some(k) } @@ -281,4 +293,18 @@ mod tests { assert!(warp_from_forward_map(&dip, 1.0, 1e-2).is_none()); assert!(warp_from_forward_map(&|_| f64::NAN, 1.0, 1e-3).is_none()); } + + #[test] + fn fit_radial_warp_identity_and_gate() { + let k = fit_radial_warp(&|u| u, 1e-9, 1.0).unwrap(); + assert!((k[0] - 1.0).abs() < 1e-9 && k[1].abs() < 1e-9 && k[2].abs() < 1e-9 && k[3].abs() < 1e-9, "{k:?}"); + assert!(fit_radial_warp(&|u| if u > 0.5 { f64::NAN } else { u }, 1.0, 1.0).is_none(), "a NaN sample"); + assert!(fit_radial_warp(&|u| if u > 0.5 { f64::INFINITY } else { u }, 1.0, 1.0).is_none(), "an infinite sample"); + assert!(fit_radial_warp(&|u: f64| u.sqrt(), 1.0, 1.0).is_some()); + assert!(fit_radial_warp(&|u: f64| u.sqrt(), 0.0, 1.0).is_none(), "not a polynomial: the gate rejects it"); + // a step at u = 1 alone: worst fit error 0.057 over all samples, 0.006 over u <= 0.9 (the fit is the same) + let step = |u: f64| if u > 0.99 { 1.1 } else { u }; + assert!(fit_radial_warp(&step, 0.01, 1.0).is_none()); + assert_eq!(fit_radial_warp(&step, 0.01, 0.9), fit_radial_warp(&step, 1.0, 1.0), "gated only where shown"); + } } diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 5ca4777b7..60c81fd1f 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -626,6 +626,69 @@ fn corpus_sony_16x9_crops() { eprintln!("Sony 16:9 crops checked on {seen} files"); } +/// Sony ILCE-7RM6: the distortion table in the raw IFD (`0x7037`) becomes a `WarpRectilinear` in `OpcodeList3` +/// (`vendor::arw_lens_rm6`). Expected: the conversion recipe measured on 54 ILCE-7RM6 photos, evaluated in f64 for each +/// file's decoded active area (the padded lossless tiles) and default crop. Both files carry distortion correction +/// off in camera; the table is applied anyway. +#[test] +fn corpus_sony_lens_tables_become_opcodes() { + let dir = corpus_root().join("raw"); + // (file, active area, default crop x, y, width, height, opcode centre, G-plane k0..k3) + let cases = [ + // not pinned in `cargo xtask corpus` (at most two pins per PR; #625 pins two Compressed ARW 6.0 files): checked + // only where the file is present (RawDB `ILCE-7RM6_FF_land_RAW.ARW`, CC0) + ( + "arw-sony-ilce7rm6-ff-land-lossless.arw", + (10240, 7168), + (12, 8, 9984, 6656), + [0.488670768629749, 0.4653969582810102], + [0.9717033132764651, 0.023249297636569695, 0.008638979168841135, 0.003091041831374855], + ), + // pinned in `cargo xtask corpus` (RawDB `ILCE-7RM6_APSC_land_RAW.ARW`, CC0) + ( + "arw-sony-ilce7rm6-apsc-land-lossless.arw", + (6656, 4608), + (16, 10, 6528, 4352), + [0.49278737791134486, 0.4743868026915563], + [1.016366854640611, -0.0688824189782517, 0.03615745253291194, -0.009438825010476538], + ), + ]; + let mut seen = 0; + for (name, (aw, ah), crop, center, k) in cases { + let bytes = match std::fs::read(dir.join(name)) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + eprintln!("skip: {name} absent"); + continue; + } + Err(e) => panic!("{name}: {e}"), + }; + let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + let (a, c) = (img.active_area, img.crop); + eprintln!("{name}: active area {a:?}, crop {c:?}"); + assert_eq!((a.x, a.y, a.width, a.height), (0, 0, aw, ah), "{name}: active area"); + assert_eq!((c.x, c.y, c.width, c.height), crop, "{name}: default crop"); + assert_eq!(probe_info(&bytes).unwrap().opcodes, img.opcodes, "{name}: the headers-only probe reads the same correction"); + let warps: Vec<_> = img + .opcodes + .list3 + .iter() + .filter_map(|op| match op { + lightcraft_raw::Opcode::WarpRectilinear { planes, center } => Some((planes, center)), + _ => None, + }) + .collect(); + let [(planes, got)] = warps.as_slice() else { panic!("{name}: {:?}", img.opcodes.list3) }; + assert_eq!(planes.len(), 1, "{name}: one plane (G)"); + assert!((got[0] - center[0]).abs() < 1e-6 && (got[1] - center[1]).abs() < 1e-6, "{name}: centre {got:?}, want {center:?}"); + for (i, want) in k.iter().enumerate() { + assert!((planes[0][i] - want).abs() < 1e-6, "{name}: k{i} {}, want {want}", planes[0][i]); + } + seen += 1; + } + eprintln!("Sony lens tables checked on {seen} files"); +} + /// The embedded JPEG as linear RGB, reduced to `gw × gh` cells. fn jpeg_cells(jpeg: &[u8], gw: usize, gh: usize) -> Vec<[f64; 3]> { use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 08ef435c5..28fb3a38b 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -448,6 +448,13 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/data/Sony/DSLR-A900/_DSC7969.ARW", "ac7c1532df77c321e8010aa1be60c1b9f245b1ac0db6f38fe617a73aae81af50", ), + // Sony ILCE-7RM6 lossless, CC0 1.0 (RawDB): the APS-C file, for its lens tables (`vendor::arw_lens_rm6`). The + // full-frame lossless file is not pinned (at most two pins per PR; #625 pins two Compressed ARW 6.0 files). + ( + "arw-sony-ilce7rm6-apsc-land-lossless.arw", + "https://rawdb.dnglab.org/api/download/Sony/ILCE-7RM6/raw_modes/ILCE-7RM6_APSC_land_RAW.ARW", + "7aaf0b3941509d7364a6f03844783155ebc912d246d1c456e5cdb4693ec09fbc", + ), // CR2 colour-filter layouts differ by model (issue #85): CR2CFAPattern 3 (GBRG) and 1 (RGGB) samples ( "cr2-canon-40d.cr2", From b677fbac0a0f2bb0f3321b0440d0ce5bf375c062 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Sat, 10 Oct 2026 23:14:08 +0200 Subject: [PATCH 3/6] LR-EDIT-OPTICS-PROFILE: Sony ARW lateral CA correction from the embedded table vendor::arw_lens_rm6::warp_opcode (was distortion_opcode) turns the CA table (0x7035: red half, blue half, on the distortion knots) into the R and B planes of the WarpRectilinear: a colour's source radius is g (1 + c(g) / 3.36e6) at the green source radius g. The unit is provisional (borderline measurement, about +-30 % scene bias). Each colour plane goes through the same fit gate as G, and a table with a value beyond 2e-3 relative (about 12 px at the full-frame corner; the 54 photos' tables stay below 5.1e-4, i.e. 1724) is refused; without a CA table, or when it is refused, the warp keeps its single G plane, and CA alone is never applied. fit_radial_warp now rounds its gate up to the next sample, capped at 1 (a NaN gates every sample), so the band between the last sample inside the crop and the crop corner is checked too (RW2 passes 1.0: unchanged). Co-Authored-By: Claude --- crates/raw/src/vendor/arw.rs | 23 ++-- crates/raw/src/vendor/arw_lens_rm6.rs | 167 +++++++++++++++++++++----- crates/raw/src/vendor/mod.rs | 15 ++- crates/raw/tests/corpus.rs | 30 +++-- 4 files changed, 186 insertions(+), 49 deletions(-) diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index 5a23218c0..d6715d49e 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -874,12 +874,12 @@ mod tests { TiffWriter::default().write(&[raw]).unwrap() } - /// The ILCE-7RM6's distortion table reaches `OpcodeList3`, in the headers-only probe and in the full decode; - /// another body's table doesn't (see [`super::super::arw_lens_rm6`]). + /// The ILCE-7RM6's distortion table reaches `OpcodeList3`, in the headers-only probe and in the full decode, as + /// one plane, or three with the CA table; another body's tables don't (see [`super::super::arw_lens_rm6`]). #[test] fn lens_tables_reach_opcode_list3() { use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; - let file = |model: &str| { + let file = |model: &str, ca: bool| { let mut raw = IfdBuilder::new(); raw.set(t::MAKE, Value::Ascii("SONY".into())); raw.set(t::MODEL, Value::Ascii(model.into())); @@ -893,14 +893,23 @@ mod tests { raw.set(TONE_CURVE, Value::Short(vec![8000, 10400, 12900, 14100])); let table = vec![16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479]; raw.set(super::super::arw_lens_rm6::DISTORTION_PARAMS, Value::SShort(table)); + if ca { + let table = vec![ + 32, 242, 234, 224, 212, 196, 176, 154, 132, 108, 86, 66, 46, 30, 16, 8, 4, -276, -258, -238, -212, -184, -148, -114, -74, -40, + -6, 20, 42, 56, 60, 54, 32, + ]; + raw.set(super::super::arw_lens_rm6::CA_PARAMS, Value::SShort(table)); + } raw.set_image(ImageData::Strips { rows_per_strip: 64, strips: vec![vec![0u8; 96 * 64]] }); TiffWriter::default().write(&[raw]).unwrap() }; for mode in [Mode::Header, Mode::Full] { - let list3 = |model| decode(&file(model), mode).unwrap().opcodes.list3; - let ops = list3("ILCE-7RM6"); - assert!(matches!(ops.as_slice(), [crate::Opcode::WarpRectilinear { planes, .. }] if planes.len() == 1), "{mode:?}: {ops:?}"); - assert!(list3("ILCE-7M4").is_empty(), "{mode:?}"); + for (ca, n) in [(false, 1), (true, 3)] { + let list3 = |model| decode(&file(model, ca), mode).unwrap().opcodes.list3; + let ops = list3("ILCE-7RM6"); + assert!(matches!(ops.as_slice(), [crate::Opcode::WarpRectilinear { planes, .. }] if planes.len() == n), "{mode:?} {ca}: {ops:?}"); + assert!(list3("ILCE-7M4").is_empty(), "{mode:?} {ca}"); + } } } diff --git a/crates/raw/src/vendor/arw_lens_rm6.rs b/crates/raw/src/vendor/arw_lens_rm6.rs index ba9aaaa9d..37d76fdd6 100644 --- a/crates/raw/src/vendor/arw_lens_rm6.rs +++ b/crates/raw/src/vendor/arw_lens_rm6.rs @@ -28,8 +28,12 @@ //! 0.049 px for the full-frame 16–35 mm example in the tests. //! - vignetting: read but never applied. The camera JPEGs keep the uncorrected render's radial falloff (median //! corner/centre ratio 0.996 over 39 photos) and no radial gain model fits the tables. -//! - lateral CA: red and blue relative to green on the same knots (unit about 1/3.36·10⁶; 0.399 px, borderline); -//! not applied yet. +//! - lateral CA: red's (blue's) source radius is `g · (1 + c(g) / 3.36·10⁶)` with `g` the green source radius and `c` +//! the red (blue) half on the same knots, as the warp's R and B planes. The unit is **provisional**: the +//! measurement is borderline (0.399 px worst against a 0.3 px budget; per-photo best units scatter from 1.5·10⁶ +//! to 7.2·10⁶, about ±30 % scene bias), but it cuts the rms CA over 46 photos from 0.079 to 0.052 px. When a CA +//! value is implausible (beyond [`CA_MAX_RELATIVE`]) or a colour plane fails the fit gate above, both colour +//! planes are dropped and G alone is applied to all three channels. //! //! The correction is produced whatever the in-camera flag says. Only bodies listed in [`FULL_FRAME_CROP`] get it: the //! unit and the knot spacing are verified on the ILCE-7RM6 alone. Another body is added by repeating the measurement @@ -53,6 +57,12 @@ pub(crate) const CA_FLAG: u16 = 0x7034; const FULL_FRAME_CROP: &[(&str, (f64, f64))] = &[("ILCE-7RM6", (9984.0, 6656.0))]; /// The distortion table's value of 1 (relative radius); a global least-squares fit over the 34 photos gives 15000.7. const DISTORTION_UNIT: f64 = 15000.0; +/// The CA table's value of 1 (relative to the green source radius). Provisional: the median best unit of the 20 +/// cleanest photos; per-photo units scatter from 1.5·10⁶ to 7.2·10⁶ (the measurement's scene bias is about ±30 %). +const CA_UNIT: f64 = 3.36e6; +/// Largest plausible CA value relative to the green source radius (|c| / [`CA_UNIT`]): about 12 px at the full-frame +/// corner; the measured tables stay below 790 / 3.36·10⁶ ≈ 2.4·10⁻⁴. A table beyond it is refused. +const CA_MAX_RELATIVE: f64 = 2e-3; /// Crop border radii scanned by the frame-filling zoom. const ZOOM_SCAN: usize = 4096; /// Largest polynomial fit error accepted at the fit samples the crop shows, px at a 1440 px long edge. @@ -62,7 +72,7 @@ const FIT_ERROR_PX_AT_1440: f64 = 0.25; /// `vignetting` and `flags` are parsed for the tests and the docs only: don't build an opcode from them (the camera /// applies no vignetting correction to its JPEGs, and the flags don't decide anything). #[derive(Debug)] -// only the distortion table becomes an opcode so far +// vignetting and the flags never become an opcode #[cfg_attr(not(test), allow(dead_code))] pub(crate) struct LensTables { pub distortion: Option>, @@ -140,11 +150,13 @@ fn fill_zoom(source: &dyn Fn(f64) -> f64, x: f64, y: f64) -> Option { Some(0.5 * (lo + hi)) } -/// The distortion table as a one-plane `WarpRectilinear` relative to the active area `active` (centred on the -/// default crop `crop`, as the camera corrects), with knots `knot_spacing` px apart. `None` when the table is absent -/// or all zero, the zoom or the fit fails, or the fit is worse than 0.25 px at a 1440 px long edge at one of the fit -/// samples within the crop (a padded active area, as in lossless files, reaches radii that are never shown). -pub(crate) fn distortion_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: Rect) -> Option { +/// The distortion and CA tables as a `WarpRectilinear` relative to the active area `active` (centred on the default +/// crop `crop`, as the camera corrects), with knots `knot_spacing` px apart: planes R, G, B, or G alone (applied to +/// all three) when the CA table is absent or a colour plane's fit fails. `None` when the distortion table is absent or +/// all zero (CA alone is not applied: it is relative to green), the zoom or the G fit fails. A fit fails when it is +/// worse than 0.25 px at a 1440 px long edge at one of the fit samples within the crop (a padded active area, as in +/// lossless files, reaches radii that are never shown). +pub(crate) fn warp_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: Rect) -> Option { let v = t.distortion.as_deref().filter(|v| v.iter().any(|&x| x != 0.0))?; let (aw, ah) = (active.width as f64, active.height as f64); if aw < 2.0 || ah < 2.0 || crop.width == 0 || crop.height == 0 { @@ -158,15 +170,32 @@ pub(crate) fn distortion_opcode(t: &LensTables, knot_spacing: f64, active: Rect, let m = max_radius(cx, cy, aw - 1.0, ah - 1.0); let max_fit_error = FIT_ERROR_PX_AT_1440 * (cw.max(ch) / 1440.0) / m; let shown = (cw / 2.0).hypot(ch / 2.0) / m; - let [k0, k1, k2, k3] = fit_radial_warp(&|u| source(u * m / z) / m, max_fit_error, shown)?; - Some(Opcode::WarpRectilinear { planes: vec![[k0, k1, k2, k3, 0.0, 0.0]], center: [cx / (aw - 1.0), cy / (ah - 1.0)] }) + // output radius (px) → green source radius + let green = |r: f64| source(r / z); + let plane = |map: &dyn Fn(f64) -> f64| { + let [k0, k1, k2, k3] = fit_radial_warp(&|u| map(u * m) / m, max_fit_error, shown)?; + Some([k0, k1, k2, k3, 0.0, 0.0]) + }; + let g = plane(&green)?; + // red and blue: relative to the green source radius, at that radius + let colour = |c: &[f64]| { + plane(&|r| { + let s = green(r); + s * (1.0 + interpolate(c, knot_spacing, s) / CA_UNIT) + }) + }; + // a NaN fails the comparison: refused too + let plausible = |c: &[f64]| c.iter().all(|v| v.abs() <= CA_MAX_RELATIVE * CA_UNIT); + let rgb = t.ca.as_ref().filter(|(red, blue)| plausible(red) && plausible(blue)); + let rgb = rgb.and_then(|(red, blue)| Some(vec![colour(red)?, g, colour(blue)?])); + Some(Opcode::WarpRectilinear { planes: rgb.unwrap_or_else(|| vec![g]), center: [cx / (aw - 1.0), cy / (ah - 1.0)] }) } /// The `OpcodeList3` lens corrections of a Sony raw IFD for the body `model` (Exif `Model`); empty for a body whose /// tables are not verified. pub(crate) fn lens_opcodes(raw: &Ifd, model: &str, active: Rect, crop: Rect) -> Vec { let Some(spacing) = knot_spacing(model) else { return Vec::new() }; - distortion_opcode(&read_tables(raw), spacing, active, crop).into_iter().collect() + warp_opcode(&read_tables(raw), spacing, active, crop).into_iter().collect() } #[cfg(test)] @@ -181,6 +210,11 @@ mod tests { 32, 242, 234, 224, 212, 196, 176, 154, 132, 108, 86, 66, 46, 30, 16, 8, 4, -276, -258, -238, -212, -184, -148, -114, -74, -40, -6, 20, 42, 56, 60, 54, 32, ]; + /// The CA table of corpus `arw6-sony-ilce7rm6-ff-land-hq.arw` (the same scene; same distortion table). + const FF_HQ_CA: [i16; 33] = [ + 32, 242, 234, 222, 208, 192, 172, 152, 128, 106, 82, 62, 42, 26, 12, 4, 2, -270, -254, -234, -208, -180, -146, -112, -72, -38, -4, 22, 44, + 58, 64, 56, 36, + ]; /// The APS-C example (corpus `arw6-sony-ilce7rm6-apsc-land-hq.arw`): counts 11 / 11 / 22, then padding. const APSC_DISTORTION: [i16; 17] = [11, 0, -10, -36, -78, -134, -202, -277, -356, -435, -512, -583, -583, -583, -583, -583, -583]; const APSC_VIGNETTING: [i16; 17] = [11, 0, 48, 208, 496, 880, 1360, 1952, 2624, 3568, 4832, 6176, 0, 0, 0, 0, 0]; @@ -193,6 +227,14 @@ mod tests { const FF_CROP: Rect = Rect { x: 12, y: 8, width: 9984, height: 6656 }; const APSC_ACTIVE: Rect = Rect { x: 0, y: 0, width: 6592, height: 4372 }; const APSC_CROP: Rect = Rect { x: 16, y: 10, width: 6528, height: 4352 }; + /// The G planes of the examples on their ARW6 geometry, and the R and B planes with each file's CA table + /// (docs/sony-ilce7rm6-lens-corrections.md). + const FF_G: [f64; 4] = [0.9714495823, 0.022508032, 0.0006090085237, 0.005683284548]; + const FF_HQ_R: [f64; 4] = [0.9715153129, 0.02236266369, 0.0007060224765, 0.005666341563]; + const FF_HQ_B: [f64; 4] = [0.97137946, 0.02277016389, 0.0003830082563, 0.005727820598]; + const APSC_G: [f64; 4] = [1.01627551, -0.0618606692, 0.02768349621, -0.005595798468]; + const APSC_R: [f64; 4] = [1.016503991, -0.06206460153, 0.0278447625, -0.005644118171]; + const APSC_B: [f64; 4] = [1.016155702, -0.06162477627, 0.02753315326, -0.005554915901]; fn knots() -> f64 { knot_spacing("ILCE-7RM6").unwrap() @@ -223,16 +265,18 @@ mod tests { table[1..=table[0] as usize].iter().map(|&v| v as f64).collect() } - /// The warp's one plane and centre against the recipe's numbers (docs/sony-ilce7rm6-lens-corrections.md). - fn assert_warp(op: Option, center: [f64; 2], k: [f64; 4]) { + /// The warp's planes and centre against the recipe's numbers (docs/sony-ilce7rm6-lens-corrections.md); returns the planes. + fn assert_warp(op: Option, center: [f64; 2], k: &[[f64; 4]]) -> Vec<[f64; 6]> { let Some(Opcode::WarpRectilinear { planes, center: c }) = op else { panic!("no warp: {op:?}") }; - assert_eq!(planes.len(), 1); + assert_eq!(planes.len(), k.len()); assert!((c[0] - center[0]).abs() < 1e-9 && (c[1] - center[1]).abs() < 1e-9, "centre {c:?}, want {center:?}"); - let p = planes[0]; - for (i, want) in k.iter().enumerate() { - assert!((p[i] - want).abs() < 1e-6, "k{i} {}, want {want} ({p:?})", p[i]); + for (p, k) in planes.iter().zip(k) { + for (i, want) in k.iter().enumerate() { + assert!((p[i] - want).abs() < 1e-6, "k{i} {}, want {want} ({p:?})", p[i]); + } + assert_eq!((p[4], p[5]), (0.0, 0.0), "radial only"); } - assert_eq!((p[4], p[5]), (0.0, 0.0), "radial only"); + planes } #[test] @@ -281,7 +325,7 @@ mod tests { let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(table))]); let t = read_tables(&ifd); assert!(t.distortion.is_none(), "{what}"); - assert!(distortion_opcode(&t, knots(), FF_ACTIVE, FF_CROP).is_none(), "{what}"); + assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP).is_none(), "{what}"); assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty(), "{what}"); } // not an integer array, or absent @@ -309,7 +353,7 @@ mod tests { ("not finite", vec![0.0, f64::NAN, 20.0]), ("infinite", vec![0.0, f64::INFINITY, 20.0]), ] { - assert!(distortion_opcode(&tables_with_distortion(&table), knots(), FF_ACTIVE, FF_CROP).is_none(), "{what}"); + assert!(warp_opcode(&tables_with_distortion(&table), knots(), FF_ACTIVE, FF_CROP).is_none(), "{what}"); } // the same through the IFD, as 64-bit values for v in [i64::MAX / 4, i64::MIN, i64::MAX] { @@ -326,25 +370,88 @@ mod tests { ("knot spacing NaN", f64::NAN, FF_ACTIVE, FF_CROP), ("knot spacing 0", 0.0, FF_ACTIVE, FF_CROP), ] { - assert!(distortion_opcode(&ff, knots, active, crop).is_none(), "{what}"); + assert!(warp_opcode(&ff, knots, active, crop).is_none(), "{what}"); } } #[test] fn ff_example_table_becomes_the_measured_warp() { let t = tables_with_distortion(&values(&FF_DISTORTION)); - assert_warp( - distortion_opcode(&t, knots(), FF_ACTIVE, FF_CROP), - [0.4996005991, 0.5], - [0.9714495823, 0.022508032, 0.0006090085237, 0.005683284548], - ); + assert_warp(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP), [0.4996005991, 0.5], &[FF_G]); } #[test] fn apsc_example_table_becomes_the_measured_warp() { let t = tables_with_distortion(&values(&APSC_DISTORTION)); - let op = distortion_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP); - assert_warp(op, [0.4975724473, 0.5], [1.01627551, -0.0618606692, 0.02768349621, -0.005595798468]); + let op = warp_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP); + assert_warp(op, [0.4975724473, 0.5], &[APSC_G]); + } + + #[test] + fn ca_table_shifts_red_and_blue_planes() { + let t = read_tables(&sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_HQ_CA, [0, 2, 1])); + let planes = assert_warp(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP), [0.4996005991, 0.5], &[FF_HQ_R, FF_G, FF_HQ_B]); + assert_ne!(planes[0][0], planes[1][0]); + // G is the distortion plane alone + let g = warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP); + assert!(matches!(g, Some(Opcode::WarpRectilinear { planes: ref p, .. }) if p == &[planes[1]]), "{g:?}"); + } + + #[test] + fn apsc_ca_planes_match_the_measured_warp() { + let t = read_tables(&sony_lens_ifd(&APSC_DISTORTION, &APSC_VIGNETTING, &APSC_CA, [0, 2, 1])); + assert_warp(warp_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP), [0.4975724473, 0.5], &[APSC_R, APSC_G, APSC_B]); + } + + #[test] + fn ca_without_distortion_is_not_applied() { + let ifd = ifd(vec![(CA_PARAMS, Value::SShort(FF_HQ_CA.to_vec()))]); + let t = read_tables(&ifd); + assert!(t.ca.is_some() && t.distortion.is_none()); + assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP).is_none()); + assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty()); + // an all-zero distortion table counts as absent + let zero = LensTables { ca: t.ca, ..tables_with_distortion(&[0.0; 16]) }; + assert!(warp_opcode(&zero, knots(), FF_ACTIVE, FF_CROP).is_none()); + } + + #[test] + fn rejected_ca_keeps_the_single_green_plane() { + let green = + assert_warp(warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP), [0.4996005991, 0.5], &[FF_G]); + let ok = values(&FF_HQ_CA); + let huge = vec![(i64::MAX / 4) as f64; 16]; + for (what, red, blue) in [ + ("huge red", huge.clone(), ok[16..].to_vec()), + ("huge blue", ok[..16].to_vec(), huge), + ("most negative blue", ok[..16].to_vec(), vec![i64::MIN as f64; 16]), + ("not finite red", vec![0.0, f64::NAN, 20.0], ok[16..].to_vec()), + ("one knot", vec![100.0], vec![100.0]), + ] { + let t = LensTables { ca: Some((red, blue)), ..tables_with_distortion(&values(&FF_DISTORTION)) }; + let Some(Opcode::WarpRectilinear { planes, .. }) = warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP) else { panic!("{what}: no warp") }; + assert_eq!(planes, green, "{what}"); + } + // through the IFD: an odd count, or values that overflow the fit + let mut odd = FF_HQ_CA.to_vec(); + odd[0] = 31; + let mut huge_ifd = vec![i64::MAX / 4; 33]; + huge_ifd[0] = 32; + // a constant 32767 (the SShort maximum) fits as well as G does and would shift red ~59 px at the full-res + // corner: refused as implausible (bound 2·10⁻³ relative = 6720) + let mut max = vec![32767; 33]; + max[0] = 32; + for (what, ca) in [("odd count", Value::SShort(odd)), ("huge", Value::SLong8(huge_ifd)), ("constant 32767", Value::SShort(max))] { + let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(FF_DISTORTION.to_vec())), (CA_PARAMS, ca)]); + let ops = lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP); + assert!(matches!(ops.as_slice(), [Opcode::WarpRectilinear { planes, .. }] if planes == &green), "{what}: {ops:?}"); + } + // values up to the bound are kept + let mut at_bound = vec![6720; 33]; + at_bound[0] = 32; + let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(FF_DISTORTION.to_vec())), (CA_PARAMS, Value::SShort(at_bound))]); + let ops = lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP); + assert!(matches!(ops.as_slice(), [Opcode::WarpRectilinear { planes, .. }] if planes.len() == 3), "{ops:?}"); } /// The lossless files' padded active area (10240 × 7168) puts the farthest active-area corner (DNG radius 1) well @@ -354,7 +461,7 @@ mod tests { fn padded_active_area_gates_only_the_displayed_radii() { let strong: Vec = values(&FF_DISTORTION).iter().map(|v| v * 1.5).collect(); let padded = Rect { x: 0, y: 0, width: 10240, height: 7168 }; - let op = distortion_opcode(&tables_with_distortion(&strong), knots(), padded, FF_CROP); + let op = warp_opcode(&tables_with_distortion(&strong), knots(), padded, FF_CROP); assert!(matches!(op, Some(Opcode::WarpRectilinear { .. })), "{op:?}"); } @@ -365,7 +472,7 @@ mod tests { assert!(lens_opcodes(&ifd, "", FF_ACTIVE, FF_CROP).is_empty()); // the camera's in-camera flag (0 = off here) doesn't matter: the table is applied let ops = lens_opcodes(&ifd, " ILCE-7RM6 ", FF_ACTIVE, FF_CROP); - assert_eq!(ops, distortion_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP).into_iter().collect::>()); + assert_eq!(ops, warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP).into_iter().collect::>()); assert_eq!(ops.len(), 1); } } diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index 5512a1169..ae4b0892d 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -158,8 +158,8 @@ pub(crate) fn warp_from_forward_map(corrected: &dyn Fn(f64) -> f64, t_max: f64, /// Least-squares `source = u·(k0 + k1u² + k2u⁴ + k3u⁶)` through `source_of_u` sampled at `u = i/64`, i = 0..=64 /// (`u` = output radius, `WarpRectilinear` units, so the result is directly a plane's `[k0, k1, k2, k3]`). `None` when -/// a sample is not finite, the normal equations are singular, or the worst fit error at the samples with -/// `u ≤ gate_until` (the radii the image shows; 1 = all) exceeds `max_fit_error`. +/// a sample is not finite, the normal equations are singular, or the worst fit error at the samples up to the first +/// one at or beyond `gate_until` (the radii the image shows; 1 = all) exceeds `max_fit_error`. pub(crate) fn fit_radial_warp(source_of_u: &dyn Fn(f64) -> f64, max_fit_error: f64, gate_until: f64) -> Option<[f64; 4]> { let samples: Vec<(f64, f64)> = (0..=DISTORTION_SAMPLES) .map(|i| { @@ -187,6 +187,10 @@ pub(crate) fn fit_radial_warp(source_of_u: &dyn Fn(f64) -> f64, max_fit_error: f } let k = solve4(a, rhs)?; let fit = |u: f64| basis(u).iter().zip(&k).map(|(f, c)| f * c).sum::(); + // rounded up to a sample, so the band between the last sample inside and `gate_until` itself is checked too; + // capped at 1 (`min` also turns a NaN into 1: every sample is gated) + let n = DISTORTION_SAMPLES as f64; + let gate_until = (gate_until * n).ceil().min(n) / n; let worst = samples.iter().filter(|&&(u, _)| u <= gate_until).map(|&(u, v)| (fit(u) - v).abs()).fold(0.0, f64::max); (worst <= max_fit_error).then_some(k) } @@ -306,5 +310,12 @@ mod tests { let step = |u: f64| if u > 0.99 { 1.1 } else { u }; assert!(fit_radial_warp(&step, 0.01, 1.0).is_none()); assert_eq!(fit_radial_warp(&step, 0.01, 0.9), fit_radial_warp(&step, 1.0, 1.0), "gated only where shown"); + // the first sample at or beyond `gate_until` is gated too (the band up to it is shown): 0.023 up to + // u = 62/64, 0.032 at 63/64 + assert!(fit_radial_warp(&step, 0.03, 62.0 / 64.0).is_some(), "a sample exactly at the bound"); + assert!(fit_radial_warp(&step, 0.03, 0.98).is_none(), "63/64 is the next sample beyond 0.98"); + // capped at 1, and a NaN gates every sample (fails closed) + assert_eq!(fit_radial_warp(&step, 1.0, f64::INFINITY), fit_radial_warp(&step, 1.0, 1.0)); + assert!(fit_radial_warp(&step, 0.01, f64::NAN).is_none(), "a NaN gate"); } } diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 60c81fd1f..c32857100 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -626,14 +626,14 @@ fn corpus_sony_16x9_crops() { eprintln!("Sony 16:9 crops checked on {seen} files"); } -/// Sony ILCE-7RM6: the distortion table in the raw IFD (`0x7037`) becomes a `WarpRectilinear` in `OpcodeList3` -/// (`vendor::arw_lens_rm6`). Expected: the conversion recipe measured on 54 ILCE-7RM6 photos, evaluated in f64 for each -/// file's decoded active area (the padded lossless tiles) and default crop. Both files carry distortion correction -/// off in camera; the table is applied anyway. +/// Sony ILCE-7RM6: the distortion and lateral CA tables in the raw IFD (`0x7037`, `0x7035`) become a three-plane +/// (R, G, B) `WarpRectilinear` in `OpcodeList3` (`vendor::arw_lens_rm6`). Expected: the conversion recipe measured on 54 +/// ILCE-7RM6 photos, evaluated in f64 for each file's tables, decoded active area (the padded lossless tiles) and +/// default crop. Both files carry distortion correction off in camera; the table is applied anyway. #[test] fn corpus_sony_lens_tables_become_opcodes() { let dir = corpus_root().join("raw"); - // (file, active area, default crop x, y, width, height, opcode centre, G-plane k0..k3) + // (file, active area, default crop x, y, width, height, opcode centre, R, G, B planes k0..k3) let cases = [ // not pinned in `cargo xtask corpus` (at most two pins per PR; #625 pins two Compressed ARW 6.0 files): checked // only where the file is present (RawDB `ILCE-7RM6_FF_land_RAW.ARW`, CC0) @@ -642,7 +642,11 @@ fn corpus_sony_lens_tables_become_opcodes() { (10240, 7168), (12, 8, 9984, 6656), [0.488670768629749, 0.4653969582810102], - [0.9717033132764651, 0.023249297636569695, 0.008638979168841135, 0.003091041831374855], + [ + [0.971770511193381, 0.023073568897462152, 0.00879158563716919, 0.0030468782543633132], + [0.9717033132764651, 0.023249297636569695, 0.008638979168841135, 0.003091041831374855], + [0.9716312798157171, 0.02356676356656341, 0.00830169937204417, 0.003182927930635947], + ], ), // pinned in `cargo xtask corpus` (RawDB `ILCE-7RM6_APSC_land_RAW.ARW`, CC0) ( @@ -650,7 +654,11 @@ fn corpus_sony_lens_tables_become_opcodes() { (6656, 4608), (16, 10, 6528, 4352), [0.49278737791134486, 0.4743868026915563], - [1.016366854640611, -0.0688824189782517, 0.03615745253291194, -0.009438825010476538], + [ + [1.0165959888027158, -0.0690974265041942, 0.036328960724338874, -0.009486844437228997], + [1.016366854640611, -0.0688824189782517, 0.03615745253291194, -0.009438825010476538], + [1.0162457267369314, -0.0686225599869027, 0.035974238433727206, -0.009384155126102897], + ], ), ]; let mut seen = 0; @@ -679,10 +687,12 @@ fn corpus_sony_lens_tables_become_opcodes() { }) .collect(); let [(planes, got)] = warps.as_slice() else { panic!("{name}: {:?}", img.opcodes.list3) }; - assert_eq!(planes.len(), 1, "{name}: one plane (G)"); + assert_eq!(planes.len(), 3, "{name}: R, G, B planes"); assert!((got[0] - center[0]).abs() < 1e-6 && (got[1] - center[1]).abs() < 1e-6, "{name}: centre {got:?}, want {center:?}"); - for (i, want) in k.iter().enumerate() { - assert!((planes[0][i] - want).abs() < 1e-6, "{name}: k{i} {}, want {want}", planes[0][i]); + for (plane, (p, k)) in ["R", "G", "B"].iter().zip(planes.iter().zip(k)) { + for (i, want) in k.iter().enumerate() { + assert!((p[i] - want).abs() < 1e-6, "{name}: {plane} k{i} {}, want {want}", p[i]); + } } seen += 1; } From d982a073c940fb9cf8ccfdd67e47d1639ac4b71d Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Sat, 10 Oct 2026 19:55:51 +0200 Subject: [PATCH 4/6] LR-EDIT-OPTICS-PROFILE: Sony embedded corrections on by default for ARW An ILCE-7RM6 ARW with Sony's distortion table now imports with Enable Lens Corrections on: Photo::camera_defaults already turns the correction on for any file whose probe yields embedded lens data, and the ARW decoder emits it since the previous commit. No new default rule is needed, so RW2 and DNG keep theirs (on when the file carries a correction) and stored settings are never rewritten. Tests pin it: a synthetic ARW imports with the correction on (another body's tables are not read: off), RW2 / DNG defaults are unchanged, and an ARW catalogued without lens data or switched off by the user keeps its settings across a library reopen. The Optics panel's hint drops "(DNG lens opcodes only)" in every locale. Co-Authored-By: Claude --- crates/engine/src/files.rs | 108 ++++++++++++++++++++++++++++ crates/ui-egui/locales/de.json | 2 +- crates/ui-egui/locales/es.json | 2 +- crates/ui-egui/locales/fr.json | 2 +- crates/ui-egui/locales/ja.json | 2 +- crates/ui-egui/locales/pt-br.json | 2 +- crates/ui-egui/locales/ru.json | 2 +- crates/ui-egui/locales/uk.json | 2 +- crates/ui-egui/locales/zh-hans.json | 2 +- crates/ui-egui/locales/zh-hant.json | 2 +- crates/ui-egui/src/panels/edit.rs | 4 +- 11 files changed, 118 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index dc79b8b31..3256ee61c 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -2296,4 +2296,112 @@ mod tests { assert_eq!(s.quick_view_job(id, 1600, true).unwrap().run().quick, Some(QuickSource::Small)); let _ = std::fs::remove_dir_all(&dir); } + + /// A Sony ARW of `model`: a 64 × 48 uncompressed 14-bit CFA strip in a SubIFD (as `crates/raw/tests/vendor_robust.rs` + /// lays it out) whose IFD carries the full-frame 16–35 mm distortion table (tag 0x7037). + fn sony_arw(model: &str) -> Vec { + use lightcraft_tiff::tags::{self as t, photometric}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; + // 3:2, the aspect Sony's lens opcodes are limited to + let (w, h) = (72u32, 48u32); + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![14])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + raw.set(0x7037, Value::SShort(vec![16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479])); + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![2000u16.to_le_bytes().repeat((w * h) as usize)] }); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("SONY".into())); + ifd0.set(t::MODEL, Value::Ascii(model.into())); + ifd0.add_sub_ifd(raw); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() + } + + /// Sony's embedded distortion correction is on by default: an ARW of a verified body imports with "Enable Lens + /// Corrections" on and still counts as unedited; another body's tables aren't read, so its correction is off. + #[test] + fn arw_with_lens_data_imports_with_profile_corrections_on() { + let dir = std::env::temp_dir().join(format!("lc-arw-lens-default-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = crate::Session::new().with_fs(); + for (model, on) in [("ILCE-7RM6", true), ("ILCE-7M4", false)] { + let path = dir.join(format!("{model}.arw")); + std::fs::write(&path, sony_arw(model)).unwrap(); + let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + let p = s.catalog.photo(lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap())).unwrap(); + assert_eq!((p.format.as_str(), p.embedded_lens.is_some(), p.develop.optics.lens_profile), ("ARW", on, on), "{model}"); + assert!(!p.is_edited(), "{model}: as imported"); + } + let _ = std::fs::remove_dir_all(&dir); + } + + /// RW2 and DNG keep their default: the correction is on when the file carries one (RW2 distortion, a DNG's + /// opcodes), off when it doesn't. + #[test] + fn rw2_and_dng_defaults_unchanged() { + use lightcraft_catalog::{Photo, PhotoId, Source}; + let s = crate::Session::new(); + let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), ..Default::default() }; + for format in ["RW2", "DNG"] { + for embedded in [None, Some(lens)] { + let name = format!("x.{format}"); + let mut p = Photo::new(PhotoId(1), Source::File { path: format!("/{name}") }, &name, format, 64, 48, ""); + p.kind = MediaKind::Raw; + p.embedded_lens = embedded; + crate::import::apply_import_defaults(&s, &mut p); + assert_eq!(p.develop.optics.lens_profile, embedded.is_some(), "{format} {embedded:?}"); + } + } + } + + /// Stored settings stay as they are when the library opens again: an ARW catalogued before its lens data was read + /// (none, correction off) keeps the correction off and counts as unedited, and one whose user switched the + /// correction off keeps it off. + #[test] + fn existing_photo_settings_are_not_rewritten() { + use lightcraft_catalog::{Op, PhotoId}; + let dir = std::env::temp_dir().join(format!("lc-arw-lens-existing-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (lib, files) = (dir.join("lib"), dir.join("files")); + std::fs::create_dir_all(&files).unwrap(); + let mut s = crate::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let import = |s: &mut crate::Session, name: &str| { + let path = files.join(name); + // trailing bytes of its own: not a duplicate of the other file + std::fs::write(&path, [sony_arw("ILCE-7RM6"), name.as_bytes().to_vec()].concat()).unwrap(); + let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + PhotoId(r["imported"][0].as_u64().unwrap()) + }; + let old = import(&mut s, "old.arw"); + // as a release that didn't read the Sony tables catalogued it + let mut d = (*s.catalog.photo(old).unwrap().develop).clone(); + d.optics.lens_profile = false; + let ops = vec![ + Op::SetEmbeddedLens { id: old, lens: None }, + Op::SetDevelop { id: old, settings: std::sync::Arc::new(d), label: "x".into(), edited: None }, + ]; + s.commit("x", Op::Batch { ops }).unwrap(); + let off = import(&mut s, "off.arw"); + s.execute("develop.merge", &serde_json::json!({"settings": {"optics": {"lens_profile": false}}})).unwrap(); + let before: Vec<_> = [old, off].iter().map(|id| s.catalog.photo(*id).unwrap().clone()).collect(); + assert!(before.iter().all(|p| !p.develop.optics.lens_profile)); + s.persist().unwrap(); + drop(s); + let mut s = crate::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + for b in &before { + let p = s.catalog.photo(b.id).unwrap(); + assert_eq!((&p.develop, &p.embedded_lens), (&b.develop, &b.embedded_lens), "{}", p.file_name); + } + assert!(!s.catalog.photo(old).unwrap().is_edited(), "still as imported"); + drop(s); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..ea37b0403 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -945,7 +945,7 @@ "No edits yet.": "Noch keine Bearbeitungen.", "No exposure information": "Keine Belichtungsinformationen", "No filter presets yet": "Noch keine Filtervorgaben", - "No lens data embedded in this file (DNG lens opcodes only).": "Keine Objektivdaten in dieser Datei eingebettet (nur DNG-Objektiv-Opcodes).", + "No lens data embedded in this file.": "Keine Objektivdaten in dieser Datei eingebettet.", "No masks yet. Choose a mask type above.": "Noch keine Masken. Wähle oben einen Maskentyp.", "No matching commands": "Keine passenden Befehle", "No matching photos": "Keine passenden Fotos", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..0df2ebecb 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -920,7 +920,7 @@ "No edits yet.": "Aún no hay ediciones.", "No exposure information": "Sin información de exposición", "No filter presets yet": "Aún no hay ajustes preestablecidos de filtro", - "No lens data embedded in this file (DNG lens opcodes only).": "Este archivo no incluye datos de objetivo (solo opcodes de objetivo DNG).", + "No lens data embedded in this file.": "Este archivo no incluye datos de objetivo.", "No masks yet. Choose a mask type above.": "Aún no hay máscaras. Elegir un tipo de máscara arriba.", "No matching commands": "Ningún comando coincide", "No matching photos": "No hay fotos coincidentes", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..2035f615c 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -897,7 +897,7 @@ "No edits yet.": "Aucune retouche pour l'instant.", "No exposure information": "Aucune information d'exposition", "No filter presets yet": "Aucun préréglage de filtre pour l'instant", - "No lens data embedded in this file (DNG lens opcodes only).": "Aucune donnée d'objectif intégrée à ce fichier (opcodes d'objectif DNG uniquement).", + "No lens data embedded in this file.": "Aucune donnée d'objectif intégrée à ce fichier.", "No masks yet. Choose a mask type above.": "Aucun masque pour l'instant. Choisissez un type de masque ci-dessus.", "No matching commands": "Aucune commande correspondante", "No matching photos": "Aucune photo correspondante", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..41f5d9004 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -949,7 +949,7 @@ "No edits yet.": "まだ編集していません", "No exposure information": "露出情報がありません", "No filter presets yet": "フィルタープリセットはまだありません", - "No lens data embedded in this file (DNG lens opcodes only).": "このファイルにレンズ情報は含まれていません(DNGのレンズ情報のみ対応)", + "No lens data embedded in this file.": "このファイルにレンズ情報は含まれていません", "No masks yet. Choose a mask type above.": "マスクはありません。上から種類を選択してください", "No matching photos": "一致する写真がありません", "No metadata found": "メタデータがありません", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 2e21c727c..ea90aee8f 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -909,7 +909,7 @@ "No edits yet.": "Ainda sem edições.", "No exposure information": "Sem informações de exposição", "No filter presets yet": "Ainda não há predefinições de filtro", - "No lens data embedded in this file (DNG lens opcodes only).": "Nenhum dado de lente incorporado neste arquivo (somente opcodes de lente DNG).", + "No lens data embedded in this file.": "Nenhum dado de lente incorporado neste arquivo.", "No masks yet. Choose a mask type above.": "Ainda não há máscaras. Escolha um tipo de máscara acima.", "No matching commands": "Nenhum comando corresponde", "No matching photos": "Nenhuma foto correspondente", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..8107b2d0a 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -752,7 +752,7 @@ "No edits yet.": "Правок пока нет.", "No exposure information": "Нет данных об экспозиции", "No filter presets yet": "Пресетов фильтров пока нет", - "No lens data embedded in this file (DNG lens opcodes only).": "В этом файле нет данных об объективе (только опкоды объектива DNG).", + "No lens data embedded in this file.": "В этом файле нет данных об объективе.", "No masks yet. Choose a mask type above.": "Масок пока нет. Выберите тип маски выше.", "No matching commands": "Подходящих команд нет", "No matching photos": "Нет подходящих фото", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..6fa4c2e46 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -1086,7 +1086,7 @@ "No edits yet.": "Змін поки немає.", "No exposure information": "Немає інформації про експозицію", "No filter presets yet": "Наборів налаштувань фільтрів поки немає", - "No lens data embedded in this file (DNG lens opcodes only).": "У цьому файлі немає даних об’єктива (лише інструкції об’єктива DNG).", + "No lens data embedded in this file.": "У цьому файлі немає даних об’єктива.", "No masks yet. Choose a mask type above.": "Масок поки немає. Виберіть тип маски вище.", "No matching photos": "Немає відповідних фото", "No metadata found": "Метаданих не знайдено", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..8c68cb7e3 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -971,7 +971,7 @@ "No edits yet.": "尚未编辑。", "No exposure information": "无曝光信息", "No filter presets yet": "暂无筛选器预设", - "No lens data embedded in this file (DNG lens opcodes only).": "此文件中未嵌入镜头数据 (仅支持 DNG 镜头操作码)。", + "No lens data embedded in this file.": "此文件中未嵌入镜头数据。", "No masks yet. Choose a mask type above.": "尚无蒙版。请在上方选择蒙版类型。", "No matching photos": "没有匹配的照片", "No metadata found": "未找到元数据", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..6f3cf4389 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -971,7 +971,7 @@ "No edits yet.": "尚未編輯。", "No exposure information": "沒有曝光資訊", "No filter presets yet": "尚無篩選預設集", - "No lens data embedded in this file (DNG lens opcodes only).": "此檔案未內嵌鏡頭資料(僅支援 DNG 鏡頭操作碼)。", + "No lens data embedded in this file.": "此檔案未內嵌鏡頭資料。", "No masks yet. Choose a mask type above.": "尚無遮色片。請在上方選擇遮色片類型。", "No matching photos": "沒有符合的照片", "No metadata found": "找不到中繼資料", diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 76a54171f..bce9b1c2d 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -367,9 +367,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } if lp && !has_lens { ui.label( - egui::RichText::new(crate::i18n::tr("No lens data embedded in this file (DNG lens opcodes only).")) - .size(11.0) - .color(Tokens::get(ui.ctx()).text_dim), + egui::RichText::new(crate::i18n::tr("No lens data embedded in this file.")).size(11.0).color(Tokens::get(ui.ctx()).text_dim), ); } }); From 33c362a7e4fd1331e83a8a7dc775831008da56fd Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Sat, 10 Oct 2026 22:35:34 +0200 Subject: [PATCH 5/6] LR-EDIT-OPTICS-PROFILE: Sony lens corrections verified on the ILCE-7RM6; docs and tracker docs/sony-ilce7rm6-lens-corrections.md records the three Sony tables, the measured conventions with their residuals, the conversion to WarpRectilinear and its fit gate, the model allowlist and how to extend it, the measurement method, the verification and the limits. Verified locally on 54 ILCE-7RM6 photos with four lenses. The corrected render lines up with the camera JPEG within 0.679 px at 1440 px at the corners and edge midpoints of the 33 measurable photos shot with in-camera correction on (up to 17.3 px uncorrected). The 13 controls show the model's own shift. Every fit passes the 0.25 px gate (margin 0.075). Lateral CA stays within 0.3 px, but applied after demosaic it raises the render's corner CA (rms 0.032 -> 0.052 px); the doc and tracker say so. Its "Two conventions" section sets these conventions next to the ILCE-7RM4A's (vendor::arw_lens, docs/sony-lens-corrections.md, #498): on the 34 measurable ILCE-7RM6 photos that formula gives 1.429 / 0.319 px (max / rms) against 0.596 / 0.143, failing only on the 24-70 GM II and fitting the 100 mm and 200-600 better; no single unit fits every lens under either, and what would settle it (Sony On/Off exports of a wide zoom, more lenses). docs/sony-lens-corrections.md gains a pointer to it. Tracker rows LR-EDIT-OPTICS-PROFILE (one note for both bodies) and LR-IMP-CAMERA-COVERAGE in docs/parity-checklist.md, gap 8 in docs/gaps.md, the lens-profile section of docs/raw-parity.md (lens score unchanged), whats-new (lossless ARWs now; Compressed / Compressed HQ once the ARW 6.0 decoder, #625, lands) and the colour-fit follow-up note are updated. Co-Authored-By: Claude --- docs/camera-preview-colour.md | 2 +- docs/gaps.md | 3 +- docs/parity-checklist.md | 4 +- docs/raw-parity.md | 4 +- docs/sony-ilce7rm6-lens-corrections.md | 301 +++++++++++++++++++++++++ docs/sony-lens-corrections.md | 5 + docs/whats-new.md | 12 + 7 files changed, 326 insertions(+), 5 deletions(-) create mode 100644 docs/sony-ilce7rm6-lens-corrections.md diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 1210e6b54..3b4e076d9 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -17,7 +17,7 @@ Matrix-only and matrix + table looks are each completed (tone curve, then chroma The matrix is applied to scene-linear sensor pixels. The curve is carried in `SourceInfo` and applied in the pipeline finish stage, after exposure/WB/local edits. It is never baked into decoded luminance or clipped to JPEG precision. CPU and GPU use the same tone LUT. Contrast, whites and blacks adjust the starting look through the display-domain controls. JPEG neutral rendering and the existing DNG colour model are unchanged. -One third of the pixels are held out from both fits. The combined model must reduce linear-display squared error by at least 30% and have per-channel RMS error ≤0.10 (0.055 rejected half of the public raw.pixls.us samples tried — ILCE-6400, -6700, -7M4 — whose fits were still 1.5–3× closer to the camera JPEG than the fallback and visibly better). When the fit on all pixels fails these gates, it is tried once more, with the same gates, on the pixels away from edges (where the 3×3 neighbourhood in either proxy spans a luminance ratio above 3). Many camera JPEGs are lens-corrected while the raw is not (Sony "Distortion Comp.: Auto", compacts and kit zooms), and on a detailed scene the misaligned edges alone fail the gate although the colours agree everywhere else (issue #232). The public ILCE-7RM2 samples (raw.pixls.us, one façade shot in seven compression/correction variants, 24–70 mm at 51 mm) are up to 15 px of 1440 off their JPEG; the six with distortion correction on missed the gate at held-out RMS 0.108–0.115 and opened in the grey neutral fallback, 13 L* too dark (ΔE76 16.6–16.9 against the JPEG after registering its geometry). Away from edges they pass at 0.049–0.052 and land at ΔE 3.7–3.8 with lightness within 0.6 L*, like the variant shot with distortion correction off (0.059 on all pixels, ΔE 3.9). The corpus GX80 kit-zoom sample (up to 63 px of 1440 off) passes too: 0.107 → 0.081, ΔE 19.1 → 7.9. Fits accepted on all pixels are unchanged. These are provisional acceptance gates, not a colour-accuracy certification. Singular, unrelated and aspect-mismatched references retain the documented fallback; poor-fitting ones get the partial look below when it is clearly closer to the camera JPEG, and black-and-white ones only their tone curve (below). Both fits use the same fixed proxy at every decode resolution. +One third of the pixels are held out from both fits. The combined model must reduce linear-display squared error by at least 30% and have per-channel RMS error ≤0.10 (0.055 rejected half of the public raw.pixls.us samples tried — ILCE-6400, -6700, -7M4 — whose fits were still 1.5–3× closer to the camera JPEG than the fallback and visibly better). When the fit on all pixels fails these gates, it is tried once more, with the same gates, on the pixels away from edges (where the 3×3 neighbourhood in either proxy spans a luminance ratio above 3). Many camera JPEGs are lens-corrected while the raw is not (Sony "Distortion Comp.: Auto", compacts and kit zooms), and on a detailed scene the misaligned edges alone fail the gate although the colours agree everywhere else (issue #232). Sony's embedded lens tables now correct ILCE-7RM6 renders under Enable Lens Corrections ([Sony ARW lens corrections](sony-ilce7rm6-lens-corrections.md)), but this fit still matches its pairs without them (the sensor proxy is uncorrected); aligning the pairs first is a follow-up. The public ILCE-7RM2 samples (raw.pixls.us, one façade shot in seven compression/correction variants, 24–70 mm at 51 mm) are up to 15 px of 1440 off their JPEG; the six with distortion correction on missed the gate at held-out RMS 0.108–0.115 and opened in the grey neutral fallback, 13 L* too dark (ΔE76 16.6–16.9 against the JPEG after registering its geometry). Away from edges they pass at 0.049–0.052 and land at ΔE 3.7–3.8 with lightness within 0.6 L*, like the variant shot with distortion correction off (0.059 on all pixels, ΔE 3.9). The corpus GX80 kit-zoom sample (up to 63 px of 1440 off) passes too: 0.107 → 0.081, ΔE 19.1 → 7.9. Fits accepted on all pixels are unchanged. These are provisional acceptance gates, not a colour-accuracy certification. Singular, unrelated and aspect-mismatched references retain the documented fallback; poor-fitting ones get the partial look below when it is clearly closer to the camera JPEG, and black-and-white ones only their tone curve (below). Both fits use the same fixed proxy at every decode resolution. **A partial look when the full look misses the gates.** The neutral fallback (camera RGB taken as sRGB, the default tone map) is far from the camera JPEG. Rendered for every photo on this path in the CC0 archive (1,150 files with a camera JPEG), it is 10.7 L\* too dark (median; about one stop), keeps 0.36 of the camera's colourfulness and has hues 7° off on the coloured pixels (median CIEDE2000 13.6). Its white balance is right: on the pixels the camera renders near-neutral the cast is under 1 a\*/b\* (median, every maker). The "blue" fallback on newer Sony bodies (Δb\* −22 over the whole frame on the ILCE-7RM5 sample) is a warm scene losing its colour, not a cast: its near-neutral pixels are within −1.6 b\*. So before falling back, a partial look is fitted (`fit_partial`): the quantile tone curve (registration-free, the largest part of the error), the chroma curve, and, when the scene has enough colour to learn a matrix (the full look's condition), the photo's own colour matrix at half weight, the other half the identity (`PARTIAL_MATRIX_WEIGHT`). No hue/saturation table. Half weight because, on the held-out proxy pixels of the 23 colourful photos without a look, the full matrix left 2 of them more than 2° further from the camera's hues than the neutral fallback and half weight none (median hue error 9.6° with the generic colour, 5.1° at half weight, 4.2° at full weight; on the 1,080 photos with a look 3.9° and 2.7°). Without a matrix (tone and chroma curve alone, or with one global saturation scale) about a third of all photos ended up more than 2° further from the camera's hues than the fallback, as the added colour makes the generic matrix's hue errors visible. The partial look is tried on all pixels, then away from edges, and used only when it cuts the held-out squared error below 0.7 of the neutral fallback's in linear and in gamma-encoded display values, and when the JPEG's luminance ranks the pixels like the scene's (Spearman ≥ 0.5, `PARTIAL_MIN_RANK_CORRELATION`): a curve matched to the brightness of an unrelated picture also beats the dark fallback pixel for pixel. The accepted full looks have 0.55 or more; three old Coolpix files whose raws decode wrongly have 0.07–0.27 and keep the fallback. There is no limit on the partial look's own error. On the archive (1,783 renders): all 1,087 full looks are byte-identical; of the 55 photos that reach the partial fit, 49 get it (40 on all pixels, 9 away from edges; 13 with the damped matrix, 41 with a chroma curve) and 6 keep the fallback (lens-corrected compacts: RX10, TZ80 1:1, D-LUX 4, G10 16:9, GF2 ×2). On the 49, median CIEDE2000 to the camera JPEG 14.0 → 2.1, ΔL\* −11.5 → +0.6, colourfulness 0.36 → 0.87, hue error 10.1° → 7.1°; every one is closer, by at least 1.3. Most of the others without a look never reach a fit: on 48 files (Nikon D4, Df, D300/D300S, D7000, D5100 and others) the camera JPEG proxy comes out a pixel narrower than the sensor proxy and no pairs are collected (the resize in PR #531 fixes this; with it 46 of them pass the full look's gates, CIEDE2000 17.9 → 2.9). diff --git a/docs/gaps.md b/docs/gaps.md index 39e6c6486..2a80452ac 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -22,7 +22,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 5 B | **AI masks: Subject, Sky, People, Background, Landscape** are heuristics; Object / Describe run SAM 3 but its download has no mirror configured | `crates/segment`; ai-masks.md; MASK rows 67% | masking is how most Lightroom users edit today | 40–70 + model decision | [target-app-parity](target-app-parity.md#ai) | | 6 B | **Compressed ORF** (every Olympus / OM body since ~2008) | measured: 66 of 78 Olympus / OM models preview-only; `orf.rs` refuses; OM bucket 3% of photographers, 3% of them fully work | +2.6 points; best gain per hour of any raw gap (~0.2) | 8–15 | [raw-parity](raw-parity.md#compressed-orf-a-clean-room-starting-point) (the two Olympus patents that describe the method) | | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | -| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony ILCE-7RM4A distortion | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | +| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony in-file distortion on two bodies (ILCE-7RM4A; ILCE-7RM6, also lateral CA), each with its own conventions; other Sony bodies' tables not applied until verified | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | | 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 236 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 236 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | @@ -89,6 +89,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-10 | minor | Compressed ORF entry links the two Olympus patents that describe the method | | 2026-10-11 | minor | Gap 14: `crs:HDREditMode` is read from XMP (#663); the headroom limit and SDR rendition still aren't | | 2026-10-11 | minor | Gaps 2 and 10 shrink: EOS R5 Mark II CR3 decodes and is verified (fourth CR3 body, Canon 12 verified, 235 models, ~42% on a verified body); CR3 gain +6.5 → +6.2 points | +| 2026-10-10 | minor | Gap 8: Sony ILCE-7RM6 in-file distortion and lateral CA added next to the ILCE-7RM4A's (docs/sony-ilce7rm6-lens-corrections.md); the gap stays open (no lens database), hours unchanged | | 2026-10-10 | minor | RAW gaps re-measured model by model (raw-parity.md): CR3, compressed ORF and verification entries carry measured shares; HE NEF no longer a beta blocker (+1.1 points for 20–40 h); verification entry absorbs the cheap per-body fixes (#769, #702, A200 series) | | 2026-10-10 | minor | Beta total raised to 340–600 h to include the ~92 open core-path user issues | | 2026-10-10 | major | Created from the checklist's Top gaps (whose items 1–2 had been duplicated by a merge) and ROADMAP's Where we stand; re-ranked; added evidence, impact, hours and beta flags; added platform-CI, lens-database, HE NEF and localization gaps | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..b511764da 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -81,7 +81,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Sony lens corrections are per model too: the distortion table is applied only on the ILCE-7RM4A (verified against Sony's exports, `docs/sony-lens-corrections.md`) and the ILCE-7RM6 (verified against the camera JPEG on 54 photos: 41 shot with in-camera correction on, three lenses (a fourth, the 16–35 mm, only as a control with correction off), `docs/sony-ilce7rm6-lens-corrections.md`), with different conventions; each further body needs the same check. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | @@ -211,7 +211,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `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); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | +| 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/raw/src/vendor/arw_lens.rs`, `crates/raw/src/vendor/arw_lens_rm6.rs`, `crates/engine/src/crs.rs`, `docs/sony-lens-corrections.md`, `docs/sony-ilce7rm6-lens-corrections.md` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `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); Sony ARW raw-IFD lens tables use the same warp path on two bodies, each with the conventions it was validated with, selected by camera model: the **ILCE-7RM4A**'s distortion table (`0x7037`, Bayer, 16-sample layout, native 3:2 framing; validated against Sony Imaging Edge On/Off exports with the FE 24–105 and FE 200–600 lenses, `docs/sony-lens-corrections.md`) and the **ILCE-7RM6**'s distortion and lateral CA tables (`0x7037`, `0x7035`) as one three-plane `WarpRectilinear` (measured against the camera JPEG, `docs/sony-ilce7rm6-lens-corrections.md`; on the 33 measurable of 41 photos shot with in-camera correction on, three lenses (a fourth, the 16–35 mm, only as a control with correction off), the corrected render lines up with the camera JPEG within 0.679 px at 1440 px at the corners and edge midpoints, from up to 17.3 px uncorrected); the two conventions differ in unit and table index, the ILCE-7RM4A's misses the ILCE-7RM6's 24–70 GM II by up to 1.43 px, and no single unit fits every lens under either (`docs/sony-ilce7rm6-lens-corrections.md` → Two conventions); the ILCE-7RM6's CA unit is provisional and, applied after demosaic, raises the render's corner CA (rms 0.032 → 0.052 px, within the 0.3 px budget); its vignetting table is read but not applied (the camera's JPEGs don't apply it; no model fits); other Sony bodies' tables are not applied until verified; ILCE-7RM6 APS-C distortion and in-camera aspect-ratio crops unverified; on by default for new imports with embedded lens data; an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); 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 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); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | ) | 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` | | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index e3fdf5fcf..d07753b6e 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -7,6 +7,7 @@ does not decode, or decodes with the wrong colour, cannot switch. This document model. The numbers feed [target-app-parity.md](target-app-parity.md); individual shortfalls are ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), [raf-compression.md](raf-compression.md), [sony-lens-corrections.md](sony-lens-corrections.md), +[sony-ilce7rm6-lens-corrections.md](sony-ilce7rm6-lens-corrections.md), [camera-preview-colour.md](camera-preview-colour.md), [denoise.md](denoise.md). ## Headline @@ -219,7 +220,7 @@ essentials user (the default look is what they keep). | Item | Adobe | LightCraft | |---|---|---| | Profile database | 3,635 profile files, 56 makers, auto-selected | **0** (Adobe LCPs never used; no database of our own) | -| In-file corrections | ✅ | DNG opcodes (WarpRectilinear, FixVignetteRadial, GainMap…) applied; Panasonic / Leica RW2 distortion (79 photos, 13 bodies, 11 lenses); Sony tag 0x7037 distortion for the ILCE-7RM4A (2 lenses) | +| In-file corrections | ✅ | DNG opcodes (WarpRectilinear, FixVignetteRadial, GainMap…) applied; Panasonic / Leica RW2 distortion (79 photos, 13 bodies, 11 lenses); Sony tag 0x7037 distortion for the ILCE-7RM4A (2 lenses, checked against Sony's exports) and the ILCE-7RM6 (three lenses, a fourth, the 16–35 mm, only as a control with correction off; checked against the camera JPEG), with different conventions ([sony-ilce7rm6-lens-corrections.md](sony-ilce7rm6-lens-corrections.md#two-conventions)); the ILCE-7RM6's raw-IFD lateral CA table too (provisional unit, applied after demosaic), not its raw-IFD vignetting table | | Vignetting / lateral CA from maker notes | ✅ | ⬜ | | Manual distortion, vignetting, CA, defringe | ✅ | ✅ | @@ -281,5 +282,6 @@ Beyond decoding: | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | | 2026-10-12 | minor | PEF without area tags: masked border rows and columns trimmed against the BlackPoint (issue #1271; KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z); counts unchanged | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | +| 2026-10-10 (later) | minor | Lens profiles: Sony ILCE-7RM6 in-file distortion and lateral CA next to the ILCE-7RM4A's distortion; lens score unchanged (~10%: one more body) | | 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | | 2026-10-10 | major | Created: formats, makers, demosaic, colour and lens checklists; target counts from the installed Lightroom 9.6 bundle listing | diff --git a/docs/sony-ilce7rm6-lens-corrections.md b/docs/sony-ilce7rm6-lens-corrections.md new file mode 100644 index 000000000..64963fcac --- /dev/null +++ b/docs/sony-ilce7rm6-lens-corrections.md @@ -0,0 +1,301 @@ +# Sony ARW lens corrections + +Sony cameras record three lens-correction tables in the raw IFD of every ARW: distortion, vignetting and lateral +chromatic aberration (CA). LightCraft reads them and turns distortion and lateral CA into a DNG `WarpRectilinear` +opcode, so they reach the same **Optics ▸ Enable Lens Corrections** (`optics.lens_profile`) as a DNG's embedded warp +and Panasonic's RW2 distortion data. Vignetting is read but not applied (below). The conversion is verified on the +**ILCE-7RM6** only; the ILCE-7RM4A has its own conversion, with other conventions ([Two conventions](#two-conventions)), +and other Sony bodies' tables give no correction until they are verified. + +The correction is on by default for new imports, as for any file with embedded lens data (`Photo::camera_defaults`). +Photos imported before this change keep their settings: **Photo → Reload from Disk** adds the lens data and, when the +photo is still at its import settings, turns the correction on as one undoable step; the automatic reload after an +external editor saves the file does the same. Edited photos keep their settings. The Profile Distortion slider +scales the warp (all three colour planes) as for DNG and RW2. + +## The tables + +ExifTool's names (EXIF table, `SubIFD` group); each table is a count-prefixed array of signed 16-bit values +(`SSHORT`, 17 / 17 / 33 values), and each has an in-camera on/off tag: + +| Table | Tag | Flag tag | Count (full frame / APS-C crop mode) | Flag values seen | +|---|---|---|---|---| +| Distortion (`DistortionCorrParams`) | `0x7037` | `0x7036` | 16 / 11 | 0 (off) or 1 (on) | +| Vignetting (`VignettingCorrParams`) | `0x7032` | `0x7031` | 16 / 11 | 2 on all 54 photos | +| Lateral CA (`ChromaticAberrationCorrParams`) | `0x7035` | `0x7034` | 32 / 22 (red, then blue) | 1 on all 54 photos | + +The first value is the knot count; only the first `count` values are used. Full-frame files carry 16 / 16 / 32 knots; +APS-C crop-mode files 11 / 11 / 22, padded to the same array length (distortion with its last value repeated, +vignetting and CA with zeros). The CA table holds red's `count / 2` knots, then blue's. The corrections are built +whatever the flags say: the flag only records what the camera did to its own JPEG. Tables with a count outside +2–16 (CA: an odd count or outside 4–32), a count that doesn't fit the array, or non-integer values are ignored. + +## Measured conventions + +None of this is documented; it was measured against the camera's own JPEGs (method below). Residuals are in px at a +1440 px long edge. + +- **Knots and centre.** Knot `i` sits at sensor radius `i · s` from the default-crop centre, with + `s = hypot(9984, 6656) / 30` = 399.98 px: the full-frame default crop's half diagonal divided by 15, so knot 15 is + the full-frame crop corner. The spacing is fixed on the sensor: APS-C files use the same pixel spacing (verified on + CA, rms 0.004–0.010 px against 0.011–0.014 px for `i/15` of the APS-C half diagonal; `i/10` of it, 392 px, fits as + well). Values are interpolated linearly between + knots; beyond the last knot the last segment is extended (it matters only in the outermost ~0.4 knot spacing at + the corners of a zoomed-out barrel correction). The centre is the default-crop centre: the measured translation + between our render and the camera JPEG has median 0.013 px, max 0.216 px. +- **Distortion** is relative and indexed at the corrected radius, in units of 1/15000: a corrected radius `r` + reads the source radius `S(r) = r · (1 + v(r) / 15000)` (positive values: pincushion; negative: barrel). The camera + then zooms so the output fills the default crop: output radius = `z` · corrected radius, with `z` chosen so that + the largest `S(ρ / z) / ρ` over the crop's border radii `ρ` is 1 (binding at the corner for pincushion, at the + short-edge midpoint for barrel). `z` was 0.971–1.035 on these photos. Without the zoom every hypothesis fails + (24.6 px); with it, the worst residual over the 34 measurable corrected photos was 0.596 px (rms 0.143; corners and + edge midpoints 0.570 px), within the 0.75 px budget for a unit between about 14700 and 15350. A global + least-squares fit gives 15000.7. +- **Lateral CA** is relative to the green source radius, at that radius: red's source radius is + `g · (1 + c_R(g) / 3.36·10⁶)`, with `g` the green source radius (after the distortion correction above) and `c_R` + the red half on the same knots; blue likewise with the second half. Positive values: that colour's image is larger + than green's. **The unit is provisional.** The measurement is borderline: on the 15 photos with CA above 0.15 px + that a single unit explains, the best (minimax) unit 2.26·10⁶ leaves 0.305 px against a 0.3 px budget; per-photo + best units scatter from 1.5·10⁶ to 7.2·10⁶ (about ±30 % scene bias: the RawDB landscape and portrait shots of one + scene, with nearly identical tables, measure 3.4–3.5·10⁶ and 4.5·10⁶). The chosen 3.36·10⁶ is the median of the 20 + cleanest photos: worst 0.399 px on the 15, and the rms raw CA over all 46 measured photos falls from 0.079 to + 0.052 px. Swapping the halves or an absolute form (`v · norm / U`) is clearly worse. +- **Vignetting: not applied.** The camera JPEGs show no vignetting compensation relative to our uncorrected render: + over the 39 photos whose measurement reaches the corners, the corner/centre ratio JPEG / render has median 0.996 + (p10 0.907, p90 1.087), while the tables would predict a corner gain with median +32 % for `1 + v / 2¹⁴` (+16 % + for `v / 2¹⁵`, +63 % for `v / 2¹³`). Regressing the measured corner EV on the predicted one gives slope 0.009 ± + 0.063. No model (`1 + v/U`, `U/(U − v)`, `2^(v/U)`) at any unit from 2¹³ to 2¹⁸ reaches 3 %; the identity is the + best of all. The raw data isn't pre-compensated either: the read noise of three dark high-ISO frames is flat from + centre to corner, where a digital gain from these tables would raise it by 60–80 %. So the table probably describes + the lens's natural falloff, but this camera's JPEGs don't apply it, and nothing shows its unit or model. Options: + (a) skip it, so renders match the camera JPEG (the current ruling); (b) apply it with an unverified model for a + Lightroom-like look; (c) shoot flat fields (a white wall at several apertures, one lens) and measure the model. + +## Conversion to `WarpRectilinear` + +`crates/raw/src/vendor/arw_lens_rm6.rs` builds one `WarpRectilinear` in `OpcodeList3`, centred on the default-crop centre +(DNG pixel-index units relative to the active area; radius 1 = the farthest active-area corner, `m`): + +1. `S` from the distortion table and the zoom `z` (bisection on [0.5, 2], 4097 border radii); the green map is + `G(r) = S(r / z)`. Red and blue: `G(r) · (1 + c(G(r)) / 3.36·10⁶)`. +2. Each map is sampled at `u = i / 64`, i = 0…64 (65 samples of `G(u · m) / m`) and fitted by least squares with + DNG's 4-term odd polynomial `u · (k0 + k1 u² + k2 u⁴ + k3 u⁶)` (`vendor::fit_radial_warp`, shared with RW2). +3. **Fit gate:** a plane is refused when its fit is worse than 0.25 px at a 1440 px long edge at a sample the crop + shows: the samples up to the first one at or beyond the crop corner. The lossless files' padded active area + (10240 × 7168 for a 9984 × 6656 crop) reaches radii that are never shown (`u` up to 0.925 is shown there). + Over all 54 photos the fit's worst error at all 65 samples was 0.220 px (a lossless file; ARW6 files at most + 0.175 px). Between samples the error is somewhat larger: 0.071 against 0.049 px for the full-frame 16–35 mm + example in the tests. +4. Planes `[R, G, B]`; when the CA table is absent or malformed, a CA value is implausible (|c| above 2·10⁻³ of the + radius, i.e. 6720; the largest value in the 54 photos' tables is 1724) or a colour plane fails the gate, G alone is applied to all + three channels. No opcode when the distortion table is absent or all zero (CA alone is never applied: it is + relative to green), the zoom has no solution in [0.5, 2] or the G fit fails. A refused table leaves the photo + uncorrected rather than wrongly warped. + +The opcode then goes through the same pipeline stage as a DNG's (`crates/pipeline/src/optics.rs`): it rotates with +the photo (portrait files are covered) and is carried by DNG export, as RW2's is. + +## Which bodies get it + +Only bodies listed in `FULL_FRAME_CROP` (`arw_lens_rm6.rs`) get an opcode: today `ILCE-7RM6` (9984 × 6656). The unit and +the knot spacing are verified on that body alone; the spacing is defined by its full-frame crop, and a body with +another sensor size or another unit would be warped wrongly. Other bodies get no correction from this conversion; the +ILCE-7RM4A gets its own, with other conventions (below). To add a body: collect photos of it shot with distortion +correction on (several lenses and focal lengths), repeat the camera-JPEG registration below against the uncorrected +render, confirm the unit, the knot spacing and the zoom rule (or measure the body's own), then add its full-frame +default crop to the table with a test. + +## Two conventions + +A second Sony decoder, `crates/raw/src/vendor/arw_lens.rs` +([Sony embedded distortion corrections](sony-lens-corrections.md), PR #498), reads the same `0x7037` table on the +**ILCE-7RM4A**, validated against Sony Imaging Edge exports (distortion On/Off pairs at 200 and 600 mm with the +FE 200–600 mm; corrected TIFFs at 24 mm with the FE 24–105 mm). Its conventions differ from the ones above +(`r_o` = output radius about the crop centre, `v` = the table, linear between knots): + +| | ILCE-7RM6 (this page, `arw_lens_rm6.rs`) | ILCE-7RM4A (`arw_lens.rs`) | +|---|---|---| +| Knots | `i · 399.98` px, fixed on the sensor; last segment extended | `i / 15` of the crop's half diagonal; radius clamped to 1 | +| Table indexed at | the corrected radius `r_o / z` | the output radius `r_o` | +| Unit | 15000 | 16384 | +| Zoom | fill: the largest `S(ρ / z) / ρ` over the border is 1 | `1 / max f` over the border radii | +| Aspect crops | applied; the zoom for the recorded crop is unverified | refused (native 3:2 only) | +| Tables | distortion (16 knots; 11 in APS-C) and lateral CA; vignetting read, not applied | distortion, 16 entries only | + +`arw::decode` passes both the same active area and crop (the DNG-style default crop, on the ILCE-7RM6 the same as +Sony's crop tags); their model lists don't overlap, so a photo gets at most one of them: an ILCE-7RM6 this page's +conversion, an ILCE-7RM4A the other, every other body neither. + +**Cross-check.** The ILCE-7RM4A formula, scored on the 34 measurable corrected ILCE-7RM6 photos exactly as in +Measured conventions (radial bins, px at 1440, max / rms; "c" = the worst corner or edge-midpoint point): + +| Variant | Unit | All photos | Corners max / median | 24–70 GM II (23) | 100 mm Macro (8) | 200–600 G (3) | +|---|---|---|---|---|---|---| +| **A**: this page | 15000 | **0.596 / 0.143** | 0.570 / 0.099 | 0.596 / 0.100, c 0.474 | 0.361 / 0.135, c 0.336 | 0.588 / 0.325, c 0.570 | +| **B1**: the ILCE-7RM4A formula | 16384 | **1.429 / 0.319** | 1.913 / 0.204 | 1.429 / 0.385, c 1.913 | 0.289 / 0.049, c 0.234 | 0.264 / 0.115, c 0.320 | +| **B2**: B1 at its best single unit | 15680 | 0.940 / 0.223 | 1.202 / 0.098 | 0.940 / 0.215, c 1.202 | 0.327 / 0.100, c 0.278 | 0.683 / 0.428, c 0.637 | +| **B3**: A's indexing and zoom at 16384 | 16384 | 1.450 / 0.455 | 1.845 / 0.350 | 1.450 / 0.539, c 1.845 | 0.284 / 0.062, c 0.238 | 0.654 / 0.329, c 0.586 | + +- B1 misses the 0.75 px budget on these photos, but only on the 24–70 GM II: at 24–28 mm it under-corrects the barrel + in the outer half (up to 1.43 px at 0.7–0.85 of the half diagonal), at 45–70 mm the pincushion by a nearly uniform + 0.2–0.4 px. On the 100 mm and the 200–600 it fits better than A, and on the 200–600, the one lens both validations + share, its unit agrees with this body's (best unit under its indexing 16524–16602). So the data does not show the + two bodies differing. +- The zoom rules agree (within 0.0035 on every photo); the unit and the table index make the difference. Under A's + indexing the 24–70 GM II's barrel and pincushion ends want the same unit (14749 / 14860, 0.7 % apart); under B's + they need 14792 / 15705 (6 % apart). +- APS-C: B refuses 11-entry tables. Its knots read literally on the APS-C crop (`i / 15` of its half diagonal, + 261.5 px) fit the APS-C RawDB files' lateral CA 4 × worse on 3 of 6 photos than the sensor-fixed 400 px; `i / 10` + (392 px) can't be told from 400 px. + +**Open question: no single unit fits every lens under either convention.** The 200–600 wants a 5–7 % larger unit (a +weaker correction) than the 24–70 GM II's pincushion end with similar table values (+409 … +561 against +406 … +454 +at the corner), under both indexings and every knot spacing tried (0.94–1.12 ×: the ratio of the two lenses' best +units stays 1.053–1.073). A's 15000 is the compromise that keeps every photo within 0.6 px; 16384 is the 200–600's +value. That rests on 3 measurable 200–600 photos (594–600 mm). The ILCE-7RM4A's own wide-angle evidence is its +weakest: its On/Off pairs are all 200–600, and its 24 mm check (LightCraft export against Sony's TIFF) is median 0.35 / +95th percentile 1.04 px at 2376 px. On its `WIDE` unit-test table the two formulas differ by up to 2.49 px at 1440 at +the corner (computed from the formulas, not measured). + +**What would settle it:** Sony Imaging Edge On/Off exports of the 24–70 GM II (or any wide zoom's wide end) on either +body, which would show whether the wide-end disagreement is the body or the formula; and more lenses, and more +200–600 photos, on the ILCE-7RM6, to tell a per-lens unit from a convention. Until then each body keeps the +conventions it was validated with. + +## Method + +Photos: 54 ILCE-7RM6 ARWs: 42 private, and the 12 CC0 samples of the +[dnglab RawDB ILCE-7RM6 set](https://rawdb.dnglab.org/sets/Sony/ILCE-7RM6) (`ILCE-7RM6_{FF,APSC}_{land,port}_RAW{,-C,-C-HD}.ARW`: +full frame and APS-C, landscape and portrait, lossless, Compressed and Compressed HQ). `cargo xtask corpus --download` +fetches one of them, the APS-C lossless file, as `arw-sony-ilce7rm6-apsc-land-lossless.arw`, for the corpus test +`corpus_sony_lens_tables_become_opcodes` (its full-frame lossless case runs only where that file is present); the ARW +6.0 decoder (#625) pins one Compressed and one Compressed HQ file. +Four lenses: FE 24–70 mm F2.8 GM II (27 photos), FE 16–35 mm F2.8 GM II (12, all RawDB), FE 100 mm F2.8 Macro GM OSS +(9), FE 200–600 mm F5.6–6.3 G OSS (6). 41 photos have in-camera distortion correction on (all private); 13 have it +off (the 12 RawDB files and one private photo). Only aggregate numbers per lens are reported. The scripts are local +(the gitignored `plan/arw6/lens/`), not in the repository; the steps are described so they can be redone. + +- **Oracle:** the camera JPEG inside each ARW (`JpgFromRaw`, 9984 × 6656 / 6528 × 4352, stored unrotated, turned to + the photo's orientation). Tag values were read with ExifTool used as a tool. Clean room: no lens-correction code of + any other project and no Adobe data was read. +- **Distortion (camera-JPEG block registration).** Our render at half the JPEG size and the JPEG (2 × 2 box-reduced) + are both reduced to a quarter of the JPEG size; on the green channel, every 40 px a 64 px JPEG block is located in + the render by normalised cross-correlation with parabolic sub-pixel refinement. Pass 1 searches coarse-to-fine + (±28 px at 1/16 size, then ±6 px); pass 2 (used for every number) searches ±6 px around pass 1's smooth radial fit + (data-derived, needed for a macro of a periodic screen). Blocks with low contrast (std < 2), NCC < 0.8 or a + tangential displacement above 4 px are dropped. A robust fit of `p_render = g(r) · p_jpeg + t` (`g` an even + 4-term polynomial) gives the translation `t`; the measured radius ratio is the median of 32 radial bins (≥ 10 + blocks each). A photo is measurable with ≥ 300 blocks and ≥ 15 bins reaching 0.7 of the half diagonal: 34 of the + 41 corrected photos (two black frames, a near-black night frame, two low-detail frames and two whose blocks reach + only 0.54 of the half diagonal are not). 108 hypotheses (radius norm, knot placement, relative or absolute, table + indexed at the corrected or the source radius, zoom rule), each with its best unit, were compared. +- **Lateral CA (raw CFA).** The demosaiced render shows only 25–60 % of the lens's CA and the JPEG's 4:2:2 chroma + and noise reduction hide it, so CA was measured on the raw: from LightCraft's uncompressed CFA DNG export, the + red (blue) plane against green interpolated at the same sites (4-neighbour mean), square-root encoded, 2 × 2 + binned; 40 px blocks every 24 px, ±3 px NCC search, radial component, median per 16 radial bins. +- **Vignetting (exposure brackets).** The JPEG is resampled into the render's geometry; per pixel and channel, the + exposure at which eight renders bracketed −0.3 … +1.8 EV equal the JPEG value is interpolated (smooth areas, JPEG + values 12–240, monotone brackets). That cancels the fitted tone curve, since exposure is a scene-linear gain before + it. Medians in 16 annuli give the JPEG's falloff relative to our uncorrected render. + +## Verification + +Every photo rendered with Enable Lens Corrections on and off at half the JPEG size (50 of the 54 are Compressed or +Compressed HQ ARW 6.0 files, so the renders used a local build of this code merged with the ARW6 decoder), then the same block +registration (passes 1 and 2) against the camera JPEG, and R and B against G inside each render (80 px blocks every +128 full-resolution px, ±4 px search; per corner the median radial shift of the 12 nearest blocks). px at a 1440 px +long edge; aggregates only. + +**Distortion, photos corrected in camera** (our render with the correction on vs the camera JPEG; budget 0.75 px). +"Corners + midpoints" is the 2-D displacement at the four corners and four edge midpoints, global translation +included; "bins" the radial residual over 32 radial bins. A photo counts as measurable as in the method above. + +| Lens | Photos (measurable) | Focal lengths | Corners + midpoints max / rms | Bins max / rms | Uncorrected, corners (per-photo max) | +|---|---|---|---|---|---| +| FE 24–70 mm F2.8 GM II | 26 (23) | 24–70 mm | 0.553 / 0.125 | 0.503 / 0.066 | 1.1–17.3 | +| FE 100 mm F2.8 Macro GM OSS | 9 (7) | 100 mm | 0.409 / 0.111 | 0.317 / 0.097 | 0.6–4.8 | +| FE 200–600 mm F5.6–6.3 G OSS | 6 (3) | 594–600 mm | 0.679 / 0.321 | 0.498 / 0.274 | 7.5–10.8 | +| **All** | **41 (33)** | | **0.679** / 0.150 (206 points) | 0.503 / 0.109 | 0.6–17.3 | + +No photo is over budget; the global translation is at most 0.056 px. The 8 not measurable are black, near-black or +low-detail frames, or frames whose blocks don't reach 0.7 of the half diagonal. That is one more than in the +measurement (33 measurable against 34): one 100 mm photo keeps only 255 of the 300 blocks needed when it is matched on +the corrected render, as block coverage differs slightly between the two renders. The 200–600 mm keeps the systematic +residual the measurement found for it (its own best unit is 15659, see Limits). + +**Controls, correction off in camera** (13 photos: the 12 RawDB files, full frame and APS-C, and one private 24–70 mm +at 58 mm). Their JPEG keeps the lens's distortion, so our corrected render must differ from it by the model's own shift: +it does, by up to 10.5 px (full frame) and 11.4 px (APS-C), and matches that shift within 0.639 px at the corners and +midpoints (bins 0.259 px; full-frame RawDB files ≤ 0.218 px, APS-C ≤ 0.639 px). This checks that the pipeline renders +the model (also for APS-C, barrel, zoom 0.984), not that the APS-C model is right. + +**Portrait photos:** 8 corrected (orientation 6 and 8) within 0.232 px, 6 controls within 0.369 px of the model's +shift: the warp turns with the photo. + +**Lateral CA in the render** (radial R-vs-G and B-vs-G shift, median of the 12 blocks nearest each corner; 43 photos +with enough detail at all four corners; budget 0.3 px): + +| Lens | Photos | Off: max R / B | Off: rms | On: max R / B | On: rms | Camera JPEG: max / rms | +|---|---|---|---|---|---|---| +| FE 24–70 mm F2.8 GM II | 23 | 0.089 / 0.071 | 0.028 | 0.219 / 0.148 | 0.069 | 0.150 / 0.036 | +| FE 16–35 mm F2.8 GM II | 12 | 0.125 / 0.054 | 0.041 | 0.030 / 0.051 | 0.020 | 0.058 / 0.016 | +| FE 100 mm F2.8 Macro GM OSS | 6 | 0.020 / 0.050 | 0.013 | 0.061 / 0.157 | 0.044 | 0.021 / 0.004 | +| FE 200–600 mm F5.6–6.3 G OSS | 2 | 0.033 / 0.101 | 0.032 | 0.146 / 0.075 | 0.070 | 0.113 / 0.039 | +| **All** | **43** | 0.125 / 0.101 | 0.032 | **0.219** / 0.157 | 0.052 | 0.150 / 0.028 | + +Within budget everywhere, but **the correction makes the render's own CA larger on average** (rms 0.032 → 0.052 px; +smaller only for the 16–35 mm). The reason is where it runs: over the radial bins beyond 0.3 of the half diagonal +(418 bins), our demosaiced render shows only 0.29 × the lens CA measured on the raw CFA (correlation 0.85), while the +warp after demosaic moves R and B by 0.84 × the table (correlation 0.96). On the raw data the table halves the CA (rms +0.062 → 0.035 px); applied after a demosaic that already hid most of it, it overshoots (rms 0.021 → 0.035 px over the +same bins). All of this is far below one pixel at 1440 px. + +**Fit gate margins** (worst of R, G and B at the gated samples; gate 0.25 px): the 4 lossless files 0.160 px (margin +0.090; 0.220 px at all 65 samples, the padded radii never shown), the 46 full-frame ARW6 files 0.175 px (margin 0.075), +the 4 APS-C ARW6 files 0.035 px. The largest CA value in the 54 tables is 1724 (FE 24–70 mm), a quarter of the 6720 +bound. So every photo gets the three-plane warp: the corrected and uncorrected renders differ on all 54. + +## Performance + +`LIGHTCRAFT_PROFILE=1` renders of the full-frame lossless file and the full-frame Compressed HQ file (corpus +`arw-sony-ilce7rm6-ff-land-lossless.arw`, `arw6-sony-ilce7rm6-ff-land-hq.arw`), on a 4-core laptop shared with other +builds (so absolute times are high); the fastest of 3–5 runs. The warp runs in the `sample` stage: + +| Render | Off | Single-plane warp (manual Distortion 1) | On (three planes) | +|---|---|---|---| +| Full size 9984 × 6656, lossless | 267 ms | — | 3098 ms | +| Full size 9984 × 6656, Compressed HQ | 403 ms | 1187 ms | 4014 ms | +| 1920 px long edge (2496 × 1664 source), Compressed HQ | 23.7 ms | 45.4 ms | 112.0 ms | + +The three colour planes cost about 2.5–3× a single-plane warp, as for an RW2 or DNG with three planes (the same +path). The stage cache keys the resampled source by the source, framing and output size, so tone, colour and +exposure drags don't re-run it. + +## Limits + +- **ILCE-7RM6 only.** Other Sony bodies record the same tags; their knot spacing (defined here by the full-frame crop), + unit and zoom rule are unverified, so they get no correction from this conversion (the ILCE-7RM4A's distortion has + its own: [Two conventions](#two-conventions)). +- **APS-C distortion is unverified in camera:** no APS-C photo with distortion correction on was available; the APS-C + knot spacing is verified on CA only, and the APS-C zoom follows the full-frame rule. +- **Aspect-ratio crops** (in-camera 4:3, 16:9, 1:1) were not measured; the correction uses the crop the file records. +- **One distortion unit for every lens.** Fitted per lens the unit is 14826 (24–70 mm, 23 photos, very consistent), + 15659 (200–600 mm, 3 photos) and 16017 (100 mm, tiny tables); with the single 15000 the 200–600 mm keeps a systematic + residual of up to 0.59 px (within budget, above the method's 0.03 px noise floor). Something lens-dependent is not + captured; per-lens units would need per-lens verification. +- **Lateral CA unit provisional** (above), and the CA warp runs after demosaic, as for RW2 and DNG. Our demosaic + already hides about 70 % of the lens CA, so the full table overshoots in the render: within budget, but the rms + corner CA rises from 0.032 to 0.052 px (Verification). Correcting CA before demosaic would be the principled fix; + scaling the colour planes down or dropping them are the cheap alternatives. +- **Vignetting not applied** (above). +- **DNG export** carries the warp in `OpcodeList3`, as for RW2: a DNG reader applies it like any embedded lens + correction. +- **Existing imports** need Photo → Reload from Disk to pick up the lens data. +- The camera-JPEG colour fit still matches its pairs without the correction (docs/camera-preview-colour.md). + +## Sources + +- [ExifTool EXIF tag names](https://exiftool.org/TagNames/EXIF.html): the names and types of tags `0x7031`–`0x7037` + (ExifTool 13.59 was used to read the tag values). Their meaning, units and conventions were measured as above. +- [dnglab RawDB, Sony ILCE-7RM6](https://rawdb.dnglab.org/sets/Sony/ILCE-7RM6): the 12 CC0 samples (data only; + dnglab's code was not consulted). diff --git a/docs/sony-lens-corrections.md b/docs/sony-lens-corrections.md index 375f773ab..dbc8090c7 100644 --- a/docs/sony-lens-corrections.md +++ b/docs/sony-lens-corrections.md @@ -1,5 +1,10 @@ # Sony embedded distortion corrections +The **ILCE-7RM6** (A7R VI) has its own conversion, with other conventions measured against its camera JPEGs: +[Sony ARW lens corrections](sony-ilce7rm6-lens-corrections.md). On ILCE-7RM6 photos the formula below misses the 24–70 GM II by +up to 1.43 px at 1440 px while fitting the 100 mm and 200–600 better, and no single unit fits every lens under either +convention; that page's *Two conventions* section has the comparison and what would settle it. + LightCraft reads the signed 16-sample distortion table in the raw image IFD (`0x7037`) of Sony **ILCE-7RM4A (A7R IVA)** Bayer ARWs. Other camera models are deliberately left uncorrected, even when they contain a table with the same layout. diff --git a/docs/whats-new.md b/docs/whats-new.md index b5c2ac68c..57e4e1c52 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -102,6 +102,18 @@ - Compare and Reference View panes say "Rendering…" (or that the photo can't be opened) the same way, so a magnified preview isn't taken for the photo when checking focus at 1:1. +### Lens corrections +- Sony A7R VI (ILCE-7RM6) raws are corrected for lens distortion and lateral chromatic aberration from the tables the + camera records in each file, under Optics ▸ Enable Lens Corrections, on by default for newly imported photos. On 33 + photos shot with the camera's correction on (24–70 mm, 100 mm macro, 200–600 mm) the render lines up with the + camera's JPEG within 0.68 px at 1440 px at the corners, where it was up to 17 px off before. The camera's vignetting + table is read but not applied: its own JPEGs don't apply it either. This reaches lossless ARWs now; Compressed and + Compressed HQ ARWs (ARW 6.0) get it once their decoder (#625) lands, and until then open from their embedded JPEG. + The A7R IVA (ILCE-7RM4A) has its own distortion correction, checked against Sony's exports; other Sony bodies record + the same tables, but they stay uncorrected until they are checked the same way. Photos imported earlier get the + correction with Photo ▸ Reload from Disk (when still at their import settings). See + `docs/sony-ilce7rm6-lens-corrections.md`. + ### JPEG XL export - Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI (`render in.raw -o out.jxl`). Quality as for JPEG, or Lossless; Effort (Fast / Normal) trades time for a From 8c64c467f5f0195cef61868235f10003268e33af Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Sat, 10 Oct 2026 23:17:31 +0200 Subject: [PATCH 6/6] LR-EDIT-OPTICS-PROFILE: Sony lateral CA planes off by default (the demosaic already hides most of it) Verified on 54 ILCE-7RM6 photos, the CA planes made the render worse: our demosaic already hides about 70 % of the lens CA (the render shows 0.29x the raw-CFA CA) while the post-demosaic warp moves R and B by 0.84x the table, so the corner R/G and B/G CA went from rms 0.032 / max 0.125 px (off) to 0.052 / 0.219 px (on) at 1440 px; only the 16-35 mm improved. warp_opcode takes a lateral_ca flag; lens_opcodes passes false, so a Sony ARW gets the one-plane distortion warp. The CA conversion stays live and tested (FF and APS-C examples, both lossless corpus files' tables). The follow-up is to warp the CFA colour planes before demosaic. The doc, tracker (docs/parity-checklist.md), docs/gaps.md, docs/raw-parity.md and whats-new say distortion applied, CA parsed and off by default, vignetting not applied; the one-plane default costs 24 -> 45 ms in the sample stage at 1920 px (three planes: 112 ms). Co-Authored-By: Claude --- crates/raw/src/vendor/arw.rs | 7 +- crates/raw/src/vendor/arw_lens_rm6.rs | 118 +++++++++++++++++++------ crates/raw/tests/corpus.rs | 50 ++++++----- docs/gaps.md | 4 +- docs/parity-checklist.md | 8 +- docs/raw-parity.md | 4 +- docs/sony-ilce7rm6-lens-corrections.md | 65 ++++++++------ docs/whats-new.md | 20 ++--- xtask/src/main.rs | 2 +- 9 files changed, 180 insertions(+), 98 deletions(-) diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index d6715d49e..49543c4fe 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -875,7 +875,8 @@ mod tests { } /// The ILCE-7RM6's distortion table reaches `OpcodeList3`, in the headers-only probe and in the full decode, as - /// one plane, or three with the CA table; another body's tables don't (see [`super::super::arw_lens_rm6`]). + /// one plane, also with a CA table (lateral CA is off by default); another body's tables don't (see + /// [`super::super::arw_lens_rm6`]). #[test] fn lens_tables_reach_opcode_list3() { use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; @@ -904,10 +905,10 @@ mod tests { TiffWriter::default().write(&[raw]).unwrap() }; for mode in [Mode::Header, Mode::Full] { - for (ca, n) in [(false, 1), (true, 3)] { + for ca in [false, true] { let list3 = |model| decode(&file(model, ca), mode).unwrap().opcodes.list3; let ops = list3("ILCE-7RM6"); - assert!(matches!(ops.as_slice(), [crate::Opcode::WarpRectilinear { planes, .. }] if planes.len() == n), "{mode:?} {ca}: {ops:?}"); + assert!(matches!(ops.as_slice(), [crate::Opcode::WarpRectilinear { planes, .. }] if planes.len() == 1), "{mode:?} {ca}: {ops:?}"); assert!(list3("ILCE-7M4").is_empty(), "{mode:?} {ca}"); } } diff --git a/crates/raw/src/vendor/arw_lens_rm6.rs b/crates/raw/src/vendor/arw_lens_rm6.rs index 37d76fdd6..cc3a23e61 100644 --- a/crates/raw/src/vendor/arw_lens_rm6.rs +++ b/crates/raw/src/vendor/arw_lens_rm6.rs @@ -29,11 +29,13 @@ //! - vignetting: read but never applied. The camera JPEGs keep the uncorrected render's radial falloff (median //! corner/centre ratio 0.996 over 39 photos) and no radial gain model fits the tables. //! - lateral CA: red's (blue's) source radius is `g · (1 + c(g) / 3.36·10⁶)` with `g` the green source radius and `c` -//! the red (blue) half on the same knots, as the warp's R and B planes. The unit is **provisional**: the -//! measurement is borderline (0.399 px worst against a 0.3 px budget; per-photo best units scatter from 1.5·10⁶ -//! to 7.2·10⁶, about ±30 % scene bias), but it cuts the rms CA over 46 photos from 0.079 to 0.052 px. When a CA -//! value is implausible (beyond [`CA_MAX_RELATIVE`]) or a colour plane fails the fit gate above, both colour -//! planes are dropped and G alone is applied to all three channels. +//! the red (blue) half on the same knots. The unit is **provisional**: the measurement is borderline (0.399 px +//! worst against a 0.3 px budget; per-photo best units scatter from 1.5·10⁶ to 7.2·10⁶, about ±30 % scene bias), +//! but it cuts the rms raw-CFA CA over 46 photos from 0.079 to 0.052 px. The table is parsed and [`warp_opcode`] +//! converts it to the warp's R and B planes, but they are **not emitted by default**: [`lens_opcodes`] passes +//! `lateral_ca: false`, because applied after demosaic they made the render's CA worse (numbers there). To turn +//! them on in code, pass `true` there. When on, a CA value beyond [`CA_MAX_RELATIVE`] or a colour plane that fails +//! the fit gate above drops both colour planes, and G alone is applied to all three channels. //! //! The correction is produced whatever the in-camera flag says. Only bodies listed in [`FULL_FRAME_CROP`] get it: the //! unit and the knot spacing are verified on the ILCE-7RM6 alone. Another body is added by repeating the measurement @@ -61,7 +63,8 @@ const DISTORTION_UNIT: f64 = 15000.0; /// cleanest photos; per-photo units scatter from 1.5·10⁶ to 7.2·10⁶ (the measurement's scene bias is about ±30 %). const CA_UNIT: f64 = 3.36e6; /// Largest plausible CA value relative to the green source radius (|c| / [`CA_UNIT`]): about 12 px at the full-frame -/// corner; the measured tables stay below 790 / 3.36·10⁶ ≈ 2.4·10⁻⁴. A table beyond it is refused. +/// corner; the largest value in the 54 measured photos' tables is 1724 (FE 24–70 mm), 1724 / 3.36·10⁶ ≈ 5.1·10⁻⁴. +/// A table beyond it is refused. const CA_MAX_RELATIVE: f64 = 2e-3; /// Crop border radii scanned by the frame-filling zoom. const ZOOM_SCAN: usize = 4096; @@ -151,12 +154,13 @@ fn fill_zoom(source: &dyn Fn(f64) -> f64, x: f64, y: f64) -> Option { } /// The distortion and CA tables as a `WarpRectilinear` relative to the active area `active` (centred on the default -/// crop `crop`, as the camera corrects), with knots `knot_spacing` px apart: planes R, G, B, or G alone (applied to -/// all three) when the CA table is absent or a colour plane's fit fails. `None` when the distortion table is absent or +/// crop `crop`, as the camera corrects), with knots `knot_spacing` px apart: planes R, G, B with `lateral_ca`, or G +/// alone (applied to all three) without it, or when the CA table is absent, implausible or a colour plane's fit +/// fails. `None` when the distortion table is absent or /// all zero (CA alone is not applied: it is relative to green), the zoom or the G fit fails. A fit fails when it is /// worse than 0.25 px at a 1440 px long edge at one of the fit samples within the crop (a padded active area, as in /// lossless files, reaches radii that are never shown). -pub(crate) fn warp_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: Rect) -> Option { +pub(crate) fn warp_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: Rect, lateral_ca: bool) -> Option { let v = t.distortion.as_deref().filter(|v| v.iter().any(|&x| x != 0.0))?; let (aw, ah) = (active.width as f64, active.height as f64); if aw < 2.0 || ah < 2.0 || crop.width == 0 || crop.height == 0 { @@ -186,16 +190,22 @@ pub(crate) fn warp_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: }; // a NaN fails the comparison: refused too let plausible = |c: &[f64]| c.iter().all(|v| v.abs() <= CA_MAX_RELATIVE * CA_UNIT); - let rgb = t.ca.as_ref().filter(|(red, blue)| plausible(red) && plausible(blue)); + let rgb = t.ca.as_ref().filter(|(red, blue)| lateral_ca && plausible(red) && plausible(blue)); let rgb = rgb.and_then(|(red, blue)| Some(vec![colour(red)?, g, colour(blue)?])); Some(Opcode::WarpRectilinear { planes: rgb.unwrap_or_else(|| vec![g]), center: [cx / (aw - 1.0), cy / (ah - 1.0)] }) } /// The `OpcodeList3` lens corrections of a Sony raw IFD for the body `model` (Exif `Model`); empty for a body whose /// tables are not verified. +/// +/// Distortion only: the lateral CA planes are off (`lateral_ca: false`). Our demosaic already hides about 70 % of the +/// lens CA (the render shows 0.29 × the CA measured on the raw CFA), while the warp, which runs after demosaic, moves +/// R and B by 0.84 × the table. Over the 43 verified photos with enough corner detail, the render's corner R/G and B/G +/// CA went from rms 0.032 / max 0.125 px (off) to 0.052 / 0.219 px (on) at a 1440 px long edge; only the 16–35 mm +/// improved. Follow-up: warp the CFA colour planes before demosaic, then turn the planes on here. pub(crate) fn lens_opcodes(raw: &Ifd, model: &str, active: Rect, crop: Rect) -> Vec { let Some(spacing) = knot_spacing(model) else { return Vec::new() }; - warp_opcode(&read_tables(raw), spacing, active, crop).into_iter().collect() + warp_opcode(&read_tables(raw), spacing, active, crop, false).into_iter().collect() } #[cfg(test)] @@ -222,6 +232,12 @@ mod tests { 22, 790, 764, 734, 702, 666, 624, 582, 544, 510, 484, 470, -432, -404, -372, -330, -286, -230, -174, -114, -58, -6, 34, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ]; + /// The APS-C lossless corpus file `arw-sony-ilce7rm6-apsc-land-lossless.arw` (same scene; its tables differ slightly). + const APSC_LOSSLESS_DISTORTION: [i16; 17] = [11, 0, -10, -36, -78, -134, -202, -277, -356, -435, -511, -583, -583, -583, -583, -583, -583]; + const APSC_LOSSLESS_CA: [i16; 33] = [ + 22, 792, 766, 738, 706, 670, 630, 588, 548, 514, 488, 476, -436, -408, -376, -334, -290, -234, -178, -118, -62, -12, 30, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, + ]; /// ARW6 geometry (the decoder's raw image size and default crop). const FF_ACTIVE: Rect = Rect { x: 0, y: 0, width: 10016, height: 6672 }; const FF_CROP: Rect = Rect { x: 12, y: 8, width: 9984, height: 6656 }; @@ -325,7 +341,7 @@ mod tests { let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(table))]); let t = read_tables(&ifd); assert!(t.distortion.is_none(), "{what}"); - assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP).is_none(), "{what}"); + assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, false).is_none(), "{what}"); assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty(), "{what}"); } // not an integer array, or absent @@ -353,7 +369,7 @@ mod tests { ("not finite", vec![0.0, f64::NAN, 20.0]), ("infinite", vec![0.0, f64::INFINITY, 20.0]), ] { - assert!(warp_opcode(&tables_with_distortion(&table), knots(), FF_ACTIVE, FF_CROP).is_none(), "{what}"); + assert!(warp_opcode(&tables_with_distortion(&table), knots(), FF_ACTIVE, FF_CROP, false).is_none(), "{what}"); } // the same through the IFD, as 64-bit values for v in [i64::MAX / 4, i64::MIN, i64::MAX] { @@ -370,37 +386,37 @@ mod tests { ("knot spacing NaN", f64::NAN, FF_ACTIVE, FF_CROP), ("knot spacing 0", 0.0, FF_ACTIVE, FF_CROP), ] { - assert!(warp_opcode(&ff, knots, active, crop).is_none(), "{what}"); + assert!(warp_opcode(&ff, knots, active, crop, false).is_none(), "{what}"); } } #[test] fn ff_example_table_becomes_the_measured_warp() { let t = tables_with_distortion(&values(&FF_DISTORTION)); - assert_warp(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP), [0.4996005991, 0.5], &[FF_G]); + assert_warp(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, false), [0.4996005991, 0.5], &[FF_G]); } #[test] fn apsc_example_table_becomes_the_measured_warp() { let t = tables_with_distortion(&values(&APSC_DISTORTION)); - let op = warp_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP); + let op = warp_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP, false); assert_warp(op, [0.4975724473, 0.5], &[APSC_G]); } #[test] fn ca_table_shifts_red_and_blue_planes() { let t = read_tables(&sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_HQ_CA, [0, 2, 1])); - let planes = assert_warp(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP), [0.4996005991, 0.5], &[FF_HQ_R, FF_G, FF_HQ_B]); + let planes = assert_warp(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, true), [0.4996005991, 0.5], &[FF_HQ_R, FF_G, FF_HQ_B]); assert_ne!(planes[0][0], planes[1][0]); // G is the distortion plane alone - let g = warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP); + let g = warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP, false); assert!(matches!(g, Some(Opcode::WarpRectilinear { planes: ref p, .. }) if p == &[planes[1]]), "{g:?}"); } #[test] fn apsc_ca_planes_match_the_measured_warp() { let t = read_tables(&sony_lens_ifd(&APSC_DISTORTION, &APSC_VIGNETTING, &APSC_CA, [0, 2, 1])); - assert_warp(warp_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP), [0.4975724473, 0.5], &[APSC_R, APSC_G, APSC_B]); + assert_warp(warp_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP, true), [0.4975724473, 0.5], &[APSC_R, APSC_G, APSC_B]); } #[test] @@ -408,17 +424,20 @@ mod tests { let ifd = ifd(vec![(CA_PARAMS, Value::SShort(FF_HQ_CA.to_vec()))]); let t = read_tables(&ifd); assert!(t.ca.is_some() && t.distortion.is_none()); - assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP).is_none()); + assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, true).is_none()); assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty()); // an all-zero distortion table counts as absent let zero = LensTables { ca: t.ca, ..tables_with_distortion(&[0.0; 16]) }; - assert!(warp_opcode(&zero, knots(), FF_ACTIVE, FF_CROP).is_none()); + assert!(warp_opcode(&zero, knots(), FF_ACTIVE, FF_CROP, true).is_none()); } #[test] fn rejected_ca_keeps_the_single_green_plane() { - let green = - assert_warp(warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP), [0.4996005991, 0.5], &[FF_G]); + let green = assert_warp( + warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP, false), + [0.4996005991, 0.5], + &[FF_G], + ); let ok = values(&FF_HQ_CA); let huge = vec![(i64::MAX / 4) as f64; 16]; for (what, red, blue) in [ @@ -429,7 +448,7 @@ mod tests { ("one knot", vec![100.0], vec![100.0]), ] { let t = LensTables { ca: Some((red, blue)), ..tables_with_distortion(&values(&FF_DISTORTION)) }; - let Some(Opcode::WarpRectilinear { planes, .. }) = warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP) else { panic!("{what}: no warp") }; + let Some(Opcode::WarpRectilinear { planes, .. }) = warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, true) else { panic!("{what}: no warp") }; assert_eq!(planes, green, "{what}"); } // through the IFD: an odd count, or values that overflow the fit @@ -443,15 +462,56 @@ mod tests { max[0] = 32; for (what, ca) in [("odd count", Value::SShort(odd)), ("huge", Value::SLong8(huge_ifd)), ("constant 32767", Value::SShort(max))] { let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(FF_DISTORTION.to_vec())), (CA_PARAMS, ca)]); - let ops = lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP); - assert!(matches!(ops.as_slice(), [Opcode::WarpRectilinear { planes, .. }] if planes == &green), "{what}: {ops:?}"); + let op = warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, true); + assert!(matches!(op, Some(Opcode::WarpRectilinear { ref planes, .. }) if planes == &green), "{what}: {op:?}"); } // values up to the bound are kept let mut at_bound = vec![6720; 33]; at_bound[0] = 32; let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(FF_DISTORTION.to_vec())), (CA_PARAMS, Value::SShort(at_bound))]); + let op = warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, true); + assert!(matches!(op, Some(Opcode::WarpRectilinear { ref planes, .. }) if planes.len() == 3), "{op:?}"); + } + + /// The default: a valid CA table is read and converts (above), but `lens_opcodes` emits the G plane alone. + #[test] + fn lens_opcodes_emit_the_green_plane_only() { + let ifd = sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_HQ_CA, [1, 2, 1]); + let with_ca = warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, true); + assert!(matches!(with_ca, Some(Opcode::WarpRectilinear { ref planes, .. }) if planes.len() == 3), "{with_ca:?}"); let ops = lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP); - assert!(matches!(ops.as_slice(), [Opcode::WarpRectilinear { planes, .. }] if planes.len() == 3), "{ops:?}"); + let [Opcode::WarpRectilinear { planes, .. }] = ops.as_slice() else { panic!("{ops:?}") }; + assert_eq!(planes.len(), 1); + let green = warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP, false); + assert_eq!(ops.first(), green.as_ref()); + } + + /// The corpus lossless files' own tables on their decoded geometry (padded active areas; the corpus test checks + /// the G plane from the files): R, G, B with the CA planes enabled. + #[test] + fn lossless_examples_ca_planes_match_the_measured_warp() { + let ff = read_tables(&sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_CA, [0, 2, 1])); + let ff_active = Rect { x: 0, y: 0, width: 10240, height: 7168 }; + assert_warp( + warp_opcode(&ff, knots(), ff_active, FF_CROP, true), + [0.488670768629749, 0.4653969582810102], + &[ + [0.971770511193381, 0.023073568897462152, 0.00879158563716919, 0.0030468782543633132], + [0.9717033132764651, 0.023249297636569695, 0.008638979168841135, 0.003091041831374855], + [0.9716312798157171, 0.02356676356656341, 0.00830169937204417, 0.003182927930635947], + ], + ); + let apsc = read_tables(&sony_lens_ifd(&APSC_LOSSLESS_DISTORTION, &APSC_VIGNETTING, &APSC_LOSSLESS_CA, [0, 2, 1])); + let apsc_active = Rect { x: 0, y: 0, width: 6656, height: 4608 }; + assert_warp( + warp_opcode(&apsc, knots(), apsc_active, APSC_CROP, true), + [0.49278737791134486, 0.4743868026915563], + &[ + [1.0165959888027158, -0.0690974265041942, 0.036328960724338874, -0.009486844437228997], + [1.016366854640611, -0.0688824189782517, 0.03615745253291194, -0.009438825010476538], + [1.0162457267369314, -0.0686225599869027, 0.035974238433727206, -0.009384155126102897], + ], + ); } /// The lossless files' padded active area (10240 × 7168) puts the farthest active-area corner (DNG radius 1) well @@ -461,7 +521,7 @@ mod tests { fn padded_active_area_gates_only_the_displayed_radii() { let strong: Vec = values(&FF_DISTORTION).iter().map(|v| v * 1.5).collect(); let padded = Rect { x: 0, y: 0, width: 10240, height: 7168 }; - let op = warp_opcode(&tables_with_distortion(&strong), knots(), padded, FF_CROP); + let op = warp_opcode(&tables_with_distortion(&strong), knots(), padded, FF_CROP, false); assert!(matches!(op, Some(Opcode::WarpRectilinear { .. })), "{op:?}"); } @@ -472,7 +532,7 @@ mod tests { assert!(lens_opcodes(&ifd, "", FF_ACTIVE, FF_CROP).is_empty()); // the camera's in-camera flag (0 = off here) doesn't matter: the table is applied let ops = lens_opcodes(&ifd, " ILCE-7RM6 ", FF_ACTIVE, FF_CROP); - assert_eq!(ops, warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP).into_iter().collect::>()); + assert_eq!(ops, warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, false).into_iter().collect::>()); assert_eq!(ops.len(), 1); } } diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index c32857100..b4041178b 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -626,27 +626,24 @@ fn corpus_sony_16x9_crops() { eprintln!("Sony 16:9 crops checked on {seen} files"); } -/// Sony ILCE-7RM6: the distortion and lateral CA tables in the raw IFD (`0x7037`, `0x7035`) become a three-plane -/// (R, G, B) `WarpRectilinear` in `OpcodeList3` (`vendor::arw_lens_rm6`). Expected: the conversion recipe measured on 54 +/// Sony ILCE-7RM6: the distortion table in the raw IFD (`0x7037`) becomes a one-plane `WarpRectilinear` in +/// `OpcodeList3` (`vendor::arw_lens_rm6`; the lateral CA table is read but its planes are off by default, and their +/// numbers for these files are checked in `arw_lens_rm6`'s unit tests). Expected: the conversion recipe measured on 54 /// ILCE-7RM6 photos, evaluated in f64 for each file's tables, decoded active area (the padded lossless tiles) and -/// default crop. Both files carry distortion correction off in camera; the table is applied anyway. +/// default crop. All four files carry distortion correction off in camera; the table is applied anyway. #[test] fn corpus_sony_lens_tables_become_opcodes() { let dir = corpus_root().join("raw"); - // (file, active area, default crop x, y, width, height, opcode centre, R, G, B planes k0..k3) + // (file, active area, default crop x, y, width, height, opcode centre, G-plane k0..k3) let cases = [ - // not pinned in `cargo xtask corpus` (at most two pins per PR; #625 pins two Compressed ARW 6.0 files): checked - // only where the file is present (RawDB `ILCE-7RM6_FF_land_RAW.ARW`, CC0) + // not pinned in `cargo xtask corpus` (at most two pins per PR; the ARW 6.0 decoder pins the two Compressed files + // below): checked only where the file is present (RawDB `ILCE-7RM6_FF_land_RAW.ARW`, CC0) ( "arw-sony-ilce7rm6-ff-land-lossless.arw", (10240, 7168), (12, 8, 9984, 6656), [0.488670768629749, 0.4653969582810102], - [ - [0.971770511193381, 0.023073568897462152, 0.00879158563716919, 0.0030468782543633132], - [0.9717033132764651, 0.023249297636569695, 0.008638979168841135, 0.003091041831374855], - [0.9716312798157171, 0.02356676356656341, 0.00830169937204417, 0.003182927930635947], - ], + [0.9717033132764651, 0.023249297636569695, 0.008638979168841135, 0.003091041831374855], ), // pinned in `cargo xtask corpus` (RawDB `ILCE-7RM6_APSC_land_RAW.ARW`, CC0) ( @@ -654,11 +651,24 @@ fn corpus_sony_lens_tables_become_opcodes() { (6656, 4608), (16, 10, 6528, 4352), [0.49278737791134486, 0.4743868026915563], - [ - [1.0165959888027158, -0.0690974265041942, 0.036328960724338874, -0.009486844437228997], - [1.016366854640611, -0.0688824189782517, 0.03615745253291194, -0.009438825010476538], - [1.0162457267369314, -0.0686225599869027, 0.035974238433727206, -0.009384155126102897], - ], + [1.016366854640611, -0.0688824189782517, 0.03615745253291194, -0.009438825010476538], + ), + // pinned by the ARW 6.0 decoder (RawDB `ILCE-7RM6_FF_land_RAW-C.ARW`, CC0): the same scene as the full-frame + // lossless file, its table one step off at knot 12 (268, not 267), on the decoder's unpadded geometry + ( + "arw6-sony-ilce7rm6-ff-land-c.arw", + (10016, 6672), + (12, 8, 9984, 6656), + [0.49960059910134796, 0.5], + [0.9714600903266707, 0.02234210164294959, 0.001142578769532717, 0.005292120046651031], + ), + // pinned by the ARW 6.0 decoder (RawDB `ILCE-7RM6_APSC_land_RAW-C-HD.ARW`, CC0) + ( + "arw6-sony-ilce7rm6-apsc-land-hq.arw", + (6592, 4372), + (16, 10, 6528, 4352), + [0.4975724472765893, 0.5], + [1.01627551, -0.0618606692, 0.02768349621, -0.005595798468], ), ]; let mut seen = 0; @@ -687,12 +697,10 @@ fn corpus_sony_lens_tables_become_opcodes() { }) .collect(); let [(planes, got)] = warps.as_slice() else { panic!("{name}: {:?}", img.opcodes.list3) }; - assert_eq!(planes.len(), 3, "{name}: R, G, B planes"); + assert_eq!(planes.len(), 1, "{name}: one plane (G; lateral CA off by default)"); assert!((got[0] - center[0]).abs() < 1e-6 && (got[1] - center[1]).abs() < 1e-6, "{name}: centre {got:?}, want {center:?}"); - for (plane, (p, k)) in ["R", "G", "B"].iter().zip(planes.iter().zip(k)) { - for (i, want) in k.iter().enumerate() { - assert!((p[i] - want).abs() < 1e-6, "{name}: {plane} k{i} {}, want {want}", p[i]); - } + for (i, want) in k.iter().enumerate() { + assert!((planes[0][i] - want).abs() < 1e-6, "{name}: k{i} {}, want {want}", planes[0][i]); } seen += 1; } diff --git a/docs/gaps.md b/docs/gaps.md index 2a80452ac..4812de920 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -22,7 +22,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 5 B | **AI masks: Subject, Sky, People, Background, Landscape** are heuristics; Object / Describe run SAM 3 but its download has no mirror configured | `crates/segment`; ai-masks.md; MASK rows 67% | masking is how most Lightroom users edit today | 40–70 + model decision | [target-app-parity](target-app-parity.md#ai) | | 6 B | **Compressed ORF** (every Olympus / OM body since ~2008) | measured: 66 of 78 Olympus / OM models preview-only; `orf.rs` refuses; OM bucket 3% of photographers, 3% of them fully work | +2.6 points; best gain per hour of any raw gap (~0.2) | 8–15 | [raw-parity](raw-parity.md#compressed-orf-a-clean-room-starting-point) (the two Olympus patents that describe the method) | | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | -| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony in-file distortion on two bodies (ILCE-7RM4A; ILCE-7RM6, also lateral CA), each with its own conventions; other Sony bodies' tables not applied until verified | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | +| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony in-file distortion on two bodies (ILCE-7RM4A, ILCE-7RM6), each with its own conventions; the ILCE-7RM6's lateral CA table is parsed but off by default (it overshoots after demosaic); other Sony bodies' tables not applied until verified | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | | 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 236 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 236 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | @@ -89,7 +89,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-10 | minor | Compressed ORF entry links the two Olympus patents that describe the method | | 2026-10-11 | minor | Gap 14: `crs:HDREditMode` is read from XMP (#663); the headroom limit and SDR rendition still aren't | | 2026-10-11 | minor | Gaps 2 and 10 shrink: EOS R5 Mark II CR3 decodes and is verified (fourth CR3 body, Canon 12 verified, 235 models, ~42% on a verified body); CR3 gain +6.5 → +6.2 points | -| 2026-10-10 | minor | Gap 8: Sony ILCE-7RM6 in-file distortion and lateral CA added next to the ILCE-7RM4A's (docs/sony-ilce7rm6-lens-corrections.md); the gap stays open (no lens database), hours unchanged | +| 2026-10-10 | minor | Gap 8: Sony ILCE-7RM6 in-file distortion added next to the ILCE-7RM4A's, its lateral CA parsed but off by default (docs/sony-ilce7rm6-lens-corrections.md); the gap stays open (no lens database), hours unchanged | | 2026-10-10 | minor | RAW gaps re-measured model by model (raw-parity.md): CR3, compressed ORF and verification entries carry measured shares; HE NEF no longer a beta blocker (+1.1 points for 20–40 h); verification entry absorbs the cheap per-body fixes (#769, #702, A200 series) | | 2026-10-10 | minor | Beta total raised to 340–600 h to include the ~92 open core-path user issues | | 2026-10-10 | major | Created from the checklist's Top gaps (whose items 1–2 had been duplicated by a merge) and ROADMAP's Where we stand; re-ranked; added evidence, impact, hours and beta flags; added platform-CI, lens-database, HE NEF and localization gaps | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index b511764da..85b1039fc 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -36,7 +36,7 @@ cited here still exists. Formerly docs/parity.md. | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | -| K. Masking (MASK) | 13 | 5 | 5 | 0 | 7/8 (88%) | 5/5 (100%) | +| K. Masking (MASK) | 14 | 5 | 5 | 0 | 7/8 (88%) | 6/6 (100%) | | L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | | M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 408 | 52 | 72 | 35 | 193/201 (96%) | 145/157 (92%) | +| **Total** | 409 | 52 | 72 | 35 | 193/201 (96%) | 146/158 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 532 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.0% of 173. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 533 in-scope rows — P0 98.0% of 201 · P1 95.3% of 158 · P2 50.0% of 173. ## Top gaps and shortcut notes @@ -211,7 +211,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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/raw/src/vendor/raf_lens.rs`, `crates/raw/src/vendor/arw_lens.rs`, `crates/raw/src/vendor/arw_lens_rm6.rs`, `crates/engine/src/crs.rs`, `docs/sony-lens-corrections.md`, `docs/sony-ilce7rm6-lens-corrections.md` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `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); Sony ARW raw-IFD lens tables use the same warp path on two bodies, each with the conventions it was validated with, selected by camera model: the **ILCE-7RM4A**'s distortion table (`0x7037`, Bayer, 16-sample layout, native 3:2 framing; validated against Sony Imaging Edge On/Off exports with the FE 24–105 and FE 200–600 lenses, `docs/sony-lens-corrections.md`) and the **ILCE-7RM6**'s distortion and lateral CA tables (`0x7037`, `0x7035`) as one three-plane `WarpRectilinear` (measured against the camera JPEG, `docs/sony-ilce7rm6-lens-corrections.md`; on the 33 measurable of 41 photos shot with in-camera correction on, three lenses (a fourth, the 16–35 mm, only as a control with correction off), the corrected render lines up with the camera JPEG within 0.679 px at 1440 px at the corners and edge midpoints, from up to 17.3 px uncorrected); the two conventions differ in unit and table index, the ILCE-7RM4A's misses the ILCE-7RM6's 24–70 GM II by up to 1.43 px, and no single unit fits every lens under either (`docs/sony-ilce7rm6-lens-corrections.md` → Two conventions); the ILCE-7RM6's CA unit is provisional and, applied after demosaic, raises the render's corner CA (rms 0.032 → 0.052 px, within the 0.3 px budget); its vignetting table is read but not applied (the camera's JPEGs don't apply it; no model fits); other Sony bodies' tables are not applied until verified; ILCE-7RM6 APS-C distortion and in-camera aspect-ratio crops unverified; on by default for new imports with embedded lens data; an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); 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 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); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | +| 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/raw/src/vendor/arw_lens.rs`, `crates/raw/src/vendor/arw_lens_rm6.rs`, `crates/engine/src/crs.rs`, `docs/sony-lens-corrections.md`, `docs/sony-ilce7rm6-lens-corrections.md` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `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); Sony ARW raw-IFD lens tables use the same warp path on two bodies, each with the conventions it was validated with, selected by camera model: the **ILCE-7RM4A**'s distortion table (`0x7037`, Bayer, 16-sample layout, native 3:2 framing; validated against Sony Imaging Edge On/Off exports with the FE 24–105 and FE 200–600 lenses, `docs/sony-lens-corrections.md`) and the **ILCE-7RM6**'s distortion table as a one-plane `WarpRectilinear` (measured against the camera JPEG, `docs/sony-ilce7rm6-lens-corrections.md`; on the 33 measurable of 41 photos shot with in-camera correction on, three lenses (a fourth, the 16–35 mm, only as a control with correction off), the corrected render lines up with the camera JPEG within 0.679 px at 1440 px at the corners and edge midpoints, from up to 17.3 px uncorrected); the two conventions differ in unit and table index, the ILCE-7RM4A's misses the ILCE-7RM6's 24–70 GM II by up to 1.43 px, and no single unit fits every lens under either (`docs/sony-ilce7rm6-lens-corrections.md` → Two conventions); the ILCE-7RM6's lateral CA table (`0x7035`) is parsed and converts to red and blue planes but is off by default: applied after demosaic it raised the render's corner CA (rms 0.032 → 0.052 px, max 0.125 → 0.219 px), because our demosaic already hides about 70 % of the lens CA; correcting it before demosaic is the follow-up, and its unit is provisional; its vignetting table is read but not applied (the camera's JPEGs don't apply it; no model fits); other Sony bodies' tables are not applied until verified; ILCE-7RM6 APS-C distortion and in-camera aspect-ratio crops unverified; on by default for new imports with embedded lens data; an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); 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 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); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | ) | 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` | | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index d07753b6e..614294086 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -220,7 +220,7 @@ essentials user (the default look is what they keep). | Item | Adobe | LightCraft | |---|---|---| | Profile database | 3,635 profile files, 56 makers, auto-selected | **0** (Adobe LCPs never used; no database of our own) | -| In-file corrections | ✅ | DNG opcodes (WarpRectilinear, FixVignetteRadial, GainMap…) applied; Panasonic / Leica RW2 distortion (79 photos, 13 bodies, 11 lenses); Sony tag 0x7037 distortion for the ILCE-7RM4A (2 lenses, checked against Sony's exports) and the ILCE-7RM6 (three lenses, a fourth, the 16–35 mm, only as a control with correction off; checked against the camera JPEG), with different conventions ([sony-ilce7rm6-lens-corrections.md](sony-ilce7rm6-lens-corrections.md#two-conventions)); the ILCE-7RM6's raw-IFD lateral CA table too (provisional unit, applied after demosaic), not its raw-IFD vignetting table | +| In-file corrections | ✅ | DNG opcodes (WarpRectilinear, FixVignetteRadial, GainMap…) applied; Panasonic / Leica RW2 distortion (79 photos, 13 bodies, 11 lenses); Sony tag 0x7037 distortion for the ILCE-7RM4A (2 lenses, checked against Sony's exports) and the ILCE-7RM6 (three lenses, a fourth, the 16–35 mm, only as a control with correction off; checked against the camera JPEG), with different conventions ([sony-ilce7rm6-lens-corrections.md](sony-ilce7rm6-lens-corrections.md#two-conventions)); the ILCE-7RM6's raw-IFD lateral CA table is parsed but off by default (it overshoots after demosaic), its raw-IFD vignetting table not applied | | Vignetting / lateral CA from maker notes | ✅ | ⬜ | | Manual distortion, vignetting, CA, defringe | ✅ | ✅ | @@ -282,6 +282,6 @@ Beyond decoding: | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | | 2026-10-12 | minor | PEF without area tags: masked border rows and columns trimmed against the BlackPoint (issue #1271; KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z); counts unchanged | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | -| 2026-10-10 (later) | minor | Lens profiles: Sony ILCE-7RM6 in-file distortion and lateral CA next to the ILCE-7RM4A's distortion; lens score unchanged (~10%: one more body) | +| 2026-10-10 (later) | minor | Lens profiles: Sony ILCE-7RM6 in-file distortion next to the ILCE-7RM4A's (its lateral CA parsed, off by default); lens score unchanged (~10%: one more body) | | 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | | 2026-10-10 | major | Created: formats, makers, demosaic, colour and lens checklists; target counts from the installed Lightroom 9.6 bundle listing | diff --git a/docs/sony-ilce7rm6-lens-corrections.md b/docs/sony-ilce7rm6-lens-corrections.md index 64963fcac..d59723ac4 100644 --- a/docs/sony-ilce7rm6-lens-corrections.md +++ b/docs/sony-ilce7rm6-lens-corrections.md @@ -1,17 +1,18 @@ # Sony ARW lens corrections Sony cameras record three lens-correction tables in the raw IFD of every ARW: distortion, vignetting and lateral -chromatic aberration (CA). LightCraft reads them and turns distortion and lateral CA into a DNG `WarpRectilinear` -opcode, so they reach the same **Optics ▸ Enable Lens Corrections** (`optics.lens_profile`) as a DNG's embedded warp -and Panasonic's RW2 distortion data. Vignetting is read but not applied (below). The conversion is verified on the -**ILCE-7RM6** only; the ILCE-7RM4A has its own conversion, with other conventions ([Two conventions](#two-conventions)), -and other Sony bodies' tables give no correction until they are verified. +chromatic aberration (CA). LightCraft reads them and turns distortion into a DNG `WarpRectilinear` opcode, so it +reaches the same **Optics ▸ Enable Lens Corrections** (`optics.lens_profile`) as a DNG's embedded warp and Panasonic's +RW2 distortion data. Lateral CA is parsed and converts to the warp's red and blue planes, but it is **off by default**: +applied after demosaic it made the render's CA worse (Verification). Vignetting is read but not applied (below). The +conversion is verified on the **ILCE-7RM6** only; the ILCE-7RM4A has its own conversion, with other conventions +([Two conventions](#two-conventions)), and other Sony bodies' tables give no correction until they are verified. The correction is on by default for new imports, as for any file with embedded lens data (`Photo::camera_defaults`). Photos imported before this change keep their settings: **Photo → Reload from Disk** adds the lens data and, when the photo is still at its import settings, turns the correction on as one undoable step; the automatic reload after an external editor saves the file does the same. Edited photos keep their settings. The Profile Distortion slider -scales the warp (all three colour planes) as for DNG and RW2. +scales the warp as for DNG and RW2. ## The tables @@ -59,7 +60,8 @@ None of this is documented; it was measured against the camera's own JPEGs (meth best units scatter from 1.5·10⁶ to 7.2·10⁶ (about ±30 % scene bias: the RawDB landscape and portrait shots of one scene, with nearly identical tables, measure 3.4–3.5·10⁶ and 4.5·10⁶). The chosen 3.36·10⁶ is the median of the 20 cleanest photos: worst 0.399 px on the 15, and the rms raw CA over all 46 measured photos falls from 0.079 to - 0.052 px. Swapping the halves or an absolute form (`v · norm / U`) is clearly worse. + 0.052 px. Swapping the halves or an absolute form (`v · norm / U`) is clearly worse. **Not applied by default** + (Verification). - **Vignetting: not applied.** The camera JPEGs show no vignetting compensation relative to our uncorrected render: over the 39 photos whose measurement reaches the corners, the corner/centre ratio JPEG / render has median 0.996 (p10 0.907, p90 1.087), while the tables would predict a corner gain with median +32 % for `1 + v / 2¹⁴` (+16 % @@ -77,7 +79,7 @@ None of this is documented; it was measured against the camera's own JPEGs (meth (DNG pixel-index units relative to the active area; radius 1 = the farthest active-area corner, `m`): 1. `S` from the distortion table and the zoom `z` (bisection on [0.5, 2], 4097 border radii); the green map is - `G(r) = S(r / z)`. Red and blue: `G(r) · (1 + c(G(r)) / 3.36·10⁶)`. + `G(r) = S(r / z)`. Red and blue, when the CA planes are on: `G(r) · (1 + c(G(r)) / 3.36·10⁶)`. 2. Each map is sampled at `u = i / 64`, i = 0…64 (65 samples of `G(u · m) / m`) and fitted by least squares with DNG's 4-term odd polynomial `u · (k0 + k1 u² + k2 u⁴ + k3 u⁶)` (`vendor::fit_radial_warp`, shared with RW2). 3. **Fit gate:** a plane is refused when its fit is worse than 0.25 px at a 1440 px long edge at a sample the crop @@ -86,11 +88,13 @@ None of this is documented; it was measured against the camera's own JPEGs (meth Over all 54 photos the fit's worst error at all 65 samples was 0.220 px (a lossless file; ARW6 files at most 0.175 px). Between samples the error is somewhat larger: 0.071 against 0.049 px for the full-frame 16–35 mm example in the tests. -4. Planes `[R, G, B]`; when the CA table is absent or malformed, a CA value is implausible (|c| above 2·10⁻³ of the - radius, i.e. 6720; the largest value in the 54 photos' tables is 1724) or a colour plane fails the gate, G alone is applied to all - three channels. No opcode when the distortion table is absent or all zero (CA alone is never applied: it is - relative to green), the zoom has no solution in [0.5, 2] or the G fit fails. A refused table leaves the photo - uncorrected rather than wrongly warped. +4. **By default one plane, G**, applied to all three channels: `lens_opcodes` calls `warp_opcode(…, lateral_ca: + false)`. With `lateral_ca: true` (a code change) the planes are `[R, G, B]`, unless the CA table is absent or + malformed, a CA value is implausible (|c| above 2·10⁻³ of the radius, i.e. 6720; the largest value in the 54 + photos' tables is 1724) or a colour plane fails the gate: then G alone again. The G plane is the same either way. + No opcode when the distortion table is absent or all zero (CA alone is never applied: it is relative to green), + the zoom has no solution in [0.5, 2] or the G fit fails. A refused table leaves the photo uncorrected rather than + wrongly warped. The opcode then goes through the same pipeline stage as a DNG's (`crates/pipeline/src/optics.rs`): it rotates with the photo (portrait files are covered) and is carried by DNG export, as RW2's is. @@ -120,7 +124,7 @@ FE 200–600 mm; corrected TIFFs at 24 mm with the FE 24–105 mm). Its conventi | Unit | 15000 | 16384 | | Zoom | fill: the largest `S(ρ / z) / ρ` over the border is 1 | `1 / max f` over the border radii | | Aspect crops | applied; the zoom for the recorded crop is unverified | refused (native 3:2 only) | -| Tables | distortion (16 knots; 11 in APS-C) and lateral CA; vignetting read, not applied | distortion, 16 entries only | +| Tables | distortion (16 knots; 11 in APS-C); lateral CA parsed, off by default; vignetting read, not applied | distortion, 16 entries only | `arw::decode` passes both the same active area and crop (the DNG-style default crop, on the ILCE-7RM6 the same as Sony's crop tags); their model lists don't overlap, so a photo gets at most one of them: an ILCE-7RM6 this page's @@ -167,9 +171,10 @@ conventions it was validated with. Photos: 54 ILCE-7RM6 ARWs: 42 private, and the 12 CC0 samples of the [dnglab RawDB ILCE-7RM6 set](https://rawdb.dnglab.org/sets/Sony/ILCE-7RM6) (`ILCE-7RM6_{FF,APSC}_{land,port}_RAW{,-C,-C-HD}.ARW`: full frame and APS-C, landscape and portrait, lossless, Compressed and Compressed HQ). `cargo xtask corpus --download` -fetches one of them, the APS-C lossless file, as `arw-sony-ilce7rm6-apsc-land-lossless.arw`, for the corpus test -`corpus_sony_lens_tables_become_opcodes` (its full-frame lossless case runs only where that file is present); the ARW -6.0 decoder (#625) pins one Compressed and one Compressed HQ file. +fetches three of them: the APS-C lossless file as `arw-sony-ilce7rm6-apsc-land-lossless.arw`, and the ARW 6.0 +decoder's full-frame Compressed and APS-C Compressed HQ files (`arw6-sony-ilce7rm6-ff-land-c.arw`, +`arw6-sony-ilce7rm6-apsc-land-hq.arw`). The corpus test `corpus_sony_lens_tables_become_opcodes` checks all three, and +the full-frame lossless file where it is present. Four lenses: FE 24–70 mm F2.8 GM II (27 photos), FE 16–35 mm F2.8 GM II (12, all RawDB), FE 100 mm F2.8 Macro GM OSS (9), FE 200–600 mm F5.6–6.3 G OSS (6). 41 photos have in-camera distortion correction on (all private); 13 have it off (the 12 RawDB files and one private photo). Only aggregate numbers per lens are reported. The scripts are local @@ -250,10 +255,18 @@ warp after demosaic moves R and B by 0.84 × the table (correlation 0.96). On th 0.062 → 0.035 px); applied after a demosaic that already hid most of it, it overshoots (rms 0.021 → 0.035 px over the same bins). All of this is far below one pixel at 1440 px. +**Ruling: the lateral CA planes are off by default.** `lens_opcodes` emits the G plane alone; the conversion stays in +the code and its unit tests (`warp_opcode(…, lateral_ca: true)`), so it can be turned on once CA is corrected where +the table applies. **Follow-up:** warp the raw CFA's red and blue sites before demosaic (the table halves the raw CA, +rms 0.062 → 0.035 px), then turn the planes on and repeat this check. Optics ▸ Remove Chromatic Aberration +(`optics.remove_ca`, an automatic estimate from the image) remains available for visible fringes. The distortion +numbers above were measured with the three-plane warp, whose G plane is the one the default emits. + **Fit gate margins** (worst of R, G and B at the gated samples; gate 0.25 px): the 4 lossless files 0.160 px (margin 0.090; 0.220 px at all 65 samples, the padded radii never shown), the 46 full-frame ARW6 files 0.175 px (margin 0.075), the 4 APS-C ARW6 files 0.035 px. The largest CA value in the 54 tables is 1724 (FE 24–70 mm), a quarter of the 6720 -bound. So every photo gets the three-plane warp: the corrected and uncorrected renders differ on all 54. +bound. So every photo passes: with the CA planes on (the verification renders) each got the three-plane warp, and the +corrected and uncorrected renders differ on all 54; by default each gets the one-plane distortion warp. ## Performance @@ -261,15 +274,16 @@ bound. So every photo gets the three-plane warp: the corrected and uncorrected r `arw-sony-ilce7rm6-ff-land-lossless.arw`, `arw6-sony-ilce7rm6-ff-land-hq.arw`), on a 4-core laptop shared with other builds (so absolute times are high); the fastest of 3–5 runs. The warp runs in the `sample` stage: -| Render | Off | Single-plane warp (manual Distortion 1) | On (three planes) | +| Render | Off | One plane (the default; measured with manual Distortion 1) | Three planes (CA planes on) | |---|---|---|---| | Full size 9984 × 6656, lossless | 267 ms | — | 3098 ms | | Full size 9984 × 6656, Compressed HQ | 403 ms | 1187 ms | 4014 ms | | 1920 px long edge (2496 × 1664 source), Compressed HQ | 23.7 ms | 45.4 ms | 112.0 ms | -The three colour planes cost about 2.5–3× a single-plane warp, as for an RW2 or DNG with three planes (the same -path). The stage cache keys the resampled source by the source, framing and output size, so tone, colour and -exposure drags don't re-run it. +The default one-plane warp takes the sample stage from 24 to 45 ms at 1920 px (inferred from the manual Distortion +run: the same single-plane path, one plane for all three channels). The three colour planes cost about 2.5–3× that, +as for an RW2 or DNG with three planes (the same path): the price of turning the CA planes on. The stage cache keys +the resampled source by the source, framing and output size, so tone, colour and exposure drags don't re-run it. ## Limits @@ -283,10 +297,9 @@ exposure drags don't re-run it. 15659 (200–600 mm, 3 photos) and 16017 (100 mm, tiny tables); with the single 15000 the 200–600 mm keeps a systematic residual of up to 0.59 px (within budget, above the method's 0.03 px noise floor). Something lens-dependent is not captured; per-lens units would need per-lens verification. -- **Lateral CA unit provisional** (above), and the CA warp runs after demosaic, as for RW2 and DNG. Our demosaic - already hides about 70 % of the lens CA, so the full table overshoots in the render: within budget, but the rms - corner CA rises from 0.032 to 0.052 px (Verification). Correcting CA before demosaic would be the principled fix; - scaling the colour planes down or dropping them are the cheap alternatives. +- **Lateral CA off by default.** Its unit is provisional (above), and a warp after demosaic, as for RW2 and DNG, + overshoots: our demosaic already hides about 70 % of the lens CA, and with the planes on the rms corner CA rose + from 0.032 to 0.052 px (Verification). Correcting CA before demosaic is the follow-up. - **Vignetting not applied** (above). - **DNG export** carries the warp in `OpcodeList3`, as for RW2: a DNG reader applies it like any embedded lens correction. diff --git a/docs/whats-new.md b/docs/whats-new.md index 57e4e1c52..b1efd9e3a 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -103,16 +103,16 @@ magnified preview isn't taken for the photo when checking focus at 1:1. ### Lens corrections -- Sony A7R VI (ILCE-7RM6) raws are corrected for lens distortion and lateral chromatic aberration from the tables the - camera records in each file, under Optics ▸ Enable Lens Corrections, on by default for newly imported photos. On 33 - photos shot with the camera's correction on (24–70 mm, 100 mm macro, 200–600 mm) the render lines up with the - camera's JPEG within 0.68 px at 1440 px at the corners, where it was up to 17 px off before. The camera's vignetting - table is read but not applied: its own JPEGs don't apply it either. This reaches lossless ARWs now; Compressed and - Compressed HQ ARWs (ARW 6.0) get it once their decoder (#625) lands, and until then open from their embedded JPEG. - The A7R IVA (ILCE-7RM4A) has its own distortion correction, checked against Sony's exports; other Sony bodies record - the same tables, but they stay uncorrected until they are checked the same way. Photos imported earlier get the - correction with Photo ▸ Reload from Disk (when still at their import settings). See - `docs/sony-ilce7rm6-lens-corrections.md`. +- Sony A7R VI (ILCE-7RM6) raws are corrected for lens distortion from the table the camera records in each file, + under Optics ▸ Enable Lens Corrections, on by default for newly imported photos. On 33 photos shot with the camera's + correction on (24–70 mm, 100 mm macro, 200–600 mm) the render lines up with the camera's JPEG within 0.68 px at + 1440 px at the corners, where it was up to 17 px off before. The camera's lateral chromatic aberration table is read + too, but not applied by default: our demosaic already hides most of that colour fringing, and the table on top made + it slightly worse. The vignetting table is read but not applied: the camera's own JPEGs don't apply it either. This + covers lossless, Compressed and Compressed HQ (ARW 6.0) ARWs alike. The A7R IVA (ILCE-7RM4A) has its own distortion + correction, checked against Sony's exports; other Sony bodies record the same tables, but they stay uncorrected until + they are checked the same way. Photos imported earlier get the correction with Photo ▸ Reload from Disk (when still + at their import settings). See `docs/sony-ilce7rm6-lens-corrections.md`. ### JPEG XL export - Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 28fb3a38b..6c60526ad 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -449,7 +449,7 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "ac7c1532df77c321e8010aa1be60c1b9f245b1ac0db6f38fe617a73aae81af50", ), // Sony ILCE-7RM6 lossless, CC0 1.0 (RawDB): the APS-C file, for its lens tables (`vendor::arw_lens_rm6`). The - // full-frame lossless file is not pinned (at most two pins per PR; #625 pins two Compressed ARW 6.0 files). + // full-frame lossless file is not pinned (at most two pins per PR); the two ARW6 pins below cover Compressed files. ( "arw-sony-ilce7rm6-apsc-land-lossless.arw", "https://rawdb.dnglab.org/api/download/Sony/ILCE-7RM6/raw_modes/ILCE-7RM6_APSC_land_RAW.ARW",