import logging
from enum import Enum
from typing import Callable, Optional
import cv2
import numpy as np
import numpy.ma as ma
from mapchete import Timer
from rasterio.plot import reshape_as_image, reshape_as_raster
from mapchete_eo.image_operations import blend_functions
logger = logging.getLogger(__name__)
[docs]
def to_rgba(arr: np.ndarray) -> np.ndarray:
def _expanded_mask(arr: ma.MaskedArray) -> np.ndarray:
if isinstance(arr.mask, np.bool_):
return np.full(arr.shape, fill_value=arr.mask, dtype=bool)
else:
return arr.mask
# make sure array is a proper MaskedArray with expanded mask
if not isinstance(arr, ma.MaskedArray):
arr = ma.masked_array(arr, mask=np.zeros(arr.shape, dtype=bool))
if arr.dtype != np.uint8:
raise TypeError(f"image array must be of type uint8, not {str(arr.dtype)}")
num_bands = arr.shape[0]
if num_bands == 1:
alpha = np.where(~_expanded_mask(arr[0]), 255, 0).astype(np.uint8, copy=False)
out = np.stack([arr[0], arr[0], arr[0], alpha]).data
elif num_bands == 2:
out = np.stack([arr[0], arr[0], arr[0], arr[1]]).data
elif num_bands == 3:
alpha = np.where(
(
~_expanded_mask(arr[0])
& ~_expanded_mask(arr[1])
& ~_expanded_mask(arr[2])
),
255,
0,
).astype(np.uint8, copy=False)
out = np.stack([arr[0], arr[1], arr[2], alpha]).data
elif num_bands == 4:
out = arr.data
else: # pragma: no cover
raise TypeError(
f"array must have between one and four bands but has {num_bands}"
)
return np.array(out, dtype=np.float16)
def _blend_base(
bg: np.ndarray, fg: np.ndarray, opacity: float, operation: Callable
) -> ma.MaskedArray:
# generate RGBA output and run compositing and normalize by dividing by 255
out_arr = reshape_as_raster(
(
operation(
reshape_as_image(to_rgba(bg) / 255),
reshape_as_image(to_rgba(fg) / 255),
opacity,
)
* 255
).astype(np.uint8, copy=False)
)
# generate mask from alpha band
out_mask = np.where(out_arr[3] == 0, True, False)
return ma.masked_array(out_arr, mask=np.stack([out_mask for _ in range(4)]))
[docs]
def normal(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
return _blend_base(bg, fg, opacity, blend_functions.normal)
[docs]
def soft_light(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
return _blend_base(bg, fg, opacity, blend_functions.soft_light)
[docs]
def lighten_only(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
return _blend_base(bg, fg, opacity, blend_functions.lighten_only)
[docs]
def screen(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
return _blend_base(bg, fg, opacity, blend_functions.screen)
[docs]
def dodge(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
return _blend_base(bg, fg, opacity, blend_functions.dodge)
[docs]
def addition(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
return _blend_base(bg, fg, opacity, blend_functions.addition)
[docs]
def darken_only(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
return _blend_base(bg, fg, opacity, blend_functions.darken_only)
[docs]
def multiply(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
return _blend_base(bg, fg, opacity, blend_functions.multiply)
[docs]
def hard_light(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
return _blend_base(bg, fg, opacity, blend_functions.hard_light)
[docs]
def difference(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
return _blend_base(bg, fg, opacity, blend_functions.difference)
[docs]
def subtract(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
return _blend_base(bg, fg, opacity, blend_functions.subtract)
[docs]
def grain_merge(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
return _blend_base(bg, fg, opacity, blend_functions.grain_merge)
[docs]
def divide(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
return _blend_base(bg, fg, opacity, blend_functions.divide)
[docs]
def overlay(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
return _blend_base(bg, fg, opacity, blend_functions.overlay)
METHODS = {
"multiply": multiply,
"normal": normal,
"soft_light": soft_light,
"lighten_only": lighten_only,
"screen": screen,
"dodge": dodge,
"addition": addition,
"darken_only": darken_only,
"hard_light": hard_light,
"difference": difference,
"subtract": subtract,
"grain_extract": grain_extract,
"grain_merge": grain_merge,
"divide": divide,
"overlay": overlay,
}
[docs]
def composite(
method: str, bg: np.ndarray, fg: np.ndarray, opacity: float = 1
) -> ma.MaskedArray:
"""
Composite two image arrays using a named blending method.
Args:
method: Blending method name (e.g., 'multiply', 'screen').
bg: Background image array (channels-first).
fg: Foreground image array (channels-first).
opacity: Opacity of the foreground layer (0-1).
Returns:
ma.MaskedArray: Blended RGBA result.
"""
return METHODS[method](bg, fg, opacity)
[docs]
def fuzzy_mask(
arr: np.ndarray,
fill_value: float,
radius: int = 0,
invert: bool = True,
dilate: bool = True,
) -> np.ndarray:
"""Create fuzzy mask from binary mask."""
if arr.ndim == 2:
arr = np.expand_dims(arr, 0)
if arr.ndim != 3:
raise TypeError("array must have exactly three dimensions")
if arr.shape[0] == 1:
three_bands = np.stack([arr[0] for _ in range(3)])
elif arr.shape[0] == 3:
three_bands = arr
else:
raise TypeError(
f"array must have either one or three bands, not {arr.shape[0]}"
)
if invert:
three_bands = ~three_bands
# convert mask into an image and set true values to fill value
# dilate = buffer image using the blur radius
out = np.multiply(reshape_as_image(three_bands), fill_value, dtype=np.uint8)
if dilate and radius:
with Timer() as t:
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (radius, radius))
logger.debug("dilation kernel generated in %s", t)
with Timer() as t:
out = cv2.morphologyEx(out, cv2.MORPH_DILATE, kernel)
logger.debug("dilation took %s", t)
# blur and return
if radius:
with Timer() as t:
out = reshape_as_raster(cv2.blur(out, (radius, radius)))[0]
logger.debug("blur filter took %s", t)
else:
out = reshape_as_raster(out)[0]
if invert:
return -(out - fill_value).astype(np.uint8)
return out
[docs]
class GradientPosition(Enum):
inside = "inside"
outside = "outside"
edge = "edge"
[docs]
def fuzzy_alpha_mask(
arr: np.ndarray,
mask: Optional[np.ndarray] = None,
radius=0,
fill_value=255,
gradient_position=GradientPosition.outside,
) -> np.ndarray:
"""Return an RGBA array with a fuzzy alpha mask."""
try:
gradient_position = (
GradientPosition[gradient_position]
if isinstance(gradient_position, str)
else gradient_position
)
except KeyError:
raise ValueError(f"unknown gradient_position: {gradient_position}")
if arr.shape[0] != 3:
raise TypeError("input array must have exactly three bands")
if mask is None:
if not isinstance(arr, ma.MaskedArray):
raise TypeError(
"input array must be a numpy MaskedArray or mask must be provided"
)
mask = arr.mask
if gradient_position == GradientPosition.outside:
fuzzy = fuzzy_mask(
mask, fill_value=fill_value, radius=radius, invert=False, dilate=True
)
elif gradient_position == GradientPosition.inside:
fuzzy = fuzzy_mask(
mask, fill_value=fill_value, radius=radius, invert=True, dilate=True
)
elif gradient_position == GradientPosition.edge:
fuzzy = fuzzy_mask(mask, fill_value=fill_value, radius=radius, dilate=False)
else: # pragma: no cover
raise ValueError(f"unknown gradient_position: {gradient_position}")
# doing this makes sure that originally masked pixels are also fully masked
# fuzzy[mask[0]] = 255
return np.concatenate((arr, np.expand_dims(fuzzy, 0)), axis=0)