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,386 @@
1
+ """Live indexing: a buffer of camera frames indexed and refined where it already is.
2
+
3
+ Built for a camera streaming thousands of patterns a second into GPU memory. A
4
+ buffer of frames is indexed in a fixed number of batched steps -- nothing loops
5
+ over patterns or phases -- and only each pattern's phase, orientation and score
6
+ leave the device.
7
+
8
+ 1. **Prepare** (:meth:`LiveIndexer.prepare`): each frame averaged over the whole
9
+ sensor down to the refine shape, divided by the scan's static background (the
10
+ running mean of its frames, :meth:`LiveIndexer.observe`, started from a search's
11
+ and blurred for a single crystal, :meth:`LiveIndexer.restart_static`), the
12
+ per-pattern dynamic background removed, then averaged again to the search shape.
13
+ 2. **Search** (:meth:`LiveIndexer.search`): one matrix product against every
14
+ phase's dictionary at once. The dictionaries are the phases' dynamical master
15
+ patterns projected over their fundamental zones, stacked into one matrix, so the
16
+ best entry gives the phase and a seed orientation together; a low symmetry's
17
+ large dictionary is scored a chunk of entries at a time.
18
+ 3. **Refine** (:meth:`LiveIndexer.refine`): a local dictionary around each seed --
19
+ the seed and the six rotations of one step about each axis -- projected from the
20
+ master on the fly at the refine shape. A parabola through each axis's three
21
+ scores moves the orientation; the step shrinks each level. Every level is the
22
+ same seven projections per pattern, so its cost is fixed and known.
23
+ 4. **Pattern center** (:meth:`LiveIndexer.refine_pattern_center`): the center
24
+ shared by a buffer's best patterns, refined the same way -- seven centers a
25
+ level, each scored with every pattern's orientation refined again there.
26
+
27
+ Orientations are rotation matrices ``g`` taking a sample-frame direction to the
28
+ crystal frame (``v_crystal = g @ v_sample``), as :meth:`MasterPattern.project`
29
+ uses them.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import math
35
+ from dataclasses import replace
36
+
37
+ import numpy as np
38
+ import torch
39
+
40
+ from de_ebsd.core.device import resolve_device
41
+ from de_ebsd.core.lambert import sample_texture
42
+ from de_ebsd.core.rotation import quaternion_to_matrix, rotation_vector_to_matrix
43
+ from de_ebsd.index import _kernels
44
+ from de_ebsd.index.live import calibration, deformation
45
+ from de_ebsd.index.live.background import ScanBackground
46
+ from de_ebsd.index.live.common import BATCH, STENCIL, average_to, parabola_offset
47
+ from de_ebsd.index.preprocess import gaussian_blur, normalize_rows
48
+
49
+ __all__ = ["LiveIndexer"]
50
+
51
+
52
+ #: Refine steps, in degrees, each half the last. On a twin scan rendered from a
53
+ #: dynamical master (1024 patterns, 33 grains, 3° dictionary, 128² refine) these
54
+ #: gave a median of 0.14° from the truth, against 0.23° for (1.5, 0.5, 0.17) and
55
+ #: 0.19° with a fourth level of 0.06.
56
+ REFINE_STEPS_DEG = (2.0, 1.0, 0.5, 0.25, 0.12)
57
+
58
+ #: How much the search's copy of the masters is blurred (Gaussian sigma, degrees on
59
+ #: the sphere). A sharp dynamical pattern's score falls off so steeply away from
60
+ #: its orientation that at a 3° dictionary no entry is near enough for some
61
+ #: grains and a distant wrong one wins: 6-9% of a twin scan's patterns landed
62
+ #: 40-60° off unblurred, none at 2°.
63
+ SEARCH_BLUR_DEG = 2.0
64
+
65
+
66
+ #: Dictionary entries scored at a time by :meth:`LiveIndexer.search`, which bounds
67
+ #: its scores at (patterns, this): 64 MB for a batch. A cubic phase's 3° dictionary
68
+ #: (30,443 entries) is one chunk; a triclinic one's (729,000) would otherwise
69
+ #: score into 750 MB per batch.
70
+ SEARCH_CHUNK = 65_536
71
+
72
+
73
+ def texture_of(masters, device, blur_deg: float = 0.0) -> torch.Tensor:
74
+ """Every phase's hemispheres as channels of one texture ``(1, 2 * phases, n, n)``:
75
+ one sampling pass serves all phases, and each pattern keeps its own phase's."""
76
+ if len({m.npx for m in masters}) != 1:
77
+ raise ValueError("every master must have the same npx")
78
+ texture = torch.cat([m.data[m.energy_index(None)].to(device, torch.float32) for m in masters])
79
+ # Near the center of the square Lambert grid a pixel spans 90° / npx.
80
+ per_degree = masters[0].npx / 90.0
81
+ if blur_deg:
82
+ texture = gaussian_blur(texture, blur_deg * per_degree, 4.0)
83
+ return texture[None].contiguous()
84
+
85
+
86
+ class LiveIndexer:
87
+ """Index and refine EBSD patterns of several phases on the device.
88
+
89
+ Parameters
90
+ ----------
91
+ masters
92
+ One :class:`~de_ebsd.sim.MasterPattern` per phase, all at the same ``npx``.
93
+ Each is used at its highest energy bin.
94
+ point_groups
95
+ Each phase's orix point group, whose fundamental zone its dictionary samples.
96
+ detector
97
+ The camera's :class:`~de_ebsd.sim.detector.EBSDDetector` (its pattern center
98
+ and angles); frames may come at any resolution, since they are averaged to
99
+ ``search_shape`` and ``refine_shape``.
100
+ resolution
101
+ The dictionary's sampling, in degrees.
102
+ search_shape, refine_shape
103
+ Detector shapes the search and the refine compare patterns at.
104
+ refine_steps_deg
105
+ The refine levels' steps; empty for no refine.
106
+ search_blur_deg
107
+ How much the search's copy of the masters is blurred, see
108
+ :data:`SEARCH_BLUR_DEG`.
109
+ device
110
+ Defaults to the first master's.
111
+ dtype
112
+ The search dictionary's. ``None`` picks float16 on GPUs with tensor cores
113
+ (compute capability 7.0 and up) and float32 elsewhere: Pascal multiplies
114
+ float16 at 1/64 of its float32 rate, and a CPU has no float16 product. Half
115
+ precision halves the dictionary, which is what grows with low symmetry; the
116
+ search only picks seeds, so the refine sets the accuracy either way.
117
+ """
118
+
119
+ def __init__(
120
+ self,
121
+ masters,
122
+ point_groups,
123
+ detector,
124
+ *,
125
+ resolution: float = 3.0,
126
+ search_shape: tuple[int, int] = (32, 32),
127
+ refine_shape: tuple[int, int] = (128, 128),
128
+ refine_steps_deg=REFINE_STEPS_DEG,
129
+ search_blur_deg: float = SEARCH_BLUR_DEG,
130
+ device=None,
131
+ dtype=None,
132
+ ) -> None:
133
+ from orix.sampling import get_sample_fundamental
134
+
135
+ if len(masters) != len(point_groups):
136
+ raise ValueError("one point group per master")
137
+ self.device = resolve_device(device, masters[0].device)
138
+ if dtype is None:
139
+ tensor_cores = (
140
+ self.device.type == "cuda" and torch.cuda.get_device_capability(self.device)[0] >= 7
141
+ )
142
+ dtype = torch.float16 if tensor_cores else torch.float32
143
+ self.dtype = dtype
144
+ self.dictionary = None
145
+ self.detector = detector
146
+ self.search_shape = tuple(search_shape)
147
+ self.refine_shape = tuple(refine_shape)
148
+ self.refine_steps = tuple(math.radians(s) for s in refine_steps_deg)
149
+ self.fused = _kernels.available(self.device)
150
+ self.texture = texture_of(masters, self.device)
151
+ self.search_texture = texture_of(masters, self.device, search_blur_deg)
152
+ self.stencil = torch.tensor(STENCIL, device=self.device, dtype=torch.float32)
153
+
154
+ rotations, phases = [], []
155
+ for number, point_group in enumerate(point_groups):
156
+ q = np.asarray(
157
+ get_sample_fundamental(resolution=resolution, point_group=point_group).data
158
+ )
159
+ rotations.append(
160
+ quaternion_to_matrix(torch.as_tensor(q, device=self.device, dtype=torch.float32))
161
+ )
162
+ phases.append(torch.full((len(q),), number, device=self.device, dtype=torch.int64))
163
+ self.rotations = torch.cat(rotations)
164
+ self.phase_of = torch.cat(phases)
165
+ self.sizes = [len(r) for r in rotations]
166
+ self.set_pattern_center(detector.pattern_center)
167
+ self.background = ScanBackground(self.refine_shape, self.device)
168
+
169
+ def directions_at(self, pattern_center, shape) -> torch.Tensor:
170
+ detector = replace(
171
+ self.detector, pattern_center=tuple(float(v) for v in pattern_center), shape=shape
172
+ )
173
+ return detector.direction_cosines(device=self.device)
174
+
175
+ def set_pattern_center(self, pattern_center) -> None:
176
+ """Index at pattern center *pattern_center* from now on: the dictionary is projected
177
+ again, which takes milliseconds with the fused kernels.
178
+
179
+ Each chunk of entries is written straight into the dictionary, and a new
180
+ center overwrites the old one's in place: the dictionary is the largest
181
+ thing on the device, and building it beside a copy of itself (or a list of
182
+ its chunks) took two to three times its size."""
183
+ self.detector = replace(
184
+ self.detector, pattern_center=tuple(float(v) for v in pattern_center)
185
+ )
186
+ self.search_directions = self.directions_at(pattern_center, self.search_shape)
187
+ self.refine_directions = self.directions_at(pattern_center, self.refine_shape)
188
+ shape = (len(self.search_directions), len(self.rotations))
189
+ if self.dictionary is None or tuple(self.dictionary.shape) != shape:
190
+ #: ``(pixels, entries)``, so the search's product reads it at full rate.
191
+ self.dictionary = torch.empty(shape, device=self.device, dtype=self.dtype)
192
+ for lo in range(0, len(self.rotations), BATCH * 8):
193
+ entries = normalize_rows(
194
+ self.project(
195
+ self.search_texture,
196
+ self.rotations[lo : lo + BATCH * 8],
197
+ self.phase_of[lo : lo + BATCH * 8],
198
+ self.search_directions,
199
+ )
200
+ )
201
+ self.dictionary[:, lo : lo + len(entries)] = entries.T
202
+
203
+ def project(
204
+ self,
205
+ texture: torch.Tensor,
206
+ rotations: torch.Tensor,
207
+ phases: torch.Tensor,
208
+ directions: torch.Tensor,
209
+ ) -> torch.Tensor:
210
+ """The patterns ``(M, pixels)`` of *rotations* ``(M, 3, 3)``, each from its
211
+ own phase's master in *texture*, over *directions* ``(pixels, 3)``."""
212
+ if self.fused:
213
+ return _kernels.project(texture, rotations, phases * 2, directions)
214
+ v = torch.einsum("mij,pj->mpi", rotations, directions)
215
+ return sample_texture(texture, v, phases[:, None])
216
+
217
+ def score_candidates(
218
+ self, candidates: torch.Tensor, phases: torch.Tensor, rows: torch.Tensor, directions
219
+ ) -> torch.Tensor:
220
+ """Scores ``(N, K)`` of candidates ``(N, K, 3, 3)`` against their patterns'
221
+ normalized *rows* ``(N, pixels)``, sampled at *directions*."""
222
+ n, k = candidates.shape[:2]
223
+ flat = candidates.reshape(-1, 3, 3)
224
+ bases = phases.repeat_interleave(k) * 2
225
+ if self.fused:
226
+ return _kernels.score(self.texture, flat, bases, directions, rows, k).view(n, k)
227
+ simulated = normalize_rows(self.project(self.texture, flat, bases // 2, directions))
228
+ return torch.einsum("nkp,np->nk", simulated.view(n, k, -1), rows)
229
+
230
+ # --- the scan's backgrounds ------------------------------------------------
231
+
232
+ @property
233
+ def static(self):
234
+ """The scan's static background, ``None`` until enough frames have been seen."""
235
+ return self.background.static
236
+
237
+ @property
238
+ def ghost(self):
239
+ """The scan's grains as the static background shows them, or ``None``; see
240
+ :attr:`ScanBackground.ghost`."""
241
+ return self.background.ghost
242
+
243
+ def pooled(self, frames) -> torch.Tensor:
244
+ """*frames* on the device as float ``(N, H, W)`` averaged to the refine shape."""
245
+ t = torch.as_tensor(frames, device=self.device)
246
+ t = t[None] if t.ndim == 2 else t
247
+ return average_to(t.to(torch.float32), self.refine_shape)
248
+
249
+ def restart_static(self, frames=None, *, blur: float | None = None) -> None:
250
+ """Forget the scan, for a new one: its static background and its ghost.
251
+
252
+ *frames* from all over the same field, such as a search's, start the new
253
+ static background, so the scan's first patterns are divided by one that
254
+ holds no grain's bands rather than by the mean of its first few frames.
255
+
256
+ With *blur* (a Gaussian sigma, as a share of the pattern's width) the
257
+ static background is the frames' mean blurred, for a single crystal: its
258
+ frames all carry its bands, so their mean would divide them out. It then
259
+ has no ghost."""
260
+ self.background.restart(blur)
261
+ if frames is not None:
262
+ self.observe(frames)
263
+
264
+ def observe(self, frames) -> None:
265
+ """Add *frames* to the scan's static background."""
266
+ self.background.observe(self.pooled(frames))
267
+
268
+ def add_fits(self, phase, g, center_maps=None) -> None:
269
+ """Add the simulations of fitted orientations *g* ``(N, 3, 3)`` to the
270
+ scan's :attr:`ghost`."""
271
+ if self.background.blur:
272
+ return
273
+ if center_maps is not None:
274
+ g = g @ center_maps
275
+ self.background.add_fits(self.project(self.texture, g, phase, self.refine_directions))
276
+
277
+ def corrected(self, frames) -> torch.Tensor:
278
+ """*frames* ``(N, H, W)`` with the backgrounds removed as :meth:`prepare` removes
279
+ them, at their own resolution: what the indexer matches, to be looked at."""
280
+ t = torch.as_tensor(frames, device=self.device)
281
+ t = t[None] if t.ndim == 2 else t
282
+ return self.background.remove(t.to(torch.float32))
283
+
284
+ def prepare(self, frames) -> tuple[torch.Tensor, torch.Tensor]:
285
+ """*frames* ``(N, H, W)`` corrected and normalized, as ``(search, refine)``
286
+ rows ``(N, pixels)`` at the two shapes."""
287
+ t = self.background.remove(self.pooled(frames))
288
+ search = average_to(t, self.search_shape)
289
+ return normalize_rows(search.flatten(1)), normalize_rows(t.flatten(1))
290
+
291
+ def search(self, search_rows: torch.Tensor):
292
+ """The best dictionary entry for each pattern: ``(phase, g, score, entry)``,
293
+ *entry* indexing :attr:`rotations` (every phase's entries, in phase order).
294
+
295
+ The dictionary is scored :data:`SEARCH_CHUNK` entries at a time, keeping
296
+ each pattern's best so far, so the scores never outgrow one chunk."""
297
+ rows = search_rows.to(self.dtype)
298
+ score = torch.full((len(rows),), -math.inf, device=self.device)
299
+ entry = torch.zeros(len(rows), dtype=torch.int64, device=self.device)
300
+ for lo in range(0, self.dictionary.shape[1], SEARCH_CHUNK):
301
+ chunk_score, chunk_entry = (rows @ self.dictionary[:, lo : lo + SEARCH_CHUNK]).max(1)
302
+ better = chunk_score.float() > score
303
+ score = torch.where(better, chunk_score.float(), score)
304
+ entry = torch.where(better, chunk_entry + lo, entry)
305
+ return self.phase_of[entry], self.rotations[entry], score, entry
306
+
307
+ def refine(
308
+ self,
309
+ refine_rows: torch.Tensor,
310
+ phase: torch.Tensor,
311
+ g: torch.Tensor,
312
+ *,
313
+ steps=None,
314
+ directions=None,
315
+ ):
316
+ """Each orientation *g* ``(N, 3, 3)`` refined against its pattern:
317
+ ``(g, score)``, the score being the last level's at the seed. *steps*
318
+ (radians) default to the indexer's, *directions* to its pattern center's."""
319
+ steps = self.refine_steps if steps is None else steps
320
+ directions = self.refine_directions if directions is None else directions
321
+ score = torch.zeros(len(g), device=self.device)
322
+ for step in steps:
323
+ candidates = g[:, None] @ rotation_vector_to_matrix(self.stencil * step)
324
+ scores = self.score_candidates(candidates, phase, refine_rows, directions)
325
+ g = g @ rotation_vector_to_matrix(parabola_offset(scores) * step)
326
+ score = scores[:, 0]
327
+ return g, score
328
+
329
+ # --- the pattern center and the strain --------------------------------------
330
+
331
+ def refine_pattern_center(self, frames, **kwargs) -> tuple[float, float, float]:
332
+ """The pattern center that best fits the best-scored patterns of *frames*
333
+ ``(N, H, W)``, which the indexer then uses; see
334
+ :func:`~de_ebsd.index.live.calibration.refine_pattern_center`."""
335
+ return calibration.refine_pattern_center(self, frames, **kwargs)
336
+
337
+ def center_maps(self, pattern_centers) -> torch.Tensor:
338
+ """One ``(3, 3)`` map per pattern center; see
339
+ :func:`~de_ebsd.index.live.deformation.center_maps`."""
340
+ detector = replace(self.detector, shape=self.refine_shape)
341
+ return deformation.center_maps(detector, pattern_centers, self.device)
342
+
343
+ def refine_deformation(self, refine_rows, phase, g, **kwargs):
344
+ """Each orientation refined together with its pattern's strain; see
345
+ :func:`~de_ebsd.index.live.deformation.refine_deformation`."""
346
+ return deformation.refine_deformation(self, refine_rows, phase, g, **kwargs)
347
+
348
+ def strain_from_reference(self, frames, reference, **kwargs):
349
+ """The strain of *frames* relative to one real *reference* frame; see
350
+ :func:`~de_ebsd.index.live.deformation.strain_from_reference`."""
351
+ return deformation.strain_from_reference(self, frames, reference, **kwargs)
352
+
353
+ def index_with_strain(self, frames, pattern_centers=None):
354
+ """:meth:`index`, then each orientation refined together with its pattern's
355
+ strain (:meth:`refine_deformation`): ``(phase, g, strain, score, entry)``.
356
+ *pattern_centers* ``(N, 3)``, one per frame, default to the indexer's.
357
+
358
+ Each buffer's orientations join the scan's :attr:`ghost` before its
359
+ strains are fitted, as its frames joined the static background."""
360
+ maps = None if pattern_centers is None else self.center_maps(pattern_centers)
361
+ out = []
362
+ for lo in range(0, len(frames), BATCH):
363
+ search_rows, refine_rows = self.prepare(frames[lo : lo + BATCH])
364
+ phase, g, _, entry = self.search(search_rows)
365
+ g, _ = self.refine(refine_rows, phase, g)
366
+ batch_maps = None if maps is None else maps[lo : lo + BATCH]
367
+ self.add_fits(phase, g, batch_maps)
368
+ g, strain, score = self.refine_deformation(
369
+ refine_rows, phase, g, center_maps=batch_maps, ghost=self.ghost
370
+ )
371
+ out.append((phase, g, strain, score, entry))
372
+ return tuple(torch.cat(column) for column in zip(*out, strict=True))
373
+
374
+ def index(self, frames):
375
+ """*frames* ``(N, H, W)`` on the device, indexed: ``(phase, g, score, entry)``.
376
+ *entry* is the dictionary entry the search seeded the refine from (see
377
+ :meth:`search`): anything worked out once per entry, such as a color, stands
378
+ for the refined orientation to within the dictionary's resolution."""
379
+ out = []
380
+ for lo in range(0, len(frames), BATCH):
381
+ search_rows, refine_rows = self.prepare(frames[lo : lo + BATCH])
382
+ phase, g, score, entry = self.search(search_rows)
383
+ if self.refine_steps:
384
+ g, score = self.refine(refine_rows, phase, g)
385
+ out.append((phase, g, score, entry))
386
+ return tuple(torch.cat(column) for column in zip(*out, strict=True))
@@ -0,0 +1,195 @@
1
+ """Background removal for experimental patterns.
2
+
3
+ Static (mean over the scan) and dynamic (Gaussian-blurred per pattern)
4
+ background correction, batched on the GPU.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import torch
10
+ import torch.nn.functional as F
11
+
12
+ from de_ebsd.core.constants import DIVISION_FLOOR, SINGLE_PRECISION_FLOOR
13
+
14
+ __all__ = [
15
+ "remove_static_background",
16
+ "remove_dynamic_background",
17
+ "remove_background",
18
+ ]
19
+
20
+
21
+ def _as_stack(patterns, device, dtype):
22
+ x = torch.as_tensor(patterns, device=device, dtype=dtype)
23
+ if x.ndim == 2:
24
+ lead = ()
25
+ elif x.ndim == 3:
26
+ lead = (x.shape[0],)
27
+ elif x.ndim == 4:
28
+ lead = (x.shape[0], x.shape[1])
29
+ else:
30
+ raise ValueError(
31
+ f"patterns must be (H,W), (N,H,W) or (rows,cols,H,W); got shape {tuple(x.shape)}"
32
+ )
33
+ h, w = x.shape[-2], x.shape[-1]
34
+ stack = x.reshape(-1, h, w)
35
+
36
+ def restore(processed):
37
+ return processed.reshape(*lead, h, w)
38
+
39
+ return stack, restore
40
+
41
+
42
+ def _rescale01(p):
43
+ lo = p.amin(dim=(-2, -1), keepdim=True)
44
+ hi = p.amax(dim=(-2, -1), keepdim=True)
45
+ return (p - lo) / (hi - lo).clamp_min(DIVISION_FLOOR)
46
+
47
+
48
+ def _apply(pattern, background, operation):
49
+ if operation == "subtract":
50
+ return pattern - background
51
+ if operation == "divide":
52
+ return pattern / background.clamp_min(DIVISION_FLOOR)
53
+ raise ValueError(f"operation must be 'subtract' or 'divide', got {operation!r}")
54
+
55
+
56
+ def normalize_rows(x: torch.Tensor) -> torch.Tensor:
57
+ """Each row of *x* shifted to zero mean and scaled to unit length, so a dot product
58
+ of two rows is their normalized cross-correlation."""
59
+ x = x - x.mean(-1, keepdim=True)
60
+ return x / x.norm(dim=-1, keepdim=True).clamp_min(SINGLE_PRECISION_FLOOR)
61
+
62
+
63
+ def _gaussian_kernel_1d(sigma, truncate, device, dtype):
64
+ radius = int(truncate * float(sigma) + 0.5)
65
+ x = torch.arange(-radius, radius + 1, device=device, dtype=dtype)
66
+ k = torch.exp(-0.5 * (x / float(sigma)) ** 2)
67
+ return k / k.sum(), radius
68
+
69
+
70
+ def gaussian_blur(stack, sigma, truncate):
71
+ k, r = _gaussian_kernel_1d(sigma, truncate, stack.device, stack.dtype)
72
+ x = stack[:, None] # (N, 1, H, W)
73
+ x = F.conv2d(F.pad(x, (r, r, 0, 0), mode="reflect"), k.view(1, 1, 1, -1))
74
+ x = F.conv2d(F.pad(x, (0, 0, r, r), mode="reflect"), k.view(1, 1, -1, 1))
75
+ return x[:, 0]
76
+
77
+
78
+ def remove_static_background(
79
+ patterns, static_bg, operation="subtract", device=None, dtype=torch.float32
80
+ ):
81
+ """Remove the fixed detector/beam background from a stack of patterns.
82
+
83
+ Parameters
84
+ ----------
85
+ patterns
86
+ Detector images, ``(H, W)`` / ``(N, H, W)`` / ``(rows, cols, H, W)``, numpy or
87
+ torch. ``H``, ``W`` are the detector shape.
88
+ static_bg
89
+ The static background image ``(H, W)`` (e.g. a kikuchipy signal's
90
+ ``static_background``, or the scan mean), numpy or torch.
91
+ operation
92
+ ``"subtract"`` (default) or ``"divide"``.
93
+ device
94
+ Torch device to run on. ``None`` (default) keeps the patterns' device
95
+ (``"cuda"`` recommended for a whole scan).
96
+ dtype
97
+ Working floating dtype (default ``float32``).
98
+
99
+ Returns
100
+ -------
101
+ torch.Tensor
102
+ The cleaned patterns, same leading shape as the input, each rescaled to
103
+ ``[0, 1]`` (indexing is invariant to the per-pattern affine scale).
104
+ """
105
+ if device is None:
106
+ device = torch.as_tensor(patterns).device if torch.is_tensor(patterns) else "cpu"
107
+ stack, restore = _as_stack(patterns, device, dtype)
108
+ bg = torch.as_tensor(static_bg, device=stack.device, dtype=dtype)
109
+ if bg.shape != stack.shape[-2:]:
110
+ raise ValueError(f"static_bg shape {tuple(bg.shape)} != detector {tuple(stack.shape[-2:])}")
111
+ out = _rescale01(_apply(stack, bg[None], operation))
112
+ return restore(out)
113
+
114
+
115
+ def remove_dynamic_background(
116
+ patterns, sigma=None, operation="subtract", truncate=4.0, device=None, dtype=torch.float32
117
+ ):
118
+ """Remove the per-pattern dynamic background (a Gaussian blur of each pattern).
119
+
120
+ Parameters
121
+ ----------
122
+ patterns
123
+ Detector images, ``(H, W)`` / ``(N, H, W)`` / ``(rows, cols, H, W)``, numpy or
124
+ torch.
125
+ sigma
126
+ Standard deviation of the Gaussian window. ``None`` (default) uses
127
+ ``width / 8`` (kikuchipy's default), the width being the number of columns.
128
+ operation
129
+ ``"subtract"`` (default) or ``"divide"``.
130
+ truncate
131
+ Truncate the Gaussian window at this many standard deviations (default ``4.0``,
132
+ matching scipy/kikuchipy).
133
+ device, dtype
134
+ As in :func:`remove_static_background`.
135
+
136
+ Returns
137
+ -------
138
+ torch.Tensor
139
+ The cleaned patterns, same leading shape as the input, each rescaled to
140
+ ``[0, 1]``.
141
+ """
142
+ if device is None:
143
+ device = torch.as_tensor(patterns).device if torch.is_tensor(patterns) else "cpu"
144
+ stack, restore = _as_stack(patterns, device, dtype)
145
+ if sigma is None:
146
+ sigma = stack.shape[-1] / 8.0
147
+ bg = gaussian_blur(stack, sigma, truncate)
148
+ out = _rescale01(_apply(stack, bg, operation))
149
+ return restore(out)
150
+
151
+
152
+ def remove_background(
153
+ patterns,
154
+ static_bg=None,
155
+ dynamic=True,
156
+ operation="subtract",
157
+ sigma=None,
158
+ truncate=4.0,
159
+ device=None,
160
+ dtype=torch.float32,
161
+ ):
162
+ """Full EBSD preprocessing: static then dynamic background removal, on the GPU.
163
+
164
+ Convenience wrapper that runs :func:`remove_static_background` (when ``static_bg``
165
+ is given) followed by :func:`remove_dynamic_background` (when ``dynamic``), the
166
+ usual pre-indexing pipeline, all on ``device`` in one pass over the scan.
167
+
168
+ Parameters
169
+ ----------
170
+ patterns
171
+ The raw scan, ``(H, W)`` / ``(N, H, W)`` / ``(rows, cols, H, W)``, numpy or torch.
172
+ static_bg
173
+ The static background image ``(H, W)``; if ``None``, the static step is skipped.
174
+ dynamic
175
+ Whether to also remove the dynamic (per-pattern Gaussian) background
176
+ (default ``True``).
177
+ operation, sigma, truncate, device, dtype
178
+ Passed through to the two steps (see those functions).
179
+
180
+ Returns
181
+ -------
182
+ torch.Tensor
183
+ The cleaned patterns, same leading shape as the input, rescaled to ``[0, 1]``,
184
+ ready to hand to :meth:`~de_ebsd.index.dictionary.DictionaryIndexer.index`.
185
+ """
186
+ out = patterns
187
+ if static_bg is not None:
188
+ out = remove_static_background(
189
+ out, static_bg, operation=operation, device=device, dtype=dtype
190
+ )
191
+ if dynamic:
192
+ out = remove_dynamic_background(
193
+ out, sigma=sigma, operation=operation, truncate=truncate, device=device, dtype=dtype
194
+ )
195
+ return out
@@ -0,0 +1,7 @@
1
+ """Gradient refinement of orientation and pattern center."""
2
+
3
+ from de_ebsd.index.refine.highres import refine_hr
4
+ from de_ebsd.index.refine.orientation import refine_orientation
5
+ from de_ebsd.index.refine.pattern_center import determine_pattern_center
6
+
7
+ __all__ = ["determine_pattern_center", "refine_hr", "refine_orientation"]