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,654 @@
1
+ """HR-EBSD: deviatoric strain and lattice rotation by pattern cross-correlation.
2
+
3
+ Where :func:`~de_ebsd.index.refine.highres.refine_hr` matches a pattern against a
4
+ dynamical master, HR-EBSD is purely differential: it cross-correlates regions of a
5
+ test pattern against a *reference* pattern of the same phase and fits the
6
+ displacement gradient tensor that maps one onto the other. Nothing about the master
7
+ pattern enters, so the model-fidelity ceiling that caps absolute pattern-center
8
+ accuracy near 1e-3 does not apply here.
9
+
10
+ The shift equation
11
+ ------------------
12
+ A lattice displacement gradient ``beta = F - I`` sends a crystal direction ``r`` to
13
+ ``(I + beta) r``. In gnomonic screen coordinates ``(x, y, z)`` with ``z = pcz``, a
14
+ feature therefore moves by, to first order,
15
+
16
+ ``qx = b11 x + b12 y + b13 z - (x/z)(b31 x + b32 y + b33 z)``
17
+ ``qy = b21 x + b22 y + b23 z - (y/z)(b31 x + b32 y + b33 z)``
18
+
19
+ Two equations per region of interest, linear in ``beta``. Adding ``c I`` to ``beta``
20
+ leaves both *exactly* unchanged -- the hydrostatic component is invisible to a
21
+ projection -- so only the 8 trace-free components are measurable and :func:`hrebsd`
22
+ solves under ``trace(beta) = 0``. Recovering the hydrostatic part needs an elastic
23
+ boundary condition (usually ``sigma_33 = 0`` at the free surface) and the stiffness
24
+ tensor; that is left to the caller.
25
+
26
+ Reference remapping
27
+ -------------------
28
+ The first-order shift equation degrades once the lattice rotation is large, and a
29
+ pattern-center error coupled to a lattice rotation ``w`` leaks in as a phantom strain
30
+ of order ``(dpc / pcz) * w``. So :func:`hrebsd` defaults to two passes: measure
31
+ ``F``, re-project the reference *through the gnomonic geometry* into the deformed
32
+ configuration (:func:`remap_reference`), then re-correlate and compose. The residual
33
+ rotation entering the second pass is only as large as the first pass's error, which
34
+ relaxes the pattern-center requirement by one to two orders of magnitude. The remap
35
+ is exact rather than first-order, and it absorbs any pattern-center difference
36
+ between the reference and test points as well.
37
+
38
+ How much of this is verified
39
+ ----------------------------
40
+ The geometry: the design matrix reproduces the exact projective displacement of a
41
+ region to ~1e-4 px, and the solver inverts it to ~1e-3 of ``beta``.
42
+
43
+ The locator, on real 2048px direct-detection patterns (64 regions on a uniform grid,
44
+ 5% independent noise added, true shifts from 0.04 to 13 px, known exactly by Fourier
45
+ shift): **median error 0.002-0.003 px, worst 0.006-0.009 px** at 128-256 px regions.
46
+ Plain correlation with one refinement iteration measured best; whitening was neither
47
+ needed nor helpful. Feeding that precision through the shift equation puts ``beta``
48
+ around 1e-5 with ~64 regions, and the error falls as ``1 / sqrt(M)``.
49
+
50
+ After Wilkinson, Meaden & Dingley (2006); remapping after Britton & Wilkinson (2012).
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ from dataclasses import dataclass
56
+
57
+ import numpy as np
58
+ import torch
59
+
60
+ from de_ebsd.core.constants import DIVISION_FLOOR
61
+ from de_ebsd.core.device import resolve_device
62
+ from de_ebsd.core.geometry import detector_rays, sample_to_detector_tensor
63
+
64
+ __all__ = ["HREBSDResult", "hrebsd", "remap_reference", "roi_centers"]
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # geometry
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ def _pixels_from_ray(detector, pattern_center: torch.Tensor, ray: torch.Tensor):
73
+ """The fractional ``(row, col)`` a ray lands on: the inverse of ``detector_rays``.
74
+
75
+ ``col + 0.5 = ncols * pcx + nrows * pcz * (r1 / r3)`` and
76
+ ``row + 0.5 = nrows * pcy - nrows * pcz * (r2 / r3)``. Both gnomonic axes scale
77
+ by ``nrows``, so a pattern-center change is a pure scale-and-shift of the pattern
78
+ in pixel coordinates -- the magnification-about-the-pattern-center that the
79
+ moving-screen method exploits.
80
+ """
81
+ pcx, pcy, pcz = pattern_center[..., 0:1], pattern_center[..., 1:2], pattern_center[..., 2:3]
82
+ inv_z = 1.0 / ray[..., 2]
83
+ col = detector.ncols * pcx + detector.nrows * pcz * (ray[..., 0] * inv_z) - 0.5
84
+ row = detector.nrows * pcy - detector.nrows * pcz * (ray[..., 1] * inv_z) - 0.5
85
+ return row, col
86
+
87
+
88
+ def roi_centers(detector, grid: int = 8, roi_size: int = 128, margin: float = 0.05):
89
+ """Region-of-interest centers on a ``grid x grid`` layout, as ``(M, 2)`` row/col.
90
+
91
+ Regions are inset far enough that a full ``roi_size`` box fits, plus ``margin``
92
+ (a fraction of each side) to stay off the detector edge, where the pattern is
93
+ weakest and the gnomonic lever arm is most sensitive to pattern-center error.
94
+ """
95
+ half = roi_size // 2
96
+ axes = []
97
+ for length in (detector.nrows, detector.ncols):
98
+ lo = half + margin * length
99
+ hi = length - half - margin * length
100
+ if hi <= lo:
101
+ raise ValueError(
102
+ f"roi_size={roi_size} with margin={margin} leaves no room on a "
103
+ f"{detector.nrows}x{detector.ncols} detector"
104
+ )
105
+ axes.append(np.linspace(lo, hi, grid))
106
+ rr, cc = np.meshgrid(axes[0], axes[1], indexing="ij")
107
+ return np.stack([rr.reshape(-1), cc.reshape(-1)], axis=-1)
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # sub-pixel cross-correlation
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ def _roi_pixels(centers: torch.Tensor, roi_size: int):
116
+ """``(M, 2)`` centers -> ``(M, roi, roi)`` row and col indices, integer-aligned."""
117
+ half = roi_size // 2
118
+ offs = torch.arange(roi_size, device=centers.device, dtype=centers.dtype) - half
119
+ rows = centers[:, 0, None] + offs[None, :] # (M, roi)
120
+ cols = centers[:, 1, None] + offs[None, :]
121
+ return rows[:, :, None].expand(-1, -1, roi_size), cols[:, None, :].expand(-1, roi_size, -1)
122
+
123
+
124
+ def _extract(patterns: torch.Tensor, centers: torch.Tensor, roi_size: int):
125
+ """``(N, H, W)`` and ``(M, 2)`` -> ``(N, M, roi, roi)`` boxes at integer pixels."""
126
+ rows, cols = _roi_pixels(centers, roi_size)
127
+ r = rows[:, :, 0].long().clamp(0, patterns.shape[-2] - 1) # (M, roi)
128
+ c = cols[:, 0, :].long().clamp(0, patterns.shape[-1] - 1)
129
+ return patterns[:, r[:, :, None], c[:, None, :]]
130
+
131
+
132
+ def _window(roi_size: int, device, dtype) -> torch.Tensor:
133
+ w = torch.hann_window(roi_size, periodic=False, device=device, dtype=dtype)
134
+ return w[:, None] * w[None, :]
135
+
136
+
137
+ def _prepare(rois: torch.Tensor, win: torch.Tensor) -> torch.Tensor:
138
+ """Mean-subtract, window, then unit-normalize, so peaks are comparable."""
139
+ x = rois - rois.mean(dim=(-2, -1), keepdim=True)
140
+ x = x * win
141
+ return x / x.flatten(-2).norm(dim=-1)[..., None, None].clamp_min(DIVISION_FLOOR)
142
+
143
+
144
+ def _spectral_mask(roi_size: int, bandpass, device, dtype):
145
+ """Gaussian band-pass over the sampling frequency, or ``None`` for flat.
146
+
147
+ ``bandpass`` is ``(low, high)`` in cycles per pixel; Nyquist is 0.5. The
148
+ high-frequency roll-off matters because whitening (see :func:`_measure_shifts`)
149
+ gives every frequency equal weight, including the ones carrying only noise.
150
+ """
151
+ if bandpass is None:
152
+ return None
153
+ low, high = bandpass
154
+ f = torch.fft.fftfreq(roi_size, device=device, dtype=dtype)
155
+ r = (f[:, None] ** 2 + f[None, :] ** 2).sqrt()
156
+ mask = torch.ones_like(r)
157
+ if low and low > 0:
158
+ mask = mask * torch.exp(-((low / r.clamp_min(DIVISION_FLOOR)) ** 2))
159
+ if high and high > 0:
160
+ mask = mask * torch.exp(-((r / high) ** 2))
161
+ return mask
162
+
163
+
164
+ def _fourier_shift(rois: torch.Tensor, shift: torch.Tensor) -> torch.Tensor:
165
+ """Shift each region by a sub-pixel ``(drow, dcol)``, band-limited and exact."""
166
+ r = rois.shape[-1]
167
+ f = torch.fft.fftfreq(r, device=rois.device, dtype=rois.dtype)
168
+ phase = torch.exp(
169
+ -2j
170
+ * torch.pi
171
+ * (shift[..., 0, None, None] * f[:, None] + shift[..., 1, None, None] * f[None, :])
172
+ )
173
+ return torch.fft.ifft2(torch.fft.fft2(rois) * phase).real
174
+
175
+
176
+ def _upsampled_peak(cps: torch.Tensor, upsample: int, radius: float = 1.0):
177
+ """Sub-pixel cross-correlation peak by local upsampled DFT.
178
+
179
+ ``cps`` is the cross-power spectrum ``(B, r, r)``. Takes the integer peak of the
180
+ inverse transform, then evaluates the inverse DFT on a ``1 / upsample`` grid
181
+ within ``+-radius`` pixels of it by matrix multiply (after Guizar-Sicairos,
182
+ Thurman & Fienup, 2008), which costs a small matmul rather than a padded FFT.
183
+
184
+ The integer peak is within half a pixel of the true one, so a radius of one
185
+ pixel is enough. The grid's cost goes as the radius squared: at 2.5 pixels and
186
+ an upsample of 50 it was most of :func:`hrebsd`'s time and 9-15 GB of memory
187
+ on 256 patterns of 36-64 regions.
188
+
189
+ Returns ``(shift, peak)``: ``(B, 2)`` sub-pixel ``(drow, dcol)`` and ``(B,)``
190
+ correlation peak height.
191
+ """
192
+ b, m, n = cps.shape
193
+ real = cps.real.dtype
194
+ cc = torch.fft.ifft2(cps).real
195
+ flat = cc.flatten(-2).argmax(-1)
196
+ peak = cc.flatten(-2).gather(-1, flat[:, None])[:, 0]
197
+ py, px = torch.div(flat, n, rounding_mode="floor"), flat % n
198
+ # a peak past the midpoint wraps to a negative shift
199
+ iy = torch.where(py > m // 2, py - m, py).to(real)
200
+ ix = torch.where(px > n // 2, px - n, px).to(real)
201
+
202
+ n_up = int(np.ceil(radius * upsample)) * 2 + 1
203
+ step = (torch.arange(n_up, device=cps.device, dtype=real) - n_up // 2) / upsample
204
+ dy = iy[:, None] + step[None, :] # (B, n_up)
205
+ dx = ix[:, None] + step[None, :]
206
+ fy = torch.fft.fftfreq(m, device=cps.device, dtype=real)
207
+ fx = torch.fft.fftfreq(n, device=cps.device, dtype=real)
208
+ two_pi_i = 2j * torch.pi
209
+ ky = torch.exp(two_pi_i * dy[:, :, None] * fy[None, None, :]) # (B, n_up, m)
210
+ kx = torch.exp(two_pi_i * dx[:, :, None] * fx[None, None, :]) # (B, n_up, n)
211
+ fine = torch.einsum("bpu,buv,bqv->bpq", ky, cps, kx).real / (m * n)
212
+
213
+ flat = fine.flatten(-2).argmax(-1)
214
+ sy = dy.gather(-1, torch.div(flat, n_up, rounding_mode="floor")[:, None])[:, 0]
215
+ sx = dx.gather(-1, (flat % n_up)[:, None])[:, 0]
216
+ return torch.stack([sy, sx], dim=-1), peak
217
+
218
+
219
+ def _measure_shifts(test_rois, ref_rois, win, upsample, mask=None, refine=True, whiten=0.0):
220
+ """``(N, M, r, r)`` pairs -> ``(N, M, 2)`` shifts in pixels and ``(N, M)`` NCC.
221
+
222
+ ``refine`` shifts the reference by the first estimate and re-measures the
223
+ residual. That second look is what buys the precision: measured on real 2048px
224
+ direct-detection patterns it roughly halves the error, to a median of 0.002-0.003
225
+ px. It is skipped for any region whose shift exceeds an eighth of the region --
226
+ the shift is circular, and a large one would wrap real content into the
227
+ comparison.
228
+
229
+ ``whiten`` divides the cross-power spectrum by ``|cps| ** whiten``, 1.0 being full
230
+ phase correlation. It defends against a specific failure: a taper is unavoidable
231
+ (regions are not periodic), and the window's own autocorrelation peaks at zero lag
232
+ and drags a plain correlation peak toward it, in proportion to how broad the
233
+ pattern's autocorrelation is. On a pathologically smooth pattern that bias reaches
234
+ whole pixels. Real Kikuchi patterns are nowhere near that regime -- so the default
235
+ is 0, plain correlation, which measured *better* than whitening on real data and
236
+ does not amplify noise-dominated frequencies the way whitening does.
237
+ """
238
+ n, m, r, _ = test_rois.shape
239
+ raw_ref = ref_rois.reshape(n * m, r, r)
240
+ ft = torch.fft.fft2(_prepare(test_rois, win).reshape(n * m, r, r))
241
+
242
+ def measure(ref):
243
+ cps = ft * torch.fft.fft2(_prepare(ref, win)).conj()
244
+ if whiten:
245
+ cps = cps / cps.abs().clamp_min(DIVISION_FLOOR) ** whiten
246
+ if mask is not None:
247
+ cps = cps * mask
248
+ return _upsampled_peak(cps, upsample)
249
+
250
+ shift, _ = measure(raw_ref)
251
+ if refine:
252
+ moved = _fourier_shift(raw_ref, shift)
253
+ residual, _ = measure(moved)
254
+ ok = shift.norm(dim=-1) < r / 8.0
255
+ shift = shift + torch.where(ok[:, None], residual, torch.zeros_like(residual))
256
+
257
+ # quality is the plain normalized cross-correlation of the aligned regions,
258
+ # which stays interpretable in [-1, 1] even though the locator is whitened
259
+ aligned = _prepare(_fourier_shift(raw_ref, shift), win)
260
+ ncc = (_prepare(test_rois, win).reshape(n * m, r, r) * aligned).flatten(-2).sum(-1)
261
+ return shift.reshape(n, m, 2), ncc.reshape(n, m)
262
+
263
+
264
+ # ---------------------------------------------------------------------------
265
+ # shifts -> displacement gradient
266
+ # ---------------------------------------------------------------------------
267
+
268
+
269
+ def _design(detector, pattern_center: torch.Tensor, centers: torch.Tensor):
270
+ """Design matrix ``(N, M, 2, 8)`` for the trace-free shift equation.
271
+
272
+ Free parameters are ``[b11, b12, b13, b21, b22, b23, b31, b32]`` with
273
+ ``b33 = -(b11 + b22)``; rows are the gnomonic ``(qx, qy)`` of each region.
274
+ """
275
+ ray = detector_rays(detector, pattern_center, centers[:, 0], centers[:, 1]) # (N, M, 3)
276
+ x, y, z = ray[..., 0], ray[..., 1], ray[..., 2]
277
+ o = torch.zeros_like(x)
278
+ a9 = torch.stack(
279
+ [
280
+ torch.stack([x, y, z, o, o, o, -x * x / z, -x * y / z, -x], dim=-1),
281
+ torch.stack([o, o, o, x, y, z, -y * x / z, -y * y / z, -y], dim=-1),
282
+ ],
283
+ dim=-2,
284
+ ) # (N, M, 2, 9)
285
+ a = a9[..., :8].clone()
286
+ a[..., 0] -= a9[..., 8] # b33 = -(b11 + b22) folds into the b11 ...
287
+ a[..., 4] -= a9[..., 8] # ... and b22 columns
288
+ return a
289
+
290
+
291
+ def _solve_beta(a: torch.Tensor, q: torch.Tensor, w: torch.Tensor):
292
+ """Weighted least squares ``(N, M, 2, 8)`` x ``(N, M, 2)`` -> ``(N, 3, 3)``, rms."""
293
+ n = q.shape[0]
294
+ a_flat = a.reshape(n, -1, 8)
295
+ q_flat = q.reshape(n, -1, 1)
296
+ ww = w.repeat_interleave(2, dim=-1).clamp_min(0.0) # (N, 2M)
297
+ sw = ww[..., None].sqrt()
298
+ sol = torch.linalg.lstsq(a_flat * sw, q_flat * sw).solution[..., 0] # (N, 8)
299
+ resid = (a_flat @ sol[..., None] - q_flat)[..., 0]
300
+ rms = ((resid * resid * ww).sum(-1) / ww.sum(-1).clamp_min(DIVISION_FLOOR)).sqrt()
301
+
302
+ b33 = -(sol[:, 0] + sol[:, 4])
303
+ beta = torch.stack(
304
+ [
305
+ torch.stack([sol[:, 0], sol[:, 1], sol[:, 2]], dim=-1),
306
+ torch.stack([sol[:, 3], sol[:, 4], sol[:, 5]], dim=-1),
307
+ torch.stack([sol[:, 6], sol[:, 7], b33], dim=-1),
308
+ ],
309
+ dim=1,
310
+ )
311
+ return beta, rms
312
+
313
+
314
+ # ---------------------------------------------------------------------------
315
+ # remapping
316
+ # ---------------------------------------------------------------------------
317
+
318
+
319
+ def _reference_grid(detector, f, pattern_center, reference_pattern_center, rows, cols):
320
+ """Test-frame fractional pixels ``(N, K)`` -> ``(N, K, 2)`` grid into the reference.
321
+
322
+ A feature at crystal direction ``r`` in the reference sits at ``F r`` once the
323
+ lattice is deformed, so the remapped image at ray ``r`` carries the reference's
324
+ content at ``F^-1 r``. Leaving through the test pattern center and re-entering
325
+ through the reference's also absorbs their pattern-center difference, exactly.
326
+ """
327
+ ray = detector_rays(detector, pattern_center, rows, cols) # (N, K, 3)
328
+ ray_ref = torch.einsum("nij,nkj->nki", torch.linalg.inv(f), ray)
329
+ row_ref, col_ref = _pixels_from_ray(detector, reference_pattern_center, ray_ref)
330
+ gx = 2.0 * (col_ref + 0.5) / detector.ncols - 1.0
331
+ gy = 2.0 * (row_ref + 0.5) / detector.nrows - 1.0
332
+ return torch.stack([gx, gy], dim=-1)
333
+
334
+
335
+ def _sample(ref: torch.Tensor, grid: torch.Tensor) -> torch.Tensor:
336
+ return torch.nn.functional.grid_sample(
337
+ ref[:, None], grid, mode="bilinear", padding_mode="zeros", align_corners=False
338
+ )[:, 0]
339
+
340
+
341
+ def _warped_rois(ref, detector, f, pattern_center, reference_pattern_center, centers, roi_size):
342
+ """Only the regions of the remapped reference, never the whole warped frame.
343
+
344
+ On a 4k detector a full remap is ~17x the pixels this actually needs.
345
+ """
346
+ n = f.shape[0]
347
+ rows, cols = _roi_pixels(centers, roi_size) # (M, roi, roi)
348
+ rr = rows.reshape(1, -1).expand(n, -1)
349
+ cc = cols.reshape(1, -1).expand(n, -1)
350
+ grid = _reference_grid(detector, f, pattern_center, reference_pattern_center, rr, cc)
351
+ m = centers.shape[0]
352
+ grid = grid.reshape(n, m * roi_size, roi_size, 2)
353
+ return _sample(ref, grid).reshape(n, m, roi_size, roi_size)
354
+
355
+
356
+ def remap_reference(
357
+ reference: torch.Tensor, detector, f: torch.Tensor, pattern_center, reference_pattern_center
358
+ ) -> torch.Tensor:
359
+ """Re-project a whole reference pattern into the deformed configuration.
360
+
361
+ :func:`hrebsd` uses a region-only path internally; this is the full-frame version,
362
+ for inspecting a remap or for building one by hand.
363
+
364
+ Parameters
365
+ ----------
366
+ reference
367
+ ``(H, W)`` or ``(N, H, W)``, broadcast against ``f``.
368
+ detector
369
+ Geometry, for the pixel <-> gnomonic conversion.
370
+ f
371
+ ``(N, 3, 3)`` deformation gradient in the detector frame.
372
+ pattern_center, reference_pattern_center
373
+ Test and reference pattern centers, ``(3,)`` or ``(N, 3)``.
374
+
375
+ Returns
376
+ -------
377
+ torch.Tensor
378
+ ``(N, H, W)``, bilinearly resampled; rays falling outside the reference come
379
+ back as zero.
380
+ """
381
+ device, dtype = reference.device, reference.dtype
382
+ f = torch.as_tensor(f, device=device, dtype=dtype).reshape(-1, 3, 3)
383
+ n = f.shape[0]
384
+ pattern_center = (
385
+ torch.as_tensor(pattern_center, device=device, dtype=dtype).reshape(-1, 3).expand(n, 3)
386
+ )
387
+ reference_pattern_center = (
388
+ torch.as_tensor(reference_pattern_center, device=device, dtype=dtype)
389
+ .reshape(-1, 3)
390
+ .expand(n, 3)
391
+ )
392
+ ref = reference.reshape(-1, detector.nrows, detector.ncols)
393
+ if ref.shape[0] == 1:
394
+ ref = ref.expand(n, -1, -1)
395
+
396
+ rows = torch.arange(detector.nrows, device=device, dtype=dtype)
397
+ cols = torch.arange(detector.ncols, device=device, dtype=dtype)
398
+ rr, cc = torch.meshgrid(rows, cols, indexing="ij")
399
+ grid = _reference_grid(
400
+ detector,
401
+ f,
402
+ pattern_center,
403
+ reference_pattern_center,
404
+ rr.reshape(1, -1).expand(n, -1),
405
+ cc.reshape(1, -1).expand(n, -1),
406
+ )
407
+ return _sample(ref, grid.reshape(n, detector.nrows, detector.ncols, 2))
408
+
409
+
410
+ # ---------------------------------------------------------------------------
411
+ # entry point
412
+ # ---------------------------------------------------------------------------
413
+
414
+
415
+ @dataclass(frozen=True)
416
+ class HREBSDResult:
417
+ """What :func:`hrebsd` measured, per test pattern, in the **detector** frame.
418
+
419
+ Use :meth:`to_sample` for the sample frame.
420
+ """
421
+
422
+ beta: torch.Tensor
423
+ """``(N, 3, 3)`` displacement gradient, trace-free: the hydrostatic part is
424
+ invisible to the projection (see the module docstring)."""
425
+
426
+ strain: torch.Tensor
427
+ """``(N, 3, 3)`` deviatoric strain, ``sym(beta)``."""
428
+
429
+ rotation: torch.Tensor
430
+ """``(N, 3, 3)`` lattice rotation, ``skew(beta)``."""
431
+
432
+ rotation_vector: torch.Tensor
433
+ """``(N, 3)`` axial vector of :attr:`rotation`, in radians."""
434
+
435
+ shifts: torch.Tensor
436
+ """``(N, M, 2)`` region shifts ``(drow, dcol)`` in pixels, from the final pass.
437
+ After remapping these are residuals, so they should be small."""
438
+
439
+ centers: torch.Tensor
440
+ """``(M, 2)`` region-of-interest centers as ``(row, col)``."""
441
+
442
+ peak: torch.Tensor
443
+ """``(N, M)`` correlation peak height; low values flag an unusable region."""
444
+
445
+ residual: torch.Tensor
446
+ """``(N,)`` weighted rms of the shift-equation fit, in gnomonic units -- multiply
447
+ by ``detector.nrows`` for pixels. The honest per-point error bar: divide by the
448
+ gnomonic half-width to read it as a strain floor."""
449
+
450
+ n_passes: int
451
+ """Passes run, remapping between each."""
452
+
453
+ def to_sample(self, detector):
454
+ """``(beta, strain, rotation)`` rotated into the sample frame."""
455
+ om = sample_to_detector_tensor(detector, self.beta.device, self.beta.dtype)
456
+
457
+ def rot(t):
458
+ return torch.einsum("ji,njk,kl->nil", om, t, om)
459
+
460
+ return rot(self.beta), rot(self.strain), rot(self.rotation)
461
+
462
+
463
+ def hrebsd(
464
+ patterns,
465
+ reference,
466
+ detector,
467
+ pattern_center=None,
468
+ reference_pattern_center=None,
469
+ *,
470
+ roi_size: int = 128,
471
+ grid: int = 8,
472
+ margin: float = 0.05,
473
+ upsample: int = 50,
474
+ n_passes: int = 2,
475
+ bandpass: tuple[float, float] | None = None,
476
+ whiten: float = 0.0,
477
+ min_peak: float = 0.0,
478
+ batch: int = 256,
479
+ device=None,
480
+ ) -> HREBSDResult:
481
+ """Deviatoric strain and lattice rotation relative to a reference pattern.
482
+
483
+ Cross-correlates a grid of regions between each test pattern and the reference,
484
+ fits the gnomonic shift equation for the trace-free displacement gradient, then
485
+ remaps the reference into the measured configuration and repeats. That second
486
+ pass is what makes the result trustworthy once lattice rotations exceed a degree:
487
+ it drops the residual rotation to first-pass accuracy, and pattern-center error
488
+ reaches the strain only through that residual.
489
+
490
+ Parameters
491
+ ----------
492
+ patterns
493
+ Test patterns ``(H, W)`` or ``(N, H, W)``, background-corrected; see
494
+ :mod:`de_ebsd.index.preprocess`.
495
+ reference
496
+ ``(H, W)``, or ``(N, H, W)`` for one reference per test point. Strain is
497
+ measured *relative* to whatever state this pattern is in.
498
+ detector
499
+ :class:`~de_ebsd.sim.detector.EBSDDetector`, for the gnomonic geometry.
500
+ pattern_center, reference_pattern_center
501
+ Pattern center at the test and reference points, ``(3,)`` or ``(N, 3)``; both
502
+ default to ``detector.pattern_center``. Passing them separately is worth doing over any
503
+ appreciable scan -- a millimetre of travel moves the pattern center by
504
+ several percent of the screen -- and the remap absorbs the difference.
505
+ roi_size
506
+ Region side in pixels. Larger regions correlate more precisely but average
507
+ the deformation over a wider gnomonic span.
508
+ grid
509
+ Regions per side, so ``grid ** 2`` in total, against 8 free parameters.
510
+ margin
511
+ Edge inset as a fraction of each side.
512
+ upsample
513
+ Sub-pixel resolution of the correlation peak, ``1 / upsample`` pixels.
514
+ n_passes
515
+ Correlation passes; ``1`` disables remapping and is the classic single-pass
516
+ formulation. A pattern-center difference is still applied on pass 1.
517
+ bandpass
518
+ ``(low, high)`` Gaussian band-pass in cycles per pixel (Nyquist is 0.5), or
519
+ ``None`` for flat. Rarely needed on real patterns; raise ``low`` above zero to
520
+ suppress slow background gradients that survived preprocessing.
521
+ whiten
522
+ Spectral whitening exponent, 1.0 being full phase correlation. Defaults to 0
523
+ (plain correlation), which measured better than whitening on real patterns.
524
+ See :func:`_measure_shifts` for the failure mode it exists to defend against.
525
+ min_peak
526
+ Regions whose normalized cross-correlation falls below this are dropped from
527
+ the fit. Regions are weighted by that correlation regardless.
528
+ batch
529
+ Patterns correlated at once, which bounds memory: it scales as
530
+ ``batch * grid**2 * roi_size**2``.
531
+ device
532
+ Defaults to the test patterns'.
533
+
534
+ Returns
535
+ -------
536
+ HREBSDResult
537
+ Detector-frame tensors, the raw region shifts, and a per-point residual.
538
+ """
539
+ patt = torch.as_tensor(patterns)
540
+ device = resolve_device(device, patt.device)
541
+ dtype = patt.dtype if patt.is_floating_point() else torch.float32
542
+ patt = patt.to(device=device, dtype=dtype)
543
+ if patt.ndim == 2:
544
+ patt = patt[None]
545
+ if patt.ndim != 3:
546
+ raise ValueError(f"patterns must be (H, W) or (N, H, W), got {tuple(patt.shape)}")
547
+ if tuple(patt.shape[-2:]) != (detector.nrows, detector.ncols):
548
+ raise ValueError(
549
+ f"patterns are {tuple(patt.shape[-2:])} but the detector is "
550
+ f"{(detector.nrows, detector.ncols)}"
551
+ )
552
+ n = patt.shape[0]
553
+
554
+ ref = torch.as_tensor(reference, device=device, dtype=dtype)
555
+ if ref.ndim == 2:
556
+ ref = ref[None]
557
+ if ref.shape[0] not in (1, n):
558
+ raise ValueError(f"reference must be (H, W) or (N, H, W) with N={n}")
559
+ if ref.shape[0] == 1:
560
+ ref = ref.expand(n, -1, -1)
561
+ if n_passes < 1:
562
+ raise ValueError("n_passes must be at least 1")
563
+
564
+ pattern_center = detector.pattern_center if pattern_center is None else pattern_center
565
+ reference_pattern_center = (
566
+ pattern_center if reference_pattern_center is None else reference_pattern_center
567
+ )
568
+ pattern_center = torch.as_tensor(pattern_center, device=device, dtype=dtype).reshape(-1, 3)
569
+ reference_pattern_center = torch.as_tensor(
570
+ reference_pattern_center, device=device, dtype=dtype
571
+ ).reshape(-1, 3)
572
+ pattern_center = pattern_center.expand(n, 3) if pattern_center.shape[0] == 1 else pattern_center
573
+ reference_pattern_center = (
574
+ reference_pattern_center.expand(n, 3)
575
+ if reference_pattern_center.shape[0] == 1
576
+ else reference_pattern_center
577
+ )
578
+
579
+ if n > batch:
580
+ parts = [
581
+ hrebsd(
582
+ patt[lo : lo + batch],
583
+ ref[lo : lo + batch],
584
+ detector,
585
+ pattern_center[lo : lo + batch],
586
+ reference_pattern_center[lo : lo + batch],
587
+ roi_size=roi_size,
588
+ grid=grid,
589
+ margin=margin,
590
+ upsample=upsample,
591
+ n_passes=n_passes,
592
+ bandpass=bandpass,
593
+ whiten=whiten,
594
+ min_peak=min_peak,
595
+ batch=batch,
596
+ device=device,
597
+ )
598
+ for lo in range(0, n, batch)
599
+ ]
600
+ return HREBSDResult(
601
+ **{
602
+ name: torch.cat([getattr(part, name) for part in parts])
603
+ for name in (
604
+ "beta",
605
+ "strain",
606
+ "rotation",
607
+ "rotation_vector",
608
+ "shifts",
609
+ "peak",
610
+ "residual",
611
+ )
612
+ },
613
+ centers=parts[0].centers,
614
+ n_passes=n_passes,
615
+ )
616
+
617
+ centers = torch.as_tensor(
618
+ roi_centers(detector, grid, roi_size, margin), device=device, dtype=dtype
619
+ ).round()
620
+ win = _window(roi_size, device, dtype)
621
+ mask = _spectral_mask(roi_size, bandpass, device, dtype)
622
+ test_rois = _extract(patt, centers, roi_size) # fixed; the test never moves
623
+ # shifts are measured in the test frame, so the design matrix uses the test pattern_center
624
+ a = _design(detector, pattern_center, centers)
625
+
626
+ eye = torch.eye(3, device=device, dtype=dtype).expand(n, 3, 3)
627
+ f_total = eye.clone()
628
+ shifts = peak = residual = None
629
+ for _ in range(n_passes):
630
+ ref_rois = _warped_rois(
631
+ ref, detector, f_total, pattern_center, reference_pattern_center, centers, roi_size
632
+ )
633
+ shifts, peak = _measure_shifts(test_rois, ref_rois, win, upsample, mask, whiten=whiten)
634
+ # pixels -> gnomonic: both axes scale by 1 / nrows, y increasing upward
635
+ q = torch.stack([shifts[..., 1], -shifts[..., 0]], dim=-1) / detector.nrows
636
+ w = torch.where(peak >= min_peak, peak.clamp_min(0.0), torch.zeros_like(peak))
637
+ beta_step, residual = _solve_beta(a, q, w)
638
+ f_total = (eye + beta_step) @ f_total
639
+
640
+ beta = f_total - eye
641
+ trace = torch.diagonal(beta, dim1=-2, dim2=-1).sum(-1)
642
+ beta = beta - trace[:, None, None] / 3.0 * eye # the gauge the projection fixes
643
+ rot = 0.5 * (beta - beta.transpose(-1, -2))
644
+ return HREBSDResult(
645
+ beta=beta,
646
+ strain=0.5 * (beta + beta.transpose(-1, -2)),
647
+ rotation=rot,
648
+ rotation_vector=torch.stack([rot[:, 2, 1], rot[:, 0, 2], rot[:, 1, 0]], dim=-1),
649
+ shifts=shifts,
650
+ centers=centers,
651
+ peak=peak,
652
+ residual=residual,
653
+ n_passes=n_passes,
654
+ )