collective

Browse / github.com/storytold/lightcraft / patch

Issue #3191: write the DNG WhiteLevel as LONG, never RATIONAL

[Bug] WhiteLevel DNG tag is written with an incompatible data type HDR DNG files created by LightCraft contain a `WhiteLevel` tag using a data type that does not conform to the DNG specification. Observed tag: - Tag: `WhiteLevel` (`50717`) - Type written by LightCraft: `RATIONAL` - Values: `1/1, 1/1, 1/1` - Image format: three-channel, 16-bit IEEE floating-point LinearRaw According to the DNG specification, the permitted data types for `WhiteLevel` are `SHORT` or `LONG`, with one value per sample. For floating-point image data, the default white level is `1.0`. When inspecting the file with `libtiff`, the following warning is reported: ```text TIFFFetchN

Status
open
Score
0.00
Adopters
0 other operators
Author
Livia-Rasp (imported from pull request #3199, open)
Base
37b2e523ad4084991211e99b0c501dd951a98dbf
Size
+100 −8 in 2 files
Review
pending
Shared
2026-10-28
Id
7bf12c7ac29e9b528c773039

How it went

No other operator has reported using it yet.

Diff

raw · crates/raw/src/dngwrite.rs crates/raw/tests/it/dng.rs

diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs--- a/crates/raw/src/dngwrite.rs+++ b/crates/raw/src/dngwrite.rs@@ -175,6 +175,43 @@ fn rat_vec(v: &[f64]) -> Value {     Value::Rational(v.iter().map(|&x| rational(x)).collect()) } +/// `WhiteLevel` can only hold whole numbers, so float data whose white level isn't one (a float DNG+/// from another writer, say) is rescaled to white = 1 — samples, black levels and black deltas — the+/// way the DNG spec expects float data to be stored. `None` when the white levels already fit.+fn float_white_rescaled(raw: &RawImage) -> Result<Option<RawImage>> {+    let RawData::F32(data) = &raw.data else { return Ok(None) };+    let cpp = raw.cpp.max(1);+    let whites: Vec<f32> = (0..cpp).map(|s| raw.white_at(s)).collect();+    if whites.iter().all(|w| w.is_finite() && *w >= 1.0 && w.fract() == 0.0 && *w <= u32::MAX as f32) {+        return Ok(None);+    }+    if whites.iter().any(|w| !w.is_finite() || *w <= 0.0) {+        return Err(RawError::Corrupt(format!("float raw with white level {whites:?}")));+    }+    let uniform = whites.iter().all(|w| *w == whites[0]);+    let b = &raw.black;+    if !uniform && (!b.delta_h.is_empty() || !b.delta_v.is_empty()) {+        return Err(RawError::Unsupported("float raw with per-sample fractional white levels and black-level deltas".into()));+    }+    let white = |i: usize| whites.get(i % cpp).copied().unwrap_or(1.0);+    let n = b.repeat_rows.max(1).saturating_mul(b.repeat_cols.max(1)).saturating_mul(cpp);+    let values = if uniform {+        b.values.iter().map(|v| v / whites[0]).collect()+    } else {+        (0..n).map(|i| b.values.get(i).or_else(|| b.values.first()).copied().unwrap_or(0.0) / white(i)).collect()+    };+    let mut out = raw.clone();+    out.data = RawData::F32(data.iter().enumerate().map(|(i, v)| v / white(i)).collect());+    out.black = crate::BlackLevel {+        values,+        delta_h: b.delta_h.iter().map(|v| v / whites[0]).collect(),+        delta_v: b.delta_v.iter().map(|v| v / whites[0]).collect(),+        ..b.clone()+    };+    out.white = vec![1.0; cpp];+    Ok(Some(out))+}+ /// Serialise `raw` as a DNG. pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {     raw.validate()?;@@ -186,6 +223,14 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {         }         _ => raw,     };+    let rescaled;+    let raw = match float_white_rescaled(raw)? {+        Some(r) => {+            rescaled = r;+            &rescaled+        }+        None => raw,+    };     let (w, h, cpp) = (raw.width, raw.height, raw.cpp);     let mut ifd = IfdBuilder::new();     ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));@@ -241,12 +286,9 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {     if !b.delta_v.is_empty() {         ifd.set(t::BLACK_LEVEL_DELTA_V, Value::SRational(b.delta_v.iter().map(|&v| srational(v as f64)).collect()));     }+    // SHORT or LONG only (DNG 1.7): a RATIONAL here is ignored by libtiff and stricter readers     let white: Vec<u32> = (0..cpp).map(|s| raw.white_at(s).round().clamp(0.0, u32::MAX as f32) as u32).collect();-    if matches!(raw.data, RawData::F32(_)) {-        ifd.set(t::WHITE_LEVEL, rat_vec(&(0..cpp).map(|s| raw.white_at(s) as f64).collect::<Vec<_>>()));-    } else {-        ifd.set(t::WHITE_LEVEL, Value::Long(white));-    }+    ifd.set(t::WHITE_LEVEL, Value::Long(white));     let a = raw.active_area;     ifd.set(t::ACTIVE_AREA, Value::Long(vec![a.y as u32, a.x as u32, (a.y + a.height) as u32, (a.x + a.width) as u32]));     ifd.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![raw.crop.x as u32, raw.crop.y as u32]));@@ -598,4 +640,52 @@ mod tests {         assert_ne!(hostile, write_dng(&raw, &opts(256)).expect("a normal tile writes"), "the clamp is not a no-op");         assert_eq!(crate::probe(&hostile), Some(RawFormat::Dng), "the clamped output is still a DNG");     }++    fn white_level(dng: &[u8]) -> Value {+        let tiff = lightcraft_tiff::Tiff::parse(dng).expect("the DNG parses");+        tiff.ifds[0].value(t::WHITE_LEVEL).cloned().expect("WhiteLevel is written")+    }++    /// #3191: float DNGs (HDR merge, Render to DNG) wrote `WhiteLevel` as RATIONAL, which the DNG+    /// spec doesn't allow and libtiff ignores ("Incompatible type for WhiteLevel").+    #[test]+    fn float_white_level_is_written_as_long() {+        let dng = write_rendered_dng(2, 2, &[0.25; 12], &lightcraft_color::SRGB, &Default::default(), None).expect("writes");+        assert_eq!(white_level(&dng), Value::Long(vec![1, 1, 1]));+    }++    /// A float white level that isn't a whole number can't be stored as LONG, so the samples and black+    /// level are rescaled to white = 1 and decode to the same normalized values.+    #[test]+    fn a_fractional_float_white_level_is_rescaled_to_one() {+        let full = Rect::new(0, 0, 2, 1);+        let raw = RawImage {+            format: RawFormat::Dng,+            width: 2,+            height: 1,+            cpp: 1,+            data: RawData::F32(vec![0.05, 0.4]),+            cfa: None,+            bits: 32,+            black: BlackLevel::uniform(0.05),+            white: vec![0.5],+            active_area: full,+            crop: full,+            orientation: lightcraft_geom::Orientation::Normal,+            color: crate::ColorData::default(),+            wb_multipliers: None,+            linearized: false,+            pixel_aspect: 1.0,+            opcodes: OpcodeLists::default(),+            metadata: Default::default(),+            lens_metadata: Default::default(),+        };+        let dng = write_dng(&raw, &DngWriteOptions { compression: DngCompression::Deflate { tile: 16, half: false }, ..Default::default() })+            .expect("writes");+        assert_eq!(white_level(&dng), Value::Long(vec![1]));+        let back = crate::decode(&dng).expect("decodes");+        let RawData::F32(got) = &back.data else { panic!("float data expected") };+        assert_eq!(got, &vec![0.1, 0.8]);+        assert!((back.black.values[0] - 0.1).abs() < 1e-6, "black rescaled with the data: {:?}", back.black.values);+    } }diff --git a/crates/raw/tests/it/dng.rs b/crates/raw/tests/it/dng.rs--- a/crates/raw/tests/it/dng.rs+++ b/crates/raw/tests/it/dng.rs@@ -439,12 +439,14 @@ fn non_finite_float_samples_decode_and_develop() {     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+        // NaN and −∞ read as black, +∞ as the white level; the fractional white is stored as 1 with the+        // samples rescaled by it (`WhiteLevel` is LONG only, #3191), bit-exactly         let RawData::F32(got) = &img.data else { panic!("float data expected") };+        assert_eq!(img.white, vec![1.0]);         for (a, b) in vals.iter().zip(got) {             let want = match *a {-                f32::INFINITY => 0.75,-                v if v.is_finite() => v,+                f32::INFINITY => 1.0,+                v if v.is_finite() => v / 0.75,                 _ => 0.0,             };             assert_eq!(want.to_bits(), b.to_bits(), "{a} read as {b}");