Skip to main content

async_geotiff/io/
scale_offset.rs

1//! Per-band SCALE/OFFSET from a COG's `GDAL_METADATA` XML (TIFF tag 42112).
2//!
3//! GDAL writes `<Item name="SCALE" sample="0">0.0001</Item>` /
4//! `<Item name="OFFSET" sample="0">0</Item>`. Bands without metadata default
5//! to identity `(1.0, 0.0)`.
6
7fn items(xml: &str) -> Vec<(String, usize, String)> {
8    use quick_xml::events::Event;
9
10    let mut reader = quick_xml::Reader::from_str(xml);
11    reader.config_mut().trim_text(true);
12    let mut out = Vec::new();
13    let mut current: Option<(String, usize)> = None;
14
15    loop {
16        match reader.read_event() {
17            Ok(Event::Start(e)) if e.name().as_ref() == b"Item" => {
18                let mut name = String::new();
19                let mut sample = 0usize;
20                for attr in e.attributes().flatten() {
21                    let Ok(value) = attr.decode_and_unescape_value(reader.decoder()) else {
22                        continue;
23                    };
24                    match attr.key.as_ref() {
25                        b"name" => name = value.into_owned(),
26                        b"sample" => sample = value.parse().unwrap_or(0),
27                        _ => {}
28                    }
29                }
30                current = (!name.is_empty()).then_some((name, sample));
31            }
32            Ok(Event::Text(e)) => {
33                if let Some((name, sample)) = current.take() {
34                    if let Ok(value) = e.decode() {
35                        out.push((name, sample, value.trim().to_string()));
36                    }
37                }
38            }
39            Ok(Event::End(e)) if e.name().as_ref() == b"Item" => {
40                current = None;
41            }
42            Ok(Event::Eof) => break,
43            Err(_) => break,
44            _ => {}
45        }
46    }
47    out
48}
49
50/// Per-band GDAL SCALE/OFFSET pair: `physical = raw * scale + offset`.
51#[derive(Debug, Clone, Copy, PartialEq)]
52pub struct ScaleOffset {
53    /// Raw-sample multiplier; missing metadata defaults to `1.0`.
54    pub scale: f64,
55    /// Value added after scaling; missing metadata defaults to `0.0`.
56    pub offset: f64,
57}
58
59impl ScaleOffset {
60    /// No-op transform for bands without SCALE/OFFSET metadata.
61    pub const IDENTITY: Self = Self {
62        scale: 1.0,
63        offset: 0.0,
64    };
65
66    /// Apply the affine transform to a raw sample value.
67    pub fn apply(self, raw: f64) -> f64 {
68        raw * self.scale + self.offset
69    }
70
71    /// Test exact equality with scale `1.0` and offset `0.0` (no tolerance).
72    pub fn is_identity(self) -> bool {
73        self == Self::IDENTITY
74    }
75}
76
77/// Returns `bands` entries, identity where absent.
78pub fn parse_scale_offset(gdal_metadata: Option<&str>, bands: usize) -> Vec<ScaleOffset> {
79    let parsed = gdal_metadata.map(items).unwrap_or_default();
80    let get = |name: &str, b: usize| -> Option<f64> {
81        parsed
82            .iter()
83            .find(|(n, s, _)| n == name && *s == b)
84            .and_then(|(_, _, v)| v.parse::<f64>().ok())
85    };
86    (0..bands)
87        .map(|b| ScaleOffset {
88            scale: get("SCALE", b).unwrap_or(1.0),
89            offset: get("OFFSET", b).unwrap_or(0.0),
90        })
91        .collect()
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    const XML: &str = r#"<GDALMetadata>
99      <Item name="SCALE" sample="0">0.0001</Item>
100      <Item name="OFFSET" sample="0">-0.1</Item>
101      <Item name="SCALE" sample="1">2.0</Item>
102    </GDALMetadata>"#;
103
104    #[test]
105    fn parses_per_band_scale_offset() {
106        let so = parse_scale_offset(Some(XML), 3);
107        assert_eq!(so.len(), 3);
108        assert_eq!(
109            so[0],
110            ScaleOffset {
111                scale: 0.0001,
112                offset: -0.1
113            }
114        );
115        // offset absent → 0
116        assert_eq!(
117            so[1],
118            ScaleOffset {
119                scale: 2.0,
120                offset: 0.0
121            }
122        );
123        assert_eq!(so[2], ScaleOffset::IDENTITY); // band absent → identity
124    }
125
126    #[test]
127    fn none_metadata_is_all_identity() {
128        assert_eq!(
129            parse_scale_offset(None, 2),
130            vec![ScaleOffset::IDENTITY, ScaleOffset::IDENTITY]
131        );
132    }
133
134    #[test]
135    fn malformed_xml_is_identity() {
136        assert_eq!(
137            parse_scale_offset(Some("<not xml"), 1),
138            vec![ScaleOffset::IDENTITY]
139        );
140    }
141}