pyRadMC 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyradmc/__init__.py +308 -0
- pyradmc/adapters/__init__.py +9 -0
- pyradmc/adapters/ct.py +244 -0
- pyradmc/backends/__init__.py +1 -0
- pyradmc/backends/ref/__init__.py +5 -0
- pyradmc/backends/ref/engine.py +463 -0
- pyradmc/backends/results.py +115 -0
- pyradmc/backends/warp/__init__.py +1 -0
- pyradmc/backends/warp/engine.py +3451 -0
- pyradmc/backends/warp/kernels.py +2401 -0
- pyradmc/backends/warp/physics.py +211 -0
- pyradmc/backends/warp/presolve.py +742 -0
- pyradmc/data/__init__.py +1 -0
- pyradmc/data/analytic.py +321 -0
- pyradmc/data/berger_seltzer.py +240 -0
- pyradmc/data/goudsmit_saunderson.py +1122 -0
- pyradmc/data/handles.py +34 -0
- pyradmc/data/interface.py +437 -0
- pyradmc/data/materials.py +345 -0
- pyradmc/data/tables.py +362 -0
- pyradmc/data/tabulated/__init__.py +10 -0
- pyradmc/data/tabulated/build.py +211 -0
- pyradmc/data/tabulated/eedl.py +315 -0
- pyradmc/data/tabulated/endf.py +258 -0
- pyradmc/data/tabulated/epdl.py +234 -0
- pyradmc/data/tabulated/format.py +102 -0
- pyradmc/data/tabulated/model.py +48 -0
- pyradmc/data/tabulated/precompile.py +267 -0
- pyradmc/data/tabulated/source.py +241 -0
- pyradmc/geometry/__init__.py +1 -0
- pyradmc/geometry/collimation.py +1284 -0
- pyradmc/geometry/cylinder.py +118 -0
- pyradmc/geometry/fluence.py +147 -0
- pyradmc/geometry/grid.py +337 -0
- pyradmc/geometry/head.py +599 -0
- pyradmc/geometry/phasespace.py +769 -0
- pyradmc/geometry/source.py +1195 -0
- pyradmc/geometry/spectrum.py +310 -0
- pyradmc/physics/__init__.py +1 -0
- pyradmc/physics/brems.py +83 -0
- pyradmc/physics/channel.py +55 -0
- pyradmc/physics/compton.py +118 -0
- pyradmc/physics/direction.py +81 -0
- pyradmc/physics/gs.py +166 -0
- pyradmc/physics/moller.py +87 -0
- pyradmc/physics/msc.py +54 -0
- pyradmc/physics/path.py +35 -0
- pyradmc/physics/rayleigh.py +118 -0
- pyradmc/physics/roulette.py +34 -0
- pyradmc/progress.py +96 -0
- pyradmc/py.typed +0 -0
- pyradmc/rng/__init__.py +12 -0
- pyradmc/rng/host.py +48 -0
- pyradmc/rng/interface.py +64 -0
- pyradmc/rng/warp_shim.py +81 -0
- pyradmc/scoring/__init__.py +1 -0
- pyradmc/scoring/cylinder.py +513 -0
- pyradmc/scoring/dij.py +434 -0
- pyradmc/scoring/dose.py +207 -0
- pyradmc/scoring/dose_to_water.py +84 -0
- pyradmc/scoring/grid.py +219 -0
- pyradmc/study.py +150 -0
- pyradmc/transport/__init__.py +1 -0
- pyradmc/transport/electron.py +481 -0
- pyradmc/transport/history.py +150 -0
- pyradmc/transport/particles.py +102 -0
- pyradmc/transport/photon.py +348 -0
- pyradmc-0.2.0.dist-info/METADATA +162 -0
- pyradmc-0.2.0.dist-info/RECORD +72 -0
- pyradmc-0.2.0.dist-info/WHEEL +4 -0
- pyradmc-0.2.0.dist-info/licenses/LICENSE +201 -0
- pyradmc-0.2.0.dist-info/licenses/NOTICE +60 -0
pyradmc/__init__.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""pyradmc: fast photon Monte Carlo for beamlet-resolved treatment planning.
|
|
2
|
+
|
|
3
|
+
See AGENTS.md for the development contract.
|
|
4
|
+
|
|
5
|
+
This module is the public API: everything named in ``__all__`` is supported and
|
|
6
|
+
versioned, and everything else is an implementation detail that may move between
|
|
7
|
+
releases. Physical constants and defaults that are accuracy-defining live here too,
|
|
8
|
+
so that there is exactly one place to change them and so that a change is visible in
|
|
9
|
+
a diff. They are not tuning knobs; see AGENTS.md section 2.8.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
__version__ = "0.2.0"
|
|
18
|
+
|
|
19
|
+
# --- accuracy-defining defaults ------------------------------------------------
|
|
20
|
+
# Changing any of these requires a test demonstrating the dosimetric effect.
|
|
21
|
+
|
|
22
|
+
ECUT_MEV: float = 0.200
|
|
23
|
+
"""Electron transport and production cutoff, kinetic energy in MeV (DPM default)."""
|
|
24
|
+
|
|
25
|
+
PCUT_MEV: float = 0.050
|
|
26
|
+
"""Photon transport cutoff in MeV. Below this, energy is deposited locally."""
|
|
27
|
+
|
|
28
|
+
DIJ_TRUNCATION_RELATIVE: float = 1.0e-3
|
|
29
|
+
"""Dij column truncation, relative to that beamlet column's maximum.
|
|
30
|
+
|
|
31
|
+
This biases the low-dose tail, which is where NTCP and LET-guided objectives operate.
|
|
32
|
+
It is tested against DVH endpoints, never against a matrix norm.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
# --- variance-reduction parameters -------------------------------------
|
|
36
|
+
# Russian roulette on low-energy photons (see pyradmc.physics.roulette and the
|
|
37
|
+
# transport loops). Unbiased by construction — these change realizations and
|
|
38
|
+
# efficiency, never expectations; unbiasedness is test-pinned against a
|
|
39
|
+
# roulette-free run. They are still fixed project-wide values, not per-run options
|
|
40
|
+
# (AGENTS.md 2.10): one configuration is what the validation tier certifies.
|
|
41
|
+
|
|
42
|
+
PHOTON_ROULETTE_MEV: float = 0.5
|
|
43
|
+
"""Photons below this energy play Russian roulette at their creation or scatter.
|
|
44
|
+
|
|
45
|
+
Chosen just below the 511 keV annihilation line so annihilation photons are exempt
|
|
46
|
+
and positron energy accounting stays analog.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
PHOTON_ROULETTE_SURVIVAL: float = 0.5
|
|
50
|
+
"""Survival probability per game; a survivor's weight is boosted by its inverse."""
|
|
51
|
+
|
|
52
|
+
PHOTON_ROULETTE_WEIGHT_CAP: float = 4.0
|
|
53
|
+
"""No roulette at or above this weight (the weight-window ceiling).
|
|
54
|
+
|
|
55
|
+
Caps the boost cascade at two consecutive survivals (1 -> 2 -> 4), bounding the
|
|
56
|
+
graininess a single high-weight deposit can leave in the low-dose tail.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
PHOTON_SPLIT_N: int = 1
|
|
60
|
+
"""Compton splitting multiplicity at a *primary* photon's first Compton scatter.
|
|
61
|
+
|
|
62
|
+
``N = 1`` is **splitting off — the shipped configuration.** At N == 1 the
|
|
63
|
+
primary Comptons into a single full-weight copy, i.e. exactly analog transport.
|
|
64
|
+
For ``N > 1`` the primary's Compton final state is sampled N times, each copy
|
|
65
|
+
(scattered photon + recoil electron) carrying weight ``1 / N``: N independent
|
|
66
|
+
samples of the dominant scatter source, exactly unbiased and energy-conserving
|
|
67
|
+
per realization, with cost growing about linearly in N. Only the primary
|
|
68
|
+
splits, so the population is bounded and the soft-photon roulette culls the
|
|
69
|
+
degraded copies.
|
|
70
|
+
|
|
71
|
+
This is a variance-reduction efficiency knob, not accuracy-defining: it changes
|
|
72
|
+
realizations and cost, never expectations. Correctness of the ``N > 1`` path
|
|
73
|
+
(unbiasedness, energy books, N-fold fair copies, variance reduction) is
|
|
74
|
+
test-pinned with N = 2 as the instrument, so the mechanism stays validated
|
|
75
|
+
though it is dormant.
|
|
76
|
+
|
|
77
|
+
**Why it ships off (measured).** For the analytic-water
|
|
78
|
+
Dij, splitting does not earn its keep: the figure of merit ``1/(sigma^2*time)``
|
|
79
|
+
is < 1 on the reference CPU (variance falls to ~0.67 in the high/mid-dose
|
|
80
|
+
region but cost rises ~1.7x) and roughly neutral on the GPU (a warp retires
|
|
81
|
+
with its longest thread). Worse, it does **not** help the low-dose tail — the
|
|
82
|
+
Dij's NTCP/LET region — because that tail is fed by rare wide-angle multiple
|
|
83
|
+
scatters that uniform primary splitting cannot target; splitting deeper only
|
|
84
|
+
degrades the FOM further (measured).
|
|
85
|
+
|
|
86
|
+
**Re-measured on a phase-space source, it still ships off.** On
|
|
87
|
+
now-stable-power hardware the FOM ratio split/no-split was 0.75, 0.48, 0.28 at
|
|
88
|
+
N = 2, 4, 8 — worse, monotonically. First-Compton splitting decorrelates copies
|
|
89
|
+
only after that scatter (variance saturates far below 1/N) while cost grows
|
|
90
|
+
~linearly, and emitting a phase-space primary is as cheap as an analytic beam,
|
|
91
|
+
so the cost structure matches. The N > 1 path stays retained and N=2-pinned. See
|
|
92
|
+
docs/decisions.md for the full record and the emission-time-splitting alternative.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
# --- physical constants --------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
ELECTRON_MASS_MEV: float = 0.510_998_950_69
|
|
98
|
+
"""Electron rest mass energy in MeV (CODATA 2022)."""
|
|
99
|
+
|
|
100
|
+
GY_PER_MEV_PER_G: float = 1.602_176_634e-10
|
|
101
|
+
"""Absolute-dose calibration: 1 MeV/g = this many gray.
|
|
102
|
+
|
|
103
|
+
Exact by SI definition: 1 MeV = e x 1e6 J with the elementary charge fixed at
|
|
104
|
+
1.602176634e-19 C (SI 2019), and per gram -> per kilogram is 1e3. The engines
|
|
105
|
+
score dose in MeV/g per emitted history; a planning consumer multiplies by this
|
|
106
|
+
constant for Gy per history and applies its own particles-per-MU scaling on top
|
|
107
|
+
(see :meth:`pyradmc.scoring.dij.DijResult.dose_csc`)."""
|
|
108
|
+
|
|
109
|
+
RAYLEIGH_MOMENTUM_TRANSFER_PER_MEV: float = 80.65543
|
|
110
|
+
"""Coherent-scattering momentum-transfer coefficient: the tabulated form-factor abscissa
|
|
111
|
+
is ``x [1/angstrom] = this * E[MeV] * sin(theta/2)``, i.e. ``1/hc`` with
|
|
112
|
+
``hc = 0.012_398_42 MeV*angstrom`` (CODATA 2022). EPDL MF=27 tabulates ``F`` against ``x``."""
|
|
113
|
+
|
|
114
|
+
# --- public API ----------------------------------------------------------------
|
|
115
|
+
# Re-exported lazily (PEP 562). ``import pyradmc`` must stay cheap and dependency-free:
|
|
116
|
+
# a consumer that wants only the constants must not pay for NumPy, and a core-only
|
|
117
|
+
# install (no ``warp`` extra) must not fail at import merely because ``WarpEngine`` is
|
|
118
|
+
# a public name. The ``TYPE_CHECKING`` block below is what mypy and IDEs resolve
|
|
119
|
+
# against; ``_LAZY_EXPORTS`` is what actually runs.
|
|
120
|
+
#
|
|
121
|
+
# Names NOT promoted here are still importable from their modules, but they are not
|
|
122
|
+
# the public API: beam-limiting devices (``pyradmc.geometry.collimation``), the
|
|
123
|
+
# treatment-head pre-solve (``pyradmc.geometry.head``), the CT adapter
|
|
124
|
+
# (``pyradmc.adapters.ct``), the tabulated-table precompiler
|
|
125
|
+
# (``pyradmc.data.tabulated``) and the toy optimizer (``pyradmc.study``) are
|
|
126
|
+
# documented at subpackage level because each is a coherent subsystem with its own
|
|
127
|
+
# vocabulary, not a name a first script reaches for.
|
|
128
|
+
|
|
129
|
+
if TYPE_CHECKING:
|
|
130
|
+
from pyradmc.backends.ref.engine import ReferenceEngine
|
|
131
|
+
from pyradmc.backends.results import TransportResult
|
|
132
|
+
from pyradmc.backends.warp.engine import WarpEngine
|
|
133
|
+
from pyradmc.data.analytic import AnalyticCrossSections
|
|
134
|
+
from pyradmc.data.interface import CrossSectionSource, PhotonProcess
|
|
135
|
+
from pyradmc.data.materials import (
|
|
136
|
+
ADIPOSE,
|
|
137
|
+
AIR,
|
|
138
|
+
CORTICAL_BONE,
|
|
139
|
+
LUNG,
|
|
140
|
+
MATERIALS,
|
|
141
|
+
TUNGSTEN,
|
|
142
|
+
WATER,
|
|
143
|
+
MaterialData,
|
|
144
|
+
)
|
|
145
|
+
from pyradmc.data.tabulated.source import TabulatedCrossSections
|
|
146
|
+
from pyradmc.geometry.fluence import RadialFluence
|
|
147
|
+
from pyradmc.geometry.grid import VoxelGrid
|
|
148
|
+
from pyradmc.geometry.phasespace import InMemoryPhaseSpaceSource, PhaseSpaceSource
|
|
149
|
+
from pyradmc.geometry.source import (
|
|
150
|
+
BeamletGridSource,
|
|
151
|
+
BeamletSource,
|
|
152
|
+
CompositeBeamletSource,
|
|
153
|
+
CompositeSource,
|
|
154
|
+
GaussianSpotBeamletSource,
|
|
155
|
+
GaussianSpotBeamSource,
|
|
156
|
+
ParallelBeamSource,
|
|
157
|
+
PencilBeamSource,
|
|
158
|
+
Primary,
|
|
159
|
+
PrimaryFluenceBeamletSource,
|
|
160
|
+
PrimaryFluenceBeamSource,
|
|
161
|
+
Source,
|
|
162
|
+
SpectralBeamletSource,
|
|
163
|
+
SpectralBeamSource,
|
|
164
|
+
)
|
|
165
|
+
from pyradmc.geometry.spectrum import ALI_ROGERS_BEAMS, Spectrum, ali_rogers_mv
|
|
166
|
+
from pyradmc.rng.host import HostRNG
|
|
167
|
+
from pyradmc.scoring.cylinder import (
|
|
168
|
+
CylindricalScoringGrid,
|
|
169
|
+
geometric_edges,
|
|
170
|
+
graded_edges,
|
|
171
|
+
uniform_edges,
|
|
172
|
+
)
|
|
173
|
+
from pyradmc.scoring.dij import DijResult
|
|
174
|
+
from pyradmc.scoring.grid import ScoringGrid
|
|
175
|
+
|
|
176
|
+
_LAZY_EXPORTS: dict[str, str] = {
|
|
177
|
+
"ADIPOSE": "pyradmc.data.materials",
|
|
178
|
+
"AIR": "pyradmc.data.materials",
|
|
179
|
+
"ALI_ROGERS_BEAMS": "pyradmc.geometry.spectrum",
|
|
180
|
+
"AnalyticCrossSections": "pyradmc.data.analytic",
|
|
181
|
+
"BeamletGridSource": "pyradmc.geometry.source",
|
|
182
|
+
"BeamletSource": "pyradmc.geometry.source",
|
|
183
|
+
"CORTICAL_BONE": "pyradmc.data.materials",
|
|
184
|
+
"CompositeBeamletSource": "pyradmc.geometry.source",
|
|
185
|
+
"CompositeSource": "pyradmc.geometry.source",
|
|
186
|
+
"CrossSectionSource": "pyradmc.data.interface",
|
|
187
|
+
"CylindricalScoringGrid": "pyradmc.scoring.cylinder",
|
|
188
|
+
"DijResult": "pyradmc.scoring.dij",
|
|
189
|
+
"GaussianSpotBeamSource": "pyradmc.geometry.source",
|
|
190
|
+
"GaussianSpotBeamletSource": "pyradmc.geometry.source",
|
|
191
|
+
"HostRNG": "pyradmc.rng.host",
|
|
192
|
+
"InMemoryPhaseSpaceSource": "pyradmc.geometry.phasespace",
|
|
193
|
+
"LUNG": "pyradmc.data.materials",
|
|
194
|
+
"MATERIALS": "pyradmc.data.materials",
|
|
195
|
+
"MaterialData": "pyradmc.data.materials",
|
|
196
|
+
"ParallelBeamSource": "pyradmc.geometry.source",
|
|
197
|
+
"PencilBeamSource": "pyradmc.geometry.source",
|
|
198
|
+
"PhaseSpaceSource": "pyradmc.geometry.phasespace",
|
|
199
|
+
"PhotonProcess": "pyradmc.data.interface",
|
|
200
|
+
"Primary": "pyradmc.geometry.source",
|
|
201
|
+
"PrimaryFluenceBeamSource": "pyradmc.geometry.source",
|
|
202
|
+
"PrimaryFluenceBeamletSource": "pyradmc.geometry.source",
|
|
203
|
+
"RadialFluence": "pyradmc.geometry.fluence",
|
|
204
|
+
"ReferenceEngine": "pyradmc.backends.ref.engine",
|
|
205
|
+
"ScoringGrid": "pyradmc.scoring.grid",
|
|
206
|
+
"Source": "pyradmc.geometry.source",
|
|
207
|
+
"Spectrum": "pyradmc.geometry.spectrum",
|
|
208
|
+
"SpectralBeamSource": "pyradmc.geometry.source",
|
|
209
|
+
"SpectralBeamletSource": "pyradmc.geometry.source",
|
|
210
|
+
"TUNGSTEN": "pyradmc.data.materials",
|
|
211
|
+
"TabulatedCrossSections": "pyradmc.data.tabulated.source",
|
|
212
|
+
"TransportResult": "pyradmc.backends.results",
|
|
213
|
+
"VoxelGrid": "pyradmc.geometry.grid",
|
|
214
|
+
"WATER": "pyradmc.data.materials",
|
|
215
|
+
"WarpEngine": "pyradmc.backends.warp.engine",
|
|
216
|
+
"ali_rogers_mv": "pyradmc.geometry.spectrum",
|
|
217
|
+
"geometric_edges": "pyradmc.scoring.cylinder",
|
|
218
|
+
"graded_edges": "pyradmc.scoring.cylinder",
|
|
219
|
+
"uniform_edges": "pyradmc.scoring.cylinder",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
# Third-party module whose absence means an optional extra is not installed -> the
|
|
223
|
+
# extra that provides it. Only consulted when the import actually failed on that
|
|
224
|
+
# module, so a genuine ImportError from inside our own code still propagates as-is.
|
|
225
|
+
_EXTRA_FOR_MISSING_MODULE: dict[str, str] = {"warp": "warp", "SimpleITK": "ct"}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def __getattr__(name: str) -> Any:
|
|
229
|
+
"""Resolve a public name on first access, then cache it in the module globals."""
|
|
230
|
+
module_name = _LAZY_EXPORTS.get(name)
|
|
231
|
+
if module_name is None:
|
|
232
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
233
|
+
try:
|
|
234
|
+
module = importlib.import_module(module_name)
|
|
235
|
+
except ImportError as exc:
|
|
236
|
+
missing = (getattr(exc, "name", "") or "").split(".")[0]
|
|
237
|
+
extra = _EXTRA_FOR_MISSING_MODULE.get(missing)
|
|
238
|
+
if extra is None:
|
|
239
|
+
raise
|
|
240
|
+
raise ImportError(
|
|
241
|
+
f"pyradmc.{name} needs the optional {extra!r} extra: pip install 'pyradmc[{extra}]'"
|
|
242
|
+
) from exc
|
|
243
|
+
value = getattr(module, name)
|
|
244
|
+
globals()[name] = value # later lookups find it directly and skip __getattr__
|
|
245
|
+
return value
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def __dir__() -> list[str]:
|
|
249
|
+
"""List the public API, so tab-completion sees names not yet imported."""
|
|
250
|
+
return sorted(__all__)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
__all__ = [
|
|
254
|
+
"ADIPOSE",
|
|
255
|
+
"AIR",
|
|
256
|
+
"ALI_ROGERS_BEAMS",
|
|
257
|
+
"CORTICAL_BONE",
|
|
258
|
+
"DIJ_TRUNCATION_RELATIVE",
|
|
259
|
+
"ECUT_MEV",
|
|
260
|
+
"ELECTRON_MASS_MEV",
|
|
261
|
+
"GY_PER_MEV_PER_G",
|
|
262
|
+
"LUNG",
|
|
263
|
+
"MATERIALS",
|
|
264
|
+
"PCUT_MEV",
|
|
265
|
+
"PHOTON_ROULETTE_MEV",
|
|
266
|
+
"PHOTON_ROULETTE_SURVIVAL",
|
|
267
|
+
"PHOTON_ROULETTE_WEIGHT_CAP",
|
|
268
|
+
"PHOTON_SPLIT_N",
|
|
269
|
+
"RAYLEIGH_MOMENTUM_TRANSFER_PER_MEV",
|
|
270
|
+
"TUNGSTEN",
|
|
271
|
+
"WATER",
|
|
272
|
+
"AnalyticCrossSections",
|
|
273
|
+
"BeamletGridSource",
|
|
274
|
+
"BeamletSource",
|
|
275
|
+
"CompositeBeamletSource",
|
|
276
|
+
"CompositeSource",
|
|
277
|
+
"CrossSectionSource",
|
|
278
|
+
"CylindricalScoringGrid",
|
|
279
|
+
"DijResult",
|
|
280
|
+
"GaussianSpotBeamSource",
|
|
281
|
+
"GaussianSpotBeamletSource",
|
|
282
|
+
"HostRNG",
|
|
283
|
+
"InMemoryPhaseSpaceSource",
|
|
284
|
+
"MaterialData",
|
|
285
|
+
"ParallelBeamSource",
|
|
286
|
+
"PencilBeamSource",
|
|
287
|
+
"PhaseSpaceSource",
|
|
288
|
+
"PhotonProcess",
|
|
289
|
+
"Primary",
|
|
290
|
+
"PrimaryFluenceBeamSource",
|
|
291
|
+
"PrimaryFluenceBeamletSource",
|
|
292
|
+
"RadialFluence",
|
|
293
|
+
"ReferenceEngine",
|
|
294
|
+
"ScoringGrid",
|
|
295
|
+
"Source",
|
|
296
|
+
"SpectralBeamSource",
|
|
297
|
+
"SpectralBeamletSource",
|
|
298
|
+
"Spectrum",
|
|
299
|
+
"TabulatedCrossSections",
|
|
300
|
+
"TransportResult",
|
|
301
|
+
"VoxelGrid",
|
|
302
|
+
"WarpEngine",
|
|
303
|
+
"__version__",
|
|
304
|
+
"ali_rogers_mv",
|
|
305
|
+
"geometric_edges",
|
|
306
|
+
"graded_edges",
|
|
307
|
+
"uniform_edges",
|
|
308
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Optional adapters that bridge external inputs to the engine's core types.
|
|
2
|
+
|
|
3
|
+
Adapters live outside the core (AGENTS.md 6): each may carry its own optional
|
|
4
|
+
dependency, declared as an extra in ``pyproject.toml``, and the core never imports one.
|
|
5
|
+
The CT adapter (:mod:`pyradmc.adapters.ct`) turns a patient CT into a
|
|
6
|
+
:class:`~pyradmc.geometry.grid.VoxelGrid`; the pyRadPlan adapter is planned to follow.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
pyradmc/adapters/ct.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""CT image -> :class:`~pyradmc.geometry.grid.VoxelGrid` adapter.
|
|
2
|
+
|
|
3
|
+
Turns a patient CT into the engine's geometry: a Hounsfield-unit calibration maps each
|
|
4
|
+
voxel's CT number to a mass density (a piecewise-linear ramp) and to a registry
|
|
5
|
+
material index (HU threshold bins into the ICRP media the materials task added), and
|
|
6
|
+
:func:`grid_from_hu` assembles the grid. :func:`read_ct` reads an image file through
|
|
7
|
+
SimpleITK; everything above it is pure NumPy and needs no optional dependency, so the
|
|
8
|
+
calibration is testable and reusable on its own.
|
|
9
|
+
|
|
10
|
+
Segmentation and density are deliberately independent, which is the whole point of the
|
|
11
|
+
multi-material design: the material index selects the elemental composition (the
|
|
12
|
+
compiled cross-section row) and the per-voxel density scales it. A voxel at -500 HU is
|
|
13
|
+
*lung tissue* (composition) at ~0.5 g/cm^3 (density), not a fixed reference-density
|
|
14
|
+
material.
|
|
15
|
+
|
|
16
|
+
The default calibration is illustrative, in Schneider's spirit (Schneider, Bortfeld &
|
|
17
|
+
Schlegel, Phys. Med. Biol. 45, 459 (2000), doi:10.1088/0031-9155/45/2/314; bilinear
|
|
18
|
+
density ramp after Schneider, Pedroni & Lomax, Phys. Med. Biol. 41, 111 (1996),
|
|
19
|
+
doi:10.1088/0031-9155/41/1/009). CT calibration is scanner-specific: a real plan passes
|
|
20
|
+
its own :class:`HounsfieldCalibration`, and these defaults are a runnable starting
|
|
21
|
+
point, not a clinical curve.
|
|
22
|
+
|
|
23
|
+
This adapter is out of the core (AGENTS.md 6); ``read_ct`` needs the optional
|
|
24
|
+
``pyradmc[ct]`` extra (SimpleITK, Apache-2.0).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from typing import TYPE_CHECKING
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
import numpy.typing as npt
|
|
34
|
+
|
|
35
|
+
from pyradmc.data.materials import ADIPOSE, AIR, CORTICAL_BONE, LUNG, MATERIALS, WATER
|
|
36
|
+
from pyradmc.geometry.grid import VoxelGrid
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
import SimpleITK as sitk
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"DEFAULT_CALIBRATION",
|
|
43
|
+
"HounsfieldCalibration",
|
|
44
|
+
"grid_from_hu",
|
|
45
|
+
"grid_from_image",
|
|
46
|
+
"read_ct",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
_MM_PER_CM = 10.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Default HU -> mass density ramp (g/cm^3), linearly interpolated and clamped at the
|
|
53
|
+
# ends. Air and water are the fixed physical anchors; the bone segment reaches liquid
|
|
54
|
+
# water's cortical-bone reference density (~1.85 g/cm^3) near 1400 HU and continues to
|
|
55
|
+
# dense mineral above. Nodes ascending in HU.
|
|
56
|
+
_DEFAULT_DENSITY_HU: tuple[float, ...] = (-1000.0, 0.0, 1000.0, 3000.0)
|
|
57
|
+
_DEFAULT_DENSITY_VALUES: tuple[float, ...] = (0.00121, 1.000, 1.600, 2.800)
|
|
58
|
+
|
|
59
|
+
# Default HU -> material bins. n thresholds cut the HU axis into n+1 bins; bin i (HU in
|
|
60
|
+
# [thresholds[i-1], thresholds[i])) takes material_indices[i]. Illustrative Schneider-
|
|
61
|
+
# style cut points into the ICRP registry media.
|
|
62
|
+
_DEFAULT_MATERIAL_THRESHOLDS: tuple[float, ...] = (-950.0, -120.0, -20.0, 125.0)
|
|
63
|
+
_DEFAULT_MATERIAL_INDICES: tuple[int, ...] = (AIR, LUNG, ADIPOSE, WATER, CORTICAL_BONE)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class HounsfieldCalibration:
|
|
68
|
+
"""A CT-number calibration: HU -> mass density and HU -> registry material.
|
|
69
|
+
|
|
70
|
+
Attributes
|
|
71
|
+
----------
|
|
72
|
+
density_hu, density_values
|
|
73
|
+
The density ramp: HU control points (strictly ascending) and the mass density
|
|
74
|
+
in g/cm^3 at each. Interpolated linearly, clamped flat beyond the ends. All
|
|
75
|
+
densities must be positive — the grid rejects non-positive density.
|
|
76
|
+
material_thresholds
|
|
77
|
+
Ascending HU cut points. ``n`` thresholds define ``n + 1`` bins.
|
|
78
|
+
material_indices
|
|
79
|
+
The registry material index for each bin, low-HU to high-HU; length must be
|
|
80
|
+
``len(material_thresholds) + 1``. Each must be a valid index into
|
|
81
|
+
:data:`pyradmc.data.materials.MATERIALS`.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
density_hu: tuple[float, ...]
|
|
85
|
+
density_values: tuple[float, ...]
|
|
86
|
+
material_thresholds: tuple[float, ...]
|
|
87
|
+
material_indices: tuple[int, ...]
|
|
88
|
+
_density_hu_arr: npt.NDArray[np.float64] = field(init=False, repr=False, compare=False)
|
|
89
|
+
_density_val_arr: npt.NDArray[np.float64] = field(init=False, repr=False, compare=False)
|
|
90
|
+
_threshold_arr: npt.NDArray[np.float64] = field(init=False, repr=False, compare=False)
|
|
91
|
+
_index_arr: npt.NDArray[np.int32] = field(init=False, repr=False, compare=False)
|
|
92
|
+
|
|
93
|
+
def __post_init__(self) -> None:
|
|
94
|
+
"""Validate the ramp and the bins, then cache their array forms."""
|
|
95
|
+
hu = np.asarray(self.density_hu, dtype=np.float64)
|
|
96
|
+
values = np.asarray(self.density_values, dtype=np.float64)
|
|
97
|
+
if hu.shape != values.shape or hu.size < 2:
|
|
98
|
+
raise ValueError("density_hu and density_values must be equal-length (>=2)")
|
|
99
|
+
if not np.all(np.diff(hu) > 0.0):
|
|
100
|
+
raise ValueError("density_hu must be strictly ascending")
|
|
101
|
+
if not np.all(values > 0.0):
|
|
102
|
+
raise ValueError("density_values must all be positive (g/cm^3)")
|
|
103
|
+
|
|
104
|
+
thresholds = np.asarray(self.material_thresholds, dtype=np.float64)
|
|
105
|
+
indices = np.asarray(self.material_indices, dtype=np.int32)
|
|
106
|
+
if thresholds.size and not np.all(np.diff(thresholds) > 0.0):
|
|
107
|
+
raise ValueError("material_thresholds must be strictly ascending")
|
|
108
|
+
if indices.size != thresholds.size + 1:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"{thresholds.size} thresholds define {thresholds.size + 1} bins, "
|
|
111
|
+
f"but got {indices.size} material_indices"
|
|
112
|
+
)
|
|
113
|
+
if np.any((indices < 0) | (indices >= len(MATERIALS))):
|
|
114
|
+
raise ValueError("material_indices must all be valid registry indices")
|
|
115
|
+
|
|
116
|
+
object.__setattr__(self, "_density_hu_arr", hu)
|
|
117
|
+
object.__setattr__(self, "_density_val_arr", values)
|
|
118
|
+
object.__setattr__(self, "_threshold_arr", thresholds)
|
|
119
|
+
object.__setattr__(self, "_index_arr", indices)
|
|
120
|
+
|
|
121
|
+
def density(self, hu: npt.ArrayLike) -> npt.NDArray[np.float64]:
|
|
122
|
+
"""Mass density in g/cm^3 for the given HU (scalar or array), clamped at the ends."""
|
|
123
|
+
return np.interp(
|
|
124
|
+
np.asarray(hu, dtype=np.float64), self._density_hu_arr, self._density_val_arr
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def material(self, hu: npt.ArrayLike) -> npt.NDArray[np.int32]:
|
|
128
|
+
"""Registry material index for the given HU (scalar or array).
|
|
129
|
+
|
|
130
|
+
``np.digitize`` places each HU in its bin (left-closed, ``x < threshold``);
|
|
131
|
+
the bin index gathers the material index.
|
|
132
|
+
"""
|
|
133
|
+
bins = np.digitize(np.asarray(hu, dtype=np.float64), self._threshold_arr)
|
|
134
|
+
return self._index_arr[bins]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
DEFAULT_CALIBRATION = HounsfieldCalibration(
|
|
138
|
+
density_hu=_DEFAULT_DENSITY_HU,
|
|
139
|
+
density_values=_DEFAULT_DENSITY_VALUES,
|
|
140
|
+
material_thresholds=_DEFAULT_MATERIAL_THRESHOLDS,
|
|
141
|
+
material_indices=_DEFAULT_MATERIAL_INDICES,
|
|
142
|
+
)
|
|
143
|
+
"""A runnable, scanner-independent default calibration; see the module docstring."""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def grid_from_hu(
|
|
147
|
+
hu: npt.NDArray[np.float64],
|
|
148
|
+
spacing: tuple[float, float, float],
|
|
149
|
+
*,
|
|
150
|
+
calibration: HounsfieldCalibration = DEFAULT_CALIBRATION,
|
|
151
|
+
origin: tuple[float, float, float] = (0.0, 0.0, 0.0),
|
|
152
|
+
) -> VoxelGrid:
|
|
153
|
+
"""Assemble a :class:`VoxelGrid` from a HU volume and a voxel ``spacing`` (cm).
|
|
154
|
+
|
|
155
|
+
Pure NumPy: ``hu`` is a ``(nx, ny, nz)`` array of CT numbers already in the engine's
|
|
156
|
+
axis order. The calibration maps it to per-voxel density and material; the grid
|
|
157
|
+
validates the result (positive density, in-registry material).
|
|
158
|
+
"""
|
|
159
|
+
hu = np.asarray(hu, dtype=np.float64)
|
|
160
|
+
nx, ny, nz = hu.shape
|
|
161
|
+
return VoxelGrid(
|
|
162
|
+
shape=(nx, ny, nz),
|
|
163
|
+
spacing=spacing,
|
|
164
|
+
origin=origin,
|
|
165
|
+
density=calibration.density(hu),
|
|
166
|
+
material=calibration.material(hu).astype(np.int32),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def grid_from_image(
|
|
171
|
+
image: sitk.Image,
|
|
172
|
+
*,
|
|
173
|
+
calibration: HounsfieldCalibration = DEFAULT_CALIBRATION,
|
|
174
|
+
) -> VoxelGrid:
|
|
175
|
+
"""Convert a 3D SimpleITK CT image into a :class:`VoxelGrid`.
|
|
176
|
+
|
|
177
|
+
Handles the frame mismatch between ITK and the engine: SimpleITK indexes ``(z, y, x)``
|
|
178
|
+
and works in mm with the physical origin at voxel (0,0,0)'s *centre*, while the grid
|
|
179
|
+
is ``(x, y, z)`` in cm with the origin at that voxel's lower *corner*. The image's
|
|
180
|
+
intensities are taken to be Hounsfield units already (true for NIfTI/NRRD and for a
|
|
181
|
+
rescale-applied DICOM series).
|
|
182
|
+
|
|
183
|
+
Only axis-aligned CTs are supported: the direction cosine matrix must be diagonal.
|
|
184
|
+
A negative diagonal entry (an LPS/RAS sign flip, common clinically) means that axis's
|
|
185
|
+
physical coordinate decreases with index; the array is flipped and the origin
|
|
186
|
+
recomputed so the grid is stored in increasing-coordinate order. An oblique
|
|
187
|
+
(off-diagonal) orientation raises — resample to an axis-aligned grid first.
|
|
188
|
+
"""
|
|
189
|
+
import SimpleITK as sitk
|
|
190
|
+
|
|
191
|
+
if image.GetDimension() != 3:
|
|
192
|
+
raise ValueError(f"expected a 3D CT image, got {image.GetDimension()}D")
|
|
193
|
+
|
|
194
|
+
direction = np.asarray(image.GetDirection(), dtype=np.float64).reshape(3, 3)
|
|
195
|
+
if not np.allclose(direction - np.diag(np.diagonal(direction)), 0.0, atol=1.0e-6):
|
|
196
|
+
raise ValueError(
|
|
197
|
+
"oblique CT orientation is not supported; resample to an axis-aligned grid first"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# SimpleITK array is (z, y, x); transpose to the engine's (x, y, z). Spacing, origin
|
|
201
|
+
# and the direction diagonal are all in image (x, y, z) order, so they line up.
|
|
202
|
+
hu = np.transpose(sitk.GetArrayFromImage(image).astype(np.float64), (2, 1, 0))
|
|
203
|
+
spacing_mm = np.asarray(image.GetSpacing(), dtype=np.float64)
|
|
204
|
+
origin_mm = np.asarray(image.GetOrigin(), dtype=np.float64)
|
|
205
|
+
signs = np.sign(np.diagonal(direction))
|
|
206
|
+
|
|
207
|
+
first_center_mm = origin_mm.copy()
|
|
208
|
+
for axis in range(3):
|
|
209
|
+
if signs[axis] < 0.0:
|
|
210
|
+
hu = np.flip(hu, axis=axis)
|
|
211
|
+
# Physical coordinate fell with index, so the minimum-coordinate voxel was
|
|
212
|
+
# the last one; after the flip it is index 0.
|
|
213
|
+
first_center_mm[axis] = origin_mm[axis] - spacing_mm[axis] * (hu.shape[axis] - 1)
|
|
214
|
+
|
|
215
|
+
lower_corner_mm = first_center_mm - 0.5 * spacing_mm
|
|
216
|
+
ox, oy, oz = (lower_corner_mm / _MM_PER_CM).tolist()
|
|
217
|
+
sx, sy, sz = (spacing_mm / _MM_PER_CM).tolist()
|
|
218
|
+
return grid_from_hu(
|
|
219
|
+
np.ascontiguousarray(hu),
|
|
220
|
+
spacing=(sx, sy, sz),
|
|
221
|
+
calibration=calibration,
|
|
222
|
+
origin=(ox, oy, oz),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def read_ct(
|
|
227
|
+
path: str,
|
|
228
|
+
*,
|
|
229
|
+
calibration: HounsfieldCalibration = DEFAULT_CALIBRATION,
|
|
230
|
+
) -> VoxelGrid:
|
|
231
|
+
"""Read a CT image file into a :class:`VoxelGrid` (needs the ``pyradmc[ct]`` extra).
|
|
232
|
+
|
|
233
|
+
Accepts anything SimpleITK reads as a single volume — NIfTI, NRRD, MetaImage, a
|
|
234
|
+
single multi-frame DICOM. For a DICOM *series* (a directory of slices), read it with
|
|
235
|
+
``SimpleITK.ImageSeriesReader`` yourself and pass the image to :func:`grid_from_image`,
|
|
236
|
+
so slice ordering and the rescale slope/intercept are handled explicitly. See
|
|
237
|
+
:func:`grid_from_image` for the frame and orientation handling.
|
|
238
|
+
"""
|
|
239
|
+
try:
|
|
240
|
+
import SimpleITK as sitk
|
|
241
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
242
|
+
raise ImportError("read_ct needs the optional CT extra: pip install 'pyradmc[ct]'") from exc
|
|
243
|
+
|
|
244
|
+
return grid_from_image(sitk.ReadImage(path), calibration=calibration)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Subpackage placeholder; see AGENTS.md before adding code."""
|