LR-IMP-FORMATS: a float raw with NaN or infinite samples no longer panics the demosaic

Cause: a floating-point CFA DNG is read as stored (no check of its float
samples). AHD and PPG clamp each green estimate between its two neighbouring
greens with `est.clamp(a.min(b), a.max(b))`; when both greens are NaN the
bounds are NaN and `f32::clamp` panics (ppg.rs:45, ahd.rs green pass). Any
float DNG with two adjacent NaN greens crashed decode + develop. Infinities
alone did not panic but turned into NaN in the demosaic arithmetic.

Fix, at two layers:
- demosaic: a `clamp_between` helper replaces the three clamps. With finite
  or infinite bounds it is the same `f32::clamp` call (bit-identical
  results); with NaN bounds it returns the estimate unclamped, so the panic
  is gone whatever puts NaN into the mosaic.
- DNG decode: non-finite float samples are replaced where the white level is
  known. NaN and -inf read as black (0), +inf as the white level (a clipped
  highlight). No sensor records NaN or infinity, so a valid file is never
  touched; finite samples, negative or above white included, stay as stored.

Tests: `demosaic::tests::non_finite_samples_do_not_panic` (NaN block, +/-inf
and an all-NaN mosaic through Bilinear, PPG and AHD; plus `clamp_between`
equal to the old clamp bit for bit over finite, signed-zero and infinite
values) and `non_finite_float_samples_decode_and_develop` (a float CFA DNG,
uncompressed and Deflate, with NaN and +/-inf samples: decoded values
checked, develop with all three methods gives a finite image). Both panicked
before the fix at ppg.rs:45 / ahd.rs.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
dev-null-undefined 2026-10-11 23:48:12 +02:00
parent 76b5377868
commit 89fa4e4b59
No known key found for this signature in database
GPG Key ID: B1C4FAB94F0F1443
5 changed files with 89 additions and 3 deletions

View File

@ -77,10 +77,10 @@ fn tile(m: &Mosaic, x0: usize, y0: usize, tw: usize, th: usize) -> Vec<[f32; 3]>
}
let (l, r) = (mos[cl(x - 1, y)], mos[cl(x + 1, y)]);
let est = (l + r) * 0.5 + (2.0 * c - mos[cl(x - 2, y)] - mos[cl(x + 2, y)]) * 0.25;
gh[i] = est.clamp(l.min(r), l.max(r));
gh[i] = super::clamp_between(est, l, r);
let (u, d) = (mos[cl(x, y - 1)], mos[cl(x, y + 1)]);
let est = (u + d) * 0.5 + (2.0 * c - mos[cl(x, y - 2)] - mos[cl(x, y + 2)]) * 0.25;
gv[i] = est.clamp(u.min(d), u.max(d));
gv[i] = super::clamp_between(est, u, d);
}
}
// 2. full candidates

View File

@ -52,6 +52,14 @@ pub(crate) fn reflect(i: isize, n: usize) -> usize {
i as usize
}
/// `v` clamped between `a` and `b`: `v.clamp(a.min(b), a.max(b))`, except that it returns `v` as is when both
/// bounds are NaN (NaN samples in a float raw), where `f32::clamp` panics.
#[inline]
pub(crate) fn clamp_between(v: f32, a: f32, b: f32) -> f32 {
let (lo, hi) = (a.min(b), a.max(b));
if lo <= hi { v.clamp(lo, hi) } else { v }
}
impl Mosaic<'_> {
#[inline]
pub fn get(&self, x: isize, y: isize) -> f32 {
@ -222,6 +230,37 @@ mod tests {
assert_eq!(demosaic(&n, Method::Ahd).data, vec![[0.1, 0.2, 0.3]]);
}
#[test]
fn non_finite_samples_do_not_panic() {
// regression: NaN samples (a float DNG) gave AHD and PPG NaN bounds for `f32::clamp`, which panics
let cfa = Cfa::bayer("RGGB").unwrap();
let mut patchy = mosaic_from_rgb(&edgy_scene(24, 20), &cfa);
for (i, v) in patchy.data.iter_mut().enumerate() {
let (x, y) = (i % 24, i / 24);
if (8..12).contains(&x) && (6..10).contains(&y) {
*v = f32::NAN;
} else if i % 7 == 0 {
*v = if i % 2 == 0 { f32::INFINITY } else { f32::NEG_INFINITY };
}
}
let all_nan = Normalized { width: 9, height: 7, cpp: 1, data: vec![f32::NAN; 63], cfa: Some(cfa) };
for n in [&patchy, &all_nan] {
for m in [Method::Bilinear, Method::Ppg, Method::Ahd] {
let out = demosaic(n, m);
assert_eq!((out.width, out.height), (n.width, n.height), "{m:?}");
}
}
// without NaN bounds it is the `f32::clamp` it replaced, bit for bit (finite results stay identical)
let vals = [f32::NEG_INFINITY, -3e38, -2.5, -0.0, 0.0, 1e-40, 0.5, 1.0, 3e38, f32::INFINITY];
for v in vals {
for a in vals {
for b in vals {
assert_eq!(clamp_between(v, a, b).to_bits(), v.clamp(a.min(b), a.max(b)).to_bits(), "{v} {a} {b}");
}
}
}
}
#[test]
fn reflect_indices() {
assert_eq!(reflect(-1, 5), 1);

View File

@ -42,7 +42,7 @@ pub(crate) fn green_plane(m: &Mosaic) -> Vec<f32> {
_ => (ge, gw, ce),
};
let est = (near * 3.0 + far + c - cfar) * 0.25;
*o = est.clamp(near.min(far), near.max(far));
*o = super::clamp_between(est, near, far);
}
});
g

View File

@ -234,6 +234,16 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
if white.is_empty() {
white = vec![default_white];
}
// No sensor records NaN or infinity, so such a float sample is damage. Left in, it turns into NaN in demosaic
// arithmetic and spreads; NaN and −∞ read as black (0), +∞ as the white level (a clipped highlight). Finite
// samples, negative or above white included, are kept as stored.
if let RawData::F32(v) = &mut data {
for (i, s) in v.iter_mut().enumerate() {
if !s.is_finite() {
*s = if *s > 0.0 { white.get(i % cpp).or(white.first()).copied().unwrap_or(default_white) } else { 0.0 };
}
}
}
let dim = raw.u64s(t::BLACK_LEVEL_REPEAT_DIM).unwrap_or_default();
let (br, bc) = match dim.as_slice() {

View File

@ -378,6 +378,43 @@ fn floating_point_dngs() {
assert_eq!(decode(&dng(raw, ByteOrder::Big)).unwrap().data, RawData::F32(vals));
}
#[test]
fn non_finite_float_samples_decode_and_develop() {
// regression: NaN samples in a float CFA DNG panicked AHD and PPG (`f32::clamp` with NaN bounds)
let (w, h) = (24, 20);
let mut raw = synthetic(w, h, Some(Cfa::bayer("RGGB").unwrap()), 1);
let mut vals: Vec<f32> = (0..w * h).map(|i| ((i * 37) % 200) as f32 / 256.0).collect();
for (i, v) in vals.iter_mut().enumerate() {
let (x, y) = (i % w, i / w);
if (8..14).contains(&x) && (6..12).contains(&y) {
*v = f32::NAN;
} else if i % 11 == 0 {
*v = if i % 2 == 0 { f32::INFINITY } else { f32::NEG_INFINITY };
}
}
raw.data = RawData::F32(vals.clone());
raw.black = BlackLevel::uniform(0.0);
raw.white = vec![0.75];
for compression in [DngCompression::Uncompressed, DngCompression::Deflate { tile: 16, half: false }] {
let bytes = write_dng(&raw, &DngWriteOptions { compression, ..Default::default() }).unwrap();
let img = decode(&bytes).unwrap();
// NaN and −∞ read as black, +∞ as the white level; finite samples are kept bit-exactly
let RawData::F32(got) = &img.data else { panic!("float data expected") };
for (a, b) in vals.iter().zip(got) {
let want = match *a {
f32::INFINITY => 0.75,
v if v.is_finite() => v,
_ => 0.0,
};
assert_eq!(want.to_bits(), b.to_bits(), "{a} read as {b}");
}
for m in [Method::Bilinear, Method::Ppg, Method::Ahd] {
let rgb = img.develop(m).unwrap();
assert!(rgb.data.iter().all(|p| p.iter().all(|v| v.is_finite())), "{m:?}");
}
}
}
#[test]
fn lj92_strips_and_levels_and_linearization() {
let (w, h) = (20, 12);