LR-EDIT-OPTICS-PROFILE: Apply Nikon's in-file distortion correction for the Z 6II, Z 8 and Z f

Read the polynomial, zoom and optical center offset from NikonNEFInfo.
This commit is contained in:
Michael Anderson committed 2026-10-13 11:10:46 -05:00
1 parent b3d392b499
commit 1ee751ebdf
11 files changed
+464 -11

No files matched your search

+1 -1
View File
@@ -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 8 / 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)) |
+1
View File
@@ -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;
+50 -2
View File
@@ -177,7 +177,8 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
(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<RawImage> {
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<u8> = (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)] {
+259
View File
@@ -0,0 +1,259 @@
//! 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 8", "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<f64> {
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<Distortion> {
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<Opcode> {
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<u8> {
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<u8> {
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<u8>)]) -> Vec<u8> {
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<u8>)]) -> 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());
assert!(warp("NIKON Z 8", &raw_with(&[(DISTORTION_INFO, good.clone())])).is_some());
for model in ["", "NIKON Z 6", "NIKON Z 6_3", "NIKON Z 9", "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());
}
}
+35
View File
@@ -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.
+3 -2
View File
@@ -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 8 / 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 8 / 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 8 / Z f NEFs use the camera's in-file distortion correction (`docs/nikon-lens-corrections.md`) |
+1 -1
View File
@@ -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.
+101
View File
@@ -0,0 +1,101 @@
# 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), the NIKON Z 8 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 three.
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, 45 Z 8 photos (below), 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 |
On the Z 8 (8256 px wide JPEGs) it looks the same. The 70-200 f/2.8G on the FTZ matches within 0.14 to 0.21 px median
at 70, 135 and 200 mm. The NIKKOR Z 24-120mm f/4 S is within 0.6 to 1.0 px median from 36 to 120 mm and 1.5 px at 24
to 28 mm, with the same kind of error toward the edges at the wide end as the Z lenses on the Z 6II. A few 14-24mm
f/2.8G frames had Auto distortion control off, so their JPEGs aren't corrected and match about the same either way.
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. On the Z 8 it was 0.14 px for the 70-200 at 200 mm, 0.45 and 0.68 px for the 24-120
at 68 and 120 mm, 1.8 px for the Z 20 mm and 3.7 px for the 24-120 at 24 mm. 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`, `NIKON Z 8` 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.
+3 -2
View File
@@ -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 8 / 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, Z 8 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 8 / 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 |
+4 -3
View File
@@ -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 8 / 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, Z 8 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, Z 8 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 |
+6
View File
@@ -2,6 +2,12 @@
## 2026-10-13
### Nikon lens corrections
- Raws from the Nikon Z 6II, Z 8 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