pytme 0.1.9__cp311-cp311-macosx_14_0_arm64.whl → 0.2.0b0__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 (36) hide show
  1. {pytme-0.1.9.data → pytme-0.2.0b0.data}/scripts/match_template.py +148 -126
  2. pytme-0.2.0b0.data/scripts/postprocess.py +570 -0
  3. {pytme-0.1.9.data → pytme-0.2.0b0.data}/scripts/preprocessor_gui.py +244 -60
  4. {pytme-0.1.9.dist-info → pytme-0.2.0b0.dist-info}/METADATA +3 -1
  5. pytme-0.2.0b0.dist-info/RECORD +66 -0
  6. {pytme-0.1.9.dist-info → pytme-0.2.0b0.dist-info}/WHEEL +1 -1
  7. scripts/extract_candidates.py +218 -0
  8. scripts/match_template.py +148 -126
  9. scripts/match_template_filters.py +852 -0
  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 +545 -78
  16. tme/backends/cupy_backend.py +80 -15
  17. tme/backends/npfftw_backend.py +33 -2
  18. tme/backends/pytorch_backend.py +15 -7
  19. tme/density.py +156 -63
  20. tme/extensions.cpython-311-darwin.so +0 -0
  21. tme/matching_constrained.py +195 -0
  22. tme/matching_data.py +74 -33
  23. tme/matching_exhaustive.py +351 -208
  24. tme/matching_memory.py +1 -0
  25. tme/matching_optimization.py +728 -651
  26. tme/matching_utils.py +152 -8
  27. tme/orientations.py +561 -0
  28. tme/preprocessor.py +21 -18
  29. tme/structure.py +2 -37
  30. pytme-0.1.9.data/scripts/postprocess.py +0 -625
  31. pytme-0.1.9.dist-info/RECORD +0 -61
  32. {pytme-0.1.9.data → pytme-0.2.0b0.data}/scripts/estimate_ram_usage.py +0 -0
  33. {pytme-0.1.9.data → pytme-0.2.0b0.data}/scripts/preprocess.py +0 -0
  34. {pytme-0.1.9.dist-info → pytme-0.2.0b0.dist-info}/LICENSE +0 -0
  35. {pytme-0.1.9.dist-info → pytme-0.2.0b0.dist-info}/entry_points.txt +0 -0
  36. {pytme-0.1.9.dist-info → pytme-0.2.0b0.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.
138
349
 
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.
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.
362
+
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
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
148
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
196
486
 
197
- def __call__(self, x: NDArray):
487
+ if hasattr(self, "_post_init"):
488
+ self._post_init(**kwargs)
489
+
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,134 @@ 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
+ __doc__ += _MatchDensityToDensity.__doc__
238
525
 
526
+ def _post_init(self, **kwargs: Dict):
527
+ if self.target_mask is not None:
528
+ backend.multiply(self.target, self.target_mask, out=self.target)
239
529
 
240
- class CrossCorrelation(MatchCoordinatesToDensity):
241
- """
242
- Class representing the Cross-Correlation matching score.
530
+ self.target_square = backend.square(self.target)
531
+
532
+ normalize_under_mask(
533
+ template=self.template,
534
+ mask=self.template_mask,
535
+ mask_intensity=backend.sum(self.template_mask),
536
+ )
243
537
 
244
- Cross-Correlation score formula:
538
+ self.template = backend.reverse(self.template)
539
+ self.template_mask = backend.reverse(self.template_mask)
245
540
 
246
- .. math::
541
+ def __call__(self) -> float:
542
+ """Returns the score of the current configuration."""
543
+ n_observations = backend.sum(self.template_mask_rot)
247
544
 
248
- \\text{score} = \\text{target_weights} \\cdot \\text{template_weights}
545
+ normalize_under_mask(
546
+ template=self.template_rot,
547
+ mask=self.template_mask_rot,
548
+ mask_intensity=n_observations,
549
+ )
249
550
 
551
+ ex2 = backend.sum(
552
+ backend.divide(
553
+ backend.sum(
554
+ backend.multiply(self.target_square, self.template_mask_rot),
555
+ ),
556
+ n_observations,
557
+ )
558
+ )
559
+ e2x = backend.square(
560
+ backend.divide(
561
+ backend.sum(backend.multiply(self.target, self.template_mask_rot)),
562
+ n_observations,
563
+ )
564
+ )
565
+
566
+ denominator = backend.maximum(backend.subtract(ex2, e2x), 0.0)
567
+ denominator = backend.sqrt(denominator)
568
+ denominator = backend.multiply(denominator, n_observations)
569
+
570
+ overlap = backend.sum(backend.multiply(self.template_rot, self.target))
571
+
572
+ score = backend.divide(overlap, denominator) * self.score_sign
573
+ return score
574
+
575
+
576
+ class CrossCorrelation(_MatchCoordinatesToDensity):
250
577
  """
578
+ Computes the Cross-Correlation score as:
251
579
 
252
- def __init__(self, **kwargs):
253
- super().__init__(**kwargs)
254
- self.denominator = 1
580
+ .. math::
255
581
 
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.
582
+ \\text{score} = \\text{target_weights} \\cdot \\text{template_weights}
583
+ """
265
584
 
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.
585
+ __doc__ += _MatchCoordinatesToDensity.__doc__
586
+
587
+ def __call__(self) -> float:
588
+ """Returns the score of the current configuration."""
589
+ try:
590
+ score = np.dot(
591
+ self.target_density[
592
+ tuple(
593
+ self.template_coordinates_rotated[:, self.in_volume].astype(int)
594
+ )
595
+ ],
596
+ self.template_weights[self.in_volume],
597
+ )
598
+ except:
599
+ print(self.template_coordinates_rotated[:, self.in_volume].astype(int))
600
+ print(self.target_density.shape)
601
+ print(self.in_volume)
602
+ coordinates = self.template_coordinates_rotated[:, self.in_volume].astype(
603
+ int
604
+ )
605
+ in_volume = np.logical_and(
606
+ coordinates < np.array(self.target_density.shape)[:, None],
607
+ coordinates >= 0,
608
+ ).min(axis=0)
609
+ print(in_volume)
278
610
 
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],
287
- )
611
+ raise ValueError()
288
612
  score /= self.denominator
289
- return score
613
+ return score * self.score_sign
290
614
 
291
615
 
292
616
  class LaplaceCrossCorrelation(CrossCorrelation):
293
617
  """
294
- Class representing the Laplace Cross-Correlation matching score.
295
-
296
- The score is computed like CrossCorrelation, but with Laplace filtered
297
- weights, indicated by the Laplace operator :math:`\\nabla^{2}`.
618
+ Uses the same formalism as :py:class:`CrossCorrelation` but with Laplace
619
+ filtered weights (:math:`\\nabla^{2}`):
298
620
 
299
621
  .. math::
300
622
 
301
623
  \\text{score} = \\nabla^{2} \\text{target_weights} \\cdot
302
624
  \\nabla^{2} \\text{template_weights}
303
-
304
625
  """
305
626
 
306
- def __init__(self, **kwargs):
307
- super().__init__(**kwargs)
627
+ __doc__ += _MatchCoordinatesToDensity.__doc__
628
+
629
+ def _post_init(self, **kwargs):
308
630
  self.target_density = laplace(self.target_density)
309
631
 
310
- arr, positions, _ = FitRefinement.array_from_coordinates(
311
- self.template_coordinates, self.template_weights, self.sampling_rate
632
+ arr, positions, _ = self.array_from_coordinates(
633
+ self.template_coordinates,
634
+ self.template_weights,
635
+ np.ones(self.template_coordinates.shape[0]),
312
636
  )
313
637
  self.template_weights = laplace(arr)[tuple(positions)]
314
638
 
315
639
 
316
640
  class NormalizedCrossCorrelation(CrossCorrelation):
317
641
  """
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.
642
+ Computes a normalized version of the :py:class:`CrossCorrelation` score based
643
+ on the dot product of `target_weights` and `template_weights`, in order to
644
+ reduce bias to regions of high local energy.
324
645
 
325
646
  .. math::
326
647
 
@@ -338,11 +659,11 @@ class NormalizedCrossCorrelation(CrossCorrelation):
338
659
  \\text{template_norm} = ||\\text{template_weights}||
339
660
 
340
661
  Here, :math:`||.||` denotes the L2 (Euclidean) norm.
341
-
342
662
  """
343
663
 
344
- def __init__(self, **kwargs):
345
- super().__init__(**kwargs)
664
+ __doc__ += _MatchCoordinatesToDensity.__doc__
665
+
666
+ def _post_init(self, **kwargs):
346
667
  target_norm = np.linalg.norm(self.target_density[self.target_density != 0])
347
668
  template_norm = np.linalg.norm(self.template_weights)
348
669
  self.denominator = np.fmax(target_norm * template_norm, np.finfo(float).eps)
@@ -350,14 +671,8 @@ class NormalizedCrossCorrelation(CrossCorrelation):
350
671
 
351
672
  class NormalizedCrossCorrelationMean(NormalizedCrossCorrelation):
352
673
  """
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:
674
+ Computes a similar score than :py:class:`NormalizedCrossCorrelation`, but
675
+ additionally factors in the mean of template and target.
361
676
 
362
677
  .. math::
363
678
 
@@ -381,21 +696,21 @@ class NormalizedCrossCorrelationMean(NormalizedCrossCorrelation):
381
696
  computes the mean of the respective weights.
382
697
  """
383
698
 
699
+ __doc__ += _MatchCoordinatesToDensity.__doc__
700
+
384
701
  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()
702
+ kwargs["target"] = np.subtract(kwargs["target"], kwargs["target"].mean())
703
+ kwargs["template_weights"] = np.subtract(
704
+ kwargs["template_weights"], kwargs["template_weights"].mean()
705
+ )
388
706
  super().__init__(**kwargs)
389
707
 
390
708
 
391
- class MaskedCrossCorrelation(MatchCoordinatesToDensity):
709
+ class MaskedCrossCorrelation(_MatchCoordinatesToDensity):
392
710
  """
393
- Class representing the Masked Cross-Correlation matching score.
394
-
395
711
  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.
712
+ and `template_weights` under respective masks. The score provides a measure of
713
+ similarity even in the presence of missing or masked data.
399
714
 
400
715
  The formula for the Masked Cross-Correlation is:
401
716
 
@@ -437,54 +752,37 @@ class MaskedCrossCorrelation(MatchCoordinatesToDensity):
437
752
  .. [1] Masked FFT registration, Dirk Padfield, CVPR 2010 conference
438
753
  """
439
754
 
440
- def __init__(self, **kwargs):
441
- super().__init__(**kwargs)
755
+ __doc__ += _MatchCoordinatesToDensity.__doc__
442
756
 
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.
465
-
466
- Returns
467
- -------
468
- float
469
- The Masked Cross-Correlation score.
470
- """
757
+ def __call__(self) -> float:
758
+ """Returns the score of the current configuration."""
471
759
  mask_overlap = np.sum(
472
760
  self.target_mask_density[
473
- tuple(transformed_coordinates_mask[:, in_volume_mask])
761
+ tuple(
762
+ self.template_mask_coordinates_rotated[
763
+ :, self.in_volume_mask
764
+ ].astype(int)
765
+ )
474
766
  ],
475
767
  )
476
768
  mask_overlap = np.fmax(mask_overlap, np.finfo(float).eps)
477
769
 
478
770
  mask_target = self.target_density[
479
- tuple(transformed_coordinates_mask[:, in_volume_mask])
771
+ tuple(
772
+ self.template_mask_coordinates_rotated[:, self.in_volume_mask].astype(
773
+ int
774
+ )
775
+ )
480
776
  ]
481
777
  denominator1 = np.subtract(
482
778
  np.sum(mask_target**2),
483
779
  np.divide(np.square(np.sum(mask_target)), mask_overlap),
484
780
  )
485
781
  mask_template = np.multiply(
486
- self.template_weights[in_volume],
487
- self.target_mask_density[tuple(transformed_coordinates[:, in_volume])],
782
+ self.template_weights[self.in_volume],
783
+ self.target_mask_density[
784
+ tuple(self.template_coordinates_rotated[:, self.in_volume].astype(int))
785
+ ],
488
786
  )
489
787
  denominator2 = np.subtract(
490
788
  np.sum(mask_template**2),
@@ -496,8 +794,10 @@ class MaskedCrossCorrelation(MatchCoordinatesToDensity):
496
794
  denominator = np.sqrt(np.multiply(denominator1, denominator2))
497
795
 
498
796
  numerator = np.dot(
499
- self.target_density[tuple(transformed_coordinates[:, in_volume])],
500
- self.template_weights[in_volume],
797
+ self.target_density[
798
+ tuple(self.template_coordinates_rotated[:, self.in_volume].astype(int))
799
+ ],
800
+ self.template_weights[self.in_volume],
501
801
  )
502
802
 
503
803
  numerator -= np.divide(
@@ -505,16 +805,14 @@ class MaskedCrossCorrelation(MatchCoordinatesToDensity):
505
805
  )
506
806
 
507
807
  if denominator == 0:
508
- return 0
808
+ return 0.0
509
809
 
510
810
  score = numerator / denominator
511
- return score
811
+ return float(score * self.score_sign)
512
812
 
513
813
 
514
- class PartialLeastSquareDifference(MatchCoordinatesToDensity):
814
+ class PartialLeastSquareDifference(_MatchCoordinatesToDensity):
515
815
  """
516
- Class representing the Partial Least Square Difference matching score.
517
-
518
816
  The Partial Least Square Difference (PLSQ) between the target :math:`f` and the
519
817
  template :math:`g` is calculated as:
520
818
 
@@ -529,109 +827,32 @@ class PartialLeastSquareDifference(MatchCoordinatesToDensity):
529
827
  pp. 333--343, 2011. DOI: https://doi.org/10.1016/j.jsb.2011.01.012
530
828
  """
531
829
 
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.
547
-
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.
830
+ __doc__ += _MatchCoordinatesToDensity.__doc__
560
831
 
561
- Returns
562
- -------
563
- float
564
- The negative of the Partial Least Square Difference score.
565
- """
832
+ def __call__(self) -> float:
833
+ """Returns the score of the current configuration."""
566
834
  score = np.sum(
567
835
  np.square(
568
836
  np.subtract(
569
- self.target_density[tuple(transformed_coordinates[:, in_volume])],
570
- self.template_weights[in_volume],
837
+ self.target_density[
838
+ tuple(
839
+ self.template_coordinates_rotated[:, self.in_volume].astype(
840
+ int
841
+ )
842
+ )
843
+ ],
844
+ self.template_weights[self.in_volume],
571
845
  )
572
846
  )
573
847
  )
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
589
-
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
-
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
848
+ score += np.sum(np.square(self.template_weights[np.invert(self.in_volume)]))
849
+ return score * self.score_sign
628
850
 
629
851
 
630
- class MutualInformation(MatchCoordinatesToDensity):
852
+ class MutualInformation(_MatchCoordinatesToDensity):
631
853
  """
632
- Class representing the Mutual Information matching score.
633
-
634
- The Mutual Information (MI) score is calculated as:
854
+ The Mutual Information (MI) score between the target :math:`f` and the
855
+ template :math:`g` is calculated as:
635
856
 
636
857
  .. math::
637
858
 
@@ -645,43 +866,15 @@ class MutualInformation(MatchCoordinatesToDensity):
645
866
 
646
867
  """
647
868
 
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.
663
-
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.
869
+ __doc__ += _MatchCoordinatesToDensity.__doc__
676
870
 
677
- Returns
678
- -------
679
- float
680
- The Mutual Information score.
681
- """
871
+ def __call__(self) -> float:
872
+ """Returns the score of the current configuration."""
682
873
  p_xy, target, template = np.histogram2d(
683
- self.target_density[tuple(transformed_coordinates[:, in_volume])],
684
- self.template_weights[in_volume],
874
+ self.target_density[
875
+ tuple(self.template_coordinates_rotated[:, self.in_volume].astype(int))
876
+ ],
877
+ self.template_weights[self.in_volume],
685
878
  )
686
879
  p_x, p_y = np.sum(p_xy, axis=1), np.sum(p_xy, axis=0)
687
880
 
@@ -692,14 +885,13 @@ class MutualInformation(MatchCoordinatesToDensity):
692
885
  logprob = np.divide(p_xy, p_x[:, None] * p_y[None, :] + np.finfo(float).eps)
693
886
  score = np.nansum(p_xy * logprob)
694
887
 
695
- return score
888
+ return score * self.score_sign
696
889
 
697
890
 
698
- class Envelope(MatchCoordinatesToDensity):
891
+ class Envelope(_MatchCoordinatesToDensity):
699
892
  """
700
- Class representing the Envelope matching score.
701
-
702
- The Envelope score (ENV) is calculated as:
893
+ The Envelope score (ENV) between the target :math:`f` and the
894
+ template :math:`g` is calculated as:
703
895
 
704
896
  .. math::
705
897
 
@@ -713,59 +905,64 @@ class Envelope(MatchCoordinatesToDensity):
713
905
  pp. 333--343, 2011. DOI: https://doi.org/10.1016/j.jsb.2011.01.012
714
906
  """
715
907
 
716
- def __init__(self, target_threshold: float, **kwargs):
908
+ __doc__ += _MatchCoordinatesToDensity.__doc__
909
+
910
+ def __init__(self, target_threshold: float = None, **kwargs):
717
911
  super().__init__(**kwargs)
912
+ if target_threshold is None:
913
+ target_threshold = np.mean(self.target_density)
718
914
  self.target_density = np.where(self.target_density > target_threshold, -1, 1)
719
915
  self.target_density_present = np.sum(self.target_density == -1)
720
916
  self.target_density_absent = np.sum(self.target_density == 1)
721
917
  self.template_weights = np.ones_like(self.template_weights)
722
918
 
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])]
919
+ def __call__(self) -> float:
920
+ """Returns the score of the current configuration."""
921
+ score = self.target_density[
922
+ tuple(self.template_coordinates_rotated[:, self.in_volume].astype(int))
923
+ ]
755
924
  unassigned_density = self.target_density_present - (score == -1).sum()
