Skip to content

Commit

Permalink
add guards for indexing when drawing on CR, busycursor when updating …
Browse files Browse the repository at this point in the history
…display on W/L,Handle interop elements (#304)

* added RT Ion Plan SOP Class as one of the rtplan data types in the allowed_classes dictionary

* fixed comment for RT Ion Plan dictionary entry in allowed_classes

* add guards for indexing when drawing on to CR
add busycursor context when updating window/level using W/L bars (doesn't display on MacOS)

* Error out on missing StudyID
Commented example on how to error out for missing IOD Specific element (CT Slice Location)
Workaround for missing SliceLocation

* refactor to extract method for checking for missing element values
using HTML <br> to tidy up the QMessageBox
calling QMessageBox.critical() rather than about()
  • Loading branch information
sjswerdloff authored Oct 25, 2023
1 parent 4b41249 commit dc1f91a
Show file tree
Hide file tree
Showing 4 changed files with 114 additions and 10 deletions.
72 changes: 71 additions & 1 deletion src/Model/ImageLoading.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@
durability of the process).
"""
import collections
import logging
import math
import re
import os
from multiprocessing import Queue, Process

import numpy as np
from dicompylercore import dvhcalc
from pydicom import dcmread
from pydicom import dcmread, DataElement
from pydicom.errors import InvalidDicomError

allowed_classes = {
Expand Down Expand Up @@ -81,14 +83,41 @@
}
}

all_iods_required_attributes = [ "StudyID" ]

iod_specific_required_attributes = {
# CT must have SliceLocation
"1.2.840.10008.5.1.4.1.1.2": [ "SliceLocation" ],
}

class NotRTSetError(Exception):
"""Error to indicate that the types of data required for the intended use are not present
Args:
Exception (_type_): _description_
"""
pass


class NotAllowedClassError(Exception):
"""Error to indicate that the SOP Class of the object is not supported by OnkoDICOM
Args:
Exception (_type_): _description_
"""
pass

class NotInteroperableWithOnkoDICOMError(Exception):
"""OnkoDICOM requires certain DICOM elements/values that
are not DICOM Type 1 (required).
For data that lack said elements (DICOM Type 3)
or lack values in those elements (DICOM Type 2)
raise an exception/error to indicate the data is lacking
Args:
Exception (_type_): _description_
"""


def get_datasets(filepath_list, file_type=None):
"""
Expand Down Expand Up @@ -116,6 +145,17 @@ def get_datasets(filepath_list, file_type=None):
else:
if read_file.SOPClassUID in allowed_classes:
allowed_class = allowed_classes[read_file.SOPClassUID]
is_interoperable = True
is_missing = missing_interop_elements(read_file)
is_interoperable = len(is_missing) == 0
if not is_interoperable:
missing_elements = ', '.join(is_missing)
error_message = "Interoperability failure:"
error_message += f"<br>{file} "
error_message += "<br>is missing " + missing_elements
error_message += f"<br>needed for SOP Class {read_file.SOPClassUID}"
logging.error(error_message)
raise NotInteroperableWithOnkoDICOMError(error_message)
if allowed_class["sliceable"]:
slice_name = slice_count
slice_count += 1
Expand Down Expand Up @@ -145,6 +185,29 @@ def get_datasets(filepath_list, file_type=None):
return sorted_read_data_dict, sorted_file_names_dict


def missing_interop_elements(read_file) -> bool:
"""Check for element values that are missing but needed by OnkoDICOM
Args:
read_file (pydicom.DataSet): DICOM Object/dataset
Returns:
list: list of attribute names whose values are needed by OnkoDICOM
but not present in the data
"""
is_missing = []
for onko_required_attribute in all_iods_required_attributes:
if (not hasattr(read_file, onko_required_attribute)
or read_file[onko_required_attribute].is_empty):
is_missing.append(onko_required_attribute)
if read_file.SOPClassUID in iod_specific_required_attributes:
for onko_required_attribute in iod_specific_required_attributes[read_file.SOPClassUID]:
if (not hasattr(read_file, onko_required_attribute)
or read_file[onko_required_attribute].is_empty):
is_missing.append(onko_required_attribute)
return is_missing


def img_stack_displacement(orientation, position):
"""
Calculate the projection of the image position patient along the
Expand Down Expand Up @@ -190,6 +253,13 @@ def get_dict_sort_on_displacement(item):
orientation = img_dataset.ImageOrientationPatient
position = img_dataset.ImagePositionPatient
sort_key = img_stack_displacement(orientation, position)
# SliceLocation is a type 3 attribute (optional), but it is relied upon elsewhere in OnkoDICOM
# So if it isn't present, the displacement along the stack (in mm, rounded here to mm precision)
# is a reasonable value
if not hasattr(img_dataset,"SliceLocation"):
slice_location = f"{round(sort_key)}"
elem = DataElement(0x00201041, 'DS', slice_location)
img_dataset.add(elem)

return sort_key

Expand Down
7 changes: 7 additions & 0 deletions src/View/OpenPatientWindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,13 @@ def on_loading_error(self, exception):
QMessageBox.about(self.progress_window, "Unable to open selection",
"Selected files cannot be opened as they contain"
" unsupported DICOM classes.")
elif isinstance(exception[1],ImageLoading.NotInteroperableWithOnkoDICOMError):
msg = "Selected files cannot be opened as they are missing values "\
"for DICOM elements needed by OnkoDICOM: "
msg += repr(exception[1])
QMessageBox.about(self.progress_window, "Unable to open selection",
msg
)
self.progress_window.close()

def get_checked_nodes(self, root):
Expand Down
10 changes: 9 additions & 1 deletion src/View/mainpage/DrawROIWindow/Drawing.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,11 @@ def getValues(self):
for j in range(DEFAULT_WINDOW_SIZE):
x, y = linear_transform(
i, j, self.rows, self.cols)
self.values.append(self.data[x][y])
if (x <self.cols and y < self.rows and x>=0 and y>=0):
self.values.append(self.data[x][y])
else:
msg = f"x {x} or y {y} value out of range while drawing, cols={self.cols} rows={self.rows}"
logging.warning(msg)

def refresh_image(self):
"""
Expand All @@ -326,9 +330,13 @@ def refresh_image(self):
"""
if self.q_pixmaps:
self.removeItem(self.q_pixmaps)
if (self.q_image is None):
logging.error("Self.q_image is Null in Drawing.py refresh_image(), resetting to img")
self.q_image = self.img.toImage()
self.q_pixmaps = QtWidgets.QGraphicsPixmapItem(
QtGui.QPixmap.fromImage(self.q_image))
self.addItem(self.q_pixmaps)


def remove_pixels_within_circle(self, clicked_x, clicked_y):
"""
Expand Down
35 changes: 27 additions & 8 deletions src/View/mainpage/WindowingSlider.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
from src.Model.PatientDictContainer import PatientDictContainer
from src.Model.Windowing import windowing_model_direct, set_windowing_slider
import numpy as np

from contextlib import contextmanager
from math import ceil

from PySide6.QtWidgets import QWidget, QLabel, QApplication, QGridLayout, QSizePolicy
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QHorizontalBarSeries, QBarSet
from PySide6.QtGui import QPixmap, QPainter
from PySide6.QtGui import QCursor, QPixmap, QPainter, Qt
from PySide6 import QtCore
from math import ceil
import numpy as np

from src.Model.PatientDictContainer import PatientDictContainer
from src.Model.Windowing import windowing_model_direct, set_windowing_slider

@contextmanager
def wait_cursor():
"""context for wrapping a long running function
with wait_cursor():
# do lengthy process
pass
"""
try:
QApplication.setOverrideCursor(Qt.BusyCursor)
yield
finally:
QApplication.restoreOverrideCursor()

class WindowingSlider(QWidget):
"""
Expand Down Expand Up @@ -324,9 +340,12 @@ def mouse_release(self, event):
window = 2 * (bottom_bar - level)
level = level - WindowingSlider.LEVEL_OFFSET

windowing_model_direct(level, window, send)
if self.action_handler is not None:
self.action_handler.update_views()
with wait_cursor():
windowing_model_direct(level, window, send)
if self.action_handler is not None:
self.action_handler.update_views()
pass


def update_bar_position(self, event):
# move selected bar to the closest valid position
Expand Down

0 comments on commit dc1f91a

Please sign in to comment.