#!/usr/bin/env python
# -*- coding: utf8 -*-
"""
This module contains functions to create patches.
"""
import numpy as np
import cv2
import matplotlib.pyplot as plt
from PIL import Image
from matplotlib.figure import Figure
from matplotlib.colors import Colormap
from matplotlib.axes import Axes
from typing import List, Tuple, Union, Optional
from .gradient import fill_gradient
from .colortypes import to_rgb
from .imagetools import pixels_to_size_and_dpi, remove_margins, add_layer
from .imagetools import fig2img, img2arr, arr2img, mask2img
import warnings
warnings.filterwarnings("ignore")
[docs]
class Patch:
"""
A class representing a patch that can be drawn on images.
A patch is defined by either a set of coordinates or a mask image,
and can be filled with solid colors or gradients. It can also cast
shadows or glows on images.
Attributes
----------
bbox : List[Tuple[int, int]]
Bounding box of the patch [(x0, y0), (x1, y1)]
mask : numpy.ndarray
Binary mask where 1 indicates inside area and 0 outside
start : Tuple[int, int]
Starting position (x, y) of the patch
"""
[docs]
def __init__(
self,
coords: Optional[List[Tuple[int, int]]] = None,
img: Optional[Image.Image] = None,
start: Optional[Tuple[int, int]] = None
):
"""
Initializes a Patch from coordinates or mask.
Parameters
----------
coords : List[Tuple[int, int]], optional
List of (x, y) coordinates defining the patch boundary
img : PIL.Image.Image, optional
Image where black pixels define the patch area
start : Tuple[int, int], optional
Starting position (x, y) for the mask. Required if mask is provided.
Raises
------
ValueError
If neither coords nor mask is provided
If mask is provided without start_pos
"""
if coords is None and img is None:
raise ValueError("Either coords or mask must be provided")
if img is not None and start is None:
raise ValueError("start must be provided when using img")
if img is not None and coords is not None:
raise ValueError("coords must not be provided when using img")
if coords is not None:
# Calculate bounding box from coordinates
coords = np.array(coords)
if len(coords) == 0:
coords = np.array([[0, 0]])
x0, y0 = np.min(coords, axis=0)
x1, y1 = np.max(coords, axis=0)
size = (int(x1 - x0), int(y1 - y0))
self.inches, self.dpi = pixels_to_size_and_dpi(size, exact=True)
self.size = (int(self.inches[0] * self.dpi), int(self.inches[1] * self.dpi))
self.width, self.height = self.size
self.start = (int(x0), int(y0))
self.end = (int(x0 +self.width), int(y0 + self.height))
self.bbox = [self.start, self.end]
self.mask = None
if self.inches[0] > 0 and self.inches[1] > 0:
self._calculate_mask_from_coords(coords)
else:
# Use provided mask and position
self.size = img.size
self.width, self.height = self.size
self.start = start
self.end = (int(start[0] + self.width), int(start[1] + self.height))
self.bbox = [self.start, self.end]
self._calculate_mask_from_img(img)
# Calculate size of the patch
self.size = (self.bbox[1][0] - self.bbox[0][0], self.bbox[1][1] - self.bbox[0][1])
self.fill = None
self.shadow = None
return
def _calculate_mask_from_coords(self, coords: np.ndarray) -> None:
"""
Calculates binary mask from coordinates using matplotlib polygon filling.
Notes
-----
The mask is a 2D binary array (1 - inside, 0 - outside).
The mask is array (x, y), y axis goes from bottom to top.
Transpose it to convert to image dimensions (y, x),
Then reverse y axis to make it go from top to bottom.
Parameters
----------
coords : numpy.ndarray
Array of (x, y) coordinates
"""
# Create a figure with the exact size needed
fig = plt.figure(figsize=self.inches, dpi=self.dpi, facecolor='w')
# Set xlim and ylim to the size of the patch
plt.xlim(0, self.inches[0] * self.dpi)
plt.ylim(0, self.inches[1] * self.dpi)
# Move the patch to the image origin
coords[:, 0] -= self.bbox[0][0]
coords[:, 1] -= self.bbox[0][1]
# Fill the polygon
plt.fill(coords[:, 0], coords[:, 1], color='black')
# Convert to PIL Image
remove_margins()
img = fig2img(fig)
plt.close(fig)
# Convert to binary mask
arr = img2arr(img)
self.mask = (arr[..., 0] < 128).astype(np.uint8)
return
def _calculate_mask_from_img(self, img: Image.Image) -> None:
"""
Calculates binary mask from image.
Notes
-----
The mask is a 2D binary array (1 - inside, 0 - outside).
The mask is array (x, y), y axis goes from bottom to top.
Transpose it to convert to image dimensions (y, x),
Then reverse y axis to make it go from top to bottom.
Parameters
----------
img : PIL.Image.Image
Image to calculate mask from
"""
if img.mode == 'RGBA':
# If alpha channel is present, use it to create mask
arr = img2arr(img)
self.mask = (arr[..., 3] > 0).astype(np.uint8)
else:
# If no alpha channel, convert to grayscale and use threshold
arr = img2arr(img.convert('L'))
self.mask = (arr < 128).astype(np.uint8)
return
[docs]
def fill_solid(self, color: str) -> Image.Image:
"""
Fills the patch with a solid color.
Parameters
----------
color : str
Color to fill the patch with. Can be:
- Color name (e.g., 'red')
- Hex string (e.g., '#FF0000')
Returns
-------
PIL.Image.Image
Image of the filled patch with transparency
"""
img = Image.new('RGBA', (self.width, self.height), color)
img.putalpha(mask2img(1 - self.mask))
self.fill = img
return img
[docs]
def fill_gradient(
self,
colors: Union[List[str], str, Colormap],
angle: float = 0,
) -> Image.Image:
"""
Fills the patch with a gradient.
Parameters
----------
colors : str or List[str] or Colormap
Colormap or color or list of colors for the gradient
angle : float, default=0
Angle of the gradient in degrees
Returns
-------
PIL.Image.Image
Image of the gradient-filled patch with transparency
"""
img = fill_gradient(colors, (self.width, self.height), angle=angle, show=False)
img.putalpha(mask2img(1 - self.mask))
self.fill = img
return img
def _create_full_image_mask(self, img: Image.Image) -> np.ndarray:
"""
Creates a binary mask for the entire image.
This method creates a binary mask for the entire image, handling cases where
parts of the patch's bounding box may fall outside the image boundaries.
Notes
-----
The mask is a 2D binary array (1 - inside, 0 - outside).
The mask is array (x, y), y axis goes from bottom to top.
Transpose it to convert to image dimensions (y, x),
Then reverse y axis to make it go from top to bottom.
Parameters
----------
img : PIL.Image.Image
The image to create the mask for
Returns
-------
numpy.ndarray
Binary mask array of the same size as the image
"""
# Create an empty array of the same size as the image
arr = np.zeros(img.size, dtype=float)
# Get image dimensions
img_width, img_height = arr.shape
# Calculate valid slice ranges for the mask
x_start = max(0, self.bbox[0][0])
y_start = max(0, self.bbox[0][1])
x_end = min(img_width, self.bbox[1][0])
y_end = min(img_height, self.bbox[1][1])
# Calculate corresponding slice ranges for the patch mask
mask_x_start = max(0, x_start - self.bbox[0][0])
mask_y_start = max(0, y_start - self.bbox[0][1])
mask_x_end = min(self.mask.shape[0], x_end - self.bbox[0][0])
mask_y_end = min(self.mask.shape[1], y_end - self.bbox[0][1])
# Only proceed if there is an overlap between the patch and the image
if y_start < y_end and x_start < x_end:
# Fill the valid area with the corresponding part of the mask
arr[x_start:x_end, y_start:y_end] = self.mask[mask_x_start:mask_x_end, mask_y_start:mask_y_end]
return arr
def _create_blurred_mask(
self,
img: Image.Image,
kernel: Tuple[int, int] = (31, 31),
sigma: float = 10
) -> np.ndarray:
"""
Creates a blurred binary mask for shadow or glow.
Returns a 2D array of the same size as the image.
"""
# Create a binary mask for the entire image
arr = self._create_full_image_mask(img)
# Apply Gaussian blur
kernel = (int(kernel[0] / 2) * 2 + 1, int(kernel[1] / 2) * 2 + 1)
arr = cv2.GaussianBlur(arr, kernel, sigma)
return arr
[docs]
def cast_shadow(
self,
img: Image.Image,
kernel: Tuple[int, int] = (31, 31),
sigma: float = 10,
color: str = "#000000"
) -> Image.Image:
"""
Casts a shadow of the patch.
Note:
-----
Returns only shadow image.
Use add_layer() to add shadow to an image.
Parameters
----------
img : PIL.Image.Image
Image to cast shadow onto
kernel : Tuple[int, int], default=(31, 31)
Size of the Gaussian kernel
sigma : float, default=10
Standard deviation of the Gaussian kernel
color : str, default="#000000"
Color of the shadow
Returns
-------
PIL.Image.Image
Image with shadow cast
"""
# Create a blurred binary mask for shadow
arr = self._create_blurred_mask(img, kernel, sigma)
# Create color channels
rgb = to_rgb(color)
shadow = [rgb[i] * np.ones_like(arr) for i in range(3)]
# Add alpha channel
shadow.append(arr)
shadow = np.stack(shadow, axis=2)
# Convert binary array to image
shadow = np.clip(255 * shadow, 0, 255).astype(np.uint8)
shadow = arr2img(shadow)
self.shadow = shadow
return shadow
[docs]
def get_average_color(self, img: Image.Image) -> str:
"""
Calculates the average color of the area where the patch will be placed.
Parameters
----------
img : PIL.Image.Image
Image to analyze
Returns
-------
str
Average color as hex string
"""
arr = img2arr(img)
mask = self._create_full_image_mask(img) > 0.5
rgb = (0, 0, 0)
if np.sum(mask) > 0:
if len(arr.shape) == 2: # Grayscale
rgb = [np.mean(arr[mask])] * 3
else: # RGB
rgb = [np.mean(arr[..., i][mask]) for i in range(3)]
color = '#{:02x}{:02x}{:02x}'.format(int(rgb[0]), int(rgb[1]), int(rgb[2]))
return color
[docs]
def get_centroid_color(self, img: Image.Image) -> str:
"""
Calculates the color of the centroid of the patch.
Parameters
----------
img : PIL.Image.Image
Image to analyze
Returns
-------
str
Centroid color as hex string
"""
arr = img2arr(img)
mask = self._create_full_image_mask(img) > 0.5
rgb = (0, 0, 0)
if np.sum(mask) > 0:
centroid = np.mean(np.argwhere(mask), axis=0).astype(int)
if len(arr.shape) == 2: # Grayscale
rgb = arr[centroid[0], centroid[1]] * np.ones(3)
else: # RGB
rgb = arr[centroid[0], centroid[1]]
color = '#{:02x}{:02x}{:02x}'.format(int(rgb[0]), int(rgb[1]), int(rgb[2]))
return color
[docs]
def get_darkest_color(self, img: Image.Image) -> str:
"""
Calculates the darkest color of the area where the patch will be placed.
Parameters
----------
img : PIL.Image.Image
Image to analyze
Returns
-------
str
Darkest color as hex string
"""
arr = img2arr(img)
mask = self._create_full_image_mask(img) > 0.5
rgb = (0, 0, 0)
if np.sum(mask) > 0:
if len(arr.shape) == 2: # Grayscale
rgb = [np.min(arr[mask])] * 3
else: # RGB
rgb = np.stack([arr[..., i][mask].flatten() for i in range(3)])
intensity = np.mean(rgb, axis=0)
k = np.argmin(intensity)
rgb = rgb[:,k]
color = '#{:02x}{:02x}{:02x}'.format(int(rgb[0]), int(rgb[1]), int(rgb[2]))
return color
[docs]
def get_lightest_color(self, img: Image.Image) -> str:
"""
Calculates the lightest color of the area where the patch will be placed.
Parameters
----------
img : PIL.Image.Image
Image to analyze
Returns
-------
str
Lightest color as hex string
"""
arr = img2arr(img)
mask = self._create_full_image_mask(img) > 0.5
rgb = (0, 0, 0)
if np.sum(mask) > 0:
if len(arr.shape) == 2: # Grayscale
rgb = [np.max(arr[mask])] * 3
else: # RGB
rgb = np.stack([arr[..., i][mask].flatten() for i in range(3)])
intensity = np.mean(rgb, axis=0)
k = np.argmax(intensity)
rgb = rgb[:,k]
color = '#{:02x}{:02x}{:02x}'.format(int(rgb[0]), int(rgb[1]), int(rgb[2]))
return color
[docs]
def draw(self, fig: Figure, size: Tuple[int, int]) -> None:
"""
Draws the patch fill and shadow on an image.
Parameters
----------
fig : matplotlib.figure.Figure
Figure to draw on
size : Tuple[int, int]
The size of the figure canvas in pixels
"""
if self.shadow is not None:
add_layer(fig, size, self.shadow, (0, 0))
if self.fill is not None:
add_layer(fig, size, self.fill, self.start)
return
import types
__all__ = [name for name, thing in globals().items()
if not (name.startswith("_") or isinstance(thing, types.ModuleType))]
del types