Source code for stellarphot.photometry.source_detection

import numpy as np
from astropy import units as u
from astropy.modeling.fitting import LevMarLSQFitter
from astropy.modeling.models import Const2D, Gaussian2D
from astropy.nddata import CCDData
from astropy.nddata.utils import Cutout2D
from astropy.stats import gaussian_sigma_to_fwhm, sigma_clipped_stats
from astropy.table import Table
from photutils.detection import DAOStarFinder
from photutils.morphology import data_properties

from stellarphot.core import SourceListData

__all__ = ["source_detection", "compute_fwhm"]


def _fit_2dgaussian(data):
    """
    Fit a 2D Gaussian to data.

    Written as a replacement for functionality that was removed from
    photutils.

    This function will be kept private so we don't have to support it.

    Copy/pasted from
    https://github.com/astropy/photutils/pull/1064/files#diff-9e64908ff7ac552845b4831870a749f397d73c681d218267bd9087c7757e6dd4R285

    Parameters
    ----------
    data : array-like
        The 2D array of data to fit.

    Returns
    -------
    gfit : `astropy.modeling.Model`
        The best-fit 2D Gaussian model.
    """
    mask = ~np.isfinite(data)

    # If there are non-finite pixels they need to be masked out
    # or many of the returned properties will be NaN.
    props = data_properties(data - np.min(data[~mask]), mask=mask)

    init_const = 0.0  # subtracted data minimum above
    # ptp = peak-to-peak, i.e. max - min, need to also exclude non-finite
    # values here.
    init_amplitude = np.ptp(data[~mask])

    g_init = Const2D(init_const) + Gaussian2D(
        amplitude=init_amplitude,
        x_mean=props.xcentroid,
        y_mean=props.ycentroid,
        x_stddev=props.semimajor_sigma.value,
        y_stddev=props.semiminor_sigma.value,
        theta=props.orientation.value,
    )

    fitter = LevMarLSQFitter()
    y, x = np.indices(data.shape)
    # Call fitter, enabling filtering of NaN/inf values
    gfit = fitter(g_init, x, y, data, filter_non_finite=True)

    return gfit


