Skip to content

Commit 66e0936

Browse files
authored
Add multisample pin xrc fitting routine (#371)
Add separate fitting routine to handle multi-sample pin xray centring results. First finds continuous regions in the restructured grid, then applies a relative threshold to each sub-region before finding any resulting separated continuous regions.
1 parent 5205eda commit 66e0936

2 files changed

Lines changed: 142 additions & 47 deletions

File tree

src/dlstbx/util/xray_centering_3d.py

Lines changed: 81 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ def gridscan3d(
4848
data: tuple[np.ndarray, ...],
4949
threshold: float = 0.25,
5050
threshold_absolute: float = 0,
51+
threshold_msp: float = 0.25,
52+
threshold_msp_absolute: float = 3,
5153
plot: bool = False,
5254
sample_id: int | None = None,
5355
multipin_sample_ids: dict[int, int] = {},
@@ -56,6 +58,14 @@ def gridscan3d(
5658
"""
5759
3D gridscan analysis from 2 x 2D perpendicular gridscans.
5860
61+
Fitting routine finds each separate region of continuously connected data, filters
62+
out any regions which do not have a voxel with intensity > threshold_absolute. Then,
63+
for each of these regions, a relative threshold of the max voxel intensity for that
64+
region is applied, with the aim to separate/disconnect peaks of intensity within the
65+
region. Each continuously connected sub_region within the region after applying the
66+
threshold is then found and is stored as grid_Scan result. The purpose of this second
67+
step is to try and locate the individual centres of crystals that are close together.
68+
5969
Assumption: X is along the rotation axis, Y, Z are perpendicular
6070
6171
- spot find on images at -45°, +45° -> 2 x 2D array of spot finding metrics
@@ -68,16 +78,21 @@ def gridscan3d(
6878
6979
Args:
7080
data: A tuple of spot counts from 2 orthogonal 2D gridscans
71-
threshold: mask out values less than this fraction of the maximum data value
72-
in the reconstructed 3d grid
73-
threshold_absolute: mask out values less than this absolute value in the
74-
original grids
81+
threshold: Mask out values less than this fraction of the maximum data value
82+
in an identified region of the reconstructed 3d grid. Does not apply to
83+
multi-sample pins.
84+
threshold_absolute: filter out identified regions of continuous signal where the
85+
max value is less than this absolute value.
86+
threshold_msp: Applies only to multi-sample pins. Mask out values less than this
87+
fraction of the maximum data value in each continuous region of the reconstructed
88+
3d grid.
89+
threshold_msp_absolute: Applied instead of threshold_absolute for multi-sample pins.
7590
plot: Show interactive debug plots of the grid scan analysis (default=False)
7691
sample_id: The sample id attributed to the grid_scan. This will usually be the
7792
sample in sublocation 1 in the case of multi-sample pins.
7893
multipin_sample_ids: Sample_ids for all samples on a multi-sample pin.
79-
A dictionary of sample ids with the corresponding sub-locations in the
80-
pin as keys.
94+
A dictionary of sample ids with the corresponding sub-locations in the
95+
pin as keys.
8196
well_limits: The lower and upper limits of the x-coordinate for each well in
8297
the multi-sample pin in units of grid scan boxes.
8398
@@ -88,62 +103,81 @@ def gridscan3d(
88103
assert data[0].ndim == 2
89104
assert data[1].ndim == 2
90105

91-
# mask out grid scans to reduce impact of noisy pixels / edge effects
106+
# Use alternative thresholds for multi-sample pins
107+
if multipin_sample_ids:
108+
threshold = threshold_msp
109+
threshold_absolute = threshold_msp_absolute
110+
111+
# Apply absolute threshold to screen out noise.
92112
data[0][data[0] < threshold_absolute] = 0
93113
data[1][data[1] < threshold_absolute] = 0
94114

95115
reconstructed_3d = data[0][:, :, np.newaxis] * data[1][:, np.newaxis, :]
96116
logger.debug(data[0].shape)
97117
logger.debug(data[1].shape)
98118
logger.debug(reconstructed_3d.shape)
99-
max_idx = tuple(
100-
int(r[0]) for r in np.where(reconstructed_3d == reconstructed_3d.max())
101-
)
102-
max_count = int(reconstructed_3d[max_idx])
103119

104-
thresholded = (reconstructed_3d >= threshold * max_count) * reconstructed_3d
105-
# Count corner-corner contacts as a contiguous region
120+
# Count corner-corner contacts as a continuous region
106121
structure = np.ones((3, 3, 3))
107-
labels, n_regions = scipy.ndimage.label(thresholded, structure=structure)
108-
logger.info(f"Found {n_regions} distinct regions")
122+
# For multi-sample pins first find separate continuous regions in the 3d grid
123+
if multipin_sample_ids:
124+
# Find all continuous regions
125+
labels, n_regions = scipy.ndimage.label(reconstructed_3d, structure=structure)
126+
logger.info(f"Found {n_regions} distinct regions")
109127

110-
object_slices = scipy.ndimage.find_objects(labels)
128+
else:
129+
# For single sample pins, treat the entire grid as a single region
130+
labels = np.ones_like(reconstructed_3d, dtype=int)
131+
n_regions = 1
111132

112133
results: list[GridScan3DResult] = []
113-
for index in range(1, n_regions + 1):
114-
com = tuple(
115-
c + 0.5
116-
for c in scipy.ndimage.center_of_mass(
117-
thresholded, labels=labels, index=index
118-
)
134+
# Loop over each continuous region, apply a relative threshold then find sub-regions
135+
for label in range(1, n_regions + 1):
136+
labelled_data = (labels == label) * reconstructed_3d
137+
# Apply relative threshold to filter out edge effects and to separate out multiple centres in a single region.
138+
thresholded = (labelled_data >= threshold * labelled_data.max()) * labelled_data
139+
sub_labels, n_sub_regions = scipy.ndimage.label(
140+
thresholded, structure=structure
119141
)
120-
max_voxel = tuple(
121-
int(i)
122-
for i in scipy.ndimage.maximum_position(
123-
thresholded, labels=labels, index=index
142+
logger.debug(f"For label {label}, {n_sub_regions} sub regions found")
143+
object_slices = scipy.ndimage.find_objects(sub_labels)
144+
145+
for sub_label in range(1, n_sub_regions + 1):
146+
com = tuple(
147+
c + 0.5
148+
for c in scipy.ndimage.center_of_mass(
149+
thresholded, labels=sub_labels, index=sub_label
150+
)
151+
)
152+
max_voxel = tuple(
153+
int(i)
154+
for i in scipy.ndimage.maximum_position(
155+
thresholded, labels=sub_labels, index=sub_label
156+
)
157+
)
158+
max_count = int(thresholded[max_voxel])
159+
n_voxels = np.count_nonzero(sub_labels == sub_label)
160+
total_count = int(
161+
scipy.ndimage.sum_labels(
162+
thresholded, labels=sub_labels, index=sub_label
163+
)
164+
)
165+
x, y, z = object_slices[sub_label - 1]
166+
bounding_box = ((x.start, y.start, z.start), (x.stop, y.stop, z.stop))
167+
tagged_sample_id = tag_sample_id(
168+
sample_id, multipin_sample_ids, well_limits, com[0]
124169
)
125-
)
126-
max_count = int(thresholded[max_voxel])
127-
n_voxels = np.count_nonzero(labels == index)
128-
total_count = int(
129-
scipy.ndimage.sum_labels(thresholded, labels=labels, index=index)
130-
)
131-
x, y, z = object_slices[index - 1]
132-
bounding_box = ((x.start, y.start, z.start), (x.stop, y.stop, z.stop))
133-
tagged_sample_id = tag_sample_id(
134-
sample_id, multipin_sample_ids, well_limits, com[0]
135-
)
136170

137-
result = GridScan3DResult(
138-
centre_of_mass=com,
139-
max_voxel=max_voxel,
140-
max_count=max_count,
141-
n_voxels=n_voxels,
142-
total_count=total_count,
143-
bounding_box=bounding_box,
144-
sample_id=tagged_sample_id,
145-
)
146-
results.append(result)
171+
result = GridScan3DResult(
172+
centre_of_mass=com,
173+
max_voxel=max_voxel,
174+
max_count=max_count,
175+
n_voxels=n_voxels,
176+
total_count=total_count,
177+
bounding_box=bounding_box,
178+
sample_id=tagged_sample_id,
179+
)
180+
results.append(result)
147181

148182
if plot:
149183
plot_gridscan3d_results(data, reconstructed_3d, results)

tests/util/test_xray_centering_3d.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,64 @@ def test_gridscan3d_with_absolute_threshold():
103103
"bounding_box": ((2, 3, 2), (7, 6, 6)),
104104
"sample_id": 12345,
105105
}
106+
107+
108+
def test_gridscan3d_for_multipin_sample():
109+
# Create simple dataset comprising two 12x1 grids with two real peaks and one noise peak.
110+
ydata = np.array([0, 3, 3, 0, 0, 20, 10, 0, 0, 1, 0, 1])
111+
zdata = np.array([0, 3, 3, 0, 0, 10, 10, 0, 0, 1, 1, 1])
112+
data = np.array([ydata, zdata])
113+
steps = (12, 1)
114+
115+
data = tuple(
116+
dlstbx.util.xray_centering.reshape_grid(
117+
d,
118+
steps,
119+
snaked=True,
120+
orientation=dlstbx.util.xray_centering.Orientation.HORIZONTAL,
121+
)
122+
for d in data
123+
)
124+
125+
sample_id = 12345
126+
multipin_sample_ids = {1: 12345, 2: 12346, 3: 12347}
127+
well_limits = [(0.0, 4.0), (4.0, 7.0), (8.0, 11.0)]
128+
129+
# Fit peaks with multipin threshold parameters and check that correct results are obtained.
130+
results = dlstbx.util.xray_centering_3d.gridscan3d(
131+
data,
132+
threshold_msp=0.25,
133+
threshold_msp_absolute=3,
134+
sample_id=sample_id,
135+
plot=False,
136+
multipin_sample_ids=multipin_sample_ids,
137+
well_limits=well_limits,
138+
)
139+
140+
assert len(results) == 2
141+
expected_results = [
142+
{
143+
"centre_of_mass": (5.833333333333333, 0.5, 0.5),
144+
"max_voxel": (5, 0, 0),
145+
"max_count": 200.0,
146+
"n_voxels": 2,
147+
"total_count": 300.0,
148+
"sample_id": 12346,
149+
"bounding_box": ((5, 0, 0), (7, 1, 1)),
150+
},
151+
{
152+
"centre_of_mass": (2.0, 0.5, 0.5),
153+
"max_voxel": (1, 0, 0),
154+
"max_count": 9.0,
155+
"n_voxels": 2,
156+
"total_count": 18.0,
157+
"sample_id": 12345,
158+
"bounding_box": ((1, 0, 0), (3, 1, 1)),
159+
},
160+
]
161+
162+
for result_num, result in enumerate(results):
163+
result_d = result.model_dump()
164+
# check that the results are JSON-serializable
165+
json.dumps(result_d)
166+
assert result_d == expected_results[result_num]

0 commit comments

Comments
 (0)