8000 allow passing extra measurement functions to regionprops and regionprops_table by VolkerH · Pull Request #4810 · scikit-image/scikit-image · GitHub
[go: up one dir, main page]

Skip to content

allow passing extra measurement functions to regionprops and regionprops_table #4810

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 35 commits into from
Jul 7, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
22a0720
seperate out test for empty properties
VolkerH Jun 30, 2020
530e018
infer dtype for custom regionprop functions
VolkerH Jun 30, 2020
1ade250
__getattr__ to handle extra_properties
VolkerH Jun 30, 2020
54ecca0
handle both intensity and non-intensity cases
VolkerH Jun 30, 2020
81e17da
added extra_properties to regionprops_table
VolkerH Jun 30, 2020
deefe82
small bug fix
VolkerH Jun 30, 2020
915cdd6
fixed check for shadowed property names
VolkerH Jun 30, 2020
84a51f2
fixed various small bugs
VolkerH Jun 30, 2020
10681de
fix PEP8
VolkerH Jun 30, 2020
7ca57d4
more PEP8
VolkerH Jun 30, 2020
940581b
hopefully shutting up PEP8 this time
VolkerH Jun 30, 2020
374032b
decreasing indent by one spave
VolkerH Jun 30, 2020
edbd824
removing another space for PEP8
VolkerH Jun 30, 2020
ed763ba
check for intensity img, raise AttributeError
VolkerH Jul 1, 2020
639cec1
added tests for extra_properties
VolkerH Jul 1, 2020
18f82ce
autopep8 -range on pep8speaks complaints
VolkerH Jul 2, 2020
1b7dd6e
line length
VolkerH Jul 2, 2020
78dea77
fixed preexisting docstring example bug
VolkerH Jul 2, 2020
0adbabf
fixed bug and added an example for regionpros_table
VolkerH Jul 2, 2020
7c106cc
added example for regionprops
VolkerH Jul 2, 2020
ed74a86
added test with multiple labels that catches previously corrected bug
VolkerH Jul 2, 2020
bac6264
autopep8 on --line-range with problems
VolkerH Jul 2, 2020
952c3dd
more pep8 niggles addressed
VolkerH Jul 2, 2020
aa0c679
newline to end of file restored
VolkerH Jul 2, 2020
2309d3e
Update skimage/measure/_regionprops.py
VolkerH Jul 3, 2020
e814531
Update skimage/measure/_regionprops.py
VolkerH Jul 3, 2020
443e357
Update skimage/measure/_regionprops.py
VolkerH Jul 3, 2020
cec33ab
Update skimage/measure/_regionprops.py
VolkerH Jul 3, 2020
58a0ed4
Update skimage/measure/_regionprops.py
VolkerH Jul 3, 2020
9f69ca5
typo
VolkerH Jul 3, 2020
3bbc718
explain which arguments need to be passed
VolkerH Jul 4, 2020
9c10a75
Update skimage/measure/_regionprops.py
VolkerH Jul 4, 2020
ab0be5d
Update skimage/measure/_regionprops.py
VolkerH Jul 6, 2020
ba545ea
Correct doctest formatting in skimage/measure/_regionprops.py
jni Jul 6, 2020
b8b4919
Update skimage/measure/_regionprops.py
rfezzani Jul 6, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 8000 changes: 189 additions & 19 deletions skimage/measure/_regionprops.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import inspect
from warnings import warn
from math import sqrt, atan2, pi as PI
import numpy as np
Expand Down Expand Up @@ -118,6 +119,67 @@
PROP_VALS = set(PROPS.values())


def _infer_number_of_required_args(func):
"""Infer the number of required arguments for a function

Parameters
----------
func : callable
The function that is being inspected.

Returns
-------
n_args : int
The number of required arguments of func.
"""
argspec = inspect.getfullargspec(func)
n_args = len(argspec.args)
if argspec.defaults is not None:
n_args -= len(argspec.defaults)
return n_args


def _infer_regionprop_dtype(func, *, intensity, ndim):
"""Infer the dtype of a region property calculated by func.

If a region property function always returns the same shape and type of
output regardless of input size, then the dtype is the dtype of the
returned array. Otherwise, the property has object dtype.

Parameters
----------
func : callable
Function to be tested. The signature should be array[bool] -> Any if
intensity is False, or *(array[bool], array[float]) -> Any otherwise.
intensity : bool
Whether the regionprop is calculated on an intensity image.
ndim : int
The number of dimensions for which to check func.

Returns
-------
dtype : NumPy data type
The data type of the returned property.
"""
labels = [1, 2]
sample = np.zeros((3,) * ndim, dtype=np.intp)
sample[(0,) * ndim] = labels[0]
sample[(slice(1, None),) * ndim] = labels[1]
propmasks = [(sample == n) for n in labels]
if intensity and _infer_number_of_required_args(func) == 2:
def _func(mask):
return func(mask, np.random.random(sample.shape))
else:
_func = func
props1, props2 = map(_func, propmasks)
if (np.isscalar(props1) and np.isscalar(props2)
or np.array(props1).shape == np.array(props2).shape):
dtype = np.array(props1).dtype.type
else:
dtype = np.object_
return dtype


