Repository: scikit-image/scikit-image

Issue:
axis length regionprops is incorrect for 3D data
## Description

The formula in returning the major and minor axis lengths seems to be specific to 2D ellipses, but there is no restriction on the user calling this function on 3D data.

https://forum.image.sc/t/scikit-image-regionprops-minor-axis-length-in-3d-gives-first-minor-radius-regardless-of-whether-it-is-actually-the-shortest/59273/2

In my response in the forum post, I gave an algorithm based on the description here for an ImageJ tool:
https://imagejdocu.list.lu/tutorial/plugins/3d_ellipsoid


## Way to reproduce
```python
import numpy as np

from skimage.measure import regionprops
from skimage import draw

e = draw.ellipsoid(20, 10, 50).astype(int)
stats = regionprops(e)

print(stats[0]['minor_axis_length'])
print(stats[0]['major_axis_length'])
```
39.96593417262304
96.4629444283893

The major axis length is close to the expected value of 100, but the minor axis length of 40 reported here is much larger than the expected value of 20.

With the function I gave in the forum, the result is:
```Python
ellipsoid_axis_lengths(stats[0]['moments_central'])
```
 (100.15219508248649, 40.01140312339376, 19.891769352179885)


## Version information
skimage 0.19.dev0 development branch



Discussion:

Resolving patch:
diff --git a/skimage/measure/_regionprops.py b/skimage/measure/_regionprops.py
--- a/skimage/measure/_regionprops.py
+++ b/skimage/measure/_regionprops.py
@@ -1,6 +1,8 @@
 import inspect
 from functools import wraps
-from math import atan2, pi as PI, sqrt
+from math import atan2
+from math import pi as PI
+from math import sqrt
 from warnings import warn
 
 import numpy as np
@@ -12,7 +14,6 @@
 from ._marching_cubes_lewiner import marching_cubes
 from ._regionprops_utils import euler_number, perimeter, perimeter_crofton
 
-
 __all__ = ['regionprops', 'euler_number', 'perimeter', 'perimeter_crofton']
 
 
@@ -227,6 +228,49 @@ def func2d(self, *args, **kwargs):
     return func2d
 
 
