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:
parent
859d9492f9
commit
75feedcae0
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue