MilanMagik's picture
Add StarDenoise source code
574aa8f verified
Raw History Blame Contribute Delete
10.2 kB
"""Command-line inference demo for Cosmic Clarity AI 3.6."""
from __future__ import annotations
import argparse
import contextlib
import math
import time
from pathlib import Path
from typing import Iterator
import cv2
import numpy as np
import torch
from image_io import load_image, save_image
from model import load_model, model_statistics
ROOT = Path(__file__).resolve().parent
DEFAULT_WEIGHTS = ROOT / "models" / "deep_denoise_cnn_AI3_6.pth"
def rgb_to_ycbcr(image: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
matrix = np.array(
[[0.299, 0.587, 0.114], [-0.168736, -0.331264, 0.5], [0.5, -0.418688, -0.081312]],
dtype=np.float32,
)
converted = image @ matrix.T
return converted[..., 0], converted[..., 1] + 0.5, converted[..., 2] + 0.5
def ycbcr_to_rgb(y: np.ndarray, cb: np.ndarray, cr: np.ndarray) -> np.ndarray:
converted = np.stack((y, cb - 0.5, cr - 0.5), axis=-1)
matrix = np.array(
[[1.0, 0.0, 1.402], [1.0, -0.344136, -0.714136], [1.0, 1.772, 0.0]],
dtype=np.float32,
)
return np.clip(converted @ matrix.T, 0.0, 1.0)
def _mtf_stretch(image: np.ndarray, target_median: float = 0.25) -> tuple[np.ndarray, float, list[float]]:
original_min = float(image.min())
stretched = image - original_min
medians = [float(np.median(stretched[..., channel])) for channel in range(3)]
for channel, median in enumerate(medians):
if median == 0:
continue
x = stretched[..., channel]
denominator = median * (target_median + x - 1.0) - target_median * x
stretched[..., channel] = ((median - 1.0) * target_median * x) / np.where(
np.abs(denominator) < 1e-12, 1e-12, denominator
)
return np.clip(stretched, 0.0, 1.0), original_min, medians
def _mtf_unstretch(image: np.ndarray, original_min: float, original_medians: list[float]) -> np.ndarray:
result = image.copy()
current_medians = [float(np.median(result[..., channel])) for channel in range(3)]
for channel, current in enumerate(current_medians):
target = original_medians[channel]
if current == 0 or target == 0:
continue
x = result[..., channel]
denominator = current * (target + x - 1.0) - target * x
result[..., channel] = ((current - 1.0) * target * x) / np.where(
np.abs(denominator) < 1e-12, 1e-12, denominator
)
return np.clip(result + original_min, 0.0, 1.0)
def _guided_filter(guide: np.ndarray, source: np.ndarray, radius: int, epsilon: float) -> np.ndarray:
kernel = (2 * radius + 1, 2 * radius + 1)
kwargs = {"ddepth": -1, "ksize": kernel, "borderType": cv2.BORDER_REFLECT}
mean_i = cv2.boxFilter(guide, **kwargs)
mean_p = cv2.boxFilter(source, **kwargs)
corr_i = cv2.boxFilter(guide * guide, **kwargs)
corr_ip = cv2.boxFilter(guide * source, **kwargs)
variance_i = corr_i - mean_i * mean_i
covariance_ip = corr_ip - mean_i * mean_p
a = covariance_ip / (variance_i + epsilon)
b = mean_p - a * mean_i
return cv2.boxFilter(a, **kwargs) * guide + cv2.boxFilter(b, **kwargs)
def denoise_chroma(y: np.ndarray, cb: np.ndarray, cr: np.ndarray, strength: float) -> tuple[np.ndarray, np.ndarray]:
effective = float(np.clip(strength * 2.0, 0.0, 1.0))
if effective == 0:
return cb, cr
radius = 2 + int(round(10 * effective))
epsilon = (0.001 + 0.05 * effective) ** 2
cb_filtered = _guided_filter(y, cb, radius, epsilon)
cr_filtered = _guided_filter(y, cr, radius, epsilon)
return (
(1.0 - effective) * cb + effective * cb_filtered,
(1.0 - effective) * cr + effective * cr_filtered,
)
def _positions(length: int, tile: int, overlap: int) -> list[int]:
if length <= tile:
return [0]
stride = tile - overlap
result = list(range(0, length - tile + 1, stride))
if result[-1] != length - tile:
result.append(length - tile)
return result
def _batches(items: list[tuple[int, int, np.ndarray]], size: int) -> Iterator[list[tuple[int, int, np.ndarray]]]:
for start in range(0, len(items), size):
yield items[start : start + size]
def infer_channel(
channel: np.ndarray,
model: torch.nn.Module,
device: torch.device,
tile_size: int,
overlap: int,
batch_size: int,
half_precision: bool,
) -> np.ndarray:
if overlap >= tile_size:
raise ValueError("overlap must be smaller than tile-size")
height, width = channel.shape
pad_y, pad_x = max(0, tile_size - height), max(0, tile_size - width)
padded = np.pad(channel, ((0, pad_y), (0, pad_x)), mode="reflect")
ph, pw = padded.shape
tiles = [
(y, x, padded[y : y + tile_size, x : x + tile_size])
for y in _positions(ph, tile_size, overlap)
for x in _positions(pw, tile_size, overlap)
]
# A Hann window suppresses tile seams; the floor guarantees edge coverage.
window_1d = np.hanning(tile_size).astype(np.float32)
window = np.maximum(np.outer(window_1d, window_1d), 1e-3)
accumulated = np.zeros_like(padded, dtype=np.float32)
weights = np.zeros_like(padded, dtype=np.float32)
use_amp = half_precision and device.type == "cuda"
for batch in _batches(tiles, batch_size):
array = np.stack([item[2] for item in batch])
tensor = torch.from_numpy(array[:, None]).repeat(1, 3, 1, 1).to(device)
amp = torch.autocast(device_type="cuda", dtype=torch.float16) if use_amp else contextlib.nullcontext()
with torch.inference_mode(), amp:
prediction = model(tensor)[:, 0].float().cpu().numpy()
for (y, x, _), tile_prediction in zip(batch, prediction):
accumulated[y : y + tile_size, x : x + tile_size] += tile_prediction * window
weights[y : y + tile_size, x : x + tile_size] += window
return (accumulated / np.maximum(weights, 1e-8))[:height, :width]
def denoise_array(
pixels: np.ndarray,
model: torch.nn.Module,
device: torch.device,
strength: float = 0.9,
color_strength: float | None = None,
mode: str = "full",
tile_size: int = 256,
overlap: int = 64,
batch_size: int = 1,
half_precision: bool = True,
linear_stretch: str = "auto",
) -> np.ndarray:
if not 0.0 <= strength <= 1.0:
raise ValueError("strength must be in [0, 1]")
if strength == 0.0:
return pixels.copy()
mono = pixels.shape[-1] == 1
rgb = np.repeat(pixels, 3, axis=-1) if mono else pixels[..., :3].copy()
should_stretch = linear_stretch == "always" or (
linear_stretch == "auto" and float(np.median(rgb - rgb.min())) < 0.05
)
stretch_state = None
if should_stretch:
rgb, minimum, medians = _mtf_stretch(rgb)
stretch_state = (minimum, medians)
def run(channel: np.ndarray) -> np.ndarray:
return infer_channel(channel, model, device, tile_size, overlap, batch_size, half_precision)
if mono:
denoised = ((1.0 - strength) * rgb[..., 0] + strength * run(rgb[..., 0]))[..., None]
result = np.repeat(denoised, 3, axis=-1)
elif mode == "separate":
result = np.stack(
[(1.0 - strength) * rgb[..., c] + strength * run(rgb[..., c]) for c in range(3)],
axis=-1,
)
elif mode in ("luminance", "full"):
y, cb, cr = rgb_to_ycbcr(rgb)
y = (1.0 - strength) * y + strength * run(y)
if mode == "full":
cb, cr = denoise_chroma(y, cb, cr, strength if color_strength is None else color_strength)
result = ycbcr_to_rgb(y, cb, cr)
else:
raise ValueError(f"Unknown mode: {mode}")
if stretch_state is not None:
result = _mtf_unstretch(result, *stretch_state)
return result[..., :1] if mono else result
def choose_device(requested: str) -> torch.device:
if requested == "auto":
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
device = torch.device(requested)
if device.type == "cuda" and not torch.cuda.is_available():
raise RuntimeError("CUDA was requested but is not available")
return device
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Astronomy-specific Cosmic Clarity AI 3.6 denoising demo")
parser.add_argument("input", type=Path, help="Input JPG/PNG/TIFF/FITS")
parser.add_argument("output", type=Path, help="Output path; extension selects format")
parser.add_argument("--weights", type=Path, default=DEFAULT_WEIGHTS)
parser.add_argument("--device", default="auto", help="auto, cpu, cuda, or cuda:N")
parser.add_argument("--strength", type=float, default=0.9)
parser.add_argument("--color-strength", type=float, default=None)
parser.add_argument("--mode", choices=("luminance", "full", "separate"), default="full")
parser.add_argument("--tile-size", type=int, default=256)
parser.add_argument("--overlap", type=int, default=64)
parser.add_argument("--batch-size", type=int, default=1)
parser.add_argument("--precision", choices=("fp16", "fp32"), default="fp16")
parser.add_argument("--linear-stretch", choices=("auto", "always", "never"), default="auto")
return parser
def main() -> None:
args = build_parser().parse_args()
device = choose_device(args.device)
model = load_model(args.weights, device)
record = load_image(args.input)
started = time.perf_counter()
output = denoise_array(
record.pixels,
model,
device,
strength=args.strength,
color_strength=args.color_strength,
mode=args.mode,
tile_size=args.tile_size,
overlap=args.overlap,
batch_size=args.batch_size,
half_precision=args.precision == "fp16",
linear_stretch=args.linear_stretch,
)
save_image(args.output, record, output)
elapsed = time.perf_counter() - started
statistics = model_statistics(model)
print(f"Model: Cosmic Clarity AI 3.6 ({statistics['parameters']:,} parameters)")
print(f"Device: {device}; input: {record.pixels.shape}; elapsed: {elapsed:.3f}s")
print(f"Saved: {args.output.resolve()}")
if __name__ == "__main__":
main()