The scripts that processed the NGC 5128 session of 2026-07-21 previously lived inside the data directory and addressed it with absolute paths. Code and data are now separated: the code lives here, and a session is located at runtime through the ASTRO_SESSION environment variable. layout.py is what makes that work. It maps a FILENAME to the subdirectory that file belongs in, using the same rules the session directories are organised with, so a script can go on asking for 'master-Red.fit' or '_stars.npz' without any call site knowing the directory structure. Anything unrecognised resolves to the session root, which is visible and correctable rather than silently wrong. restructure.py reorganises a flat session directory into that layout. It is idempotent and dry-run by default. The 50 session scripts are kept as they were run rather than tidied into a library. They were written in sequence as the work went along, several of them by parallel agents, and they show it - but they are the honest provenance of a published set of results, and the productionised pipeline should be able to reproduce those results exactly. Verified before committing: all 51 files compile without warnings, and verify_core.py, closeup.py and triptych.py were run end to end against the reorganised session, correctly finding inputs across calibrated/, stacks/masters/ and final/ and writing outputs back to the right places.
137 lines
5.1 KiB
Python
137 lines
5.1 KiB
Python
"""Moving-object search, pass 1: per-sub source detection and frame registration.
|
|
|
|
Every calibrated sub is opened one at a time (they are 61 MB each and there is
|
|
only a few GB of RAM), background-subtracted with sep, and its sources
|
|
extracted. The detection list, not the pixels, is what gets carried forward.
|
|
|
|
Registration is done on the coordinates rather than the images. astroalign
|
|
matches asterisms between each sub's star list and the reference sub's star
|
|
list and returns a similarity transform; applying that transform to the
|
|
detection coordinates puts every sub's sources into one common pixel grid
|
|
without ever warping a 15 Mpx array. The reference is Luminance_002, the same
|
|
frame the master stack was registered to, so the master's WCS applies directly
|
|
to the common grid and every detection can be turned into RA/Dec.
|
|
|
|
Output: _mo_dets.npz with one record array per frame plus the transform
|
|
parameters and mid-exposure times.
|
|
"""
|
|
import os
|
|
import sys
|
|
|
|
import astroalign as aa
|
|
import numpy as np
|
|
import sep
|
|
from astropy.io import fits
|
|
|
|
import layout
|
|
|
|
SRC = layout.SESSION
|
|
OUT = layout.SESSION
|
|
CACHE = layout.path("_mo_dets.npz")
|
|
REFERENCE = "Luminance_002"
|
|
|
|
aa.MIN_MATCHES_FRACTION = 0.6
|
|
aa.NUM_NEAREST_NEIGHBORS = 8
|
|
|
|
# Detection threshold in sigma. Deliberately low: a moving object is only in
|
|
# any one sub for 300 s, so it is much fainter per-frame than in the 60 min
|
|
# master. Spurious detections are cheap here because the tracklet linker
|
|
# demands a straight line through five or more frames, which noise does not
|
|
# supply.
|
|
THRESH = 3.5
|
|
MINAREA = 5
|
|
|
|
|
|
def frames():
|
|
"""(key, filename, filter, mid-exposure MJD) for every calibrated sub."""
|
|
rows = []
|
|
counts = {}
|
|
for fn in sorted(os.listdir(SRC)):
|
|
if not (fn.startswith("calibrated-") and fn.endswith(".fit")):
|
|
continue
|
|
filt = fn.split("-")[6]
|
|
counts[filt] = counts.get(filt, 0) + 1
|
|
rows.append((f"{filt}_{counts[filt]:03d}", fn, filt))
|
|
return rows
|
|
|
|
|
|
def detect(image, thresh=THRESH, minarea=MINAREA):
|
|
bkg = sep.Background(image, bw=64, bh=64, fw=3, fh=3)
|
|
sub = image - bkg.back()
|
|
rms = bkg.globalrms
|
|
objs = sep.extract(sub, thresh, err=rms, minarea=minarea,
|
|
deblend_cont=0.005, filter_kernel=None)
|
|
return objs, sub, rms
|
|
|
|
|
|
def main():
|
|
rows = frames()
|
|
print(f"{len(rows)} calibrated subs")
|
|
|
|
# Reference star list first: everything else is matched onto it.
|
|
ref_fn = [r[1] for r in rows if r[0] == REFERENCE][0]
|
|
with fits.open(layout.path(ref_fn), memmap=False) as hd:
|
|
img = hd[0].data.astype(np.float32)
|
|
objs, _, rms = detect(img)
|
|
bright = objs[(objs["flag"] == 0) & (objs["npix"] > 12) &
|
|
(objs["npix"] < 3000)]
|
|
bright = bright[np.argsort(bright["flux"])[::-1][:600]]
|
|
ref_xy = np.column_stack([bright["x"], bright["y"]])
|
|
del img
|
|
print(f"reference {REFERENCE}: {len(ref_xy)} registration stars, "
|
|
f"rms {rms:.2f}")
|
|
|
|
store = {}
|
|
meta = []
|
|
for key, fn, filt in rows:
|
|
path = layout.path(fn)
|
|
with fits.open(path, memmap=False) as hd:
|
|
img = hd[0].data.astype(np.float32)
|
|
mjd = float(hd[0].header["JD"]) - 2400000.5
|
|
mjd_mid = mjd + 150.0 / 86400.0 # mid-exposure
|
|
objs, _, rms = detect(img)
|
|
del img
|
|
|
|
clean = objs[(objs["flag"] == 0) & (objs["npix"] > 12) &
|
|
(objs["npix"] < 3000)]
|
|
clean = clean[np.argsort(clean["flux"])[::-1][:600]]
|
|
xy = np.column_stack([clean["x"], clean["y"]])
|
|
|
|
if key == REFERENCE:
|
|
params = (1.0, 0.0, 0.0, 0.0)
|
|
nmatch = len(xy)
|
|
tx, ty = objs["x"].copy(), objs["y"].copy()
|
|
else:
|
|
try:
|
|
tform, (src_m, _) = aa.find_transform(xy, ref_xy)
|
|
except Exception as exc: # noqa: BLE001
|
|
print(f" {key:16s} REGISTRATION FAILED ({exc}) - dropped")
|
|
continue
|
|
nmatch = len(src_m)
|
|
pts = np.column_stack([objs["x"], objs["y"]])
|
|
warped = tform(pts)
|
|
tx, ty = warped[:, 0], warped[:, 1]
|
|
params = (tform.scale, np.degrees(tform.rotation),
|
|
tform.translation[0], tform.translation[1])
|
|
|
|
# Everything the tracklet linker and the vetting need, per detection.
|
|
rec = np.column_stack([
|
|
tx, ty, # reference-frame x, y
|
|
objs["x"], objs["y"], # native x, y
|
|
objs["flux"], objs["peak"],
|
|
objs["a"], objs["b"], objs["theta"],
|
|
objs["npix"].astype(float), objs["flag"].astype(float),
|
|
]).astype(np.float32)
|
|
store[key] = rec
|
|
meta.append((key, fn, filt, f"{mjd_mid:.8f}", f"{rms:.4f}",
|
|
str(len(rec)), str(nmatch),
|
|
*[f"{p:.6f}" for p in params]))
|
|
print(f" {key:16s} {len(rec):5d} det match={nmatch:3d} "
|
|
f"rms={rms:6.2f} rot={params[1]:+7.3f} deg")
|
|
|
|
np.savez_compressed(CACHE, meta=np.array(meta, dtype=object), **store)
|
|
print(f"\nwrote {CACHE}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|