Repository: scikit-image/scikit-image

Issue:
regionprops hodge-podge of output types - optionally return pd.DataFrame ?
## Description

Currently, `regionprops` provides a list of `skimage.measure._regionprops._RegionProperties`.
These in turn contain the properties,  some of which are tuples of integers, others are `np.ndarrays` and others or scalar types. 

I would argue that a common use case for `regionprops` is to calculate features for subsequent classification of these image regions. For this it would be most useful to have the numerical features in a tabular form, ideally a data frame where the rows correspond to the object labels and the columns correspond to the features. For this, some of the features (tuples, ndarrays) such as for example the moments must be flattened. 
Other features such as `coords` are probably not too useful in this scenario.
  
Currently, I am using some fairly ugly code to loop over the regions, loop over all features, checking each one against a list of which features I want to keep and flattening them according to their data type. 

I assume returning a data frame directly from `regionprops` is not an option as it would introduce a dependency on pandas and would break backward compatibility. But given that pandas seems to be here to stay such a dependency could be acceptable. 
I would argue that a helper function as part of `skimage` that takes the output of regionprops and turns it into a `pd.DataFrame` would be highly useful. This would also adress the issue raised in https://github.com/scikit-image/scikit-image/issues/2755


Discussion:
Sorry, but there is approximately zero chance of `regionprops` returning a DataFrame. Pandas is a very heavy dependency, and getting heavier.

I've come across this problem [myself](https://github.com/microscopium/microscopium/blob/b0aed6fda616f9af1599100324849cd00d3521ac/microscopium/features.py#L166) and I found that a NumPy array was sufficient to store my results. And anyway machine learning libraries like scikit-learn prefer NumPy arrays over DataFrames.

However, you are right that this is a common use case. So, my vote would be for a function `regionprops_table` that takes as input an image and a list of properties and returns a 2D NumPy array and a list of column names. Converting to a data frame after that is trivial and can be done without memory copying.
+1 on a way to get regionprops mapped back to the original image, or to a table.  Both would be useful.
@jni I agree a numpy array plus a list of column names would be fully sufficient and trivial to convert into a pandas dataframe.
In that case, do you propose to cast all features (many of which are integers) to `np.float` for the final array? 
> do you propose to cast all features (many of which are integers) to np.float for the final table

Yes. 
>returns a 2D NumPy array and a list of column names

Could be also a dictionary. Dicts are easily convertible to DataFrames as well.
@soupault yes, but they can’t be used as input to sklearn. 
great idea, and I agree skimage shouldn't add pandas as a hard dependency. However, as someone who has taught a number of introductory courses to image analysis, I feel like the typical user would appreciate having a meaningful html/pandas rendered view when they run regionprops. DataFrame does this quite well and so maybe having it use data.frame if pandas is available and fallback to dicts or arrays+columns if not and having an argument `use_dataframe` to allow for more predictible behavior.

I've done some work like this before in case someone is interested (it isn't elegant but it works, https://github.com/4Quant/pyqae/blob/59f43786520c3a216a8e6b79ce6e73f8f5933448/pyqae/shape_analysis.py#L27-L70)

I would be -1 on the output types changing depending on the system configuration, but if we return a dictionary it makes it quite easy to construct a dataframe from it (`pandas.DataFrame.from_dict`), and we can document this too.

Having `pandas` as an optional dependency just for this shouldn't be a problem.  We can add both `regionprops_as_df` and `regionprops_as_dict`, and re-use the underlying implementation.
Another issue as I start to think about this more closely: many region properties are multi-dimensional. I think the "correct" way to deal with these is as a multi-index for pandas columns. However the multi-indexing doesn't work great when mixing scalar values and multi-values:

```python
In [16]: x = pd.DataFrame({
    ...:     ('foo', None): [5, 6, 7],
    ...:     ('bar', 1): [8., 9.8, 2.2],
    ...:     ('bar', 2): [9., 2., 2.4],
    ...: })

In [17]: x
Out[17]: 
  foo  bar     
  NaN    1    2
0   5  8.0  9.0
1   6  9.8  2.0
2   7  2.2  2.4
```

Options:

- Use hierarchical (tuple) indices with three levels: feature name, index for axis 0, index for axis 1. This lets us represent features up to 2D arrays. I think that encompasses all features except `image` and `label_image`. Where we only have 1D or scalars, we use 0 for the first level or both levels, respectively.
- Use hierarchical (tuple) indices with two levels: feature name, and linear (raveled) index. This works but you lose information about feature shape. Maybe this is fine anyway — if you need round-trip accuracy then you should build your own wrapper around the current regionprops?
- Use a single level index, where we concatenate the multiple levels above where present. e.g. `'inertia_tensor-1-2'`.
- Use object columns. This is my least preferred method but the most generic.

