Astronomical Imaging and FITS#

Gather round, for I shall tell a tale old as time. Long, long ago, astronomers gazed at the heavens, and with naught but their eyes, recorded the positions of the stars they saw there. Then, with the telescope, long, heavy contraptions which reached for the skies like an outstretched arm, (thanks, f/20 focal lengths) they gazed through eyepieces in freezing domes, falling, on occasion, to their deaths from cages suspended at prime foci.

Next came glass – emulsion plates – which upon extended exposure revealed upon their ghostly frames the splotches of stars and soon, galaxies and nebulae. Many a grad student, of course, spent their nights twiddling thumbs over micrometer dials to keep these stars in place. Manual guiding… this story teller shudders to imagine it. And yet, even then, with permanent record, no measure could be made that weren’t ‘tween the eyes of an observer, peering at the glass through magnifying eyepiece, assigning grades of brightness and, amazingly, pretty much nailing the spectral classification of stars by their absorption features.

And after this painstaking work, came the earliest CCDs, fractured by detector failures, riddled with readout noise, consumed by cosmic ray hits, laid low by low quantum efficiencies.

Now… we use Python.

What’s in an image#

Astronomical images differ from standard images taken with a phone or DSLR camera primarily in that they both use older CCD technology and are typically “black and white”, recording only intensity of light and not color.

Knowledge about color instead comes from the placement of filters in front of the detectors which have a certain bandpass, allowing through light within a certain wavelength range and blocking light outside it.

Thus, we only ever get one color at a time, unless the telescope splits incoming light by color using, for example, a dichroic, and then sends the two streams to two detectors with different filters simultaneously.

This means that in some sense, single-band (i.e., one narrow-ish wavelength range) images are simple programmatically: They are 2D arrays in which each pixel (position in array) contains a single number: counts (in some units).

Usually, these units are counts in ADU (analog-to-digital units), which are roughly related to the actual counts of photons hitting the detector, via properties such as the gain of the system.

Generally, we are not concerned with the exact units/gain/etc., because we back out the flux in photons (or energy, rather) via empirical calibrations with objects in the sky of known flux. Sometimes, we are given images that have already been flux calibrated, in which case measurements of, say, counts in an aperture are real flux units.

Image Handling with Astropy#

Lastly, we need to know how to work with astronomical images in Python. As “simple” 2D arrays, simply opening and plotting an image is easy. But keeping track of, say, the celestial coordinates associated with a pointing on the sky, is not.

Luckily, the open-source astropy library has many tools that facilitate the streamlined handling of astronomical imaging.

Opening the Image#

Opening images using astropy is made easy using the fits file handler. We’ll use a context manager to read in an image:

from astropy.io import fits 

with fits.open('antenna_Rband.fits') as hdu:
    header = hdu[0].header
    image = hdu[0].data

Note

A context manager is a useful tool for properly managing external resources in your programs. In this case, the external resource is a FITS file. When opening almost any file directly in Python, we also want to close our connection to that file once we have pulled out the data we need. A context manager, using python’s with above, will automatically close our fits file once extracting the header and image.

We can confirm we’ve loaded a 2D array:

image
array([[ 4.8691406,  5.432617 ,  5.713867 , ..., 11.48584  , 11.48584  ,
        11.48584  ],
       [ 5.573242 ,  5.713867 ,  5.291504 , ..., 11.48584  , 11.48584  ,
        11.48584  ],
       [ 6.4179688,  5.573242 ,  5.573242 , ..., 11.48584  , 11.48584  ,
        11.48584  ],
       ...,
       [11.48584  , 11.48584  , 11.48584  , ...,  6.2768555,  5.432617 ,
         5.9956055],
       [11.48584  , 11.48584  , 11.48584  , ...,  5.291504 ,  5.432617 ,
         5.573242 ],
       [11.48584  , 11.48584  , 11.48584  , ...,  5.291504 ,  4.8691406,
         3.4614258]], dtype=float32)

We can make a basic plot showing it:

