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,311 @@
|
|
|
1
|
+
"""Spherical harmonic transform on a Gauss-Legendre x equiangular grid.
|
|
2
|
+
|
|
3
|
+
Forward and inverse SHT for band-limited functions on the sphere, with the
|
|
4
|
+
associated Legendre functions built by stable upward recursion.
|
|
5
|
+
:class:`SphericalHarmonicTransform` caches the grid and basis so the master and
|
|
6
|
+
every pattern are expanded identically.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import torch
|
|
15
|
+
|
|
16
|
+
from de_ebsd.core.device import resolve_device
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"SphericalHarmonicTransform",
|
|
20
|
+
"forward_sht",
|
|
21
|
+
"gauss_legendre_grid",
|
|
22
|
+
"inverse_sht",
|
|
23
|
+
"lm_indices",
|
|
24
|
+
"n_coefficients",
|
|
25
|
+
"spherical_harmonics",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
_INV_SQRT_4PI = 1.0 / math.sqrt(4.0 * math.pi)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def n_coefficients(bandwidth: int) -> int:
|
|
32
|
+
"""Number of packed coefficients for a transform of the given bandwidth ``L``."""
|
|
33
|
+
return (bandwidth + 1) ** 2
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def lm_indices(
|
|
37
|
+
bandwidth: int, device: torch.device | str | None = None
|
|
38
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
39
|
+
"""Degree and order of each packed coefficient slot.
|
|
40
|
+
|
|
41
|
+
Returns ``(degrees, orders)``, each a 1-D ``long`` tensor of length
|
|
42
|
+
``(L + 1) ** 2``, so that slot ``c`` holds ``a_{l m}`` with ``l = degrees[c]``
|
|
43
|
+
and ``m = orders[c]``, ordered ``c = l * l + l + m``.
|
|
44
|
+
"""
|
|
45
|
+
degrees = []
|
|
46
|
+
orders = []
|
|
47
|
+
for ell in range(bandwidth + 1):
|
|
48
|
+
for m in range(-ell, ell + 1):
|
|
49
|
+
degrees.append(ell)
|
|
50
|
+
orders.append(m)
|
|
51
|
+
dl = torch.tensor(degrees, dtype=torch.long, device=device)
|
|
52
|
+
dm = torch.tensor(orders, dtype=torch.long, device=device)
|
|
53
|
+
return dl, dm
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _gauss_legendre_nodes(
|
|
57
|
+
n: int, device: torch.device | str | None, dtype: torch.dtype
|
|
58
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
59
|
+
x, w = np.polynomial.legendre.leggauss(n)
|
|
60
|
+
xt = torch.as_tensor(x, device=device, dtype=dtype)
|
|
61
|
+
wt = torch.as_tensor(w, device=device, dtype=dtype)
|
|
62
|
+
return xt, wt
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def gauss_legendre_grid(
|
|
66
|
+
bandwidth: int,
|
|
67
|
+
device: torch.device | str | None = None,
|
|
68
|
+
dtype: torch.dtype = torch.float64,
|
|
69
|
+
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
|
70
|
+
"""Build the exact quadrature grid for bandwidth ``L``.
|
|
71
|
+
|
|
72
|
+
Returns ``(theta, phi, weights)``:
|
|
73
|
+
|
|
74
|
+
- ``theta`` -- ``(L + 1,)`` colatitude nodes, ``arccos`` of the Gauss-Legendre
|
|
75
|
+
nodes (in ``[0, pi]``).
|
|
76
|
+
- ``phi`` -- ``(2 L + 2,)`` equiangular azimuth nodes ``2 pi k / N_phi``.
|
|
77
|
+
- ``weights`` -- ``(L + 1, 2 L + 2)`` quadrature weights ``w_theta[j] * (2 pi /
|
|
78
|
+
N_phi)`` such that ``sum weights[j, k] f(theta_j, phi_k)`` integrates any
|
|
79
|
+
band-limited ``f`` over the sphere exactly.
|
|
80
|
+
"""
|
|
81
|
+
n_theta = bandwidth + 1
|
|
82
|
+
n_phi = 2 * bandwidth + 2
|
|
83
|
+
x, w_theta = _gauss_legendre_nodes(n_theta, device, dtype)
|
|
84
|
+
theta = torch.arccos(x)
|
|
85
|
+
phi = 2.0 * math.pi * torch.arange(n_phi, device=device, dtype=dtype) / n_phi
|
|
86
|
+
weights = w_theta[:, None] * (2.0 * math.pi / n_phi)
|
|
87
|
+
weights = weights.expand(n_theta, n_phi).contiguous()
|
|
88
|
+
return theta, phi, weights
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _normalized_alf(bandwidth: int, x: torch.Tensor, u: torch.Tensor) -> torch.Tensor:
|
|
92
|
+
L = bandwidth
|
|
93
|
+
p = x.new_zeros((L + 1, L + 1, *x.shape))
|
|
94
|
+
p[0, 0] = _INV_SQRT_4PI
|
|
95
|
+
# sectoral terms P_m^m (Condon-Shortley phase accumulates through the -1)
|
|
96
|
+
for m in range(1, L + 1):
|
|
97
|
+
p[m, m] = -math.sqrt((2 * m + 1) / (2 * m)) * u * p[m - 1, m - 1]
|
|
98
|
+
# ascend in degree l at fixed order m
|
|
99
|
+
for m in range(L + 1):
|
|
100
|
+
if m + 1 <= L:
|
|
101
|
+
# l = m + 1: the P_{l-2} term vanishes (P_{m-1}^m = 0)
|
|
102
|
+
a = math.sqrt((2 * (m + 1) - 1) * (2 * (m + 1) + 1) / ((m + 1 - m) * (m + 1 + m)))
|
|
103
|
+
p[m + 1, m] = a * x * p[m, m]
|
|
104
|
+
for ell in range(m + 2, L + 1):
|
|
105
|
+
a = math.sqrt((2 * ell - 1) * (2 * ell + 1) / ((ell - m) * (ell + m)))
|
|
106
|
+
b = math.sqrt(
|
|
107
|
+
(2 * ell + 1)
|
|
108
|
+
* (ell + m - 1)
|
|
109
|
+
* (ell - m - 1)
|
|
110
|
+
/ ((2 * ell - 3) * (ell - m) * (ell + m))
|
|
111
|
+
)
|
|
112
|
+
p[ell, m] = a * x * p[ell - 1, m] - b * p[ell - 2, m]
|
|
113
|
+
return p
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def spherical_harmonics(bandwidth: int, theta: torch.Tensor, phi: torch.Tensor) -> torch.Tensor:
|
|
117
|
+
"""Dense complex spherical-harmonic basis ``Y_lm`` on a grid.
|
|
118
|
+
|
|
119
|
+
``theta`` (``(N_theta,)`` colatitudes) and ``phi`` (``(N_phi,)`` azimuths) are
|
|
120
|
+
the 1-D node vectors of a separable grid. Returns the complex design matrix of
|
|
121
|
+
shape ``((L + 1) ** 2, N_theta, N_phi)`` with slot ``c`` (ordering of
|
|
122
|
+
:func:`lm_indices`) holding ``Y_{l m}`` evaluated at every ``(theta_j, phi_k)``.
|
|
123
|
+
Matches ``scipy.special.sph_harm_y(l, m, theta, phi)``.
|
|
124
|
+
"""
|
|
125
|
+
x = torch.cos(theta)
|
|
126
|
+
u = torch.sin(theta).clamp_min(0.0)
|
|
127
|
+
p = _normalized_alf(bandwidth, x, u) # (L+1, L+1, N_theta)
|
|
128
|
+
degrees, orders = lm_indices(bandwidth, device=theta.device)
|
|
129
|
+
# P_l^{|m|} for each coefficient slot -> (n_coeff, N_theta)
|
|
130
|
+
p_lm = p[degrees, orders.abs()]
|
|
131
|
+
# Condon-Shortley: Y_l^{-m} = (-1)^m conj(Y_l^m); fold the sign of negative m
|
|
132
|
+
parity = 1.0 - 2.0 * (orders.abs() % 2).to(theta.dtype) # (-1)^|m|
|
|
133
|
+
sign = torch.where(orders < 0, parity, torch.ones_like(parity))
|
|
134
|
+
m_real = orders.to(theta.dtype)
|
|
135
|
+
phase = torch.exp(1j * (m_real[:, None] * phi[None, :])) # (n_coeff, N_phi)
|
|
136
|
+
y = (sign[:, None].to(phase.dtype) * p_lm.to(phase.dtype))[:, :, None] * phase[:, None, :]
|
|
137
|
+
return y
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def forward_sht(
|
|
141
|
+
values: torch.Tensor,
|
|
142
|
+
theta: torch.Tensor,
|
|
143
|
+
phi: torch.Tensor,
|
|
144
|
+
bandwidth: int,
|
|
145
|
+
weights: torch.Tensor | None = None,
|
|
146
|
+
) -> torch.Tensor:
|
|
147
|
+
"""Stateless forward transform on a separable grid (dense design matrix).
|
|
148
|
+
|
|
149
|
+
``values`` has shape ``(..., N_theta, N_phi)`` (real or complex), sampled at the
|
|
150
|
+
``theta``/``phi`` nodes. Returns complex coefficients ``(..., (L + 1) ** 2)``.
|
|
151
|
+
``weights`` are the quadrature weights ``(N_theta, N_phi)``; if omitted the
|
|
152
|
+
Gauss-Legendre weights for this bandwidth are used, which assumes ``theta`` /
|
|
153
|
+
``phi`` are the canonical grid from :func:`gauss_legendre_grid`. For repeated
|
|
154
|
+
transforms prefer :class:`SphericalHarmonicTransform`.
|
|
155
|
+
"""
|
|
156
|
+
y = spherical_harmonics(bandwidth, theta, phi) # (n_coeff, N_theta, N_phi)
|
|
157
|
+
if weights is None:
|
|
158
|
+
_, _, weights = gauss_legendre_grid(bandwidth, theta.device, theta.dtype)
|
|
159
|
+
weighted = (values * weights).to(y.dtype)
|
|
160
|
+
return torch.einsum("...tp,ctp->...c", weighted, y.conj())
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def inverse_sht(coeffs: torch.Tensor, theta: torch.Tensor, phi: torch.Tensor) -> torch.Tensor:
|
|
164
|
+
"""Stateless inverse transform: coefficients ``a_lm`` -> samples on a grid.
|
|
165
|
+
|
|
166
|
+
``coeffs`` has shape ``(..., (L + 1) ** 2)``; the bandwidth is inferred from its
|
|
167
|
+
length. Returns complex samples ``(..., N_theta, N_phi)`` at the ``theta``/``phi``
|
|
168
|
+
nodes (the dense synthesis ``sum_lm a_lm Y_lm``).
|
|
169
|
+
"""
|
|
170
|
+
n_coeff = coeffs.shape[-1]
|
|
171
|
+
bandwidth = int(round(math.sqrt(n_coeff))) - 1
|
|
172
|
+
y = spherical_harmonics(bandwidth, theta, phi)
|
|
173
|
+
return torch.einsum("...c,ctp->...tp", coeffs.to(y.dtype), y)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class SphericalHarmonicTransform:
|
|
177
|
+
"""A cached, separable spherical harmonic transform for a fixed bandwidth.
|
|
178
|
+
|
|
179
|
+
Builds the exact Gauss-Legendre x equiangular quadrature grid and the
|
|
180
|
+
associated-Legendre design matrices once, then transforms via an FFT over phi
|
|
181
|
+
and a per-order matmul over theta. Both directions are batched over arbitrary
|
|
182
|
+
leading dimensions and are autograd-differentiable in the samples/coefficients.
|
|
183
|
+
|
|
184
|
+
Parameters
|
|
185
|
+
----------
|
|
186
|
+
bandwidth : int
|
|
187
|
+
The band limit ``L``. Sets the quadrature grid (``N_theta = L + 1``,
|
|
188
|
+
``N_phi = 2 L + 2``) and the packed coefficient count ``(L + 1) ** 2``.
|
|
189
|
+
device : torch.device or str, optional
|
|
190
|
+
Device the grid and cached design matrices are built on (default CPU).
|
|
191
|
+
dtype : torch.dtype, optional
|
|
192
|
+
Real floating dtype of the transform; must be ``torch.float32`` or
|
|
193
|
+
``torch.float64`` (default ``torch.float64``). The complex coefficient dtype
|
|
194
|
+
is the matching ``complex64`` / ``complex128``.
|
|
195
|
+
|
|
196
|
+
Attributes
|
|
197
|
+
----------
|
|
198
|
+
bandwidth : int
|
|
199
|
+
The band limit ``L``.
|
|
200
|
+
theta, phi : torch.Tensor
|
|
201
|
+
1-D quadrature nodes (``(L + 1,)`` and ``(2 L + 2,)``).
|
|
202
|
+
weights : torch.Tensor
|
|
203
|
+
``(L + 1, 2 L + 2)`` quadrature weights.
|
|
204
|
+
n_coeff : int
|
|
205
|
+
Number of packed coefficients ``(L + 1) ** 2``.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
def __init__(
|
|
209
|
+
self,
|
|
210
|
+
bandwidth: int,
|
|
211
|
+
*,
|
|
212
|
+
device: torch.device | str | None = None,
|
|
213
|
+
dtype: torch.dtype = torch.float64,
|
|
214
|
+
) -> None:
|
|
215
|
+
if dtype not in (torch.float32, torch.float64):
|
|
216
|
+
raise ValueError("dtype must be a real floating type (float32 or float64)")
|
|
217
|
+
self.bandwidth = int(bandwidth)
|
|
218
|
+
self.device = resolve_device(device)
|
|
219
|
+
self.dtype = dtype
|
|
220
|
+
self.cdtype = torch.complex128 if dtype == torch.float64 else torch.complex64
|
|
221
|
+
|
|
222
|
+
L = self.bandwidth
|
|
223
|
+
self.n_theta = L + 1
|
|
224
|
+
self.n_phi = 2 * L + 2
|
|
225
|
+
self.n_coeff = n_coefficients(L)
|
|
226
|
+
|
|
227
|
+
theta, phi, weights = gauss_legendre_grid(L, self.device, dtype)
|
|
228
|
+
self.theta = theta
|
|
229
|
+
self.phi = phi
|
|
230
|
+
self.weights = weights
|
|
231
|
+
|
|
232
|
+
degrees, orders = lm_indices(L, device=self.device)
|
|
233
|
+
self._degrees = degrees
|
|
234
|
+
self._orders = orders
|
|
235
|
+
# FFT bin holding Fourier order m (negative m wraps to N_phi + m)
|
|
236
|
+
self._freq_bins = torch.remainder(orders, self.n_phi)
|
|
237
|
+
|
|
238
|
+
with torch.no_grad():
|
|
239
|
+
x = torch.cos(theta)
|
|
240
|
+
u = torch.sin(theta).clamp_min(0.0)
|
|
241
|
+
_, w_theta = _gauss_legendre_nodes(self.n_theta, self.device, dtype)
|
|
242
|
+
p = _normalized_alf(L, x, u) # (L+1, L+1, N_theta)
|
|
243
|
+
p_lm = p[degrees, orders.abs()] # (n_coeff, N_theta)
|
|
244
|
+
# fold the Condon-Shortley sign of negative orders into the design matrix
|
|
245
|
+
parity = 1.0 - 2.0 * (orders.abs() % 2).to(dtype) # (-1)^|m|
|
|
246
|
+
sign = torch.where(orders < 0, parity, torch.ones_like(parity))
|
|
247
|
+
design = sign[:, None] * p_lm # A[c, j] = s(m) P_l^{|m|}(cos theta_j)
|
|
248
|
+
self._design = design.contiguous()
|
|
249
|
+
# forward weights: (2 pi / N_phi) w_theta[j] A[c, j]
|
|
250
|
+
self._fwd = ((2.0 * math.pi / self.n_phi) * w_theta[None, :] * design).contiguous()
|
|
251
|
+
|
|
252
|
+
def to(
|
|
253
|
+
self,
|
|
254
|
+
device: torch.device | str | None = None,
|
|
255
|
+
dtype: torch.dtype | None = None,
|
|
256
|
+
) -> SphericalHarmonicTransform:
|
|
257
|
+
"""Return a plan on a new device / real dtype (cheap to rebuild)."""
|
|
258
|
+
return SphericalHarmonicTransform(
|
|
259
|
+
self.bandwidth,
|
|
260
|
+
device=device if device is not None else self.device,
|
|
261
|
+
dtype=dtype if dtype is not None else self.dtype,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def grid_directions(self) -> torch.Tensor:
|
|
265
|
+
"""Cartesian unit vectors ``(N_theta, N_phi, 3)`` of every quadrature node.
|
|
266
|
+
|
|
267
|
+
``n = (sin th cos ph, sin th sin ph, cos th)`` -- sample the sphere here
|
|
268
|
+
(e.g. resample a master pattern onto these directions), then ``forward``.
|
|
269
|
+
"""
|
|
270
|
+
st = torch.sin(self.theta)
|
|
271
|
+
ct = torch.cos(self.theta)
|
|
272
|
+
cp = torch.cos(self.phi)
|
|
273
|
+
sp = torch.sin(self.phi)
|
|
274
|
+
nx = st[:, None] * cp[None, :]
|
|
275
|
+
ny = st[:, None] * sp[None, :]
|
|
276
|
+
nz = ct[:, None].expand(self.n_theta, self.n_phi)
|
|
277
|
+
return torch.stack([nx, ny, nz], dim=-1)
|
|
278
|
+
|
|
279
|
+
def forward(self, values: torch.Tensor) -> torch.Tensor:
|
|
280
|
+
"""Samples ``(..., N_theta, N_phi)`` -> coefficients ``(..., n_coeff)``.
|
|
281
|
+
|
|
282
|
+
``values`` may be real or complex; the result is complex. Differentiable
|
|
283
|
+
in ``values``.
|
|
284
|
+
"""
|
|
285
|
+
if values.shape[-2:] != (self.n_theta, self.n_phi):
|
|
286
|
+
raise ValueError(
|
|
287
|
+
f"values trailing shape {tuple(values.shape[-2:])} != grid "
|
|
288
|
+
f"{(self.n_theta, self.n_phi)}"
|
|
289
|
+
)
|
|
290
|
+
# FFT over phi: fhat[..., j, m] = sum_k values[..., j, k] exp(-i m phi_k)
|
|
291
|
+
fhat = torch.fft.fft(values.to(self.cdtype), dim=-1)
|
|
292
|
+
# pick the bin of each coefficient's order -> (..., N_theta, n_coeff)
|
|
293
|
+
fhat_c = fhat.index_select(-1, self._freq_bins)
|
|
294
|
+
# a_c = sum_j fwd[c, j] fhat_c[..., j, c]
|
|
295
|
+
return torch.einsum("cj,...jc->...c", self._fwd.to(self.cdtype), fhat_c)
|
|
296
|
+
|
|
297
|
+
def inverse(self, coeffs: torch.Tensor) -> torch.Tensor:
|
|
298
|
+
"""Coefficients ``(..., n_coeff)`` -> samples ``(..., N_theta, N_phi)``.
|
|
299
|
+
|
|
300
|
+
Returns complex samples (take ``.real`` for a real-valued function).
|
|
301
|
+
Differentiable in ``coeffs``.
|
|
302
|
+
"""
|
|
303
|
+
if coeffs.shape[-1] != self.n_coeff:
|
|
304
|
+
raise ValueError(f"coeffs last dim {coeffs.shape[-1]} != n_coeff {self.n_coeff}")
|
|
305
|
+
coeffs = coeffs.to(self.cdtype)
|
|
306
|
+
# per-order theta synthesis, scattered onto FFT bins: g[..., j, bin]
|
|
307
|
+
contrib = torch.einsum("...c,cj->...jc", coeffs, self._design.to(self.cdtype))
|
|
308
|
+
g = coeffs.new_zeros((*coeffs.shape[:-1], self.n_theta, self.n_phi))
|
|
309
|
+
g.index_add_(-1, self._freq_bins, contrib)
|
|
310
|
+
# inverse FFT (unnormalized): f[..., j, k] = sum_bin g[..., j, bin] exp(+i bin phi_k)
|
|
311
|
+
return self.n_phi * torch.fft.ifft(g, dim=-1)
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""End-to-end spherical indexer.
|
|
2
|
+
|
|
3
|
+
Back-projects each pattern to the sphere, expands it in spherical harmonics,
|
|
4
|
+
cross-correlates against the precomputed master spectrum over all of SO(3), and
|
|
5
|
+
reads the orientation off the peak. ``normalize=True`` (the default) divides out
|
|
6
|
+
the rotation-dependent master energy inside the field of view, which keeps a
|
|
7
|
+
bright master region from outscoring the true orientation.
|
|
8
|
+
|
|
9
|
+
``grid_to_rotation(argmax)`` is directly the sample orientation passed to
|
|
10
|
+
``MasterPattern.project`` -- no inverse or transpose.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
|
|
17
|
+
import torch
|
|
18
|
+
|
|
19
|
+
from de_ebsd.core.device import resolve_device
|
|
20
|
+
from de_ebsd.index.backproject import pattern_to_sphere
|
|
21
|
+
from de_ebsd.index.spherical.correlation import so3_cross_correlation
|
|
22
|
+
from de_ebsd.index.spherical.harmonics import SphericalHarmonicTransform
|
|
23
|
+
from de_ebsd.index.spherical.wigner import grid_to_rotation, so3_grid, wigner_d
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SphericalIndexer:
|
|
27
|
+
"""Index EBSD patterns against a master pattern by spherical cross-correlation.
|
|
28
|
+
|
|
29
|
+
Build once (the master spectrum is precomputed here), then call :meth:`index`
|
|
30
|
+
on a single pattern or a stack.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
master
|
|
35
|
+
A :class:`de_ebsd.sim.MasterPattern`, sampled via ``master.sample``.
|
|
36
|
+
detector
|
|
37
|
+
:class:`~de_ebsd.sim.detector.EBSDDetector` geometry, inverted to
|
|
38
|
+
back-project each pattern to the sphere.
|
|
39
|
+
bandwidth
|
|
40
|
+
Band limit ``L``, the accuracy/speed knob: the SOFT grid step is
|
|
41
|
+
``360 / (2L + 2)`` degrees. Cost grows ~``O(L^4)`` in both time and memory,
|
|
42
|
+
since :func:`~de_ebsd.index.spherical.wigner.wigner_d` materializes a
|
|
43
|
+
``(N, L+1, 2L+1, 2L+1)`` tensor.
|
|
44
|
+
device
|
|
45
|
+
Resolved as explicit argument > ``master.device`` > CPU.
|
|
46
|
+
energy_kev
|
|
47
|
+
Energy bin to sample the master at; ``None`` (default) selects the highest.
|
|
48
|
+
|
|
49
|
+
Attributes
|
|
50
|
+
----------
|
|
51
|
+
sht : SphericalHarmonicTransform
|
|
52
|
+
The shared transform plan; master and patterns both go through it.
|
|
53
|
+
a_lm, a2_lm : torch.Tensor
|
|
54
|
+
Spectra of the master and of ``M^2``, complex, shape ``((L+1)**2,)``.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, master, detector, bandwidth=53, device=None, energy_kev=None):
|
|
58
|
+
self.master = master
|
|
59
|
+
self.detector = detector
|
|
60
|
+
self.bandwidth = int(bandwidth)
|
|
61
|
+
self.energy_kev = energy_kev
|
|
62
|
+
|
|
63
|
+
self.device = resolve_device(device, getattr(master, "device", "cpu"))
|
|
64
|
+
|
|
65
|
+
# pin the plan to the master's real dtype so master and patterns transform
|
|
66
|
+
# identically; the plan requires a real float type
|
|
67
|
+
master_dtype = getattr(getattr(master, "data", None), "dtype", torch.float64)
|
|
68
|
+
self.dtype = (
|
|
69
|
+
master_dtype if master_dtype in (torch.float32, torch.float64) else (torch.float64)
|
|
70
|
+
)
|
|
71
|
+
self._eps = torch.finfo(self.dtype).eps
|
|
72
|
+
|
|
73
|
+
# the plan does NOT auto-move from its CPU/float64 default, so pin it here
|
|
74
|
+
self.sht = SphericalHarmonicTransform(self.bandwidth, device=self.device, dtype=self.dtype)
|
|
75
|
+
|
|
76
|
+
# quadrature directions M = (L+1)*(2L+2)
|
|
77
|
+
self._dirs = self.sht.grid_directions().reshape(-1, 3)
|
|
78
|
+
|
|
79
|
+
m_samples = master.sample(self._dirs, energy_kev=self.energy_kev)
|
|
80
|
+
m_samples = m_samples.to(device=self.device, dtype=self.dtype)
|
|
81
|
+
grid_shape = (self.sht.n_theta, self.sht.n_phi)
|
|
82
|
+
self.a_lm = self.sht.forward(m_samples.reshape(grid_shape))
|
|
83
|
+
self.a2_lm = self.sht.forward((m_samples**2).reshape(grid_shape))
|
|
84
|
+
|
|
85
|
+
self._weights_flat = self.sht.weights.reshape(-1)
|
|
86
|
+
|
|
87
|
+
# cache the O(L^4) Wigner-d block: so3_cross_correlation otherwise rebuilds it
|
|
88
|
+
# per call, so a mini-batched scan would pay for it per chunk
|
|
89
|
+
_, beta_grid, _ = so3_grid(self.bandwidth, device=self.device, dtype=self.dtype)
|
|
90
|
+
self._wig_d = wigner_d(self.bandwidth, beta_grid)
|
|
91
|
+
|
|
92
|
+
# normalizer terms, cached on the first normalize=True call; they depend only
|
|
93
|
+
# on the master and the fixed detector pattern_center, so a variable pattern_center must
|
|
94
|
+
# invalidate them
|
|
95
|
+
self._w_lm = None
|
|
96
|
+
self._a_w = None
|
|
97
|
+
self._denom_geo = None
|
|
98
|
+
|
|
99
|
+
def _geometry_normalizer(self):
|
|
100
|
+
if self._denom_geo is not None:
|
|
101
|
+
return self._denom_geo
|
|
102
|
+
# the FOV mask is fixed by detector geometry: recover it by back-projecting a
|
|
103
|
+
# zero pattern and keeping only the mask output
|
|
104
|
+
zero = torch.zeros(
|
|
105
|
+
(1, self.detector.nrows, self.detector.ncols),
|
|
106
|
+
device=self.device,
|
|
107
|
+
dtype=self.dtype,
|
|
108
|
+
)
|
|
109
|
+
_, w = pattern_to_sphere(zero, self.detector, self._dirs)
|
|
110
|
+
w_lm = self.sht.forward(w.reshape(self.sht.n_theta, self.sht.n_phi))
|
|
111
|
+
# master mean and second moment over the rotated FOV, aligned elementwise with X
|
|
112
|
+
s1 = so3_cross_correlation(self.a_lm, w_lm, self.bandwidth, wigner_d_cached=self._wig_d)
|
|
113
|
+
s2 = so3_cross_correlation(self.a2_lm, w_lm, self.bandwidth, wigner_d_cached=self._wig_d)
|
|
114
|
+
a_w = math.sqrt(4.0 * math.pi) * w_lm[0].real # FOV spherical area
|
|
115
|
+
denom = (s2 - s1**2 / a_w.clamp_min(self._eps)).clamp_min(self._eps).sqrt()
|
|
116
|
+
self._w_lm = w_lm
|
|
117
|
+
self._a_w = a_w
|
|
118
|
+
self._denom_geo = denom
|
|
119
|
+
return denom
|
|
120
|
+
|
|
121
|
+
def index(self, patterns, normalize=True, return_surface=False):
|
|
122
|
+
"""Index one pattern or a stack, returning orix orientations and quality.
|
|
123
|
+
|
|
124
|
+
Parameters
|
|
125
|
+
----------
|
|
126
|
+
patterns
|
|
127
|
+
A detector image ``(H, W)`` or stack ``(Np, H, W)``, numpy or torch.
|
|
128
|
+
normalize
|
|
129
|
+
Divide out the master's local FOV norm (default), removing the bias
|
|
130
|
+
toward orientations that place bright master regions in the field of
|
|
131
|
+
view. ``False`` keeps the raw masked cross-correlation, unbiased only
|
|
132
|
+
for near-uniform-norm masters.
|
|
133
|
+
return_surface
|
|
134
|
+
Also return the SO(3) score surface that was arg-maxed, as a third
|
|
135
|
+
element. It feeds :func:`de_ebsd.viz.maps.show_correlation` directly.
|
|
136
|
+
|
|
137
|
+
Returns
|
|
138
|
+
-------
|
|
139
|
+
rotations : orix.quaternion.Rotation
|
|
140
|
+
Best-matching sample orientation per pattern, length ``Np``.
|
|
141
|
+
quality : torch.Tensor
|
|
142
|
+
Peak score per pattern. Normalized, this is roughly in ``[-1, 1]`` and
|
|
143
|
+
comparable across patterns; unnormalized it has arbitrary scale.
|
|
144
|
+
surface : torch.Tensor
|
|
145
|
+
Only when ``return_surface``: shape ``(Np, N, N, N)``, ``N = 2L + 2``,
|
|
146
|
+
on the ``(alpha, beta, gamma)`` grid. Detached.
|
|
147
|
+
"""
|
|
148
|
+
patt = torch.as_tensor(patterns, device=self.device, dtype=self.dtype)
|
|
149
|
+
single = patt.ndim == 2
|
|
150
|
+
if single:
|
|
151
|
+
patt = patt[None]
|
|
152
|
+
if patt.ndim != 3:
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"patterns must be (H, W) or (Np, H, W), got shape {tuple(patt.shape)}"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
samples, mask = pattern_to_sphere(patt, self.detector, self._dirs)
|
|
158
|
+
|
|
159
|
+
# standardize over the FOV and zero outside it. Zeroing the FOV mean makes E's
|
|
160
|
+
# l=0 harmonic vanish, which drops the DC term from the masked correlation.
|
|
161
|
+
w = mask
|
|
162
|
+
denom = w.sum().clamp_min(self._eps)
|
|
163
|
+
mean = (samples * w).sum(-1, keepdim=True) / denom
|
|
164
|
+
var = ((samples - mean) ** 2 * w).sum(-1, keepdim=True) / denom
|
|
165
|
+
e = ((samples - mean) / var.clamp_min(self._eps).sqrt()) * w
|
|
166
|
+
b_lm = self.sht.forward(e.reshape(*e.shape[:-1], self.sht.n_theta, self.sht.n_phi))
|
|
167
|
+
|
|
168
|
+
c = so3_cross_correlation(self.a_lm, b_lm, self.bandwidth, wigner_d_cached=self._wig_d)
|
|
169
|
+
n_grid = 2 * self.bandwidth + 2
|
|
170
|
+
|
|
171
|
+
if normalize:
|
|
172
|
+
# rho = X / (sqrt(P) * denom_geo). P and A_W are g-independent, so they
|
|
173
|
+
# scale the surface but not the argmax.
|
|
174
|
+
denom_geo = self._geometry_normalizer()
|
|
175
|
+
p_energy = (e**2 * self._weights_flat).sum(-1)
|
|
176
|
+
scale = p_energy.clamp_min(self._eps).sqrt().reshape(-1, 1, 1, 1)
|
|
177
|
+
surface = c / (scale * denom_geo[None])
|
|
178
|
+
else:
|
|
179
|
+
surface = c
|
|
180
|
+
|
|
181
|
+
flat = surface.reshape(surface.shape[0], -1)
|
|
182
|
+
quality, peak_flat = flat.max(dim=-1)
|
|
183
|
+
j, k, p = (idx.cpu() for idx in torch.unravel_index(peak_flat, (n_grid, n_grid, n_grid)))
|
|
184
|
+
|
|
185
|
+
rotations = grid_to_rotation(self.bandwidth, j, k, p)
|
|
186
|
+
if return_surface:
|
|
187
|
+
return rotations, quality.detach(), surface.detach()
|
|
188
|
+
return rotations, quality.detach()
|
|
189
|
+
|
|
190
|
+
def refine(
|
|
191
|
+
self,
|
|
192
|
+
patterns,
|
|
193
|
+
rotations=None,
|
|
194
|
+
pattern_center=None,
|
|
195
|
+
refine_pattern_center=False,
|
|
196
|
+
n_iterations=100,
|
|
197
|
+
learning_rate=0.02,
|
|
198
|
+
pattern_center_learning_rate=None,
|
|
199
|
+
keep_best=True,
|
|
200
|
+
return_history=False,
|
|
201
|
+
):
|
|
202
|
+
"""Gradient-refine indexed orientations (and optionally the pattern center).
|
|
203
|
+
|
|
204
|
+
Wraps :func:`de_ebsd.index.refine.orientation.refine_orientation` with this
|
|
205
|
+
indexer's master, detector, device and SHT quadrature directions, so
|
|
206
|
+
refinement scores over the same grid the coarse index used. With
|
|
207
|
+
``rotations=None`` the seeds come from :meth:`index`, taking raw patterns
|
|
208
|
+
straight to sharpened orientations.
|
|
209
|
+
|
|
210
|
+
``pattern_center`` defaults to ``detector.pattern_center``; the remaining arguments pass
|
|
211
|
+
through.
|
|
212
|
+
Returns ``(rotation, score)``, with ``pattern_center`` appended when
|
|
213
|
+
``refine_pattern_center`` and
|
|
214
|
+
``history`` when ``return_history``.
|
|
215
|
+
"""
|
|
216
|
+
from de_ebsd.index.refine.orientation import refine_orientation
|
|
217
|
+
|
|
218
|
+
if rotations is None:
|
|
219
|
+
rotations, _ = self.index(patterns)
|
|
220
|
+
return refine_orientation(
|
|
221
|
+
patterns,
|
|
222
|
+
self.master,
|
|
223
|
+
self.detector,
|
|
224
|
+
rotations,
|
|
225
|
+
pattern_center=pattern_center,
|
|
226
|
+
refine_pattern_center=refine_pattern_center,
|
|
227
|
+
n_iterations=n_iterations,
|
|
228
|
+
learning_rate=learning_rate,
|
|
229
|
+
pattern_center_learning_rate=pattern_center_learning_rate,
|
|
230
|
+
directions=self._dirs,
|
|
231
|
+
device=self.device,
|
|
232
|
+
keep_best=keep_best,
|
|
233
|
+
return_history=return_history,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def index_scan(
|
|
237
|
+
self,
|
|
238
|
+
patterns,
|
|
239
|
+
shape=None,
|
|
240
|
+
step=1.0,
|
|
241
|
+
phase=None,
|
|
242
|
+
batch_size=None,
|
|
243
|
+
normalize=True,
|
|
244
|
+
refine=False,
|
|
245
|
+
refine_iters=100,
|
|
246
|
+
refine_lr=0.02,
|
|
247
|
+
):
|
|
248
|
+
"""Index a whole scan and assemble it into an orix ``CrystalMap``.
|
|
249
|
+
|
|
250
|
+
Parameters
|
|
251
|
+
----------
|
|
252
|
+
patterns
|
|
253
|
+
Either ``(rows, cols, H, W)``, from which the scan grid is inferred, or
|
|
254
|
+
a flat row-major ``(N, H, W)``, which requires ``shape``.
|
|
255
|
+
shape
|
|
256
|
+
``(rows, cols)`` of the scan. Cross-checked against 4-D input.
|
|
257
|
+
step
|
|
258
|
+
Scan step size, written onto the map's ``x``/``y``.
|
|
259
|
+
phase
|
|
260
|
+
An orix :class:`~orix.crystal_map.Phase` or a de_ebsd
|
|
261
|
+
:class:`~de_ebsd.crystal.Crystal`. If ``None``, derived from
|
|
262
|
+
``master.metadata["crystal"]``.
|
|
263
|
+
batch_size
|
|
264
|
+
Patterns per mini-batch (default 64). The SO(3) surface is
|
|
265
|
+
``(batch_size, N, N, N)``, which dominates memory at production
|
|
266
|
+
bandwidth. Results are identical to a single batch.
|
|
267
|
+
refine
|
|
268
|
+
Gradient-refine each mini-batch before assembly, making
|
|
269
|
+
``prop["quality"]`` the refined NCC. Orientation only -- a scan shares
|
|
270
|
+
one detector geometry, and joint pattern-center fitting over a whole
|
|
271
|
+
scan is not done here.
|
|
272
|
+
refine_iters, refine_lr
|
|
273
|
+
Adam iterations and rotation learning rate used when ``refine``.
|
|
274
|
+
|
|
275
|
+
Returns
|
|
276
|
+
-------
|
|
277
|
+
orix.crystal_map.CrystalMap
|
|
278
|
+
One orientation + ``prop["quality"]`` per scan point, single phase.
|
|
279
|
+
"""
|
|
280
|
+
import numpy as np
|
|
281
|
+
from orix.quaternion import Rotation
|
|
282
|
+
|
|
283
|
+
from de_ebsd.index.io import phase_from_crystal, to_crystal_map
|
|
284
|
+
|
|
285
|
+
# shape inference and reshaping stay on the stack's own device (view-only);
|
|
286
|
+
# only each mini-batch moves to self.device, so a 10^6-pattern scan never
|
|
287
|
+
# lands wholesale in GPU memory
|
|
288
|
+
patt = torch.as_tensor(patterns, dtype=self.dtype)
|
|
289
|
+
if patt.ndim == 4:
|
|
290
|
+
rows, cols = int(patt.shape[0]), int(patt.shape[1])
|
|
291
|
+
if shape is not None and (int(shape[0]), int(shape[1])) != (rows, cols):
|
|
292
|
+
raise ValueError(
|
|
293
|
+
f"shape {tuple(shape)} does not match the (rows, cols, H, W) "
|
|
294
|
+
f"pattern grid {(rows, cols)}"
|
|
295
|
+
)
|
|
296
|
+
patt = patt.reshape(rows * cols, patt.shape[2], patt.shape[3])
|
|
297
|
+
elif patt.ndim == 3:
|
|
298
|
+
if shape is None:
|
|
299
|
+
raise ValueError(
|
|
300
|
+
"shape=(rows, cols) is required for (N, H, W) patterns "
|
|
301
|
+
"(pass (rows, cols, H, W) to infer it instead)"
|
|
302
|
+
)
|
|
303
|
+
rows, cols = int(shape[0]), int(shape[1])
|
|
304
|
+
if rows * cols != patt.shape[0]:
|
|
305
|
+
raise ValueError(
|
|
306
|
+
f"shape {(rows, cols)} implies {rows * cols} patterns but got {patt.shape[0]}"
|
|
307
|
+
)
|
|
308
|
+
else:
|
|
309
|
+
raise ValueError(
|
|
310
|
+
f"patterns must be (rows, cols, H, W) or (N, H, W), got shape {tuple(patt.shape)}"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
n = rows * cols
|
|
314
|
+
bs = int(batch_size) if batch_size is not None else 64
|
|
315
|
+
if bs < 1:
|
|
316
|
+
raise ValueError(f"batch_size must be >= 1, got {batch_size}")
|
|
317
|
+
|
|
318
|
+
rot_chunks = []
|
|
319
|
+
qual_chunks = []
|
|
320
|
+
for start in range(0, n, bs):
|
|
321
|
+
chunk = patt[start : start + bs].to(self.device)
|
|
322
|
+
rot_c, qual_c = self.index(chunk, normalize=normalize)
|
|
323
|
+
if refine:
|
|
324
|
+
rot_c, qual_c = self.refine(
|
|
325
|
+
chunk, rotations=rot_c, n_iterations=refine_iters, learning_rate=refine_lr
|
|
326
|
+
)
|
|
327
|
+
rot_chunks.append(np.asarray(rot_c.data))
|
|
328
|
+
qual_chunks.append(qual_c)
|
|
329
|
+
rotations = Rotation(np.concatenate(rot_chunks, axis=0))
|
|
330
|
+
quality = torch.cat(qual_chunks)
|
|
331
|
+
|
|
332
|
+
if phase is None:
|
|
333
|
+
crystal = None
|
|
334
|
+
meta = getattr(self.master, "metadata", None)
|
|
335
|
+
if isinstance(meta, dict):
|
|
336
|
+
crystal = meta.get("crystal")
|
|
337
|
+
if crystal is None:
|
|
338
|
+
raise ValueError(
|
|
339
|
+
"no phase given and master.metadata has no 'crystal' to derive "
|
|
340
|
+
"one from; pass phase=<orix Phase or de_ebsd Crystal> to "
|
|
341
|
+
"index_scan (the Phase gives the CrystalMap its crystal symmetry)"
|
|
342
|
+
)
|
|
343
|
+
phase = phase_from_crystal(crystal)
|
|
344
|
+
|
|
345
|
+
return to_crystal_map(rotations, quality, (rows, cols), phase, step=step)
|