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}