fig, ax = plt.subplots(figsize=(6,6))
ax.imshow(image)
<matplotlib.image.AxesImage at 0x7fcfd9379bb0>
../../_images/b1a261c1097346be5df115856199f4c26f97e3a385d9df99aec7485c24ffbebc.png

That may not have looked like much, but it did at least confirm we have a 2D image. Let’s tweak some settings.

import numpy as np
fig, ax = plt.subplots(figsize=(6,6))
m = np.mean(image)
s = np.std(image)
ax.imshow(image,origin='lower',vmin=m-s,vmax=m+5*s,cmap='gray');
../../_images/851cbc28cc939939ab5a196c9524b8897913bb23a35217b8144b4e287fcfc2ef.png

Next, we need to plot with our \(x\) and \(y\) axes representing sky coordinate (RA/DEC) rather than pixel position. For this, we’ll need to use astropy’s World Coordinate System module. There are several defined coordinate systems on the sky, but for this class, we’ll primarily be using RA and DEC. (i.e., equitorial coordinates measured in hour angles and degrees).

from astropy.wcs import WCS 

wcs_im = WCS(header)
INFO: 
                Inconsistent SIP distortion information is present in the FITS header and the WCS object:
                SIP coefficients were detected, but CTYPE is missing a "-SIP" suffix.
                astropy.wcs is using the SIP distortion coefficients,
                therefore the coordinates calculated here might be incorrect.

                If you do not want to apply the SIP distortion coefficients,
                please remove the SIP coefficients from the FITS header or the
                WCS object.  As an example, if the image is already distortion-corrected
                (e.g., drizzled) then distortion components should not apply and the SIP
                coefficients should be removed.

                While the SIP distortion coefficients are being applied here, if that was indeed the intent,
                for consistency please append "-SIP" to the CTYPE in the FITS header or the WCS object.

                 [astropy.wcs.wcs]
WARNING: FITSFixedWarning: 'datfix' made the change 'Set MJD-OBS to 53207.000000 from DATE-OBS'. [astropy.wcs.wcs]

The wcs for a given image is constructed from the image header, where some information about the pixel scale and image pointing are stored. Notice here we got a warning. This happens sometimes with HST images that are mosaics of pointings that have already been drizzled (an image alignment/combination process). We need to remove the mentioned coefficients.

The following will be provided for lab:

def strip_SIP(header):
    A_prefixes = [i for i in header.keys() if i.startswith('A_')]
    B_prefixes = [i for i in header.keys() if i.startswith('B_')]
    for a,b in zip(A_prefixes,B_prefixes):
        del header[a]
        del header[b]
    return header
header_fixed = strip_SIP(header)
wcs = WCS(header_fixed)

We are now ready to plot our image in celestial coordinates. Our game plan is to use matplotlib’s projection. You may be familiar with this as the way to make an axis, e.g., in polar coordinates. Astropy WCS objects can be treated as projections in matplotlib. Below I show two ways to initialize an axis with our new wcs projection.

fig, ax = plt.subplots(figsize=(6,6),subplot_kw={'projection':wcs})
../../_images/9266921567c52b17a9df532a6311d1d9be046e469bda99cb37cf9376235869cf.png
fig = plt.figure(figsize=(6,6))
ax = fig.add_subplot(projection=wcs)
../../_images/9266921567c52b17a9df532a6311d1d9be046e469bda99cb37cf9376235869cf.png

The two methods work equally well. The former is slightly preferred, as it makes it easy to make multiple panels with a wcs projection.

Now, all we have to do, is plop our image into it:

fig, ax = plt.subplots(figsize=(6,6),subplot_kw={'projection':wcs})
ax.imshow(image,origin='lower',vmin=m-s,vmax=m+5*s,cmap='gray');
../../_images/d2cf7c59dfa8029104e0f20301c78dcbbd544b21dc79c1cff1e3b8f003cd652b.png
ax
<WCSAxesSubplot:xlabel='pos.eq.ra', ylabel='pos.eq.dec'>

Lab Preview#

