Move the processing code under pipeline/

Preparing to merge this repository into a combined astrophotography
repo. session-scripts/ becomes pipeline/ because the scripts import
layout.py from their own directory and must stay together, and because
'pipeline' says what it is rather than how it came about. observing/
stays at the top level: observing plans are not processing code.
This commit is contained in:
laurence 2026-07-21 17:13:54 +01:00
parent 653ca103cd
commit c6299f41ab
53 changed files with 0 additions and 0 deletions

91
pipeline/starless.py Normal file
View file

@ -0,0 +1,91 @@
"""Split the finished image into a starless view and a star-only layer.
The first attempt used a greyscale morphological opening, the classical trick,
and it failed in a way worth recording: an opening replaces each pixel with a
local minimum, so on a noisy sky it does not merely delete the star, it digs a
hole slightly BELOW sky level and leaves a black dot behind. It also left the
brightest stars standing, because their wings are wider than any structuring
element small enough to spare the galaxy.
What works instead is catalogue-and-fill. The stars to remove are taken from
Gaia DR3 rather than from a blind detection: Gaia is essentially complete for
stars down to G = 20, which is fainter than anything visible here, so the
catalogue identifies the foreground almost perfectly. That matters because a
blind detector cannot tell a Milky Way star from one of Centaurus A's own
globular clusters or from the blue knots in its dust lane, and removing those
guts the very structure the image is about. Each Gaia star is painted into a
mask whose radius follows its magnitude, and the masked pixels are
replaced by a normalised convolution - a Gaussian blur of the unmasked pixels
divided by the same blur of the mask itself, which interpolates across each
star using only real neighbouring pixels. That follows the galaxy's gradient
instead of flattening it, and leaves no holes.
The star layer is then simply what was removed. Two honest limitations: the
extended halos and diffraction spikes of the very brightest stars reach beyond
any sensible mask radius and leave soft residual glows behind, and anything
Gaia does not list (background galaxies, the cluster system) stays in the
starless frame by design. This is a presentation tool, not a measurement.
"""
import os
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
from scipy.ndimage import gaussian_filter
import layout
OUT = layout.SESSION
SOURCE = "NGC5128-img-deconvolved.png"
FILL_SIGMA = 9.0 # interpolation scale for the normalised convolution
rgb = np.asarray(Image.open(layout.path(SOURCE))).astype(np.float32) / 255
ny, nx = rgb.shape[:2]
print(f"{SOURCE}: {nx} x {ny}")
z = np.load(layout.path("_gaia_deep.npz"))
with fits.open(layout.path("NGC5128-LRGB.fit")) as hd:
wcs = WCS(hd[0].header, naxis=2)
gx, gy = wcs.world_to_pixel(SkyCoord(z["ra"] * u.deg, z["dec"] * u.deg))
gmag = z["g"]
on_frame = (gx > -30) & (gx < nx + 30) & (gy > -30) & (gy < ny + 30)
gx, gy, gmag = gx[on_frame], gy[on_frame], gmag[on_frame]
print(f"{len(gx)} Gaia stars on the frame, G {gmag.min():.1f}-{gmag.max():.1f}")
mask = np.zeros((ny, nx), np.float32)
R = 30
yy, xx = np.mgrid[-R:R + 1, -R:R + 1]
rr = np.hypot(xx, yy)
for x, y, g in zip(gx, gy, gmag):
# Radius from magnitude: a bright star's visible disc and wings are far
# wider than a faint one's, and a fixed radius would either miss the
# bright ones or scour the field around the faint ones.
r = float(np.clip(30.0 - 1.9 * (g - 8.0), 4, R - 2))
cx, cy = int(round(x)), int(round(y))
x0, x1 = max(0, cx - R), min(nx, cx + R + 1)
y0, y1 = max(0, cy - R), min(ny, cy + R + 1)
patch = (rr <= r).astype(np.float32)[(y0 - cy + R):(y1 - cy + R),
(x0 - cx + R):(x1 - cx + R)]
np.maximum(mask[y0:y1, x0:x1], patch, out=mask[y0:y1, x0:x1])
mask = np.clip(gaussian_filter(mask, 1.5), 0, 1)
print(f"mask covers {mask.mean():.2%} of the frame")
starless = np.empty_like(rgb)
keep = 1.0 - mask
denom = gaussian_filter(keep, FILL_SIGMA)
denom[denom < 1e-3] = 1e-3
for i in range(3):
filled = gaussian_filter(rgb[:, :, i] * keep, FILL_SIGMA) / denom
starless[:, :, i] = rgb[:, :, i] * keep + filled * mask
stars = np.clip(rgb - starless, 0.0, 1.0)
Image.fromarray((np.clip(starless, 0, 1) * 255 + 0.5).astype(np.uint8)).save(
layout.path("NGC5128-img-starless.png"))
# The star layer is faint on its own; a square-root stretch makes it legible
# without adding or removing anything.
Image.fromarray((np.sqrt(stars) * 255 + 0.5).astype(np.uint8)).save(
layout.path("NGC5128-img-stars.png"))
print("wrote NGC5128-img-starless.png and NGC5128-img-stars.png")