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
@@ -0,0 +1,570 @@
1
+ #!python
2
+ """ CLI to simplify analysing the output of match_template.py.
3
+
4
+ Copyright (c) 2023 European Molecular Biology Laboratory
5
+
6
+ Author: Valentin Maurer <valentin.maurer@embl-hamburg.de>
7
+ """
8
+ import argparse
9
+ from sys import exit
10
+ from os import getcwd
11
+ from os.path import join, abspath
12
+ from typing import List
13
+ from os.path import splitext
14
+
15
+ import numpy as np
16
+ from numpy.typing import NDArray
17
+ from scipy.special import erfcinv
18
+
19
+ from tme import Density, Structure, Orientations
20
+ from tme.analyzer import (
21
+ PeakCallerSort,
22
+ PeakCallerMaximumFilter,
23
+ PeakCallerFast,
24
+ PeakCallerRecursiveMasking,
25
+ PeakCallerScipy,
26
+ )
27
+ from tme.matching_utils import (
28
+ load_pickle,
29
+ euler_to_rotationmatrix,
30
+ euler_from_rotationmatrix,
31
+ )
32
+
33
+ PEAK_CALLERS = {
34
+ "PeakCallerSort": PeakCallerSort,
35
+ "PeakCallerMaximumFilter": PeakCallerMaximumFilter,
36
+ "PeakCallerFast": PeakCallerFast,
37
+ "PeakCallerRecursiveMasking": PeakCallerRecursiveMasking,
38
+ "PeakCallerScipy": PeakCallerScipy,
39
+ }
40
+
41
+
42
+ def parse_args():
43
+ parser = argparse.ArgumentParser(
44
+ description="Peak Calling for Template Matching Outputs"
45
+ )
46
+
47
+ input_group = parser.add_argument_group("Input")
48
+ output_group = parser.add_argument_group("Output")
49
+ peak_group = parser.add_argument_group("Peak Calling")
50
+ additional_group = parser.add_argument_group("Additional Parameters")
51
+
52
+ input_group.add_argument(
53
+ "--input_file",
54
+ required=True,
55
+ nargs="+",
56
+ help="Path to the output of match_template.py.",
57
+ )
58
+ input_group.add_argument(
59
+ "--target_mask",
60
+ required=False,
61
+ type=str,
62
+ help="Path to an optional mask applied to template matching scores.",
63
+ )
64
+ input_group.add_argument(
65
+ "--orientations",
66
+ required=False,
67
+ type=str,
68
+ help="Path to file generated using output_format orientations. Can be filtered "
69
+ "to exclude false-positive peaks. If this file is provided, peak calling "
70
+ "is skipped and corresponding parameters ignored.",
71
+ )
72
+
73
+ output_group.add_argument(
74
+ "--output_prefix",
75
+ required=True,
76
+ help="Output filename, extension will be added based on output_format.",
77
+ )
78
+ output_group.add_argument(
79
+ "--output_format",
80
+ choices=[
81
+ "orientations",
82
+ "alignment",
83
+ "extraction",
84
+ "relion",
85
+ "backmapping",
86
+ "average",
87
+ ],
88
+ default="orientations",
89
+ help="Available output formats:"
90
+ "orientations (translation, rotation, and score), "
91
+ "alignment (aligned template to target based on orientations), "
92
+ "extraction (extract regions around peaks from targets, i.e. subtomograms), "
93
+ "relion (perform extraction step and generate corresponding star files), "
94
+ "backmapping (map template to target using identified peaks),"
95
+ "average (extract matched regions from target and average them).",
96
+ )
97
+
98
+ peak_group.add_argument(
99
+ "--peak_caller",
100
+ choices=list(PEAK_CALLERS.keys()),
101
+ default="PeakCallerScipy",
102
+ help="Peak caller for local maxima identification.",
103
+ )
104
+ peak_group.add_argument(
105
+ "--minimum_score",
106
+ type=float,
107
+ default=None,
108
+ help="Minimum score from which peaks will be considered.",
109
+ )
110
+ peak_group.add_argument(
111
+ "--maximum_score",
112
+ type=float,
113
+ default=None,
114
+ help="Maximum score until which peaks will be considered.",
115
+ )
116
+ peak_group.add_argument(
117
+ "--min_distance",
118
+ type=int,
119
+ default=5,
120
+ help="Minimum distance between peaks.",
121
+ )
122
+ peak_group.add_argument(
123
+ "--min_boundary_distance",
124
+ type=int,
125
+ default=0,
126
+ help="Minimum distance of peaks to target edges.",
127
+ )
128
+ peak_group.add_argument(
129
+ "--mask_edges",
130
+ action="store_true",
131
+ default=False,
132
+ help="Whether candidates should not be identified from scores that were "
133
+ "computed from padded densities. Superseded by min_boundary_distance.",
134
+ )
135
+ peak_group.add_argument(
136
+ "--number_of_peaks",
137
+ type=int,
138
+ default=None,
139
+ required=False,
140
+ help="Upper limit of peaks to call, subject to filtering parameters. Default 1000. "
141
+ "If minimum_score is provided all peaks scoring higher will be reported.",
142
+ )
143
+ peak_group.add_argument(
144
+ "--peak_oversampling",
145
+ type=int,
146
+ default=1,
147
+ help="1 / factor equals voxel precision, e.g. 2 detects half voxel "
148
+ "translations. Useful for matching structures to electron density maps.",
149
+ )
150
+
151
+ additional_group.add_argument(
152
+ "--subtomogram_box_size",
153
+ type=int,
154
+ default=None,
155
+ help="Subtomogram box size, by default equal to the centered template. Will be "
156
+ "padded to even values if output_format is relion.",
157
+ )
158
+ additional_group.add_argument(
159
+ "--mask_subtomograms",
160
+ action="store_true",
161
+ default=False,
162
+ help="Whether to mask subtomograms using the template mask. The mask will be "
163
+ "rotated according to determined angles.",
164
+ )
165
+ additional_group.add_argument(
166
+ "--invert_target_contrast",
167
+ action="store_true",
168
+ default=False,
169
+ help="Whether to invert the target contrast.",
170
+ )
171
+ additional_group.add_argument(
172
+ "--wedge_mask",
173
+ type=str,
174
+ default=None,
175
+ help="Path to file used as ctf_mask for output_format relion.",
176
+ )
177
+ additional_group.add_argument(
178
+ "--n_false_positives",
179
+ type=int,
180
+ default=None,
181
+ required=False,
182
+ help="Number of accepted false-positives picks to determine minimum score.",
183
+ )
184
+
185
+ args = parser.parse_args()
186
+
187
+ if args.wedge_mask is not None:
188
+ args.wedge_mask = abspath(args.wedge_mask)
189
+
190
+ if args.output_format == "relion" and args.subtomogram_box_size is not None:
191
+ args.subtomogram_box_size += args.subtomogram_box_size % 2
192
+
193
+ if args.orientations is not None:
194
+ args.orientations = Orientations.from_file(filename=args.orientations)
195
+
196
+ if args.minimum_score is not None or args.n_false_positives is not None:
197
+ args.number_of_peaks = np.iinfo(np.int64).max
198
+ else:
199
+ args.number_of_peaks = 1000
200
+
201
+ return args
202
+
203
+
204
+ def load_template(filepath: str, sampling_rate: NDArray, center: bool = True):
205
+ try:
206
+ template = Density.from_file(filepath)
207
+ center_of_mass = template.center_of_mass(template.data)
208
+ template_is_density = True
209
+ except ValueError:
210
+ template = Structure.from_file(filepath)
211
+ center_of_mass = template.center_of_mass()[::-1]
212
+ template = Density.from_structure(template, sampling_rate=sampling_rate)
213
+ template_is_density = False
214
+
215
+ translation = np.zeros_like(center_of_mass)
216
+ if center:
217
+ template, translation = template.centered(0)
218
+
219
+ return template, center_of_mass, translation, template_is_density
220
+
221
+
222
+ def merge_outputs(data, filepaths: List[str], args):
223
+ if len(filepaths) == 0:
224
+ return data, 1
225
+
226
+ if data[0].ndim != data[2].ndim:
227
+ return data, 1
228
+
229
+ from tme.matching_exhaustive import _normalize_under_mask
230
+
231
+ def _norm_scores(data, args):
232
+ target_origin, _, sampling_rate, cli_args = data[-1]
233
+
234
+ _, template_extension = splitext(cli_args.template)
235
+ ret = load_template(
236
+ filepath=cli_args.template,
237
+ sampling_rate=sampling_rate,
238
+ center=not cli_args.no_centering,
239
+ )
240
+ template, center_of_mass, translation, template_is_density = ret
241
+
242
+ if args.mask_edges and args.min_boundary_distance == 0:
243
+ max_shape = np.max(template.shape)
244
+ args.min_boundary_distance = np.ceil(np.divide(max_shape, 2))
245
+
246
+ target_mask = 1
247
+ if args.target_mask is not None:
248
+ target_mask = Density.from_file(args.target_mask).data
249
+ elif cli_args.target_mask is not None:
250
+ target_mask = Density.from_file(args.target_mask).data
251
+
252
+ mask = np.ones_like(data[0])
253
+ np.multiply(mask, target_mask, out=mask)
254
+
255
+ cropped_shape = np.subtract(
256
+ mask.shape, np.multiply(args.min_boundary_distance, 2)
257
+ ).astype(int)
258
+ mask[cropped_shape] = 0
259
+ _normalize_under_mask(template=data[0], mask=mask, mask_intensity=mask.sum())
260
+ return data[0]
261
+
262
+ entities = np.zeros_like(data[0])
263
+ data[0] = _norm_scores(data=data, args=args)
264
+ for index, filepath in enumerate(filepaths):
265
+ new_scores = _norm_scores(data=load_pickle(filepath), args=args)
266
+ indices = new_scores > data[0]
267
+ entities[indices] = index + 1
268
+ data[0][indices] = new_scores[indices]
269
+
270
+ return data, entities
271
+
272
+
273
+ def main():
274
+ args = parse_args()
275
+ data = load_pickle(args.input_file[0])
276
+
277
+ target_origin, _, sampling_rate, cli_args = data[-1]
278
+
279
+ _, template_extension = splitext(cli_args.template)
280
+ ret = load_template(
281
+ filepath=cli_args.template,
282
+ sampling_rate=sampling_rate,
283
+ center=not cli_args.no_centering,
284
+ )
285
+ template, center_of_mass, translation, template_is_density = ret
286
+
287
+ if args.output_format == "relion" and args.subtomogram_box_size is None:
288
+ new_shape = np.add(template.shape, np.mod(template.shape, 2))
289
+ new_shape = np.repeat(new_shape.max(), new_shape.size).astype(int)
290
+ print(f"Padding template from {template.shape} to {new_shape} for RELION.")
291
+ template.pad(new_shape)
292
+
293
+ template_mask = template.empty
294
+ template_mask.data[:] = 1
295
+ if cli_args.template_mask is not None:
296
+ template_mask = Density.from_file(cli_args.template_mask)
297
+ template_mask.pad(template.shape, center=False)
298
+ origin_translation = np.divide(
299
+ np.subtract(template.origin, template_mask.origin), template.sampling_rate
300
+ )
301
+ translation = np.add(translation, origin_translation)
302
+
303
+ template_mask = template_mask.rigid_transform(
304
+ rotation_matrix=np.eye(template_mask.data.ndim),
305
+ translation=-translation,
306
+ order=1,
307
+ )
308
+
309
+ if args.mask_edges and args.min_boundary_distance == 0:
310
+ max_shape = np.max(template.shape)
311
+ args.min_boundary_distance = np.ceil(np.divide(max_shape, 2))
312
+
313
+ # data, entities = merge_outputs(data=data, filepaths=args.input_file[1:], args=args)
314
+
315
+ orientations = args.orientations
316
+ if orientations is None:
317
+ translations, rotations, scores, details = [], [], [], []
318
+ # Output is MaxScoreOverRotations
319
+ if data[0].ndim == data[2].ndim:
320
+ scores, offset, rotation_array, rotation_mapping, meta = data
321
+
322
+ if args.target_mask is not None:
323
+ target_mask = Density.from_file(args.target_mask)
324
+ scores = scores * target_mask.data
325
+
326
+ if args.n_false_positives is not None:
327
+ args.n_false_positives = max(args.n_false_positives, 1)
328
+ cropped_shape = np.subtract(
329
+ scores.shape, np.multiply(args.min_boundary_distance, 2)
330
+ ).astype(int)
331
+
332
+ cropped_shape = tuple(
333
+ slice(
334
+ int(args.min_boundary_distance),
335
+ int(x - args.min_boundary_distance),
336
+ )
337
+ for x in scores.shape
338
+ )
339
+ # Rickgauer et al. 2017
340
+ n_correlations = np.size(scores[cropped_shape]) * len(rotation_mapping)
341
+ minimum_score = np.multiply(
342
+ erfcinv(2 * args.n_false_positives / n_correlations),
343
+ np.sqrt(2) * np.std(scores[cropped_shape]),
344
+ )
345
+ print(f"Determined minimum score cutoff: {minimum_score}.")
346
+ minimum_score = max(minimum_score, 0)
347
+ args.minimum_score = minimum_score
348
+
349
+ peak_caller = PEAK_CALLERS[args.peak_caller](
350
+ number_of_peaks=args.number_of_peaks,
351
+ min_distance=args.min_distance,
352
+ min_boundary_distance=args.min_boundary_distance,
353
+ )
354
+ if args.minimum_score is not None:
355
+ args.number_of_peaks = np.inf
356
+
357
+ peak_caller(
358
+ scores,
359
+ rotation_matrix=np.eye(3),
360
+ mask=template.data,
361
+ rotation_mapping=rotation_mapping,
362
+ rotation_array=rotation_array,
363
+ minimum_score=args.minimum_score,
364
+ )
365
+ candidates = peak_caller.merge(
366
+ candidates=[tuple(peak_caller)],
367
+ number_of_peaks=args.number_of_peaks,
368
+ min_distance=args.min_distance,
369
+ min_boundary_distance=args.min_boundary_distance,
370
+ )
371
+ if len(candidates) == 0:
372
+ print("Found no peaks. Consider changing peak calling parameters.")
373
+ exit(-1)
374
+
375
+ for translation, _, score, detail in zip(*candidates):
376
+ rotations.append(rotation_mapping[rotation_array[tuple(translation)]])
377
+
378
+ else:
379
+ candidates = data
380
+ translation, rotation, *_ = data
381
+ for i in range(translation.shape[0]):
382
+ rotations.append(euler_from_rotationmatrix(rotation[i]))
383
+
384
+ rotations = np.vstack(rotations).astype(float)
385
+ translations, scores, details = candidates[0], candidates[2], candidates[3]
386
+ orientations = Orientations(
387
+ translations=translations,
388
+ rotations=rotations,
389
+ scores=scores,
390
+ details=details,
391
+ )
392
+
393
+ if args.minimum_score is not None:
394
+ keep = orientations.scores >= args.minimum_score
395
+ orientations = orientations[keep]
396
+
397
+ if args.maximum_score is not None:
398
+ keep = orientations.scores <= args.maximum_score
399
+ orientations = orientations[keep]
400
+
401
+ if args.output_format == "orientations":
402
+ orientations.to_file(filename=f"{args.output_prefix}.tsv", file_format="text")
403
+ exit(0)
404
+
405
+ target = Density.from_file(cli_args.target)
406
+ if args.invert_target_contrast:
407
+ if args.output_format == "relion":
408
+ target.data = target.data * -1
409
+ target.data = np.divide(
410
+ np.subtract(target.data, target.data.mean()), target.data.std()
411
+ )
412
+ else:
413
+ target.data = (
414
+ -np.divide(
415
+ np.subtract(target.data, target.data.min()),
416
+ np.subtract(target.data.max(), target.data.min()),
417
+ )
418
+ + 1
419
+ )
420
+
421
+ if args.output_format in ("extraction", "relion"):
422
+ if not np.all(np.divide(target.shape, template.shape) > 2):
423
+ print(
424
+ "Target might be too small relative to template to extract"
425
+ " meaningful particles."
426
+ f" Target : {target.shape}, template : {template.shape}."
427
+ )
428
+
429
+ extraction_shape = template.shape
430
+ if args.subtomogram_box_size is not None:
431
+ extraction_shape = np.repeat(
432
+ args.subtomogram_box_size, len(extraction_shape)
433
+ )
434
+
435
+ orientations, cand_slices, obs_slices = orientations.get_extraction_slices(
436
+ target_shape=target.shape,
437
+ extraction_shape=extraction_shape,
438
+ drop_out_of_box=True,
439
+ return_orientations=True,
440
+ )
441
+
442
+ working_directory = getcwd()
443
+ if args.output_format == "relion":
444
+ orientations.to_file(
445
+ filename=f"{args.output_prefix}.star",
446
+ file_format="relion",
447
+ name_prefix=join(working_directory, args.output_prefix),
448
+ ctf_image=args.wedge_mask,
449
+ sampling_rate=target.sampling_rate.max(),
450
+ subtomogram_size=extraction_shape[0],
451
+ )
452
+
453
+ observations = np.zeros((len(cand_slices), *extraction_shape))
454
+ slices = zip(cand_slices, obs_slices)
455
+ for idx, (cand_slice, obs_slice) in enumerate(slices):
456
+ observations[idx][:] = np.mean(target.data[obs_slice])
457
+ observations[idx][cand_slice] = target.data[obs_slice]
458
+
459
+ for index in range(observations.shape[0]):
460
+ cand_start = [x.start for x in cand_slices[index]]
461
+ out_density = Density(
462
+ data=observations[index],
463
+ sampling_rate=sampling_rate,
464
+ origin=np.multiply(cand_start, sampling_rate),
465
+ )
466
+ if args.mask_subtomograms:
467
+ rotation_matrix = euler_to_rotationmatrix(orientations.rotations[index])
468
+ mask_transfomed = template_mask.rigid_transform(
469
+ rotation_matrix=rotation_matrix, order=1
470
+ )
471
+ out_density.data = out_density.data * mask_transfomed.data
472
+ out_density.to_file(
473
+ join(working_directory, f"{args.output_prefix}_{index}.mrc")
474
+ )
475
+
476
+ exit(0)
477
+
478
+ if args.output_format == "backmapping":
479
+ orientations, cand_slices, obs_slices = orientations.get_extraction_slices(
480
+ target_shape=target.shape,
481
+ extraction_shape=template.shape,
482
+ drop_out_of_box=True,
483
+ return_orientations=True,
484
+ )
485
+ ret, template_sum = target.empty, template.data.sum()
486
+ for index in range(len(cand_slices)):
487
+ rotation_matrix = euler_to_rotationmatrix(orientations.rotations[index])
488
+
489
+ transformed_template = template.rigid_transform(
490
+ rotation_matrix=rotation_matrix
491
+ )
492
+ transformed_template.data = np.multiply(
493
+ transformed_template.data,
494
+ np.divide(template_sum, transformed_template.data.sum()),
495
+ )
496
+ cand_slice, obs_slice = cand_slices[index], obs_slices[index]
497
+ ret.data[obs_slice] += transformed_template.data[cand_slice]
498
+ ret.to_file(f"{args.output_prefix}_backmapped.mrc")
499
+ exit(0)
500
+
501
+ if args.output_format == "average":
502
+ orientations, cand_slices, obs_slices = orientations.get_extraction_slices(
503
+ target_shape=target.shape,
504
+ extraction_shape=np.multiply(template.shape, 2),
505
+ drop_out_of_box=True,
506
+ return_orientations=True,
507
+ )
508
+ out = np.zeros_like(template.data)
509
+ out = np.zeros(np.multiply(template.shape, 2).astype(int))
510
+ for index in range(len(cand_slices)):
511
+ from scipy.spatial.transform import Rotation
512
+
513
+ rotation = Rotation.from_euler(
514
+ angles=orientations.rotations[index], seq="zyx", degrees=True
515
+ )
516
+ rotation_matrix = rotation.inv().as_matrix()
517
+
518
+ # rotation_matrix = euler_to_rotationmatrix(orientations.rotations[index])
519
+ subset = Density(target.data[obs_slices[index]])
520
+ subset = subset.rigid_transform(rotation_matrix=rotation_matrix, order=1)
521
+
522
+ np.add(out, subset.data, out=out)
523
+ out /= len(cand_slices)
524
+ ret = Density(out, sampling_rate=template.sampling_rate, origin=0)
525
+ ret.pad(template.shape, center=True)
526
+ ret.to_file(f"{args.output_prefix}_average.mrc")
527
+ exit(0)
528
+
529
+ if args.peak_oversampling > 1:
530
+ peak_caller = peak_caller = PEAK_CALLERS[args.peak_caller]()
531
+ if data[0].ndim != data[2].ndim:
532
+ print(
533
+ "Input pickle does not contain template matching scores."
534
+ " Cannot oversample peaks."
535
+ )
536
+ exit(-1)
537
+ orientations.translations = peak_caller.oversample_peaks(
538
+ score_space=data[0],
539
+ translations=orientations.translations,
540
+ oversampling_factor=args.oversampling_factor,
541
+ )
542
+
543
+ for index, (translation, angles, *_) in enumerate(orientations):
544
+ rotation_matrix = euler_to_rotationmatrix(angles)
545
+ if template_is_density:
546
+ translation = np.subtract(translation, center_of_mass)
547
+ transformed_template = template.rigid_transform(
548
+ rotation_matrix=rotation_matrix
549
+ )
550
+ new_origin = np.add(target_origin / sampling_rate, translation)
551
+ transformed_template.origin = np.multiply(new_origin, sampling_rate)
552
+ else:
553
+ template = Structure.from_file(cli_args.template)
554
+ new_center_of_mass = np.add(
555
+ np.multiply(translation, sampling_rate), target_origin
556
+ )
557
+ translation = np.subtract(new_center_of_mass, center_of_mass)
558
+ transformed_template = template.rigid_transform(
559
+ translation=translation[::-1],
560
+ rotation_matrix=rotation_matrix[::-1, ::-1],
561
+ )
562
+ # template_extension should contain '.'
563
+ transformed_template.to_file(
564
+ f"{args.output_prefix}_{index}{template_extension}"
565
+ )
566
+ index += 1
567
+
568
+
569
+ if __name__ == "__main__":
570
+ main()