Skip to main content

fits_well/data/
mod.rs

1//! Typed image data model.
2//!
3//! FITS exposes image data on two planes: a *raw* plane (the stored samples) and a
4//! *physical* plane (`BZERO + BSCALE × raw`). The stored samples are big-endian, so
5//! [`ImageData::decode`] swaps a data unit into an owned, host-endian [`ImageData`]
6//! and [`ImageData::encode_into`] writes them back. When no swap is needed
7//! (`BITPIX = 8`, or a big-endian host) an in-memory reader can skip even that copy
8//! and borrow the data unit in place — see [`RawImage`] /
9//! [`crate::FitsReader::read_image`]. The per-element swap loops are
10//! memory-bandwidth-bound, so they lean on autovectorization rather than threads
11//! (the thread-parallel layer is the compute-bound tiled codecs in the `compress`
12//! module, not this path).
13
14use crate::bitpix::Bitpix;
15use crate::endian::decode_be;
16use crate::endian::decode_be_into_slice;
17use crate::endian::extend_be;
18use crate::header::Header;
19
20/// An owned, host-endian sample buffer, tagged by its `BITPIX` element type.
21#[derive(Debug, Clone, PartialEq)]
22pub enum ImageData {
23    U8(Vec<u8>),
24    I16(Vec<i16>),
25    I32(Vec<i32>),
26    I64(Vec<i64>),
27    F32(Vec<f32>),
28    F64(Vec<f64>),
29}
30
31/// Element count for an N-d `shape`: the product of the axis lengths, or `0` for
32/// an empty shape (`NAXIS = 0` ⇒ no data, not the empty-product `1`).
33pub(crate) fn shape_product(shape: &[usize]) -> usize {
34    if shape.is_empty() {
35        0
36    } else {
37        shape.iter().product()
38    }
39}
40
41impl ImageData {
42    /// The `BITPIX` element kind backing this buffer.
43    pub fn bitpix(&self) -> Bitpix {
44        match self {
45            ImageData::U8(_) => Bitpix::U8,
46            ImageData::I16(_) => Bitpix::I16,
47            ImageData::I32(_) => Bitpix::I32,
48            ImageData::I64(_) => Bitpix::I64,
49            ImageData::F32(_) => Bitpix::F32,
50            ImageData::F64(_) => Bitpix::F64,
51        }
52    }
53
54    /// Number of samples in the buffer.
55    pub fn len(&self) -> usize {
56        match self {
57            ImageData::U8(v) => v.len(),
58            ImageData::I16(v) => v.len(),
59            ImageData::I32(v) => v.len(),
60            ImageData::I64(v) => v.len(),
61            ImageData::F32(v) => v.len(),
62            ImageData::F64(v) => v.len(),
63        }
64    }
65
66    pub fn is_empty(&self) -> bool {
67        self.len() == 0
68    }
69
70    /// Decode the raw, big-endian data unit into host-endian typed samples.
71    /// `bytes` is the unpadded data (a whole number of `bitpix` elements); the
72    /// fill past the data range must already be sliced off (see
73    /// [`crate::DataUnit::data`]).
74    pub(crate) fn decode(bytes: &[u8], bitpix: Bitpix) -> ImageData {
75        assert_eq!(
76            bytes.len() % bitpix.elem_size(),
77            0,
78            "data length must be a whole number of {bitpix:?} elements"
79        );
80        match bitpix {
81            Bitpix::U8 => ImageData::U8(bytes.to_vec()),
82            Bitpix::I16 => ImageData::I16(decode_be(bytes, i16::from_be_bytes)),
83            Bitpix::I32 => ImageData::I32(decode_be(bytes, i32::from_be_bytes)),
84            Bitpix::I64 => ImageData::I64(decode_be(bytes, i64::from_be_bytes)),
85            Bitpix::F32 => ImageData::F32(decode_be(bytes, f32::from_be_bytes)),
86            Bitpix::F64 => ImageData::F64(decode_be(bytes, f64::from_be_bytes)),
87        }
88    }
89
90    /// Append the samples to `out` in big-endian order — the inverse of
91    /// [`ImageData::decode`]. This is the unpadded data unit; the writer pads it
92    /// to the 2880-byte block grid. Appends (never clears), so a writer reusing one
93    /// buffer across HDUs clears it first and pays no per-image staging allocation.
94    pub(crate) fn encode_into(&self, out: &mut Vec<u8>) {
95        match self {
96            ImageData::U8(v) => out.extend_from_slice(v),
97            ImageData::I16(v) => extend_be(out, v, i16::to_be_bytes),
98            ImageData::I32(v) => extend_be(out, v, i32::to_be_bytes),
99            ImageData::I64(v) => extend_be(out, v, i64::to_be_bytes),
100            ImageData::F32(v) => extend_be(out, v, f32::to_be_bytes),
101            ImageData::F64(v) => extend_be(out, v, f64::to_be_bytes),
102        }
103    }
104
105    /// The physical-plane values for these samples under `scaling`: `BZERO + BSCALE
106    /// × sample` (§3.4), with integer samples equal to the `BLANK` sentinel mapped
107    /// to `NaN` (float `NaN`/`Inf` pass through). Shared by [`Image::physical`] and
108    /// [`RawImage::physical`].
109    fn physical(&self, scaling: &Scaling) -> Vec<f64> {
110        self.physical_as(scaling)
111    }
112
113    /// The physical plane narrowed to `f32` in a single pass — see
114    /// [`RawImage::physical_f32`]. Scaling is still evaluated in `f64`, so each
115    /// element is the correctly-rounded `f32` of the true physical value.
116    fn physical_f32(&self, scaling: &Scaling) -> Vec<f32> {
117        self.physical_as(scaling)
118    }
119
120    fn physical_as<O: PhysicalOut>(&self, scaling: &Scaling) -> Vec<O> {
121        let Scaling {
122            bscale,
123            bzero,
124            blank,
125        } = *scaling;
126        let scale = |x: f64| O::from_f64(bzero + bscale * x);
127        match self {
128            ImageData::U8(v) => scale_ints(v, blank, scale),
129            ImageData::I16(v) => scale_ints(v, blank, scale),
130            ImageData::I32(v) => scale_ints(v, blank, scale),
131            ImageData::I64(v) => scale_ints(v, blank, scale),
132            ImageData::F32(v) => v.iter().map(|&x| scale(x as f64)).collect(),
133            ImageData::F64(v) => v.iter().map(|&x| scale(x)).collect(),
134        }
135    }
136
137    /// Exact typed unsigned (or signed-byte) reinterpretation when `scaling` is
138    /// precisely the FITS unsigned convention (`BSCALE == 1`, no `BLANK`, and
139    /// `BZERO` the matching sign-bit offset); `None` otherwise. Exact for all 64-bit
140    /// values (no `f64` rounding). Shared by [`Image::unsigned`]/[`RawImage::unsigned`].
141    pub(crate) fn unsigned(&self, scaling: &Scaling) -> Option<UnsignedView> {
142        // `BLANK` marks null samples that have no exact integer value, so an unsigned
143        // view can't represent them; `SampleType` deliberately ignores `BLANK`, so
144        // guard it here. The `BSCALE`/`BZERO`-offset convention itself is resolved
145        // once, by `SampleType::from_scaling`, rather than re-checked against the
146        // offset constants a second time.
147        if scaling.blank.is_some() {
148            return None;
149        }
150        match (self, SampleType::from_scaling(self.bitpix(), scaling)) {
151            (ImageData::U8(v), SampleType::I8) => Some(UnsignedView::from_signed_byte(v)),
152            (ImageData::I16(v), SampleType::U16) => Some(UnsignedView::from_offset_i16(v)),
153            (ImageData::I32(v), SampleType::U32) => Some(UnsignedView::from_offset_i32(v)),
154            (ImageData::I64(v), SampleType::U64) => Some(UnsignedView::from_offset_i64(v)),
155            _ => None,
156        }
157    }
158}
159
160/// A borrowed, host-endian view of an image's samples, tagged by `BITPIX` — the
161/// zero-/low-copy counterpart to the owned [`ImageData`], returned by
162/// [`crate::FitsReader::read_image_view`]. Match it exactly like [`ImageData`], but
163/// the slices borrow the reader's reused decode scratch (or, for `BITPIX = 8`, the
164/// source bytes directly), so a view is valid only until the next read — ideal for a
165/// hot loop that processes each image and moves on, since reusing one scratch across
166/// reads pays the output allocation (and its page faults) once and even reuses it
167/// across *different* `BITPIX`. For samples you need to keep, use the owned
168/// [`RawImage::decode`].
169#[derive(Debug, Clone, Copy, PartialEq)]
170pub enum ImageView<'a> {
171    U8(&'a [u8]),
172    I16(&'a [i16]),
173    I32(&'a [i32]),
174    I64(&'a [i64]),
175    F32(&'a [f32]),
176    F64(&'a [f64]),
177}
178
179impl ImageView<'_> {
180    /// The `BITPIX` element kind backing this view.
181    pub fn bitpix(&self) -> Bitpix {
182        match self {
183            ImageView::U8(_) => Bitpix::U8,
184            ImageView::I16(_) => Bitpix::I16,
185            ImageView::I32(_) => Bitpix::I32,
186            ImageView::I64(_) => Bitpix::I64,
187            ImageView::F32(_) => Bitpix::F32,
188            ImageView::F64(_) => Bitpix::F64,
189        }
190    }
191
192    /// Number of samples in the view.
193    pub fn len(&self) -> usize {
194        match self {
195            ImageView::U8(v) => v.len(),
196            ImageView::I16(v) => v.len(),
197            ImageView::I32(v) => v.len(),
198            ImageView::I64(v) => v.len(),
199            ImageView::F32(v) => v.len(),
200            ImageView::F64(v) => v.len(),
201        }
202    }
203
204    pub fn is_empty(&self) -> bool {
205        self.len() == 0
206    }
207}
208
209/// Byte-swap big-endian image bytes `src` into `words` — a `u64`-backed (8-byte-
210/// aligned) reused scratch, resized to fit — so [`view_words`] can hand back typed
211/// `&[T]` slices over the result. `bitpix` must not be `U8` (that needs no swap; the
212/// reader borrows the source bytes directly).
213pub(crate) fn swap_into_words(src: &[u8], bitpix: Bitpix, words: &mut Vec<u64>) {
214    let count = src.len() / bitpix.elem_size();
215    words.resize(src.len().div_ceil(8), 0);
216    let p = words.as_mut_ptr() as *mut u8;
217    // SAFETY: `words` is `u64`-backed (8-aligned, valid for every BITPIX type's
218    // alignment) with room for `src.len()` bytes. Each reinterpretation is write-only,
219    // covers exactly `count` elements (= src.len() bytes, in bounds), and `src` is a
220    // separate buffer so the typed slice never aliases it.
221    unsafe {
222        match bitpix {
223            Bitpix::I16 => decode_be_into_slice(
224                src,
225                std::slice::from_raw_parts_mut(p as *mut i16, count),
226                i16::from_be_bytes,
227            ),
228            Bitpix::I32 => decode_be_into_slice(
229                src,
230                std::slice::from_raw_parts_mut(p as *mut i32, count),
231                i32::from_be_bytes,
232            ),
233            Bitpix::I64 => decode_be_into_slice(
234                src,
235                std::slice::from_raw_parts_mut(p as *mut i64, count),
236                i64::from_be_bytes,
237            ),
238            Bitpix::F32 => decode_be_into_slice(
239                src,
240                std::slice::from_raw_parts_mut(p as *mut f32, count),
241                f32::from_be_bytes,
242            ),
243            Bitpix::F64 => decode_be_into_slice(
244                src,
245                std::slice::from_raw_parts_mut(p as *mut f64, count),
246                f64::from_be_bytes,
247            ),
248            Bitpix::U8 => unreachable!("U8 is handled by the caller, never swapped"),
249        }
250    }
251}
252
253/// Reinterpret the first `nbytes` of a `u64`-backed host-endian scratch (written by
254/// [`swap_into_words`]) as a typed [`ImageView`]. `nbytes` is a whole number of
255/// `bitpix` elements and `<= words.len() * 8`.
256pub(crate) fn view_words(words: &[u64], bitpix: Bitpix, nbytes: usize) -> ImageView<'_> {
257    let count = nbytes / bitpix.elem_size();
258    let p = words.as_ptr() as *const u8;
259    // SAFETY: `words` is `u64`-backed (8-aligned ≥ every type's align); `swap_into_words`
260    // wrote all `nbytes` (= count elements) host-endian bytes; int/float types have no
261    // invalid bit patterns. So each slice is a valid, fully-initialized `&[T]`.
262    unsafe {
263        match bitpix {
264            Bitpix::U8 => ImageView::U8(std::slice::from_raw_parts(p, count)),
265            Bitpix::I16 => ImageView::I16(std::slice::from_raw_parts(p as *const i16, count)),
266            Bitpix::I32 => ImageView::I32(std::slice::from_raw_parts(p as *const i32, count)),
267            Bitpix::I64 => ImageView::I64(std::slice::from_raw_parts(p as *const i64, count)),
268            Bitpix::F32 => ImageView::F32(std::slice::from_raw_parts(p as *const f32, count)),
269            Bitpix::F64 => ImageView::F64(std::slice::from_raw_parts(p as *const f64, count)),
270        }
271    }
272}
273
274/// The already-host-endian samples reinterpreted as their raw bytes — every element
275/// type is `Pod` (no padding, all bit patterns valid), so the byte view is sound.
276#[cfg(feature = "compression")]
277fn samples_as_bytes(data: &ImageData) -> &[u8] {
278    // SAFETY: a typed sample slice viewed as its own bytes (read-only); length is the
279    // element count times the element width.
280    unsafe {
281        let (ptr, len) = match data {
282            ImageData::U8(v) => return v,
283            ImageData::I16(v) => (v.as_ptr() as *const u8, v.len() * 2),
284            ImageData::I32(v) => (v.as_ptr() as *const u8, v.len() * 4),
285            ImageData::I64(v) => (v.as_ptr() as *const u8, v.len() * 8),
286            ImageData::F32(v) => (v.as_ptr() as *const u8, v.len() * 4),
287            ImageData::F64(v) => (v.as_ptr() as *const u8, v.len() * 8),
288        };
289        std::slice::from_raw_parts(ptr, len)
290    }
291}
292
293/// Copy already-host-endian `samples` (a decompressed image) into the `u64`-backed
294/// `words` scratch so [`view_words`] can hand back a view — the compressed-image
295/// path, whose pixels have no on-disk bytes to swap. Returns the byte length written.
296#[cfg(feature = "compression")]
297pub(crate) fn copy_samples_into_words(samples: &ImageData, words: &mut Vec<u64>) -> usize {
298    let bytes = samples_as_bytes(samples);
299    words.resize(bytes.len().div_ceil(8), 0);
300    // SAFETY: `words` is `u64`-backed (8-aligned) with room for `bytes.len()`; the
301    // reinterpreted destination is write-only and does not alias `samples`.
302    unsafe {
303        std::slice::from_raw_parts_mut(words.as_mut_ptr() as *mut u8, bytes.len())
304            .copy_from_slice(bytes);
305    }
306    bytes.len()
307}
308
309/// An image read from an HDU, in whichever form the reader could give cheaply —
310/// returned by [`crate::FitsReader::read_image`] for *both* plain and tiled-
311/// compressed images, so callers needn't know which they have. Carries the shape,
312/// `BITPIX`, and [`Scaling`]; the pixels are exposed lazily through [`decode`],
313/// [`u8`], [`physical`], and [`unsigned`].
314///
315/// A **plain** image borrows the data unit's big-endian bytes in place (zero-copy);
316/// a **compressed** one (`ZIMAGE`) holds the reconstructed host-endian samples it had
317/// to decompress. The accessors paper over the difference — e.g. [`u8`] is the
318/// zero-copy `BITPIX = 8` plane either way — so you only reach for [`raw_bytes`] when
319/// you specifically want the undecoded on-disk bytes (plain images only).
320///
321/// [`decode`]: RawImage::decode
322/// [`u8`]: RawImage::u8
323/// [`physical`]: RawImage::physical
324/// [`unsigned`]: RawImage::unsigned
325/// [`raw_bytes`]: RawImage::raw_bytes
326#[derive(Debug)]
327pub struct RawImage<'a> {
328    pub shape: Vec<usize>,
329    pub bitpix: Bitpix,
330    pub scaling: Scaling,
331    data: ImageBytes<'a>,
332}
333
334/// The two forms a [`RawImage`]'s pixels can take, by how it was read.
335#[derive(Debug)]
336enum ImageBytes<'a> {
337    /// Plain image: the data unit's big-endian on-disk bytes, viewed in place over
338    /// the source (or the reader's reused scratch for a seeking source).
339    Raw(&'a [u8]),
340    /// Compressed image (`ZIMAGE`): pixels reconstructed into an owned, host-endian
341    /// buffer (only the `compression` feature ever builds this).
342    #[cfg_attr(not(feature = "compression"), allow(dead_code))]
343    Decoded(ImageData),
344}
345
346impl<'a> RawImage<'a> {
347    /// A plain image over borrowed big-endian bytes.
348    pub(crate) fn raw(
349        shape: Vec<usize>,
350        bitpix: Bitpix,
351        scaling: Scaling,
352        bytes: &'a [u8],
353    ) -> RawImage<'a> {
354        RawImage {
355            shape,
356            bitpix,
357            scaling,
358            data: ImageBytes::Raw(bytes),
359        }
360    }
361
362    /// A compressed image over its reconstructed, host-endian samples.
363    #[cfg(feature = "compression")]
364    pub(crate) fn decoded(samples: ImageData, shape: Vec<usize>, scaling: Scaling) -> RawImage<'a> {
365        RawImage {
366            shape,
367            bitpix: samples.bitpix(),
368            scaling,
369            data: ImageBytes::Decoded(samples),
370        }
371    }
372
373    /// The host-endian samples. For a plain image this byte-swaps the on-disk bytes
374    /// into an owned buffer; a compressed image's pixels are already decoded (cloned
375    /// out here).
376    pub fn decode(&self) -> ImageData {
377        match &self.data {
378            ImageBytes::Raw(bytes) => ImageData::decode(bytes, self.bitpix),
379            ImageBytes::Decoded(samples) => samples.clone(),
380        }
381    }
382
383    /// The samples as a borrowed `&[u8]` when no byte-swap is needed (`BITPIX = 8`):
384    /// a plain image's borrowed on-disk bytes, or a compressed image's decoded `u8`
385    /// buffer. `None` for multi-byte element types — use [`RawImage::decode`].
386    pub fn u8(&self) -> Option<&[u8]> {
387        match &self.data {
388            ImageBytes::Raw(bytes) if self.bitpix == Bitpix::U8 => Some(bytes),
389            ImageBytes::Decoded(ImageData::U8(v)) => Some(v),
390            _ => None,
391        }
392    }
393
394    /// The undecoded big-endian on-disk bytes — `Some` only for a **plain** image
395    /// (zero-copy borrow); `None` for a compressed one, whose pixels were
396    /// reconstructed and have no on-disk byte form. Use [`RawImage::decode`] for the
397    /// samples regardless of form.
398    pub fn raw_bytes(&self) -> Option<&[u8]> {
399        match &self.data {
400            ImageBytes::Raw(bytes) => Some(bytes),
401            ImageBytes::Decoded(_) => None,
402        }
403    }
404
405    /// The physical-plane values: `BZERO + BSCALE × sample`, `BLANK` → `NaN` (§3.4).
406    pub fn physical(&self) -> Vec<f64> {
407        match &self.data {
408            ImageBytes::Raw(bytes) => ImageData::decode(bytes, self.bitpix).physical(&self.scaling),
409            ImageBytes::Decoded(samples) => samples.physical(&self.scaling),
410        }
411    }
412
413    /// The physical plane narrowed to `f32` in a single pass — the compact, lossy
414    /// counterpart to [`physical`](RawImage::physical). The scaling is still evaluated
415    /// in `f64` (so each value is the correctly-rounded `f32`), but only one `Vec<f32>`
416    /// is allocated rather than a `Vec<f64>` the caller then re-walks to narrow. Prefer
417    /// it when the consumer wants `f32` regardless (display, GPU upload, `f32`
418    /// pipelines); use [`physical`](RawImage::physical) when you need double precision —
419    /// e.g. large `BITPIX = 64` integers or fine `BSCALE`/`BZERO` past `f32`'s range.
420    pub fn physical_f32(&self) -> Vec<f32> {
421        match &self.data {
422            ImageBytes::Raw(bytes) => {
423                ImageData::decode(bytes, self.bitpix).physical_f32(&self.scaling)
424            }
425            ImageBytes::Decoded(samples) => samples.physical_f32(&self.scaling),
426        }
427    }
428
429    /// Exact typed integers when the scaling is the FITS unsigned (or signed-byte)
430    /// convention; `None` otherwise — same rule as [`Image::unsigned`].
431    pub fn unsigned(&self) -> Option<UnsignedView> {
432        match &self.data {
433            ImageBytes::Raw(bytes) => ImageData::decode(bytes, self.bitpix).unsigned(&self.scaling),
434            ImageBytes::Decoded(samples) => samples.unsigned(&self.scaling),
435        }
436    }
437
438    /// The effective element type these samples represent, resolving the unsigned and
439    /// signed-byte conventions from `BITPIX` + [`Scaling`] without decoding the pixels.
440    pub fn sample_type(&self) -> SampleType {
441        SampleType::from_scaling(self.bitpix, &self.scaling)
442    }
443}
444
445/// The `BZERO`/`TZEROn` offsets that realize the FITS unsigned-integer convention:
446/// a sign-bit flip (`2^(n-1)`), exactly representable as `f64`. Shared by the image
447/// (`BZERO`) and binary-table (`TZEROn`) unsigned paths.
448pub(crate) const U16_OFFSET: f64 = 32_768.0; // 2¹⁵
449pub(crate) const U32_OFFSET: f64 = 2_147_483_648.0; // 2³¹
450pub(crate) const U64_OFFSET: f64 = 9_223_372_036_854_775_808.0; // 2⁶³
451
452/// The effective element type of an image's *physical* samples — the analogue of
453/// cfitsio's image "equivalent type". `BITPIX` records only the stored width and
454/// signedness; the FITS unsigned and signed-byte conventions then layer a `BZERO`
455/// offset on top (`BSCALE == 1` with `BZERO = 2^(n-1)`, or `BZERO = -128` for signed
456/// bytes), so the values actually mean an unsigned (or signed-byte) integer. This
457/// enum is what [`RawImage::physical`] / [`RawImage::unsigned`] yield, resolved up
458/// front from `BITPIX` + [`Scaling`] without touching the pixels — so a caller can
459/// pick a code path (e.g. a per-type normalization range) without re-deriving the
460/// `BZERO` convention itself.
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
462pub enum SampleType {
463    /// `BITPIX = 8`, `BZERO = -128`: a signed byte.
464    I8,
465    /// `BITPIX = 8`: an unsigned byte (the FITS default for `BITPIX = 8`).
466    U8,
467    /// `BITPIX = 16`, no unsigned offset.
468    I16,
469    /// `BITPIX = 16`, `BZERO = 2¹⁵`.
470    U16,
471    /// `BITPIX = 32`, no unsigned offset.
472    I32,
473    /// `BITPIX = 32`, `BZERO = 2³¹`.
474    U32,
475    /// `BITPIX = 64`, no unsigned offset.
476    I64,
477    /// `BITPIX = 64`, `BZERO = 2⁶³`.
478    U64,
479    /// `BITPIX = -32`.
480    F32,
481    /// `BITPIX = -64`.
482    F64,
483}
484
485impl SampleType {
486    /// Resolve the effective type from the stored `BITPIX` and its [`Scaling`].
487    ///
488    /// A signed integer `BITPIX` whose scaling is exactly the unsigned (or signed-byte)
489    /// convention — `BSCALE == 1` and `BZERO` the matching sign-bit offset — resolves
490    /// to the corresponding unsigned (or `I8`) type; any other scaling leaves the
491    /// stored type as-is. `BLANK` does not affect the classification: it marks null
492    /// samples *within* a type, not the type itself.
493    pub fn from_scaling(bitpix: Bitpix, scaling: &Scaling) -> SampleType {
494        let offset = scaling.bscale == 1.0;
495        match bitpix {
496            Bitpix::U8 if offset && scaling.bzero == -128.0 => SampleType::I8,
497            Bitpix::U8 => SampleType::U8,
498            Bitpix::I16 if offset && scaling.bzero == U16_OFFSET => SampleType::U16,
499            Bitpix::I16 => SampleType::I16,
500            Bitpix::I32 if offset && scaling.bzero == U32_OFFSET => SampleType::U32,
501            Bitpix::I32 => SampleType::I32,
502            Bitpix::I64 if offset && scaling.bzero == U64_OFFSET => SampleType::U64,
503            Bitpix::I64 => SampleType::I64,
504            Bitpix::F32 => SampleType::F32,
505            Bitpix::F64 => SampleType::F64,
506        }
507    }
508
509    /// `true` for `U8`/`U16`/`U32`/`U64`.
510    pub fn is_unsigned(self) -> bool {
511        matches!(
512            self,
513            SampleType::U8 | SampleType::U16 | SampleType::U32 | SampleType::U64
514        )
515    }
516
517    /// `true` for `F32`/`F64`.
518    pub fn is_float(self) -> bool {
519        matches!(self, SampleType::F32 | SampleType::F64)
520    }
521
522    /// `true` for every integer variant (signed or unsigned).
523    pub fn is_integer(self) -> bool {
524        !self.is_float()
525    }
526}
527
528/// A typed integer realization of the FITS unsigned (and signed-byte) storage
529/// conventions — `BSCALE == 1` with `BZERO` the sign-bit offset. Values are exact
530/// (no `f64` rounding), recovered by flipping the stored sign bit.
531#[derive(Debug, Clone, PartialEq, Eq)]
532pub enum UnsignedView {
533    /// `BITPIX = 8`, `BZERO = -128`: stored `u8` → `i8`.
534    I8(Vec<i8>),
535    /// `BITPIX = 16`, `BZERO = 2¹⁵`: stored `i16` → `u16`.
536    U16(Vec<u16>),
537    /// `BITPIX = 32`, `BZERO = 2³¹`: stored `i32` → `u32`.
538    U32(Vec<u32>),
539    /// `BITPIX = 64`, `BZERO = 2⁶³`: stored `i64` → `u64`.
540    U64(Vec<u64>),
541}
542
543impl UnsignedView {
544    /// Recover unsigned values from sign-bit-offset storage (the §5.2.5 / Table 19
545    /// convention) by flipping the sign bit. Shared by [`Image::unsigned`] and
546    /// `ColumnReader::unsigned` so the bit math has one definition.
547    pub(crate) fn from_signed_byte(stored: &[u8]) -> UnsignedView {
548        UnsignedView::I8(stored.iter().map(|&x| (x ^ 0x80) as i8).collect())
549    }
550    pub(crate) fn from_offset_i16(stored: &[i16]) -> UnsignedView {
551        UnsignedView::U16(stored.iter().map(|&x| (x as u16) ^ 0x8000).collect())
552    }
553    pub(crate) fn from_offset_i32(stored: &[i32]) -> UnsignedView {
554        UnsignedView::U32(stored.iter().map(|&x| (x as u32) ^ 0x8000_0000).collect())
555    }
556    pub(crate) fn from_offset_i64(stored: &[i64]) -> UnsignedView {
557        UnsignedView::U64(
558            stored
559                .iter()
560                .map(|&x| (x as u64) ^ 0x8000_0000_0000_0000)
561                .collect(),
562        )
563    }
564}
565
566/// An N-dimensional image: a flat, Fortran-ordered buffer (axis 0 varies
567/// fastest), the axis lengths from `NAXISn`, and the scaling map that turns its
568/// stored (raw) samples into physical values.
569#[derive(Debug, Clone)]
570pub struct Image {
571    pub shape: Vec<usize>,
572    pub samples: ImageData,
573    pub scaling: Scaling,
574}
575
576impl Image {
577    /// Build an image storing a `u16` buffer via the FITS unsigned convention
578    /// (`BITPIX = 16`, `BZERO = 2¹⁵`, `BSCALE = 1`) — the inverse of
579    /// [`Image::unsigned`]. The writer emits the `BZERO` keyword so it round-trips.
580    pub fn from_u16(shape: Vec<usize>, data: &[u16]) -> Image {
581        Image::offset_image(
582            shape,
583            ImageData::I16(data.iter().map(|&x| (x ^ 0x8000) as i16).collect()),
584            U16_OFFSET,
585        )
586    }
587
588    /// Build an image storing a `u32` buffer (`BITPIX = 32`, `BZERO = 2³¹`).
589    pub fn from_u32(shape: Vec<usize>, data: &[u32]) -> Image {
590        Image::offset_image(
591            shape,
592            ImageData::I32(data.iter().map(|&x| (x ^ 0x8000_0000) as i32).collect()),
593            U32_OFFSET,
594        )
595    }
596
597    /// Build an image storing a `u64` buffer (`BITPIX = 64`, `BZERO = 2⁶³`).
598    pub fn from_u64(shape: Vec<usize>, data: &[u64]) -> Image {
599        Image::offset_image(
600            shape,
601            ImageData::I64(
602                data.iter()
603                    .map(|&x| (x ^ 0x8000_0000_0000_0000) as i64)
604                    .collect(),
605            ),
606            U64_OFFSET,
607        )
608    }
609
610    /// Build an image storing a signed-`i8` buffer (`BITPIX = 8`, `BZERO = -128`).
611    pub fn from_i8(shape: Vec<usize>, data: &[i8]) -> Image {
612        Image::offset_image(
613            shape,
614            ImageData::U8(data.iter().map(|&x| (x as u8) ^ 0x80).collect()),
615            -128.0,
616        )
617    }
618
619    fn offset_image(shape: Vec<usize>, samples: ImageData, bzero: f64) -> Image {
620        Image {
621            shape,
622            samples,
623            scaling: Scaling {
624                bscale: 1.0,
625                bzero,
626                blank: None,
627            },
628        }
629    }
630
631    /// Reinterpret the stored buffer as exact typed integers when the scaling is
632    /// precisely a FITS unsigned-integer (or signed-byte) convention: `BSCALE == 1`,
633    /// no `BLANK`, and `BZERO` the matching sign-bit offset. Unlike
634    /// [`Image::physical`], this is exact for all 64-bit values (no `f64` rounding
635    /// past 2⁵³). Returns `None` for any other scaling or element type.
636    pub fn unsigned(&self) -> Option<UnsignedView> {
637        self.samples.unsigned(&self.scaling)
638    }
639
640    /// The physical-plane values: `BZERO + BSCALE × sample` for every sample
641    /// (§3.4). Integer samples equal to the `BLANK` sentinel become `NaN`; float
642    /// `NaN`/`Inf` pass through. The unsigned-integer convention falls out for
643    /// free — e.g. a signed-16 buffer with `BZERO = 32768` yields the `u16` value.
644    pub fn physical(&self) -> Vec<f64> {
645        self.samples.physical(&self.scaling)
646    }
647
648    /// The physical plane narrowed to `f32` in a single pass — the compact, lossy
649    /// counterpart to [`physical`](Image::physical); see [`RawImage::physical_f32`].
650    pub fn physical_f32(&self) -> Vec<f32> {
651        self.samples.physical_f32(&self.scaling)
652    }
653
654    /// The effective element type these samples represent, resolving the unsigned and
655    /// signed-byte conventions from the stored `BITPIX` + [`Scaling`].
656    pub fn sample_type(&self) -> SampleType {
657        SampleType::from_scaling(self.samples.bitpix(), &self.scaling)
658    }
659}
660
661/// Scale an integer sample buffer to the physical plane, mapping the `BLANK`
662/// sentinel (a stored integer value) to `NaN`.
663fn scale_ints<T, O>(v: &[T], blank: Option<i64>, scale: impl Fn(f64) -> O) -> Vec<O>
664where
665    T: Copy + Into<i64>,
666    O: PhysicalOut,
667{
668    v.iter()
669        .map(|&x| {
670            let xi: i64 = x.into();
671            if blank == Some(xi) {
672                O::from_f64(f64::NAN)
673            } else {
674                scale(xi as f64)
675            }
676        })
677        .collect()
678}
679
680/// Output element type of the physical-plane map. Private, hence sealed: the only
681/// implementors are `f64` (the canonical plane, [`ImageData::physical`]) and `f32`
682/// (the compact plane, [`ImageData::physical_f32`]). The scaling arithmetic always
683/// runs in `f64`; `from_f64` is the final per-element narrowing.
684trait PhysicalOut: Copy {
685    fn from_f64(value: f64) -> Self;
686}
687
688impl PhysicalOut for f64 {
689    fn from_f64(value: f64) -> f64 {
690        value
691    }
692}
693
694impl PhysicalOut for f32 {
695    fn from_f64(value: f64) -> f32 {
696        value as f32
697    }
698}
699
700/// The linear `BSCALE`/`BZERO` map from a stored value to its physical value,
701/// plus the integer `BLANK` sentinel marking undefined pixels.
702#[derive(Debug, Clone, Copy, PartialEq)]
703pub struct Scaling {
704    pub bscale: f64,
705    pub bzero: f64,
706    pub blank: Option<i64>,
707}
708
709impl Scaling {
710    /// The public entry point is [`Header::scaling`](crate::Header::scaling).
711    pub(crate) fn from_header(header: &Header) -> Scaling {
712        Scaling {
713            bscale: header.get_real("BSCALE").unwrap_or(1.0),
714            bzero: header.get_real("BZERO").unwrap_or(0.0),
715            blank: header.get_integer("BLANK"),
716        }
717    }
718
719    /// `true` when decoding needs no arithmetic — just an endian swap or copy.
720    pub fn is_identity(&self) -> bool {
721        self.bscale == 1.0 && self.bzero == 0.0
722    }
723}
724
725/// An image as an N-dimensional [`ndarray`] array, tagged by element type — the n-D
726/// analog of [`ImageData`], from [`Image::into_ndarray`] / [`RawImage::to_ndarray`].
727/// Requires the `ndarray` feature.
728///
729/// Axes are in **FITS order** (axis 0 = `NAXIS1`, the fastest-varying), so a 2-D image
730/// indexes `arr[[x, y]]`. For the NumPy/Astropy `arr[[y, x]]` convention call
731/// `.reversed_axes()` — a zero-copy stride swap.
732#[cfg(feature = "ndarray")]
733#[derive(Debug, Clone, PartialEq)]
734pub enum ImageArray {
735    U8(ndarray::ArrayD<u8>),
736    I16(ndarray::ArrayD<i16>),
737    I32(ndarray::ArrayD<i32>),
738    I64(ndarray::ArrayD<i64>),
739    F32(ndarray::ArrayD<f32>),
740    F64(ndarray::ArrayD<f64>),
741}
742
743/// Wrap a flat, FITS-ordered buffer (axis 1 fastest) in an [`ndarray`] without
744/// copying — a Fortran-order array so `arr[[i1, i2, …]]` maps to the right element.
745/// `NAXIS = 0` (no pixels) becomes an empty 1-D array.
746#[cfg(feature = "ndarray")]
747fn fortran_array<T>(shape: &[usize], data: Vec<T>) -> ndarray::ArrayD<T> {
748    use ndarray::ShapeBuilder as _;
749    let dims: Vec<usize> = if shape.is_empty() {
750        vec![0]
751    } else {
752        shape.to_vec()
753    };
754    ndarray::ArrayD::from_shape_vec(ndarray::IxDyn(&dims).f(), data)
755        .expect("decoded buffer length equals the axis product")
756}
757
758#[cfg(feature = "ndarray")]
759impl ImageData {
760    /// Move the samples into a typed N-d [`ImageArray`] of `shape` (FITS axis order,
761    /// fastest first) — zero-copy: the backing `Vec` is reused, not cloned.
762    pub fn into_ndarray(self, shape: &[usize]) -> ImageArray {
763        match self {
764            ImageData::U8(v) => ImageArray::U8(fortran_array(shape, v)),
765            ImageData::I16(v) => ImageArray::I16(fortran_array(shape, v)),
766            ImageData::I32(v) => ImageArray::I32(fortran_array(shape, v)),
767            ImageData::I64(v) => ImageArray::I64(fortran_array(shape, v)),
768            ImageData::F32(v) => ImageArray::F32(fortran_array(shape, v)),
769            ImageData::F64(v) => ImageArray::F64(fortran_array(shape, v)),
770        }
771    }
772}
773
774#[cfg(feature = "ndarray")]
775impl RawImage<'_> {
776    /// The physical plane as an N-d `f64` array (`BZERO + BSCALE × sample`, `BLANK` →
777    /// `NaN`), in FITS axis order — index `arr[[x, y]]`.
778    pub fn physical_array(&self) -> ndarray::ArrayD<f64> {
779        fortran_array(&self.shape, self.physical())
780    }
781
782    /// The samples as a typed N-d [`ImageArray`], in FITS axis order. Decodes into an
783    /// owned buffer first (the array then owns it, no further copy).
784    pub fn to_ndarray(&self) -> ImageArray {
785        self.decode().into_ndarray(&self.shape)
786    }
787}
788
789#[cfg(feature = "ndarray")]
790impl Image {
791    /// The physical plane as an N-d `f64` array (`BZERO + BSCALE × sample`, `BLANK` →
792    /// `NaN`), in FITS axis order — index `arr[[x, y]]`.
793    pub fn physical_array(&self) -> ndarray::ArrayD<f64> {
794        fortran_array(&self.shape, self.physical())
795    }
796
797    /// Move the samples into a typed N-d [`ImageArray`], in FITS axis order — zero-copy.
798    pub fn into_ndarray(self) -> ImageArray {
799        let Image { shape, samples, .. } = self;
800        samples.into_ndarray(&shape)
801    }
802}
803
804#[cfg(test)]
805mod tests;