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,362 @@
|
|
|
1
|
+
"""Brute-force dictionary (template) indexing.
|
|
2
|
+
|
|
3
|
+
Projects a fundamental-zone orientation sample through the master pattern into
|
|
4
|
+
simulated patterns and takes the best normalized cross-correlation against each
|
|
5
|
+
experimental pattern. The conventional baseline that
|
|
6
|
+
:class:`~de_ebsd.index.spherical.indexer.SphericalIndexer` replaces.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import replace
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import torch
|
|
15
|
+
import torch.nn.functional as F
|
|
16
|
+
from orix.quaternion import Rotation, Symmetry
|
|
17
|
+
from orix.sampling import get_sample_fundamental
|
|
18
|
+
|
|
19
|
+
from de_ebsd.core.device import resolve_device
|
|
20
|
+
from de_ebsd.index.preprocess import normalize_rows
|
|
21
|
+
|
|
22
|
+
# orientations projected per chunk when building the dictionary, so a large
|
|
23
|
+
# fundamental zone never materializes its whole (n_dict, P, 3) sampling tensor
|
|
24
|
+
_PROJ_CHUNK = 4096
|
|
25
|
+
# cap on the per-chunk (chunk, n_dict) score matrix, which bounds match memory
|
|
26
|
+
# regardless of dictionary size
|
|
27
|
+
_MAX_SCORE_ELEMS = 64_000_000
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DictionaryIndexer:
|
|
31
|
+
"""Index EBSD patterns against a fundamental-zone dictionary by matmul.
|
|
32
|
+
|
|
33
|
+
The dictionary -- the master projected at every fundamental-zone orientation,
|
|
34
|
+
normalized -- is built once in :meth:`__init__`; :meth:`index` then scores a
|
|
35
|
+
whole stack with one matmul. The grid is coarse by design, so the intended
|
|
36
|
+
workflow is this fast coarse pass followed by :meth:`refine`.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
master
|
|
41
|
+
A :class:`de_ebsd.sim.MasterPattern`, projected onto the detector at every
|
|
42
|
+
dictionary orientation.
|
|
43
|
+
detector
|
|
44
|
+
:class:`~de_ebsd.sim.detector.EBSDDetector` geometry; indexed patterns must
|
|
45
|
+
match its ``(nrows, ncols)``.
|
|
46
|
+
point_group
|
|
47
|
+
orix :class:`~orix.quaternion.Symmetry` whose fundamental zone is sampled.
|
|
48
|
+
Falls back to ``master.metadata["crystal"]``; one or the other is required.
|
|
49
|
+
resolution
|
|
50
|
+
Target average disorientation (degrees) of the sampling. Default ``3.0``,
|
|
51
|
+
which refines to the same sub-degree accuracy as ``2.0`` at a ~3x cheaper
|
|
52
|
+
build, while ``4.0`` starts dropping patterns into a wrong basin that
|
|
53
|
+
refinement cannot escape. Go finer only for strongly pseudo-symmetric phases.
|
|
54
|
+
device
|
|
55
|
+
Explicit argument > ``master.device`` > CPU.
|
|
56
|
+
dtype
|
|
57
|
+
``None`` (default) auto-selects fp16 on tensor-core GPUs (compute capability
|
|
58
|
+
>= 7.0) and fp32 elsewhere -- on Pascal (native fp16 at 1/64 the fp32 rate)
|
|
59
|
+
and on CPU (no fp16 matmul) fp16 is a pessimization.
|
|
60
|
+
energy_kev
|
|
61
|
+
Energy bin the master is projected at; ``None`` (default) is the highest.
|
|
62
|
+
match_binning
|
|
63
|
+
Bin factor for the coarse match only (default ``1``). Kikuchi bands are
|
|
64
|
+
low-frequency, so a binned pattern resolves the coarse cell just as well
|
|
65
|
+
while the match shrinks by ``match_binning**2``. Must divide both detector
|
|
66
|
+
dimensions. Refinement still runs at full resolution, so this trades nothing
|
|
67
|
+
away.
|
|
68
|
+
|
|
69
|
+
Attributes
|
|
70
|
+
----------
|
|
71
|
+
dictionary_rotations : orix.quaternion.Rotation
|
|
72
|
+
The fundamental-zone orientations, length ``n_dict``.
|
|
73
|
+
dictionary : torch.Tensor
|
|
74
|
+
Normalized, stored transposed and contiguous as ``(n_px, n_dict)`` so the
|
|
75
|
+
match runs at full matmul throughput (a transposed view runs at ~half).
|
|
76
|
+
n_dict : int
|
|
77
|
+
Number of orientations in the dictionary.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
master,
|
|
83
|
+
detector,
|
|
84
|
+
point_group=None,
|
|
85
|
+
resolution=3.0,
|
|
86
|
+
device=None,
|
|
87
|
+
dtype=None,
|
|
88
|
+
energy_kev=None,
|
|
89
|
+
match_binning=1,
|
|
90
|
+
):
|
|
91
|
+
self.master = master
|
|
92
|
+
self.detector = detector # full resolution; refinement uses this one
|
|
93
|
+
self.resolution = float(resolution)
|
|
94
|
+
self.energy_kev = energy_kev
|
|
95
|
+
self.match_binning = int(match_binning)
|
|
96
|
+
|
|
97
|
+
# the pattern center is a fraction, so the binned match detector shares the
|
|
98
|
+
# full one's geometry
|
|
99
|
+
b = self.match_binning
|
|
100
|
+
if b > 1:
|
|
101
|
+
h, w = self.detector.nrows, self.detector.ncols
|
|
102
|
+
if h % b or w % b:
|
|
103
|
+
raise ValueError(f"match_binning {b} must divide the detector shape {(h, w)}")
|
|
104
|
+
self._match_detector = replace(self.detector, shape=(h // b, w // b))
|
|
105
|
+
else:
|
|
106
|
+
self._match_detector = self.detector
|
|
107
|
+
|
|
108
|
+
self.point_group = self._resolve_point_group(master, point_group)
|
|
109
|
+
|
|
110
|
+
self.device = resolve_device(device, getattr(master, "device", "cpu"))
|
|
111
|
+
|
|
112
|
+
if dtype is None:
|
|
113
|
+
has_tensor_cores = (
|
|
114
|
+
self.device.type == "cuda" and torch.cuda.get_device_capability(self.device)[0] >= 7
|
|
115
|
+
)
|
|
116
|
+
dtype = torch.float16 if has_tensor_cores else torch.float32
|
|
117
|
+
self.dtype = dtype
|
|
118
|
+
|
|
119
|
+
self.dictionary_rotations = get_sample_fundamental(
|
|
120
|
+
resolution=self.resolution, point_group=self.point_group
|
|
121
|
+
)
|
|
122
|
+
self.n_dict = int(self.dictionary_rotations.size)
|
|
123
|
+
self.dictionary = self._build_dictionary()
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def _resolve_point_group(master, point_group) -> Symmetry:
|
|
127
|
+
if point_group is not None:
|
|
128
|
+
if not isinstance(point_group, Symmetry):
|
|
129
|
+
raise TypeError(
|
|
130
|
+
"point_group must be an orix Symmetry (e.g. "
|
|
131
|
+
"orix.quaternion.symmetry.Oh), got "
|
|
132
|
+
f"{type(point_group).__name__}"
|
|
133
|
+
)
|
|
134
|
+
return point_group
|
|
135
|
+
|
|
136
|
+
crystal = None
|
|
137
|
+
meta = getattr(master, "metadata", None)
|
|
138
|
+
if isinstance(meta, dict):
|
|
139
|
+
crystal = meta.get("crystal")
|
|
140
|
+
if crystal is None:
|
|
141
|
+
raise ValueError(
|
|
142
|
+
"no point_group given and master.metadata has no 'crystal' to derive "
|
|
143
|
+
"one from; dictionary indexing needs the crystal symmetry to sample the "
|
|
144
|
+
"fundamental zone -- pass point_group=<orix Symmetry> or a master whose "
|
|
145
|
+
"metadata['crystal'] is a de_ebsd Crystal"
|
|
146
|
+
)
|
|
147
|
+
from de_ebsd.index.io import phase_from_crystal
|
|
148
|
+
|
|
149
|
+
return phase_from_crystal(crystal).point_group
|
|
150
|
+
|
|
151
|
+
def _build_dictionary(self) -> torch.Tensor:
|
|
152
|
+
# master.project runs on master.data.device, so move the master here once
|
|
153
|
+
master = self.master
|
|
154
|
+
if master.device != self.device:
|
|
155
|
+
master = master.to(self.device)
|
|
156
|
+
|
|
157
|
+
det = self._match_detector
|
|
158
|
+
q = torch.as_tensor(np.asarray(self.dictionary_rotations.data), dtype=torch.float32)
|
|
159
|
+
n_px = int(det.nrows * det.ncols)
|
|
160
|
+
dictionary = torch.empty(self.n_dict, n_px, device=self.device, dtype=self.dtype)
|
|
161
|
+
for i in range(0, self.n_dict, _PROJ_CHUNK):
|
|
162
|
+
sim = master.project(
|
|
163
|
+
det,
|
|
164
|
+
q[i : i + _PROJ_CHUNK],
|
|
165
|
+
energy_kev=self.energy_kev,
|
|
166
|
+
rescale=False,
|
|
167
|
+
)
|
|
168
|
+
sim = sim.reshape(sim.shape[0], -1).to(torch.float32) # normalize in fp32
|
|
169
|
+
dictionary[i : i + _PROJ_CHUNK] = normalize_rows(sim).to(self.dtype)
|
|
170
|
+
return dictionary.T.contiguous()
|
|
171
|
+
|
|
172
|
+
def _match_chunk(self, n_patterns: int) -> int:
|
|
173
|
+
chunk = max(1, _MAX_SCORE_ELEMS // max(1, self.n_dict))
|
|
174
|
+
return min(chunk, n_patterns)
|
|
175
|
+
|
|
176
|
+
def index(self, patterns, return_scores=False):
|
|
177
|
+
"""Index one pattern or a stack against the fundamental-zone dictionary.
|
|
178
|
+
|
|
179
|
+
Patterns are always given at full detector resolution; binning to the match
|
|
180
|
+
resolution happens internally.
|
|
181
|
+
|
|
182
|
+
Parameters
|
|
183
|
+
----------
|
|
184
|
+
patterns
|
|
185
|
+
A detector image ``(H, W)`` or stack ``(Np, H, W)``, numpy or torch.
|
|
186
|
+
return_scores
|
|
187
|
+
Also return the full ``(Np, n_dict)`` float32 score matrix.
|
|
188
|
+
|
|
189
|
+
Returns
|
|
190
|
+
-------
|
|
191
|
+
rotations : orix.quaternion.Rotation
|
|
192
|
+
Best-matching fundamental-zone orientation per pattern.
|
|
193
|
+
quality : torch.Tensor
|
|
194
|
+
Peak NCC per pattern, roughly in ``[-1, 1]``; higher is better.
|
|
195
|
+
scores : torch.Tensor
|
|
196
|
+
Only when ``return_scores``: the ``(Np, n_dict)`` matrix whose per-row
|
|
197
|
+
argmax gives ``rotations``.
|
|
198
|
+
"""
|
|
199
|
+
patt = torch.as_tensor(patterns)
|
|
200
|
+
single = patt.ndim == 2
|
|
201
|
+
if single:
|
|
202
|
+
patt = patt[None]
|
|
203
|
+
if patt.ndim != 3:
|
|
204
|
+
raise ValueError(
|
|
205
|
+
f"patterns must be (H, W) or (Np, H, W), got shape {tuple(patt.shape)}"
|
|
206
|
+
)
|
|
207
|
+
if tuple(patt.shape[-2:]) != (self.detector.nrows, self.detector.ncols):
|
|
208
|
+
raise ValueError(
|
|
209
|
+
f"pattern shape {tuple(patt.shape[-2:])} does not match the detector "
|
|
210
|
+
f"{(self.detector.nrows, self.detector.ncols)}"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
n_p = patt.shape[0]
|
|
214
|
+
patt = patt.to(device=self.device, dtype=torch.float32)
|
|
215
|
+
if self.match_binning > 1:
|
|
216
|
+
patt = F.avg_pool2d(patt[:, None], self.match_binning)[:, 0]
|
|
217
|
+
# patterns are low-bit-depth: keep fp32 precision until the match itself
|
|
218
|
+
exp_n = normalize_rows(patt.reshape(n_p, -1)).to(self.dtype)
|
|
219
|
+
|
|
220
|
+
best_idx = torch.empty(n_p, dtype=torch.long, device=self.device)
|
|
221
|
+
best_score = torch.empty(n_p, dtype=torch.float32, device=self.device)
|
|
222
|
+
scores_out = [] if return_scores else None
|
|
223
|
+
chunk = self._match_chunk(n_p)
|
|
224
|
+
for i in range(0, n_p, chunk):
|
|
225
|
+
scores = exp_n[i : i + chunk] @ self.dictionary
|
|
226
|
+
# argmax on the raw dtype is bit-identical to casting first, and skips a
|
|
227
|
+
# large per-chunk fp32 allocation that otherwise dominates the fast matmul
|
|
228
|
+
bscore, bidx = scores.max(dim=1)
|
|
229
|
+
best_score[i : i + chunk] = bscore.float()
|
|
230
|
+
best_idx[i : i + chunk] = bidx
|
|
231
|
+
if return_scores:
|
|
232
|
+
scores_out.append(scores.float())
|
|
233
|
+
|
|
234
|
+
rot_data = np.asarray(self.dictionary_rotations.data)[best_idx.cpu().numpy()]
|
|
235
|
+
rotations = Rotation(rot_data)
|
|
236
|
+
if return_scores:
|
|
237
|
+
return rotations, best_score, torch.cat(scores_out, dim=0)
|
|
238
|
+
return rotations, best_score
|
|
239
|
+
|
|
240
|
+
def refine(self, patterns, rotations=None, **kw):
|
|
241
|
+
"""Gradient-refine the coarse dictionary orientations to sub-grid accuracy.
|
|
242
|
+
|
|
243
|
+
Wraps :func:`de_ebsd.index.refine.orientation.refine_orientation`, seeding
|
|
244
|
+
from this indexer's master and detector (from :meth:`index` when
|
|
245
|
+
``rotations`` is ``None``).
|
|
246
|
+
|
|
247
|
+
Defaults to ``method="lm"``: the second-order refiner reaches the same
|
|
248
|
+
sub-degree accuracy in ~10 steps rather than Adam's ~60. LM is
|
|
249
|
+
orientation-only, so ``refine_pattern_center=True`` falls back to Adam. ``**kw`` is
|
|
250
|
+
forwarded; ``device`` defaults to this indexer's.
|
|
251
|
+
"""
|
|
252
|
+
from de_ebsd.index.refine.orientation import refine_orientation
|
|
253
|
+
|
|
254
|
+
if rotations is None:
|
|
255
|
+
rotations, _ = self.index(patterns)
|
|
256
|
+
kw.setdefault("method", "lm")
|
|
257
|
+
kw.setdefault("device", self.device)
|
|
258
|
+
return refine_orientation(patterns, self.master, self.detector, rotations, **kw)
|
|
259
|
+
|
|
260
|
+
def index_scan(
|
|
261
|
+
self,
|
|
262
|
+
patterns,
|
|
263
|
+
shape=None,
|
|
264
|
+
step=1.0,
|
|
265
|
+
phase=None,
|
|
266
|
+
batch_size=None,
|
|
267
|
+
refine=False,
|
|
268
|
+
refine_iters=12,
|
|
269
|
+
):
|
|
270
|
+
"""Index a whole scan and assemble it into an orix ``CrystalMap``.
|
|
271
|
+
|
|
272
|
+
Mirrors
|
|
273
|
+
:meth:`~de_ebsd.index.spherical.indexer.SphericalIndexer.index_scan`.
|
|
274
|
+
|
|
275
|
+
Parameters
|
|
276
|
+
----------
|
|
277
|
+
patterns
|
|
278
|
+
Either ``(rows, cols, H, W)``, from which the grid is inferred, or a flat
|
|
279
|
+
row-major ``(N, H, W)``, which requires ``shape``.
|
|
280
|
+
shape
|
|
281
|
+
``(rows, cols)`` of the scan. Cross-checked against 4-D input.
|
|
282
|
+
step
|
|
283
|
+
Scan step size, written onto the map's ``x``/``y``.
|
|
284
|
+
phase
|
|
285
|
+
An orix :class:`~orix.crystal_map.Phase` or a de_ebsd
|
|
286
|
+
:class:`~de_ebsd.crystal.Crystal`. If ``None``, derived from
|
|
287
|
+
``master.metadata["crystal"]``.
|
|
288
|
+
batch_size
|
|
289
|
+
Patterns per mini-batch (default ``256``). Results are identical to a
|
|
290
|
+
single batch.
|
|
291
|
+
refine
|
|
292
|
+
Gradient-refine each mini-batch before assembly, making
|
|
293
|
+
``prop["quality"]`` the refined NCC. The coarse grid makes this the
|
|
294
|
+
intended finish.
|
|
295
|
+
refine_iters
|
|
296
|
+
Levenberg-Marquardt steps when ``refine`` (default ``12``).
|
|
297
|
+
|
|
298
|
+
Returns
|
|
299
|
+
-------
|
|
300
|
+
orix.crystal_map.CrystalMap
|
|
301
|
+
One orientation + ``prop["quality"]`` per scan point, single phase.
|
|
302
|
+
"""
|
|
303
|
+
from de_ebsd.index.io import phase_from_crystal, to_crystal_map
|
|
304
|
+
|
|
305
|
+
# the stack stays on CPU (view-only reshape); only each mini-batch moves to
|
|
306
|
+
# self.device, so a large scan never lands wholesale on the GPU
|
|
307
|
+
patt = torch.as_tensor(patterns, dtype=torch.float32)
|
|
308
|
+
if patt.ndim == 4:
|
|
309
|
+
rows, cols = int(patt.shape[0]), int(patt.shape[1])
|
|
310
|
+
if shape is not None and (int(shape[0]), int(shape[1])) != (rows, cols):
|
|
311
|
+
raise ValueError(
|
|
312
|
+
f"shape {tuple(shape)} does not match the (rows, cols, H, W) "
|
|
313
|
+
f"pattern grid {(rows, cols)}"
|
|
314
|
+
)
|
|
315
|
+
patt = patt.reshape(rows * cols, patt.shape[2], patt.shape[3])
|
|
316
|
+
elif patt.ndim == 3:
|
|
317
|
+
if shape is None:
|
|
318
|
+
raise ValueError(
|
|
319
|
+
"shape=(rows, cols) is required for (N, H, W) patterns "
|
|
320
|
+
"(pass (rows, cols, H, W) to infer it instead)"
|
|
321
|
+
)
|
|
322
|
+
rows, cols = int(shape[0]), int(shape[1])
|
|
323
|
+
if rows * cols != patt.shape[0]:
|
|
324
|
+
raise ValueError(
|
|
325
|
+
f"shape {(rows, cols)} implies {rows * cols} patterns but got {patt.shape[0]}"
|
|
326
|
+
)
|
|
327
|
+
else:
|
|
328
|
+
raise ValueError(
|
|
329
|
+
f"patterns must be (rows, cols, H, W) or (N, H, W), got shape {tuple(patt.shape)}"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
n = rows * cols
|
|
333
|
+
bs = int(batch_size) if batch_size is not None else 256
|
|
334
|
+
if bs < 1:
|
|
335
|
+
raise ValueError(f"batch_size must be >= 1, got {batch_size}")
|
|
336
|
+
|
|
337
|
+
rot_chunks = []
|
|
338
|
+
qual_chunks = []
|
|
339
|
+
for start in range(0, n, bs):
|
|
340
|
+
chunk = patt[start : start + bs].to(self.device)
|
|
341
|
+
rot_c, qual_c = self.index(chunk)
|
|
342
|
+
if refine:
|
|
343
|
+
rot_c, qual_c = self.refine(chunk, rotations=rot_c, n_iterations=refine_iters)
|
|
344
|
+
rot_chunks.append(np.asarray(rot_c.data))
|
|
345
|
+
qual_chunks.append(qual_c.detach().cpu())
|
|
346
|
+
rotations = Rotation(np.concatenate(rot_chunks, axis=0))
|
|
347
|
+
quality = torch.cat(qual_chunks)
|
|
348
|
+
|
|
349
|
+
if phase is None:
|
|
350
|
+
crystal = None
|
|
351
|
+
meta = getattr(self.master, "metadata", None)
|
|
352
|
+
if isinstance(meta, dict):
|
|
353
|
+
crystal = meta.get("crystal")
|
|
354
|
+
if crystal is None:
|
|
355
|
+
raise ValueError(
|
|
356
|
+
"no phase given and master.metadata has no 'crystal' to derive one "
|
|
357
|
+
"from; pass phase=<orix Phase or de_ebsd Crystal> to index_scan "
|
|
358
|
+
"(the Phase gives the CrystalMap its crystal symmetry)"
|
|
359
|
+
)
|
|
360
|
+
phase = phase_from_crystal(crystal)
|
|
361
|
+
|
|
362
|
+
return to_crystal_map(rotations, quality, (rows, cols), phase, step=step)
|
de_ebsd/index/io.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Bridges to orix and kikuchipy: phases, crystal maps, EMsoft master patterns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def phase_from_crystal(crystal):
|
|
9
|
+
"""Bridge a de_ebsd :class:`~de_ebsd.sim.Crystal` to an orix ``Phase``.
|
|
10
|
+
|
|
11
|
+
The returned :class:`orix.crystal_map.Phase` is what gives a
|
|
12
|
+
:class:`~orix.crystal_map.CrystalMap` its **crystal symmetry**: orix derives
|
|
13
|
+
the point group (hence the symmetry operations used for disorientation,
|
|
14
|
+
IPF coloring, fundamental-zone reduction, ...) from the space-group number.
|
|
15
|
+
The lattice + space group is therefore the load-bearing part; the atom sites
|
|
16
|
+
are added for completeness when present.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
crystal
|
|
21
|
+
A :class:`de_ebsd.sim.Crystal`. Its ``space_group`` (int), lattice
|
|
22
|
+
(``a, b, c`` in nm and ``alpha, beta, gamma`` in degrees) and asymmetric
|
|
23
|
+
unit (``sites``) are read off.
|
|
24
|
+
|
|
25
|
+
Returns
|
|
26
|
+
-------
|
|
27
|
+
orix.crystal_map.Phase
|
|
28
|
+
A phase carrying the space group (and the point group orix derives from
|
|
29
|
+
it) plus a diffpy ``Structure`` with the lattice and the asymmetric-unit
|
|
30
|
+
atoms.
|
|
31
|
+
|
|
32
|
+
Notes
|
|
33
|
+
-----
|
|
34
|
+
The ``Crystal.origin`` setting (origin choice 1 vs 2 for the two-origin
|
|
35
|
+
space groups) shifts the atom positions but not the point group, so it does
|
|
36
|
+
not change the symmetry the ``CrystalMap`` carries; it is recorded on the
|
|
37
|
+
diffpy lattice only through the fractional coordinates already stored on the
|
|
38
|
+
crystal. Lattice lengths are passed through in nm (the crystal's own unit).
|
|
39
|
+
"""
|
|
40
|
+
from diffpy.structure import Atom, Lattice, Structure
|
|
41
|
+
from orix.crystal_map import Phase
|
|
42
|
+
|
|
43
|
+
if not hasattr(crystal, "space_group"):
|
|
44
|
+
# MasterPattern.save() stringifies metadata, so a master round-tripped
|
|
45
|
+
# through HDF5 carries a repr here rather than the Crystal itself
|
|
46
|
+
raise TypeError(
|
|
47
|
+
f"expected a de_ebsd Crystal, got {type(crystal).__name__}; a master "
|
|
48
|
+
"loaded from HDF5 keeps only a string copy of metadata['crystal'], so "
|
|
49
|
+
"pass the phase (or point_group) explicitly for such a master"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
name = getattr(crystal, "name", None) or _crystal_name(crystal)
|
|
53
|
+
|
|
54
|
+
lattice = Lattice(crystal.a, crystal.b, crystal.c, crystal.alpha, crystal.beta, crystal.gamma)
|
|
55
|
+
atoms = []
|
|
56
|
+
for site in getattr(crystal, "sites", ()): # asymmetric unit
|
|
57
|
+
atoms.append(
|
|
58
|
+
Atom(
|
|
59
|
+
atype=site.element,
|
|
60
|
+
xyz=np.asarray(site.position, dtype=np.float64),
|
|
61
|
+
occupancy=float(site.occupancy),
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
structure = Structure(atoms=atoms, lattice=lattice)
|
|
65
|
+
|
|
66
|
+
# orix derives the point group from the space-group number -- this is the
|
|
67
|
+
# symmetry the CrystalMap inherits.
|
|
68
|
+
return Phase(name=name, space_group=int(crystal.space_group), structure=structure)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _crystal_name(crystal) -> str:
|
|
72
|
+
sites = getattr(crystal, "sites", ())
|
|
73
|
+
elements = []
|
|
74
|
+
for site in sites:
|
|
75
|
+
if site.element not in elements:
|
|
76
|
+
elements.append(site.element)
|
|
77
|
+
return "".join(elements) if elements else f"sg{int(crystal.space_group)}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def to_crystal_map(rotations, quality, shape, phase, step=1.0):
|
|
81
|
+
"""Assemble indexed orientations + a quality metric into an orix ``CrystalMap``.
|
|
82
|
+
|
|
83
|
+
Parameters
|
|
84
|
+
----------
|
|
85
|
+
rotations : orix.quaternion.Rotation
|
|
86
|
+
A **flat** orix ``Rotation`` of length ``rows * cols`` in **row-major**
|
|
87
|
+
scan order (point ``(r, c)`` is at flat index ``r * cols + c``), e.g. the
|
|
88
|
+
concatenated output of :meth:`SphericalIndexer.index` over the scan.
|
|
89
|
+
quality
|
|
90
|
+
A length-``rows * cols`` real array or torch tensor of the per-point peak
|
|
91
|
+
score, stored under ``prop["quality"]`` as a plain numpy array.
|
|
92
|
+
shape : tuple of int
|
|
93
|
+
``(rows, cols)`` of the scan.
|
|
94
|
+
phase
|
|
95
|
+
An :class:`orix.crystal_map.Phase`, **or** a de_ebsd
|
|
96
|
+
:class:`~de_ebsd.sim.Crystal` (converted via :func:`phase_from_crystal`).
|
|
97
|
+
This carries the crystal symmetry of the returned map.
|
|
98
|
+
step : float, optional
|
|
99
|
+
Scan step size (same value for both axes). Default ``1.0``.
|
|
100
|
+
|
|
101
|
+
Returns
|
|
102
|
+
-------
|
|
103
|
+
orix.crystal_map.CrystalMap
|
|
104
|
+
A single-phase map: ``x = column index * step``, ``y = row index * step``
|
|
105
|
+
following orix conventions, so ``xmap.plot()``/``get_map_data`` reshape to
|
|
106
|
+
the correct ``(rows, cols)`` 2-D layout and ``xmap[r, c]`` selects the
|
|
107
|
+
row-major flat index ``r * cols + c``. Every point has ``phase_id == 0``.
|
|
108
|
+
|
|
109
|
+
Notes
|
|
110
|
+
-----
|
|
111
|
+
The scan is **single-phase** here (``phase_id`` all zero). Multi-phase scans
|
|
112
|
+
(a phase list plus a per-point phase assignment) are a future extension.
|
|
113
|
+
"""
|
|
114
|
+
from orix.crystal_map import CrystalMap, PhaseList, create_coordinate_arrays
|
|
115
|
+
|
|
116
|
+
rows, cols = int(shape[0]), int(shape[1])
|
|
117
|
+
n = rows * cols
|
|
118
|
+
if rotations.size != n:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"rotations has length {rotations.size}, expected rows*cols = {n} for "
|
|
121
|
+
f"shape {(rows, cols)}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
quality_np = _to_numpy(quality).reshape(-1)
|
|
125
|
+
if quality_np.size != n:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"quality has length {quality_np.size}, expected rows*cols = {n} for "
|
|
128
|
+
f"shape {(rows, cols)}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if not _is_orix_phase(phase):
|
|
132
|
+
phase = phase_from_crystal(phase) # accept a de_ebsd Crystal
|
|
133
|
+
|
|
134
|
+
# idiomatic orix x/y: x varies fastest (columns), y is constant per row, so
|
|
135
|
+
# the flat arrays ravel row-major and match the flat rotation order above.
|
|
136
|
+
coords, n_coords = create_coordinate_arrays((rows, cols), (float(step), float(step)))
|
|
137
|
+
assert n_coords == n # create_coordinate_arrays agrees with rows*cols
|
|
138
|
+
|
|
139
|
+
return CrystalMap(
|
|
140
|
+
rotations=rotations,
|
|
141
|
+
phase_id=np.zeros(n, dtype=int),
|
|
142
|
+
x=coords["x"],
|
|
143
|
+
y=coords["y"],
|
|
144
|
+
phase_list=PhaseList(phase),
|
|
145
|
+
prop={"quality": quality_np},
|
|
146
|
+
scan_unit="um",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _is_orix_phase(obj) -> bool:
|
|
151
|
+
from orix.crystal_map import Phase
|
|
152
|
+
|
|
153
|
+
return isinstance(obj, Phase)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _to_numpy(x) -> np.ndarray:
|
|
157
|
+
detach = getattr(x, "detach", None)
|
|
158
|
+
if detach is not None:
|
|
159
|
+
x = detach().cpu().numpy()
|
|
160
|
+
return np.asarray(x)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def master_from_emsoft(path, device=None):
|
|
164
|
+
"""Load an EMsoft EBSD master-pattern HDF5 file as a de_ebsd ``MasterPattern``.
|
|
165
|
+
|
|
166
|
+
Reads the square-Lambert masters ``EMData/EBSDmaster/mLPNH``/``mLPSH``, summing
|
|
167
|
+
their leading asymmetric-unit **site** axis (indexing only the first site is a
|
|
168
|
+
known bug); an already-collapsed ``(n_energy, n, n)`` array is used as-is. All
|
|
169
|
+
``EkeVs`` energy bins are kept -- pick one with
|
|
170
|
+
``SphericalIndexer(energy_kev=...)``.
|
|
171
|
+
|
|
172
|
+
``CrystalData`` is rebuilt into a :class:`de_ebsd.crystal.Crystal` and stashed
|
|
173
|
+
in ``metadata["crystal"]`` so the indexer can auto-derive the phase. Atomic
|
|
174
|
+
numbers map to symbols via ``ELEMENT_SYMBOLS[Z - 1]``, and the space-group
|
|
175
|
+
setting (1 or 2) becomes ``Crystal(origin=...)``, defaulting to 2::
|
|
176
|
+
|
|
177
|
+
master = master_from_emsoft("Si_MC.h5")
|
|
178
|
+
xmap = SphericalIndexer(master, detector).index_scan(patterns, shape=shape)
|
|
179
|
+
|
|
180
|
+
Parameters
|
|
181
|
+
----------
|
|
182
|
+
path
|
|
183
|
+
Path to the EMsoft master-pattern HDF5 file.
|
|
184
|
+
device
|
|
185
|
+
Optional torch device the returned master is moved to.
|
|
186
|
+
|
|
187
|
+
Returns
|
|
188
|
+
-------
|
|
189
|
+
de_ebsd.sim.MasterPattern
|
|
190
|
+
``data`` of shape ``(n_energy, 2, n, n)`` (axis 1 = hemisphere, north
|
|
191
|
+
first), with ``metadata`` carrying ``"crystal"``, ``"dmin"``, ``"bethe"``
|
|
192
|
+
and ``"source"``. A missing or malformed ``CrystalData`` group leaves
|
|
193
|
+
``metadata["crystal_error"]`` instead, rather than sinking the whole load.
|
|
194
|
+
"""
|
|
195
|
+
import h5py
|
|
196
|
+
import torch
|
|
197
|
+
|
|
198
|
+
from de_ebsd.sim import MasterPattern
|
|
199
|
+
|
|
200
|
+
path = str(path)
|
|
201
|
+
with h5py.File(path, "r") as f:
|
|
202
|
+
mlpnh = np.asarray(f["EMData/EBSDmaster/mLPNH"], dtype=np.float32)
|
|
203
|
+
mlpsh = np.asarray(f["EMData/EBSDmaster/mLPSH"], dtype=np.float32)
|
|
204
|
+
energies = np.asarray(f["EMData/EBSDmaster/EkeVs"], dtype=np.float64).ravel()
|
|
205
|
+
dmin = _read_emsoft_scalar(f, "NMLparameters/EBSDMasterNameList/dmin", float)
|
|
206
|
+
bethe = _read_emsoft_array(f, "EMData/EBSDmaster/BetheParameters")
|
|
207
|
+
crystal_arrays = _read_crystal_arrays(f)
|
|
208
|
+
|
|
209
|
+
# site-summed hemispheres -> (n_energy, 2, n, n), north = 0 / south = 1.
|
|
210
|
+
north = _sum_over_sites(mlpnh)
|
|
211
|
+
south = _sum_over_sites(mlpsh)
|
|
212
|
+
data = torch.as_tensor(np.stack([north, south], axis=1).astype(np.float32))
|
|
213
|
+
|
|
214
|
+
metadata = {
|
|
215
|
+
"backend": "EMsoft (imported)",
|
|
216
|
+
"source": path,
|
|
217
|
+
"dmin": dmin,
|
|
218
|
+
"bethe": tuple(float(x) for x in bethe) if bethe is not None else None,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
# Rebuild the crystal for phase auto-derivation, but never let a malformed or
|
|
222
|
+
# partial CrystalData sink the load -- record the reason and return the master.
|
|
223
|
+
crystal, crystal_error = _crystal_from_arrays(crystal_arrays)
|
|
224
|
+
if crystal is not None:
|
|
225
|
+
metadata["crystal"] = crystal
|
|
226
|
+
if crystal_error is not None:
|
|
227
|
+
metadata["crystal_error"] = crystal_error
|
|
228
|
+
|
|
229
|
+
master = MasterPattern(data=data, energies_kev=energies, metadata=metadata)
|
|
230
|
+
if device is not None:
|
|
231
|
+
master = master.to(device)
|
|
232
|
+
return master
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _sum_over_sites(array: np.ndarray) -> np.ndarray:
|
|
236
|
+
array = np.asarray(array, dtype=np.float32)
|
|
237
|
+
if array.ndim == 4:
|
|
238
|
+
return array.sum(axis=0)
|
|
239
|
+
if array.ndim == 3:
|
|
240
|
+
return array
|
|
241
|
+
raise ValueError(
|
|
242
|
+
f"mLP master must be 3-D (n_energy, n, n) or 4-D (n_sites, n_energy, n, n), "
|
|
243
|
+
f"got ndim {array.ndim}"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _read_emsoft_scalar(f, key: str, cast):
|
|
248
|
+
try:
|
|
249
|
+
return cast(np.asarray(f[key]).ravel()[0])
|
|
250
|
+
except (KeyError, IndexError, ValueError, TypeError):
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _read_emsoft_array(f, key: str):
|
|
255
|
+
try:
|
|
256
|
+
return np.asarray(f[key], dtype=np.float64).ravel()
|
|
257
|
+
except (KeyError, ValueError, TypeError):
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _read_crystal_arrays(f):
|
|
262
|
+
try:
|
|
263
|
+
cd = f["CrystalData"]
|
|
264
|
+
arrays = {
|
|
265
|
+
"lattice": np.asarray(cd["LatticeParameters"], dtype=np.float64).ravel(),
|
|
266
|
+
"atom_data": np.asarray(cd["AtomData"], dtype=np.float64),
|
|
267
|
+
"atom_types": np.asarray(cd["Atomtypes"]).ravel(),
|
|
268
|
+
"space_group": int(np.asarray(cd["SpaceGroupNumber"]).ravel()[0]),
|
|
269
|
+
}
|
|
270
|
+
except (KeyError, IndexError, ValueError, TypeError):
|
|
271
|
+
return None
|
|
272
|
+
# setting is optional; origin choice 2 is the modern EMsoft default.
|
|
273
|
+
setting = _read_emsoft_scalar(cd, "SpaceGroupSetting", int)
|
|
274
|
+
arrays["setting"] = setting if setting in (1, 2) else 2
|
|
275
|
+
return arrays
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _crystal_from_arrays(arrays):
|
|
279
|
+
if arrays is None:
|
|
280
|
+
return None, "CrystalData group missing or unreadable"
|
|
281
|
+
try:
|
|
282
|
+
from de_ebsd.crystal import AtomSite, Crystal
|
|
283
|
+
from de_ebsd.crystal.data.scattering_tables import ELEMENT_SYMBOLS
|
|
284
|
+
|
|
285
|
+
lattice = arrays["lattice"]
|
|
286
|
+
if lattice.size < 6:
|
|
287
|
+
return None, f"LatticeParameters has {lattice.size} values, expected 6"
|
|
288
|
+
a, b, c, alpha, beta, gamma = (float(x) for x in lattice[:6])
|
|
289
|
+
|
|
290
|
+
atom_data = arrays["atom_data"]
|
|
291
|
+
atom_types = arrays["atom_types"]
|
|
292
|
+
n_atoms = min(atom_data.shape[1], atom_types.shape[0])
|
|
293
|
+
if n_atoms < 1:
|
|
294
|
+
return None, "CrystalData has no atoms"
|
|
295
|
+
|
|
296
|
+
sites = []
|
|
297
|
+
for j in range(n_atoms):
|
|
298
|
+
z = int(atom_types[j])
|
|
299
|
+
if not 1 <= z <= len(ELEMENT_SYMBOLS):
|
|
300
|
+
return None, f"atomic number {z} (site {j}) is out of the known range"
|
|
301
|
+
x, y, z_frac, occupancy, dw = (float(v) for v in atom_data[:5, j])
|
|
302
|
+
sites.append(
|
|
303
|
+
AtomSite(
|
|
304
|
+
element=ELEMENT_SYMBOLS[z - 1],
|
|
305
|
+
position=(x, y, z_frac),
|
|
306
|
+
occupancy=occupancy,
|
|
307
|
+
debye_waller=dw,
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
crystal = Crystal(
|
|
312
|
+
a=a,
|
|
313
|
+
b=b,
|
|
314
|
+
c=c,
|
|
315
|
+
alpha=alpha,
|
|
316
|
+
beta=beta,
|
|
317
|
+
gamma=gamma,
|
|
318
|
+
space_group=int(arrays["space_group"]),
|
|
319
|
+
sites=sites,
|
|
320
|
+
origin=int(arrays["setting"]),
|
|
321
|
+
)
|
|
322
|
+
return crystal, None
|
|
323
|
+
except (KeyError, IndexError, ValueError, TypeError) as exc:
|
|
324
|
+
return None, f"could not build crystal from CrystalData: {exc}"
|