Repository: scikit-image/scikit-image

Issue:
EuclideanTransform and SimilarityTransform does not give correct properties for dimensionality=3
## Description
EuclideanTransform and SimilarityTransform does not give correct properties for dimensionality=3.

This is because the rotation, translaton and scale properties only take care of dimensionality=2
Rotation and translation
https://github.com/scikit-image/scikit-image/blob/30a426cc7f63be50e60faf682c0ac48231a328f0/skimage/transform/_geometric.py#L1250-L1256

Scale
https://github.com/scikit-image/scikit-image/blob/30a426cc7f63be50e60faf682c0ac48231a328f0/skimage/transform/_geometric.py#L1367-L1370

## Way to reproduce
```python
# Place the full code we need to recreate your issue here
from skimage.transform import warp, SimilarityTransform
import math

src_points = np.array([[1,0,0], [2,0,0], [3,0,0]])
dst_points = np.array([[4,1,1], [7,1,1], [10,1,1]]) # scale = 3, rotation = 0, tx = 1, ty = 1, tz = 1

model = SimilarityTransform(dimensionality=3)
model.estimate(src_points, dst_points)

print(model.scale, model.rotation, model.translation)
# 5.196152422706632 0.0 [0. 0.]

print(model.params)
# [[3. 0. 0. 1.]
#  [0. 3. 0. 1.]
#  [0. 0. 3. 1.]
#  [0. 0. 0. 1.]]

# upload all necessary images to github too!
```

Model output should be 
scale = 3
rotation = [0.0, 0.0, 0.0]
transation = [1, 1, 1]

## Version information
```python
# Paste the output of the following python commands
from __future__ import print_function
import sys; print(sys.version)
import platform; print(platform.platform())
import skimage; print(f'scikit-image version: {skimage.__version__}')
import numpy; print(f'numpy version: {numpy.__version__}')
```

```python
# your output here
3.8.3 (default, Jul  2 2020, 16:21:59) 
[GCC 7.3.0]
Linux-5.4.0-107-generic-x86_64-with-glibc2.10
scikit-image version: 0.19.0
numpy version: 1.22.2
```



Discussion:
Fix would be to use the dimensionality param as a check in the properties like in the __init__ function for the two models.
I can raise PR with the fix
@sandeepnmenon oops! Yeah when we upgraded the models to work in >2D, I only worried about not breaking the 2D case — the intention was that this API was not to be used in >2D!

I think translation and scale are easy to implement, but decomposing rotation from the matrix is a bit trickier, right? Anyway, a PR would indeed be most welcome! :blush: Thank you!

Decomposing rotation would fail when the points are colinear. When I tested the code with random points the `transformation matrix[:3,:3]` gives the rotation and we can get the euler angles from it.
Am I missing something?


Resolving patch:
diff --git a/skimage/transform/_geometric.py b/skimage/transform/_geometric.py
--- a/skimage/transform/_geometric.py
+++ b/skimage/transform/_geometric.py
@@ -1249,17 +1249,25 @@ def estimate(self, src, dst):
 
     @property
     def rotation(self):
-        return math.atan2(self.params[1, 0], self.params[1, 1])
+        if self.dimensionality == 2:
+            return math.atan2(self.params[1, 0], self.params[1, 1])
+        elif self.dimensionality == 3:
+            # Returning 3D Euler rotation matrix
+            return self.params[:3, :3]
+        else:
+            raise NotImplementedError(
+                'Rotation only implemented for 2D and 3D transforms.'
+            )
 
     @property
     def translation(self):
-        return self.params[0:2, 2]
+        return self.params[0:self.dimensionality, self.dimensionality]
 
 
 class SimilarityTransform(EuclideanTransform):
-    """2D similarity transformation.
+    """Similarity transformation.
 
-    Has the following form::
+    Has the following form in 2D::
 
         X = a0 * x - b0 * y + a1 =
           = s * x * cos(rotation) - s * y * sin(rotation) + a1
@@ -1347,9 +1355,9 @@ def estimate(self, src, dst):
 
         Parameters
         ----------
-        src : (N, 2) array
+        src : (N, ndim) array
             Source coordinates.
-        dst : (N, 2) array
+        dst : (N, ndim) array
             Destination coordinates.
 
         Returns
@@ -1366,8 +1374,14 @@ def estimate(self, src, dst):
 
     @property
     def scale(self):
-        # det = scale**(# of dimensions), therefore scale = det**(1/2)
-        return np.sqrt(np.linalg.det(self.params))
+        # det = scale**(# of dimensions), therefore scale = det**(1/ndim)
+        if self.dimensionality == 2:
+            return np.sqrt(np.linalg.det(self.params))
+        elif self.dimensionality == 3:
+            return np.cbrt(np.linalg.det(self.params))
+        else:
+            raise NotImplementedError(
+                'Scale is only implemented for 2D and 3D.')
 
 
 class PolynomialTransform(GeometricTransform):


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
@@ -71,6 +71,31 @@ def test_euclidean_estimation():
     assert_almost_equal(tform3.params, tform2.params)
 
 
+def test_3d_euclidean_estimation():
+    src_points = np.random.rand(1000, 3)
+
+    # Random transformation for testing
+    angles = np.random.random((3,)) * 2 * np.pi - np.pi
+    rotation_matrix = _euler_rotation_matrix(angles)
+    translation_vector = np.random.random((3,))
+    dst_points = []
+    for pt in src_points:
+        pt_r = pt.reshape(3, 1)
+        dst = np.matmul(rotation_matrix, pt_r) + \
+            translation_vector.reshape(3, 1)
+        dst = dst.reshape(3)
+        dst_points.append(dst)
+
+    dst_points = np.array(dst_points)
+    # estimating the transformation
+    tform = EuclideanTransform(dimensionality=3)
+    assert tform.estimate(src_points, dst_points)
+    estimated_rotation = tform.rotation
+    estimated_translation = tform.translation
+    assert_almost_equal(estimated_rotation, rotation_matrix)
+    assert_almost_equal(estimated_translation, translation_vector)
+
+
 def test_euclidean_init():
     # init with implicit parameters
     rotation = 1
@@ -118,6 +143,34 @@ def test_similarity_estimation():
     assert_almost_equal(tform3.params, tform2.params)
 
 
+def test_3d_similarity_estimation():
+    src_points = np.random.rand(1000, 3)
+
+    # Random transformation for testing
+    angles = np.random.random((3,)) * 2 * np.pi - np.pi
+    scale = np.random.randint(0, 20)
+    rotation_matrix = _euler_rotation_matrix(angles) * scale
+    translation_vector = np.random.random((3,))
+    dst_points = []
+    for pt in src_points:
+        pt_r = pt.reshape(3, 1)
+        dst = np.matmul(rotation_matrix, pt_r) + \
+            translation_vector.reshape(3, 1)
+        dst = dst.reshape(3)
+        dst_points.append(dst)
+
+    dst_points = np.array(dst_points)
+    # estimating the transformation
+    tform = SimilarityTransform(dimensionality=3)
+    assert tform.estimate(src_points, dst_points)
+    estimated_rotation = tform.rotation
+    estimated_translation = tform.translation
+    estimated_scale = tform.scale
+    assert_almost_equal(estimated_translation, translation_vector)
+    assert_almost_equal(estimated_scale, scale)
+    assert_almost_equal(estimated_rotation, rotation_matrix)
+
+
 def test_similarity_init():
     # init with implicit parameters
     scale = 0.1