mmrech's picture
feat: v0.2 — real FITS support, TAI/UTC fix, SkyBoT, two-pass bg
41d98e2 verified
"""
AsteroidNET Source Extractor (source_extractor.detector)
Two-pass source detection: bright pass at 5σ to build PSF model,
faint pass at 3σ for asteroid candidates.
"""
from __future__ import annotations
import logging
from typing import Optional
import numpy as np
from astropy.table import Table
from astropy.wcs import WCS
import astropy.units as u
logger = logging.getLogger(__name__)
_CATALOG_COLS = [
"x_pixel", "y_pixel", "ra_deg", "dec_deg",
"flux_adu", "flux_err", "mag", "snr",
"fwhm", "roundness", "sharpness",
]
def extract_sources(
data_sub: np.ndarray,
bkg_rms: np.ndarray,
wcs: WCS,
config: Optional[dict] = None,
) -> Table:
"""
Detect sources in a background-subtracted image and return a catalog.
Parameters
----------
data_sub : ndarray
Background-subtracted image (float32, NaN where masked).
bkg_rms : ndarray
Per-pixel background RMS (for threshold computation).
wcs : WCS
Frame WCS for pixel→sky coordinate conversion.
config : dict, optional
Pipeline configuration.
Returns
-------
Table
Source catalog with standardized columns.
"""
cfg = (config or {}).get("detection", {})
thresh_hi = float(cfg.get("bright_threshold_sigma", 5.0))
thresh_lo = float(cfg.get("threshold_sigma", 3.0))
fwhm_lo, fwhm_hi = cfg.get("fwhm_range", [2.0, 8.0])
rms_median = float(np.nanmedian(bkg_rms))
if rms_median <= 0:
rms_median = float(np.nanstd(data_sub[np.isfinite(data_sub)])) or 1.0
try:
from photutils.detection import DAOStarFinder
from photutils.aperture import CircularAperture, aperture_photometry
# Estimate typical FWHM from bright sources
fwhm_est = _estimate_fwhm(data_sub, bkg_rms, thresh_hi, fwhm_lo, fwhm_hi)
# Low-threshold pass for asteroid candidates
finder = DAOStarFinder(
fwhm=fwhm_est,
threshold=thresh_lo * rms_median,
sharplo=0.2, sharphi=1.0,
roundlo=-1.0, roundhi=1.0,
exclude_border=True,
)
# Replace NaN with 0 for detection only
data_clean = np.nan_to_num(data_sub, nan=0.0)
sources = finder(data_clean)
if sources is None or len(sources) == 0:
logger.info("No sources detected (threshold=%.1fσ)", thresh_lo)
return _empty_catalog()
# Aperture photometry
positions = np.column_stack([sources["xcentroid"], sources["ycentroid"]])
ap = CircularAperture(positions, r=fwhm_est)
phot = aperture_photometry(data_sub, ap, error=bkg_rms)
flux = np.array(phot["aperture_sum"], dtype=float)
flux_err = np.array(phot["aperture_sum_err"], dtype=float) if "aperture_sum_err" in phot.colnames else np.full(len(flux), rms_median * np.sqrt(np.pi * fwhm_est**2))
flux = np.maximum(flux, 0.0)
snr = np.where(flux_err > 0, flux / flux_err, 0.0)
# Sky coordinates via WCS
sky = wcs.pixel_to_world(sources["xcentroid"], sources["ycentroid"])
ra = np.atleast_1d(sky.icrs.ra.deg)
dec = np.atleast_1d(sky.icrs.dec.deg)
# Magnitude (relative, no ZP needed for detection)
with np.errstate(invalid="ignore", divide="ignore"):
mag = np.where(flux > 0, -2.5 * np.log10(flux), 99.0)
catalog = Table({
"x_pixel": np.array(sources["xcentroid"]),
"y_pixel": np.array(sources["ycentroid"]),
"ra_deg": ra,
"dec_deg": dec,
"flux_adu": flux,
"flux_err": flux_err,
"mag": mag,
"snr": snr,
"fwhm": np.full(len(flux), fwhm_est),
"roundness": np.array(sources["roundness1"]),
"sharpness": np.array(sources["sharpness"]),
})
logger.info("Extracted %d sources (fwhm=%.2fpx, threshold=%.1fσ)",
len(catalog), fwhm_est, thresh_lo)
return catalog
except ImportError:
logger.warning("photutils not available — falling back to sigma-clip peak finder")
return _fallback_extract(data_sub, bkg_rms, wcs, thresh_lo)
def _estimate_fwhm(data, bkg_rms, thresh, lo, hi):
"""Estimate FWHM from bright sources, clamped to [lo, hi]."""
try:
from photutils.detection import DAOStarFinder
rms = float(np.nanmedian(bkg_rms))
finder = DAOStarFinder(fwhm=3.5, threshold=thresh * rms,
sharplo=0.3, sharphi=0.9,
roundlo=-0.5, roundhi=0.5,
exclude_border=True)
data_clean = np.nan_to_num(data, nan=0.0)
sources = finder(data_clean)
if sources and len(sources) > 5:
fwhm = float(np.median(sources["fwhm"]))
return float(np.clip(fwhm, lo, hi))
except Exception:
pass
return 3.5 # default
def _empty_catalog() -> Table:
return Table({c: [] for c in _CATALOG_COLS})
def _fallback_extract(data, bkg_rms, wcs, thresh):
"""Simple connected-component fallback when photutils absent."""
try:
from scipy.ndimage import label, center_of_mass
rms = float(np.nanmedian(bkg_rms))
binary = np.nan_to_num(data) > thresh * rms
labeled, n = label(binary)
if n == 0:
return _empty_catalog()
indices = list(range(1, n + 1))
coms = center_of_mass(data, labeled, indices)
xs = np.array([c[1] for c in coms])
ys = np.array([c[0] for c in coms])
sky = wcs.pixel_to_world(xs, ys)
ra = np.atleast_1d(sky.icrs.ra.deg)
dec = np.atleast_1d(sky.icrs.dec.deg)
flux = np.array([float(np.nansum(data[labeled == i])) for i in indices])
flux = np.maximum(flux, 0.0)
return Table({
"x_pixel": xs, "y_pixel": ys,
"ra_deg": ra, "dec_deg": dec,
"flux_adu": flux, "flux_err": np.full(n, rms),
"mag": np.where(flux > 0, -2.5 * np.log10(np.maximum(flux, 1e-10)), 99.0),
"snr": flux / rms,
"fwhm": np.full(n, 3.5),
"roundness": np.zeros(n),
"sharpness": np.zeros(n),
})
except Exception as exc:
logger.error("Fallback extraction failed: %s", exc)
return _empty_catalog()