Repository: python-pillow/Pillow

Problem statement:
Image.resize() incorrectly centers non-nearest filters
(Discovered in the context of scipy, see https://github.com/scipy/scipy/issues/4112 for more details)

Using an interpolation method other than 'nearest' with Image.resize results in an image shift away from (0,0) in the result (among other problems)

It behaves as though the filter kernel starts at (-0.5, -0.5), but is walked across the image without a corresponding change in step size.

Repeated application of `Image.resize` with `Image.BILINEAR` results in the image shrinking and walking off:
![linear](https://cloud.githubusercontent.com/assets/2577318/4888977/f6ef0bc4-6391-11e4-98ec-566ad4d93881.gif)
even more weird, but still walking with `Image.BICUBIC`:
![cubic](https://cloud.githubusercontent.com/assets/2577318/4888999/17c7c4bc-6392-11e4-9d7b-14160e91557b.gif)
while 'Image.NEAREST' behaves as expected:
![nearest](https://cloud.githubusercontent.com/assets/2577318/4889010/2dcd33be-6392-11e4-8d14-cd6a28f18b7c.gif)

I started looking at Geometry.c, and it seems like it needs a bit of love, but I am very unfamiliar with the code so I'm hesitant to start rewriting things :-)

Here's the test code:

``` #!/usr/bin/python

from PIL import Image

src = Image.open("src.png").convert('F')
cbc = src
lnr = src
nrs = src

for idx in xrange(0, 128):
    cbc = cbc.resize(tuple(2 * i for i in cbc.size), Image.BICUBIC)
    cbc = cbc.resize(src.size, Image.BICUBIC)

    lnr = lnr.resize(tuple(2 * i for i in lnr.size), Image.BILINEAR)
    lnr = lnr.resize(src.size, Image.BILINEAR)

    nrs = nrs.resize(tuple(2 * i for i in nrs.size), Image.NEAREST)
    nrs = nrs.resize(src.size, Image.NEAREST)

    nrs.convert('L').save("nrs_%03d.png" % (idx))
    lnr.convert('L').save("lnr_%03d.png" % (idx))
    cbc.convert('L').save("cbc_%03d.png" % (idx))
```

Thanks

Platform information:

```
Python 2.7.8 (default, Jul 13 2014, 17:11:32)
[GCC 4.2.1 Compatible Apple LLVM 5.1 (clang-503.0.40)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
```



Hints:
Thanks for reporting. I've also noticed this issue recently. There is no need to fix this particular issue, because `im.resize()` should be whole replaced with `im,stretch()` internal implementation. These and many other errors will go away.

That bicubic one is pretty freaky. 

Just for fun, here's the output on another [image](https://secure.flickr.com/photos/george_eastman_house/3122873609/in/set-72157611386593623). 

Bilinear: http://giphy.com/gifs/TlK63EPFRDPqETTakec

Bicubic: http://giphy.com/gifs/TlK63EvgnEChMti41FK


Patch:
diff --git a/PIL/Image.py b/PIL/Image.py
--- a/PIL/Image.py
+++ b/PIL/Image.py
@@ -876,7 +876,7 @@ def convert(self, mode=None, matrix=None, dither=None,
             elif self.mode == 'P' and mode == 'RGBA':
                 t = self.info['transparency']
                 delete_trns = True
-                
+
                 if isinstance(t, bytes):
                     self.im.putpalettealphas(t)
                 elif isinstance(t, int):
@@ -1515,9 +1515,8 @@ def resize(self, size, resample=NEAREST):
            (width, height).
         :param resample: An optional resampling filter.  This can be
            one of :py:attr:`PIL.Image.NEAREST` (use nearest neighbour),
-           :py:attr:`PIL.Image.BILINEAR` (linear interpolation in a 2x2
-           environment), :py:attr:`PIL.Image.BICUBIC` (cubic spline
-           interpolation in a 4x4 environment), or
+           :py:attr:`PIL.Image.BILINEAR` (linear interpolation),
+           :py:attr:`PIL.Image.BICUBIC` (cubic spline interpolation), or
            :py:attr:`PIL.Image.ANTIALIAS` (a high-quality downsampling filter).
            If omitted, or if the image has mode "1" or "P", it is
            set :py:attr:`PIL.Image.NEAREST`.
@@ -1539,16 +1538,7 @@ def resize(self, size, resample=NEAREST):
         if self.mode == 'RGBA':
             return self.convert('RGBa').resize(size, resample).convert('RGBA')
 
-        if resample == ANTIALIAS:
-            # requires stretch support (imToolkit & PIL 1.1.3)
-            try:
-                im = self.im.stretch(size, resample)
-            except AttributeError:
-                raise ValueError("unsupported resampling filter")
-        else:
-            im = self.im.resize(size, resample)
-
-        return self._new(im)
+        return self._new(self.im.resize(size, resample))
 
     def rotate(self, angle, resample=NEAREST, expand=0):
         """
@@ -1763,12 +1753,7 @@ def thumbnail(self, size, resample=ANTIALIAS):
         :py:meth:`~PIL.Image.Image.draft` method to configure the file reader
         (where applicable), and finally resizes the image.
 
-        Note that the bilinear and bicubic filters in the current
-        version of PIL are not well-suited for thumbnail generation.
-        You should use :py:attr:`PIL.Image.ANTIALIAS` unless speed is much more
-        important than quality.
-
-        Also note that this function modifies the :py:class:`~PIL.Image.Image`
+        Note that this function modifies the :py:class:`~PIL.Image.Image`
         object in place.  If you need to use the full resolution image as well,
         apply this method to a :py:meth:`~PIL.Image.Image.copy` of the original
         image.
@@ -1776,10 +1761,9 @@ def thumbnail(self, size, resample=ANTIALIAS):
         :param size: Requested size.
         :param resample: Optional resampling filter.  This can be one
            of :py:attr:`PIL.Image.NEAREST`, :py:attr:`PIL.Image.BILINEAR`,
-           :py:attr:`PIL.Image.BICUBIC`, or :py:attr:`PIL.Image.ANTIALIAS`
-           (best quality).  If omitted, it defaults to
-           :py:attr:`PIL.Image.ANTIALIAS`. (was :py:attr:`PIL.Image.NEAREST`
-           prior to version 2.5.0)
+           :py:attr:`PIL.Image.BICUBIC`, or :py:attr:`PIL.Image.ANTIALIAS`.
+           If omitted, it defaults to :py:attr:`PIL.Image.ANTIALIAS`.
+           (was :py:attr:`PIL.Image.NEAREST` prior to version 2.5.0)
         :returns: None
         """
 
@@ -1798,14 +1782,7 @@ def thumbnail(self, size, resample=ANTIALIAS):
 
         self.draft(None, size)
 
-        self.load()
-
-        try:
-            im = self.resize(size, resample)
-        except ValueError:
-            if resample != ANTIALIAS:
-                raise
-            im = self.resize(size, NEAREST)  # fallback
+        im = self.resize(size, resample)
 
         self.im = im.im
         self.mode = im.mode
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -26,7 +26,7 @@
     "decode", "encode", "map", "display", "outline", "path")
 
 _LIB_IMAGING = (
-    "Access", "AlphaComposite", "Antialias", "Bands", "BitDecode", "Blend",
+    "Access", "AlphaComposite", "Resample", "Bands", "BitDecode", "Blend",
     "Chops", "Convert", "ConvertYCbCr", "Copy", "Crc32", "Crop", "Dib", "Draw",
     "Effects", "EpsEncode", "File", "Fill", "Filter", "FliDecode",
     "Geometry", "GetBBox", "GifDecode", "GifEncode", "HexDecode",


Test patch:
diff --git a/Tests/test_image_resize.py b/Tests/test_image_resize.py
--- a/Tests/test_image_resize.py
+++ b/Tests/test_image_resize.py
@@ -1,5 +1,91 @@
+"""
+Tests for resize functionality.
+"""
+from itertools import permutations
+
 from helper import unittest, PillowTestCase, hopper
 
+from PIL import Image
+
+
+class TestImagingCoreResize(PillowTestCase):
+
+    def resize(self, im, size, f):
+        # Image class independent version of resize.
+        im.load()
+        return im._new(im.im.resize(size, f))
+
+    def test_nearest_mode(self):
+        for mode in ["1", "P", "L", "I", "F", "RGB", "RGBA", "CMYK", "YCbCr",
+                     "I;16"]: # exotic mode
+            im = hopper(mode)
+            r = self.resize(im, (15, 12), Image.NEAREST)
+            self.assertEqual(r.mode, mode)
+            self.assertEqual(r.size, (15, 12) )
+            self.assertEqual(r.im.bands, im.im.bands)
+
+    def test_convolution_modes(self):
+        self.assertRaises(ValueError, self.resize, hopper("1"),
+                          (15, 12), Image.BILINEAR)
+        self.assertRaises(ValueError, self.resize, hopper("P"),
+                          (15, 12), Image.BILINEAR)
+        self.assertRaises(ValueError, self.resize, hopper("I;16"),
+                          (15, 12), Image.BILINEAR)
+        for mode in ["L", "I", "F", "RGB", "RGBA", "CMYK", "YCbCr"]:
+            im = hopper(mode)
+            r = self.resize(im, (15, 12), Image.BILINEAR)
+            self.assertEqual(r.mode, mode)
+            self.assertEqual(r.size, (15, 12) )
+            self.assertEqual(r.im.bands, im.im.bands)
+
+    def test_reduce_filters(self):
+        for f in [Image.LINEAR, Image.BILINEAR, Image.BICUBIC, Image.ANTIALIAS]:
+            r = self.resize(hopper("RGB"), (15, 12), f)
+            self.assertEqual(r.mode, "RGB")
+            self.assertEqual(r.size, (15, 12))
+
+    def test_enlarge_filters(self):
+        for f in [Image.LINEAR, Image.BILINEAR, Image.BICUBIC, Image.ANTIALIAS]:
+            r = self.resize(hopper("RGB"), (212, 195), f)
+            self.assertEqual(r.mode, "RGB")
+            self.assertEqual(r.size, (212, 195))
+
+    def test_endianness(self):
+        # Make an image with one colored pixel, in one channel.
+        # When resized, that channel should be the same as a GS image.
+        # Other channels should be unaffected.
+        # The R and A channels should not swap, which is indicitive of
+        # an endianness issues.
+
+        samples = {
+            'blank': Image.new('L', (2, 2), 0),
+            'filled': Image.new('L', (2, 2), 255),
+            'dirty': Image.new('L', (2, 2), 0),
+        }
+        samples['dirty'].putpixel((1, 1), 128)
+
+        for f in [Image.LINEAR, Image.BILINEAR, Image.BICUBIC, Image.ANTIALIAS]:
+            # samples resized with current filter
+            references = dict(
+                (name, self.resize(ch, (4, 4), f))
+                for name, ch in samples.items()
+            )
+
+            for mode, channels_set in [
+                ('RGB', ('blank', 'filled', 'dirty')),
+                ('RGBA', ('blank', 'blank', 'filled', 'dirty')),
+                ('LA', ('filled', 'dirty')),
+            ]:
+                for channels in set(permutations(channels_set)):
+                    # compile image from different channels permutations
+                    im = Image.merge(mode, [samples[ch] for ch in channels])
+                    resized = self.resize(im, (4, 4), f)
+
+                    for i, ch in enumerate(resized.split()):
+                        # check what resized channel in image is the same
+                        # as separately resized channel
+                        self.assert_image_equal(ch, references[channels[i]])
+
 
 class TestImageResize(PillowTestCase):
 
@@ -9,8 +95,8 @@ def resize(mode, size):
             self.assertEqual(out.mode, mode)
             self.assertEqual(out.size, size)
         for mode in "1", "P", "L", "RGB", "I", "F":
-            resize(mode, (100, 100))
-            resize(mode, (200, 200))
+            resize(mode, (112, 103))
+            resize(mode, (188, 214))
 
 
 if __name__ == '__main__':
diff --git a/Tests/test_image_transform.py b/Tests/test_image_transform.py
--- a/Tests/test_image_transform.py
+++ b/Tests/test_image_transform.py
@@ -44,7 +44,9 @@ def test_quad(self):
                                     w//2, h//2, w//2, 0),
                                    Image.BILINEAR)
 
-        scaled = im.resize((w*2, h*2), Image.BILINEAR).crop((0, 0, w, h))
+        scaled = im.transform((w, h), Image.AFFINE,
+                              (.5, 0, 0, 0, .5, 0),
+                              Image.BILINEAR)
 
         self.assert_image_equal(transformed, scaled)
 
@@ -61,9 +63,9 @@ def test_mesh(self):
                                      w, h, w, 0))],  # ul -> ccw around quad
                                    Image.BILINEAR)
 
