Skip to main content

limnifs_write/file_categorizer/
fits.rs

1//! FITS image categorizer — routes FITS files to ricepp.
2//!
3//! **Status:** DETECTION READY, ROUTING DISABLED.
4//!
5//! FITS (Flexible Image Transport System) is the standard format
6//! for astronomical images. Each file begins with a 2880-byte
7//! header containing key-value records in ASCII. The key fields for
8//! ricepp routing:
9//!
10//! - `SIMPLE  = T` — marks a primary header (always the first record)
11//! - `BITPIX  = N` — bits per pixel (8, 16, 32, -32 for float, -64 for double)
12//! - `NAXIS   = N` — number of axes (2 for a 2D image)
13//! - `NAXISn  = N` — size of axis n
14//!
15//! All FITS files are big-endian.
16//!
17//! Routing is disabled until `omnizip-ricepp` ships a real Rice++
18//! encoder. When it does, flip `RICEPP_ENABLED` to `true` and the
19//! categorizer will claim FITS files for the ricepp codec (id 0x08).
20
21use std::path::Path;
22
23use super::{Categorization, FileCategorizer};
24use limnifs_core::codec::CODEC_RICEPP;
25
26/// Flip to `false` if ricepp routing causes regressions. Currently
27/// always on because `omnizip-ricepp` 0.4 ships a working encoder
28/// and the wrapper at `limnifs-core::codec::ricepp` round-trips.
29const RICEPP_ENABLED: bool = true;
30
31/// First 9 bytes of every FITS primary header.
32const FITS_MAGIC: &[u8] = b"SIMPLE  =";
33
34/// FITS records are 80 bytes, header blocks are 2880 bytes.
35const FITS_RECORD_LEN: usize = 80;
36
37/// Parameters extracted from the FITS header for the ricepp codec.
38#[derive(Clone, Copy, Debug, Eq, PartialEq)]
39pub struct FitsParams {
40    /// Bits per pixel (8, 16, 32, -32, -64).
41    pub bitpix: i32,
42    /// Number of axes (typically 2 for a 2D image).
43    pub naxis: u32,
44    /// Size of axis 1.
45    pub naxis1: u32,
46    /// Size of axis 2.
47    pub naxis2: u32,
48}
49
50impl FitsParams {
51    /// Encode as a compact 16-byte prefix the ricepp codec can decode.
52    #[must_use]
53    pub fn encode(&self) -> [u8; 16] {
54        let mut out = [0u8; 16];
55        out[0..4].copy_from_slice(&self.bitpix.to_le_bytes());
56        out[4..8].copy_from_slice(&self.naxis.to_le_bytes());
57        out[8..12].copy_from_slice(&self.naxis1.to_le_bytes());
58        out[12..16].copy_from_slice(&self.naxis2.to_le_bytes());
59        out
60    }
61}
62
63pub struct FitsCategorizer;
64
65impl FileCategorizer for FitsCategorizer {
66    fn name(&self) -> &'static str {
67        "fits"
68    }
69
70    fn categories(&self) -> &'static [&'static str] {
71        &["fits/image"]
72    }
73
74    fn first_byte_hint(&self) -> Option<&'static [u8]> {
75        Some(b"S")
76    }
77
78    fn categorize(&self, _path: &Path, data: &[u8]) -> Option<Categorization> {
79        if !RICEPP_ENABLED {
80            return None;
81        }
82        let params = parse_fits(data)?;
83        Some(Categorization {
84            codec_id: CODEC_RICEPP,
85            codec_params: params.encode().to_vec(),
86            category: "fits/image",
87        })
88    }
89}
90
91/// Parse the FITS primary header. Returns `None` if the magic is
92/// missing or the required keys (`BITPIX`, `NAXIS`) can't be parsed.
93#[must_use]
94fn parse_fits(data: &[u8]) -> Option<FitsParams> {
95    if data.len() < FITS_MAGIC.len() + FITS_RECORD_LEN {
96        return None;
97    }
98    if &data[0..FITS_MAGIC.len()] != FITS_MAGIC {
99        return None;
100    }
101    let mut bitpix: Option<i32> = None;
102    let mut naxis: Option<u32> = None;
103    let mut naxis1: Option<u32> = None;
104    let mut naxis2: Option<u32> = None;
105
106    // Walk up to the END record or the first header block boundary.
107    let header_block = data.len().min(2880);
108    let mut off = 0;
109    while off + FITS_RECORD_LEN <= header_block {
110        let record = &data[off..off + FITS_RECORD_LEN];
111        let key = &record[0..8];
112        let value = &record[10..30];
113        if key.starts_with(b"END     ") {
114            break;
115        }
116        match key {
117            b"BITPIX  " => bitpix = parse_int(value),
118            b"NAXIS   " => naxis = parse_int(value).map(|i| i as u32),
119            b"NAXIS1  " => naxis1 = parse_int(value).map(|i| i as u32),
120            b"NAXIS2  " => naxis2 = parse_int(value).map(|i| i as u32),
121            _ => {}
122        }
123        off += FITS_RECORD_LEN;
124    }
125    Some(FitsParams {
126        bitpix: bitpix?,
127        naxis: naxis?,
128        naxis1: naxis1.unwrap_or(0),
129        naxis2: naxis2.unwrap_or(0),
130    })
131}
132
133/// Parse a FORTRAN-style integer from a value field. Returns `None`
134/// on parse failure.
135fn parse_int(value: &[u8]) -> Option<i32> {
136    let s = std::str::from_utf8(value).ok()?;
137    s.trim().trim_end_matches('/').trim().parse().ok()
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    fn make_fits_record(key: &str, value: &str) -> [u8; FITS_RECORD_LEN] {
145        let mut rec = [b' '; FITS_RECORD_LEN];
146        let key_bytes = key.as_bytes();
147        let copy_len = key_bytes.len().min(8);
148        rec[..copy_len].copy_from_slice(&key_bytes[..copy_len]);
149        rec[8] = b'=';
150        rec[9] = b' ';
151        let value_bytes = value.as_bytes();
152        let copy_len = value_bytes.len().min(20);
153        rec[10..10 + copy_len].copy_from_slice(&value_bytes[..copy_len]);
154        rec
155    }
156
157    fn make_fits_header() -> Vec<u8> {
158        let mut buf = Vec::new();
159        buf.extend_from_slice(&make_fits_record("SIMPLE", "T"));
160        buf.extend_from_slice(&make_fits_record("BITPIX", "16"));
161        buf.extend_from_slice(&make_fits_record("NAXIS", "2"));
162        buf.extend_from_slice(&make_fits_record("NAXIS1", "512"));
163        buf.extend_from_slice(&make_fits_record("NAXIS2", "512"));
164        buf.extend_from_slice(&make_fits_record("END", ""));
165        // Pad to 2880.
166        while buf.len() < 2880 {
167            buf.push(0);
168        }
169        buf
170    }
171
172    #[test]
173    fn fits_routes_to_ricepp_when_enabled() {
174        let c = FitsCategorizer;
175        let fits = make_fits_header();
176        let cat = c
177            .categorize(Path::new("/x.fits"), &fits)
178            .expect("fits claims");
179        assert_eq!(cat.codec_id, limnifs_core::codec::CODEC_RICEPP);
180    }
181
182    #[test]
183    fn fits_header_parsed_correctly() {
184        let fits = make_fits_header();
185        let params = parse_fits(&fits).expect("fits parses");
186        assert_eq!(params.bitpix, 16);
187        assert_eq!(params.naxis, 2);
188        assert_eq!(params.naxis1, 512);
189        assert_eq!(params.naxis2, 512);
190    }
191
192    #[test]
193    fn rejects_non_fits_magic() {
194        let junk = vec![0u8; 4096];
195        assert!(parse_fits(&junk).is_none());
196    }
197}