Orientation correlations among rice grains, part 7: analysis of the shape of the grains

$\newcommand{\tens}{\mathbf}\newcommand{\D}{\mathrm{d}}$

In the previous instalment of this series, we have segmented the 3D image of the assembly of rice grains. In other words, each voxel of the image is attributed the label of the grain to which it belongs. Remember the initial goal of this series: we want to quantify orientation correlations between grains. To do so, we must analyse the orientation of each individual grain. This is the topic of the present post.

It is customary to define the orientation of an elongated object by means of the eigenvectors of the tensor of second moments, defined as follows

$$J_{ij} = \int_\text{Grain}\bigl(x_i-X_i\bigr)\bigl(x_j-X_j\bigr)\D x_1\,\D x_2\,\D x_3,\tag{1}$$

where the integral is carried out over the grain, and $X_i$ denotes the $i$-th coordinate of the grain's center of mass

$$X_i = \frac1V\int_\text{Grain}x_i\,\D x_1\,\D x_2\,\D x_3$$

($V$: volume of the grain). In coordinate-free form, Eq. (1) reads

$$\tens J=\int_\text{Grain}\bigl(\vec x-\vec X\bigr)\otimes\bigl(\vec x-\vec X\bigr)\D x_1\,\D x_2\,\D x_3.$$

The above defined tensor of second moments is related to the inertia tensor $\tens I$

$$\tens I=\operatorname{tr}\tens J\,\tens\delta-\tens J,\tag{2}$$

where $\tens I$ is defined as follows

$$\tens I=\int_\text{Grain}\bigl[\bigl(\vec x-\vec X\bigr)\cdot\bigl(\vec x-\vec X\bigr)\tens\delta-\bigl(\vec x-\vec X\bigr)\otimes\bigl(\vec x-\vec X\bigr)\bigr]\D x_1\,\D x_2\,\D x_3.$$

Being symmetric, the tensor $\tens J$ of second moments is diagonalizable, and we compute its eigenvalues $J_a$, $J_b$ and $J_c$, and the associated eigenvectors $\vec v_a$, $\vec v_b$ and $\vec v_c$

$$\tens J\cdot\vec v_\alpha=J_\alpha\vec v_\alpha,$$

where $\alpha=a, b, c$. In the present post, we define the orientation of the grain as the eigenvector associated to the largest eigenvalue. We can further define the equivalent ellipsoid as the ellipsoid with same volume and principal second moments. It can readily be verified that the volume and principal second moments of an ellipsoid are

$$V=\frac{4\pi}3 abc,\quad J_a=\frac{Va^2}5,\quad J_b=\frac{Vb^2}5,\quad J_c=\frac{Vc^2}5,$$

where $a$, $b$ and $c$ are the radii of the ellipsoid. The above expressions can be retrieved from Wikipedia and Eq. (2). For example

$$J_a = \frac12\bigl(I_b+I_c-I_a\bigr).$$

Then, the radii of the equivalent ellipsoid are retrieved as follows from the volume $V$ and the principal second moments $J_a$, $J_b$ and $J_c$ of the grain

$$a=\sqrt{\frac{5I_a}V},\quad b=\sqrt{\frac{5I_b}V},\quad c=\sqrt{\frac{5I_c}V}.$$

The radii of the equivalent ellipsoid can be used to characterize the size of the grains. In the present post, we compute for each grain: the volume, the center of mass, the tensor of second moments, the orientation and the radii of the equivalent ellipsoid. We will then perform rudimentary analysis of these morphological parameters.

As usual, we will use Python to carry out the dirty work. We will start with a very naive approach and present a nearly loop-free approach using the clever scipy.ndimage.sum function. We start with loading the segmented images.

import os.path

import h5py
import matplotlib as mpl
import matplotlib.figure
import numpy as np
import scipy.ndimage

#plt.style.use('../include/zenburn-light.mplstyle')

filename = os.path.join("/media/sf_sbrisard/Documents/tmp/rice-bin_4x4x4.hdf5")

with h5py.File(filename, "r") as f:
    labels = np.asarray(f["labels"])

indices = np.unique(labels)
# Discard index 0, which is the background
indices = indices[1:]

Morphological description of the grains

Direct computation of the morphological parameters of grain 42

The direct (naive) approach presented in this section will serve as a reference for a better approach presented in the next section. It will be illustrated on one grain only (namely grain 42), which we first locate. The remainder of the analysis is then restricted to a ROI surrounding the selected grain.

index = 42
slices = scipy.ndimage.find_objects(labels)[index-1]
roi = labels[slices]

The grain shape is then defined by mask, an array of booleans, where all voxels of the ROI that belong to the grain are set to True.

mask = roi == index

The volume (in voxels) of the grain is the sum of the above array

