Table of Contents

FITS

FITS (Flexible Image Transport System) is astronomy's image format. A file is a chain of header and data units (HDUs). Each header is a list of 80-character keyword cards in 2880-byte blocks. Its data is an array of big-endian 8, 16, 32 or 64-bit integers or 32 or 64-bit floats, and BSCALE and BZERO turn the stored numbers into physical values such as counts or flux. FITS reads and writes through the Egami.Formats.Extended package: register it with WithFits() or WithExtendedFormats() (Extended formats).

Reads The FITS Standard 4.0: the primary HDU and every IMAGE extension, BITPIX 8, 16, 32, 64, -32 and -64, BSCALE, BZERO and BLANK, 2D images and cubes (each plane an image); tile-compressed images (the tiled image convention) in RICE_1, GZIP_1, GZIP_2, PLIO_1 and NOCOMPRESS, with quantized floats and their dither; headers with CONTINUE long strings and HIERARCH keywords. Tables are listed, not read as images
Writes One primary HDU: BITPIX 8, 16 (unsigned, BZERO 32768) or -32; gray as a 2D image, colour as a cube of three planes; the header cards of a raster read from FITS, and cards of your own
Natural pixel type GrayF32, or RgbF32 with FitsReaderOptions.Rgb
FormatInfo FitsFormat.Info: name FITS, extensions .fits, .fit and .fts, MIME types image/fits and application/fits
// Load stretches the values into 0 to 1 (from the smallest to the largest, or DATAMIN to DATAMAX when the header gives
// them) as GrayF32; the first stored row is at the bottom, as astronomy viewers show it.
EgamiContext context = EgamiContext.Default.WithFits();
using Raster image = Raster.Load("m51.fits", context);
image.Save("m51.png");

// Every image HDU, and every plane of a cube, is an image of its own.
RasterInfo info = Raster.Probe("m51.fits", context);
Console.WriteLine($"{info.Width}×{info.Height}, {info.ImageCount} image(s)");

The first stored row is the bottom of the picture, as the standard's axes run and astronomy viewers show it. Load turns it upright, so the first row of the raster is the last row of the file. FitsFile.ReadValues keeps the file's order.

Images and planes

Every image HDU is an image, and so is every plane of a cube: an HDU with NAXIS3 of 4 gives four images, one after the other. RasterInfo.ImageCount counts them, LoadAll reads them all and FitsReaderOptions.Image picks one by that count. An empty primary HDU (NAXIS 0, common when the images are in extensions) is skipped. Tables, random groups and extensions Egami does not know are skipped too. A file with no image throws UnsupportedFormatException.

FitsReaderOptions.Rgb reads a cube of exactly three planes as one RGB image, the planes as red, green and blue, stretched alike.

Stretching

The values of a FITS image have no fixed range: a 16-bit image may hold counts up to a few hundred, and a float image may be negative. Load maps them into the picture's 0 to 1 with FitsReaderOptions.Stretch:

FitsStretch Black and white Use
MinMax (the default) DATAMIN and DATAMAX when the header has both, otherwise the smallest and largest finite value A faithful view of the whole range
ZScale IRAF's zscale limits: a line fitted to a sorted sample of up to 1000 pixels, contrast 0.25 Astronomers' usual view: the sky and faint detail show, bright stars clip
Asinh As MinMax, then asinh(x / 0.1) / asinh(1 / 0.1) Faint detail lifted while bright cores keep their shape, as in Lupton's colour images
None No mapping: the physical values themselves Measuring

Missing pixels (BLANK in an integer image, NaN in a float one) load black, or as NaN with None. FitsDetails.StretchLow and FitsDetails.StretchHigh say which values became black and white.

// ZScale shows faint detail the way astronomy viewers do; Asinh lifts the faint end and keeps the bright one.
using Raster zscale = Raster.Load("m51.fits", context, new FitsReaderOptions { Stretch = FitsStretch.ZScale });

// None keeps the physical values (BSCALE and BZERO applied, blanks as NaN) for measuring.
using Raster<GrayF32> values = Raster.Load<GrayF32>("m51.fits", context, new FitsReaderOptions { Stretch = FitsStretch.None });

// The header, and the values that became black and white, are in the details.
if (zscale.Metadata.FormatDetails is FitsDetails fits)
{
    Console.WriteLine($"{fits.Header.GetString("OBJECT")}: BITPIX {fits.Bitpix}, {fits.StretchLow} to {fits.StretchHigh}");
}

// A three-plane cube as one RGB image instead of three gray ones.
using Raster colour = Raster.Load("m51-rgb.fits", context, new FitsReaderOptions { Rgb = true });

Headers and FitsFile

