diff --git a/Tests/images/radial_gradients.png b/Tests/images/radial_gradients.png new file mode 100644 index 000000000..39a02fbbf Binary files /dev/null and b/Tests/images/radial_gradients.png differ diff --git a/Tests/test_image_reduce.py b/Tests/test_image_reduce.py index 73bfa6498..993d51d15 100644 --- a/Tests/test_image_reduce.py +++ b/Tests/test_image_reduce.py @@ -1,6 +1,6 @@ -from PIL import Image +from PIL import Image, ImageMode, ImageMath -from .helper import PillowTestCase, hopper +from .helper import PillowTestCase, hopper, convert_to_comparable class TestImageReduce(PillowTestCase): @@ -10,11 +10,16 @@ class TestImageReduce(PillowTestCase): (1, 2), (1, 3), (1, 4), # 1xN implementation (2, 1), (3, 1), (4, 1), # Nx1 implementation # general implementation with different paths - (4, 6), (5, 6), (4, 7), (5, 7), + (4, 6), (5, 6), (4, 7), (5, 7), (19, 17), ] + @classmethod + def setUpClass(cls): + cls.gradients_image = Image.open("Tests/images/radial_gradients.png") + cls.gradients_image.load() + def test_args(self): - im = Image.new('L', (10, 10)) + im = Image.new("L", (10, 10)) self.assertEqual((4, 4), im.reduce(3).size) self.assertEqual((4, 10), im.reduce((3, 1)).size) @@ -27,35 +32,114 @@ class TestImageReduce(PillowTestCase): with self.assertRaises(ValueError): im.reduce((0, 10)) - def check_correctness(self, im, factor): + def get_image(self, mode): + bands = [self.gradients_image] + for _ in ImageMode.getmode(mode).bands[1:]: + # rotate previous image + band = bands[-1].transpose(Image.ROTATE_90) + bands.append(band) + # Correct alpha channel to exclude completely transparent pixels. + # Low alpha values also emphasize error after alpha multiplication. + if mode.endswith('A'): + bands[-1] = bands[-1].point(lambda x: int(85 + x / 1.5)) + return Image.merge(mode, bands) + + def compare_reduce_with_reference(self, im, factor, average_diff=0.4, max_diff=1): + """Image.reduce() should look very similar to Image.resize(BOX). + + A reference image is compiled from a large source area + and possible last column and last row. + +-----------+ + |..........c| + |..........c| + |..........c| + |rrrrrrrrrrp| + +-----------+ + """ + reduced = im.reduce(factor) + if not isinstance(factor, (list, tuple)): factor = (factor, factor) - # Image.reduce() should works very similar to Image.resize(BOX) - # when image size is dividable by the factor. - desired_size = (im.width // factor[0], im.height // factor[1]) - im = im.crop((0, 0, desired_size[0] * factor[0], desired_size[1] * factor[1])) + reference = Image.new(im.mode, reduced.size) + area_size = (im.size[0] // factor[0], im.size[1] // factor[1]) + area_box = (0, 0, area_size[0] * factor[0], area_size[1] * factor[1]) + area = im.resize(area_size, Image.BOX, area_box) + reference.paste(area, (0, 0)) - reduced = im.reduce(factor) - resized = im.resize(desired_size, Image.BOX) + if area_size[0] < reduced.size[0]: + self.assertEqual(reduced.size[0] - area_size[0], 1) + last_column_box = (area_box[2], 0, im.size[0], area_box[3]) + last_column = im.resize((1, area_size[1]), Image.BOX, last_column_box) + reference.paste(last_column, (area_size[0], 0)) - epsilon = 0.5 * len(reduced.getbands()) - self.assert_image_similar(reduced, resized, epsilon) + if area_size[1] < reduced.size[1]: + self.assertEqual(reduced.size[1] - area_size[1], 1) + last_row_box = (0, area_box[3], area_box[2], im.size[1]) + last_row = im.resize((area_size[0], 1), Image.BOX, last_row_box) + reference.paste(last_row, (0, area_size[1])) - def test_mode_RGB(self): - im = hopper('RGB') - for factor in self.remarkable_factors: - self.check_correctness(im, factor) + if area_size[0] < reduced.size[0]: + last_pixel_box = (area_box[2], area_box[3], im.size[0], im.size[1]) + last_pixel = im.resize((1, 1), Image.BOX, last_pixel_box) + reference.paste(last_pixel, area_size) + + self.assert_compare_images(reduced, reference, average_diff, max_diff) + + def assert_compare_images(self, a, b, max_average_diff, max_diff=255): + self.assertEqual( + a.mode, b.mode, "got mode %r, expected %r" % (a.mode, b.mode)) + self.assertEqual( + a.size, b.size, "got size %r, expected %r" % (a.size, b.size)) + + a, b = convert_to_comparable(a, b) + + bands = ImageMode.getmode(a.mode).bands + for band, ach, bch in zip(bands, a.split(), b.split()): + ch_diff = ImageMath.eval("convert(abs(a - b), 'L')", a=ach, b=bch) + ch_hist = ch_diff.histogram() + + average_diff = (sum(i * num for i, num in enumerate(ch_hist)) + / float(a.size[0] * a.size[1])) + self.assertGreaterEqual( + max_average_diff, average_diff, + ("average pixel value difference {:.4f} > expected {:.4f} " + "for '{}' band").format(average_diff, max_average_diff, band), + ) + + last_diff = [i for i, num in enumerate(ch_hist) if num > 0][-1] + self.assertGreaterEqual( + max_diff, last_diff, + "max pixel value difference {} > expected {} for '{}' band" + .format(last_diff, max_diff, band), + ) def test_mode_LA(self): - im = Image.open("Tests/images/transparent.png").convert('LA') + im = self.get_image("LA") for factor in self.remarkable_factors: - print(factor) - self.check_correctness(im, factor) + self.compare_reduce_with_reference(im, factor, 0.8, 5) + + # With opaque alpha, error should be way smaller + im.putalpha(Image.new('L', im.size, 255)) + for factor in self.remarkable_factors: + self.compare_reduce_with_reference(im, factor) + + def test_mode_RGB(self): + im = self.get_image("RGB") + for factor in self.remarkable_factors: + self.compare_reduce_with_reference(im, factor) def test_mode_RGBA(self): - im = Image.open("Tests/images/transparent.png").convert('RGBA') + im = self.get_image("RGBA") for factor in self.remarkable_factors: - print(factor) - self.check_correctness(im, factor) + self.compare_reduce_with_reference(im, factor, 0.8, 5) + # With opaque alpha, error should be way smaller + im.putalpha(Image.new('L', im.size, 255)) + for factor in self.remarkable_factors: + self.compare_reduce_with_reference(im, factor) + + def test_mode_CMYK(self): + im = self.get_image("CMYK") + for factor in self.remarkable_factors: + self.compare_reduce_with_reference(im, factor)