[1]:
!pip install photutils  # we'll use photutils to make synthetic images
from pathlib import Path

import numpy as np
import matplotlib.pyplot as plt
from astropy.modeling.models import Gaussian2D
from photutils.datasets import make_model_image, make_model_params

from regularizepsf import simple_functional_psf, varied_functional_psf, ArrayPSFTransform, ArrayPSFBuilder
from regularizepsf.util import calculate_covering
from regularizepsf.visualize import visualize_patch_counts
zsh:1: command not found: pip

Example#

We will walk through how to use the various parts of regularizePSF. We will generate fake data with an irregular PSF and test if we can determine the underlying PSF and correct it.

To start, we will generate some fake point spread functions.

[2]:
# Define the parameters and directory of images to use
psf_size = 64  # size of the PSF model to use in pixels
initial_sigma = 3.1
img_size = 512

@simple_functional_psf
def baked_in_initial_psf(row,
                         col,
                         x0=psf_size / 2,
                         y0=psf_size / 2,
                         sigma_x=initial_sigma,
                         sigma_y=initial_sigma,
                         A=1.0/(2 * np.pi * initial_sigma * initial_sigma)):
    return A * np.exp(-(np.square(row - x0) / (2 * np.square(sigma_x)) + np.square(col - y0) / (2 * np.square(sigma_y))))


@simple_functional_psf
def target_psf(row,
                        col,
                        core_sigma_x=initial_sigma,
                        core_sigma_y=initial_sigma,
                        tail_angle=0,
                        tail_separation=0,
                        ):
    x0 = psf_size / 2
    y0 = psf_size / 2
    A = 0.1
    core = A * np.exp(
        -(np.square(row - x0) / (2 * np.square(core_sigma_x)) + np.square(col - y0) / (2 * np.square(core_sigma_y))))

    A_tail = 0.05
    sigma_x = tail_separation
    sigma_y = core_sigma_y + 0.15
    a = np.square(np.cos(tail_angle)) / (2 * np.square(sigma_x)) + np.square(np.sin(tail_angle)) / (
                2 * np.square(sigma_y))
    b = -np.sin(tail_angle) * np.cos(tail_angle) / (2 * np.square(sigma_x)) + (
                (np.sin(tail_angle) * np.cos(tail_angle)) / (2 * np.square(sigma_y)))
    c = np.square(np.sin(tail_angle)) / (2 * np.square(sigma_x)) + np.square(np.cos(tail_angle)) / (
                2 * np.square(sigma_y))
    tail_x0 = x0 - tail_separation * np.cos(tail_angle)
    tail_y0 = y0 + tail_separation * np.sin(tail_angle)
    tail = A_tail * np.exp(-(a * (row - tail_x0) ** 2 + 2 * b * (row - tail_x0) * (col - tail_y0) + c * (col - tail_y0) ** 2))
    total = core + tail
    return total / np.sum(total)