756
925
 
757
- score = score.sum() - unassigned_density - 2 * np.sum(np.invert(in_volume))
926
+ score = score.sum() - unassigned_density - 2 * np.sum(np.invert(self.in_volume))
758
927
  min_score = -self.target_density_present - 2 * self.target_density_absent
759
928
  score = (score - 2 * min_score) / (2 * self.target_density_present - min_score)
760
929
 
761
- return score
930
+ return score * self.score_sign
931
+
932
+
933
+ class Chamfer(_MatchCoordinatesToCoordinates):
934
+ """
935
+ The Chamfer distance between the target :math:`f` and the template :math:`g`
936
+ is calculated as:
937
+
938
+ .. math::
762
939
 
940
+ \\text{d(f,g)} = \\frac{1}{|X|} \\sum_{\\mathbf{f}_i \\in X}
941
+ \\inf_{\\mathbf{g} \\in Y} ||\\mathbf{f}_i - \\mathbf{g}||_2
763
942
 
764
- class NormalVectorScore(MatchCoordinatesToCoordinates):
943
+ References
944
+ ----------
945
+ .. [1] Daven Vasishtan and Maya Topf, "Scoring functions for cryoEM density
946
+ fitting", Journal of Structural Biology, vol. 174, no. 2,
947
+ pp. 333--343, 2011. DOI: https://doi.org/10.1016/j.jsb.2011.01.012
765
948
  """
