"""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()