Skip to content

feat(ebsd): Wave 3 — GPU dictionary indexing + pattern preprocessing - #83

Open
CSSFrancis wants to merge 3 commits into
feat/0.3.0-wave0-wave1from
feat/0.3.0-wave3-ebsd
Open

feat(ebsd): Wave 3 — GPU dictionary indexing + pattern preprocessing#83
CSSFrancis wants to merge 3 commits into
feat/0.3.0-wave0-wave1from
feat/0.3.0-wave3-ebsd

Conversation

@CSSFrancis

Copy link
Copy Markdown
Owner

Closes #70, #71. Part of tracker #48.

Stacked on #82 (needs the synthetic EBSD data) — review that one first, base retargets to main once it lands.

The idea

NCC between an experimental and a simulated pattern is, once both are zero-mean and unit-norm, exactly a dot product. So indexing P patterns against D dictionary entries is one matmul plus a top-k — the operation a GPU is built for.

The (P, D) similarity must never exist (65k × 100k float32 = 26 GB), so both axes are tiled with a running top-k: each tile is reduced against the best-so-far and discarded. A test asserts tiled == untiled, or results would quietly depend on chunk size.

Background removal is not cosmetic

Measured here: NCC is invariant to gain and offset but not to a spatial gradient. The detector background is in every experimental pattern and no simulated one, so it dilutes every score.

best-match score
raw ~0.92
background removed (both sides) > 0.99

And it must be applied to both sides — correcting only the experimental patterns leaves the dictionary carrying low-frequency content its counterpart no longer has, which is a different mismatch rather than a fix. kikuchipy preprocesses its dictionary for the same reason.

Correctness comes from ground truth, not a fixture

The synthetic EBSD data stamps the exact Euler angles it was generated from, so the tests assert:

  • indexing recovers the true orientation for every position;
  • the two grains index to disjoint dictionary entries — a blank map would pass every score check;
  • a uniform grain indexes consistently (one entry for all its positions);
  • a finer dictionary matches monotonically better (15° → 10° → 5°). That relationship is the real property; an absolute threshold would just encode whatever this synthetic crystal happens to produce.

One real bug, caught by its own test

The shape guard compared pixel counts after reshaping the experimental stack to the dictionary's pixel count — which silently succeeds whenever the sizes share a factor. A 40×40 scan against a 20×20 dictionary reshaped to 4× as many "patterns" and indexed garbage instead of raising.

Scope note

