mirror of
https://github.com/python-pillow/Pillow.git
synced 2024-12-25 17:36:18 +03:00
4aa24f88d9
Co-authored-by: Andrew Murray <radarhere@users.noreply.github.com>
593 lines
18 KiB
Python
593 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from PIL import Image, ImageDraw, ImageOps, ImageStat, features
|
|
|
|
from .helper import (
|
|
assert_image_equal,
|
|
assert_image_similar,
|
|
assert_image_similar_tofile,
|
|
assert_tuple_approx_equal,
|
|
hopper,
|
|
)
|
|
|
|
|
|
class Deformer(ImageOps.SupportsGetMesh):
|
|
def getmesh(
|
|
self, im: Image.Image
|
|
) -> list[
|
|
tuple[tuple[int, int, int, int], tuple[int, int, int, int, int, int, int, int]]
|
|
]:
|
|
x, y = im.size
|
|
return [((0, 0, x, y), (0, 0, x, 0, x, y, y, 0))]
|
|
|
|
|
|
deformer = Deformer()
|
|
|
|
|
|
def test_sanity() -> None:
|
|
ImageOps.autocontrast(hopper("L"))
|
|
ImageOps.autocontrast(hopper("RGB"))
|
|
|
|
ImageOps.autocontrast(hopper("L"), cutoff=10)
|
|
ImageOps.autocontrast(hopper("L"), cutoff=(2, 10))
|
|
ImageOps.autocontrast(hopper("L"), ignore=[0, 255])
|
|
ImageOps.autocontrast(hopper("L"), mask=hopper("L"))
|
|
ImageOps.autocontrast(hopper("L"), preserve_tone=True)
|
|
|
|
ImageOps.colorize(hopper("L"), (0, 0, 0), (255, 255, 255))
|
|
ImageOps.colorize(hopper("L"), "black", "white")
|
|
|
|
ImageOps.pad(hopper("L"), (128, 128))
|
|
ImageOps.pad(hopper("RGB"), (128, 128))
|
|
|
|
ImageOps.contain(hopper("L"), (128, 128))
|
|
ImageOps.contain(hopper("RGB"), (128, 128))
|
|
|
|
ImageOps.cover(hopper("L"), (128, 128))
|
|
ImageOps.cover(hopper("RGB"), (128, 128))
|
|
|
|
ImageOps.crop(hopper("L"), 1)
|
|
ImageOps.crop(hopper("RGB"), 1)
|
|
|
|
ImageOps.deform(hopper("L"), deformer)
|
|
ImageOps.deform(hopper("RGB"), deformer)
|
|
|
|
ImageOps.equalize(hopper("L"))
|
|
ImageOps.equalize(hopper("RGB"))
|
|
|
|
ImageOps.expand(hopper("L"), 1)
|
|
ImageOps.expand(hopper("RGB"), 1)
|
|
ImageOps.expand(hopper("L"), 2, "blue")
|
|
ImageOps.expand(hopper("RGB"), 2, "blue")
|
|
|
|
ImageOps.fit(hopper("L"), (128, 128))
|
|
ImageOps.fit(hopper("RGB"), (128, 128))
|
|
|
|
ImageOps.flip(hopper("L"))
|
|
ImageOps.flip(hopper("RGB"))
|
|
|
|
ImageOps.grayscale(hopper("L"))
|
|
ImageOps.grayscale(hopper("RGB"))
|
|
|
|
ImageOps.invert(hopper("1"))
|
|
ImageOps.invert(hopper("L"))
|
|
ImageOps.invert(hopper("RGB"))
|
|
|
|
ImageOps.mirror(hopper("L"))
|
|
ImageOps.mirror(hopper("RGB"))
|
|
|
|
ImageOps.posterize(hopper("L"), 4)
|
|
ImageOps.posterize(hopper("RGB"), 4)
|
|
|
|
ImageOps.solarize(hopper("L"))
|
|
ImageOps.solarize(hopper("RGB"))
|
|
|
|
ImageOps.exif_transpose(hopper("L"))
|
|
ImageOps.exif_transpose(hopper("RGB"))
|
|
|
|
|
|
def test_1pxfit() -> None:
|
|
# Division by zero in equalize if image is 1 pixel high
|
|
newimg = ImageOps.fit(hopper("RGB").resize((1, 1)), (35, 35))
|
|
assert newimg.size == (35, 35)
|
|
|
|
newimg = ImageOps.fit(hopper("RGB").resize((1, 100)), (35, 35))
|
|
assert newimg.size == (35, 35)
|
|
|
|
newimg = ImageOps.fit(hopper("RGB").resize((100, 1)), (35, 35))
|
|
assert newimg.size == (35, 35)
|
|
|
|
|
|
def test_fit_same_ratio() -> None:
|
|
# The ratio for this image is 1000.0 / 755 = 1.3245033112582782
|
|
# If the ratios are not acknowledged to be the same,
|
|
# and Pillow attempts to adjust the width to
|
|
# 1.3245033112582782 * 755 = 1000.0000000000001
|
|
# then centering this greater width causes a negative x offset when cropping
|
|
with Image.new("RGB", (1000, 755)) as im:
|
|
new_im = ImageOps.fit(im, (1000, 755))
|
|
assert new_im.size == (1000, 755)
|
|
|
|
|
|
@pytest.mark.parametrize("new_size", ((256, 256), (512, 256), (256, 512)))
|
|
def test_contain(new_size: tuple[int, int]) -> None:
|
|
im = hopper()
|
|
new_im = ImageOps.contain(im, new_size)
|
|
assert new_im.size == (256, 256)
|
|
|
|
|
|
def test_contain_round() -> None:
|
|
im = Image.new("1", (43, 63), 1)
|
|
new_im = ImageOps.contain(im, (5, 7))
|
|
assert new_im.width == 5
|
|
|
|
im = Image.new("1", (63, 43), 1)
|
|
new_im = ImageOps.contain(im, (7, 5))
|
|
assert new_im.height == 5
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"image_name, expected_size",
|
|
(
|
|
("colr_bungee.png", (1024, 256)), # landscape
|
|
("imagedraw_stroke_multiline.png", (256, 640)), # portrait
|
|
("hopper.png", (256, 256)), # square
|
|
),
|
|
)
|
|
def test_cover(image_name: str, expected_size: tuple[int, int]) -> None:
|
|
with Image.open("Tests/images/" + image_name) as im:
|
|
new_im = ImageOps.cover(im, (256, 256))
|
|
assert new_im.size == expected_size
|
|
|
|
|
|
def test_pad() -> None:
|
|
# Same ratio
|
|
im = hopper()
|
|
new_size = (im.width * 2, im.height * 2)
|
|
new_im = ImageOps.pad(im, new_size)
|
|
assert new_im.size == new_size
|
|
|
|
for label, color, new_size in [
|
|
("h", None, (im.width * 4, im.height * 2)),
|
|
("v", "#f00", (im.width * 2, im.height * 4)),
|
|
]:
|
|
for i, centering in enumerate([(0, 0), (0.5, 0.5), (1, 1)]):
|
|
new_im = ImageOps.pad(im, new_size, color=color, centering=centering)
|
|
assert new_im.size == new_size
|
|
|
|
assert_image_similar_tofile(
|
|
new_im, "Tests/images/imageops_pad_" + label + "_" + str(i) + ".jpg", 6
|
|
)
|
|
|
|
|
|
def test_pad_round() -> None:
|
|
im = Image.new("1", (1, 1), 1)
|
|
new_im = ImageOps.pad(im, (4, 1))
|
|
px = new_im.load()
|
|
assert px is not None
|
|
assert px[2, 0] == 1
|
|
|
|
new_im = ImageOps.pad(im, (1, 4))
|
|
px = new_im.load()
|
|
assert px is not None
|
|
assert px[0, 2] == 1
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ("P", "PA"))
|
|
def test_palette(mode: str) -> None:
|
|
im = hopper(mode)
|
|
|
|
# Expand
|
|
expanded_im = ImageOps.expand(im)
|
|
assert_image_equal(im.convert("RGB"), expanded_im.convert("RGB"))
|
|
|
|
# Pad
|
|
padded_im = ImageOps.pad(im, (256, 128), centering=(0, 0))
|
|
assert_image_equal(
|
|
im.convert("RGB"), padded_im.convert("RGB").crop((0, 0, 128, 128))
|
|
)
|
|
|
|
|
|
def test_pil163() -> None:
|
|
# Division by zero in equalize if < 255 pixels in image (@PIL163)
|
|
|
|
i = hopper("RGB").resize((15, 16))
|
|
|
|
ImageOps.equalize(i.convert("L"))
|
|
ImageOps.equalize(i.convert("P"))
|
|
ImageOps.equalize(i.convert("RGB"))
|
|
|
|
|
|
def test_scale() -> None:
|
|
# Test the scaling function
|
|
i = hopper("L").resize((50, 50))
|
|
|
|
with pytest.raises(ValueError):
|
|
ImageOps.scale(i, -1)
|
|
|
|
newimg = ImageOps.scale(i, 1)
|
|
assert newimg.size == (50, 50)
|
|
|
|
newimg = ImageOps.scale(i, 2)
|
|
assert newimg.size == (100, 100)
|
|
|
|
newimg = ImageOps.scale(i, 0.5)
|
|
assert newimg.size == (25, 25)
|
|
|
|
|
|
@pytest.mark.parametrize("border", (10, (1, 2, 3, 4)))
|
|
def test_expand_palette(border: int | tuple[int, int, int, int]) -> None:
|
|
with Image.open("Tests/images/p_16.tga") as im:
|
|
im_expanded = ImageOps.expand(im, border, (255, 0, 0))
|
|
|
|
if isinstance(border, int):
|
|
left = top = right = bottom = border
|
|
else:
|
|
left, top, right, bottom = border
|
|
px = im_expanded.convert("RGB").load()
|
|
assert px is not None
|
|
for x in range(im_expanded.width):
|
|
for b in range(top):
|
|
assert px[x, b] == (255, 0, 0)
|
|
for b in range(bottom):
|
|
assert px[x, im_expanded.height - 1 - b] == (255, 0, 0)
|
|
for y in range(im_expanded.height):
|
|
for b in range(left):
|
|
assert px[b, y] == (255, 0, 0)
|
|
for b in range(right):
|
|
assert px[im_expanded.width - 1 - b, y] == (255, 0, 0)
|
|
|
|
im_cropped = im_expanded.crop(
|
|
(left, top, im_expanded.width - right, im_expanded.height - bottom)
|
|
)
|
|
assert_image_equal(im_cropped, im)
|
|
|
|
|
|
def test_colorize_2color() -> None:
|
|
# Test the colorizing function with 2-color functionality
|
|
|
|
# Open test image (256px by 10px, black to white)
|
|
with Image.open("Tests/images/bw_gradient.png") as im:
|
|
im = im.convert("L")
|
|
|
|
# Create image with original 2-color functionality
|
|
im_test = ImageOps.colorize(im, "red", "green")
|
|
|
|
# Test output image (2-color)
|
|
left = (0, 1)
|
|
middle = (127, 1)
|
|
right = (255, 1)
|
|
value = im_test.getpixel(left)
|
|
assert isinstance(value, tuple)
|
|
assert_tuple_approx_equal(
|
|
value,
|
|
(255, 0, 0),
|
|
threshold=1,
|
|
msg="black test pixel incorrect",
|
|
)
|
|
value = im_test.getpixel(middle)
|
|
assert isinstance(value, tuple)
|
|
assert_tuple_approx_equal(
|
|
value,
|
|
(127, 63, 0),
|
|
threshold=1,
|
|
msg="mid test pixel incorrect",
|
|
)
|
|
value = im_test.getpixel(right)
|
|
assert isinstance(value, tuple)
|
|
assert_tuple_approx_equal(
|
|
value,
|
|
(0, 127, 0),
|
|
threshold=1,
|
|
msg="white test pixel incorrect",
|
|
)
|
|
|
|
|
|
def test_colorize_2color_offset() -> None:
|
|
# Test the colorizing function with 2-color functionality and offset
|
|
|
|
# Open test image (256px by 10px, black to white)
|
|
with Image.open("Tests/images/bw_gradient.png") as im:
|
|
im = im.convert("L")
|
|
|
|
# Create image with original 2-color functionality with offsets
|
|
im_test = ImageOps.colorize(
|
|
im, black="red", white="green", blackpoint=50, whitepoint=100
|
|
)
|
|
|
|
# Test output image (2-color) with offsets
|
|
left = (25, 1)
|
|
middle = (75, 1)
|
|
right = (125, 1)
|
|
value = im_test.getpixel(left)
|
|
assert isinstance(value, tuple)
|
|
assert_tuple_approx_equal(
|
|
value,
|
|
(255, 0, 0),
|
|
threshold=1,
|
|
msg="black test pixel incorrect",
|
|
)
|
|
value = im_test.getpixel(middle)
|
|
assert isinstance(value, tuple)
|
|
assert_tuple_approx_equal(
|
|
value,
|
|
(127, 63, 0),
|
|
threshold=1,
|
|
msg="mid test pixel incorrect",
|
|
)
|
|
value = im_test.getpixel(right)
|
|
assert isinstance(value, tuple)
|
|
assert_tuple_approx_equal(
|
|
value,
|
|
(0, 127, 0),
|
|
threshold=1,
|
|
msg="white test pixel incorrect",
|
|
)
|
|
|
|
|
|
def test_colorize_3color_offset() -> None:
|
|
# Test the colorizing function with 3-color functionality and offset
|
|
|
|
# Open test image (256px by 10px, black to white)
|
|
with Image.open("Tests/images/bw_gradient.png") as im:
|
|
im = im.convert("L")
|
|
|
|
# Create image with new three color functionality with offsets
|
|
im_test = ImageOps.colorize(
|
|
im,
|
|
black="red",
|
|
white="green",
|
|
mid="blue",
|
|
blackpoint=50,
|
|
whitepoint=200,
|
|
midpoint=100,
|
|
)
|
|
|
|
# Test output image (3-color) with offsets
|
|
left = (25, 1)
|
|
left_middle = (75, 1)
|
|
middle = (100, 1)
|
|
right_middle = (150, 1)
|
|
right = (225, 1)
|
|
value = im_test.getpixel(left)
|
|
assert isinstance(value, tuple)
|
|
assert_tuple_approx_equal(
|
|
value,
|
|
(255, 0, 0),
|
|
threshold=1,
|
|
msg="black test pixel incorrect",
|
|
)
|
|
value = im_test.getpixel(left_middle)
|
|
assert isinstance(value, tuple)
|
|
assert_tuple_approx_equal(
|
|
value,
|
|
(127, 0, 127),
|
|
threshold=1,
|
|
msg="low-mid test pixel incorrect",
|
|
)
|
|
value = im_test.getpixel(middle)
|
|
assert isinstance(value, tuple)
|
|
assert_tuple_approx_equal(value, (0, 0, 255), threshold=1, msg="mid incorrect")
|
|
value = im_test.getpixel(right_middle)
|
|
assert isinstance(value, tuple)
|
|
assert_tuple_approx_equal(
|
|
value,
|
|
(0, 63, 127),
|
|
threshold=1,
|
|
msg="high-mid test pixel incorrect",
|
|
)
|
|
value = im_test.getpixel(right)
|
|
assert isinstance(value, tuple)
|
|
assert_tuple_approx_equal(
|
|
value,
|
|
(0, 127, 0),
|
|
threshold=1,
|
|
msg="white test pixel incorrect",
|
|
)
|
|
|
|
|
|
def test_exif_transpose() -> None:
|
|
exts = [".jpg"]
|
|
if features.check("webp") and features.check("webp_anim"):
|
|
exts.append(".webp")
|
|
for ext in exts:
|
|
with Image.open("Tests/images/hopper" + ext) as base_im:
|
|
|
|
def check(orientation_im: Image.Image) -> None:
|
|
for im in [
|
|
orientation_im,
|
|
orientation_im.copy(),
|
|
]: # ImageFile # Image
|
|
if orientation_im is base_im:
|
|
assert "exif" not in im.info
|
|
else:
|
|
original_exif = im.info["exif"]
|
|
transposed_im = ImageOps.exif_transpose(im)
|
|
assert transposed_im is not None
|
|
assert_image_similar(base_im, transposed_im, 17)
|
|
if orientation_im is base_im:
|
|
assert "exif" not in im.info
|
|
else:
|
|
assert transposed_im.info["exif"] != original_exif
|
|
|
|
assert 0x0112 in im.getexif()
|
|
assert 0x0112 not in transposed_im.getexif()
|
|
|
|
# Repeat the operation to test that it does not keep transposing
|
|
transposed_im2 = ImageOps.exif_transpose(transposed_im)
|
|
assert transposed_im2 is not None
|
|
assert_image_equal(transposed_im2, transposed_im)
|
|
|
|
check(base_im)
|
|
for i in range(2, 9):
|
|
with Image.open(
|
|
"Tests/images/hopper_orientation_" + str(i) + ext
|
|
) as orientation_im:
|
|
check(orientation_im)
|
|
|
|
# Orientation from "XML:com.adobe.xmp" info key
|
|
for suffix in ("", "_exiftool"):
|
|
with Image.open("Tests/images/xmp_tags_orientation" + suffix + ".png") as im:
|
|
assert im.getexif()[0x0112] == 3
|
|
|
|
transposed_im = ImageOps.exif_transpose(im)
|
|
assert transposed_im is not None
|
|
assert 0x0112 not in transposed_im.getexif()
|
|
|
|
transposed_im._reload_exif()
|
|
assert 0x0112 not in transposed_im.getexif()
|
|
|
|
# Orientation from "Raw profile type exif" info key
|
|
# This test image has been manually hexedited from exif_imagemagick.png
|
|
# to have a different orientation
|
|
with Image.open("Tests/images/exif_imagemagick_orientation.png") as im:
|
|
assert im.getexif()[0x0112] == 3
|
|
|
|
transposed_im = ImageOps.exif_transpose(im)
|
|
assert transposed_im is not None
|
|
assert 0x0112 not in transposed_im.getexif()
|
|
|
|
# Orientation set directly on Image.Exif
|
|
im = hopper()
|
|
im.getexif()[0x0112] = 3
|
|
transposed_im = ImageOps.exif_transpose(im)
|
|
assert transposed_im is not None
|
|
assert 0x0112 not in transposed_im.getexif()
|
|
|
|
|
|
def test_exif_transpose_xml_without_xmp() -> None:
|
|
with Image.open("Tests/images/xmp_tags_orientation.png") as im:
|
|
assert im.getexif()[0x0112] == 3
|
|
assert "XML:com.adobe.xmp" in im.info
|
|
|
|
del im.info["xmp"]
|
|
transposed_im = ImageOps.exif_transpose(im)
|
|
assert transposed_im is not None
|
|
assert 0x0112 not in transposed_im.getexif()
|
|
|
|
|
|
def test_exif_transpose_in_place() -> None:
|
|
with Image.open("Tests/images/orientation_rectangle.jpg") as im:
|
|
assert im.size == (2, 1)
|
|
assert im.getexif()[0x0112] == 8
|
|
expected = im.rotate(90, expand=True)
|
|
|
|
ImageOps.exif_transpose(im, in_place=True)
|
|
assert im.size == (1, 2)
|
|
assert 0x0112 not in im.getexif()
|
|
assert_image_equal(im, expected)
|
|
|
|
|
|
def test_autocontrast_unsupported_mode() -> None:
|
|
im = Image.new("RGBA", (1, 1))
|
|
with pytest.raises(OSError):
|
|
ImageOps.autocontrast(im)
|
|
|
|
|
|
def test_autocontrast_cutoff() -> None:
|
|
# Test the cutoff argument of autocontrast
|
|
with Image.open("Tests/images/bw_gradient.png") as img:
|
|
|
|
def autocontrast(cutoff: int | tuple[int, int]) -> list[int]:
|
|
return ImageOps.autocontrast(img, cutoff).histogram()
|
|
|
|
assert autocontrast(10) == autocontrast((10, 10))
|
|
assert autocontrast(10) != autocontrast((1, 10))
|
|
|
|
|
|
def test_autocontrast_mask_toy_input() -> None:
|
|
# Test the mask argument of autocontrast
|
|
with Image.open("Tests/images/bw_gradient.png") as img:
|
|
rect_mask = Image.new("L", img.size, 0)
|
|
draw = ImageDraw.Draw(rect_mask)
|
|
x0 = img.size[0] // 4
|
|
y0 = img.size[1] // 4
|
|
x1 = 3 * img.size[0] // 4
|
|
y1 = 3 * img.size[1] // 4
|
|
draw.rectangle((x0, y0, x1, y1), fill=255)
|
|
|
|
result = ImageOps.autocontrast(img, mask=rect_mask)
|
|
result_nomask = ImageOps.autocontrast(img)
|
|
|
|
assert result != result_nomask
|
|
assert ImageStat.Stat(result, mask=rect_mask).median == [127]
|
|
assert ImageStat.Stat(result_nomask).median == [128]
|
|
|
|
|
|
def test_autocontrast_mask_real_input() -> None:
|
|
# Test the autocontrast with a rectangular mask
|
|
with Image.open("Tests/images/iptc.jpg") as img:
|
|
rect_mask = Image.new("L", img.size, 0)
|
|
draw = ImageDraw.Draw(rect_mask)
|
|
x0, y0 = img.size[0] // 2, img.size[1] // 2
|
|
x1, y1 = img.size[0] - 40, img.size[1]
|
|
draw.rectangle((x0, y0, x1, y1), fill=255)
|
|
|
|
result = ImageOps.autocontrast(img, mask=rect_mask)
|
|
result_nomask = ImageOps.autocontrast(img)
|
|
|
|
assert result_nomask != result
|
|
assert_tuple_approx_equal(
|
|
ImageStat.Stat(result, mask=rect_mask).median,
|
|
(195, 202, 184),
|
|
threshold=2,
|
|
msg="autocontrast with mask pixel incorrect",
|
|
)
|
|
assert_tuple_approx_equal(
|
|
ImageStat.Stat(result_nomask).median,
|
|
(119, 106, 79),
|
|
threshold=2,
|
|
msg="autocontrast without mask pixel incorrect",
|
|
)
|
|
|
|
|
|
def test_autocontrast_preserve_tone() -> None:
|
|
def autocontrast(mode: str, preserve_tone: bool) -> list[int]:
|
|
im = hopper(mode)
|
|
return ImageOps.autocontrast(im, preserve_tone=preserve_tone).histogram()
|
|
|
|
assert autocontrast("RGB", True) != autocontrast("RGB", False)
|
|
assert autocontrast("L", True) == autocontrast("L", False)
|
|
|
|
|
|
def test_autocontrast_preserve_gradient() -> None:
|
|
gradient = Image.linear_gradient("L")
|
|
|
|
# test with a grayscale gradient that extends to 0,255.
|
|
# Should be a noop.
|
|
out = ImageOps.autocontrast(gradient, cutoff=0, preserve_tone=True)
|
|
|
|
assert_image_equal(gradient, out)
|
|
|
|
# cutoff the top and bottom
|
|
# autocontrast should make the first and last histogram entries equal
|
|
# and, with rounding, should be 10% of the image pixels
|
|
out = ImageOps.autocontrast(gradient, cutoff=10, preserve_tone=True)
|
|
hist = out.histogram()
|
|
assert hist[0] == hist[-1]
|
|
assert hist[-1] == 256 * round(256 * 0.10)
|
|
|
|
# in rgb
|
|
img = gradient.convert("RGB")
|
|
out = ImageOps.autocontrast(img, cutoff=0, preserve_tone=True)
|
|
assert_image_equal(img, out)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"color", ((255, 255, 255), (127, 255, 0), (127, 127, 127), (0, 0, 0))
|
|
)
|
|
def test_autocontrast_preserve_one_color(color: tuple[int, int, int]) -> None:
|
|
img = Image.new("RGB", (10, 10), color)
|
|
|
|
# single color images shouldn't change
|
|
out = ImageOps.autocontrast(img, cutoff=0, preserve_tone=True)
|
|
assert_image_equal(img, out) # single color, no cutoff
|
|
|
|
# even if there is a cutoff
|
|
out = ImageOps.autocontrast(
|
|
img, cutoff=10, preserve_tone=True
|
|
) # single color 10 cutoff
|
|
assert_image_equal(img, out)
|