Skip to main content

fits_well/compress/
mod.rs

1//! Tiled image compression (§10.1) — behind the `compression` feature.
2//!
3//! A compressed image is a `BINTABLE` with `ZIMAGE = T`: the original image
4//! (`ZBITPIX`, `ZNAXISn`) is split into `ZTILEn` tiles, each compressed and stored
5//! in `COMPRESSED_DATA` (with `GZIP_COMPRESSED_DATA`/`UNCOMPRESSED_DATA` fallbacks).
6//! This module orchestrates tile reassembly and (de)quantization; the per-codec
7//! work lives in [`gzip`], [`rice`], [`plio`], and [`hcompress`].
8//!
9//! Decode and encode ([`compress_image`]) cover all five codecs: `GZIP_1`,
10//! `GZIP_2`, `RICE_1`, `PLIO_1`, and `HCOMPRESS_1` (with `SMOOTH=1` decode). Float
11//! images are quantized per-tile (`ZSCALE`/`ZZERO`) with `NO_DITHER`,
12//! `SUBTRACTIVE_DITHER_1`, or `SUBTRACTIVE_DITHER_2`, with `ZBLANK`/NaN nulls and a
13//! raw-gzip fallback for constant tiles. Tiled *table* compression (§10.3) lives in
14//! [`table`] ([`compress_table`]/[`uncompress_table`]).
15
16mod convert;
17mod geometry;
18mod gzip;
19mod hcompress;
20mod plio;
21mod quantize;
22mod rice;
23mod table;
24
25use convert::as_bytes;
26use convert::as_i16;
27use convert::be_floats_into;
28use convert::be_to_i64_into;
29use convert::bytepix_to_bitpix;
30use convert::cell_len;
31use convert::cell_to_f64_into;
32use convert::cell_to_i64_into;
33use convert::float_to_be;
34use convert::gather_f64;
35use convert::gather_i64;
36use convert::i64_to_be;
37use convert::i64_to_be_into;
38use convert::zeroed_samples;
39use geometry::TileGeometry;
40use geometry::TileScratch;
41
42pub(crate) use table::compress_table;
43pub(crate) use table::uncompress_table;
44
45use crate::bitpix::Bitpix;
46use crate::data::Image;
47use crate::data::ImageData;
48use crate::data::Scaling;
49use crate::endian::push_pq_descriptor;
50use crate::error::FitsError;
51use crate::error::Result;
52use crate::header::Header;
53use crate::keyword::key;
54use crate::table::BinTable;
55use crate::table::ColumnData;
56
57/// Float-quantization dithering for [`crate::FitsWriter::write_compressed_image`]
58/// (`ZQUANTIZ`, §10.2). Applies only when compressing a float image; the integer
59/// codecs ignore it.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum DitherMethod {
62    /// `NO_DITHER` — plain linear quantization.
63    None,
64    /// `SUBTRACTIVE_DITHER_1` — per-pixel dither from the shared random sequence
65    /// (cfitsio's default).
66    #[default]
67    Subtractive1,
68    /// `SUBTRACTIVE_DITHER_2` — like `Subtractive1`, but exact zeros are preserved
69    /// (stored as a reserved integer rather than dithered).
70    Subtractive2,
71}
72
73impl DitherMethod {
74    /// Whether this method applies a per-pixel dither (everything but `None`).
75    pub(crate) fn dithered(self) -> bool {
76        !matches!(self, DitherMethod::None)
77    }
78}
79
80/// Write-time tuning for [`crate::FitsWriter::write_compressed_image`]. Each field
81/// applies only to the codecs that use it; the rest ignore it. Every field defaults
82/// to conventional behavior, so `CompressOptions::default()` (row tiling) or
83/// `CompressOptions::tiled(shape)` is the common case.
84#[derive(Debug, Clone)]
85pub struct CompressOptions {
86    /// Tile shape, fastest axis first. Empty ⇒ one tile per row (the default).
87    /// `HCOMPRESS_1` requires a 2-D shape.
88    pub tile_shape: Vec<usize>,
89    /// `flate2` deflate level (0–9) for `GZIP_1`/`GZIP_2`. Lossless — only the
90    /// speed↔ratio tradeoff changes.
91    pub gzip_level: u32,
92    /// `HCOMPRESS_1` quantization scale: `0` = lossless, larger = more lossy / smaller.
93    pub hcompress_scale: i32,
94    /// Float quantization noise divisor (`qlevel`): `0` ⇒ cfitsio's default of
95    /// noise/4; larger keeps more precision (and grows the output). Ignored by the
96    /// integer codecs.
97    pub quantize_level: f64,
98    /// Dithering for float quantization (`ZQUANTIZ`). Defaults to
99    /// `SUBTRACTIVE_DITHER_1`. Ignored by the integer codecs.
100    pub dither: DitherMethod,
101}
102
103impl Default for CompressOptions {
104    fn default() -> CompressOptions {
105        CompressOptions {
106            tile_shape: Vec::new(),
107            gzip_level: gzip::DEFAULT_GZIP_LEVEL,
108            hcompress_scale: 0,
109            quantize_level: 0.0,
110            dither: DitherMethod::Subtractive1,
111        }
112    }
113}
114
115impl CompressOptions {
116    /// Default options with an explicit tile shape (fastest axis first; empty ⇒ row
117    /// tiling). Tune further with struct-update syntax:
118    /// `CompressOptions { gzip_level: 9, ..CompressOptions::tiled([256, 256]) }`.
119    pub fn tiled(tile_shape: impl Into<Vec<usize>>) -> CompressOptions {
120        CompressOptions {
121            tile_shape: tile_shape.into(),
122            ..CompressOptions::default()
123        }
124    }
125}
126
127/// A restored header and its decompressed data unit — the result of
128/// [`uncompress_table`] (a named pair rather than a bare `(Header, Vec<u8>)`).
129#[derive(Debug)]
130pub(crate) struct HduParts {
131    pub header: Header,
132    pub data: Vec<u8>,
133}
134
135/// Map `f` over each tile index `0..ntiles`, collecting the per-tile results in
136/// tile order and short-circuiting on the first error. `init` builds the reusable
137/// per-worker scratch `f` writes through (one per thread under `parallel`, reused
138/// across that worker's tiles; a single one serially).
139///
140/// Tiles (de)compress independently and the codecs are compute-bound, so with the
141/// `parallel` feature this fans the per-tile work across the rayon pool for a
142/// near-linear speedup. The caller then folds the results — scatter into the image,
143/// or concatenate into the heap — and *that* step stays serial because tile order
144/// and heap offsets are sequential.
145#[cfg(feature = "parallel")]
146pub(crate) fn map_tiles<S, T, I, F>(ntiles: usize, init: I, f: F) -> Result<Vec<T>>
147where
148    S: Send,
149    T: Send,
150    I: Fn() -> S + Sync + Send,
151    F: Fn(&mut S, usize) -> Result<T> + Sync + Send,
152{
153    use rayon::prelude::*;
154    (0..ntiles)
155        .into_par_iter()
156        .map_init(init, |scratch, t| f(scratch, t))
157        .collect()
158}
159
160#[cfg(not(feature = "parallel"))]
161pub(crate) fn map_tiles<S, T, I, F>(ntiles: usize, init: I, f: F) -> Result<Vec<T>>
162where
163    I: FnOnce() -> S,
164    F: Fn(&mut S, usize) -> Result<T>,
165{
166    let mut scratch = init();
167    (0..ntiles).map(|t| f(&mut scratch, t)).collect()
168}
169
170/// Decompress a tiled-image `BINTABLE` into the full [`Image`] it encodes.
171pub(crate) fn decompress_image(header: &Header, table: &BinTable) -> Result<Image> {
172    if header.get_logical("ZIMAGE") != Some(true) {
173        return Err(FitsError::NotCompressedImage);
174    }
175    let zbitpix = Bitpix::from_code(
176        header
177            .get_integer("ZBITPIX")
178            .ok_or(FitsError::MissingKeyword { name: "ZBITPIX" })?,
179    )?;
180    let is_float = zbitpix.is_float();
181    let cmptype = header
182        .get_text("ZCMPTYPE")
183        .ok_or(FitsError::MissingKeyword { name: "ZCMPTYPE" })?
184        .to_string();
185
186    let znaxis = header
187        .get_integer("ZNAXIS")
188        .ok_or(FitsError::MissingKeyword { name: "ZNAXIS" })?;
189    // `ZNAXIS` is untrusted; cap it like the uncompressed `NAXIS` path (§4.4.1) so a
190    // negative value can't wrap through `as usize` and a huge one can't drive the
191    // per-axis keyword loops below.
192    if !(0..=999).contains(&znaxis) {
193        return Err(FitsError::KeywordOutOfRange { name: "ZNAXIS" });
194    }
195    let znaxis = znaxis as usize;
196    let dims = read_axes(header, znaxis)?;
197    // A `ZNAXIS = 0` ZIMAGE has no data array (as an uncompressed `NAXIS = 0` does).
198    // Return empty before building the geometry, which would otherwise size `total`
199    // as the empty product (1) and fabricate a phantom one-pixel tile.
200    if dims.is_empty() {
201        return Ok(Image {
202            shape: dims,
203            samples: zeroed_samples(zbitpix, 0)?,
204            scaling: Scaling::from_header(header),
205        });
206    }
207    // `ZNAXISn` are untrusted; guard the product up front — before reading any tile
208    // — so a wrapped value can't mis-size the output buffer below (the un-wrapped
209    // strides would then scatter out of bounds). Mirrors `hdu::data_extent`.
210    let total = dims
211        .iter()
212        .try_fold(1usize, |acc, &n| acc.checked_mul(n))
213        .ok_or(FitsError::DataUnitOverflow)?;
214    let tiles: Vec<usize> = (1..=znaxis)
215        .map(|i| {
216            header
217                .get_integer(key!("ZTILE{i}").as_str())
218                .map(|v| v.max(1) as usize)
219                .unwrap_or(if i == 1 { dims[0] } else { 1 })
220        })
221        .collect();
222
223    let rice = rice::rice_params(header, zbitpix);
224    // Float pixels are quantized to integers of `bytepix` bytes; decode the tile
225    // as that integer type, then dequantize. Integer images decode as `zbitpix`.
226    let int_bitpix = if is_float {
227        bytepix_to_bitpix(rice.bytepix)
228    } else {
229        zbitpix
230    };
231
232    // Float quantization: NO_DITHER, SUBTRACTIVE_DITHER_1, and SUBTRACTIVE_DITHER_2.
233    let zquantiz = header
234        .get_text("ZQUANTIZ")
235        .unwrap_or("NO_DITHER")
236        .to_string();
237    let method = match zquantiz.as_str() {
238        "NO_DITHER" => DitherMethod::None,
239        "SUBTRACTIVE_DITHER_1" => DitherMethod::Subtractive1,
240        "SUBTRACTIVE_DITHER_2" => DitherMethod::Subtractive2,
241        other => {
242            if is_float {
243                return Err(FitsError::UnsupportedCompression {
244                    name: format!("float quantization {other}"),
245                });
246            }
247            DitherMethod::None
248        }
249    };
250    let zdither0 = header.get_integer("ZDITHER0").unwrap_or(1);
251    // ZBLANK may be a keyword (constant) or a per-tile column; §10.1.3 says the
252    // column value wins where present.
253    let zblank_keyword = header.get_integer("ZBLANK");
254    let zblank_column = read_i64_column(table, "ZBLANK");
255    let smooth = hcompress_smooth(header);
256    let params = CodecParams {
257        blocksize: rice.blocksize,
258        bytepix: rice.bytepix,
259        smooth,
260    };
261
262    // Per-tile compressed data, with the conventional fallback columns.
263    let primary = read_tiles(table, "COMPRESSED_DATA")?;
264    let gzip_fallback = read_tiles(table, "GZIP_COMPRESSED_DATA")?;
265    let uncompressed = read_tiles(table, "UNCOMPRESSED_DATA")?;
266    // Per-tile linear dequantization parameters (float only).
267    let zscale = read_f64_column(table, "ZSCALE");
268    let zzero = read_f64_column(table, "ZZERO");
269
270    let geom = TileGeometry::new(&dims, &tiles);
271    let ntiles = geom.ntiles();
272    let mut samples = zeroed_samples(zbitpix, total)?;
273
274    // Decode and scatter each tile in one fused pass — parallel under the `parallel`
275    // feature, where tiles write disjoint regions of `samples` concurrently (they
276    // partition the image). Each value is narrowed to `ZBITPIX` as it lands, so there
277    // is no whole-image `i64`/`f64` intermediate and no separate serial scatter tail.
278    let ctx = DecodeCtx {
279        codec: ImageCodec::parse(&cmptype)?,
280        zbitpix,
281        int_bitpix,
282        params,
283    };
284    if is_float {
285        let decode = |t: usize, s: &TileScratch, out: &mut Vec<f64>, ints: &mut Vec<i64>| {
286            let cols = TileColumns {
287                primary: primary.get(t),
288                gzip: gzip_fallback.get(t),
289                uncompressed: uncompressed.get(t),
290            };
291            let dq = Dequant {
292                scale: column_at(&zscale, t).unwrap_or(1.0),
293                zero: column_at(&zzero, t).unwrap_or(0.0),
294                method,
295                irow: t as i64 + zdither0,
296                zblank: column_at(&zblank_column, t).or(zblank_keyword),
297            };
298            decode_float_tile_into(&ctx, cols, s.nelem(), dq, out, ints)
299        };
300        match &mut samples {
301            ImageData::F32(o) => run_decode_scatter(ntiles, &geom, o, decode, |v| v as f32)?,
302            ImageData::F64(o) => run_decode_scatter(ntiles, &geom, o, decode, |v| v)?,
303            _ => unreachable!("a float ZBITPIX yields a float sample buffer"),
304        }
305    } else {
306        let decode = |t: usize, s: &TileScratch, out: &mut Vec<i64>, _ints: &mut Vec<i64>| {
307            let cols = TileColumns {
308                primary: primary.get(t),
309                gzip: gzip_fallback.get(t),
310                uncompressed: uncompressed.get(t),
311            };
312            decode_one_tile_into(&ctx, cols, s.nelem(), out)
313        };
314        match &mut samples {
315            ImageData::U8(o) => run_decode_scatter(ntiles, &geom, o, decode, |v| v as u8)?,
316            ImageData::I16(o) => run_decode_scatter(ntiles, &geom, o, decode, |v| v as i16)?,
317            ImageData::I32(o) => run_decode_scatter(ntiles, &geom, o, decode, |v| v as i32)?,
318            ImageData::I64(o) => run_decode_scatter(ntiles, &geom, o, decode, |v| v)?,
319            _ => unreachable!("an integer ZBITPIX yields an integer sample buffer"),
320        }
321    }
322    Ok(Image {
323        shape: dims,
324        samples,
325        scaling: Scaling::from_header(header),
326    })
327}
328
329/// Decode every tile and scatter its values into `out` at the tile's positions,
330/// narrowing each with `convert`. Under `parallel` the tiles run concurrently and
331/// write disjoint regions of `out` directly (no collect, no serial scatter);
332/// otherwise it is a plain fused loop.
333fn run_decode_scatter<S, D>(
334    ntiles: usize,
335    geom: &TileGeometry,
336    out: &mut [D],
337    decode: impl Fn(usize, &TileScratch, &mut Vec<S>, &mut Vec<i64>) -> Result<()> + Sync + Send,
338    convert: impl Fn(S) -> D + Sync + Send,
339) -> Result<()>
340where
341    S: Copy + Send,
342{
343    // Per-worker decode buffers, reused across that worker's tiles (one set per rayon
344    // worker via `map_init`, a single set serially): `vals` is the decoded tile (the
345    // scatter source), `ints` the float path's quantized-int temp (unused otherwise).
346    // Reusing them means steady-state decode allocates nothing per tile, and the
347    // buffers stay cache-resident across tiles.
348    #[cfg(feature = "parallel")]
349    {
350        let sink = DisjointOut::new(out);
351        let init = || (TileScratch::default(), Vec::<S>::new(), Vec::<i64>::new());
352        map_tiles(ntiles, init, |(scratch, vals, ints), t| -> Result<()> {
353            geom.tile_into(t, scratch);
354            decode(t, scratch, vals, ints)?;
355            // SAFETY: the image tiles partition the pixel grid, so this tile's row
356            // ranges are disjoint from every other tile's — concurrent writes through
357            // `sink` never alias. `tile_into` clips rows to the image, which sized
358            // `out`, so each row is in bounds.
359            unsafe { sink.scatter_rows(&scratch.row_bases, scratch.row_len, vals, &convert) };
360            Ok(())
361        })?;
362        Ok(())
363    }
364    #[cfg(not(feature = "parallel"))]
365    {
366        let mut scratch = TileScratch::default();
367        let mut vals: Vec<S> = Vec::new();
368        let mut ints: Vec<i64> = Vec::new();
369        for t in 0..ntiles {
370            geom.tile_into(t, &mut scratch);
371            decode(t, &scratch, &mut vals, &mut ints)?;
372            scatter_rows(out, &scratch.row_bases, scratch.row_len, &vals, &convert);
373        }
374        Ok(())
375    }
376}
377
378/// Scatter `vals` (the tile's pixels in row-major order) into `out` one contiguous
379/// row at a time: `row_len` values land at each `row_bases` offset, narrowed by
380/// `convert`. A `vals` shorter than the tile fills only what it covers (matching the
381/// old index-zip), so a malformed tile can't index out of bounds.
382#[cfg(not(feature = "parallel"))]
383fn scatter_rows<S: Copy, D>(
384    out: &mut [D],
385    row_bases: &[usize],
386    row_len: usize,
387    vals: &[S],
388    convert: &impl Fn(S) -> D,
389) {
390    let mut off = 0;
391    for &base in row_bases {
392        if off >= vals.len() {
393            break;
394        }
395        let rl = row_len.min(vals.len() - off);
396        for (d, &v) in out[base..base + rl].iter_mut().zip(&vals[off..off + rl]) {
397            *d = convert(v);
398        }
399        off += row_len;
400    }
401}
402
403/// A raw pointer into the decode output, shared across rayon workers so each tile
404/// scatters its decoded values in place. The `Sync` impl is sound *only* under the
405/// contract that callers write disjoint index sets — which holds because the image
406/// tiles partition the pixel grid (see [`run_decode_scatter`]).
407#[cfg(feature = "parallel")]
408#[derive(Debug)]
409struct DisjointOut<D> {
410    ptr: *mut D,
411    len: usize,
412}
413
414// SAFETY: see the type doc — concurrent use only writes disjoint, in-bounds indices.
415#[cfg(feature = "parallel")]
416unsafe impl<D> Sync for DisjointOut<D> {}
417
418#[cfg(feature = "parallel")]
419impl<D> DisjointOut<D> {
420    fn new(out: &mut [D]) -> DisjointOut<D> {
421        DisjointOut {
422            ptr: out.as_mut_ptr(),
423            len: out.len(),
424        }
425    }
426
427    /// Write `vals` (row-major) into the tile's contiguous rows: `row_len` values at
428    /// each `row_bases` offset, narrowed by `convert`. A short `vals` fills only what
429    /// it covers (matching the serial [`scatter_rows`]).
430    ///
431    /// # Safety
432    /// Each `[base, base + row_len)` range must be `<= self.len` and disjoint from
433    /// those passed by any concurrent call, so no two writes alias.
434    unsafe fn scatter_rows<S: Copy>(
435        &self,
436        row_bases: &[usize],
437        row_len: usize,
438        vals: &[S],
439        convert: &impl Fn(S) -> D,
440    ) {
441        let mut off = 0;
442        for &base in row_bases {
443            if off >= vals.len() {
444                break;
445            }
446            let rl = row_len.min(vals.len() - off);
447            debug_assert!(base + rl <= self.len, "tile row out of bounds {}", self.len);
448            // SAFETY: `[base, base + rl)` is in bounds (debug-asserted; guaranteed by
449            // the tile geometry) and disjoint across tiles, so these are non-aliasing
450            // in-bounds writes over one contiguous run.
451            let dst = unsafe { std::slice::from_raw_parts_mut(self.ptr.add(base), rl) };
452            for (d, &v) in dst.iter_mut().zip(&vals[off..off + rl]) {
453                *d = convert(v);
454            }
455            off += row_len;
456        }
457    }
458}
459
460/// Per-worker tile-encode scratch, reused across the tiles one rayon worker
461/// handles (via `map_tiles`'s `init`): the tile geometry plus the widened pixel
462/// buffers the codec reads from. Reusing them means steady-state tile compression
463/// allocates only each tile's compressed output, not the gather buffers.
464#[derive(Debug, Default)]
465struct EncodeScratch {
466    tile: TileScratch,
467    /// The tile's pixels widened to `i64` — the integer codec input (and the
468    /// quantized int32 plane for float images).
469    ints: Vec<i64>,
470    /// The tile's pixels as `f64` (float images only; stays empty otherwise).
471    floats: Vec<f64>,
472    /// The tile's pixels packed to big-endian bytes — the gzip codecs' input,
473    /// reused so each tile allocates only its compressed output.
474    be: Vec<u8>,
475}
476
477/// The tiled-image codec selected by `ZCMPTYPE`, parsed once from the keyword string
478/// then matched exhaustively — the image-path counterpart to the table path's `Algo`.
479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
480enum ImageCodec {
481    Gzip1,
482    Gzip2,
483    Rice1,
484    Plio1,
485    Hcompress1,
486    NoCompress,
487}
488
489impl ImageCodec {
490    fn parse(s: &str) -> Result<ImageCodec> {
491        Ok(match s {
492            "GZIP_1" => ImageCodec::Gzip1,
493            "GZIP_2" => ImageCodec::Gzip2,
494            "RICE_1" => ImageCodec::Rice1,
495            "PLIO_1" => ImageCodec::Plio1,
496            "HCOMPRESS_1" => ImageCodec::Hcompress1,
497            "NOCOMPRESS" => ImageCodec::NoCompress,
498            other => {
499                return Err(FitsError::UnsupportedCompression {
500                    name: other.to_string(),
501                });
502            }
503        })
504    }
505}
506
507/// Encode an integer [`Image`] as a tiled-compressed `BINTABLE`: returns the
508/// `ZIMAGE` header and the data unit (per-tile `P` descriptors + the heap of
509/// compressed tile bytes). Float images and codecs without an encoder are rejected.
510pub(crate) fn compress_image(
511    image: &Image,
512    cmptype: &str,
513    options: &CompressOptions,
514    out: &mut Vec<u8>,
515) -> Result<Header> {
516    let bitpix = image.samples.bitpix();
517    if bitpix.is_float() {
518        return compress_float_image(image, cmptype, options, out);
519    }
520    let codec = ImageCodec::parse(cmptype)?;
521    // RICE handles only 1/2/4-byte pixels (cfitsio parity); refuse the 64-bit path
522    // rather than silently corrupting. Table 37 lists BYTEPIX 8 as permitted, but
523    // neither this encoder nor the decoder implements the 64-bit bitstream.
524    if codec == ImageCodec::Rice1 && bitpix.elem_size() > 4 {
525        return Err(FitsError::UnsupportedCompression {
526            name: "RICE_1 with BYTEPIX > 4 (64-bit pixels)".to_string(),
527        });
528    }
529    // HCOMPRESS is a 32-bit transform; an I64 image would be silently truncated to
530    // i32 by the encoder. Refuse it rather than corrupt (I32 stays supported, with
531    // the documented "moderate values" caveat against H-transform overflow).
532    if codec == ImageCodec::Hcompress1 && bitpix.elem_size() > 4 {
533        return Err(FitsError::UnsupportedCompression {
534            name: "HCOMPRESS_1 with 64-bit pixels".to_string(),
535        });
536    }
537    let dims = &image.shape;
538    let tiles = resolve_tile_shape(dims, &options.tile_shape)?;
539
540    let geom = TileGeometry::new(dims, &tiles);
541    // A `NAXIS = 0` image has no data array; `geom.ntiles()` would otherwise be the
542    // empty product (1) and fabricate a phantom one-pixel tile that indexes the empty
543    // sample buffer. Zero tiles yields an empty `NAXIS2 = 0` table, which the decoder's
544    // matching `ZNAXIS = 0` guard turns back into the empty image.
545    let ntiles = if dims.is_empty() { 0 } else { geom.ntiles() };
546    let bytepix = bitpix.elem_size();
547    let (gzip_level, scale) = (options.gzip_level, options.hcompress_scale);
548
549    // Compress every tile independently (the compute-bound step — parallel under
550    // the `parallel` feature). The heap layout is sequential (each descriptor's
551    // offset is the running heap length), so concatenate the cells serially after.
552    let cells = map_tiles(ntiles, EncodeScratch::default, |s, t| -> Result<TileCell> {
553        geom.tile_into(t, &mut s.tile);
554        // Gather + widen this tile's pixels straight from the typed source — no
555        // whole-image `i64` buffer.
556        gather_i64(
557            &image.samples,
558            &s.tile.row_bases,
559            s.tile.row_len,
560            &mut s.ints,
561        );
562        let vals = &s.ints;
563        Ok(match codec {
564            ImageCodec::Gzip1 => {
565                i64_to_be_into(vals, bitpix, &mut s.be);
566                TileCell::Bytes(gzip::gzip_encode(&s.be, gzip_level))
567            }
568            ImageCodec::Gzip2 => {
569                i64_to_be_into(vals, bitpix, &mut s.be);
570                TileCell::Bytes(gzip::gzip2_encode(&s.be, bytepix, gzip_level))
571            }
572            ImageCodec::Rice1 => TileCell::Bytes(rice::rice_encode(vals, bytepix, 32)),
573            ImageCodec::Plio1 => TileCell::I16(plio::plio_encode(vals, vals.len())),
574            ImageCodec::Hcompress1 => TileCell::Bytes(hcompress::hcompress_tile_encode(
575                vals,
576                &s.tile.tdims,
577                scale,
578            )?),
579            // §10.4: store the tile's raw big-endian pixels, uncompressed.
580            ImageCodec::NoCompress => TileCell::Bytes(i64_to_be(vals, bitpix)),
581        })
582    })?;
583
584    let mut descriptors: Vec<(usize, usize)> = Vec::with_capacity(ntiles);
585    let mut heap: Vec<u8> = Vec::new();
586    for cell in &cells {
587        descriptors.push((cell.nelem(), heap.len()));
588        cell.extend_heap(&mut heap);
589    }
590
591    let maxnelem = descriptors.iter().map(|&(n, _)| n).max().unwrap_or(0);
592    // §10.1.3: use 64-bit `Q` descriptors once the heap or a tile's element count
593    // exceeds the 32-bit `P` range; otherwise the compact `P` form.
594    let wide = heap.len() > i32::MAX as usize || maxnelem > i32::MAX as usize;
595
596    // Data unit: an array descriptor (count, heap offset) per tile, then the heap.
597    // Built into the caller's reused buffer (the writer's scratch).
598    out.clear();
599    out.reserve(ntiles * if wide { 16 } else { 8 } + heap.len());
600    for &(nelem, offset) in &descriptors {
601        push_pq_descriptor(out, wide, nelem as u64, offset as u64);
602    }
603    out.extend_from_slice(&heap);
604
605    let tform_letter = if codec == ImageCodec::Plio1 { 'I' } else { 'B' };
606    let desc = if wide { 'Q' } else { 'P' };
607    let mut h = Header::new();
608    h.set("XTENSION", "BINTABLE")
609        .comment("XTENSION", "binary table extension");
610    h.set("BITPIX", 8).set("NAXIS", 2);
611    h.set("NAXIS1", if wide { 16 } else { 8 })
612        .set("NAXIS2", ntiles as i64);
613    h.set("PCOUNT", heap.len() as i64).set("GCOUNT", 1);
614    h.set("TFIELDS", 1);
615    h.set("TTYPE1", "COMPRESSED_DATA");
616    h.set("TFORM1", format!("1{desc}{tform_letter}({maxnelem})"));
617    set_zimage_axes(&mut h, cmptype, bitpix, dims, &tiles);
618    match codec {
619        ImageCodec::Rice1 => {
620            h.set("ZNAME1", "BLOCKSIZE").set("ZVAL1", 32);
621            h.set("ZNAME2", "BYTEPIX").set("ZVAL2", bytepix as i64);
622        }
623        ImageCodec::Hcompress1 => {
624            h.set("ZNAME1", "SCALE").set("ZVAL1", scale as i64);
625            h.set("ZNAME2", "SMOOTH").set("ZVAL2", 0);
626        }
627        _ => {}
628    }
629    // §10.2: tiles store the *raw* stored integers, so the original image's
630    // physical scaling and undefined-pixel sentinel must travel in the header to
631    // be reconstructed on decode (`bitpix` is integer here — floats diverted above).
632    if !image.scaling.is_identity() {
633        h.set("BZERO", image.scaling.bzero);
634        h.set("BSCALE", image.scaling.bscale);
635    }
636    if let Some(blank) = image.scaling.blank {
637        h.set("BLANK", blank);
638    }
639    Ok(h)
640}
641
642/// One encoded float tile: its compressed bytes plus the per-tile dequantization
643/// metadata. `quantized` distinguishes a normal tile (bytes → `COMPRESSED_DATA`,
644/// `zscale`/`zzero` meaningful) from a constant tile stored as raw gzip'd floats in
645/// the `GZIP_COMPRESSED_DATA` fallback.
646#[derive(Debug)]
647struct FloatTile {
648    bytes: Vec<u8>,
649    zscale: f64,
650    zzero: f64,
651    quantized: bool,
652    has_null: bool,
653}
654
655/// Encode a float [`Image`] as a quantized, tiled-compressed `BINTABLE`
656/// (`SUBTRACTIVE_DITHER_1`). Each tile is quantized to int32 with a per-tile
657/// `ZSCALE`/`ZZERO`, then compressed with `cmptype` (`GZIP_1`/`GZIP_2`/`RICE_1`);
658/// a tile that can't be quantized (constant data) is stored as raw gzip'd floats
659/// in `GZIP_COMPRESSED_DATA`. The table has four columns: `COMPRESSED_DATA`,
660/// `GZIP_COMPRESSED_DATA`, `ZSCALE`, `ZZERO`.
661fn compress_float_image(
662    image: &Image,
663    cmptype: &str,
664    options: &CompressOptions,
665    out: &mut Vec<u8>,
666) -> Result<Header> {
667    let codec = ImageCodec::parse(cmptype)?;
668    if !matches!(
669        codec,
670        ImageCodec::Gzip1 | ImageCodec::Gzip2 | ImageCodec::Rice1
671    ) {
672        return Err(FitsError::UnsupportedCompression {
673            name: format!("{cmptype} for float images (write)"),
674        });
675    }
676    let zbitpix = image.samples.bitpix();
677    let dims = &image.shape;
678    let tiles = resolve_tile_shape(dims, &options.tile_shape)?;
679
680    let geom = TileGeometry::new(dims, &tiles);
681    // See `compress_image`: zero tiles for a `NAXIS = 0` image, avoiding the phantom
682    // one-pixel tile the empty-product `ntiles` would otherwise produce.
683    let ntiles = if dims.is_empty() { 0 } else { geom.ntiles() };
684
685    let zdither0 = 1i64; // deterministic dither seed (any 1..=10000 is valid)
686    let int_bitpix = Bitpix::I32; // quantized planes are always int32
687    let method = options.dither;
688    let (gzip_level, qlevel) = (options.gzip_level, options.quantize_level);
689
690    // Quantize + compress each tile independently (the compute-bound step —
691    // parallel under the `parallel` feature); the §10 row layout and heap offsets
692    // are assembled serially after, since they are sequential.
693    let tiles_out = map_tiles(
694        ntiles,
695        EncodeScratch::default,
696        |s, t| -> Result<FloatTile> {
697            geom.tile_into(t, &mut s.tile);
698            let nx = s.tile.tdims[0];
699            let ny = s.tile.row_bases.len();
700            // Gather + widen this tile's pixels straight from the typed source.
701            gather_f64(
702                &image.samples,
703                &s.tile.row_bases,
704                s.tile.row_len,
705                &mut s.floats,
706            );
707            let irow = t as i64 + zdither0; // = (1-based tile row) + ZDITHER0 - 1
708            Ok(
709                match quantize::quantize_tile(&s.floats, nx, ny, qlevel, method, irow) {
710                    Some(q) => {
711                        s.ints.clear();
712                        s.ints.extend(q.idata.iter().map(|&v| v as i64));
713                        let bytes = match codec {
714                            ImageCodec::Gzip1 => {
715                                i64_to_be_into(&s.ints, int_bitpix, &mut s.be);
716                                gzip::gzip_encode(&s.be, gzip_level)
717                            }
718                            ImageCodec::Gzip2 => {
719                                i64_to_be_into(&s.ints, int_bitpix, &mut s.be);
720                                gzip::gzip2_encode(&s.be, 4, gzip_level)
721                            }
722                            ImageCodec::Rice1 => rice::rice_encode(&s.ints, 4, 32),
723                            _ => unreachable!(),
724                        };
725                        FloatTile {
726                            bytes,
727                            zscale: q.bscale,
728                            zzero: q.bzero,
729                            quantized: true,
730                            has_null: q.has_null,
731                        }
732                    }
733                    // Constant tile: store the raw floats, gzip'd, in the fallback.
734                    None => FloatTile {
735                        bytes: gzip::gzip_encode(&float_to_be(&s.floats, zbitpix), gzip_level),
736                        zscale: 0.0,
737                        zzero: 0.0,
738                        quantized: false,
739                        has_null: false,
740                    },
741                },
742            )
743        },
744    )?;
745
746    let mut cd_desc: Vec<(usize, usize)> = Vec::with_capacity(ntiles);
747    let mut gz_desc: Vec<(usize, usize)> = Vec::with_capacity(ntiles);
748    let mut zscale = vec![0f64; ntiles];
749    let mut zzero = vec![0f64; ntiles];
750    let mut heap: Vec<u8> = Vec::new();
751    let mut any_null = false;
752    for (t, tile) in tiles_out.iter().enumerate() {
753        zscale[t] = tile.zscale;
754        zzero[t] = tile.zzero;
755        any_null |= tile.has_null;
756        let (cd, gz) = if tile.quantized {
757            ((tile.bytes.len(), heap.len()), (0, heap.len()))
758        } else {
759            ((0, heap.len()), (tile.bytes.len(), heap.len()))
760        };
761        cd_desc.push(cd);
762        gz_desc.push(gz);
763        heap.extend_from_slice(&tile.bytes);
764    }
765
766    // Fixed table: per tile, the two `P` descriptors then the `ZSCALE`/`ZZERO`
767    // doubles (row width 32), followed by the heap.
768    out.clear();
769    out.reserve(ntiles * 32 + heap.len());
770    for t in 0..ntiles {
771        // Two 32-bit `P` descriptors (COMPRESSED_DATA, GZIP_COMPRESSED_DATA) then
772        // the ZSCALE/ZZERO doubles — the §10 quantized-float row layout.
773        push_pq_descriptor(out, false, cd_desc[t].0 as u64, cd_desc[t].1 as u64);
774        push_pq_descriptor(out, false, gz_desc[t].0 as u64, gz_desc[t].1 as u64);
775        out.extend_from_slice(&zscale[t].to_be_bytes());
776        out.extend_from_slice(&zzero[t].to_be_bytes());
777    }
778    out.extend_from_slice(&heap);
779
780    let max_cd = cd_desc.iter().map(|&(n, _)| n).max().unwrap_or(0);
781    let max_gz = gz_desc.iter().map(|&(n, _)| n).max().unwrap_or(0);
782    let mut h = Header::new();
783    h.set("XTENSION", "BINTABLE")
784        .comment("XTENSION", "binary table extension");
785    h.set("BITPIX", 8).set("NAXIS", 2);
786    h.set("NAXIS1", 32).set("NAXIS2", ntiles as i64);
787    h.set("PCOUNT", heap.len() as i64).set("GCOUNT", 1);
788    h.set("TFIELDS", 4);
789    h.set("TTYPE1", "COMPRESSED_DATA")
790        .set("TFORM1", format!("1PB({max_cd})"));
791    h.set("TTYPE2", "GZIP_COMPRESSED_DATA")
792        .set("TFORM2", format!("1PB({max_gz})"));
793    h.set("TTYPE3", "ZSCALE").set("TFORM3", "1D");
794    h.set("TTYPE4", "ZZERO").set("TFORM4", "1D");
795    set_zimage_axes(&mut h, cmptype, zbitpix, dims, &tiles);
796    if codec == ImageCodec::Rice1 {
797        h.set("ZNAME1", "BLOCKSIZE").set("ZVAL1", 32);
798        h.set("ZNAME2", "BYTEPIX").set("ZVAL2", 4);
799    } else {
800        // Tell the decoder the quantized integers are 4 bytes wide.
801        h.set("ZNAME1", "BYTEPIX").set("ZVAL1", 4);
802    }
803    h.set("ZQUANTIZ", dither_name(method));
804    h.set("ZDITHER0", zdither0);
805    if any_null {
806        // Quantized nulls are stored as this reserved integer; ZBLANK tells the
807        // decoder which value maps back to a blank (NaN) pixel.
808        h.set("ZBLANK", quantize::NULL_VALUE as i64);
809    }
810    Ok(h)
811}
812
813/// The `ZQUANTIZ` keyword string for a dither method.
814fn dither_name(method: DitherMethod) -> &'static str {
815    match method {
816        DitherMethod::None => "NO_DITHER",
817        DitherMethod::Subtractive1 => "SUBTRACTIVE_DITHER_1",
818        DitherMethod::Subtractive2 => "SUBTRACTIVE_DITHER_2",
819    }
820}
821
822/// One tile's compressed payload: a byte stream (`1PB`) for most codecs, or an
823/// i16 instruction list (`1PI`) for `PLIO_1`.
824#[derive(Debug)]
825enum TileCell {
826    Bytes(Vec<u8>),
827    I16(Vec<i16>),
828}
829
830impl TileCell {
831    /// Element count for the `P` descriptor (bytes, or i16 words).
832    fn nelem(&self) -> usize {
833        match self {
834            TileCell::Bytes(b) => b.len(),
835            TileCell::I16(v) => v.len(),
836        }
837    }
838
839    /// Append the cell to the heap as big-endian bytes.
840    fn extend_heap(&self, heap: &mut Vec<u8>) {
841        match self {
842            TileCell::Bytes(b) => heap.extend_from_slice(b),
843            TileCell::I16(v) => {
844                for &x in v {
845                    heap.extend_from_slice(&x.to_be_bytes());
846                }
847            }
848        }
849    }
850}
851
852/// Resolve the tile shape for an image: an empty `tile_shape` defaults to row-tiling
853/// (the first axis whole, the rest 1); a non-empty one is used as given (each axis
854/// clamped to ≥1) and must match the image rank, else [`FitsError::TileShapeRankMismatch`].
855fn resolve_tile_shape(dims: &[usize], tile_shape: &[usize]) -> Result<Vec<usize>> {
856    if tile_shape.is_empty() {
857        Ok((0..dims.len())
858            .map(|i| if i == 0 { dims[i].max(1) } else { 1 })
859            .collect())
860    } else if tile_shape.len() == dims.len() {
861        Ok(tile_shape.iter().map(|&t| t.max(1)).collect())
862    } else {
863        Err(FitsError::TileShapeRankMismatch {
864            tile_rank: tile_shape.len(),
865            image_rank: dims.len(),
866        })
867    }
868}
869
870/// Append the `ZIMAGE`/`ZCMPTYPE`/`ZBITPIX`/`ZNAXIS` keywords plus the per-axis
871/// `ZNAXISn`/`ZTILEn` series — the block shared verbatim by both the integer and
872/// float `ZIMAGE` headers.
873fn set_zimage_axes(
874    h: &mut Header,
875    cmptype: &str,
876    zbitpix: Bitpix,
877    dims: &[usize],
878    tiles: &[usize],
879) {
880    h.set("ZIMAGE", true)
881        .comment("ZIMAGE", "this is a tiled-compressed image");
882    h.set("ZCMPTYPE", cmptype);
883    h.set("ZBITPIX", zbitpix.code());
884    h.set("ZNAXIS", dims.len() as i64);
885    for (i, &n) in dims.iter().enumerate() {
886        h.set(key!("ZNAXIS{}", i + 1).as_str(), n as i64);
887    }
888    for (i, &t) in tiles.iter().enumerate() {
889        h.set(key!("ZTILE{}", i + 1).as_str(), t as i64);
890    }
891}
892
893/// Read a compressed-data column's per-tile cells, or empty if the column is absent.
894fn read_tiles(table: &BinTable, name: &str) -> Result<Vec<ColumnData>> {
895    match table.column_index(name) {
896        Some(c) => table.column_by_idx(c)?.vla(),
897        None => Ok(Vec::new()),
898    }
899}
900
901/// Read a per-tile `f64` column (e.g. `ZSCALE`/`ZZERO`), or `None` if absent.
902fn read_f64_column(table: &BinTable, name: &str) -> Option<Vec<f64>> {
903    let c = table.column_index(name)?;
904    match table.column_by_idx(c).and_then(|col| col.raw()) {
905        Ok(ColumnData::F64(v)) => Some(v),
906        _ => None,
907    }
908}
909
910/// Read a per-tile integer column (e.g. a `ZBLANK` column), widening any integer
911/// `TFORM` to `i64`, or `None` if absent.
912fn read_i64_column(table: &BinTable, name: &str) -> Option<Vec<i64>> {
913    let c = table.column_index(name)?;
914    match table.column_by_idx(c).and_then(|col| col.raw()) {
915        Ok(ColumnData::Bytes(v)) => Some(v.iter().map(|&x| x as i64).collect()),
916        Ok(ColumnData::I16(v)) => Some(v.iter().map(|&x| x as i64).collect()),
917        Ok(ColumnData::I32(v)) => Some(v.iter().map(|&x| x as i64).collect()),
918        Ok(ColumnData::I64(v)) => Some(v),
919        _ => None,
920    }
921}
922
923fn column_at<T: Copy>(col: &Option<Vec<T>>, t: usize) -> Option<T> {
924    col.as_ref().and_then(|v| v.get(t).copied())
925}
926
927/// Decode one tile, honoring the fallback columns: the primary `COMPRESSED_DATA`
928/// (via `ZCMPTYPE`), else gzip'd `GZIP_COMPRESSED_DATA`, else raw `UNCOMPRESSED_DATA`.
929/// The three per-tile source columns (§10.1.3): the primary `COMPRESSED_DATA` and
930/// the `GZIP_COMPRESSED_DATA` / `UNCOMPRESSED_DATA` fallbacks.
931#[derive(Debug, Clone, Copy)]
932struct TileColumns<'a> {
933    primary: Option<&'a ColumnData>,
934    gzip: Option<&'a ColumnData>,
935    uncompressed: Option<&'a ColumnData>,
936}
937
938/// The resolved source for one tile — which non-empty column holds its bytes.
939#[derive(Debug)]
940enum TileSource<'a> {
941    Compressed(&'a ColumnData),
942    Gzip(&'a ColumnData),
943    Uncompressed(&'a ColumnData),
944}
945
946impl<'a> TileColumns<'a> {
947    /// Pick the first non-empty source: primary `COMPRESSED_DATA`, then the
948    /// gzip and uncompressed fallbacks; error if every column's cell is empty.
949    fn resolve(&self) -> Result<TileSource<'a>> {
950        if let Some(c) = self.primary.filter(|c| cell_len(c) > 0) {
951            Ok(TileSource::Compressed(c))
952        } else if let Some(c) = self.gzip.filter(|c| cell_len(c) > 0) {
953            Ok(TileSource::Gzip(c))
954        } else if let Some(c) = self.uncompressed.filter(|c| cell_len(c) > 0) {
955            Ok(TileSource::Uncompressed(c))
956        } else {
957            Err(FitsError::UnsupportedCompression {
958                name: "empty tile (no compressed or uncompressed data)".to_string(),
959            })
960        }
961    }
962}
963
964/// The codec knobs from `ZNAMEi`/`ZVALi`: Rice block size & pixel width, and the
965/// HCOMPRESS `SMOOTH` flag.
966#[derive(Debug, Clone, Copy)]
967struct CodecParams {
968    blocksize: usize,
969    bytepix: usize,
970    smooth: bool,
971}
972
973/// Per-tile float dequantization parameters (§10.2): `physical = zero + scale·I`,
974/// the dither method/seed, and the integer null sentinel.
975#[derive(Debug)]
976struct Dequant {
977    scale: f64,
978    zero: f64,
979    method: DitherMethod,
980    irow: i64,
981    zblank: Option<i64>,
982}
983
984/// The decode parameters constant across all of a tiled image's tiles: the codec,
985/// the stored/quantized integer bitpix (and float `ZBITPIX`), and the codec knobs.
986/// Bundled so the per-tile decode helpers take one context rather than a long
987/// parameter list.
988#[derive(Debug)]
989struct DecodeCtx {
990    codec: ImageCodec,
991    zbitpix: Bitpix,
992    int_bitpix: Bitpix,
993    params: CodecParams,
994}
995
996fn decode_one_tile_into(
997    ctx: &DecodeCtx,
998    cols: TileColumns,
999    tile_elems: usize,
1000    out: &mut Vec<i64>,
1001) -> Result<()> {
1002    match cols.resolve()? {
1003        TileSource::Compressed(cell) => decode_tile_cell_into(ctx, cell, tile_elems, out),
1004        TileSource::Gzip(cell) => {
1005            gzip::gzip_tile_into(as_bytes(cell)?, ctx.int_bitpix, tile_elems, out)
1006        }
1007        TileSource::Uncompressed(cell) => {
1008            cell_to_i64_into(cell, out);
1009            Ok(())
1010        }
1011    }
1012}
1013
1014/// Decode one tile of a *float* image into `out`. A primary `COMPRESSED_DATA` cell
1015/// holds quantized integers (decoded into the reused `ints` buffer, then dequantized
1016/// as `scale·int + zero`); otherwise the `GZIP_COMPRESSED_DATA`/`UNCOMPRESSED_DATA`
1017/// fallbacks hold the raw float values.
1018fn decode_float_tile_into(
1019    ctx: &DecodeCtx,
1020    cols: TileColumns,
1021    tile_elems: usize,
1022    dq: Dequant,
1023    out: &mut Vec<f64>,
1024    ints: &mut Vec<i64>,
1025) -> Result<()> {
1026    match cols.resolve()? {
1027        TileSource::Compressed(cell) => {
1028            // Quantized integers (float images never use HCOMPRESS).
1029            decode_tile_cell_into(ctx, cell, tile_elems, ints)?;
1030            quantize::dequantize_into(ints, dq.scale, dq.zero, dq.method, dq.irow, dq.zblank, out);
1031            Ok(())
1032        }
1033        TileSource::Gzip(cell) => {
1034            // Raw floats, bounded at the tile's known byte size (`tile_elems` floats).
1035            let max = tile_elems.saturating_mul(ctx.zbitpix.elem_size());
1036            be_floats_into(&gzip::gunzip(as_bytes(cell)?, max)?, ctx.zbitpix, out);
1037            Ok(())
1038        }
1039        TileSource::Uncompressed(cell) => {
1040            cell_to_f64_into(cell, ctx.zbitpix, out);
1041            Ok(())
1042        }
1043    }
1044}
1045
1046/// Decode one tile's primary `COMPRESSED_DATA` cell into `tile_elems` integer values
1047/// in `out`, per `ZCMPTYPE`. The cell is a byte array except for `PLIO_1` (i16).
1048fn decode_tile_cell_into(
1049    ctx: &DecodeCtx,
1050    cell: &ColumnData,
1051    tile_elems: usize,
1052    out: &mut Vec<i64>,
1053) -> Result<()> {
1054    let params = ctx.params;
1055    match ctx.codec {
1056        ImageCodec::Gzip1 => gzip::gzip_tile_into(as_bytes(cell)?, ctx.int_bitpix, tile_elems, out),
1057        ImageCodec::Gzip2 => {
1058            gzip::gzip2_tile_into(as_bytes(cell)?, ctx.int_bitpix, tile_elems, out)
1059        }
1060        ImageCodec::Rice1 => {
1061            // Only 1/2/4-byte pixels are defined (cfitsio parity). A `BYTEPIX` of
1062            // 3/5/6/7 from an untrusted header would otherwise decode with mismatched
1063            // `fsbits`/mask and emit garbage instead of erroring.
1064            if !matches!(params.bytepix, 1 | 2 | 4) {
1065                return Err(FitsError::UnsupportedCompression {
1066                    name: format!("RICE_1 with BYTEPIX = {} (only 1/2/4)", params.bytepix),
1067                });
1068            }
1069            rice::rice_decode_into(
1070                as_bytes(cell)?,
1071                tile_elems,
1072                params.bytepix,
1073                params.blocksize,
1074                out,
1075            );
1076            Ok(())
1077        }
1078        ImageCodec::Plio1 => {
1079            plio::plio_decode_into(as_i16(cell)?, tile_elems, out);
1080            Ok(())
1081        }
1082        ImageCodec::Hcompress1 => {
1083            hcompress::hcompress_tile_into(as_bytes(cell)?, params.smooth, tile_elems, out)
1084        }
1085        // §10.4: a tile stored verbatim — the cell is the raw big-endian pixels.
1086        ImageCodec::NoCompress => {
1087            be_to_i64_into(as_bytes(cell)?, ctx.int_bitpix, out);
1088            Ok(())
1089        }
1090    }
1091}
1092
1093/// HCOMPRESS smoothing flag: the `SMOOTH` `ZVALn` is non-zero (cfitsio applies
1094/// inverse-transform smoothing to suppress blocking in lossy images).
1095fn hcompress_smooth(header: &Header) -> bool {
1096    let mut i = 1;
1097    while let Some(name) = header.get_text(key!("ZNAME{i}").as_str()) {
1098        if name == "SMOOTH" {
1099            return header.get_integer(key!("ZVAL{i}").as_str()).unwrap_or(0) != 0;
1100        }
1101        i += 1;
1102    }
1103    false
1104}
1105
1106/// Read the `ZNAXIS1..ZNAXISn` integer axis lengths.
1107fn read_axes(header: &Header, n: usize) -> Result<Vec<usize>> {
1108    (1..=n)
1109        .map(|i| match header.get_integer(key!("ZNAXIS{i}").as_str()) {
1110            Some(v) if v >= 0 => Ok(v as usize),
1111            Some(_) => Err(FitsError::KeywordOutOfRange { name: "ZNAXISn" }),
1112            None => Err(FitsError::MissingKeyword { name: "ZNAXISn" }),
1113        })
1114        .collect()
1115}
1116
1117#[cfg(test)]
1118mod tests;