Skip to main content

convolve_rs/
fits_io.rs

1//! FITS image reading and writing.
2//!
3//! Handles both 2D (NAXIS=2, shape `[ny, nx]`) and 4D (NAXIS=4, shape
4//! `[1,1,ny,nx]`) images as produced by ASKAP/CASA.
5use std::path::{Path, PathBuf};
6
7use fitsio::FitsFile;
8use ndarray::Array2;
9use thiserror::Error;
10
11use crate::beam::Beam;
12use crate::smooth::BrightnessUnit;
13
14#[derive(Debug, Error)]
15pub enum FitsError {
16    #[error("FITS I/O error: {0}")]
17    Fitsio(#[from] fitsio::errors::Error),
18    #[error("missing keyword: {0}")]
19    MissingKeyword(String),
20    #[error("unsupported NAXIS={0} (expected 2 or 4)")]
21    UnsupportedNaxis(i64),
22    #[error("I/O error: {0}")]
23    Io(#[from] std::io::Error),
24}
25
26pub struct FitsImageData {
27    pub path: PathBuf,
28    pub image: Array2<f32>,
29    pub is_4d: bool,
30    /// |CDELT1| in degrees (x / RA pixel size)
31    pub dx_deg: f64,
32    /// |CDELT2| in degrees (y / Dec pixel size)
33    pub dy_deg: f64,
34    pub beam: Beam,
35    /// Brightness unit from BUNIT (defaults to Jy/beam if absent).
36    pub unit: BrightnessUnit,
37    /// Full header keyword list (key, value strings) for re-writing.
38    pub header_cards: Vec<(String, String)>,
39}
40
41/// Read a 2D or 4D FITS image.
42pub fn read_fits(path: &Path) -> Result<FitsImageData, FitsError> {
43    let path_str = path.to_string_lossy().into_owned();
44    let mut fptr = FitsFile::open(&path_str)?;
45    let hdu = fptr.primary_hdu()?;
46
47    let naxis: i64 = hdu.read_key(&mut fptr, "NAXIS")?;
48    let naxis1: i64 = hdu.read_key(&mut fptr, "NAXIS1")?; // x / RA (cols)
49    let naxis2: i64 = hdu.read_key(&mut fptr, "NAXIS2")?; // y / Dec (rows)
50
51    if naxis != 2 && naxis != 4 {
52        return Err(FitsError::UnsupportedNaxis(naxis));
53    }
54
55    let nx = naxis1 as usize;
56    let ny = naxis2 as usize;
57
58    // Read flat image data (works for both 2D and 4D when extra axes are size 1).
59    let data: Vec<f32> = hdu.read_image(&mut fptr)?;
60    let image = Array2::from_shape_vec((ny, nx), data)
61        .map_err(|e| FitsError::Io(std::io::Error::other(e.to_string())))?;
62
63    // Pixel scales — use absolute values since CDELT1 is negative for RA.
64    let cdelt1: f64 = hdu.read_key(&mut fptr, "CDELT1")?;
65    let cdelt2: f64 = hdu.read_key(&mut fptr, "CDELT2")?;
66    let dx_deg = cdelt1.abs();
67    let dy_deg = cdelt2.abs();
68
69    // Beam.
70    let bmaj: f64 = hdu
71        .read_key(&mut fptr, "BMAJ")
72        .map_err(|_| FitsError::MissingKeyword("BMAJ".into()))?;
73    let bmin: f64 = hdu.read_key(&mut fptr, "BMIN").unwrap_or(bmaj);
74    let bpa: f64 = hdu.read_key(&mut fptr, "BPA").unwrap_or(0.0);
75
76    let beam = Beam::new(bmaj, bmin, bpa)
77        .map_err(|e| FitsError::Io(std::io::Error::other(e.to_string())))?;
78
79    // Brightness unit (BUNIT); warn and default to Jy/beam when absent.
80    let unit = match hdu.read_key::<String>(&mut fptr, "BUNIT") {
81        Ok(s) => BrightnessUnit::from_bunit(&s),
82        Err(_) => {
83            tracing::warn!(
84                "No BUNIT keyword in {}; assuming Jy/beam (flux scaling applied).",
85                path.display()
86            );
87            BrightnessUnit::default()
88        }
89    };
90
91    Ok(FitsImageData {
92        path: path.to_path_buf(),
93        image,
94        is_4d: naxis == 4,
95        dx_deg,
96        dy_deg,
97        beam,
98        unit,
99        header_cards: vec![],
100    })
101}
102
103/// Write a smoothed image to `out_path`.
104///
105/// Copies `template_path` to `out_path`, then overwrites the pixel data and
106/// updates BMAJ/BMIN/BPA in the header.  This preserves all other keywords
107/// (WCS, HISTORY, etc.) from the original file.
108pub fn write_fits(
109    image: &Array2<f32>,
110    out_path: &Path,
111    template_path: &Path,
112    new_beam: &Beam,
113    _was_4d: bool,
114) -> Result<(), FitsError> {
115    // Initialise the destination cheaply: copy only the template's primary-HDU
116    // header (preserving WCS/HISTORY/etc.), not its pixel data.  The data unit is
117    // defined by the copied NAXIS keywords; we overwrite every pixel below, so
118    // reading the template's data via `std::fs::copy` would be wasted I/O.
119    copy_header_only(template_path, out_path)?;
120
121    let out_str = out_path.to_string_lossy().into_owned();
122    let mut fptr = FitsFile::edit(&out_str)?;
123    let hdu = fptr.primary_hdu()?;
124
125    // Flatten to Vec<f32> in row-major order (C order = FITS row-major).
126    let flat: Vec<f32> = image.iter().cloned().collect();
127    hdu.write_image(&mut fptr, &flat)?;
128
129    // Update beam keywords.
130    hdu.write_key(&mut fptr, "BMAJ", new_beam.major_deg)?;
131    hdu.write_key(&mut fptr, "BMIN", new_beam.minor_deg)?;
132    hdu.write_key(&mut fptr, "BPA", new_beam.pa_deg)?;
133
134    Ok(())
135}
136
137/// Create `output` containing only the primary-HDU header of `input` — no pixel data.
138///
139/// Uses cfitsio `fits_copy_header` (ffcphd): the output data unit is defined by the
140/// copied NAXIS keywords and zero-filled (sparsely) on close, avoiding a full read of
141/// the template's pixel data.
142fn copy_header_only(input: &Path, output: &Path) -> Result<(), FitsError> {
143    let mut in_fptr = FitsFile::open(input.to_string_lossy().into_owned())?;
144    in_fptr.primary_hdu()?; // position at the primary HDU to copy
145    let mut out_fptr = FitsFile::create(output).overwrite().open()?;
146
147    let mut status = 0;
148    unsafe {
149        fitsio::sys::ffcphd(in_fptr.as_raw(), out_fptr.as_raw(), &mut status);
150    }
151    fitsio::errors::check_status(status)?;
152    Ok(())
153}
154
155/// Build the output path from the input path with an optional suffix/prefix/outdir.
156pub fn output_path(
157    input: &Path,
158    suffix: Option<&str>,
159    prefix: Option<&str>,
160    outdir: Option<&Path>,
161) -> PathBuf {
162    let stem = input.file_stem().unwrap_or_default().to_string_lossy();
163    let ext = input.extension().unwrap_or_default().to_string_lossy();
164
165    let filename = match suffix {
166        Some(s) => format!("{stem}.{s}.{ext}"),
167        None => format!("{stem}.{ext}"),
168    };
169    let filename = match prefix {
170        Some(p) => format!("{p}{filename}"),
171        None => filename,
172    };
173
174    let dir = outdir.unwrap_or_else(|| input.parent().unwrap_or(Path::new(".")));
175    dir.join(filename)
176}