Repository: scikit-image/scikit-image

Issue:
Interpolation error in measure.profile_line
## Description

#4206, once merged, exposes interesting behavior for [measure.profile_line](https://scikit-image.org/docs/dev/api/skimage.measure.html#skimage.measure.profile_line). Consider

```python
image = np.arange(12).reshape(4, 3)
# reduce_func is a new parameter proposed in #4206
prof = profile_line(image, (0, 1), (3, 1), linewidth=3, reduce_func=None)
assert_array_equal(image, prof)
```
As I understand it, the returned profile should be identical with the original image but we get this (value at (0, 2) is different):
```
AssertionError: 
Arrays are not equal
Mismatch: 8.33%
Max absolute difference: 2
Max relative difference: 0.
 x: array([[ 0,  1,  2],
       [ 3,  4,  5],
       [ 6,  7,  8],
       [ 9, 10, 11]])
 y: array([[ 0,  1,  0],
       [ 3,  4,  5],
       [ 6,  7,  8],
       [ 9, 10, 11]])
```
This might originate in [scipy.ndimage.map_coordinates](https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html#scipy.ndimage.map_coordinates). Looks like a potential bug to me but might be an edge case for the underlying interpolation that can be ignored/is not reasonable to fix. 
This behavior is already relevant even before merging #4206 because the faulty value will change the mean value of the profile line.

https://github.com/scikit-image/scikit-image/pull/4206#issuecomment-544920806 also highlights additional strange behavior.

## Version information

Current master, exposed once #4206 is merged.



Discussion:
@lagru it has to do with this: https://github.com/scipy/scipy/issues/9746 (though that issue needs links to other posts).

I think what is happening is that the `arctan2` call is getting a coordinate that is *just* past (2,) for axis=1 due to floating point error, and that evaluates to 0, which I've argued with @stefanv is a mistake, but I'll leave that battle for a future post. =P I agree with his statement on Zulip that someone needs to take a close look at this, *and* I kinda wanna be that someone. =P Soon...
Thanks for the pointer! Do you mean to take a look at scipy/scipy#9746 or at NumPy's `arctan2`?

Resolving patch:
diff --git a/skimage/measure/profile.py b/skimage/measure/profile.py
--- a/skimage/measure/profile.py
+++ b/skimage/measure/profile.py
@@ -4,21 +4,22 @@
 
 
 def profile_line(image, src, dst, linewidth=1,
-                 order=None, mode='constant', cval=0.0,
+                 order=None, mode=None, cval=0.0,
                  *, reduce_func=np.mean):
     """Return the intensity profile of an image measured along a scan line.
 
     Parameters
     ----------
-    image : numeric array, shape (M, N[, C])
+    image : ndarray, shape (M, N[, C])
         The image, either grayscale (2D array) or multichannel
         (3D array, where the final axis contains the channel
         information).
-    src : 2-tuple of numeric scalar (float or int)
-        The start point of the scan line.
-    dst : 2-tuple of numeric scalar (float or int)
-        The end point of the scan line. The destination point is *included*
-        in the profile, in contrast to standard numpy indexing.
+    src : array_like, shape (2, )
+        The coordinates of the start point of the scan line.
+    dst : array_like, shape (2, )
+        The coordinates of the end point of the scan
+        line. The destination point is *included* in the profile, in
+        contrast to standard numpy indexing.
     linewidth : int, optional
         Width of the scan, perpendicular to the line
     order : int in {0, 1, 2, 3, 4, 5}, optional
@@ -97,6 +98,12 @@ def profile_line(image, src, dst, linewidth=1,
              "cast input image to another data type. Starting from version "
              "0.19 a ValueError will be raised instead of this warning.",
              FutureWarning, stacklevel=2)
+    if mode is None:
+        warn("Default out of bounds interpolation mode 'constant' is "
+             "deprecated. In version 0.19 it will be set to 'reflect'. "
+             "To avoid this warning, set `mode=` explicitly.",
+             FutureWarning, stacklevel=2)
+        mode = 'constant'
 
     perp_lines = _line_profile_coordinates(src, dst, linewidth=linewidth)
     if image.ndim == 3:


Test patch:
diff --git a/skimage/measure/tests/test_profile.py b/skimage/measure/tests/test_profile.py
--- a/skimage/measure/tests/test_profile.py
+++ b/skimage/measure/tests/test_profile.py
@@ -1,38 +1,38 @@
 import numpy as np
 
 from ..._shared.testing import assert_equal, assert_almost_equal
+from ..._shared._warnings import expected_warnings
 from ..profile import profile_line
 
-
 image = np.arange(100).reshape((10, 10)).astype(np.float)
 
 
 def test_horizontal_rightward():
-    prof = profile_line(image, (0, 2), (0, 8), order=0)
+    prof = profile_line(image, (0, 2), (0, 8), order=0, mode='constant')
     expected_prof = np.arange(2, 9)
     assert_equal(prof, expected_prof)
 
 
 def test_horizontal_leftward():
-    prof = profile_line(image, (0, 8), (0, 2), order=0)
+    prof = profile_line(image, (0, 8), (0, 2), order=0, mode='constant')
     expected_prof = np.arange(8, 1, -1)
     assert_equal(prof, expected_prof)
 
 
 def test_vertical_downward():
-    prof = profile_line(image, (2, 5), (8, 5), order=0)
+    prof = profile_line(image, (2, 5), (8, 5), order=0, mode='constant')
     expected_prof = np.arange(25, 95, 10)
     assert_equal(prof, expected_prof)
 
 
 def test_vertical_upward():
-    prof = profile_line(image, (8, 5), (2, 5), order=0)
+    prof = profile_line(image, (8, 5), (2, 5), order=0, mode='constant')
     expected_prof = np.arange(85, 15, -10)
     assert_equal(prof, expected_prof)
 
 
 def test_45deg_right_downward():
-    prof = profile_line(image, (2, 2), (8, 8), order=0)
+    prof = profile_line(image, (2, 2), (8, 8), order=0, mode='constant')
     expected_prof = np.array([22, 33, 33, 44, 55, 55, 66, 77, 77, 88])
     # repeats are due to aliasing using nearest neighbor interpolation.
     # to see this, imagine a diagonal line with markers every unit of
@@ -43,37 +43,37 @@ def test_45deg_right_downward():
 
 
 def test_45deg_right_downward_interpolated():
-    prof = profile_line(image, (2, 2), (8, 8), order=1)
+    prof = profile_line(image, (2, 2), (8, 8), order=1, mode='constant')
     expected_prof = np.linspace(22, 88, 10)
     assert_almost_equal(prof, expected_prof)
 
 
 def test_45deg_right_upward():
-    prof = profile_line(image, (8, 2), (2, 8), order=1)
+    prof = profile_line(image, (8, 2), (2, 8), order=1, mode='constant')
     expected_prof = np.arange(82, 27, -6)
     assert_almost_equal(prof, expected_prof)
 
 
 def test_45deg_left_upward():
-    prof = profile_line(image, (8, 8), (2, 2), order=1)
+    prof = profile_line(image, (8, 8), (2, 2), order=1, mode='constant')
     expected_prof = np.arange(88, 21, -22. / 3)
     assert_almost_equal(prof, expected_prof)
 
 
 def test_45deg_left_downward():
-    prof = profile_line(image, (2, 8), (8, 2), order=1)
+    prof = profile_line(image, (2, 8), (8, 2), order=1, mode='constant')
     expected_prof = np.arange(28, 83, 6)
     assert_almost_equal(prof, expected_prof)
 
 
 def test_pythagorean_triangle_right_downward():
-    prof = profile_line(image, (1, 1), (7, 9), order=0)
+    prof = profile_line(image, (1, 1), (7, 9), order=0, mode='constant')
     expected_prof = np.array([11, 22, 23, 33, 34, 45, 56, 57, 67, 68, 79])
     assert_equal(prof, expected_prof)
 
 
 def test_pythagorean_triangle_right_downward_interpolated():
-    prof = profile_line(image, (1, 1), (7, 9), order=1)
+    prof = profile_line(image, (1, 1), (7, 9), order=1, mode='constant')
     expected_prof = np.linspace(11, 79, 11)
     assert_almost_equal(prof, expected_prof)
 
@@ -88,95 +88,107 @@ def test_pythagorean_triangle_right_downward_interpolated():
 
 
 def test_pythagorean_triangle_right_downward_linewidth():
-    prof = profile_line(pyth_image, (1, 1), (4, 5), linewidth=3, order=0)
+    prof = profile_line(pyth_image, (1, 1), (4, 5), linewidth=3, order=0,
+                        mode='constant')
     expected_prof = np.ones(6)
     assert_almost_equal(prof, expected_prof)
 
 
 def test_pythagorean_triangle_right_upward_linewidth():
     prof = profile_line(pyth_image[::-1, :], (4, 1), (1, 5),
-                        linewidth=3, order=0)
+                        linewidth=3, order=0, mode='constant')
     expected_prof = np.ones(6)
     assert_almost_equal(prof, expected_prof)
 
 
 def test_pythagorean_triangle_transpose_left_down_linewidth():
     prof = profile_line(pyth_image.T[:, ::-1], (1, 4), (5, 1),
-                        linewidth=3, order=0)
+                        linewidth=3, order=0, mode='constant')
     expected_prof = np.ones(6)
     assert_almost_equal(prof, expected_prof)
 
 
 def test_reduce_func_mean():
     prof = profile_line(pyth_image, (0, 1), (3, 1), linewidth=3, order=0,
-                        reduce_func=np.mean)
-    expected_prof = np.array([0, 0.8, 1, 0.2])
+                        reduce_func=np.mean, mode='reflect')
+    expected_prof = pyth_image[:4, :3].mean(1)
     assert_almost_equal(prof, expected_prof)
 
 
 def test_reduce_func_max():
     prof = profile_line(pyth_image, (0, 1), (3, 1), linewidth=3, order=0,
-                        reduce_func=np.max)
-    expected_prof = np.array([0, 1.8, 1.8, 0.6])
+                        reduce_func=np.max, mode='reflect')
+    expected_prof = pyth_image[:4, :3].max(1)
     assert_almost_equal(prof, expected_prof)
 
 
 def test_reduce_func_sum():
     prof = profile_line(pyth_image, (0, 1), (3, 1), linewidth=3, order=0,
-                        reduce_func=np.sum)
-    expected_prof = np.array([0, 2.4, 3, 0.6])
+                        reduce_func=np.sum, mode='reflect')
+    expected_prof = pyth_image[:4, :3].sum(1)
     assert_almost_equal(prof, expected_prof)
 
 
 def test_reduce_func_mean_linewidth_1():
     prof = profile_line(pyth_image, (0, 1), (3, 1), linewidth=1, order=0,
-                        reduce_func=np.mean)
-    expected_prof = np.array([0, 1.8, 0.6, 0.])
+                        reduce_func=np.mean, mode='constant')
+    expected_prof = pyth_image[:4, 1]
     assert_almost_equal(prof, expected_prof)
 
 
 def test_reduce_func_None_linewidth_1():
-    prof = profile_line(pyth_image, (1, 2), (4, 2),
-                        linewidth=1, order=0, reduce_func=None)
-    expected_prof = np.array([[0.6], [1.8], [0.6], [0.]])
+    prof = profile_line(pyth_image, (1, 2), (4, 2), linewidth=1,
+                        order=0, reduce_func=None, mode='constant')
+    expected_prof = pyth_image[1:5, 2, np.newaxis]
     assert_almost_equal(prof, expected_prof)
 
 
 def test_reduce_func_None_linewidth_3():
-    prof = profile_line(pyth_image, (1, 2), (4, 2),
-                        linewidth=3, order=0, reduce_func=None)
-    expected_prof = np.array([[1.8, 0.6, 0.6],
-                              [0.6, 1.8, 1.8],
-                              [0., 0.6, 1.8],
-                              [0., 0., 0.6]])
+    prof = profile_line(pyth_image, (1, 2), (4, 2), linewidth=3,
+                        order=0, reduce_func=None, mode='constant')
+    expected_prof = pyth_image[1:5, 1:4]
     assert_almost_equal(prof, expected_prof)
 
 
 def test_reduce_func_lambda_linewidth_3():
+    def reduce_func(x):
+        return x + x ** 2
     prof = profile_line(pyth_image, (1, 2), (4, 2), linewidth=3, order=0,
-                        reduce_func=lambda x: x + x**2)
-    expected_prof = np.array([[5.04, 0.96, 0.96],
-                              [0.96, 5.04, 5.04],
-                              [0., 0.96, 5.04],
-                              [0., 0., 0.96]])
-    # The lambda function acts on each pixel value individually.
+                        reduce_func=reduce_func, mode='constant')
+    expected_prof = np.apply_along_axis(reduce_func,
+                                        arr=pyth_image[1:5, 1:4], axis=1)
     assert_almost_equal(prof, expected_prof)
 
 
 def test_reduce_func_sqrt_linewidth_3():
-    prof = profile_line(pyth_image, (1, 2), (4, 2),
-                        linewidth=3, order=0, reduce_func=lambda x: x**0.5)
-    expected_prof = np.array([[1.34164079, 0.77459667, 0.77459667],
-                              [0.77459667, 1.34164079, 1.34164079],
-                              [0., 0.77459667, 1.34164079],
-                              [0., 0., 0.77459667]])
+    def reduce_func(x):
+        return x ** 0.5
+    prof = profile_line(pyth_image, (1, 2), (4, 2), linewidth=3,
+                        order=0, reduce_func=reduce_func,
+                        mode='constant')
+    expected_prof = np.apply_along_axis(reduce_func,
+                                        arr=pyth_image[1:5, 1:4], axis=1)
     assert_almost_equal(prof, expected_prof)
 
 
 def test_reduce_func_sumofsqrt_linewidth_3():
+    def reduce_func(x):
+        return np.sum(x ** 0.5)
     prof = profile_line(pyth_image, (1, 2), (4, 2), linewidth=3, order=0,
-                        reduce_func=lambda x: np.sum(x**0.5))
-    expected_prof = np.array([2.89083412, 3.45787824, 2.11623746, 0.77459667])
+                        reduce_func=reduce_func, mode='constant')
+    expected_prof = np.apply_along_axis(reduce_func,
+                                        arr=pyth_image[1:5, 1:4], axis=1)
+    assert_almost_equal(prof, expected_prof)
+
+
+def test_oob_coodinates():
+    offset = 2
+    idx = pyth_image.shape[0] + offset
+    prof = profile_line(pyth_image, (-offset, 2), (idx, 2), linewidth=1,
+                        order=0, reduce_func=None, mode='constant')
+    expected_prof = np.vstack([np.zeros((offset, 1)),
+                               pyth_image[:, 2, np.newaxis],
+                               np.zeros((offset + 1, 1))])
     assert_almost_equal(prof, expected_prof)