Source code for excolor.imagetools

#!/usr/bin/env python
# -*- coding: utf8 -*-

"""
This module contains functions to manipulate images.
"""

import io
import requests
import numpy as np
import matplotlib.pyplot as plt
from PIL import Image
from matplotlib.figure import Figure
import matplotlib.colors as mc
from matplotlib.axes import Axes
from typing import List, Callable, Tuple, Optional

import warnings
warnings.filterwarnings("ignore")


def _to_prime_factors(n: int) -> List[int]:
    """
    Decomposes an integer into its prime factors.

    This function takes an integer and returns a list of its prime factors.
    It uses trial division to find the prime factors.

    Parameters
    ----------
    n : int
        The integer to decompose into prime factors

    Returns
    -------
    factors : List[int]
        A list of prime factors of n

    Examples
    --------
    >>> _to_prime_factors(12)
    [2, 2, 3]
    >>> _to_prime_factors(100)
    [2, 2, 5, 5]
    """
    if n <= 2:
        factors = [n]
    else:
        factors = []
        i = 2
        while i * i <= n:
            if n % i:
                i += 1
            else:
                n //= i
                factors.append(i)
        factors.append(int(n))
    return factors


def _to_combinations(x: List[int]) -> List[List[int]]:
    """
    Generates all combinations of a list of integers.

    This function takes a list of integers and returns all possible combinations
    of the integers. It uses a recursive approach to generate all combinations.

    Parameters
    ----------
    x : List[int]
        The list of integers to generate combinations from

    Returns
    -------
    cs : List[List[int]]
        A list of all possible combinations of the integers in x

    Examples
    --------
    >>> _to_combinations([1, 2, 3])
    [[1], [2], [3], [1, 2], [1, 3], [2, 3], [1, 2, 3]]
    """
    if len(x) == 0:
        return [[]]
    comb = []
    for c in _to_combinations(x[1:]):
        comb += [c, c+[x[0]]]
    return comb


def _find_common_divisors(x: int, y: int) -> np.ndarray:
    """
    Finds all common divisors of two integers.

    This function takes two integers and returns a list of their common divisors.
    It uses trial division to find the common divisors.

    Parameters
    ----------
    x : int
        The first integer
    y : int
        The second integer

    Returns
    -------
    divisors : List[int]
        A list of common divisors of x and y

    Examples
    --------
    >>> _find_common_divisors(12, 18)
    [1, 2, 3, 6]
    """
    gcd = np.gcd(x, y)
    factors = _to_prime_factors(gcd)
    combs = _to_combinations(factors)
    divisors = np.unique([np.prod(c) for c in combs if len(c) > 0])
    return divisors


