Merge pull request #1314 from AlexChambers/craft-raw/nikon-z6-z7-distortion

craft-raw: Nikon Z 6 and Z 7 in-file distortion; modes 1 and 3 only
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-14 09:54:06 -05:00
commit a207d20700
3 files changed
+79 -40

No files matched your search

+27 -7
View File
@@ -1,10 +1,10 @@
# 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
(NikonNEFInfo). LightCraft reads it for the NIKON Z 6, NIKON Z 7, NIKON Z 6_2 (Z 6II), NIKON Z 8 and 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.
same tag. Body recognition is limited to files checked against their camera JPEGs.
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
@@ -24,7 +24,7 @@ NikonNEFInfo starts with `"Nikon\0"` and a 4-byte version, followed by a small T
| Entry | Bytes | What it holds |
|---|---|---|
| `0x0005` DistortionInfo | 0–3 | `"0100"` |
| | 4 | correction mode: 1 "On (Optional)", 3 "On (Required)", 0 none |
| | 4 | correction mode: 1 "On (Optional)", 3 "On (Required)"; 0 none, 2 "Off" and unknown modes are skipped |
| | 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 |
@@ -84,18 +84,38 @@ extra correction for Z lenses that isn't in the readable coefficients. NikonNEFI
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.
## Z 6 and Z 7 validation
Six CC0 raw.pixls.us files were checked: three Z 6 and three Z 7 exposures of two repeated scenes, all with one
NIKKOR Z 24-70mm f/4 S (Z 6 at 52 mm and f/6.7, Z 7 at 56 mm and f/5.6). They don't establish coverage at other focal
lengths or with other lenses. Log-green patches in a quarter-size sensor proxy were matched against the full-size
embedded JPEG (normalised correlation at least 0.90, and a peak at least 0.02 above competing matches outside its 7 by
7 neighbourhood). The stored coefficients, zoom and optical centre were used as stored, with no per-photo fit. Radial
errors are full-size pixels over reliable matches between 0.15 and 0.95 image half diagonals:
| Body | Files | Uncorrected radial median | Corrected radial median | Corrected radial p95 |
|---|---:|---:|---:|---:|
| Z 6 | 3 | 82.84-84.00 px | 0.88-0.96 px | 4.69-4.99 px |
| Z 7 | 3 | 124.21-124.80 px | 1.37-1.46 px | 6.18-6.38 px |
Reliable patches reach about 0.90 half diagonals on the Z 6 and 0.94 on the Z 7, so the extreme corners aren't
validated. The remaining smooth edge error matches the native Z lens residual described above. The method, file
hashes and a production render sweep are in
[LightCraft PR #3796](https://github.com/storytold/lightcraft/pull/3796).
## 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
Only `NIKON Z 6`, `NIKON Z 7`, `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),
The decoder also skips data it doesn't recognise (another layout, radius or term count, or a correction mode other than 1 or 3),
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.
and a DNG export. `nikon_distortion_modes_are_respected_by_probe_and_full_decode` checks that only modes 1 and 3
give a warp, in the header probe and the full decode alike. `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.
+41 -27
View File
@@ -725,36 +725,38 @@ mod tests {
TiffWriter::new(order, false).write(&[ifd0]).unwrap()
}
/// A small uncompressed NEF with a real NikonNEFInfo TIFF block, without binary fixtures.
fn nikon_distortion_file(model: &str, mode: u8) -> Vec<u8> {
use crate::nef_lens::tests::{distortion_block, nef_info_blob, offset_block};
let (w, h) = (48u32, 32u32);
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(mode, 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()
}
/// 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 crate::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 bytes = nikon_distortion_file("NIKON Z 6_2", 3);
let header = decode(&bytes, Mode::Header, &Limits::default()).unwrap();
let full = decode(&bytes, Mode::Full, &Limits::default()).unwrap();
assert_eq!(header.info(), full.info());
@@ -767,7 +769,19 @@ mod tests {
let dng = crate::write_dng(&full, &Default::default()).unwrap();
assert_eq!(crate::decode(&dng).unwrap().opcodes.list3, full.opcodes.list3);
}
assert!(decode(&file("NIKON D850"), Mode::Full, &Limits::default()).unwrap().opcodes.list3.is_empty());
assert!(decode(&nikon_distortion_file("NIKON D850", 3), Mode::Full, &Limits::default()).unwrap().opcodes.list3.is_empty());
}
/// Off, no-lens and unrecognized modes must not create a source correction through either public path.
#[test]
fn nikon_distortion_modes_are_respected_by_probe_and_full_decode() {
for mode in [0, 1, 2, 3, 4, 128, 255] {
let bytes = nikon_distortion_file("NIKON Z 6_2", mode);
let header = crate::probe_info(&bytes).unwrap();
let full = crate::decode(&bytes).unwrap();
assert_eq!(header, full.info(), "mode {mode}: header/full agreement");
assert_eq!(!header.opcodes.list3.is_empty(), matches!(mode, 1 | 3), "mode {mode}");
}
}
#[test]
+11 -6
View File
@@ -15,7 +15,7 @@ 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"];
const VALIDATED: &[&str] = &["NIKON Z 6", "NIKON Z 7", "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;
@@ -45,10 +45,10 @@ fn nef_info(raw: &Ifd) -> Option<(Ifd, ByteOrder)> {
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.
/// DistortionInfo: `"0100"`, mode (1 optional on, 3 required on; 0 no lens, 2 off), radius (mm) at 8,
/// term count (4) at 16, coefficients at 20 / 28 / 36 and 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 {
if b.get(0..4)? != b"0100" || !matches!(*b.get(4)?, 1 | 3) {
return None;
}
let norm = rational(b, 8, order)?;
@@ -222,15 +222,20 @@ pub(crate) mod tests {
#[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());
for model in ["NIKON Z 6", "NIKON Z 7", "NIKON Z 6_2"] {
assert!(warp(model, &raw_with(&[(DISTORTION_INFO, good.clone())])).is_some(), "{model}");
}
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"] {
for model in ["", "NIKON Z 7_2", "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(2, 21.6, 4, WIDE.0, WIDE.1),
distortion_block(4, 21.6, 4, WIDE.0, WIDE.1),
distortion_block(255, 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),