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,166 +0,0 @@
|
|||
"""Pass 2: register every frame to one reference and combine per filter.
|
||||
|
||||
One reference frame is used for ALL four filters, not one per filter, so the
|
||||
four masters come out pixel-aligned and the colour composite needs no further
|
||||
registration.
|
||||
|
||||
Registration works on the cached star lists rather than the pixels: astroalign
|
||||
matches asterisms between the two point sets and returns a similarity transform,
|
||||
which is then applied to the image with a bicubic warp. Matching a few hundred
|
||||
coordinates is far cheaper than cross-correlating 15 Mpx frames, and it copes
|
||||
with the field rotation between the east and west sides of the meridian.
|
||||
|
||||
Before combining, each frame is sky-subtracted and then rescaled so that its
|
||||
bright-signal level (the 99.5th percentile, which on this field is set by stars
|
||||
and the galaxy core rather than by sky) matches the group median. That corrects
|
||||
for transparency changes without needing per-star photometry. A
|
||||
sigma-clipped mean then rejects cosmic rays and satellite trails; with only four
|
||||
frames per colour the clip is deliberately gentle (3 sigma, one iteration) so it
|
||||
does not start eating real signal.
|
||||
"""
|
||||
import os
|
||||
|
||||
import astroalign as aa
|
||||
import numpy as np
|
||||
from astropy.io import fits
|
||||
from astropy.stats import sigma_clip
|
||||
from skimage.transform import warp
|
||||
|
||||
import layout
|
||||
|
||||
SRC = layout.SESSION
|
||||
OUT = layout.SESSION
|
||||
CACHE = layout.path("_stars.npz")
|
||||
|
||||
# Best-seeing luminance frame, chosen from the pass-1 FWHM table.
|
||||
REFERENCE = "Luminance_002"
|
||||
FILTERS = ["Luminance", "Red", "Green", "Blue"]
|
||||
|
||||
aa.MIN_MATCHES_FRACTION = 0.6
|
||||
aa.NUM_NEAREST_NEIGHBORS = 8
|
||||
|
||||
|
||||
def load_cache():
|
||||
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}
|
||||
return meta, stars, z
|
||||
|
||||
|
||||
def frame_path(fname):
|
||||
return layout.path(fname)
|
||||
|
||||
|
||||
def combine(cube, weights):
|
||||
"""Weighted sigma-clipped mean along axis 0, done row-block by row-block.
|
||||
|
||||
The full cube is already in memory; the blocking here is only to keep the
|
||||
boolean mask and the float64 intermediates that sigma_clip allocates from
|
||||
tripling peak usage on a machine with a few GB free.
|
||||
"""
|
||||
n, ny, nx = cube.shape
|
||||
out = np.zeros((ny, nx), dtype=np.float32)
|
||||
w = np.asarray(weights, dtype=np.float32)[:, None, None]
|
||||
step = 256
|
||||
for y0 in range(0, ny, step):
|
||||
y1 = min(y0 + step, ny)
|
||||
block = cube[:, y0:y1, :]
|
||||
clipped = sigma_clip(block, sigma=3.0, maxiters=1, axis=0,
|
||||
masked=True, copy=True)
|
||||
# Warped frames carry NaN outside their footprint; those pixels must
|
||||
# drop out of both the sum and the weight total, exactly like a
|
||||
# clipped outlier.
|
||||
finite = np.isfinite(block)
|
||||
good = (~clipped.mask) & finite
|
||||
vals = np.where(finite, block, 0.0)
|
||||
wb = np.broadcast_to(w, block.shape) * good
|
||||
denom = wb.sum(axis=0)
|
||||
denom[denom == 0] = np.nan
|
||||
out[y0:y1, :] = np.nansum(vals * wb, axis=0) / denom
|
||||
del block, clipped, good, finite, vals, wb, denom
|
||||
return np.nan_to_num(out, nan=0.0)
|
||||
|
||||
|
||||
def main():
|
||||
meta, stars, _ = load_cache()
|
||||
ref_xy = stars[REFERENCE]
|
||||
ref_row = meta[REFERENCE]
|
||||
print(f"reference {REFERENCE} ({ref_row[1]})")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
report = []
|
||||
for filt in FILTERS:
|
||||
keys = sorted(k for k in meta if meta[k][2] == filt)
|
||||
planes, weights, headers, used = [], [], [], []
|
||||
for key in keys:
|
||||
row = meta[key]
|
||||
fname, rms = row[1], float(row[7])
|
||||
with fits.open(frame_path(fname), memmap=False) as hd:
|
||||
data = hd[0].data.astype(np.float32)
|
||||
hdr = hd[0].header
|
||||
if key == REFERENCE:
|
||||
reg = data
|
||||
nmatch = len(ref_xy)
|
||||
else:
|
||||
try:
|
||||
tform, (src_m, tgt_m) = aa.find_transform(stars[key],
|
||||
ref_xy)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f" {key}: registration FAILED ({exc}) - dropped")
|
||||
del data
|
||||
continue
|
||||
nmatch = len(src_m)
|
||||
reg = warp(data, inverse_map=tform.inverse, order=3,
|
||||
mode="constant", cval=np.nan,
|
||||
preserve_range=True).astype(np.float32)
|
||||
del data
|
||||
# Sky subtraction uses the frame's own median, which on a field
|
||||
# this empty outside the galaxy is a fair estimate of sky level.
|
||||
sky = float(np.nanmedian(reg))
|
||||
reg -= sky
|
||||
planes.append(reg)
|
||||
weights.append(1.0 / (rms * rms))
|
||||
headers.append(hdr)
|
||||
used.append((key, nmatch, sky, rms))
|
||||
print(f" {key:16s} matched={nmatch:3d} sky={sky:8.1f} "
|
||||
f"shift-corrected")
|
||||
|
||||
cube = np.stack(planes, axis=0)
|
||||
del planes
|
||||
# Photometric scaling: normalise each frame to the stack's own median
|
||||
# signal so a frame taken through thin cloud cannot drag the mean down.
|
||||
levels = np.array([np.nanpercentile(p, 99.5) for p in cube])
|
||||
ref_level = np.nanmedian(levels)
|
||||
for i, lv in enumerate(levels):
|
||||
if lv > 0:
|
||||
cube[i] *= float(ref_level / lv)
|
||||
print(f" {filt}: photometric scale factors "
|
||||
f"{np.round(ref_level / levels, 4)}")
|
||||
|
||||
master = combine(cube, weights)
|
||||
nframes = cube.shape[0]
|
||||
del cube
|
||||
|
||||
hdr = headers[0].copy()
|
||||
for k in ("CBLACK", "CWHITE", "PEDESTAL", "HISTORY"):
|
||||
hdr.remove(k, ignore_missing=True, remove_all=True)
|
||||
hdr["FILTER"] = filt
|
||||
hdr["NCOMBINE"] = (nframes, "frames in this master")
|
||||
hdr["EXPTOTAL"] = (300.0 * nframes, "[s] total integration")
|
||||
hdr["STACKREF"] = (REFERENCE, "registration reference frame")
|
||||
hdr["STACKALG"] = ("sigma-clipped weighted mean", "combine method")
|
||||
hdr["IMAGETYP"] = "Master Light"
|
||||
out = layout.path(f"master-{filt}.fit")
|
||||
fits.PrimaryHDU(master.astype(np.float32), hdr).writeto(out,
|
||||
overwrite=True)
|
||||
print(f" -> {out} ({nframes} x 300 s = {nframes * 5:.0f} min)")
|
||||
report.append((filt, nframes, out))
|
||||
del master
|
||||
|
||||
print("\nmasters written:")
|
||||
for filt, n, path in report:
|
||||
print(f" {filt:10s} {n:2d} frames {path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue