Repository: scikit-image/scikit-image

Issue:
Add ability to calculate moments from coordinates (not only image)
Currently, scikit-image can only calculate moments (and, by implication, hu moments) from full images.  We should extend this to also work for arrays of coordinates.

- https://mail.python.org/pipermail/scikit-image/2017-June/005264.html
- http://scikit-image.org/docs/dev/api/skimage.measure.html#skimage.measure.moments


Discussion:

Resolving patch:
diff --git a/skimage/measure/__init__.py b/skimage/measure/__init__.py
--- a/skimage/measure/__init__.py
+++ b/skimage/measure/__init__.py
@@ -8,7 +8,8 @@
 from ._structural_similarity import compare_ssim
 from ._polygon import approximate_polygon, subdivide_polygon
 from .pnpoly import points_in_poly, grid_points_in_poly
-from ._moments import (moments, moments_central, moments_normalized, centroid,
+from ._moments import (moments, moments_central, moments_coords,
+                       moments_coords_central, moments_normalized, centroid,
                        moments_hu, inertia_tensor, inertia_tensor_eigvals)
 from .profile import profile_line
 from .fit import LineModelND, CircleModel, EllipseModel, ransac
@@ -29,6 +30,8 @@
            'block_reduce',
            'moments',
            'moments_central',
+           'moments_coords',
+           'moments_coords_central'
            'moments_normalized',
            'moments_hu',
            'marching_cubes_lewiner',
diff --git a/skimage/measure/_moments.py b/skimage/measure/_moments.py
--- a/skimage/measure/_moments.py
+++ b/skimage/measure/_moments.py
@@ -1,11 +1,137 @@
 # coding: utf-8
 from __future__ import division
 import numpy as np
+from .._shared.utils import assert_nD
 from . import _moments_cy
 import itertools
 from warnings import warn
 
 
+def moments_coords(coords, order=3):
+    """Calculate all raw image moments up to a certain order.
+
+    The following properties can be calculated from raw image moments:
+     * Area as: ``M[0, 0]``.
+     * Centroid as: {``M[1, 0] / M[0, 0]``, ``M[0, 1] / M[0, 0]``}.
+
+    Note that raw moments are neither translation, scale nor rotation
+    invariant.
+
+    Parameters
+    ----------
+    coords : (N, D) double or uint8 array
+        Array of N points that describe an image of D dimensionality in
+        Cartesian space.
+    order : int, optional
+        Maximum order of moments. Default is 3.
+
+    Returns
+    -------
+    m : (``order + 1``, ``order + 1``) array
+        Raw image moments.
+
+    References
+    ----------
+    .. [1] Johannes Kilian. Simple Image Analysis By Moments. Durham
+           University, version 0.2, Durham, 2001.
+
+    Examples
+    --------
+    >>> coords = np.array([[r, c] for r in range(13, 17) \
+                  for c in range(13, 17)], dtype=np.double)
+    >>> M = moments_coords(coords)
+    >>> cr = M[1, 0] / M[0, 0]
+    >>> cc = M[0, 1] / M[0, 0]
+    >>> cr, cc
+    (14.5, 14.5)
+    """
+    return moments_coords_central(coords, 0, order=order)
+
+
+def moments_coords_central(coords, center=None, order=3):
+    """Calculate all central image moments up to a certain order.
+
+    The following properties can be calculated from raw image moments:
+     * Area as: ``M[0, 0]``.
+     * Centroid as: {``M[1, 0] / M[0, 0]``, ``M[0, 1] / M[0, 0]``}.
+
+    Note that raw moments are neither translation, scale nor rotation
+    invariant.
+
+    Parameters
+    ----------
+    coords : (N, D) double or uint8 array
+        Array of N points that describe an image of D dimensionality in
+        Cartesian space.
+    center : tuple of float, optional
+        Coordinates of the image centroid. This will be computed if it
+        is not provided.
+    order : int, optional
+        Maximum order of moments. Default is 3.
+
+    Returns
+    -------
+    m : (``order + 1``, ``order + 1``) array
+        Raw image moments.
+
+    References
+    ----------
+    .. [1] Johannes Kilian. Simple Image Analysis By Moments. Durham
+           University, version 0.2, Durham, 2001.
+
+    Examples
+    --------
+    >>> coords = np.array([[r, c] for r in range(13, 17) \
+                  for c in range(13, 17)], dtype=np.double)
+    >>> M = moments_coords(coords)
+    >>> cr = M[1, 0] / M[0, 0]
+    >>> cc = M[0, 1] / M[0, 0]
+    >>> moments_coords_central(coords, (cr, cc))
+    array([[ 16.,   0.,  20.,   0.],
+           [  0.,   0.,   0.,   0.],
+           [ 20.,   0.,  25.,   0.],
+           [  0.,   0.,   0.,   0.]])
+    """
+    if isinstance(coords, tuple):
+        # Represent as array and rotate to standardize orientation.
+        coords = np.transpose(coords)
+    assert_nD(coords, 2)
+    ndim = coords.shape[1]
+    if center is None:
+        center = np.mean(coords, axis=0)
+    else:
+        # TODO: Handle broadcasting of `center` argument.
+        if type(center) not in [tuple, list, np.ndarray]:
+            center = (center,) * ndim
+
+    # center the coordinates
+    coords = coords.astype(float) - center
+
+    # generate all possible exponents for each axis in the given set of points
+    # produces a matrix of shape (N, D, order + 1)
+    coords = coords[..., np.newaxis] ** np.arange(order + 1)
+
+    # add extra dimensions for proper broadcasting
+    coords = coords.reshape(coords.shape + (1,) * (ndim - 1))
+
+    calc = 1
+
+    for axis in range(ndim):
+        # isolate each point's axis
+        isolated_axis = coords[:, axis::ndim].squeeze(axis=1)
+
+        # rotate orientation of matrix for proper broadcasting
+        isolated_axis = np.moveaxis(isolated_axis, 1, 1 + axis)
+
+        # calculate the moments for each point, one axis at a time
+        calc = calc * isolated_axis
+
+    # sum all individual point moments to get our final answer
+    calc = calc.sum(axis=0)
+
+    return calc
+
+
 def moments(image, order=3):
     """Calculate all raw image moments up to a certain order.
 
@@ -18,7 +144,7 @@ def moments(image, order=3):
 
     Parameters
     ----------
-    image : 2D double or uint8 array
+    image : nD double or uint8 array
         Rasterized shape as image.
     order : int, optional
         Maximum order of moments. Default is 3.
@@ -63,7 +189,7 @@ def moments_central(image, center=None, cc=None, order=3, **kwargs):
 
     Parameters
     ----------
-    image : 2D double or uint8 array
+    image : nD double or uint8 array
         Rasterized shape as image.
     center : tuple of float, optional
         Coordinates of the image centroid. This will be computed if it


Test patch:
diff --git a/skimage/measure/tests/test_moments.py b/skimage/measure/tests/test_moments.py
--- a/skimage/measure/tests/test_moments.py
+++ b/skimage/measure/tests/test_moments.py
@@ -1,7 +1,8 @@
 from __future__ import division
 import numpy as np
 from skimage import draw
-from skimage.measure import (moments, moments_central, moments_normalized,
+from skimage.measure import (moments, moments_central, moments_coords,
+                             moments_coords_central, moments_normalized,
                              moments_hu, centroid)
 
 from skimage._shared import testing
@@ -58,6 +59,42 @@ def test_moments_central_deprecated():
     assert_almost_equal(mu1.T, mu_ref)
 
 
+def test_moments_coords():
+    image = np.zeros((20, 20), dtype=np.double)
+    image[13:17, 13:17] = 1
+    mu_image = moments(image)
+
+    coords = np.array([[r, c] for r in range(13, 17)
+                       for c in range(13, 17)], dtype=np.double)
+    mu_coords = moments_coords(coords)
+    assert_almost_equal(mu_coords, mu_image)
+
+
+def test_moments_central_coords():
+    image = np.zeros((20, 20), dtype=np.double)
+    image[13:17, 13:17] = 1
+    mu_image = moments_central(image, (14.5, 14.5))
+
+    coords = np.array([[r, c] for r in range(13, 17)
+                       for c in range(13, 17)], dtype=np.double)
+    mu_coords = moments_coords_central(coords, (14.5, 14.5))
+    assert_almost_equal(mu_coords, mu_image)
+
+    # ensure that center is being calculated normally
+    mu_coords_calc_centroid = moments_coords_central(coords)
+    assert_almost_equal(mu_coords_calc_centroid, mu_coords)
+
+    # shift image by dx=3 dy=3
+    image = np.zeros((20, 20), dtype=np.double)
+    image[16:20, 16:20] = 1
+    mu_image = moments_central(image, (14.5, 14.5))
+
+    coords = np.array([[r, c] for r in range(16, 20)
+                       for c in range(16, 20)], dtype=np.double)
+    mu_coords = moments_coords_central(coords, (14.5, 14.5))
+    assert_almost_equal(mu_coords, mu_image)
+
+
 def test_moments_normalized():
     image = np.zeros((20, 20), dtype=np.double)
     image[13:17, 13:17] = 1
@@ -74,11 +111,15 @@ def test_moments_normalized():
 
 def test_moments_normalized_3d():
     image = draw.ellipsoid(1, 1, 10)
-    mu = moments_central(image)
-    nu = moments_normalized(mu)
+    mu_image = moments_central(image)
+    nu = moments_normalized(mu_image)
     assert nu[0, 0, 2] > nu[0, 2, 0]
     assert_almost_equal(nu[0, 2, 0], nu[2, 0, 0])
 
+    coords = np.where(image)
+    mu_coords = moments_coords_central(coords)
+    assert_almost_equal(mu_coords, mu_image)
+
 
 def test_moments_normalized_invalid():
     with testing.raises(ValueError):