"""
Python code for calculating non-parametric morphological diagnostics
of galaxy images.
"""
# Author: Vicente Rodriguez-Gomez <vrodgom.astro@gmail.com>
# Licensed under a 3-Clause BSD License.
import warnings
import time
import numpy as np
import scipy.optimize as opt
import scipy.ndimage as ndi
import scipy.signal
import skimage.measure
import skimage.transform
import skimage.feature
import skimage.segmentation
from astropy.utils import lazyproperty
from astropy.stats import (sigma_clipped_stats, akaike_info_criterion_lsq,
bayesian_info_criterion_lsq)
from astropy.modeling import models, fitting, Fittable2DModel, Parameter
from astropy.utils.exceptions import (AstropyUserWarning,
AstropyDeprecationWarning)
from astropy.convolution import convolve
from photutils.aperture import (CircularAperture, CircularAnnulus,
EllipticalAperture, EllipticalAnnulus)
from photutils.background import ModeEstimatorBackground
from photutils.segmentation import SegmentationImage
__all__ = [
'ConvolvedSersic2D',
'DoubleSersic2D',
'ConvolvedDoubleSersic2D',
'SourceMorphology',
'source_morphology',
'__version__',
]
__version__ = '0.6.1'
# A list of the quantities calculated by SourceMorphology,
# excluding the double Sersic parameters:
_quantity_names = [
'xc_centroid',
'yc_centroid',
'ellipticity_centroid',
'elongation_centroid',
'orientation_centroid',
'xc_asymmetry',
'yc_asymmetry',
'ellipticity_asymmetry',
'elongation_asymmetry',
'orientation_asymmetry',
'flux_circ',
'flux_ellip',
'rpetro_circ',
'rpetro_ellip',
'rmax_circ',
'rmax_ellip',
'rhalf_circ',
'rhalf_ellip',
'r20',
'r50',
'r80',
'gini',
'm20',
'gini_m20_bulge',
'gini_m20_merger',
'sn_per_pixel',
'concentration',
'asymmetry',
'smoothness',
'multimode',
'intensity',
'deviation',
'rms_asymmetry2',
'outer_asymmetry',
'shape_asymmetry',
'sersic_amplitude',
'sersic_rhalf',
'sersic_n',
'sersic_xc',
'sersic_yc',
'sersic_ellip',
'sersic_theta',
'sersic_chi2_dof',
'sersic_aic',
'sersic_bic',
'sky_mean',
'sky_median',
'sky_sigma',
'xmin_stamp',
'ymin_stamp',
'xmax_stamp',
'ymax_stamp',
'nx_stamp',
'ny_stamp',
]
# A list of the quantities generated by the double Sersic fit:
_doublesersic_quantity_names = [
'doublesersic_xc',
'doublesersic_yc',
'doublesersic_amplitude1',
'doublesersic_rhalf1',
'doublesersic_n1',
'doublesersic_ellip1',
'doublesersic_theta1',
'doublesersic_amplitude2',
'doublesersic_rhalf2',
'doublesersic_n2',
'doublesersic_ellip2',
'doublesersic_theta2',
'doublesersic_chi2_dof',
'doublesersic_aic',
'doublesersic_bic',
]
def _quantile(sorted_values, q):
"""
For a sorted (in increasing order) 1-d array, return the value
corresponding to the quantile ``q``.
Notes
-----
The result is identical to np.percentile(..., method='lower'),
but the currently defined function is infinitely faster for sorted arrays.
"""
# Only scalars or length-1 arrays are supported:
if hasattr(q, '__len__'):
assert len(q) == 1
q = q[0]
if q < 0 or q > 1:
raise ValueError('Quantiles must be in the range [0, 1].')
return sorted_values[int(q*(len(sorted_values)-1))]
def _aperture_area(ap, mask, **kwargs):
"""
Calculate the area of a photutils aperture object,
excluding masked pixels.
"""
return ap.do_photometry(np.float64(~mask), **kwargs)[0][0]
def _aperture_mean_nomask(ap, image, **kwargs):
"""
Calculate the mean flux of an image for a given photutils
aperture object. Note that we do not use ``_aperture_area``
here. Instead, we divide by the full area of the
aperture, regardless of masked and out-of-range pixels.
This avoids problems when the aperture is larger than the
region of interest.
"""
return ap.do_photometry(image, **kwargs)[0][0] / ap.area
def _fraction_of_total_function_circ(r, image, center, fraction, total_sum):
"""
Helper function to calculate ``_radius_at_fraction_of_total_circ``.
"""
assert (r >= 0) & (fraction >= 0) & (fraction <= 1) & (total_sum > 0)
if r == 0:
cur_fraction = 0.0
else:
ap = CircularAperture(center, r)
# Force flux sum to be positive:
ap_sum = np.abs(ap.do_photometry(image, method='exact')[0][0])
cur_fraction = ap_sum / total_sum
return cur_fraction - fraction
def _radius_at_fraction_of_total_circ(image, center, r_total, fraction):
"""
Return the radius (in pixels) of a concentric circle that
contains a given fraction of the light within ``r_total``.
"""
flag = 0
ap_total = CircularAperture(center, r_total)
total_sum = ap_total.do_photometry(image, method='exact')[0][0]
if total_sum == 0:
warnings.warn('[r_circ] Total flux sum is zero.', AstropyUserWarning)
flag = 2
return r_total, flag
elif total_sum < 0:
warnings.warn('[r_circ] Total flux sum is negative.', AstropyUserWarning)
flag = 2
total_sum = np.abs(total_sum)
# Find appropriate range for root finder
npoints = 100
r_grid = np.linspace(0.0, r_total, num=npoints)
i = 0 # initial value
while True:
assert i < npoints, 'Root not found within range.'
r = r_grid[i]
curval = _fraction_of_total_function_circ(
r, image, center, fraction, total_sum)
if curval <= 0:
r_min = r
elif curval > 0:
r_max = r
break
i += 1
r = opt.brentq(_fraction_of_total_function_circ, r_min, r_max,
args=(image, center, fraction, total_sum), xtol=1e-6)
return r, flag
def _fraction_of_total_function_ellip(a, image, center, elongation, theta,
fraction, total_sum):
"""
Helper function to calculate ``_radius_at_fraction_of_total_ellip``.
"""
assert (a >= 0) & (fraction >= 0) & (fraction <= 1) & (total_sum > 0)
if a == 0:
cur_fraction = 0.0
else:
b = a / elongation
ap = EllipticalAperture(center, a, b, theta=theta)
# Force flux sum to be positive:
ap_sum = np.abs(ap.do_photometry(image, method='exact')[0][0])
cur_fraction = ap_sum / total_sum
return cur_fraction - fraction
def _radius_at_fraction_of_total_ellip(image, center, elongation, theta,
a_total, fraction):
"""
Return the semimajor axis (in pixels) of a concentric ellipse that
contains a given fraction of the light within a larger ellipse of
semimajor axis ``a_total``.
"""
flag = 0
b_total = a_total / elongation
ap_total = EllipticalAperture(
center, a_total, b_total, theta=theta)
total_sum = ap_total.do_photometry(image, method='exact')[0][0]
if total_sum == 0:
warnings.warn('[r_ellip] Total flux sum is zero.', AstropyUserWarning)
flag = 2
return a_total, flag
elif total_sum < 0:
warnings.warn('[r_ellip] Total flux sum is negative.', AstropyUserWarning)
flag = 2
total_sum = np.abs(total_sum)
# Find appropriate range for root finder
npoints = 100
a_grid = np.linspace(0.0, a_total, num=npoints)
i = 0 # initial value
while True:
assert i < npoints, 'Root not found within range.'
a = a_grid[i]
curval = _fraction_of_total_function_ellip(
a, image, center, elongation, theta, fraction, total_sum)
if curval <= 0:
a_min = a
elif curval > 0:
a_max = a
break
i += 1
a = opt.brentq(_fraction_of_total_function_ellip, a_min, a_max,
args=(image, center, elongation, theta, fraction, total_sum),
xtol=1e-6)
return a, flag
def _postfitting_fix(a, ellip, theta):
"""
In some rare cases, the ellipticity of the fitted Sersic models is
outside the range [0, 1]. While we could constrain this parameter
during the fit using the ``bounds`` keyword argument in `Sersic2D`,
a less aggressive solution is to create an equivalent model after
fitting by exploiting degeneracies between the ellipticity,
semimajor and semiminor axes, and position angle, as we do here.
"""
if a < 0: # (1)
# First of all, we make sure that the semimajor axis is positive
# (note that only a^2 appears in the equations in Sersic2D).
a *= -1
if ellip > 1: # (2)
# Note that the semiminor axis is given by
# b = (1 - ellip) * a.
# This would yield a negative value for b, but since only b^2
# is required, we can define an equivalent ellipticity as
# 1 - ellip' = -(1 - ellip) => ellip' = 2 - ellip.
ellip = 2.0 - ellip
if ellip < 0: # (3)
# This can be interpreted as a, b being flipped (a < b), so we
# switch them and define an equivalent ellipticity as
# 1 - ellip' = 1 / (1 - ellip) => ellip' = ellip / (ellip - 1).
a = (1 - ellip) * a
ellip = ellip / (ellip - 1)
# We also need to rotate the position angle by 90 degrees:
theta += np.pi / 2
# Finally, note that the "corrections" above are not mutually exclusive.
# If a Sersic model has ellipticity > 2, then steps (2) and (3) are
# applied successively.
return a, ellip, theta
class ConvolvedSersic2D(models.Sersic2D):
"""
Sersic2D model convolved with a PSF (provided by the user as a
numpy array).
See Also
--------
astropy.modeling.models.Sersic2D
"""
psf = None
def set_psf(self, psf):
"""
Specify the PSF to be convolved with the Sersic2D model.
"""
self.psf = psf / np.sum(psf) # make sure it's normalized
def evaluate(self, x, y, amplitude, r_eff, n, x_0, y_0, ellip, theta):
"""
Evaluate the ConvolvedSersic2D model.
"""
z = models.Sersic2D.evaluate(
x, y, amplitude, r_eff, n, x_0, y_0, ellip, theta)
if self.psf is None:
raise AssertionError('Must specify PSF using set_psf method.')
return scipy.signal.fftconvolve(z, self.psf, mode='same')
class DoubleSersic2D(Fittable2DModel):
"""
Custom class for a double 2D Sersic model. Note that the two
components share the same center.
"""
# Shared center
x_0 = Parameter(default=0)
y_0 = Parameter(default=0)
# First component
amplitude_1 = Parameter(default=1)
r_eff_1 = Parameter(default=1)
n_1 = Parameter(default=4) # de Vaucouleurs
ellip_1 = Parameter(default=0)
theta_1 = Parameter(default=0)
# Second component
amplitude_2 = Parameter(default=1)
r_eff_2 = Parameter(default=1)
n_2 = Parameter(default=1) # exponential
ellip_2 = Parameter(default=0)
theta_2 = Parameter(default=0)
@classmethod
def evaluate(cls, x, y, x_0, y_0,
amplitude_1, r_eff_1, n_1, ellip_1, theta_1,
amplitude_2, r_eff_2, n_2, ellip_2, theta_2):
"""
Evaluate the DoubleSersic2D model.
"""
return (
models.Sersic2D.evaluate(
x, y, amplitude_1, r_eff_1, n_1, x_0, y_0, ellip_1, theta_1)
+ models.Sersic2D.evaluate(
x, y, amplitude_2, r_eff_2, n_2, x_0, y_0, ellip_2, theta_2)
)
class ConvolvedDoubleSersic2D(DoubleSersic2D):
"""
DoubleSersic2D model convolved with a PSF (provided by the user
as a numpy array).
"""
psf = None
def set_psf(self, psf):
"""
Specify the PSF to be convolved with the DoubleSersic2D model.
"""
self.psf = psf / np.sum(psf) # make sure it's normalized
def evaluate(self, x, y, x_0, y_0,
amplitude_1, r_eff_1, n_1, ellip_1, theta_1,
amplitude_2, r_eff_2, n_2, ellip_2, theta_2):
"""
Evaluate the ConvolvedDoubleSersic2D model.
"""
z = DoubleSersic2D.evaluate(
x, y, x_0, y_0,
amplitude_1, r_eff_1, n_1, ellip_1, theta_1,
amplitude_2, r_eff_2, n_2, ellip_2, theta_2)
if self.psf is None:
raise AssertionError('Must specify PSF using set_psf method.')
return scipy.signal.fftconvolve(z, self.psf, mode='same')
[docs]
class SourceMorphology(object):
"""
Class to measure the morphological parameters of a single labeled
source. The parameters can be accessed as attributes or keys.
Parameters
----------
image : array-like
A 2D image containing the sources of interest.
The image must already be background-subtracted.
segmap : array-like (int) or ``photutils.segmentation.SegmentationImage``
A 2D segmentation map where different sources are
labeled with different positive integer values.
A value of zero is reserved for the background.
It is assumed that the sum of pixel values within each
labeled segment is positive.
label : int
A label indicating the source of interest.
mask : array-like (bool), optional
A 2D array with the same size as ``image``, where pixels
set to ``True`` are ignored from all calculations.
weightmap : array-like, optional
Also known as the "sigma" image, this is a 2D array with the
same size and units as ``image`` that contains one standard
deviation of the value at each pixel (which is related to the
Poisson noise). Note that SExtractor and other software
sometimes produce a weight map in units of the variance (RMS^2)
or the inverse variance (1/RMS^2).
gain : scalar, optional
A multiplication factor that converts the image units into
electrons/pixel, which is used internally to calculate the
weight map (i.e., the sigma-image) using Poisson statistics.
This parameter is only used when ``weightmap`` is not provided.
psf : array-like, optional
A 2D array representing the PSF, where the central pixel
corresponds to the center of the PSF. Typically, including
this keyword argument will make the code run slower by a
factor of a few, depending on the size of the PSF, but the
resulting Sersic fits will be more correct.
cutout_extent : float, optional
The target fractional size of the data cutout relative to
the minimal bounding box containing the source. The value
must be >= 1.
min_cutout_size : int, optional
The minimum size of the cutout, in case ``cutout_extent`` times
the size of the minimal bounding box is smaller than this.
Any given value will be truncated to an even number.
n_sigma_outlier : scalar, optional
The number of standard deviations that define a pixel as an
outlier, relative to its 8 neighbors. Outlying pixels are
removed as described in Lotz et al. (2004). If the value is zero
or negative, outliers are not removed. The default value is 10.
annulus_width : float, optional
The width (in pixels) of the annuli used to calculate the
Petrosian radius and other quantities. The default value is 1.0.
eta : float, optional
The Petrosian ``eta`` parameter used to define the Petrosian
radius. For a circular or elliptical aperture at the Petrosian
radius, the mean flux at the edge of the aperture divided by
the mean flux within the aperture is equal to ``eta``. The
default value is typically set to 0.2 (Petrosian 1976).
petro_fraction_gini : float, optional
In the Gini calculation, this is the fraction of the Petrosian
"radius" used as a smoothing scale in order to define the pixels
that belong to the galaxy. The default value is 0.2.
skybox_size : int, optional
The target size (in pixels) of the "skybox" used to characterize
the sky background.
petro_extent_cas : float, optional
The radius of the circular aperture used for the asymmetry
calculation, in units of the circular Petrosian radius. The
default value is 1.5.
petro_fraction_cas : float, optional
In the CAS calculations, this is the fraction of the Petrosian
radius used as a smoothing scale. The default value is 0.25.
boxcar_size_mid : float, optional
In the MID calculations, this is the size (in pixels)
of the constant kernel used to regularize the MID segmap.
The default value is 3.0.
niter_bh_mid : int, optional
When calculating the multimode statistic, this is the number of
iterations in the basin-hopping stage of the maximization.
A value of at least 100 is recommended for "production" runs,
but it can be time-consuming. The default value is 5.
sigma_mid : float, optional
In the MID calculations, this is the smoothing scale (in pixels)
used to compute the intensity (I) statistic. The default is 1.0.
petro_extent_flux : float, optional
The number of Petrosian radii used to define the aperture over
which the flux is measured. This is also used to define the inner
"radius" of the elliptical aperture used to estimate the sky
background in the shape asymmetry calculation. Following SDSS,
the default value is 2.0.
boxcar_size_shape_asym : float, optional
When calculating the shape asymmetry segmap, this is the size
(in pixels) of the constant kernel used to regularize the segmap.
The default value is 3.0.
sersic_fitting_args : dict, optional
A dictionary of keyword arguments passed to Astropy's
`LevMarLSQFitter`, which cannot include ``z`` or ``weights``
(these are handled internally by statmorph).
sersic_model_args : dict, optional
A dictionary of keyword arguments passed to Astropy's
`Sersic2D`. Note that, by default, statmorph will make reasonable
initial guesses for all the model parameters (recommended),
although this functionality can be overridden for more
customized fits. Also note that, by default, statmorph imposes
a lower bound of n = 0.01 for the fitted Sersic index.
sersic_maxiter : int, optional
Deprecated. Please use ``sersic_fitting_args`` instead.
include_doublesersic : bool, optional
If ``True``, also fit a double 2D Sersic model. The default is
``False``.
doublesersic_rsep_over_rhalf : float, optional
This specifies the "boundary" used to separate the inner and outer
regions of the image, which are used to perform single Sersic fits
and thus construct an initial guess for the double Sersic fit.
This argument is provided as a multiple of the (non-parametric)
half-light semimajor axis ``rhalf_ellip``. The default value is 2.0.
doublesersic_tied_ellip : bool, optional
If True, both components of the double Sersic model share the
same ellipticity and position angle. The same effect could be
achieved using doublesersic_model_args in combination with the
'tied' argument, although the syntax would be slightly more
involved. The default value is ``False``.
doublesersic_fitting_args : dict, optional
Same as sersic_fitting_args, but for the double 2D Sersic fit.
doublesersic_model_args : dict, optional
Same as sersic_model_args, but for the double 2D Sersic fit.
segmap_overlap_ratio : float, optional
The minimum ratio (in order to have a "good" measurement)
between the area of the intersection of the Gini and MID segmaps
and the area of the largest of these two segmaps.
verbose : bool, optional
If ``True``, this prints various minor warnings (which do not
result in "bad" measurements) during the calculations.
The default value is ``False``.
References
----------
See `README.rst` for a list of references.
"""
def __init__(self, image, segmap, label, mask=None, weightmap=None,
gain=None, psf=None, cutout_extent=2.5, min_cutout_size=48,
n_sigma_outlier=10, annulus_width=1.0,
eta=0.2, petro_fraction_gini=0.2, skybox_size=32,
petro_extent_cas=1.5, petro_fraction_cas=0.25,
boxcar_size_mid=3.0, niter_bh_mid=5, sigma_mid=1.0,
petro_extent_flux=2.0, boxcar_size_shape_asym=3.0,
sersic_fitting_args=None, sersic_model_args=None,
sersic_maxiter=None, include_doublesersic=False,
doublesersic_rsep_over_rhalf=2.0,
doublesersic_tied_ellip=False, doublesersic_fitting_args=None,
doublesersic_model_args=None, segmap_overlap_ratio=0.25,
verbose=False):
self._image = image
self._segmap = segmap
self.label = label
self._mask = mask
self._weightmap = weightmap
self._gain = gain
self._psf = psf
self._cutout_extent = cutout_extent
self._min_cutout_size = min_cutout_size - min_cutout_size % 2
self._n_sigma_outlier = n_sigma_outlier
self._annulus_width = annulus_width
self._eta = eta
self._petro_fraction_gini = petro_fraction_gini
self._skybox_size = skybox_size
self._petro_extent_cas = petro_extent_cas
self._petro_fraction_cas = petro_fraction_cas
self._boxcar_size_mid = boxcar_size_mid
self._niter_bh_mid = niter_bh_mid
self._sigma_mid = sigma_mid
self._petro_extent_flux = petro_extent_flux
self._boxcar_size_shape_asym = boxcar_size_shape_asym
self._sersic_fitting_args = sersic_fitting_args
self._sersic_model_args = sersic_model_args
self._sersic_maxiter = sersic_maxiter
self._include_doublesersic = include_doublesersic
self._doublesersic_rsep_over_rhalf = doublesersic_rsep_over_rhalf
self._doublesersic_tied_ellip = doublesersic_tied_ellip
self._doublesersic_fitting_args = doublesersic_fitting_args
self._doublesersic_model_args = doublesersic_model_args
self._segmap_overlap_ratio = segmap_overlap_ratio
self._verbose = verbose
# Measure runtime
start = time.time()
if not isinstance(self._segmap, SegmentationImage):
self._segmap = SegmentationImage(self._segmap)
# Check sanity of input data
self._segmap.check_labels([self.label])
assert self._segmap.data.shape == self._image.shape
if self._mask is not None:
assert self._mask.shape == self._image.shape
assert self._mask.dtype == np.bool_
if self._weightmap is not None:
assert self._weightmap.shape == self._image.shape
if self._weightmap is None and self._gain is None:
raise AssertionError('Must provide either weightmap or gain.')
# These flags will be modified during the calculations:
self.flag = 0 # attempts to flag bad measurements (0-4)
self.flag_sersic = 0 # attempts to flag bad Sersic fits (0 or 1)
if self._include_doublesersic:
self.flag_doublesersic = 0 # flag bad double Sersic fits (0 or 1)
# Check that the labeled galaxy segment has a positive flux sum.
# If not, this is bad enough to abort all calculations and return
# an empty object.
if np.sum(self._cutout_stamp_maskzeroed_no_bg) <= 0:
warnings.warn('Total flux is nonpositive. Returning empty object.',
AstropyUserWarning)
self._abort_calculations()
return
# Centroid of the source relative to the "postage stamp" cutout:
self._xc_stamp = self.xc_centroid - self.xmin_stamp
self._yc_stamp = self.yc_centroid - self.ymin_stamp
# Print warning if centroid is masked:
ic, jc = int(self._yc_stamp), int(self._xc_stamp)
if self._cutout_stamp_maskzeroed[ic, jc] == 0:
warnings.warn('Centroid is masked.', AstropyUserWarning)
self.flag = 2
# Position of the source's brightest pixel relative to the stamp cutout:
maxval = np.max(self._cutout_stamp_maskzeroed_no_bg)
maxval_stamp_pos = np.argwhere(self._cutout_stamp_maskzeroed_no_bg == maxval)[0]
self._x_maxval_stamp = maxval_stamp_pos[1]
self._y_maxval_stamp = maxval_stamp_pos[0]
# --------------------------------------------------------------------
# NOTE: most morphology calculations have not been performed yet, but
# this will change below this line. Modify this __init__ with care.
# --------------------------------------------------------------------
# For simplicity, evaluate all "lazy" properties at once:
self._calculate_morphology()
# Check if image is background-subtracted; set flag = 2 (bad) if not.
if np.abs(self.sky_mean) > self.sky_sigma:
warnings.warn('Image is not background-subtracted.', AstropyUserWarning)
self.flag = 2
# Check segmaps and set flag = 1 (suspect) if they are very different
self._check_segmaps()
# Save runtime
self.runtime = time.time() - start
def __getitem__(self, key):
return getattr(self, key)
def _get_badpixels(self, image):
"""
Detect outliers (bad pixels) as described in Lotz et al. (2004).
Notes
-----
ndi.generic_filter(image, np.std, ...) is too slow,
so we do a workaround using ndi.convolve.
"""
# Pixel weights, excluding central pixel.
w = np.array([
[1, 1, 1],
[1, 0, 1],
[1, 1, 1]], dtype=image.dtype)
w = w / np.sum(w)
# Use the fact that var(x) = <x^2> - <x>^2.
local_mean = convolve(image, w, boundary='extend',
normalize_kernel=False, nan_treatment='fill', fill_value=0)
local_mean2 = convolve(image**2, w, boundary='extend',
normalize_kernel=False, nan_treatment='fill', fill_value=0)
local_std = np.sqrt(local_mean2 - local_mean**2)
# Get "bad pixels"
badpixels = (np.abs(image - local_mean) >
self._n_sigma_outlier * local_std)
return badpixels
def _abort_calculations(self):
"""
Some cases are so bad that basically nothing can be measured
(e.g. a bunch of pixels with a nonpositive total sum). We
deal with these cases by creating an "empty" object and
interrupting the constructor.
"""
for q in _quantity_names:
setattr(self, q, -99.0)
self.nx_stamp = -99
self.ny_stamp = -99
self.flag = 4 # catastrophic
self.flag_sersic = 4
self.runtime = -99.0
self.sersic_runtime = -99.0
if self._include_doublesersic:
self.flag_doublesersic = 4
self.doublesersic_runtime = -99.0
def _calculate_morphology(self):
"""
Calculate morphological parameters and store them
as "lazy" properties.
"""
for q in _quantity_names:
_ = self[q]
if self._include_doublesersic:
for q in _doublesersic_quantity_names:
_ = self[q]
def _check_segmaps(self):
"""
Compare Gini segmap and MID segmap; set flag = 1 (suspect) if they are
very different from each other.
"""
area_max = max(np.sum(self._segmap_gini),
np.sum(self._segmap_mid))
area_overlap = np.sum(self._segmap_gini &
self._segmap_mid)
if area_max == 0:
warnings.warn('Segmaps are empty!', AstropyUserWarning)
self.flag = 2 # bad
return
area_ratio = area_overlap / float(area_max)
if area_ratio < self._segmap_overlap_ratio:
if self._verbose:
warnings.warn('Gini and MID segmaps are quite different.',
AstropyUserWarning)
self.flag = max(self.flag, 1)
@lazyproperty
def _centroid(self):
"""
The (xc, yc) centroid of the input segment, relative to
``_slice_stamp``.
"""
image = np.float64(self._cutout_stamp_maskzeroed_no_bg) # skimage wants double
# Calculate centroid
M = skimage.measure.moments(image, order=1)
assert M[0, 0] > 0 # already checked by constructor
yc = M[1, 0] / M[0, 0]
xc = M[0, 1] / M[0, 0]
ny, nx = self._cutout_stamp_maskzeroed_no_bg.shape
if (yc < 0) or (yc >= ny) or (xc < 0) or (xc >= nx):
warnings.warn('Centroid is out-of-range. Fixing at center of ' +
'postage stamp (bad!).', AstropyUserWarning)
yc = ny / 2.0
xc = nx / 2.0
self.flag = 2
return np.array([xc, yc])
@lazyproperty
def xc_centroid(self):
"""
The x-coordinate of the centroid, relative to the original image.
"""
return self._centroid[0] + self.xmin_stamp
@lazyproperty
def yc_centroid(self):
"""
The y-coordinate of the centroid, relative to the original image.
"""
return self._centroid[1] + self.ymin_stamp
def _covariance_generic(self, xc, yc):
"""
The covariance matrix of a Gaussian function that has the same
second-order moments as the source, with respect to ``(xc, yc)``.
"""
# skimage wants double precision:
image = np.float64(self._cutout_stamp_maskzeroed_no_bg)
# Calculate moments w.r.t. given center
xc = xc - self.xmin_stamp
yc = yc - self.ymin_stamp
Mc = skimage.measure.moments_central(image, center=(yc, xc), order=2)
assert Mc[0, 0] > 0
covariance = np.array([
[Mc[0, 2], Mc[1, 1]],
[Mc[1, 1], Mc[2, 0]]])
covariance /= Mc[0, 0] # normalize
# If there are nonpositive second moments, we deal with them,
# but we indicate that there's something wrong with the data.
if (covariance[0, 0] <= 0) or (covariance[1, 1] <= 0):
warnings.warn('Nonpositive second moment.', AstropyUserWarning)
self.flag = 2
# Modify covariance matrix in case of "infinitely thin" sources
# by iteratively increasing the diagonal elements (see SExtractor
# manual, eq. 43). Note that we allow negative moments.
rho = 1.0 / 12.0 # variance of 1 pixel-wide top-hat distribution
x2, xy, xy, y2 = covariance.flat
while np.abs(x2*y2 - xy**2) < rho**2:
x2 += (x2 >= 0) * rho - (x2 < 0) * rho # np.sign(0) == 0 is no good
y2 += (y2 >= 0) * rho - (y2 < 0) * rho
covariance = np.array([[x2, xy],
[xy, y2]])
return covariance
def _eigvals_generic(self, covariance):
"""
The ordered (largest first) eigenvalues of the covariance
matrix, which correspond to the *squared* semimajor and
semiminor axes. Note that we allow negative eigenvalues.
"""
eigvals = np.linalg.eigvals(covariance)
eigvals = np.sort(np.abs(eigvals))[::-1] # largest first (by abs. value)
# We deal with negative eigenvalues, but we indicate that something
# is not OK with the data (eigenvalues cannot be exactly zero after
# the SExtractor-like regularization routine).
if np.any(eigvals < 0):
warnings.warn('Some negative eigenvalues.', AstropyUserWarning)
self.flag = 2
return eigvals
@staticmethod
def _ellipticity_generic(eigvals):
"""
The ellipticity of (the Gaussian function that has the same
second-order moments as) the source. Note that we allow
negative eigenvalues.
"""
a = np.sqrt(np.abs(eigvals[0]))
b = np.sqrt(np.abs(eigvals[1]))
return 1.0 - (b / a)
@staticmethod
def _elongation_generic(eigvals):
"""
The elongation of (the Gaussian function that has the same
second-order moments as) the source. Note that we allow
negative eigenvalues.
"""
a = np.sqrt(np.abs(eigvals[0]))
b = np.sqrt(np.abs(eigvals[1]))
return a / b
@staticmethod
def _orientation_generic(covariance):
"""
The orientation (in radians) of the source.
"""
x2, xy, xy, y2 = covariance.flat
# SExtractor manual, eq. (21):
theta = 0.5 * np.arctan2(2.0 * xy, x2 - y2)
return theta
@lazyproperty
def _covariance_centroid(self):
"""
The covariance matrix of a Gaussian function that has the same
second-order moments as the source, with respect to the centroid.
"""
return self._covariance_generic(self.xc_centroid, self.yc_centroid)
@lazyproperty
def _eigvals_centroid(self):
"""
The ordered (largest first) eigenvalues of the covariance
matrix, which correspond to the *squared* semimajor and
semiminor axes, relative to the centroid.
"""
return self._eigvals_generic(self._covariance_centroid)
@lazyproperty
def ellipticity_centroid(self):
"""
The ellipticity of (the Gaussian function that has the same
second-order moments as) the source, relative to the centroid.
"""
return self._ellipticity_generic(self._eigvals_centroid)
@lazyproperty
def elongation_centroid(self):
"""
The elongation of (the Gaussian function that has the same
second-order moments as) the source, relative to the centroid.
"""
return self._elongation_generic(self._eigvals_centroid)
@lazyproperty
def orientation_centroid(self):
"""
The orientation (in radians) of the source, relative to the
centroid.
"""
return self._orientation_generic(self._covariance_centroid)
@lazyproperty
def _slice_stamp(self):
"""
Attempt to create a square slice that is somewhat larger
than the minimal bounding box containing the labeled segment.
Note that the cutout may not be square when the source is
close to a border of the original image.
"""
assert self._cutout_extent >= 1.0
assert self._min_cutout_size >= 2
# Get dimensions of original bounding box
s = self._segmap.slices[self._segmap.get_index(self.label)]
xmin, xmax = s[1].start, s[1].stop - 1
ymin, ymax = s[0].start, s[0].stop - 1
dx, dy = xmax + 1 - xmin, ymax + 1 - ymin
xc, yc = xmin + dx//2, ymin + dy//2
# Add some extra space in each dimension
dist = int(max(dx, dy) * self._cutout_extent / 2.0)
# Make sure that cutout size is at least ``min_cutout_size``
dist = max(dist, self._min_cutout_size // 2)
# Make cutout
ny, nx = self._image.shape
slice_stamp = (slice(max(0, yc-dist), min(ny, yc+dist)),
slice(max(0, xc-dist), min(nx, xc+dist)))
return slice_stamp
@lazyproperty
def xmin_stamp(self):
"""
The minimum ``x`` position of the 'postage stamp'.
"""
return self._slice_stamp[1].start
@lazyproperty
def ymin_stamp(self):
"""
The minimum ``y`` position of the 'postage stamp'.
"""
return self._slice_stamp[0].start
@lazyproperty
def xmax_stamp(self):
"""
The maximum ``x`` position of the 'postage stamp'.
"""
return self._slice_stamp[1].stop - 1
@lazyproperty
def ymax_stamp(self):
"""
The maximum ``y`` position of the 'postage stamp'.
"""
return self._slice_stamp[0].stop - 1
@lazyproperty
def nx_stamp(self):
"""
Number of pixels in the 'postage stamp' along the ``x`` direction.
"""
return self.xmax_stamp + 1 - self.xmin_stamp
@lazyproperty
def ny_stamp(self):
"""
Number of pixels in the 'postage stamp' along the ``y`` direction.
"""
return self.ymax_stamp + 1 - self.ymin_stamp
@lazyproperty
def _mask_stamp(self):
"""
Create a total binary mask for the "postage stamp".
Pixels belonging to other sources (as well as pixels masked
using the ``mask`` keyword argument) are set to ``True``,
but the background (segmap == 0) is left alone.
"""
# Flag any NaN or inf values within the postage stamp.
mask_stamp_nan = ~np.isfinite(self._image[self._slice_stamp])
if self._weightmap is not None:
mask_stamp_nan |= ~np.isfinite(self._weightmap[self._slice_stamp])
# Flag bad pixels (outliers).
self.num_badpixels = -1
mask_stamp_badpixels = np.zeros(
(self.ny_stamp, self.nx_stamp), dtype=np.bool_)
if self._n_sigma_outlier > 0:
mask_stamp_badpixels = self._get_badpixels(
self._image[self._slice_stamp])
self.num_badpixels = int(np.sum(mask_stamp_badpixels))
segmap_stamp = self._segmap.data[self._slice_stamp]
mask_stamp = (segmap_stamp != 0) & (segmap_stamp != self.label)
if self._mask is not None:
mask_stamp |= self._mask[self._slice_stamp]
mask_stamp |= mask_stamp_nan
mask_stamp |= mask_stamp_badpixels
return mask_stamp
@lazyproperty
def _mask_stamp_no_bg(self):
"""
Similar to ``_mask_stamp``, but also mask the background.
"""
segmap_stamp = self._segmap.data[self._slice_stamp]
return self._mask_stamp | (segmap_stamp == 0)
@lazyproperty
def _cutout_stamp_maskzeroed(self):
"""
Return a data cutout with its shape and position determined
by ``_slice_stamp``. Pixels belonging to other sources
(as well as pixels where ``mask`` == 1) are set to zero,
but the background is left alone.
In addition, NaN or inf values are removed at this point,
as well as badpixels (outliers).
"""
return np.where(~self._mask_stamp,
self._image[self._slice_stamp], 0.0)
@lazyproperty
def _cutout_stamp_maskzeroed_no_bg(self):
"""
Like ``_cutout_stamp_maskzeroed``, but also mask the
background.
"""
return np.where(~self._mask_stamp_no_bg,
self._image[self._slice_stamp], 0.0)
@lazyproperty
def _weightmap_stamp(self):
"""
Return a cutout of the weight map over the "postage stamp" region.
If a weightmap is not provided as input, it is created using the
``gain`` argument.
"""
if self._weightmap is None:
# Already checked that gain is not None:
assert self._gain > 0
weightmap_stamp = np.sqrt(
np.abs(self._image[self._slice_stamp])/self._gain + self.sky_sigma**2)
else:
weightmap_stamp = self._weightmap[self._slice_stamp]
weightmap_stamp[self._mask_stamp] = 0.0
return weightmap_stamp
@lazyproperty
def _sorted_pixelvals_stamp_no_bg(self):
"""
The sorted pixel values of the (zero-masked) postage stamp,
excluding masked values and the background.
"""
return np.sort(self._cutout_stamp_maskzeroed[~self._mask_stamp_no_bg])
@lazyproperty
def _cutout_stamp_maskzeroed_no_bg_nonnegative(self):
"""
Same as ``_cutout_stamp_maskzeroed_no_bg``, but masking
negative pixels.
"""
image = self._cutout_stamp_maskzeroed_no_bg
return np.where(image > 0, image, 0.0)
@lazyproperty
def _sorted_pixelvals_stamp_no_bg_nonnegative(self):
"""
Same as ``_sorted_pixelvals_stamp_no_bg``, but masking
negative pixels.
"""
image = self._cutout_stamp_maskzeroed_no_bg_nonnegative
return np.sort(image[~self._mask_stamp_no_bg])
@lazyproperty
def _diagonal_distance(self):
"""
Return the diagonal distance (in pixels) of the postage stamp.
This is used as an upper bound in some calculations.
"""
ny, nx = self._cutout_stamp_maskzeroed.shape
return np.sqrt(nx**2 + ny**2)
def _petrosian_function_circ(self, r, center):
"""
Helper function to calculate the circular Petrosian radius.
For a given radius ``r``, return the ratio of the mean flux
over a circular annulus divided by the mean flux within the
circle, minus "eta" (eq. 4 from Lotz et al. 2004). The root
of this function is the Petrosian radius.
"""
image = self._cutout_stamp_maskzeroed
r_in = r - 0.5 * self._annulus_width
r_out = r + 0.5 * self._annulus_width
circ_annulus = CircularAnnulus(center, r_in, r_out)
circ_aperture = CircularAperture(center, r)
# Force mean fluxes to be positive:
circ_annulus_mean_flux = np.abs(_aperture_mean_nomask(
circ_annulus, image, method='exact'))
circ_aperture_mean_flux = np.abs(_aperture_mean_nomask(
circ_aperture, image, method='exact'))
if circ_aperture_mean_flux == 0:
warnings.warn('[rpetro_circ] Mean flux is zero.', AstropyUserWarning)
# If flux within annulus is also zero (e.g. beyond the image
# boundaries), return zero. Otherwise return 1.0:
ratio = float(circ_annulus_mean_flux != 0)
self.flag = 2
else:
ratio = circ_annulus_mean_flux / circ_aperture_mean_flux
return ratio - self._eta
def _rpetro_circ_generic(self, center):
"""
Compute the Petrosian radius for concentric circular apertures.
Notes
-----
The so-called "curve of growth" is not always monotonic,
e.g., when there is a bright, unlabeled and unmasked
secondary source in the image, so we cannot just apply a
root-finding algorithm over the full interval.
Instead, we proceed in two stages: first we do a coarse,
brute-force search for an appropriate interval (that
contains a root), and then we apply the root-finder.
"""
# Find appropriate range for root finder
npoints = 100
r_inner = self._annulus_width
r_outer = self._diagonal_distance
assert r_inner < r_outer
r_min, r_max = None, None
for r in np.linspace(r_inner, r_outer, npoints):
curval = self._petrosian_function_circ(r, center)
if curval == 0:
warnings.warn('[rpetro_circ] Found rpetro by chance?',
AstropyUserWarning)
return r
elif curval > 0: # we have not reached rpetro yet
r_min = r
else: # we are beyond rpetro
if r_min is None:
warnings.warn('rpetro_circ < annulus_width! ' +
'Setting rpetro_circ = annulus_width.',
AstropyUserWarning)
self.flag = 2
return r_inner
r_max = r
break
assert r_min is not None
if r_max is None:
warnings.warn('[rpetro_circ] rpetro too large.',
AstropyUserWarning)
self.flag = 2
r_max = r_outer
rpetro_circ = opt.brentq(self._petrosian_function_circ,
r_min, r_max, args=(center,), xtol=1e-6)
return rpetro_circ
@lazyproperty
def _rpetro_circ_centroid(self):
"""
Calculate the Petrosian radius with respect to the centroid.
This is only used as a preliminary value for the asymmetry
calculation.
"""
center = np.array([self._xc_stamp, self._yc_stamp])
return self._rpetro_circ_generic(center)
@lazyproperty
def rpetro_circ(self):
"""
Calculate the Petrosian radius with respect to the point
that minimizes the asymmetry.
"""
return self._rpetro_circ_generic(self._asymmetry_center)
@lazyproperty
def flux_circ(self):
"""
Return the sum of the pixel values over a circular aperture
with radius equal to ``petro_extent_flux`` (usually 2) times
the circular Petrosian radius.
"""
image = self._cutout_stamp_maskzeroed
r = self._petro_extent_flux * self.rpetro_circ
ap = CircularAperture(self._asymmetry_center, r)
# Force flux sum to be positive:
ap_sum = np.abs(ap.do_photometry(image, method='exact')[0][0])
return ap_sum
def _petrosian_function_ellip(self, a, center, elongation, theta):
"""
Helper function to calculate the Petrosian "radius".
For the ellipse with semi-major axis ``a``, return the
ratio of the mean flux over an elliptical annulus
divided by the mean flux within the ellipse,
minus "eta" (eq. 4 from Lotz et al. 2004). The root of
this function is the Petrosian "radius".
"""
image = self._cutout_stamp_maskzeroed
b = a / elongation
a_in = a - 0.5 * self._annulus_width
a_out = a + 0.5 * self._annulus_width
b_out = a_out / elongation
ellip_annulus = EllipticalAnnulus(
center, a_in, a_out, b_out, theta=theta)
ellip_aperture = EllipticalAperture(
center, a, b, theta=theta)
# Force mean fluxes to be positive:
ellip_annulus_mean_flux = np.abs(_aperture_mean_nomask(
ellip_annulus, image, method='exact'))
ellip_aperture_mean_flux = np.abs(_aperture_mean_nomask(
ellip_aperture, image, method='exact'))
if ellip_aperture_mean_flux == 0:
warnings.warn('[rpetro_ellip] Mean flux is zero.', AstropyUserWarning)
# If flux within annulus is also zero (e.g. beyond the image
# boundaries), return zero. Otherwise return 1.0:
ratio = float(ellip_annulus_mean_flux != 0)
self.flag = 2
else:
ratio = ellip_annulus_mean_flux / ellip_aperture_mean_flux
return ratio - self._eta
def _rpetro_ellip_generic(self, center, elongation, theta):
"""
Compute the Petrosian "radius" (actually the semi-major axis)
for concentric elliptical apertures.
Notes
-----
The so-called "curve of growth" is not always monotonic,
e.g., when there is a bright, unlabeled and unmasked
secondary source in the image, so we cannot just apply a
root-finding algorithm over the full interval.
Instead, we proceed in two stages: first we do a coarse,
brute-force search for an appropriate interval (that
contains a root), and then we apply the root-finder.
"""
# Find appropriate range for root finder
npoints = 100
a_inner = self._annulus_width
a_outer = self._diagonal_distance * elongation
assert a_inner < a_outer
a_min, a_max = None, None
for a in np.linspace(a_inner, a_outer, npoints):
curval = self._petrosian_function_ellip(a, center, elongation, theta)
if curval == 0:
warnings.warn('[rpetro_ellip] Found rpetro by chance?',
AstropyUserWarning)
return a
elif curval > 0: # we have not reached rpetro yet
a_min = a
else: # we are beyond rpetro
if a_min is None:
warnings.warn('rpetro_ellip < annulus_width! ' +
'Setting rpetro_ellip = annulus_width.',
AstropyUserWarning)
self.flag = 2
return a_inner
a_max = a
break
assert a_min is not None
if a_max is None:
warnings.warn('[rpetro_ellip] rpetro too large.',
AstropyUserWarning)
self.flag = 2
a_max = a_outer
rpetro_ellip = opt.brentq(self._petrosian_function_ellip, a_min, a_max,
args=(center, elongation, theta,), xtol=1e-6)
return rpetro_ellip
@lazyproperty
def rpetro_ellip(self):
"""
Return the elliptical Petrosian "radius", calculated with
respect to the point that minimizes the asymmetry.
"""
return self._rpetro_ellip_generic(
self._asymmetry_center, self.elongation_asymmetry,
self.orientation_asymmetry)
@lazyproperty
def flux_ellip(self):
"""
Return the sum of the pixel values over an elliptical aperture
with "radius" equal to ``petro_extent_flux`` (usually 2) times
the elliptical Petrosian "radius".
"""
image = self._cutout_stamp_maskzeroed
a = self._petro_extent_flux * self.rpetro_ellip
b = a / self.elongation_asymmetry
theta = self.orientation_asymmetry
ap = EllipticalAperture(
self._asymmetry_center, a, b, theta=theta)
# Force flux sum to be positive:
ap_sum = np.abs(ap.do_photometry(image, method='exact')[0][0])
return ap_sum
#######################
# Gini-M20 statistics #
#######################
@lazyproperty
def _segmap_gini(self):
"""
Create a new segmentation map (relative to the "postage stamp")
based on the elliptical Petrosian radius.
"""
# Smooth image
petro_sigma = self._petro_fraction_gini * self.rpetro_ellip
cutout_smooth = ndi.gaussian_filter(self._cutout_stamp_maskzeroed, petro_sigma)
# Use mean flux at the Petrosian "radius" as threshold
a_in = self.rpetro_ellip - 0.5 * self._annulus_width
a_out = self.rpetro_ellip + 0.5 * self._annulus_width
b_out = a_out / self.elongation_asymmetry
theta = self.orientation_asymmetry
ellip_annulus = EllipticalAnnulus(
(self._xc_stamp, self._yc_stamp), a_in, a_out, b_out, theta=theta)
ellip_annulus_mean_flux = _aperture_mean_nomask(
ellip_annulus, cutout_smooth, method='exact')
above_threshold = cutout_smooth >= ellip_annulus_mean_flux
# Grow regions with 8-connected neighbor "footprint"
s = ndi.generate_binary_structure(2, 2)
labeled_array, num_features = ndi.label(above_threshold, structure=s)
# In some rare cases (e.g., Pan-STARRS J020218.5+672123_g.fits.gz),
# this results in an empty segmap, so there is nothing to do.
if num_features == 0:
warnings.warn('[segmap_gini] Empty Gini segmap!',
AstropyUserWarning)
self.flag = 2 # bad
return above_threshold
# In other cases (e.g., object 110 from CANDELS/GOODS-S WFC/F160W),
# the Gini segmap occupies the entire image, which is also not OK.
if np.sum(above_threshold) == cutout_smooth.size:
warnings.warn('[segmap_gini] Full Gini segmap!',
AstropyUserWarning)
self.flag = 2 # bad
return above_threshold
# If more than one region, set flag = 1 (suspect) and
# only keep the segment that contains the brightest pixel.
if num_features > 1:
if self._verbose:
warnings.warn('[segmap_gini] Disjoint features in Gini segmap.',
AstropyUserWarning)
self.flag = max(self.flag, 1)
ic, jc = np.argwhere(cutout_smooth == np.max(cutout_smooth))[0]
assert labeled_array[ic, jc] != 0
segmap = labeled_array == labeled_array[ic, jc]
else:
segmap = above_threshold
return segmap
@lazyproperty
def gini(self):
"""
Calculate the Gini coefficient as described in Lotz et al. (2004).
"""
image = self._cutout_stamp_maskzeroed.flatten()
segmap = self._segmap_gini.flatten()
sorted_pixelvals = np.sort(np.abs(image[segmap]))
n = len(sorted_pixelvals)
if n <= 1 or np.sum(sorted_pixelvals) == 0:
warnings.warn('[gini] Not enough data for Gini calculation.',
AstropyUserWarning)
self.flag = 2
return -99.0 # invalid
indices = np.arange(1, n+1) # start at i=1
gini = (np.sum((2*indices-n-1) * sorted_pixelvals) /
(float(n-1) * np.sum(sorted_pixelvals)))
return gini
@lazyproperty
def m20(self):
"""
Calculate the M_20 coefficient as described in Lotz et al. (2004).
"""
if np.sum(self._segmap_gini) == 0:
return -99.0 # invalid
# Use the same region as in the Gini calculation
image = np.where(self._segmap_gini, self._cutout_stamp_maskzeroed, 0.0)
image = np.float64(image) # skimage wants double
# Calculate centroid
M = skimage.measure.moments(image, order=1)
if M[0, 0] <= 0:
warnings.warn('[deviation] Nonpositive flux within Gini segmap.',
AstropyUserWarning)
self.flag = 2
return -99.0 # invalid
yc = M[1, 0] / M[0, 0]
xc = M[0, 1] / M[0, 0]
# Calculate second total central moment
Mc = skimage.measure.moments_central(image, center=(yc, xc), order=2)
second_moment_tot = Mc[0, 2] + Mc[2, 0]
# Calculate threshold pixel value
sorted_pixelvals = np.sort(image.flatten())
flux_fraction = np.cumsum(sorted_pixelvals) / np.sum(sorted_pixelvals)
sorted_pixelvals_20 = sorted_pixelvals[flux_fraction >= 0.8]
if len(sorted_pixelvals_20) == 0:
# This can happen when there are very few pixels.
warnings.warn('[m20] Not enough data for M20 calculation.',
AstropyUserWarning)
self.flag = 2
return -99.0 # invalid
threshold = sorted_pixelvals_20[0]
# Calculate second moment of the brightest pixels
image_20 = np.where(image >= threshold, image, 0.0)
Mc_20 = skimage.measure.moments_central(image_20, center=(yc, xc), order=2)
second_moment_20 = Mc_20[0, 2] + Mc_20[2, 0]
if (second_moment_20 <= 0) | (second_moment_tot <= 0):
warnings.warn('[m20] Negative second moment(s).',
AstropyUserWarning)
self.flag = 2
m20 = -99.0 # invalid
else:
m20 = np.log10(second_moment_20 / second_moment_tot)
return m20
@lazyproperty
def gini_m20_bulge(self):
"""
Return the Gini-M20 bulge statistic, F(G, M20), as defined
in Rodriguez-Gomez et al. (2019).
"""
if (self.gini == -99.0) or (self.m20 == -99.0):
return -99.0 # invalid
return -0.693*self.m20 + 4.95*self.gini - 3.96
@lazyproperty
def gini_m20_merger(self):
"""
Return the Gini-M20 merger statistic, S(G, M20), as defined
in Rodriguez-Gomez et al. (2019).
"""
if (self.gini == -99.0) or (self.m20 == -99.0):
return -99.0 # invalid
return 0.139*self.m20 + 0.990*self.gini - 0.327
# @lazyproperty
# def sn_per_pixel(self):
# """
# Calculate the signal-to-noise per pixel using the Petrosian segmap.
# """
# weightmap = self._weightmap_stamp
# if np.any(weightmap < 0):
# warnings.warn('[sn_per_pixel] Some negative weightmap values.',
# AstropyUserWarning)
# weightmap = np.abs(weightmap)
#
# locs = self._segmap_gini & (self._cutout_stamp_maskzeroed >= 0)
# pixelvals = self._cutout_stamp_maskzeroed[locs]
# # The sky background noise is already included in the weightmap:
# snp = np.mean(pixelvals / weightmap[locs])
#
# if not np.isfinite(snp):
# warnings.warn('Invalid sn_per_pixel.', AstropyUserWarning)
# self.flag = 2
# snp = -99.0 # invalid
#
# return snp
@lazyproperty
def sn_per_pixel(self):
"""
Calculate the signal-to-noise per pixel using the Petrosian segmap.
"""
weightmap = self._weightmap_stamp
if np.any(weightmap < 0):
warnings.warn('[sn_per_pixel] Some negative weightmap values.',
AstropyUserWarning)
weightmap = np.abs(weightmap)
locs = (self._segmap_gini & (self._cutout_stamp_maskzeroed >= 0) &
(weightmap > 0))
if np.sum(locs) == 0:
warnings.warn('Invalid sn_per_pixel.', AstropyUserWarning)
self.flag = 2
snp = -99.0 # invalid
else:
pixelvals = self._cutout_stamp_maskzeroed[locs]
# The sky background noise is already included in the weightmap:
snp = np.mean(pixelvals / weightmap[locs])
return snp
##################
# CAS statistics #
##################
@lazyproperty
def _slice_skybox(self):
"""
Try to find a region of the sky that only contains background.
In principle, a more accurate approach is possible
(e.g. Shi et al. 2009, ApJ, 697, 1764).
"""
segmap = self._segmap.data[self._slice_stamp]
ny, nx = segmap.shape
mask = np.zeros(segmap.shape, dtype=np.bool_)
if self._mask is not None:
mask = self._mask[self._slice_stamp]
assert self._skybox_size >= 2
cur_skybox_size = self._skybox_size
while True:
for i in range(0, ny - cur_skybox_size):
for j in range(0, nx - cur_skybox_size):
boxslice = (slice(i, i + cur_skybox_size),
slice(j, j + cur_skybox_size))
if np.all(segmap[boxslice] == 0) and np.all(~mask[boxslice]):
return boxslice
# If we got here, a skybox of the given size was not found.
if cur_skybox_size <= 2:
warnings.warn('[skybox] Skybox not found.', AstropyUserWarning)
self.flag = 2
return slice(0, 0), slice(0, 0)
else:
cur_skybox_size //= 2
if self._verbose:
warnings.warn('[skybox] Reducing skybox size to %d.' % (
cur_skybox_size), AstropyUserWarning)
@lazyproperty
def sky_mean(self):
"""
Mean background value. Equal to -99.0 when there is no skybox.
"""
bkg = self._cutout_stamp_maskzeroed[self._slice_skybox]
if bkg.size == 0:
assert self.flag == 2
return -99.0
return np.mean(bkg)
@lazyproperty
def sky_median(self):
"""
Median background value. Equal to -99.0 when there is no skybox.
"""
bkg = self._cutout_stamp_maskzeroed[self._slice_skybox]
if bkg.size == 0:
assert self.flag == 2
return -99.0
return np.median(bkg)
@lazyproperty
def sky_sigma(self):
"""
Standard deviation of the background. Equal to -99.0 when there
is no skybox.
"""
bkg = self._cutout_stamp_maskzeroed[self._slice_skybox]
if bkg.size == 0:
assert self.flag == 2
return -99.0
return np.std(bkg)
@lazyproperty
def _sky_asymmetry(self):
"""
Asymmetry of the background. Equal to -99.0 when there is no
skybox. Note the peculiar normalization (for reference only).
"""
bkg = self._cutout_stamp_maskzeroed[self._slice_skybox]
bkg_180 = bkg[::-1, ::-1]
if bkg.size == 0:
assert self.flag == 2
return -99.0
return np.sum(np.abs(bkg_180 - bkg)) / float(bkg.size)
@lazyproperty
def _sky_smoothness(self):
"""
Smoothness of the background. Equal to -99.0 when there is no
skybox. Note the peculiar normalization (for reference only).
"""
bkg = self._cutout_stamp_maskzeroed[self._slice_skybox]
if bkg.size == 0:
assert self.flag == 2
return -99.0
# If the smoothing "boxcar" is larger than the skybox itself,
# this just sets all values equal to the mean:
boxcar_size = int(self._petro_fraction_cas * self.rpetro_circ)
bkg_smooth = ndi.uniform_filter(bkg, size=boxcar_size)
bkg_diff = bkg - bkg_smooth
bkg_diff[bkg_diff < 0] = 0.0 # set negative pixels to zero
return np.sum(bkg_diff) / float(bkg.size)
def _asymmetry_function(self, center, image, kind):
"""
Helper function to determine the asymmetry and center of asymmetry.
The idea is to minimize the output of this function.
Parameters
----------
center : tuple or array-like
The (x,y) position of the center.
image : array-like
The 2D image.
kind : {'cas', 'rms', 'outer', 'shape'}
Whether to calculate the traditional CAS asymmetry (default),
RMS asymmetry, outer asymmetry, or shape asymmetry.
Returns
-------
asym : The asymmetry statistic for the given center.
"""
image = np.float64(image) # skimage wants double
ny, nx = image.shape
xc, yc = center
if xc < 0 or xc >= nx or yc < 0 or yc >= ny:
warnings.warn('[asym_center] Minimizer tried to exit bounds.',
AstropyUserWarning)
self.flag = max(self.flag, 1)
# Return large value to keep minimizer within range:
return 100.0
# Rotate around given center
image_180 = skimage.transform.rotate(image, 180.0, center=center)
# Apply symmetric mask
mask = self._mask_stamp.copy()
mask_180 = skimage.transform.rotate(mask, 180.0, center=center)
mask_180 = mask_180 >= 0.5 # convert back to bool
mask_symmetric = mask | mask_180
image = np.where(~mask_symmetric, image, 0.0)
image_180 = np.where(~mask_symmetric, image_180, 0.0)
# Create aperture for the chosen kind of asymmetry
if kind == 'cas' or kind == 'rms':
r = self._petro_extent_cas * self._rpetro_circ_centroid
ap = CircularAperture(center, r)
elif kind == 'outer':
a_in = self.rhalf_ellip
a_out = self.rmax_ellip
b_out = a_out / self.elongation_asymmetry
theta = self.orientation_asymmetry
assert (a_in > 0) & (a_out > 0)
ap = EllipticalAnnulus(center, a_in, a_out, b_out, theta=theta)
elif kind == 'shape':
if np.isnan(self.rmax_circ) or (self.rmax_circ <= 0):
warnings.warn('[shape_asym] Invalid rmax_circ value.',
AstropyUserWarning)
self.flag = 2
return -99.0 # invalid
ap = CircularAperture(center, self.rmax_circ)
else:
raise NotImplementedError('Asymmetry kind not understood:', kind)
# Aperture area (in pixels)
ap_area = _aperture_area(ap, mask_symmetric)
# Apply eq. 10 from Lotz et al. (2004)
ap_abs_sum = ap.do_photometry(np.abs(image), method='exact')[0][0]
ap_abs_diff = ap.do_photometry(np.abs(image_180-image), method='exact')[0][0]
if ap_abs_sum == 0.0:
warnings.warn('[asymmetry_function] Zero flux sum.',
AstropyUserWarning)
self.flag = max(self.flag, 1)
# Return large value to get minimizer out of masked region:
return 100.0
if kind == 'shape':
# The shape asymmetry of the background is zero
asym = ap_abs_diff / ap_abs_sum
elif kind == 'rms':
# Apply eq. 27 from Sazonova et al. (2024)
ap_sqr_sum = ap.do_photometry(image**2, method='exact')[0][0]
ap_sqr_diff = ap.do_photometry((image_180-image)**2, method='exact')[0][0]
if self.sky_sigma == -99.0: # invalid skybox
asym = ap_sqr_diff / ap_sqr_sum
else:
asym = (ap_sqr_diff - 2*ap_area*self.sky_sigma**2) / (
ap_sqr_sum - ap_area*self.sky_sigma**2)
else:
if self._sky_asymmetry == -99.0: # invalid skybox
asym = ap_abs_diff / ap_abs_sum
else:
asym = (ap_abs_diff - ap_area*self._sky_asymmetry) / ap_abs_sum
return asym
@lazyproperty
def _asymmetry_center(self):
"""
Find the position of the central pixel (relative to the
"postage stamp" cutout) that minimizes the (CAS) asymmetry.
"""
centroid = np.array([self._xc_stamp, self._yc_stamp]) # initial guess
center_asym = opt.fmin(self._asymmetry_function, centroid,
args=(self._cutout_stamp_maskzeroed, 'cas'),
xtol=1e-6, disp=False)
# If the asymmetry center drifted too far away from the centroid,
# try again with the brightest pixel as the starting point.
dist = self._petro_fraction_cas * self._rpetro_circ_centroid
if np.linalg.norm(center_asym - centroid) > dist:
warnings.warn('Asymmetry center drifted too far away from the ' +
'centroid. Trying again with the brightest pixel.',
AstropyUserWarning)
self.flag = max(self.flag, 1)
center_brightest = np.array([self._x_maxval_stamp,
self._y_maxval_stamp])
center_asym = opt.fmin(self._asymmetry_function, center_brightest,
args=(self._cutout_stamp_maskzeroed, 'cas'),
xtol=1e-6, disp=False)
# If the asymmetry center again drifted too far, just
# use the centroid and flip the bad measurement flag.
if np.linalg.norm(center_asym - center_brightest) > dist:
warnings.warn('Using centroid instead of asymmetry center.',
AstropyUserWarning)
self.flag = 2
center_asym = centroid
# Print warning if center is masked
ic, jc = int(center_asym[1]), int(center_asym[0])
if self._cutout_stamp_maskzeroed[ic, jc] == 0:
warnings.warn('[asym_center] Asymmetry center is masked.',
AstropyUserWarning)
self.flag = 2
return center_asym
@lazyproperty
def xc_asymmetry(self):
"""
The x-coordinate of the point that minimizes the asymmetry,
relative to the original image.
"""
return self.xmin_stamp + self._asymmetry_center[0]
@lazyproperty
def yc_asymmetry(self):
"""
The y-coordinate of the point that minimizes the asymmetry,
relative to the original image.
"""
return self.ymin_stamp + self._asymmetry_center[1]
@lazyproperty
def _covariance_asymmetry(self):
"""
The covariance matrix of a Gaussian function that has the same
second-order moments as the source, with respect to the point
that minimizes the asymmetry.
"""
return self._covariance_generic(self.xc_asymmetry, self.yc_asymmetry)
@lazyproperty
def _eigvals_asymmetry(self):
"""
The ordered (largest first) eigenvalues of the covariance
matrix, which correspond to the *squared* semimajor and
semiminor axes, relative to the point that minimizes the asymmetry.
"""
return self._eigvals_generic(self._covariance_asymmetry)
@lazyproperty
def ellipticity_asymmetry(self):
"""
The ellipticity of (the Gaussian function that has the same
second-order moments as) the source, relative to the point that
minimizes the asymmetry.
"""
return self._ellipticity_generic(self._eigvals_asymmetry)
@lazyproperty
def elongation_asymmetry(self):
"""
The elongation of (the Gaussian function that has the same
second-order moments as) the source, relative to the point that
minimizes the asymmetry.
"""
return self._elongation_generic(self._eigvals_asymmetry)
@lazyproperty
def orientation_asymmetry(self):
"""
The orientation (in radians) of the source, relative to the
point that minimizes the asymmetry.
"""
return self._orientation_generic(self._covariance_asymmetry)
@lazyproperty
def asymmetry(self):
"""
Calculate asymmetry as described in Lotz et al. (2004).
"""
image = self._cutout_stamp_maskzeroed
asym = self._asymmetry_function(self._asymmetry_center,
image, 'cas')
return asym
@lazyproperty
def rms_asymmetry2(self):
"""
Calculate the RMS asymmetry as in eq. 27 of Sazonova et al. (2024).
Note that this actually corresponds to the *square* of A_rms.
"""
image = self._cutout_stamp_maskzeroed
asym = self._asymmetry_function(self._asymmetry_center,
image, 'rms')
return asym
def _radius_at_fraction_of_total_cas(self, fraction):
"""
Specialization of ``_radius_at_fraction_of_total_circ`` for
the CAS calculations.
"""
image = self._cutout_stamp_maskzeroed
center = self._asymmetry_center
r_upper = self._petro_extent_cas * self.rpetro_circ
r, flag = _radius_at_fraction_of_total_circ(image, center, r_upper, fraction)
self.flag = max(self.flag, flag)
if np.isnan(r) or (r <= 0.0):
warnings.warn('[CAS] Invalid radius_at_fraction_of_total.',
AstropyUserWarning)
self.flag = 2
r = -99.0 # invalid
return r
@lazyproperty
def r20(self):
"""
The radius that contains 20% of the light within
'petro_extent_cas' (usually 1.5) times 'rpetro_circ'.
"""
return self._radius_at_fraction_of_total_cas(0.2)
@lazyproperty
def r50(self):
"""
The radius that contains 50% of the light within
'petro_extent_cas' (usually 1.5) times 'rpetro_circ'.
"""
return self._radius_at_fraction_of_total_cas(0.5)
@lazyproperty
def r80(self):
"""
The radius that contains 80% of the light within
'petro_extent_cas' (usually 1.5) times 'rpetro_circ'.
"""
return self._radius_at_fraction_of_total_cas(0.8)
@lazyproperty
def concentration(self):
"""
Calculate concentration as described in Lotz et al. (2004).
"""
if (self.r20 == -99.0) or (self.r80 == -99.0):
C = -99.0 # invalid
else:
C = 5.0 * np.log10(self.r80 / self.r20)
return C
@lazyproperty
def smoothness(self):
"""
Calculate smoothness (a.k.a. clumpiness) as defined in eq. (11)
from Lotz et al. (2004). Note that the original definition by
Conselice (2003) includes an additional factor of 10.
"""
image = self._cutout_stamp_maskzeroed
# Exclude central region during smoothness calculation:
r_in = self._petro_fraction_cas * self.rpetro_circ
r_out = self._petro_extent_cas * self.rpetro_circ
ap = CircularAnnulus(self._asymmetry_center, r_in, r_out)
boxcar_size = int(self._petro_fraction_cas * self.rpetro_circ)
image_smooth = ndi.uniform_filter(image, size=boxcar_size)
image_diff = image - image_smooth
image_diff[image_diff < 0] = 0.0 # set negative pixels to zero
ap_flux = ap.do_photometry(image, method='exact')[0][0]
ap_diff = ap.do_photometry(image_diff, method='exact')[0][0]
if ap_flux <= 0:
warnings.warn('[smoothness] Nonpositive total flux.',
AstropyUserWarning)
self.flag = 2
return -99.0 # invalid
if self._sky_smoothness == -99.0: # invalid skybox
S = ap_diff / ap_flux
else:
S = (ap_diff - ap.area*self._sky_smoothness) / ap_flux
if not np.isfinite(S):
warnings.warn('Invalid smoothness.', AstropyUserWarning)
self.flag = 2
return -99.0 # invalid
return S
##################
# MID statistics #
##################
def _segmap_mid_main_clump(self, q):
"""
For a given quantile ``q``, return a boolean array indicating
the locations of pixels above ``q`` (within the original segment)
that are also part of the "main" clump.
"""
threshold = _quantile(self._sorted_pixelvals_stamp_no_bg_nonnegative, q)
above_threshold = self._cutout_stamp_maskzeroed_no_bg_nonnegative >= threshold
# Instead of assuming that the main segment is at the center
# of the stamp, use the position of the brightest pixel:
ic = int(self._y_maxval_stamp)
jc = int(self._x_maxval_stamp)
# Grow regions using 8-connected neighbor "footprint"
s = ndi.generate_binary_structure(2, 2)
labeled_array, num_features = ndi.label(above_threshold, structure=s)
# Sanity check (brightest pixel should be part of the main clump):
assert labeled_array[ic, jc] != 0
return labeled_array == labeled_array[ic, jc]
def _segmap_mid_function(self, q):
"""
Helper function to calculate the MID segmap.
For a given quantile ``q``, return the ratio of the mean flux of
pixels at the level of ``q`` (within the main clump) divided by
the mean of pixels above ``q`` (within the main clump).
"""
locs_main_clump = self._segmap_mid_main_clump(q)
mean_flux_main_clump = np.mean(
self._cutout_stamp_maskzeroed_no_bg_nonnegative[locs_main_clump])
mean_flux_new_pixels = _quantile(
self._sorted_pixelvals_stamp_no_bg_nonnegative, q)
if mean_flux_main_clump == 0:
warnings.warn('[segmap_mid] Zero flux sum.', AstropyUserWarning)
ratio = 1.0
self.flag = 2
else:
ratio = mean_flux_new_pixels / mean_flux_main_clump
return ratio - self._eta
@lazyproperty
def _segmap_mid(self):
"""
Create a new segmentation map as described in Section 4.3 from
Freeman et al. (2013).
Notes
-----
This implementation improves upon previous ones by making
the MID segmap independent of the number of quantiles
used in the calculation, as well as other parameters.
"""
num_pixelvals = len(self._sorted_pixelvals_stamp_no_bg_nonnegative)
# In some rare cases (as a consequence of an erroneous
# initial segmap, as in J095553.0+694048_g.fits.gz),
# the MID segmap is technically undefined because the
# mean flux of "newly added" pixels never reaches the
# target value, at least within the original segmap.
# In these cases we simply assume that the MID segmap
# is equal to the Gini segmap.
if self._segmap_mid_function(0.0) > 0.0:
if self._verbose:
warnings.warn(
'segmap_mid is undefined; using segmap_gini instead.',
AstropyUserWarning)
return self._segmap_gini
# Find appropriate quantile using numerical solver
q_min = 0.0
q_max = 1.0
xtol = 1.0 / float(num_pixelvals)
q = opt.brentq(self._segmap_mid_function, q_min, q_max, xtol=xtol)
locs_main_clump = self._segmap_mid_main_clump(q)
# Regularize a bit the shape of the segmap:
segmap_float = ndi.uniform_filter(
np.float64(locs_main_clump), size=self._boxcar_size_mid)
segmap = segmap_float > 0.5
# Make sure that brightest pixel is in segmap
ic = int(self._y_maxval_stamp)
jc = int(self._x_maxval_stamp)
if ~segmap[ic, jc]:
warnings.warn('[segmap_mid] Adding brightest pixel to segmap.',
AstropyUserWarning)
segmap[ic, jc] = True
self.flag = 2
# Grow regions with 8-connected neighbor "footprint"
s = ndi.generate_binary_structure(2, 2)
labeled_array, num_features = ndi.label(segmap, structure=s)
return labeled_array == labeled_array[ic, jc]
@lazyproperty
def _cutout_mid(self):
"""
Apply the MID segmap to the postage stamp cutout
and set negative pixels to zero.
"""
image = np.where(self._segmap_mid,
self._cutout_stamp_maskzeroed_no_bg_nonnegative, 0.0)
return image
@lazyproperty
def _sorted_pixelvals_mid(self):
"""
Just the sorted pixel values of the MID cutout.
"""
image = self._cutout_mid
return np.sort(image[~self._mask_stamp_no_bg])
def _multimode_function(self, q):
"""
Helper function to calculate the multimode statistic.
Returns the sorted "areas" of the clumps at quantile ``q``.
"""
threshold = _quantile(self._sorted_pixelvals_mid, q)
above_threshold = self._cutout_mid >= threshold
# Neighbor "footprint" for growing regions, including corners:
s = ndi.generate_binary_structure(2, 2)
labeled_array, num_features = ndi.label(above_threshold, structure=s)
# Zero is reserved for non-labeled pixels:
labeled_array_nonzero = labeled_array[labeled_array != 0]
labels, counts = np.unique(labeled_array_nonzero, return_counts=True)
sorted_counts = np.sort(counts)[::-1]
return sorted_counts
def _multimode_ratio(self, q):
"""
For a given quantile ``q``, return the "ratio" (A2/A1)*A2
multiplied by -1, which is used for minimization.
"""
invalid = np.sum(self._cutout_mid) # high "energy" for basin-hopping
if (q < 0) or (q > 1):
ratio = invalid
else:
sorted_counts = self._multimode_function(q)
if len(sorted_counts) == 1:
ratio = invalid
else:
ratio = -1.0 * float(sorted_counts[1])**2 / float(sorted_counts[0])
return ratio
@lazyproperty
def multimode(self):
"""
Calculate the multimode (M) statistic as described in
Freeman et al. (2013) and Peth et al. (2016).
Notes
-----
In the original publication, Freeman et al. (2013)
recommends using the more robust quantity (A2/A1)*A2,
while Peth et al. (2016) recommends using the
size-independent quantity A2/A1. Here we take a mixed
approach (which, incidentally, is also what Mike Peth's
IDL implementation actually does): we maximize the
quantity (A2/A1)*A2 (as a function of the brightness
threshold) but ultimately define the M statistic
as the corresponding A2/A1 value.
The original IDL implementation only explores quantiles
in the range [0.5, 1.0], at least with the default settings.
While this might be useful in practice, in theory the
maximum (A2/A1)*A2 value could also happen in the quantile
range [0.0, 0.5], so here we take a safer, more general
approach and search over [0.0, 1.0].
In practice, the quantity (A2/A1)*A2 is tricky to optimize.
We improve over previous implementations by doing so
in two stages: starting with a brute-force search
over a relatively coarse array of quantiles, as in the
original implementation, followed by a finer search using
the basin-hopping method. This should do a better job of
finding the global maximum.
"""
q_min = 0.0
q_max = 1.0
# STAGE 1: brute-force
# We start with a relatively coarse separation between the
# quantiles, equal to the value used in the original IDL
# implementation. If every calculated ratio is invalid, we
# try a smaller size.
mid_stepsize = 0.02
while True:
quantile_array = np.arange(q_min, q_max, mid_stepsize)
ratio_array = np.zeros_like(quantile_array)
for k, q in enumerate(quantile_array):
ratio_array[k] = self._multimode_ratio(q)
k_min = np.argmin(ratio_array)
q0 = quantile_array[k_min]
ratio_min = ratio_array[k_min]
if ratio_min < 0: # valid "ratios" should be negative
break
elif mid_stepsize < 1e-3:
if self._verbose:
warnings.warn(
'[M statistic] Single clump!', AstropyUserWarning)
# This sometimes happens when the source is a star, so the user
# might want to discard M=0 cases, depending on the dataset.
return 0.0
else:
mid_stepsize = mid_stepsize / 2.0
if self._verbose:
warnings.warn('[M statistic] Reduced stepsize to %g.' % (
mid_stepsize), AstropyUserWarning)
# STAGE 2: basin-hopping method
# The results seem quite robust to changes in this parameter,
# so I leave it hardcoded for now:
mid_bh_rel_temp = 0.5
temperature = -1.0 * mid_bh_rel_temp * ratio_min
res = opt.basinhopping(
self._multimode_ratio, q0, minimizer_kwargs={"method": "Nelder-Mead"},
niter=self._niter_bh_mid, T=temperature, stepsize=mid_stepsize,
interval=self._niter_bh_mid/2, disp=False, seed=0)
q_final = res.x[0]
# Finally, return A2/A1 instead of (A2/A1)*A2
sorted_counts = self._multimode_function(q_final)
return float(sorted_counts[1]) / float(sorted_counts[0])
@lazyproperty
def _cutout_mid_smooth(self):
"""
Just a Gaussian-smoothed version of the zero-masked image used
in the MID calculations.
"""
image_smooth = ndi.gaussian_filter(self._cutout_mid, self._sigma_mid)
return image_smooth
@lazyproperty
def _watershed_mid(self):
"""
This replaces the "i_clump" routine from the original IDL code.
The main difference is that we do not place a limit on the
number of labeled regions (previously limited to 100 regions).
This is also much faster, thanks to the highly optimized
"peak_local_max" and "watershed" skimage routines.
Returns a labeled array indicating regions around local maxima.
"""
peaks = skimage.feature.peak_local_max(
self._cutout_mid_smooth, num_peaks=np.inf)
num_peaks = peaks.shape[0]
# The zero label is reserved for the background:
peak_labels = np.arange(1, num_peaks+1, dtype=np.int64)
ypeak, xpeak = peaks.T
markers = np.zeros(self._cutout_mid_smooth.shape, dtype=np.int64)
markers[ypeak, xpeak] = peak_labels
mask = self._cutout_mid_smooth > 0
labeled_array = skimage.segmentation.watershed(
-self._cutout_mid_smooth, markers, connectivity=2, mask=mask)
return labeled_array, peak_labels, xpeak, ypeak
@lazyproperty
def _intensity_sums(self):
"""
Helper function to calculate the intensity (I) and
deviation (D) statistics.
"""
labeled_array, peak_labels, xpeak, ypeak = self._watershed_mid
num_peaks = len(peak_labels)
flux_sums = np.zeros(num_peaks, dtype=np.float64)
for k, label in enumerate(peak_labels):
locs = labeled_array == label
flux_sums[k] = np.sum(self._cutout_mid_smooth[locs])
sid = np.argsort(flux_sums)[::-1]
sorted_flux_sums = flux_sums[sid]
sorted_xpeak = xpeak[sid]
sorted_ypeak = ypeak[sid]
return sorted_flux_sums, sorted_xpeak, sorted_ypeak
@lazyproperty
def intensity(self):
"""
Calculate the intensity (I) statistic as described in
Peth et al. (2016).
"""
sorted_flux_sums, sorted_xpeak, sorted_ypeak = self._intensity_sums
if len(sorted_flux_sums) <= 1:
# Unlike the M=0 cases, there seem to be some legitimate
# I=0 cases, so we do not turn on the "bad measurement" flag.
return 0.0
else:
return sorted_flux_sums[1] / sorted_flux_sums[0]
@lazyproperty
def deviation(self):
"""
Calculate the deviation (D) statistic as described in
Peth et al. (2016).
"""
image = np.float64(self._cutout_mid) # skimage wants double
sorted_flux_sums, sorted_xpeak, sorted_ypeak = self._intensity_sums
if len(sorted_flux_sums) == 0:
warnings.warn('[deviation] There are no peaks.', AstropyUserWarning)
self.flag = 2
return -99.0 # invalid
xp = sorted_xpeak[0]
yp = sorted_ypeak[0]
# Calculate centroid
M = skimage.measure.moments(image, order=1)
if M[0, 0] <= 0:
warnings.warn('[deviation] Nonpositive flux within MID segmap.',
AstropyUserWarning)
self.flag = 2
return -99.0 # invalid
yc = M[1, 0] / M[0, 0]
xc = M[0, 1] / M[0, 0]
area = np.sum(self._segmap_mid)
D = np.sqrt(np.pi/area) * np.sqrt((xp-xc)**2 + (yp-yc)**2)
if not np.isfinite(D):
warnings.warn('Invalid D-statistic.', AstropyUserWarning)
self.flag = 2
return -99.0 # invalid
# This is the "last" MID statistic that is calculated, so we try
# to clear some memory here.
del self._cutout_stamp_maskzeroed_no_bg
del self._sorted_pixelvals_stamp_no_bg
del self._cutout_stamp_maskzeroed_no_bg_nonnegative
del self._sorted_pixelvals_stamp_no_bg_nonnegative
del self._cutout_mid
del self._sorted_pixelvals_mid
del self._cutout_mid_smooth
return D
###################
# SHAPE ASYMMETRY #
###################
@lazyproperty
def rhalf_circ(self):
"""
The radius of a circular aperture containing 50% of the light,
assuming that the center is the point that minimizes the
asymmetry and that the total is at ``rmax_circ``.
"""
image = self._cutout_stamp_maskzeroed
center = self._asymmetry_center
if self.rmax_circ == 0:
r = 0.0
else:
r, flag = _radius_at_fraction_of_total_circ(
image, center, self.rmax_circ, 0.5)
self.flag = max(self.flag, flag)
# In theory, this return value can also be NaN
return r
@lazyproperty
def rhalf_ellip(self):
"""
The semimajor axis of an elliptical aperture containing 50% of
the light, assuming that the center is the point that minimizes
the asymmetry and that the total is at ``rmax_ellip``.
"""
image = self._cutout_stamp_maskzeroed
center = self._asymmetry_center
if self.rmax_ellip == 0:
r = 0.0
else:
r, flag = _radius_at_fraction_of_total_ellip(
image, center, self.elongation_asymmetry,
self.orientation_asymmetry, self.rmax_ellip, 0.5)
self.flag = max(self.flag, flag)
# In theory, this return value can also be NaN
return r
@lazyproperty
def _segmap_shape_asym(self):
"""
Construct a binary detection mask as described in Section 3.1
from Pawlik et al. (2016).
Notes
-----
The original algorithm assumes that a circular aperture with
inner and outer radii equal to 20 and 40 times the FWHM is
representative of the background. In order to avoid amplifying
uncertainties from the central regions of the galaxy profile,
we assume that such an aperture has inner and outer radii
equal to multiples of the elliptical Petrosian radius
(which can be specified by the user). Also, we define the center
as the point that minimizes the asymmetry, instead of the
brightest pixel.
"""
ny, nx = self._cutout_stamp_maskzeroed.shape
# Center at pixel that minimizes asymmetry
center = self._asymmetry_center
# Create a circular annulus around the center
# that only contains background sky (hopefully).
r_in = self._petro_extent_flux * self.rpetro_ellip
r_out = 2.0 * self._petro_extent_flux * self.rpetro_ellip
circ_annulus = CircularAnnulus(center, r_in, r_out)
# Convert circular annulus aperture to binary mask
circ_annulus_mask = circ_annulus.to_mask(method='center')
# With the same shape as the postage stamp
circ_annulus_mask = circ_annulus_mask.to_image((ny, nx))
# Invert mask and exclude other sources
total_mask = self._mask_stamp | np.logical_not(circ_annulus_mask)
# If sky area is too small (e.g., if annulus is outside the
# image), use skybox instead.
if np.sum(~total_mask) < self._skybox_size**2:
if self._verbose:
warnings.warn('[shape_asym] Using skybox for background.',
AstropyUserWarning)
total_mask = np.ones((ny, nx), dtype=np.bool_)
total_mask[self._slice_skybox] = False
# However, if skybox is undefined, there is nothing to do.
if np.sum(~total_mask) == 0:
warnings.warn('[shape_asym] Asymmetry segmap undefined.',
AstropyUserWarning)
self.flag = 2
return ~self._mask_stamp_no_bg
# Define the "mode" as in Bertin & Arnouts (1996):
bkg_estimator = ModeEstimatorBackground(
median_factor=2.5, mean_factor=1.5)
# Do sigma-clipping until convergence
mean, median, std = sigma_clipped_stats(
self._cutout_stamp_maskzeroed, mask=total_mask, sigma=3.0,
maxiters=None, cenfunc=bkg_estimator)
# Mode as defined in Bertin & Arnouts (1996)
mode = 2.5*median - 1.5*mean
threshold = mode + std
# Smooth image slightly and apply 1-sigma threshold
image_smooth = ndi.uniform_filter(
self._cutout_stamp_maskzeroed, size=self._boxcar_size_shape_asym)
above_threshold = image_smooth >= threshold
# Make sure that brightest pixel (of smoothed image) is in segmap
ic, jc = np.argwhere(image_smooth == np.max(image_smooth))[0]
if ~above_threshold[ic, jc]:
warnings.warn('[shape_asym] Adding brightest pixel to segmap.',
AstropyUserWarning)
above_threshold[ic, jc] = True
self.flag = 2
# Grow regions with 8-connected neighbor "footprint"
s = ndi.generate_binary_structure(2, 2)
labeled_array, num_features = ndi.label(above_threshold, structure=s)
return labeled_array == labeled_array[ic, jc]
@lazyproperty
def rmax_circ(self):
"""
Return the distance (in pixels) from the pixel that minimizes
the asymmetry to the edge of the main source segment, similar
to Pawlik et al. (2016).
"""
image = self._cutout_stamp_maskzeroed
ny, nx = image.shape
# Center at pixel that minimizes asymmetry
xc, yc = self._asymmetry_center
# Distances from all pixels to the center
ypos, xpos = np.mgrid[0:ny, 0:nx]
distances = np.sqrt((ypos-yc)**2 + (xpos-xc)**2)
# Only consider pixels within the segmap.
rmax_circ = np.max(distances[self._segmap_shape_asym])
if rmax_circ == 0:
warnings.warn('[rmax_circ] rmax_circ = 0!', AstropyUserWarning)
self.flag = 2
return rmax_circ
@lazyproperty
def rmax_ellip(self):
"""
Return the semimajor axis of the minimal ellipse (with fixed
center, elongation and orientation) that contains the entire
main segment of the shape asymmetry segmap. In most cases
this is almost identical to rmax_circ.
"""
image = self._cutout_stamp_maskzeroed
ny, nx = image.shape
# Center at pixel that minimizes asymmetry
xc, yc = self._asymmetry_center
theta = self.orientation_asymmetry
y, x = np.mgrid[0:ny, 0:nx]
xprime = (x-xc)*np.cos(theta) + (y-yc)*np.sin(theta)
yprime = -(x-xc)*np.sin(theta) + (y-yc)*np.cos(theta)
r_ellip = np.sqrt(xprime**2 + (yprime*self.elongation_asymmetry)**2)
# Only consider pixels within the segmap.
rmax_ellip = np.max(r_ellip[self._segmap_shape_asym])
if rmax_ellip == 0:
warnings.warn('[rmax_ellip] rmax_ellip = 0!', AstropyUserWarning)
self.flag = 2
return rmax_ellip
@lazyproperty
def outer_asymmetry(self):
"""
Calculate outer asymmetry as described in Wen et al. (2014).
Note that the center is the one used for the standard asymmetry.
"""
image = self._cutout_stamp_maskzeroed
asym = self._asymmetry_function(self._asymmetry_center, image, 'outer')
return asym
@lazyproperty
def shape_asymmetry(self):
"""
Calculate shape asymmetry as described in Pawlik et al. (2016).
Note that the center is the one used for the standard asymmetry.
"""
image = np.where(self._segmap_shape_asym, 1.0, 0.0)
asym = self._asymmetry_function(self._asymmetry_center, image, 'shape')
return asym
####################
# SERSIC MODEL FIT #
####################
@lazyproperty
def _sersic_model(self):
"""
Fit a 2D Sersic profile using Astropy's model fitting library.
An initial guess for the model is automatically generated based on
other statmorph measurements, although users can provide their
own initial values (and optionally keep them fixed) via the
``sersic_model_args`` keyword argument.
Return the fitted model object.
"""
image = self._cutout_stamp_maskzeroed
ny, nx = image.shape
# Measure runtime
start = time.time()
# Initialize dict for fitting arguments (unless provided by user)
if self._sersic_fitting_args is None:
self._sersic_fitting_args = {}
# Check deprecated argument
if self._sersic_maxiter is not None:
warnings.warn(
"The argument sersic_maxiter is deprecated. Please use "
"sersic_fitting_args instead.", AstropyDeprecationWarning)
self._sersic_fitting_args['maxiter'] = self._sersic_maxiter
# Set default fitting options (unless provided by user)
if 'maxiter' not in self._sersic_fitting_args:
self._sersic_fitting_args['maxiter'] = 500
if 'acc' not in self._sersic_fitting_args:
self._sersic_fitting_args['acc'] = 1e-5
# Initialize dict for model arguments (unless provided by user)
if self._sersic_model_args is None:
self._sersic_model_args = {}
# Impose minimum value of n = 0.01 (prevent floating point overflow)
if 'bounds' not in self._sersic_model_args:
self._sersic_model_args['bounds'] = {'n': (0.01, None)}
# Start from approximate relation between n and concentration
empirical_n = 10.0**(-1.5) * self.concentration**3.5
empirical_n = min(max(empirical_n, 1.0), 3.5) # limit to range [1, 3.5]
# Create initial guesses for some model parameters (amplitude later)
if 'r_eff' not in self._sersic_model_args:
self._sersic_model_args['r_eff'] = self.rhalf_ellip
if 'n' not in self._sersic_model_args:
self._sersic_model_args['n'] = empirical_n
if 'x_0' not in self._sersic_model_args:
self._sersic_model_args['x_0'] = self.xc_asymmetry
if 'y_0' not in self._sersic_model_args:
self._sersic_model_args['y_0'] = self.yc_asymmetry
if 'ellip' not in self._sersic_model_args:
self._sersic_model_args['ellip'] = self.ellipticity_asymmetry
if 'theta' not in self._sersic_model_args:
self._sersic_model_args['theta'] = self.orientation_asymmetry
# Origin must coincide with that of image cutout
self._sersic_model_args['x_0'] -= self.xmin_stamp
self._sersic_model_args['y_0'] -= self.ymin_stamp
# ..as well as the bounds for the origin
if 'x_0' in self._sersic_model_args['bounds']:
self._sersic_model_args['bounds']['x_0'][0] -= self.xmin_stamp
self._sersic_model_args['bounds']['x_0'][1] -= self.xmin_stamp
if 'y_0' in self._sersic_model_args['bounds']:
self._sersic_model_args['bounds']['y_0'][0] -= self.ymin_stamp
self._sersic_model_args['bounds']['y_0'][1] -= self.ymin_stamp
# For readability
guess_r_eff = self._sersic_model_args['r_eff']
guess_center = np.array([self._sersic_model_args['x_0'],
self._sersic_model_args['y_0']])
guess_ellip = self._sersic_model_args['ellip']
guess_theta = self._sersic_model_args['theta']
# Get mean flux at the effective "radius"
a_in = guess_r_eff - 0.5 * self._annulus_width
a_out = guess_r_eff + 0.5 * self._annulus_width
if a_in < 0:
warnings.warn('[sersic] guess_r_eff < annulus_width.',
AstropyUserWarning)
self.flag_sersic = 2
a_in = guess_r_eff
b_out = (1 - guess_ellip) * a_out
ellip_annulus = EllipticalAnnulus(
guess_center, a_in, a_out, b_out, theta=guess_theta)
ellip_annulus_mean_flux = _aperture_mean_nomask(
ellip_annulus, image, method='exact')
if ellip_annulus_mean_flux <= 0.0:
warnings.warn('[sersic] Nonpositive flux at r_e.', AstropyUserWarning)
self.flag_sersic = 2
ellip_annulus_mean_flux = np.abs(ellip_annulus_mean_flux)
# Final parameter
if 'amplitude' not in self._sersic_model_args:
self._sersic_model_args['amplitude'] = ellip_annulus_mean_flux
# Create initial model
if self._psf is None:
sersic_init = models.Sersic2D(**self._sersic_model_args)
else:
sersic_init = ConvolvedSersic2D(**self._sersic_model_args)
sersic_init.set_psf(self._psf)
# Dummy value (in case calculations are aborted)
self._sersic_chi2 = -99.0
# Prepare data for fitting
y, x = np.mgrid[0:ny, 0:nx].astype(image.dtype)
weightmap = self._weightmap_stamp
# Exclude pixels with image == 0 or weightmap == 0 from the fit.
fit_weights = np.zeros_like(image)
locs = (image != 0) & (weightmap != 0)
# The sky background noise is already included in the weightmap:
fit_weights[locs] = 1.0 / weightmap[locs]
# Number of "valid" pixels
self._sersic_num_validpixels = np.sum(locs)
# Calculate number of free parameters and degrees of freedom
self._sersic_num_freeparam = sersic_init.parameters.size # 7
for kwarg in ['fixed', 'tied']:
if kwarg in self._sersic_model_args:
for param, value in self._sersic_model_args[kwarg].items():
if value:
self._sersic_num_freeparam -= 1
assert self._sersic_num_freeparam >= 0
self._sersic_num_dof = (self._sersic_num_validpixels
- self._sersic_num_freeparam)
if self._sersic_num_dof <= 0:
warnings.warn('[sersic] Not enough data for fit.',
AstropyUserWarning)
self.flag_sersic = 2
return sersic_init
# Try to fit model
fit_sersic = fitting.LevMarLSQFitter()
sersic_model = fit_sersic(
sersic_init, x, y, z=image, weights=fit_weights,
**self._sersic_fitting_args)
if fit_sersic.fit_info['ierr'] not in [1, 2, 3, 4]:
warnings.warn("[sersic] fit_info['message']: "
+ fit_sersic.fit_info['message'],
AstropyUserWarning)
self.flag_sersic = 2
# If any of the parameters gets "stuck" to a boundary, label the
# fit as "suspect" (flag_sersic = 1).
if 'bounds' in self._sersic_model_args:
for param, bounds in self._sersic_model_args['bounds'].items():
value = getattr(sersic_model, param).value
if value in bounds:
if self._verbose:
warnings.warn(
f"[sersic] {param} got stuck at {value}.",
AstropyUserWarning)
self.flag_sersic = max(self.flag_sersic, 1)
# Apply post-fitting corrections (if applicable)
(sersic_model.r_eff.value,
sersic_model.ellip.value,
sersic_model.theta.value) = _postfitting_fix(
sersic_model.r_eff.value,
sersic_model.ellip.value,
sersic_model.theta.value)
# Calculate chi^2 statistic of the fitted model.
self._sersic_chi2 = np.sum(
(fit_weights * (image - sersic_model(x, y)))**2)
# Save runtime
self.sersic_runtime = time.time() - start
return sersic_model
@lazyproperty
def sersic_amplitude(self):
"""
The amplitude of the 2D Sersic fit at the effective (half-light)
radius (`astropy.modeling.models.Sersic2D`).
"""
return self._sersic_model.amplitude.value
@lazyproperty
def sersic_rhalf(self):
"""
The effective (half-light) radius of the 2D Sersic fit
(`astropy.modeling.models.Sersic2D`).
"""
return self._sersic_model.r_eff.value
@lazyproperty
def sersic_n(self):
"""
The Sersic index ``n`` (`astropy.modeling.models.Sersic2D`).
"""
return self._sersic_model.n.value
@lazyproperty
def sersic_xc(self):
"""
The x-coordinate of the center of the 2D Sersic fit
(`astropy.modeling.models.Sersic2D`), relative to the
original image.
"""
return self.xmin_stamp + self._sersic_model.x_0.value
@lazyproperty
def sersic_yc(self):
"""
The y-coordinate of the center of the 2D Sersic fit
(`astropy.modeling.models.Sersic2D`), relative to the
original image.
"""
return self.ymin_stamp + self._sersic_model.y_0.value
@lazyproperty
def sersic_ellip(self):
"""
The ellipticity of the 2D Sersic fit
(`astropy.modeling.models.Sersic2D`).
"""
return self._sersic_model.ellip.value
@lazyproperty
def sersic_theta(self):
"""
The orientation (counterclockwise, in radians) of the
2D Sersic fit (`astropy.modeling.models.Sersic2D`).
"""
theta = self._sersic_model.theta.value
return theta - np.floor(theta/np.pi) * np.pi
@lazyproperty
def sersic_chi2_dof(self):
"""
Reduced chi^2 statistic of the fitted model.
"""
_ = self._sersic_model
if self._sersic_chi2 == -99.0:
return -99.0
return self._sersic_chi2 / self._sersic_num_dof
@lazyproperty
def sersic_aic(self):
"""
Akaike Information Criterion (AIC) of the fitted model.
"""
_ = self._sersic_model
if self._sersic_chi2 == -99.0:
return -99.0
return akaike_info_criterion_lsq(
self._sersic_chi2, self._sersic_num_freeparam,
self._sersic_num_validpixels)
@lazyproperty
def sersic_bic(self):
"""
Bayesian Information Criterion (BIC) of the fitted model.
"""
_ = self._sersic_model
if self._sersic_chi2 == -99.0:
return -99.0
return bayesian_info_criterion_lsq(
self._sersic_chi2, self._sersic_num_freeparam,
self._sersic_num_validpixels)
###########################
# DOUBLE SERSIC MODEL FIT #
###########################
@lazyproperty
def _doublesersic_initial_guess(self):
"""
Construct an initial guess for the double 2D Sersic model by
separately performing "inner" and "outer" single Sersic fits
to the light distribution. The inner and outer regions are
separated by an ellipse with semi-major axis given by r_sep,
which can be specified by the user as a multiple of rhalf_ellip.
"""
image = self._cutout_stamp_maskzeroed
ny, nx = image.shape
# Prepare data for fitting
y, x = np.mgrid[0:ny, 0:nx].astype(image.dtype)
weightmap = self._weightmap_stamp
# Center at pixel that minimizes asymmetry
xc, yc = self._asymmetry_center
theta = self.orientation_asymmetry
xprime = (x-xc)*np.cos(theta) + (y-yc)*np.sin(theta)
yprime = -(x-xc)*np.sin(theta) + (y-yc)*np.cos(theta)
r_ellip = np.sqrt(xprime**2 + (yprime*self.elongation_asymmetry)**2)
# Semimajor axis of the ellipse used to separate the inner and
# outer regions:
r_sep = self._doublesersic_rsep_over_rhalf * self.rhalf_ellip
# The initial model is the same for the inner and outer fits:
sersic_init = self._sersic_model.copy()
# Fix center
sersic_init.fixed['x_0'] = True
sersic_init.fixed['y_0'] = True
# INNER SERSIC FIT
# Exclude pixels with image == 0 or weightmap == 0 from the fit,
# as well as pixels outside the region of interest.
fit_weights = np.zeros_like(image)
locs = (image != 0) & (weightmap != 0) & (r_ellip < r_sep)
# The sky background noise is already included in the weightmap:
fit_weights[locs] = 1.0 / weightmap[locs]
# Try to fit model. Here we use less demanding values for ``maxiter``
# and ``acc``, since the goal is just to obtain a first guess.
fit_sersic = fitting.LevMarLSQFitter()
sersic_inner = fit_sersic(
sersic_init, x, y, z=image, weights=fit_weights, maxiter=100,
acc=1e-4)
# OUTER SERSIC FIT
# Exclude pixels with image == 0 or weightmap == 0 from the fit,
# as well as pixels outside the region of interest.
fit_weights = np.zeros_like(image)
locs = (image != 0) & (weightmap != 0) & (r_ellip >= r_sep)
# The sky background noise is already included in the weightmap:
fit_weights[locs] = 1.0 / weightmap[locs]
# Try to fit model. Here we use less demanding values for ``maxiter``
# and ``acc``, since the goal is just to obtain a first guess.
fit_sersic = fitting.LevMarLSQFitter()
sersic_outer = fit_sersic(
sersic_init, x, y, z=image, weights=fit_weights, maxiter=100,
acc=1e-4)
return sersic_inner, sersic_outer
@lazyproperty
def _doublesersic_model(self):
"""
Fit a double 2D Sersic profile using Astropy's model fitting library.
An initial guess for the model is automatically generated based on
other statmorph measurements, although users can provide their own
initial values (and optionally keep them fixed) via the
``doublesersic_model_args`` keyword argument.
Return the fitted model object.
"""
image = self._cutout_stamp_maskzeroed
ny, nx = image.shape
# Measure runtime
start = time.time()
# Initialize dict for fitting arguments (unless provided by user)
if self._doublesersic_fitting_args is None:
self._doublesersic_fitting_args = {}
# Set default fitting options (unless provided by user)
if 'maxiter' not in self._doublesersic_fitting_args:
self._doublesersic_fitting_args['maxiter'] = 500
if 'acc' not in self._doublesersic_fitting_args:
self._doublesersic_fitting_args['acc'] = 1e-5
# Initialize dict for model arguments (unless provided by user)
if self._doublesersic_model_args is None:
self._doublesersic_model_args = {}
# Impose minimum value of n = 0.01 (prevent floating point overflow)
if 'bounds' not in self._doublesersic_model_args:
self._doublesersic_model_args['bounds'] = {'n_1': (0.01, None),
'n_2': (0.01, None)}
# Obtain initial guess for the model parameters
sersic_inner, sersic_outer = self._doublesersic_initial_guess
if 'x_0' not in self._doublesersic_model_args:
self._doublesersic_model_args['x_0'] = self.sersic_xc
if 'y_0' not in self._doublesersic_model_args:
self._doublesersic_model_args['y_0'] = self.sersic_yc
if 'amplitude_1' not in self._doublesersic_model_args:
self._doublesersic_model_args['amplitude_1'] = sersic_inner.amplitude.value
if 'r_eff_1' not in self._doublesersic_model_args:
self._doublesersic_model_args['r_eff_1'] = sersic_inner.r_eff.value
if 'n_1' not in self._doublesersic_model_args:
self._doublesersic_model_args['n_1'] = sersic_inner.n.value
if 'ellip_1' not in self._doublesersic_model_args:
self._doublesersic_model_args['ellip_1'] = sersic_inner.ellip.value
if 'theta_1' not in self._doublesersic_model_args:
self._doublesersic_model_args['theta_1'] = sersic_inner.theta.value
if 'amplitude_2' not in self._doublesersic_model_args:
self._doublesersic_model_args['amplitude_2'] = sersic_outer.amplitude.value
if 'r_eff_2' not in self._doublesersic_model_args:
self._doublesersic_model_args['r_eff_2'] = sersic_outer.r_eff.value
if 'n_2' not in self._doublesersic_model_args:
self._doublesersic_model_args['n_2'] = sersic_outer.n.value
if 'ellip_2' not in self._doublesersic_model_args:
self._doublesersic_model_args['ellip_2'] = sersic_outer.ellip.value
if 'theta_2' not in self._doublesersic_model_args:
self._doublesersic_model_args['theta_2'] = sersic_outer.theta.value
# Origin must coincide with that of image cutout
self._doublesersic_model_args['x_0'] -= self.xmin_stamp
self._doublesersic_model_args['y_0'] -= self.ymin_stamp
# Create initial model
if self._psf is None:
doublesersic_init = DoubleSersic2D(**self._doublesersic_model_args)
else:
doublesersic_init = ConvolvedDoubleSersic2D(**self._doublesersic_model_args)
doublesersic_init.set_psf(self._psf)
# Tie ellipticity and position angle of the two components
if self._doublesersic_tied_ellip:
doublesersic_init.ellip_2.tied = lambda model: model.ellip_1
doublesersic_init.theta_2.tied = lambda model: model.theta_1
# Dummy value (in case calculations are aborted)
self._doublesersic_chi2 = -99.0
# Prepare data for fitting
y, x = np.mgrid[0:ny, 0:nx].astype(image.dtype)
weightmap = self._weightmap_stamp
# Exclude pixels with image == 0 or weightmap == 0 from the fit.
fit_weights = np.zeros_like(image)
locs = (image != 0) & (weightmap != 0)
# The sky background noise is already included in the weightmap:
fit_weights[locs] = 1.0 / weightmap[locs]
# Number of "valid" pixels
self._doublesersic_num_validpixels = np.sum(locs)
# Calculate number of free parameters and degrees of freedom
self._doublesersic_num_freeparam = doublesersic_init.parameters.size # 12
for kwarg in ['fixed', 'tied']:
if kwarg in self._doublesersic_model_args:
for param, value in self._doublesersic_model_args[kwarg].items():
if value:
self._doublesersic_num_freeparam -= 1
assert self._doublesersic_num_freeparam >= 0
self._doublesersic_num_dof = (self._doublesersic_num_validpixels
- self._doublesersic_num_freeparam)
if self._doublesersic_num_dof <= 0:
warnings.warn('[doublesersic] Not enough data for fit.',
AstropyUserWarning)
self.flag_doublesersic = 2
return doublesersic_init
# Try to fit model
fit_doublesersic = fitting.LevMarLSQFitter()
doublesersic_model = fit_doublesersic(
doublesersic_init, x, y, z=image, weights=fit_weights,
**self._doublesersic_fitting_args)
if fit_doublesersic.fit_info['ierr'] not in [1, 2, 3, 4]:
warnings.warn("[doublesersic] fit_info['message']: "
+ fit_doublesersic.fit_info['message'],
AstropyUserWarning)
self.flag_doublesersic = 2
# If any of the parameters gets "stuck" to a boundary, label the
# fit as "suspect" (flag_doublesersic = 1).
if 'bounds' in self._doublesersic_model_args:
for param, bounds in self._doublesersic_model_args['bounds'].items():
value = getattr(doublesersic_model, param).value
if value in bounds:
if self._verbose:
warnings.warn(
f"[doublesersic] {param} got stuck at {value}.",
AstropyUserWarning)
self.flag_doublesersic = max(self.flag_doublesersic, 1)
# Apply post-fitting corrections (if applicable)
(doublesersic_model.r_eff_1.value,
doublesersic_model.ellip_1.value,
doublesersic_model.theta_1.value) = _postfitting_fix(
doublesersic_model.r_eff_1.value,
doublesersic_model.ellip_1.value,
doublesersic_model.theta_1.value)
(doublesersic_model.r_eff_2.value,
doublesersic_model.ellip_2.value,
doublesersic_model.theta_2.value) = _postfitting_fix(
doublesersic_model.r_eff_2.value,
doublesersic_model.ellip_2.value,
doublesersic_model.theta_2.value)
# Calculate chi^2 statistic of the fitted model.
self._doublesersic_chi2 = np.sum(
(fit_weights * (image - doublesersic_model(x, y)))**2)
# Save runtime
self.doublesersic_runtime = time.time() - start
return doublesersic_model
@lazyproperty
def doublesersic_xc(self):
"""
The x-coordinate of the center of the double 2D Sersic fit,
relative to the original image.
"""
return self.xmin_stamp + self._doublesersic_model.x_0.value
@lazyproperty
def doublesersic_yc(self):
"""
The y-coordinate of the center of the double 2D Sersic fit,
relative to the original image.
"""
return self.ymin_stamp + self._doublesersic_model.y_0.value
@lazyproperty
def doublesersic_amplitude1(self):
"""
The amplitude of the first component of the double 2D Sersic fit
at its effective radius (rhalf1).
"""
return self._doublesersic_model.amplitude_1.value
@lazyproperty
def doublesersic_rhalf1(self):
"""
The effective (half-light) radius of the first component of the
double 2D Sersic fit.
"""
return self._doublesersic_model.r_eff_1.value
@lazyproperty
def doublesersic_n1(self):
"""
The Sersic index ``n`` of the first component of the double 2D
Sersic fit.
"""
return self._doublesersic_model.n_1.value
@lazyproperty
def doublesersic_ellip1(self):
"""
The ellipticity of the first component of the double 2D Sersic fit.
"""
return self._doublesersic_model.ellip_1.value
@lazyproperty
def doublesersic_theta1(self):
"""
The orientation (counterclockwise, in radians) of the
first component of the double 2D Sersic fit.
"""
theta = self._doublesersic_model.theta_1.value
return theta - np.floor(theta/np.pi) * np.pi
@lazyproperty
def doublesersic_amplitude2(self):
"""
The amplitude of the second component of the double 2D Sersic fit
at its effective radius (rhalf2).
"""
return self._doublesersic_model.amplitude_2.value
@lazyproperty
def doublesersic_rhalf2(self):
"""
The effective (half-light) radius of the second component of the
double 2D Sersic fit.
"""
return self._doublesersic_model.r_eff_2.value
@lazyproperty
def doublesersic_n2(self):
"""
The Sersic index ``n`` of the second component of the double 2D
Sersic fit.
"""
return self._doublesersic_model.n_2.value
@lazyproperty
def doublesersic_ellip2(self):
"""
The ellipticity of the second component of the double 2D Sersic fit.
"""
return self._doublesersic_model.ellip_2.value
@lazyproperty
def doublesersic_theta2(self):
"""
The orientation (counterclockwise, in radians) of the
second component of the double 2D Sersic fit.
"""
theta = self._doublesersic_model.theta_2.value
return theta - np.floor(theta/np.pi) * np.pi
@lazyproperty
def doublesersic_chi2_dof(self):
"""
Reduced chi^2 statistic of the fitted double 2D Sersic model.
"""
_ = self._doublesersic_model
if self._doublesersic_chi2 == -99.0:
return -99.0
return self._doublesersic_chi2 / self._doublesersic_num_dof
@lazyproperty
def doublesersic_aic(self):
"""
Akaike Information Criterion (AIC) of the fitted model.
"""
_ = self._doublesersic_model
if self._doublesersic_chi2 == -99.0:
return -99.0
return akaike_info_criterion_lsq(
self._doublesersic_chi2, self._doublesersic_num_freeparam,
self._doublesersic_num_validpixels)
@lazyproperty
def doublesersic_bic(self):
"""
Bayesian Information Criterion (BIC) of the fitted model.
"""
_ = self._doublesersic_model
if self._doublesersic_chi2 == -99.0:
return -99.0
return bayesian_info_criterion_lsq(
self._doublesersic_chi2, self._doublesersic_num_freeparam,
self._doublesersic_num_validpixels)
[docs]
def source_morphology(image, segmap, **kwargs):
"""
Calculate the morphological parameters of all sources in ``image``
as labeled by ``segmap``.
Parameters
----------
image : array-like
A 2D image containing the sources of interest.
The image must already be background-subtracted.
segmap : array-like (int) or `photutils.segmentation.SegmentationImage`
A 2D segmentation map where different sources are
labeled with different positive integer values.
A value of zero is reserved for the background.
Other parameters
----------------
kwargs : `~statmorph.SourceMorphology` properties.
Returns
-------
sources_morph : list
A list of `SourceMorphology` objects, one for each
source. The morphological parameters can be accessed
as attributes or keys.
See Also
--------
SourceMorphology : Class to measure morphological parameters.
Examples
--------
See `README.rst` for usage examples.
References
----------
See `README.rst` for a list of references.
"""
if not isinstance(segmap, SegmentationImage):
segmap = SegmentationImage(segmap)
sources_morph = []
for label in segmap.labels:
sources_morph.append(SourceMorphology(image, segmap, label, **kwargs))
return sources_morph