Skip to content

Commit 6be366c

Browse files
committed
Add option to have spot centers off pixel centers
1 parent 275750c commit 6be366c

File tree

4 files changed

+356
-19
lines changed

4 files changed

+356
-19
lines changed

diffsims/pattern/detector_functions.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
# You should have received a copy of the GNU General Public License
1717
# along with diffsims. If not, see <http://www.gnu.org/licenses/>.
1818

19+
from typing import Tuple
20+
1921
import numpy as np
22+
from numba import jit
2023

2124
from numpy.random import default_rng
2225
from scipy import ndimage as ndi
@@ -31,6 +34,7 @@
3134
"add_shot_noise",
3235
"add_shot_and_point_spread",
3336
"constrain_to_dynamic_range",
37+
"get_pattern_from_pixel_coordinates_and_intensities",
3438
]
3539

3640

@@ -243,3 +247,114 @@ def add_detector_offset(pattern, offset):
243247
"""
244248
pattern = np.add(pattern, offset)
245249
return constrain_to_dynamic_range(pattern)
250+
251+
252+
def get_pattern_from_pixel_coordinates_and_intensities(
253+
coordinates: np.ndarray,
254+
intensities: np.ndarray,
255+
shape: Tuple[int, int],
256+
sigma: float,
257+
clip_threshold: float = 1,
258+
) -> np.ndarray:
259+
"""Generate a diffraction pattern from spot pixel-coordinates and intensities,
260+
using a gaussian blur.
261+
This is subpixel-precise, meaning the coordinates can be floats.
262+
Values less than `clip_threshold` are rounded down to 0 to simplify computation.
263+
264+
Parameters
265+
----------
266+
coordinates : np.ndarray
267+
Coordinates of reflections, in pixels. Shape (n, 2) or (n, 3). Can be floats
268+
intensities : np.ndarray
269+
Intensities of each reflection. Must have same same first dimension as `coordinates`
270+
shape : tuple[int, int]
271+
Output shape
272+
sigma : float
273+
For Gaussian blur
274+
intensity_scale : float
275+
Scale to multiply the final diffraction pattern with
276+
277+
Returns
278+
-------
279+
np.ndarray
280+
dtype int
281+
282+
Notes
283+
-----
284+
Not all values below the clipping threshold are ignored.
285+
The threshold is used to estimate a radius (box) around each reflection where the pixel intensity is greater than the threshold.
286+
As the radius is rounded up and as the box is square rather than circular, some values below the threshold can be included.
287+
288+
When using float coordinates, the intensity is spread as if the edge was not there.
289+
This is in line with what should be expected from a beam on the edge of the detector, as part of the beam is simply outside the detector area.
290+
However, when using integer coordinates, the total intensity is preserved for the pixels in the pattern.
291+
This means that the intensity contribution from parts of the beam which would hit outside the detector are now kept in the pattern.
292+
Thus, reflections wich are partially outside the detector will have higher intensities than expected, when using integer coordinates.
293+
"""
294+
if np.issubdtype(coordinates.dtype, np.integer):
295+
# Much simpler with integer coordinates
296+
coordinates = coordinates.astype(int)
297+
out = np.zeros(shape)
298+
# coordinates are xy(z), out array indices are yx.
299+
out[coordinates[:, 1], coordinates[:, 0]] = intensities
300+
out = add_shot_and_point_spread(out, sigma, shot_noise=False)
301+
return out
302+
303+
# coordinates of each pixel in the output, such that the final axis is yx coordinates
304+
inds = np.transpose(np.indices(shape), (1, 2, 0))
305+
return _subpixel_gaussian(
306+
coordinates,
307+
intensities,
308+
inds,
309+
shape,
310+
sigma,
311+
clip_threshold,
312+
)
313+
314+
315+
@jit(
316+
nopython=True
317+
) # Not parallel, we might get a race condition with overlapping spots
318+
def _subpixel_gaussian(
319+
coordinates: np.ndarray,
320+
intensities: np.ndarray,
321+
inds: np.ndarray,
322+
shape: Tuple[int, int],
323+
sigma: float,
324+
clip_threshold: float = 1,
325+
) -> np.ndarray:
326+
out = np.zeros(shape)
327+
328+
# Pre-calculate the constants
329+
prefactor = 1 / (2 * np.pi * sigma**2)
330+
exp_prefactor = -1 / (2 * sigma**2)
331+
332+
for i in range(intensities.size):
333+
# Reverse since coords are xy, but indices are yx
334+
coord = coordinates[i][:2][::-1]
335+
intens = intensities[i]
336+
337+
# The gaussian is expensive to evaluate for all pixels and spots.
338+
# Therefore, we limit the calculations to a box around each reflection where the intensity is above a threshold.
339+
# Formula found by inverting the gaussian
340+
radius = np.sqrt(np.log(clip_threshold / (prefactor * intens)) / exp_prefactor)
341+
342+
if np.isnan(radius):
343+
continue
344+
slic = (
345+
slice(
346+
max(0, int(np.ceil(coord[0] - radius))),
347+
min(shape[0], int(np.floor(coord[0] + radius + 1))),
348+
),
349+
slice(
350+
max(0, int(np.ceil(coord[1] - radius))),
351+
min(shape[1], int(np.floor(coord[1] + radius + 1))),
352+
),
353+
)
354+
# Calculate the values of the Gaussian manually
355+
out[slic] += (
356+
intens
357+
* prefactor
358+
* np.exp(exp_prefactor * np.sum((inds[slic] - coord) ** 2, axis=-1))
359+
)
360+
return out

diffsims/simulations/simulation2d.py

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
# You should have received a copy of the GNU General Public License
1717
# along with diffsims. If not, see <http://www.gnu.org/licenses/>.
1818

19-
from typing import Union, Sequence, TYPE_CHECKING, Any
19+
from typing import Union, Sequence, Tuple, TYPE_CHECKING, Any
2020
import copy
2121

2222
import numpy as np
@@ -27,7 +27,9 @@
2727
from orix.vector import Vector3d
2828

2929
from diffsims.crystallography._diffracting_vector import DiffractingVector
30-
from diffsims.pattern.detector_functions import add_shot_and_point_spread
30+
from diffsims.pattern.detector_functions import (
31+
get_pattern_from_pixel_coordinates_and_intensities,
32+
)
3133

3234
# to avoid circular imports
3335
if TYPE_CHECKING: # pragma: no cover
@@ -343,12 +345,15 @@ def polar_flatten_simulations(self, radial_axes=None, azimuthal_axes=None):
343345

344346
def get_diffraction_pattern(
345347
self,
346-
shape=None,
347-
sigma=10,
348-
direct_beam_position=None,
349-
in_plane_angle=0,
350-
calibration=0.01,
351-
mirrored=False,
348+
shape: Tuple[int, int] = None,
349+
sigma: float = 10,
350+
direct_beam_position: Tuple[int, int] = None,
351+
in_plane_angle: float = 0,
352+
calibration: float = 0.01,
353+
mirrored: bool = False,
354+
fast: bool = True,
355+
normalize: bool = True,
356+
fast_clip_threshold: float = 1,
352357
):
353358
"""Returns the diffraction data as a numpy array with
354359
two-dimensional Gaussians representing each diffracted peak. Should only
@@ -368,11 +373,19 @@ def get_diffraction_pattern(
368373
mirrored: bool, optional
369374
Whether the pattern should be flipped over the x-axis,
370375
corresponding to the inverted orientation
371-
376+
fast: bool, optional
377+
Whether to speed up calculations by rounding spot coordinates down to integer pixel
378+
normalize: bool, optional
379+
Whether to normalize the pattern to values between 0 and 1
380+
fast_clip_threshold: float, optional
381+
Only used when `fast` is False.
382+
Pixel intensity threshold, such that pixels which would be below this value are ignored.
383+
Thresholding performed before possible normalization.
384+
See diffsims.pattern.detector_functions.get_pattern_from_pixel_coordinates_and_intensities for details.
372385
Returns
373386
-------
374387
diffraction-pattern : numpy.array
375-
The simulated electron diffraction pattern, normalised.
388+
The simulated electron diffraction pattern, normalized by default.
376389
377390
Notes
378391
-----
@@ -381,7 +394,13 @@ def get_diffraction_pattern(
381394
the order of 0.5nm and the default size and sigma are used.
382395
"""
383396
if direct_beam_position is None:
384-
direct_beam_position = (shape[1] // 2, shape[0] // 2)
397+
# Use subpixel-precise center if possible
398+
if fast or np.issubdtype(
399+
self.get_current_coordinates().data.dtype, np.integer
400+
):
401+
direct_beam_position = (shape[1] // 2, shape[0] // 2)
402+
else:
403+
direct_beam_position = ((shape[1] - 1) / 2, (shape[0] - 1) / 2)
385404
transformed = self._get_transformed_coordinates(
386405
in_plane_angle,
387406
direct_beam_position,
@@ -395,17 +414,21 @@ def get_diffraction_pattern(
395414
& (transformed.data[:, 1] >= 0)
396415
& (transformed.data[:, 1] < shape[0])
397416
)
398-
spot_coords = transformed.data[in_frame].astype(int)
399-
417+
spot_coords = transformed.data[in_frame]
418+
if fast:
419+
spot_coords = spot_coords.astype(int)
400420
spot_intens = transformed.intensity[in_frame]
401-
pattern = np.zeros(shape)
421+
402422
# checks that we have some spots
403423
if spot_intens.shape[0] == 0:
404-
return pattern
405-
else:
406-
pattern[spot_coords[:, 0], spot_coords[:, 1]] = spot_intens
407-
pattern = add_shot_and_point_spread(pattern.T, sigma, shot_noise=False)
408-
return np.divide(pattern, np.max(pattern))
424+
return np.zeros(shape)
425+
426+
pattern = get_pattern_from_pixel_coordinates_and_intensities(
427+
spot_coords, spot_intens, shape, sigma, fast_clip_threshold
428+
)
429+
if normalize:
430+
pattern = np.divide(pattern, np.max(pattern))
431+
return pattern
409432

410433
@property
411434
def num_phases(self):

0 commit comments

Comments
 (0)