Skip to content

Commit eb5d463

Browse files
committed
Merge branch 'refactor/rt-performance' into 'develop'
Increase RayTracer and Dij Filling Performance See merge request e040/e0404/pyRadPlan!74
2 parents e9b6e89 + 3b44ed6 commit eb5d463

File tree

7 files changed

+384
-246
lines changed

7 files changed

+384
-246
lines changed

examples/import_matrad_patient.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
# For that, we use a default HU->rSP table to convert our CT to water-equivalent thickness and then
7474
# call a voxel-wise RayTracing algorithm (proposed by Siddon) to calculate the radiological depth.
7575
rt = RayTracerSiddon([ct.compute_wet(default_hlut())])
76+
rt.debug_core_performance = True
7677
rad_depth_cubes = rt.trace_cubes(stf[0])
7778

7879
# We could write the image if we want, but we don't do this by default.

pyRadPlan/core/np2sitk.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,10 @@ def linear_indices_to_sitk_mask(
8080

8181

8282
def linear_indices_to_grid_coordinates(
83-
indices: np.ndarray, grid: Grid, index_type: Literal["numpy", "sitk"] = "numpy"
83+
indices: np.ndarray,
84+
grid: Grid,
85+
index_type: Literal["numpy", "sitk"] = "numpy",
86+
dtype: np.dtype = np.float64,
8487
) -> np.ndarray:
8588
"""
8689
Convert linear indices to gridcoordinates.
@@ -94,32 +97,45 @@ def linear_indices_to_grid_coordinates(
9497
index_type : Literal["numpy", "sitk"], optional
9598
The ordering of the indices. Can be 'sitk' for Fortran-like index
9699
ordering or 'numpy' for C-like index ordering. Default is 'numpy'.
100+
dtype : np.dtype, optional
101+
The data type of the output coordinates. Default is np.float64.
97102
98103
Returns
99104
-------
100105
np.ndarray
101106
A 2D numpy array of image coordinates.
102107
"""
103108

109+
# this is a manual reimplementation of np.unravel_index
110+
# to avoid the overhead of creating a tuple of arrays
104111
if index_type == "numpy":
105-
z, y, x = np.unravel_index(indices, grid.dimensions[::-1])
112+
_, d1, d2 = grid.dimensions[::-1]
113+
order = [0, 1, 2]
106114
elif index_type == "sitk":
107-
x, y, z = np.unravel_index(indices, grid.dimensions)
115+
_, d1, d2 = grid.dimensions
116+
order = [2, 1, 0]
108117
else:
109118
raise ValueError("Invalid index type. Must be 'numpy' or 'sitk'.")
110119

111-
v = np.array([x, y, z])
120+
v = np.empty((3, np.asarray(indices).size), dtype=dtype)
121+
tmp, v[order[0]] = np.divmod(indices, d2)
122+
v[order[2]], v[order[1]] = np.divmod(tmp, d1)
112123

113-
spacing_diag = np.diag([grid.resolution["x"], grid.resolution["y"], grid.resolution["z"]])
114-
origin = grid.origin
124+
spacing_diag = np.diag(
125+
[grid.resolution["x"], grid.resolution["y"], grid.resolution["z"]]
126+
).astype(dtype)
127+
origin = grid.origin.astype(dtype)
115128

116129
physical_point = origin + np.matmul(np.matmul(grid.direction, spacing_diag), v).T
117130

118131
return physical_point
119132

120133

121134
def linear_indices_to_image_coordinates(
122-
indices: np.ndarray, image: sitk.Image, index_type: Literal["numpy", "sitk"] = "numpy"
135+
indices: np.ndarray,
136+
image: sitk.Image,
137+
index_type: Literal["numpy", "sitk"] = "numpy",
138+
dtype: np.dtype = np.float64,
123139
) -> np.ndarray:
124140
"""
125141
Convert linear indices to image coordinates.
@@ -133,6 +149,8 @@ def linear_indices_to_image_coordinates(
133149
index_type : Literal["numpy", "sitk"], optional
134150
The ordering of the indices. Can be 'sitk' for Fortran-like index
135151
ordering or 'numpy' for C-like index ordering. Default is 'numpy'.
152+
dtype : np.dtype, optional
153+
The data type of the output coordinates. Default is np.float64.
136154
137155
Returns
138156
-------
@@ -141,4 +159,4 @@ def linear_indices_to_image_coordinates(
141159
"""
142160

143161
grid = Grid.from_sitk_image(image)
144-
return linear_indices_to_grid_coordinates(indices, grid, index_type)
162+
return linear_indices_to_grid_coordinates(indices, grid, index_type, dtype=dtype)

pyRadPlan/dose/engines/_base.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -470,12 +470,7 @@ def _init_dose_calc(self, ct: CT, cst: StructureSet, stf: SteeringInformation) -
470470
dij["ct_grid"] = ct.grid
471471

472472
if self.dose_grid is None:
473-
logger.info(
474-
"Dose grid not set. Using default resolution: x=%f, y=%f, z=%f",
475-
ct.grid.resolution["x"],
476-
ct.grid.resolution["y"],
477-
ct.grid.resolution["z"],
478-
)
473+
logger.info("Dose Grid not set. Using default resolution.")
479474
self.dose_grid = ct.grid.resample({"x": 5.0, "y": 5.0, "z": 5.0})
480475

481476
if not isinstance(self.dose_grid, Grid):
@@ -484,6 +479,16 @@ def _init_dose_calc(self, ct: CT, cst: StructureSet, stf: SteeringInformation) -
484479
except ValidationError:
485480
self.dose_grid = ct.grid.resample(self.dose_grid["resolution"])
486481

482+
logger.info(
483+
"Dose Grid has Dimensions (%d,%d,%d) with resolution x=%f, y=%f, z=%f.",
484+
self.dose_grid.dimensions[0],
485+
self.dose_grid.dimensions[1],
486+
self.dose_grid.dimensions[2],
487+
self.dose_grid.resolution["x"],
488+
self.dose_grid.resolution["y"],
489+
self.dose_grid.resolution["z"],
490+
)
491+
487492
dij["dose_grid"] = self.dose_grid
488493

489494
# Meta information for dij #TODO: Change dij Model in future

pyRadPlan/dose/engines/_base_pencilbeam.py

Lines changed: 107 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Base class for pencil beam dose calculation algorithms."""
22

33
from abc import abstractmethod
4-
from typing import cast, Any, Literal
4+
from typing import Any, Literal
55
import warnings
66
import logging
77
import time
@@ -97,6 +97,7 @@ class PencilBeamEngineAbstract(DoseEngineBase):
9797
ssd_density_threshold: float
9898
use_given_eq_density_cube: bool
9999
ignore_outside_densities: bool
100+
trace_on_dose_grid: bool
100101
cube_wed: sitk.Image
101102
hlut: np.ndarray
102103

@@ -107,13 +108,15 @@ def __init__(self, pln=None):
107108
self.ssd_density_threshold: float = 0.0500
108109
self.use_given_eq_density_cube: bool = False
109110
self.ignore_outside_densities: bool = True
111+
self.trace_on_dose_grid: bool = True
110112
self.cube_wed = None
111113
self.hlut = None
112114

113115
self._computed_quantities = []
114116
self._effective_lateral_cutoff = None
115117
self._num_of_bixels_container = None
116118
self._rad_depth_cubes = []
119+
self._raytracer = None
117120

118121
super().__init__(pln)
119122

@@ -275,6 +278,18 @@ def _init_dose_calc(self, ct: CT, cst: StructureSet, stf: SteeringInformation):
275278
# Allocate memory for quantity containers
276279
dij = self._allocate_quantity_matrices(dij, ["physical_dose"])
277280

281+
# Initialize ray-tracer
282+
if self.trace_on_dose_grid:
283+
wed_cube_trace = resample_image(
284+
input_image=self.cube_wed,
285+
interpolator=sitk.sitkLinear,
286+
target_grid=self.dose_grid,
287+
)
288+
else:
289+
wed_cube_trace = self.cube_wed
290+
291+
self._raytracer = RayTracerSiddon([wed_cube_trace])
292+
278293
return dij
279294

280295
def _allocate_quantity_matrices(self, dij: dict[str, Any], names: list[str]):
@@ -293,14 +308,18 @@ def _allocate_quantity_matrices(self, dij: dict[str, Any], names: list[str]):
293308
(self.dose_grid.num_voxels, dij["num_of_beams"]), dtype=np.float32
294309
)
295310
else:
296-
# This could probably be optimized by using direct access to the
297-
# lil_matrix's data structures
298-
# TODO: we could store a single sparse pattern matrix and then only store
299-
# the values for all quantities for better memory management
300-
dij[q_name].flat[i] = sparse.lil_matrix(
301-
(self._num_of_columns_dij, self.dose_grid.num_voxels),
302-
dtype=np.float32,
303-
)
311+
# We allocate raw csc sparse matrix structures using
312+
# a rough estimat ebased on number of voxels and
313+
# beamlets
314+
est_nnz = np.fix(
315+
5e-4 * self.dose_grid.num_voxels * self._num_of_columns_dij
316+
).astype(np.int64)
317+
dij[q_name].flat[i] = {
318+
"data": np.empty((est_nnz,), dtype=np.float32),
319+
"indices": np.empty((est_nnz,), dtype=np.int64),
320+
"indptr": np.zeros((self._num_of_columns_dij + 1,), dtype=np.int64),
321+
"nnz": 0,
322+
}
304323

305324
self._computed_quantities.append(q_name)
306325

@@ -360,12 +379,11 @@ def _init_beam(
360379
# Calculate radiological depth cube
361380
logger.info("Calculating radiological depth cube...")
362381

363-
ct_cube = self.cube_wed
364-
raytracer = RayTracerSiddon([ct_cube])
365-
366382
start_time = time.time()
367383

368-
rad_depth_cubes = raytracer.trace_cubes(beam_info["beam"])
384+
self._raytracer.debug_core_performance = True
385+
rad_depth_cubes = self._raytracer.trace_cubes(beam_info["beam"])
386+
self._raytracer.debug_core_performance = False
369387

370388
# TODO: add universal time-debugging method
371389
end_time = time.time()
@@ -376,15 +394,18 @@ def _init_beam(
376394
beam_info["rad_depths"] = [None] * len(rad_depth_cubes)
377395

378396
for c, rad_depth_cube in enumerate(rad_depth_cubes):
379-
resampled_rad_depth_cube = resample_image(
380-
input_image=rad_depth_cube,
381-
interpolator=sitk.sitkLinear,
382-
target_grid=self.dose_grid,
383-
)
397+
if self.trace_on_dose_grid:
398+
rad_depth_cube_dose_grid = rad_depth_cube
399+
else:
400+
rad_depth_cube_dose_grid = resample_image(
401+
input_image=rad_depth_cube,
402+
interpolator=sitk.sitkLinear,
403+
target_grid=self.dose_grid,
404+
)
384405
if self.keep_rad_depth_cubes:
385-
self._rad_depth_cubes.append(resampled_rad_depth_cube)
406+
self._rad_depth_cubes.append(rad_depth_cube_dose_grid)
386407

387-
rad_depth_cube_dosegrid = sitk.GetArrayViewFromImage(resampled_rad_depth_cube)
408+
rad_depth_cube_dosegrid = sitk.GetArrayViewFromImage(rad_depth_cube_dose_grid)
388409
rad_depth_vdose_grid = rad_depth_cube_dosegrid.ravel()[self._vdose_grid]
389410

390411
# Find valid coordinates
@@ -484,8 +505,7 @@ def _compute_ssd(
484505
ray_pos_bev = np.array([ray["ray_pos_bev"] for ray in beam["rays"]])
485506
target_points = np.array([ray["target_point"] for ray in beam["rays"]])
486507

487-
raytracer = RayTracerSiddon([self.cube_wed])
488-
alpha, _, rho, d12, _ = raytracer.trace_rays(
508+
alpha, _, rho, d12, _ = self._raytracer.trace_rays(
489509
beam["iso_center"],
490510
beam["source_point"].reshape((1, 3)),
491511
target_points,
@@ -708,10 +728,44 @@ def _fill_dij(
708728
bixel["weight"] * bixel[q_name]
709729
)
710730
else:
711-
# We insert into the lil sparse matrix
712-
# This could probably be optimized by using direct access to the lil_matrix's
713-
# data structures
714-
dij[q_name][sub_scen_idx][bixel_counter, bixel["ix"]] = bixel[q_name]
731+
# first we fill the index pointer array
732+
dij[q_name][sub_scen_idx]["indptr"][bixel_counter + 1] = (
733+
dij[q_name][sub_scen_idx]["indptr"][bixel_counter] + bixel["ix"].size
734+
)
735+
736+
# If we haven't preallocated enough, we need to expand the arrays
737+
if (
738+
dij[q_name][sub_scen_idx]["data"].size
739+
< dij[q_name][sub_scen_idx]["nnz"] + bixel["ix"].size
740+
):
741+
logger.debug("Resizing data and indices arrays for %s...", q_name)
742+
743+
# We estimate the required size by the remaining
744+
# number of beamlets / columns in the sparse matrix
745+
# 1 additional beamlet is added
746+
shape_resize = (self._num_of_columns_dij - bixel_counter) * (
747+
bixel["ix"].size + 1
748+
)
749+
shape_resize += dij[q_name][sub_scen_idx]["data"].size
750+
dij[q_name][sub_scen_idx]["data"].resize((shape_resize,), refcheck=False)
751+
dij[q_name][sub_scen_idx]["indices"].resize(
752+
(shape_resize,), refcheck=False
753+
)
754+
755+
# Fill the corresponding values and indices using indptr
756+
dij[q_name][sub_scen_idx]["data"][
757+
dij[q_name][sub_scen_idx]["indptr"][bixel_counter] : dij[q_name][
758+
sub_scen_idx
759+
]["indptr"][bixel_counter + 1]
760+
] = bixel[q_name]
761+
dij[q_name][sub_scen_idx]["indices"][
762+
dij[q_name][sub_scen_idx]["indptr"][bixel_counter] : dij[q_name][
763+
sub_scen_idx
764+
]["indptr"][bixel_counter + 1]
765+
] = bixel["ix"]
766+
767+
# Store how many nnzs we actually have in the matrix
768+
dij[q_name][sub_scen_idx]["nnz"] += bixel["ix"].size
715769

716770
# Bookkeeping of bixel numbers
717771
# remember beam and bixel number
@@ -749,9 +803,34 @@ def _finalize_dose(self, dij: dict):
749803
# Loop over all used quantities
750804
for q_name in self._computed_quantities:
751805
if not self._calc_dose_direct:
752-
tmp_matrix = cast(sparse.lil_matrix, dij[q_name].flat[i])
753-
tmp_matrix = tmp_matrix.tocsr().T
806+
# tmp_matrix = cast(sparse.lil_matrix, dij[q_name].flat[i])
807+
# tmp_matrix = tmp_matrix.tocsr().T
808+
data_dict = dij[q_name].flat[i]
809+
810+
# Resize to the actual number of non-zero elements
811+
data_dict["data"].resize((data_dict["nnz"],), refcheck=False)
812+
data_dict["indices"].resize((data_dict["nnz"],), refcheck=False)
813+
814+
# Create the matrix, avoid copies
815+
tmp_matrix = sparse.csc_array(
816+
(data_dict["data"], data_dict["indices"], data_dict["indptr"]),
817+
shape=(self.dose_grid.num_voxels, self._num_of_columns_dij),
818+
dtype=np.float32,
819+
copy=False,
820+
)
821+
822+
# Do we need this?
754823
tmp_matrix.eliminate_zeros()
824+
825+
# make sure indices are sorted and matrix is canonical
826+
if not tmp_matrix.has_sorted_indices:
827+
logger.debug("Sorting indices for %s...", q_name)
828+
tmp_matrix.sort_indices()
829+
830+
if not tmp_matrix.has_canonical_format:
831+
logger.debug("Matrix is not in canonical format for %s...", q_name)
832+
tmp_matrix.sum_duplicates()
833+
755834
dij[q_name].flat[i] = tmp_matrix
756835

757836
if self.keep_rad_depth_cubes and self._rad_depth_cubes:

pyRadPlan/dose/engines/_base_pencilbeam_particle.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def __init__(self, pln):
5555
self.calc_let = True
5656
self.calc_bio_dose = False
5757
self.air_offset_correction = True
58-
self.lateral_model = "auto"
58+
self.lateral_model = "fastest"
5959
self.cut_off_method = "integral"
6060

6161
# Protected properties with public get access

0 commit comments

Comments
 (0)