Skip to main content

fits_io/image/
normalizer.rs

1use crate::header::{Bitpix, Header};
2use std::error::Error;
3
4/// Maps raw FITS array values onto the `0.0..=1.0` range.
5///
6/// A FITS data array stores raw values that have to be shifted and scaled into
7/// physical units before they mean anything:
8///
9/// ```text
10/// physical = BZERO + BSCALE * raw
11/// ```
12///
13/// Normalising additionally needs to know which physical values correspond to
14/// black and white. Those come from the DATAMIN and DATAMAX cards when the file
15/// carries them, and otherwise from the full representable range of BITPIX — for
16/// example BITPIX = 16 with BZERO = 32768 describes unsigned 16-bit samples, so
17/// physical 0 maps to 0.0 and physical 65535 maps to 1.0.
18///
19/// Floating point images have no representable range to fall back on, so they
20/// require DATAMIN and DATAMAX; see [`Normalizer::from_header`].
21///
22/// An integer image may also mark pixels as carrying no value at all, with a
23/// BLANK card naming the raw value that means "undefined". Such pixels have no
24/// physical value and no place on the 0.0..=1.0 scale, so both [`physical`] and
25/// [`normalize`] answer NaN for them — the same way FITS itself spells an
26/// undefined floating point sample.
27///
28/// [`physical`]: Normalizer::physical
29/// [`normalize`]: Normalizer::normalize
30#[derive(Debug, Clone, Copy, PartialEq)]
31pub struct Normalizer {
32    zero_offset: f64,
33    scale: f64,
34    minimum: f64,
35    maximum: f64,
36    blank: Option<f64>,
37}
38
39impl Normalizer {
40    /// Builds a normaliser from explicit black and white points, given in
41    /// physical units.
42    pub fn new(zero_offset: f64, scale: f64, minimum: f64, maximum: f64) -> Self {
43        // A negative BSCALE flips the ends of the range.
44        Self {
45            zero_offset,
46            scale,
47            minimum: minimum.min(maximum),
48            maximum: minimum.max(maximum),
49            blank: None,
50        }
51    }
52
53    /// Marks `blank` as the raw value that means "this pixel is undefined".
54    ///
55    /// This is the BLANK card, which the standard defines only for the integer
56    /// BITPIX types; a floating point image says the same thing with a NaN and
57    /// needs no card for it.
58    pub fn with_blank(mut self, blank: Option<i64>) -> Self {
59        self.blank = blank.map(|blank| blank as f64);
60        self
61    }
62
63    /// The raw value this normaliser treats as undefined, if any.
64    pub fn blank(&self) -> Option<f64> {
65        self.blank
66    }
67
68    /// Whether `raw` is the BLANK value, and so carries no data.
69    pub fn is_blank(&self, raw: f64) -> bool {
70        self.blank == Some(raw)
71    }
72
73    /// Builds a normaliser spanning the full representable range of `bitpix`.
74    ///
75    /// Returns `None` for the floating point types, which have no such range;
76    /// use [`Normalizer::from_samples`] or [`Normalizer::new`] for those.
77    pub fn for_bitpix(bitpix: Bitpix, zero_offset: f64, scale: f64) -> Option<Self> {
78        let (raw_min, raw_max) = bitpix.value_range()?;
79        let physical = |raw: f64| zero_offset + scale * raw;
80
81        Some(Self::new(
82            zero_offset,
83            scale,
84            physical(raw_min),
85            physical(raw_max),
86        ))
87    }
88
89    /// Builds a normaliser whose black and white points are the smallest and
90    /// largest values actually present in `samples`.
91    ///
92    /// This is the honest choice for floating point images, which carry no
93    /// representable range — but it needs every sample up front, so it is only
94    /// available once the whole array has been read.
95    pub fn from_samples(
96        zero_offset: f64,
97        scale: f64,
98        samples: impl IntoIterator<Item = f64>,
99    ) -> Self {
100        let mut minimum = f64::INFINITY;
101        let mut maximum = f64::NEG_INFINITY;
102
103        for raw in samples {
104            let physical = zero_offset + scale * raw;
105            if physical.is_finite() {
106                minimum = minimum.min(physical);
107                maximum = maximum.max(physical);
108            }
109        }
110
111        // An empty or wholly non-finite array leaves no range to speak of.
112        if !minimum.is_finite() || !maximum.is_finite() {
113            minimum = 0.0;
114            maximum = 0.0;
115        }
116
117        Self::new(zero_offset, scale, minimum, maximum)
118    }
119
120    /// Builds a normaliser from a header's BITPIX, BZERO, BSCALE, DATAMIN and
121    /// DATAMAX cards.
122    ///
123    /// # Errors
124    ///
125    /// Returns an error for a floating point image (BITPIX -32 or -64) that
126    /// carries neither DATAMIN nor DATAMAX. Such an image has no black and white
127    /// point that can be known without reading every pixel, so a single-pass
128    /// normalisation is not possible — read the image with
129    /// [`ImageHDU::read_image`](crate::hdu::ImageHDU::read_image) instead, which
130    /// has the whole array available.
131    pub fn from_header(header: &Header) -> Result<Self, Box<dyn Error + Send + Sync>> {
132        let bitpix = header
133            .bitpix()
134            .ok_or("Cannot normalise an image from a header without a BITPIX card")?;
135
136        let zero_offset = header.bzero_or_default();
137        let scale = header.bscale_or_default();
138
139        let blank = blank_for(header, bitpix);
140
141        // DATAMIN and DATAMAX are already in physical units.
142
143        if let (Some(minimum), Some(maximum)) = (header.data_min(), header.data_max()) {
144            return Ok(Self::new(zero_offset, scale, minimum, maximum).with_blank(blank));
145        }
146
147        Self::for_bitpix(bitpix, zero_offset, scale)
148            .map(|normalizer| normalizer.with_blank(blank))
149            .ok_or_else(|| {
150                format!(
151                "Cannot normalise a {:?} image in a single pass: it carries neither a DATAMIN nor \
152                 a DATAMAX card, so its black and white points are unknown",
153                bitpix
154            )
155            .into()
156            })
157    }
158
159    /// Converts a raw array value into physical units.
160    ///
161    /// A BLANK pixel has no physical value, and reads as NaN.
162    pub fn physical(&self, raw: f64) -> f64 {
163        if self.is_blank(raw) {
164            return f64::NAN;
165        }
166
167        self.zero_offset + self.scale * raw
168    }
169
170    /// Converts a raw array value into the `0.0..=1.0` range.
171    ///
172    /// Values outside the black and white points are clamped, which matters when
173    /// DATAMIN and DATAMAX do not actually bound the data. A BLANK pixel is not
174    /// clamped into range but reads as NaN, so that "undefined" stays
175    /// distinguishable from "black".
176    pub fn normalize(&self, raw: f64) -> f64 {
177        if self.is_blank(raw) {
178            return f64::NAN;
179        }
180
181        let range = self.maximum - self.minimum;
182
183        // A degenerate range (BSCALE of 0, or DATAMIN == DATAMAX) has no
184        // gradient to spread values over.
185        if range <= 0.0 || !range.is_finite() {
186            return 0.0;
187        }
188
189        ((self.physical(raw) - self.minimum) / range).clamp(0.0, 1.0)
190    }
191}
192
193/// The BLANK card, but only where the standard gives it a meaning.
194///
195/// BLANK is defined for the integer BITPIX types only. A floating point image
196/// that carries the card anyway is ignoring the standard, and honouring it there
197/// would blank out every pixel that happened to equal the card's value.
198fn blank_for(header: &Header, bitpix: Bitpix) -> Option<i64> {
199    match bitpix {
200        Bitpix::F32 | Bitpix::F64 => None,
201        Bitpix::U8 | Bitpix::I16 | Bitpix::I32 => header.blank(),
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::Normalizer;
208
209    fn normalizer(zero_offset: f64, scale: f64, minimum: f64, maximum: f64) -> Normalizer {
210        Normalizer {
211            zero_offset,
212            scale,
213            minimum,
214            maximum,
215            blank: None,
216        }
217    }
218
219    #[test]
220    fn a_blank_pixel_has_no_physical_value_and_no_place_on_the_scale() {
221        let normalizer = normalizer(0.0, 1.0, 0.0, 100.0).with_blank(Some(-32768));
222
223        assert!(normalizer.normalize(-32768.0).is_nan());
224        assert!(normalizer.physical(-32768.0).is_nan());
225
226        // Every other value still reads normally.
227        assert_eq!(normalizer.normalize(50.0), 0.5);
228        assert_eq!(normalizer.physical(50.0), 50.0);
229    }
230
231    #[test]
232    fn a_blank_pixel_is_not_clamped_to_black() {
233        // Without BLANK handling, a sentinel below DATAMIN clamps to 0.0 and
234        // becomes indistinguishable from a genuinely black pixel.
235        let plain = normalizer(0.0, 1.0, 0.0, 100.0);
236        assert_eq!(plain.normalize(-32768.0), 0.0);
237
238        let blanked = plain.with_blank(Some(-32768));
239        assert!(blanked.normalize(-32768.0).is_nan());
240    }
241
242    #[test]
243    fn unsigned_16_bit_samples_span_the_full_range() {
244        // BITPIX = 16 with BZERO = 32768 is the usual unsigned-short encoding.
245        let normalizer = normalizer(32768.0, 1.0, 0.0, 65535.0);
246
247        assert_eq!(normalizer.normalize(i16::MIN as f64), 0.0);
248        assert_eq!(normalizer.normalize(i16::MAX as f64), 1.0);
249        assert!((normalizer.normalize(-1.0) - 0.5).abs() < 1e-4);
250    }
251
252    #[test]
253    fn raw_values_are_converted_to_physical_units() {
254        let normalizer = normalizer(32768.0, 2.0, 0.0, 65535.0);
255
256        assert_eq!(normalizer.physical(0.0), 32768.0);
257        assert_eq!(normalizer.physical(10.0), 32788.0);
258    }
259
260    #[test]
261    fn values_outside_the_range_are_clamped() {
262        let normalizer = normalizer(0.0, 1.0, 10.0, 20.0);
263
264        assert_eq!(normalizer.normalize(5.0), 0.0);
265        assert_eq!(normalizer.normalize(25.0), 1.0);
266        assert_eq!(normalizer.normalize(15.0), 0.5);
267    }
268
269    #[test]
270    fn a_degenerate_range_does_not_produce_nan_or_infinity() {
271        let normalizer = normalizer(0.0, 0.0, 7.0, 7.0);
272
273        assert_eq!(normalizer.normalize(1.0), 0.0);
274        assert_eq!(normalizer.normalize(f64::MAX), 0.0);
275    }
276}