| import pydicom |
| import numpy as np |
| import math |
| import time |
| import pickle, scipy |
|
|
|
|
|
|
| class RTplan: |
|
|
| def __init__(self): |
| self.SeriesInstanceUID = "" |
| self.SOPInstanceUID = "" |
| self.PatientInfo = {} |
| self.StudyInfo = {} |
| self.DcmFile = "" |
| self.Modality = "" |
| self.RadiationType = "" |
| self.ScanMode = "" |
| self.TreatmentMachineName = "" |
| self.NumberOfFractionsPlanned = 1 |
| self.NumberOfSpots = 0 |
| self.Beams = [] |
| self.TotalMeterset = 0.0 |
| self.PlanName = "" |
| self.isLoaded = 0 |
| self.beamlets = [] |
| self.OriginalDicomDataset = [] |
| |
| |
| |
| def print_plan_info(self, prefix=""): |
| print(prefix + "Plan: " + self.SeriesInstanceUID) |
| print(prefix + " " + self.DcmFile) |
| |
| |
| |
| def import_Dicom_plan(self): |
| if(self.isLoaded == 1): |
| print("Warning: RTplan " + self.SeriesInstanceUID + " is already loaded") |
| return |
| |
| dcm = pydicom.dcmread(self.DcmFile) |
| |
| self.OriginalDicomDataset = dcm |
| |
| |
| if dcm.SOPClassUID == "1.2.840.10008.5.1.4.1.1.481.5": |
| print("ERROR: Conventional radiotherapy (photon) plans are not supported") |
| self.Modality = "Radiotherapy" |
| return |
| |
| |
| elif dcm.SOPClassUID == "1.2.840.10008.5.1.4.1.1.481.8": |
| self.Modality = "Ion therapy" |
| |
| if dcm.IonBeamSequence[0].RadiationType == "PROTON": |
| self.RadiationType = "Proton" |
| else: |
| print("ERROR: Radiation type " + dcm.IonBeamSequence[0].RadiationType + " not supported") |
| self.RadiationType = dcm.IonBeamSequence[0].RadiationType |
| return |
| |
| if dcm.IonBeamSequence[0].ScanMode == "MODULATED": |
| self.ScanMode = "MODULATED" |
| else: |
| print("ERROR: Scan mode " + dcm.IonBeamSequence[0].ScanMode + " not supported") |
| self.ScanMode = dcm.IonBeamSequence[0].ScanMode |
| return |
| |
| |
| else: |
| print("ERROR: Unknown SOPClassUID " + dcm.SOPClassUID + " for file " + self.DcmFile) |
| self.Modality = "Unknown" |
| return |
| |
| |
| self.SOPInstanceUID = dcm.SOPInstanceUID |
| self.NumberOfFractionsPlanned = int(dcm.FractionGroupSequence[0].NumberOfFractionsPlanned) |
| self.NumberOfSpots = 0 |
| self.TotalMeterset = 0 |
| |
| if(hasattr(dcm.IonBeamSequence[0], 'TreatmentMachineName')): |
| self.TreatmentMachineName = dcm.IonBeamSequence[0].TreatmentMachineName |
| else: |
| self.TreatmentMachineName = "" |
| |
| for dcm_beam in dcm.IonBeamSequence: |
| if dcm_beam.TreatmentDeliveryType != "TREATMENT": |
| continue |
| |
| first_layer = dcm_beam.IonControlPointSequence[0] |
| |
| beam = Plan_IonBeam() |
| beam.SeriesInstanceUID = self.SeriesInstanceUID |
| beam.BeamName = dcm_beam.BeamName |
| beam.IsocenterPosition = [float(first_layer.IsocenterPosition[0]), float(first_layer.IsocenterPosition[1]), float(first_layer.IsocenterPosition[2])] |
| beam.GantryAngle = float(first_layer.GantryAngle) |
| beam.PatientSupportAngle = float(first_layer.PatientSupportAngle) |
| beam.FinalCumulativeMetersetWeight = float(dcm_beam.FinalCumulativeMetersetWeight) |
| |
| |
| ReferencedBeam_id = next((x for x, val in enumerate(dcm.FractionGroupSequence[0].ReferencedBeamSequence) if val.ReferencedBeamNumber == dcm_beam.BeamNumber), -1) |
| if ReferencedBeam_id == -1: |
| print("ERROR: Beam number " + dcm_beam.BeamNumber + " not found in FractionGroupSequence.") |
| print("This beam is therefore discarded.") |
| continue |
| else: beam.BeamMeterset = float(dcm.FractionGroupSequence[0].ReferencedBeamSequence[ReferencedBeam_id].BeamMeterset) |
| |
| self.TotalMeterset += beam.BeamMeterset |
| |
| if dcm_beam.NumberOfRangeShifters == 0: |
| beam.RangeShifterID = "" |
| beam.RangeShifterType = "none" |
| elif dcm_beam.NumberOfRangeShifters == 1: |
| beam.RangeShifterID = dcm_beam.RangeShifterSequence[0].RangeShifterID |
| if dcm_beam.RangeShifterSequence[0].RangeShifterType == "BINARY": |
| beam.RangeShifterType = "binary" |
| elif dcm_beam.RangeShifterSequence[0].RangeShifterType == "ANALOG": |
| beam.RangeShifterType = "analog" |
| else: |
| print("ERROR: Unknown range shifter type for beam " + dcm_beam.BeamName) |
| beam.RangeShifterType = "none" |
| else: |
| print("ERROR: More than one range shifter defined for beam " + dcm_beam.BeamName) |
| beam.RangeShifterID = "" |
| beam.RangeShifterType = "none" |
| |
| |
| SnoutPosition = 0 |
| if hasattr(first_layer, 'SnoutPosition'): |
| SnoutPosition = float(first_layer.SnoutPosition) |
| |
| IsocenterToRangeShifterDistance = SnoutPosition |
| RangeShifterWaterEquivalentThickness = "" |
| RangeShifterSetting = "OUT" |
| ReferencedRangeShifterNumber = 0 |
| |
| if hasattr(first_layer, 'RangeShifterSettingsSequence'): |
| if hasattr(first_layer.RangeShifterSettingsSequence[0], 'IsocenterToRangeShifterDistance'): |
| IsocenterToRangeShifterDistance = float(first_layer.RangeShifterSettingsSequence[0].IsocenterToRangeShifterDistance) |
| if hasattr(first_layer.RangeShifterSettingsSequence[0], 'RangeShifterWaterEquivalentThickness'): |
| RangeShifterWaterEquivalentThickness = float(first_layer.RangeShifterSettingsSequence[0].RangeShifterWaterEquivalentThickness) |
| if hasattr(first_layer.RangeShifterSettingsSequence[0], 'RangeShifterSetting'): |
| RangeShifterSetting = first_layer.RangeShifterSettingsSequence[0].RangeShifterSetting |
| if hasattr(first_layer.RangeShifterSettingsSequence[0], 'ReferencedRangeShifterNumber'): |
| ReferencedRangeShifterNumber = int(first_layer.RangeShifterSettingsSequence[0].ReferencedRangeShifterNumber) |
| |
| CumulativeMeterset = 0 |
| |
| for dcm_layer in dcm_beam.IonControlPointSequence: |
| if dcm_layer.NumberOfScanSpotPositions == 1: sum_weights = dcm_layer.ScanSpotMetersetWeights |
| else: sum_weights = sum(dcm_layer.ScanSpotMetersetWeights) |
| |
| if sum_weights == 0.0: |
| continue |
| |
| layer = Plan_IonLayer() |
| layer.SeriesInstanceUID = self.SeriesInstanceUID |
| |
| if hasattr(dcm_layer, 'SnoutPosition'): |
| SnoutPosition = float(dcm_layer.SnoutPosition) |
| |
| if hasattr(dcm_layer, 'NumberOfPaintings'): layer.NumberOfPaintings = int(dcm_layer.NumberOfPaintings) |
| else: layer.NumberOfPaintings = 1 |
| |
| layer.NominalBeamEnergy = float(dcm_layer.NominalBeamEnergy) |
| layer.ScanSpotPositionMap_x = dcm_layer.ScanSpotPositionMap[0::2] |
| layer.ScanSpotPositionMap_y = dcm_layer.ScanSpotPositionMap[1::2] |
| layer.ScanSpotMetersetWeights = dcm_layer.ScanSpotMetersetWeights |
| layer.SpotMU = np.array(dcm_layer.ScanSpotMetersetWeights) * beam.BeamMeterset / beam.FinalCumulativeMetersetWeight |
| if layer.SpotMU.size == 1: layer.SpotMU = [layer.SpotMU] |
| else: layer.SpotMU = layer.SpotMU.tolist() |
| |
| self.NumberOfSpots += len(layer.SpotMU) |
| CumulativeMeterset += sum(layer.SpotMU) |
| layer.CumulativeMeterset = CumulativeMeterset |
| |
| if beam.RangeShifterType != "none": |
| if hasattr(dcm_layer, 'RangeShifterSettingsSequence'): |
| RangeShifterSetting = dcm_layer.RangeShifterSettingsSequence[0].RangeShifterSetting |
| ReferencedRangeShifterNumber = dcm_layer.RangeShifterSettingsSequence[0].ReferencedRangeShifterNumber |
| if hasattr(dcm_layer.RangeShifterSettingsSequence[0], 'IsocenterToRangeShifterDistance'): |
| IsocenterToRangeShifterDistance = dcm_layer.RangeShifterSettingsSequence[0].IsocenterToRangeShifterDistance |
| if hasattr(dcm_layer.RangeShifterSettingsSequence[0], 'RangeShifterWaterEquivalentThickness'): |
| RangeShifterWaterEquivalentThickness = dcm_layer.RangeShifterSettingsSequence[0].RangeShifterWaterEquivalentThickness |
| |
| layer.RangeShifterSetting = RangeShifterSetting |
| layer.IsocenterToRangeShifterDistance = IsocenterToRangeShifterDistance |
| layer.RangeShifterWaterEquivalentThickness = RangeShifterWaterEquivalentThickness |
| layer.ReferencedRangeShifterNumber = ReferencedRangeShifterNumber |
| |
| |
| beam.Layers.append(layer) |
| |
| self.Beams.append(beam) |
| |
| self.isLoaded = 1 |
|
|
| def export_Dicom_with_new_UID(self, OutputFile): |
| |
| initial_uid = self.OriginalDicomDataset.SOPInstanceUID |
| new_uid = pydicom.uid.generate_uid() |
| self.OriginalDicomDataset.SOPInstanceUID = new_uid |
|
|
| |
| print("Export dicom RTPLAN: " + OutputFile) |
| self.OriginalDicomDataset.save_as(OutputFile) |
|
|
| |
| self.OriginalDicomDataset.SOPInstanceUID = initial_uid |
|
|
| return new_uid |
|
|
|
|
|
|
| def save(self, file_path): |
| beamlets = self.beamlets |
| self.beamlets = [] |
|
|
| with open(file_path, 'wb') as fid: |
| pickle.dump(self.__dict__, fid) |
|
|
| self.beamlets = beamlets |
|
|
|
|
|
|
| def load(self, file_path): |
| with open(file_path, 'rb') as fid: |
| tmp = pickle.load(fid) |
|
|
| self.__dict__.update(tmp) |
|
|
| def compute_cartesian_coordinates(self, CT, Scanner, beams, RangeShifters=[]): |
| time_start = time.time() |
| |
| SPR = SPRimage() |
| SPR.convert_CT_to_SPR(CT, Scanner) |
| |
| CTborders_x = [SPR.ImagePositionPatient[0], SPR.ImagePositionPatient[0] + SPR.GridSize[0] * SPR.PixelSpacing[0]] |
| CTborders_y = [SPR.ImagePositionPatient[1], SPR.ImagePositionPatient[1] + SPR.GridSize[1] * SPR.PixelSpacing[1]] |
| CTborders_z = [SPR.ImagePositionPatient[2], SPR.ImagePositionPatient[2] + SPR.GridSize[2] * SPR.PixelSpacing[2]] |
| |
| spot_positions = [] |
| spot_directions = [] |
| spot_ranges = [] |
|
|
| |
| for beam in self.Beams: |
| |
|
|
| RangeShifter = -1 |
| if beam.RangeShifterType == "binary": |
| RangeShifter = next((RS for RS in RangeShifters if RS.ID == beam.RangeShifterID), -1) |
|
|
| for layer in beam.Layers: |
|
|
| range_in_water = SPR.energyToRange(layer.NominalBeamEnergy)*10 |
| if(layer.RangeShifterSetting == 'IN'): |
| if(layer.RangeShifterWaterEquivalentThickness != ""): RangeShifter_WET = layer.RangeShifterWaterEquivalentThickness |
| elif(RangeShifter != -1): RangeShifter_WET = RangeShifter.WET |
| else: RangeShifter_WET = 0.0 |
|
|
| if(RangeShifter_WET > 0.0): range_in_water -= RangeShifter_WET |
|
|
| for s in range(len(layer.ScanSpotPositionMap_x)): |
| |
| |
| x,y,z = layer.ScanSpotPositionMap_x[s], 0, layer.ScanSpotPositionMap_y[s] |
| u,v,w = 1e-10, 1.0, 1e-10 |
| |
| |
| angle = math.radians(beam.GantryAngle) |
| [x,y,z] = self.Rotate_vector([x,y,z], angle, 'z') |
| [u,v,w] = self.Rotate_vector([u,v,w], angle, 'z') |
| |
| |
| angle = math.radians(beam.PatientSupportAngle) |
| [x,y,z] = self.Rotate_vector([x,y,z], angle, 'y') |
| [u,v,w] = self.Rotate_vector([u,v,w], angle, 'y') |
| |
| |
| x = x + beam.IsocenterPosition[0] |
| y = y + beam.IsocenterPosition[1] |
| z = z + beam.IsocenterPosition[2] |
| |
| |
| Translation = np.array([1.0, 1.0, 1.0]) |
| Translation[0] = (x - CTborders_x[int(u<0)]) / u |
| Translation[1] = (y - CTborders_y[int(v<0)]) / v |
| Translation[2] = (z - CTborders_z[int(w<0)]) / w |
| Translation = Translation[np.argmin(np.absolute(Translation))] |
| x = x - Translation * u |
| y = y - Translation * v |
| z = z - Translation * w |
|
|
| |
| spot_positions.append([x,y,z]) |
| spot_directions.append([u,v,w]) |
| spot_ranges.append(range_in_water) |
|
|
|
|
| CartesianSpotPositions = compute_position_from_range(SPR, spot_positions, spot_directions, spot_ranges) |
| |
| print("Spot RayTracing: " + str(time.time()-time_start) + " sec") |
| return CartesianSpotPositions |
|
|
| |
| def compute_spot_maps(self, CT, plan, Struct, RangeShifters): |
| |
| |
| for ROI in Struct.Contours: |
| if ROI.ROIName == 'BODY': |
| BODY = ROI |
| break |
|
|
| |
| SpotMapBinary = np.full((CT.GridSize[0], CT.GridSize[1], CT.GridSize[2], len(plan.Beams)), False) |
| SpotMapWeights = np.zeros((CT.GridSize[0], CT.GridSize[1], CT.GridSize[2], len(plan.Beams))) |
|
|
| |
| |
| CartesianCoordinates = plan.compute_cartesian_coordinates(CT, 'UCL_Toshiba', plan.Beams, RangeShifters) |
|
|
| |
| SpotsID = np.zeros((plan.NumberOfSpots, 4),dtype=int) |
| SpotNumber = 0 |
| SpotsOutsideBody = 0 |
| |
| |
|
|
| print('ImagePositionPatient',CT.ImagePositionPatient) |
| |
| print('\n') |
| counter = 0 |
| for beamNumber, beam in enumerate(plan.Beams): |
| print('nb of layers',len(beam.Layers)) |
| for layer in beam.Layers: |
| for weight in layer.SpotMU: |
| |
| |
| |
| |
| |
| |
| |
|
|
| x = int(CartesianCoordinates[SpotNumber][0]/CT.PixelSpacing[0]) |
| y = int(CartesianCoordinates[SpotNumber][1]/CT.PixelSpacing[1]) |
| z = int(CartesianCoordinates[SpotNumber][2]/CT.PixelSpacing[2]) |
| |
| SpotsID[SpotNumber,:] = [x,y,z,beamNumber] |
| |
| |
| |
| SpotNumber += 1 |
|
|
| if BODY.Mask[x,y,z]==False: |
| SpotsOutsideBody += 1 |
| print('This spot is outside the body',[x,y,z]) |
| continue |
| |
| |
| |
| |
| |
| SpotMapBinary[x,y,z,beamNumber] = True |
| SpotMapWeights[x,y,z,beamNumber] = weight |
| counter+=1 |
| |
| |
| plan.SpotMapWeights = SpotMapWeights |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| def Rotate_vector(self, vec, angle, axis): |
| if axis == 'x': |
| x = vec[0] |
| y = vec[1] * math.cos(angle) - vec[2] * math.sin(angle) |
| z = vec[1] * math.sin(angle) + vec[2] * math.cos(angle) |
| elif axis == 'y': |
| x = vec[0] * math.cos(angle) + vec[2] * math.sin(angle) |
| y = vec[1] |
| z = -vec[0] * math.sin(angle) + vec[2] * math.cos(angle) |
| elif axis == 'z': |
| x = vec[0] * math.cos(angle) - vec[1] * math.sin(angle) |
| y = vec[0] * math.sin(angle) + vec[1] * math.cos(angle) |
| z = vec[2] |
| |
| return [x,y,z] |
| |
| |
| class Plan_IonBeam: |
|
|
| def __init__(self): |
| self.SeriesInstanceUID = "" |
| self.BeamName = "" |
| self.IsocenterPosition = [0,0,0] |
| self.GantryAngle = 0.0 |
| self.PatientSupportAngle = 0.0 |
| self.FinalCumulativeMetersetWeight = 0.0 |
| self.BeamMeterset = 0.0 |
| self.RangeShifter = "none" |
| self.Layers = [] |
| |
| |
| |
| class Plan_IonLayer: |
|
|
| def __init__(self): |
| self.SeriesInstanceUID = "" |
| self.NumberOfPaintings = 1 |
| self.NominalBeamEnergy = 0.0 |
| self.ScanSpotPositionMap_x = [] |
| self.ScanSpotPositionMap_y = [] |
| self.ScanSpotMetersetWeights = [] |
| self.SpotMU = [] |
| self.CumulativeMeterset = 0.0 |
| self.RangeShifterSetting = 'OUT' |
| self.IsocenterToRangeShifterDistance = 0.0 |
| self.RangeShifterWaterEquivalentThickness = 0.0 |
| self.ReferencedRangeShifterNumber = 0 |
| |
| |
| |
|
|