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;