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

View file

@ -1,153 +0,0 @@
"""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()