TrueEBSD distortion correction in under a minute (trueEbsd2) edit page

This is the trueEbsd2 variant of the short page. Same data, same five maps, same four distortions and the same numbers — but built on MTEX's own mapImage and spatialTransform rather than on TrueEBSD's @distortedImg and its distortion-name strings. See MIGRATION-mapImage.md in the toolbox for why, and for what still differs.

Authors: Vivian Tong; Stefan Olovsjö, Seco Tools AB, R&D Materials and Technology, 737 82 Fagersta, Sweden. Contact: vivian.tong@extern.tu-freiberg.de

An EBSD map and an SEM image of the same area never quite line up: the beam drifts during the scan, the camera moves between acquisitions, the specimen is tilted. TrueEBSD corrects that, so every pixel of the map and every pixel of the images refer to the same point on the sample.

This is the short version of TrueEBSD on a WC-Co composite — the same five maps and the same four distortions, on a cut-down copy of the dataset, so it finishes in under a minute instead of twenty. Read the full page for what each step means and how to tune it.

This script needs the TrueEBSD toolbox, which is not part of MTEX and is distributed separately under Apache-2.0: https://github.com/vtvivian/mtex-trueebsd

addpath(genpath('<path to mtex-trueEbsd>'))

The data

trueEbsdWCCoSmall is the centre half of the full WC-Co field of view, coarsened by four: a 20.4 x 15.3 µm area with WC grains about 12 px across. Small enough to be quick, still large enough for every distortion to be measurable. makeWCCoSmall.m in the TrueEBSD toolbox shows how it was cut, and why simply coarsening the whole field does not work.

As in the full dataset the four SEM images are in ebsd.opt.trueEbsdImgs, and pixSzImg is their pixel size in microns.

TrueEBSD needs to know which way round each picture is stored before it can compare them pixel by pixel. Here it works that out for itself: the map and the images were collected in one session and stored the same way, which is the usual case. The frames page is the case where they were not.

The convention set here only decides which way up the figures come out. It has no effect on the correction.

plottingConvention.default('y↓→x')

mtexdata trueEbsdWCCoSmall

display(ebsd.opt.trueEbsdImgs)
ans = plottingConvention (y↓→x)
 
ebsd = EBSDsquare (y↓→x, row↓→col)
 
 Phase  Orientations     Mineral         Color  Symmetry  Crystal reference frame
     0    89 (0.72%)  notIndexed          none                                   
     1   11180 (91%)         W C  LightSkyBlue      -6m2        X||a*, Y||b, Z||c
     2    991 (8.1%)      Co-fcc  DarkSeaGreen      m-3m                         
     3    28 (0.23%)      Co-hcp     Goldenrod     6/mmm        X||a*, Y||b, Z||c
 
 Properties: bc, bs, bands, MAD, quality, oldId
 Scan unit : um
 X x Y x Z : [10 → 30] x [8 → 23] x [0 → 0]
 Normal vector: (0,0,1)
 Square grid  :96 x 128
 source: trueEbsdWCCo, centre half of the field of view, coarsened 4x
 madeBy: trueEBSD tools/makeWCCoSmall.m
 
  struct with fields:

       fsdB3: [192×256×3 double]
       fsdT3: [192×256 double]
       fsdT1: [192×256 double]
      fsdT10: [192×256 double]
    pixSzImg: 0.0795

Build the sequence

TrueEBSD does not jump straight from the EBSD map to the reference image. It steps through the images one pair at a time, correcting one kind of distortion at each step — which is why there are four images rather than one. Each pair differs by something simple enough to model, where the map and the final image differ by everything at once.

So the order matters. It runs from the most distorted to the ground truth. The images go in one list and the distortions in another, one per step: beam drift plus a rigid offset, then nothing, then the camera moving, then the specimen tilt seen at a different kV. The reference is last.

'name' is what each image is called once it is attached to the map at the end, so the result reads as ebsd.fsdT1.

img = ebsd.opt.trueEbsdImgs;

imgList = [mapImage(ebsd.bc, ebsd,             'name','bcImg'), ...
           mapImage(img.fsdB3,  'dxy',img.pixSzImg, 'name','fsdB3'), ...
           mapImage(img.fsdT3,  'dxy',img.pixSzImg, 'name','fsdT3'), ...
           mapImage(img.fsdT1,  'dxy',img.pixSzImg, 'name','fsdT1'), ...
           mapImage(img.fsdT10, 'dxy',img.pixSzImg, 'name','fsdT10')];

A light box filter first — cross-correlation dislikes noise — and one common range, since the levels off different detectors mean nothing to each other. Both are methods of mapImage and both take the whole list at once. Neither needs the Image Processing Toolbox.

