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.
177 lines
7.7 KiB
Python
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)
|