A grain is a phase-homogeneous, spatially connected region of EBSD pixels produced by
segmentation. A phase change between neighbouring pixels is always a grain boundary.
Within one phase, the usual segmentation rule asks whether the orientations differ by
more than a chosen angle. The command that applies these rules is calcGrains.
This page assumes that you can select and plot an EBSD map. If the angle between two orientations is new to you, first see Misorientation Theory.
Three settings shape the result. They are the boundary misorientation angle, the treatment of measurements that could not be indexed, and the minimum number of pixels returned as a grain. They are segmentation choices, not properties measured independently from the specimen. We take them one at a time on a map that exercises all three.
import matplotlib.pyplot as plt
import numpy as np
from mtex import *
# import the data
plottingConvention.default('y↑→x')
ebsd = mtexdata('forsterite')
# restrict it to a subregion of interest
ebsd = ebsd[ebsd.inpolygon(np.array([5, 2, 10, 5]) * 1e3)]
ebsd
EBSD (y↑→x)
size: 20301
Phase Orientations Mineral Color Symmetry Crystal reference frame
0 4052 (20%) notIndexed
1 14093 (69%) Forsterite LightSkyBlue mmm Forsterite
2 1397 (6.9%) Enstatite DarkSeaGreen mmm Enstatite
3 759 (3.7%) Diopside Goldenrod 12/m1 Diopside
properties : bands, bc, bs, error, mad
scan unit : um
X × Y : [5000 → 1.5e+04] × [2000 → 7000]
square lattice: spacing 50
# make a phase plot
plot(ebsd, micronbar=False)

The colours are the phases: forsterite, enstatite and diopside. The white speckle is the
first phase in the displayed summary, notIndexed. Its diffraction patterns could not be
indexed. One measurement in five on this map is notIndexed. Most lie where boundaries
will be reconstructed.
A first reconstruction
We choose a 10 degree threshold and leave alpha and minPixel at their defaults. The
displayed grain2d summary is useful here: it reports the number of grains and their
phases.
grains = calcGrains(ebsd, angle=10 * degree)
grains
grain2d (y↑→x)
size: 211
Phase Grains Mineral Color Symmetry Crystal reference frame
0 1 (259 pixels) notIndexed
1 107 (14093 pixels) Forsterite LightSkyBlue mmm Forsterite
2 32 (1397 pixels) Enstatite DarkSeaGreen mmm Enstatite
3 71 (759 pixels) Diopside Goldenrod 12/m1 Diopside
boundary segments: 4075, inner: 1, triple points: 248
Each grain is one entry of the list, with its own phase, mean orientation, size and
shape. The boundaries are stored separately in grains.boundary, which we plot on top of
the map.
plot(ebsd, micronbar=False)
hold(True)
plot(grains.boundary, lineWidth=1.5)
hold(False)