766
- Class representing the Normal Vector matching score.
767
949
 
768
- The Normal Vector Score (NVS) is calculated as:
950
+ __doc__ += _MatchCoordinatesToDensity.__doc__
951
+
952
+ def _post_init(self, **kwargs):
953
+ self.target_tree = KDTree(self.target_coordinates.T)
954
+
955
+ def __call__(self) -> float:
956
+ """Returns the score of the current configuration."""
957
+ dist, _ = self.target_tree.query(self.template_coordinates_rotated.T)
958
+ score = np.mean(dist)
959
+ return score * self.score_sign
960
+
961
+
962
+ class NormalVectorScore(_MatchCoordinatesToCoordinates):
963
+ """
964
+ The Normal Vector Score (NVS) between the target's :math:`f` and the template
965
+ :math:`g`'s normal vectors is calculated as:
769
966
 
770
967
  .. math::
771
968
 
@@ -784,35 +981,14 @@ class NormalVectorScore(MatchCoordinatesToCoordinates):
784
981
 
785
982
  """
786
983
 
787
- def __init__(self, **kwargs):
788
- super().__init__(**kwargs)
789
-
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.
984
+ __doc__ += _MatchCoordinatesToDensity.__doc__
798
985
 
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)
986
+ def __call__(self) -> float:
987
+ """Returns the score of the current configuration."""
988
+ numerator = np.multiply(
989
+ self.template_coordinates_rotated, self.target_coordinates
990
+ )
991
+ denominator = np.linalg.norm(self.template_coordinates_rotated)
816
992
  denominator *= np.linalg.norm(self.target_coordinates)
