From 0af486842f47b054b6d48dbd29a65acf2a94f42d Mon Sep 17 00:00:00 2001 From: Christian Herz Date: Wed, 27 Sep 2017 22:36:31 -0400 Subject: [PATCH] ENH: added test case for report creation (issue #169) * moved TestDataLogic and QuantitativeReportingTest to separate file * TODO: implement further tests --- CMakeLists.txt | 3 +- QuantitativeReporting.py | 158 +++++------------ Testing/CMakeLists.txt | 9 + Testing/QuantitativeReportingTests.py | 237 ++++++++++++++++++++++++++ Testing/__init__.py | 0 5 files changed, 288 insertions(+), 119 deletions(-) create mode 100644 Testing/CMakeLists.txt create mode 100644 Testing/QuantitativeReportingTests.py create mode 100644 Testing/__init__.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 66afbea..3cf4751 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -37,8 +37,9 @@ string(TOUPPER ${MODULE_NAME} MODULE_NAME_UPPER) add_subdirectory(DICOMPlugins) #----------------------------------------------------------------------------- + if(BUILD_TESTING) -# add_subdirectory(Testing) + add_subdirectory(Testing) endif() #----------------------------------------------------------------------------- diff --git a/QuantitativeReporting.py b/QuantitativeReporting.py index 6200f74..4ed5fc9 100644 --- a/QuantitativeReporting.py +++ b/QuantitativeReporting.py @@ -1,29 +1,30 @@ import getpass import json import logging -import slicer -import qt +import os import ctk import vtk -import os +import qt +import slicer from slicer.ScriptedLoadableModule import * import vtkSegmentationCorePython as vtkSegmentationCore -from SlicerDevelopmentToolboxUtils.mixins import ModuleWidgetMixin, ModuleLogicMixin, ParameterNodeObservationMixin +from DICOMLib.DICOMWidgets import DICOMDetailsWidget +from SegmentEditor import SegmentEditorWidget +from SegmentStatistics import SegmentStatisticsLogic, SegmentStatisticsParameterEditorDialog +from DICOMSegmentationPlugin import DICOMSegmentationExporter + +from SlicerDevelopmentToolboxUtils.buttons import CrosshairButton +from SlicerDevelopmentToolboxUtils.buttons import RedSliceLayoutButton, FourUpLayoutButton, FourUpTableViewLayoutButton +from SlicerDevelopmentToolboxUtils.constants import DICOMTAGS from SlicerDevelopmentToolboxUtils.decorators import onExceptionReturnNone, postCall from SlicerDevelopmentToolboxUtils.helpers import WatchBoxAttribute -from SlicerDevelopmentToolboxUtils.widgets import DICOMBasedInformationWatchBox, ImportLabelMapIntoSegmentationWidget +from SlicerDevelopmentToolboxUtils.mixins import ModuleWidgetMixin, ModuleLogicMixin, ParameterNodeObservationMixin from SlicerDevelopmentToolboxUtils.widgets import CopySegmentBetweenSegmentationsWidget -from SlicerDevelopmentToolboxUtils.constants import DICOMTAGS -from SlicerDevelopmentToolboxUtils.buttons import RedSliceLayoutButton, FourUpLayoutButton, FourUpTableViewLayoutButton -from SlicerDevelopmentToolboxUtils.buttons import CrosshairButton - -from SegmentEditor import SegmentEditorWidget -from SegmentStatistics import SegmentStatisticsLogic, SegmentStatisticsParameterEditorDialog +from SlicerDevelopmentToolboxUtils.widgets import DICOMBasedInformationWatchBox, ImportLabelMapIntoSegmentationWidget -from DICOMLib.DICOMWidgets import DICOMDetailsWidget -from DICOMSegmentationPlugin import DICOMSegmentationExporter +from Testing.QuantitativeReportingTests import TestDataLogic class QuantitativeReporting(ScriptedLoadableModule): @@ -181,30 +182,27 @@ def setupWatchBox(self): self.mainModuleWidgetLayout.addWidget(self.watchBox) def setupTestArea(self): - if not self.developerMode: - return - - def loadTestData(): - mrHeadSeriesUID = "2.16.840.1.113662.4.4168496325.1025306170.548651188813145058" - if not len(slicer.dicomDatabase.filesForSeries(mrHeadSeriesUID)): - sampleData = TestDataLogic.downloadSampleData() - unzipped = TestDataLogic.unzipSampleData(sampleData) - TestDataLogic.importIntoDICOMDatabase(unzipped) - self.loadSeries(mrHeadSeriesUID) - masterNode = slicer.util.getNode('2: SAG*') - tableNode = slicer.vtkMRMLTableNode() - tableNode.SetAttribute("QuantitativeReporting", "Yes") - slicer.mrmlScene.AddNode(tableNode) - self.measurementReportSelector.setCurrentNode(tableNode) - self.segmentEditorWidget.editor.setMasterVolumeNode(masterNode) - self.retrieveTestDataButton.enabled = False - self.testArea = qt.QGroupBox("Test Area") self.testAreaLayout = qt.QFormLayout(self.testArea) self.retrieveTestDataButton = self.createButton("Retrieve and load test data") self.testAreaLayout.addWidget(self.retrieveTestDataButton) - self.retrieveTestDataButton.clicked.connect(loadTestData) - self.mainModuleWidgetLayout.addWidget(self.testArea) + + if self.developerMode: + self.mainModuleWidgetLayout.addWidget(self.testArea) + + def loadTestData(self, collection="MRHead", + uid="2.16.840.1.113662.4.4168496325.1025306170.548651188813145058"): + if not len(slicer.dicomDatabase.filesForSeries(uid)): + sampleData = TestDataLogic.downloadAndUnzipSampleData(collection) + TestDataLogic.importIntoDICOMDatabase(sampleData['volume']) + self.loadSeries(uid) + masterNode = slicer.util.getNodesByClass('vtkMRMLScalarVolumeNode')[-1] + tableNode = slicer.vtkMRMLTableNode() + tableNode.SetAttribute("QuantitativeReporting", "Yes") + slicer.mrmlScene.AddNode(tableNode) + self.measurementReportSelector.setCurrentNode(tableNode) + self.segmentEditorWidget.editor.setMasterVolumeNode(masterNode) + self.retrieveTestDataButton.enabled = False def loadSeriesByFileName(self, filename): seriesUID = slicer.dicomDatabase.seriesForFile(filename) @@ -333,6 +331,7 @@ def setupButtonConnections(): getattr(self.completeReportButton.clicked, funcName)(self.onCompleteReportButtonClicked) getattr(self.calculateMeasurementsButton.clicked, funcName)(lambda: self.updateMeasurementsTable(triggered=True)) getattr(self.segmentStatisticsConfigButton.clicked, funcName)(self.onEditParameters) + getattr(self.retrieveTestDataButton.clicked, funcName)(self.loadTestData) def setupOtherConnections(): getattr(self.layoutManager.layoutChanged, funcName)(self.onLayoutChanged) @@ -565,18 +564,22 @@ def onDisplayMeasurementsTable(self): slicer.app.applicationLogic().PropagateTableSelection() def onSaveReportButtonClicked(self): - success = self.saveReport() + success, err = self.saveReport() self.saveReportButton.enabled = not success if success: slicer.util.infoDisplay("Report successfully saved into SlicerDICOMDatabase") + if err: + slicer.util.warningDisplay(err) def onCompleteReportButtonClicked(self): - success = self.saveReport(completed=True) + success, err = self.saveReport(completed=True) self.saveReportButton.enabled = not success self.completeReportButton.enabled = not success if success: slicer.util.infoDisplay("Report successfully completed and saved into SlicerDICOMDatabase") self.tableNode.SetAttribute("readonly", "Yes") + else: + slicer.util.warningDisplay(err) def saveReport(self, completed=False): try: @@ -587,11 +590,10 @@ def saveReport(self, completed=False): self.createDICOMSR(dcmSegmentationPath, completed) self.addProducedDataToDICOMDatabase() except (RuntimeError, ValueError, AttributeError) as exc: - slicer.util.warningDisplay(exc.message) - return False + return False, exc.message finally: self.cleanupTemporaryData() - return True + return True, None def createSEG(self, dcmSegmentationPath): segmentIDs = None @@ -672,57 +674,6 @@ def saveJSON(self, data, destination): return destination -class QuantitativeReportingTest(ScriptedLoadableModuleTest): - """ - This is the test case for your scripted module. - Uses ScriptedLoadableModuleTest base class, available at: - https://github.com/Slicer/Slicer/blob/master/Base/Python/slicer/ScriptedLoadableModule.py - """ - - def setUp(self): - """ Do whatever is needed to reset the state - typically a scene clear will be enough. - """ - slicer.mrmlScene.Clear(0) - - def runTest(self): - """Run as few or as many tests as needed here. - """ - self.setUp() - self.test_Reporting1() - - def test_Reporting1(self): - """ Ideally you should have several levels of tests. At the lowest level - tests should exercise the functionality of the logic with different inputs - (both valid and invalid). At higher levels your tests should emulate the - way the user would interact with your code and confirm that it still works - the way you intended. - One of the most important features of the tests is that it should alert other - developers when their changes will have an impact on the behavior of your - module. For example, if a developer removes a feature that you depend on, - your test should break so they know that the feature is needed. - """ - - self.delayDisplay("Starting the test") - # - # first, get some data - # - import urllib - downloads = ( - ('http://slicer.kitware.com/midas3/download?items=5767', 'FA.nrrd', slicer.util.loadVolume), - ) - - for url,name,loader in downloads: - filePath = slicer.app.temporaryPath + '/' + name - if not os.path.exists(filePath) or os.stat(filePath).st_size == 0: - logging.info('Requesting download %s from %s...\n' % (name, url)) - urllib.urlretrieve(url, filePath) - if loader: - logging.info('Loading %s...' % (name,)) - loader(filePath) - self.delayDisplay('Finished with download and loading') - self.delayDisplay('Test passed!') - - class QuantitativeReportingSegmentEditorWidget(SegmentEditorWidget, ModuleWidgetMixin): @property @@ -1022,35 +973,6 @@ def onLoadingFinished(self): self.invokeEvent(self.FinishedLoadingEvent) -class TestDataLogic(ScriptedLoadableModuleLogic): - @staticmethod - def importIntoDICOMDatabase(dicomFilesDirectory): - indexer = ctk.ctkDICOMIndexer() - indexer.addDirectory(slicer.dicomDatabase, dicomFilesDirectory, None) - indexer.waitForImportFinished() - - @staticmethod - def unzipSampleData(filePath): - dicomFilesDirectory = slicer.app.temporaryPath + '/dicomFiles' - qt.QDir().mkpath(dicomFilesDirectory) - slicer.app.applicationLogic().Unzip(filePath, dicomFilesDirectory) - return dicomFilesDirectory - - @staticmethod - def downloadSampleData(): - import urllib - downloads = ( - ('http://slicer.kitware.com/midas3/download/item/220834/PieperMRHead.zip', 'PieperMRHead.zip'), - ) - slicer.util.delayDisplay("Downloading", 1000) - for url, name in downloads: - filePath = slicer.app.temporaryPath + '/' + name - if not os.path.exists(filePath) or os.stat(filePath).st_size == 0: - slicer.util.delayDisplay('Requesting download %s from %s...\n' % (name, url), 1000) - urllib.urlretrieve(url, filePath) - return filePath - - class QuantitativeReportingSlicelet(qt.QWidget, ModuleWidgetMixin): def __init__(self): diff --git a/Testing/CMakeLists.txt b/Testing/CMakeLists.txt new file mode 100644 index 0000000..377b9f9 --- /dev/null +++ b/Testing/CMakeLists.txt @@ -0,0 +1,9 @@ +slicer_add_python_unittest(SCRIPT ${MODULE_NAME}Tests.py) + +ctkMacroCompilePythonScript( + TARGET_NAME ApplicationSelfTests + SCRIPTS "${MODULE_NAME}Tests.py" + DESTINATION_DIR ${Slicer_BINARY_DIR}/${Slicer_QTSCRIPTEDMODULES_LIB_DIR} + INSTALL_DIR ${Slicer_INSTALL_QTSCRIPTEDMODULES_LIB_DIR} + NO_INSTALL_SUBDIR +) \ No newline at end of file diff --git a/Testing/QuantitativeReportingTests.py b/Testing/QuantitativeReportingTests.py new file mode 100644 index 0000000..9bc942c --- /dev/null +++ b/Testing/QuantitativeReportingTests.py @@ -0,0 +1,237 @@ +import os +import qt +import ctk +import vtk +import slicer +import logging + +from slicer.ScriptedLoadableModule import ScriptedLoadableModuleTest, ScriptedLoadableModuleWidget, \ + ScriptedLoadableModuleLogic + +__all__ = ['QuantitativeReportingTest'] + + +class QuantitativeReportingTests: + + def __init__(self, parent): + parent.title = "Quantitative Reporting Tests" + parent.categories = ["Testing.TestCases"] + parent.dependencies = ["QuantitativeReporting"] + parent.contributors = ["Christian Herz (SPL, BWH), Andrey Fedorov (SPL, BWH)"] + parent.helpText = """ + This self test includes creation/read of a structured report (DICOM TID1500) including its segmentation. + For more information: https://github.com/QIICR/QuantitativeReporting + """ + parent.acknowledgementText = """ + This work was supported in part by the National Cancer Institute funding to the + Quantitative Image Informatics for Cancer Research (QIICR) (U24 CA180918). + """ + self.parent = parent + + try: + slicer.selfTests + except AttributeError: + slicer.selfTests = {} + slicer.selfTests['QuantitativeReporting'] = self.runTest + + def runTest(self): + tester = QuantitativeReportingTest() + tester.runTest() + + +class QuantitativeReportingTestsWidget(ScriptedLoadableModuleWidget): + + def __init__(self, parent=None): + ScriptedLoadableModuleWidget.__init__(self, parent) + + def setup(self): + ScriptedLoadableModuleWidget.setup(self) + + testsCollapsibleButton = ctk.ctkCollapsibleButton() + testsCollapsibleButton.setLayout(qt.QFormLayout()) + testsCollapsibleButton.text = "Quantitative Reporting Tests" + self.layout.addWidget(testsCollapsibleButton) + + self.runReportCreationTest = qt.QPushButton("Run Report Creation Test") + self.runReportCreationTest.toolTip = "Creation of a DICOM TID1500 structured report." + testsCollapsibleButton.layout().addWidget(self.runReportCreationTest) + + self.runReportReadingTest = qt.QPushButton("Run Report Read Test") + self.runReportReadingTest.toolTip = "Reading of a DICOM TID1500 structured report." + testsCollapsibleButton.layout().addWidget(self.runReportReadingTest) + + self.setupConnections() + + def setupConnections(self): + self.runReportCreationTest.connect('clicked(bool)', self.onReportCreationButtonClicked) + self.runReportReadingTest.connect('clicked(bool)', self.onReportReadingButtonClicked) + + self.layout.addStretch(1) + + def onReportCreationButtonClicked(self): + tester = QuantitativeReportingTest() + tester.setUp() + tester.test_create_report() + + def onReportReadingButtonClicked(self): + tester = QuantitativeReportingTest() + tester.setUp() + tester.test_read_report() + + + # def onReloadAndTest(self,moduleName="AtlasTests"): + # self.onReload() + # evalString = 'globals()["%s"].%sTest()' % (moduleName, moduleName) + # tester = eval(evalString) + # tester.runTest() + + +class QuantitativeReportingTest(ScriptedLoadableModuleTest): + """ + This is the test case for your scripted module. + Uses ScriptedLoadableModuleTest base class, available at: + https://github.com/Slicer/Slicer/blob/master/Base/Python/slicer/ScriptedLoadableModule.py + """ + + @property + def layoutManager(self): + return slicer.app.layoutManager() + + def setUp(self): + self.delayDisplay("Closing the scene") + slicer.mrmlScene.Clear(0) + + def loadTestData(self): + liverCTSeriesUID = "1.2.392.200103.20080913.113635.1.2009.6.22.21.43.10.23430.1" + collection = "CTLiver" + + qrWidget = slicer.modules.QuantitativeReportingWidget + qrWidget.loadTestData(collection, liverCTSeriesUID) + + def runTest(self): + """Run as few or as many tests as needed here. + """ + self.setUp() + self.test_create_report() + self.test_import_labelmap() + self.test_import_segmentation() + + self.test_read_report() + + def test_create_report(self): + + self.delayDisplay("Starting test for creating a report") + + + self.layoutManager.selectModule("QuantitativeReporting") + qrWidget = slicer.modules.QuantitativeReportingWidget + + self.loadTestData() + success, err = qrWidget.saveReport() + self.assertFalse(success) + + self.delayDisplay('Add segments') + + import vtkSegmentationCorePython as vtkSegmentationCore + + qrWidget = slicer.modules.QuantitativeReportingWidget + segmentation = qrWidget.segmentEditorWidget.segmentationNode.GetSegmentation() + + segmentGeometries = { + 'Tumor': [[2, 30, 30, -127.7], [2, 40, 40, -127.7], [2, 50, 50, -127.7], [2, 40, 80, -127.7]], + 'Air': [[2, 60, 100, -127.7], [2, 80, 30, -127.7]] + } + + for segmentName, segmentGeometry in segmentGeometries.iteritems(): + appender = vtk.vtkAppendPolyData() + + for sphere in segmentGeometry: + sphereSource = vtk.vtkSphereSource() + sphereSource.SetRadius(sphere[0]) + sphereSource.SetCenter(sphere[1], sphere[2], sphere[3]) + appender.AddInputConnection(sphereSource.GetOutputPort()) + + segment = vtkSegmentationCore.vtkSegment() + segment.SetName(segmentation.GenerateUniqueSegmentID(segmentName)) + + appender.Update() + representationName = vtkSegmentationCore.vtkSegmentationConverter.GetSegmentationClosedSurfaceRepresentationName() + segment.AddRepresentation(representationName, appender.GetOutput()) + segmentation.AddSegment(segment) + + self.delayDisplay('Save report') + + success, err = qrWidget.saveReport() + self.assertTrue(success) + + self.delayDisplay('Test passed!') + + def test_import_labelmap(self): + self.delayDisplay('Test passed!') + + def test_import_segmentation(self): + self.delayDisplay('Test passed!') + + def test_read_report(self): + self.delayDisplay('Test passed!') + + +class TestDataLogic(ScriptedLoadableModuleLogic): + + collections = { + 'MRHead': { + 'volume': ('http://slicer.kitware.com/midas3/download/item/220834/PieperMRHead.zip', 'PieperMRHead.zip') + }, + 'CTLiver': { + 'volume': ('https://github.com/QIICR/QuantitativeReporting/releases/download/test-data/CTLiver.zip', 'CTLiver.zip'), + 'sr': ('https://github.com/QIICR/QuantitativeReporting/releases/download/test-data/SR.zip', 'SR.zip'), + 'seg_dcm': ('https://github.com/QIICR/QuantitativeReporting/releases/download/test-data/Liver_Segmentation_DCM.zip', + 'Liver_Segmentation_DCM.zip'), + 'seg_nrrd': ('https://github.com/QIICR/QuantitativeReporting/releases/download/test-data/Segmentations_NRRD.zip', + 'Segmentations_NRRD.zip') + } + } + + DOWNLOAD_DIRECTORY = os.path.join(slicer.app.temporaryPath, 'dicomFiles') + + @staticmethod + def importIntoDICOMDatabase(dicomFilesDirectory): + indexer = ctk.ctkDICOMIndexer() + indexer.addDirectory(slicer.dicomDatabase, dicomFilesDirectory, None) + indexer.waitForImportFinished() + + @staticmethod + def unzipSampleData(filePath, collection, kind): + dicomFilesDirectory = TestDataLogic.getUnzippedDirectoryPath(collection, kind) + qt.QDir().mkpath(dicomFilesDirectory) + success = slicer.app.applicationLogic().Unzip(filePath, dicomFilesDirectory) + if not success: + logging.error("Archive %s was NOT unzipped successfully." % filePath) + return dicomFilesDirectory + + @staticmethod + def getUnzippedDirectoryPath(collection, kind): + return os.path.join(TestDataLogic.DOWNLOAD_DIRECTORY, collection, kind) + + @staticmethod + def downloadAndUnzipSampleData(collection='MRHead'): + import urllib + slicer.util.delayDisplay("Downloading", 1000) + + downloaded = {} + for kind, (url, filename) in TestDataLogic.collections[collection].iteritems(): + filePath = os.path.join(slicer.app.temporaryPath, 'downloads', collection, kind, filename) + if not os.path.exists(os.path.dirname(filePath)): + os.makedirs(os.path.dirname(filePath)) + logging.debug('Saving download %s to %s ' % (filename, filePath)) + expectedOutput = TestDataLogic.getUnzippedDirectoryPath(collection, kind) + if not os.path.exists(filePath) or os.stat(filePath).st_size == 0: + slicer.util.delayDisplay('Requesting download %s from %s...\n' % (filename, url), 1000) + urllib.urlretrieve(url, filePath) + if not os.path.exists(expectedOutput): + logging.debug('Unzipping data into %s' % expectedOutput) + downloaded[kind] = TestDataLogic.unzipSampleData(filePath, collection, kind) + else: + downloaded[kind] = expectedOutput + + return downloaded \ No newline at end of file diff --git a/Testing/__init__.py b/Testing/__init__.py new file mode 100644 index 0000000..e69de29