fits-io 0.2.0

A pure-Rust FITS file reading and writing library inspired by CFITSIO, focused on safety, clarity, and performance.
Documentation

FitsIo

A safe, ergonomic, and pure-Rust library for reading and writing FITS (Flexible Image Transport System) files, inspired by CFITSIO.

This crate offers optional async I/O with Tokio and structured access to FITS headers, images, and tables — without any C dependencies.

Designed for astronomy, astrophotography, and scientific pipelines where portability and safety matter.

Features

  • 📦 Pure Rust implementation (no CFITSIO, no C bindings)
  • ⚡ Async I/O with Tokio (enabled by default)
  • 🧩 Support for Primary HDUs and extensions
  • 🖼️ Image HDUs of any dimensionality, cubes and hypercubes included
  • 📊 Binary and ASCII tables alike, with optional serde support in both directions
  • 🗜️ Tile-compressed images, read and written as images like any other
  • 🌍 World coordinate helpers: pixels to sky positions and back, with SIP and TPV distortions
  • 🧠 Typed access to FITS header keywords, and any keyword at all by name
  • 🚀 Streaming and memory-efficient reads
  • 🛡️ Idiomatic error handling with Result
  • 🔁 CFITSIO-inspired API, redesigned for Rust

Installation

Add the crate to your Cargo.toml:

[dependencies]
fits-io = "0.2"

Reading a file

# #[cfg(feature = "fs")]
# fn example() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use fits_io::Fits;
use fits_io::fs::FsFits;
use fits_io::hdu::ImageHDU;

let fits = FsFits::open("observation.fits".as_ref())?;

let hdu = fits.primary_hdu();
println!("{} x {}", hdu.images_width(), hdu.images_height());

if let Some(image) = hdu.read_image(0)? {
    let normalised = image.normalized();
    println!("first pixel: {}", normalised.get_pixel(0, 0)[0]);
}
# Ok(())
# }
# fn main() {}

Reading table rows into your own structs

With the serde feature, a table's rows deserialize straight into a struct, matching columns to fields by their TTYPEn names. The same works for an ASCII table through read_rows, from_ascii_table and to_ascii_table.

# #[cfg(all(feature = "fs", feature = "serde"))]
# fn example() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use fits_io::Fits;
use fits_io::fs::FsFits;
use fits_io::hdu::{BinTableHDU, ExtensionHDU};
use serde::Deserialize;

#[derive(Deserialize)]
struct Source {
    #[serde(rename = "RA")]
    right_ascension: f64,
    #[serde(rename = "DEC")]
    declination: f64,
    // A column with a TNULLn card may leave entries undefined.
    #[serde(rename = "MAG")]
    magnitude: Option<f32>,
}

let fits = FsFits::open("catalogue.fits".as_ref())?;

if let Some(ExtensionHDU::BinTable(hdu)) = fits.extension_hdu(0) {
    let sources: Vec<Source> = hdu.read_rows()?;
    println!("{} sources", sources.len());
}
# Ok(())
# }
# fn main() {}

Writing

Setting data also brings the header into line with it, and saving fills in the mandatory cards, puts them in the order the standard requires, and writes CHECKSUM and DATASUM.

# #[cfg(feature = "fs")]
# fn example() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use fits_io::Fits;
use fits_io::fs::FsFits;
use fits_io::hdu::ImageHDU;

let mut fits = FsFits::open("observation.fits".as_ref())?;

fits.primary_hdu_mut()
    .set_raw_images_i16(2, 2, &[&[1, 2, 3, 4]])?;

// `to_vec` returns the bytes; `save` writes them back over the file.
let bytes = fits.to_vec()?;
fits.save()?;
# Ok(())
# }
# fn main() {}

Editing the header

Every keyword this crate knows has an accessor of its own, and any keyword at all can be set by name. A keyword too long for the eight columns a card gives it becomes a HIERARCH card, and a value too long for one card is written across CONTINUE cards.

# fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use fits_io::header::{Header, Value};

let mut header = Header::default();

header.set_card("OBJECT", "NGC 7000")?;
header.set_card("EXPTIME", Value::from(300.0).with_comment("seconds"))?;
header.set_card("ESO INS FILT1 NAME", "Halpha")?;
header.add_history("stacked from 42 subframes");

// The typed accessors see what was set by name.
assert_eq!(header.object(), Some("NGC 7000"));
# Ok(())
# }

Compressing an image

An image can be stored tile-compressed, the way fpack writes one: cut into tiles, each tile compressed on its own, and the result written as a binary table that a reader treats as the image it stands for. Everything that reads an image goes on working.

# #[cfg(feature = "gzip")]
# fn example() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use fits_io::hdu::ImageHDU;
use fits_io::image::compression::{Compression, CompressionOptions, Quantize};
use fits_io::{Fits, FitsSlice};

let mut fits = FitsSlice::new();
fits.primary_hdu_mut()
    .set_raw_images_i16(2, 2, &[&[1, 2, 3, 4]])?;

fits.primary_hdu_mut()
    .compress(&CompressionOptions::new(Compression::Rice))?;

let bytes = fits.to_vec()?;

// A floating point image has to be quantised before an integer coder can take
// it, which loses the low bits of every pixel; `Compression::Gzip` compresses
// one as it stands and loses nothing.
let lossy = CompressionOptions::new(Compression::Rice)
    .with_quantization(Quantize::NoiseLevel(4.0));
# Ok(())
# }
# fn main() {}

Building a file from nothing

