Processing and analysis code for remote-telescope imaging sessions
The scripts that processed the NGC 5128 session of 2026-07-21 previously lived inside the data directory and addressed it with absolute paths. Code and data are now separated: the code lives here, and a session is located at runtime through the ASTRO_SESSION environment variable. layout.py is what makes that work. It maps a FILENAME to the subdirectory that file belongs in, using the same rules the session directories are organised with, so a script can go on asking for 'master-Red.fit' or '_stars.npz' without any call site knowing the directory structure. Anything unrecognised resolves to the session root, which is visible and correctable rather than silently wrong. restructure.py reorganises a flat session directory into that layout. It is idempotent and dry-run by default. The 50 session scripts are kept as they were run rather than tidied into a library. They were written in sequence as the work went along, several of them by parallel agents, and they show it - but they are the honest provenance of a published set of results, and the productionised pipeline should be able to reproduce those results exactly. Verified before committing: all 51 files compile without warnings, and verify_core.py, closeup.py and triptych.py were run end to end against the reorganised session, correctly finding inputs across calibrated/, stacks/masters/ and final/ and writing outputs back to the right places.
This commit is contained in:
commit
5286a2e81b
53 changed files with 8820 additions and 0 deletions
185
session-scripts/solve.py
Normal file
185
session-scripts/solve.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
"""Pass 3: plate solve the luminance master and copy the WCS to every master.
|
||||
|
||||
iTelescope's calibrated frames arrive with a PinPoint HISTORY line but no WCS
|
||||
keywords at all, so the astrometry has to be redone locally. A blind solve is
|
||||
not needed: the header gives the pointing to arcminutes and the plate scale to
|
||||
four figures, so this fetches a Gaia DR3 catalogue for that patch of sky and
|
||||
matches it to the detected stars.
|
||||
|
||||
The match itself is asterism-based (astroalign), which is invariant to rotation
|
||||
and scale, so the roll angle never has to be guessed. It is NOT invariant to a
|
||||
mirror flip, so both parities are tried and the one that matches wins. The
|
||||
final WCS is a least-squares TAN fit to the matched pairs, and the residual it
|
||||
reports is the honest measure of whether the solve is real.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import astroalign as aa
|
||||
import numpy as np
|
||||
import sep
|
||||
from astropy import units as u
|
||||
from astropy.coordinates import SkyCoord
|
||||
from astropy.io import fits
|
||||
from astropy.wcs import WCS
|
||||
from astropy.wcs.utils import fit_wcs_from_points
|
||||
|
||||
import layout
|
||||
|
||||
SRC = layout.SESSION
|
||||
OUT = layout.SESSION
|
||||
MASTERS = ["Luminance", "Red", "Green", "Blue"]
|
||||
CATCACHE = layout.path("_gaia.npz")
|
||||
|
||||
|
||||
def detect(image, nmax=600):
|
||||
bkg = sep.Background(image, bw=64, bh=64, fw=3, fh=3)
|
||||
sub = image - bkg.back()
|
||||
objs = sep.extract(sub, 8.0, err=bkg.globalrms, minarea=9,
|
||||
deblend_cont=0.005)
|
||||
objs = objs[(objs["flag"] == 0) & (objs["npix"] > 12) &
|
||||
(objs["npix"] < 3000)]
|
||||
objs = objs[np.argsort(objs["flux"])[::-1][:nmax]]
|
||||
return np.column_stack([objs["x"], objs["y"]]), objs["flux"]
|
||||
|
||||
|
||||
def gaia_catalogue(ra_deg, dec_deg, radius_deg, nmax=600):
|
||||
"""Gaia DR3 sources around the pointing, brightest first, cached to disk."""
|
||||
if os.path.exists(CATCACHE):
|
||||
z = np.load(CATCACHE)
|
||||
print(f"catalogue: {len(z['ra'])} cached Gaia sources")
|
||||
return z["ra"], z["dec"], z["g"]
|
||||
from astroquery.gaia import Gaia
|
||||
|
||||
Gaia.ROW_LIMIT = nmax
|
||||
query = f"""
|
||||
SELECT TOP {nmax} ra, dec, phot_g_mean_mag
|
||||
FROM gaiadr3.gaia_source
|
||||
WHERE 1 = CONTAINS(POINT('ICRS', ra, dec),
|
||||
CIRCLE('ICRS', {ra_deg}, {dec_deg}, {radius_deg}))
|
||||
AND phot_g_mean_mag IS NOT NULL
|
||||
ORDER BY phot_g_mean_mag ASC
|
||||
"""
|
||||
tbl = Gaia.launch_job_async(query).get_results()
|
||||
ra = np.asarray(tbl["ra"], dtype=float)
|
||||
dec = np.asarray(tbl["dec"], dtype=float)
|
||||
g = np.asarray(tbl["phot_g_mean_mag"], dtype=float)
|
||||
np.savez_compressed(CATCACHE, ra=ra, dec=dec, g=g)
|
||||
print(f"catalogue: {len(ra)} Gaia DR3 sources, G {g.min():.1f}-{g.max():.1f}")
|
||||
return ra, dec, g
|
||||
|
||||
|
||||
def project(ra, dec, ra0, dec0, scale_arcsec, parity):
|
||||
"""Gnomonic projection to pixel-like coordinates for asterism matching."""
|
||||
c = SkyCoord(ra * u.deg, dec * u.deg)
|
||||
centre = SkyCoord(ra0 * u.deg, dec0 * u.deg)
|
||||
dx, dy = centre.spherical_offsets_to(c)
|
||||
x = dx.to_value(u.arcsec) / scale_arcsec * parity
|
||||
y = dy.to_value(u.arcsec) / scale_arcsec
|
||||
return np.column_stack([x, y])
|
||||
|
||||
|
||||
def main():
|
||||
path = layout.path("master-Luminance.fit")
|
||||
with fits.open(path) as hd:
|
||||
image = hd[0].data.astype(np.float32)
|
||||
hdr = hd[0].header
|
||||
ny, nx = image.shape
|
||||
|
||||
centre = SkyCoord(hdr["OBJCTRA"], hdr["OBJCTDEC"],
|
||||
unit=(u.hourangle, u.deg))
|
||||
scale = float(hdr["HIERARCH iTelescopePlateScaleH"])
|
||||
radius = 1.15 * 0.5 * np.hypot(nx, ny) * scale / 3600.0
|
||||
print(f"pointing {centre.to_string('hmsdms')} scale {scale:.4f}\"/px "
|
||||
f"search radius {radius:.3f} deg")
|
||||
|
||||
xy, flux = detect(image)
|
||||
print(f"detected {len(xy)} stars in the luminance master")
|
||||
|
||||
ra, dec, gmag = gaia_catalogue(centre.ra.deg, centre.dec.deg, radius)
|
||||
|
||||
best = None
|
||||
for parity in (-1.0, 1.0):
|
||||
cat_xy = project(ra, dec, centre.ra.deg, centre.dec.deg, scale, parity)
|
||||
try:
|
||||
tform, (src, dst) = aa.find_transform(cat_xy, xy)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f" parity {parity:+.0f}: no match ({exc})")
|
||||
continue
|
||||
print(f" parity {parity:+.0f}: matched {len(src)} stars, "
|
||||
f"rotation {np.degrees(tform.rotation):.3f} deg, "
|
||||
f"scale {tform.scale:.5f}")
|
||||
if best is None or len(src) > best[0]:
|
||||
best = (len(src), parity, src, dst)
|
||||
if best is None:
|
||||
sys.exit("plate solve failed: no asterism match in either parity")
|
||||
|
||||
nmatch, parity, cat_pts, img_pts = best
|
||||
# Recover which catalogue rows were matched so the fit uses sky coordinates
|
||||
# rather than the projected proxy.
|
||||
cat_xy = project(ra, dec, centre.ra.deg, centre.dec.deg, scale, parity)
|
||||
idx = [int(np.argmin(np.hypot(cat_xy[:, 0] - px, cat_xy[:, 1] - py)))
|
||||
for px, py in cat_pts]
|
||||
world = SkyCoord(ra[idx] * u.deg, dec[idx] * u.deg)
|
||||
|
||||
wcs = fit_wcs_from_points((img_pts[:, 0], img_pts[:, 1]), world,
|
||||
proj_point="center", projection="TAN")
|
||||
pred = wcs.world_to_pixel(world)
|
||||
resid = np.hypot(pred[0] - img_pts[:, 0], pred[1] - img_pts[:, 1])
|
||||
print(f"seed fit on {nmatch} stars: residual median {np.median(resid):.2f} px "
|
||||
f"({np.median(resid) * scale:.2f}\"), max {resid.max():.2f} px")
|
||||
|
||||
# The asterism match only ever returns a handful of stars. Now that an
|
||||
# approximate solution exists, every catalogue source can be pushed through
|
||||
# it and paired with the nearest detection, which grows the fit from a
|
||||
# dozen stars to hundreds and averages down the centroid noise. Two passes
|
||||
# with a shrinking tolerance is enough to converge.
|
||||
all_world = SkyCoord(ra * u.deg, dec * u.deg)
|
||||
for tol in (4.0, 2.0):
|
||||
px, py = wcs.world_to_pixel(all_world)
|
||||
pairs = []
|
||||
for i, (cx, cy) in enumerate(zip(px, py)):
|
||||
if not (0 <= cx < nx and 0 <= cy < ny):
|
||||
continue
|
||||
d = np.hypot(xy[:, 0] - cx, xy[:, 1] - cy)
|
||||
j = int(np.argmin(d))
|
||||
if d[j] <= tol:
|
||||
pairs.append((i, j))
|
||||
if len(pairs) < 20:
|
||||
print(f" refine (tol {tol} px): only {len(pairs)} pairs, kept seed")
|
||||
break
|
||||
ci = np.array([p[0] for p in pairs])
|
||||
ii = np.array([p[1] for p in pairs])
|
||||
wcs = fit_wcs_from_points((xy[ii, 0], xy[ii, 1]), all_world[ci],
|
||||
proj_point="center", projection="TAN")
|
||||
qx, qy = wcs.world_to_pixel(all_world[ci])
|
||||
resid = np.hypot(qx - xy[ii, 0], qy - xy[ii, 1])
|
||||
nmatch = len(pairs)
|
||||
print(f" refine (tol {tol} px): {nmatch} stars, residual median "
|
||||
f"{np.median(resid):.2f} px ({np.median(resid) * scale:.2f}\"), "
|
||||
f"max {resid.max():.2f} px")
|
||||
|
||||
cen = wcs.pixel_to_world(nx / 2.0, ny / 2.0)
|
||||
cd = wcs.pixel_scale_matrix * 3600.0
|
||||
solved_scale = np.sqrt(abs(np.linalg.det(cd)))
|
||||
rot = np.degrees(np.arctan2(cd[0, 1], cd[1, 1]))
|
||||
print(f"field centre {cen.to_string('hmsdms')}")
|
||||
print(f"solved scale {solved_scale:.4f}\"/px, position angle {rot:.2f} deg")
|
||||
print(f"field of view {nx * solved_scale / 60:.1f}' x "
|
||||
f"{ny * solved_scale / 60:.1f}'")
|
||||
|
||||
whdr = wcs.to_header()
|
||||
for name in MASTERS:
|
||||
p = layout.path(f"master-{name}.fit")
|
||||
with fits.open(p, mode="update") as hd:
|
||||
for card in whdr.cards:
|
||||
hd[0].header[card.keyword] = (card.value, card.comment)
|
||||
hd[0].header["ASTRSOLV"] = (
|
||||
f"Gaia DR3 / {nmatch} stars / {np.median(resid) * scale:.2f} arcsec",
|
||||
"local plate solution")
|
||||
hd.flush()
|
||||
print(f" WCS written to {os.path.basename(p)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue