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:
parent
653ca103cd
commit
c6299f41ab
53 changed files with 0 additions and 0 deletions
|
|
@ -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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue