astrophotography/session-scripts/sb_prep.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

132 lines
6.1 KiB
Python

"""Step 1: masks and basic calibration checks for the NGC 5128 analysis.
Produces
sb-mask-stars.fits foreground stars / saturated cores / compact objects
sb-mask-dust.fits the dust lane, defined from the B-R colour excess
sb-colour-excess.npy E(B-R) instrumental colour excess map (float32)
The dust mask is built from COLOUR, not from a model residual: the lane is the
only thing in the frame that is strongly red relative to the smooth stellar
body, so a colour cut is far more specific than a brightness-residual cut and
does not eat the galaxy itself.
"""
import numpy as np, sep
from astropy.io import fits
from scipy import ndimage
from sb_common import *
# ------------------------------------------------------------------ saturation
d = load('Luminance')
H, W = d.shape
print('-- saturation census (luminance master) --')
for thr in [30000, 50000, 60000, 62000, 63000, 64000, 65000, 66000]:
print(' > %6d ADU : %6d px' % (thr, (d > thr).sum()))
SAT = 63000.0
core = ndimage.median_filter(d[int(Y0)-400:int(Y0)+400, int(X0)-400:int(X0)+400], 25)
print(' star-free (median-25) peak of galaxy light, inner 800 px: %.1f ADU' % core.max())
print(' => the galaxy core is a factor %.0f below the clip level: NOT saturated'
% (SAT/core.max()))
del core
bkg = sep.Background(d, bw=128, bh=128)
rms = float(bkg.globalrms)
print('\nglobal sky rms %.3f ADU/px -> 1-sigma = %.2f mag/arcsec^2' % (rms, mu(rms)))
np.save(path('_rms.npy'), np.array([rms]))
# ------------------------------------------------------------------ star mask
mask = np.zeros((H, W), bool)
z = np.load(path('_gaia_deep.npz'))
gx, gy = wcs().world_to_pixel_values(z['ra'], z['dec']); gg = z['g']
rad = np.clip(4.0 + 3.6*(16.5 - gg), 4, 120)
sel = (gx > -150) & (gx < W+150) & (gy > -150) & (gy < H+150) & (gg < 19.0)
gx, gy, rad = gx[sel], gy[sel], rad[sel]
print('\nGaia stars masked: %d (radii %.0f-%.0f px)' % (gx.size, rad.min(), rad.max()))
for x, y, r in zip(gx, gy, rad):
i0, i1 = max(0, int(y-r)), min(H, int(y+r)+1)
j0, j1 = max(0, int(x-r)), min(W, int(x+r)+1)
if i1 <= i0 or j1 <= j0: continue
sy = np.arange(i0, i1)[:, None] - y; sx = np.arange(j0, j1)[None, :] - x
mask[i0:i1, j0:j1] |= (sx*sx + sy*sy) < r*r
rr = np.hypot(np.arange(W)[None, :]-X0, np.arange(H)[:, None]-Y0)
# compact non-Gaia objects, but leave the crowded inner 150 px to the
# isophote fitter's own sigma clipping (masking there kills the fit)
sub = d - bkg.back()
obj, seg = sep.extract(sub, 8.0, err=rms, minarea=6, deblend_cont=0.005,
segmentation_map=True)
compact = obj['npix'] < 20000
segmask = ndimage.binary_dilation(np.isin(seg, np.nonzero(compact)[0]+1), np.ones((5, 5)))
mask |= segmask & (rr > 150)
print('sep compact objects: %d (applied outside r=150 px)' % compact.sum())
del sub, seg, segmask
sat = ndimage.binary_dilation(d > SAT, np.ones((5, 5)), iterations=6)
mask |= sat
print('saturated-core mask (grown 12 px): %d px' % sat.sum())
del sat, d
print('star mask: %.2f%% of frame' % (100*mask.mean()))
for a, b in [(0,25),(25,50),(50,100),(100,200),(200,400),(400,800),(800,1600)]:
s = (rr >= a) & (rr < b)
print(' r %4d-%4d px: %.1f%%' % (a, b, 100*mask[s].mean()))
fits.PrimaryHDU(mask.astype(np.uint8)).writeto(path('sb-mask-stars.fits'), overwrite=True)
# ------------------------------------------------------------------ dust mask
# instrumental B-R from smoothed R and B masters
R = ndimage.gaussian_filter(load('Red'), 3.0)
B = ndimage.gaussian_filter(load('Blue'), 3.0)
good = (R > 15) & (B > 4)
col = np.full((H, W), np.nan, np.float32)
col[good] = -2.5*np.log10(B[good]/R[good])
del R, B
# Unobscured baseline colour vs elliptical radius. Dust only ever reddens, so
# the blue tail of the colour distribution in each annulus is the dust-free
# stellar colour. The 15th percentile is measured over 150 < a < 900 px (where
# both the colour SNR is high and the lane does not fill the annulus) and fitted
# with a quadratic in log a, which is then extrapolated inwards and outwards.
a_map = ell_radius((H, W), X0, Y0, 0.17, np.radians(150.0))
bins = np.geomspace(5, 2200, 60)
ib = np.digitize(a_map, bins)
base_r, base_v = [], []
valid = good & ~mask
for k in range(1, len(bins)):
s = (ib == k) & valid
if s.sum() < 400: continue
base_r.append(0.5*(bins[k-1]+bins[k])); base_v.append(np.nanpercentile(col[s], 15))
base_r, base_v = np.array(base_r), np.array(base_v)
fitr = (base_r > 150) & (base_r < 900)
pcoef = np.polyfit(np.log10(base_r[fitr]), base_v[fitr], 2)
print()
print('colour baseline: quadratic in log10(a), coeffs', np.round(pcoef, 4))
print(' baseline B-R at a = 20/50/150/400/900 px:',
np.round(np.polyval(pcoef, np.log10([20, 50, 150, 400, 900])), 3))
base = np.polyval(pcoef, np.log10(np.clip(a_map, 5, 3000))).astype(np.float32)
exc = (col - base).astype(np.float32) # E(B-R), instrumental
np.save(path('sb-colour-excess.npy'), exc)
np.save(path('_colbase.npy'), np.c_[base_r, base_v])
np.save(path('_colbasefit.npy'), pcoef)
# Hysteresis threshold: seed on a firm colour excess, grow into the fainter
# wings of the same connected structure. A flat low threshold alone picks up a
# spurious ring at the edge of the colour-SNR region, so it is not used.
e0 = np.nan_to_num(exc, nan=-9.0)
seed = (e0 > 0.30) & (a_map < 650)
grow = (e0 > 0.20) & (a_map < 650)
seed = ndimage.binary_opening(seed, np.ones((5, 5)))
lab, n = ndimage.label(ndimage.binary_closing(grow, np.ones((7, 7))))
keep = np.unique(lab[seed & (lab > 0)])
dust = np.isin(lab, keep[keep > 0])
dust = ndimage.binary_closing(dust, np.ones((15, 15)))
lab, n = ndimage.label(dust); sz = np.bincount(lab.ravel()); sz[0] = 0
dust = np.isin(lab, np.nonzero(sz > 3000)[0])
print('dust mask: %d px = %.1f arcmin^2 (%.2f%% of frame)'
% (dust.sum(), dust.sum()*PIXAREA/3600., 100*dust.mean()))
for a, b in [(0,25),(25,50),(50,100),(100,200),(200,400),(400,800)]:
s = (a_map >= a) & (a_map < b)
print(' dust a %4d-%4d px: %5.1f%% star+dust %5.1f%%'
% (a, b, 100*dust[s].mean(), 100*(dust | mask)[s].mean()))
fits.PrimaryHDU(dust.astype(np.uint8)).writeto(path('sb-mask-dust.fits'), overwrite=True)
print()
print('wrote sb-mask-stars.fits, sb-mask-dust.fits, sb-colour-excess.npy')