From 2dad18d2ecdb590e99f64f7cb2e0919fe7568c07 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Tue, 13 Oct 2026 05:15:31 -0400 Subject: [PATCH] LR-RAW-NEF: stretch non-square pixels (D1X/D1H) to square The D1X/D1H raw SubIFD is 4028x1324 with XResolution 600 / YResolution 300: the pixel is twice as tall as it is wide. Nothing read that, so the picture came out about 3:1. Add a pixel aspect to RawImage/RawInfo, read it from the raw IFD resolution ratio (NEF) and from DefaultScale (DNG), accept only 1.5..4, and resample the rows after demosaic. developed_size() reports the stretched size, binning is off for non-square pixels, and the catalog and lens-profile sizing use developed_size(). --- crates/engine/src/camera_preview.rs | 3 ++ crates/engine/src/files.rs | 6 ++-- crates/engine/src/lens_profiles.rs | 3 +- crates/engine/src/tests_xmp.rs | 1 + crates/merge/src/output.rs | 1 + crates/raw/src/binned.rs | 3 +- crates/raw/src/color.rs | 1 + crates/raw/src/dng.rs | 19 ++++++++++++ crates/raw/src/dngwrite.rs | 1 + crates/raw/src/lib.rs | 46 ++++++++++++++++++++++++++--- crates/raw/src/saturation.rs | 1 + crates/raw/src/vendor/arw.rs | 1 + crates/raw/src/vendor/cr2.rs | 1 + crates/raw/src/vendor/cr2/sraw.rs | 1 + crates/raw/src/vendor/cr3.rs | 1 + crates/raw/src/vendor/kodak.rs | 1 + crates/raw/src/vendor/nef.rs | 14 ++++++++- crates/raw/src/vendor/orf.rs | 1 + crates/raw/src/vendor/pef.rs | 1 + crates/raw/src/vendor/raf.rs | 1 + crates/raw/src/vendor/rw2.rs | 1 + crates/raw/src/vendor/srw.rs | 1 + crates/raw/tests/dng.rs | 1 + crates/raw/tests/robust.rs | 1 + 24 files changed, 99 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 741fc391d..8fbcde9cb 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -1656,6 +1656,7 @@ mod tests { color: ColorData::default(), wb_multipliers: Some([1.0; 3]), linearized: true, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: lightcraft_meta::Metadata::default(), }; @@ -1999,6 +2000,7 @@ mod tests { color, wb_multipliers: Some([2.0, 1.0, 1.5]), linearized: false, + pixel_aspect: 1.0, opcodes: Default::default(), metadata: lightcraft_raw::Metadata { make: Some(make.to_owned()), model: Some(model.to_owned()), ..Default::default() }, } @@ -3125,6 +3127,7 @@ mod tests { color: ColorData::default(), wb_multipliers: Some([1.0; 3]), linearized: true, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: lightcraft_meta::Metadata { make: Some("SONY".into()), model: Some("ILCE-7M4".into()), ..Default::default() }, }; diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index dc79b8b31..4ae58ceff 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -158,10 +158,8 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { } Err(Err(e)) => return Err(e), }; - let (mut w, mut h) = (raw.crop.width.max(1) as u32, raw.crop.height.max(1) as u32); - if w <= 1 || h <= 1 { - (w, h) = (raw.active_area.width as u32, raw.active_area.height as u32); - } + let (w, h) = raw.developed_size(); + let (mut w, mut h) = (w.max(1) as u32, h.max(1) as u32); if raw.orientation.swaps_axes() { std::mem::swap(&mut w, &mut h); } diff --git a/crates/engine/src/lens_profiles.rs b/crates/engine/src/lens_profiles.rs index 1894f7189..6338b3b40 100644 --- a/crates/engine/src/lens_profiles.rs +++ b/crates/engine/src/lens_profiles.rs @@ -64,8 +64,7 @@ fn frame_crop_factor(camera: Option, focal: f64, focal_35mm: Option) - pub fn for_raw(raw: &lightcraft_raw::RawInfo, meta: impl FnOnce() -> lightcraft_meta::Metadata) -> Option { crate::files::embedded_lens(raw).or_else(|| { let db = database()?; - let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height); - let (w, h) = if c.width == 0 || c.height == 0 { (raw.active_area.width, raw.active_area.height) } else { (c.width, c.height) }; + let (w, h) = raw.developed_size(); lookup(db, &meta(), w as f64, h as f64, raw.orientation) }) } diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 58d0d7a14..386fd7c1d 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -584,6 +584,7 @@ pub(crate) fn synthetic_dng_of(w: usize, h: usize, cfa: &str, data: Vec, xm }, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/merge/src/output.rs b/crates/merge/src/output.rs index 8128644b6..ed24e636e 100644 --- a/crates/merge/src/output.rs +++ b/crates/merge/src/output.rs @@ -107,6 +107,7 @@ pub fn write_linear_dng( color: cd, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: meta, }; diff --git a/crates/raw/src/binned.rs b/crates/raw/src/binned.rs index 674651c90..0ad9adb3d 100644 --- a/crates/raw/src/binned.rs +++ b/crates/raw/src/binned.rs @@ -24,7 +24,7 @@ impl RawImage { /// and B; Bayer: `k` even, so that every block has the same layout)? pub fn can_bin(&self, k: usize) -> bool { let Some(cfa) = &self.cfa else { return false }; - if k < 2 || self.cpp != 1 || !cfa.valid() || (cfa.is_bayer() && !k.is_multiple_of(2)) { + if k < 2 || self.cpp != 1 || (self.pixel_aspect - 1.0).abs() >= 0.01 || !cfa.valid() || (cfa.is_bayer() && !k.is_multiple_of(2)) { return false; } // every window position relative to the pattern @@ -163,6 +163,7 @@ mod tests { color: ColorData::default(), wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: Metadata::default(), } diff --git a/crates/raw/src/color.rs b/crates/raw/src/color.rs index a7cea813d..88081eebc 100644 --- a/crates/raw/src/color.rs +++ b/crates/raw/src/color.rs @@ -287,6 +287,7 @@ mod tests { color, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: Default::default(), metadata: Default::default(), } diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index 3c7ece61b..69185e193 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -331,6 +331,7 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result Result) -> f32 { + match scale { + Some([h, v]) if *h > 0.0 && *v > 0.0 && (1.5..=4.0).contains(&(v / h)) => (v / h) as f32, + _ => 1.0, + } +} + #[cfg(test)] mod tests { + #[test] + fn default_scale_makes_tall_pixels() { + assert_eq!(default_scale_aspect(Some(&[1.0, 2.0])), 2.0); + assert_eq!(default_scale_aspect(Some(&[1.0, 1.0])), 1.0); + assert_eq!(default_scale_aspect(Some(&[2.0, 1.0])), 1.0); + assert_eq!(default_scale_aspect(Some(&[1.0, 1.2])), 1.0); + assert_eq!(default_scale_aspect(None), 1.0); + } + use super::*; /// Store `image` the way DNG 1.7 interleaves it: row `r` of the image is written to stored row @@ -441,3 +459,4 @@ mod tests { assert!(deinterleave(&mut hostile, 8, 6, 1, 1 << 20, 1).is_err()); } } + diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index 55a96984b..b935849d1 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -419,6 +419,7 @@ pub fn write_rendered_dng( color, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: crate::OpcodeLists::default(), metadata: metadata.clone(), }; diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 120ce98e9..98b29dbd8 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -415,13 +415,27 @@ pub struct RawInfo { pub wb_multipliers: Option<[f32; 3]>, pub opcodes: OpcodeLists, pub metadata: Metadata, + /// See [`RawImage::pixel_aspect`]. + #[serde(default = "square_pixels")] + pub pixel_aspect: f32, +} + +fn square_pixels() -> f32 { + 1.0 +} + +/// Rows of a picture `height` rows tall once its pixels (taller than wide by `aspect`) are made square. +pub fn stretched_height(height: usize, aspect: f32) -> usize { + if (aspect - 1.0).abs() < 0.01 { height } else { ((height as f32 * aspect).round() as usize).max(1) } } impl RawInfo { - /// Size of the developed image before orientation: the default crop, else the active area. + /// Size of the developed image before orientation: the default crop, else the active area, with the rows + /// stretched for non-square pixels. pub fn developed_size(&self) -> (usize, usize) { let c = self.crop.clipped(self.active_area.width, self.active_area.height); - if c.width > 1 && c.height > 1 { (c.width, c.height) } else { (self.active_area.width, self.active_area.height) } + let (w, h) = if c.width > 1 && c.height > 1 { (c.width, c.height) } else { (self.active_area.width, self.active_area.height) }; + (w, stretched_height(h, self.pixel_aspect)) } } @@ -646,6 +660,10 @@ pub struct RawImage { pub wb_multipliers: Option<[f32; 3]>, /// Whether a `LinearizationTable` was applied to `data`. pub linearized: bool, + /// Height of a sensor pixel over its width (1.0 for square pixels). The D1X and D1H write 2.0: their frame is + /// 4028 x 1324 and the picture is 3:2 only once the rows are doubled. [`Self::develop`] stretches the rows by + /// this factor. + pub pixel_aspect: f32, pub opcodes: OpcodeLists, pub metadata: Metadata, } @@ -679,6 +697,7 @@ impl RawImage { wb_multipliers: self.wb_multipliers, opcodes: self.opcodes.clone(), metadata: self.metadata.clone(), + pixel_aspect: self.pixel_aspect, } } @@ -698,6 +717,7 @@ impl RawImage { wb_multipliers: self.wb_multipliers, opcodes: self.opcodes, metadata: self.metadata, + pixel_aspect: self.pixel_aspect, } } @@ -791,10 +811,16 @@ impl RawImage { pub fn finish_demosaiced(&self, mut rgb: Rgb32f) -> Rgb32f { opcodes::apply_list3(&self.opcodes.list3, &mut rgb); let c = self.develop_crop(rgb.width, rgb.height); - if c.x == 0 && c.y == 0 && c.width == rgb.width && c.height == rgb.height { + let rgb = if c.x == 0 && c.y == 0 && c.width == rgb.width && c.height == rgb.height { + rgb + } else { + rgb.into_crop(c.x, c.y, c.width, c.height) + }; + let h = stretched_height(rgb.height, self.pixel_aspect); + if h == rgb.height { return rgb; } - rgb.into_crop(c.x, c.y, c.width, c.height) + lightcraft_raster::resample::resize(&rgb, rgb.width, h, lightcraft_raster::resample::Filter::Mitchell) } /// The part of a `width × height` demosaiced picture (the active area) that [`Self::develop`] keeps: the default @@ -853,6 +879,7 @@ mod tests { color: ColorData::default(), wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: Metadata::default(), }; @@ -869,6 +896,17 @@ mod tests { // a crop that pokes out is clipped to the picture whole.crop = Rect::new(6, 4, 10, 10); assert_eq!(whole.develop_crop(8, 6), Rect::new(6, 4, 2, 2)); + + // tall pixels (D1X: 2.0) are stretched to square ones: the rows double, `developed_size` says so + let mut tall = raw.clone(); + tall.pixel_aspect = 2.0; + let d = tall.develop(Method::Bilinear).unwrap(); + assert_eq!((d.width, d.height), (5, 8)); + assert_eq!(tall.info().developed_size(), (5, 8)); + assert_eq!(raw.info().developed_size(), (5, 4)); + assert!(!tall.can_bin(2)); + assert_eq!(stretched_height(1324, 2.0), 2648); + assert_eq!(stretched_height(1324, 1.0), 1324); } #[test] diff --git a/crates/raw/src/saturation.rs b/crates/raw/src/saturation.rs index aaa0a6dff..76c1cd503 100644 --- a/crates/raw/src/saturation.rs +++ b/crates/raw/src/saturation.rs @@ -135,6 +135,7 @@ mod tests { color: ColorData::default(), wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: Metadata::default(), } diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index 59a9cf59e..c2edc9f80 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -767,6 +767,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { // makes a neutral surface magenta. WB edits remain relative to this as-shot RGB. wb_multipliers: if linear_rgb { Some([1.0; 3]) } else { wb }, linearized: false, + pixel_aspect: 1.0, // The distortion table is centred on the DNG-style default crop when the file has one: on the ILCE-7RM4A // it starts at x = 32 where Sony's crop tags (the image crop, unchanged) start at 0, and the warp was // validated against Sony's exports with that centre. The opcode's centre is in active-area coordinates, so diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index 74d93534d..6c797ec4a 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -249,6 +249,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/src/vendor/cr2/sraw.rs b/crates/raw/src/vendor/cr2/sraw.rs index 68b406238..f8ef18e32 100644 --- a/crates/raw/src/vendor/cr2/sraw.rs +++ b/crates/raw/src/vendor/cr2/sraw.rs @@ -391,6 +391,7 @@ pub(super) fn decode(bytes: &[u8], tiff: &Tiff, raw: &Ifd, mode: Mode) -> Result // the camera's white balance is already applied, as in Sony's linear YCbCr wb_multipliers: Some([1.0; 3]), linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/src/vendor/cr3.rs b/crates/raw/src/vendor/cr3.rs index 7c93e3bac..b4c3d2d54 100644 --- a/crates/raw/src/vendor/cr3.rs +++ b/crates/raw/src/vendor/cr3.rs @@ -253,6 +253,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: levels.wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/src/vendor/kodak.rs b/crates/raw/src/vendor/kodak.rs index f7f82cff8..def2e369d 100644 --- a/crates/raw/src/vendor/kodak.rs +++ b/crates/raw/src/vendor/kodak.rs @@ -115,6 +115,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 695d55712..0b7a68e6a 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -98,6 +98,16 @@ fn measured_body(model: &str, w: usize, h: usize, bits: u32) -> Option<&'static MEASURED_BODIES.iter().find(|b| b.model == model.trim() && b.size == (w, h) && b.bits == bits) } +/// Pixel height over width from the raw IFD's resolution tags: the D1X and D1H write 600 x 300 per inch for a +/// 4028 x 1324 frame, rows twice as tall as the columns are wide. Only ratios from 1.5 to 4 count (a frame +/// squashed 3:1 or 2:1), the rest is rounding or a stray tag. +pub(crate) fn pixel_aspect_of(raw: &Ifd) -> f32 { + match (raw.f64(t::X_RESOLUTION), raw.f64(t::Y_RESOLUTION)) { + (Some(x), Some(y)) if x > 0.0 && y > 0.0 && (1.5..=4.0).contains(&(x / y)) => (x / y).round() as f32, + _ => 1.0, + } +} + fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { tiff.all_ifds() .into_iter() @@ -190,9 +200,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { Some(&MeasuredBody { crop: (x, y, cw, ch), .. }) if x + cw <= active_w && y + ch <= h => Rect::new(x, y, cw, ch), _ => default_crop(mn.as_ref(), active_w, h), }; + let pixel_aspect = pixel_aspect_of(raw); let mut metadata = lightcraft_meta::from_tiff(&tiff); metadata.width = Some(crop.width as u32); - metadata.height = Some(crop.height as u32); + metadata.height = Some(crate::stretched_height(crop.height, pixel_aspect) as u32); let img = RawImage { format: RawFormat::Nef, width: w, @@ -212,6 +223,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/src/vendor/orf.rs b/crates/raw/src/vendor/orf.rs index ea7f30eb7..5a4776c39 100644 --- a/crates/raw/src/vendor/orf.rs +++ b/crates/raw/src/vendor/orf.rs @@ -329,6 +329,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/src/vendor/pef.rs b/crates/raw/src/vendor/pef.rs index abb928e68..8ce426a24 100644 --- a/crates/raw/src/vendor/pef.rs +++ b/crates/raw/src/vendor/pef.rs @@ -318,6 +318,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/src/vendor/raf.rs b/crates/raw/src/vendor/raf.rs index 7b148929d..b45cf4902 100644 --- a/crates/raw/src/vendor/raf.rs +++ b/crates/raw/src/vendor/raf.rs @@ -212,6 +212,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists { list3: super::raf_lens::corrections(&camera_name(bytes), &ifd, active), ..Default::default() }, metadata, }; diff --git a/crates/raw/src/vendor/rw2.rs b/crates/raw/src/vendor/rw2.rs index b856fbd52..c96a1310f 100644 --- a/crates/raw/src/vendor/rw2.rs +++ b/crates/raw/src/vendor/rw2.rs @@ -616,6 +616,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists { list3: distortion(ifd0, active).into_iter().collect(), ..Default::default() }, metadata, }; diff --git a/crates/raw/src/vendor/srw.rs b/crates/raw/src/vendor/srw.rs index f64b451ff..a48f944b4 100644 --- a/crates/raw/src/vendor/srw.rs +++ b/crates/raw/src/vendor/srw.rs @@ -184,6 +184,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, }; diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index 9876e349e..d9c161779 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -59,6 +59,7 @@ fn synthetic(w: usize, h: usize, cfa: Option, cpp: usize) -> RawImage { color, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, } diff --git a/crates/raw/tests/robust.rs b/crates/raw/tests/robust.rs index 7bd5a5e54..733b029b9 100644 --- a/crates/raw/tests/robust.rs +++ b/crates/raw/tests/robust.rs @@ -24,6 +24,7 @@ fn sample(comp: DngCompression) -> Vec { color: ColorData::default(), wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: Default::default(), };