mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
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:
3 files changed
+79
-40
No files matched your search
@@ -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
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user