Skip to content

Commit 3e6c6eb

Browse files
committed
Merge branch 'develop' into 'main'
Update main to version 0.2.3 See merge request e040/e0404/pyRadPlan!73
2 parents 0d83ec6 + 3f332a8 commit 3e6c6eb

39 files changed

+1340
-679
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import logging
2+
3+
import numpy as np
4+
5+
from pyRadPlan import (
6+
IonPlan,
7+
generate_stf,
8+
calc_dose_influence,
9+
fluence_optimization,
10+
plot_slice,
11+
load_tg119,
12+
)
13+
14+
from pyRadPlan.optimization.objectives import SquaredDeviation, SquaredOverdosing, MeanDose
15+
16+
logging.basicConfig(level=logging.INFO)
17+
18+
# Read patient from provided TG119.mat file and validate data
19+
ct, cst = load_tg119()
20+
21+
# Create a plan object
22+
pln = IonPlan(radiation_mode="protons", machine="Generic")
23+
pln.prop_opt = {"solver": "scipy"}
24+
25+
# Generate Steering Geometry ("stf")
26+
stf = generate_stf(ct, cst, pln)
27+
28+
# Calculate Dose Influence Matrix ("dij")
29+
dij = calc_dose_influence(ct, cst, stf, pln)
30+
31+
# Optimization
32+
cst.vois[1].objectives = [SquaredDeviation(priority=100.0, d_ref=3.0)] # Target
33+
cst.vois[0].objectives = [
34+
SquaredOverdosing(priority=10.0, d_max=1.0),
35+
SquaredOverdosing(priority=5.0, d_max=1.0, quantity="let_dose"),
36+
] # OAR
37+
cst.vois[2].objectives = [
38+
MeanDose(priority=1.0, d_ref=0.0),
39+
SquaredOverdosing(priority=10.0, d_max=2.0),
40+
] # BODY
41+
fluence = fluence_optimization(ct, cst, stf, dij, pln)
42+
43+
# Result
44+
result = dij.compute_result_ct_grid(fluence)
45+
46+
# Choose a slice to visualize
47+
view_slice = int(np.round(ct.size[2] / 2))
48+
49+
# Visualize
50+
plot_slice(
51+
ct=ct,
52+
cst=cst,
53+
overlay=result["physical_dose"],
54+
view_slice=view_slice,
55+
)
56+
plot_slice(
57+
ct=ct,
58+
cst=cst,
59+
overlay=result["let"],
60+
view_slice=view_slice,
61+
)

pyRadPlan/core/_grids.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,9 @@ def _check_resolution(cls, value: dict[str, float]) -> dict[str, float]:
7676
raise ValueError("resolution must have keys 'x', 'y', 'z'")
7777

7878
# Check if all resolution values are positive floats. If not, try to cast them to float
79-
for key in value:
80-
if value[key] <= 0:
81-
raise ValueError(f"resolution values must be positive floats, got {value[key]}")
79+
for _, v in value.items():
80+
if v <= 0:
81+
raise ValueError(f"resolution values must be positive floats, got {v}")
8282

8383
return value
8484

@@ -93,11 +93,12 @@ def _check_dimensions(cls, value: tuple[int, int, int]) -> tuple[int, int, int]:
9393
# Check if all dimensions values are positive integers. If not, try to cast them to int
9494
for dim in value:
9595
try:
96-
dim = int(dim)
96+
tmpdim = int(dim)
9797
except ValueError:
9898
raise ValueError(f"dimension value could not be casted into int, got {dim}")
99-
if dim <= 0:
100-
raise ValueError(f"dimension values must be positive integers, got {dim}")
99+
100+
if tmpdim <= 0:
101+
raise ValueError(f"dimension values must be positive integers, got {tmpdim}")
101102

102103
return value
103104