FitsDetails, in RasterMetadata.FormatDetails, keeps the HDU's whole header as a FitsHeader: a list of FitsCards (keyword, value and comment). Values are a string, bool, long or double, and commentary cards (COMMENT, HISTORY) have no value. GetString, GetInteger, GetReal and GetLogical find a card's value by keyword. FitsDetails also keeps which HDU and plane the raster is, its BITPIX, its tile compression, and the stretch with its limits. The world coordinate cards (CRVAL1, CD1_1 and the rest) are kept as cards; Egami does not compute sky positions from them.

FitsFile is for code that wants the file rather than a picture. FitsFile.Load reads every HDU's header (tables included), Hdus lists them as FitsHdus with their kind (a FitsHduKind), BITPIX, axes and compression, ReadValues gives an image's physical values as double, and ReadPlane gives one plane as an unstretched Raster<GrayF32>.

// FitsFile lists every HDU with its header, tables included, and reads an image's values in file order.
FitsFile file = FitsFile.Load("m51.fits");
foreach (FitsHdu hdu in file.Hdus)
{
    Console.WriteLine($"HDU {hdu.Index}: {hdu.Kind}, {string.Join(" × ", hdu.Axes)}");
}

FitsHdu image = file.FirstImage!;
double exposure = image.Header.GetReal("EXPTIME") ?? 0;
double[] counts = file.ReadValues(image);
using Raster<GrayF32> plane = file.ReadPlane(image);

Tile compression

A tile-compressed image is a binary table in which each row codes one tile of the image, as the FITS tiled image convention (Pence, Seaman and White) describes, and as fpack and funpack write and read it. Egami reads it as the image it holds: the header's ZBITPIX, ZNAXISn and ZTILEn give the image, and FitsHeader shows the image's keywords, not the table's. The codecs are Rice (RICE_1), gzip (GZIP_1, and GZIP_2 with its byte shuffle), IRAF's PLIO for masks (PLIO_1) and NOCOMPRESS. Floating-point images quantized to integers are restored from each tile's ZSCALE and ZZERO, with SUBTRACTIVE_DITHER_1 and SUBTRACTIVE_DITHER_2 undone, and tiles stored losslessly in GZIP_COMPRESSED_DATA or UNCOMPRESSED_DATA are read from there; gzip tiles of floats that were not quantized (no ZSCALE) hold the values themselves. HCOMPRESS (HCOMPRESS_1) is not read: a load throws UnsupportedFormatException that names it.

Writing

The writer writes one primary HDU. FitsWriterOptions.Bitpix is 8, 16 or -32. By default it follows the pixel type: -32 for floating-point pixels, 16 for 16-bit ones (stored with BZERO 32768, so 0 to 65535 survive exactly) and 8 for the rest. Gray pixels write a 2D image; colour pixels write a cube of three planes, red, green and blue, which reads back as one image with FitsReaderOptions.Rgb. Alpha is dropped, and a save says so in the Warnings.

A raster read from FITS keeps its header cards: everything but the cards the structure needs (such as SIMPLE, BITPIX, NAXISn, BSCALE, BZERO, BLANK, EXTEND and the extension and compression keywords), which the writer makes again. A raster loaded with a stretch no longer holds the physical values, so DATAMIN, DATAMAX and BUNIT are dropped from it. FitsWriterOptions.Keywords adds more cards. Long strings are written as CONTINUE cards, keywords longer than eight characters as HIERARCH, and characters outside printable ASCII are dropped, as the standard asks.

// BITPIX follows the pixel type: -32 for floating point, 16 for 16-bit, 8 otherwise. The cards of a loaded header go
// with it, and Keywords adds more.
using (FileStream output = File.Create("m51-copy.fits"))
{
    values.Save(output, FitsFormat.Info, new FitsWriterOptions { Keywords = [new FitsCard("HISTORY", null, "dark subtracted")] });
}

// A colour image becomes a three-plane cube; Bitpix picks the depth.
using (FileStream output = File.Create("duck.fits"))
{
    photo.Save(output, FitsFormat.Info, new FitsWriterOptions { Bitpix = 16 });
}

Speed and memory

A FITS image is read whole: the stretch needs all the values before the first row. A 4000 × 3000 16-bit image loads in about 50 ms with MinMax and saves in about 25 ms; ImageMagick takes 180 ms to read it. A load needs about eight bytes a pixel (24 with Rgb) while it runs, which is checked against SafetyLimits.MaxTotalBytes before it allocates.

Damaged files

Warnings start Fits.. A lenient read of a file that ends early keeps the rows it has and leaves the rest black, with RowsMissing. A compressed tile that does not decode is left black, with TilesDamaged. A strict read throws CorruptDataException for each. A header without an END card, or with a BITPIX or NAXIS the standard does not allow, throws CorruptDataException either way. Data after the last HDU that is not an extension is ignored.