817
993
  score = np.mean(numerator / denominator)
818
994
  return score
@@ -829,12 +1005,13 @@ MATCHING_OPTIMIZATION_REGISTER = {
829
1005
  "Chamfer": Chamfer,
830
1006
  "MutualInformation": MutualInformation,
831
1007
  "NormalVectorScore": NormalVectorScore,
1008
+ "FLC": FLC,
832
1009
  }
833
1010
 
834
1011
 
835
1012
  def register_matching_optimization(match_name: str, match_class: type):
836
1013
  """
837
- Registers a class to be used by :py:class:`FitRefinement`.
1014
+ Registers a new mtaching method.
838
1015
 
839
1016
  Parameters
840
1017
  ----------
@@ -848,7 +1025,7 @@ def register_matching_optimization(match_name: str, match_class: type):
848
1025
  ValueError
849
1026
  If any of the required methods is not defined.
850
1027
  """
851
- methods_to_check = ["__init__", "__call__", "scoring_function"]
1028
+ methods_to_check = ["__init__", "__call__"]
852
1029
 
853
1030
  for method in methods_to_check:
854
1031
  if not hasattr(match_class, method):
@@ -858,266 +1035,166 @@ def register_matching_optimization(match_name: str, match_class: type):
858
1035
  MATCHING_OPTIMIZATION_REGISTER[match_name] = match_class