def _cached(f):
@wraps(f)
def wrapper(obj):
Expand Down Expand Up @@ -148,7 +210,7 @@ class RegionProperties:
"""

def __init__(self, slice, label, label_image, intensity_image,
cache_active):
cache_active, *, extra_properties=None):

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

self._extra_properties = {}
if extra_properties is None:
extra_properties = []
for func in extra_properties:
name = func.__name__
if hasattr(self, name):
msg = (
f"Extra property '{name}' is shadowed by existing "
"property and will be inaccessible. Consider renaming it."
)
warn(msg)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we dynamically edit COL_DTYPES to avoid the handling of dtype for extra properties in _props_to_dict?

Suggested change
warn(msg)
warn(msg)
else:
COL_DTYPES[func.__name__] = _infer_regionprop_dtype(
func,
intensity=intensity_image is not None,
ndim=self._ndim,
)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rfezzani here we chose to preserve existing behaviour rather than try to improve it, which could have unintended consequences. I suggest leaving dynamic inference of existing properties for a future PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jni was faster than me. In general, I don't like modifying a global (in the namespace) state. That state change will persist in COL_DTYPES potentially leading to the "unintended consequences" that @jni mentioned.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The suggestion is not an improvement to existing behavior but the modification of the extra properties (new feature) dtype handeling ^^. Avoiding namespace variable modification is a good point, no problem ;)

self._extra_properties = {
func.__name__: func for func in extra_properties
}

def __getattr__(self, attr):
if attr in self._extra_properties:
func = self._extra_properties[attr]
n_args = _infer_number_of_required_args(func)
# determine whether func requires intensity image
if n_args == 2:
if self._intensity_image is not None:
return func(self.image, self.intensity_image)
else:
raise AttributeError(
f"intensity image required to calculate {attr}"
)
elif n_args == 1:
return func(self.image)
else:
raise AttributeError(
"Custom 8000 regionprop function's number of arguments must be 1 or 2"
f"but {attr} takes {n_args} arguments."
)
else:
raise AttributeError(
f"'{type(self)}' object has no attribute '{attr}'"
)

@property
@_cached
def area(self):
Expand Down Expand Up @@ -517,22 +618,31 @@ def _props_to_dict(regions, properties=('label', 'bbox'), separator='-'):
out = {}
n = len(regions)
for prop in properties:
dtype = COL_DTYPES[prop]
r = regions[0]
rp = getattr(r, prop)
if prop in COL_DTYPES:
dtype = COL_DTYPES[prop]
else:
func = r._extra_properties[prop]
dtype = _infer_regionprop_dtype(
func,
intensity=r._intensity_image is not None,
ndim=r.image.ndim,
)
column_buffer = np.zeros(n, dtype=dtype)
r = regions[0][prop]

# scalars and objects are dedicated one column per prop
# array properties are raveled into multiple columns
# for more info, refer to notes 1
if np.isscalar(r) or prop in OBJECT_COLUMNS:
if np.isscalar(rp) or prop in OBJECT_COLUMNS or dtype is np.object_:
for i in range(n):
column_buffer[i] = regions[i][prop]
out[prop] = np.copy(column_buffer)
else:
if isinstance(r, np.ndarray):
shape = r.shape
if isinstance(rp, np.ndarray):
shape = rp.shape
else:
shape = (len(r),)
shape = (len(rp),)

for ind in np.ndindex(shape):
for k in range(n):
Expand All @@ -546,7 +656,7 @@ def _props_to_dict(regions, properties=('label', 'bbox'), separator='-'):
def regionprops_table(label_image, intensity_image=None,
properties=('label', 'bbox'),
*,
cache=True, separator='-'):
cache=True, separator='-', extra_properties=None):
"""Compute image properties and return them as a pandas-compatible table.

