Repository: scikit-image/scikit-image

Issue:
regionprops: document each property for introspection purposes



Discussion:
+1! Maybe we can automatically populate the doc string of the regionprops function from the members of the private _RegionProps class.

That would be great!

@stefanv I don't understand the suggestion. Do you mean that some properties are not mentioned in the doctring of `measure.regionprops`?

I think the idea is to document this once, in one place, and have other instances pulled from there instead of needing to do multiple changes in multiple places (which inevitably results in inconsistencies)

thanks, makes sense


Resolving patch:
diff --git a/skimage/measure/_regionprops.py b/skimage/measure/_regionprops.py
--- a/skimage/measure/_regionprops.py
+++ b/skimage/measure/_regionprops.py
@@ -7,6 +7,9 @@
 from . import _moments
 
 
+from functools import wraps
+from collections import defaultdict
+
 __all__ = ['regionprops', 'perimeter']
 
 
@@ -20,14 +23,14 @@
     'CentralMoments': 'moments_central',
     'Centroid': 'centroid',
     'ConvexArea': 'convex_area',
-#    'ConvexHull',
+    # 'ConvexHull',
     'ConvexImage': 'convex_image',
     'Coordinates': 'coords',
     'Eccentricity': 'eccentricity',
     'EquivDiameter': 'equivalent_diameter',
     'EulerNumber': 'euler_number',
     'Extent': 'extent',
-#    'Extrema',
+    # 'Extrema',
     'FilledArea': 'filled_area',
     'FilledImage': 'filled_image',
     'HuMoments': 'moments_hu',
@@ -42,10 +45,10 @@
     'NormalizedMoments': 'moments_normalized',
     'Orientation': 'orientation',
     'Perimeter': 'perimeter',
-#    'PixelIdxList',
-#    'PixelList',
+    # 'PixelIdxList',
+    # 'PixelList',
     'Solidity': 'solidity',
-#    'SubarrayIdx'
+    # 'SubarrayIdx'
     'WeightedCentralMoments': 'weighted_moments_central',
     'WeightedCentroid': 'weighted_centroid',
     'WeightedHuMoments': 'weighted_moments_hu',
@@ -56,52 +59,18 @@
 PROP_VALS = set(PROPS.values())
 
 
-class _cached_property(object):
-    """Decorator to use a function as a cached property.
-
-    The function is only called the first time and each successive call returns
-    the cached result of the first call.
-
-        class Foo(object):
-
-            @_cached_property
-            def foo(self):
-                return "Cached"
+def _cached(f):
+    @wraps(f)
+    def wrapper(obj):
+        cache = obj._cache
+        prop = f.__name__
 
-        class Foo(object):
+        if not ((prop in cache) and obj._cache_active):
+            cache[prop] = f(obj)
 
-            def __init__(self):
-                self._cache_active = False
+        return cache[prop]
 
-            @_cached_property
-            def foo(self):
-                return "Not cached"
-
-    Adapted from <http://wiki.python.org/moin/PythonDecoratorLibrary>.
-
-    """
-
-    def __init__(self, func, name=None, doc=None):
-        self.__name__ = name or func.__name__
-        self.__module__ = func.__module__
-        self.__doc__ = doc or func.__doc__
-        self.func = func
-
-    def __get__(self, obj, type=None):
-        if obj is None:
-            return self
-
-        # call every time, if cache is not active
-        if not obj.__dict__.get('_cache_active', True):
-            return self.func(obj)
-
-        # try to retrieve from cache or call and store result in cache
-        try:
-            value = obj.__dict__[self.__name__]
-        except KeyError:
-            value = self.func(obj)
-            obj.__dict__[self.__name__] = value
-        return value
+    return wrapper
 
 
 class _RegionProperties(object):
@@ -112,75 +81,69 @@ class _RegionProperties(object):
     def __init__(self, slice, label, label_image, intensity_image,
                  cache_active):
         self.label = label
+
         self._slice = slice
         self._label_image = label_image
         self._intensity_image = intensity_image
+
         self._cache_active = cache_active
+        self._cache = {}
 
-    @property
     def area(self):
         return self.moments[0, 0]
 
-    @property
     def bbox(self):
         return (self._slice[0].start, self._slice[1].start,
                 self._slice[0].stop, self._slice[1].stop)
 
-    @property
     def centroid(self):
         row, col = self.local_centroid
         return row + self._slice[0].start, col + self._slice[1].start
 
-    @property
     def convex_area(self):
         return np.sum(self.convex_image)
 
-    @_cached_property
+    @_cached
     def convex_image(self):
         from ..morphology.convex_hull import convex_hull_image
         return convex_hull_image(self.image)
 
-    @property
     def coords(self):
         rr, cc = np.nonzero(self.image)
         return np.vstack((rr + self._slice[0].start,
                           cc + self._slice[1].start)).T
 
-    @property
     def eccentricity(self):
         l1, l2 = self.inertia_tensor_eigvals
         if l1 == 0:
             return 0
         return sqrt(1 - l2 / l1)
 
-    @property
     def equivalent_diameter(self):
         return sqrt(4 * self.moments[0, 0] / PI)
 
-    @property
     def euler_number(self):
         euler_array = self.filled_image != self.image
-        _, num = label(euler_array, neighbors=8, return_num=True, background=-1)
+        _, num = label(euler_array, neighbors=8, return_num=True,
+                       background=-1)
         return -num + 1
 
-    @property
     def extent(self):
         rows, cols = self.image.shape
         return self.moments[0, 0] / (rows * cols)
 
-    @property
     def filled_area(self):
         return np.sum(self.filled_image)
 
-    @_cached_property
+    @_cached
     def filled_image(self):
         return ndi.binary_fill_holes(self.image, STREL_8)
 
-    @_cached_property
+    @_cached
     def image(self):
         return self._label_image[self._slice] == self.label
 
-    @_cached_property
+    @_cached
     def inertia_tensor(self):
         mu = self.moments_central
         a = mu[2, 0] / mu[0, 0]
@@ -188,7 +151,7 @@ def inertia_tensor(self):
         c = mu[0, 2] / mu[0, 0]
         return np.array([[a, b], [b, c]])
 
-    @_cached_property
+    @_cached
     def inertia_tensor_eigvals(self):
         a, b, b, c = self.inertia_tensor.flat
         # eigen values of inertia tensor
@@ -196,64 +159,55 @@ def inertia_tensor_eigvals(self):
         l2 = (a + c) / 2 - sqrt(4 * b ** 2 + (a - c) ** 2) / 2
         return l1, l2
 
-    @_cached_property
+    @_cached
     def intensity_image(self):
         if self._intensity_image is None:
             raise AttributeError('No intensity image specified.')
         return self._intensity_image[self._slice] * self.image
 
-    @property
     def _intensity_image_double(self):
         return self.intensity_image.astype(np.double)
 
-    @property
     def local_centroid(self):
         m = self.moments
         row = m[0, 1] / m[0, 0]
         col = m[1, 0] / m[0, 0]
         return row, col
 
-    @property
     def max_intensity(self):
         return np.max(self.intensity_image[self.image])
 
-    @property
     def mean_intensity(self):
         return np.mean(self.intensity_image[self.image])
 
-    @property
     def min_intensity(self):
         return np.min(self.intensity_image[self.image])
 
-    @property
     def major_axis_length(self):
         l1, _ = self.inertia_tensor_eigvals
         return 4 * sqrt(l1)
 
-    @property
     def minor_axis_length(self):
         _, l2 = self.inertia_tensor_eigvals
         return 4 * sqrt(l2)
 
-    @_cached_property
+    @_cached
     def moments(self):
         return _moments.moments(self.image.astype(np.uint8), 3)
 
-    @_cached_property
+    @_cached
     def moments_central(self):
         row, col = self.local_centroid
         return _moments.moments_central(self.image.astype(np.uint8),
                                         row, col, 3)
 
-    @property
     def moments_hu(self):
         return _moments.moments_hu(self.moments_normalized)
 
-    @_cached_property
+    @_cached
     def moments_normalized(self):
         return _moments.moments_normalized(self.moments_central, 3)
 
-    @property
     def orientation(self):
         a, b, b, c = self.inertia_tensor.flat
         b = -b
@@ -265,41 +219,37 @@ def orientation(self):
         else:
             return - 0.5 * atan2(2 * b, (a - c))
 
-    @property
     def perimeter(self):
         return perimeter(self.image, 4)
 
-    @property
     def solidity(self):
         return self.moments[0, 0] / np.sum(self.convex_image)
 
-    @property
     def weighted_centroid(self):
         row, col = self.weighted_local_centroid
         return row + self._slice[0].start, col + self._slice[1].start
 
-    @property
     def weighted_local_centroid(self):
         m = self.weighted_moments
         row = m[0, 1] / m[0, 0]
         col = m[1, 0] / m[0, 0]
         return row, col
 
