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,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
|
+
)
|