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:
laurence 2026-07-21 15:29:49 +01:00
commit 5286a2e81b
53 changed files with 8820 additions and 0 deletions

195
session-scripts/enhance.py Normal file
View file

@ -0,0 +1,195 @@
"""Pass 5: three further renderings from the same masters.
1. NGC5128-img-deconvolved - the luminance sharpened by Richardson-Lucy against a PSF
measured from the frame's own stars, then recombined into LRGB. The seeing
was 2.69 arcsec, so there is real detail to recover in the dust lane; the
deconvolution is deliberately stopped early and applied only where the
signal is strong, because RL amplifies noise and rings around bright stars
if it is let run.
2. NGC5128-img-core-print - a full-resolution crop of the galaxy for printing.
The star/starless separation lives in starless.py, which works from this
script's output.
The stretch, colour calibration and gradient handling are imported from
compose.py rather than re-implemented, so these renderings and the main image
cannot drift apart.
"""
import os
import sys
import numpy as np
import sep
import tifffile
from astropy.io import fits
from PIL import Image
from scipy.ndimage import gaussian_filter, median_filter
from skimage.restoration import richardson_lucy
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import compose as C # noqa: E402
import layout
OUT = C.OUT
RL_ITERS = 12
PSF_BOX = 25
def measure_psf(img):
"""Median-stack cutouts of isolated stars to get the frame's own PSF."""
bkg = sep.Background(img, bw=64, bh=64, fw=3, fh=3)
sub = img - bkg.back()
o = sep.extract(sub, 25.0, err=bkg.globalrms, minarea=9,
deblend_cont=0.005)
o = o[(o["flag"] == 0) & (o["npix"] > 15) & (o["npix"] < 400)]
ny, nx = img.shape
h = PSF_BOX // 2
# Isolated stars only: a neighbour inside the cutout would drag the wings.
keep = []
xs, ys = o["x"], o["y"]
for i in range(len(o)):
if not (h + 2 < xs[i] < nx - h - 2 and h + 2 < ys[i] < ny - h - 2):
continue
d = np.hypot(xs - xs[i], ys - ys[i])
if np.sort(d)[1] < 3 * PSF_BOX:
continue
keep.append(i)
keep = keep[:120]
stack = []
for i in keep:
cx, cy = int(round(xs[i])), int(round(ys[i]))
cut = sub[cy - h:cy + h + 1, cx - h:cx + h + 1].astype(np.float64)
peak = cut.max()
if peak > 0 and peak < 60000: # skip anything near saturation
stack.append(cut / cut.sum())
psf = np.median(np.stack(stack), axis=0)
psf[psf < 0] = 0
psf /= psf.sum()
print(f"PSF from {len(stack)} isolated stars, "
f"peak fraction {psf.max():.4f}")
return psf.astype(np.float32)
def star_mask(img, rms):
"""Feathered mask over stars, used to keep deconvolution off them.
Richardson-Lucy rings around any source whose profile is steeper than the
PSF model can account for, and on a star field that means a dark annulus
round every bright star. Excluding stars from the deconvolution entirely
is the standard cure: the galaxy is what needed sharpening anyway.
"""
bkg = sep.Background(img, bw=64, bh=64, fw=3, fh=3)
sub = img - bkg.back()
o = sep.extract(sub, 6.0, err=rms, minarea=6, deblend_cont=0.005)
o = o[o["npix"] < 3000]
ny, nx = img.shape
m = np.zeros((ny, nx), np.float32)
yy, xx = np.mgrid[-20:21, -20:21]
rr = np.hypot(xx, yy)
for x, y, npix, flux in zip(o["x"], o["y"], o["npix"], o["flux"]):
# Bright stars ring over a wider radius than faint ones.
r = float(np.clip(2.5 * np.sqrt(npix / np.pi) + 3.0, 5, 18))
cx, cy = int(round(x)), int(round(y))
x0, x1 = max(0, cx - 20), min(nx, cx + 21)
y0, y1 = max(0, cy - 20), min(ny, cy + 21)
patch = (rr <= r).astype(np.float32)[
(y0 - cy + 20):(y1 - cy + 20), (x0 - cx + 20):(x1 - cx + 20)]
np.maximum(m[y0:y1, x0:x1], patch, out=m[y0:y1, x0:x1])
print(f" star mask over {len(o)} sources, {m.mean():.2%} of the frame")
return np.clip(gaussian_filter(m, 2.5), 0, 1)
def deconvolve(img, psf):
"""Richardson-Lucy on the bright signal, feathered back into the noise."""
rms = 1.4826 * np.median(np.abs(img - np.median(img)))
pedestal = 5.0 * rms
positive = np.clip(img + pedestal, 1e-3, None).astype(np.float32)
scale = float(positive.max())
out = richardson_lucy(positive / scale, psf, num_iter=RL_ITERS,
clip=False) * scale - pedestal
# Two weights multiply together: apply the result only where there is
# signal to sharpen, and only where there is no star to ring.
signal = np.clip((img - 3.0 * rms) / (20.0 * rms), 0.0, 1.0)
w = (signal * (1.0 - star_mask(img, rms))).astype(np.float32)
return (out * w + img * (1.0 - w)).astype(np.float32)
def build_rgb(lum_lin, rgb_lin):
"""The colour half of compose.main(), reused verbatim in spirit."""
lum, params = C.autostretch(lum_lin)
rgb = np.empty_like(rgb_lin)
for i in range(3):
ch = rgb_lin[:, :, i]
sky = np.median(ch)
mad = 1.4826 * np.median(np.abs(ch - sky))
black = sky - 2.8 * mad
white = np.percentile(ch, 99.995)
rgb[:, :, i] = C.mtf(np.clip((ch - black) / (white - black), 0, 1),
params["midtone"])
return lum, rgb
def main():
data, hdr = C.load()
shape = data["Luminance"].shape
mask = C.galaxy_mask(shape)
for name in C.CHANNELS:
data[name], *_ = C.remove_gradient(data[name], mask)
o, flux = C.star_photometry(data["Luminance"], data)
good = (flux["Red"] > 0) & (flux["Green"] > 0) & (flux["Blue"] > 0)
gr = np.median(flux["Green"][good] / flux["Red"][good])
gb = np.median(flux["Green"][good] / flux["Blue"][good])
data["Red"] *= gr
data["Blue"] *= gb
psf = measure_psf(data["Luminance"])
print(f"deconvolving luminance, {RL_ITERS} Richardson-Lucy iterations")
lum_lin = deconvolve(data["Luminance"], psf)
rgb_lin = np.dstack([data["Red"], data["Green"], data["Blue"]])
del data
lum, rgb = build_rgb(lum_lin, rgb_lin)
del rgb_lin, lum_lin
sky_med = [float(np.median(rgb[:, :, i][~mask])) for i in range(3)]
target = float(np.mean(sky_med))
for i in range(3):
rgb[:, :, i] = np.clip(rgb[:, :, i] - (sky_med[i] - target), 0.0, 1.0)
rgb_lum = rgb.mean(axis=2, keepdims=True)
chroma = rgb - rgb_lum
for i in range(3):
chroma[:, :, i] = gaussian_filter(median_filter(chroma[:, :, i], 3),
1.5)
rgb = np.clip(rgb_lum + chroma * C.SATURATION, 0.0, 1.0)
del chroma, rgb_lum
ratio = lum / np.maximum(rgb.mean(axis=2), 1e-5)
out = np.clip(rgb * ratio[:, :, None], 0.0, 1.0)
del ratio, rgb
neutral = 0.5 * (out[:, :, 0] + out[:, :, 2])
green = out[:, :, 1]
out[:, :, 1] = np.where(green > neutral, green * 0.15 + neutral * 0.85,
green)
u8 = (out * 255 + 0.5).astype(np.uint8)
Image.fromarray(u8).save(layout.path("NGC5128-img-deconvolved.png"))
tifffile.imwrite(layout.path("NGC5128-img-deconvolved.tif"),
(out * 65535 + 0.5).astype(np.uint16), photometric="rgb")
print("wrote NGC5128-img-deconvolved.png / .tif")
# A crop for print, taken from the deconvolved version at full resolution.
ny, nx = out.shape[:2]
cw, ch = 2600, 1950
crop = u8[ny // 2 - ch // 2:ny // 2 + ch // 2,
nx // 2 - cw // 2:nx // 2 + cw // 2]
Image.fromarray(crop).save(layout.path("NGC5128-img-core-print.jpg"),
quality=95)
print(f"wrote NGC5128-img-core-print.jpg ({cw}x{ch}, "
f"{cw * 0.5376 / 60:.1f}' x {ch * 0.5376 / 60:.1f}')")
del crop, u8
if __name__ == "__main__":
main()