MilanMagik's picture
Add StarDenoise source code
574aa8f verified
Raw History Blame Contribute Delete
4.3 kB
"""PyTorch reproduction of the SetiAstro Cosmic Clarity AI 3.6 denoiser.
The architecture follows SetiAstroCosmicClarity_denoise.py from the upstream
MIT-licensed project. It is intentionally kept independent of the original
GUI so it can be embedded in a larger image-processing pipeline.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import torch
from torch import nn
class ResidualBlock(nn.Module):
def __init__(self, channels: int) -> None:
super().__init__()
self.conv1 = nn.Conv2d(channels, channels, 3, padding=1)
self.relu = nn.ReLU(inplace=False)
self.conv2 = nn.Conv2d(channels, channels, 3, padding=1)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.relu(self.conv2(self.relu(self.conv1(x))) + x)
class CosmicClarityDenoiseCNN(nn.Module):
"""Full-resolution residual encoder/decoder used by AI 3.6.
Despite the encoder/decoder names, the network does not spatially
downsample. Dilated convolutions and skip concatenations preserve faint
stars and small-scale structure while increasing the receptive field.
"""
def __init__(self) -> None:
super().__init__()
self.encoder1 = self._encoder(3, 16)
self.encoder2 = self._encoder(16, 32)
self.encoder3 = self._encoder(32, 64, dilation=2)
self.encoder4 = self._encoder(64, 128)
self.encoder5 = self._encoder(128, 256, dilation=2)
self.decoder5 = self._decoder(256 + 128, 128)
self.decoder4 = self._decoder(128 + 64, 64)
self.decoder3 = self._decoder(64 + 32, 32)
self.decoder2 = self._decoder(32 + 16, 16)
self.decoder1 = nn.Sequential(nn.Conv2d(16, 3, 3, padding=1), nn.Sigmoid())
@staticmethod
def _encoder(in_channels: int, out_channels: int, dilation: int = 1) -> nn.Sequential:
return nn.Sequential(
nn.Conv2d(
in_channels,
out_channels,
3,
padding=dilation,
dilation=dilation,
),
nn.ReLU(inplace=False),
ResidualBlock(out_channels),
)
@staticmethod
def _decoder(in_channels: int, out_channels: int) -> nn.Sequential:
return nn.Sequential(
nn.Conv2d(in_channels, out_channels, 3, padding=1),
nn.ReLU(inplace=False),
ResidualBlock(out_channels),
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
e1 = self.encoder1(x)
e2 = self.encoder2(e1)
e3 = self.encoder3(e2)
e4 = self.encoder4(e3)
e5 = self.encoder5(e4)
d5 = self.decoder5(torch.cat((e5, e4), dim=1))
d4 = self.decoder4(torch.cat((d5, e3), dim=1))
d3 = self.decoder3(torch.cat((d4, e2), dim=1))
d2 = self.decoder2(torch.cat((d3, e1), dim=1))
return self.decoder1(d2)
def _safe_torch_load(path: Path, device: torch.device) -> Any:
"""Load a trusted upstream checkpoint while preferring restricted unpickling."""
try:
return torch.load(path, map_location=device, weights_only=True)
except TypeError: # torch < 2.0 compatibility
return torch.load(path, map_location=device)
def load_model(weights: str | Path, device: torch.device) -> CosmicClarityDenoiseCNN:
weights_path = Path(weights)
if not weights_path.is_file():
raise FileNotFoundError(
f"Model weights not found: {weights_path}. Run download_assets.py first."
)
checkpoint = _safe_torch_load(weights_path, device)
if not isinstance(checkpoint, dict):
raise ValueError("Unexpected checkpoint format: expected a mapping")
state_dict = checkpoint.get("model_state_dict", checkpoint)
model = CosmicClarityDenoiseCNN().to(device)
model.load_state_dict(state_dict, strict=True)
model.eval()
return model
def model_statistics(model: nn.Module) -> dict[str, float | int]:
parameters = sum(parameter.numel() for parameter in model.parameters())
trainable = sum(parameter.numel() for parameter in model.parameters() if parameter.requires_grad)
return {
"parameters": parameters,
"trainable_parameters": trainable,
"fp32_size_mib": parameters * 4 / 1024**2,
}