astrophotography/pipeline/measure.py
laurence 3c4ae5c77e Solve by pointing where the header allows it, and refuse doubtful solutions
Three fixes and one refusal, all found by working on the wide-field
narrowband session that would not solve.

The detection floor was a fixed 12 pixels, which quietly assumed a well
sampled star. At 0.53 arcsec/px with 5.9 px seeing that is right; at 3.5
arcsec/px the stars are undersampled at 1.75 px FWHM and cover a handful
of pixels each, so the floor discarded nearly every real star and kept
blends and galaxies instead. It now scales with the measured seeing, and
that session went from 305 usable detections to 600.

Matching now happens over the frame's inscribed circle rather than a cone
reaching its corners. On a 4 degree field the old cone covered 33 square
degrees of sky against 16 of image, so half the catalogue was not in the
picture at all.

Where the header records a roll angle - and iTelescope's does - the
orientation no longer has to be recovered from scratch. Rotation, scale
and parity are applied directly and only the residual pointing error is
searched, by histogramming every detection-to-catalogue offset and taking
the peak. Asterism matching remains as the fallback for headers that say
nothing about orientation.

The refusal matters most. With catalogue depth slices added, the wide
field produced a "solution" of 25 stars at 2.50 px residual, claiming a
centre half a degree from the pointing. A genuine solve on that field
matches hundreds of stars and refines to well under a pixel. Accepting it
would have silently corrupted every position in the science catalogue, so
a solution must now reach 40 stars AND 1.5 px or it is discarded and the
session is reported as unsolved. A wrong WCS is worse than no WCS.

NGC 2030 improves from 0.31 to 0.12 arcsec on 123 stars under the
pointing-based match. NGC 2070 is honestly unsolved.
2026-07-21 22:41:36 +01:00

140 lines
5.5 KiB
Python

"""Stage 2: measure every frame and cache its star list.
Two jobs. It produces the quality numbers used to choose a registration
reference and to weight the stack - sky level, noise, star sharpness - and it
caches each frame's detected stars, because both registration and the plate
solve need them and detection costs far more than reading a small array back.
Frames are opened one at a time. A 4096x4096 float32 frame is 67 MB and a
session can hold ninety of them; there is no reason to have more than one in
memory at once.
"""
import os
import numpy as np
import sep
from astropy.io import fits
import layout
MAX_STARS = 500 # cached per frame, brightest first
DETECT_SIGMA = 5.0
def measure_frame(path, max_stars=MAX_STARS):
"""Sky, noise, seeing and a star list for one frame."""
with fits.open(path, memmap=False) as hd:
data = hd[0].data.astype(np.float32)
bkg = sep.Background(data, bw=64, bh=64, fw=3, fh=3)
sky = float(np.median(bkg.back()))
rms = float(bkg.globalrms)
sub = data - bkg.back()
objs = sep.extract(sub, DETECT_SIGMA, err=rms, minarea=5,
deblend_cont=0.005)
# Saturated cores and cosmic-ray hits make poor registration anchors and
# poor seeing estimates, so they go first.
objs = objs[(objs["flag"] == 0) & (objs["npix"] < 3000)]
# The minimum size has to follow the SAMPLING, not be a fixed number of
# pixels. A fixed floor of 12 px assumes a well sampled star: at 0.53
# arcsec/px with 5.9 px seeing that is right, but at 3.5 arcsec/px the
# stars are undersampled at 1.75 px FWHM and cover only a handful of
# pixels each, so the same floor discards nearly every real star and keeps
# blends and galaxies instead. That is what made the wide-field sessions
# impossible to plate solve: the surviving detections were not the objects
# the catalogue lists.
if len(objs) > 10:
rad0, _ = sep.flux_radius(sub, objs["x"], objs["y"], 6.0 * objs["a"],
0.5, normflux=objs["flux"])
fwhm0 = float(np.median(rad0) * 2.0)
min_npix = max(4, int(0.5 * np.pi * (fwhm0 / 2.0) ** 2))
objs = objs[objs["npix"] >= min_npix]
if len(objs) == 0:
del data, sub, bkg
return dict(sky=sky, rms=rms, fwhm=np.nan, nstars=0,
xy=np.zeros((0, 2)), flux=np.zeros(0))
order = np.argsort(objs["flux"])[::-1][:max_stars]
objs = objs[order]
radius, _ = sep.flux_radius(sub, objs["x"], objs["y"], 6.0 * objs["a"],
0.5, normflux=objs["flux"])
fwhm = float(np.median(radius) * 2.0)
result = dict(
sky=sky, rms=rms, fwhm=fwhm, nstars=int(len(objs)),
xy=np.column_stack([objs["x"], objs["y"]]).astype(np.float64),
flux=objs["flux"].astype(np.float64),
)
del data, sub, bkg, objs
return result
def run(session, force=False, verbose=True):
"""Measure every frame in a session, caching to intermediates/."""
cache_path = os.path.join(session.root, layout.INTERMEDIATES,
"_measure.npz")
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
cached = {}
if os.path.exists(cache_path) and not force:
z = np.load(cache_path, allow_pickle=True)
cached = {k: z[k] for k in z.files}
store, rows = {}, []
for frame in session.frames:
key = frame.name
if f"{key}|xy" in cached and not force:
xy = cached[f"{key}|xy"]
stats = cached[f"{key}|stats"].item()
else:
stats = measure_frame(frame.path)
xy = stats.pop("xy")
flux = stats.pop("flux")
store[f"{key}|flux"] = flux
store[f"{key}|xy"] = xy
store[f"{key}|stats"] = np.array(stats, dtype=object)
rows.append((frame, stats))
if verbose:
print(f" {frame.filter:10s} {frame.name[-28:]:28s} "
f"sky={stats['sky']:8.1f} rms={stats['rms']:7.2f} "
f"fwhm={stats['fwhm']:5.2f}px stars={stats['nstars']:4d}")
# Carry forward anything already cached that was not re-measured.
for k, v in cached.items():
store.setdefault(k, v)
np.savez_compressed(cache_path, **store)
return rows
def load(session):
"""Star lists and stats for a session, as measured earlier."""
path = os.path.join(session.root, layout.INTERMEDIATES, "_measure.npz")
if not os.path.exists(path):
raise FileNotFoundError(
f"{path} not found - run the measure stage first")
z = np.load(path, allow_pickle=True)
xy = {k.split("|")[0]: z[k] for k in z.files if k.endswith("|xy")}
stats = {k.split("|")[0]: z[k].item() for k in z.files
if k.endswith("|stats")}
return xy, stats
def choose_reference(session, stats):
"""Pick the frame everything else is aligned to.
The reference sets the output grid, so it should be the sharpest frame of
the filter with the most signal - which is also usually the filter with the
finest sampling. Choosing a poor reference costs resolution in every other
frame, permanently, because registration can only resample onto it.
"""
best_filter = session.filters[0]
candidates = [f for f in session.by_filter(best_filter)
if stats.get(f.name, {}).get("nstars", 0) >= 8]
if not candidates:
candidates = session.by_filter(best_filter)
ref = min(candidates,
key=lambda f: stats.get(f.name, {}).get("fwhm", np.inf))
return ref