859
1036
 
860
1037
 
861
- class FitRefinement:
1038
+ def create_score_object(score: str, **kwargs) -> object:
862
1039
  """
863
- A class to refine the fit between target and template coordinates.
1040
+ Initialize score object with name ``score`` using `**kwargs``.
864
1041
 
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
-
872
- """
873
-
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.
1042
+ Parameters
1043
+ ----------
1044
+ score: str
1045
+ Name of the score.
1046
+ **kwargs: Dict
1047
+ Keyword arguments passed to the __init__ method of the score object.
884
1048
 
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].
1049
+ Returns
1050
+ -------
1051
+ object
1052
+ Initialized score object.
897
1053
 
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)
1054
+ Raises
1055
+ ------
1056
+ ValueError
1057
+ If ``score`` is not a key in MATCHING_OPTIMIZATION_REGISTER.
913
1058
 
914
- transformed_coordinates_mask, in_volume_mask = None, None
1059
+ See Also
1060
+ --------
1061
+ :py:meth:`register_matching_optimization`
1062
+ """
915
1063
 
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)
1064
+ score_object = MATCHING_OPTIMIZATION_REGISTER.get(score, None)
928
1065
 
929
- return (
930
- transformed_coordinates,
931
- transformed_coordinates_mask,
932
- in_volume,
933
- in_volume_mask,
1066
+ if score_object is None:
1067
+ raise ValueError(
1068
+ f"{score} is not defined. Please pick from "
1069
+ f" {', '.join(list(MATCHING_OPTIMIZATION_REGISTER.keys()))}."
934
1070
  )
935
1071
 
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.
1072
+ score_object = score_object(**kwargs)
1073
+ return score_object
946
1074
 
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.
1002
1075
 
1003
- template_coordinates : NDArray
1004
- The coordinates of the template.
1005
-
1006
- template_weights : NDArray
1007
- The weights of the template.
1076
+ def optimize_match(
1077
+ score_object: object,
1078
+ bounds_translation: Tuple[Tuple[float]] = None,
1079
+ bounds_rotation: Tuple[Tuple[float]] = None,
1080
+ optimization_method: str = "basinhopping",
1081
+ maxiter: int = 500,
1082
+ ) -> Tuple[ArrayLike, ArrayLike, float]:
1083
+ """
1084
+ Find the translation and rotation optimizing the score returned by `score_object`
1085
+ with respect to provided bounds.
1008
1086
 
