diffct 2.2.0__py3-none-any.whl → 2.2.1__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/__init__.py +13 -1
- diffct/analytical.py +151 -6
- diffct/kernels/__init__.py +8 -0
- diffct/kernels/analytical_transpose.py +295 -0
- diffct/reconstruction.py +399 -0
- {diffct-2.2.0.dist-info → diffct-2.2.1.dist-info}/METADATA +3 -3
- {diffct-2.2.0.dist-info → diffct-2.2.1.dist-info}/RECORD +10 -8
- {diffct-2.2.0.dist-info → diffct-2.2.1.dist-info}/WHEEL +0 -0
- {diffct-2.2.0.dist-info → diffct-2.2.1.dist-info}/licenses/LICENSE +0 -0
- {diffct-2.2.0.dist-info → diffct-2.2.1.dist-info}/licenses/NOTICE +0 -0
diffct/__init__.py
CHANGED
|
@@ -17,6 +17,13 @@ from .projectors import (
|
|
|
17
17
|
from .operators import Projector
|
|
18
18
|
from .storage import TensorStore, NpyStore
|
|
19
19
|
from .surfaces import ParameterizedSurface
|
|
20
|
+
from .reconstruction import (
|
|
21
|
+
fbp,
|
|
22
|
+
fdk,
|
|
23
|
+
helical_fdk,
|
|
24
|
+
helical_view_weights,
|
|
25
|
+
helical_weighted_backproject,
|
|
26
|
+
)
|
|
20
27
|
|
|
21
28
|
from .geometry import (
|
|
22
29
|
circular_trajectory_3d,
|
|
@@ -51,13 +58,18 @@ try:
|
|
|
51
58
|
except ImportError:
|
|
52
59
|
pass
|
|
53
60
|
|
|
54
|
-
__version__ = '2.2.
|
|
61
|
+
__version__ = '2.2.1'
|
|
55
62
|
|
|
56
63
|
__all__ = [
|
|
57
64
|
'Projector',
|
|
58
65
|
'TensorStore',
|
|
59
66
|
'NpyStore',
|
|
60
67
|
'ParameterizedSurface',
|
|
68
|
+
'fbp',
|
|
69
|
+
'fdk',
|
|
70
|
+
'helical_fdk',
|
|
71
|
+
'helical_view_weights',
|
|
72
|
+
'helical_weighted_backproject',
|
|
61
73
|
'ParallelProjectorFunction',
|
|
62
74
|
'ParallelBackprojectorFunction',
|
|
63
75
|
'FanProjectorFunction',
|
diffct/analytical.py
CHANGED
|
@@ -40,6 +40,9 @@ from .kernels import (
|
|
|
40
40
|
_parallel_2d_fbp_backproject_kernel,
|
|
41
41
|
_fan_2d_fbp_backproject_kernel,
|
|
42
42
|
_cone_3d_fdk_backproject_kernel,
|
|
43
|
+
_parallel_2d_fbp_transpose_kernel,
|
|
44
|
+
_fan_2d_fbp_transpose_kernel,
|
|
45
|
+
_cone_3d_fdk_transpose_kernel,
|
|
43
46
|
)
|
|
44
47
|
|
|
45
48
|
|
|
@@ -367,8 +370,8 @@ def _as_contig_f32(t, device):
|
|
|
367
370
|
|
|
368
371
|
|
|
369
372
|
@_on_device_of("sinogram")
|
|
370
|
-
def
|
|
371
|
-
|
|
373
|
+
def _parallel_weighted_backproject_raw(sinogram, ray_dir, det_origin, det_u_vec,
|
|
374
|
+
detector_spacing, H, W, voxel_spacing=1.0):
|
|
372
375
|
"""Voxel-driven parallel-beam FBP backprojection with analytical constant.
|
|
373
376
|
|
|
374
377
|
Runs the dedicated parallel-beam FBP gather kernel (one thread per
|
|
@@ -451,8 +454,8 @@ def _analytical_geometry(src_pos, det_center, n, isocenter):
|
|
|
451
454
|
|
|
452
455
|
|
|
453
456
|
@_on_device_of("sinogram")
|
|
454
|
-
def
|
|
455
|
-
|
|
457
|
+
def _fan_weighted_backproject_raw(sinogram, src_pos, det_center, det_u_vec,
|
|
458
|
+
detector_spacing, H, W, voxel_spacing=1.0, *, isocenter=None):
|
|
456
459
|
"""Voxel-driven fan-beam FBP backprojection with analytical constant.
|
|
457
460
|
|
|
458
461
|
Runs the dedicated fan-beam FBP gather kernel (one thread per output
|
|
@@ -507,8 +510,8 @@ def fan_weighted_backproject(sinogram, src_pos, det_center, det_u_vec,
|
|
|
507
510
|
|
|
508
511
|
|
|
509
512
|
@_on_device_of("sinogram")
|
|
510
|
-
def
|
|
511
|
-
|
|
513
|
+
def _cone_weighted_backproject_raw(sinogram, src_pos, det_center, det_u_vec, det_v_vec,
|
|
514
|
+
D, H, W, du, dv, voxel_spacing=1.0, *, isocenter=None):
|
|
512
515
|
"""Voxel-driven cone-beam FDK backprojection with analytical constant.
|
|
513
516
|
|
|
514
517
|
Runs the dedicated cone-beam FDK gather kernel (one thread per output
|
|
@@ -571,3 +574,145 @@ def cone_weighted_backproject(sinogram, src_pos, det_center, det_u_vec, det_v_ve
|
|
|
571
574
|
# Convert back to (D, H, W) layout for the user.
|
|
572
575
|
reco = reco_perm.permute(2, 1, 0).contiguous() * scale
|
|
573
576
|
return reco
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _validate_analytical_inputs(sinogram, geometry, ndim, isocenter=None):
|
|
580
|
+
if not isinstance(sinogram, torch.Tensor) or sinogram.ndim != ndim:
|
|
581
|
+
raise ValueError(f"sinogram must have rank {ndim}")
|
|
582
|
+
if min(sinogram.shape[1:]) < 2:
|
|
583
|
+
raise ValueError("Analytical backprojection requires at least two detector bins on each axis")
|
|
584
|
+
for component in geometry:
|
|
585
|
+
if not isinstance(component, torch.Tensor) or component.shape != (sinogram.shape[0], ndim):
|
|
586
|
+
raise ValueError(f"Every geometry tensor must have shape (n_views, {ndim})")
|
|
587
|
+
if component.requires_grad:
|
|
588
|
+
raise ValueError("Analytical backprojection does not support geometry gradients")
|
|
589
|
+
if isinstance(isocenter, torch.Tensor) and isocenter.requires_grad:
|
|
590
|
+
raise ValueError("Analytical backprojection does not support isocenter gradients")
|
|
591
|
+
if not sinogram.is_cuda:
|
|
592
|
+
raise ValueError("sinogram must be on a CUDA device")
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _analytical_gather_raw(sinogram, beam, geometry, parameters, isocenter):
|
|
596
|
+
if beam == "parallel":
|
|
597
|
+
return _parallel_weighted_backproject_raw(sinogram, *geometry, *parameters)
|
|
598
|
+
if beam == "fan":
|
|
599
|
+
return _fan_weighted_backproject_raw(sinogram, *geometry, *parameters, isocenter=isocenter)
|
|
600
|
+
return _cone_weighted_backproject_raw(sinogram, *geometry, *parameters, isocenter=isocenter)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
@_on_device_of("image")
|
|
604
|
+
def _analytical_transpose_raw(image, beam, geometry, parameters, isocenter, sinogram_shape):
|
|
605
|
+
device = image.device
|
|
606
|
+
geometry = tuple(_as_contig_f32(component, device) for component in geometry)
|
|
607
|
+
image = _as_contig_f32(image, device)
|
|
608
|
+
sinogram = torch.zeros(sinogram_shape, device=device, dtype=torch.float32)
|
|
609
|
+
d_sino = TorchCUDABridge.tensor_to_cuda_array(sinogram)
|
|
610
|
+
d_geometry = tuple(TorchCUDABridge.tensor_to_cuda_array(component) for component in geometry)
|
|
611
|
+
stream = _get_numba_external_stream_for(torch.cuda.current_stream())
|
|
612
|
+
|
|
613
|
+
scale = 1.0 / (2.0 * math.pi)
|
|
614
|
+
iso = ()
|
|
615
|
+
if beam != "parallel":
|
|
616
|
+
source, detector, u = geometry[:3]
|
|
617
|
+
normal = (torch.stack((-u[:, 1], u[:, 0]), dim=1) if beam == "fan"
|
|
618
|
+
else torch.cross(u, geometry[3], dim=1))
|
|
619
|
+
isocenter, sid_mean, sdd_mean = _analytical_geometry(source, detector, normal, isocenter)
|
|
620
|
+
scale = sdd_mean / (2.0 * math.pi * sid_mean)
|
|
621
|
+
iso = tuple(_DTYPE(value.item() / parameters[-1]) for value in isocenter)
|
|
622
|
+
image = image * scale
|
|
623
|
+
|
|
624
|
+
if beam == "cone":
|
|
625
|
+
D, H, W, du, dv, spacing = parameters
|
|
626
|
+
image = image.permute(2, 1, 0).contiguous()
|
|
627
|
+
d_image = TorchCUDABridge.tensor_to_cuda_array(image)
|
|
628
|
+
grid, tpb = _grid_3d(D, H, W)
|
|
629
|
+
_cone_3d_fdk_transpose_kernel[grid, tpb, stream](
|
|
630
|
+
d_sino, *sinogram_shape, d_image, W, H, D, _DTYPE(du), _DTYPE(dv),
|
|
631
|
+
*d_geometry, _DTYPE(W * 0.5), _DTYPE(H * 0.5), _DTYPE(D * 0.5),
|
|
632
|
+
_DTYPE(spacing), *iso,
|
|
633
|
+
)
|
|
634
|
+
else:
|
|
635
|
+
pitch, H, W, spacing = parameters
|
|
636
|
+
d_image = TorchCUDABridge.tensor_to_cuda_array(image)
|
|
637
|
+
grid, tpb = _grid_2d(W, H)
|
|
638
|
+
kernel = (_parallel_2d_fbp_transpose_kernel if beam == "parallel"
|
|
639
|
+
else _fan_2d_fbp_transpose_kernel)
|
|
640
|
+
kernel[grid, tpb, stream](
|
|
641
|
+
d_sino, *sinogram_shape, d_image, W, H, _DTYPE(pitch), *d_geometry,
|
|
642
|
+
_DTYPE(W * 0.5), _DTYPE(H * 0.5), _DTYPE(spacing), *iso,
|
|
643
|
+
)
|
|
644
|
+
return sinogram
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
class _AnalyticalGather(torch.autograd.Function):
|
|
648
|
+
@staticmethod
|
|
649
|
+
def forward(ctx, sinogram, beam, geometry, parameters, isocenter):
|
|
650
|
+
# Backward uses the exact geometry observed by forward, even if the
|
|
651
|
+
# caller edits its fixed trajectory or isocenter afterwards.
|
|
652
|
+
geometry = tuple(component.detach().clone() for component in geometry)
|
|
653
|
+
if isocenter is not None:
|
|
654
|
+
isocenter = torch.as_tensor(isocenter, device=sinogram.device,
|
|
655
|
+
dtype=torch.float32).detach().clone()
|
|
656
|
+
ctx.save_for_backward(*geometry, isocenter)
|
|
657
|
+
ctx.beam, ctx.parameters = beam, parameters
|
|
658
|
+
ctx.input_shape, ctx.input_dtype, ctx.input_device = sinogram.shape, sinogram.dtype, sinogram.device
|
|
659
|
+
return _analytical_gather_raw(sinogram, beam, geometry, parameters, isocenter)
|
|
660
|
+
|
|
661
|
+
@staticmethod
|
|
662
|
+
def backward(ctx, grad_output):
|
|
663
|
+
*geometry, isocenter = ctx.saved_tensors
|
|
664
|
+
grad_input = _AnalyticalTranspose.apply(
|
|
665
|
+
grad_output, ctx.beam, tuple(geometry), ctx.parameters, isocenter, ctx.input_shape,
|
|
666
|
+
).to(device=ctx.input_device, dtype=ctx.input_dtype)
|
|
667
|
+
return grad_input, None, None, None, None
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
class _AnalyticalTranspose(torch.autograd.Function):
|
|
671
|
+
@staticmethod
|
|
672
|
+
def forward(ctx, image, beam, geometry, parameters, isocenter, sinogram_shape):
|
|
673
|
+
ctx.beam, ctx.geometry, ctx.parameters, ctx.isocenter = beam, geometry, parameters, isocenter
|
|
674
|
+
ctx.input_dtype, ctx.input_device = image.dtype, image.device
|
|
675
|
+
return _analytical_transpose_raw(image, beam, geometry, parameters, isocenter, sinogram_shape)
|
|
676
|
+
|
|
677
|
+
@staticmethod
|
|
678
|
+
def backward(ctx, grad_output):
|
|
679
|
+
grad_input = _AnalyticalGather.apply(
|
|
680
|
+
grad_output, ctx.beam, ctx.geometry, ctx.parameters, ctx.isocenter,
|
|
681
|
+
).to(device=ctx.input_device, dtype=ctx.input_dtype)
|
|
682
|
+
return grad_input, None, None, None, None, None
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _analytical_backproject(sinogram, beam, geometry, parameters, isocenter=None):
|
|
686
|
+
_validate_analytical_inputs(sinogram, geometry, 3 if beam == "cone" else 2, isocenter)
|
|
687
|
+
if torch.is_grad_enabled() and sinogram.requires_grad:
|
|
688
|
+
return _AnalyticalGather.apply(sinogram, beam, geometry, parameters, isocenter)
|
|
689
|
+
return _analytical_gather_raw(sinogram, beam, geometry, parameters, isocenter)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def parallel_weighted_backproject(sinogram, ray_dir, det_origin, det_u_vec,
|
|
693
|
+
detector_spacing, H, W, voxel_spacing=1.0):
|
|
694
|
+
return _analytical_backproject(
|
|
695
|
+
sinogram, "parallel", (ray_dir, det_origin, det_u_vec),
|
|
696
|
+
(detector_spacing, H, W, voxel_spacing),
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def fan_weighted_backproject(sinogram, src_pos, det_center, det_u_vec,
|
|
701
|
+
detector_spacing, H, W, voxel_spacing=1.0, *, isocenter=None):
|
|
702
|
+
return _analytical_backproject(
|
|
703
|
+
sinogram, "fan", (src_pos, det_center, det_u_vec),
|
|
704
|
+
(detector_spacing, H, W, voxel_spacing), isocenter,
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def cone_weighted_backproject(sinogram, src_pos, det_center, det_u_vec, det_v_vec,
|
|
709
|
+
D, H, W, du, dv, voxel_spacing=1.0, *, isocenter=None):
|
|
710
|
+
return _analytical_backproject(
|
|
711
|
+
sinogram, "cone", (src_pos, det_center, det_u_vec, det_v_vec),
|
|
712
|
+
(D, H, W, du, dv, voxel_spacing), isocenter,
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
parallel_weighted_backproject.__doc__ = _parallel_weighted_backproject_raw.__doc__
|
|
717
|
+
fan_weighted_backproject.__doc__ = _fan_weighted_backproject_raw.__doc__
|
|
718
|
+
cone_weighted_backproject.__doc__ = _cone_weighted_backproject_raw.__doc__
|
diffct/kernels/__init__.py
CHANGED
|
@@ -26,6 +26,11 @@ from .geometry_vjp import (
|
|
|
26
26
|
_fan_2d_geometry_vjp_kernel,
|
|
27
27
|
_cone_3d_geometry_vjp_kernel,
|
|
28
28
|
)
|
|
29
|
+
from .analytical_transpose import (
|
|
30
|
+
_parallel_2d_fbp_transpose_kernel,
|
|
31
|
+
_fan_2d_fbp_transpose_kernel,
|
|
32
|
+
_cone_3d_fdk_transpose_kernel,
|
|
33
|
+
)
|
|
29
34
|
|
|
30
35
|
__all__ = [
|
|
31
36
|
'_parallel_2d_forward_kernel',
|
|
@@ -40,4 +45,7 @@ __all__ = [
|
|
|
40
45
|
'_parallel_2d_geometry_vjp_kernel',
|
|
41
46
|
'_fan_2d_geometry_vjp_kernel',
|
|
42
47
|
'_cone_3d_geometry_vjp_kernel',
|
|
48
|
+
'_parallel_2d_fbp_transpose_kernel',
|
|
49
|
+
'_fan_2d_fbp_transpose_kernel',
|
|
50
|
+
'_cone_3d_fdk_transpose_kernel',
|
|
43
51
|
]
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Discrete transposes of the analytical FBP/FDK gather kernels.
|
|
2
|
+
|
|
3
|
+
Geometry arithmetic and interpolation bounds mirror the gather kernels;
|
|
4
|
+
only the final sample operation changes to an atomic detector-bin scatter.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from numba import cuda
|
|
11
|
+
|
|
12
|
+
from ..constants import _EPSILON, _FDK_ACCURACY_DECORATOR, _HALF, _ONE, _ZERO
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@_FDK_ACCURACY_DECORATOR
|
|
16
|
+
def _parallel_2d_fbp_transpose_kernel(
|
|
17
|
+
d_sino, n_views, n_det,
|
|
18
|
+
d_image, Nx, Ny,
|
|
19
|
+
det_spacing, d_ray_dir, d_det_origin, d_det_u_vec,
|
|
20
|
+
cx, cy, voxel_spacing
|
|
21
|
+
):
|
|
22
|
+
"""Scatter image cotangents through the parallel analytical gather stencil."""
|
|
23
|
+
ix, iy = cuda.grid(2)
|
|
24
|
+
if ix >= Nx or iy >= Ny:
|
|
25
|
+
return
|
|
26
|
+
if n_det < 2:
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
# Sample at voxel centres, matching the cell-constant Siddon projector.
|
|
30
|
+
x_v = np.float32(ix) + _HALF - cx
|
|
31
|
+
y_v = np.float32(iy) + _HALF - cy
|
|
32
|
+
|
|
33
|
+
det_spacing_v = det_spacing / voxel_spacing
|
|
34
|
+
half_u = (np.float32(n_det) - _ONE) * _HALF
|
|
35
|
+
|
|
36
|
+
for iview in range(n_views):
|
|
37
|
+
dox = d_det_origin[iview, 0] / voxel_spacing
|
|
38
|
+
doy = d_det_origin[iview, 1] / voxel_spacing
|
|
39
|
+
|
|
40
|
+
ux = d_det_u_vec[iview, 0]
|
|
41
|
+
uy = d_det_u_vec[iview, 1]
|
|
42
|
+
|
|
43
|
+
rx = x_v - dox
|
|
44
|
+
ry = y_v - doy
|
|
45
|
+
|
|
46
|
+
u_det = rx * ux + ry * uy
|
|
47
|
+
|
|
48
|
+
fu = u_det / det_spacing_v + half_u
|
|
49
|
+
if fu < _ZERO or fu > (np.float32(n_det) - _ONE):
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
iu0 = int(math.floor(fu))
|
|
53
|
+
if iu0 >= n_det - 1:
|
|
54
|
+
iu0 = n_det - 2
|
|
55
|
+
if iu0 < 0:
|
|
56
|
+
iu0 = 0
|
|
57
|
+
tu = fu - np.float32(iu0)
|
|
58
|
+
if tu < _ZERO:
|
|
59
|
+
tu = _ZERO
|
|
60
|
+
elif tu > _ONE:
|
|
61
|
+
tu = _ONE
|
|
62
|
+
|
|
63
|
+
value = d_image[iy, ix]
|
|
64
|
+
cuda.atomic.add(d_sino, (iview, iu0), value * (_ONE - tu))
|
|
65
|
+
cuda.atomic.add(d_sino, (iview, iu0 + 1), value * tu)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@_FDK_ACCURACY_DECORATOR
|
|
69
|
+
def _fan_2d_fbp_transpose_kernel(
|
|
70
|
+
d_sino, n_views, n_det,
|
|
71
|
+
d_image, Nx, Ny,
|
|
72
|
+
det_spacing, d_src_pos, d_det_center, d_det_u_vec,
|
|
73
|
+
cx, cy, voxel_spacing, iso_x, iso_y
|
|
74
|
+
):
|
|
75
|
+
"""Scatter image cotangents through the fan analytical gather stencil."""
|
|
76
|
+
ix, iy = cuda.grid(2)
|
|
77
|
+
if ix >= Nx or iy >= Ny:
|
|
78
|
+
return
|
|
79
|
+
if n_det < 2:
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
# Sample at voxel centres, matching the cell-constant Siddon projector.
|
|
83
|
+
x_v = np.float32(ix) + _HALF - cx
|
|
84
|
+
y_v = np.float32(iy) + _HALF - cy
|
|
85
|
+
|
|
86
|
+
det_spacing_v = det_spacing / voxel_spacing
|
|
87
|
+
half_u = (np.float32(n_det) - _ONE) * _HALF
|
|
88
|
+
|
|
89
|
+
for iview in range(n_views):
|
|
90
|
+
sx = d_src_pos[iview, 0] / voxel_spacing
|
|
91
|
+
sy = d_src_pos[iview, 1] / voxel_spacing
|
|
92
|
+
|
|
93
|
+
dcx = d_det_center[iview, 0] / voxel_spacing
|
|
94
|
+
dcy = d_det_center[iview, 1] / voxel_spacing
|
|
95
|
+
|
|
96
|
+
ux = d_det_u_vec[iview, 0]
|
|
97
|
+
uy = d_det_u_vec[iview, 1]
|
|
98
|
+
|
|
99
|
+
# Detector normal in 2D: rotate u_vec 90 degrees. Choose the
|
|
100
|
+
# orientation that points from the source toward the detector,
|
|
101
|
+
# so that ``U_n`` is positive for voxels on the detector side.
|
|
102
|
+
nx = -uy
|
|
103
|
+
ny = ux
|
|
104
|
+
# Align normal so that (det_center - src) . n > 0.
|
|
105
|
+
align = (dcx - sx) * nx + (dcy - sy) * ny
|
|
106
|
+
if align < _ZERO:
|
|
107
|
+
nx = -nx
|
|
108
|
+
ny = -ny
|
|
109
|
+
|
|
110
|
+
# Perpendicular distance from source to voxel along normal.
|
|
111
|
+
px = x_v - sx
|
|
112
|
+
py = y_v - sy
|
|
113
|
+
U_n = px * nx + py * ny
|
|
114
|
+
if U_n <= _EPSILON:
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
sdd_n = (dcx - sx) * nx + (dcy - sy) * ny
|
|
118
|
+
if sdd_n <= _EPSILON:
|
|
119
|
+
continue
|
|
120
|
+
mag = sdd_n / U_n
|
|
121
|
+
|
|
122
|
+
# Source-to-isocenter distance, independent of coordinate origin.
|
|
123
|
+
sid_n = (iso_x - sx) * nx + (iso_y - sy) * ny
|
|
124
|
+
if sid_n <= _EPSILON:
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
hx = sx + mag * px
|
|
128
|
+
hy = sy + mag * py
|
|
129
|
+
|
|
130
|
+
rx = hx - dcx
|
|
131
|
+
ry = hy - dcy
|
|
132
|
+
u_det = rx * ux + ry * uy
|
|
133
|
+
|
|
134
|
+
fu = u_det / det_spacing_v + half_u
|
|
135
|
+
if fu < _ZERO or fu > (np.float32(n_det) - _ONE):
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
iu0 = int(math.floor(fu))
|
|
139
|
+
if iu0 >= n_det - 1:
|
|
140
|
+
iu0 = n_det - 2
|
|
141
|
+
if iu0 < 0:
|
|
142
|
+
iu0 = 0
|
|
143
|
+
tu = fu - np.float32(iu0)
|
|
144
|
+
if tu < _ZERO:
|
|
145
|
+
tu = _ZERO
|
|
146
|
+
elif tu > _ONE:
|
|
147
|
+
tu = _ONE
|
|
148
|
+
|
|
149
|
+
w_ratio = sid_n / U_n
|
|
150
|
+
w = w_ratio * w_ratio
|
|
151
|
+
value = d_image[iy, ix] * w
|
|
152
|
+
cuda.atomic.add(d_sino, (iview, iu0), value * (_ONE - tu))
|
|
153
|
+
cuda.atomic.add(d_sino, (iview, iu0 + 1), value * tu)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@_FDK_ACCURACY_DECORATOR
|
|
157
|
+
def _cone_3d_fdk_transpose_kernel(
|
|
158
|
+
d_sino, n_views, n_u, n_v,
|
|
159
|
+
d_vol, Nx, Ny, Nz,
|
|
160
|
+
du, dv, d_src_pos, d_det_center, d_det_u_vec, d_det_v_vec,
|
|
161
|
+
cx, cy, cz, voxel_spacing, iso_x, iso_y, iso_z
|
|
162
|
+
):
|
|
163
|
+
"""Scatter image cotangents through the cone analytical gather stencil."""
|
|
164
|
+
iz, iy, ix = cuda.grid(3)
|
|
165
|
+
if ix >= Nx or iy >= Ny or iz >= Nz:
|
|
166
|
+
return
|
|
167
|
+
if n_u < 2 or n_v < 2:
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
# Voxel position in voxel-unit, origin-centred coordinates.
|
|
171
|
+
# Sample at voxel centres, matching the cell-constant Siddon projector.
|
|
172
|
+
x_v = np.float32(ix) + _HALF - cx
|
|
173
|
+
y_v = np.float32(iy) + _HALF - cy
|
|
174
|
+
z_v = np.float32(iz) + _HALF - cz
|
|
175
|
+
|
|
176
|
+
du_v = du / voxel_spacing
|
|
177
|
+
dv_v = dv / voxel_spacing
|
|
178
|
+
|
|
179
|
+
half_u = (np.float32(n_u) - _ONE) * _HALF
|
|
180
|
+
half_v = (np.float32(n_v) - _ONE) * _HALF
|
|
181
|
+
|
|
182
|
+
for iview in range(n_views):
|
|
183
|
+
# Source position in voxel units.
|
|
184
|
+
sx = d_src_pos[iview, 0] / voxel_spacing
|
|
185
|
+
sy = d_src_pos[iview, 1] / voxel_spacing
|
|
186
|
+
sz = d_src_pos[iview, 2] / voxel_spacing
|
|
187
|
+
|
|
188
|
+
# Detector centre in voxel units.
|
|
189
|
+
dcx = d_det_center[iview, 0] / voxel_spacing
|
|
190
|
+
dcy = d_det_center[iview, 1] / voxel_spacing
|
|
191
|
+
dcz = d_det_center[iview, 2] / voxel_spacing
|
|
192
|
+
|
|
193
|
+
# Detector orientation (unit vectors, ratio-free).
|
|
194
|
+
ux = d_det_u_vec[iview, 0]
|
|
195
|
+
uy = d_det_u_vec[iview, 1]
|
|
196
|
+
uz = d_det_u_vec[iview, 2]
|
|
197
|
+
vx = d_det_v_vec[iview, 0]
|
|
198
|
+
vy = d_det_v_vec[iview, 1]
|
|
199
|
+
vz = d_det_v_vec[iview, 2]
|
|
200
|
+
|
|
201
|
+
# Detector normal n = u x v. Normalized defensively in case the
|
|
202
|
+
# caller supplied non-orthonormal u/v axes.
|
|
203
|
+
nx = uy * vz - uz * vy
|
|
204
|
+
ny = uz * vx - ux * vz
|
|
205
|
+
nz = ux * vy - uy * vx
|
|
206
|
+
n_len = math.sqrt(nx * nx + ny * ny + nz * nz)
|
|
207
|
+
if n_len < _EPSILON:
|
|
208
|
+
continue
|
|
209
|
+
nx /= n_len
|
|
210
|
+
ny /= n_len
|
|
211
|
+
nz /= n_len
|
|
212
|
+
|
|
213
|
+
# Orient the normal so it points from the source toward the
|
|
214
|
+
# detector. Without this flip, user-supplied (u_vec, v_vec) pairs
|
|
215
|
+
# whose cross product happens to point the other way would make
|
|
216
|
+
# every voxel's U_n negative and get silently skipped, producing
|
|
217
|
+
# a zero reconstruction. The circular_trajectory_3d helper already
|
|
218
|
+
# produces the right-handed convention so this is a no-op there.
|
|
219
|
+
align = (dcx - sx) * nx + (dcy - sy) * ny + (dcz - sz) * nz
|
|
220
|
+
if align < _ZERO:
|
|
221
|
+
nx = -nx
|
|
222
|
+
ny = -ny
|
|
223
|
+
nz = -nz
|
|
224
|
+
|
|
225
|
+
# Signed distance from source to voxel along the detector normal
|
|
226
|
+
# (this is the U in the (sid/U)^2 FDK weight).
|
|
227
|
+
px = x_v - sx
|
|
228
|
+
py = y_v - sy
|
|
229
|
+
pz = z_v - sz
|
|
230
|
+
U_n = px * nx + py * ny + pz * nz
|
|
231
|
+
if U_n <= _EPSILON:
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
# Source-to-detector distance along the normal.
|
|
235
|
+
sdd_n = (dcx - sx) * nx + (dcy - sy) * ny + (dcz - sz) * nz
|
|
236
|
+
if sdd_n <= _EPSILON:
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
# Source-to-isocenter distance, independent of coordinate origin.
|
|
240
|
+
sid_n = (iso_x - sx) * nx + (iso_y - sy) * ny + (iso_z - sz) * nz
|
|
241
|
+
if sid_n <= _EPSILON:
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
mag = sdd_n / U_n
|
|
245
|
+
|
|
246
|
+
# Hit point on the detector plane.
|
|
247
|
+
hx = sx + mag * px
|
|
248
|
+
hy = sy + mag * py
|
|
249
|
+
hz = sz + mag * pz
|
|
250
|
+
|
|
251
|
+
# Detector coordinates relative to detector centre.
|
|
252
|
+
rx = hx - dcx
|
|
253
|
+
ry = hy - dcy
|
|
254
|
+
rz = hz - dcz
|
|
255
|
+
|
|
256
|
+
u_det = rx * ux + ry * uy + rz * uz
|
|
257
|
+
v_det = rx * vx + ry * vy + rz * vz
|
|
258
|
+
|
|
259
|
+
# Convert (u, v) to fractional bin index.
|
|
260
|
+
fu = u_det / du_v + half_u
|
|
261
|
+
fv = v_det / dv_v + half_v
|
|
262
|
+
|
|
263
|
+
if fu < _ZERO or fu > (np.float32(n_u) - _ONE) or fv < _ZERO or fv > (np.float32(n_v) - _ONE):
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
iu0 = int(math.floor(fu))
|
|
267
|
+
iv0 = int(math.floor(fv))
|
|
268
|
+
if iu0 >= n_u - 1:
|
|
269
|
+
iu0 = n_u - 2
|
|
270
|
+
if iv0 >= n_v - 1:
|
|
271
|
+
iv0 = n_v - 2
|
|
272
|
+
if iu0 < 0:
|
|
273
|
+
iu0 = 0
|
|
274
|
+
if iv0 < 0:
|
|
275
|
+
iv0 = 0
|
|
276
|
+
tu = fu - np.float32(iu0)
|
|
277
|
+
tv = fv - np.float32(iv0)
|
|
278
|
+
if tu < _ZERO:
|
|
279
|
+
tu = _ZERO
|
|
280
|
+
elif tu > _ONE:
|
|
281
|
+
tu = _ONE
|
|
282
|
+
if tv < _ZERO:
|
|
283
|
+
tv = _ZERO
|
|
284
|
+
elif tv > _ONE:
|
|
285
|
+
tv = _ONE
|
|
286
|
+
|
|
287
|
+
omtu = _ONE - tu
|
|
288
|
+
omtv = _ONE - tv
|
|
289
|
+
w_ratio = sid_n / U_n
|
|
290
|
+
w = w_ratio * w_ratio
|
|
291
|
+
value = d_vol[ix, iy, iz] * w
|
|
292
|
+
cuda.atomic.add(d_sino, (iview, iu0, iv0), value * omtu * omtv)
|
|
293
|
+
cuda.atomic.add(d_sino, (iview, iu0 + 1, iv0), value * tu * omtv)
|
|
294
|
+
cuda.atomic.add(d_sino, (iview, iu0, iv0 + 1), value * omtu * tv)
|
|
295
|
+
cuda.atomic.add(d_sino, (iview, iu0 + 1, iv0 + 1), value * tu * tv)
|
diffct/reconstruction.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Composable analytical reconstruction APIs for fixed, flat-detector geometry."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import numbers
|
|
5
|
+
|
|
6
|
+
import torch
|
|
7
|
+
import torch.nn.functional as F
|
|
8
|
+
|
|
9
|
+
from .analytical import (
|
|
10
|
+
_analytical_geometry, angular_integration_weights, cone_weighted_backproject,
|
|
11
|
+
fan_weighted_backproject, parallel_weighted_backproject,
|
|
12
|
+
parker_weights, ramp_filter_1d,
|
|
13
|
+
)
|
|
14
|
+
from .operators import _detector_spacing, _positive_float, _shape, _validate_trajectory
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _fixed(value, name):
|
|
18
|
+
if isinstance(value, torch.Tensor) and value.requires_grad:
|
|
19
|
+
raise ValueError(f"{name} gradients are not supported by analytical reconstruction")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _prepare(sinogram, trajectory, volume_shape, beam, detector_spacing, voxel_spacing, isocenter):
|
|
23
|
+
if not isinstance(sinogram, torch.Tensor) or not sinogram.is_cuda:
|
|
24
|
+
raise ValueError("Analytical reconstruction requires a CUDA sinogram tensor")
|
|
25
|
+
if not torch.is_floating_point(sinogram):
|
|
26
|
+
raise ValueError("sinogram must have a real floating-point dtype")
|
|
27
|
+
# FFT and geometric checks need at least float32 precision. The native
|
|
28
|
+
# cast keeps gradients connected to low-precision input/parameter leaves.
|
|
29
|
+
if sinogram.dtype not in (torch.float32, torch.float64):
|
|
30
|
+
sinogram = sinogram.float()
|
|
31
|
+
rank = 3 if beam == "cone" else 2
|
|
32
|
+
if sinogram.ndim != rank or min(sinogram.shape[1:]) < 2:
|
|
33
|
+
raise ValueError("sinogram rank must match the beam and detector axes need at least two bins")
|
|
34
|
+
for component in trajectory:
|
|
35
|
+
_fixed(component, "trajectory")
|
|
36
|
+
_fixed(isocenter, "isocenter")
|
|
37
|
+
geometry, views = _validate_trajectory(trajectory, beam)
|
|
38
|
+
if sinogram.shape[0] != views:
|
|
39
|
+
raise ValueError("sinogram and trajectory must have the same number of views")
|
|
40
|
+
shape = _shape(volume_shape, rank, "volume_shape")
|
|
41
|
+
pitch = _detector_spacing(detector_spacing, beam)
|
|
42
|
+
spacing = _positive_float(voxel_spacing, "voxel_spacing")
|
|
43
|
+
geometry = tuple(t.to(device=sinogram.device, dtype=sinogram.dtype) for t in geometry)
|
|
44
|
+
return sinogram, geometry, shape, pitch, spacing
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _angles(angles, views, device):
|
|
48
|
+
_fixed(angles, "angles")
|
|
49
|
+
value = torch.as_tensor(angles, device=device)
|
|
50
|
+
if not torch.is_floating_point(value) or value.shape != (views,) or not torch.isfinite(value).all():
|
|
51
|
+
raise ValueError("angles must be a finite real vector with one unwrapped radian angle per view")
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _weights(value, sinogram, name, shape=None):
|
|
56
|
+
value = torch.as_tensor(value, device=sinogram.device, dtype=sinogram.dtype)
|
|
57
|
+
if not torch.isfinite(value).all():
|
|
58
|
+
raise ValueError(f"{name} must contain finite values")
|
|
59
|
+
if shape is not None:
|
|
60
|
+
if value.shape != shape:
|
|
61
|
+
raise ValueError(f"{name} must have shape {shape}")
|
|
62
|
+
else:
|
|
63
|
+
try:
|
|
64
|
+
if torch.broadcast_shapes(value.shape, sinogram.shape) != sinogram.shape:
|
|
65
|
+
raise ValueError
|
|
66
|
+
except (RuntimeError, ValueError) as error:
|
|
67
|
+
raise ValueError(f"{name} must broadcast to the sinogram shape") from error
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _cosine(sinogram, geometry, pitch):
|
|
72
|
+
"""Detector-normal cosine from actual per-view rays, including offsets."""
|
|
73
|
+
source, detector, axis_u = geometry[:3]
|
|
74
|
+
vector = detector - source
|
|
75
|
+
if sinogram.ndim == 2:
|
|
76
|
+
normal = torch.stack((-axis_u[:, 1], axis_u[:, 0]), dim=1)
|
|
77
|
+
else:
|
|
78
|
+
normal = torch.linalg.cross(axis_u, geometry[3], dim=1)
|
|
79
|
+
normal = F.normalize(normal, dim=1)
|
|
80
|
+
distance = (vector * normal).sum(1).abs()
|
|
81
|
+
u = (torch.arange(sinogram.shape[1], device=sinogram.device, dtype=sinogram.dtype)
|
|
82
|
+
- (sinogram.shape[1] - 1) / 2) * pitch[0]
|
|
83
|
+
offset_u = (vector * axis_u).sum(1)
|
|
84
|
+
u = u[None, :] + offset_u[:, None]
|
|
85
|
+
if sinogram.ndim == 2:
|
|
86
|
+
return distance[:, None] / torch.sqrt(distance[:, None].square() + u.square())
|
|
87
|
+
v = (torch.arange(sinogram.shape[2], device=sinogram.device, dtype=sinogram.dtype)
|
|
88
|
+
- (sinogram.shape[2] - 1) / 2) * pitch[1]
|
|
89
|
+
offset_v = (vector * geometry[3]).sum(1)
|
|
90
|
+
v = v[None, :] + offset_v[:, None]
|
|
91
|
+
return distance[:, None, None] / torch.sqrt(
|
|
92
|
+
distance[:, None, None].square() + u[:, :, None].square() + v[:, None, :].square())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _filter(sinogram, pitch, window, pad_factor, response):
|
|
96
|
+
if isinstance(pad_factor, bool) or not isinstance(pad_factor, numbers.Integral) or pad_factor < 2:
|
|
97
|
+
raise ValueError("pad_factor must be an integer >=2 for analytical reconstruction")
|
|
98
|
+
if response is None:
|
|
99
|
+
return ramp_filter_1d(sinogram, dim=1, sample_spacing=pitch,
|
|
100
|
+
pad_factor=pad_factor, window=window)
|
|
101
|
+
n_u = sinogram.shape[1]
|
|
102
|
+
fft_size = pad_factor * n_u
|
|
103
|
+
response = _weights(response, sinogram, "filter_response", (fft_size // 2 + 1,))
|
|
104
|
+
left = (fft_size - n_u) // 2
|
|
105
|
+
right = fft_size - n_u - left
|
|
106
|
+
padded = F.pad(sinogram, (0, 0, left, right) if sinogram.ndim == 3 else (left, right))
|
|
107
|
+
broadcast = (1, len(response)) + (1,) * (sinogram.ndim - 2)
|
|
108
|
+
filtered = torch.fft.irfft(torch.fft.rfft(padded, dim=1) * response.reshape(broadcast),
|
|
109
|
+
n=fft_size, dim=1)
|
|
110
|
+
return filtered.narrow(1, left, n_u)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _parker(angles, sinogram, geometry, pitch, isocenter, geometry_eps):
|
|
114
|
+
source, detector, u = geometry[:3]
|
|
115
|
+
vector = detector - source
|
|
116
|
+
normal = (torch.stack((-u[:, 1], u[:, 0]), dim=1) if sinogram.ndim == 2
|
|
117
|
+
else torch.linalg.cross(u, geometry[3], dim=1))
|
|
118
|
+
normal = F.normalize(normal, dim=1)
|
|
119
|
+
normal = torch.where(((vector * normal).sum(1) < 0)[:, None], -normal, normal)
|
|
120
|
+
distance = (vector * normal).sum(1).abs()
|
|
121
|
+
offset = (vector * u).sum(1)
|
|
122
|
+
# Preserve each input's quantization after _prepare promotes the geometry.
|
|
123
|
+
# Eight eps covers coordinate rounding, subtraction, projection and a
|
|
124
|
+
# comparison between two views; its physical scale also covers translation.
|
|
125
|
+
coordinate_scale = float(torch.linalg.vector_norm(
|
|
126
|
+
source.double().abs() + detector.double().abs(), dim=1).max())
|
|
127
|
+
arithmetic_eps = torch.finfo(source.dtype).eps
|
|
128
|
+
# V participates in the cone normal/distance, but never the U-offset dot.
|
|
129
|
+
distance_precision = max((arithmetic_eps, *geometry_eps))
|
|
130
|
+
offset_precision = max((arithmetic_eps, *geometry_eps[:3]))
|
|
131
|
+
distance_tolerance = max(1e-5, 8 * distance_precision * coordinate_scale)
|
|
132
|
+
offset_tolerance = max(1e-5, 8 * offset_precision * coordinate_scale)
|
|
133
|
+
if not (torch.allclose(distance, distance[:1].expand_as(distance), rtol=1e-4, atol=distance_tolerance)
|
|
134
|
+
and torch.allclose(offset, offset[:1].expand_as(offset), rtol=1e-4, atol=offset_tolerance)):
|
|
135
|
+
raise ValueError("Parker short-scan weighting requires constant detector distance and offset")
|
|
136
|
+
sdd, offset_u = float(distance[0]), float(offset[0])
|
|
137
|
+
gamma_max = math.atan(((sinogram.shape[1] - 1) * pitch[0] / 2 + abs(offset_u)) / sdd)
|
|
138
|
+
span = float(angles.max() - angles.min())
|
|
139
|
+
if span < math.pi + 2 * gamma_max - 1e-5:
|
|
140
|
+
raise ValueError("short scan must cover at least pi + twice the maximum fan angle")
|
|
141
|
+
if not (torch.all(angles[1:] > angles[:-1]) or torch.all(angles[1:] < angles[:-1])):
|
|
142
|
+
raise ValueError("Parker short-scan angles must be strictly monotone")
|
|
143
|
+
|
|
144
|
+
center, _, _ = _analytical_geometry(source, detector, normal, isocenter)
|
|
145
|
+
radial = (source - center).double()
|
|
146
|
+
radius = torch.linalg.vector_norm(radial, dim=1)
|
|
147
|
+
if (not torch.allclose(radius, radius[:1].expand_as(radius), rtol=1e-4, atol=1e-5)
|
|
148
|
+
or not torch.allclose(normal.double(), -F.normalize(radial, dim=1), rtol=1e-4, atol=1e-5)):
|
|
149
|
+
raise ValueError("Parker weighting requires a circular orbit with radial detector normals")
|
|
150
|
+
delta = angles.double() - angles[0].double()
|
|
151
|
+
cosine, sine = delta.cos(), delta.sin()
|
|
152
|
+
index = int(sine.abs().argmax())
|
|
153
|
+
if abs(float(sine[index])) < 1e-4:
|
|
154
|
+
raise ValueError("Parker weighting needs circular views that define the orbit orientation")
|
|
155
|
+
tangent = (radial[index] - radial[0] * cosine[index]) / sine[index]
|
|
156
|
+
predicted = radial[0] * cosine[:, None] + tangent * sine[:, None]
|
|
157
|
+
if (not torch.allclose(predicted, radial, rtol=1e-4, atol=1e-5)
|
|
158
|
+
or not torch.allclose(tangent.norm(), radius[0], rtol=1e-4, atol=1e-5)):
|
|
159
|
+
raise ValueError("Parker weighting requires angles matching a circular orbit")
|
|
160
|
+
# The detector u axis points opposite the source tangent for the scalar
|
|
161
|
+
# Parker helper's convention. Derive its sign from the actual orbit, so a
|
|
162
|
+
# detector-axis mirror plus a data-bin mirror leaves reconstruction intact.
|
|
163
|
+
expected_u = F.normalize(radial[0] * sine[:, None] - tangent * cosine[:, None], dim=1)
|
|
164
|
+
handedness = (expected_u * u.double()).sum(1)
|
|
165
|
+
if not torch.allclose(handedness.abs(), torch.ones_like(handedness), rtol=1e-4, atol=1e-5):
|
|
166
|
+
raise ValueError("Parker weighting requires detector u tangent to the circular orbit")
|
|
167
|
+
if not (torch.all(handedness > 0) or torch.all(handedness < 0)):
|
|
168
|
+
raise ValueError("Parker weighting requires consistent detector-u orientation")
|
|
169
|
+
direction = 1 if angles[-1] > angles[0] else -1
|
|
170
|
+
fan_sign = direction * (1 if handedness[0] > 0 else -1)
|
|
171
|
+
weights = parker_weights(delta * direction, sinogram.shape[1], pitch[0], sdd,
|
|
172
|
+
detector_offset=fan_sign * offset_u)
|
|
173
|
+
if fan_sign < 0:
|
|
174
|
+
weights = weights.flip(1)
|
|
175
|
+
return weights[..., None] if sinogram.ndim == 3 else weights
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _pipeline(sinogram, geometry, angles, beam, pitch, window, pad_factor,
|
|
179
|
+
projection_weights, angular_weights, filter_response, short_scan, isocenter,
|
|
180
|
+
geometry_eps):
|
|
181
|
+
if short_scan and beam == "parallel":
|
|
182
|
+
raise ValueError("Parker short-scan weights apply to fan or cone beams, not parallel")
|
|
183
|
+
if projection_weights is None:
|
|
184
|
+
weighted = sinogram if beam == "parallel" else sinogram * _cosine(sinogram, geometry, pitch)
|
|
185
|
+
else:
|
|
186
|
+
weighted = sinogram * _weights(projection_weights, sinogram, "projection_weights")
|
|
187
|
+
if short_scan:
|
|
188
|
+
weighted = weighted * _parker(angles, sinogram, geometry, pitch, isocenter, geometry_eps)
|
|
189
|
+
filtered = _filter(weighted, pitch[0], window, pad_factor, filter_response)
|
|
190
|
+
relative_angles = angles.double() - angles[0].double()
|
|
191
|
+
angular = (angular_integration_weights(relative_angles) if angular_weights is None
|
|
192
|
+
else _weights(angular_weights, sinogram, "angular_weights", (len(angles),)))
|
|
193
|
+
return filtered * angular.reshape((len(angles),) + (1,) * (sinogram.ndim - 1))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def fbp(sinogram, trajectory, volume_shape, angles, *, beam="parallel",
|
|
197
|
+
detector_spacing=1.0, voxel_spacing=1.0, window="ram-lak", pad_factor=2,
|
|
198
|
+
projection_weights=None, angular_weights=None, filter_response=None,
|
|
199
|
+
short_scan=False, isocenter=None):
|
|
200
|
+
"""Differentiable parallel/fan FBP for fixed flat-detector geometry.
|
|
201
|
+
|
|
202
|
+
``sinogram`` is a CUDA tensor ``(views, bins)``; ``volume_shape`` is ``(H,W)``.
|
|
203
|
+
``angles`` are explicit unwrapped radians. Detector/voxel spacings share the
|
|
204
|
+
trajectory's physical unit. ``projection_weights`` and ``angular_weights``
|
|
205
|
+
replace the default cosine and angular quadrature, respectively.
|
|
206
|
+
``filter_response`` replaces the complete real rFFT multiplier, including
|
|
207
|
+
its physical units; length is ``pad_factor*bins//2+1``. Its use ignores
|
|
208
|
+
``window``. These tensors retain autograd. ``short_scan=True`` adds Parker
|
|
209
|
+
weighting before filtering for a circular fan scan with sufficient coverage.
|
|
210
|
+
Low-precision inputs are promoted to float32 for reconstruction, retaining
|
|
211
|
+
gradients to their original dtype. Output is signed float32; geometry
|
|
212
|
+
gradients are rejected.
|
|
213
|
+
"""
|
|
214
|
+
if beam not in ("parallel", "fan"):
|
|
215
|
+
raise ValueError("fbp beam must be 'parallel' or 'fan'; use fdk for cone data")
|
|
216
|
+
sinogram, geometry, shape, pitch, spacing = _prepare(
|
|
217
|
+
sinogram, trajectory, volume_shape, beam, detector_spacing, voxel_spacing, isocenter)
|
|
218
|
+
angles = _angles(angles, len(geometry[0]), sinogram.device)
|
|
219
|
+
filtered = _pipeline(sinogram, geometry, angles, beam, pitch, window, pad_factor,
|
|
220
|
+
projection_weights, angular_weights, filter_response, short_scan, isocenter,
|
|
221
|
+
tuple(torch.finfo(component.dtype).eps for component in trajectory))
|
|
222
|
+
if beam == "parallel":
|
|
223
|
+
return parallel_weighted_backproject(filtered, *geometry, pitch[0], *shape, spacing)
|
|
224
|
+
return fan_weighted_backproject(filtered, *geometry, pitch[0], *shape, spacing, isocenter=isocenter)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def fdk(sinogram, trajectory, volume_shape, angles, *, detector_spacing=1.0,
|
|
228
|
+
voxel_spacing=1.0, window="ram-lak", pad_factor=2,
|
|
229
|
+
projection_weights=None, angular_weights=None, filter_response=None,
|
|
230
|
+
short_scan=False, isocenter=None):
|
|
231
|
+
"""Differentiable flat-detector FDK; fixed geometry, approximate inversion.
|
|
232
|
+
|
|
233
|
+
``sinogram`` is ``(views,U,V)`` on CUDA; ``volume_shape`` is ``(D,H,W)``.
|
|
234
|
+
Filtering runs along U. ``detector_spacing`` is a scalar or ``(du,dv)``.
|
|
235
|
+
The explicit weight/filter overrides and ``short_scan`` follow :func:`fbp`.
|
|
236
|
+
Returns signed float32 without clamping. The backward pass is the precise
|
|
237
|
+
discrete interpolation transpose; it does not make FDK an exact inverse.
|
|
238
|
+
Use :func:`helical_fdk` for slice-centered integration of an axial helix.
|
|
239
|
+
"""
|
|
240
|
+
sinogram, geometry, shape, pitch, spacing = _prepare(
|
|
241
|
+
sinogram, trajectory, volume_shape, "cone", detector_spacing, voxel_spacing, isocenter)
|
|
242
|
+
angles = _angles(angles, len(geometry[0]), sinogram.device)
|
|
243
|
+
filtered = _pipeline(sinogram, geometry, angles, "cone", pitch, window, pad_factor,
|
|
244
|
+
projection_weights, angular_weights, filter_response, short_scan, isocenter,
|
|
245
|
+
tuple(torch.finfo(component.dtype).eps for component in trajectory))
|
|
246
|
+
return cone_weighted_backproject(filtered, *geometry, *shape, *pitch, spacing, isocenter=isocenter)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def helical_view_weights(angles, source_z, slice_z):
|
|
250
|
+
"""Return ``(slices,views)`` weights for complete slice-centered turns.
|
|
251
|
+
|
|
252
|
+
``angles`` are strictly monotone, unwrapped radians; ``source_z`` is the
|
|
253
|
+
corresponding strictly monotone source height, with at least two samples.
|
|
254
|
+
``slice_z`` holds physical slice centers. Reversed acquisitions are valid.
|
|
255
|
+
Interpolate the angle at each height, then integrate angular sample cells
|
|
256
|
+
over the centered ``2*pi`` interval. Weights include the half redundancy
|
|
257
|
+
factor and sum to pi. Missing coverage raises instead of renormalizing.
|
|
258
|
+
These acquisition coordinates are fixed; learn replacement weights instead.
|
|
259
|
+
"""
|
|
260
|
+
for value, name in ((angles, "angles"), (source_z, "source_z"), (slice_z, "slice_z")):
|
|
261
|
+
_fixed(value, name)
|
|
262
|
+
angles = torch.as_tensor(angles)
|
|
263
|
+
if not torch.is_floating_point(angles) or angles.ndim != 1 or len(angles) < 2:
|
|
264
|
+
raise ValueError("angles must be a real vector with at least two views")
|
|
265
|
+
output_dtype = angles.dtype
|
|
266
|
+
source_z = torch.as_tensor(source_z, device=angles.device, dtype=torch.float64)
|
|
267
|
+
slice_z = torch.as_tensor(slice_z, device=angles.device, dtype=torch.float64)
|
|
268
|
+
if (source_z.shape != angles.shape or slice_z.ndim != 1 or len(slice_z) == 0
|
|
269
|
+
or not all(torch.isfinite(t).all() for t in (angles, source_z, slice_z))):
|
|
270
|
+
raise ValueError("angles/source_z must match and slice_z must be a finite vector")
|
|
271
|
+
for value, name in ((angles, "angles"), (source_z, "source_z")):
|
|
272
|
+
differences = value[1:] - value[:-1]
|
|
273
|
+
if not (torch.all(differences > 0) or torch.all(differences < 0)):
|
|
274
|
+
raise ValueError(f"{name} must be strictly monotone; use circular fdk for zero pitch")
|
|
275
|
+
# Compute interval arithmetic relative to the first angle in float64. A
|
|
276
|
+
# large absolute angle must neither erase pi nor widen the coverage guard.
|
|
277
|
+
angles = angles.double() - angles[0].double()
|
|
278
|
+
source_z, slice_z = source_z.double(), slice_z.double()
|
|
279
|
+
ordered, order = angles.sort()
|
|
280
|
+
heights = source_z[order]
|
|
281
|
+
heights, z_order = heights.sort()
|
|
282
|
+
angle_at_z = ordered[z_order]
|
|
283
|
+
if torch.any(slice_z < heights[0]) or torch.any(slice_z > heights[-1]):
|
|
284
|
+
raise ValueError("Source heights do not cover the requested slices")
|
|
285
|
+
upper = torch.searchsorted(heights, slice_z).clamp(1, len(heights) - 1)
|
|
286
|
+
fraction = (slice_z - heights[upper - 1]) / (heights[upper] - heights[upper - 1])
|
|
287
|
+
center = angle_at_z[upper - 1] + fraction * (angle_at_z[upper] - angle_at_z[upper - 1])
|
|
288
|
+
lower, upper_angle = center - math.pi, center + math.pi
|
|
289
|
+
# Output rounding must not relax the acquired-window coverage contract.
|
|
290
|
+
precision = min(torch.finfo(output_dtype).eps, torch.finfo(torch.float32).eps)
|
|
291
|
+
tolerance = 8 * precision * max(1.0, float(ordered[-1] - ordered[0]))
|
|
292
|
+
if torch.any(lower < ordered[0] - tolerance) or torch.any(upper_angle > ordered[-1] + tolerance):
|
|
293
|
+
raise ValueError("Every reconstructed slice needs a fully acquired 2*pi angular window")
|
|
294
|
+
# Clip Voronoi integration cells, including fractional boundary cells.
|
|
295
|
+
# Their union is the acquired interval. Missing views are never normalized.
|
|
296
|
+
edges = torch.cat((ordered[:1], (ordered[:-1] + ordered[1:]) / 2, ordered[-1:]))
|
|
297
|
+
overlap = (torch.minimum(edges[1:][None, :], upper_angle[:, None])
|
|
298
|
+
- torch.maximum(edges[:-1][None, :], lower[:, None])).clamp_min(0) * 0.5
|
|
299
|
+
weights = torch.empty_like(overlap)
|
|
300
|
+
weights[:, order] = overlap
|
|
301
|
+
return weights.to(output_dtype)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _axial_helix(geometry, isocenter):
|
|
305
|
+
_fixed(isocenter, "isocenter")
|
|
306
|
+
source, detector, u, v = geometry
|
|
307
|
+
center = torch.as_tensor(isocenter, device=source.device, dtype=source.dtype)
|
|
308
|
+
if center.shape != (3,) or not torch.isfinite(center).all():
|
|
309
|
+
raise ValueError("isocenter must be a finite physical (x,y,z) vector")
|
|
310
|
+
radial = source[:, :2] - center[:2]
|
|
311
|
+
radius = torch.linalg.vector_norm(radial, dim=1)
|
|
312
|
+
if torch.any(radius <= 0) or not torch.allclose(radius, radius[:1].expand_as(radius), rtol=1e-4, atol=1e-5):
|
|
313
|
+
raise ValueError("helical FDK requires a constant source radius about isocenter")
|
|
314
|
+
if (not torch.allclose(u[:, 2], torch.zeros_like(u[:, 2]), atol=1e-5)
|
|
315
|
+
or not torch.allclose(v[:, :2], torch.zeros_like(v[:, :2]), atol=1e-5)
|
|
316
|
+
or not torch.allclose(source[:, 2], detector[:, 2], rtol=1e-4, atol=1e-5)):
|
|
317
|
+
raise ValueError("helical FDK requires an axial helix and an untilted vertical detector")
|
|
318
|
+
normal = F.normalize(torch.linalg.cross(u, v), dim=1)
|
|
319
|
+
align = ((detector - source) * normal).sum(1)
|
|
320
|
+
normal = torch.where(align[:, None] < 0, -normal, normal)
|
|
321
|
+
if not torch.allclose(normal[:, :2], -radial / radius[:, None], rtol=1e-4, atol=1e-5):
|
|
322
|
+
raise ValueError("helical FDK requires radial detector normals")
|
|
323
|
+
dz = source[1:, 2] - source[:-1, 2]
|
|
324
|
+
if len(dz) == 0 or not (torch.all(dz > 0) or torch.all(dz < 0)):
|
|
325
|
+
raise ValueError("helical source heights must be strictly monotone; use circular fdk for zero pitch")
|
|
326
|
+
return center
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def helical_weighted_backproject(sinogram, trajectory, volume_shape, view_weights,
|
|
330
|
+
*, isocenter, detector_spacing=1.0,
|
|
331
|
+
voxel_spacing=1.0):
|
|
332
|
+
"""Gather filtered cone data using replaceable ``(D,views)`` weights.
|
|
333
|
+
|
|
334
|
+
``sinogram`` is a filtered CUDA ``(views,U,V)`` tensor. ``view_weights``
|
|
335
|
+
includes angular quadrature and redundancy and is applied AFTER filtering.
|
|
336
|
+
Gradients flow to both tensors through the discrete cone-gather transpose.
|
|
337
|
+
Fixed axial geometry, constant source radius and radial flat detectors are
|
|
338
|
+
required. Caller-supplied weights own their angular coverage and truncation.
|
|
339
|
+
One slice is processed per launch, avoiding a views-by-volume weight array;
|
|
340
|
+
a fused slice-weight kernel can replace this if launch overhead dominates.
|
|
341
|
+
"""
|
|
342
|
+
sinogram, geometry, shape, pitch, spacing = _prepare(
|
|
343
|
+
sinogram, trajectory, volume_shape, "cone", detector_spacing, voxel_spacing, isocenter)
|
|
344
|
+
center = _axial_helix(geometry, isocenter)
|
|
345
|
+
weights = _weights(view_weights, sinogram, "view_weights", (shape[0], len(geometry[0])))
|
|
346
|
+
slices = []
|
|
347
|
+
# One launch per slice; fuse slice weights if measured launch overhead
|
|
348
|
+
# dominates. Preserve ordinary PyTorch weight gradients here.
|
|
349
|
+
for index in range(shape[0]):
|
|
350
|
+
z = (index + 0.5 - shape[0] / 2) * spacing
|
|
351
|
+
shift = sinogram.new_tensor((0, 0, z))
|
|
352
|
+
source, detector, u, v = geometry
|
|
353
|
+
weighted = sinogram * weights[index, :, None, None]
|
|
354
|
+
plane = cone_weighted_backproject(
|
|
355
|
+
weighted, source - shift, detector - shift, u, v,
|
|
356
|
+
1, shape[1], shape[2], *pitch, spacing, isocenter=center - shift)
|
|
357
|
+
slices.append(plane[0])
|
|
358
|
+
return torch.stack(slices)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def helical_fdk(sinogram, trajectory, volume_shape, angles, *, isocenter,
|
|
362
|
+
detector_spacing=1.0, voxel_spacing=1.0, window="ram-lak",
|
|
363
|
+
pad_factor=2, projection_weights=None, view_weights=None,
|
|
364
|
+
filter_response=None):
|
|
365
|
+
"""Approximate axial helical FDK with one complete angular turn per slice.
|
|
366
|
+
|
|
367
|
+
Filters detector U once, then applies :func:`helical_view_weights` inside
|
|
368
|
+
the analytical gather. ``isocenter`` is required in physical coordinates.
|
|
369
|
+
Source heights and unwrapped ``angles`` must agree with the actual helix;
|
|
370
|
+
finite scans must cover a full turn around every requested slice.
|
|
371
|
+
``view_weights`` replaces the complete ``(D,views)`` window/quadrature stage.
|
|
372
|
+
Projection/filter replacements follow :func:`fbp`. Custom tensors retain
|
|
373
|
+
autograd and are not normalized or clamped. Detector truncation is not
|
|
374
|
+
corrected. This native-cone approximation does not implement cone-parallel
|
|
375
|
+
rebinning, 3D conjugate weights or exact Katsevich inversion.
|
|
376
|
+
"""
|
|
377
|
+
sinogram, geometry, shape, pitch, spacing = _prepare(
|
|
378
|
+
sinogram, trajectory, volume_shape, "cone", detector_spacing, voxel_spacing, isocenter)
|
|
379
|
+
center = _axial_helix(geometry, isocenter)
|
|
380
|
+
angles = _angles(angles, len(geometry[0]), sinogram.device)
|
|
381
|
+
relative = geometry[0][:, :2] - center[:2]
|
|
382
|
+
observed = torch.atan2(-relative[:, 0], relative[:, 1])
|
|
383
|
+
phase = observed.double() - angles.double()
|
|
384
|
+
if not torch.allclose(torch.cos(phase - phase[0]), torch.ones_like(phase), rtol=0, atol=1e-5):
|
|
385
|
+
raise ValueError("angles must describe the source's actual unwrapped rotation")
|
|
386
|
+
if view_weights is None:
|
|
387
|
+
z = (torch.arange(shape[0], device=sinogram.device, dtype=torch.float64)
|
|
388
|
+
+ 0.5 - shape[0] / 2) * spacing
|
|
389
|
+
view_weights = helical_view_weights(angles, geometry[0][:, 2], z)
|
|
390
|
+
else:
|
|
391
|
+
# Still validate the acquired order when a caller replaces the windows.
|
|
392
|
+
difference = angles[1:] - angles[:-1]
|
|
393
|
+
if not (torch.all(difference > 0) or torch.all(difference < 0)):
|
|
394
|
+
raise ValueError("helical angles must be strictly monotone")
|
|
395
|
+
weighted = (sinogram * _cosine(sinogram, geometry, pitch) if projection_weights is None
|
|
396
|
+
else sinogram * _weights(projection_weights, sinogram, "projection_weights"))
|
|
397
|
+
filtered = _filter(weighted, pitch[0], window, pad_factor, filter_response)
|
|
398
|
+
return helical_weighted_backproject(filtered, geometry, shape, view_weights,
|
|
399
|
+
isocenter=center, detector_spacing=pitch, voxel_spacing=spacing)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: diffct
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.1
|
|
4
4
|
Summary: Differentiable CUDA projectors for CT: arbitrary trajectories, multi-GPU and multi-node execution, geometry gradients
|
|
5
5
|
Project-URL: Homepage, https://github.com/sypsyp97/diffct
|
|
6
6
|
Project-URL: Documentation, https://sypsyp97.github.io/diffct/
|
|
@@ -77,7 +77,7 @@ To our knowledge, diffct is the only open-source GPU CT library that combines ar
|
|
|
77
77
|
- **Matched operators.** `project()` and `backproject()` form an exact adjoint pair for the cell-constant Siddon model. Both support PyTorch autograd, with volume/sinogram gradients and Hessian-vector products.
|
|
78
78
|
- **Large volumes.** Automatic spatial, view and pixel blocks with bounded transfer buffers control working memory. `project_into`/`backproject_into` accept caller-owned tensor or disk stores; tensor autograd keeps complete data on its input device. See [chunked execution](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#chunked-execution).
|
|
79
79
|
- **Multi-GPU and multi-node.** Use `devices=[0, 1, 2, 3]` in one process, or torchrun and NCCL across ranks. Default `partition="views"` shards projections and replicates volumes. `partition="space"` gives each rank an owned volume slab and combines ray contributions. Speedup depends on workload and communication costs.
|
|
80
|
-
- **
|
|
80
|
+
- **Differentiable FBP and FDK.** `fbp`, `fdk` and approximate `helical_fdk` compose the public filters, weights and analytical backprojectors. Their backward kernels transpose the actual interpolation stencil; custom filters and weights can be learned. See the [analytical guide](docs/source/analytical_learning.rst).
|
|
81
81
|
|
|
82
82
|
Capabilities, limits and isocenter rules: [docs/REFERENCE.md](https://github.com/sypsyp97/diffct/blob/main/docs/REFERENCE.md#capabilities-and-limits).
|
|
83
83
|
|
|
@@ -220,7 +220,7 @@ Walnut data: Meaney 2022, Zenodo 6986012, CC BY 4.0; see [examples/data/NOTICE](
|
|
|
220
220
|
|
|
221
221
|
## Examples
|
|
222
222
|
|
|
223
|
-
`quickstart.py`, `curved_detector.py`, `chunked_reconstruction.py`, `analytical_reconstruction.py`, `iterative_reconstruction.py`, `walnut_reconstruction.py`, `geometry_calibration.py`, `benchmark_projector.py`, `plot_trajectory.py`, and the Slurm template `slurm/multi_node.sbatch`. Launch modes and distributed-loss rules are in [examples/README.md](https://github.com/sypsyp97/diffct/blob/main/examples/README.md).
|
|
223
|
+
`quickstart.py`, `curved_detector.py`, `chunked_reconstruction.py`, `disk_reconstruction.py`, `benchmark_execution.py`, `analytical_reconstruction.py`, `learnable_reconstruction.py`, `helical_fdk.py`, `iterative_reconstruction.py`, `walnut_reconstruction.py`, `geometry_calibration.py`, `benchmark_projector.py`, `benchmark_scaling.py`, `plot_trajectory.py`, and the Slurm template `slurm/multi_node.sbatch`. Launch modes and distributed-loss rules are in [examples/README.md](https://github.com/sypsyp97/diffct/blob/main/examples/README.md).
|
|
224
224
|
|
|
225
225
|
## Documentation
|
|
226
226
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
diffct/__init__.py,sha256=
|
|
2
|
-
diffct/analytical.py,sha256=
|
|
1
|
+
diffct/__init__.py,sha256=megkY4RH88JkBLvG24tSJizwAfyCSoO_Qb3ZLsR1o8g,2572
|
|
2
|
+
diffct/analytical.py,sha256=_utt7n9dxR-D11NJuQ2wjI-9Fd5J62rm9AzMyB-fTpk,32318
|
|
3
3
|
diffct/chunking.py,sha256=Ny7_QCGQDpNlQQAJQ_Y8XnTK3dvVWLt-U0GUozqC2Wg,9485
|
|
4
4
|
diffct/constants.py,sha256=IKC_0u9aoNCG4M13KcRrl2plO0dTiokac-2aCYJJMb4,3060
|
|
5
5
|
diffct/differentiable.py,sha256=cFirhqXRpUemL7y7Sl_0uqZKGJNj__ptQEcTwYNKzys,1879
|
|
@@ -7,17 +7,19 @@ diffct/geometry.py,sha256=ZFXqrn2b1kmwzlXqDFGHPVOhHkuAsjRuFbUF8slnOYQ,38154
|
|
|
7
7
|
diffct/operators.py,sha256=Ia-WCK1L6O77bIcOiuLFXnq10jWoMvb5LbyivmWHViQ,118162
|
|
8
8
|
diffct/pipeline.py,sha256=jp7AvDeQQOME3OPkO9XTcjWFLvOLrv8wczhAf1jcklM,10891
|
|
9
9
|
diffct/projectors.py,sha256=xrPtQWQwVCrUovAXZX82q4J_RxCURZffF4GQ0ua_2sY,42520
|
|
10
|
+
diffct/reconstruction.py,sha256=I546L5bNigSb39oOHSJtRZeslI8fleNSBv7w-iGd994,23484
|
|
10
11
|
diffct/storage.py,sha256=EdcFTzRtEAPRvAZhRPrSf8gRNF5o_JVbPLE8eKPhNdY,4153
|
|
11
12
|
diffct/streaming.py,sha256=EjHOQd5RECMl2QLYyqEARwUzzrUAyEaFsCv5UFUjWSo,2380
|
|
12
13
|
diffct/surfaces.py,sha256=OYVju0urcF8SzTkbGF-BJQrYZw1YjieYwCWH5ziypn8,5155
|
|
13
14
|
diffct/utils.py,sha256=S-87F7z4AcxajtLbxIXlkLd7esE5ISncsBEwAFDIxQs,14648
|
|
14
|
-
diffct/kernels/__init__.py,sha256=
|
|
15
|
+
diffct/kernels/__init__.py,sha256=QPA-d6Mg1ZAaOiqjLYGxuRyPtt9C5IhN0kyyxC739_w,1366
|
|
16
|
+
diffct/kernels/analytical_transpose.py,sha256=D4vr_ZFPrjFeQyhoDqTyijbkTY-g-sHJet52ho-cbkQ,9187
|
|
15
17
|
diffct/kernels/cone_beam.py,sha256=Y17Yeb8qhUXDoiRPk6s3xQJChG4uHk7L7aBVf5VQXo8,25400
|
|
16
18
|
diffct/kernels/fan_beam.py,sha256=J3Oj3LXEOcTSaB0pBf7tne_itqBJSgAPH3CmRtgCOCg,17068
|
|
17
19
|
diffct/kernels/geometry_vjp.py,sha256=rv2v8QqCDEtMollYAOBiyVA6w8BhUV9Wl0UBsQnmNCk,20907
|
|
18
20
|
diffct/kernels/parallel_beam.py,sha256=tCiSx9W8nO4mn8DVyRuK4E0Ry8q38g3pAUACab0UYVU,17143
|
|
19
|
-
diffct-2.2.
|
|
20
|
-
diffct-2.2.
|
|
21
|
-
diffct-2.2.
|
|
22
|
-
diffct-2.2.
|
|
23
|
-
diffct-2.2.
|
|
21
|
+
diffct-2.2.1.dist-info/METADATA,sha256=gwqjaVILUAR3Mh90hHrg9s7Y4GdlGbFwW4xWpo1htP8,15905
|
|
22
|
+
diffct-2.2.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
23
|
+
diffct-2.2.1.dist-info/licenses/LICENSE,sha256=a1fjzGs44LpDnV_LmA2Tzp8z-eJKoNVVdLOnh-F6J6E,11344
|
|
24
|
+
diffct-2.2.1.dist-info/licenses/NOTICE,sha256=du9I2sDF5IDfiY9vF64WWzMJh-MarpVXyLfWQmhbvmI,714
|
|
25
|
+
diffct-2.2.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|