pytme 0.1.8__cp311-cp311-macosx_14_0_arm64.whl → 0.2.0__cp311-cp311-macosx_14_0_arm64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. pytme-0.2.0.data/scripts/match_template.py +1019 -0
  2. pytme-0.2.0.data/scripts/postprocess.py +570 -0
  3. {pytme-0.1.8.data → pytme-0.2.0.data}/scripts/preprocessor_gui.py +244 -60
  4. {pytme-0.1.8.dist-info → pytme-0.2.0.dist-info}/METADATA +3 -1
  5. pytme-0.2.0.dist-info/RECORD +72 -0
  6. {pytme-0.1.8.dist-info → pytme-0.2.0.dist-info}/WHEEL +1 -1
  7. scripts/extract_candidates.py +218 -0
  8. scripts/match_template.py +459 -218
  9. pytme-0.1.8.data/scripts/match_template.py → scripts/match_template_filters.py +459 -218
  10. scripts/postprocess.py +380 -435
  11. scripts/preprocessor_gui.py +244 -60
  12. scripts/refine_matches.py +218 -0
  13. tme/__init__.py +2 -1
  14. tme/__version__.py +1 -1
  15. tme/analyzer.py +533 -78
  16. tme/backends/cupy_backend.py +80 -15
  17. tme/backends/npfftw_backend.py +35 -6
  18. tme/backends/pytorch_backend.py +15 -7
  19. tme/density.py +173 -78
  20. tme/extensions.cpython-311-darwin.so +0 -0
  21. tme/matching_constrained.py +195 -0
  22. tme/matching_data.py +78 -32
  23. tme/matching_exhaustive.py +369 -221
  24. tme/matching_memory.py +1 -0
  25. tme/matching_optimization.py +753 -649
  26. tme/matching_utils.py +152 -8
  27. tme/orientations.py +561 -0
  28. tme/preprocessing/__init__.py +2 -0
  29. tme/preprocessing/_utils.py +176 -0
  30. tme/preprocessing/composable_filter.py +30 -0
  31. tme/preprocessing/compose.py +52 -0
  32. tme/preprocessing/frequency_filters.py +322 -0
  33. tme/preprocessing/tilt_series.py +967 -0
  34. tme/preprocessor.py +35 -25
  35. tme/structure.py +2 -37
  36. pytme-0.1.8.data/scripts/postprocess.py +0 -625
  37. pytme-0.1.8.dist-info/RECORD +0 -61
  38. {pytme-0.1.8.data → pytme-0.2.0.data}/scripts/estimate_ram_usage.py +0 -0
  39. {pytme-0.1.8.data → pytme-0.2.0.data}/scripts/preprocess.py +0 -0
  40. {pytme-0.1.8.dist-info → pytme-0.2.0.dist-info}/LICENSE +0 -0
  41. {pytme-0.1.8.dist-info → pytme-0.2.0.dist-info}/entry_points.txt +0 -0
  42. {pytme-0.1.8.dist-info → pytme-0.2.0.dist-info}/top_level.txt +0 -0
@@ -15,96 +15,306 @@ from scipy.optimize import (
15
15
  differential_evolution,
16
16
  LinearConstraint,
17
17
  basinhopping,
18
+ minimize,
18
19
  )
19
- from scipy.ndimage import laplace
20
+ from scipy.ndimage import laplace, map_coordinates
20
21
  from scipy.spatial import KDTree
21
22
 
23
+ from .types import ArrayLike
24
+ from .backends import backend
25
+ from .matching_data import MatchingData
22
26
  from .matching_utils import rigid_transform, euler_to_rotationmatrix
27
+ from .matching_exhaustive import normalize_under_mask
23
28
 
24
29
 
25
- class MatchCoordinatesToDensity(ABC):
30
+ def _format_rigid_transform(x: Tuple[float]) -> Tuple[ArrayLike, ArrayLike]:
26
31
  """
27
- A class to template match coordinate sets.
32
+ Returns a formated rigid transform definition.
28
33
 
29
34
  Parameters
30
35
  ----------
31
- target_coordinates : NDArray
32
- The coordinates of the target.
36
+ x : tuple of float
37
+ Even-length tuple where the first half represents translations and the
38
+ second half Euler angles in zyx convention for each dimension.
39
+
40
+ Returns
41
+ -------
42
+ Tuple[ArrayLike, ArrayLike]
43
+ Translation of length [d, ] and rotation matrix with dimension [d x d].
44
+ """
45
+ split = len(x) // 2
46
+ translation, angles = x[:split], x[split:]
47
+
48
+ translation = backend.to_backend_array(translation)
49
+ rotation_matrix = euler_to_rotationmatrix(backend.to_numpy_array(angles))
50
+ rotation_matrix = backend.to_backend_array(rotation_matrix)
51
+
52
+ return translation, rotation_matrix
53
+
54
+
55
+ class _MatchDensityToDensity(ABC):
56
+ """
57
+ Parameters
58
+ ----------
59
+ target : array_like
60
+ The target density array.
61
+ template : array_like
62
+ The template density array.
63
+ template_mask : array_like, optional
64
+ Mask array for the template density.
65
+ target_mask : array_like, optional
66
+ Mask array for the target density.
67
+ pad_target_edges : bool, optional
68
+ Whether to pad the edges of the target density array. Default is False.
69
+ pad_fourier : bool, optional
70
+ Whether to pad the Fourier transform of the target and template densities.
71
+ rotate_mask : bool, optional
72
+ Whether to rotate the mask arrays along with the densities. Default is True.
73
+ interpolation_order : int, optional
74
+ The interpolation order for rigid transforms. Default is 1.
75
+ negate_score : bool, optional
76
+ Whether the final score should be multiplied by negative one. Default is True.
77
+ **kwargs : Dict, optional
78
+ Keyword arguments propagated to downstream functions.
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ target: ArrayLike,
84
+ template: ArrayLike,
85
+ template_mask: ArrayLike = None,
86
+ target_mask: ArrayLike = None,
87
+ pad_target_edges: bool = False,
88
+ pad_fourier: bool = False,
89
+ rotate_mask: bool = True,
90
+ interpolation_order: int = 1,
91
+ negate_score: bool = True,
92
+ **kwargs: Dict,
93
+ ):
94
+ self.rotate_mask = rotate_mask
95
+ self.interpolation_order = interpolation_order
96
+
97
+ matching_data = MatchingData(target=target, template=template)
98
+ if template_mask is not None:
99
+ matching_data.template_mask = template_mask
100
+ if target_mask is not None:
101
+ matching_data.target_mask = target_mask
102
+
103
+ target_pad = matching_data.target_padding(pad_target=pad_target_edges)
104
+ matching_data = matching_data.subset_by_slice(target_pad=target_pad)
105
+
106
+ fast_shape, fast_ft_shape, fourier_shift = matching_data.fourier_padding(
107
+ pad_fourier=pad_fourier
108
+ )
109
+
110
+ self.target = backend.topleft_pad(matching_data.target, fast_shape)
111
+ self.target_mask = matching_data.target_mask
112
+
113
+ self.template = matching_data.template
114
+ self.template_rot = backend.preallocate_array(
115
+ fast_shape, backend._default_dtype
116
+ )
117
+
118
+ self.template_mask, self.template_mask_rot = 1, 1
119
+ rotate_mask = False if matching_data.template_mask is None else rotate_mask
120
+ if matching_data.template_mask is not None:
121
+ self.template_mask = matching_data.template_mask
122
+ self.template_mask_rot = backend.topleft_pad(
123
+ matching_data.template_mask, fast_shape
124
+ )
125
+
126
+ self.score_sign = -1 if negate_score else 1
127
+
128
+ if hasattr(self, "_post_init"):
129
+ self._post_init(**kwargs)
130
+
131
+ @staticmethod
132
+ def rigid_transform(
133
+ arr,
134
+ rotation_matrix,
135
+ translation,
136
+ arr_mask=None,
137
+ out=None,
138
+ out_mask=None,
139
+ order: int = 1,
140
+ use_geometric_center: bool = False,
141
+ ):
142
+ rotate_mask = arr_mask is not None
143
+ return_type = (out is None) + 2 * rotate_mask * (out_mask is None)
144
+ translation = np.zeros(arr.ndim) if translation is None else translation
145
+
146
+ center = np.floor(np.array(arr.shape) / 2)[:, None]
147
+ grid = np.indices(arr.shape, dtype=np.float32).reshape(arr.ndim, -1)
148
+ np.subtract(grid, center, out=grid)
149
+ np.matmul(rotation_matrix.T, grid, out=grid)
150
+ np.add(grid, center, out=grid)
151
+
152
+ if out is None:
153
+ out = np.zeros_like(arr)
154
+
155
+ map_coordinates(arr, grid, order=order, output=out.ravel())
156
+
157
+ if out_mask is None and arr_mask is not None:
158
+ out_mask = np.zeros_like(arr_mask)
159
+
160
+ if arr_mask is not None:
161
+ map_coordinates(arr_mask, grid, order=order, output=out_mask.ravel())
162
+
163
+ match return_type:
164
+ case 0:
165
+ return None
166
+ case 1:
167
+ return out
168
+ case 2:
169
+ return out_mask
170
+ case 3:
171
+ return out, out_mask
172
+
173
+ def score_translation(self, x: Tuple[float]) -> float:
174
+ """
175
+ Computes the score after a given translation.
176
+
177
+ Parameters
178
+ ----------
179
+ x : tuple of float
180
+ Tuple representing the translation transformation in each dimension.
181
+
182
+ Returns
183
+ -------
184
+ float
185
+ The score obtained for the translation transformation.
186
+ """
187
+ return self.score((*x, *[0 for _ in range(len(x))]))
188
+
189
+ def score_angles(self, x: Tuple[float]) -> float:
190
+ """
191
+ Computes the score after a given rotation.
192
+
193
+ Parameters
194
+ ----------
195
+ x : tuple of float
196
+ Tuple of Euler angles in zyx convention for each dimension.
197
+
198
+ Returns
199
+ -------
200
+ float
201
+ The score obtained for the rotation transformation.
202
+ """
203
+ return self.score((*[0 for _ in range(len(x))], *x))
204
+
205
+ def score(self, x: Tuple[float]) -> float:
206
+ """
207
+ Compute the matching score for the given transformation parameters.
208
+
209
+ Parameters
210
+ ----------
211
+ x : tuple of float
212
+ Even-length tuple where the first half represents translations and the
213
+ second half Euler angles in zyx convention for each dimension.
214
+
215
+ Returns
216
+ -------
217
+ float
218
+ The matching score obtained for the transformation.
219
+ """
220
+ translation, rotation_matrix = _format_rigid_transform(x)
221
+ kw_dict = {
222
+ "arr": self.template,
223
+ "rotation_matrix": rotation_matrix,
224
+ "translation": translation,
225
+ "out": self.template_rot,
226
+ "use_geometric_center": False,
227
+ "order": self.interpolation_order,
228
+ }
229
+ if self.rotate_mask:
230
+ kw_dict["arr_mask"] = self.template_mask
231
+ kw_dict["out_mask"] = self.template_mask_rot
232
+
233
+ self.rigid_transform(**kw_dict)
234
+
235
+ return self()
236
+
237
+ @abstractmethod
238
+ def __call__(self) -> float:
239
+ """Returns the score of the current configuration."""
240
+
241
+
242
+ class _MatchCoordinatesToDensity(_MatchDensityToDensity):
243
+ """
244
+ Parameters
245
+ ----------
246
+ target : NDArray
247
+ A d-dimensional target to match the template coordinate set to.
33
248
  template_coordinates : NDArray
