Repository: scikit-image/scikit-image

Issue:
`EllipseModel` fitting fails if relative point differences are too small
### Description:

Hi, I am trying to fit thousands of ellipse models to features in my microscopy data. There is little apparent difference between points that ellipse models can be fitted to and points that fail. Can someone explain what the difference is, or is this a bug? 

Download a Jupyter Notebook of the code here: https://gist.github.com/chrispook/3fb15068dc7dcfb0a4ec6e3c4e768852

### Way to reproduce:

```Python
import numpy as np
from skimage.measure import EllipseModel
import plotly.express as px

good_x1 = [779.026, 778.125, 776.953, 776.195, 775.617, 775.068, 774.127, 773.696, 773.305, 773.113, 773.088, 773.233, 773.449, 773.913, 774.344, 774.625, 775.179, 775.777, 776.254, 777.039, 777.926, 778.945, 780.023, 781.059, 781.973, 782.777, 783.244, 783.922, 784.995, 785.825, 786.196, 786.486, 786.65, 786.614, 786.482, 786.153, 785.749, 785.507, 784.901, 784.482, 783.879, 782.809, 781.965, 780.998, 780.001, 779.026]
good_y1 = [309.143, 309.432, 309.912, 310.35, 310.46, 311.087, 312.099, 312.879, 314.085, 315.012, 315.995, 316.948, 318.166, 319.044, 319.751, 320.283, 320.794, 321.34, 321.505, 321.908, 322.254, 322.478, 322.467, 322.243, 321.929, 321.561, 321.449, 320.891, 319.995, 318.905, 318.07, 316.872, 315.97, 315.037, 313.883, 312.943, 312.17, 311.623, 311.093, 310.477, 310.151, 309.54, 309.18, 309.027, 309.022, 309.143]

good_x2 = [434.959, 434.0, 433.012, 432.093, 430.938, 430.279, 429.847, 429.535, 429.257, 429.031, 428.843, 429.0, 429.348, 429.872, 430.313, 431.048, 432.189, 433.043, 434.003, 434.971, 435.769, 436.199, 436.743, 437.263, 437.824, 438.017, 438.018, 437.831, 437.449, 437.29, 436.807, 436.255, 435.776, 434.959]
good_y2 = [215.849, 216.001, 215.929, 215.684, 215.09, 214.615, 214.117, 213.631, 212.903, 211.992, 211.017, 210.0, 209.39, 208.857, 208.587, 208.087, 207.57, 207.247, 207.135, 207.2, 207.565, 207.73, 208.248, 208.819, 210.055, 210.998, 212.001, 212.952, 213.687, 214.168, 214.781, 215.333, 215.49, 215.849]

good_x3 = [1666.998, 1666.014, 1665.206, 1664.689, 1664.302, 1663.977, 1663.969, 1664.293, 1664.527, 1665.09, 1665.929, 1667.048, 1668.016, 1668.658, 1669.171, 1669.638, 1669.599, 1668.995, 1667.916, 1666.998]
good_y3 = [85.023, 85.07, 85.414, 85.685, 86.245, 86.994, 88.004, 88.835, 89.364, 89.862, 90.302, 90.338, 90.034, 89.491, 89.134, 87.917, 86.807, 86.004, 85.251, 85.023]

bad_x1 = [1541.221, 1541.848, 1543.009, 1544.15, 1544.962, 1545.777, 1545.943, 1545.786, 1545.103, 1543.986, 1543.14, 1541.968, 1541.094, 1540.765, 1540.799, 1541.221]
bad_y1 = [1254.78, 1255.29, 1255.535, 1255.395, 1254.945, 1253.922, 1253.0, 1252.063, 1250.892, 1250.374, 1250.401, 1250.959, 1252.049, 1252.968, 1254.069, 1254.78]

bad_x2 = [1739.079, 1738.567, 1738.392, 1738.118, 1738.17, 1738.782, 1739.302, 1740.179, 1741.013, 1741.999, 1742.997, 1743.423, 1744.178, 1743.811, 1743.735, 1743.595, 1743.308, 1742.834, 1742.342, 1741.813, 1740.998, 1739.995, 1739.079]
bad_y2 = [329.807, 329.316, 328.814, 327.989, 327.061, 325.853, 325.22, 324.478, 324.115, 324.078, 324.154, 324.49, 324.753, 325.994, 326.902, 327.679, 328.143, 328.836, 329.41, 329.628, 329.99, 330.067, 329.807]

bad_x3 = [992.001, 991.057, 989.879, 989.599, 989.252, 989.286, 989.894, 991.05, 991.983, 992.806, 993.286, 993.846, 994.32, 994.481, 994.088, 992.959, 992.001]
bad_y3 = [136.048, 136.19, 136.883, 137.551, 138.053, 138.929, 140.102, 140.767, 140.846, 140.551, 140.416, 139.851, 139.115, 137.938, 136.94, 136.168, 136.048]



xy = [(good_x1, good_y1, 'good_1'), (good_x2, good_y2, 'good_2'), (good_x3, good_y3, 'good_3'),
      (bad_x1, bad_y1, 'bad_1'), (bad_x2, bad_y2, 'bad_2'), (bad_x3, bad_y3, 'bad_3')]
for ii in xy:
    points = list(zip(ii[0], ii[1]))
    a_points = np.array(points)

    model = EllipseModel()
    if model.estimate(a_points) == False:
        fig = px.line(x= ii[0], y= ii[1], title='model fitting failed for ' + ii[2])
        fig.show()
        try: 
            xc, yc, a, b, theta = model.params
            print(model.params)
            ellipse_centre = (xc, yc)
            residuals = model.residuals(a_points)
            print(residuals)
        except Exception as e: print(e)
    
    else:
        fig = px.line(x= ii[0], y= ii[1], title='model fitting successful for ' + ii[2])
        fig.show()
        xc, yc, a, b, theta = model.params
        print(model.params)
        ellipse_centre = (xc, yc)
        residuals = model.residuals(a_points)
        print(residuals)
```


### Traceback or output:

```Shell
cannot unpack non-iterable NoneType object
```


### Version information:

```Shell
3.9.7 (default, Sep 16 2021, 16:59:28) [MSC v.1916 64 bit (AMD64)]
Windows-10-10.0.19044-SP0
scikit-image version: 0.18.3
numpy version: 1.21.5
```



Discussion:
FTP, I update to scikit-image 0.19.3- the current version on PyPi- and the issue is still there. 
There is another implementation of the algorithm behind EllipseModel() at SciPy, here: https://scipython.com/blog/direct-linear-least-squares-fitting-of-an-ellipse/

I have tested this on the same data and it too fits, misfits or throws errors on my sets of points. Interesting it does this in a slightly different pattern to EllipseModel(). I think this is due to a fundamental flaw in the algorithm and not a bug.

You can download a Jupyter Notebook to reproduce this here: https://gist.github.com/chrispook/56b7ed348301939489bb8ccae84c2736

Resolving patch:
diff --git a/skimage/measure/fit.py b/skimage/measure/fit.py
--- a/skimage/measure/fit.py
+++ b/skimage/measure/fit.py
@@ -282,6 +282,20 @@ def estimate(self, data):
         # to prevent integer overflow, cast data to float, if it isn't already
         float_type = np.promote_types(data.dtype, np.float32)
         data = data.astype(float_type, copy=False)
+        # normalize value range to avoid misfitting due to numeric errors if
+        # the relative distanceses are small compared to absolute distances
+        origin = data.mean(axis=0)
+        data = data - origin
+        scale = data.std()
+        if scale < np.finfo(float_type).tiny:
+            warn(
+                "Standard deviation of data is too small to estimate "
+                "circle with meaningful precision.",
+                category=RuntimeWarning,
+                stacklevel=2,
+            )
+            return False
+        data /= scale
 
         # Adapted from a spherical estimator covered in a blog post by Charles
         # Jeckel (see also reference 1 above):
@@ -300,6 +314,10 @@ def estimate(self, data):
         distances = spatial.minkowski_distance(center, data)
         r = np.sqrt(np.mean(distances ** 2))
 
+        # revert normalization and set params
+        center *= scale
+        r *= scale
+        center += origin
         self.params = tuple(center) + (r,)
 
         return True
