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

View file

@ -22,6 +22,7 @@ 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
import layout
@ -124,71 +125,44 @@ def load_masters(session):
return masters
def run(session, verbose=True):
"""Produce the final image for whatever palette this session supports."""
masters = load_masters(session)
if not masters:
raise RuntimeError("no masters found - run the register stage first")
palette = session.palette
if verbose:
print(f" palette {palette} from {sorted(masters)}")
def assemble(session, data, palette, gradient=True, neutralise=True,
denoise=True, saturation=SATURATION, hdr=False,
protect_compact=False, verbose=True):
"""Build an RGB image in [0,1] from per-filter linear masters.
data, headers = {}, {}
for filt, path in masters.items():
with fits.open(path) as hd:
data[filt] = hd[0].data.astype(np.float32)
headers[filt] = hd[0].header
# Background: one mask from the deepest channel, applied to all, so every
# channel is treated identically and colour is not shifted by the fit.
deepest = session.filters[0] if session.filters[0] in data \
else sorted(data)[0]
The flags exist so the same code can produce the untouched baseline and the
fully corrected version, which is what makes the two comparable: the only
differences between the deliverables are the ones named here.
"""
deepest = session.filters[0] if session.filters[0] in data else sorted(data)[0]
mask = target_mask(data[deepest])
if verbose:
print(f" target mask covers {mask.mean():.1%} of the frame")
for filt in list(data):
data[filt], coef = remove_gradient(data[filt], mask)
# Which master feeds which output channel.
if gradient:
for filt in list(data):
data[filt], _ = remove_gradient(data[filt], mask)
if palette in PALETTE_MAP:
mapping = PALETTE_MAP[palette]
channels = [data[mapping[c]] for c in ("Red", "Green", "Blue")]
lum_src = None
elif palette in ("LRGB", "RGB", "PARTIAL") and all(
c in data for c in ("Red", "Green", "Blue")):
channels = [data["Red"], data["Green"], data["Blue"]]
lum_src = data.get("Luminance")
else:
only = data[deepest]
stretched, _ = autostretch(only)
grey = (np.clip(stretched, 0, 1) * 65535).astype(np.uint16)
return _write(session, np.dstack([grey] * 3), palette, verbose)
if not all(v in data for v in mapping.values()):
palette = "MONO"
else:
channels = [data[mapping[c]] for c in ("Red", "Green", "Blue")]
lum_src = None
if palette not in PALETTE_MAP:
if all(c in data for c in ("Red", "Green", "Blue")):
channels = [data["Red"], data["Green"], data["Blue"]]
lum_src = data.get("Luminance")
else:
grey, _ = autostretch(data[deepest])
return np.dstack([np.clip(grey, 0, 1)] * 3)
# Colour balance: make the median star colour neutral, which is the cheap
# substitute for a photometric calibration and is well behaved on a rich
# field.
refs = [float(np.percentile(c, 99.5)) for c in channels]
target = float(np.median(refs))
channels = [c * (target / r if r > 0 else 1.0) for c, r in
zip(channels, refs)]
tgt = float(np.median(refs))
channels = [c * (tgt / r if r > 0 else 1.0) for c, r in zip(channels, refs)]
lum_lin = lum_src if lum_src is not None else np.mean(channels, axis=0)
lum, params = autostretch(lum_lin)
# Broadband and narrowband want opposite treatments here.
#
# For broadband, all three channels share the luminance's midtone (a
# "linked" stretch). That preserves the real brightness ratios between
# them, which is what keeps star colours honest.
#
# For narrowband it is wrong. The three lines have wildly different
# strengths - Ha is typically far brighter than SII and OIII - so a linked
# stretch renders the whole nebula in whichever colour Ha was mapped to,
# which in the Hubble palette means overwhelmingly green. Stretching each
# channel to its OWN sky target instead brings the weak lines up to
# comparable visual weight, which is what the palette is for. The result is
# deliberately false colour either way; this makes it a readable false
# colour.
unlinked = palette in PALETTE_MAP
rgb = np.empty(lum.shape + (3,), np.float32)
for i, ch in enumerate(channels):
@ -200,60 +174,73 @@ def run(session, verbose=True):
black = sky - 2.8 * mad
white = float(np.percentile(ch, 99.995))
rgb[:, :, i] = mtf(
np.clip((ch - black) / (max(white - black, 1e-6)), 0, 1),
np.clip((ch - black) / max(white - black, 1e-6), 0, 1),
params["midtone"])
if unlinked:
# With each channel independently stretched the composite already
# carries its own brightness; taking luminance from the linear mean
# would flatten it back down.
lum = rgb.mean(axis=2)
del channels
# Neutralise the sky: calibrating on stars leaves the background tinted,
# and without this an empty frame reads brown or blue depending on the moon.
sky_med = [float(np.median(rgb[:, :, i][~mask])) for i in range(3)]
neutral_to = float(np.mean(sky_med))
for i in range(3):
rgb[:, :, i] = np.clip(rgb[:, :, i] - (sky_med[i] - neutral_to), 0, 1)
if hdr:
# The white point above is set by field stars, which are far brighter
# than an extended target, so the target's whole tonal range lands in
# the top few percent of the curve and reads as a flat blob. A second
# curve scaled to the target's own peak - measured with stars filtered
# out - restores that range. Stars clip in it, which does not matter
# because it is only used where the first curve has run out of room.
h = max(64, min(lum_lin.shape) // 8)
ny, nx = lum_lin.shape
core = lum_lin[ny // 2 - h:ny // 2 + h, nx // 2 - h:nx // 2 + h]
peak = float(median_filter(core, size=min(41, h // 2 * 2 + 1)).max())
if peak > params["black"]:
bright = mtf(np.clip((lum_lin - params["black"]) /
max(peak * 1.15 - params["black"], 1e-6),
0, 1), 0.35)
w = gaussian_filter(
np.clip((lum - 0.55) / 0.35, 0, 1).astype(np.float32), 8.0)
lum = np.clip(lum * (1 - w) + bright * w, 0, 1)
if verbose:
print(f" HDR blend over {float((w > 0.05).mean()):.1%} "
f"of the frame (target peak {peak:.0f} ADU)")
# Denoise colour only; structure comes from the luminance, so this is
# invisible at normal viewing scale.
rgb_lum = rgb.mean(axis=2, keepdims=True)
chroma = rgb - rgb_lum
for i in range(3):
chroma[:, :, i] = gaussian_filter(median_filter(chroma[:, :, i], 3),
1.5)
rgb = np.clip(rgb_lum + chroma * SATURATION, 0, 1)
del chroma, rgb_lum
if neutralise:
sky_med = [float(np.median(rgb[:, :, i][~mask])) for i in range(3)]
to = float(np.mean(sky_med))
for i in range(3):
rgb[:, :, i] = np.clip(rgb[:, :, i] - (sky_med[i] - to), 0, 1)
if denoise:
rgb_lum = rgb.mean(axis=2, keepdims=True)
chroma = rgb - rgb_lum
for i in range(3):
chroma[:, :, i] = gaussian_filter(
median_filter(chroma[:, :, i], 3), 1.5)
rgb = np.clip(rgb_lum + chroma * saturation, 0, 1)
del chroma, rgb_lum
if protect_compact:
# Smooth the sky, but never a compact source. Faint point-like objects
# in these fields are not all noise: on Centaurus A they included 289
# globular cluster candidates, which a blanket denoise erases.
import finals
rms = 1.4826 * float(np.median(np.abs(lum - np.median(lum)))) or 1e-3
smask, nsrc = finals.star_mask(lum.astype(np.float32), rms, max_r=12)
keep = np.clip(np.clip((lum - 0.10) * 4.0, 0, 1) + smask, 0, 1)
smooth = denoise_tv_chambolle(lum, weight=0.012)
lum = np.clip(lum * keep + smooth * (1 - keep), 0, 1)
if verbose:
print(f" sky denoise, {nsrc} compact sources protected")
ratio = lum / np.maximum(rgb.mean(axis=2), 1e-5)
out = np.clip(rgb * ratio[:, :, None], 0.0, 1.0)
del ratio, rgb
# Narrowband palettes are deliberately false colour, so green suppression
# would fight the palette. It only applies to broadband.
if palette not in PALETTE_MAP:
neutralish = 0.5 * (out[:, :, 0] + out[:, :, 2])
green = out[:, :, 1]
out[:, :, 1] = np.where(green > neutralish,
green * 0.15 + neutralish * 0.85, green)
return _write(session, (out * 65535 + 0.5).astype(np.uint16), palette,
verbose)
return out
def _write(session, rgb16, palette, verbose=True):
target = session.target.replace(" ", "")
final = os.path.join(session.root, layout.FINAL)
os.makedirs(final, exist_ok=True)
stem = f"{target}-{palette}"
tifffile.imwrite(os.path.join(final, stem + ".tif"), rgb16,
photometric="rgb")
u8 = (rgb16 / 257).astype(np.uint8)
Image.fromarray(u8).save(os.path.join(final, stem + ".png"))
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 run(session, verbose=True):
"""Produce the four deliverable images for this session."""
import finals
return finals.build(session, verbose=verbose)