@scikit-image/core @VolkerH any opinions here?

@kmader if I understand your code, you just discarded higher-dimensional features. What do you think of the above options?
Tricky one. At this stage, you probably want to maintain backwards compatibility at least by enabling an option.

I would prefer to have the single level index, with the string giving hints about dimensionality, e.g. the `inertia_tensor-1-2` or something like `moments_0_0`. Maybe there could even be a naming scheme such as 

`scalar_name` with `name` being the feature name, e.g. mean_intensity
`vector_name_n` (with `n` being an integer)
`mat_name_n_m`
I guess putting the type information in the string is not very clean, but it would allow for fairly easy filtering by the users to narrow it down to the type of features they want.

As you mention `image` and `label_image` are special cases. I have used those to create thumbnails, but I cannot think of lots of use cases where one would want to unravel those into a feature table. 
There should be an easy way to drop these (maybe a simple flag passed as a function argument).
The `scalar_`,  `vector_`, and  `mat_` seems like a very nice solution for most of the use cases I have seen. It then let's you use pandas to easily filter out the columns you want to keep.
It lets you directly use sklearn and also is very easy to save to disk as csv or json which can get tricked up when values are anything but scalars.
Thanks for your comments! Some further thoughts:

- the separator should definitely be hyphen or spaces or something else that is not an underscore, since some feature names have underscores (e.g. `inertia_tensor`).
- I suggest not including `vec` etc in the name. `startswith('vec')` is easily replaced with `len(elem.split('-')) == 1`. Maybe the pandas filtering is marginally more cumbersome but I think that's a worthwhile tradeoff.
- backwards compatibility is a major concern and my proposed solution currently is to make a new function, `regionprops_table(label_image, intensity_image, *, property_list=None)`. That will either return a format compatible with `pd.DataFrame` so that it's trivial to wrap, *or* an option to specify dataframe as a return type. I prefer the former because it's a bit icky to have different return types based on input values. We could add the function `regionprops_dataframe`, also, that is only implemented if pandas is installed. But there should be some real consistency between all the options.
Those suggestions make sense, 
- As for spaces/hyphens I would tend to go with hyphens as `inertial_tensor 2 3 1` seems less intuitive for me than `intertial_tensor-2-3-1`
-  I can now see a few cases where having the `mat_`, `vec_` prefixes might be problematic because in 2D they are vectors and in 3D they could be matrices and so columns with changing prefixes might be annoying. 
- `pd.DataFrame(regionprops_table(label_image, intensity_image))` is certainly easy enough for students to learn. I also agree that having output types change based on input arguments is not preferable and particularly as more teams are using mypy and type annotations this makes life much more difficult for them. I don't think a `regionprops_dataframe` function is that important and having a function that only works when an optional dependency is installed could be confusing.


Fully agree with @kmader here, lots of good points that I hadn't thought about.

Resolving patch:
diff --git a/skimage/measure/__init__.py b/skimage/measure/__init__.py
--- a/skimage/measure/__init__.py
+++ b/skimage/measure/__init__.py
@@ -2,7 +2,7 @@
 from ._marching_cubes_lewiner import marching_cubes_lewiner
 from ._marching_cubes_classic import (marching_cubes_classic,
                                       mesh_surface_area)
-from ._regionprops import regionprops, perimeter
+from ._regionprops import regionprops, perimeter, regionprops_table
 from .simple_metrics import compare_mse, compare_nrmse, compare_psnr
 from ._structural_similarity import compare_ssim
 from ._polygon import approximate_polygon, subdivide_polygon
@@ -19,6 +19,7 @@
 
 __all__ = ['find_contours',
            'regionprops',
+           'regionprops_table',
            'perimeter',
            'approximate_polygon',
            'subdivide_polygon',
diff --git a/skimage/measure/_regionprops.py b/skimage/measure/_regionprops.py
--- a/skimage/measure/_regionprops.py
+++ b/skimage/measure/_regionprops.py
@@ -35,7 +35,11 @@
     'FilledImage': 'filled_image',
     'HuMoments': 'moments_hu',
     'Image': 'image',
+    'InertiaTensor': 'inertia_tensor',
+    'InertiaTensorEigvals': 'inertia_tensor_eigvals',
+    'IntensityImage': 'intensity_image',
     'Label': 'label',
+    'LocalCentroid': 'local_centroid',
     'MajorAxisLength': 'major_axis_length',
     'MaxIntensity': 'max_intensity',
     'MeanIntensity': 'mean_intensity',
@@ -53,10 +57,57 @@
     'WeightedCentralMoments': 'weighted_moments_central',
     'WeightedCentroid': 'weighted_centroid',
     'WeightedHuMoments': 'weighted_moments_hu',
+    'WeightedLocalCentroid': 'weighted_local_centroid',
     'WeightedMoments': 'weighted_moments',
     'WeightedNormalizedMoments': 'weighted_moments_normalized'
 }
 