1009
- sampling_rate : float, optional
1010
- The size of the voxel. Default is None.
1087
+ Parameters
1088
+ ----------
1089
+ score_object: object
1090
+ Class object that defines a score method, which returns a floating point
1091
+ value given a tuple of floating points where the first half describes a
1092
+ translation and the second a rotation. The score will be minimized, i.e.
1093
+ it has to be negated if similarity should be optimized.
1094
+ bounds_translation : tuple of tuple float, optional
1095
+ Bounds on the evaluated translations. Has to be specified per dimension
1096
+ as tuple of (min, max). Default is None.
1097
+ bounds_rotation : tuple of tuple float, optional
1098
+ Bounds on the evaluated zyx Euler angles. Has to be specified per dimension
1099
+ as tuple of (min, max). Default is None.
1100
+ optimization_method : str, optional
1101
+ Optimizer that will be used, by default basinhopping. For further
1102
+ information refer to :doc:`scipy:reference/optimize`.
1103
+
1104
+ +--------------------------+-----------------------------------------+
1105
+ | 'differential_evolution' | Highest accuracy but long runtime. |
1106
+ | | Requires bounds on translation. |
1107
+ +--------------------------+-----------------------------------------+
1108
+ | 'basinhopping' | Decent accuracy, medium runtime. |
1109
+ +--------------------------+-----------------------------------------+
1110
+ | 'minimize' | If initial values are closed to optimum |
1111
+ | | decent performance, short runtime. |
1112
+ +--------------------------+-----------------------------------------+
1113
+ maxiter : int, optional
1114
+ The maximum number of iterations. Default is 500. Not considered for
1115
+ `optimization_method` 'minimize'.
1116
+
1117
+ Returns
1118
+ -------
1119
+ Tuple[ArrayLike, ArrayLike, float]
1120
+ Translation and rotation matrix yielding final score.
1011
1121
 
