mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 11:17:05 -04:00
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:
1 parent
b3d392b499
commit
1ee751ebdf
11 files changed
+464
-11
No files matched your search
@@ -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)) |
|
||||
|
||||
Vendored
+1
@@ -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;
|
||||
|
||||
Vendored
+50
-2
@@ -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)] {
|
||||
|
||||
Vendored
+259
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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 |
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user