After working through this tutorial you should be able to:
This notebook is intended to be runnable on lsst-lsp-stable.ncsa.illinois.edu
from a local git clone of https://github.com/LSSTScienceCollaborations/StackClub.
When editing the stackclub
package files, we want the latest version to be imported when we re-run the import command. To enable this, we need the %autoreload magic command.
%load_ext autoreload
%autoreload
You can find the Stack version that this notebook is running by using eups list -s on the terminal command line:
# What version of the Stack am I using?
! echo $HOSTNAME
! eups list -s | grep lsst_distrib
nb-kadrlica-r21-0-0 lsst_distrib 21.0.0+973e4c9e85 current v21_0_0 setup
For this tutorial we'll need the following modules:
%matplotlib inline
import warnings
# Filter some warnings printed by v16.0 of the stack
warnings.simplefilter("ignore", category=FutureWarning)
warnings.simplefilter("ignore", category=UserWarning)
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import LogNorm
from mpl_toolkits.axes_grid1 import make_axes_locatable
import matplotlib.animation as animation
from IPython.display import IFrame, display, Markdown, HTML
import lsst.daf.persistence as dafPersist
import lsst.afw.table as afwTable
from lsst.meas.algorithms import SourceDetectionTask
from lsst.meas.base import NoiseReplacer, NoiseReplacerConfig
import lsst.obs.base as obsBase
import lsst.afw.detection as afwDetection
import lsst.afw.display as afwDisplay
afwDisplay.setDefaultBackend('matplotlib')
plt.rcParams['figure.figsize'] = (8.0, 8.0)
To paraphrase from Bosch et al. (2017),
Footprints record the exact above-threshold detection region on a CCD. These are similar to SExtractor’s “segmentation map", in that they identify which pixels belong to which detected objects
As you might expect, this means footprints are integral to high-level CCD processing tasks—like detection, measurement, and deblending—which directly impact science results. Because footprints are so closely related to these very important processes, we will take a close look at them in this notebook.
In the quote above, an analogy was drawn between footprints and segmentation maps, as they both identify above threshold pixels. As we first introduce footprints, we will concentrate on this similarity as it gives us a place to start understanding the location and geometeric properties of footprints.
dataset='DC2'
# Reset the filter definition for the dataset
obsBase.FilterDefinitionCollection.reset()
if dataset == 'HSC':
# There are several "layers" of HSC data: SSP_WIDE, SSP_DEEP, SSP_UDEEP
datadir = '/datasets/hsc/repo/rerun/RC/v20_0_0_rc1/DM-25349'
dataId = {'filter':'HSC-I', 'tract': 9615, 'patch':'0,3'}
#dataset_type = "deepCoadd_calexp" # provides more agressive background subtraction
dataset_type = "deepCoadd"
elif dataset == 'DC2':
# DC2 WFD coadd
datadir = '/datasets/DC2/DR6/Run2.2i/patched/2021-02-10/rerun/run2.2i-coadd-wfd-dr6-v1'
dataId = {'filter':'i', 'tract': 4226, 'patch':'0,4'}
dataset_type = "deepCoadd"
# Create the butler and grab the data
butler = dafPersist.Butler(datadir)
deepCoadd = butler.get(dataset_type, dataId=dataId)
# Let's grab the source catalog too
cat = butler.get(dataset_type+'_meas', dataId=dataId)
# Take a look at the image of the patch
plt.figure(figsize=(30,30))
afw_display = afwDisplay.Display()
afw_display.setMaskTransparency(100)
afw_display.scale('asinh', 'zscale')
afw_display.mtv(deepCoadd.getMaskedImage())
<Figure size 2160x2160 with 0 Axes>
# set up minimal detection task
config = SourceDetectionTask.ConfigClass()
# detect sources at 10 sigma
config.thresholdValue = 10
config.thresholdType = "stdev"
sourceDetectionTask = SourceDetectionTask(config=config)
We will use the detectFootprints
method in SourceDetectionTask
to find and store the detected footprints in the image
# run detection on our frame
detect_res = sourceDetectionTask.detectFootprints(deepCoadd)
detect_res
is a Struct
. Struct
objects are similar to python dictionaries, in that they have key value pairs. However, here you access the values by using the keys as an attribute. If you are and IDL user, these will remind you of Structures
in IDL.
detect_res
Struct(positive=<lsst.afw.detection.FootprintSet object at 0x7f45ec37db30>; negative=None; factor=1.0; background=<lsst.afw.math.backgroundList.BackgroundList object at 0x7f45ee5d51d0>; numPos=5758; numPosPeaks=6978; numNeg=0; numNegPeaks=0)
Looking at detect_res
, we can see that there are attributes associated with sources detected positive (above threshold) and detected negative (below threshold). Let's concentrate on the positive detections for now, so we can look at astrophysical sources. The positive attribute holds a FootprintSet
, an object that contains all the positively detected footprints. The numPos
attribute tells us the number of footprints that were detected positive. The numPosPeaks
attribute tells us the number of peaks that were detected positive. You'll notice that the number of peaks is larger because more than one peak can belong to a footprint. We will discuss peaks more fully in a later section, but for now think of them as what they sound like—the peaks in the surface brightness profile.
# lets grab the above threshold footprints that were detected and assign them to a varriable
fpset = detect_res.positive
the variable fpset
is a FootprintSet
object. While it contains many footprints itself, a few more steps are required to obtain individual footprints. Namely, we will ask for a FootprintSet
to give us a list of footprints, which we can iterate over
fps = fpset.getFootprints()
Now we have drilled our way down to getting our hands on footprints. Lets discuss some of their methods and attributes. To begin, we will concentrate on footprint geometry and location.
# Get the region
# This will return a box with the pixel coordinates of the CCD that the footprint lies on
fps[0].getRegion()
Box2I(minimum=Point2I(0, 15900), dimensions=Extent2I(4100, 4200))
# The center of the footprint, in the x,y coordinates on the CCD chip
fps[0].getCentroid()
Point2D(2813.769231, 15906.30769)
The bounding box of the footprint. This is the smallest rectangle you can enscribe around the footprint. Not all of the pixels in the bounding box are pixels in the footprint, since the footprint is not necessarily rectangular in shape. the BBox will always be inside the region
fps[0].getBBox()
Box2I(minimum=Point2I(2809, 15902), dimensions=Extent2I(11, 10))
Get the area of the footprint. NOTE: this is not the area of the bounding box. It is the number of pixels associated with the footprint.
fps[0].getArea()
65
The shape of a footprint can be calculated. It is assumed to be an ellipse, and the relevent parameters are defined with the quadurpole, $Q_{i,j}$, a matrix containing the second moments, where
$$Q_{i,j} = \int x_i x_j \mathrm{d}x^i \mathrm{d}x^j$$Notice this definition does not include the surface brightness of the detected object. That is to say, the quadropole computed for footprints describes the geometry of the pixels in the footprint, not the intensity weighted shape of its detected object.
The elements for a footprint can be computed with the getShape()
method. Note that the matix is symmetric.
fps[0].getShape()
Quadrupole(ixx=5.5621301775147876, iyy=5.136094674556213, ixy=-0.46745562130177515)
Looking at $Q_{i,j}$, the diagonal elements are close in magnitude, and the off diagonal elements are close to zero. We expect the footprint to be roughly circular. Now that we have all the pertinent information about where the footprint is and what pixels belong to it, we can display it and see if our expectation is true.
We get information about which pixels in the footprints bounding box actually belong to the footprint by using its SpanSet
. The DM stack is object oriented, and often classes inherit or contain other classes. That is ture here too. The spanSet is a collection of n
spans
, where each span has m
elements, where n
and m
are the height and width of the bounding box, respectively.
If an element in a span is zero, this means the pixel is inside the footprint's bounding box but does not belong to the footprint. If an element is one, the pixel is both in the bounding box and associated with the footprint.
Lets dump the output of the spanset from the first footprint in our list
fps[0].getSpans()
[0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0] [0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0] [0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0] [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0] [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0] [0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0] [0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0] [0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0] [0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0]
You can almost see the footprint by looking at the 1's and zeros here. To extract the actual pixel values that correspond to the ones in the span, we need an additional step. At the moment, our footprints can tell you if a pixel belongs to it or not, buthave no way of accessing pixel values on the deepCoadd
. To remedy this, we will turn our footprint into a HeavyFootprint
. HeavyFootprints have all of the qualities of Footprints, but additionally 'know' about pixel level data from the deepCoadd image, variance, and mask planes.
# first we domonstrate the footprint is not heavy
fps[0].isHeavy()
False
# we will make all the footprins heavy at the same time by operating on the footprint set
fpset.makeHeavy(deepCoadd.getMaskedImage())
# we have to redefine fps
hfps = fpset.getFootprints()
# prove the footprints are now indeed heavy
hfps[0].isHeavy()
True
# all of the arrays here will be flattend 1D arrays of pixels from the footprint
hfps[0].getImageArray()
array([ 0.11573484, 0.04972936, 0.08429693, 0.10734895, 0.01472075, 0.04201473, 0.08537254, 0.19513902, 0.05400472, 0.13219565, 0.10588662, 0.05250683, 0.09763835, 0.1104894 , 0.1150781 , 0.04088843, 0.13299188, 0.13513364, 0.16948812, 0.2151161 , 0.1755628 , 0.07756021, 0.04945043, 0.06386581, 0.08539727, 0.10681199, 0.05153239, 0.08384833, 0.17738134, 0.22146425, 0.2317412 , 0.23162585, 0.13299598, 0.12093912, 0.1179215 , 0.14433497, 0.08422835, 0.17975338, 0.07573207, 0.21867846, 0.21538414, 0.12452497, 0.0960758 , 0.15244317, 0.07824084, 0.11182557, 0.15689485, 0.22277844, 0.15605615, 0.093711 , -0.03043608, 0.0036625 , 0.07141926, 0.02705521, 0.1301129 , 0.12911531, 0.21227098, 0.146878 , 0.19132118, 0.01902573, -0.00237089, 0.11525813, 0.00975156, 0.08873113, 0.20294233], dtype=float32)
Now we can use the spanset to reassemble the image array into the footprint. Above we saw that the image array is a 1D numpy array-but the footprint itself is 2 dimensional. Fortunately, the span set has an unflatten
method that we will use, which can rearrange the image array into the proper 2 dimensional shape
plt.imshow(fps[0].getSpans().unflatten(hfps[0].getImageArray()),
cmap='bone', origin='lower')
<matplotlib.image.AxesImage at 0x7f45eda86c10>
We can do a simmilar exercise with the mask plane values, instead of the image plane values. We can first grab the (flattened) mask plane values of the footprint
hfps[0].getMaskArray()
array([53296, 53296, 53296, 53296, 53296, 53296, 53296, 53296, 53296, 53296, 53296, 53296, 53296, 53296, 53296, 53296, 53296, 53296, 53296, 53296, 53296, 53296, 53296, 53296, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280, 53280], dtype=int32)
To understand these values, lets look at the mask plane's dictionary
deepCoadd.getMask().getMaskPlaneDict()
{'BAD': 0, 'BRIGHT_OBJECT': 9, 'CLIPPED': 10, 'CR': 3, 'CROSSTALK': 11, 'DETECTED': 5, 'DETECTED_NEGATIVE': 6, 'EDGE': 4, 'INEXACT_PSF': 12, 'INTRP': 2, 'NOT_DEBLENDED': 13, 'NO_DATA': 8, 'REJECTED': 14, 'SAT': 1, 'SENSOR_EDGE': 15, 'SUSPECT': 7, 'UNMASKEDNAN': 16}
The values are the exponent of the bitmask. So Pixels only marked detected will be 2^5 = 32. Pixels that are both on the edge and detected will be 2^5 + 2^4 = 48. Now we will visualize this in a similar manner to the imshow exercise we did before, only now we are only using data for the footprint because we are using the span.
plt.figure(figsize=(8,8))
ax = plt.gca()
# create an axes on the right side of ax. The width of cax will be 5%
# of ax and the padding between cax and ax will be fixed at 0.05 inch.
divider = make_axes_locatable(ax)
im = plt.imshow(fps[0].getSpans().unflatten(hfps[0].getMaskArray()),
origin='lower')
cax = divider.append_axes("right", size="5%", pad=0.05)
plt.colorbar(im, cax=cax, ticks=[0, 32, 32+16])
<matplotlib.colorbar.Colorbar at 0x7f45edb7ab50>
We can grow or shrink footprints by using the dilate()
and erode()
methods. You may imagine doing this if you want to ensure you are getting all the flux associated with the detected source, or break apart one big footprint that contains many sources into several which each contain far fewer. Using these methods requires that we provide a scale by which to grow or shrink the footprint. This scale is often related to the PSF scale, e.g., scaling the RMS or $\sigma$ of a gaussian PSF.
In the dilation process, we can grow out footprint by including its surroundinb pixels. During erosion, we shrinking the object by excluding its outermost pixels.
Let's demonstrate this below
# first get the gaussian fwhm of the PSF
psf = deepCoadd.getPsf()
sigma = psf.computeShape().getDeterminantRadius()
# print how many pixels belong to the footprint before we dilate it
print('before dilating the footprint has an area of {}'.format(hfps[0].getArea()))
# now we will grow the footprint by 2 x sigma. We need to pass an int
hfps[0].dilate(int(2*sigma))
# print how many pixels belong to the footprint ater dilation
print('after dilating the footprint has an area of {}'.format(hfps[0].getArea()))
before dilating the footprint has an area of 65 after dilating the footprint has an area of 173
# lets chip away at the footprint by using the erode method now
hfps[0].erode(int(2*sigma))
# print how many pixels belong to the footprint ater dilation
print('after eroding the footprint has an area of {}'.format(hfps[0].getArea()))
after eroding the footprint has an area of 65
To give us some more intuition on how dilation effects the footprints, we will show cut outs of several footprints before they have been dilated (top row) and after they have been dilated. To exagerate the effect, we will use a large scale length in the dilation. Note the change in the range of the x and y axes in these images.
def plot_fp(ax, fp, **kwargs):
span = fp.getSpans()
im_data = fp.getImageArray()
ax.imshow(span.unflatten(im_data), origin='lower', **kwargs)
return
f, ax = plt.subplots(nrows=3, ncols=4, figsize=(10,6))
# prepare a list with indicies of a few footprints
fp_ids = [100,200,300,400]
# make cut outs of footprints before dilation
for i, j in zip(range(len(fp_ids)), fp_ids):
plot_fp(ax[0,i], hfps[j])
# dilate footprints
for j in fp_ids:
hfps[j].dilate(int(5*sigma))
# we need to make the dilated footprints heavy again
# updating the spans by dilation does not automatically update their associated image data
fpset.makeHeavy(deepCoadd.getMaskedImage())
hfps = fpset.getFootprints()
# cut outs of footprints after dilation
for i, j in zip(range(len(fp_ids)), fp_ids):
plot_fp(ax[1,i], hfps[j])
# now lets erode by the same scale so we can demonstrate that
# erode and dilate are inverses of each other
for j in fp_ids:
hfps[j].erode(int(5*sigma))
# make them heavy again
fpset.makeHeavy(deepCoadd.getMaskedImage())
hfps = fpset.getFootprints()
# plot the last row
for i, j in zip(range(len(fp_ids)), fp_ids):
plot_fp(ax[2,i], hfps[j])
plt.tight_layout()
In the previous sections, we paid close attention to a fairly bright, isolated sources and their associated footprints that were identified as being above a 10$\sigma$ threshold. In pratice, especially as we detect with lower thresholds, footprints from a first-pass threshold detection can contain several astronomical objects. This is because the wings of the intensity profiles of individual, but closely packed, sources can bleed into each other, forming one above threshold region, and thus one footprint.
Formally, pixels which are local maxima in the smoothed image during detection are regarded as peaks, and each peak is assumed to be an astrophysical source. In the subsequent deblending process, the deblender looks at all the peaks in each footprints, and gives them their own deblended child footprint. In addition to identifying realestate to deblended children footprints, the deblender also allocates flux from the parent footprint to its children. As a result, the flux in a deblended heavy footprint will not necessarily equal the flux at the same pixels in its image.
In this section we will examine footprints that contain more than one peak, and the interplay between footprints, peaks, and deblending. To do so, we will read footprints from data that has already gone through detection, deblending, and measurement.
In the following few cells, we will use a few afwTable
tricks. Because we are interested in understanding peaks and deblending, we are going to sort the source catalog to find the source with the largest number of deblended children. You can learn more about afwTables
in the afwTables: A Guided Tour notebook.
# We grabbed the source catalogs at the start of the notebook
print(type(cat))
# First get the key for the deblend_nChild field, and id. We need these to sort on them
nChild_key = cat.getSchema().find('deblend_nChild').key
idKey = cat.getIdKey()
# Sort the catalog on number of children
cat.sort(nChild_key)
# The catalog is sorted low to high, so grab the last element
# Use the nChild key to get the number of children, and the id key to get the parent's ID
num_children = cat[-1].get(nChild_key)
parent_id = cat[-1].getId()
# Footprints are stored in the source catalog, we can access them easily
parent_fp = cat[-1].getFootprint()
print('Parent source {} has {} deblended children'.format(parent_id, num_children))
<class 'lsst.afw.table.SourceCatalog'> Parent source 18586161735799627 has 86 deblended children
The footprint of the parent object contains catalog of the peaks it contains. This catalog gives the peaks and thier locations and peak pixel value in the smoothed image. During the detection process, the image in question is smoothed with a spatial filter. A nice demonstration of this is avaliable in the LowSurfaceBrightness notebook. The peaks' centers and counts are in this smoothed image, and may differ slightly from their subsequent children footprints after deblending.
parent_fp = cat[-1].getFootprint()
parent_fp.getPeaks()
<class 'lsst.afw.detection.PeakCatalog'> id f_x f_y i_x i_y peakValue ... merge_peak_r merge_peak_z merge_peak_y merge_peak_g merge_peak_u merge_peak_sky pix pix pix pix ct ... ----- ------ ------- ---- ----- ----------- ... ------------ ------------ ------------ ------------ ------------ -------------- 56428 2107.0 17914.0 2107 17914 200.05702 ... True True True True True False 56461 2045.0 18088.0 2045 18088 77.95774 ... True True True True True False 56417 2178.0 17830.0 2178 17830 20.211931 ... True True True True True False 56150 2026.0 17804.0 2026 17804 7.4411597 ... True True True True True False 56427 2068.0 17907.0 2068 17907 5.1338825 ... True True True True True False 43784 2205.0 18032.0 2205 18032 4.7647886 ... True True True True False False 56452 2153.0 18041.0 2153 18041 2.3221982 ... True True True True True False 56456 2009.0 18040.0 2009 18040 2.2721548 ... True True True True False False 56448 2134.0 18024.0 2134 18024 2.1709893 ... True True True True True False 56211 2337.0 17843.0 2337 17843 2.1109066 ... True True True True True False 56444 2193.0 17995.0 2193 17995 1.2246596 ... True True True True True False 56434 2218.0 17913.0 2218 17913 1.0821986 ... True True True True True False 56437 1987.0 17933.0 1987 17933 0.8506877 ... True True True True True False 43287 1963.0 17914.0 1963 17914 0.79272425 ... True True True True True False ... ... ... ... ... ... ... ... ... ... ... ... ... 42770 2153.0 17787.0 2153 17787 0.052919246 ... False False False True False False 42728 2154.0 17774.0 2154 17774 0.052187067 ... False False False True False False 56270 2258.0 17949.0 2258 17949 0.050748967 ... True False False True False False 60705 2003.0 18004.0 2003 18004 0.050724022 ... True False False True False False 56441 2173.0 17956.0 2173 17956 0.05023223 ... True False False False False False 42755 2139.0 17781.0 2139 17781 0.049945544 ... False False False True False False 56463 1990.0 18063.0 1990 18063 0.04912457 ... True False False True False False 43575 1986.0 17982.0 1986 17982 0.046400122 ... False False False True False False 43097 2266.0 17857.0 2266 17857 0.04324682 ... True False False True False False 60692 2174.0 17911.0 2174 17911 0.042229384 ... True False False True True False 44454 1989.0 17811.0 1989 17811 0.033330575 ... True False False True False False 60675 2232.0 17834.0 2232 17834 0.026347104 ... True False False True False False 60679 2201.0 17856.0 2201 17856 0.024713702 ... True False False True False False 45409 1972.0 18014.0 1972 18014 0.018217957 ... True False False True False False Length = 86 rows
Now we will set up a visualization to see our parent source, the locations of its peaks (as red crosses), and the centers of the deblended children footprints (as orange circles).
# Get the centroid of the parent
parent_x = cat[-1].getX()
parent_y = cat[-1].getY()
parent_x = int(parent_x)
parent_y = int(parent_y)
# Sort the catalog on ID so we can get a view of the
# catalog that contains the children of the parent source
cat.sort(idKey)
child_cat = cat.getChildren(parent_id)
# credit to ADW for this afwDisplay snippet
plt.figure()
afw_display = afwDisplay.Display()
afw_display.setMaskTransparency(100)
afw_display.scale('asinh', 'zscale')
afw_display.mtv(deepCoadd.getMaskedImage()[parent_x-200:parent_x+200, parent_y-200:parent_y+200])
# We use display buffering to avoid re-drawing the image after each source is plotted
with afw_display.Buffering():
# loop over child footprints, placing a red plus on their centers
for s in child_cat:
afw_display.dot('+', s.getX(), s.getY(), ctype=afwDisplay.RED)
# loop over peaks, placing an orange circle on their centers
for p in parent_fp.getPeaks():
afw_display.dot('o', p.getIx(), p.getIy(), ctype=afwDisplay.ORANGE, size=4)
<Figure size 576x576 with 0 Axes>
A few comments are in order about the above image.
Even though you can identify by eye there is are several distinct sources here, the detection algorithm grouped them together into one big footprint. Remember, that is totally fine, as footprints can have multiple "peaks" that correspond to different sources. Take a look at one of the large galaxies. It has several peaks associated with it, even though by eye we might call it one large astrophysical object. The deblender shredded these sources into several children sources. This is a known failure mode of deblenders. Deblending is hard, and we will not attempt to solve it here, instead opting to restrict our attention to footprints. If you are interested in learning more about deblending beyond the scope of footprints, Fred Moolekamp has prepared deblending notebooks for the StackClub.
The peaks are marked in the detection process. The peaks are formally detections of distinct astrophysical sources. Notice this is different than Source Extractor. although by eye you can tell they are likely just noise peaks.
The peaks in the parent footprint (red crosses) and the centers of the deblended children are almost all right about on top of each other. There is, however, a region where there is some miss-match. To see this more clearly, we will re-make the plot but zoom in.
plt.figure()
afw_display = afwDisplay.Display()
afw_display.setMaskTransparency(100)
afw_display.scale('asinh', 'zscale')
#afw_display.mtv(deepCoadd.getMaskedImage()[3100:3200, 14600:14750])
afw_display.mtv(deepCoadd.getMaskedImage()[parent_x-100:parent_x+100, parent_y-100:parent_y+100])
# We use display buffering to avoid re-drawing the image after each source is plotted
with afw_display.Buffering():
# loop over child footprints, placing a red plus on their centers
for s in child_cat:
afw_display.dot('+', s.getX(), s.getY(), ctype=afwDisplay.RED)
# loop over peaks, placing an orange circle on their centers
for p in parent_fp.getPeaks():
afw_display.dot('o', p.getIx(), p.getIy(), ctype=afwDisplay.ORANGE, size=4)
<Figure size 576x576 with 0 Axes>
Now that we have covered erosion/dilation and peaks, we will discuss the interplay between the two. Previously we saw we can change the shape of a footprint by using erosion and dilation operations. Because footprints also contain a list of peaks contained by its span, these operations will in turn effect peaks. Let's continue using the elliptical galaxy with many noise peaks from the previous section.
We will fist plot the elliptical image data and overlay its peaks with red crosses. Then we will erode the eliptical and see the impact this has on peaks.
# Create a heavy footprint. This will let us grab pixel info associated with the footprint
heavy_parent_fp = afwDetection.HeavyFootprintF(parent_fp, deepCoadd.getMaskedImage())
f, ax = plt.subplots(ncols=2, nrows=1)
plot_fp(ax[0], heavy_parent_fp,norm=LogNorm())
# iterate over the peaks and overplot them on the image as red crosses
for p in heavy_parent_fp.getPeaks():
ax[0].plot((p.getIx() - heavy_parent_fp.getBBox().getBeginX()),
(p.getIy() - heavy_parent_fp.getBBox().getBeginY()), 'r+')
# the title will tell us how many peaks are in the FP before erosion
ax[0].set_title('before erosion, {} peaks'.format(len(heavy_parent_fp.getPeaks())))
# erode the footprint by a large ammount, then repeat the process.
heavy_parent_fp.erode(10)
# assign pixel values to the new eroded heavy footprint
eroded_heavy_parent_fp = afwDetection.HeavyFootprintF(heavy_parent_fp, deepCoadd.getMaskedImage())
plot_fp(ax[1], eroded_heavy_parent_fp,norm=LogNorm())
for p in eroded_heavy_parent_fp.getPeaks():
ax[1].plot((p.getIx() - heavy_parent_fp.getBBox().getBeginX()),
(p.getIy() - heavy_parent_fp.getBBox().getBeginY()), 'r+')
ax[1].set_title('after erosion, {} peaks'.format(len(eroded_heavy_parent_fp.getPeaks())))
plt.tight_layout()
A few things happened to the footprint after erosion.
Its area decreased, as expected
The number of peaks decreased. When you use the erode
method, any peaks that fall outside the footprint after erosion (called orphan peaks in DM parlance) are discarded. If you happen to alter the span set of a footprint in antother way, you may also use the removeOrphanPeaks
method by hand to cull any peaks that are outside the span.
The footprint was broken up and is no longer contiguous. We can check the contiguity explicitly
eroded_heavy_parent_fp.isContiguous()
False
We can remedy this by using the split
method on the non-contiguous footprint. This will give us several contiguous footprints, in which all peaks from the initial peak are contained
# split the non-contiguous fp into a vector of contiguous ones
split_fps = eroded_heavy_parent_fp.split()
f, ax = plt.subplots(nrows=1, ncols=2, figsize=(20, 7))
# plot up the split fps
plot_fp(ax[0],afwDetection.HeavyFootprintF(split_fps[0], deepCoadd.getMaskedImage()),norm=LogNorm())
plot_fp(ax[1],afwDetection.HeavyFootprintF(split_fps[1], deepCoadd.getMaskedImage()),norm=LogNorm())
We have established that deblending can go wrong, and give us unreliable footprints at times. But how does this impact the measurement process? How much flux actually goes to peaks? We will build our intuition for the impact of deblending on heavy footprints in the next section.
As we mentioned at the start of our notebook, footprints work hand in hand with detection, deblending, and measurement. In the following few cells we will loosely follow high level steps that are taken in single frame measurement, so we can understand how footprint deblending can impact measurements and science results.
Refering again to Bosch et al. (2017), the procedure for source measurement is
- We replace all Footprints in the image with random Gaussian noise with the same variance as the original noise in those pixels.
- We insert the deblended pixels for a particular source to be measured back into the image (replacing the noise pixels).
- We run all measurement plug-ins on the current source.
- We re-replace the Footprint of the current source with noise.
- Repeat this process for the next Footprint
In our discussion below, we will concentrate on one parent footprint, and visualize its deblended children being swapped back in.
# We will sort the source catalog and grab the object that has a bright psf flux and plenty of children
cat.sort(cat.getPsfFluxSlot().getMeasKey())
cat = cat.copy(deep=True)
# the 55th brightest source should be bright enough
# sources much brighter than this tend to be saturated stars,
# but feel free to play around with this
cat.sort(nChild_key)
idx = -55
# verify there are plenty of children
cat[idx].get(nChild_key)
20
# Find the centroid of this source so we can visualize it in a few steps
x, y = cat[idx].getCentroid()
ix = int(x)
iy= int(y)
# grab the parent ID
pId = cat[idx].getId()
Before we start playing around with swapping footprints in and out of the frame, lets take a look at the calexp as it is. We can see a nice bright source at the center, along with a few distinct fainter sources in the background
cat.sort()
children = cat.getChildren(pId)['id']
children
array([18586161735838614, 18586161735838615, 18586161735838616, 18586161735838617, 18586161735838618, 18586161735838619, 18586161735838620, 18586161735838621, 18586161735838622, 18586161735838623, 18586161735838624, 18586161735838625, 18586161735838626, 18586161735838627, 18586161735838628, 18586161735838629, 18586161735838630, 18586161735838631, 18586161735838632, 18586161735838633], dtype=int64)
afw_display = afwDisplay.Display()
afw_display.setMaskTransparency(100)
afw_display.scale('asinh', 'zscale')
afw_display.mtv(deepCoadd.getMaskedImage()[ix - 100:ix + 100,iy - 100:iy + 100])
Now we will see how we can exchange the detection footprints with noise by using the NoiseReplacer
object in the stack. We will emulate steps that are taken in SingleFrameMeasurementTask
, where measurement algorithms are run on sources. The next two cells are inspired by a few lines from sfm.py. We will first need to organize our footprints into a dictionary, where the source ID's are the keys and the associated value pairs are the footprints themselves.
We will also point out that you can grab footprints directly from the source catalog.
# prepare footprints in dictionary
fp_dict = {measRecord.getId(): (measRecord.getParent(), measRecord.getFootprint())
for measRecord in cat}
# instantiate NoiseReplacer. show it our calexp and footprints
nr_config = NoiseReplacerConfig()
noiseReplacer = NoiseReplacer(nr_config, deepCoadd, fp_dict)
As we create the noiseReplacer
object, noise properties are calculated using the pixel values whose associated mask plane pixels have not been set. This noise distribution is used to replace the footprints with noise that is generated to be representative of the true noise properties of the deepCoadd. Now we will display the deepCoadd again to see the result.
plt.figure()
afw_display = afwDisplay.Display()
afw_display.setMaskTransparency(100)
afw_display.scale('asinh', 'zscale')
afw_display.mtv(deepCoadd.getMaskedImage()[ix-100:ix+100,iy-100:iy+100])
<Figure size 576x576 with 0 Axes>
As advertised, we swapped out the data in our footprints with noise. As an aside, if you look closely you can see an 'edge' in the data where the parent object used to be, suggesting perhaps the faint tails of the elliptical's surface brightness profile were missed in detection and now remain in our noise image. We could imagine dilating the footprint to capture these.
Now we will draw some more inspiration from sfm.py where we will insert our parent footprint of interest back into the image using its ID from the source catalog.
noiseReplacer.insertSource(pId)
plt.figure()
afw_display = afwDisplay.Display()
afw_display.setMaskTransparency(100)
afw_display.scale('asinh', 'zscale')
afw_display.mtv(deepCoadd.getMaskedImage()[ix-100:ix+100,iy-100:iy+100])
<Figure size 576x576 with 0 Axes>
We have used our NoiseReplacer
instance to ensure this parent footprint is the only source in the image, and all other's have been replaced with noise. The sequence during the measurement process on parent and children footprints would roughly follow like this:
This process would iterate over all parent and child sources. In the next cell we will make a small animation to let us visualize this.
fig = plt.figure()
# ims is a list of lists, each row is a list of artists to draw in the
# current frame; here we are just animating one artist, the image, in
# each frame
ims = []
src_ids = [pId] + children.tolist()
for i in src_ids:
noiseReplacer.insertSource(i)
data = deepCoadd.getMaskedImage()[ix-100:ix+100,iy-100:iy+100].getImage().array
im = plt.imshow(data, origin='lower', animated=True,
vmin=0, vmax=5,
visible=False)
ims.append([im])
noiseReplacer.removeSource(i)
# the plt.close catches a spurious extra image
plt.close()
# makes an animation of the parent, then child footprints
HTML(animation.ArtistAnimation(fig, ims, interval=1000, blit=True,
repeat_delay=1000).to_jshtml())
# takes a minute to get going,
At first glance, in the animation above it seems the central bright galaxies and fainter smaller sources are successfully deblended. However, as the movie progresses we can see that the bright galaxy is shredded by the deblender. This is a common failure mode of the deblender. There are peaks in the outer wings of the galaxy; the deblender receives this list of peaks and assigns flux to them, creating these wispy looking sources that are inserted into the image. Because the deblender conserves the flux of the parent source, any flux allocated to the whispy sources is flux that cannot be allocated to the main child footprint of the main galaxy.
Now we will show a few cut outs of the parent (top left), and first few deblended sources.
f, ax = plt.subplots(nrows=2, ncols=3, sharey=True, sharex=True, figsize=(12,8))
for i, a in zip(range(0, 6), ax.flatten()):
noiseReplacer.insertSource(src_ids[i])
data = deepCoadd.getMaskedImage()[ix-100:ix+100,iy-100:iy+100].getImage().array
a.imshow(data, origin='lower', animated=True)
noiseReplacer.removeSource(src_ids[i])
plt.tight_layout()
It is good pratice to use the end method for the NoiseReplacer
after you are finished. This will restore our deepCoadd to the state it was in before we began replacing sources with noise, and so forth
noiseReplacer.end()
In this notebook, we learned how to make footprints from detection tasks, create heavy footprints, and access pixel level data from heavy footprints. We also learned the role footprints play in detection, deblending, and measurement.