Nothing here needs the ebsd extra — indexing is plain torch + numpy, so it develops and tests without kikuchipy. Only the IO/geometry/master-pattern layer (#69) will, and that is requires_package-gated.

sample_orientations is a deliberately simple Euler grid, not equal-area SO(3) — enough to develop and test indexing; #69 wires up orix/kikuchipy's correct sampling.

17 tests, all green. Still open in this wave: #69 (IO + detector geometry), #72 (refinement), #73 (CrystalMap → existing IPF display).

The reason Wave 3 exists. Normalised cross-correlation between an experimental
and a simulated pattern is, once both are zero-mean and unit-norm, EXACTLY a
dot product — so indexing P patterns against D dictionary entries is one matmul
plus a top-k, which is the operation a GPU is built for.

The (P, D) similarity must never exist (65k x 100k float32 = 26 GB), so both
axes are tiled with a RUNNING top-k: each tile is reduced against the
best-so-far and discarded. A test asserts tiled == untiled, because otherwise
results would quietly depend on chunk size.

preprocess.py adds static/dynamic background removal and the average
dot-product map, all batched over the whole scan.

Why background removal is not cosmetic, measured here: NCC is invariant to gain
and offset but NOT to a spatial GRADIENT. The detector background is in every
experimental pattern and in no simulated one, so it dilutes every score. An
exact-orientation match scores ~0.92 with it left in and >0.99 once removed —
and the correction must be applied to BOTH sides, or the dictionary keeps
low-frequency content its counterpart no longer has.

Correctness comes from ground truth, not from a fixture: the synthetic EBSD
data stamps the exact Euler angles it was generated from, so the tests assert
that indexing recovers them, that the two grains index to disjoint entries
(a blank map would pass every score check), that a uniform grain indexes
consistently, and that a finer dictionary matches monotonically better.

One real bug caught by its own test: the shape guard compared pixel counts
AFTER reshaping the experimental stack to the dictionary's pixel count, which
silently succeeds whenever the sizes share a factor — a 40x40 scan against a
20x20 dictionary reshaped to 4x as many "patterns" and indexed garbage rather
than raising.

Nothing here needs the ebsd extra; indexing is plain torch + numpy. Only the
IO/geometry/master-pattern layer (#69) will, and that is requires_package-gated.
Dictionary indexing can only ever return a dictionary ENTRY, so its accuracy is
capped by the sampling step — a 5-degree dictionary gives 5-degree answers.
Refinement lifts that cap by optimising each orientation continuously from its
indexed match.

Same shape of problem as actions/vector_orientation_gpu.py, solved the same
way: the whole field at once. Every pattern's three Euler angles are one row of
a (P, 3) tensor, the simulated patterns are one batched forward pass, and one
Adam optimiser walks all P orientations together. No dask, no per-pattern loop.

Two things carried over from the vector-orientation work rather than
rediscovered (CLAUDE.md, GPU Computing):

- backward() segfaults off the main thread under CUDA on Windows, so the loop
  runs INLINE with an on_yield callback instead of being pushed to a worker.
- Yield INSIDE the step loop, not between stages, or a UI freezes for seconds
  while the progress bar looks stuck. A test asserts on_yield fires during the
  optimisation, not just around it.

Refinement never returns a WORSE orientation than it was given: it is an
improvement step on top of indexing, so if Adam wanders off (bad start, too
large an lr) the indexed answer is still the better estimate and is what comes
back. Tested by driving it to diverge on purpose with lr=5.

The simulator is injectable and the built-in one renders the same band geometry
as the synthetic data — asserted equal to the numpy generator, since refining
against a DIFFERENT forward model than the one that made the data would be
fitting the wrong thing. #69 swaps in kikuchipy master patterns without
touching the optimiser.

Orientations are compared by the PATTERNS they produce, never by Euler
distance: Euler angles are not a metric space and cubic symmetry means
different triples describe the same crystal.
The display half of Wave 3, and it is nearly free by design. SpyDE already
depends on orix and already has IPF views, orientation maps and a 3D IPF
toolbar from the 4D-STEM work, so the job is to hand the EBSD result over in
the form that machinery already speaks — not to build a second orientation
display beside the first. ipf_colors returns (H, W, 3) RGB, which
commit_result_tree already accepts as an RGB primary.

orientation_similarity_map is the one piece with non-obvious semantics, and it
earns its place: it measures how much each position's RANKED match list agrees
with its neighbours', which exposes indexing that is confidently WRONG. A raw
score map cannot — a pattern that matched something strongly scores just as
highly whether or not the answer is right. Tested on exactly that case: one
position indexed to a completely different entry from its neighbourhood reads
0.0 while the rest of the map reads > 0.9.

merge_phases combines per-phase runs by score, since multi-phase indexing runs
one dictionary per phase.

This commit is COMPUTE ONLY. Putting these on screen is UI work and gets
verified by running the app and looking at pixels (CLAUDE.md), not by these
tests. The closest headless proxy is asserted instead: index the synthetic
scan, colour it, and require the two grains to come out distinguishable —
identical colours would mean the map renders as a flat block and would still
pass every range check.

Two small fixes while writing the tests:

- merge_phases validated coverage AFTER np.stack had already raised "all input
  arrays must have the same shape", which says nothing about which phase
  disagreed. Validate first, and name the sizes.
- numpy's assert_allclose no longer broadcasts shapes, so comparing (N, 3)
  against row 0 fails on shape even when every row is identical.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant