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,103 @@
1
+ """The backgrounds a scan's patterns are divided by: static, dynamic and ghost.
2
+
3
+ The static background is the running mean of the scan's frames; a pattern is
4
+ divided by it, then its own slowly varying (dynamic) background is removed. The
5
+ ghost is what the static background keeps of the scan's grains.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import torch
11
+ import torch.nn.functional as F
12
+
13
+ from de_ebsd.core.constants import SINGLE_PRECISION_FLOOR
14
+ from de_ebsd.index.live.common import average_to
15
+ from de_ebsd.index.preprocess import gaussian_blur, normalize_rows
16
+
17
+ #: Frames the static background is averaged over before it is used: fewer still
18
+ #: carry the bands of the few grains they came from.
19
+ MIN_FRAMES_FOR_STATIC = 32
20
+
21
+
22
+ class ScanBackground:
23
+ """The static background and ghost of one scan, at the refine shape."""
24
+
25
+ def __init__(self, refine_shape: tuple[int, int], device: torch.device) -> None:
26
+ self.refine_shape = refine_shape
27
+ self.device = device
28
+ self.static = None
29
+ self.blur = None
30
+ self._static_sum = torch.zeros(refine_shape, device=device)
31
+ self._static_count = 0
32
+ self._fit_sum = torch.zeros(refine_shape[0] * refine_shape[1], device=device)
33
+ self._fit_count = 0
34
+
35
+ def restart(self, blur: float | None = None) -> None:
36
+ """Forget the scan. With *blur* (a Gaussian sigma, as a share of the pattern's
37
+ width) the static background will be the frames' mean blurred, for a single
38
+ crystal: its frames all carry its bands, so their mean would divide them
39
+ out. It then has no ghost."""
40
+ self.static = None
41
+ self.blur = blur
42
+ self._static_sum.zero_()
43
+ self._static_count = 0
44
+ self._fit_sum.zero_()
45
+ self._fit_count = 0
46
+
47
+ def observe(self, pooled: torch.Tensor) -> None:
48
+ """Add *pooled* frames ``(N, H, W)``, at the refine shape, to the static background."""
49
+ self._static_sum += pooled.sum(0)
50
+ self._static_count += len(pooled)
51
+ if self._static_count >= MIN_FRAMES_FOR_STATIC:
52
+ static = self._static_sum / self._static_count
53
+ if self.blur:
54
+ static = gaussian_blur(static[None], self.blur * static.shape[-1], 4.0)[0]
55
+ self.static = static
56
+
57
+ def remove(self, t: torch.Tensor) -> torch.Tensor:
58
+ """*t* ``(N, H, W)`` divided by the static background, less each pattern's
59
+ dynamic one, at whatever shape it is.
60
+
61
+ Divided rather than less: the bands' contrast scales with the
62
+ backscattered intensity, so a pattern less its static background still
63
+ has its bands weighted by the bright middle of the screen, and that
64
+ weighting moves a band's center differently in every grain."""
65
+ shape = tuple(t.shape[-2:])
66
+ if self.static is not None:
67
+ static = self.static
68
+ if shape != self.refine_shape:
69
+ static = F.interpolate(
70
+ static[None, None], size=shape, mode="bilinear", align_corners=False
71
+ )[0, 0]
72
+ t = t / static.clamp_min(SINGLE_PRECISION_FLOOR) - 1.0
73
+ # The dynamic background is an eighth of the width wide, so it is found at a
74
+ # quarter of the refine resolution and drawn back up: a full-size blur that
75
+ # wide was most of the preparation's time.
76
+ coarse = average_to(t, (self.refine_shape[0] // 4, self.refine_shape[1] // 4))
77
+ coarse = gaussian_blur(coarse, coarse.shape[-1] / 8.0, 4.0)
78
+ return (
79
+ t
80
+ - F.interpolate(coarse[:, None], size=shape, mode="bilinear", align_corners=False)[:, 0]
81
+ )
82
+
83
+ def add_fits(self, simulations: torch.Tensor) -> None:
84
+ """Add the simulations ``(N, pixels)`` of fitted orientations to the :attr:`ghost`."""
85
+ if self.blur:
86
+ return
87
+ self._fit_sum += normalize_rows(simulations).sum(0)
88
+ self._fit_count += len(simulations)
89
+
90
+ @property
91
+ def ghost(self) -> torch.Tensor | None:
92
+ """The scan's grains as the static background shows them: the normalized
93
+ mean simulation of the orientations fitted so far (:meth:`add_fits`).
94
+
95
+ The static background is the mean of the scan's frames, so it carries a
96
+ faint copy of every grain's bands, and a frame divided by it carries
97
+ their negative. A simulation has no such ghost; fitted without it, a
98
+ strain bends to match the ghost of the other grains, differently in each
99
+ grain. A blurred static background has no bands, so then there is no ghost.
100
+ """
101
+ if self.static is None or not self._fit_count:
102
+ return None
103
+ return normalize_rows(self._fit_sum[None])[0]
@@ -0,0 +1,70 @@
1
+ """Calibrating the pattern center from a buffer of live frames."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ import torch
8
+
9
+ from de_ebsd.index.live.common import parabola_offset, pick
10
+
11
+ #: Pattern-center steps (fractions of the detector, as the center is), each half the
12
+ # : last. On a twin scan (1024 patterns, 33 grains), starts 0.004-0.01 off in every coordinate came
13
+ # back
14
+ #: within 0.001 of the truth (256 patterns, 0.45 s); 0.002 off costs 0.07° of median
15
+ #: orientation error.
16
+ PATTERN_CENTER_STEPS = (0.008, 0.004, 0.002, 0.001)
17
+
18
+ #: The orientation refine run at each candidate center. It must resolve far finer
19
+ #: than the indexing's: a center 0.004 off in x moves every pattern's best fit by a
20
+ #: few tenths of a degree, and steps ending at 0.12° follow that in jumps, which
21
+ #: leaves the mean score flat in x and the center stuck 0.003 short.
22
+ PATTERN_CENTER_ORIENTATION_STEPS_DEG = (0.5, 0.25, 0.12, 0.06, 0.03)
23
+
24
+ #: Patterns a center is fitted with: the best-scored of the buffer.
25
+ PATTERN_CENTER_PATTERNS = 256
26
+
27
+
28
+ def refine_pattern_center(
29
+ indexer,
30
+ frames,
31
+ *,
32
+ patterns: int = PATTERN_CENTER_PATTERNS,
33
+ steps=PATTERN_CENTER_STEPS,
34
+ orientation_steps_deg=PATTERN_CENTER_ORIENTATION_STEPS_DEG,
35
+ ) -> tuple[float, float, float]:
36
+ """The pattern center that best fits the best-scored *patterns* of
37
+ *frames* ``(N, H, W)``, which the indexer then uses
38
+ (:meth:`LiveIndexer.set_pattern_center`).
39
+
40
+ A center moved along x or y looks to every pattern much like the sample
41
+ turned slightly, so the center is fitted with each orientation free: at
42
+ each candidate center every orientation is refined again
43
+ (*orientation_steps_deg*) and the candidate scored by the patterns' mean
44
+ score. A level scores the center and one step either side of it on each
45
+ axis and moves to each axis's parabola vertex, as :meth:`LiveIndexer.refine` does for
46
+ an orientation. The patterns should come from several grains: one
47
+ orientation alone cannot tell the center from a turn.
48
+ """
49
+ phase, g, score, _ = indexer.index(frames)
50
+ best = torch.argsort(score, descending=True)[:patterns]
51
+ _, rows = indexer.prepare(pick(torch.as_tensor(frames, device=indexer.device), best))
52
+ phase, g = phase[best], g[best]
53
+ orientation_steps = tuple(math.radians(s) for s in orientation_steps_deg)
54
+ pattern_center = torch.tensor(indexer.detector.pattern_center, dtype=torch.float64)
55
+ for step in steps:
56
+ scores, refined = [], []
57
+ for offset in indexer.stencil.cpu().double():
58
+ directions = indexer.directions_at(pattern_center + step * offset, indexer.refine_shape)
59
+ found, found_score = indexer.refine(
60
+ rows, phase, g, steps=orientation_steps, directions=directions
61
+ )
62
+ scores.append(found_score.mean())
63
+ refined.append(found)
64
+ g = refined[0]
65
+ pattern_center = (
66
+ pattern_center + step * parabola_offset(torch.stack(scores).double().cpu()[None])[0]
67
+ )
68
+ center = tuple(float(v) for v in pattern_center)
69
+ indexer.set_pattern_center(center)
70
+ return center
@@ -0,0 +1,44 @@
1
+ """Small pieces the live indexer's modules share."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+ import torch.nn.functional as F
7
+
8
+ from de_ebsd.core.constants import SINGLE_PRECISION_FLOOR
9
+
10
+ #: The local dictionary of one refine level, in units of its step: the seed, then
11
+ #: +x, -x, +y, -y, +z, -z.
12
+ STENCIL = ((0, 0, 0), (1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1))
13
+
14
+ #: Patterns per batched step, which bounds the largest intermediate: the refine's
15
+ #: (patterns, 7, pixels, 3) sample directions.
16
+ BATCH = 256
17
+
18
+
19
+ def average_to(stack: torch.Tensor, shape) -> torch.Tensor:
20
+ """*stack* ``(N, H, W)`` averaged down to *shape*: a whole-number bin when the
21
+ shapes divide, which runs several times faster than a fractional one."""
22
+ rows, cols = stack.shape[-2] // shape[0], stack.shape[-1] // shape[1]
23
+ if (rows * shape[0], cols * shape[1]) == tuple(stack.shape[-2:]):
24
+ return F.avg_pool2d(stack[:, None], (rows, cols))[:, 0]
25
+ return F.adaptive_avg_pool2d(stack[:, None], shape)[:, 0]
26
+
27
+
28
+ def pick(frames: torch.Tensor, rows: torch.Tensor) -> torch.Tensor:
29
+ """``frames[rows]``, for a camera's uint16 frames too: CUDA cannot index a
30
+ uint16 tensor, but it can the same bits as int16."""
31
+ if frames.dtype == torch.uint16:
32
+ return frames.view(torch.int16)[rows].view(torch.uint16)
33
+ return frames[rows]
34
+
35
+
36
+ def parabola_offset(scores: torch.Tensor) -> torch.Tensor:
37
+ """The step ``(N, 3)``, in units of the stencil's, to each axis's best: the
38
+ vertex of the parabola through the scores ``(N, 7)`` at -1, 0 and +1 (see
39
+ :data:`STENCIL`), or the better neighbor where the three make no maximum."""
40
+ center, plus, minus = scores[:, :1], scores[:, 1::2], scores[:, 2::2]
41
+ curvature = plus - 2 * center + minus
42
+ vertex = 0.5 * (minus - plus) / curvature.clamp_max(-SINGLE_PRECISION_FLOOR)
43
+ fallback = torch.where(plus > minus, 1.0, -1.0) * (torch.maximum(plus, minus) > center)
44
+ return torch.where(curvature < 0, vertex.clamp(-1, 1), fallback.to(scores.dtype))
@@ -0,0 +1,289 @@
1
+ """Strain from a live-indexed pattern: orientation and deformation fitted together.
2
+
3
+ :func:`refine_deformation` fits each pattern against the master with eight free
4
+ numbers (three rotations, five strains), :func:`strain_from_reference`
5
+ cross-correlates real regions against a reference frame instead, and
6
+ :func:`center_maps` lets every pattern of a scan have its own pattern center.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from dataclasses import replace
13
+
14
+ import numpy as np
15
+ import torch
16
+
17
+ from de_ebsd.core.constants import SINGLE_PRECISION_FLOOR
18
+ from de_ebsd.index.live.common import BATCH
19
+ from de_ebsd.index.preprocess import normalize_rows
20
+
21
+ #: Gauss-Newton iterations of :func:`refine_deformation`. A plain step
22
+ #: closes only about half of what is left, because the simulation is sharper than
23
+ #: the pattern; with :data:`DEFORMATION_LINE_SEARCH` three iterations do what seven
24
+ #: plain ones did, for half the projections. On a 15 kV silicon indent, against
25
+ #: CrossCourt's HR-EBSD, the four strongest strains reached 0.94-1.05 of their size
26
+ #: (0.64-0.87 at three plain iterations). Iterating on to convergence came no
27
+ #: closer but scattered more (rms difference 5.0-6.5e-4 against 3.4-5.4e-4).
28
+ DEFORMATION_ITERATIONS = 3
29
+
30
+ #: The multiples of each Gauss-Newton step that are tried, keeping whichever scores
31
+ #: best (or none, if all score worse than the current fit). Three more projections
32
+ #: per pattern on top of an iteration's seventeen.
33
+ DEFORMATION_LINE_SEARCH = (1.0, 2.0, 3.0)
34
+
35
+ #: The central-difference step of the deformation's Jacobian, as a strain. Much
36
+ #: smaller steps measure the bilinear master texture's facets rather than its slope.
37
+ DEFORMATION_STEP = 2e-3
38
+
39
+ #: The largest change one Gauss-Newton iteration may make to any of the
40
+ #: deformation's eight numbers. A step is the solve of a local linear model, and on
41
+ #: a poor pattern (a grain boundary, two patterns overlapping) that model can
42
+ #: point far outside the elastic range; without the limit 1-3% of a real scan's
43
+ #: fits ran off to strains of 0.1 and more.
44
+ DEFORMATION_MAX_STEP = 5e-3
45
+
46
+ #: How far below its seed's score a deformation fit may end before it is refused:
47
+ #: a fit that only ties its seed loses that much to rounding.
48
+ DEFORMATION_SCORE_MARGIN = 1e-4
49
+
50
+
51
+ def trace_free_basis(device) -> torch.Tensor:
52
+ """The 8 trace-free 3x3 matrices ``(8, 3, 3)``, orthonormal in the Frobenius
53
+ sense: rotations about x, y and z, then the five deviatoric strains. A
54
+ projection cannot see the ninth, hydrostatic, component."""
55
+ root2, root6 = math.sqrt(2.0), math.sqrt(6.0)
56
+ basis = torch.zeros(8, 3, 3, dtype=torch.float64)
57
+ for k, (i, j) in enumerate(((1, 2), (2, 0), (0, 1))):
58
+ basis[k, i, j], basis[k, j, i] = -1 / root2, 1 / root2
59
+ basis[3 + k, i, j] = basis[3 + k, j, i] = 1 / root2
60
+ basis[6] = torch.diag(torch.tensor([1.0, -1.0, 0.0], dtype=torch.float64)) / root2
61
+ basis[7] = torch.diag(torch.tensor([1.0, 1.0, -2.0], dtype=torch.float64)) / root6
62
+ return basis.to(device, torch.float32)
63
+
64
+
65
+ def polar_parts(beta: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
66
+ """The deformation gradient ``F = (I - beta)^-1`` of a displacement gradient
67
+ *beta* ``(N, 3, 3)``, split as ``F = R U``: ``(R, U - I)``, the lattice rotation
68
+ and the (Biot) strain, both in the sample frame."""
69
+ eye = torch.eye(3, device=beta.device, dtype=torch.float64)
70
+ f = torch.linalg.inv(eye - beta.double())
71
+ u, s, vt = torch.linalg.svd(f)
72
+ stretch = vt.transpose(-1, -2) @ torch.diag_embed(s) @ vt
73
+ return (u @ vt).to(beta.dtype), (stretch - eye).to(beta.dtype)
74
+
75
+
76
+ def center_maps(detector, pattern_centers, device) -> torch.Tensor:
77
+ """``(N, 3, 3)`` maps taking the refine's directions at the *detector*'s pattern
78
+ center to those at each of *pattern_centers* ``(N, 3)``, up to length. *detector* is at the
79
+ refine shape.
80
+
81
+ On a flat screen a pixel's position is linear in its index at any center,
82
+ so moving the center is exactly a linear map of the directions: in a
83
+ candidate ``g @ m`` it is one more matrix, and the fused kernels, which
84
+ normalize what they are given, need nothing new. It also means that, within
85
+ one pattern, a center error is indistinguishable from a strain, which is
86
+ why a scan's centers must come from its geometry rather than from each
87
+ pattern.
88
+ """
89
+ from de_ebsd.core.geometry import sample_to_detector_matrix
90
+
91
+ def screen(pattern_center):
92
+ # (col, row, 1) -> the unnormalized screen position, as direction_cosines
93
+ b = replace(
94
+ detector, pattern_center=tuple(float(v) for v in pattern_center)
95
+ ).gnomonic_bounds
96
+ x_scale, y_scale = (b[1] - b[0]) / detector.ncols, (b[3] - b[2]) / detector.nrows
97
+ z = float(pattern_center[2])
98
+ return np.array(
99
+ [
100
+ [x_scale * z, 0.0, (b[0] + 0.5 * x_scale) * z],
101
+ [0.0, -y_scale * z, (b[3] - 0.5 * y_scale) * z],
102
+ [0.0, 0.0, z],
103
+ ]
104
+ )
105
+
106
+ frame = sample_to_detector_matrix(
107
+ detector.sample_tilt, detector.tilt, detector.azimuthal, detector.twist
108
+ )
109
+ to_index = np.linalg.inv(screen(detector.pattern_center)) @ frame
110
+ maps = [
111
+ frame.T @ screen(pattern_center) @ to_index
112
+ for pattern_center in np.asarray(pattern_centers, float)
113
+ ]
114
+ return torch.as_tensor(np.stack(maps), device=device, dtype=torch.float32)
115
+
116
+
117
+ def refine_deformation(
118
+ indexer,
119
+ refine_rows: torch.Tensor,
120
+ phase: torch.Tensor,
121
+ g: torch.Tensor,
122
+ *,
123
+ iterations: int = DEFORMATION_ITERATIONS,
124
+ line_search: tuple[float, ...] = DEFORMATION_LINE_SEARCH,
125
+ step: float = DEFORMATION_STEP,
126
+ center_maps: torch.Tensor | None = None,
127
+ ghost: torch.Tensor | None = None,
128
+ ):
129
+ """Each orientation *g* ``(N, 3, 3)`` refined together with its pattern's
130
+ deviatoric strain: ``(g, strain, score)``, *g* the lattice's orientation,
131
+ *strain* ``(N, 3, 3)`` the symmetric, trace-free Biot strain in the sample
132
+ frame (the hydrostatic part is invisible to a projection), *score* the fit's.
133
+
134
+ A crystal deformed by ``F`` shows the master's pattern at ``g F^-1 r`` for a
135
+ ray ``r``, so a candidate is the matrix ``g (I - beta)`` with *beta* the
136
+ eight trace-free numbers -- three rotations and five strains -- that the
137
+ fused kernels project like any orientation. Each Gauss-Newton iteration
138
+ projects the current fit and its 16 central differences and solves the 8x8
139
+ normal equations of the normalized pattern, for every pattern at once.
140
+ Fitting the rotations alongside the strains is what sharpens the
141
+ orientations: a strain left out of the model is partly fitted as a turn.
142
+
143
+ *g* should already be within a few tenths of a degree (:meth:`LiveIndexer.refine`).
144
+ *center_maps* (:meth:`center_maps`) give each pattern its own center.
145
+ *ghost* (:attr:`ghost`) is taken out of the patterns and the simulations
146
+ alike, so every pattern may have any amount of it.
147
+
148
+ *line_search* tries multiples of each Gauss-Newton step, keeping the best
149
+ scored (no move, if none scores better than the current fit); an empty
150
+ one takes each step as it is. Each step is at most
151
+ :data:`DEFORMATION_MAX_STEP`, so a move is at most that times the
152
+ largest multiple. A fit that ends scoring below its seed
153
+ (:data:`DEFORMATION_SCORE_MARGIN`) is refused: its strain is NaN and its
154
+ *g* and *score* are the seed's.
155
+ """
156
+ basis = trace_free_basis(indexer.device)
157
+ n, k = len(g), 1 + 2 * len(basis)
158
+ eye = torch.eye(3, device=indexer.device)
159
+ offsets = step * torch.cat(
160
+ [torch.zeros(1, 8, device=indexer.device), torch.eye(8, device=indexer.device)]
161
+ )
162
+ offsets = torch.cat([offsets, -offsets[1:]]) # (17, 8): the fit, +each, -each
163
+ damping = 1e-3 * torch.eye(8, device=indexer.device)
164
+ p = torch.zeros(n, 8, device=indexer.device)
165
+ if ghost is not None:
166
+ refine_rows = normalize_rows(refine_rows - (refine_rows @ ghost)[:, None] * ghost)
167
+
168
+ def on_ghost(simulated):
169
+ """Each simulation's component along the ghost. The simulations are
170
+ never taken off it: every product below is corrected instead, which
171
+ is one pass over them rather than several."""
172
+ if ghost is None:
173
+ return torch.zeros(simulated.shape[:-1], device=indexer.device)
174
+ return simulated @ ghost
175
+
176
+ seed_score = None
177
+ for _ in range(iterations):
178
+ beta = torch.einsum("nkc,cij->nkij", p[:, None] + offsets, basis)
179
+ candidates = g[:, None] @ (eye - beta)
180
+ if center_maps is not None:
181
+ candidates = candidates @ center_maps[:, None]
182
+ simulated = indexer.project(
183
+ indexer.texture,
184
+ candidates.reshape(-1, 3, 3),
185
+ phase.repeat_interleave(k),
186
+ indexer.refine_directions,
187
+ )
188
+ simulated = normalize_rows(simulated).view(n, k, -1)
189
+ along = on_ghost(simulated)
190
+ fit, fit_along = simulated[:, 0], along[:, :1]
191
+ jacobian = (simulated[:, 1:9] - simulated[:, 9:]) / (2 * step)
192
+ jacobian_along = (along[:, 1:9] - along[:, 9:]) / (2 * step)
193
+ # the pattern's contrast is free: the residual is against scale * fit,
194
+ # both off the ghost (the patterns already are, so only the fit's
195
+ # length and the products with the Jacobian need correcting)
196
+ scale = (refine_rows * fit).sum(-1, keepdim=True) / (1 - fit_along**2)
197
+ if seed_score is None:
198
+ seed_score = scale[:, 0] * (1 - fit_along[:, 0] ** 2).sqrt()
199
+ residual = refine_rows - scale * fit
200
+ normal = torch.einsum("nkp,nlp->nkl", jacobian, jacobian)
201
+ normal = normal - jacobian_along[:, :, None] * jacobian_along[:, None, :]
202
+ normal = normal + damping * normal.diagonal(dim1=-2, dim2=-1).mean(-1)[:, None, None]
203
+ gradient = (
204
+ torch.einsum("nkp,np->nk", jacobian, residual) + scale * jacobian_along * fit_along
205
+ ) / scale.clamp_min(SINGLE_PRECISION_FLOOR)
206
+ change = torch.linalg.solve(normal, gradient)
207
+ reach = change.abs().amax(-1, keepdim=True)
208
+ change = change * (DEFORMATION_MAX_STEP / reach.clamp_min(DEFORMATION_MAX_STEP))
209
+ if not line_search:
210
+ p = p + change
211
+ continue
212
+ # The simulation is sharper than the pattern, so each step falls short
213
+ # by about the same share: try it longer too, and keep the best scored.
214
+ # Capping the longer moves as well cost a third of the strain near an
215
+ # indent, where the real steps are largest.
216
+ multiples = torch.tensor((0.0, *line_search), device=indexer.device)
217
+ tried = p[:, None] + multiples[None, :, None] * change[:, None]
218
+ trial = g[:, None] @ (eye - torch.einsum("nmc,cij->nmij", tried[:, 1:], basis))
219
+ if center_maps is not None:
220
+ trial = trial @ center_maps[:, None]
221
+ simulated = normalize_rows(
222
+ indexer.project(
223
+ indexer.texture,
224
+ trial.reshape(-1, 3, 3),
225
+ phase.repeat_interleave(len(line_search)),
226
+ indexer.refine_directions,
227
+ )
228
+ )
229
+ scores = (simulated.view(n, len(line_search), -1) * refine_rows[:, None]).sum(-1)
230
+ scores = scores / (1 - on_ghost(simulated).view(n, -1) ** 2).sqrt()
231
+ current = scale[:, 0] * (1 - fit_along[:, 0] ** 2).sqrt()
232
+ scores = torch.cat([current[:, None], scores], 1).nan_to_num(-torch.inf)
233
+ p = tried[torch.arange(n, device=indexer.device), scores.argmax(1)]
234
+ beta = torch.einsum("nc,cij->nij", p, basis)
235
+ final = g @ (eye - beta)
236
+ if center_maps is not None:
237
+ final = final @ center_maps
238
+ simulated = normalize_rows(
239
+ indexer.project(indexer.texture, final, phase, indexer.refine_directions)
240
+ )
241
+ score = (simulated * refine_rows).sum(-1) / (1 - on_ghost(simulated) ** 2).sqrt()
242
+ rotation, strain = polar_parts(beta)
243
+ fitted = g @ rotation.transpose(-1, -2)
244
+ if seed_score is not None:
245
+ refused = ~(score >= seed_score - DEFORMATION_SCORE_MARGIN) # NaN is refused too
246
+ strain = torch.where(refused[:, None, None], torch.nan, strain)
247
+ fitted = torch.where(refused[:, None, None], g, fitted)
248
+ score = torch.where(refused, seed_score, score)
249
+ return fitted, strain, score
250
+
251
+
252
+ def strain_from_reference(indexer, frames, reference, *, grid: int = 6, n_passes: int = 2):
253
+ """The strain of each of *frames* ``(N, H, W)`` relative to one real
254
+ *reference* frame ``(H, W)``, by cross-correlating regions of the patterns
255
+ (:func:`~de_ebsd.index.crosscorr.hrebsd`): ``(strain, rotation_vector,
256
+ residual)``, the strain ``(N, 3, 3)`` and lattice rotation ``(N, 3)`` in the
257
+ sample frame and the fit's residual ``(N,)``.
258
+
259
+ No master enters, so unlike :meth:`refine_deformation` nothing about how
260
+ well a simulation matches the camera limits it, but it measures only
261
+ differences from the reference, which should be strain-free, and only
262
+ within its grain: the remap holds for lattice rotations of a few degrees.
263
+ On a twin scan with a random 3e-3 strain at a 128 refine, the error relative
264
+ to a reference in each grain was 1.7e-3, against 7.3e-4 absolute from
265
+ :meth:`refine_deformation`; correlating regions against simulations
266
+ instead was 6e-3.
267
+ """
268
+ from de_ebsd.index.crosscorr import hrebsd
269
+
270
+ test = torch.cat(
271
+ [
272
+ indexer.background.remove(indexer.pooled(frames[lo : lo + BATCH]))
273
+ for lo in range(0, len(frames), BATCH)
274
+ ]
275
+ )
276
+ ref = indexer.background.remove(indexer.pooled(reference))
277
+ detector = replace(indexer.detector, shape=indexer.refine_shape)
278
+ result = hrebsd(
279
+ test,
280
+ ref,
281
+ detector,
282
+ roi_size=indexer.refine_shape[0] // 4,
283
+ grid=grid,
284
+ n_passes=n_passes,
285
+ batch=BATCH,
286
+ )
287
+ _, strain, rotation = result.to_sample(detector)
288
+ vector = torch.stack([rotation[:, 2, 1], rotation[:, 0, 2], rotation[:, 1, 0]], dim=-1)
289
+ return strain, vector, result.residual