@varied_functional_psf(target_psf)
def synthetic_psf(row, col):
    return {"tail_angle": -np.arctan2(row - img_size//2, col - img_size//2),
            "tail_separation": np.sqrt((row - img_size//2) ** 2 + (col - img_size//2) ** 2)/105 * 2 + 1E-3,
            "core_sigma_x": 2.5,
            "core_sigma_y": 2.5}

coords = calculate_covering((img_size, img_size), psf_size)
example = synthetic_psf.as_array_psf(coords, psf_size)

Our synthetic PSF is a variable PSF meaning that it looks different in different parts of the image. This will be clear when we apply it to an image. For now, let’s look at one example of the synthetic PSF. We can see that it has a core and then a long tail. The orientation and length of the tail will vary across the image.

[3]:
fig, ax = plt.subplots()
ax.imshow(example[(0, 0)], origin='lower')
plt.show()
_images/example_4_0.png

Let’s check out what the PSF looks like across an image. We’ll start by simulating an image without coma.

[4]:
def simulate_perfect_image():
    model = Gaussian2D()
    shape = (img_size, img_size)
    n_sources = 100
    params = make_model_params(shape, n_sources, x_name='x_mean',
                               y_name='y_mean', min_separation=5,
                               amplitude=(100, 1000), x_stddev=(3, 3),
                               y_stddev=(3, 3), theta=(0, 0), seed=None)
    model_shape = (25, 25)
    image = make_model_image(shape, model, params, model_shape=model_shape,
                            x_name='x_mean', y_name='y_mean')
    image += np.random.uniform(-5, 5, size=image.shape)
    image = image.astype(float)
    return image

example_image = simulate_perfect_image()

fig, ax = plt.subplots()
ax.imshow(example_image, origin='lower')
ax.set_axis_off()
plt.show()
_images/example_6_0.png

The stars are uniform everywhere. It’s time to apply our synthetic psf and see what happens! We’ll define a PSF transform that converts this symmetric PSF to our new synthetic PSF.

[5]:
coords = calculate_covering((img_size, img_size), psf_size)
t = ArrayPSFTransform.construct(baked_in_initial_psf.as_array_psf(coords, psf_size),
                                synthetic_psf.as_array_psf(coords, psf_size), 1.0, 0.1)
[6]:
coma_image = t.apply(example_image)

fig, axs = plt.subplots(ncols=2, sharex=True, sharey=True, figsize=(16, 8))
axs[0].imshow(example_image, interpolation='None', vmin=0, vmax=500, origin='lower')
axs[1].imshow(coma_image, interpolation='None', vmin=0, vmax=500, origin='lower')
for ax in axs:
    ax.set_axis_off()
axs[0].set_title("Original")
axs[1].set_title("Synthetic observation")
plt.show()
_images/example_9_0.png

To make our image more realistic, let’s apply a mask where the image might be obscured by an occulter or suffer poor data quality. For the sake of our image, we’ll keep it simple and just block out the same square of an image every time.

[7]:
mask = np.zeros((img_size, img_size), dtype=bool)
mask[100:175, 200:285] = True

coma_image = t.apply(example_image)
coma_image[mask] = 0

fig, axs = plt.subplots(ncols=2, sharex=True, sharey=True, figsize=(16, 8))
axs[0].imshow(example_image, interpolation='None', vmin=0, vmax=500,  origin='lower')
axs[1].imshow(coma_image, interpolation='None', vmin=0, vmax=500,  origin='lower')
for ax in axs:
    ax.set_axis_off()
axs[0].set_title("Original")
axs[1].set_title("Synthetic observation")
plt.show()
_images/example_11_0.png

Let’s also put some saturated pixels in to crudely represent cosmic ray strikes that can contaminate the calculations.

[8]:
mask = np.zeros((img_size, img_size), dtype=bool)
mask[100:175, 200:285] = True

coma_image = t.apply(example_image)
coma_image[mask] = 0

cosmic_ray_pixels = np.random.randint(0, img_size, (100, 2))
coma_image[cosmic_ray_pixels[:, 0], cosmic_ray_pixels[:, 1]] = np.random.randint(2_000, 3_000, 100)

fig, axs = plt.subplots(ncols=2, sharex=True, sharey=True, figsize=(16, 8))
axs[0].imshow(example_image, interpolation='None', vmin=0, vmax=500, origin='lower')
axs[1].imshow(coma_image, interpolation='None', vmin=0, vmax=500, origin='lower')
for ax in axs:
    ax.set_axis_off()
axs[0].set_title("Original")
axs[1].set_title("Synthetic observation")
plt.show()
_images/example_13_0.png

Look at how the stars have tails that are oriented differently in different parts of the image. That’s our variable synthetic PSF at work! We also see the dead patch of pixels and the saturated pixels.

Now, we’ll generate 200 of these synthetic coma images with different star samples.

[9]:
mask = np.zeros((img_size, img_size), dtype=bool)
mask[100:175, 200:285] = True

def simulate_coma_image(t):
    coma_image = t.apply(simulate_perfect_image())
    coma_image[mask] = 0
    cosmic_ray_pixels = np.random.randint(0, img_size, (100, 2))
    coma_image[cosmic_ray_pixels[:, 0], cosmic_ray_pixels[:, 1]] = np.random.randint(2_000, 3_000, 100)
    return coma_image

images = np.array([simulate_coma_image(t) for _ in range(200)])

The real purpose of this package is to regularize or make the PSF homogeneous across the image. We have a handy utility to do just that. We’ll extract the underlying PSF using stars from the 100 images we just generated.

[10]:
b = ArrayPSFBuilder(psf_size)
model, counts = b.build(images, saturation_threshold=2_000, image_mask=mask)
/Users/mhughes/repos/regularizepsf/regularizepsf/builder.py:135: RuntimeWarning: All-NaN slice encountered
  percentile_method = lambda d: np.nanmedian(d, axis=0)

The ArrayPSFBuilder provides both our model (we’ll look at it in just a second) and a diagnostic of how many stars were used in each part of the image to build the model.

[11]:
visualize_patch_counts(counts)
[11]:
<Axes: xlabel='Patch number', ylabel='Patch number'>
_images/example_19_1.png

Most regions of the image had at least 140 stars used to build the model. That should be plenty! Let’s inspect the model.

[12]:
model.visualize_psfs(all_patches=False)
_images/example_21_0.png

Yep! It varies kind of like how we expected. It’s time now to use the model to undo the coma in an image. We’ll define a uniform PSF as the output of our new transform and then apply it to each image.

For the sake of illustration, let’s build the a PSF model from the same data but not carefully handle the mask or saturated pixels to see how it’s different.

[13]:
b_bad = ArrayPSFBuilder(psf_size)
model_bad, counts_bad = b_bad.build(images)
model_bad.visualize_psfs(all_patches=False)
_images/example_23_0.png

This model does not capture the true PSF because of all the saturated pixels and the missing patch.

[14]:
@simple_functional_psf
def uniform_psf(row,
             col,
             x0=psf_size / 2,
             y0=psf_size / 2,
             sigma_x=3.0,
             sigma_y=3.0,
             A=1.0/(2 * np.pi * 3 * 3)):
    return A * np.exp(-(np.square(row - x0) / (2 * np.square(sigma_x)) + np.square(col - y0) / (2 * np.square(sigma_y))))

correcter = ArrayPSFTransform.construct(model, uniform_psf.as_array_psf(coords, psf_size), 1.0, 0.1)
corrected_images = np.array([correcter.apply(image, saturation_threshold=2_000) for image in images])
/Users/mhughes/repos/regularizepsf/regularizepsf/transform.py:138: RuntimeWarning: Mean of empty slice
  padded_image[i, j] = np.nanmean(padded_image[neighborhood_slice])

If we inspect an image, we’ll see the coma is indeed reversed. This will work on any image observed using the telescope with this coma. If our telescope alignment changes, we can always build a new PSF model.

[15]:
fig, axs = plt.subplots(ncols=2, sharex=True, sharey=True, figsize=(16, 8))
axs[0].imshow(images[0], interpolation='None', vmin=0, vmax=500)
axs[1].imshow(corrected_images[0], interpolation='None', vmin=0, vmax=500)
for ax in axs:
    ax.set_axis_off()
axs[0].set_title("Observation")
axs[1].set_title("Corrected image")
plt.show()
_images/example_27_0.png

It looks pretty uniform! Our model worked! Let’s save it for later. We can use either h5 or FITS.

[16]:
model.save(Path("model.fits"))
correcter.save(Path("corrector.fits"))
[17]:
model.save(Path("model.h5"))
correcter.save(Path("corrector.h5"))