8000 allow passing extra measurement functions to regionprops and regionpr… · scikit-image/scikit-image@3a4dc58 · GitHub
[go: up one dir, main page]

Skip to content

Commit 3a4dc58

Browse files
VolkerHjnirfezzani
authored
allow passing extra measurement functions to regionprops and regionprops_table (#4810)
* seperate out test for empty properties Co-authored-by: Juan Nunez-Iglesias <juan.nunez-iglesias@monash.edu> * infer dtype for custom regionprop functions * __getattr__ to handle extra_properties * handle both intensity and non-intensity cases * added extra_properties to regionprops_table * small bug fix * fixed check for shadowed property names * fixed various small bugs * fix PEP8 * more PEP8 * hopefully shutting up PEP8 this time * decreasing indent by one spave * removing another space for PEP8 * check for intensity img, raise AttributeError * added tests for extra_properties * autopep8 -range on pep8speaks complaints * line length * fixed preexisting docstring example bug * fixed bug and added an example for regionpros_table * added example for regionprops * added test with multiple labels that catches previously corrected bug * autopep8 on --line-range with problems * more pep8 niggles addressed * newline to end of file restored * Update skimage/measure/_regionprops.py Co-authored-by: Juan Nunez-Iglesias <juan.nunez-iglesias@monash.edu> * Update skimage/measure/_regionprops.py Co-authored-by: Juan Nunez-Iglesias <juan.nunez-iglesias@monash.edu> * Update skimage/measure/_regionprops.py Co-authored-by: Juan Nunez-Iglesias <juan.nunez-iglesias@monash.edu> * Update skimage/measure/_regionprops.py Co-authored-by: Juan Nunez-Iglesias <juan.nunez-iglesias@monash.edu> * Update skimage/measure/_regionprops.py Co-authored-by: Juan Nunez-Iglesias <juan.nunez-iglesias@monash.edu> * typo * explain which arguments need to be passed * Update skimage/measure/_regionprops.py Co-authored-by: Riadh Fezzani <rfezzani@gmail.com> * Update skimage/measure/_regionprops.py Co-authored-by: Riadh Fezzani <rfezzani@gmail.com> * Correct doctest formatting in skimage/measure/_regionprops.py Co-authored-by: Riadh Fezzani <rfezzani@gmail.com> * Update skimage/measure/_regionprops.py Fix doc Co-authored-by: Juan Nunez-Iglesias <juan.nunez-iglesias@monash.edu> Co-authored-by: Riadh Fezzani <rfezzani@gmail.com>
1 parent 91a46e3 commit 3a4dc58

File tree

2 files changed

+258
-19
lines changed

2 files changed

+258
-19
lines changed

skimage/measure/_regionprops.py

Lines changed: 189 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import inspect
12
from warnings import warn
23
from math import sqrt, atan2, pi as PI
34
import numpy as np
@@ -118,6 +119,67 @@
118119
PROP_VALS = set(PROPS.values())
119120

120121

122+
def _infer_number_of_required_args(func):
123+
"""Infer the number of required arguments for a function
124+
125+
Parameters
126+
----------
127+
func : callable
128+
The function that is being inspected.
129+
130+
Returns
131+
-------
132+
n_args : int
133+
The number of required arguments of func.
134+
"""
135+
argspec = inspect.getfullargspec(func)
136+
n_args = len(argspec.args)
137+
if argspec.defaults is not None:
138+
n_args -= len(argspec.defaults)
139+
return n_args
140+
141+
142+
def _infer_regionprop_dtype(func, *, intensity, ndim):
143+
"""Infer the dtype of a region property calculated by func.
144+
145+
If a region property function always returns the same shape and type of
146+
output regardless of input size, then the dtype is the dtype of the
147+
returned array. Otherwise, the property has object dtype.
148+
149+
Parameters
150+
----------
151+
func : callable
152+
Function to be tested. The signature should be array[bool] -> Any if
153+
intensity is False, or *(array[bool], array[float]) -> Any otherwise.
154+
intensity : bool
155+
Whether the regionprop is calculated on an intensity image.
156+
ndim : int
157+
The number of dimensions for which to check func.
158+
159+
Returns
160+
-------
161+
dtype : NumPy data type
162+
The data type of the returned property.
163+
"""
164+
labels = [1, 2]
165+
sample = np.zeros((3,) * ndim, dtype=np.intp)
166+
sample[(0,) * ndim] = labels[0]
167+
sample[(slice(1, None),) * ndim] = labels[1]
168+
propmasks = [(sample == n) for n in labels]
169+
if intensity and _infer_number_of_required_args(func) == 2:
170+
def _func(mask):
171+
return func(mask, np.random.random(sample.shape))
172+
else:
173+
_func = func
174+
props1, props2 = map(_func, propmasks)
175+
if (np.isscalar(props1) and np.isscalar(props2)
176+
or np.array(props1).shape == np.array(props2).shape):
177+
dtype = np.array(props1).dtype.type
178+
else:
179+
dtype = np.object_
180+
return dtype
181+
182+
121183
def _cached(f):
122184
@wraps(f)
123185
def wrapper(obj):
@@ -148,7 +210,7 @@ class RegionProperties:
148210
"""
149211

150212
def __init__(self, slice, label, label_image, intensity_image,
151-
cache_active):
213+
cache_active, *, extra_properties=None):
152214

153215
if intensity_image is not None:
154216
if not intensity_image.shape == label_image.shape:
@@ -166,6 +228,45 @@ def __init__(self, slice, label, label_image, intensity_image,
166228
self._cache = {}
167229
self._ndim = label_image.ndim
168230

231+
self._extra_properties = {}
232+
if extra_properties is None:
233+
extra_properties = []
234+
for func in extra_properties:
235+
name = func.__name__
236+
if hasattr(self, name):
237+
msg = (
238+
f"Extra property '{name}' is shadowed by existing "
239+
"property and will be inaccessible. Consider renaming it."
240+
)
241+
warn(msg)
242+
self._extra_properties = {
243+
func.__name__: func for func in extra_properties
244+
}
245+
246+
def __getattr__(self, attr):
247+
if attr in self._extra_properties:
248+
func = self._extra_properties[attr]
249+
n_args = _infer_number_of_required_args(func)
250+
# determine whether func requires intensity image
251+
if n_args == 2:
252+
if self._intensity_image is not None:
253+
return func(self.image, self.intensity_image)
254+
else:
255+
raise AttributeError(
256+
f"intensity image required to calculate {attr}"
257+
)
258+
elif n_args == 1:
259+
return func(self.image)
260+
else:
261+
raise AttributeError(
262+
"Custom regionprop function's number of arguments must be 1 or 2"
263+
f"but {attr} takes {n_args} arguments."
264+
)
265+
else:
266+
raise AttributeError(
267+
f"'{type(self)}' object has no attribute '{attr}'"
268+
)
269+
169270
@property
170271
@_cached
171272
def area(self):
@@ -517,22 +618,31 @@ def _props_to_dict(regions, properties=('label', 'bbox'), separator='-'):
517618
out = {}
518619
n = len(regions)
519620
for prop in properties:
520-
dtype = COL_DTYPES[prop]
621+
r = regions[0]
622+
rp = getattr(r, prop)
623+
if prop in COL_DTYPES:
624+
dtype = COL_DTYPES[prop]
625+
else:
626+
func = r._extra_properties[prop]
627+
dtype = _infer_regionprop_dtype(
628+
func,
629+
intensity=r._intensity_image is not None,
630+
ndim=r.image.ndim,
631+
)
521632
column_buffer = np.zeros(n, dtype=dtype)
522-
r = regions[0][prop]
523633

524634
# scalars and objects are dedicated one column per prop
525635
# array properties are raveled into multiple columns
526636
# for more info, refer to notes 1
527-
if np.isscalar(r) or prop in OBJECT_COLUMNS:
637+
if np.isscalar(rp) or prop in OBJECT_COLUMNS or dtype is np.object_:
528638
for i in range(n):
529639
column_buffer[i] = regions[i][prop]
530640
out[prop] = np.copy(column_buffer)
531641
else:
532-
if isinstance(r, np.ndarray):
533-
shape = r.shape
642+
if isinstance(rp, np.ndarray):
643+
shape = rp.shape
534644
else:
535-
shape = (len(r),)
645+
shape = (len(rp),)
536646

537647
for ind in np.ndindex(shape):
538648
for k in range(n):
@@ -546,7 +656,7 @@ def _props_to_dict(regions, properties=('label', 'bbox'), separator='-'):
546656
def regionprops_table(label_image, intensity_image=None,
547657
properties=('label', 'bbox'),
548658
*,
549-
cache=True, separator='-'):
659+
cache=True, separator='-', extra_properties=None):
550660
"""Compute image properties and return them as a pandas-compatible table.
551661
552662
The table is a dictionary mapping column names to value arrays. See Notes
@@ -579,6 +689,15 @@ def regionprops_table(label_image, intensity_image=None,
579689
Object columns are those that cannot be split in this way because the
580690
number of columns would change depending on the object. For example,
581691
``image`` and ``coords``.
692+
extra_properties : Iterable of callables
693+
Add extra property computation functions that are not included with
694+
skimage. The name of the property is derived from the function name,
695+
the dtype is inferred by calling the function on a small sample.
696+
If the name of an extra property clashes with the name of an existing
697+
property the extra property wil not be visible and a UserWarning is
698+
issued. A property computation function must take a region mask as its
699+
first argument. If the property requires an intensity image, it must
700+
accept the intensity image as the second argument.
582701
583702
Returns
584703
-------
@@ -614,7 +733,7 @@ def regionprops_table(label_image, intensity_image=None,
614733
>>> from skimage import data, util, measure
615734
>>> image = data.coins()
616735
>>> label_image = measure.label(image > 110, connectivity=image.ndim)
617-
>>> props = regionprops_table(label_image, image,
736+
>>> props = measure.regionprops_table(label_image, image,
618737
... properties=['label', 'inertia_tensor',
619738
... 'inertia_tensor_eigvals'])
620739
>>> props # doctest: +ELLIPSIS +SKIP
@@ -638,33 +757,62 @@ def regionprops_table(label_image, intensity_image=None,
638757
639758
[5 rows x 7 columns]
640759
760+
If we want to measure a feature that does not come as a built-in
761+
property, we can define custom functions and pass them as
762+
``extra_properties``. For example, we can create a custom function
763+
that measures the intensity quartiles in a region:
764+
765+
>>> from skimage import data, util, measure
766+
>>> import numpy as np
767+
>>> def quartiles(regionmask, intensity):
768+
... return np.percentile(intensity[regionmask], q=(25, 50, 75))
769+
>>>
770+
>>> image = data.coins()
771+
>>> label_image = measure.label(image > 110, connectivity=image.ndim)
772+
>>> props = measure.regionprops_table(label_image, intensity_image=image,
773+
... properties=('label',),
774+
... extra_properties=(quartiles,))
775+
>>> import pandas as pd # doctest: +SKIP
776+
>>> pd.DataFrame(props).head() # doctest: +SKIP
777+
label quartiles-0 quartiles-1 quartiles-2
778+
0 1 117.00 123.0 130.0
779+
1 2 111.25 112.0 114.0
780+
2 3 111.00 111.0 111.0
781+
3 4 111.00 111.5 112.5
782+
4 5 112.50 113.0 114.0
783+
641784
"""
642785
regions = regionprops(label_image, intensity_image=intensity_image,
643-
cache=cache)
644-
786+
cache=cache, extra_properties=extra_properties)
787+
if extra_properties is not None:
788+
properties = (
789+
list(properties) + [prop.__name__ for prop in extra_properties]
790+
)
645791
if len(regions) == 0:
646792
label_image = np.zeros((3,) * label_image.ndim, dtype=int)
647793
label_image[(1,) * label_image.ndim] = 1
648794
if intensity_image is not None:
649795
intensity_image = np.zeros(label_image.shape,
650796
dtype=intensity_image.dtype)
651797
regions = regionprops(label_image, intensity_image=intensity_image,
652-
cache=cache)
798+
cache=cache, extra_properties=extra_properties)
653799

654800
out_d = _props_to_dict(regions, properties=properties,
655801
separator=separator)
656802
return {k: v[:0] for k, v in out_d.items()}
657803

658-
return _props_to_dict(regions, properties=properties, separator=separator)
804+
return _props_to_dict(
805+
regions, properties=properties, separator=separator
806+
)
659807

660808

661809
def regionprops(label_image, intensity_image=None, cache=True,
662-
coordinates=None):
810+
coordinates=None, *, extra_properties=None):
663811
r"""Measure properties of labeled image regions.
664812
665813
Parameters
666814
----------
667-
label_image : (N, M) ndarray
815+
label_image : (M, N[, P]) ndarray
668816
Labeled input image. Labels with value 0 are ignored.
669817
670818
.. versionchanged:: 0.14.1
@@ -673,7 +821,7 @@ def regionprops(label_image, intensity_image=None, cache=True,
673821
inconsistent handling of images with singleton dimensions. To
674822
recover the old behaviour, use
675823
``regionprops(np.squeeze(label_image), ...)``.
676-
intensity_image : (N, M) ndarray, optional
824+
intensity_image : (M, N[, P]) ndarray, optional
677825
Intensity (i.e., input) image with same size as labeled image.
678826
Default is None.
679827
cache : bool, optional
@@ -693,7 +841,15 @@ def regionprops(label_image, intensity_image=None, cache=True,
693841
0.15 and earlier. However, for some properties, the transformation
694842
will be less trivial. For example, the new orientation is
695843
:math:`\frac{\pi}{2}` plus the old orientation.
696-
844+
extra_properties : Iterable of callables
845+
Add extra property computation functions that are not included with
846+
skimage. The name of the property is derived from the function name,
847+
the dtype is inferred by calling the function on a small sample.
848+
If the name of an extra property clashes with the name of an existing
849+
property the extra property wil not be visible and a UserWarning is
850+
issued. A property computation function must take a region mask as its
851+
first argument. If the property requires an intensity image, it must
852+
accept the intensity image as the second argument.
697853
698854
Returns
699855
-------
@@ -861,7 +1017,7 @@ def regionprops(label_image, intensity_image=None, cache=True,
8611017
Examples
8621018
--------
8631019
>>> from skimage import data, util
864-
>>> from skimage.measure import label
1020+
>>> from skimage.measure import label, regionprops
8651021
>>> img = util.img_as_ubyte(data.coins()) > 110
8661022
>>> label_img = label(img, connectivity=img.ndim)
8671023
>>> props = regionprops(label_img)
@@ -872,6 +1028,20 @@ def regionprops(label_image, intensity_image=None, cache=True,
8721028
>>> props[0]['centroid']
8731029
(22.72987986048314, 81.91228523446583)
8741030
1031+
Add custom measurements by passing functions as ``extra_properties``
1032+
>>> from skimage import data, util
1033+
>>> from skimage.measure import label, regionprops
1034+
>>> import numpy as np
1035+
>>> img = util.img_as_ubyte(data.coins()) > 110
1036+
>>> label_img = label(img, connectivity=img.ndim)
1037+
>>> def pixelcount(regionmask):
1038+
... return np.sum(regionmask)
1039+
>>> props = regionprops(label_img, extra_properties=(pixelcount,))
1040+
>>> props[0].pixelcount
1041+
7741
1042+
>>> props[1]['pixelcount']
1043+
42
1044+
8751045
"""
8761046

8771047
if label_image.ndim not in (2, 3):
@@ -915,7 +1085,7 @@ def regionprops(label_image, intensity_image=None, cache=True,
9151085
label = i + 1
9161086

9171087
props = RegionProperties(sl, label, label_image, intensity_image,
918-
cache)
1088+
cache, extra_properties=extra_properties)
9191089
regions.append(props)
9201090

9211091
return regions

0 commit comments

Comments
 (0)
0