Repository: python-pillow/Pillow

Issue:
Add center and translate option to Image.rotate.
Hi,

freeimage has a RotateEx function that allows to set a rotation center and a subsequent translation. Due to the affine matrix used for the interpolation, this is super-easy to implement in Pillow, too, and really makes a lot of sense to provide in the API, as calculating the transformation matrix is not something that every user will be able to do easily.

Changes proposed in this pull request:
- Add center=(x,y) argument to Image.rotate
- Add translate=(x,y) argument to Image.rotate
- Documentation and tests for these.



Discussion:

Resolving patch:
diff --git a/PIL/Image.py b/PIL/Image.py
--- a/PIL/Image.py
+++ b/PIL/Image.py
@@ -1555,7 +1555,7 @@ def resize(self, size, resample=NEAREST):
 
         return self._new(self.im.resize(size, resample))
 
-    def rotate(self, angle, resample=NEAREST, expand=0):
+    def rotate(self, angle, resample=NEAREST, expand=0, center=None, translate=None):
         """
         Returns a rotated copy of this image.  This method returns a
         copy of this image, rotated the given number of degrees counter
@@ -1572,48 +1572,81 @@ def rotate(self, angle, resample=NEAREST, expand=0):
         :param expand: Optional expansion flag.  If true, expands the output
            image to make it large enough to hold the entire rotated image.
            If false or omitted, make the output image the same size as the
-           input image.
+           input image.  Note that the expand flag assumes rotation around
+           the center and no translation.
+        :param center: Optional center of rotation (a 2-tuple).  Origin is
+           the upper left corner.  Default is the center of the image.
+        :param translate: An optional post-rotate translation (a 2-tuple).
         :returns: An :py:class:`~PIL.Image.Image` object.
         """
 
         angle = angle % 360.0
 
-        # Fast paths regardless of filter
-        if angle == 0:
-            return self.copy()
-        if angle == 180:
-            return self.transpose(ROTATE_180)
-        if angle == 90 and expand:
-            return self.transpose(ROTATE_90)
-        if angle == 270 and expand:
-            return self.transpose(ROTATE_270)
+        # Fast paths regardless of filter, as long as we're not
+        # translating or changing the center. 
+        if not (center or translate):
+            if angle == 0:
+                return self.copy()
+            if angle == 180:
+                return self.transpose(ROTATE_180)
+            if angle == 90 and expand:
+                return self.transpose(ROTATE_90)
+            if angle == 270 and expand:
+                return self.transpose(ROTATE_270)
+
+        # Calculate the affine matrix.  Note that this is the reverse
+        # transformation (from destination image to source) because we
+        # want to interpolate the (discrete) destination pixel from
+        # the local area around the (floating) source pixel.
+
+        # The matrix we actually want (note that it operates from the right):
+        # (1, 0, tx)   (1, 0, cx)   ( cos a, sin a, 0)   (1, 0, -cx)
+        # (0, 1, ty) * (0, 1, cy) * (-sin a, cos a, 0) * (0, 1, -cy)
+        # (0, 0,  1)   (0, 0,  1)   (     0,     0, 1)   (0, 0,   1)
+
+        # The reverse matrix is thus:
+        # (1, 0, cx)   ( cos -a, sin -a, 0)   (1, 0, -cx)   (1, 0, -tx)
+        # (0, 1, cy) * (-sin -a, cos -a, 0) * (0, 1, -cy) * (0, 1, -ty)
+        # (0, 0,  1)   (      0,      0, 1)   (0, 0,   1)   (0, 0,   1)
+
+        # In any case, the final translation may be updated at the end to
+        # compensate for the expand flag.
+
+        w, h = self.size
+
+        if translate is None:
+            translate = [0, 0]
+        if center is None:
+            center = [w / 2.0, h / 2.0]
 
         angle = - math.radians(angle)
         matrix = [
             round(math.cos(angle), 15), round(math.sin(angle), 15), 0.0,
             round(-math.sin(angle), 15), round(math.cos(angle), 15), 0.0
-            ]
-
-        def transform(x, y, matrix=matrix):
+        ]
+        def transform(x, y, matrix):
             (a, b, c, d, e, f) = matrix
             return a*x + b*y + c, d*x + e*y + f
+        matrix[2], matrix[5] = transform(-center[0] - translate[0], -center[1] - translate[1], matrix)
+        matrix[2] += center[0]
+        matrix[5] += center[1]
 
-        w, h = self.size
         if expand:
             # calculate output size
             xx = []
             yy = []
             for x, y in ((0, 0), (w, 0), (w, h), (0, h)):
-                x, y = transform(x, y)
+                x, y = transform(x, y, matrix)
                 xx.append(x)
                 yy.append(y)
-            w = int(math.ceil(max(xx)) - math.floor(min(xx)))
-            h = int(math.ceil(max(yy)) - math.floor(min(yy)))
-
-        # adjust center
-        x, y = transform(w / 2.0, h / 2.0)
-        matrix[2] = self.size[0] / 2.0 - x
-        matrix[5] = self.size[1] / 2.0 - y
+            nw = int(math.ceil(max(xx)) - math.floor(min(xx)))
+            nh = int(math.ceil(max(yy)) - math.floor(min(yy)))
+
+            # We multiply a translation matrix from the right.  Because of its
+            # special form, this is the same as taking the image of the translation vector
+            # as new translation vector.
+            matrix[2], matrix[5] = transform(-(nw - w) / 2.0, -(nh - h) / 2.0, matrix)
+            w, h = nw, nh
 
         return self.transform((w, h), AFFINE, matrix, resample)
 


Test patch:
diff --git a/Tests/images/hopper_45.png b/Tests/images/hopper_45.png
new file mode 100644
Binary files /dev/null and b/Tests/images/hopper_45.png differ
diff --git a/Tests/test_image_rotate.py b/Tests/test_image_rotate.py
--- a/Tests/test_image_rotate.py
+++ b/Tests/test_image_rotate.py
@@ -4,32 +4,100 @@
 
 class TestImageRotate(PillowTestCase):
 
-    def test_rotate(self):
-        def rotate(im, mode, angle):
-            out = im.rotate(angle)
-            self.assertEqual(out.mode, mode)
-            self.assertEqual(out.size, im.size)  # default rotate clips output
-            out = im.rotate(angle, expand=1)
-            self.assertEqual(out.mode, mode)
-            if angle % 180 == 0:
-                self.assertEqual(out.size, im.size)
-            elif im.size == (0, 0):
-                self.assertEqual(out.size, im.size)
-            else:
-                self.assertNotEqual(out.size, im.size)
-
-                
+    def rotate(self, im, mode, angle, center=None, translate=None):
+        out = im.rotate(angle, center=center, translate=translate)
+        self.assertEqual(out.mode, mode)
+        self.assertEqual(out.size, im.size)  # default rotate clips output
+        out = im.rotate(angle, center=center, translate=translate, expand=1)
+        self.assertEqual(out.mode, mode)
+        if angle % 180 == 0:
+            self.assertEqual(out.size, im.size)
+        elif im.size == (0, 0):
+            self.assertEqual(out.size, im.size)
+        else:
+            self.assertNotEqual(out.size, im.size)
+
+    def test_mode(self):                
         for mode in ("1", "P", "L", "RGB", "I", "F"):
             im = hopper(mode)
-            rotate(im, mode, 45)
+            self.rotate(im, mode, 45)
 
+    def test_angle(self):
         for angle in (0, 90, 180, 270):
             im = Image.open('Tests/images/test-card.png')
-            rotate(im, im.mode, angle)
+            self.rotate(im, im.mode, angle)
 
+    def test_zero(self):
         for angle in (0, 45, 90, 180, 270):
             im = Image.new('RGB',(0,0))
-            rotate(im, im.mode, angle)
+            self.rotate(im, im.mode, angle)
+
+    def test_resample(self):
+        # Target image creation, inspected by eye. 
+        # >>> im = Image.open('Tests/images/hopper.ppm')
+        # >>> im = im.rotate(45, resample=Image.BICUBIC, expand=True)         
+        # >>> im.save('Tests/images/hopper_45.png')
+
+        target = Image.open('Tests/images/hopper_45.png')
+        for (resample, epsilon) in ((Image.NEAREST, 10),
+                                    (Image.BILINEAR, 5),
+                                    (Image.BICUBIC, 0)):
+            im = hopper()
+            im = im.rotate(45, resample=resample, expand=True)
+            self.assert_image_similar(im, target, epsilon)
+
+    def test_center_0(self):
+        im = hopper()
+        target = Image.open('Tests/images/hopper_45.png')
+        target_origin = target.size[1]/2
+        target = target.crop((0, target_origin, 128, target_origin + 128))
+
+        im = im.rotate(45, center=(0,0), resample=Image.BICUBIC)
+
+        self.assert_image_similar(im, target, 15)
+
+    def test_center_14(self):
+        im = hopper()
+        target = Image.open('Tests/images/hopper_45.png')
+        target_origin = target.size[1] / 2 - 14
+        target = target.crop((6, target_origin, 128 + 6, target_origin + 128))
+
+        im = im.rotate(45, center=(14,14), resample=Image.BICUBIC)
+
+        self.assert_image_similar(im, target, 10)
+
+    def test_translate(self):
+        im = hopper()
+        target = Image.open('Tests/images/hopper_45.png')
+        target_origin = (target.size[1] / 2 - 64) - 5
+        target = target.crop((target_origin, target_origin,
+                              target_origin + 128,  target_origin + 128))
+
+        im = im.rotate(45, translate=(5,5), resample=Image.BICUBIC)
+
+        self.assert_image_similar(im, target, 1)
+
+    def test_fastpath_center(self):
+        # if the center is -1,-1 and we rotate by 90<=x<=270 the
+        # resulting image should be black
+        for angle in (90, 180, 270):
+            im = hopper().rotate(angle, center=(-1,-1))
+            self.assert_image_equal(im, Image.new('RGB', im.size, 'black'))
+
+    def test_fastpath_translate(self):
+        # if we post-translate by -128
+        # resulting image should be black
+        for angle in (0, 90, 180, 270):
+            im = hopper().rotate(angle, translate=(-128,-128))
+            self.assert_image_equal(im, Image.new('RGB', im.size, 'black'))
+
+    def test_center(self):
+        im = hopper()
+        self.rotate(im, im.mode, 45, center=(0, 0))
+        self.rotate(im, im.mode, 45, translate=(im.size[0]/2, 0))
+        self.rotate(im, im.mode, 45, center=(0, 0), translate=(im.size[0]/2, 0))
+
+    
 
 
 if __name__ == '__main__':