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 e211776001)
This commit is contained in:
grlessa
2026-10-13 12:41:29 +07:00
committed by bflatastic
parent 859d9492f9
commit 75feedcae0
6 changed files with 261 additions and 8 deletions
+1 -2
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);
+77 -6
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);
+138
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 {