vol_ref = mask.sum()
print('Volume of grain {} = {} vox^3.'.format(index, vol_ref))
Volume of grain 42 = 7087 vox^3.

To compute the center of mass and inertia of the grain, we must define the coordinates of each voxel of the grain. To do so, we use the mgrid function

coords = np.mgrid[slices].astype(np.float64)

The center of mass of the grain is the sum of these coordinates divided by the total volume

com_ref = np.sum(mask*coords, axis=(-1, -2, -3))/vol_ref
print('Center of mass of grain {} = {} vox'.format(index, com_ref))
Center of mass of grain 42 = [  4.92069987 191.41597291 103.69183011] vox

Note that we pre-multiplied coords by mask in order to keep only those voxels that belong to the grain. To compute the inertia of the grain, we first subtract the coordinates of the center of mass from the voxel coordinates.

coords -= com_ref[:, None, None, None]

We then compute the array of coordinates cross-products coords_xprod defined as follows

coords_xprod[m, n, i, j, k] = coords[m, i, j, k]*coords[n, i, j, k]

(i, j, k: voxel indices; m, n: coordinates indices). The coords_xprod array is produced by the following line of code

coords_xprod = coords[None, ...]*coords[:, None, ...]

and we find

moments2_ref = np.sum(mask*coords_xprod, axis=(-1, -2,-3))
print('Moments of inertia of grain {} (vox^5)'.format(index))
print(moments2_ref)
Moments of inertia of grain 42 (vox^5)
[[ 71603.43332863 -45107.22322562 -71060.19147735]
 [-45107.22322562 426435.7118668  181708.48483138]
 [-71060.19147735 181708.48483138 880736.95696345]]

This is a bit tedious, isn't it? Besides, we should normally loop over the grains in order to carry out the analysis for all grains. Comes the wonderful scipy.ndimage.sum function to the rescue!

Using the scipy.ndimage.sum function

This function will allow us to carry out the analysis over all grains simultaneously. We start with the volume, which is seen as

$$V=\sum_\text{Grain} 1,$$

where the sum is carried over all voxels of each grain.

ones = np.ones_like(labels, dtype=np.float64)
vol = scipy.ndimage.sum(ones, labels, indices)

And we can check that the value we found for grain 42 is correct

print('Volume of grain {}'.format(index))
print('    expected   = {}'.format(vol_ref))
print('    actual     = {}'.format(vol[index-1]))
Volume of grain 42
    expected   = 7087
    actual     = 7087.0

For the center of mass, we will use the center_of_mass function rather than the sum function.

com = np.asarray(scipy.ndimage.center_of_mass(ones, labels, indices))
print('Center of mass of grain {}'.format(index))
print('    expected   = {}'.format(com_ref))
print('    actual     = {}'.format(com[index-1]))
Center of mass of grain 42
    expected   = [  4.92069987 191.41597291 103.69183011]
    actual     = [  4.92069987 191.41597291 103.69183011]

Finally, the second moments are seen as the following sum

$$J_{ij}=\sum_\text{Grain}(x_i-X_i)(x_j-X_j)=\sum_\text{Grain}x_ix_j-VX_iX_j,$$

where the last identity is known as the parallel axis theorem. Implementation of this formulation is straightforward, starting from the construction of the array of voxel coordinates coords.

coords = np.mgrid[[slice(n) for n in labels.shape]]
moments2 = np.empty((indices.size, 3, 3), dtype=np.float64)

for i in range(3):
    for j in range(3):
        xi, xj = coords[(i, j), :]
        moments2[:, i, j] = (scipy.ndimage.sum(xi*xj, labels, indices) -
                             vol*com[:, i]*com[:, j])

Nota: I could not find a pythonic way to get rid of this uggly nested loop…

We can again check that the result is correct for grain 42.

print('Second moments of grain {}'.format(index))
print('    expected =')
print(moments2_ref)
print('    actual =')
print(moments2[index-1])
Second moments of grain 42
    expected =
[[ 71603.43332863 -45107.22322562 -71060.19147735]
 [-45107.22322562 426435.7118668  181708.48483138]
 [-71060.19147735 181708.48483138 880736.95696345]]
    actual =
[[ 71603.43332863 -45107.22322562 -71060.19147735]
 [-45107.22322562 426435.7118668  181708.48483139]
 [-71060.19147735 181708.48483139 880736.95696346]]

That's it! We have computed the second moments of each grain. See how this sum function is convenient? We are now ready to compute the orientation of each grain, as well as the radii of the equivalent ellipsoid. We first compute the eigenvalues and eigenvectors of the tensors of second moments.

moments2_eigvals, moments2_eigvecs = np.linalg.eig(moments2)

The orientation $\vec n$ of the grain is defined as the eigenvector associated with the largest principal second moment.

