Repository: sunpy/sunpy

Issue:
OpenCV as alternative library for map.image.affine_transform
<!--
We know asking good questions takes effort, and we appreciate your time.
Thank you.

Please be aware that everyone has to follow our code of conduct:
https://github.com/sunpy/sunpy/blob/master/CODE_OF_CONDUCT.rst

These comments are hidden when you submit this github issue.

Please have a search on our GitHub repository to see if a similar issue has already been posted.
If a similar issue is closed, have a quick look to see if you are satisfied by the resolution.
If not please go ahead and open an issue!
-->

### Description
<!--
Provide a general description of the feature you would like.
If you prefer, you can also suggest a draft design or API.
-->
As part of the [Helio Hackweek](https://github.com/heliohackweek/AIA_acceleration) a few weeks ago, Raphael Attie (@WaaallEEE) and I experimented with using the OpenCV python library, specifically `cv2.warpAffine`, as an alternative to `scipy.ndimage.transform` and `skimage.warp`. I have now figured out the nuances regarding different coordinate systems and order of operations, and have been able to essentially reproduce the current behavior of `map.rotate` and `image.affine_transform` using `cv2.warpAffine` as the underlying image manipulator.

I've added my [changes to a local fork](https://github.com/cbard/sunpy/blob/opencv2_warp/sunpy/image/transform.py#L123) and run [some benchmarking tests](https://github.com/cbard/sunpy/blob/opencv2_warp/open_cv_tests/CV_rotate_tests2.ipynb). Basically, `openCV.warpAffine` is 3x faster than `skimage.warp` on my laptop.

I have not created a pull request, since discussions with @wtbarnes and @Cadair suggest that it may be better to add optional argument(s) in `image.affine_transform` and `map.rotate` that allow for the passing in of custom `affine_transform` functions and their related `**kwargs`. My current fork has the openCV implementation hardcoded, so I wanted to get some information on the best way to proceed.

A difficulty with passing through a general function (+arguments) is that the required arguments for the underlying image manip routines are calculated within `image.affine_transform` or `map.rotate`; i.e. they are hidden from the general user. Even passing in an angle or rotation matrix to `map.rotate` may require some additional processing to calculate a rotation matrix and put it in the right format for the underlying routine. It seems weird to me to have the user do the required calculations and then pass the resulting arguments through `rotate()`. That kinda defeats the purpose of having `rotate()` in the first place. 


Discussion:
Personally I think it would be reasonable to add a `method=` argument to `rotate()`, that would replace the `use_scipy` argument, and would then allow values from `'scipy', 'skimage', 'opencv'`. Then code can be added to sunpy to deal with the opencv rotation, and users only need to have opencv installed if they want to be able to use the 'opencv' option.
Yes, I think that's a better suggestion. I believe it's easy enough to change the method handling in `affine_transform()` without touching `rotate()` (except to add the `method=` argument). 
I think the larger issue here though is whether we want to have a few hard-coded options (as we do now) or if we want to allow for arbitrary functions to be passed in to handle the affine transform. I think ideally we would have the latter and then maybe `sunpy` would include options for using scipy or scikit-image as we already do, with these specified as a string. So maybe this would look something like,

```python
def affine_transform(..., method='scipy', ...):
    if isinstance(method, str):
        # Check whether this is scipy or scikit-image
        # Set method to the appropriate method for whichever method is chosen
    if not isinstance(method, types.FunctionType):
        # At this point, method has to be a function
        # Otherwise, throw an error
    # Continue with the logic of the affine transform, using method to do the transformation    
```

I'm just weary of maintaining two many different options ourselves as well as further complicating the logic in `affine_transform`. If we provide this type of entrypoint, it is then easier for downstream packages (e.g. `aiapy`) to then shove in whatever type of transform implementation they like (e.g. OpenCV) while still leveraging the `Map.rotate` API. Also, at some point, there's bound to be some other, faster, fancier affine transform method that someone will want to try and this helps future proof us a bit.
> I think the larger issue here though is whether we want to have a few hard-coded options (as we do now) or if we want to allow for arbitrary functions to be passed in to handle the affine transform.

Such decisions are above my paygrade, but I have found it easy to leverage the pre- and post-processing in `rotate()`  and `image.affine_transform()` if we define the library-specific routines relative to some base function (`scipy.ndimage.affine_transform`) like so:
```python
def _scipy_affine_transform(image,rmatrix,order,scale,missing,shift):
    # do nothing (except maybe set NaNs to zero)
    return scipy.ndimage.affine_transform(arguments)

def _opencv_affine_transform(image,rmatrix,order,scale,missing,shift):
    #do preprocessing of input arguments for openCV 
    # (e.g. calculate rotation matrix, convert image to float)
    return cv2.warpAffine(arguments)

def _skimage_affine_transform(image,rmatrix,order,scale,missing,shift):
    # do preprocessing of input arguments for skimage
    # (e.g. calculate rotation matrix, convert image to float, scale/rescale image?)
    return skimage.transform.warp(arguments)
```
and the main `image.affine_transform()` would be something like
```python
def affine_transform(args, method='scipy'):
    # preprocessing of `shift` same as current
    if method == 'skimage':
       return _skimage_affine_transform(image,rmatrix,order,scale,missing,shift)
    elif method == 'opencv':
       return _opencv_affine_transform(image,rmatrix,order,scale,missing,shift)
    else:
       return _scipy_affine_transform(image,rmatrix,order,scale,missing,shift)
```
This would make it easy to manage the logic of switching between different hardcoded library implementations (and add new ones in the future). This would also make it easy to adapt for drop-in functions (with switching logic as @wtbarnes has provided above), provided that the drop-in functions use the required arguments (and no other): `image,rmatrix,order,scale,missing,shift` . 
We had a chat about this on our weekly dev call, and and decided the best way forward with this is to accept an arbitrary function as the `method` argument. I think the next step here (open to being worked on to whoever wants to) is to define what the function signature we accept is going to be.
Ok, sounds good. I'm 80% of the way to having drop-in arbitrary functions in `rotate()`, so I'll make a pull request in the next few days. 

It looks like the drop-in function signature is probably going to be the same as the current implementation of `affine_transform`, except without the `method=` argument. I think openCV is different enough from skimage and scipy.ndimage that the rotation, scaling, and translation will have to be calculated within the drop-in function. 

Resolving patch:
diff --git a/docs/conf.py b/docs/conf.py
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -253,6 +253,39 @@
     'only_warn_on_example_error': True,
 }
 
