download as script · edit page

Grain segmentation correctly identifies a twin domain as a grain: it is a phase-homogeneous, spatially connected region of EBSD measurements. For an analysis of the grain before twinning, however, that domain belongs to its host. Merging removes the selected internal boundaries and reconstructs this parent-grain footprint.

In parent-phase reconstruction, merging is the second of two distinct steps. The child grains are first transformed to candidate parent orientations and only then merged where those candidates are compatible. See Grain Graph Based Reconstruction for that complete workflow.

This page uses deformation twins in magnesium. It assumes familiarity with grain reconstruction, grain IDs, and boundary misorientations.

import numpy as np
from mtex import *
# load the example in its specimen plotting frame
plottingConvention.default('y↑→x')
ebsd = mtexdata('twins')

# reconstruct and smooth the grains
grains = calcGrains(ebsd, angle=5 * degree, minPixel=3)
grains = smoothBoundary(grains, 5)
grains = grains['indexed']

# compute the mean-orientation colours without printing a colour-key notice
colorKey = ipfColorKey(grains)
grainColor = colorKey.orientation2color(grains.meanOrientation)
plot(grains, grainColor)

The narrow lamellae have mean-orientation colours unlike their surrounding grains. Their morphology suggests twins, but morphology alone does not establish a twin relationship.

Select candidate twin boundaries

A twin law maps two pairs of crystallographic directions onto each other. orientation.map constructs that complete relationship. Comparing complete misorientations is more selective than comparing their rotation angles alone.

# define the ideal twinning misorientation
CS = grains.CS
twinning = orientation.map(Miller(0, 1, -1, -2, CS), Miller(0, -1, 1, -2, CS), Miller(2, -1, -1, 0, CS), Miller(2, -1, -1, 0, CS))

# extract magnesium-to-magnesium grain boundaries
gB = grains.boundary['Magnesium', 'Magnesium']

# select segments within 5 degrees of the twin law
isTwinning = angle(gB.misorientation, twinning) < 5 * degree
twinBoundary = gB[isTwinning]
twinBoundary
grainBoundary (y↑→x)
  size: 1408 segments, 67 chains
  segments  length  mineral 1  mineral 2
      1408  356 µm  Magnesium  Magnesium
# report how much of the sampled boundary network passed the test
print(f'Mg-Mg segments: {len(gB)}, candidate twin segments: {len(twinBoundary)}, percent: {100 * len(twinBoundary) / len(gB):.3f}')
Mg-Mg segments: 2698, candidate twin segments: 1408, percent: 52.187
# overlay the candidate twin boundaries
hold(True)
plot(twinBoundary, lineColor='w', lineWidth=4, displayName='candidate twin boundary')
hold(False)

The white traces follow the narrow lamellae. With this boundary sampling, 1406 of 2696 magnesium-to-magnesium segments pass the five-degree test. A segment count is not a count of physical twins, and smoothing or resampling the same traces can change it. The tolerance is also an analyst choice: these are candidate twin boundaries, not proof of the mechanism or of which side is the parent. Inferring Twin Boundaries develops those limitations.

Merging along selected boundaries

merge joins the grains on both sides of every supplied boundary. It treats the selected boundaries as connections in a graph and merges each connected component. The operation is therefore transitive: if A is joined to B and B to C, all three become one grain. One false connecting boundary can consequently fuse a much larger parent than expected.

mergedGrains = merge(grains, twinBoundary)
parentId = grains.parentId

print(f'grains before: {len(grains)}, after: {len(mergedGrains)}')

# overlay the reconstructed parent-grain boundaries
hold(True)
plot(mergedGrains.boundary, lineColor='k', lineWidth=2.5, displayName='merged grains')
hold(False)
grains before: 91, after: 28

The 91 segmented grains have become 28 merged grains. Black lines are the reconstructed parent-grain outlines. Each white trace that no longer has a black line on it is now internal to one merged grain.

Keeping track of the child grains

merge writes parentId into the grains it was given, with one entry for every grain in grains. parentId[k] is the ID of the merged grain that contains grains[k]. This is the same kind of bookkeeping that ebsd.grainId provides between measurements and grains; it is a mapping of persistent IDs, not a list position.

selectedParentId = mergedGrains[15].id
selectedParentId
array([16], dtype=int32)

The following object summary lists the child grains assigned to merged grain 16.

childs = grains[parentId == selectedParentId]
childs
grain2d (y↑→x)
  size: 6
  Phase  Grains           Mineral    Color         Symmetry  Crystal reference frame
  1      6 (1512 pixels)  Magnesium  LightSkyBlue  6/mmm     Magnesium
  boundary segments: 302, inner: 0, triple points: 20
  properties       : parentId
        Id   Phase  Pixels        meanRotation         GOS
        42       1     346     (72°,168°,202°)     0.00997
        43       1      63      (19°,80°,220°)     0.00867
        47       1      12      (21°,79°,220°)      0.0133
        48       1     274      (20°,78°,220°)     0.00772
        52       1     811     (71°,168°,200°)     0.00875
        65       1       6      (20°,79°,219°)      0.0102

Which child domains are twins

Merging establishes which domains belong together. It does not identify the original host or the twin. One simple rule works while twins occupy less area than their host: cluster each parent’s child orientations and label the largest orientation cluster by area as the original grain.

# cache the child-grain areas
gArea = grains.area

