import numpy as np
from math import floor, ceil, sqrt
from functools import partial
from enum import Enum
from collections import namedtuple
from typing import Union, List
[docs]class Loc(Enum):
UPPER_LEFT = "upper_left"
LOWER_LEFT = "lower_left"
CENTER = "center"
def _box_weighting_function(x: float) -> float:
return 1 if x <= 0.5 else 0
def _triangle_weighting_function(x: float) -> float:
return max(1 - x, 0.0)
def _catmull_rom_weighting_function(x: float) -> float:
if x <= 1:
return (3*x**3 - 5*x**2 + 2) / 2
elif x <= 2:
return (-x**3 + 5*x**2 - 8*x + 4) / 2
else:
return 0
def _gaussian_weighting_function(x: float, sigma: float = 0.5,
blur: float = 1.0) -> float:
sigma = sigma * blur
return (1 / sqrt(2*np.pi*sigma**2))*np.power(np.e, -x**2 / (2*sigma**2))
ResizeFilter = namedtuple('ResizeFilter', 'weighting_function support')
ResizeFilter.__doc__ = """\
Image resize filter.
To learn more about image resize filters, see the `ImageMagick`_ reference on
`Resampling Filters`_.
Parameters:
weighting_function (Callable): Weighting function defined on [0, support].
support (float): The ideal neighborhood size of the filter.
.. _ImageMagick: https://imagemagick.org/script/index.php
.. _Resampling Filters: https://legacy.imagemagick.org/Usage/filter/
"""
[docs]class ResizeFilterName(Enum):
"""An enumeration of supported resize filter names.
The supported filters are a subset of `ImageMagick`_ filters.
- `Point Filter`_ (POINT): Nearest-neighbor heuristic.
- `Box Filter`_ (BOX): Average of neighboring pixels.
- `Triangle Filter`_ (TRIANGLE): Linear decrease in pixel weight.
- `Catmull-Rom Filter`_ (CATROM): Produces a sharper edge.
- `Gaussian Filter`_ (GAUSSIAN): Blurs image. Useful as low pass filter.
.. _ImageMagick: https://imagemagick.org/script/index.php
.. _Point Filter: https://legacy.imagemagick.org/Usage/filter/#point
.. _Box Filter: https://legacy.imagemagick.org/Usage/filter/#box
.. _Triangle Filter: https://legacy.imagemagick.org/Usage/filter/#triangle
.. _Catmull-Rom Filter: https://legacy.imagemagick.org/Usage/filter/#cubics
.. _Gaussian Filter: https://legacy.imagemagick.org/Usage/filter/#gaussian
"""
POINT = ResizeFilter(_box_weighting_function, 0.0)
BOX = ResizeFilter(_box_weighting_function, 0.5)
TRIANGLE = ResizeFilter(_triangle_weighting_function, 1.0)
CATROM = ResizeFilter(_catmull_rom_weighting_function, 2.0)
GAUSSIAN = ResizeFilter(_gaussian_weighting_function, 2.0)
def _safe_divide(n: np.ndarray, d: np.ndarray) -> np.ndarray:
"""Divide NumPy arrays where divide by zero is zero.
Args:
n (np.ndarray): Numerator NumPy array.
d (np.ndarray): Denominator NumPy array.
Returns:
np.ndarray: Numerator divided by denominator.
"""
return np.divide(n, d, out=np.zeros_like(n), where=(d != 0))
def _over_alpha_composite(aA, aB) -> np.ndarray:
return aA + aB * (1 - aA)
def _over_color_composite(xA, aA, xB, aB, xaA, xaB, aR) -> np.ndarray:
return _safe_divide(xaA + xaB * (1 - aA), aR)
def _dest_over_alpha_composite(aA, aB) -> np.ndarray:
return aB + aA * (1 - aB)
def _dest_over_color_composite(xA, aA, xB, aB, xaA, xaB, aR) -> np.ndarray:
return _safe_divide(xaB + xaA * (1 - aB), aR)
def _add_alpha_composite(aA, aB) -> np.ndarray:
return np.clip(aA + aB, 0, 1)
def _add_color_composite(xA, aA, xB, aB, xaA, xaB, aR) -> np.ndarray:
return _safe_divide(xaA + xaB, aR)
CompositeOp = namedtuple('CompositeOp', 'alpha color')
CompositeOp.__doc__ = """\
Image alpha compositing operators.
To learn more about image alpha compositing, see `Alpha Compositing`_.
Parameters:
alpha (Callable): The function producing the alpha of the resulting image.
color (Callable): The function producing the color of the resulting image.
.. _Alpha Compositing: https://en.wikipedia.org/wiki/Alpha_compositing
"""
[docs]class CompositeOpName(Enum):
"""An enumeration of supported image alpha compositing operator names.
The supported operators are a subset of `Cairo`_ operators.
- (OVER): two semi-transparent slides; source over dest.
- (DEST_OVER): two semi-transparent slides; dest over source.
- (ADD): Add source and dest.
.. _Cairo: https://www.cairographics.org/operators
"""
OVER = CompositeOp(_over_alpha_composite, _over_color_composite)
DEST_OVER = CompositeOp(_dest_over_alpha_composite,
_dest_over_color_composite)
ADD = CompositeOp(_add_alpha_composite, _add_color_composite)
EPSILON = 1.0e-6
def _rescale_axis(image: np.ndarray, axis: int, k: int,
filter: Union[ResizeFilterName, ResizeFilter],
**kwargs) -> np.ndarray:
# set the weighting function and support
if not isinstance(filter, ResizeFilter):
filter = filter.value
f = filter.weighting_function
support = filter.support
# scale support if blur keyword argument is passed
if 'blur' in kwargs:
support = support * kwargs['blur']
if k > 1:
support = support * k
if axis == 1:
image = np.swapaxes(image,0,1)
n, *_ = image.shape
new_shape = list(image.shape)
new_shape[0] = int(new_shape[0] * k)
rescaled_image = np.zeros(new_shape)
for i in range(new_shape[0]):
# get range of rows in the support
bisect = i + 0.5
a = max((bisect - support) / k, 0.0)
b = min((bisect + support) / k, n)
if (b-a < 1):
# fall back to nearest neighbor heuristic
if ceil(a) - a > ((b - a) / 2.0):
a = floor(a)
else:
a = ceil(a)
b = a + 1
a = round(a)
b = round(b)
row = image[a:b,:]
def x(i):
"""Return distance to source pixel."""
return abs((i+0.5) - (bisect / k))
# use weighting function to weight rows
if k <= 1:
weights = np.array([f(x(i) * k, **kwargs) for i in range(a,b)])
else:
weights = np.array([f(x(i), **kwargs) for i in range(a,b)])
# TODO: This is the numerically stable way to implement this.
# Need to decide if this implementation should be used.
# weights = weights / max(np.sum(weights), EPSILON) # normalize weights
# row = np.dot(weights, np.swapaxes(row,0,1))
row = np.average(row, axis=0, weights=weights)
# set row of rescaled image
rescaled_image[i,:] = row
if axis == 1:
rescaled_image = np.swapaxes(rescaled_image,0,1)
return rescaled_image
[docs]def rescale(image: np.ndarray, k: int = -1, w: int = -1, h: int = -1,
filter: Union[ResizeFilterName, ResizeFilter] =
ResizeFilterName.POINT, **kwargs) -> np.ndarray:
"""Rescale the image.
Provide either a global scale factor k or the desired dimensions (w,h) of
the rescaled image. This image rescale implentation is largley based off of
the `ImageMagick`_ impmenetation.
.. _ImageMagick: https://imagemagick.org/script/index.php
Args:
image (np.ndarray): Image to rescale.
k (int): Scaling factor.
w (int): Desired width (in pixels).
h (int): Desired height (in pixels).
filter (Union[ResizeFilterName, ResizeFilter]): Resize filter to use.
Returns:
np.ndarray: Rescaled image.
"""
if k != -1:
w_scale = k
h_scale = k
elif w != -1 and h != -1:
n,m,*_ = image.shape
w_scale = w / m
h_scale = h / n
else:
raise ValueError("Provide scale factor k or desired dimensions (w,h).")
rescaled_image = _rescale_axis(image=image, axis=0, k=h_scale,
filter=filter, **kwargs)
rescaled_image = _rescale_axis(image=rescaled_image, axis=1, k=w_scale,
filter=filter, **kwargs)
return rescaled_image
[docs]def blur(image: np.ndarray, sigma: float, radius: float = 0) -> np.ndarray:
"""Blur the image.
This image blur implentation is largley based off of the `ImageMagick`_
impmenetation. It uses a `Gaussian Filter`_ with parameter ``sigma`` and
a support of ``radius`` to blur the image.
.. _ImageMagick: https://imagemagick.org/script/index.php
.. _Gaussian Filter: https://legacy.imagemagick.org/Usage/filter/#gaussian
Args:
image (np.ndarray): Image to be blurred.
sigma (float): "Neighborhood" of the blur. A larger value is blurrier.
radius (float): Limit of the blur. Defaults to 4 x sigma.
Returns:
np.ndarray: Blurred image.
"""
if radius == 0:
radius = 4 * sigma
f = partial(_gaussian_weighting_function, sigma=sigma)
filter = ResizeFilter(f, radius)
return rescale(image, k=1, filter=filter)
[docs]def composite(source: np.ndarray, dest: np.ndarray,
operator: Union[CompositeOpName, CompositeOp] =
CompositeOpName.OVER) -> np.ndarray:
"""Return the image formed by compositing one image with another.
For more information about alpha compositing, see `Alpha Compositing`_. The
implementation is largely based on the `Cairo`_ implementation.
.. _Alpha Compositing: https://en.wikipedia.org/wiki/Alpha_compositing
.. _Cairo: https://www.cairographics.org/operators
Args:
source (np.ndarray): Image on top.
dest (np.ndarray): Image on bottom.
operator (Union[CompositeOpName, CompositeOp]): \
The compositing operator to use.
Returns:
np.ndarray: The two images overlaid.
"""
xA, aA = np.split(source, [3], axis=2)
xB, aB = np.split(dest, [3], axis=2)
xaA = xA * aA
xaB = xB * aB
if not isinstance(operator, CompositeOp):
operator = operator.value
alpha_composite = operator.alpha
color_composite = operator.color
aR = alpha_composite(aA, aB)
xR = color_composite(xA, aA, xB, aB, xaA, xaB, aR)
return np.append(xR, aR, axis=2)
def _standardize_selection(image: np.ndarray, x: float, y: float, w: float,
h: float, relative: bool, loc: Loc) -> List[float]:
if relative:
n,m,*_ = image.shape
x = m * x
y = n * y
w = m * w
h = n * h
if loc == Loc.UPPER_LEFT:
pass
elif loc == Loc.LOWER_LEFT:
y = image.shape[0] - y
elif loc == Loc.CENTER:
x = x - (w / 2)
y = y - (h / 2)
else:
raise ValueError(f"{loc.value} is not a supported loc.")
return int(x), int(y), int(w), int(h)
[docs]def substitute(image: np.ndarray, substitution: np.ndarray, x: float, y: float,
relative: bool = False,
loc: Loc = Loc.UPPER_LEFT) -> np.ndarray:
"""Substitute a portion of image with substitution.
Args:
image (np.ndarray): Base image.
substitution (np.ndarray): Image to substitute into the base image.
x (float): x coordinate of the point (relative to left of image).
y (float): y coordinate of the point (relative to top of image).
relative (bool): If True, x, y, w, and h are given relative to the \
dimensions of the image. Defaults to False.
loc (Loc): Location of (x,y) relative to substituted portion.
Returns:
np.ndarray: The image with substitution substituted in.
"""
if relative:
n,m,*_ = image.shape
h,w,*_ = substitution.shape
w = w / m
h = h / n
else:
h,w,*_ = substitution.shape
x, y, w, h = _standardize_selection(image, x, y, w, h, relative, loc)
if len(image.shape) == 3:
image[y:y+h, x:x+w, :] = substitution
else:
image[y:y+h, x:x+w] = substitution
return image
[docs]def crop(image: np.ndarray, x: float, y: float, w: float, h: float,
relative: bool = False, loc: Loc = Loc.UPPER_LEFT) -> np.ndarray:
"""Crop an image using an (x,y) point, width, and height.
Args:
image (np.ndarray): Image to be cropped.
x (float): x coordinate of the point (relative to left of image).
y (float): y coordinate of the point (relative to top of image).
w (float): Width of the cropped portion.
h (float): Height of the cropped portion.
relative (bool): If True, x, y, w, and h are given relative to the \
dimensions of the image. Defaults to False.
loc (Loc): Location of (x,y) relative to substituted portion.
Returns:
np.ndarray: The cropped portion of the image.
"""
x, y, w, h = _standardize_selection(image, x, y, w, h, relative, loc)
if len(image.shape) == 3:
return image[y:y+h, x:x+w, :]
else:
return image[y:y+h, x:x+w]
[docs]def clip(image: np.ndarray) -> np.ndarray:
"""Clip gray/color values that are out of bounds.
Every value less than 0 is mapped to 0 and every value more than 1 is
mapped to 1. Values in [0,1] are untouched.
Args:
image (np.ndarray): Image to clip.
Returns:
np.ndarray: Clipped image.
"""
return np.clip(image, 0, 1)
[docs]def normalize(image: np.ndarray) -> np.ndarray:
"""Normalize the image to bring all gray/color values into bounds.
Normalize the range of values in the image to [0,1]. If applied to a
three channel image, normalizes each channel by the same amount.
Args:
image (np.ndarray): Image to normalize.
Returns:
np.ndarray: Normalized image.
"""
if np.max(image) == np.min(image):
# every value in the image is the same--fall back to clip
return clip(image)
image = image - np.min(image)
return image * (1 / (np.max(image)))
[docs]def wraparound(image: np.ndarray) -> np.ndarray:
"""Wraparound gray/color values that are out of bounds.
Each value x is mapped to x mod 1 such that values outside of [0,1]
wraparound until they fall in the desired range.
Args:
image (np.ndarray): Image to wraparound
Returns:
np.ndarray: Wraparound image.
"""
# TODO: Is there a quicker way to implement this?
# TODO: Is this the right implementation?
image = np.where(image > 1, np.modf(image)[0], image)
image = np.where(image < 0, np.modf(image)[0] + 1, image)
return image