Skip to content

Commit 293f75d

Browse files
authored
Merge pull request #235 from viljarjf/accurate-diffraction-spot-positions
Add option to have spot centers off pixel centers
2 parents 347f7d4 + 0bc5c2a commit 293f75d

File tree

6 files changed

+363
-22
lines changed

6 files changed

+363
-22
lines changed

CHANGELOG.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ Unreleased
1313
Added
1414
-----
1515
- Progressbar for SimulationGenerator.calculate_diffraction2d in (#233)
16-
16+
- Added ``fast`` parameter to ``Simulation2D.get_diffraction_pattern`` which allows
17+
for sub pixel sampling of the diffraction pattern. This is useful for simulating
18+
an accurate diffraction pattern. (#235)
1719
Changed
1820
-------
1921

@@ -25,6 +27,8 @@ Removed
2527

2628
Fixed
2729
-----
30+
- Fixed bug in masking logic for ``Simulation2D.polar_flatten`` in (#238)
31+
- Fixed a bug in 1D diffraction patterns for non-cubic lattices. (#238)
2832

2933
2024-10-11 - version 0.6.1
3034
==========================

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: # pragma: no cover
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
@@ -354,12 +356,15 @@ def polar_flatten_simulations(self, radial_axes=None, azimuthal_axes=None):
354356

355357
def get_diffraction_pattern(
356358
self,
357-
shape=None,
358-
sigma=10,
359-
direct_beam_position=None,
360-
in_plane_angle=0,
361-
calibration=0.01,
362-
mirrored=False,
359+
shape: Tuple[int, int] = None,
360+
sigma: float = 10,
361+
direct_beam_position: Tuple[int, int] = None,
362+
in_plane_angle: float = 0,
363+
calibration: float = 0.01,
364+
mirrored: bool = False,
365+
fast: bool = True,
366+
normalize: bool = True,
367+
clip_threshold: float = 1,
363368
):
364369
"""Returns the diffraction data as a numpy array with
365370
two-dimensional Gaussians representing each diffracted peak. Should only
@@ -379,11 +384,19 @@ def get_diffraction_pattern(
379384
mirrored: bool, optional
380385
Whether the pattern should be flipped over the x-axis,
381386
corresponding to the inverted orientation
382-
387+
fast: bool, optional
388+
Whether to speed up calculations by rounding spot coordinates down to integer pixel
389+
normalize: bool, optional
390+
Whether to normalize the pattern to values between 0 and 1
391+
clip_threshold: float, optional
392+
Only used when `fast` is False.
393+
Pixel intensity threshold, such that pixels which would be below this value are ignored.
394+
Thresholding performed before possible normalization.
395+
See diffsims.pattern.detector_functions.get_pattern_from_pixel_coordinates_and_intensities for details.
383396
Returns
384397
-------
385398
diffraction-pattern : numpy.array
386-
The simulated electron diffraction pattern, normalised.
399+
The simulated electron diffraction pattern, normalized by default.
387400
388401
Notes
389402
-----
@@ -392,7 +405,13 @@ def get_diffraction_pattern(
392405
the order of 0.5nm and the default size and sigma are used.
393406
"""
394407
if direct_beam_position is None:
395-
direct_beam_position = (shape[1] // 2, shape[0] // 2)
408+
# Use subpixel-precise center if possible
409+
if fast or np.issubdtype(
410+
self.get_current_coordinates().data.dtype, np.integer
411+
):
412+
direct_beam_position = (shape[1] // 2, shape[0] // 2)
413+
else:
414+
direct_beam_position = ((shape[1] - 1) / 2, (shape[0] - 1) / 2)
396415
transformed = self._get_transformed_coordinates(
397416
in_plane_angle,
398417
direct_beam_position,
@@ -406,17 +425,21 @@ def get_diffraction_pattern(
406425
& (transformed.data[:, 1] >= 0)
407426
& (transformed.data[:, 1] < shape[0])
408427
)
409-
spot_coords = transformed.data[in_frame].astype(int)
410-
428+
spot_coords = transformed.data[in_frame]
429+
if fast:
430+
spot_coords = spot_coords.astype(int)
411431
spot_intens = transformed.intensity[in_frame]
412-
pattern = np.zeros(shape)
432+
413433
# checks that we have some spots
414434
if spot_intens.shape[0] == 0:
415-
return pattern
416-
else:
417-
pattern[spot_coords[:, 0], spot_coords[:, 1]] = spot_intens
418-
pattern = add_shot_and_point_spread(pattern.T, sigma, shot_noise=False)
419-
return np.divide(pattern, np.max(pattern))
435+
return np.zeros(shape)
436+
437+
pattern = get_pattern_from_pixel_coordinates_and_intensities(
438+
spot_coords, spot_intens, shape, sigma, clip_threshold
439+
)
440+
if normalize:
441+
pattern = np.divide(pattern, np.max(pattern))
442+
return pattern
420443

421444
@property
422445
def num_phases(self):

0 commit comments

Comments
 (0)