Repository: scikit-image/scikit-image

Issue:
Consistent ProjectiveTransform params value when estimate fails
## Description

The two `ProjectiveTransform.estimate` failure modes will leave the object in different states.  See below, where the first failure path sets `self.params` to `np.nan` while the second leaves them untouched.

https://github.com/scikit-image/scikit-image/blob/1544b32de29440f787d3d038ca912ad85e54d616/skimage/transform/_geometric.py#L704-L706

https://github.com/scikit-image/scikit-image/blob/1544b32de29440f787d3d038ca912ad85e54d616/skimage/transform/_geometric.py#L735-L736

Should these both do the same thing?

(Also, I believe the `self.params` should have shape `(d+1,d+1)`, but perhaps this is irrelevant in failure cases)


Discussion:

Resolving patch:
diff --git a/skimage/transform/_geometric.py b/skimage/transform/_geometric.py
--- a/skimage/transform/_geometric.py
+++ b/skimage/transform/_geometric.py
@@ -702,7 +702,7 @@ def estimate(self, src, dst, weights=None):
         src_matrix, src = _center_and_normalize_points(src)
         dst_matrix, dst = _center_and_normalize_points(dst)
         if not np.all(np.isfinite(src_matrix + dst_matrix)):
-            self.params = np.full((d, d), np.nan)
+            self.params = np.full((d + 1, d + 1), np.nan)
             return False
 
         # params: a0, a1, a2, b0, b1, b2, c0, c1
@@ -733,6 +733,7 @@ def estimate(self, src, dst, weights=None):
         # because it is a rank-defective transform, which would map points
         # to a line rather than a plane.
         if np.isclose(V[-1, -1], 0):
+            self.params = np.full((d + 1, d + 1), np.nan)
             return False
 
         H = np.zeros((d+1, d+1))
@@ -1356,7 +1357,8 @@ def estimate(self, src, dst):
 
         self.params = _umeyama(src, dst, estimate_scale=True)
 
-        return True
+        # _umeyama will return nan if the problem is not well-conditioned.
+        return not np.any(np.isnan(self.params))
 
     @property
     def scale(self):


Test patch:
diff --git a/skimage/transform/tests/test_geometric.py b/skimage/transform/tests/test_geometric.py
--- a/skimage/transform/tests/test_geometric.py
+++ b/skimage/transform/tests/test_geometric.py
@@ -67,7 +67,7 @@ def test_euclidean_estimation():
 
     # via estimate method
     tform3 = EuclideanTransform()
-    tform3.estimate(SRC, DST)
+    assert tform3.estimate(SRC, DST)
     assert_almost_equal(tform3.params, tform2.params)
 
 
@@ -114,7 +114,7 @@ def test_similarity_estimation():
 
     # via estimate method
     tform3 = SimilarityTransform()
-    tform3.estimate(SRC, DST)
+    assert tform3.estimate(SRC, DST)
     assert_almost_equal(tform3.params, tform2.params)
 
 
@@ -180,7 +180,7 @@ def test_affine_estimation():
 
     # via estimate method
     tform3 = AffineTransform()
-    tform3.estimate(SRC, DST)
+    assert tform3.estimate(SRC, DST)
     assert_almost_equal(tform3.params, tform2.params)
 
 
@@ -210,7 +210,7 @@ def test_affine_init():
 
 def test_piecewise_affine():
     tform = PiecewiseAffineTransform()
-    tform.estimate(SRC, DST)
+    assert tform.estimate(SRC, DST)
     # make sure each single affine transform is exactly estimated
     assert_almost_equal(tform(SRC), DST)
     assert_almost_equal(tform.inverse(DST), SRC)
@@ -324,7 +324,7 @@ def test_projective_estimation():
 
     # via estimate method
     tform3 = ProjectiveTransform()
-    tform3.estimate(SRC, DST)
+    assert tform3.estimate(SRC, DST)
     assert_almost_equal(tform3.params, tform2.params)
 
 
@@ -368,7 +368,7 @@ def test_polynomial_estimation():
 
     # via estimate method
     tform2 = PolynomialTransform()
-    tform2.estimate(SRC, DST, order=10)
+    assert tform2.estimate(SRC, DST, order=10)
     assert_almost_equal(tform2.params, tform.params)
 
 
@@ -519,15 +519,15 @@ def test_degenerate():
     src = dst = np.zeros((10, 2))
 
     tform = SimilarityTransform()
-    tform.estimate(src, dst)
+    assert not tform.estimate(src, dst)
     assert np.all(np.isnan(tform.params))
 
     tform = AffineTransform()
-    tform.estimate(src, dst)
+    assert not tform.estimate(src, dst)
     assert np.all(np.isnan(tform.params))
 
     tform = ProjectiveTransform()
-    tform.estimate(src, dst)
+    assert not tform.estimate(src, dst)
     assert np.all(np.isnan(tform.params))
 
     # See gh-3926 for discussion details
@@ -543,6 +543,13 @@ def test_degenerate():
         # a transform could be returned with nan values.
         assert(not tform.estimate(src, dst) or np.isfinite(tform.params).all())
 
+    src = np.array([[0, 2, 0], [0, 2, 0], [0, 4, 0]])
+    dst = np.array([[0, 1, 0], [0, 1, 0], [0, 3, 0]])
+    tform = AffineTransform()
+    assert not tform.estimate(src, dst)
+    # Prior to gh-6207, the above would set the parameters as the identity.
+    assert np.all(np.isnan(tform.params))
+
 
 def test_normalize_degenerate_points():
     """Return nan matrix *of appropriate size* when point is repeated."""
@@ -612,7 +619,7 @@ def test_estimate_affine_3d():
     dst = tf(src)
     dst_noisy = dst + np.random.random((25, ndim))
     tf2 = AffineTransform(dimensionality=ndim)
-    tf2.estimate(src, dst_noisy)
+    assert tf2.estimate(src, dst_noisy)
     # we check rot/scale/etc more tightly than translation because translation
     # estimation is on the 1 pixel scale
     assert_almost_equal(tf2.params[:, :-1], matrix[:, :-1], decimal=2)