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.
153 lines
6.7 KiB
Python
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()
|