[docs] def compute_fwhm( ccd, sources, fwhm_estimate=5, x_column="xcenter", y_column="ycenter", fit=True, sky_per_pix_avg=0, ): """ Computes the FWHM in both x and y directions of sources in an image. Parameters ---------- ccd : `astropy.nddata.CCDData` The CCD Image array. sources : `astropy.table.Table` An astropy table of the positions of sources in the image. fwhm_estimate : float, optional (default=5) The initial guess for the FWHM of the sources in the image. x_column : str, optional (default='xcenter') The name of the column in `sources` that contains the x positions of the sources. y_column : str, optional (default='ycenter') The name of the column in `sources` that contains the y positions of the sources. fit : bool, optional (default=True) If ``True``, fit a 2D Gaussian to each source to estimate its FWHM. If ``False``, estimate the FWHM of each source by computing the second moments of its light distribution using photutils. sky_per_pix_avg : float or array-like of float, optional (default=0) Sky background to subtract before centroiding. Returns ------- fwhm_x, fwhm_y : tuple of np.array float The FWHM of each source in the x and y directions. """ fwhm_x = [] fwhm_y = [] for source in sources: x = source[x_column] y = source[y_column] sky = sky_per_pix_avg # Cutout2D needs no units on the center position, so remove unit # if it is present. try: x = x.value y = y.value sky = sky.value except AttributeError: pass cutout = Cutout2D(ccd.data, (x, y), 5 * fwhm_estimate) if fit: fit = _fit_2dgaussian(cutout.data) fwhm_x.append(gaussian_sigma_to_fwhm * fit.x_stddev_1) fwhm_y.append(gaussian_sigma_to_fwhm * fit.y_stddev_1) # print('Still fitting!!') else: sc = data_properties(cutout.data - sky) # dat = np.where(cutout.data - sky > 0, cutout.data - sky, 0) # mom1 = _moments(dat, order=1) # xc = mom1[0, 1] / mom1[0, 0] # yc = mom1[1, 0] / mom1[0, 0] # moments = _moments_central(dat, # center=(xc, yc), order=2) # mom_scale = (moments / mom1[0, 0]) # fwhm_xm = 2 * np.sqrt(np.log(2) * mom_scale[0, 2]) # fwhm_ym = 2 * np.sqrt(np.log(2) * mom_scale[2, 0]) fwhm_xm = sc.fwhm.value fwhm_ym = fwhm_xm fwhm_x.append(fwhm_xm) fwhm_y.append(fwhm_ym) return np.array(fwhm_x), np.array(fwhm_y)
[docs] def source_detection( ccd, fwhm=8, sigma=3.0, iters=5, threshold=10.0, find_fwhm=True, sky_per_pix_avg=0, padding=0, ): """ Returns an SourceListData object containing the position of sources within the image identified using `photutils.DAOStarFinder` algorithm. It can also compute the FWHM of each source by fitting a 2D Gaussian which can be useful for choosing a useful aperture size for photometry. Parameters ---------- ccd : `numpy.ndarray` or `astropy.nddata.CCDData` This is the 2-D array of the image to be analyzed. If a CCDData object is passed, its data attribute will be used. fwhm : float, optional (default=8) Initial estimate of full-width half-max of stars in the image, in pixels. sigma : float, optional. (default=3.0) The number of standard deviations to use as the lower and upper clipping limit when calculating the image background. iters : int, optional (default=5) The number of iterations to perform sigma clipping threshold : float, optional. (default=10.0) The number of standard deviations above which to select sources. find_fwhm : bool, optional (default=True) If ``True``, estimate the FWHM of each source by fitting a 2D Gaussian to it. If ``False``, the 'fwhm_x', 'fwhm_y', and 'width' columns of output table will be filled with NaN values. sky_per_pix_avg : float or None, optional (default=None) Sky background to subtract before centroiding. If set to ``None``, it will be estimated using the mean of the sigma_clipped_stats of the image. padding : int, optional (default=0) Distance from the edge of the image to ignore when searching for sources. Returns ------- sources: `stellardev.SourceListData` A table of the positions of sources in the image. If `find_fwhm` is ``True``, includes columns for `fwhm_x`, `fwhm_y, and `width`. If the input CCDData object has WCS information, the columns `ra` and `dec` are also populated, otherwise they are set to NaN. """ # if image is a CCDData object, extract the data array if not isinstance(ccd, CCDData) and not isinstance(ccd, np.ndarray): raise ValueError("ccd must be a numpy array or CCDData object") # If user uses units for fwhm or sky_per_pix_avg, convert to value if isinstance(sky_per_pix_avg, u.Quantity): sky_per_pix_avg = sky_per_pix_avg.value if isinstance(fwhm, u.Quantity): fwhm = fwhm.value # Get statistics of the input image (and use them to estimate the sky background # if not provided). Using clipped stats should hopefully get rid of any # bright stars that might be in the image, so the mean should be a good # estimate of the sky background. print( "source_detection: You may see a warning about invalid values in the " "input image. This is expected if any pixels are saturated and can be " "ignored." ) mean, median, std = sigma_clipped_stats(ccd, sigma=sigma, maxiters=iters) print( f"source_detection: sigma_clipped_stats mean={mean:.4f}, " f"median={median:.4f}, std={std:.4f}" ) if sky_per_pix_avg is None: sky_per_pix_avg = mean print(f"source_detection: sky_per_pix_avg set to {sky_per_pix_avg:.4f}") # Identify sources applying DAOStarFinder to a "sky subtracted" # image. print( f"source_detection: threshold set to {threshold}* standard deviation " f"({std:.4f})" ) print(f"source_detection: Assuming fwhm of {fwhm} for DAOStarFinder") # daofind should be run on background subtracted image # (fails, or at least returns garbage, if sky_per_pix_avg is too low) daofind = DAOStarFinder(fwhm=fwhm, threshold=threshold * std) if isinstance(ccd, CCDData): sources = daofind(ccd.data - sky_per_pix_avg) else: sources = daofind(ccd - sky_per_pix_avg) # Identify sources near the edge of the image and remove them # from the source list. padding_smt = "" if padding > 0: src_cnt0 = len(sources) y_lim, x_lim = ccd.shape keep = ( (sources["xcentroid"].value >= padding) & (sources["ycentroid"].value >= padding) & (sources["xcentroid"].value < x_lim - padding) & (sources["ycentroid"].value < y_lim - padding) ) sources = sources[keep] padding_smt = f" (after removing {src_cnt0-len(sources)} sources near the edge)" src_cnt = len(sources) print(f"source_detection: {src_cnt} sources identified{padding_smt}.") # If image as WCS, compute RA and Dec of each source try: # Retrieve the RA and Dec of each source as SKyCoord objects, then convert to # arrays of floats to add to table skypos = ccd.wcs.pixel_to_world(sources["xcentroid"], sources["ycentroid"]) sources["ra"] = skypos.ra.value sources["dec"] = skypos.dec.value except AttributeError: # No WCS, so add empty columns sources["ra"] = np.nan * np.ones(src_cnt) sources["dec"] = np.nan * np.ones(src_cnt) # If requested, compute the FWHM of each source if find_fwhm: x, y = compute_fwhm( ccd, sources, fwhm_estimate=fwhm, x_column="xcentroid", y_column="ycentroid", sky_per_pix_avg=sky_per_pix_avg, ) sources["fwhm_x"] = x sources["fwhm_y"] = y sources["width"] = (x + y) / 2 # Average of x and y FWHM # Flag bogus fwhm values returned from fitting (no objects # have a fwhm less than 1 pixel) bad_src = (sources["fwhm_x"] < 1) | (sources["fwhm_y"] < 1) sources["fwhm_x"][bad_src] = np.nan sources["fwhm_y"][bad_src] = np.nan sources["width"][bad_src] = np.nan else: # add empty columns sources["fwhm_x"] = np.nan * np.ones(src_cnt) sources["fwhm_y"] = np.nan * np.ones(src_cnt) sources["width"] = np.nan * np.ones(src_cnt) # Convert sources to SourceListData object by adding # units to the columns units_dict = { "id": None, "ra": u.deg, "dec": u.deg, "xcentroid": u.pix, "ycentroid": u.pix, "fwhm_x": u.pix, "fwhm_y": u.pix, "width": u.pix, } sources = Table(data=sources, units=units_dict) # Rename columns to match SourceListData colnamemap = {"id": "star_id", "xcentroid": "xcenter", "ycentroid": "ycenter"} sl_data = SourceListData(input_data=sources, colname_map=colnamemap) return sl_data