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.
161 lines
6.9 KiB
Python
161 lines
6.9 KiB
Python
"""Pass 6: re-render with the core recovered.
|
|
|
|
The first composite blew out the galaxy's centre, and the earlier explanation
|
|
for that - a saturated nucleus - was wrong. The surface-photometry analysis
|
|
found, and a direct check confirmed, that the bright clipped pixels near the
|
|
middle of the frame belong to a foreground star 128 px (69 arcsec) from the
|
|
nucleus. The galaxy's own light never comes close to the clip level: inside the
|
|
inner 800 x 800 px box there is not one star-free pixel above 30000 ADU.
|
|
|
|
So the core was lost to the STRETCH, not to the sensor. Setting the white point
|
|
at the 99.995th percentile put it at 64283 ADU, a level set by field stars,
|
|
while the galaxy peaks around a twentieth of that. The midtone transfer needed
|
|
to lift a sky at 7 ADU into visibility then pushed everything above a few
|
|
thousand ADU to white.
|
|
|
|
The fix is the standard high-dynamic-range one: stretch the same data twice and
|
|
blend. A faint-biased curve for the sky and halo, a bright-biased curve that
|
|
keeps the inner galaxy on the shoulder rather than the ceiling, and a mask that
|
|
chooses between them by brightness. No extra data is needed, and none of this
|
|
invents anything: both curves are monotonic functions of the same pixels.
|
|
"""
|
|
import os
|
|
import sys
|
|
|
|
import numpy as np
|
|
import tifffile
|
|
from astropy.io import fits
|
|
from PIL import Image
|
|
from scipy.ndimage import gaussian_filter, median_filter
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import compose as C # noqa: E402
|
|
|
|
import layout
|
|
|
|
OUT = C.OUT
|
|
FAINT_TARGET = 0.10 # where the sky sits in the faint-biased curve
|
|
CORE_HEADROOM_SCALE = 1.15 # bright-curve white point, as a multiple of the
|
|
# galaxy's own peak: slightly above it, so the very
|
|
# centre keeps a little headroom
|
|
CORE_MIDTONE = 0.35 # gentle: the core needs tonal separation, not lift
|
|
|
|
|
|
def bright_curve(lum_lin, galaxy_peak, black, white):
|
|
"""A second stretch whose white point is the galaxy, not the field stars.
|
|
|
|
This is the whole trick. The faint curve normalises against a white point
|
|
of 64283 ADU, set by field stars, so the galaxy's entire tonal range - sky
|
|
at 7 ADU up to a peak near 1944 - is squeezed into the top few percent of
|
|
the curve and comes out as a featureless white blob. Rescaling so that the
|
|
galaxy's own peak IS the white point spreads that same range across the
|
|
full output, and the bulge's smooth gradient and the dust lane silhouetted
|
|
against it become visible. Stars clip in this curve, which does not matter:
|
|
it is only ever used where the faint curve has already run out of room.
|
|
"""
|
|
lo = black
|
|
hi = galaxy_peak * CORE_HEADROOM_SCALE
|
|
norm = np.clip((lum_lin - lo) / (hi - lo), 0.0, 1.0)
|
|
print(f" bright curve: black {lo:.1f} ADU, white {hi:.0f} ADU "
|
|
f"(galaxy peak {galaxy_peak:.0f}), midtone {CORE_MIDTONE}")
|
|
return C.mtf(norm, CORE_MIDTONE)
|
|
|
|
|
|
def main():
|
|
data, hdr = C.load()
|
|
shape = data["Luminance"].shape
|
|
mask = C.galaxy_mask(shape)
|
|
for name in C.CHANNELS:
|
|
data[name], *_ = C.remove_gradient(data[name], mask)
|
|
o, flux = C.star_photometry(data["Luminance"], data)
|
|
good = (flux["Red"] > 0) & (flux["Green"] > 0) & (flux["Blue"] > 0)
|
|
data["Red"] *= np.median(flux["Green"][good] / flux["Red"][good])
|
|
data["Blue"] *= np.median(flux["Green"][good] / flux["Blue"][good])
|
|
|
|
lum_lin = data["Luminance"]
|
|
rgb_lin = np.dstack([data["Red"], data["Green"], data["Blue"]])
|
|
del data
|
|
|
|
# The galaxy's true peak, with stars filtered out. A median filter wide
|
|
# enough to swallow a stellar profile leaves the smooth galaxy alone.
|
|
ny, nx = shape
|
|
h = 500
|
|
core = lum_lin[ny // 2 - h:ny // 2 + h, nx // 2 - h:nx // 2 + h]
|
|
galaxy_peak = float(median_filter(core, size=41).max())
|
|
print(f"galaxy peak (star-free) {galaxy_peak:.0f} ADU vs frame max "
|
|
f"{lum_lin.max():.0f} ADU")
|
|
|
|
lum_faint, params = C.autostretch(lum_lin, target=FAINT_TARGET)
|
|
lum_bright = bright_curve(lum_lin, galaxy_peak, params["black"],
|
|
params["white"])
|
|
|
|
# Blend on the FAINT curve's brightness: where it has run out of headroom,
|
|
# hand over to the bright curve. Feathered so the transition is invisible.
|
|
w = np.clip((lum_faint - 0.55) / 0.35, 0.0, 1.0)
|
|
w = gaussian_filter(w.astype(np.float32), 8.0)
|
|
lum = np.clip(lum_faint * (1.0 - w) + lum_bright * w, 0.0, 1.0)
|
|
print(f" HDR blend covers {float((w > 0.05).mean()):.2%} of the frame")
|
|
|
|
rgb = np.empty_like(rgb_lin)
|
|
for i in range(3):
|
|
ch = rgb_lin[:, :, i]
|
|
sky = np.median(ch)
|
|
mad = 1.4826 * np.median(np.abs(ch - sky))
|
|
black = sky - 2.8 * mad
|
|
white = np.percentile(ch, 99.995)
|
|
norm = np.clip((ch - black) / (white - black), 0, 1)
|
|
cf = C.mtf(norm, params["midtone"])
|
|
# The colour channels get the same two-curve treatment, so the core
|
|
# keeps its colour instead of going white while the luminance holds
|
|
# detail.
|
|
peak_c = float(median_filter(
|
|
ch[ny // 2 - h:ny // 2 + h, nx // 2 - h:nx // 2 + h],
|
|
size=41).max())
|
|
nb = np.clip((ch - black) / (peak_c * CORE_HEADROOM_SCALE - black),
|
|
0.0, 1.0)
|
|
cb = C.mtf(nb, CORE_MIDTONE)
|
|
rgb[:, :, i] = cf * (1.0 - w) + cb * w
|
|
del rgb_lin
|
|
|
|
sky_med = [float(np.median(rgb[:, :, i][~mask])) for i in range(3)]
|
|
target = float(np.mean(sky_med))
|
|
for i in range(3):
|
|
rgb[:, :, i] = np.clip(rgb[:, :, i] - (sky_med[i] - target), 0.0, 1.0)
|
|
|
|
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 * C.SATURATION, 0.0, 1.0)
|
|
del chroma, rgb_lum
|
|
|
|
detail = lum - gaussian_filter(lum, 2.0)
|
|
weight = np.clip((lum - FAINT_TARGET) * 4.0, 0.0, 1.0)
|
|
lum = np.clip(lum + 0.35 * detail * weight, 0.0, 1.0)
|
|
del detail, weight
|
|
|
|
ratio = lum / np.maximum(rgb.mean(axis=2), 1e-5)
|
|
out = np.clip(rgb * ratio[:, :, None], 0.0, 1.0)
|
|
del ratio, rgb
|
|
|
|
neutral = 0.5 * (out[:, :, 0] + out[:, :, 2])
|
|
green = out[:, :, 1]
|
|
out[:, :, 1] = np.where(green > neutral, green * 0.15 + neutral * 0.85,
|
|
green)
|
|
|
|
frac = float((out.max(axis=2) > 0.995).mean())
|
|
print(f" pixels at full white: {frac:.3%}")
|
|
|
|
Image.fromarray((out * 255 + 0.5).astype(np.uint8)).save(
|
|
layout.path("NGC5128-LRGB-hdr.png"))
|
|
tifffile.imwrite(layout.path("NGC5128-LRGB-hdr.tif"),
|
|
(out * 65535 + 0.5).astype(np.uint16), photometric="rgb")
|
|
prev = Image.fromarray((out * 255 + 0.5).astype(np.uint8))
|
|
prev.thumbnail((2400, 2400), Image.LANCZOS)
|
|
prev.save(layout.path("NGC5128-LRGB-hdr-preview.jpg"), quality=93)
|
|
print("wrote NGC5128-LRGB-hdr.png / .tif / -preview.jpg")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|