@@ -399,7 +417,7 @@ class EllipseModel(BaseModel):
     """
 
     def estimate(self, data):
-        """Estimate circle model from data using total least squares.
+        """Estimate ellipse model from data using total least squares.
 
         Parameters
         ----------
@@ -428,6 +446,21 @@ def estimate(self, data):
         float_type = np.promote_types(data.dtype, np.float32)
         data = data.astype(float_type, copy=False)
 
+        # normalize value range to avoid misfitting due to numeric errors if
+        # the relative distanceses are small compared to absolute distances
+        origin = data.mean(axis=0)
+        data = data - origin
+        scale = data.std()
+        if scale < np.finfo(float_type).tiny:
+            warn(
+                "Standard deviation of data is too small to estimate "
+                "ellipse with meaningful precision.",
+                category=RuntimeWarning,
+                stacklevel=2,
+            )
+            return False
+        data /= scale
+
         x = data[:, 0]
         y = data[:, 1]
 
@@ -492,8 +525,12 @@ def estimate(self, data):
         if a > c:
             phi += 0.5 * np.pi
 
-        self.params = np.nan_to_num([x0, y0, width, height, phi]).tolist()
-        self.params = [float(np.real(x)) for x in self.params]
+        # revert normalization and set params
+        params = np.nan_to_num([x0, y0, width, height, phi]).real
+        params[:4] *= scale
+        params[:2] += origin
+        self.params = tuple(float(p) for p in params)
+
         return True
 
     def residuals(self, data):


Test patch:
diff --git a/skimage/measure/tests/test_fit.py b/skimage/measure/tests/test_fit.py
--- a/skimage/measure/tests/test_fit.py
+++ b/skimage/measure/tests/test_fit.py
@@ -1,4 +1,5 @@
 import numpy as np
+import pytest
 
 from skimage._shared import testing
 from skimage._shared._warnings import expected_warnings
@@ -152,12 +153,33 @@ def test_circle_model_insufficient_data():
     with expected_warnings(warning_message):
         model.estimate(np.array([[1, 2], [3, 4]]))
 
-    with expected_warnings(warning_message):
-        model.estimate(np.ones((6, 2)))
-
     with expected_warnings(warning_message):
         model.estimate(np.array([[0, 0], [1, 1], [2, 2]]))
 
+    warning_message = (
+        "Standard deviation of data is too small to estimate "
+        "circle with meaningful precision."
+    )
+    with pytest.warns(RuntimeWarning, match=warning_message) as _warnings:
+        assert not model.estimate(np.ones((6, 2)))
+    # Check that stacklevel is correct
+    assert len(_warnings) == 1
+    assert _warnings[0].filename == __file__
+
+
+def test_circle_model_estimate_from_small_scale_data():
+    params = np.array([1.23e-90, 2.34e-90, 3.45e-100], dtype=np.float64)
+    angles = np.array([
+        0.107, 0.407, 1.108, 1.489, 2.216, 2.768,
+        3.183, 3.969, 4.840, 5.387, 5.792, 6.139
+    ], dtype=np.float64)
+    data = CircleModel().predict_xy(angles, params=params)
+    model = CircleModel()
+    # assert that far small scale data can be estimated
+    assert model.estimate(data.astype(np.float64))
+    # test whether the predicted parameters are close to the original ones
+    assert_almost_equal(params, model.params)
+
 
 def test_ellipse_model_invalid_input():
     with testing.raises(ValueError):
@@ -234,6 +256,20 @@ def test_ellipse_model_estimate_from_data():
     assert_array_less(np.zeros(4), np.abs(model.params[:4]))
 
 
+def test_ellipse_model_estimate_from_far_shifted_data():
+    params = np.array([1e6, 2e6, 0.5, 0.1, 0.5], dtype=np.float64)
+    angles = np.array([
+        0.107, 0.407, 1.108, 1.489, 2.216, 2.768,
+        3.183, 3.969, 4.840, 5.387, 5.792, 6.139
+    ], dtype=np.float64)
+    data = EllipseModel().predict_xy(angles, params=params)
+    model = EllipseModel()
+    # assert that far shifted data can be estimated
+    assert model.estimate(data.astype(np.float64))
+    # test whether the predicted parameters are close to the original ones
+    assert_almost_equal(params, model.params)
+
+
 @xfail(condition=arch32,
        reason=('Known test failure on 32-bit platforms. See links for '
                'details: '
@@ -242,7 +278,16 @@ def test_ellipse_model_estimate_from_data():
 def test_ellipse_model_estimate_failers():
     # estimate parameters of real data
     model = EllipseModel()
-    assert not model.estimate(np.ones((5, 2)))
+    warning_message = (
+        "Standard deviation of data is too small to estimate "
+        "ellipse with meaningful precision."
+    )
+    with pytest.warns(RuntimeWarning, match=warning_message) as _warnings:
+        assert not model.estimate(np.ones((6, 2)))
+    # Check that stacklevel is correct
+    assert len(_warnings) == 1
+    assert _warnings[0].filename == __file__
+
     assert not model.estimate(np.array([[50, 80], [51, 81], [52, 80]]))