astrophotography/pipeline/science.py
laurence 3eb5ab6a03 Produce the four deliverable images and the science outputs for every session
The four-image set worked out on Centaurus A is now generated for any
target, because its value is the comparison: the same data at four levels
of treatment, so a viewer can see what processing did and did not add.
colour.py's assembly is driven by flags - gradient, neutralise, denoise,
saturation, hdr, protect-compact - so the baseline and the fully
corrected version come from ONE code path and the only differences
between them are the ones named.

Image 3 applies what the measurements justify rather than a house style.
Each item is there because measuring the first session caught the
conventional version getting something wrong: a plane fit that had
absorbed 17.9 ADU/px of galaxy halo, a core flattened by a white point
set by field stars, deconvolution ringing around every bright star, and
denoising erasing faint compact sources that turned out to be globular
clusters. Compact sources are now explicitly protected from smoothing -
2374 of them on NGC 2030.

The close-up revealed a real design error, caught by its own assertion.
Forcing a square crop cannot contain a target wider than the frame is
tall, which is the normal case for a nebula in a wide field, and the
assertion fired rather than silently cutting the subject in half. Crops
are no longer square, and when a target genuinely fills the field the
close-up is skipped with that said plainly - re-saving image 3 under a
name claiming to be a close-up would be worse than producing nothing.

science.py adds the measurements that generalise to any target:
photometric calibration from the field's own Gaia stars, the limiting
magnitude actually reached, a source catalogue with calibrated
magnitudes, an annotated field placed by the plate solution, and a radial
surface-brightness profile. Object-specific analyses stay hand-driven,
because a cluster survey suits a galaxy and is meaningless for a nebula.

All of it depends on astrometry, so an unsolved session gets no science
and says so instead of quietly producing less. NGC 2030 calibrates to a
zero point of 24.794 with 0.202 mag scatter on 917 stars, 3470 sources,
limiting G of 18.2.
2026-07-21 22:18:35 +01:00

297 lines
12 KiB
Python

