Comparison with Source-Extractor (formerly SExtractor)
Source-Extractor (formerly SExtractor) is a popular astronomical image reduction software that runs in the command line. While both photutils and Source-Extractor serve similar purposes, they do so in subtly different ways and have fundamentally different design principles. Nonetheless, the end-result should be extremely similar, regardless of the choice of software, assuming both are correct.
In this notebook, I compare the results of opticam (photutils-based) to Source-Extractor and show that they are equivalent when configured similarly. For simplicity, image calibrations are omitted from this comparison; instead, opticam’s calibration methods are test in a separate notebook.
A test image
For this example, I’ll use an OPTICAM-MX image of V709 Cas:
[ ]:
from pathlib import Path
from astropy.io import fits
import numpy as np
data_path = Path('sextractor_comparison')
data, header = fits.getdata(data_path / 'V709_Cas_image.fits', header=True)
data = np.asarray(data, dtype=np.float64)
print(repr(header))
As we can see, this image was taken in the 2x2 binning mode, and so has a resolution of 1024x1024. Let’s take a look at the image:
[ ]:
from astropy.visualization import simple_norm
from matplotlib import pyplot as plt
fig, ax = plt.subplots()
ax.imshow(data, origin='lower', norm=simple_norm(data, stretch='log'))
plt.show()
Instead of using opticam.Reducer, since we’re only working with a single image, we’ll compute the image background, identify sources, and perform photometry manually (using opticam’s default values).
By default, opticam uses photutils.segmentation.SourceFinder to perform source detection. This requires specifying an npixels parameter that defines the how many pixels need to be above the specified threshold to constitute a source. opticam defaults to a threshold value of 5. Note: this is the threshold above the background RMS rather than the photometric signal-to-noise ratio. For
npixels, opticam defaults to a value of 128 for images taken in the 1x1 binning mode, and then reduces this value by the square of the image binning. In this case, npixels would therefore be set to \(128/2^2 = 32\):
[ ]:
import opticam
threshold = 5
finder = opticam.DefaultFinder(npixels=32)
To compute image backgrounds, opticam uses `photutils.background.Background2D <https://photutils.readthedocs.io/en/stable/user_guide/background.html#d-background-and-noise-estimation>`__. By default, Background2D uses a similar algorithm to Source-Extractor to compute the background. Background2D requires specifying a box_size parameter that determines the size of the background mesh used to compute the two-dimensional background. opticam sets this value to 1/32nd of
the image width; in this case, box_size would therefore be set to \(1024 / 32 = 32\):
[ ]:
background = opticam.DefaultBackground(box_size=32)
Let’s compute the background of our image using photutils with opticam’s defaults:
[ ]:
bkg = background(data)
data_clean = data - bkg.background
Let’s look at the background subtracted image and the corresponding background mesh to make sure everything looks reasonable:
[ ]:
from matplotlib import pyplot as plt
fig, ax = plt.subplots()
im = ax.imshow(data_clean, origin='lower', norm=simple_norm(data_clean, stretch='log'))
bkg.plot_meshes(ax=ax, marker='.', color='red', alpha=.5, outlines=True, markersize=5,)
fig.colorbar(im)
plt.show()
The mesh looks to be a reasonable size: boxes are larger than individual sources while being small enough to capture variations in the background across the image. Let’s now identify the sources in this background-subtracted image:
[ ]:
cat = finder(data_clean, 5 * bkg.background_rms)
cat.pprint_all()
As we can see, a number of sources have been identified (22, to be specific). Let’s take a look at these sources:
[ ]:
import numpy as np
source_coords = np.asarray([cat['xcentroid'], cat['ycentroid']]).T
semimajor_sigma = np.median(cat['semimajor_sigma'].value)
radius = 5 * semimajor_sigma # aperture radius for plotting
[ ]:
from matplotlib import pyplot as plt
from matplotlib.patches import Circle
fig, ax = plt.subplots()
im = ax.imshow(data_clean, origin='lower', norm=simple_norm(data_clean, stretch='log'))
for coords in source_coords:
ax.add_patch(
Circle(coords, radius=radius, edgecolor='red', facecolor='none')
)
fig.colorbar(im)
plt.show()
Source detection looks reasonable. Let’s move on to photometry.
For comparison with Source-Extractor, we’ll only look at aperture photometry. To perform aperture photometry, opticam uses the `photutils.aperture <https://photutils.readthedocs.io/en/stable/user_guide/aperture.html>`__ subpackage. This subpackage defines convenience routines for defining apertures and performing aperture photometry. For simplicity, we’ll use a circular aperture with a 5 pixel radius. By default, opticam defines an
`EllipticalAperture <https://photutils.readthedocs.io/en/stable/api/photutils.aperture.EllipticalAperture.html#photutils.aperture.EllipticalAperture>`__, so we’ll make it circular by passing the same value to both axes, and setting the orientation of the ellipse to 0 degrees:
[ ]:
photometer = opticam.AperturePhotometer(
semimajor_axis=5,
semiminor_axis=5,
orientation=0.,
forced=True, # use forced photometry since we only have one image
)
results = photometer.compute(
image=data_clean,
bias_var=0., # ignore bias variance
dark_var=0., # ignore dark variance
flat_var=0., # ignore flat variance
background_rms=bkg.background_rms,
cat_coords=source_coords,
image_coords=None, # not needed since forced=True
psf_params=None, # not needed since aperture size set manually
read_noise=0., # ignore read noise
)
fluxes = np.asarray(results['flux'])
flux_errs = np.asarray(results['flux_err'])
One important difference between opticam/photutils and Source-Extractor is how apertures handle partially overlapping pixels. By default, photutils (and, by extension, opticam) computes the exact fractional overlap of each pixel with the aperture. In contrast, Source-Extractor divides pixels into 5x5 subpixels, and includes subpixels whose centres fall within the aperture.
photutils can be configured to perform the same subpixelling as Source-Extractor, but this is not the default behaviour. As such, opticam will generally result in slightly different flux values when compared to Source-Extractor.
Source-Extractor
To run Source-Extractor, a number of configuration files are required. We therefore need to tweak these configuration files to match the default behaviour of opticam. In particular, we need to edit default.sex to set DETECT_MINAREA to 32, DETECT_THRESH and ANALYSIS_THRESH to 5, PHOT_APERTURES to 10 (since this quantifies the diameter of the aperture), and BACK_SIZE (background mesh size) to 32. The full configuration file used is given below:
[ ]:
with open(data_path / 'opticam_default.sex', 'r') as file:
print(file.read())
I also edited the default.param configuration file to only save the following columns in the resulting catalogue:
[ ]:
with open(data_path / 'opticam_default.param', 'r') as file:
print(file.read())
Source-Extractor is a command line tool, so it’s not as easy as photutils to run it in a notebook like this. However, we can use the `os.system() <https://docs.python.org/3/library/os.html#os.system>`__ function to pass the required commands to the system shell:
[ ]:
import os
image_path = data_path.joinpath('V709_Cas_image.fits').resolve()
os.chdir(os.path.join(os.getcwd(), 'sextractor_comparison'))
os.system(f'source-extractor {image_path} -c opticam_default.sex')
os.chdir(os.path.join(os.getcwd(), '..')) # go back to original directory
We can see that 22 sources were detected, just like photutils. We can also see that the image background was 117.514 with an RMS of 7.8876. Let’s compare these values to those computed by photutils:
[ ]:
print(round(bkg.background_median, 3), round(bkg.background_rms_median, 4))
The background values are extremely similar, as we would hope. Slight differences are likely due to different sigma clipping parameters. Source-Exractor will iteratively clip pixels within a mesh box until all pixels are within :math:`3 sigma of the median value <https://sextractor.readthedocs.io/en/latest/Background.html#background-estimation>`__. By default, photutils (and, by extension, opticam) uses the same sigma clipping threshold, but only performs up to 10
iterations. Since photutils uses an `astropy.stats.SigmaClip <https://docs.astropy.org/en/stable/api/astropy.stats.SigmaClip.html#astropy.stats.SigmaClip>`__ instance to perform sigma clipping, it can be configured to clip until convergence is acheived, as in Source-Extractor, by defining a SigmaClip instance with maxiters=None. However, this is not the default
behaviour. As such, opticam will generally result in slightly different background values when compared to Source-Extractor.
Let’s now take a look at the resulting catalogue:
[ ]:
from astropy.table import Table
sextractor_cat = Table.read(data_path / 'test.cat', format="fits")
sextractor_cat.sort('FLUX_APER', reverse=True)
sextractor_cat.pprint()
We can see that we have the coordinates of each detected source as well as the measured flux values and their corresponding errors. Let’s see how the detected sources compare to those of photutils:
[ ]:
from matplotlib.patches import Rectangle
sextractor_coords = np.asarray([sextractor_cat['X_IMAGE'], sextractor_cat['Y_IMAGE']]).T
fig, ax = plt.subplots()
ax.imshow(data_clean, origin='lower', norm=simple_norm(data_clean, stretch='log'))
for coords in source_coords:
ax.add_patch(
Circle(coords, radius=radius, edgecolor='red', facecolor='none')
)
for coords in sextractor_coords:
ax.add_patch(
Rectangle(coords - radius, width=2 * radius, height= 2 * radius, edgecolor='white', facecolor='none', zorder=0)
)
plt.show()
As we can see, source identification appears identical.
Let’s now compare the measured fluxes:
[ ]:
sextractor_fluxes = np.asarray(sextractor_cat['FLUX_APER'])
sextractor_flux_errs = np.asarray(sextractor_cat['FLUXERR_APER'])
fig, ax = plt.subplots()
ax.plot(sextractor_fluxes, sextractor_flux_errs, 'r+', label='Source-Extractor')
ax.plot(fluxes, flux_errs, 'kx', label='opticam')
ax.set_xscale('log')
ax.legend(fontsize='large')
ax.set_ylabel('Flux error [ADU]', fontsize='large')
ax.set_xlabel('Flux [ADU]', fontsize='large')
ax.minorticks_on()
ax.tick_params(which='both', direction='in', right=True, top=True)
plt.show()
Reassuringly, there looks to be very little difference between the two programs. Let’s compare the results more quantitatively.
[ ]:
err_ratio = flux_errs / sextractor_flux_errs
print(f'Average error ratio: {err_ratio.mean()}')
print(f'Max err ratio: {err_ratio.max()}')
print(f'Max err ratio: {err_ratio.min()}')
print()
ratio = fluxes / sextractor_fluxes
print(f'Average ratio: {ratio.mean()}')
print(f'Max ratio: {ratio.max()}')
print(f'Min ratio: {ratio.min()}')
As we can see, opticam (using photutils) performs very similarly to Source-Extractor when both are configured similarly. On average, the difference between opticam/photutils and Source-Extractor was less than 1% in this example, with extremes of \(\sim 2.5\)%. As noted above, this difference is due to a combination of how opticam and Source-Extractor treat partially overlapping pixels differently, and use different sigma clipping parameters.
[ ]:
assert np.isclose(err_ratio.mean(), 1., rtol=0.01, atol=0.)
assert np.isclose(ratio.mean(), 1., rtol=0.01, atol=0.)
That concludes this comparison between opticam and Source-Extractor.