World coordinates¶
A World Coordinate System turns a pixel position into a world coordinate, and back. The common case is a celestial image: pixel to right ascension and declination.
Getting a WCS¶
fitsy.ImageHdu.wcs() and fitsy.FitsFile.wcs() return the
same fitsy.Wcs, so f[i].wcs() and f.wcs(i) are
equivalent. Both return None when the header carries no WCS.
with fitsy.open("image.fits") as f:
wcs = f[0].wcs()
ra, dec = wcs.pixel_to_world([256.0, 128.0])
px, py = wcs.world_to_pixel([ra, dec])
A single point goes in as a length-naxis sequence and comes back
as a list. print(wcs) summarizes the keywords the description
uses.
fitsy applies the distortion a header declares, on every transform,
with no extra call: SIP (RA---TAN-SIP), TPV, TNX/ZPX, and DSS
plate solutions. The bundled NGC 2403 scan carries TAN with SIP,
so the example below exercises that path.
"""WCS pixel <-> sky transforms on the bundled NGC 2403 image.
Run from the repository 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_world([724.0, 1086.0])
print(f"center: RA={ra:.4f} Dec={dec:.4f}")
# Sky -> pixel (round-trip).
px, py = wcs.world_to_pixel([ra, dec])
print(f"round-trip: ({px:.2f}, {py:.2f})")
# Batch transform: corners + center -> sky.
# Accepts any array-like (numpy array, list of lists, etc.).
sky = wcs.pixel_to_world(np.array([[0.0, 0.0], [1447.0, 2171.0], [724.0, 1086.0]]))
print("corners + center sky:")
print(sky)
# Plain Python lists work too (no numpy import required at call site).
sky2 = wcs.pixel_to_world([[0.0, 0.0], [724.0, 1086.0]])
print("list-of-lists input:", sky2)
# pixel_to_world / world_to_pixel take one point or many. One point is
# a length-naxis sequence and gives a list back. An (N, naxis) array is
# a batch and gives an (N, naxis) array back. Unlike the celestial
# helpers, this path also reaches a spectral, time or -TAB axis.
print("one point: ", wcs.pixel_to_world([724.0, 1086.0]))
batch = wcs.pixel_to_world(np.array([[0.0, 0.0], [724.0, 1086.0]]))
print("batch shape:", batch.shape)
print(batch)
# A batch marks a point it cannot transform with nan rather than
# raising, so one bad pixel does not lose the rest of the field.
print("round-trip:", wcs.world_to_pixel(batch))
Rust¶
FitsFile::wcs takes the HDU index and the alternate-description
letter. Wcs::from_header parses a header you already hold.
//! Transform pixel coordinates to sky coordinates.
//!
//! This reads the bundled NGC 2403 plate scan, which carries a `TAN`
//! WCS with SIP distortion. It shows the single-point and batch
//! transforms, the inverse, and the local pixel scale.
//!
//! Run from the repository root:
//!
//! cargo run --example wcs
use fitsy::{AxisKind, 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");
// `pixel_to_world` returns one value per axis, in axis order.
// `axis_kinds` says which value is which, so a caller finds an axis
// by meaning rather than by position -- FITS permits DEC on axis 1.
let kinds = wcs.axis_kinds();
let lon = kinds
.iter()
.position(|k| *k == AxisKind::Longitude)
.unwrap();
let lat = kinds.iter().position(|k| *k == AxisKind::Latitude).unwrap();
// Single pixel -> sky (0-based pixel coordinates).
// The center of the first pixel is (0.0, 0.0).
let world = wcs.pixel_to_world(&[724.0, 1086.0])?;
let (ra, dec) = (world[lon], world[lat]);
println!("center: RA={ra:.4} Dec={dec:.4}");
// center: RA=114.2089 Dec=65.5917
// Sky -> pixel (round-trip). The result needs no `lon`/`lat`
// lookup. A pixel coordinate belongs to an axis by position, so
// entry 0 is axis 1. Only the world side carries a coordinate kind.
let back = wcs.world_to_pixel(&world)?;
let (px, py) = (back[0], back[1]);
println!("round-trip: ({px:.2}, {py:.2})");
// round-trip: (724.00, 1086.00)
// Batch transform: corners + center -> sky. Points go in flat,
// NAXIS values each, and come back in the same layout.
let pairs = [(0.0_f64, 0.0_f64), (1447.0, 2171.0), (724.0, 1086.0)];
let flat: Vec<f64> = pairs.iter().flat_map(|&(x, y)| [x, y]).collect();
let out = wcs.pixel_to_world_many(&flat)?;
let sky: Vec<(f64, f64)> = out
.as_chunks::<2>()
.0
.iter()
.map(|c| (c[lon], c[lat]))
.collect();
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. These transform
// every axis the WCS declares, celestial or not.
let world = wcs.pixel_to_world(&[724.0, 1086.0])?;
println!("world: {world:?}");
// The batch form takes the points flat: NAXIS values per point,
// end to end, and returns the same layout. It builds its working
// buffers once rather than once per point. A point outside the
// projection becomes NaN rather than failing the whole call.
// Each point comes back in axis order, so `lon` and `lat` from
// `axis_kinds` above still say which value is which -- the batch
// form changes the layout, not the meaning of a slot.
let flat = [0.0, 0.0, 724.0, 1086.0, 1447.0, 2171.0];
let many = wcs.pixel_to_world_many(&flat)?;
for point in many.as_chunks::<2>().0 {
println!("batch: lon={:.4} lat={:.4}", point[lon], point[lat]);
}
// Parsing straight from a Header skips -TAB resolution, so it
// reads no other HDU.
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_world([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_world([256.0, 128.0])
Batch transforms¶
pixel_to_world() and
world_to_pixel() take one point or many. A
length-naxis sequence is one point and returns a list. An
(N, naxis) array is N points and returns an (N, naxis)
array. One path serves every axis kind, celestial or not:
sky = wcs.pixel_to_world(np.array([[31.0, 23.0], [10.0, 40.0]]))
The column count must be exactly naxis. An (naxis, N) array is
the transpose of a batch, not a batch of N points. Such an array
raises a ValueError instead of pairing the wrong values together.
Pass pixels.T when your points run down the columns.
In Rust the batch entry points are Wcs::pixel_to_world_many and
Wcs::world_to_pixel_many, which take the points flat –
NAXIS values per point, end to end – and return the same
layout. They see no shape. They reject only a length that is not a
whole multiple of NAXIS.
Prefer a batch call over a loop. In Python the gain is about fortyfold, because one call crosses the language boundary once and converts its arguments once. In Rust it is two to seven times, from building the working buffers once for the whole call rather than per point; the cheaper the projection, the more that saving is worth.
Most projections cover only part of the plane – SIN’s unit circle,
ZPN below PV2_0, AZP beyond the horizon – so a wide field
routinely mixes valid and invalid pixels. A batch method puts
nan in those slots and returns everything else. Mask the result
with numpy.isfinite to drop them:
sky = wcs.pixel_to_world(pixels)
good = numpy.isfinite(sky).all(axis=1)
A batch call raises only when the whole WCS cannot transform: a malformed point count, or an unresolved lookup table (see Lookup-table axes). Passing a single point instead raises for an out-of-domain coordinate, which is where to go for a diagnostic message explaining why that point failed.
Celestial images¶
is_celestial reports whether the description
declares a longitude and latitude pair, and
celestial_axes() gives their zero-based indices.
A plain sky image puts them at (0, 1):
wcs.is_celestial # True
wcs.celestial_axes() # (0, 1)
pixel_scale_at() measures the local scale in
arcseconds per pixel, one value per celestial axis:
sx, sy = wcs.pixel_scale_at(724.0, 1086.0) # (0.9725, 0.9731)
fitsy measures this by finite difference on the sphere, so the result
carries the projection distortion and any local skew at that pixel. It
is a great-circle distance, always positive, not the signed CDELT
value. An image with flipped RA still reports a positive scale.
The scale of a wide field varies across the image, so measure it where
you need it. The call raises when the WCS declares no celestial pair.
Image extent and footprint¶
A WCS parsed from an image header also records that image’s size as
pixel_shape (FITS axis order, NAXIS1 first),
which footprint() uses to return the world positions of
the corner pixels.
with fitsy.open("image.fits") as f:
wcs = f[0].wcs()
print(wcs.pixel_shape) # e.g. (1448, 2172)
print(wcs.footprint()) # (4, 2) array of (ra, dec)
The shape is (2**k, naxis), where k is the number of axes
pixel_shape covers. That is naxis for a normal image. A
two-axis image gives the familiar four corners; a three-axis cube gives
eight, covering the spectral or time axis as well. Corners come back in
Gray-code order, so consecutive corners differ on one axis alone, and a
two-axis image walks counter-clockwise from the origin and closes the
ring.
These are corners, not an axis-aligned bounding box. A rotated image has
corners outside the box its own minimum and maximum describe, and an
RA axis crossing zero makes such a box meaningless – a one-degree
field straddling the wrap reports RA from 0.18 to 359.81.
Use the corner polygon, or take minima and maxima yourself on axes where
you know neither hazard applies.
Corners go through the batch transform, so they follow its nan
rule. A corner outside the projection’s domain comes back as a row of
nan rather than raising. A wide-field SIN or AZP image can
put every corner outside that domain. Test the result with
numpy.isfinite. Pass one corner to
pixel_to_world() to read the reason it failed.
WCSAXES may exceed NAXIS. A coordinate axis past the end of
pixel_shape then has no length to take a corner from. That axis
holds its reference pixel for every corner. The corner count follows
the image, and every corner still carries a full naxis-value world
vector.
pixel_shape is a snapshot of the NAXISn cards, not part of the
coordinate description. It is None for a WCS from
fitsy.fit_wcs(), since no image exists; no transform consults
it; and nothing revalidates it, so after cropping or rebinning it
still describes the original image.
Alternate descriptions¶
A header may carry up to 26 more descriptions of the same pixels,
each tagged with a letter from A to Z (Standard Sec.8.2).
A survey image often uses one to publish a second astrometric
solution. Pass the letter to select it; ' ' (the default) selects
the primary description:
wcs = f[0].wcs("A") # or f.wcs(0, "A")
The result is None when the header carries no description for
that letter, which is how to test for one.
Finding an axis by meaning¶
pixel_to_world() returns one value per axis, in axis
order. axis_kinds() says what each of those values is,
so a caller locates an axis by what it carries rather than by where it
sits:
with fitsy.open("cube.fits") as f:
wcs = f[0].wcs()
kinds = wcs.axis_kinds() # ['longitude', 'latitude', 'spectral']
world = wcs.pixel_to_world([31.0, 23.0, 4.0])
freq = world[kinds.index("spectral")]
Each entry is one of 'longitude', 'latitude', 'spectral',
'time', 'phase', 'stokes' or 'linear'. Reading by
meaning matters because axis order is not fixed: FITS permits
CTYPE1 = 'DEC--TAN' with CTYPE2 = 'RA---TAN', and real archives
hold such headers. On one, axis_kinds() reports
['latitude', 'longitude'], while indexing position 0 for right
ascension returns declination.
The kind names the type half of CTYPEia, the part before the
algorithm code. RA---TAN and RA---TAB are both 'longitude'.
Fitting a WCS¶
fitsy.fit_wcs() (Python) and fitsy::wcs::fit_celestial_wcs
(Rust) solve for a celestial WCS given pixel <-> sky correspondences.
Use to_header() to turn the result – or any parsed
WCS – back into a fitsy.Header you can merge into an HDU.
It writes everything the reader understands, so parsing the output
reproduces the original transform: the linear pipeline, LONPOLE /
LATPOLE and the projection’s PVi_m parameters, SIP, TPV,
TNX/ZPX, DSS plate solutions, and spectral rest quantities.
One thing a bare header cannot carry is NAXISn, emitted as zero
placeholders, since a WCS has no image attached.
"""Fit a celestial WCS from pixel and sky pairs.
Run from the repository 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],
]
)
# `pixels` and `sky` also accept plain Python lists of lists;
# no numpy import is required at the call site.
pix_list = [[100.0, 100.0], [200.0, 100.0], [100.0, 200.0], [200.0, 200.0]]
sky_list = [[10.00, -5.00], [10.05, -5.00], [10.00, -4.95], [10.05, -4.95]]
fit = fitsy.fit_wcs(pix, sky, projection="TAN")
fit2 = fitsy.fit_wcs(pix_list, sky_list, projection="TAN")
same_rms = abs(fit2.rms_arcsec - fit.rms_arcsec) < 1e-10
assert same_rms, "list and array results should match"
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_world([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 and sky pairs.
//!
//! This shows `fit_celestial_wcs` solving for a `TAN` description from
//! four reference points, and the residuals it reports.
//!
//! Run from the repository root:
//!
//! 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 transforms in both directions.
// `fit_wcs` always emits RA on axis 1 and Dec on axis 2.
let world = fit.wcs.pixel_to_world(&[150.0, 150.0])?;
println!("center: RA={:.4} Dec={:.4}", world[0], world[1]);
Ok(())
}
Lookup-table axes¶
A -TAB axis reads its coordinates from an array in a separate
BINTABLE rather than from a formula (FITS Paper III Sec.6). Nothing
above changes for one, except where the table comes from.
Both fitsy.ImageHdu.wcs() and fitsy.FitsFile.wcs() load
the table from the file the HDU was opened from, so a -TAB axis
transforms like any other. An HDU or FitsFile built in memory has
no file to search, and raises at the wcs() call.
fitsy.Wcs(header) parses a header alone, without the table; its
transforms raise on the unresolved axis. In Rust, FitsFile::wcs
and FitsFile::wcs_inherited load the table, Wcs::from_header
does not, and Wcs::resolve_tab loads it after the fact.
is_tabular() reports whether a given axis takes this
path.
A -TAB axis is defined over its table plus half a sample step at
each end (Paper III Sec.6.1.2, covering the outer halves of the
boundary pixels). A pixel beyond that range has no coordinate. A
single-point call raises there, and a batch method returns nan.
to_header() writes the PSi_m / PVi_m pointer
cards, but not the table they name. Write that BINTABLE as its own
extension.