+# -- Linking to OpenCV docs by using rst_epilog --------------------------------
+try:
+    import requests
+    from bs4 import BeautifulSoup
+
+    base_url = "https://docs.opencv.org"
+
+    # The stable-version docs are the first item in the second list on the main page
+    all_docs = BeautifulSoup(requests.get(base_url).text, 'html.parser')
+    version = all_docs.find_all('ul')[1].li.a.attrs['href'][2:]  # strip leading "./"
+
+    # Find the relative URL to the page for the `cv` namespace
+    stable_docs = BeautifulSoup(requests.get(f"{base_url}/{version}/namespaces.html").text,
+                                'html.parser')
+    cv_namespace = stable_docs.find("a", string="cv").attrs['href']
+
+    # Find the relative URL for warpAffine in the `cv` namespace
+    all_cv = BeautifulSoup(requests.get(f"{base_url}/{version}/{cv_namespace}").text,
+                           'html.parser')
+    warpAffine = all_cv.find("a", string="warpAffine").attrs['href'][6:]  # strip leading "../../"
+
+    # Construct the full URL for warpAffine
+    warpAffine_full = f"{base_url}/{version}/{warpAffine}"
+except:
+    # In the event of any failure (e.g., no network connectivity)
+    warpAffine_full = ""
+
+rst_epilog = f"""
+.. |cv2_warpAffine| replace:: **cv2.warpAffine()**
+.. _cv2_warpAffine: {warpAffine_full}
+"""
+
+
 # -- Stability Page ------------------------------------------------------------
 with open('./code_ref/sunpy_stability.yaml', 'r') as estability:
     sunpy_modules = yaml.load(estability.read(), Loader=yaml.Loader)
diff --git a/sunpy/image/transform.py b/sunpy/image/transform.py
--- a/sunpy/image/transform.py
+++ b/sunpy/image/transform.py
@@ -4,6 +4,7 @@
 import sys
 import numbers
 from functools import wraps
+from collections import namedtuple
 
 import numpy as np
 import scipy.ndimage
