Source code for dmtools.transform

import numpy as np
from math import floor, ceil, sqrt
from functools import partial
from typing import List, Callable


def _box_weighting_function(x: float) -> float:
    """Box filter's weighting function.

    For more information about the Box filter, see
    `Box <https://legacy.imagemagick.org/Usage/filter/#box>`_.

    Args:
        x (float): distance to source pixel.

    Returns:
        float: weight on the source pixel.
    """
    return 1 if x <= 0.5 else 0


def _triangle_weighting_function(x: float) -> float:
    """Triangle filter's weighting function.

    For more information about the Triangle filter, see
    `Triangle <https://legacy.imagemagick.org/Usage/filter/#triangle>`_.

    Args:
        x (float): distance to source pixel.

    Returns:
        float: weight on the source pixel.
    """
    return max(1 - x, 0.0)


def _catmull_rom_weighting_function(x: float) -> float:
    """Catmull-Rom filter's weighting function.

    For more information about the Catmull-Rom filter, see
    `Cubic Filters <https://legacy.imagemagick.org/Usage/filter/#cubics>`_.

    Args:
        x (float): distance to source pixel.

    Returns:
        float: weight on the source pixel.
    """
    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:
    """Gaussian blur function.

    For information about Gaussian blur, see
    `Gaussian <https://legacy.imagemagick.org/Usage/filter/#gaussian>`_.

    Args:
        x (float): Distance to source pixel.
        sigma (float): Determines the "neighborhood" of blur. Defaults to 0.5.
        blur (float): Scale sigma by some multiplier. Defaults to 1.0.

    Returns:
        float: weight on the source pixel.
    """
    sigma = sigma * blur
    return (1 / sqrt(2*np.pi*sigma**2))*np.power(np.e, -x**2 / (2*sigma**2))


RESIZE_FILTERS = \
    {'point':    (_box_weighting_function,          0.0),
     'box':      (_box_weighting_function,          0.5),
     'triangle': (_triangle_weighting_function,     1.0),
     'catrom':   (_catmull_rom_weighting_function,  2.0),
     'gaussian': (_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)


COMPOSITE_OPERATORS = \
    {'over':      (_over_alpha_composite, _over_color_composite),
     'dest_over': (_dest_over_alpha_composite, _dest_over_color_composite),
     'add':       (_add_alpha_composite, _add_color_composite)}


EPSILON = 1.0e-6


def _rescale_axis(image: np.ndarray,
                  axis: int,
                  k: int,
                  filter: str,
                  weighting_function: Callable = None,
                  support: Callable = None,
                  **kwargs) -> np.ndarray:
    # set the weighting function and support
    if weighting_function is not None:
        if support is None:
            raise ValueError('Weighting function provided without support.')
        f = weighting_function
        support = support
    else:
        f, support = RESIZE_FILTERS[filter]

    # 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, filter: str = 'point', weighting_function: Callable = None, support: Callable = None, **kwargs) -> np.ndarray: """Rescale the image by the given scaling factor. This image rescale implentation is largley based off of the `ImageMagick`_ impmenetation. The following filters are built-in: - `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. Additionally, advanced users can specify a custom filter by providing a weighting function and a support. .. _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 Args: image (np.ndarray): Image to rescale. k (int): Scaling factor. filter (str): {point, box, triangle, catrom, gaussian}. weighting_function (Callable): Weighting function to use. support (float): Support of the provided weighting function. Returns: np.ndarray: Rescaled image. """ rescaled_image = _rescale_axis(image=image, axis=0, k=k, filter=filter, weighting_function=weighting_function, support=support, **kwargs) rescaled_image = _rescale_axis(image=rescaled_image, axis=1, k=k, filter=filter, weighting_function=weighting_function, support=support, **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) return rescale(image, k=1, weighting_function=f, support=radius)
[docs]def composite(source: np.ndarray, dest: np.ndarray, operator: str = 'over', alpha_composite_function: Callable = None, color_composite_function: Callable = None) -> np.ndarray: """Return the image formed by compositing one image with another. For more information about alpha compositing, see `Alpha Compositing`_. The following compositing operators are built-in: - ("over"): two semi-transparent slides; source over dest. - ("dest_over"): two semi-transparent slides; dest over source. - ("add"): Add source and dest. The built-in operators use the `Cairo Compositing Operators`_. .. _Alpha Compositing: https://en.wikipedia.org/wiki/Alpha_compositing .. _Cairo Compositing Operators: https://www.cairographics.org/operators Args: source (np.ndarray): Image on top. dest (np.ndarray): Image on bottom. operator (str): The compositing operator to use {over, dest_over, add} alpha_composite_function (Callable): Alpha composite function to use. color_composite_function (Callable): Color composite function 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 alpha_composite_function is not None: if color_composite_function is None: error_msg = "Alpha composite provided without color composite." raise ValueError(error_msg) alpha_composite = alpha_composite_function color_composite = color_composite_function else: alpha_composite, color_composite = COMPOSITE_OPERATORS[operator] 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: str) -> List[float]: if relative: n,m,*_ = image.shape x = m * x y = n * y w = m * w h = n * h if loc == "upper-left": y = image.shape[0] - y elif loc == "lower-left": pass elif loc == "center": x = x - (w / 2) y = y - (h / 2) else: raise ValueError(f"{loc} 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: str = '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 bottom of image). relative (bool): If True, x, y, w, and h are given relative to the \ dimensions of the image. Defaults to False. loc (str): Location of (x,y) relative to substituted portion: \ {upper-left, lower-left, center}. 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: str = '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 bottom 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 (str): Location of (x,y) relative to cropped portion: \ {upper-left, lower-left, center}. 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