Repository: sunpy/sunpy

Issue:
Normalise images before handing them to skimage.transform.warp
From @ayshih at https://github.com/sunpy/sunpy/pull/4452#issuecomment-686504683:

> #2041 removed the normalization code and cast everything to float64. However, I believe the removal of the normalization code should not have been done. Scikit-image's documentation is quite clear that it always assumes that floats are in the range of [0, 1]. While warp() does appear to work fine when that assumption is violated, we are currently relying on undefined behavior.

> I suggest that the normalization code be re-inserted.

I believe the work here is to normalise floating point input to `skimage.transform.warp` to the range [0, 1] in `sunpy.image.transform.affine_transform`, and then un-normalise it after the transform.


Discussion:
Hi, I'd like to take this up.
Is it as simple as just adding back the relevant normalization code that was deleted from [here](https://github.com/sunpy/sunpy/pull/2041/files#diff-9fba7e11930644e328435ce0f0ab94dfL133-L148)? I hope I'm not missing something?
Sounds like it.
Should I document this behavior in the `affine_transform` docstring? Even though it's an implementation detail, seems like an important one, no? 😅
It is probably worth adding a note.

Resolving patch:
diff --git a/sunpy/image/transform.py b/sunpy/image/transform.py
--- a/sunpy/image/transform.py
+++ b/sunpy/image/transform.py
@@ -59,14 +59,16 @@ def affine_transform(image, rmatrix, order=3, scale=1.0, image_center=None,
     an alternative affine transformation. The two transformations use different
     algorithms and thus do not give identical output.
 
-    When using for `skimage.transform.warp` with order >= 4 or using
-    `scipy.ndimage.affine_transform` at all, "NaN" values will
-    replaced with zero prior to rotation. No attempt is made to retain the NaN
-    values.
+    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>`__
@@ -115,8 +117,7 @@ def affine_transform(image, rmatrix, order=3, scale=1.0, image_center=None,
         tform = skimage.transform.AffineTransform(skmatrix)
 
         if issubclass(image.dtype.type, numbers.Integral):
-            warnings.warn("Integer input data has been cast to float64, "
-                          "which is required for the skikit-image transform.",
+            warnings.warn("Integer input data has been cast to float64.",
                           SunpyUserWarning)
             adjusted_image = image.astype(np.float64)
         else:
@@ -126,8 +127,28 @@ def affine_transform(image, rmatrix, order=3, scale=1.0, image_center=None,
                           SunpyUserWarning)
             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=missing,
-                                               preserve_range=True)
+                                               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
 
     return rotated_image


Test patch: