de-ebsd 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- de_ebsd/__init__.py +76 -0
- de_ebsd/core/__init__.py +23 -0
- de_ebsd/core/constants.py +81 -0
- de_ebsd/core/device.py +12 -0
- de_ebsd/core/geometry.py +67 -0
- de_ebsd/core/lambert.py +148 -0
- de_ebsd/core/rotation.py +139 -0
- de_ebsd/crystal/__init__.py +22 -0
- de_ebsd/crystal/data/__init__.py +0 -0
- de_ebsd/crystal/data/element_properties.py +29 -0
- de_ebsd/crystal/data/scattering_tables.py +414 -0
- de_ebsd/crystal/lattice.py +517 -0
- de_ebsd/crystal/scattering.py +339 -0
- de_ebsd/crystal/scattering_torch.py +233 -0
- de_ebsd/crystal/structure_factors.py +291 -0
- de_ebsd/diffraction/__init__.py +45 -0
- de_ebsd/diffraction/backends.py +318 -0
- de_ebsd/diffraction/bethe.py +28 -0
- de_ebsd/diffraction/kinematic.py +389 -0
- de_ebsd/diffraction/kvectors.py +120 -0
- de_ebsd/diffraction/prism.py +204 -0
- de_ebsd/diffraction/reflections.py +234 -0
- de_ebsd/index/__init__.py +61 -0
- de_ebsd/index/_kernels.py +222 -0
- de_ebsd/index/backproject.py +171 -0
- de_ebsd/index/crosscorr.py +654 -0
- de_ebsd/index/dictionary.py +362 -0
- de_ebsd/index/io.py +324 -0
- de_ebsd/index/live/__init__.py +5 -0
- de_ebsd/index/live/background.py +103 -0
- de_ebsd/index/live/calibration.py +70 -0
- de_ebsd/index/live/common.py +44 -0
- de_ebsd/index/live/deformation.py +289 -0
- de_ebsd/index/live/indexer.py +386 -0
- de_ebsd/index/preprocess.py +195 -0
- de_ebsd/index/refine/__init__.py +7 -0
- de_ebsd/index/refine/highres.py +479 -0
- de_ebsd/index/refine/levenberg.py +33 -0
- de_ebsd/index/refine/orientation.py +367 -0
- de_ebsd/index/refine/pattern_center.py +356 -0
- de_ebsd/index/spherical/__init__.py +41 -0
- de_ebsd/index/spherical/correlation.py +106 -0
- de_ebsd/index/spherical/harmonics.py +311 -0
- de_ebsd/index/spherical/indexer.py +345 -0
- de_ebsd/index/spherical/wigner.py +190 -0
- de_ebsd/sim/__init__.py +20 -0
- de_ebsd/sim/detector.py +145 -0
- de_ebsd/sim/masterpattern.py +231 -0
- de_ebsd/sim/microscope.py +93 -0
- de_ebsd/sim/montecarlo/__init__.py +19 -0
- de_ebsd/sim/montecarlo/depth.py +167 -0
- de_ebsd/sim/montecarlo/transport.py +532 -0
- de_ebsd/sim/simulator.py +291 -0
- de_ebsd/viz/__init__.py +53 -0
- de_ebsd/viz/camera.py +56 -0
- de_ebsd/viz/depth_widget.js +76 -0
- de_ebsd/viz/explorer.py +647 -0
- de_ebsd/viz/figures.py +67 -0
- de_ebsd/viz/maps.py +277 -0
- de_ebsd/viz/patterns.py +164 -0
- de_ebsd/viz/setup_widget.js +171 -0
- de_ebsd/viz/views.py +125 -0
- de_ebsd/viz/widgets.py +191 -0
- de_ebsd-0.2.0.dist-info/METADATA +251 -0
- de_ebsd-0.2.0.dist-info/RECORD +69 -0
- de_ebsd-0.2.0.dist-info/WHEEL +5 -0
- de_ebsd-0.2.0.dist-info/licenses/LICENSE +674 -0
- de_ebsd-0.2.0.dist-info/licenses/NOTICE +44 -0
- de_ebsd-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Wigner d/D matrices and the SOFT grid on SO(3).
|
|
2
|
+
|
|
3
|
+
Real Wigner-d at beta = pi/2 by stable recursion, the (alpha, beta, gamma) SOFT
|
|
4
|
+
sampling grid, and the bridge from ZYZ Euler angles to orix ``Rotation``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
import torch
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"euler_zyz_to_orix",
|
|
15
|
+
"grid_to_rotation",
|
|
16
|
+
"n_orientations",
|
|
17
|
+
"so3_grid",
|
|
18
|
+
"wigner_D",
|
|
19
|
+
"wigner_d",
|
|
20
|
+
"wigner_small_d",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _small_d_generator(
|
|
25
|
+
ell: int, device: torch.device | str | None, dtype: torch.dtype
|
|
26
|
+
) -> torch.Tensor:
|
|
27
|
+
n = 2 * ell + 1
|
|
28
|
+
k = torch.zeros((n, n), device=device, dtype=dtype)
|
|
29
|
+
if ell > 0:
|
|
30
|
+
i = torch.arange(2 * ell, device=device, dtype=dtype)
|
|
31
|
+
c = 0.5 * torch.sqrt((2 * ell - i) * (i + 1)) # coupling on the i-th sub-diagonal
|
|
32
|
+
idx = torch.arange(2 * ell, device=device)
|
|
33
|
+
k[idx + 1, idx] = -c # (m+1, m)
|
|
34
|
+
k[idx, idx + 1] = c # (m, m+1) = -(m+1, m) -> antisymmetric
|
|
35
|
+
return k
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def wigner_small_d(
|
|
39
|
+
ell: int,
|
|
40
|
+
beta: torch.Tensor,
|
|
41
|
+
) -> torch.Tensor:
|
|
42
|
+
"""Real small Wigner-d matrix ``d^l_{m'm}(beta)`` for a single degree ``ell``.
|
|
43
|
+
|
|
44
|
+
``beta`` is a real tensor of any shape. Returns ``(*beta.shape, 2l+1, 2l+1)``
|
|
45
|
+
real, with row index ``m'`` and column index ``m`` (both ``-l .. l``), computed
|
|
46
|
+
as ``matrix_exp(beta * K_l)`` (the ``exp(-i beta J_y)`` convention).
|
|
47
|
+
"""
|
|
48
|
+
k = _small_d_generator(ell, beta.device, beta.dtype)
|
|
49
|
+
mats = beta[..., None, None] * k # (*beta.shape, n, n)
|
|
50
|
+
return torch.matrix_exp(mats)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def wigner_d(
|
|
54
|
+
bandwidth: int,
|
|
55
|
+
beta: torch.Tensor,
|
|
56
|
+
) -> torch.Tensor:
|
|
57
|
+
"""Small Wigner-d matrices ``d^l_{m'm}(beta)`` for all ``l <= bandwidth``.
|
|
58
|
+
|
|
59
|
+
Returns a padded real tensor of shape ``(*beta.shape, L+1, 2L+1, 2L+1)``: axis
|
|
60
|
+
``-3`` is the degree ``l = 0 .. L``, axes ``-2`` / ``-1`` are the orders
|
|
61
|
+
``m' = idx - L`` (row) and ``m = idx - L`` (column). Entries with ``|m'| > l`` or
|
|
62
|
+
``|m| > l`` are zero, so degree ``l`` occupies the centred ``(2l+1)``-block.
|
|
63
|
+
"""
|
|
64
|
+
L = int(bandwidth)
|
|
65
|
+
out = beta.new_zeros((*beta.shape, L + 1, 2 * L + 1, 2 * L + 1))
|
|
66
|
+
for ell in range(L + 1):
|
|
67
|
+
d_l = wigner_small_d(ell, beta) # (*beta.shape, 2l+1, 2l+1)
|
|
68
|
+
lo, hi = L - ell, L + ell + 1
|
|
69
|
+
out[..., ell, lo:hi, lo:hi] = d_l
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def wigner_D(
|
|
74
|
+
bandwidth: int,
|
|
75
|
+
alpha: torch.Tensor,
|
|
76
|
+
beta: torch.Tensor,
|
|
77
|
+
gamma: torch.Tensor,
|
|
78
|
+
) -> torch.Tensor:
|
|
79
|
+
"""Full complex Wigner-D matrices ``D^l_{m'm}(alpha, beta, gamma)`` for ``l <= L``.
|
|
80
|
+
|
|
81
|
+
``alpha``, ``beta``, ``gamma`` are real tensors, broadcast to a common shape
|
|
82
|
+
``S``. Returns a padded complex tensor ``(*S, L+1, 2L+1, 2L+1)`` with
|
|
83
|
+
|
|
84
|
+
D^l_{m'm} = exp(-i m' alpha) d^l_{m'm}(beta) exp(-i m gamma),
|
|
85
|
+
|
|
86
|
+
row index ``m'`` (axis ``-2``), column index ``m`` (axis ``-1``), both
|
|
87
|
+
``m = idx - L``. Entries outside ``|m'|, |m| <= l`` are zero.
|
|
88
|
+
"""
|
|
89
|
+
L = int(bandwidth)
|
|
90
|
+
alpha, beta, gamma = torch.broadcast_tensors(alpha, beta, gamma)
|
|
91
|
+
d = wigner_d(L, beta) # (*S, L+1, 2L+1, 2L+1), real
|
|
92
|
+
cdtype = torch.complex128 if d.dtype == torch.float64 else torch.complex64
|
|
93
|
+
orders = torch.arange(-L, L + 1, device=d.device, dtype=d.dtype) # (2L+1,)
|
|
94
|
+
# row phase exp(-i m' alpha) and column phase exp(-i m gamma)
|
|
95
|
+
row_phase = torch.exp(-1j * orders * alpha[..., None].to(d.dtype)) # (*S, 2L+1)
|
|
96
|
+
col_phase = torch.exp(-1j * orders * gamma[..., None].to(d.dtype)) # (*S, 2L+1)
|
|
97
|
+
D = d.to(cdtype)
|
|
98
|
+
D = D * row_phase[..., None, :, None] # broadcast over degree and column
|
|
99
|
+
D = D * col_phase[..., None, None, :] # broadcast over degree and row
|
|
100
|
+
return D
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def so3_grid(
|
|
104
|
+
bandwidth: int,
|
|
105
|
+
device: torch.device | str | None = None,
|
|
106
|
+
dtype: torch.dtype = torch.float64,
|
|
107
|
+
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
|
108
|
+
"""The equiangular SOFT SO(3) sampling grid for bandwidth ``L``.
|
|
109
|
+
|
|
110
|
+
Returns the three 1-D angle vectors ``(alpha, beta, gamma)`` of the separable
|
|
111
|
+
grid, each of length ``N = 2L + 2`` (SOFT bandwidth ``B = L + 1``):
|
|
112
|
+
|
|
113
|
+
- ``alpha[j] = 2 pi j / N`` and ``gamma[p] = 2 pi p / N`` -- equiangular in
|
|
114
|
+
``[0, 2 pi)``, the Fourier grids conjugate to the orders ``m', m`` (so the
|
|
115
|
+
correlation is an inverse FFT over ``alpha`` and ``gamma``);
|
|
116
|
+
- ``beta[k] = pi (2k + 1) / (2 N)`` -- the ``2N``-point equiangular colatitude
|
|
117
|
+
grid in ``(0, pi)`` (no samples at the poles).
|
|
118
|
+
|
|
119
|
+
The cross-correlation returned by :func:`.so3_cross_correlation` is indexed
|
|
120
|
+
``C[..., j, k, p]`` on this ``(alpha, beta, gamma)`` grid; :func:`grid_to_rotation`
|
|
121
|
+
converts an ``(j, k, p)`` index to an orix Rotation.
|
|
122
|
+
"""
|
|
123
|
+
L = int(bandwidth)
|
|
124
|
+
n = 2 * L + 2
|
|
125
|
+
j = torch.arange(n, device=device, dtype=dtype)
|
|
126
|
+
alpha = 2.0 * math.pi * j / n
|
|
127
|
+
gamma = 2.0 * math.pi * j / n
|
|
128
|
+
beta = math.pi * (2.0 * j + 1.0) / (2.0 * n)
|
|
129
|
+
return alpha, beta, gamma
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def n_orientations(bandwidth: int) -> int:
|
|
133
|
+
"""Number of SOFT grid points ``(2L + 2) ** 3`` for bandwidth ``L``."""
|
|
134
|
+
return (2 * bandwidth + 2) ** 3
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def euler_zyz_to_orix(
|
|
138
|
+
alpha,
|
|
139
|
+
beta,
|
|
140
|
+
gamma,
|
|
141
|
+
):
|
|
142
|
+
"""Convert active ``ZYZ`` Euler angles to an :class:`orix.quaternion.Rotation`.
|
|
143
|
+
|
|
144
|
+
``alpha``, ``beta``, ``gamma`` are array-likes (numpy arrays, torch tensors, or
|
|
145
|
+
scalars, in radians) broadcast to a common shape. The returned Rotation has that
|
|
146
|
+
shape and satisfies ``R.to_matrix() == (R_z(alpha) R_y(beta) R_z(gamma))^T``:
|
|
147
|
+
the SOFT triple is passed to orix's Bunge (``ZXZ``) ``from_euler`` as
|
|
148
|
+
``(alpha + pi/2, beta, gamma - pi/2)``.
|
|
149
|
+
"""
|
|
150
|
+
import numpy as np
|
|
151
|
+
from orix.quaternion import Rotation
|
|
152
|
+
|
|
153
|
+
def _to_np(x):
|
|
154
|
+
if isinstance(x, torch.Tensor):
|
|
155
|
+
return x.detach().cpu().numpy()
|
|
156
|
+
return np.asarray(x, dtype=float)
|
|
157
|
+
|
|
158
|
+
a = _to_np(alpha)
|
|
159
|
+
b = _to_np(beta)
|
|
160
|
+
g = _to_np(gamma)
|
|
161
|
+
a, b, g = np.broadcast_arrays(a, b, g)
|
|
162
|
+
phi1 = a + np.pi / 2.0
|
|
163
|
+
Phi = b
|
|
164
|
+
phi2 = g - np.pi / 2.0
|
|
165
|
+
euler = np.stack([phi1, Phi, phi2], axis=-1)
|
|
166
|
+
return Rotation.from_euler(euler)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def grid_to_rotation(
|
|
170
|
+
bandwidth: int,
|
|
171
|
+
alpha_index=None,
|
|
172
|
+
beta_index=None,
|
|
173
|
+
gamma_index=None,
|
|
174
|
+
):
|
|
175
|
+
"""Map SOFT grid indices to orix Rotations.
|
|
176
|
+
|
|
177
|
+
With no indices, returns the full grid as a Rotation of shape
|
|
178
|
+
``(N, N, N)`` (``N = 2L + 2``) matching the ``C[..., j, k, p]`` correlation
|
|
179
|
+
layout -- index it directly with the peak location, e.g.
|
|
180
|
+
``grid_to_rotation(L)[j, k, p]``. Passing ``alpha_index`` / ``beta_index`` /
|
|
181
|
+
``gamma_index`` (ints or index arrays, broadcast together) returns the Rotation
|
|
182
|
+
at just those grid points. The conversion is :func:`euler_zyz_to_orix`.
|
|
183
|
+
"""
|
|
184
|
+
alpha, beta, gamma = so3_grid(bandwidth)
|
|
185
|
+
if alpha_index is None and beta_index is None and gamma_index is None:
|
|
186
|
+
a, b, g = torch.meshgrid(alpha, beta, gamma, indexing="ij")
|
|
187
|
+
return euler_zyz_to_orix(a, b, g)
|
|
188
|
+
if alpha_index is None or beta_index is None or gamma_index is None:
|
|
189
|
+
raise ValueError("pass all three of alpha_index, beta_index, gamma_index, or none")
|
|
190
|
+
return euler_zyz_to_orix(alpha[alpha_index], beta[beta_index], gamma[gamma_index])
|
de_ebsd/sim/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Master-pattern generation: the simulator, its output, and the detector geometry.
|
|
2
|
+
|
|
3
|
+
The crystallography lives in :mod:`de_ebsd.crystal`, the diffraction physics in
|
|
4
|
+
:mod:`de_ebsd.diffraction`, and the electron transport in
|
|
5
|
+
:mod:`de_ebsd.sim.montecarlo`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from de_ebsd.sim.detector import EBSDDetector
|
|
9
|
+
from de_ebsd.sim.masterpattern import MasterPattern
|
|
10
|
+
from de_ebsd.sim.microscope import Microscope
|
|
11
|
+
from de_ebsd.sim.montecarlo import DepthDistribution
|
|
12
|
+
from de_ebsd.sim.simulator import MasterPatternSimulator
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DepthDistribution",
|
|
16
|
+
"EBSDDetector",
|
|
17
|
+
"MasterPattern",
|
|
18
|
+
"MasterPatternSimulator",
|
|
19
|
+
"Microscope",
|
|
20
|
+
]
|
de_ebsd/sim/detector.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""EBSD detector geometry and per-pixel direction cosines.
|
|
2
|
+
|
|
3
|
+
Torch port of kikuchipy's ``EBSDDetector`` (Bruker pattern-center convention),
|
|
4
|
+
so patterns projected here match kikuchipy pixel for pixel.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import torch
|
|
13
|
+
|
|
14
|
+
from de_ebsd.core.geometry import sample_to_detector_matrix
|
|
15
|
+
|
|
16
|
+
__all__ = ["EBSDDetector"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class EBSDDetector:
|
|
21
|
+
"""An EBSD detector with a fixed pattern center.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
shape
|
|
26
|
+
(n_rows, n_cols) in pixels.
|
|
27
|
+
pattern_center
|
|
28
|
+
Pattern center (PCx, PCy, PCz) in the Bruker convention.
|
|
29
|
+
sample_tilt
|
|
30
|
+
Sample tilt in degrees (usually set from ``Microscope.sample_tilt``).
|
|
31
|
+
tilt
|
|
32
|
+
Detector tilt in degrees.
|
|
33
|
+
azimuthal
|
|
34
|
+
Detector azimuthal angle in degrees.
|
|
35
|
+
twist
|
|
36
|
+
Detector twist about its normal, in degrees.
|
|
37
|
+
px_size
|
|
38
|
+
Effective screen pixel pitch in micrometers, including binning (not
|
|
39
|
+
camera sensor pitch). Used for physical dimensions in the inspector;
|
|
40
|
+
pattern projection itself only depends on the normalized PC.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
shape: tuple[int, int] = (480, 480)
|
|
44
|
+
pattern_center: tuple[float, float, float] = (0.5, 0.5, 0.5)
|
|
45
|
+
sample_tilt: float = 70.0
|
|
46
|
+
tilt: float = 0.0
|
|
47
|
+
azimuthal: float = 0.0
|
|
48
|
+
twist: float = 0.0
|
|
49
|
+
px_size: float = 1.0
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def nrows(self) -> int:
|
|
53
|
+
return int(self.shape[0])
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def ncols(self) -> int:
|
|
57
|
+
return int(self.shape[1])
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def size(self) -> int:
|
|
61
|
+
return self.nrows * self.ncols
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def aspect_ratio(self) -> float:
|
|
65
|
+
return self.ncols / self.nrows
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def gnomonic_bounds(self) -> np.ndarray:
|
|
69
|
+
"""Detector bounds [x_min, x_max, y_min, y_max] in gnomonic coordinates."""
|
|
70
|
+
pcx, pcy, pcz = self.pattern_center
|
|
71
|
+
x_min = -self.aspect_ratio * pcx / pcz
|
|
72
|
+
x_max = self.aspect_ratio * (1.0 - pcx) / pcz
|
|
73
|
+
y_min = -(1.0 - pcy) / pcz
|
|
74
|
+
y_max = pcy / pcz
|
|
75
|
+
return np.array([x_min, x_max, y_min, y_max])
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def corners_sample_mm(self) -> np.ndarray:
|
|
79
|
+
"""Top-left clockwise corners relative to the beam impact, in mm.
|
|
80
|
+
|
|
81
|
+
Uses the same Bruker PC and frame transform as direction_cosines.
|
|
82
|
+
Physical distances assume px_size is the effective screen pixel pitch
|
|
83
|
+
(including binning), not the camera sensor's pixel pitch.
|
|
84
|
+
"""
|
|
85
|
+
height = self.nrows * self.px_size / 1000.0
|
|
86
|
+
width = self.ncols * self.px_size / 1000.0
|
|
87
|
+
x, y, z = self.pattern_center
|
|
88
|
+
corners = np.array(
|
|
89
|
+
[
|
|
90
|
+
[-x * width, y * height, z * height],
|
|
91
|
+
[(1 - x) * width, y * height, z * height],
|
|
92
|
+
[(1 - x) * width, -(1 - y) * height, z * height],
|
|
93
|
+
[-x * width, -(1 - y) * height, z * height],
|
|
94
|
+
]
|
|
95
|
+
)
|
|
96
|
+
return corners @ sample_to_detector_matrix(
|
|
97
|
+
self.sample_tilt, self.tilt, self.azimuthal, self.twist
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def pattern_center_sample_mm(self) -> np.ndarray:
|
|
102
|
+
"""Perpendicular foot from impact to screen, in sample coordinates."""
|
|
103
|
+
distance = self.pattern_center[2] * self.nrows * self.px_size / 1000.0
|
|
104
|
+
return (
|
|
105
|
+
distance
|
|
106
|
+
* sample_to_detector_matrix(self.sample_tilt, self.tilt, self.azimuthal, self.twist)[2]
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def direction_cosines(
|
|
110
|
+
self,
|
|
111
|
+
device: torch.device | str | None = None,
|
|
112
|
+
dtype: torch.dtype = torch.float32,
|
|
113
|
+
) -> torch.Tensor:
|
|
114
|
+
"""Unit direction vectors (n_rows * n_cols, 3) in the sample frame.
|
|
115
|
+
|
|
116
|
+
Row-major over the detector (row 0 = top). Port of kikuchipy
|
|
117
|
+
``_get_direction_cosines_for_fixed_pc``.
|
|
118
|
+
"""
|
|
119
|
+
bounds = self.gnomonic_bounds
|
|
120
|
+
pcz = self.pattern_center[2]
|
|
121
|
+
x_scale = (bounds[1] - bounds[0]) / self.ncols
|
|
122
|
+
y_scale = (bounds[3] - bounds[2]) / self.nrows
|
|
123
|
+
|
|
124
|
+
gn_x = bounds[0] + x_scale * np.arange(self.ncols) + 0.5 * x_scale
|
|
125
|
+
gn_y = bounds[3] - y_scale * np.arange(self.nrows) - 0.5 * y_scale
|
|
126
|
+
|
|
127
|
+
det = np.empty((self.nrows, self.ncols, 3))
|
|
128
|
+
det[..., 0] = gn_x[None, :] * pcz
|
|
129
|
+
det[..., 1] = gn_y[:, None] * pcz
|
|
130
|
+
det[..., 2] = pcz
|
|
131
|
+
|
|
132
|
+
om = sample_to_detector_matrix(self.sample_tilt, self.tilt, self.azimuthal, self.twist)
|
|
133
|
+
dc = det.reshape(-1, 3) @ om # (det_to_sample = om.T; row vectors: v @ om)
|
|
134
|
+
dc /= np.linalg.norm(dc, axis=-1, keepdims=True)
|
|
135
|
+
return torch.as_tensor(dc, device=device, dtype=dtype)
|
|
136
|
+
|
|
137
|
+
def show(self, microscope=None, crystal=None):
|
|
138
|
+
"""Interactive screen placement and PC editor, also exportable to HTML.
|
|
139
|
+
|
|
140
|
+
If a microscope is supplied, its sample tilt and azimuth must match.
|
|
141
|
+
Edits are a separate snapshot; read them with widget.to_detector().
|
|
142
|
+
"""
|
|
143
|
+
from de_ebsd.viz.widgets import MicroscopeWidget
|
|
144
|
+
|
|
145
|
+
return MicroscopeWidget.from_setup(microscope=microscope, detector=self, crystal=crystal)
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""The simulated master pattern and projection onto a detector."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import h5py
|
|
10
|
+
import numpy as np
|
|
11
|
+
import torch
|
|
12
|
+
|
|
13
|
+
from de_ebsd.core.constants import DIVISION_FLOOR
|
|
14
|
+
from de_ebsd.core.lambert import sample_texture
|
|
15
|
+
from de_ebsd.core.rotation import quaternion_to_matrix
|
|
16
|
+
from de_ebsd.sim.detector import EBSDDetector
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from anyplotlib import Figure
|
|
20
|
+
|
|
21
|
+
__all__ = ["MasterPattern"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class MasterPattern:
|
|
26
|
+
"""Backscatter intensity on the sphere in square-Lambert storage.
|
|
27
|
+
|
|
28
|
+
Attributes
|
|
29
|
+
----------
|
|
30
|
+
data
|
|
31
|
+
Intensities, shape ``(n_energies, 2, n, n)``; hemisphere 0 is north.
|
|
32
|
+
energies_kev
|
|
33
|
+
Energy bin centers in keV, shape ``(n_energies,)``.
|
|
34
|
+
metadata
|
|
35
|
+
Free-form provenance (crystal, dmin, backend name, ...).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
data: torch.Tensor
|
|
39
|
+
energies_kev: np.ndarray
|
|
40
|
+
metadata: dict = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
def __post_init__(self) -> None:
|
|
43
|
+
shape_ok = (
|
|
44
|
+
self.data.ndim == 4
|
|
45
|
+
and self.data.shape[1] == 2
|
|
46
|
+
and self.data.shape[2] == self.data.shape[3]
|
|
47
|
+
)
|
|
48
|
+
if not shape_ok:
|
|
49
|
+
raise ValueError("data must have shape (n_energies, 2, n, n)")
|
|
50
|
+
self.energies_kev = np.atleast_1d(np.asarray(self.energies_kev, dtype=np.float64))
|
|
51
|
+
if len(self.energies_kev) != self.data.shape[0]:
|
|
52
|
+
raise ValueError("energies_kev length must match data.shape[0]")
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def npx(self) -> int:
|
|
56
|
+
"""Half-width of the Lambert grid (n = 2 npx + 1)."""
|
|
57
|
+
return (self.data.shape[-1] - 1) // 2
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def device(self) -> torch.device:
|
|
61
|
+
return self.data.device
|
|
62
|
+
|
|
63
|
+
def to(self, device: torch.device | str) -> MasterPattern:
|
|
64
|
+
"""Copy to another device."""
|
|
65
|
+
return MasterPattern(self.data.to(device), self.energies_kev, dict(self.metadata))
|
|
66
|
+
|
|
67
|
+
def energy_index(self, energy_kev: float | None) -> int:
|
|
68
|
+
if energy_kev is None:
|
|
69
|
+
return len(self.energies_kev) - 1 # highest energy bin
|
|
70
|
+
return int(np.argmin(np.abs(self.energies_kev - energy_kev)))
|
|
71
|
+
|
|
72
|
+
def sample(self, directions: torch.Tensor, energy_kev: float | None = None) -> torch.Tensor:
|
|
73
|
+
"""Interpolate the master pattern at unit vectors (..., 3).
|
|
74
|
+
|
|
75
|
+
Bilinear interpolation on the square-Lambert grid via
|
|
76
|
+
``grid_sample(align_corners=True)``, identical to kikuchipy's
|
|
77
|
+
interpolation. The hemisphere is selected by ``sign(z)``.
|
|
78
|
+
"""
|
|
79
|
+
directions = directions.to(self.data.device, self.data.dtype)
|
|
80
|
+
return sample_texture(self.data[self.energy_index(energy_kev)][None], directions)
|
|
81
|
+
|
|
82
|
+
def project(
|
|
83
|
+
self,
|
|
84
|
+
detector: EBSDDetector,
|
|
85
|
+
orientation,
|
|
86
|
+
energy_kev: float | None = None,
|
|
87
|
+
rescale: bool = True,
|
|
88
|
+
) -> torch.Tensor:
|
|
89
|
+
"""Project onto a detector for one or more crystal orientations.
|
|
90
|
+
|
|
91
|
+
Parameters
|
|
92
|
+
----------
|
|
93
|
+
detector
|
|
94
|
+
The detector geometry (fixed pattern center).
|
|
95
|
+
orientation
|
|
96
|
+
Crystal orientation(s): an ``orix.quaternion.Rotation`` or a
|
|
97
|
+
tensor/array of unit quaternions (N, 4) in (a, b, c, d) order.
|
|
98
|
+
energy_kev
|
|
99
|
+
Energy bin to project (default: highest).
|
|
100
|
+
rescale
|
|
101
|
+
Linearly rescale each pattern to [-1, 1].
|
|
102
|
+
|
|
103
|
+
Returns
|
|
104
|
+
-------
|
|
105
|
+
torch.Tensor
|
|
106
|
+
Patterns of shape (N, n_rows, n_cols).
|
|
107
|
+
"""
|
|
108
|
+
q = _as_quaternions(orientation).to(self.data.device, self.data.dtype)
|
|
109
|
+
dc = detector.direction_cosines(device=self.data.device, dtype=self.data.dtype)
|
|
110
|
+
|
|
111
|
+
rot = quaternion_to_matrix(q) # (N, 3, 3)
|
|
112
|
+
v = torch.einsum("nij,pj->npi", rot, dc) # sample frame -> crystal frame
|
|
113
|
+
pattern = self.sample(v, energy_kev=energy_kev) # (N, P)
|
|
114
|
+
|
|
115
|
+
if rescale:
|
|
116
|
+
pmin = pattern.amin(dim=1, keepdim=True)
|
|
117
|
+
pmax = pattern.amax(dim=1, keepdim=True)
|
|
118
|
+
pattern = 2.0 * (pattern - pmin) / (pmax - pmin).clamp_min(DIVISION_FLOOR) - 1.0
|
|
119
|
+
return pattern.reshape(-1, detector.nrows, detector.ncols)
|
|
120
|
+
|
|
121
|
+
def to_stereographic(
|
|
122
|
+
self, n: int = 501, hemisphere: str = "north", energy_kev: float | None = None
|
|
123
|
+
) -> torch.Tensor:
|
|
124
|
+
"""Resample one hemisphere into a stereographic (equal-angle) image."""
|
|
125
|
+
sign = 1.0 if hemisphere == "north" else -1.0
|
|
126
|
+
coords = torch.linspace(-1.0, 1.0, n, device=self.device)
|
|
127
|
+
ys, xs = torch.meshgrid(coords, coords, indexing="ij")
|
|
128
|
+
r2 = xs * xs + ys * ys
|
|
129
|
+
# inverse stereographic projection of the unit disk
|
|
130
|
+
vz = sign * (1.0 - r2) / (1.0 + r2)
|
|
131
|
+
vx = 2.0 * xs / (1.0 + r2)
|
|
132
|
+
vy = 2.0 * ys / (1.0 + r2)
|
|
133
|
+
v = torch.stack([vx, vy, vz], dim=-1)
|
|
134
|
+
image = self.sample(v, energy_kev=energy_kev)
|
|
135
|
+
return torch.where(r2 <= 1.0, image, torch.zeros_like(image))
|
|
136
|
+
|
|
137
|
+
def to_equirectangular(
|
|
138
|
+
self, height: int = 256, energy_kev: float | None = None
|
|
139
|
+
) -> torch.Tensor:
|
|
140
|
+
"""Resample the full sphere into an equirectangular (lat-long) image.
|
|
141
|
+
|
|
142
|
+
Row 0 is the north pole; used as the texture for the sphere view.
|
|
143
|
+
"""
|
|
144
|
+
width = 2 * height
|
|
145
|
+
theta = torch.linspace(0.0, math.pi, height, device=self.device) # polar
|
|
146
|
+
phi = torch.linspace(-math.pi, math.pi, width, device=self.device)
|
|
147
|
+
t, p = torch.meshgrid(theta, phi, indexing="ij")
|
|
148
|
+
v = torch.stack(
|
|
149
|
+
[torch.sin(t) * torch.cos(p), torch.sin(t) * torch.sin(p), torch.cos(t)], dim=-1
|
|
150
|
+
)
|
|
151
|
+
return self.sample(v, energy_kev=energy_kev)
|
|
152
|
+
|
|
153
|
+
def show(
|
|
154
|
+
self,
|
|
155
|
+
projection: str = "lambert",
|
|
156
|
+
energy_kev: float | None = None,
|
|
157
|
+
texture_height: int = 256,
|
|
158
|
+
**kwargs,
|
|
159
|
+
) -> Figure:
|
|
160
|
+
"""Display the master pattern.
|
|
161
|
+
|
|
162
|
+
Parameters
|
|
163
|
+
----------
|
|
164
|
+
projection
|
|
165
|
+
``"lambert"`` (both hemispheres side by side), ``"stereographic"``
|
|
166
|
+
(both hemispheres, equal-angle), or ``"sphere"`` (interactive 3D
|
|
167
|
+
sphere with the pattern as texture).
|
|
168
|
+
energy_kev
|
|
169
|
+
Energy bin to display (default: highest).
|
|
170
|
+
texture_height
|
|
171
|
+
Height of the equirectangular texture used by the interactive
|
|
172
|
+
sphere. Lower values reduce notebook/SpyDE payload size while
|
|
173
|
+
leaving the master-pattern data unchanged.
|
|
174
|
+
"""
|
|
175
|
+
from de_ebsd.viz.patterns import hemispheres_figure, sphere_figure
|
|
176
|
+
|
|
177
|
+
i_e = self.energy_index(energy_kev)
|
|
178
|
+
label = f"{self.energies_kev[i_e]:g} keV"
|
|
179
|
+
if projection == "lambert":
|
|
180
|
+
north = self.data[i_e, 0].detach().cpu().numpy()
|
|
181
|
+
south = self.data[i_e, 1].detach().cpu().numpy()
|
|
182
|
+
return hemispheres_figure(north, south, title=f"Master pattern ({label})")
|
|
183
|
+
if projection == "stereographic":
|
|
184
|
+
north = self.to_stereographic(hemisphere="north", energy_kev=energy_kev)
|
|
185
|
+
south = self.to_stereographic(hemisphere="south", energy_kev=energy_kev)
|
|
186
|
+
return hemispheres_figure(
|
|
187
|
+
north.detach().cpu().numpy(),
|
|
188
|
+
south.detach().cpu().numpy(),
|
|
189
|
+
title=f"Master pattern, stereographic ({label})",
|
|
190
|
+
)
|
|
191
|
+
if projection == "sphere":
|
|
192
|
+
if texture_height < 8:
|
|
193
|
+
raise ValueError("texture_height must be >= 8")
|
|
194
|
+
texture = self.to_equirectangular(height=int(texture_height), energy_kev=energy_kev)
|
|
195
|
+
return sphere_figure(
|
|
196
|
+
texture.detach().cpu().numpy(), title=f"Master pattern ({label})", **kwargs
|
|
197
|
+
)
|
|
198
|
+
raise ValueError("projection must be 'lambert', 'stereographic', or 'sphere'")
|
|
199
|
+
|
|
200
|
+
def save(self, path: str) -> None:
|
|
201
|
+
"""Save to HDF5 (data, energies, and string metadata)."""
|
|
202
|
+
with h5py.File(path, "w") as f:
|
|
203
|
+
f.create_dataset("master_pattern", data=self.data.detach().cpu().numpy())
|
|
204
|
+
f.create_dataset("energies_kev", data=self.energies_kev)
|
|
205
|
+
meta = f.create_group("metadata")
|
|
206
|
+
for key, value in self.metadata.items():
|
|
207
|
+
meta.attrs[key] = str(value)
|
|
208
|
+
|
|
209
|
+
@classmethod
|
|
210
|
+
def load(cls, path: str, device: torch.device | str | None = None) -> MasterPattern:
|
|
211
|
+
"""Load a master pattern saved with :meth:`save`."""
|
|
212
|
+
with h5py.File(path, "r") as f:
|
|
213
|
+
data = torch.as_tensor(np.asarray(f["master_pattern"]), device=device)
|
|
214
|
+
energies = np.asarray(f["energies_kev"])
|
|
215
|
+
metadata = dict(f["metadata"].attrs) if "metadata" in f else {}
|
|
216
|
+
return cls(data=data, energies_kev=energies, metadata=metadata)
|
|
217
|
+
|
|
218
|
+
def __repr__(self) -> str:
|
|
219
|
+
e = self.energies_kev
|
|
220
|
+
return (
|
|
221
|
+
f"MasterPattern(npx={self.npx}, {len(e)} energy bin(s) "
|
|
222
|
+
f"[{e.min():g}..{e.max():g} keV], device={self.data.device.type}, "
|
|
223
|
+
f"backend={self.metadata.get('backend', '?')})"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _as_quaternions(orientation) -> torch.Tensor:
|
|
228
|
+
data = getattr(orientation, "data", orientation) # orix Rotation -> .data
|
|
229
|
+
q = torch.as_tensor(np.asarray(data) if not isinstance(data, torch.Tensor) else data)
|
|
230
|
+
q = q.reshape(-1, 4)
|
|
231
|
+
return q / q.norm(dim=-1, keepdim=True).clamp_min(DIVISION_FLOOR)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Microscope: beam energy and sample geometry.
|
|
2
|
+
|
|
3
|
+
The :class:`Microscope` collects the experimental parameters that belong to
|
|
4
|
+
the instrument rather than to the crystal or the detector: accelerating
|
|
5
|
+
voltage and the sample tilt. Detector geometry lives on
|
|
6
|
+
:class:`de_ebsd.sim.detector.EBSDDetector`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from de_ebsd.core.constants import (
|
|
15
|
+
interaction_constant,
|
|
16
|
+
relativistic_correction,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = ["Microscope"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Microscope:
|
|
24
|
+
"""An SEM configured for EBSD.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
energy
|
|
29
|
+
Beam energy in eV (e.g. ``20e3``).
|
|
30
|
+
sample_tilt
|
|
31
|
+
Sample tilt from the horizontal in degrees (70 is standard for EBSD).
|
|
32
|
+
azimuthal
|
|
33
|
+
Detector azimuthal angle with respect to the tilt axis, in degrees.
|
|
34
|
+
probe_diameter_nm
|
|
35
|
+
Nominal circular beam diameter perpendicular to the beam, in nm.
|
|
36
|
+
Optional geometry metadata, not a finite-probe scattering model.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
energy: float = 20e3
|
|
40
|
+
sample_tilt: float = 70.0
|
|
41
|
+
azimuthal: float = 0.0
|
|
42
|
+
probe_diameter_nm: float | None = None
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
if self.energy <= 0:
|
|
46
|
+
raise ValueError("energy must be positive (in eV)")
|
|
47
|
+
if self.probe_diameter_nm is not None and (
|
|
48
|
+
not math.isfinite(self.probe_diameter_nm) or self.probe_diameter_nm <= 0
|
|
49
|
+
):
|
|
50
|
+
raise ValueError("probe_diameter_nm must be finite and positive")
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def probe_footprint_nm(self) -> tuple[float, float] | None:
|
|
54
|
+
"""Major/minor diameters on a flat surface; None at grazing incidence.
|
|
55
|
+
|
|
56
|
+
Geometric projection of a circular parallel probe, not an interaction
|
|
57
|
+
volume, beam broadening prediction, or a measured spatial resolution.
|
|
58
|
+
"""
|
|
59
|
+
cosine = math.cos(math.radians(self.sample_tilt))
|
|
60
|
+
if self.probe_diameter_nm is None or cosine <= 1e-12:
|
|
61
|
+
return None
|
|
62
|
+
return self.probe_diameter_nm / cosine, self.probe_diameter_nm
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def energy_kev(self) -> float:
|
|
66
|
+
"""Beam energy in keV."""
|
|
67
|
+
return self.energy / 1e3
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def gamma(self) -> float:
|
|
71
|
+
"""Relativistic correction factor 1 + e V / (m0 c^2)."""
|
|
72
|
+
return relativistic_correction(self.energy_kev)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def sigma(self) -> float:
|
|
76
|
+
"""Interaction constant in (V nm)^-1."""
|
|
77
|
+
return interaction_constant(self.energy_kev, v0mod=0.0)
|
|
78
|
+
|
|
79
|
+
def show(self, detector=None, crystal=None):
|
|
80
|
+
"""Editable beam, footprint and optional detector geometry inspector.
|
|
81
|
+
|
|
82
|
+
When supplied, detector sample tilt/azimuth must agree with this object.
|
|
83
|
+
Edits are a separate snapshot; read them with widget.to_microscope().
|
|
84
|
+
"""
|
|
85
|
+
from de_ebsd.viz.widgets import MicroscopeWidget
|
|
86
|
+
|
|
87
|
+
return MicroscopeWidget.from_setup(microscope=self, detector=detector, crystal=crystal)
|
|
88
|
+
|
|
89
|
+
def __repr__(self) -> str:
|
|
90
|
+
return (
|
|
91
|
+
f"Microscope(energy={self.energy:g} eV, sample_tilt={self.sample_tilt:g} deg, "
|
|
92
|
+
f"azimuthal={self.azimuthal:g} deg)"
|
|
93
|
+
)
|