#!/usr/bin/env python
# coding: utf-8
# This notebook is part of the `kikuchipy` documentation https://kikuchipy.org.
# Links to the documentation won't work from the notebook.
# # Extrapolate projection centers from a mean
#
# In this tutorial, we will extrapolate a plane of projection centers (PCs) from a mean PC.
# The PCs are determined from patterns spread out across the sample region of interest (ROI).
# This is an alternative to *fitting* a plane to PCs, as is demonstrated in the tutorial [Fit a plane to selected projection centers](pc_fit_plane.ipynb).
# As a validation of the extrapolated PCs, we will compare them to the PCs obtained from fitting a plane to the PCs.
#
# We'll start by importing the necessary libraries
# In[1]:
get_ipython().run_line_magic('matplotlib', 'inline')
import matplotlib.pyplot as plt
import numpy as np
from diffsims.crystallography import ReciprocalLatticeVector
import hyperspy.api as hs
import kikuchipy as kp
from orix.crystal_map import PhaseList
plt.rcParams.update(
{
"figure.facecolor": "w",
"figure.dpi": 75,
"figure.figsize": (8, 8),
"font.size": 15,
}
)
# ## Load and inspect data
#
# We will use nine calibration patterns from recrystallized nickel.
# The patterns are acquired with a NORDIF UF-1100 EBSD detector with the full (480, 480) px$^2$ resolution.
# These patterns should always be acquired from spread out sample positions across the ROI in order to calibrate the PCs prior to indexing the full dataset.
#
# Read the calibration patterns
# In[2]:
s_cal = kp.data.ni_gain_calibration(1, allow_download=True)
s_cal
# Get information read from the NORDIF settings file
# In[3]:
omd = s_cal.original_metadata.as_dictionary()
# Plot coordinates of calibration patterns on the secondary electron area overview image (part of the dataset), highlighting the ROI
# In[4]:
kp.draw.plot_pattern_positions_in_map(
rc=omd["calibration_patterns"]["indices_scaled"],
roi_shape=omd["roi"]["shape_scaled"],
roi_origin=omd["roi"]["origin_scaled"],
area_shape=omd["area"]["shape_scaled"],
area_image=omd["area_image"],
color="w",
)
# Improve signal-to-noise ratio by removing the static and dynamic background
# In[5]:
s_cal.remove_static_background("divide")
s_cal.remove_dynamic_background("divide")
# Let's plot the nine background-corrected calibration patterns.
#
# Before we do that, though, we find a suitable signal mask.
# The mask should exclude parts of the pattern without Kikuchi diffraction.
# Previous EBSD experiments on the microscope showed that the maximum intensity on the detector is a little to the left of the detector center.
# We therefore use a circular mask to exclude intensities in the upper and lower right corners of the detector
# In[6]:
r_pattern = kp.filters.distance_to_origin(
s_cal.axes_manager.signal_shape[::-1], origin=(230, 220)
)
signal_mask = r_pattern > 310 # Exclude pixels set to True
# For visual display only, we normalize intensities to a mean of 0 and a standard deviation of 1.
# We also exclude extreme intensities outside the range [-3, 3].
# In[7]:
s_cal2 = s_cal.normalize_intensity(dtype_out="float32", inplace=False)
# In[8]:
fig = plt.figure(figsize=(12, 12))
_ = hs.plot.plot_images(
s_cal2 * ~signal_mask,
per_row=3,
axes_decor=None,
colorbar=False,
label=None,
fig=fig,
vmin=-3,
vmax=3,
)
for i, ax in enumerate(fig.axes):
ax.axis("off")
ax.text(475, 10, str(i), va="top", ha="right", c="w")
fig.subplots_adjust(wspace=0.01, hspace=0.01)
# We know that all patterns are of nickel.
# To get a description of nickel, we could create a [Phase](https://orix.readthedocs.io/en/stable/reference/generated/orix.crystal_map.Phase.html) manually.
# However, we will later on use a dynamically simulated EBSD master pattern of nickel (created with EMsoft), which is loaded with a `Phase`.
# We will use this in the remaining analysis.
# In[9]:
mp = kp.data.ebsd_master_pattern(
"ni", allow_download=True, projection="lambert", energy=20
)
mp
# Extract the phase, and change the lattice parameter unit from nm to Ångström
# In[10]:
phase = mp.phase
lat = phase.structure.lattice
lat.setLatPar(lat.a * 10, lat.b * 10, lat.c * 10)
# In[11]:
print(phase)
print(phase.structure)
# ## Estimate PCs with Hough indexing
#
# We will estimate PCs for the nine calibration patterns using [PyEBSDIndex](https://pyebsdindex.readthedocs.io).
# See the [Hough indexing tutorial](hough_indexing.rst) for more details.
#
#
#
# Note
#
# PyEBSDIndex is an optional dependency of kikuchipy, and can be installed with both `pip` and `conda` (from `conda-forge`).
# To install PyEBSDIndex, see their [installation instructions](https://pyebsdindex.readthedocs.io/en/latest/user/installation.html).
#
#
# We need an [EBSDIndexer](https://pyebsdindex.readthedocs.io/en/stable/reference/generated/pyebsdindex.ebsd_index.EBSDIndexer.html) to use `PyEBSDIndex`.
# We can obtain an indexer by passing a [PhaseList](https://orix.readthedocs.io/en/stable/reference/generated/orix.crystal_map.PhaseList.html) to [EBSDDetector.get_indexer()](../reference/generated/kikuchipy.detectors.EBSDDetector.get_indexer.rst).
# Therefore, we need an initial EBSD detector
# In[12]:
det_cal = s_cal.detector
print(det_cal)
print(det_cal.sample_tilt)
# In[13]:
pl = PhaseList(phase)
pl
# In[14]:
indexer = det_cal.get_indexer(pl, rhoMaskFrac=0.05)
print(indexer.phaselist[0].phasename)
print(indexer.bandDetectPlan.rhoMaskFrac)
# We estimate the PC of each pattern with Hough indexing, and plot both the mean and standard deviation of the resulting PCs.
# Note that Bruker's PC convention is used in kikuchipy.
# (We will "overwrite" the existing detector variable.)
# In[15]:
det_cal = s_cal.hough_indexing_optimize_pc(
pc0=[0.42, 0.21, 0.50], # Initial guess based on previous experiments
indexer=indexer,
batch=True,
method="PSO",
search_limit=0.1,
)
print(det_cal.pc.mean(axis=0))
print(det_cal.pc.std(0))
# Plot the PCs
# In[16]:
det_cal.plot_pc("scatter", annotate=True)
# Unfortunately, we do not recognize the spatial distribution from the overview image above.
# The expected inverse relation between (PCz, PCy) is not present either.
# We can try to improve indexing by refining the PCs using dynamical simulations.
# These simulations are created with EMsoft.
#
# First, we need an initial guess of the orientations, which we get using Hough indexing via [EBSD.hough_indexing()](../reference/generated/kikuchipy.signals.EBSD.hough_indexing.rst).
# We will use the mean PC for all patterns
# In[17]:
indexer.PC = det_cal.pc_average
# In[18]:
xmap_hi = s_cal.hough_indexing(pl, indexer=indexer, verbose=2)
# In[19]:
print(xmap_hi)
print(xmap_hi.fit.mean())
# ## Refine PCs with pattern matching
#
# Refine the PCs (and orientations) using the Nelder-Mead implementation from NLopt
# In[20]:
xmap_ref, det_ref = s_cal.refine_orientation_projection_center(
xmap=xmap_hi,
detector=det_cal,
master_pattern=mp,
signal_mask=signal_mask,
energy=20,
method="LN_NELDERMEAD",
trust_region=[5, 5, 5, 0.05, 0.05, 0.05],
rtol=1e-6,
# A pattern per iteration to use all CPUs
chunk_kwargs=dict(chunk_shape=1),
)
# Inspect some refinement statistics
# In[21]:
print("Score mean: ", xmap_ref.scores.mean())
print("Mean number of evaluations:", xmap_ref.num_evals.mean())
print("PC mean: ", det_ref.pc.mean(0))
print("PC std: ", det_ref.pc.std(0))
print("PC max. diff:", abs(det_cal.pc - det_ref.pc).max(0))
angles = xmap_hi.orientations.angle_with(xmap_ref.orientations, degrees=True)
print("Ori. max. diff [deg]:", angles.max())
# In[22]:
det_ref.plot_pc("scatter", annotate=True)
# The diagonals 5-1-0-3-7 and 8-4-0-2-6 seen in the overview image above should be replicated in the (PCx, PCy) scatter plot.
# We see that 5-1-0-3-7 and 8-0-6 align as expected, but the PC values from the 2nd and 4th patterns do not lie in the expected range.
# Fitting a plane to all these values might not work to our satisfaction, so we will exclude the 2nd and 4th PC values when fitting a plane to the seven remaining PC values.
# The plane will have PC values for all points in the ROI in the overview image above.
# In[23]:
is_outlier = np.zeros(det_ref.navigation_size, dtype=bool)
is_outlier[[2, 4]] = True
# ## Get PC plane by fitting
#
# The fitting is done by finding a transformation function which takes 2D sample coordinates and gives PC values for those coordinates.
# Both an affine and a projective transformation function is supported, following Winkelmann et al. (2020).
# By passing 2D indices of all ROI map points and of the points where the nine calibration patterns were obtained, [EBSDDetector.fit_pc()](../reference/generated/kikuchipy.detectors.EBSDDetector.fit_pc.rst) returns a new detector with PC values for all map points.
# We will use the maximum difference between the above refined PC values and the corresponding fitted PC values as a measure of how good the fitted PC values are.
# In[24]:
pc_indices = omd["calibration_patterns"]["indices_scaled"]
pc_indices -= omd["roi"]["origin_scaled"]
pc_indices = pc_indices.T
map_indices = np.indices(omd["roi"]["shape_scaled"])
print("Full map shape (n rows, n columns):", omd["roi"]["shape_scaled"])
# Fit PC values using the affine transformation function
# In[25]:
det_fit_aff = det_ref.fit_pc(
pc_indices=pc_indices,
map_indices=map_indices,
transformation="affine",
is_outlier=is_outlier,
)
print(det_fit_aff.pc_average)
print(det_fit_aff.sample_tilt)
# Fit PC values using the projective transformation function
# In[26]:
det_fit_proj = det_ref.fit_pc(
pc_indices=pc_indices,
map_indices=map_indices,
transformation="projective",
is_outlier=is_outlier,
)
print(det_fit_proj.pc_average)
print(det_fit_proj.sample_tilt)
# Compare PC differences of all but the PC values from the 2nd and 4th patterns
# In[27]:
pc_indices2 = pc_indices.T[~is_outlier].T
# Refined PC values as a reference (ground truth)
pc_ref = det_ref.pc[~is_outlier]
# Difference in PC values from the affine transformation function
pc_diff_aff = det_fit_aff.pc[tuple(pc_indices2)] - pc_ref
pc_diff_aff_max = abs(pc_diff_aff).max(axis=0)
print(pc_diff_aff_max)
# Difference in PC values from the projective transformation function
pc_diff_proj = det_fit_proj.pc[tuple(pc_indices2)] - pc_ref
pc_diff_proj_max = abs(pc_diff_proj).max(axis=0)
print(pc_diff_proj_max)
# Which fitted PCs are more different from refined PCs, the projective (True)
# or the affine (False)?
print(pc_diff_proj_max > pc_diff_aff_max)
# ## Get PC plane by extrapolation
#
# Instead of fitting a plane to several PCs, we can extrapolate an average PC using [EBSDDetector.extrapolate_pc()](../reference/generated/kikuchipy.detectors.EBSDDetector.extrapolate_pc.rst).
# To do this we need to know the detector pixel size and map step sizes, both given in the same unit.
# In[28]:
det_ext = det_ref.extrapolate_pc(
pc_indices=pc_indices,
navigation_shape=omd["roi"]["shape_scaled"],
step_sizes=(1.5, 1.5), # um
shape=det_cal.shape,
px_size=70, # In um. Generally unique for every detector model!
is_outlier=is_outlier,
)
print(det_ext.pc_average)
print(det_ext.sample_tilt)
# Difference in PC values between the refined PCs and the corresponding extrapolated PCs
# In[29]:
pc_diff_ext = det_ext.pc[tuple(pc_indices2)] - pc_ref
pc_diff_ext_max = abs(pc_diff_ext).max(axis=0)
print(pc_diff_ext_max)
print(pc_diff_ext_max > pc_diff_proj_max)
# The extrapolated PCs deviate more from the refined PCs than the PCs obtained from fitting, although the difference is small.
# In[30]:
det_ext.plot_pc()
# ## Validate extrapolated PCs
#
# As a final check of the difference in PCs, we can plot the geometrical simulations on top of the patterns using the refined orientation but the three different PCs.
# In[31]:
g = ReciprocalLatticeVector.from_min_dspacing(phase.deepcopy())
g.sanitise_phase() # "Fill atoms in the unit cell"
g.calculate_structure_factor()
F = abs(g.structure_factor)
g = g[F > 0.5 * F.max()]
g.print_table()
# In[32]:
simulator = kp.simulations.KikuchiPatternSimulator(g)
# Using PCs from the affine transformation function
# In[33]:
det_aff_cal = det_fit_aff.deepcopy()
det_aff_cal.pc = det_aff_cal.pc[tuple(pc_indices)]
# In[34]:
xmap_aff = s_cal.refine_orientation(
xmap=xmap_hi,
detector=det_aff_cal,
master_pattern=mp,
energy=20,
signal_mask=signal_mask,
method="LN_NELDERMEAD",
trust_region=[5, 5, 5],
rtol=1e-6,
chunk_kwargs={"chunk_shape": 1},
)
# In[35]:
sim_aff = simulator.on_detector(det_aff_cal, xmap_aff.rotations)
# Using PCs from the projective transformation function
# In[36]:
det_proj_cal = det_fit_proj.deepcopy()
det_proj_cal.pc = det_proj_cal.pc[tuple(pc_indices)]
# In[37]:
xmap_proj = s_cal.refine_orientation(
xmap=xmap_hi,
detector=det_proj_cal,
master_pattern=mp,
energy=20,
signal_mask=signal_mask,
method="LN_NELDERMEAD",
trust_region=[5, 5, 5],
rtol=1e-6,
chunk_kwargs={"chunk_shape": 1},
)
# In[38]:
sim_proj = simulator.on_detector(det_proj_cal, xmap_proj.rotations)
# Using the extrapolated PCs
# In[39]:
det_ext_cal = det_ext.deepcopy()
det_ext_cal.pc = det_ext_cal.pc[tuple(pc_indices)]
# In[40]:
xmap_ext = s_cal.refine_orientation(
xmap=xmap_hi,
detector=det_ext_cal,
master_pattern=mp,
energy=20,
signal_mask=signal_mask,
method="LN_NELDERMEAD",
trust_region=[5, 5, 5],
rtol=1e-6,
chunk_kwargs={"chunk_shape": 1},
)
# In[41]:
sim_ext = simulator.on_detector(det_ext_cal, xmap_ext.rotations)
# Compare normalized cross-correlation scores and number of evaluations (iterations)
# In[42]:
print("Scores\n------")
print(f"Affine: {xmap_aff.scores.mean():.7f}")
print(f"Projective: {xmap_proj.scores.mean():.7f}")
print(f"Extrapolated: {xmap_ext.scores.mean():.7f}\n")
print("Number of evaluations\n---------------------")
print(f"Affine: {xmap_aff.num_evals.mean():.1f}")
print(f"Projective: {xmap_proj.num_evals.mean():.1f}")
print(f"Extrapolated: {xmap_ext.num_evals.mean():.1f}")
# Plot Kikuchi bands on top of patterns for the solutions using the affine transformed PCs (red), projective transformed PCs (blue), and extrapolated PCs (white)
# In[43]:
fig, axes = plt.subplots(ncols=3, nrows=3, figsize=(12, 12))
for i, ax in enumerate(axes.ravel()):
ax.imshow(
s_cal2.inav[i].data * ~signal_mask, cmap="gray", vmin=-3, vmax=3
)
ax.axis("off")
# Affine
lines = sim_aff.as_collections(
i, lines_kwargs={"linewidth": 4, "alpha": 0.4}
)[0]
ax.add_collection(lines)
# Projective
lines = sim_proj.as_collections(
i, lines_kwargs={"color": "b", "linewidth": 3, "alpha": 0.4}
)[0]
ax.add_collection(lines)
# Extrapolated
lines = sim_ext.as_collections(
i, lines_kwargs={"color": "w", "linewidth": 1, "alpha": 0.4}
)[0]
ax.add_collection(lines)
ax.text(5, 10, i, c="w", va="top", ha="left", fontsize=20)
fig.subplots_adjust(wspace=0.01, hspace=0.01)
# As expected from the intermediate results above (similar average PC and NCC score), all PCs produce visually identical geometrical simulations.
# However, the orientations may be slightly different
# In[44]:
xmap_proj.orientations.angle_with(xmap_ext.orientations, degrees=True).mean()
# The estimated orientations using PCs from plane fitting are on average misoriented by about 1$^{\circ}$ from the estimated orientations using extrapolated PCs.
# The misorientation is most likely a result of the difference in applied sample tilts
# In[45]:
abs(det_proj_cal.sample_tilt - det_ext_cal.sample_tilt)
# Since these are experimental data, it's difficult to say which sample tilt is more correct, although the nominal sample tilt from the microscope was 70$^{\circ}$.