+def _inertia_eigvals_to_axes_lengths_3D(inertia_tensor_eigvals):
+    """Compute ellipsoid axis lengths from inertia tensor eigenvalues.
+
+    Paramters
+    ---------
+    inertia_tensor_eigvals : seqeunce of float
+        A sequence of 3 floating point eigenvalues, sorted in descending order.
+
+    Returns
+    -------
+    axis_lengths : list of float
+        The ellipsoid axis lengths sorted in descending order.
+
+    Notes
+    -----
+    Let a >= b >= c be the ellipsoid semi-axes and s1 >= s2 >= s3 be the
+    inertia tensor eigenvalues.
+
+    The inertia tensor eigenvalues are given for a solid ellipsoid in [1]_.
+    s1 = 1 / 5 * (a**2 + b**2)
+    s2 = 1 / 5 * (a**2 + c**2)
+    s3 = 1 / 5 * (b**2 + c**2)
+
+    Rearranging to solve for a, b, c in terms of s1, s2, s3 gives
+    a = math.sqrt(5 / 2 * ( s1 + s2 - s3))
+    b = math.sqrt(5 / 2 * ( s1 - s2 + s3))
+    c = math.sqrt(5 / 2 * (-s1 + s2 + s3))
+
+    We can then simply replace sqrt(5/2) by sqrt(10) to get the full axes
+    lengths rather than the semi-axes lengths.
+
+    References
+    ----------
+    ..[1] https://en.wikipedia.org/wiki/List_of_moments_of_inertia#List_of_3D_inertia_tensors  # noqa
+    """
+    axis_lengths = []
+    for ax in range(2, -1, -1):
+        w = sum(v * -1 if i == ax else v
+                for i, v in enumerate(inertia_tensor_eigvals))
+        axis_lengths.append(sqrt(10 * w))
+    return axis_lengths
+
+
 class RegionProperties:
     """Please refer to `skimage.measure.regionprops` for more information
     on the available region properties.
@@ -267,7 +311,8 @@ def __init__(self, slice, label, label_image, intensity_image,
                 if hasattr(self, name):
                     msg = (
                         f"Extra property '{name}' is shadowed by existing "
-                        f"property and will be inaccessible. Consider renaming it."
+                        f"property and will be inaccessible. Consider "
+                        f"renaming it."
                     )
                     warn(msg)
             self._extra_properties = {
@@ -297,8 +342,8 @@ def __getattr__(self, attr):
                 return func(self.image)
             else:
                 raise AttributeError(
-                    f'Custom regionprop function\'s number of arguments must be 1 or 2, '
-                    f'but {attr} takes {n_args} arguments.'
+                    f'Custom regionprop function\'s number of arguments must '
+                    f'be 1 or 2, but {attr} takes {n_args} arguments.'
                 )
         else:
             raise AttributeError(
@@ -377,7 +422,8 @@ def feret_diameter_max(self):
             coordinates = np.vstack(find_contours(identity_convex_hull, .5,
                                                   fully_connected='high'))
         elif self._ndim == 3:
-            coordinates, _, _, _ = marching_cubes(identity_convex_hull, level=.5)
+            coordinates, _, _, _ = marching_cubes(identity_convex_hull,
+                                                  level=.5)
         distances = pdist(coordinates, 'sqeuclidean')
         return sqrt(np.max(distances))
 
@@ -450,13 +496,27 @@ def intensity_min(self):
 
     @property
     def axis_major_length(self):
-        l1 = self.inertia_tensor_eigvals[0]
-        return 4 * sqrt(l1)
+        if self._ndim == 2:
+            l1 = self.inertia_tensor_eigvals[0]
+            return 4 * sqrt(l1)
+        elif self._ndim == 3:
+            # equivalent to _inertia_eigvals_to_axes_lengths_3D(ev)[0]
+            ev = self.inertia_tensor_eigvals
+            return sqrt(10 * (ev[0] + ev[1] - ev[2]))
+        else:
+            raise ValueError("axis_major_length only available in 2D and 3D")
 
     @property
     def axis_minor_length(self):
-        l2 = self.inertia_tensor_eigvals[-1]
-        return 4 * sqrt(l2)
+        if self._ndim == 2:
+            l2 = self.inertia_tensor_eigvals[-1]
+            return 4 * sqrt(l2)
+        elif self._ndim == 3:
+            # equivalent to _inertia_eigvals_to_axes_lengths_3D(ev)[-1]
+            ev = self.inertia_tensor_eigvals
+            return sqrt(10 * (-ev[0] + ev[1] + ev[2]))
+        else:
+            raise ValueError("axis_minor_length only available in 2D and 3D")
 
     @property
     @_cached


Test patch:
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
@@ -2,19 +2,20 @@
 
 import numpy as np
 import pytest
-from numpy.testing import (assert_almost_equal, assert_array_equal,
-                           assert_array_almost_equal, assert_equal)
+import scipy.ndimage as ndi
+from numpy.testing import (assert_almost_equal, assert_array_almost_equal,
+                           assert_array_equal, assert_equal)
 
-from skimage import data
+from skimage import data, draw, transform
 from skimage._shared._warnings import expected_warnings
-from skimage.measure._regionprops import (regionprops, PROPS, perimeter,
-                                          perimeter_crofton, euler_number,
+from skimage.measure._regionprops import (COL_DTYPES, OBJECT_COLUMNS, PROPS,
+                                          _inertia_eigvals_to_axes_lengths_3D,
                                           _parse_docs, _props_to_dict,
-                                          regionprops_table, OBJECT_COLUMNS,
-                                          COL_DTYPES)
+                                          euler_number, perimeter,
+                                          perimeter_crofton, regionprops,
+                                          regionprops_table)
 from skimage.segmentation import slic
 
-
 SAMPLE = np.array(
     [[0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 0, 0, 0, 0],
      [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0],
@@ -752,3 +753,37 @@ def test_multichannel():
             # property uses multiple channels, returns props stacked along
             # final axis
             assert_array_equal(p, np.asarray(p_multi)[..., 1])
+
+
+def test_3d_ellipsoid_axis_lengths():
+    """Verify that estimated axis lengths are correct.
+
+    Uses an ellipsoid at an arbitrary position and orientation.
+    """
+    # generate a centered ellipsoid with non-uniform half-lengths (radii)
+    half_lengths = (20, 10, 50)
+    e = draw.ellipsoid(*half_lengths).astype(int)
+
+    # Pad by asymmetric amounts so the ellipse isn't centered. Also, pad enough
+    # that the rotated ellipse will still be within the original volume.
+    e = np.pad(e, pad_width=[(30, 18), (30, 12), (40, 20)], mode='constant')
+
+    # apply rotations to the ellipsoid
+    R = transform.EuclideanTransform(rotation=[0.2, 0.3, 0.4],
+                                     dimensionality=3)
+    e = ndi.affine_transform(e, R.params)
+
+    # Compute regionprops
+    rp = regionprops(e)[0]
+
+    # estimate principal axis lengths via the inertia tensor eigenvalues
+    evs = rp.inertia_tensor_eigvals
+    axis_lengths = _inertia_eigvals_to_axes_lengths_3D(evs)
+    expected_lengths = sorted([2 * h for h in half_lengths], reverse=True)
+    for ax_len_expected, ax_len in zip(expected_lengths, axis_lengths):
+        # verify accuracy to within 1%
+        assert abs(ax_len - ax_len_expected) < 0.01 * ax_len_expected
+
+    # verify that the axis length regionprops also agree
+    assert abs(rp.axis_major_length - axis_lengths[0]) < 1e-7
+    assert abs(rp.axis_minor_length - axis_lengths[-1]) < 1e-7