34
- The coordinates of the template.
35
- target_weights : NDArray
36
- The weights of the target.
249
+ Template coordinate array with shape [d x N].
37
250
  template_weights : NDArray
38
- The weights of the template.
39
- sampling_rate : NDArray
40
- The size of the voxel.
251
+ Template weight array with shape [N].
41
252
  template_mask_coordinates : NDArray, optional
42
- The coordinates of the template mask. Default is None.
43
- target_mask_coordinates : NDArray, optional
44
- The coordinates of the target mask. Default is None.
45
- **kwargs : dict, optional
46
- Other keyword arguments.
253
+ Template mask coordinates with shape [d x N].
254
+ target_mask : NDArray, optional
255
+ A d-dimensional mask to be applied to the target.
256
+ negate_score : bool, optional
257
+ Whether the final score should be multiplied by negative one. Default is True.
258
+ **kwargs : Dict, optional
259
+ Keyword arguments propagated to downstream functions.
47
260
  """
48
261
 
49
262
  def __init__(
50
263
  self,
51
- target_coordinates: NDArray,
264
+ target: NDArray,
52
265
  template_coordinates: NDArray,
53
- target_weights: NDArray,
54
266
  template_weights: NDArray,
55
- sampling_rate: NDArray,
56
267
  template_mask_coordinates: NDArray = None,
57
- target_mask_coordinates: NDArray = None,
58
- **kwargs,
268
+ target_mask: NDArray = None,
269
+ negate_score: bool = True,
270
+ **kwargs: Dict,
59
271
  ):
60
- target, _, origin = FitRefinement.array_from_coordinates(
61
- target_coordinates, target_weights, sampling_rate
62
- )
63
272
  self.target_density = target
64
- self.target_origin = origin
65
- self.sampling_rate = sampling_rate
273
+ self.target_mask_density = target_mask
66
274
 
67
275
  self.template_weights = template_weights
68
276
  self.template_coordinates = template_coordinates
69
- self.template_coordinates_rotated = np.empty(
70
- self.template_coordinates.shape, dtype=np.float32
277
+ self.template_coordinates_rotated = np.copy(self.template_coordinates).astype(
278
+ np.float32
71
279
  )
72
280
 
73
- self.target_mask_density = None
74
- if target_mask_coordinates is not None:
75
- target_mask, *_ = FitRefinement.array_from_coordinates(
76
- coordinates=target_mask_coordinates.astype(np.float32),
77
- weights=np.ones(target_mask_coordinates.shape[1]),
78
- shape=self.target_density.shape,
79
- origin=self.target_origin,
80
- sampling_rate=self.sampling_rate,
81
- )
82
- self.target_mask_density = target_mask
83
-
84
- self.template_mask_coordinates = None
85
- self.template_mask_coordinates_rotated = None
281
+ self.template_mask_coordinates = template_mask_coordinates
282
+ self.template_mask_coordinates_rotated = template_mask_coordinates
86
283
  if template_mask_coordinates is not None:
87
- self.template_mask_coordinates = template_mask_coordinates
88
- self.template_mask_coordinates_rotated = np.empty(
89
- self.template_mask_coordinates.shape, dtype=np.float32
90
- )
284
+ self.template_mask_coordinates_rotated = np.copy(
285
+ self.template_mask_coordinates
286
+ ).astype(np.float32)
287
+
288
+ self.denominator = 1
289
+ self.score_sign = -1 if negate_score else 1
290
+
291
+ self.in_volume, self.in_volume_mask = self.map_coordinates_to_array(
292
+ coordinates=self.template_coordinates_rotated,
293
+ coordinates_mask=self.template_mask_coordinates_rotated,
294
+ array_origin=backend.zeros(target.ndim),
295
+ array_shape=self.target_density.shape,
296
+ sampling_rate=backend.full(target.ndim, fill_value=1),
297
+ )
91
298
 
92
- def __call__(self, x: NDArray):
299
+ if hasattr(self, "_post_init"):
300
+ self._post_init(**kwargs)
301
+
302
+ def score(self, x: Tuple[float]):
93
303
  """
94
- Return the score for a given transformation.
304
+ Compute the matching score for the given transformation parameters.
95
305
 
96
306
  Parameters
97
307
  ----------
98
- x : NDArray
99
- The input transformation parameters.
308
+ x : tuple of float
309
+ Even-length tuple where the first half represents translations and the
310
+ second half Euler angles in zyx convention for each dimension.
100
311
 
101
312
  Returns
102
313
  -------
103
314
  float
104
- The negative score from the scoring function.
315
+ The matching score obtained for the transformation.
105
316
  """
106
- translation, rotation = x[:3], x[3:]
107
- rotation_matrix = euler_to_rotationmatrix(rotation)
317
+ translation, rotation_matrix = _format_rigid_transform(x)
108
318
 
109
319
  rigid_transform(
110
320
  coordinates=self.template_coordinates,
@@ -116,54 +326,132 @@ class MatchCoordinatesToDensity(ABC):
116
326
  use_geometric_center=False,
117
327
  )
118
328
 
119
- mapping = FitRefinement.map_coordinates_to_array(
329
+ self.in_volume, self.in_volume_mask = self.map_coordinates_to_array(
120
330
  coordinates=self.template_coordinates_rotated,
121
331
  coordinates_mask=self.template_mask_coordinates_rotated,
122
- array_origin=self.target_origin,
332
+ array_origin=backend.zeros(rotation_matrix.shape[0]),
123
333
  array_shape=self.target_density.shape,
124
- sampling_rate=self.sampling_rate,
334
+ sampling_rate=backend.full(rotation_matrix.shape[0], fill_value=1),
125
335
  )
126
336
 
127
- return -self.scoring_function(
128
- transformed_coordinates=mapping[0],
129
- transformed_coordinates_mask=mapping[1],
130
- in_volume=mapping[2],
131
- in_volume_mask=mapping[3],
132
- )
337
+ return self()
133
338
 
134
- @abstractmethod
135
- def scoring_function(*args, **kwargs):
339
+ @staticmethod
340
+ def array_from_coordinates(
341
+ coordinates: NDArray,
342
+ weights: NDArray,
343
+ sampling_rate: NDArray,
344
+ origin: NDArray = None,
345
+ shape: NDArray = None,
346
+ ) -> Tuple[NDArray, NDArray, NDArray]:
136
347
  """
