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:
"""
Detects stars in a reference image using a tiered-threshold DAOFIND approach.
On construction, background statistics are estimated from `ref_image` (via
sigma-clipped stats) and an initial source search is run at
`star_find_threshold`. If no sources are found, the search retries at
progressively lower thresholds from `FALLBACK_THRESHOLDS`. See
`find_sources` for the search logic. If no sources are found even after
all fallbacks, a `ValueError` is raised.
Parameters
----------
ref_image : 2D array_like
The reference image to search for stars.
fwhm : float, optional
Full-Width at Half-Maximum (pixels) of the Gaussian kernel used by
DAOStarFinder. Default is 3.0.
star_find_threshold : float, optional
Initial detection threshold in units of background standard
deviation (sigma). Default is 4.0.
absolute_detection_limit : float, optional
Hard floor for detection in ADU/counts. The effective threshold is
max(absolute_detection_limit, std * threshold). Default is 0.0.
saturation_threshold : float, optional
Maximum allowed pixel value; peaks above this are rejected. Default
is None (no cap).
max_stars : int, optional
Maximum number of sources to keep, sorted by brightness. Default is
50.
sharpness_range : tuple of float, optional
(sharplo, sharphi) bounds passed to DAOStarFinder; see
`find_sources` for details. Default is (0.05, 1.0).
Attributes
----------
ref_background : float
Sigma-clipped median background level of `ref_image`.
ref_std : float
Sigma-clipped standard deviation of `ref_image`.
selected_stars : astropy.table.QTable
Table of detected sources with centroids and photometry.
Raises
------
ValueError
If no sources are found at `star_find_threshold` or any fallback
threshold.
Examples
--------
StarFinder(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,
sharpness_range: tuple[float, float] = (0.05, 1.0),
) -> 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
self.sharpness_range = sharpness_range
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,
sharpness_range=self.sharpness_range,
)
[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,
sharpness_range: tuple[float, float] = (0.05, 1.0),
):
"""
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.
sharpness_range : tuple of float, optional
(sharplo, sharphi) bounds passed to DAOStarFinder. The default
lower bound of 0.05 (below the photutils default of 0.2) admits
softer/broader sources, which helps find stars on an initial
out-of-focus reference image. Loosening it further increases the
risk of picking up non-stellar diffuse artifacts (e.g. hot-pixel
clusters, cosmic rays, background blobs).
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),
peak_max=saturation_threshold,
n_brightest=max_stars,
sharpness_range=sharpness_range,
)
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),
peak_max=saturation_threshold,
n_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,
n_brightest=None,
peak_max=None,
sharpness_range=(0.05, 1.0),
):
daofind = DAOStarFinder(
fwhm=fwhm,
threshold=threshold,
n_brightest=n_brightest,
peak_max=peak_max,
sharpness_range=sharpness_range,
)
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})"
)