World coordinates

Python

Every fitsy.ImageHdu exposes a wcs() helper that returns a fitsy.Wcs instance (or None if the header carries no WCS). The same parser is reachable from fitsy.FitsFile.wcs(), which additionally resolves -TAB look-up axes.

"""WCS pixel <-> sky transforms on the bundled NGC 2403 image.

Run from the repo root:

    python examples/python/wcs.py
"""

import fitsy
import numpy as np

with fitsy.open("examples/data/ngc2403.fits.gz") as f:
    wcs = f.wcs(0)  # equivalent: f[0].wcs()

# Single pixel -> sky (0-based pixel coordinates).
ra, dec = wcs.pixel_to_celestial(724.0, 1086.0)
print(f"center:     RA={ra:.4f}  Dec={dec:.4f}")

# Sky -> pixel (round-trip).
px, py = wcs.celestial_to_pixel(ra, dec)
print(f"round-trip: ({px:.2f}, {py:.2f})")

# Batch transform: corners + center -> sky.
sky = wcs.pixel_to_celestial_many(
    np.array([[0.0, 0.0], [1447.0, 2171.0], [724.0, 1086.0]])
)
print("corners + center sky:")
print(sky)

Rust

FitsFile::wcs resolves -TAB axes automatically; ImageHdu::wcs is a lighter-weight alternative when you already have the HDU in hand and know there are no tabular axes.

//! WCS pixel-to-sky coordinate transforms using a real FITS image.
//!
//! Run from the repo root with:
//!
//!     cargo run --example wcs

use fitsy::{FitsFile, Hdu, Wcs};

fn main() -> Result<(), fitsy::FitsError> {
    let f = FitsFile::open("examples/data/ngc2403.fits.gz")?;

    // FitsFile::wcs(hdu_index, alt_char) resolves -TAB axes automatically.
    // Use ' ' (space) for the primary WCS; 'A'..'Z' for alternates.
    let wcs: Wcs = f.wcs(0, ' ')?.expect("no WCS in HDU 0");

    // Single pixel -> sky (0-based pixel coordinates).
    // The center of the first pixel is (0.0, 0.0).
    let (ra, dec) = wcs.pixel_to_celestial(724.0, 1086.0)?;
    println!("center:     RA={ra:.4}  Dec={dec:.4}");
    // center:     RA=114.2089  Dec=65.5917

    // Sky -> pixel (round-trip).
    let (px, py) = wcs.celestial_to_pixel(ra, dec)?;
    println!("round-trip: ({px:.2}, {py:.2})");
    // round-trip: (724.00, 1086.00)

    // Batch transform: corners + center -> sky.
    let pairs = vec![(0.0_f64, 0.0_f64), (1447.0, 2171.0), (724.0, 1086.0)];
    let sky = wcs.pixel_to_celestial_many(&pairs)?;
    println!("corners + center:");
    for ((px, py), (ra, dec)) in pairs.iter().zip(&sky) {
        println!("  ({px:.0}, {py:.0}) -> RA={ra:.4}  Dec={dec:.4}");
    }

    // Local pixel scale at the center (arcseconds per pixel, each axis).
    let (sx, sy) = wcs.pixel_scale_at(724.0, 1086.0)?;
    println!("pixel scale: {sx:.4}\" x {sy:.4}\"/px");

    // Full N-axis pixel_to_world / world_to_pixel (useful when the
    // image has non-celestial axes, e.g. spectral).
    let world = wcs.pixel_to_world(&[724.0, 1086.0])?;
    println!("world:  {world:?}");

    // Parsing directly from a Header skips -TAB resolution and is
    // lighter-weight when you know the image has no tabular axes.
    if let Hdu::Image(img) = f.hdu(0)? {
        let _wcs2 = Wcs::from_header(img.header(), ' ')?.expect("no WCS");
    }

    Ok(())
}

Pixel coordinate convention

Important

Both the Python and Rust APIs default to 0-based pixel coordinates (numpy / C convention): the center of the first pixel is 0.0, and the center of the last pixel along NAXISn is float(NAXISn) - 1. A numpy index [row, col] maps directly to pixel_to_celestial(col, row).

Pass origin=1 (Python) to use the FITS 1-based convention (matching CRPIX in the header). The Rust API is always 0-based; subtract 1 from FITS-native coordinates before calling.

# The pixel at numpy index [row, col] = [128, 256]
ra, dec = wcs.pixel_to_celestial(256.0, 128.0)

Batch transforms

Use pixel_to_celestial_many() and celestial_to_pixel_many() for (N, 2) numpy inputs. The Rust equivalents take &[(f64, f64)] slices.

Fitting a WCS

fitsy.fit_wcs() (Python) and fitsy::wcs::fit_celestial_wcs (Rust) solve for a celestial WCS given pixel <-> sky correspondences.

"""Fit a celestial WCS from pixel/sky correspondences.

Run from the repo root:

    python examples/python/fit_wcs.py
"""

import fitsy
import numpy as np

pix = np.array(
    [
        [100.0, 100.0],
        [200.0, 100.0],
        [100.0, 200.0],
        [200.0, 200.0],
    ]
)
sky = np.array(
    [
        [10.00, -5.00],
        [10.05, -5.00],
        [10.00, -4.95],
        [10.05, -4.95],
    ]
)

fit = fitsy.fit_wcs(pix, sky, projection="TAN")
print(f'rms = {fit.rms_arcsec:.3f}"  max = {fit.max_arcsec:.3f}"')

# `fit.wcs` is a fully usable Wcs.
ra, dec = fit.wcs.pixel_to_celestial(150.0, 150.0)
print(f"center: RA={ra:.4f}  Dec={dec:.4f}")

# Serialize back to a header dict for writing.
header = fit.wcs.to_header()
print("CRVAL1:", header["CRVAL1"])
//! Fit a celestial WCS from pixel / sky reference correspondences.
//!
//! Run from the repo root with:
//!
//!     cargo run --example fit_wcs

use fitsy::wcs::{WcsFitOptions, fit_celestial_wcs};

fn main() -> Result<(), fitsy::FitsError> {
    // Four corner correspondences: pixel (0-based) and sky in degrees.
    let pixels = vec![
        (100.0_f64, 100.0),
        (200.0, 100.0),
        (100.0, 200.0),
        (200.0, 200.0),
    ];
    let sky = vec![
        (10.00_f64, -5.00),
        (10.05, -5.00),
        (10.00, -4.95),
        (10.05, -4.95),
    ];

    // Default: TAN projection, CRPIX solved as a free parameter, no SIP.
    let opts = WcsFitOptions::default();
    let fit = fit_celestial_wcs(&pixels, &sky, &opts)?;

    println!(
        "rms = {:.3}\"  max = {:.3}\"",
        fit.rms_arcsec, fit.max_arcsec
    );
    for (i, (dx, dy)) in fit.residuals_arcsec.iter().enumerate() {
        println!("  point {i}: ({dx:+.3}\", {dy:+.3}\")");
    }

    // The fitted Wcs is fully usable for forward / inverse transforms.
    let (ra, dec) = fit.wcs.pixel_to_celestial(150.0, 150.0)?;
    println!("center: RA={ra:.4}  Dec={dec:.4}");

    Ok(())
}