M2.3: normalize DNG black levels independently per sample plane

Share phase-aware scales across full and binned development, guard empty
and non-finite ranges, invalidate old renders, and add synthetic/property coverage.

Co-authored-by: Codex <noreply@openai.com>
(cherry picked from commit e21177600167732fd22cbbcda70210c9f3f309f6)
This commit is contained in:
grlessa 2026-10-11 15:11:32 -03:00 committed by bflatastic
parent 859d9492f9
commit 75feedcae0
6 changed files with 261 additions and 8 deletions

View File

@ -64,8 +64,7 @@ impl RawImage {
// Opcode lists 1/2 need the normalised copy; plain files are normalised row by row.
let norm: Option<Normalized> = if self.opcodes.list1.is_empty() && self.opcodes.list2.is_empty() { None } else { Some(self.normalized()?) };
let cfa = cfa.shifted(a.x, a.y);
let range = self.white_at(0) - self.black.mean();
let scale = if range > 0.0 { 1.0 / range } else { 1.0 };
let scale = self.normalization_scales().first().copied().unwrap_or(1.0);
let uniform_black =
self.black.delta_h.is_empty() && self.black.delta_v.is_empty() && self.black.values.iter().all(|&v| v == self.black.values[0]);
let black0 = self.black.at(0, 0, 0, 1);

View File

@ -583,6 +583,35 @@ impl BlackLevel {
let base = self.values.get(idx).or_else(|| self.values.first()).copied().unwrap_or(0.0);
base + self.delta_h.get(x).copied().unwrap_or(0.0) + self.delta_v.get(y).copied().unwrap_or(0.0)
}
/// Maximum computed black of one sample plane over the active area (DNG 1.7.1, p. 99).
/// Delta maxima must share the repeat-pattern phase of the base value: independent global
/// maxima can describe a black level that occurs nowhere in the image.
fn maximum(&self, width: usize, height: usize, s: usize, cpp: usize) -> f32 {
fn phase_maxima(deltas: &[f32], len: usize, period: usize) -> Vec<f32> {
if deltas.is_empty() {
return vec![0.0; len.min(period)];
}
let mut maxima = vec![f32::NEG_INFINITY; len.min(period)];
for i in 0..len {
if let Some(v) = maxima.get_mut(i % period) {
*v = v.max(deltas.get(i).copied().unwrap_or(0.0));
}
}
maxima
}
let (r, c) = (self.repeat_rows.max(1), self.repeat_cols.max(1));
let dx = phase_maxima(&self.delta_h, width, c);
let dy = phase_maxima(&self.delta_v, height, r);
let mut max = f32::NEG_INFINITY;
for (y, &v) in dy.iter().enumerate() {
for (x, &h) in dx.iter().enumerate() {
let idx = y.saturating_mul(c).saturating_add(x).saturating_mul(cpp).saturating_add(s);
let base = self.values.get(idx).or_else(|| self.values.first()).copied().unwrap_or(0.0);
max = max.max(base + h + v);
}
}
max
}
/// Mean of the repeat-pattern values (for display / heuristics).
pub fn mean(&self) -> f32 {
if self.values.is_empty() { 0.0 } else { self.values.iter().sum::<f32>() / self.values.len() as f32 }
@ -737,6 +766,19 @@ impl RawImage {
self.white.get(s).or_else(|| self.white.first()).copied().unwrap_or(65535.0)
}
/// Shared by full-resolution and binned development. DNG's scale uses the maximum computed
/// black independently for each sample plane; vendor formats keep their existing scaling.
pub(crate) fn normalization_scales(&self) -> Vec<f32> {
let dng = matches!(self.format, RawFormat::Dng | RawFormat::CfaTiff);
(0..self.cpp)
.map(|s| {
let black = if dng { self.black.maximum(self.active_area.width, self.active_area.height, s, self.cpp) } else { self.black.mean() };
let range = self.white_at(s) - black;
if range.is_finite() && range > 0.0 && range.recip().is_finite() { range.recip() } else { 1.0 }
})
.collect()
}
/// Black-subtract, white-scale and crop to the active area, applying `OpcodeList1` (on raw values) first and
/// `OpcodeList2` (on normalised values) after. 1.0 = white level.
pub fn normalized(&self) -> Result<Normalized> {
@ -755,12 +797,7 @@ impl RawImage {
Some(v) => v[i],
None => self.data.get(i),
};
let scale: Vec<f32> = (0..cpp)
.map(|s| {
let range = self.white_at(s) - self.black.mean();
if range > 0.0 { 1.0 / range } else { 1.0 }
})
.collect();
let scale = self.normalization_scales();
let mut out = vec![0f32; a.width * a.height * cpp];
let row_len = a.width * cpp;
use rayon::prelude::*;
@ -1303,6 +1340,40 @@ mod tests {
assert_eq!(probe(&with_note(b"STMN100\0\0\0\0\0\0\0\0\0", false)), None);
}
#[test]
fn normalization_scales_fall_back_for_empty_or_non_finite_ranges() {
let mut raw = RawImage {
format: RawFormat::Dng,
width: 1,
height: 1,
cpp: 1,
data: RawData::U16(vec![0]),
cfa: None,
bits: 16,
black: BlackLevel::default(),
white: vec![1024.0],
active_area: Rect::new(0, 0, 1, 1),
crop: Rect::new(0, 0, 1, 1),
orientation: Orientation::Rotate90,
color: ColorData::default(),
wb_multipliers: None,
linearized: false,
opcodes: OpcodeLists::default(),
metadata: Metadata::default(),
};
assert_eq!(raw.normalization_scales(), vec![1.0 / 1024.0]);
for (width, height) in [(0, 1), (1, 0), (0, 0)] {
raw.active_area = Rect::new(0, 0, width, height);
assert_eq!(raw.normalization_scales(), vec![1.0], "empty area {width}×{height}");
assert!(raw.normalized().is_err());
}
raw.active_area = Rect::new(0, 0, 1, 1);
for white in [f32::INFINITY, f32::NEG_INFINITY, f32::NAN, 0.0, f32::from_bits(1)] {
raw.white = vec![white];
assert_eq!(raw.normalization_scales(), vec![1.0], "white {white}");
}
}
#[test]
fn probe_rejects_junk() {
assert_eq!(probe(b""), None);

View File

@ -166,6 +166,144 @@ fn normalized_and_develop() {
}
}
/// Linear DNG sample planes have independent black/white endpoints, even without a CFA.
#[test]
fn normalization_uses_each_linear_sample_planes_black_level() {
let (w, h) = (8usize, 6usize);
let mut ifd = base_ifd(w, h, 16, 3, false);
ifd.set(t::BLACK_LEVEL, Value::Rational(vec![(64, 1), (256, 1), (512, 1)]));
ifd.set(t::WHITE_LEVEL, Value::Long(vec![1024, 2048, 4096]));
let samples: Vec<u8> = (0..w * h).flat_map(|_| [1024u16, 2048, 4096].into_iter().flat_map(u16::to_le_bytes)).collect();
ifd.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![samples] });
let raw = decode(&dng(ifd, ByteOrder::Little)).unwrap();
let n = raw.normalized().unwrap();
for (i, &v) in n.data.iter().enumerate() {
assert!((v - 1.0).abs() < 1e-6, "white in plane {} mapped to {v}", i % 3);
}
}
/// The maximum computed black is per sample plane, including row/column deltas and their phases.
#[test]
fn normalization_uses_maximum_black_in_the_active_area() {
let mut raw = synthetic(12, 10, Cfa::bayer("RGGB"), 1);
raw.white = vec![1024.0];
raw.black = BlackLevel {
repeat_rows: 2,
repeat_cols: 2,
values: vec![64.0, 128.0, 192.0, 256.0],
delta_h: vec![100.0, -10.0, 0.0, -20.0, 0.0, -10.0, 0.0, -20.0, 900.0],
delta_v: vec![80.0, -5.0, 0.0, -10.0, 0.0, -5.0, 0.0, -10.0, 900.0],
};
// Only the first 8 columns/rows of the deltas belong to the active area. The maximum is
// 192 + 100 - 5 = 287, not max(base) + max(horizontal) + max(vertical) = 436.
assert_eq!((raw.active_area.width, raw.active_area.height), (8, 8));
let max_black = 287.0;
let (a, w) = (raw.active_area, raw.width);
let mut data = vec![0.0; w * raw.height];
for y in 0..a.height {
for x in 0..a.width {
data[(a.y + y) * w + a.x + x] = raw.black.at(x, y, 0, 1) + (1024.0 - max_black) * 0.5;
}
}
// Preserve negative shadow noise and linear headroom, as the existing Normalized API promises.
data[a.y * w + a.x] = raw.black.at(0, 0, 0, 1) - 4.0;
data[(a.y + 7) * w + a.x + 7] = raw.black.at(7, 7, 0, 1) + (1024.0 - max_black) * 1.25;
raw.data = RawData::F32(data);
let n = raw.normalized().unwrap();
assert!((n.data[0] + 4.0 / 737.0).abs() < 1e-6);
for &v in &n.data[1..63] {
assert!((v - 0.5).abs() < 1e-6, "mid-grey mapped to {v}");
}
assert!((n.data[63] - 1.25).abs() < 1e-6);
}
#[test]
fn normalization_and_binning_share_dng_scaling_and_clipping() {
let mut raw = synthetic(20, 16, Cfa::bayer("RGGB"), 1);
raw.white = vec![1024.0];
raw.black = BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![64.0, 128.0, 192.0, 256.0], ..Default::default() };
let (a, w) = (raw.active_area, raw.width);
let mut data = vec![0.0; w * raw.height];
for y in 0..a.height {
for x in 0..a.width {
data[(a.y + y) * w + a.x + x] = raw.black.at(x, y, 0, 1) + 768.0 * 0.995;
}
}
raw.data = RawData::F32(data);
let direct = raw.develop_binned_masked(2, 0.99).unwrap().unwrap();
// An identity opcode forces the binner through RawImage::normalized instead of its fast path.
raw.opcodes.list2.push(Opcode::MapPolynomial {
area: Area { top: 0, left: 0, bottom: a.height as u32, right: a.width as u32, plane: 0, planes: 1, row_pitch: 1, col_pitch: 1 },
coefficients: vec![0.0, 1.0],
});
let via_normalized = raw.develop_binned_masked(2, 0.99).unwrap().unwrap();
assert_eq!(direct, via_normalized);
for p in &direct.0.data {
assert!(p.iter().all(|v| (*v - 0.995).abs() < 1e-6), "{p:?}");
}
assert!(direct.1.iter().all(|&m| m == 7), "all three channels must be marked clipped");
let full = raw.develop(Method::Bilinear).unwrap();
assert!(full.data.iter().all(|p| p.iter().all(|v| (*v - 0.995).abs() < 1e-6)));
}
proptest::proptest! {
#[test]
fn normalization_matches_a_brute_force_black_maximum(
aw in 1usize..10, ah in 1usize..10,
br in 1usize..5, bc in 1usize..5, cpp in 1usize..4,
dh in proptest::collection::vec(-50i16..50, 0..12),
dv in proptest::collection::vec(-50i16..50, 0..12),
) {
let mut raw = synthetic(20, 16, None, cpp);
raw.active_area = Rect::new(2, 1, aw, ah);
raw.black = BlackLevel {
repeat_rows: br,
repeat_cols: bc,
values: (0..br * bc * cpp).map(|i| 100.0 + (i % 7) as f32 * 16.0).collect(),
delta_h: dh.iter().map(|&v| v as f32).collect(),
delta_v: dv.iter().map(|&v| v as f32).collect(),
};
raw.white = (0..cpp).map(|s| 2048.0 + s as f32 * 256.0).collect();
let maximum: Vec<f32> = (0..cpp).map(|s| {
let mut max = f32::NEG_INFINITY;
for y in 0..ah {
for x in 0..aw {
max = max.max(raw.black.at(x, y, s, cpp));
}
}
max
}).collect();
let mut data = vec![0.0; raw.width * raw.height * cpp];
for y in 0..ah {
for x in 0..aw {
for s in 0..cpp {
data[((y + 1) * raw.width + x + 2) * cpp + s] =
raw.black.at(x, y, s, cpp) + 0.25 * (raw.white_at(s) - maximum[s]);
}
}
}
raw.data = RawData::F32(data);
let n = raw.normalized().unwrap();
proptest::prop_assert!(n.data.iter().all(|v| (*v - 0.25).abs() < 1e-6));
}
}
#[test]
fn non_dng_normalization_keeps_the_existing_vendor_scale() {
let mut raw = synthetic(20, 16, Cfa::bayer("RGGB"), 1);
raw.format = RawFormat::Cr2;
raw.black = BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![64.0, 128.0, 192.0, 256.0], ..Default::default() };
let RawData::U16(d) = &raw.data else { unreachable!() };
let n = raw.normalized().unwrap();
for y in 0..n.height {
for x in 0..n.width {
let v = d[(y + raw.active_area.y) * raw.width + x + raw.active_area.x] as f32;
let expected = (v - raw.black.at(x, y, 0, 1)) / (raw.white_at(0) - 160.0);
assert!((n.data[y * n.width + x] - expected).abs() < 1e-6);
}
}
}
// ---------------------------------------------------------------- hand-built variants
fn base_ifd(w: usize, h: usize, bits: u16, cpp: usize, cfa: bool) -> IfdBuilder {

40
docs/dng-normalization.md Normal file
View File

@ -0,0 +1,40 @@
# DNG black-level normalization
DNG and DNG-layout CFA TIFFs use the maximum computed black level of each sample plane when
scaling black-subtracted samples. This follows the public [DNG 1.7.1 specification, p. 99](https://helpx.adobe.com/content/dam/help/en/camera-raw/digital-negative/jcr_content/root/content/flex/items/position/position-par/download_section_733958301/download-1/DNG_Spec_1_7_1_0.pdf).
The computed black includes the repeat-pattern value and the horizontal and vertical deltas,
anchored at the active area. Each linear RGB sample plane has its own black and white levels.
Previously, the scale used the mean of all repeat-pattern values and ignored the deltas. For a
linear DNG with black levels 64 / 256 / 512 and white levels 1024 / 2048 / 4096, sensor white in
the red plane became 1.286 instead of 1.0. For a CFA DNG with black levels 64 / 128 / 192 / 256
and white 1024, a normalized sample of 0.995 became 0.884, below the engine's 0.99 clipping
threshold. These are synthetic regressions, not measurements of Lightroom fidelity or of a
particular camera.
`RawImage::normalization_scales` is shared by full-resolution and binned development. It finds
the maximum over the entire active area, before the default crop, in each sample plane. Row and
column delta maxima are grouped by repeat-pattern phase: simply adding three global maxima
can produce a black level that never occurs. Work is proportional to the active area's width
and height plus the repeat-pattern size, rather than its pixel count; scaling remains a
precomputed multiplication per sample. Missing delta entries contribute zero. Negative deltas
are kept. Values outside the active area do not contribute.
The source API still preserves negative shadow noise and values above white. Vendor RAW formats
keep their existing scale. This is a DNG source-decoding correction, not a change to the develop
process or to tone-slider response (`docs/process-versions.md`). Cached thumbnails are invalidated.
Existing smart previews contain already developed pixels and must be removed and rebuilt from
their originals to receive the correction; offline proxies keep their stored rendering.
Regression coverage in `crates/raw/tests/dng.rs` checks a decoded linear DNG, repeated black with
phase-dependent deltas, cropped binned previews through both the direct and opcode paths,
clipping masks, negative noise and headroom. A property test checks the optimized maximum
against exhaustive computation for varying active sizes, repeat patterns, sample-plane counts
and signed delta arrays. A vendor-format regression preserves the previous scale.
This change was implemented from the public format specification and LightCraft's source model;
it does not incorporate code or data from other RAW developers.
A non-finite or non-positive range, an overflowing reciprocal, or an empty active area falls back to a unit scale. Empty active areas still fail validation when normalizing pixels. The unit regression `normalization_scales_fall_back_for_empty_or_non_finite_ranges` covers these cases.
The render-cache version is bumped so cached thumbnails and renders from the previous normalization are not reused. Existing offline smart-preview pixels cannot be rebuilt without their original raw.

File diff suppressed because one or more lines are too long

View File

@ -307,3 +307,7 @@ Beyond decoding:
| 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 |
### DNG normalization follow-up (2026-10-11)
The normalizer now subtracts the computed black and scales each DNG sample plane by its own white-to-maximum-black range, respecting the repeat-pattern phase and active-area deltas. Binned and full development share these scales. Synthetic and property regressions cover per-plane white levels, clipping, negative shadow noise, and retained highlight headroom. Empty areas or non-finite/unusable ranges get unit scales; empty pixel areas still return validation errors. Non-DNG vendor normalization is preserved. See [DNG normalization](dng-normalization.md). This does not establish Lightroom colour/render fidelity or wider camera coverage.