Tutorial

In this tutorial we create a (simplified) synthetic galaxy image from scratch, along with its associated segmentation map, and then run the statmorph code on it.

Setting up

We import some Python packages first. If you are missing any of these, see the the Installation section of the README.


In [1]:
import numpy as np
import matplotlib.pyplot as plt
import scipy.ndimage as ndi
from astropy.visualization import simple_norm
from astropy.modeling import models
import photutils
import time
import statmorph
%matplotlib inline

Creating a model galaxy image

We assume that the image size is 240x240 pixels, and that the "true" light distribution is described by a 2D Sersic profile with the following parameters:


In [2]:
ny, nx = 240, 240
y, x = np.mgrid[0:ny, 0:nx]
sersic_model = models.Sersic2D(amplitude=1, r_eff=20, n=1.5, x_0=0.5*nx, y_0=0.4*ny,
                               ellip=0.5, theta=0.5)
image = sersic_model(x, y)
plt.imshow(image, cmap='gray', origin='lower',
           norm=simple_norm(image, stretch='log', log_a=10000))


Out[2]:
<matplotlib.image.AxesImage at 0x7fb4ef81b490>

Convolving with a PSF

In practice, every astronomical image is the convolution of a "true" image with a point spread function (PSF), which depends on the optics of the telescope, atmospheric conditions, etc. Here we assume that the PSF is a simple 2D Gaussian distribution:


In [3]:
size = 20  # on each side from the center
sigma_psf = 2.0
y, x = np.mgrid[-size:size+1, -size:size+1]
psf = np.exp(-(x**2 + y**2)/(2.0*sigma_psf**2))
psf /= np.sum(psf)
plt.imshow(psf, origin='lower', cmap='gray')


Out[3]:
<matplotlib.image.AxesImage at 0x7fb4ef69b050>

Now we convolve the image with the PSF.


In [4]:
image = ndi.convolve(image, psf)
plt.imshow(image, cmap='gray', origin='lower',
           norm=simple_norm(image, stretch='log', log_a=10000))


Out[4]:
<matplotlib.image.AxesImage at 0x7fb4ef5c9610>

Adding noise

Here we add homogeneous Gaussian background noise, assuming that the signal-to-noise ratio (S/N) is 100 at the effective radius (where we defined the Sérsic profile amplitude as 1.0). For simplicity, we do not consider Poisson noise associated with the source itself.


In [5]:
np.random.seed(1)
snp = 100.0
image += (1.0 / snp) * np.random.standard_normal(size=(ny, nx))
plt.imshow(image, cmap='gray', origin='lower',
           norm=simple_norm(image, stretch='log', log_a=10000))


Out[5]:
<matplotlib.image.AxesImage at 0x7fb4ef5ad290>

Gain and weight maps

The code will ask for one of two input arguments: (1) a weight map, which is a 2D array (of the same size as the input image) representing one standard deviation at each pixel value, or (2) the gain, a scalar that can be multiplied by the science image to obtain the number of electron counts per pixel. This is used internally by statmorph to calculate the weight map.

Here we assume, rather arbitrarily, that there is an average of 1000 electron counts/pixel at the effective radius (where we defined the amplitude as 1.0), so that the gain is 1000.0.


In [6]:
gain = 1000.0

Creating a segmentation map

Besides the image itself, the only other required argument is the segmentation map, which labels the pixels belonging to different sources. It is usually generated by specialized tools such as SExtractor, but here we create it using photutils:


In [7]:
threshold = photutils.detect_threshold(image, 1.5)
npixels = 5  # minimum number of connected pixels
segm = photutils.detect_sources(image, threshold, npixels)

Although statmorph is designed to process all the sources labeled by the segmentation map, in this example we only focus on the main (largest) source found in the image.


In [8]:
# Keep only the largest segment
label = np.argmax(segm.areas) + 1
segmap = segm.data == label
plt.imshow(segmap, origin='lower', cmap='gray')


Out[8]:
<matplotlib.image.AxesImage at 0x7fb4ef69b750>

We regularize a bit the shape of the segmentation map:


In [9]:
segmap_float = ndi.uniform_filter(np.float64(segmap), size=10)
segmap = segmap_float > 0.5
plt.imshow(segmap, origin='lower', cmap='gray')


Out[9]:
<matplotlib.image.AxesImage at 0x7fb4ef5add90>

Measuring morphology

Now that we have all the required data, we are ready to measure the morphology of the source just created. Note that we include the PSF as a keyword argument. In principle, this results in more correct Sersic profile fits, although it can also make the code run slower, depending on the size of the PSF.


In [10]:
start = time.time()
source_morphs = statmorph.source_morphology(image, segmap, gain=gain, psf=psf)
print('Time: %g s.' % (time.time() - start))


Finished processing source 1.

Time: 0.666117 s.

In general, source_morphs is a list of objects, each corresponding to a labeled source in the image. Here we only focus on the first labeled source.


In [11]:
morph = source_morphs[0]

Now we print some of the morphological properties just calculated:


