Repository: scikit-image/scikit-image

Issue:
Incorrect inertia_tensor implementation
## Description
Implementation of inertia_tensor in skimage/measure/_moments.py is not correct

Diagonal components of inertia_tensor are currently set to second order moments  (e.g. m[2,0,0] for Ixx in a 3D system) but they should be set to the sum of all 2nd order moments but the one for the corresponding axe (i.e. sum of square of the distance from corresponding axe).

```
import numpy as np
import skimage.measure as skm

obj=np.zeros((3,3,3), dtype=int)
obj[0,0,1]=obj[2,2,1]=1

props=skm.regionprops(obj)
m=props[0].moments_central

# Second order moments
m2=np.array([
   [m[2,0,0],m[1,1,0],m[1,0,1]],
   [m[1,1,0],m[0,2,0],m[0,1,1]],
   [m[1,0,1],m[0,1,1],m[0,0,2]]
])

# Compute related inertia tensor
Itensor= -m2
(Itensor[0,0], Itensor[1,1], Itensor[2,2])=(m2[1,1]+m2[2,2], m2[0,0]+m2[2,2], m2[0,0]+ m2[1,1])
```
props[0].inertia_tensor gives:
```
array([[ 1., -1., -0.],
       [-1.,  1., -0.],
       [-0., -0.,  0.]])
```
while it should be Itensor/m[0,0,0]:
```
array([[ 1., -1., -0.],
       [-1.,  1., -0.],
       [-0., -0.,  2.]])
```
Given m2:
```
array([[ 2.,  2.,  0.],
       [ 2.,  2.,  0.],
       [ 0.,  0.,  0.]])
```


Discussion:
@jpcornil-git oops, I must have made a mistake when converting to nD in #2603! (Are you using skimage 0.14?) Do you have a good reference for me to check the definition in 3D? I wasn't able to find one at the time...
@jni Yes, I'm using 0.14.

You should probably only replace:
`d[:] = mu[corners2] / mu0`
with:
`d[:] = (mu[corners2].sum() - mu[corners2])/mu0`

This reference may be helpful: https://ocw.mit.edu/courses/aeronautics-and-astronautics/16-07-dynamics-fall-2009/lecture-notes/MIT16_07F09_Lec26.pdf

Cheers
jpc
Haha I see what happened now. I was looking at the previous code, which was 2D only *and* used `xy` coordinates. So `I_00` was equal to `mu[2, 0]`... This just happened to be exactly what you suggest — in `xy` coordinates — but also happened to *look* exactly like my mistaken interpretation/generalisation, in `rc` coordinates.

Working on fixing this now. Thanks so much for the report and for your patience!
Thanks for the update jni (not sure I understand why you are differentiating `xy` and `rc` coordinates here as these shouldn't affect diagonal components given they are all **2)

Cheers
jpc
@jpcornil-git Because in the 2D case "the sum of the squared moments for all axes except the current one" is exactly the same as "the squared moment for the other axis". Now `xy` is `rc` inverted, so this became "the squared moment for the current axis".
Ah, I see your point, rc is not cr ... just like endianess, the one who invented the second system should be fired :-) [integrated drawbacks offsetting benefits by far] !
Cheers,
jpc
@jpcornil-git another doubt... When determining orientation (2D) from the inertia tensor, is the eigenvector of the smallest eigenvalue the correct place to look? 
Hi jni,

Not sure I understood correctly your question but eigenvectors will give you the main object axes and eigenvalues inertia around each axe => eigenvector corresponding to the smallest eigenvalue will indicate the axe along the 'longest side' of the object.

Cheers
jpc
@jpcornil-git got it, thanks! Your explanation clarified quite a few things for me.

Yes, I'm trying to determine the orientation of the major (longest) axis of the object. To get deeper at my question, I'm trying to understand this implementation:

https://github.com/scikit-image/scikit-image/blob/dc8b56f8df6b9662f072c4cb5548939f12070c7c/skimage/measure/_regionprops.py#L273-L282

because things are getting transposed in weird ways when I fix the inertia tensor, and I want to make sure I don't break another thing while fixing this. Specifically, I don't understand the factor of -0.5. I presume it has something to do with the fact that the values in the matrix are sums of squares. I could just use np.arctan2 on the values of the eigenvector, but I presume that would be much slower, so if you can explain the formula above for me, I would be most grateful!
Hi jni,

The -0.5 is just the consequence of axis rotation, e.g. if you rotate axis by α the 2D Inertia matrix becomes:
Ix'x' = (Ixx +Iyy)/2 + (Ixx −Iyy)/2 * cos2α − Ixy * sin2α
Iy'y' = (Ixx +Iyy)/2 + (Ixx −Iyy)/2 * cos2α + Ixy * sin2α
Ix'y' = (Ixx −Iyy)/2 * sin2α +Ixy * cos2α

=> Principal axis is when Ix'y'/last line above becomes 0 or
tan2α=-2 * Ixy/(Ixx −Iyy)