@@ -87,16 +88,16 @@ def affine_transform(image, rmatrix, order=3, scale=1.0, image_center=None,
     method = _get_transform_method(method, use_scipy)
 
     # Transform the image using the appropriate function
-    rotated_image = _rotation_function_registry[method](image, rmatrix, shift, order, missing, clip)
+    rotated_image = _rotation_registry[method].function(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
-    if method not in _rotation_function_registry:
+    if method not in _rotation_registry:
         raise ValueError(f'Method {method} not in supported methods: '
-                         f'{_rotation_function_registry.keys()}')
+                         f'{_rotation_registry.keys()}')
 
     if use_scipy is not None:
         warn_deprecated("The 'use_scipy' argument is deprecated. "
@@ -106,7 +107,7 @@ def _get_transform_method(method, use_scipy):
             warn_user(f"Using scipy instead of {method} for rotation.")
             method = 'scipy'
 
-    if method == 'skimage':
+    if method == 'scikit-image':
         try:
             import skimage  # NoQA
         except ImportError:
@@ -115,7 +116,8 @@ def _get_transform_method(method, use_scipy):
     return method
 
 
-def add_rotation_function(name, handles_clipping, handles_image_nans, handles_nan_missing):
+def add_rotation_function(name, *, allowed_orders,
+                          handles_clipping, handles_image_nans, handles_nan_missing):
     """
     Decorator to add a rotation function to the registry of selectable
     implementations.
@@ -128,7 +130,7 @@ def add_rotation_function(name, handles_clipping, handles_image_nans, handles_na
     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
+    The decorator requires 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.
 
@@ -136,6 +138,8 @@ def add_rotation_function(name, handles_clipping, handles_image_nans, handles_na
     ----------
     name : `str`
         The name that will be used to select the rotation function
+    allowed_orders : `set`
+        The allowed values for the ``order`` parameter.
     handles_clipping : `bool`
         Specifies whether the rotation function will internally perform clipping.
         If ``False``, the rotation function will always receive ``False`` for the
@@ -186,6 +190,10 @@ def add_rotation_function(name, handles_clipping, handles_image_nans, handles_na
     def decorator(rotation_function):
         @wraps(rotation_function)
         def wrapper(image, matrix, shift, order, missing, clip):
+            if order not in allowed_orders:
+                raise ValueError(f"{order} is one of the allowed orders for method '{name}': "
+                                 f"{set(allowed_orders)}")
+
             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
@@ -236,7 +244,8 @@ def wrapper(image, matrix, shift, order, missing, clip):
 
             return rotated_image
 
-        _rotation_function_registry[name] = wrapper
+        _rotation_registry[name] = _rotation_method(function=wrapper,
+                                                    allowed_orders=set(allowed_orders))
 
         # 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:**"
@@ -246,14 +255,17 @@ def wrapper(image, matrix, shift, order, missing, clip):
     return decorator
 
 
-_rotation_function_registry = {}
+_rotation_method = namedtuple('_rotation_method', ['function', 'allowed_orders'])
+_rotation_registry = {}
 
 
-@add_rotation_function("scipy",
+@add_rotation_function("scipy", allowed_orders=range(6),
                        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 ``order`` parameter is the order of the spline interpolation, and ranges
+      from 0 to 5.
     * The ``mode`` parameter for :func:`~scipy.ndimage.affine_transform` is fixed to
       be ``'constant'``
     """
@@ -263,11 +275,20 @@ def _rotation_scipy(image, matrix, shift, order, missing, clip):
     return rotated_image
 
 
-@add_rotation_function("skimage",
+@add_rotation_function("scikit-image", allowed_orders=range(6),
                        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 ``order`` parameter selects from the following interpolation algorithms:
+
+      * 0: nearest-neighbor
+      * 1: bi-linear
+      * 2: bi-quadratic
+      * 3: bi-cubic
+      * 4: bi-quartic
+      * 5: bi-quintic
+
     * 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.
@@ -325,8 +346,58 @@ def _rotation_skimage(image, matrix, shift, order, missing, clip):
     return rotated_image
 
 
+@add_rotation_function("opencv", allowed_orders={0, 1, 3},
+                       handles_clipping=False, handles_image_nans=False, handles_nan_missing=False)
+def _rotation_cv2(image, matrix, shift, order, missing, clip):
+    """
+    * Rotates using |cv2_warpAffine|_ from `OpenCV <https://opencv.org>`__
+    * The ``order`` parameter selects from the following interpolation algorithms:
+
+      * 0: nearest-neighbor interpolation
+      * 1: bilinear interpolation
+      * 3: bicubic interpolation
+
+    * An input image with byte ordering that does not match the native byte order of
+      the system (e.g., big-endian values on a little-endian system) will be
+      copied and byte-swapped prior to rotation.
+    * An input image with integer data is cast to floats prior to passing to
+      |cv2_warpAffine|_.  The output image can be re-cast using
+      :meth:`numpy.ndarray.astype` if desired.
+    """
+    try:
+        import cv2
+    except ImportError:
+        raise ImportError("The opencv-python package is required to use this rotation method.")
+
+    _CV_ORDER_FLAGS = {
+        0: cv2.INTER_NEAREST,
+        1: cv2.INTER_LINEAR,
+        3: cv2.INTER_CUBIC,
+    }
+    order_to_use = _CV_ORDER_FLAGS[order]
+
+    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()
+
+    trans = np.concatenate([matrix, shift[:, np.newaxis]], axis=1)
+
+    # Swap the byte order if it is non-native (e.g., big-endian on a little-endian system)
+    if adjusted_image.dtype.byteorder == ('>' if sys.byteorder == 'little' else '<'):
+        adjusted_image = adjusted_image.byteswap().newbyteorder()
+
+    # missing must be a Python float, not a NumPy float
+    missing = float(missing)
+
+    return cv2.warpAffine(adjusted_image, trans, np.flip(adjusted_image.shape),
+                          flags=order_to_use | cv2.WARP_INVERSE_MAP,
+                          borderMode=cv2.BORDER_CONSTANT, borderValue=missing)
+
+
 # Generate the string with allowable rotation-function names for use in docstrings
-_rotation_function_names = ", ".join([f"``'{name}'``" for name in _rotation_function_registry])
+_rotation_function_names = ", ".join([f"``'{name}'``" for name in _rotation_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
@@ -1536,7 +1536,7 @@ def resample(self, dimensions: u.pixel, method='linear'):
 
     @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,
+    def rotate(self, angle: u.deg = None, rmatrix=None, order=3, scale=1.0,
                recenter=False, missing=np.nan, use_scipy=None, *, method='scipy', clip=True):
         """
         Returns a new rotated and rescaled map.
@@ -1560,7 +1560,7 @@ def rotate(self, angle: u.deg = None, rmatrix=None, order=4, scale=1.0,
         order : int
             Interpolation order to be used.  The precise meaning depends on the
             rotation method specified by ``method``.
-            Default: 4
+            Default: 3
         scale : float
             A scale factor for the image, default is no scaling
         recenter : bool


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
@@ -6,7 +6,7 @@
 
 from astropy.coordinates.matrix_utilities import rotation_matrix
 
-from sunpy.image.transform import _rotation_function_registry, affine_transform
+from sunpy.image.transform import _rotation_registry, affine_transform
 from sunpy.tests.helpers import figure_test
 from sunpy.util import SunpyDeprecationWarning, SunpyUserWarning
 
@@ -91,14 +91,14 @@ def test_skimage_rotation(original, angle, k):
     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='skimage')
+    rot = affine_transform(original, rmatrix=rmatrix, method='scikit-image')
     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='skimage')
+    derot = affine_transform(rot, rmatrix=derot_matrix, method='scikit-image')
     assert compare_results(original, derot, allclose=False)
 
 
@@ -218,7 +218,7 @@ def test_flat(identity):
 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')
+    out_arr = affine_transform(in_arr, rmatrix=identity, order=0, method='scikit-image')
     assert np.isnan(out_arr[0, 0])
 
 
@@ -233,7 +233,7 @@ 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, method='skimage')
+        out_arr = affine_transform(in_arr, rmatrix=identity, method='scikit-image')
     assert np.issubdtype(out_arr.dtype, np.floating)
 
 
@@ -256,8 +256,8 @@ def test_deprecated_args(identity):
     with pytest.raises(ValueError, match="Method blah not in supported methods"):
         out_arr = affine_transform(in_arr, rmatrix=identity, method='blah')
 
-    with pytest.warns(SunpyUserWarning, match="Using scipy instead of skimage for rotation"):
-        out_arr = affine_transform(in_arr, rmatrix=identity, use_scipy=True, method='skimage')
+    with pytest.warns(SunpyUserWarning, match="Using scipy instead of scikit-image for rotation"):
+        out_arr = affine_transform(in_arr, rmatrix=identity, use_scipy=True, method='scikit-image')
 
 
 def test_reproducible_matrix_multiplication():
@@ -285,10 +285,12 @@ def test_clipping(rot30):
     image = np.ones((20, 20))
     image[4:-4, 4:-4] = 2
 
-    fig = Figure(figsize=(12, 4))
-    axs = fig.subplots(2, 5)
+    num_methods = len(_rotation_registry.keys())
 
-    for i, method in enumerate(['scipy', 'skimage']):
+    fig = Figure(figsize=(12, 2*num_methods))
+    axs = fig.subplots(nrows=num_methods, ncols=5)
+
+    for i, method in enumerate(_rotation_registry.keys()):
         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)
@@ -298,6 +300,7 @@ def test_clipping(rot30):
                          vmin=0, vmax=3)
         axs[i, 4].imshow(affine_transform(image, rot30, clip=True, method=method, missing=np.nan),
                          vmin=0, vmax=3)
+        axs[i, 0].set_ylabel(method)
 
     axs[0, 0].set_title('Original')
     axs[0, 1].set_title('no clip & missing=0')
@@ -305,9 +308,6 @@ def test_clipping(rot30):
     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
 
 
@@ -319,32 +319,38 @@ def test_nans(rot30):
     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)
+    num_methods = len(_rotation_registry.keys())
 
-    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)
+    fig = Figure(figsize=(16, 2*num_methods))
+    axs = fig.subplots(nrows=num_methods, ncols=7)
 
-    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_title('Original (NaNs are white)')
 
-    axs[0, 0].set_ylabel('SciPy')
-    axs[1, 0].set_ylabel('scikit-image')
+    for j in range(6):
+        axs[0, j+1].set_title(f'order={j}')
+    for i, method in enumerate(_rotation_registry.keys()):
+        axs[i, 0].imshow(image_with_nans, vmin=-1.1, vmax=1.1)
+        for j in range(6):
+            if j not in _rotation_registry[method].allowed_orders:
+                with pytest.raises(ValueError):
+                    affine_transform(image_with_nans, rot30, order=j, method=method, missing=np.nan)
+                axs[i, j+1].remove()
+            else:
+                axs[i, j+1].imshow(affine_transform(image_with_nans, rot30,
+                                                    order=j, method=method, missing=np.nan),
+                                   vmin=-1.1, vmax=1.1)
+        axs[i, 0].set_ylabel(method)
 
     return fig
 
 
 @pytest.mark.filterwarnings("ignore:.*bug in the implementation of scikit-image")
-@pytest.mark.parametrize('method', _rotation_function_registry.keys())
+@pytest.mark.parametrize('method', _rotation_registry.keys())
 @pytest.mark.parametrize('order', range(6))
 def test_endian(method, order, rot30):
+    if order not in _rotation_registry[method].allowed_orders:
+        return
+
     # Test that the rotation output values do not change with input byte order
     native = np.ones((10, 10))
     swapped = native.byteswap().newbyteorder()
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
@@ -27,6 +27,7 @@
 import sunpy.map
 import sunpy.sun
 from sunpy.coordinates import HeliographicCarrington, HeliographicStonyhurst, sun
+from sunpy.image.transform import _rotation_registry
 from sunpy.map.mapbase import GenericMap
 from sunpy.map.sources import AIAMap
 from sunpy.tests.helpers import figure_test
@@ -1431,13 +1432,14 @@ def test_rotation_rect_pixelated_data(aia171_test_map):
     rect_rot_map.peek()
 
 
+@pytest.mark.parametrize('method', _rotation_registry.keys())
 @figure_test
-def test_derotating_nonpurerotation_pcij(aia171_test_map):
+def test_derotating_nonpurerotation_pcij(aia171_test_map, method):
     # The following map has a a PCij matrix that is not a pure rotation
     weird_map = aia171_test_map.rotate(30*u.deg).superpixel([2, 1]*u.pix)
 
     # De-rotating the map by its PCij matrix should result in a normal-looking map
-    derotated_map = weird_map.rotate()
+    derotated_map = weird_map.rotate(method=method)
 
     fig = Figure(figsize=(8, 4))
 
@@ -1445,7 +1447,7 @@ def test_derotating_nonpurerotation_pcij(aia171_test_map):
     weird_map.plot(axes=ax1, title='Map with a non-pure-rotation PCij matrix')
 
     ax2 = fig.add_subplot(122, projection=derotated_map)
-    derotated_map.plot(axes=ax2, title='De-rotated map')
+    derotated_map.plot(axes=ax2, title=f'De-rotated map via {method}')
 
     return fig
 
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,6 +1,6 @@
 {
-  "sunpy.image.tests.test_transform.test_clipping": "86b5ee766c04942844b5614d40aa3235f8bc08f15697b1014faea84208a36eb4",
-  "sunpy.image.tests.test_transform.test_nans": "2c1e06fca9f7ab153ef48bf81479505be3d57fbc0858f588ec7b8fc85c451bf9",
+  "sunpy.image.tests.test_transform.test_clipping": "f532f38f2f31765ab20c2a0f4863a578f04e3e99894eb664926d1ce26ddaca1d",
+  "sunpy.image.tests.test_transform.test_nans": "5e87b5900391974ab62b6204fe7581d1e577dd2cbf3973636cbb2ba5059304f6",
   "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",
@@ -8,8 +8,10 @@
   "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": "c5fa14672b22dfb082b6d6b186bbfb13b5b70bd8f50776836d76eb470c81f70b",
-  "sunpy.map.tests.test_mapbase.test_derotating_nonpurerotation_pcij": "e0a7f55c5dea4d1e285dbbb1e55823598368cea0e11bd7f290988f1025e2a910",
+  "sunpy.map.tests.test_mapbase.test_rotation_rect_pixelated_data": "b114da1bf9f4a465862fe5cd2d82368e476e84502cae0716446bf2ec66475c35",
+  "sunpy.map.tests.test_mapbase.test_derotating_nonpurerotation_pcij[scipy]": "573b05bd76db2a07eb77f9aa7e9d5b5f8270928a8c39f99f63a989ac86f2debb",
+  "sunpy.map.tests.test_mapbase.test_derotating_nonpurerotation_pcij[scikit-image]": "4f30e2b63f6bcec76756bf42f4993bdc0faceace5e10e46c006ee7d1e23b1917",
+  "sunpy.map.tests.test_mapbase.test_derotating_nonpurerotation_pcij[opencv]": "f82d4c8699b30f6052cde9e9f9d98bdf065dda8fbb2c82aaf2472c414b5aa562",
   "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",
@@ -28,7 +30,7 @@
   "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": "2aa81bdfb709ed20d2f3cefcf651ce10e2cc5fa54cf41e3fba39ec099a91720b",
+  "sunpy.map.tests.test_plotting.test_quadrangle_aia17_pix_top_right_different_axes": "7c9d27518d1aaf334aca833c1937f4a31d0c9c6deca5ca16a5ce5b312cb25906",
   "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",
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,6 +1,6 @@
 {
-  "sunpy.image.tests.test_transform.test_clipping": "86b5ee766c04942844b5614d40aa3235f8bc08f15697b1014faea84208a36eb4",
-  "sunpy.image.tests.test_transform.test_nans": "2c1e06fca9f7ab153ef48bf81479505be3d57fbc0858f588ec7b8fc85c451bf9",
+  "sunpy.image.tests.test_transform.test_clipping": "f532f38f2f31765ab20c2a0f4863a578f04e3e99894eb664926d1ce26ddaca1d",
+  "sunpy.image.tests.test_transform.test_nans": "5e87b5900391974ab62b6204fe7581d1e577dd2cbf3973636cbb2ba5059304f6",
   "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",
@@ -8,8 +8,10 @@
   "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": "f456a1cb2098221a1a0809406e22c90c183dcfe4c3f679966ed5c1ea38851ac7",
-  "sunpy.map.tests.test_mapbase.test_derotating_nonpurerotation_pcij": "efb204b086e36168f1153182106f039a3e33cd6ed302cf5f09e7b634b5e579bd",
+  "sunpy.map.tests.test_mapbase.test_rotation_rect_pixelated_data": "875c2c95692c468f768bdb239c53438f0584898742705478a4be19cef9b02158",
+  "sunpy.map.tests.test_mapbase.test_derotating_nonpurerotation_pcij[scipy]": "c6463c3f8960346023c8e5b9a6ae1c2f03fb7f22fcfbd66ee5f25d694e86984d",
+  "sunpy.map.tests.test_mapbase.test_derotating_nonpurerotation_pcij[scikit-image]": "12f3b0a29d00da99a4eada843990d83e057708bac65496db0fef22ee4d886772",
+  "sunpy.map.tests.test_mapbase.test_derotating_nonpurerotation_pcij[opencv]": "0c395d9900075f9f646199bc2b8b7812ede465865b3f2fe1e2527c48dc44ec8b",
   "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",
@@ -28,7 +30,7 @@
   "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": "1882b9e2acbe9fd377ea1fee04787cfe3ab965d615221b181e58b9622bc1aa97",
+  "sunpy.map.tests.test_plotting.test_quadrangle_aia17_pix_top_right_different_axes": "69b169851b43eeb2e73d4a893da047e8b52964c5b6030aecee7430ea7723dda0",
   "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",