LR-EDIT-OPTICS-PROFILE: merge main (#3144: ILCE-7M3 distortion, kept in the gaps and checklist rows)

This commit is contained in:
FredApps
2026-11-04 13:36:25 +01:00
10 changed files with 546 additions and 21 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
use super::{Arc, Deserialize, DevelopSettings, MediaKind, Photo, Rgb32f, Rgba8, Serialize, SourceInfo};
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 73;
pub const RENDER_CACHE_VERSION: u64 = 74;
/// 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];
+48 -5
View File
@@ -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();
+5 -5
View File
@@ -1070,8 +1070,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),
@@ -1228,7 +1228,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]
@@ -1352,13 +1352,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;
+1 -1
View File
@@ -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 | **Lens profiles: lensfun only, no picker, no own calibration** (LR-EDIT-OPTICS-PROFILE): the desktop app offers lensfun's database (CC BY-SA 3.0, pinned, never bundled) once on the first launch and downloads it on that click or from Settings, then looks every raw up by its lens ([lens-profiles.md](lens-profiles.md)), but those profiles are not validated against Lightroom, there is no profile picker (in the file / lens database / own calibration) and no calibration wizard; in-file corrections: DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A, full-frame ILCE-7CR distortion (Auto only) and ILCE-7RM6 and ILCE-6700 distortion (`docs/sony-ilce7rm6-lens-corrections.md`) and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`); tracked in #329 | 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 | **Lens profiles: lensfun only, no picker, no own calibration** (LR-EDIT-OPTICS-PROFILE): the desktop app offers lensfun's database (CC BY-SA 3.0, pinned, never bundled) once on the first launch and downloads it on that click or from Settings, then looks every raw up by its lens ([lens-profiles.md](lens-profiles.md)), but those profiles are not validated against Lightroom, there is no profile picker (in the file / lens database / own calibration) and no calibration wizard; in-file corrections: DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A, ILCE-7M3, full-frame ILCE-7CR distortion (Auto only) and ILCE-7RM6 and ILCE-6700 distortion (`docs/sony-ilce7rm6-lens-corrections.md`) and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`); tracked in #329 | 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, and no CI job runs the GPU equivalence tests**: the CI gate (`ci.yml`, #2667) runs `cargo xtask ci`, which includes `cargo test`, on Linux x64 for every push to `main` and every pull request; FreeBSD runs `cargo test` nightly, on pushes to `release` and on PRs that change its workflow or `packaging/freebsd/`; the macOS and Windows jobs build packages | `.github/workflows/` (ci, freebsd, packaging-lint, release, sonarcloud, windows-arm64); the gate's runner has Mesa software Vulkan drivers installed, but `Gpu::new` refuses software adapters (`crates/gpu/src/ctx.rs`), so the CPU ↔ GPU equivalence tests skip; the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU; #1148 (open, not merged; #2667 names it superseded) has the macOS and Windows test legs and a `LIGHTCRAFT_GPU_ALLOW_SOFTWARE` opt-in that runs the GPU tests on lavapipe, Metal and WARP | a regression that only shows on macOS or Windows, or only on a GPU, reaches users; `cargo xtask ci` runs only on Linux in CI | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) |
| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 557 of 1,446 Adobe models verified (39%), ~86% 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) |
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -246,7 +246,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) the ILCE-7RM6 (3 lenses, 54 photos) and the APS-C ILCE-6700 (3 lenses, 145 photos; 0.73 px worst, with a measured Sigma 10–18 mm exception at 10–10.5 mm; `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) the ILCE-7RM6 (3 lenses, 54 photos) and the APS-C ILCE-6700 (3 lenses, 145 photos; 0.73 px worst, with a measured Sigma 10–18 mm exception at 10–10.5 mm; `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 | ✅ | ✅ |
+49 -7
View File
@@ -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
+8
View File
@@ -2,6 +2,14 @@
## 2026-10-30
### 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.
### Lens corrections without grey corners
- Lens profiles, the camera's own distortion correction, manual Distortion and Chromatic Aberration now crop just
inside the corrected photo, as in Lightroom, so no grey corners or edge marks show. Perspective corrections still
@@ -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.0
+426
View File
@@ -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()