# #[cfg(feature = "serde")]
# fn example() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use fits_io::bin_table::to_bin_table;
use fits_io::hdu::{BinTableHDU, ExtensionHDU, ImageHDU};
use fits_io::{Fits, FitsSlice, SliceBinTableHDU};
use serde::Serialize;

#[derive(Serialize)]
struct Star {
    #[serde(rename = "NAME")]
    name: String,
    #[serde(rename = "MAG")]
    magnitude: f64,
}

let mut fits = FitsSlice::new();

fits.primary_hdu_mut()
    .set_raw_images_u8(2, 2, &[&[1, 2, 3, 4]])?;

// Column types and widths are worked out from the rows themselves.
let stars = vec![Star {
    name: "Vega".into(),
    magnitude: 0.03,
}];
let table = SliceBinTableHDU::from_table(&to_bin_table(&stars)?)?;
fits.push_extension(ExtensionHDU::BinTable(table));

let bytes = fits.to_vec()?;
# Ok(())
# }
# fn main() {}

Working without a filesystem

FitsSlice reads a file that is already in memory — one arriving over a network, say, or a build with the fs feature turned off. It reads, writes and streams the same things FsFits does, and from_vec takes over your buffer rather than copying it. A gzipped buffer is decompressed transparently.

use fits_io::{Fits, FitsSlice};
use fits_io::hdu::ImageHDU;

# fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
# let mut bytes = format!("{:<80}{:<80}{:<80}{:<80}{:<80}{:<80}",
#     "SIMPLE  =                    T", "BITPIX  =                    8",
#     "NAXIS   =                    2", "NAXIS1  =                    2",
#     "NAXIS2  =                    2", "END").into_bytes();
# bytes.resize(2880, b' ');
# bytes.extend_from_slice(&[1, 2, 3, 4]);
# bytes.resize(5760, 0);
let fits = FitsSlice::from_slice(&bytes)?;

assert_eq!(fits.primary_hdu().image_count(), 1);
# Ok(())
# }

Feature flags

default-features = false gives you header, image and table parsing over in-memory data through FitsSlice, with no filesystem, async or threading support.

Feature Default Effect
fs Read and write FITS files on the filesystem via FsFits
gzip Transparently decompress gzipped files and buffers
tokio Async open and streaming reads
rayon Parallel table row decoding, worth about 4x on a big table
serde Convert table rows to and from your own structs

Benchmarks

cargo bench --features fs,serde,rayon times opening a file, reading a table and decoding its rows, against the Gaia fixture under tests/. It reports the fastest of several runs and skips when the fixture is absent.

Changelog

What has changed between releases is in CHANGELOG.md.

Documentation

Every public item is documented, and #![deny(missing_docs)] keeps it that way. The API docs are on docs.rs, built with every feature enabled so the serde, tokio and gzip parts are visible.

Design Goals

  • Safety — eliminate undefined behavior and unsafe FFI
  • Portability — run anywhere Rust runs
  • Ergonomics — minimal boilerplate
  • Performance — streaming-friendly, low overhead
  • Familiarity — CFITSIO-inspired, Rust-native

Supported FITS Features

Feature Status Notes
Primary HDU
Extension HDUs
Image HDU Any number of axes; planes beyond the second are indexed
Binary tables serde converts rows to and from your structs
ASCII tables Read, written, streamed and serde-mapped like binary ones
Variable-length array columns TFORMn P and Q, read and written through the heap
Complex columns TFORMn C and M
Column scaling TSCALn, TZEROn and TNULLn applied both ways
Unsigned columns The TZEROn convention, at all four integer widths
Multidimensional columns TDIMn read and written, nesting as deep as it says
Undefined image pixels BLANK reads as NaN rather than as black
Header read CONTINUE long values and HIERARCH keywords included
Header write Mandatory cards filled in and ordered as the standard asks
Header editing set_card and remove_card by keyword, writing HIERARCH and CONTINUE as needed
Image write Any number of axes, then save or to_vec
Table write set_table / set_rows, for both table kinds
Building files push_extension and remove_extension
Gzip decompression .fits.gz files and gzipped buffers alike
Streaming image reads stream_normalised_image, via the tokio feature
Streaming table rows stream_table_rows, via the tokio feature
WCS conventions CDi_j, PCi_j and CDELTn/CROTAn, with LONPOLE and LATPOLE
WCS projections TAN, SIN, ARC, STG, ZEA, CAR, MER, CEA, AIT, MOL
WCS distortions SIP and TPV, both applied and inverted
Non-celestial axes A cube's third axis and beyond, linear or -LOG
CHECKSUM and DATASUM Written on save; checksum::verify checks an HDU
Random groups group_count and read_group, with PSCALn and PZEROn
Compressed image read RICE_1, HCOMPRESS_1, PLIO_1, GZIP_1/2, NOCOMPRESS, any number of axes
Compressed image write RICE_1, HCOMPRESS_1, PLIO_1, GZIP_1/2 and NOCOMPRESS, through ImageHDU::compress
Quantised floating point ZQUANTIZ dithering read and written, ZBLANK included
HCOMPRESS smoothing SMOOTH honoured, matching the reference implementation
Conic WCS projections 🚧 COE, COD and the rest say so rather than guessing

License

Licensed under either of:

  • Apache License, Version 2.0
  • MIT License

at your option.

Contributing

Issues, discussions, and pull requests are welcome. Please open an issue for large changes or new features.

Acknowledgements

Inspired by CFITSIO and the FITS standard maintained by NASA/HEASARC.

License