@@ -162,8 +163,7 @@ def resample(
162163
],
163164
) -> Self:
164165
"""
165-
Create a resampled grid covering the original grid in a new
166-
resolution.
166+
Create a resampled grid covering the original grid in new resolution.
167167
168168
Parameters
169169
----------

pyRadPlan/core/np2sitk.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
"""Helper Functions relating to conversion between numpy and SimpleITK data
2-
types.
3-
"""
1+
"""Helpers for conversion between numpy and SimpleITK."""
42

53
from typing import Literal
64
import SimpleITK as sitk

pyRadPlan/core/resample.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"""Image / Grid Resampling."""
2+
13
import numpy as np
24
import SimpleITK as sitk
35
from ._grids import Grid
@@ -11,8 +13,9 @@ def resample_image(
1113
target_grid: Grid = None,
1214
) -> sitk.Image: # also accept sitk images and grid points. Can be dealt with with kwargs
1315
"""
14-
Resamples an sitk Image to a target resolution, reference image
15-
dimensions, or Grid.
16+
Resample an sitk Image.
17+
18+
Use target resolution, reference image, dimensions, or Grid as input.
1619
1720
Parameters
1821
----------
@@ -70,8 +73,10 @@ def resample_numpy_array(
7073
target_grid: Grid = None,
7174
) -> np.ndarray:
7275
"""
73-
Resamples a numpy grid to a target resolution, reference image
74-
dimensions, or Grid.
76+
Resample a numpy grid.
77+
78+
Use target resolution, reference image, dimensions, or Grid as input.
79+
7580
We convert the numpy array to an sitk.Image, so the dimensions will be
7681
switched. More exactly,
7782
the numpy array will be index i,j,k <-> z,y,x, which will converted to

pyRadPlan/ct/_ct.py

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ def validate_cube_hu(
9191
if cube_hu is None:
9292
raise ValueError("HU cube not present in dictionary.")
9393

94+
if isinstance(cube_hu, sitk.Image) and "origin" not in data:
95+
data["origin"] = cube_hu.GetOrigin()
96+
9497
if isinstance(cube_hu, np.ndarray):
9598
# Now we do check if it is matRad data
9699
if data.get("cubeDim", None) is not None:
@@ -133,16 +136,6 @@ def validate_cube_hu(
133136
is4d = cube_hu.GetDimension() == 4
134137
# Now we parse the rest of the data if additional data is available in the dictionary
135138

136-
# TODO: Either set up a Grid or check for x/y/z to do the data management
137-
if "origin" in data:
138-
data["cube_hu"].SetOrigin(data["origin"])
139-
140-
elif all(key in data for key in ("x", "y", "z")):
141-
origin = np.array([data["x"][0], data["y"][0], data["z"][0]], dtype=float)
142-
if is4d:
143-
origin = np.append(origin, [0.0])
144-
data["cube_hu"].SetOrigin(origin)
145-
146139
if "direction" in data:
147140
data["cube_hu"].SetDirection(data["direction"])
148141

@@ -153,6 +146,23 @@ def validate_cube_hu(
153146
else:
154147
cube_hu.SetSpacing([resolution["x"], resolution["y"], resolution["z"]])
155148

149+
# TODO: Either set up a Grid or check for x/y/z to do the data management
150+
if "origin" in data:
151+
data["cube_hu"].SetOrigin(data["origin"])
152+
153+
else:
154+
data["cube_hu"].SetOrigin(
155+
-np.array(data["cube_hu"].GetSize())
156+
/ 2.0
157+
* np.array(data["cube_hu"].GetSpacing())
158+
)
159+
if is4d:
160+
data["cube_hu"].SetOrigin(np.append(data["cube_hu"].GetOrigin(), [0.0]))
161+
# elif all(key in data for key in ("x", "y", "z")):
162+
# origin = np.array([data["x"][0], data["y"][0], data["z"][0]], dtype=float)
163+
# if is4d:
164+
# origin = np.append(origin, [0.0])
165+
# data["cube_hu"].SetOrigin(origin)
156166
return data
157167

158168
@computed_field

pyRadPlan/dij/_dij.py

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Contains the dij class as a (collection of) influence matrices."""
22