imgList(2:end) = rescale(imboxfilt(imgList(2:end),3));

The distortions are objects, one per hop, not names on the images. There are five maps and so four hops, and the reference has no entry of its own — where nothing separates a pair, that is spatialTransformId rather than a 'true' label.

A multi-stage model is built with + and not *. + reads left to right in the order the stages are applied and keeps both of them, where an unfitted prototype has zero coefficients, so it reports itself as the identity and * would absorb it away.

T = [spatialTransformShift + spatialTransformDrift, ...
     spatialTransformId, ...
     spatialTransformShift, ...
     spatialTransformTilt];

The job holds the whole workflow, and each step below adds to it. Building it also checks that the sequence really is stored one consistent way round, and that every entry measures the sample in the same unit.

job = trueEbsd2(imgList,T)
job = trueEbsd2 (as imported)
 
      name          image   distortion  shift  residual
 1   bcImg       96 x 128  shift-drift      -         -
 2   fsdB3  192 x 256 x 3     identity      -         -
 3   fsdT3      192 x 256        shift      -         -
 4   fsdT1      192 x 256         tilt      -         -
 5  fsdT10      192 x 256                   -         -

Plot the sequence to check the images really do cover the same area. Note how different the contrasts look — that is why matching is done on edges rather than on the original values.

plot(imgList)

Put everything on one pixel grid

The map is on a 0.159 µm grid and the images on 0.0795 µm. pixelSizeMatch resamples them all onto the finest one, so pixel (i,j) means roughly the same place in each. Give it a pixel size to ask for a particular grid; with no argument it takes the smallest present.

Images are interpolated linearly. EBSD data is not: orientations and phase labels have no meaningful average, so the nearest measured point is used.

Nothing has been corrected yet — this is only bookkeeping.

job.pixelSizeMatch
using default pixel size of 0.079501 um, minimum from imgList
 
ans = trueEbsd2 (pixel size matched)
 
      name          image   distortion  shift  residual
 1   bcImg      192 x 256  shift-drift      -         -
 2   fsdB3  192 x 256 x 3     identity      -         -
 3   fsdT3      192 x 256        shift      -         -
 4   fsdT1      192 x 256         tilt      -         -
 5  fsdT10      192 x 256                   -         -
 
 common grid: 192 x 256 at 0.08 µm

The resampled sequence is in job.resizedList.

job.resizedList
ans = mapImage (y↓→x)
 
            image    pixel   frame    layout    name                 EBSD
 1      192 x 256  0.08 um    y↓→x  row↓→col   bcImg  W C, Co-fcc, Co-hcp
 2  192 x 256 x 3  0.08 um  iY↓→iX  row↓→col   fsdB3                    -
 3      192 x 256  0.08 um  iY↓→iX  row↓→col   fsdT3                    -
 4      192 x 256  0.08 um  iY↓→iX  row↓→col   fsdT1                    -
 5      192 x 256  0.08 um  iY↓→iX  row↓→col  fsdT10                    -

Adjust the matching windows

Distortions are measured by cutting both images of a pair into small boxes and cross-correlating each box with its partner. That gives a local shift at each box, and those shifts are fitted to the distortion model.

Almost none of it has to be specified. The box width roiSize and the edgeWidth below are measured from the images themselves the first time calcDistortion needs them, and what was chosen is printed so it can be overridden. Both are lengths, in the map's own scanUnit, so they mean the same thing before and after pixelSizeMatch and may be set at any point.

What is worth setting here is how many boxes to use. The default of 24 across suits a full-size map; this grid is small, and the matching is the entire runtime, so fewer is quicker.

job.setOptions('numROI',16)
ans = trueEbsd2 (pixel size matched)
 
      name          image   distortion  shift  residual
 1   bcImg      192 x 256  shift-drift      -         -
 2   fsdB3  192 x 256 x 3     identity      -         -
 3   fsdT3      192 x 256        shift      -         -
 4   fsdT1      192 x 256         tilt      -         -
 5  fsdT10      192 x 256                   -         -
 
 common grid: 192 x 256 at 0.08 µm

Measure the distortion

These are the pictures that will actually be matched. A band contrast map and a backscatter image have nothing in common as grey values, but their grain boundaries fall in the same places — which is why the edge transform is what gets correlated, and why registerOn defaults to 'edge'. Set it to 'raw' for a pair that already shares contrast.

plot(job.resizedList,'edge')

'fitErr' re-measures the shifts after each correction and reports what is left over. That residual is how you tell whether it worked: around a pixel or less is good.

