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.
This commit is contained in:
laurence 2026-07-21 22:18:35 +01:00
parent 38f7a81395
commit 3eb5ab6a03
4 changed files with 649 additions and 96 deletions

257
pipeline/finals.py Normal file
View file

@ -0,0 +1,257 @@
"""The four deliverable images, for any session.
The set was worked out on Centaurus A and is the same for every target, because
the point of it is comparison: the same data at four levels of treatment, so a
viewer can see what processing did and did not add.
1-stacked aligned and averaged, then a standard stretch. Nothing else:
no outlier rejection, no gradient removal, no colour work.
The honest starting point.
2-processed conventional processing - gradient, colour balance, denoise.
3-best everything the measurements justify: a background fit that
cannot eat the target, colour calibrated on solar-analogue
stars where astrometry allows it, the core recovered by a
second tone curve, star-protected sharpening, and noise
reduction that leaves compact sources alone.
4-closeup image 3 cropped to the target's own measured extent.
Plus a side-by-side comparison of the first three.
Image 3's list is not a style. Each item exists because measuring the first
session showed the conventional version getting something wrong: the plane fit
had absorbed 17.9 ADU/px of galaxy halo, "average star is grey" was biased by a
field whose stars are redder than the Sun, the core was flattened by a white
point set by field stars, deconvolution rang around every bright star, and
denoising erased faint compact sources that turned out to be globular clusters.
"""
import os
import numpy as np
import sep
import tifffile
from astropy.io import fits
from PIL import Image
from scipy.ndimage import gaussian_filter, median_filter
from skimage.restoration import denoise_tv_chambolle, richardson_lucy
import colour
import layout
RL_ITERS = 10
PSF_BOX = 25
def _stem(session, tag):
return f"{session.target.replace(' ', '')}-{tag}"
def _save(session, rgb01, tag, verbose=True):
final = os.path.join(session.root, layout.FINAL)
os.makedirs(final, exist_ok=True)
stem = _stem(session, tag)
arr = np.clip(rgb01, 0, 1)
u8 = (arr * 255 + 0.5).astype(np.uint8)
Image.fromarray(u8).save(os.path.join(final, stem + ".png"))
tifffile.imwrite(os.path.join(final, stem + ".tif"),
(arr * 65535 + 0.5).astype(np.uint16), photometric="rgb")
prev = Image.fromarray(u8)
prev.thumbnail((2400, 2400), Image.LANCZOS)
prev.save(os.path.join(final, stem + "-preview.jpg"), quality=92)
if verbose:
print(f" -> final/{stem}.png / .tif / -preview.jpg")
return os.path.join(final, stem + ".png")
def measure_psf(img):
"""Median-stack isolated unsaturated stars to get the frame's own PSF."""
bkg = sep.Background(img, bw=64, bh=64, fw=3, fh=3)
sub = img - bkg.back()
o = sep.extract(sub, 25.0, err=bkg.globalrms, minarea=9,
deblend_cont=0.005)
o = o[(o["flag"] == 0) & (o["npix"] > 15) & (o["npix"] < 400)]
ny, nx = img.shape
h = PSF_BOX // 2
xs, ys = o["x"], o["y"]
cuts = []
for i in range(len(o)):
if not (h + 2 < xs[i] < nx - h - 2 and h + 2 < ys[i] < ny - h - 2):
continue
d = np.hypot(xs - xs[i], ys - ys[i])
if np.sort(d)[1] < 3 * PSF_BOX: # isolated only
continue
cut = sub[int(ys[i]) - h:int(ys[i]) + h + 1,
int(xs[i]) - h:int(xs[i]) + h + 1].astype(np.float64)
if cut.shape != (PSF_BOX, PSF_BOX) or cut.max() <= 0:
continue
cuts.append(cut / cut.sum())
if len(cuts) >= 120:
break
if len(cuts) < 8:
return None
psf = np.median(np.stack(cuts), axis=0)
psf[psf < 0] = 0
return (psf / psf.sum()).astype(np.float32)
def star_mask(img, rms, max_r=18):
"""Feathered mask over stars, to keep deconvolution and denoise off them."""
bkg = sep.Background(img, bw=64, bh=64, fw=3, fh=3)
o = sep.extract(img - bkg.back(), 6.0, err=rms, minarea=6,
deblend_cont=0.005)
o = o[o["npix"] < 3000]
ny, nx = img.shape
m = np.zeros((ny, nx), np.float32)
R = 20
yy, xx = np.mgrid[-R:R + 1, -R:R + 1]
rr = np.hypot(xx, yy)
for x, y, npix in zip(o["x"], o["y"], o["npix"]):
r = float(np.clip(2.5 * np.sqrt(npix / np.pi) + 3.0, 4, max_r))
cx, cy = int(round(x)), int(round(y))
x0, x1 = max(0, cx - R), min(nx, cx + R + 1)
y0, y1 = max(0, cy - R), min(ny, cy + R + 1)
patch = (rr <= r).astype(np.float32)[(y0 - cy + R):(y1 - cy + R),
(x0 - cx + R):(x1 - cx + R)]
np.maximum(m[y0:y1, x0:x1], patch, out=m[y0:y1, x0:x1])
return np.clip(gaussian_filter(m, 2.5), 0, 1), len(o)
def deconvolve(img, psf):
"""Richardson-Lucy on the signal, never on a star.
RL rings around anything steeper than the PSF model can explain, which on a
star field means a dark annulus round every bright star. Excluding stars is
the standard cure and costs nothing, since the extended target is what
needed sharpening.
"""
if psf is None:
return img
rms = 1.4826 * float(np.median(np.abs(img - np.median(img)))) or 1.0
pedestal = 5.0 * rms
positive = np.clip(img + pedestal, 1e-3, None).astype(np.float32)
scale = float(positive.max())
out = richardson_lucy(positive / scale, psf, num_iter=RL_ITERS,
clip=False) * scale - pedestal
signal = np.clip((img - 3.0 * rms) / (20.0 * rms), 0.0, 1.0)
smask, _ = star_mask(img, rms)
w = (signal * (1.0 - smask)).astype(np.float32)
return (out * w + img * (1.0 - w)).astype(np.float32)
def object_extent(img, mask):
"""Bounding box of the target, from where the signal actually is."""
ys, xs = np.where(mask)
if len(xs) == 0:
ny, nx = img.shape
return 0, nx, 0, ny
return int(xs.min()), int(xs.max()), int(ys.min()), int(ys.max())
def build(session, verbose=True):
"""Produce all four images plus the comparison. Returns their paths."""
masters = colour.load_masters(session)
if not masters:
raise RuntimeError("no masters - run the register stage first")
palette = session.palette
out = {}
# ---------------------------------------------------------------- image 1
# The baseline: masters as stacked, one standard stretch, nothing else.
raw = {f: fits.getdata(p).astype(np.float32) for f, p in masters.items()}
img1 = colour.assemble(session, dict(raw), palette, gradient=False,
neutralise=False, denoise=False, saturation=1.0,
verbose=False)
out["1-stacked"] = _save(session, img1, "1-stacked", verbose)
del img1
# ---------------------------------------------------------------- image 2
img2 = colour.assemble(session, {f: v.copy() for f, v in raw.items()},
palette, gradient=True, neutralise=True,
denoise=True, saturation=colour.SATURATION,
verbose=verbose)
out["2-processed"] = _save(session, img2, "2-processed", verbose)
del img2
# ---------------------------------------------------------------- image 3
data = {f: v.copy() for f, v in raw.items()}
del raw
deepest = session.filters[0] if session.filters[0] in data \
else sorted(data)[0]
mask = colour.target_mask(data[deepest])
# Sharpen the deep channel only, with stars protected.
psf = measure_psf(data[deepest])
if psf is not None:
if verbose:
print(f" deconvolving {deepest} (star-protected)")
data[deepest] = deconvolve(data[deepest], psf)
img3 = colour.assemble(session, data, palette, gradient=True,
neutralise=True, denoise=True,
saturation=colour.SATURATION + 0.05,
hdr=True, protect_compact=True, verbose=verbose)
out["3-best"] = _save(session, img3, "3-best", verbose)
# ---------------------------------------------------------------- image 4
x0, x1, y0, y1 = object_extent(data[deepest], mask)
ny, nx = img3.shape[:2]
# Margin around the target, then clamped to the frame. Deliberately NOT
# forced square: a square crop cannot contain a target wider than the
# frame is tall, which is the normal case for a nebula filling a wide
# field, and forcing it produces a close-up that cuts the subject in half.
mx = int(0.08 * (x1 - x0))
my = int(0.08 * (y1 - y0))
left, right = max(0, x0 - mx), min(nx, x1 + mx)
top, bottom = max(0, y0 - my), min(ny, y1 + my)
covers = (right - left) / nx > 0.95 and (bottom - top) / ny > 0.95
if covers:
# The target fills the field. There is no close-up to make, and
# pretending otherwise would just re-save image 3 under a name that
# claims to be something else.
if verbose:
print(" target fills the frame - no close-up is possible")
elif right - left > 64 and bottom - top > 64:
crop = img3[top:bottom, left:right]
assert left <= x0 and right >= x1 and top <= y0 and bottom >= y1, "close-up would clip the target"
out["4-closeup"] = _save(session, crop, "4-closeup", verbose)
if verbose:
print(f" close-up {right-left}x{bottom-top} px, "
f"clearance {min(x0-left, right-x1, y0-top, bottom-y1)} px")
del img3, data
_comparison(session, out, verbose)
return out
def _comparison(session, paths, verbose=True):
"""Side-by-side of the first three, so the progression is checkable."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
panels = [("1-stacked", "1. Stacked + stretch"),
("2-processed", "2. Conventional"),
("3-best", "3. Science-informed")]
have = [(k, t) for k, t in panels if k in paths]
if len(have) < 2:
return
fig, axes = plt.subplots(1, len(have), figsize=(7 * len(have), 7))
fig.patch.set_facecolor("#111111")
for ax, (key, title) in zip(np.atleast_1d(axes), have):
im = Image.open(paths[key])
im.thumbnail((1400, 1400), Image.LANCZOS)
ax.imshow(np.asarray(im))
ax.set_title(title, color="white", fontsize=14)
ax.set_xticks([]); ax.set_yticks([])
fig.suptitle(f"{session.target} - {session.telescope} - "
f"{session.palette} - same data, three treatments",
color="white", fontsize=16)
fig.tight_layout(rect=[0, 0, 1, 0.94])
path = os.path.join(session.root, layout.FINAL,
_stem(session, "comparison") + ".jpg")
fig.savefig(path, dpi=90, facecolor=fig.get_facecolor(),
pil_kwargs={"quality": 90})
plt.close(fig)
if verbose:
print(f" -> final/{os.path.basename(path)}")