3-
from typing import Any, Union, Annotated, cast
3+
from typing import Any, Union, Annotated, Optional, cast
44
from pydantic import (
55
Field,
66
field_validator,
@@ -41,15 +41,17 @@ class Dij(PyRadPlanBaseModel):
4141
ct_grid: Annotated[Grid, Field(default=None)]
4242

4343
physical_dose: Annotated[NDArray, Field(default=None)]
44-
let_dose: Annotated[NDArray, Field(default=None)]
44+
let_dose: Annotated[NDArray, Field(default=None, alias="mLETDose")]
4545
alpha_dose: Annotated[NDArray, Field(default=None)]
4646
sqrt_beta_dose: Annotated[NDArray, Field(default=None)]
4747

4848
num_of_beams: Annotated[int, Field(default=None)]
4949

50-
bixel_num: Annotated[np.ndarray, Field(default=None)]
51-
ray_num: Annotated[np.ndarray, Field(default=None)]
52-
beam_num: Annotated[np.ndarray, Field(default=None)]
50+
bixel_num: Annotated[NDArray, Field(default=None)]
51+
ray_num: Annotated[NDArray, Field(default=None)]
52+
beam_num: Annotated[NDArray, Field(default=None)]
53+
54+
rad_depth_cubes: Optional[list[NDArray]] = Field(default=None)
5355

5456
@computed_field
5557
@property
@@ -109,11 +111,30 @@ def validate_matrices(cls, v: Any, info: ValidationInfo) -> np.ndarray:
109111
if mat.shape[0] != info.data["dose_grid"].num_voxels:
110112
raise ValueError(f"{info.field_name} shape inconsistent with ct grid")
111113

114+
if info.context and "from_matRad" in info.context and info.context["from_matRad"]:
115+
if v is not None:
116+
for i in range(v.size):
117+
shape = (
118+
int(info.data["dose_grid"].dimensions[2]),
119+
int(info.data["dose_grid"].dimensions[0]),
120+
int(info.data["dose_grid"].dimensions[1]),
121+
)
122+
v.flat[i] = swap_orientation_sparse_matrix(
123+
v.flat[i],
124+
shape,
125+
(1, 2), # (65, 100, 100) example
126+
)
127+
if v.flat[i] is not None and not isinstance(v.flat[i], sp.csc_matrix):
128+
v.flat[i] = sp.csc_matrix(v.flat[i])
129+
else:
130+
v = np.array([0])
131+
return [[v]] # TODO
132+
112133
return v
113134

114135
@field_validator("dose_grid", "ct_grid", mode="before")
115136
@classmethod
116-
def validate_grid(cls, grid: Union[Grid, dict]) -> Union[Grid, dict]:
137+
def validate_grid(cls, grid: Union[Grid, dict], info: ValidationInfo) -> Union[Grid, dict]:
117138
"""
118139
Validate grid dictionaries.
119140
@@ -123,7 +144,14 @@ def validate_grid(cls, grid: Union[Grid, dict]) -> Union[Grid, dict]:
123144
"""
124145
# Check if it is a dictionary and then try to create a Grid object
125146
if isinstance(grid, dict):
126-
grid = Grid.model_validate(grid)
147+
if info.context and "from_matRad" in info.context and info.context["from_matRad"]:
148+
grid["dimensions"] = np.array(
149+
[grid["dimensions"][1], grid["dimensions"][0], grid["dimensions"][2]]
150+
)
151+
# TODO: might swap offset and resolution
152+
grid = Grid.model_validate(grid)
153+
else:
154+
grid = Grid.model_validate(grid)
127155
return grid
128156

129157
@field_validator("beam_num", mode="before")
@@ -184,7 +212,6 @@ def grid_serializer(
184212
context = info.context
185213
if context and context.get("matRad") == "mat-file":
186214
return value.to_matrad(context=context["matRad"])
187-
188215
return handler(value, info)
189216

190217
@field_serializer("physical_dose", "let_dose", "alpha_dose", "sqrt_beta_dose")
@@ -204,6 +231,18 @@ def physical_dose_serializer(self, value: np.ndarray, info: SerializationInfo) -
204231
)
205232
if value.flat[i] is not None and not isinstance(value.flat[i], sp.csc_matrix):
206233
value.flat[i] = sp.csc_matrix(value.flat[i])
234+
# return 0 if value is None. savemat() cant handle 'None'
235+
elif context and context.get("matRad") == "mat-file" and value is None:
236+
value = np.array([0])
237+
return value
238+
239+
@field_serializer("rad_depth_cubes")
240+
def rad_depth_cubes_serializer(self, value: np.ndarray, info: SerializationInfo) -> np.ndarray:
241+
context = info.context
242+
if context and context.get("matRad") == "mat-file" and value is not None:
243+
# TODO: it might be necessary to rotate the cube!
244+
return value
245+
# return 0 if value is None. savemat() cant handle 'None'
207246
elif context and context.get("matRad") == "mat-file" and value is None:
208247
value = np.array([0])
209248
return value
@@ -253,7 +292,7 @@ def get_result_arrays_from_intensity(
253292
if self.physical_dose is None:
254293
raise ValueError("Physical dose must be calculated for dose-weighted let")
255294

256-
indices = out["physical_dose"] > 0.0
295+
indices = out["physical_dose"] > 0.05 * np.max(out["physical_dose"])
257296

258297
let_dose = self.let_dose.flat[scenario_index] @ intensity
259298
out["let"] = np.zeros_like(let_dose)

pyRadPlan/dose/engines/_base_pencilbeam_particle.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import cast, Literal, Any
66
import logging
77
import time
8+
from copy import deepcopy
89

910
import numpy as np
1011
from scipy.interpolate import interp1d
@@ -735,6 +736,8 @@ def _calc_lateral_particle_cut_off(self, cut_off_level, stf_element):
735736
comp_fac=1.0, depths=depth_values, cut_off=np.inf * np.ones_like(depth_values)
736737
)
737738

739+
base_kernel = deepcopy(kernel) # similar to base_data in matRad
740+
738741
# TODO: this could probably be done for all depths with one calculation without a loop
739742
for j, current_depth in enumerate(depth_values):
740743
# If there's no cut-off set, we do not need to find it. Set it to infinity
@@ -744,12 +747,13 @@ def _calc_lateral_particle_cut_off(self, cut_off_level, stf_element):
744747
# depth
745748
bixel = {
746749
"energy_ix": energy_ix,
747-
"kernel": kernel,
750+
"kernel": base_kernel,
748751
"radial_dist_sq": radial_dist_sq,
749752
"sigma_ini_sq": largest_sigma_sq4unique_energies[
750753
cnt - 1
751754
], # TODO: check if this result is correct (rounded but may be right)
752-
"rad_depths": (current_depth + kernel.offset) * np.ones_like(radial_dist_sq),
755+
"rad_depths": (current_depth + base_kernel.offset)
756+
* np.ones_like(radial_dist_sq),
753757
"v_tissue_index": np.ones_like(radial_dist_sq),
754758
"v_alpha_x": 0.5 * np.ones_like(radial_dist_sq),
755759
"v_beta_x": 0.05 * np.ones_like(radial_dist_sq),

pyRadPlan/dose/engines/_svdpb.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ def _compute_bixel(self, curr_ray: dict[str], k: int) -> dict[str, Any]:
299299
bixel_dose *= ((sad) / geo_depths) ** 2
300300

301301
bixel["physical_dose"] = bixel_dose
302-
302+
bixel["weight"] = curr_ray["beamlets"][k]["weight"]
303303
bixel["ix"] = curr_ray["ix"]
304304

305305
return bixel

pyRadPlan/optimization/problems/_optiprob.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ def _initialize(self):
191191
)
192192

193193
# TODO: manage scenarios
194+
195+
# Manage quantites by getting them from the objective quantities
194196
self._quantities = [q(self._dij) for q in quantities]
195197

196198
# obtain cache info to match quantities with objectives

0 commit comments

Comments
 (0)