+OBJECT_COLUMNS = {
+    'image', 'coords', 'convex_image', 'slice',
+    'filled_image', 'intensity_image'
+}
+
+COL_DTYPES = {
+    'area': int,
+    'bbox': int,
+    'bbox_area': int,
+    'moments_central': float,
+    'centroid': int,
+    'convex_area': int,
+    'convex_image': object,
+    'coords': object,
+    'eccentricity': float,
+    'equivalent_diameter': float,
+    'euler_number': int,
+    'extent': float,
+    'filled_area': int,
+    'filled_image': object,
+    'moments_hu': float,
+    'image': object,
+    'inertia_tensor': float,
+    'inertia_tensor_eigvals': float,
+    'intensity_image': object,
+    'label': int,
+    'local_centroid': int,
+    'major_axis_length': float,
+    'max_intensity': float,
+    'mean_intensity': float,
+    'min_intensity': float,
+    'minor_axis_length': float,
+    'moments': float,
+    'moments_normalized': float,
+    'orientation': float,
+    'perimeter': float,
+    'slice': object,
+    'solidity': float,
+    'weighted_moments_central': float,
+    'weighted_centroid': int,
+    'weighted_moments_hu': float,
+    'weighted_local_centroid': int,
+    'weighted_moments': int,
+    'weighted_moments_normalized': float
+}
+
 PROP_VALS = set(PROPS.values())
 
 
@@ -84,7 +135,7 @@ def func2d(self, *args, **kwargs):
     return func2d
 
 
-class _RegionProperties(object):
+class RegionProperties:
     """Please refer to `skimage.measure.regionprops` for more information
     on the available region properties.
     """
@@ -309,7 +360,7 @@ def __getitem__(self, key):
             return getattr(self, PROPS[key])
 
     def __eq__(self, other):
-        if not isinstance(other, _RegionProperties):
+        if not isinstance(other, RegionProperties):
             return False
 
         for key in PROP_VALS:
@@ -323,6 +374,218 @@ def __eq__(self, other):
         return True
 
 
