LR-IMP-CAMERA-COVERAGE: measured image area for Nikon bodies without CropArea

Nikon bodies from before ~2016 record no CropArea, so the decoder kept
their whole sensor data, less trailing dark columns. Where that data
holds masked lines above black or optical black, they were developed as
a coloured or dark bar on that edge: the D7000 and D5100's 36 columns at
~600 (#976), the D800's 14 (#914, the top in portrait), the D600 / D610's
8 (#1236), the D3200's 46, and others.

MEASURED_BODIES (crates/raw/src/vendor/nef.rs) now gives 44 more model /
data size / depth groups the frame of their camera JPEG inside the data,
next to the D4, D7100 and D90 entries already there: D7000, D5100, D800,
D800E, Df, the D4's 12-bit mode, D3, D3S, D700, D300, D300S, D600, D610,
D810, D4S, D5600, Coolpix A, D3000, D40X, D60, D80, D2Xs, D200, D3200
and Nikon 1 V1; and, on best-guess frames short of the evidence rule,
each with a comment naming its evidence, the D3100 (3 of 4 clean files
agree), D5000 (4 of 5, and the D90's sensor), D40 (2 of 2), D50 and D70
(one file each, agreeing) and D70s (next to them, between pixels).
Every best-guess frame keeps the masked lines out by several pixels.
An entry matches model, data size and depth exactly. Bodies whose data
holds only picture beyond the camera frame get no entry and render as
before.

`cargo xtask raw-frame FILE...` (crates/engine/examples/raw_frame.rs)
measures a body: it aligns each raw's embedded camera JPEG with the
sensor data (half-size luminance, gradient correlation, sub-pixel peak;
weak, ambiguous or between-pixel peaks are flagged), counts the edge
lines that hold no picture and reports what the decoder's crop keeps
beyond the camera frame. A masked line is flat and its two CFA phases
agree within the noise or within 0.4% of white, so filler with a small
fixed offset between them (D90, D5000) counts. It prints entries only
with evidence: at least 3 clean files agreeing exactly, of the model
(12- and 14-bit pooled) or, with at least one of its own, with sibling
models of the same readout (data and JPEG size) that agree on the same
frame. On the D7500, which records its CropArea, it finds that area
exactly; it declines the Z f, whose JPEG is distortion-corrected. It
works for any make whose raw embeds a full-size camera JPEG: on Sony,
Canon CR2 / CR3, Pentax and Samsung files it finds the decoder's own
crop (relative to the active area).

Every Nikon raw on raw.pixls.us without CropArea was surveyed this way
(its 138 CC0 samples, plus 124 other publicly posted sample files; only
the measurements are committed). Bodies whose data holds only picture
beyond the frame need no entry (D750, D5300, D5500, D3300, D2X, D2Hs,
D3X, D5200, Coolpix P6000, Nikon 1 AW1 / J3 / V2 / V3, the D800's crop
modes); raw-parity.md lists them, and no longer claims every NEF
coding but HE decodes. measured_bodies_are_consistent checks every entry lies
inside its data and is listed once; measured_older_dx_bodies_image_area
covers the D200, D3200, Nikon 1 V1 and best-guess entries.

Fixes #976
Fixes #914
Fixes #1236

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
robmdunn
2026-10-14 22:40:06 -04:00
co-authored by Claude Opus 5.5
parent af4c5e102c
commit 2b3b6e7871
7 changed files with 700 additions and 9 deletions
+499
View File
@@ -0,0 +1,499 @@
//! Measure where a camera's own JPEG sits inside its raw sensor data, and which edge bands of that data hold no
//! picture: `cargo xtask raw-frame [--json] FILE…` (or `cargo run --release -p lightcraft-engine --example raw_frame --
//! [--json] FILE…`). It checks the crop any decoder applies against the camera's own framing, and measures the entries
//! of Nikon `MEASURED_BODIES` (`crates/raw/src/vendor/nef.rs`) for bodies that record
//! no crop. It works for any colour-filter raw that embeds a full-size camera JPEG (checked on Nikon, Sony, Canon CR2 /
//! CR3, Pentax and Samsung files, where it finds the decoder's crop); bodies with a smaller embedded preview (most
//! Fujifilm, Panasonic and Olympus files) get the edge bands only.
//!
//! **Frame.** For each raw it aligns the largest embedded JPEG with the sensor data: both are reduced to half-size
//! luminance (the raw by summing each 2×2 colour-filter block), and every offset that keeps the JPEG inside the data is
//! scored by the correlation of their gradient magnitudes. The best offset is refined to sub-pixel precision; a peak
//! that is weak, ambiguous or between two pixels is flagged. Files are grouped by model and data size (bit depths
//! pooled: both read out the same area); when 3 clean files agree, the group prints its table entries (siblings of the
//! same readout that agree on the same frame count towards the 3; see `MIN_FILES`). Use several
//! files with detail across the frame: a blank sky, fog or a lens cap has nothing to align on (low score). The JPEG
//! must be the camera's full-size one, unrotated and without in-camera geometry corrections (distortion control off);
//! a smaller preview is not aligned.
//!
//! **Edge bands.** From each edge inwards (at most 128 lines) it counts lines that hold no picture: *empty or dark*
//! (mean below 1% of the range over black) or *masked* (flat, patternless and above black: even and odd samples
//! alike within 4 standard errors or within 0.4% of white, since some filler holds a small fixed offset between the
//! two, as on the D90 and D5000; spread no more than the line's own neighbour noise and under 0.4% of white). A
//! band that the decoder's crop still includes is developed as picture: the coloured bars of issue #976. This is a
//! survey aid: a dark band can also be deep shadow, so read it with the frame.
use std::collections::BTreeMap;
use lightcraft_codecs::DecodeOptions;
use lightcraft_raw::RawData;
/// Smallest correlation peak worth trusting, and how far it must stand above the best offset outside its 3×3
/// neighbourhood (measured: 0.75–0.88 on detailed D5100 / D7000 frames, runners-up ≥ 0.05 lower).
const MIN_SCORE: f64 = 0.5;
const MIN_MARGIN: f64 = 0.02;
/// Largest sub-pixel distance from a whole pixel accepted as a clean alignment.
const MAX_FRACTION: f64 = 0.35;
/// Search at most this many half-size offsets in each direction (384 px of margin around the JPEG: Canon bodies keep
/// up to ~280 px of masked border).
const MAX_SEARCH: usize = 192;
/// Windows of up to this many offsets are searched exhaustively (every Nikon body); larger ones coarse to fine.
const FULL_SEARCH: usize = 2048;
/// Edge bands are counted up to this many lines from each edge.
const MAX_BAND: usize = 128;
/// Clean, agreeing files an entry needs: of one model and data size (bit depths pooled: both read out the same area),
/// or, with at least one of its own, together with sibling models of the same readout (data and JPEG size) whose own
/// files agree on the same frame.
const MIN_FILES: usize = 3;
struct Frame {
jpeg: (usize, usize),
/// Sub-pixel offset of the JPEG's top left corner in sensor pixels.
at: (f64, f64),
score: f64,
margin: f64,
}
impl Frame {
fn whole(&self) -> (usize, usize) {
(self.at.0.round().max(0.0) as usize, self.at.1.round().max(0.0) as usize)
}
fn clean(&self) -> bool {
let frac = |v: f64| (v - v.round()).abs();
self.score >= MIN_SCORE && self.margin >= MIN_MARGIN && frac(self.at.0) <= MAX_FRACTION && frac(self.at.1) <= MAX_FRACTION
}
}
/// Lines without picture at one edge: empty or dark ones, then masked ones (or the other way round), from the edge.
#[derive(Clone, Copy, Default)]
struct Band {
dark: usize,
masked: usize,
}
impl Band {
fn total(self) -> usize {
self.dark + self.masked
}
}
struct Measured {
model: String,
size: (usize, usize),
bits: u32,
black: f64,
white: f64,
/// The crop the decoder applies, in sensor-data coordinates: x, y, width, height.
crop: (usize, usize, usize, usize),
/// Left, right, top, bottom.
bands: [Band; 4],
frame: Result<Frame, String>,
/// With a clean frame: what the crop holds beyond it.
beyond: Option<[Beyond; 4]>,
}
impl Measured {
/// Lines of each edge band (left, right, top, bottom) inside the decoder's crop: developed as picture.
fn in_crop(&self) -> [usize; 4] {
let (w, h) = self.size;
let (x, y, cw, ch) = self.crop;
let [l, r, t, b] = self.bands.map(Band::total);
[l.saturating_sub(x), (x + cw).saturating_sub(w - r.min(w)), t.saturating_sub(y), (y + ch).saturating_sub(h - b.min(h))]
}
}
/// What the decoder's crop holds outside the camera's JPEG frame, per side (left, right, top, bottom): lines that are
/// masked (flat above black: a coloured bar), dark (empty or optical black: a dark edge) or picture (trimmed by the
/// camera, harmless).
#[derive(Clone, Copy, Default)]
struct Beyond {
masked: usize,
dark: usize,
picture: usize,
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let json = args.iter().any(|a| a == "--json");
let files: Vec<&String> = args.iter().filter(|a| *a != "--json").collect();
if files.is_empty() {
eprintln!("usage: raw_frame [--json] FILE… (raw files whose largest embedded JPEG is the camera's full-size one)");
std::process::exit(2);
}
// model, data size, JPEG size -> (bit depth, frame) of each file
let mut groups: BTreeMap<(String, (usize, usize), (usize, usize)), Vec<(u32, Frame)>> = BTreeMap::new();
for f in files {
let m = match measure(f) {
Ok(m) => m,
Err(e) => {
if json {
println!("{}", serde_json::json!({ "file": f, "error": e }));
} else {
println!("{f}: skipped: {e}");
}
continue;
}
};
let in_crop = m.in_crop();
if json {
let band = |b: Band| serde_json::json!({ "dark": b.dark, "masked": b.masked });
let frame = match &m.frame {
Ok(fr) => serde_json::json!({
"jpeg": [fr.jpeg.0, fr.jpeg.1], "at": [fr.at.0, fr.at.1], "score": fr.score, "margin": fr.margin, "clean": fr.clean()
}),
Err(e) => serde_json::json!({ "error": e }),
};
println!(
"{}",
serde_json::json!({
"file": f, "model": m.model, "size": [m.size.0, m.size.1], "bits": m.bits, "black": m.black, "white": m.white,
"crop": [m.crop.0, m.crop.1, m.crop.2, m.crop.3],
"bands": { "left": band(m.bands[0]), "right": band(m.bands[1]), "top": band(m.bands[2]), "bottom": band(m.bands[3]) },
"in_crop": { "left": in_crop[0], "right": in_crop[1], "top": in_crop[2], "bottom": in_crop[3] },
"beyond_frame": m.beyond.map(|b| {
let side = |b: Beyond| serde_json::json!({ "masked": b.masked, "dark": b.dark, "picture": b.picture });
serde_json::json!({ "left": side(b[0]), "right": side(b[1]), "top": side(b[2]), "bottom": side(b[3]) })
}),
"frame": frame,
})
);
} else {
let frame = match &m.frame {
Ok(fr) => format!(
"JPEG {}×{} at ({:.2}, {:.2}), score {:.3}, margin {:.3}{}",
fr.jpeg.0,
fr.jpeg.1,
fr.at.0,
fr.at.1,
fr.score,
fr.margin,
if fr.clean() { "" } else { " (not clean: weak, ambiguous or between pixels)" }
),
Err(e) => format!("no frame: {e}"),
};
println!("{f}: {} {}×{} {}-bit, {frame}", m.model, m.size.0, m.size.1, m.bits);
let side = |name: &str, b: Band| match (b.dark, b.masked) {
(0, 0) => None,
(d, 0) => Some(format!("{name} {d} dark")),
(0, k) => Some(format!("{name} {k} masked")),
(d, k) => Some(format!("{name} {d} dark + {k} masked")),
};
let bands: Vec<String> = ["left", "right", "top", "bottom"].iter().zip(m.bands).filter_map(|(n, b)| side(n, b)).collect();
let shown: Vec<String> =
["left", "right", "top", "bottom"].iter().zip(in_crop).filter(|(_, n)| *n > 0).map(|(s, n)| format!("{s} {n}")).collect();
println!(
" edge bands: {}; decoder crop ({}, {}, {}, {}){}",
if bands.is_empty() { "none".to_string() } else { bands.join(", ") },
m.crop.0,
m.crop.1,
m.crop.2,
m.crop.3,
match m.beyond {
Some(b) => {
let parts: Vec<String> = ["left", "right", "top", "bottom"]
.iter()
.zip(b)
.filter(|(_, b)| b.masked + b.dark + b.picture > 0)
.map(|(s, b)| format!("{s} {} masked / {} dark / {} picture", b.masked, b.dark, b.picture))
.collect();
if parts.is_empty() {
"; nothing beyond the camera frame".to_string()
} else {
format!("; beyond the camera frame: {}", parts.join(", "))
}
}
None if shown.is_empty() => String::new(),
None => format!(" still includes edge bands: {} lines", shown.join(", ")),
}
);
}
if let Ok(fr) = m.frame {
groups.entry((m.model.clone(), m.size, fr.jpeg)).or_default().push((m.bits, fr));
}
}
if json {
return;
}
println!();
// each group's agreed frame: (model, data size, JPEG size) -> (offset, clean files), when its clean files agree
let agreed: BTreeMap<&(String, (usize, usize), (usize, usize)), ((usize, usize), usize)> = groups
.iter()
.filter_map(|(key, frames)| {
let offsets: Vec<(usize, usize)> = frames.iter().filter(|(_, f)| f.clean()).map(|(_, f)| f.whole()).collect();
let first = *offsets.first()?;
offsets.iter().all(|o| *o == first).then_some((key, (first, offsets.len())))
})
.collect();
for (key, frames) in &groups {
let (model, size, jpeg) = key;
let clean = frames.iter().filter(|(_, f)| f.clean()).count();
let mut depths: Vec<u32> = frames.iter().map(|(b, _)| *b).collect();
depths.sort_unstable();
depths.dedup();
let label = format!("{model} {}×{}", size.0, size.1);
let Some(&((x, y), _)) = agreed.get(key) else {
if clean == 0 {
println!("{label}: no clean alignment in {} files; no entry", frames.len());
} else {
let offsets: Vec<(usize, usize)> = frames.iter().filter(|(_, f)| f.clean()).map(|(_, f)| f.whole()).collect();
println!("{label}: files disagree: {offsets:?}; no entry");
}
continue;
};
let note = if clean < frames.len() { format!(", {} not clean", frames.len() - clean) } else { String::new() };
println!("{label}: {clean} of {} files agree on ({x}, {y}){note}", frames.len());
// siblings: other models whose sensor readout matches (same data and JPEG size) and whose own files agree on
// the same frame
let siblings: Vec<(&str, usize)> = agreed
.iter()
.filter(|((m, s, j), (o, _))| m != model && s == size && j == jpeg && *o == (x, y))
.map(|((m, _, _), (_, n))| (m.as_str(), *n))
.collect();
let pooled = clean + siblings.iter().map(|(_, n)| n).sum::<usize>();
if clean < MIN_FILES {
if pooled < MIN_FILES {
println!(" pending: {} more clean file(s) needed for an entry", MIN_FILES - pooled);
continue;
}
let names: Vec<String> = siblings.iter().map(|(m, n)| format!("{m} ({n})")).collect();
println!(" with siblings of the same readout and frame: {}", names.join(", "));
}
for bits in depths {
println!(
" MeasuredBody {{ model: {model:?}, size: ({}, {}), bits: {bits}, crop: ({x}, {y}, {}, {}), white: None }},",
size.0, size.1, jpeg.0, jpeg.1
);
}
}
}
fn measure(path: &str) -> Result<Measured, String> {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
let raw = lightcraft_raw::decode(&bytes).map_err(|e| e.to_string())?;
let RawData::U16(d) = &raw.data else { return Err("not 16-bit integer sensor data".into()) };
if raw.cpp != 1 || raw.cfa.is_none() {
return Err("not colour-filter sensor data".into());
}
let (w, h) = (raw.width, raw.height);
if w < 4 || h < 4 || d.len() < w.saturating_mul(h) {
return Err("sensor data shorter than its size".into());
}
let black = f64::from(raw.black.values.iter().sum::<f32>() / raw.black.values.len().max(1) as f32).max(0.0);
let white = f64::from(raw.white.first().copied().unwrap_or(((1u32 << raw.bits.min(16)) - 1) as f32)).max(black + 1.0);
let at = |x: usize, y: usize| f64::from(d.get(y * w + x).copied().unwrap_or(0));
let bands = [
band((0..w.min(MAX_BAND)).map(|x| line(h, |i| at(x, i))), black, white),
band((0..w.min(MAX_BAND)).map(|k| line(h, |i| at(w - 1 - k, i))), black, white),
band((0..h.min(MAX_BAND)).map(|y| line(w, |i| at(i, y))), black, white),
band((0..h.min(MAX_BAND)).map(|k| line(w, |i| at(i, h - 1 - k))), black, white),
];
// the decoder's crop is relative to the active area: in sensor-data coordinates here
let a = raw.active_area;
let c = lightcraft_raw::Rect::new(a.x + raw.crop.x, a.y + raw.crop.y, raw.crop.width, raw.crop.height);
let frame = frame(&bytes, d, w, h);
let beyond = frame.as_ref().ok().filter(|f| f.clean()).map(|f| {
let ((fx, fy), (fw, fh)) = (f.whole(), f.jpeg);
let kinds = |lines: Vec<Line>| {
lines.iter().fold(Beyond::default(), |mut b, l| {
match kind(l, black, white) {
Kind::Dark => b.dark += 1,
Kind::Masked => b.masked += 1,
Kind::Picture => b.picture += 1,
}
b
})
};
let col = |x: usize| line(h, |i| at(x, i));
let row = |y: usize| line(w, |i| at(i, y));
[
kinds((c.x..fx.min(c.x + c.width)).map(col).collect()),
kinds(((fx + fw).max(c.x)..c.x + c.width).map(col).collect()),
kinds((c.y..fy.min(c.y + c.height)).map(row).collect()),
kinds(((fy + fh).max(c.y)..c.y + c.height).map(row).collect()),
]
});
Ok(Measured {
model: raw.metadata.model.clone().unwrap_or_default().trim().to_string(),
size: (w, h),
bits: raw.bits,
black,
white,
crop: (c.x, c.y, c.width, c.height),
bands,
frame,
beyond,
})
}
/// Statistics of one line of sensor samples: mean, |even − odd| mean, spread, neighbour noise (same colour, 2 apart)
/// and the number of sample pairs.
struct Line {
mean: f64,
pattern: f64,
spread: f64,
noise: f64,
pairs: f64,
}
fn line(n: usize, at: impl Fn(usize) -> f64) -> Line {
let stride = ((n / 512).max(1)).div_ceil(2) * 2;
let (mut k, mut even, mut odd, mut var, mut noise) = (0f64, 0f64, 0f64, 0f64, 0f64);
let idx: Vec<usize> = (0..n.saturating_sub(2)).step_by(stride).collect();
for &i in &idx {
even += at(i);
odd += at(i + 1);
k += 1.0;
}
let (even, odd) = (even / k.max(1.0), odd / k.max(1.0));
for &i in &idx {
var += (at(i) - even).powi(2);
noise += (at(i + 2) - at(i)).powi(2) / 2.0;
}
Line { mean: (even + odd) / 2.0, pattern: (even - odd).abs(), spread: (var / k.max(1.0)).sqrt(), noise: (noise / k.max(1.0)).sqrt(), pairs: k }
}
enum Kind {
Dark,
Masked,
Picture,
}
fn kind(l: &Line, black: f64, white: f64) -> Kind {
let range = white - black;
let slack = white / 4096.0;
if l.mean < black + range * 0.01 {
Kind::Dark
} else if l.mean > black + range * 0.02
&& l.mean < white * 0.25
// patternless within the noise, or with a small fixed offset between the CFA phases (D90 / D5000 filler)
&& (l.pattern < 4.0 * l.noise * (2.0 / l.pairs.max(1.0)).sqrt() + slack || l.pattern < white * 0.004)
&& l.spread < 1.5 * l.noise + slack
&& l.spread < white * 0.004
{
Kind::Masked
} else {
Kind::Picture
}
}
fn band(lines: impl Iterator<Item = Line>, black: f64, white: f64) -> Band {
let mut b = Band::default();
for l in lines {
match kind(&l, black, white) {
Kind::Dark => b.dark += 1,
Kind::Masked => b.masked += 1,
Kind::Picture => break,
}
}
b
}
fn frame(bytes: &[u8], d: &[u16], w: usize, h: usize) -> Result<Frame, String> {
let jpeg = lightcraft_raw::embedded_preview(bytes).ok_or("no embedded preview")?;
let jpeg = lightcraft_codecs::decode(&jpeg, DecodeOptions::default()).map_err(|e| e.to_string())?;
let (jw, jh) = (jpeg.image.width, jpeg.image.height);
if jw > w || jh > h || jw * 10 < w * 9 || jh * 10 < h * 9 {
return Err(format!("the largest preview ({jw}×{jh}) is not a full-size JPEG of the {w}×{h} data"));
}
// half-size luminance, square-rooted so the linear raw and the JPEG weigh their tones alike
let (rw, rh) = (w / 2, h / 2);
let raw_y: Vec<f64> = (0..rh * rw)
.map(|i| {
let (x, y) = (2 * (i % rw), 2 * (i / rw));
let s: f64 =
[(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)].iter().map(|&(x, y)| f64::from(d.get(y * w + x).copied().unwrap_or(0))).sum();
s.sqrt()
})
.collect();
let (pw, ph) = (jw / 2, jh / 2);
let px = &jpeg.image.data;
let jpg_y: Vec<f64> = (0..ph * pw)
.map(|i| {
let (x, y) = (2 * (i % pw), 2 * (i / pw));
let s: f64 = [(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)]
.iter()
.map(|&(x, y)| px.get(y * jw + x).map_or(0.0, |p| f64::from(0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]).max(0.0)))
.sum();
s.sqrt()
})
.collect();
let (sx, sy) = ((rw - pw).min(MAX_SEARCH), (rh - ph).min(MAX_SEARCH));
let grad = |img: &[f64], stride: usize, x: usize, y: usize| {
let at = |x: usize, y: usize| img.get(y * stride + x).copied().unwrap_or(0.0);
(at(x + 1, y) - at(x, y)).hypot(at(x, y + 1) - at(x, y))
};
let normalise = |v: Vec<f64>| {
let n = v.len().max(1) as f64;
let mean = v.iter().sum::<f64>() / n;
let sd = (v.iter().map(|a| (a - mean).powi(2)).sum::<f64>() / n).sqrt().max(1e-12);
v.into_iter().map(|a| (a - mean) / sd).collect::<Vec<f64>>()
};
// gradient magnitudes on every `step`-th half-size pixel, normalised, and the correlation at one offset
let sample = |step: usize| {
let points: Vec<(usize, usize)> =
(0..ph.saturating_sub(1)).step_by(step).flat_map(|y| (0..pw.saturating_sub(1)).step_by(step).map(move |x| (x, y))).collect();
let gj = normalise(points.iter().map(|&(x, y)| grad(&jpg_y, pw, x, y)).collect());
(points, gj)
};
let score = |(points, gj): &(Vec<(usize, usize)>, Vec<f64>), dx: usize, dy: usize| {
let gr = normalise(points.iter().map(|&(x, y)| grad(&raw_y, rw, x + dx, y + dy)).collect());
gr.iter().zip(gj).map(|(a, b)| a * b).sum::<f64>() / gj.len().max(1) as f64
};
let fine = sample(2);
// every offset when the window is small (every Nikon body); else every 4th on sparser points, then each offset
// around the best of those
let mut scores = vec![vec![f64::NEG_INFINITY; sx + 1]; sy + 1];
let mut coarse_best = f64::NEG_INFINITY;
let (mut cx, mut cy) = (0, 0);
let window = if (sx + 1) * (sy + 1) <= FULL_SEARCH {
(0..=sx, 0..=sy)
} else {
let coarse = sample(8);
for dy in (0..=sy).step_by(4) {
for dx in (0..=sx).step_by(4) {
let v = score(&coarse, dx, dy);
if let Some(s) = scores.get_mut(dy).and_then(|r| r.get_mut(dx)) {
*s = v;
}
if v > coarse_best {
(coarse_best, cx, cy) = (v, dx, dy);
}
}
}
(cx.saturating_sub(5)..=(cx + 5).min(sx), cy.saturating_sub(5)..=(cy + 5).min(sy))
};
for dy in window.1.clone() {
for dx in window.0.clone() {
if let Some(s) = scores.get_mut(dy).and_then(|r| r.get_mut(dx)) {
*s = score(&fine, dx, dy);
}
}
}
let at = |dx: usize, dy: usize| scores.get(dy).and_then(|r| r.get(dx)).copied().unwrap_or(f64::NEG_INFINITY);
let (mut bx, mut by) = (0, 0);
for dy in 0..=sy {
for dx in 0..=sx {
if at(dx, dy) > at(bx, by) {
(bx, by) = (dx, dy);
}
}
}
let best = at(bx, by);
let runner_up = (0..=sy)
.flat_map(|dy| (0..=sx).map(move |dx| (dx, dy)))
.filter(|&(dx, dy)| dx.abs_diff(bx) > 1 || dy.abs_diff(by) > 1)
.map(|(dx, dy)| at(dx, dy))
.fold(f64::NEG_INFINITY, f64::max);
// parabola through the peak and its neighbours (none at the window's edge)
let refine = |a: f64, b: f64, c: f64| {
let k = a - 2.0 * b + c;
if a.is_finite() && c.is_finite() && k < 0.0 { (0.5 * (a - c) / k).clamp(-0.5, 0.5) } else { 0.0 }
};
let fx = if bx > 0 { refine(at(bx - 1, by), best, at(bx + 1, by)) } else { 0.0 };
let fy = if by > 0 { refine(at(bx, by - 1), best, at(bx, by + 1)) } else { 0.0 };
Ok(Frame {
jpeg: (jw, jh),
at: (2.0 * (bx as f64 + fx), 2.0 * (by as f64 + fy)),
score: best,
margin: if runner_up.is_finite() { best - runner_up } else { best },
})
}
+164 -4
View File
@@ -87,11 +87,88 @@ struct MeasuredBody {
/// the masked-column test keeps them and they develop as a pink band on the right. The camera's 4288 × 2848 JPEG
/// sits at (10, 10). Green saturates at 3840–3851 while red and blue run on to 4095; one level of 3830 keeps
/// clipped highlights white.
/// * **NIKON D7000** and **NIKON D5100** (and the Coolpix A, the same readout): the data is 4992 × 3280, the picture its
/// first 4948 columns, then 36 masked ones at one level (~600, with a black level of 0) and 8 of 0. The masked
/// columns sit above the dark-column trim's threshold, so without this area they show as a coloured bar on the right
/// edge (the top in portrait; issue #976). The camera's 4928 × 3264 JPEG sits at (8, 8): in the D5100's 3 CC0
/// samples, and in the D7000's one clean one, with the D5100 and Coolpix A as siblings (see below).
/// * **The other bodies** below keep masked or dark lines inside the whole data (a survey of every Nikon raw on raw.pixls.us):
/// each crop is its camera JPEG's frame as `cargo xtask raw-frame` measures it on raw.pixls.us CC0 samples (the
/// D200, D3200 and Nikon 1 V1 also on other publicly posted sample files), with at least 3 clean
/// files agreeing: of the model (bit depths pooled: both read out the same area), or, with at least
/// one of its own, together with models of the same readout (data and JPEG size) that agree on the same frame
/// (*siblings*). The D4's 12-bit mode reads out the 14-bit area measured above; the Df, the same readout, agrees on
/// (8, 6) in 6 files. Bodies whose crop holds only picture beyond the camera frame have no entry (no change).
const MEASURED_BODIES: &[MeasuredBody] = &[
MeasuredBody { model: "NIKON D4", size: (4992, 3292), bits: 14, crop: (8, 6, 4928, 3280), white: Some(15520.0) },
MeasuredBody { model: "NIKON D4", size: (4992, 3292), bits: 12, crop: (8, 6, 4928, 3280), white: None },
MeasuredBody { model: "NIKON D7100", size: (6036, 4020), bits: 14, crop: (18, 10, 6000, 4000), white: None },
MeasuredBody { model: "NIKON D7100", size: (4812, 3212), bits: 14, crop: (6, 6, 4800, 3200), white: None },
MeasuredBody { model: "NIKON D90", size: (4352, 2868), bits: 12, crop: (10, 10, 4288, 2848), white: Some(3830.0) },
MeasuredBody { model: "NIKON D7000", size: (4992, 3280), bits: 14, crop: (8, 8, 4928, 3264), white: None },
MeasuredBody { model: "NIKON D7000", size: (4992, 3280), bits: 12, crop: (8, 8, 4928, 3264), white: None },
MeasuredBody { model: "NIKON D5100", size: (4992, 3280), bits: 14, crop: (8, 8, 4928, 3264), white: None },
// siblings: D7000 (4), D5100 (3); masked: right 36
MeasuredBody { model: "COOLPIX A", size: (4992, 3280), bits: 14, crop: (8, 8, 4928, 3264), white: None },
// 6 files each; masked: left 2, right 2
MeasuredBody { model: "NIKON D3", size: (4288, 2844), bits: 12, crop: (15, 6, 4256, 2832), white: None },
MeasuredBody { model: "NIKON D3", size: (4288, 2844), bits: 14, crop: (15, 6, 4256, 2832), white: None },
MeasuredBody { model: "NIKON D3S", size: (4288, 2844), bits: 12, crop: (15, 6, 4256, 2832), white: None },
MeasuredBody { model: "NIKON D3S", size: (4288, 2844), bits: 14, crop: (15, 6, 4256, 2832), white: None },
MeasuredBody { model: "NIKON D700", size: (4288, 2844), bits: 12, crop: (15, 6, 4256, 2832), white: None },
MeasuredBody { model: "NIKON D700", size: (4288, 2844), bits: 14, crop: (15, 6, 4256, 2832), white: None },
// 6 and 4 files; masked: right up to 32
MeasuredBody { model: "NIKON D300", size: (4352, 2868), bits: 12, crop: (15, 10, 4288, 2848), white: None },
MeasuredBody { model: "NIKON D300", size: (4352, 2868), bits: 14, crop: (15, 10, 4288, 2848), white: None },
MeasuredBody { model: "NIKON D300S", size: (4352, 2868), bits: 12, crop: (15, 10, 4288, 2848), white: None },
MeasuredBody { model: "NIKON D300S", size: (4352, 2868), bits: 14, crop: (15, 10, 4288, 2848), white: None },
// D2X (1) and D2Xs (2) as siblings; dark: right 1
MeasuredBody { model: "NIKON D2Xs", size: (4320, 2868), bits: 12, crop: (16, 10, 4288, 2848), white: None },
// D3000, D40X, D60, D80: one file each, siblings; masked: right 4, bottom 3
MeasuredBody { model: "NIKON D3000", size: (3904, 2616), bits: 12, crop: (14, 9, 3872, 2592), white: None },
MeasuredBody { model: "NIKON D40X", size: (3904, 2616), bits: 12, crop: (14, 9, 3872, 2592), white: None },
MeasuredBody { model: "NIKON D60", size: (3904, 2616), bits: 12, crop: (14, 9, 3872, 2592), white: None },
MeasuredBody { model: "NIKON D80", size: (3904, 2616), bits: 12, crop: (14, 9, 3872, 2592), white: None },
// 3 files; masked: left 1, right 3 (the D80's data size, framed elsewhere)
MeasuredBody { model: "NIKON D200", size: (3904, 2616), bits: 12, crop: (16, 12, 3872, 2592), white: None },
// 22 files; masked: right 46
MeasuredBody { model: "NIKON D3200", size: (6080, 4012), bits: 12, crop: (8, 6, 6016, 4000), white: None },
// 8 clean of 19, the rest ~0.3 px between pixels on the same frame; dark: bottom 2
MeasuredBody { model: "NIKON 1 V1", size: (3904, 2606), bits: 12, crop: (12, 6, 3872, 2592), white: None },
// Best guesses, short of the evidence rule; every frame below keeps the masked lines out by several pixels.
// D3100: 3 of 4 clean files at (24, 6), the 4th at (22, 7); masked: left 6, right 22
MeasuredBody { model: "NIKON D3100", size: (4672, 3084), bits: 12, crop: (24, 6, 4608, 3072), white: None },
// D5000: 4 of 5 clean files at (11, 10), the 5th at (11, 12); the D90's sensor, whose own files also align at
// x = 11; masked: right 42 (the same filler as the D90's)
MeasuredBody { model: "NIKON D5000", size: (4352, 2868), bits: 12, crop: (11, 10, 4288, 2848), white: None },
// D40: 2 of 2 clean files at (16, 9); masked: right 1. The D50 / D70 / D70s share its data size but not its frame
MeasuredBody { model: "NIKON D40", size: (3040, 2014), bits: 12, crop: (16, 9, 3008, 2000), white: None },
// D50, D70, D70s (one readout): one clean D50 and one clean D70 file at (15, 7); the D70s's 2 files align
// between pixels next to it, at (15.4, 6.9) and (15.6, 6.4); masked: right 1
MeasuredBody { model: "NIKON D50", size: (3040, 2014), bits: 12, crop: (15, 7, 3008, 2000), white: None },
MeasuredBody { model: "NIKON D70", size: (3040, 2014), bits: 12, crop: (15, 7, 3008, 2000), white: None },
MeasuredBody { model: "NIKON D70s", size: (3040, 2014), bits: 12, crop: (15, 7, 3008, 2000), white: None },
// 3 files; dark: bottom 4
MeasuredBody { model: "NIKON D4S", size: (4936, 3288), bits: 12, crop: (4, 4, 4928, 3280), white: None },
MeasuredBody { model: "NIKON D4S", size: (4936, 3288), bits: 14, crop: (4, 4, 4928, 3280), white: None },
// 4 files; dark: bottom up to 4
MeasuredBody { model: "NIKON D5600", size: (6016, 4016), bits: 12, crop: (8, 8, 6000, 4000), white: None },
MeasuredBody { model: "NIKON D5600", size: (6016, 4016), bits: 14, crop: (8, 8, 6000, 4000), white: None },
// 4 files each; masked: right 8
MeasuredBody { model: "NIKON D600", size: (6080, 4028), bits: 12, crop: (8, 6, 6016, 4016), white: None },
MeasuredBody { model: "NIKON D600", size: (6080, 4028), bits: 14, crop: (8, 6, 6016, 4016), white: None },
MeasuredBody { model: "NIKON D610", size: (6080, 4028), bits: 12, crop: (8, 6, 6016, 4016), white: None },
MeasuredBody { model: "NIKON D610", size: (6080, 4028), bits: 14, crop: (8, 6, 6016, 4016), white: None },
// D800: 6 files; D800E: 2, with the D800 as sibling; masked: right 14 (issue #914, the top in portrait)
MeasuredBody { model: "NIKON D800", size: (7424, 4924), bits: 12, crop: (8, 6, 7360, 4912), white: None },
MeasuredBody { model: "NIKON D800", size: (7424, 4924), bits: 14, crop: (8, 6, 7360, 4912), white: None },
MeasuredBody { model: "NIKON D800E", size: (7424, 4924), bits: 14, crop: (8, 6, 7360, 4912), white: None },
// 3 files; dark: bottom 1
MeasuredBody { model: "NIKON D810", size: (7380, 4928), bits: 12, crop: (12, 8, 7360, 4912), white: None },
MeasuredBody { model: "NIKON D810", size: (7380, 4928), bits: 14, crop: (12, 8, 7360, 4912), white: None },
// 6 files; masked: left 2, right 42 (the D4's readout)
MeasuredBody { model: "NIKON Df", size: (4992, 3292), bits: 12, crop: (8, 6, 4928, 3280), white: None },
MeasuredBody { model: "NIKON Df", size: (4992, 3292), bits: 14, crop: (8, 6, 4928, 3280), white: None },
];
fn measured_body(model: &str, w: usize, h: usize, bits: u32) -> Option<&'static MeasuredBody> {
@@ -297,6 +374,22 @@ mod tests {
/// [`nef_with_note`] in either byte order (file and maker note).
#[allow(clippy::too_many_arguments)]
fn nef_in(order: ByteOrder, compression: u16, bits: u16, strips: Vec<Vec<u8>>, w: u32, h: u32, rps: u32, note: Option<IfdBuilder>) -> Vec<u8> {
nef_model_in(order, "NIKON TEST", compression, bits, strips, w, h, rps, note)
}
/// [`nef_in`] from camera `model`.
#[allow(clippy::too_many_arguments)]
fn nef_model_in(
order: ByteOrder,
model: &str,
compression: u16,
bits: u16,
strips: Vec<Vec<u8>>,
w: u32,
h: u32,
rps: u32,
note: Option<IfdBuilder>,
) -> Vec<u8> {
let mut raw = IfdBuilder::new();
raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
raw.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
@@ -309,7 +402,7 @@ mod tests {
raw.set_image(ImageData::Strips { rows_per_strip: rps, strips });
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::MAKE, Value::Ascii("NIKON CORPORATION".into()));
ifd0.set(t::MODEL, Value::Ascii("NIKON TEST".into()));
ifd0.set(t::MODEL, Value::Ascii(model.into()));
if let Some(mn) = note {
let mut bytes = b"Nikon\0\x02\x10\0\0".to_vec();
bytes.extend(TiffWriter::new(order, false).write(&[mn]).unwrap());
@@ -457,9 +550,76 @@ mod tests {
assert_eq!((d4.metadata.width, d4.metadata.height), (Some(4928), Some(3280)));
let developed = d4.develop(crate::Method::Bilinear).unwrap();
assert_eq!((developed.width, developed.height), (4928, 3280));
for other in [decode_as("NIKON D5", 14), decode_as("NIKON D4", 12)] {
assert_eq!(other.crop, Rect::new(0, 0, w, h));
assert_ne!(other.white, vec![15520.0]);
// the 12-bit mode reads out the same area but keeps the white level from its data
let d4_12 = decode_as("NIKON D4", 12);
assert_eq!(d4_12.crop, Rect::new(8, 6, 4928, 3280));
assert_ne!(d4_12.white, vec![15520.0]);
let other = decode_as("NIKON D5", 14);
assert_eq!(other.crop, Rect::new(0, 0, w, h));
assert_ne!(other.white, vec![15520.0]);
}
/// Issue #976: the D7000 and D5100 record no CropArea, and their 36 masked columns at ~600 were developed as a
/// coloured bar. Their measured area applies to the measured models, data sizes and depths only.
#[test]
fn measured_d7000_and_d5100_image_area() {
let (w, h) = (4992usize, 3280usize);
let decode_as = |model: &str, bits: u16| {
let nef = nef_model_in(ByteOrder::Big, model, 1, bits, vec![vec![0x10; w * h * 2]], w as u32, h as u32, h as u32, None);
crate::decode(&nef).unwrap()
};
for (model, bits) in [("NIKON D7000", 14), ("NIKON D7000", 12), ("NIKON D5100", 14)] {
let raw = decode_as(model, bits);
assert_eq!(raw.crop, Rect::new(8, 8, 4928, 3264), "{model} {bits}-bit");
assert_eq!((raw.metadata.width, raw.metadata.height), (Some(4928), Some(3264)));
let developed = raw.develop(crate::Method::Bilinear).unwrap();
assert_eq!((developed.width, developed.height), (4928, 3264));
}
// another model, or the D5100's unmeasured 12-bit mode, keeps the whole data
for (model, bits) in [("NIKON D7100", 14), ("NIKON D5100", 12), ("NIKON D7000 ", 16)] {
assert_eq!(decode_as(model, bits).crop, Rect::new(0, 0, w, h), "{model} {bits}-bit");
}
}
/// The D200, D3200 and Nikon 1 V1 keep masked or dark lines in their whole data (the D200 shares the D80's data
/// size but frames it elsewhere), measured on public sample files beyond the CC0 ones; the D3100, D5000, D40, D50,
/// D70 and D70s get best-guess frames (the D40 shares the D50 / D70 / D70s data size but not their frame).
#[test]
fn measured_older_dx_bodies_image_area() {
let decode_as = |model: &str, (w, h): (usize, usize)| {
let nef = nef_model_in(ByteOrder::Big, model, 1, 12, vec![vec![0x10; w * h * 2]], w as u32, h as u32, h as u32, None);
crate::decode(&nef).unwrap()
};
for (model, size, crop) in [
("NIKON D200", (3904, 2616), Rect::new(16, 12, 3872, 2592)),
("NIKON D80", (3904, 2616), Rect::new(14, 9, 3872, 2592)),
("NIKON D3200", (6080, 4012), Rect::new(8, 6, 6016, 4000)),
("NIKON 1 V1", (3904, 2606), Rect::new(12, 6, 3872, 2592)),
("NIKON D3100", (4672, 3084), Rect::new(24, 6, 4608, 3072)),
("NIKON D5000", (4352, 2868), Rect::new(11, 10, 4288, 2848)),
("NIKON D40", (3040, 2014), Rect::new(16, 9, 3008, 2000)),
("NIKON D50", (3040, 2014), Rect::new(15, 7, 3008, 2000)),
("NIKON D70", (3040, 2014), Rect::new(15, 7, 3008, 2000)),
("NIKON D70s", (3040, 2014), Rect::new(15, 7, 3008, 2000)),
] {
assert_eq!(decode_as(model, size).crop, crop, "{model}");
}
// the V1's area on the V2's data size: no entry, the whole data
assert_eq!(decode_as("NIKON 1 V1", (4620, 3084)).crop, Rect::new(0, 0, 4620, 3084));
}
/// Every measured entry is a crop inside its data, once per model, data size and depth, with a white level (if
/// any) inside its bit range.
#[test]
fn measured_bodies_are_consistent() {
let mut seen = std::collections::HashSet::new();
for b in MEASURED_BODIES {
let (x, y, w, h) = b.crop;
assert!(w > 0 && h > 0 && x + w <= b.size.0 && y + h <= b.size.1, "{}: crop outside its data", b.model);
assert!(seen.insert((b.model, b.size, b.bits)), "{} {:?} {}-bit listed twice", b.model, b.size, b.bits);
assert!(matches!(b.bits, 12 | 14), "{}: {}-bit", b.model, b.bits);
assert!(b.white.is_none_or(|v| v > 0.0 && v < (1u32 << b.bits) as f32), "{}: white level", b.model);
assert_eq!(b.model.trim(), b.model, "{}: model names are matched trimmed", b.model);
}
}
+4 -2
View File
@@ -1,6 +1,6 @@
# Gaps: where LightCraft falls short of Lightroom
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (catalog import result, offline Local promotion and reduced copies; gap 8: Nikon Z 6II / Z 8 / Z f in-file distortion; gaps 10, 22 and 23 listed once again: merges had left two copies of each, and `cargo xtask parity` now fails on a gap listed twice)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (Nikon edge bars on bodies without CropArea fixed, #976 / #914 / #1236; gap 10 notes the survey; catalog import result, offline Local promotion and reduced copies; gap 8: Nikon Z 6II / Z 8 / Z f in-file distortion; gaps 10, 22 and 23 listed once again: merges had left two copies of each, and `cargo xtask parity` now fails on a gap listed twice)
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
@@ -24,7 +24,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
| 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, 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, 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; Nikon crops of bodies without CropArea now surveyed and measured ([raw-parity](raw-parity.md#variant-matrix-depth-per-format), D3100 / D5000 / D40 / D50 / D70 / D70s on best-guess frames until more samples confirm them) | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) |
| 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) |
| 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) |
| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; offline record counts and destination are now shown in a lasting import result | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) |
@@ -59,6 +59,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
## Closed recently (kept for calibration)
- 2026-10-14: Nikon edge bars on bodies without CropArea (#976, #914, #1236): 44 more model / size / depth groups measured (6 on best-guess frames).
- 2026-10-10: Nikon lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map / PQ AVIF / float
TIFF export (#646), activity stack (#345), JPEG XL export (#247).
- 2026-10-09: EOS R7 C-RAW with an odd-height QP map (a45c2b66; part of gap 2), HEIC in release builds (#609), Samsung uncompressed SRW (#459), contact-sheet PDF (#563),
@@ -81,6 +82,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
| 2026-10-12 | minor | Gap 25: Render to DNG lands as an export format; lossy DNG and AVIF import remain |
| 2026-10-12 | minor | Gap 10 shrinks: the Hasselblad Lunar (a Sony NEX-7 ARW under its own Make) decodes and is verified, 236 models (#1275) |
| 2026-10-12 | minor | Gap 14: HLG AVIF export (`hdrTransfer`) and a tested SDR/HDR display decision |
| 2026-10-14 | minor | Nikon edge bars on bodies without CropArea fixed for 44 more model / size / depth groups (#976, #914, #1236); gap 10 notes the survey and the best-guess bodies; gap not closed |
| 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap |
| 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed |
| 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h |
File diff suppressed because one or more lines are too long
+5 -2
View File
@@ -1,6 +1,6 @@
# RAW parity: cameras, formats, demosaic, colour and lens profiles
> **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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Nikon image areas of bodies without CropArea surveyed and measured, issues #976 / #914 / #1236; NEF decode gaps listed; 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
@@ -93,7 +93,7 @@ committed table is stale.
|---|---:|---:|---:|---|
| Canon | 33% | 73% | 19% | 18 of the 22 popular bodies write CR3; 4 of them verified (M50, R8, R100, R5 Mark II), the rest at 0.6 |
| Sony | 27% | 98% | 91% | bulk-checked |
| Nikon | 16% | 91% | 12% | decoders cover every NEF coding but HE; few bodies verified |
| Nikon | 16% | 91% | 12% | decoders cover NEF codings but HE and the compressed NEFs without a linearization table (D100, D1X, Coolpix P330 / P7700 / P7800, D4S and D810 small raw); few bodies verified; image area of every body without `CropArea` surveyed |
| Fujifilm | 10% | 91% | 45% | |
| Panasonic | 4% | 100% | 98% | bulk-checked |
| OM / Olympus | 3% | 3% | 1% | compressed ORF |
@@ -125,6 +125,8 @@ checked, and Canon, the largest bucket, is the weakest of the big four.
| NEF | uncompressed, lossless, lossy type 1 / 2, lossy after split | ✅ | D5100, D7000, D7500, Z f corpus; ~60 NEFs by hand; #633; D4 and D90 image area and white level measured against the camera JPEG |
| NEF | packed 14-bit stored under a compressed tag (Z 6II uncompressed) | ⬜ | #769; fix in PR #671 (not merged) |
| NEF | High Efficiency / HE★ (JPEG XS) | ⬜ | named, preview (#193); needs a JPEG XS decoder |
| NEF | compressed without a linearization table (D100, D1X, Coolpix P330 / P7700 / P7800 NRW, D4S and D810 small raw) | ⬜ | refused as unsupported; one raw.pixls.us sample each |
| NEF | image area of bodies without `CropArea` | 🟡 | 47 model / size / depth groups cropped to their measured camera frame (D800 #914, D7000 #976, Df, D3, D300, D600, D3200 …); the D3100, D5000, D40, D50, D70 and D70s on best-guess frames until more samples confirm them (evidence in each entry's comment, `MEASURED_BODIES` in `crates/raw/src/vendor/nef.rs`). Checked and needing no entry, their data holding only picture beyond the camera frame: D750, D5300, D5500, D3300, D2X, D2Hs, D3X, D5200, Coolpix P6000, Nikon 1 AW1 / J3 / V2 / V3 and the D800's DX / 1.2x / 5:4 modes. `cargo xtask raw-frame FILE…` measures a body |
| ARW | uncompressed, cRAW (ARW2), lossless LJ92 (L / M / S) | ✅ | 86 bodies (#535); M/S black level 1024; the Hasselblad Lunar, a NEX-7 under Make HASSELBLAD, routed by Sony's raw coding (#1275) |
| ARW | packed 12-bit (A900) | ✅ | one CC0 file; A850 unmeasured |
| ARW | ARW6 Compressed / HQ (compression 32766; ILCE-7RM6) | ✅ | 8 CC0 files; 12-bit mode unsupported; `docs/arw6-compression.md` |
@@ -278,6 +280,7 @@ Beyond decoding:
| 2026-10-12 | minor | Olympus E-10: as-shot white balance (`0x1017`/`0x1018`) and black level (`0x1012` in `0x102c` ValidBits units) from the main maker note, issue #1273; ΔE76 against its thumbnail 18.5 → 11.5, camera-look fit now accepted; counts unchanged |
| 2026-10-13 | minor | Nikon D90 image area (4288 × 2848 at 10, 10) and white level (3830) measured on 10 of 550 NEFs from one body: its 42 filler columns no longer develop as a pink band |
| 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-14 | minor | Nikon: every body without `CropArea` on raw.pixls.us surveyed; 44 more model / size / depth groups cropped to their measured camera frame (#976, #914, #1236), 6 of them on best-guess frames; NEF row no longer claims every coding but HE decodes (compressed NEFs without a linearization table listed) |
| 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 |
+7
View File
@@ -2,6 +2,13 @@
## 2026-10-14
### Nikon raws without coloured edge bars
- Raws from older Nikon bodies no longer show a thin coloured or dark bar along one edge (the right edge, or the top
of a portrait photo): masked sensor columns were being developed as part of the picture (issues #976, #914,
#1236). The D7000, D5100, D800, D800E, Df, D4, D3, D3S, D700, D300, D300S, D600, D610, D810, D4S, D5600,
Coolpix A, D3000, D40X, D60, D80, D2Xs, D200, D3200, D3100, D5000, D40, D50, D70, D70s and Nikon 1 V1 now show
the area the camera itself frames, a few pixels smaller than before.
### Hard-edged Heal and Clone spots
- A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity
changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged.
+20
View File
@@ -37,6 +37,11 @@ commands:
corpus [--download]
show where test corpora live; --download fetches PngSuite and CC0 raw samples (raw.pixls.us) into corpus/ and checks their sha256
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
raw-frame [--json] FILE…
measure where each raw's embedded camera JPEG sits in its sensor data, which edge bands hold no
picture and what the decoder's crop keeps beyond the camera frame (any make with a full-size
embedded JPEG); print image-area entries for Nikon bodies that record no crop (MEASURED_BODIES in
crates/raw/src/vendor/nef.rs)
";
fn main() -> ExitCode {
@@ -54,6 +59,7 @@ fn main() -> ExitCode {
Some("ci") => cmd_ci(),
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
Some("raw-frame") => cmd_raw_frame(&rest),
Some("-h" | "--help" | "help") | None => {
print!("{USAGE}");
Ok(())
@@ -69,6 +75,20 @@ fn main() -> ExitCode {
}
}
/// `cargo xtask raw-frame FILE…`: runs the `raw_frame` example of `lightcraft-engine` on the files.
fn cmd_raw_frame(files: &[&str]) -> Result<(), String> {
if files.is_empty() {
return Err("raw-frame needs one or more raw files (several per camera, with detail across the frame)".into());
}
eprintln!("$ cargo run --release -p lightcraft-engine --example raw_frame -- {}", files.join(" "));
let status = cargo()
.args(["run", "--release", "-q", "-p", "lightcraft-engine", "--example", "raw_frame", "--"])
.args(files)
.status()
.map_err(|e| format!("raw_frame: {e}"))?;
if status.success() { Ok(()) } else { Err("raw_frame failed".into()) }
}
/// Workspace root (parent of the xtask crate).
pub fn root() -> PathBuf {
// `cargo run` sets it at run time; prefer that over the compile-time value, which goes stale