This is the same as the orientation of one of the eigenvector

```
>>> import numpy as np
>>> import numpy.linalg as npla
>>> I=np.array([[1,2],[2,3]])
>>> IEval, IEvec=npla.eig(I)
>>> IEval
array([-0.23606798,  4.23606798])
>>> IEvec
array([[-0.85065081, -0.52573111],
       [ 0.52573111, -0.85065081]])
>>> math.atan2(IEvec[1][1],IEvec[1][0])
-1.0172219678978514
>>> a,b,c=I[0,0],I[0,1],I[1,1]
>>> -0.5 * math.atan2(-2 * b, (a - c))
1.0172219678978514
```

Now, the atan formula has two solutions for α and the `orientation ` function only picks one of the two, i.e. not the one related to the major axis. You can improve this with the eigen values/vectors.

The negative sign in front of the atan2 is strange .... likely another consequence of xy/rc/... permutation/direction :-)

Cheers
jpc
... minus sign may also just be a consequence/fix of the Ixx<->Iyy (a<->c inversion)
Cheers
jpc
@jpcornil-git Neat! Thanks so much, that was crystal clear!

Did you pick the right eigenvector though? You want the eigenvector corresponding to the smallest eigenvalue, which is column 0 of `I`...? Therefore:

```python
In [1]: import numpy as np
In [2]: I = np.array([[1, 2], [2, 3]])
In [3]: Λ, V = np.linalg.eig(I)
In [4]: Λ
Out[4]: array([-0.23606798,  4.23606798])

In [5]: V
Out[5]: 
array([[-0.85065081, -0.52573111],
       [ 0.52573111, -0.85065081]])

In [7]: np.arctan2(I[1, 0], I[0, 0])
Out[7]: 1.1071487177940904

In [8]: a, b, b, c = I.flat
In [9]: -0.5 * np.arctan2(-2 * b, a - c)
Out[9]: 1.0172219678978514
```

Anyway, armed with this new understanding I think I can finally untangle this. Hold, please!
*sigh* it's worse than I thought, but it finally makes sense. If you use "xy", which is not actually the Cartesian xy with the origin at the bottom left, but rather a hybrid abomination where "x" is "columns right from the top left corner" and "y" is "rows down from the top left corner", then the "natural" direction for arctan is clockwise. But the documentation specifies counter-clockwise orientation to match common understanding of rotation, *even though that makes no sense in this coordinate system.* *That's* why there's a minus in front.

Of course the consequence of this is that you need a minus for the old behaviour but not a minus for the new one. :man_facepalming: 
@jni 

I just picked the eigenvector matching the arctan2 formula (note that line 7 in your ipython code should use V not I, i.e. it should be `np.arctan2(V[1, 1], V[1, 0])`)

Cheers
jpc
@jpcornil-git Ah, yes, but it should be `V[0, 0], V[1, 0]`, no? The 0th column to correspond to the 0th eigenvalue?