The table is a dictionary mapping column names to value arrays. See Notes
Expand Down Expand Up @@ -579,6 +689,15 @@ def regionprops_table(label_image, intensity_image=None,
Object columns are those that cannot be split in this way because the
number of columns would change depending on the object. For example,
``image`` and ``coords``.
extra_properties : Iterable of callables
Add extra property computation functions that are not included with
skimage. The name of the property is derived from the function name,
the dtype is inferred by calling the function on a small sample.
If the name of an extra property clashes with the name of an existing
property the extra property wil not be visible and a UserWarning is
issued. A property computation function must take a region mask as its
first argument. If the property requires an intensity image, it must
accept the intensity image as the second argument.

Returns
-------
Expand Down Expand Up @@ -614,7 +733,7 @@ def regionprops_table(label_image, intensity_image=None,
>>> from skimage import data, util, measure
>>> image = data.coins()
>>> label_image = measure.label(image > 110, connectivity=image.ndim)
& A3E2 gt;>> props = regionprops_table(label_image, image,
>>> props = measure.regionprops_table(label_image, image,
... properties=['label', 'inertia_tensor',
... 'inertia_tensor_eigvals'])
>>> props # doctest: +ELLIPSIS +SKIP
Expand All @@ -638,33 +757,62 @@ def regionprops_table(label_image, intensity_image=None,

[5 rows x 7 columns]

If we want to measure a feature that does not come as a built-in
property, we can define custom functions and pass them as
``extra_properties``. For example, we can create a custom function
that measures the intensity quartiles in a region:

>>> from skimage import data, util, measure
>>> import numpy as np
>>> def quartiles(regionmask, intensity):
... return np.percentile(intensity[regionmask], q=(25, 50, 75))
>>>
>>> image = data.coins()
>>> label_image = measure.label(image > 110, connectivity=image.ndim)
>>> props = measure.regionprops_table(label_image, intensity_image=image,
... properties=('label',),
... extra_properties=(quartiles,))
>>> import pandas as pd # doctest: +SKIP
>>> pd.DataFrame(props).head() # doctest: +SKIP
label quartiles-0 quartiles-1 quartiles-2
0 1 117.00 123.0 130.0
1 2 111.25 112.0 114.0
2 3 111.00 111.0 111.0
3 4 111.00 111.5 112.5
4 5 112.50 113.0 114.0

"""
regions = regionprops(label_image, intensity_image=intensity_image,
cache=cache)

cache=cache, extra_properties=extra_properties)
if extra_properties is not None:
properties = (
list(properties) + [prop.__name__ for prop in extra_properties]
)
if len(regions) == 0:
label_image = np.zeros((3,) * label_image.ndim, dtype=int)
label_image[(1,) * label_image.ndim] = 1
if intensity_image is not None:
intensity_image = np.zeros(label_image.shape,
dtype=intensity_image.dtype)
regions = regionprops(label_image, intensity_image=intensity_image,
cache=cache)
cache=cache, extra_properties=extra_properties)

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

return _props_to_dict(regions, properties=properties, separator=separator)
return _props_to_dict(
regions, properties=properties, separator=separator
)


def regionprops(label_image, intensity_image=None, cache=True,
coordinates=None):
coordinates=None, *, extra_properties=None):
r"""Measure properties of labeled image regions.

Parameters
----------
label_image : (N, M) ndarray
label_image : (M, N[, P]) ndarray
Labeled input image. Labels with value 0 are ignored.

.. versionchanged:: 0.14.1
Expand All @@ -673,7 +821,7 @@ def regionprops(label_image, intensity_image=None, cache=True,
inconsistent handling of images with singleton dimensions. To
recover the old behaviour, use
``regionprops(np.squeeze(label_image), ...)``.
intensity_image : (N, M) ndarray, optional
intensity_image : (M, N[, P]) ndarray, optional
Intensity (i.e., input) image with same size as labeled image.
Default is None.
cache : bool, optional
Expand All @@ -693,7 +841,15 @@ def regionprops(label_image, intensity_image=None, cache=True,
0.15 and earlier. However, for some properties, the transformation
will be less trivial. For example, the new orientation is
:math:`\frac{\pi}{2}` plus the old orientation.

extra_properties : Iterable of callables
Add extra property computation functions that are not included with
skimage. The name of the property is derived from the function name,
the dtype is inferred by calling the function on a small sample.
If the name of an extra property clashes with the name of an existing
property the extra property wil not be visible and a UserWarning is
issued. A property computation function must take a region mask as its
first argument. If the property requires an intensity image, it must
accept the intensity image as the second argument.

Returns
-------
Expand Down Expand Up @@ -861,7 +1017,7 @@ def regionprops(label_image, intensity_image=None, cache=True,
Examples
--------
>>> from skimage import data, util
>>> from skimage.measure import label
>>> from skimage.measure import label, regionprops
>>> img = util.img_as_ubyte(data.coins()) > 110
>>> label_img = label(img, connectivity=img.ndim)
>>> props = regionprops(label_img)
Expand All @@ -872,6 +1028,20 @@ def regionprops(label_image, intensity_image=None, cache=True,
>>> props[0]['centroid']
(22.72987986048314, 81.91228523446583)

Add custom measurements by passing functions as ``extra_properties``
>>> from skimage import data, util
>>> from skimage.measure import label, regionprops
>>> import numpy as np
>>> img = util.img_as_ubyte(data.coins()) > 110
>>> label_img = label(img, connectivity=img.ndim)
>>> def pixelcount(regionmask):
... return np.sum(regionmask)
>>> props = regionprops(label_img, extra_properties=(pixelcount,))
>>> props[0].pixelcount
7741
>>> props[1]['pixelcount']
42

"""

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

props = RegionProperties(sl, label, label_image, intensity_image,
cache)
cache, extra_properties=extra_properties)
regions.append(props)

return regions
Expand Down
Loading
0