-    @_cached_property
+    @_cached
     def weighted_moments(self):
-        return _moments.moments_central(self._intensity_image_double, 0, 0, 3)
+        return _moments.moments_central(self._intensity_image_double(),
+                                        0, 0, 3)
 
-    @_cached_property
+    @_cached
     def weighted_moments_central(self):
         row, col = self.weighted_local_centroid
-        return _moments.moments_central(self._intensity_image_double,
+        return _moments.moments_central(self._intensity_image_double(),
                                         row, col, 3)
 
-    @property
     def weighted_moments_hu(self):
         return _moments.moments_hu(self.weighted_moments_normalized)
 
-    @_cached_property
+    @_cached
     def weighted_moments_normalized(self):
         return _moments.moments_normalized(self.weighted_moments_central, 3)
 
@@ -335,7 +285,7 @@ def __eq__(self, other):
 
         for key in PROP_VALS:
             try:
-                #so that NaNs are equal
+                # so that NaNs are equal
                 np.testing.assert_equal(getattr(self, key, None),
                                         getattr(other, key, None))
             except AssertionError:
@@ -352,7 +302,8 @@ def regionprops(label_image, intensity_image=None, cache=True):
     label_image : (N, M) ndarray
         Labeled input image. Labels with value 0 are ignored.
     intensity_image : (N, M) ndarray, optional
-        Intensity image with same size as labeled image. Default is None.
+        Intensity (i.e., input) image with same size as labeled image.
+        Default is None.
     cache : bool, optional
         Determine whether to cache calculated properties. The computation is
         much faster for cached properties, whereas the memory consumption
@@ -371,7 +322,7 @@ def regionprops(label_image, intensity_image=None, cache=True):
     **area** : int
         Number of pixels of region.
     **bbox** : tuple
-       Bounding box ``(min_row, min_col, max_row, max_col)``
+        Bounding box ``(min_row, min_col, max_row, max_col)``
     **centroid** : array
         Centroid coordinate tuple ``(row, col)``.
     **convex_area** : int
@@ -405,8 +356,13 @@ def regionprops(label_image, intensity_image=None, cache=True):
         Inertia tensor of the region for the rotation around its mass.
     **inertia_tensor_eigvals** : tuple
         The two eigen values of the inertia tensor in decreasing order.
+    **intensity_image** : ndarray
+        Image inside region bounding box.
     **label** : int
         The label in the labeled input image.
+    **local_centroid** : array
+        Centroid coordinate tuple ``(row, col)``, relative to region bounding
+        box.
     **major_axis_length** : float
         The length of the major axis of the ellipse that has the same
         normalized second central moments as the region.
@@ -452,6 +408,9 @@ def regionprops(label_image, intensity_image=None, cache=True):
     **weighted_centroid** : array
         Centroid coordinate tuple ``(row, col)`` weighted with intensity
         image.
+    **weighted_local_centroid** : array
+        Centroid coordinate tuple ``(row, col)``, relative to region bounding
+        box, weighted with intensity image.
     **weighted_moments** : (3, 3) ndarray
         Spatial moments of intensity image up to 3rd order::
 
@@ -568,7 +527,6 @@ def perimeter(image, neighbourhood=4):
     perimeter_weights[[21, 33]] = sqrt(2)
     perimeter_weights[[13, 23]] = (1 + sqrt(2)) / 2
 
-
     perimeter_image = ndi.convolve(border_image, np.array([[10, 2, 10],
                                                            [ 2, 1,  2],
                                                            [10, 2, 10]]),
@@ -581,3 +539,32 @@ def perimeter(image, neighbourhood=4):
     perimeter_histogram = np.bincount(perimeter_image.ravel(), minlength=50)
     total_perimeter = np.dot(perimeter_histogram, perimeter_weights)
     return total_perimeter
+
+
+def _parse_docs():
+    import re
+    import textwrap
+
+    doc = regionprops.__doc__
+    matches = re.finditer('\*\*(\w+)\*\* \:.*?\n(.*?)(?=\n    [\*\S]+)',
+                          doc, flags=re.DOTALL)
+    prop_doc = dict((m.group(1), textwrap.dedent(m.group(2))) for m in matches)
+
+    return prop_doc
+
+
+def _install_properties_docs():
+    prop_doc = _parse_docs()
+
+    for p in [member for member in dir(_RegionProperties)
+              if not member.startswith('_')]:
+        try:
+            getattr(_RegionProperties, p).__doc__ = prop_doc[p]
+        except AttributeError:
+            # For Python 2.x
+            getattr(_RegionProperties, p).im_func.__doc__ = prop_doc[p]
+
+        setattr(_RegionProperties, p, property(getattr(_RegionProperties, p)))
+
+
+_install_properties_docs()


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
@@ -3,8 +3,8 @@
 import numpy as np
 import math
 
-from skimage.measure._regionprops import regionprops, PROPS, perimeter
-from skimage._shared._warnings import expected_warnings
+from skimage.measure._regionprops import (regionprops, PROPS, perimeter,
+                                          _parse_docs)
 
 
 SAMPLE = np.array(
@@ -63,14 +63,14 @@ def test_bbox():
 def test_moments_central():
     mu = regionprops(SAMPLE)[0].moments_central
     # determined with OpenCV
-    assert_almost_equal(mu[0,2], 436.00000000000045)
+    assert_almost_equal(mu[0, 2], 436.00000000000045)
     # different from OpenCV results, bug in OpenCV
-    assert_almost_equal(mu[0,3], -737.333333333333)
-    assert_almost_equal(mu[1,1], -87.33333333333303)
-    assert_almost_equal(mu[1,2], -127.5555555555593)
-    assert_almost_equal(mu[2,0], 1259.7777777777774)
-    assert_almost_equal(mu[2,1], 2000.296296296291)
-    assert_almost_equal(mu[3,0], -760.0246913580195)
+    assert_almost_equal(mu[0, 3], -737.333333333333)
+    assert_almost_equal(mu[1, 1], -87.33333333333303)
+    assert_almost_equal(mu[1, 2], -127.5555555555593)
+    assert_almost_equal(mu[2, 0],  1259.7777777777774)
+    assert_almost_equal(mu[2, 1],  2000.296296296291)
+    assert_almost_equal(mu[3, 0], -760.0246913580195)
 
 
 def test_centroid():
@@ -145,13 +145,13 @@ def test_extent():
 def test_moments_hu():
     hu = regionprops(SAMPLE)[0].moments_hu
     ref = np.array([
-        3.27117627e-01,
-        2.63869194e-02,
-        2.35390060e-02,
-        1.23151193e-03,
-        1.38882330e-06,
+         3.27117627e-01,
+         2.63869194e-02,
+         2.35390060e-02,
+         1.23151193e-03,
+         1.38882330e-06,
         -2.72586158e-05,
-        6.48350653e-06
+         6.48350653e-06
     ])
     # bug in OpenCV caused in Central Moments calculation?
     assert_array_almost_equal(hu, ref)
@@ -217,27 +217,27 @@ def test_minor_axis_length():
 def test_moments():
     m = regionprops(SAMPLE)[0].moments
     # determined with OpenCV
-    assert_almost_equal(m[0,0], 72.0)
-    assert_almost_equal(m[0,1], 408.0)
-    assert_almost_equal(m[0,2], 2748.0)
-    assert_almost_equal(m[0,3], 19776.0)
-    assert_almost_equal(m[1,0], 680.0)
-    assert_almost_equal(m[1,1], 3766.0)
-    assert_almost_equal(m[1,2], 24836.0)
-    assert_almost_equal(m[2,0], 7682.0)
-    assert_almost_equal(m[2,1], 43882.0)
-    assert_almost_equal(m[3,0], 95588.0)
+    assert_almost_equal(m[0, 0], 72.0)
+    assert_almost_equal(m[0, 1], 408.0)
+    assert_almost_equal(m[0, 2], 2748.0)
+    assert_almost_equal(m[0, 3], 19776.0)
+    assert_almost_equal(m[1, 0], 680.0)
+    assert_almost_equal(m[1, 1], 3766.0)
+    assert_almost_equal(m[1, 2], 24836.0)
+    assert_almost_equal(m[2, 0], 7682.0)
+    assert_almost_equal(m[2, 1], 43882.0)
+    assert_almost_equal(m[3, 0], 95588.0)
 
 
 def test_moments_normalized():
     nu = regionprops(SAMPLE)[0].moments_normalized
     # determined with OpenCV
-    assert_almost_equal(nu[0,2], 0.08410493827160502)
-    assert_almost_equal(nu[1,1], -0.016846707818929982)
-    assert_almost_equal(nu[1,2], -0.002899800614433943)
-    assert_almost_equal(nu[2,0], 0.24301268861454037)
-    assert_almost_equal(nu[2,1], 0.045473992910668816)
-    assert_almost_equal(nu[3,0], -0.017278118992041805)
+    assert_almost_equal(nu[0, 2],  0.08410493827160502)
+    assert_almost_equal(nu[1, 1], -0.016846707818929982)
+    assert_almost_equal(nu[1, 2], -0.002899800614433943)
+    assert_almost_equal(nu[2, 0],  0.24301268861454037)
+    assert_almost_equal(nu[2, 1],  0.045473992910668816)
+    assert_almost_equal(nu[3, 0], -0.017278118992041805)
 
 
 def test_orientation():
@@ -277,14 +277,14 @@ def test_weighted_moments_central():
     wmu = regionprops(SAMPLE, intensity_image=INTENSITY_SAMPLE
                       )[0].weighted_moments_central
     ref = np.array(
-        [[  7.4000000000e+01, -2.1316282073e-13,  4.7837837838e+02,
-            -7.5943608473e+02],
-         [  3.7303493627e-14, -8.7837837838e+01, -1.4801314828e+02,
-            -1.2714707125e+03],
-         [  1.2602837838e+03,  2.1571526662e+03,  6.6989799420e+03,
-             1.5304076361e+04],
-         [ -7.6561796932e+02, -4.2385971907e+03, -9.9501164076e+03,
-            -3.3156729271e+04]]
+        [[ 7.4000000000e+01, -2.1316282073e-13,
+           4.7837837838e+02, -7.5943608473e+02],
+         [ 3.7303493627e-14, -8.7837837838e+01,
+          -1.4801314828e+02, -1.2714707125e+03],
+         [ 1.2602837838e+03,  2.1571526662e+03,
+           6.6989799420e+03,  1.5304076361e+04],
+         [-7.6561796932e+02, -4.2385971907e+03,
+          -9.9501164076e+03, -3.3156729271e+04]]
     )
     np.set_printoptions(precision=10)
     assert_array_almost_equal(wmu, ref)
@@ -315,14 +315,10 @@ def test_weighted_moments():
     wm = regionprops(SAMPLE, intensity_image=INTENSITY_SAMPLE
                      )[0].weighted_moments
     ref = np.array(
-        [[  7.4000000000e+01, 4.1000000000e+02, 2.7500000000e+03,
-            1.9778000000e+04],
-         [  6.9900000000e+02, 3.7850000000e+03, 2.4855000000e+04,
-            1.7500100000e+05],
-         [  7.8630000000e+03, 4.4063000000e+04, 2.9347700000e+05,
-            2.0810510000e+06],
-         [  9.7317000000e+04, 5.7256700000e+05, 3.9007170000e+06,
-            2.8078871000e+07]]
+        [[7.4000000e+01, 4.1000000e+02, 2.7500000e+03, 1.9778000e+04],
+         [6.9900000e+02, 3.7850000e+03, 2.4855000e+04, 1.7500100e+05],
+         [7.8630000e+03, 4.4063000e+04, 2.9347700e+05, 2.0810510e+06],
+         [9.7317000e+04, 5.7256700e+05, 3.9007170e+06, 2.8078871e+07]]
     )
     assert_array_almost_equal(wm, ref)
 
@@ -355,8 +351,10 @@ def test_pure_background():
 
 def test_invalid():
     ps = regionprops(SAMPLE)
+
     def get_intensity_image():
         ps[0].intensity_image
+
     assert_raises(AttributeError, get_intensity_image)
 
 
@@ -386,6 +384,37 @@ def test_iterate_all_props():
     assert len(p0) < len(p1)
 
 
+def test_cache():
+    region = regionprops(SAMPLE)[0]
+    f0 = region.filled_image
+    region._label_image[:10] = 1
+    f1 = region.filled_image
+
+    # Changed underlying image, but cache keeps result the same
+    assert_array_equal(f0, f1)
+
+    # Now invalidate cache
+    region._cache_active = False
+    f1 = region.filled_image
+
+    assert np.any(f0 != f1)
+
+
+def test_docstrings_and_props():
+    region = regionprops(SAMPLE)[0]
+
+    docs = _parse_docs()
+    props = [m for m in dir(region) if not m.startswith('_')]
+
+    nr_docs_parsed = len(docs)
+    nr_props = len(props)
+    assert_equal(nr_docs_parsed, nr_props)
+
+    ds = docs['weighted_moments_normalized']
+    assert 'iteration' not in ds
+    assert len(ds.split('\n')) > 3
+
+
 if __name__ == "__main__":
     from numpy.testing import run_module_suite
     run_module_suite()