From 9682caae2a6dde18393d40a6359919e99f87a8db Mon Sep 17 00:00:00 2001 From: Elliot Graham Date: Sat, 10 Oct 2026 10:30:44 +0100 Subject: [PATCH] LR-EDIT-OPTICS-PROFILE: guard Sony aspect crops and validate the full corpus Keep image framing inside the validated 3:2 scope even when DefaultCrop retains native geometry. Cover all 23 Sony fixtures, preserve crop precedence, invalidate old renders, and record fresh Sony reference measurements. Co-authored-by: Codex --- crates/engine/src/media.rs | 2 +- crates/raw/src/vendor/arw.rs | 4 +- crates/raw/src/vendor/arw_lens.rs | 61 ++++++++++++++++--------------- crates/raw/tests/corpus.rs | 12 ++++++ docs/sony-lens-corrections.md | 38 +++++++++++++++---- 5 files changed, 78 insertions(+), 39 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6d5b548f1..d863060ec 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 42; +pub const RENDER_CACHE_VERSION: u64 = 43; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index d80c67f84..4295b0b8c 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -694,7 +694,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { // validated against Sony's exports with that centre. The opcode's centre is in active-area coordinates, so // it stays on the optical centre whichever crop frames the image. opcodes: OpcodeLists { - list3: if linear_rgb { + // DefaultCrop can retain native geometry while Sony's tags select an aspect crop. + // Both the image framing and the table geometry must stay within the validated 3:2 scope. + list3: if linear_rgb || crop.height == 0 || (crop.width as f64 / crop.height as f64 - 1.5).abs() > 0.005 { Vec::new() } else { let geometry = dng_default_crop(raw, w, h).unwrap_or(crop); diff --git a/crates/raw/src/vendor/arw_lens.rs b/crates/raw/src/vendor/arw_lens.rs index 2fa0fdb72..f4e02d69d 100644 --- a/crates/raw/src/vendor/arw_lens.rs +++ b/crates/raw/src/vendor/arw_lens.rs @@ -194,36 +194,39 @@ mod tests { 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 order in [ByteOrder::Little, ByteOrder::Big] { - let mut raw = IfdBuilder::new(); - raw.set(t::MAKE, Value::Ascii("SONY".into())); - raw.set(t::MODEL, Value::Ascii(model.into())); - raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); - raw.set(t::IMAGE_LENGTH, Value::Long(vec![24])); - raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); - raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); - raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); - raw.set(t::COMPRESSION, Value::Short(vec![1])); - raw.set(t::WHITE_LEVEL, Value::Long(vec![16383])); - raw.set(0x74c7, Value::Long(vec![0, 2])); - raw.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![2, 2])); - raw.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![30, 20])); - raw.set(0x74c8, Value::Long(vec![30, 20])); - raw.set(DISTORTION, Value::SShort(std::iter::once(16).chain(TELE).collect())); - // Zero pixels have the same byte representation in both orders. - raw.set_image(ImageData::Strips { rows_per_strip: 24, strips: vec![vec![0; 32 * 24 * 2]] }); - let file = TiffWriter { order, ..Default::default() }.write(&[raw]).unwrap(); - let header = super::super::arw::decode(&file, crate::Mode::Header).unwrap(); - let full = super::super::arw::decode(&file, crate::Mode::Full).unwrap(); - assert_eq!(header.info(), full.info()); - assert_eq!(full.opcodes.list3.len(), expected, "{model} {order:?}"); - // the image keeps Sony's crop tags; the warp is centred on the DNG default crop (x 2..32 of 32) - assert_eq!(full.crop, Rect::new(0, 2, 30, 20)); - if let Some(Opcode::WarpRectilinear { center, .. }) = full.opcodes.list3.first() { - assert!((center[0] - 16.5 / 31.0).abs() < 1e-12 && (center[1] - 11.5 / 23.0).abs() < 1e-12, "{center:?}"); + for (height, expected) in [(20, expected), (16, 0)] { + for order in [ByteOrder::Little, ByteOrder::Big] { + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii(model.into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![24])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::WHITE_LEVEL, Value::Long(vec![16383])); + raw.set(0x74c7, Value::Long(vec![0, 2])); + raw.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![2, 2])); + raw.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![30, 20])); + // An aspect crop must stay excluded even when DefaultCrop still describes 3:2. + raw.set(0x74c8, Value::Long(vec![30, height])); + raw.set(DISTORTION, Value::SShort(std::iter::once(16).chain(TELE).collect())); + // Zero pixels have the same byte representation in both orders. + raw.set_image(ImageData::Strips { rows_per_strip: 24, strips: vec![vec![0; 32 * 24 * 2]] }); + let file = TiffWriter { order, ..Default::default() }.write(&[raw]).unwrap(); + let header = super::super::arw::decode(&file, crate::Mode::Header).unwrap(); + let full = super::super::arw::decode(&file, crate::Mode::Full).unwrap(); + assert_eq!(header.info(), full.info()); + assert_eq!(full.opcodes.list3.len(), expected, "{model} {order:?} height={height}"); + // the image keeps Sony's crop tags; the warp is centred on the DNG default crop (x 2..32 of 32) + assert_eq!(full.crop, Rect::new(0, 2, 30, height as usize)); + if let Some(Opcode::WarpRectilinear { center, .. }) = full.opcodes.list3.first() { + assert!((center[0] - 16.5 / 31.0).abs() < 1e-12 && (center[1] - 11.5 / 23.0).abs() < 1e-12, "{center:?}"); + } + let dng = crate::write_dng(&full, &Default::default()).unwrap(); + assert_eq!(crate::decode(&dng).unwrap().opcodes.list3, full.opcodes.list3); } - let dng = crate::write_dng(&full, &Default::default()).unwrap(); - assert_eq!(crate::decode(&dng).unwrap().opcodes.list3, full.opcodes.list3); } } } diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 8649dd703..d3912c292 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -915,6 +915,18 @@ fn sony_embedded_distortion_is_limited_to_validated_models() { ("arw-sony-a7rm2-12bit-uncompressed.arw", "ILCE-7RM2", 0), ("arw-sony-rx100.arw", "DSC-RX100", 0), ("arw-sony-rx100m3.arw", "DSC-RX100M3", 0), + ("arw-sony-a7cr-lossless-l.arw", "ILCE-7CR", 0), + ("arw-sony-a7cr-lossless-m.arw", "ILCE-7CR", 0), + ("arw-sony-a7cr-compressed.arw", "ILCE-7CR", 0), + ("arw-sony-a7rm4-14bit-compressed.arw", "ILCE-7RM4", 0), + ("arw-sony-a500.arw", "DSLR-A500", 0), + ("arw-sony-a700.arw", "DSLR-A700", 0), + ("arw-sony-a33.arw", "SLT-A33", 0), + ("arw-sony-a3500-5600k.arw", "ILCE-3500", 0), + ("arw-sony-a7s-shade.arw", "ILCE-7S", 0), + ("arw-sony-a7sm2-16x9.arw", "ILCE-7SM2", 0), + ("arw-sony-a580-16x9.arw", "DSLR-A580", 0), + ("arw-sony-a900-packed12.arw", "DSLR-A900", 0), ] { let path = root.join(name); if !path.exists() { diff --git a/docs/sony-lens-corrections.md b/docs/sony-lens-corrections.md index 9137dfbf6..375f773ab 100644 --- a/docs/sony-lens-corrections.md +++ b/docs/sony-lens-corrections.md @@ -64,7 +64,8 @@ from the interpolated file table was below 0.12 px at 1440 px. That measures app not agreement with an independent renderer. Camera JPEG and Apple Core Image comparisons support the wide-lens interpretation but are weaker evidence than same-renderer On/Off pairs. -Actual LightCraft exports after crop normalization were also compared directly with Sony's corrected +Historical validation before restoring main's image crop precedence: LightCraft exports with the +standard default crop framing were compared directly with Sony's corrected TIFFs. At 2376 px wide, median / 95th-percentile feature residuals were 0.35 / 1.04 px at 24 mm, 0.15 / 0.40 px at 200 mm, and 0.13 / 0.56 px at 600 mm (median translation below 0.024 px). The uncorrected medians at 200 and 600 mm were 8.63 and 10.82 px respectively. Different colour, @@ -73,6 +74,20 @@ An isolated desktop run on Apple M1 Metal detected the profile, rendered without warnings, and exposed the existing Optics controls. Existing library edits are not reset: once the source has loaded, enable lens corrections if they were previously disabled. +After restoring main's crop precedence, fresh 2376×1584 exports on 2026-10-10 +(main `b70ae174` plus the aspect-framing guard) measured: + +| Focal length | Corrected median / p95 residual | Uncorrected median | Removed translation x / y | Matches / maximum radius | +|---|---|---|---|---| +| 24 mm | 0.36 / 1.12 px | 5.62 px | 7.98 / −0.01 px | 1,442 / 0.95 | +| 200 mm | 0.13 / 0.35 px | 8.65 px | 8.01 / 0.02 px | 11,606 / 0.98 | +| 600 mm | 0.10 / 0.37 px | 10.33 px | 8.02 / 0.00 px | 12,268 / 0.97 | + +Residuals subtract only the median feature translation; no scale or lens coefficients are fitted to +these reference pairs. The roughly eight-pixel horizontal translation is the retained crop-origin +difference described above, not a claim of exact Sony framing. Maximum radius is in half diagonals. +The distortion geometry remains aligned while every body's image framing follows main's crop order. + Tests cover signedness/length rejection, zero and malformed tables, independent 600 mm reference 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. @@ -84,10 +99,9 @@ model names, and every other model remain unsupported by this distortion decoder 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. -The original nine Sony samples listed by `cargo xtask corpus --download` were downloaded from its -CC0 URLs. The manifest now also includes CC0 samples 4822 (A7R IVA / 24–105 mm) and 3989 -(A9 II / 200–600 mm), with their SHA-256 hashes recorded alongside the URLs. All eleven files -are checked with `sony_embedded_distortion_is_limited_to_validated_models`: +The current `cargo xtask corpus --download` manifest contains 23 Sony CC0 samples across 17 bodies, +including samples 4822 (A7R IVA / 24–105 mm) and 3989 (A9 II / 200–600 mm). All 23 files have +pinned SHA-256 hashes and are checked with `sony_embedded_distortion_is_limited_to_validated_models`: | Camera | Corpus samples | Raw-IFD table observed | Expected correction | |---|---|---|---| @@ -98,15 +112,23 @@ are checked with `sony_embedded_distortion_is_limited_to_validated_models`: | ILCE-7RM2 | 12-bit uncompressed | 16 samples | None | | DSC-RX100 | One | No raw-IFD table | None | | DSC-RX100M3 | One | 11 samples | None | +| ILCE-7CR | Compressed, lossless L/M | Not used; model excluded | None | +| ILCE-7RM4 | 14-bit compressed | Not used; model excluded | None | +| DSLR-A500, DSLR-A700, SLT-A33 | One each | Not used; model excluded | None | +| ILCE-3500, ILCE-7S | 5600 K, Shade respectively | Not used; model excluded | None | +| 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 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 -models. The existing independent Sony geometry tests continue to cover the enabled A7R IVA path. -Re-exporting the 24, 200 and 600 mm reference files after the model restriction produced byte-identical -corrected PNGs to the earlier validated exports. Isolated headless app checks showed the A7R IVA profile +models. A non-3:2 Sony image crop is also rejected when the standard default crop still describes +3:2 geometry; the regression fails without the image-framing guard. The existing independent Sony +geometry tests continue to cover the enabled A7R IVA path. +Before restoring main's crop precedence, re-exporting the 24, 200 and 600 mm reference files after +the model restriction produced byte-identical corrected PNGs to the earlier validated exports. Isolated headless app checks showed the A7R IVA profile controls present and the A7 III profile controls absent, with no notices and completed RAW renders. Before enabling another body, compare corrected and uncorrected renders against independent references