nanodrr 0.1.0__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.
- nanodrr-0.1.0/PKG-INFO +56 -0
- nanodrr-0.1.0/README.md +41 -0
- nanodrr-0.1.0/pyproject.toml +48 -0
- nanodrr-0.1.0/src/nanodrr/__init__.py +0 -0
- nanodrr-0.1.0/src/nanodrr/camera/.ipynb_checkpoints/extrinsics-checkpoint.py +70 -0
- nanodrr-0.1.0/src/nanodrr/camera/.ipynb_checkpoints/intrinsics-checkpoint.py +55 -0
- nanodrr-0.1.0/src/nanodrr/camera/.ipynb_checkpoints/matrices-checkpoint.py +174 -0
- nanodrr-0.1.0/src/nanodrr/camera/__init__.py +4 -0
- nanodrr-0.1.0/src/nanodrr/camera/extrinsics.py +70 -0
- nanodrr-0.1.0/src/nanodrr/camera/intrinsics.py +57 -0
- nanodrr-0.1.0/src/nanodrr/data/.ipynb_checkpoints/__init__-checkpoint.py +4 -0
- nanodrr-0.1.0/src/nanodrr/data/.ipynb_checkpoints/demo-checkpoint.py +22 -0
- nanodrr-0.1.0/src/nanodrr/data/.ipynb_checkpoints/io-checkpoint.py +125 -0
- nanodrr-0.1.0/src/nanodrr/data/.ipynb_checkpoints/lac-checkpoint.py +10 -0
- nanodrr-0.1.0/src/nanodrr/data/.ipynb_checkpoints/preprocess-checkpoint.py +39 -0
- nanodrr-0.1.0/src/nanodrr/data/__init__.py +4 -0
- nanodrr-0.1.0/src/nanodrr/data/demo.py +23 -0
- nanodrr-0.1.0/src/nanodrr/data/io.py +125 -0
- nanodrr-0.1.0/src/nanodrr/data/preprocess.py +39 -0
- nanodrr-0.1.0/src/nanodrr/drr/.ipynb_checkpoints/__init__-checkpoint.py +4 -0
- nanodrr-0.1.0/src/nanodrr/drr/.ipynb_checkpoints/drr-checkpoint.py +50 -0
- nanodrr-0.1.0/src/nanodrr/drr/.ipynb_checkpoints/render-checkpoint.py +97 -0
- nanodrr-0.1.0/src/nanodrr/drr/__init__.py +4 -0
- nanodrr-0.1.0/src/nanodrr/drr/drr.py +70 -0
- nanodrr-0.1.0/src/nanodrr/drr/render.py +97 -0
- nanodrr-0.1.0/src/nanodrr/geometry/.ipynb_checkpoints/transform-checkpoint.py +29 -0
- nanodrr-0.1.0/src/nanodrr/geometry/__init__.py +4 -0
- nanodrr-0.1.0/src/nanodrr/geometry/se3.py +254 -0
- nanodrr-0.1.0/src/nanodrr/geometry/transform.py +28 -0
- nanodrr-0.1.0/src/nanodrr/metrics/.ipynb_checkpoints/geo-checkpoint.py +45 -0
- nanodrr-0.1.0/src/nanodrr/metrics/__init__.py +13 -0
- nanodrr-0.1.0/src/nanodrr/metrics/geo.py +45 -0
- nanodrr-0.1.0/src/nanodrr/metrics/ncc.py +133 -0
- nanodrr-0.1.0/src/nanodrr/plot/__init__.py +3 -0
- nanodrr-0.1.0/src/nanodrr/plot/plot.py +167 -0
- nanodrr-0.1.0/src/nanodrr/py.typed +0 -0
- nanodrr-0.1.0/src/nanodrr/registration/.ipynb_checkpoints/registration-checkpoint.py +55 -0
- nanodrr-0.1.0/src/nanodrr/registration/__init__.py +3 -0
- nanodrr-0.1.0/src/nanodrr/registration/registration.py +52 -0
- nanodrr-0.1.0/src/nanodrr/scene/__init__.py +3 -0
- nanodrr-0.1.0/src/nanodrr/scene/camera.py +76 -0
- nanodrr-0.1.0/src/nanodrr/scene/scene.py +62 -0
- nanodrr-0.1.0/src/nanodrr/scene/surface.py +12 -0
- nanodrr-0.1.0/src/nanodrr/scene/utils.py +22 -0
nanodrr-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: nanodrr
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Blazing fast differentiable DRR rendering in modern PyTorch
|
|
5
|
+
Requires-Dist: jaxtyping>=0.3.0
|
|
6
|
+
Requires-Dist: matplotlib>=3.0.0
|
|
7
|
+
Requires-Dist: roma>=1.5.6
|
|
8
|
+
Requires-Dist: torch>=2.4.0
|
|
9
|
+
Requires-Dist: torchio>=0.21.0
|
|
10
|
+
Requires-Dist: pyvista[all]>=0.47.0 ; extra == 'scene'
|
|
11
|
+
Requires-Dist: vtk>=9.6.0 ; extra == 'scene'
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Provides-Extra: scene
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# nanodrr
|
|
17
|
+
|
|
18
|
+
A performance-oriented reimplementation of [`DiffDRR`](https://github.com/eigenvivek/DiffDRR) with the following improvements:
|
|
19
|
+
|
|
20
|
+
- Optimized, pure PyTorch implementation (**~5× faster than `DiffDRR` at baseline**)
|
|
21
|
+
- Modular design (freely swap subjects, extrinsics, and intrinsics during rendering)
|
|
22
|
+
- Compatibility with `torch.compile` and mixed precision
|
|
23
|
+
- Extensive type hints with `jaxtyping`
|
|
24
|
+
- Standard Python package structure managed with `uv`
|
|
25
|
+
|
|
26
|
+
All projective geometry is implemented internally using the standard [Hartley and Zisserman](https://www.cambridge.org/core/books/multiple-view-geometry-in-computer-vision/0B6F289C78B2B23F596CAA76D3D43F7A) pinhole camera formulation.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
> [!NOTE]
|
|
31
|
+
>
|
|
32
|
+
> On `pytorch<2.9`, `torch.compile` with `bfloat16` is slower than eager due to a CUDA graph capture issue (see [Benchmarks](#benchmarks)). Use `pytorch>=2.9` (Triton ≥3.5) for best results.
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pip install "git+https://github.com/eigenvivek/nanodrr.git"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Benchmarks
|
|
39
|
+
|
|
40
|
+
> [!IMPORTANT]
|
|
41
|
+
> - **~5× faster** than [`DiffDRR`](https://github.com/eigenvivek/DiffDRR) out of the box, without compilation (946 FPS vs 213 FPS)
|
|
42
|
+
> - **~8× faster** with `torch.compile` and `bfloat16` on `pytorch>=2.9` (1,650 FPS vs 213 FPS)
|
|
43
|
+
> - **~2.5× less memory** than `DiffDRR` (516 MB vs 1,344 MB peak reserved with `bfloat16` + compile)
|
|
44
|
+
|
|
45
|
+

|
|
46
|
+
|
|
47
|
+
> *Mean ± std. dev. of 10 runs, 100 loops each. Benchmarked by rendering 200×200 DRRs on an NVIDIA RTX 6000 Ada (48 GB) with Python 3.12. Compile represents `torch.compile(mode="reduce-overhead", fullgraph=True)`. Full experiment at [`tests/benchmark/`](tests/benchmark/).*
|
|
48
|
+
|
|
49
|
+
## Roadmap
|
|
50
|
+
|
|
51
|
+
- [x] Implement a fully optimized renderer
|
|
52
|
+
- [x] Port strictly necessary modules from `DiffDRR` (e.g., SE(3) utilities, loss functions, and 2D plotting)
|
|
53
|
+
- [x] Migrate 3D plotting functions to an optional module
|
|
54
|
+
- [ ] Integrate with [`xvr`](https://github.com/eigenvivek/xvr) to speed up network training and registration
|
|
55
|
+
- [ ] Integrate with [`polypose`](https://github.com/eigenvivek/polypose) to speed up registration
|
|
56
|
+
- [ ] Release as `v1.0.0` of `DiffDRR`!
|
nanodrr-0.1.0/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# nanodrr
|
|
2
|
+
|
|
3
|
+
A performance-oriented reimplementation of [`DiffDRR`](https://github.com/eigenvivek/DiffDRR) with the following improvements:
|
|
4
|
+
|
|
5
|
+
- Optimized, pure PyTorch implementation (**~5× faster than `DiffDRR` at baseline**)
|
|
6
|
+
- Modular design (freely swap subjects, extrinsics, and intrinsics during rendering)
|
|
7
|
+
- Compatibility with `torch.compile` and mixed precision
|
|
8
|
+
- Extensive type hints with `jaxtyping`
|
|
9
|
+
- Standard Python package structure managed with `uv`
|
|
10
|
+
|
|
11
|
+
All projective geometry is implemented internally using the standard [Hartley and Zisserman](https://www.cambridge.org/core/books/multiple-view-geometry-in-computer-vision/0B6F289C78B2B23F596CAA76D3D43F7A) pinhole camera formulation.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
> [!NOTE]
|
|
16
|
+
>
|
|
17
|
+
> On `pytorch<2.9`, `torch.compile` with `bfloat16` is slower than eager due to a CUDA graph capture issue (see [Benchmarks](#benchmarks)). Use `pytorch>=2.9` (Triton ≥3.5) for best results.
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
pip install "git+https://github.com/eigenvivek/nanodrr.git"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Benchmarks
|
|
24
|
+
|
|
25
|
+
> [!IMPORTANT]
|
|
26
|
+
> - **~5× faster** than [`DiffDRR`](https://github.com/eigenvivek/DiffDRR) out of the box, without compilation (946 FPS vs 213 FPS)
|
|
27
|
+
> - **~8× faster** with `torch.compile` and `bfloat16` on `pytorch>=2.9` (1,650 FPS vs 213 FPS)
|
|
28
|
+
> - **~2.5× less memory** than `DiffDRR` (516 MB vs 1,344 MB peak reserved with `bfloat16` + compile)
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+
|
|
32
|
+
> *Mean ± std. dev. of 10 runs, 100 loops each. Benchmarked by rendering 200×200 DRRs on an NVIDIA RTX 6000 Ada (48 GB) with Python 3.12. Compile represents `torch.compile(mode="reduce-overhead", fullgraph=True)`. Full experiment at [`tests/benchmark/`](tests/benchmark/).*
|
|
33
|
+
|
|
34
|
+
## Roadmap
|
|
35
|
+
|
|
36
|
+
- [x] Implement a fully optimized renderer
|
|
37
|
+
- [x] Port strictly necessary modules from `DiffDRR` (e.g., SE(3) utilities, loss functions, and 2D plotting)
|
|
38
|
+
- [x] Migrate 3D plotting functions to an optional module
|
|
39
|
+
- [ ] Integrate with [`xvr`](https://github.com/eigenvivek/xvr) to speed up network training and registration
|
|
40
|
+
- [ ] Integrate with [`polypose`](https://github.com/eigenvivek/polypose) to speed up registration
|
|
41
|
+
- [ ] Release as `v1.0.0` of `DiffDRR`!
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "nanodrr"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Blazing fast differentiable DRR rendering in modern PyTorch"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"jaxtyping>=0.3.0",
|
|
9
|
+
"matplotlib>=3.0.0",
|
|
10
|
+
"roma>=1.5.6",
|
|
11
|
+
"torch>=2.4.0",
|
|
12
|
+
"torchio>=0.21.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
scene = [
|
|
17
|
+
"pyvista[all]>=0.47.0",
|
|
18
|
+
"vtk>=9.6.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["uv_build>=0.10.2,<0.11.0"]
|
|
23
|
+
build-backend = "uv_build"
|
|
24
|
+
|
|
25
|
+
[tool.ruff]
|
|
26
|
+
line-length = 120
|
|
27
|
+
|
|
28
|
+
[tool.ruff.lint]
|
|
29
|
+
ignore = [
|
|
30
|
+
"F722", # Forward annotation false positive from jaxtyping
|
|
31
|
+
"F821", # Forward annotation false positive from jaxtyping
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = [
|
|
36
|
+
"prek>=0.3.2",
|
|
37
|
+
]
|
|
38
|
+
docs = [
|
|
39
|
+
"griffe>=2.0.0",
|
|
40
|
+
"mkdocs-callouts>=1.16.0",
|
|
41
|
+
"mkdocs-gen-files>=0.6.0",
|
|
42
|
+
"mkdocs-jupyter>=0.25.1",
|
|
43
|
+
"mkdocs-literate-nav>=0.6.2",
|
|
44
|
+
"mkdocs-material>=9.7.1",
|
|
45
|
+
"mkdocs-same-dir>=0.1.3",
|
|
46
|
+
"mkdocs-section-index>=0.3.10",
|
|
47
|
+
"mkdocstrings[python]>=1.0.3",
|
|
48
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
from jaxtyping import Float
|
|
3
|
+
|
|
4
|
+
from ..geometry import convert
|
|
5
|
+
|
|
6
|
+
_ORIENTATION_MATRICES = {
|
|
7
|
+
"AP": [
|
|
8
|
+
[-1, 0, 0, 0],
|
|
9
|
+
[ 0, 0, -1, 0],
|
|
10
|
+
[ 0,-1, 0, 0],
|
|
11
|
+
[ 0, 0, 0, 1],
|
|
12
|
+
],
|
|
13
|
+
"PA": [
|
|
14
|
+
[-1, 0, 0, 0],
|
|
15
|
+
[ 0, 0, 1, 0],
|
|
16
|
+
[ 0,-1, 0, 0],
|
|
17
|
+
[ 0, 0, 0, 1],
|
|
18
|
+
],
|
|
19
|
+
None: [
|
|
20
|
+
[-1, 0, 0, 0],
|
|
21
|
+
[ 0,-1, 0, 0],
|
|
22
|
+
[ 0, 0, 1, 0],
|
|
23
|
+
[ 0, 0, 0, 1],
|
|
24
|
+
],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_rt_inv(
|
|
29
|
+
rotation: Float[torch.Tensor, "B 3"],
|
|
30
|
+
translation: Float[torch.Tensor, "B 3"],
|
|
31
|
+
orientation: str | None = "AP",
|
|
32
|
+
isocenter: Float[torch.Tensor, "3"] | None = None,
|
|
33
|
+
) -> Float[torch.Tensor, "B 4 4"]:
|
|
34
|
+
"""Create 4x4 camera-to-world (extrinsic inverse) matrices.
|
|
35
|
+
|
|
36
|
+
Composes pose and reorientation as ``extrinsic_inv = pose @ reorient``
|
|
37
|
+
so that *translation* is applied in the pre-reoriented frame.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
rotation: (B, 3) Euler angles (z, x, y) in degrees, ZXY convention.
|
|
41
|
+
translation: (B, 3) camera position in mm, relative to *isocenter*
|
|
42
|
+
(or world origin when isocenter is ``None``).
|
|
43
|
+
orientation: ``"AP"``, ``"PA"``, or ``None``.
|
|
44
|
+
isocenter: Optional (3,) volume centre in world coordinates.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
(B, 4, 4) camera-to-world transformation matrices.
|
|
48
|
+
"""
|
|
49
|
+
if orientation not in _ORIENTATION_MATRICES:
|
|
50
|
+
raise ValueError(f"Unknown orientation: {orientation}. Use 'AP', 'PA', or None")
|
|
51
|
+
|
|
52
|
+
device = rotation.device
|
|
53
|
+
dtype = rotation.dtype
|
|
54
|
+
|
|
55
|
+
if isocenter is None:
|
|
56
|
+
isocenter = torch.zeros(3, device=device, dtype=dtype)
|
|
57
|
+
|
|
58
|
+
pose = convert(rotation, translation, "euler", convention="ZXY", isocenter=isocenter)
|
|
59
|
+
orientation_matrix = _get_orientation_matrix(orientation, device, dtype)
|
|
60
|
+
|
|
61
|
+
return pose @ orientation_matrix
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get_orientation_matrix(
|
|
65
|
+
orientation: str | None,
|
|
66
|
+
device: torch.device,
|
|
67
|
+
dtype: torch.dtype,
|
|
68
|
+
) -> Float[torch.Tensor, "4 4"]:
|
|
69
|
+
"""Return the combined orientation + Rz(180°) matrix."""
|
|
70
|
+
return torch.tensor(_ORIENTATION_MATRICES[orientation], device=device, dtype=dtype)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
from jaxtyping import Float
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def make_k_inv(
|
|
6
|
+
sdd: float,
|
|
7
|
+
delx: float,
|
|
8
|
+
dely: float,
|
|
9
|
+
x0: float,
|
|
10
|
+
y0: float,
|
|
11
|
+
height: int,
|
|
12
|
+
width: int,
|
|
13
|
+
dtype: torch.dtype | None = None,
|
|
14
|
+
device: torch.device | None = None,
|
|
15
|
+
) -> Float[torch.Tensor, "1 3 3"]:
|
|
16
|
+
"""Build the inverse intrinsic matrix K⁻¹ for a cone-beam projector.
|
|
17
|
+
|
|
18
|
+
Focal lengths and principal point are derived from the physical geometry:
|
|
19
|
+
|
|
20
|
+
fx = sdd / delx cy = y0 / dely + height / 2
|
|
21
|
+
fy = sdd / dely cx = x0 / delx + width / 2
|
|
22
|
+
|
|
23
|
+
The returned matrix is the analytical inverse of:
|
|
24
|
+
|
|
25
|
+
K = [[fx, 0, cx],
|
|
26
|
+
[0, fy, cy],
|
|
27
|
+
[0, 0, 1]]
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
sdd: Source-to-detector distance (mm).
|
|
31
|
+
delx, dely: Pixel spacing in x and y (mm/px).
|
|
32
|
+
x0, y0: Principal-point offset from detector centre (mm).
|
|
33
|
+
height, width: Detector dimensions in pixels.
|
|
34
|
+
dtype: Optional tensor dtype.
|
|
35
|
+
device: Optional tensor device.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
(1, 3, 3) inverse intrinsic matrix.
|
|
39
|
+
"""
|
|
40
|
+
fx = sdd / delx
|
|
41
|
+
fy = sdd / dely
|
|
42
|
+
cx = x0 / delx + width / 2.0
|
|
43
|
+
cy = y0 / dely + height / 2.0
|
|
44
|
+
|
|
45
|
+
return torch.tensor(
|
|
46
|
+
[
|
|
47
|
+
[
|
|
48
|
+
[1.0 / fx, 0.0, -cx / fx],
|
|
49
|
+
[0.0, 1.0 / fy, -cy / fy],
|
|
50
|
+
[0.0, 0.0, 1.0],
|
|
51
|
+
]
|
|
52
|
+
],
|
|
53
|
+
dtype=dtype,
|
|
54
|
+
device=device,
|
|
55
|
+
)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def make_k_inv(
|
|
5
|
+
sdd: float,
|
|
6
|
+
delx: float,
|
|
7
|
+
dely: float,
|
|
8
|
+
x0: float,
|
|
9
|
+
y0: float,
|
|
10
|
+
height: int,
|
|
11
|
+
width: int,
|
|
12
|
+
) -> torch.Tensor:
|
|
13
|
+
fx = sdd / delx
|
|
14
|
+
fy = sdd / dely
|
|
15
|
+
cx = x0 / delx + width / 2.0
|
|
16
|
+
cy = y0 / dely + height / 2.0
|
|
17
|
+
|
|
18
|
+
fx_inv = 1.0 / fx
|
|
19
|
+
fy_inv = 1.0 / fy
|
|
20
|
+
|
|
21
|
+
return torch.tensor(
|
|
22
|
+
[
|
|
23
|
+
[
|
|
24
|
+
[fx_inv, 0.0, -cx * fx_inv],
|
|
25
|
+
[0.0, fy_inv, -cy * fy_inv],
|
|
26
|
+
[0.0, 0.0, 1.0],
|
|
27
|
+
]
|
|
28
|
+
]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def make_rt_inv(
|
|
33
|
+
rotation: torch.Tensor,
|
|
34
|
+
translation: torch.Tensor,
|
|
35
|
+
orientation: str | None = "AP",
|
|
36
|
+
isocenter: torch.Tensor | None = None,
|
|
37
|
+
) -> torch.Tensor:
|
|
38
|
+
"""Create 4x4 camera-to-world (extrinsic inverse) transformation matrix.
|
|
39
|
+
|
|
40
|
+
Composes the pose and reorientation to match DiffDRR's behavior:
|
|
41
|
+
extrinsic_inv = pose @ reorient
|
|
42
|
+
|
|
43
|
+
This order means the translation is applied in the pre-reoriented frame,
|
|
44
|
+
so translation=(0, 850, 0) with AP orientation places the source at Y=850
|
|
45
|
+
in world coordinates (behind the patient for AP imaging).
|
|
46
|
+
|
|
47
|
+
When isocenter is provided, the translation is interpreted as relative to
|
|
48
|
+
the isocenter rather than world origin.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
rotation: (batch, 3) Euler angles (angle_z, angle_x, angle_y) in degrees, ZXY convention
|
|
52
|
+
translation: (batch, 3) camera position (mm). If isocenter is provided,
|
|
53
|
+
this is relative to isocenter; otherwise relative to world origin.
|
|
54
|
+
orientation: "AP", "PA", or None for frame-of-reference
|
|
55
|
+
isocenter: Optional (3,) volume isocenter in world coordinates.
|
|
56
|
+
When provided, the translation is relative to this point.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
(batch, 4, 4) camera-to-world transformation matrices
|
|
60
|
+
"""
|
|
61
|
+
if orientation not in (None, "AP", "PA"):
|
|
62
|
+
raise ValueError(f"Unknown orientation: {orientation}. Use 'AP', 'PA', or None")
|
|
63
|
+
|
|
64
|
+
batch_size = rotation.shape[0]
|
|
65
|
+
device = rotation.device
|
|
66
|
+
dtype = rotation.dtype
|
|
67
|
+
|
|
68
|
+
# Default isocenter to origin
|
|
69
|
+
if isocenter is None:
|
|
70
|
+
isocenter = torch.zeros(3, device=device, dtype=dtype)
|
|
71
|
+
|
|
72
|
+
# Get rotation matrices from Euler angles
|
|
73
|
+
R = euler_to_matrix(rotation) # (batch, 3, 3)
|
|
74
|
+
|
|
75
|
+
# Compute camera center: R @ translation + isocenter
|
|
76
|
+
# bij,bj->bi : batched matrix-vector multiply
|
|
77
|
+
camera_center = torch.einsum("bij,bj->bi", R, translation)
|
|
78
|
+
camera_center = camera_center + isocenter
|
|
79
|
+
|
|
80
|
+
# Build 4x4 pose matrices [R | camera_center]
|
|
81
|
+
pose = torch.zeros(batch_size, 4, 4, device=device, dtype=dtype)
|
|
82
|
+
pose[:, :3, :3] = R
|
|
83
|
+
pose[:, :3, 3] = camera_center
|
|
84
|
+
pose[:, 3, 3] = 1.0
|
|
85
|
+
|
|
86
|
+
# Apply orientation (pose @ combined)
|
|
87
|
+
# bij,jk->bik : batched matrix times single matrix
|
|
88
|
+
orientation_matrix = get_orientation_matrix(orientation, device, dtype)
|
|
89
|
+
out = torch.einsum("bij,jk->bik", pose, orientation_matrix)
|
|
90
|
+
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def euler_to_matrix(rotation: torch.Tensor) -> torch.Tensor:
|
|
95
|
+
"""Convert ZXY Euler angles (degrees) to rotation matrices.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
rotation: Euler angles (angle_z, angle_x, angle_y) in degrees, shape (batch, 3)
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Rotation matrices of shape (batch, 3, 3)
|
|
102
|
+
"""
|
|
103
|
+
angles = torch.deg2rad(rotation)
|
|
104
|
+
z, x, y = angles[:, 0], angles[:, 1], angles[:, 2]
|
|
105
|
+
|
|
106
|
+
cz, sz = torch.cos(z), torch.sin(z)
|
|
107
|
+
cx, sx = torch.cos(x), torch.sin(x)
|
|
108
|
+
cy, sy = torch.cos(y), torch.sin(y)
|
|
109
|
+
|
|
110
|
+
# ZXY Euler rotation matrix
|
|
111
|
+
R = torch.stack(
|
|
112
|
+
[
|
|
113
|
+
torch.stack(
|
|
114
|
+
[cy * cz - sx * sy * sz, -cx * sz, cz * sy + cy * sx * sz], dim=1
|
|
115
|
+
),
|
|
116
|
+
torch.stack(
|
|
117
|
+
[cy * sz + cz * sx * sy, cx * cz, sy * sz - cy * cz * sx], dim=1
|
|
118
|
+
),
|
|
119
|
+
torch.stack([-cx * sy, sx, cx * cy], dim=1),
|
|
120
|
+
],
|
|
121
|
+
dim=1,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return R
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_orientation_matrix(
|
|
128
|
+
orientation: str | None, device: torch.device, dtype: torch.dtype
|
|
129
|
+
) -> torch.Tensor:
|
|
130
|
+
"""Get the combined orientation + Rz(180°) matrix.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
orientation: "AP", "PA", or None
|
|
134
|
+
device: torch device
|
|
135
|
+
dtype: torch dtype
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
4x4 transformation matrix
|
|
139
|
+
"""
|
|
140
|
+
if orientation == "AP":
|
|
141
|
+
combined = torch.tensor(
|
|
142
|
+
[
|
|
143
|
+
[-1.0, 0.0, 0.0, 0.0],
|
|
144
|
+
[0.0, 0.0, -1.0, 0.0],
|
|
145
|
+
[0.0, -1.0, 0.0, 0.0],
|
|
146
|
+
[0.0, 0.0, 0.0, 1.0],
|
|
147
|
+
],
|
|
148
|
+
device=device,
|
|
149
|
+
dtype=dtype,
|
|
150
|
+
)
|
|
151
|
+
elif orientation == "PA":
|
|
152
|
+
combined = torch.tensor(
|
|
153
|
+
[
|
|
154
|
+
[-1.0, 0.0, 0.0, 0.0],
|
|
155
|
+
[0.0, 0.0, -1.0, 0.0],
|
|
156
|
+
[0.0, -1.0, 0.0, 0.0],
|
|
157
|
+
[0.0, 0.0, 0.0, 1.0],
|
|
158
|
+
],
|
|
159
|
+
device=device,
|
|
160
|
+
dtype=dtype,
|
|
161
|
+
)
|
|
162
|
+
else: # None - just Rz180
|
|
163
|
+
combined = torch.tensor(
|
|
164
|
+
[
|
|
165
|
+
[-1.0, 0.0, 0.0, 0.0],
|
|
166
|
+
[0.0, -1.0, 0.0, 0.0],
|
|
167
|
+
[0.0, 0.0, 1.0, 0.0],
|
|
168
|
+
[0.0, 0.0, 0.0, 1.0],
|
|
169
|
+
],
|
|
170
|
+
device=device,
|
|
171
|
+
dtype=dtype,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return combined
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
from jaxtyping import Float
|
|
3
|
+
|
|
4
|
+
from ..geometry import convert
|
|
5
|
+
|
|
6
|
+
_ORIENTATION_MATRICES = {
|
|
7
|
+
"AP": [
|
|
8
|
+
[-1, 0, 0, 0],
|
|
9
|
+
[0, 0, -1, 0],
|
|
10
|
+
[0, -1, 0, 0],
|
|
11
|
+
[0, 0, 0, 1],
|
|
12
|
+
],
|
|
13
|
+
"PA": [
|
|
14
|
+
[-1, 0, 0, 0],
|
|
15
|
+
[0, 0, 1, 0],
|
|
16
|
+
[0, -1, 0, 0],
|
|
17
|
+
[0, 0, 0, 1],
|
|
18
|
+
],
|
|
19
|
+
None: [
|
|
20
|
+
[-1, 0, 0, 0],
|
|
21
|
+
[0, -1, 0, 0],
|
|
22
|
+
[0, 0, 1, 0],
|
|
23
|
+
[0, 0, 0, 1],
|
|
24
|
+
],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_rt_inv(
|
|
29
|
+
rotation: Float[torch.Tensor, "B 3"],
|
|
30
|
+
translation: Float[torch.Tensor, "B 3"],
|
|
31
|
+
orientation: str | None = "AP",
|
|
32
|
+
isocenter: Float[torch.Tensor, "3"] | None = None,
|
|
33
|
+
) -> Float[torch.Tensor, "B 4 4"]:
|
|
34
|
+
"""Create 4x4 camera-to-world (extrinsic inverse) matrices.
|
|
35
|
+
|
|
36
|
+
Composes pose and reorientation as ``extrinsic_inv = pose @ reorient``
|
|
37
|
+
so that *translation* is applied in the pre-reoriented frame.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
rotation: (B, 3) Euler angles (z, x, y) in degrees, ZXY convention.
|
|
41
|
+
translation: (B, 3) camera position in mm, relative to *isocenter*
|
|
42
|
+
(or world origin when isocenter is ``None``).
|
|
43
|
+
orientation: ``"AP"``, ``"PA"``, or ``None``.
|
|
44
|
+
isocenter: Optional (3,) volume centre in world coordinates.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
(B, 4, 4) camera-to-world transformation matrices.
|
|
48
|
+
"""
|
|
49
|
+
if orientation not in _ORIENTATION_MATRICES:
|
|
50
|
+
raise ValueError(f"Unknown orientation: {orientation}. Use 'AP', 'PA', or None")
|
|
51
|
+
|
|
52
|
+
device = rotation.device
|
|
53
|
+
dtype = rotation.dtype
|
|
54
|
+
|
|
55
|
+
if isocenter is None:
|
|
56
|
+
isocenter = torch.zeros(3, device=device, dtype=dtype)
|
|
57
|
+
|
|
58
|
+
pose = convert(rotation, translation, "euler", convention="ZXY", isocenter=isocenter)
|
|
59
|
+
orientation_matrix = _get_orientation_matrix(orientation, device, dtype)
|
|
60
|
+
|
|
61
|
+
return pose @ orientation_matrix
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get_orientation_matrix(
|
|
65
|
+
orientation: str | None,
|
|
66
|
+
device: torch.device,
|
|
67
|
+
dtype: torch.dtype,
|
|
68
|
+
) -> Float[torch.Tensor, "4 4"]:
|
|
69
|
+
"""Return the combined orientation + Rz(180°) matrix."""
|
|
70
|
+
return torch.tensor(_ORIENTATION_MATRICES[orientation], device=device, dtype=dtype)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
from jaxtyping import Float
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def make_k_inv(
|
|
6
|
+
sdd: float,
|
|
7
|
+
delx: float,
|
|
8
|
+
dely: float,
|
|
9
|
+
x0: float,
|
|
10
|
+
y0: float,
|
|
11
|
+
height: int,
|
|
12
|
+
width: int,
|
|
13
|
+
dtype: torch.dtype | None = None,
|
|
14
|
+
device: torch.device | None = None,
|
|
15
|
+
) -> Float[torch.Tensor, "1 3 3"]:
|
|
16
|
+
"""Build the inverse intrinsic matrix K⁻¹ for a cone-beam projector.
|
|
17
|
+
|
|
18
|
+
Focal lengths and principal point are derived from the physical geometry:
|
|
19
|
+
|
|
20
|
+
fx = sdd / delx cy = y0 / dely + height / 2
|
|
21
|
+
fy = sdd / dely cx = x0 / delx + width / 2
|
|
22
|
+
|
|
23
|
+
The returned matrix is the analytical inverse of:
|
|
24
|
+
|
|
25
|
+
K = [[fx, 0, cx],
|
|
26
|
+
[0, fy, cy],
|
|
27
|
+
[0, 0, 1]]
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
sdd: Source-to-detector distance (mm).
|
|
31
|
+
delx: Pixel spacing in x (mm/px).
|
|
32
|
+
dely: Pixel spacing in y (mm/px).
|
|
33
|
+
x0: Principal-point offset from detector centre in x (mm).
|
|
34
|
+
y0: Principal-point offset from detector centre in y (mm).
|
|
35
|
+
height: Detector height in pixels.
|
|
36
|
+
width: Detector width in pixels.
|
|
37
|
+
dtype: Optional tensor dtype.
|
|
38
|
+
device: Optional tensor device.
|
|
39
|
+
Returns:
|
|
40
|
+
(1, 3, 3) inverse intrinsic matrix.
|
|
41
|
+
"""
|
|
42
|
+
fx = sdd / delx
|
|
43
|
+
fy = sdd / dely
|
|
44
|
+
cx = x0 / delx + width / 2.0
|
|
45
|
+
cy = y0 / dely + height / 2.0
|
|
46
|
+
|
|
47
|
+
return torch.tensor(
|
|
48
|
+
[
|
|
49
|
+
[
|
|
50
|
+
[1.0 / fx, 0.0, -cx / fx],
|
|
51
|
+
[0.0, 1.0 / fy, -cy / fy],
|
|
52
|
+
[0.0, 0.0, 1.0],
|
|
53
|
+
]
|
|
54
|
+
],
|
|
55
|
+
dtype=dtype,
|
|
56
|
+
device=device,
|
|
57
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import torch
|
|
3
|
+
from platformdirs import user_cache_dir
|
|
4
|
+
|
|
5
|
+
CACHE_DIR = user_cache_dir("nanodrr")
|
|
6
|
+
|
|
7
|
+
def download_deepfluoro(subject: int = 1) -> tuple[str, str]:
|
|
8
|
+
"""Download a subject from the DeepFluoro dataset."""
|
|
9
|
+
subject = f"subject{subject:02d}"
|
|
10
|
+
base_url = f"https://huggingface.co/datasets/eigenvivek/xvr-data/resolve/main/deepfluoro/{subject}"
|
|
11
|
+
imagepath = os.path.join(CACHE_DIR, "deepfluoro", subject, "volume.nii.gz")
|
|
12
|
+
labelpath = os.path.join(CACHE_DIR, "deepfluoro", subject, "mask.nii.gz")
|
|
13
|
+
|
|
14
|
+
for url, local_path in [
|
|
15
|
+
(f"{base_url}/volume.nii.gz", imagepath),
|
|
16
|
+
(f"{base_url}/mask.nii.gz", labelpath),
|
|
17
|
+
]:
|
|
18
|
+
if not os.path.exists(local_path):
|
|
19
|
+
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
|
20
|
+
torch.hub.download_url_to_file(url, local_path)
|
|
21
|
+
|
|
22
|
+
return imagepath, labelpath
|