Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
404 changes: 404 additions & 0 deletions .ipynb_checkpoints/RTUTILS_read_world_example_for_PET-checkpoint.ipynb

Large diffs are not rendered by default.

22 changes: 0 additions & 22 deletions rt_utils/nifti2rt.py

This file was deleted.

8 changes: 6 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/qurit/rtutils",
package_dir={'':"rt_utils"},
packages=setuptools.find_packages("rt_utils", exclude="tests"),

# package_dir={'':"rt_utils"},
# packages=setuptools.find_packages("rt_utils", exclude="tests"),
package_dir={"": "src"},
packages=setuptools.find_packages(where="src"),

keywords=["RTStruct", "Dicom", "Pydicom"],
classifiers=[
"Operating System :: OS Independent",
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
43 changes: 25 additions & 18 deletions rt_utils/image_helper.py → src/rt_utils/image_helper.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import os
from typing import List
from typing import List, Union
from enum import IntEnum

import SimpleITK
import cv2 as cv
import numpy as np
from pydicom import dcmread
Expand Down Expand Up @@ -60,7 +61,11 @@ def get_contours_coords(roi_data: ROIData, series_data):
mask_slice = create_pin_hole_mask(mask_slice, roi_data.approximate_contours)

# Get contours from mask
contours, _ = find_mask_contours(mask_slice, roi_data.approximate_contours)
contours, _ = find_mask_contours(mask_slice,
roi_data.approximate_contours,
scaling_factor=roi_data.scaling_factor)
if not contours:
continue
validate_contours(contours)

# Format for DICOM
Expand All @@ -82,7 +87,8 @@ def get_contours_coords(roi_data: ROIData, series_data):
return series_contours


def find_mask_contours(mask: np.ndarray, approximate_contours: bool):

def find_mask_contours(mask: np.ndarray, approximate_contours: bool, scaling_factor: int):
approximation_method = (
cv.CHAIN_APPROX_SIMPLE if approximate_contours else cv.CHAIN_APPROX_NONE
)
Expand All @@ -93,8 +99,12 @@ def find_mask_contours(mask: np.ndarray, approximate_contours: bool):
contours = list(
contours
) # Open-CV updated contours to be a tuple so we convert it back into a list here

# Coordinates are rescaled to image grid by dividing with scaling factor
for i, contour in enumerate(contours):
contours[i] = [[pos[0][0], pos[0][1]] for pos in contour]
contours[i] = [[(contour[i][0][0] / scaling_factor), (contour[i][0][1] / scaling_factor)] for i in
range(0, len(contour))]

hierarchy = hierarchy[0] # Format extra array out of data

return contours, hierarchy
Expand Down Expand Up @@ -126,7 +136,7 @@ def create_pin_hole_mask(mask: np.ndarray, approximate_contours: bool):


def draw_line_upwards_from_point(
mask: np.ndarray, start, fill_value: int
mask: np.ndarray, start, fill_value: int
) -> np.ndarray:
line_width = 2
end = (start[0], start[1] - 1)
Expand Down Expand Up @@ -161,10 +171,8 @@ def get_pixel_to_patient_transformation_matrix(series_data):
row_direction, column_direction, slice_direction = get_slice_directions(first_slice)

mat = np.identity(4, dtype=np.float32)
#The following might appear counter-intuitive, i.e. multiplying the row direction with the column spacing and vice-versa
#But is the correct way to create the transformation matrix, see https://nipy.org/nibabel/dicom/dicom_orientation.html
mat[:3, 0] = row_direction * column_spacing
mat[:3, 1] = column_direction * row_spacing
mat[:3, 0] = row_direction * row_spacing
mat[:3, 1] = column_direction * column_spacing
mat[:3, 2] = slice_direction * slice_spacing
mat[:3, 3] = offset

Expand All @@ -185,11 +193,9 @@ def get_patient_to_pixel_transformation_matrix(series_data):
# inv(M) = [ inv(rotation&scaling) -inv(rotation&scaling) * translation ]
# [ 0 1 ]

#The following might appear counter-intuitive, i.e. dividing the row direction with the column spacing and vice-versa
#But is the correct way to create the inverse transformation matrix, see https://nipy.org/nibabel/dicom/dicom_orientation.html
linear = np.identity(3, dtype=np.float32)
linear[0, :3] = row_direction / column_spacing
linear[1, :3] = column_direction / row_spacing
linear[0, :3] = row_direction / row_spacing
linear[1, :3] = column_direction / column_spacing
linear[2, :3] = slice_direction / slice_spacing

mat = np.identity(4, dtype=np.float32)
Expand All @@ -200,7 +206,7 @@ def get_patient_to_pixel_transformation_matrix(series_data):


def apply_transformation_to_3d_points(
points: np.ndarray, transformation_matrix: np.ndarray
points: np.ndarray, transformation_matrix: np.ndarray
):
"""
* Augment each point with a '1' as the fourth coordinate to allow translation
Expand All @@ -223,7 +229,7 @@ def get_slice_directions(series_slice: Dataset):
slice_direction = np.cross(row_direction, column_direction)

if not np.allclose(
np.dot(row_direction, column_direction), 0.0, atol=1e-3
np.dot(row_direction, column_direction), 0.0, atol=1e-3
) or not np.allclose(np.linalg.norm(slice_direction), 1.0, atol=1e-3):
raise Exception("Invalid Image Orientation (Patient) attribute")

Expand Down Expand Up @@ -267,7 +273,7 @@ def get_slice_contour_data(series_slice: Dataset, contour_sequence: Sequence):


def get_slice_mask_from_slice_contour_data(
series_slice: Dataset, slice_contour_data, transformation_matrix: np.ndarray
series_slice: Dataset, slice_contour_data, transformation_matrix: np.ndarray
):
# Go through all contours in a slice, create polygons in correct space and with a correct format
# and append to polygons array (appropriate for fillPoly)
Expand All @@ -279,9 +285,10 @@ def get_slice_mask_from_slice_contour_data(
polygon = np.array(polygon).squeeze()
polygons.append(polygon)
slice_mask = create_empty_slice_mask(series_slice).astype(np.uint8)
cv.fillPoly(img=slice_mask, pts = polygons, color = 1)
cv.fillPoly(img=slice_mask, pts=polygons, color=1)
return slice_mask


def create_empty_series_mask(series_data):
ref_dicom_image = series_data[0]
mask_dims = (
Expand All @@ -307,4 +314,4 @@ class Hierarchy(IntEnum):
next_node = 0
previous_node = 1
first_child = 2
parent_node = 3
parent_node = 3
File renamed without changes.
96 changes: 41 additions & 55 deletions rt_utils/rtstruct.py → src/rt_utils/rtstruct.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from typing import List, Union
from typing import List, Union, Dict

import numpy as np
from pydicom.dataset import FileDataset
from rt_utils.utils import ROIData
from . import ds_helper, image_helper

from rt_utils.utils import ROIData
from . import ds_helper, image_helper, smoothing
from typing import Tuple

class RTStruct:
"""
Expand All @@ -15,12 +17,13 @@ def __init__(self, series_data, ds: FileDataset, ROIGenerationAlgorithm=0):
self.ds = ds
self.frame_of_reference_uid = ds.ReferencedFrameOfReferenceSequence[
-1
].FrameOfReferenceUID # Use last structured set ROI
].FrameOfReferenceUID # Use last strucitured set ROI

