download as script · edit page

The boundary of a set of three-dimensional grains is one surface mesh for the whole volume. Every face separates two grains and, when the grains were reconstructed from voxels, knows the two voxels it separates. Along the edges where three grains meet run the triple lines, and where four grains meet sits a quadruple point. These contacts constrain grain growth and provide possible paths for intergranular transport or damage. This page measures how much interface is present and shows how the junctions connect it, using the IN100 volume of the reconstruction page.

import numpy as np
from mtex import *
plottingConvention.default('y↑→x')
how2plot = plottingConvention.default3D

fname = mtexdatafile('SmallIN100')
ebsd = EBSD3.load(fname)
grains = calcGrains(ebsd, angle=5 * degree)
gB = grains.boundary
print(gB)
grain3Boundary
  faces   : 757562 triangles (637562 between grains, 120000 on the outside)
  vertices: 346729

One face, two grains, two voxels

A face is a triangle, two per voxel face. grainId holds the two grains it separates, with the face normal pointing from the first into the second. ebsdId holds the two voxels on either side, and misorientation the misorientation between their orientations, computed from the voxels rather than from the grain means. Faces on the outer hull of this voxel reconstruction have a zero in the second column of both. In a general imported mesh the zero can occur on either side, so test both columns.

print(np.hstack([gB.grainId[:5], gB.ebsdId[:5]]))
[[   150     56 200000 210000]
 [   150     56 200000 210000]
 [    56    137 250000 260000]
 [    56    137 250000 260000]
 [   137     51 340000 350000]]

Count area, not triangles: refining a face changes its mesh count without adding any physical interface. The following fractions distinguish the measurement hull from contacts between indexed grains.

faceArea = gB.area
isHull = np.any(gB.grainId == 0, axis=1)
isIndexed = np.all(gB.isIndexed, axis=1) & ~isHull
print(np.r_[faceArea[isHull].sum(), faceArea[isIndexed].sum()] / faceArea.sum())
[0.1584 0.8416]

Select faces

A boundary is selected like a list: by the grains at its sides, by phase, or by any property of the faces. The faces of one grain, the faces between two grains, and the faces above a misorientation angle are three common selections.

largestIndex = int(np.argmax(grains.volume))
id = grains.id[largestIndex]
gBid = gB[np.any(gB.grainId == id, axis=1)]
neighbours = np.setdiff1d(np.unique(gBid.grainId), [0, id])
gBpair = gB[np.any(gB.grainId == id, axis=1) & np.any(gB.grainId == neighbours[0], axis=1)]
gBhigh = gB[(gB.misorientation.angle() > 30 * degree) & np.all(gB.grainId > 0, axis=1)]

print([len(gBid), len(gBpair), len(gBhigh)])
[16524, 300, 547682]

The largest grain, with its faces coloured by the misorientation angle across them. Each triangle has one value, but the values may vary over a contact between the same two grains: they come from the local voxel orientations. Missing orientations and the measurement hull do not supply a meaningful misorientation angle.

plot(gBid, gBid.misorientation.angle() / degree, edgeAlpha=0.1, micronbar='off')
setCamera(how2plot)
mtexColorbar(title='misorientation angle in degree')

Edges, triple lines and quadruple points

edges lists every edge once and tells for each face which three edges bound it. An edge on two faces lies inside a boundary face, an edge on three or more faces on a triple line. nodeType counts the grains at every vertex: 2 inside a face, 3 on a triple line, 4 at a quadruple point, and 10 more for a vertex on the outer hull.

E, F2E = gB.edges()
isTripleLine = np.bincount(F2E.reshape(-1), minlength=len(E)) >= 3
t = gB.nodeType()

print([len(E), np.count_nonzero(isTripleLine), np.count_nonzero(t % 10 == 3), np.count_nonzero(t % 10 >= 4)])
[1103992, 62789, 42406, 4004]

The triple lines of the largest grain, drawn over its faces. Every line is where a neighbour ends and the next one begins, and the lines meet at the quadruple points.

