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.
This commit is contained in:
commit
5286a2e81b
53 changed files with 8820 additions and 0 deletions
102
session-scripts/closeup.py
Normal file
102
session-scripts/closeup.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue