diff --git a/PIL/Image.py b/PIL/Image.py index 03f3973ee..f49834e5b 100644 --- a/PIL/Image.py +++ b/PIL/Image.py @@ -1555,7 +1555,7 @@ class Image(object): 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 @@ class Image(object): :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))) + nw = int(math.ceil(max(xx)) - math.floor(min(xx))) + nh = 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 + # 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) diff --git a/Tests/images/hopper_45.png b/Tests/images/hopper_45.png new file mode 100644 index 000000000..a6e614283 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 index dfe5a0731..9ee01db44 100644 --- a/Tests/test_image_rotate.py +++ b/Tests/test_image_rotate.py @@ -4,32 +4,100 @@ from PIL import Image 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__':