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
311
session-scripts/final.py
Normal file
311
session-scripts/final.py
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
"""The three renderings, from minimal to everything we learned.
|
||||
|
||||
Same 24 subs, same alignment, same field, same crop. The ONLY variable is how
|
||||
much is done to the pixels, so the three are directly comparable.
|
||||
|
||||
NGC5128-final-1-stacked.png stack + a standard stretch, nothing else
|
||||
NGC5128-final-2-processed.png conventional processing: gradient, colour, denoise
|
||||
NGC5128-final-3-best.png the above, corrected by what the analyses established
|
||||
|
||||
What image 3 does differently, and why each change is justified by a measured
|
||||
result rather than by taste:
|
||||
|
||||
1. **Background fit that does not eat the halo.** The surface photometry
|
||||
measured the far field sitting at -17.9 ADU/px instead of zero: the plane
|
||||
fitted for image 2 absorbed real halo light, because Centaurus A's halo
|
||||
fills this field and there is no genuinely empty corner to fit to. Image 3
|
||||
fits `channel = a * galaxy_model + plane` simultaneously, using the isophote
|
||||
model from the surface photometry, so the plane can only take the part that
|
||||
is actually a gradient. The halo survives.
|
||||
|
||||
2. **Photometric colour calibration.** Image 2 assumed the average field star
|
||||
is grey. Image 3 uses Gaia BP-RP to pick the 1313 stars that are genuinely
|
||||
solar-coloured and neutralises on those alone, which does not care what mix
|
||||
of spectral types this particular field contains.
|
||||
|
||||
3. **The core, recovered.** The nucleus was never saturated - it peaks at 1944
|
||||
ADU against a ~63000 clip. A second tone curve scaled to the galaxy rather
|
||||
than to field stars restores the bulge gradient the single curve flattened.
|
||||
|
||||
4. **Deconvolution that does not ring.** PSF measured from the frame's own
|
||||
isolated stars, applied only where there is signal and never on a star.
|
||||
|
||||
5. **Noise reduction that cannot eat clusters.** The globular cluster survey
|
||||
showed this field contains 289 cluster candidates that look exactly like
|
||||
faint stars. Smoothing is therefore driven by a Gaia star mask plus a
|
||||
signal mask, so every compact source - foreground star or cluster - is
|
||||
excluded from it.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import sep
|
||||
import tifffile
|
||||
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
|
||||
from scipy.ndimage import gaussian_filter, median_filter
|
||||
from skimage.restoration import denoise_tv_chambolle, richardson_lucy
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import compose as C # noqa: E402
|
||||
import enhance as E # noqa: E402
|
||||
|
||||
import layout
|
||||
|
||||
SRC = layout.SESSION
|
||||
OUT = layout.SESSION
|
||||
# The finished renderings live in their own directory: they are the
|
||||
# deliverables, and keeping them apart from the masters, the
|
||||
# intermediates and the analysis figures makes it obvious which
|
||||
# files are meant to be looked at.
|
||||
FINAL = layout.path("final")
|
||||
ORIG = layout.path("original")
|
||||
CROP = 48
|
||||
FILTERS = ["Luminance", "Red", "Green", "Blue"]
|
||||
|
||||
|
||||
def save(arr, stem, title):
|
||||
os.makedirs(FINAL, exist_ok=True)
|
||||
u8 = (np.clip(arr, 0, 1) * 255 + 0.5).astype(np.uint8)
|
||||
Image.fromarray(u8).save(layout.path(f"{stem}.png"))
|
||||
tifffile.imwrite(layout.path(f"{stem}.tif"),
|
||||
(np.clip(arr, 0, 1) * 65535 + 0.5).astype(np.uint16),
|
||||
photometric="rgb")
|
||||
prev = Image.fromarray(u8)
|
||||
prev.thumbnail((2400, 2400), Image.LANCZOS)
|
||||
prev.save(layout.path(f"{stem}-preview.jpg"), quality=93)
|
||||
print(f" wrote {stem}.png / .tif / -preview.jpg [{title}]")
|
||||
|
||||
|
||||
def lrgb(lum, rgb):
|
||||
"""Take hue from the colour channels, brightness from the luminance."""
|
||||
ratio = lum / np.maximum(rgb.mean(axis=2), 1e-5)
|
||||
return np.clip(rgb * ratio[:, :, None], 0.0, 1.0)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- image 1
|
||||
def image1():
|
||||
"""Stack plus a standard stretch. No corrections of any kind.
|
||||
|
||||
Uses the plain-mean alignment-only stack, so there is not even outlier
|
||||
rejection: satellite trails and the moon's gradient are all present. The
|
||||
stretch is the conventional one - a black point just below each channel's
|
||||
own sky, a white point at its 99.995th percentile, and a single shared
|
||||
midtone taken from the luminance so no colour balancing sneaks in through
|
||||
the tone curve.
|
||||
"""
|
||||
print("image 1: stack + standard stretch")
|
||||
data = {}
|
||||
for f in FILTERS:
|
||||
d = fits.getdata(layout.path(f"original-{f}.fit"))
|
||||
data[f] = np.array(d[CROP:-CROP, CROP:-CROP], dtype=np.float32)
|
||||
lum_lin = data["Luminance"]
|
||||
lum, params = C.autostretch(lum_lin)
|
||||
print(f" shared midtone {params['midtone']:.4f}")
|
||||
rgb = np.empty(lum.shape + (3,), np.float32)
|
||||
for i, f in enumerate(("Red", "Green", "Blue")):
|
||||
ch = data[f]
|
||||
sky = np.median(ch)
|
||||
mad = 1.4826 * np.median(np.abs(ch - sky))
|
||||
black, white = sky - 2.8 * mad, np.percentile(ch, 99.995)
|
||||
rgb[:, :, i] = C.mtf(np.clip((ch - black) / (white - black), 0, 1),
|
||||
params["midtone"])
|
||||
print(f" {f:6s} black {black:7.1f} white {white:8.1f} ADU")
|
||||
del data
|
||||
out = lrgb(lum, rgb)
|
||||
save(out, "NGC5128-final-1-stacked", "align + mean + stretch")
|
||||
return out.shape
|
||||
|
||||
|
||||
# --------------------------------------------------------------- image 3
|
||||
def solar_white_balance(lum, channels, wcs, shape):
|
||||
"""Neutralise on stars that are genuinely solar-coloured, per Gaia BP-RP."""
|
||||
z = np.load(layout.path("_gaia_colours.npz"))
|
||||
solar = np.abs(z["bprp"] - 0.82) < 0.15
|
||||
sky = SkyCoord(z["ra"][solar] * u.deg, z["dec"][solar] * u.deg)
|
||||
x, y = wcs.world_to_pixel(sky)
|
||||
g = z["g"][solar]
|
||||
ny, nx = shape
|
||||
# Keep them away from the edges, off the galaxy's bright core, and out of
|
||||
# saturation; faint ones carry too little signal in 20 minutes of colour.
|
||||
ok = ((x > 40) & (x < nx - 40) & (y > 40) & (y < ny - 40) &
|
||||
(g > 11.5) & (g < 16.5))
|
||||
x, y = x[ok], y[ok]
|
||||
flux = {}
|
||||
for f in ("Red", "Green", "Blue"):
|
||||
img = np.ascontiguousarray(channels[f])
|
||||
fl, _, _ = sep.sum_circle(img, x, y, 6.0, subpix=5)
|
||||
flux[f] = fl
|
||||
good = (flux["Red"] > 0) & (flux["Green"] > 0) & (flux["Blue"] > 0)
|
||||
gr = float(np.median(flux["Green"][good] / flux["Red"][good]))
|
||||
gb = float(np.median(flux["Green"][good] / flux["Blue"][good]))
|
||||
print(f" solar-analogue white balance on {int(good.sum())} stars: "
|
||||
f"R x {gr:.4f}, B x {gb:.4f}")
|
||||
return gr, gb
|
||||
|
||||
|
||||
def fit_background(ch, model, star_mask):
|
||||
"""Solve ch = a*model + (plane) and subtract ONLY the plane.
|
||||
|
||||
Fitting a plane on its own to a field this full of galaxy makes the plane
|
||||
absorb halo light - measured at -17.9 ADU/px in the far field of image 2.
|
||||
Including the galaxy model as a free component in the same least-squares
|
||||
problem gives the fit something else to attribute that light to.
|
||||
"""
|
||||
ny, nx = ch.shape
|
||||
ys, xs = np.mgrid[0:ny:8, 0:nx:8]
|
||||
m = model[::8, ::8]
|
||||
v = ch[::8, ::8]
|
||||
keep = (star_mask[::8, ::8] == 0) & np.isfinite(v)
|
||||
A = np.column_stack([m[keep], xs[keep] / nx, ys[keep] / ny,
|
||||
np.ones(keep.sum())])
|
||||
coef, *_ = np.linalg.lstsq(A, v[keep], rcond=None)
|
||||
for _ in range(3): # clip and refit
|
||||
pred = A @ coef
|
||||
r = v[keep] - pred
|
||||
s = 1.4826 * np.median(np.abs(r - np.median(r)))
|
||||
m2 = np.abs(r - np.median(r)) < 2.5 * s
|
||||
coef, *_ = np.linalg.lstsq(A[m2], v[keep][m2], rcond=None)
|
||||
yy, xx = np.mgrid[0:ny, 0:nx]
|
||||
plane = (coef[1] * xx / nx + coef[2] * yy / ny + coef[3]).astype(np.float32)
|
||||
print(f" model amplitude {coef[0]:.4f}, plane offset {coef[3]:8.2f} ADU")
|
||||
return ch - plane
|
||||
|
||||
|
||||
def image3():
|
||||
print("image 3: science-informed")
|
||||
model_full = fits.getdata(layout.path("sb-model.fits")).astype(
|
||||
np.float32)
|
||||
star_full = fits.getdata(layout.path("sb-mask-stars.fits"))
|
||||
with fits.open(layout.path("master-Luminance.fit")) as hd:
|
||||
wcs_full = WCS(hd[0].header, naxis=2)
|
||||
|
||||
data = {}
|
||||
for f in FILTERS:
|
||||
d = fits.getdata(layout.path(f"master-{f}.fit"))
|
||||
data[f] = np.array(d, dtype=np.float32)
|
||||
model = model_full
|
||||
smask = star_full
|
||||
print(" background fit with the galaxy model as a free component:")
|
||||
for f in FILTERS:
|
||||
print(f" {f}")
|
||||
data[f] = fit_background(data[f], model, smask)
|
||||
del model_full, star_full, model, smask
|
||||
|
||||
shape_full = data["Luminance"].shape
|
||||
gr, gb = solar_white_balance(data["Luminance"], data, wcs_full, shape_full)
|
||||
data["Red"] *= gr
|
||||
data["Blue"] *= gb
|
||||
|
||||
for f in FILTERS:
|
||||
data[f] = np.ascontiguousarray(data[f][CROP:-CROP, CROP:-CROP])
|
||||
shape = data["Luminance"].shape
|
||||
|
||||
psf = E.measure_psf(data["Luminance"])
|
||||
print(" deconvolving luminance (star-protected)")
|
||||
lum_lin = E.deconvolve(data["Luminance"], psf)
|
||||
rgb_lin = np.dstack([data["Red"], data["Green"], data["Blue"]])
|
||||
del data
|
||||
|
||||
# Two tone curves, blended: the faint one for sky and halo, one scaled to
|
||||
# the galaxy for the core the single curve flattened.
|
||||
ny, nx = shape
|
||||
h = 500
|
||||
core = lum_lin[ny // 2 - h:ny // 2 + h, nx // 2 - h:nx // 2 + h]
|
||||
peak = float(median_filter(core, size=41).max())
|
||||
lum_faint, params = C.autostretch(lum_lin)
|
||||
hi = peak * 1.15
|
||||
lum_bright = C.mtf(np.clip((lum_lin - params["black"]) /
|
||||
(hi - params["black"]), 0, 1), 0.35)
|
||||
w = gaussian_filter(np.clip((lum_faint - 0.55) / 0.35, 0, 1).astype(
|
||||
np.float32), 8.0)
|
||||
lum = np.clip(lum_faint * (1 - w) + lum_bright * w, 0, 1)
|
||||
print(f" galaxy peak {peak:.0f} ADU, HDR blend over "
|
||||
f"{float((w > 0.05).mean()):.2%} of frame")
|
||||
|
||||
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, white = sky - 2.8 * mad, np.percentile(ch, 99.995)
|
||||
cf = C.mtf(np.clip((ch - black) / (white - black), 0, 1),
|
||||
params["midtone"])
|
||||
pc = float(median_filter(
|
||||
ch[ny // 2 - h:ny // 2 + h, nx // 2 - h:nx // 2 + h], 41).max())
|
||||
cb = C.mtf(np.clip((ch - black) / (pc * 1.15 - black), 0, 1), 0.35)
|
||||
rgb[:, :, i] = cf * (1 - w) + cb * w
|
||||
del rgb_lin
|
||||
|
||||
# Sky neutralisation, measured where the galaxy model says there is no
|
||||
# galaxy rather than outside an arbitrary ellipse.
|
||||
mask = C.galaxy_mask(shape)
|
||||
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, 1)
|
||||
|
||||
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 * 1.4, 0, 1)
|
||||
del chroma, rgb_lum
|
||||
|
||||
detail = lum - gaussian_filter(lum, 2.0)
|
||||
protect = np.clip((lum - 0.10) * 4.0, 0.0, 1.0)
|
||||
lum = np.clip(lum + 0.35 * detail * protect, 0, 1)
|
||||
|
||||
# Denoise the sky only. The cluster survey found 289 cluster candidates
|
||||
# that look like faint stars, so compact sources are excluded from the
|
||||
# smoothing along with the galaxy itself.
|
||||
z = np.load(layout.path("_gaia_deep.npz"))
|
||||
gx, gy = wcs_full.world_to_pixel(SkyCoord(z["ra"] * u.deg,
|
||||
z["dec"] * u.deg))
|
||||
gx, gy = gx - CROP, gy - CROP
|
||||
point = np.zeros(shape, np.float32)
|
||||
R = 14
|
||||
yy, xx = np.mgrid[-R:R + 1, -R:R + 1]
|
||||
rr = np.hypot(xx, yy)
|
||||
for x, y, g in zip(gx, gy, z["g"]):
|
||||
if not (R < x < nx - R and R < y < ny - R):
|
||||
continue
|
||||
r = float(np.clip(16.0 - 0.7 * (g - 8.0), 4, R - 1))
|
||||
cx, cy = int(x), int(y)
|
||||
patch = (rr <= r).astype(np.float32)
|
||||
sl = (slice(cy - R, cy + R + 1), slice(cx - R, cx + R + 1))
|
||||
np.maximum(point[sl], patch, out=point[sl])
|
||||
keep_sharp = np.clip(protect + gaussian_filter(point, 2.0), 0, 1)
|
||||
smooth = denoise_tv_chambolle(lum, weight=0.012)
|
||||
lum = np.clip(lum * keep_sharp + smooth * (1 - keep_sharp), 0, 1)
|
||||
print(f" sky denoise applied to {float((keep_sharp < 0.5).mean()):.1%} "
|
||||
f"of the frame; stars and clusters excluded")
|
||||
del detail, protect, smooth, point, keep_sharp
|
||||
|
||||
out = lrgb(lum, rgb)
|
||||
neutral = 0.5 * (out[:, :, 0] + out[:, :, 2])
|
||||
green = out[:, :, 1]
|
||||
out[:, :, 1] = np.where(green > neutral, green * 0.15 + neutral * 0.85,
|
||||
green)
|
||||
save(out, "NGC5128-final-3-best", "science-informed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
image1()
|
||||
print("image 2: the conventional pipeline output (compose.py)")
|
||||
os.makedirs(FINAL, exist_ok=True)
|
||||
for ext in ("png", "tif"):
|
||||
src = layout.path(f"NGC5128-LRGB.{ext}")
|
||||
dst = layout.path(f"NGC5128-final-2-processed.{ext}")
|
||||
with open(src, "rb") as a, open(dst, "wb") as b:
|
||||
b.write(a.read())
|
||||
im = Image.open(layout.path("NGC5128-final-2-processed.png"))
|
||||
im.thumbnail((2400, 2400), Image.LANCZOS)
|
||||
im.save(layout.path("NGC5128-final-2-processed-preview.jpg"), quality=93)
|
||||
print(" wrote NGC5128-final-2-processed.png / .tif / -preview.jpg")
|
||||
image3()
|
||||
Loading…
Add table
Add a link
Reference in a new issue