Files
tensor/io/fits.go
T

562 lines
18 KiB
Go
Raw Permalink Normal View History

2026-09-03 10:00:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package io
import (
"encoding/binary"
"fmt"
"maps"
"math"
"os"
"slices"
"strconv"
"strings"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// FITS image I/O. The Flexible Image Transport System is astronomy's
// archival format: a self-describing header of 80-character ASCII
// cards in 2880-byte blocks, followed by big-endian binary data
// padded to the same block size. Every observation archived by a
// telescope in the last four decades reads back with the same parser,
// which is the property that makes a format worth speaking.
//
// This implementation covers the primary HDU image: BITPIX -64
// (float64) and -32 (float32), any rank with positive axes. FITS
// orders axes Fortran-style with NAXIS1 varying fastest, the opposite
// of Go's row-major convention, so NAXISj is declared from the shape
// in reverse and the flat payload needs no permutation. Extensions
// (XTENSION), tables and the integer bit depths are refused with an
// error rather than half-read.
// fitsCardsPerBlock is the number of 80-byte cards in one 2880-byte
// FITS block.
const fitsCardsPerBlock = 2880 / 80
// SaveFITS writes a float64 or float32 array as a FITS primary image
// with the given header entries. Keywords are uppercased and must be
// 1 to 8 characters from A-Z, 0-9, '-' and '_' (the format's reserved
// SIMPLE, BITPIX, NAXIS, NAXISn, EXTEND and END are refused); values
// are written as FITS strings, at most 68 characters after the
// format's quote escaping.
func SaveFITS(path string, a *core.Array, headers map[string]string) error {
var bitpix int
switch a.Dtype() {
case core.Float:
bitpix = -64
case core.Float32:
bitpix = -32
default:
return base.Errf("SaveFITS: supports float64 and float32 arrays, got dtype %s", a.Dtype())
}
if a.NDim() == 0 {
return base.Errf("SaveFITS: the image needs at least one axis")
}
for i, d := range a.Shape() {
if d <= 0 {
return base.Errf("SaveFITS: axis %d has extent %d, every axis must be positive", i+1, d)
}
}
cards := []string{
fitsBoolCard("SIMPLE", true),
fitsIntCard("BITPIX", bitpix),
fitsIntCard("NAXIS", a.NDim()),
}
// NAXIS1 is the fastest-varying axis, the last one in Go's
// row-major order.
for j := range a.NDim() {
cards = append(cards, fitsIntCard("NAXIS"+strconv.Itoa(j+1), a.Shape()[a.NDim()-1-j]))
}
cards = append(cards, fitsBoolCard("EXTEND", true))
userCards, err := fitsUserCards(headers)
if err != nil {
return base.Errf("SaveFITS: %w", err)
}
cards = append(cards, userCards...)
cards = append(cards, fitsEndCard())
elem := a.Len()
width := 8
if a.Dtype() == core.Float32 {
width = 4
}
// The final size is known up front: both the header and the payload
// pad to whole blocks, so one allocation serves the whole file.
out := make([]byte, 0, fitsBlockSize(len(cards))+fitsBlockSize(elem*width))
out = fitsAppendCards(out, cards)
if a.Dtype() == core.Float {
raw := a.RawFloats()
for i := range elem {
out = binary.BigEndian.AppendUint64(out, math.Float64bits(raw[i]))
}
} else {
raw := a.RawFloat32s()
for i := range elem {
out = binary.BigEndian.AppendUint32(out, math.Float32bits(raw[i]))
}
}
// Zero bytes pad the data to the block boundary.
out = fitsAppendZeroPad(out)
return os.WriteFile(path, out, 0o644)
}
// LoadFITS reads a FITS primary image into a float64 (BITPIX -64) or
// float32 (BITPIX -32) array, returning every non-structural header
// entry alongside it. String values are unquoted and unescaped,
// logical values come back as "T" or "F", numbers as their literal
// text; COMMENT, HISTORY and blank cards carry no value and are
// skipped.
func LoadFITS(path string) (*core.Array, map[string]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, nil, base.Errf("LoadFITS: %w", err)
}
return parseFITS(data)
}
// parseFITS decodes a FITS primary image from raw bytes.
func parseFITS(data []byte) (*core.Array, map[string]string, error) {
if len(data) < 80 {
return nil, nil, base.Errf("LoadFITS: file is shorter than one header card")
}
if string(data[:9]) == "XTENSION " {
return nil, nil, base.Errf("LoadFITS: extensions are not supported, only the primary image")
}
if strings.TrimRight(string(data[:8]), " ") != "SIMPLE" {
return nil, nil, base.Errf("LoadFITS: the first card must be SIMPLE")
}
cards, dataAt, err := scanFITSCards(data, 0)
if err != nil {
return nil, nil, base.Errf("LoadFITS: %w", err)
}
var (
headers = map[string]string{}
bitpix int
naxis = -1
axisVals = map[int]int{}
dims []int
)
for _, c := range cards {
switch {
case c.key == "SIMPLE":
if c.value != "T" {
return nil, nil, base.Errf("LoadFITS: SIMPLE = F marks a non-conformant file")
}
case c.key == "EXTEND":
// Structural; EXTEND still reports itself to the caller.
headers[c.key] = c.value
case c.key == "BITPIX":
v, cerr := strconv.Atoi(c.value)
if cerr != nil {
return nil, nil, base.Errf("LoadFITS: %s = %q is not an integer", c.key, c.value)
}
bitpix = v
case c.key == "NAXIS":
v, cerr := strconv.Atoi(c.value)
if cerr != nil {
return nil, nil, base.Errf("LoadFITS: %s = %q is not an integer", c.key, c.value)
}
naxis = v
default:
// NAXISn is an axis only when a number follows the prefix,
// the rule fitsCheckKeyword applies on write kept symmetric;
// NAXISREF and every other spelling is a user keyword. The
// value is stored under its axis number, so the card order
// cannot re-bind the axes.
if fitsAxisKeyword(c.key) {
v, cerr := strconv.Atoi(c.value)
if cerr != nil {
return nil, nil, base.Errf("LoadFITS: %s = %q is not an integer", c.key, c.value)
}
j, _ := strconv.Atoi(c.key[len("NAXIS"):])
if j < 1 {
return nil, nil, base.Errf("LoadFITS: %s is not an axis keyword", c.key)
}
if _, dup := axisVals[j]; dup {
return nil, nil, base.Errf("LoadFITS: %s repeats", c.key)
}
axisVals[j] = v
continue
}
headers[c.key] = c.value
}
}
// A zero-axis primary HDU (the standard container for extension
// files) answers an empty image.
if naxis == 0 {
return core.New(core.Float, 0), headers, nil
}
if bitpix != -64 && bitpix != -32 {
return nil, nil, base.Errf("LoadFITS: BITPIX %d is not supported (want -64 or -32)", bitpix)
}
// NAXISn values are bound by their axis number, not by card order:
// a header writing NAXIS2 before NAXIS1, or omitting an axis, is
// malformed and must be refused rather than silently re-bound.
if naxis < 0 {
return nil, nil, base.Errf("LoadFITS: NAXIS = %d is negative or the card is missing", naxis)
}
// The card count gates the allocation: a hostile NAXIS far beyond
// the NAXISn cards the file actually carries is refused here, not
// turned into a slice of that length.
if len(axisVals) != naxis {
return nil, nil, base.Errf("LoadFITS: NAXIS = %d with %d NAXISn cards", naxis, len(axisVals))
}
dims = make([]int, naxis)
for j := 1; j <= naxis; j++ {
d, ok := axisVals[j]
if !ok {
return nil, nil, base.Errf("LoadFITS: NAXIS%d is missing under NAXIS = %d", j, naxis)
}
dims[j-1] = d
}
width := -bitpix / 8
if dataAt > len(data) {
return nil, nil, base.Errf("LoadFITS: data is truncated (%d bytes present)", len(data))
}
// Every axis extent and running product is bounded by the bytes
// the file actually holds, so a hostile header cannot overflow the
// int product before the truncation check rejects it.
avail := (len(data) - dataAt) / width
shape := make([]int, naxis)
total := 1
for i, d := range dims {
if d <= 0 {
return nil, nil, base.Errf("LoadFITS: NAXIS%d = %d, every axis must be positive", i+1, d)
}
if d > avail || total > avail/d {
return nil, nil, base.Errf("LoadFITS: data is truncated (%d bytes present, more needed)",
len(data)-dataAt)
}
shape[naxis-1-i] = d
total *= d
}
payload := data[dataAt : dataAt+total*width]
var out *core.Array
if bitpix == -64 {
out = core.New(core.Float, shape...)
raw := out.RawFloats()
for i := range total {
raw[i] = math.Float64frombits(binary.BigEndian.Uint64(payload[i*8:]))
}
} else {
out = core.New(core.Float32, shape...)
raw := out.RawFloat32s()
for i := range total {
raw[i] = math.Float32frombits(binary.BigEndian.Uint32(payload[i*4:]))
}
}
// BSCALE/BZERO scaling: physical = raw*scale + zero (FITS 4.1).
// A silent skip would hand back storage values as physical ones, so
// the affine map is applied whenever the keywords deviate from the
// identity; float32 values are computed in float64 and rounded once.
scale, serr := fitsScaledHeader(headers, "BSCALE", 1)
if serr != nil {
return nil, nil, base.Errf("LoadFITS: %w", serr)
}
zero, zerr := fitsScaledHeader(headers, "BZERO", 0)
if zerr != nil {
return nil, nil, base.Errf("LoadFITS: %w", zerr)
}
if scale != 1 || zero != 0 {
if out.Dtype() == core.Float {
raw := out.RawFloats()
for i := range raw {
raw[i] = raw[i]*scale + zero
}
} else {
raw := out.RawFloat32s()
for i := range raw {
raw[i] = float32(float64(raw[i])*scale + zero)
}
}
}
return out, headers, nil
}
// fitsScaledHeader parses a floating-point header entry, falling back
// to def when the keyword is absent. A present but malformed value is
// an error, never silently the default.
func fitsScaledHeader(headers map[string]string, key string, def float64) (float64, error) {
v, ok := headers[key]
if !ok || v == "" {
return def, nil
}
f, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
if err != nil {
return 0, base.Errf("%s = %q is not a number", key, v)
}
return f, nil
}
// fitsValue extracts the value field of a card: everything after the
// "= " indicator, minus any trailing comment, with FITS string
// quoting resolved. The second return says whether the value was a
// quoted string, which the long-string CONTINUE convention keys on.
func fitsValue(card string) (string, bool, error) {
field := card[10:]
if strings.HasPrefix(strings.TrimLeft(field, " "), "'") {
// A quoted string: '' inside escapes one quote.
var b strings.Builder
in := field[strings.Index(field, "'"):]
i := 1
for i < len(in) {
if in[i] == '\'' {
if i+1 < len(in) && in[i+1] == '\'' {
b.WriteByte('\'')
i += 2
continue
}
return strings.TrimRight(b.String(), " "), true, nil
}
b.WriteByte(in[i])
i++
}
return "", false, base.Errf("card %q has an unterminated string value", card[:min(20, len(card))])
}
// Free-format value: cut at the comment slash and trim.
if slash := strings.IndexByte(field, '/'); slash >= 0 {
field = field[:slash]
}
return strings.TrimSpace(field), false, nil
}
// fitsContinueString extracts the quoted segment of a CONTINUE card.
// The keyword occupies columns 1-8 and no "= " indicator follows, so
// the string opens at the first quote anywhere in the card.
func fitsContinueString(card string) (string, error) {
q := strings.IndexByte(card, '\'')
if q < 0 {
return "", base.Errf("CONTINUE card %q has no quoted segment", card[:min(20, len(card))])
}
var b strings.Builder
i := q + 1
for i < len(card) {
if card[i] == '\'' {
if i+1 < len(card) && card[i+1] == '\'' {
b.WriteByte('\'')
i += 2
continue
}
return b.String(), nil
}
b.WriteByte(card[i])
i++
}
return "", base.Errf("CONTINUE card %q has an unterminated string", card[:min(20, len(card))])
}
// fitsCheckKeyword validates a header keyword the caller supplies.
// The format's commentary keywords COMMENT and HISTORY carry no
// value, so they are refused like the structural ones.
func fitsCheckKeyword(kw string) error {
if len(kw) < 1 || len(kw) > 8 {
return base.Errf("keyword %q must be 1 to 8 characters", kw)
}
for _, r := range kw {
switch {
case r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
default:
return base.Errf("keyword %q may only contain A-Z, 0-9, '-' and '_'", kw)
}
}
switch kw {
case "SIMPLE", "BITPIX", "NAXIS", "EXTEND", "END", "COMMENT", "HISTORY":
return base.Errf("keyword %q is reserved by the format", kw)
case "BSCALE", "BZERO":
// SaveFITS writes physical values directly; a user scaling card
// would make conforming readers scale them a second time.
return base.Errf("keyword %q is reserved by the format (values are written unscaled)", kw)
}
if rest, ok := strings.CutPrefix(kw, "NAXIS"); ok && rest != "" {
if _, err := strconv.Atoi(rest); err == nil {
return base.Errf("keyword %q is reserved by the format", kw)
}
}
return nil
}
// fitsAxisKeyword reports whether key is a structural NAXISn card: the
// prefix followed by a number, the same rule fitsCheckKeyword applies
// when it refuses a reserved keyword on write. Any other spelling of
// the prefix (NAXISREF and the like) is a user keyword, and counting it
// as an axis used to make the reader answer "NAXIS = 1 with 2 NAXISn
// cards" for a file that carries one.
func fitsAxisKeyword(key string) bool {
rest, ok := strings.CutPrefix(key, "NAXIS")
if !ok {
return false
}
_, err := strconv.Atoi(rest)
return err == nil
}
// fitsLookupN reads headers[prefix+index] without building the key on
// the heap: the digits are formatted into a stack buffer, and the map
// lookup over that buffer compiles to a lookup over the bytes with no
// string conversion.
func fitsLookupN(headers map[string]string, prefix string, index int) string {
var kb [24]byte
b := append(kb[:0], prefix...)
b = strconv.AppendInt(b, int64(index), 10)
return headers[string(b)]
}
// fitsIntCard renders an integer-valued card with the value
// right-justified in columns 11 to 30.
func fitsIntCard(keyword string, v int) string {
return fitsPadCard(fmt.Sprintf("%-8s= %20d", keyword, v))
}
// fitsBoolCard renders a logical-valued card.
func fitsBoolCard(keyword string, v bool) string {
t := "F"
if v {
t = "T"
}
return fitsPadCard(fmt.Sprintf("%-8s= %20s", keyword, t))
}
// fitsStringCard renders a string-valued card; the format pads the
// quoted value to at least eight characters.
func fitsStringCard(keyword, v string) (string, error) {
escaped := strings.ReplaceAll(v, "'", "''")
inner := escaped
if len(inner) < 8 {
inner += strings.Repeat(" ", 8-len(inner))
}
body := fmt.Sprintf("%-8s= '%s'", keyword, inner)
if len(body) > 80 {
return "", base.Errf("value for %q does not fit a card after quote escaping (%d characters)",
keyword, len(escaped))
}
return fitsPadCard(body), nil
}
// fitsEndCard renders the header terminator.
func fitsEndCard() string {
return fitsPadCard("END")
}
// fitsPadCard right-pads a card body with spaces to the full 80 bytes.
func fitsPadCard(body string) string {
return body + strings.Repeat(" ", 80-len(body))
}
// fitsUserCards renders the caller's header entries as cards, keywords
// uppercased and sorted so the output is deterministic.
func fitsUserCards(headers map[string]string) ([]string, error) {
cards := make([]string, 0, len(headers))
for _, key := range slices.Sorted(maps.Keys(headers)) {
kw := strings.ToUpper(key)
if err := fitsCheckKeyword(kw); err != nil {
return nil, err
}
card, err := fitsStringCard(kw, headers[key])
if err != nil {
return nil, err
}
cards = append(cards, card)
}
return cards, nil
}
// fitsCard is one parsed header card: its keyword and the value field
// with quoting resolved.
type fitsCard struct {
key, value string
}
// scanFITSCards walks the 80-byte cards of one header starting at off
// and returns every value card up to the END terminator together with
// the block-aligned header length in bytes, which is where the data
// block begins. Blank, COMMENT and HISTORY cards carry no value and
// are skipped before the value-indicator check, because a commentary
// card may legitimately carry an "= " sequence in columns 9-10; a
// header that never terminates is an error.
func scanFITSCards(data []byte, off int) ([]fitsCard, int, error) {
var cards []fitsCard
seen := 0
for pos := off; pos+80 <= len(data); pos += 80 {
line := string(data[pos : pos+80])
seen++
key := strings.TrimRight(line[:8], " ")
if key == "END" {
return cards, (seen + fitsCardsPerBlock - 1) / fitsCardsPerBlock * 2880, nil
}
// Commentary cards have no value regardless of what follows
// columns 9-10; check them before the "= " indicator.
if key == "" || key == "COMMENT" || key == "HISTORY" {
continue
}
if key == "CONTINUE" {
// A CONTINUE card only makes sense after an open long
// string; reaching one here means the base card never
// ended in '&', and silently dropping it would lose the
// caller's value.
return nil, 0, base.Errf("CONTINUE card without an open long string")
}
if line[8:10] != "= " {
continue
}
value, wasString, err := fitsValue(line)
if err != nil {
return nil, 0, err
}
// Long-string convention: a string value ending in '&' is
// continued by the following CONTINUE cards, each carrying the
// next quoted segment. Dropping them would truncate the value
// at the card boundary.
for wasString && strings.HasSuffix(value, "&") && pos+160 <= len(data) {
next := string(data[pos+80 : pos+160])
if strings.TrimSpace(next[:8]) != "CONTINUE" {
break
}
pos += 80
seen++
cont, cerr := fitsContinueString(next)
if cerr != nil {
return nil, 0, cerr
}
value = strings.TrimSuffix(value, "&") + strings.TrimRight(cont, " ")
}
cards = append(cards, fitsCard{key, value})
}
return nil, 0, base.Errf("no END card terminates the header")
}
// fitsBlockSize returns n rounded up to a whole FITS block.
func fitsBlockSize(n int) int {
return (n + 2879) / 2880 * 2880
}
// fitsAppendCards appends the rendered cards as one header block,
// blank-padded to the block boundary.
func fitsAppendCards(dst []byte, cards []string) []byte {
for _, card := range cards {
dst = append(dst, card...)
}
// Blank cards pad the header to the block boundary.
if rem := len(dst) % 2880; rem != 0 {
blank := fitsPadCard("")
for i := 0; i < 2880-rem; i += 80 {
dst = append(dst, blank...)
}
}
return dst
}
// fitsAppendZeroPad appends zero bytes up to the block boundary.
func fitsAppendZeroPad(dst []byte) []byte {
if rem := len(dst) % 2880; rem != 0 {
dst = append(dst, make([]byte, 2880-rem)...)
}
return dst
}