def set_series_description(self, description: str):
"""
Set the series description for the RTStruct dataset
"""

self.ds.SeriesDescription = description

def add_roi(
Expand All @@ -32,51 +35,30 @@ def add_roi(
use_pin_hole: bool = False,
approximate_contours: bool = True,
roi_generation_algorithm: Union[str, int] = 0,
apply_smoothing: Union[str, None] = None, # strings can be "2d" or "3d" or something else if a different smoothing function is used
smoothing_function = smoothing.pipeline, # Can be any function/set of functions that takes the following parameters
# # smoothing_function(mask=mask, apply_smoothing=apply_smoothing,
# smoothing_parameters=smoothing_parameters) -> np.ndarray
# The returned np.ndarray can be of any integer scalar shape in x and y of the used dicom image.
# Note that Z direction should not be scaled. For instance CT_image.shape == (512, 512, 150).
# Smoothed returned array can be (1024, 1024, 150) or (5120, 5120, 150), though you RAM will suffer with the latter.
smoothing_parameters: Union[Dict, None] = None,
):
"""
Add a Region of Interest (ROI) to the RTStruct given a 3D binary mask for each slice.

Optionally input a color or name for the ROI.
If `use_pin_hole` is set to True, attempts to handle ROIs with holes by creating a single continuous contour.
If `approximate_contours` is set to False, no approximation is done during contour generation,
potentially resulting in a large amount of contour data.

This method updates the internal DICOM structure (RTStruct) by adding:
- ROIContourSequence
- StructureSetROISequence
- RTROIObservationsSequence

Parameters
----------
mask : np.ndarray
3D boolean array indicating the ROI. Its shape must match
the underlying DICOM series in the third dimension.
color : str or list of int, optional
Color representation for the ROI (e.g., "red" or [255, 0, 0]). Defaults to None.
name : str, optional
Name/label for the ROI. Defaults to None.
description : str, optional
Longer description of the ROI. Defaults to an empty string.
use_pin_hole : bool, optional
If True, attempts to create a single continuous contour for ROIs with holes. Defaults to False.
approximate_contours : bool, optional
If False, skips approximation during contour generation, leading to larger contour data. Defaults to True.
roi_generation_algorithm : str or int, optional
Identifier for the algorithm used to generate the ROI. Defaults to 0.

Raises
------
ROIException
- If the mask is not a 3D boolean array.
- If the mask's shape does not match the loaded DICOM series dimensions.
- If the mask is empty (no voxels set to True).

Returns
-------
None
Modifies the internal RTStruct.
Add a ROI to the rtstruct given a 3D binary mask for the ROI's at each slice
Optionally input a color or name for the ROI
If use_pin_hole is set to true, will cut a pinhole through ROI's with holes in them so that they are represented with one contour
If approximate_contours is set to False, no approximation will be done when generating contour data, leading to much larger amount of contour data
"""
# TODO: test if name already exists
if apply_smoothing:
mask = smoothing_function(mask=mask, apply_smoothing=apply_smoothing,
smoothing_parameters=smoothing_parameters)

## If upscaled coords are given, they should be adjusted accordingly
rows = self.series_data[0][0x00280010].value
scaling_factor = int(mask.shape[0] / rows)

# TODO test if name already exists
self.validate_mask(mask)
roi_number = len(self.ds.StructureSetROISequence) + 1
roi_data = ROIData(
Expand All @@ -89,6 +71,7 @@ def add_roi(
use_pin_hole,
approximate_contours,
roi_generation_algorithm,
scaling_factor
)

self.ds.ROIContourSequence.append(
Expand All @@ -104,15 +87,15 @@ def add_roi(
def validate_mask(self, mask: np.ndarray) -> bool:
if mask.dtype != bool:
raise RTStruct.ROIException(
f"Mask data type must be boolean, but got {mask.dtype}. Please ensure the mask is a 3D boolean array."
f"Mask data type must be boolean. Got {mask.dtype}"
)

if mask.ndim != 3:
raise RTStruct.ROIException(f"Mask must be 3 dimensional. Got {mask.ndim}")

if len(self.series_data) != np.shape(mask)[2]:
raise RTStruct.ROIException(
"Mask must have the same number of layers (in the 3rd dimension) as the input series. "
"Mask must have the save number of layers (In the 3rd dimension) as input series. "
+ f"Expected {len(self.series_data)}, got {np.shape(mask)[2]}"
)

Expand Down Expand Up @@ -151,22 +134,25 @@ def get_roi_mask_by_name(self, name) -> np.ndarray:

def save(self, file_path: str):
"""
Saves the RTStruct with the specified name / location.
Automatically adds '.dcm' as a suffix if necessary.
Saves the RTStruct with the specified name / location
Automatically adds '.dcm' as a suffix
"""

# Add .dcm if needed
file_path = file_path if file_path.endswith(".dcm") else file_path + ".dcm"

try:
# Using 'with' to handle file opening and closing automatically
with open(file_path, "w") as file:
print("Writing file to", file_path)
self.ds.save_as(file_path)
file = open(file_path, "w")
# Opening worked, we should have a valid file_path
print("Writing file to", file_path)
self.ds.save_as(file_path)
file.close()
except OSError:
raise Exception(f"Cannot write to file path '{file_path}'")

class ROIException(Exception):
"""
Exception class for invalid ROI masks
"""
pass

pass
File renamed without changes.
File renamed without changes.
Loading