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,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}"
@@ -0,0 +1,5 @@
1
+ """Live indexing; see :class:`LiveIndexer`."""
2
+
3
+ from de_ebsd.index.live.indexer import LiveIndexer
4
+
5
+ __all__ = ["LiveIndexer"]