137
- Computes a scoring metric for a given set of coordinates.
348
+ Create a volume from coordinates, using given weights and voxel size.
349
+
350
+ Parameters
351
+ ----------
352
+ coordinates : NDArray
353
+ An array representing the coordinates [d x N].
354
+ weights : NDArray
355
+ An array representing the weights for each coordinate [N].
356
+ sampling_rate : NDArray
357
+ The size of a voxel in the volume.
358
+ origin : NDArray, optional
359
+ The origin of the volume.
360
+ shape : NDArray, optional
361
+ The shape of the volume.
138
362
 
139
- This function is not intended to be called directly, but should rather be
140
- defined by classes inheriting from :py:class:`MatchCoordinatesToDensity`
141
- to parse a given file format.
363
+ Returns
364
+ -------
365
+ tuple
366
+ Returns the generated volume, positions of coordinates, and origin.
142
367
  """
368
+ if origin is None:
369
+ origin = coordinates.min(axis=1)
143
370
 
371
+ positions = np.divide(coordinates - origin[:, None], sampling_rate[:, None])
372
+ positions = positions.astype(int)
144
373
 
145
- class MatchCoordinatesToCoordinates(ABC):
146
- """
147
- A class to template match coordinate sets.
374
+ if shape is None:
375
+ shape = positions.max(axis=1) + 1
148
376
 
377
+ arr = np.zeros(shape, dtype=np.float32)
378
+ np.add.at(arr, tuple(positions), weights)
379
+ return arr, positions, origin
380
+
381
+ @staticmethod
382
+ def map_coordinates_to_array(
383
+ coordinates: NDArray,
384
+ array_shape: NDArray,
385
+ array_origin: NDArray,
386
+ sampling_rate: NDArray,
387
+ coordinates_mask: NDArray = None,
388
+ ) -> Tuple[NDArray, NDArray]:
389
+ """
390
+ Map coordinates to a volume based on given voxel size and origin.
391
+
392
+ Parameters
393
+ ----------
394
+ coordinates : NDArray
395
+ An array representing the coordinates to be mapped [d x N].
396
+ array_shape : NDArray
397
+ The shape of the array to which the coordinates are mapped.
398
+ array_origin : NDArray
399
+ The origin of the array to which the coordinates are mapped.
400
+ sampling_rate : NDArray
401
+ The size of a voxel in the array.
402
+ coordinates_mask : NDArray, optional
403
+ An array representing the mask for the coordinates [d x T].
404
+
405
+ Returns
406
+ -------
407
+ tuple
408
+ Returns transformed coordinates, transformed coordinates mask,
409
+ mask for in_volume points, and mask for in_volume points in mask.
410
+ """
411
+ np.divide(
412
+ coordinates - array_origin[:, None], sampling_rate[:, None], out=coordinates
413
+ )
414
+
415
+ in_volume = np.logical_and(
416
+ coordinates < np.array(array_shape)[:, None],
417
+ coordinates >= 0,
418
+ ).min(axis=0)
419
+
420
+ in_volume_mask = None
421
+ if coordinates_mask is not None:
422
+ np.divide(
423
+ coordinates_mask - array_origin[:, None],
424
+ sampling_rate[:, None],
425
+ out=coordinates_mask,
426
+ )
427
+ in_volume_mask = np.logical_and(
428
+ coordinates_mask < np.array(array_shape)[:, None],
429
+ coordinates_mask >= 0,
430
+ ).min(axis=0)
431
+
432
+ return in_volume, in_volume_mask
433
+
434
+
435
+ class _MatchCoordinatesToCoordinates(_MatchDensityToDensity):
436
+ """
149
437
  Parameters
150
438
  ----------
151
439
  target_coordinates : NDArray
152
- The coordinates of the target.
440
+ The coordinates of the target with shape [d x N].
153
441
  template_coordinates : NDArray
154
- The coordinates of the template.
442
+ The coordinates of the template with shape [d x T].
155
443
  target_weights : NDArray
156
- The weights of the target.
444
+ The weights of the target with shape [N].
157
445
  template_weights : NDArray
158
- The weights of the template.
159
- sampling_rate : NDArray
160
- The size of the voxel.
446
+ The weights of the template with shape [T].
161
447
  template_mask_coordinates : NDArray, optional
162
- The coordinates of the template mask. Default is None.
448
+ The coordinates of the template mask with shape [d x T]. Default is None.
163
449
  target_mask_coordinates : NDArray, optional
164
- The coordinates of the target mask. Default is None.
165
- **kwargs : dict, optional
166
- Other keyword arguments.
450
+ The coordinates of the target mask with shape [d X N]. Default is None.
451
+ negate_score : bool, optional
452
+ Whether the final score should be multiplied by negative one. Default is True.
453
+ **kwargs : Dict, optional
454
+ Keyword arguments propagated to downstream functions.
167
455
  """
168
456
 
169
457
  def __init__(
@@ -174,6 +462,7 @@ class MatchCoordinatesToCoordinates(ABC):
174
462
  template_weights: NDArray,
175
463
  template_mask_coordinates: NDArray = None,
176
464
  target_mask_coordinates: NDArray = None,
465
+ negate_score: bool = True,
177
466
  **kwargs,
178
467
  ):
179
468
  self.target_weights = target_weights
@@ -193,23 +482,27 @@ class MatchCoordinatesToCoordinates(ABC):
193
482
  self.template_mask_coordinates_rotated = np.empty(
194
483
  self.template_mask_coordinates.shape, dtype=np.float32
195
484
  )
485
+ self.score_sign = -1 if negate_score else 1
486
+
487
+ if hasattr(self, "_post_init"):
488
+ self._post_init(**kwargs)
196
489
 
197
- def __call__(self, x: NDArray):
490
+ def score(self, x: Tuple[float]) -> float:
198
491
  """
199
- Return the score for a given transformation.
492
+ Compute the matching score for the given transformation parameters.
200
493
 
201
494
  Parameters
202
495
  ----------
203
- x : NDArray
204
- The input transformation parameters.
496
+ x : tuple of float
497
+ Even-length tuple where the first half represents translations and the
498
+ second half Euler angles in zyx convention for each dimension.
205
499
 
206
500
  Returns
207
501
  -------
208
502
  float
209
- The negative score from the scoring function.
503
+ The matching score obtained for the transformation.
210
504
  """
211
- translation, rotation = x[:3], x[3:]
212
- rotation_matrix = euler_to_rotationmatrix(rotation)
505
+ translation, rotation_matrix = _format_rigid_transform(x)
213
506
 
214
507
  rigid_transform(
215
508
  coordinates=self.template_coordinates,
@@ -221,106 +514,161 @@ class MatchCoordinatesToCoordinates(ABC):
221
514
  use_geometric_center=False,
222
515
  )
223
516
 
