|
| 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