Repository: sunpy/sunpy

Issue:
Change the map.rotate() and image.affine_transform API to use something other than image_center for the rotation point.
This is a follow up to #1074, in that discussion `rotation_center`, `solar_center` and `fixed_point` were suggested, we should choose one and issue a PR to fix it in 0.6



Discussion:

Resolving patch:
diff --git a/sunpy/image/transform.py b/sunpy/image/transform.py
--- a/sunpy/image/transform.py
+++ b/sunpy/image/transform.py
@@ -31,13 +31,12 @@ def affine_transform(image, rmatrix, order=3, scale=1.0, image_center=None,
         Linear transformation rotation matrix.
     order : int 0-5
         Interpolation order to be used. When using scikit-image this parameter
-        is passed into :func:`skimage.transform.warp`.
-        The default value of order 3 is a bicubic interpolation when using
-        :func:`skimage.transform.warp` and a cubic spline interpolation
-        when using :func:`scipy.ndimage.interpolation.affine_transform`.
+        is passed into :func:`skimage.transform.warp` (e.g., 3 corresponds to
+        bi-cubic interpolation).
         When using scipy it is passed into
         :func:`scipy.ndimage.interpolation.affine_transform` where it controls
         the order of the spline.
+        Default: 3
     scale : float
         A scale factor for the image. Default is no scaling.
     image_center : tuple
@@ -124,8 +123,9 @@ def affine_transform(image, rmatrix, order=3, scale=1.0, image_center=None,
         adjusted_image -= im_min
         im_max = np.nanmax(adjusted_image)
         adjusted_image /= im_max
+        adjusted_missing = (missing - im_min) / im_max
         rotated_image = skimage.transform.warp(adjusted_image, tform, order=order,
-                                               mode='constant', cval=missing)
+                                               mode='constant', cval=adjusted_missing)
 
 
         rotated_image *= im_max
diff --git a/sunpy/map/mapbase.py b/sunpy/map/mapbase.py
--- a/sunpy/map/mapbase.py
+++ b/sunpy/map/mapbase.py
@@ -571,8 +571,8 @@ def resample(self, dimensions, method='linear'):
         new_map.meta = new_meta
         return new_map
 
-    def rotate(self, angle=None, rmatrix=None, order=3, scale=1.0,
-               image_center=(0,0), recenter=False, missing=0.0, use_scipy=False):
+    def rotate(self, angle=None, rmatrix=None, order=4, scale=1.0,
+               rotation_center=(0,0), recenter=False, missing=0.0, use_scipy=False):
         """
         Returns a new rotated and rescaled map.  Specify either a rotation
         angle or a rotation matrix, but not both.  If neither an angle or a
@@ -590,15 +590,16 @@ def rotate(self, angle=None, rmatrix=None, order=3, scale=1.0,
             Linear transformation rotation matrix.
         order : int 0-5
             Interpolation order to be used. When using scikit-image this parameter
-            is passed into :func:`skimage.transform.warp`.
+            is passed into :func:`skimage.transform.warp` (e.g., 4 corresponds to
+            bi-quartic interpolation).
             When using scipy it is passed into
             :func:`scipy.ndimage.interpolation.affine_transform` where it controls
             the order of the spline.
-            Higher accuracy may be obtained at the cost of performance by using
-            higher values.
+            Faster performance may be obtained at the cost of accuracy by using lower values.
+            Default: 4
         scale : float
             A scale factor for the image, default is no scaling
-        image_center : tuple
+        rotation_center : tuple
             The axis of rotation in data coordinates
             Default: the origin in the data coordinate system
         recenter : bool
@@ -652,31 +653,42 @@ def rotate(self, angle=None, rmatrix=None, order=3, scale=1.0,
         new_map = deepcopy(self)
 
         if angle is not None:
-            #Calulate the parameters for the affine_transform
+            # Calulate the parameters for the affine_transform
             c = np.cos(np.deg2rad(angle))
             s = np.sin(np.deg2rad(angle))
             rmatrix = np.matrix([[c, -s], [s, c]])
 
+        # Calculate the shape in pixels to contain all of the image data
+        extent = np.max(np.abs(np.vstack((new_map.shape * rmatrix, new_map.shape * rmatrix.T))), axis=0)
+        # Calculate the needed padding or unpadding
+        diff = np.asarray(np.ceil((extent - new_map.shape) / 2)).ravel()
+        # Pad the image array
+        pad_x = np.max((diff[1], 0))
+        pad_y = np.max((diff[0], 0))
+        new_map.data = np.pad(new_map.data,
+                              ((pad_y, pad_y), (pad_x, pad_x)),
+                              mode='constant',
+                              constant_values=(missing, missing))
+        new_map.meta['crpix1'] += pad_x
+        new_map.meta['crpix2'] += pad_y
+
         # map_center is swapped compared to the x-y convention
-        array_center = (np.array(self.data.shape)-1)/2.0
+        array_center = (np.array(new_map.data.shape)-1)/2.0
 
-        # rotation_center is swapped compared to the x-y convention
+        # pixel_center is swapped compared to the x-y convention
         if recenter:
             # Convert the axis of rotation from data coordinates to pixel coordinates
-            x = self.data_to_pixel(image_center[0], 'x')
-            y = self.data_to_pixel(image_center[1], 'y')
-            rotation_center = (y, x)
+            x = new_map.data_to_pixel(rotation_center[0], 'x')
+            y = new_map.data_to_pixel(rotation_center[1], 'y')
+            pixel_center = (y, x)
         else:
-            rotation_center = array_center
-
-        #Return a new map
-        #Copy Header
-        new_map = deepcopy(self)
+            pixel_center = array_center
 
+        # Apply the rotation to the image data
         new_map.data = affine_transform(new_map.data.T,
                                         np.asarray(rmatrix),
                                         order=order, scale=scale,
-                                        image_center=rotation_center,
+                                        image_center=pixel_center,
                                         recenter=recenter, missing=missing,
                                         use_scipy=use_scipy).T
 
@@ -684,13 +696,13 @@ def rotate(self, angle=None, rmatrix=None, order=3, scale=1.0,
         # Calculate new reference pixel and coordinate at the center of the
         # image.
         if recenter:
-            new_center = image_center
+            new_center = rotation_center
         else:
             # Retrieve old coordinates for the center of the array
-            old_center = np.asarray(self.pixel_to_data(array_center[1], array_center[0]))
+            old_center = np.asarray(new_map.pixel_to_data(array_center[1], array_center[0]))
 
             # Calculate new coordinates for the center of the array
-            new_center = image_center - np.dot(rmatrix, image_center - old_center)
+            new_center = rotation_center - np.dot(rmatrix, rotation_center - old_center)
             new_center = np.asarray(new_center)[0]
 
         # Define a new reference pixel in the rotated space
@@ -699,11 +711,21 @@ def rotate(self, angle=None, rmatrix=None, order=3, scale=1.0,
         new_map.meta['crpix1'] = array_center[1] + 1 # FITS counts pixels from 1
         new_map.meta['crpix2'] = array_center[0] + 1 # FITS counts pixels from 1
 
+        # Unpad the array if necessary
+        unpad_x = -np.min((diff[1], 0))
+        if unpad_x > 0:
+            new_map.data = new_map.data[:, unpad_x:-unpad_x]
+            new_map.meta['crpix1'] -= unpad_x
+        unpad_y = -np.min((diff[0], 0))
+        if unpad_y > 0:
+            new_map.data = new_map.data[unpad_y:-unpad_y, :]
+            new_map.meta['crpix2'] -= unpad_y
+
         # Calculate the new rotation matrix to store in the header by
         # "subtracting" the rotation matrix used in the rotate from the old one
         # That being calculate the dot product of the old header data with the
         # inverse of the rotation matrix.
-        pc_C = np.dot(self.rotation_matrix, rmatrix.I)
+        pc_C = np.dot(new_map.rotation_matrix, rmatrix.I)
         new_map.meta['PC1_1'] = pc_C[0,0]
         new_map.meta['PC1_2'] = pc_C[0,1]
         new_map.meta['PC2_1'] = pc_C[1,0]
@@ -711,8 +733,8 @@ def rotate(self, angle=None, rmatrix=None, order=3, scale=1.0,
 
         # Update pixel size if image has been scaled.
         if scale != 1.0:
-            new_map.meta['cdelt1'] = self.scale['x'] / scale
-            new_map.meta['cdelt2'] = self.scale['y'] / scale
+            new_map.meta['cdelt1'] = new_map.scale['x'] / scale
+            new_map.meta['cdelt2'] = new_map.scale['y'] / scale
 
         # Remove old CROTA kwargs because we have saved a new PCi_j matrix.
         new_map.meta.pop('CROTA1', None)
@@ -1073,9 +1095,9 @@ def plot(self, gamma=None, annotate=True, axes=None, **imshow_args):
         aia.draw_limb()
         aia.draw_grid()
         """
-        # Check that the image is properly aligned
+        # Check that the image is properly oriented
         if not np.array_equal(self.rotation_matrix, np.matrix(np.identity(2))):
-            warnings.warn("This map is not aligned. Plot axes may be incorrect",
+            warnings.warn("This map is not properly oriented. Plot axes may be incorrect",
                           Warning)
         #Get current axes
         if not axes:


Test patch:
diff --git a/sunpy/instr/tests/test_iris.py b/sunpy/instr/tests/test_iris.py
--- a/sunpy/instr/tests/test_iris.py
+++ b/sunpy/instr/tests/test_iris.py
@@ -25,7 +25,6 @@ def test_iris_rot():
     irismap_rot = irismap.rotate()
 
     assert isinstance(irismap_rot, sunpy.map.sources.IRISMap)
-    assert not np.allclose(irismap_rot.data, irismap.data)
 
     np.testing.assert_allclose(irismap_rot.meta['pc1_1'], 1)
     np.testing.assert_allclose(irismap_rot.meta['pc1_2'], 0, atol=1e-7)
diff --git a/sunpy/map/tests/test_mapbase.py b/sunpy/map/tests/test_mapbase.py
--- a/sunpy/map/tests/test_mapbase.py
+++ b/sunpy/map/tests/test_mapbase.py
@@ -334,7 +334,6 @@ def test_rotate():
     rotated_map_1 = aia_map.rotate(20)
     rotated_map_2 = rotated_map_1.rotate(20)
     assert rotated_map_2.center == rotated_map_1.center == aia_map.center
-    assert rotated_map_2.shape == rotated_map_1.shape == aia_map.shape
     np.testing.assert_allclose(rotated_map_1.rotation_matrix,
                                np.dot(aia_map.rotation_matrix,
                                       calc_new_matrix(20).T))
@@ -342,11 +341,13 @@ def test_rotate():
                                np.dot(aia_map.rotation_matrix,
                                       calc_new_matrix(40).T))
 
-    # Rotation of a square map by non-integral multiple of 90 degrees cuts off the corners
-    # and assigns the value of 0 to corner pixels. This results in reduction
-    # of the mean and an increase in standard deviation.
+    # Rotation of a map by a non-integral multiple of 90 degrees expands the map
+    # and assigns the value of 0 to corner pixels. This results in a reduction
+    # of the mean for a map of all non-negative values.
+    assert rotated_map_2.shape > rotated_map_1.shape > aia_map.shape
+    np.testing.assert_allclose(rotated_map_1.data[0, 0], 0., atol=1e-7)
+    np.testing.assert_allclose(rotated_map_2.data[0, 0], 0., atol=1e-7)
     assert rotated_map_2.mean() < rotated_map_1.mean() < aia_map.mean()
-    assert rotated_map_2.std() > rotated_map_1.std() > aia_map.std()
 
     rotated_map_3 = aia_map.rotate(0, scale=1.5)
     assert rotated_map_3.mean() > aia_map.mean()
@@ -354,9 +355,23 @@ def test_rotate():
     # Mean and std should be equal when angle of rotation is integral multiple
     # of 90 degrees for a square map
     rotated_map_4 = aia_map.rotate(90, scale=1.5)
+    np.testing.assert_allclose(rotated_map_3.mean(), rotated_map_4.mean(), rtol=1e-3)
+    np.testing.assert_allclose(rotated_map_3.std(), rotated_map_4.std(), rtol=1e-3)
     rotated_map_5 = aia_map.rotate(180, scale=1.5)
-    assert int(rotated_map_3.mean()) == int(rotated_map_4.mean()) == int(rotated_map_5.mean())
-    assert int(rotated_map_3.std()) == int(rotated_map_4.std()) == int(rotated_map_5.std())
+    np.testing.assert_allclose(rotated_map_3.mean(), rotated_map_5.mean(), rtol=1e-3)
+    np.testing.assert_allclose(rotated_map_3.std(), rotated_map_5.std(), rtol=1e-3)
+
+    # Rotation of a rectangular map by a large enough angle will change which dimension is larger
+    aia_map_crop = aia_map.submap([0, 1000], [0, 400])
+    aia_map_crop_rot = aia_map_crop.rotate(60)
+    assert aia_map_crop.shape[0] < aia_map_crop.shape[1]
+    assert aia_map_crop_rot.shape[0] > aia_map_crop_rot.shape[1]
+
+    # Same test as above, to test the other direction
+    aia_map_crop = aia_map.submap([0, 400], [0, 1000])
+    aia_map_crop_rot = aia_map_crop.rotate(60)
+    assert aia_map_crop.shape[0] > aia_map_crop.shape[1]
+    assert aia_map_crop_rot.shape[0] < aia_map_crop_rot.shape[1]
 
 
 def test_rotate_recenter(aia_map):
@@ -365,14 +380,15 @@ def test_rotate_recenter(aia_map):
     # New image center in data coordinates
     new_center = np.asarray((200, 100))
 
-    rotated_map = aia_map.rotate(20, image_center=new_center, recenter=True)
+    rotated_map = aia_map.rotate(20, rotation_center=new_center, recenter=True)
 
     # Retrieve pixel coordinates for the centers from the new map
     new_x = rotated_map.data_to_pixel(new_center[0], 'x')
     new_y = rotated_map.data_to_pixel(new_center[1], 'y')
 
     # The new desired image center should be in the map center
-    np.testing.assert_allclose((new_y, new_x), array_center)
+    new_array_center = (np.array(rotated_map.data.shape)-1)/2.0
+    np.testing.assert_allclose((new_y, new_x), new_array_center)
 
 
 def test_rotate_crota_remove(aia_map):