From 90fa467d7ee407bf4374dc8e92fb17b69efd48f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 23:53:59 +0000 Subject: [PATCH] LR-EDIT-OPTICS-PROFILE: enable Sony ILCE-7M3 embedded distortion correction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The A7 III's raw-IFD 0x7037 tables were refused until their geometry was checked. Measured against the camera's own JPEG of the same exposure (Lens Comp. Distortion: Auto) on 27 RAW+JPEG pairs from one body with the FE 16-35 F4 ZA, FE 28-70 and FE 50 F1.8 (strong barrel to strong pincushion, compressed and uncompressed, TV and monitor checkerboards): corrected board corners sit 0.06-0.44 px (median, 6000 px frame) from the JPEG's, against 0.8-26 px uncorrected; LightCraft applies the camera's amount of correction within 2 %; corrected rows and columns are as straight as the JPEG's. Sony's crop and the default crop agree on this body, so the warp is centred on the image. A unit test pins the warp from three of these tables to the camera's fitted geometry; the model boundary tests now accept ILCE-7M3 and still refuse its neighbours. RENDER_CACHE_VERSION is bumped. The measurement script handles screen moiré (X-corner check, duplicate corners, consensus lattice vectors). The corpus test's two ILCE-7M3 samples now expect one warp; raw.pixls.us is not reachable from this environment, so that test was skipped here. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS --- crates/engine/src/media.rs | 2 +- crates/raw/src/vendor/arw_lens.rs | 52 +++++++++++++++++++--- crates/raw/tests/corpus.rs | 4 +- docs/gaps.md | 2 +- docs/parity-checklist.md | 2 +- docs/raw-parity.md | 2 +- docs/sony-lens-corrections.md | 59 +++++++++++++++++++++---- docs/whats-new.md | 10 +++++ scripts/sony_distortion_check.py | 72 +++++++++++++++++++++++++------ 9 files changed, 172 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6f6f0359c..58b9712ac 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 = 61; +pub const RENDER_CACHE_VERSION: u64 = 62; /// 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_lens.rs b/crates/raw/src/vendor/arw_lens.rs index f4e02d69d..d6fdd9ad4 100644 --- a/crates/raw/src/vendor/arw_lens.rs +++ b/crates/raw/src/vendor/arw_lens.rs @@ -11,13 +11,13 @@ 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. +/// Only independently validated camera models (ILCE-7RM4A, ILCE-7M3) 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" { + if !matches!(model, "ILCE-7RM4A" | "ILCE-7M3") { return None; } let Value::SShort(values) = &ifd.get(DISTORTION)?.value else { return None }; @@ -147,6 +147,47 @@ mod tests { } } + // ILCE-7M3 raw-IFD tables (FE 16-35mm F4 ZA OSS at 16 and 35 mm, FE 28-70mm F3.5-5.6 OSS at 70 mm) and the + // geometry of the camera's own JPEG of the same exposure (Lens Comp. Distortion: Auto): checkerboard corners of + // that JPEG matched with the uncorrected raw and fitted with the warp's polynomial over the 6000×4000 frame (rms + // 0.11–0.25 px). See docs/sony-lens-corrections.md. + const A7M3: [([i16; 16], [f64; 4]); 3] = [ + ( + [32, 2, -38, -83, -138, -199, -270, -336, -402, -462, -519, -568, -611, -643, -670, -690], + [1.0247000859, -0.1092847508, 0.1061422243, -0.0397989678], + ), + ([2, 2, 4, 9, 18, 29, 45, 65, 92, 124, 164, 210, 264, 329, 403, 489], [0.9708150714, 0.0157256288, 0.0175613359, -0.004107544]), + ([4, 7, 18, 40, 68, 108, 155, 212, 280, 359, 453, 557, 679, 819, 978, 1161], [0.9342002611, 0.0560486347, 0.0076184603, 0.0021497933]), + ]; + + #[test] + fn ilce_7m3_matches_the_cameras_own_jpeg_geometry() { + let frame = Rect::new(0, 0, 6000, 4000); + let evaluate = |k: &[f64], r: f64| r * (k[0] + r * r * (k[1] + r * r * (k[2] + r * r * k[3]))); + for (values, reference) in A7M3 { + let Opcode::WarpRectilinear { planes, center } = distortion("ILCE-7M3", &table(&values), frame, frame).unwrap() else { panic!("warp") }; + assert_eq!(center, [0.5, 0.5]); + // The camera fills its frame with its own zoom; compare the shape over the measured radii (to 0.97). + let radii: Vec = (1..=97).map(|i| i as f64 / 100.0).collect(); + let (ours, camera): (Vec, Vec) = radii.iter().map(|&r| (evaluate(&planes[0], r), evaluate(&reference, r))).unzip(); + let worst = |ours: &[f64]| { + let zoom = ours.iter().zip(&camera).map(|(a, b)| a * b).sum::() / ours.iter().map(|a| a * a).sum::(); + ours.iter().zip(&camera).map(|(a, b)| (a * zoom - b).abs()).fold(0.0, f64::max) + }; + // 0.0008 of the half diagonal is 2.9 px on the 6000 px frame (measured: 0.4–2.4 px); uncorrected is 10×+ off. + let (corrected, uncorrected) = (worst(&ours), worst(&radii)); + assert!(corrected < 0.0008 && uncorrected > 10.0 * corrected, "{values:?}: {corrected} vs {uncorrected}"); + } + // Sony's crop and the default crop agree on this body (12, 12, 6000×4000 of 6048×4024), so the warp sits on + // the image centre. + let Opcode::WarpRectilinear { center, .. } = + distortion("ILCE-7M3", &table(&A7M3[0].0), Rect::new(0, 0, 6048, 4024), Rect::new(12, 12, 6000, 4000)).unwrap() + else { + panic!("warp") + }; + assert!((center[0] * 6047.0 - 3011.5).abs() < 1e-9 && (center[1] * 4023.0 - 2011.5).abs() < 1e-9, "{center:?}"); + } + #[test] fn barrel_framing_and_offset_crop_keep_the_same_geometry() { let crop = Rect::new(32, 20, 9504, 6336); @@ -185,7 +226,8 @@ mod tests { 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("ILCE-7M3", &valid, AREA, AREA).is_some()); + for model in ["", "ILCE-7M4", "ILCE-7RM4", "ILCE-7RM5", "ILCE-9M2", "DSC-RX100M3", "ILCE-7RM4A unknown", "ILCE-7M3 ", "ILCE-7SM3"] { assert!(distortion(model, &valid, AREA, AREA).is_none(), "{model}"); } } @@ -193,7 +235,7 @@ mod tests { #[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 (model, expected) in [("ILCE-7RM4A", 1), ("ILCE-7RM4", 0), ("ILCE-7M3", 1), ("ILCE-7M4", 0), ("", 0)] { for (height, expected) in [(20, expected), (16, 0)] { for order in [ByteOrder::Little, ByteOrder::Big] { let mut raw = IfdBuilder::new(); diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 5ca4777b7..b6604a904 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -980,8 +980,8 @@ fn sony_embedded_distortion_is_limited_to_validated_models() { 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-a7m3-compressed.arw", "ILCE-7M3", 1), + ("arw-sony-a7m3-uncompressed.arw", "ILCE-7M3", 1), ("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), diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f56223..683304ba6 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 ILCE-7RM4A / ILCE-7M3 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) | | 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**: 235 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) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..c50667818 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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/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 (FE 24–105 and FE 200–600 lenses) and ILCE-7M3 (FE 16–35 F4 ZA, FE 28–70, FE 50 F1.8; 27 shots within 0.06–0.44 px median of the camera JPEG at 6000 px, from up to 26 px uncorrected); 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-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 2b4b9efb5..dc88ea7a5 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -213,7 +213,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) and ILCE-7M3 (3 lenses) | | Vignetting / lateral CA from maker notes | ✅ | ⬜ | | Manual distortion, vignetting, CA, defringe | ✅ | ✅ | diff --git a/docs/sony-lens-corrections.md b/docs/sony-lens-corrections.md index 375f773ab..b2fc5c290 100644 --- a/docs/sony-lens-corrections.md +++ b/docs/sony-lens-corrections.md @@ -1,14 +1,15 @@ # 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. +Sony **ILCE-7RM4A (A7R IVA)** and **ILCE-7M3 (A7 III)** 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 +The enabled and independently validated models are the ILCE-7RM4A with the FE 24–105mm F4 G OSS and +FE 200–600mm F5.6–6.3 G OSS, and the ILCE-7M3 with the FE 16–35mm F4 ZA OSS, FE 28–70mm F3.5–5.6 OSS and +FE 50mm F1.8 ([ILCE-7M3 validation](#ilce-7m3-validation)). 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 @@ -92,9 +93,50 @@ Tests cover signedness/length rejection, zero and malformed tables, independent 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. +## ILCE-7M3 validation + +Checked on 2026-10-12 against the camera's own JPEG of the same exposure, which is an independent renderer +that applied the correction (RAW+JPEG, Lens Comp. ▸ Distortion Comp.: Auto). 27 RAW+JPEG pairs from one +ILCE-7M3: a checkerboard filling the frame on a TV (handheld) and on a desktop monitor (tripod), compressed and +uncompressed raws, apertures from wide open to f/8, plus one textured wall. Tables covered strong barrel (16 mm, +−690 at the corner), strong pincushion (70 mm, +1161) and the sign change between them. + +Method (`scripts/sony_distortion_check.py`): LightCraft renders each ARW at full size with the profile correction +on and off; sub-pixel board corners (Hessian saddle points refined with `cornerSubPix`, kept only where the four +quadrants alternate, labelled into rows and columns) are matched with the JPEG's by grid position, with the +match fixed by the central corners before any fit, because a board is periodic. A similarity (zoom, rotation, +shift) is fitted, as the camera fills its frame with its own zoom; no lens coefficient is fitted. The textured wall +uses SIFT matches instead. The script was checked on synthetic boards with a known radial distortion. + +| Lens / focal length | Shots | Corrected vs JPEG: median / p95 (px at 6000) | Uncorrected median | Corner (r > 0.9) | Camera / LightCraft correction | +|---|---:|---|---:|---:|---:| +| FE 16–35 F4 ZA / 16 mm | 10 | 0.33–0.38 / 1.01–1.15 | 16.7–17.0 | 1.40–1.56 px | 1.02 | +| FE 16–35 F4 ZA / 20 mm | 4 | 0.16–0.24 / 0.34–0.50 | 3.9–4.4 | 0.15–0.24 px | 0.98 | +| FE 16–35 F4 ZA / 35 mm | 2 | 0.18–0.21 / 0.39–0.44 | 11.0–11.1 | 0.18–0.26 px | 1.01–1.02 | +| FE 28–70 / 28 mm | 2 | 0.24–0.25 / 0.59–0.64 | 13.2–13.4 | 0.92 px | 1.02 | +| FE 28–70 / 33 mm | 1 | 0.07 / 0.16 | 0.8 | 0.07 px | 1.00 | +| FE 28–70 / 50 mm | 1 | 0.30 / 0.69 | 20.8 | 1.24 px | 1.01 | +| FE 28–70 / 70 mm | 1 | 0.44 / 0.93 | 26.4 | 1.49 px | 1.01 | +| FE 50 F1.8 / 50 mm | 4 | 0.06–0.12 / 0.15–0.31 | 2.8–3.0 | 0.08–0.14 px | 1.00–1.01 | +| Wall, 16 mm (SIFT) | 1 | 0.37 / 1.06 | 5.6 | 1.22 px | — | + +Residuals are in pixels of the 6000×4000 JPEG; at the 2376 px exports used above for the ILCE-7RM4A, the +board medians are 0.02–0.17 px and the p95 at most 0.46 px. The last column fits JPEG ≈ uncorrected + +α·(corrected − uncorrected) radially: LightCraft applies the camera's amount of correction within 2 %, slightly +less for strong barrel and slightly more at 20 mm, which is what remains at the 16 mm corners (about 1.5 px of a +35 px correction). Rows and columns of corrected boards are as straight as in the JPEG in every shot (largest +bow 0.8–7.8 px in both, from the screens), against 7–55 px uncorrected. Compressed and uncompressed raws, +the TV and the monitor, and wide open and f/8 agree. Two 16 mm board shots whose board stopped short of the +corners (reach 0.8 of the half diagonal) gave 0.85 px medians and are left out of the table. + +On this body Sony's crop tags and the default crop agree (origin 12, 12, 6000×4000 of 6048×4024), so the +warp sits on the image centre; fitted against the camera, the centre is within 0.3 px. Unit tests keep the warp from +three of these tables within 0.0008 of the half diagonal (2.9 px at 6000 px) of the camera's fitted geometry. +The photographs stay with their owner; none is committed. + ## Camera coverage and regression boundary -The model check is exact: `ILCE-7RM4` (A7R IV without the A suffix), `ILCE-7M3`, unknown/missing +The model check is exact: `ILCE-7RM4` (A7R IV without the A suffix), 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. @@ -107,7 +149,7 @@ pinned SHA-256 hashes and are checked with `sony_embedded_distortion_is_limited_ |---|---|---|---| | ILCE-7RM4A | Compressed (4822) | 16 samples | One distortion warp | | ILCE-9M2 | Compressed (3989) | 16 samples | None | -| ILCE-7M3 | Compressed, uncompressed | 16 samples | None | +| ILCE-7M3 | Compressed, uncompressed | 16 samples | One distortion warp | | 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 | @@ -119,8 +161,9 @@ pinned SHA-256 hashes and are checked with `sony_embedded_distortion_is_limited_ | ILCE-7SM2, DSLR-A580 | 16:9 framing, one each | Not used; model excluded | None | | DSLR-A900 | Packed 12-bit | Not used; model excluded | 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 +These are **model-boundary regression checks, not new geometric validation of those models**. The A7 III tables +were refused at first because their corner warp was unverified; the [ILCE-7M3 validation](#ilce-7m3-validation) +measured it against the camera's own JPEGs and enabled the body. 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 diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..0277fbf73 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,15 @@ # What's new in LightCraft +## 2026-10-12 + +### Lens distortion correction for the Sony A7 III +- Raws from the Sony A7 III (ILCE-7M3) now get the camera's own distortion correction, read from the file, like + the A7R IVA's: straight lines stay straight at wide angles (the FE 16–35 F4 ZA at 16 mm shifts detail near the + corners by up to about 40 px on a 24 MP photo) and at long ones (the FE 28–70 at 70 mm). It lines up with the + camera's JPEG to well under a pixel. Optics ▸ Enable Lens Corrections turns it off and Profile Distortion scales + it; photos imported before this pick it up with Photo ▸ Reload from Disk. Vignetting and chromatic aberration + from Sony files are not read yet. + ## 2026-10-11 ### JPEG XL export diff --git a/scripts/sony_distortion_check.py b/scripts/sony_distortion_check.py index ee81a118c..9b740b17d 100755 --- a/scripts/sony_distortion_check.py +++ b/scripts/sony_distortion_check.py @@ -69,24 +69,67 @@ def detect_corners(gray): if len(pts) == 0: return pts.reshape(-1, 2) crit = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 50, 0.005) - pts = cv2.cornerSubPix(gray, pts, (11, 11), (-1, -1), crit) - return pts.reshape(-1, 2).astype(np.float64) + pts = cv2.cornerSubPix(gray, pts, (11, 11), (-1, -1), crit).reshape(-1, 2).astype(np.float64) + # Two peaks near one corner can refine to the same point; keep one of each such pair. + drop = {j for _, j in cKDTree(pts).query_pairs(3.0)} + pts = pts[[i for i in range(len(pts)) if i not in drop]] + return pts[[is_x_corner(g, p) for p in pts]] + + +def is_x_corner(g, p, r=12): + """A board corner has alternating quadrants: equal diagonals, contrasting neighbours. + A point on a straight edge (screen texture can make one look like a saddle) does not.""" + h, w = g.shape + x, y = int(round(p[0])), int(round(p[1])) + if not (r <= x < w - r and r <= y < h - r): + return False + a, c = g[y + r, x + r], g[y - r, x - r] + b, d = g[y - r, x + r], g[y + r, x - r] + contrast = abs((a + c) - (b + d)) / 2 + return contrast > 15 and contrast > 2 * max(abs(a - c), abs(b - d)) def label_lattice(pts, centre): - """Integer (i, j) grid labels for corners connected to the one nearest the centre. - i grows along +x, j along +y; local lattice vectors follow the distortion gradually.""" + """Integer (i, j) grid labels for corners connected to one near the centre. + i grows along +x, j along +y; local lattice vectors follow the distortion gradually. + The starting vectors are the consensus of many central corners, so a stray corner (screen + moiré) next to the start can't derail it; a few starts are tried, the largest result kept.""" if len(pts) < 20: return {} tree = cKDTree(pts) - start = int(tree.query(centre)[1]) - d, idx = tree.query(pts[start], k=5) - offsets = pts[idx[1:]] - pts[start] - spacing = float(np.median(d[1:])) - a = offsets[np.argmax(offsets @ np.array([1.0, 0.0]))] - b = offsets[np.argmax(offsets @ np.array([0.0, 1.0]))] - if abs(np.dot(a, b)) > 0.5 * np.linalg.norm(a) * np.linalg.norm(b): + vectors = lattice_vectors(pts, tree, centre) + if vectors is None: return {} + best = {} + for start in tree.query(centre, k=min(9, len(pts)))[1]: + labels = grow_lattice(pts, tree, int(start), *vectors) + if len(labels) > len(best): + best = labels + if len(best) > 0.8 * len(pts): + break + return best if len(best) > 50 else {} + + +def lattice_vectors(pts, tree, centre): + """Median neighbour offsets near +x and +y among the corners nearest the centre.""" + k = min(9, len(pts)) + offsets = [] + for i in tree.query(centre, k=min(60, len(pts)))[1]: + nn = tree.query(pts[i], k=k)[1] + offsets.extend(pts[nn[1:]] - pts[i]) + offsets = np.array(offsets) + length = np.linalg.norm(offsets, axis=1) + spacing = np.median(np.sort(length.reshape(-1, k - 1), axis=1)[:, :4]) + offsets = offsets[np.abs(length - spacing) < 0.25 * spacing] + angle = np.arctan2(offsets[:, 1], offsets[:, 0]) + near_x = np.abs(np.angle(np.exp(1j * angle))) < np.pi / 4 + near_y = np.abs(np.angle(np.exp(1j * (angle - np.pi / 2)))) < np.pi / 4 + if near_x.sum() < 10 or near_y.sum() < 10: + return None + return np.median(offsets[near_x], axis=0), np.median(offsets[near_y], axis=0) + + +def grow_lattice(pts, tree, start, a, b): labels = {start: (0, 0)} taken = {(0, 0): start} vec = {start: (a, b)} @@ -109,7 +152,7 @@ def label_lattice(pts, centre): taken[key] = q vec[q] = (na, nb) queue.append(q) - return labels if len(labels) > 50 else {} + return labels def board(path): @@ -305,8 +348,9 @@ def measure(cli, work, arw): s, d = match_boards(grid, (size[0] * k, size[1] * k), jgrid, jsize) else: s, d = sift_pairs(path, jpg) - if s is not None: - out["vs_jpeg_" + tag] = agreement(s, d, jsize) + a = agreement(s, d, jsize) if s is not None else None + if a: + out["vs_jpeg_" + tag] = a if "vs_jpeg_on" in out and "vs_jpeg_off" in out and out["kind"] == "board": out["fraction"] = correction_fraction(out["vs_jpeg_on"], out["vs_jpeg_off"], jsize) for tag in ("on", "off"):