Merge pull request #7786 from radarhere/type_hints_imageops

Added type hints to ImageOps
This commit is contained in:
Hugo van Kemenade 2024-02-17 09:16:27 +02:00 committed by GitHub
commit 80d4fc14b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 139 additions and 74 deletions

View File

@ -1430,7 +1430,7 @@ class Image:
root = ElementTree.fromstring(xmp_tags)
return {get_name(root.tag): get_value(root)}
def getexif(self):
def getexif(self) -> Exif:
"""
Gets EXIF data from the image.
@ -1438,7 +1438,6 @@ class Image:
"""
if self._exif is None:
self._exif = Exif()
self._exif._loaded = False
elif self._exif._loaded:
return self._exif
self._exif._loaded = True
@ -1525,7 +1524,7 @@ class Image:
self.load()
return self.im.ptr
def getpalette(self, rawmode="RGB"):
def getpalette(self, rawmode: str | None = "RGB") -> list[int] | None:
"""
Returns the image palette as a list.
@ -1615,7 +1614,7 @@ class Image:
x, y = self.im.getprojection()
return list(x), list(y)
def histogram(self, mask=None, extrema=None):
def histogram(self, mask=None, extrema=None) -> list[int]:
"""
Returns a histogram for the image. The histogram is returned as a
list of pixel counts, one for each pixel value in the source
@ -1804,7 +1803,7 @@ class Image:
result = alpha_composite(background, overlay)
self.paste(result, box)
def point(self, lut, mode=None):
def point(self, lut, mode: str | None = None) -> Image:
"""
Maps this image through a lookup table or function.
@ -1928,7 +1927,7 @@ class Image:
self.im.putdata(data, scale, offset)
def putpalette(self, data, rawmode="RGB"):
def putpalette(self, data, rawmode="RGB") -> None:
"""
Attaches a palette to this image. The image must be a "P", "PA", "L"
or "LA" image.
@ -2108,7 +2107,7 @@ class Image:
min(self.size[1], math.ceil(box[3] + support_y)),
)
def resize(self, size, resample=None, box=None, reducing_gap=None):
def resize(self, size, resample=None, box=None, reducing_gap=None) -> Image:
"""
Returns a resized copy of this image.
@ -2200,10 +2199,11 @@ class Image:
if factor_x > 1 or factor_y > 1:
reduce_box = self._get_safe_box(size, resample, box)
factor = (factor_x, factor_y)
if callable(self.reduce):
self = self.reduce(factor, box=reduce_box)
else:
self = Image.reduce(self, factor, box=reduce_box)
self = (
self.reduce(factor, box=reduce_box)
if callable(self.reduce)
else Image.reduce(self, factor, box=reduce_box)
)
box = (
(box[0] - reduce_box[0]) / factor_x,
(box[1] - reduce_box[1]) / factor_y,
@ -2818,7 +2818,7 @@ class Image:
self.im.transform2(box, image.im, method, data, resample, fill)
def transpose(self, method):
def transpose(self, method: Transpose) -> Image:
"""
Transpose image (flip or rotate in 90 degree steps)
@ -2870,7 +2870,9 @@ class ImagePointHandler:
(for use with :py:meth:`~PIL.Image.Image.point`)
"""
pass
@abc.abstractmethod
def point(self, im: Image) -> Image:
pass
class ImageTransformHandler:
@ -3690,6 +3692,7 @@ class Exif(_ExifBase):
endian = None
bigtiff = False
_loaded = False
def __init__(self):
self._data = {}
@ -3805,7 +3808,7 @@ class Exif(_ExifBase):
return merged_dict
def tobytes(self, offset=8):
def tobytes(self, offset: int = 8) -> bytes:
from . import TiffImagePlugin
head = self._get_head()
@ -3960,7 +3963,7 @@ class Exif(_ExifBase):
del self._info[tag]
self._data[tag] = value
def __delitem__(self, tag):
def __delitem__(self, tag: int) -> None:
if self._info is not None and tag in self._info:
del self._info[tag]
else:

View File

@ -124,7 +124,7 @@ def getrgb(color):
@lru_cache
def getcolor(color, mode):
def getcolor(color, mode: str) -> tuple[int, ...]:
"""
Same as :py:func:`~PIL.ImageColor.getrgb` for most modes. However, if
``mode`` is HSV, converts the RGB value to a HSV value, or if ``mode`` is

