import unittest from contextlib import contextmanager from PIL import Image, ImageDraw from .helper import PillowTestCase, hopper class TestImagingResampleVulnerability(PillowTestCase): # see https://github.com/python-pillow/Pillow/issues/1710 def test_overflow(self): im = hopper("L") size_too_large = 0x100000008 // 4 size_normal = 1000 # unimportant for xsize, ysize in ( (size_too_large, size_normal), (size_normal, size_too_large), ): with self.assertRaises(MemoryError): # any resampling filter will do here im.im.resize((xsize, ysize), Image.BILINEAR) def test_invalid_size(self): im = hopper() # Should not crash im.resize((100, 100)) with self.assertRaises(ValueError): im.resize((-100, 100)) with self.assertRaises(ValueError): im.resize((100, -100)) def test_modify_after_resizing(self): im = hopper("RGB") # get copy with same size copy = im.resize(im.size) # some in-place operation copy.paste("black", (0, 0, im.width // 2, im.height // 2)) # image should be different self.assertNotEqual(im.tobytes(), copy.tobytes()) class TestImagingCoreResampleAccuracy(PillowTestCase): def make_case(self, mode, size, color): """Makes a sample image with two dark and two bright squares. For example: e0 e0 1f 1f e0 e0 1f 1f 1f 1f e0 e0 1f 1f e0 e0 """ case = Image.new("L", size, 255 - color) rectangle = ImageDraw.Draw(case).rectangle rectangle((0, 0, size[0] // 2 - 1, size[1] // 2 - 1), color) rectangle((size[0] // 2, size[1] // 2, size[0], size[1]), color) return Image.merge(mode, [case] * len(mode)) def make_sample(self, data, size): """Restores a sample image from given data string which contains hex-encoded pixels from the top left fourth of a sample. """ data = data.replace(" ", "") sample = Image.new("L", size) s_px = sample.load() w, h = size[0] // 2, size[1] // 2 for y in range(h): for x in range(w): val = int(data[(y * w + x) * 2 : (y * w + x + 1) * 2], 16) s_px[x, y] = val s_px[size[0] - x - 1, size[1] - y - 1] = val s_px[x, size[1] - y - 1] = 255 - val s_px[size[0] - x - 1, y] = 255 - val return sample def check_case(self, case, sample): s_px = sample.load() c_px = case.load() for y in range(case.size[1]): for x in range(case.size[0]): if c_px[x, y] != s_px[x, y]: message = "\nHave: \n{}\n\nExpected: \n{}".format( self.serialize_image(case), self.serialize_image(sample) ) self.assertEqual(s_px[x, y], c_px[x, y], message) def serialize_image(self, image): s_px = image.load() return "\n".join( " ".join("{:02x}".format(s_px[x, y]) for x in range(image.size[0])) for y in range(image.size[1]) ) def test_reduce_box(self): for mode in ["RGBX", "RGB", "La", "L"]: case = self.make_case(mode, (8, 8), 0xE1) case = case.resize((4, 4), Image.BOX) # fmt: off data = ("e1 e1" "e1 e1") # fmt: on for channel in case.split(): self.check_case(channel, self.make_sample(data, (4, 4))) def test_reduce_bilinear(self): for mode in ["RGBX", "RGB", "La", "L"]: case = self.make_case(mode, (8, 8), 0xE1) case = case.resize((4, 4), Image.BILINEAR) # fmt: off data = ("e1 c9" "c9 b7") # fmt: on for channel in case.split(): self.check_case(channel, self.make_sample(data, (4, 4))) def test_reduce_hamming(self): for mode in ["RGBX", "RGB", "La", "L"]: case = self.make_case(mode, (8, 8), 0xE1) case = case.resize((4, 4), Image.HAMMING) # fmt: off data = ("e1 da" "da d3") # fmt: on for channel in case.split(): self.check_case(channel, self.make_sample(data, (4, 4))) def test_reduce_bicubic(self): for mode in ["RGBX", "RGB", "La", "L"]: case = self.make_case(mode, (12, 12), 0xE1) case = case.resize((6, 6), Image.BICUBIC) # fmt: off data = ("e1 e3 d4" "e3 e5 d6" "d4 d6 c9") # fmt: on for channel in case.split(): self.check_case(channel, self.make_sample(data, (6, 6))) def test_reduce_lanczos(self): for mode in ["RGBX", "RGB", "La", "L"]: case = self.make_case(mode, (16, 16), 0xE1) case = case.resize((8, 8), Image.LANCZOS) # fmt: off data = ("e1 e0 e4 d7" "e0 df e3 d6" "e4 e3 e7 da" "d7 d6 d9 ce") # fmt: on for channel in case.split(): self.check_case(channel, self.make_sample(data, (8, 8))) def test_enlarge_box(self): for mode in ["RGBX", "RGB", "La", "L"]: case = self.make_case(mode, (2, 2), 0xE1) case = case.resize((4, 4), Image.BOX) # fmt: off data = ("e1 e1" "e1 e1") # fmt: on for channel in case.split(): self.check_case(channel, self.make_sample(data, (4, 4))) def test_enlarge_bilinear(self): for mode in ["RGBX", "RGB", "La", "L"]: case = self.make_case(mode, (2, 2), 0xE1) case = case.resize((4, 4), Image.BILINEAR) # fmt: off data = ("e1 b0" "b0 98") # fmt: on for channel in case.split(): self.check_case(channel, self.make_sample(data, (4, 4))) def test_enlarge_hamming(self): for mode in ["RGBX", "RGB", "La", "L"]: case = self.make_case(mode, (2, 2), 0xE1) case = case.resize((4, 4), Image.HAMMING) # fmt: off data = ("e1 d2" "d2 c5") # fmt: on for channel in case.split(): self.check_case(channel, self.make_sample(data, (4, 4))) def test_enlarge_bicubic(self): for mode in ["RGBX", "RGB", "La", "L"]: case = self.make_case(mode, (4, 4), 0xE1) case = case.resize((8, 8), Image.BICUBIC) # fmt: off data = ("e1 e5 ee b9" "e5 e9 f3 bc" "ee f3 fd c1" "b9 bc c1 a2") # fmt: on for channel in case.split(): self.check_case(channel, self.make_sample(data, (8, 8))) def test_enlarge_lanczos(self): for mode in ["RGBX", "RGB", "La", "L"]: case = self.make_case(mode, (6, 6), 0xE1) case = case.resize((12, 12), Image.LANCZOS) data = ( "e1 e0 db ed f5 b8" "e0 df da ec f3 b7" "db db d6 e7 ee b5" "ed ec e6 fb ff bf" "f5 f4 ee ff ff c4" "b8 b7 b4 bf c4 a0" ) for channel in case.split(): self.check_case(channel, self.make_sample(data, (12, 12))) class CoreResampleConsistencyTest(PillowTestCase): def make_case(self, mode, fill): im = Image.new(mode, (512, 9), fill) return im.resize((9, 512), Image.LANCZOS), im.load()[0, 0] def run_case(self, case): channel, color = case px = channel.load() for x in range(channel.size[0]): for y in range(channel.size[1]): if px[x, y] != color: message = "{} != {} for pixel {}".format(px[x, y], color, (x, y)) self.assertEqual(px[x, y], color, message) def test_8u(self): im, color = self.make_case("RGB", (0, 64, 255)) r, g, b = im.split() self.run_case((r, color[0])) self.run_case((g, color[1])) self.run_case((b, color[2])) self.run_case(self.make_case("L", 12)) def test_32i(self): self.run_case(self.make_case("I", 12)) self.run_case(self.make_case("I", 0x7FFFFFFF)) self.run_case(self.make_case("I", -12)) self.run_case(self.make_case("I", -1 << 31)) def test_32f(self): self.run_case(self.make_case("F", 1)) self.run_case(self.make_case("F", 3.40282306074e38)) self.run_case(self.make_case("F", 1.175494e-38)) self.run_case(self.make_case("F", 1.192093e-07)) class CoreResampleAlphaCorrectTest(PillowTestCase): def make_levels_case(self, mode): i = Image.new(mode, (256, 16)) px = i.load() for y in range(i.size[1]): for x in range(i.size[0]): pix = [x] * len(mode) pix[-1] = 255 - y * 16 px[x, y] = tuple(pix) return i def run_levels_case(self, i): px = i.load() for y in range(i.size[1]): used_colors = {px[x, y][0] for x in range(i.size[0])} self.assertEqual( 256, len(used_colors), "All colors should present in resized image. " "Only {} on {} line.".format(len(used_colors), y), ) @unittest.skip("current implementation isn't precise enough") def test_levels_rgba(self): case = self.make_levels_case("RGBA") self.run_levels_case(case.resize((512, 32), Image.BOX)) self.run_levels_case(case.resize((512, 32), Image.BILINEAR)) self.run_levels_case(case.resize((512, 32), Image.HAMMING)) self.run_levels_case(case.resize((512, 32), Image.BICUBIC)) self.run_levels_case(case.resize((512, 32), Image.LANCZOS)) @unittest.skip("current implementation isn't precise enough") def test_levels_la(self): case = self.make_levels_case("LA") self.run_levels_case(case.resize((512, 32), Image.BOX)) self.run_levels_case(case.resize((512, 32), Image.BILINEAR)) self.run_levels_case(case.resize((512, 32), Image.HAMMING)) self.run_levels_case(case.resize((512, 32), Image.BICUBIC)) self.run_levels_case(case.resize((512, 32), Image.LANCZOS)) def make_dirty_case(self, mode, clean_pixel, dirty_pixel): i = Image.new(mode, (64, 64), dirty_pixel) px = i.load() xdiv4 = i.size[0] // 4 ydiv4 = i.size[1] // 4 for y in range(ydiv4 * 2): for x in range(xdiv4 * 2): px[x + xdiv4, y + ydiv4] = clean_pixel return i def run_dirty_case(self, i, clean_pixel): px = i.load() for y in range(i.size[1]): for x in range(i.size[0]): if px[x, y][-1] != 0 and px[x, y][:-1] != clean_pixel: message = "pixel at ({}, {}) is differ:\n{}\n{}".format( x, y, px[x, y], clean_pixel ) self.assertEqual(px[x, y][:3], clean_pixel, message) def test_dirty_pixels_rgba(self): case = self.make_dirty_case("RGBA", (255, 255, 0, 128), (0, 0, 255, 0)) self.run_dirty_case(case.resize((20, 20), Image.BOX), (255, 255, 0)) self.run_dirty_case(case.resize((20, 20), Image.BILINEAR), (255, 255, 0)) self.run_dirty_case(case.resize((20, 20), Image.HAMMING), (255, 255, 0)) self.run_dirty_case(case.resize((20, 20), Image.BICUBIC), (255, 255, 0)) self.run_dirty_case(case.resize((20, 20), Image.LANCZOS), (255, 255, 0)) def test_dirty_pixels_la(self): case = self.make_dirty_case("LA", (255, 128), (0, 0)) self.run_dirty_case(case.resize((20, 20), Image.BOX), (255,)) self.run_dirty_case(case.resize((20, 20), Image.BILINEAR), (255,)) self.run_dirty_case(case.resize((20, 20), Image.HAMMING), (255,)) self.run_dirty_case(case.resize((20, 20), Image.BICUBIC), (255,)) self.run_dirty_case(case.resize((20, 20), Image.LANCZOS), (255,)) class CoreResamplePassesTest(PillowTestCase): @contextmanager def count(self, diff): count = Image.core.get_stats()["new_count"] yield self.assertEqual(Image.core.get_stats()["new_count"] - count, diff) def test_horizontal(self): im = hopper("L") with self.count(1): im.resize((im.size[0] - 10, im.size[1]), Image.BILINEAR) def test_vertical(self): im = hopper("L") with self.count(1): im.resize((im.size[0], im.size[1] - 10), Image.BILINEAR) def test_both(self): im = hopper("L") with self.count(2): im.resize((im.size[0] - 10, im.size[1] - 10), Image.BILINEAR) def test_box_horizontal(self): im = hopper("L") box = (20, 0, im.size[0] - 20, im.size[1]) with self.count(1): # the same size, but different box with_box = im.resize(im.size, Image.BILINEAR, box) with self.count(2): cropped = im.crop(box).resize(im.size, Image.BILINEAR) self.assert_image_similar(with_box, cropped, 0.1) def test_box_vertical(self): im = hopper("L") box = (0, 20, im.size[0], im.size[1] - 20) with self.count(1): # the same size, but different box with_box = im.resize(im.size, Image.BILINEAR, box) with self.count(2): cropped = im.crop(box).resize(im.size, Image.BILINEAR) self.assert_image_similar(with_box, cropped, 0.1) class CoreResampleCoefficientsTest(PillowTestCase): def test_reduce(self): test_color = 254 for size in range(400000, 400010, 2): i = Image.new("L", (size, 1), 0) draw = ImageDraw.Draw(i) draw.rectangle((0, 0, i.size[0] // 2 - 1, 0), test_color) px = i.resize((5, i.size[1]), Image.BICUBIC).load() if px[2, 0] != test_color // 2: self.assertEqual(test_color // 2, px[2, 0]) def test_nonzero_coefficients(self): # regression test for the wrong coefficients calculation # due to bug https://github.com/python-pillow/Pillow/issues/2161 im = Image.new("RGBA", (1280, 1280), (0x20, 0x40, 0x60, 0xFF)) histogram = im.resize((256, 256), Image.BICUBIC).histogram() # first channel self.assertEqual(histogram[0x100 * 0 + 0x20], 0x10000) # second channel self.assertEqual(histogram[0x100 * 1 + 0x40], 0x10000) # third channel self.assertEqual(histogram[0x100 * 2 + 0x60], 0x10000) # fourth channel self.assertEqual(histogram[0x100 * 3 + 0xFF], 0x10000) class CoreResampleBoxTest(PillowTestCase): def test_wrong_arguments(self): im = hopper() for resample in ( Image.NEAREST, Image.BOX, Image.BILINEAR, Image.HAMMING, Image.BICUBIC, Image.LANCZOS, ): im.resize((32, 32), resample, (0, 0, im.width, im.height)) im.resize((32, 32), resample, (20, 20, im.width, im.height)) im.resize((32, 32), resample, (20, 20, 20, 100)) im.resize((32, 32), resample, (20, 20, 100, 20)) with self.assertRaisesRegex(TypeError, "must be sequence of length 4"): im.resize((32, 32), resample, (im.width, im.height)) with self.assertRaisesRegex(ValueError, "can't be negative"): im.resize((32, 32), resample, (-20, 20, 100, 100)) with self.assertRaisesRegex(ValueError, "can't be negative"): im.resize((32, 32), resample, (20, -20, 100, 100)) with self.assertRaisesRegex(ValueError, "can't be empty"): im.resize((32, 32), resample, (20.1, 20, 20, 100)) with self.assertRaisesRegex(ValueError, "can't be empty"): im.resize((32, 32), resample, (20, 20.1, 100, 20)) with self.assertRaisesRegex(ValueError, "can't be empty"): im.resize((32, 32), resample, (20.1, 20.1, 20, 20)) with self.assertRaisesRegex(ValueError, "can't exceed"): im.resize((32, 32), resample, (0, 0, im.width + 1, im.height)) with self.assertRaisesRegex(ValueError, "can't exceed"): im.resize((32, 32), resample, (0, 0, im.width, im.height + 1)) def resize_tiled(self, im, dst_size, xtiles, ytiles): def split_range(size, tiles): scale = size / tiles for i in range(tiles): yield (int(round(scale * i)), int(round(scale * (i + 1)))) tiled = Image.new(im.mode, dst_size) scale = (im.size[0] / tiled.size[0], im.size[1] / tiled.size[1]) for y0, y1 in split_range(dst_size[1], ytiles): for x0, x1 in split_range(dst_size[0], xtiles): box = (x0 * scale[0], y0 * scale[1], x1 * scale[0], y1 * scale[1]) tile = im.resize((x1 - x0, y1 - y0), Image.BICUBIC, box) tiled.paste(tile, (x0, y0)) return tiled def test_tiles(self): im = Image.open("Tests/images/flower.jpg") self.assertEqual(im.size, (480, 360)) dst_size = (251, 188) reference = im.resize(dst_size, Image.BICUBIC) for tiles in [(1, 1), (3, 3), (9, 7), (100, 100)]: tiled = self.resize_tiled(im, dst_size, *tiles) self.assert_image_similar(reference, tiled, 0.01) def test_subsample(self): # This test shows advantages of the subpixel resizing # after supersampling (e.g. during JPEG decoding). im = Image.open("Tests/images/flower.jpg") self.assertEqual(im.size, (480, 360)) dst_size = (48, 36) # Reference is cropped image resized to destination reference = im.crop((0, 0, 473, 353)).resize(dst_size, Image.BICUBIC) # Image.BOX emulates supersampling (480 / 8 = 60, 360 / 8 = 45) supersampled = im.resize((60, 45), Image.BOX) with_box = supersampled.resize(dst_size, Image.BICUBIC, (0, 0, 59.125, 44.125)) without_box = supersampled.resize(dst_size, Image.BICUBIC) # error with box should be much smaller than without self.assert_image_similar(reference, with_box, 6) with self.assertRaisesRegex(AssertionError, r"difference 29\."): self.assert_image_similar(reference, without_box, 5) def test_formats(self): for resample in [Image.NEAREST, Image.BILINEAR]: for mode in ["RGB", "L", "RGBA", "LA", "I", ""]: im = hopper(mode) box = (20, 20, im.size[0] - 20, im.size[1] - 20) with_box = im.resize((32, 32), resample, box) cropped = im.crop(box).resize((32, 32), resample) self.assert_image_similar(cropped, with_box, 0.4) def test_passthrough(self): # When no resize is required im = hopper() for size, box in [ ((40, 50), (0, 0, 40, 50)), ((40, 50), (0, 10, 40, 60)), ((40, 50), (10, 0, 50, 50)), ((40, 50), (10, 20, 50, 70)), ]: try: res = im.resize(size, Image.LANCZOS, box) self.assertEqual(res.size, size) self.assert_image_equal(res, im.crop(box)) except AssertionError: print(">>>", size, box) raise def test_no_passthrough(self): # When resize is required im = hopper() for size, box in [ ((40, 50), (0.4, 0.4, 40.4, 50.4)), ((40, 50), (0.4, 10.4, 40.4, 60.4)), ((40, 50), (10.4, 0.4, 50.4, 50.4)), ((40, 50), (10.4, 20.4, 50.4, 70.4)), ]: try: res = im.resize(size, Image.LANCZOS, box) self.assertEqual(res.size, size) with self.assertRaisesRegex(AssertionError, r"difference \d"): # check that the difference at least that much self.assert_image_similar(res, im.crop(box), 20) except AssertionError: print(">>>", size, box) raise def test_skip_horizontal(self): # Can skip resize for one dimension im = hopper() for flt in [Image.NEAREST, Image.BICUBIC]: for size, box in [ ((40, 50), (0, 0, 40, 90)), ((40, 50), (0, 20, 40, 90)), ((40, 50), (10, 0, 50, 90)), ((40, 50), (10, 20, 50, 90)), ]: try: res = im.resize(size, flt, box) self.assertEqual(res.size, size) # Borders should be slightly different self.assert_image_similar(res, im.crop(box).resize(size, flt), 0.4) except AssertionError: print(">>>", size, box, flt) raise def test_skip_vertical(self): # Can skip resize for one dimension im = hopper() for flt in [Image.NEAREST, Image.BICUBIC]: for size, box in [ ((40, 50), (0, 0, 90, 50)), ((40, 50), (20, 0, 90, 50)), ((40, 50), (0, 10, 90, 60)), ((40, 50), (20, 10, 90, 60)), ]: try: res = im.resize(size, flt, box) self.assertEqual(res.size, size) # Borders should be slightly different self.assert_image_similar(res, im.crop(box).resize(size, flt), 0.4) except AssertionError: print(">>>", size, box, flt) raise