"""Sky-region noise statistics for realism FOM 1.
Science frames have no flat-field pairs, so noise is estimated by local
differencing inside near-uniform patches: paired horizontal pixel
differences cancel scene structure that varies slowly across the patch,
and dividing the difference scale by sqrt(2) recovers the per-pixel sigma.
A robust (MAD-based) scale keeps residual stars, cosmic rays, and hot
pixels in a patch from inflating the estimate; naive signal-binning would
conflate scene texture with noise, which is exactly what this estimator
avoids.
The sky spatial power spectrum (radially averaged over annuli of spatial
frequency) catches banding and coherent noise that a scalar sigma cannot:
white read noise is flat, banding is a comb.
"""
from dataclasses import dataclass
import numpy as np
from spindoctor.support.types import NDArrayFloatType
__all__ = [
'SkyPatch',
'find_uniform_patches',
'paired_difference_sigma',
'radial_power_spectrum',
]
# MAD to sigma conversion for a normal distribution.
_MAD_TO_SIGMA = 1.4826
[docs]
@dataclass(frozen=True)
class SkyPatch:
"""One near-uniform patch selected from a frame.
Parameters:
v0: Top row of the patch (inclusive).
u0: Left column of the patch (inclusive).
size: Patch edge length in pixels.
mean: Mean signal inside the patch.
sigma: Paired-difference noise sigma inside the patch.
"""
v0: int
u0: int
size: int
mean: float
sigma: float
[docs]
def paired_difference_sigma(patch: NDArrayFloatType) -> float:
"""Robust per-pixel noise sigma from paired horizontal differences.
Parameters:
patch: 2-D array of pixel values (a near-uniform region).
Returns:
``MAD(d) * 1.4826 / sqrt(2)`` where ``d`` are horizontal
neighbor differences; NaN for a degenerate patch.
"""
arr = np.asarray(patch, dtype=np.float64)
if arr.ndim != 2 or arr.shape[0] < 2 or arr.shape[1] < 2:
return float('nan')
diffs = (arr[:, 1:] - arr[:, :-1]).ravel()
diffs = diffs[np.isfinite(diffs)]
if diffs.size < 8:
return float('nan')
mad = float(np.median(np.abs(diffs - np.median(diffs))))
return float(mad * _MAD_TO_SIGMA / np.sqrt(2.0))
[docs]
def radial_power_spectrum(
patch: NDArrayFloatType, *, n_bins: int = 16
) -> tuple[NDArrayFloatType, NDArrayFloatType]:
"""Radially averaged spatial power spectrum of one patch.
The patch is mean-subtracted and windowed (Hann, separable) to suppress
edge leakage, then the 2-D periodogram is averaged over annuli of
spatial frequency. The DC bin is excluded.
Parameters:
patch: 2-D square array of pixel values.
n_bins: Number of radial frequency bins between 0 and the Nyquist
frequency (0.5 cycles / pixel).
Returns:
``(freq, power)`` arrays of length ``n_bins``: annulus-center
spatial frequency in cycles / pixel and mean periodogram power.
Empty annuli carry NaN power.
"""
arr = np.asarray(patch, dtype=np.float64)
size_v, size_u = arr.shape
window = np.outer(np.hanning(size_v), np.hanning(size_u))
spectrum = np.abs(np.fft.fft2((arr - arr.mean()) * window)) ** 2
freq_v = np.fft.fftfreq(size_v)[:, np.newaxis]
freq_u = np.fft.fftfreq(size_u)[np.newaxis, :]
freq_r = np.sqrt(freq_v**2 + freq_u**2)
edges = np.linspace(0.0, 0.5, n_bins + 1)
centers = 0.5 * (edges[:-1] + edges[1:])
power = np.full(n_bins, np.nan)
flat_r = freq_r.ravel()
flat_p = spectrum.ravel()
keep = flat_r > 0.0
flat_r = flat_r[keep]
flat_p = flat_p[keep]
for i in range(n_bins):
in_bin = (flat_r >= edges[i]) & (flat_r < edges[i + 1])
if np.any(in_bin):
power[i] = float(flat_p[in_bin].mean())
return centers, power