MTEX first partitions the measured surface into one spatial cell per measurement. Neighbouring cells are then connected or separated by the segmentation rule, and connected cells are collected into grains. On this regular grid, the cell edges lie between measurement points. The plotted boundaries therefore follow the grid in steps rather than passing through the points.
Although a fifth of the map is notIndexed, only one grain is notIndexed: the long white
bar right of centre. The other notIndexed pixels have been absorbed by adjacent grains.
The section on alpha below explains this default behaviour.
The threshold angle
The option angle sets the misorientation above which two neighbouring measurements of
the same phase are separated by a boundary. Its default is 15 degrees. Values between 10
and 15 degrees are long-standing conventions, not measurements; see the discussion in
the chapter opener.
On a recrystallised map like this one the exact value hardly matters.
for threshold in np.array([2, 5, 10, 15]) * degree:
g = calcGrains(ebsd, angle=threshold)
print(f'{round(threshold / degree):2d} degree threshold: {len(g["indexed"]):3d} indexed grains')
2 degree threshold: 256 indexed grains
5 degree threshold: 227 indexed grains
10 degree threshold: 210 indexed grains
15 degree threshold: 208 indexed grains
Between 5 and 15 degrees the printed count changes by less than one tenth. It rises sharply only at 2 degrees. Neighbouring measurements on this map are usually either far below all these thresholds or far above them. Few ambiguous pairs lie in between. This is a property of the material, not of the algorithm. It stops being true in the deformed example later on.
Measurements that were not indexed
A notIndexed measurement is not missing from the map. Its diffraction pattern could not
be indexed, and MTEX records that fact as the degenerate phase notIndexed. Like any
other phase, a connected notIndexed area can form a grain with a boundary around it.
Whether this is what you want depends on the patch. A wide unindexed region is part of the specimen about which you know nothing. It should remain a grain of its own. A one-pixel-wide seam along a grain boundary instead records indexing failure where two lattices overlap. Leaving such a seam creates a spurious grain between the indexed grains on either side.
The option alpha controls the spatial closing that distinguishes these cases. Its value
is a radius in multiples of the pixel spacing. A notIndexed area narrower than about
2*alpha pixel spacings is absorbed by the surrounding grains, while a wider area
survives. The default is alpha = 3.1.
for alpha in [0, 1, 3.1, 6]:
g = calcGrains(ebsd, angle=10 * degree, alpha=alpha)
print(f'alpha = {alpha:3.1f}: {len(g):4d} grains, {len(g["notIndexed"]):4d} of them notIndexed')
alpha = 0.0: 1589 grains, 1064 of them notIndexed
alpha = 1.0: 317 grains, 52 of them notIndexed
alpha = 3.1: 211 grains, 1 of them notIndexed
alpha = 6.0: 209 grains, 0 of them notIndexed
With alpha = 0 nothing is absorbed. The notIndexed pixels then contribute more than one
thousand grains of their own, five times the number of indexed grains found with the
default. Their seams also divide regions that should be single indexed grains, so the
indexed count rises from 210 to 525. A closing radius of one pixel removes almost all of
them. The default keeps only the wide notIndexed region, while alpha = 6 absorbs that
one too.
The effect is easiest to see when the two extremes are drawn on the same part of the map.
region = np.array([5, 2, 2, 1.5]) * 1e3
ebsdSub = ebsd[ebsd.inpolygon(region)]
grainsSharp = calcGrains(ebsd, angle=10 * degree, alpha=0)
newMtexFigure(layout=[1, 2])
plot(ebsdSub, micronbar=False)
hold(True)
plot(grainsSharp.boundary, lineWidth=1.5)
hold(False)
plt.xlim(region[0], region[0] + region[2]), plt.ylim(region[1], region[1] + region[3])
nextAxis()
plot(ebsdSub, micronbar=False)
hold(True)
plot(grains.boundary, lineWidth=1.5)
hold(False)
plt.xlim(region[0], region[0] + region[2]), plt.ylim(region[1], region[1] + region[3])
((5000.0, 7000.0), (2000.0, 3500.0))

