Browse / github.com/storytold/lightcraft / patch
LR-EDIT-OPTICS-PROFILE: enable Sony ILCE-7M3 embedded distortion correction (rebase of #1384)
LR-EDIT-OPTICS-PROFILE: enable Sony ILCE-7M3 embedded distortion correction (rebase of #1384) Rebased follow-up of #1384 by @alvarohn, which conflicts with current `main`. The original commits keep their author; only the conflict resolution is new. If #1384 is rebased itself, close this one. ## Problem Sony A7 III (ILCE-7M3) raws carry the camera's own distortion table (raw IFD `0x7037`), but LightCraft refuses it because the body was not validated, so Optics > Enable Lens Corrections changes nothing on A7 III photos. ## Change Same change as #1384, ported to the current `arw_lens.rs` (the decoder was rewritten in #1176 and gained the A7CR path in #1901): - `ILCE-7M3` joins `DISTO
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- janickm (imported from pull request #3144, open)
- Base
662df66f1c08bdb1b1f1d0c1c638c4cef936d0b0- Size
- +548 −21 in 10 files
- Review
- pending
- Shared
- 2026-10-28
- Id
e7ba80b067b48a4ba30715f1
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/media/source.rs crates/raw/src/vendor/arw_lens.rs crates/raw/tests/it/corpus.rs docs/gaps.md docs/parity-checklist.md docs/raw-parity.md docs/sony-lens-corrections.md docs/whats-new.md tools/requirements-sony-distortion-check.txt tools/sony_distortion_check.py
@@ -1,7 +1,7 @@ use super::*; /// Bump when the pipeline's output changes, to invalidate cached thumbnails.-pub const RENDER_CACHE_VERSION: u64 = 70;+pub const RENDER_CACHE_VERSION: u64 = 71; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];@@ -1,6 +1,6 @@ //! Sony's in-file lens-correction records (shading, lateral chromatic aberration, distortion): bounded decoding of //! the raw image IFD's fields and their encrypted `SR2SubIFD` copies into [`SonyLensCorrections`], and validated-//! ILCE-7RM4A and ILCE-7CR distortion tables into DNG warps for the shared optics pipeline.+//! ILCE-7RM4A, ILCE-7M3 and ILCE-7CR distortion tables into DNG warps for the shared optics pipeline. //! //! Tag identity/layout: https://exiftool.org/TagNames/EXIF.html (`0x7031` to `0x7037`, signed shorts) and //! https://exiftool.org/TagNames/Sony.html#SR2SubIFD (`0x797d`, `0x7980`, `0x7982`). The `SR2SubIFD` setting tags@@ -34,9 +34,9 @@ const DISTORTION: Tags = Tags { setting: (0x7036, 0x7981), table: (0x7037, 0x798 /// TIFF field types. const SHORT: u16 = 3; const SSHORT: u16 = 8;-/// Models using the validated A7R IVA conversion. The A7CR uses a separate conversion.+/// Models using the validated A7R IVA conversion (ILCE-7RM4A, ILCE-7M3). The A7CR uses a separate conversion. /// ILCE-7RM4 and ILCE-7RM4A are distinct models, not interchangeable aliases.-const DISTORTION_MODELS: &[&str] = &["ILCE-7RM4A"];+const DISTORTION_MODELS: &[&str] = &["ILCE-7RM4A", "ILCE-7M3"]; /// Most directory entries scanned: a classic TIFF directory's largest count, also bounding a BigTIFF one. const MAX_ENTRIES: usize = 1 << 16; const SAMPLES: usize = 1024;@@ -1397,6 +1397,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", &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<f64> = (1..=97).map(|i| i as f64 / 100.0).collect();+ let (ours, camera): (Vec<f64>, Vec<f64>) = 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::<f64>() / ours.iter().map(|a| a * a).sum::<f64>();+ 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", &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);@@ -1427,14 +1468,16 @@ mod tests { #[test] fn refuses_unvalidated_models_even_with_a_valid_table() { assert!(distortion("ILCE-7RM4A", &TELE, AREA, AREA).is_some());- for model in ["", "ILCE-7M3", "ILCE-7M4", "ILCE-7RM4", "ILCE-7RM5", "ILCE-9M2", "DSC-RX100M3", "ILCE-7RM4A unknown", "ILCE-7CR"] {+ assert!(distortion("ILCE-7M3", &TELE, AREA, AREA).is_some());+ for model in ["", "ILCE-7M4", "ILCE-7RM4", "ILCE-7RM5", "ILCE-9M2", "DSC-RX100M3", "ILCE-7RM4A unknown", "ILCE-7CR", "ILCE-7M3 ", "ILCE-7SM3"]+ { assert!(distortion(model, &TELE, AREA, AREA).is_none(), "{model}"); } } #[test] fn header_and_full_decode_preserve_correction_through_dng() {- 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();@@ -1069,8 +1069,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),@@ -1227,7 +1227,7 @@ fn a_sony_fixture_that_differs_from_its_pin_fails() { /// Sony's lens-correction records (`lightcraft_raw::lens_metadata`, `crates/raw/src/vendor/arw_lens.rs`) on the public /// samples: shading, CA and distortion decoded from the raw IFD with their settings, equal to their `SR2SubIFD` copies,-/// the same from header and full decode, and nothing applied beyond the validated ILCE-7RM4A distortion. Expected+/// the same from header and full decode, and nothing applied beyond the validated ILCE-7RM4A and ILCE-7M3 distortion. Expected /// tables and settings: what ExifTool, run as a black-box tool, prints for these files. Every file is checked against /// its pinned SHA-256 before it is decoded; absent files are skipped. #[test]@@ -1351,13 +1351,13 @@ fn sony_lens_records_of_the_public_samples() { eprintln!("{name}: shading, CA and distortion decoded; distortion {status:?}"); checked += 1; }- // every Sony corpus file: only the ILCE-7RM4A's distortion is ever applied, and an applied status is an opcode+ // every Sony corpus file: only the ILCE-7RM4A's and ILCE-7M3's distortion is ever applied, and an applied status is an opcode for (name, _) in SONY_SAMPLES { let Some(bytes) = pinned_sony(&root, name) else { continue }; let info = probe_info(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); let Some(s) = &info.lens_metadata.sony else { continue }; let applied = s.distortion.status == SonyLensStatus::Applied;- assert_eq!(applied, info.metadata.model.as_deref() == Some("ILCE-7RM4A"), "{name}");+ assert_eq!(applied, matches!(info.metadata.model.as_deref(), Some("ILCE-7RM4A" | "ILCE-7M3")), "{name}"); assert_eq!(info.opcodes.list3.len(), usize::from(applied), "{name}"); assert!(![s.shading.status, s.chromatic_aberration.status].contains(&SonyLensStatus::Applied), "{name}"); checked += 1;@@ -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, Sony ILCE-7RM4A, full-frame ILCE-7CR distortion (Auto only) and ILCE-7RM6 distortion (`docs/sony-ilce7rm6-lens-corrections.md`) and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | 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, Sony ILCE-7RM4A, ILCE-7M3, full-frame ILCE-7CR distortion (Auto only) and ILCE-7RM6 distortion (`docs/sony-ilce7rm6-lens-corrections.md`) and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | 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 or Windows**: Linux x64 runs `cargo xtask ci` on every PR and push to main (`.github/workflows/ci.yml`) and FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (ci, freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU; macOS / Windows jobs: #1148 | regressions in `cfg(target_os)` code reach macOS and Windows users | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | | 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 551 of 1,446 Adobe models verified (38%), ~85% of photographers on a verified body; Nikon 5 bodies verified, Canon 34, 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 regression (#702); F828 SRF now discovered and imported as preview-only, sensor decoding still missing, 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) |@@ -212,7 +212,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 for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses, and ILCE-7CR full-frame Bayer 3:2 files with correction Auto or forced Auto (a separate corrected-radius conversion validated against camera JPEGs with FE 24-50mm, FE 16-25mm and FE 50mm lenses); A7CR Off, APS-C, aspect crops and lossless M/S remain uncorrected; ILCE-7RM6 verified against the camera JPEG on 54 photos with three lenses (`docs/sony-ilce7rm6-lens-corrections.md`); other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Nikon NEF distortion from NikonNEFInfo (`0xc7d5`: polynomial, frame-filling zoom and the optical-centre offset left by in-body stabilisation; `crates/raw/src/vendor/nef_lens.rs`, `docs/nikon-lens-corrections.md`) for the Z 6II, Z 8 and Z f, full-frame image area only: F-mount lenses on the FTZ match the camera JPEG within 0.2 px median, Z lenses within 0.5–1.5 px except the 24-70 f/4's wide end, where the camera applies a further correction that is not in the readable data (7 px median at 24 mm, from 16 px / 106 px p95 uncorrected); 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; Sony ARW shading, lateral CA and distortion tables (raw IFD `0x7031`–`0x7037`, with the encrypted `SR2SubIFD` copies as a checked fallback) are decoded with their camera settings, provenance and framing evidence for every body that records them (`crates/raw/src/lens_metadata.rs`), but only the ILCE-7RM4A, ILCE-7RM6 and ILCE-7CR use the separate distortion conversions above; Sony shading and CA semantics are not validated beyond the ILCE-7RM6's (lateral CA off) (`docs/sony-lens-corrections.md`); 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`); raws that name no lens model (Nikon DSLRs: maker-note lens specification only) look it up by a name built from the specification (`24-70mm f/2.8`) and take only a profile named exactly that; 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 for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses, ILCE-7M3 (FE 16–35 F4 ZA, FE 28–70, FE 50 F1.8; 26 shots within 0.06–0.44 px median of the camera JPEG at 6000 px, from up to 26 px uncorrected), and ILCE-7CR full-frame Bayer 3:2 files with correction Auto or forced Auto (a separate corrected-radius conversion validated against camera JPEGs with FE 24-50mm, FE 16-25mm and FE 50mm lenses); A7CR Off, APS-C, aspect crops and lossless M/S remain uncorrected; ILCE-7RM6 verified against the camera JPEG on 54 photos with three lenses (`docs/sony-ilce7rm6-lens-corrections.md`); other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Nikon NEF distortion from NikonNEFInfo (`0xc7d5`: polynomial, frame-filling zoom and the optical-centre offset left by in-body stabilisation; `crates/raw/src/vendor/nef_lens.rs`, `docs/nikon-lens-corrections.md`) for the Z 6II, Z 8 and Z f, full-frame image area only: F-mount lenses on the FTZ match the camera JPEG within 0.2 px median, Z lenses within 0.5–1.5 px except the 24-70 f/4's wide end, where the camera applies a further correction that is not in the readable data (7 px median at 24 mm, from 16 px / 106 px p95 uncorrected); 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; Sony ARW shading, lateral CA and distortion tables (raw IFD `0x7031`–`0x7037`, with the encrypted `SR2SubIFD` copies as a checked fallback) are decoded with their camera settings, provenance and framing evidence for every body that records them (`crates/raw/src/lens_metadata.rs`), but only the ILCE-7RM4A, ILCE-7M3, ILCE-7RM6 and ILCE-7CR use the separate distortion conversions above; Sony shading and CA semantics are not validated beyond the ILCE-7RM6's (lateral CA off) (`docs/sony-lens-corrections.md`); 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`); raws that name no lens model (Nikon DSLRs: maker-note lens specification only) look it up by a name built from the specification (`24-70mm f/2.8`) and take only a profile named exactly that; 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` | |@@ -243,7 +243,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), full-frame Bayer 3:2 ILCE-7CR files with correction Auto or forced Auto (3 lenses; see sony-lens-corrections.md for residuals and limits) and the ILCE-7RM6 (3 lenses, 54 photos; `sony-ilce7rm6-lens-corrections.md`); Nikon NikonNEFInfo distortion (tag `0xc7d5`, about the sensor-shifted optical centre) for the Z 6II and Z f: F-mount lenses on the FTZ within 0.2 px of the camera JPEG, Z lenses within 0.5–1.5 px except the 24-70's wide end (7 px median; `docs/nikon-lens-corrections.md`) |+| 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), full-frame Bayer 3:2 ILCE-7CR files with correction Auto or forced Auto (3 lenses; see sony-lens-corrections.md for residuals and limits) and the ILCE-7RM6 (3 lenses, 54 photos; `sony-ilce7rm6-lens-corrections.md`); Nikon NikonNEFInfo distortion (tag `0xc7d5`, about the sensor-shifted optical centre) for the Z 6II and Z f: F-mount lenses on the FTZ within 0.2 px of the camera JPEG, Z lenses within 0.5–1.5 px except the 24-70's wide end (7 px median; `docs/nikon-lens-corrections.md`) | | Vignetting / lateral CA from maker notes | ✅ | ⬜ | | Manual distortion, vignetting, CA, defringe | ✅ | ✅ | @@ -7,9 +7,10 @@ convention; that page's *Two conventions* section has the comparison and what wo Sony ARWs record three lens corrections per shot in the raw image IFD: shading (vignetting), lateral chromatic aberration (CA) and distortion, each as a camera setting and a radial table, with copies in the encrypted-`SR2SubIFD`. LightCraft **decodes all three** wherever a file carries them and applies distortion here on two validated-bodies: **ILCE-7RM4A (A7R IVA)** and **ILCE-7CR (A7CR)**. The A7R IVA keeps its existing Bayer 3:2 conversion,-validated with the FE 24-105mm F4 G OSS and FE 200-600mm F5.6-6.3 G OSS. The A7CR uses a separate conversion,+`SR2SubIFD`. LightCraft **decodes all three** wherever a file carries them and applies distortion here on three validated+bodies: **ILCE-7RM4A (A7R IVA)**, **ILCE-7M3 (A7 III)** and **ILCE-7CR (A7CR)**. The A7R IVA keeps its existing Bayer 3:2 conversion,+validated with the FE 24-105mm F4 G OSS and FE 200-600mm F5.6-6.3 G OSS; the A7 III uses the same conversion, validated+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)). The A7CR uses a separate conversion, limited to full-frame Bayer 3:2 files with distortion Auto or forced Auto. Apart from the separate ILCE-7RM6 converter, other models and shading and CA on every model are decoded only: no warp, vignetting or CA correction is added for them. The decoded record stays with the image (`RawInfo::lens_metadata` / `RawImage::lens_metadata`, types in `crates/raw/src/lens_metadata.rs`, decoder in@@ -289,9 +290,50 @@ decode or fail without a panic. `tests/sr2_heap.rs` measures the peak heap of de `SR2Private` directory holds eight fields aliasing the whole file: 37 kB, against 134 MB when those fields were decoded. 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). RAW+JPEG pairs from one+ILCE-7M3 (26 in the table below, plus two left out as explained under it): 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 (`tools/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.@@ -309,7 +351,7 @@ fail on any absent pinned file instead. |---|---|---|---| | ILCE-7RM4A | Compressed (4822) | All three tables; 257 / 1 / 17 | One distortion warp | | ILCE-9M2 | Compressed (3989) | All three; 257 / 1 / 0 | None |-| ILCE-7M3 | Compressed, uncompressed | All three; 257 / 1 / 0 | None |+| ILCE-7M3 | Compressed, uncompressed | All three; 257 / 1 / 0 | One distortion warp | | ILCE-7M4 | 14-bit, lossless L/M/S | L/M/S: all three, 272 / 1 / 17; the 14-bit file (APS-C framing, 4608 × 3072): settings only, its 11-sample tables are not interpreted | None | | ILCE-7RM2 | 12-bit uncompressed | All three; 256 / 0 / 1 | None | | DSC-RX100 | One | Shading, from the `SR2SubIFD` (the raw IFD has no tables); CA and distortion in the 11-sample layout | None |@@ -324,8 +366,8 @@ fail on any absent pinned file instead. These are **model-boundary regression checks, not new geometric validation of those models**. `sony_lens_records_of_the_public_samples` also checks the exact tables, settings, provenance and framing evidence of the three A7CR codings and the A7R IVA sample, and that across the Sony corpus only the A7R IVA's distortion is ever-applied. In particular,-refusing the A7 III tables resolves the unverified corner warp identified in review. The lossless M/S+applied, besides the A7 III's. 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@@ -1,5 +1,15 @@ # What's new in LightCraft +## 2026-10-26++### 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). Measured against the+ camera's JPEG, the median error is well under a pixel (up to about 1.5 px in the 16 mm corners). 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 applied yet.+ ## 2026-10-24 ### Relative-date filters and smart albums follow the clocknew file mode 100644@@ -0,0 +1,6 @@+# Python packages for tools/sony_distortion_check.py (docs/sony-lens-corrections.md).+# python3 -m venv .venv-distortion && .venv-distortion/bin/pip install -r tools/requirements-sony-distortion-check.txt+numpy==2.5.3+scipy==1.18.1+opencv-python-headless==5.0.0.93+Pillow==12.3.0new file mode 100755@@ -0,0 +1,426 @@+#!/usr/bin/env python3+"""Compare LightCraft's Sony embedded distortion correction with the camera's own JPEG.++ python3 -m venv .venv-distortion && .venv-distortion/bin/pip install -r tools/requirements-sony-distortion-check.txt+ .venv-distortion/bin/python tools/sony_distortion_check.py --cli target/release/lightcraft-cli \+ --work WORKDIR [--json results.json] PHOTO_DIR [PHOTO_DIR…]++Every ARW with a JPG of the same name is rendered by LightCraft twice, embedded correction on+and off, and measured against the camera JPEG (shot with Lens Comp. Distortion on Auto):++* agreement: offsets of matching features after the best similarity (scale, rotation, shift)+ between the render and the JPEG, by distance from the centre (0 = centre, 1 = corner);+* straightness: how far each checkerboard row and column bows away from a straight line.++Checkerboard shots use the board's corners (sub-pixel), other shots SIFT features (agreement+only). Offsets are in pixels of the camera JPEG (6000 px wide on the A7 III); the "@2376"+columns rescale them to the 2376 px exports docs/sony-lens-corrections.md reports (method and results+there, under "ILCE-7M3 validation").+"""++import argparse+import hashlib+import json+import os+import shutil+import subprocess+import sys++import cv2+import numpy as np+from scipy.spatial import cKDTree++WORK_LONG_EDGE = 3000 # detection resolution; coordinates are rescaled to the JPEG's pixels+BINS = [0.0, 0.25, 0.5, 0.75, 0.9, 1.01]+REPORT_WIDTH = 2376+++def render(cli, arw, out, on):+ """Render `arw` with the profile correction `on` or off to `out`, reusing an output newer than the CLI and ARW."""+ settings = out + ".settings.json"+ with open(settings, "w") as f:+ json.dump({"optics": {"lens_profile": on, "profile_distortion": 100}}, f)+ exe = shutil.which(cli) or cli+ current = os.path.exists(out) and os.path.getmtime(out) >= max(os.path.getmtime(exe), os.path.getmtime(arw))+ if not current:+ r = subprocess.run([exe, "render", arw, "-o", out, "--settings", settings], capture_output=True)+ if r.returncode != 0:+ err = r.stderr.decode(errors="replace").strip()+ raise RuntimeError(f"render failed ({r.returncode}) for {arw}: {err[-500:]}")+ return out+++def load_gray(path):+ """Grey image at the working resolution and the factor back to its own full-size pixels."""+ img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)+ if img is None:+ raise RuntimeError(f"cannot read {path}")+ h, w = img.shape+ k = WORK_LONG_EDGE / max(w, h)+ small = cv2.resize(img, (round(w * k), round(h * k)), interpolation=cv2.INTER_AREA)+ return small, 1.0 / k, (w, h)+++# ---------------------------------------------------------------- checkerboard corners++def detect_corners(gray):+ """X-corners of a checkerboard: Hessian saddle points, refined with cornerSubPix."""+ g = cv2.GaussianBlur(gray.astype(np.float32), (0, 0), 3.0)+ gxx = cv2.Sobel(g, cv2.CV_32F, 2, 0, ksize=5)+ gyy = cv2.Sobel(g, cv2.CV_32F, 0, 2, ksize=5)+ gxy = cv2.Sobel(g, cv2.CV_32F, 1, 1, ksize=5)+ saddle = gxy * gxy - gxx * gyy+ top = np.percentile(saddle, 99.95)+ if top <= 0:+ return np.zeros((0, 2), np.float32)+ peaks = (saddle == cv2.dilate(saddle, np.ones((15, 15), np.uint8))) & (saddle > 0.08 * top)+ ys, xs = np.nonzero(peaks)+ pts = np.stack([xs, ys], 1).astype(np.float32).reshape(-1, 1, 2)+ 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).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 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)+ 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):+ """Label corners (row, column) by stepping lattice vectors `a` and `b` out from corner `start`."""+ labels = {start: (0, 0)}+ taken = {(0, 0): start}+ vec = {start: (a, b)}+ queue = [start]+ while queue:+ p = queue.pop()+ i, j = labels[p]+ va, vb = vec[p]+ for di, dj, step in ((1, 0, va), (-1, 0, -va), (0, 1, vb), (0, -1, -vb)):+ key = (i + di, j + dj)+ if key in taken:+ continue+ dist, q = tree.query(pts[p] + step)+ if dist > 0.3 * np.linalg.norm(step) or q in labels:+ continue+ found = pts[q] - pts[p]+ na = found * di if di else va+ nb = found * dj if dj else vb+ labels[q] = key+ taken[key] = q+ vec[q] = (na, nb)+ queue.append(q)+ return labels+++def board(path):+ """Checkerboard corners of image `path`, keyed by (row, column), in its full-size pixels, and its size."""+ gray, back, size = load_gray(path)+ pts = detect_corners(gray)+ labels = label_lattice(pts, np.array(gray.shape[::-1]) / 2.0)+ full = {lab: pts[k] * back for k, lab in labels.items()}+ return full, size+++def straightness(grid):+ """Largest deviation from a fitted straight line over every row/column with 8+ corners."""+ lines = {}+ for (i, j), p in grid.items():+ lines.setdefault(("col", i), []).append(p)+ lines.setdefault(("row", j), []).append(p)+ worst = []+ for pts in lines.values():+ if len(pts) < 8:+ continue+ pts = np.array(pts)+ keep = np.ones(len(pts), bool)+ for _ in range(3): # drop stray corners, then refit+ c = pts[keep].mean(0)+ _, _, vt = np.linalg.svd(pts[keep] - c)+ dev = (pts - c) @ vt[1]+ keep = np.abs(dev) <= max(1.0, 4 * np.median(np.abs(dev[keep])))+ if keep.sum() < 8:+ break+ if keep.sum() >= 8:+ worst.append(np.abs(dev[keep]).max())+ if not worst:+ return None+ worst = np.array(worst)+ return {"lines": len(worst), "median": float(np.median(worst)), "max": float(worst.max())}+++def match_boards(src, src_size, dst, dst_size):+ """Corner pairs between two labellings of the same shot. A board is periodic, so a fit can't+ tell a one-square shift from the right match; the corners near the centre can, unfitted:+ the two framings differ by a few percent of zoom, far less than a square there."""+ sc, dc = np.array(src_size) / 2.0, np.array(dst_size) / 2.0+ half_diag = np.hypot(*dst_size) / 2.0+ best = None+ for oi in range(-3, 4):+ for oj in range(-3, 4):+ pairs = [(p, dst[(i + oi, j + oj)]) for (i, j), p in src.items() if (i + oi, j + oj) in dst]+ central = [np.linalg.norm((p - sc) - (q - dc)) for p, q in pairs if np.linalg.norm(q - dc) < 0.3 * half_diag]+ if len(pairs) < 50 or len(central) < 10:+ continue+ err = float(np.median(central))+ if best is None or err < best[0]:+ best = (err, pairs)+ if best is None:+ return None, None+ s, d = map(np.array, zip(*best[1]))+ return s, d+++# ---------------------------------------------------------------- SIFT (other scenes)++def sift_pairs(src_path, dst_path):+ """SIFT matches; both point sets in the destination's full-size pixels."""+ a, ka, size_a = load_gray(src_path)+ b, kb, size_b = load_gray(dst_path)+ sift = cv2.SIFT_create(nfeatures=20000)+ pa, da = sift.detectAndCompute(a, None)+ pb, db = sift.detectAndCompute(b, None)+ if da is None or db is None:+ return None, None+ good = [m for m, n in cv2.BFMatcher().knnMatch(da, db, k=2) if m.distance < 0.75 * n.distance]+ if len(good) < 30:+ return None, None+ s = np.array([pa[m.queryIdx].pt for m in good]) * ka * (size_b[0] / size_a[0])+ d = np.array([pb[m.trainIdx].pt for m in good]) * kb+ return s, d+++# ---------------------------------------------------------------- geometry++def fit_similarity(s, d):+ """Least-squares similarity (zoom, rotation, shift) mapping points `s` onto `d`, as complex (scale, offset)."""+ zs = s[:, 0] + 1j * s[:, 1]+ zd = d[:, 0] + 1j * d[:, 1]+ ms, md = zs.mean(), zd.mean()+ c = np.vdot(zs - ms, zd - md) / np.vdot(zs - ms, zs - ms)+ return c, md - c * ms+++def apply(model, p):+ """Points `p` mapped by a `fit_similarity` model."""+ c, t = model+ z = c * (p[:, 0] + 1j * p[:, 1]) + t+ return np.stack([z.real, z.imag], 1)+++def agreement(s, d, size):+ """Similarity-fit render points s onto JPEG points d; residual statistics by radius."""+ pairs = {tuple(q): p for q, p in zip(d, s)}+ keep = np.ones(len(s), bool)+ # A loose first pass, then reject gross mismatches (SIFT outliers, mislabelled corners).+ for limit in (0.02 * size[0], None, None):+ model = fit_similarity(s[keep], d[keep])+ res = np.linalg.norm(apply(model, s) - d, axis=1)+ cut = limit if limit else max(5 * np.median(res[keep]), 2.0)+ keep = res < cut+ if keep.sum() < 20:+ return None+ model = fit_similarity(s[keep], d[keep])+ s, d = s[keep], d[keep]+ fitted = apply(model, s)+ res = fitted - d+ centre = np.array(size) / 2.0+ half_diag = np.hypot(*size) / 2.0+ rel = d - centre+ r = np.linalg.norm(rel, axis=1) / half_diag+ radial = np.sum(res * rel, axis=1) / np.maximum(np.linalg.norm(rel, axis=1), 1e-9)+ mag = np.linalg.norm(res, axis=1)+ bins = []+ for lo, hi in zip(BINS, BINS[1:]):+ m = (r >= lo) & (r < hi)+ bins.append({"r": f"{lo:.2f}-{min(hi, 1):.2f}", "n": int(m.sum()),+ "median": float(np.median(mag[m])) if m.any() else None,+ "radial": float(np.mean(radial[m])) if m.any() else None})+ return {"n": int(len(s)), "scale": float(abs(model[0])), "median": float(np.median(mag)),+ "p95": float(np.percentile(mag, 95)), "max": float(mag.max()), "max_r": float(r.max()),+ "bins": bins, "pairs": pairs}+++def correction_fraction(on, off, size):+ """How the camera's correction compares with LightCraft's: JPEG ≈ off + α·(on − off) radially,+ both renders aligned to the JPEG on the same corners. 1 = the same amount as the camera;+ above 1, the camera corrects more than LightCraft."""+ keys = [k for k in on["pairs"] if k in off["pairs"]]+ if len(keys) < 50:+ return None+ d = np.array(keys)+ raw_on = np.array([on["pairs"][k] for k in keys])+ raw_off = np.array([off["pairs"][k] for k in keys])+ keep = np.ones(len(d), bool)+ for _ in range(4):+ a = apply(fit_similarity(raw_on[keep], d[keep]), raw_on)+ b = apply(fit_similarity(raw_off[keep], d[keep]), raw_off)+ rel = d - np.array(size) / 2.0+ unit = rel / np.maximum(np.linalg.norm(rel, axis=1, keepdims=True), 1e-9)+ u = np.sum((a - b) * unit, axis=1)+ v = np.sum((d - b) * unit, axis=1)+ if np.median(np.abs(u[keep])) < 1.0: # too little distortion at this focal length to tell+ return None+ alpha = float(u[keep] @ v[keep] / (u[keep] @ u[keep]))+ e = np.abs(v - alpha * u)+ keep = e <= max(0.5, 4 * np.median(e[keep])) # mismatched corners, not the lens+ return alpha+++# ---------------------------------------------------------------- driver++def jpeg_for(arw):+ """The camera JPEG next to `arw` (same stem), or None."""+ stem = os.path.splitext(arw)[0]+ for ext in (".JPG", ".jpg", ".JPEG", ".jpeg"):+ if os.path.exists(stem + ext):+ return stem + ext+ return None+++def exif_summary(jpg):+ """Lens, focal length and aperture of `jpg` from its Exif, for the report."""+ from PIL import Image+ tags = Image.open(jpg).getexif().get_ifd(0x8769)+ lens = tags.get(0xA434, "?")+ focal = tags.get(0x920A)+ fnum = tags.get(0x829D)+ return f"{lens} {float(focal):.0f}mm f/{float(fnum):.1f}" if focal and fnum else str(lens)+++def measure(cli, work, arw):+ """Render `arw` corrected and uncorrected and measure both against its camera JPEG (None without one)."""+ jpg = jpeg_for(arw)+ if not jpg:+ return None+ name = os.path.splitext(os.path.basename(arw))[0]+ # cards repeat names (DSC00001.ARW): the path's hash keeps renders of different folders apart+ uid = hashlib.sha1(os.path.abspath(arw).encode()).hexdigest()[:8]+ on = render(cli, arw, os.path.join(work, f"{name}.{uid}.on.png"), True)+ off = render(cli, arw, os.path.join(work, f"{name}.{uid}.off.png"), False)+ out = {"file": name, "shot": exif_summary(jpg)}+ jgrid, jsize = board(jpg)+ out["kind"] = "board" if jgrid else "scene"+ if jgrid:+ out["straight_jpeg"] = straightness(jgrid)+ for tag, path in (("on", on), ("off", off)):+ if jgrid:+ grid, size = board(path)+ if not grid:+ continue+ k = jsize[0] / size[0]+ grid = {key: p * k for key, p in grid.items()}+ out["straight_" + tag] = straightness(grid)+ s, d = match_boards(grid, (size[0] * k, size[1] * k), jgrid, jsize)+ else:+ s, d = sift_pairs(path, jpg)+ 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"):+ out.get("vs_jpeg_" + tag, {}).pop("pairs", None)+ out["jpeg_width"] = jsize[0]+ return out+++def show(o):+ """Print one file's measurements."""+ k = REPORT_WIDTH / o["jpeg_width"]+ print(f"\n{o['file']} {o['shot']} [{o['kind']}]")+ for tag, label in (("on", "LightCraft corrected"), ("off", "LightCraft uncorrected")):+ a = o.get("vs_jpeg_" + tag)+ if a:+ print(f" {label:24s} vs camera JPEG: median {a['median']:.2f} px, p95 {a['p95']:.2f}, max {a['max']:.2f}"+ f" (@{REPORT_WIDTH}: {a['median'] * k:.2f} / {a['p95'] * k:.2f}) n={a['n']} reach r={a['max_r']:.2f}")+ print(" by radius: " + " ".join(+ f"{b['r']}: {b['median']:.2f} ({b['radial']:+.2f})" for b in a["bins"] if b["n"]))+ for tag in ("jpeg", "on", "off"):+ st = o.get("straight_" + tag)+ if st:+ print(f" straightness {tag:4s}: worst line {st['max']:.2f} px, median line {st['median']:.2f} px ({st['lines']} lines)")+ if o.get("fraction") is not None:+ print(f" camera correction / LightCraft correction: {o['fraction']:.3f} (1 = same amount)")+++def main():+ """Measure every ARW with a camera JPEG in the given folders and print (and optionally save) the results."""+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)+ ap.add_argument("--cli", required=True)+ ap.add_argument("--work", required=True)+ ap.add_argument("--json")+ ap.add_argument("dirs", nargs="+")+ args = ap.parse_args()+ os.makedirs(args.work, exist_ok=True)+ arws = sorted(os.path.join(d, f) for d in args.dirs for f in os.listdir(d) if f.lower().endswith(".arw"))+ results = []+ for arw in arws:+ try:+ o = measure(args.cli, args.work, arw)+ except Exception as e: # keep going: one bad file must not stop the batch+ print(f"\n{arw}: {e}", file=sys.stderr)+ continue+ if o:+ show(o)+ results.append(o)+ if args.json:+ with open(args.json, "w") as f:+ json.dump(results, f, indent=1)+++if __name__ == "__main__":+ main()