Skip to content

Commit 08b6a32

Browse files
Better macro rendering for point-focused beams
1 parent 3d01cf0 commit 08b6a32

6 files changed

Lines changed: 240 additions & 18 deletions

File tree

1.61 KB
Loading

docs/index.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,8 @@ <h1>Introduction<a class="headerlink" href="#introduction" title="Link to this h
393393
</pre></div>
394394
</div>
395395
</div></blockquote>
396-
<img alt="https://github.com/FABLE-3DXRD/xrd_simulator/blob/main/docs/source/images/diffraction_pattern.png?raw=true" class="align-center" src="https://github.com/FABLE-3DXRD/xrd_simulator/blob/main/docs/source/images/diffraction_pattern.png?raw=true" />
396+
<a class="reference internal image-reference" href="https://github.com/FABLE-3DXRD/xrd_simulator/blob/main/docs/source/images/diffraction_pattern.png?raw=true"><img alt="https://github.com/FABLE-3DXRD/xrd_simulator/blob/main/docs/source/images/diffraction_pattern.png?raw=true" class="align-center" src="https://github.com/FABLE-3DXRD/xrd_simulator/blob/main/docs/source/images/diffraction_pattern.png?raw=true" style="width: 95%;" />
397+
</a>
397398
<p>To compute several frames simply change the motion and collect the diffraction again. The sample may be moved before
398399
each computation using the same or another motion.</p>
399400
<blockquote>

tests/test_detector.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33

44
import numpy as np
55
import torch
6+
from scipy.ndimage import label
67
from scipy.spatial import ConvexHull
8+
from scipy.spatial.transform import Rotation
79

10+
from xrd_simulator import polycrystal
811
from xrd_simulator.beam import Beam
912
from xrd_simulator.detector import Detector
1013
from xrd_simulator.mesh import TetraMesh
@@ -704,6 +707,107 @@ def test_lorentz_infinity_handling_nano(self):
704707
msg="Pattern should be empty when all peaks have infinite Lorentz",
705708
)
706709

710+
def test_macro_scanning_3dxrd_setup(self):
711+
712+
radius = 100.0
713+
mesh = TetraMesh.generate_mesh_from_levelset(
714+
level_set=lambda x: np.linalg.norm(x) - radius,
715+
bounding_radius=radius + 5.0,
716+
max_cell_circumradius=50.0,
717+
)
718+
719+
mesh.translate(-mesh.ecentroids.mean(axis=0))
720+
data = os.path.join(
721+
os.path.join(os.path.dirname(__file__), "data"),
722+
"Fe_mp-150_conventional_standard.cif",
723+
)
724+
unit_cell = [3.64570000, 3.64570000, 3.64570000, 90.0, 90.0, 90.0]
725+
sgname = "Fm-3m"
726+
phase = Phase(unit_cell, sgname, path_to_cif_file=data)
727+
728+
detector_distance = 98932.41043969788
729+
pixel_size = 75 # microns
730+
detector_size = 2048 * pixel_size
731+
energy = 55.5 # kev
732+
wavelength = 12.3984198742 / energy # angstrom
733+
734+
phase._setup_diffracting_planes(wavelength, 0, 20 * torch.pi / 180)
735+
736+
pc = polycrystal.Polycrystal(
737+
mesh=mesh,
738+
orientation=Rotation.random(mesh.number_of_elements).as_matrix(),
739+
strain=np.zeros((3, 3)),
740+
phases=[Phase(unit_cell, sgname, path_to_cif_file=None)],
741+
element_phase_map=np.zeros((mesh.number_of_elements,)).astype(int),
742+
)
743+
744+
det_corner_0 = np.array(
745+
[detector_distance, -detector_size / 2.0, -detector_size / 2.0]
746+
)
747+
det_corner_1 = np.array(
748+
[detector_distance, detector_size / 2.0, -detector_size / 2.0]
749+
)
750+
det_corner_2 = np.array(
751+
[detector_distance, -detector_size / 2.0, detector_size / 2.0]
752+
)
753+
754+
beam_y = 2.0 # um
755+
t = beam_y / 2.0
756+
dz = beam_y / 2.0
757+
beam_vertices = np.array(
758+
[
759+
[-detector_distance, -t, -dz],
760+
[-detector_distance, t, -dz],
761+
[-detector_distance, t, dz],
762+
[-detector_distance, -t, dz],
763+
[detector_distance, -t, -dz],
764+
[detector_distance, t, -dz],
765+
[detector_distance, t, dz],
766+
[detector_distance, -t, dz],
767+
]
768+
)
769+
xray_propagation_direction = np.array([1, 0, 0]) * 2 * np.pi / wavelength
770+
polarization_vector = np.array([0, 1, 0])
771+
xray_beam = Beam(
772+
beam_vertices, xray_propagation_direction, wavelength, polarization_vector
773+
)
774+
rotation_angle = np.radians(20.0)
775+
rotation_axis = np.array([0, 0, 1])
776+
single_frame_motion = RigidBodyMotion(
777+
rotation_axis,
778+
rotation_angle,
779+
translation=np.array([0, 0, 0]),
780+
)
781+
782+
eiger = Detector(
783+
det_corner_0,
784+
det_corner_1,
785+
det_corner_2,
786+
pixel_size=(pixel_size, pixel_size),
787+
gaussian_sigma=0.4,
788+
kernel_threshold=0.001,
789+
max_gaussian_kernel_radius=9,
790+
)
791+
peaks = pc.diffract(
792+
xray_beam, single_frame_motion, detector=eiger, verbose=True
793+
)
794+
diffraction_pattern, peaks = eiger.render(
795+
peaks,
796+
frames_to_render=1,
797+
method="macro",
798+
)
799+
800+
im = diffraction_pattern[0].numpy()
801+
im[np.isnan(im)] = 0
802+
803+
self.assertGreaterEqual(im.max(), 0, msg="Diffraction pattern is empty")
804+
805+
_, num_labels = label(im)
806+
807+
self.assertGreaterEqual(
808+
num_labels, 20, msg="There appears to be very few diffraction spots"
809+
)
810+
707811

