diff --git a/skimage/measure/_regionprops.py b/skimage/measure/_regionprops.py index 44694b1e..de523592 100644 --- a/skimage/measure/_regionprops.py +++ b/skimage/measure/_regionprops.py @@ -7,6 +7,9 @@ from ._label import label from . import _moments +from functools import wraps +from collections import defaultdict + __all__ = ['regionprops', 'perimeter'] @@ -20,14 +23,14 @@ PROPS = { '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 @@ PROPS = { '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 @@ PROPS = { PROP_VALS = set(PROPS.values()) -class _cached_property(object): - """Decorator to use a function as a cached property. +def _cached(f): + @wraps(f) + def wrapper(obj): + cache = obj._cache + prop = f.__name__ - The function is only called the first time and each successive call returns - the cached result of the first call. + if not ((prop in cache) and obj._cache_active): + cache[prop] = f(obj) - class Foo(object): + return cache[prop] - @_cached_property - def foo(self): - return "Cached" - - class Foo(object): - - def __init__(self): - self._cache_active = False - - @_cached_property - def foo(self): - return "Not cached" - - Adapted from . - - """ - - 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 - @property + self._cache_active = cache_active + self._cache = {} + 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 @@ class _RegionProperties(object): 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 @@ class _RegionProperties(object): 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 @@ class _RegionProperties(object): 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 @@ class _RegionProperties(object): 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() diff --git a/skimage/measure/tests/test_regionprops.py b/skimage/measure/tests/test_regionprops.py index 9b2ad186..259967ea 100644 --- a/skimage/measure/tests/test_regionprops.py +++ b/skimage/measure/tests/test_regionprops.py @@ -3,8 +3,8 @@ from numpy.testing import assert_array_equal, assert_almost_equal, \ 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()