1012
- translational_uncertainty : (float,), optional
1013
- The translational uncertainty. Default is None.
1122
+ Raises
1123
+ ------
1124
+ ValueError
1125
+ If `optimization_method` is not supported.
1014
1126
 
1015
- rotational_uncertainty : (float,), optional
1016
- The rotational uncertainty. Default is None.
1127
+ Notes
1128
+ -----
1129
+ This function currently only supports three-dimensional optimization and
1130
+ `score_object` will be modified during this operation.
1131
+ """
1132
+ ndim = 3
1133
+ _optimization_method = {
1134
+ "differential_evolution": differential_evolution,
1135
+ "basinhopping": basinhopping,
1136
+ "minimize": minimize,
1137
+ }
1138
+ if optimization_method not in _optimization_method:
1139
+ raise ValueError(
1140
+ f"{optimization_method} is not supported. "
1141
+ f"Pick from {', '.join(list(_optimization_method.keys()))}"
1142
+ )
1017
1143
 
1018
- scoring_class : str, optional
1019
- The scoring class to be used. Default is "CC".
1144
+ finfo = np.finfo(np.float32)
1020
1145
 
1021
- scoring_class_parameters : dict, optional
1022
- The parameters for the scoring class. Default is an empty dictionary.
1146
+ # DE always requires bounds
1147
+ if optimization_method == "differential_evolution" and bounds_translation is None:
1148
+ bounds_translation = tuple((finfo.min, finfo.max) for _ in range(ndim))
1023
1149
 
1024
- local_optimization : bool, optional
1025
- Whether to use local optimization. Default is True.
1150
+ if bounds_translation is None and bounds_rotation is not None:
1151
+ bounds_translation = tuple((finfo.min, finfo.max) for _ in range(ndim))
1026
1152
 
1027
- maxiter : int, optional
1028
- The maximum number of iterations. Default is 100.
1153
+ if bounds_rotation is None and bounds_translation is not None:
1154
+ bounds_rotation = tuple((-180, 180) for _ in range(ndim))
1029
1155
 
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
1156
+ bounds, linear_constraint = None, ()
1157
+ if bounds_rotation is not None and bounds_translation is not None:
1158
+ uncertainty = (*bounds_translation, *bounds_rotation)
1159
+ bounds = [
1160
+ bound if bound != (0, 0) else (-finfo.resolution, finfo.resolution)
1161
+ for bound in uncertainty
1162
+ ]
1163
+ linear_constraint = LinearConstraint(
1164
+ np.eye(len(bounds)), np.min(bounds, axis=1), np.max(bounds, axis=1)
1058
1165
  )
1059
1166
 
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,
1167
+ initial_score = score_object()
1168
+ if optimization_method == "basinhopping":
1169
+ result = basinhopping(
1170
+ x0=np.zeros(2 * ndim),
1171
+ func=score_object.score,
1172
+ niter=maxiter,
1173
+ minimizer_kwargs={"method": "COBYLA", "constraints": linear_constraint},
1067
1174
  )
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)
1175
+ elif optimization_method == "differential_evolution":
1176
+ result = differential_evolution(
1177
+ func=score_object.score,
1178
+ bounds=bounds,
1179
+ constraints=linear_constraint,
1180
+ maxiter=maxiter,
1181
+ )
1182
+ elif optimization_method == "minimize":
1183
+ result = minimize(
1184
+ x0=np.zeros(2 * ndim),
1185
+ fun=score_object.score,
1186
+ bounds=bounds,
1187
+ constraints=linear_constraint,
1101
1188
  )
1189
+ print(f"Niter: {result.nit}, success : {result.success} ({result.message}).")
1190
+ print(f"Initial score: {initial_score} - Refined score: {result.fun}")
1191
+ if initial_score < result.fun:
1192
+ print("Initial score better than refined score. Returning identity.")
1193
+ result.x = np.zeros_like(result.x)
1194
+ translation, rotation = result.x[:ndim], result.x[ndim:]
1195
+ rotation_matrix = euler_to_rotationmatrix(rotation)
1196
+ return translation, rotation_matrix, result.fun
1102
1197
 
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
1198
 
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
1199
+ class FitRefinement:
1200
+ pass