from astropy.io import fits
from astropy import units
import numpy as np
import scipy.signal
from pathlib import Path
from typing import List, Tuple, Union
from autoarray.mask.mask_2d import Mask2D
from autoarray.structures.arrays.uniform_2d import AbstractArray2D
from autoarray.structures.arrays.uniform_2d import Array2D
from autoarray.structures.grids.uniform_2d import Grid2D
from autoarray.structures.header import Header
from autoarray import exc
from autoarray import type as ty
from autoarray.structures.arrays import array_2d_util
[docs]
class Kernel2D(AbstractArray2D):
def __init__(
self,
values,
mask,
header=None,
normalize: bool = False,
store_native: bool = False,
*args,
**kwargs
):
"""
An array of values, which are paired to a uniform 2D mask of pixels. Each entry
on the array corresponds to a value at the centre of a pixel in an unmasked pixel. See the ``Array2D`` class
for a full description of how Arrays work.
The ``Kernel2D`` class is an ``Array2D`` but with additioonal methods that allow it to be convolved with data.
Parameters
----------
values
The values of the array.
mask
The 2D mask associated with the array, defining the pixels each array value is paired with and
originates from.
normalize
If True, the Kernel2D's array values are normalized such that they sum to 1.0.
"""
super().__init__(
values=values,
mask=mask,
header=header,
store_native=store_native,
)
if normalize:
self._array[:] = np.divide(self._array, np.sum(self._array))
[docs]
@classmethod
def no_mask(
cls,
values: Union[np.ndarray, List],
pixel_scales: ty.PixelScales,
shape_native: Tuple[int, int] = None,
origin: Tuple[float, float] = (0.0, 0.0),
normalize: bool = False,
):
"""
Create a Kernel2D (see *Kernel2D.__new__*) by inputting the kernel values in 1D or 2D, automatically
determining whether to use the 'manual_slim' or 'manual_native' methods.
See the manual_slim and manual_native methods for examples.
Parameters
----------
values
The values of the array input as an ndarray of shape [total_unmasked_pixels] or a list of
lists.
shape_native
The 2D shape of the mask the array is paired with.
pixel_scales
The (y,x) arcsecond-to-pixel units conversion factor of every pixel. If this is input as a `float`,
it is converted to a (float, float).
origin
The (y,x) scaled units origin of the mask's coordinate system.
normalize
If True, the Kernel2D's array values are normalized such that they sum to 1.0.
"""
values = Array2D.no_mask(
values=values,
shape_native=shape_native,
pixel_scales=pixel_scales,
origin=origin,
)
return Kernel2D(values=values, mask=values.mask, normalize=normalize)
[docs]
@classmethod
def full(
cls,
fill_value: float,
shape_native: Tuple[int, int],
pixel_scales: ty.PixelScales,
origin: Tuple[float, float] = (0.0, 0.0),
normalize: bool = False,
) -> "Kernel2D":
"""
Create a Kernel2D (see *Kernel2D.__new__*) where all values are filled with an input fill value, analogous to
the method numpy ndarray.full.
From 1D input the method cannot determine the 2D shape of the array and its mask, thus the shape_native must be
input into this method. The mask is setup as a unmasked `Mask2D` of shape_native.
Parameters
----------
fill_value
The value all array elements are filled with.
shape_native
The 2D shape of the mask the array is paired with.
pixel_scales
The (y,x) arcsecond-to-pixel units conversion factor of every pixel. If this is input as a `float`,
it is converted to a (float, float).
origin
The (y,x) scaled units origin of the mask's coordinate system.
normalize
If True, the Kernel2D's array values are normalized such that they sum to 1.0.
"""
return Kernel2D.no_mask(
values=np.full(fill_value=fill_value, shape=shape_native),
pixel_scales=pixel_scales,
origin=origin,
normalize=normalize,
)
[docs]
@classmethod
def ones(
cls,
shape_native: Tuple[int, int],
pixel_scales: ty.PixelScales,
origin: Tuple[float, float] = (0.0, 0.0),
normalize: bool = False,
):
"""
Create an Kernel2D (see *Kernel2D.__new__*) where all values are filled with ones, analogous to the method numpy
ndarray.ones.
From 1D input the method cannot determine the 2D shape of the array and its mask, thus the shape_native must be
input into this method. The mask is setup as a unmasked `Mask2D` of shape_native.
Parameters
----------
shape_native
The 2D shape of the mask the array is paired with.
pixel_scales
The (y,x) arcsecond-to-pixel units conversion factor of every pixel. If this is input as a `float`,
it is converted to a (float, float).
origin
The (y,x) scaled units origin of the mask's coordinate system.
normalize
If True, the Kernel2D's array values are normalized such that they sum to 1.0.
"""
return cls.full(
fill_value=1.0,
shape_native=shape_native,
pixel_scales=pixel_scales,
origin=origin,
normalize=normalize,
)
[docs]
@classmethod
def zeros(
cls,
shape_native: Tuple[int, int],
pixel_scales: ty.PixelScales,
origin: Tuple[float, float] = (0.0, 0.0),
normalize: bool = False,
) -> "Kernel2D":
"""
Create an Kernel2D (see *Kernel2D.__new__*) where all values are filled with zeros, analogous to the method numpy
ndarray.ones.
From 1D input the method cannot determine the 2D shape of the array and its mask, thus the shape_native must be
input into this method. The mask is setup as a unmasked `Mask2D` of shape_native.
Parameters
----------
shape_native
The 2D shape of the mask the array is paired with.
pixel_scales
The (y,x) arcsecond-to-pixel units conversion factor of every pixel. If this is input as a `float`,
it is converted to a (float, float).
origin
The (y,x) scaled units origin of the mask's coordinate system.
normalize
If True, the Kernel2D's array values are normalized such that they sum to 1.0.
"""
return cls.full(
fill_value=0.0,
shape_native=shape_native,
pixel_scales=pixel_scales,
origin=origin,
normalize=normalize,
)
[docs]
@classmethod
def no_blur(cls, pixel_scales):
"""
Setup the Kernel2D as a kernel which does not convolve any signal, which is simply an array of shape (1, 1)
with value 1.
Parameters
----------
pixel_scales
The (y,x) arcsecond-to-pixel units conversion factor of every pixel. If this is input as a `float`,
it is converted to a (float, float).
"""
array = np.array([[0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]])
return cls.no_mask(values=array, pixel_scales=pixel_scales)
[docs]
@classmethod
def from_gaussian(
cls,
shape_native: Tuple[int, int],
pixel_scales: ty.PixelScales,
sigma: float,
centre: Tuple[float, float] = (0.0, 0.0),
axis_ratio: float = 1.0,
angle: float = 0.0,
normalize: bool = False,
) -> "Kernel2D":
"""
Setup the Kernel2D as a 2D symmetric elliptical Gaussian profile, according to the equation:
(1.0 / (sigma * sqrt(2.0*pi))) * exp(-0.5 * (r/sigma)**2)
Parameters
----------
shape_native
The 2D shape of the mask the array is paired with.
pixel_scales
The (y,x) arcsecond-to-pixel units conversion factor of every pixel. If this is input as a `float`,
it is converted to a (float, float).
sigma
The value of sigma in the equation, describing the size and full-width half maximum of the Gaussian.
centre
The (y,x) central coordinates of the Gaussian.
axis_ratio
The axis-ratio of the elliptical Gaussian.
angle
The rotational angle of the Gaussian's ellipse defined counter clockwise from the positive x-axis.
normalize
If True, the Kernel2D's array values are normalized such that they sum to 1.0.
"""
grid = Grid2D.uniform(shape_native=shape_native, pixel_scales=pixel_scales)
grid_shifted = np.subtract(grid, centre)
grid_radius = np.sqrt(np.sum(grid_shifted**2.0, 1))
theta_coordinate_to_profile = np.arctan2(
grid_shifted[:, 0], grid_shifted[:, 1]
) - np.radians(angle)
grid_transformed = np.vstack(
(
grid_radius * np.sin(theta_coordinate_to_profile),
grid_radius * np.cos(theta_coordinate_to_profile),
)
).T
grid_elliptical_radii = np.sqrt(
np.add(
np.square(grid_transformed[:, 1]),
np.square(np.divide(grid_transformed[:, 0], axis_ratio)),
)
)
gaussian = np.multiply(
np.divide(1.0, sigma * np.sqrt(2.0 * np.pi)),
np.exp(-0.5 * np.square(np.divide(grid_elliptical_radii, sigma))),
)
return cls.no_mask(
values=gaussian,
shape_native=shape_native,
pixel_scales=pixel_scales,
normalize=normalize,
)
@classmethod
def from_as_gaussian_via_alma_fits_header_parameters(
cls,
shape_native: Tuple[int, int],
pixel_scales: ty.PixelScales,
y_stddev: float,
x_stddev: float,
theta: float,
centre: Tuple[float, float] = (0.0, 0.0),
normalize: bool = False,
) -> "Kernel2D":
x_stddev = (
x_stddev * (units.deg).to(units.arcsec) / (2.0 * np.sqrt(2.0 * np.log(2.0)))
)
y_stddev = (
y_stddev * (units.deg).to(units.arcsec) / (2.0 * np.sqrt(2.0 * np.log(2.0)))
)
axis_ratio = x_stddev / y_stddev
return Kernel2D.from_gaussian(
shape_native=shape_native,
pixel_scales=pixel_scales,
sigma=y_stddev,
axis_ratio=axis_ratio,
angle=90.0 - theta,
centre=centre,
normalize=normalize,
)
[docs]
@classmethod
def from_fits(
cls,
file_path: Union[Path, str],
hdu: int,
pixel_scales,
origin=(0.0, 0.0),
normalize: bool = False,
) -> "Kernel2D":
"""
Loads the Kernel2D from a .fits file.
Parameters
----------
file_path
The path the file is loaded from, including the filename and the ``.fits`` extension,
e.g. '/path/to/filename.fits'
hdu
The Header-Data Unit of the .fits file the array data is loaded from.
pixel_scales
The (y,x) arcsecond-to-pixel units conversion factor of every pixel. If this is input as a `float`,
it is converted to a (float, float).
origin
The (y,x) scaled units origin of the mask's coordinate system.
normalize
If True, the Kernel2D's array values are normalized such that they sum to 1.0.
"""
array = Array2D.from_fits(
file_path=file_path, hdu=hdu, pixel_scales=pixel_scales, origin=origin
)
header_sci_obj = array_2d_util.header_obj_from(file_path=file_path, hdu=0)
header_hdu_obj = array_2d_util.header_obj_from(file_path=file_path, hdu=hdu)
return Kernel2D(
values=array[:],
mask=array.mask,
normalize=normalize,
header=Header(header_sci_obj=header_sci_obj, header_hdu_obj=header_hdu_obj),
)
[docs]
@classmethod
def from_primary_hdu(
cls,
primary_hdu: fits.PrimaryHDU,
origin: Tuple[float, float] = (0.0, 0.0),
) -> "Kernel2D":
"""
Returns an ``Kernel2D`` by from a `PrimaryHDU` object which has been loaded via `astropy.fits`
This assumes that the `header` of the `PrimaryHDU` contains an entry named `PIXSCALE` which gives the
pixel-scale of the array.
For a full description of ``Kernel2D`` objects, including a description of the ``slim`` and ``native`` attribute
used by the API, see
the :meth:`Kernel2D class API documentation <autoarray.structures.arrays.uniform_2d.AbstractKernel2D.__new__>`.
Parameters
----------
primary_hdu
The `PrimaryHDU` object which has already been loaded from a .fits file via `astropy.fits` and contains
the array data and the pixel-scale in the header with an entry named `PIXSCALE`.
origin
The (y,x) scaled units origin of the coordinate system.
Examples
--------
.. code-block:: python
from astropy.io import fits
import autoarray as aa
primary_hdu = fits.open("path/to/file.fits")
array_2d = aa.Kernel2D.from_primary_hdu(
primary_hdu=primary_hdu,
)
"""
return cls.no_mask(
values=cls.flip_hdu_for_ds9(primary_hdu.data.astype("float")),
pixel_scales=primary_hdu.header["PIXSCALE"],
origin=origin,
)
[docs]
def rescaled_with_odd_dimensions_from(
self, rescale_factor: float, normalize: bool = False
) -> "Kernel2D":
"""
If the PSF kernel has one or two even-sized dimensions, return a PSF object where the kernel has odd-sized
dimensions (odd-sized dimensions are required by a *Convolver*).
The PSF can be scaled to larger / smaller sizes than the input size, if the rescale factor uses values that
deviate furher from 1.0.
Kernels are rescald using the scikit-image routine rescale, which performs rescaling via an interpolation
routine. This may lead to loss of accuracy in the PSF kernel and it is advised that users, where possible,
create their PSF on an odd-sized array using their data reduction pipelines that remove this approximation.
Parameters
----------
rescale_factor
The factor by which the kernel is rescaled. If this has a value of 1.0, the kernel is rescaled to the
closest odd-sized dimensions (e.g. 20 -> 19). Higher / lower values scale to higher / lower dimensions.
normalize
Whether the PSF should be normalized after being rescaled.
"""
from skimage.transform import resize, rescale
try:
kernel_rescaled = rescale(
self.native,
rescale_factor,
anti_aliasing=False,
mode="constant",
channel_axis=None,
)
except TypeError:
kernel_rescaled = rescale(
self.native,
rescale_factor,
anti_aliasing=False,
mode="constant",
)
if kernel_rescaled.shape[0] % 2 == 0 and kernel_rescaled.shape[1] % 2 == 0:
kernel_rescaled = resize(
kernel_rescaled,
output_shape=(
kernel_rescaled.shape[0] + 1,
kernel_rescaled.shape[1] + 1,
),
anti_aliasing=False,
mode="constant",
)
elif kernel_rescaled.shape[0] % 2 == 0 and kernel_rescaled.shape[1] % 2 != 0:
kernel_rescaled = resize(
kernel_rescaled,
output_shape=(kernel_rescaled.shape[0] + 1, kernel_rescaled.shape[1]),
anti_aliasing=False,
mode="constant",
)
elif kernel_rescaled.shape[0] % 2 != 0 and kernel_rescaled.shape[1] % 2 == 0:
kernel_rescaled = resize(
kernel_rescaled,
output_shape=(kernel_rescaled.shape[0], kernel_rescaled.shape[1] + 1),
anti_aliasing=False,
mode="constant",
)
if self.pixel_scales is not None:
pixel_scale_factors = (
self.mask.shape[0] / kernel_rescaled.shape[0],
self.mask.shape[1] / kernel_rescaled.shape[1],
)
pixel_scales = (
self.pixel_scales[0] * pixel_scale_factors[0],
self.pixel_scales[1] * pixel_scale_factors[1],
)
else:
pixel_scales = None
return Kernel2D.no_mask(
values=kernel_rescaled, pixel_scales=pixel_scales, normalize=normalize
)
@property
def normalized(self) -> "Kernel2D":
"""
Normalize the Kernel2D such that its data_vector values sum to unity.
"""
return Kernel2D(values=self, mask=self.mask, normalize=True)
[docs]
def convolved_array_from(self, array: Array2D) -> Array2D:
"""
Convolve an array with this Kernel2D
Parameters
----------
image
An array representing the image the Kernel2D is convolved with.
Returns
-------
convolved_image
An array representing the image after convolution.
Raises
------
KernelException if either Kernel2D psf dimension is odd
"""
if self.mask.shape[0] % 2 == 0 or self.mask.shape[1] % 2 == 0:
raise exc.KernelException("Kernel2D Kernel2D must be odd")
array_2d = array.native
convolved_array_2d = scipy.signal.convolve2d(array_2d, self.native, mode="same")
convolved_array_1d = array_2d_util.array_2d_slim_from(
mask_2d=np.array(array_2d.mask),
array_2d_native=convolved_array_2d,
)
return Array2D(values=convolved_array_1d, mask=array_2d.mask)
[docs]
def convolved_array_with_mask_from(self, array: Array2D, mask: Mask2D) -> Array2D:
"""
Convolve an array with this Kernel2D
Parameters
----------
image
An array representing the image the Kernel2D is convolved with.
Returns
-------
convolved_image
An array representing the image after convolution.
Raises
------
KernelException if either Kernel2D psf dimension is odd
"""
if self.mask.shape[0] % 2 == 0 or self.mask.shape[1] % 2 == 0:
raise exc.KernelException("Kernel2D Kernel2D must be odd")
convolved_array_2d = scipy.signal.convolve2d(array, self.native, mode="same")
convolved_array_1d = array_2d_util.array_2d_slim_from(
mask_2d=np.array(mask),
array_2d_native=np.array(convolved_array_2d),
)
return Array2D(values=convolved_array_1d, mask=mask)