1
+ import inspect
1
2
from warnings import warn
2
3
from math import sqrt , atan2 , pi as PI
3
4
import numpy as np
118
119
PROP_VALS = set (PROPS .values ())
119
120
120
121
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
+
121
183
def _cached (f ):
122
184
@wraps (f )
123
185
def wrapper (obj ):
@@ -148,7 +210,7 @@ class RegionProperties:
148
210
"""
149
211
150
212
def __init__ (self , slice , label , label_image , intensity_image ,
151
- cache_active ):
213
+ cache_active , * , extra_properties = None ):
152
214
153
215
if intensity_image is not None :
154
216
if not intensity_image .shape == label_image .shape :
@@ -166,6 +228,45 @@ def __init__(self, slice, label, label_image, intensity_image,
166
228
self ._cache = {}
167
229
self ._ndim = label_image .ndim
168
230
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
+
169
270
@property
170
271
@_cached
171
272
def area (self ):
@@ -517,22 +618,31 @@ def _props_to_dict(regions, properties=('label', 'bbox'), separator='-'):
517
618
out = {}
518
619
n = len (regions )
519
620
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
+ )
521
632
column_buffer = np .zeros (n , dtype = dtype )
522
- r = regions [0 ][prop ]
523
633
524
634
# scalars and objects are dedicated one column per prop
525
635
# array properties are raveled into multiple columns
526
636
# 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_ :
528
638
for i in range (n ):
529
639
column_buffer [i ] = regions [i ][prop ]
530
640
out [prop ] = np .copy (column_buffer )
531
641
else :
532
- if isinstance (r , np .ndarray ):
533
- shape = r .shape
642
+ if isinstance (rp , np .ndarray ):
643
+ shape = rp .shape
534
644
else :
535
- shape = (len (r ),)
645
+ shape = (len (rp ),)
536
646
537
647
for ind in np .ndindex (shape ):
538
648
for k in range (n ):
@@ -546,7 +656,7 @@ def _props_to_dict(regions, properties=('label', 'bbox'), separator='-'):
546
656
def regionprops_table (label_image , intensity_image = None ,
547
657
properties = ('label' , 'bbox' ),
548
658
* ,
549
- cache = True , separator = '-' ):
659
+ cache = True , separator = '-' , extra_properties = None ):
550
660
"""Compute image properties and return them as a pandas-compatible table.
551
661
552
662
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,
579
689
Object columns are those that cannot be split in this way because the
580
690
number of columns would change depending on the object. For example,
581
691
``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.
582
701
583
702
Returns
584
703
-------
@@ -614,7 +733,7 @@ def regionprops_table(label_image, intensity_image=None,
614
733
>>> from skimage import data, util, measure
615
734
>>> image = data.coins()
616
735
>>> label_image = measure.label(image > 110, connectivity=image.ndim)
617
- >>> props = regionprops_table(label_image, image,
736
+ >>> props = measure. regionprops_table(label_image, image,
618
737
... properties=['label', 'inertia_tensor',
619
738
... 'inertia_tensor_eigvals'])
620
739
>>> props # doctest: +ELLIPSIS +SKIP
@@ -638,33 +757,62 @@ def regionprops_table(label_image, intensity_image=None,
638
757
639
758
[5 rows x 7 columns]
640
759
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
+
641
784
"""
642
785
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
+ )
645
791
if len (regions ) == 0 :
646
792
label_image = np .zeros ((3 ,) * label_image .ndim , dtype = int )
647
793
label_image [(1 ,) * label_image .ndim ] = 1
648
794
if intensity_image is not None :
649
795
intensity_image = np .zeros (label_image .shape ,
650
796
dtype = intensity_image .dtype )
651
797
regions = regionprops (label_image , intensity_image = intensity_image ,
652
- cache = cache )
798
+ cache = cache , extra_properties = extra_properties )
653
799
654
800
out_d = _props_to_dict (regions , properties = properties ,
655
801
separator = separator )
656
802
return {k : v [:0 ] for k , v in out_d .items ()}
657
803
658
- return _props_to_dict (regions , properties = properties , separator = separator )
804
+ return _props_to_dict (
805
+ regions , properties = properties , separator = separator
806
+ )
659
807
660
808
661
809
def regionprops (label_image , intensity_image = None , cache = True ,
662
- coordinates = None ):
810
+ coordinates = None , * , extra_properties = None ):
663
811
r"""Measure properties of labeled image regions.
664
812
665
813
Parameters
666
814
----------
667
- label_image : (N, M ) ndarray
815
+ label_image : (M, N[, P] ) ndarray
668
816
Labeled input image. Labels with value 0 are ignored.
669
817
670
818
.. versionchanged:: 0.14.1
@@ -673,7 +821,7 @@ def regionprops(label_image, intensity_image=None, cache=True,
673
821
inconsistent handling of images with singleton dimensions. To
674
822
recover the old behaviour, use
675
823
``regionprops(np.squeeze(label_image), ...)``.
676
- intensity_image : (N, M ) ndarray, optional
824
+ intensity_image : (M, N[, P] ) ndarray, optional
677
825
Intensity (i.e., input) image with same size as labeled image.
678
826
Default is None.
679
827
cache : bool, optional
@@ -693,7 +841,15 @@ def regionprops(label_image, intensity_image=None, cache=True,
693
841
0.15 and earlier. However, for some properties, the transformation
694
842
will be less trivial. For example, the new orientation is
695
843
: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.
697
853
698
854
Returns
699
855
-------
@@ -861,7 +1017,7 @@ def regionprops(label_image, intensity_image=None, cache=True,
861
1017
Examples
862
1018
--------
863
1019
>>> from skimage import data, util
864
- >>> from skimage.measure import label
1020
+ >>> from skimage.measure import label, regionprops
865
1021
>>> img = util.img_as_ubyte(data.coins()) > 110
866
1022
>>> label_img = label(img, connectivity=img.ndim)
867
1023
>>> props = regionprops(label_img)
@@ -872,6 +1028,20 @@ def regionprops(label_image, intensity_image=None, cache=True,
872
1028
>>> props[0]['centroid']
873
1029
(22.72987986048314, 81.91228523446583)
874
1030
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
+
875
1045
"""
876
1046
877
1047
if label_image .ndim not in (2 , 3 ):
@@ -915,7 +1085,7 @@ def regionprops(label_image, intensity_image=None, cache=True,
915
1085
label = i + 1
916
1086
917
1087
props = RegionProperties (sl , label , label_image , intensity_image ,
918
- cache )
1088
+ cache , extra_properties = extra_properties )
919
1089
regions .append (props )
920
1090
921
1091
return regions
0 commit comments