"""Functions to help with image and array manipulations."""
import os
import numpy as np
import PIL.Image
import scipy.ndimage as sp_ndimage
import skimage.registration as ski_registration
import opihiexarata.library as library
import opihiexarata.library.error as error
import opihiexarata.library.hint as hint
[docs]
def slice_array_boundary(
array: hint.array, x_min: int, x_max: int, y_min: int, y_max: int
) -> hint.array:
"""Slice an image array such that it stops at the boundaries and does not
exceed past it. This function basically handels runtime slicing, but it
returns a copy.
This function does not wrap around slices.
Parameters
----------
array : array-like
The base array which the slice will access.
x_min : int
The lower index bound of the x-axis slice.
x_max : int
The upper index bound of the x-axis slice.
y_min : int
The lower index bound of the y-axis slice.
y_max : int
The upper index bound of the y-axis slice.
Returns
-------
boundary_sliced_array : array-like
The array, sliced while adhering to the boundary of the slices.
"""
# The maximum value that a slice can have is determined by their column
# and row count.
n_rows, n_cols = array.shape
# Negative slices are already invalid and out of bounds.
x_min = 0 if x_min <= 0 else x_min
x_max = n_cols if x_max <= 0 else x_max
y_min = 0 if y_min <= 0 else y_min
y_max = n_rows if y_max <= 0 else y_max
# Likewise, if any of the indexes exceed the bounds of the array, force
# them back.
x_max = n_cols if n_cols < x_max else x_max
y_max = n_rows if n_rows < y_max else y_max
# Return the slice with these bounds.
boundary_sliced_array = array[y_min:y_max, x_min:x_max]
return boundary_sliced_array
[docs]
def scale_image_array(
array: hint.array,
minimum: float,
maximum: float,
lower_percent_cut: float = 0,
upper_percent_cut: float = 0,
) -> hint.array:
"""This function scales the array to the provided minimum and maximum
ranges after the percentile masks are taken.
Parameters
----------
array : array-like
The array to be scaled.
minimum : float
The minimum value of the scaling axis. This will be equal to the
minimum value of the scaled array after accounting for the percentile
cuts.
maximum : float
The maximum value of the scaling axis. This will be equal to the
maximum value of the scaled array after accounting for the percentile
cuts.
lower_percent_cut : float
The percent of values that will be masked from the lower end. Must be
between 0-100.
upper_percent_cut : float
The percent of values that will be masked from the upper end. Must be
between 0-100.
Returns
-------
scaled_array : array-like
The array, after the scaling.
"""
# Ensure that the percentile values are within percentile ranges.
if not 0 <= lower_percent_cut <= 100:
raise error.InputError(
"The lower percentile cut should be between 0 <= x <= 100. You provided:"
" {value}".format(value=lower_percent_cut)
)
if not 0 <= upper_percent_cut <= 100:
raise error.InputError(
"The upper percentile cut should be between 0 <= x <= 100. You provided:"
" {value}".format(value=upper_percent_cut)
)
# Find where to mask out using the percentile cuts.
lower_cut = np.nanpercentile(array, lower_percent_cut)
upper_cut = np.nanpercentile(array, 100 - upper_percent_cut)
invalid_pixels = np.where(
np.logical_and(lower_cut <= array, array <= upper_cut), False, True
)
# Mask out those values which are invalid.
array = np.array(array, copy=True)
array[invalid_pixels] = np.nan
array[~np.isfinite(array)] = np.nan
# Scale the array via linear interpolation.
a_min = np.nanmin(array)
a_max = np.nanmax(array)
scaled_array = np.interp(array, (a_min, a_max), (minimum, maximum))
# Ensuring the invalid pixels are still invalid.
scaled_array[invalid_pixels] = np.nan
return scaled_array
[docs]
def translate_image_array(
array: hint.array, shift_x: float = 0, shift_y: float = 0, pad_value: float = np.nan
) -> hint.array:
"""This function translates an image or array in some direction. The image
is treated as value padded so pixels beyond the scope of the image after
translation are given by the value specified.
Parameters
----------
array : array
The image array which is going to be translated.
shift_x : float, default = 0
The number of pixels the image will be shifted in the x direction.
shift_y : float, default = 0
The number of pixels the image will be shifted in the y direction.
pad_value : float, default = np.nan
The value to pad around the image.
Returns
-------
shifted_image : array
The image array after shifting.
"""
# The padded values, assumed to be NaN.
# Using Scipy's built-in shifting function.
shifted_array = sp_ndimage.shift(
array,
(shift_y, shift_x),
order=2,
mode="constant",
cval=pad_value,
)
# All done
return shifted_array
[docs]
def determine_translation_image_array(
translate_array: hint.array, reference_array: hint.array
) -> tuple[float, float]:
"""This function determines the cross-correlated translation
required to determine the translation which occurred to the translated
array image from the reference array image.
This function deals with only translation, it does not handle scaling
or rotation. More sophisticated methods are needed for that. This
algorithm finds the mode of all translation vectors between star-points
in the images.
Parameters
----------
translate_array : array-like
The array which was translated from the reference array. We are
computing the translation of this array.
reference_array : array-like
The array before the translation. Inverting the translation on
the translate_array returns back to this array.
Returns
-------
delta_x : float
The x-axis length, in pixels, of the translation vector determined
which would translate the translation array back onto the reference
array.
delta_y : float
The y-axis length, in pixels, of the translation vector determined
which would translate the translation array back onto the reference
array.
"""
# Using masked phased cross correlation is too heavy and there are few
# masked pixels, defaulting to some standard random value for any
# non-finite numbers allows for faster stable computation with little
# impact on accuracy.
reference_array[~np.isfinite(reference_array)] = 0.0
translate_array[~np.isfinite(translate_array)] = 0.0
# Using scikit's implementation of FFT/DFT. Too high of an up-sample factor
# leads to slow computation time. 1/10 of a pixel is more than good enough
# here.
translation = ski_registration.phase_cross_correlation(
reference_array,
translate_array,
upsample_factor=50,
return_error=False,
)
# The function has the axis order in Numpy's convention, we break it up
# to match the return signature of this function.
delta_y, delta_x = translation
return delta_x, delta_y
[docs]
def create_circular_mask(
array: hint.array, center_x: int, center_y: int, radius: float
) -> hint.array:
"""Creates an array which is a circular mask of some radius centered at a
custom index value location. This process is a little intensive so using
smaller subsets of arrays are preferred.
Method inspired by https://stackoverflow.com/a/44874588.
Parameters
----------
array : array-like
The data array which the mask will base itself off of. The data in the
array is not actually modified but it is required for the shape
definition.
center_x : integer
The x-axis coordinate where the mask will be centered.
center_y : integer
The y-axis coordinate where the mask will be centered.
radius : float
The radius of the circle of the mask in pixels.
Returns
-------
circular_mask : array-like
The mask; it is the same dimensions of the input data array. If True,
the the mask should be applied.
"""
# Creating an array to make the circle, it should be just big enough to
# create the circle but not too big to have unneeded calculations. To
# ensure that it is centered, the array should have odd widths.
width = int(2 * radius) + 3
width += 1 - (width % 2)
working_array = np.full((width, width), False)
# Performing the circular mask calculation on this middle region.
near_n_rows, near_n_cols = working_array.shape
near_center_x = near_n_rows // 2
near_center_y = near_n_cols // 2
grid_y, grid_x = np.ogrid[:near_n_rows, :near_n_cols]
dist_sq = (grid_x - near_center_x) ** 2 + (grid_y - near_center_y) ** 2
near_mask = dist_sq <= radius**2
# The circular mask is local and should be expanded to the full array's
# size. Padding is needed in the event that the center pixel is on the
# edge as the arrays are co-aligned.
center_x = int(center_x)
center_y = int(center_y)
half_width = width // 2
base_mask = np.full_like(array, False, dtype=bool)
padded_mask = np.pad(base_mask, width, mode="constant", constant_values=False)
center_x = int(center_x) + width
center_y = int(center_y) + width
padded_mask[
center_y - half_width : center_y + half_width + 1,
center_x - half_width : center_x + half_width + 1,
] = near_mask
# Reshape the padded mask to the proper size, the same as the input array.
circular_mask = padded_mask[width:-width, width:-width]
# As per Numpy, masked values are considered True in the mask. The current
# method above creates a circle of True values, so the real mask is the
# inverse.
circular_mask = ~circular_mask
return circular_mask
[docs]
def save_array_as_png_grayscale(
array: hint.array, filename: str, overwrite: bool = False
) -> None:
"""This converts an array to a grayscale PNG file.
The PNG specification requires that the data values be integer. Note that
if you are saving an array as a PNG, then data may be lost during the
conversion between float to integer.
Parameters
----------
array : array-like
The array that will be saved as a png.
filename : string
The filename where the png will be saved. If the filename does not have
the appropriate filename extension, it will be appended.
overwrite : boolean
If the file already exists, should it be overwritten?
"""
# Check the extension.
user_ext = library.path.get_file_extension(pathname=filename)
valid_ext = ("png",)
if user_ext not in valid_ext:
# Adding the extension.
preferred_ext = valid_ext[-1]
filename_png = library.path.merge_pathname(
filename=filename, extension=preferred_ext
)
else:
filename_png = filename
# Check if the file already exists, if it does, check if overwriting was
# allowed.
if os.path.isfile(filename_png):
if overwrite:
# Overwrite the file by deleting it.
os.remove(filename_png)
else:
raise error.FileError(
"The png file already exists. Overwrite is False. The image cannot be"
" saved at the specified path: {path}".format(path=filename_png)
)
# Finally, scale the file.
image_object = PIL.Image.fromarray(array).convert("L")
image_object.save(filename_png)
return None