Files
Amorano-Jovimetrix/sup/image.py
T
Alexander G. Morano b1e32fa82a *new node: QUEUE TOO -- media specific Q
cozy_menu filter for specific -jov types
MATTE now default for Resample Workflow
2024-08-31 15:38:25 -07:00

2535 lines
92 KiB
Python

"""
Jovimetrix - http://www.github.com/amorano/jovimetrix
Image Support
"""
import io
import math
import base64
import sys
import urllib
import requests
from enum import Enum
from io import BytesIO
from typing import Any, List, Optional, Tuple, Union
import cv2
import torch
import cupy as cp
import numpy as np
from numba import jit, cuda
from daltonlens import simulate
from sklearn.cluster import MiniBatchKMeans
from scipy import ndimage
from skimage import exposure
from skimage.metrics import structural_similarity as ssim
from PIL import Image, ImageDraw, ImageOps, ImageChops
from blendmodes.blend import blendLayers, BlendType
from loguru import logger
from Jovimetrix.sup.util import grid_make
# =============================================================================
# === GLOBAL ===
# =============================================================================
MIN_IMAGE_SIZE = 32
HALFPI = math.pi / 2
TAU = math.pi * 2
# =============================================================================
# === TYPE SHORTCUTS ===
# =============================================================================
TYPE_COORD = Union[
Tuple[int, int],
Tuple[float, float]
]
TYPE_PIXEL = Union[
int,
float,
Tuple[float, float, float],
Tuple[float, float, float, Optional[float]],
Tuple[int, int, int],
Tuple[int, int, int, Optional[int]]
]
TYPE_IMAGE = Union[np.ndarray, torch.Tensor]
TYPE_VECTOR = Union[TYPE_IMAGE|TYPE_PIXEL]
# =============================================================================
# === ENUM GLOBALS ===
# =============================================================================
class EnumAdjustOP(Enum):
BLUR = 0
STACK_BLUR = 1
GAUSSIAN_BLUR = 2
MEDIAN_BLUR = 3
SHARPEN = 10
EMBOSS = 20
INVERT = 25
# MEAN = 30 -- in UNARY
# ADAPTIVE_HISTOGRAM = 35
HSV = 30
LEVELS = 35
EQUALIZE = 40
PIXELATE = 50
QUANTIZE = 55
POSTERIZE = 60
FIND_EDGES = 80
OUTLINE = 70
DILATE = 71
ERODE = 72
OPEN = 73
CLOSE = 74
class EnumBlendType(Enum):
"""Rename the blend type names."""
NORMAL = BlendType.NORMAL
ADDITIVE = BlendType.ADDITIVE
NEGATION = BlendType.NEGATION
DIFFERENCE = BlendType.DIFFERENCE
MULTIPLY = BlendType.MULTIPLY
DIVIDE = BlendType.DIVIDE
LIGHTEN = BlendType.LIGHTEN
DARKEN = BlendType.DARKEN
SCREEN = BlendType.SCREEN
BURN = BlendType.COLOURBURN
DODGE = BlendType.COLOURDODGE
OVERLAY = BlendType.OVERLAY
HUE = BlendType.HUE
SATURATION = BlendType.SATURATION
LUMINOSITY = BlendType.LUMINOSITY
COLOR = BlendType.COLOUR
SOFT = BlendType.SOFTLIGHT
HARD = BlendType.HARDLIGHT
PIN = BlendType.PINLIGHT
VIVID = BlendType.VIVIDLIGHT
EXCLUSION = BlendType.EXCLUSION
REFLECT = BlendType.REFLECT
GLOW = BlendType.GLOW
XOR = BlendType.XOR
EXTRACT = BlendType.GRAINEXTRACT
MERGE = BlendType.GRAINMERGE
DESTIN = BlendType.DESTIN
DESTOUT = BlendType.DESTOUT
SRCATOP = BlendType.SRCATOP
DESTATOP = BlendType.DESTATOP
class EnumImageBySize(Enum):
LARGEST = 10
SMALLEST = 20
WIDTH_MIN = 30
WIDTH_MAX = 40
HEIGHT_MIN = 50
HEIGHT_MAX = 60
class EnumColorMap(Enum):
AUTUMN = cv2.COLORMAP_AUTUMN
BONE = cv2.COLORMAP_BONE
JET = cv2.COLORMAP_JET
WINTER = cv2.COLORMAP_WINTER
RAINBOW = cv2.COLORMAP_RAINBOW
OCEAN = cv2.COLORMAP_OCEAN
SUMMER = cv2.COLORMAP_SUMMER
SPRING = cv2.COLORMAP_SPRING
COOL = cv2.COLORMAP_COOL
HSV = cv2.COLORMAP_HSV
PINK = cv2.COLORMAP_PINK
HOT = cv2.COLORMAP_HOT
PARULA = cv2.COLORMAP_PARULA
MAGMA = cv2.COLORMAP_MAGMA
INFERNO = cv2.COLORMAP_INFERNO
PLASMA = cv2.COLORMAP_PLASMA
VIRIDIS = cv2.COLORMAP_VIRIDIS
CIVIDIS = cv2.COLORMAP_CIVIDIS
TWILIGHT = cv2.COLORMAP_TWILIGHT
TWILIGHT_SHIFTED = cv2.COLORMAP_TWILIGHT_SHIFTED
TURBO = cv2.COLORMAP_TURBO
DEEPGREEN = cv2.COLORMAP_DEEPGREEN
class EnumColorTheory(Enum):
COMPLIMENTARY = 0
MONOCHROMATIC = 1
SPLIT_COMPLIMENTARY = 2
ANALOGOUS = 3
TRIADIC = 4
# TETRADIC = 5
SQUARE = 6
COMPOUND = 8
# DOUBLE_COMPLIMENTARY = 9
CUSTOM_TETRAD = 9
class EnumEdge(Enum):
CLIP = 1
WRAP = 2
WRAPX = 3
WRAPY = 4
class EnumGrayscaleCrunch(Enum):
LOW = 0
HIGH = 1
MEAN = 2
class EnumImageType(Enum):
GRAYSCALE = 0
RGB = 10
RGBA = 20
BGR = 30
BGRA = 40
class EnumInterpolation(Enum):
NEAREST = cv2.INTER_NEAREST
LINEAR = cv2.INTER_LINEAR
CUBIC = cv2.INTER_CUBIC
AREA = cv2.INTER_AREA
LANCZOS4 = cv2.INTER_LANCZOS4
LINEAR_EXACT = cv2.INTER_LINEAR_EXACT
NEAREST_EXACT = cv2.INTER_NEAREST_EXACT
# INTER_MAX = cv2.INTER_MAX
# WARP_FILL_OUTLIERS = cv2.WARP_FILL_OUTLIERS
# WARP_INVERSE_MAP = cv2.WARP_INVERSE_MAP
class EnumIntFloat(Enum):
FLOAT = 0
INT = 1
class EnumMirrorMode(Enum):
NONE = -1
X = 0
FLIP_X = 10
Y = 20
FLIP_Y = 30
XY = 40
X_FLIP_Y = 50
FLIP_XY = 60
FLIP_X_FLIP_Y = 70
class EnumOrientation(Enum):
HORIZONTAL = 0
VERTICAL = 1
GRID = 2
class EnumProjection(Enum):
NORMAL = 0
POLAR = 5
SPHERICAL = 10
FISHEYE = 15
PERSPECTIVE = 20
class EnumScaleMode(Enum):
# NONE = 0
MATTE = 25
CROP = 20
FIT = 10
ASPECT = 30
ASPECT_SHORT = 35
class EnumShapes(Enum):
CIRCLE = 0
SQUARE = 1
ELLIPSE = 2
RECTANGLE = 3
POLYGON = 4
class EnumThreshold(Enum):
BINARY = cv2.THRESH_BINARY
TRUNC = cv2.THRESH_TRUNC
TOZERO = cv2.THRESH_TOZERO
class EnumThresholdAdapt(Enum):
ADAPT_NONE = -1
ADAPT_MEAN = cv2.ADAPTIVE_THRESH_MEAN_C
ADAPT_GAUSS = cv2.ADAPTIVE_THRESH_GAUSSIAN_C
class EnumPixelSwizzle(Enum):
RED_A = 20
GREEN_A = 10
BLUE_A = 0
ALPHA_A = 30
RED_B = 21
GREEN_B = 11
BLUE_B = 1
ALPHA_B = 31
CONSTANT = 50
class EnumCBSimulator(Enum):
AUTOSELECT = 0
BRETTEL1997 = 1
COBLISV1 = 2
COBLISV2 = 3
MACHADO2009 = 4
VIENOT1999 = 5
VISCHECK = 6
class EnumCBDeficiency(Enum):
PROTAN = simulate.Deficiency.PROTAN
DEUTAN = simulate.Deficiency.DEUTAN
TRITAN = simulate.Deficiency.TRITAN
# =============================================================================
# === CONVERSION GLOBAL ===
# =============================================================================
MODE_CV2 = {
EnumImageType.BGRA: {
4: cv2.COLOR_RGBA2BGRA,
3: cv2.COLOR_RGB2BGRA,
1: cv2.COLOR_GRAY2BGRA,
},
EnumImageType.RGBA: {
4: lambda x: x,
3: cv2.COLOR_RGB2RGBA,
1: cv2.COLOR_GRAY2RGBA,
},
EnumImageType.BGR: {
4: cv2.COLOR_RGBA2BGR,
3: cv2.COLOR_RGB2BGR,
1: cv2.COLOR_GRAY2BGR,
},
EnumImageType.RGB: {
4: cv2.COLOR_RGBA2RGB,
3: lambda x: x,
1: cv2.COLOR_GRAY2RGB,
},
EnumImageType.GRAYSCALE: {
4: cv2.COLOR_RGBA2GRAY,
3: cv2.COLOR_RGB2GRAY,
1: lambda x: x,
}
}
# =============================================================================
# === COLOR SPACE CONVERSION ===
# =============================================================================
def gamma2linear(image: TYPE_IMAGE) -> TYPE_IMAGE:
"""Gamma correction for old PCs/CRT monitors"""
return np.power(image, 2.2)
def linear2gamma(image: TYPE_IMAGE) -> TYPE_IMAGE:
"""Inverse gamma correction for old PCs/CRT monitors"""
return np.power(np.clip(image, 0., 1.), 1.0 / 2.2)
def sRGB2Linear(image: TYPE_IMAGE) -> TYPE_IMAGE:
"""Convert sRGB to linearRGB, removing the gamma correction.
Works for grayscale, RGB, or RGBA images.
"""
image = image.astype(float) / 255.0
# If the image has an alpha channel, separate it
if image.shape[-1] == 4:
rgb = image[..., :3]
alpha = image[..., 3]
else:
rgb = image
alpha = None
gamma = ((rgb + 0.055) / 1.055) ** 2.4
scale = rgb / 12.92
rgb = np.where(rgb > 0.04045, gamma, scale)
# Recombine the alpha channel if it exists
if alpha is not None:
image = np.concatenate((rgb, alpha[..., np.newaxis]), axis=-1)
else:
image = rgb
return (image * 255).astype(np.uint8)
def linear2sRGB(image: TYPE_IMAGE) -> TYPE_IMAGE:
"""Convert linearRGB to sRGB, applying the gamma correction.
Works for grayscale, RGB, or RGBA images.
"""
image = image.astype(float) / 255.0
# If the image has an alpha channel, separate it
if image.shape[-1] == 4:
rgb = image[..., :3]
alpha = image[..., 3]
else:
rgb = image
alpha = None
higher = 1.055 * np.power(rgb, 1.0 / 2.4) - 0.055
lower = rgb * 12.92
rgb = np.where(rgb > 0.0031308, higher, lower)
# Recombine the alpha channel if it exists
if alpha is not None:
image = np.concatenate((rgb, alpha[..., np.newaxis]), axis=-1)
else:
image = rgb
return np.clip(image * 255.0, 0, 255).astype(np.uint8)
# =============================================================================
# === IMAGE CONVERSION ===
# =============================================================================
def bgr2hsv(bgr_color: TYPE_PIXEL) -> TYPE_PIXEL:
return cv2.cvtColor(np.uint8([[bgr_color]]), cv2.COLOR_BGR2HSV)[0, 0]
def bgr2image(image: TYPE_IMAGE, alpha: TYPE_IMAGE=None, gray: bool=False) -> TYPE_IMAGE:
"""Restore image with alpha, if any, and converting to grayscale (optional)."""
if gray:
return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
return image_mask_add(image, alpha)
def b64_2_tensor(base64str: str) -> torch.Tensor:
img = base64.b64decode(base64str)
img = Image.open(BytesIO(img))
img = ImageOps.exif_transpose(img)
return pil2tensor(img)
def b64_2_pil(base64_string):
prefix, base64_data = base64_string.split(",", 1)
image_data = base64.b64decode(base64_data)
image_stream = io.BytesIO(image_data)
return Image.open(image_stream)
def b64_2_cv(base64_string) -> TYPE_IMAGE:
_, data = base64_string.split(",", 1)
data = base64.b64decode(data)
data = io.BytesIO(data)
data = Image.open(data)
data = np.array(data)
return cv2.cvtColor(data, cv2.COLOR_RGB2BGR)
def cv2pil(image: TYPE_IMAGE) -> Image.Image:
"""Convert a CV2 image to a PIL Image."""
if (cc := image.shape[2] if len(image.shape) > 2 else 1) > 1:
if cc == 3:
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
else:
image = cv2.cvtColor(image, cv2.COLOR_BGRA2RGBA)
return Image.fromarray(image)
def cv2tensor(image: TYPE_IMAGE, mask:bool=False) -> torch.Tensor:
"""Convert a CV2 image to a torch tensor."""
if mask or image.ndim < 3 or (image.ndim == 3 and image.shape[2] == 1):
mask = True
image = image_grayscale(image)
# image = linear2sRGB(image)
image = image.astype(np.float32) / 255.0
ret = torch.from_numpy(image).unsqueeze(0)
if mask and ret.ndim == 4:
ret = ret.squeeze(-1)
return ret
def cv2tensor_full(image: TYPE_IMAGE, matte:TYPE_PIXEL=0) -> Tuple[torch.Tensor, ...]:
rgba = image_convert(image, 4)
rgb = image_matte(rgba, matte)[:,:,:3]
mask = image_mask(rgba)
rgba = torch.from_numpy(rgba.astype(np.float32) / 255.0).unsqueeze(0)
rgb = torch.from_numpy(rgb.astype(np.float32) / 255.0).unsqueeze(0)
mask = torch.from_numpy(mask.astype(np.float32) / 255.0)
return rgba, rgb, mask
def hsv2bgr(hsl_color: TYPE_PIXEL) -> TYPE_PIXEL:
return cv2.cvtColor(np.uint8([[hsl_color]]), cv2.COLOR_HSV2BGR)[0, 0]
def image2bgr(image: TYPE_IMAGE) -> Tuple[TYPE_IMAGE, TYPE_IMAGE, int]:
"""RGB Helper function.
Return channel count, BGR, and Alpha.
"""
alpha = image_mask(image)
cc = image.shape[2] if image.ndim == 3 else 1
if cc == 1:
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
elif cc == 4:
image = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
return image, alpha, cc
def pil2cv(image: Image.Image) -> TYPE_IMAGE:
"""Convert a PIL Image to a CV2 Matrix."""
new_image = np.array(image, dtype=np.uint8)
if new_image.ndim == 2:
pass
elif new_image.shape[2] == 3:
new_image = new_image[:, :, ::-1]
elif new_image.shape[2] == 4:
new_image = new_image[:, :, [2, 1, 0, 3]]
return new_image
def pil2tensor(image: Image.Image) -> torch.Tensor:
"""Convert a PIL Image to a Torch Tensor."""
image = np.array(image).astype(np.float32) / 255.0
return torch.from_numpy(image).unsqueeze(0)
def tensor2cv(tensor: torch.Tensor) -> TYPE_IMAGE:
"""Convert a torch Tensor to a numpy ndarray."""
tensor = tensor.cpu().squeeze().numpy()
if tensor.ndim == 1:
tensor = np.expand_dims(tensor, -1)
image = np.clip(255.0 * tensor, 0, 255).astype(np.uint8)
if image.shape[2] == 4:
image_flatten_mask
mask = image_mask(image)
image = image_blend(image, image, mask)
image = image_mask_add(image, mask)
return image
def tensor2pil(tensor: torch.Tensor) -> Image.Image:
"""Convert a torch Tensor to a PIL Image.
Tensor should be HxWxC [no batch].
"""
tensor = tensor.cpu().numpy().squeeze()
tensor = np.clip(255. * tensor, 0, 255).astype(np.uint8)
return Image.fromarray(tensor)
def mixlabLayer2cv(layer: dict) -> torch.Tensor:
image=layer['image']
mask=layer['mask']
if 'type' in layer and layer['type']=='base64' and type(image) == str:
image = b64_2_cv(image)
mask = b64_2_cv(mask)
else:
image = tensor2cv(image)
mask = tensor2cv(mask)
return image_mask_add(image, mask)
# =============================================================================
# === PIXEL ===
# =============================================================================
def pixel_eval(color: TYPE_PIXEL,
target: EnumImageType=EnumImageType.BGR,
precision:EnumIntFloat=EnumIntFloat.INT,
crunch:EnumGrayscaleCrunch=EnumGrayscaleCrunch.MEAN) -> Tuple[TYPE_PIXEL] | TYPE_PIXEL:
"""Evaluates R(GB)(A) pixels in range (0-255) into target target pixel type."""
def parse_single_color(c: TYPE_PIXEL) -> TYPE_PIXEL:
if not isinstance(c, int):
c = np.clip(c, 0, 1)
if precision == EnumIntFloat.INT:
c = int(c * 255)
else:
c = np.clip(c, 0, 255)
if precision == EnumIntFloat.FLOAT:
c /= 255
return c
# make sure we are an RGBA value already
if isinstance(color, (float, int)):
color = tuple([parse_single_color(color)])
elif isinstance(color, (set, tuple, list)):
color = tuple([parse_single_color(c) for c in color])
if target == EnumImageType.GRAYSCALE:
alpha = 1
if len(color) > 3:
alpha = color[3]
if precision == EnumIntFloat.INT:
alpha /= 255
color = color[:3]
match crunch:
case EnumGrayscaleCrunch.LOW:
val = min(color) * alpha
case EnumGrayscaleCrunch.HIGH:
val = max(color) * alpha
case EnumGrayscaleCrunch.MEAN:
val = np.mean(color) * alpha
if precision == EnumIntFloat.INT:
val = int(val)
return val
if len(color) < 3:
color += (0,) * (3 - len(color))
if target in [EnumImageType.RGB, EnumImageType.BGR]:
color = color[:3]
if target == EnumImageType.BGR:
color = color[::-1]
return color
if len(color) < 4:
color += (255,)
if target == EnumImageType.BGRA:
color = tuple(color[2::-1]) + tuple([color[-1]])
return color
def pixel_hsv_adjust(color:TYPE_PIXEL, hue:int=0, saturation:int=0, value:int=0, mod_color:bool=True, mod_sat:bool=False, mod_value:bool=False) -> TYPE_PIXEL:
"""Adjust an HSV type pixel.
OpenCV uses... H: 0-179, S: 0-255, V: 0-255"""
hsv = [0, 0, 0]
hsv[0] = (color[0] + hue) % 180 if mod_color else np.clip(color[0] + hue, 0, 180)
hsv[1] = (color[1] + saturation) % 255 if mod_sat else np.clip(color[1] + saturation, 0, 255)
hsv[2] = (color[2] + value) % 255 if mod_value else np.clip(color[2] + value, 0, 255)
return hsv
def pixel_convert(color:TYPE_PIXEL, size:int=4, alpha:int=255) -> TYPE_PIXEL:
"""
This function converts X channel pixel into Y channel pixel by adjusting the
size and alpha value if needed.
:param color: The `color` parameter in the `pixel_convert` function represents
the pixel value that you want to convert. It is expected to be a tuple
representing the color channels of the pixel. The number of elements in the
tuple should match the `size` parameter, which specifies the desired number of
color channels
:type color: TYPE_PIXEL
:param size: The `size` parameter in the `pixel_convert` function specifies the
number of channels in the pixel. It determines the expected size of the pixel
tuple that is passed as the `color` argument. The function will modify the
`color` tuple based on the `size` parameter to ensure it matches, defaults to 4
:type size: int (optional)
:param alpha: The `alpha` parameter in the `pixel_convert` function represents
the alpha channel value of the pixel. It is an integer value ranging from 0 to
255, where 0 indicates full transparency and 255 indicates full opacity. The
default value for `alpha` is set to 255 if, defaults to 255
:type alpha: int (optional)
:return: The function `pixel_convert` returns the input `color` if its length is
equal to the specified `size`. If the length of `color` is less than `size`, it
pads the color with zeros to make it of the required length. If `size` is
greater than 2, it adds alpha value to the color if `size` is 4. If `size` is
"""
"""Convert X channel pixel into Y channel pixel."""
if (cc := len(color)) == size:
return color
if size > 2:
color += (0,) * (3 - cc)
if size == 4:
color += (alpha,)
return color
return color[0]
# =============================================================================
# === CHANNEL ===
# =============================================================================
def channel_add(image:TYPE_IMAGE, color:TYPE_PIXEL=255) -> TYPE_IMAGE:
"""
This function adds a new channel with a solid color to an image.
:param image: The `image` parameter is expected to be an image represented as a
NumPy array. The function assumes that the image has a shape attribute that
returns a tuple representing the dimensions of the image (height, width, and
channels if it's a color image)
:type image: TYPE_IMAGE
:param color: The `color` parameter in the `channel_add` function represents the
color value that will be added as a new channel to the input image. The default
value for `color` is 255, which is typically a white color in grayscale images,
defaults to 255
:type color: TYPE_PIXEL (optional)
:return: The function `channel_add` returns a new image with an additional
channel appended to the original image. The new channel has a solid color
specified by the `color` parameter.
"""
h, w = image.shape[:2]
color = pixel_eval(color, EnumImageType.GRAYSCALE)
new = channel_solid(w, h, color, EnumImageType.GRAYSCALE)
return np.concatenate([image, new], axis=-1)
def channel_solid(width:int=MIN_IMAGE_SIZE, height:int=MIN_IMAGE_SIZE, color:TYPE_PIXEL=(0, 0, 0, 255),
chan:EnumImageType=EnumImageType.BGR) -> TYPE_IMAGE:
if chan == EnumImageType.GRAYSCALE:
color = pixel_eval(color, EnumImageType.GRAYSCALE)
what = np.full((height, width, 1), color, dtype=np.uint8)
return what
if not type(color) in [list, set, tuple]:
color = [color]
color += (0,) * (3 - len(color))
if chan in [EnumImageType.BGR, EnumImageType.RGB]:
if chan == EnumImageType.RGB:
color = color[2::-1]
return np.full((height, width, 3), color[:3], dtype=np.uint8)
if len(color) < 4:
color += (255,)
if chan == EnumImageType.RGBA:
color = color[2::-1]
return np.full((height, width, 4), color, dtype=np.uint8)
def channel_merge(channels: List[TYPE_IMAGE]) -> TYPE_IMAGE:
max_height = max(ch.shape[0] for ch in channels if ch is not None)
max_width = max(ch.shape[1] for ch in channels if ch is not None)
num_channels = len(channels)
dtype = channels[0].dtype
output = np.zeros((max_height, max_width, num_channels), dtype=dtype)
for i, channel in enumerate(channels):
if channel is None:
continue
h, w = channel.shape[:2]
if channel.ndim > 2:
channel = channel[..., 0]
pad_top = (max_height - h) // 2
pad_bottom = max_height - h - pad_top
pad_left = (max_width - w) // 2
pad_right = max_width - w - pad_left
padded_channel = np.pad(channel, ((pad_top, pad_bottom), (pad_left, pad_right)),
mode='constant', constant_values=0)
output[..., i] = padded_channel
if num_channels == 1:
output = output[..., 0]
return output
def channel_swap(imageA:TYPE_IMAGE, swap_ot:EnumPixelSwizzle,
imageB:TYPE_IMAGE, swap_in:EnumPixelSwizzle) -> TYPE_IMAGE:
index_out = int(swap_ot.value / 10)
cc_out = imageA.shape[2] if imageA.ndim == 3 else 1
# swap channel is out of range of image size
if index_out > cc_out:
return imageA
index_in = int(swap_in.value / 10)
cc_in = imageB.shape[2] if imageB.ndim == 3 else 1
if index_in > cc_in:
return imageA
h, w = imageA.shape[:2]
imageB = image_scalefit(imageB, w, h, EnumScaleMode.FIT)
imageA[:,:,index_out] = imageB[:,:,index_in]
return imageA
# =============================================================================
# === EXPLICIT SHAPE FUNCTIONS ===
# =============================================================================
def shape_ellipse(width: int, height: int, sizeX:float=1., sizeY:float=1., fill:TYPE_PIXEL=255, back:TYPE_PIXEL=0) -> Image:
sizeX = max(0.5, sizeX / 2 + 0.5)
sizeY = max(0.5, sizeY / 2 + 0.5)
xy = [(width * (1. - sizeX), height * (1. - sizeY)),(width * sizeX, height * sizeY)]
image = Image.new("RGB", (width, height), back)
ImageDraw.Draw(image).ellipse(xy, fill=fill)
return image
def shape_quad(width: int, height: int, sizeX:float=1., sizeY:float=1., fill:TYPE_PIXEL=255, back:TYPE_PIXEL=0) -> Image:
sizeX = max(0.5, sizeX / 2 + 0.5)
sizeY = max(0.5, sizeY / 2 + 0.5)
xy = [(width * (1. - sizeX), height * (1. - sizeY)),(width * sizeX, height * sizeY)]
image = Image.new("RGB", (width, height), back)
ImageDraw.Draw(image).rectangle(xy, fill=fill)
return image
def shape_polygon(width: int, height: int, size: float=1., sides: int=3, fill:TYPE_PIXEL=255, back:TYPE_PIXEL=0) -> Image:
size = max(0.00001, size)
r = min(width, height) * size * 0.5
xy = (width * 0.5, height * 0.5, r)
image = Image.new("RGB", (width, height), back)
d = ImageDraw.Draw(image)
d.regular_polygon(xy, sides, fill=fill)
return image
# =============================================================================
# === IMAGE ===
# =============================================================================
def image_affine_edge(image: TYPE_IMAGE, callback: object, edge: EnumEdge=EnumEdge.WRAP) -> TYPE_IMAGE:
height, width = image.shape[:2]
if edge != EnumEdge.CLIP:
image = image_edge_wrap(image, edge=edge)
image = callback(image)
image = image_crop_center(image, width, height)
return image
def image_blend(imageA: TYPE_IMAGE, imageB: TYPE_IMAGE, mask:Optional[TYPE_IMAGE]=None,
blendOp:BlendType=BlendType.NORMAL, alpha:float=1) -> TYPE_IMAGE:
h, w = imageA.shape[:2]
imageA = image_convert(imageA, 4)
imageA = cv2pil(imageA)
imageB = image_convert(imageB, 4)
h2, w2 = imageB.shape[:2]
w2 = min(w, w2)
h2 = min(h, h2)
imageB = image_crop_center(imageB, w2, h2)
imageB = image_matte(imageB, (0,0,0,0), w, h)
imageB = image_convert(imageB, 4)
#
#old_mask = image_mask(imageB)
#if len(old_mask.shape) > 2:
# old_mask = old_mask[..., 0][:,:]
if mask is not None:
mask = image_crop_center(mask, w, h)
mask = image_matte(mask, (0,0,0,0), w, h)
if len(mask.shape) > 2:
mask = mask[..., 0][:,:]
#old_mask = cv2.bitwise_and(mask, old_mask)
imageB[..., 3] = mask #old_mask
imageB = cv2pil(imageB)
alpha = np.clip(alpha, 0, 1)
image = blendLayers(imageA, imageB, blendOp.value, alpha)
image = pil2cv(image)
#if mask is not None:
# image = image_mask_add(image, mask)
return image_crop_center(image, w, h)
def image_by_size(image_list: List[TYPE_IMAGE], enumSize: EnumImageBySize=EnumImageBySize.LARGEST) -> Tuple[TYPE_IMAGE, int, int]:
img = None
mega, width, height = 0, 0, 0
if enumSize in [EnumImageBySize.SMALLEST, EnumImageBySize.WIDTH_MIN, EnumImageBySize.HEIGHT_MIN]:
mega, width, height = sys.maxsize, sys.maxsize, sys.maxsize
for i in image_list:
h, w = i.shape[:2]
match enumSize:
case EnumImageBySize.LARGEST:
if (new_mega := w * h) > mega:
mega = new_mega
img = i
width = max(width, w)
height = max(height, h)
case EnumImageBySize.SMALLEST:
if (new_mega := w * h) < mega:
mega = new_mega
img = i
width = min(width, w)
height = min(height, h)
case EnumImageBySize.WIDTH_MIN:
if w < width:
width = w
img = i
case EnumImageBySize.WIDTH_MAX:
if w > width:
width = w
img = i
case EnumImageBySize.HEIGHT_MIN:
if h < height:
height = h
img = i
case EnumImageBySize.HEIGHT_MAX:
if h > height:
height = h
img = i
return img, width, height
def image_color_blind(image: TYPE_IMAGE, deficiency:EnumCBDeficiency,
simulator:EnumCBSimulator=EnumCBSimulator.AUTOSELECT,
severity:float=1.0) -> TYPE_IMAGE:
cc = image.shape[2] if image.ndim == 3 else 1
if cc == 4:
mask = image_mask(image)
image = image_convert(image, 3)
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
match simulator:
case EnumCBSimulator.AUTOSELECT:
simulator = simulate.Simulator_AutoSelect()
case EnumCBSimulator.BRETTEL1997:
simulator = simulate.Simulator_Brettel1997()
case EnumCBSimulator.COBLISV1:
simulator = simulate.Simulator_CoblisV1()
case EnumCBSimulator.COBLISV2:
simulator = simulate.Simulator_CoblisV2()
case EnumCBSimulator.MACHADO2009:
simulator = simulate.Simulator_Machado2009()
case EnumCBSimulator.VIENOT1999:
simulator = simulate.Simulator_Vienot1999()
case EnumCBSimulator.VISCHECK:
simulator = simulate.Simulator_Vischeck()
image = simulator.simulate_cvd(image, deficiency.value, severity=severity)
image = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
if cc == 4:
image = image_mask_add(image, mask)
return image
def image_contrast(image: TYPE_IMAGE, value: float) -> TYPE_IMAGE:
image, alpha, cc = image2bgr(image)
mean_value = np.mean(image)
image = (image - mean_value) * value + mean_value
image = np.clip(image, 0, 255).astype(np.uint8)
return bgr2image(image, alpha, cc == 1)
def image_convert(image: TYPE_IMAGE, channels: int) -> TYPE_IMAGE:
"""Force image format to a specific number of channels.
Args:
image (TYPE_IMAGE): Input image.
channels (int): Desired number of channels (1, 3, or 4).
Returns:
TYPE_IMAGE: Image with the specified number of channels.
"""
if image.ndim == 2:
# Expand grayscale image to have a channel dimension
image = np.expand_dims(image, -1)
# Ensure channels value is within the valid range
channels = max(1, min(4, channels))
cc = image.shape[2] if image.ndim == 3 else 1
if cc == channels:
return image
if channels == 1:
return image_grayscale(image)
if channels == 3:
if cc == 1:
return cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
elif cc == 4:
return cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
if cc == 1:
return cv2.cvtColor(image, cv2.COLOR_GRAY2BGRA)
return cv2.cvtColor(image, cv2.COLOR_BGR2BGRA)
def image_crop_polygonal(image: TYPE_IMAGE, points: List[TYPE_COORD]) -> TYPE_IMAGE:
cc = image.shape[2] if image.ndim == 3 else 1
height, width = image.shape[:2]
point_mask = np.zeros((height, width), dtype=np.uint8)
points = np.array(points, np.int32).reshape((-1, 1, 2))
point_mask = cv2.fillPoly(point_mask, [points], 255)
x, y, w, h = cv2.boundingRect(point_mask)
cropped_image = cv2.resize(image[y:y+h, x:x+w], (w, h)).astype(np.uint8)
# Apply the mask to the cropped image
point_mask_cropped = cv2.resize(point_mask[y:y+h, x:x+w], (w, h))
if cc == 4:
mask = image_mask(image, 0)
alpha_channel = cv2.resize(mask[y:y+h, x:x+w], (w, h))
cropped_image = cv2.cvtColor(cropped_image, cv2.COLOR_BGRA2BGR)
cropped_image = cv2.bitwise_and(cropped_image, cropped_image, mask=point_mask_cropped)
return image_mask_add(cropped_image, alpha_channel)
elif cc == 1:
cropped_image = cv2.cvtColor(cropped_image, cv2.COLOR_GRAY2BGR)
cropped_image = cv2.bitwise_and(cropped_image, cropped_image, mask=point_mask_cropped)
return image_convert(cropped_image, cc)
return cv2.bitwise_and(cropped_image, cropped_image, mask=point_mask_cropped)
def image_crop(image: TYPE_IMAGE, width:int=None, height:int=None, offset:Tuple[float, float]=(0, 0)) -> TYPE_IMAGE:
h, w = image.shape[:2]
width = width if width is not None else w
height = height if height is not None else h
x, y = offset
x = max(0, min(width, x))
y = max(0, min(width, y))
x2 = max(0, min(width, x + width))
y2 = max(0, min(height, y + height))
points = [(x, y), (x2, y), (x2, y2), (x, y2)]
return image_crop_polygonal(image, points)
def image_crop_center(image: TYPE_IMAGE, width:int=None, height:int=None) -> TYPE_IMAGE:
"""Helper crop function to find the "center" of the area of interest."""
h, w = image.shape[:2]
cx = w // 2
cy = h // 2
width = w if width is None else width
height = h if height is None else height
x1 = max(0, int(cx - width // 2))
y1 = max(0, int(cy - height // 2))
x2 = min(w, int(cx + width // 2)) - 1
y2 = min(h, int(cy + height // 2)) - 1
points = [(x1, y1), (x2, y1), (x2, y2), (x1, y2)]
return image_crop_polygonal(image, points)
def image_crop_head(image: TYPE_IMAGE) -> TYPE_IMAGE:
"""
Given a file path or np.ndarray image with a face,
returns cropped np.ndarray around the largest detected
face.
Parameters
----------
- `path_or_array` : {`str`, `np.ndarray`}
* The filepath or numpy array of the image.
Returns
-------
- `image` : {`np.ndarray`, `None`}
* A cropped numpy array if face detected, else None.
"""
MIN_FACE = 8
gray = image_grayscale(image)
h, w = image.shape[:2]
minface = int(np.sqrt(h**2 + w**2) / MIN_FACE)
'''
# Create the haar cascade
face_cascade = cv2.CascadeClassifier(self.casc_path)
# ====== Detect faces in the image ======
faces = face_cascade.detectMultiScale(
gray,
scaleFactor=1.1,
minNeighbors=5,
minSize=(minface, minface),
flags=cv2.CASCADE_FIND_BIGGEST_OBJECT | cv2.CASCADE_DO_ROUGH_SEARCH,
)
# Handle no faces
if len(faces) == 0:
return None
# Make padding from biggest face found
x, y, w, h = faces[-1]
pos = self._crop_positions(
img_height,
img_width,
x,
y,
w,
h,
)
# ====== Actual cropping ======
image = image[pos[0] : pos[1], pos[2] : pos[3]]
# Resize
if self.resize:
with Image.fromarray(image) as img:
image = np.array(img.resize((self.width, self.height)))
# Underexposition fix
if self.gamma:
image = check_underexposed(image, gray)
return bgr_to_rbg(image)
def _determine_safe_zoom(self, imgh, imgw, x, y, w, h):
"""
Determines the safest zoom level with which to add margins
around the detected face. Tries to honor `self.face_percent`
when possible.
Parameters:
-----------
imgh: int
Height (px) of the image to be cropped
imgw: int
Width (px) of the image to be cropped
x: int
Leftmost coordinates of the detected face
y: int
Bottom-most coordinates of the detected face
w: int
Width of the detected face
h: int
Height of the detected face
Diagram:
--------
i / j := zoom / 100
+
h1 | h2
+---------|---------+
| MAR|GIN |
| (x+w, y+h)|
| +-----|-----+ |
| | FA|CE | |
| | | | |
| ├──i──┤ | |
| | cen|ter | |
| | | | |
| +-----|-----+ |
| (x, y)| |
| | |
+---------|---------+
├────j────┤
+
"""
# Find out what zoom factor to use given self.aspect_ratio
corners = itertools.product((x, x + w), (y, y + h))
center = np.array([x + int(w / 2), y + int(h / 2)])
i = np.array(
[(0, 0), (0, imgh), (imgw, imgh), (imgw, 0), (0, 0)]
) # image_corners
image_sides = [(i[n], i[n + 1]) for n in range(4)]
corner_ratios = [self.face_percent] # Hopefully we use this one
for c in corners:
corner_vector = np.array([center, c])
a = distance(*corner_vector)
intersects = list(intersect(corner_vector, side) for side in image_sides)
for pt in intersects:
if (pt >= 0).all() and (pt <= i[2]).all(): # if intersect within image
dist_to_pt = distance(center, pt)
corner_ratios.append(100 * a / dist_to_pt)
return max(corner_ratios)
def _crop_positions(
self,
imgh,
imgw,
x,
y,
w,
h,
):
"""
Retuns the coordinates of the crop position centered
around the detected face with extra margins. Tries to
honor `self.face_percent` if possible, else uses the
largest margins that comply with required aspect ratio
given by `self.height` and `self.width`.
Parameters:
-----------
imgh: int
Height (px) of the image to be cropped
imgw: int
Width (px) of the image to be cropped
x: int
Leftmost coordinates of the detected face
y: int
Bottom-most coordinates of the detected face
w: int
Width of the detected face
h: int
Height of the detected face
"""
zoom = self._determine_safe_zoom(imgh, imgw, x, y, w, h)
# Adjust output height based on percent
if self.height >= self.width:
height_crop = h * 100.0 / zoom
width_crop = self.aspect_ratio * float(height_crop)
else:
width_crop = w * 100.0 / zoom
height_crop = float(width_crop) / self.aspect_ratio
# Calculate padding by centering face
xpad = (width_crop - w) / 2
ypad = (height_crop - h) / 2
# Calc. positions of crop
h1 = x - xpad
h2 = x + w + xpad
v1 = y - ypad
v2 = y + h + ypad
return [int(v1), int(v2), int(h1), int(h2)]
'''
def image_detect(image: TYPE_IMAGE) -> Tuple[TYPE_IMAGE, Tuple[int, ...]]:
gray = image_grayscale(image)
_, thresh = cv2.threshold(gray, 128, 255, cv2.THRESH_BINARY_INV)
# contours
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# Assume the largest contour is the item we want to recenter
largest_contour = max(contours, key=cv2.contourArea)
x, y, w, h = cv2.boundingRect(largest_contour)
cropped_image = image[y:y+h, x:x+w]
return cropped_image, (x, y, w, h)
def image_diff(imageA: TYPE_IMAGE, imageB: TYPE_IMAGE, threshold:int=0, color:TYPE_PIXEL=(255, 0, 0)) -> Tuple[TYPE_IMAGE, TYPE_IMAGE, TYPE_IMAGE, TYPE_IMAGE, float]:
"""imageA, imageB, diff, thresh, score
"""
h1, w1 = imageA.shape[:2]
h2, w2 = imageB.shape[:2]
w1 = max(w1, w2)
h1 = max(h1, h2)
imageA = image_matte(imageA, (0, 0, 0, 0), w1, h1)
imageA = image_convert(imageA, 3)
imageB = image_matte(imageB, (0, 0, 0, 0), w1, h1)
imageB = image_convert(imageB, 3)
grayA = image_grayscale(imageA)
grayB = image_grayscale(imageB)
(score, diff) = ssim(grayA, grayB, full=True, channel_axis=2)
diff = (diff * 255).astype("uint8")
diff_box = cv2.merge([diff, diff, diff])
_, thresh = cv2.threshold(diff, threshold, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)
contours = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
contours = contours[0] if len(contours) == 2 else contours[1]
high_a = imageA.copy()
high_a = image_convert(high_a, 3)
high_b = imageB.copy()
high_b = image_convert(high_b, 3)
for c in contours:
area = cv2.contourArea(c)
if area > 40:
x,y,w,h = cv2.boundingRect(c)
cv2.rectangle(imageA, (x, y), (x + w, y + h), (36,255,12), 2)
cv2.rectangle(imageB, (x, y), (x + w, y + h), (36,255,12), 2)
cv2.rectangle(diff_box, (x, y), (x + w, y + h), (36,255,12), 2)
cv2.drawContours(high_a, [c], 0, color[::-1], -1)
cv2.drawContours(high_b, [c], 0, color[::-1], -1)
cv2.drawContours(diff_box, [c], 0, color[::-1], -1)
imageA = cv2.addWeighted(imageA, 0.0, high_a, 1, 0)
imageB = cv2.addWeighted(imageB, 0.0, high_b, 1, 0)
return imageA, imageB, diff, thresh, score
def image_disparity(imageA: np.ndarray) -> np.ndarray:
imageA = imageA.astype(np.float32) / 255.
imageA = cv2.normalize(imageA, None, alpha=0, beta=1, norm_type=cv2.NORM_MINMAX)
disparity_map = np.divide(1.0, imageA, where=imageA != 0)
return np.where(imageA == 0, 1, disparity_map)
def image_edge_wrap(image: TYPE_IMAGE, tileX: float=1., tileY: float=1., edge:EnumEdge=EnumEdge.WRAP) -> TYPE_IMAGE:
"""TILING."""
height, width = image.shape[:2]
tileX = int(width * tileX) if edge in [EnumEdge.WRAP, EnumEdge.WRAPX] else 0
tileY = int(height * tileY) if edge in [EnumEdge.WRAP, EnumEdge.WRAPY] else 0
return cv2.copyMakeBorder(image, tileY, tileY, tileX, tileX, cv2.BORDER_WRAP)
def image_equalize(image:TYPE_IMAGE) -> TYPE_IMAGE:
image, alpha, cc = image2bgr(image)
image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
image = cv2.equalizeHist(image)
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
return bgr2image(image, alpha, cc == 1)
def image_exposure(image: TYPE_IMAGE, value: float) -> TYPE_IMAGE:
image, alpha, cc = image2bgr(image)
image = np.clip(image * value, 0, 255).astype(np.uint8)
return bgr2image(image, alpha, cc == 1)
def image_filter(image:TYPE_IMAGE, start:Tuple[int]=(128,128,128), end:Tuple[int]=(128,128,128), fuzz:Tuple[float]=(0.5,0.5,0.5), use_range:bool=False) -> Tuple[TYPE_IMAGE, TYPE_IMAGE]:
"""Filter an image based on a range threshold.
It can use a start point with fuzziness factor and/or a start and end point with fuzziness on both points.
Args:
image (np.ndarray): Input image in the form of a NumPy array.
start (tuple): The lower bound of the color range to be filtered.
end (tuple): The upper bound of the color range to be filtered.
fuzz (float): A factor for adding fuzziness (tolerance) to the color range.
use_range (bool): Boolean indicating whether to use a start and end range or just the start point with fuzziness.
Returns:
Tuple[np.ndarray, np.ndarray]: A tuple containing the filtered image and the mask.
"""
old_alpha = None
image: torch.tensor = cv2tensor(image)
cc = image.shape[2]
if cc == 4:
old_alpha = image[..., 3]
new_image = image[:, :, :3]
elif cc == 1:
new_image = np.repeat(image, 3, axis=2)
else:
new_image = image
fuzz = torch.tensor(fuzz, dtype=torch.float64, device="cpu")
start = torch.tensor(start, dtype=torch.float64, device="cpu") / 255.
end = torch.tensor(end, dtype=torch.float64, device="cpu") / 255.
if not use_range:
end = start
start -= fuzz
end += fuzz
start = torch.clamp(start, 0.0, 1.0)
end = torch.clamp(end, 0.0, 1.0)
mask = ((new_image[..., 0] > start[0]) & (new_image[..., 0] < end[0]))
#mask |= ((new_image[..., 1] > start[1]) & (new_image[..., 1] < end[1]))
#mask |= ((new_image[..., 2] > start[2]) & (new_image[..., 2] < end[2]))
mask = ((new_image[..., 0] >= start[0]) & (new_image[..., 0] <= end[0]) &
(new_image[..., 1] >= start[1]) & (new_image[..., 1] <= end[1]) &
(new_image[..., 2] >= start[2]) & (new_image[..., 2] <= end[2]))
output_image = torch.zeros_like(new_image)
output_image[mask] = new_image[mask]
if old_alpha is not None:
output_image = torch.cat([output_image, old_alpha.unsqueeze(2)], dim=2)
return tensor2cv(output_image), mask.cpu().numpy().astype(np.uint8) * 255
def image_flatten_mask(image: TYPE_IMAGE) -> Tuple[TYPE_IMAGE, TYPE_IMAGE|None]:
"""Flatten the image with its own alpha channel, if any."""
mask = image_mask(image)
return image_blend(image, image, mask), mask
def image_formats() -> List[str]:
exts = Image.registered_extensions()
return [ex for ex, f in exts.items() if f in Image.OPEN]
def image_gamma(image: TYPE_IMAGE, value: float) -> TYPE_IMAGE:
# preserve original format
image, alpha, cc = image2bgr(image)
if value <= 0:
image = (image * 0).astype(np.uint8)
else:
invGamma = 1.0 / max(0.000001, value)
table = cv2.pow(np.arange(256) / 255, invGamma) * 255
lookUpTable = np.clip(table, 0, 255).astype(np.uint8)
image = cv2.LUT(image, lookUpTable)
# now back to the original "format"
return bgr2image(image, alpha, cc == 1)
def image_gradient_map2(image, gradient_map):
na = np.array(image)
grey = np.mean(na, axis=2).astype(np.uint8)
cmap = np.array(gradient_map.convert('RGB'))
result = np.zeros((*grey.shape, 3), dtype=np.uint8)
grey_reshaped = grey.reshape(-1)
np.take(cmap.reshape(-1, 3), grey_reshaped, axis=0, out=result.reshape(-1, 3))
return result
def image_gradient(width:int, height:int, color_map:dict=None) -> TYPE_IMAGE:
if color_map is None:
color_map = {0: (0,0,0,255)}
else:
color_map = {np.clip(float(k), 0, 1): [np.clip(int(c), 0, 255) for c in v] for k, v in color_map.items()}
color_map = dict(sorted(color_map.items()))
image = Image.new('RGBA', (width, height))
draw = image.load()
widthf = float(width)
@jit
def gaussian(x, a, b, c, d=0) -> Any:
return a * math.exp(-(x - b)**2 / (2 * c**2)) + d
def pixel(x, spread:int=1) -> Tuple[int, int, int]:
ws = widthf / (spread * len(color_map))
r = sum([gaussian(x, p[0], k * widthf, ws) for k, p in color_map.items()])
g = sum([gaussian(x, p[1], k * widthf, ws) for k, p in color_map.items()])
b = sum([gaussian(x, p[2], k * widthf, ws) for k, p in color_map.items()])
return min(255, int(r)), min(255, int(g)), min(255, int(b))
for x in range(width):
r, g, b = pixel(x)
for y in range(height):
draw[x, y] = r, g, b
return pil2cv(image)
# Adapted from WAS Suite -- gradient_map
# https://github.com/WASasquatch/was-node-suite-comfyui
def image_gradient_map(image:TYPE_IMAGE, gradient_map:TYPE_IMAGE, reverse:bool=False) -> TYPE_IMAGE:
if reverse:
gradient_map = gradient_map[:,:,::-1]
grey = image_grayscale(image)
cmap = image_convert(gradient_map, 3)
cmap = cv2.resize(cmap, (256, 256))
cmap = cmap[0,:,:].reshape((256, 1, 3)).astype(np.uint8)
return cv2.applyColorMap(grey, cmap)
def image_grayscale(image: TYPE_IMAGE, use_alpha: bool = False) -> TYPE_IMAGE:
"""Convert image to grayscale, optionally using the alpha channel if present.
Args:
image (TYPE_IMAGE): Input image, potentially with multiple channels.
use_alpha (bool): If True and the image has 4 channels, multiply the grayscale
values by the alpha channel. Defaults to False.
Returns:
TYPE_IMAGE: Grayscale image, optionally alpha-multiplied.
"""
if image.ndim == 2 or image.shape[2] == 1:
return image
if image.shape[2] == 4:
# Convert RGBA to grayscale
grayscale = cv2.cvtColor(image, cv2.COLOR_BGRA2GRAY)
if use_alpha:
# Normalize alpha to [0, 1]
alpha_channel = image[:, :, 3] / 255.0
grayscale = (grayscale * alpha_channel).astype(np.uint8)
return grayscale
# Convert RGB to grayscale
return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
def image_grid(data: List[TYPE_IMAGE], width: int, height: int) -> TYPE_IMAGE:
#@TODO: makes poor assumption all images are the same dimensions.
chunks, col, row = grid_make(data)
frame = np.zeros((height * row, width * col, 4), dtype=np.uint8)
i = 0
for y, strip in enumerate(chunks):
for x, item in enumerate(strip):
cc = item.shape[2] if item.ndim == 3 else 1
if cc == 3:
item = channel_add(item)
y1, y2 = y * height, (y+1) * height
x1, x2 = x * width, (x+1) * width
frame[y1:y2, x1:x2, ] = item
i += 1
return frame
def image_histogram(image:TYPE_IMAGE, bins=256) -> TYPE_IMAGE:
bins = max(image.max(), bins) + 1
flatImage = image.flatten()
histogram = np.zeros(bins)
for pixel in flatImage:
histogram[pixel] += 1
return histogram
def image_histogram_normalize(image:TYPE_IMAGE)-> TYPE_IMAGE:
L = image.max()
nonEqualizedHistogram = image_histogram(image, bins=L)
sumPixels = np.sum(nonEqualizedHistogram)
nonEqualizedHistogram = nonEqualizedHistogram/sumPixels
cfdHistogram = np.cumsum(nonEqualizedHistogram)
transformMap = np.floor((L-1) * cfdHistogram)
flatNonEqualizedImage = list(image.flatten())
flatEqualizedImage = [transformMap[p] for p in flatNonEqualizedImage]
return np.reshape(flatEqualizedImage, image.shape)
def image_histogram_statistics(histogram:np.ndarray, L=256)-> TYPE_IMAGE:
sumPixels = np.sum(histogram)
normalizedHistogram = histogram/sumPixels
mean = 0
for i in range(L):
mean += i * normalizedHistogram[i]
variance = 0
for i in range(L):
variance += (i-mean)**2 * normalizedHistogram[i]
std = np.sqrt(variance)
return mean, variance, std
def image_hsv(image: TYPE_IMAGE, hue: float, saturation: float, value: float) -> TYPE_IMAGE:
image, alpha, cc = image2bgr(image)
image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
hue *= 255
image[:, :, 0] = (image[:, :, 0] + hue) % 180
image[:, :, 1] = np.clip(image[:, :, 1] * saturation, 0, 255)
image[:, :, 2] = np.clip(image[:, :, 2] * value, 0, 255)
image = cv2.cvtColor(image, cv2.COLOR_HSV2BGR)
return bgr2image(image, alpha, cc == 1)
def image_invert(image: TYPE_IMAGE, value: float) -> TYPE_IMAGE:
"""
Invert an Grayscale, RGB or RGBA image using a specified inversion intensity.
Parameters:
- image: Input image as a NumPy array (RGB or RGBA).
- value: Float between 0 and 1 representing the intensity of inversion (0: no inversion, 1: full inversion).
Returns:
- Inverted image.
"""
# Clip the value to be within [0, 1] and scale to [0, 255]
value = np.clip(value, 0, 1)
if image.ndim == 3 and image.shape[2] == 4:
rgb = image[:, :, :3]
alpha = image[:, :, 3]
mask = alpha > 0
inverted_rgb = 255 - rgb
image = np.where(mask[:, :, None], (1 - value) * rgb + value * inverted_rgb, rgb)
return np.dstack((image.astype(np.uint8), alpha))
inverted_image = 255 - image
return ((1 - value) * image + value * inverted_image).astype(np.uint8)
def image_lerp(imageA: TYPE_IMAGE, imageB:TYPE_IMAGE, mask:TYPE_IMAGE=None,
alpha:float=1.) -> TYPE_IMAGE:
imageA = imageA.astype(np.float32)
imageB = imageB.astype(np.float32)
# establish mask
alpha = np.clip(alpha, 0, 1)
if mask is None:
height, width = imageA.shape[:2]
mask = np.ones((height, width, 1), dtype=np.float32)
else:
# normalize the mask
mask = mask.astype(np.float32)
mask = (mask - mask.min()) / (mask.max() - mask.min()) * alpha
# LERP
imageA = cv2.multiply(1. - mask, imageA)
imageB = cv2.multiply(mask, imageB)
imageA = (cv2.add(imageA, imageB) / 255. - 0.5) * 2.0
imageA = (imageA * 255).astype(np.uint8)
return np.clip(imageA, 0, 255)
def image_levels(image: np.ndarray, black_point:int=0, white_point=255,
mid_point=128, gamma=1.0) -> np.ndarray:
"""
Adjusts the levels of an image including black, white, midpoints, and gamma correction.
Args:
image (numpy.ndarray): Input image tensor in RGB(A) format.
black_point (int): The black point to adjust shadows. Default is 0.
white_point (int): The white point to adjust highlights. Default is 255.
mid_point (int): The mid point for mid-tone adjustment. Default is 128.
gamma (float): Gamma correction value. Default is 1.0.
Returns:
numpy.ndarray: Adjusted image tensor.
"""
image, alpha, cc = image2bgr(image)
# Convert points and gamma to float32 for calculations
black = np.array([black_point] * 3, dtype=np.float32)
white = np.array([white_point] * 3, dtype=np.float32)
mid = np.array([mid_point] * 3, dtype=np.float32)
inGamma = np.array([gamma] * 3, dtype=np.float32)
outBlack = np.array([0, 0, 0], dtype=np.float32)
outWhite = np.array([255, 255, 255], dtype=np.float32)
# Apply levels adjustment
image = np.clip((image - black) / (white - black), 0, 1)
image = (image - mid) / (1.0 - mid)
image = (image ** (1 / inGamma)) * (outWhite - outBlack) + outBlack
image = np.clip(image, 0, 255).astype(np.uint8)
return bgr2image(image, alpha, cc == 1)
def image_load(url: str) -> Tuple[TYPE_IMAGE, ...]:
try:
img = cv2.imread(url, cv2.IMREAD_UNCHANGED)
if img is None:
raise ValueError(f"Image at {url} could not be loaded.")
img = image_normalize(img)
logger.debug(f"load image {url}: {img.ndim} {img.shape}")
if img.ndim == 3:
if img.shape[2] == 4:
img = cv2.cvtColor(img, cv2.COLOR_RGBA2BGRA)
else:
img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
elif img.ndim < 3:
img = np.expand_dims(img, axis=2)
except Exception:
logger.debug(f"load image fallback to PIL {url}")
try:
img = Image.open(url)
img = ImageOps.exif_transpose(img)
img = np.array(img)
if img.dtype != np.uint8:
img = np.clip(np.array(img * 255), 0, 255).astype(dtype=np.uint8)
except Exception as e:
logger.error(str(e))
raise Exception(f"Error loading image: {e}")
if img is None:
raise Exception(f"No file found at {url}")
mask = image_mask(img)
img = image_blend(img, img, mask)
return img, mask
def image_load_data(data: str) -> TYPE_IMAGE:
img = ImageOps.exif_transpose(data)
return pil2cv(img)
def image_load_exr(url: str) -> Tuple[TYPE_IMAGE, TYPE_IMAGE]:
"""
exr_file = OpenEXR.InputFile(url)
exr_header = exr_file.header()
r,g,b = exr_file.channels("RGB", pixel_type=Imath.PixelType(Imath.PixelType.FLOAT) )
dw = exr_header[ "dataWindow" ]
w = dw.max.x - dw.min.x + 1
h = dw.max.y - dw.min.y + 1
image = np.ones( (h, w, 4), dtype = np.float32 )
image[:, :, 0] = np.core.multiarray.frombuffer( r, dtype = np.float32 ).reshape(h, w)
image[:, :, 1] = np.core.multiarray.frombuffer( g, dtype = np.float32 ).reshape(h, w)
image[:, :, 2] = np.core.multiarray.frombuffer( b, dtype = np.float32 ).reshape(h, w)
return create_optix_image_2D( w, h, image.flatten() )
"""
pass
def image_load_from_url(url: str) -> TYPE_IMAGE:
"""Creates a CV2 BGR image from a url."""
try:
image = urllib.request.urlopen(url)
image = np.asarray(bytearray(image.read()), dtype=np.uint8)
return cv2.imdecode(image, cv2.IMREAD_COLOR)
except:
try:
image = Image.open(requests.get(url, stream=True).raw)
return pil2cv(image)
except Exception as e:
logger.error(str(e))
def image_mask(image:TYPE_IMAGE, color:TYPE_PIXEL=255) -> TYPE_IMAGE:
"""Create a mask from the image, preserving transparency."""
if image.ndim == 3 and image.shape[2] == 4:
return image[..., 3]
image = np.ones_like(image, dtype=np.uint8) * color
return image[:,:,0]
def image_mask_add(image:TYPE_IMAGE, mask:TYPE_IMAGE=None, alpha:float=255) -> TYPE_IMAGE:
"""Put custom mask into an image. If there is no mask, alpha is applied.
Images are expanded to 4 channels.
Existing 4 channel images with no mask input just return themselves.
"""
image = image_convert(image, 4)
mask = image_mask(image, alpha) if mask is None else image_convert(mask, 1)
image[..., 3] = mask if mask.ndim == 2 else mask[:, :, 0]
return image
def image_mask_binary(image: TYPE_IMAGE) -> TYPE_IMAGE:
"""
Convert an image to a binary mask where non-black pixels are 1 and black pixels are 0.
Supports BGR, single-channel grayscale, and RGBA images.
Args:
image (TYPE_IMAGE): Input image in BGR, grayscale, or RGBA format.
Returns:
TYPE_IMAGE: Binary mask with the same width and height as the input image, where
pixels are 1 for non-black and 0 for black.
"""
if image.ndim == 2:
# Grayscale image
gray = image
elif image.shape[2] == 3:
# BGR image
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
elif image.shape[2] == 4:
# RGBA image
alpha_channel = image[..., 3]
# Create a mask from the alpha channel where alpha > 0
alpha_mask = alpha_channel > 0
# Convert RGB to grayscale
gray = cv2.cvtColor(image[:, :, :3], cv2.COLOR_BGR2GRAY)
# Apply the alpha mask to the grayscale image
gray = cv2.bitwise_and(gray, gray, mask=alpha_mask.astype(np.uint8))
else:
raise ValueError("Unsupported image format")
# Create a binary mask where any non-black pixel is set to 1
_, mask = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY)
if mask.ndim == 2:
mask = np.expand_dims(mask, -1)
return mask.astype(np.uint8)
def image_matte(image: TYPE_IMAGE, color: tuple = (0, 0, 0, 255), width: int = None, height: int = None) -> TYPE_IMAGE:
"""
Puts an image atop a colored matte with the same dimensions as the image.
Args:
image (TYPE_IMAGE): The input image.
color (tuple): The color of the matte as a tuple (R, G, B, A).
width (int, optional): The width of the matte. Defaults to the image width.
height (int, optional): The height of the matte. Defaults to the image height.
Returns:
TYPE_IMAGE: The composited image on a matte. Output is RGBA with the original Alpha (if any) or solid white.
"""
# Determine the dimensions of the image and the matte
image_height, image_width = image.shape[:2]
width = width or image_width
height = height or image_height
# Create a solid matte with the specified color
matte = np.full((height, width, 4), color, dtype=np.uint8)
# Ensure the image has 4 channels (RGBA)
image = image_convert(image, 4)
# Extract the alpha channel from the image
alpha = image[:, :, 3] / 255.0
# Calculate the center position for the image on the matte
x_offset = (width - image_width) // 2
y_offset = (height - image_height) // 2
# Place the image onto the matte using the alpha channel for blending
for c in range(0, 3):
matte[y_offset:y_offset + image_height, x_offset:x_offset + image_width, c] = \
(1 - alpha) * matte[y_offset:y_offset + image_height, x_offset:x_offset + image_width, c] + \
alpha * image[:, :, c]
# Set the alpha channel of the matte to the maximum of the matte's and the image's alpha
matte[y_offset:y_offset + image_height, x_offset:x_offset + image_width, 3] = \
np.maximum(matte[y_offset:y_offset + image_height, x_offset:x_offset + image_width, 3], image[:, :, 3])
return matte
def image_merge(imageA: TYPE_IMAGE, imageB: TYPE_IMAGE, axis: int=0, flip: bool=False) -> TYPE_IMAGE:
if flip:
imageA, imageB = imageB, imageA
axis = 1 if axis == "HORIZONTAL" else 0
return np.concatenate((imageA, imageB), axis=axis)
def image_minmax(image:List[TYPE_IMAGE]) -> Tuple[int, int, int, int]:
h_min = w_min = 100000000000
h_max = w_max = MIN_IMAGE_SIZE
for img in image:
if img is None:
continue
h, w = img.shape[:2]
h_max = max(h, h_max)
w_max = max(w, w_max)
h_min = min(h, h_min)
w_min = min(w, w_min)
# x,y - x+width, y+height
return w_min, h_min, w_max, h_max
def image_mirror(image: TYPE_IMAGE, mode:EnumMirrorMode, x:float=0.5, y:float=0.5) -> TYPE_IMAGE:
cc = image.shape[2] if image.ndim == 3 else 1
height, width = image.shape[:2]
def mirror(img:TYPE_IMAGE, axis:int, reverse:bool=False) -> TYPE_IMAGE:
pivot = x if axis == 1 else y
flip = cv2.flip(img, axis)
pivot = np.clip(pivot, 0, 1)
if reverse:
pivot = 1. - pivot
flip, img = img, flip
scalar = height if axis == 0 else width
slice1 = int(pivot * scalar)
slice1w = scalar - slice1
slice2w = min(scalar - slice1w, slice1w)
if cc >= 3:
output = np.zeros((height, width, cc), dtype=np.uint8)
else:
output = np.zeros((height, width), dtype=np.uint8)
if axis == 0:
output[:slice1, :] = img[:slice1, :]
output[slice1:slice1 + slice2w, :] = flip[slice1w:slice1w + slice2w, :]
else:
output[:, :slice1] = img[:, :slice1]
output[:, slice1:slice1 + slice2w] = flip[:, slice1w:slice1w + slice2w]
return output
if mode in [EnumMirrorMode.X, EnumMirrorMode.FLIP_X, EnumMirrorMode.XY, EnumMirrorMode.FLIP_XY, EnumMirrorMode.X_FLIP_Y, EnumMirrorMode.FLIP_X_FLIP_Y]:
reverse = mode in [EnumMirrorMode.FLIP_X, EnumMirrorMode.FLIP_XY, EnumMirrorMode.FLIP_X_FLIP_Y]
image = mirror(image, 1, reverse)
if mode not in [EnumMirrorMode.NONE, EnumMirrorMode.X, EnumMirrorMode.FLIP_X]:
reverse = mode in [EnumMirrorMode.FLIP_Y, EnumMirrorMode.FLIP_X_FLIP_Y, EnumMirrorMode.X_FLIP_Y]
image = mirror(image, 0, reverse)
return image
def image_mirror_mandela(imageA: np.ndarray, imageB: np.ndarray) -> Tuple[np.ndarray, ...]:
"""Merge 4 flipped copies of input images to make them wrap.
Output is twice bigger in both dimensions."""
top = np.hstack([imageA, -np.flip(imageA, axis=1)])
bottom = np.hstack([np.flip(imageA, axis=0), -np.flip(imageA)])
imageA = np.vstack([top, bottom])
top = np.hstack([imageB, np.flip(imageB, axis=1)])
bottom = np.hstack([-np.flip(imageB, axis=0), -np.flip(imageB)])
imageB = np.vstack([top, bottom])
return imageA, imageB
def image_normalize(image: TYPE_IMAGE) -> TYPE_IMAGE:
image = image.astype(np.float32)
img_min = np.min(image)
img_max = np.max(image)
if img_min == img_max:
return np.zeros_like(image, dtype=np.float32)
image = (image - img_min) / (img_max - img_min)
return (image * 255).astype(np.uint8)
def image_pixelate(image: TYPE_IMAGE, amount:float=1.)-> TYPE_IMAGE:
h, w = image.shape[:2]
amount = max(0, min(1, amount))
block_size_h = max(1, (h * amount))
block_size_w = max(1, (w * amount))
num_blocks_h = int(np.ceil(h / block_size_h))
num_blocks_w = int(np.ceil(w / block_size_w))
block_size_h = h // num_blocks_h
block_size_w = w // num_blocks_w
pixelated_image = image.copy()
for i in range(num_blocks_h):
for j in range(num_blocks_w):
# Calculate block boundaries
y_start = i * block_size_h
y_end = min((i + 1) * block_size_h, h)
x_start = j * block_size_w
x_end = min((j + 1) * block_size_w, w)
# Average color values within the block
block_average = np.mean(image[y_start:y_end, x_start:x_end], axis=(0, 1))
# Fill the block with the average color
pixelated_image[y_start:y_end, x_start:x_end] = block_average
return pixelated_image.astype(np.uint8)
def image_posterize(image: TYPE_IMAGE, levels:int=256) -> TYPE_IMAGE:
divisor = 256 / max(2, min(256, levels))
return (np.floor(image / divisor) * int(divisor)).astype(np.uint8)
def image_quantize(image:TYPE_IMAGE, levels:int=256, iterations:int=10, epsilon:float=0.2) -> TYPE_IMAGE:
levels = int(max(2, min(256, levels)))
pixels = np.float32(image)
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, iterations, epsilon)
_, labels, centers = cv2.kmeans(pixels, levels, None, criteria, 5, cv2.KMEANS_RANDOM_CENTERS)
centers = np.uint8(centers)
return centers[labels.flatten()].reshape(image.shape)
def image_recenter(image: TYPE_IMAGE) -> TYPE_IMAGE:
cropped_image = image_detect(image)[0]
new_image = np.zeros_like(image)
paste_x = (new_image.shape[1] - cropped_image.shape[1]) // 2
paste_y = (new_image.shape[0] - cropped_image.shape[0]) // 2
new_image[paste_y:paste_y+cropped_image.shape[0], paste_x:paste_x+cropped_image.shape[1]] = cropped_image
return new_image
def image_rotate(image: TYPE_IMAGE, angle: float, center:TYPE_COORD=(0.5, 0.5), edge:EnumEdge=EnumEdge.CLIP) -> TYPE_IMAGE:
def func_rotate(img: TYPE_IMAGE) -> TYPE_IMAGE:
height, width = img.shape[:2]
c = (int(width * center[0]), int(height * center[1]))
M = cv2.getRotationMatrix2D(c, -angle, 1.0)
return cv2.warpAffine(img, M, (width, height), flags=cv2.INTER_LINEAR)
image = image_affine_edge(image, func_rotate, edge)
return image
def image_save_gif(fpath:str, images: List[Image.Image], fps: int=0,
loop:int=0, optimize:bool=False) -> None:
fps = min(50, max(1, fps))
images[0].save(
fpath,
append_images=images[1:],
duration=3, # int(100.0 / fps),
loop=loop,
optimize=optimize,
save_all=True
)
def image_scale(image: TYPE_IMAGE, scale:TYPE_COORD=(1.0, 1.0), sample:EnumInterpolation=EnumInterpolation.LANCZOS4, edge:EnumEdge=EnumEdge.CLIP) -> TYPE_IMAGE:
def scale_func(img: TYPE_IMAGE) -> TYPE_IMAGE:
height, width = img.shape[:2]
width = int(width * scale[0])
height = int(height * scale[1])
return cv2.resize(img, (width, height), interpolation=sample.value)
if edge == EnumEdge.CLIP:
return scale_func(image)
return image_affine_edge(image, scale_func, edge)
def image_scalefit(image: TYPE_IMAGE, width: int, height:int,
mode:EnumScaleMode=EnumScaleMode.MATTE,
sample:EnumInterpolation=EnumInterpolation.LANCZOS4,
matte:TYPE_PIXEL=(0,0,0,0)) -> TYPE_IMAGE:
match mode:
case EnumScaleMode.MATTE:
image = image_matte(image, matte, width, height)
case EnumScaleMode.ASPECT:
h, w = image.shape[:2]
ratio = max(width, height) / max(w, h)
image = cv2.resize(image, None, fx=ratio, fy=ratio, interpolation=sample.value)
case EnumScaleMode.ASPECT_SHORT:
h, w = image.shape[:2]
ratio = min(width, height) / min(w, h)
image = cv2.resize(image, None, fx=ratio, fy=ratio, interpolation=sample.value)
case EnumScaleMode.CROP:
image = cv2.resize(image_crop_center(image, width, height), (width, height))
case EnumScaleMode.FIT:
image = cv2.resize(image, (width, height), interpolation=sample.value)
if image.ndim == 2:
image = np.expand_dims(image, -1)
return image
def image_sharpen(image:TYPE_IMAGE, kernel_size=None, sigma:float=1.0,
amount:float=1.0, threshold:float=0) -> TYPE_IMAGE:
"""Return a sharpened version of the image, using an unsharp mask."""
kernel_size = (kernel_size, kernel_size) if kernel_size else (5, 5)
blurred = cv2.GaussianBlur(image, kernel_size, sigma)
sharpened = float(amount + 1) * image - float(amount) * blurred
sharpened = np.maximum(sharpened, np.zeros(sharpened.shape))
sharpened = np.minimum(sharpened, 255 * np.ones(sharpened.shape))
sharpened = sharpened.round().astype(np.uint8)
if threshold > 0:
low_contrast_mask = np.absolute(image - blurred) < threshold
np.copyto(sharpened, image, where=low_contrast_mask)
return sharpened
def image_split(image: TYPE_IMAGE, convert:object=image_grayscale) -> Tuple[TYPE_IMAGE, ...]:
h, w = image.shape[:2]
dtype = image.dtype
# Grayscale image
if image.ndim == 2 or image.shape[2] == 1:
r = g = b = image.reshape(h, w)
a = np.full((h, w), 255, dtype=dtype)
# BGR image
elif image.shape[2] == 3:
r, g, b = cv2.split(image)
a = np.full((h, w), 255, dtype=dtype)
else:
r, g, b, a = cv2.split(image)
return r, g, b, a
def image_stack(image_list: List[TYPE_IMAGE], axis:EnumOrientation=EnumOrientation.HORIZONTAL,
stride:int=0, matte:TYPE_PIXEL=(0,0,0,255)) -> TYPE_IMAGE:
_, width, height = image_by_size(image_list)
images = [image_matte(image_convert(i, 4), matte, width, height) for i in image_list]
count = len(images)
matte = pixel_convert(matte, 4)
match axis:
case EnumOrientation.GRID:
if stride < 1:
stride = np.ceil(np.sqrt(count))
stride = int(stride)
stride = min(stride, count)
stride = max(stride, 1)
rows = []
for i in range(0, count, stride):
row = images[i:i + stride]
row_stacked = np.hstack(row)
rows.append(row_stacked)
height, width = images[0].shape[:2]
overhang = count % stride
if overhang != 0:
overhang = stride - overhang
size = (height, overhang * width, 4)
filler = np.full(size, matte, dtype=np.uint8)
rows[-1] = np.hstack([rows[-1], filler])
image = np.vstack(rows)
case EnumOrientation.HORIZONTAL:
image = np.hstack(images)
case EnumOrientation.VERTICAL:
image = np.vstack(images)
return image
def image_stereogram(image: TYPE_IMAGE, depth: TYPE_IMAGE, divisions:int=8, mix:float=0.33, gamma:float=0.33, shift:float=1.) -> TYPE_IMAGE:
height, width = depth.shape[:2]
out = np.zeros((height, width, 3), dtype=np.uint8)
image = cv2.resize(image, (width, height))
image = image_convert(image, 3)
depth = image_convert(depth, 3)
noise = np.random.randint(0, max(1, int(gamma * 255)), (height, width, 3), dtype=np.uint8)
# noise = cv2.cvtColor(noise, cv2.COLOR_GRAY2BGR)
image = cv2.addWeighted(image, 1. - mix, noise, mix, 0)
pattern_width = width // divisions
# shift -= 1
for y in range(height):
for x in range(width):
if x < pattern_width:
out[y, x] = image[y, x]
else:
# out[y, x] = out[y, x - pattern_width + int(shift * invert)]
offset = depth[y, x][0] // divisions
pos = x - pattern_width + int(shift * offset)
# pos = max(-pattern_width, min(pattern_width, pos))
out[y, x] = out[y, pos]
return out
def image_stereo_shift(image: TYPE_IMAGE, depth: TYPE_IMAGE, shift:float=10) -> TYPE_IMAGE:
# Ensure base image has alpha
image = image_convert(image, 4)
depth = image_convert(depth, 1)
deltas = np.array((depth / 255.0) * float(shift), dtype=int)
shifted_data = np.zeros_like(image)
_, width = image.shape[:2]
for y, row in enumerate(deltas):
for x, dx in enumerate(row):
x2 = x + dx
if (x2 >= width) or (x2 < 0):
continue
shifted_data[y][x2] = image[y][x]
shifted_image = cv2pil(shifted_data)
alphas_image = Image.fromarray(
ndimage.binary_fill_holes(
ImageChops.invert(
shifted_image.getchannel("A")
)
)
).convert("1")
shifted_image.putalpha(ImageChops.invert(alphas_image))
return pil2cv(shifted_image)
def image_threshold(image:TYPE_IMAGE, threshold:float=0.5,
mode:EnumThreshold=EnumThreshold.BINARY,
adapt:EnumThresholdAdapt=EnumThresholdAdapt.ADAPT_NONE,
block:int=3, const:float=0.) -> TYPE_IMAGE:
const = max(-100, min(100, const))
block = max(3, block if block % 2 == 1 else block + 1)
image, alpha, cc = image2bgr(image)
if adapt != EnumThresholdAdapt.ADAPT_NONE:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
gray = cv2.adaptiveThreshold(gray, 255, adapt.value, cv2.THRESH_BINARY, block, const)
gray = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
# gray = np.stack([gray, gray, gray], axis=-1)
image = cv2.bitwise_and(image, gray)
else:
threshold = int(threshold * 255)
_, image = cv2.threshold(image, threshold, 255, mode.value)
return bgr2image(image, alpha, cc == 1)
def image_translate(image: TYPE_IMAGE, offset: TYPE_COORD = (0.0, 0.0), edge: EnumEdge = EnumEdge.CLIP, border_value:int=0) -> TYPE_IMAGE:
"""
Translates an image by a given offset. Supports various edge handling methods.
Args:
image (TYPE_IMAGE): Input image as a numpy array.
offset (TYPE_COORD): Tuple (offset_x, offset_y) representing the translation offset.
edge (EnumEdge): Enum representing edge handling method. Options are 'CLIP', 'WRAP', 'WRAPX', 'WRAPY'.
Returns:
TYPE_IMAGE: Translated image.
"""
def translate(img: TYPE_IMAGE) -> TYPE_IMAGE:
height, width = img.shape[:2]
scalarX = 0.333 if edge in [EnumEdge.WRAP, EnumEdge.WRAPX] else 1.0
scalarY = 0.333 if edge in [EnumEdge.WRAP, EnumEdge.WRAPY] else 1.0
M = np.float32([[1, 0, offset[0] * width * scalarX], [0, 1, offset[1] * height * scalarY]])
if edge == EnumEdge.CLIP:
border_mode = cv2.BORDER_CONSTANT
else:
border_mode = cv2.BORDER_WRAP
return cv2.warpAffine(img, M, (width, height), flags=cv2.INTER_LINEAR, borderMode=border_mode, borderValue=border_value)
return translate(image)
def image_transform(image: TYPE_IMAGE, offset:TYPE_COORD=(0.0, 0.0), angle:float=0, scale:TYPE_COORD=(1.0, 1.0), sample:EnumInterpolation=EnumInterpolation.LANCZOS4, edge:EnumEdge=EnumEdge.CLIP) -> TYPE_IMAGE:
sX, sY = scale
if sX < 0:
image = cv2.flip(image, 1)
sX = -sX
if sY < 0:
image = cv2.flip(image, 0)
sY = -sY
if sX != 1. or sY != 1.:
image = image_scale(image, (sX, sY), sample, edge)
if angle != 0:
image = image_rotate(image, angle, edge=edge)
if offset[0] != 0. or offset[1] != 0.:
image = image_translate(image, offset, edge)
return image
# MORPHOLOGY
def morph_edge_detect(image: TYPE_IMAGE,
ksize: int=3,
low: float=0.27,
high:float=0.6) -> TYPE_IMAGE:
image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
ksize = max(3, ksize)
image = cv2.GaussianBlur(src=image, ksize=(ksize, ksize+2), sigmaX=0.5)
# Perform Canny edge detection
return cv2.Canny(image, int(low * 255), int(high * 255))
def morph_emboss(image: TYPE_IMAGE, amount: float=1., kernel: int=2) -> TYPE_IMAGE:
kernel = max(2, kernel)
kernel = np.array([
[-kernel, -kernel+1, 0],
[-kernel+1, kernel-1, 1],
[kernel-2, kernel-1, 2]
]) * amount
return cv2.filter2D(src=image, ddepth=-1, kernel=kernel)
# KERNELS
def MEDIAN3x3(image: TYPE_IMAGE) -> TYPE_IMAGE:
height, width = image.shape[:2]
out = np.zeros([height, width])
for i in range(1, height-1):
for j in range(1, width-1):
temp = [
image[i-1, j-1],
image[i-1, j],
image[i-1, j + 1],
image[i, j-1],
image[i, j],
image[i, j + 1],
image[i + 1, j-1],
image[i + 1, j],
image[i + 1, j + 1]
]
temp = sorted(temp)
out[i, j]= temp[4]
return out
def kernel(stride: int) -> TYPE_IMAGE:
"""
Generate a kernel matrix with a specific stride.
The kernel matrix has a size of (stride, stride) and is filled with values
such that if i < j, the element is set to -1; if i > j, the element is set to 1.
Parameters:
- stride (int): The size of the square kernel matrix.
Returns:
- TYPE_IMAGE: The generated kernel matrix.
Example:
>>> KERNEL(3)
array([[ 0, 1, 1],
[-1, 0, 1],
[-1, -1, 0]], dtype=int8)
"""
# Create an initial matrix of zeros
kernel = np.zeros((stride, stride), dtype=np.int8)
# Create a mask for elements where i < j and set them to -1
mask_lower = np.tril(np.ones((stride, stride), dtype=bool), k=-1)
kernel[mask_lower] = -1
# Create a mask for elements where i > j and set them to 1
mask_upper = np.triu(np.ones((stride, stride), dtype=bool), k=1)
kernel[mask_upper] = 1
return kernel
# =============================================================================
# === COLOR FUNCTIONS ===
# =============================================================================
@cuda.jit
def kmeans_kernel(pixels, centroids, assignments) -> None:
idx = cuda.grid(1)
if idx < pixels.shape[0]:
min_dist = 1e10
min_centroid = 0
for i in range(centroids.shape[0]):
dist = 0
for j in range(3):
diff = pixels[idx, j] - centroids[i, j]
dist += diff * diff
if dist < min_dist:
min_dist = dist
min_centroid = i
assignments[idx] = min_centroid
def color_image2lut(image: np.ndarray, num_colors: int = 256) -> np.ndarray:
"""Create X sized LUT from an RGB image using GPU acceleration."""
# Ensure image is in RGB format
if image.shape[2] == 4: # If RGBA, convert to RGB
image = cv2.cvtColor(image, cv2.COLOR_RGBA2RGB)
elif image.shape[2] == 1: # If grayscale, convert to RGB
image = cv2.cvtColor(image, cv2.COLOR_GRAY2RGB)
# Reshape and transfer to GPU
pixels = cp.asarray(image.reshape(-1, 3)).astype(cp.float32)
# logger.debug("Pixel range:", cp.min(pixels), cp.max(pixels))
# Initialize centroids using random pixels
random_indices = cp.random.choice(pixels.shape[0], size=num_colors, replace=False)
centroids = pixels[random_indices]
# logger.debug("Initial centroids range:", cp.min(centroids), cp.max(centroids))
# Prepare for K-means
assignments = cp.zeros(pixels.shape[0], dtype=cp.int32)
threads_per_block = 256
blocks = (pixels.shape[0] + threads_per_block - 1) // threads_per_block
# K-means iterations
for iteration in range(20): # Adjust the number of iterations as needed
kmeans_kernel[blocks, threads_per_block](pixels, centroids, assignments)
new_centroids = cp.zeros((num_colors, 3), dtype=cp.float32)
for i in range(num_colors):
mask = (assignments == i)
if cp.any(mask):
new_centroids[i] = cp.mean(pixels[mask], axis=0)
centroids = new_centroids
if iteration % 5 == 0:
# logger.debug(f"Iteration {iteration}, Centroids range: {cp.min(centroids)} {cp.max(centroids)}")
pass
# Create LUT
lut = cp.zeros((256, 1, 3), dtype=cp.uint8)
lut[:num_colors] = cp.clip(centroids, 0, 255).reshape(-1, 1, 3).astype(cp.uint8)
# logger.debug(f"Final LUT range: { cp.min(lut)} {cp.max(lut)}")
return cp.asnumpy(lut)
def color_match_histogram(image: TYPE_IMAGE, usermap: TYPE_IMAGE) -> TYPE_IMAGE:
"""Colorize one input based on the histogram matches."""
cc = image.shape[2] if image.ndim == 3 else 1
if cc == 4:
alpha = image_mask(image)
image = image_convert(image, 3)
image = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)
beta = cv2.cvtColor(usermap, cv2.COLOR_BGR2LAB)
image = exposure.match_histograms(image, beta, channel_axis=2)
image = cv2.cvtColor(image, cv2.COLOR_LAB2BGR)
image = image_blend(usermap, image, blendOp=BlendType.LUMINOSITY)
image = image_convert(image, cc)
if cc == 4:
image[..., 3] = alpha[..., 0]
return image
def color_match_reinhard(image: TYPE_IMAGE, target: TYPE_IMAGE) -> TYPE_IMAGE:
"""
Apply Reinhard color matching to an image based on a target image.
Works only for BGR images and returns an BGR image.
based on https://www.cs.tau.ac.il/~turkel/imagepapers/ColorTransfer.
Args:
image (TYPE_IMAGE): The input image (BGR or BGRA or Grayscale).
target (TYPE_IMAGE): The target image (BGR or BGRA or Grayscale).
Returns:
TYPE_IMAGE: The color-matched image in BGR format.
"""
target = image_convert(target, 3)
lab_tar = cv2.cvtColor(target, cv2.COLOR_BGR2Lab)
image = image_convert(image, 3)
lab_ori = cv2.cvtColor(image, cv2.COLOR_BGR2Lab)
mean_tar, std_tar = cv2.meanStdDev(lab_tar)
mean_ori, std_ori = cv2.meanStdDev(lab_ori)
ratio = (std_tar / std_ori).reshape(-1)
offset = (mean_tar - mean_ori * std_tar / std_ori).reshape(-1)
lab_tar = cv2.convertScaleAbs(lab_ori * ratio + offset)
return cv2.cvtColor(lab_tar, cv2.COLOR_Lab2BGR)
def color_match_lut(image: TYPE_IMAGE, colormap:int=cv2.COLORMAP_JET,
usermap:TYPE_IMAGE=None, num_colors:int=255) -> TYPE_IMAGE:
"""Colorize one input based on built in cv2 color maps or a user defined image."""
cc = image.shape[2] if image.ndim == 3 else 1
if cc == 4:
alpha = image_mask(image)
image = image_convert(image, 3)
if usermap is not None:
usermap = image_convert(usermap, 3)
colormap = color_image2lut(usermap, num_colors)
image = cv2.applyColorMap(image, colormap)
image = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
image = cv2.addWeighted(image, 0.5, image, 0.5, 0)
image = image_convert(image, cc)
if cc == 4:
image[..., 3] = alpha[..., 0]
return image
def color_mean(image: TYPE_IMAGE) -> TYPE_IMAGE:
color = [0, 0, 0]
cc = image.shape[2] if image.ndim == 3 else 1
if cc == 1:
raw = int(np.mean(image))
color = [raw] * 3
else:
# each channel....
color = [
int(np.mean(image[..., 0])),
int(np.mean(image[:,:,1])),
int(np.mean(image[:,:,2])) ]
return color
def color_theory_complementary(color: TYPE_PIXEL) -> TYPE_PIXEL:
color = bgr2hsv(color)
color_a = pixel_hsv_adjust(color, 90, 0, 0)
return hsv2bgr(color_a)
def color_theory_monochromatic(color: TYPE_PIXEL) -> Tuple[TYPE_PIXEL, TYPE_PIXEL]:
color = bgr2hsv(color)
sat = 255 / 5
val = 255 / 5
color_a = pixel_hsv_adjust(color, 0, -1 * sat, -1 * val, mod_sat=True, mod_value=True)
color_b = pixel_hsv_adjust(color, 0, -2 * sat, -2 * val, mod_sat=True, mod_value=True)
color_c = pixel_hsv_adjust(color, 0, -3 * sat, -3 * val, mod_sat=True, mod_value=True)
color_d = pixel_hsv_adjust(color, 0, -4 * sat, -4 * val, mod_sat=True, mod_value=True)
return hsv2bgr(color_a), hsv2bgr(color_b), hsv2bgr(color_c), hsv2bgr(color_d)
def color_theory_split_complementary(color: TYPE_PIXEL) -> Tuple[TYPE_PIXEL, TYPE_PIXEL]:
color = bgr2hsv(color)
color_a = pixel_hsv_adjust(color, 75, 0, 0)
color_b = pixel_hsv_adjust(color, 105, 0, 0)
return hsv2bgr(color_a), hsv2bgr(color_b)
def color_theory_analogous(color: TYPE_PIXEL) -> Tuple[TYPE_PIXEL, TYPE_PIXEL, TYPE_PIXEL, TYPE_PIXEL]:
color = bgr2hsv(color)
color_a = pixel_hsv_adjust(color, 30, 0, 0)
color_b = pixel_hsv_adjust(color, 15, 0, 0)
color_c = pixel_hsv_adjust(color, 165, 0, 0)
color_d = pixel_hsv_adjust(color, 150, 0, 0)
return hsv2bgr(color_a), hsv2bgr(color_b), hsv2bgr(color_c), hsv2bgr(color_d)
def color_theory_triadic(color: TYPE_PIXEL) -> Tuple[TYPE_PIXEL, TYPE_PIXEL]:
color = bgr2hsv(color)
color_a = pixel_hsv_adjust(color, 60, 0, 0)
color_b = pixel_hsv_adjust(color, 120, 0, 0)
return hsv2bgr(color_a), hsv2bgr(color_b)
def color_theory_compound(color: TYPE_PIXEL) -> Tuple[TYPE_PIXEL, TYPE_PIXEL, TYPE_PIXEL]:
color = bgr2hsv(color)
color_a = pixel_hsv_adjust(color, 90, 0, 0)
color_b = pixel_hsv_adjust(color, 120, 0, 0)
color_c = pixel_hsv_adjust(color, 150, 0, 0)
return hsv2bgr(color_a), hsv2bgr(color_b), hsv2bgr(color_c)
def color_theory_square(color: TYPE_PIXEL) -> Tuple[TYPE_PIXEL, TYPE_PIXEL, TYPE_PIXEL]:
color = bgr2hsv(color)
color_a = pixel_hsv_adjust(color, 45, 0, 0)
color_b = pixel_hsv_adjust(color, 90, 0, 0)
color_c = pixel_hsv_adjust(color, 135, 0, 0)
return hsv2bgr(color_a), hsv2bgr(color_b), hsv2bgr(color_c)
def color_theory_tetrad_custom(color: TYPE_PIXEL, delta:int=0) -> Tuple[TYPE_PIXEL, TYPE_PIXEL, TYPE_PIXEL]:
color = bgr2hsv(color)
# modulus on neg and pos
while delta < 0:
delta += 90
if delta > 90:
delta = delta % 90
color_a = pixel_hsv_adjust(color, -delta, 0, 0)
color_b = pixel_hsv_adjust(color, delta, 0, 0)
# just gimme a compliment
color_c = pixel_hsv_adjust(color, 90 - delta, 0, 0)
color_d = pixel_hsv_adjust(color, 90 + delta, 0, 0)
return hsv2bgr(color_a), hsv2bgr(color_b), hsv2bgr(color_c), hsv2bgr(color_d)
def color_theory(image: TYPE_IMAGE, custom:int=0, scheme: EnumColorTheory=EnumColorTheory.COMPLIMENTARY) -> Tuple[TYPE_IMAGE, TYPE_IMAGE, TYPE_IMAGE, TYPE_IMAGE, TYPE_IMAGE]:
b = [0,0,0]
c = [0,0,0]
d = [0,0,0]
color = color_mean(image)
match scheme:
case EnumColorTheory.COMPLIMENTARY:
a = color_theory_complementary(color)
case EnumColorTheory.MONOCHROMATIC:
a, b, c, d = color_theory_monochromatic(color)
case EnumColorTheory.SPLIT_COMPLIMENTARY:
a, b = color_theory_split_complementary(color)
case EnumColorTheory.ANALOGOUS:
a, b, c, d = color_theory_analogous(color)
case EnumColorTheory.TRIADIC:
a, b = color_theory_triadic(color)
case EnumColorTheory.SQUARE:
a, b, c = color_theory_square(color)
case EnumColorTheory.COMPOUND:
a, b, c = color_theory_compound(color)
case EnumColorTheory.CUSTOM_TETRAD:
a, b, c, d = color_theory_tetrad_custom(color, custom)
h, w = image.shape[:2]
return (
np.full((h, w, 3), color, dtype=np.uint8),
np.full((h, w, 3), a, dtype=np.uint8),
np.full((h, w, 3), b, dtype=np.uint8),
np.full((h, w, 3), c, dtype=np.uint8),
np.full((h, w, 3), d, dtype=np.uint8),
)
# =============================================================================
def coord_cart2polar(x, y) -> Tuple[Any, Any]:
r = np.sqrt(x**2 + y**2)
theta = np.arctan2(y, x)
return r, theta
def coord_polar2cart(r, theta) -> tuple:
x = r * np.cos(theta)
y = r * np.sin(theta)
return x, y
def coord_default(width:int, height:int, origin:Tuple[float, float]=None) -> tuple:
"""Creates x & y coords for the indicies in a numpy array "data".
"origin" defaults to the center of the image. Specify origin=(0,0)
to set the origin to the lower left corner of the image."""
if origin is None:
origin_x, origin_y = width // 2, height // 2
else:
origin_x, origin_y = origin
x, y = np.meshgrid(np.arange(width), np.arange(height))
x -= origin_x
y -= origin_y
return x, y
def coord_fisheye(width: int, height: int, distortion: float) -> Tuple[TYPE_IMAGE, TYPE_IMAGE]:
map_x, map_y = np.meshgrid(np.linspace(0., 1., width), np.linspace(0., 1., height))
# normalized
xnd, ynd = (2 * map_x - 1), (2 * map_y - 1)
rd = np.sqrt(xnd**2 + ynd**2)
# fish-eye distortion
condition = (dist := 1 - distortion * (rd**2)) == 0
xdu, ydu = np.where(condition, xnd, xnd / dist), np.where(condition, ynd, ynd / dist)
xu, yu = ((xdu + 1) * width) / 2, ((ydu + 1) * height) / 2
return xu.astype(np.float32), yu.astype(np.float32)
def coord_perspective(width: int, height: int, pts: List[TYPE_COORD]) -> TYPE_IMAGE:
object_pts = np.float32([[0, 0], [width, 0], [width, height], [0, height]])
pts = np.float32(pts)
pts = np.column_stack([pts[:, 0], pts[:, 1]])
return cv2.getPerspectiveTransform(object_pts, pts)
def coord_sphere(width: int, height: int, radius: float) -> Tuple[TYPE_IMAGE, TYPE_IMAGE]:
theta, phi = np.meshgrid(np.linspace(0, TAU, width), np.linspace(0, np.pi, height))
x = radius * np.sin(phi) * np.cos(theta)
y = radius * np.sin(phi) * np.sin(theta)
# z = radius * np.cos(phi)
x_image = (x + 1) * (width - 1) / 2
y_image = (y + 1) * (height - 1) / 2
return x_image.astype(np.float32), y_image.astype(np.float32)
def remap_fisheye(image: TYPE_IMAGE, distort: float) -> TYPE_IMAGE:
cc = image.shape[2] if image.ndim == 3 else 1
height, width = image.shape[:2]
if cc == 1:
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
map_x, map_y = coord_fisheye(width, height, distort)
image = cv2.remap(image, map_x, map_y, interpolation=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT)
#if cc == 1:
# image = image[..., 0]
return image
def remap_perspective(image: TYPE_IMAGE, pts: list) -> TYPE_IMAGE:
cc = image.shape[2] if image.ndim == 3 else 1
height, width = image.shape[:2]
if cc == 1:
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
pts = coord_perspective(width, height, pts)
image = cv2.warpPerspective(image, pts, (width, height))
#if cc == 1:
# image = image[..., 0]
return image
def remap_polar(image: TYPE_IMAGE) -> TYPE_IMAGE:
"""Re-projects a 3D numpy array ("data") into a polar coordinate system.
"origin" is a tuple of (x0, y0) and defaults to the center of the image."""
h, w = image.shape[:2]
radius = max(w, h)
return cv2.linearPolar(image, (h // 2, w // 2), radius // 2, cv2.WARP_INVERSE_MAP)
def remap_sphere(image: TYPE_IMAGE, radius: float) -> TYPE_IMAGE:
height, width = image.shape[:2]
map_x, map_y = coord_sphere(width, height, radius)
return cv2.remap(image, map_x, map_y, interpolation=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT)
def depth_from_gradient(grad_x, grad_y):
"""Optimized Frankot-Chellappa depth-from-gradient algorithm."""
rows, cols = grad_x.shape
rows_scale = np.fft.fftfreq(rows)
cols_scale = np.fft.fftfreq(cols)
u_grid, v_grid = np.meshgrid(cols_scale, rows_scale)
grad_x_F = np.fft.fft2(grad_x)
grad_y_F = np.fft.fft2(grad_y)
denominator = u_grid**2 + v_grid**2
denominator[0, 0] = 1.0
Z_F = (-1j * u_grid * grad_x_F - 1j * v_grid * grad_y_F) / denominator
Z_F[0, 0] = 0.0
Z = np.fft.ifft2(Z_F).real
Z -= np.min(Z)
Z /= np.max(Z)
return Z
def height_from_normal(image: TYPE_IMAGE, tile:bool=True) -> TYPE_IMAGE:
"""Computes a height map from the given normal map."""
image = np.transpose(image, (2, 0, 1))
flip_img = np.flip(image, axis=1)
grad_x, grad_y = (flip_img[0] - 0.5) * 2, (flip_img[1] - 0.5) * 2
grad_x = np.flip(grad_x, axis=0)
grad_y = np.flip(grad_y, axis=0)
if not tile:
grad_x, grad_y = image_mirror_mandela(grad_x, grad_y)
pred_img = depth_from_gradient(-grad_x, grad_y)
# re-crop
if not tile:
height, width = image.shape[1], image.shape[2]
pred_img = pred_img[:height, :width]
image = np.stack([pred_img, pred_img, pred_img])
image = np.transpose(image, (1, 2, 0))
return image
def curvature_from_normal(image: TYPE_IMAGE, blur_radius:int=2)-> TYPE_IMAGE:
"""Computes a curvature map from the given normal map."""
image = np.transpose(image, (2, 0, 1))
blur_factor = 1 / 2 ** min(8, max(2, blur_radius))
diff_kernel = np.array([-1, 0, 1])
def conv_1d(array, kernel) -> np.ndarray[Any, np.dtype[Any]]:
"""Performs row-wise 1D convolutions with repeat padding."""
k_l = len(kernel)
extended = np.pad(array, k_l // 2, mode="wrap")
return np.array([np.convolve(row, kernel, mode="valid") for row in extended[k_l//2:-k_l//2+1]])
h_conv = conv_1d(image[0], diff_kernel)
v_conv = conv_1d(-image[1].T, diff_kernel).T
edges_conv = h_conv + v_conv
# Calculate blur radius in pixels
blur_radius_px = int(np.mean(image.shape[1:3]) * blur_factor)
if blur_radius_px < 2:
# If blur radius is too small, just normalize the edge convolution
image = (edges_conv - np.min(edges_conv)) / (np.ptp(edges_conv) + 1e-10)
else:
blur_radius_px += blur_radius_px % 2 == 0
# Compute Gaussian kernel
sigma = max(1, blur_radius_px // 8)
x = np.linspace(-(blur_radius_px - 1) / 2, (blur_radius_px - 1) / 2, blur_radius_px)
g_kernel = np.exp(-0.5 * np.square(x) / np.square(sigma))
g_kernel /= np.sum(g_kernel)
# Apply Gaussian blur
h_blur = conv_1d(edges_conv, g_kernel)
v_blur = conv_1d(h_blur.T, g_kernel).T
image = (v_blur - np.min(v_blur)) / (np.ptp(v_blur) + 1e-10)
image = (image - image.min()) / (image.max() - image.min()) * 255
return image.astype(np.uint8)
def roughness_from_normal(image: TYPE_IMAGE) -> TYPE_IMAGE:
"""Roughness from a normal map."""
up_vector = np.array([0, 0, 1])
image = 1 - np.dot(image, up_vector)
image = (image - image.min()) / (image.max() - image.min())
image = (255 * image).astype(np.uint8)
return image_grayscale(image)
def roughness_from_albedo(image: TYPE_IMAGE) -> TYPE_IMAGE:
"""Roughness from an albedo map."""
kernel_size = 3
image = cv2.Laplacian(image, cv2.CV_64F, ksize=kernel_size)
image = (image - image.min()) / (image.max() - image.min())
image = (255 * image).astype(np.uint8)
return image_grayscale(image)
def roughness_from_albedo_normal(albedo: TYPE_IMAGE, normal: TYPE_IMAGE, blur:int=2, blend:float=0.5, iterations:int=3) -> TYPE_IMAGE:
normal = roughness_from_normal(normal)
normal = image_normalize(normal)
albedo = roughness_from_albedo(albedo)
albedo = image_normalize(albedo)
rough = image_lerp(normal, albedo, alpha=blend)
rough = image_normalize(rough)
image = image_lerp(normal, rough, alpha=blend)
iterations = min(16, max(2, iterations))
blur += (blur % 2 == 0)
step = 1 / 2 ** iterations
for i in range(iterations):
image = cv2.add(normal * step, image * step)
image = cv2.GaussianBlur(image, (blur + i * 2, blur + i * 2), 3 * i)
inverted = 255 - image_normalize(image)
inverted = cv2.subtract(inverted, albedo) * 0.5
inverted = cv2.GaussianBlur(inverted, (blur, blur), blur)
inverted = image_normalize(inverted)
image = cv2.add(image * 0.5, inverted * 0.5)
for i in range(iterations):
image = cv2.GaussianBlur(image, (blur, blur), blur)
image = cv2.add(image * 0.5, inverted * 0.5)
for i in range(iterations):
image = cv2.GaussianBlur(image, (blur, blur), blur)
image = image_normalize(image)
return image