On the left, at alpha = 0, isolated white pixels are fenced off and white seams cut the
blue forsterite into pieces. On the right, at the default, the same white measurements
remain visible and remain notIndexed, but they no longer separate the surrounding grains.
The isolated orange pixels keep boundaries on both sides because they are indexed
diopside. The minPixel option below deals with such small indexed grains.
Small indexed grains
Even with the unindexed seams absorbed, a threshold criterion produces grains of one, two
or three pixels where a few measurements are mis-indexed. On this map the isolated
indexed islands are implausible, but a small grain can be real in another specimen.
Inspect the map before choosing a cutoff. The option minPixel removes an indexed grain
below the cutoff from the returned list. It marks that grain’s measurements notIndexed
rather than merging them into a neighbour. Since it marks them in the map it was given,
every step of the sweep below works on a copy of the map.
for minPixel in [1, 5, 10]:
g = calcGrains(ebsd.copy(), angle=10 * degree, minPixel=minPixel)
print(f'minPixel = {minPixel:2d}: {len(g["indexed"]):3d} indexed grains, '
f'holding {100 * np.sum(g["indexed"].numPixel) / np.count_nonzero(ebsd.isIndexed):4.1f}% of the indexed pixels')
minPixel = 1: 210 indexed grains, holding 100.0% of the indexed pixels
minPixel = 5: 90 indexed grains, holding 99.0% of the indexed pixels
minPixel = 10: 71 indexed grains, holding 98.2% of the indexed pixels
More than half of the indexed grains contain fewer than five pixels. Yet together they hold only about one indexed measurement in one hundred. Removing them changes count-based grain statistics greatly. It scarcely changes the large-scale microstructure visible in the map. For a quantitative study, report the cutoff and test nearby values.
Smoothing the boundaries
Because the boundaries run between measurement points, they follow the measurement grid
in steps - the staircase effect. This is a property of the reconstruction, not of the
material. It biases measurements such as boundary length and direction. The command
smoothBoundary first simplifies the staircase, then resamples the boundary at even
spacing, and finally applies a smoothing filter. With the default filter, its numeric
argument is the number of Laplacian smoothing iterations.
grains = calcGrains(ebsd, angle=10 * degree, minPixel=5)
grains = smoothBoundary(grains, 5)
plot(ebsd, micronbar=False)
hold(True)
plot(grains.boundary, lineWidth=1.5)
hold(False)

The steps are gone while the larger-scale course of each boundary remains. Smoothing is still a measurement choice rather than recovered sub-pixel truth, and the default filter can shrink grains. How far to smooth and which filter to use are the subject of Grain Boundary Smoothing.
Keeping map and grains together
calcGrains writes one per-pixel property into the map it was given. The grainId
property records which grain contains each measurement. Almost every map-to-grain
operation in this chapter needs it, and it is there from the first reconstruction on.
grains = calcGrains(ebsd, angle=10 * degree, minPixel=5)
# the measurements inside the largest grain
id = np.argmax(grains.numPixel)
ebsd[grains[id]]
EBSD (y↑→x)
size: 1624
Phase Orientations Mineral Color Symmetry Crystal reference frame
1 1624 (100%) Forsterite LightSkyBlue mmm Forsterite
properties : bands, bc, bs, error, grainId, mad
scan unit : um
X × Y : [5350 → 7750] × [3750 → 7000]
square lattice: spacing 50
The displayed summary belongs to the measurements in the largest grain, the notIndexed
pixels the closing absorbed among them, since they keep their phase. The grain object in
ebsd[grains[id]] supplies its stored grain id. That id need not equal its position in
a shortened or reordered grain list. Selecting Grains develops
this distinction.
The map contains the same measurement positions, but it is not an untouched copy of the
import. Removal through minPixel rewrites the phase of the affected measurements.
Reconstructing grains from this map a second time is therefore not meaningful; keep
ebsd.copy() where the original is still needed.
Grain reconstruction in heavily deformed microstructures
Everything above rests on one assumption: that a misorientation between two neighbouring pixels means the same thing everywhere on the map. A single threshold should then separate “inside a grain” from “across a grain boundary”. In a heavily deformed material that assumption fails from both sides. Inside a grain the lattice is bent. Small neighbour-to-neighbour changes can accumulate to tens of degrees across it. Between two grains, the misorientation may be below any threshold one would call high angle.
We use an austenitic steel deformed in situ. It was indexed by spherical pattern matching rather than by the Hough transform. Its orientation noise is about 0.1 degree. This is roughly one order of magnitude below that of a typical Hough-indexed map, so the deformation substructure is resolved.
plottingConvention.default('y↓→x')
ebsd = mtexdata('EMSphinx')
# the deformed austenite
ebsd = ebsd['Iron fcc']
plot(ebsd, ebsd.orientations, ipfDirection=zvector)