Resolving patch:
diff --git a/skimage/measure/_moments.py b/skimage/measure/_moments.py
--- a/skimage/measure/_moments.py
+++ b/skimage/measure/_moments.py
@@ -410,7 +410,7 @@ def inertia_tensor(image, mu=None):
            Scientific Applications. (Chapter 8: Tensor Methods) Springer, 1993.
     """
     if mu is None:
-        mu = moments_central(image)
+        mu = moments_central(image, order=2)  # don't need higher-order moments
     mu0 = mu[(0,) * image.ndim]
     result = np.zeros((image.ndim, image.ndim))
 
@@ -419,7 +419,12 @@ def inertia_tensor(image, mu=None):
     corners2 = tuple(2 * np.eye(image.ndim, dtype=int))
     d = np.diag(result)
     d.flags.writeable = True
-    d[:] = mu[corners2] / mu0
+    # See https://ocw.mit.edu/courses/aeronautics-and-astronautics/
+    #             16-07-dynamics-fall-2009/lecture-notes/MIT16_07F09_Lec26.pdf
+    # Iii is the sum of second-order moments of every axis *except* i, not the
+    # second order moment of axis i.
+    # See also https://github.com/scikit-image/scikit-image/issues/3229
+    d[:] = (np.sum(mu[corners2]) - mu[corners2]) / mu0
 
     for dims in itertools.combinations(range(image.ndim), 2):
         mu_index = np.zeros(image.ndim, dtype=int)
diff --git a/skimage/measure/_regionprops.py b/skimage/measure/_regionprops.py
--- a/skimage/measure/_regionprops.py
+++ b/skimage/measure/_regionprops.py
@@ -273,13 +273,14 @@ def moments_normalized(self):
     @only2d
     def orientation(self):
         a, b, b, c = self.inertia_tensor.flat
+        sign = -1 if self._transpose_moments else 1
         if a - c == 0:
             if b < 0:
                 return -PI / 4.
             else:
                 return PI / 4.
         else:
-            return -0.5 * atan2(-2 * b, (a - c))
+            return sign * 0.5 * atan2(-2 * b, c - a)
 
     @only2d
     def perimeter(self):
@@ -475,9 +476,12 @@ def regionprops(label_image, intensity_image=None, cache=True,
 
         where `m_00` is the zeroth spatial moment.
     **orientation** : float
-        Angle between the X-axis and the major axis of the ellipse that has
-        the same second-moments as the region. Ranging from `-pi/2` to
-        `pi/2` in counter-clockwise direction.
+        In 'rc' coordinates, angle between the 0th axis (rows) and the major
+        axis of the ellipse that has the same second moments as the region,
+        ranging from `-pi/2` to `pi/2` counter-clockwise.
+
+        In `xy` coordinates, as above but the angle is now measured from the
+        "x" or horizontal axis.
     **perimeter** : float
         Perimeter of object which approximates the contour as a line
         through the centers of border pixels using a 4-connectivity.


Test patch:
diff --git a/skimage/measure/tests/test_moments.py b/skimage/measure/tests/test_moments.py
--- a/skimage/measure/tests/test_moments.py
+++ b/skimage/measure/tests/test_moments.py
@@ -1,8 +1,10 @@
 import numpy as np
+from scipy import ndimage as ndi
 from skimage import draw
 from skimage.measure import (moments, moments_central, moments_coords,
                              moments_coords_central, moments_normalized,
-                             moments_hu, centroid)
+                             moments_hu, centroid, inertia_tensor,
+                             inertia_tensor_eigvals)
 
 from skimage._shared import testing
 from skimage._shared.testing import (assert_equal, assert_almost_equal,
@@ -150,3 +152,37 @@ def test_centroid():
     image[15, 14:16] = 1/3
     image_centroid = centroid(image)
     assert_allclose(image_centroid, (14.25, 14.5))
+
+
+def test_inertia_tensor_2d():
+    image = np.zeros((40, 40))
+    image[15:25, 5:35] = 1  # big horizontal rectangle (aligned with axis 1)
+    T = inertia_tensor(image)
+    assert T[0, 0] > T[1, 1]
+    np.testing.assert_allclose(T[0, 1], 0)
+    v0, v1 = inertia_tensor_eigvals(image, T=T)
+    np.testing.assert_allclose(np.sqrt(v0/v1), 3, rtol=0.01, atol=0.05)
+
+
+def test_inertia_tensor_3d():
+    image = draw.ellipsoid(10, 5, 3)
+    T0 = inertia_tensor(image)
+    eig0, V0 = np.linalg.eig(T0)
+    # principal axis of ellipse = eigenvector of smallest eigenvalue
+    v0 = V0[:, np.argmin(eig0)]
+
+    assert np.allclose(v0, [1, 0, 0]) or np.allclose(-v0, [1, 0, 0])
+
+    imrot = ndi.rotate(image.astype(float), 30, axes=(0, 1), order=1)
+    Tr = inertia_tensor(imrot)
+    eigr, Vr = np.linalg.eig(Tr)
+    vr = Vr[:, np.argmin(eigr)]
+
+    # Check that axis has rotated by expected amount
+    pi, cos, sin = np.pi, np.cos, np.sin
+    R = np.array([[ cos(pi/6), -sin(pi/6), 0],
+                  [ sin(pi/6),  cos(pi/6), 0],
+                  [         0,          0, 1]])
+    expected_vr = R @ v0
+    assert (np.allclose(vr, expected_vr, atol=1e-3, rtol=0.01) or
+            np.allclose(-vr, expected_vr, atol=1e-3, rtol=0.01))
diff --git a/skimage/measure/tests/test_regionprops.py b/skimage/measure/tests/test_regionprops.py
--- a/skimage/measure/tests/test_regionprops.py
+++ b/skimage/measure/tests/test_regionprops.py
@@ -305,15 +305,15 @@ def test_moments_normalized():
 
 
 def test_orientation():
-    orientation = regionprops(SAMPLE.T)[0].orientation
+    orientation = regionprops(SAMPLE, coordinates='xy')[0].orientation
     # determined with MATLAB
     assert_almost_equal(orientation, 0.10446844651921)
     # test correct quadrant determination
-    orientation2 = regionprops(SAMPLE)[0].orientation
-    assert_almost_equal(orientation2, math.pi / 2 - orientation)
+    orientation2 = regionprops(SAMPLE, coordinates='rc')[0].orientation
+    assert_almost_equal(orientation2, -math.pi / 2 + orientation)
     # test diagonal regions
     diag = np.eye(10, dtype=int)
-    orientation_diag = regionprops(diag)[0].orientation
+    orientation_diag = regionprops(diag, coordinates='xy')[0].orientation
     assert_almost_equal(orientation_diag, -math.pi / 4)
     orientation_diag = regionprops(np.flipud(diag))[0].orientation
     assert_almost_equal(orientation_diag, math.pi / 4)