224
- return -self.scoring_function(
517
+ return self(
225
518
  transformed_coordinates=self.template_coordinates_rotated,
226
519
  transformed_coordinates_mask=self.template_mask_coordinates_rotated,
227
520
  )
228
521
 
229
- @abstractmethod
230
- def scoring_function(*args, **kwargs):
231
- """
232
- Computes a scoring metric for a given set of coordinates.
233
522
 
234
- This function is not intended to be called directly, but should rather be
235
- defined by classes inheriting from :py:class:`MatchCoordinatesToDensity`
236
- to parse a given file format.
237
- """
523
+ class FLC(_MatchDensityToDensity):
524
+ """
525
+ Computes a normalized cross-correlation score of a target f a template g
526
+ and a mask m:
238
527
 
528
+ .. math::
239
529
 
240
- class CrossCorrelation(MatchCoordinatesToDensity):
241
- """
242
- Class representing the Cross-Correlation matching score.
530
+ \\frac{CC(f, \\frac{g*m - \\overline{g*m}}{\\sigma_{g*m}})}
531
+ {N_m * \\sqrt{
532
+ \\frac{CC(f^2, m)}{N_m} - (\\frac{CC(f, m)}{N_m})^2}
533
+ }
243
534
 
244
- Cross-Correlation score formula:
535
+ Where:
245
536
 
246
537
  .. math::
247
538
 
248
- \\text{score} = \\text{target_weights} \\cdot \\text{template_weights}
539
+ CC(f,g) = \\mathcal{F}^{-1}(\\mathcal{F}(f) \\cdot \\mathcal{F}(g)^*)
540
+
541
+ and Nm is the number of voxels within the template mask m.
249
542
 
543
+ References
544
+ ----------
545
+ .. [1] W. Wan, S. Khavnekar, J. Wagner, P. Erdmann, and W. Baumeister
546
+ Microsc. Microanal. 26, 2516 (2020)
547
+ .. [2] T. Hrabe, Y. Chen, S. Pfeffer, L. Kuhn Cuellar, A.-V. Mangold,
548
+ and F. Förster, J. Struct. Biol. 178, 177 (2012).
250
549
  """
251
550
 
252
- def __init__(self, **kwargs):
253
- super().__init__(**kwargs)
254
- self.denominator = 1
551
+ __doc__ += _MatchDensityToDensity.__doc__
255
552
 
256
- def scoring_function(
257
- self,
258
- transformed_coordinates: NDArray,
259
- transformed_coordinates_mask: NDArray,
260
- in_volume: NDArray,
261
- in_volume_mask: NDArray,
262
- ) -> float:
263
- """
264
- Compute the Cross-Correlation score.
553
+ def _post_init(self, **kwargs: Dict):
554
+ if self.target_mask is not None:
555
+ backend.multiply(self.target, self.target_mask, out=self.target)
265
556
 
266
- Parameters
267
- ----------
268
- transformed_coordinates : NDArray
269
- Transformed coordinates.
270
- transformed_coordinates_mask : NDArray
271
- Mask for the transformed coordinates.
272
- in_volume : NDArray
273
- Binary mask indicating which ``transformed_coordinates`` are in the
274
- target volume.
275
- in_volume_mask : NDArray
276
- Binary mask indicating which ``transformed_coordinates`` are in the
277
- target mask volume.
557
+ self.target_square = backend.square(self.target)
278
558
 
279
- Returns
280
- -------
281
- float
282
- The Cross-Correlation score.
283
- """
284
- score = np.dot(
285
- self.target_density[tuple(transformed_coordinates[:, in_volume])],
286
- self.template_weights[in_volume],
559
+ normalize_under_mask(
560
+ template=self.template,
561
+ mask=self.template_mask,
562
+ mask_intensity=backend.sum(self.template_mask),
287
563
  )
288
- score /= self.denominator
564
+
565
+ self.template = backend.reverse(self.template)
566
+ self.template_mask = backend.reverse(self.template_mask)
567
+
568
+ def __call__(self) -> float:
569
+ """Returns the score of the current configuration."""
570
+ n_observations = backend.sum(self.template_mask_rot)
571
+
572
+ normalize_under_mask(
573
+ template=self.template_rot,
574
+ mask=self.template_mask_rot,
575
+ mask_intensity=n_observations,
576
+ )
577
+
578
+ ex2 = backend.sum(
579
+ backend.divide(
580
+ backend.sum(
581
+ backend.multiply(self.target_square, self.template_mask_rot),
582
+ ),
583
+ n_observations,
584
+ )
585
+ )
586
+ e2x = backend.square(
587
+ backend.divide(
588
+ backend.sum(backend.multiply(self.target, self.template_mask_rot)),
589
+ n_observations,
590
+ )
591
+ )
592
+
593
+ denominator = backend.maximum(backend.subtract(ex2, e2x), 0.0)
594
+ denominator = backend.sqrt(denominator)
595
+ denominator = backend.multiply(denominator, n_observations)
596
+
597
+ overlap = backend.sum(backend.multiply(self.template_rot, self.target))
598
+
599
+ score = backend.divide(overlap, denominator) * self.score_sign
289
600
  return score
290
601
 
291
602
 
292
- class LaplaceCrossCorrelation(CrossCorrelation):
603
+ class CrossCorrelation(_MatchCoordinatesToDensity):
604
+ """
605
+ Computes the Cross-Correlation score as:
606
+
607
+ .. math::
608
+
609
+ \\text{score} = \\text{target_weights} \\cdot \\text{template_weights}
293
610
  """
294
- Class representing the Laplace Cross-Correlation matching score.
295
611
 
296
- The score is computed like CrossCorrelation, but with Laplace filtered
297
- weights, indicated by the Laplace operator :math:`\\nabla^{2}`.
612
+ __doc__ += _MatchCoordinatesToDensity.__doc__
613
+
614
+ def __call__(self) -> float:
615
+ """Returns the score of the current configuration."""
616
+ try:
617
+ score = np.dot(
618
+ self.target_density[
619
+ tuple(
620
+ self.template_coordinates_rotated[:, self.in_volume].astype(int)
621
+ )
622
+ ],
623
+ self.template_weights[self.in_volume],
624
+ )
625
+ except:
626
+ print(self.template_coordinates_rotated[:, self.in_volume].astype(int))
627
+ print(self.target_density.shape)
628
+ print(self.in_volume)
629
+ coordinates = self.template_coordinates_rotated[:, self.in_volume].astype(
630
+ int
631
+ )
632
+ in_volume = np.logical_and(
633
+ coordinates < np.array(self.target_density.shape)[:, None],
634
+ coordinates >= 0,
635
+ ).min(axis=0)
636
+ print(in_volume)
637
+
638
+ raise ValueError()
639
+ score /= self.denominator
640
+ return score * self.score_sign
641
+
642
+
643
+ class LaplaceCrossCorrelation(CrossCorrelation):
644
+ """
645
+ Uses the same formalism as :py:class:`CrossCorrelation` but with Laplace
646
+ filtered weights (:math:`\\nabla^{2}`):
298
647
 
299
648
  .. math::
300
649
 
301
650
  \\text{score} = \\nabla^{2} \\text{target_weights} \\cdot
302
651
  \\nabla^{2} \\text{template_weights}
303
-
304
652
  """
305
653
 
306
- def __init__(self, **kwargs):
307
- super().__init__(**kwargs)
654
+ __doc__ += _MatchCoordinatesToDensity.__doc__
655
+
656
+ def _post_init(self, **kwargs):
308
657
  self.target_density = laplace(self.target_density)
309
658
 
310
- arr, positions, _ = FitRefinement.array_from_coordinates(
311
- self.template_coordinates, self.template_weights, self.sampling_rate
659
+ arr, positions, _ = self.array_from_coordinates(
660
+ self.template_coordinates,
661
+ self.template_weights,
662
+ np.ones(self.template_coordinates.shape[0]),
312
663
  )
313
664
  self.template_weights = laplace(arr)[tuple(positions)]
314
665
 
315
666
 
316
667
  class NormalizedCrossCorrelation(CrossCorrelation):
317
668
  """
318
- Class representing the Normalized Cross-Correlation matching score.
319
-
320
- The score is computed by normalizing the dot product of `target_weights` and
321
- `template_weights` with the product of their norms. This normalization ensures
322
- the score lies between -1 and 1, providing a measure of similarity that's invariant
323
- to scale.
669
+ Computes a normalized version of the :py:class:`CrossCorrelation` score based
670
+ on the dot product of `target_weights` and `template_weights`, in order to
671
+ reduce bias to regions of high local energy.
324
672
 
325
673
  .. math::
326
674
 
@@ -338,11 +686,11 @@ class NormalizedCrossCorrelation(CrossCorrelation):
338
686
  \\text{template_norm} = ||\\text{template_weights}||
339
687
 
340
688
  Here, :math:`||.||` denotes the L2 (Euclidean) norm.
341
-
342
689
  """
343
690
 
344
- def __init__(self, **kwargs):
345
- super().__init__(**kwargs)
691
+ __doc__ += _MatchCoordinatesToDensity.__doc__
692
+
693
+ def _post_init(self, **kwargs):
346
694
  target_norm = np.linalg.norm(self.target_density[self.target_density != 0])
347
695
  template_norm = np.linalg.norm(self.template_weights)
348
696
  self.denominator = np.fmax(target_norm * template_norm, np.finfo(float).eps)
@@ -350,14 +698,8 @@ class NormalizedCrossCorrelation(CrossCorrelation):
350
698
 
351
699
  class NormalizedCrossCorrelationMean(NormalizedCrossCorrelation):
352
700
  """
353
- Class representing the Mean Normalized Cross-Correlation matching score.
354
-
355
- This class extends the Normalized Cross-Correlation by computing the score
356
- after subtracting the mean from both `target_weights` and `template_weights`.
357
- This modification enhances the matching score's sensitivity to patterns
358
- over flat regions in the data.
359
-
360
- Mathematically, the Mean Normalized Cross-Correlation score is computed as:
701
+ Computes a similar score than :py:class:`NormalizedCrossCorrelation`, but
702
+ additionally factors in the mean of template and target.
361
703
 
362
704
  .. math::
363
705
 
@@ -381,21 +723,21 @@ class NormalizedCrossCorrelationMean(NormalizedCrossCorrelation):
381
723
  computes the mean of the respective weights.
382
724
  """
383
725
 
726
+ __doc__ += _MatchCoordinatesToDensity.__doc__
727
+
384
728
  def __init__(self, **kwargs):
385
- print(kwargs["target_weights"].mean())
386
- kwargs["target_weights"] -= kwargs["target_weights"].mean()
387
- kwargs["template_weights"] -= kwargs["template_weights"].mean()
729
+ kwargs["target"] = np.subtract(kwargs["target"], kwargs["target"].mean())
730
+ kwargs["template_weights"] = np.subtract(
731
+ kwargs["template_weights"], kwargs["template_weights"].mean()
732
+ )
388
733
  super().__init__(**kwargs)
389
734
 
390
735
 
391
- class MaskedCrossCorrelation(MatchCoordinatesToDensity):
736
+ class MaskedCrossCorrelation(_MatchCoordinatesToDensity):
392
737
  """
393
- Class representing the Masked Cross-Correlation matching score.
394
-
395
738
  The Masked Cross-Correlation computes the similarity between `target_weights`
396
- and `template_weights` under respective masks. The score is normalized and lies
397
- between -1 and 1, providing a measure of similarity even in the presence of
398
- missing or masked data.
739
+ and `template_weights` under respective masks. The score provides a measure of
740
+ similarity even in the presence of missing or masked data.
399
741
 
400
742
  The formula for the Masked Cross-Correlation is:
401
743
 
@@ -437,54 +779,37 @@ class MaskedCrossCorrelation(MatchCoordinatesToDensity):
437
779
  .. [1] Masked FFT registration, Dirk Padfield, CVPR 2010 conference
438
780
  """
439
781
 
440
- def __init__(self, **kwargs):
441
- super().__init__(**kwargs)
442
-
443
- def scoring_function(
444
- self,
445
- transformed_coordinates: NDArray,
446
- transformed_coordinates_mask: NDArray,
447
- in_volume: NDArray,
448
- in_volume_mask: NDArray,
449
- ) -> float:
450
- """
451
- Compute the Masked Cross-Correlation score.
452
-
453
- Parameters
454
- ----------
455
- transformed_coordinates : NDArray
456
- Transformed coordinates.
457
- transformed_coordinates_mask : NDArray
458
- Mask for the transformed coordinates.
459
- in_volume : NDArray
460
- Binary mask indicating which ``transformed_coordinates`` are in the
461
- target volume.
462
- in_volume_mask : NDArray
463
- Binary mask indicating which ``transformed_coordinates`` are in the
464
- target mask volume.
782
+ __doc__ += _MatchCoordinatesToDensity.__doc__
465
783
 
466
- Returns
467
- -------
468
- float
469
- The Masked Cross-Correlation score.
470
- """
784
+ def __call__(self) -> float:
785
+ """Returns the score of the current configuration."""
471
786
  mask_overlap = np.sum(
472
787
  self.target_mask_density[
473
- tuple(transformed_coordinates_mask[:, in_volume_mask])
788
+ tuple(
789
+ self.template_mask_coordinates_rotated[
790
+ :, self.in_volume_mask
791
+ ].astype(int)
792
+ )
474
793
  ],
475
794
  )
476
795
  mask_overlap = np.fmax(mask_overlap, np.finfo(float).eps)
477
796
 
478
797
  mask_target = self.target_density[
479
- tuple(transformed_coordinates_mask[:, in_volume_mask])
798
+ tuple(
799
+ self.template_mask_coordinates_rotated[:, self.in_volume_mask].astype(
800
+ int
801
+ )
802
+ )
480
803
  ]
481
804
  denominator1 = np.subtract(
482
805
  np.sum(mask_target**2),
483
806
  np.divide(np.square(np.sum(mask_target)), mask_overlap),
484
807
  )
485
808
  mask_template = np.multiply(
486
- self.template_weights[in_volume],
487
- self.target_mask_density[tuple(transformed_coordinates[:, in_volume])],
809
+ self.template_weights[self.in_volume],
810
+ self.target_mask_density[
811
+ tuple(self.template_coordinates_rotated[:, self.in_volume].astype(int))
812
+ ],
488
813
  )
489
814
  denominator2 = np.subtract(
490
815
  np.sum(mask_template**2),
@@ -496,8 +821,10 @@ class MaskedCrossCorrelation(MatchCoordinatesToDensity):
496
821
  denominator = np.sqrt(np.multiply(denominator1, denominator2))
497
822
 
498
823
  numerator = np.dot(
499
- self.target_density[tuple(transformed_coordinates[:, in_volume])],
500
- self.template_weights[in_volume],
824
+ self.target_density[
825
+ tuple(self.template_coordinates_rotated[:, self.in_volume].astype(int))
826
+ ],
827
+ self.template_weights[self.in_volume],
501
828
  )
502
829
 
503
830
  numerator -= np.divide(
@@ -505,16 +832,14 @@ class MaskedCrossCorrelation(MatchCoordinatesToDensity):
505
832
  )
506
833
 
507
834
  if denominator == 0:
508
- return 0
835
+ return 0.0
509
836
 
510
837
  score = numerator / denominator
511
- return score
838
+ return float(score * self.score_sign)
512
839
 
513
840
 
514
- class PartialLeastSquareDifference(MatchCoordinatesToDensity):
841
+ class PartialLeastSquareDifference(_MatchCoordinatesToDensity):
515
842
  """
516
- Class representing the Partial Least Square Difference matching score.
517
-
518
843
  The Partial Least Square Difference (PLSQ) between the target :math:`f` and the
519
844
  template :math:`g` is calculated as:
520
845
 
@@ -529,109 +854,32 @@ class PartialLeastSquareDifference(MatchCoordinatesToDensity):
529
854
  pp. 333--343, 2011. DOI: https://doi.org/10.1016/j.jsb.2011.01.012
530
855
  """
531
856
 
532
- def __init__(self, **kwargs):
533
- super().__init__(**kwargs)
534
-
535
- def scoring_function(
536
- self,
537
- transformed_coordinates: NDArray,
538
- transformed_coordinates_mask: NDArray,
539
- in_volume: NDArray,
540
- in_volume_mask: NDArray,
541
- ) -> float:
542
- """
543
- Compute the Partial Least Square Difference score.
544
-
545
- Given the transformed coordinates and their associated mask, this function
546
- computes the difference between target and template densities.
857
+ __doc__ += _MatchCoordinatesToDensity.__doc__
547
858
 
548
- Parameters
549
- ----------
550
- transformed_coordinates : NDArray
551
- Transformed coordinates.
552
- transformed_coordinates_mask : NDArray
553
- Mask for the transformed coordinates.
554
- in_volume : NDArray
555
- Binary mask indicating which ``transformed_coordinates`` are in the
556
- target volume.
557
- in_volume_mask : NDArray
558
- Binary mask indicating which ``transformed_coordinates`` are in the
559
- target mask volume.
560
-
561
- Returns
562
- -------
563
- float
564
- The negative of the Partial Least Square Difference score.
565
- """
859
+ def __call__(self) -> float:
860
+ """Returns the score of the current configuration."""
566
861
  score = np.sum(
567
862
  np.square(
568
863
  np.subtract(
569
- self.target_density[tuple(transformed_coordinates[:, in_volume])],
570
- self.template_weights[in_volume],
864
+ self.target_density[
865
+ tuple(
866
+ self.template_coordinates_rotated[:, self.in_volume].astype(
867
+ int
868
+ )
869
+ )
870
+ ],
871
+ self.template_weights[self.in_volume],
571
872
  )
572
873
  )
573
874
  )
574
- score += np.sum(np.square(self.template_weights[np.invert(in_volume)]))
575
-
576
- return -score
577
-
578
-
579
- class Chamfer(MatchCoordinatesToCoordinates):
580
- """
581
- Class representing the Chamfer matching score.
582
-
583
- The Chamfer distance is computed as:
584
-
585
- .. math::
586
-
587
- \\text{d(f,g)} = \\frac{1}{|X|} \\sum_{\\mathbf{f}_i \\in X}
588
- \\inf_{\\mathbf{g} \\in Y} ||\\mathbf{f}_i - \\mathbf{g}||_2
875
+ score += np.sum(np.square(self.template_weights[np.invert(self.in_volume)]))
876
+ return score * self.score_sign
589
877
 
590
- References
591
- ----------
592
- .. [1] Daven Vasishtan and Maya Topf, "Scoring functions for cryoEM density
593
- fitting", Journal of Structural Biology, vol. 174, no. 2,
594
- pp. 333--343, 2011. DOI: https://doi.org/10.1016/j.jsb.2011.01.012
595
- """
596
878
 
597
- def __init__(self, **kwargs):
598
- super().__init__(**kwargs)
599
- self.target_tree = KDTree(self.target_coordinates.T)
600
-
601
- def scoring_function(
602
- self,
603
- transformed_coordinates: NDArray,
604
- transformed_coordinates_mask: NDArray,
605
- **kwargs,
606
- ) -> float:
607
- """
608
- Compute the Chamfer distance score.
609
-
610
- Given the transformed coordinates and their associated mask, this function
611
- calculates the average distance between the rotated template coordinates
612
- and the nearest target coordinates.
613
-
614
- Parameters
615
- ----------
616
- transformed_coordinates : NDArray
617
- Transformed coordinates.
618
-
619
- Returns
620
- -------
621
- float
622
- The negative of the Chamfer distance score.
623
-
624
- """
625
- dist, _ = self.target_tree.query(self.template_coordinates_rotated.T)
626
- score = np.mean(dist)
627
- return -score
628
-
629
-
630
- class MutualInformation(MatchCoordinatesToDensity):
879
+ class MutualInformation(_MatchCoordinatesToDensity):
631
880
  """
632
- Class representing the Mutual Information matching score.
633
-
634
- The Mutual Information (MI) score is calculated as:
881
+ The Mutual Information (MI) score between the target :math:`f` and the
882
+ template :math:`g` is calculated as:
635
883
 
636
884
  .. math::
637
885
 
@@ -645,43 +893,15 @@ class MutualInformation(MatchCoordinatesToDensity):
645
893
 
646
894
  """
647
895
 
648
- def __init__(self, **kwargs):
649
- super().__init__(**kwargs)
650
-
651
- def scoring_function(
652
- self,
653
- transformed_coordinates: NDArray,
654
- transformed_coordinates_mask: NDArray,
655
- in_volume: NDArray,
656
- in_volume_mask: NDArray,
657
- ) -> float:
658
- """
659
- Compute the Mutual Information score.
660
-
661
- Given the transformed coordinates and their associated mask, this function
662
- computes the mutual information between the target and template densities.
896
+ __doc__ += _MatchCoordinatesToDensity.__doc__
663
897
 
664
- Parameters
665
- ----------
666
- transformed_coordinates : NDArray
667
- Transformed coordinates.
668
- transformed_coordinates_mask : NDArray
669
- Mask for the transformed coordinates.
670
- in_volume : NDArray
671
- Binary mask indicating which ``transformed_coordinates`` are in the
672
- target volume.
673
- in_volume_mask : NDArray
674
- Binary mask indicating which ``transformed_coordinates`` are in the
675
- target mask volume.
676
-
677
- Returns
678
- -------
679
- float
680
- The Mutual Information score.
681
- """
898
+ def __call__(self) -> float:
899
+ """Returns the score of the current configuration."""
682
900
  p_xy, target, template = np.histogram2d(
683
- self.target_density[tuple(transformed_coordinates[:, in_volume])],
684
- self.template_weights[in_volume],
901
+ self.target_density[
902
+ tuple(self.template_coordinates_rotated[:, self.in_volume].astype(int))
903
+ ],
904
+ self.template_weights[self.in_volume],
685
905
  )
686
906
  p_x, p_y = np.sum(p_xy, axis=1), np.sum(p_xy, axis=0)
687
907
 
@@ -692,14 +912,13 @@ class MutualInformation(MatchCoordinatesToDensity):
692
912
  logprob = np.divide(p_xy, p_x[:, None] * p_y[None, :] + np.finfo(float).eps)
693
913
  score = np.nansum(p_xy * logprob)
694
914
 
695
- return score
915
+ return score * self.score_sign
696
916
 
697
917
 
698
- class Envelope(MatchCoordinatesToDensity):
918
+ class Envelope(_MatchCoordinatesToDensity):
699
919
  """
700
- Class representing the Envelope matching score.
701
-
702
- The Envelope score (ENV) is calculated as:
920
+ The Envelope score (ENV) between the target :math:`f` and the
921
+ template :math:`g` is calculated as:
703
922
 
704
923
  .. math::
705
924
 
@@ -713,59 +932,64 @@ class Envelope(MatchCoordinatesToDensity):
713
932
  pp. 333--343, 2011. DOI: https://doi.org/10.1016/j.jsb.2011.01.012
714
933
  """
715
934
 
716
- def __init__(self, target_threshold: float, **kwargs):
935
+ __doc__ += _MatchCoordinatesToDensity.__doc__
936
+
937
+ def __init__(self, target_threshold: float = None, **kwargs):
717
938
  super().__init__(**kwargs)
939
+ if target_threshold is None:
940
+ target_threshold = np.mean(self.target_density)
718
941
  self.target_density = np.where(self.target_density > target_threshold, -1, 1)
719
942
  self.target_density_present = np.sum(self.target_density == -1)
720
943
  self.target_density_absent = np.sum(self.target_density == 1)
721
944
  self.template_weights = np.ones_like(self.template_weights)
722
945
 
723
- def scoring_function(
724
- self,
725
- transformed_coordinates: NDArray,
726
- transformed_coordinates_mask: NDArray,
727
- in_volume: NDArray,
728
- in_volume_mask: NDArray,
729
- ) -> float:
730
- """
731
- Compute the Envelope score.
732
-
733
- Given the transformed coordinates and their associated mask, this function
734
- computes the envelope score based on target density thresholds.
735
-
736
- Parameters
737
- ----------
738
- transformed_coordinates : NDArray
739
- Transformed coordinates.
740
- transformed_coordinates_mask : NDArray
741
- Mask for the transformed coordinates.
742
- in_volume : NDArray
743
- Binary mask indicating which ``transformed_coordinates`` are in the
744
- target volume.
745
- in_volume_mask : NDArray
746
- Binary mask indicating which ``transformed_coordinates`` are in the
747
- target mask volume.
748
-
749
- Returns
750
- -------
751
- float
752
- The Envelope score.
753
- """
754
- score = self.target_density[tuple(transformed_coordinates[:, in_volume])]
946
+ def __call__(self) -> float:
947
+ """Returns the score of the current configuration."""
948
+ score = self.target_density[
949
+ tuple(self.template_coordinates_rotated[:, self.in_volume].astype(int))
950
+ ]
755
951
  unassigned_density = self.target_density_present - (score == -1).sum()
756
952
 
757
- score = score.sum() - unassigned_density - 2 * np.sum(np.invert(in_volume))
953
+ score = score.sum() - unassigned_density - 2 * np.sum(np.invert(self.in_volume))
758
954
  min_score = -self.target_density_present - 2 * self.target_density_absent
759
955
  score = (score - 2 * min_score) / (2 * self.target_density_present - min_score)
760
956
 
761
- return score
957
+ return score * self.score_sign
958
+
959
+
960
+ class Chamfer(_MatchCoordinatesToCoordinates):
961
+ """
962
+ The Chamfer distance between the target :math:`f` and the template :math:`g`
963
+ is calculated as:
964
+
965
+ .. math::
762
966
 
967
+ \\text{d(f,g)} = \\frac{1}{|X|} \\sum_{\\mathbf{f}_i \\in X}
968
+ \\inf_{\\mathbf{g} \\in Y} ||\\mathbf{f}_i - \\mathbf{g}||_2
763
969
 
764
- class NormalVectorScore(MatchCoordinatesToCoordinates):
970
+ References
971
+ ----------
972
+ .. [1] Daven Vasishtan and Maya Topf, "Scoring functions for cryoEM density
973
+ fitting", Journal of Structural Biology, vol. 174, no. 2,
974
+ pp. 333--343, 2011. DOI: https://doi.org/10.1016/j.jsb.2011.01.012
765
975
  """
766
- Class representing the Normal Vector matching score.
767
976
 
768
- The Normal Vector Score (NVS) is calculated as:
977
+ __doc__ += _MatchCoordinatesToDensity.__doc__
978
+
979
+ def _post_init(self, **kwargs):
980
+ self.target_tree = KDTree(self.target_coordinates.T)
981
+
982
+ def __call__(self) -> float:
983
+ """Returns the score of the current configuration."""
984
+ dist, _ = self.target_tree.query(self.template_coordinates_rotated.T)
985
+ score = np.mean(dist)
986
+ return score * self.score_sign
987
+
988
+
989
+ class NormalVectorScore(_MatchCoordinatesToCoordinates):
990
+ """
991
+ The Normal Vector Score (NVS) between the target's :math:`f` and the template
992
+ :math:`g`'s normal vectors is calculated as:
769
993
 
770
994
  .. math::
771
995
 
@@ -784,35 +1008,14 @@ class NormalVectorScore(MatchCoordinatesToCoordinates):
784
1008
 
785
1009
  """
786
1010
 
787
- def __init__(self, **kwargs):
788
- super().__init__(**kwargs)
1011
+ __doc__ += _MatchCoordinatesToDensity.__doc__
789
1012
 
790
- def scoring_function(
791
- self,
792
- transformed_coordinates: NDArray,
793
- transformed_coordinates_mask: NDArray,
794
- **kwargs,
795
- ) -> float:
796
- """
797
- Compute the Normal Vector Score.
798
-
799
- Given the template and target vectors, this function computes the average
800
- cosine similarity between the two sets of vectors.
801
-
802
- Parameters
803
- ----------
804
- template_vectors : NDArray
805
- Normal vectors derived from the template.
806
- target_vectors : NDArray
807
- Normal vectors derived from the target.
808
-
809
- Returns
810
- -------
811
- float
812
- The Normal Vector Score.
813
- """
814
- numerator = np.multiply(transformed_coordinates, self.target_coordinates)
815
- denominator = np.linalg.norm(transformed_coordinates)
1013
+ def __call__(self) -> float:
1014
+ """Returns the score of the current configuration."""
1015
+ numerator = np.multiply(
1016
+ self.template_coordinates_rotated, self.target_coordinates
1017
+ )
1018
+ denominator = np.linalg.norm(self.template_coordinates_rotated)
816
1019
  denominator *= np.linalg.norm(self.target_coordinates)
817
1020
  score = np.mean(numerator / denominator)
818
1021
  return score
@@ -829,12 +1032,13 @@ MATCHING_OPTIMIZATION_REGISTER = {
829
1032
  "Chamfer": Chamfer,
830
1033
  "MutualInformation": MutualInformation,
831
1034
  "NormalVectorScore": NormalVectorScore,
1035
+ "FLC": FLC,
832
1036
  }
833
1037
 
834
1038
 
835
1039
  def register_matching_optimization(match_name: str, match_class: type):
836
1040
  """
837
- Registers a class to be used by :py:class:`FitRefinement`.
1041
+ Registers a new mtaching method.
838
1042
 
839
1043
  Parameters
840
1044
  ----------
@@ -848,7 +1052,7 @@ def register_matching_optimization(match_name: str, match_class: type):
848
1052
  ValueError
849
1053
  If any of the required methods is not defined.
850
1054
  """
851
- methods_to_check = ["__init__", "__call__", "scoring_function"]
1055
+ methods_to_check = ["__init__", "__call__"]
852
1056
 
853
1057
  for method in methods_to_check:
854
1058
  if not hasattr(match_class, method):
@@ -858,266 +1062,166 @@ def register_matching_optimization(match_name: str, match_class: type):
858
1062
  MATCHING_OPTIMIZATION_REGISTER[match_name] = match_class
859
1063
 
860
1064
 
861
- class FitRefinement:
862
- """
863
- A class to refine the fit between target and template coordinates.
864
-
865
- Notes
866
- -----
867
- By default scipy.optimize.differential_evolution or scipy.optimize.basinhopping
868
- are used which can be unreliable if the initial alignment is very poor. Other
869
- optimizers can be implemented by subclassing :py:class:`FitRefinement` and
870
- overwriting the :py:meth:`FitRefinement.refine` function.
871
-
1065
+ def create_score_object(score: str, **kwargs) -> object:
872
1066
  """
1067
+ Initialize score object with name ``score`` using `**kwargs``.
873
1068
 
874
- @staticmethod
875
- def map_coordinates_to_array(
876
- coordinates: NDArray,
877
- array_shape: NDArray,
878
- array_origin: NDArray,
879
- sampling_rate: NDArray,
880
- coordinates_mask: NDArray = None,
881
- ) -> Tuple[NDArray, NDArray]:
882
- """
883
- Map coordinates to a volume based on given voxel size and origin.
1069
+ Parameters
1070
+ ----------
1071
+ score: str
1072
+ Name of the score.
1073
+ **kwargs: Dict
1074
+ Keyword arguments passed to the __init__ method of the score object.
884
1075
 
885
- Parameters
886
- ----------
887
- coordinates : NDArray
888
- An array representing the coordinates to be mapped [d x N].
889
- array_shape : NDArray
890
- The shape of the array to which the coordinates are mapped.
891
- array_origin : NDArray
892
- The origin of the array to which the coordinates are mapped.
893
- sampling_rate : NDArray
894
- The size of a voxel in the array.
895
- coordinates_mask : NDArray, optional
896
- An array representing the mask for the coordinates [d x T].
1076
+ Returns
1077
+ -------
1078
+ object
1079
+ Initialized score object.
897
1080
 
898
- Returns
899
- -------
900
- tuple
901
- Returns transformed coordinates, transformed coordinates mask,
902
- mask for in_volume points, and mask for in_volume points in mask.
903
- """
904
- coordinates = coordinates.astype(sampling_rate.dtype)
905
- np.divide(
906
- coordinates - array_origin[:, None], sampling_rate[:, None], out=coordinates
907
- )
908
- transformed_coordinates = coordinates.astype(int)
909
- in_volume = np.logical_and(
910
- transformed_coordinates < np.array(array_shape)[:, None],
911
- transformed_coordinates >= 0,
912
- ).min(axis=0)
1081
+ Raises
1082
+ ------
1083
+ ValueError
1084
+ If ``score`` is not a key in MATCHING_OPTIMIZATION_REGISTER.
913
1085
 
914
- transformed_coordinates_mask, in_volume_mask = None, None
1086
+ See Also
1087
+ --------
1088
+ :py:meth:`register_matching_optimization`
1089
+ """
915
1090
 
916
- if coordinates_mask is not None:
917
- coordinates_mask = coordinates_mask.astype(sampling_rate.dtype)
918
- np.divide(
919
- coordinates_mask - array_origin[:, None],
920
- sampling_rate[:, None],
921
- out=coordinates_mask,
922
- )
923
- transformed_coordinates_mask = coordinates_mask.astype(int)
924
- in_volume_mask = np.logical_and(
925
- transformed_coordinates_mask < np.array(array_shape)[:, None],
926
- transformed_coordinates_mask >= 0,
927
- ).min(axis=0)
1091
+ score_object = MATCHING_OPTIMIZATION_REGISTER.get(score, None)
928
1092
 
929
- return (
930
- transformed_coordinates,
931
- transformed_coordinates_mask,
932
- in_volume,
933
- in_volume_mask,
1093
+ if score_object is None:
1094
+ raise ValueError(
1095
+ f"{score} is not defined. Please pick from "
1096
+ f" {', '.join(list(MATCHING_OPTIMIZATION_REGISTER.keys()))}."
934
1097
  )
935
1098
 
936
- @staticmethod
937
- def array_from_coordinates(
938
- coordinates: NDArray,
939
- weights: NDArray,
940
- sampling_rate: NDArray,
941
- origin: NDArray = None,
942
- shape: NDArray = None,
943
- ) -> Tuple[NDArray, NDArray, NDArray]:
944
- """
945
- Create a volume from coordinates, using given weights and voxel size.
946
-
947
- Parameters
948
- ----------
949
- coordinates : NDArray
950
- An array representing the coordinates [d x N].
951
- weights : NDArray
952
- An array representing the weights for each coordinate [N].
953
- sampling_rate : NDArray
954
- The size of a voxel in the volume.
955
- origin : NDArray, optional
956
- The origin of the volume.
957
- shape : NDArray, optional
958
- The shape of the volume.
959
-
960
- Returns
961
- -------
962
- tuple
963
- Returns the generated volume, positions of coordinates, and origin.
964
- """
965
- if origin is None:
966
- origin = coordinates.min(axis=1)
967
-
968
- positions = np.divide(coordinates - origin[:, None], sampling_rate[:, None])
969
- positions = positions.astype(int)
970
-
971
- if shape is None:
972
- shape = positions.max(axis=1) + 1
973
-
974
- arr = np.zeros(shape, dtype=np.float32)
975
- np.add.at(arr, tuple(positions), weights)
976
- return arr, positions, origin
977
-
978
- def refine(
979
- self,
980
- target_coordinates: NDArray,
981
- target_weights: NDArray,
982
- template_coordinates: NDArray,
983
- template_weights: NDArray,
984
- sampling_rate: float = None,
985
- translational_uncertainty: Tuple[float] = None,
986
- rotational_uncertainty: Tuple[float] = None,
987
- scoring_class: str = "CrossCorrelation",
988
- scoring_class_parameters: Dict = dict(),
989
- local_optimization: bool = True,
990
- maxiter: int = 100,
991
- ) -> (NDArray, NDArray):
992
- """
993
- Refines the alignment of template coordinates to target coordinates.
994
-
995
- Parameters
996
- ----------
997
- target_coordinates : NDArray
998
- The coordinates of the target.
999
-
1000
- target_weights : NDArray
1001
- The weights of the target.
1099
+ score_object = score_object(**kwargs)
1100
+ return score_object
1002
1101
 
1003
- template_coordinates : NDArray
1004
- The coordinates of the template.
1005
1102
 
1006
- template_weights : NDArray
1007
- The weights of the template.
1103
+ def optimize_match(
1104
+ score_object: object,
1105
+ bounds_translation: Tuple[Tuple[float]] = None,
1106
+ bounds_rotation: Tuple[Tuple[float]] = None,
1107
+ optimization_method: str = "basinhopping",
1108
+ maxiter: int = 500,
1109
+ ) -> Tuple[ArrayLike, ArrayLike, float]:
1110
+ """
1111
+ Find the translation and rotation optimizing the score returned by `score_object`
1112
+ with respect to provided bounds.
1008
1113
 
1009
- sampling_rate : float, optional
1010
- The size of the voxel. Default is None.
1114
+ Parameters
1115
+ ----------
1116
+ score_object: object
1117
+ Class object that defines a score method, which returns a floating point
1118
+ value given a tuple of floating points where the first half describes a
1119
+ translation and the second a rotation. The score will be minimized, i.e.
1120
+ it has to be negated if similarity should be optimized.
1121
+ bounds_translation : tuple of tuple float, optional
1122
+ Bounds on the evaluated translations. Has to be specified per dimension
1123
+ as tuple of (min, max). Default is None.
1124
+ bounds_rotation : tuple of tuple float, optional
1125
+ Bounds on the evaluated zyx Euler angles. Has to be specified per dimension
1126
+ as tuple of (min, max). Default is None.
1127
+ optimization_method : str, optional
1128
+ Optimizer that will be used, by default basinhopping. For further
1129
+ information refer to :doc:`scipy:reference/optimize`.
1130
+
1131
+ +--------------------------+-----------------------------------------+
1132
+ | 'differential_evolution' | Highest accuracy but long runtime. |
1133
+ | | Requires bounds on translation. |
1134
+ +--------------------------+-----------------------------------------+
1135
+ | 'basinhopping' | Decent accuracy, medium runtime. |
1136
+ +--------------------------+-----------------------------------------+
1137
+ | 'minimize' | If initial values are closed to optimum |
1138
+ | | decent performance, short runtime. |
1139
+ +--------------------------+-----------------------------------------+
1140
+ maxiter : int, optional
1141
+ The maximum number of iterations. Default is 500. Not considered for
1142
+ `optimization_method` 'minimize'.
1143
+
1144
+ Returns
1145
+ -------
1146
+ Tuple[ArrayLike, ArrayLike, float]
1147
+ Translation and rotation matrix yielding final score.
1011
1148
 
1012
- translational_uncertainty : (float,), optional
1013
- The translational uncertainty. Default is None.
1149
+ Raises
1150
+ ------
1151
+ ValueError
1152
+ If `optimization_method` is not supported.
1014
1153
 
1015
- rotational_uncertainty : (float,), optional
1016
- The rotational uncertainty. Default is None.
1154
+ Notes
1155
+ -----
1156
+ This function currently only supports three-dimensional optimization and
1157
+ `score_object` will be modified during this operation.
1158
+ """
1159
+ ndim = 3
1160
+ _optimization_method = {
1161
+ "differential_evolution": differential_evolution,
1162
+ "basinhopping": basinhopping,
1163
+ "minimize": minimize,
1164
+ }
1165
+ if optimization_method not in _optimization_method:
1166
+ raise ValueError(
1167
+ f"{optimization_method} is not supported. "
1168
+ f"Pick from {', '.join(list(_optimization_method.keys()))}"
1169
+ )
1017
1170
 
1018
- scoring_class : str, optional
1019
- The scoring class to be used. Default is "CC".
1171
+ finfo = np.finfo(np.float32)
1020
1172
 
1021
- scoring_class_parameters : dict, optional
1022
- The parameters for the scoring class. Default is an empty dictionary.
1173
+ # DE always requires bounds
1174
+ if optimization_method == "differential_evolution" and bounds_translation is None:
1175
+ bounds_translation = tuple((finfo.min, finfo.max) for _ in range(ndim))
1023
1176
 
1024
- local_optimization : bool, optional
1025
- Whether to use local optimization. Default is True.
1177
+ if bounds_translation is None and bounds_rotation is not None:
1178
+ bounds_translation = tuple((finfo.min, finfo.max) for _ in range(ndim))
1026
1179
 
1027
- maxiter : int, optional
1028
- The maximum number of iterations. Default is 100.
1180
+ if bounds_rotation is None and bounds_translation is not None:
1181
+ bounds_rotation = tuple((-180, 180) for _ in range(ndim))
1029
1182
 
1030
- Returns
1031
- -------
1032
- tuple
1033
- A tuple containing the translation and rotation matrix of the refinement,
1034
- as well as the score of the refinement.
1035
-
1036
- Raises
1037
- ------
1038
- NotNotImplementedError
1039
- If scoring class is not a part of `MATCHING_OPTIMIZATION_REGISTER`.
1040
- Individual scores can be added via
1041
- :py:meth:`register_matching_optimization`.
1042
-
1043
- See Also
1044
- --------
1045
- :py:meth:`register_matching_optimization`
1046
- """
1047
- if scoring_class not in MATCHING_OPTIMIZATION_REGISTER:
1048
- raise NotImplementedError(
1049
- f"Parameter score has to be one of "
1050
- f"{', '.join(MATCHING_OPTIMIZATION_REGISTER.keys())}."
1051
- )
1052
- scoring_class = MATCHING_OPTIMIZATION_REGISTER.get(scoring_class, None)
1053
-
1054
- if sampling_rate is None:
1055
- sampling_rate = np.ones(1)
1056
- sampling_rate = np.repeat(
1057
- sampling_rate, target_coordinates.shape[0] // sampling_rate.size
1183
+ bounds, linear_constraint = None, ()
1184
+ if bounds_rotation is not None and bounds_translation is not None:
1185
+ uncertainty = (*bounds_translation, *bounds_rotation)
1186
+ bounds = [
1187
+ bound if bound != (0, 0) else (-finfo.resolution, finfo.resolution)
1188
+ for bound in uncertainty
1189
+ ]
1190
+ linear_constraint = LinearConstraint(
1191
+ np.eye(len(bounds)), np.min(bounds, axis=1), np.max(bounds, axis=1)
1058
1192
  )
1059
1193
 
1060
- score = scoring_class(
1061
- target_coordinates=target_coordinates,
1062
- template_coordinates=template_coordinates,
1063
- target_weights=target_weights,
1064
- template_weights=template_weights,
1065
- sampling_rate=sampling_rate,
1066
- **scoring_class_parameters,
1194
+ initial_score = score_object()
1195
+ if optimization_method == "basinhopping":
1196
+ result = basinhopping(
1197
+ x0=np.zeros(2 * ndim),
1198
+ func=score_object.score,
1199
+ niter=maxiter,
1200
+ minimizer_kwargs={"method": "COBYLA", "constraints": linear_constraint},
1067
1201
  )
1068
-
1069
- initial_score = score(np.zeros(6))
1070
-
1071
- mass_center_target = np.dot(target_coordinates, target_weights)
1072
- mass_center_target /= target_weights.sum()
1073
- mass_center_template = np.dot(template_coordinates, template_weights)
1074
- mass_center_template /= template_weights.sum()
1075
-
1076
- if translational_uncertainty is None:
1077
- mass_center_difference = np.ceil(
1078
- np.subtract(mass_center_target, mass_center_template)
1079
- ).astype(int)
1080
- target_range = np.ceil(
1081
- np.divide(
1082
- np.subtract(
1083
- target_coordinates.max(axis=1), target_coordinates.min(axis=1)
1084
- ),
1085
- 2,
1086
- )
1087
- ).astype(int)
1088
- translational_uncertainty = tuple(
1089
- (center - start, center + start)
1090
- for center, start in zip(mass_center_difference, target_range)
1091
- )
1092
- if rotational_uncertainty is None:
1093
- rotational_uncertainty = tuple(
1094
- (-90, 90) for _ in range(target_coordinates.shape[0])
1095
- )
1096
-
1097
- uncertainty = (*translational_uncertainty, *rotational_uncertainty)
1098
- bounds = [bound if bound != (0, 0) else (-1e-9, 1e-9) for bound in uncertainty]
1099
- linear_constraint = LinearConstraint(
1100
- np.eye(len(bounds)), np.min(bounds, axis=1), np.max(bounds, axis=1)
1202
+ elif optimization_method == "differential_evolution":
1203
+ result = differential_evolution(
1204
+ func=score_object.score,
1205
+ bounds=bounds,
1206
+ constraints=linear_constraint,
1207
+ maxiter=maxiter,
1101
1208
  )
1209
+ elif optimization_method == "minimize":
1210
+ result = minimize(
1211
+ x0=np.zeros(2 * ndim),
1212
+ fun=score_object.score,
1213
+ bounds=bounds,
1214
+ constraints=linear_constraint,
1215
+ )
1216
+ print(f"Niter: {result.nit}, success : {result.success} ({result.message}).")
1217
+ print(f"Initial score: {initial_score} - Refined score: {result.fun}")
1218
+ if initial_score < result.fun:
1219
+ print("Initial score better than refined score. Returning identity.")
1220
+ result.x = np.zeros_like(result.x)
1221
+ translation, rotation = result.x[:ndim], result.x[ndim:]
1222
+ rotation_matrix = euler_to_rotationmatrix(rotation)
1223
+ return translation, rotation_matrix, result.fun
1102
1224
 
1103
- if local_optimization:
1104
- result = basinhopping(
1105
- x0=np.zeros(6),
1106
- func=score,
1107
- niter=maxiter,
1108
- minimizer_kwargs={"method": "COBYLA", "constraints": linear_constraint},
1109
- )
1110
- else:
1111
- result = differential_evolution(
1112
- func=score,
1113
- bounds=bounds,
1114
- constraints=linear_constraint,
1115
- maxiter=maxiter,
1116
- )
1117
1225
 
1118
- print(f"Initial score: {-initial_score} - Refined score: {-result.fun}")
1119
- if initial_score < result.fun:
1120
- result.x = np.zeros_like(result.x)
1121
- translation, rotation = result.x[:3], result.x[3:]
1122
- rotation_matrix = euler_to_rotationmatrix(rotation)
1123
- return translation, rotation_matrix, -result.fun
1226
+ class FitRefinement:
1227
+ pass