├── .gitignore ├── Executable ├── SplashScreen.PNG ├── UserInterface.py ├── dist │ └── Mangle.exe └── mangleUI.py ├── LICENSE ├── Mangle.7z ├── README.md ├── UserInterface.py ├── mangle.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | *.dcm 2 | *.spec 3 | __pycache__/ 4 | build/ 5 | -------------------------------------------------------------------------------- /Executable/SplashScreen.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-blackmore/rtp-mangle/4846084d7a36048ce1897e4e8e1cc19a3d37d721/Executable/SplashScreen.PNG -------------------------------------------------------------------------------- /Executable/UserInterface.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import wx 3 | import mangleUI 4 | import pydicom 5 | 6 | class PopUp(wx.Frame): 7 | def __init__(self, text): 8 | super().__init__(parent=None, title='', size = (600,100)) 9 | 10 | panel = wx.Panel(self) 11 | my_sizer = wx.BoxSizer(wx.VERTICAL) 12 | 13 | self.text = wx.StaticText(panel, label=text, style=wx.TE_MULTILINE | wx.TE_READONLY) 14 | 15 | my_sizer.Add(self.text, 1, flag = wx.ALL | wx.ALIGN_CENTER_HORIZONTAL, border = 20) 16 | 17 | panel.SetSizer(my_sizer) 18 | self.Show() 19 | 20 | class MyFrame(wx.Frame): 21 | def __init__(self): 22 | 23 | self.commandString = '' 24 | self.outFile = '' 25 | self.pathname = '' 26 | self.directory = '' 27 | self.addedInFile = False 28 | self.addedKeepUid = False 29 | self.addedVerbose = False 30 | self.dark_mode = False 31 | 32 | super().__init__(parent=None, title='Plan Mangler', size = (1500, 650)) 33 | 34 | panel = wx.Panel(self) 35 | 36 | my_sizer = wx.BoxSizer(wx.VERTICAL) 37 | 38 | initialOptions = wx.GridSizer(2,9,5,5) 39 | beamData = wx.GridSizer(9,9,5,5) 40 | helptext = wx.GridSizer(6,1,5,5,) 41 | perform = wx.FlexGridSizer(1,4,5,5) 42 | 43 | file_open = wx.Button(panel, label='Input File') 44 | file_open.Bind(wx.EVT_BUTTON, self.OnOpen) 45 | 46 | 47 | self.uid = wx.CheckBox(panel, label = 'Keep SOPInstanceUID' ) 48 | self.verbose = wx.CheckBox(panel, label = 'Verbose Console Output ?') 49 | 50 | 51 | # Filters 52 | self.beam = wx.ComboBox(panel) 53 | self.controlPointsFrom = wx.ComboBox(panel) 54 | self.controlPointsTo = wx.ComboBox(panel) 55 | 56 | 57 | # Checkbox to choose either jaw or Mlc to be modified. 58 | self.mlc_jaw = wx.ToggleButton(panel, label = 'Edit MLC') 59 | self.mlc_jaw.Bind(wx.EVT_TOGGLEBUTTON, self.on_check) 60 | 61 | self.jaw_label = wx.StaticText(panel , label ='Jaw :') 62 | self.jaw = wx.ComboBox(panel, choices=['0- X Jaws','1- Y Jaws','0,1- Both Jaws'], style=wx.BORDER_NONE) 63 | self.jaw.Bind(wx.EVT_COMBOBOX, self.OnJawChoice) 64 | self.jaw_bank_label = wx.StaticText(panel , label ='Jaw Bank:') 65 | self.jawBank = wx.ComboBox(panel, choices=['']) 66 | 67 | self.leaf_bank_label = wx.StaticText(panel , label ='Leaf Bank :') 68 | self.leafBank = wx.ComboBox(panel, choices=['0','1','0,1']) 69 | self.leaf_pair_label = wx.StaticText(panel , label ='Leaf Pair From:') 70 | self.leaf_pair_from_label = wx.StaticText(panel , label ='From :') 71 | self.leafPairFrom = wx.ComboBox(panel) 72 | self.leaf_pair_to_label = wx.StaticText(panel , label ='To :') 73 | self.leafPairTo = wx.ComboBox(panel) 74 | 75 | 76 | # Checkbox to choose either postion absolute or position relative for jaw or MlC modifications. 77 | self.jaw_relative = wx.CheckBox(panel, label = 'Relative Change ?' ) 78 | self.jaw_position = wx.TextCtrl(panel) 79 | 80 | self.leaf_relative = wx.CheckBox(panel, label = 'Relative Change ?' ) 81 | self.leaf_position = wx.TextCtrl(panel) 82 | 83 | self.outputFile = wx.TextCtrl(panel) 84 | 85 | self.commandString_view = wx.TextCtrl(panel, -1,'' ,style=wx.TE_MULTILINE) 86 | 87 | self.darkMode = wx.ToggleButton(panel, label='Dark Mode') 88 | self.darkMode.Bind(wx.EVT_TOGGLEBUTTON, self.onToggleDark) 89 | 90 | 91 | # Setters 92 | self.MU = wx.TextCtrl(panel) 93 | self.Machine = wx.TextCtrl(panel) 94 | self.Gantry = wx.TextCtrl(panel) 95 | self.Collimator = wx.TextCtrl(panel) 96 | 97 | perform_btn = wx.Button(panel, label='Perform') 98 | perform_btn.Bind(wx.EVT_BUTTON, self.perform) 99 | 100 | self.AddToCommand = wx.Button(panel, label = 'Add to Command String') 101 | self.AddToCommand.Bind(wx.EVT_BUTTON, self.on_press) 102 | 103 | initialOptions.AddMany([ 104 | 105 | (file_open, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(self.darkMode,0,wx.EXPAND,5), 106 | 107 | (self.uid, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')),(self.verbose, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')),(wx.StaticText(panel, label ='Output File Name:' ),0, wx.ALIGN_CENTER_VERTICAL,5),(self.outputFile,0,wx.EXPAND,5),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')), 108 | 109 | 110 | ]) 111 | 112 | beamData.AddMany([ 113 | 114 | (wx.StaticText(panel, label ='Beam :'),0, wx.ALIGN_CENTER_VERTICAL,5),(self.beam, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')), 115 | 116 | (wx.StaticText(panel , label ='Control Points From:'),0, wx.ALIGN_CENTER_VERTICAL,5),(self.controlPointsFrom, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='To:'),0, wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_CENTER_HORIZONTAL,5),(self.controlPointsTo, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label =''),0, wx.ALIGN_CENTER_VERTICAL| wx.ALIGN_CENTER_HORIZONTAL,5), 117 | 118 | (self.mlc_jaw, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')), 119 | 120 | (self.jaw_label,0, wx.ALIGN_CENTER_VERTICAL,5),(self.jaw, 0 , wx.EXPAND, 5),(self.jaw_bank_label,0, wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_CENTER_HORIZONTAL,5),(self.jawBank, 0 , wx.EXPAND, 5),(self.jaw_relative, 0 , wx.EXPAND | wx.ALIGN_CENTER_HORIZONTAL, 5), (self.jaw_position, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')), 121 | 122 | (self.leaf_bank_label,0, wx.ALIGN_CENTER_VERTICAL,5),(self.leafBank, 0 , wx.EXPAND, 5),(self.leaf_pair_label,0, wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_CENTER_HORIZONTAL,5),(self.leafPairFrom, 0 , wx.EXPAND, 5),(self.leaf_pair_to_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_CENTER_HORIZONTAL,5),(self.leafPairTo, 0, wx.EXPAND, 5),(self.leaf_relative, 0 , wx.EXPAND | wx.ALIGN_CENTER_HORIZONTAL, 5), (self.leaf_position, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')), 123 | 124 | (wx.StaticText(panel, label ='MU :' ),0, wx.ALIGN_CENTER_VERTICAL,5),(self.MU, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')), 125 | 126 | (wx.StaticText(panel, label ='Machine ID:' ),0, wx.ALIGN_CENTER_VERTICAL,5),(self.Machine, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')), 127 | 128 | (wx.StaticText(panel, label ='Gantry Angle:' ),0, wx.ALIGN_CENTER_VERTICAL,5),(self.Gantry, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')), 129 | 130 | (wx.StaticText(panel, label ='Collimator Angle :' ),0, wx.ALIGN_CENTER_VERTICAL,5),(self.Collimator, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')), 131 | 132 | 133 | ]) 134 | 135 | helptext.AddMany([ 136 | 137 | (wx.StaticLine(panel, 0, size =(1450,1), style=wx.LI_HORIZONTAL),0,wx.ALIGN_CENTER_VERTICAL,5), 138 | 139 | (wx.StaticText(panel , label ='• Not all parameters are required, only specify what requires modification.'),0,wx.ALIGN_CENTER_HORIZONTAL,5), 140 | 141 | (wx.StaticText(panel , label ='• SOPInstanceUID - Some devices require this to remain unchanged to allow analysis, others refuse to import files with duplicate UID.'),0,wx.ALIGN_CENTER_HORIZONTAL,5), 142 | 143 | (wx.StaticText(panel , label ='• Leaf bank defines the A or B side, experimentation is required to determine which is 0 and which is 1.'),0,wx.ALIGN_CENTER_HORIZONTAL,5), 144 | 145 | (wx.StaticText(panel , label ='• Absolute values or relative changes can be applied to Jaws, Leafs, MU, Gantry Angle, and Collimator Angle. Relative changes can be specified as either +/- values (+10 or -5 for example) or percent values (+10% or -5% for example).'),0,wx.ALIGN_CENTER_HORIZONTAL,5), 146 | 147 | (wx.StaticLine(panel, 0, size =(1450,1), style=wx.LI_HORIZONTAL),0,wx.ALIGN_CENTER_VERTICAL,5), 148 | ]) 149 | 150 | 151 | perform.AddMany([ 152 | 153 | (self.AddToCommand,0,wx.EXPAND,5),(wx.StaticText(panel , label ='Current Command String :'),0,wx.ALIGN_CENTER_VERTICAL,5),(self.commandString_view, 0, wx.EXPAND, 5),(perform_btn, 0, wx.EXPAND, 5) 154 | 155 | ]) 156 | 157 | perform.AddGrowableCol(2) 158 | perform.AddGrowableRow(0) 159 | 160 | my_sizer.Add(initialOptions, 1, flag = wx.ALL | wx.EXPAND, border = 15) 161 | my_sizer.Add(beamData, 1, flag = wx.ALL | wx.EXPAND, border = 15) 162 | my_sizer.Add(helptext, 1, flag = wx.ALL | wx.EXPAND, border = 15) 163 | my_sizer.Add(perform, 1, flag = wx.ALL | wx.EXPAND, border = 15) 164 | 165 | panel.SetSizer(my_sizer) 166 | 167 | self.Show() 168 | 169 | self.jaw_label.Show() 170 | self.jaw.Show() 171 | self.jaw_bank_label.Show() 172 | self.jawBank.Show() 173 | self.jaw_relative.Show() 174 | self.jaw_position.Show() 175 | 176 | self.leaf_bank_label.Hide() 177 | self.leafBank.Hide() 178 | self.leaf_pair_label.Hide() 179 | self.leafPairFrom.Hide() 180 | self.leaf_pair_from_label.Hide() 181 | self.leafPairTo.Hide() 182 | self.leaf_pair_to_label.Hide() 183 | self.leaf_relative.Hide() 184 | self.leaf_position.Hide() 185 | 186 | 187 | 188 | def on_check(self, event): 189 | if self.mlc_jaw.GetValue(): 190 | self.jaw_label.Hide() 191 | self.jaw.Hide() 192 | self.jaw_bank_label.Hide() 193 | self.jawBank.Hide() 194 | self.jaw_relative.Hide() 195 | self.jaw_position.Hide() 196 | 197 | self.leaf_bank_label.Show() 198 | self.leafBank.Show() 199 | self.leaf_pair_label.Show() 200 | self.leafPairFrom.Show() 201 | self.leaf_pair_from_label.Show() 202 | self.leafPairTo.Show() 203 | self.leaf_pair_to_label.Show() 204 | self.leaf_relative.Show() 205 | self.leaf_position.Show() 206 | 207 | self.mlc_jaw.SetLabel('Edit Jaws') 208 | 209 | else: 210 | self.jaw_label.Show() 211 | self.jaw.Show() 212 | self.jaw_bank_label.Show() 213 | self.jawBank.Show() 214 | self.jaw_relative.Show() 215 | self.jaw_position.Show() 216 | 217 | self.leaf_bank_label.Hide() 218 | self.leafBank.Hide() 219 | self.leaf_pair_label.Hide() 220 | self.leafPairFrom.Hide() 221 | self.leaf_pair_from_label.Hide() 222 | self.leafPairTo.Hide() 223 | self.leaf_pair_to_label.Hide() 224 | self.leaf_relative.Hide() 225 | self.leaf_position.Hide() 226 | 227 | self.mlc_jaw.SetLabel('Edit MLC') 228 | 229 | 230 | 231 | 232 | def on_press(self, event): 233 | #script_path = mangle.__path__ 234 | 235 | # In File 236 | inFile = self.pathname 237 | if not self.addedInFile: 238 | self.addedInFile = True 239 | 240 | # Command string 241 | #Filters 242 | beams = '' 243 | if self.beam.GetValue(): 244 | beams = 'b' + self.beam.GetValue().split('-')[0] 245 | controlPoints = '' 246 | if self.controlPointsFrom.GetValue(): 247 | if self.controlPointsTo.GetValue(): 248 | controlPoints = 'cp' + self.controlPointsFrom.GetValue() + '-' + self.controlPointsTo.GetValue() 249 | else: 250 | controlPoints = 'cp' + self.controlPointsFrom.GetValue() 251 | 252 | jaw_or_mlc_command = '' 253 | if self.mlc_jaw.GetValue(): 254 | if self.leafBank.GetValue(): 255 | leafbank = 'lb' + self.leafBank.GetValue() 256 | leafpair = '' 257 | if self.leafPairFrom.GetValue(): 258 | if self.leafPairTo.GetValue(): 259 | leafpair = 'lp' + self.leafPairFrom.GetValue() + '-' + self.leafPairTo.GetValue() 260 | else: 261 | leafpair = 'lp' + self.leafPairFrom.GetValue() 262 | position = '' 263 | if self.leaf_relative.GetValue(): 264 | postion = 'pr=' + self.leaf_position.GetValue() 265 | else: 266 | position = 'pa=' + self.leaf_position.GetValue() 267 | 268 | jaw_or_mlc_command = leafbank + ' ' + leafpair + ' ' + position 269 | else: 270 | if self.jaw.GetValue(): 271 | jaw = 'j' + self.jaw.GetValue().split('-')[0] 272 | jawBank = 'jb' + self.jawBank.GetValue().split('-')[0] 273 | position = '' 274 | if self.jaw_relative.GetValue(): 275 | position = 'pr=' + self.jaw_position.GetValue() 276 | else: 277 | position = 'pa=' + self.jaw_position.GetValue() 278 | jaw_or_mlc_command = jaw + ' ' + jawBank + ' ' + position 279 | 280 | 281 | mu = '' 282 | if self.MU.GetValue(): 283 | mu = 'mu=' + self.MU.GetValue() 284 | machine ='' 285 | if self.Machine.GetValue(): 286 | machine = 'm=' + self.Machine.GetValue() 287 | gantry = '' 288 | if self.Gantry.GetValue(): 289 | gantry = 'g=' + self.Gantry.GetValue() 290 | collimator = '' 291 | if self.Collimator.GetValue(): 292 | collimator = 'c=' + self.Collimator.GetValue() 293 | 294 | 295 | #Options 296 | 297 | if self.uid.GetValue(): 298 | if not self.addedKeepUid: 299 | self.addedKeepUid = True 300 | else: 301 | self.addedKeepUid = False 302 | 303 | if self.verbose.GetValue(): 304 | if not self.addedVerbose: 305 | self.addedVerbose = True 306 | else: 307 | self.addedVerbose = False 308 | 309 | if self.outputFile.GetValue(): 310 | self.outFile = self.directory + "\\" + self.outputFile.GetValue() + ".dcm" 311 | else: 312 | self.outFile = self.directory + "\\outFile.dcm" 313 | 314 | 315 | command = beams + ' ' + controlPoints + ' ' + jaw_or_mlc_command + ' ' + mu + ' ' + machine + ' ' + gantry + ' ' + collimator 316 | command = ' '.join(command.split()) 317 | 318 | self.commandString = self.commandString_view.GetValue() 319 | 320 | self.commandString = self.commandString + '"' + command + '"' 321 | self.commandString = self.commandString.replace(' ""', '') 322 | 323 | 324 | self.commandString_view.SetValue(self.commandString) 325 | 326 | self.beam.SetValue('') 327 | self.controlPointsFrom.SetValue('') 328 | self.controlPointsTo.SetValue('') 329 | # Checkbox to choose either jaw or Mlc to be modified. 330 | self.mlc_jaw.SetValue(wx.CHK_UNCHECKED) 331 | self.jaw.SetValue('') 332 | self.jawBank.SetValue('') 333 | self.leafBank.SetValue('') 334 | self.leafPairFrom.SetValue('') 335 | self.leafPairTo.SetValue('') 336 | # Checkbox to choose either postion absolute or position relative for jaw or MlC modifications. 337 | self.jaw_relative.SetValue(wx.CHK_UNCHECKED) 338 | self.jaw_position.SetValue('') 339 | self.leaf_relative.SetValue(wx.CHK_UNCHECKED) 340 | self.leaf_position.SetValue('') 341 | self.MU.SetValue('') 342 | self.Machine.SetValue('') 343 | self.Gantry.SetValue('') 344 | self.Collimator.SetValue('') 345 | 346 | 347 | def OnOpen(self, event): 348 | self.commandString = '' 349 | self.commandString_view.SetValue(self.commandString) 350 | with wx.FileDialog(self, "Open Dicom File", wildcard="Dicom (*.dcm)|*.dcm", 351 | style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) as fileDialog: 352 | 353 | if fileDialog.ShowModal() == wx.ID_CANCEL: 354 | return # the user changed their mind 355 | 356 | # Proceed loading the file chosen by the user 357 | self.pathname = fileDialog.GetPath() 358 | self.directory = fileDialog.GetDirectory() 359 | 360 | dicom = pydicom.dcmread(self.pathname) 361 | beams = dicom.BeamSequence 362 | allBeams = '' 363 | maxPairs = 0 364 | maxControlPoints = 0 365 | i = 0 366 | self.beam.Clear() 367 | for beam in beams: 368 | self.beam.Append(str(i) +'- ' + beam.BeamName) 369 | blds = beam.BeamLimitingDeviceSequence 370 | for bld in blds: 371 | if bld.RTBeamLimitingDeviceType == "MLCX" or bld.RTBeamLimitingDeviceType == "MLCY": 372 | if int(bld.NumberOfLeafJawPairs) > maxPairs: 373 | maxPairs = bld.NumberOfLeafJawPairs 374 | if int(beam.NumberOfControlPoints) > maxControlPoints: 375 | maxControlPoints = beam.NumberOfControlPoints 376 | allBeams = allBeams + str(i) + ',' 377 | i += 1 378 | self.beam.Append(allBeams + '- ' + 'All Beams') 379 | listPairs = [str(x) for x in range(0, maxPairs)] 380 | self.leafPairFrom.Clear() 381 | self.leafPairFrom.Append(listPairs) 382 | self.leafPairTo.Clear() 383 | self.leafPairTo.Append(listPairs) 384 | 385 | listControlPoints = [str(x) for x in range(0, maxControlPoints)] 386 | self.controlPointsFrom.Clear() 387 | self.controlPointsFrom.Append(listControlPoints) 388 | self.controlPointsTo.Clear() 389 | self.controlPointsTo.Append(listControlPoints) 390 | 391 | 392 | 393 | 394 | 395 | def onToggleDark(self, event): 396 | darkMode(self, self.dark_mode) 397 | if not self.dark_mode: 398 | self.dark_mode = True 399 | else: 400 | self.dark_mode = False 401 | 402 | def perform(self, event): 403 | if not self.commandString_view.GetValue(): 404 | frame = PopUp('Command String is Blank, Please Add To Command String') 405 | else: 406 | commandStrings = [item.replace('"', '') for item in self.commandString_view.GetValue().split('" "')] 407 | mangleUI.mangle(self.pathname, self.outFile, self.addedKeepUid, self.addedVerbose, commandStrings) 408 | frame = PopUp('Output File Created At: ' + self.outFile) 409 | 410 | def OnJawChoice(self, event): 411 | if self.jaw.GetValue().split('-')[0] == '0': 412 | self.jawBank.Clear() 413 | self.jawBank.Append(['0- X1 Jaw', '1- X2 Jaw', '0,1- Both Jaws']) 414 | elif self.jaw.GetValue().split('-')[0] == '1': 415 | self.jawBank.Clear() 416 | self.jawBank.Append(['0- Y1 Jaw', '1- Y2 Jaw', '0,1- Both Jaws']) 417 | else: 418 | self.jawBank.Clear() 419 | self.jawBank.Append(['0- X1 & Y1 Jaws', '1- X2 & Y2 Jaws', '0,1- All Jaws']) 420 | 421 | def getWidgets(parent): 422 | items = [parent] 423 | for item in parent.GetChildren(): 424 | items.append(item) 425 | if hasattr(item, "GetChildren"): 426 | for child in item.GetChildren(): 427 | items.append(child) 428 | return items 429 | 430 | def darkMode(self, darkmode): 431 | dark_grey = "#212121" 432 | widgets = getWidgets(self) 433 | for widget in widgets: 434 | if not darkmode: 435 | widget.SetBackgroundColour(dark_grey) 436 | widget.SetForegroundColour("White") 437 | else: 438 | widget.SetBackgroundColour(wx.NullColour) 439 | widget.SetForegroundColour("Black") 440 | self.Refresh() 441 | return True 442 | 443 | 444 | if __name__ == '__main__': 445 | app = wx.App() 446 | frame = MyFrame() 447 | if '_PYIBoot_SPLASH' in os.environ and importlib.util.find_spec("pyi_splash"): 448 | import pyi_splash 449 | pyi_splash.update_text('UI Loaded ...') 450 | pyi_splash.close() 451 | 452 | app.MainLoop() -------------------------------------------------------------------------------- /Executable/dist/Mangle.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-blackmore/rtp-mangle/4846084d7a36048ce1897e4e8e1cc19a3d37d721/Executable/dist/Mangle.exe -------------------------------------------------------------------------------- /Executable/mangleUI.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """mangle.py: Modifies a DICOM-RT Plan File to create intentional delivery errors.""" 4 | 5 | # Imports 6 | import shlex 7 | import re 8 | import pydicom 9 | from pydicom.uid import generate_uid 10 | 11 | 12 | def mangle(inFile, outFile, keep_uid, verbose, commandString): 13 | 14 | # Open DICOM File in pydicom and retrieve a dataset: 15 | ds = pydicom.dcmread(inFile) 16 | 17 | # Unless Instructed, change the file's UID to prevent duplicates. 18 | if not keep_uid: 19 | ds.SOPInstanceUID = generate_uid() 20 | 21 | # Parse Command Strings 22 | if verbose: 23 | print("Found " + str(len(commandString)) + " command string(s)." ) 24 | for cmdStr in commandString: 25 | if verbose: 26 | print("\nProcessing Command String: " + cmdStr + "\n" ) 27 | 28 | # Prevent Simultaneous Jaw and MLC editing. 29 | if "lp" in cmdStr or "lb" in cmdStr: 30 | if "jb" in cmdStr or "j" in cmdStr: 31 | print("ERROR: Cannot Edit Leaf and Jaw positions in the same command.\n") 32 | raise ValueError 33 | 34 | # Prevent Simultaneous Relative and Absoulte edits. 35 | if "pr=" in cmdStr and "pa=" in cmdStr: 36 | print("ERROR: Cannot Edit Relative and Absolute positions in the same command.\n") 37 | raise ValueError 38 | cmds = shlex.split(cmdStr) 39 | 40 | filters = [] 41 | beams = [] 42 | cps = [] 43 | pairs = [] 44 | 45 | filters.append({"name": "beam", "key": "b", "default": "*", "matches": ""}) 46 | filters.append({"name": "control pt", "key": "cp", "default": "*", "matches": ""}) 47 | filters.append({"name": "jaw", "key": "j", "default": "*", "matches": ""}) 48 | filters.append({"name": "jaw bank", "key": "jb", "default": "*", "matches": ""}) 49 | filters.append({"name": "leaf pair", "key": "lp", "default": "*", "matches": ""}) 50 | filters.append({"name": "leaf bank", "key": "lb", "default": "*", "matches": ""}) 51 | 52 | for f in filters: 53 | 54 | r = re.compile("(^" + f["key"] + "\d+)") 55 | reArgs = list(filter(r.match, cmds)) 56 | 57 | if len(reArgs) > 1: 58 | # More than one fstring. 59 | print("ERROR: More than one " + f["name"] + " filter found.\n") 60 | raise ValueError 61 | elif len(reArgs) < 1: 62 | reArgs = [f['key'] + f["default"]] 63 | 64 | # Remove the key from the command 65 | reArgs = reArgs[0][len(f["key"]):] 66 | 67 | if verbose and reArgs != f["default"]: 68 | print("Found " + f["name"] + " filter - Value: " + reArgs) 69 | 70 | # Handle Multiple Specified Values 71 | reArgs = reArgs.split(",") 72 | 73 | # Handle Range of Values 74 | for i in reArgs: 75 | if "-" in i: 76 | reArgs.remove(i) 77 | for j in range(int(i.split("-")[0]), int(i.split("-")[1])+1): 78 | reArgs.append(str(j)) 79 | 80 | f["matches"] = reArgs 81 | 82 | # Gather the data to act upon 83 | if f["name"] == "beam": # Build the Beam list. 84 | if f["matches"][0] == "*": 85 | beams = ds.BeamSequence 86 | else: 87 | for i in f["matches"]: 88 | try: 89 | beams.append(ds.BeamSequence[int(i)]) 90 | except IndexError: 91 | print("WARNING: Beam Index Out of Plan Range - Ignoring beam " + i + ".\n") 92 | elif f["name"] == "control pt": # Build the CP list. 93 | if f["matches"][0] == "*": 94 | for beam in beams: 95 | for cp in beam.ControlPointSequence: 96 | cps.append(cp) 97 | else: 98 | for beam in beams: 99 | for i in f["matches"]: 100 | try: 101 | cps.append(beam.ControlPointSequence[int(i)]) 102 | except IndexError: 103 | print("WARNING: Control Point Index Out of Beam Range - Ignoring CP " + i + ".\n") 104 | elif f["name"] == "jaw" or f["name"] == "jaw bank" or f["name"] == "leaf bank": 105 | if f["matches"][0] == "*": 106 | f["matches"] = list(range(0, 2)) 107 | 108 | elif f["name"] == "leaf pair": 109 | maxPairs = 0 110 | for bld in beams[0].BeamLimitingDeviceSequence: 111 | if bld.RTBeamLimitingDeviceType == "MLCX" or bld.RTBeamLimitingDeviceType == "MLCY": 112 | if int(bld.NumberOfLeafJawPairs) > maxPairs: 113 | maxPairs = bld.NumberOfLeafJawPairs 114 | if f["matches"][0] == "*": 115 | f["matches"] = list(range(0, maxPairs)) 116 | 117 | 118 | """ 119 | Perform the edits 120 | 121 | Now we have gathered the items to be edited, we can start looking at the setters. 122 | 123 | """ 124 | 125 | # Perform Edits 126 | setters = [] 127 | setters.append({"name": "MU", "type": "int", "key": "mu="}) 128 | setters.append({"name": "Machine", "type": "str", "key": "m="}) 129 | setters.append({"name": "Gantry", "type": "int", "key": "g=", "attr": "GantryAngle"}) 130 | setters.append({"name": "Collimator", "type": "int", "key": "c=", "attr": "BeamLimitingDeviceAngle"}) 131 | setters.append({"name": "Position Absolute", "type": "int", "key": "pa=", "attr": "BeamLimitingDevicePositionSequence"}) 132 | setters.append({"name": "Position Relative", "type": "int", "key": "pr=", "attr": "BeamLimitingDevicePositionSequence"}) 133 | 134 | for s in setters: 135 | 136 | if s["type"] == "int": 137 | r = re.compile("(" + s["key"] + "[+-]?\d+%?)") 138 | else: 139 | r = re.compile("(" + s["key"] + "[\']?[a-zA-Z0-9\ ]+[\']?)") 140 | 141 | reArgs = list(filter(r.match, cmds)) 142 | 143 | if len(reArgs) > 1: 144 | # More than one fstring. 145 | print("ERROR: More than one " + s["name"] + " set command found.\n") 146 | raise ValueError 147 | elif len(reArgs) != 1: 148 | continue 149 | 150 | # Remove the key from the command 151 | reArg = reArgs[0][len(s["key"]):] 152 | 153 | if verbose: 154 | print("Found " + s["name"] + " setter - Value: " + reArg) 155 | 156 | if s["name"] == "MU": 157 | for beam in beams: 158 | cmdArg = reArg 159 | meterset = ds.FractionGroupSequence[0].ReferencedBeamSequence[beam.BeamNumber - 1].BeamMeterset 160 | if cmdArg[0] == "+": 161 | cmdArg = cmdArg[1:] 162 | if cmdArg[-1] == "%": 163 | cmdArg = cmdArg[:-1] 164 | ds.FractionGroupSequence[0].ReferencedBeamSequence[beam.BeamNumber - 1].BeamMeterset = meterset * (1+(float(cmdArg)/100)) 165 | else: 166 | ds.FractionGroupSequence[0].ReferencedBeamSequence[beam.BeamNumber - 1].BeamMeterset = meterset + float(cmdArg) 167 | elif cmdArg[0] == "-": 168 | cmdArg = cmdArg[1:] 169 | if cmdArg[-1] == "%": 170 | cmdArg = cmdArg[:-1] 171 | ds.FractionGroupSequence[0].ReferencedBeamSequence[beam.BeamNumber - 1].BeamMeterset = meterset * (1-(float(cmdArg)/100)) 172 | else: 173 | ds.FractionGroupSequence[0].ReferencedBeamSequence[beam.BeamNumber - 1].BeamMeterset = meterset - float(cmdArg) 174 | else: 175 | meterset = cmdArg 176 | elif s["name"] == "Machine": 177 | for beam in beams: 178 | cmdArg = reArg 179 | beam.TreatmentMachineName = cmdArg 180 | else: 181 | for cp in cps: 182 | cmdArg = reArg 183 | if hasattr(cp, s["attr"]): 184 | # Handle Jaw/MLC Changes 185 | if s['name'] == "Position Absolute" or s['name'] == "Position Relative": 186 | if "lp" in cmdStr or "lb" in cmdStr: 187 | # MLCs 188 | 189 | # Collect the list of Leaf Banks and Leaf Pairs that must be edited. 190 | lb = [lb["matches"] for lb in filters if lb['name'] == 'leaf bank'][0] 191 | lp = [lp["matches"] for lp in filters if lp['name'] == 'leaf pair'][0] 192 | lb = [int(i) for i in lb] 193 | lp = [int(i) for i in lp] 194 | 195 | # Cycle Through the BLD Sequences in the control point. Look for "MLCX" or "MLCY" - Note that futuristic machines with both MLCX and MLCY won't work! 196 | for bld in cp.BeamLimitingDevicePositionSequence: 197 | if bld.RTBeamLimitingDeviceType == "MLCX" or bld.RTBeamLimitingDeviceType == "MLCY": 198 | # Split the Banks up - DICOM stores the MLCs in one long list. 199 | banks = [] 200 | banks.append(bld.LeafJawPositions[:maxPairs]) 201 | banks.append(bld.LeafJawPositions[maxPairs:]) 202 | 203 | # For each bank 204 | for bank in lb: 205 | bank = int(bank) 206 | if s['name'] == "Position Absolute": 207 | for pair in lp: 208 | # Absolute Position Specified. Set each of the pairs to modify in this bank to that value. 209 | banks[bank][pair] = cmdArg 210 | elif s['name'] == "Position Relative": 211 | if cmdArg[0] == "-": 212 | cmdArgV = cmdArg[1:] 213 | if cmdArg[-1] == "%": 214 | cmdArgV = cmdArgV[:-1] 215 | for pair in lp: 216 | # Negative Percentage Relative Edit. Decrement the existing value by x%. 217 | banks[bank][pair] = float(banks[bank][pair]) * (1 - (float(cmdArgV)/100)) 218 | else: 219 | for pair in lp: 220 | # Negative Relative Edit. Decrease the exisiting value. 221 | banks[bank][pair] = float(banks[bank][pair]) - float(cmdArgV) 222 | else: 223 | cmdArgV = cmdArg 224 | if cmdArg[0] == "+": 225 | cmdArgV = cmdArg[1:] 226 | if cmdArg[-1] == "%": 227 | cmdArgV = cmdArgV[:-1] 228 | for pair in lp: 229 | # Positive Percentage Relative Edit. Increment the existing value by x%. 230 | banks[bank][pair] = float(banks[bank][pair]) * (1 + (float(cmdArgV)/100)) 231 | else: 232 | for pair in lp: 233 | # Positive Relative Edit. Increase the exisiting value. 234 | banks[bank][pair] = float(banks[bank][pair]) + float(cmdArgV) 235 | 236 | 237 | bld.LeafJawPositions = banks[0] + banks[1] 238 | 239 | elif "jb" in cmdStr or "j" in cmdStr: 240 | # Jaws 241 | jb = [jb["matches"] for jb in filters if jb['name'] == 'jaw bank'][0] 242 | j = [j["matches"] for j in filters if j['name'] == 'jaw'][0] 243 | 244 | target = "" 245 | for jaw in j: 246 | if int(jaw) == 0: 247 | target = "ASYMX" 248 | elif int(jaw) == 1: 249 | target = "ASYMY" 250 | 251 | for bld in cp.BeamLimitingDevicePositionSequence: 252 | if bld.RTBeamLimitingDeviceType == target: 253 | for bank in jb: 254 | if s['name'] == "Position Absolute": 255 | bld.LeafJawPositions[int(bank)] = cmdArg 256 | elif s['name'] == "Position Relative": 257 | if cmdArg[0] == "-": 258 | cmdArgV = cmdArg[1:] 259 | if cmdArg[-1] == "%": 260 | cmdArgV = cmdArgV[:-1] 261 | # Negative Percentage Relative Edit. Decrement the existing value by x%. 262 | bld.LeafJawPositions[int(bank)] = bld.LeafJawPositions[int(bank)] * (1 - (float(cmdArgV)/100)) 263 | else: 264 | # Negative Relative Edit. Increase the exisiting value. 265 | bld.LeafJawPositions[int(bank)] = bld.LeafJawPositions[int(bank)] - float(cmdArgV) 266 | else: 267 | cmdArgV = cmdArg 268 | if cmdArg[0] == "+": 269 | cmdArgV = cmdArg[1:] 270 | if cmdArg[-1] == "%": 271 | cmdArgV = cmdArgV[:-1] 272 | # Positive Percentage Relative Edit. Increment the existing value by x%. 273 | bld.LeafJawPositions[int(bank)] = bld.LeafJawPositions[int(bank)] * (1 + (float(cmdArgV)/100)) 274 | else: 275 | # Positive Relative Edit. Increase the exisiting value. 276 | bld.LeafJawPositions[int(bank)] = bld.LeafJawPositions[int(bank)] + float(cmdArgV) 277 | 278 | # Handle Gantry/Collimator Changes 279 | elif cmdArg[0] == "+": 280 | cmdArg = cmdArg[1:] 281 | if cmdArg[-1] == "%": 282 | cmdArg = cmdArg[:-1] 283 | exec("cp." + s["attr"] + "= (cp." + s["attr"] + " * (1 + (" + cmdArg + "/100))") 284 | else: 285 | exec("cp." + s["attr"] + "= (cp." + s["attr"] + " + " + cmdArg + ")") 286 | elif cmdArg[0] == "-": 287 | cmdArg = cmdArg[1:] 288 | if cmdArg[-1] == "%": 289 | cmdArg = cmdArg[:-1] 290 | exec("cp." + s["attr"] + "= (cp." + s["attr"] + " * (1 - (" + cmdArg + "/100))") 291 | else: 292 | exec("cp." + s["attr"] + "= (cp." + s["attr"] + " - " + cmdArg + ")") 293 | else: 294 | exec("cp." + s["attr"] + "=" + cmdArg) 295 | 296 | """ 297 | Write the output file. 298 | """ 299 | 300 | ds.save_as(outFile) 301 | print("Output File " + outFile + " created.") 302 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Licence file for rtp-mangle 2 | 3 | MIT License 4 | 5 | Copyright (c) 2020 A Blackmore 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /Mangle.7z: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-blackmore/rtp-mangle/4846084d7a36048ce1897e4e8e1cc19a3d37d721/Mangle.7z -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rtp-mangle 2 | A Python DICOM-RT Plan "Mangler" for Radiotherapy Quality Assurance 3 | 4 | Ever wanted to change your radiotherapy treatment plan in weird and wonderful ways? Well - now you can. With this tool, which is essentially a wrapper to pydicom, you can modify delivery properties to intentionally deliver incorrect treatment plans. This allows you to test the sensitivity of your quality assurance methods, such as diode arrays or in-vivo dosimetry systems. 5 | 6 | 7 | # Installation 8 | To run rtp-mangle, first download or clone this repository. To obtain the minimal prerequisite packages, run "pip install pip install -r requirements.txt" 9 | 10 | 11 | # Usage 12 | 13 | ## Basic Operation 14 | The script can be run with the following command - square brackets indicate optional parameters: 15 | ``` 16 | python mangle.py [options] "input.dcm" "" ["" ...] 17 | ``` 18 | A command string specifies how to edit the RT Plan file - see below for more details. 19 | 20 | ## Options 21 | Additional options include displaying the help text (-h), specifying the output file name (-o "output.dcm"), verbose mode for command string debugging (-v), and keep SOPInstanceUID mode (-k). 22 | 23 | The keep SOPInstanceUID mode is important for testing - some devices you might be testing will require this to be identical to the original planned treatment in order to allow analysis to be performed. Other systems will refuse to import files with a duplicate UID. The default behaviour of rtp-mangle is to create a new SOPInstanceUID. 24 | 25 | ## Command Strings 26 | To make edits to the plan, we use a command string. Command strings comprise of two parts - filters and setters. Filters are used to specify which parts of the plan should be changed. Setters are used to make a change. All available filters and setters are listed in the table below. 27 | 28 | The simplest command string would have no filters, and include a single setter. For example, "g=0" will set all control points for all beams to deliver at gantry angle 0. Whenever you don't specify a filter, it assumes you want to edit all available items. 29 | 30 | Setters can be given an absolute value like the previous example, or they can perform a relative modification. To increase the gantry angle in every beam and control point by +5 degrees we'd use the command string "g=+5" - or if we wanted to decrease it by -5% we'd use "g=-5%". Note that currently there is no wrap around 360 degrees, so negative values or values >360 degrees are possible - this will probably be rejected when attempting to deliver the plan. 31 | 32 | To set a beam limiting device position absolutely or relatively, we need to be more specific than this because negative values are allowed and =-10 is ambiguous. Therefore we have pr (position relative) and pa (position absolute) setters. 33 | 34 | We can introduce filters to restrict the edits to only the first beam in the file. The string "b0 g=0" will set all control points for only the first beam to deliver at gantry angle 0. Note that filters don't use an equals sign - but setters do. Also, remember that DICOM uses a zero indexing system, so the first beam is beam 0. 35 | 36 | We can specify multiple filters to restrict the edits even further - if we wanted to change the gantry angle only in the first control point of the second beam, we'd use a command string of "b1 cp0 g=0". 37 | 38 | Filters also allow you to specify more than one number or even a range. If we wanted to change the collimator angle of beams 1, 2 and 3 we can achieve this with either "b0,1,2 c=0" or "b0-3 c=0". 39 | 40 | Any more complex filtering might require using two command strings one after the other - in a plan with 6 beams, we may wish to leave beam 5 alone. "b0-3 c=0" "b5 c=0". 41 | 42 | ## Available Filters 43 | 44 | | Filter | Name | Description | 45 | |--------|------|-------------| 46 | | b | Beam | DICOM Beam number. Starts at 0. | 47 | | cp | Control Point | Control Points in Beam Sequence. Starts at 0. | 48 | | j | Jaw | X or Y jaw - use 0 or 1. Experiment to determine which is which. | 49 | | jb | Jaw Bank | X1/Y1 or X2/Y2. Use 0 or 1, experiment to identify. | 50 | | lp | Leaf Pair | The leaf pair number. Starts at 0. | 51 | | lb | Leaf Bank | MLC Bank A or B. Use 0 or 1, experiment to identify. | 52 | 53 | 54 | ## Available Setters 55 | 56 | | Setter | Name | Description | 57 | |--------|------|-------------| 58 | | mu= | MU | Change the prescribed monitor units. | 59 | | m= | Machine | Change the treatment machine name. Use single quotes to include a space. | 60 | | g= | Gantry | Gantry Angle | 61 | | c= | Collimator | Collimator Angle. | 62 | | pa= | Position Absolute | Change the absolute position of the BLD. Requires a jaw or MLC filter to work. | 63 | | pr= | Position Relative | Change the relative position of the BLD. Requires a jaw or MLC filter to work. | 64 | 65 | 66 | ## Rules 67 | * Never, EVER, use this on a clinical treatment plan. This is a QA tool only. 68 | * You can't edit the Jaw and MLC positions within the same command string, because they both require the pa= and pr= setters. Do one edit and then the next in two separate command strings. 69 | * You can't use both the pa= and pr= setters within the same command string. It doesn't make any sense! If you want to shift the absolute position, then perform a relative movement, use two separate command strings. 70 | 71 | # Examples 72 | 73 | ## Changing the MU 74 | Our plan has three beams. We need to set the MU of the first beam to 100MU exactly. We need the second beam to be increased by 10MU, and the third beam's MU to be increased by 15%. 75 | 76 | ``` 77 | python mangle.py "input.dcm" "b0 mu=100" "b1 mu=+10" "b2 mu=+15%" 78 | ``` 79 | 80 | ## Changing the Treatment Machine Name 81 | We need to deliver this plan on "Linac 2" instead. Note that in some oncology management systems this must be an exact value before being imported. 82 | 83 | ``` 84 | python mangle.py "input.dcm" "m='Linac 2'" 85 | ``` 86 | 87 | ## Changing the Jaw Positions 88 | For the first beam, between control points 12 and 16, we need to move the X jaw (which in our case happens to be j0) on the X2 side (which in our case happens to be jb1) to an absolute position of -5.2. 89 | 90 | ``` 91 | python mangle.py "input.dcm" "b0 cp12-16 j0 jb1 pa=-5.2" 92 | ``` 93 | 94 | ## Changing the MLC Positions 95 | Open all of the Y1 side MLCs an extra 10mm in all control points of the second beam. 96 | 97 | ``` 98 | python mangle.py "input.dcm" "b1 lb0 pr=+10" 99 | ``` 100 | 101 | ## Simulate a Stuck Leaf 102 | Set leaf pair 7 on the Y2 side to an absolute value of -400 for all beams and control points. 103 | 104 | ``` 105 | python mangle.py "input.dcm" "lb1 lp6 pa=-400" 106 | ``` 107 | 108 | ## Specify the output File 109 | Set the gantry to 0 in all beams and control points, then save the edits to the filename specified. 110 | 111 | ``` 112 | python mangle.py "input.dcm" -o "output.dcm" "g=0" 113 | ``` 114 | -------------------------------------------------------------------------------- /UserInterface.py: -------------------------------------------------------------------------------- 1 | import wx 2 | import subprocess 3 | import pydicom 4 | 5 | 6 | class PopUp(wx.Frame): 7 | def __init__(self, text): 8 | super().__init__(parent=None, title='', size = (600,100)) 9 | 10 | panel = wx.Panel(self) 11 | my_sizer = wx.BoxSizer(wx.VERTICAL) 12 | 13 | self.text = wx.StaticText(panel, label=text, style=wx.TE_MULTILINE | wx.TE_READONLY) 14 | 15 | my_sizer.Add(self.text, 1, flag = wx.ALL | wx.ALIGN_CENTER_HORIZONTAL, border = 20) 16 | 17 | panel.SetSizer(my_sizer) 18 | self.Show() 19 | 20 | class MyFrame(wx.Frame): 21 | def __init__(self): 22 | 23 | self.commandString = '' 24 | self.pathname = '' 25 | self.directory = '' 26 | self.outFile = '' 27 | self.addedInFile = False 28 | self.addedKeepUid = False 29 | self.addedVerbose = False 30 | self.dark_mode = False 31 | 32 | super().__init__(parent=None, title='Plan Mnagler', size = (1500, 650)) 33 | 34 | panel = wx.Panel(self) 35 | 36 | my_sizer = wx.BoxSizer(wx.VERTICAL) 37 | 38 | initialOptions = wx.GridSizer(2,9,5,5) 39 | beamData = wx.GridSizer(9,9,5,5) 40 | helptext = wx.GridSizer(6,1,5,5,) 41 | perform = wx.FlexGridSizer(1,4,5,5) 42 | 43 | file_open = wx.Button(panel, label='Input File') 44 | file_open.Bind(wx.EVT_BUTTON, self.OnOpen) 45 | 46 | 47 | self.uid = wx.CheckBox(panel, label = 'Keep SOPInstanceUID' ) 48 | self.verbose = wx.CheckBox(panel, label = 'Verbose Console Ouput ?') 49 | 50 | 51 | # Filters 52 | self.beam = wx.ComboBox(panel) 53 | self.controlPointsFrom = wx.ComboBox(panel) 54 | self.controlPointsTo = wx.ComboBox(panel) 55 | 56 | 57 | # Checkbox to choose either jaw or Mlc to be modified. 58 | self.mlc_jaw = wx.ToggleButton(panel, label = 'Edit MLC') 59 | self.mlc_jaw.Bind(wx.EVT_TOGGLEBUTTON, self.on_check) 60 | 61 | self.jaw_label = wx.StaticText(panel , label ='Jaw :') 62 | self.jaw = wx.ComboBox(panel, choices=['0- X Jaws','1- Y Jaws','0,1- Both Jaws'], style=wx.BORDER_NONE) 63 | self.jaw.Bind(wx.EVT_COMBOBOX, self.OnJawChoice) 64 | self.jaw_bank_label = wx.StaticText(panel , label ='Jaw Bank:') 65 | self.jawBank = wx.ComboBox(panel, choices=['']) 66 | 67 | self.leaf_bank_label = wx.StaticText(panel , label ='Leaf Bank :') 68 | self.leafBank = wx.ComboBox(panel, choices=['0','1','0,1']) 69 | self.leaf_pair_label = wx.StaticText(panel , label ='Leaf Pair From:') 70 | self.leaf_pair_from_label = wx.StaticText(panel , label ='From :') 71 | self.leafPairFrom = wx.ComboBox(panel) 72 | self.leaf_pair_to_label = wx.StaticText(panel , label ='To :') 73 | self.leafPairTo = wx.ComboBox(panel) 74 | 75 | 76 | # Checkbox to choose either postion absolute or position relative for jaw or MlC modifications. 77 | self.jaw_relative = wx.CheckBox(panel, label = 'Relative Change ?' ) 78 | self.jaw_position = wx.TextCtrl(panel) 79 | 80 | self.leaf_relative = wx.CheckBox(panel, label = 'Relative Change ?' ) 81 | self.leaf_position = wx.TextCtrl(panel) 82 | 83 | self.outputFile = wx.TextCtrl(panel) 84 | 85 | self.commandString_view = wx.TextCtrl(panel, -1,'' ,style=wx.TE_MULTILINE) 86 | 87 | self.darkMode = wx.ToggleButton(panel, label='Dark Mode') 88 | self.darkMode.Bind(wx.EVT_TOGGLEBUTTON, self.onToggleDark) 89 | 90 | 91 | # Setters 92 | self.MU = wx.TextCtrl(panel) 93 | self.Machine = wx.TextCtrl(panel) 94 | self.Gantry = wx.TextCtrl(panel) 95 | self.Collimator = wx.TextCtrl(panel) 96 | 97 | perform_btn = wx.Button(panel, label='Perform') 98 | perform_btn.Bind(wx.EVT_BUTTON, self.perform) 99 | 100 | self.AddToCommand = wx.Button(panel, label = 'Add to Command') 101 | self.AddToCommand.Bind(wx.EVT_BUTTON, self.on_press) 102 | 103 | initialOptions.AddMany([ 104 | 105 | (file_open, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(self.darkMode,0,wx.EXPAND,5), 106 | 107 | (self.uid, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')),(self.verbose, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')),(wx.StaticText(panel, label ='Output File Name:' ),0, wx.ALIGN_CENTER_VERTICAL,5),(self.outputFile,0,wx.EXPAND,5),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')), 108 | 109 | 110 | ]) 111 | 112 | beamData.AddMany([ 113 | 114 | (wx.StaticText(panel, label ='Beam :'),0, wx.ALIGN_CENTER_VERTICAL,5),(self.beam, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')), 115 | 116 | (wx.StaticText(panel , label ='Control Points From:'),0, wx.ALIGN_CENTER_VERTICAL,5),(self.controlPointsFrom, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='To:'),0, wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_CENTER_HORIZONTAL,5),(self.controlPointsTo, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label =''),0, wx.ALIGN_CENTER_VERTICAL| wx.ALIGN_CENTER_HORIZONTAL,5), 117 | 118 | (self.mlc_jaw, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')), 119 | 120 | (self.jaw_label,0, wx.ALIGN_CENTER_VERTICAL,5),(self.jaw, 0 , wx.EXPAND, 5),(self.jaw_bank_label,0, wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_CENTER_HORIZONTAL,5),(self.jawBank, 0 , wx.EXPAND, 5),(self.jaw_relative, 0 , wx.EXPAND | wx.ALIGN_CENTER_HORIZONTAL, 5), (self.jaw_position, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')), 121 | 122 | (self.leaf_bank_label,0, wx.ALIGN_CENTER_VERTICAL,5),(self.leafBank, 0 , wx.EXPAND, 5),(self.leaf_pair_label,0, wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_CENTER_HORIZONTAL,5),(self.leafPairFrom, 0 , wx.EXPAND, 5),(self.leaf_pair_to_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALIGN_CENTER_HORIZONTAL,5),(self.leafPairTo, 0, wx.EXPAND, 5),(self.leaf_relative, 0 , wx.EXPAND | wx.ALIGN_CENTER_HORIZONTAL, 5), (self.leaf_position, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')), 123 | 124 | (wx.StaticText(panel, label ='MU :' ),0, wx.ALIGN_CENTER_VERTICAL,5),(self.MU, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')), 125 | 126 | (wx.StaticText(panel, label ='Machine ID:' ),0, wx.ALIGN_CENTER_VERTICAL,5),(self.Machine, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')), 127 | 128 | (wx.StaticText(panel, label ='Gantry Angle:' ),0, wx.ALIGN_CENTER_VERTICAL,5),(self.Gantry, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')), 129 | 130 | (wx.StaticText(panel, label ='Collimator Angle :' ),0, wx.ALIGN_CENTER_VERTICAL,5),(self.Collimator, 0, wx.EXPAND, 5),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')),(wx.StaticText(panel , label ='')), 131 | 132 | 133 | ]) 134 | 135 | helptext.AddMany([ 136 | 137 | (wx.StaticLine(panel, 0, size =(1450,1), style=wx.LI_HORIZONTAL),0,wx.ALIGN_CENTER_VERTICAL,5), 138 | 139 | (wx.StaticText(panel , label ='• Not all parameters are required, only specify what requires modification.'),0,wx.ALIGN_CENTER_HORIZONTAL,5), 140 | 141 | (wx.StaticText(panel , label ='• SOPInstanceUID - Some devices require this to remain unchanged to allow analysis, others refuse to import files with duplicate UID.'),0,wx.ALIGN_CENTER_HORIZONTAL,5), 142 | 143 | (wx.StaticText(panel , label ='• Leaf bank defines the A or B side, experimentation is required to determine which is 0 and which is 1.'),0,wx.ALIGN_CENTER_HORIZONTAL,5), 144 | 145 | (wx.StaticText(panel , label ='• Absolute values or relative changes can be applied to Jaws, Leafs, MU, Gantry Angle, and Collimator Angle. Relative changes can be specified as either +/- values (+10 or -5 for example) or percent values (+10% or -5% for example).'),0,wx.ALIGN_CENTER_HORIZONTAL,5), 146 | 147 | (wx.StaticLine(panel, 0, size =(1450,1), style=wx.LI_HORIZONTAL),0,wx.ALIGN_CENTER_VERTICAL,5), 148 | ]) 149 | 150 | 151 | perform.AddMany([ 152 | 153 | (self.AddToCommand,0,wx.EXPAND,5),(wx.StaticText(panel , label ='Current Command :'),0,wx.ALIGN_CENTER_VERTICAL,5),(self.commandString_view, 0, wx.EXPAND, 5),(perform_btn, 0, wx.EXPAND, 5) 154 | 155 | ]) 156 | 157 | perform.AddGrowableCol(2) 158 | perform.AddGrowableRow(0) 159 | 160 | my_sizer.Add(initialOptions, 1, flag = wx.ALL | wx.EXPAND, border = 15) 161 | my_sizer.Add(beamData, 1, flag = wx.ALL | wx.EXPAND, border = 15) 162 | my_sizer.Add(helptext, 1, flag = wx.ALL | wx.EXPAND, border = 15) 163 | my_sizer.Add(perform, 1, flag = wx.ALL | wx.EXPAND, border = 15) 164 | 165 | panel.SetSizer(my_sizer) 166 | 167 | self.Show() 168 | 169 | self.jaw_label.Show() 170 | self.jaw.Show() 171 | self.jaw_bank_label.Show() 172 | self.jawBank.Show() 173 | self.jaw_relative.Show() 174 | self.jaw_position.Show() 175 | 176 | self.leaf_bank_label.Hide() 177 | self.leafBank.Hide() 178 | self.leaf_pair_label.Hide() 179 | self.leafPairFrom.Hide() 180 | self.leaf_pair_from_label.Hide() 181 | self.leafPairTo.Hide() 182 | self.leaf_pair_to_label.Hide() 183 | self.leaf_relative.Hide() 184 | self.leaf_position.Hide() 185 | 186 | 187 | def on_check(self, event): 188 | if self.mlc_jaw.IsChecked(): 189 | self.jaw_label.Hide() 190 | self.jaw.Hide() 191 | self.jaw_bank_label.Hide() 192 | self.jawBank.Hide() 193 | self.jaw_relative.Hide() 194 | self.jaw_position.Hide() 195 | 196 | self.leaf_bank_label.Show() 197 | self.leafBank.Show() 198 | self.leaf_pair_label.Show() 199 | self.leafPairFrom.Show() 200 | self.leaf_pair_from_label.Show() 201 | self.leafPairTo.Show() 202 | self.leaf_pair_to_label.Show() 203 | self.leaf_relative.Show() 204 | self.leaf_position.Show() 205 | else: 206 | self.jaw_label.Show() 207 | self.jaw.Show() 208 | self.jaw_bank_label.Show() 209 | self.jawBank.Show() 210 | self.jaw_relative.Show() 211 | self.jaw_position.Show() 212 | 213 | self.leaf_bank_label.Hide() 214 | self.leafBank.Hide() 215 | self.leaf_pair_label.Hide() 216 | self.leafPairFrom.Hide() 217 | self.leaf_pair_from_label.Hide() 218 | self.leafPairTo.Hide() 219 | self.leaf_pair_to_label.Hide() 220 | self.leaf_relative.Hide() 221 | self.leaf_position.Hide() 222 | 223 | 224 | 225 | def on_press(self, event): 226 | #script_path = mangle.__path__ 227 | 228 | 229 | # In File 230 | inFile = self.pathname 231 | if not self.addedInFile: 232 | self.commandString = self.commandString + '"' + inFile + '"' 233 | self.addedInFile = True 234 | 235 | 236 | # Command string 237 | #Filters 238 | beams = '' 239 | if self.beam.GetValue(): 240 | beams = 'b' + self.beam.GetValue().split('-')[0] 241 | controlPoints = '' 242 | if self.controlPointsFrom.GetValue(): 243 | if self.controlPointsTo.GetValue(): 244 | controlPoints = 'cp' + self.controlPointsFrom.GetValue() + '-' + self.controlPointsTo.GetValue() 245 | else: 246 | controlPoints = 'cp' + self.controlPointsFrom.GetValue() 247 | 248 | jaw_or_mlc_command = '' 249 | if self.mlc_jaw.GetValue(): 250 | if self.leafBank.GetValue(): 251 | leafbank = 'lb' + self.leafBank.GetValue() 252 | leafpair = '' 253 | if self.leafPairFrom.GetValue(): 254 | if self.leafPairTo.GetValue(): 255 | leafpair = 'lp' + self.leafPairFrom.GetValue() + '-' + self.leafPairTo.GetValue() 256 | else: 257 | leafpair = 'lp' + self.leafPairFrom.GetValue() 258 | position = '' 259 | if self.leaf_relative.GetValue(): 260 | postion = 'pr=' + self.leaf_position.GetValue() 261 | else: 262 | position = 'pa=' + self.leaf_position.GetValue() 263 | 264 | jaw_or_mlc_command = leafbank + ' ' + leafpair + ' ' + position 265 | else: 266 | if self.jaw.GetValue(): 267 | jaw = 'j' + self.jaw.GetValue().split('-')[0] 268 | jawBank = 'jb' + self.jawBank.GetValue().split('-')[0] 269 | position = '' 270 | if self.jaw_relative.GetValue(): 271 | position = 'pr=' + self.jaw_position.GetValue() 272 | else: 273 | position = 'pa=' + self.jaw_position.GetValue() 274 | jaw_or_mlc_command = jaw + ' ' + jawBank + ' ' + position 275 | print(jaw_or_mlc_command) 276 | 277 | mu = '' 278 | if self.MU.GetValue(): 279 | mu = 'mu=' + self.MU.GetValue() 280 | machine ='' 281 | if self.Machine.GetValue(): 282 | machine = 'm=' + self.Machine.GetValue() 283 | gantry = '' 284 | if self.Gantry.GetValue(): 285 | gantry = 'g=' + self.Gantry.GetValue() 286 | collimator = '' 287 | if self.Collimator.GetValue(): 288 | collimator = 'c=' + self.Collimator.GetValue() 289 | 290 | 291 | #Options 292 | keepUID ='-k ' 293 | verbose ='-v ' 294 | 295 | if self.uid.GetValue(): 296 | if not self.addedKeepUid: 297 | self.commandString = keepUID + self.commandString 298 | self.commandString = self.commandString.replace(' ""', '') 299 | self.addedKeepUid = True 300 | else: 301 | self.commandString = self.commandString.replace(keepUID, '') 302 | self.commandString = self.commandString.replace(' ""', '') 303 | self.addedKeepUid = False 304 | 305 | if self.verbose.GetValue(): 306 | if not self.addedVerbose: 307 | self.commandString = verbose + self.commandString 308 | self.commandString = self.commandString.replace(' ""', '') 309 | self.addedVerbose = True 310 | else: 311 | self.commandString = self.commandString.replace(verbose, '') 312 | self.commandString = self.commandString.replace(' ""', '') 313 | self.addedVerbose = False 314 | 315 | if self.outputFile.GetValue(): 316 | self.outFile = self.directory + "\\" + self.outputFile.GetValue() + ".dcm" 317 | self.commandString = self.commandString + ' -o "' + self.directory + "\\" + self.outputFile.GetValue() + ".dcm" + '"' 318 | else: 319 | self.outFile = "out.dcm" 320 | 321 | 322 | command = beams + ' ' + controlPoints + ' ' + jaw_or_mlc_command + ' ' + mu + ' ' + machine + ' ' + gantry + ' ' + collimator 323 | command = ' '.join(command.split()) 324 | 325 | self.commandString = self.commandString + ' "' + command + '"' 326 | self.commandString = self.commandString.replace(' ""', '') 327 | 328 | 329 | self.commandString_view.SetValue(self.commandString) 330 | 331 | self.beam.SetValue('') 332 | self.controlPointsFrom.SetValue('') 333 | self.controlPointsTo.SetValue('') 334 | # Checkbox to choose either jaw or Mlc to be modified. 335 | self.mlc_jaw.SetValue(wx.CHK_UNCHECKED) 336 | self.jaw.SetValue('') 337 | self.jawBank.SetValue('') 338 | self.leafBank.SetValue('') 339 | self.leafPairFrom.SetValue('') 340 | self.leafPairTo.SetValue('') 341 | # Checkbox to choose either postion absolute or position relative for jaw or MlC modifications. 342 | self.jaw_relative.SetValue(wx.CHK_UNCHECKED) 343 | self.jaw_position.SetValue('') 344 | self.leaf_relative.SetValue(wx.CHK_UNCHECKED) 345 | self.leaf_position.SetValue('') 346 | self.MU.SetValue('') 347 | self.Machine.SetValue('') 348 | self.Gantry.SetValue('') 349 | self.Collimator.SetValue('') 350 | 351 | 352 | def OnOpen(self, event): 353 | self.commandString = '' 354 | self.commandString_view.SetValue(self.commandString) 355 | with wx.FileDialog(self, "Open Dicom File", wildcard="Dicom (*.dcm)|*.dcm", 356 | style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) as fileDialog: 357 | 358 | if fileDialog.ShowModal() == wx.ID_CANCEL: 359 | return # the user changed their mind 360 | 361 | # Proceed loading the file chosen by the user 362 | self.pathname = fileDialog.GetPath() 363 | self.directory = fileDialog.GetDirectory() 364 | 365 | dicom = pydicom.dcmread(self.pathname) 366 | beams = dicom.BeamSequence 367 | allBeams = '' 368 | maxPairs = 0 369 | maxControlPoints = 0 370 | i = 0 371 | self.beam.Clear() 372 | for beam in beams: 373 | self.beam.Append(str(i) +'- ' + beam.BeamName) 374 | blds = beam.BeamLimitingDeviceSequence 375 | for bld in blds: 376 | if bld.RTBeamLimitingDeviceType == "MLCX" or bld.RTBeamLimitingDeviceType == "MLCY": 377 | if int(bld.NumberOfLeafJawPairs) > maxPairs: 378 | maxPairs = bld.NumberOfLeafJawPairs 379 | if int(beam.NumberOfControlPoints) > maxControlPoints: 380 | maxControlPoints = beam.NumberOfControlPoints 381 | allBeams = allBeams + str(i) + ',' 382 | i += 1 383 | self.beam.Append(allBeams + '- ' + 'All Beams') 384 | listPairs = [str(x) for x in range(0, maxPairs)] 385 | self.leafPairFrom.Clear() 386 | self.leafPairFrom.Append(listPairs) 387 | self.leafPairTo.Clear() 388 | self.leafPairTo.Append(listPairs) 389 | 390 | listControlPoints = [str(x) for x in range(0, maxControlPoints)] 391 | self.controlPointsFrom.Clear() 392 | self.controlPointsFrom.Append(listControlPoints) 393 | self.controlPointsTo.Clear() 394 | self.controlPointsTo.Append(listControlPoints) 395 | 396 | jaws = beams[0].BeamLimitingDeviceSequence 397 | 398 | def onToggleDark(self, event): 399 | darkMode(self, self.dark_mode) 400 | if not self.dark_mode: 401 | self.dark_mode = True 402 | else: 403 | self.dark_mode = False 404 | 405 | def perform(self, event): 406 | if not self.commandString_view.GetValue(): 407 | frame = PopUp('Command String is Blank, Please Add To Command String') 408 | else: 409 | script_path = 'mangle.py' 410 | command = 'python' + ' ' + script_path + ' ' + self.commandString_view.GetValue() 411 | 412 | subprocess.run(command) 413 | frame = PopUp('Output File Created At: ' + self.outFile) 414 | 415 | def OnJawChoice(self, event): 416 | if self.jaw.GetValue().split('-')[0] == '0': 417 | self.jawBank.Clear() 418 | self.jawBank.Append(['0- X1 Jaw', '1- X2 Jaw', '0,1- Both Jaws']) 419 | elif self.jaw.GetValue().split('-')[0] == '1': 420 | self.jawBank.Clear() 421 | self.jawBank.Append(['0- Y1 Jaw', '1- Y2 Jaw', '0,1- Both Jaws']) 422 | else: 423 | self.jawBank.Clear() 424 | self.jawBank.Append(['0- X1 & Y1 Jaws', '1- X2 & Y2 Jaws', '0,1- All Jaws']) 425 | 426 | def getWidgets(parent): 427 | items = [parent] 428 | for item in parent.GetChildren(): 429 | items.append(item) 430 | if hasattr(item, "GetChildren"): 431 | for child in item.GetChildren(): 432 | items.append(child) 433 | return items 434 | 435 | def darkMode(self, darkmode): 436 | dark_grey = "#212121" 437 | widgets = getWidgets(self) 438 | for widget in widgets: 439 | if not darkmode: 440 | widget.SetBackgroundColour(dark_grey) 441 | widget.SetForegroundColour("White") 442 | else: 443 | widget.SetBackgroundColour(wx.NullColour) 444 | widget.SetForegroundColour("Black") 445 | self.Refresh() 446 | return True 447 | 448 | 449 | if __name__ == '__main__': 450 | app = wx.App() 451 | frame = MyFrame() 452 | app.MainLoop() -------------------------------------------------------------------------------- /mangle.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """mangle.py: Modifies a DICOM-RT Plan File to create intentional delivery errors.""" 4 | 5 | # Imports 6 | import argparse 7 | import shlex 8 | import re 9 | import pydicom 10 | from pydicom.uid import generate_uid 11 | 12 | """ 13 | Parse Command Line Arguments 14 | ---------------------------- 15 | 16 | Uses argparse - https://docs.python.org/3/library/argparse.html 17 | 18 | """ 19 | 20 | parser = argparse.ArgumentParser(description='Modify a DICOM-RT Plan File to Add Delivery Errors.') 21 | parser.add_argument('inFile', # Use strings for filenames rather than file objects, 22 | type=str, # allows pydicom to handle the file operations. 23 | help='DICOM-RT Plan to Modify.') 24 | parser.add_argument("-v", "--verbose", 25 | help="increase output verbosity", 26 | action="store_true") 27 | parser.add_argument("-k", "--keep_uid", 28 | help="Keep Original Instance UID", 29 | action="store_true") 30 | parser.add_argument('-o', '--outFile', 31 | type=str, 32 | default="out.dcm", 33 | help='Output File to create.', 34 | nargs='?',) 35 | parser.add_argument('commandString', 36 | type=str, 37 | help='A Mangle command string describing how to alter the file. See documentation for details.', 38 | nargs='+',) 39 | args = parser.parse_args() 40 | 41 | 42 | """ 43 | Parse the Command Strings 44 | ------------------------- 45 | 46 | See wiki documentation for full instructions. The command strings instruct the script on how to 47 | edit the RTPlan. They consist of two types: filters and setters. 48 | 49 | Filters specify what needs to be edited; the specific beams and control points within the file. The 50 | intention is to ultimately be able to finely filter, by gantry angle for example, or CPs with less than 51 | a certain MU. 52 | 53 | Setters are the methods through which we change the file. Parameters can either be specified exactly, 54 | or they can be relative, such as +x% or +y units. 55 | 56 | The first step in parsing the command strings is to gather the required data into an easily accessible form 57 | using the filters. 58 | 59 | """ 60 | 61 | # Open DICOM File in pydicom and retrieve a dataset: 62 | ds = pydicom.dcmread(args.inFile) 63 | 64 | # Unless Instructed, change the file's UID to prevent duplicates. 65 | if not args.keep_uid: 66 | ds.SOPInstanceUID = generate_uid() 67 | 68 | # Parse Command Strings 69 | if args.verbose: 70 | print("Found " + str(len(args.commandString)) + " command string(s)." ) 71 | 72 | for cmdStr in args.commandString: 73 | if args.verbose: 74 | print("\nProcessing Command String: " + cmdStr + "\n" ) 75 | 76 | # Prevent Simultaneous Jaw and MLC editing. 77 | if "lp" in cmdStr or "lb" in cmdStr: 78 | if "jb" in cmdStr or "j" in cmdStr: 79 | print("ERROR: Cannot Edit Leaf and Jaw positions in the same command.\n") 80 | raise ValueError 81 | 82 | # Prevent Simultaneous Relative and Absoulte edits. 83 | if "pr=" in cmdStr and "pa=" in cmdStr: 84 | print("ERROR: Cannot Edit Relative and Absolute positions in the same command.\n") 85 | raise ValueError 86 | 87 | cmds = shlex.split(cmdStr) 88 | 89 | filters = [] 90 | beams = [] 91 | cps = [] 92 | pairs = [] 93 | 94 | filters.append({"name": "beam", "key": "b", "default": "*", "matches": ""}) 95 | filters.append({"name": "control pt", "key": "cp", "default": "*", "matches": ""}) 96 | filters.append({"name": "jaw", "key": "j", "default": "*", "matches": ""}) 97 | filters.append({"name": "jaw bank", "key": "jb", "default": "*", "matches": ""}) 98 | filters.append({"name": "leaf pair", "key": "lp", "default": "*", "matches": ""}) 99 | filters.append({"name": "leaf bank", "key": "lb", "default": "*", "matches": ""}) 100 | 101 | for f in filters: 102 | 103 | r = re.compile("(^" + f["key"] + "\d+)") 104 | reArgs = list(filter(r.match, cmds)) 105 | 106 | if len(reArgs) > 1: 107 | # More than one fstring. 108 | print("ERROR: More than one " + f["name"] + " filter found.\n") 109 | raise ValueError 110 | elif len(reArgs) < 1: 111 | reArgs = [f['key'] + f["default"]] 112 | 113 | # Remove the key from the command 114 | reArgs = reArgs[0][len(f["key"]):] 115 | 116 | if args.verbose and reArgs != f["default"]: 117 | print("Found " + f["name"] + " filter - Value: " + reArgs) 118 | 119 | # Handle Multiple Specified Values 120 | reArgs = reArgs.split(",") 121 | 122 | # Handle Range of Values 123 | for i in reArgs: 124 | if "-" in i: 125 | reArgs.remove(i) 126 | for j in range(int(i.split("-")[0]), int(i.split("-")[1])+1): 127 | reArgs.append(str(j)) 128 | 129 | f["matches"] = reArgs 130 | 131 | # Gather the data to act upon 132 | if f["name"] == "beam": # Build the Beam list. 133 | if f["matches"][0] == "*": 134 | beams = ds.BeamSequence 135 | else: 136 | for i in f["matches"]: 137 | try: 138 | beams.append(ds.BeamSequence[int(i)]) 139 | except IndexError: 140 | print("WARNING: Beam Index Out of Plan Range - Ignoring beam " + i + ".\n") 141 | elif f["name"] == "control pt": # Build the CP list. 142 | if f["matches"][0] == "*": 143 | for beam in beams: 144 | for cp in beam.ControlPointSequence: 145 | cps.append(cp) 146 | else: 147 | for beam in beams: 148 | for i in f["matches"]: 149 | try: 150 | cps.append(beam.ControlPointSequence[int(i)]) 151 | except IndexError: 152 | print("WARNING: Control Point Index Out of Beam Range - Ignoring CP " + i + ".\n") 153 | elif f["name"] == "jaw" or f["name"] == "jaw bank" or f["name"] == "leaf bank": 154 | if f["matches"][0] == "*": 155 | f["matches"] = list(range(0, 2)) 156 | 157 | elif f["name"] == "leaf pair": 158 | maxPairs = 0 159 | for bld in beams[0].BeamLimitingDeviceSequence: 160 | if bld.RTBeamLimitingDeviceType == "MLCX" or bld.RTBeamLimitingDeviceType == "MLCY": 161 | if int(bld.NumberOfLeafJawPairs) > maxPairs: 162 | maxPairs = bld.NumberOfLeafJawPairs 163 | if f["matches"][0] == "*": 164 | f["matches"] = list(range(0, maxPairs)) 165 | 166 | 167 | """ 168 | Perform the edits 169 | 170 | Now we have gathered the items to be edited, we can start looking at the setters. 171 | 172 | """ 173 | 174 | # Perform Edits 175 | setters = [] 176 | setters.append({"name": "MU", "type": "int", "key": "mu="}) 177 | setters.append({"name": "Machine", "type": "str", "key": "m="}) 178 | setters.append({"name": "Gantry", "type": "int", "key": "g=", "attr": "GantryAngle"}) 179 | setters.append({"name": "Collimator", "type": "int", "key": "c=", "attr": "BeamLimitingDeviceAngle"}) 180 | setters.append({"name": "Position Absolute", "type": "int", "key": "pa=", "attr": "BeamLimitingDevicePositionSequence"}) 181 | setters.append({"name": "Position Relative", "type": "int", "key": "pr=", "attr": "BeamLimitingDevicePositionSequence"}) 182 | 183 | for s in setters: 184 | 185 | if s["type"] == "int": 186 | r = re.compile("(" + s["key"] + "[+-]?\d+%?)") 187 | else: 188 | r = re.compile("(" + s["key"] + "[\']?[a-zA-Z0-9\ ]+[\']?)") 189 | 190 | reArgs = list(filter(r.match, cmds)) 191 | 192 | if len(reArgs) > 1: 193 | # More than one fstring. 194 | print("ERROR: More than one " + s["name"] + " set command found.\n") 195 | raise ValueError 196 | elif len(reArgs) != 1: 197 | continue 198 | 199 | # Remove the key from the command 200 | reArg = reArgs[0][len(s["key"]):] 201 | 202 | if args.verbose: 203 | print("Found " + s["name"] + " setter - Value: " + reArg) 204 | 205 | if s["name"] == "MU": 206 | for beam in beams: 207 | cmdArg = reArg 208 | meterset = ds.FractionGroupSequence[0].ReferencedBeamSequence[beam.BeamNumber - 1].BeamMeterset 209 | if cmdArg[0] == "+": 210 | cmdArg = cmdArg[1:] 211 | if cmdArg[-1] == "%": 212 | cmdArg = cmdArg[:-1] 213 | ds.FractionGroupSequence[0].ReferencedBeamSequence[beam.BeamNumber - 1].BeamMeterset = meterset * (1+(float(cmdArg)/100)) 214 | else: 215 | ds.FractionGroupSequence[0].ReferencedBeamSequence[beam.BeamNumber - 1].BeamMeterset = meterset + float(cmdArg) 216 | 217 | elif cmdArg[0] == "-": 218 | cmdArg = cmdArg[1:] 219 | if cmdArg[-1] == "%": 220 | cmdArg = cmdArg[:-1] 221 | ds.FractionGroupSequence[0].ReferencedBeamSequence[beam.BeamNumber - 1].BeamMeterset = meterset * (1-(float(cmdArg)/100)) 222 | else: 223 | ds.FractionGroupSequence[0].ReferencedBeamSequence[beam.BeamNumber - 1].BeamMeterset = meterset - float(cmdArg) 224 | else: 225 | meterset = cmdArg 226 | elif s["name"] == "Machine": 227 | for beam in beams: 228 | cmdArg = reArg 229 | beam.TreatmentMachineName = cmdArg 230 | else: 231 | for cp in cps: 232 | cmdArg = reArg 233 | if hasattr(cp, s["attr"]): 234 | # Handle Jaw/MLC Changes 235 | if s['name'] == "Position Absolute" or s['name'] == "Position Relative": 236 | if "lp" in cmdStr or "lb" in cmdStr: 237 | # MLCs 238 | 239 | # Collect the list of Leaf Banks and Leaf Pairs that must be edited. 240 | lb = [lb["matches"] for lb in filters if lb['name'] == 'leaf bank'][0] 241 | lp = [lp["matches"] for lp in filters if lp['name'] == 'leaf pair'][0] 242 | lb = [int(i) for i in lb] 243 | lp = [int(i) for i in lp] 244 | 245 | # Cycle Through the BLD Sequences in the control point. Look for "MLCX" or "MLCY" - Note that futuristic machines with both MLCX and MLCY won't work! 246 | for bld in cp.BeamLimitingDevicePositionSequence: 247 | if bld.RTBeamLimitingDeviceType == "MLCX" or bld.RTBeamLimitingDeviceType == "MLCY": 248 | # Split the Banks up - DICOM stores the MLCs in one long list. 249 | banks = [] 250 | banks.append(bld.LeafJawPositions[:maxPairs]) 251 | banks.append(bld.LeafJawPositions[maxPairs:]) 252 | 253 | # For each bank 254 | for bank in lb: 255 | bank = int(bank) 256 | if s['name'] == "Position Absolute": 257 | for pair in lp: 258 | # Absolute Position Specified. Set each of the pairs to modify in this bank to that value. 259 | banks[bank][pair] = cmdArg 260 | elif s['name'] == "Position Relative": 261 | if cmdArg[0] == "-": 262 | cmdArgV = cmdArg[1:] 263 | if cmdArg[-1] == "%": 264 | cmdArgV = cmdArgV[:-1] 265 | for pair in lp: 266 | # Negative Percentage Relative Edit. Decrement the existing value by x%. 267 | banks[bank][pair] = float(banks[bank][pair]) * (1 - (float(cmdArgV)/100)) 268 | else: 269 | for pair in lp: 270 | # Negative Relative Edit. Decrease the exisiting value. 271 | banks[bank][pair] = float(banks[bank][pair]) - float(cmdArgV) 272 | else: 273 | cmdArgV = cmdArg 274 | if cmdArg[0] == "+": 275 | cmdArgV = cmdArg[1:] 276 | if cmdArg[-1] == "%": 277 | cmdArgV = cmdArgV[:-1] 278 | for pair in lp: 279 | # Positive Percentage Relative Edit. Increment the existing value by x%. 280 | banks[bank][pair] = float(banks[bank][pair]) * (1 + (float(cmdArgV)/100)) 281 | else: 282 | for pair in lp: 283 | # Positive Relative Edit. Increase the exisiting value. 284 | banks[bank][pair] = float(banks[bank][pair]) + float(cmdArgV) 285 | 286 | 287 | bld.LeafJawPositions = banks[0] + banks[1] 288 | 289 | elif "jb" in cmdStr or "j" in cmdStr: 290 | # Jaws 291 | jb = [jb["matches"] for jb in filters if jb['name'] == 'jaw bank'][0] 292 | j = [j["matches"] for j in filters if j['name'] == 'jaw'][0] 293 | 294 | target = "" 295 | for jaw in j: 296 | if int(jaw) == 0: 297 | target = "ASYMX" 298 | elif int(jaw) == 1: 299 | target = "ASYMY" 300 | 301 | for bld in cp.BeamLimitingDevicePositionSequence: 302 | if bld.RTBeamLimitingDeviceType == target: 303 | for bank in jb: 304 | if s['name'] == "Position Absolute": 305 | bld.LeafJawPositions[int(bank)] = cmdArg 306 | elif s['name'] == "Position Relative": 307 | if cmdArg[0] == "-": 308 | cmdArgV = cmdArg[1:] 309 | if cmdArg[-1] == "%": 310 | cmdArgV = cmdArgV[:-1] 311 | # Negative Percentage Relative Edit. Decrement the existing value by x%. 312 | bld.LeafJawPositions[int(bank)] = bld.LeafJawPositions[int(bank)] * (1 - (float(cmdArgV)/100)) 313 | else: 314 | # Negative Relative Edit. Increase the exisiting value. 315 | bld.LeafJawPositions[int(bank)] = bld.LeafJawPositions[int(bank)] - float(cmdArgV) 316 | else: 317 | cmdArgV = cmdArg 318 | if cmdArg[0] == "+": 319 | cmdArgV = cmdArg[1:] 320 | if cmdArg[-1] == "%": 321 | cmdArgV = cmdArgV[:-1] 322 | # Positive Percentage Relative Edit. Increment the existing value by x%. 323 | bld.LeafJawPositions[int(bank)] = bld.LeafJawPositions[int(bank)] * (1 + (float(cmdArgV)/100)) 324 | else: 325 | # Positive Relative Edit. Increase the exisiting value. 326 | bld.LeafJawPositions[int(bank)] = bld.LeafJawPositions[int(bank)] + float(cmdArgV) 327 | 328 | # Handle Gantry/Collimator Changes 329 | elif cmdArg[0] == "+": 330 | cmdArg = cmdArg[1:] 331 | if cmdArg[-1] == "%": 332 | cmdArg = cmdArg[:-1] 333 | exec("cp." + s["attr"] + "= (cp." + s["attr"] + " * (1 + (" + cmdArg + "/100))") 334 | else: 335 | exec("cp." + s["attr"] + "= (cp." + s["attr"] + " + " + cmdArg + ")") 336 | elif cmdArg[0] == "-": 337 | cmdArg = cmdArg[1:] 338 | if cmdArg[-1] == "%": 339 | cmdArg = cmdArg[:-1] 340 | exec("cp." + s["attr"] + "= (cp." + s["attr"] + " * (1 - (" + cmdArg + "/100))") 341 | else: 342 | exec("cp." + s["attr"] + "= (cp." + s["attr"] + " - " + cmdArg + ")") 343 | else: 344 | exec("cp." + s["attr"] + "=" + cmdArg) 345 | 346 | """ 347 | Write the output file. 348 | """ 349 | 350 | ds.save_as(args.outFile) 351 | print("Output File " + args.outFile + " created.") 352 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # rtp-mangle Pip Requirements 2 | argparse 3 | shlex 4 | re 5 | pydicom 6 | --------------------------------------------------------------------------------