In this lab, you’ll be working with the same image of the Antenna galaxy, in several bands. In addition to the basic reading in and use of header/wcs, you’ll be learning about SkyCoord, the astropy handler for coordinates, and units which is a module we’ll be discussing throughout class. You’ll use Cutout2D to make zoom in plots of the image while retaining coordinate information. And you’ll use the photutils package and sep to find sources and perform some aperture photometry.

Lab: Astronomical Images#

Astronomical images are one of the basic building blocks of astronomical research. While we now obtain data in myriad ways, from gravitational waves to neutrinos, from spectra to polarimetry, the basic tenant of astronomy (and it’s most recongizable public impact) is the images we make of the sky.

That is why the first science topic we’ll be tackling is imaging. And along the way, we’ll learn how astropy makes our lives so much easier when dealing with images, and how to use robust functions to perform the analyses one might want to carry out on images (after we’re done admiring their beauty).

Many of you are probably familiar with the FITS standard. It stands for Flexible Image Transport System, and for all intents and purposes, it acts as a container (much like a zip file). Within it, one can store several kinds of information: images (2d arrays of values), as the name implies, headers (dictionary like objects with metadata), and sometimes tabular data as well. A FITS file can contain any number of extensions, which just refer to “slots” where stuff is stored. Every “slot” has a header attribute and a data attribute. Thus, you could store a hundred astronomical images in 1 file, each with a different extension and a different header describing the image in that slot.

Tip

Contextual clues are important when dealing with FITS files. Files that are explicitly single images almost always have the image stored in the 0th extension, while files that are explicitly table data tend to have the table stored in the 1st extension (with the 0th empty).

The FITS standard is pretty old, and may be retired soon, but almost all ground based telescopes still use it for their native image output, so it behooves us to know how to get image data out of FITS files.

Exercise 1: Loading a FITS File

Write a function which takes as its argument a string filepath to a FITS file, and should have an optional argument to set the extension (default 0). It should then load the given extension of that fits file using a context manager, and return a tuple containing the header and data of that extension.

The function should have documentation that describes the inputs and outputs.

Then use your function to read in the antennae R-band image.

# Your Code Here 

Exercise 2.1: Plotting

Next, we need to plot the image data. There are several operations that we almost always perform when plotting astronomical data, as well as several user-preferences for how we “by default” plot images before we begin tweaking things. If you spend any time as an astronomer, you will plot quite literally thousands of images — why set all these settings every time, when we can write a handy function to do it for us?

Write a function which takes in as input arguments

  • an image (2D array or masked array)

as well as the following optional arguments (so set a default)

  • figsize (default (15,13) )

  • cmap (default ‘gray_r’)

  • scale (default 0.5)

  • **kwargs (see here)

Inside the function, create figure and axes objects using plt.subplots(). When working in notebooks, it is often useful to set the figsize argument of subplots to a nice large-ish value, such as (15,13), which will make the image fill most of the notebook. Since your function has set figsize as an argument, you can feed figsize directly into the subplots call, so that a user of the function can leave the default or set their own.

Next, use ax.imshow() to actually plot the image. You’ll want to save the output of this, e.g., im = ax.imshow(...). In this plotting call, set imshow’s argument origin='lower'. We always want to do this when dealing with imaging, as we want (0,0) to be a coordinate.

Note

By default, matplotlib uses a “matrix-style” plotting, where 0 of the y axis is in the top left corner, and 0 of the x axis is in the bottom left corner.

Also within the call to imshow(), feed in the cmap from your function (i.e., cmap=cmap). The other critical imshow() arguments are vmin and vmax, which sets the saturation points (and thus the contrast) of the image.

We haven’t set vmin and vmax as arguments of our outer function, but because of kwargs, we can still create a default here that can be overriden from outside.

