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

153 lines
6.7 KiB
Python

"""The plainest possible stack: align the frames, average them, stop.
This exists as a baseline to compare every processed version against. The
only operation applied to the pixels is the geometric one needed to make the
frames line up. In particular there is NO:
- sky/background subtraction - gradient or plane removal
- per-frame flux normalisation - outlier or sigma rejection
- weighting by noise - colour calibration or white balance
- stretch, saturation or denoise - deconvolution or sharpening
so cosmic rays, satellite trails, the moon gradient and every frame's own sky
level all survive into the result, exactly as they were recorded. That is the
point: it is the honest sum of the data.
Two things it is NOT innocent of, and cannot be:
1. The frames arrive from iTelescope already bias/dark/flat calibrated
(CALSTAT = 'BDF'). That cannot be undone here.
2. Alignment resamples. A bicubic warp interpolates, which very slightly
smooths and correlates neighbouring pixels. The reference frame itself is
not resampled at all, so it is the one frame that stays pristine.
Output is linear 32-bit FITS, which is what a baseline should be. A linear
image displays as almost pure black, so a display-only stretched preview is
written alongside and clearly labelled as such - the numbers live in the FITS.
"""
import os
import astroalign as aa
import numpy as np
from astropy.io import fits
from PIL import Image
from skimage.transform import warp
import layout
SRC = layout.SESSION
STACKED = layout.SESSION
OUT = layout.path("original")
CACHE = layout.path("_stars.npz")
# The same reference frame the processed stack used, so the two are pixel
# aligned and can be compared or differenced directly.
REFERENCE = "Luminance_002"
FILTERS = ["Luminance", "Red", "Green", "Blue"]
def main():
os.makedirs(OUT, exist_ok=True)
z = np.load(CACHE, allow_pickle=True)
meta = {row[0]: row for row in z["meta"]}
stars = {k: z[k + "_xy"] for k in meta}
ref_xy = stars[REFERENCE]
print(f"reference {REFERENCE}, no crop, no rejection, no normalisation")
# The plate solution was fitted on this same pixel grid, so it can be
# carried over. Copying header keywords does not touch the pixels.
with fits.open(layout.path("master-Luminance.fit")) as hd:
solved = hd[0].header
wcs_keys = [k for k in ("WCSAXES", "CRPIX1", "CRPIX2", "CDELT1", "CDELT2",
"CUNIT1", "CUNIT2", "CTYPE1", "CTYPE2", "CRVAL1",
"CRVAL2", "LONPOLE", "LATPOLE", "MJDREF",
"RADESYS", "PC1_1", "PC1_2", "PC2_1", "PC2_2")
if k in solved]
planes = {}
for filt in FILTERS:
keys = sorted(k for k in meta if meta[k][2] == filt)
total = None
count = None
hdr0 = None
for key in keys:
fname = meta[key][1]
with fits.open(layout.path(fname), memmap=False) as hd:
data = hd[0].data.astype(np.float32)
if hdr0 is None:
hdr0 = hd[0].header.copy()
if key == REFERENCE:
reg = data # reference is never resampled
else:
tform, _ = aa.find_transform(stars[key], ref_xy)
reg = warp(data, inverse_map=tform.inverse, order=3,
mode="constant", cval=np.nan,
preserve_range=True).astype(np.float32)
del data
valid = np.isfinite(reg)
if total is None:
total = np.where(valid, reg, 0.0).astype(np.float32)
count = valid.astype(np.float32)
else:
total += np.where(valid, reg, 0.0)
count += valid
print(f" {key:16s} added (mean level {np.nanmean(reg):8.2f} ADU)")
del reg, valid
# Straight arithmetic mean. Where a frame did not cover a pixel it
# simply does not contribute, which is bookkeeping rather than
# processing: no pixel is invented.
stack = total / np.maximum(count, 1)
stack[count == 0] = 0.0
del total, count
hdr = hdr0
hdr["FILTER"] = filt
hdr["NCOMBINE"] = (len(keys), "frames averaged")
hdr["EXPTOTAL"] = (300.0 * len(keys), "[s] total integration")
hdr["STACKREF"] = (REFERENCE, "alignment reference frame")
hdr["STACKALG"] = ("plain mean, no rejection", "combine method")
hdr["PROCLVL"] = ("align+average only", "no other processing applied")
for k in wcs_keys:
hdr[k] = (solved[k], solved.comments[k])
path = layout.path(f"original-{filt}.fit")
fits.PrimaryHDU(stack.astype(np.float32), hdr).writeto(path,
overwrite=True)
print(f" -> {path} min={stack.min():.1f} median={np.median(stack):.1f} "
f"max={stack.max():.1f} ADU")
planes[filt] = stack
# A colour version assembled with no calibration at all: the three filters
# dropped straight into R, G and B on a shared linear scale. Centaurus A
# will look yellow-green, because that is what the raw filter throughputs
# and a 46% moon actually produced.
rgb = np.dstack([planes["Red"], planes["Green"], planes["Blue"]])
hdr = fits.getheader(layout.path("original-Red.fit"))
hdr["PROCLVL"] = ("align+average only", "no colour calibration applied")
fits.PrimaryHDU(np.moveaxis(rgb, 2, 0).astype(np.float32), hdr).writeto(
layout.path("original-RGB.fit"), overwrite=True)
print("wrote original-RGB.fit (uncalibrated colour cube)")
# Display-only previews. The stretch here is a viewing aid and is NOT
# baked into any of the FITS above.
def preview(arr, name, note):
lo = np.percentile(arr, 25)
hi = np.percentile(arr, 99.9)
s = np.clip((arr - lo) / max(hi - lo, 1e-6), 0, 1) ** 0.35
im = Image.fromarray((s * 255 + 0.5).astype(np.uint8))
im.thumbnail((2400, 2400), Image.LANCZOS)
im.save(layout.path(name), quality=92)
print(f"wrote {name} ({note})")
preview(planes["Luminance"], "NGC5128-original-Luminance-preview.jpg",
"display stretch only, linear data is in the FITS")
lo = np.percentile(rgb, 25)
hi = np.percentile(rgb, 99.9)
s = np.clip((rgb - lo) / max(hi - lo, 1e-6), 0, 1) ** 0.35
im = Image.fromarray((s * 255 + 0.5).astype(np.uint8))
im.thumbnail((2400, 2400), Image.LANCZOS)
im.save(layout.path("NGC5128-original-RGB-preview.jpg"), quality=92)
print("wrote NGC5128-original-RGB-preview.jpg (display stretch only, no colour "
"calibration)")
if __name__ == "__main__":
main()