From 8bac629c0fe569a5c2f5e79e62f09dbf4020805b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 00:46:33 -0500 Subject: [PATCH] Preserve CFA anchor in shared raw metadata --- crates/raw/src/arw.rs | 4 +++- crates/raw/src/dng.rs | 8 ++++++-- crates/raw/src/erf.rs | 4 +++- crates/raw/src/kodak.rs | 4 +++- crates/raw/src/lib.rs | 19 ++++++++++++++----- crates/raw/src/mef.rs | 4 +++- crates/raw/src/nef.rs | 4 +++- crates/raw/src/pef.rs | 4 +++- crates/raw/src/raf.rs | 4 +++- crates/raw/src/srw.rs | 4 +++- 10 files changed, 44 insertions(+), 15 deletions(-) diff --git a/crates/raw/src/arw.rs b/crates/raw/src/arw.rs index e95d9a0bc..639fc0032 100644 --- a/crates/raw/src/arw.rs +++ b/crates/raw/src/arw.rs @@ -721,7 +721,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result= (w * h * 2) as u64 { 14 } else { out_bits }; let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) { - (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, + (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => { + Cfa { width: 2, height: 2, pattern: p.to_vec(), origin_x: 0, origin_y: 0 } + } _ => Cfa::bayer_static("RGGB"), }; let default_black = if scale_bits >= 14 { 512.0 } else { 128.0 }; diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index 04b0b8e66..87f23f81f 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -410,9 +410,13 @@ fn decode_ifd(bytes: &[u8], tiff: &Tiff, pixels: &Ifd, main: &Ifd, mode: Mode, f // sensor origin (the crop to the active area shifts it back), so move the anchor out by the area's offset // (DNGs that LightCraft wrote before its writer followed the spec carry the sensor-origin pattern; they are marked // by a bare "LightCraft" Software tag and keep their original reading) - let at_origin = Cfa { width: cols, height: rows, pattern }; + let mut at_origin = Cfa { width: cols, height: rows, pattern, origin_x: 0, origin_y: 0 }; let legacy_writer = [raw, ifd0].iter().any(|i| i.string(t::SOFTWARE).as_deref().map(str::trim) == Some("LightCraft")); - Some(if legacy_writer { at_origin } else { at_origin.shifted(cols - active_area.x % cols, rows - active_area.y % rows) }) + if !legacy_writer { + at_origin.origin_x = active_area.x; + at_origin.origin_y = active_area.y; + } + Some(at_origin) } else { None }; diff --git a/crates/raw/src/erf.rs b/crates/raw/src/erf.rs index 1779f19ad..40190ca20 100644 --- a/crates/raw/src/erf.rs +++ b/crates/raw/src/erf.rs @@ -78,7 +78,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result 0 && *n <= limits.samples()).ok_or(RawError::Limit("image too large"))?; let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) { - (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, + (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => { + Cfa { width: 2, height: 2, pattern: p.to_vec(), origin_x: 0, origin_y: 0 } + } _ => return Err(RawError::Unsupported("Erf raw without a 2x2 CFAPattern".into())), }; let mn = maker_note(bytes, &tiff); diff --git a/crates/raw/src/kodak.rs b/crates/raw/src/kodak.rs index 40e186d6c..bd517225c 100644 --- a/crates/raw/src/kodak.rs +++ b/crates/raw/src/kodak.rs @@ -68,7 +68,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result 0 && *n <= limits.samples()).ok_or(RawError::Limit("image too large"))?; let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) { - (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, + (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => { + Cfa { width: 2, height: 2, pattern: p.to_vec(), origin_x: 0, origin_y: 0 } + } _ => return Err(RawError::Unsupported("Kodak raw without a 2x2 CFAPattern".into())), }; let curve: Vec = raw diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 016185675..9ba94db04 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -785,6 +785,11 @@ pub struct Cfa { pub height: usize, /// `width × height` colour indices, row-major, anchored at image pixel (0, 0). pub pattern: Vec, + /// The sensor-space coordinate where this periodic pattern is anchored. + #[serde(default)] + pub origin_x: usize, + #[serde(default)] + pub origin_y: usize, } impl Cfa { @@ -801,13 +806,13 @@ impl Cfa { }) }) .collect::>()?; - (p.len() == 4).then_some(Cfa { width: 2, height: 2, pattern: p }) + (p.len() == 4).then_some(Cfa { width: 2, height: 2, pattern: p, origin_x: 0, origin_y: 0 }) } /// A 2×2 Bayer layout named in code (`"RGGB"`, `"BGGR"`, `"GRBG"`, `"GBRG"`). Only for literal /// names (tested below); anything else falls back to RGGB instead of failing. #[cfg_attr(not(feature = "crw"), allow(dead_code))] pub(crate) fn bayer_static(s: &'static str) -> Cfa { - Cfa::bayer(s).unwrap_or(Cfa { width: 2, height: 2, pattern: vec![0, 1, 1, 2] }) + Cfa::bayer(s).unwrap_or(Cfa { width: 2, height: 2, pattern: vec![0, 1, 1, 2], origin_x: 0, origin_y: 0 }) } /// The Fujifilm X-Trans 6×6 layout (as commonly documented), anchored at (0, 0). pub fn xtrans() -> Cfa { @@ -822,16 +827,20 @@ impl Cfa { }) }) .collect(); - Cfa { width: 6, height: 6, pattern } + Cfa { width: 6, height: 6, pattern, origin_x: 0, origin_y: 0 } } #[inline] pub fn color_at(&self, x: usize, y: usize) -> u8 { - self.pattern[(y % self.height) * self.width + (x % self.width)] + let relative = |v: usize, origin: usize, period: usize| { + let (v, origin) = (v % period, origin % period); + if v >= origin { v - origin } else { period - (origin - v) } + }; + self.pattern[relative(y, self.origin_y, self.height) * self.width + relative(x, self.origin_x, self.width)] } /// The same pattern anchored at `(dx, dy)` of the current anchor (e.g. after cropping to an active area). pub fn shifted(&self, dx: usize, dy: usize) -> Cfa { let pattern = (0..self.height).flat_map(|y| (0..self.width).map(move |x| (x, y))).map(|(x, y)| self.color_at(x + dx, y + dy)).collect(); - Cfa { width: self.width, height: self.height, pattern } + Cfa { width: self.width, height: self.height, pattern, origin_x: 0, origin_y: 0 } } pub fn is_bayer(&self) -> bool { if self.width != 2 || self.height != 2 { diff --git a/crates/raw/src/mef.rs b/crates/raw/src/mef.rs index 90849a7bd..5cd1fb705 100644 --- a/crates/raw/src/mef.rs +++ b/crates/raw/src/mef.rs @@ -70,7 +70,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result 0 && *n <= limits.samples()).ok_or(RawError::Limit("image too large"))?; let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) { - (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, + (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => { + Cfa { width: 2, height: 2, pattern: p.to_vec(), origin_x: 0, origin_y: 0 } + } _ => return Err(RawError::Unsupported("Mef raw without a 2x2 CFAPattern".into())), }; let preview = rgb_preview(&tiff, bytes); diff --git a/crates/raw/src/nef.rs b/crates/raw/src/nef.rs index d378d5cbe..bd880ebb8 100644 --- a/crates/raw/src/nef.rs +++ b/crates/raw/src/nef.rs @@ -281,7 +281,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result= 2 && v[0] > 0.1 && v[1] > 0.1 && v[0] < 10.0 && v[1] < 10.0) .map(|v| [v[0] as f32, 1.0, v[1] as f32]); let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) { - (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, + (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => { + Cfa { width: 2, height: 2, pattern: p.to_vec(), origin_x: 0, origin_y: 0 } + } _ => Cfa::bayer_static("RGGB"), }; let model = ifd0.string(t::MODEL).unwrap_or_default(); diff --git a/crates/raw/src/pef.rs b/crates/raw/src/pef.rs index d5f14bd9a..a33750db5 100644 --- a/crates/raw/src/pef.rs +++ b/crates/raw/src/pef.rs @@ -195,7 +195,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result dark_trimmed(samples, w, hgt, black_point.as_deref().map(|v| v.iter().sum::() / 4.0), white), }; let cfa = match (ifd0.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), ifd0.bytes(t::CFA_PATTERN_EP)) { - (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, + (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => { + Cfa { width: 2, height: 2, pattern: p.to_vec(), origin_x: 0, origin_y: 0 } + } // Every body states its layout in the Exif `CFAPattern`, read from the first sample of the stored array, not // of the image area: the *ist D (origin 19, 11) and the 645D (80, 59) only fit unshifted. The 12-bit 10 MP // bodies and everything from the K-3 on say RGGB; the K-7 / K-5 / 645D generation says BGGR. Files without the diff --git a/crates/raw/src/raf.rs b/crates/raw/src/raf.rs index b92f7568f..f75fa160b 100644 --- a/crates/raw/src/raf.rs +++ b/crates/raw/src/raf.rs @@ -125,7 +125,9 @@ fn camera_name(b: &[u8]) -> String { /// The CFA: X-Trans from the (reversed) layout record, else RGGB. fn cfa(h: &Header) -> Cfa { match record(h, XTRANS_LAYOUT) { - Some(l) if l.len() == 36 && l.iter().all(|&c| c <= 2) => Cfa { width: 6, height: 6, pattern: l.iter().rev().copied().collect() }, + Some(l) if l.len() == 36 && l.iter().all(|&c| c <= 2) => { + Cfa { width: 6, height: 6, pattern: l.iter().rev().copied().collect(), origin_x: 0, origin_y: 0 } + } _ => Cfa::bayer_static("RGGB"), } } diff --git a/crates/raw/src/srw.rs b/crates/raw/src/srw.rs index 3946cbf79..c78ef1f6f 100644 --- a/crates/raw/src/srw.rs +++ b/crates/raw/src/srw.rs @@ -134,7 +134,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode, limits: &Limits) -> Result Cfa { width: 2, height: 2, pattern: p.to_vec() }, + (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => { + Cfa { width: 2, height: 2, pattern: p.to_vec(), origin_x: 0, origin_y: 0 } + } _ => return Err(RawError::Unsupported("Samsung raw without a 2x2 CFAPattern".into())), }; // saturation levels and black levels (R, G1, G2, B), in the file's own words