+# For compatibility with code written prior to 0.16
+_RegionProperties = RegionProperties
+
+
+def _props_to_dict(regions, properties=('label', 'bbox'), separator='-'):
+    """Convert image region properties list into a column dictionary.
+
+    Parameters
+    ----------
+    regions : (N,) list
+        List of RegionProperties objects as returned by :func:`regionprops`.
+    properties : tuple or list of str, optional
+        Properties that will be included in the resulting dictionary
+        For a list of available properties, please see :func:`regionprops`.
+        Users should remember to add "label" to keep track of region
+        identities.
+    separator : str, optional
+        For non-scalar properties not listed in OBJECT_COLUMNS, each element
+        will appear in its own column, with the index of that element separated
+        from the property name by this separator. For example, the inertia
+        tensor of a 2D region will appear in four columns:
+        ``inertia_tensor-0-0``, ``inertia_tensor-0-1``, ``inertia_tensor-1-0``,
+        and ``inertia_tensor-1-1`` (where the separator is ``-``).
+
+        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``.
+
+    Returns
+    -------
+    out_dict : dict
+        Dictionary mapping property names to an array of values of that
+        property, one value per region. This dictionary can be used as input to
+        pandas ``DataFrame`` to map property names to columns in the frame and
+        regions to rows.
+
+    Notes
+    -----
+    Each column contains either a scalar property, an object property, or an
+    element in a multidimensional array.
+
+    Properties with scalar values for each region, such as "eccentricity", will
+    appear as a float or int array with that property name as key.
+
+    Multidimensional properties *of fixed size* for a given image dimension,
+    such as "centroid" (every centroid will have three elements in a 3D image,
+    no matter the region size), will be split into that many columns, with the
+    name {property_name}{separator}{element_num} (for 1D properties),
+    {property_name}{separator}{elem_num0}{separator}{elem_num1} (for 2D
+    properties), and so on.
+
+    For multidimensional properties that don't have a fixed size, such as
+    "image" (the image of a region varies in size depending on the region
+    size), an object array will be used, with the corresponding property name
+    as the key.
+
+    Examples
+    --------
+    >>> from skimage import data, util, measure
+    >>> image = data.coins()
+    >>> label_image = measure.label(image > 110, connectivity=image.ndim)
+    >>> proplist = regionprops(label_image, image)
+    >>> props = _props_to_dict(proplist, properties=['label', 'inertia_tensor',
+    ...                                              'inertia_tensor_eigvals'])
+    >>> props  # doctest: +ELLIPSIS +SKIP
+    {'label': array([ 1,  2, ...]), ...
+     'inertia_tensor-0-0': array([  4.012...e+03,   8.51..., ...]), ...
+     ...,
+     'inertia_tensor_eigvals-1': array([  2.67...e+02,   2.83..., ...])}
+
+    The resulting dictionary can be directly passed to pandas, if installed, to
+    obtain a clean DataFrame:
+
+    >>> import pandas as pd  # doctest: +SKIP
+    >>> data = pd.DataFrame(props)  # doctest: +SKIP
+    >>> data.head()  # doctest: +SKIP
+       label  inertia_tensor-0-0  ...  inertia_tensor_eigvals-1
+    0      1         4012.909888  ...                267.065503
+    1      2            8.514739  ...                  2.834806
+    2      3            0.666667  ...                  0.000000
+    3      4            0.000000  ...                  0.000000
+    4      5            0.222222  ...                  0.111111
+
+    """
+
+    out = {}
+    n = len(regions)
+    for prop in properties:
+        dtype = COL_DTYPES[prop]
+        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:
+            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
+            else:
+                shape = (len(r),)
+
+            for ind in np.ndindex(shape):
+                for k in range(n):
+                    loc = ind if len(ind) > 1 else ind[0]
+                    column_buffer[k] = regions[k][prop][loc]
+                modified_prop = separator.join(map(str, (prop,) + ind))
+                out[modified_prop] = np.copy(column_buffer)
+    return out
+
+
+def regionprops_table(label_image, intensity_image=None, cache=True,
+                      properties=('label', 'bbox'), separator='-'):
+    """Find image properties and convert them into a dictionary
+
+    Parameters
+    ----------
+    label_image : (N, M) ndarray
+        Labeled input image. Labels with value 0 are ignored.
+    intensity_image : (N, M) ndarray, optional
+        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
+        increases.
+    coordinates : 'rc' or 'xy', optional
+        Coordinate conventions for 2D images. (Only 'rc' coordinates are
+        supported for 3D images.)
+    properties : tuple or list of str, optional
+        Properties that will be included in the resulting dictionary
+        For a list of available properties, please see :func:`regionprops`.
+        Users should remember to add "label" to keep track of region
+        identities.
+    separator : str, optional
+        For non-scalar properties not listed in OBJECT_COLUMNS, each element
+        will appear in its own column, with the index of that element separated
+        from the property name by this separator. For example, the inertia
+        tensor of a 2D region will appear in four columns:
+        ``inertia_tensor-0-0``, ``inertia_tensor-0-1``, ``inertia_tensor-1-0``,
+        and ``inertia_tensor-1-1`` (where the separator is ``-``).
+
+        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``.
+
+    Returns
+    -------
+    out_dict : dict
+        Dictionary mapping property names to an array of values of that
+        property, one value per region. This dictionary can be used as input to
+        pandas ``DataFrame`` to map property names to columns in the frame and
+        regions to rows.
+
+    Notes
+    -----
+    Each column contains either a scalar property, an object property, or an
+    element in a multidimensional array.
+
+    Properties with scalar values for each region, such as "eccentricity", will
+    appear as a float or int array with that property name as key.
+
+    Multidimensional properties *of fixed size* for a given image dimension,
+    such as "centroid" (every centroid will have three elements in a 3D image,
+    no matter the region size), will be split into that many columns, with the
+    name {property_name}{separator}{element_num} (for 1D properties),
+    {property_name}{separator}{elem_num0}{separator}{elem_num1} (for 2D
+    properties), and so on.
+
+    For multidimensional properties that don't have a fixed size, such as
+    "image" (the image of a region varies in size depending on the region
+    size), an object array will be used, with the corresponding property name
+    as the key.
+
+    Examples
+    --------
+    >>> from skimage import data, util, measure
+    >>> image = data.coins()
+    >>> label_image = measure.label(image > 110, connectivity=image.ndim)
+    >>> props = regionprops_table(label_image, image,
+    ...                           properties=['label', 'inertia_tensor',
+    ...                                       'inertia_tensor_eigvals'])
+    >>> props  # doctest: +ELLIPSIS +SKIP
+    {'label': array([ 1,  2, ...]), ...
+     'inertia_tensor-0-0': array([  4.012...e+03,   8.51..., ...]), ...
+     ...,
+     'inertia_tensor_eigvals-1': array([  2.67...e+02,   2.83..., ...])}
+
+    The resulting dictionary can be directly passed to pandas, if installed, to
+    obtain a clean DataFrame:
+
+    >>> import pandas as pd  # doctest: +SKIP
+    >>> data = pd.DataFrame(props)  # doctest: +SKIP
+    >>> data.head()  # doctest: +SKIP
+       label  inertia_tensor-0-0  ...  inertia_tensor_eigvals-1
+    0      1         4012.909888  ...                267.065503
+    1      2            8.514739  ...                  2.834806
+    2      3            0.666667  ...                  0.000000
+    3      4            0.000000  ...                  0.000000
+    4      5            0.222222  ...                  0.111111
+
+    [5 rows x 7 columns]
+
+    """
+    regions = regionprops(label_image, intensity_image=intensity_image,
+                          cache=cache)
+    return _props_to_dict(regions, properties=properties, separator=separator)
+
+
 def regionprops(label_image, intensity_image=None, cache=True):
     """Measure properties of labeled image regions.
 
@@ -532,8 +795,8 @@ def regionprops(label_image, intensity_image=None, cache=True):
 
         label = i + 1
 
-        props = _RegionProperties(sl, label, label_image, intensity_image,
-                                  cache)
+        props = RegionProperties(sl, label, label_image, intensity_image,
+                                 cache)
         regions.append(props)
 
     return regions
@@ -620,10 +883,10 @@ def _parse_docs():
 def _install_properties_docs():
     prop_doc = _parse_docs()
 
-    for p in [member for member in dir(_RegionProperties)
+    for p in [member for member in dir(RegionProperties)
               if not member.startswith('_')]:
-        getattr(_RegionProperties, p).__doc__ = prop_doc[p]
-        setattr(_RegionProperties, p, property(getattr(_RegionProperties, p)))
+        getattr(RegionProperties, p).__doc__ = prop_doc[p]
+        setattr(RegionProperties, p, property(getattr(RegionProperties, p)))
 
 
 if __debug__:


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
@@ -1,9 +1,11 @@
 import math
-import functools
 
 import numpy as np
+from numpy import array
 from skimage.measure._regionprops import (regionprops, PROPS, perimeter,
-                                          _parse_docs)
+                                          _parse_docs, _props_to_dict,
+                                          regionprops_table, OBJECT_COLUMNS,
+                                          COL_DTYPES)
 from skimage._shared import testing
 from skimage._shared.testing import (assert_array_equal, assert_almost_equal,
                                      assert_array_almost_equal, assert_equal)
@@ -484,3 +486,41 @@ def foo():
         assert len(ds.split('\n')) > 3
     else:
         assert_equal(nr_docs_parsed, 0)
+
+
+def test_props_to_dict():
+    regions = regionprops(SAMPLE)
+    out = _props_to_dict(regions)
+    assert out == {'label': array([1]),
+                   'bbox-0': array([0]), 'bbox-1': array([0]),
+                   'bbox-2': array([10]), 'bbox-3': array([18])}
+
+    regions = regionprops(SAMPLE)
+    out = _props_to_dict(regions, properties=('label', 'area', 'bbox'),
+                         separator='+')
+    assert out == {'label': array([1]), 'area': array([180]),
+                   'bbox+0': array([0]), 'bbox+1': array([0]),
+                   'bbox+2': array([10]), 'bbox+3': array([18])}
+
+
+def test_regionprops_table():
+    out = regionprops_table(SAMPLE)
+    assert out == {'label': array([1]),
+                   'bbox-0': array([0]), 'bbox-1': array([0]),
+                   'bbox-2': array([10]), 'bbox-3': array([18])}
+
+    out = regionprops_table(SAMPLE, properties=('label', 'area', 'bbox'),
+                            separator='+')
+    assert out == {'label': array([1]), 'area': array([180]),
+                   'bbox+0': array([0]), 'bbox+1': array([0]),
+                   'bbox+2': array([10]), 'bbox+3': array([18])}
+
+
+def test_props_dict_complete():
+    region = regionprops(SAMPLE)[0]
+    properties = [s for s in dir(region) if not s.startswith('_')]
+    assert set(properties) == set(PROPS.values())
+
+
+def test_column_dtypes_complete():
+    assert set(COL_DTYPES.keys()).union(OBJECT_COLUMNS) == set(PROPS.values())
\ No newline at end of file