Spaces:
Sleeping
Sleeping
| """ | |
| 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() | |