Skip to content

Commit 5ab416b

Browse files
committed
add standalone bragg-disk detection module
Generalized Bragg-disk detection (matched-filter cross-correlation + multicorr DFT-upsampled subpixel) shared across diffraction/stack widgets. Pure widget-side reimplementation; no py4DSTEM/abtem imports. Adds tests/test_bragg_module.py.
1 parent 0e8747a commit 5ab416b

2 files changed

Lines changed: 451 additions & 0 deletions

File tree

src/quantem/widget/bragg.py

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
"""Generalized Bragg-disk detection for diffraction patterns.
2+
3+
Shared by diffraction/stack widgets (Show3D; Show4DSTEM should
4+
import this in the future to drop its duplicated copy)
5+
"""
6+
7+
import numpy as np
8+
import torch
9+
10+
from quantem.core.utils.imaging_utils import upsampled_correlation_torch
11+
12+
13+
def detect_bragg_disks_single(
14+
dp: np.ndarray,
15+
probe_kernel: np.ndarray,
16+
*,
17+
corr_power: float = 1.0,
18+
sigma: float = 2.0,
19+
edge_boundary: int = 1,
20+
min_relative_intensity: float = 0.005,
21+
min_absolute_intensity: float = 0.0,
22+
min_peak_spacing: float = 4.0,
23+
max_num_peaks: int = 70,
24+
subpixel: str = "multicorr",
25+
upsample_factor: int = 4,
26+
) -> np.ndarray:
27+
"""Detect Bragg disks in a single diffraction pattern.
28+
29+
Cross-correlates ``dp`` with ``probe_kernel`` (origin-aligned vacuum probe),
30+
finds local maxima, filters them, and refines to sub-pixel precision.
31+
32+
Parameters
33+
----------
34+
dp : (n_rows, n_cols) array
35+
Diffraction pattern (float32, non-negative).
36+
probe_kernel : (n_rows, n_cols) array
37+
Origin-aligned vacuum-probe template (peak at (0, 0)); see
38+
:func:`vacuum_probe_kernel`. Must match ``dp`` shape.
39+
corr_power : float
40+
Correlation power: 1 = cross-correlation, 0 = phase, between = hybrid.
41+
sigma : float
42+
Gaussian smoothing of the correlogram before maxima (0 disables).
43+
edge_boundary : int
44+
Reject peaks within this many pixels of the edge.
45+
min_relative_intensity, min_absolute_intensity : float
46+
Drop peaks below this fraction of the brightest / this absolute value.
47+
min_peak_spacing : float
48+
Minimum peak separation in pixels (greedy NMS).
49+
max_num_peaks : int
50+
Keep at most this many peaks (intensity-sorted, descending).
51+
subpixel : str
52+
``"pixel"``, ``"poly"`` (3x3 parabolic), or ``"multicorr"`` (DFT upsample).
53+
upsample_factor : int
54+
Multicorr upsampling factor (>= 2).
55+
56+
Returns
57+
-------
58+
peaks : (N, 3) float32 array
59+
``[row, col, intensity]`` rows, sorted by intensity descending.
60+
"""
61+
from scipy.ndimage import gaussian_filter, maximum_filter
62+
63+
if subpixel not in {"pixel", "poly", "multicorr"}:
64+
raise ValueError(
65+
f"subpixel must be 'pixel', 'poly', or 'multicorr', got {subpixel!r}"
66+
)
67+
68+
dp = np.asarray(dp, dtype=np.float32)
69+
template = np.asarray(probe_kernel, dtype=np.float32)
70+
if dp.shape != template.shape:
71+
raise ValueError(
72+
f"dp shape {dp.shape} must match probe_kernel shape {template.shape}"
73+
)
74+
75+
# (1) FFTs
76+
dp_ft = np.fft.fft2(dp)
77+
template_ft_conj = np.conj(np.fft.fft2(template))
78+
79+
# (2) cross-power spectrum (hybrid correlation when corr_power != 1)
80+
m = dp_ft * template_ft_conj
81+
if corr_power != 1.0:
82+
# |m|^p * exp(i*angle(m)) — preserves phase, modulates magnitude
83+
cc_ft = (np.abs(m) ** float(corr_power)) * np.exp(1j * np.angle(m))
84+
else:
85+
cc_ft = m
86+
cc = np.maximum(np.real(np.fft.ifft2(cc_ft)), 0.0).astype(np.float32)
87+
88+
# (3) Gaussian blur of correlogram
89+
if sigma > 0:
90+
cc_smooth = gaussian_filter(cc, float(sigma)).astype(np.float32)
91+
else:
92+
cc_smooth = cc
93+
94+
# (4) local maxima detection
95+
footprint_size = max(3, int(round(float(min_peak_spacing))))
96+
if footprint_size % 2 == 0:
97+
footprint_size += 1
98+
filtered = maximum_filter(cc_smooth, size=footprint_size, mode="constant", cval=0.0)
99+
maxima = (cc_smooth == filtered) & (cc_smooth > 0)
100+
101+
# edge boundary
102+
eb = max(1, int(edge_boundary))
103+
if eb < maxima.shape[0] and eb < maxima.shape[1]:
104+
maxima[:eb, :] = False
105+
maxima[-eb:, :] = False
106+
maxima[:, :eb] = False
107+
maxima[:, -eb:] = False
108+
109+
rows, cols = np.nonzero(maxima)
110+
if rows.size == 0:
111+
return np.zeros((0, 3), dtype=np.float32)
112+
intensities = cc_smooth[rows, cols]
113+
114+
# sort by intensity (descending)
115+
order = np.argsort(-intensities)
116+
rows = rows[order]
117+
cols = cols[order]
118+
intensities = intensities[order]
119+
120+
# (5) filtering
121+
if min_absolute_intensity > 0:
122+
keep = intensities >= float(min_absolute_intensity)
123+
rows, cols, intensities = rows[keep], cols[keep], intensities[keep]
124+
if intensities.size and min_relative_intensity > 0:
125+
peak_max = float(intensities[0])
126+
if peak_max > 0:
127+
keep = intensities >= float(min_relative_intensity) * peak_max
128+
rows, cols, intensities = rows[keep], cols[keep], intensities[keep]
129+
130+
# greedy NMS for minimum spacing
131+
if min_peak_spacing > 0 and rows.size > 1:
132+
spacing_sq = float(min_peak_spacing) ** 2
133+
keep_mask = np.ones(rows.size, dtype=bool)
134+
for i in range(rows.size):
135+
if not keep_mask[i]:
136+
continue
137+
dy = rows[i + 1 :] - rows[i]
138+
dx = cols[i + 1 :] - cols[i]
139+
too_close = (dy * dy + dx * dx) < spacing_sq
140+
keep_mask[i + 1 :] &= ~too_close
141+
rows = rows[keep_mask]
142+
cols = cols[keep_mask]
143+
intensities = intensities[keep_mask]
144+
145+
# cap at max_num_peaks
146+
if int(max_num_peaks) > 0 and rows.size > int(max_num_peaks):
147+
rows = rows[: int(max_num_peaks)]
148+
cols = cols[: int(max_num_peaks)]
149+
intensities = intensities[: int(max_num_peaks)]
150+
151+
if rows.size == 0:
152+
return np.zeros((0, 3), dtype=np.float32)
153+
154+
qy = rows.astype(np.float64)
155+
qx = cols.astype(np.float64)
156+
inten = intensities.astype(np.float64)
157+
158+
# (6) subpixel refinement
159+
if subpixel == "pixel":
160+
pass
161+
elif subpixel == "poly":
162+
n_rows, n_cols = cc_smooth.shape
163+
for i in range(qy.size):
164+
r = int(rows[i])
165+
c = int(cols[i])
166+
if r <= 0 or r >= n_rows - 1 or c <= 0 or c >= n_cols - 1:
167+
continue
168+
center = float(cc_smooth[r, c])
169+
row_minus = float(cc_smooth[r - 1, c])
170+
row_plus = float(cc_smooth[r + 1, c])
171+
col_minus = float(cc_smooth[r, c - 1])
172+
col_plus = float(cc_smooth[r, c + 1])
173+
denom_y = 4.0 * center - 2.0 * row_plus - 2.0 * row_minus
174+
denom_x = 4.0 * center - 2.0 * col_plus - 2.0 * col_minus
175+
dy = (row_plus - row_minus) / denom_y if denom_y != 0 else 0.0
176+
dx = (col_plus - col_minus) / denom_x if denom_x != 0 else 0.0
177+
# clamp to [-0.5, 0.5] to avoid runaway
178+
dy = max(-0.5, min(0.5, dy))
179+
dx = max(-0.5, min(0.5, dx))
180+
qy[i] += dy
181+
qx[i] += dx
182+
else: # multicorr — DFT upsampling via quantem primitive
183+
# upsampled_correlation_torch asserts factor > 2; clamp to 3.
184+
up = max(3, int(upsample_factor))
185+
cc_ft_full = np.fft.fft2(cc) # use the un-smoothed cc for upsampling
186+
cc_ft_torch = torch.from_numpy(cc_ft_full)
187+
for i in range(qy.size):
188+
xy_shift = torch.tensor([float(qy[i]), float(qx[i])], dtype=torch.float64)
189+
try:
190+
refined = upsampled_correlation_torch(cc_ft_torch, up, xy_shift)
191+
qy[i] = float(refined[0].item())
192+
qx[i] = float(refined[1].item())
193+
except Exception:
194+
# fall back to pixel coords if DFT upsample fails (e.g. near edge)
195+
pass
196+
197+
out = np.column_stack([qy, qx, inten]).astype(np.float32)
198+
return out
199+
200+
201+
def vacuum_probe_kernel(
202+
probe_centered: np.ndarray,
203+
center_row: float,
204+
center_col: float,
205+
) -> np.ndarray:
206+
"""Shift a centered vacuum probe to the FFT origin via a Fourier phase ramp.
207+
208+
Moves the probe peak from ``(center_row, center_col)`` to ``(0, 0)`` (FFT
209+
convention, like py4DSTEM's ``probe.kernel``) so correlation peaks land at
210+
the Bragg-disk positions. The phase-ramp shift is sub-pixel-correct, unlike
211+
``np.roll`` which would quantize non-integer centers to half a pixel.
212+
213+
Parameters
214+
----------
215+
probe_centered : (n_rows, n_cols) array
216+
Vacuum probe with its peak at ``(center_row, center_col)``.
217+
center_row, center_col : float
218+
Probe center (may be non-integer).
219+
220+
Returns
221+
-------
222+
kernel : (n_rows, n_cols) float32 array
223+
Origin-aligned probe kernel for matched filtering.
224+
"""
225+
probe_centered = np.asarray(probe_centered, dtype=np.float32)
226+
n_rows, n_cols = probe_centered.shape
227+
center_row = float(center_row)
228+
center_col = float(center_col)
229+
ky = np.fft.fftfreq(n_rows).astype(np.float32)[:, None]
230+
kx = np.fft.fftfreq(n_cols).astype(np.float32)[None, :]
231+
# FFT shift theorem g(y)=f(y+a) <-> G(k)=F(k)*exp(+2j*pi*k*a): peak -> origin.
232+
phase_ramp = np.exp(2j * np.pi * (ky * center_row + kx * center_col))
233+
ft = np.fft.fft2(probe_centered) * phase_ramp
234+
return np.real(np.fft.ifft2(ft)).astype(np.float32)
235+
236+
237+
def build_soft_disk_probe(
238+
n_rows: int,
239+
n_cols: int,
240+
center_row: float,
241+
center_col: float,
242+
radius: float,
243+
soft_edge: float = 2.0,
244+
) -> np.ndarray:
245+
"""Build a soft-edge disk vacuum probe (1 inside, linear roll-off at the rim).
246+
247+
The "user-visible" centered probe; detection shifts it to the origin with
248+
:func:`vacuum_probe_kernel`.
249+
250+
Parameters
251+
----------
252+
n_rows, n_cols : int
253+
Probe shape (match the diffraction pattern).
254+
center_row, center_col : float
255+
Disk center (may be non-integer).
256+
radius : float
257+
Disk radius in pixels.
258+
soft_edge : float
259+
Half-width of the roll-off region in pixels.
260+
"""
261+
rows = np.arange(int(n_rows), dtype=np.float32)[:, None]
262+
cols = np.arange(int(n_cols), dtype=np.float32)[None, :]
263+
center_row = float(center_row)
264+
center_col = float(center_col)
265+
radius = max(float(radius), 1.0)
266+
soft_edge = max(float(soft_edge), 1e-3)
267+
dist = np.sqrt((rows - center_row) ** 2 + (cols - center_col) ** 2)
268+
probe = np.clip((radius + soft_edge - dist) / (2 * soft_edge), 0.0, 1.0)
269+
return probe.astype(np.float32)

0 commit comments

Comments
 (0)