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.
Files changed (69) hide show
  1. de_ebsd/__init__.py +76 -0
  2. de_ebsd/core/__init__.py +23 -0
  3. de_ebsd/core/constants.py +81 -0
  4. de_ebsd/core/device.py +12 -0
  5. de_ebsd/core/geometry.py +67 -0
  6. de_ebsd/core/lambert.py +148 -0
  7. de_ebsd/core/rotation.py +139 -0
  8. de_ebsd/crystal/__init__.py +22 -0
  9. de_ebsd/crystal/data/__init__.py +0 -0
  10. de_ebsd/crystal/data/element_properties.py +29 -0
  11. de_ebsd/crystal/data/scattering_tables.py +414 -0
  12. de_ebsd/crystal/lattice.py +517 -0
  13. de_ebsd/crystal/scattering.py +339 -0
  14. de_ebsd/crystal/scattering_torch.py +233 -0
  15. de_ebsd/crystal/structure_factors.py +291 -0
  16. de_ebsd/diffraction/__init__.py +45 -0
  17. de_ebsd/diffraction/backends.py +318 -0
  18. de_ebsd/diffraction/bethe.py +28 -0
  19. de_ebsd/diffraction/kinematic.py +389 -0
  20. de_ebsd/diffraction/kvectors.py +120 -0
  21. de_ebsd/diffraction/prism.py +204 -0
  22. de_ebsd/diffraction/reflections.py +234 -0
  23. de_ebsd/index/__init__.py +61 -0
  24. de_ebsd/index/_kernels.py +222 -0
  25. de_ebsd/index/backproject.py +171 -0
  26. de_ebsd/index/crosscorr.py +654 -0
  27. de_ebsd/index/dictionary.py +362 -0
  28. de_ebsd/index/io.py +324 -0
  29. de_ebsd/index/live/__init__.py +5 -0
  30. de_ebsd/index/live/background.py +103 -0
  31. de_ebsd/index/live/calibration.py +70 -0
  32. de_ebsd/index/live/common.py +44 -0
  33. de_ebsd/index/live/deformation.py +289 -0
  34. de_ebsd/index/live/indexer.py +386 -0
  35. de_ebsd/index/preprocess.py +195 -0
  36. de_ebsd/index/refine/__init__.py +7 -0
  37. de_ebsd/index/refine/highres.py +479 -0
  38. de_ebsd/index/refine/levenberg.py +33 -0
  39. de_ebsd/index/refine/orientation.py +367 -0
  40. de_ebsd/index/refine/pattern_center.py +356 -0
  41. de_ebsd/index/spherical/__init__.py +41 -0
  42. de_ebsd/index/spherical/correlation.py +106 -0
  43. de_ebsd/index/spherical/harmonics.py +311 -0
  44. de_ebsd/index/spherical/indexer.py +345 -0
  45. de_ebsd/index/spherical/wigner.py +190 -0
  46. de_ebsd/sim/__init__.py +20 -0
  47. de_ebsd/sim/detector.py +145 -0
  48. de_ebsd/sim/masterpattern.py +231 -0
  49. de_ebsd/sim/microscope.py +93 -0
  50. de_ebsd/sim/montecarlo/__init__.py +19 -0
  51. de_ebsd/sim/montecarlo/depth.py +167 -0
  52. de_ebsd/sim/montecarlo/transport.py +532 -0
  53. de_ebsd/sim/simulator.py +291 -0
  54. de_ebsd/viz/__init__.py +53 -0
  55. de_ebsd/viz/camera.py +56 -0
  56. de_ebsd/viz/depth_widget.js +76 -0
  57. de_ebsd/viz/explorer.py +647 -0
  58. de_ebsd/viz/figures.py +67 -0
  59. de_ebsd/viz/maps.py +277 -0
  60. de_ebsd/viz/patterns.py +164 -0
  61. de_ebsd/viz/setup_widget.js +171 -0
  62. de_ebsd/viz/views.py +125 -0
  63. de_ebsd/viz/widgets.py +191 -0
  64. de_ebsd-0.2.0.dist-info/METADATA +251 -0
  65. de_ebsd-0.2.0.dist-info/RECORD +69 -0
  66. de_ebsd-0.2.0.dist-info/WHEEL +5 -0
  67. de_ebsd-0.2.0.dist-info/licenses/LICENSE +674 -0
  68. de_ebsd-0.2.0.dist-info/licenses/NOTICE +44 -0
  69. 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)