| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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, |
| ) |
|
|