View File

@ -21,6 +21,7 @@ from __future__ import annotations
import functools
import operator
import re
from typing import Protocol, Sequence, cast
from . import ExifTags, Image, ImagePalette
@ -28,7 +29,7 @@ from . import ExifTags, Image, ImagePalette
# helpers
def _border(border):
def _border(border: int | tuple[int, ...]) -> tuple[int, int, int, int]:
if isinstance(border, tuple):
if len(border) == 2:
left, top = right, bottom = border
@ -39,7 +40,7 @@ def _border(border):
return left, top, right, bottom
def _color(color, mode):
def _color(color: str | int | tuple[int, ...], mode: str) -> int | tuple[int, ...]:
if isinstance(color, str):
from . import ImageColor
@ -47,7 +48,7 @@ def _color(color, mode):
return color
def _lut(image, lut):
def _lut(image: Image.Image, lut: list[int]) -> Image.Image:
if image.mode == "P":
# FIXME: apply to lookup table, not image data
msg = "mode P support coming soon"
@ -65,7 +66,13 @@ def _lut(image, lut):
# actions
def autocontrast(image, cutoff=0, ignore=None, mask=None, preserve_tone=False):
def autocontrast(
image: Image.Image,
cutoff: float | tuple[float, float] = 0,
ignore: int | Sequence[int] | None = None,
mask: Image.Image | None = None,
preserve_tone: bool = False,
) -> Image.Image:
"""
Maximize (normalize) image contrast. This function calculates a
histogram of the input image (or mask region), removes ``cutoff`` percent of the
@ -97,10 +104,9 @@ def autocontrast(image, cutoff=0, ignore=None, mask=None, preserve_tone=False):
h = histogram[layer : layer + 256]
if ignore is not None:
# get rid of outliers
try:
if isinstance(ignore, int):
h[ignore] = 0
except TypeError:
# assume sequence
else:
for ix in ignore:
h[ix] = 0
if cutoff:
@ -112,7 +118,7 @@ def autocontrast(image, cutoff=0, ignore=None, mask=None, preserve_tone=False):
for ix in range(256):
n = n + h[ix]
# remove cutoff% pixels from the low end
cut = n * cutoff[0] // 100
cut = int(n * cutoff[0] // 100)
for lo in range(256):
if cut > h[lo]:
cut = cut - h[lo]
@ -123,7 +129,7 @@ def autocontrast(image, cutoff=0, ignore=None, mask=None, preserve_tone=False):
if cut <= 0:
break
# remove cutoff% samples from the high end
cut = n * cutoff[1] // 100
cut = int(n * cutoff[1] // 100)
for hi in range(255, -1, -1):
if cut > h[hi]:
cut = cut - h[hi]
@ -156,7 +162,15 @@ def autocontrast(image, cutoff=0, ignore=None, mask=None, preserve_tone=False):
return _lut(image, lut)
def colorize(image, black, white, mid=None, blackpoint=0, whitepoint=255, midpoint=127):
def colorize(
image: Image.Image,
black: str | tuple[int, ...],
white: str | tuple[int, ...],
mid: str | int | tuple[int, ...] | None = None,
blackpoint: int = 0,
whitepoint: int = 255,
midpoint: int = 127,
) -> Image.Image:
"""
Colorize grayscale image.
This function calculates a color wedge which maps all black pixels in
@ -188,10 +202,9 @@ def colorize(image, black, white, mid=None, blackpoint=0, whitepoint=255, midpoi
assert 0 <= blackpoint <= midpoint <= whitepoint <= 255
# Define colors from arguments
black = _color(black, "RGB")
white = _color(white, "RGB")
if mid is not None:
mid = _color(mid, "RGB")
rgb_black = cast(Sequence[int], _color(black, "RGB"))
rgb_white = cast(Sequence[int], _color(white, "RGB"))
rgb_mid = cast(Sequence[int], _color(mid, "RGB")) if mid is not None else None
# Empty lists for the mapping
red = []
@ -200,18 +213,24 @@ def colorize(image, black, white, mid=None, blackpoint=0, whitepoint=255, midpoi
# Create the low-end values
for i in range(0, blackpoint):
red.append(black[0])
green.append(black[1])
blue.append(black[2])
red.append(rgb_black[0])
green.append(rgb_black[1])
blue.append(rgb_black[2])
# Create the mapping (2-color)
if mid is None:
if rgb_mid is None:
range_map = range(0, whitepoint - blackpoint)
for i in range_map:
red.append(black[0] + i * (white[0] - black[0]) // len(range_map))
green.append(black[1] + i * (white[1] - black[1]) // len(range_map))
blue.append(black[2] + i * (white[2] - black[2]) // len(range_map))
red.append(
rgb_black[0] + i * (rgb_white[0] - rgb_black[0]) // len(range_map)
)
green.append(
rgb_black[1] + i * (rgb_white[1] - rgb_black[1]) // len(range_map)
)
blue.append(
rgb_black[2] + i * (rgb_white[2] - rgb_black[2]) // len(range_map)
)
# Create the mapping (3-color)
else:
@ -219,26 +238,36 @@ def colorize(image, black, white, mid=None, blackpoint=0, whitepoint=255, midpoi
range_map2 = range(0, whitepoint - midpoint)
for i in range_map1:
red.append(black[0] + i * (mid[0] - black[0]) // len(range_map1))
green.append(black[1] + i * (mid[1] - black[1]) // len(range_map1))
blue.append(black[2] + i * (mid[2] - black[2]) // len(range_map1))
red.append(
rgb_black[0] + i * (rgb_mid[0] - rgb_black[0]) // len(range_map1)
)
green.append(
rgb_black[1] + i * (rgb_mid[1] - rgb_black[1]) // len(range_map1)
)
blue.append(
rgb_black[2] + i * (rgb_mid[2] - rgb_black[2]) // len(range_map1)
)
for i in range_map2:
red.append(mid[0] + i * (white[0] - mid[0]) // len(range_map2))
green.append(mid[1] + i * (white[1] - mid[1]) // len(range_map2))
blue.append(mid[2] + i * (white[2] - mid[2]) // len(range_map2))
red.append(rgb_mid[0] + i * (rgb_white[0] - rgb_mid[0]) // len(range_map2))
green.append(
rgb_mid[1] + i * (rgb_white[1] - rgb_mid[1]) // len(range_map2)
)
blue.append(rgb_mid[2] + i * (rgb_white[2] - rgb_mid[2]) // len(range_map2))
# Create the high-end values
for i in range(0, 256 - whitepoint):
red.append(white[0])
green.append(white[1])
blue.append(white[2])
red.append(rgb_white[0])
green.append(rgb_white[1])
blue.append(rgb_white[2])
# Return converted image
image = image.convert("RGB")
return _lut(image, red + green + blue)
def contain(image, size, method=Image.Resampling.BICUBIC):
def contain(
image: Image.Image, size: tuple[int, int], method: int = Image.Resampling.BICUBIC
) -> Image.Image:
"""
Returns a resized version of the image, set to the maximum width and height
within the requested size, while maintaining the original aspect ratio.
@ -267,7 +296,9 @@ def contain(image, size, method=Image.Resampling.BICUBIC):
return image.resize(size, resample=method)
def cover(image, size, method=Image.Resampling.BICUBIC):
def cover(
image: Image.Image, size: tuple[int, int], method: int = Image.Resampling.BICUBIC
) -> Image.Image:
"""
Returns a resized version of the image, so that the requested size is
covered, while maintaining the original aspect ratio.
@ -296,7 +327,13 @@ def cover(image, size, method=Image.Resampling.BICUBIC):
return image.resize(size, resample=method)
def pad(image, size, method=Image.Resampling.BICUBIC, color=None, centering=(0.5, 0.5)):
def pad(
image: Image.Image,
size: tuple[int, int],
method: int = Image.Resampling.BICUBIC,
color: str | int | tuple[int, ...] | None = None,
centering: tuple[float, float] = (0.5, 0.5),
) -> Image.Image:
"""
Returns a resized and padded version of the image, expanded to fill the
requested aspect ratio and size.
@ -334,7 +371,7 @@ def pad(image, size, method=Image.Resampling.BICUBIC, color=None, centering=(0.5
return out
def crop(image, border=0):
def crop(image: Image.Image, border: int = 0) -> Image.Image:
"""
Remove border from image. The same amount of pixels are removed
from all four sides. This function works on all image modes.
@ -349,7 +386,9 @@ def crop(image, border=0):
return image.crop((left, top, image.size[0] - right, image.size[1] - bottom))
def scale(image, factor, resample=Image.Resampling.BICUBIC):
def scale(
image: Image.Image, factor: float, resample: int = Image.Resampling.BICUBIC
) -> Image.Image:
"""
Returns a rescaled image by a specific factor given in parameter.
A factor greater than 1 expands the image, between 0 and 1 contracts the
@ -372,7 +411,19 @@ def scale(image, factor, resample=Image.Resampling.BICUBIC):
return image.resize(size, resample)
def deform(image, deformer, resample=Image.Resampling.BILINEAR):
class _SupportsGetMesh(Protocol):
def getmesh(
self, image: Image.Image
) -> list[
tuple[tuple[int, int, int, int], tuple[int, int, int, int, int, int, int, int]]
]: ...
def deform(
image: Image.Image,
deformer: _SupportsGetMesh,
resample: int = Image.Resampling.BILINEAR,
) -> Image.Image:
"""
Deform the image.
@ -388,7 +439,7 @@ def deform(image, deformer, resample=Image.Resampling.BILINEAR):
)
def equalize(image, mask=None):
def equalize(image: Image.Image, mask: Image.Image | None = None) -> Image.Image:
"""
Equalize the image histogram. This function applies a non-linear
mapping to the input image, in order to create a uniform
@ -419,7 +470,11 @@ def equalize(image, mask=None):
return _lut(image, lut)
def expand(image, border=0, fill=0):
def expand(
image: Image.Image,
border: int | tuple[int, ...] = 0,
fill: str | int | tuple[int, ...] = 0,
) -> Image.Image:
"""
Add border to the image
@ -445,7 +500,13 @@ def expand(image, border=0, fill=0):
return out
def fit(image, size, method=Image.Resampling.BICUBIC, bleed=0.0, centering=(0.5, 0.5)):
def fit(
image: Image.Image,
size: tuple[int, int],
method: int = Image.Resampling.BICUBIC,
bleed: float = 0.0,
centering: tuple[float, float] = (0.5, 0.5),
) -> Image.Image:
"""
Returns a resized and cropped version of the image, cropped to the
requested aspect ratio and size.
@ -479,13 +540,12 @@ def fit(image, size, method=Image.Resampling.BICUBIC, bleed=0.0, centering=(0.5,
# kevin@cazabon.com
# https://www.cazabon.com
# ensure centering is mutable
centering = list(centering)
centering_x, centering_y = centering
if not 0.0 <= centering[0] <= 1.0:
centering[0] = 0.5
if not 0.0 <= centering[1] <= 1.0:
centering[1] = 0.5
if not 0.0 <= centering_x <= 1.0:
centering_x = 0.5
if not 0.0 <= centering_y <= 1.0:
centering_y = 0.5
if not 0.0 <= bleed < 0.5:
bleed = 0.0
@ -522,8 +582,8 @@ def fit(image, size, method=Image.Resampling.BICUBIC, bleed=0.0, centering=(0.5,
crop_height = live_size[0] / output_ratio
# make the crop
crop_left = bleed_pixels[0] + (live_size[0] - crop_width) * centering[0]
crop_top = bleed_pixels[1] + (live_size[1] - crop_height) * centering[1]
crop_left = bleed_pixels[0] + (live_size[0] - crop_width) * centering_x
crop_top = bleed_pixels[1] + (live_size[1] - crop_height) * centering_y
crop = (crop_left, crop_top, crop_left + crop_width, crop_top + crop_height)
@ -531,7 +591,7 @@ def fit(image, size, method=Image.Resampling.BICUBIC, bleed=0.0, centering=(0.5,
return image.resize(size, method, box=crop)
def flip(image):
def flip(image: Image.Image) -> Image.Image:
"""
Flip the image vertically (top to bottom).
@ -541,7 +601,7 @@ def flip(image):
return image.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
def grayscale(image):
def grayscale(image: Image.Image) -> Image.Image:
"""
Convert the image to grayscale.
@ -551,7 +611,7 @@ def grayscale(image):
return image.convert("L")
def invert(image):
def invert(image: Image.Image) -> Image.Image:
"""
Invert (negate) the image.
@ -562,7 +622,7 @@ def invert(image):
return image.point(lut) if image.mode == "1" else _lut(image, lut)
def mirror(image):
def mirror(image: Image.Image) -> Image.Image:
"""
Flip image horizontally (left to right).
@ -572,7 +632,7 @@ def mirror(image):
return image.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
def posterize(image, bits):
def posterize(image: Image.Image, bits: int) -> Image.Image:
"""
Reduce the number of bits for each color channel.
@ -585,7 +645,7 @@ def posterize(image, bits):
return _lut(image, lut)
def solarize(image, threshold=128):
def solarize(image: Image.Image, threshold: int = 128) -> Image.Image:
"""
Invert all pixel values above a threshold.
@ -602,7 +662,7 @@ def solarize(image, threshold=128):
return _lut(image, lut)
def exif_transpose(image, *, in_place=False):
def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image | None:
"""
If an image has an EXIF Orientation tag, other than 1, transpose the image
accordingly, and remove the orientation data.
@ -616,7 +676,7 @@ def exif_transpose(image, *, in_place=False):
"""
image.load()
image_exif = image.getexif()
orientation = image_exif.get(ExifTags.Base.Orientation)
orientation = image_exif.get(ExifTags.Base.Orientation, 1)
method = {
2: Image.Transpose.FLIP_LEFT_RIGHT,
3: Image.Transpose.ROTATE_180,
@ -653,3 +713,4 @@ def exif_transpose(image, *, in_place=False):
return transposed_image
elif not in_place:
return image.copy()
return None

View File

@ -18,6 +18,7 @@
from __future__ import annotations
import array
from typing import Sequence
from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile
@ -34,11 +35,11 @@ class ImagePalette:
Defaults to an empty palette.
"""
def __init__(self, mode="RGB", palette=None):
def __init__(self, mode: str = "RGB", palette: Sequence[int] | None = None) -> None:
self.mode = mode
self.rawmode = None # if set, palette contains raw data
self.palette = palette or bytearray()
self.dirty = None
self.dirty: int | None = None
@property
def palette(self):
@ -127,7 +128,7 @@ class ImagePalette:
raise ValueError(msg) from e
return index
def getcolor(self, color, image=None):
def getcolor(self, color, image=None) -> int:
"""Given an rgb tuple, allocate palette entry.
.. warning:: This method is experimental.