708812
if __name__ == "__main__":
709813
unittest.main()

tests/test_utils.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,37 @@
11
import unittest
22
import warnings
3+
34
import numpy as np
5+
from scipy.spatial import ConvexHull
6+
from scipy.spatial.transform import Rotation
47
from xfab import tools
8+
59
from xrd_simulator import utils
6-
from scipy.spatial.transform import Rotation
710

11+
rng = np.random.default_rng(0)
812

9-
class TestUtils(unittest.TestCase):
1013

14+
class TestUtils(unittest.TestCase):
1115
def setUp(self):
1216
np.random.seed(10) # changes all randomisation in the test
1317

18+
def test_sample_convex_hull_3d(self):
19+
hull = ConvexHull(rng.random((100, 3)))
20+
points = hull.points
21+
r = points.mean(axis=0)
22+
tris = points[hull.simplices]
23+
a = tris[:, 0] - r
24+
b = tris[:, 1] - r
25+
c = tris[:, 2] - r
26+
vols = np.abs(np.einsum("ij,ij->i", a, np.cross(b, c))) / 6.0
27+
cents = (r + tris[:, 0] + tris[:, 1] + tris[:, 2]) / 4.0
28+
cents = np.sum(cents * vols[:, None], axis=0) / vols.sum()
29+
X = utils._sample_convex_hull_3d(hull, 200000)
30+
A = hull.equations[:, :-1]
31+
b = hull.equations[:, -1]
32+
assert np.all(X @ A.T + b <= 1e-10)
33+
assert np.allclose(X.mean(axis=0), cents, atol=1e-2)
34+
1435
def test_clip_line_with_convex_polyhedron(self):
1536
line_points = np.ascontiguousarray([[-1.0, 0.2, 0.2], [-1.0, 0.4, 0.6]])
1637
line_direction = np.ascontiguousarray([1.0, 0.0, 0.0])
@@ -149,7 +170,7 @@ def test_get_misorientations(self):
149170

