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,356 @@
1
+ """Calibrate a single pattern center shared across many patterns.
2
+
3
+ Alternates per-pattern orientation refinement with a global Levenberg-Marquardt
4
+ step on the shared PC, using the band-edge samples of
5
+ :mod:`de_ebsd.index.refine.highres`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+ import torch
12
+
13
+ from de_ebsd.core.device import resolve_device
14
+ from de_ebsd.core.rotation import quaternion_to_matrix, rotation_vector_to_matrix
15
+ from de_ebsd.index.refine.highres import (
16
+ direction_cosine_derivative,
17
+ direction_cosines_from_pattern_center,
18
+ edge_importance_samples,
19
+ levenberg_marquardt_refine,
20
+ normalized_residual_jacobian,
21
+ standardize_tiled,
22
+ )
23
+ from de_ebsd.index.refine.levenberg import adapt_shared_damping
24
+
25
+ __all__ = ["determine_pattern_center"]
26
+
27
+
28
+ #: Blur of the master, in radians on the sphere, from the first round to the last.
29
+ #: A real dynamical master is sharp: its band-edge NCC falls from 1 to ~0 within
30
+ #: 0.02 of PC, so a start 0.03 off sees no slope at all. Blurring widens that basin;
31
+ #: the schedule ends unblurred, so the answer is the sharp master's optimum.
32
+ _DEFAULT_BLUR = (0.05, 0.025, 0.012, 0.0)
33
+
34
+
35
+ def _blurred(master, sigma: float):
36
+ """*master* blurred by a Gaussian of *sigma* radians on the sphere.
37
+
38
+ Blurred in its square-Lambert grid, which is equal-area, so one pixel is
39
+ about ``sqrt(2 pi) / n`` radians everywhere; the grid's shape distortion
40
+ and the seam at each hemisphere's edge only matter in the coarse stages,
41
+ which the schedule's final unblurred stage corrects. One convolution per
42
+ stage, so a blurred stage samples as fast as a sharp one.
43
+ """
44
+ from de_ebsd.sim.masterpattern import MasterPattern
45
+
46
+ data = master.data
47
+ n = data.shape[-1]
48
+ sigma_px = sigma * (n - 1) / np.sqrt(2.0 * np.pi)
49
+ radius = min(int(np.ceil(3.0 * sigma_px)), n - 1)
50
+ x = torch.arange(-radius, radius + 1, device=data.device, dtype=data.dtype)
51
+ kernel = torch.exp(-0.5 * (x / sigma_px) ** 2)
52
+ kernel = kernel / kernel.sum()
53
+ flat = data.reshape(-1, 1, n, n)
54
+ flat = torch.nn.functional.pad(flat, (radius, radius, 0, 0), mode="reflect")
55
+ flat = torch.nn.functional.conv2d(flat, kernel.view(1, 1, 1, -1))
56
+ flat = torch.nn.functional.pad(flat, (0, 0, radius, radius), mode="reflect")
57
+ flat = torch.nn.functional.conv2d(flat, kernel.view(1, 1, -1, 1))
58
+ return MasterPattern(flat.reshape(data.shape), master.energies_kev, dict(master.metadata))
59
+
60
+
61
+ def determine_pattern_center(
62
+ patterns,
63
+ master,
64
+ detector,
65
+ rotations,
66
+ pattern_center=None,
67
+ n_rounds: int = 5,
68
+ n_tiles: int = 16,
69
+ samples_per_tile: int = 8,
70
+ refine_orientations: bool = True,
71
+ n_pattern_center_steps: int = 10,
72
+ damping: float = 1e-3,
73
+ keep_best: bool = True,
74
+ device=None,
75
+ return_orientations: bool = False,
76
+ n_orientation_steps: int = 15,
77
+ blur=_DEFAULT_BLUR,
78
+ ):
79
+ """Calibrate one shared pattern center from many EBSD patterns.
80
+
81
+ Fits a single pattern center shared by the whole calibration set by alternating
82
+ an orientation-only refinement of every pattern at the current PC with a
83
+ Levenberg-Marquardt step on the 3-parameter PC whose normal equations are
84
+ accumulated over all patterns. That ensemble accumulation is what breaks the
85
+ single-pattern R--PC degeneracy. Calibrate once, then refine the full scan at the
86
+ fixed PC with ``refine_hr(refine_pattern_center=False, pattern_center=<calibrated
87
+ pattern_center>)``.
88
+
89
+ Parameters
90
+ ----------
91
+ patterns
92
+ Calibration patterns ``(N, H, W)``, or a single ``(H, W)`` image. A
93
+ well-distributed few dozen to few hundred are plenty to fit three
94
+ parameters. The raw sampled values are used.
95
+ master, detector
96
+ The continuous :class:`de_ebsd.sim.MasterPattern` and the
97
+ :class:`~de_ebsd.sim.detector.EBSDDetector` geometry. ``detector.pattern_center`` is
98
+ used only when ``pattern_center`` is ``None``.
99
+ rotations
100
+ Per-pattern seed orientations, e.g. from coarse indexing.
101
+ pattern_center
102
+ Starting shared pattern center ``(3,)``; ``None`` uses ``detector.pattern_center``.
103
+ n_rounds
104
+ Alternating (orientation, shared-PC) rounds.
105
+ n_tiles, samples_per_tile
106
+ Tiles per detector side and band-edge samples kept per tile; the defaults
107
+ give 2048 samples per pattern.
108
+ refine_orientations
109
+ Re-refine each orientation at the current PC every round (default), or hold
110
+ the input ``rotations`` fixed and fit only the PC.
111
+ n_pattern_center_steps
112
+ Levenberg-Marquardt steps on the shared PC per round.
113
+ damping
114
+ Initial LM damping, adapted each step (x0.5 accepted, x3 rejected) and
115
+ carried across rounds. Also seeds the inner orientation refinement.
116
+ keep_best
117
+ Never return a PC / orientation set scoring below the best seen, making the
118
+ calibration monotone in the total per-tile NCC.
119
+ device
120
+ Defaults to ``master.data.device``; otherwise everything is moved there.
121
+ return_orientations
122
+ Also return the refined orientations, before ``info``.
123
+ n_orientation_steps
124
+ LM steps for the inner orientation refinement each round.
125
+ blur
126
+ Gaussian blur of the master (radians on the sphere) per stage, coarse to
127
+ fine, spread evenly over the rounds. A sharp dynamical master only has a
128
+ slope within ~0.01 of the true PC; the blurred stages pull a further-off
129
+ seed into that basin. ``None`` or ``(0,)`` fits the sharp master only.
130
+
131
+ Returns
132
+ -------
133
+ pattern_center : torch.Tensor
134
+ The calibrated shared pattern center ``(3,)``.
135
+ rotations : orix.quaternion.Rotation
136
+ Only when ``return_orientations``: the orientations at the calibrated PC.
137
+ info : dict
138
+ ``"ncc"`` and ``"ncc_seed"`` (mean per-tile NCC at the calibrated and seed
139
+ geometry), ``"pc_step"`` (L2 magnitude of the final round's PC move -- small
140
+ means converged), ``"pc_step_history"``, and ``"n_rounds"``.
141
+ """
142
+ device = resolve_device(device, master.data.device)
143
+ dtype = master.data.dtype
144
+ if master.data.device != device:
145
+ master = master.to(device)
146
+
147
+ patt = torch.as_tensor(patterns, device=device, dtype=dtype)
148
+ if patt.ndim == 2:
149
+ patt = patt[None]
150
+ if patt.ndim != 3:
151
+ raise ValueError(f"patterns must be (H, W) or (N, H, W), got shape {tuple(patt.shape)}")
152
+ n_p = patt.shape[0]
153
+
154
+ q0 = torch.as_tensor(np.asarray(rotations.data), device=device, dtype=dtype).reshape(-1, 4)
155
+ if q0.shape[0] != n_p:
156
+ raise ValueError(f"rotations length {q0.shape[0]} != number of patterns {n_p}")
157
+ r_mat = quaternion_to_matrix(q0).detach() # (N, 3, 3)
158
+
159
+ if pattern_center is None:
160
+ pattern_center = detector.pattern_center
161
+ pattern_center = torch.as_tensor(pattern_center, device=device, dtype=dtype)
162
+ if tuple(pattern_center.shape) != (3,):
163
+ raise ValueError(
164
+ "pattern_center must be a shared 3-vector (3,), "
165
+ f"got shape {tuple(pattern_center.shape)}"
166
+ )
167
+ pattern_center = pattern_center.clone()
168
+
169
+ # fixed band-edge samples + per-tile normalization, computed once (patterns never move)
170
+ rows, cols, flat, tile_ids, counts = edge_importance_samples(patt, n_tiles, samples_per_tile)
171
+ e_raw = patt.reshape(n_p, -1).gather(1, flat.long()) # (N, K) raw experimental
172
+ n_total = int(counts.shape[0])
173
+ n_active = int((counts > 0).sum())
174
+ counts_ps = counts.index_select(0, tile_ids).to(dtype) # (K,)
175
+ _, _, e_hat = standardize_tiled(e_raw, tile_ids, counts_ps, n_total) # (N, K) fixed
176
+
177
+ def _ncc_per_pattern(r_cur: torch.Tensor, pc_vec: torch.Tensor, seen=master) -> torch.Tensor:
178
+ pc_bc = pc_vec[None].expand(n_p, 3)
179
+ dc = direction_cosines_from_pattern_center(detector, pc_bc, rows, cols)
180
+ m = seen.sample(dc @ r_cur.transpose(-1, -2)).to(device=device, dtype=dtype)
181
+ _, _, m_hat = standardize_tiled(m, tile_ids, counts_ps, n_total)
182
+ return (e_hat * m_hat).sum(-1) / n_active
183
+
184
+ def _total(r_cur: torch.Tensor, pc_vec: torch.Tensor) -> torch.Tensor:
185
+ return _ncc_per_pattern(r_cur, pc_vec).sum()
186
+
187
+ ncc_seed = float(_ncc_per_pattern(r_mat, pattern_center).mean())
188
+
189
+ best_pc = pattern_center.clone()
190
+ best_r = r_mat.clone()
191
+ best_total = _total(r_mat, pattern_center)
192
+ damping = float(damping)
193
+ pc_step_history: list[float] = []
194
+ blur = tuple(blur) if blur else (0.0,)
195
+
196
+ for round_index in range(n_rounds):
197
+ # spread the blur stages over the rounds, ending on the last (sharpest)
198
+ sigma = blur[round_index * len(blur) // n_rounds]
199
+ seen = _blurred(master, sigma) if sigma > 0 else master
200
+
201
+ def _seen_total(r_cur, pc_vec, seen=seen):
202
+ return _ncc_per_pattern(r_cur, pc_vec, seen).sum()
203
+
204
+ # 1. orientation block -- refine each pattern at the current shared PC (well-posed)
205
+ if refine_orientations:
206
+ pc_bc = pattern_center[None].expand(n_p, 3).contiguous()
207
+ r_mat, _, _ = levenberg_marquardt_refine(
208
+ seen,
209
+ detector,
210
+ rows,
211
+ cols,
212
+ e_raw,
213
+ tile_ids,
214
+ counts,
215
+ r_mat,
216
+ pc_bc,
217
+ False,
218
+ n_orientation_steps,
219
+ damping,
220
+ keep_best,
221
+ device,
222
+ dtype,
223
+ )
224
+
225
+ # 2. shared-PC block -- LM on the 3-vector, normal equations summed over all
226
+ # patterns; jointly with the orientations when those are refined, so the PC
227
+ # step already accounts for how each orientation would compensate it
228
+ pc_before = pattern_center.clone()
229
+ pattern_center, r_mat, damping = _shared_pc_lm(
230
+ seen,
231
+ detector,
232
+ rows,
233
+ cols,
234
+ r_mat,
235
+ pattern_center,
236
+ e_hat,
237
+ tile_ids,
238
+ counts_ps,
239
+ n_total,
240
+ damping,
241
+ n_pattern_center_steps,
242
+ _seen_total,
243
+ device,
244
+ dtype,
245
+ joint=refine_orientations,
246
+ )
247
+ pc_step_history.append(float((pattern_center - pc_before).norm()))
248
+
249
+ # judged on the sharp master, so a blurred round cannot claim "best"
250
+ total = _total(r_mat, pattern_center)
251
+ if bool(total > best_total):
252
+ best_total, best_pc, best_r = total, pattern_center.clone(), r_mat.clone()
253
+
254
+ if keep_best:
255
+ pattern_center, r_mat = best_pc, best_r
256
+
257
+ pattern_center = pattern_center.detach()
258
+ info = {
259
+ "ncc": float(_ncc_per_pattern(r_mat, pattern_center).mean()),
260
+ "ncc_seed": ncc_seed,
261
+ "pc_step": pc_step_history[-1] if pc_step_history else 0.0,
262
+ "pc_step_history": pc_step_history,
263
+ "n_rounds": n_rounds,
264
+ }
265
+ if return_orientations:
266
+ from orix.quaternion import Rotation
267
+
268
+ rotation = Rotation.from_matrix(r_mat.cpu().numpy())
269
+ return pattern_center, rotation, info
270
+ return pattern_center, info
271
+
272
+
273
+ def _shared_pc_lm(
274
+ master,
275
+ detector,
276
+ rows,
277
+ cols,
278
+ r_mat,
279
+ pattern_center,
280
+ e_hat,
281
+ tile_ids,
282
+ counts_ps,
283
+ n_total,
284
+ damping,
285
+ n_steps,
286
+ total_fn,
287
+ device,
288
+ dtype,
289
+ joint=False,
290
+ ):
291
+ """LM on the shared PC; with ``joint``, on the orientations alongside it.
292
+
293
+ The joint system is block-arrow: each pattern's 3x3 orientation block couples
294
+ only to the shared PC. Eliminating the orientation blocks (a Schur complement)
295
+ leaves a 3x3 system for the PC that already allows for every orientation's
296
+ compensating move -- so the PC is not dragged back along the R--PC valley the
297
+ way it is when the two blocks are refined in turn.
298
+ """
299
+ n_p = r_mat.shape[0]
300
+ cur_total = total_fn(r_mat, pattern_center)
301
+
302
+ for _ in range(n_steps):
303
+ pc_bc = pattern_center[None].expand(n_p, 3).contiguous()
304
+ dc = direction_cosines_from_pattern_center(
305
+ detector, pc_bc, rows, cols
306
+ ).detach() # (N, K, 3)
307
+ # one reverse-mode grid_sample pass -> per-point master gradient dM/dv
308
+ with torch.enable_grad():
309
+ v = (dc @ r_mat.transpose(-1, -2)).detach().requires_grad_(True)
310
+ m_raw = master.sample(v)
311
+ (g_m,) = torch.autograd.grad(m_raw.sum(), v)
312
+ m = m_raw.detach().to(device=device, dtype=dtype)
313
+ g_m = g_m.to(device=device, dtype=dtype)
314
+
315
+ q = torch.einsum("pka,pab->pkb", g_m, r_mat) # gM @ R -> (N, K, 3)
316
+ ddc_dpc = direction_cosine_derivative(detector, pc_bc, rows, cols) # (N, K, 3, 3)
317
+ dm_dpc = torch.einsum("pkb,pkbc->pkc", q, ddc_dpc) # raw dM/dpc, (N, K, 3)
318
+ if joint:
319
+ dm_dw = torch.linalg.cross(dc, q, dim=-1) # raw dM/dw, (N, K, 3)
320
+ g_raw = torch.cat([dm_dw, dm_dpc], dim=-1) # (N, K, 6)
321
+ else:
322
+ g_raw = dm_dpc
323
+
324
+ res, j_r = normalized_residual_jacobian(m, e_hat, g_raw, tile_ids, counts_ps, n_total)
325
+ with torch.no_grad():
326
+ jtr = torch.einsum("pk,pki->pi", res, j_r) # per pattern, (N, P)
327
+ jtj = torch.einsum("pki,pkj->pij", j_r, j_r) # per pattern, (N, P, P)
328
+ if joint:
329
+ a = jtj[:, :3, :3]
330
+ a = a + damping * torch.diag_embed(
331
+ torch.diagonal(a, dim1=-2, dim2=-1).clamp_min(1e-9)
332
+ )
333
+ b = jtj[:, :3, 3:] # (N, 3, 3) orientation-PC coupling
334
+ c = jtj[:, 3:, 3:].sum(0)
335
+ c = c + damping * torch.diag_embed(torch.diagonal(c).clamp_min(1e-9))
336
+ a_inv_b = torch.linalg.solve(a, b)
337
+ a_inv_g = torch.linalg.solve(a, jtr[:, :3, None])[..., 0]
338
+ schur = c - torch.einsum("pji,pjk->ik", b, a_inv_b)
339
+ rhs = jtr[:, 3:].sum(0) - torch.einsum("pji,pj->i", b, a_inv_g)
340
+ delta_pc = -torch.linalg.solve(schur, rhs) # (3,)
341
+ delta_w = -(a_inv_g + (a_inv_b @ delta_pc[:, None])[..., 0]) # (N, 3)
342
+ r_new = r_mat @ rotation_vector_to_matrix(delta_w)
343
+ else:
344
+ jtr, jtj = jtr.sum(0), jtj.sum(0) # summed over patterns
345
+ diag = torch.diagonal(jtj).clamp_min(1e-9)
346
+ a = jtj + damping * torch.diag_embed(diag) # Marquardt (scale-invariant) damping
347
+ delta_pc = -torch.linalg.solve(a, jtr) # (3,)
348
+ r_new = r_mat
349
+ pc_new = pattern_center + delta_pc
350
+ new_total = total_fn(r_new, pc_new)
351
+ accepted = bool(new_total > cur_total)
352
+ if accepted:
353
+ pattern_center, r_mat = pc_new, r_new
354
+ cur_total = new_total
355
+ damping = adapt_shared_damping(damping, accepted)
356
+ return pattern_center, r_mat, damping
@@ -0,0 +1,41 @@
1
+ """Spherical-harmonic and SO(3) machinery behind the spherical indexer."""
2
+
3
+ from de_ebsd.index.spherical.correlation import so3_cross_correlation
4
+ from de_ebsd.index.spherical.harmonics import (
5
+ SphericalHarmonicTransform,
6
+ forward_sht,
7
+ gauss_legendre_grid,
8
+ inverse_sht,
9
+ lm_indices,
10
+ n_coefficients,
11
+ spherical_harmonics,
12
+ )
13
+ from de_ebsd.index.spherical.indexer import SphericalIndexer
14
+ from de_ebsd.index.spherical.wigner import (
15
+ euler_zyz_to_orix,
16
+ grid_to_rotation,
17
+ n_orientations,
18
+ so3_grid,
19
+ wigner_D,
20
+ wigner_d,
21
+ wigner_small_d,
22
+ )
23
+
24
+ __all__ = [
25
+ "SphericalHarmonicTransform",
26
+ "SphericalIndexer",
27
+ "euler_zyz_to_orix",
28
+ "forward_sht",
29
+ "gauss_legendre_grid",
30
+ "grid_to_rotation",
31
+ "inverse_sht",
32
+ "lm_indices",
33
+ "n_coefficients",
34
+ "n_orientations",
35
+ "so3_cross_correlation",
36
+ "so3_grid",
37
+ "spherical_harmonics",
38
+ "wigner_D",
39
+ "wigner_d",
40
+ "wigner_small_d",
41
+ ]
@@ -0,0 +1,106 @@
1
+ """SO(3) cross-correlation of two spherical-harmonic spectra.
2
+
3
+ Combines the two spectra with the Wigner-d matrices into a coefficient cube,
4
+ then one 3-D inverse FFT gives the correlation over all of SO(3) on the SOFT
5
+ grid.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import torch
11
+
12
+ from de_ebsd.index.spherical.harmonics import lm_indices, n_coefficients
13
+ from de_ebsd.index.spherical.wigner import so3_grid, wigner_d
14
+
15
+ __all__ = ["so3_cross_correlation"]
16
+
17
+
18
+ def _pack_to_grid(coeffs: torch.Tensor, bandwidth: int) -> torch.Tensor:
19
+ L = int(bandwidth)
20
+ degrees, orders = lm_indices(L, device=coeffs.device)
21
+ pos = degrees * (2 * L + 1) + (orders + L) # flat index into (L+1)*(2L+1)
22
+ base = coeffs.new_zeros((*coeffs.shape[:-1], (L + 1) * (2 * L + 1)))
23
+ packed = base.index_add(-1, pos, coeffs)
24
+ return packed.reshape(*coeffs.shape[:-1], L + 1, 2 * L + 1)
25
+
26
+
27
+ def so3_cross_correlation(
28
+ master_coeffs: torch.Tensor,
29
+ pattern_coeffs: torch.Tensor,
30
+ bandwidth: int,
31
+ wigner_d_cached: torch.Tensor | None = None,
32
+ ) -> torch.Tensor:
33
+ """Cross-correlation of two harmonic spectra over all of SO(3), via SOFT.
34
+
35
+ Parameters
36
+ ----------
37
+ master_coeffs : torch.Tensor
38
+ Master spectrum ``a_lm``, shape ``(..., (L+1)**2)`` (packed
39
+ ``c = l*l + l + m``, the :mod:`.harmonics` convention). May carry leading
40
+ batch dims that broadcast against ``pattern_coeffs``.
41
+ pattern_coeffs : torch.Tensor
42
+ Pattern spectrum ``b_lm``, shape ``(..., (L+1)**2)``. The leading dims are
43
+ the batch of patterns (many indexed at once).
44
+ bandwidth : int
45
+ The band limit ``L``; both spectra must have ``(L+1)**2`` slots.
46
+ wigner_d_cached : torch.Tensor, optional
47
+ A precomputed Wigner-d block ``wigner_d(L, beta)`` for this bandwidth's SOFT
48
+ ``beta`` grid, shape ``(N, L+1, 2L+1, 2L+1)`` with ``N = 2L + 2`` (see
49
+ :func:`de_ebsd.index.spherical.wigner.wigner_d`). This ``O(L^4)`` block is otherwise
50
+ rebuilt on every call; pass it to reuse one block across a mini-batched scan
51
+ (:class:`~de_ebsd.index.spherical.indexer.SphericalIndexer` caches it in
52
+ ``__init__``). The result is byte-identical to omitting it, provided the
53
+ cached block matches this bandwidth's ``beta`` grid and the real dtype used
54
+ here (``float64`` for complex128 spectra, else ``float32``).
55
+
56
+ Returns
57
+ -------
58
+ torch.Tensor
59
+ Real tensor ``C`` of shape ``(*batch, N, N, N)`` with ``N = 2L + 2``, sampled
60
+ on the :func:`.so3_grid` ``(alpha, beta, gamma)`` grid (axes in that order),
61
+ where ``batch`` is the broadcast of the two inputs' leading dims. ``argmax``
62
+ gives the best-matching orientation index; feed it to
63
+ :func:`.grid_to_rotation`. Differentiable in both spectra.
64
+ """
65
+ L = int(bandwidth)
66
+ n_coeff = n_coefficients(L)
67
+ if master_coeffs.shape[-1] != n_coeff or pattern_coeffs.shape[-1] != n_coeff:
68
+ raise ValueError(
69
+ f"expected {n_coeff} coefficients for bandwidth {L}, got "
70
+ f"{master_coeffs.shape[-1]} (master) and {pattern_coeffs.shape[-1]} (pattern)"
71
+ )
72
+ a = master_coeffs
73
+ b = pattern_coeffs
74
+ cdtype = torch.result_type(a, b)
75
+ if not cdtype.is_complex:
76
+ cdtype = torch.complex128 if cdtype == torch.float64 else torch.complex64
77
+ a = a.to(cdtype)
78
+ b = b.to(cdtype)
79
+
80
+ a_grid = _pack_to_grid(a, L) # (..., L+1, 2L+1) over m'
81
+ b_grid = _pack_to_grid(b, L) # (..., L+1, 2L+1) over m
82
+ # cross-spectrum f_{l, m, m'} = b_lm conj(a_lm')
83
+ f = b_grid[..., :, :, None] * a_grid.conj()[..., :, None, :] # (..., L+1, 2L+1, 2L+1)
84
+
85
+ n = 2 * L + 2
86
+ real_dtype = torch.float64 if cdtype == torch.complex128 else torch.float32
87
+ alpha, beta, gamma = so3_grid(L, device=a.device, dtype=real_dtype)
88
+ if wigner_d_cached is not None:
89
+ d = wigner_d_cached.to(cdtype) # reuse the precomputed O(L^4) block
90
+ else:
91
+ d = wigner_d(L, beta).to(cdtype) # (N, L+1, 2L+1, 2L+1)
92
+
93
+ # Wigner-d contraction over beta: S_{k, m, m'} = sum_l f_{l, m, m'} d^l_{m m'}(beta_k)
94
+ s = torch.einsum("...lmn,klmn->...kmn", f, d) # (..., N, 2L+1, 2L+1)
95
+
96
+ # inverse FFT over alpha (m) and gamma (m'): pad the (2L+1) order axes to N,
97
+ # then C = N^2 ifft2(s) with the linear phase that shifts m,m' back to -L..L
98
+ s = torch.nn.functional.pad(s, (0, 1, 0, 1)) # (..., N, N, N)
99
+ c = (n * n) * torch.fft.ifft2(s, dim=(-2, -1)) # axis -2 -> alpha, -1 -> gamma
100
+ phase_a = torch.exp(-1j * L * alpha).to(cdtype).reshape(n, 1)
101
+ phase_g = torch.exp(-1j * L * gamma).to(cdtype).reshape(1, n)
102
+ c = c * phase_a * phase_g # (..., N=beta, N=alpha, N=gamma)
103
+
104
+ # reorder (beta, alpha, gamma) -> (alpha, beta, gamma) and take the real part
105
+ c = c.transpose(-3, -2)
106
+ return c.real.contiguous()