-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathExtend5CmScript.py
More file actions
284 lines (235 loc) · 13.1 KB
/
Extend5CmScript.py
File metadata and controls
284 lines (235 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
import clr
clr.AddReference("System.Windows.Forms")
from math import ceil
from os import listdir, makedirs, write
from os.path import isdir
from shutil import copy2, rmtree
import sys
sys.path.append(r"\\vs20filesvr01\groups\CANCER\Physics\Scripts\RayStation")
from connect import *
from datetime import datetime
from pydicom import dcmread
from System.Windows.Forms import *
case = None
def compute_new_ids(exam):
# Helper function that creates a unique DICOM StudyInstanceUID and SeriesInstanceUID for an examination
# Return a 2-tuple of (new StudyInstanceUID, new SeriesInstanceUID)
dcm = exam.GetAcquisitionDataFromDicom()
new_ids = []
for id_type in ["Study", "Series"]:
module_key = "{}Module".format(id_type)
id_key = "{}InstanceUID".format(id_type)
all_ids = [e.GetAcquisitionDataFromDicom()[module_key][id_key] for e in case.Examinations]
new_id = dcm[module_key][id_key]
while new_id in all_ids:
dot_idx = new_id.rfind(".")
new_id = "{}.{}".format(new_id[:dot_idx], int(new_id[(dot_idx + 1):]) + 1)
new_ids.append(new_id)
return new_ids
def copy_dicom_files(export_path, exam, dist, sup=True):
# Helper function that copies the inferior or superior slice's DICOM file at `export_path` according to the expansion (`dist`) needed
# `export_path`: Absolute path to the exported DICOM files for this run of the script
# `exam`: The examination to be extended
# `dist`: The distance to extend (cm)
# `sup`: True if exam should be extended in superior direction, False for inferior
# Set SOP Instance UID (also use for filename), Slice Location, z-coordinate of Image Position (Patient), and Instance Number in the copied slice DICOM files
# Return the distance by which the exam was expanded
# Info for computing slice UIDs for new filenames
# In RS, slices are ordered superior to inferior, smallest ID to largest
if sup:
slice_id = exam.Series[0].ImageStack.ImportedDicomSliceUIDs[-1] # E.g., "1.2.840.113704.1.111.2528.1583439123.410"
else: # Inf
slice_id = list(exam.Series[0].ImageStack.ImportedDicomSliceUIDs)[0]
# Split slice ID into first part (used for all new slice IDs) and second part (incremented/decremented for each new slice ID)
dot_idx = slice_id.rfind(".")
part_1, part_2 = slice_id[:dot_idx], int(slice_id[(dot_idx + 1):])
# Get DICOM data for top (for sup) or bottom (for inf) slice
dcm_filepath = r"{}\CT{}.dcm".format(export_path, slice_id)
dcm = dcmread(dcm_filepath)
# Slice data
slice_thickness = dcm.SliceThickness
slice_loc = dcm.SliceLocation
num_copies = ceil((5 - dist) * 10 / slice_thickness) # Convert to mm
# `copy_instance_num` = InstanceNumber for next slice
if sup:
copy_instance_num = len(listdir(export_path)) # We will increment the largest slice ID (see above)
else:
copy_instance_num = num_copies + 1 # We will decrement the smallest slice ID (see above)
# Make `num_copies` copies of top slice
for _ in range(num_copies):
if sup:
part_2 += 1 # Last part of slice UID (filename) is 1 more than previous slice (filename)
copy_instance_num += 1
slice_loc += slice_thickness
else:
part_2 -= 1 # Last part of slice UID (filename) is 1 less than previous slice (filename)
copy_instance_num -= 1
slice_loc -= slice_thickness
# Copy DICOM file to appropriate new filename
instance_id = "{}.{}".format(part_1, part_2)
copy_dcm_filepath = r"{}\CT{}.dcm".format(export_path, instance_id)
copy2(dcm_filepath, copy_dcm_filepath)
# Change instance data in new slice
copy_dcm = dcmread(copy_dcm_filepath)
copy_dcm.SOPInstanceUID = instance_id
copy_dcm.SliceLocation = slice_loc
copy_dcm.ImagePositionPatient[2] = slice_loc
copy_dcm.InstanceNumber = copy_instance_num
# Overwrite copied DICOM file
copy_dcm.save_as(copy_dcm_filepath)
# Renumber the old instances if we added slices to the beginning
if not sup:
copy_instance_num = num_copies + 1
for f in listdir(export_path)[num_copies:]: # Ignore the copies, as they already have the correct instance number
f = r"{}\{}".format(export_path, f)
dcm = dcmread(f)
dcm.InstanceNumber = copy_instance_num
dcm.save_as(f)
copy_instance_num += 1
def get_tx_technique(bs):
# Helper function that returns a beam set's treatment technique
# If technique is not recognized, return None
# Modified from a function written by RaySearch support
if bs.Modality == "Photons":
if bs.PlanGenerationTechnique == "Imrt":
if bs.DeliveryTechnique == "SMLC":
return "SMLC"
if bs.DeliveryTechnique == "DynamicArc":
return "VMAT"
if bs.DeliveryTechnique == "DMLC":
return "DMLC"
if bs.PlanGenerationTechnique == "Conformal":
if bs.DeliveryTechnique == "SMLC":
return "SMLC" # Changed from "Conformal". Failing with forward plans.
if bs.DeliveryTechnique == "Arc":
return "Conformal Arc"
if bs.Modality == "Electrons":
if bs.PlanGenerationTechnique == "Conformal":
if bs.DeliveryTechnique == "SMLC":
return "ApplicatorAndCutout"
def name_item(item, l, max_len=sys.maxsize):
# Helper function that generates a unique name for `item` in list `l` (case insensitive)
# Limit name to `max_len` characters
# E.g., name_item("Isocenter Name A", ["Isocenter Name A", "Isocenter Na (1)", "Isocenter N (10)"]) -> "Isocenter Na (2)"
l_lower = [l_item.lower() for l_item in l]
copy_num = 0
old_item = item
while item.lower() in l_lower:
copy_num += 1
copy_num_str = " ({})".format(copy_num)
item = "{}{}".format(old_item[:(max_len - len(copy_num_str))].strip(), copy_num_str)
return item[:max_len]
def extend_5_cm():
"""Extend the current exam so that the target is at least 5 cm from the superior and inferior edges of the exam
If a beam set is loaded and its Rx is to a non-empty target, use that target. If not, use the first non-empty PTV, GTV, or CTV (checked in that order) found on the exam
Export exam, copy the top and/or bottom slice(s) so that target is far enough from the edges, and reimport
New exam name is old exam name plus " - Expanded" (possibly with a copy number - e.g., "SBRT Lung_R - Extended (2)")
Copy ROI and POI geometries from old exam to new exam
Do not copy any plans to new exam
"""
global case
# Get current objects
try:
case = get_current("Case")
except:
MessageBox.Show("There is no case loaded. Click OK to abort the script.", "No Case Loaded")
sys.exit(1)
try:
exam = get_current("Examination")
except:
MessageBox.Show("There are no exams in the current case. Click OK to abort the script.", "No Exams")
sys.exit(1)
patient_db = get_current("PatientDB")
patient = get_current("Patient")
struct_set = case.PatientModel.StructureSets[exam.Name]
# Find the target
target = None # Assume no targets on exam
try:
rx_struct = get_current("BeamSet").Prescription.PrimaryDosePrescription.OnStructure
if rx_struct.OrganData.OrganType == "Target" and struct_set.RoiGeometries[rx_struct.Name].HasContours():
target = struct_set.RoiGeometries[rx_struct.Name]
except:
# Find first PTV, GTV, or CTV (in that order) contoured on the exam
for target_type in ["PTV", "GTV", "CTV"]:
targets = [geom for geom in struct_set.RoiGeometries if geom.OfRoi.Type.upper() == target_type and geom.HasContours()]
if targets:
target = targets[0]
break
# If no targets contoured on exam, alert user and exit script with an error
if target is None:
MessageBox.Show("There are no target geometries on the current exam. Click OK to abort the script.", "No Target Geometries")
sys.exit(1)
# Exam bounds
img_bounds = exam.Series[0].ImageStack.GetBoundingBox()
img_inf, img_sup = img_bounds[0].z, img_bounds[1].z
# Target bounds
target_bounds = target.GetBoundingBox()
target_inf, target_sup = target_bounds[0].z, target_bounds[1].z
# Distance from target to inf and sup exam edges
inf_dist = abs(img_inf - target_inf)
sup_dist = abs(img_sup - target_sup)
if inf_dist < 5 or sup_dist < 5: # Target is < 5 cm from top or bottom of exam
# Create export directory
base_path = r"\\vs20filesvr01\groups\CANCER\Physics\Temp\Extend2CmScript"
export_path = r"{}\{}".format(base_path, datetime.now().strftime("%m-%d-%Y %H_%M_%S"))
makedirs(export_path)
# Export exam
# Note that we could also export the structure set,
# but there is no way to access a structure set's UID from RS,
# and exporting every structure set so we could get the UID from the DICOM would unnecessary slow down the script.
# Instead, after importing the new exam (later), we simply copy all ROI and POI geometries from the old exam to the new exam
patient.Save() # Error if you attempt to export when there are unsaved modifications
try:
case.ScriptableDicomExport(ExportFolderPath=export_path, Examinations=[exam.Name], IgnorePreConditionWarnings=False)
except:
case.ScriptableDicomExport(ExportFolderPath=export_path, Examinations=[exam.Name], IgnorePreConditionWarnings=True)
# Compute new study and series IDs so RS doesn't think the new exam is the same as the old
study_id, series_id = compute_new_ids(exam)
# Add slices to top, if necessary
if sup_dist < 5:
copy_dicom_files(export_path, exam, sup_dist)
# Add slices to top, if necessary
if inf_dist < 5:
copy_dicom_files(export_path, exam, inf_dist, False)
# Change study and series UIDs in all files so RS doesn't think the new exam is the same as the old
for f in listdir(export_path):
f = r"{}\{}".format(export_path, f) # Absolute path
dcm = dcmread(f)
dcm.StudyInstanceUID = study_id
dcm.SeriesInstanceUID = series_id
dcm.save_as(f)
# Import new exam
study = patient_db.QueryStudiesFromPath(Path=export_path, SearchCriterias={"PatientID": patient.PatientID})[0] # There is only one study in the directory
series = patient_db.QuerySeriesFromPath(Path=export_path, SearchCriterias=study) # Series belonging to the study
patient.ImportDataFromPath(Path=export_path, CaseName=case.CaseName, SeriesOrInstances=series)
# Select new exam
new_exam = [e for e in case.Examinations if e.Series[0].ImportedDicomUID == series_id][0]
new_exam.Name = name_item("{} - Extended".format(exam.Name), [e.Name for e in case.Examinations])
# Set new exam imaging system
if exam.EquipmentInfo.ImagingSystemReference:
new_exam.EquipmentInfo.SetImagingSystemReference(ImagingSystemName=exam.EquipmentInfo.ImagingSystemReference.ImagingSystemName)
# Add external geometry to new exam
ext = [roi for roi in case.PatientModel.RegionsOfInterest if roi.Type == "External"]
if ext:
ext = ext[0]
else:
ext_name = name_item("External", [roi.Name for roi in case.PatientModel.RegionsOfInterest], 16)
ext = case.PatientModel.CreateRoi(Name=ext_name, Color="255, 255, 255", Type="External")
ext.CreateExternalGeometry(Examination=new_exam)
# Copy ROI geometries from old exam to new exam
geom_names = [geom.OfRoi.Name for geom in case.PatientModel.StructureSets[exam.Name].RoiGeometries if geom.HasContours() and geom.OfRoi.Type != "External"]
if geom_names:
case.PatientModel.CopyRoiGeometries(SourceExamination=exam, TargetExaminationNames=[new_exam.Name], RoiNames=geom_names)
# Update derived geometries (this shouldn't change any geometries since the new exam is effectively the same as the old)
for geom in case.PatientModel.StructureSets[exam.Name].RoiGeometries:
roi = case.PatientModel.RegionsOfInterest[geom.OfRoi.Name]
if geom.OfRoi.DerivedRoiExpression and geom.PrimaryShape.DerivedRoiStatus and not geom.PrimaryShape.DerivedRoiStatus.IsShapeDirty:
roi.UpdateDerivedGeometry(Examination=new_exam)
# Copy POI geometries from old exam to new exam
for i, poi in enumerate(case.PatientModel.StructureSets[exam.Name].PoiGeometries):
if abs(poi.Point.x) < 1000: # Empty POI geometry if infinite coordinates
case.PatientModel.StructureSets[new_exam.Name].PoiGeometries[i].Point = poi.Point
# Delete the temporary directory and all its contents
rmtree(export_path)
else:
MessageBox.Show("The target ('{}') is at least 5 cm from the inferior and superior edges of the planning exam. No action is necessary.".format(target.OfRoi.Name), "Exam OK")