astrophotography/session-scripts/closeup.py
laurence 5286a2e81b Processing and analysis code for remote-telescope imaging sessions
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.
2026-07-21 15:29:49 +01:00

102 lines
4.4 KiB
Python

"""Image 4: the close-up, framed by the galaxy's own measured extent.
The crop box is not chosen by eye. The surface photometry produced an isophote
model of NGC 5128, and the region where that model carries real signal defines
how much sky the galaxy actually occupies; the frame is that region plus a
margin. So "keeping the object in frame" is a measured statement rather than a
judgement, and the same rule would frame any other galaxy without retuning.
It is cut from image 3's 16-bit TIFF rather than from the PNG, and never
resampled, so every pixel is exactly the pixel that came out of the pipeline.
Re-rendering was considered and rejected: the stretch in image 3 is already
anchored on sky statistics measured across the whole frame, and a tight crop
contains too little empty sky to measure a better one. Cropping is therefore
not a compromise here - it is the correct operation.
"""
import os
import numpy as np
import tifffile
from astropy.io import fits
from astropy.wcs import WCS
from PIL import Image
import layout
OUT = layout.SESSION
# The finished renderings live in their own directory: they are the
# deliverables, and keeping them apart from the masters, the
# intermediates and the analysis figures makes it obvious which
# files are meant to be looked at.
FINAL = layout.path("final")
SOURCE = "NGC5128-final-3-best.tif"
CROP = 48 # the border already removed from image 3
MARGIN = 0.08 # sky margin around the galaxy, as a fraction
MODEL_FLOOR = 5.0 # ADU/px: where the isophote model still has signal
def main():
model = fits.getdata(layout.path("sb-model.fits")).astype(np.float32)
ys, xs = np.where(model > MODEL_FLOOR)
# Model coordinates are on the uncropped grid; image 3 lost CROP px.
x0, x1 = xs.min() - CROP, xs.max() - CROP
y0, y1 = ys.min() - CROP, ys.max() - CROP
print(f"galaxy extent (model > {MODEL_FLOOR} ADU/px): "
f"x {x0}-{x1}, y {y0}-{y1} = {x1 - x0} x {y1 - y0} px")
with fits.open(layout.path("master-Luminance.fit")) as hd:
wcs = WCS(hd[0].header, naxis=2)
img = tifffile.imread(layout.path(SOURCE))
ny, nx = img.shape[:2]
print(f"source {SOURCE}: {nx} x {ny}, {img.dtype}")
# Centre the frame on the galaxy's own centre, not on the frame's.
cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0
half_x = (x1 - x0) / 2.0 * (1 + MARGIN)
half_y = (y1 - y0) / 2.0 * (1 + MARGIN)
# One box for both axes keeps the galaxy from touching a short edge, and a
# round object in a near-square frame reads better than in a letterbox.
half = max(half_x, half_y)
# Clamp inside the image while keeping the galaxy centred as far as
# possible; report honestly if the frame has to shrink.
half = min(half, cx, cy, nx - cx, ny - cy)
left, right = int(round(cx - half)), int(round(cx + half))
top, bottom = int(round(cy - half)), int(round(cy + half))
print(f"crop box x {left}-{right}, y {top}-{bottom} "
f"({right - left} x {bottom - top} px)")
# Verify the whole galaxy really is inside before writing anything.
assert left <= x0 and right >= x1 and top <= y0 and bottom >= y1, \
"galaxy would be clipped - refusing to write"
inset_x = min(x0 - left, right - x1)
inset_y = min(y0 - top, bottom - y1)
print(f"clearance to the nearest edge: {inset_x} px horizontally, "
f"{inset_y} px vertically")
scale = 0.5376
print(f"field of view {(right - left) * scale / 60:.1f}' x "
f"{(bottom - top) * scale / 60:.1f}'")
corner = wcs.pixel_to_world(left + CROP, top + CROP)
centre = wcs.pixel_to_world((left + right) / 2 + CROP,
(top + bottom) / 2 + CROP)
print(f"frame centre {centre.to_string('hmsdms')}")
print(f"top-left corner {corner.to_string('hmsdms')}")
crop = img[top:bottom, left:right]
tifffile.imwrite(layout.path("NGC5128-final-4-closeup.tif"), crop,
photometric="rgb")
u8 = (crop.astype(np.float32) / 65535.0 * 255 + 0.5).astype(np.uint8)
Image.fromarray(u8).save(layout.path("NGC5128-final-4-closeup.png"))
prev = Image.fromarray(u8)
prev.thumbnail((2400, 2400), Image.LANCZOS)
prev.save(layout.path("NGC5128-final-4-closeup-preview.jpg"),
quality=93)
print("wrote NGC5128-final-4-closeup.tif / .png / -preview.jpg")
os.makedirs(FINAL, exist_ok=True)
if __name__ == "__main__":
main()