In [12]:
print('xc_centroid =', morph.xc_centroid)
print('yc_centroid =', morph.yc_centroid)
print('ellipticity_centroid =', morph.ellipticity_centroid)
print('elongation_centroid =', morph.elongation_centroid)
print('orientation_centroid =', morph.orientation_centroid)
print('xc_asymmetry =', morph.xc_asymmetry)
print('yc_asymmetry =', morph.yc_asymmetry)
print('ellipticity_asymmetry =', morph.ellipticity_asymmetry)
print('elongation_asymmetry =', morph.elongation_asymmetry)
print('orientation_asymmetry =', morph.orientation_asymmetry)
print('rpetro_circ =', morph.rpetro_circ)
print('rpetro_ellip =', morph.rpetro_ellip)
print('rhalf_circ =', morph.rhalf_circ)
print('rhalf_ellip =', morph.rhalf_ellip)
print('r20 =', morph.r20)
print('r80 =', morph.r80)
print('Gini =', morph.gini)
print('M20 =', morph.m20)
print('F(G, M20) =', morph.gini_m20_bulge)
print('S(G, M20) =', morph.gini_m20_merger)
print('sn_per_pixel =', morph.sn_per_pixel)
print('C =', morph.concentration)
print('A =', morph.asymmetry)
print('S =', morph.smoothness)
print('sersic_amplitude =', morph.sersic_amplitude)
print('sersic_rhalf =', morph.sersic_rhalf)
print('sersic_n =', morph.sersic_n)
print('sersic_xc =', morph.sersic_xc)
print('sersic_yc =', morph.sersic_yc)
print('sersic_ellip =', morph.sersic_ellip)
print('sersic_theta =', morph.sersic_theta)
print('sky_mean =', morph.sky_mean)
print('sky_median =', morph.sky_median)
print('sky_sigma =', morph.sky_sigma)
print('flag =', morph.flag)
print('flag_sersic =', morph.flag_sersic)


xc_centroid = 120.0499996444634
yc_centroid = 96.0280842357917
ellipticity_centroid = 0.4931527870505855
elongation_centroid = 1.972981155762623
orientation_centroid = 0.5007478736739457
xc_asymmetry = 120.01888604867575
yc_asymmetry = 96.01473182912568
ellipticity_asymmetry = 0.49315344788381166
elongation_asymmetry = 1.9729837281615015
orientation_asymmetry = 0.5007475256066473
rpetro_circ = 33.21485364762297
rpetro_ellip = 44.615006267722734
rhalf_circ = 15.003851909850958
rhalf_ellip = 19.888190969850438
r20 = 6.300367936994521
r80 = 26.645044248767714
Gini = 0.5290077073184654
M20 = -1.9155623404417832
F(G, M20) = -0.013927146847440675
S(G, M20) = -0.06954553507612715
sn_per_pixel = 22.279094150355558
C = 3.1312526655872146
A = -0.0008091864221996277
S = 0.00848836301685182
sersic_amplitude = 1.0075771719817648
sersic_rhalf = 19.924212800698374
sersic_n = 1.4904154332705293
sersic_xc = 119.99950616747263
sersic_yc = 96.00048310985905
sersic_ellip = 0.4999724186714237
sersic_theta = 0.4998375400159127
sky_mean = 0.001003993483089696
sky_median = 0.0010949023482071448
sky_sigma = 0.010136873182253868
flag = 0
flag_sersic = 0

Note that the fitted Sersic profile is in pretty good agreement with the "true" Sersic profile that we originally defined (n=1.5, r_eff=20, etc.). However, such agreement tends to deteriorate somewhat at higher noise levels and larger Sersic indices (not to mention that real galaxies are not always well described by Sersic profiles). Other morphological measurements that are more robust to noise, which are also calculated by statmorph, include the Gini-M20 (Lotz et al. 2004), CAS (Conselice 2003) and MID (Freeman et al. 2013) statistics, as well as the outer asymmetry (Wen et al. 2014) and shape asymmetry (Pawlik et al. 2016).

Also note that statmorph calculates two different "bad measurement" flags (where 0 means good measurement and 1 means bad):

  1. flag : indicates a problem with the basic morphological measurements.

  2. flag_sersic : indicates if there was a problem/warning during the Sersic profile fitting.

In general, flag==0 should always be enforced, while flag_sersic==0 should only be used when interested in Sersic fits (which might fail for merging galaxies and other "irregular" objects).

Examining the fitted Sersic profile

Finally, we can reconstruct the fitted Sersic profile and examine its residual. Here we used the ConvolvedSersic2D class defined in statmorph.


In [13]:
ny, nx = image.shape
y, x = np.mgrid[0:ny, 0:nx]
fitted_model = statmorph.ConvolvedSersic2D(
    amplitude=morph.sersic_amplitude,
    r_eff=morph.sersic_rhalf,
    n=morph.sersic_n,
    x_0=morph.sersic_xc,
    y_0=morph.sersic_yc,
    ellip=morph.sersic_ellip,
    theta=morph.sersic_theta)
fitted_model.set_psf(psf)  # always required when using ConvolvedSersic2D
image_model = fitted_model(x, y)
bg_noise = (1.0 / snp) * np.random.standard_normal(size=(ny, nx))
fig = plt.figure(figsize=(15,5))
ax = fig.add_subplot(131)
ax.imshow(image, cmap='gray', origin='lower',
           norm=simple_norm(image, stretch='log', log_a=10000))
ax.set_title('Original image')
ax = fig.add_subplot(132)
ax.imshow(image_model + bg_noise, cmap='gray', origin='lower',
           norm=simple_norm(image, stretch='log', log_a=10000))
ax.set_title('Fitted model')
ax = fig.add_subplot(133)
residual = image - image_model
ax.imshow(residual, cmap='gray', origin='lower',
           norm=simple_norm(residual, stretch='linear'))
ax.set_title('Residual')


Out[13]:
Text(0.5, 1.0, 'Residual')

Examining other morphological diagnostics

For convenience, we also provide a make_figure function that can be used to visualize some of the basic morphological measurements carried out by statmorph. This creates a multi-panel figure analogous to Fig. 4 from Rodriguez-Gomez et al. (2019).


In [14]:
from statmorph.utils.image_diagnostics import make_figure
fig = make_figure(morph)



In [15]:
fig.savefig('tutorial.png', dpi=150)