mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-13 17:31:09 -06:00
LR-EDIT-OPTICS-PROFILE: merge main (#3144: ILCE-7M3 distortion, kept in the gaps and checklist rows)
This commit is contained in:
@@ -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];
|
||||
|
||||
Vendored
+48
-5
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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 | ✅ | ✅ |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Executable
+426
@@ -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()
|
||||
Reference in New Issue
Block a user