[docs] def pixels_to_size_and_dpi(size: Tuple[int, int], exact: bool = True) -> Tuple[Tuple[int, int], int]: """ Converts pixel dimensions to optimal size in inches and DPI. This function takes pixel dimensions and converts them to physical size (in inches) and DPI (dots per inch) while maintaining the aspect ratio. It ensures the output size is at least 5 inches in the smaller dimension by adjusting the DPI if necessary. Parameters ---------- size : Tuple[int, int] Image dimensions in pixels (width, height) exact : bool, default True If True, the size will match the target size exactly Returns ------- Tuple[Tuple[int, int], int] A tuple containing: - Tuple[int, int]: Optimal size in inches (width, height) - int: DPI (dots per inch) Examples -------- >>> pixels_to_size_and_dpi((1200, 800)) # ((6, 4), 200) >>> pixels_to_size_and_dpi((300, 300)) # ((5, 5), 60) """ # Find optimal size x, y = size if x <= 0 or y <= 0: return (0, 0), 1 # Generate size for DPI values in range 20 - 300 dpi = 10 * np.arange(2,31) xs = np.ceil(x / dpi).astype(int) ys = np.ceil(y / dpi).astype(int) # Calculate error between target size and actual size err1 = np.abs(dpi * dpi * (xs * ys) - (x * y)) / (x * y) # Calculate error between target size and (5 x 5) err2 = np.abs((xs * ys) - 25) / 25 # Calculate combined error err = 0.5 * (err1 + err2) # Select size and DPI by minimum of combined error k = np.argmin(err) dpi_ = int(dpi[k]) inches_ = (int(xs[k]), int(ys[k])) # FInd exact size based on Greatest Common Divisor dpi = _find_common_divisors(*size) # Select exact DPI closest to optimal DPI err = np.abs(dpi - dpi_) k = np.argmin(err) dpi = int(dpi[k]) inches = (int(x // dpi), int(y // dpi)) # If exact == False and DPI falls outside 20 - 300 range, select optimal if dpi < 20 or dpi > 300: if not exact: dpi = dpi_ inches = inches_ return inches, dpi
[docs] def remove_margins() -> None: """ Removes figure margins in matplotlib to keep only the plot area. This function removes all margins and axes from the current matplotlib figure, leaving only the plot area visible. It is useful for creating clean, borderless visualizations. Returns ------- None Examples -------- >>> plt.figure() >>> plt.plot([1, 2, 3]) >>> remove_margins() # Removes all margins and axes >>> plt.show() """ plt.gca().set_axis_off() plt.subplots_adjust(top=1, bottom=0, right=1, left=0, hspace=0, wspace=0) plt.margins(0,0) plt.axis("off") return
[docs] def load_image(fname: str) -> Image.Image: """ Loads an image from a file or URL. Parameters ---------- fname : str Image path or url Returns ------- img : PIL.PngImagePlugin.PngImageFile Image Examples -------- >>> img = load_image("https://example.com/image.png") >>> img = load_image("image.png") """ if fname.find("http://") == 0 or fname.find("https://") == 0: img = Image.open(requests.get(fname, stream=True).raw) else: img = Image.open(fname) return img
[docs] def img2arr(img: Image.Image) -> np.ndarray: """ Converts a PIL Image to a numpy array. Notes ----- Image dimensions are (y, x). Image y axis goes from top to bottom. Array dimensions are transposed to (x, y) Array y axis is reversed and goes from bottom to top. Parameters ---------- img : PIL.Image.Image The image to convert Returns ------- arr : numpy.ndarray The converted image """ # Reverse y axis arr = np.array(img)[::-1,...] # Transpose array dimensions if arr.ndim == 2: arr = arr.transpose(1, 0) elif arr.ndim == 3: arr = arr.transpose(1, 0, 2) return arr
[docs] def arr2img(arr: np.ndarray) -> Image.Image: """ Converts a numpy array to a PIL Image. Notes ----- Array dimensions are (x, y). Array y axis goes from bottom to top. Image dimensions are transposed to (y, x). Image y axis goes from top to bottom. Parameters ---------- arr : numpy.ndarray The array to convert Returns ------- img : PIL.Image.Image """ # Reverse y axis and transpose array dimensions if arr.ndim == 2: arr = arr[:,::-1].transpose(1, 0) img = Image.fromarray(arr) elif arr.ndim == 3: arr = arr[:,::-1,...].transpose(1, 0, 2) img = Image.fromarray(arr) return img
[docs] def mask2img(mask: np.ndarray) -> Image.Image: """ Converts a binary numpy array to a PIL Image. Notes ----- Array dimensions are (x, y). Array y axis goes from bottom to top. Image dimensions are transposed to (y, x). Image y axis goes from top to bottom. Array values are in range 0 - 1. Parameters ---------- mask : numpy.ndarray The mask to convert Returns ------- img : PIL.Image.Image """ # Convert to uint8, reverse y axis and transpose array dimensions arr = np.clip(255 * (1 - mask), 0, 255).astype(np.uint8) return arr2img(arr)
[docs] def fig2img(fig: Optional[Figure] = None) -> Image.Image: """ Converts a Matplotlib figure to a PIL Image and return it Parameters ---------- fig : matplotlib.figure.Figure or None The figure to convert. If None, use plt.gcf() Returns ------- img : PIL.Image.Image The converted image """ if fig is None: fig = plt.gcf() buf = io.BytesIO() fig.savefig(buf, format="png") buf.seek(0) img = Image.open(buf) return img
[docs] def fig2img_from_canvas(fig: Optional[Figure] = None) -> Image.Image: """ Converts a Matplotlib figure to a PIL Image from the canvas and return it Parameters ---------- fig : matplotlib.figure.Figure or None The figure to convert. If None, use plt.gcf() Returns ------- img : PIL.Image.Image The converted image """ if fig is None: fig = plt.gcf() fig.canvas.draw() img = np.asarray(fig.canvas.renderer._renderer) img = Image.fromarray(img) return img
[docs] def add_layer(fig: Figure, size: Tuple[int, int], layer: Image.Image, start: Tuple[int, int] = (0, 0)) -> None: """ Adds a layer to an image using plt.imshow(). Parameters ---------- fig : matplotlib.figure.Figure The base figure size : Tuple[int, int] The size of the figure canvas in pixels layer : PIL.Image.Image The layer to add start : Tuple[int, int], default (0, 0) The starting position of the layer in the image """ #Calculate position and size of layer [left, bottom, width, height] in figure fraction width, height = size x = start[0] / width y = start[1] / height w = layer.size[0] / width h = layer.size[1] / height # Add layer to figure ax = fig.add_axes([x, y, w, h]) ax.imshow(np.array(layer)) ax.axis('off') # Hide axes for clean display return
def _find_midpoint(data: np.ndarray) -> np.ndarray: """ Identifies midpoint in a distribution of color intensities. This function analyzes a distribution of color intensities to find midpoint. Uses a multi-scale approach with adaptive window size. Parameters ---------- data : numpy.ndarray 1D array of color intensity values to analyze Returns ------- numpy.ndarray Array of intensity values at which peaks are found. Returns at least 2 peaks if found, otherwise returns [0, 1]. Examples -------- >>> # Find peaks in a bimodal distribution >>> rng = np.random.default_rng(0) >>> data = np.concatenate([rng.normal(0.2, 0.1, 100), ... rng.normal(0.8, 0.1, 100)]) >>> peaks = find_peaks(data) >>> # Find peaks in a uniform distribution >>> rng = np.random.default_rng(0) >>> data = rng.random(200) >>> peaks = find_peaks(data) # Likely returns [0, 1] """ values = np.clip(data.flatten(), 0, 1) bins = np.round(np.linspace(-0.1,1.1,121), 2) idx = 0.5 * (bins[:-1] + bins[1:]) hist, _ = np.histogram(values, bins) windows = np.logspace(0,6,13,base=2)[::-1] peaks = np.arange(2) midpoint = 0.5 for window in windows: w = np.hanning(max(1, window)) smooth = np.convolve(hist, w/np.sum(w), mode="same") smooth = smooth * np.sum(hist) / np.sum(smooth) diff = np.diff(smooth) mask = (diff[:-1] > 0) & (diff[1:] <= 0) if np.sum(mask) >= 2: imax = idx[1:-1][mask] err = hist > 5 * smooth imax = np.concatenate([imax, idx[err]]) imax = np.unique(imax) i = np.argmax(np.diff(imax)) peaks = np.array([imax[i], imax[i+1]]) midpoint = np.mean(peaks) break return midpoint
[docs] def colorize_image(image, facecolor="blue", backgroundcolor=None, contrast=0.5): """ Colorizes a b&w image Parameters ---------- image : str or PIL.PngImagePlugin.PngImageFile Image or file path facecolor : str or tuple of float Face color. Can be: - A color name (e.g., 'red') - A hex string (e.g., '#FF0000') - An RGB tuple (e.g., (1.0, 0.0, 0.0)) backgroundcolor : str or tuple of float or None, default None Background color. Can be: - A color name (e.g., 'red') - A hex string (e.g., '#FF0000') - An RGB tuple (e.g., (1.0, 0.0, 0.0)) contrast : float, default 0.5 Contrast of the colorized image (0 - 1) Returns ------- img : PIL.PngImagePlugin.PngImageFile Colorized image """ # Read image and convert to numpy array img = image if isinstance(img, str): img = load_image(img) x = np.asarray(img).astype(float) # Calculate color intensity has_alpha = x.ndim == 3 and x.shape[2] % 2 == 0 if has_alpha: x = x[...,-1] else: x = np.mean(x, axis=2) x = 1 - x / 255 # Find intensity midpoint and range x0 = _find_midpoint(x) dx = x0 if x0 <= 0.5 else 1 - x0 contrast = np.clip(contrast, 0.01, 0.99) dx = dx * (1 - contrast) # Normalize intensity range x = (x - x0) / dx x[x < 0] = 0 x[x > 1] = 1 # Calc RGBA channels based on color fractions c1 = mc.to_rgba(facecolor) c0 = mc.to_rgba(backgroundcolor) if backgroundcolor is not None else (0.,0.,0.,0.) rgba = [x * c1[i] + (1 - x) * c0[i] for i in range(4)] rgba = np.stack(rgba, 2) rgba = 255 * np.clip(rgba, 0, 1) img = Image.fromarray(rgba.astype(np.uint8)) return img
[docs] def resize_image(image, size): """ Resizes an image to the specified size Parameters ---------- image : str or PIL.PngImagePlugin.PngImageFile Image or file path size : float or tuple of int If float, the image is resized to the specified scale factor. If tuple, the image is resized to the specified size in pixels (width, height). Returns ------- img : PIL.PngImagePlugin.PngImageFile Resized image """ # Read image and convert to numpy array img = image if isinstance(img, str): img = load_image(img) # Resize image if isinstance(size, (float, int)): size = (int(img.size[0] * size), int(img.size[1] * size)) img = img.resize(size) return img
[docs] def greyscale_image(image): """ Converts an image to greyscale keeping channels Parameters ---------- image : str or PIL.PngImagePlugin.PngImageFile Image or file path Returns ------- img : PIL.PngImagePlugin.PngImageFile Greyscaled image """ # Read image and convert to numpy array img = image if isinstance(img, str): img = load_image(img) x = np.asarray(img).astype(float) if x.ndim == 3 and x.shape[2] > 1: y = np.mean(x[:, :, :3], axis=2) y = np.clip(y, 0, 255) for i in range(3): x[:, :, i] = y grayscale = Image.fromarray(x.astype(np.uint8)) return grayscale
""" Aliases for functions """ grayscale_image: Callable[..., None] = greyscale_image import types __all__ = [name for name, thing in globals().items() if not (name.startswith("_") or isinstance(thing, types.ModuleType))] del types