mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 11:37:13 -06:00
LR-EDIT-OPTICS-PROFILE: enable Sony ILCE-7M3 embedded distortion correction
The A7 III's raw-IFD 0x7037 tables were refused until their geometry was checked. Measured against the camera's own JPEG of the same exposure (Lens Comp. Distortion: Auto) on 27 RAW+JPEG pairs from one body with the FE 16-35 F4 ZA, FE 28-70 and FE 50 F1.8 (strong barrel to strong pincushion, compressed and uncompressed, TV and monitor checkerboards): corrected board corners sit 0.06-0.44 px (median, 6000 px frame) from the JPEG's, against 0.8-26 px uncorrected; LightCraft applies the camera's amount of correction within 2 %; corrected rows and columns are as straight as the JPEG's. Sony's crop and the default crop agree on this body, so the warp is centred on the image. A unit test pins the warp from three of these tables to the camera's fitted geometry; the model boundary tests now accept ILCE-7M3 and still refuse its neighbours. RENDER_CACHE_VERSION is bumped. The measurement script handles screen moiré (X-corner check, duplicate corners, consensus lattice vectors). The corpus test's two ILCE-7M3 samples now expect one warp; raw.pixls.us is not reachable from this environment, so that test was skipped here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gRtdWF1E9358FfzhzYqCS
This commit is contained in:
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 61;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 62;
|
||||
|
||||
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
|
||||
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
|
||||
|
||||
Vendored
+47
-5
@@ -11,13 +11,13 @@ const DISTORTION: u16 = 0x7037;
|
||||
const SAMPLES: usize = 1024;
|
||||
const MAX_ERROR: f64 = 0.0005;
|
||||
|
||||
/// Only independently validated camera models and the 16-entry layout. Unknown models/layouts
|
||||
/// and implausible/non-smooth maps are left uncorrected. See docs/sony-lens-corrections.md.
|
||||
/// Only independently validated camera models (ILCE-7RM4A, ILCE-7M3) and the 16-entry layout.
|
||||
/// Unknown models/layouts and implausible/non-smooth maps are left uncorrected. See docs/sony-lens-corrections.md.
|
||||
/// A camera setting of Off does not remove the lens data: the user can enable it in the developer.
|
||||
pub(super) fn distortion(model: &str, ifd: &Ifd, active: Rect, crop: Rect) -> Option<Opcode> {
|
||||
// A matching table shape does not establish its scale or radial normalization on another body.
|
||||
// Keep this exact: ILCE-7RM4 and ILCE-7RM4A are distinct models, not interchangeable aliases.
|
||||
if model != "ILCE-7RM4A" {
|
||||
if !matches!(model, "ILCE-7RM4A" | "ILCE-7M3") {
|
||||
return None;
|
||||
}
|
||||
let Value::SShort(values) = &ifd.get(DISTORTION)?.value else { return None };
|
||||
@@ -147,6 +147,47 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ILCE-7M3 raw-IFD tables (FE 16-35mm F4 ZA OSS at 16 and 35 mm, FE 28-70mm F3.5-5.6 OSS at 70 mm) and the
|
||||
// geometry of the camera's own JPEG of the same exposure (Lens Comp. Distortion: Auto): checkerboard corners of
|
||||
// that JPEG matched with the uncorrected raw and fitted with the warp's polynomial over the 6000×4000 frame (rms
|
||||
// 0.11–0.25 px). See docs/sony-lens-corrections.md.
|
||||
const A7M3: [([i16; 16], [f64; 4]); 3] = [
|
||||
(
|
||||
[32, 2, -38, -83, -138, -199, -270, -336, -402, -462, -519, -568, -611, -643, -670, -690],
|
||||
[1.0247000859, -0.1092847508, 0.1061422243, -0.0397989678],
|
||||
),
|
||||
([2, 2, 4, 9, 18, 29, 45, 65, 92, 124, 164, 210, 264, 329, 403, 489], [0.9708150714, 0.0157256288, 0.0175613359, -0.004107544]),
|
||||
([4, 7, 18, 40, 68, 108, 155, 212, 280, 359, 453, 557, 679, 819, 978, 1161], [0.9342002611, 0.0560486347, 0.0076184603, 0.0021497933]),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn ilce_7m3_matches_the_cameras_own_jpeg_geometry() {
|
||||
let frame = Rect::new(0, 0, 6000, 4000);
|
||||
let evaluate = |k: &[f64], r: f64| r * (k[0] + r * r * (k[1] + r * r * (k[2] + r * r * k[3])));
|
||||
for (values, reference) in A7M3 {
|
||||
let Opcode::WarpRectilinear { planes, center } = distortion("ILCE-7M3", &table(&values), frame, frame).unwrap() else { panic!("warp") };
|
||||
assert_eq!(center, [0.5, 0.5]);
|
||||
// The camera fills its frame with its own zoom; compare the shape over the measured radii (to 0.97).
|
||||
let radii: Vec<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", &table(&A7M3[0].0), Rect::new(0, 0, 6048, 4024), Rect::new(12, 12, 6000, 4000)).unwrap()
|
||||
else {
|
||||
panic!("warp")
|
||||
};
|
||||
assert!((center[0] * 6047.0 - 3011.5).abs() < 1e-9 && (center[1] * 4023.0 - 2011.5).abs() < 1e-9, "{center:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn barrel_framing_and_offset_crop_keep_the_same_geometry() {
|
||||
let crop = Rect::new(32, 20, 9504, 6336);
|
||||
@@ -185,7 +226,8 @@ mod tests {
|
||||
fn refuses_unvalidated_models_even_with_a_valid_table() {
|
||||
let valid = table(&TELE);
|
||||
assert!(distortion("ILCE-7RM4A", &valid, AREA, AREA).is_some());
|
||||
for model in ["", "ILCE-7M3", "ILCE-7M4", "ILCE-7RM4", "ILCE-7RM5", "ILCE-9M2", "DSC-RX100M3", "ILCE-7RM4A unknown"] {
|
||||
assert!(distortion("ILCE-7M3", &valid, AREA, AREA).is_some());
|
||||
for model in ["", "ILCE-7M4", "ILCE-7RM4", "ILCE-7RM5", "ILCE-9M2", "DSC-RX100M3", "ILCE-7RM4A unknown", "ILCE-7M3 ", "ILCE-7SM3"] {
|
||||
assert!(distortion(model, &valid, AREA, AREA).is_none(), "{model}");
|
||||
}
|
||||
}
|
||||
@@ -193,7 +235,7 @@ mod tests {
|
||||
#[test]
|
||||
fn header_and_full_decode_preserve_correction_through_dng() {
|
||||
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, tags as t};
|
||||
for (model, expected) in [("ILCE-7RM4A", 1), ("ILCE-7RM4", 0), ("ILCE-7M3", 0), ("", 0)] {
|
||||
for (model, expected) in [("ILCE-7RM4A", 1), ("ILCE-7RM4", 0), ("ILCE-7M3", 1), ("ILCE-7M4", 0), ("", 0)] {
|
||||
for (height, expected) in [(20, expected), (16, 0)] {
|
||||
for order in [ByteOrder::Little, ByteOrder::Big] {
|
||||
let mut raw = IfdBuilder::new();
|
||||
|
||||
@@ -980,8 +980,8 @@ fn sony_embedded_distortion_is_limited_to_validated_models() {
|
||||
for (name, model, expected) in [
|
||||
("arw-sony-a7rm4a-compressed.arw", "ILCE-7RM4A", 1),
|
||||
("arw-sony-a9m2-compressed.arw", "ILCE-9M2", 0),
|
||||
("arw-sony-a7m3-compressed.arw", "ILCE-7M3", 0),
|
||||
("arw-sony-a7m3-uncompressed.arw", "ILCE-7M3", 0),
|
||||
("arw-sony-a7m3-compressed.arw", "ILCE-7M3", 1),
|
||||
("arw-sony-a7m3-uncompressed.arw", "ILCE-7M3", 1),
|
||||
("arw-sony-a7m4-14bit.arw", "ILCE-7M4", 0),
|
||||
("arw-sony-a7m4-lossless-l.arw", "ILCE-7M4", 0),
|
||||
("arw-sony-a7m4-lossless-m.arw", "ILCE-7M4", 0),
|
||||
|
||||
+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 | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony ILCE-7RM4A distortion | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) |
|
||||
| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion and Sony ILCE-7RM4A / ILCE-7M3 distortion | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) |
|
||||
| 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) |
|
||||
| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 235 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12. Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) |
|
||||
| 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) |
|
||||
|
||||
@@ -211,7 +211,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | |
|
||||
| LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | | |
|
||||
| LR-EDIT-OPTICS-CA | Remove chromatic aberration | P1 | ✅ | `crates/ui-egui/src/panels/edit.rs` (checkbox), `ctl:optics.caRed`, `ctl:optics.caBlue` | |
|
||||
| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled only for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses; other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus and Sony raws not read yet; Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet |
|
||||
| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled only for the independently validated ILCE-7RM4A (FE 24–105 and FE 200–600 lenses) and ILCE-7M3 (FE 16–35 F4 ZA, FE 28–70, FE 50 F1.8; 27 shots within 0.06–0.44 px median of the camera JPEG at 6000 px, from up to 26 px uncorrected); other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus and Sony raws not read yet; Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet |
|
||||
)
|
||||
| LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper |
|
||||
| LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | |
|
||||
|
||||
+1
-1
@@ -213,7 +213,7 @@ essentials user (the default look is what they keep).
|
||||
| Item | Adobe | LightCraft |
|
||||
|---|---|---|
|
||||
| Profile database | 3,635 profile files, 56 makers, auto-selected | **0** (Adobe LCPs never used; no database of our own) |
|
||||
| In-file corrections | ✅ | DNG opcodes (WarpRectilinear, FixVignetteRadial, GainMap…) applied; Panasonic / Leica RW2 distortion (79 photos, 13 bodies, 11 lenses); Sony tag 0x7037 distortion for the ILCE-7RM4A (2 lenses) |
|
||||
| In-file corrections | ✅ | DNG opcodes (WarpRectilinear, FixVignetteRadial, GainMap…) applied; Panasonic / Leica RW2 distortion (79 photos, 13 bodies, 11 lenses); Sony tag 0x7037 distortion for the ILCE-7RM4A (2 lenses) and ILCE-7M3 (3 lenses) |
|
||||
| Vignetting / lateral CA from maker notes | ✅ | ⬜ |
|
||||
| Manual distortion, vignetting, CA, defringe | ✅ | ✅ |
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
# Sony embedded distortion corrections
|
||||
|
||||
LightCraft reads the signed 16-sample distortion table in the raw image IFD (`0x7037`) of
|
||||
Sony **ILCE-7RM4A (A7R IVA)** Bayer ARWs. Other camera models are deliberately left uncorrected,
|
||||
even when they contain a table with the same layout.
|
||||
Sony **ILCE-7RM4A (A7R IVA)** and **ILCE-7M3 (A7 III)** Bayer ARWs. Other camera models are deliberately
|
||||
left uncorrected, even when they contain a table with the same layout.
|
||||
It converts that table into `OpcodeList3` / `WarpRectilinear`, so the existing profile correction control,
|
||||
CPU/GPU optics path, EXIF orientation handling, and DNG export use the same correction.
|
||||
|
||||
This adds **distortion only**. Sony vignetting and lateral chromatic-aberration tables are not decoded.
|
||||
The enabled and independently validated model is ILCE-7RM4A with the FE 24–105mm F4 G OSS and
|
||||
FE 200–600mm F5.6–6.3 G OSS. Linear YCbCr ARWs and non-3:2 camera crops are excluded pending validation. An aspect crop may
|
||||
The enabled and independently validated models are the ILCE-7RM4A with the FE 24–105mm F4 G OSS and
|
||||
FE 200–600mm F5.6–6.3 G OSS, and the ILCE-7M3 with the FE 16–35mm F4 ZA OSS, FE 28–70mm F3.5–5.6 OSS and
|
||||
FE 50mm F1.8 ([ILCE-7M3 validation](#ilce-7m3-validation)). Linear YCbCr ARWs and non-3:2 camera crops are excluded pending validation. An aspect crop may
|
||||
retain the full-frame radial normalization; the decoder does not guess that relationship. Older files that carry
|
||||
only encrypted correction metadata, different table lengths, and rejected tables remain uncorrected.
|
||||
The camera's distortion Off setting does not erase its table. Newly imported photos use LightCraft's existing
|
||||
@@ -92,9 +93,50 @@ Tests cover signedness/length rejection, zero and malformed tables, independent
|
||||
geometry, barrel framing, offset crop normalization, both TIFF byte orders, header/full consistency,
|
||||
and preservation through DNG export. No private photographs or reference TIFFs are committed.
|
||||
|
||||
## ILCE-7M3 validation
|
||||
|
||||
Checked on 2026-10-12 against the camera's own JPEG of the same exposure, which is an independent renderer
|
||||
that applied the correction (RAW+JPEG, Lens Comp. ▸ Distortion Comp.: Auto). 27 RAW+JPEG pairs from one
|
||||
ILCE-7M3: a checkerboard filling the frame on a TV (handheld) and on a desktop monitor (tripod), compressed and
|
||||
uncompressed raws, apertures from wide open to f/8, plus one textured wall. Tables covered strong barrel (16 mm,
|
||||
−690 at the corner), strong pincushion (70 mm, +1161) and the sign change between them.
|
||||
|
||||
Method (`scripts/sony_distortion_check.py`): LightCraft renders each ARW at full size with the profile correction
|
||||
on and off; sub-pixel board corners (Hessian saddle points refined with `cornerSubPix`, kept only where the four
|
||||
quadrants alternate, labelled into rows and columns) are matched with the JPEG's by grid position, with the
|
||||
match fixed by the central corners before any fit, because a board is periodic. A similarity (zoom, rotation,
|
||||
shift) is fitted, as the camera fills its frame with its own zoom; no lens coefficient is fitted. The textured wall
|
||||
uses SIFT matches instead. The script was checked on synthetic boards with a known radial distortion.
|
||||
|
||||
| Lens / focal length | Shots | Corrected vs JPEG: median / p95 (px at 6000) | Uncorrected median | Corner (r > 0.9) | Camera / LightCraft correction |
|
||||
|---|---:|---|---:|---:|---:|
|
||||
| FE 16–35 F4 ZA / 16 mm | 10 | 0.33–0.38 / 1.01–1.15 | 16.7–17.0 | 1.40–1.56 px | 1.02 |
|
||||
| FE 16–35 F4 ZA / 20 mm | 4 | 0.16–0.24 / 0.34–0.50 | 3.9–4.4 | 0.15–0.24 px | 0.98 |
|
||||
| FE 16–35 F4 ZA / 35 mm | 2 | 0.18–0.21 / 0.39–0.44 | 11.0–11.1 | 0.18–0.26 px | 1.01–1.02 |
|
||||
| FE 28–70 / 28 mm | 2 | 0.24–0.25 / 0.59–0.64 | 13.2–13.4 | 0.92 px | 1.02 |
|
||||
| FE 28–70 / 33 mm | 1 | 0.07 / 0.16 | 0.8 | 0.07 px | 1.00 |
|
||||
| FE 28–70 / 50 mm | 1 | 0.30 / 0.69 | 20.8 | 1.24 px | 1.01 |
|
||||
| FE 28–70 / 70 mm | 1 | 0.44 / 0.93 | 26.4 | 1.49 px | 1.01 |
|
||||
| FE 50 F1.8 / 50 mm | 4 | 0.06–0.12 / 0.15–0.31 | 2.8–3.0 | 0.08–0.14 px | 1.00–1.01 |
|
||||
| Wall, 16 mm (SIFT) | 1 | 0.37 / 1.06 | 5.6 | 1.22 px | — |
|
||||
|
||||
Residuals are in pixels of the 6000×4000 JPEG; at the 2376 px exports used above for the ILCE-7RM4A, the
|
||||
board medians are 0.02–0.17 px and the p95 at most 0.46 px. The last column fits JPEG ≈ uncorrected +
|
||||
α·(corrected − uncorrected) radially: LightCraft applies the camera's amount of correction within 2 %, slightly
|
||||
less for strong barrel and slightly more at 20 mm, which is what remains at the 16 mm corners (about 1.5 px of a
|
||||
35 px correction). Rows and columns of corrected boards are as straight as in the JPEG in every shot (largest
|
||||
bow 0.8–7.8 px in both, from the screens), against 7–55 px uncorrected. Compressed and uncompressed raws,
|
||||
the TV and the monitor, and wide open and f/8 agree. Two 16 mm board shots whose board stopped short of the
|
||||
corners (reach 0.8 of the half diagonal) gave 0.85 px medians and are left out of the table.
|
||||
|
||||
On this body Sony's crop tags and the default crop agree (origin 12, 12, 6000×4000 of 6048×4024), so the
|
||||
warp sits on the image centre; fitted against the camera, the centre is within 0.3 px. Unit tests keep the warp from
|
||||
three of these tables within 0.0008 of the half diagonal (2.9 px at 6000 px) of the camera's fitted geometry.
|
||||
The photographs stay with their owner; none is committed.
|
||||
|
||||
## Camera coverage and regression boundary
|
||||
|
||||
The model check is exact: `ILCE-7RM4` (A7R IV without the A suffix), `ILCE-7M3`, unknown/missing
|
||||
The model check is exact: `ILCE-7RM4` (A7R IV without the A suffix), unknown/missing
|
||||
model names, and every other model remain unsupported by this distortion decoder. Matching tag
|
||||
numbers and table lengths do not establish the scale, interpolation or crop normalization on another
|
||||
body. Support can expand after independent geometry validation; do not add model-name prefix matching.
|
||||
@@ -107,7 +149,7 @@ pinned SHA-256 hashes and are checked with `sony_embedded_distortion_is_limited_
|
||||
|---|---|---|---|
|
||||
| ILCE-7RM4A | Compressed (4822) | 16 samples | One distortion warp |
|
||||
| ILCE-9M2 | Compressed (3989) | 16 samples | None |
|
||||
| ILCE-7M3 | Compressed, uncompressed | 16 samples | None |
|
||||
| ILCE-7M3 | Compressed, uncompressed | 16 samples | One distortion warp |
|
||||
| ILCE-7M4 | 14-bit, lossless L/M/S | 11 samples in the 14-bit file; 16 in L/M/S | None |
|
||||
| ILCE-7RM2 | 12-bit uncompressed | 16 samples | None |
|
||||
| DSC-RX100 | One | No raw-IFD table | None |
|
||||
@@ -119,8 +161,9 @@ pinned SHA-256 hashes and are checked with `sony_embedded_distortion_is_limited_
|
||||
| ILCE-7SM2, DSLR-A580 | 16:9 framing, one each | Not used; model excluded | None |
|
||||
| DSLR-A900 | Packed 12-bit | Not used; model excluded | None |
|
||||
|
||||
These are **model-boundary regression checks, not new geometric validation of those models**. In particular,
|
||||
refusing the A7 III tables resolves the unverified corner warp identified in review. The lossless M/S
|
||||
These are **model-boundary regression checks, not new geometric validation of those models**. The A7 III tables
|
||||
were refused at first because their corner warp was unverified; the [ILCE-7M3 validation](#ilce-7m3-validation)
|
||||
measured it against the camera's own JPEGs and enabled the body. The lossless M/S
|
||||
files are linear YCbCr and remain excluded independently of the model check. Header and full decode
|
||||
must agree for every sample. Synthetic tests also verify both TIFF byte orders, exact model matching,
|
||||
that the warp is centred on the standard default crop while the image keeps Sony's crop, and that DNG export preserves an accepted warp without adding one to rejected
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# What's new in LightCraft
|
||||
|
||||
## 2026-10-12
|
||||
|
||||
### Lens distortion correction for the Sony A7 III
|
||||
- Raws from the Sony A7 III (ILCE-7M3) now get the camera's own distortion correction, read from the file, like
|
||||
the A7R IVA's: straight lines stay straight at wide angles (the FE 16–35 F4 ZA at 16 mm shifts detail near the
|
||||
corners by up to about 40 px on a 24 MP photo) and at long ones (the FE 28–70 at 70 mm). It lines up with the
|
||||
camera's JPEG to well under a pixel. Optics ▸ Enable Lens Corrections turns it off and Profile Distortion scales
|
||||
it; photos imported before this pick it up with Photo ▸ Reload from Disk. Vignetting and chromatic aberration
|
||||
from Sony files are not read yet.
|
||||
|
||||
## 2026-10-11
|
||||
|
||||
### JPEG XL export
|
||||
|
||||
@@ -69,24 +69,67 @@ def detect_corners(gray):
|
||||
if len(pts) == 0:
|
||||
return pts.reshape(-1, 2)
|
||||
crit = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 50, 0.005)
|
||||
pts = cv2.cornerSubPix(gray, pts, (11, 11), (-1, -1), crit)
|
||||
return pts.reshape(-1, 2).astype(np.float64)
|
||||
pts = cv2.cornerSubPix(gray, pts, (11, 11), (-1, -1), crit).reshape(-1, 2).astype(np.float64)
|
||||
# Two peaks near one corner can refine to the same point; keep one of each such pair.
|
||||
drop = {j for _, j in cKDTree(pts).query_pairs(3.0)}
|
||||
pts = pts[[i for i in range(len(pts)) if i not in drop]]
|
||||
return pts[[is_x_corner(g, p) for p in pts]]
|
||||
|
||||
|
||||
def is_x_corner(g, p, r=12):
|
||||
"""A board corner has alternating quadrants: equal diagonals, contrasting neighbours.
|
||||
A point on a straight edge (screen texture can make one look like a saddle) does not."""
|
||||
h, w = g.shape
|
||||
x, y = int(round(p[0])), int(round(p[1]))
|
||||
if not (r <= x < w - r and r <= y < h - r):
|
||||
return False
|
||||
a, c = g[y + r, x + r], g[y - r, x - r]
|
||||
b, d = g[y - r, x + r], g[y + r, x - r]
|
||||
contrast = abs((a + c) - (b + d)) / 2
|
||||
return contrast > 15 and contrast > 2 * max(abs(a - c), abs(b - d))
|
||||
|
||||
|
||||
def label_lattice(pts, centre):
|
||||
"""Integer (i, j) grid labels for corners connected to the one nearest the centre.
|
||||
i grows along +x, j along +y; local lattice vectors follow the distortion gradually."""
|
||||
"""Integer (i, j) grid labels for corners connected to one near the centre.
|
||||
i grows along +x, j along +y; local lattice vectors follow the distortion gradually.
|
||||
The starting vectors are the consensus of many central corners, so a stray corner (screen
|
||||
moiré) next to the start can't derail it; a few starts are tried, the largest result kept."""
|
||||
if len(pts) < 20:
|
||||
return {}
|
||||
tree = cKDTree(pts)
|
||||
start = int(tree.query(centre)[1])
|
||||
d, idx = tree.query(pts[start], k=5)
|
||||
offsets = pts[idx[1:]] - pts[start]
|
||||
spacing = float(np.median(d[1:]))
|
||||
a = offsets[np.argmax(offsets @ np.array([1.0, 0.0]))]
|
||||
b = offsets[np.argmax(offsets @ np.array([0.0, 1.0]))]
|
||||
if abs(np.dot(a, b)) > 0.5 * np.linalg.norm(a) * np.linalg.norm(b):
|
||||
vectors = lattice_vectors(pts, tree, centre)
|
||||
if vectors is None:
|
||||
return {}
|
||||
best = {}
|
||||
for start in tree.query(centre, k=min(9, len(pts)))[1]:
|
||||
labels = grow_lattice(pts, tree, int(start), *vectors)
|
||||
if len(labels) > len(best):
|
||||
best = labels
|
||||
if len(best) > 0.8 * len(pts):
|
||||
break
|
||||
return best if len(best) > 50 else {}
|
||||
|
||||
|
||||
def lattice_vectors(pts, tree, centre):
|
||||
"""Median neighbour offsets near +x and +y among the corners nearest the centre."""
|
||||
k = min(9, len(pts))
|
||||
offsets = []
|
||||
for i in tree.query(centre, k=min(60, len(pts)))[1]:
|
||||
nn = tree.query(pts[i], k=k)[1]
|
||||
offsets.extend(pts[nn[1:]] - pts[i])
|
||||
offsets = np.array(offsets)
|
||||
length = np.linalg.norm(offsets, axis=1)
|
||||
spacing = np.median(np.sort(length.reshape(-1, k - 1), axis=1)[:, :4])
|
||||
offsets = offsets[np.abs(length - spacing) < 0.25 * spacing]
|
||||
angle = np.arctan2(offsets[:, 1], offsets[:, 0])
|
||||
near_x = np.abs(np.angle(np.exp(1j * angle))) < np.pi / 4
|
||||
near_y = np.abs(np.angle(np.exp(1j * (angle - np.pi / 2)))) < np.pi / 4
|
||||
if near_x.sum() < 10 or near_y.sum() < 10:
|
||||
return None
|
||||
return np.median(offsets[near_x], axis=0), np.median(offsets[near_y], axis=0)
|
||||
|
||||
|
||||
def grow_lattice(pts, tree, start, a, b):
|
||||
labels = {start: (0, 0)}
|
||||
taken = {(0, 0): start}
|
||||
vec = {start: (a, b)}
|
||||
@@ -109,7 +152,7 @@ def label_lattice(pts, centre):
|
||||
taken[key] = q
|
||||
vec[q] = (na, nb)
|
||||
queue.append(q)
|
||||
return labels if len(labels) > 50 else {}
|
||||
return labels
|
||||
|
||||
|
||||
def board(path):
|
||||
@@ -305,8 +348,9 @@ def measure(cli, work, arw):
|
||||
s, d = match_boards(grid, (size[0] * k, size[1] * k), jgrid, jsize)
|
||||
else:
|
||||
s, d = sift_pairs(path, jpg)
|
||||
if s is not None:
|
||||
out["vs_jpeg_" + tag] = agreement(s, d, jsize)
|
||||
a = agreement(s, d, jsize) if s is not None else None
|
||||
if a:
|
||||
out["vs_jpeg_" + tag] = a
|
||||
if "vs_jpeg_on" in out and "vs_jpeg_off" in out and out["kind"] == "board":
|
||||
out["fraction"] = correction_fraction(out["vs_jpeg_on"], out["vs_jpeg_off"], jsize)
|
||||
for tag in ("on", "off"):
|
||||
|
||||
Reference in New Issue
Block a user