The colour gradients within the elongated grains show the bent lattice. We zoom into a smaller region to see what a threshold makes of it.
region = [40, 30, 80, 60]
ebsd = ebsd[ebsd.inpolygon(region)]
plot(ebsd, ebsd.orientations, ipfDirection=zvector)

At the usual 10 degree threshold, the reconstruction misses every boundary below that angle. The figure shows several such low-angle boundaries with no black line on them.
grains = smoothBoundary(calcGrains(ebsd, angle=10 * degree, minPixel=10), 5)
plot(ebsd, ebsd.orientations, ipfDirection=zvector)
hold(True)
plot(grains.boundary, lineWidth=1.5)
hold(False)

Lowering the threshold does not solve the problem. Before it reaches all the boundary angles of interest, it cuts through the bent lattice inside grains. The dense black lines in the next figure follow contours of the smooth orientation field rather than physical grain boundaries.
grains = smoothBoundary(calcGrains(ebsd, angle=0.5 * degree, minPixel=10), 5)
plot(ebsd, ebsd.orientations, ipfDirection=zvector)
hold(True)
plot(grains.boundary, lineWidth=1.5)
hold(False)

Fast multiscale clustering
The way out is to stop asking about pixel pairs in isolation. Fast multiscale clustering,
gbcFMC, builds a hierarchy of progressively coarser pixel aggregates. It fits the
lattice gradient within each aggregate. It then compares the residual misorientation
between aggregates with their internal orientation spread. A 1 degree step between two
uniform aggregates can therefore be a boundary. A comparable step explained by bending
within one grain is not. FMC has no threshold angle.
The option fmc selects this criterion. Its value is cmaha. This controls how sharply
an unexpected residual misorientation suppresses the coupling between two aggregates.
Larger values return more grains.
grains = smoothBoundary(calcGrains(ebsd.copy(), fmc=0.5, minPixel=10), 5)
plot(ebsd, ebsd.orientations, ipfDirection=zvector)
hold(True)
plot(grains.boundary, lineWidth=1.5)
hold(False)

The low-angle boundaries missed by the 10 degree threshold now appear, without the dense spurious contours produced by the 0.5 degree threshold.
Raising cmaha resolves more of the substructure within those grains, including the
dislocation cells that carry the deformation.
grains = smoothBoundary(calcGrains(ebsd.copy(), fmc=1.5, minPixel=10), 5)
plot(ebsd, ebsd.orientations, ipfDirection=zvector)
hold(True)
plot(grains.boundary, lineWidth=1.5)
hold(False)

Finally, we apply the same reconstruction to the full map. Unlike a local threshold
criterion, FMC clusters the entire map at once rather than one pixel pair at a time,
which is why this takes a few seconds. The verbose flag prints how far the hierarchy
coarsened and the scales from which the final grains were read.
ebsd = mtexdata('EMSphinx')['Iron fcc']
grains = smoothBoundary(calcGrains(ebsd, fmc=1.5, minPixel=10, verbose=True), 5)
plot(ebsd, ebsd.orientations, ipfDirection=zvector)
hold(True)
plot(grains.boundary)
hold(False)
fast multiscale clustering of Iron fcc
478052 pixels, noise 0.108 degree
cmaha 1.5, cmaha0 0.05, quatmax 5, alpha 0.2, gammaW 10, minPixel 10
scale aggregates gradients pixels read
---------------------------------------------
1 478052 - -
2 189100 0 979
3 82866 111 2213
4 36302 11719 5678
5 16044 12238 11190
6 7562 6739 24319
7 4069 3574 42784
8 2654 2208 68926
9 2148 1715 72031
10 1972 1540 89716
11 1919 1488 85767
12 1909 1478 34946
13 1907 1476 36056
14 1907 1476 0
3447 pixels read off at no scale
6553 regions -> 1903 grains, 7017 pixels absorbed into a neighbour, 5 left unassigned