# classify the smaller orientation clusters as twin domains
isTwin = np.ones(len(grains), dtype=bool)
for i in range(len(mergedGrains)):

  # find the children of this merged-grain ID
  parent = mergedGrains[i].id
  childInd = np.flatnonzero(parentId == parent)

  # cluster children with similar mean orientations
  fId, _ = calcCluster(grains.meanOrientation[childInd], maxAngle=15 * degree, method='hierarchical')

  # find the orientation cluster with the largest total area
  clusterArea = np.bincount(fId, gArea[childInd])
  fParent = np.argmax(clusterArea)
  isTwin[childInd[fId == fParent]] = False

# report the mapped-area fraction assigned to twins
twinAreaPercent = 100 * np.sum(grains[isTwin].area) / np.sum(grains.area)
twinAreaPercent
16.6267
# visualize the classification
plot(grains[~isTwin], faceColor='darkgray', displayName='not twin')
hold(True)
plot(grains[isTwin], faceColor='red', displayName='twin')
plot(mergedGrains.boundary, lineColor='k', lineWidth=2, displayName='merged grains')
mtexTitle('twin classification')
hold(False)

The rule assigns 17 percent of the mapped area to twins. The red domains are lamellae inside the grey hosts, which is a useful spatial check on the classification. The rule fails if a twin consumes more than half of its host. This map alone cannot reveal that history.

Properties of the merged grains

A merged grain receives a new shape and the summed pixel count. Other grain properties have no universal merge rule. In particular, directly averaging host and twin orientations usually does not recover the host orientation, so the mean orientation of a merged grain is not appropriate for this example. The intended physical quantity must determine how each property is carried from children to parents.

For grain orientation spread (GOS), parentId supplies the groups to np.bincount. An unweighted mean gives every child grain one vote.

# compute an unweighted child-grain mean for each parent
unweightedGOS = np.bincount(parentId, grains.GOS, len(mergedGrains) + 1)[1:] / np.bincount(parentId, minlength=len(mergedGrains) + 1)[1:]
mergedGrains.GOS = unweightedGOS

# compare child and unweighted parent values
plot(grains, grains.GOS / degree)
hold(True)
plot(mergedGrains.boundary, lineColor='white', lineWidth=2)
mtexTitle('child-grain GOS')
hold(False)

nextAxis()
plot(mergedGrains, unweightedGOS / degree)
mtexTitle('unweighted merged GOS')
setColorRange([0, 1.5])

This mean counts a two-pixel twin as much as the grain that contains it. That is rarely the intended statistic. Weighting each child by area gives each measured part of the parent equal influence.

# cache GOS and area for the weighted accumulation
childGOS = grains.GOS
childArea = grains.area

# compute the area-weighted child-grain mean for each parent
weightedGOS = np.bincount(parentId, childGOS * childArea, len(mergedGrains) + 1)[1:] / np.bincount(parentId, childArea, len(mergedGrains) + 1)[1:]
mergedGrains.GOS = weightedGOS

nextAxis()
plot(mergedGrains, weightedGOS / degree)
mtexTitle('area-weighted merged GOS')
mtexColorbar()
setColorRange([0, 1.5])

print(f'weighted value higher in {np.count_nonzero(weightedGOS > unweightedGOS)} of {len(mergedGrains)} parents, '
      f'maximum increase {np.max(weightedGOS - unweightedGOS) / degree:.5f} degrees')
weighted value higher in 14 of 28 parents, maximum increase 0.57888 degrees

The two parent maps differ in both directions. The weighted value is larger in 14 of the 28 parents, by as much as 0.6 degrees. In those parents the larger children carry the higher GOS values, while small twins pull the unweighted mean down. More generally, orientation spread can depend on grain size, so the weighting rule must be reported.

Pointing EBSD measurements at the merged grains

The EBSD measurements still carry the grainId values from the original segmentation. Those IDs refer to a different grain list after merging. Indexing ebsd with a merged grain therefore selects the wrong measurements without raising an error.

targetMergedGrain = mergedGrains['id', 22]
staleMeasurements = ebsd[targetMergedGrain]
staleColor = colorKey.orientation2color(staleMeasurements.orientations)

plot(targetMergedGrain.boundary, lineWidth=2)
hold(True)
plot(staleMeasurements, staleColor)
hold(False)

The coloured measurements do not fill the outlined merged grain. They are the measurements whose old grainId happens to equal the new ID 22. Replace every indexed measurement’s old grain ID by the parent ID of its original grain.

# copy the EBSD data so the original mapping remains available
ebsd_merged = ebsd.copy()

# map old grain IDs to indices in grains, then from children to parent IDs
indexed = ebsd_merged.isIndexed
ebsd_merged.grainId[indexed] = parentId[grains.id2ind(ebsd.grainId[indexed])]

correctedMeasurements = ebsd_merged[targetMergedGrain]
correctedColor = colorKey.orientation2color(correctedMeasurements.orientations)

The same lookup now returns the measurements inside the outline. Updating grainId is essential whenever subsequent EBSD analysis uses the merged grains.

plot(correctedMeasurements, correctedColor)
hold(True)
plot(targetMergedGrain.boundary, lineWidth=3)
hold(False)

References

Next

Continue with Inferring Twin Boundaries when the twin law must be inferred from the data. For a phase transformation, see Grain Graph Based Reconstruction for the transform-then-merge workflow.

Technical details

merge returns the merged grains and writes parentId into the grains it was given, where MATLAB returns it as a second output. MATLAB’s accumarray over the parent ids is np.bincount with weights, and the grain id written into a subset of a map goes through a mask on the whole map, since ebsd['indexed'] is a copy.

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