diffct 2.0.0__py3-none-any.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.
diffct/geometry.py ADDED
@@ -0,0 +1,991 @@
1
+ """Geometry and trajectory generation for cone beam CT.
2
+
3
+ This module provides functions for generating circular, spiral, sinusoidal, saddle,
4
+ and custom trajectories for cone beam CT scanning geometries.
5
+ """
6
+
7
+ import math
8
+ import torch
9
+ from typing import Callable, Tuple
10
+
11
+
12
+ # ============================================================================
13
+ # Trajectory Generation Functions
14
+ # ============================================================================
15
+
16
+ def circular_trajectory_3d(n_views, sid, sdd, start_angle=0.0, end_angle=None, device='cuda', dtype=torch.float32):
17
+ """Generate circular trajectory geometry for cone-beam CT.
18
+
19
+ Creates source and detector position matrices for a standard circular
20
+ orbit around the z-axis, commonly used in cone-beam CT imaging.
21
+
22
+ Parameters
23
+ ----------
24
+ n_views : int
25
+ Number of projection views.
26
+ sid : float
27
+ Source-to-Isocenter Distance (SID), in physical units.
28
+ sdd : float
29
+ Source-to-Detector Distance (SDD), in physical units.
30
+ start_angle : float, optional
31
+ Starting angle in radians (default: 0.0).
32
+ end_angle : float, optional
33
+ Ending angle in radians (default: 2*pi, full rotation).
34
+ device : str or torch.device, optional
35
+ Device for tensors (default: 'cuda').
36
+ dtype : torch.dtype, optional
37
+ Data type for tensors (default: torch.float32).
38
+
39
+ Returns
40
+ -------
41
+ src_pos : torch.Tensor
42
+ Source positions, shape (n_views, 3).
43
+ det_center : torch.Tensor
44
+ Detector center positions, shape (n_views, 3).
45
+ det_u_vec : torch.Tensor
46
+ Detector u-direction unit vectors, shape (n_views, 3).
47
+ det_v_vec : torch.Tensor
48
+ Detector v-direction unit vectors, shape (n_views, 3).
49
+
50
+ Examples
51
+ --------
52
+ >>> src_pos, det_center, det_u_vec, det_v_vec = circular_trajectory_3d(
53
+ ... n_views=360, sid=1000.0, sdd=1500.0, device='cuda'
54
+ ... )
55
+ >>> print(src_pos.shape) # (360, 3)
56
+ """
57
+ import math
58
+
59
+ if end_angle is None:
60
+ end_angle = 2 * math.pi
61
+
62
+ # Generate angles (equivalent to linspace with endpoint=False)
63
+ step = (end_angle - start_angle) / n_views
64
+ angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
65
+
66
+ # Preallocate position matrices
67
+ src_pos = torch.zeros((n_views, 3), device=device, dtype=dtype)
68
+ det_center = torch.zeros((n_views, 3), device=device, dtype=dtype)
69
+ det_u_vec = torch.zeros((n_views, 3), device=device, dtype=dtype)
70
+ det_v_vec = torch.zeros((n_views, 3), device=device, dtype=dtype)
71
+
72
+ # Compute trigonometric values
73
+ cos_angles = torch.cos(angles)
74
+ sin_angles = torch.sin(angles)
75
+
76
+ # Source rotates around isocenter at distance sid
77
+ # Convention: source at (x, y, 0), rotating in xy-plane
78
+ src_pos[:, 0] = -sid * sin_angles # x
79
+ src_pos[:, 1] = sid * cos_angles # y
80
+ src_pos[:, 2] = 0.0 # z
81
+
82
+ # Detector center is opposite to source at distance (sdd - sid) from isocenter
83
+ idd = sdd - sid # Isocenter-to-Detector Distance
84
+ det_center[:, 0] = idd * sin_angles # x
85
+ det_center[:, 1] = -idd * cos_angles # y
86
+ det_center[:, 2] = 0.0 # z
87
+
88
+ # Detector u-direction (tangent to rotation, in xy-plane)
89
+ det_u_vec[:, 0] = cos_angles # x
90
+ det_u_vec[:, 1] = sin_angles # y
91
+ det_u_vec[:, 2] = 0.0 # z
92
+
93
+ # Detector v-direction (vertical, along z-axis)
94
+ det_v_vec[:, 0] = 0.0 # x
95
+ det_v_vec[:, 1] = 0.0 # y
96
+ det_v_vec[:, 2] = 1.0 # z
97
+
98
+ return src_pos, det_center, det_u_vec, det_v_vec
99
+
100
+
101
+ def random_trajectory_3d(n_views, sid_mean, sdd_mean, sid_std=0.0, pos_std=0.0,
102
+ angle_std=0.0, device='cuda', dtype=torch.float32, seed=None):
103
+ """Generate random trajectory geometry for cone-beam CT.
104
+
105
+ Creates source and detector position matrices with random perturbations,
106
+ useful for testing reconstruction with non-ideal trajectories or for
107
+ simulating robotic C-arm systems with positioning uncertainties.
108
+
109
+ Parameters
110
+ ----------
111
+ n_views : int
112
+ Number of projection views.
113
+ sid_mean : float
114
+ Mean Source-to-Isocenter Distance, in physical units.
115
+ sdd_mean : float
116
+ Mean Source-to-Detector Distance, in physical units.
117
+ sid_std : float, optional
118
+ Standard deviation for SID variations (default: 0.0).
119
+ pos_std : float, optional
120
+ Standard deviation for random position offsets (default: 0.0).
121
+ angle_std : float, optional
122
+ Standard deviation for angular perturbations in radians (default: 0.0).
123
+ device : str or torch.device, optional
124
+ Device for tensors (default: 'cuda').
125
+ dtype : torch.dtype, optional
126
+ Data type for tensors (default: torch.float32).
127
+ seed : int, optional
128
+ Random seed for reproducibility (default: None).
129
+
130
+ Returns
131
+ -------
132
+ src_pos : torch.Tensor
133
+ Source positions, shape (n_views, 3).
134
+ det_center : torch.Tensor
135
+ Detector center positions, shape (n_views, 3).
136
+ det_u_vec : torch.Tensor
137
+ Detector u-direction unit vectors, shape (n_views, 3).
138
+ det_v_vec : torch.Tensor
139
+ Detector v-direction unit vectors, shape (n_views, 3).
140
+
141
+ Examples
142
+ --------
143
+ >>> # Generate random trajectory with 10% SID variation and 5mm position noise
144
+ >>> src_pos, det_center, det_u_vec, det_v_vec = random_trajectory_3d(
145
+ ... n_views=180, sid_mean=1000.0, sdd_mean=1500.0,
146
+ ... sid_std=100.0, pos_std=5.0, seed=42, device='cuda'
147
+ ... )
148
+ """
149
+ import math
150
+
151
+ if seed is not None:
152
+ torch.manual_seed(seed)
153
+
154
+ # Start with circular trajectory
155
+ src_pos, det_center, det_u_vec, det_v_vec = circular_trajectory_3d(
156
+ n_views, sid_mean, sdd_mean, device=device, dtype=dtype
157
+ )
158
+
159
+ # Add random perturbations
160
+ if sid_std > 0.0:
161
+ # Random SID variations
162
+ sid_perturbations = torch.randn(n_views, device=device, dtype=dtype) * sid_std
163
+ sdd_perturbations = sid_perturbations.clone() # Keep SDD-SID distance approximately constant
164
+
165
+ # Compute angles from existing positions
166
+ angles = torch.atan2(-src_pos[:, 0], src_pos[:, 1])
167
+ cos_angles = torch.cos(angles)
168
+ sin_angles = torch.sin(angles)
169
+
170
+ # Apply SID perturbations
171
+ sid_actual = sid_mean + sid_perturbations
172
+ src_pos[:, 0] = -sid_actual * sin_angles
173
+ src_pos[:, 1] = sid_actual * cos_angles
174
+
175
+ # Apply SDD perturbations
176
+ sdd_actual = sdd_mean + sdd_perturbations
177
+ idd = sdd_actual - sid_actual
178
+ det_center[:, 0] = idd * sin_angles
179
+ det_center[:, 1] = -idd * cos_angles
180
+
181
+ if pos_std > 0.0:
182
+ # Random 3D position offsets
183
+ src_pos += torch.randn_like(src_pos) * pos_std
184
+ det_center += torch.randn_like(det_center) * pos_std
185
+
186
+ if angle_std > 0.0:
187
+ # Random angular perturbations (rotate detector orientation)
188
+ angle_perturbations = torch.randn(n_views, device=device, dtype=dtype) * angle_std
189
+ cos_perturb = torch.cos(angle_perturbations)
190
+ sin_perturb = torch.sin(angle_perturbations)
191
+
192
+ # Rotate u and v vectors
193
+ u_new_x = det_u_vec[:, 0] * cos_perturb - det_u_vec[:, 1] * sin_perturb
194
+ u_new_y = det_u_vec[:, 0] * sin_perturb + det_u_vec[:, 1] * cos_perturb
195
+ det_u_vec[:, 0] = u_new_x
196
+ det_u_vec[:, 1] = u_new_y
197
+
198
+ # Renormalize direction vectors to ensure they remain unit vectors
199
+ det_u_vec = det_u_vec / torch.norm(det_u_vec, dim=1, keepdim=True)
200
+ det_v_vec = det_v_vec / torch.norm(det_v_vec, dim=1, keepdim=True)
201
+
202
+ return src_pos, det_center, det_u_vec, det_v_vec
203
+
204
+
205
+ def spiral_trajectory_3d(n_views, sid, sdd, z_range=100.0, n_turns=2.0,
206
+ start_angle=0.0, device='cuda', dtype=torch.float32):
207
+ """Generate spiral (helical) trajectory geometry for cone-beam CT.
208
+
209
+ Creates a helical trajectory where the source moves in a spiral pattern
210
+ around the z-axis, commonly used in helical CT scanners.
211
+
212
+ Parameters
213
+ ----------
214
+ n_views : int
215
+ Number of projection views.
216
+ sid : float
217
+ Source-to-Isocenter Distance (SID), in physical units.
218
+ sdd : float
219
+ Source-to-Detector Distance (SDD), in physical units.
220
+ z_range : float, optional
221
+ Total z-axis range covered during the spiral (default: 100.0).
222
+ n_turns : float, optional
223
+ Number of complete rotations (default: 2.0).
224
+ start_angle : float, optional
225
+ Starting angle in radians (default: 0.0).
226
+ device : str or torch.device, optional
227
+ Device for tensors (default: 'cuda').
228
+ dtype : torch.dtype, optional
229
+ Data type for tensors (default: torch.float32).
230
+
231
+ Returns
232
+ -------
233
+ src_pos : torch.Tensor
234
+ Source positions, shape (n_views, 3).
235
+ det_center : torch.Tensor
236
+ Detector center positions, shape (n_views, 3).
237
+ det_u_vec : torch.Tensor
238
+ Detector u-direction unit vectors, shape (n_views, 3).
239
+ det_v_vec : torch.Tensor
240
+ Detector v-direction unit vectors, shape (n_views, 3).
241
+
242
+ Examples
243
+ --------
244
+ >>> src_pos, det_center, det_u_vec, det_v_vec = spiral_trajectory_3d(
245
+ ... n_views=360, sid=1000.0, sdd=1500.0, z_range=200.0, n_turns=3.0
246
+ ... )
247
+ """
248
+ # Generate angles for spiral motion
249
+ end_angle = start_angle + 2 * math.pi * n_turns
250
+ step = (end_angle - start_angle) / n_views
251
+ angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
252
+
253
+ # Generate z positions (linear motion along z-axis)
254
+ z_positions = torch.linspace(-z_range/2, z_range/2, n_views, device=device, dtype=dtype)
255
+
256
+ # Preallocate position matrices
257
+ src_pos = torch.zeros((n_views, 3), device=device, dtype=dtype)
258
+ det_center = torch.zeros((n_views, 3), device=device, dtype=dtype)
259
+ det_u_vec = torch.zeros((n_views, 3), device=device, dtype=dtype)
260
+ det_v_vec = torch.zeros((n_views, 3), device=device, dtype=dtype)
261
+
262
+ # Compute trigonometric values
263
+ cos_angles = torch.cos(angles)
264
+ sin_angles = torch.sin(angles)
265
+
266
+ # Source rotates around z-axis with z-motion
267
+ src_pos[:, 0] = -sid * sin_angles
268
+ src_pos[:, 1] = sid * cos_angles
269
+ src_pos[:, 2] = z_positions
270
+
271
+ # Detector center opposite to source
272
+ idd = sdd - sid
273
+ det_center[:, 0] = idd * sin_angles
274
+ det_center[:, 1] = -idd * cos_angles
275
+ det_center[:, 2] = z_positions
276
+
277
+ # Detector u-direction (tangent to rotation)
278
+ det_u_vec[:, 0] = cos_angles
279
+ det_u_vec[:, 1] = sin_angles
280
+ det_u_vec[:, 2] = 0.0
281
+
282
+ # Detector v-direction (vertical)
283
+ det_v_vec[:, 0] = 0.0
284
+ det_v_vec[:, 1] = 0.0
285
+ det_v_vec[:, 2] = 1.0
286
+
287
+ return src_pos, det_center, det_u_vec, det_v_vec
288
+
289
+
290
+ def sinusoidal_trajectory_3d(n_views, sid, sdd, amplitude=50.0, frequency=2.0,
291
+ start_angle=0.0, device='cuda', dtype=torch.float32):
292
+ """Generate sinusoidal trajectory geometry for cone-beam CT.
293
+
294
+ Creates a trajectory where the source follows a sinusoidal path in 3D space,
295
+ with radial oscillations as it rotates around the z-axis.
296
+
297
+ Parameters
298
+ ----------
299
+ n_views : int
300
+ Number of projection views.
301
+ sid : float
302
+ Mean Source-to-Isocenter Distance (SID), in physical units.
303
+ sdd : float
304
+ Source-to-Detector Distance (SDD), in physical units.
305
+ amplitude : float, optional
306
+ Amplitude of sinusoidal radial variation (default: 50.0).
307
+ frequency : float, optional
308
+ Number of oscillation cycles per full rotation (default: 2.0).
309
+ start_angle : float, optional
310
+ Starting angle in radians (default: 0.0).
311
+ device : str or torch.device, optional
312
+ Device for tensors (default: 'cuda').
313
+ dtype : torch.dtype, optional
314
+ Data type for tensors (default: torch.float32).
315
+
316
+ Returns
317
+ -------
318
+ src_pos : torch.Tensor
319
+ Source positions, shape (n_views, 3).
320
+ det_center : torch.Tensor
321
+ Detector center positions, shape (n_views, 3).
322
+ det_u_vec : torch.Tensor
323
+ Detector u-direction unit vectors, shape (n_views, 3).
324
+ det_v_vec : torch.Tensor
325
+ Detector v-direction unit vectors, shape (n_views, 3).
326
+
327
+ Examples
328
+ --------
329
+ >>> src_pos, det_center, det_u_vec, det_v_vec = sinusoidal_trajectory_3d(
330
+ ... n_views=360, sid=1000.0, sdd=1500.0, amplitude=100.0, frequency=3.0
331
+ ... )
332
+ """
333
+ # Generate angles
334
+ end_angle = start_angle + 2 * math.pi
335
+ step = (end_angle - start_angle) / n_views
336
+ angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
337
+
338
+ # Compute sinusoidal radial variation
339
+ radial_variation = amplitude * torch.sin(frequency * angles)
340
+ sid_varying = sid + radial_variation
341
+
342
+ # Preallocate position matrices
343
+ src_pos = torch.zeros((n_views, 3), device=device, dtype=dtype)
344
+ det_center = torch.zeros((n_views, 3), device=device, dtype=dtype)
345
+ det_u_vec = torch.zeros((n_views, 3), device=device, dtype=dtype)
346
+ det_v_vec = torch.zeros((n_views, 3), device=device, dtype=dtype)
347
+
348
+ # Compute trigonometric values
349
+ cos_angles = torch.cos(angles)
350
+ sin_angles = torch.sin(angles)
351
+
352
+ # Source with sinusoidal radial variation
353
+ src_pos[:, 0] = -sid_varying * sin_angles
354
+ src_pos[:, 1] = sid_varying * cos_angles
355
+ src_pos[:, 2] = 0.0
356
+
357
+ # Detector center
358
+ idd = sdd - sid_varying
359
+ det_center[:, 0] = idd * sin_angles
360
+ det_center[:, 1] = -idd * cos_angles
361
+ det_center[:, 2] = 0.0
362
+
363
+ # Detector u-direction
364
+ det_u_vec[:, 0] = cos_angles
365
+ det_u_vec[:, 1] = sin_angles
366
+ det_u_vec[:, 2] = 0.0
367
+
368
+ # Detector v-direction
369
+ det_v_vec[:, 0] = 0.0
370
+ det_v_vec[:, 1] = 0.0
371
+ det_v_vec[:, 2] = 1.0
372
+
373
+ return src_pos, det_center, det_u_vec, det_v_vec
374
+
375
+
376
+ def saddle_trajectory_3d(n_views, sid, sdd, z_amplitude=50.0, radial_amplitude=30.0,
377
+ start_angle=0.0, device='cuda', dtype=torch.float32):
378
+ """Generate saddle-shaped trajectory geometry for cone-beam CT.
379
+
380
+ Creates a saddle-shaped trajectory where the source moves in both z and radial
381
+ directions following a saddle surface (hyperbolic paraboloid).
382
+
383
+ Parameters
384
+ ----------
385
+ n_views : int
386
+ Number of projection views.
387
+ sid : float
388
+ Mean Source-to-Isocenter Distance (SID), in physical units.
389
+ sdd : float
390
+ Source-to-Detector Distance (SDD), in physical units.
391
+ z_amplitude : float, optional
392
+ Amplitude of z-axis variation (default: 50.0).
393
+ radial_amplitude : float, optional
394
+ Amplitude of radial variation (default: 30.0).
395
+ start_angle : float, optional
396
+ Starting angle in radians (default: 0.0).
397
+ device : str or torch.device, optional
398
+ Device for tensors (default: 'cuda').
399
+ dtype : torch.dtype, optional
400
+ Data type for tensors (default: torch.float32).
401
+
402
+ Returns
403
+ -------
404
+ src_pos : torch.Tensor
405
+ Source positions, shape (n_views, 3).
406
+ det_center : torch.Tensor
407
+ Detector center positions, shape (n_views, 3).
408
+ det_u_vec : torch.Tensor
409
+ Detector u-direction unit vectors, shape (n_views, 3).
410
+ det_v_vec : torch.Tensor
411
+ Detector v-direction unit vectors, shape (n_views, 3).
412
+
413
+ Examples
414
+ --------
415
+ >>> src_pos, det_center, det_u_vec, det_v_vec = saddle_trajectory_3d(
416
+ ... n_views=360, sid=1000.0, sdd=1500.0, z_amplitude=80.0, radial_amplitude=50.0
417
+ ... )
418
+ """
419
+ # Generate angles
420
+ end_angle = start_angle + 2 * math.pi
421
+ step = (end_angle - start_angle) / n_views
422
+ angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
423
+
424
+ # Saddle surface: z varies with cos(2*theta), radius varies with sin(2*theta)
425
+ z_positions = z_amplitude * torch.cos(2 * angles)
426
+ radial_variation = radial_amplitude * torch.sin(2 * angles)
427
+ sid_varying = sid + radial_variation
428
+
429
+ # Preallocate position matrices
430
+ src_pos = torch.zeros((n_views, 3), device=device, dtype=dtype)
431
+ det_center = torch.zeros((n_views, 3), device=device, dtype=dtype)
432
+ det_u_vec = torch.zeros((n_views, 3), device=device, dtype=dtype)
433
+ det_v_vec = torch.zeros((n_views, 3), device=device, dtype=dtype)
434
+
435
+ # Compute trigonometric values
436
+ cos_angles = torch.cos(angles)
437
+ sin_angles = torch.sin(angles)
438
+
439
+ # Source with saddle shape
440
+ src_pos[:, 0] = -sid_varying * sin_angles
441
+ src_pos[:, 1] = sid_varying * cos_angles
442
+ src_pos[:, 2] = z_positions
443
+
444
+ # Detector center
445
+ idd = sdd - sid_varying
446
+ det_center[:, 0] = idd * sin_angles
447
+ det_center[:, 1] = -idd * cos_angles
448
+ det_center[:, 2] = z_positions
449
+
450
+ # Detector u-direction
451
+ det_u_vec[:, 0] = cos_angles
452
+ det_u_vec[:, 1] = sin_angles
453
+ det_u_vec[:, 2] = 0.0
454
+
455
+ # Detector v-direction
456
+ det_v_vec[:, 0] = 0.0
457
+ det_v_vec[:, 1] = 0.0
458
+ det_v_vec[:, 2] = 1.0
459
+
460
+ return src_pos, det_center, det_u_vec, det_v_vec
461
+
462
+
463
+ def custom_trajectory_3d(n_views, sid, sdd,
464
+ source_path_fn: Callable[[torch.Tensor, float], torch.Tensor],
465
+ start_angle=0.0, device='cuda', dtype=torch.float32):
466
+ """Generate custom trajectory geometry for cone-beam CT using user-defined function.
467
+
468
+ Creates a trajectory based on a user-provided function that defines the source
469
+ position as a function of angle and SID.
470
+
471
+ Parameters
472
+ ----------
473
+ n_views : int
474
+ Number of projection views.
475
+ sid : float
476
+ Source-to-Isocenter Distance (SID), in physical units.
477
+ sdd : float
478
+ Source-to-Detector Distance (SDD), in physical units.
479
+ source_path_fn : Callable[[torch.Tensor, float], torch.Tensor]
480
+ Function that takes (angles, sid) and returns source positions (n_views, 3).
481
+ The function signature should be: f(angles: torch.Tensor, sid: float) -> torch.Tensor
482
+ where angles has shape (n_views,) and output has shape (n_views, 3).
483
+ start_angle : float, optional
484
+ Starting angle in radians (default: 0.0).
485
+ device : str or torch.device, optional
486
+ Device for tensors (default: 'cuda').
487
+ dtype : torch.dtype, optional
488
+ Data type for tensors (default: torch.float32).
489
+
490
+ Returns
491
+ -------
492
+ src_pos : torch.Tensor
493
+ Source positions, shape (n_views, 3).
494
+ det_center : torch.Tensor
495
+ Detector center positions, shape (n_views, 3).
496
+ det_u_vec : torch.Tensor
497
+ Detector u-direction unit vectors, shape (n_views, 3).
498
+ det_v_vec : torch.Tensor
499
+ Detector v-direction unit vectors, shape (n_views, 3).
500
+
501
+ Examples
502
+ --------
503
+ >>> # Define a custom figure-8 trajectory
504
+ >>> def figure8_path(angles, sid):
505
+ ... src_pos = torch.zeros((len(angles), 3), device=angles.device, dtype=angles.dtype)
506
+ ... src_pos[:, 0] = -sid * torch.sin(angles)
507
+ ... src_pos[:, 1] = sid * torch.cos(angles) * torch.sin(angles)
508
+ ... src_pos[:, 2] = 50 * torch.sin(2 * angles)
509
+ ... return src_pos
510
+ >>>
511
+ >>> src_pos, det_center, det_u_vec, det_v_vec = custom_trajectory_3d(
512
+ ... n_views=360, sid=1000.0, sdd=1500.0, source_path_fn=figure8_path
513
+ ... )
514
+ """
515
+ # Generate angles
516
+ end_angle = start_angle + 2 * math.pi
517
+ step = (end_angle - start_angle) / n_views
518
+ angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
519
+
520
+ # Get source positions from user-defined function
521
+ src_pos = source_path_fn(angles, sid)
522
+
523
+ if src_pos.shape != (n_views, 3):
524
+ raise ValueError(f"source_path_fn must return tensor of shape ({n_views}, 3), "
525
+ f"got {src_pos.shape}")
526
+
527
+ # Keep the entire frame in the source-path autograd graph. Constructing
528
+ # tensors from scalar tensors detaches them; rewriting view slices in
529
+ # place also invalidates tensors saved by cross/norm for backward.
530
+ src_unit = src_pos / torch.linalg.vector_norm(src_pos, dim=1, keepdim=True)
531
+ det_center = src_pos - sdd * src_unit
532
+ u_unnorm = torch.stack((-src_pos[:, 1], src_pos[:, 0],
533
+ torch.zeros_like(src_pos[:, 0])), dim=1)
534
+ along_z = (src_pos[:, :2].abs() < 1e-6).all(dim=1, keepdim=True)
535
+ u_unnorm = torch.where(along_z, src_pos.new_tensor([1.0, 0.0, 0.0]), u_unnorm)
536
+ det_u_vec = u_unnorm / torch.linalg.vector_norm(u_unnorm, dim=1, keepdim=True)
537
+ det_v_vec = torch.cross(src_unit, det_u_vec, dim=1)
538
+ det_v_vec = det_v_vec / torch.linalg.vector_norm(det_v_vec, dim=1, keepdim=True)
539
+
540
+ return src_pos, det_center, det_u_vec, det_v_vec
541
+
542
+
543
+ # ============================================================================
544
+ # 2D Fan Beam Trajectory Generation Functions
545
+ # ============================================================================
546
+
547
+ def circular_trajectory_2d_fan(n_views, sid, sdd, start_angle=0.0, end_angle=None, device='cuda', dtype=torch.float32):
548
+ """Generate circular trajectory geometry for 2D fan-beam CT.
549
+
550
+ Creates source and detector position matrices for a standard circular
551
+ orbit around the origin, commonly used in 2D fan-beam CT imaging.
552
+
553
+ Parameters
554
+ ----------
555
+ n_views : int
556
+ Number of projection views.
557
+ sid : float
558
+ Source-to-Isocenter Distance (SID), in physical units.
559
+ sdd : float
560
+ Source-to-Detector Distance (SDD), in physical units.
561
+ start_angle : float, optional
562
+ Starting angle in radians (default: 0.0).
563
+ end_angle : float, optional
564
+ Ending angle in radians (default: 2*pi, full rotation).
565
+ device : str or torch.device, optional
566
+ Device for tensors (default: 'cuda').
567
+ dtype : torch.dtype, optional
568
+ Data type for tensors (default: torch.float32).
569
+
570
+ Returns
571
+ -------
572
+ src_pos : torch.Tensor
573
+ Source positions, shape (n_views, 2).
574
+ det_center : torch.Tensor
575
+ Detector center positions, shape (n_views, 2).
576
+ det_u_vec : torch.Tensor
577
+ Detector u-direction unit vectors, shape (n_views, 2).
578
+
579
+ Examples
580
+ --------
581
+ >>> src_pos, det_center, det_u_vec = circular_trajectory_2d_fan(
582
+ ... n_views=360, sid=1000.0, sdd=1500.0, device='cuda'
583
+ ... )
584
+ >>> print(src_pos.shape) # (360, 2)
585
+ """
586
+ if end_angle is None:
587
+ end_angle = 2 * math.pi
588
+
589
+ # Generate angles
590
+ step = (end_angle - start_angle) / n_views
591
+ angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
592
+
593
+ # Preallocate position matrices
594
+ src_pos = torch.zeros((n_views, 2), device=device, dtype=dtype)
595
+ det_center = torch.zeros((n_views, 2), device=device, dtype=dtype)
596
+ det_u_vec = torch.zeros((n_views, 2), device=device, dtype=dtype)
597
+
598
+ # Compute trigonometric values
599
+ cos_angles = torch.cos(angles)
600
+ sin_angles = torch.sin(angles)
601
+
602
+ # Source rotates around isocenter at distance sid
603
+ src_pos[:, 0] = -sid * sin_angles # x
604
+ src_pos[:, 1] = sid * cos_angles # y
605
+
606
+ # Detector center is opposite to source at distance (sdd - sid) from isocenter
607
+ idd = sdd - sid # Isocenter-to-Detector Distance
608
+ det_center[:, 0] = idd * sin_angles # x
609
+ det_center[:, 1] = -idd * cos_angles # y
610
+
611
+ # Detector u-direction (tangent to rotation)
612
+ det_u_vec[:, 0] = cos_angles # x
613
+ det_u_vec[:, 1] = sin_angles # y
614
+
615
+ return src_pos, det_center, det_u_vec
616
+
617
+
618
+ def sinusoidal_trajectory_2d_fan(n_views, sid, sdd, amplitude=50.0, frequency=2.0,
619
+ start_angle=0.0, device='cuda', dtype=torch.float32):
620
+ """Generate sinusoidal trajectory geometry for 2D fan-beam CT.
621
+
622
+ Creates a trajectory where the source follows a sinusoidal path,
623
+ with radial oscillations as it rotates around the origin.
624
+
625
+ Parameters
626
+ ----------
627
+ n_views : int
628
+ Number of projection views.
629
+ sid : float
630
+ Mean Source-to-Isocenter Distance (SID), in physical units.
631
+ sdd : float
632
+ Source-to-Detector Distance (SDD), in physical units.
633
+ amplitude : float, optional
634
+ Amplitude of sinusoidal radial variation (default: 50.0).
635
+ frequency : float, optional
636
+ Number of oscillation cycles per full rotation (default: 2.0).
637
+ start_angle : float, optional
638
+ Starting angle in radians (default: 0.0).
639
+ device : str or torch.device, optional
640
+ Device for tensors (default: 'cuda').
641
+ dtype : torch.dtype, optional
642
+ Data type for tensors (default: torch.float32).
643
+
644
+ Returns
645
+ -------
646
+ src_pos : torch.Tensor
647
+ Source positions, shape (n_views, 2).
648
+ det_center : torch.Tensor
649
+ Detector center positions, shape (n_views, 2).
650
+ det_u_vec : torch.Tensor
651
+ Detector u-direction unit vectors, shape (n_views, 2).
652
+
653
+ Examples
654
+ --------
655
+ >>> src_pos, det_center, det_u_vec = sinusoidal_trajectory_2d_fan(
656
+ ... n_views=360, sid=1000.0, sdd=1500.0, amplitude=100.0, frequency=3.0
657
+ ... )
658
+ """
659
+ # Generate angles
660
+ end_angle = start_angle + 2 * math.pi
661
+ step = (end_angle - start_angle) / n_views
662
+ angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
663
+
664
+ # Compute sinusoidal radial variation
665
+ radial_variation = amplitude * torch.sin(frequency * angles)
666
+ sid_varying = sid + radial_variation
667
+
668
+ # Preallocate position matrices
669
+ src_pos = torch.zeros((n_views, 2), device=device, dtype=dtype)
670
+ det_center = torch.zeros((n_views, 2), device=device, dtype=dtype)
671
+ det_u_vec = torch.zeros((n_views, 2), device=device, dtype=dtype)
672
+
673
+ # Compute trigonometric values
674
+ cos_angles = torch.cos(angles)
675
+ sin_angles = torch.sin(angles)
676
+
677
+ # Source with sinusoidal radial variation
678
+ src_pos[:, 0] = -sid_varying * sin_angles
679
+ src_pos[:, 1] = sid_varying * cos_angles
680
+
681
+ # Detector center
682
+ idd = sdd - sid_varying
683
+ det_center[:, 0] = idd * sin_angles
684
+ det_center[:, 1] = -idd * cos_angles
685
+
686
+ # Detector u-direction
687
+ det_u_vec[:, 0] = cos_angles
688
+ det_u_vec[:, 1] = sin_angles
689
+
690
+ return src_pos, det_center, det_u_vec
691
+
692
+
693
+ def custom_trajectory_2d_fan(n_views, sid, sdd,
694
+ source_path_fn: Callable[[torch.Tensor, float], torch.Tensor],
695
+ start_angle=0.0, device='cuda', dtype=torch.float32):
696
+ """Generate custom trajectory geometry for 2D fan-beam CT using user-defined function.
697
+
698
+ Creates a trajectory based on a user-provided function that defines the source
699
+ position as a function of angle and SID.
700
+
701
+ Parameters
702
+ ----------
703
+ n_views : int
704
+ Number of projection views.
705
+ sid : float
706
+ Source-to-Isocenter Distance (SID), in physical units.
707
+ sdd : float
708
+ Source-to-Detector Distance (SDD), in physical units.
709
+ source_path_fn : Callable[[torch.Tensor, float], torch.Tensor]
710
+ Function that takes (angles, sid) and returns source positions (n_views, 2).
711
+ The function signature should be: f(angles: torch.Tensor, sid: float) -> torch.Tensor
712
+ where angles has shape (n_views,) and output has shape (n_views, 2).
713
+ start_angle : float, optional
714
+ Starting angle in radians (default: 0.0).
715
+ device : str or torch.device, optional
716
+ Device for tensors (default: 'cuda').
717
+ dtype : torch.dtype, optional
718
+ Data type for tensors (default: torch.float32).
719
+
720
+ Returns
721
+ -------
722
+ src_pos : torch.Tensor
723
+ Source positions, shape (n_views, 2).
724
+ det_center : torch.Tensor
725
+ Detector center positions, shape (n_views, 2).
726
+ det_u_vec : torch.Tensor
727
+ Detector u-direction unit vectors, shape (n_views, 2).
728
+
729
+ Examples
730
+ --------
731
+ >>> # Define a custom elliptical trajectory
732
+ >>> def ellipse_path(angles, sid):
733
+ ... src_pos = torch.zeros((len(angles), 2), device=angles.device, dtype=angles.dtype)
734
+ ... src_pos[:, 0] = -sid * 1.5 * torch.sin(angles) # wider in x
735
+ ... src_pos[:, 1] = sid * torch.cos(angles)
736
+ ... return src_pos
737
+ >>>
738
+ >>> src_pos, det_center, det_u_vec = custom_trajectory_2d_fan(
739
+ ... n_views=360, sid=1000.0, sdd=1500.0, source_path_fn=ellipse_path
740
+ ... )
741
+ """
742
+ # Generate angles
743
+ end_angle = start_angle + 2 * math.pi
744
+ step = (end_angle - start_angle) / n_views
745
+ angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
746
+
747
+ # Get source positions from user-defined function
748
+ src_pos = source_path_fn(angles, sid)
749
+
750
+ if src_pos.shape != (n_views, 2):
751
+ raise ValueError(f"source_path_fn must return tensor of shape ({n_views}, 2), "
752
+ f"got {src_pos.shape}")
753
+
754
+ # Preallocate remaining position matrices
755
+ det_center = torch.zeros((n_views, 2), device=device, dtype=dtype)
756
+ det_u_vec = torch.zeros((n_views, 2), device=device, dtype=dtype)
757
+
758
+ # For each view, compute detector position and orientation
759
+ for i in range(n_views):
760
+ # Vector from isocenter to source
761
+ src_vec = src_pos[i]
762
+ src_vec_norm = torch.norm(src_vec)
763
+ src_unit = src_vec / src_vec_norm
764
+
765
+ # Detector center is opposite to source
766
+ det_center[i] = -src_unit * (sdd - src_vec_norm)
767
+
768
+ # Detector u-direction: perpendicular to source direction (rotate 90 degrees)
769
+ det_u_vec[i, 0] = -src_unit[1] # perpendicular: (-y, x)
770
+ det_u_vec[i, 1] = src_unit[0]
771
+
772
+ return src_pos, det_center, det_u_vec
773
+
774
+
775
+ # ============================================================================
776
+ # 2D Parallel Beam Trajectory Generation Functions
777
+ # ============================================================================
778
+
779
+ def circular_trajectory_2d_parallel(n_views, detector_distance=0.0, start_angle=0.0, end_angle=None,
780
+ device='cuda', dtype=torch.float32):
781
+ """Generate circular trajectory geometry for 2D parallel-beam CT.
782
+
783
+ Creates ray direction and detector position matrices for a standard circular
784
+ scanning geometry, commonly used in 2D parallel-beam CT imaging.
785
+
786
+ Parameters
787
+ ----------
788
+ n_views : int
789
+ Number of projection views.
790
+ detector_distance : float, optional
791
+ Distance from isocenter to detector origin (default: 0.0).
792
+ start_angle : float, optional
793
+ Starting angle in radians (default: 0.0).
794
+ end_angle : float, optional
795
+ Ending angle in radians (default: pi, half rotation for parallel beam).
796
+ device : str or torch.device, optional
797
+ Device for tensors (default: 'cuda').
798
+ dtype : torch.dtype, optional
799
+ Data type for tensors (default: torch.float32).
800
+
801
+ Returns
802
+ -------
803
+ ray_dir : torch.Tensor
804
+ Ray direction unit vectors for each view, shape (n_views, 2).
805
+ det_origin : torch.Tensor
806
+ Detector origin positions for each view, shape (n_views, 2).
807
+ det_u_vec : torch.Tensor
808
+ Detector u-direction unit vectors for each view, shape (n_views, 2).
809
+
810
+ Examples
811
+ --------
812
+ >>> ray_dir, det_origin, det_u_vec = circular_trajectory_2d_parallel(
813
+ ... n_views=180, device='cuda'
814
+ ... )
815
+ >>> print(ray_dir.shape) # (180, 2)
816
+ """
817
+ if end_angle is None:
818
+ end_angle = math.pi # Half rotation for parallel beam
819
+
820
+ # Generate angles
821
+ step = (end_angle - start_angle) / n_views
822
+ angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
823
+
824
+ # Preallocate position matrices
825
+ ray_dir = torch.zeros((n_views, 2), device=device, dtype=dtype)
826
+ det_origin = torch.zeros((n_views, 2), device=device, dtype=dtype)
827
+ det_u_vec = torch.zeros((n_views, 2), device=device, dtype=dtype)
828
+
829
+ # Compute trigonometric values
830
+ cos_angles = torch.cos(angles)
831
+ sin_angles = torch.sin(angles)
832
+
833
+ # Ray direction: perpendicular to detector
834
+ ray_dir[:, 0] = cos_angles
835
+ ray_dir[:, 1] = sin_angles
836
+
837
+ # Detector origin: offset from isocenter perpendicular to ray direction
838
+ det_origin[:, 0] = -detector_distance * sin_angles
839
+ det_origin[:, 1] = detector_distance * cos_angles
840
+
841
+ # Detector u-direction: tangent to rotation (perpendicular to ray)
842
+ det_u_vec[:, 0] = -sin_angles
843
+ det_u_vec[:, 1] = cos_angles
844
+
845
+ return ray_dir, det_origin, det_u_vec
846
+
847
+
848
+ def sinusoidal_trajectory_2d_parallel(n_views, amplitude=50.0, frequency=2.0,
849
+ start_angle=0.0, device='cuda', dtype=torch.float32):
850
+ """Generate sinusoidal trajectory geometry for 2D parallel-beam CT.
851
+
852
+ Creates a trajectory where the detector follows a sinusoidal path
853
+ while maintaining parallel rays.
854
+
855
+ Parameters
856
+ ----------
857
+ n_views : int
858
+ Number of projection views.
859
+ amplitude : float, optional
860
+ Amplitude of sinusoidal detector displacement (default: 50.0).
861
+ frequency : float, optional
862
+ Number of oscillation cycles per rotation (default: 2.0).
863
+ start_angle : float, optional
864
+ Starting angle in radians (default: 0.0).
865
+ device : str or torch.device, optional
866
+ Device for tensors (default: 'cuda').
867
+ dtype : torch.dtype, optional
868
+ Data type for tensors (default: torch.float32).
869
+
870
+ Returns
871
+ -------
872
+ ray_dir : torch.Tensor
873
+ Ray direction unit vectors for each view, shape (n_views, 2).
874
+ det_origin : torch.Tensor
875
+ Detector origin positions for each view, shape (n_views, 2).
876
+ det_u_vec : torch.Tensor
877
+ Detector u-direction unit vectors for each view, shape (n_views, 2).
878
+
879
+ Examples
880
+ --------
881
+ >>> ray_dir, det_origin, det_u_vec = sinusoidal_trajectory_2d_parallel(
882
+ ... n_views=180, amplitude=100.0, frequency=3.0
883
+ ... )
884
+ """
885
+ # Generate angles
886
+ end_angle = start_angle + math.pi
887
+ step = (end_angle - start_angle) / n_views
888
+ angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
889
+
890
+ # Compute sinusoidal detector displacement
891
+ detector_offset = amplitude * torch.sin(frequency * angles)
892
+
893
+ # Preallocate position matrices
894
+ ray_dir = torch.zeros((n_views, 2), device=device, dtype=dtype)
895
+ det_origin = torch.zeros((n_views, 2), device=device, dtype=dtype)
896
+ det_u_vec = torch.zeros((n_views, 2), device=device, dtype=dtype)
897
+
898
+ # Compute trigonometric values
899
+ cos_angles = torch.cos(angles)
900
+ sin_angles = torch.sin(angles)
901
+
902
+ # Ray direction: perpendicular to detector
903
+ ray_dir[:, 0] = cos_angles
904
+ ray_dir[:, 1] = sin_angles
905
+
906
+ # Detector origin with sinusoidal offset
907
+ det_origin[:, 0] = -detector_offset * sin_angles
908
+ det_origin[:, 1] = detector_offset * cos_angles
909
+
910
+ # Detector u-direction: tangent to rotation
911
+ det_u_vec[:, 0] = -sin_angles
912
+ det_u_vec[:, 1] = cos_angles
913
+
914
+ return ray_dir, det_origin, det_u_vec
915
+
916
+
917
+ def custom_trajectory_2d_parallel(n_views,
918
+ ray_dir_fn: Callable[[torch.Tensor], torch.Tensor],
919
+ det_origin_fn: Callable[[torch.Tensor], torch.Tensor],
920
+ start_angle=0.0, device='cuda', dtype=torch.float32):
921
+ """Generate custom trajectory geometry for 2D parallel-beam CT using user-defined functions.
922
+
923
+ Creates a trajectory based on user-provided functions that define the ray
924
+ direction and detector origin as functions of angle.
925
+
926
+ Parameters
927
+ ----------
928
+ n_views : int
929
+ Number of projection views.
930
+ ray_dir_fn : Callable[[torch.Tensor], torch.Tensor]
931
+ Function that takes angles and returns ray directions (n_views, 2).
932
+ det_origin_fn : Callable[[torch.Tensor], torch.Tensor]
933
+ Function that takes angles and returns detector origins (n_views, 2).
934
+ start_angle : float, optional
935
+ Starting angle in radians (default: 0.0).
936
+ device : str or torch.device, optional
937
+ Device for tensors (default: 'cuda').
938
+ dtype : torch.dtype, optional
939
+ Data type for tensors (default: torch.float32).
940
+
941
+ Returns
942
+ -------
943
+ ray_dir : torch.Tensor
944
+ Ray direction unit vectors for each view, shape (n_views, 2).
945
+ det_origin : torch.Tensor
946
+ Detector origin positions for each view, shape (n_views, 2).
947
+ det_u_vec : torch.Tensor
948
+ Detector u-direction unit vectors for each view, shape (n_views, 2).
949
+
950
+ Examples
951
+ --------
952
+ >>> # Define custom trajectory functions
953
+ >>> def ray_dirs(angles):
954
+ ... dirs = torch.zeros((len(angles), 2), device=angles.device, dtype=angles.dtype)
955
+ ... dirs[:, 0] = torch.cos(angles)
956
+ ... dirs[:, 1] = torch.sin(angles)
957
+ ... return dirs
958
+ >>>
959
+ >>> def det_origins(angles):
960
+ ... origins = torch.zeros((len(angles), 2), device=angles.device, dtype=angles.dtype)
961
+ ... return origins
962
+ >>>
963
+ >>> ray_dir, det_origin, det_u_vec = custom_trajectory_2d_parallel(
964
+ ... n_views=180, ray_dir_fn=ray_dirs, det_origin_fn=det_origins
965
+ ... )
966
+ """
967
+ # Generate angles
968
+ end_angle = start_angle + math.pi
969
+ step = (end_angle - start_angle) / n_views
970
+ angles = start_angle + torch.arange(n_views, device=device, dtype=dtype) * step
971
+
972
+ # Get ray directions and detector origins from user-defined functions
973
+ ray_dir = ray_dir_fn(angles)
974
+ det_origin = det_origin_fn(angles)
975
+
976
+ if ray_dir.shape != (n_views, 2):
977
+ raise ValueError(f"ray_dir_fn must return tensor of shape ({n_views}, 2), "
978
+ f"got {ray_dir.shape}")
979
+ if det_origin.shape != (n_views, 2):
980
+ raise ValueError(f"det_origin_fn must return tensor of shape ({n_views}, 2), "
981
+ f"got {det_origin.shape}")
982
+
983
+ # Normalize ray directions
984
+ ray_dir = ray_dir / torch.norm(ray_dir, dim=1, keepdim=True)
985
+
986
+ # Detector u-direction: perpendicular to ray direction (rotate 90 degrees)
987
+ det_u_vec = torch.zeros((n_views, 2), device=device, dtype=dtype)
988
+ det_u_vec[:, 0] = -ray_dir[:, 1] # perpendicular: (-y, x)
989
+ det_u_vec[:, 1] = ray_dir[:, 0]
990
+
991
+ return ray_dir, det_origin, det_u_vec