diff --git a/ROADMAP.md b/ROADMAP.md index dad5a432..ff1aa0ed 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,7 +36,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | User | Readiness | What blocks them | |---|---:|---| | JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks | -| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); Panasonic compacts and kit zooms also miss their embedded distortion correction | +| Nikon / Sony / Panasonic / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW, NEF and RW2 preview estimates are only a starting point); embedded distortion is supported for Panasonic / Leica RW2 and the Sony ILCE-7RM4A (Bayer ARW, 24–105 / 200–600 tested); other lens metadata needs coverage and validation | | Fujifilm raw shooter | ~65% | Uncompressed and lossless/lossy compressed RAF decoded; camera colour calibration remains missing. Verified on 13 bodies, including 14/16-bit GFX; see `docs/raf-compression.md` | | Canon CR3 / Olympus compressed-raw shooter | ~45% | CR3 lossless Bayer and version 0x100/0x200 C-RAW develop from sensor data (M50, R100, R8 verified pixel for pixel); other CRX variants and compressed ORF open as embedded JPEG previews. Camera colour and broader model verification remain | | Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering | @@ -57,7 +57,8 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par checked for plausible colour; fix per-model bugs (#85). 4. **Render fidelity suite** (LR-BEHAV-RENDER-FIDELITY, P1): measure our output against Lightroom on the same CC0 raws (references stay in the local `plan/`), then tune against the numbers. -5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1). +5. **Lens profiles of our own** (LR-EDIT-OPTICS-PROFILE, P1): DNG, Panasonic / Leica RW2 and Sony ILCE-7RM4A Bayer ARW + embedded distortion are supported; broaden validation and decode remaining vignetting / lateral CA metadata. 6. **AI model strategy** (maintainer decision): which permissively licensed models (or our own training) for segmentation masks and denoise. Bayer denoise inference is implemented in pure Rust with an opt-in GPL model; release policy and independently licensed weights remain open. Unblocks M12 and Enhance. Face detection and recognition run on our own checked Rust engine. Object / Describe masks now run Meta's SAM 3 in pure Rust (optional, user-consented download under the non-OSI SAM License; CDN mirrors still diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index a14093de..3961b5a2 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 = 38; +pub const RENDER_CACHE_VERSION: u64 = 39; /// 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/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index 1e8f64bb..b9a05a49 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -485,19 +485,27 @@ fn word16_order(strip: &[u8], bits: u32, file: lightcraft_tiff::ByteOrder) -> li if n > 0 && over_other == 0 && over_file * 100 > n { other } else { file } } -/// The image area for files without Sony's crop tags (`0x74c7/0x74c8`, written since about 2017): the DNG-style -/// default crop when the raw IFD has one, else the image size the camera records (see [`recorded_size_crop`]). +/// The raw IFD's DNG-style `DefaultCropOrigin` / `DefaultCropSize`, clipped to the `w × h` frame. /// /// `centred` places that recorded-size window in the middle of the frame (on even offsets, keeping the CFA phase) /// instead of at the left: the packed 12-bit frame (DSLR-A900) has no padding at either edge, and its camera JPEG /// is centred on the frame. -fn default_crop(raw: &Ifd, mn: Option<&makernote::MakerNote>, exif_size: Option<(u64, u64)>, w: usize, h: usize, centred: bool) -> Rect { - let full = Rect::new(0, 0, w, h); +fn dng_default_crop(raw: &Ifd, w: usize, h: usize, centred: bool) -> Option { if let (Some([x, y]), Some([cw, ch])) = (raw.u64s(t::DEFAULT_CROP_ORIGIN).as_deref(), raw.u64s(t::DEFAULT_CROP_SIZE).as_deref()) && *cw > 0 && *ch > 0 { - return Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h); + return Some(Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h)); + } + None +} + +/// The image area for files without Sony's crop tags (`0x74c7/0x74c8`, written since about 2017): the DNG-style +/// default crop when the raw IFD has one, else the image size the camera records (see [`recorded_size_crop`]). +fn default_crop(raw: &Ifd, mn: Option<&makernote::MakerNote>, exif_size: Option<(u64, u64)>, w: usize, h: usize) -> Rect { + let full = Rect::new(0, 0, w, h); + if let Some(crop) = dng_default_crop(raw, w, h) { + return crop; } match mn.and_then(|m| m.ifd.u64s(MN_FULL_IMAGE_SIZE)).as_deref() { Some([fh, fw]) => recorded_size_crop((*fw, *fh), exif_size, w, h, centred).unwrap_or(full), @@ -681,7 +689,19 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { // makes a neutral surface magenta. WB edits remain relative to this as-shot RGB. wb_multipliers: if linear_rgb { Some([1.0; 3]) } else { wb }, linearized: false, - opcodes: OpcodeLists::default(), + // 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. + opcodes: OpcodeLists { + list3: if linear_rgb { + 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() + }, + ..Default::default() + }, metadata, }; img.validate_for(mode)?; diff --git a/crates/raw/src/vendor/arw_lens.rs b/crates/raw/src/vendor/arw_lens.rs new file mode 100644 index 00000000..2fa0fdb7 --- /dev/null +++ b/crates/raw/src/vendor/arw_lens.rs @@ -0,0 +1,230 @@ +//! Sony's plain raw-IFD distortion table, expressed as a DNG warp for the shared optics pipeline. +//! +//! Tag identity/layout: https://exiftool.org/TagNames/EXIF.html (`0x7037`, signed shorts). +//! Geometry inferred independently from files and Sony Imaging Edge exports; see docs/sony-lens-corrections.md. +//! No lens database or external decoder code. The unrelated MakerNote table has a different scale. + +use crate::{Opcode, Rect}; +use lightcraft_tiff::{Ifd, Value}; + +const DISTORTION: u16 = 0x7037; +const SAMPLES: usize = 1024; +const MAX_ERROR: f64 = 0.0005; + +/// Only independently validated camera models and the 16-entry layout. Unknown models/layouts +/// and implausible/non-smooth maps are left uncorrected. See docs/sony-lens-corrections.md. +/// A camera setting of Off does not remove the lens data: the user can enable it in the developer. +pub(super) fn distortion(model: &str, ifd: &Ifd, active: Rect, crop: Rect) -> Option { + // A matching table shape does not establish its scale or radial normalization on another body. + // Keep this exact: ILCE-7RM4 and ILCE-7RM4A are distinct models, not interchangeable aliases. + if model != "ILCE-7RM4A" { + return None; + } + let Value::SShort(values) = &ifd.get(DISTORTION)?.value else { return None }; + let [16, table @ ..] = values.as_slice() else { return None }; + let table: &[i16; 16] = table.try_into().ok()?; + if table.iter().all(|v| *v == 0) || table.iter().any(|v| !(-4096..=4096).contains(v)) { + return None; + } + let crop = crop.clipped(active.width, active.height); + if active.width < 2 || active.height < 2 || crop.width < 2 || crop.height < 2 { + return None; + } + // Only native 3:2 framing has been validated. An in-camera aspect crop may retain a table + // normalized to the full sensor frame; do not silently reinterpret it as the smaller crop. + if (crop.width as f64 / crop.height as f64 - 1.5).abs() > 0.005 { + return None; + } + // Interpolate fractional radial scale at evenly spaced radii from centre to cropped corner. + let scale = |r: f64| { + let at = r.clamp(0.0, 1.0) * 15.0; + let i = (at.floor() as usize).min(14); + let a = f64::from(table[i]); + let b = f64::from(table[i + 1]); + 1.0 + (a + (b - a) * (at - i as f64)) / 16384.0 + }; + let (cw, ch) = (crop.width as f64 - 1.0, crop.height as f64 - 1.0); + let diagonal = cw.hypot(ch); + // Zoom until the entire rectangular output boundary is inside the source. For a piecewise-linear + // scale its maximum on that boundary occurs at its nearest radius or at one of the remaining knots. + let nearest = cw.min(ch) / diagonal; + let max_scale = (0..16).map(|i| i as f64 / 15.0).filter(|&r| r >= nearest).map(scale).fold(scale(nearest), f64::max); + let zoom = 1.0 / max_scale; + let basis = |r: f64| { + let r2 = r * r; + [r, r * r2, r * r2 * r2, r * r2 * r2 * r2] + }; + let mut matrix = [[0.0; 4]; 4]; + let mut rhs = [0.0; 4]; + let mut previous = -1.0; + for j in 0..=SAMPLES { + let r = j as f64 / SAMPLES as f64; + let source = r * scale(r) * zoom; + if source <= previous { + return None; + } + previous = source; + let terms = basis(r); + for (i, &a) in terms.iter().enumerate() { + rhs[i] += a * source; + for (k, &b) in terms.iter().enumerate() { + matrix[i][k] += a * b; + } + } + } + let mut k = solve4(matrix, rhs)?; + // Keep the polynomial within a fraction of a preview pixel of the file's table, and forbid folds. + for j in 0..=SAMPLES { + let r = j as f64 / SAMPLES as f64; + let fit = basis(r).iter().zip(k).map(|(a, b)| a * b).sum::(); + let r2 = r * r; + let derivative = k[0] + r2 * (3.0 * k[1] + r2 * (5.0 * k[2] + r2 * 7.0 * k[3])); + if (fit - r * scale(r) * zoom).abs() > MAX_ERROR || derivative <= 0.0 { + return None; + } + } + // DNG uses the farthest *active-area* corner, not the cropped diagonal. Preserve the same mapping + // after the engine translates the opcode into default-cropped and EXIF-oriented coordinates. + let (ax, ay) = (active.width as f64 - 1.0, active.height as f64 - 1.0); + let (cx, cy) = (crop.x as f64 + cw / 2.0, crop.y as f64 + ch / 2.0); + let farthest = cx.max(ax - cx).hypot(cy.max(ay - cy)); + let ratio2 = (2.0 * farthest / diagonal).powi(2); + for (i, coefficient) in k.iter_mut().enumerate() { + *coefficient *= ratio2.powi(i as i32); + } + Some(Opcode::WarpRectilinear { planes: vec![[k[0], k[1], k[2], k[3], 0.0, 0.0]], center: [cx / ax, cy / ay] }) +} + +/// Small fixed-size least-squares system; partial pivoting, no input-sized allocations. +fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> { + for col in 0..4 { + let pivot = (col..4).max_by(|&i, &j| a[i][col].abs().total_cmp(&a[j][col].abs()))?; + if !a[pivot][col].is_finite() || a[pivot][col].abs() < 1e-12 { + return None; + } + a.swap(col, pivot); + b.swap(col, pivot); + for row in col + 1..4 { + let factor = a[row][col] / a[col][col]; + for j in col..4 { + a[row][j] -= factor * a[col][j]; + } + b[row] -= factor * b[col]; + } + } + let mut x = [0.0; 4]; + for i in (0..4).rev() { + x[i] = (b[i] - (i + 1..4).map(|j| a[i][j] * x[j]).sum::()) / a[i][i]; + } + x.iter().all(|v| v.is_finite()).then_some(x) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_tiff::Entry; + + fn ifd(value: Value) -> Ifd { + Ifd { entries: vec![Entry { tag: DISTORTION, value, offset: 0 }], ..Default::default() } + } + fn table(values: &[i16]) -> Ifd { + ifd(Value::SShort(std::iter::once(16).chain(values.iter().copied()).collect())) + } + const TELE: [i16; 16] = [-2, 1, 7, 17, 31, 48, 68, 93, 120, 152, 187, 225, 265, 309, 356, 403]; + const WIDE: [i16; 16] = [14, 0, -27, -59, -106, -158, -223, -291, -367, -440, -519, -593, -667, -731, -792, -839]; + const AREA: Rect = Rect { x: 0, y: 0, width: 9504, height: 6336 }; + + #[test] + fn matches_independent_sony_reference_geometry() { + // On/off feature correspondences from Imaging Edge 4.1 at 600 mm, independently fitted. + let Opcode::WarpRectilinear { planes, center } = distortion("ILCE-7RM4A", &table(&TELE), AREA, AREA).unwrap() else { panic!("warp") }; + assert_eq!(center, [0.5, 0.5]); + let reference = [0.9760159044, 0.0254891644, -0.0005984764, -0.0010120638]; + for i in 0..=100 { + let r = i as f64 / 100.0; + let evaluate = |k: &[f64]| r * (k[0] + r * r * (k[1] + r * r * (k[2] + r * r * k[3]))); + assert!((evaluate(&planes[0]) - evaluate(&reference)).abs() < 0.00015); + } + } + + #[test] + fn barrel_framing_and_offset_crop_keep_the_same_geometry() { + let crop = Rect::new(32, 20, 9504, 6336); + let active = Rect::new(0, 0, 9600, 6376); + let Opcode::WarpRectilinear { planes: a, .. } = distortion("ILCE-7RM4A", &table(&WIDE), AREA, AREA).unwrap() else { panic!("warp") }; + let Opcode::WarpRectilinear { planes: b, center } = distortion("ILCE-7RM4A", &table(&WIDE), active, crop).unwrap() else { panic!("warp") }; + assert!(a[0][0] > 1.02 && a[0][0] < 1.03, "barrel correction expands the middle"); + assert!((center[0] * 9599.0 - 4783.5).abs() < 1e-10); + assert!((center[1] * 6375.0 - 3187.5).abs() < 1e-10); + let ratio = (4815.5f64.hypot(3187.5)) / (4751.5f64.hypot(3167.5)); + for i in 0..4 { + assert!((a[0][i] - b[0][i] / ratio.powi(2 * i as i32)).abs() < 1e-10); + } + } + + #[test] + fn refuses_missing_unknown_malformed_or_unusable_tables() { + for bad in [ + Ifd::default(), + table(&[]), + table(&TELE[..15]), + table(&[0; 16]), + table(&[i16::MIN; 16]), + table(&[0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + ifd(Value::SShort(vec![15; 17])), + ifd(Value::Short(vec![16; 17])), + ifd(Value::Double(vec![f64::NAN; 17])), + ] { + assert!(distortion("ILCE-7RM4A", &bad, AREA, AREA).is_none()); + } + assert!(distortion("ILCE-7RM4A", &table(&TELE), Rect::new(0, 0, 1, 1), AREA).is_none()); + assert!(distortion("ILCE-7RM4A", &table(&TELE), AREA, Rect::new(0, 0, 9504, 5346)).is_none()); + assert!(distortion("ILCE-7RM4A", &table(&TELE), AREA, Rect::new(99999, 0, 10, 10)).is_none()); + } + #[test] + fn refuses_unvalidated_models_even_with_a_valid_table() { + let valid = table(&TELE); + assert!(distortion("ILCE-7RM4A", &valid, AREA, AREA).is_some()); + for model in ["", "ILCE-7M3", "ILCE-7M4", "ILCE-7RM4", "ILCE-7RM5", "ILCE-9M2", "DSC-RX100M3", "ILCE-7RM4A unknown"] { + assert!(distortion(model, &valid, AREA, AREA).is_none(), "{model}"); + } + } + + #[test] + fn header_and_full_decode_preserve_correction_through_dng() { + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, tags as t}; + for (model, expected) in [("ILCE-7RM4A", 1), ("ILCE-7RM4", 0), ("ILCE-7M3", 0), ("", 0)] { + for order in [ByteOrder::Little, ByteOrder::Big] { + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii(model.into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![24])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::WHITE_LEVEL, Value::Long(vec![16383])); + raw.set(0x74c7, Value::Long(vec![0, 2])); + raw.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![2, 2])); + raw.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![30, 20])); + raw.set(0x74c8, Value::Long(vec![30, 20])); + raw.set(DISTORTION, Value::SShort(std::iter::once(16).chain(TELE).collect())); + // Zero pixels have the same byte representation in both orders. + raw.set_image(ImageData::Strips { rows_per_strip: 24, strips: vec![vec![0; 32 * 24 * 2]] }); + let file = TiffWriter { order, ..Default::default() }.write(&[raw]).unwrap(); + let header = super::super::arw::decode(&file, crate::Mode::Header).unwrap(); + let full = super::super::arw::decode(&file, crate::Mode::Full).unwrap(); + assert_eq!(header.info(), full.info()); + assert_eq!(full.opcodes.list3.len(), expected, "{model} {order:?}"); + // the image keeps Sony's crop tags; the warp is centred on the DNG default crop (x 2..32 of 32) + assert_eq!(full.crop, Rect::new(0, 2, 30, 20)); + if let Some(Opcode::WarpRectilinear { center, .. }) = full.opcodes.list3.first() { + assert!((center[0] - 16.5 / 31.0).abs() < 1e-12 && (center[1] - 11.5 / 23.0).abs() < 1e-12, "{center:?}"); + } + let dng = crate::write_dng(&full, &Default::default()).unwrap(); + assert_eq!(crate::decode(&dng).unwrap().opcodes.list3, full.opcodes.list3); + } + } + } +} diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index e0019c48..fcab4eab 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -1,6 +1,7 @@ //! Vendor raw formats (TIFF-based) and helpers shared by them. pub mod arw; +mod arw_lens; pub mod cr2; pub mod cr3; pub mod crx; diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 3ccdef45..8649dd70 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -896,3 +896,38 @@ fn corpus_raws_keep_their_container_and_are_not_thumbnail_shells() { } eprintln!("{checked} corpus raws keep their container"); } + +/// Matching Sony table dimensions are not evidence of a shared correction model. Exercise the +/// actual corpus headers and decoded files as well as the synthetic model-boundary unit tests. +#[test] +fn sony_embedded_distortion_is_limited_to_validated_models() { + let root = corpus_root().join("raw"); + let mut checked = 0; + for (name, model, expected) in [ + ("arw-sony-a7rm4a-compressed.arw", "ILCE-7RM4A", 1), + ("arw-sony-a9m2-compressed.arw", "ILCE-9M2", 0), + ("arw-sony-a7m3-compressed.arw", "ILCE-7M3", 0), + ("arw-sony-a7m3-uncompressed.arw", "ILCE-7M3", 0), + ("arw-sony-a7m4-14bit.arw", "ILCE-7M4", 0), + ("arw-sony-a7m4-lossless-l.arw", "ILCE-7M4", 0), + ("arw-sony-a7m4-lossless-m.arw", "ILCE-7M4", 0), + ("arw-sony-a7m4-lossless-s.arw", "ILCE-7M4", 0), + ("arw-sony-a7rm2-12bit-uncompressed.arw", "ILCE-7RM2", 0), + ("arw-sony-rx100.arw", "DSC-RX100", 0), + ("arw-sony-rx100m3.arw", "DSC-RX100M3", 0), + ] { + let path = root.join(name); + if !path.exists() { + eprintln!("skip: {} absent", path.display()); + continue; + } + let bytes = std::fs::read(&path).unwrap(); + let full = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(probe_info(&bytes).unwrap(), full.info(), "{name}"); + assert_eq!(full.metadata.model.as_deref(), Some(model), "{name}"); + assert_eq!(full.opcodes.list3.len(), expected, "{name}: only the validated Sony model gets a distortion warp"); + eprintln!("{name}: {expected} warp(s); header and full decode agree"); + checked += 1; + } + eprintln!("Sony distortion model boundary: {checked} corpus files checked"); +} diff --git a/docs/parity.md b/docs/parity.md index 3cec757c..e08fc3c6 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -76,7 +76,9 @@ Take the first one nobody is working on. 4b. **LR-VIEW-ZOOM** (P0): finish the sharp zoomed views of issue #323: windows for compare, overlays and soft proofing (Before/After has them); check sharpness and the wide-stage margin on real raw photos (only synthetic scenes so far). 5. **LR-EDIT-OPTICS-PROFILE** (P1): a lens-profile database of our own (corrections embedded in DNG files and the - distortion correction Panasonic / Leica cameras record in RW2 / RWL files work today; issue #256). + distortion correction Panasonic / Leica cameras record in RW2 / RWL files work today; issue #256). Sony ILCE-7RM4A Bayer + ARW 16-sample tables in native 3:2 framing now feed the same distortion path; broader coverage and Sony + vignetting / lateral CA remain (see `docs/sony-lens-corrections.md`). 6. **AI masks and Enhance** (LR-MASK-SUBJECT / SKY / PEOPLE / OBJECTS, LR-EDIT-DETAIL-DENOISE, SUPERRES, LENSBLUR): Object and Describe masks run SAM 3 in pure Rust (`crates/segment`); the weights (SAM License) are never bundled: the app offers a consented, verified download, but **LightCraft's CDN mirrors are not configured yet** (the @@ -274,7 +276,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | | | LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | | | | LR-EDIT-OPTICS-CA | Remove chromatic aberration | P1 | ✅ | `crates/ui-egui/src/panels/edit.rs` (checkbox), `ctl:optics.caRed`, `ctl:optics.caBlue` | | -| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); vignetting and lateral CA data of other raw formats (Olympus, Fujifilm, Sony) not read yet; Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); no lens-profile database | +| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled only for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses; other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); vignetting and lateral CA data of other raw formats (Olympus, Fujifilm, Sony) not read yet; Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); no lens-profile database | ) | LR-EDIT-OPTICS-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/sony-lens-corrections.md b/docs/sony-lens-corrections.md new file mode 100644 index 00000000..9137dfbf --- /dev/null +++ b/docs/sony-lens-corrections.md @@ -0,0 +1,117 @@ +# Sony embedded distortion corrections + +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. +It converts that table into `OpcodeList3` / `WarpRectilinear`, so the existing profile correction control, +CPU/GPU optics path, EXIF orientation handling, and DNG export use the same correction. + +This adds **distortion only**. Sony vignetting and lateral chromatic-aberration tables are not decoded. +The enabled and independently validated model is ILCE-7RM4A with the FE 24–105mm F4 G OSS and +FE 200–600mm F5.6–6.3 G OSS. Linear YCbCr ARWs and non-3:2 camera crops are excluded pending validation. An aspect crop may +retain the full-frame radial normalization; the decoder does not guess that relationship. Older files that carry +only encrypted correction metadata, different table lengths, and rejected tables remain uncorrected. +The camera's distortion Off setting does not erase its table. Newly imported photos use LightCraft's existing +embedded-profile default (enabled); the profile correction control can disable or adjust it. + +## Clean-room evidence + +The [ExifTool EXIF tag-name documentation](https://exiftool.org/TagNames/EXIF.html) identifies +`0x7037` as 17 signed 16-bit integers. The first value is the count, 16, followed by radial samples. +No external photo decoder implementation or Adobe lens profile was consulted. +Sony Imaging Edge Edit 4.1 was used only as a black-box reference on local scratch copies. + +The observed inverse mapping, with radius `r` normalized to the default crop's half diagonal, is: + +``` +f(r) = 1 + interpolate(table, r) / 16384 +source = center + (output - center) * zoom * f(r) +zoom = 1 / max(f(r)) over radii on the rectangular output boundary +``` + +The border radii range from the shorter half edge / half diagonal to 1. Table knots span 0 through 1. +Linear interpolation approximates the observed map closely; we do not claim to reproduce Sony's +internal interpolator. A fixed-size weighted least-squares fit expresses source radius as +`r * (k0 + k1*r² + k2*r⁴ + k3*r⁶)`. The fit is rejected if radial error exceeds 0.0005 of the +half diagonal (about 0.43 px at a 1440 px 3:2 preview), or the mapping folds. Table values are bounded +and the input must have precisely the supported signed-short layout. Crop offset and normalization +are converted into DNG's active-area coordinate system before creating the opcode. The warp's centre +and normalization come from the standard `DefaultCropOrigin` / `DefaultCropSize` when the file has them, +not from Sony's crop tags: on the tested A7R IVA files the former start at raw x=32 and the latter at +x=0, and the standard origin is the one that aligns with Sony's exports. The image itself keeps the +decoder's crop precedence for every Sony body (Sony's crop tags, then the default crop, then the +recorded image size), so the framing is unchanged; the opcode's centre is in active-area coordinates, +so the warp stays on the optical centre either way. On the A7R IVA that framing still sits 32 raw +pixels left of Sony's exports (eight pixels in a 2376-pixel-wide export). + +The main raw-IFD table is important: the similarly named MakerNote table has different values and +must not be substituted into this formula. Main raw-IFD and SR2SubIFD values agreed in the test files. + +## Validation + +At 2376×1584, independent SIFT correspondences between Sony distortion On and Off TIFF exports gave: + +| Lens / focal length | Median residual | 95th percentile | Maximum matched radius | +|---|---:|---:|---:| +| FE 200–600 / 200 mm | 0.043 px | 0.191 px | 0.97 half diagonals | +| FE 200–600 / 600 mm | 0.055 px | 0.231 px | 0.95 half diagonals | + +These use the fixed 16384 scale and automatic border framing, not fitted per-image scale or zoom. +A median translation below 0.004 px was removed from the residuals. Correspondences do not cover +all pixels; these are geometric feature residuals, not photometric equality or a worst-case guarantee. +Across 20 sampled RAWs (16 at 24–105 mm, four at 200–600 mm), the polynomial's maximum deviation +from the interpolated file table was below 0.12 px at 1440 px. That measures approximation error, +not agreement with an independent renderer. Camera JPEG and Apple Core Image comparisons support +the wide-lens interpretation but are weaker evidence than same-renderer On/Off pairs. + +Actual LightCraft exports after crop normalization were also compared directly with Sony's corrected +TIFFs. At 2376 px wide, median / 95th-percentile feature residuals were 0.35 / 1.04 px at 24 mm, +0.15 / 0.40 px at 200 mm, and 0.13 / 0.56 px at 600 mm (median translation below 0.024 px). +The uncorrected medians at 200 and 600 mm were 8.63 and 10.82 px respectively. Different colour, +sharpening, and demosaicing affect feature localization; these are geometry checks, not colour parity. +An isolated desktop run on Apple M1 Metal detected the profile, rendered without GPU fallback or +warnings, and exposed the existing Optics controls. Existing library edits are not reset: once the +source has loaded, enable lens corrections if they were previously disabled. + +Tests cover signedness/length rejection, zero and malformed tables, independent 600 mm reference +geometry, barrel framing, offset crop normalization, both TIFF byte orders, header/full consistency, +and preservation through DNG export. No private photographs or reference TIFFs are committed. + +## Camera coverage and regression boundary + +The model check is exact: `ILCE-7RM4` (A7R IV without the A suffix), `ILCE-7M3`, unknown/missing +model names, and every other model remain unsupported by this distortion decoder. Matching tag +numbers and table lengths do not establish the scale, interpolation or crop normalization on another +body. Support can expand after independent geometry validation; do not add model-name prefix matching. + +The original nine Sony samples listed by `cargo xtask corpus --download` were downloaded from its +CC0 URLs. The manifest now also includes CC0 samples 4822 (A7R IVA / 24–105 mm) and 3989 +(A9 II / 200–600 mm), with their SHA-256 hashes recorded alongside the URLs. All eleven files +are checked with `sony_embedded_distortion_is_limited_to_validated_models`: + +| Camera | Corpus samples | Raw-IFD table observed | Expected correction | +|---|---|---|---| +| ILCE-7RM4A | Compressed (4822) | 16 samples | One distortion warp | +| ILCE-9M2 | Compressed (3989) | 16 samples | None | +| ILCE-7M3 | Compressed, uncompressed | 16 samples | None | +| ILCE-7M4 | 14-bit, lossless L/M/S | 11 samples in the 14-bit file; 16 in L/M/S | None | +| ILCE-7RM2 | 12-bit uncompressed | 16 samples | None | +| DSC-RX100 | One | No raw-IFD table | None | +| DSC-RX100M3 | One | 11 samples | None | + +These are **model-boundary regression checks, not new geometric validation of those models**. In particular, +refusing the A7 III tables resolves the unverified corner warp identified in review. The lossless M/S +files are linear YCbCr and remain excluded independently of the model check. Header and full decode +must agree for every sample. Synthetic tests also verify both TIFF byte orders, exact model matching, +that the warp is centred on the standard default crop while the image keeps Sony's crop, and that DNG export preserves an accepted warp without adding one to rejected +models. The existing independent Sony geometry tests continue to cover the enabled A7R IVA path. +Re-exporting the 24, 200 and 600 mm reference files after the model restriction produced byte-identical +corrected PNGs to the earlier validated exports. Isolated headless app checks showed the A7R IVA profile +controls present and the A7 III profile controls absent, with no notices and completed RAW renders. + +Before enabling another body, compare corrected and uncorrected renders against independent references +across multiple lenses/focal lengths and photographs with features near the edges and corners. Record +residuals and spatial coverage, and check crop/aspect and compression variants. Embedded JPEGs are +useful when the camera actually applied distortion correction; an Off JPEG cannot certify an On warp. +Prefer same-renderer Sony On/Off exports when the camera setting or geometry is ambiguous. Table-fit +error alone only checks our polynomial approximation, not whether the table's interpretation is correct. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index a42b4a4c..b9aaa225 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -313,6 +313,16 @@ fn cmd_ci() -> Result<(), String> { /// The third field is the SHA-256 of the file as downloaded; `cargo xtask corpus --download` checks it. /// One per format / compression variant we decode or deliberately report as unsupported (preview only). const RAW_SAMPLES: &[(&str, &str, &str)] = &[ + ( + "arw-sony-a7rm4a-compressed.arw", + "https://raw.pixls.us/getfile.php/4822/nice/Sony%20-%20ILCE-7RM4A%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW", + "690c774f1d7bc1db3fa8c2489762743d8e7586e50b6f65d0b6a61cb467972c67", + ), + ( + "arw-sony-a9m2-compressed.arw", + "https://raw.pixls.us/getfile.php/3989/nice/Sony%20-%20ILCE-9M2%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW", + "161c2a9da2b5f1e50be6117a0b4da0ce249660b5d7de13568d301c4034716b97", + ), ( "arw-sony-a7m3-compressed.arw", "https://raw.pixls.us/getfile.php/2414/nice/Sony%20-%20ILCE-7M3%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW",