Source code for cerr.imrtp.imrtp_gui

"""imrtp_gui
~~~~~~~~~~~

pyCERR IMRTP GUI - a Qt port of Matlab CERR's ``IMRTP/IMRTPGui.m``.

Create a GUI to set up IM (intensity-modulated) beam/structure problems and
calculate beamlet dose.

Usage:
    >>> from cerr import plan_container as pc
    >>> from cerr.imrtp import imrtp_gui
    >>> planC = pc.loadDcmDir(r'path/to/dicom')
    >>> imrtp_gui.IMRTPGui(planC)          # blocks until window closed

    or, inside an existing Qt event loop (e.g. napari console):
    >>> gui = imrtp_gui.IMRTPGui(planC, block=False)

Panels (mirroring IMRTPGui.m):
    Beams               beam list, BEV check, New / Equispaced / Delete
    Geometry Preview    axial CT at isocenter with draggable gantry lines
    Select Scan         associated-scan picker
    Structures          goal list: isTarg / marg / sampRate / remove
    Beam Parameters     per-beam fields with auto-calculation checkboxes
    IM Parameters       dose-engine parameters (algorithm, DoseTerm, ...)
    VMC Parameters      Monte-Carlo parameters
    IM Dosimetry set    browse / delete / rename stored IM sets
    File                Recompute/Copy/Overwrite/Revert actions, Go/Show/Exit
    Status              status text + progress bar

Original Matlab author: JRA 4/30/04;  JJW 07/05/06;  APA 10/16/06.
This file is part of pyCERR and is distributed under the terms of the
Lesser GNU Public License (same terms as CERR).
"""

from __future__ import annotations

import copy
import math
import sys
from datetime import date

import numpy as np

try:
    from qtpy import QtWidgets, QtCore, QtGui            # napari installs qtpy
except ImportError:                                       # pragma: no cover
    from PyQt5 import QtWidgets, QtCore, QtGui

from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
from matplotlib.figure import Figure

from cerr.dataclasses import scan as scn
from cerr.dataclasses import structure as structr
from cerr.utils import uid as uid_utils

from . import imrtp_problem as imp
from . import imrtp as imrtp_run


FILE_ACTIONS = ['Recompute & add dosimetry',
                'Recompute & overwrite dosimetry',
                'Copy/Add dosimetry w/o calc.',
                'Overwrite dosimetry w/o calc.',
                'Revert to Original']

STALE_TITLE = 'IMRTP *(beamlets may be stale)'
FRESH_TITLE = 'IMRTP'


def _num(text, fallback=None):
    try:
        return float(text)
    except (TypeError, ValueError):
        return fallback


# ==========================================================================
# Geometry-preview canvas (the "bg" frame in IMRTPGui.m)
# ==========================================================================

[docs] class GeometryPreview(FigureCanvasQTAgg): """Axial CT thumbnail with a 100 cm gantry circle and draggable beam lines; dragging the mouse sets the current beam's gantry angle.""" angleChanged = QtCore.Signal(float) if hasattr(QtCore, 'Signal') \ else QtCore.pyqtSignal(float) dragFinished = QtCore.Signal() if hasattr(QtCore, 'Signal') \ else QtCore.pyqtSignal() def __init__(self, parent=None): fig = Figure(figsize=(3.0, 3.0), facecolor='black') super().__init__(fig) self.setParent(parent) self.ax = fig.add_axes([0, 0, 1, 1]) self.ax.set_facecolor('black') self.ax.set_xticks([]); self.ax.set_yticks([]) self._imHandle = None self._beamLines = [] self._errText = None self._dragging = False self.mpl_connect('button_press_event', self._onPress) self.mpl_connect('motion_notify_event', self._onMotion) self.mpl_connect('button_release_event', self._onRelease) # -- drawing -----------------------------------------------------------
[docs] def refresh(self, planC, im, currentBeam): ax = self.ax ax.clear() ax.set_facecolor('black') ax.set_xticks([]); ax.set_yticks([]) R = 100.0 # gantry circle radius, cm (matches Matlab 100/gridUnits) scanNum = im.assocScanNum(planC) ctOK = False isoXY = (0.0, 0.0) if scanNum is not None and im.beams: beam = im.beams[max(0, currentBeam)] if 0 <= currentBeam < \ len(im.beams) else im.beams[0] xV, yV, zV = planC.scan[scanNum].getScanXYZVals() isoZ = beam.isocenter.z try: isoZres = imp.resolveIsocenter(beam, im, planC) isoZ = isoZres.z isoXY = (isoZres.x, isoZres.y) except Exception: pass if isinstance(isoZ, (int, float)) and \ (min(zV) <= isoZ <= max(zV)): sliceNum = int(np.argmin(np.abs(np.asarray(zV) - isoZ))) sliceM = planC.scan[scanNum].getScanArray()[:, :, sliceNum] ax.imshow(sliceM, cmap='gray', extent=[xV[0], xV[-1], yV[-1], yV[0]], origin='upper', zorder=1) ctOK = True else: ax.text(0, 0, 'An isocenterZ value is\noutside the CT ' 'bounds.', color='white', ha='center', va='center') elif scanNum is not None: # No beams yet: show the middle slice for orientation. xV, yV, zV = planC.scan[scanNum].getScanXYZVals() sliceM = planC.scan[scanNum].getScanArray()[:, :, len(zV) // 2] ax.imshow(sliceM, cmap='gray', extent=[xV[0], xV[-1], yV[-1], yV[0]], origin='upper', zorder=1) ctOK = True # Yellow circle of beam positions, centered on the isocenter. th = np.linspace(0, 2 * np.pi, 360) cx, cy = isoXY if ctOK else (0.0, 0.0) ax.plot(cx + np.sin(th) * R, cy + np.cos(th) * R, color='yellow', lw=1, zorder=2) self._center = (cx, cy) # Beam lines: source point plus two opposite rays (175 / 185 deg). for i, b in enumerate(im.beams): col = 'white' if i == currentBeam else 'blue' self._plotBeamLine(b.gantryAngle, col, R) pad = R * 1.12 ax.set_xlim(cx - pad, cx + pad) ax.set_ylim(cy - pad, cy + pad) ax.set_aspect('equal') self.draw_idle()
def _plotBeamLine(self, angle, color, R): cx, cy = self._center a = math.radians(angle) sx, sy = math.sin(a), math.cos(a) o1 = a + math.radians(175) o2 = a + math.radians(185) xs = [cx + math.sin(o1) * R, cx + sx * R, cx + math.sin(o2) * R] ys = [cy + math.cos(o1) * R, cy + sy * R, cy + math.cos(o2) * R] self.ax.plot(xs, ys, color=color, lw=1, zorder=3) # -- interaction ------------------------------------------------------- def _angleFromEvent(self, event): cx, cy = getattr(self, '_center', (0.0, 0.0)) dx, dy = event.xdata - cx, event.ydata - cy return math.floor(math.degrees(math.atan2(dx, dy)) % 360.0) def _onPress(self, event): if event.inaxes is self.ax and event.xdata is not None: self._dragging = True self.angleChanged.emit(self._angleFromEvent(event)) def _onMotion(self, event): if self._dragging and event.inaxes is self.ax \ and event.xdata is not None: self.angleChanged.emit(self._angleFromEvent(event)) def _onRelease(self, event): if self._dragging: self._dragging = False self.dragFinished.emit()
[docs] def beamGeometry(beam, im, planC): """Compute a beam's display geometry in world coords (cm, pyCERR virtual): ``polylines`` - the divergent field pyramid edges, the iso/exit field rectangles and the central axis (used for the 3D view); ``apex`` and ``corners`` - the pyramid source point and 4 far field corners (used to compute the per-slice beam cross-section in the 2D views). Mirrors Matlab CERR's beam's-eye-view geometry: gantry in the axial plane (couch 0), source at SAD from the isocenter.""" try: iso = imp.resolveIsocenter(beam, im, planC) if any(isinstance(v, str) for v in (iso.x, iso.y, iso.z)): return None I = np.array([float(iso.x), float(iso.y), float(iso.z)]) except Exception: return None sad = float(beam.isodistance) or 100.0 g = math.radians(beam.gantryAngle) # source is at +SAD along (sin g, cos g) from iso (gantry 0 = anterior) S = I + np.array([sad * math.sin(g), sad * math.cos(g), 0.0]) d = I - S n = np.linalg.norm(d) if n < 1e-6: return None d = d / n # beam axis, source -> iso u = np.array([math.cos(g), -math.sin(g), 0.0]) # lateral (axial plane) v = np.array([0.0, 0.0, 1.0]) # superior-inferior # field half-size at iso: from the beamlet grid if available, else 5 cm def _half(posV, fallback=5.0): if posV is not None and len(np.atleast_1d(posV)): return float(np.max(np.abs(posV))) or fallback return fallback w = _half(getattr(beam, 'xPBPosV', None)) h = _half(getattr(beam, 'yPBPosV', None)) exitC = S + d * (2.0 * sad) # exit plane at 2*SAD signs = [(+1, +1), (+1, -1), (-1, -1), (-1, +1)] cIso = [I + sx * w * u + sy * h * v for sx, sy in signs] cExit = [exitC + sx * 2 * w * u + sy * 2 * h * v for sx, sy in signs] polys = [] for ce in cExit: # pyramid edges source -> corner polys.append(np.array([S, ce])) polys.append(np.array(cExit + [cExit[0]])) # exit rectangle polys.append(np.array(cIso + [cIso[0]])) # iso field rectangle polys.append(np.array([S, exitC])) # central axis # pyramid geometry for the per-slice 2D cross-section return {'polylines': polys, 'apex': S, 'corners': np.array(cExit)}
[docs] def beamPolylines(beam, im, planC): """Backward-compatible: just the polylines of :func:`beamGeometry`.""" geo = beamGeometry(beam, im, planC) return geo['polylines'] if geo else []
# ========================================================================== # Main GUI # ==========================================================================
[docs] class IMRTPGuiWindow(QtWidgets.QMainWindow): def __init__(self, planC, im=None, saveIndex=None, parent=None, viewer=None): super().__init__(parent) if planC is None or len(planC.scan) == 0: raise ValueError('Load a plan into planC before opening IMRTPGui.') self.planC = planC self.viewer = viewer # optional PyCerrViewer for dose/beam display self.currentBeam = -1 self.saveIndex = -1 if saveIndex is None else saveIndex if im is not None: self.im = im self.currentBeam = 0 if im.beams else -1 else: self.im = imp.initIMRTProblem(planC) # Make engine default match what is actually available; # prefer the QIB pencil beam, as in Matlab CERR. engines = imrtp_run.availableEngines() if 'QIB' in engines: self.im.params.algorithm = 'QIB' else: self.im.params.algorithm = engines[0] if engines else 'QIB' self.setWindowTitle(FRESH_TITLE) self.resize(1180, 720) self._build() self._refreshAll() if not imrtp_run.availableEngines(): self.statusbarMsg('No dose engines registered. QIB/VMC ' 'calculations disabled. See cerr.imrtp docs.')
[docs] def closeEvent(self, event): # remove beam overlays from the viewer when IMRTP closes if self.viewer is not None: try: self.viewer.setBeams([]) except Exception: # noqa: BLE001 pass super().closeEvent(event)
# ------------------------------------------------------------------ UI def _build(self): central = QtWidgets.QWidget(self) self.setCentralWidget(central) grid = QtWidgets.QGridLayout(central) # ---- Beams frame ("bl") ---- blBox = QtWidgets.QGroupBox('Beams') blLay = QtWidgets.QVBoxLayout(blBox) hdr = QtWidgets.QHBoxLayout() hdr.addStretch(1) hdr.addWidget(QtWidgets.QLabel("Beam's Eye View")) blLay.addLayout(hdr) self.beamList = QtWidgets.QTableWidget(0, 3) self.beamList.setHorizontalHeaderLabels(['#', 'Description', 'BEV']) self.beamList.horizontalHeader().setStretchLastSection(False) self.beamList.setColumnWidth(0, 28) self.beamList.setColumnWidth(1, 150) self.beamList.setColumnWidth(2, 44) self.beamList.verticalHeader().setVisible(False) self.beamList.setSelectionBehavior( QtWidgets.QAbstractItemView.SelectRows) self.beamList.setSelectionMode( QtWidgets.QAbstractItemView.SingleSelection) self.beamList.setEditTriggers( QtWidgets.QAbstractItemView.NoEditTriggers) self.beamList.itemSelectionChanged.connect(self.selectBeam) blLay.addWidget(self.beamList) btnLay = QtWidgets.QHBoxLayout() for txt, fn in (('New', self.newBeam), ('Equispaced', self.newEquispaced), ('Delete', self.delBeam)): b = QtWidgets.QPushButton(txt) b.clicked.connect(fn) btnLay.addWidget(b) blLay.addLayout(btnLay) grid.addWidget(blBox, 0, 0) # ---- Geometry preview ("bg") ---- bgBox = QtWidgets.QGroupBox('Geometry Preview') bgLay = QtWidgets.QVBoxLayout(bgBox) self.preview = GeometryPreview(bgBox) self.preview.angleChanged.connect(self.previewAngleChanged) self.preview.dragFinished.connect(self.previewDragFinished) bgLay.addWidget(self.preview) grid.addWidget(bgBox, 0, 1) # ---- Right column: scan + structures ---- rightCol = QtWidgets.QVBoxLayout() ssScanBox = QtWidgets.QGroupBox('Select Scan') ssScanLay = QtWidgets.QHBoxLayout(ssScanBox) self.scanPop = QtWidgets.QComboBox() self.scanPop.activated.connect(self.selScan) ssScanLay.addWidget(self.scanPop) rightCol.addWidget(ssScanBox) ssBox = QtWidgets.QGroupBox('Structures') ssLay = QtWidgets.QVBoxLayout(ssBox) addLay = QtWidgets.QHBoxLayout() addLay.addWidget(QtWidgets.QLabel('Add:')) self.addStructPop = QtWidgets.QComboBox() self.addStructPop.activated.connect(self.addGoal) addLay.addWidget(self.addStructPop, 1) ssLay.addLayout(addLay) self.goalTable = QtWidgets.QTableWidget(0, 5) self.goalTable.setSelectionMode( QtWidgets.QAbstractItemView.NoSelection) self.goalTable.setHorizontalHeaderLabels( ['Index / Name', 'isTarg', 'marg', 'sampRate', '']) self.goalTable.verticalHeader().setVisible(False) self.goalTable.setColumnWidth(0, 130) self.goalTable.setColumnWidth(1, 46) self.goalTable.setColumnWidth(2, 44) self.goalTable.setColumnWidth(3, 60) self.goalTable.setColumnWidth(4, 28) ssLay.addWidget(self.goalTable) rightCol.addWidget(ssBox, 1) grid.addLayout(rightCol, 0, 2) # ---- Beam parameters ("bp") ---- bpBox = QtWidgets.QGroupBox('Beam Parameters ' '(checkboxes toggle auto field ' 'calculation)') bpGrid = QtWidgets.QGridLayout(bpBox) self.bpWidgets = {} # fieldName tuple -> (autoChk|None, editor) nFields = len(imp.BEAM_FIELD_NAMES) half = (nFields + 1) // 2 for i, fName in enumerate(imp.BEAM_FIELD_NAMES): col = 0 if i < half else 3 row = i if i < half else i - half label = QtWidgets.QLabel(''.join(fName)) bpGrid.addWidget(label, row, col) chk = None if not imp.BEAM_FIELD_EDITABLE[i]: chk = QtWidgets.QCheckBox() chk.setChecked(True) chk.setToolTip('Auto-calculate this field') chk.stateChanged.connect(self.autoCheckChanged) bpGrid.addWidget(chk, row, col + 1) choices = imp.BEAM_FIELD_CHOICES.get(fName) if choices: ed = QtWidgets.QComboBox() ed.addItems([str(c) for c in choices]) ed.activated.connect( lambda _=None, f=fName: self.beamParamChanged(f)) else: ed = QtWidgets.QLineEdit() ed.editingFinished.connect( lambda f=fName: self.beamParamChanged(f)) bpGrid.addWidget(ed, row, col + 2) self.bpWidgets[fName] = (chk, ed) grid.addWidget(bpBox, 1, 0, 1, 2) # ---- IM parameters ("ip") ---- ipBox = QtWidgets.QGroupBox('IM Parameters') ipGrid = QtWidgets.QGridLayout(ipBox) self.ipWidgets = {} for i, pName in enumerate(imp.PARAM_NAMES): ipGrid.addWidget(QtWidgets.QLabel(''.join(pName)), i, 0) if pName == ('algorithm',): ed = QtWidgets.QComboBox() ed.addItems(imrtp_run.availableEngines() or ['QIB']) ed.activated.connect( lambda _=None, p=pName: self.imParamChanged(p)) elif pName in imp.PARAM_CHOICES: ed = QtWidgets.QComboBox() ed.addItems(imp.PARAM_CHOICES[pName]) ed.activated.connect( lambda _=None, p=pName: self.imParamChanged(p)) else: ed = QtWidgets.QLineEdit() ed.editingFinished.connect( lambda p=pName: self.imParamChanged(p)) ipGrid.addWidget(ed, i, 1) self.ipWidgets[pName] = ed grid.addWidget(ipBox, 1, 2) # ---- VMC parameters ("mc") column ---- mcBox = QtWidgets.QGroupBox('VMC Parameters') mcGrid = QtWidgets.QGridLayout(mcBox) self.mcWidgets = {} for i, pName in enumerate(imp.MC_PARAM_NAMES): mcGrid.addWidget(QtWidgets.QLabel(''.join(pName)), i, 0) if pName in imp.MC_PARAM_CHOICES: ed = QtWidgets.QComboBox() ed.addItems(imp.MC_PARAM_CHOICES[pName]) ed.activated.connect( lambda _=None, p=pName: self.mcParamChanged(p)) else: ed = QtWidgets.QLineEdit() ed.editingFinished.connect( lambda p=pName: self.mcParamChanged(p)) mcGrid.addWidget(ed, i, 1) self.mcWidgets[pName] = ed grid.addWidget(mcBox, 0, 3, 3, 1) # The VMC panel is only relevant for the VMC++ engine; visibility # is driven by the selected algorithm (see # _updateParamPanelVisibility). self.ipBox = ipBox self.mcBox = mcBox # ---- Bottom row: IM browser ("ib"), File ("us"), Status ("wb") ---- bottom = QtWidgets.QHBoxLayout() wbBox = QtWidgets.QGroupBox('Status') wbLay = QtWidgets.QVBoxLayout(wbBox) self.statusText = QtWidgets.QLabel('') self.waitBar = QtWidgets.QProgressBar() self.waitBar.setRange(0, 100) self.waitBar.setValue(0) wbLay.addWidget(self.statusText) wbLay.addWidget(self.waitBar) bottom.addWidget(wbBox, 2) ibBox = QtWidgets.QGroupBox('IM Dosimetry set') ibLay = QtWidgets.QGridLayout(ibBox) self.browsePop = QtWidgets.QComboBox() self.browsePop.activated.connect(self.browseIM) ibLay.addWidget(self.browsePop, 0, 0, 1, 2) delBtn = QtWidgets.QPushButton('Delete') delBtn.clicked.connect(self.deleteIM) ibLay.addWidget(delBtn, 0, 2) ibLay.addWidget(QtWidgets.QLabel('Rename'), 1, 0) self.renameEdit = QtWidgets.QLineEdit() self.renameEdit.editingFinished.connect(self.renameIM) ibLay.addWidget(self.renameEdit, 1, 1, 1, 2) bottom.addWidget(ibBox, 2) usBox = QtWidgets.QGroupBox('File') usLay = QtWidgets.QGridLayout(usBox) self.filePop = QtWidgets.QComboBox() self.filePop.addItems(FILE_ACTIONS) usLay.addWidget(self.filePop, 0, 0, 1, 2) goBtn = QtWidgets.QPushButton('Go') goBtn.clicked.connect(self.save) usLay.addWidget(goBtn, 0, 2) showBtn = QtWidgets.QPushButton('Show') showBtn.clicked.connect(self.showDose) usLay.addWidget(showBtn, 1, 1) exitBtn = QtWidgets.QPushButton('Exit') exitBtn.clicked.connect(self.close) usLay.addWidget(exitBtn, 1, 2) bottom.addWidget(usBox, 2) grid.addLayout(bottom, 2, 0, 1, 3) grid.setColumnStretch(0, 3) grid.setColumnStretch(1, 3) grid.setColumnStretch(2, 3) grid.setColumnStretch(3, 2) grid.setRowStretch(0, 4) grid.setRowStretch(1, 4) grid.setRowStretch(2, 1) # ------------------------------------------------------------ helpers
[docs] def statusbarMsg(self, msg): self.statusText.setText(str(msg))
[docs] def waitbar(self, frac): self.waitBar.setValue(int(round(100 * max(0.0, min(1.0, frac))))) QtWidgets.QApplication.processEvents()
[docs] def markStale(self): self.setWindowTitle(STALE_TITLE) self.im.isFresh = False
def _structsInScan(self): """Structure objects (and absolute indices) on the associated scan.""" scanNum = self.im.assocScanNum(self.planC) out = [] for i, s in enumerate(self.planC.structure): try: if scn.getScanNumFromUID(s.assocScanUID, self.planC) == scanNum: out.append((i, s)) except Exception: pass return out # ----------------------------------------------------------- refreshes def _refreshAll(self): self.refreshScan() self.refreshStructs() self.refreshBeams() self.refreshPreview() self.refreshBeamParams() self.refreshIMParams() self.refreshMCParams() self.refreshBrowser() self._updateViewerBeams() # keep BEV overlays in sync with geometry
[docs] def refreshScan(self): self.scanPop.blockSignals(True) self.scanPop.clear() for i, s in enumerate(self.planC.scan): sType = getattr(s.scanInfo[0], 'imageType', '') or \ getattr(s, 'scanType', '') self.scanPop.addItem('%d %s' % (i + 1, sType)) scanNum = self.im.assocScanNum(self.planC) if scanNum is not None: self.scanPop.setCurrentIndex(scanNum) self.scanPop.blockSignals(False) # structure pulldown for this scan self.addStructPop.blockSignals(True) self.addStructPop.clear() for _, s in self._structsInScan(): self.addStructPop.addItem(s.structureName) self.addStructPop.setCurrentIndex(-1) self.addStructPop.blockSignals(False)
[docs] def refreshStructs(self): tbl = self.goalTable tbl.blockSignals(True) tbl.clearContents() tbl.setRowCount(0) tbl.setRowCount(len(self.im.goals)) for i, g in enumerate(self.im.goals): try: absNum = structr.getStructNumFromUID(g.strUID, self.planC) rgb = structr.getColorForStructNum(absNum) bg = QtGui.QColor(int(rgb[0]), int(rgb[1]), int(rgb[2])) idxStr = str(absNum + 1) except Exception: bg = self.palette().color(QtGui.QPalette.Window) idxStr = 'N-A' nameItem = QtWidgets.QTableWidgetItem( ('%s. %s' % (idxStr, g.structName))[:20]) nameItem.setBackground(bg) lum = 0.299 * bg.red() + 0.587 * bg.green() + 0.114 * bg.blue() nameItem.setForeground(QtGui.QColor( 'black' if lum > 128 else 'white')) nameItem.setFlags(QtCore.Qt.ItemIsEnabled) tbl.setItem(i, 0, nameItem) tbl.setRowHeight(i, 26) chk = QtWidgets.QCheckBox() chk.setChecked(g.is_target()) chk.stateChanged.connect( lambda _=None, n=i: self.targBoxClicked(n)) w = QtWidgets.QWidget() l = QtWidgets.QHBoxLayout(w) l.setContentsMargins(0, 0, 0, 0) l.setAlignment(QtCore.Qt.AlignCenter) l.addWidget(chk) tbl.setCellWidget(i, 1, w) margEd = QtWidgets.QLineEdit(str(g.PBMargin)) margEd.editingFinished.connect( lambda n=i, e=margEd: self.pbMarginText(n, e)) tbl.setCellWidget(i, 2, margEd) sampEd = QtWidgets.QLineEdit(str(g.xySampleRate)) sampEd.editingFinished.connect( lambda n=i, e=sampEd: self.strSampleRate(n, e)) tbl.setCellWidget(i, 3, sampEd) delBtn = QtWidgets.QPushButton('-') delBtn.setFixedWidth(24) delBtn.clicked.connect(lambda _=None, n=i: self.delGoal(n)) tbl.setCellWidget(i, 4, delBtn) tbl.blockSignals(False)
[docs] def refreshBeams(self): tbl = self.beamList tbl.blockSignals(True) tbl.setRowCount(len(self.im.beams)) for i, b in enumerate(self.im.beams): numItem = QtWidgets.QTableWidgetItem('%d.' % (i + 1)) numItem.setFlags(QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable) tbl.setItem(i, 0, numItem) nameItem = QtWidgets.QTableWidgetItem(b.beamDescription) nameItem.setFlags(QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable) if i == self.currentBeam: nameItem.setBackground(QtGui.QColor('white')) nameItem.setForeground(QtGui.QColor('black')) else: nameItem.setBackground(QtGui.QColor('black')) nameItem.setForeground(QtGui.QColor('white')) tbl.setItem(i, 1, nameItem) if tbl.cellWidget(i, 2) is None: chk = QtWidgets.QCheckBox() chk.setToolTip("Show beam's eye view in viewer") chk.stateChanged.connect(self._updateViewerBeams) w = QtWidgets.QWidget() l = QtWidgets.QHBoxLayout(w) l.setContentsMargins(0, 0, 0, 0) l.setAlignment(QtCore.Qt.AlignCenter) l.addWidget(chk) tbl.setCellWidget(i, 2, w) if 0 <= self.currentBeam < len(self.im.beams): tbl.selectRow(self.currentBeam) tbl.blockSignals(False)
def _bevChecked(self, i): """Is the BEV checkbox ticked for beam row i?""" w = self.beamList.cellWidget(i, 2) if w is None: return False chk = w.findChild(QtWidgets.QCheckBox) return chk is not None and chk.isChecked() def _updateViewerBeams(self, *_): """Push the geometry of all BEV-checked beams to the pyCERR viewer.""" if self.viewer is None: if any(self._bevChecked(i) for i in range(len(self.im.beams))): self.statusbarMsg("Beam's eye view needs the pyCERR viewer " "(open IMRTP from the viewer's Tools menu).") return palette = [(0.20, 0.85, 0.90), (0.95, 0.75, 0.20), (0.40, 0.90, 0.40), (0.95, 0.45, 0.85), (0.55, 0.65, 1.00), (0.95, 0.55, 0.35)] beams = [] for i, b in enumerate(self.im.beams): if not self._bevChecked(i): continue geo = beamGeometry(b, self.im, self.planC) if geo: geo['color'] = palette[i % len(palette)] beams.append(geo) try: self.viewer.setBeams(beams) except Exception as e: # noqa: BLE001 self.statusbarMsg('Could not draw beams: %s' % e)
[docs] def refreshPreview(self): self.preview.refresh(self.planC, self.im, self.currentBeam)
[docs] def refreshBeamParams(self): haveBeam = 0 <= self.currentBeam < len(self.im.beams) beam = self.im.beams[self.currentBeam] if haveBeam else None for i, fName in enumerate(imp.BEAM_FIELD_NAMES): chk, ed = self.bpWidgets[fName] ed.blockSignals(True) ed.setEnabled(haveBeam) if chk is not None: chk.setEnabled(haveBeam) if not haveBeam: if isinstance(ed, QtWidgets.QLineEdit): ed.setText('') ed.blockSignals(False) continue val = self._getBeamField(beam, fName) if isinstance(ed, QtWidgets.QComboBox): ix = ed.findText(str(val)) if ix < 0: ed.addItem(str(val)) ix = ed.count() - 1 ed.setCurrentIndex(ix) else: if isinstance(val, float): val = round(val, 4) ed.setText('' if val is None else str(val)) auto = chk is not None and chk.isChecked() ed.setReadOnly(auto) ed.setStyleSheet('color: gray;' if auto else '') ed.blockSignals(False)
[docs] def refreshIMParams(self): p = self.im.params vals = {('algorithm',): p.algorithm, ('DoseTerm',): p.DoseTerm, ('ScatterMethod',): p.ScatterMethod, ('Scatter', 'Threshold'): p.Scatter.Threshold, ('Scatter', 'RandomStep'): p.Scatter.RandomStep, ('xyDownsampleIndex',): p.xyDownsampleIndex, ('numCTSamplePts',): p.numCTSamplePts, ('cutoffDistance',): p.cutoffDistance} for pName, ed in self.ipWidgets.items(): ed.blockSignals(True) v = vals[pName] if isinstance(ed, QtWidgets.QComboBox): ix = ed.findText(str(v)) if ix < 0 and v: ed.addItem(str(v)); ix = ed.count() - 1 ed.setCurrentIndex(max(ix, 0)) else: ed.setText(str(v)) ed.blockSignals(False)
[docs] def refreshMCParams(self): vmc = self.im.params.VMC for pName, ed in self.mcWidgets.items(): ed.blockSignals(True) v = getattr(vmc, pName[0]) if isinstance(ed, QtWidgets.QComboBox): ix = ed.findText(str(v)) ed.setCurrentIndex(max(ix, 0)) else: ed.setText('' if v is None else str(v)) ed.blockSignals(False) self._updateParamPanelVisibility()
def _updateParamPanelVisibility(self): """Show the VMC Parameters panel only when the VMC++ engine is selected; the IM Parameters panel (QIB / pencil-beam settings) is always shown and QIB is the default algorithm.""" alg = str(self.im.params.algorithm or '').upper() self.mcBox.setVisible('VMC' in alg)
[docs] def refreshBrowser(self): imList = imp.getIMList(self.planC) self.browsePop.blockSignals(True) self.browsePop.clear() for s in imList: self.browsePop.addItem(s.name) self.browsePop.addItem('IM doseSet %d' % (len(imList) + 1)) if 0 <= self.saveIndex < len(imList): self.browsePop.setCurrentIndex(self.saveIndex) else: self.browsePop.setCurrentIndex(self.browsePop.count() - 1) self.browsePop.blockSignals(False) self.renameEdit.setText(self.im.name)
# --------------------------------------------------------- beam fields @staticmethod def _getBeamField(beam, fName): obj = beam for part in fName: obj = getattr(obj, part) return obj @staticmethod def _setBeamField(beam, fName, value): obj = beam for part in fName[:-1]: obj = getattr(obj, part) setattr(obj, fName[-1], value)
[docs] def beamParamChanged(self, fName): if not (0 <= self.currentBeam < len(self.im.beams)): return beam = self.im.beams[self.currentBeam] chk, ed = self.bpWidgets[fName] if chk is not None and chk.isChecked(): return # auto field, ignore edits idx = imp.BEAM_FIELD_NAMES.index(fName) if isinstance(ed, QtWidgets.QComboBox): text = ed.currentText() else: text = ed.text() if imp.BEAM_FIELD_IS_NUM[idx]: val = _num(text) if val is None and text.upper() != 'COM': self.statusbarMsg('"%s" requires a numeric value.' % ''.join(fName)) self.refreshBeamParams() return val = text.upper() if val is None else val else: val = text self._setBeamField(beam, fName, val) beam.beamlets = [] # geometry changed -> stale self.markStale() # keep dependent auto fields in sync try: auto = {'isocenter': self._autoChecked('isocenter'), 'dateOfCreation': self._autoChecked('dateOfCreation'), 'sourceRel': self._autoChecked('sourceRel')} imp.conditionBeam(beam, self.im, self.planC, auto) except Exception as e: self.statusbarMsg(str(e)) self.refreshBeams() self.refreshPreview() self.refreshBeamParams()
def _autoChecked(self, what): if what == 'isocenter': keys = [('isocenter', 'x'), ('isocenter', 'y'), ('isocenter', 'z')] elif what == 'dateOfCreation': keys = [('dateOfCreation',)] else: keys = [('zRel',), ('xRel',), ('yRel',)] return all(self.bpWidgets[k][0].isChecked() for k in keys if self.bpWidgets[k][0] is not None)
[docs] def autoCheckChanged(self): self.refreshBeamParams()
# -------------------------------------------------------------- beams
[docs] def newBeam(self): nB = len(self.im.beams) if 0 <= self.currentBeam < nB: beam = copy.deepcopy(self.im.beams[self.currentBeam]) else: beam = imp.createDefaultBeam(nB + 1, self.planC) beam.beamNum = nB + 1 beam.gantryAngle = 0.0 beam.beamDescription = 'Beam %d' % (nB + 1) beam.beamUID = uid_utils.createUID('BEAM') beam.beamlets = [] self.im.beams.append(beam) self.currentBeam = nB self.markStale() self.refreshBeams() self.refreshPreview() self.refreshBeamParams()
[docs] def newEquispaced(self): n, ok = QtWidgets.QInputDialog.getInt( self, 'Beam Creation', 'Add how many equispaced beams?', 5, 1, 50) if not ok: return start, ok = QtWidgets.QInputDialog.getInt( self, 'Beam Creation', 'Starting point? (0-359)', 0, 0, 359) if not ok: return template = self.im.beams[self.currentBeam] \ if 0 <= self.currentBeam < len(self.im.beams) else None firstNew = len(self.im.beams) imp.addEquispacedBeams(self.im, n, start, self.planC, template) self.currentBeam = firstNew self.markStale() self.refreshBeams() self.refreshPreview() self.refreshBeamParams()
[docs] def delBeam(self): if not (0 <= self.currentBeam < len(self.im.beams)): return del self.im.beams[self.currentBeam] for i, b in enumerate(self.im.beams): b.beamNum = i + 1 self.currentBeam = min(self.currentBeam, len(self.im.beams) - 1) self.markStale() self.refreshBeams() self.refreshPreview() self.refreshBeamParams()
[docs] def selectBeam(self): rows = self.beamList.selectionModel().selectedRows() if rows: self.currentBeam = rows[0].row() self.refreshBeams() self.refreshPreview() self.refreshBeamParams()
[docs] def previewAngleChanged(self, angle): if not (0 <= self.currentBeam < len(self.im.beams)): return beam = self.im.beams[self.currentBeam] beam.gantryAngle = float(angle) beam.beamlets = [] self.markStale() try: imp.conditionBeam(beam, self.im, self.planC, {'isocenter': self._autoChecked('isocenter'), 'dateOfCreation': False, 'sourceRel': self._autoChecked('sourceRel')}) except Exception: pass self.refreshPreview() self.refreshBeamParams()
[docs] def previewDragFinished(self): self.refreshBeams() self._updateViewerBeams() # gantry angle changed -> redraw beams
# --------------------------------------------------------------- scan
[docs] def selScan(self, scanIdx): if scanIdx == self.im.assocScanNum(self.planC): return self.im.assocScanUID = self.planC.scan[scanIdx].scanUID self.im.goals = [] for b in self.im.beams: b.beamlets = [] b.beamUID = uid_utils.createUID('BEAM') self.markStale() self.refreshScan() self.refreshStructs() self.refreshPreview()
# --------------------------------------------------------------- goals
[docs] def addGoal(self, relStrNum): if relStrNum < 0: return imp.addGoal(self.im, relStrNum, self.planC) self.markStale() self.refreshStructs() self.addStructPop.setCurrentIndex(-1)
[docs] def delGoal(self, goalNum): if not (0 <= goalNum < len(self.im.goals)): return del self.im.goals[goalNum] for b in self.im.beams: # beamlets indexed by goal -> stale b.beamlets = [] self.markStale() self.refreshStructs()
[docs] def pbMarginText(self, goalNum, editor): val = _num(editor.text()) if val is None: return self.im.goals[goalNum].PBMargin = val for b in self.im.beams: b.beamlets = [] self.markStale()
[docs] def strSampleRate(self, goalNum, editor): val = _num(editor.text()) if val is None: return self.im.goals[goalNum].xySampleRate = int(val) for b in self.im.beams: b.beamlets = [] self.markStale()
[docs] def targBoxClicked(self, goalNum): w = self.goalTable.cellWidget(goalNum, 1) chk = w.findChild(QtWidgets.QCheckBox) self.im.goals[goalNum].isTarget = 'Yes' if chk.isChecked() else 'No' # Beams whose isocenter depends on the target COM are now stale. for b in self.im.beams: if any(isinstance(v, str) for v in (b.isocenter.x, b.isocenter.y, b.isocenter.z)): b.beamlets = [] self.markStale() self.refreshPreview() self.refreshBeamParams()
# ------------------------------------------------------------- params
[docs] def imParamChanged(self, pName): ed = self.ipWidgets[pName] text = ed.currentText() if isinstance(ed, QtWidgets.QComboBox) \ else ed.text() p = self.im.params idx = imp.PARAM_NAMES.index(pName) val = _num(text, text) if imp.PARAM_IS_NUM[idx] else text if pName == ('Scatter', 'Threshold'): p.Scatter.Threshold = val elif pName == ('Scatter', 'RandomStep'): p.Scatter.RandomStep = val else: setattr(p, pName[0], val) if pName == ('algorithm',): self._updateParamPanelVisibility() self.markStale()
[docs] def mcParamChanged(self, pName): ed = self.mcWidgets[pName] text = ed.currentText() if isinstance(ed, QtWidgets.QComboBox) \ else ed.text() idx = imp.MC_PARAM_NAMES.index(pName) val = _num(text, text) if imp.MC_PARAM_IS_NUM[idx] else text setattr(self.im.params.VMC, pName[0], val) self.markStale()
# ------------------------------------------------------------ browser
[docs] def browseIM(self, idx): imList = imp.getIMList(self.planC) if idx < len(imList): self.im = imList[idx] self.saveIndex = idx self.currentBeam = 0 if self.im.beams else -1 else: self.im = imp.initIMRTProblem(self.planC) self.saveIndex = -1 self.currentBeam = -1 self.setWindowTitle(FRESH_TITLE if self.im.isFresh else STALE_TITLE) self._refreshAll()
[docs] def deleteIM(self): imList = imp.getIMList(self.planC) idx = self.browsePop.currentIndex() if idx >= len(imList): return if QtWidgets.QMessageBox.question( self, 'Delete IM', 'Delete "%s" from the plan?' % imList[idx].name) != QtWidgets.QMessageBox.Yes: return del imList[idx] if self.saveIndex == idx: self.saveIndex = -1 elif self.saveIndex > idx: self.saveIndex -= 1 self.refreshBrowser() self.statusbarMsg('IM set deleted.')
[docs] def renameIM(self): newName = self.renameEdit.text().strip() if newName: self.im.name = newName self.refreshBrowser()
# -------------------------------------------------------- save / run
[docs] def save(self): """'Go' button: dispatch on the File action popup.""" action = self.filePop.currentText() try: if action == 'Recompute & add dosimetry': self._recompute() self.saveIndex = imp.saveIMToPlan(self.im, self.planC, None) elif action == 'Recompute & overwrite dosimetry': self._recompute() self.saveIndex = imp.saveIMToPlan( self.im, self.planC, self.saveIndex if self.saveIndex >= 0 else None) elif action == 'Copy/Add dosimetry w/o calc.': self.saveIndex = imp.saveIMToPlan( copy.deepcopy(self.im), self.planC, None) elif action == 'Overwrite dosimetry w/o calc.': self.saveIndex = imp.saveIMToPlan( self.im, self.planC, self.saveIndex if self.saveIndex >= 0 else None) elif action == 'Revert to Original': imList = imp.getIMList(self.planC) if 0 <= self.saveIndex < len(imList): self.im = imList[self.saveIndex] else: self.im = imp.initIMRTProblem(self.planC) self.currentBeam = 0 if self.im.beams else -1 self._refreshAll() self.statusbarMsg('Reverted.') return self.setWindowTitle(FRESH_TITLE if self.im.isFresh else STALE_TITLE) self.refreshBrowser() self.statusbarMsg('Done: %s' % action) except Exception as e: self.statusbarMsg('Error: %s' % e) QtWidgets.QMessageBox.warning(self, 'IMRTP', str(e)) finally: self.waitbar(0)
def _recompute(self): def cb(msg, frac): self.statusbarMsg(msg) if frac is not None: self.waitbar(frac) dose3M = imrtp_run.runIMRTP(self.im, self.planC, cb) self._lastDoseNum = imrtp_run.doseToPlanC(dose3M, self.im, self.planC) self.setWindowTitle(FRESH_TITLE) self.statusbarMsg('Dose added to planC.dose[%d].' % self._lastDoseNum)
[docs] def showDose(self): """'Show' button: display the computed dose. Uses the pyCERR Qt viewer (pycerr_gui) when one is attached; otherwise falls back to the napari viewer.""" doseNum = getattr(self, '_lastDoseNum', None) if doseNum is None and len(self.planC.dose) > 0: doseNum = len(self.planC.dose) - 1 if doseNum is None: self.statusbarMsg('No dose computed yet; press Go first.') return if self.viewer is not None: try: self.viewer.display_dose(doseNum) self.statusbarMsg('Dose %d shown in the pyCERR viewer.' % (doseNum + 1)) return except Exception as e: # fall through to napari self.statusbarMsg('pyCERR viewer unavailable: %s' % e) try: from cerr import viewer as vwr scanNum = self.im.assocScanNum(self.planC) or 0 strNums = [structr.getStructNumFromUID(g.strUID, self.planC) for g in self.im.goals] vwr.showNapari(self.planC, scan_nums=[scanNum], struct_nums=strNums, dose_nums=[doseNum], displayMode='2d') except Exception as e: self.statusbarMsg('Viewer unavailable: %s' % e)
# ========================================================================== # Entry point # ==========================================================================
[docs] def IMRTPGui(planC, im=None, saveIndex=None, block=True, viewer=None): """Open the IMRTP GUI for ``planC``. Args: planC: pyCERR plan container with at least one scan loaded. im: optional existing :class:`~cerr.imrtp.imrtp_problem.IMRTProblem` to edit (analog of ``IMRTPGui('init', IM, index)`` in Matlab). saveIndex: index of ``im`` within ``planC.im`` when editing a stored set. block: when True (script usage) run the Qt event loop until the window is closed; when False (napari / IPython with an active event loop) just show the window and return it. viewer: optional ``PyCerrViewer`` to show the computed dose and the beam's-eye-view overlays in (instead of opening napari). Returns: IMRTPGuiWindow: the window instance. """ app = QtWidgets.QApplication.instance() owns_app = app is None if owns_app: app = QtWidgets.QApplication(sys.argv) win = IMRTPGuiWindow(planC, im=im, saveIndex=saveIndex, viewer=viewer) win.show() if block and owns_app: app.exec_() if hasattr(app, 'exec_') else app.exec() return win