Preprocessing the CCD images

Contents

Preprocessing the CCD images#

Software

Version

Python

3.12.2 64bit [Clang 14.0.6]

IPython

8.20.0

OS

macOS 13.4.1 arm64 arm 64bit

numpy

1.26.4

scipy

1.11.4

matplotlib

3.8.0

astropy

5.3.4

ccdproc

2.4.1

photutils

1.11.0

%config InlineBackend.figure_format = 'retina'
import numpy as np
from pathlib import Path
import re
import time

from astropy.io import fits
from astropy.table import Table
from astropy.visualization import ZScaleInterval
from astropy.nddata import CCDData
import astroalign as aa
from ccdproc import combine
from matplotlib import pyplot as plt

plt.rcParams['font.size'] = 12
plt.rcParams['axes.labelsize'] = 14
# setting locations
DATADIR = Path('./data/reduc/2024-03-20')
RAWDIR = DATADIR / 'raw'
OUTDIR = DATADIR/ 'reduced'

# making output directory if it doesn't exist
if not OUTDIR.exists():
    OUTDIR.mkdir()
def make_summary_table(rawdir: Path):
    # making a summary table
    summary = []
    for f in rawdir.glob('*.fit'):
        hdr = fits.getheader(f)
        # getting the filter information
        filt = 'unknown'
        if 'filter' in hdr:
            filt = hdr['filter']
        elif 'FILTER' in hdr:
            filt = hdr['FILTER']
        else:
        # - checking for the filter in the filename with regex, if not in header
            mtch = re.search(r'[UBVRIgriz].fit$', f.name)
            mtch_Ha = re.search(r'Ha.fit$', f.name)
            if mtch:
                filt = mtch.group(0)[0]
            elif mtch_Ha:
                filt = 'Ha'
            
        # getting the airmass X (Pickering 2002)
        try:
            alt = float(hdr['OBJCTALT'])
            X = 1/(np.sin(np.radians( alt + 244/(165 + 47*alt**1.1 ))))
        except TypeError:
            X = -1
        except Exception as e:
            print(f'Error in {f.name}: {e}')
            X = -1
        
        summary.append([f.name, hdr['DATE-OBS'], hdr['OBJECT'],
                        hdr['OBJCTRA'], hdr['OBJCTDEC'],
                        hdr['IMAGETYP'], hdr['EXPTIME'],
                        X, filt])
        
    stab = Table(rows=summary,
                    names=['filename', 'date-obs', 'object', 'ra', 'dec',
                        'imagetyp', 'exptime', 'airmass', 'filter'],
                    dtype=['U50', 'U50', 'U50', 'U50', 'U50', 'U50',
                        'f8', 'f8', 'U50'])
    return stab
