| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | from __future__ import annotations |
| |
|
| | import abc |
| | import functools |
| | from collections.abc import Sequence |
| | from typing import cast |
| |
|
| | TYPE_CHECKING = False |
| | if TYPE_CHECKING: |
| | from collections.abc import Callable |
| | from types import ModuleType |
| | from typing import Any |
| |
|
| | from . import _imaging |
| | from ._typing import NumpyArray |
| |
|
| |
|
| | class Filter(abc.ABC): |
| | @abc.abstractmethod |
| | def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: |
| | pass |
| |
|
| |
|
| | class MultibandFilter(Filter): |
| | pass |
| |
|
| |
|
| | class BuiltinFilter(MultibandFilter): |
| | filterargs: tuple[Any, ...] |
| |
|
| | def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: |
| | if image.mode == "P": |
| | msg = "cannot filter palette images" |
| | raise ValueError(msg) |
| | return image.filter(*self.filterargs) |
| |
|
| |
|
| | class Kernel(BuiltinFilter): |
| | """ |
| | Create a convolution kernel. This only supports 3x3 and 5x5 integer and floating |
| | point kernels. |
| | |
| | Kernels can only be applied to "L" and "RGB" images. |
| | |
| | :param size: Kernel size, given as (width, height). This must be (3,3) or (5,5). |
| | :param kernel: A sequence containing kernel weights. The kernel will be flipped |
| | vertically before being applied to the image. |
| | :param scale: Scale factor. If given, the result for each pixel is divided by this |
| | value. The default is the sum of the kernel weights. |
| | :param offset: Offset. If given, this value is added to the result, after it has |
| | been divided by the scale factor. |
| | """ |
| |
|
| | name = "Kernel" |
| |
|
| | def __init__( |
| | self, |
| | size: tuple[int, int], |
| | kernel: Sequence[float], |
| | scale: float | None = None, |
| | offset: float = 0, |
| | ) -> None: |
| | if scale is None: |
| | |
| | scale = functools.reduce(lambda a, b: a + b, kernel) |
| | if size[0] * size[1] != len(kernel): |
| | msg = "not enough coefficients in kernel" |
| | raise ValueError(msg) |
| | self.filterargs = size, scale, offset, kernel |
| |
|
| |
|
| | class RankFilter(Filter): |
| | """ |
| | Create a rank filter. The rank filter sorts all pixels in |
| | a window of the given size, and returns the ``rank``'th value. |
| | |
| | :param size: The kernel size, in pixels. |
| | :param rank: What pixel value to pick. Use 0 for a min filter, |
| | ``size * size / 2`` for a median filter, ``size * size - 1`` |
| | for a max filter, etc. |
| | """ |
| |
|
| | name = "Rank" |
| |
|
| | def __init__(self, size: int, rank: int) -> None: |
| | self.size = size |
| | self.rank = rank |
| |
|
| | def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: |
| | if image.mode == "P": |
| | msg = "cannot filter palette images" |
| | raise ValueError(msg) |
| | image = image.expand(self.size // 2, self.size // 2) |
| | return image.rankfilter(self.size, self.rank) |
| |
|
| |
|
| | class MedianFilter(RankFilter): |
| | """ |
| | Create a median filter. Picks the median pixel value in a window with the |
| | given size. |
| | |
| | :param size: The kernel size, in pixels. |
| | """ |
| |
|
| | name = "Median" |
| |
|
| | def __init__(self, size: int = 3) -> None: |
| | self.size = size |
| | self.rank = size * size // 2 |
| |
|
| |
|
| | class MinFilter(RankFilter): |
| | """ |
| | Create a min filter. Picks the lowest pixel value in a window with the |
| | given size. |
| | |
| | :param size: The kernel size, in pixels. |
| | """ |
| |
|
| | name = "Min" |
| |
|
| | def __init__(self, size: int = 3) -> None: |
| | self.size = size |
| | self.rank = 0 |
| |
|
| |
|
| | class MaxFilter(RankFilter): |
| | """ |
| | Create a max filter. Picks the largest pixel value in a window with the |
| | given size. |
| | |
| | :param size: The kernel size, in pixels. |
| | """ |
| |
|
| | name = "Max" |
| |
|
| | def __init__(self, size: int = 3) -> None: |
| | self.size = size |
| | self.rank = size * size - 1 |
| |
|
| |
|
| | class ModeFilter(Filter): |
| | """ |
| | Create a mode filter. Picks the most frequent pixel value in a box with the |
| | given size. Pixel values that occur only once or twice are ignored; if no |
| | pixel value occurs more than twice, the original pixel value is preserved. |
| | |
| | :param size: The kernel size, in pixels. |
| | """ |
| |
|
| | name = "Mode" |
| |
|
| | def __init__(self, size: int = 3) -> None: |
| | self.size = size |
| |
|
| | def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: |
| | return image.modefilter(self.size) |
| |
|
| |
|
| | class GaussianBlur(MultibandFilter): |
| | """Blurs the image with a sequence of extended box filters, which |
| | approximates a Gaussian kernel. For details on accuracy see |
| | <https://www.mia.uni-saarland.de/Publications/gwosdek-ssvm11.pdf> |
| | |
| | :param radius: Standard deviation of the Gaussian kernel. Either a sequence of two |
| | numbers for x and y, or a single number for both. |
| | """ |
| |
|
| | name = "GaussianBlur" |
| |
|
| | def __init__(self, radius: float | Sequence[float] = 2) -> None: |
| | self.radius = radius |
| |
|
| | def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: |
| | xy = self.radius |
| | if isinstance(xy, (int, float)): |
| | xy = (xy, xy) |
| | if xy == (0, 0): |
| | return image.copy() |
| | return image.gaussian_blur(xy) |
| |
|
| |
|
| | class BoxBlur(MultibandFilter): |
| | """Blurs the image by setting each pixel to the average value of the pixels |
| | in a square box extending radius pixels in each direction. |
| | Supports float radius of arbitrary size. Uses an optimized implementation |
| | which runs in linear time relative to the size of the image |
| | for any radius value. |
| | |
| | :param radius: Size of the box in a direction. Either a sequence of two numbers for |
| | x and y, or a single number for both. |
| | |
| | Radius 0 does not blur, returns an identical image. |
| | Radius 1 takes 1 pixel in each direction, i.e. 9 pixels in total. |
| | """ |
| |
|
| | name = "BoxBlur" |
| |
|
| | def __init__(self, radius: float | Sequence[float]) -> None: |
| | xy = radius if isinstance(radius, (tuple, list)) else (radius, radius) |
| | if xy[0] < 0 or xy[1] < 0: |
| | msg = "radius must be >= 0" |
| | raise ValueError(msg) |
| | self.radius = radius |
| |
|
| | def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: |
| | xy = self.radius |
| | if isinstance(xy, (int, float)): |
| | xy = (xy, xy) |
| | if xy == (0, 0): |
| | return image.copy() |
| | return image.box_blur(xy) |
| |
|
| |
|
| | class UnsharpMask(MultibandFilter): |
| | """Unsharp mask filter. |
| | |
| | See Wikipedia's entry on `digital unsharp masking`_ for an explanation of |
| | the parameters. |
| | |
| | :param radius: Blur Radius |
| | :param percent: Unsharp strength, in percent |
| | :param threshold: Threshold controls the minimum brightness change that |
| | will be sharpened |
| | |
| | .. _digital unsharp masking: https://en.wikipedia.org/wiki/Unsharp_masking#Digital_unsharp_masking |
| | |
| | """ |
| |
|
| | name = "UnsharpMask" |
| |
|
| | def __init__( |
| | self, radius: float = 2, percent: int = 150, threshold: int = 3 |
| | ) -> None: |
| | self.radius = radius |
| | self.percent = percent |
| | self.threshold = threshold |
| |
|
| | def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: |
| | return image.unsharp_mask(self.radius, self.percent, self.threshold) |
| |
|
| |
|
| | class BLUR(BuiltinFilter): |
| | name = "Blur" |
| | |
| | filterargs = (5, 5), 16, 0, ( |
| | 1, 1, 1, 1, 1, |
| | 1, 0, 0, 0, 1, |
| | 1, 0, 0, 0, 1, |
| | 1, 0, 0, 0, 1, |
| | 1, 1, 1, 1, 1, |
| | ) |
| | |
| |
|
| |
|
| | class CONTOUR(BuiltinFilter): |
| | name = "Contour" |
| | |
| | filterargs = (3, 3), 1, 255, ( |
| | -1, -1, -1, |
| | -1, 8, -1, |
| | -1, -1, -1, |
| | ) |
| | |
| |
|
| |
|
| | class DETAIL(BuiltinFilter): |
| | name = "Detail" |
| | |
| | filterargs = (3, 3), 6, 0, ( |
| | 0, -1, 0, |
| | -1, 10, -1, |
| | 0, -1, 0, |
| | ) |
| | |
| |
|
| |
|
| | class EDGE_ENHANCE(BuiltinFilter): |
| | name = "Edge-enhance" |
| | |
| | filterargs = (3, 3), 2, 0, ( |
| | -1, -1, -1, |
| | -1, 10, -1, |
| | -1, -1, -1, |
| | ) |
| | |
| |
|
| |
|
| | class EDGE_ENHANCE_MORE(BuiltinFilter): |
| | name = "Edge-enhance More" |
| | |
| | filterargs = (3, 3), 1, 0, ( |
| | -1, -1, -1, |
| | -1, 9, -1, |
| | -1, -1, -1, |
| | ) |
| | |
| |
|
| |
|
| | class EMBOSS(BuiltinFilter): |
| | name = "Emboss" |
| | |
| | filterargs = (3, 3), 1, 128, ( |
| | -1, 0, 0, |
| | 0, 1, 0, |
| | 0, 0, 0, |
| | ) |
| | |
| |
|
| |
|
| | class FIND_EDGES(BuiltinFilter): |
| | name = "Find Edges" |
| | |
| | filterargs = (3, 3), 1, 0, ( |
| | -1, -1, -1, |
| | -1, 8, -1, |
| | -1, -1, -1, |
| | ) |
| | |
| |
|
| |
|
| | class SHARPEN(BuiltinFilter): |
| | name = "Sharpen" |
| | |
| | filterargs = (3, 3), 16, 0, ( |
| | -2, -2, -2, |
| | -2, 32, -2, |
| | -2, -2, -2, |
| | ) |
| | |
| |
|
| |
|
| | class SMOOTH(BuiltinFilter): |
| | name = "Smooth" |
| | |
| | filterargs = (3, 3), 13, 0, ( |
| | 1, 1, 1, |
| | 1, 5, 1, |
| | 1, 1, 1, |
| | ) |
| | |
| |
|
| |
|
| | class SMOOTH_MORE(BuiltinFilter): |
| | name = "Smooth More" |
| | |
| | filterargs = (5, 5), 100, 0, ( |
| | 1, 1, 1, 1, 1, |
| | 1, 5, 5, 5, 1, |
| | 1, 5, 44, 5, 1, |
| | 1, 5, 5, 5, 1, |
| | 1, 1, 1, 1, 1, |
| | ) |
| | |
| |
|
| |
|
| | class Color3DLUT(MultibandFilter): |
| | """Three-dimensional color lookup table. |
| | |
| | Transforms 3-channel pixels using the values of the channels as coordinates |
| | in the 3D lookup table and interpolating the nearest elements. |
| | |
| | This method allows you to apply almost any color transformation |
| | in constant time by using pre-calculated decimated tables. |
| | |
| | .. versionadded:: 5.2.0 |
| | |
| | :param size: Size of the table. One int or tuple of (int, int, int). |
| | Minimal size in any dimension is 2, maximum is 65. |
| | :param table: Flat lookup table. A list of ``channels * size**3`` |
| | float elements or a list of ``size**3`` channels-sized |
| | tuples with floats. Channels are changed first, |
| | then first dimension, then second, then third. |
| | Value 0.0 corresponds lowest value of output, 1.0 highest. |
| | :param channels: Number of channels in the table. Could be 3 or 4. |
| | Default is 3. |
| | :param target_mode: A mode for the result image. Should have not less |
| | than ``channels`` channels. Default is ``None``, |
| | which means that mode wouldn't be changed. |
| | """ |
| |
|
| | name = "Color 3D LUT" |
| |
|
| | def __init__( |
| | self, |
| | size: int | tuple[int, int, int], |
| | table: Sequence[float] | Sequence[Sequence[int]] | NumpyArray, |
| | channels: int = 3, |
| | target_mode: str | None = None, |
| | **kwargs: bool, |
| | ) -> None: |
| | if channels not in (3, 4): |
| | msg = "Only 3 or 4 output channels are supported" |
| | raise ValueError(msg) |
| | self.size = size = self._check_size(size) |
| | self.channels = channels |
| | self.mode = target_mode |
| |
|
| | |
| | |
| | copy_table = kwargs.get("_copy_table", True) |
| | items = size[0] * size[1] * size[2] |
| | wrong_size = False |
| |
|
| | numpy: ModuleType | None = None |
| | if hasattr(table, "shape"): |
| | try: |
| | import numpy |
| | except ImportError: |
| | pass |
| |
|
| | if numpy and isinstance(table, numpy.ndarray): |
| | numpy_table: NumpyArray = table |
| | if copy_table: |
| | numpy_table = numpy_table.copy() |
| |
|
| | if numpy_table.shape in [ |
| | (items * channels,), |
| | (items, channels), |
| | (size[2], size[1], size[0], channels), |
| | ]: |
| | table = numpy_table.reshape(items * channels) |
| | else: |
| | wrong_size = True |
| |
|
| | else: |
| | if copy_table: |
| | table = list(table) |
| |
|
| | |
| | if table and isinstance(table[0], (list, tuple)): |
| | raw_table = cast(Sequence[Sequence[int]], table) |
| | flat_table: list[int] = [] |
| | for pixel in raw_table: |
| | if len(pixel) != channels: |
| | msg = ( |
| | "The elements of the table should " |
| | f"have a length of {channels}." |
| | ) |
| | raise ValueError(msg) |
| | flat_table.extend(pixel) |
| | table = flat_table |
| |
|
| | if wrong_size or len(table) != items * channels: |
| | msg = ( |
| | "The table should have either channels * size**3 float items " |
| | "or size**3 items of channels-sized tuples with floats. " |
| | f"Table should be: {channels}x{size[0]}x{size[1]}x{size[2]}. " |
| | f"Actual length: {len(table)}" |
| | ) |
| | raise ValueError(msg) |
| | self.table = table |
| |
|
| | @staticmethod |
| | def _check_size(size: Any) -> tuple[int, int, int]: |
| | try: |
| | _, _, _ = size |
| | except ValueError as e: |
| | msg = "Size should be either an integer or a tuple of three integers." |
| | raise ValueError(msg) from e |
| | except TypeError: |
| | size = (size, size, size) |
| | size = tuple(int(x) for x in size) |
| | for size_1d in size: |
| | if not 2 <= size_1d <= 65: |
| | msg = "Size should be in [2, 65] range." |
| | raise ValueError(msg) |
| | return size |
| |
|
| | @classmethod |
| | def generate( |
| | cls, |
| | size: int | tuple[int, int, int], |
| | callback: Callable[[float, float, float], tuple[float, ...]], |
| | channels: int = 3, |
| | target_mode: str | None = None, |
| | ) -> Color3DLUT: |
| | """Generates new LUT using provided callback. |
| | |
| | :param size: Size of the table. Passed to the constructor. |
| | :param callback: Function with three parameters which correspond |
| | three color channels. Will be called ``size**3`` |
| | times with values from 0.0 to 1.0 and should return |
| | a tuple with ``channels`` elements. |
| | :param channels: The number of channels which should return callback. |
| | :param target_mode: Passed to the constructor of the resulting |
| | lookup table. |
| | """ |
| | size_1d, size_2d, size_3d = cls._check_size(size) |
| | if channels not in (3, 4): |
| | msg = "Only 3 or 4 output channels are supported" |
| | raise ValueError(msg) |
| |
|
| | table: list[float] = [0] * (size_1d * size_2d * size_3d * channels) |
| | idx_out = 0 |
| | for b in range(size_3d): |
| | for g in range(size_2d): |
| | for r in range(size_1d): |
| | table[idx_out : idx_out + channels] = callback( |
| | r / (size_1d - 1), g / (size_2d - 1), b / (size_3d - 1) |
| | ) |
| | idx_out += channels |
| |
|
| | return cls( |
| | (size_1d, size_2d, size_3d), |
| | table, |
| | channels=channels, |
| | target_mode=target_mode, |
| | _copy_table=False, |
| | ) |
| |
|
| | def transform( |
| | self, |
| | callback: Callable[..., tuple[float, ...]], |
| | with_normals: bool = False, |
| | channels: int | None = None, |
| | target_mode: str | None = None, |
| | ) -> Color3DLUT: |
| | """Transforms the table values using provided callback and returns |
| | a new LUT with altered values. |
| | |
| | :param callback: A function which takes old lookup table values |
| | and returns a new set of values. The number |
| | of arguments which function should take is |
| | ``self.channels`` or ``3 + self.channels`` |
| | if ``with_normals`` flag is set. |
| | Should return a tuple of ``self.channels`` or |
| | ``channels`` elements if it is set. |
| | :param with_normals: If true, ``callback`` will be called with |
| | coordinates in the color cube as the first |
| | three arguments. Otherwise, ``callback`` |
| | will be called only with actual color values. |
| | :param channels: The number of channels in the resulting lookup table. |
| | :param target_mode: Passed to the constructor of the resulting |
| | lookup table. |
| | """ |
| | if channels not in (None, 3, 4): |
| | msg = "Only 3 or 4 output channels are supported" |
| | raise ValueError(msg) |
| | ch_in = self.channels |
| | ch_out = channels or ch_in |
| | size_1d, size_2d, size_3d = self.size |
| |
|
| | table: list[float] = [0] * (size_1d * size_2d * size_3d * ch_out) |
| | idx_in = 0 |
| | idx_out = 0 |
| | for b in range(size_3d): |
| | for g in range(size_2d): |
| | for r in range(size_1d): |
| | values = self.table[idx_in : idx_in + ch_in] |
| | if with_normals: |
| | values = callback( |
| | r / (size_1d - 1), |
| | g / (size_2d - 1), |
| | b / (size_3d - 1), |
| | *values, |
| | ) |
| | else: |
| | values = callback(*values) |
| | table[idx_out : idx_out + ch_out] = values |
| | idx_in += ch_in |
| | idx_out += ch_out |
| |
|
| | return type(self)( |
| | self.size, |
| | table, |
| | channels=ch_out, |
| | target_mode=target_mode or self.mode, |
| | _copy_table=False, |
| | ) |
| |
|
| | def __repr__(self) -> str: |
| | r = [ |
| | f"{self.__class__.__name__} from {self.table.__class__.__name__}", |
| | "size={:d}x{:d}x{:d}".format(*self.size), |
| | f"channels={self.channels:d}", |
| | ] |
| | if self.mode: |
| | r.append(f"target_mode={self.mode}") |
| | return "<{}>".format(" ".join(r)) |
| |
|
| | def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore: |
| | from . import Image |
| |
|
| | return image.color_lut_3d( |
| | self.mode or image.mode, |
| | Image.Resampling.BILINEAR, |
| | self.channels, |
| | self.size, |
| | self.table, |
| | ) |
| |
|