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