-        # transformed.save('transformed.png')
-
-        scaled = im.resize((w//2, h//2), Image.BILINEAR)
+        scaled = im.transform((w//2, h//2), Image.AFFINE,
+                              (2, 0, 0, 0, 2, 0),
+                              Image.BILINEAR)
 
         checker = Image.new('RGBA', im.size)
         checker.paste(scaled, (0, 0))
@@ -128,7 +130,8 @@ def test_blank_fill(self):
 
         foo = [
             Image.new('RGBA', (1024, 1024), (a, a, a, a))
-            for a in range(1, 65)]
+            for a in range(1, 65)
+        ]
 
         # Yeah. Watch some JIT optimize this out.
         foo = None
diff --git a/Tests/test_imaging_stretch.py b/Tests/test_imaging_stretch.py
deleted file mode 100644
--- a/Tests/test_imaging_stretch.py
+++ /dev/null
@@ -1,86 +0,0 @@
-"""
-Tests for ImagingCore.stretch functionality.
-"""
-from itertools import permutations
-
-from helper import unittest, PillowTestCase
-
-from PIL import Image
-
-
-im = Image.open("Tests/images/hopper.ppm").copy()
-
-
-class TestImagingStretch(PillowTestCase):
-
-    def stretch(self, im, size, f):
-        return im._new(im.im.stretch(size, f))
-
-    def test_modes(self):
-        self.assertRaises(ValueError, im.convert("1").im.stretch,
-                          (15, 12), Image.ANTIALIAS)
-        self.assertRaises(ValueError, im.convert("P").im.stretch,
-                          (15, 12), Image.ANTIALIAS)
-        for mode in ["L", "I", "F", "RGB", "RGBA", "CMYK", "YCbCr"]:
-            s = im.convert(mode).im
-            r = s.stretch((15, 12), Image.ANTIALIAS)
-            self.assertEqual(r.mode, mode)
-            self.assertEqual(r.size, (15, 12))
-            self.assertEqual(r.bands, s.bands)
-
-    def test_reduce_filters(self):
-        # There is no Image.NEAREST because im.stretch implementation
-        # is not NEAREST for reduction. It should be removed
-        # or renamed to supersampling.
-        for f in [Image.BILINEAR, Image.BICUBIC, Image.ANTIALIAS]:
-            r = im.im.stretch((15, 12), f)
-            self.assertEqual(r.mode, "RGB")
-            self.assertEqual(r.size, (15, 12))
-
-    def test_enlarge_filters(self):
-        for f in [Image.BILINEAR, Image.BICUBIC, Image.ANTIALIAS]:
-            r = im.im.stretch((764, 414), f)
-            self.assertEqual(r.mode, "RGB")
-            self.assertEqual(r.size, (764, 414))
-
-    def test_endianness(self):
-        # Make an image with one colored pixel, in one channel.
-        # When stretched, that channel should be the same as a GS image.
-        # Other channels should be unaffected.
-        # The R and A channels should not swap, which is indicitive of
-        # an endianness issues.
-
-        samples = {
-            'blank': Image.new('L', (2, 2), 0),
-            'filled': Image.new('L', (2, 2), 255),
-            'dirty': Image.new('L', (2, 2), 0),
-        }
-        samples['dirty'].putpixel((1, 1), 128)
-
-        for f in [Image.BILINEAR, Image.BICUBIC, Image.ANTIALIAS]:
-            # samples resized with current filter
-            resized = dict(
-                (name, self.stretch(ch, (4, 4), f))
-                for name, ch in samples.items()
-            )
-
-            for mode, channels_set in [
-                ('RGB', ('blank', 'filled', 'dirty')),
-                ('RGBA', ('blank', 'blank', 'filled', 'dirty')),
-                ('LA', ('filled', 'dirty')),
-            ]:
-                for channels in set(permutations(channels_set)):
-                    # compile image from different channels permutations
-                    im = Image.merge(mode, [samples[ch] for ch in channels])
-                    stretched = self.stretch(im, (4, 4), f)
-
-                    for i, ch in enumerate(stretched.split()):
-                        # check what resized channel in image is the same
-                        # as separately resized channel
-                        self.assert_image_equal(ch, resized[channels[i]])
-
-
-if __name__ == '__main__':
-    unittest.main()
-
-# End of file