As a default, within your function, calculate the mean and standard deviation of the image. Set some temporary variables with the quantities mu - scale*sigma and mu + scale*sigma (where here mu is the calculated mean and sigma is the calculated std dev, and scale was the optional input). Next, check the kwargs dictionary (which will exist in your function because we added the packing argument **kwargs to our function. IF vmin and vmax are in this dictionary, plug those into your imshow command. Otherwise, use the values determined by the calculation above. Bonus point for accomodating either no vmin/vmax entered, just vmin or vmax, or both (using the calculated values when things aren’t provided).

Your function should return the created fig and ax objects so the user can continue to tweak them.

Run your function and test its outputs. Once you’re satisfied it’s working, use it to plot the provided data. Find either a vmin/vmax pair, or a choice of scale which makes the image look pretty!

import matplotlib.pyplot as plt
import numpy as np

# Your code
def implot(...):
    ''' 
    WRITE DOCSTRING HERE
    '''
    pass #replace with your code

Exercise 2.2 More: Features

Copy your function down, we’re going to keep messing with it.

So far, we’ve made it so that with a simple function call and, potentially, with just the input of an image array, we get out a nice plot with a scaling, colormap, and origin selection. In this section, we are going to allow (optionally) for a colorbar to be added to the figure. We’re also going to add in the ability for the figure to be plotted in celestial coordinates (i.e., RA and DEC) instead of pixel units, if information about the image (via the world coordinate system, WCS) exists in the image header.

Note

Generally, WCS information is present in the headers of published images like the one here, but not present in raw data gathered at the telescope. This is because images need to undergo a process known as plate solving to determine both the direction (coordinates) toward the image, as well as the pixel scale (i.e., how many arcseconds per pixel in the image).

Add three new optional arguments to your function.

  • colorbar = False

  • header = None

  • wcs = None

Let’s start with the colorbar. At the end of your plotting commands, check if colorbar=True, and if so, create a colorbar via plt.colorbar(), setting the mappable argument to whatever you saved the output of ax.imshow() into above. Also set the ax argument to be your ax; this will tell matplotlib to steal a bit of space from that axis to make room for the colorbar.


# Your code here

Tip

When I do this, the colorbar is matching the figure height, rather than the axis height. If that bugs you like it bugs me, jump to the bottom of this workbook for a slightly more complicated, but robust, way of making the colorbar exactly the height (or width) of the axis.

In order to plot in RA and DEC coordinates, we need to first have an astropy WCS object associated with the image in question. You can import WCS from astropy.wcs. WCS objects are created from the headers of plate-solved fits files. In our function, we allow the user to input either a header or a WCS object directly. More on WCS can be found in the lecture notes, or here.

Exercise 2.3: Adding WCS Plotting

Within your function, check if a wcs is input – if it is, we’re good to go and can safely ignore header (even if it is provided). If instead only header is provided, use the WCS() function to create a new wcs object from that header.

Tip

You’ll want to do this at the very top of your function.

We now need to move our fig, ax = .... creation line into an if-statement. If we are using WCS “stuff”, you’ll need to set a projection for your plot that uses the wcs. This is accomplished as follows:

fig, ax = plt.subplots(...,subplot_kw={'projection':wcs}) 

where wcs is whatever you’ve named the output of WCS(header) or is the WCS input directly into the function.

from astropy.wcs import WCS

# your code

Warning

In this case, we will get an error from our function that happens because of some distortion coefficient nonsense between astropy and drizzled HST images. Since it’s not pertinent to our lab, I’m providing below a snippet of code you should use to fix your header before running WCS(header).

def strip_SIP(header):
    A_prefixes = [i for i in header.keys() if i.startswith('A_')]
    B_prefixes = [i for i in header.keys() if i.startswith('B_')]
    for a,b in zip(A_prefixes,B_prefixes):
        del header[a]
        del header[b]
    return header

If this worked correctly, when you add the header you read in from our image, you should now see the axes of your plot change from pixels to pos.eq.ra and pos.eq.dec. We’re now looking at actual on-sky coordinates! Yay!

Exercise 2.4: Plotting Goodies

Within the if-blocks of your function that sets the ax to be a wcs projection, set the \(x\) and \(y\) labels to read “Right Ascension [hms]” and “Declination [degrees]” in fontsize 15.

Lastly, to polish things off, use ax.tick_params() to set inward, larger ticks, and increase the axis tick label size to 15.

Note

You’ll notice (especially with larger ticks) that the are not perpendicular to the axes spines. This is because this particular image has been rotated with respect to the standard celestial coordinate axes. This can be seen more clearly if you add the following to your function: ax.coords.grid(color='gray', alpha=0.5, linestyle='solid'). Try doing that, adding an optional keyword to your function called ‘grid’ and enabling this command if it is set.

Tip

To check if a condition is true (e.g, if condition == True:), you can just type if condition:

It’s taken us some time, but this image could now be placed in a scientific publication. And, since we have a handy function for it, we can make images that look this nice on the fly with a short one line command, yet still have a lot of flexibility over many important inputs. And of course, the figure and axes objects are returned, so one could run this function and then continue tweaking the plot after the fact.

Note

As a final note, I want to draw your attention to the fact that once you use the wcs projection on some plot, it’s no longer a normal ax object, it’s a wcsax object. This makes changing certain elements of those axes a little more involved than for a standard one. I use this page and the others linked there when doing so.

Cutouts#

When working with astronomical images, like the one we’ve been using in this lab, it is often advantageous to be working with a cutout – a chunk of the image centered on a certain coordinate, and of a certain size. For example, if there is an HII region or star cluster of interest in the above image, we may like to zoom in on it to examine it more closely.

Now that we’ve switched over to using celestial coordinates instead of pixels as our projection frame, zooming in on a region is not as simple as slicing our array, e.g., image[500:700,200:550]. On the plus side, the framework we’ll learn here is very robust, and will allow for all sorts of useful measurements.

To make a cutout, we’ll need the Cutout2D module within astropy, which I’ll import below. To provide the position of the cutout, we need to be able to feed in astronomical coordinates. For this, we’ll use SkyCoord, a module in astropy.coordinates. Finally, we’ll need to integrate the units module in astropy to successfully create coordinate objects.

from astropy.nddata import Cutout2D
from astropy.coordinates import SkyCoord
import astropy.units as u

Let’s start with a SkyCoord object. There are several coordinate systems used in astronomy, e.g., Galactic coordinates (\(b\), \(l\)), Equatorial (\(RA\), \(DEC\)). The most common (especially in any extragalactic setting) is RA and DEC (as you can see in the image you’ve plotted already).

The documentation for SkyCoord is solid, and worth reading.

The general way we create these objects is, e.g.,Let’s start with a SkyCoord object. There are several coordinate systems used in astronomy, e.g., Galactic coordinates (\(b\), \(l\)), Equatorial (\(RA\), \(DEC\)). The most common (especially in any extragalactic setting) is RA and DEC (as you can see in the image you’ve plotted already).

The documentation for SkyCoord is solid, and worth reading.

The general way we create these objects is, e.g.,

coord = SkyCoord('12:01:53.6 -18:53:11',unit=(u.hourangle,u.deg))

Tip

You can input various types of strings and formattings for the coordinates, just be sure to specify the units as shown above. The documentation shows the various methods. You can also enter RA and DEC separately if you have them stored that way, e.g., SkyCoord(ra=241.53,dec=7.1345,unit='deg').

Exercise 3.1: Make a Cutout

In this case, the coordinates I set above are for NGC 4039, which is the smaller of the two galaxies in the image we’re using.

Note

If at any point you’re trying to make a coordinate object for a well known galaxy/object, try, e.g., coord = SkyCoord.from_name('NGC 4038'), and ususally that will work!

In the cell below, use the coordinate we’ve created, plus a size (use 1x1 arcminutes), and the wcs object for our image, to create a Cutout2D object.

# your code here

Now, use your fancy new function to plot the new cutout.

Hint

Cutout objects contain the image and their own wcs as attributes, accessible via, e.g., cutout.data and cutout.wcs.

Congrats! You’re now able to interact with images using astropy. Some next steps may be running sep/source extracter to find objects, or perform aperture photometry on regions of the image.

If those are things you might actually do this summer, check out the second half of this lab to continue along this path.