MilanMagik's picture
Add StarDenoise source code
574aa8f verified
Raw History Blame Contribute Delete
4.24 kB
"""Image I/O with FITS metadata and dynamic-range preservation."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
import tifffile
from astropy.io import fits
from PIL import Image
FITS_EXTENSIONS = {".fit", ".fits", ".fts"}
TIFF_EXTENSIONS = {".tif", ".tiff"}
@dataclass
class ImageRecord:
pixels: np.ndarray # HWC float32, normalized to [0, 1]
offset: float
scale: float
original_dtype: np.dtype
original_layout: str
header: Any = None
def _to_hwc(array: np.ndarray) -> tuple[np.ndarray, str]:
if array.ndim == 2:
return array[..., None], "hw"
if array.ndim != 3:
raise ValueError(f"Only mono/RGB 2-D images are supported, got shape {array.shape}")
if array.shape[-1] in (1, 3, 4):
return array[..., :3], "hwc"
if array.shape[0] in (1, 3, 4):
return np.moveaxis(array[:3], 0, -1), "chw"
raise ValueError(f"Cannot determine channel axis for shape {array.shape}")
def _normalize(array: np.ndarray) -> tuple[np.ndarray, float, float]:
finite = np.isfinite(array)
if not finite.any():
raise ValueError("Image contains no finite pixels")
clean = np.nan_to_num(array, copy=True)
if np.issubdtype(array.dtype, np.integer):
info = np.iinfo(array.dtype)
offset = float(max(0, info.min))
scale = float(info.max - offset)
else:
minimum = float(np.min(clean[finite]))
maximum = float(np.max(clean[finite]))
if minimum >= 0.0 and maximum <= 1.0:
offset, scale = 0.0, 1.0
else:
offset, scale = minimum, maximum - minimum
if scale <= 0:
scale = 1.0
normalized = np.clip((clean.astype(np.float32) - offset) / scale, 0.0, 1.0)
return normalized, offset, scale
def load_image(path: str | Path) -> ImageRecord:
path = Path(path)
extension = path.suffix.lower()
header = None
if extension in FITS_EXTENSIONS:
with fits.open(path, memmap=False) as hdul:
index = next((i for i, hdu in enumerate(hdul) if hdu.data is not None), None)
if index is None:
raise ValueError(f"No image HDU in {path}")
array = np.asarray(hdul[index].data)
header = hdul[index].header.copy()
elif extension in TIFF_EXTENSIONS:
array = np.asarray(tifffile.imread(path))
else:
with Image.open(path) as image:
if image.mode not in ("L", "I;16", "I", "F"):
image = image.convert("RGB")
array = np.asarray(image)
original_dtype = array.dtype
hwc, layout = _to_hwc(array)
pixels, offset, scale = _normalize(hwc)
return ImageRecord(pixels, offset, scale, original_dtype, layout, header)
def _restore_layout(array: np.ndarray, layout: str) -> np.ndarray:
if layout == "hw":
return array[..., 0]
if layout == "chw":
return np.moveaxis(array, -1, 0)
return array
def _restore_range(record: ImageRecord, pixels: np.ndarray) -> np.ndarray:
restored = np.clip(pixels, 0.0, 1.0) * record.scale + record.offset
if np.issubdtype(record.original_dtype, np.integer):
info = np.iinfo(record.original_dtype)
restored = np.clip(np.rint(restored), info.min, info.max).astype(record.original_dtype)
else:
restored = restored.astype(record.original_dtype)
return _restore_layout(restored, record.original_layout)
def save_image(path: str | Path, record: ImageRecord, pixels: np.ndarray) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
extension = path.suffix.lower()
restored = _restore_range(record, pixels)
if extension in FITS_EXTENSIONS:
fits.PrimaryHDU(restored, header=record.header).writeto(path, overwrite=True)
elif extension in TIFF_EXTENSIONS:
tifffile.imwrite(path, restored)
else:
# JPEG/PNG demo output is display-oriented. Preserve mono when applicable.
display = np.clip(pixels * 255.0 + 0.5, 0, 255).astype(np.uint8)
if record.original_layout == "hw":
display = display[..., 0]
Image.fromarray(display).save(path, quality=95)