From 5e5dfbad81152d8a5cfbcb556a1d9e58a86f8bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dag=20W=C3=A4stberg?= Date: Fri, 22 Nov 2019 14:03:59 +0100 Subject: [PATCH 01/27] add hardlight and softlight chops --- Tests/test_imagechops.py | 49 ++++++++++++++++++++++++++++++++-------- src/PIL/ImageChops.py | 21 +++++++++++++++++ src/_imaging.c | 24 ++++++++++++++++++++ src/libImaging/Chops.c | 18 +++++++++++++++ src/libImaging/Imaging.h | 2 ++ 5 files changed, 104 insertions(+), 10 deletions(-) diff --git a/Tests/test_imagechops.py b/Tests/test_imagechops.py index 8dec6a1d5..82e469cc1 100644 --- a/Tests/test_imagechops.py +++ b/Tests/test_imagechops.py @@ -38,6 +38,9 @@ def test_sanity(): ImageChops.blend(im, im, 0.5) ImageChops.composite(im, im, im) + ImageChops.softlight(im, im) + ImageChops.hardlight(im, im) + ImageChops.offset(im, 10) ImageChops.offset(im, 10, 20) @@ -319,9 +322,9 @@ def test_subtract_scale_offset(): # Act new = ImageChops.subtract(im1, im2, scale=2.5, offset=100) - # Assert - assert new.getbbox() == (0, 0, 100, 100) - assert new.getpixel((50, 50)) == (100, 202, 100) + # Assert + assert new.getbbox() == (0, 0, 100, 100) + assert new.getpixel((50, 50)) == (100, 202, 100) def test_subtract_clip(): @@ -332,8 +335,8 @@ def test_subtract_clip(): # Act new = ImageChops.subtract(im1, im2) - # Assert - assert new.getpixel((50, 50)) == (0, 0, 127) + # Assert + assert new.getpixel((50, 50)) == (0, 0, 127) def test_subtract_modulo(): @@ -344,10 +347,10 @@ def test_subtract_modulo(): # Act new = ImageChops.subtract_modulo(im1, im2) - # Assert - assert new.getbbox() == (25, 50, 76, 76) - assert new.getpixel((50, 50)) == GREEN - assert new.getpixel((50, 51)) == BLACK + # Assert + assert new.getbbox() == (25, 50, 76, 76) + assert new.getpixel((50, 50)) == GREEN + assert new.getpixel((50, 51)) == BLACK def test_subtract_modulo_no_clip(): @@ -358,8 +361,34 @@ def test_subtract_modulo_no_clip(): # Act new = ImageChops.subtract_modulo(im1, im2) + # Assert + assert new.getpixel((50, 50)) == (241, 167, 127) + + +def test_softlight(self): + # Arrange + im1 = Image.open("Tests/images/hopper.png") + im2 = Image.open("Tests/images/hopper-XYZ.png") + + # Act + new = ImageChops.softlight(im1, im2) + # Assert - assert new.getpixel((50, 50)) == (241, 167, 127) + self.assertEqual(new.getpixel((64, 64)), (163, 54, 32)) + self.assertEqual(new.getpixel((15, 100)), (1, 1, 3)) + + +def test_hardlight(self): + # Arrange + im1 = Image.open("Tests/images/hopper.png") + im2 = Image.open("Tests/images/hopper-XYZ.png") + + # Act + new = ImageChops.hardlight(im1, im2) + + # Assert + self.assertEqual(new.getpixel((64, 64)), (144, 50, 27)) + self.assertEqual(new.getpixel((15, 100)), (1, 1, 2)) def test_logical(): diff --git a/src/PIL/ImageChops.py b/src/PIL/ImageChops.py index b1f71b5e7..d33186fb2 100644 --- a/src/PIL/ImageChops.py +++ b/src/PIL/ImageChops.py @@ -138,6 +138,27 @@ def screen(image1, image2): image2.load() return image1._new(image1.im.chop_screen(image2.im)) +def softlight(image1, image2): + """ + Superimposes two images on top of each other using the Soft Light algorithm + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_softlight(image2.im)) + +def hardlight(image1, image2): + """ + Superimposes two images on top of each other using the Hard Light algorithm + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_hardlight(image2.im)) def add(image1, image2, scale=1.0, offset=0): """ diff --git a/src/_imaging.c b/src/_imaging.c index 190b312bc..c146a975f 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -2406,6 +2406,27 @@ _chop_subtract_modulo(ImagingObject* self, PyObject* args) return PyImagingNew(ImagingChopSubtractModulo(self->image, imagep->image)); } +static PyObject* +_chop_softlight(ImagingObject* self, PyObject* args) +{ + ImagingObject* imagep; + + if (!PyArg_ParseTuple(args, "O!", &Imaging_Type, &imagep)) + return NULL; + + return PyImagingNew(ImagingChopSoftLight(self->image, imagep->image)); +} + +static PyObject* +_chop_hardlight(ImagingObject* self, PyObject* args) +{ + ImagingObject* imagep; + + if (!PyArg_ParseTuple(args, "O!", &Imaging_Type, &imagep)) + return NULL; + + return PyImagingNew(ImagingChopHardLight(self->image, imagep->image)); +} #endif @@ -3325,6 +3346,9 @@ static struct PyMethodDef methods[] = { {"chop_and", (PyCFunction)_chop_and, 1}, {"chop_or", (PyCFunction)_chop_or, 1}, {"chop_xor", (PyCFunction)_chop_xor, 1}, + {"chop_softlight", (PyCFunction)_chop_softlight, 1}, + {"chop_hardlight", (PyCFunction)_chop_hardlight, 1}, + #endif #ifdef WITH_UNSHARPMASK diff --git a/src/libImaging/Chops.c b/src/libImaging/Chops.c index 8059b6ffb..302a8c2b9 100644 --- a/src/libImaging/Chops.c +++ b/src/libImaging/Chops.c @@ -146,3 +146,21 @@ ImagingChopSubtractModulo(Imaging imIn1, Imaging imIn2) { CHOP2(in1[x] - in2[x], NULL); } + +Imaging +ImagingChopSoftLight(Imaging imIn1, Imaging imIn2) +{ + // CHOP2( ( ( (255-in1[x]) * (in1[x]*in2[x]) ) / 65536) + + // ((in1[x] * (255 - ((255 - in1[1]) * (255 - in2[x]) / 255 ) )) / 255), NULL ); + CHOP2( (((255-in1[x]) * (in1[x]*in2[x]) ) / 65536) + + (in1[x] * ( 255 - ( (255 - in1[x]) * (255 - in2[x] ) / 255) )) / 255 + , NULL ); +} + +Imaging +ImagingChopHardLight(Imaging imIn1, Imaging imIn2) +{ + CHOP2( (in2[x]<128) ? ( (in1[x]*in2[x])/127) + : 255 - ( ((255-in2[x]) * (255-in1[x])) / 127) + , NULL); +} \ No newline at end of file diff --git a/src/libImaging/Imaging.h b/src/libImaging/Imaging.h index 71dc9c003..99ce6d6d6 100644 --- a/src/libImaging/Imaging.h +++ b/src/libImaging/Imaging.h @@ -339,6 +339,8 @@ extern Imaging ImagingChopSubtract( Imaging imIn1, Imaging imIn2, float scale, int offset); extern Imaging ImagingChopAddModulo(Imaging imIn1, Imaging imIn2); extern Imaging ImagingChopSubtractModulo(Imaging imIn1, Imaging imIn2); +extern Imaging ImagingChopSoftLight(Imaging imIn1, Imaging imIn2); +extern Imaging ImagingChopHardLight(Imaging imIn1, Imaging imIn2); /* "1" images only */ extern Imaging ImagingChopAnd(Imaging imIn1, Imaging imIn2); From c8a46ef387a8981c93ecfa0050c87d4b2c074ded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dag=20W=C3=A4stberg?= Date: Fri, 22 Nov 2019 14:09:41 +0100 Subject: [PATCH 02/27] update docs --- docs/reference/ImageChops.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/reference/ImageChops.rst b/docs/reference/ImageChops.rst index 6c8f11253..d9b14002d 100644 --- a/docs/reference/ImageChops.rst +++ b/docs/reference/ImageChops.rst @@ -36,6 +36,8 @@ operations in this module). .. autofunction:: PIL.ImageChops.logical_or .. autofunction:: PIL.ImageChops.logical_xor .. autofunction:: PIL.ImageChops.multiply +.. autofunction:: PIL.ImageChops.softlight +.. autofunction:: PIL.ImageChops.hardlight .. py:method:: PIL.ImageChops.offset(image, xoffset, yoffset=None) Returns a copy of the image where data has been offset by the given From 13c1b7070d26d0cc76eab802a85e4024ef1485c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dag=20W=C3=A4stberg?= Date: Fri, 22 Nov 2019 14:30:43 +0100 Subject: [PATCH 03/27] add Overlay chop --- Tests/test_imagechops.py | 14 ++++++++++++++ docs/reference/ImageChops.rst | 1 + src/PIL/ImageChops.py | 11 +++++++++++ src/_imaging.c | 12 ++++++++++++ src/libImaging/Chops.c | 8 ++++++++ src/libImaging/Imaging.h | 1 + 6 files changed, 47 insertions(+) diff --git a/Tests/test_imagechops.py b/Tests/test_imagechops.py index 82e469cc1..cfdf9365d 100644 --- a/Tests/test_imagechops.py +++ b/Tests/test_imagechops.py @@ -40,6 +40,7 @@ def test_sanity(): ImageChops.softlight(im, im) ImageChops.hardlight(im, im) + ImageChops.overlay(im, im) ImageChops.offset(im, 10) ImageChops.offset(im, 10, 20) @@ -391,6 +392,19 @@ def test_hardlight(self): self.assertEqual(new.getpixel((15, 100)), (1, 1, 2)) +def test_overlay(self): + # Arrange + im1 = Image.open("Tests/images/hopper.png") + im2 = Image.open("Tests/images/hopper-XYZ.png") + + # Act + new = ImageChops.overlay(im1, im2) + + # Assert + self.assertEqual(new.getpixel((64, 64)), (159, 50, 27)) + self.assertEqual(new.getpixel((15, 100)), (1, 1, 2)) + + def test_logical(): def table(op, a, b): out = [] diff --git a/docs/reference/ImageChops.rst b/docs/reference/ImageChops.rst index d9b14002d..31142cc3f 100644 --- a/docs/reference/ImageChops.rst +++ b/docs/reference/ImageChops.rst @@ -38,6 +38,7 @@ operations in this module). .. autofunction:: PIL.ImageChops.multiply .. autofunction:: PIL.ImageChops.softlight .. autofunction:: PIL.ImageChops.hardlight +.. autofunction:: PIL.ImageChops.overlay .. py:method:: PIL.ImageChops.offset(image, xoffset, yoffset=None) Returns a copy of the image where data has been offset by the given diff --git a/src/PIL/ImageChops.py b/src/PIL/ImageChops.py index d33186fb2..bfc6e80d8 100644 --- a/src/PIL/ImageChops.py +++ b/src/PIL/ImageChops.py @@ -160,6 +160,17 @@ def hardlight(image1, image2): image2.load() return image1._new(image1.im.chop_hardlight(image2.im)) +def overlay(image1, image2): + """ + Superimposes two images on top of each other using the Overlay algorithm + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_overlay(image2.im)) + def add(image1, image2, scale=1.0, offset=0): """ Adds two images, dividing the result by scale and adding the diff --git a/src/_imaging.c b/src/_imaging.c index c146a975f..7875ae278 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -2427,6 +2427,17 @@ _chop_hardlight(ImagingObject* self, PyObject* args) return PyImagingNew(ImagingChopHardLight(self->image, imagep->image)); } + +static PyObject* +_chop_overlay(ImagingObject* self, PyObject* args) +{ + ImagingObject* imagep; + + if (!PyArg_ParseTuple(args, "O!", &Imaging_Type, &imagep)) + return NULL; + + return PyImagingNew(ImagingOverlay(self->image, imagep->image)); +} #endif @@ -3348,6 +3359,7 @@ static struct PyMethodDef methods[] = { {"chop_xor", (PyCFunction)_chop_xor, 1}, {"chop_softlight", (PyCFunction)_chop_softlight, 1}, {"chop_hardlight", (PyCFunction)_chop_hardlight, 1}, + {"chop_overlay", (PyCFunction)_chop_overlay, 1}, #endif diff --git a/src/libImaging/Chops.c b/src/libImaging/Chops.c index 302a8c2b9..cbd65b196 100644 --- a/src/libImaging/Chops.c +++ b/src/libImaging/Chops.c @@ -163,4 +163,12 @@ ImagingChopHardLight(Imaging imIn1, Imaging imIn2) CHOP2( (in2[x]<128) ? ( (in1[x]*in2[x])/127) : 255 - ( ((255-in2[x]) * (255-in1[x])) / 127) , NULL); +} + +Imaging +ImagingOverlay(Imaging imIn1, Imaging imIn2) +{ + CHOP2( (in1[x]<128) ? ( (in1[x]*in2[x])/127) + : 255 - ( ((255-in1[x]) * (255-in2[x])) / 127) + , NULL); } \ No newline at end of file diff --git a/src/libImaging/Imaging.h b/src/libImaging/Imaging.h index 99ce6d6d6..9032fcf07 100644 --- a/src/libImaging/Imaging.h +++ b/src/libImaging/Imaging.h @@ -341,6 +341,7 @@ extern Imaging ImagingChopAddModulo(Imaging imIn1, Imaging imIn2); extern Imaging ImagingChopSubtractModulo(Imaging imIn1, Imaging imIn2); extern Imaging ImagingChopSoftLight(Imaging imIn1, Imaging imIn2); extern Imaging ImagingChopHardLight(Imaging imIn1, Imaging imIn2); +extern Imaging ImagingOverlay(Imaging imIn1, Imaging imIn2); /* "1" images only */ extern Imaging ImagingChopAnd(Imaging imIn1, Imaging imIn2); From e18e96d736c6e0e829dad22963af709e9cb34040 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dag=20W=C3=A4stberg?= Date: Fri, 22 Nov 2019 14:47:51 +0100 Subject: [PATCH 04/27] fix formatting --- src/PIL/ImageChops.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/PIL/ImageChops.py b/src/PIL/ImageChops.py index bfc6e80d8..ec9c6b59d 100644 --- a/src/PIL/ImageChops.py +++ b/src/PIL/ImageChops.py @@ -138,6 +138,7 @@ def screen(image1, image2): image2.load() return image1._new(image1.im.chop_screen(image2.im)) + def softlight(image1, image2): """ Superimposes two images on top of each other using the Soft Light algorithm @@ -149,6 +150,7 @@ def softlight(image1, image2): image2.load() return image1._new(image1.im.chop_softlight(image2.im)) + def hardlight(image1, image2): """ Superimposes two images on top of each other using the Hard Light algorithm @@ -160,6 +162,7 @@ def hardlight(image1, image2): image2.load() return image1._new(image1.im.chop_hardlight(image2.im)) + def overlay(image1, image2): """ Superimposes two images on top of each other using the Overlay algorithm @@ -171,6 +174,7 @@ def overlay(image1, image2): image2.load() return image1._new(image1.im.chop_overlay(image2.im)) + def add(image1, image2, scale=1.0, offset=0): """ Adds two images, dividing the result by scale and adding the From 319f5409fe3bb211ab8846ebd5f43375fe6d9caa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dag=20W=C3=A4stberg?= Date: Fri, 22 Nov 2019 14:54:00 +0100 Subject: [PATCH 05/27] fix formatting --- src/PIL/ImageChops.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PIL/ImageChops.py b/src/PIL/ImageChops.py index ec9c6b59d..1dc456156 100644 --- a/src/PIL/ImageChops.py +++ b/src/PIL/ImageChops.py @@ -141,7 +141,7 @@ def screen(image1, image2): def softlight(image1, image2): """ - Superimposes two images on top of each other using the Soft Light algorithm + Superimposes two images on top of each other using the Soft Light algorithm :rtype: :py:class:`~PIL.Image.Image` """ @@ -153,7 +153,7 @@ def softlight(image1, image2): def hardlight(image1, image2): """ - Superimposes two images on top of each other using the Hard Light algorithm + Superimposes two images on top of each other using the Hard Light algorithm :rtype: :py:class:`~PIL.Image.Image` """ @@ -165,7 +165,7 @@ def hardlight(image1, image2): def overlay(image1, image2): """ - Superimposes two images on top of each other using the Overlay algorithm + Superimposes two images on top of each other using the Overlay algorithm :rtype: :py:class:`~PIL.Image.Image` """ From 705140cc2cd0da4724f8792d8400be6ab4a39a25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dag=20W=C3=A4stberg?= Date: Fri, 22 Nov 2019 14:03:59 +0100 Subject: [PATCH 06/27] add hardlight and softlight chops --- Tests/test_imagechops.py | 15 +-------------- src/PIL/ImageChops.py | 21 +++++++++++++++++++++ src/libImaging/Chops.c | 18 +++++++++--------- 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/Tests/test_imagechops.py b/Tests/test_imagechops.py index cfdf9365d..f22c9b058 100644 --- a/Tests/test_imagechops.py +++ b/Tests/test_imagechops.py @@ -363,7 +363,7 @@ def test_subtract_modulo_no_clip(): new = ImageChops.subtract_modulo(im1, im2) # Assert - assert new.getpixel((50, 50)) == (241, 167, 127) + self.assertEqual(new.getpixel((50, 50)), (241, 167, 127)) def test_softlight(self): @@ -392,19 +392,6 @@ def test_hardlight(self): self.assertEqual(new.getpixel((15, 100)), (1, 1, 2)) -def test_overlay(self): - # Arrange - im1 = Image.open("Tests/images/hopper.png") - im2 = Image.open("Tests/images/hopper-XYZ.png") - - # Act - new = ImageChops.overlay(im1, im2) - - # Assert - self.assertEqual(new.getpixel((64, 64)), (159, 50, 27)) - self.assertEqual(new.getpixel((15, 100)), (1, 1, 2)) - - def test_logical(): def table(op, a, b): out = [] diff --git a/src/PIL/ImageChops.py b/src/PIL/ImageChops.py index 1dc456156..01e560d76 100644 --- a/src/PIL/ImageChops.py +++ b/src/PIL/ImageChops.py @@ -138,6 +138,27 @@ def screen(image1, image2): image2.load() return image1._new(image1.im.chop_screen(image2.im)) +def softlight(image1, image2): + """ + Superimposes two images on top of each other using the Soft Light algorithm + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_softlight(image2.im)) + +def hardlight(image1, image2): + """ + Superimposes two images on top of each other using the Hard Light algorithm + + :rtype: :py:class:`~PIL.Image.Image` + """ + + image1.load() + image2.load() + return image1._new(image1.im.chop_hardlight(image2.im)) def softlight(image1, image2): """ diff --git a/src/libImaging/Chops.c b/src/libImaging/Chops.c index cbd65b196..6a9bf0069 100644 --- a/src/libImaging/Chops.c +++ b/src/libImaging/Chops.c @@ -148,27 +148,27 @@ ImagingChopSubtractModulo(Imaging imIn1, Imaging imIn2) } Imaging -ImagingChopSoftLight(Imaging imIn1, Imaging imIn2) +ImagingChopSoftLight(Imaging imIn1, Imaging imIn2) { // CHOP2( ( ( (255-in1[x]) * (in1[x]*in2[x]) ) / 65536) + // ((in1[x] * (255 - ((255 - in1[1]) * (255 - in2[x]) / 255 ) )) / 255), NULL ); - CHOP2( (((255-in1[x]) * (in1[x]*in2[x]) ) / 65536) + + CHOP2( (((255-in1[x]) * (in1[x]*in2[x]) ) / 65536) + (in1[x] * ( 255 - ( (255 - in1[x]) * (255 - in2[x] ) / 255) )) / 255 , NULL ); } Imaging -ImagingChopHardLight(Imaging imIn1, Imaging imIn2) +ImagingChopHardLight(Imaging imIn1, Imaging imIn2) { - CHOP2( (in2[x]<128) ? ( (in1[x]*in2[x])/127) - : 255 - ( ((255-in2[x]) * (255-in1[x])) / 127) + CHOP2( (in2[x]<128) ? ( (in1[x]*in2[x])/127) + : 255 - ( ((255-in2[x]) * (255-in1[x])) / 127) , NULL); } Imaging -ImagingOverlay(Imaging imIn1, Imaging imIn2) +ImagingOverlay(Imaging imIn1, Imaging imIn2) { - CHOP2( (in1[x]<128) ? ( (in1[x]*in2[x])/127) - : 255 - ( ((255-in1[x]) * (255-in2[x])) / 127) + CHOP2( (in1[x]<128) ? ( (in1[x]*in2[x])/127) + : 255 - ( ((255-in1[x]) * (255-in2[x])) / 127) , NULL); -} \ No newline at end of file +} From 23c9da5264e65f7e82c3270884eae6b12a6ec0fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dag=20W=C3=A4stberg?= Date: Fri, 22 Nov 2019 14:30:43 +0100 Subject: [PATCH 07/27] add Overlay chop --- Tests/test_imagechops.py | 13 +++++++++++++ src/PIL/ImageChops.py | 7 +++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Tests/test_imagechops.py b/Tests/test_imagechops.py index f22c9b058..022c72567 100644 --- a/Tests/test_imagechops.py +++ b/Tests/test_imagechops.py @@ -392,6 +392,19 @@ def test_hardlight(self): self.assertEqual(new.getpixel((15, 100)), (1, 1, 2)) +def test_overlay(self): + # Arrange + im1 = Image.open("Tests/images/hopper.png") + im2 = Image.open("Tests/images/hopper-XYZ.png") + + # Act + new = ImageChops.overlay(im1, im2) + + # Assert + self.assertEqual(new.getpixel((64, 64)), (159, 50, 27)) + self.assertEqual(new.getpixel((15, 100)), (1, 1, 2)) + + def test_logical(): def table(op, a, b): out = [] diff --git a/src/PIL/ImageChops.py b/src/PIL/ImageChops.py index 01e560d76..751152f71 100644 --- a/src/PIL/ImageChops.py +++ b/src/PIL/ImageChops.py @@ -138,9 +138,10 @@ def screen(image1, image2): image2.load() return image1._new(image1.im.chop_screen(image2.im)) + def softlight(image1, image2): """ - Superimposes two images on top of each other using the Soft Light algorithm + Superimposes two images on top of each other using the Soft Light algorithm :rtype: :py:class:`~PIL.Image.Image` """ @@ -149,9 +150,10 @@ def softlight(image1, image2): image2.load() return image1._new(image1.im.chop_softlight(image2.im)) + def hardlight(image1, image2): """ - Superimposes two images on top of each other using the Hard Light algorithm + Superimposes two images on top of each other using the Hard Light algorithm :rtype: :py:class:`~PIL.Image.Image` """ @@ -160,6 +162,7 @@ def hardlight(image1, image2): image2.load() return image1._new(image1.im.chop_hardlight(image2.im)) + def softlight(image1, image2): """ Superimposes two images on top of each other using the Soft Light algorithm From 23a61b00b508c803cc5442a69f0cca5264c5c8c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dag=20W=C3=A4stberg?= Date: Fri, 22 Nov 2019 14:47:51 +0100 Subject: [PATCH 08/27] fix formatting --- src/PIL/ImageChops.py | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/src/PIL/ImageChops.py b/src/PIL/ImageChops.py index 751152f71..1dc456156 100644 --- a/src/PIL/ImageChops.py +++ b/src/PIL/ImageChops.py @@ -163,30 +163,6 @@ def hardlight(image1, image2): return image1._new(image1.im.chop_hardlight(image2.im)) -def softlight(image1, image2): - """ - Superimposes two images on top of each other using the Soft Light algorithm - - :rtype: :py:class:`~PIL.Image.Image` - """ - - image1.load() - image2.load() - return image1._new(image1.im.chop_softlight(image2.im)) - - -def hardlight(image1, image2): - """ - Superimposes two images on top of each other using the Hard Light algorithm - - :rtype: :py:class:`~PIL.Image.Image` - """ - - image1.load() - image2.load() - return image1._new(image1.im.chop_hardlight(image2.im)) - - def overlay(image1, image2): """ Superimposes two images on top of each other using the Overlay algorithm From 7d5ab9f1d40a267a72754f3525a6df2e173e56a3 Mon Sep 17 00:00:00 2001 From: dwastberg Date: Wed, 25 Dec 2019 19:51:43 +0100 Subject: [PATCH 09/27] Remove old code --- src/libImaging/Chops.c | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/libImaging/Chops.c b/src/libImaging/Chops.c index 6a9bf0069..a1673dff6 100644 --- a/src/libImaging/Chops.c +++ b/src/libImaging/Chops.c @@ -150,8 +150,6 @@ ImagingChopSubtractModulo(Imaging imIn1, Imaging imIn2) Imaging ImagingChopSoftLight(Imaging imIn1, Imaging imIn2) { - // CHOP2( ( ( (255-in1[x]) * (in1[x]*in2[x]) ) / 65536) + - // ((in1[x] * (255 - ((255 - in1[1]) * (255 - in2[x]) / 255 ) )) / 255), NULL ); CHOP2( (((255-in1[x]) * (in1[x]*in2[x]) ) / 65536) + (in1[x] * ( 255 - ( (255 - in1[x]) * (255 - in2[x] ) / 255) )) / 255 , NULL ); From 2e02500fa627cbd4d3ced983d42d36a9629350f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dag=20W=C3=A4stberg?= Date: Wed, 25 Dec 2019 20:23:32 +0100 Subject: [PATCH 10/27] change function names to snake_case --- Tests/test_imagechops.py | 12 ++++++------ docs/reference/ImageChops.rst | 4 ++-- src/PIL/ImageChops.py | 8 ++++---- src/_imaging.c | 8 ++++---- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Tests/test_imagechops.py b/Tests/test_imagechops.py index 022c72567..3378b6bd0 100644 --- a/Tests/test_imagechops.py +++ b/Tests/test_imagechops.py @@ -38,8 +38,8 @@ def test_sanity(): ImageChops.blend(im, im, 0.5) ImageChops.composite(im, im, im) - ImageChops.softlight(im, im) - ImageChops.hardlight(im, im) + ImageChops.soft_light(im, im) + ImageChops.hard_light(im, im) ImageChops.overlay(im, im) ImageChops.offset(im, 10) @@ -366,26 +366,26 @@ def test_subtract_modulo_no_clip(): self.assertEqual(new.getpixel((50, 50)), (241, 167, 127)) -def test_softlight(self): +def test_soft_light(self): # Arrange im1 = Image.open("Tests/images/hopper.png") im2 = Image.open("Tests/images/hopper-XYZ.png") # Act - new = ImageChops.softlight(im1, im2) + new = ImageChops.soft_light(im1, im2) # Assert self.assertEqual(new.getpixel((64, 64)), (163, 54, 32)) self.assertEqual(new.getpixel((15, 100)), (1, 1, 3)) -def test_hardlight(self): +def test_hard_light(self): # Arrange im1 = Image.open("Tests/images/hopper.png") im2 = Image.open("Tests/images/hopper-XYZ.png") # Act - new = ImageChops.hardlight(im1, im2) + new = ImageChops.hard_light(im1, im2) # Assert self.assertEqual(new.getpixel((64, 64)), (144, 50, 27)) diff --git a/docs/reference/ImageChops.rst b/docs/reference/ImageChops.rst index 31142cc3f..fb7422549 100644 --- a/docs/reference/ImageChops.rst +++ b/docs/reference/ImageChops.rst @@ -36,8 +36,8 @@ operations in this module). .. autofunction:: PIL.ImageChops.logical_or .. autofunction:: PIL.ImageChops.logical_xor .. autofunction:: PIL.ImageChops.multiply -.. autofunction:: PIL.ImageChops.softlight -.. autofunction:: PIL.ImageChops.hardlight +.. autofunction:: PIL.ImageChops.soft_light +.. autofunction:: PIL.ImageChops.hard_light .. autofunction:: PIL.ImageChops.overlay .. py:method:: PIL.ImageChops.offset(image, xoffset, yoffset=None) diff --git a/src/PIL/ImageChops.py b/src/PIL/ImageChops.py index 1dc456156..2d13b529f 100644 --- a/src/PIL/ImageChops.py +++ b/src/PIL/ImageChops.py @@ -139,7 +139,7 @@ def screen(image1, image2): return image1._new(image1.im.chop_screen(image2.im)) -def softlight(image1, image2): +def soft_light(image1, image2): """ Superimposes two images on top of each other using the Soft Light algorithm @@ -148,10 +148,10 @@ def softlight(image1, image2): image1.load() image2.load() - return image1._new(image1.im.chop_softlight(image2.im)) + return image1._new(image1.im.chop_soft_light(image2.im)) -def hardlight(image1, image2): +def hard_light(image1, image2): """ Superimposes two images on top of each other using the Hard Light algorithm @@ -160,7 +160,7 @@ def hardlight(image1, image2): image1.load() image2.load() - return image1._new(image1.im.chop_hardlight(image2.im)) + return image1._new(image1.im.chop_hard_light(image2.im)) def overlay(image1, image2): diff --git a/src/_imaging.c b/src/_imaging.c index 7875ae278..f40b19e4d 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -2407,7 +2407,7 @@ _chop_subtract_modulo(ImagingObject* self, PyObject* args) } static PyObject* -_chop_softlight(ImagingObject* self, PyObject* args) +_chop_soft_light(ImagingObject* self, PyObject* args) { ImagingObject* imagep; @@ -2418,7 +2418,7 @@ _chop_softlight(ImagingObject* self, PyObject* args) } static PyObject* -_chop_hardlight(ImagingObject* self, PyObject* args) +_chop_hard_light(ImagingObject* self, PyObject* args) { ImagingObject* imagep; @@ -3357,8 +3357,8 @@ static struct PyMethodDef methods[] = { {"chop_and", (PyCFunction)_chop_and, 1}, {"chop_or", (PyCFunction)_chop_or, 1}, {"chop_xor", (PyCFunction)_chop_xor, 1}, - {"chop_softlight", (PyCFunction)_chop_softlight, 1}, - {"chop_hardlight", (PyCFunction)_chop_hardlight, 1}, + {"chop_soft_light", (PyCFunction)_chop_soft_light, 1}, + {"chop_hard_light", (PyCFunction)_chop_hard_light, 1}, {"chop_overlay", (PyCFunction)_chop_overlay, 1}, #endif From c6749183f8d93e2da87b80ae9d25d5d82b489c6b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 19 Feb 2020 19:52:07 +1100 Subject: [PATCH 11/27] Updated tests --- Tests/test_imagechops.py | 56 ++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/Tests/test_imagechops.py b/Tests/test_imagechops.py index 3378b6bd0..7d042cb9f 100644 --- a/Tests/test_imagechops.py +++ b/Tests/test_imagechops.py @@ -213,8 +213,8 @@ def test_lighter_image(): # Act new = ImageChops.lighter(im1, im2) - # Assert - assert_image_equal(new, im1) + # Assert + assert_image_equal(new, im1) def test_lighter_pixel(): @@ -279,13 +279,13 @@ def test_offset(): # Act new = ImageChops.offset(im, xoffset, yoffset) - # Assert - assert new.getbbox() == (0, 45, 100, 96) - assert new.getpixel((50, 50)) == BLACK - assert new.getpixel((50 + xoffset, 50 + yoffset)) == DARK_GREEN + # Assert + assert new.getbbox() == (0, 45, 100, 96) + assert new.getpixel((50, 50)) == BLACK + assert new.getpixel((50 + xoffset, 50 + yoffset)) == DARK_GREEN - # Test no yoffset - assert ImageChops.offset(im, xoffset) == ImageChops.offset(im, xoffset, xoffset) + # Test no yoffset + assert ImageChops.offset(im, xoffset) == ImageChops.offset(im, xoffset, xoffset) def test_screen(): @@ -323,9 +323,9 @@ def test_subtract_scale_offset(): # Act new = ImageChops.subtract(im1, im2, scale=2.5, offset=100) - # Assert - assert new.getbbox() == (0, 0, 100, 100) - assert new.getpixel((50, 50)) == (100, 202, 100) + # Assert + assert new.getbbox() == (0, 0, 100, 100) + assert new.getpixel((50, 50)) == (100, 202, 100) def test_subtract_clip(): @@ -336,8 +336,8 @@ def test_subtract_clip(): # Act new = ImageChops.subtract(im1, im2) - # Assert - assert new.getpixel((50, 50)) == (0, 0, 127) + # Assert + assert new.getpixel((50, 50)) == (0, 0, 127) def test_subtract_modulo(): @@ -348,10 +348,10 @@ def test_subtract_modulo(): # Act new = ImageChops.subtract_modulo(im1, im2) - # Assert - assert new.getbbox() == (25, 50, 76, 76) - assert new.getpixel((50, 50)) == GREEN - assert new.getpixel((50, 51)) == BLACK + # Assert + assert new.getbbox() == (25, 50, 76, 76) + assert new.getpixel((50, 50)) == GREEN + assert new.getpixel((50, 51)) == BLACK def test_subtract_modulo_no_clip(): @@ -362,11 +362,11 @@ def test_subtract_modulo_no_clip(): # Act new = ImageChops.subtract_modulo(im1, im2) - # Assert - self.assertEqual(new.getpixel((50, 50)), (241, 167, 127)) + # Assert + assert new.getpixel((50, 50)) == (241, 167, 127) -def test_soft_light(self): +def test_soft_light(): # Arrange im1 = Image.open("Tests/images/hopper.png") im2 = Image.open("Tests/images/hopper-XYZ.png") @@ -375,11 +375,11 @@ def test_soft_light(self): new = ImageChops.soft_light(im1, im2) # Assert - self.assertEqual(new.getpixel((64, 64)), (163, 54, 32)) - self.assertEqual(new.getpixel((15, 100)), (1, 1, 3)) + assert new.getpixel((64, 64)) == (163, 54, 32) + assert new.getpixel((15, 100)) == (1, 1, 3) -def test_hard_light(self): +def test_hard_light(): # Arrange im1 = Image.open("Tests/images/hopper.png") im2 = Image.open("Tests/images/hopper-XYZ.png") @@ -388,11 +388,11 @@ def test_hard_light(self): new = ImageChops.hard_light(im1, im2) # Assert - self.assertEqual(new.getpixel((64, 64)), (144, 50, 27)) - self.assertEqual(new.getpixel((15, 100)), (1, 1, 2)) + assert new.getpixel((64, 64)) == (144, 50, 27) + assert new.getpixel((15, 100)) == (1, 1, 2) -def test_overlay(self): +def test_overlay(): # Arrange im1 = Image.open("Tests/images/hopper.png") im2 = Image.open("Tests/images/hopper-XYZ.png") @@ -401,8 +401,8 @@ def test_overlay(self): new = ImageChops.overlay(im1, im2) # Assert - self.assertEqual(new.getpixel((64, 64)), (159, 50, 27)) - self.assertEqual(new.getpixel((15, 100)), (1, 1, 2)) + assert new.getpixel((64, 64)) == (159, 50, 27) + assert new.getpixel((15, 100)) == (1, 1, 2) def test_logical(): From 748739c9928a3961c3991ffd48359228417f2b08 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 27 Mar 2020 19:50:51 +1100 Subject: [PATCH 12/27] Converted addCleanup --- Tests/test_decompression_bomb.py | 10 +- Tests/test_font_pcf.py | 117 +++++++++++---------- Tests/test_image_fromqimage.py | 75 +++++++------- Tests/test_imageops_usm.py | 172 +++++++++++++++++-------------- Tests/test_imageqt.py | 7 +- 5 files changed, 204 insertions(+), 177 deletions(-) diff --git a/Tests/test_decompression_bomb.py b/Tests/test_decompression_bomb.py index f3981b3ec..58bcbd085 100644 --- a/Tests/test_decompression_bomb.py +++ b/Tests/test_decompression_bomb.py @@ -61,9 +61,8 @@ class TestDecompressionBomb(PillowTestCase): class TestDecompressionCrop(PillowTestCase): def setUp(self): - self.src = hopper() - self.addCleanup(self.src.close) - Image.MAX_IMAGE_PIXELS = self.src.height * self.src.width * 4 - 1 + width, height = 128, 128 + Image.MAX_IMAGE_PIXELS = height * width * 4 - 1 def tearDown(self): Image.MAX_IMAGE_PIXELS = ORIGINAL_LIMIT @@ -71,8 +70,9 @@ class TestDecompressionCrop(PillowTestCase): def testEnlargeCrop(self): # Crops can extend the extents, therefore we should have the # same decompression bomb warnings on them. - box = (0, 0, self.src.width * 2, self.src.height * 2) - pytest.warns(Image.DecompressionBombWarning, self.src.crop, box) + with hopper() as src: + box = (0, 0, src.width * 2, src.height * 2) + pytest.warns(Image.DecompressionBombWarning, src.crop, box) def test_crop_decompression_checks(self): diff --git a/Tests/test_font_pcf.py b/Tests/test_font_pcf.py index 95250e5ee..16f8571f5 100644 --- a/Tests/test_font_pcf.py +++ b/Tests/test_font_pcf.py @@ -1,3 +1,4 @@ +import os import pytest from PIL import FontFile, Image, ImageDraw, ImageFont, PcfFontFile @@ -13,67 +14,73 @@ fontname = "Tests/fonts/10x20-ISO8859-1.pcf" message = "hello, world" -@skip_unless_feature("zlib") -class TestFontPcf(PillowTestCase): - def save_font(self): - with open(fontname, "rb") as test_file: - font = PcfFontFile.PcfFontFile(test_file) - assert isinstance(font, FontFile.FontFile) - # check the number of characters in the font - assert len([_f for _f in font.glyph if _f]) == 223 +pytestmark = skip_unless_feature("zlib") - tempname = self.tempfile("temp.pil") - self.addCleanup(self.delete_tempfile, tempname[:-4] + ".pbm") - font.save(tempname) +def save_font(request, tmp_path): + with open(fontname, "rb") as test_file: + font = PcfFontFile.PcfFontFile(test_file) + assert isinstance(font, FontFile.FontFile) + # check the number of characters in the font + assert len([_f for _f in font.glyph if _f]) == 223 - with Image.open(tempname.replace(".pil", ".pbm")) as loaded: - with Image.open("Tests/fonts/10x20.pbm") as target: - assert_image_equal(loaded, target) + tempname = str(tmp_path / "temp.pil") - with open(tempname, "rb") as f_loaded: - with open("Tests/fonts/10x20.pil", "rb") as f_target: - assert f_loaded.read() == f_target.read() - return tempname + def delete_tempfile(): + try: + os.remove(tempname[:-4] + ".pbm") + except OSError: + pass # report? + request.addfinalizer(delete_tempfile) + font.save(tempname) - def test_sanity(self): - self.save_font() + with Image.open(tempname.replace(".pil", ".pbm")) as loaded: + with Image.open("Tests/fonts/10x20.pbm") as target: + assert_image_equal(loaded, target) - def test_invalid_file(self): - with open("Tests/images/flower.jpg", "rb") as fp: - with pytest.raises(SyntaxError): - PcfFontFile.PcfFontFile(fp) + with open(tempname, "rb") as f_loaded: + with open("Tests/fonts/10x20.pil", "rb") as f_target: + assert f_loaded.read() == f_target.read() + return tempname - def test_draw(self): - tempname = self.save_font() - font = ImageFont.load(tempname) - im = Image.new("L", (130, 30), "white") - draw = ImageDraw.Draw(im) - draw.text((0, 0), message, "black", font=font) - with Image.open("Tests/images/test_draw_pbm_target.png") as target: - assert_image_similar(im, target, 0) +def test_sanity(request, tmp_path): + save_font(request, tmp_path) - def test_textsize(self): - tempname = self.save_font() - font = ImageFont.load(tempname) - for i in range(255): - (dx, dy) = font.getsize(chr(i)) - assert dy == 20 - assert dx in (0, 10) - for l in range(len(message)): - msg = message[: l + 1] - assert font.getsize(msg) == (len(msg) * 10, 20) +def test_invalid_file(): + with open("Tests/images/flower.jpg", "rb") as fp: + with pytest.raises(SyntaxError): + PcfFontFile.PcfFontFile(fp) - def _test_high_characters(self, message): - tempname = self.save_font() - font = ImageFont.load(tempname) - im = Image.new("L", (750, 30), "white") - draw = ImageDraw.Draw(im) - draw.text((0, 0), message, "black", font=font) - with Image.open("Tests/images/high_ascii_chars.png") as target: - assert_image_similar(im, target, 0) +def test_draw(request, tmp_path): + tempname = save_font(request, tmp_path) + font = ImageFont.load(tempname) + im = Image.new("L", (130, 30), "white") + draw = ImageDraw.Draw(im) + draw.text((0, 0), message, "black", font=font) + with Image.open("Tests/images/test_draw_pbm_target.png") as target: + assert_image_similar(im, target, 0) - def test_high_characters(self): - message = "".join(chr(i + 1) for i in range(140, 232)) - self._test_high_characters(message) - # accept bytes instances. - self._test_high_characters(message.encode("latin1")) +def test_textsize(request, tmp_path): + tempname = save_font(request, tmp_path) + font = ImageFont.load(tempname) + for i in range(255): + (dx, dy) = font.getsize(chr(i)) + assert dy == 20 + assert dx in (0, 10) + for l in range(len(message)): + msg = message[: l + 1] + assert font.getsize(msg) == (len(msg) * 10, 20) + +def _test_high_characters(request, tmp_path, message): + tempname = save_font(request, tmp_path) + font = ImageFont.load(tempname) + im = Image.new("L", (750, 30), "white") + draw = ImageDraw.Draw(im) + draw.text((0, 0), message, "black", font=font) + with Image.open("Tests/images/high_ascii_chars.png") as target: + assert_image_similar(im, target, 0) + +def test_high_characters(request, tmp_path): + message = "".join(chr(i + 1) for i in range(140, 232)) + _test_high_characters(request, tmp_path, message) + # accept bytes instances. + _test_high_characters(request, tmp_path, message.encode("latin1")) diff --git a/Tests/test_image_fromqimage.py b/Tests/test_image_fromqimage.py index ee26b75bc..f8c27bbfa 100644 --- a/Tests/test_image_fromqimage.py +++ b/Tests/test_image_fromqimage.py @@ -1,47 +1,52 @@ +import pytest from PIL import Image, ImageQt from .helper import PillowTestCase, assert_image_equal, hopper -from .test_imageqt import PillowQtTestCase +from .test_imageqt import skip_if_qt_is_not_installed -class TestFromQImage(PillowQtTestCase, PillowTestCase): - def setUp(self): - super().setUp() - self.files_to_test = [ - hopper(), - Image.open("Tests/images/transparent.png"), - Image.open("Tests/images/7x13.png"), - ] - for im in self.files_to_test: - self.addCleanup(im.close) +pytestmark = skip_if_qt_is_not_installed() - def roundtrip(self, expected): - # PIL -> Qt - intermediate = expected.toqimage() - # Qt -> PIL - result = ImageQt.fromqimage(intermediate) +@pytest.fixture +def test_images(): + ims = [ + hopper(), + Image.open("Tests/images/transparent.png"), + Image.open("Tests/images/7x13.png"), + ] + try: + yield ims + finally: + for im in ims.values(): + im.close() - if intermediate.hasAlphaChannel(): - assert_image_equal(result, expected.convert("RGBA")) - else: - assert_image_equal(result, expected.convert("RGB")) +def roundtrip(expected): + # PIL -> Qt + intermediate = expected.toqimage() + # Qt -> PIL + result = ImageQt.fromqimage(intermediate) - def test_sanity_1(self): - for im in self.files_to_test: - self.roundtrip(im.convert("1")) + if intermediate.hasAlphaChannel(): + assert_image_equal(result, expected.convert("RGBA")) + else: + assert_image_equal(result, expected.convert("RGB")) - def test_sanity_rgb(self): - for im in self.files_to_test: - self.roundtrip(im.convert("RGB")) +def test_sanity_1(test_images): + for im in test_images: + roundtrip(im.convert("1")) - def test_sanity_rgba(self): - for im in self.files_to_test: - self.roundtrip(im.convert("RGBA")) +def test_sanity_rgb(test_images): + for im in test_images: + roundtrip(im.convert("RGB")) - def test_sanity_l(self): - for im in self.files_to_test: - self.roundtrip(im.convert("L")) +def test_sanity_rgba(test_images): + for im in test_images: + roundtrip(im.convert("RGBA")) - def test_sanity_p(self): - for im in self.files_to_test: - self.roundtrip(im.convert("P")) +def test_sanity_l(test_images): + for im in test_images: + roundtrip(im.convert("L")) + +def test_sanity_p(test_images): + for im in test_images: + roundtrip(im.convert("P")) diff --git a/Tests/test_imageops_usm.py b/Tests/test_imageops_usm.py index 8bd01d588..8fd49baf9 100644 --- a/Tests/test_imageops_usm.py +++ b/Tests/test_imageops_usm.py @@ -4,95 +4,109 @@ from PIL import Image, ImageFilter from .helper import PillowTestCase -class TestImageOpsUsm(PillowTestCase): - def setUp(self): - super().setUp() - self.im = Image.open("Tests/images/hopper.ppm") - self.addCleanup(self.im.close) - self.snakes = Image.open("Tests/images/color_snakes.png") - self.addCleanup(self.snakes.close) +@pytest.fixture +def test_images(): + ims = { + "im": Image.open("Tests/images/hopper.ppm"), + "snakes": Image.open("Tests/images/color_snakes.png"), + } + try: + yield ims + finally: + for im in ims.values(): + im.close() - def test_filter_api(self): - test_filter = ImageFilter.GaussianBlur(2.0) - i = self.im.filter(test_filter) - assert i.mode == "RGB" - assert i.size == (128, 128) +def test_filter_api(test_images): + im = test_images["im"] - test_filter = ImageFilter.UnsharpMask(2.0, 125, 8) - i = self.im.filter(test_filter) - assert i.mode == "RGB" - assert i.size == (128, 128) + test_filter = ImageFilter.GaussianBlur(2.0) + i = im.filter(test_filter) + assert i.mode == "RGB" + assert i.size == (128, 128) - def test_usm_formats(self): + test_filter = ImageFilter.UnsharpMask(2.0, 125, 8) + i = im.filter(test_filter) + assert i.mode == "RGB" + assert i.size == (128, 128) - usm = ImageFilter.UnsharpMask - with pytest.raises(ValueError): - self.im.convert("1").filter(usm) - self.im.convert("L").filter(usm) - with pytest.raises(ValueError): - self.im.convert("I").filter(usm) - with pytest.raises(ValueError): - self.im.convert("F").filter(usm) - self.im.convert("RGB").filter(usm) - self.im.convert("RGBA").filter(usm) - self.im.convert("CMYK").filter(usm) - with pytest.raises(ValueError): - self.im.convert("YCbCr").filter(usm) - def test_blur_formats(self): +def test_usm_formats(test_images): + im = test_images["im"] - blur = ImageFilter.GaussianBlur - with pytest.raises(ValueError): - self.im.convert("1").filter(blur) - blur(self.im.convert("L")) - with pytest.raises(ValueError): - self.im.convert("I").filter(blur) - with pytest.raises(ValueError): - self.im.convert("F").filter(blur) - self.im.convert("RGB").filter(blur) - self.im.convert("RGBA").filter(blur) - self.im.convert("CMYK").filter(blur) - with pytest.raises(ValueError): - self.im.convert("YCbCr").filter(blur) + usm = ImageFilter.UnsharpMask + with pytest.raises(ValueError): + im.convert("1").filter(usm) + im.convert("L").filter(usm) + with pytest.raises(ValueError): + im.convert("I").filter(usm) + with pytest.raises(ValueError): + im.convert("F").filter(usm) + im.convert("RGB").filter(usm) + im.convert("RGBA").filter(usm) + im.convert("CMYK").filter(usm) + with pytest.raises(ValueError): + im.convert("YCbCr").filter(usm) - def test_usm_accuracy(self): - src = self.snakes.convert("RGB") - i = src.filter(ImageFilter.UnsharpMask(5, 1024, 0)) - # Image should not be changed because it have only 0 and 255 levels. - assert i.tobytes() == src.tobytes() +def test_blur_formats(test_images): + im = test_images["im"] - def test_blur_accuracy(self): + blur = ImageFilter.GaussianBlur + with pytest.raises(ValueError): + im.convert("1").filter(blur) + blur(im.convert("L")) + with pytest.raises(ValueError): + im.convert("I").filter(blur) + with pytest.raises(ValueError): + im.convert("F").filter(blur) + im.convert("RGB").filter(blur) + im.convert("RGBA").filter(blur) + im.convert("CMYK").filter(blur) + with pytest.raises(ValueError): + im.convert("YCbCr").filter(blur) - i = self.snakes.filter(ImageFilter.GaussianBlur(0.4)) - # These pixels surrounded with pixels with 255 intensity. - # They must be very close to 255. - for x, y, c in [ - (1, 0, 1), - (2, 0, 1), - (7, 8, 1), - (8, 8, 1), - (2, 9, 1), - (7, 3, 0), - (8, 3, 0), - (5, 8, 0), - (5, 9, 0), - (1, 3, 0), - (4, 3, 2), - (4, 2, 2), - ]: - assert i.im.getpixel((x, y))[c] >= 250 - # Fuzzy match. - def gp(x, y): - return i.im.getpixel((x, y)) +def test_usm_accuracy(test_images): + snakes = test_images["snakes"] - assert 236 <= gp(7, 4)[0] <= 239 - assert 236 <= gp(7, 5)[2] <= 239 - assert 236 <= gp(7, 6)[2] <= 239 - assert 236 <= gp(7, 7)[1] <= 239 - assert 236 <= gp(8, 4)[0] <= 239 - assert 236 <= gp(8, 5)[2] <= 239 - assert 236 <= gp(8, 6)[2] <= 239 - assert 236 <= gp(8, 7)[1] <= 239 + src = snakes.convert("RGB") + i = src.filter(ImageFilter.UnsharpMask(5, 1024, 0)) + # Image should not be changed because it have only 0 and 255 levels. + assert i.tobytes() == src.tobytes() + + +def test_blur_accuracy(test_images): + snakes = test_images["snakes"] + + i = snakes.filter(ImageFilter.GaussianBlur(0.4)) + # These pixels surrounded with pixels with 255 intensity. + # They must be very close to 255. + for x, y, c in [ + (1, 0, 1), + (2, 0, 1), + (7, 8, 1), + (8, 8, 1), + (2, 9, 1), + (7, 3, 0), + (8, 3, 0), + (5, 8, 0), + (5, 9, 0), + (1, 3, 0), + (4, 3, 2), + (4, 2, 2), + ]: + assert i.im.getpixel((x, y))[c] >= 250 + # Fuzzy match. + + def gp(x, y): + return i.im.getpixel((x, y)) + + assert 236 <= gp(7, 4)[0] <= 239 + assert 236 <= gp(7, 5)[2] <= 239 + assert 236 <= gp(7, 6)[2] <= 239 + assert 236 <= gp(7, 7)[1] <= 239 + assert 236 <= gp(8, 4)[0] <= 239 + assert 236 <= gp(8, 5)[2] <= 239 + assert 236 <= gp(8, 6)[2] <= 239 + assert 236 <= gp(8, 7)[1] <= 239 diff --git a/Tests/test_imageqt.py b/Tests/test_imageqt.py index ef777374f..d4a56e001 100644 --- a/Tests/test_imageqt.py +++ b/Tests/test_imageqt.py @@ -1,3 +1,4 @@ +import pytest from PIL import ImageQt from .helper import PillowTestCase, hopper @@ -5,14 +6,14 @@ from .helper import PillowTestCase, hopper if ImageQt.qt_is_installed: from PIL.ImageQt import qRgba - def skip_if_qt_is_not_installed(_): + def skip_if_qt_is_not_installed(): pass else: - def skip_if_qt_is_not_installed(test_case): - test_case.skipTest("Qt bindings are not installed") + def skip_if_qt_is_not_installed(): + return pytest.mark.skip(reason="Qt bindings are not installed") class PillowQtTestCase: From a8637449b9e21d575cf1103ea1b2351b9f9e6116 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 27 Mar 2020 20:30:00 +1100 Subject: [PATCH 13/27] Converted common Qt test classes --- Tests/test_font_pcf.py | 9 ++- Tests/test_image_fromqimage.py | 9 ++- Tests/test_imageops_usm.py | 2 - Tests/test_imageqt.py | 92 ++++++++++++++---------------- Tests/test_qt_image_fromqpixmap.py | 23 ++++---- Tests/test_qt_image_toqimage.py | 68 +++++++++++----------- Tests/test_qt_image_toqpixmap.py | 24 ++++---- 7 files changed, 120 insertions(+), 107 deletions(-) diff --git a/Tests/test_font_pcf.py b/Tests/test_font_pcf.py index 16f8571f5..2f19beeb2 100644 --- a/Tests/test_font_pcf.py +++ b/Tests/test_font_pcf.py @@ -3,7 +3,6 @@ import pytest from PIL import FontFile, Image, ImageDraw, ImageFont, PcfFontFile from .helper import ( - PillowTestCase, assert_image_equal, assert_image_similar, skip_unless_feature, @@ -16,6 +15,7 @@ message = "hello, world" pytestmark = skip_unless_feature("zlib") + def save_font(request, tmp_path): with open(fontname, "rb") as test_file: font = PcfFontFile.PcfFontFile(test_file) @@ -30,6 +30,7 @@ def save_font(request, tmp_path): os.remove(tempname[:-4] + ".pbm") except OSError: pass # report? + request.addfinalizer(delete_tempfile) font.save(tempname) @@ -42,14 +43,17 @@ def save_font(request, tmp_path): assert f_loaded.read() == f_target.read() return tempname + def test_sanity(request, tmp_path): save_font(request, tmp_path) + def test_invalid_file(): with open("Tests/images/flower.jpg", "rb") as fp: with pytest.raises(SyntaxError): PcfFontFile.PcfFontFile(fp) + def test_draw(request, tmp_path): tempname = save_font(request, tmp_path) font = ImageFont.load(tempname) @@ -59,6 +63,7 @@ def test_draw(request, tmp_path): with Image.open("Tests/images/test_draw_pbm_target.png") as target: assert_image_similar(im, target, 0) + def test_textsize(request, tmp_path): tempname = save_font(request, tmp_path) font = ImageFont.load(tempname) @@ -70,6 +75,7 @@ def test_textsize(request, tmp_path): msg = message[: l + 1] assert font.getsize(msg) == (len(msg) * 10, 20) + def _test_high_characters(request, tmp_path, message): tempname = save_font(request, tmp_path) font = ImageFont.load(tempname) @@ -79,6 +85,7 @@ def _test_high_characters(request, tmp_path, message): with Image.open("Tests/images/high_ascii_chars.png") as target: assert_image_similar(im, target, 0) + def test_high_characters(request, tmp_path): message = "".join(chr(i + 1) for i in range(140, 232)) _test_high_characters(request, tmp_path, message) diff --git a/Tests/test_image_fromqimage.py b/Tests/test_image_fromqimage.py index f8c27bbfa..b653edb16 100644 --- a/Tests/test_image_fromqimage.py +++ b/Tests/test_image_fromqimage.py @@ -1,12 +1,13 @@ import pytest from PIL import Image, ImageQt -from .helper import PillowTestCase, assert_image_equal, hopper +from .helper import assert_image_equal, hopper from .test_imageqt import skip_if_qt_is_not_installed pytestmark = skip_if_qt_is_not_installed() + @pytest.fixture def test_images(): ims = [ @@ -20,6 +21,7 @@ def test_images(): for im in ims.values(): im.close() + def roundtrip(expected): # PIL -> Qt intermediate = expected.toqimage() @@ -31,22 +33,27 @@ def roundtrip(expected): else: assert_image_equal(result, expected.convert("RGB")) + def test_sanity_1(test_images): for im in test_images: roundtrip(im.convert("1")) + def test_sanity_rgb(test_images): for im in test_images: roundtrip(im.convert("RGB")) + def test_sanity_rgba(test_images): for im in test_images: roundtrip(im.convert("RGBA")) + def test_sanity_l(test_images): for im in test_images: roundtrip(im.convert("L")) + def test_sanity_p(test_images): for im in test_images: roundtrip(im.convert("P")) diff --git a/Tests/test_imageops_usm.py b/Tests/test_imageops_usm.py index 8fd49baf9..61f8dc2ba 100644 --- a/Tests/test_imageops_usm.py +++ b/Tests/test_imageops_usm.py @@ -1,8 +1,6 @@ import pytest from PIL import Image, ImageFilter -from .helper import PillowTestCase - @pytest.fixture def test_images(): diff --git a/Tests/test_imageqt.py b/Tests/test_imageqt.py index d4a56e001..fbb650861 100644 --- a/Tests/test_imageqt.py +++ b/Tests/test_imageqt.py @@ -1,7 +1,7 @@ import pytest from PIL import ImageQt -from .helper import PillowTestCase, hopper +from .helper import hopper if ImageQt.qt_is_installed: from PIL.ImageQt import qRgba @@ -9,6 +9,23 @@ if ImageQt.qt_is_installed: def skip_if_qt_is_not_installed(): pass + @pytest.fixture + def qpixmap_app(): + try: + if ImageQt.qt_version == "5": + from PyQt5.QtGui import QGuiApplication + elif ImageQt.qt_version == "side2": + from PySide2.QtGui import QGuiApplication + except ImportError: + pytest.skip("QGuiApplication not installed") + return + + app = QGuiApplication([]) + try: + yield + finally: + app.quit() + else: @@ -16,57 +33,34 @@ else: return pytest.mark.skip(reason="Qt bindings are not installed") -class PillowQtTestCase: - def setUp(self): - skip_if_qt_is_not_installed(self) - - def tearDown(self): - pass +pytestmark = skip_if_qt_is_not_installed() -class PillowQPixmapTestCase(PillowQtTestCase): - def setUp(self): - super().setUp() - try: - if ImageQt.qt_version == "5": - from PyQt5.QtGui import QGuiApplication - elif ImageQt.qt_version == "side2": - from PySide2.QtGui import QGuiApplication - except ImportError: - self.skipTest("QGuiApplication not installed") +def test_rgb(): + # from https://doc.qt.io/archives/qt-4.8/qcolor.html + # typedef QRgb + # An ARGB quadruplet on the format #AARRGGBB, + # equivalent to an unsigned int. + if ImageQt.qt_version == "5": + from PyQt5.QtGui import qRgb + elif ImageQt.qt_version == "side2": + from PySide2.QtGui import qRgb - self.app = QGuiApplication([]) + assert qRgb(0, 0, 0) == qRgba(0, 0, 0, 255) - def tearDown(self): - super().tearDown() - self.app.quit() + def checkrgb(r, g, b): + val = ImageQt.rgb(r, g, b) + val = val % 2 ** 24 # drop the alpha + assert val >> 16 == r + assert ((val >> 8) % 2 ** 8) == g + assert val % 2 ** 8 == b + + checkrgb(0, 0, 0) + checkrgb(255, 0, 0) + checkrgb(0, 255, 0) + checkrgb(0, 0, 255) -class TestImageQt(PillowQtTestCase, PillowTestCase): - def test_rgb(self): - # from https://doc.qt.io/archives/qt-4.8/qcolor.html - # typedef QRgb - # An ARGB quadruplet on the format #AARRGGBB, - # equivalent to an unsigned int. - if ImageQt.qt_version == "5": - from PyQt5.QtGui import qRgb - elif ImageQt.qt_version == "side2": - from PySide2.QtGui import qRgb - - assert qRgb(0, 0, 0) == qRgba(0, 0, 0, 255) - - def checkrgb(r, g, b): - val = ImageQt.rgb(r, g, b) - val = val % 2 ** 24 # drop the alpha - assert val >> 16 == r - assert ((val >> 8) % 2 ** 8) == g - assert val % 2 ** 8 == b - - checkrgb(0, 0, 0) - checkrgb(255, 0, 0) - checkrgb(0, 255, 0) - checkrgb(0, 0, 255) - - def test_image(self): - for mode in ("1", "RGB", "RGBA", "L", "P"): - ImageQt.ImageQt(hopper(mode)) +def test_image(): + for mode in ("1", "RGB", "RGBA", "L", "P"): + ImageQt.ImageQt(hopper(mode)) diff --git a/Tests/test_qt_image_fromqpixmap.py b/Tests/test_qt_image_fromqpixmap.py index 1c7184376..96eaba41f 100644 --- a/Tests/test_qt_image_fromqpixmap.py +++ b/Tests/test_qt_image_fromqpixmap.py @@ -1,15 +1,18 @@ +import pytest from PIL import ImageQt -from .helper import PillowTestCase, assert_image_equal, hopper -from .test_imageqt import PillowQPixmapTestCase +from .helper import assert_image_equal, hopper +from .test_imageqt import qpixmap_app, skip_if_qt_is_not_installed + +pytestmark = skip_if_qt_is_not_installed() -class TestFromQPixmap(PillowQPixmapTestCase, PillowTestCase): - def roundtrip(self, expected): - result = ImageQt.fromqpixmap(ImageQt.toqpixmap(expected)) - # Qt saves all pixmaps as rgb - assert_image_equal(result, expected.convert("RGB")) +def roundtrip(expected): + result = ImageQt.fromqpixmap(ImageQt.toqpixmap(expected)) + # Qt saves all pixmaps as rgb + assert_image_equal(result, expected.convert("RGB")) - def test_sanity(self): - for mode in ("1", "RGB", "RGBA", "L", "P"): - self.roundtrip(hopper(mode)) + +def test_sanity(qpixmap_app): + for mode in ("1", "RGB", "RGBA", "L", "P"): + roundtrip(hopper(mode)) diff --git a/Tests/test_qt_image_toqimage.py b/Tests/test_qt_image_toqimage.py index 8283753b9..e6fd18c52 100644 --- a/Tests/test_qt_image_toqimage.py +++ b/Tests/test_qt_image_toqimage.py @@ -1,7 +1,9 @@ from PIL import Image, ImageQt -from .helper import PillowTestCase, assert_image_equal, hopper -from .test_imageqt import PillowQtTestCase +from .helper import assert_image_equal, hopper +from .test_imageqt import skip_if_qt_is_not_installed + +pytestmark = skip_if_qt_is_not_installed() if ImageQt.qt_is_installed: from PIL.ImageQt import QImage @@ -14,43 +16,43 @@ if ImageQt.qt_is_installed: from PySide2.QtWidgets import QWidget, QHBoxLayout, QLabel, QApplication -class TestToQImage(PillowQtTestCase, PillowTestCase): - def test_sanity(self): - for mode in ("RGB", "RGBA", "L", "P", "1"): - src = hopper(mode) - data = ImageQt.toqimage(src) +def test_sanity(tmp_path): + for mode in ("RGB", "RGBA", "L", "P", "1"): + src = hopper(mode) + data = ImageQt.toqimage(src) - assert isinstance(data, QImage) - assert not data.isNull() + assert isinstance(data, QImage) + assert not data.isNull() - # reload directly from the qimage - rt = ImageQt.fromqimage(data) - if mode in ("L", "P", "1"): - assert_image_equal(rt, src.convert("RGB")) - else: - assert_image_equal(rt, src) + # reload directly from the qimage + rt = ImageQt.fromqimage(data) + if mode in ("L", "P", "1"): + assert_image_equal(rt, src.convert("RGB")) + else: + assert_image_equal(rt, src) - if mode == "1": - # BW appears to not save correctly on QT4 and QT5 - # kicks out errors on console: - # libpng warning: Invalid color type/bit depth combination - # in IHDR - # libpng error: Invalid IHDR data - continue + if mode == "1": + # BW appears to not save correctly on QT4 and QT5 + # kicks out errors on console: + # libpng warning: Invalid color type/bit depth combination + # in IHDR + # libpng error: Invalid IHDR data + continue - # Test saving the file - tempfile = self.tempfile("temp_{}.png".format(mode)) - data.save(tempfile) + # Test saving the file + tempfile = str(tmp_path / "temp_{}.png".format(mode)) + data.save(tempfile) - # Check that it actually worked. - with Image.open(tempfile) as reloaded: - assert_image_equal(reloaded, src) + # Check that it actually worked. + with Image.open(tempfile) as reloaded: + assert_image_equal(reloaded, src) - def test_segfault(self): - app = QApplication([]) - ex = Example() - assert app # Silence warning - assert ex # Silence warning + +def test_segfault(): + app = QApplication([]) + ex = Example() + assert app # Silence warning + assert ex # Silence warning if ImageQt.qt_is_installed: diff --git a/Tests/test_qt_image_toqpixmap.py b/Tests/test_qt_image_toqpixmap.py index 70b0d6839..f7cf59709 100644 --- a/Tests/test_qt_image_toqpixmap.py +++ b/Tests/test_qt_image_toqpixmap.py @@ -1,20 +1,22 @@ +import pytest from PIL import ImageQt -from .helper import PillowTestCase, hopper -from .test_imageqt import PillowQPixmapTestCase +from .helper import hopper +from .test_imageqt import qpixmap_app, skip_if_qt_is_not_installed if ImageQt.qt_is_installed: from PIL.ImageQt import QPixmap +pytestmark = skip_if_qt_is_not_installed() -class TestToQPixmap(PillowQPixmapTestCase, PillowTestCase): - def test_sanity(self): - for mode in ("1", "RGB", "RGBA", "L", "P"): - data = ImageQt.toqpixmap(hopper(mode)) - assert isinstance(data, QPixmap) - assert not data.isNull() +def test_sanity(qpixmap, tmp_path): + for mode in ("1", "RGB", "RGBA", "L", "P"): + data = ImageQt.toqpixmap(hopper(mode)) - # Test saving the file - tempfile = self.tempfile("temp_{}.png".format(mode)) - data.save(tempfile) + assert isinstance(data, QPixmap) + assert not data.isNull() + + # Test saving the file + tempfile = str(tmp_path / "temp_{}.png".format(mode)) + data.save(tempfile) From b602f365ae3aa5d2656b1717f2c4c4cf25d1f9c7 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 28 Mar 2020 12:51:28 +1100 Subject: [PATCH 14/27] Removed PillowTestCase helper class --- Tests/bench_cffi_access.py | 32 ++++---- Tests/check_fli_overflow.py | 17 +---- Tests/check_imaging_leaks.py | 72 +++++++++--------- Tests/check_j2k_leaks.py | 57 +++++++------- Tests/check_j2k_overflow.py | 21 ++---- Tests/check_jpeg_leaks.py | 115 ++++++++++++++--------------- Tests/check_large_memory.py | 46 ++++++------ Tests/check_large_memory_numpy.py | 44 +++++------ Tests/check_libtiff_segfault.py | 23 ++---- Tests/check_png_dos.py | 102 ++++++++++++------------- Tests/helper.py | 17 ----- Tests/test_decompression_bomb.py | 15 ++-- Tests/test_file_wmf.py | 113 ++++++++++++++-------------- Tests/test_font_pcf.py | 7 +- Tests/test_image_fromqimage.py | 8 +- Tests/test_image_paste.py | 4 +- Tests/test_image_resize.py | 63 ++++++++-------- Tests/test_imagefont.py | 18 +++-- Tests/test_imageqt.py | 30 +++----- Tests/test_qt_image_fromqpixmap.py | 21 +++--- Tests/test_qt_image_toqimage.py | 6 +- Tests/test_qt_image_toqpixmap.py | 22 +++--- 22 files changed, 392 insertions(+), 461 deletions(-) diff --git a/Tests/bench_cffi_access.py b/Tests/bench_cffi_access.py index 8b172343c..f196757dc 100644 --- a/Tests/bench_cffi_access.py +++ b/Tests/bench_cffi_access.py @@ -1,9 +1,8 @@ import time -import unittest from PIL import PyAccess -from .helper import PillowTestCase, hopper +from .helper import hopper # Not running this test by default. No DOS against Travis CI. @@ -41,22 +40,17 @@ def timer(func, label, *args): ) -class BenchCffiAccess(PillowTestCase): - def test_direct(self): - im = hopper() - im.load() - # im = Image.new( "RGB", (2000, 2000), (1, 3, 2)) - caccess = im.im.pixel_access(False) - access = PyAccess.new(im, False) +def test_direct(): + im = hopper() + im.load() + # im = Image.new( "RGB", (2000, 2000), (1, 3, 2)) + caccess = im.im.pixel_access(False) + access = PyAccess.new(im, False) - assert caccess[(0, 0)] == access[(0, 0)] + assert caccess[(0, 0)] == access[(0, 0)] - print("Size: %sx%s" % im.size) - timer(iterate_get, "PyAccess - get", im.size, access) - timer(iterate_set, "PyAccess - set", im.size, access) - timer(iterate_get, "C-api - get", im.size, caccess) - timer(iterate_set, "C-api - set", im.size, caccess) - - -if __name__ == "__main__": - unittest.main() + print("Size: %sx%s" % im.size) + timer(iterate_get, "PyAccess - get", im.size, access) + timer(iterate_set, "PyAccess - set", im.size, access) + timer(iterate_get, "C-api - get", im.size, caccess) + timer(iterate_set, "C-api - set", im.size, caccess) diff --git a/Tests/check_fli_overflow.py b/Tests/check_fli_overflow.py index 206a86007..08a55d349 100644 --- a/Tests/check_fli_overflow.py +++ b/Tests/check_fli_overflow.py @@ -1,19 +1,10 @@ -import unittest - from PIL import Image -from .helper import PillowTestCase - TEST_FILE = "Tests/images/fli_overflow.fli" -class TestFliOverflow(PillowTestCase): - def test_fli_overflow(self): +def test_fli_overflow(): - # this should not crash with a malloc error or access violation - with Image.open(TEST_FILE) as im: - im.load() - - -if __name__ == "__main__": - unittest.main() + # this should not crash with a malloc error or access violation + with Image.open(TEST_FILE) as im: + im.load() diff --git a/Tests/check_imaging_leaks.py b/Tests/check_imaging_leaks.py index 8ca955ac7..db12d00e3 100755 --- a/Tests/check_imaging_leaks.py +++ b/Tests/check_imaging_leaks.py @@ -1,46 +1,44 @@ #!/usr/bin/env python -import unittest - +import pytest from PIL import Image -from .helper import PillowTestCase, is_win32 +from .helper import is_win32 min_iterations = 100 max_iterations = 10000 - -@unittest.skipIf(is_win32(), "requires Unix or macOS") -class TestImagingLeaks(PillowTestCase): - def _get_mem_usage(self): - from resource import getpagesize, getrusage, RUSAGE_SELF - - mem = getrusage(RUSAGE_SELF).ru_maxrss - return mem * getpagesize() / 1024 / 1024 - - def _test_leak(self, min_iterations, max_iterations, fn, *args, **kwargs): - mem_limit = None - for i in range(max_iterations): - fn(*args, **kwargs) - mem = self._get_mem_usage() - if i < min_iterations: - mem_limit = mem + 1 - continue - msg = "memory usage limit exceeded after %d iterations" % (i + 1) - assert mem <= mem_limit, msg - - def test_leak_putdata(self): - im = Image.new("RGB", (25, 25)) - self._test_leak(min_iterations, max_iterations, im.putdata, im.getdata()) - - def test_leak_getlist(self): - im = Image.new("P", (25, 25)) - self._test_leak( - min_iterations, - max_iterations, - # Pass a new list at each iteration. - lambda: im.point(range(256)), - ) +pytestmark = pytest.mark.skipif(is_win32(), reason="requires Unix or macOS") -if __name__ == "__main__": - unittest.main() +def _get_mem_usage(): + from resource import getpagesize, getrusage, RUSAGE_SELF + + mem = getrusage(RUSAGE_SELF).ru_maxrss + return mem * getpagesize() / 1024 / 1024 + + +def _test_leak(min_iterations, max_iterations, fn, *args, **kwargs): + mem_limit = None + for i in range(max_iterations): + fn(*args, **kwargs) + mem = _get_mem_usage() + if i < min_iterations: + mem_limit = mem + 1 + continue + msg = "memory usage limit exceeded after %d iterations" % (i + 1) + assert mem <= mem_limit, msg + + +def test_leak_putdata(): + im = Image.new("RGB", (25, 25)) + _test_leak(min_iterations, max_iterations, im.putdata, im.getdata()) + + +def test_leak_getlist(): + im = Image.new("P", (25, 25)) + _test_leak( + min_iterations, + max_iterations, + # Pass a new list at each iteration. + lambda: im.point(range(256)), + ) diff --git a/Tests/check_j2k_leaks.py b/Tests/check_j2k_leaks.py index a7a91f782..5cef4b544 100755 --- a/Tests/check_j2k_leaks.py +++ b/Tests/check_j2k_leaks.py @@ -1,9 +1,9 @@ -import unittest from io import BytesIO +import pytest from PIL import Image -from .helper import PillowTestCase, is_win32, skip_unless_feature +from .helper import is_win32, skip_unless_feature # Limits for testing the leak mem_limit = 1024 * 1048576 @@ -11,32 +11,31 @@ stack_size = 8 * 1048576 iterations = int((mem_limit / stack_size) * 2) test_file = "Tests/images/rgb_trns_ycbc.jp2" - -@unittest.skipIf(is_win32(), "requires Unix or macOS") -@skip_unless_feature("jpg_2000") -class TestJpegLeaks(PillowTestCase): - def test_leak_load(self): - from resource import setrlimit, RLIMIT_AS, RLIMIT_STACK - - setrlimit(RLIMIT_STACK, (stack_size, stack_size)) - setrlimit(RLIMIT_AS, (mem_limit, mem_limit)) - for _ in range(iterations): - with Image.open(test_file) as im: - im.load() - - def test_leak_save(self): - from resource import setrlimit, RLIMIT_AS, RLIMIT_STACK - - setrlimit(RLIMIT_STACK, (stack_size, stack_size)) - setrlimit(RLIMIT_AS, (mem_limit, mem_limit)) - for _ in range(iterations): - with Image.open(test_file) as im: - im.load() - test_output = BytesIO() - im.save(test_output, "JPEG2000") - test_output.seek(0) - test_output.read() +pytestmark = [ + pytest.mark.skipif(is_win32(), reason="requires Unix or macOS"), + skip_unless_feature("jpg_2000"), +] -if __name__ == "__main__": - unittest.main() +def test_leak_load(): + from resource import setrlimit, RLIMIT_AS, RLIMIT_STACK + + setrlimit(RLIMIT_STACK, (stack_size, stack_size)) + setrlimit(RLIMIT_AS, (mem_limit, mem_limit)) + for _ in range(iterations): + with Image.open(test_file) as im: + im.load() + + +def test_leak_save(): + from resource import setrlimit, RLIMIT_AS, RLIMIT_STACK + + setrlimit(RLIMIT_STACK, (stack_size, stack_size)) + setrlimit(RLIMIT_AS, (mem_limit, mem_limit)) + for _ in range(iterations): + with Image.open(test_file) as im: + im.load() + test_output = BytesIO() + im.save(test_output, "JPEG2000") + test_output.seek(0) + test_output.read() diff --git a/Tests/check_j2k_overflow.py b/Tests/check_j2k_overflow.py index d5b6e455f..f20ad6748 100644 --- a/Tests/check_j2k_overflow.py +++ b/Tests/check_j2k_overflow.py @@ -1,18 +1,9 @@ -import unittest - +import pytest from PIL import Image -from .helper import PillowTestCase - -class TestJ2kEncodeOverflow(PillowTestCase): - def test_j2k_overflow(self): - - im = Image.new("RGBA", (1024, 131584)) - target = self.tempfile("temp.jpc") - with self.assertRaises(IOError): - im.save(target) - - -if __name__ == "__main__": - unittest.main() +def test_j2k_overflow(tmp_path): + im = Image.new("RGBA", (1024, 131584)) + target = str(tmp_path / "temp.jpc") + with pytest.raises(IOError): + im.save(target) diff --git a/Tests/check_jpeg_leaks.py b/Tests/check_jpeg_leaks.py index 6b2801a21..b63fa2a1e 100644 --- a/Tests/check_jpeg_leaks.py +++ b/Tests/check_jpeg_leaks.py @@ -1,7 +1,8 @@ -import unittest from io import BytesIO -from .helper import PillowTestCase, hopper, is_win32 +import pytest + +from .helper import hopper, is_win32 iterations = 5000 @@ -15,10 +16,9 @@ valgrind --tool=massif python test-installed.py -s -v Tests/check_jpeg_leaks.py """ -@unittest.skipIf(is_win32(), "requires Unix or macOS") -class TestJpegLeaks(PillowTestCase): +pytestmark = pytest.mark.skipif(is_win32(), reason="requires Unix or macOS") - """ +""" pre patch: MB @@ -74,49 +74,51 @@ post-patch: """ - def test_qtables_leak(self): - im = hopper("RGB") - standard_l_qtable = [ - int(s) - for s in """ - 16 11 10 16 24 40 51 61 - 12 12 14 19 26 58 60 55 - 14 13 16 24 40 57 69 56 - 14 17 22 29 51 87 80 62 - 18 22 37 56 68 109 103 77 - 24 35 55 64 81 104 113 92 - 49 64 78 87 103 121 120 101 - 72 92 95 98 112 100 103 99 - """.split( - None - ) - ] +def test_qtables_leak(): + im = hopper("RGB") - standard_chrominance_qtable = [ - int(s) - for s in """ - 17 18 24 47 99 99 99 99 - 18 21 26 66 99 99 99 99 - 24 26 56 99 99 99 99 99 - 47 66 99 99 99 99 99 99 - 99 99 99 99 99 99 99 99 - 99 99 99 99 99 99 99 99 - 99 99 99 99 99 99 99 99 - 99 99 99 99 99 99 99 99 - """.split( - None - ) - ] + standard_l_qtable = [ + int(s) + for s in """ + 16 11 10 16 24 40 51 61 + 12 12 14 19 26 58 60 55 + 14 13 16 24 40 57 69 56 + 14 17 22 29 51 87 80 62 + 18 22 37 56 68 109 103 77 + 24 35 55 64 81 104 113 92 + 49 64 78 87 103 121 120 101 + 72 92 95 98 112 100 103 99 + """.split( + None + ) + ] - qtables = [standard_l_qtable, standard_chrominance_qtable] + standard_chrominance_qtable = [ + int(s) + for s in """ + 17 18 24 47 99 99 99 99 + 18 21 26 66 99 99 99 99 + 24 26 56 99 99 99 99 99 + 47 66 99 99 99 99 99 99 + 99 99 99 99 99 99 99 99 + 99 99 99 99 99 99 99 99 + 99 99 99 99 99 99 99 99 + 99 99 99 99 99 99 99 99 + """.split( + None + ) + ] - for _ in range(iterations): - test_output = BytesIO() - im.save(test_output, "JPEG", qtables=qtables) + qtables = [standard_l_qtable, standard_chrominance_qtable] - def test_exif_leak(self): - """ + for _ in range(iterations): + test_output = BytesIO() + im.save(test_output, "JPEG", qtables=qtables) + + +def test_exif_leak(): + """ pre patch: MB @@ -171,15 +173,16 @@ post patch: 0 11.33 """ - im = hopper("RGB") - exif = b"12345678" * 4096 + im = hopper("RGB") + exif = b"12345678" * 4096 - for _ in range(iterations): - test_output = BytesIO() - im.save(test_output, "JPEG", exif=exif) + for _ in range(iterations): + test_output = BytesIO() + im.save(test_output, "JPEG", exif=exif) - def test_base_save(self): - """ + +def test_base_save(): + """ base case: MB 20.99^ ::::: :::::::::::::::::::::::::::::::::::::::::::@::: @@ -205,12 +208,8 @@ base case: 0 +----------------------------------------------------------------------->Gi 0 7.882 """ - im = hopper("RGB") + im = hopper("RGB") - for _ in range(iterations): - test_output = BytesIO() - im.save(test_output, "JPEG") - - -if __name__ == "__main__": - unittest.main() + for _ in range(iterations): + test_output = BytesIO() + im.save(test_output, "JPEG") diff --git a/Tests/check_large_memory.py b/Tests/check_large_memory.py index 7fcaa4cf9..f44a5a5bb 100644 --- a/Tests/check_large_memory.py +++ b/Tests/check_large_memory.py @@ -1,10 +1,8 @@ import sys -import unittest +import pytest from PIL import Image -from .helper import PillowTestCase - # This test is not run automatically. # # It requires > 2gb memory for the >2 gigapixel image generated in the @@ -24,26 +22,26 @@ YDIM = 32769 XDIM = 48000 -@unittest.skipIf(sys.maxsize <= 2 ** 32, "requires 64-bit system") -class LargeMemoryTest(PillowTestCase): - def _write_png(self, xdim, ydim): - f = self.tempfile("temp.png") - im = Image.new("L", (xdim, ydim), 0) - im.save(f) - - def test_large(self): - """ succeeded prepatch""" - self._write_png(XDIM, YDIM) - - def test_2gpx(self): - """failed prepatch""" - self._write_png(XDIM, XDIM) - - @unittest.skipIf(numpy is None, "Numpy is not installed") - def test_size_greater_than_int(self): - arr = numpy.ndarray(shape=(16394, 16394)) - Image.fromarray(arr) +pytestmark = pytest.mark.skipif(sys.maxsize <= 2 ** 32, reason="requires 64-bit system") -if __name__ == "__main__": - unittest.main() +def _write_png(tmp_path, xdim, ydim): + f = str(tmp_path / "temp.png") + im = Image.new("L", (xdim, ydim), 0) + im.save(f) + + +def test_large(tmp_path): + """ succeeded prepatch""" + _write_png(tmp_path, XDIM, YDIM) + + +def test_2gpx(tmp_path): + """failed prepatch""" + _write_png(tmp_path, XDIM, XDIM) + + +@pytest.mark.skipif(numpy is None, reason="Numpy is not installed") +def test_size_greater_than_int(): + arr = numpy.ndarray(shape=(16394, 16394)) + Image.fromarray(arr) diff --git a/Tests/check_large_memory_numpy.py b/Tests/check_large_memory_numpy.py index 8e65dc1cb..de6f4571c 100644 --- a/Tests/check_large_memory_numpy.py +++ b/Tests/check_large_memory_numpy.py @@ -1,10 +1,8 @@ import sys -import unittest +import pytest from PIL import Image -from .helper import PillowTestCase - # This test is not run automatically. # # It requires > 2gb memory for the >2 gigapixel image generated in the @@ -14,32 +12,28 @@ from .helper import PillowTestCase # Raspberry Pis). -try: - import numpy as np -except ImportError: - raise unittest.SkipTest("numpy not installed") +np = pytest.importorskip("numpy", reason="NumPy not installed") YDIM = 32769 XDIM = 48000 -@unittest.skipIf(sys.maxsize <= 2 ** 32, "requires 64-bit system") -class LargeMemoryNumpyTest(PillowTestCase): - def _write_png(self, xdim, ydim): - dtype = np.uint8 - a = np.zeros((xdim, ydim), dtype=dtype) - f = self.tempfile("temp.png") - im = Image.fromarray(a, "L") - im.save(f) - - def test_large(self): - """ succeeded prepatch""" - self._write_png(XDIM, YDIM) - - def test_2gpx(self): - """failed prepatch""" - self._write_png(XDIM, XDIM) +pytestmark = pytest.mark.skipif(sys.maxsize <= 2 ** 32, reason="requires 64-bit system") -if __name__ == "__main__": - unittest.main() +def _write_png(tmp_path, xdim, ydim): + dtype = np.uint8 + a = np.zeros((xdim, ydim), dtype=dtype) + f = str(tmp_path / "temp.png") + im = Image.fromarray(a, "L") + im.save(f) + + +def test_large(tmp_path): + """ succeeded prepatch""" + _write_png(tmp_path, XDIM, YDIM) + + +def test_2gpx(tmp_path): + """failed prepatch""" + _write_png(tmp_path, XDIM, XDIM) diff --git a/Tests/check_libtiff_segfault.py b/Tests/check_libtiff_segfault.py index 711168f65..5187385d6 100644 --- a/Tests/check_libtiff_segfault.py +++ b/Tests/check_libtiff_segfault.py @@ -1,23 +1,14 @@ -import unittest - import pytest from PIL import Image -from .helper import PillowTestCase - TEST_FILE = "Tests/images/libtiff_segfault.tif" -class TestLibtiffSegfault(PillowTestCase): - def test_segfault(self): - """ This test should not segfault. It will on Pillow <= 3.1.0 and - libtiff >= 4.0.0 - """ +def test_libtiff_segfault(): + """ This test should not segfault. It will on Pillow <= 3.1.0 and + libtiff >= 4.0.0 + """ - with pytest.raises(IOError): - with Image.open(TEST_FILE) as im: - im.load() - - -if __name__ == "__main__": - unittest.main() + with pytest.raises(IOError): + with Image.open(TEST_FILE) as im: + im.load() diff --git a/Tests/check_png_dos.py b/Tests/check_png_dos.py index b981d36bf..86eb937e9 100644 --- a/Tests/check_png_dos.py +++ b/Tests/check_png_dos.py @@ -1,67 +1,61 @@ -import unittest import zlib from io import BytesIO from PIL import Image, ImageFile, PngImagePlugin -from .helper import PillowTestCase - TEST_FILE = "Tests/images/png_decompression_dos.png" -class TestPngDos(PillowTestCase): - def test_ignore_dos_text(self): - ImageFile.LOAD_TRUNCATED_IMAGES = True +def test_ignore_dos_text(): + ImageFile.LOAD_TRUNCATED_IMAGES = True - try: - im = Image.open(TEST_FILE) - im.load() - finally: - ImageFile.LOAD_TRUNCATED_IMAGES = False + try: + im = Image.open(TEST_FILE) + im.load() + finally: + ImageFile.LOAD_TRUNCATED_IMAGES = False - for s in im.text.values(): - assert len(s) < 1024 * 1024, "Text chunk larger than 1M" + for s in im.text.values(): + assert len(s) < 1024 * 1024, "Text chunk larger than 1M" - for s in im.info.values(): - assert len(s) < 1024 * 1024, "Text chunk larger than 1M" - - def test_dos_text(self): - - try: - im = Image.open(TEST_FILE) - im.load() - except ValueError as msg: - assert msg, "Decompressed Data Too Large" - return - - for s in im.text.values(): - assert len(s) < 1024 * 1024, "Text chunk larger than 1M" - - def test_dos_total_memory(self): - im = Image.new("L", (1, 1)) - compressed_data = zlib.compress(b"a" * 1024 * 1023) - - info = PngImagePlugin.PngInfo() - - for x in range(64): - info.add_text("t%s" % x, compressed_data, zip=True) - info.add_itxt("i%s" % x, compressed_data, zip=True) - - b = BytesIO() - im.save(b, "PNG", pnginfo=info) - b.seek(0) - - try: - im2 = Image.open(b) - except ValueError as msg: - assert "Too much memory" in msg - return - - total_len = 0 - for txt in im2.text.values(): - total_len += len(txt) - assert total_len < 64 * 1024 * 1024, "Total text chunks greater than 64M" + for s in im.info.values(): + assert len(s) < 1024 * 1024, "Text chunk larger than 1M" -if __name__ == "__main__": - unittest.main() +def test_dos_text(): + + try: + im = Image.open(TEST_FILE) + im.load() + except ValueError as msg: + assert msg, "Decompressed Data Too Large" + return + + for s in im.text.values(): + assert len(s) < 1024 * 1024, "Text chunk larger than 1M" + + +def test_dos_total_memory(): + im = Image.new("L", (1, 1)) + compressed_data = zlib.compress(b"a" * 1024 * 1023) + + info = PngImagePlugin.PngInfo() + + for x in range(64): + info.add_text("t%s" % x, compressed_data, zip=True) + info.add_itxt("i%s" % x, compressed_data, zip=True) + + b = BytesIO() + im.save(b, "PNG", pnginfo=info) + b.seek(0) + + try: + im2 = Image.open(b) + except ValueError as msg: + assert "Too much memory" in msg + return + + total_len = 0 + for txt in im2.text.values(): + total_len += len(txt) + assert total_len < 64 * 1024 * 1024, "Total text chunks greater than 64M" diff --git a/Tests/helper.py b/Tests/helper.py index 39d3ed482..15a51ccd1 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -7,7 +7,6 @@ import os import shutil import sys import tempfile -import unittest from io import BytesIO import pytest @@ -176,22 +175,6 @@ def skip_unless_feature(feature): return pytest.mark.skipif(not features.check(feature), reason=reason) -class PillowTestCase(unittest.TestCase): - def delete_tempfile(self, path): - try: - os.remove(path) - except OSError: - pass # report? - - def tempfile(self, template): - assert template[:5] in ("temp.", "temp_") - fd, path = tempfile.mkstemp(template[4:], template[:4]) - os.close(fd) - - self.addCleanup(self.delete_tempfile, path) - return path - - @pytest.mark.skipif(sys.platform.startswith("win32"), reason="Requires Unix or macOS") class PillowLeakTestCase: # requires unix/macOS diff --git a/Tests/test_decompression_bomb.py b/Tests/test_decompression_bomb.py index 58bcbd085..1704400b4 100644 --- a/Tests/test_decompression_bomb.py +++ b/Tests/test_decompression_bomb.py @@ -1,15 +1,16 @@ import pytest from PIL import Image -from .helper import PillowTestCase, hopper +from .helper import hopper TEST_FILE = "Tests/images/hopper.ppm" ORIGINAL_LIMIT = Image.MAX_IMAGE_PIXELS -class TestDecompressionBomb(PillowTestCase): - def tearDown(self): +class TestDecompressionBomb: + @classmethod + def teardown_class(self): Image.MAX_IMAGE_PIXELS = ORIGINAL_LIMIT def test_no_warning_small_file(self): @@ -59,12 +60,14 @@ class TestDecompressionBomb(PillowTestCase): Image.open("Tests/images/decompression_bomb.gif") -class TestDecompressionCrop(PillowTestCase): - def setUp(self): +class TestDecompressionCrop: + @classmethod + def setup_class(self): width, height = 128, 128 Image.MAX_IMAGE_PIXELS = height * width * 4 - 1 - def tearDown(self): + @classmethod + def teardown_class(self): Image.MAX_IMAGE_PIXELS = ORIGINAL_LIMIT def testEnlargeCrop(self): diff --git a/Tests/test_file_wmf.py b/Tests/test_file_wmf.py index 1c2c0442b..71b6012c3 100644 --- a/Tests/test_file_wmf.py +++ b/Tests/test_file_wmf.py @@ -1,74 +1,77 @@ import pytest from PIL import Image, WmfImagePlugin -from .helper import PillowTestCase, assert_image_similar, hopper +from .helper import assert_image_similar, hopper -class TestFileWmf(PillowTestCase): - def test_load_raw(self): +def test_load_raw(): - # Test basic EMF open and rendering - with Image.open("Tests/images/drawing.emf") as im: - if hasattr(Image.core, "drawwmf"): - # Currently, support for WMF/EMF is Windows-only - im.load() - # Compare to reference rendering - with Image.open("Tests/images/drawing_emf_ref.png") as imref: - imref.load() - assert_image_similar(im, imref, 0) + # Test basic EMF open and rendering + with Image.open("Tests/images/drawing.emf") as im: + if hasattr(Image.core, "drawwmf"): + # Currently, support for WMF/EMF is Windows-only + im.load() + # Compare to reference rendering + with Image.open("Tests/images/drawing_emf_ref.png") as imref: + imref.load() + assert_image_similar(im, imref, 0) - # Test basic WMF open and rendering - with Image.open("Tests/images/drawing.wmf") as im: - if hasattr(Image.core, "drawwmf"): - # Currently, support for WMF/EMF is Windows-only - im.load() - # Compare to reference rendering - with Image.open("Tests/images/drawing_wmf_ref.png") as imref: - imref.load() - assert_image_similar(im, imref, 2.0) + # Test basic WMF open and rendering + with Image.open("Tests/images/drawing.wmf") as im: + if hasattr(Image.core, "drawwmf"): + # Currently, support for WMF/EMF is Windows-only + im.load() + # Compare to reference rendering + with Image.open("Tests/images/drawing_wmf_ref.png") as imref: + imref.load() + assert_image_similar(im, imref, 2.0) - def test_register_handler(self): - class TestHandler: - methodCalled = False - def save(self, im, fp, filename): - self.methodCalled = True +def test_register_handler(tmp_path): + class TestHandler: + methodCalled = False - handler = TestHandler() - WmfImagePlugin.register_handler(handler) + def save(self, im, fp, filename): + self.methodCalled = True - im = hopper() - tmpfile = self.tempfile("temp.wmf") - im.save(tmpfile) - assert handler.methodCalled + handler = TestHandler() + WmfImagePlugin.register_handler(handler) - # Restore the state before this test - WmfImagePlugin.register_handler(None) + im = hopper() + tmpfile = str(tmp_path / "temp.wmf") + im.save(tmpfile) + assert handler.methodCalled - def test_load_dpi_rounding(self): - # Round up - with Image.open("Tests/images/drawing.emf") as im: - assert im.info["dpi"] == 1424 + # Restore the state before this test + WmfImagePlugin.register_handler(None) - # Round down - with Image.open("Tests/images/drawing_roundDown.emf") as im: - assert im.info["dpi"] == 1426 - def test_load_set_dpi(self): - with Image.open("Tests/images/drawing.wmf") as im: - assert im.size == (82, 82) +def test_load_dpi_rounding(): + # Round up + with Image.open("Tests/images/drawing.emf") as im: + assert im.info["dpi"] == 1424 - if hasattr(Image.core, "drawwmf"): - im.load(144) - assert im.size == (164, 164) + # Round down + with Image.open("Tests/images/drawing_roundDown.emf") as im: + assert im.info["dpi"] == 1426 - with Image.open("Tests/images/drawing_wmf_ref_144.png") as expected: - assert_image_similar(im, expected, 2.0) - def test_save(self): - im = hopper() +def test_load_set_dpi(): + with Image.open("Tests/images/drawing.wmf") as im: + assert im.size == (82, 82) - for ext in [".wmf", ".emf"]: - tmpfile = self.tempfile("temp" + ext) - with pytest.raises(IOError): - im.save(tmpfile) + if hasattr(Image.core, "drawwmf"): + im.load(144) + assert im.size == (164, 164) + + with Image.open("Tests/images/drawing_wmf_ref_144.png") as expected: + assert_image_similar(im, expected, 2.0) + + +def test_save(tmp_path): + im = hopper() + + for ext in [".wmf", ".emf"]: + tmpfile = str(tmp_path / ("temp" + ext)) + with pytest.raises(IOError): + im.save(tmpfile) diff --git a/Tests/test_font_pcf.py b/Tests/test_font_pcf.py index 2f19beeb2..afd0c38b2 100644 --- a/Tests/test_font_pcf.py +++ b/Tests/test_font_pcf.py @@ -1,12 +1,9 @@ import os + import pytest from PIL import FontFile, Image, ImageDraw, ImageFont, PcfFontFile -from .helper import ( - assert_image_equal, - assert_image_similar, - skip_unless_feature, -) +from .helper import assert_image_equal, assert_image_similar, skip_unless_feature fontname = "Tests/fonts/10x20-ISO8859-1.pcf" diff --git a/Tests/test_image_fromqimage.py b/Tests/test_image_fromqimage.py index b653edb16..170d49ae1 100644 --- a/Tests/test_image_fromqimage.py +++ b/Tests/test_image_fromqimage.py @@ -2,10 +2,10 @@ import pytest from PIL import Image, ImageQt from .helper import assert_image_equal, hopper -from .test_imageqt import skip_if_qt_is_not_installed - -pytestmark = skip_if_qt_is_not_installed() +pytestmark = pytest.mark.skipif( + not ImageQt.qt_is_installed, reason="Qt bindings are not installed" +) @pytest.fixture @@ -18,7 +18,7 @@ def test_images(): try: yield ims finally: - for im in ims.values(): + for im in ims: im.close() diff --git a/Tests/test_image_paste.py b/Tests/test_image_paste.py index 4e97ee50b..1d3ca8135 100644 --- a/Tests/test_image_paste.py +++ b/Tests/test_image_paste.py @@ -1,9 +1,9 @@ from PIL import Image -from .helper import PillowTestCase, assert_image_equal, cached_property +from .helper import assert_image_equal, cached_property -class TestImagingPaste(PillowTestCase): +class TestImagingPaste: masks = {} size = 128 diff --git a/Tests/test_image_resize.py b/Tests/test_image_resize.py index 3281836d5..ad4be135a 100644 --- a/Tests/test_image_resize.py +++ b/Tests/test_image_resize.py @@ -6,10 +6,10 @@ from itertools import permutations import pytest from PIL import Image -from .helper import PillowTestCase, assert_image_equal, assert_image_similar, hopper +from .helper import assert_image_equal, assert_image_similar, hopper -class TestImagingCoreResize(PillowTestCase): +class TestImagingCoreResize: def resize(self, im, size, f): # Image class independent version of resize. im.load() @@ -135,31 +135,36 @@ class TestImagingCoreResize(PillowTestCase): self.resize(hopper(), (10, 10), 9) -class TestReducingGapResize(PillowTestCase): - @classmethod - def setUpClass(cls): - cls.gradients_image = Image.open("Tests/images/radial_gradients.png") - cls.gradients_image.load() +@pytest.fixture +def gradients_image(): + im = Image.open("Tests/images/radial_gradients.png") + im.load() + try: + yield im + finally: + im.close() - def test_reducing_gap_values(self): - ref = self.gradients_image.resize((52, 34), Image.BICUBIC, reducing_gap=None) - im = self.gradients_image.resize((52, 34), Image.BICUBIC) + +class TestReducingGapResize: + def test_reducing_gap_values(self, gradients_image): + ref = gradients_image.resize((52, 34), Image.BICUBIC, reducing_gap=None) + im = gradients_image.resize((52, 34), Image.BICUBIC) assert_image_equal(ref, im) with pytest.raises(ValueError): - self.gradients_image.resize((52, 34), Image.BICUBIC, reducing_gap=0) + gradients_image.resize((52, 34), Image.BICUBIC, reducing_gap=0) with pytest.raises(ValueError): - self.gradients_image.resize((52, 34), Image.BICUBIC, reducing_gap=0.99) + gradients_image.resize((52, 34), Image.BICUBIC, reducing_gap=0.99) - def test_reducing_gap_1(self): + def test_reducing_gap_1(self, gradients_image): for box, epsilon in [ (None, 4), ((1.1, 2.2, 510.8, 510.9), 4), ((3, 10, 410, 256), 10), ]: - ref = self.gradients_image.resize((52, 34), Image.BICUBIC, box=box) - im = self.gradients_image.resize( + ref = gradients_image.resize((52, 34), Image.BICUBIC, box=box) + im = gradients_image.resize( (52, 34), Image.BICUBIC, box=box, reducing_gap=1.0 ) @@ -168,14 +173,14 @@ class TestReducingGapResize(PillowTestCase): assert_image_similar(ref, im, epsilon) - def test_reducing_gap_2(self): + def test_reducing_gap_2(self, gradients_image): for box, epsilon in [ (None, 1.5), ((1.1, 2.2, 510.8, 510.9), 1.5), ((3, 10, 410, 256), 1), ]: - ref = self.gradients_image.resize((52, 34), Image.BICUBIC, box=box) - im = self.gradients_image.resize( + ref = gradients_image.resize((52, 34), Image.BICUBIC, box=box) + im = gradients_image.resize( (52, 34), Image.BICUBIC, box=box, reducing_gap=2.0 ) @@ -184,14 +189,14 @@ class TestReducingGapResize(PillowTestCase): assert_image_similar(ref, im, epsilon) - def test_reducing_gap_3(self): + def test_reducing_gap_3(self, gradients_image): for box, epsilon in [ (None, 1), ((1.1, 2.2, 510.8, 510.9), 1), ((3, 10, 410, 256), 0.5), ]: - ref = self.gradients_image.resize((52, 34), Image.BICUBIC, box=box) - im = self.gradients_image.resize( + ref = gradients_image.resize((52, 34), Image.BICUBIC, box=box) + im = gradients_image.resize( (52, 34), Image.BICUBIC, box=box, reducing_gap=3.0 ) @@ -200,29 +205,27 @@ class TestReducingGapResize(PillowTestCase): assert_image_similar(ref, im, epsilon) - def test_reducing_gap_8(self): + def test_reducing_gap_8(self, gradients_image): for box in [None, (1.1, 2.2, 510.8, 510.9), (3, 10, 410, 256)]: - ref = self.gradients_image.resize((52, 34), Image.BICUBIC, box=box) - im = self.gradients_image.resize( + ref = gradients_image.resize((52, 34), Image.BICUBIC, box=box) + im = gradients_image.resize( (52, 34), Image.BICUBIC, box=box, reducing_gap=8.0 ) assert_image_equal(ref, im) - def test_box_filter(self): + def test_box_filter(self, gradients_image): for box, epsilon in [ ((0, 0, 512, 512), 5.5), ((0.9, 1.7, 128, 128), 9.5), ]: - ref = self.gradients_image.resize((52, 34), Image.BOX, box=box) - im = self.gradients_image.resize( - (52, 34), Image.BOX, box=box, reducing_gap=1.0 - ) + ref = gradients_image.resize((52, 34), Image.BOX, box=box) + im = gradients_image.resize((52, 34), Image.BOX, box=box, reducing_gap=1.0) assert_image_similar(ref, im, epsilon) -class TestImageResize(PillowTestCase): +class TestImageResize: def test_resize(self): def resize(mode, size): out = hopper(mode).resize(size) diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index b3686aea1..75eec44a0 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -10,7 +10,6 @@ import pytest from PIL import Image, ImageDraw, ImageFont from .helper import ( - PillowTestCase, assert_image_equal, assert_image_similar, assert_image_similar_tofile, @@ -25,8 +24,10 @@ FONT_SIZE = 20 TEST_TEXT = "hey you\nyou are awesome\nthis looks awkward" -@skip_unless_feature("freetype2") -class TestImageFont(PillowTestCase): +pytestmark = skip_unless_feature("freetype2") + + +class TestImageFont: LAYOUT_ENGINE = ImageFont.LAYOUT_BASIC # Freetype has different metrics depending on the version. @@ -37,7 +38,8 @@ class TestImageFont(PillowTestCase): "Default": {"multiline": 0.5, "textsize": 0.5, "getters": (12, 16)}, } - def setUp(self): + @classmethod + def setup_class(self): freetype = distutils.version.StrictVersion(ImageFont.core.freetype2_version) self.metrics = self.METRICS["Default"] @@ -107,12 +109,12 @@ class TestImageFont(PillowTestCase): with open(FONT_PATH, "rb") as f: self._render(f) - def test_non_unicode_path(self): + def test_non_unicode_path(self, tmp_path): + tempfile = str(tmp_path / ("temp_" + chr(128) + ".ttf")) try: - tempfile = self.tempfile("temp_" + chr(128) + ".ttf") + shutil.copy(FONT_PATH, tempfile) except UnicodeEncodeError: - self.skipTest("Unicode path could not be created") - shutil.copy(FONT_PATH, tempfile) + pytest.skip("Unicode path could not be created") ImageFont.truetype(tempfile, FONT_SIZE) diff --git a/Tests/test_imageqt.py b/Tests/test_imageqt.py index fbb650861..d723690ef 100644 --- a/Tests/test_imageqt.py +++ b/Tests/test_imageqt.py @@ -6,11 +6,11 @@ from .helper import hopper if ImageQt.qt_is_installed: from PIL.ImageQt import qRgba - def skip_if_qt_is_not_installed(): - pass - @pytest.fixture - def qpixmap_app(): +@pytest.mark.skipif(not ImageQt.qt_is_installed, reason="Qt bindings are not installed") +class PillowQPixmapTestCase: + @classmethod + def setup_class(self): try: if ImageQt.qt_version == "5": from PyQt5.QtGui import QGuiApplication @@ -20,22 +20,15 @@ if ImageQt.qt_is_installed: pytest.skip("QGuiApplication not installed") return - app = QGuiApplication([]) - try: - yield - finally: - app.quit() - - -else: - - def skip_if_qt_is_not_installed(): - return pytest.mark.skip(reason="Qt bindings are not installed") - - -pytestmark = skip_if_qt_is_not_installed() + self.app = QGuiApplication([]) + + @classmethod + def teardown_class(self): + self.app.quit() + self.app = None +@pytest.mark.skipif(not ImageQt.qt_is_installed, reason="Qt bindings are not installed") def test_rgb(): # from https://doc.qt.io/archives/qt-4.8/qcolor.html # typedef QRgb @@ -61,6 +54,7 @@ def test_rgb(): checkrgb(0, 0, 255) +@pytest.mark.skipif(not ImageQt.qt_is_installed, reason="Qt bindings are not installed") def test_image(): for mode in ("1", "RGB", "RGBA", "L", "P"): ImageQt.ImageQt(hopper(mode)) diff --git a/Tests/test_qt_image_fromqpixmap.py b/Tests/test_qt_image_fromqpixmap.py index 96eaba41f..cb1b385ec 100644 --- a/Tests/test_qt_image_fromqpixmap.py +++ b/Tests/test_qt_image_fromqpixmap.py @@ -1,18 +1,15 @@ -import pytest from PIL import ImageQt from .helper import assert_image_equal, hopper -from .test_imageqt import qpixmap_app, skip_if_qt_is_not_installed - -pytestmark = skip_if_qt_is_not_installed() +from .test_imageqt import PillowQPixmapTestCase -def roundtrip(expected): - result = ImageQt.fromqpixmap(ImageQt.toqpixmap(expected)) - # Qt saves all pixmaps as rgb - assert_image_equal(result, expected.convert("RGB")) +class TestFromQPixmap(PillowQPixmapTestCase): + def roundtrip(self, expected): + result = ImageQt.fromqpixmap(ImageQt.toqpixmap(expected)) + # Qt saves all pixmaps as rgb + assert_image_equal(result, expected.convert("RGB")) - -def test_sanity(qpixmap_app): - for mode in ("1", "RGB", "RGBA", "L", "P"): - roundtrip(hopper(mode)) + def test_sanity(self): + for mode in ("1", "RGB", "RGBA", "L", "P"): + self.roundtrip(hopper(mode)) diff --git a/Tests/test_qt_image_toqimage.py b/Tests/test_qt_image_toqimage.py index e6fd18c52..4c98bf0b4 100644 --- a/Tests/test_qt_image_toqimage.py +++ b/Tests/test_qt_image_toqimage.py @@ -1,9 +1,11 @@ +import pytest from PIL import Image, ImageQt from .helper import assert_image_equal, hopper -from .test_imageqt import skip_if_qt_is_not_installed -pytestmark = skip_if_qt_is_not_installed() +pytestmark = pytest.mark.skipif( + not ImageQt.qt_is_installed, reason="Qt bindings are not installed" +) if ImageQt.qt_is_installed: from PIL.ImageQt import QImage diff --git a/Tests/test_qt_image_toqpixmap.py b/Tests/test_qt_image_toqpixmap.py index f7cf59709..af281da69 100644 --- a/Tests/test_qt_image_toqpixmap.py +++ b/Tests/test_qt_image_toqpixmap.py @@ -1,22 +1,20 @@ -import pytest from PIL import ImageQt from .helper import hopper -from .test_imageqt import qpixmap_app, skip_if_qt_is_not_installed +from .test_imageqt import PillowQPixmapTestCase if ImageQt.qt_is_installed: from PIL.ImageQt import QPixmap -pytestmark = skip_if_qt_is_not_installed() +class TestToQPixmap(PillowQPixmapTestCase): + def test_sanity(self, tmp_path): + for mode in ("1", "RGB", "RGBA", "L", "P"): + data = ImageQt.toqpixmap(hopper(mode)) -def test_sanity(qpixmap, tmp_path): - for mode in ("1", "RGB", "RGBA", "L", "P"): - data = ImageQt.toqpixmap(hopper(mode)) + assert isinstance(data, QPixmap) + assert not data.isNull() - assert isinstance(data, QPixmap) - assert not data.isNull() - - # Test saving the file - tempfile = str(tmp_path / "temp_{}.png".format(mode)) - data.save(tempfile) + # Test saving the file + tempfile = str(tmp_path / "temp_{}.png".format(mode)) + data.save(tempfile) From cbf0bf1010c4611565a377e13e83a518f26b9156 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 27 Mar 2020 22:15:04 +1100 Subject: [PATCH 15/27] Fixed restoring original state --- Tests/test_file_wmf.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Tests/test_file_wmf.py b/Tests/test_file_wmf.py index 71b6012c3..03444eb9d 100644 --- a/Tests/test_file_wmf.py +++ b/Tests/test_file_wmf.py @@ -35,6 +35,7 @@ def test_register_handler(tmp_path): self.methodCalled = True handler = TestHandler() + original_handler = WmfImagePlugin._handler WmfImagePlugin.register_handler(handler) im = hopper() @@ -43,7 +44,7 @@ def test_register_handler(tmp_path): assert handler.methodCalled # Restore the state before this test - WmfImagePlugin.register_handler(None) + WmfImagePlugin.register_handler(original_handler) def test_load_dpi_rounding(): From 30a2d694cf1b07d104a7869fb0f87132932c64cb Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 29 Mar 2020 10:40:46 +1100 Subject: [PATCH 16/27] Converted unittest mock to pytest monkeypatch --- Tests/test_imagefont.py | 134 ++++++++++++++++++++-------------------- 1 file changed, 66 insertions(+), 68 deletions(-) diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 75eec44a0..e93aff4b2 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -3,8 +3,8 @@ import distutils.version import os import re import shutil +import sys from io import BytesIO -from unittest import mock import pytest from PIL import Image, ImageDraw, ImageFont @@ -459,10 +459,11 @@ class TestImageFont: assert_image_similar_tofile(img, target, self.metrics["multiline"]) - def _test_fake_loading_font(self, path_to_fake, fontname): + def _test_fake_loading_font(self, monkeypatch, path_to_fake, fontname): # Make a copy of FreeTypeFont so we can patch the original free_type_font = copy.deepcopy(ImageFont.FreeTypeFont) - with mock.patch.object(ImageFont, "_FreeTypeFont", free_type_font, create=True): + with monkeypatch.context() as m: + m.setattr(ImageFont, "_FreeTypeFont", free_type_font, raising=False) def loadable_font(filepath, size, index, encoding, *args, **kwargs): if filepath == path_to_fake: @@ -473,87 +474,84 @@ class TestImageFont: filepath, size, index, encoding, *args, **kwargs ) - with mock.patch.object(ImageFont, "FreeTypeFont", loadable_font): - font = ImageFont.truetype(fontname) - # Make sure it's loaded - name = font.getname() - assert ("FreeMono", "Regular") == name + m.setattr(ImageFont, "FreeTypeFont", loadable_font) + font = ImageFont.truetype(fontname) + # Make sure it's loaded + name = font.getname() + assert ("FreeMono", "Regular") == name @pytest.mark.skipif(is_win32(), reason="requires Unix or macOS") - def test_find_linux_font(self): + def test_find_linux_font(self, monkeypatch): # A lot of mocking here - this is more for hitting code and # catching syntax like errors font_directory = "/usr/local/share/fonts" - with mock.patch("sys.platform", "linux"): - patched_env = {"XDG_DATA_DIRS": "/usr/share/:/usr/local/share/"} - with mock.patch.dict(os.environ, patched_env): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setenv("XDG_DATA_DIRS", "/usr/share/:/usr/local/share/") - def fake_walker(path): - if path == font_directory: - return [ - ( - path, - [], - [ - "Arial.ttf", - "Single.otf", - "Duplicate.otf", - "Duplicate.ttf", - ], - ) - ] - return [(path, [], ["some_random_font.ttf"])] - - with mock.patch("os.walk", fake_walker): - # Test that the font loads both with and without the - # extension - self._test_fake_loading_font( - font_directory + "/Arial.ttf", "Arial.ttf" + def fake_walker(path): + if path == font_directory: + return [ + ( + path, + [], + ["Arial.ttf", "Single.otf", "Duplicate.otf", "Duplicate.ttf"], ) - self._test_fake_loading_font(font_directory + "/Arial.ttf", "Arial") + ] + return [(path, [], ["some_random_font.ttf"])] - # Test that non-ttf fonts can be found without the - # extension - self._test_fake_loading_font( - font_directory + "/Single.otf", "Single" - ) + monkeypatch.setattr(os, "walk", fake_walker) + # Test that the font loads both with and without the + # extension + self._test_fake_loading_font( + monkeypatch, font_directory + "/Arial.ttf", "Arial.ttf" + ) + self._test_fake_loading_font( + monkeypatch, font_directory + "/Arial.ttf", "Arial" + ) - # Test that ttf fonts are preferred if the extension is - # not specified - self._test_fake_loading_font( - font_directory + "/Duplicate.ttf", "Duplicate" - ) + # Test that non-ttf fonts can be found without the + # extension + self._test_fake_loading_font( + monkeypatch, font_directory + "/Single.otf", "Single" + ) + + # Test that ttf fonts are preferred if the extension is + # not specified + self._test_fake_loading_font( + monkeypatch, font_directory + "/Duplicate.ttf", "Duplicate" + ) @pytest.mark.skipif(is_win32(), reason="requires Unix or macOS") - def test_find_macos_font(self): + def test_find_macos_font(self, monkeypatch): # Like the linux test, more cover hitting code rather than testing # correctness. font_directory = "/System/Library/Fonts" - with mock.patch("sys.platform", "darwin"): + monkeypatch.setattr(sys, "platform", "darwin") - def fake_walker(path): - if path == font_directory: - return [ - ( - path, - [], - [ - "Arial.ttf", - "Single.otf", - "Duplicate.otf", - "Duplicate.ttf", - ], - ) - ] - return [(path, [], ["some_random_font.ttf"])] + def fake_walker(path): + if path == font_directory: + return [ + ( + path, + [], + ["Arial.ttf", "Single.otf", "Duplicate.otf", "Duplicate.ttf"], + ) + ] + return [(path, [], ["some_random_font.ttf"])] - with mock.patch("os.walk", fake_walker): - self._test_fake_loading_font(font_directory + "/Arial.ttf", "Arial.ttf") - self._test_fake_loading_font(font_directory + "/Arial.ttf", "Arial") - self._test_fake_loading_font(font_directory + "/Single.otf", "Single") - self._test_fake_loading_font( - font_directory + "/Duplicate.ttf", "Duplicate" - ) + monkeypatch.setattr(os, "walk", fake_walker) + self._test_fake_loading_font( + monkeypatch, font_directory + "/Arial.ttf", "Arial.ttf" + ) + self._test_fake_loading_font( + monkeypatch, font_directory + "/Arial.ttf", "Arial" + ) + self._test_fake_loading_font( + monkeypatch, font_directory + "/Single.otf", "Single" + ) + self._test_fake_loading_font( + monkeypatch, font_directory + "/Duplicate.ttf", "Duplicate" + ) def test_imagefont_getters(self): # Arrange From 963dfe6dbc0bfb97d19304de3937f466dea391ef Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 29 Mar 2020 12:34:00 +1100 Subject: [PATCH 17/27] Allow 0.01% drop in coverage --- codecov.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/codecov.yml b/codecov.yml index e93896692..f3afccc1c 100644 --- a/codecov.yml +++ b/codecov.yml @@ -8,6 +8,12 @@ codecov: comment: false +coverage: + status: + project: + default: + threshold: 0.01% + # Matches 'omit:' in .coveragerc ignore: - "Tests/32bit_segfault_check.py" From 0d87b1596c70fcae9fa4c3bd69e07533c2a21498 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 29 Mar 2020 16:05:42 +1100 Subject: [PATCH 18/27] Updated fribidi to 1.0.9 --- .github/workflows/test-windows.yml | 2 +- winbuild/config.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 6ae410a64..94d3854c4 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -269,7 +269,7 @@ jobs: set INCLUDE=C:\Program Files (x86)\Microsoft SDKs\Windows\V7.1A\Include set INCLIB=%GITHUB_WORKSPACE%\winbuild\depends\msvcr10-x32 set BUILD=%GITHUB_WORKSPACE%\winbuild\build - cd /D %BUILD%\fribidi-1.0.7 + cd /D %BUILD%\fribidi-1.0.9 call "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" ${{ matrix.platform-vcvars }} echo on copy /Y /B %GITHUB_WORKSPACE%\winbuild\fribidi.cmake CMakeLists.txt diff --git a/winbuild/config.py b/winbuild/config.py index f9b23b4ff..ef3a8619a 100644 --- a/winbuild/config.py +++ b/winbuild/config.py @@ -103,9 +103,9 @@ libs = { "dir": "harfbuzz-2.6.1", }, "fribidi": { - "url": "https://github.com/fribidi/fribidi/archive/v1.0.7.zip", - "filename": "fribidi-1.0.7.zip", - "dir": "fribidi-1.0.7", + "url": "https://github.com/fribidi/fribidi/archive/v1.0.9.zip", + "filename": "fribidi-1.0.9.zip", + "dir": "fribidi-1.0.9", }, "libraqm": { "url": "https://github.com/HOST-Oman/libraqm/archive/v0.7.0.zip", From 1af0f97c4a7a4d8900f906f61067c95e53557627 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 29 Mar 2020 16:05:49 +1100 Subject: [PATCH 19/27] Updated harfbuzz to 2.6.4 --- .github/workflows/test-windows.yml | 2 +- winbuild/config.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 94d3854c4..705c61e50 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -251,7 +251,7 @@ jobs: set BUILD=%GITHUB_WORKSPACE%\winbuild\build set INCLUDE=%INCLUDE%;%INCLIB% set LIB=%LIB%;%INCLIB% - cd /D %BUILD%\harfbuzz-2.6.1 + cd /D %BUILD%\harfbuzz-2.6.4 call "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\VC\Auxiliary\Build\vcvarsall.bat" ${{ matrix.platform-vcvars }} echo on set CMAKE=cmake.exe -DCMAKE_VERBOSE_MAKEFILE=ON -DCMAKE_RULE_MESSAGES:BOOL=OFF diff --git a/winbuild/config.py b/winbuild/config.py index ef3a8619a..93413d1e5 100644 --- a/winbuild/config.py +++ b/winbuild/config.py @@ -98,9 +98,9 @@ libs = { "dir": "libimagequant-e5d454bc7f5eb63ee50c84a83a7fa5ac94f68ec4", }, "harfbuzz": { - "url": "https://github.com/harfbuzz/harfbuzz/archive/2.6.1.zip", - "filename": "harfbuzz-2.6.1.zip", - "dir": "harfbuzz-2.6.1", + "url": "https://github.com/harfbuzz/harfbuzz/archive/2.6.4.zip", + "filename": "harfbuzz-2.6.4.zip", + "dir": "harfbuzz-2.6.4", }, "fribidi": { "url": "https://github.com/fribidi/fribidi/archive/v1.0.9.zip", From ff6ca4159a0f9547472eacbe42596d14bc95725b Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 24 Mar 2020 19:19:46 +1100 Subject: [PATCH 20/27] Prevent masking Image reduce method --- Tests/test_file_jpeg2k.py | 9 +++++++++ Tests/test_image_reduce.py | 11 +++++++++++ src/PIL/Image.py | 6 +++++- src/PIL/Jpeg2KImagePlugin.py | 18 +++++++++++++----- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index e37b46a41..0a80d9dee 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -127,10 +127,19 @@ def test_prog_res_rt(): def test_reduce(): with Image.open("Tests/images/test-card-lossless.jp2") as im: + assert callable(im.reduce) + im.reduce = 2 + assert im.reduce == 2 + im.load() assert im.size == (160, 120) + try: + im.thumbnail((40, 40)) + except ValueError as e: + assert str(e) == "box can't exceed original image size" + def test_layers_type(tmp_path): outfile = str(tmp_path / "temp_layers.jp2") diff --git a/Tests/test_image_reduce.py b/Tests/test_image_reduce.py index 658a0f513..729645a0b 100644 --- a/Tests/test_image_reduce.py +++ b/Tests/test_image_reduce.py @@ -3,6 +3,9 @@ from PIL import Image, ImageMath, ImageMode from .helper import convert_to_comparable +codecs = dir(Image.core) + + # There are several internal implementations remarkable_factors = [ # special implementations @@ -247,3 +250,11 @@ def test_mode_F(): for factor in remarkable_factors: compare_reduce_with_reference(im, factor, 0, 0) compare_reduce_with_box(im, factor) + + +@pytest.mark.skipif( + "jpeg2k_decoder" not in codecs, reason="JPEG 2000 support not available" +) +def test_jpeg2k(): + with Image.open("Tests/images/test-card-lossless.jp2") as im: + assert im.reduce(2).size == (320, 240) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index b1e8ad3ea..f296ff86b 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1866,7 +1866,11 @@ class Image: factor_y = int((box[3] - box[1]) / size[1] / reducing_gap) or 1 if factor_x > 1 or factor_y > 1: reduce_box = self._get_safe_box(size, resample, box) - self = self.reduce((factor_x, factor_y), box=reduce_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) box = ( (box[0] - reduce_box[0]) / factor_x, (box[1] - reduce_box[1]) / factor_y, diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index 2c51d3678..19499959f 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -176,7 +176,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile): if self.size is None or self.mode is None: raise SyntaxError("unable to determine size/mode") - self.reduce = 0 + self._reduce = 0 self.layers = 0 fd = -1 @@ -200,13 +200,21 @@ class Jpeg2KImageFile(ImageFile.ImageFile): "jpeg2k", (0, 0) + self.size, 0, - (self.codec, self.reduce, self.layers, fd, length), + (self.codec, self._reduce, self.layers, fd, length), ) ] + @property + def reduce(self): + return self._reduce or super().reduce + + @reduce.setter + def reduce(self, value): + self._reduce = value + def load(self): - if self.reduce: - power = 1 << self.reduce + if self._reduce: + power = 1 << self._reduce adjust = power >> 1 self._size = ( int((self.size[0] + adjust) / power), @@ -216,7 +224,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile): if self.tile: # Update the reduce and layers settings t = self.tile[0] - t3 = (t[3][0], self.reduce, self.layers, t[3][3], t[3][4]) + t3 = (t[3][0], self._reduce, self.layers, t[3][3], t[3][4]) self.tile = [(t[0], (0, 0) + self.size, t[2], t3)] return ImageFile.ImageFile.load(self) From 4f9118bdbd83b32723be1a8d364806686f1d3108 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 20 Mar 2020 17:49:36 +1100 Subject: [PATCH 21/27] Added comment [ci skip] --- src/PIL/Jpeg2KImagePlugin.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index 19499959f..2dab7808f 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -206,6 +206,9 @@ class Jpeg2KImageFile(ImageFile.ImageFile): @property def reduce(self): + # https://github.com/python-pillow/Pillow/issues/4343 found that the + # new Image 'reduce' method was shadowed by this plugin's 'reduce' + # property. This attempts to allow for both scenarios return self._reduce or super().reduce @reduce.setter From 6d8f2f95db026c61aeb6298b242c16d573cdf3bb Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Fri, 20 Mar 2020 18:14:08 +1100 Subject: [PATCH 22/27] Do not reduce size if tile already loaded Co-Authored-By: Alexander Karpinsky --- src/PIL/Jpeg2KImagePlugin.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index 2dab7808f..0b0d433db 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -216,7 +216,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile): self._reduce = value def load(self): - if self._reduce: + if self.tile and self._reduce: power = 1 << self._reduce adjust = power >> 1 self._size = ( @@ -224,7 +224,6 @@ class Jpeg2KImageFile(ImageFile.ImageFile): int((self.size[1] + adjust) / power), ) - if self.tile: # Update the reduce and layers settings t = self.tile[0] t3 = (t[3][0], self._reduce, self.layers, t[3][3], t[3][4]) From d00b9296514262acdf88fe2bcde7d2ed9a091afa Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sun, 29 Mar 2020 16:29:34 +1100 Subject: [PATCH 23/27] Updated test --- Tests/test_file_jpeg2k.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index 0a80d9dee..72bc7df67 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -135,10 +135,8 @@ def test_reduce(): im.load() assert im.size == (160, 120) - try: - im.thumbnail((40, 40)) - except ValueError as e: - assert str(e) == "box can't exceed original image size" + im.thumbnail((40, 40)) + assert im.size == (40, 30) def test_layers_type(tmp_path): From 4cdfd652ee49b8f7a2dd670fe5d0b99ada62c1b7 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 30 Mar 2020 07:33:34 +1100 Subject: [PATCH 24/27] Updated CHANGES.rst [ci skip] --- CHANGES.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 3c0355f91..3cbb88325 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ Changelog (Pillow) 7.1.0 (unreleased) ------------------ +- Prevent masking of Image reduce method in Jpeg2KImagePlugin #4474 + [radarhere, homm] + - Added reading of earlier ImageMagick PNG EXIF data #4471 [radarhere] From 474f9d84cdd8f8985765276008e5ba622b8afe10 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 31 Mar 2020 18:36:43 +1100 Subject: [PATCH 25/27] Updated CHANGES.rst [ci skip] --- CHANGES.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 3cbb88325..869038a97 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,9 @@ Changelog (Pillow) 7.1.0 (unreleased) ------------------ +- Added three new channel operations #4230 + [dwastberg, radarhere] + - Prevent masking of Image reduce method in Jpeg2KImagePlugin #4474 [radarhere, homm] From a1eaba76897ca096e31fd9d2f49eff60746d3861 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 31 Mar 2020 18:42:06 +1100 Subject: [PATCH 26/27] Documented new channel operations [ci skip] --- docs/releasenotes/7.1.0.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/releasenotes/7.1.0.rst b/docs/releasenotes/7.1.0.rst index e3bc107dd..35587db4a 100644 --- a/docs/releasenotes/7.1.0.rst +++ b/docs/releasenotes/7.1.0.rst @@ -27,6 +27,12 @@ Reading JPEG comments When opening a JPEG image, the comment may now be read into :py:attr:`~PIL.Image.Image.info`. +New Channel Operations +^^^^^^^^^^^^^^^^^^^^^^ + +Three new channel operations have been added: :py:meth:`~PIL.ImageChops.soft_light`, +:py:meth:`~PIL.ImageChops.hard_light` and :py:meth:`~PIL.ImageChops.overlay`. + Other Changes ============= From b700edfeb5d44061f2f391f4ebb18bbe0c309066 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Tue, 31 Mar 2020 19:18:45 +1100 Subject: [PATCH 27/27] Changed capitalisation [ci skip] Co-Authored-By: Hugo van Kemenade --- docs/releasenotes/7.1.0.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releasenotes/7.1.0.rst b/docs/releasenotes/7.1.0.rst index 35587db4a..0f7a22b24 100644 --- a/docs/releasenotes/7.1.0.rst +++ b/docs/releasenotes/7.1.0.rst @@ -27,7 +27,7 @@ Reading JPEG comments When opening a JPEG image, the comment may now be read into :py:attr:`~PIL.Image.Image.info`. -New Channel Operations +New channel operations ^^^^^^^^^^^^^^^^^^^^^^ Three new channel operations have been added: :py:meth:`~PIL.ImageChops.soft_light`,