More ways to reconstruct grains
The threshold angle and fast multiscale clustering are two of several criteria by which
calcGrains can separate neighbouring pixels. MTEX represents them as interchangeable
grainBoundaryCriterion objects.
Advanced Grain Reconstruction explains
phase-dependent and soft thresholds. It also covers segmentation by another property and
custom criteria. Markovian Clustering turns criterion
weights into grains in a second way, by clustering the map instead of taking connected
components.
Continue with Plotting Grains, then
Selecting Grains. The latter uses the grainId relationship
established above. The resulting boundary network is the subject of
Grain Boundaries.
References
-
F. Bachmann, R. Hielscher and H. Schaeben, “Grain detection from 2d and 3d EBSD data - Specification of the MTEX algorithm”, Ultramicroscopy 111 (2011), 1720-1733, doi:10.1016/j.ultramic.2011.08.002. This paper derives the Voronoi-cell reconstruction used by
calcGrains. -
C. McMahon et al., “Boundary identification in EBSD data with a generalization of fast multiscale clustering”, Ultramicroscopy 133 (2013), 16-25, doi:10.1016/j.ultramic.2013.04.009.
-
R. Hielscher, F. Bartel and T. B. Britton, “Gazing at crystal balls: Electron backscatter diffraction pattern analysis and cross correlation on the sphere”, Ultramicroscopy 207 (2019), 112836, doi:10.1016/j.ultramic.2019.112836. For a direct precision comparison with Hough indexing, see G. Sparks et al., Ultramicroscopy 222 (2021), 113187, doi:10.1016/j.ultramic.2020.113187.
-
ISO 13067:2020 describes EBSD measurement of average grain size. It warns that highly deformed specimens require careful interpretation. ASTM E2627-13(2019) applies to fully recrystallised polycrystalline materials. Both make reconstruction choices part of the reported measurement method.
Technical details
calcGrains returns the grains alone and writes grainId into the map it was given,
where MATLAB returns a modified copy of the map as its second output; the phases that
minPixel marks notIndexed are rewritten in that map too, so a sweep over minPixel on
one map passes ebsd.copy(). The alpha closing keeps the phase of every pixel it absorbs,
so the sweep over alpha runs on the map itself.
Fast multiscale clustering picks its seeds in order of coupling and aggregate size, and
the order of the pixels decides between equal values. MATLAB stores this map with y
running fastest, the port with x. Taken in MATLAB’s order, the zoom gives MATLAB’s 55 and
107 grains at cmaha 0.5 and 1.5 and the same hierarchy pair for pair; in the port’s own
order it gives 56 and 113. The full map gives 1903 grains, 1900 in MATLAB’s order, for
MATLAB’s 1894, in about 14 seconds for MATLAB’s 23.
Citing this page.
This page is part of the documentation of
MTEX, a free and open
source MATLAB toolbox for analyzing and modeling crystallographic textures.
It was written by The MTEX Developers and is published at
https://mtex-toolbox.github.io/GrainReconstruction_py.html.
If you use MTEX, or reuse text or figures from this page, in your research,
please cite
F. Bachmann, R. Hielscher, H. Schaeben: Texture Analysis with MTEX - Free and Open Source Software Toolbox, Solid State Phenomena 160 (2010), 63-68. 10.4028/www.scientific.net/SSP.160.63
BibTeX
@article{bachmann2010mtex,
author = {F. Bachmann and R. Hielscher and H. Schaeben},
title = {Texture Analysis with MTEX - Free and Open Source Software Toolbox},
journal = {Solid State Phenomena},
volume = {160},
pages = {63-68},
year = {2010},
doi = {10.4028/www.scientific.net/SSP.160.63},
url = {https://doi.org/10.4028/www.scientific.net/SSP.160.63}
}
Other papers describing specific MTEX methods are listed under Publications — please cite the one that best fits your application. The MTEX source code is licensed under the GNU General Public License v2.0; the text and figures of this documentation are licensed under CC BY 4.0, which permits reuse — including by automated systems — provided The MTEX Developers and this page are credited.