diffct 2.4.0__tar.gz → 2.4.1__tar.gz
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-2.4.0 → diffct-2.4.1}/CHANGELOG.md +11 -0
- {diffct-2.4.0 → diffct-2.4.1}/CITATION.cff +1 -1
- {diffct-2.4.0 → diffct-2.4.1}/PKG-INFO +7 -7
- {diffct-2.4.0 → diffct-2.4.1}/README.md +6 -6
- {diffct-2.4.0 → diffct-2.4.1}/diffct/__init__.py +1 -1
- {diffct-2.4.0 → diffct-2.4.1}/diffct/_solver_state.py +7 -1
- {diffct-2.4.0 → diffct-2.4.1}/diffct/backends.py +165 -110
- {diffct-2.4.0 → diffct-2.4.1}/diffct/kernels/cone_beam.py +27 -20
- {diffct-2.4.0 → diffct-2.4.1}/diffct/kernels/fan_beam.py +23 -16
- {diffct-2.4.0 → diffct-2.4.1}/diffct/kernels/geometry_vjp.py +45 -31
- {diffct-2.4.0 → diffct-2.4.1}/diffct/operators.py +69 -32
- {diffct-2.4.0 → diffct-2.4.1}/diffct/projectors.py +3 -2
- {diffct-2.4.0 → diffct-2.4.1}/pyproject.toml +1 -1
- diffct-2.4.1/tests/test_checkpoint_metadata_validation.py +154 -0
- diffct-2.4.1/tests/test_live_trajectory_validation.py +234 -0
- diffct-2.4.1/tests/test_native_endpoint_precision.py +182 -0
- diffct-2.4.1/tests/test_portable_sampler_snapshot.py +160 -0
- diffct-2.4.1/tests/test_surface_gpu_execution.py +221 -0
- diffct-2.4.1/tests/test_surface_parameter_updates.py +271 -0
- {diffct-2.4.0 → diffct-2.4.1}/.gitignore +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/LICENSE +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/NOTICE +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/diffct/_solver_tv.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/diffct/analytical.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/diffct/analytical_execution.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/diffct/chunking.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/diffct/constants.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/diffct/differentiable.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/diffct/distribution.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/diffct/geometry.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/diffct/kernels/__init__.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/diffct/kernels/analytical_transpose.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/diffct/kernels/parallel_beam.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/diffct/kernels/surface_geometry.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/diffct/pipeline.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/diffct/reconstruction.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/diffct/solvers.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/diffct/storage.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/diffct/streaming.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/diffct/surface_gradients.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/diffct/surfaces.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/diffct/utils.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/docs/video/README.md +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/examples/README.md +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/examples/_block_cgls.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/examples/_common.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/examples/data/NOTICE +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/examples/data/preprocess_walnut.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/examples/walnut_reconstruction.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/formal/README.md +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/pytest.ini +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/__init__.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/_phantoms.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/benchmarks/README.md +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/benchmarks/__init__.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/benchmarks/_common.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/benchmarks/test_bench_cone.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/benchmarks/test_bench_fan.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/benchmarks/test_bench_parallel.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/distributed_projector_check.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_adjoint_inner_product.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_allocator_capacity.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_analytical_autograd.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_analytical_distribution.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_analytical_regressions.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_analytical_weight_distribution.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_angular_low_precision.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_axis_aligned_chords.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_batch_execution_edges.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_batched_surfaces.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_block_cgls.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_block_storage.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_call_trajectory.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_cgls_state_admission.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_chunked_projector.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_completed_transfer_reuse.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_cone_backprojection_transpose.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_cone_projector_autograd.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_cpu_install.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_cuda_exit_deallocations.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_cuda_sink_lifetimes.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_cuda_smoke.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_detector_surface_dtypes.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_detector_surface_mapping.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_detector_surface_units.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_detector_surfaces.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_distributed_training_transforms.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_example_psnr.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_execution_buffers.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_execution_features.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_execution_schedules.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_fbp_fan_accuracy.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_fbp_fan_offsets.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_fbp_parallel_accuracy.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_fdk_cone_accuracy.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_fdk_cone_offsets.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_geometry_autograd.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_geometry_conversions.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_geometry_gradients.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_gradcheck.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_lazy_native_observers.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_multi_gpu_exit.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_native_surface_transforms.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_native_volume_layout.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_native_volume_layout_distributed.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_output_pipeline_overlap.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_parallel3d.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_parallel3d_surface_oracle.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_partition_lifecycle.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_plan_memory_bounds.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_portable_distributed_geometry.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_portable_endpoint_precision.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_portable_execution_bounds.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_portable_numeric_regressions.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_portable_surface_admission.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_projector_api.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_projector_degenerate_geometry.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_projector_geometry_devices.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_ramp_filter_windows.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_reconstruction_api.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_reference_backend.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_release_2_0_1.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_review_230.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_review_regressions.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_solver_admission_regressions.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_solver_api.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_solver_checkpoint_edges.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_solver_collective_failures.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_spatial_partition.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_surface_collective_failures.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_surface_composition_kernel.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_surface_compute_shards.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_surface_geometry_cudasim.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_surface_gradient_replay.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_surface_native_regressions.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_surface_pipeline_contracts.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_surface_trajectory_transform.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_training_transforms.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_trajectory_admission_cost.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_trajectory_surface_integration.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_transfer_pipeline.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_transfer_retirement_lifetimes.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_tv_zero_dependency.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_two_far_endpoints.py +0 -0
- {diffct-2.4.0 → diffct-2.4.1}/tests/test_weights.py +0 -0
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.4.1] - 2026-10-20
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Let `ParameterizedSurface` use full native CUDA execution when the existing memory limits admit it. Full curved-detector geometry gradients now pull back one GPU composition graph instead of replaying tiny pixel fragments through host autograd; explicitly chunked and memory-limited calls retain bounded streaming (#37).
|
|
15
|
+
- Revalidate updated `ParameterizedSurface` pixels on portable calls and native data transforms. Native `*_into` calls validate all sampled batches before changing caller output (#37).
|
|
16
|
+
- Apply call-time trajectory admission to retained, trainable constructor frames, including geometry updated into degenerate or out-of-range values. Flat CUDA frame validation avoids bulk CPU round trips (#37).
|
|
17
|
+
- Keep each portable distributed forward's surface sampler for backward when callers replace the sampler or parameters before differentiating (#37).
|
|
18
|
+
- Preserve native fan/cone ray weights for distant endpoints by clipping and rebasing each ray in FP64 on the GPU, while retaining FP32 local traversal (#37).
|
|
19
|
+
- Reject malformed checkpoint metadata structures collectively before solver work, keeping distributed process groups usable after a failed resume (#37).
|
|
20
|
+
|
|
10
21
|
## [2.4.0] - 2026-10-20
|
|
11
22
|
|
|
12
23
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: diffct
|
|
3
|
-
Version: 2.4.
|
|
3
|
+
Version: 2.4.1
|
|
4
4
|
Summary: Differentiable CT projection and reconstruction: precision modes, streaming, distributed execution and geometry gradients
|
|
5
5
|
Project-URL: Homepage, https://github.com/sypsyp97/diffct
|
|
6
6
|
Project-URL: Documentation, https://sypsyp97.github.io/diffct/
|
|
@@ -177,18 +177,18 @@ volume slabs use `--partition space` under torchrun. Run
|
|
|
177
177
|
|
|
178
178
|
<p align="center">
|
|
179
179
|
<picture>
|
|
180
|
-
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_dark.png?v=2.4.
|
|
181
|
-
<img src="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_light.png?v=2.4.
|
|
180
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_dark.png?v=2.4.1">
|
|
181
|
+
<img src="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_light.png?v=2.4.1" width="100%" alt="Time per forward, adjoint and CGLS iteration on 1, 4 and 8 GPUs, 512^3 volume">
|
|
182
182
|
</picture>
|
|
183
183
|
</p>
|
|
184
184
|
|
|
185
185
|
| 512³ volume | 1 GPU | 4 GPUs, 1 node | 8 GPUs, 2 nodes |
|
|
186
186
|
|---|---:|---:|---:|
|
|
187
|
-
| Forward projection |
|
|
188
|
-
| Adjoint (backprojection) |
|
|
189
|
-
| CGLS iteration |
|
|
187
|
+
| Forward projection | 569.99 ms | 151.02 ms (3.8×) | 81.50 ms (7.0×) |
|
|
188
|
+
| Adjoint (backprojection) | 1061.36 ms | 278.75 ms (3.8×) | 220.34 ms (4.8×) |
|
|
189
|
+
| CGLS iteration | 1756.03 ms | 468.05 ms (3.8×) | 333.08 ms (5.3×) |
|
|
190
190
|
|
|
191
|
-
diffct 2.4.
|
|
191
|
+
diffct 2.4.1, FP32, A100 64 GB GPUs; circular cone beam, 360 views, 1024×768 detector, pitch 1.25. Default memory settings, without an imposed allocator cap.
|
|
192
192
|
|
|
193
193
|
Projection times are medians of seven samples after three warmups. CGLS uses three ten-iteration solves after a two-iteration warmup; the median includes initialization and is divided by ten. Timings use synchronized wall time and the slowest rank. Raw samples and configuration: [scaling.json](https://github.com/sypsyp97/diffct/blob/main/docs/assets/scaling.json).
|
|
194
194
|
|
|
@@ -146,18 +146,18 @@ volume slabs use `--partition space` under torchrun. Run
|
|
|
146
146
|
|
|
147
147
|
<p align="center">
|
|
148
148
|
<picture>
|
|
149
|
-
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_dark.png?v=2.4.
|
|
150
|
-
<img src="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_light.png?v=2.4.
|
|
149
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_dark.png?v=2.4.1">
|
|
150
|
+
<img src="https://raw.githubusercontent.com/sypsyp97/diffct/main/docs/assets/scaling_light.png?v=2.4.1" width="100%" alt="Time per forward, adjoint and CGLS iteration on 1, 4 and 8 GPUs, 512^3 volume">
|
|
151
151
|
</picture>
|
|
152
152
|
</p>
|
|
153
153
|
|
|
154
154
|
| 512³ volume | 1 GPU | 4 GPUs, 1 node | 8 GPUs, 2 nodes |
|
|
155
155
|
|---|---:|---:|---:|
|
|
156
|
-
| Forward projection |
|
|
157
|
-
| Adjoint (backprojection) |
|
|
158
|
-
| CGLS iteration |
|
|
156
|
+
| Forward projection | 569.99 ms | 151.02 ms (3.8×) | 81.50 ms (7.0×) |
|
|
157
|
+
| Adjoint (backprojection) | 1061.36 ms | 278.75 ms (3.8×) | 220.34 ms (4.8×) |
|
|
158
|
+
| CGLS iteration | 1756.03 ms | 468.05 ms (3.8×) | 333.08 ms (5.3×) |
|
|
159
159
|
|
|
160
|
-
diffct 2.4.
|
|
160
|
+
diffct 2.4.1, FP32, A100 64 GB GPUs; circular cone beam, 360 views, 1024×768 detector, pitch 1.25. Default memory settings, without an imposed allocator cap.
|
|
161
161
|
|
|
162
162
|
Projection times are medians of seven samples after three warmups. CGLS uses three ten-iteration solves after a two-iteration warmup; the median includes initialization and is divided by ten. Timings use synchronized wall time and the slowest rank. Raw samples and configuration: [scaling.json](https://github.com/sypsyp97/diffct/blob/main/docs/assets/scaling.json).
|
|
163
163
|
|
|
@@ -211,6 +211,8 @@ def load_checkpoint(operator, directory, expected, iterations, elements, names):
|
|
|
211
211
|
try:
|
|
212
212
|
directory = Path(directory).resolve()
|
|
213
213
|
saved = json.loads((directory / "metadata.json").read_text(encoding="utf-8"))
|
|
214
|
+
if not isinstance(saved, dict):
|
|
215
|
+
raise ValueError("checkpoint metadata must be an object")
|
|
214
216
|
# JSON canonicalization also normalizes tuple-valued physical spacing.
|
|
215
217
|
canonical = json.loads(json.dumps(expected))
|
|
216
218
|
if any(saved.get(key) != value for key, value in canonical.items()):
|
|
@@ -225,12 +227,16 @@ def load_checkpoint(operator, directory, expected, iterations, elements, names):
|
|
|
225
227
|
if expected["algorithm"] in ("cgls", "cgls_into"):
|
|
226
228
|
finite(saved.get("gamma"), "checkpoint gamma")
|
|
227
229
|
manifest = saved.get("states", {})
|
|
230
|
+
if not isinstance(manifest, dict):
|
|
231
|
+
raise ValueError("checkpoint states must be an object")
|
|
228
232
|
if set(manifest) != set(names):
|
|
229
233
|
raise ValueError("checkpoint does not contain the complete solver state")
|
|
230
234
|
states = {}
|
|
231
235
|
for name in names:
|
|
232
|
-
store = NpyStore(directory / f"{name}.npy", mode="r")
|
|
233
236
|
specification = manifest[name]
|
|
237
|
+
if not isinstance(specification, dict):
|
|
238
|
+
raise ValueError("checkpoint state specification must be an object")
|
|
239
|
+
store = NpyStore(directory / f"{name}.npy", mode="r")
|
|
234
240
|
shape = operator.projection_shape if name in ("r", "q", "row") else operator.volume_shape
|
|
235
241
|
if name == "step":
|
|
236
242
|
shape = (1,)
|
|
@@ -168,7 +168,8 @@ class ReferenceProjector:
|
|
|
168
168
|
self.spacing = voxel_spacing_tuple(owner.voxel_spacing, len(owner.global_volume_shape))
|
|
169
169
|
self.chunk_shape, self.view_chunk_size, self.detector_chunk_shape = self._work_chunks()
|
|
170
170
|
if not validate:
|
|
171
|
-
#
|
|
171
|
+
# Call-time owners validate frames separately and sample surfaces
|
|
172
|
+
# from the invocation's single geometry snapshot below.
|
|
172
173
|
return
|
|
173
174
|
with torch.enable_grad():
|
|
174
175
|
geometry = self._snapshot_geometry(synchronize=synchronize)
|
|
@@ -273,8 +274,7 @@ class ReferenceProjector:
|
|
|
273
274
|
def _validate_geometry(self, geometry, fallback, *, admission=False, synchronize=True):
|
|
274
275
|
"""Finish every local geometry check before any rank starts numerics."""
|
|
275
276
|
owner = self.owner
|
|
276
|
-
if not admission and
|
|
277
|
-
(owner.world_size == 1 and isinstance(owner.detector_surface, ParameterizedSurface))):
|
|
277
|
+
if not admission and owner.detector_surface is None:
|
|
278
278
|
return
|
|
279
279
|
devices = tuple(torch.device(device) for device in (owner._devices or (fallback,)))
|
|
280
280
|
error = None
|
|
@@ -294,14 +294,8 @@ class ReferenceProjector:
|
|
|
294
294
|
device = devices[batch % len(devices)]
|
|
295
295
|
origins, directions, _ = self._rays(
|
|
296
296
|
views, pixels, device, validate=True, geometry=staged[device],
|
|
297
|
-
admission=admission,
|
|
298
297
|
)
|
|
299
|
-
|
|
300
|
-
or not torch.isfinite(directions.to(dtype=owner.dtype)).all().item()):
|
|
301
|
-
raise ValueError("detector surface world coordinates must be finite")
|
|
302
|
-
if (owner.beam not in ("parallel", "parallel3d")
|
|
303
|
-
and torch.any(torch.all(directions == 0, dim=-1)).item()):
|
|
304
|
-
raise ValueError("source and detector surface pixels must differ")
|
|
298
|
+
self._check_rays(origins, directions)
|
|
305
299
|
except (TypeError, ValueError, RuntimeError) as failure:
|
|
306
300
|
error = failure
|
|
307
301
|
if synchronize:
|
|
@@ -309,10 +303,23 @@ class ReferenceProjector:
|
|
|
309
303
|
elif error is not None:
|
|
310
304
|
raise error
|
|
311
305
|
|
|
312
|
-
def
|
|
306
|
+
def _check_rays(self, origins, directions):
|
|
307
|
+
owner = self.owner
|
|
308
|
+
if (not torch.isfinite(origins.to(dtype=owner.dtype)).all().item()
|
|
309
|
+
or not torch.isfinite(directions.to(dtype=owner.dtype)).all().item()):
|
|
310
|
+
raise ValueError("detector surface world coordinates must be finite")
|
|
311
|
+
if (owner.beam not in ("parallel", "parallel3d")
|
|
312
|
+
and torch.any(torch.all(directions == 0, dim=-1)).item()):
|
|
313
|
+
raise ValueError("source and detector surface pixels must differ")
|
|
314
|
+
|
|
315
|
+
def _rays(self, views, pixels, device, validate=False, stats=None, geometry=None, *, admit=False):
|
|
316
|
+
"""Rays of one view/pixel batch.
|
|
317
|
+
|
|
318
|
+
``admit`` applies the admission checks of ``_validate_geometry`` to the
|
|
319
|
+
same sampled offsets before composing the numerical rays, so a
|
|
320
|
+
parameterized sampler runs once for both.
|
|
321
|
+
"""
|
|
313
322
|
owner = self.owner
|
|
314
|
-
# Admission compares original coordinates before FP32 rounding.
|
|
315
|
-
dtype = torch.float64 if validate and admission else owner.dtype
|
|
316
323
|
axes = [(torch.arange(pixel.start, pixel.stop, dtype=torch.float64, device="cpu")
|
|
317
324
|
+ .5 - size / 2) * pitch
|
|
318
325
|
for pixel, size, pitch in zip(pixels, owner.detector_shape, owner.detector_spacing)]
|
|
@@ -338,99 +345,138 @@ class ReferenceProjector:
|
|
|
338
345
|
offsets = offsets[views]
|
|
339
346
|
elif tuple(offsets.shape) not in (shared_shape, (len(ids), *shared_shape)):
|
|
340
347
|
raise ValueError("detector_surface output shape must match the selected view/pixel batch")
|
|
341
|
-
if validate:
|
|
348
|
+
if validate or admit:
|
|
342
349
|
if not torch.isfinite(offsets).all().item():
|
|
343
350
|
raise ValueError("detector_surface offsets must be finite")
|
|
344
351
|
if owner.beam not in ("cone", "parallel3d") and torch.any(offsets[..., 1] != 0).item():
|
|
345
352
|
raise ValueError("2D detector_surface middle offsets must be zero")
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
353
|
+
def compose(validate, stats):
|
|
354
|
+
check_range = (validate and owner.beam not in ("parallel", "parallel3d")
|
|
355
|
+
and owner.dtype == torch.float32 and surface is not None)
|
|
356
|
+
dtype = torch.float64 if check_range else owner.dtype
|
|
357
|
+
frames = (owner._learnable or owner._trajectory) if geometry is None else geometry[:frame_count]
|
|
358
|
+
native_surface = owner.backend == "numba" and surface is not None
|
|
359
|
+
native_legacy = native_surface and not isinstance(surface, ParameterizedSurface)
|
|
360
|
+
if native_surface and not native_legacy:
|
|
361
|
+
dtype = torch.float32
|
|
362
|
+
for component in (*frames, offsets):
|
|
363
|
+
dtype = torch.promote_types(dtype, component.dtype)
|
|
364
|
+
local_dtype = offsets.dtype if native_legacy else dtype
|
|
365
|
+
local = self._transfer(offsets.to(dtype=local_dtype), device, stats)
|
|
366
|
+
if local.ndim == u.ndim + 1:
|
|
367
|
+
local = local.unsqueeze(0)
|
|
368
|
+
dimensions = len(owner.global_volume_shape)
|
|
369
|
+
selected = tuple(self._transfer(value[views].to(
|
|
370
|
+
dtype=torch.promote_types(torch.float32, value.dtype) if native_legacy else dtype,
|
|
371
|
+
), device, stats) for value in frames)
|
|
372
|
+
|
|
373
|
+
def frame(value):
|
|
374
|
+
return value.reshape(len(ids), *([1] * u.ndim), dimensions)
|
|
375
|
+
|
|
376
|
+
def world_points(offsets, components):
|
|
377
|
+
axis_u = frame(components[2])
|
|
378
|
+
points = frame(components[1]) + offsets[..., 0, None] * axis_u
|
|
379
|
+
if owner.beam in ("cone", "parallel3d"):
|
|
380
|
+
axis_v = frame(components[3])
|
|
381
|
+
points = points + offsets[..., 1, None] * axis_v
|
|
382
|
+
normal_dtype = torch.promote_types(axis_u.dtype, axis_v.dtype)
|
|
383
|
+
normal = torch.linalg.cross(axis_u.to(dtype=normal_dtype),
|
|
384
|
+
axis_v.to(dtype=normal_dtype), dim=-1)
|
|
385
|
+
else:
|
|
386
|
+
normal = torch.stack((axis_u[..., 1], -axis_u[..., 0]), dim=-1)
|
|
387
|
+
return points + offsets[..., 2, None] * normal
|
|
388
|
+
|
|
389
|
+
if check_range and not native_surface:
|
|
390
|
+
# Certify ordinary batches without expanding raw FP64 world points.
|
|
391
|
+
# L1 norms bound Euclidean distances without squaring tiny bases.
|
|
392
|
+
pitches = local.new_tensor(self.spacing[::-1])
|
|
393
|
+
maximum = local.abs().amax(dim=tuple(range(1, local.ndim - 1)))
|
|
394
|
+
axis_u = selected[2]
|
|
395
|
+
if owner.beam == "cone":
|
|
396
|
+
basis = (axis_u, selected[3], torch.linalg.cross(axis_u, selected[3], dim=-1))
|
|
397
|
+
components = (0, 1, 2)
|
|
398
|
+
else:
|
|
399
|
+
basis = (axis_u, torch.stack((axis_u[:, 1], -axis_u[:, 0]), dim=-1))
|
|
400
|
+
components = (0, 2)
|
|
401
|
+
source_bound = torch.linalg.vector_norm(selected[0] / pitches, ord=1, dim=-1)
|
|
402
|
+
pixel_bound = torch.linalg.vector_norm(selected[1] / pitches, ord=1, dim=-1)
|
|
403
|
+
for component, axis in zip(components, basis):
|
|
404
|
+
pixel_bound = pixel_bound + maximum[:, component] * torch.linalg.vector_norm(
|
|
405
|
+
axis / pitches, ord=1, dim=-1,
|
|
406
|
+
)
|
|
407
|
+
# Cover rounding in the few frame/offset operations; nonfinite or
|
|
408
|
+
# boundary-adjacent bounds remain inconclusive and use exact points.
|
|
409
|
+
slack = 64 * torch.finfo(torch.float64).eps
|
|
410
|
+
source_bound = source_bound + slack * source_bound.clamp_min(1)
|
|
411
|
+
pixel_bound = pixel_bound + slack * pixel_bound.clamp_min(1)
|
|
412
|
+
certified = (torch.isfinite(source_bound) & torch.isfinite(pixel_bound)
|
|
413
|
+
& (torch.minimum(source_bound, pixel_bound) <= 1e6)
|
|
414
|
+
& (source_bound <= 1e15) & (pixel_bound <= 1e15))
|
|
415
|
+
if certified.all().item():
|
|
416
|
+
check_range = False
|
|
417
|
+
dtype = owner.dtype
|
|
418
|
+
selected = tuple(value.to(dtype=dtype) for value in selected)
|
|
419
|
+
local = local.to(dtype=dtype)
|
|
420
|
+
points = world_points(local, selected)
|
|
421
|
+
if check_range:
|
|
422
|
+
# Raw FP64 coordinates preserve the exact admission boundaries.
|
|
423
|
+
sources = frame(selected[0]).expand_as(points)
|
|
424
|
+
pitches = points.new_tensor(self.spacing[::-1])
|
|
425
|
+
source_distance = torch.linalg.vector_norm(sources / pitches, dim=-1)
|
|
426
|
+
pixel_distance = torch.linalg.vector_norm(points / pitches, dim=-1)
|
|
427
|
+
if torch.any(torch.minimum(source_distance, pixel_distance) > 1e6).item():
|
|
428
|
+
raise ValueError(
|
|
429
|
+
"the source or a detector pixel must lie within 1e6 voxels "
|
|
430
|
+
"of the volume center in every view"
|
|
431
|
+
)
|
|
432
|
+
if torch.any((source_distance > 1e15) | (pixel_distance > 1e15)).item():
|
|
433
|
+
raise ValueError(
|
|
434
|
+
"the source and detector surface pixels must lie within 1e15 voxels "
|
|
435
|
+
"of the volume center"
|
|
436
|
+
)
|
|
437
|
+
if native_surface:
|
|
438
|
+
# Native surface preprocessing composes before staging complete
|
|
439
|
+
# rays to FP32. Data transforms must preserve that operator.
|
|
440
|
+
selected = tuple(value.to(dtype=owner.dtype) for value in selected)
|
|
441
|
+
points = points.to(dtype=owner.dtype)
|
|
442
|
+
elif validate and dtype != owner.dtype:
|
|
443
|
+
# Casting the completed point misses cancellation/overflow during
|
|
444
|
+
# FP32 component arithmetic. Reuse the same sampled offsets.
|
|
445
|
+
selected = tuple(value.to(dtype=owner.dtype) for value in selected)
|
|
446
|
+
points = world_points(local.to(dtype=owner.dtype), selected)
|
|
447
|
+
if owner.beam in ("parallel", "parallel3d"):
|
|
448
|
+
origins, directions = points, frame(selected[0]).expand_as(points)
|
|
374
449
|
else:
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
points = world_points(local.to(dtype=owner.dtype), selected)
|
|
406
|
-
if owner.beam in ("parallel", "parallel3d"):
|
|
407
|
-
origins, directions = points, frame(selected[0]).expand_as(points)
|
|
408
|
-
else:
|
|
409
|
-
sources = frame(selected[0]).expand_as(points)
|
|
410
|
-
pitches = points.new_tensor(self.spacing[::-1])
|
|
411
|
-
source_distance = torch.linalg.vector_norm(sources / pitches, dim=-1)
|
|
412
|
-
pixel_distance = torch.linalg.vector_norm(points / pitches, dim=-1)
|
|
413
|
-
if validate:
|
|
414
|
-
if torch.any(torch.all(
|
|
415
|
-
points.to(dtype=owner.dtype) == sources.to(dtype=owner.dtype), dim=-1,
|
|
416
|
-
)).item():
|
|
417
|
-
raise ValueError("source and detector surface pixels must differ")
|
|
418
|
-
# Starting near the volume avoids subtracting its voxel planes
|
|
419
|
-
# from a distant endpoint; reversing a segment preserves [0, 1].
|
|
420
|
-
source_nearer = (source_distance <= pixel_distance).unsqueeze(-1)
|
|
421
|
-
origins = torch.where(source_nearer, sources, points)
|
|
422
|
-
difference = points - sources
|
|
423
|
-
directions = torch.where(source_nearer, difference, -difference)
|
|
424
|
-
|
|
425
|
-
pixel_ids = torch.meshgrid(*(torch.arange(pixel.start, pixel.stop, device=device)
|
|
426
|
-
for pixel in pixels), indexing="ij")
|
|
427
|
-
detector_ids = torch.zeros_like(pixel_ids[0])
|
|
428
|
-
for coordinate, size in zip(pixel_ids, owner.detector_shape):
|
|
429
|
-
detector_ids = detector_ids * size + coordinate
|
|
430
|
-
ray_ids = ((self._transfer(ids, device, stats) - owner.view_slice.start).reshape(
|
|
431
|
-
len(ids), *([1] * u.ndim),
|
|
432
|
-
) * math.prod(owner.detector_shape) + detector_ids).reshape(-1)
|
|
433
|
-
return origins.reshape(-1, dimensions), directions.reshape(-1, dimensions), ray_ids
|
|
450
|
+
sources = frame(selected[0]).expand_as(points)
|
|
451
|
+
pitches = points.new_tensor(self.spacing[::-1])
|
|
452
|
+
source_distance = torch.linalg.vector_norm(sources / pitches, dim=-1)
|
|
453
|
+
pixel_distance = torch.linalg.vector_norm(points / pitches, dim=-1)
|
|
454
|
+
if validate:
|
|
455
|
+
if torch.any(torch.all(
|
|
456
|
+
points.to(dtype=owner.dtype) == sources.to(dtype=owner.dtype), dim=-1,
|
|
457
|
+
)).item():
|
|
458
|
+
raise ValueError("source and detector surface pixels must differ")
|
|
459
|
+
# Starting near the volume avoids subtracting its voxel planes
|
|
460
|
+
# from a distant endpoint; reversing a segment preserves [0, 1].
|
|
461
|
+
source_nearer = (source_distance <= pixel_distance).unsqueeze(-1)
|
|
462
|
+
origins = torch.where(source_nearer, sources, points)
|
|
463
|
+
difference = points - sources
|
|
464
|
+
directions = torch.where(source_nearer, difference, -difference)
|
|
465
|
+
|
|
466
|
+
pixel_ids = torch.meshgrid(*(torch.arange(pixel.start, pixel.stop, device=device)
|
|
467
|
+
for pixel in pixels), indexing="ij")
|
|
468
|
+
detector_ids = torch.zeros_like(pixel_ids[0])
|
|
469
|
+
for coordinate, size in zip(pixel_ids, owner.detector_shape):
|
|
470
|
+
detector_ids = detector_ids * size + coordinate
|
|
471
|
+
ray_ids = ((self._transfer(ids, device, stats) - owner.view_slice.start).reshape(
|
|
472
|
+
len(ids), *([1] * u.ndim),
|
|
473
|
+
) * math.prod(owner.detector_shape) + detector_ids).reshape(-1)
|
|
474
|
+
return origins.reshape(-1, dimensions), directions.reshape(-1, dimensions), ray_ids
|
|
475
|
+
|
|
476
|
+
if admit:
|
|
477
|
+
with torch.no_grad():
|
|
478
|
+
self._check_rays(*compose(True, None)[:2])
|
|
479
|
+
return compose(validate, stats)
|
|
434
480
|
|
|
435
481
|
def _intervals(self, origins, directions, tile):
|
|
436
482
|
owner = self.owner
|
|
@@ -547,7 +593,7 @@ class ReferenceProjector:
|
|
|
547
593
|
return indices.flatten()
|
|
548
594
|
|
|
549
595
|
def _tensor_apply(self, tensor, is_project, reduce_cotangent=False, *, geometry=None,
|
|
550
|
-
reduce_output=True, _plan=None):
|
|
596
|
+
reduce_output=True, _plan=None, admit=False):
|
|
551
597
|
owner = self.owner
|
|
552
598
|
owner.last_execution_stats = None
|
|
553
599
|
start = time.perf_counter()
|
|
@@ -588,7 +634,7 @@ class ReferenceProjector:
|
|
|
588
634
|
slices[axis] = slice(first, min(first + step, leading[axis]))
|
|
589
635
|
pieces.append(self._tensor_apply(tensor[tuple(slices)], is_project, reduce_cotangent,
|
|
590
636
|
geometry=geometry, reduce_output=reduce_output,
|
|
591
|
-
_plan=plan))
|
|
637
|
+
_plan=plan, admit=admit))
|
|
592
638
|
part_stats = owner.last_execution_stats
|
|
593
639
|
for key in counters:
|
|
594
640
|
stats[key] += part_stats[key]
|
|
@@ -603,13 +649,14 @@ class ReferenceProjector:
|
|
|
603
649
|
# Empty ownership and empty leading dimensions retain a differentiable seam.
|
|
604
650
|
output = output + _zero_dependency(tensor).to(dtype=owner.dtype)
|
|
605
651
|
if is_project:
|
|
606
|
-
for tile, world in self._tiles(plan[0]):
|
|
652
|
+
for tile_index, (tile, world) in enumerate(self._tiles(plan[0])):
|
|
607
653
|
values = tensor[(..., *tile)]
|
|
608
654
|
if reduce_cotangent:
|
|
609
655
|
values = self._reduce(values, owner.view_group, owner.view_world_size, stats)
|
|
610
656
|
for batch, (views, pixels) in enumerate(self._batches(*plan[1:])):
|
|
611
657
|
device = self._device(batch, tensor.device, stats)
|
|
612
|
-
origins, directions, rays = self._rays(views, pixels, device, stats=stats, geometry=geometry
|
|
658
|
+
origins, directions, rays = self._rays(views, pixels, device, stats=stats, geometry=geometry,
|
|
659
|
+
admit=admit and tile_index == 0)
|
|
613
660
|
indices, lengths = self._intervals(origins, directions, world)
|
|
614
661
|
local = self._to(values, device, stats).reshape(*leading, math.prod(values.shape[-len(tile):]))
|
|
615
662
|
selected = torch.index_select(local, -1, indices.flatten()).reshape(*leading, *indices.shape)
|
|
@@ -632,7 +679,8 @@ class ReferenceProjector:
|
|
|
632
679
|
if reduce_cotangent:
|
|
633
680
|
values = self._reduce(values, owner.space_group, owner.space_world_size, stats)
|
|
634
681
|
values = self._to(values, device, stats).reshape(*leading, math.prod(values.shape[-len(shape):]))
|
|
635
|
-
origins, directions, _ = self._rays(views, pixels, device, stats=stats, geometry=geometry
|
|
682
|
+
origins, directions, _ = self._rays(views, pixels, device, stats=stats, geometry=geometry,
|
|
683
|
+
admit=admit)
|
|
636
684
|
for tile, world in self._tiles(plan[0]):
|
|
637
685
|
indices, lengths = self._intervals(origins, directions, world)
|
|
638
686
|
tile_shape = tuple(part.stop - part.start for part in tile)
|
|
@@ -657,10 +705,17 @@ class ReferenceProjector:
|
|
|
657
705
|
|
|
658
706
|
def _apply(self, tensor, is_project):
|
|
659
707
|
geometry = self._snapshot_geometry()
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
708
|
+
owner = self.owner
|
|
709
|
+
# A single process returns no output before every batch passes, so a
|
|
710
|
+
# parameterized surface is admitted in the numerical sampling pass and
|
|
711
|
+
# its sampler runs once per batch. Distributed calls finish all checks
|
|
712
|
+
# before any rank starts numerics.
|
|
713
|
+
admit = owner.world_size == 1 and isinstance(owner.detector_surface, ParameterizedSurface)
|
|
714
|
+
if not admit:
|
|
715
|
+
self._validate_geometry(geometry, tensor.device if isinstance(tensor, torch.Tensor) else torch.device("cpu"))
|
|
716
|
+
if owner.world_size > 1 and torch.is_grad_enabled():
|
|
717
|
+
return _DistributedApply.apply(tensor, self, is_project, False, owner._participation(), *geometry)
|
|
718
|
+
return self._tensor_apply(tensor, is_project, geometry=geometry, admit=admit)
|
|
664
719
|
|
|
665
720
|
def _geometry_grad(self, tensor, cotangent, is_project, reduce_cotangent, geometry):
|
|
666
721
|
owner = self.owner
|
|
@@ -120,6 +120,11 @@ def _cone_3d_forward_kernel(
|
|
|
120
120
|
det_y = d_detector_positions[iview, iu, iv, 1] / voxel_spacing
|
|
121
121
|
det_z = d_detector_positions[iview, iu, iv, 2] / voxel_spacing
|
|
122
122
|
|
|
123
|
+
# Preserve native endpoint composition, then clip in float64: when both
|
|
124
|
+
# endpoints are distant, float32 loses voxel widths before rebasing.
|
|
125
|
+
src_x, src_y, src_z = np.float64(src_x), np.float64(src_y), np.float64(src_z)
|
|
126
|
+
det_x, det_y, det_z = np.float64(det_x), np.float64(det_y), np.float64(det_z)
|
|
127
|
+
|
|
123
128
|
# === 3D RAY DIRECTION CALCULATION ===
|
|
124
129
|
# Ray direction vector from source to detector element in 3D space
|
|
125
130
|
dir_x, dir_y, dir_z = det_x - src_x, det_y - src_y, det_z - src_z
|
|
@@ -133,7 +138,7 @@ def _cone_3d_forward_kernel(
|
|
|
133
138
|
|
|
134
139
|
# === 3D RAY-VOLUME INTERSECTION CALCULATION ===
|
|
135
140
|
# Measure the ray parameter from the endpoint nearer the volume centre, so a
|
|
136
|
-
# distant source does not make the
|
|
141
|
+
# distant source does not make the parameters coarse. Only the
|
|
137
142
|
# source-to-detector segment is integrated.
|
|
138
143
|
if src_x * src_x + src_y * src_y + src_z * src_z <= det_x * det_x + det_y * det_y + det_z * det_z:
|
|
139
144
|
org_x = src_x
|
|
@@ -170,15 +175,14 @@ def _cone_3d_forward_kernel(
|
|
|
170
175
|
if t_min >= t_max: # No valid 3D intersection
|
|
171
176
|
d_sino[iview, iu, iv] = _ZERO; return
|
|
172
177
|
|
|
173
|
-
#
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
t_end = t_max - t_min
|
|
178
|
+
# Only setup needs float64; traverse in float32 from the volume entry.
|
|
179
|
+
ent_x = np.float32(org_x + t_min * dir_x)
|
|
180
|
+
ent_y = np.float32(org_y + t_min * dir_y)
|
|
181
|
+
ent_z = np.float32(org_z + t_min * dir_z)
|
|
182
|
+
ray_x = np.float32(dir_x)
|
|
183
|
+
ray_y = np.float32(dir_y)
|
|
184
|
+
ray_z = np.float32(dir_z)
|
|
185
|
+
t_end = np.float32(t_max - t_min)
|
|
182
186
|
|
|
183
187
|
# === 3D SIDDON METHOD TRAVERSAL INITIALIZATION ===
|
|
184
188
|
accum = _ZERO # Accumulated projection value
|
|
@@ -337,6 +341,10 @@ def _cone_3d_backward_kernel(
|
|
|
337
341
|
det_y = d_detector_positions[iview, iu, iv, 1] / voxel_spacing
|
|
338
342
|
det_z = d_detector_positions[iview, iu, iv, 2] / voxel_spacing
|
|
339
343
|
|
|
344
|
+
# Match forward setup while retaining native endpoint composition.
|
|
345
|
+
src_x, src_y, src_z = np.float64(src_x), np.float64(src_y), np.float64(src_z)
|
|
346
|
+
det_x, det_y, det_z = np.float64(det_x), np.float64(det_y), np.float64(det_z)
|
|
347
|
+
|
|
340
348
|
# === 3D RAY DIRECTION CALCULATION ===
|
|
341
349
|
# Ray direction vector from source to detector element in 3D space
|
|
342
350
|
dir_x, dir_y, dir_z = det_x - src_x, det_y - src_y, det_z - src_z
|
|
@@ -347,7 +355,7 @@ def _cone_3d_backward_kernel(
|
|
|
347
355
|
|
|
348
356
|
# === 3D RAY-VOLUME INTERSECTION CALCULATION ===
|
|
349
357
|
# Measure the ray parameter from the endpoint nearer the volume centre, so a
|
|
350
|
-
# distant source does not make the
|
|
358
|
+
# distant source does not make the parameters coarse. Only the
|
|
351
359
|
# source-to-detector segment is integrated.
|
|
352
360
|
if src_x * src_x + src_y * src_y + src_z * src_z <= det_x * det_x + det_y * det_y + det_z * det_z:
|
|
353
361
|
org_x = src_x
|
|
@@ -380,15 +388,14 @@ def _cone_3d_backward_kernel(
|
|
|
380
388
|
|
|
381
389
|
if t_min >= t_max: return
|
|
382
390
|
|
|
383
|
-
#
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
t_end = t_max - t_min
|
|
391
|
+
# Only setup needs float64; traverse in float32 from the volume entry.
|
|
392
|
+
ent_x = np.float32(org_x + t_min * dir_x)
|
|
393
|
+
ent_y = np.float32(org_y + t_min * dir_y)
|
|
394
|
+
ent_z = np.float32(org_z + t_min * dir_z)
|
|
395
|
+
ray_x = np.float32(dir_x)
|
|
396
|
+
ray_y = np.float32(dir_y)
|
|
397
|
+
ray_z = np.float32(dir_z)
|
|
398
|
+
t_end = np.float32(t_max - t_min)
|
|
392
399
|
|
|
393
400
|
# === 3D SIDDON METHOD TRAVERSAL INITIALIZATION ===
|
|
394
401
|
t = _ZERO
|