Repository: sunpy/sunpy

Issue:
`image.transform.affine_transform()` can produce values outside of the range of the input array (e.g., more negative)
When a map is rotated using `map.rotate` It leads to a general yellow-washing of the new map.
map without rotation and scaling :
![map](https://user-images.githubusercontent.com/40922068/72879657-5a847000-3d23-11ea-9db8-1091bfe80b5b.png)
map without rotation and scaling = 1.5 :
![Scale1 5](https://user-images.githubusercontent.com/40922068/72879664-5d7f6080-3d23-11ea-9a6c-b2c123534b85.png)


map with rotation = 45 degree and scaling = 4 :
![rot45_sc4](https://user-images.githubusercontent.com/40922068/72879739-84d62d80-3d23-11ea-9651-eb748b65b4eb.png)

map with rotation 45 degree and  scaling = 2 :
![Figure_1](https://user-images.githubusercontent.com/40922068/72879844-bb13ad00-3d23-11ea-8926-dc5595eb652f.png)

map without rotation degree and scaling = 2 :
![Figure_2](https://user-images.githubusercontent.com/40922068/72879872-ccf55000-3d23-11ea-83b7-5cd9daac50be.png)

map with rotation 45 degree and no scaling :
![45sc0](https://user-images.githubusercontent.com/40922068/72880155-54db5a00-3d24-11ea-9710-303e8436fad5.png)



I observed that the yellowness vanishes around scale = 1.812 without rotation, and on different thresholds for different rotations. 



Discussion:
The effects on the plotting are because of where the 0 point is on the colour map. Setting `plot(vmin=0)` will fix the display.

The more interesting question here is how and why are negative values appearing in the map during the call to `rotate()`.
I've looked into this a little, and it appears to be due to using SciPy's `affine_transform()` (i.e., the fallback).  Everything looks fine with scikit-image's `warp()` (i.e., the default), so I'm guessing that @Raahul-Singh doesn't have scikit-image installed.

It doesn't look like we have many tests that test the SciPy fallback.
Ah, I've gotten to the bottom of this.  Arguably SciPy has the correct behavior here because higher-order interpolation can produce excursions (in this case, negative values).  Here's a slice through the problematic spot in the `scale=1.5` example at different orders of interpolation in SciPy:

![fig2](https://user-images.githubusercontent.com/991759/72954667-2b8fed80-3d67-11ea-886a-8d6322632bcd.png)

If we use scikit-image instead, we don't see the same negative values at high orders of interpolation:

![fig1](https://user-images.githubusercontent.com/991759/72954660-27fc6680-3d67-11ea-9443-97b8bc1c40a1.png)

But, that's not because scikit-image knows something that SciPy doesn't; it's because scikit-image's `warp()` – [by default](https://scikit-image.org/docs/dev/api/skimage.transform.html#warp) – clips the output range to the input range.  It should be debated whether this behavior is what we want.

If we set `clip=False` for `warp()`, the negative values here would be expected on all setups and should instead be documented.
@ayshih Could you please share the script you used to figure this out? I was trying to do so as well and resorted to plotting after every other step in `rotate` :sweat_smile:. Also, please share the script for generating those plots as well!!
This is really interesting. We did some testing with aiaprep a while back looking at the two different interpolation methods for a range of orders when rotating a disk of 1s surrounded by 0s (I.e. such that the edge of the disk was a discret step). We found more “ringing” in the scipy method than the scikit image method for all orders. I guess this was because of the clipping that you mention here. 
wait?! scipy has support for orders > 3 now?!

Why are we even using scikit-image?
> @ayshih Could you please share the script you used to figure this out?

You'll need to have scikit-image installed, otherwise this code will just use SciPy everywhere.

```python
>>> Map(AIA_171_IMAGE).rotate(scale=1.5).plot()  # defaults to use_scipy=False and order=3
>>> plt.colorbar()
>>> plt.show()
```
![fig3](https://user-images.githubusercontent.com/991759/72991612-4b073480-3dc0-11ea-9c36-0e2be732c509.png)

```python
>>> Map(AIA_171_IMAGE).rotate(scale=1.5, use_scipy=True).plot()  # defaults to order=3
>>> plt.colorbar()
>>> plt.show()
```
![fig4](https://user-images.githubusercontent.com/991759/72991616-4cd0f800-3dc0-11ea-8f12-04f0c8227e3a.png)

And for the two line plots:
```python
>>> for order in range(6):
...     plt.plot(Map(AIA_171_IMAGE).rotate(scale=1.5, order=order).data[292, 940:950], label=f"order={order}")
>>> plt.title("Using scikit-image (the default)")
>>> plt.legend()
>>> plt.show()
```

```python
>>> for order in range(6):
...     plt.plot(Map(AIA_171_IMAGE).rotate(scale=1.5, use_scipy=True, order=order).data[292, 940:950], >>> label=f"order={order}")
>>> plt.title("Using SciPy (the fallback)")
>>> plt.legend()
>>> plt.show()
```
> We did some testing with aiaprep a while back looking at the two different interpolation methods for a range of orders when rotating a disk of 1s surrounded by 0s (I.e. such that the edge of the disk was a discret step).

Amusingly, I only realized what was going on *because* the scikit-image output was suspiciously pinned to zero in those line plots.  It's not surprising that you missed it in your artificial test case, where zero was a desired value.
> Why are we even using scikit-image?

Heh, is that the only reason we were using it?  Note that if we want clipping behavior (either optional or default), I don't think SciPy's `affine_transform()` has it as an option, so we'd have to implement it separately.
I think that it was originally. It would certainly clean up the rotation code and our dependency tree if we could just rely on the scipy implementation.

Resolving patch:
diff --git a/docs/conf.py b/docs/conf.py
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -153,6 +153,9 @@
 # Disable google style docstrings
 napoleon_google_docstring = False
 
+# Disable the use of param, which prevents a distinct "Other Parameters" section
+napoleon_use_param = False
+
 # Enable nitpicky mode, which forces links to be non-broken
 nitpicky = True
 # This is not used. See docs/nitpick-exceptions file for the actual listing.
diff --git a/sunpy/image/transform.py b/sunpy/image/transform.py
--- a/sunpy/image/transform.py
+++ b/sunpy/image/transform.py
@@ -2,22 +2,24 @@
 Functions for geometrical image transformation and warping.
 """
 import numbers
+from functools import wraps
 
 import numpy as np
 import scipy.ndimage
+from scipy.signal import convolve2d
 
 from sunpy.util.exceptions import warn_deprecated, warn_user
 
-__all__ = ['affine_transform']
+__all__ = ['add_rotation_function', 'affine_transform']
 
 
 def affine_transform(image, rmatrix, order=3, scale=1.0, image_center=None,
-                     recenter=False, missing=0.0, use_scipy=None, *, method='skimage'):
+                     recenter=False, missing=np.nan, use_scipy=None, *, method='scipy', clip=True):
     """
     Rotates, shifts and scales an image.
 
-    Will use `skimage.transform.warp` unless scikit-image can't be imported
-    then it will use`scipy.ndimage.affine_transform`.
+    This function supports NaN values in the input image and supports using NaN for
+    pixels in the output image that are beyond the extent of the input image.
 
     Parameters
     ----------
@@ -26,10 +28,8 @@ def affine_transform(image, rmatrix, order=3, scale=1.0, image_center=None,
     rmatrix : `numpy.ndarray` that is 2x2
         Linear transformation rotation matrix.
     order : `int` 0-5, optional
-        Interpolation order to be used, defaults to 3. When using scikit-image this parameter
-        is passed into `skimage.transform.warp` (e.g., 3 corresponds to bi-cubic interpolation).
-        When using scipy it is passed into
-        `scipy.ndimage.affine_transform` where it controls the order of the spline.
+        Interpolation order to be used, defaults to 3.  The precise meaning depends
+        on the rotation method specifed by ``method``.
     scale : `float`
         A scale factor for the image with the default being no scaling.
     image_center : tuple, optional
@@ -37,14 +37,18 @@ def affine_transform(image, rmatrix, order=3, scale=1.0, image_center=None,
         Defaults to the center of the array.
     recenter : `bool` or array-like, optional
         Move the axis of rotation to the center of the array or recenter coords.
-        Defaults to `True` i.e., recenter to the center of the array.
+        Defaults to `False`.
     missing : `float`, optional
-        The value to replace any missing data after the transformation.
-    method : {``'skimage'``, ``'scipy'``}, optional
-        Transform function to use. Currently
-        :func:`scipy.ndimage.affine_transform` and
-        :func:`skimage.transform.warp` are supported.
-        Defaults to 'skimage', unless scikit-image can't be imported.
+        The value to use for pixels in the output image that are beyond the extent
+        of the input image.
+        Defaults to `numpy.nan`.
+    method : {{{rotation_function_names}}}, optional
+        Rotation function to use.
+        Defaults to ``'scipy'``.
+    clip : `bool`, optional
+        If `True`, clips the pixel values of the output image to the range of the
+        input image (including the value of ``missing``, if used).
+        Defaults to `True`.
 
     Returns
     -------
@@ -53,29 +57,12 @@ def affine_transform(image, rmatrix, order=3, scale=1.0, image_center=None,
 
     Notes
     -----
-    This algorithm uses an affine transformation as opposed to a polynomial
-    geometrical transformation, which by default is `skimage.transform.warp`.
-    One can specify using `scipy.ndimage.affine_transform` as
-    an alternative affine transformation. The two transformations use different
-    algorithms and thus do not give identical output.
-
-    When using `skimage.transform.warp` with order >= 4 or using
-    `scipy.ndimage.affine_transform` at all, "NaN" values will be replaced with
-    zero prior to rotation. No attempt is made to retain the "NaN" values.
-
-    Input arrays with integer data are cast to float 64 and can be re-cast using
-    `numpy.ndarray.astype` if desired.
-
-    In the case of `skimage.transform.warp`, the image is normalized to [0, 1]
-    before passing it to the function. It is later rescaled back to the original range.
-
-    Although this function is analogous to the IDL's ``rot`` function, it does not
-    use the same algorithm as the IDL ``rot`` function.
-    IDL's ``rot`` calls the `POLY_2D <https://www.harrisgeospatial.com/docs/poly_2d.html>`__
-    method to calculate the inverse mapping of original to target pixel
-    coordinates. This is a polynomial geometrical transformation.
-    Then optionally it uses a bicubic convolution interpolation
-    algorithm to map the original to target pixel values.
+    For each NaN pixel in the input image, one or more pixels in the output image
+    will be set to NaN, with the number of pixels affected depending on the
+    interpolation order.
+
+    See :func:`~sunpy.image.transform.add_rotation_function` for how to add a
+    different rotation function.
     """
     rmatrix = rmatrix / scale
     array_center = (np.array(image.shape)[::-1] - 1) / 2.0
@@ -95,65 +82,20 @@ def affine_transform(image, rmatrix, order=3, scale=1.0, image_center=None,
     displacement = np.dot(rmatrix, rot_center)
     shift = image_center - displacement
 
+    # While `use_scipy` is still supported, we have to check which method to actually use
     method = _get_transform_method(method, use_scipy)
-    if method == 'scipy':
-        if np.any(np.isnan(image)):
-            warn_user("Setting NaNs to 0 for SciPy rotation.")
-        # Transform the image using the scipy affine transform
-        rotated_image = scipy.ndimage.affine_transform(
-            np.nan_to_num(image).T, rmatrix, offset=shift, order=order,
-            mode='constant', cval=missing).T
-    else:
-        import skimage.transform
-
-        # Make the rotation matrix 3x3 to include translation of the image
-        skmatrix = np.zeros((3, 3))
-        skmatrix[:2, :2] = rmatrix
-        skmatrix[2, 2] = 1.0
-        skmatrix[:2, 2] = shift
-        tform = skimage.transform.AffineTransform(skmatrix)
-
-        if issubclass(image.dtype.type, numbers.Integral):
-            warn_user("Integer input data has been cast to float64.")
-            adjusted_image = image.astype(np.float64)
-        else:
-            adjusted_image = image.copy()
-        if np.any(np.isnan(adjusted_image)) and order >= 4:
-            warn_user("Setting NaNs to 0 for higher-order scikit-image rotation.")
-            adjusted_image = np.nan_to_num(adjusted_image)
-
-        # Scale image to range [0, 1] if it is valid (not made up entirely of NaNs)
-        is_nan_image = np.all(np.isnan(adjusted_image))
-        if is_nan_image:
-            adjusted_missing = missing
-        else:
-            im_min = np.nanmin(adjusted_image)
-            adjusted_image -= im_min
-            im_max = np.nanmax(adjusted_image)
-            if im_max > 0:
-                adjusted_image /= im_max
-                adjusted_missing = (missing - im_min) / im_max
-            else:
-                # The input array is all one value (aside from NaNs), so no scaling is needed
-                adjusted_missing = missing - im_min
-
-        rotated_image = skimage.transform.warp(adjusted_image, tform, order=order,
-                                               mode='constant', cval=adjusted_missing)
 
-        # Convert the image back to its original range if it is valid
-        if not is_nan_image:
-            if im_max > 0:
-                rotated_image *= im_max
-            rotated_image += im_min
+    # Transform the image using the appropriate function
+    rotated_image = _rotation_function_registry[method](image, rmatrix, shift, order, missing, clip)
 
     return rotated_image
 
 
 def _get_transform_method(method, use_scipy):
     # This is re-used in affine_transform and GenericMap.rotate
-    supported_methods = {'scipy', 'skimage'}
-    if method not in supported_methods:
-        raise ValueError(f'Method {method} not in supported methods: {supported_methods}')
+    if method not in _rotation_function_registry:
+        raise ValueError(f'Method {method} not in supported methods: '
+                         f'{_rotation_function_registry.keys()}')
 
     if use_scipy is not None:
         warn_deprecated("The 'use_scipy' argument is deprecated. "
@@ -167,7 +109,216 @@ def _get_transform_method(method, use_scipy):
         try:
             import skimage  # NoQA
         except ImportError:
-            warn_user("scikit-image could not be imported. Image rotation will use scipy.")
-            method = 'scipy'
+            raise ImportError("scikit-image must be installed to be usable for rotation.")
 
     return method
+
+
+def add_rotation_function(name, handles_clipping, handles_image_nans, handles_nan_missing):
+    """
+    Decorator to add a rotation function to the registry of selectable
+    implementations.
+
+    Each registered rotation function becomes a selectable option for
+    :func:`sunpy.image.transform.affine_transform` and
+    :meth:`sunpy.map.GenericMap.rotate`.  Those two routines are required to handle
+    clipping the output image, NaNs in the input image, and NaN as the value to use
+    for pixels in the output image that are beyond the extent of the input image.
+    If the supplied rotation function cannot provide one or more of these capabilities,
+    the decorator is able to provide them instead.
+
+    The decorator accepts the parameters listed under ``Parameters``.  The decorated
+    rotation function must accept the parameters listed under ``Other Parameters``
+    in that order and return the rotated image.
+
+    Parameters
+    ----------
+    name : `str`
+        The name that will be used to select the rotation function
+    handles_clipping : `bool`
+        Specifies whether the rotation function will internally perform clipping.
+        If ``False``, the rotation function will always receive ``False`` for the
+        ``clip`` input parameter.
+    handles_image_nans : `bool`
+        Specifies whether the rotation function will internally handle NaNs in the
+        input image.  If ``False``, the rotation function is guaranteed to be
+        provided an image without any NaNs.
+    handles_nan_missing : `bool`
+        Specifies whether the rotation function will internally handle NaN as the
+        ``missing`` value.  If ``False``, the rotation function will never receive
+        NaN, but instead receive a value in the input range of the image.
+
+    Other Parameters
+    ----------------
+    image : `numpy.ndarray`
+        The image, which could be integers or floats
+    matrix : `numpy.ndarray` that is 2x2
+        The linear transformation matrix (e.g., rotation+scale+skew)
+    shift : 2-element `numpy.ndarray`
+        The translational shift to apply to the image in each axis
+    order : `int`
+        The numerical parameter that controls the degree of interpolation
+    missing : `float`
+        The value to use for outside the bounds of the original image
+    clip : `bool`
+        Whether to clip the output image to the range of the input image
+
+    Notes
+    -----
+    The docstring of the rotation function should be a bulleted list of notes
+    specific to the rotation function.  It will be appended to ``Notes`` section of
+    the docstring for :func:`~sunpy.image.transform.affine_transform`.
+
+    The rotation function is supplied the input image directly, so the function
+    should not modify the image in place.
+
+    Setting any of the ``handles_*`` parameters to ``False`` means that computation
+    will be performed to modify the image returned by the rotation function before
+    it is returned to :func:`~sunpy.image.transform.affine_transform`.
+
+    If the decorator is handling image NaNs on behalf of the rotation function
+    (i.e., ``handles_image_nans=False``), pixels in the output image will be set to
+    NaN if they are within a number of pixels equal to half of the ``order``
+    parameter.  This step requires an additional image convolution, which might be
+    avoidable if the rotation function were able to internally handle image NaNs.
+    """
+    def decorator(rotation_function):
+        @wraps(rotation_function)
+        def wrapper(image, matrix, shift, order, missing, clip):
+            clip_to_use = clip if handles_clipping else False
+
+            # If missing cannot be used directly, use a value in the input range of the image
+            needs_missing_handling = not handles_nan_missing and np.isnan(missing)
+            missing_to_use = missing if not needs_missing_handling else np.nanmin(image)
+
+            if not handles_image_nans:
+                isnan = np.isnan(image)
+                needs_nan_handling = np.any(isnan)
+            else:
+                needs_nan_handling = False
+
+            # If needed, set any image NaNs to a value that is in the range of the input image
+            image_to_use = np.nan_to_num(image, nan=np.nanmin(image)) if needs_nan_handling else image
+
+            rotated_image = rotation_function(image_to_use, matrix, shift, order,
+                                              missing_to_use, clip_to_use)
+
+            # If needed, restore the NaNs
+            if needs_nan_handling:
+                # Use a convolution to find all pixels that are affected by NaNs
+                # We want a kernel size that is an odd number that is at least order+1
+                size = 2*int(np.ceil(order/2))+1
+                expanded_nans = convolve2d(isnan.astype(int),
+                                           np.ones((size, size)).astype(int),
+                                           mode='same')
+                rotated_nans = scipy.ndimage.affine_transform(expanded_nans.T, matrix,
+                                                              offset=shift, order=1,
+                                                              mode='nearest').T
+                rotated_image[rotated_nans > 0] = np.nan
+
+            if needs_missing_handling:
+                # Rotate a constant image to determine where to apply the NaNs for `missing`
+                constant = scipy.ndimage.affine_transform(np.ones_like(image).T.astype(int), matrix,
+                                                          offset=shift, order=0, mode='constant').T
+                rotated_image[constant < 1] = np.nan
+
+            if not handles_clipping and clip and not np.all(np.isnan(rotated_image)):
+                # Clip the image to the input range
+                if np.isnan(missing):
+                    # If `missing` is NaN, clipping to the input range is straightforward
+                    rotated_image.clip(np.nanmin(image), np.nanmax(image), out=rotated_image)
+                else:
+                    # Otherwise, check if `missing` should be considered part of the input range
+                    lower = np.nanmin([np.max([missing, np.nanmin(rotated_image)]), np.nanmin(image)])
+                    upper = np.nanmax([np.min([missing, np.nanmax(rotated_image)]), np.nanmax(image)])
+                    rotated_image.clip(lower, upper, out=rotated_image)
+
+            return rotated_image
+
+        _rotation_function_registry[name] = wrapper
+
+        # Add the docstring of the rotation function to the docstring of affine_transform
+        affine_transform.__doc__ += (f"\n    **Specific notes for the '{name}' rotation method:**"
+                                     f"\n{wrapper.__doc__}")
+
+        return wrapper
+    return decorator
+
+
+_rotation_function_registry = {}
+
+
+@add_rotation_function("scipy",
+                       handles_clipping=False, handles_image_nans=False, handles_nan_missing=True)
+def _rotation_scipy(image, matrix, shift, order, missing, clip):
+    """
+    * Rotates using :func:`scipy.ndimage.affine_transform`
+    * The ``mode`` parameter for :func:`~scipy.ndimage.affine_transform` is fixed to
+      be ``'constant'``
+    """
+    rotated_image = scipy.ndimage.affine_transform(image.T, matrix, offset=shift, order=order,
+                                                   mode='constant', cval=missing).T
+
+    return rotated_image
+
+
+@add_rotation_function("skimage",
+                       handles_clipping=False, handles_image_nans=False, handles_nan_missing=False)
+def _rotation_skimage(image, matrix, shift, order, missing, clip):
+    """
+    * Rotates using :func:`skimage.transform.warp`
+    * The implementation for higher orders of interpolation means that the pixels
+      in the output image that are beyond the extent of the input image may not have
+      exactly the value of the ``missing`` parameter.
+    * An input image with integer data is cast to floats prior to passing to
+      :func:`~skimage.transform.warp`.  The output image can be re-cast using
+      :meth:`numpy.ndarray.astype` if desired.
+    * Does not let :func:`~skimage.transform.warp` handle clipping due to
+      inconsistent handling across interpolation orders
+    * Does not let :func:`~skimage.transform.warp` handle image NaNs because they
+      are not handled properly for some interpolation orders
+    * Does not pass NaN as ``missing`` to :func:`~skimage.transform.warp` due to
+      inconsistent handling across interpolation orders
+    """
+    import skimage.transform
+
+    # Make the rotation matrix 3x3 to include translation of the image
+    skmatrix = np.zeros((3, 3))
+    skmatrix[:2, :2] = matrix
+    skmatrix[2, 2] = 1.0
+    skmatrix[:2, 2] = shift
+    tform = skimage.transform.AffineTransform(skmatrix)
+
+    if issubclass(image.dtype.type, numbers.Integral):
+        warn_user("Integer input data has been cast to float64.")
+        adjusted_image = image.astype(np.float64)
+    else:
+        adjusted_image = image.copy()
+
+    # Scale image to range [0, 1]
+    im_min = np.nanmin([missing, np.min(adjusted_image)])
+    adjusted_image -= im_min
+    adjusted_missing = missing - im_min
+    im_max = np.nanmax([adjusted_missing, np.max(adjusted_image)])
+    if im_max > 0:
+        adjusted_image /= im_max
+        adjusted_missing /= im_max
+
+    # Be aware that even though mode is set to 'constant', when skimage 0.19 calls scipy,
+    # it specifies the scipy mode to be 'grid-constant' rather than 'constant'
+    rotated_image = skimage.transform.warp(adjusted_image, tform, order=order,
+                                           mode='constant', cval=adjusted_missing, clip=clip)
+
+    # Convert the image back to its original range
+    if im_max > 0:
+        rotated_image *= im_max
+    rotated_image += im_min
+
+    return rotated_image
+
+
+# Generate the string with allowable rotation-function names for use in docstrings
+_rotation_function_names = ", ".join([f"``'{name}'``" for name in _rotation_function_registry])
+# Insert into the docstring for affine_transform.  We cannot use the add_common_docstring decorator
+# due to what would be a circular loop in definitions.
+affine_transform.__doc__ = affine_transform.__doc__.format(rotation_function_names=_rotation_function_names)
diff --git a/sunpy/map/mapbase.py b/sunpy/map/mapbase.py
--- a/sunpy/map/mapbase.py
+++ b/sunpy/map/mapbase.py
@@ -36,10 +36,15 @@
 from sunpy.coordinates.utils import get_rectangle_coordinates
 from sunpy.image.resample import resample as sunpy_image_resample
 from sunpy.image.resample import reshape_image_to_4d_superpixel
+from sunpy.image.transform import _get_transform_method, _rotation_function_names, affine_transform
 from sunpy.sun import constants
 from sunpy.time import is_time, parse_time
 from sunpy.util import MetaDict, expand_list
-from sunpy.util.decorators import cached_property_based_on, check_arithmetic_compatibility
+from sunpy.util.decorators import (
+    add_common_docstring,
+    cached_property_based_on,
+    check_arithmetic_compatibility,
+)
 from sunpy.util.exceptions import warn_metadata, warn_user
 from sunpy.util.functools import seconddispatch
 from sunpy.util.util import _figure_to_base64
@@ -666,27 +671,27 @@ def ndim(self):
 
     def std(self, *args, **kwargs):
         """
-        Calculate the standard deviation of the data array.
+        Calculate the standard deviation of the data array, ignoring NaNs.
         """
-        return self.data.std(*args, **kwargs)
+        return np.nanstd(self.data, *args, **kwargs)
 
     def mean(self, *args, **kwargs):
         """
-        Calculate the mean of the data array.
+        Calculate the mean of the data array, ignoring NaNs.
         """
-        return self.data.mean(*args, **kwargs)
+        return np.nanmean(self.data, *args, **kwargs)
 
     def min(self, *args, **kwargs):
         """
-        Calculate the minimum value of the data array.
+        Calculate the minimum value of the data array, ignoring NaNs.
         """
-        return self.data.min(*args, **kwargs)
+        return np.nanmin(self.data, *args, **kwargs)
 
     def max(self, *args, **kwargs):
         """
-        Calculate the maximum value of the data array.
+        Calculate the maximum value of the data array, ignoring NaNs.
         """
-        return self.data.max(*args, **kwargs)
+        return np.nanmax(self.data, *args, **kwargs)
 
     @staticmethod
     def _parse_fits_unit(unit_str):
@@ -1529,9 +1534,10 @@ def resample(self, dimensions: u.pixel, method='linear'):
         new_map = self._new_instance(new_data, new_meta, self.plot_settings)
         return new_map
 
+    @add_common_docstring(rotation_function_names=_rotation_function_names)
     @u.quantity_input
     def rotate(self, angle: u.deg = None, rmatrix=None, order=4, scale=1.0,
-               recenter=False, missing=0.0, use_scipy=None, *, method='skimage'):
+               recenter=False, missing=np.nan, use_scipy=None, *, method='scipy', clip=True):
         """
         Returns a new rotated and rescaled map.
 
@@ -1552,27 +1558,24 @@ def rotate(self, angle: u.deg = None, rmatrix=None, order=4, scale=1.0,
         rmatrix : array-like
             2x2 linear transformation rotation matrix.
         order : int
-            Interpolation order to be used. Must be in the range 0-5.
-            When using scikit-image this
-            parameter 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.affine_transform` where it
-            controls the order of the spline. Faster performance may be
-            obtained at the cost of accuracy by using lower values.
+            Interpolation order to be used.  The precise meaning depends on the
+            rotation method specified by ``method``.
             Default: 4
         scale : float
             A scale factor for the image, default is no scaling
         recenter : bool
             If True, position the axis of rotation at the center of the new map
-            Default: False
+            Default: `False`
         missing : float
-            The numerical value to fill any missing points after rotation.
-            Default: 0.0
-        method : {``'skimage'``, ``'scipy'``}, optional
-            Rotation function to use. Currently
-            :func:`scipy.ndimage.affine_transform` and
-            :func:`skimage.transform.warp` are supported.
+            The value to use for pixels in the output map that are beyond the extent
+            of the input map.
+            Default: `numpy.nan`
+        method : {{{rotation_function_names}}}, optional
+            Rotation function to use.  Defaults to ``'scipy'``.
+        clip : `bool`, optional
+            If `True`, clips the pixel values of the output image to the range of the
+            input image (including the value of ``missing``, if used).
+            Defaults to `True`.
 
         Returns
         -------
@@ -1590,13 +1593,9 @@ def rotate(self, angle: u.deg = None, rmatrix=None, order=4, scale=1.0,
         This function will remove old CROTA keywords from the header.
         This function will also convert a CDi_j matrix to a PCi_j matrix.
 
-        See :func:`sunpy.image.transform.affine_transform` for details on the
-        transformations, situations when the underlying data is modified prior
-        to rotation, and differences from IDL's rot().
+        See :func:`sunpy.image.transform.affine_transform` for details on each of the
+        rotation functions.
         """
-        # Put the import here to reduce sunpy.map import time
-        from sunpy.image.transform import _get_transform_method, affine_transform
-
         if angle is not None and rmatrix is not None:
             raise ValueError("You cannot specify both an angle and a rotation matrix.")
         elif angle is None and rmatrix is None:
@@ -1667,7 +1666,7 @@ def rotate(self, angle: u.deg = None, rmatrix=None, order=4, scale=1.0,
                                     order=order, scale=scale,
                                     image_center=pixel_center,
                                     recenter=recenter, missing=missing,
-                                    method=method)
+                                    method=method, clip=clip)
 
         if recenter:
             new_reference_pixel = pixel_array_center


Test patch:
diff --git a/sunpy/image/tests/test_transform.py b/sunpy/image/tests/test_transform.py
--- a/sunpy/image/tests/test_transform.py
+++ b/sunpy/image/tests/test_transform.py
@@ -1,9 +1,13 @@
 import numpy as np
 import pytest
 import skimage.data as images
+from matplotlib.figure import Figure
 from skimage import transform as tf
 
+from astropy.coordinates.matrix_utilities import rotation_matrix
+
 from sunpy.image.transform import affine_transform
+from sunpy.tests.helpers import figure_test
 from sunpy.util import SunpyDeprecationWarning, SunpyUserWarning
 
 # Tolerance for tests
@@ -21,6 +25,11 @@ def identity():
     return np.array([[1, 0], [0, 1]])
 
 
+@pytest.fixture
+def rot30():
+    return rotation_matrix(30)[0:2, 0:2]
+
+
 def compare_results(expect, result, allclose=True):
     """
     Function to check that the obtained results are what was expected, to
@@ -31,8 +40,8 @@ def compare_results(expect, result, allclose=True):
     res = result[1:-1, 1:-1]
     t1 = abs(exp.mean() - res.mean()) <= RTOL*exp.mean()
 
-    # Don't do the allclose test for scipy as the bicubic algorithm has edge effects
-    # TODO: Develop a way of testing this for scipy
+    # Don't do the allclose test for skimage due to its forced interpolation beyond the edge of the
+    # original image
     if not allclose:
         return t1
     else:
@@ -41,10 +50,12 @@ def compare_results(expect, result, allclose=True):
 
         # Print out every mismatch
         if not t2:
-            mismatches = np.stack([*notclose.nonzero(), exp[notclose], res[notclose]]).T
-            for row in mismatches:
-                print(f"i={int(row[0]+1)}, j={int(row[1]+1)}: expected={row[2]}, result={row[3]}, "
-                      f"adiff={row[2]-row[3]}, rdiff={(row[2]-row[3])/row[2]}")
+            with np.errstate(divide='ignore'):
+                mismatches = np.stack([*notclose.nonzero(), exp[notclose], res[notclose]]).T
+                for row in mismatches:
+                    print(f"i={int(row[0]+1)}, j={int(row[1]+1)}: ",
+                          f"expected={row[2]}, result={row[3]}, "
+                          f"adiff={row[2]-row[3]}, rdiff={(row[2]-row[3])/row[2]}")
 
     return t1 and t2
 
@@ -73,21 +84,21 @@ def test_rotation(original, angle, k):
 
 @pytest.mark.parametrize("angle, k", [(90.0, 1), (-90.0, -1), (-270.0, 1),
                                       (-90.0, 3), (360.0, 0), (-360.0, 0)])
-def test_scipy_rotation(original, angle, k):
+def test_skimage_rotation(original, angle, k):
     # Test rotation against expected outcome
     angle = np.radians(angle)
     c = np.round(np.cos(angle))
     s = np.round(np.sin(angle))
     rmatrix = np.array([[c, -s], [s, c]])
     expected = np.rot90(original, k=k)
-    rot = affine_transform(original, rmatrix=rmatrix, method='scipy')
+    rot = affine_transform(original, rmatrix=rmatrix, method='skimage')
     assert compare_results(expected, rot, allclose=False)
 
     # TODO: Check incremental 360 degree rotation against original image
 
     # Check derotated image against original
     derot_matrix = np.array([[c, s], [-s, c]])
-    derot = affine_transform(rot, rmatrix=derot_matrix, method='scipy')
+    derot = affine_transform(rot, rmatrix=derot_matrix, method='skimage')
     assert compare_results(original, derot, allclose=False)
 
 
@@ -106,14 +117,14 @@ def test_shift(original, dx, dy):
     # Check a shifted shape against expected outcome
     expected = np.roll(np.roll(original, dx, axis=1), dy, axis=0)
     rcen = image_center - np.array([dx, dy])
-    shift = affine_transform(original, rmatrix=rmatrix, recenter=True, image_center=rcen)
+    shift = affine_transform(original, rmatrix=rmatrix, recenter=True, image_center=rcen, missing=0)
     ymin, ymax = max([0, dy]), min([original.shape[1], original.shape[1]+dy])
     xmin, xmax = max([0, dx]), min([original.shape[0], original.shape[0]+dx])
     assert compare_results(expected[ymin:ymax, xmin:xmax], shift[ymin:ymax, xmin:xmax])
 
     # Check shifted and unshifted shape against original image
     rcen = image_center + np.array([dx, dy])
-    unshift = affine_transform(shift, rmatrix=rmatrix, recenter=True, image_center=rcen)
+    unshift = affine_transform(shift, rmatrix=rmatrix, recenter=True, image_center=rcen, missing=0)
     # Need to ignore the portion of the image cut off by the first shift
     ymin, ymax = max([0, -dy]), min([original.shape[1], original.shape[1]-dy])
     xmin, xmax = max([0, -dx]), min([original.shape[0], original.shape[0]-dx])
@@ -126,8 +137,9 @@ def test_scale(original, scale_factor):
     rmatrix = np.array([[1.0, 0.0], [0.0, 1.0]])
 
     # Check a scaled image against the expected outcome
-    newim = tf.rescale(original / original.max(), scale_factor, order=4,
-                       mode='constant', multichannel=False, anti_aliasing=False) * original.max()
+    # When we depend on SciPy 1.6, we can replace this with scipy.ndimage.zoom(..., grid_mode=True)
+    newim = tf.rescale(original / original.max(), scale_factor, order=1,
+                       mode='constant', anti_aliasing=False) * original.max()
     # Old width and new center of image
     w = original.shape[0] / 2.0 - 0.5
     new_c = (newim.shape[0] / 2.0) - 0.5
@@ -139,7 +151,7 @@ def test_scale(original, scale_factor):
     else:
         lower = int(w - new_c)
         expected[lower:upper, lower:upper] = newim
-    scale = affine_transform(original, rmatrix=rmatrix, scale=scale_factor, order=4)
+    scale = affine_transform(original, rmatrix=rmatrix, scale=scale_factor, order=1, missing=0)
     assert compare_results(expected, scale)
 
 
@@ -159,8 +171,9 @@ def test_all(original, angle, dx, dy, scale_factor):
     c = np.round(np.cos(angle))
     s = np.round(np.sin(angle))
     rmatrix = np.array([[c, -s], [s, c]])
-    scale = tf.rescale(original / original.max(), scale_factor, order=4,
-                       mode='constant', multichannel=False, anti_aliasing=False) * original.max()
+    # When we depend on SciPy 1.6, we can replace this with scipy.ndimage.zoom(..., grid_mode=True)
+    scale = tf.rescale(original / original.max(), scale_factor, order=1,
+                       mode='constant', anti_aliasing=False) * original.max()
     new = np.zeros(original.shape)
 
     disp = np.array([dx, dy])
@@ -178,16 +191,16 @@ def test_all(original, angle, dx, dy, scale_factor):
     rcen = image_center - disp
     expected = np.rot90(new, k=k)
 
-    rotscaleshift = affine_transform(original, rmatrix=rmatrix, scale=scale_factor, order=4,
-                                     recenter=True, image_center=rcen)
+    rotscaleshift = affine_transform(original, rmatrix=rmatrix, scale=scale_factor, order=1,
+                                     recenter=True, image_center=rcen, missing=0)
     assert compare_results(expected, rotscaleshift)
 
     # Check a rotated/shifted and restored image against original
-    transformed = affine_transform(original, rmatrix=rmatrix, scale=1.0, order=4, recenter=True,
-                                   image_center=rcen)
+    transformed = affine_transform(original, rmatrix=rmatrix, scale=1.0, order=1, recenter=True,
+                                   image_center=rcen, missing=0)
     inv_rcen = image_center + np.dot(rmatrix.T, np.array([dx, dy]))
-    inverse = affine_transform(transformed, rmatrix=rmatrix.T, scale=1.0, order=4, recenter=True,
-                               image_center=inv_rcen)
+    inverse = affine_transform(transformed, rmatrix=rmatrix.T, scale=1.0, order=1, recenter=True,
+                               image_center=inv_rcen, missing=0)
 
     # Need to ignore the portion of the image cut off by the first shift
     ymin, ymax = max([0, -dy]), min([original.shape[1], original.shape[1]-dy])
@@ -202,38 +215,25 @@ def test_flat(identity):
     assert np.allclose(in_arr, out_arr, rtol=RTOL)
 
 
-# Although a depreaction warning is raised, behaviour is as expected and will
-# continue after the depreaction period, so ignore the warnings
-@pytest.mark.filterwarnings('ignore:Passing `np.nan` to mean no clipping in np.clip has always '
-                            'been unreliable, and is now deprecated')
-def test_nan_skimage_low(identity):
-    # Test non-replacement of NaN values for scikit-image rotation with order <= 3
-    in_arr = np.array([[np.nan]])
-    out_arr = affine_transform(in_arr, rmatrix=identity, order=3)
-    assert np.all(np.isnan(out_arr))
-
-
-def test_nan_skimage_high(identity):
-    # Test replacement of NaN values for scikit-image rotation with order >=4
-    in_arr = np.array([[np.nan]])
-    with pytest.warns(SunpyUserWarning, match='Setting NaNs to 0 for higher-order scikit-image rotation.'):
-        out_arr = affine_transform(in_arr, rmatrix=identity, order=4)
-    assert not np.all(np.isnan(out_arr))
+def test_nan_skimage(identity):
+    # Test preservation of NaN values for scikit-image rotation
+    in_arr = np.array([[np.nan, 0]])
+    out_arr = affine_transform(in_arr, rmatrix=identity, order=0, method='skimage')
+    assert np.isnan(out_arr[0, 0])
 
 
 def test_nan_scipy(identity):
-    # Test replacement of NaN values for scipy rotation
-    in_arr = np.array([[np.nan]])
-    with pytest.warns(SunpyUserWarning, match='Setting NaNs to 0 for SciPy rotation.'):
-        out_arr = affine_transform(in_arr, rmatrix=identity, method='scipy')
-    assert not np.all(np.isnan(out_arr))
+    # Test preservation of NaN values for scipy rotation
+    in_arr = np.array([[np.nan, 0]])
+    out_arr = affine_transform(in_arr, rmatrix=identity, order=0, method='scipy')
+    assert np.isnan(out_arr[0, 0])
 
 
 def test_int(identity):
     # Test casting of integer array to float array
     in_arr = np.array([[100]], dtype=int)
     with pytest.warns(SunpyUserWarning, match='Integer input data has been cast to float64'):
-        out_arr = affine_transform(in_arr, rmatrix=identity)
+        out_arr = affine_transform(in_arr, rmatrix=identity, method='skimage')
     assert np.issubdtype(out_arr.dtype, np.floating)
 
 
@@ -277,3 +277,65 @@ def test_reproducible_matrix_multiplication():
             print(f"{mismatches[i]} mismatching elements in multiplication #{i}")
 
     assert np.sum(mismatches != 0) == 0
+
+
+@figure_test
+def test_clipping(rot30):
+    # Generates a plot to test the clipping the output image to the range of the input image
+    image = np.ones((20, 20))
+    image[4:-4, 4:-4] = 2
+
+    fig = Figure(figsize=(12, 4))
+    axs = fig.subplots(2, 5)
+
+    for i, method in enumerate(['scipy', 'skimage']):
+        axs[i, 0].imshow(image, vmin=0, vmax=3)
+        axs[i, 1].imshow(affine_transform(image, rot30, clip=False, method=method, missing=0),
+                         vmin=0, vmax=3)
+        axs[i, 2].imshow(affine_transform(image, rot30, clip=True, method=method, missing=0),
+                         vmin=0, vmax=3)
+        axs[i, 3].imshow(affine_transform(image, rot30, clip=True, method=method, missing=2),
+                         vmin=0, vmax=3)
+        axs[i, 4].imshow(affine_transform(image, rot30, clip=True, method=method, missing=np.nan),
+                         vmin=0, vmax=3)
+
+    axs[0, 0].set_title('Original')
+    axs[0, 1].set_title('no clip & missing=0')
+    axs[0, 2].set_title('clip & missing=0')
+    axs[0, 3].set_title('clip & missing=2')
+    axs[0, 4].set_title('clip & missing=NaN')
+
+    axs[0, 0].set_ylabel('SciPy')
+    axs[1, 0].set_ylabel('scikit-image')
+
+    return fig
+
+
+@pytest.mark.filterwarnings("ignore:.*bug in the implementation of scikit-image")
+@figure_test
+def test_nans(rot30):
+    # Generates a plot to test the preservation and expansions of NaNs by the rotation
+    image_with_nans = np.ones((23, 23))
+    image_with_nans[4:-4, 4:-4] = 2
+    image_with_nans[9:-9, 9:-9] = np.nan
+
+    fig = Figure(figsize=(16, 4))
+    axs = fig.subplots(2, 7)
+
+    axs[0, 0].set_title('Original (NaNs are white)')
+    axs[0, 0].imshow(image_with_nans, vmin=-1.1, vmax=1.1)
+    axs[1, 0].imshow(image_with_nans, vmin=-1.1, vmax=1.1)
+
+    for i in range(6):
+        axs[0, i+1].set_title(f'order={i}')
+        axs[0, i+1].imshow(affine_transform(image_with_nans, rot30,
+                                            order=i, method='scipy', missing=np.nan),
+                           vmin=-1.1, vmax=1.1)
+        axs[1, i+1].imshow(affine_transform(image_with_nans, rot30,
+                                            order=i, method='skimage', missing=np.nan),
+                           vmin=-1.1, vmax=1.1)
+
+    axs[0, 0].set_ylabel('SciPy')
+    axs[1, 0].set_ylabel('scikit-image')
+
+    return fig
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
@@ -869,29 +869,37 @@ def test_rotate(aia171_test_map):
     # Otherwise, errors can be raised by code that has been compiled
     aia171_test_map._data = aia171_test_map.data.astype('float')
 
-    rotated_map_1 = aia171_test_map.rotate(20 * u.deg)
-    rotated_map_2 = rotated_map_1.rotate(20 * u.deg)
+    # We use order=0 for many of these tests to minimize losing edge pixels due to interpolation
+    # with NaNs that are used as the default `missing` value
+
+    rotated_map_1 = aia171_test_map.rotate(20 * u.deg, order=0)
+    rotated_map_2 = rotated_map_1.rotate(20 * u.deg, order=0)
     np.testing.assert_allclose(rotated_map_1.rotation_matrix,
                                np.dot(aia171_test_map.rotation_matrix, calc_new_matrix(20).T))
     np.testing.assert_allclose(rotated_map_2.rotation_matrix,
                                np.dot(aia171_test_map.rotation_matrix, calc_new_matrix(40).T))
 
     # 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.
+    # and assigns the value of NaN to corner regions. The mean will be approximately
+    # the same, although there will be slight change due to the loss of edge pixels
+    # due to interpolation with the NaNs.
     assert rotated_map_2.data.shape > rotated_map_1.data.shape > aia171_test_map.data.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() < aia171_test_map.mean()
+    assert np.isnan(rotated_map_1.data[0, 0])
+    assert np.isnan(rotated_map_2.data[0, 0])
+    np.testing.assert_allclose(aia171_test_map.mean(), rotated_map_1.mean(), rtol=5e-3)
+    np.testing.assert_allclose(aia171_test_map.mean(), rotated_map_2.mean(), rtol=5e-3)
 
     # A scaled-up map should have the same mean because the output map should be expanded
-    rotated_map_3 = aia171_test_map.rotate(0 * u.deg, order=3, scale=2)
+    rotated_map_3 = aia171_test_map.rotate(0 * u.deg, order=0, scale=2)
     np.testing.assert_allclose(aia171_test_map.mean(), rotated_map_3.mean(), rtol=1e-4)
 
-    # Mean and std should be equal for a 90 degree rotation
-    rotated_map_4 = aia171_test_map.rotate(90 * u.deg, order=3, scale=2)
-    np.testing.assert_allclose(rotated_map_3.mean(), rotated_map_4.mean(), rtol=1e-10)
-    np.testing.assert_allclose(rotated_map_3.std(), rotated_map_4.std(), rtol=1e-10)
+    # Mean and std should be equal for a 90 degree rotation as long as 1 pixel is cropped out on
+    # all sides
+    rotated_map_4 = aia171_test_map.rotate(90 * u.deg, order=0)
+    np.testing.assert_allclose(aia171_test_map.data[1:-1, 1:-1].mean(),
+                               rotated_map_4.data[1:-1, 1:-1].mean(), rtol=1e-10)
+    np.testing.assert_allclose(aia171_test_map.data[1:-1, 1:-1].std(),
+                               rotated_map_4.data[1:-1, 1:-1].std(), rtol=1e-10)
 
     # Rotation of a rectangular map by a large enough angle will change which dimension is larger
     aia171_test_map_crop = aia171_test_map.submap(
diff --git a/sunpy/tests/figure_hashes_mpl_332_ft_261_astropy_421_animators_100.json b/sunpy/tests/figure_hashes_mpl_332_ft_261_astropy_421_animators_100.json
--- a/sunpy/tests/figure_hashes_mpl_332_ft_261_astropy_421_animators_100.json
+++ b/sunpy/tests/figure_hashes_mpl_332_ft_261_astropy_421_animators_100.json
@@ -1,4 +1,6 @@
 {
+  "sunpy.image.tests.test_transform.test_clipping": "86b5ee766c04942844b5614d40aa3235f8bc08f15697b1014faea84208a36eb4",
+  "sunpy.image.tests.test_transform.test_nans": "2c1e06fca9f7ab153ef48bf81479505be3d57fbc0858f588ec7b8fc85c451bf9",
   "sunpy.map.tests.test_compositemap.test_plot_composite_map": "8160a5896607f62b7e7fa316236f565f54bd7a08f511808e98c9ad329678d21a",
   "sunpy.map.tests.test_compositemap.test_plot_composite_map_contours": "909ac12088dde3c15d0c7908f08b4b3c0da23e2a4966ed1ef8e705adb8727d2a",
   "sunpy.map.tests.test_compositemap.test_plot_composite_map_linewidths": "27aa1ac78d4e49f731bf1e43f4d2b398fe8a84d53ed9234794c7954fda54c45a",
@@ -6,8 +8,8 @@
   "sunpy.map.tests.test_compositemap.test_plot_composite_map_colors": "ca7af0e1ee21df8b76bac76f27ac934c452f7906d4bc9000f1faa1dde742aa62",
   "sunpy.map.tests.test_compositemap.test_set_alpha_composite_map": "b9d729ff67b0307c037ac62f97c69bb80d22ad6679a0e56352075845d4ef732b",
   "sunpy.map.tests.test_compositemap.test_peek_composite_map": "bd1df27446d420f3e8cb6fa07daff88b77dd0f0441e4acf83ee48341a9e3218f",
-  "sunpy.map.tests.test_mapbase.test_rotation_rect_pixelated_data": "3835ea8f163f8b843088524fb6f1ea7b1357ee00224bd9ea625c641d974f17b7",
-  "sunpy.map.tests.test_mapbase.test_derotating_nonpurerotation_pcij": "ea58afbd8814f450c2f22ac942400b19e67256a6342082570147e5155530b74e",
+  "sunpy.map.tests.test_mapbase.test_rotation_rect_pixelated_data": "c5fa14672b22dfb082b6d6b186bbfb13b5b70bd8f50776836d76eb470c81f70b",
+  "sunpy.map.tests.test_mapbase.test_derotating_nonpurerotation_pcij": "e0a7f55c5dea4d1e285dbbb1e55823598368cea0e11bd7f290988f1025e2a910",
   "sunpy.map.tests.test_mapsequence.test_norm_animator": "a400eef26fdd915ccf12769031bd8dadedbf8f6a93e31cd5982bffe2484702ee",
   "sunpy.map.tests.test_mapsequence.test_map_sequence_plot": "15c8aee7d3a6ff3d39856dc37e02afa983e1ebc1582f810699defd14b3f6cec7",
   "sunpy.map.tests.test_mapsequence.test_map_sequence_plot_custom_cmap_norm": "7f115dedaf88653dff71f784b0d8173d66638e429f92239a7a6f7d6f10fd05f5",
@@ -26,21 +28,21 @@
   "sunpy.map.tests.test_plotting.test_quadrangle_aia17_pix_width_height": "1bdfaf7549d925388c4df0844f04c741e14e8bad878632d6146e2de6fb511c8d",
   "sunpy.map.tests.test_plotting.test_quadrangle_aia17_top_right": "228c654dcd66ce068c60db0126048cbf9abdf852cc6396ced0244123df1c3675",
   "sunpy.map.tests.test_plotting.test_quadrangle_aia17_pix_top_right": "46f7461ed4b911c9954be24022bff5b252267edd451bad675fa95923012bd699",
-  "sunpy.map.tests.test_plotting.test_quadrangle_aia17_pix_top_right_different_axes": "c867dadf6ad99e29801e5d08bfaf5bd0696edc6166453a4452ac674d5d8e7d21",
+  "sunpy.map.tests.test_plotting.test_quadrangle_aia17_pix_top_right_different_axes": "2aa81bdfb709ed20d2f3cefcf651ce10e2cc5fa54cf41e3fba39ec099a91720b",
   "sunpy.map.tests.test_plotting.test_plot_masked_aia171": "5dd66ed4c720a87a9c26b4bc9beea13404970392172ccd0b3b5bb5d9b01e0c68",
   "sunpy.map.tests.test_plotting.test_plot_aia171_superpixel": "a1cb8c10a7df2b529ccce3edec22bbd9dab806e0d1f4322b9e32cc61977ac752",
   "sunpy.map.tests.test_plotting.test_plot_resample": "6cc2589a5ed59d8e597cdfe00d310e211efe6547a578baa3a873036c41db859a",
   "sunpy.map.tests.test_plotting.test_plot_superpixel": "ca2d632584c78b3a549b2d260daa35efcaaca0229176482efb0f526a4389dde5",
   "sunpy.map.tests.test_plotting.test_plot_masked_aia171_superpixel": "ea477b45bfc4b1e375b031be80b7c7e2f9a7c678a03c11f08276756d998de4dc",
   "sunpy.map.tests.test_plotting.test_draw_contours_aia": "97e412f902cf9e968680a8dd51e5a7ff20ae8ec04ff11551520f1d5bef37c363",
-  "sunpy.map.tests.test_plotting.test_draw_contours_different_wcs": "09bbcef2066ace76ec18fbba59fde5395811ffed5d5d08b74cb06e81c3b4fbe1",
+  "sunpy.map.tests.test_plotting.test_draw_contours_different_wcs": "af3c29e6d0b8f609f598ec75e895e9a505b354784e247f5e86d3ae139d924bf2",
   "sunpy.map.tests.test_plotting.test_heliographic_peek": "4c092580a65c5f9f9138853d8c797ad8bc998a1d6a54073995d5bd3d26817909",
   "sunpy.map.tests.test_plotting.test_heliographic_quadrangle_width_height": "a52dca9100f3f96644e924f283c7ba9bd26db115be3a8e19b91898406d06f9ba",
   "sunpy.map.tests.test_plotting.test_heliographic_quadrangle_top_right": "40591e49e6b32ec3a27b6fc50b9e2fdfcaa9665eca70b6a515dfb9dc7c0b1724",
   "sunpy.map.tests.test_plotting.test_heliographic_grid_annotations": "c7c08b2b6dae1b96c303345b04682060da97a1b304e7f25bcfd40b2154751465",
   "sunpy.map.tests.test_plotting.test_draw_limb_different_observer": "c90abae8026a71528f3afaf5abab676253f29b828f6674dbcbae57d985ef2546",
   "sunpy.map.tests.test_plotting.test_draw_limb_heliographic_stonyhurst": "2f7cf5251db91da34a86b3a72610acc9b257e4c586f2dcf6f10381bd7c3ea186",
-  "sunpy.map.tests.test_plotting.test_plot_autoalign": "698fd24a2922b4631fadf262db384deba3872a4cfa96c773ebc3e8d5a00926bc",
+  "sunpy.map.tests.test_plotting.test_plot_autoalign": "b770e1bc98631e635bc3c374c4ea572f31bf77aefb8c73dadf22951f9af68137",
   "sunpy.map.tests.test_reproject_to.test_reproject_to_hgs": "c1dc26bedf3d24690364637e1a7d6fde165445049e633e8407ad2ccf42416fcb",
   "sunpy.map.tests.test_reproject_to.test_reproject_to_hpc_interpolation": "b82e6462d4ccb8c188b5f6deac3258326ba6840ef624c18f044a9d4cadce3cf2",
   "sunpy.map.tests.test_reproject_to.test_reproject_to_hpc_exact": "93171456dfac771f53bc0465922cf5811048df0b56100d329a55f71b36bdc8f5",
diff --git a/sunpy/tests/figure_hashes_mpl_dev_ft_261_astropy_dev_animators_dev.json b/sunpy/tests/figure_hashes_mpl_dev_ft_261_astropy_dev_animators_dev.json
--- a/sunpy/tests/figure_hashes_mpl_dev_ft_261_astropy_dev_animators_dev.json
+++ b/sunpy/tests/figure_hashes_mpl_dev_ft_261_astropy_dev_animators_dev.json
@@ -1,4 +1,6 @@
 {
+  "sunpy.image.tests.test_transform.test_clipping": "86b5ee766c04942844b5614d40aa3235f8bc08f15697b1014faea84208a36eb4",
+  "sunpy.image.tests.test_transform.test_nans": "2c1e06fca9f7ab153ef48bf81479505be3d57fbc0858f588ec7b8fc85c451bf9",
   "sunpy.map.tests.test_compositemap.test_plot_composite_map": "9cdfb6c45bc5a95fa3d08fe1b8fc77a2d4f05ef8218f5bf4c7f50f11c6262f81",
   "sunpy.map.tests.test_compositemap.test_plot_composite_map_contours": "30c14e6f4ef00263494b07c600155e7698bbecdfc1925bd4cda9bc824ead1eec",
   "sunpy.map.tests.test_compositemap.test_plot_composite_map_linewidths": "89d0f1b75b833e48f7cd6a6776929af440764c2e615947270701afaca0e766d4",
@@ -6,8 +8,8 @@
   "sunpy.map.tests.test_compositemap.test_plot_composite_map_colors": "20b5cb743ee52a34f7977d538f9dbc8d0266ffed7bcfba00fdc393c660f15ef5",
   "sunpy.map.tests.test_compositemap.test_set_alpha_composite_map": "f811cf6be8abfcd1b3a7f4bae7781806b37b1b2b0cb77ac34c4ed4491a1d633a",
   "sunpy.map.tests.test_compositemap.test_peek_composite_map": "d459182443472330df9b0dde3bde647479c100f56f45b6321619fbf67810c174",
-  "sunpy.map.tests.test_mapbase.test_rotation_rect_pixelated_data": "817ef0de8c17246fc4e728d55d119b08c803b6a6906d4b0923f3969eaab0177f",
-  "sunpy.map.tests.test_mapbase.test_derotating_nonpurerotation_pcij": "3405502781af50c4f4d9610d6c36f5aa8915159cd9c734505a34d4eeba7a6d2c",
+  "sunpy.map.tests.test_mapbase.test_rotation_rect_pixelated_data": "f456a1cb2098221a1a0809406e22c90c183dcfe4c3f679966ed5c1ea38851ac7",
+  "sunpy.map.tests.test_mapbase.test_derotating_nonpurerotation_pcij": "efb204b086e36168f1153182106f039a3e33cd6ed302cf5f09e7b634b5e579bd",
   "sunpy.map.tests.test_mapsequence.test_norm_animator": "5a7217dd55ab3a593a27741fecfe0851208fd1d31baeca4cbd018d65aaf3f483",
   "sunpy.map.tests.test_mapsequence.test_map_sequence_plot": "3cdc53f41914028a4e9f23112d7e616ce8a423ef5670c8365fdadbc0e59780ca",
   "sunpy.map.tests.test_mapsequence.test_map_sequence_plot_custom_cmap_norm": "c1276702147f4debaa14c17f6b0b2ab7f9e0b1ace309c72e767b2af071c4405e",
@@ -26,21 +28,21 @@
   "sunpy.map.tests.test_plotting.test_quadrangle_aia17_pix_width_height": "68f0ac032a79416bd0287e393ea98ed5ac54a22a068db08131885046c39b8cbc",
   "sunpy.map.tests.test_plotting.test_quadrangle_aia17_top_right": "9a2d796c365b5280464ecfdb01f62d27ddeee127e6aee4f243a98ec98f923df7",
   "sunpy.map.tests.test_plotting.test_quadrangle_aia17_pix_top_right": "e55f63d480032009779aa5c1742aff30694587f8bee2f8201c7c64192880cecb",
-  "sunpy.map.tests.test_plotting.test_quadrangle_aia17_pix_top_right_different_axes": "9ff2b308a2fe6f3b78e9173b7f8eff229db3f9776c3379e62c13390d3d78bb8a",
+  "sunpy.map.tests.test_plotting.test_quadrangle_aia17_pix_top_right_different_axes": "1882b9e2acbe9fd377ea1fee04787cfe3ab965d615221b181e58b9622bc1aa97",
   "sunpy.map.tests.test_plotting.test_plot_masked_aia171": "8acfaf714eca9295e1d4ecd17e6eec62c8fc991cba45eeb0ba6d2efaa492d29b",
   "sunpy.map.tests.test_plotting.test_plot_aia171_superpixel": "1c133f41c740f4974b8dd27477a247a64c7f8e387d1d8d8c9b4f42ca0387b2ac",
   "sunpy.map.tests.test_plotting.test_plot_resample": "351baa39339b9f7d206232b9c2fb777d7edf965e1554040171ac40e3a1f5ec62",
   "sunpy.map.tests.test_plotting.test_plot_superpixel": "4fcd7af5690bbb5a1ae65ab2bf80db31aa41f240456cddaf49c6132fd459b973",
   "sunpy.map.tests.test_plotting.test_plot_masked_aia171_superpixel": "ffabe0ca544b93c8084af36edb5b293cdce613d13b73ddeaa906ddf2617da34b",
   "sunpy.map.tests.test_plotting.test_draw_contours_aia": "2ff44dd84d067bf65d541422c62d2152057d170a41dbb44b1ffb8d8d4113c6c8",
-  "sunpy.map.tests.test_plotting.test_draw_contours_different_wcs": "8f3623a5417a0ccb2be6feb83211470a7dd1b93b3eaf6d0db55d700f787d5a22",
+  "sunpy.map.tests.test_plotting.test_draw_contours_different_wcs": "bccdd4a828c4c13b71de3457cbd6747e65aa433b2e5d3fdc4ad43c7e4432a957",
   "sunpy.map.tests.test_plotting.test_heliographic_peek": "662a24f525b30ce89ba15b7668518f31d439fa8b0c5ba84da2a71bbda31edb9a",
   "sunpy.map.tests.test_plotting.test_heliographic_quadrangle_width_height": "a52dca9100f3f96644e924f283c7ba9bd26db115be3a8e19b91898406d06f9ba",
   "sunpy.map.tests.test_plotting.test_heliographic_quadrangle_top_right": "40591e49e6b32ec3a27b6fc50b9e2fdfcaa9665eca70b6a515dfb9dc7c0b1724",
   "sunpy.map.tests.test_plotting.test_heliographic_grid_annotations": "c7c08b2b6dae1b96c303345b04682060da97a1b304e7f25bcfd40b2154751465",
   "sunpy.map.tests.test_plotting.test_draw_limb_different_observer": "8802a9b36bb9679067b5c45d752f5cb9e6757a51c41d9809e0b3e131786ada02",
   "sunpy.map.tests.test_plotting.test_draw_limb_heliographic_stonyhurst": "8fb7ab6239ddae9fb06c45b32a37cc14ef0c6d281bf6cd5ae567e8ad8cfd6ff5",
-  "sunpy.map.tests.test_plotting.test_plot_autoalign": "a45cba78f9bcdebd70f3852ca672fa0140400c805ad61450f710ad80f9af0884",
+  "sunpy.map.tests.test_plotting.test_plot_autoalign": "f8891e718f55a6b082cd744ef21002b2c4e82f3f816d3b00edc253e92e1d9f18",
   "sunpy.map.tests.test_reproject_to.test_reproject_to_hgs": "c1dc26bedf3d24690364637e1a7d6fde165445049e633e8407ad2ccf42416fcb",
   "sunpy.map.tests.test_reproject_to.test_reproject_to_hpc_interpolation": "b82e6462d4ccb8c188b5f6deac3258326ba6840ef624c18f044a9d4cadce3cf2",
   "sunpy.map.tests.test_reproject_to.test_reproject_to_hpc_exact": "93171456dfac771f53bc0465922cf5811048df0b56100d329a55f71b36bdc8f5",