150171
def test_diffractogram_deprecated(self):
151172
"""Test that _diffractogram raises a deprecation warning.
152-
173+
153174
.. deprecated::
154175
This test verifies that _diffractogram is properly marked as deprecated.
155176
The function will be removed in a future version.
@@ -171,7 +192,7 @@ def test_diffractogram_deprecated(self):
171192
# Check that deprecation warning was raised
172193
self.assertTrue(
173194
any(issubclass(warning.category, DeprecationWarning) for warning in w),
174-
msg="_diffractogram should raise DeprecationWarning"
195+
msg="_diffractogram should raise DeprecationWarning",
175196
)
176197

177198
# Verify function still works correctly (for backward compatibility)
@@ -186,23 +207,23 @@ def test_diffractogram_deprecated(self):
186207

187208
def test_contained_by_intervals_deprecated(self):
188209
"""Test that _contained_by_intervals raises a deprecation warning.
189-
210+
190211
.. deprecated::
191212
This test verifies that _contained_by_intervals is properly marked as deprecated.
192213
The function will be removed in a future version.
193214
"""
194215
intervals = [[0.0, 0.5], [0.7, 1.0]]
195-
216+
196217
# Verify deprecation warning is raised
197218
with warnings.catch_warnings(record=True) as w:
198219
warnings.simplefilter("always")
199220
result = utils._contained_by_intervals(0.3, intervals)
200221
# Check that deprecation warning was raised
201222
self.assertTrue(
202223
any(issubclass(warning.category, DeprecationWarning) for warning in w),
203-
msg="_contained_by_intervals should raise DeprecationWarning"
224+
msg="_contained_by_intervals should raise DeprecationWarning",
204225
)
205-
226+
206227
# Verify function still works correctly (for backward compatibility)
207228
self.assertTrue(result, msg="0.3 should be contained in [0.0, 0.5]")
208229

xrd_simulator/detector.py

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1591,6 +1591,13 @@ def _project_convex_hull(
15911591
) -> npt.NDArray:
15921592
"""Project convex hull onto detector region.
15931593
1594+
Swithces between a sample driven and detector driven approcimation depending on
1595+
the detector pixel size and the convex hull size. When the convex hull is large
1596+
compared to the pixel size, the detector driven approach is used. This means that
1597+
rays traced from pixel centroids are used as projection lines. When the convex
1598+
hull is small compared to the pixel size, the sample driven approach is used. This
1599+
means that points are sampled from the interior of the hull and used as projection lines.
1600+
15941601
Parameters
15951602
----------
15961603
proj_context : dict
@@ -1605,12 +1612,10 @@ def _project_convex_hull(
16051612
Returns
16061613
-------
16071614
torch.Tensor
1608-
Array of clip lengths (path lengths) through the hull for each
1609-
detector ray.
1615+
Array of projected fractional contribution to each detector pixel
1616+
in the box. The sum total contribution is proportional to the
1617+
volume of the convex hull.
16101618
"""
1611-
# Get pixel coordinates - keep in torch
1612-
pixel_coords = self.pixel_coordinates[box[0] : box[1], box[2] : box[3], :]
1613-
ray_points = pixel_coords.reshape((box[1] - box[0]) * (box[3] - box[2]), 3)
16141619

16151620
# ConvexHull from scipy returns numpy, convert to torch
16161621
hull = proj_context["convex_hull"]
@@ -1620,14 +1625,62 @@ def _project_convex_hull(
16201625
)
16211626
plane_points = -plane_offsets * plane_normals
16221627

1628+
n_pixels_in_box = (box[1] - box[0]) * (box[3] - box[2])
1629+
16231630
# Ray direction in torch
16241631
scattered_vec = proj_context["scattered_wave_vector"]
16251632
ray_direction = scattered_vec / torch.linalg.norm(scattered_vec)
16261633

1627-
clip_lengths = utils._clip_line_with_convex_polyhedron(
1628-
ray_points, ray_direction, plane_points, plane_normals
1629-
)
1630-
clip_lengths = clip_lengths.reshape(box[1] - box[0], box[3] - box[2])
1634+
if n_pixels_in_box == 1:
1635+
# tetra is fully contained in a detector pixel, use the volume of the tetra as intensity
1636+
return torch.ones(1, 1) * proj_context["convex_hull"].volume
1637+
elif (
1638+
n_pixels_in_box <= 4
1639+
): # tetra is small compared to the pixel size, adapt to sampling approach.
1640+
# This is a decent approximation when the tetras are small in relation to the pixel size
1641+
n_samples = (
1642+
7 * n_pixels_in_box
1643+
) # TODO the ad-hoc number 7 here should perhaps be a parameter to give controll over the accuracy of the statistics.
1644+
ray_points = utils._sample_convex_hull_3d(
1645+
proj_context["convex_hull"], n_samples
1646+
)
1647+
ray_points.reshape(n_samples, 3)
1648+
ray_points = ensure_torch(ray_points)
1649+
1650+
clip_lengths = utils._clip_line_with_convex_polyhedron(
1651+
ray_points, ray_direction, plane_points, plane_normals
1652+
)
1653+
# now map the clip lengths to the detector pixels
1654+
ray_directions = torch.tile(ray_direction, (n_samples, 1))
1655+
out = self._get_intersection(ray_directions, ray_points)
1656+
zd = out[:, 0]
1657+
yd = out[:, 1]
1658+
1659+
row_index = ensure_numpy(zd / self.pixel_size_z).astype(int) - box[0]
1660+
col_index = ensure_numpy(yd / self.pixel_size_y).astype(int) - box[2]
1661+
1662+
box_intensities = np.zeros((box[1] - box[0], box[3] - box[2]))
1663+
np.add.at(box_intensities, (row_index, col_index), clip_lengths)
1664+
box_intensities /= box_intensities.sum()
1665+
box_intensities *= proj_context["convex_hull"].volume
1666+
1667+
return ensure_torch(box_intensities)
1668+
1669+
else:
1670+
# This is a good approximation when the tetras are large in relation to the pixel size
1671+
# Get pixel coordinates and project on pixel centorid ray-lines
1672+
pixel_coords = self.pixel_coordinates[box[0] : box[1], box[2] : box[3], :]
1673+
ray_points = pixel_coords.reshape((box[1] - box[0]) * (box[3] - box[2]), 3)
1674+
1675+
clip_lengths = utils._clip_line_with_convex_polyhedron(
1676+
ray_points, ray_direction, plane_points, plane_normals
1677+
)
1678+
1679+
# ensure that scattering is proportional to the material volume
1680+
# that is scattering.
1681+
clip_lengths /= clip_lengths.sum()
1682+
clip_lengths *= proj_context["convex_hull"].volume
1683+
clip_lengths = clip_lengths.reshape(box[1] - box[0], box[3] - box[2])
16311684

16321685
return clip_lengths
16331686

xrd_simulator/utils.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@
8383

8484
from xrd_simulator.cuda import get_selected_device
8585

86+
rng = np.random.default_rng()
87+
8688

8789
def _cif_open(cif_file):
8890
"""Helper function to be able to use the ``.CIFread`` of ``xfab``."""
@@ -122,6 +124,47 @@ def _print_progress(progress_fraction, message):
122124
print("", flush=True)
123125

124126

127+
def _sample_convex_hull_3d(hull, n_samples):
128+
"""Uniformly randomly sample points from the interior of a convex hull.
129+
130+
Parameters
131+
----------
132+
hull : scipy.spatial.ConvexHull
133+
Convex hull to sample from.
134+
n_samples : int
135+
Number of samples to draw.
136+
137+
Returns
138+
-------
139+
numpy.ndarray
140+
Sampled points, shape ``(n_samples, 3)``.
141+
"""
142+
143+
# Split the convex hull into tetrahedra
144+
points = hull.points
145+
r = points.mean(axis=0)
146+
tris = points[hull.simplices]
147+
tets = np.empty((len(tris), 4, 3), dtype=points.dtype)
148+
tets[:, 0] = r
149+
tets[:, 1:] = tris
150+
a = tets[:, 1] - tets[:, 0]
151+
b = tets[:, 2] - tets[:, 0]
152+
c = tets[:, 3] - tets[:, 0]
153+
154+
# Compute the volumes of the tetrahedra and randomly select tets
155+
# based on volume weights.
156+
vols = np.abs(np.einsum("ij,ij->i", a, np.cross(b, c))) / 6.0
157+
idx = rng.choice(len(tets), size=n_samples, p=vols / vols.sum())
158+
159+
# Now use barycentric coordinates to uniformly sample points from
160+
# the selected tetrahedra
161+
w = rng.exponential(size=(n_samples, 4))
162+
w /= w.sum(axis=1, keepdims=True)
163+
random_points = np.einsum("ni,nij->nj", w, tets[idx])
164+
165+
return random_points
166+
167+
125168
def _clip_line_with_convex_polyhedron(
126169
line_points, line_direction, plane_points, plane_normals
127170
):

0 commit comments

Comments
 (0)