# making the summary table
summary_table = make_summary_table(RAWDIR)
# sorting the table by date
summary_table.sort('date-obs')
# showing the table
summary_table.show_in_notebook(display_length=5)
Table length=60
idxfilenamedate-obsobjectradecimagetypexptimeairmassfilter
0skyflat-0001Ha.fit2024-03-20T09:22:27skyflat07 43 57+60 34 21Flat Field3.01.152217579019225Ha
1skyflat-0002Ha.fit2024-03-20T09:22:42skyflat07 44 13+60 34 21Flat Field3.01.1522164291076638Ha
2skyflat-0003Ha.fit2024-03-20T09:22:57skyflat07 44 28+60 34 22Flat Field3.01.152217579019225Ha
3skyflat-0004Ha.fit2024-03-20T09:23:12skyflat07 44 43+60 34 22Flat Field3.01.1522187289365808Ha
4skyflat-0005Ha.fit2024-03-20T09:23:27skyflat07 44 58+60 34 22Flat Field3.01.1522187289365808Ha
5skyflat-0006Ha.fit2024-03-20T09:23:42skyflat07 45 13+60 34 23Flat Field3.01.1522187289365808Ha
6skyflat-0007Ha.fit2024-03-20T09:23:57skyflat07 45 28+60 34 23Flat Field3.01.152217579019225Ha
7skyflat-0001i.fit2024-03-20T09:48:06skyflat08 09 43+60 35 08Flat Field2.01.1522187289365808i
8skyflat-0002i.fit2024-03-20T09:48:20skyflat08 09 56+60 35 08Flat Field2.01.152219878859731i
9skyflat-0003i.fit2024-03-20T09:48:34skyflat08 10 11+60 35 09Flat Field2.01.1522187289365808i
10skyflat-0004i.fit2024-03-20T09:48:48skyflat08 10 25+60 35 09Flat Field2.01.1522152792018971i
11skyflat-0005i.fit2024-03-20T09:49:02skyflat08 10 39+60 35 10Flat Field2.01.1522164291076638i
12skyflat-0006i.fit2024-03-20T09:49:16skyflat08 10 53+60 35 10Flat Field2.01.1522164291076638i
13skyflat-0007i.fit2024-03-20T09:49:30skyflat08 11 07+60 35 10Flat Field2.01.1522152792018971i
14skyflat-0001r.fit2024-03-20T09:50:02skyflat08 11 39+60 35 11Flat Field2.01.1522187289365808r
15skyflat-0002r.fit2024-03-20T09:50:16skyflat08 11 53+60 35 12Flat Field2.01.1522152792018971r
16skyflat-0003r.fit2024-03-20T09:50:30skyflat08 12 07+60 35 12Flat Field2.01.1522187289365808r
17skyflat-0004r.fit2024-03-20T09:50:44skyflat08 12 21+60 35 13Flat Field2.01.152217579019225r
18skyflat-0005r.fit2024-03-20T09:50:58skyflat08 12 35+60 35 13Flat Field2.01.152217579019225r
19skyflat-0006r.fit2024-03-20T09:51:12skyflat08 12 49+60 35 13Flat Field2.01.1522187289365808r
20skyflat-0007r.fit2024-03-20T09:51:26skyflat08 13 04+60 35 14Flat Field2.01.1522221787234155r
21skyflat-0001g.fit2024-03-20T09:55:37skyflat08 17 16+60 35 21Flat Field2.01.1522187289365808g
22skyflat-0002g.fit2024-03-20T09:55:51skyflat08 17 30+60 35 22Flat Field2.01.1522152792018971g
23skyflat-0003g.fit2024-03-20T09:56:05skyflat08 17 44+60 35 22Flat Field2.01.1522187289365808g
24skyflat-0004g.fit2024-03-20T09:56:19skyflat08 17 58+60 35 22Flat Field2.01.152217579019225g
25skyflat-0005g.fit2024-03-20T09:56:33skyflat08 18 13+60 35 23Flat Field2.01.1522187289365808g
26skyflat-0006g.fit2024-03-20T09:56:47skyflat08 18 26+60 35 23Flat Field2.01.152219878859731g
27skyflat-0007g.fit2024-03-20T09:57:01skyflat08 18 40+60 35 24Flat Field2.01.1522164291076638g
28M101-0001g.fit2024-03-20T13:46:31M10114 03 15+54 20 40Light Frame180.01.3471224286680594g
29M101-0002g.fit2024-03-20T13:49:43M10114 03 15+54 20 40Light Frame180.01.3372890993380804g
30M101-0003g.fit2024-03-20T13:52:55M10114 03 15+54 20 40Light Frame180.01.3277368037391455g
31M101-0001r.fit2024-03-20T13:56:06M10114 03 15+54 20 40Light Frame180.01.3183926677509952r
32M101-0002r.fit2024-03-20T13:59:18M10114 03 15+54 20 40Light Frame180.01.3092440875166884r
33M101-0003r.fit2024-03-20T14:02:29M10114 03 15+54 20 40Light Frame180.01.300365998812042r
34M101-0001i.fit2024-03-20T14:05:41M10114 03 15+54 20 40Light Frame180.01.291625580691236i
35M101-0002i.fit2024-03-20T14:08:52M10114 03 15+54 20 40Light Frame180.01.2831247255886573i
36M101-0003i.fit2024-03-20T14:12:04M10114 03 15+54 20 40Light Frame180.01.2748610177362498i
37M101-0001Ha.fit2024-03-20T14:15:18M10114 03 15+54 20 40Light Frame180.01.2666825141876346Ha
38M101-0002Ha.fit2024-03-20T14:18:29M10114 03 15+54 20 40Light Frame180.01.2587544068516756Ha
39M101-0003Ha.fit2024-03-20T14:21:41M10114 03 15+54 20 40Light Frame180.01.2510514616626078Ha
40Calibration-0001bias.fit2024-03-20T15:50:31Calibration14 14 26+60 37 30Bias Frame0.01.1522302284288428unknown
41Calibration-0002bias.fit2024-03-20T15:50:39Calibration14 14 27+60 37 30Bias Frame0.01.1520991685626514unknown
42Calibration-0003bias.fit2024-03-20T15:50:46Calibration14 14 35+60 37 30Bias Frame0.01.1521129607926952unknown
43Calibration-0004bias.fit2024-03-20T15:50:53Calibration14 14 42+60 37 29Bias Frame0.01.1521083632900055unknown
44Calibration-0005bias.fit2024-03-20T15:51:01Calibration14 14 49+60 37 29Bias Frame0.01.1521072139288138unknown
45Calibration-0001dk2.fit2024-03-20T15:51:08Calibration12 12 25+07 34 55Dark Frame2.01.15419822429397unknown
46Calibration-0002dk2.fit2024-03-20T15:51:17Calibration23 58 46-00 08 05Dark Frame2.0-1.0unknown
47Calibration-0003dk2.fit2024-03-20T15:51:26Calibration23 58 46-00 08 05Dark Frame2.0-1.0unknown
48Calibration-0004dk2.fit2024-03-20T15:51:35Calibration23 58 46-00 08 05Dark Frame2.0-1.0unknown
49Calibration-0005dk2.fit2024-03-20T15:51:43Calibration23 58 46-00 08 05Dark Frame2.0-1.0unknown
50Calibration-0001dk3.fit2024-03-20T15:51:52Calibration23 58 46-00 08 05Dark Frame3.0-1.0unknown
51Calibration-0002dk3.fit2024-03-20T15:52:01Calibration23 58 46-00 08 05Dark Frame3.0-1.0unknown
52Calibration-0003dk3.fit2024-03-20T15:52:10Calibration23 58 46-00 08 05Dark Frame3.0-1.0unknown
53Calibration-0004dk3.fit2024-03-20T15:52:19Calibration23 58 46-00 08 05Dark Frame3.0-1.0unknown
54Calibration-0005dk3.fit2024-03-20T15:52:28Calibration23 58 46-00 08 05Dark Frame3.0-1.0unknown
55Calibration-0001dk180.fit2024-03-20T16:13:09Calibration23 58 46-00 08 05Dark Frame180.0-1.0unknown
56Calibration-0002dk180.fit2024-03-20T16:16:15Calibration23 58 46-00 08 05Dark Frame180.0-1.0unknown
57Calibration-0003dk180.fit2024-03-20T16:19:21Calibration23 58 46-00 08 05Dark Frame180.0-1.0unknown
58Calibration-0004dk180.fit2024-03-20T16:22:27Calibration23 58 46-00 08 05Dark Frame180.0-1.0unknown
59Calibration-0005dk180.fit2024-03-20T16:25:33Calibration23 58 46-00 08 05Dark Frame180.0-1.0unknown
# table for each type of frame
bias_table = summary_table[summary_table['imagetyp'] == 'Bias Frame']
dark_table = summary_table[summary_table['imagetyp'] == 'Dark Frame']
flat_table = summary_table[summary_table['imagetyp'] == 'Flat Field']
sci_table = summary_table[summary_table['imagetyp'] == 'Light Frame']

