Source code for astrafocus.star_finder

import astropy
import numpy as np
from photutils.detection import DAOStarFinder

from astrafocus.utils.logger import get_logger
from astrafocus.utils.typing import ImageType

logger = get_logger()


[docs] class StarFinder: """ Examples ------- TargetFinder(ref_image) """ FALLBACK_THRESHOLDS = np.array([4, 3, 2.5]) def __init__( self, ref_image: ImageType, fwhm: float = 3.0, star_find_threshold: float = 4.0, absolute_detection_limit: float = 0.0, saturation_threshold: float | None = None, max_stars: int = 50, ) -> None: self.fwhm = fwhm self.star_find_threshold = star_find_threshold self.absolute_detection_limit = absolute_detection_limit self.saturation_threshold = saturation_threshold self.max_stars = max_stars mean, median, std = astropy.stats.sigma_clipped_stats(ref_image, sigma=3.0) self.ref_background = median self.ref_std = std self.selected_stars = self._find_sources(ref_image) if self.selected_stars is None: fallbacks = self.get_fallback_thresholds(self.star_find_threshold) min_tried = fallbacks[-1] if fallbacks.size > 0 else self.star_find_threshold raise ValueError( f"No sources found down to min_threshold={min_tried:.1f}σ. " "Check image quality, or adjust the star finder parameters " "(e.g. lower the star_find_threshold or absolute_detection_limit, or increase the fwhm)." ) def _find_sources(self, ref_image): return self.find_sources( ref_image=ref_image, fwhm=self.fwhm, threshold=self.star_find_threshold, std=self.ref_std, background=self.ref_background, saturation_threshold=self.saturation_threshold, absolute_detection_limit=self.absolute_detection_limit, max_stars=self.max_stars, )
[docs] @classmethod def find_sources( cls, ref_image: ImageType, fwhm: float = 3.0, threshold: float = 4.0, std=None, background=None, saturation_threshold=None, absolute_detection_limit: float = 0.0, max_stars: int = 50, ): """ Detect and locate stars using a tiered-threshold DAOFIND approach. This method performs an initial search at the specified `threshold`. If no sources are found, it automatically falls back to searching at progressively lower thresholds defined in `FALLBACK_THRESHOLDS`. This is designed to maintain autofocus reliability even when stars are blurred (out-of-focus) or sky transparency drops. Parameters ---------- ref_image : 2D array_like The background-subtracted or raw image array. fwhm : float, optional Full-Width at Half-Maximum (pixels) of the Gaussian kernel. Standard ground-based telescopes typically use 2.5 to 4.0. threshold : float, optional Initial detection threshold in units of background standard deviation (sigma). Default is 4.0. std : float, optional Background noise standard deviation. If None, estimated via sigma-clipped statistics. background : float, optional Median background level. If None, estimated via sigma-clipped statistics. saturation_threshold : float, optional Maximum allowed pixel value. Peaks above this are rejected (useful for excluding saturated stars that bias centroids). absolute_detection_limit : float, optional The hard floor for detection in ADU/counts. The effective threshold is max(absolute_limit, std * threshold). max_stars : int, optional Capping limit for returned sources to optimize downstream processing speed. Sorted by brightness. Returns ------- astropy.table.QTable or None A table of detected sources with centroids and photometry, or None if no sources meet the criteria even after fallbacks. Notes ----- The tiered approach prevents the "Noise Explosion" problem. Searching immediately at 2.0 sigma on a noisy CMOS sensor can result in thousands of false positives, slowing down the characterization phase significantly. By starting higher and falling back only when necessary, we balance speed with sensitivity. """ if std is None or background is None: mean, median, std = astropy.stats.sigma_clipped_stats(ref_image, sigma=3.0) background = median cleaned_image = ref_image - background sources = StarFinder._dao_star_finder( cleaned_image=cleaned_image, fwhm=fwhm, threshold=np.maximum(absolute_detection_limit, std * threshold), peakmax=saturation_threshold, brightest=max_stars, ) if sources is not None: sources.sort("flux", reverse=True) logger.info(f"Found {len(sources)} sources at threshold={threshold:.2f}σ.") return sources for threshold in cls.get_fallback_thresholds(threshold): sources = StarFinder._dao_star_finder( cleaned_image=cleaned_image, fwhm=fwhm, threshold=np.maximum(absolute_detection_limit, std * threshold), peakmax=saturation_threshold, brightest=max_stars, ) if sources is not None: sources.sort("flux", reverse=True) logger.info(f"Found {len(sources)} sources at fallback threshold={threshold:.1f}σ.") return sources return sources
[docs] @classmethod def get_fallback_thresholds(cls, threshold: float | None) -> np.ndarray: if threshold is None: return cls.FALLBACK_THRESHOLDS else: return cls.FALLBACK_THRESHOLDS[(cls.FALLBACK_THRESHOLDS < threshold)]
@staticmethod def _dao_star_finder(cleaned_image, fwhm, threshold, brightest=None, peakmax=None): daofind = DAOStarFinder( fwhm=fwhm, threshold=threshold, brightest=brightest, peakmax=peakmax, ) sources = daofind(cleaned_image) return sources def __repr__(self): return ( "StarFinder(" f"fwhm={self.fwhm}, " f"star_find_threshold={self.star_find_threshold}, " f"absolute_detection_limit={self.absolute_detection_limit}, " f"saturation_threshold={self.saturation_threshold})" ) def __str__(self): return ( "StarFinder(" f"fwhm={self.fwhm}, " f"star_find_threshold={self.star_find_threshold}, " f"absolute_detection_limit={self.absolute_detection_limit}, " f"saturation_threshold={self.saturation_threshold})" )