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
@@ -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);+ } }@@ -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}");