If a residual comes out above two pixels, TrueEBSD doubles the box size and tries again, repeating until it comes down or the box outgrows the image. The boxes set above are large enough that this does not happen here — the full WC-Co page undersizes one deliberately to show it.

Steps whose transform is spatialTransformId are skipped: nothing separates that pair, so their shift is taken as zero whatever the residual says.

Afterwards job.T holds the fitted transforms rather than the prototypes it started from.

job.calcDistortion('fitErr')
◆ measured settings, override with setOptions
   ▸ edgeWidth  per map  4 4 5 5 5 px
   ▸ roiSize    per hop  32 64 64 64 px   ← shifts 5.0 2.2 3.3 3.5 px, features 4 7 7 7 px

 ◆ distortion across 5 maps, 4 hops

    distortion   stage              ROI     shift  residual
    ──────────── ───────────── ──────── ───────── ─────────
  ● bcImg
  │ shift-drift  shift            32 px   4.19 px
  │              drift            32 px   3.72 px
  │              ↳ residual                         3.36 px
  │ ↻ residual over 2 px, retrying with a 64 px ROI
  │ shift-drift  shift            64 px   3.64 px
  │              drift            64 px   2.04 px
  │              ↳ residual                         1.53 px
  ▼
  ● fsdB3
  │ identity     ·                    ·   0.00 px
  │              ↳ difference                       2.03 px
  ▼
  ● fsdT3
  │ shift        shift            64 px   3.25 px
  │              ↳ residual                         0.27 px
  ▼
  ● fsdT1
  │ tilt         projective       64 px   3.61 px
  │              poly11           64 px   0.27 px
  │              poly22           64 px   0.27 px
  │              ↳ residual                         0.26 px
  ▼
  ● fsdT10

 
ans = trueEbsd2 (shifts calculated)
 
      name          image   distortion    shift  residual
 1   bcImg      192 x 256  shift-drift   1.1 px    1.5 px
 2   fsdB3  192 x 256 x 3     identity     0 px      2 px
 3   fsdT3      192 x 256        shift   3.2 px   0.27 px
 4   fsdT1      192 x 256         tilt  0.33 px   0.26 px
 5  fsdT10      192 x 256                     -         -
 
 common grid: 192 x 256 at 0.08 µm

Correct it

Each map is now moved by the shifts of every step between it and the reference. The reference itself does not move.

Resampling is nearest-neighbour throughout, so no orientation and no phase label is ever invented by averaging two real measurements.

job.undistort

plot(job.undistortedList)
ans = trueEbsd2 (undistorted)
 
      name          image   distortion    shift  residual
 1   bcImg      192 x 256  shift-drift   1.1 px    1.5 px
 2   fsdB3  192 x 256 x 3     identity     0 px      2 px
 3   fsdT3      192 x 256        shift   3.2 px   0.27 px
 4   fsdT1      192 x 256         tilt  0.33 px   0.26 px
 5  fsdT10      192 x 256                     -         -
 
 common grid: 192 x 256 at 0.08 µm

Use the result

Every image is now attached to the EBSD map as a per-pixel property, under the 'name' given earlier. So ebsd.fsdT1 is just another map property, and plot(ebsd,ebsd.fsdT1) works like any other plot — no conversion, and it stays with the map through cropping, gridding and indexing.

Plotting them back onto the map is also the quickest check that nothing came out the wrong way round.

fsdB3 is a colour image and keeps all three channels. Plotting onto a map needs one value per pixel, so it is averaged to grey here.

ebsdOut = job.undistortedList(1).ebsd;

figure
nextAxis
plot(ebsdOut('W C'), ebsdOut('W C').orientations, 'coordinates','on')
title('Undistorted MTEX EBSD map (WC IPF out of screen)','Color','k')

for n = 1:numel(job.undistortedList)

  im = ebsdOut.(job.undistortedList(n).name);
  if size(im,3) > 1, im = mean(im,3); end

  nextAxis
  plot(ebsdOut, im, 'coordinates','on')
  mtexColorMap gray
  title(['Undistorted ' job.undistortedList(n).name],'Color','k')
end

Finish

The map and the images now overlay pixel for pixel, and ebsdOut is an ordinary MTEX EBSD map that happens to carry four SEM images as properties. Anything you would normally do with a map works from here — including turning a thresholded image into a phase, which is what the copper page does with its voids.

Run the full page for the same workflow at full resolution over the whole field of view.

This page and the @trueEbsd one are compared quantity by quantity by tests_migrationAB in the toolbox. The hop shifts agree; the residuals differ by less than a tenth of a pixel and corr(bc,img) to five significant figures.