V = gB.allV
onGrain = np.zeros(len(V), dtype=bool)
onGrain[gBid.F.reshape(-1)] = True
onGrainEdge = np.zeros(len(E), dtype=bool)
onGrainEdge[F2E[np.any(gB.grainId == id, axis=1)]] = True
Eid = E[isTripleLine & onGrainEdge]
nan = np.full(len(Eid), np.nan)
X, Y, Z = (np.column_stack([V[Eid[:, 0], k], V[Eid[:, 1], k], nan]).reshape(-1) for k in range(3))

plot(gBid, faceColor=[0.85, 0.85, 0.85], edgeAlpha=0.1, micronbar='off')
hold(True)
line(X, Y, Z, color='r', lineWidth=1.5)
isQuad = (t % 10 >= 4) & onGrain
plot(vector3d(V[isQuad]), color='b', markerSize=5.5)
hold(False)
setCamera(how2plot)

The counts describe mesh vertices, not numbers of physical junctions. A triple line has many vertices, and voxel corners can join more than four grains. Types above 10 identify junctions on the measurement hull.

n = np.bincount(t[t > 0])
print(np.column_stack([np.flatnonzero(n), n[n > 0]]))
[[     2 241422]
 [     3  41328]
 [     4   3806]
 [     5    167]
 [     6      4]
 [    11  46985]
 [    12  11912]
 [    13   1078]
 [    14     27]]

From faces to the boundary character

Each face carries all five parameters of a grain boundary: the misorientation of the two grains and the normal of the face. The normals of the voxel surface point along the axes, so the surface has to be smoothed first, see Smoothing, before the boundary normal distribution or the boundary character distribution reads anything but the voxel grid.

grainsS = smoothBoundary(reduceBoundary(grains, 2, quadric=True), taubinFilter(20))
gBS = grainsS.boundary['indexed']

plot(calcGBND(gBS), upper=True)
mtexColorbar()

How much internal boundary is there per unit volume?

Boundary area per specimen volume is a useful geometric input when comparing interfacial storage or transport between microstructures. Each shared face appears once in grains.boundary. Summing grains.surface() instead would count an internal interface twice and include the hull.

internal = np.all(grainsS.boundary.grainId > 0, axis=1)
internalArea = grainsS.boundary[internal].area.sum()
boundaryAreaDensity = internalArea / grainsS.volume.sum()
print(boundaryAreaDensity)
0.8422676432793769

The unit is inverse length. This value uses all internal contacts and the full reconstructed volume. To report only indexed grain boundaries, use grainsS.boundary['indexed'] and state the corresponding volume denominator.

What fraction of indexed boundary area has a low angle?

Use a stated angle threshold and weight by face area. This describes the local misorientation stored on the reconstructed faces, rather than a count of low-angle grain pairs. After coarsening, the retained faces carry inherited misorientations; remeasure from the original data if local orientation gradients are the quantity of interest.

angleLimit = 15 * degree
lowAngle = gBS.misorientation.angle() < angleLimit
lowAngleAreaFraction = gBS[lowAngle].area.sum() / gBS.area.sum()
print(lowAngleAreaFraction)
0.02921802537286299

A low-angle fraction alone does not establish a connected boundary path. Connectivity also requires the grain pairs and junction network. Likewise, the specimen-frame normal distribution above describes preferred interface inclinations, not preferred crystallographic planes. For one selected phase, calcGBND(gBS, grainsS['phase name']) transforms normals into the crystal frame; see Boundary Normal Distribution.

Function reference

Function Purpose Function Purpose
edges list mesh edges and face incidence nodeType classify junction vertices
plot colour boundary faces quiver draw face-normal directions
calcGBND estimate the normal distribution neighbors list adjacent grain pairs

References

Next

Continue with Neper Interface to construct a synthetic comparison, or go to Properties to measure grain size and shape. Smoothing explains the geometric treatment used before the surface measurements above.

Technical details

calcGrains alters the volume it is given (the grain ids stay on it), so MATLAB’s [grains, ebsd] = calcGrains(ebsd, ...) is grains = calcGrains(ebsd, ...). edges and nodeType are methods of the boundary and accumarray is np.bincount. The grain ids count from 1 as MATLAB’s, 0 standing for the outside; ebsdId counts the voxels from 0 in the port’s array order, -1 standing for the outside, as for every map of the port.

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/Grains3DBoundaries_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.