"""The science outputs: what can be measured from a session, not just seen.
Only the analyses that are meaningful for ANY target go here. A globular
cluster survey suits an elliptical galaxy and is meaningless for an emission
nebula, so that sort of thing stays hand-driven. What generalises is:
photometric calibration a real magnitude scale, from the field's own stars
depth the limiting magnitude actually achieved
source catalogue every detection, with calibrated magnitudes
annotated field what is in the frame, placed by the plate solution
radial profile surface brightness against radius, for extended
targets
All of it depends on the plate solve, so a session that failed astrometry gets
none of it - and says so rather than silently producing less.
The photometric calibration is worth doing even when nothing else is: it turns
"this pixel is bright" into "this source is magnitude 18.4", which is what makes
a result comparable with anyone else's.
"""
import os
import numpy as np
import sep
from astropy import units as u
from astropy.coordinates import SkyCoord
from astropy.io import fits
from astropy.wcs import WCS
import astrometry
import layout
def _figdir(session):
d = os.path.join(session.root, layout.FIGURES)
os.makedirs(d, exist_ok=True)
return d
def _catdir(session):
d = os.path.join(session.root, layout.CATALOGUES)
os.makedirs(d, exist_ok=True)
return d
def detect_all(image, thresh=3.0):
bkg = sep.Background(image, bw=64, bh=64, fw=3, fh=3)
sub = image - bkg.back()
objs = sep.extract(sub, thresh, err=bkg.globalrms, minarea=6,
deblend_cont=0.005)
objs = objs[(objs["flag"] == 0) & (objs["npix"] > 6)]
flux, ferr, _ = sep.sum_circle(sub, objs["x"], objs["y"], 5.0,
err=bkg.globalrms, subpix=5)
snr = flux / np.maximum(ferr, 1e-9)
keep = (flux > 0) & (snr > thresh)
return objs[keep], flux[keep], snr[keep], bkg.globalrms
def calibrate(session, verbose=True):
"""Zero point and depth from the field's own Gaia stars."""
masters = os.path.join(session.root, layout.MASTERS)
deepest = session.filters[0]
path = os.path.join(masters, f"master-{deepest}.fit")
with fits.open(path) as hd:
image = hd[0].data.astype(np.float32)
header = hd[0].header
if "CRVAL1" not in header:
if verbose:
print(" no astrometric solution - science stage skipped")
return None
wcs = WCS(header, naxis=2)
ny, nx = image.shape
objs, flux, snr, rms = detect_all(image)
if len(objs) < 30:
if verbose:
print(f" only {len(objs)} detections - too few to calibrate")
return None
sky = wcs.pixel_to_world(objs["x"], objs["y"])
centre = wcs.pixel_to_world(nx / 2, ny / 2)
scale = float(np.sqrt(abs(np.linalg.det(wcs.pixel_scale_matrix * 3600.0))))
radius = 1.15 * 0.5 * np.hypot(nx, ny) * scale / 3600.0
cache = os.path.join(session.root, layout.INTERMEDIATES, "_gaia_deep.npz")
ra, dec, gmag = astrometry.fetch_catalogue(centre, radius, cache,
limit=20000, deep=True)
if ra is None:
return None
gcoord = SkyCoord(ra * u.deg, dec * u.deg)
idx, sep2d, _ = sky.match_to_catalog_sky(gcoord)
matched = sep2d.arcsec < 1.5
if matched.sum() < 20:
if verbose:
print(f" only {matched.sum()} catalogue matches - "
f"cannot calibrate")
return None
inst = -2.5 * np.log10(flux[matched])
gm = gmag[idx[matched]]
# Fit on well exposed but unsaturated stars only.
fit = (gm > gm.min() + 2) & (gm < np.percentile(gm, 90)) & \
(snr[matched] > 20)
if fit.sum() < 10:
fit = snr[matched] > 10
zp = float(np.median(gm[fit] - inst[fit]))
scatter = float(np.std(gm[fit] - inst[fit]))
mag = -2.5 * np.log10(flux) + zp
order = np.argsort(mag)
ms, ss = mag[order], snr[order]
win = max(11, len(ms) // 60)
run_m = np.array([np.median(ms[i:i + win])
for i in range(0, len(ms) - win, max(1, win // 2))])
run_s = np.array([np.median(ss[i:i + win])
for i in range(0, len(ss) - win, max(1, win // 2))])
below = np.where(run_s < 5.0)[0]
limit = float(run_m[below[0]]) if len(below) else float(run_m[-1])
if verbose:
print(f" zero point {zp:.3f} (scatter {scatter:.3f} mag) on "
f"{int(fit.sum())} stars; {len(objs)} sources; "
f"limiting G ~ {limit:.2f}")
# Write the catalogue.
cat = os.path.join(_catdir(session),
f"{session.target.replace(' ', '')}-sources.csv")
with open(cat, "w", encoding="utf-8") as fh:
fh.write("id,ra_deg,dec_deg,x,y,mag_G,snr,fwhm_px,in_gaia\n")
r50 = 2.0 * np.sqrt(objs["npix"] / np.pi)
for i in range(len(objs)):
fh.write(f"{i+1},{sky[i].ra.deg:.7f},{sky[i].dec.deg:.7f},"
f"{objs['x'][i]:.2f},{objs['y'][i]:.2f},{mag[i]:.3f},"
f"{snr[i]:.1f},{r50[i]:.2f},"
f"{int(matched[i])}\n")
if verbose:
print(f" -> science/catalogues/{os.path.basename(cat)}")
return dict(zero_point=zp, scatter=scatter, limiting_mag=limit,
nsources=int(len(objs)), nmatched=int(matched.sum()),
scale=scale, catalogue=cat,
image=image, wcs=wcs, mag=mag, objs=objs, matched=matched)
def depth_plot(session, cal, verbose=True):
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(7, 5))
ax.semilogy(cal["mag"], np.maximum(
np.ones_like(cal["mag"]), np.ones_like(cal["mag"])), alpha=0)
ax.clear()
ax.scatter(cal["mag"], np.clip(
(10 ** (-0.4 * (cal["mag"] - cal["zero_point"]))) * 0 + 1, 0, 1),
s=1, alpha=0)
# Simple, honest histogram of what was detected.
ax.hist(cal["mag"], bins=40, color="#4c78a8")
ax.axvline(cal["limiting_mag"], color="crimson", ls="--",
label=f"limit G = {cal['limiting_mag']:.2f} (SNR 5)")
ax.set_xlabel("calibrated magnitude (Gaia G scale)")
ax.set_ylabel("sources detected")
ax.set_title(f"{session.target} - depth reached\n"
f"zero point {cal['zero_point']:.2f}, "
f"scatter {cal['scatter']:.3f} mag, "
f"{cal['nsources']} sources")
ax.legend()
fig.tight_layout()
p = os.path.join(_figdir(session),
f"{session.target.replace(' ', '')}-depth.png")
fig.savefig(p, dpi=110)
plt.close(fig)
if verbose:
print(f" -> science/figures/{os.path.basename(p)}")
def annotate(session, cal, verbose=True):
"""The finished image with a coordinate grid and catalogued objects."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from PIL import Image
stem = session.target.replace(" ", "")
src = os.path.join(session.root, layout.FINAL, f"{stem}-3-best.png")
if not os.path.exists(src):
return
rgb = np.asarray(Image.open(src))
ny, nx = rgb.shape[:2]
scale_factor = 3
small = np.asarray(Image.fromarray(rgb).resize(
(nx // scale_factor, ny // scale_factor), Image.LANCZOS))
wcs = cal["wcs"][::scale_factor, ::scale_factor]
fig = plt.figure(figsize=(small.shape[1] / 100, small.shape[0] / 100),
dpi=100)
ax = fig.add_axes([0, 0, 1, 1])
ax.imshow(small, origin="upper")
ax.set_axis_off()
# Graticule drawn by hand rather than with WCSAxes, which insists on
# origin='lower' and would publish this mirrored against every other image.
corners = wcs.pixel_to_world([0, small.shape[1], 0, small.shape[1]],
[0, 0, small.shape[0], small.shape[0]])
ra_lo, ra_hi = corners.ra.deg.min(), corners.ra.deg.max()
dec_lo, dec_hi = corners.dec.deg.min(), corners.dec.deg.max()
for r in np.linspace(ra_lo, ra_hi, 5)[1:-1]:
t = np.linspace(dec_lo, dec_hi, 200)
px, py = wcs.world_to_pixel(SkyCoord(np.full_like(t, r) * u.deg,
t * u.deg))
ax.plot(px, py, color="#5fa8ff", alpha=0.25, ls=":", lw=0.8)
for d in np.linspace(dec_lo, dec_hi, 5)[1:-1]:
t = np.linspace(ra_lo, ra_hi, 200)
px, py = wcs.world_to_pixel(SkyCoord(t * u.deg,
np.full_like(t, d) * u.deg))
ax.plot(px, py, color="#5fa8ff", alpha=0.25, ls=":", lw=0.8)
# Scale bar, measured through the solution rather than assumed.
px_per_arcmin = 60.0 / (cal["scale"] * scale_factor)
bx, by = 40, small.shape[0] - 40
ax.plot([bx, bx + px_per_arcmin], [by, by], color="white", lw=2.5)
ax.text(bx, by - 10, "1 arcmin", color="white", fontsize=9)
ax.text(18, 24, f"{session.target} {session.telescope} "
f"{session.palette}", color="white", fontsize=11)
ax.text(18, 42, f"plate solved: {cal['nmatched']} Gaia matches, "
f"{cal['scale']:.3f} arcsec/px, "
f"limiting G ~ {cal['limiting_mag']:.1f}",
color="#9fb8d0", fontsize=8)
p = os.path.join(_figdir(session), f"{stem}-annotated.jpg")
fig.savefig(p, dpi=100, pil_kwargs={"quality": 92})
plt.close(fig)
if verbose:
print(f" -> science/figures/{os.path.basename(p)}")
def radial_profile(session, cal, verbose=True):
"""Surface brightness against radius - meaningful for extended targets."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
image = cal["image"]
ny, nx = image.shape
yy, xx = np.mgrid[0:ny, 0:nx]
r = np.hypot(xx - nx / 2.0, yy - ny / 2.0)
sky = float(np.median(image))
rmax = min(nx, ny) / 2.0
edges = np.linspace(0, rmax, 40)
prof, rad = [], []
for a, b in zip(edges[:-1], edges[1:]):
sel = (r >= a) & (r < b)
if sel.sum() < 50:
continue
prof.append(float(np.median(image[sel]) - sky))
rad.append((a + b) / 2.0 * cal["scale"] / 60.0)
prof, rad = np.array(prof), np.array(rad)
good = prof > 0
if good.sum() < 5:
if verbose:
print(" no extended signal above sky - profile skipped")
return
pixarea = cal["scale"] ** 2
mu = cal["zero_point"] - 2.5 * np.log10(prof[good] / pixarea)
fig, ax = plt.subplots(figsize=(7, 5))
ax.plot(rad[good], mu, "o-", color="#4c78a8")
ax.invert_yaxis()
ax.set_xlabel("radius from frame centre (arcmin)")
ax.set_ylabel("surface brightness (mag / arcsec$^2$)")
ax.set_title(f"{session.target} - radial surface brightness")
ax.grid(alpha=0.3)
fig.tight_layout()
p = os.path.join(_figdir(session),
f"{session.target.replace(' ', '')}-radial-profile.png")
fig.savefig(p, dpi=110)
plt.close(fig)
out = os.path.join(_catdir(session),
f"{session.target.replace(' ', '')}-radial-profile.csv")
with open(out, "w", encoding="utf-8") as fh:
fh.write("radius_arcmin,surface_brightness_mag_arcsec2\n")
for rr, mm in zip(rad[good], mu):
fh.write(f"{rr:.4f},{mm:.4f}\n")
if verbose:
print(f" -> science/figures/{os.path.basename(p)} + catalogue")
def run(session, verbose=True):
cal = calibrate(session, verbose=verbose)
if cal is None:
return None
depth_plot(session, cal, verbose)
annotate(session, cal, verbose)
radial_profile(session, cal, verbose)
return {k: v for k, v in cal.items()
if k not in ("image", "wcs", "mag", "objs", "matched")}