i = np.argmax(moments2_eigvals, axis=1)
rows = moments2.shape[0]
n = moments2_eigvecs[np.arange(rows), 0:3, i]
print(n)
[[ 0.01955891  0.05286465 -0.99841012]
 [-0.17300079 -0.67355818 -0.71860288]
 [-0.49498664 -0.84077194  0.21929608]
 ...
 [-0.14766036  0.2963219  -0.94360466]
 [ 0.          1.          0.        ]
 [-0.1722074   0.98321974  0.06019601]]

The following code snippet computes the radii of each grain, and sorts them in ascending order.

radius = np.sort(np.sqrt(5*moments2_eigvals/vol[:, None]))

We can now proceed with the analysis of the results. This will be done more thoroughly in the next instalment of this series. The present post will be restricted to basic analyses. But first of all, it is time to save our results! with h5py.File(filename, 'r+') as f: f['volume'] = vol f['center_of_mass'] = com f['radii'] = radius f['orientation'] = n

Analysis of the results

Volume of grains

fig = mpl.figure.Figure(figsize=(8, 3))
ax = fig.add_subplot(1, 1, 1)
ax.set_xlabel(u'V (vox³)')
ax.set_ylabel('Number')
ax.hist(vol, range=(0, 12000), bins=30, linewidth=0)
fig.tight_layout()
fig.savefig('./volume_histogram.png', transparent=True)
/usr/lib/python3/dist-packages/matplotlib/tight_layout.py:231: UserWarning: tight_layout : falling back to Agg renderer
  warnings.warn("tight_layout : falling back to Agg renderer")

Volume Most grains have a volume comprised between 6000 and 8000 vox³. It is observed that a significant number of grains are very small. There are two possible explanations for this

  1. some grains where broken in smaller pieces,
  2. our segmentation was not perfect (over-segmentation might have occured).

My guess is that it is in fact a little bit of both. One possible remedy would be to filter out those grains that are too small in the subsequent analysis. We will not go into such degree of refinment.

Size of the grains

In this section we visualize the radii of the equivalent ellipsoids

fig = mpl.figure.Figure(figsize=(8, 3))
ax = fig.add_subplot(1, 1, 1)
ax.hist(radius[:, -1], range=(0, 40), bins=40, linewidth=0)
ax.set_xlabel('a (vox)')
ax.set_ylabel('Number')
fig.tight_layout()
fig.savefig('radius_a_histogram.png', transparent=True)

fig = mpl.figure.Figure(figsize=(8, 3))
ax = fig.add_subplot(1, 1, 1)
ax.hist((radius[:, -2], radius[:-1]), range=(0, 20), bins=20, linewidth=0)
ax.set_xlabel('b, c (vox)')
ax.set_ylabel('Number')
fig.tight_layout()
fig.savefig('radii_b_c_histogram.png', transparent=True)
/usr/lib/python3/dist-packages/matplotlib/tight_layout.py:231: UserWarning: tight_layout : falling back to Agg renderer
  warnings.warn("tight_layout : falling back to Agg renderer")

Radius aRadius b, c The length of most grains is about 2×27=54 pixels (about 6.5 mm). Also, the grains are not spheroids: indeed $b\neq c$.

Orientation of grains

This will be the topic of the next instalment of this series. We will only check for possible anisotropy by analysing the following second-order orientation tensor: $\langle\vec n\otimes\vec n\rangle$, where angle brackets stand for ensemble average. It can readily be verified that for isotropic distributions, this tensor is diagonal

$$\langle\vec n\otimes\vec n\rangle= \frac 13 \begin{bmatrix} 1 & 0 & 0\\ 0 & 1 & 0\\ 0 & 0 & 1 \end{bmatrix}.$$

Any deviation from this diagonal tensor indicates anisotropy (the converse is not true!). Computation of this orientation tensor is easy

nn = n[:, None, :]*n[:, :, None]
nn_avg = nn.mean(axis=0)
print(nn_avg)
[[ 0.27082401  0.02535001 -0.02444013]
 [ 0.02535001  0.34864242 -0.00637534]
 [-0.02444013 -0.00637534  0.38053357]]

Which shows that the distribution of grains is not isotropic. Analysis of the eigenvalues of the orientation tensor would show that the vertical direction is in fact a direction of anisotropy (which should not come as a surprise)… but we will leave it like that for now!

Conclusion

In this post, we have analysed the orientation of each grain. The orientation was defined as the orientation of the major axis of the equivalent ellipsoid. We are now ready to analyse orientation correlations among rice grains, which will be the topic of the next instalment.

Comments

Please send your comments to sebastien [dot] brisard [at] univ [dash] eiffel [dot] fr. Your comments will be inserted below. Your email address will not appear.