diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index d6715d49e..49543c4fe 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -875,7 +875,8 @@ mod tests { } /// The ILCE-7RM6's distortion table reaches `OpcodeList3`, in the headers-only probe and in the full decode, as - /// one plane, or three with the CA table; another body's tables don't (see [`super::super::arw_lens_rm6`]). + /// one plane, also with a CA table (lateral CA is off by default); another body's tables don't (see + /// [`super::super::arw_lens_rm6`]). #[test] fn lens_tables_reach_opcode_list3() { use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; @@ -904,10 +905,10 @@ mod tests { TiffWriter::default().write(&[raw]).unwrap() }; for mode in [Mode::Header, Mode::Full] { - for (ca, n) in [(false, 1), (true, 3)] { + for ca in [false, true] { let list3 = |model| decode(&file(model, ca), mode).unwrap().opcodes.list3; let ops = list3("ILCE-7RM6"); - assert!(matches!(ops.as_slice(), [crate::Opcode::WarpRectilinear { planes, .. }] if planes.len() == n), "{mode:?} {ca}: {ops:?}"); + assert!(matches!(ops.as_slice(), [crate::Opcode::WarpRectilinear { planes, .. }] if planes.len() == 1), "{mode:?} {ca}: {ops:?}"); assert!(list3("ILCE-7M4").is_empty(), "{mode:?} {ca}"); } } diff --git a/crates/raw/src/vendor/arw_lens_rm6.rs b/crates/raw/src/vendor/arw_lens_rm6.rs index 37d76fdd6..cc3a23e61 100644 --- a/crates/raw/src/vendor/arw_lens_rm6.rs +++ b/crates/raw/src/vendor/arw_lens_rm6.rs @@ -29,11 +29,13 @@ //! - vignetting: read but never applied. The camera JPEGs keep the uncorrected render's radial falloff (median //! corner/centre ratio 0.996 over 39 photos) and no radial gain model fits the tables. //! - lateral CA: red's (blue's) source radius is `g · (1 + c(g) / 3.36·10⁶)` with `g` the green source radius and `c` -//! the red (blue) half on the same knots, as the warp's R and B planes. The unit is **provisional**: the -//! measurement is borderline (0.399 px worst against a 0.3 px budget; per-photo best units scatter from 1.5·10⁶ -//! to 7.2·10⁶, about ±30 % scene bias), but it cuts the rms CA over 46 photos from 0.079 to 0.052 px. When a CA -//! value is implausible (beyond [`CA_MAX_RELATIVE`]) or a colour plane fails the fit gate above, both colour -//! planes are dropped and G alone is applied to all three channels. +//! the red (blue) half on the same knots. The unit is **provisional**: the measurement is borderline (0.399 px +//! worst against a 0.3 px budget; per-photo best units scatter from 1.5·10⁶ to 7.2·10⁶, about ±30 % scene bias), +//! but it cuts the rms raw-CFA CA over 46 photos from 0.079 to 0.052 px. The table is parsed and [`warp_opcode`] +//! converts it to the warp's R and B planes, but they are **not emitted by default**: [`lens_opcodes`] passes +//! `lateral_ca: false`, because applied after demosaic they made the render's CA worse (numbers there). To turn +//! them on in code, pass `true` there. When on, a CA value beyond [`CA_MAX_RELATIVE`] or a colour plane that fails +//! the fit gate above drops both colour planes, and G alone is applied to all three channels. //! //! The correction is produced whatever the in-camera flag says. Only bodies listed in [`FULL_FRAME_CROP`] get it: the //! unit and the knot spacing are verified on the ILCE-7RM6 alone. Another body is added by repeating the measurement @@ -61,7 +63,8 @@ const DISTORTION_UNIT: f64 = 15000.0; /// cleanest photos; per-photo units scatter from 1.5·10⁶ to 7.2·10⁶ (the measurement's scene bias is about ±30 %). const CA_UNIT: f64 = 3.36e6; /// Largest plausible CA value relative to the green source radius (|c| / [`CA_UNIT`]): about 12 px at the full-frame -/// corner; the measured tables stay below 790 / 3.36·10⁶ ≈ 2.4·10⁻⁴. A table beyond it is refused. +/// corner; the largest value in the 54 measured photos' tables is 1724 (FE 24–70 mm), 1724 / 3.36·10⁶ ≈ 5.1·10⁻⁴. +/// A table beyond it is refused. const CA_MAX_RELATIVE: f64 = 2e-3; /// Crop border radii scanned by the frame-filling zoom. const ZOOM_SCAN: usize = 4096; @@ -151,12 +154,13 @@ fn fill_zoom(source: &dyn Fn(f64) -> f64, x: f64, y: f64) -> Option { } /// The distortion and CA tables as a `WarpRectilinear` relative to the active area `active` (centred on the default -/// crop `crop`, as the camera corrects), with knots `knot_spacing` px apart: planes R, G, B, or G alone (applied to -/// all three) when the CA table is absent or a colour plane's fit fails. `None` when the distortion table is absent or +/// crop `crop`, as the camera corrects), with knots `knot_spacing` px apart: planes R, G, B with `lateral_ca`, or G +/// alone (applied to all three) without it, or when the CA table is absent, implausible or a colour plane's fit +/// fails. `None` when the distortion table is absent or /// all zero (CA alone is not applied: it is relative to green), the zoom or the G fit fails. A fit fails when it is /// worse than 0.25 px at a 1440 px long edge at one of the fit samples within the crop (a padded active area, as in /// lossless files, reaches radii that are never shown). -pub(crate) fn warp_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: Rect) -> Option { +pub(crate) fn warp_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: Rect, lateral_ca: bool) -> Option { let v = t.distortion.as_deref().filter(|v| v.iter().any(|&x| x != 0.0))?; let (aw, ah) = (active.width as f64, active.height as f64); if aw < 2.0 || ah < 2.0 || crop.width == 0 || crop.height == 0 { @@ -186,16 +190,22 @@ pub(crate) fn warp_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: }; // a NaN fails the comparison: refused too let plausible = |c: &[f64]| c.iter().all(|v| v.abs() <= CA_MAX_RELATIVE * CA_UNIT); - let rgb = t.ca.as_ref().filter(|(red, blue)| plausible(red) && plausible(blue)); + let rgb = t.ca.as_ref().filter(|(red, blue)| lateral_ca && plausible(red) && plausible(blue)); let rgb = rgb.and_then(|(red, blue)| Some(vec![colour(red)?, g, colour(blue)?])); Some(Opcode::WarpRectilinear { planes: rgb.unwrap_or_else(|| vec![g]), center: [cx / (aw - 1.0), cy / (ah - 1.0)] }) } /// The `OpcodeList3` lens corrections of a Sony raw IFD for the body `model` (Exif `Model`); empty for a body whose /// tables are not verified. +/// +/// Distortion only: the lateral CA planes are off (`lateral_ca: false`). Our demosaic already hides about 70 % of the +/// lens CA (the render shows 0.29 × the CA measured on the raw CFA), while the warp, which runs after demosaic, moves +/// R and B by 0.84 × the table. Over the 43 verified photos with enough corner detail, the render's corner R/G and B/G +/// CA went from rms 0.032 / max 0.125 px (off) to 0.052 / 0.219 px (on) at a 1440 px long edge; only the 16–35 mm +/// improved. Follow-up: warp the CFA colour planes before demosaic, then turn the planes on here. pub(crate) fn lens_opcodes(raw: &Ifd, model: &str, active: Rect, crop: Rect) -> Vec { let Some(spacing) = knot_spacing(model) else { return Vec::new() }; - warp_opcode(&read_tables(raw), spacing, active, crop).into_iter().collect() + warp_opcode(&read_tables(raw), spacing, active, crop, false).into_iter().collect() } #[cfg(test)] @@ -222,6 +232,12 @@ mod tests { 22, 790, 764, 734, 702, 666, 624, 582, 544, 510, 484, 470, -432, -404, -372, -330, -286, -230, -174, -114, -58, -6, 34, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ]; + /// The APS-C lossless corpus file `arw-sony-ilce7rm6-apsc-land-lossless.arw` (same scene; its tables differ slightly). + const APSC_LOSSLESS_DISTORTION: [i16; 17] = [11, 0, -10, -36, -78, -134, -202, -277, -356, -435, -511, -583, -583, -583, -583, -583, -583]; + const APSC_LOSSLESS_CA: [i16; 33] = [ + 22, 792, 766, 738, 706, 670, 630, 588, 548, 514, 488, 476, -436, -408, -376, -334, -290, -234, -178, -118, -62, -12, 30, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, + ]; /// ARW6 geometry (the decoder's raw image size and default crop). const FF_ACTIVE: Rect = Rect { x: 0, y: 0, width: 10016, height: 6672 }; const FF_CROP: Rect = Rect { x: 12, y: 8, width: 9984, height: 6656 }; @@ -325,7 +341,7 @@ mod tests { let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(table))]); let t = read_tables(&ifd); assert!(t.distortion.is_none(), "{what}"); - assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP).is_none(), "{what}"); + assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, false).is_none(), "{what}"); assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty(), "{what}"); } // not an integer array, or absent @@ -353,7 +369,7 @@ mod tests { ("not finite", vec![0.0, f64::NAN, 20.0]), ("infinite", vec![0.0, f64::INFINITY, 20.0]), ] { - assert!(warp_opcode(&tables_with_distortion(&table), knots(), FF_ACTIVE, FF_CROP).is_none(), "{what}"); + assert!(warp_opcode(&tables_with_distortion(&table), knots(), FF_ACTIVE, FF_CROP, false).is_none(), "{what}"); } // the same through the IFD, as 64-bit values for v in [i64::MAX / 4, i64::MIN, i64::MAX] { @@ -370,37 +386,37 @@ mod tests { ("knot spacing NaN", f64::NAN, FF_ACTIVE, FF_CROP), ("knot spacing 0", 0.0, FF_ACTIVE, FF_CROP), ] { - assert!(warp_opcode(&ff, knots, active, crop).is_none(), "{what}"); + assert!(warp_opcode(&ff, knots, active, crop, false).is_none(), "{what}"); } } #[test] fn ff_example_table_becomes_the_measured_warp() { let t = tables_with_distortion(&values(&FF_DISTORTION)); - assert_warp(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP), [0.4996005991, 0.5], &[FF_G]); + assert_warp(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, false), [0.4996005991, 0.5], &[FF_G]); } #[test] fn apsc_example_table_becomes_the_measured_warp() { let t = tables_with_distortion(&values(&APSC_DISTORTION)); - let op = warp_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP); + let op = warp_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP, false); assert_warp(op, [0.4975724473, 0.5], &[APSC_G]); } #[test] fn ca_table_shifts_red_and_blue_planes() { let t = read_tables(&sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_HQ_CA, [0, 2, 1])); - let planes = assert_warp(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP), [0.4996005991, 0.5], &[FF_HQ_R, FF_G, FF_HQ_B]); + let planes = assert_warp(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, true), [0.4996005991, 0.5], &[FF_HQ_R, FF_G, FF_HQ_B]); assert_ne!(planes[0][0], planes[1][0]); // G is the distortion plane alone - let g = warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP); + let g = warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP, false); assert!(matches!(g, Some(Opcode::WarpRectilinear { planes: ref p, .. }) if p == &[planes[1]]), "{g:?}"); } #[test] fn apsc_ca_planes_match_the_measured_warp() { let t = read_tables(&sony_lens_ifd(&APSC_DISTORTION, &APSC_VIGNETTING, &APSC_CA, [0, 2, 1])); - assert_warp(warp_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP), [0.4975724473, 0.5], &[APSC_R, APSC_G, APSC_B]); + assert_warp(warp_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP, true), [0.4975724473, 0.5], &[APSC_R, APSC_G, APSC_B]); } #[test] @@ -408,17 +424,20 @@ mod tests { let ifd = ifd(vec![(CA_PARAMS, Value::SShort(FF_HQ_CA.to_vec()))]); let t = read_tables(&ifd); assert!(t.ca.is_some() && t.distortion.is_none()); - assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP).is_none()); + assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, true).is_none()); assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty()); // an all-zero distortion table counts as absent let zero = LensTables { ca: t.ca, ..tables_with_distortion(&[0.0; 16]) }; - assert!(warp_opcode(&zero, knots(), FF_ACTIVE, FF_CROP).is_none()); + assert!(warp_opcode(&zero, knots(), FF_ACTIVE, FF_CROP, true).is_none()); } #[test] fn rejected_ca_keeps_the_single_green_plane() { - let green = - assert_warp(warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP), [0.4996005991, 0.5], &[FF_G]); + let green = assert_warp( + warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP, false), + [0.4996005991, 0.5], + &[FF_G], + ); let ok = values(&FF_HQ_CA); let huge = vec![(i64::MAX / 4) as f64; 16]; for (what, red, blue) in [ @@ -429,7 +448,7 @@ mod tests { ("one knot", vec![100.0], vec![100.0]), ] { let t = LensTables { ca: Some((red, blue)), ..tables_with_distortion(&values(&FF_DISTORTION)) }; - let Some(Opcode::WarpRectilinear { planes, .. }) = warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP) else { panic!("{what}: no warp") }; + let Some(Opcode::WarpRectilinear { planes, .. }) = warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, true) else { panic!("{what}: no warp") }; assert_eq!(planes, green, "{what}"); } // through the IFD: an odd count, or values that overflow the fit @@ -443,15 +462,56 @@ mod tests { max[0] = 32; for (what, ca) in [("odd count", Value::SShort(odd)), ("huge", Value::SLong8(huge_ifd)), ("constant 32767", Value::SShort(max))] { let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(FF_DISTORTION.to_vec())), (CA_PARAMS, ca)]); - let ops = lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP); - assert!(matches!(ops.as_slice(), [Opcode::WarpRectilinear { planes, .. }] if planes == &green), "{what}: {ops:?}"); + let op = warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, true); + assert!(matches!(op, Some(Opcode::WarpRectilinear { ref planes, .. }) if planes == &green), "{what}: {op:?}"); } // values up to the bound are kept let mut at_bound = vec![6720; 33]; at_bound[0] = 32; let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(FF_DISTORTION.to_vec())), (CA_PARAMS, Value::SShort(at_bound))]); + let op = warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, true); + assert!(matches!(op, Some(Opcode::WarpRectilinear { ref planes, .. }) if planes.len() == 3), "{op:?}"); + } + + /// The default: a valid CA table is read and converts (above), but `lens_opcodes` emits the G plane alone. + #[test] + fn lens_opcodes_emit_the_green_plane_only() { + let ifd = sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_HQ_CA, [1, 2, 1]); + let with_ca = warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, true); + assert!(matches!(with_ca, Some(Opcode::WarpRectilinear { ref planes, .. }) if planes.len() == 3), "{with_ca:?}"); let ops = lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP); - assert!(matches!(ops.as_slice(), [Opcode::WarpRectilinear { planes, .. }] if planes.len() == 3), "{ops:?}"); + let [Opcode::WarpRectilinear { planes, .. }] = ops.as_slice() else { panic!("{ops:?}") }; + assert_eq!(planes.len(), 1); + let green = warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP, false); + assert_eq!(ops.first(), green.as_ref()); + } + + /// The corpus lossless files' own tables on their decoded geometry (padded active areas; the corpus test checks + /// the G plane from the files): R, G, B with the CA planes enabled. + #[test] + fn lossless_examples_ca_planes_match_the_measured_warp() { + let ff = read_tables(&sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_CA, [0, 2, 1])); + let ff_active = Rect { x: 0, y: 0, width: 10240, height: 7168 }; + assert_warp( + warp_opcode(&ff, knots(), ff_active, FF_CROP, true), + [0.488670768629749, 0.4653969582810102], + &[ + [0.971770511193381, 0.023073568897462152, 0.00879158563716919, 0.0030468782543633132], + [0.9717033132764651, 0.023249297636569695, 0.008638979168841135, 0.003091041831374855], + [0.9716312798157171, 0.02356676356656341, 0.00830169937204417, 0.003182927930635947], + ], + ); + let apsc = read_tables(&sony_lens_ifd(&APSC_LOSSLESS_DISTORTION, &APSC_VIGNETTING, &APSC_LOSSLESS_CA, [0, 2, 1])); + let apsc_active = Rect { x: 0, y: 0, width: 6656, height: 4608 }; + assert_warp( + warp_opcode(&apsc, knots(), apsc_active, APSC_CROP, true), + [0.49278737791134486, 0.4743868026915563], + &[ + [1.0165959888027158, -0.0690974265041942, 0.036328960724338874, -0.009486844437228997], + [1.016366854640611, -0.0688824189782517, 0.03615745253291194, -0.009438825010476538], + [1.0162457267369314, -0.0686225599869027, 0.035974238433727206, -0.009384155126102897], + ], + ); } /// The lossless files' padded active area (10240 × 7168) puts the farthest active-area corner (DNG radius 1) well @@ -461,7 +521,7 @@ mod tests { fn padded_active_area_gates_only_the_displayed_radii() { let strong: Vec = values(&FF_DISTORTION).iter().map(|v| v * 1.5).collect(); let padded = Rect { x: 0, y: 0, width: 10240, height: 7168 }; - let op = warp_opcode(&tables_with_distortion(&strong), knots(), padded, FF_CROP); + let op = warp_opcode(&tables_with_distortion(&strong), knots(), padded, FF_CROP, false); assert!(matches!(op, Some(Opcode::WarpRectilinear { .. })), "{op:?}"); } @@ -472,7 +532,7 @@ mod tests { assert!(lens_opcodes(&ifd, "", FF_ACTIVE, FF_CROP).is_empty()); // the camera's in-camera flag (0 = off here) doesn't matter: the table is applied let ops = lens_opcodes(&ifd, " ILCE-7RM6 ", FF_ACTIVE, FF_CROP); - assert_eq!(ops, warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP).into_iter().collect::>()); + assert_eq!(ops, warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, false).into_iter().collect::>()); assert_eq!(ops.len(), 1); } } diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index c32857100..b4041178b 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -626,27 +626,24 @@ fn corpus_sony_16x9_crops() { eprintln!("Sony 16:9 crops checked on {seen} files"); } -/// Sony ILCE-7RM6: the distortion and lateral CA tables in the raw IFD (`0x7037`, `0x7035`) become a three-plane -/// (R, G, B) `WarpRectilinear` in `OpcodeList3` (`vendor::arw_lens_rm6`). Expected: the conversion recipe measured on 54 +/// Sony ILCE-7RM6: the distortion table in the raw IFD (`0x7037`) becomes a one-plane `WarpRectilinear` in +/// `OpcodeList3` (`vendor::arw_lens_rm6`; the lateral CA table is read but its planes are off by default, and their +/// numbers for these files are checked in `arw_lens_rm6`'s unit tests). Expected: the conversion recipe measured on 54 /// ILCE-7RM6 photos, evaluated in f64 for each file's tables, decoded active area (the padded lossless tiles) and -/// default crop. Both files carry distortion correction off in camera; the table is applied anyway. +/// default crop. All four files carry distortion correction off in camera; the table is applied anyway. #[test] fn corpus_sony_lens_tables_become_opcodes() { let dir = corpus_root().join("raw"); - // (file, active area, default crop x, y, width, height, opcode centre, R, G, B planes k0..k3) + // (file, active area, default crop x, y, width, height, opcode centre, G-plane k0..k3) let cases = [ - // not pinned in `cargo xtask corpus` (at most two pins per PR; #625 pins two Compressed ARW 6.0 files): checked - // only where the file is present (RawDB `ILCE-7RM6_FF_land_RAW.ARW`, CC0) + // not pinned in `cargo xtask corpus` (at most two pins per PR; the ARW 6.0 decoder pins the two Compressed files + // below): checked only where the file is present (RawDB `ILCE-7RM6_FF_land_RAW.ARW`, CC0) ( "arw-sony-ilce7rm6-ff-land-lossless.arw", (10240, 7168), (12, 8, 9984, 6656), [0.488670768629749, 0.4653969582810102], - [ - [0.971770511193381, 0.023073568897462152, 0.00879158563716919, 0.0030468782543633132], - [0.9717033132764651, 0.023249297636569695, 0.008638979168841135, 0.003091041831374855], - [0.9716312798157171, 0.02356676356656341, 0.00830169937204417, 0.003182927930635947], - ], + [0.9717033132764651, 0.023249297636569695, 0.008638979168841135, 0.003091041831374855], ), // pinned in `cargo xtask corpus` (RawDB `ILCE-7RM6_APSC_land_RAW.ARW`, CC0) ( @@ -654,11 +651,24 @@ fn corpus_sony_lens_tables_become_opcodes() { (6656, 4608), (16, 10, 6528, 4352), [0.49278737791134486, 0.4743868026915563], - [ - [1.0165959888027158, -0.0690974265041942, 0.036328960724338874, -0.009486844437228997], - [1.016366854640611, -0.0688824189782517, 0.03615745253291194, -0.009438825010476538], - [1.0162457267369314, -0.0686225599869027, 0.035974238433727206, -0.009384155126102897], - ], + [1.016366854640611, -0.0688824189782517, 0.03615745253291194, -0.009438825010476538], + ), + // pinned by the ARW 6.0 decoder (RawDB `ILCE-7RM6_FF_land_RAW-C.ARW`, CC0): the same scene as the full-frame + // lossless file, its table one step off at knot 12 (268, not 267), on the decoder's unpadded geometry + ( + "arw6-sony-ilce7rm6-ff-land-c.arw", + (10016, 6672), + (12, 8, 9984, 6656), + [0.49960059910134796, 0.5], + [0.9714600903266707, 0.02234210164294959, 0.001142578769532717, 0.005292120046651031], + ), + // pinned by the ARW 6.0 decoder (RawDB `ILCE-7RM6_APSC_land_RAW-C-HD.ARW`, CC0) + ( + "arw6-sony-ilce7rm6-apsc-land-hq.arw", + (6592, 4372), + (16, 10, 6528, 4352), + [0.4975724472765893, 0.5], + [1.01627551, -0.0618606692, 0.02768349621, -0.005595798468], ), ]; let mut seen = 0; @@ -687,12 +697,10 @@ fn corpus_sony_lens_tables_become_opcodes() { }) .collect(); let [(planes, got)] = warps.as_slice() else { panic!("{name}: {:?}", img.opcodes.list3) }; - assert_eq!(planes.len(), 3, "{name}: R, G, B planes"); + assert_eq!(planes.len(), 1, "{name}: one plane (G; lateral CA off by default)"); assert!((got[0] - center[0]).abs() < 1e-6 && (got[1] - center[1]).abs() < 1e-6, "{name}: centre {got:?}, want {center:?}"); - for (plane, (p, k)) in ["R", "G", "B"].iter().zip(planes.iter().zip(k)) { - for (i, want) in k.iter().enumerate() { - assert!((p[i] - want).abs() < 1e-6, "{name}: {plane} k{i} {}, want {want}", p[i]); - } + for (i, want) in k.iter().enumerate() { + assert!((planes[0][i] - want).abs() < 1e-6, "{name}: k{i} {}, want {want}", planes[0][i]); } seen += 1; } diff --git a/docs/gaps.md b/docs/gaps.md index 2a80452ac..4812de920 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 in-file distortion on two bodies (ILCE-7RM4A; ILCE-7RM6, also lateral CA), each with its own conventions; other Sony bodies' tables not applied until verified | 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 in-file distortion on two bodies (ILCE-7RM4A, ILCE-7RM6), each with its own conventions; the ILCE-7RM6's lateral CA table is parsed but off by default (it overshoots after demosaic); other Sony bodies' tables not applied until verified | 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**: 236 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12, 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 / 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) | | 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 236 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) | @@ -89,7 +89,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-10 | minor | Compressed ORF entry links the two Olympus patents that describe the method | | 2026-10-11 | minor | Gap 14: `crs:HDREditMode` is read from XMP (#663); the headroom limit and SDR rendition still aren't | | 2026-10-11 | minor | Gaps 2 and 10 shrink: EOS R5 Mark II CR3 decodes and is verified (fourth CR3 body, Canon 12 verified, 235 models, ~42% on a verified body); CR3 gain +6.5 → +6.2 points | -| 2026-10-10 | minor | Gap 8: Sony ILCE-7RM6 in-file distortion and lateral CA added next to the ILCE-7RM4A's (docs/sony-ilce7rm6-lens-corrections.md); the gap stays open (no lens database), hours unchanged | +| 2026-10-10 | minor | Gap 8: Sony ILCE-7RM6 in-file distortion added next to the ILCE-7RM4A's, its lateral CA parsed but off by default (docs/sony-ilce7rm6-lens-corrections.md); the gap stays open (no lens database), hours unchanged | | 2026-10-10 | minor | RAW gaps re-measured model by model (raw-parity.md): CR3, compressed ORF and verification entries carry measured shares; HE NEF no longer a beta blocker (+1.1 points for 20–40 h); verification entry absorbs the cheap per-body fixes (#769, #702, A200 series) | | 2026-10-10 | minor | Beta total raised to 340–600 h to include the ~92 open core-path user issues | | 2026-10-10 | major | Created from the checklist's Top gaps (whose items 1–2 had been duplicated by a merge) and ROADMAP's Where we stand; re-ranked; added evidence, impact, hours and beta flags; added platform-CI, lens-database, HE NEF and localization gaps | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index b511764da..85b1039fc 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -36,7 +36,7 @@ cited here still exists. Formerly docs/parity.md. | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | -| K. Masking (MASK) | 13 | 5 | 5 | 0 | 7/8 (88%) | 5/5 (100%) | +| K. Masking (MASK) | 14 | 5 | 5 | 0 | 7/8 (88%) | 6/6 (100%) | | L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | | M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | | Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 408 | 52 | 72 | 35 | 193/201 (96%) | 145/157 (92%) | +| **Total** | 409 | 52 | 72 | 35 | 193/201 (96%) | 146/158 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 532 in-scope rows — P0 98.0% of 201 · P1 95.2% of 157 · P2 50.0% of 173. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 533 in-scope rows — P0 98.0% of 201 · P1 95.3% of 158 · P2 50.0% of 173. ## Top gaps and shortcut notes @@ -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/raw/src/vendor/arw_lens.rs`, `crates/raw/src/vendor/arw_lens_rm6.rs`, `crates/engine/src/crs.rs`, `docs/sony-lens-corrections.md`, `docs/sony-ilce7rm6-lens-corrections.md` | 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); Sony ARW raw-IFD lens tables use the same warp path on two bodies, each with the conventions it was validated with, selected by camera model: the **ILCE-7RM4A**'s distortion table (`0x7037`, Bayer, 16-sample layout, native 3:2 framing; validated against Sony Imaging Edge On/Off exports with the FE 24–105 and FE 200–600 lenses, `docs/sony-lens-corrections.md`) and the **ILCE-7RM6**'s distortion and lateral CA tables (`0x7037`, `0x7035`) as one three-plane `WarpRectilinear` (measured against the camera JPEG, `docs/sony-ilce7rm6-lens-corrections.md`; on the 33 measurable of 41 photos shot with in-camera correction on, three lenses (a fourth, the 16–35 mm, only as a control with correction off), the corrected render lines up with the camera JPEG within 0.679 px at 1440 px at the corners and edge midpoints, from up to 17.3 px uncorrected); the two conventions differ in unit and table index, the ILCE-7RM4A's misses the ILCE-7RM6's 24–70 GM II by up to 1.43 px, and no single unit fits every lens under either (`docs/sony-ilce7rm6-lens-corrections.md` → Two conventions); the ILCE-7RM6's CA unit is provisional and, applied after demosaic, raises the render's corner CA (rms 0.032 → 0.052 px, within the 0.3 px budget); its vignetting table is read but not applied (the camera's JPEGs don't apply it; no model fits); other Sony bodies' tables are not applied until verified; ILCE-7RM6 APS-C distortion and in-camera aspect-ratio crops unverified; on by default for new imports with embedded lens data; an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus raws not read yet; 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/raw/src/vendor/arw_lens.rs`, `crates/raw/src/vendor/arw_lens_rm6.rs`, `crates/engine/src/crs.rs`, `docs/sony-lens-corrections.md`, `docs/sony-ilce7rm6-lens-corrections.md` | 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); Sony ARW raw-IFD lens tables use the same warp path on two bodies, each with the conventions it was validated with, selected by camera model: the **ILCE-7RM4A**'s distortion table (`0x7037`, Bayer, 16-sample layout, native 3:2 framing; validated against Sony Imaging Edge On/Off exports with the FE 24–105 and FE 200–600 lenses, `docs/sony-lens-corrections.md`) and the **ILCE-7RM6**'s distortion table as a one-plane `WarpRectilinear` (measured against the camera JPEG, `docs/sony-ilce7rm6-lens-corrections.md`; on the 33 measurable of 41 photos shot with in-camera correction on, three lenses (a fourth, the 16–35 mm, only as a control with correction off), the corrected render lines up with the camera JPEG within 0.679 px at 1440 px at the corners and edge midpoints, from up to 17.3 px uncorrected); the two conventions differ in unit and table index, the ILCE-7RM4A's misses the ILCE-7RM6's 24–70 GM II by up to 1.43 px, and no single unit fits every lens under either (`docs/sony-ilce7rm6-lens-corrections.md` → Two conventions); the ILCE-7RM6's lateral CA table (`0x7035`) is parsed and converts to red and blue planes but is off by default: applied after demosaic it raised the render's corner CA (rms 0.032 → 0.052 px, max 0.125 → 0.219 px), because our demosaic already hides about 70 % of the lens CA; correcting it before demosaic is the follow-up, and its unit is provisional; its vignetting table is read but not applied (the camera's JPEGs don't apply it; no model fits); other Sony bodies' tables are not applied until verified; ILCE-7RM6 APS-C distortion and in-camera aspect-ratio crops unverified; on by default for new imports with embedded lens data; an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus raws not read yet; 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` | | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index d07753b6e..614294086 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -220,7 +220,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, checked against Sony's exports) and the ILCE-7RM6 (three lenses, a fourth, the 16–35 mm, only as a control with correction off; checked against the camera JPEG), with different conventions ([sony-ilce7rm6-lens-corrections.md](sony-ilce7rm6-lens-corrections.md#two-conventions)); the ILCE-7RM6's raw-IFD lateral CA table too (provisional unit, applied after demosaic), not its raw-IFD vignetting table | +| 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, checked against Sony's exports) and the ILCE-7RM6 (three lenses, a fourth, the 16–35 mm, only as a control with correction off; checked against the camera JPEG), with different conventions ([sony-ilce7rm6-lens-corrections.md](sony-ilce7rm6-lens-corrections.md#two-conventions)); the ILCE-7RM6's raw-IFD lateral CA table is parsed but off by default (it overshoots after demosaic), its raw-IFD vignetting table not applied | | Vignetting / lateral CA from maker notes | ✅ | ⬜ | | Manual distortion, vignetting, CA, defringe | ✅ | ✅ | @@ -282,6 +282,6 @@ Beyond decoding: | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | | 2026-10-12 | minor | PEF without area tags: masked border rows and columns trimmed against the BlackPoint (issue #1271; KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z); counts unchanged | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | -| 2026-10-10 (later) | minor | Lens profiles: Sony ILCE-7RM6 in-file distortion and lateral CA next to the ILCE-7RM4A's distortion; lens score unchanged (~10%: one more body) | +| 2026-10-10 (later) | minor | Lens profiles: Sony ILCE-7RM6 in-file distortion next to the ILCE-7RM4A's (its lateral CA parsed, off by default); lens score unchanged (~10%: one more body) | | 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | | 2026-10-10 | major | Created: formats, makers, demosaic, colour and lens checklists; target counts from the installed Lightroom 9.6 bundle listing | diff --git a/docs/sony-ilce7rm6-lens-corrections.md b/docs/sony-ilce7rm6-lens-corrections.md index 64963fcac..d59723ac4 100644 --- a/docs/sony-ilce7rm6-lens-corrections.md +++ b/docs/sony-ilce7rm6-lens-corrections.md @@ -1,17 +1,18 @@ # Sony ARW lens corrections Sony cameras record three lens-correction tables in the raw IFD of every ARW: distortion, vignetting and lateral -chromatic aberration (CA). LightCraft reads them and turns distortion and lateral CA into a DNG `WarpRectilinear` -opcode, so they reach the same **Optics ▸ Enable Lens Corrections** (`optics.lens_profile`) as a DNG's embedded warp -and Panasonic's RW2 distortion data. Vignetting is read but not applied (below). The conversion is verified on the -**ILCE-7RM6** only; the ILCE-7RM4A has its own conversion, with other conventions ([Two conventions](#two-conventions)), -and other Sony bodies' tables give no correction until they are verified. +chromatic aberration (CA). LightCraft reads them and turns distortion into a DNG `WarpRectilinear` opcode, so it +reaches the same **Optics ▸ Enable Lens Corrections** (`optics.lens_profile`) as a DNG's embedded warp and Panasonic's +RW2 distortion data. Lateral CA is parsed and converts to the warp's red and blue planes, but it is **off by default**: +applied after demosaic it made the render's CA worse (Verification). Vignetting is read but not applied (below). The +conversion is verified on the **ILCE-7RM6** only; the ILCE-7RM4A has its own conversion, with other conventions +([Two conventions](#two-conventions)), and other Sony bodies' tables give no correction until they are verified. The correction is on by default for new imports, as for any file with embedded lens data (`Photo::camera_defaults`). Photos imported before this change keep their settings: **Photo → Reload from Disk** adds the lens data and, when the photo is still at its import settings, turns the correction on as one undoable step; the automatic reload after an external editor saves the file does the same. Edited photos keep their settings. The Profile Distortion slider -scales the warp (all three colour planes) as for DNG and RW2. +scales the warp as for DNG and RW2. ## The tables @@ -59,7 +60,8 @@ None of this is documented; it was measured against the camera's own JPEGs (meth best units scatter from 1.5·10⁶ to 7.2·10⁶ (about ±30 % scene bias: the RawDB landscape and portrait shots of one scene, with nearly identical tables, measure 3.4–3.5·10⁶ and 4.5·10⁶). The chosen 3.36·10⁶ is the median of the 20 cleanest photos: worst 0.399 px on the 15, and the rms raw CA over all 46 measured photos falls from 0.079 to - 0.052 px. Swapping the halves or an absolute form (`v · norm / U`) is clearly worse. + 0.052 px. Swapping the halves or an absolute form (`v · norm / U`) is clearly worse. **Not applied by default** + (Verification). - **Vignetting: not applied.** The camera JPEGs show no vignetting compensation relative to our uncorrected render: over the 39 photos whose measurement reaches the corners, the corner/centre ratio JPEG / render has median 0.996 (p10 0.907, p90 1.087), while the tables would predict a corner gain with median +32 % for `1 + v / 2¹⁴` (+16 % @@ -77,7 +79,7 @@ None of this is documented; it was measured against the camera's own JPEGs (meth (DNG pixel-index units relative to the active area; radius 1 = the farthest active-area corner, `m`): 1. `S` from the distortion table and the zoom `z` (bisection on [0.5, 2], 4097 border radii); the green map is - `G(r) = S(r / z)`. Red and blue: `G(r) · (1 + c(G(r)) / 3.36·10⁶)`. + `G(r) = S(r / z)`. Red and blue, when the CA planes are on: `G(r) · (1 + c(G(r)) / 3.36·10⁶)`. 2. Each map is sampled at `u = i / 64`, i = 0…64 (65 samples of `G(u · m) / m`) and fitted by least squares with DNG's 4-term odd polynomial `u · (k0 + k1 u² + k2 u⁴ + k3 u⁶)` (`vendor::fit_radial_warp`, shared with RW2). 3. **Fit gate:** a plane is refused when its fit is worse than 0.25 px at a 1440 px long edge at a sample the crop @@ -86,11 +88,13 @@ None of this is documented; it was measured against the camera's own JPEGs (meth Over all 54 photos the fit's worst error at all 65 samples was 0.220 px (a lossless file; ARW6 files at most 0.175 px). Between samples the error is somewhat larger: 0.071 against 0.049 px for the full-frame 16–35 mm example in the tests. -4. Planes `[R, G, B]`; when the CA table is absent or malformed, a CA value is implausible (|c| above 2·10⁻³ of the - radius, i.e. 6720; the largest value in the 54 photos' tables is 1724) or a colour plane fails the gate, G alone is applied to all - three channels. No opcode when the distortion table is absent or all zero (CA alone is never applied: it is - relative to green), the zoom has no solution in [0.5, 2] or the G fit fails. A refused table leaves the photo - uncorrected rather than wrongly warped. +4. **By default one plane, G**, applied to all three channels: `lens_opcodes` calls `warp_opcode(…, lateral_ca: + false)`. With `lateral_ca: true` (a code change) the planes are `[R, G, B]`, unless the CA table is absent or + malformed, a CA value is implausible (|c| above 2·10⁻³ of the radius, i.e. 6720; the largest value in the 54 + photos' tables is 1724) or a colour plane fails the gate: then G alone again. The G plane is the same either way. + No opcode when the distortion table is absent or all zero (CA alone is never applied: it is relative to green), + the zoom has no solution in [0.5, 2] or the G fit fails. A refused table leaves the photo uncorrected rather than + wrongly warped. The opcode then goes through the same pipeline stage as a DNG's (`crates/pipeline/src/optics.rs`): it rotates with the photo (portrait files are covered) and is carried by DNG export, as RW2's is. @@ -120,7 +124,7 @@ FE 200–600 mm; corrected TIFFs at 24 mm with the FE 24–105 mm). Its conventi | Unit | 15000 | 16384 | | Zoom | fill: the largest `S(ρ / z) / ρ` over the border is 1 | `1 / max f` over the border radii | | Aspect crops | applied; the zoom for the recorded crop is unverified | refused (native 3:2 only) | -| Tables | distortion (16 knots; 11 in APS-C) and lateral CA; vignetting read, not applied | distortion, 16 entries only | +| Tables | distortion (16 knots; 11 in APS-C); lateral CA parsed, off by default; vignetting read, not applied | distortion, 16 entries only | `arw::decode` passes both the same active area and crop (the DNG-style default crop, on the ILCE-7RM6 the same as Sony's crop tags); their model lists don't overlap, so a photo gets at most one of them: an ILCE-7RM6 this page's @@ -167,9 +171,10 @@ conventions it was validated with. Photos: 54 ILCE-7RM6 ARWs: 42 private, and the 12 CC0 samples of the [dnglab RawDB ILCE-7RM6 set](https://rawdb.dnglab.org/sets/Sony/ILCE-7RM6) (`ILCE-7RM6_{FF,APSC}_{land,port}_RAW{,-C,-C-HD}.ARW`: full frame and APS-C, landscape and portrait, lossless, Compressed and Compressed HQ). `cargo xtask corpus --download` -fetches one of them, the APS-C lossless file, as `arw-sony-ilce7rm6-apsc-land-lossless.arw`, for the corpus test -`corpus_sony_lens_tables_become_opcodes` (its full-frame lossless case runs only where that file is present); the ARW -6.0 decoder (#625) pins one Compressed and one Compressed HQ file. +fetches three of them: the APS-C lossless file as `arw-sony-ilce7rm6-apsc-land-lossless.arw`, and the ARW 6.0 +decoder's full-frame Compressed and APS-C Compressed HQ files (`arw6-sony-ilce7rm6-ff-land-c.arw`, +`arw6-sony-ilce7rm6-apsc-land-hq.arw`). The corpus test `corpus_sony_lens_tables_become_opcodes` checks all three, and +the full-frame lossless file where it is present. Four lenses: FE 24–70 mm F2.8 GM II (27 photos), FE 16–35 mm F2.8 GM II (12, all RawDB), FE 100 mm F2.8 Macro GM OSS (9), FE 200–600 mm F5.6–6.3 G OSS (6). 41 photos have in-camera distortion correction on (all private); 13 have it off (the 12 RawDB files and one private photo). Only aggregate numbers per lens are reported. The scripts are local @@ -250,10 +255,18 @@ warp after demosaic moves R and B by 0.84 × the table (correlation 0.96). On th 0.062 → 0.035 px); applied after a demosaic that already hid most of it, it overshoots (rms 0.021 → 0.035 px over the same bins). All of this is far below one pixel at 1440 px. +**Ruling: the lateral CA planes are off by default.** `lens_opcodes` emits the G plane alone; the conversion stays in +the code and its unit tests (`warp_opcode(…, lateral_ca: true)`), so it can be turned on once CA is corrected where +the table applies. **Follow-up:** warp the raw CFA's red and blue sites before demosaic (the table halves the raw CA, +rms 0.062 → 0.035 px), then turn the planes on and repeat this check. Optics ▸ Remove Chromatic Aberration +(`optics.remove_ca`, an automatic estimate from the image) remains available for visible fringes. The distortion +numbers above were measured with the three-plane warp, whose G plane is the one the default emits. + **Fit gate margins** (worst of R, G and B at the gated samples; gate 0.25 px): the 4 lossless files 0.160 px (margin 0.090; 0.220 px at all 65 samples, the padded radii never shown), the 46 full-frame ARW6 files 0.175 px (margin 0.075), the 4 APS-C ARW6 files 0.035 px. The largest CA value in the 54 tables is 1724 (FE 24–70 mm), a quarter of the 6720 -bound. So every photo gets the three-plane warp: the corrected and uncorrected renders differ on all 54. +bound. So every photo passes: with the CA planes on (the verification renders) each got the three-plane warp, and the +corrected and uncorrected renders differ on all 54; by default each gets the one-plane distortion warp. ## Performance @@ -261,15 +274,16 @@ bound. So every photo gets the three-plane warp: the corrected and uncorrected r `arw-sony-ilce7rm6-ff-land-lossless.arw`, `arw6-sony-ilce7rm6-ff-land-hq.arw`), on a 4-core laptop shared with other builds (so absolute times are high); the fastest of 3–5 runs. The warp runs in the `sample` stage: -| Render | Off | Single-plane warp (manual Distortion 1) | On (three planes) | +| Render | Off | One plane (the default; measured with manual Distortion 1) | Three planes (CA planes on) | |---|---|---|---| | Full size 9984 × 6656, lossless | 267 ms | — | 3098 ms | | Full size 9984 × 6656, Compressed HQ | 403 ms | 1187 ms | 4014 ms | | 1920 px long edge (2496 × 1664 source), Compressed HQ | 23.7 ms | 45.4 ms | 112.0 ms | -The three colour planes cost about 2.5–3× a single-plane warp, as for an RW2 or DNG with three planes (the same -path). The stage cache keys the resampled source by the source, framing and output size, so tone, colour and -exposure drags don't re-run it. +The default one-plane warp takes the sample stage from 24 to 45 ms at 1920 px (inferred from the manual Distortion +run: the same single-plane path, one plane for all three channels). The three colour planes cost about 2.5–3× that, +as for an RW2 or DNG with three planes (the same path): the price of turning the CA planes on. The stage cache keys +the resampled source by the source, framing and output size, so tone, colour and exposure drags don't re-run it. ## Limits @@ -283,10 +297,9 @@ exposure drags don't re-run it. 15659 (200–600 mm, 3 photos) and 16017 (100 mm, tiny tables); with the single 15000 the 200–600 mm keeps a systematic residual of up to 0.59 px (within budget, above the method's 0.03 px noise floor). Something lens-dependent is not captured; per-lens units would need per-lens verification. -- **Lateral CA unit provisional** (above), and the CA warp runs after demosaic, as for RW2 and DNG. Our demosaic - already hides about 70 % of the lens CA, so the full table overshoots in the render: within budget, but the rms - corner CA rises from 0.032 to 0.052 px (Verification). Correcting CA before demosaic would be the principled fix; - scaling the colour planes down or dropping them are the cheap alternatives. +- **Lateral CA off by default.** Its unit is provisional (above), and a warp after demosaic, as for RW2 and DNG, + overshoots: our demosaic already hides about 70 % of the lens CA, and with the planes on the rms corner CA rose + from 0.032 to 0.052 px (Verification). Correcting CA before demosaic is the follow-up. - **Vignetting not applied** (above). - **DNG export** carries the warp in `OpcodeList3`, as for RW2: a DNG reader applies it like any embedded lens correction. diff --git a/docs/whats-new.md b/docs/whats-new.md index 57e4e1c52..b1efd9e3a 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -103,16 +103,16 @@ magnified preview isn't taken for the photo when checking focus at 1:1. ### Lens corrections -- Sony A7R VI (ILCE-7RM6) raws are corrected for lens distortion and lateral chromatic aberration from the tables the - camera records in each file, under Optics ▸ Enable Lens Corrections, on by default for newly imported photos. On 33 - photos shot with the camera's correction on (24–70 mm, 100 mm macro, 200–600 mm) the render lines up with the - camera's JPEG within 0.68 px at 1440 px at the corners, where it was up to 17 px off before. The camera's vignetting - table is read but not applied: its own JPEGs don't apply it either. This reaches lossless ARWs now; Compressed and - Compressed HQ ARWs (ARW 6.0) get it once their decoder (#625) lands, and until then open from their embedded JPEG. - The A7R IVA (ILCE-7RM4A) has its own distortion correction, checked against Sony's exports; other Sony bodies record - the same tables, but they stay uncorrected until they are checked the same way. Photos imported earlier get the - correction with Photo ▸ Reload from Disk (when still at their import settings). See - `docs/sony-ilce7rm6-lens-corrections.md`. +- Sony A7R VI (ILCE-7RM6) raws are corrected for lens distortion from the table the camera records in each file, + under Optics ▸ Enable Lens Corrections, on by default for newly imported photos. On 33 photos shot with the camera's + correction on (24–70 mm, 100 mm macro, 200–600 mm) the render lines up with the camera's JPEG within 0.68 px at + 1440 px at the corners, where it was up to 17 px off before. The camera's lateral chromatic aberration table is read + too, but not applied by default: our demosaic already hides most of that colour fringing, and the table on top made + it slightly worse. The vignetting table is read but not applied: the camera's own JPEGs don't apply it either. This + covers lossless, Compressed and Compressed HQ (ARW 6.0) ARWs alike. The A7R IVA (ILCE-7RM4A) has its own distortion + correction, checked against Sony's exports; other Sony bodies record the same tables, but they stay uncorrected until + they are checked the same way. Photos imported earlier get the correction with Photo ▸ Reload from Disk (when still + at their import settings). See `docs/sony-ilce7rm6-lens-corrections.md`. ### JPEG XL export - Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 28fb3a38b..6c60526ad 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -449,7 +449,7 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "ac7c1532df77c321e8010aa1be60c1b9f245b1ac0db6f38fe617a73aae81af50", ), // Sony ILCE-7RM6 lossless, CC0 1.0 (RawDB): the APS-C file, for its lens tables (`vendor::arw_lens_rm6`). The - // full-frame lossless file is not pinned (at most two pins per PR; #625 pins two Compressed ARW 6.0 files). + // full-frame lossless file is not pinned (at most two pins per PR); the two ARW6 pins below cover Compressed files. ( "arw-sony-ilce7rm6-apsc-land-lossless.arw", "https://rawdb.dnglab.org/api/download/Sony/ILCE-7RM6/raw_modes/ILCE-7RM6_APSC_land_RAW.ARW",