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,479 @@
1
+ """High-resolution refinement on band-edge samples.
2
+
3
+ Instead of a whole back-projected direction set, ``refine_hr`` importance-samples
4
+ a few thousand high-gradient band-edge pixels -- the ones that actually carry the
5
+ orientation and pattern-center signal -- and runs Levenberg-Marquardt on those.
6
+ Refines orientation and pattern center jointly.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+ import torch
13
+ import torch.nn.functional as F
14
+
15
+ from de_ebsd.core.constants import DIVISION_FLOOR
16
+ from de_ebsd.core.device import resolve_device
17
+ from de_ebsd.core.geometry import detector_rays, sample_to_detector_tensor
18
+ from de_ebsd.core.rotation import quaternion_to_matrix, rotation_vector_to_matrix
19
+ from de_ebsd.index.refine.levenberg import adapt_damping
20
+
21
+ __all__ = ["direction_cosines_from_pattern_center", "edge_importance_samples", "refine_hr"]
22
+
23
+
24
+ def direction_cosines_from_pattern_center(
25
+ detector, pattern_center: torch.Tensor, rows: torch.Tensor, cols: torch.Tensor
26
+ ) -> torch.Tensor:
27
+ """Sample-frame direction cosines at pixels ``(row, col)``, differentiable in
28
+ ``pattern_center``.
29
+
30
+ A ``grid_sample``-free rewrite of
31
+ :meth:`~de_ebsd.sim.detector.EBSDDetector.direction_cosines` over an arbitrary
32
+ pixel set, as pure torch arithmetic -- so it works under forward-mode AD, unlike
33
+ the ``grid_sample`` back-projection. The detector-frame ray is
34
+
35
+ ``x_d = aspect * ((col + 0.5) / ncols - pcx)``,
36
+ ``y_d = pcy - (row + 0.5) / nrows``,
37
+ ``z_d = pcz``,
38
+
39
+ rotated into the sample frame and normalized. Reproduces
40
+ ``EBSDDetector.direction_cosines`` to ~1e-16.
41
+
42
+ ``pattern_center`` is ``(..., 3)``, overriding ``detector.pattern_center``, and broadcasts
43
+ against the
44
+ leading dims of the ``(..., K)`` fractional ``rows``/``cols``. Returns unit
45
+ direction cosines ``(..., K, 3)``.
46
+ """
47
+ om = sample_to_detector_tensor(detector, rows.device, rows.dtype)
48
+ ray = detector_rays(detector, pattern_center, rows, cols) # (..., K, 3)
49
+ dc = ray @ om # sample-frame ray (rows: v @ om, matching direction_cosines)
50
+ return dc / dc.norm(dim=-1, keepdim=True).clamp_min(DIVISION_FLOOR)
51
+
52
+
53
+ def direction_cosine_derivative(
54
+ detector, pattern_center: torch.Tensor, rows: torch.Tensor, cols: torch.Tensor
55
+ ) -> torch.Tensor:
56
+ p = pattern_center.detach()
57
+
58
+ def f(pc_arg: torch.Tensor) -> torch.Tensor:
59
+ return direction_cosines_from_pattern_center(detector, pc_arg, rows, cols)
60
+
61
+ cols_ddc = []
62
+ for c in range(3):
63
+ tangent = torch.zeros_like(p)
64
+ tangent[..., c] = 1.0
65
+ _, dd = torch.func.jvp(f, (p,), (tangent,))
66
+ cols_ddc.append(dd)
67
+ return torch.stack(cols_ddc, dim=-1) # (..., K, 3, 3)
68
+
69
+
70
+ def _sobel_gradient_magnitude(patterns: torch.Tensor) -> torch.Tensor:
71
+ kx = patterns.new_tensor([[-1.0, 0.0, 1.0], [-2.0, 0.0, 2.0], [-1.0, 0.0, 1.0]])
72
+ ky = kx.t().contiguous()
73
+ p = F.pad(patterns[:, None], (1, 1, 1, 1), mode="replicate")
74
+ gx = F.conv2d(p, kx[None, None])
75
+ gy = F.conv2d(p, ky[None, None])
76
+ return torch.sqrt(gx * gx + gy * gy)[:, 0]
77
+
78
+
79
+ def edge_importance_samples(
80
+ patterns: torch.Tensor,
81
+ n_tiles: int = 16,
82
+ samples_per_tile: int = 8,
83
+ all_pixels: bool = False,
84
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
85
+ """Pick the band-edge sample pixels for each pattern (done once, up front).
86
+
87
+ Partitions the detector into ``n_tiles x n_tiles`` tiles and keeps the
88
+ ``samples_per_tile`` highest-Sobel-gradient pixels in each. Spreading the pick
89
+ across tiles keeps every region represented, and the tile grouping drives the
90
+ per-tile normalization of the objective. ``all_pixels`` ignores the gradient
91
+ pick and uses every pixel (the slow reference path).
92
+
93
+ Returns ``(rows, cols, flat_idx, tile_ids, counts)``: fractional coordinates
94
+ ``(Np, K)``, flat indices ``(Np, K)``, the tile index ``(K,)`` of each sample
95
+ slot (shared across patterns), and the slot count per tile. A tile with fewer
96
+ pixels than ``samples_per_tile`` is padded by repeating its best pixel, so ``K``
97
+ stays uniform.
98
+ """
99
+ np_, height, width = patterns.shape
100
+ device, dtype = patterns.device, patterns.dtype
101
+ n_total = n_tiles * n_tiles
102
+
103
+ row_tile = (torch.arange(height, device=device) * n_tiles // height).clamp_max(n_tiles - 1)
104
+ col_tile = (torch.arange(width, device=device) * n_tiles // width).clamp_max(n_tiles - 1)
105
+ tile_map = (row_tile[:, None] * n_tiles + col_tile[None, :]).reshape(-1) # (H*W,)
106
+
107
+ if all_pixels:
108
+ flat = torch.arange(height * width, device=device)
109
+ tile_ids = tile_map
110
+ counts = torch.bincount(tile_ids, minlength=n_total)
111
+ flat = flat[None].expand(np_, -1).contiguous()
112
+ else:
113
+ grad = _sobel_gradient_magnitude(patterns).reshape(np_, height * width)
114
+ th, tw = height // n_tiles, width // n_tiles
115
+ # regular blocks: one topk over (Np, n_total, tile_pixels) instead of a
116
+ # per-tile Python loop, whose nonzero/topk dominate the whole refine
117
+ if height % n_tiles == 0 and width % n_tiles == 0 and th * tw >= samples_per_tile:
118
+ gt = (
119
+ grad.view(np_, n_tiles, th, n_tiles, tw)
120
+ .permute(0, 1, 3, 2, 4)
121
+ .reshape(np_, n_total, th * tw)
122
+ )
123
+ top = gt.topk(samples_per_tile, dim=2).indices # (Np, n_total, spt), within-tile
124
+ # within-tile slot (r * tw + c) -> global flat pixel ((a*th+r)*W + b*tw+c)
125
+ a = torch.arange(n_tiles, device=device)[:, None, None, None]
126
+ b = torch.arange(n_tiles, device=device)[None, :, None, None]
127
+ r = torch.arange(th, device=device)[None, None, :, None]
128
+ c = torch.arange(tw, device=device)[None, None, None, :]
129
+ tile_to_flat = ((a * th + r) * width + (b * tw + c)).reshape(n_total, th * tw)
130
+ flat = torch.gather(tile_to_flat[None].expand(np_, -1, -1), 2, top)
131
+ flat = flat.reshape(np_, n_total * samples_per_tile) # (Np, K)
132
+ tile_ids = torch.arange(n_total, device=device).repeat_interleave(samples_per_tile)
133
+ counts = torch.bincount(tile_ids, minlength=n_total)
134
+ else: # irregular tiles: per-tile fallback (correct, rarely hit)
135
+ flat_cols, tid_cols = [], []
136
+ for t in range(n_total):
137
+ pix = torch.nonzero(tile_map == t, as_tuple=False).reshape(-1)
138
+ if pix.numel() == 0:
139
+ continue
140
+ k = min(samples_per_tile, pix.numel())
141
+ top = grad[:, pix].topk(k, dim=1).indices # (Np, k) into pix
142
+ sel = pix[top] # (Np, k) flat pixel indices
143
+ if k < samples_per_tile: # pad short tiles by repeating their best pixel
144
+ sel = torch.cat([sel, sel[:, :1].expand(np_, samples_per_tile - k)], dim=1)
145
+ flat_cols.append(sel)
146
+ tid_cols.append(torch.full((samples_per_tile,), t, device=device, dtype=torch.long))
147
+ flat = torch.cat(flat_cols, dim=1) # (Np, K)
148
+ tile_ids = torch.cat(tid_cols) # (K,)
149
+ counts = torch.bincount(tile_ids, minlength=n_total)
150
+
151
+ rows = (flat // width).to(dtype)
152
+ cols = (flat % width).to(dtype)
153
+ return rows, cols, flat, tile_ids, counts
154
+
155
+
156
+ def _seg_broadcast_sum(x: torch.Tensor, tile_ids: torch.Tensor, n_total: int) -> torch.Tensor:
157
+ sums = x.new_zeros((x.shape[0], n_total, *x.shape[2:]))
158
+ sums.index_add_(1, tile_ids, x)
159
+ return sums.index_select(1, tile_ids)
160
+
161
+
162
+ def standardize_tiled(
163
+ x: torch.Tensor, tile_ids: torch.Tensor, counts_ps: torch.Tensor, n_total: int
164
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
165
+ mean = _seg_broadcast_sum(x, tile_ids, n_total) / counts_ps
166
+ c = x - mean
167
+ s = _seg_broadcast_sum(c * c, tile_ids, n_total).sqrt().clamp_min(DIVISION_FLOOR)
168
+ return c, s, c / s
169
+
170
+
171
+ def normalized_residual_jacobian(
172
+ m: torch.Tensor,
173
+ e_hat: torch.Tensor,
174
+ g: torch.Tensor,
175
+ tile_ids: torch.Tensor,
176
+ counts_ps: torch.Tensor,
177
+ n_total: int,
178
+ ) -> tuple[torch.Tensor, torch.Tensor]:
179
+ c, s, m_hat = standardize_tiled(m, tile_ids, counts_ps, n_total)
180
+ g_mean = _seg_broadcast_sum(g, tile_ids, n_total) / counts_ps.view(1, -1, 1)
181
+ nu = _seg_broadcast_sum(c.unsqueeze(-1) * g, tile_ids, n_total) / s.unsqueeze(-1)
182
+ dm_hat = ((g - g_mean) - m_hat.unsqueeze(-1) * nu) / s.unsqueeze(-1)
183
+ return e_hat - m_hat, -dm_hat
184
+
185
+
186
+ def levenberg_marquardt_refine(
187
+ master,
188
+ detector,
189
+ rows,
190
+ cols,
191
+ e_raw,
192
+ tile_ids,
193
+ counts,
194
+ r0_mat,
195
+ initial_pattern_center,
196
+ refine_pattern_center,
197
+ n_steps,
198
+ damping,
199
+ keep_best,
200
+ device,
201
+ dtype,
202
+ ):
203
+ n_total = int(counts.shape[0])
204
+ n_active = int((counts > 0).sum())
205
+ counts_ps = counts.index_select(0, tile_ids).to(dtype) # (K,)
206
+ _, _, e_hat = standardize_tiled(e_raw, tile_ids, counts_ps, n_total)
207
+
208
+ n_p = r0_mat.shape[0]
209
+ r_mat = r0_mat.clone()
210
+ pattern_center = initial_pattern_center.clone()
211
+ damping = torch.full((n_p,), float(damping), device=device, dtype=dtype)
212
+
213
+ # orientation-only: the pattern center never moves, so build its direction
214
+ # cosines once instead of rebuilding them every step
215
+ dc_fixed = (
216
+ None
217
+ if refine_pattern_center
218
+ else direction_cosines_from_pattern_center(
219
+ detector, initial_pattern_center, rows, cols
220
+ ).detach()
221
+ )
222
+
223
+ def _ncc(r_cur, pc_cur):
224
+ dc = (
225
+ dc_fixed
226
+ if dc_fixed is not None
227
+ else direction_cosines_from_pattern_center(detector, pc_cur, rows, cols)
228
+ )
229
+ m = master.sample(dc @ r_cur.transpose(-1, -2)).to(device=device, dtype=dtype)
230
+ _, _, m_hat = standardize_tiled(m, tile_ids, counts_ps, n_total)
231
+ return (e_hat * m_hat).sum(-1) / n_active
232
+
233
+ seed_score = _ncc(r0_mat, initial_pattern_center).detach()
234
+ cur_score = seed_score.clone()
235
+
236
+ for _ in range(n_steps):
237
+ # (Np, K, 3); constant when the pattern center is fixed, so reuse the precompute
238
+ dc = (
239
+ dc_fixed
240
+ if dc_fixed is not None
241
+ else direction_cosines_from_pattern_center(
242
+ detector, pattern_center, rows, cols
243
+ ).detach()
244
+ )
245
+ # one reverse-mode grid_sample pass -> per-point master gradient dM/dv
246
+ with torch.enable_grad():
247
+ v = (dc @ r_mat.transpose(-1, -2)).detach().requires_grad_(True)
248
+ m_raw = master.sample(v)
249
+ (g_m,) = torch.autograd.grad(m_raw.sum(), v)
250
+ m = m_raw.detach().to(device=device, dtype=dtype)
251
+ g_m = g_m.to(device=device, dtype=dtype)
252
+
253
+ q = torch.einsum("pka,pab->pkb", g_m, r_mat) # gM @ R -> (Np, K, 3)
254
+ dm_dw = torch.linalg.cross(dc, q, dim=-1) # dc x (gM @ R), (Np, K, 3)
255
+ if refine_pattern_center:
256
+ ddc_dpc = direction_cosine_derivative(
257
+ detector, pattern_center, rows, cols
258
+ ) # (Np, K, 3, 3)
259
+ dm_dpc = torch.einsum("pkb,pkbc->pkc", q, ddc_dpc) # (Np, K, 3)
260
+ g_raw = torch.cat([dm_dw, dm_dpc], dim=-1) # (Np, K, 6)
261
+ else:
262
+ g_raw = dm_dw
263
+
264
+ res, j_r = normalized_residual_jacobian(m, e_hat, g_raw, tile_ids, counts_ps, n_total)
265
+ with torch.no_grad():
266
+ jtr = torch.einsum("pk,pki->pi", res, j_r) # (Np, P)
267
+ jtj = torch.einsum("pki,pkj->pij", j_r, j_r) # (Np, P, P)
268
+ diag = torch.diagonal(jtj, dim1=-2, dim2=-1).clamp_min(1e-9)
269
+ a = jtj + damping[:, None, None] * torch.diag_embed(diag)
270
+ delta = -torch.linalg.solve(a, jtr[..., None])[..., 0] # (Np, P)
271
+ r_new = r_mat @ rotation_vector_to_matrix(delta[:, :3])
272
+ pc_new = pattern_center + delta[:, 3:6] if refine_pattern_center else pattern_center
273
+ new_score = _ncc(r_new, pc_new)
274
+ # per-pattern accept/reject: robust to the bilinear grid_sample kinks,
275
+ # and monotone by construction
276
+ better = new_score > cur_score
277
+ r_mat = torch.where(better[:, None, None], r_new, r_mat)
278
+ pattern_center = torch.where(better[:, None], pc_new, pattern_center)
279
+ damping = adapt_damping(damping, better)
280
+ cur_score = torch.where(better, new_score, cur_score)
281
+
282
+ if keep_best:
283
+ keep = (cur_score >= seed_score).reshape(-1)
284
+ r_mat = torch.where(keep[:, None, None], r_mat, r0_mat)
285
+ pattern_center = torch.where(keep[:, None], pattern_center, initial_pattern_center)
286
+ cur_score = torch.maximum(cur_score, seed_score)
287
+ return r_mat, pattern_center, cur_score
288
+
289
+
290
+ def refine_hr(
291
+ patterns,
292
+ master,
293
+ detector,
294
+ rotations,
295
+ pattern_center=None,
296
+ refine_pattern_center=False,
297
+ n_tiles=16,
298
+ samples_per_tile=8,
299
+ n_steps=15,
300
+ damping=1e-3,
301
+ keep_best=True,
302
+ device=None,
303
+ all_pixels=False,
304
+ batch_size=None,
305
+ sample_by="master",
306
+ ):
307
+ """High-precision joint orientation + pattern-center refinement.
308
+
309
+ The sub-0.1-degree-capable path. Rather than touching every detector pixel, it
310
+ importance-samples a few thousand band-edge points spread across a tile grid and
311
+ runs batched Levenberg-Marquardt on ``(R, pattern_center)`` against the continuous master,
312
+ over an objective normalized per tile (robust to intensity ramps). The sample
313
+ budget (``n_tiles``, ``samples_per_tile``) is the accuracy/speed knob.
314
+
315
+ Parameters
316
+ ----------
317
+ patterns
318
+ A detector image ``(H, W)`` or stack ``(Np, H, W)``. The raw sampled values
319
+ are used -- the experimental signal is never back-projected or interpolated.
320
+ master, detector
321
+ The continuous :class:`de_ebsd.sim.MasterPattern` and the
322
+ :class:`~de_ebsd.sim.detector.EBSDDetector` geometry.
323
+ rotations
324
+ Seed :class:`orix.quaternion.Rotation`, typically a coarse index result.
325
+ pattern_center
326
+ ``None`` uses ``detector.pattern_center``. A ``(3,)`` value shares one center across the
327
+ stack; ``(Np, 3)`` gives a per-pattern center. Returned in the same shape.
328
+ refine_pattern_center
329
+ Refine the pattern center jointly (6 DOF) and return it, or (default) leave
330
+ it untouched and refine orientation only (3 DOF).
331
+ n_tiles, samples_per_tile
332
+ Tiles per detector side and samples kept per tile; the defaults give
333
+ ``256 * 8 = 2048`` samples. Raising them improves accuracy proportionally.
334
+ n_steps
335
+ Levenberg-Marquardt steps (default 15); second-order, so a coarse seed
336
+ sharpens well inside this budget.
337
+ damping
338
+ Initial damping, adapted per pattern each step (x0.5 accepted, x3 rejected).
339
+ keep_best
340
+ Never return a ``(R, pattern_center)`` scoring below the seed, making refinement
341
+ monotone.
342
+ device
343
+ Defaults to ``master.data.device``; otherwise everything is moved there.
344
+ all_pixels
345
+ Use every detector pixel instead of the band-edge subsample (slow reference
346
+ path for validation).
347
+ batch_size
348
+ Patterns per mini-batch. ``None`` picks a memory-aware size; the result is
349
+ identical for any batch size.
350
+ sample_by
351
+ Which gradient picks the sample pixels. ``"master"`` (default) renders the
352
+ simulated pattern at the seed and samples its noise-free gradient, so
353
+ samples land on true band edges. ``"experimental"`` samples the raw pattern,
354
+ which on real data is noise-dominated and adds substantial refinement
355
+ scatter; it is retained for noise-free/synthetic use.
356
+
357
+ Returns
358
+ -------
359
+ rotation : orix.quaternion.Rotation
360
+ The refined orientation(s), in the convention ``MasterPattern.project``
361
+ consumes.
362
+ score : torch.Tensor
363
+ Mean per-tile NCC per pattern, in ``[-1, 1]``; ``>=`` the seed when
364
+ ``keep_best``.
365
+ pattern_center : torch.Tensor
366
+ Only when ``refine_pattern_center``: the refined pattern center, shaped like the input.
367
+ """
368
+ device = resolve_device(device, master.data.device)
369
+ dtype = master.data.dtype
370
+ if master.data.device != device:
371
+ master = master.to(device)
372
+
373
+ patt = torch.as_tensor(patterns, device=device, dtype=dtype)
374
+ single = patt.ndim == 2
375
+ if single:
376
+ patt = patt[None]
377
+ if patt.ndim != 3:
378
+ raise ValueError(f"patterns must be (H, W) or (Np, H, W), got shape {tuple(patt.shape)}")
379
+ n_p = patt.shape[0]
380
+
381
+ q0 = torch.as_tensor(np.asarray(rotations.data), device=device, dtype=dtype).reshape(-1, 4)
382
+ if q0.shape[0] != n_p:
383
+ raise ValueError(f"rotations length {q0.shape[0]} != number of patterns {n_p}")
384
+ r0_mat = quaternion_to_matrix(q0).detach() # (Np, 3, 3)
385
+
386
+ if pattern_center is None:
387
+ pc_init = torch.as_tensor(detector.pattern_center, device=device, dtype=dtype)
388
+ else:
389
+ pc_init = torch.as_tensor(pattern_center, device=device, dtype=dtype)
390
+ if pc_init.ndim == 1 and pc_init.shape[0] == 3:
391
+ pc_shared = True
392
+ initial_pattern_center = pc_init[None].expand(n_p, 3).contiguous()
393
+ elif pc_init.ndim == 2 and pc_init.shape == (n_p, 3):
394
+ pc_shared = False
395
+ initial_pattern_center = pc_init.contiguous()
396
+ else:
397
+ raise ValueError(
398
+ f"pattern_center must be (3,) shared or (Np, 3) per-pattern (Np={n_p}), got "
399
+ f"{tuple(pc_init.shape)}"
400
+ )
401
+
402
+ # size the mini-batch from the dominant per-step working set -- a (bs, K, 3, 3)
403
+ # d dc/d pattern_center block with pattern-center refinement, (bs, K, 3) without -- to ~256 MB
404
+ k = int(n_tiles * n_tiles * samples_per_tile) if not all_pixels else patt[0].numel()
405
+ if batch_size is not None:
406
+ bs = int(batch_size)
407
+ else:
408
+ per = k * (
409
+ 36 if refine_pattern_center else 12
410
+ ) # bytes/pattern of the dominant intermediate
411
+ if sample_by == "master":
412
+ # the seed render also holds a (bs, H*W, 3) direction-cosine block
413
+ per = max(per, int(detector.nrows) * int(detector.ncols) * 12)
414
+ bs = int(max(1, min(n_p, 256_000_000 // max(per, 1))))
415
+
416
+ # full pixel grid, for rendering the seed pattern that picks the sample pixels
417
+ if sample_by == "master":
418
+ hh, ww = int(detector.nrows), int(detector.ncols)
419
+ rr, cc = torch.meshgrid(
420
+ torch.arange(hh, device=device, dtype=dtype),
421
+ torch.arange(ww, device=device, dtype=dtype),
422
+ indexing="ij",
423
+ )
424
+ full_rows, full_cols = rr.reshape(-1), cc.reshape(-1)
425
+ elif sample_by != "experimental":
426
+ raise ValueError(f"sample_by must be 'master' or 'experimental', got {sample_by!r}")
427
+
428
+ r_parts, pc_parts, score_parts = [], [], []
429
+ for i in range(0, n_p, bs):
430
+ sl = slice(i, i + bs)
431
+ p_chunk = patt[sl]
432
+ if sample_by == "master":
433
+ # the master is noise-free, so its gradient marks true band edges; the
434
+ # experimental gradient adds ~0.5 deg of scatter on real patterns
435
+ with torch.no_grad():
436
+ dc_full = direction_cosines_from_pattern_center(
437
+ detector, initial_pattern_center[sl], full_rows, full_cols
438
+ )
439
+ sim = master.sample(dc_full @ r0_mat[sl].transpose(-1, -2))
440
+ sim = sim.to(device=device, dtype=dtype).reshape(-1, hh, ww)
441
+ rows, cols, flat, tile_ids, counts = edge_importance_samples(
442
+ sim, n_tiles, samples_per_tile, all_pixels
443
+ )
444
+ else:
445
+ rows, cols, flat, tile_ids, counts = edge_importance_samples(
446
+ p_chunk, n_tiles, samples_per_tile, all_pixels
447
+ )
448
+ e_raw = p_chunk.reshape(p_chunk.shape[0], -1).gather(1, flat.long())
449
+ rf, pf, sc = levenberg_marquardt_refine(
450
+ master,
451
+ detector,
452
+ rows,
453
+ cols,
454
+ e_raw,
455
+ tile_ids,
456
+ counts,
457
+ r0_mat[sl],
458
+ initial_pattern_center[sl],
459
+ refine_pattern_center,
460
+ n_steps,
461
+ damping,
462
+ keep_best,
463
+ device,
464
+ dtype,
465
+ )
466
+ r_parts.append(rf)
467
+ pc_parts.append(pf)
468
+ score_parts.append(sc)
469
+ r_final = torch.cat(r_parts)
470
+ pc_final = torch.cat(pc_parts)
471
+ score = torch.cat(score_parts)
472
+
473
+ from orix.quaternion import Rotation
474
+
475
+ rotation = Rotation.from_matrix(r_final.cpu().numpy())
476
+ if not refine_pattern_center:
477
+ return rotation, score
478
+ pc_out = pc_final.mean(0) if pc_shared else pc_final
479
+ return rotation, score, pc_out.detach()
@@ -0,0 +1,33 @@
1
+ """The Levenberg-Marquardt damping schedule shared by the refiners.
2
+
3
+ A step that improves the score is accepted and the damping eased toward
4
+ Gauss-Newton; a step that does not is rejected and the damping raised toward
5
+ steepest descent.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import torch
11
+
12
+ __all__ = ["DAMPING_CEILING", "DAMPING_FLOOR", "adapt_damping", "adapt_shared_damping"]
13
+
14
+ DAMPING_FLOOR = 1e-6
15
+ DAMPING_CEILING = 1e2
16
+ _EASE = 0.5
17
+ _RAISE = 3.0
18
+
19
+
20
+ def adapt_damping(damping: torch.Tensor, accepted: torch.Tensor) -> torch.Tensor:
21
+ """Per-pattern damping ``(N,)`` after a step that was *accepted* ``(N,)`` or not."""
22
+ return torch.where(
23
+ accepted,
24
+ (damping * _EASE).clamp_min(DAMPING_FLOOR),
25
+ (damping * _RAISE).clamp_max(DAMPING_CEILING),
26
+ )
27
+
28
+
29
+ def adapt_shared_damping(damping: float, accepted: bool) -> float:
30
+ """The damping of one shared fit after a step that was *accepted* or not."""
31
+ if accepted:
32
+ return max(damping * _EASE, DAMPING_FLOOR)
33
+ return min(damping * _RAISE, DAMPING_CEILING)