From 853a92e2de1bc8657d252964e85a7d5a3d5839e2 Mon Sep 17 00:00:00 2001 From: Michael Anderson Date: Tue, 13 Oct 2026 09:29:23 -0500 Subject: [PATCH] LR-EDIT-OPTICS-PROFILE: Apply Nikon's in-file distortion correction for the Z 6II and Z f Read the polynomial, zoom and optical center offset from NikonNEFInfo. --- README.md | 2 +- crates/raw/src/vendor/mod.rs | 1 + crates/raw/src/vendor/nef.rs | 52 +++++- crates/raw/src/vendor/nef_lens.rs | 258 ++++++++++++++++++++++++++++++ crates/raw/tests/corpus.rs | 35 ++++ docs/gaps.md | 5 +- docs/lens-profiles.md | 2 +- docs/nikon-lens-corrections.md | 95 +++++++++++ docs/parity-checklist.md | 5 +- docs/raw-parity.md | 7 +- docs/whats-new.md | 6 + 11 files changed, 457 insertions(+), 11 deletions(-) create mode 100644 crates/raw/src/vendor/nef_lens.rs create mode 100644 docs/nikon-lens-corrections.md diff --git a/README.md b/README.md index 2aea14d87..1655272c5 100644 --- a/README.md +++ b/README.md @@ -337,7 +337,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det | Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ | | MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ | | XMP sidecars (read/write, auto-write), reading `crs:` develop settings, preset files (`.lcpreset`, XMP presets) | ✅ | -| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files and Panasonic / Leica RW2 / RWL distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ | +| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files, Panasonic / Leica RW2 / RWL distortion data and Nikon Z 6II / Z f distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ | | Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ | | GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 | | AI: segmentation masks, AI denoise, super resolution, faces; video | 🚧 (see [gaps](docs/gaps.md)) | diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index 0d59fd6ed..52b3cf541 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -12,6 +12,7 @@ pub mod crx; pub(crate) mod crx_wavelet; pub mod kodak; pub mod nef; +mod nef_lens; pub mod nefc; pub mod orf; pub mod pef; diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 695d55712..7d5b24a4f 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -177,7 +177,8 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, _ => Cfa::bayer_static("RGGB"), }; - let body = measured_body(&ifd0.string(t::MODEL).unwrap_or_default(), w, h, bits); + let model = ifd0.string(t::MODEL).unwrap_or_default(); + let body = measured_body(&model, w, h, bits); let active_w = trailing_masked_columns(samples, w, bits); // the white level is measured from the whole image (or known for a measured body); the probe (no samples in its // result) takes the nominal one @@ -212,7 +213,11 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, - opcodes: OpcodeLists::default(), + // Nikon's in-file distortion correction, about the optical centre (which follows the sensor shift) + opcodes: OpcodeLists { + list3: super::nef_lens::distortion(&model, raw, Rect::new(0, 0, active_w, h), crop).into_iter().collect(), + ..Default::default() + }, metadata, }; img.validate_for(mode)?; @@ -316,6 +321,49 @@ mod tests { TiffWriter::new(order, false).write(&[ifd0]).unwrap() } + /// A Z 6II NEF carrying NikonNEFInfo gets Nikon's distortion correction as one warp, in the header probe and + /// the full decode alike, and keeps it through DNG export; another body with the same data gets none. + #[test] + fn nikon_in_file_distortion_becomes_a_warp() { + use super::super::nef_lens::tests::{distortion_block, nef_info_blob, offset_block}; + let (w, h) = (48u32, 32u32); + let file = |model: &str| { + let words: Vec = (0..w * h).flat_map(|i| (600 + (i % 97) as u16 * 50).to_be_bytes()).collect(); + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![14])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + let info = nef_info_blob(&[ + (0x0005, distortion_block(3, 21.6, 4, [0.039895, 0.019211, -0.127218], -0.025043)), + (0x0008, offset_block(1.5, -0.75)), + ]); + raw.set(0xc7d5, Value::Undefined(info)); + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![words] }); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("NIKON CORPORATION".into())); + ifd0.set(t::MODEL, Value::Ascii(model.into())); + ifd0.add_sub_ifd(raw); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() + }; + let bytes = file("NIKON Z 6_2"); + let header = decode(&bytes, Mode::Header).unwrap(); + let full = decode(&bytes, Mode::Full).unwrap(); + assert_eq!(header.info(), full.info()); + assert_eq!(header.opcodes.list3, full.opcodes.list3); + let [crate::Opcode::WarpRectilinear { planes, center }] = full.opcodes.list3.as_slice() else { panic!("{:?}", full.opcodes.list3) }; + assert!((planes[0][0] - 1.0 / (1.0 - 0.025043)).abs() < 1e-5, "{planes:?}"); + assert!((center[0] * 47.0 - (23.5 + 1.5)).abs() < 0.01 && (center[1] * 31.0 - (15.5 - 0.75)).abs() < 0.01, "{center:?}"); + let dng = crate::write_dng(&full, &Default::default()).unwrap(); + assert_eq!(crate::decode(&dng).unwrap().opcodes.list3, full.opcodes.list3); + full.develop(crate::Method::Bilinear).unwrap(); + assert!(decode(&file("NIKON D850"), Mode::Full).unwrap().opcodes.list3.is_empty()); + } + #[test] fn preview_color_space_is_read_from_the_nikon_note() { for (value, expected) in [(1, Some(crate::PreviewColorSpace::Srgb)), (2, Some(crate::PreviewColorSpace::AdobeRgb)), (3, None)] { diff --git a/crates/raw/src/vendor/nef_lens.rs b/crates/raw/src/vendor/nef_lens.rs new file mode 100644 index 000000000..2825acdea --- /dev/null +++ b/crates/raw/src/vendor/nef_lens.rs @@ -0,0 +1,258 @@ +//! Nikon's in-file distortion correction (raw SubIFD tag `0xc7d5`, NikonNEFInfo), expressed as a DNG warp for +//! the shared optics pipeline. +//! +//! Tag identity: the ExifTool Nikon tag-name documentation (NikonNEFInfo `0xc7d5`; its DistortionInfo `0x0005` +//! with DistortionCorrection and RadialDistortionCoefficient1..3). The model below (which stored value multiplies +//! which power, the normalisation, the fill-frame zoom and the optical-centre offset in entry `0x0008`) was inferred +//! independently by matching the raw against the camera's own corrected JPEG; see docs/nikon-lens-corrections.md. +//! No lens database or external decoder code. + +use crate::{Opcode, Rect}; +use lightcraft_tiff::{ByteOrder, Ifd, ParseOptions, parse_ifd_at}; + +const NEF_INFO: u16 = 0xc7d5; +const DISTORTION_INFO: u16 = 0x0005; +const CENTRE_OFFSET: u16 = 0x0008; +/// Bodies whose files were checked against the camera's JPEG (docs/nikon-lens-corrections.md). Matching tags on +/// another body do not establish its pixel pitch or image area; add a body only after checking its files. +const VALIDATED: &[&str] = &["NIKON Z 6_2", "NIKON Z f"]; +/// The normalisation radius every checked file stores: the FX half-diagonal in millimetres. +const FX_HALF_DIAGONAL_MM: f64 = 21.6; +const SAMPLES: usize = 256; + +/// `r_src = r_out * (1 + k3 rho^2 + k2 rho^4 + k1 rho^6) / (1 + zoom)` around the optical centre, `rho` the radius +/// over the half-diagonal of the image area (`k1..k3` as ExifTool names them, in the order they are stored). +#[derive(Clone, Copy, Debug, PartialEq)] +struct Distortion { + k: [f64; 3], + zoom: f64, +} + +/// A signed rational at `at` (`i32 / i32`), when it is one. +fn rational(b: &[u8], at: usize, order: ByteOrder) -> Option { + let n = order.u32(b.get(at..at + 4)?.try_into().ok()?) as i32; + let d = order.u32(b.get(at + 4..at + 8)?.try_into().ok()?) as i32; + (d > 0).then(|| f64::from(n) / f64::from(d)).filter(|v| v.is_finite()) +} + +/// The NikonNEFInfo entries: the blob is `"Nikon\0"`, a 4-byte version, then a little TIFF stream. +fn nef_info(raw: &Ifd) -> Option<(Ifd, ByteOrder)> { + let tiff = raw.bytes(NEF_INFO)?.strip_prefix(b"Nikon\0")?.get(4..)?; + let (order, false) = lightcraft_tiff::Tiff::sniff(tiff)? else { return None }; + let first = order.u32(tiff.get(4..8)?.try_into().ok()?); + let opts = ParseOptions { follow_children: false, ..Default::default() }; + let (ifd, _) = parse_ifd_at(tiff, u64::from(first), order, 0, false, &opts).ok()?; + Some((ifd, order)) +} + +/// DistortionInfo: `"0100"`, the correction mode (0 none), the normalisation radius (mm) at 8, the term count (4) +/// at 16, the coefficients at 20 / 28 / 36 and the zoom at 64, each a signed rational. +fn distortion_info(b: &[u8], order: ByteOrder) -> Option { + if b.get(0..4)? != b"0100" || *b.get(4)? == 0 { + return None; + } + let norm = rational(b, 8, order)?; + let terms = order.u32(b.get(16..20)?.try_into().ok()?); + if (norm - FX_HALF_DIAGONAL_MM).abs() > 0.05 || terms != 4 { + return None; + } + let k = [rational(b, 20, order)?, rational(b, 28, order)?, rational(b, 36, order)?]; + let zoom = rational(b, 64, order)?; + // real lenses stay far inside these (the checked files: |k| < 0.13, |zoom| < 0.09) + (k.iter().all(|v| v.abs() < 1.0) && zoom.abs() < 0.5).then_some(Distortion { k, zoom }) +} + +/// Entry `0x0008`: `"0100"`, then the optical centre's offset from the image-area centre in pixels (x right, +/// y down), two signed rationals. Zero when the lens stabilises (the sensor stays centred). +fn centre_offset(b: &[u8], order: ByteOrder) -> Option<(f64, f64)> { + (b.get(0..4)? == b"0100").then_some(())?; + Some((rational(b, 4, order)?, rational(b, 12, order)?)) +} + +/// The correction of a `model` NEF whose raw IFD is `raw`, for the image area `crop` inside `active` (both in +/// sensor pixels), as an `OpcodeList3` warp in active-area coordinates. `None` for unvalidated bodies, other +/// image areas, missing or implausible data, and maps that fold. +pub(super) fn distortion(model: &str, raw: &Ifd, active: Rect, crop: Rect) -> Option { + if !VALIDATED.contains(&model.trim()) { + return None; + } + let crop = crop.clipped(active.width, active.height); + if active.width < 2 || active.height < 2 || crop.width < 2 || crop.height < 2 { + return None; + } + // the stored radius is the FX half-diagonal: only the full 3:2 image area (not a DX or other crop of the + // sensor) is normalised by it + if (crop.width as f64 / crop.height as f64 - 1.5).abs() > 0.005 || (crop.width as f64) < active.width as f64 * 0.95 { + return None; + } + let (info, order) = nef_info(raw)?; + let d = distortion_info(info.bytes(DISTORTION_INFO)?, order)?; + let (ox, oy) = info.bytes(CENTRE_OFFSET).and_then(|b| centre_offset(b, order)).unwrap_or((0.0, 0.0)); + let (cw, ch) = (crop.width as f64 - 1.0, crop.height as f64 - 1.0); + if ox.abs() > cw * 0.05 || oy.abs() > ch * 0.05 { + return None; + } + let half_diagonal = cw.hypot(ch) / 2.0; + let poly = |rho: f64| { + let x = rho * rho; + 1.0 + x * (d.k[2] + x * (d.k[1] + x * d.k[0])) + }; + // DNG normalises by the farthest active-area corner from the centre; the image area reaches at most there + let (ax, ay) = (active.width as f64 - 1.0, active.height as f64 - 1.0); + let (cx, cy) = (crop.x as f64 + cw / 2.0 + ox, crop.y as f64 + ch / 2.0 + oy); + let farthest = cx.max(ax - cx).hypot(cy.max(ay - cy)); + let a = farthest / half_diagonal; + // the source radius must grow with the output radius out to the farthest corner, or the map folds + let mut previous = -1.0; + for j in 0..=SAMPLES { + let rho = a * j as f64 / SAMPLES as f64; + let source = rho * poly(rho) / (1.0 + d.zoom); + if !source.is_finite() || source <= previous { + return None; + } + previous = source; + } + let s = 1.0 / (1.0 + d.zoom); + let a2 = a * a; + let planes = vec![[s, d.k[2] * a2 * s, d.k[1] * a2 * a2 * s, d.k[0] * a2 * a2 * a2 * s, 0.0, 0.0]]; + Some(Opcode::WarpRectilinear { planes, center: [cx / ax, cy / ay] }) +} + +#[cfg(test)] +pub(in crate::vendor) mod tests { + use super::*; + use lightcraft_tiff::{Entry, IfdBuilder, TiffWriter, Value}; + + const AREA: Rect = Rect { x: 0, y: 0, width: 6064, height: 4040 }; + const CROP: Rect = Rect { x: 8, y: 8, width: 6048, height: 4024 }; + + fn rat(v: f64, den: i32) -> [u8; 8] { + let mut b = [0u8; 8]; + b[..4].copy_from_slice(&((v * f64::from(den)).round() as i32).to_le_bytes()); + b[4..].copy_from_slice(&den.to_le_bytes()); + b + } + + /// DistortionInfo as the checked files lay it out (84 bytes, little-endian). + pub(in crate::vendor) fn distortion_block(mode: u8, norm: f64, terms: u32, k: [f64; 3], zoom: f64) -> Vec { + let mut b = vec![0u8; 84]; + b[..4].copy_from_slice(b"0100"); + b[4] = mode; + b[5] = 1; + b[8..16].copy_from_slice(&rat(norm, 4096)); + b[16..20].copy_from_slice(&terms.to_le_bytes()); + for (i, v) in k.iter().enumerate() { + b[20 + 8 * i..28 + 8 * i].copy_from_slice(&rat(*v, 1 << 20)); + } + b[44..52].copy_from_slice(&rat(0.0, 1)); + b[52..60].copy_from_slice(&rat(0.0, 1)); + b[64..72].copy_from_slice(&rat(zoom, 1 << 20)); + b + } + + pub(in crate::vendor) fn offset_block(x: f64, y: f64) -> Vec { + let mut b = b"0100".to_vec(); + b.extend_from_slice(&rat(x, 256)); + b.extend_from_slice(&rat(y, 256)); + b.extend_from_slice(&[0, 0, 0, 0]); + b + } + + /// A NikonNEFInfo (`0xc7d5`) blob with the given entries. + pub(in crate::vendor) fn nef_info_blob(entries: &[(u16, Vec)]) -> Vec { + let mut info = IfdBuilder::new(); + for (tag, data) in entries { + info.set(*tag, Value::Undefined(data.clone())); + } + let tiff = TiffWriter::new(ByteOrder::Little, false).write(&[info]).unwrap(); + let mut blob = b"Nikon\0\x01\x03\0\0".to_vec(); + blob.extend_from_slice(&tiff); + blob + } + + /// A raw IFD carrying a NikonNEFInfo blob with the given entries. + pub(super) fn raw_with(entries: &[(u16, Vec)]) -> Ifd { + Ifd { entries: vec![Entry { tag: NEF_INFO, value: Value::Undefined(nef_info_blob(entries)), offset: 0 }], ..Default::default() } + } + + // a Z 24-70 f/4 S at 24 mm and at 70 mm, as stored in two of the checked files + const WIDE: ([f64; 3], f64) = ([0.039895, 0.019211, -0.127218], -0.025043); + const TELE: ([f64; 3], f64) = ([0.01681, -0.010601, 0.056758], 0.088301); + + fn warp(model: &str, raw: &Ifd) -> Option<([f64; 6], [f64; 2])> { + match distortion(model, raw, AREA, CROP)? { + Opcode::WarpRectilinear { planes, center } => Some((planes[0], center)), + _ => None, + } + } + + /// The source radius the warp gives for an output radius `rho` (in image-area half-diagonals) about the centre. + fn source_rho(p: [f64; 6], centre: [f64; 2], rho: f64) -> f64 { + let (ax, ay) = (AREA.width as f64 - 1.0, AREA.height as f64 - 1.0); + let (cx, cy) = (centre[0] * ax, centre[1] * ay); + let m = cx.max(ax - cx).hypot(cy.max(ay - cy)); + let hd = (CROP.width as f64 - 1.0).hypot(CROP.height as f64 - 1.0) / 2.0; + let t2 = (rho * hd / m).powi(2); + rho * (p[0] + t2 * (p[1] + t2 * (p[2] + t2 * p[3]))) + } + + #[test] + fn the_warp_is_the_stored_polynomial_over_the_zoom() { + for (k, zoom) in [WIDE, TELE] { + let raw = raw_with(&[(DISTORTION_INFO, distortion_block(3, 21.6, 4, k, zoom))]); + let (p, c) = warp("NIKON Z 6_2", &raw).unwrap(); + for rho in [0.0, 0.25, 0.5, 0.75, 1.0] { + let x = rho * rho; + let want = rho * (1.0 + k[2] * x + k[1] * x * x + k[0] * x * x * x) / (1.0 + zoom); + assert!((source_rho(p, c, rho) - want).abs() < 2e-6, "{k:?} at {rho}"); + } + // centred on the image area, which sits 8 px in from the sensor's top-left + assert!((c[0] - (8.0 + 6047.0 / 2.0) / 6063.0).abs() < 1e-12 && (c[1] - (8.0 + 4023.0 / 2.0) / 4039.0).abs() < 1e-12); + } + } + + #[test] + fn the_optical_centre_follows_the_sensor_shift() { + let raw = raw_with(&[(DISTORTION_INFO, distortion_block(3, 21.6, 4, TELE.0, TELE.1)), (CENTRE_OFFSET, offset_block(-90.1, 18.59))]); + let (_, c) = warp("NIKON Z 6_2", &raw).unwrap(); + assert!((c[0] * 6063.0 - (8.0 + 6047.0 / 2.0 - 90.1)).abs() < 0.01, "{c:?}"); + assert!((c[1] * 4039.0 - (8.0 + 4023.0 / 2.0 + 18.59)).abs() < 0.01, "{c:?}"); + } + + #[test] + fn refuses_unvalidated_bodies_and_unknown_layouts() { + let good = distortion_block(3, 21.6, 4, WIDE.0, WIDE.1); + assert!(warp("NIKON Z 6_2", &raw_with(&[(DISTORTION_INFO, good.clone())])).is_some()); + assert!(warp("NIKON Z f", &raw_with(&[(DISTORTION_INFO, good.clone())])).is_some()); + for model in ["", "NIKON Z 6", "NIKON Z 6_3", "NIKON Z 8", "NIKON D850", "NIKON Z 6_2 x"] { + assert!(warp(model, &raw_with(&[(DISTORTION_INFO, good.clone())])).is_none(), "{model}"); + } + // correction off, a DX radius, another term count, a wild coefficient or zoom, no NEFInfo at all + for bad in [ + distortion_block(0, 21.6, 4, WIDE.0, WIDE.1), + distortion_block(3, 14.2, 4, WIDE.0, WIDE.1), + distortion_block(3, 21.6, 3, WIDE.0, WIDE.1), + distortion_block(3, 21.6, 4, [0.0, 0.0, 1.5], 0.0), + distortion_block(3, 21.6, 4, WIDE.0, 0.9), + b"0200".iter().copied().chain([0u8; 80]).collect(), + vec![0u8; 12], + ] { + assert!(warp("NIKON Z 6_2", &raw_with(&[(DISTORTION_INFO, bad)])).is_none()); + } + assert!(warp("NIKON Z 6_2", &Ifd::default()).is_none()); + // a map that folds (the source radius turns back) is refused + assert!(warp("NIKON Z 6_2", &raw_with(&[(DISTORTION_INFO, distortion_block(3, 21.6, 4, [-0.9, 0.0, 0.0], 0.0))])).is_none()); + } + + #[test] + fn only_the_full_3_2_image_area_is_corrected() { + let raw = raw_with(&[(DISTORTION_INFO, distortion_block(3, 21.6, 4, WIDE.0, WIDE.1))]); + // a DX or 1:1 crop of the sensor would need another normalisation + assert!(distortion("NIKON Z 6_2", &raw, AREA, Rect::new(1016, 676, 4032, 2688)).is_none(), "DX"); + assert!(distortion("NIKON Z 6_2", &raw, AREA, Rect::new(1016, 8, 4024, 4024)).is_none()); + assert!(distortion("NIKON Z 6_2", &raw, AREA, Rect::new(0, 0, 1, 1)).is_none()); + // a sensor-shift offset beyond 5 % of the frame is not trusted + let far = raw_with(&[(DISTORTION_INFO, distortion_block(3, 21.6, 4, WIDE.0, WIDE.1)), (CENTRE_OFFSET, offset_block(900.0, 0.0))]); + assert!(warp("NIKON Z 6_2", &far).is_none()); + } +} diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 5ca4777b7..a96165b5e 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -1018,6 +1018,41 @@ fn sony_embedded_distortion_is_limited_to_validated_models() { eprintln!("Sony distortion model boundary: {checked} corpus files checked"); } +/// Nikon's in-file distortion correction (docs/nikon-lens-corrections.md) reaches only the validated bodies: the +/// Z f sample (a Tamron 35-150 at 150 mm) gets one warp, the DSLR and Coolpix samples none, and the header probe +/// describes every file as its full decode does. +#[test] +fn nikon_in_file_distortion_is_limited_to_validated_bodies() { + let root = corpus_root().join("raw"); + let mut checked = 0; + for (name, model, expected) in [ + ("nef-nikon-zf-lossless.nef", "NIKON Z f", 1), + ("nef-nikon-d5100-lossless.nef", "NIKON D5100", 0), + ("nef-nikon-d5100-uncompressed.nef", "NIKON D5100", 0), + ("nef-nikon-d7000-lossy12.nef", "NIKON D7000", 0), + ("nef-nikon-d7500-lossless12.nef", "NIKON D7500", 0), + ("nef-nikon-d7500-lossless14.nef", "NIKON D7500", 0), + ("nrw-nikon-b700-uncompressed.nrw", "COOLPIX B700", 0), + ] { + let path = root.join(name); + if !path.exists() { + eprintln!("skip: {} absent", path.display()); + continue; + } + let bytes = std::fs::read(&path).unwrap(); + let full = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(probe_info(&bytes).unwrap(), full.info(), "{name}"); + assert_eq!(full.metadata.model.as_deref(), Some(model), "{name}"); + assert_eq!(full.opcodes.list3.len(), expected, "{name}"); + if let Some(lightcraft_raw::Opcode::WarpRectilinear { planes, .. }) = full.opcodes.list3.first() { + // the stored zoom (0.0557) frames the corrected picture as the camera does + assert!((planes[0][0] - 1.0 / 1.055719).abs() < 1e-4, "{name}: {planes:?}"); + } + checked += 1; + } + eprintln!("Nikon distortion boundary: {checked} corpus files checked"); +} + /// Kodak DCS520C (`crates/raw/src/vendor/kodak.rs`): the two-component lossless JPEG strip decodes through the /// file's response curve to a 1736 x 1160 GRBG mosaic with 12-bit white, per-position black levels and the /// white balance of the vendor IFD. Looks for the corpus copy, then the raw.pixls.us folder; skips without either. diff --git a/docs/gaps.md b/docs/gaps.md index 39e6c6486..61370b9d0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (gap 10: Leica Q3 verified, 236 models; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (gap 8: Nikon Z 6II / Z f in-file distortion; gap 10: Leica Q3 verified, 236 models; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -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, Sony ILCE-7RM4A distortion and Nikon Z 6II / Z f distortion (`docs/nikon-lens-corrections.md`) | 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) | @@ -92,3 +92,4 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 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 | +| 2026-10-13 | minor | Gap 8 narrows: Nikon Z 6II / Z f NEFs use the camera's in-file distortion correction (`docs/nikon-lens-corrections.md`) | diff --git a/docs/lens-profiles.md b/docs/lens-profiles.md index 3503699ee..1d4127d47 100644 --- a/docs/lens-profiles.md +++ b/docs/lens-profiles.md @@ -1,7 +1,7 @@ # Lens profiles from a lens database Raws that carry no lens correction of their own (DNG `OpcodeList3`, or a camera correction a raw reader converts, -see [sony-lens-corrections.md](sony-lens-corrections.md)) can be corrected from a **lens database in the lensfun +see [sony-lens-corrections.md](sony-lens-corrections.md) and [nikon-lens-corrections.md](nikon-lens-corrections.md)) can be corrected from a **lens database in the lensfun XML format** that you supply. The app does not ship any lens database: lensfun's data is licensed CC-BY-SA 3.0 and stays a separate download. Adobe LCP profiles are never used. diff --git a/docs/nikon-lens-corrections.md b/docs/nikon-lens-corrections.md new file mode 100644 index 000000000..e48cbf8f8 --- /dev/null +++ b/docs/nikon-lens-corrections.md @@ -0,0 +1,95 @@ +# Nikon embedded distortion corrections + +Nikon writes its own distortion correction into NEFs from its mirrorless bodies, in the raw SubIFD tag `0xc7d5` +(NikonNEFInfo). LightCraft reads it for the NIKON Z 6_2 (Z 6II) and the NIKON Z f and turns it into an +`OpcodeList3` `WarpRectilinear`, so it goes through the same profile correction control, optics path, orientation +handling and DNG export as the other embedded corrections. Other bodies are left alone for now even if they have the +same tag, because I've only checked files from these two. + +This is distortion only. The vignetting block (`0x0006`) and what looks like lateral CA (`0x0007`) aren't applied +yet. Like the Sony, Fujifilm and Panasonic corrections it's on by default and can be turned off under Lens +Corrections. + +## Where the data comes from + +The [ExifTool Nikon tag names](https://exiftool.org/TagNames/Nikon.html) give the names of NikonNEFInfo and its +DistortionInfo entry (`0x0005`): DistortionCorrection and RadialDistortionCoefficient1 to 3. Nothing else was taken +from other software. The rest was worked out by comparing LightCraft's uncorrected render of each NEF with the +full-size JPEG the camera embeds in it. That JPEG is always distortion-corrected with Z lenses ("On (Required)"), and +with other lenses when Auto distortion control is on. The comparison matched textured patches between the two images +(normalised cross-correlation on the green channel at quarter size). + +NikonNEFInfo starts with `"Nikon\0"` and a 4-byte version, followed by a small TIFF stream. The two entries used here: + +| Entry | Bytes | What it holds | +|---|---|---| +| `0x0005` DistortionInfo | 0–3 | `"0100"` | +| | 4 | correction mode: 1 "On (Optional)", 3 "On (Required)", 0 none | +| | 8–15 | normalisation radius in mm, a signed rational. It's 21.6 (the FX half diagonal) in every file I looked at | +| | 16–19 | number of terms, 4 | +| | 20, 28, 36 | the three coefficients, called `k1`, `k2`, `k3` here after ExifTool's names | +| | 64–71 | the zoom `q` the camera uses to fill the frame | +| `0x0008` | 0–3, 4–11, 12–19 | `"0100"`, then the optical center's offset from the center of the image area in pixels (x right, y down) | + +The `0x0008` offset is where the in-body stabilization left the sensor. It's zero with a VR lens, since the lens does +the stabilizing, and up to about 100 px with the Z lenses. The center of the distortion measured in the JPEG follows +it closely, for example (−90.1, +18.6) recorded and (−91.2, +17.5) measured. + +From an output pixel to the raw, around the optical center, the mapping is + +``` +rho = r_out / half diagonal of the image area +r_src = r_out * (1 + k3*rho^2 + k2*rho^4 + k1*rho^6) / (1 + q) +``` + +So the stored order goes from the highest power down. Reading them the other way round (`k1` with `rho^2`) doesn't fit +the JPEG at all. `q` really is the zoom: fitting a separate zoom for every file gives `1 / (1 + q)` to four decimals +(0.9675 fitted and 0.9676 from `q` on one of them), so nothing is fitted per file. The decoder turns this into DNG +terms the same way `docs/sony-lens-corrections.md` describes for Sony, with the center in active-area coordinates and +the coefficients rescaled to the farthest corner of the active area. + +## How well it matches + +I checked it on 83 of my own Z 6II photos with three lenses between 20 and 200 mm, plus the CC0 Z f sample in the +corpus. The numbers are the radial error against the camera JPEG at full size (6048 px wide), out to 0.95 half +diagonals: + +| Lens | Focal length | Frames | Uncorrected median / p95 | Corrected median / p95 | +|---|---:|---:|---:|---:| +| VR 70-200mm f/2.8G (F-mount, FTZ) | 70 mm | 11 | 10.9 / 15.9 px | 0.19 / 0.57 px | +| VR 70-200mm f/2.8G (F-mount, FTZ) | 105–140 mm | 15 | 34–42 / 38–46 px | 0.13–0.18 / 0.59–0.90 px | +| VR 70-200mm f/2.8G (F-mount, FTZ) | 175–200 mm | 21 | 45–46 / 50–51 px | 0.10–0.18 / 0.48–0.95 px | +| NIKKOR Z 24-70mm f/4 S | 70 mm | 8 | 126.4 / 141.2 px | 0.48 / 4.38 px | +| NIKKOR Z 24-70mm f/4 S | 38–59 mm | 6 | 59–101 / 69–121 px | 0.36–0.76 / 4.6–6.8 px | +| NIKKOR Z 24-70mm f/4 S | 33–34 mm | 4 | 32–44 / 43–53 px | 1.0–1.3 / 8.5–9.6 px | +| NIKKOR Z 24-70mm f/4 S | 24 mm | 7 | 16.0 / 105.6 px | 6.90 / 23.56 px | +| NIKKOR Z 20mm f/1.8 S | 20 mm | 5 | 8.2 / 53.9 px | 1.49 / 5.46 px | +| Tamron 35-150mm (Z f, CC0 corpus) | 150 mm | 1 | 70.4 px median | 0.22 px median | + +I also rendered a few with `lightcraft-cli render` at default settings and measured those against the camera JPEG. +The median error was 0.10 px for the 70-200 at 200 mm and 0.12 px for the Z f sample. The 24-70 was 0.32 px at 70 mm, +on a frame with a 92 px sensor shift, and 8.4 px at 24 mm. The 20 mm came out at 1.3 px. That checks the +center, crop and DNG conversion along with the model itself. + +The F-mount lens on the FTZ matches about as well as I can measure. The native Z lenses still have a smooth error +toward the edges that doesn't depend on the sensor shift and is the same in every frame at a given focal length: about ++22 px at 0.7 to 0.9 half diagonals at 24 mm, +8 px at 33 mm and +3.5 px at 70 mm. It seems the camera applies an +extra correction for Z lenses that isn't in the readable coefficients. NikonNEFInfo `0x000e` looks encrypted and I +didn't try to decode it. Even so, the readable correction takes most of the distortion out. At 24 mm the error at 0.7 +half diagonals goes from −82 px to +25 px, and at 70 mm from about −137 px to under 4 px. + +## What's left out + +Only `NIKON Z 6_2` and `NIKON Z f` are corrected, matched exactly. A matching tag on another body doesn't tell us its +pixel pitch or image area, so other bodies can be added once someone checks their files. Only the full-frame 3:2 +image area is corrected, because the stored 21.6 mm radius is the FX half diagonal, so DX and other crops are skipped. +The decoder also skips data it doesn't recognise (another layout, radius or term count, or no correction mode), +values that are clearly wrong (coefficients past ±1, a zoom past ±0.5, a sensor shift over 5% of the frame) and any +mapping that folds back on itself before the farthest corner. + +The tests in `nef_lens` cover the polynomial and zoom, the sensor-shift center and refusing other bodies, layouts +and crops. `nikon_in_file_distortion_becomes_a_warp` runs a synthetic Z 6II NEF through the header and full decode +and a DNG export. `nikon_in_file_distortion_is_limited_to_validated_bodies` checks the corpus: the Z f sample gets the +warp and the D5100, D7000, D7500 and Coolpix B700 samples don't. + +None of my photos, crops or reference images are committed, only the numbers above. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..2ad0e61af 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (LR-EDIT-OPTICS-PROFILE: Nikon Z 6II / Z f in-file distortion correction; LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -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 with FE 24–105 and FE 200–600 lenses; other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Nikon NEF distortion from NikonNEFInfo (`0xc7d5`: polynomial, frame-filling zoom and the optical-centre offset left by in-body stabilisation; `crates/raw/src/vendor/nef_lens.rs`, `docs/nikon-lens-corrections.md`) for the Z 6II and Z f, full-frame image area only: F-mount lenses on the FTZ match the camera JPEG within 0.2 px median, Z lenses within 0.5–1.5 px except the 24-70 f/4's wide end, where the camera applies a further correction that is not in the readable data (7 px median at 24 mm, from 16 px / 106 px p95 uncorrected); 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` | | @@ -806,6 +806,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-12 | minor | LR-IMP-FORMATS: the Hasselblad Lunar (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data instead of opening as its preview; an uncompressed Leaf MOS imports as a preview-only raw instead of a greyscale image of its mosaic (#1275); status unchanged (🟡) | | 2026-10-12 | minor | LR-HDR-EXPORT, LR-EXP-HDR: HLG AVIF export (`hdrTransfer`); LR-VIEW-HDR-DISPLAY: tested SDR/HDR display decision | | 2026-10-12 | minor | LR-EXP-PSD, MENU-FILE-PHOTOSHOP: Photo ▸ Edit In ▸ Open Original in Other Editor (RAW, no copy) hands the original files (the raws themselves) to the other editor in one start; statuses unchanged | +| 2026-10-13 | minor | LR-EDIT-OPTICS-PROFILE: Nikon Z 6II / Z f NEFs use the camera's in-file distortion correction (NikonNEFInfo); status unchanged (🟡) | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index e3fdf5fcf..2ef4f2248 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,12 +1,12 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Leica Q3 verified by a recorded check of 38 private DNGs; EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Nikon Z 6II / Z f in-file distortion correction; Leica Q3 verified by a recorded check of 38 private DNGs; EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by model. The numbers feed [target-app-parity.md](target-app-parity.md); individual shortfalls are ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), -[raf-compression.md](raf-compression.md), [sony-lens-corrections.md](sony-lens-corrections.md), +[raf-compression.md](raf-compression.md), [sony-lens-corrections.md](sony-lens-corrections.md), [nikon-lens-corrections.md](nikon-lens-corrections.md), [camera-preview-colour.md](camera-preview-colour.md), [denoise.md](denoise.md). ## Headline @@ -219,7 +219,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); Nikon NikonNEFInfo distortion (tag `0xc7d5`, about the sensor-shifted optical centre) for the Z 6II and Z f: F-mount lenses on the FTZ within 0.2 px of the camera JPEG, Z lenses within 0.5–1.5 px except the 24-70's wide end (7 px median; `docs/nikon-lens-corrections.md`) | | Vignetting / lateral CA from maker notes | ✅ | ⬜ | | Manual distortion, vignetting, CA, defringe | ✅ | ✅ | @@ -280,6 +280,7 @@ Beyond decoding: | 2026-10-12 | minor | Hasselblad Lunar verified (a Sony NEX-7 ARW under Make HASSELBLAD, now decoded): 236 verified, 232 preview; an uncompressed Leaf MOS is recognised as a raw (preview only) instead of opening as a greyscale image of its mosaic (#1275) | | 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-13 | minor | Lens profiles: Nikon in-file distortion (NikonNEFInfo `0xc7d5`) for the Z 6II and Z f, validated on 83 frames from three lenses and the CC0 Z f sample (`docs/nikon-lens-corrections.md`); lens score 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) | 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/whats-new.md b/docs/whats-new.md index b5c2ac68c..13fc9ad98 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,12 @@ ## 2026-10-13 +### Nikon lens corrections +- Raws from the Nikon Z 6II and Z f now open with the camera's own distortion correction, as Lightroom applies a + built-in profile: straight lines near the edges stay straight and the framing matches the camera's JPEG. It works + with Z lenses and with F-mount lenses on the FTZ adapter, follows the in-body stabilisation's shift of the optical + centre, and can be turned off under Lens Corrections. Vignetting correction from the file comes later. + ### Slider drags on large raws - Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's