.. _psf-grouping:

Source Grouping Algorithms
==========================

Introduction
------------

In Point Spread Function (PSF) photometry, the accuracy of measuring
a source's brightness can be compromised by the light from nearby
sources. When sources are close to each other, their individual
light profiles overlap, affecting the fit of the PSF model used for
measurement. To address this, a grouping algorithm can be employed to
combine neighboring sources into distinct sets that are then analyzed
simultaneously.

The primary objective of this grouping is to ensure that the light from
any source within one group does not significantly spill over into the
area where a source in another group is being measured. This method of
creating and analyzing smaller groups of sources is more computationally
efficient than attempting to fit a model to all the sources in an
image at once, a task that is often impractical, especially in densely
populated star fields.

A straightforward method for this grouping was introduced by `Stetson (1987)
<https://ui.adsabs.harvard.edu/abs/1987PASP...99..191S/abstract>`_. This
algorithm determines whether a given source's light profile interferes
with that of any other source by using a "critical separation"
parameter. This parameter sets the minimum distance required between
two source for them to be placed in separate groups. Typically, this
critical separation is defined as a multiple of the stellar full width
at half maximum (FWHM), which is a measure of the source's apparent
size.


Getting Started
---------------

To group sources, Photutils includes a tool called
:class:`~photutils.psf.SourceGrouper`. This class organizes stars into
groups by applying a technique known as hierarchical agglomerative
clustering, which uses a distance-based criterion. This functionality is
implemented using the `scipy.cluster.hierarchy.fclusterdata` function
from the SciPy library.

Typically, to group stars during PSF fitting, one would provide a
:class:`~photutils.psf.SourceGrouper` object, configured with a minimum
separation distance, directly to one of the PSF photometry classes.
However, for the purpose of illustration, we will show how to use the
SourceGrouper class independently to group stars within a sample image.

The first step is to generate a simulated astronomical image that
contains sources modeled as 2D Gaussians, which we will accomplish using
the `~photutils.psf.make_psf_model_image` function::

    >>> from photutils.psf import CircularGaussianPRF, make_psf_model_image
    >>> shape = (256, 256)
    >>> fwhm = 4.7
    >>> psf_model = CircularGaussianPRF(fwhm=fwhm)
    >>> psf_shape = (11, 11)
    >>> n_sources = 100
    >>> flux = (500, 1000)
    >>> border_size = (7, 7)
    >>> data, stars = make_psf_model_image(shape, psf_model, n_sources,
    ...                                    model_shape=psf_shape,
    ...                                    flux=flux,
    ...                                    border_size=border_size, seed=123)

Let's display the image:

.. doctest-skip::

    >>> import matplotlib.pyplot as plt
    >>> plt.figure(figsize=(8, 8))
    >>> plt.imshow(data, origin='lower', interpolation='nearest')

.. plot::

    import matplotlib.pyplot as plt
    from photutils.psf import CircularGaussianPRF, make_psf_model_image

    shape = (256, 256)
    fwhm = 4.7
    psf_model = CircularGaussianPRF(fwhm=fwhm)
    psf_shape = (11, 11)
    n_sources = 100
    flux = (500, 1000)
    border_size = (7, 7)
    data, stars = make_psf_model_image(shape, psf_model, n_sources,
                                       flux=flux,
                                       model_shape=psf_shape,
                                       border_size=border_size, seed=123)
    plt.figure(figsize=(8, 8))
    plt.imshow(data, origin='lower', interpolation='nearest')
    plt.show()


The `~photutils.psf.make_psf_model_image` provides two outputs: the
image itself, which we'll call ``data``, and a table containing the
positions and fluxes of the stars, which we'll call ``stars``. The x
and y coordinates of the stars are located in the ``x_0`` and ``y_0``
columns of this table.

With the simulated data ready, we can now identify groups of stars.
The first step is to create a `~photutils.psf.SourceGrouper` object.
For this example, we will define the minimum separation between stars
in different groups (``min_separation``) as 2.5 times the full width
at half maximum (FWHM). The FWHM value is known because it was used to
create the simulated stars. In a real-world scenario, you would first
need to measure the FWHM from the actual star images::

    >>> from photutils.psf import SourceGrouper
    >>> fwhm = 4.7
    >>> min_separation = 2.5 * fwhm
    >>> grouper = SourceGrouper(min_separation)

After initializing the `~photutils.psf.SourceGrouper`, we apply it to
the x and y coordinates of the stars. While we are using the known, true
positions from our simulated data, you would typically use a source
detection tool to find the star positions in an actual image::

   >>> import numpy as np
   >>> x = np.array(stars['x_0'])
   >>> y = np.array(stars['y_0'])
   >>> groups = grouper(x, y)

The result of this process, which we call ``groups``, is an array of
integers that corresponds to the input star coordinates. Each integer
represents a group ID, so stars that have the same number belong to the
same group.

The grouping algorithm separated the 100 stars into 65 distinct groups:

.. doctest-skip::

    >>> print(max(groups))
    65

For example, to find the positions of the stars in group 3::

   >>> mask = groups == 3
   >>> x[mask], y[mask]
   (array([60.32708921, 58.73063714]), array([147.24184586, 158.0612346 ]))

When performing PSF photometry, you can add these group IDs to
the initial parameters table (``init_params``) that is passed
to the photometry tool. If you provide these group IDs, a
`~photutils.psf.SourceGrouper` does not need to be passed to the
photometry class, as the grouping will already be defined.

To visualize the results, we can draw a circle around each star, using a
unique color for the circles in each group to show which stars have been
grouped together.

.. doctest-skip::

    >>> import numpy as np
    >>> from photutils.aperture import CircularAperture
    >>> from photutils.utils import make_random_cmap
    >>> plt.imshow(data, origin='lower', interpolation='nearest',
    ...            cmap='Greys_r')
    >>> cmap = make_random_cmap(seed=123)
    >>> for i in np.arange(1, max(groups) + 1):
    >>>     mask = groups == i
    >>>     xypos = zip(x[mask], y[mask])
    >>>     ap = CircularAperture(xypos, r=fwhm)
    >>>     ap.plot(color=cmap.colors[i], lw=2)
    >>> plt.show()

.. plot::

    import matplotlib.pyplot as plt
    import numpy as np
    from photutils.aperture import CircularAperture
    from photutils.psf import (CircularGaussianPRF, SourceGrouper,
                               make_psf_model_image)
    from photutils.utils import make_random_cmap

    shape = (256, 256)
    psf_shape = (11, 11)
    border_size = (7, 7)
    flux = (500, 1000)
    fwhm = 4.7
    psf_model = CircularGaussianPRF(fwhm=fwhm)
    n_sources = 100
    data, stars = make_psf_model_image(shape, psf_model, n_sources,
                                       flux=flux,
                                       model_shape=psf_shape,
                                       border_size=border_size, seed=123)

    min_separation = 2.5 * fwhm
    grouper = SourceGrouper(min_separation)

    x = np.array(stars['x_0'])
    y = np.array(stars['y_0'])
    groups = grouper(x, y)

    plt.figure(figsize=(8, 8))
    plt.imshow(data, origin='lower', interpolation='nearest', cmap='Greys_r')
    cmap = make_random_cmap(seed=123)
    for i in np.arange(1, max(groups) + 1):
        mask = groups == i
        xypos = zip(x[mask], y[mask])
        ap = CircularAperture(xypos, r=fwhm)
        ap.plot(color=cmap.colors[i], lw=2)

    plt.show()
