astrophotography/session-scripts/annotate.py
laurence 5286a2e81b 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.
2026-07-21 15:29:49 +01:00

177 lines
7.7 KiB
Python

"""Annotated version of the finished LRGB: coordinate grid and catalogued objects.
The overlay is driven entirely by the local plate solution, so every label sits
where the astrometry says it should. Objects come from SIMBAD, restricted to a
cone matching the field and to types worth marking (galaxies, clusters, radio
sources), then filtered again to those that actually fall inside the frame.
The annotation is drawn on a downsampled copy: at full 15 Mpx the labels would
be microscopic relative to the image, and nobody views a 4692 px wide frame at
1:1 to read a caption.
"""
import os
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from astropy import units as u
from astropy.coordinates import SkyCoord
from astropy.io import fits
from astropy.wcs import WCS
from PIL import Image
import layout
OUT = layout.SESSION
CACHE = layout.path("_simbad.npz")
SCALE = 3 # downsample factor for the annotated render
with fits.open(layout.path("NGC5128-LRGB.fit")) as hd:
hdr = hd[0].header
wcs = WCS(hdr, naxis=2)
rgb = np.asarray(Image.open(layout.path("NGC5128-LRGB.png")))
ny, nx = rgb.shape[:2]
small = np.asarray(Image.fromarray(rgb).resize((nx // SCALE, ny // SCALE),
Image.LANCZOS))
# Slicing a WCS rescales it correctly whether the solution is stored as CD or
# as PC + CDELT, which hand-editing the matrix does not.
wcs_small = wcs[::SCALE, ::SCALE]
centre = wcs.pixel_to_world(nx / 2, ny / 2)
print(f"frame {nx}x{ny} -> render {small.shape[1]}x{small.shape[0]}")
def simbad_objects():
if os.path.exists(CACHE):
z = np.load(CACHE, allow_pickle=True)
return z["name"], z["ra"], z["dec"], z["otype"]
from astroquery.simbad import Simbad
sim = Simbad()
sim.ROW_LIMIT = 2000
for field in ("otype", "V"):
try:
sim.add_votable_fields(field)
except Exception: # noqa: BLE001
pass
tbl = sim.query_region(centre, radius=0.42 * u.deg)
name = np.array([str(r) for r in tbl[tbl.colnames[0]]])
coords = SkyCoord(tbl["ra"], tbl["dec"], unit=(u.deg, u.deg))
otype = np.array([str(t) for t in tbl["otype"]]) if "otype" in \
tbl.colnames else np.array([""] * len(tbl))
np.savez_compressed(CACHE, name=name, ra=coords.ra.deg,
dec=coords.dec.deg, otype=otype)
return name, coords.ra.deg, coords.dec.deg, otype
name, ra, dec, otype = simbad_objects()
print(f"{len(name)} SIMBAD entries in the cone")
# SIMBAD returns 1518 rows for this field, the bulk of them anonymous entries
# from Centaurus A cluster and variable-star surveys. Marking those would bury
# the image, and the cluster system is being catalogued separately, so the
# overlay keeps only whole objects: other galaxies, planetary nebulae and
# anything carrying a mainstream catalogue designation.
GALAXY_TYPES = ("G", "GiG", "GiP", "GiC", "AGN", "SyG", "rG", "LSB")
# Confirmed nebulae only. SIMBAD lists 93 "PN?" candidates from a single
# survey of this field; they are unconfirmed, they are not visible at this
# depth, and marking them makes the image unreadable.
NEBULA_TYPES = ("PN", "HII", "SNR")
is_gal = np.isin(otype, GALAXY_TYPES)
is_neb = np.isin(otype, NEBULA_TYPES)
mainstream = np.array([n.startswith(("NGC", "IC ", "ESO", "PGC", "AM ", "SN "))
and not n.startswith("SNR")
for n in name])
sel = is_gal | is_neb | mainstream
sky = SkyCoord(ra[sel] * u.deg, dec[sel] * u.deg)
x, y = wcs_small.world_to_pixel(sky)
inside = (x > 40) & (x < small.shape[1] - 40) & (y > 40) & \
(y < small.shape[0] - 40)
labels, kinds = name[sel][inside], otype[sel][inside]
x, y = x[inside], y[inside]
print(f"{len(labels)} catalogued objects inside the frame "
f"({is_gal[sel][inside].sum()} galaxies)")
# Plain axes, not WCSAxes: WCSAxes insists on origin='lower', which would
# publish this image as a vertical mirror of every other deliverable. Drawing
# the graticule by hand keeps all the outputs in one orientation, and the
# lines still come from the plate solution rather than from assumption.
fig = plt.figure(figsize=(small.shape[1] / 100, small.shape[0] / 100), dpi=100)
ax = fig.add_axes([0, 0, 1, 1])
ax.imshow(small, origin="upper")
ax.set_axis_off()
corners = wcs_small.pixel_to_world(
[0, small.shape[1], 0, small.shape[1]],
[0, 0, small.shape[0], small.shape[0]])
ra_lo, ra_hi = corners.ra.deg.min(), corners.ra.deg.max()
dec_lo, dec_hi = corners.dec.deg.min(), corners.dec.deg.max()
def draw_line(coord_ra, coord_dec, label, at_ra):
px, py = wcs_small.world_to_pixel(SkyCoord(coord_ra * u.deg,
coord_dec * u.deg))
ok = (px > 0) & (px < small.shape[1]) & (py > 0) & (py < small.shape[0])
if ok.sum() < 2:
return
ax.plot(px[ok], py[ok], color="#5fa8ff", alpha=0.30, linestyle=":",
linewidth=0.9)
i = np.where(ok)[0][len(np.where(ok)[0]) // 2]
ax.text(px[i], py[i], label, color="#8fc4ff", fontsize=8,
family="monospace", rotation=0 if at_ra else 90,
ha="center", va="center",
bbox=dict(boxstyle="round,pad=0.12", fc="black", ec="none",
alpha=0.45))
RA_STEP = 15.0 / 60.0 # one minute of right ascension, in degrees
DEC_STEP = 10.0 / 60.0 # ten arcminutes
t = np.linspace(dec_lo, dec_hi, 400)
for r in np.arange(np.ceil(ra_lo / RA_STEP) * RA_STEP, ra_hi, RA_STEP):
c = SkyCoord(r * u.deg, 0 * u.deg)
draw_line(np.full_like(t, r), t,
f"{int(c.ra.hms.h):02d}h{int(c.ra.hms.m):02d}m", False)
s_ = np.linspace(ra_lo, ra_hi, 400)
for d in np.arange(np.ceil(dec_lo / DEC_STEP) * DEC_STEP, dec_hi, DEC_STEP):
dm = abs(d - int(d)) * 60
draw_line(s_, np.full_like(s_, d), f"{int(d):+03d}d{dm:02.0f}m", True)
for lx, ly, lab, kind in zip(x, y, labels, kinds):
colour = "#7ee08a" if kind in GALAXY_TYPES else "#ffd166"
ax.add_patch(plt.Circle((lx, ly), 15, fill=False, color=colour,
linewidth=1.2, alpha=0.95))
ax.text(lx + 19, ly - 11, f"{lab} [{kind}]", color=colour, fontsize=7.5,
family="monospace",
bbox=dict(boxstyle="round,pad=0.12", fc="black", ec="none",
alpha=0.45))
# Scale bar: one arcminute, measured through the plate solution rather than
# assumed, plus the physical scale at Centaurus A's distance.
pix_per_arcmin = 60.0 / (0.5376 * SCALE)
bx, by = 60, small.shape[0] - 60
ax.plot([bx, bx + pix_per_arcmin], [by, by], color="white", linewidth=2.5)
ax.text(bx, by - 12, "1' = 1.1 kpc at 3.8 Mpc", color="white", fontsize=9)
# Orientation: north and east taken from the WCS, so a flipped or rotated
# solution cannot silently produce a wrong compass.
cx, cy = small.shape[1] - 120, small.shape[0] - 120
c0 = wcs_small.pixel_to_world(cx, cy)
for dlab, offset in (("N", (0 * u.arcmin, 2 * u.arcmin)),
("E", (2 * u.arcmin, 0 * u.arcmin))):
p = c0.spherical_offsets_by(*offset)
px, py = wcs_small.world_to_pixel(p)
ax.annotate("", xy=(px, py), xytext=(cx, cy),
arrowprops=dict(color="white", width=1.0, headwidth=6))
ax.text(px, py, dlab, color="white", fontsize=11, ha="center",
va="center")
ax.text(20, 26, "NGC 5128 (Centaurus A) iTelescope T32, Siding Spring "
"2026-07-21 L 12x300s RGB 4x300s each",
color="white", fontsize=10)
ax.text(20, 44, f"plate solved against Gaia DR3: {hdr.get('ASTRSOLV', '')}",
color="#9fb8d0", fontsize=8)
ax.set_xlim(0, small.shape[1])
ax.set_ylim(small.shape[0], 0)
path = layout.path("NGC5128-img-annotated.jpg")
fig.savefig(path, dpi=100, pil_kwargs={"quality": 92})
print("wrote", path)