# counting the number of frames
print(f'Number of bias frames: {len(bias_table)}')
print(f'Number of dark frames: {len(dark_table)}')
print(f'Number of flat frames: {len(flat_table)}')
print(f'Number of flat frames with g-band: {len(flat_table[flat_table["filter"] == "g"])}')
print(f'Number of science frames: {len(sci_table)}')
print(f'Number of science frames with g-band: {len(sci_table[sci_table["filter"] == "g"])}')
Number of bias frames: 5
Number of dark frames: 15
Number of flat frames: 28
Number of flat frames with g-band: 7
Number of science frames: 12
Number of science frames with g-band: 3
# test reading of science frame
fname = RAWDIR / sci_table['filename'][0]
hdu = fits.open(fname)
isci = 0
sci = hdu[isci].data   # 2D image array of pixel counts
hdr = hdu[isci].header # image header information

# simple visulization of the image
ibias, idark, iflat = 0, 0, 4
bias = fits.getdata(RAWDIR / bias_table['filename'][ibias])
dark = fits.getdata(RAWDIR / dark_table['filename'][idark])
flat = fits.getdata(RAWDIR / flat_table['filename'][iflat])
# for displaying in zscale
interval = ZScaleInterval()

# plotting the images
fig, axes = plt.subplots(2, 2, figsize=(8, 6))
titles = ['Bias', 'Dark', f'Flat ({flat_table['filter'][iflat]})',
          f'Science ({sci_table['filter'][isci]})']
for i, img in enumerate([bias, dark, flat, sci]):
    vmin, vmax = interval.get_limits(img)
    ax = axes[i // 2][i % 2]
    ax.imshow(img, cmap='gray', origin='lower', vmin=vmin, vmax=vmax)
    ax.text(0.05, 0.95, titles[i], transform=ax.transAxes, ha='left', va='top')
    ax.tick_params(axis='both', length=0.0, labelleft=False, labelbottom=False)
plt.tight_layout()
../_images/4950c6ecdd11fadb0618a732e7d7fbceb66c730d013ed46d39edfa5c11bb4ba5.png
\[\sigma_{\rm ADU} = \frac{\rm Readout\ noise}{\rm Gain}\]
# get readout noise from bias frames
bias_imgs = np.array([fits.getdata(RAWDIR/f) for f in bias_table['filename']])

# bring gain
gain = fits.getheader(RAWDIR / bias_table['filename'][0])['EGAIN'] # e-/ADU

# calculate the readout noise
bias_level = np.mean(bias_imgs.ravel())
sig_adu = np.std(bias_imgs, ddof=1) # ADU
rdnoise = sig_adu * gain # e-

# plot the bias histogram
fig = plt.figure(figsize=(6, 4))
ax = fig.add_subplot(111)
bins = np.arange(490, 530, 1)
y, _, _ = ax.hist(np.ravel(bias_imgs), bins=bins, histtype='step', align='left',
               label='Bias values')
# Gaussian model for comparison
x = np.linspace(490, 530, 1000)
gaussian = np.exp(-0.5 * ((x - bias_level) / sig_adu)**2) * np.max(y)
ax.plot(x, gaussian, c='tab:red', label=r'N($b$, $\sigma_{\rm ADU}^2$)')
ax.set_xlabel('Pixel value (ADU)')
ax.set_ylabel('Number of pixels')
ax.set_xlim(490, 530)
ax.set_title('Histogram of bias frames')
ax.legend()
<matplotlib.legend.Legend at 0x174bbadb0>
../_images/11e6e21dbe24d5940fe1a149dcb6d5ab2722b20dd854d8e82325149b71eee029.png

CCD Data Reduction Guide - 1.5. Image combination

# create a master bias combining all bias frames
# median combine the bias frames
start = time.time()
medi_combine = np.median(bias_imgs, axis=0)
finish = time.time()
print(f'Time taken by median combine: {finish - start:.2f} s')

# combining bias frames with sigma clipping
# - stacking the empty array with bias frames (using CCDData)
bias_stack = []
for i in range(len(bias_table)):
    bias_data, bias_hdr = fits.getdata(RAWDIR/bias_table['filename'][i], header=True)
    bias = CCDData(data=bias_data, header=bias_hdr, unit='adu')
    bias_stack.append(bias)
# - combining
start = time.time()
clip_combine = combine(bias_stack, sigma_clip=True,
                       sigma_clip_high_thresh=3, sigma_clip_low_thresh=3)
finish = time.time()
print(f'Time taken by sigma clipping combine: {finish - start:.2f} s')

fig, axes = plt.subplots(1, 3, figsize=(13, 7))
vmin, vmax = interval.get_limits(medi_combine)
axes[0].imshow(bias_imgs[0], cmap='gray', origin='lower', vmin=vmin, vmax=vmax)
axes[1].imshow(medi_combine, cmap='gray', origin='lower', vmin=vmin, vmax=vmax)
axes[2].imshow(clip_combine, cmap='gray', origin='lower', vmin=vmin, vmax=vmax)
axes[0].text(0.50, 0.96, 'Single bias frame',
             fontsize=12.0, bbox=dict(facecolor='white', alpha=0.5),
             transform=axes[0].transAxes, ha='center', va='top')
axes[1].text(0.50, 0.96, 'Median',
             fontsize=12.0, bbox=dict(facecolor='white', alpha=0.5),
             transform=axes[1].transAxes, ha='center', va='top')
axes[2].text(0.50, 0.96, 'Clipped average',
             fontsize=12.0, bbox=dict(facecolor='white', alpha=0.5),
             transform=axes[2].transAxes, ha='center', va='top')
axes[0].text(0.04, 0.15,
             f"Mean bias level: {np.mean(bias_imgs[0]):.1f} (count)"+
             f"\nRMS error: {np.std(bias_imgs[0]):.3f} (count)",
             fontsize=10.0, bbox=dict(facecolor='white', alpha=0.5),
             transform=axes[0].transAxes, ha='left', va='top')
axes[1].text(0.04, 0.15,
             f"Mean bias level: {np.mean(medi_combine):.1f} (count)"+
             f"\nRMS error: {np.std(bias_imgs[0]):.3f} (count)",
             fontsize=10.0, bbox=dict(facecolor='white', alpha=0.5),
             transform=axes[1].transAxes, ha='left', va='top')
axes[2].text(0.04, 0.15,
             f"Mean bias level: {np.mean(clip_combine.data):.1f} (count)"+
             f"\nRMS error: {np.std(bias_imgs[0]):.3f} (count)",
             fontsize=10.0, bbox=dict(facecolor='white', alpha=0.5),
             transform=axes[2].transAxes, ha='left', va='top')
for ax in axes:
    ax.tick_params(axis='both', length=0.0, labelleft=False, labelbottom=False)
plt.tight_layout()
    
# bias histogram
fig = plt.figure(figsize=(6, 4))
ax = fig.add_subplot(111)
bins = np.arange(490, 530, 1)
_ = ax.hist(np.ravel(bias_imgs[0]), bins=bins, histtype='step',
                     label='Single bias frame', color='tab:blue')
_ = ax.hist(np.ravel(medi_combine), bins=bins, histtype='step',
            label='Median', color='tab:red')
_ = ax.hist(np.ravel(clip_combine), bins=bins, histtype='step',
            label='Clipped average', color='tab:green')
ax.set_xlim(490, 530)
ax.set_xlabel('Pixel value (ADU)')
ax.set_ylabel('Number of pixels')
ax.set_title('Histogram of bias frames')
ax.legend()

# write the master bias to a fits file
mbias_file = OUTDIR / 'Mbias.fits'
bias_hdr = fits.getheader(RAWDIR / bias_table['filename'][0])
bias_hdr['RDNOISE'] = rdnoise # record the readout noise
bias_hdr['HISTORY'] = 'Master bias frame'
bias_hdr['HISTORY'] = f'Created on {time.asctime()}'
fits.writeto(mbias_file, clip_combine.data, bias_hdr, overwrite=True)

del bias_imgs, bias_stack, medi_combine, clip_combine
Time taken by median combine: 1.17 s
Time taken by sigma clipping combine: 8.33 s
../_images/29ed8ea738fa2e16484f32ee1b4d2a4ef8f3eee883f73d2375a20fe0997c3453.png ../_images/39f1fd87334bd13693fe44b1ce6b31b72f9ef75e46dc867f24c231ceb021f00b.png

The level of the noises is slightly reduced by combining the images. In this case, we only combined five bias frames. However, for more serious data reduction, combining typically more than 10 bias frames is recommended to reduce the noise of the bias level further.

# - Note that the dark frames might have different exposure times.
def make_mdark(dark_list, mbias=None, verbose=True, plot=False):
    # - checking the basic info
    # - Please check the consistancy in observation dates and exposure times.
    if verbose:
        for i in np.arange(len(dark_list)):
                dark_hdr = fits.getheader(dark_list[i])
                print(f"\nDark frame {i+1:d}")
                for keys in ['DATE-OBS', 'EXPTIME']:
                        print("  "+keys+" = "+str(dark_hdr[keys]))
    
    if mbias is None:
        mbias_data = 0.
    else:
        mbias_data = fits.getdata(mbias)
        
    # - stacking dark frames
    dark_stack = []
    start = time.time()
    for i in range(len(dark_list)):
        dark_data, dark_hdr = fits.getdata(dark_list[i], header=True)
        dark_bn = (dark_data - mbias_data)# / dark_hdr['EXPTIME']
        dark = CCDData(data=dark_bn, header=dark_hdr, unit='adu')    
        dark_stack.append(dark)
    finish = time.time()
    print(f"\nReading {len(dark_list)} dark frames took {finish-start:.2f} sec")
        
    # - combine with sigma clipping
    start = time.time()
    mdark = combine(dark_stack, sigma_clip=True,
                    sigma_clip_high_thresh=3, sigma_clip_low_thresh=3)
    finish = time.time()
    print(f"Combining {len(dark_list)} dark frames took {finish-start:.2f} sec")
    
    # - correcting the negative values
    mdark.data[mdark.data < 0.] = 0.
    
    # - save the master dark as fits file
    start = time.time()
    dark_hdr['NFRAMES'] = len(dark_list)  # recording # of dark frames combined
    fits.writeto(OUTDIR/f"MDark{dark_hdr['EXPTIME']:.0f}.fits",
                 mdark, dark_hdr, overwrite=True)
    finish = time.time()
    print(f"Writing the master dark took {finish-start:.2f} sec")
    
    if plot:
        # - visualization of the master dark
        fig, ax = plt.subplots(1, 1, figsize=(5,3))
        vmin, vmax = interval.get_limits(mdark)
        ax.imshow(mdark, origin='lower', vmin=vmin, vmax=vmax, cmap='gray')
        ax.tick_params(axis='both', length=0.0, labelleft=False, labelbottom=False)
        ax.text(0.50, 0.96, "Master Dark\n(sc combined, bias-subtracted)",
                fontsize=12.0, bbox=dict(facecolor='white', alpha=0.5),
                transform=ax.transAxes, ha='center', va='top')
        ax.text(0.04, 0.15, f"Exposure time: {dark_hdr['EXPTIME']:.1f} (count)"+
                f"\nMean dark level: {np.mean(mdark.data):.1f} (count)",
                fontsize=10.0, bbox=dict(facecolor='white', alpha=0.5),
                transform=ax.transAxes, ha='left', va='top')
        plt.tight_layout()
    
    return mdark
# create master darks for each exposure time
mbias_file = OUTDIR / 'Mbias.fits'
for group in dark_table.group_by('exptime').groups: # group by exposure time
    fnames = [RAWDIR/f for f in group['filename']] 
    _ = make_mdark(fnames, mbias_file, verbose=False, plot=True)
Reading 5 dark frames took 1.61 sec
Combining 5 dark frames took 10.46 sec
Writing the master dark took 0.15 sec

Reading 5 dark frames took 0.98 sec
Combining 5 dark frames took 8.49 sec
Writing the master dark took 0.15 sec

Reading 5 dark frames took 1.01 sec
Combining 5 dark frames took 9.59 sec
Writing the master dark took 0.13 sec
../_images/3cd47eaf299ca8aaeffa5211e6b4d4775993f5b493491f4e4290d6a6f7c2ed5a.png ../_images/76efec1c99abef42462f44d68868e9c93c483e5291e3aefe51df1a2be5d202a0.png ../_images/090fd4f00c2da21593389dfa696ac87ca7c3b84d85d0d28f48848a4c47053bff.png
# - combining flat frames for each filter
def make_mflat(flat_list, mbias=None, mdark=None, verbose=True, plot=False):
    if verbose:
        # - checking the basic info : check dates, exposure times, and filters.
        for i in np.arange(len(flat_list)):
            flat_hdr = fits.getheader(flat_list[i])
            print(f"\nFlat frame {i+1:d}")
            for keys in ['DATE-OBS', 'EXPTIME', 'FILTER']:
                print("  "+keys+" = "+str(flat_hdr[keys]))
    
    if mbias is None:
        mbias_data = 0.
    else:
        mbias_data = fits.getdata(mbias)
    
    if mdark is None:
        mdark_data = 0.
    else:
        mdark_data = fits.getdata(mdark)
    
    # - stacking flat frames
    nflat = len(flat_list)
    flat_stack = []
    
    if plot:
        ncols = 4
        nrows = (nflat+1)//ncols + 1
        fig, axes = plt.subplots(nrows, ncols, figsize=(4*ncols, 3*nrows))
        interval = ZScaleInterval()
        
    for i in np.arange(len(flat_list)):
        flat_data, flat_hdr = fits.getdata(flat_list[i], header=True)  
        filter_now = flat_hdr['FILTER']     # specifying current filter
        # - bias and dark subtraction
        flat_bd = (flat_data - mbias_data - mdark_data)
        # - flat scaling (with relative sensitivity=1 at the maximum)
        flat_bdn = flat_bd/np.median(flat_bd)
        flat_stack.append(CCDData(data=flat_bdn, unit='adu'))
        if plot:
            ax = axes[i//ncols][i%ncols]
            if i == 0:
                vmin, vmax = interval.get_limits(flat_bdn)
            ax.imshow(flat_bdn, origin='lower', cmap='gray',
                      vmin=vmin, vmax=vmax)
            ax.tick_params(axis='both', length=0.0,
                           labelleft=False, labelbottom=False)
            ax.text(0.04, 0.96, f"{filter_now}-band ({i+1:d})", fontsize=10.0,
                    transform=ax.transAxes, ha='left', va='top')
            ax.text(0.04, 0.04,
                    f"Flat range: {100*flat_bdn.min():.1f}"
                    + f" - {100*flat_bdn.max():.1f}%", ha='left', va='bottom',
                    fontsize=10.0, transform=ax.transAxes)
            
    if plot:
        for j in np.arange(nflat+1, nrows*ncols):
            fig.delaxes(axes.flatten()[j])
        plt.tight_layout()
            
    # - sigma clipping
    from astropy.stats import mad_std
    mflat = combine(flat_stack, sigma_clip=True,
                    sigma_clip_low_thresh=3, sigma_clip_high_thresh=3,
                    sigma_clip_func=np.ma.median, sigma_clip_dev_func=mad_std)
    
    # - save the master flat as fits file
    # - Caveat: this assumes that the flat frames are taken with the same
    #           filter, and they have the same exposure time. If not, you may
    #           need to modify the filename.
    flat_hdr['NFRAMES'] = len(flat_list)
    fits.writeto(OUTDIR/f"MFlat{filter_now}.fits", mflat.data,
                 header=flat_hdr, overwrite=True)
    
    if plot:
        # - visualization of the master flat
        # fig, ax = plt.subplots(1, 1, figsize=(5,3))
        ax = axes[(i+1)//ncols][-1]
        vmin, vmax = interval.get_limits(mflat)
        ax.imshow(mflat, origin='lower', vmin=vmin, vmax=vmax, cmap='gray')
        ax.tick_params(axis='both', length=0.0, labelleft=False, labelbottom=False)
        ax.text(0.50, 0.96, f"Master Flat ({filter_now})", fontsize=12.0,
                transform=ax.transAxes, ha='center', va='top')
        ax.text(0.50, 0.88, "(sc combined, bias/dark-subtracted)",
                fontsize=11.0, transform=ax.transAxes, ha='center', va='top')
        ax.text(0.04, 0.04, f"Flat sensitivity range: {100*mflat.data.min():.1f}"
                + f" - {100*mflat.data.max():.1f}%", ha='left', va='bottom',
                fontsize=10.0, transform=ax.transAxes)
        plt.tight_layout()
        
    return mflat
# create master flats for each filter
# flat frames for the same filter have the same exposure time
mbias_file = OUTDIR / 'Mbias.fits'
for group in flat_table.group_by('filter').groups: # group by filter
    fnames = [RAWDIR/f for f in group['filename']]
    mdark_file = OUTDIR/f"MDark{group['exptime'][0]:.0f}.fits"
    _ = make_mflat(fnames, mbias_file, mdark_file, verbose=False, plot=True)
../_images/f441d97cb60c29cc26da95358bcd9f6b090340b8a9b60ff3e7dbffff67aeb35e.png ../_images/f9dc7327b689e6c260bb1dae073f453eda21f8e73e1dfc0a64712441f5d5bc89.png ../_images/f193c66679a8063b742eb1a97a796b735120e6e9579d1f9debba95a817b853ac.png ../_images/184e0b42876c445dc49f2c89b0f1a57966cc99d52370f088540983f510f0579e.png
def preproc(sci_list, mbias=None, mdark=None, mflat=None, rdnoise=None,
            plot=False, verbose=True):
    for i in range(len(sci_list)):
        # bias subtraction, dark subtraction, and flat fielding
        sci_path = sci_list[i]
        sci_data, sci_hdr  = fits.getdata(sci_path, header=True)
        # 'int' type may cause error when calculating
        sci_data0 = sci_data.astype('float')
        
        mbias_data = 0. if mbias is None else fits.getdata(mbias)
        mdark_data = 0. if mdark is None else fits.getdata(mdark)
        mflat_data = 1. if mflat is None else fits.getdata(mflat)
        
        sci_data = sci_data0 - mbias_data    # Bias subtraction
        sci_data -= mdark_data   # Dark subtraction
        sci_data /= mflat_data    # Flat fielding
        
        # visual inspection
        if plot:
            fig, axs = plt.subplots(1, 2, figsize=(10,6))
            title = ["Raw Data", "Preprocessed Data"]
            for i, img in enumerate([sci_data0, sci_data]):
                ax = axs[i]
                vmin, vmax = interval.get_limits(img)
                ax.imshow(img, cmap='gray', origin='lower',
                          vmin=vmin, vmax=vmax)
                ax.tick_params(axis='both', length=0.0,
                               labelleft=False, labelbottom=False)
                if i == 0:
                    ax.text(0.04, 0.04, sci_path.name, fontsize=12.0, 
                            transform=ax.transAxes, ha='left', va='bottom')
                ax.text(0.50, 0.96, title[i], fontsize=12.0, fontweight='bold',
                        transform=ax.transAxes, ha='center', va='top')
            plt.tight_layout()
        
        # recording preprocessing history
        now = time.strftime("%Y-%m-%d %H:%M:%S (GMT%z)")
        sci_hdr['RDNOISE'] = rdnoise
        sci_hdr['history'] = 'Preprocessed at ' + now
            
        # saving preprocessed image to a fits file
        fits.writeto(OUTDIR/('p'+sci_path.name+'s'), sci_data, sci_hdr,
                     overwrite=True)
        if verbose:
            print(f"Done: {sci_path.name}")
for group in sci_table.group_by('filter').groups:
    fnames = [RAWDIR/f for f in group['filename']]
    filter_now = group['filter'][0]
    exptime = group['exptime'][0]
    mdark_file = OUTDIR/f"MDark{exptime:.0f}.fits"
    mflat_file = OUTDIR/f"MFlat{filter_now}.fits"
    rdnoise = fits.getheader(mbias_file)['RDNOISE']
    preproc(fnames, mbias_file, mdark_file, mflat_file, rdnoise,
            plot=True, verbose=True)
Done: M101-0001Ha.fit
Done: M101-0002Ha.fit
Done: M101-0003Ha.fit
Done: M101-0001g.fit
Done: M101-0002g.fit
Done: M101-0003g.fit
Done: M101-0001i.fit
Done: M101-0002i.fit
Done: M101-0003i.fit
Done: M101-0001r.fit
Done: M101-0002r.fit
Done: M101-0003r.fit
../_images/dc933c8d4372f43fab791a890e21c3fce6a34859401b309e60e8bd33f9677d63.png ../_images/d7177202d9c9028a0c29594de3ce21ebb4e2d25ee57934ab54be95f180f615ce.png ../_images/a89ff9f192d317e0ecc3825bd82a6017450f2e50693a532f5683acef5c21755a.png ../_images/4d2433eda400055f18812640299530d0f886e5b1382d832ab60de38d908323dd.png ../_images/26cf843f34460299dcae579ed60ca2cf33ea2bfba33d22cf1e73d2bd4658885b.png ../_images/9877f173b57636321eecfc332e75a638604aebfcec6fa7ba464d4d03802b99e0.png ../_images/7e6ee1c3fe7a827687da0470d8b214a7901500b9a03d9ac9bb6e8ea0f7069e41.png ../_images/c637f7b5856a8873c1e38f4ba674a4bd8629f9f62609e343096dcd0b8e706a9b.png ../_images/57b855ffbc16e16fa237bfa29a5d1abe38685748187e00e8750c4d3e29df8829.png ../_images/7a8aff27d659e2712ddeb2b41a85bfdfaa3e0f1ac06422136146c48f3ce6e81c.png ../_images/0fbb3b31b0a5c6c663af5e86e5d1ad9af479ded6ec7ea982b5fe4e6362006124.png ../_images/26c3ce847f854bcb8860b9f9e089f1788b76dcfaaf060154acb457ebe0903a2a.png
def align_single_frame(img_fname, ccd_ref):
    # alingning a single frame with respect to the reference frame
    dat = fits.getdata(img_fname, ext=0)
    ccd = CCDData(dat.byteswap().newbyteorder(), unit='adu')
    dat_aligned, footprint = aa.register(ccd, ccd_ref,
                                         max_control_points=50,
                                         detection_sigma=5, min_area=16,
                                         propagate_mask=True)
    ccd_aligned = CCDData(dat_aligned, unit='adu')
    return ccd_aligned

def align_frames(img_list, verbose=True, plot=False, window=[1277, 1437, 6000, 6160],
                 suptitle=None):
    # reference image: frame 0
    id_ref = 0
    dat_ref, hdr_ref = fits.getdata(img_list[id_ref], header=True, ext=0)
    ccd_ref = CCDData(dat_ref, unit='adu')

    # Aligning other images with respect to the reference image
    if verbose:
        start_time = time.time()
        print(f"--- Started aligning {len(img_list):d} images ---")
        
    aligned_list = []
    for i in range(len(img_list)):
        if (i == id_ref):
            ccd_aligned = ccd_ref
        else:
            ccd_aligned = align_single_frame(img_list[i], ccd_ref)
        aligned_list.append(ccd_aligned)
    if verbose:
        end_time = time.time()
        print(f"--- {end_time-start_time:.4f} sec were taken for aligning {len(img_list):d} images ---")
        
    if plot:
        plot_alignment_check(aligned_list, window=window, suptitle=suptitle)
        
    return aligned_list

def plot_alignment_check(aligned_list, window=[1277, 1437, 6000, 6160], suptitle=None):
    fig, axs = plt.subplots(1,len(aligned_list), figsize=(3*len(aligned_list),3))
    for i in range(len(aligned_list)):
        if isinstance(aligned_list[i], CCDData):
            img = aligned_list[i].data
        else:
            img = aligned_list[i]
        trim = img[window[0]:window[1], window[2]:window[3]]
        vmin, vmax = interval.get_limits(trim)
        ax = axs[i]
        ax.imshow(trim, origin='lower', vmin=vmin, vmax=vmax, cmap='gray')
        if i == 0:
            xc, yc = np.unravel_index(np.argmax(trim), trim.shape)
        ax.axvline(yc, c='r', lw=0.8)
        ax.axhline(xc, c='r', lw=0.8)
    if suptitle is not None:
        fig.suptitle(suptitle, fontsize=14)
# testing the alignment of the g-band science frames
group = sci_table[sci_table['filter']=='g']
sci_list = [OUTDIR/f'p{f}s' for f in group['filename']]
sci_data = [fits.getdata(f) for f in sci_list]

# checking the alignment of the science frames
plot_alignment_check(sci_data, suptitle='Before alignment')

# aligning the science frames
sci_aligned = align_frames(sci_list, verbose=True, plot=True,
                           window=[1277, 1437, 6000, 6160], suptitle='After alignment')
--- Started aligning 3 images ---
--- 6.5767 sec were taken for aligning 3 images ---
../_images/2f4fda570cbbf11c17713376404488138f168ebff64d66711f78a0504b7b51ad.png ../_images/2462f5826cda4e0002ff3cf1ee76f839861a74517d21adeae398d94e1bda177a.png
# align and combine the science frames
for group in sci_table.group_by('filter').groups:
    filter_now = group['filter'][0]
    sci_list = [OUTDIR/f'p{f}s' for f in group['filename']] # preprocessed sci frames
    # aligning the science frames
    print(f"Aligning {len(sci_list)} science frames ({filter_now}-band)")
    sci_aligned = align_frames(sci_list, verbose=True, plot=False)
    
    # combining the aligned frames
    combined = combine(sci_aligned, sigma_clip=True,
                       sigma_clip_low_thresh=3, sigma_clip_high_thresh=3)
    
    # saving the combined science frame
    object_name = group['object'][0].replace(' ', '')
    sci_hdr = fits.getheader(sci_list[0])
    sci_hdr['NFRAMES'] = len(sci_list)
    sci_hdr['HISTORY'] = 'Aligned and combined'
    fits.writeto(OUTDIR/f'M{object_name}_{filter_now}.fits', combined.data,
                 sci_hdr, overwrite=True)
Aligning 3 science frames (Ha-band)
--- Started aligning 3 images ---
--- 7.5915 sec were taken for aligning 3 images ---
Aligning 3 science frames (g-band)
--- Started aligning 3 images ---
--- 6.8045 sec were taken for aligning 3 images ---
Aligning 3 science frames (i-band)
--- Started aligning 3 images ---
--- 7.1318 sec were taken for aligning 3 images ---
Aligning 3 science frames (r-band)
--- Started aligning 3 images ---
--- 7.0729 sec were taken for aligning 3 images ---
def sdss_rgb(imgs, bands, scales=None, m=0.02, Q=20):
    import numpy as np
    rgbscales = {'u': (2,1.5), #1.0,
                 'g': (2,2.5),
                 'r': (1,1.5),
                 'i': (0,1.0),
                 'z': (0,0.4), #0.3
                 }
    if scales is not None:
        rgbscales.update(scales)

    I = 0
    for img,band in zip(imgs, bands):
        plane,scale = rgbscales[band]
        img = np.maximum(0, img * scale + m)
        I = I + img
    I /= len(bands)
        
    fI = np.arcsinh(Q * I) / np.sqrt(Q)
    I += (I == 0.) * 1e-6
    H,W = I.shape
    rgb = np.zeros((H,W,3), np.float32)
    for img,band in zip(imgs, bands):
        plane,scale = rgbscales[band]
        rgb[:,:,plane] = (img * scale + m) * fI / I
        
    rgb = np.clip(rgb, 0, 1)
    return rgb
def background_substraction(img, box=100, filt=3, show=True):
    from astropy.stats import SigmaClip
    from photutils.segmentation import detect_threshold, detect_sources
    from photutils.utils import circular_footprint
    import sep
    sigma_clip = SigmaClip(sigma=3.0, maxiters=10)
    threshold = detect_threshold(img, nsigma=3.0, sigma_clip=sigma_clip)
    segment_img = detect_sources(img, threshold, npixels=10)
    footprint = circular_footprint(radius=3)
    mask = segment_img.make_source_mask(footprint=footprint)
    # mask = SegmentationImage.make_source_mask(img, nsigma=2, npixels=1, dilate_size=3)
    bkg_sep = sep.Background(img.byteswap().newbyteorder(), 
                             mask=mask, bw=box, bh=box, fw=filt, fh=filt)
    bkgsub_sep = img - bkg_sep.back()
    
    if show:
        fig, axs = plt.subplots(2, 3, figsize=(10, 8))
        axs[1, 1].axis("off")
        
        data2plot = [
            dict(ax=axs[0, 0], arr=img,              title="Original data"),
            dict(ax=axs[0, 1], arr=bkg_sep.back(),   title=f"bkg (filt={filt:d}, box={box:d})"),
            dict(ax=axs[0, 2], arr=bkgsub_sep,       title="bkg subtracted"),
            dict(ax=axs[1, 0], arr=mask,             title="Mask"),
            # dict(ax=axs[1, 1], arr=bkg_sep.background_mesh,   title="bkg mesh"),
            dict(ax=axs[1, 2], arr=10*bkg_sep.rms(), title="10 * bkg RMS")
        ]
        
        for dd in data2plot:
            im = zscale_imshow(dd['ax'], dd['arr'])
            cb = fig.colorbar(im, ax=dd['ax'], orientation='horizontal')
            # cb.ax.set_xticklabels([f'{x:.3f}' for x in cb.get_ticks()])#, rotation=45)
            dd['ax'].set_title(dd['title'])
        
        plt.tight_layout()
    
    return bkgsub_sep


def plot_background_estimation(img, bkg, filt, box):
    fig, axs = plt.subplots(1, 3, figsize=(12, 4))

    data2plot = [
        dict(ax=axs[0], arr=img,      title="Original data"),
        dict(ax=axs[1], arr=bkg,      title=f"bkg (filt={filt:d}, box={box:d})"),
        dict(ax=axs[2], arr=img-bkg,  title="bkg subtracted"),
    ]

    for dd in data2plot:
        im = zscale_imshow(dd['ax'], dd['arr'])
        cb = fig.colorbar(im, ax=dd['ax'], orientation='horizontal')
        # cb.ax.set_xticklabels([f'{x:.3f}' for x in cb.get_ticks()])#, rotation=45)
        dd['ax'].set_title(dd['title'])
    fig.tight_layout()


def zscale_imshow(ax, data, **kwargs):
    interval = ZScaleInterval()
    vmin, vmax = interval.get_limits(data)
    im = ax.imshow(data, cmap='gray', origin='lower', vmin=vmin, vmax=vmax, **kwargs)
    return im
gimg = fits.getdata(OUTDIR / 'MM101_g.fits')
rimg = fits.getdata(OUTDIR / 'MM101_r.fits')
iimg = fits.getdata(OUTDIR / 'MM101_i.fits')

#downscale
nsample = 1
from skimage.transform import downscale_local_mean
gimg = downscale_local_mean(gimg, (nsample, nsample))
rimg = downscale_local_mean(rimg, (nsample, nsample))
iimg = downscale_local_mean(iimg, (nsample, nsample))


grbox = 200 / nsample
ibox = 600 / nsample
filtsize = (5,5)

imgcenter = [gimg.shape[0]//2, gimg.shape[1]//2]
mask_radius = 120 / nsample
mask = np.zeros_like(gimg, dtype=bool)
xx, yy = np.meshgrid(np.arange(gimg.shape[1]), np.arange(gimg.shape[0]))
mask[(xx - imgcenter[1])**2 + (yy - imgcenter[0])**2 < mask_radius**2] = True

from photutils.background import Background2D, MedianBackground
bkg_estimator = MedianBackground()
gbkg = Background2D(gimg, (grbox, grbox), mask=mask, filter_size=filtsize,
                    bkg_estimator=bkg_estimator)
rbkg = Background2D(rimg, (grbox, grbox), mask=mask, filter_size=filtsize,
                    bkg_estimator=bkg_estimator)
ibkg = Background2D(iimg, (ibox, ibox), mask=mask, filter_size=filtsize,
                    bkg_estimator=bkg_estimator)

gimg_bkgsub = gimg - gbkg.background  # subtract the background
rimg_bkgsub = rimg - rbkg.background
iimg_bkgsub = iimg - ibkg.background


plot_background_estimation(gimg, gbkg.background, filtsize[0], grbox)
plot_background_estimation(rimg, rbkg.background, filtsize[0], grbox)
plot_background_estimation(iimg, ibkg.background, filtsize[0], ibox)
../_images/6049f47e839bf7e72501b4b103b26be0b0646234e00c48f82b2c874ce6b65ad3.png ../_images/bb38f2bd4bca80157d487fe3375ce397eed45b956a100c490dc08708a4eff68d.png ../_images/af81ceb02b92cbb8a9f39d066af5edaca10e5e9afbac9cd0a3d617683077407b.png
rgb = sdss_rgb([gimg_bkgsub, rimg_bkgsub, iimg_bkgsub], ['g', 'r', 'i'],
               scales={'g': (2, 0.9), 'r': (1, 1.0), 'i': (0, 1.0)}, m=0., Q=0.003)

fig, ax = plt.subplots(1, 1, figsize=(15, 8))
ax.imshow(rgb, origin='lower')
<matplotlib.image.AxesImage at 0x13f895fd0>
../_images/60102c03f86ad887afefa89a60f802b86d9cc91e36c808d42ec09d9276a03b2a.png

Discussion#

flat_table_g = flat_table[flat_table['filter']=='g']
flat1 = fits.getdata(RAWDIR / flat_table_g['filename'][0]) # first flat frame
flat2 = fits.getdata(RAWDIR / flat_table_g['filename'][-1]) # last flat frame
mbias = fits.getdata(OUTDIR / 'Mbias.fits')
mdark = fits.getdata(OUTDIR / 'MDark2.fits')
flat1 = flat1 - mbias - mdark
flat2 = flat2 - mbias - mdark
mflat = fits.getdata(OUTDIR / 'MFlatg.fits')
fig, axs = plt.subplots(1, 3, figsize=(15,6))
title = ["flat1/flat2", "flat1/Mflat", "flat2/Mflat"]
for i, img in enumerate([flat1/flat2, flat1/mflat, flat2/mflat]):
    ax = axs[i]
    vmin, vmax = interval.get_limits(img)
    ax.imshow(img, cmap='gray', origin='lower',
                vmin=vmin, vmax=vmax)
    ax.tick_params(axis='both', length=0.0,
                    labelleft=False, labelbottom=False)
    ax.text(0.50, 0.96, title[i], fontsize=12.0, fontweight='bold',
            transform=ax.transAxes, ha='center', va='top')
../_images/703d277c5cbeb4861499a550fa2575a89905a30435ae8f8f8d7e8e42b3d61b62.png
group = flat_table_g
fnames = [RAWDIR/f for f in group['filename']]
mdark_file = OUTDIR/f"MDark{group['exptime'][0]:.0f}.fits"
_ = make_mflat(fnames, mbias_file, mdark_file, verbose=False, plot=True)
INFO: splitting each image into 2 chunks to limit memory usage to 16000000000.0 bytes. [ccdproc.combiner]
../_images/9a7d51046349ef24ed5543e99b5a7778d1633d69a8f79741c35aea922b5a85bb.png

This notebook is largely based on following references.