Switch to unified view

a b/Scripted/MusculoskeletalAnalysis/MusculoskeletalAnalysis.py
1
import logging
2
import os
3
import time
4
import vtk
5
import importlib
6
7
import slicer
8
from slicer.ScriptedLoadableModule import *
9
from slicer.util import VTKObservationMixin
10
11
12
#
13
# MusculoskeletalAnalysis
14
#
15
16
class MusculoskeletalAnalysis(ScriptedLoadableModule):
17
    """Uses ScriptedLoadableModule base class, available at:
18
    https://github.com/Slicer/Slicer/blob/main/Base/Python/slicer/ScriptedLoadableModule.py
19
    """
20
21
    def __init__(self, parent):
22
        ScriptedLoadableModule.__init__(self, parent)
23
        self.parent.title = "Musculoskeletal Analysis"
24
        self.parent.categories = ["Quantification"]
25
        self.parent.dependencies = ["CorticalAnalysis", "CancellousAnalysis", "DensityAnalysis", "IntervertebralAnalysis"]  # TODO: add here list of module names that this module requires
26
        self.parent.contributors = ["Joseph Szatkowski (Washington University in St. Louis)"]
27
        # TODO: update with short description of the module and a link to online module documentation
28
        self.parent.helpText = """
29
        1. Select a volume containing the image to analyze.\n
30
        2. Select a segmentation representing the area to analyze. For cortical analysis exclude the medullary cavity. For cancellous analysis exclude the cortical bone.\n
31
        3. Use the threshold slider to select a threshold identifying the bone.\n
32
        4. Select the function to perform. See <a href="https://github.com/WashUMusculoskeletalCore/Slicer-MusculoskeletalAnalysis/blob/main/README.md">here</a> for more information.\n
33
        5. Select the directory to send the output files to. If files already exist they will be appended to.\n
34
        6. Click "Apply"\n
35
        ADVANCED\n
36
        7. If the image volume is not the original DICOM, select the original DICOM node to get DICOM tags from.
37
        """
38
        # TODO: replace with organization, grant and thanks
39
        self.parent.acknowledgementText = """
40
        Developed by the Washington University in St. Louis Musculoskeletal Reseach Center with the assistance of Michael Brodt, Anish Jagannathan, Matthew Silva, and Simon Tang.\n
41
        This file was partially funded by NIH grant P30 AR074992.
42
        """
43
44
        # Additional initialization step after application startup is complete
45
        slicer.app.connect("startupCompleted()", registerSampleData)
46
47
48
#
49
# Register sample data sets in Sample Data module
50
#
51
52
def registerSampleData():
53
    """
54
    Add data sets to Sample Data module.
55
    """
56
    # It is always recommended to provide sample data for users to make it easy to try the module,
57
    # but if no sample data is available then this method (and associated startupCompeted signal connection) can be removed.
58
59
    import SampleData
60
    iconsPath = os.path.join(os.path.dirname(__file__), 'Resources/Icons')
61
62
    # To ensure that the source code repository remains small (can be downloaded and installed quickly)
63
    # it is recommended to store data sets that are larger than a few MB in a Github release.
64
65
    SampleData.SampleDataLogic.registerCustomSampleDataSource(
66
        # Category and sample name displayed in Sample Data module
67
        category='MusculoskeletalAnalysis',
68
        sampleName='Cortical1',
69
        # Thumbnail should have size of approximately 260x280 pixels and stored in Resources/Icons folder.
70
        # It can be created by Screen Capture module, "Capture all views" option enabled, "Number of images" set to "Single".
71
        thumbnailFileName=os.path.join(iconsPath, 'Cortical1.png'),
72
        # Download URL and target file name
73
        uris="https://github.com/WashUMusculoskeletalCore/Slicer-MusculoskeletalAnalysis/releases/download/v1.1-assets/CorticalSample1.nrrd",
74
        fileNames='CorticalSample1.nrrd',
75
        # Checksum to ensure file integrity. Can be computed by this command:
76
        #  import hashlib; print(hashlib.sha256(open(filename, "rb").read()).hexdigest())
77
        checksums='SHA256:740fcdbe9c7341ffeb2fb44e68e25098077281e11d541163379bf301db4b65b9',
78
        # This node name will be used when the data set is loaded
79
        nodeNames='Cortical1'
80
    )
81
82
83
    SampleData.SampleDataLogic.registerCustomSampleDataSource(
84
        # Category and sample name displayed in Sample Data module
85
        category='MusculoskeletalAnalysis',
86
        sampleName='CorticalMask1',
87
        # Thumbnail should have size of approximately 260x280 pixels and stored in Resources/Icons folder.
88
        # It can be created by Screen Capture module, "Capture all views" option enabled, "Number of images" set to "Single".
89
        thumbnailFileName=os.path.join(iconsPath, 'CorticalMask1.png'),
90
        # Download URL and target file name
91
        uris="https://github.com/WashUMusculoskeletalCore/Slicer-MusculoskeletalAnalysis/releases/download/v1.1-assets/CorticalMaskSample1.seg.nrrd",
92
        fileNames='CorticalMaskSample1.seg.nrrd',
93
        loadFileType='SegmentationFile',
94
        # Checksum to ensure file integrity. Can be computed by this command:
95
        #  import hashlib; print(hashlib.sha256(open(filename, "rb").read()).hexdigest())
96
        checksums='SHA256:1cdd2ea240d848d3d5241eddffe2467f5bf49f80dab50937c8127eb511fa3b9a',
97
        # This node name will be used when the data set is loaded
98
        nodeNames='CorticalMask1'
99
    )
100
101
    SampleData.SampleDataLogic.registerCustomSampleDataSource(
102
        # Category and sample name displayed in Sample Data module
103
        category='MusculoskeletalAnalysis',
104
        sampleName='Cortical2',
105
        # Thumbnail should have size of approximately 260x280 pixels and stored in Resources/Icons folder.
106
        # It can be created by Screen Capture module, "Capture all views" option enabled, "Number of images" set to "Single".
107
        thumbnailFileName=os.path.join(iconsPath, 'Cortical2.png'),
108
        # Download URL and target file name
109
        uris="https://github.com/WashUMusculoskeletalCore/Slicer-MusculoskeletalAnalysis/releases/download/v1.1-assets/CorticalSample2.nrrd",
110
        fileNames='CorticalSample2.nrrd',
111
        # Checksum to ensure file integrity. Can be computed by this command:
112
        #  import hashlib; print(hashlib.sha256(open(filename, "rb").read()).hexdigest())
113
        checksums='SHA256:8e0869839abc008000d32d8ffef923a89ee7fd10fd974d63baa1daecb47154f5',
114
        # This node name will be used when the data set is loaded
115
        nodeNames='Cortical2'
116
    )
117
118
    SampleData.SampleDataLogic.registerCustomSampleDataSource(
119
        # Category and sample name displayed in Sample Data module
120
        category='MusculoskeletalAnalysis',
121
        sampleName='CorticalMask2',
122
        # Thumbnail should have size of approximately 260x280 pixels and stored in Resources/Icons folder.
123
        # It can be created by Screen Capture module, "Capture all views" option enabled, "Number of images" set to "Single".
124
        thumbnailFileName=os.path.join(iconsPath, 'CorticalMask2.png'),
125
        # Download URL and target file name
126
        uris="https://github.com/WashUMusculoskeletalCore/Slicer-MusculoskeletalAnalysis/releases/download/v1.1-assets/CorticalMaskSample2.seg.nrrd",
127
        fileNames='CorticalMaskSample2.seg.nrrd',
128
        loadFileType='SegmentationFile',
129
        # Checksum to ensure file integrity. Can be computed by this command:
130
        #  import hashlib; print(hashlib.sha256(open(filename, "rb").read()).hexdigest())
131
        checksums='SHA256:509b7e5b16a838bf743ec083677ac7a1a2b1f7f3d2fb8ad93e75b38619150d7e',
132
        # This node name will be used when the data set is loaded
133
        nodeNames='CorticalMask2'
134
    )
135
136
    SampleData.SampleDataLogic.registerCustomSampleDataSource(
137
        # Category and sample name displayed in Sample Data module
138
        category='MusculoskeletalAnalysis',
139
        sampleName='Cancellous1',
140
        thumbnailFileName=os.path.join(iconsPath, 'Cancellous1.png'),
141
        # Download URL and target file name
142
        uris="https://github.com/WashUMusculoskeletalCore/Slicer-MusculoskeletalAnalysis/releases/download/v1.1-assets/CancellousSample1.nrrd",
143
        fileNames='CancellousSample1.nrrd',
144
        # Checksum to ensure file integrity. Can be computed by this command:
145
        #  import hashlib; print(hashlib.sha256(open(filename, "rb").read()).hexdigest())
146
        checksums='SHA256:cb47e20fd9d4caf210a256db8317f2553f409399834b0bc15b28e57daf46ba89',
147
        # This node name will be used when the data set is loaded
148
        nodeNames='Cancellous1'
149
    )
150
151
152
    SampleData.SampleDataLogic.registerCustomSampleDataSource(
153
        # Category and sample name displayed in Sample Data module
154
        category='MusculoskeletalAnalysis',
155
        sampleName='CancellousMask1',
156
        thumbnailFileName=os.path.join(iconsPath, 'CancellousMask1.png'),
157
        # Download URL and target file name
158
        uris="https://github.com/WashUMusculoskeletalCore/Slicer-MusculoskeletalAnalysis/releases/download/v1.1-assets/CancellousMaskSample1.seg.nrrd",
159
        fileNames='CancellousMaskSample1.seg.nrrd',
160
        loadFileType='SegmentationFile',
161
        # Checksum to ensure file integrity. Can be computed by this command:
162
        #  import hashlib; print(hashlib.sha256(open(filename, "rb").read()).hexdigest())
163
        checksums='SHA256:1d632557a415b0d84dfaca4bf743aa9319e5a5e805e7a997d76c6f7bd9e75160',
164
        # This node name will be used when the data set is loaded
165
        nodeNames='CancellousMask1'
166
    )
167
168
    SampleData.SampleDataLogic.registerCustomSampleDataSource(
169
        # Category and sample name displayed in Sample Data module
170
        category='MusculoskeletalAnalysis',
171
        sampleName='Cancellous2',
172
        thumbnailFileName=os.path.join(iconsPath, 'Cancellous2.png'),
173
        # Download URL and target file name
174
        uris="https://github.com/WashUMusculoskeletalCore/Slicer-MusculoskeletalAnalysis/releases/download/v1.1-assets/CancellousSample2.nrrd",
175
        fileNames='CancellousSample2.nrrd',
176
        # Checksum to ensure file integrity. Can be computed by this command:
177
        #  import hashlib; print(hashlib.sha256(open(filename, "rb").read()).hexdigest())
178
        checksums='SHA256:fd594a087700afcb3a1fc6c0ee4fa087f263eda86f93f0cfc317927876bda813',
179
        # This node name will be used when the data set is loaded
180
        nodeNames='Cancellous2'
181
    )
182
183
184
    SampleData.SampleDataLogic.registerCustomSampleDataSource(
185
        # Category and sample name displayed in Sample Data module
186
        category='MusculoskeletalAnalysis',
187
        sampleName='CancellousMask2',
188
        thumbnailFileName=os.path.join(iconsPath, 'CancellousMask2.png'),
189
        # Download URL and target file name
190
        uris="https://github.com/WashUMusculoskeletalCore/Slicer-MusculoskeletalAnalysis/releases/download/v1.1-assets/CancellousMaskSample2.seg.nrrd",
191
        fileNames='CancellousMaskSample2.seg.nrrd',
192
        loadFileType='SegmentationFile',
193
        # Checksum to ensure file integrity. Can be computed by this command:
194
        #  import hashlib; print(hashlib.sha256(open(filename, "rb").read()).hexdigest())
195
        checksums='SHA256:473cb8d1a2ccc8973eb7f0b69eb297d2d4119155b1a9940cb5e11d2ccd4e1315',
196
        # This node name will be used when the data set is loaded
197
        nodeNames='CancellousMask2'
198
    )
199
200
    SampleData.SampleDataLogic.registerCustomSampleDataSource(
201
        # Category and sample name displayed in Sample Data module
202
        category='MusculoskeletalAnalysis',
203
        sampleName='Intervertebral1',
204
        thumbnailFileName=os.path.join(iconsPath, 'Intervertebral1.png'),
205
        # Download URL and target file name
206
        uris="https://github.com/WashUMusculoskeletalCore/Slicer-MusculoskeletalAnalysis/releases/download/v1.1-assets/IntervertebralSample1.nrrd",
207
        fileNames='Intervertebral1.nrrd',
208
        # Checksum to ensure file integrity. Can be computed by this command:
209
        #  import hashlib; print(hashlib.sha256(open(filename, "rb").read()).hexdigest())
210
        checksums='SHA256:26706cec367bded189182e8b3e02c804bdad5267cee25f95202ee240823841a9',
211
        # This node name will be used when the data set is loaded
212
        nodeNames='Intervertebral1'
213
    )
214
215
216
    SampleData.SampleDataLogic.registerCustomSampleDataSource(
217
        # Category and sample name displayed in Sample Data module
218
        category='MusculoskeletalAnalysis',
219
        sampleName='IntervertebralMask1',
220
        thumbnailFileName=os.path.join(iconsPath, 'IntervertebralMask1.png'),
221
        # Download URL and target file name
222
        uris="https://github.com/WashUMusculoskeletalCore/Slicer-MusculoskeletalAnalysis/releases/download/v1.1-assets/IntervertebralMaskSample1.seg.nrrd",
223
        fileNames='IntervertebralMaskSample1.seg.nrrd',
224
        loadFileType='SegmentationFile',
225
        # Checksum to ensure file integrity. Can be computed by this command:
226
        #  import hashlib; print(hashlib.sha256(open(filename, "rb").read()).hexdigest())
227
        checksums='SHA256:7c140faa9924dd47d14df1208d5dc130479a8c64bfba6b741e02362465f7183f',
228
        # This node name will be used when the data set is loaded
229
        nodeNames='IntervertebralMask1'
230
    )
231
232
#
233
# MusculoskeletalAnalysisWidget
234
#
235
236
class MusculoskeletalAnalysisWidget(ScriptedLoadableModuleWidget, VTKObservationMixin):
237
    """Uses ScriptedLoadableModuleWidget base class, available at:
238
    https://github.com/Slicer/Slicer/blob/main/Base/Python/slicer/ScriptedLoadableModule.py
239
    """
240
241
    def __init__(self, parent=None):
242
        """
243
        Called when the user opens the module the first time and the widget is initialized.
244
        """
245
        ScriptedLoadableModuleWidget.__init__(self, parent)
246
        VTKObservationMixin.__init__(self)  # needed for parameter node observation
247
        self.logic = None
248
        self._parameterNode = None
249
        self._updatingGUIFromParameterNode = False
250
251
    def setup(self):
252
        """
253
        Called when the user opens the module the first time and the widget is initialized.
254
        """
255
        ScriptedLoadableModuleWidget.setup(self)
256
257
        # Load widget from .ui file (created by Qt Designer).
258
        # Additional widgets can be instantiated manually and added to self.layout.
259
        uiWidget = slicer.util.loadUI(self.resourcePath('UI/MusculoskeletalAnalysis.ui'))
260
        self.layout.addWidget(uiWidget)
261
        self.ui = slicer.util.childWidgetVariables(uiWidget)
262
263
        # Set scene in MRML widgets. Make sure that in Qt designer the top-level qMRMLWidget's
264
        # "mrmlSceneChanged(vtkMRMLScene*)" signal in is connected to each MRML widget's.
265
        # "setMRMLScene(vtkMRMLScene*)" slot.
266
        uiWidget.setMRMLScene(slicer.mrmlScene)
267
268
        # Create logic class. Logic implements all computations that should be possible to run
269
        # in batch mode, without a graphical user interface.
270
        self.logic = MusculoskeletalAnalysisLogic()
271
272
        # Connections
273
274
        # These connections ensure that we update parameter node when scene is closed
275
        self.addObserver(slicer.mrmlScene, slicer.mrmlScene.StartCloseEvent, self.onSceneStartClose)
276
        self.addObserver(slicer.mrmlScene, slicer.mrmlScene.EndCloseEvent, self.onSceneEndClose)
277
278
        # These connections ensure that whenever user changes some settings on the GUI, that is saved in the MRML scene
279
        # (in the selected parameter node).
280
        self.ui.inputSelector.connect("currentNodeChanged(vtkMRMLNode*)", self.inputVolumeChanged)
281
        self.ui.segmentSelector.connect("currentNodeChanged(vtkMRMLNode*)",self.segmentNodeChanged)
282
        self.ui.segmentSelector.connect("currentSegmentChanged(QString)",self.segmentChanged)
283
        self.ui.segmentSelector.connect("segmentSelectionChanged(QStringList)", self.segmentChanged)
284
        self.ui.thresholdSelector.connect("thresholdValuesChanged(double, double)", self.updateParameterNodeFromGUI)
285
        self.ui.analysisSelector.connect("currentTextChanged(const QString)", self.updateParameterNodeFromGUI)
286
        self.ui.DICOMOptions.connect("buttonClicked(QAbstractButton*)", self.updateParameterNodeFromGUI)
287
        self.ui.DICOMSelector.connect("currentNodeChanged(vtkMRMLNode*)", self.DICOMSeriesChanged)
288
        self.ui.voxelSizeLineEdit.connect("editingFinished()", self.updateParameterNodeFromGUI)
289
        self.ui.scalingLineEdit.connect("editingFinished()", self.updateParameterNodeFromGUI)
290
        self.ui.densitySlopeLineEdit.connect("editingFinished()", self.updateParameterNodeFromGUI)
291
        self.ui.densityInterceptLineEdit.connect("editingFinished()", self.updateParameterNodeFromGUI)
292
        self.ui.rescaleSlopeLineEdit.connect("editingFinished()", self.updateParameterNodeFromGUI)
293
        self.ui.rescaleInterceptLineEdit.connect("editingFinished()", self.updateParameterNodeFromGUI)
294
        self.ui.outputDirectorySelector.connect("currentPathChanged(const QString)", self.updateParameterNodeFromGUI)
295
296
        # Buttons
297
        self.ui.applyButton.connect('clicked(bool)', self.onApplyButton)
298
299
        # Hidden elements
300
        self.ui.AnalysisProgress.hide()
301
        # Make sure parameter node is initialized (needed for module reload)
302
        self.initializeParameterNode()
303
304
305
306
307
    def cleanup(self):
308
        """
309
        Called when the application closes and the module widget is destroyed.
310
        """
311
        self.removeObservers()
312
313
    def enter(self):
314
        """
315
        Called each time the user opens this module.
316
        """
317
        # Make sure parameter node exists and observed
318
        self.initializeParameterNode()
319
320
    def exit(self):
321
        """
322
        Called each time the user opens a different module.
323
        """
324
        # Do not react to parameter node changes (GUI wlil be updated when the user enters into the module)
325
        self.removeObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateGUIFromParameterNode)
326
327
    def onSceneStartClose(self, caller, event):
328
        """
329
        Called just before the scene is closed.
330
        """
331
        # Parameter node will be reset, do not use it anymore
332
        self.setParameterNode(None)
333
334
    def onSceneEndClose(self, caller, event):
335
        """
336
        Called just after the scene is closed.
337
        """
338
        # If this module is shown while the scene is closed then recreate a new parameter node immediately
339
        if self.parent.isEntered:
340
            self.initializeParameterNode()
341
342
    def initializeParameterNode(self):
343
        """
344
        Ensure parameter node exists and observed.
345
        """
346
        # Parameter node stores all user choices in parameter values, node selections, etc.
347
        # so that when the scene is saved and reloaded, these settings are restored.
348
349
        self.setParameterNode(self.logic.getParameterNode())
350
351
        # Select default input nodes if nothing is selected yet to save a few clicks for the user
352
        if not self._parameterNode.GetNodeReference("InputVolume"):
353
            firstVolumeNode = slicer.mrmlScene.GetFirstNodeByClass("vtkMRMLScalarVolumeNode")
354
            if firstVolumeNode:
355
                self._parameterNode.SetNodeReferenceID("InputVolume", firstVolumeNode.GetID())
356
357
        if not self._parameterNode.GetNodeReference("SegmentNode"):
358
            firstSegmentNode = slicer.mrmlScene.GetFirstNodeByClass("vtkMRMLSegmentationNode")
359
            if firstSegmentNode:
360
                self._parameterNode.SetNodeReferenceID("SegmentNode", firstSegmentNode.GetID())
361
                self.ui.segmentSelector.setCurrentNode(firstSegmentNode)
362
        if not self._parameterNode.GetParameter("SegmentID"):
363
            if self._parameterNode.GetNodeReference("SegmentNode"):
364
                segmentation = self._parameterNode.GetNodeReference("SegmentNode").GetSegmentation()
365
                self._parameterNode.SetParameter("SegmentID", segmentation.GetNthSegmentID(0))
366
367
368
369
        if not self._parameterNode.GetNodeReference("DICOMNode"):
370
            firstVolumeNode = slicer.mrmlScene.GetFirstNodeByClass("vtkMRMLScalarVolumeNode")
371
            if firstVolumeNode:
372
                self._parameterNode.SetNodeReferenceID("DICOMNode", firstVolumeNode.GetID())
373
374
        # Set default state for flags
375
        self._parameterNode.SetParameter("Analyzing", "False")
376
377
378
379
380
381
382
    def setParameterNode(self, inputParameterNode):
383
        """
384
        Set and observe parameter node.
385
        Observation is needed because when the parameter node is changed then the GUI must be updated immediately.
386
        """
387
388
        if inputParameterNode:
389
            self.logic.setDefaultParameters(inputParameterNode)
390
391
        # Unobserve previously selected parameter node and add an observer to the newly selected.
392
        # Changes of parameter node are observed so that whenever parameters are changed by a script or any other module
393
        # those are reflected immediately in the GUI.
394
        if self._parameterNode is not None:
395
            self.removeObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateGUIFromParameterNode)
396
        self._parameterNode = inputParameterNode
397
        if self._parameterNode is not None:
398
            self.addObserver(self._parameterNode, vtk.vtkCommand.ModifiedEvent, self.updateGUIFromParameterNode)
399
400
        # Initial GUI update
401
        self.updateGUIFromParameterNode()
402
403
    def updateGUIFromParameterNode(self, caller=None, event=None):
404
        """
405
        This method is called whenever parameter node is changed.
406
        The module GUI is updated to show the current state of the parameter node.
407
        """
408
409
        if self._parameterNode is None or self._updatingGUIFromParameterNode:
410
            return
411
412
        # Make sure GUI changes do not call updateParameterNodeFromGUI (it could cause infinite loop)
413
        self._updatingGUIFromParameterNode = True
414
415
        # Update node selectors and sliders
416
417
        self.ui.thresholdSelector.setMRMLVolumeNode(self._parameterNode.GetNodeReference("InputVolume"))
418
        if self._parameterNode.GetParameter("LowerThreshold") and self._parameterNode.GetParameter("UpperThreshold"):
419
            self.ui.thresholdSelector.setLowerThreshold(float(self._parameterNode.GetParameter("LowerThreshold")))
420
            self.ui.thresholdSelector.setUpperThreshold(float(self._parameterNode.GetParameter("UpperThreshold")))
421
        self.ui.analysisSelector.setCurrentText(str(self._parameterNode.GetParameter("Analysis")))
422
        self.ui.AlternateDICOMCheckBox.setChecked(self._parameterNode.GetParameter("UseAlt")=="True")
423
        self.ui.ManualDICOMCheckBox.setChecked(self._parameterNode.GetParameter("UseMan")=="True")
424
        self.ui.DICOMSelector.setCurrentNode(self._parameterNode.GetNodeReference("DICOMNode"))
425
        self.ui.voxelSizeLineEdit.setText(self._parameterNode.GetParameter("0018,0050"))
426
        self.ui.scalingLineEdit.setText(self._parameterNode.GetParameter("0029,1000"))
427
        self.ui.densitySlopeLineEdit.setText(self._parameterNode.GetParameter("0029,1004"))
428
        self.ui.densityInterceptLineEdit.setText(self._parameterNode.GetParameter("0029,1005"))
429
        self.ui.rescaleSlopeLineEdit.setText(self._parameterNode.GetParameter("0028,1053"))
430
        self.ui.rescaleInterceptLineEdit.setText(self._parameterNode.GetParameter("0028,1052"))
431
        self.ui.outputDirectorySelector.setCurrentPath(str(self._parameterNode.GetParameter("OutputDirectory")))
432
433
434
        # Update buttons states and tooltips
435
        if self._parameterNode.GetNodeReference("InputVolume"):
436
            self.ui.thresholdSelector.enabled = True
437
        else:
438
            self.ui.thresholdSelector.enabled = False
439
440
        if self._parameterNode.GetParameter("MultiSelect") == 'True':
441
            self.ui.segmentSelector.multiSelection = True
442
            self.ui.thresholdSelector.enabled = False
443
            self.ui.thresholdSelector.setVisible(False)
444
        else:
445
            self.ui.segmentSelector.multiSelection = False
446
            self.ui.thresholdSelector.enabled = True
447
            self.ui.thresholdSelector.setVisible(True)
448
449
        # Update advanced options
450
        self.ui.DICOMSelector.enabled = (self._parameterNode.GetParameter("UseAlt")=="True")
451
        manual = (self._parameterNode.GetParameter("UseMan")=="True")
452
        self.ui.voxelSizeLineEdit.enabled=manual
453
        self.ui.scalingLineEdit.enabled=manual
454
        self.ui.densitySlopeLineEdit.enabled=manual
455
        self.ui.densityInterceptLineEdit.enabled=manual
456
        self.ui.rescaleSlopeLineEdit.enabled=manual
457
        self.ui.rescaleInterceptLineEdit.enabled=manual
458
        self.ui.voxelSizeLabel.enabled=manual
459
        self.ui.scalingLabel.enabled=manual
460
        self.ui.densitySlopeLabel.enabled=manual
461
        self.ui.densityInterceptLabel.enabled=manual
462
        self.ui.rescaleSlopeLabel.enabled=manual
463
        self.ui.rescaleInterceptLabel.enabled=manual
464
        # Update Apply Button
465
        if self._parameterNode.GetParameter("Analyzing")=="True":
466
            self.ui.applyButton.toolTip = "Currently running analysis"
467
            self.ui.applyButton.enabled = False
468
        elif self._parameterNode.GetNodeReference("InputVolume") and self._parameterNode.GetParameter("SegmentID") and (self._parameterNode.GetParameter("UseDICOM")=="False" or self._parameterNode.GetNodeReferenceID("DICOMNode")):
469
            self.ui.applyButton.toolTip = "Perform the selected analysis"
470
            self.ui.applyButton.enabled = True
471
        else:
472
            self.ui.applyButton.toolTip = "Select input volume node, input segment, and output directory"
473
            self.ui.applyButton.enabled = False
474
475
        # All the GUI updates are done
476
        self._updatingGUIFromParameterNode = False
477
478
    def inputVolumeChanged(self, event):
479
        """
480
        Called when the input volume is changed in the selector.
481
        Passes the caller information to updateParameterNode
482
        """
483
        self.updateParameterNodeFromGUI(event, "InputVolume")
484
485
    def segmentNodeChanged(self, event):
486
        """
487
        Called when the segment node is changed in the selector.
488
        Passes the caller information to updateParameterNode
489
        """
490
        self.updateParameterNodeFromGUI(event, "SegmentNode")
491
492
493
    def segmentChanged(self, event):
494
        """
495
        Called when the segment is changed in the selector.
496
        Passes the caller information to updateParameterNode
497
        """
498
        self.updateParameterNodeFromGUI(event, "Segment")
499
500
    def DICOMSeriesChanged(self, event):
501
        self.updateParameterNodeFromGUI(event, "DICOM")
502
503
    def updateParameterNodeFromGUI(self, event=None, caller=None):
504
        """
505
        This method is called when the user makes any change in the GUI.
506
        The changes are saved into the parameter node (so that they are restored when the scene is saved and loaded).
507
        """
508
        if self._parameterNode is None or self._updatingGUIFromParameterNode:
509
            return
510
511
        wasModified = self._parameterNode.StartModify()  # Modify all properties in a single batch
512
        self._parameterNode.SetNodeReferenceID("InputVolume", self.ui.inputSelector.currentNodeID)
513
        if caller == "InputVolume":
514
            if event is None:
515
                self._parameterNode.SetNodeReferenceID("InputVolume", None)
516
            else:
517
                self._parameterNode.SetNodeReferenceID("InputVolume", event.GetID())
518
        elif caller == 'SegmentNode':
519
            if event is None:
520
                self._parameterNode.SetNodeReferenceID("SegmentNode", None)
521
            else:
522
                self._parameterNode.SetNodeReferenceID("SegmentNode", event.GetID())
523
        elif caller == 'Segment':
524
            self._parameterNode.SetParameter("SegmentID", str(event))
525
        elif caller == 'DICOM' and event is not None:
526
            self._parameterNode.SetNodeReferenceID("DICOMNode", event.GetID())
527
        self._parameterNode.SetParameter("LowerThreshold", str(self.ui.thresholdSelector.lowerThreshold))
528
        self._parameterNode.SetParameter("UpperThreshold", str(self.ui.thresholdSelector.upperThreshold))
529
        self._parameterNode.SetParameter("Analysis", str(self.ui.analysisSelector.currentText))
530
        self._parameterNode.SetParameter("UseAlt", str(self.ui.AlternateDICOMCheckBox.checked))
531
        self._parameterNode.SetParameter("UseMan", str(self.ui.ManualDICOMCheckBox.checked))
532
        self.setNumParameter("0018,0050", str(self.ui.voxelSizeLineEdit.text))
533
        self.setNumParameter("0029,1000", str(self.ui.scalingLineEdit.text))
534
        self.setNumParameter("0029,1004", str(self.ui.densitySlopeLineEdit.text))
535
        self.setNumParameter("0029,1005", str(self.ui.densityInterceptLineEdit.text))
536
        self.setNumParameter("0028,1053", str(self.ui.rescaleSlopeLineEdit.text))
537
        self.setNumParameter("0028,1052", str(self.ui.rescaleInterceptLineEdit.text))
538
        self._parameterNode.SetParameter("OutputDirectory", str(self.ui.outputDirectorySelector.currentPath))
539
540
        if self._parameterNode.GetParameter("Analysis") == 'Intervertebral Disc':
541
            self._parameterNode.SetParameter("MultiSelect", 'True')
542
        else:
543
            self._parameterNode.SetParameter("MultiSelect", 'False')
544
545
        self._parameterNode.EndModify(wasModified)
546
        self.updateGUIFromParameterNode()
547
548
549
550
    # Sets a parameter to a value if the value can be converted to a float, otherwises sets it to blank
551
    def setNumParameter(self, parameter, value):
552
        try:
553
            float(value)
554
            self._parameterNode.SetParameter(parameter, value)
555
        except ValueError:
556
            self._parameterNode.SetParameter(parameter, "")
557
558
559
    def onApplyButton(self):
560
        """
561
        Run processing when user clicks "Apply" button.
562
        """
563
        with slicer.util.tryWithErrorDisplay("Failed to compute results.", waitCursor=True):
564
            # Compute output
565
            self.logic.process(self._parameterNode.GetNodeReference("InputVolume"), self._parameterNode.GetNodeReference("SegmentNode"), self._parameterNode.GetParameter("SegmentID"),
566
                               self.ui.thresholdSelector.lowerThreshold,  self.ui.thresholdSelector.upperThreshold, self.ui.analysisSelector.currentText, self.ui.outputDirectorySelector.currentPath,
567
                               self.ui.AlternateDICOMCheckBox.checked, self._parameterNode.GetNodeReference("DICOMNode"), self.ui.ManualDICOMCheckBox.checked,
568
                               {'0018,0050':self.ui.voxelSizeLineEdit.text, '0029,1000':self.ui.scalingLineEdit.text, '0029,1004':self.ui.densitySlopeLineEdit.text, '0029,1005':self.ui.densityInterceptLineEdit.text, '0028,1053':self.ui.rescaleSlopeLineEdit.text, '0028,1052':self.ui.rescaleInterceptLineEdit.text}, self)
569
        self.updateGUIFromParameterNode()
570
571
572
573
574
    # Updates
575
    def analysisUpdate(self, cliNode, event):
576
        if cliNode.GetStatus() & cliNode.Completed:
577
            self.ui.AnalysisProgress.setValue(100)
578
            self.ui.AnalysisProgress.hide()
579
            if cliNode.GetStatus() & cliNode.ErrorsMask:
580
                # error
581
                errorText = cliNode.GetErrorText()
582
                print("CLI execution failed: " + errorText)
583
            else:
584
                # success
585
                print("CLI execution succeeded.")
586
            startTime=float(self._parameterNode.GetParameter("startTime"))
587
            stopTime = time.time()
588
            logging.info(f'Processing completed in {stopTime-startTime:.2f} seconds')
589
            # Clean up temp nodes
590
            slicer.mrmlScene.RemoveNode(self._parameterNode.GetNodeReference("labelmapNode"))
591
            slicer.mrmlScene.RemoveNode(self._parameterNode.GetNodeReference("labelmapNode2"))
592
            slicer.mrmlScene.RemoveNode(cliNode)
593
            self._parameterNode.SetParameter("Analyzing", "False")
594
            self.updateGUIFromParameterNode()
595
        else:
596
            self.ui.AnalysisProgress.setValue(cliNode.GetProgress())
597
598
599
600
601
602
#
603
# MusculoskeletalAnalysisLogic
604
#
605
606
class MusculoskeletalAnalysisLogic(ScriptedLoadableModuleLogic):
607
    """This class should implement all the actual
608
    computation done by your module.  The interface
609
    should be such that other python code can import
610
    this class and make use of the functionality without
611
    requiring an instance of the Widget.
612
    Uses ScriptedLoadableModuleLogic base class, available at:
613
    https://github.com/Slicer/Slicer/blob/main/Base/Python/slicer/ScriptedLoadableModule.py
614
    """
615
616
    def __init__(self):
617
        """
618
        Called when the logic class is instantiated. Can be used for initializing member variables.
619
        """
620
        ScriptedLoadableModuleLogic.__init__(self)
621
622
    def setDefaultParameters(self, parameterNode):
623
        """
624
        Initialize parameter node with default settings.
625
        """
626
        if not parameterNode.GetParameter("Analysis"):
627
            parameterNode.SetParameter("Analysis", "Cortical Bone")
628
629
    def process(self, inputVolume, mask, maskLabel, lowerThreshold, upperThreshold, analysis, outputDirectory, altDICOM=False, DICOMNode=None, manDICOM=False, DICOMOptions=None, source=None, wait=False):
630
        """
631
        Run the processing algorithm.
632
        Can be used without GUI widget.
633
        :param InputVolume: volume to be thresholded
634
        :param Analysis: analysis function to perform
635
        :param OutputDirectory: directory to write output files to
636
        """
637
638
        # Check inputs
639
        if not inputVolume:
640
            raise ValueError("Input volume is invalid")
641
        if not mask or not maskLabel:
642
            raise ValueError("Segment is invalid")
643
        if altDICOM and not DICOMNode:
644
            raise ValueError("No DICOM source")
645
        if manDICOM and not all(DICOMOptions):
646
            raise ValueError("Not all DICOM options are selected")
647
648
        if not os.access(outputDirectory, os.W_OK):
649
            if not os.access(outputDirectory, os.F_OK):
650
                # If directory doesn't exist try to create it
651
                try:
652
                    os.makedirs(outputDirectory)
653
                    if not os.access(outputDirectory, os.W_OK):
654
                        raise ValueError("Output Directory is invalid")
655
                except:
656
                    raise ValueError("Output Directory is invalid")
657
            else:
658
                # If directory is not writable for other reason
659
                raise ValueError("Output Directory is invalid")
660
661
        startTime = time.time()
662
        logging.info('Processing started')
663
664
        # Get mask segments
665
        if analysis == 'Intervertebral Disc':
666
            maskLabel = maskLabel.strip('()')
667
            labels = maskLabel.split(', ')
668
            maskID = mask.GetSegmentation().GetSegmentIdBySegmentName(labels[0].strip('\''))
669
            maskArray = vtk.vtkStringArray()
670
            maskArray.InsertNextValue(maskID)
671
            labelmap = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLLabelMapVolumeNode")
672
            slicer.vtkSlicerSegmentationsModuleLogic.ExportSegmentsToLabelmapNode(mask, maskArray, labelmap, inputVolume)
673
674
            maskID2 = mask.GetSegmentation().GetSegmentIdBySegmentName(labels[1].strip('\''))
675
            maskArray2 = vtk.vtkStringArray()
676
            maskArray2.InsertNextValue(maskID2)
677
            labelmap2 = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLLabelMapVolumeNode")
678
            slicer.vtkSlicerSegmentationsModuleLogic.ExportSegmentsToLabelmapNode(mask, maskArray2, labelmap2, inputVolume)
679
        else:
680
            maskID = mask.GetSegmentation().GetSegmentIdBySegmentName(maskLabel)
681
            maskArray = vtk.vtkStringArray()
682
            maskArray.InsertNextValue(maskID)
683
            labelmap = slicer.mrmlScene.AddNewNodeByClass("vtkMRMLLabelMapVolumeNode")
684
            slicer.vtkSlicerSegmentationsModuleLogic.ExportSegmentsToLabelmapNode(mask, maskArray, labelmap, inputVolume)
685
686
687
        # Get DICOM source
688
        if altDICOM:
689
            dSource = DICOMNode
690
        elif manDICOM:
691
            dSource = DICOMOptions
692
        else:
693
            dSource = inputVolume
694
        if analysis != 'Intervertebral Disc':
695
            # Get Density info
696
            slope=float(self.getDICOMTag(dSource, '0029,1004'))/(float(self.getDICOMTag(dSource, '0029,1000'))*float(self.getDICOMTag(dSource, '0028,1053')))
697
            intercept=float(self.getDICOMTag(dSource, '0029,1005'))-(float(self.getDICOMTag(dSource, '0028,1052'))*slope)
698
699
700
        voxelSize = self.getDICOMTag(dSource, '0018,0050')
701
702
        # Prepare parameters for the selected function
703
        if analysis == "Cortical Bone":
704
            parameters = {"image":inputVolume, "mask":labelmap, "lowerThreshold":lowerThreshold, "upperThreshold":upperThreshold, "voxelSize":voxelSize, "slope":slope, "intercept":intercept, "inputName":inputVolume.GetName(), "output":outputDirectory}
705
            module=slicer.modules.corticalanalysis
706
            requirements = [('scipy', 'scipy'), ('skimage', 'scikit-image'), ('nrrd', 'pynrrd')]
707
        elif analysis == "Cancellous Bone":
708
            parameters = {"image":inputVolume, "mask":labelmap, "lowerThreshold":lowerThreshold, "upperThreshold":upperThreshold, "voxelSize":voxelSize, "slope":slope, "intercept":intercept, "inputName":inputVolume.GetName(), "output":outputDirectory}
709
            module=slicer.modules.cancellousanalysis
710
            requirements = [('scipy', 'scipy'), ('skimage', 'scikit-image'), ('nrrd', 'pynrrd'), ('trimesh', 'trimesh')]
711
        elif analysis == "Bone Density":
712
            parameters = {"image":inputVolume, "mask":labelmap, "voxelSize":voxelSize, "slope":slope, "intercept":intercept, "inputName":inputVolume.GetName(), "output":outputDirectory}
713
            module=slicer.modules.densityanalysis
714
            requirements = [('nrrd', 'pynrrd')]
715
        elif analysis == 'Intervertebral Disc':
716
            parameters = {"image":inputVolume, "mask1":labelmap, "mask2":labelmap2, "voxelSize":voxelSize, "inputName":inputVolume.GetName(), "output":outputDirectory}
717
            module = slicer.modules.intervertebralanalysis
718
            requirements = [('scipy', 'scipy'), ('nrrd', 'pynrrd')]
719
720
        # Install required python modules
721
        self.importRequest(requirements)
722
723
        node = slicer.cli.createNode(module, parameters=parameters)
724
        # Set up source before running to avoid race conditions
725
        if source:
726
            source.ui.AnalysisProgress.setValue(0)
727
            source.ui.AnalysisProgress.show()
728
            source._parameterNode.SetParameter("Analyzing", "True")
729
            source._parameterNode.SetNodeReferenceID("labelmapNode", labelmap.GetID())
730
            if 'labelmap2' in locals():
731
                source._parameterNode.SetNodeReferenceID("labelmapNode2", labelmap2.GetID())
732
            source._parameterNode.SetParameter("startTime", str(startTime))
733
            node.AddObserver('ModifiedEvent', source.analysisUpdate)
734
        slicer.cli.run(module=module, node=node, wait_for_completion=wait)
735
736
737
    # Used to get dicom metadata from the volume
738
    # source: the volume node or DICOM dict
739
    # tag: The DICOM tag number as a string ('####,####')
740
    def getDICOMTag(self, source, tag):
741
        if type(source) is dict:
742
            data=source[tag]
743
        else:
744
            shNode = slicer.vtkMRMLSubjectHierarchyNode.GetSubjectHierarchyNode(slicer.mrmlScene)
745
            volumeItemId = shNode.GetItemByDataNode(source)
746
            seriesInstanceUID = shNode.GetItemUID(volumeItemId, 'DICOM')
747
748
            db = slicer.dicomDatabase
749
            instanceList = db.instancesForSeries(seriesInstanceUID)
750
            data = db.instanceValue(instanceList[0], tag)
751
        return data
752
753
    # Check a list of modules to see if they are installed, request permission to install any missing.
754
    # requested: A list of tuples in the format (moduleName, pipName)
755
    def importRequest(self, requested):
756
        missing = []
757
        for r in requested:
758
            if not importlib.util.find_spec(r[0]):
759
                missing.append(r)
760
        if len(missing) > 0:
761
            names, pips = zip(*missing)
762
            if slicer.util.confirmOkCancelDisplay("The following python modules are required: " + ", ".join(names) + ". Do you want to install them?"):
763
                for p in pips:
764
                    slicer.util.pip_install(p)
765
                    #pass
766
            else:
767
                raise ImportError("Required python modules do not have permission to install")
768
769
770
771
#
772
# MusculoskeletalAnalysisTest
773
#
774
775
class MusculoskeletalAnalysisTest(ScriptedLoadableModuleTest):
776
    """
777
    This is the test case for your scripted module.
778
    Uses ScriptedLoadableModuleTest base class, available at:
779
    https://github.com/Slicer/Slicer/blob/main/Base/Python/slicer/ScriptedLoadableModule.py
780
    """
781
782
    def setUp(self):
783
        """ Do whatever is needed to reset the state - typically a scene clear will be enough.
784
        """
785
        slicer.mrmlScene.Clear()
786
787
    def runTest(self):
788
        """Run as few or as many tests as needed here.
789
        """
790
        self.setUp()
791
        self.test_MusculoskeletalAnalysis1()
792
793
    def test_MusculoskeletalAnalysis1(self):
794
        """ Ideally you should have several levels of tests.  At the lowest level
795
        tests should exercise the functionality of the logic with different inputs
796
        (both valid and invalid).  At higher levels your tests should emulate the
797
        way the user would interact with your code and confirm that it still works
798
        the way you intended.
799
        One of the most important features of the tests is that it should alert other
800
        developers when their changes will have an impact on the behavior of your
801
        module.  For example, if a developer removes a feature that you depend on,
802
        your test should break so they know that the feature is needed.
803
        """
804
805
        self.delayDisplay("Starting the test")
806
807
808
        # Get/create input data
809
        from datetime import date
810
        import SampleData
811
        # Make sure the scene is clear before starting
812
        slicer.mrmlScene.Clear()
813
        registerSampleData()
814
815
816
        try:
817
            # Set test parameters
818
            inputVolume = SampleData.downloadSample('Cortical1')
819
            mask = SampleData.downloadSample('CorticalMask1')
820
            maskLabel = "Segment_1"
821
            lowerThreshold = 4000
822
            upperThreshold = 10000
823
            analysis="Cortical Bone"
824
            options = {"0018,0050":0.0073996, "0028,1052":-1000, "0028,1053":0.4943119, "0029,1000":4096,"0029,1004":365.712, "0029,1005":-199.725998, }
825
            outputDirectory = os.path.expanduser("~\\Documents\\MusculoskeletalAnalysisTest")
826
827
            self.delayDisplay('Loaded test data set')
828
            # Test the module logic
829
830
            logic = MusculoskeletalAnalysisLogic()
831
832
833
            # Test cortical analysis
834
            logic.process(inputVolume, mask, maskLabel, lowerThreshold, upperThreshold, analysis, outputDirectory, manDICOM=True, DICOMOptions=options, wait=True)
835
            self.testFile(os.path.join(outputDirectory, "cortical.txt"), [str(date.today()), "Cortical1", 0.21890675924688482, 0.06119903498019812, 1094.0291917644427, 0.08353005741605146, 1.6847633350543425, 0.9198784024224288, 0.7648849326319137, 0.47030180777724473, 0.0073996])
836
        finally:
837
            # Clean up nodes
838
            slicer.mrmlScene.Clear()
839
840
        try:
841
            # Set test parameters
842
            inputVolume = SampleData.downloadSample('Cancellous1')
843
            mask = SampleData.downloadSample('CancellousMask1')
844
            maskLabel = "Segment_1"
845
            lowerThreshold = 1500
846
            upperThreshold = 10000
847
            analysis="Cancellous Bone"
848
            self.delayDisplay('Loaded test data set')
849
            # Test cancellous analysis
850
            logic.process(inputVolume, mask, maskLabel, lowerThreshold, upperThreshold, analysis, outputDirectory, manDICOM=True, DICOMOptions=options, wait=True)
851
            self.testFile(os.path.join(outputDirectory, "cancellous.txt"), [str(date.today()), "Cancellous1", 1.333489381819056, 0.27215499848346064, 0.20409236263449296, 0.05182980579223419, 0.01560752819180971, 0.16437754493704732, 0.06443102087549339, 6.083556001417202, 1.6664854524662474, 365.2072574703726, 589.9997317002255, 0.0073996, 1500.0, 10000.0])
852
853
            # Reuses cancellous parameters
854
            analysis="Bone Density"
855
            self.delayDisplay('Loaded test data set')
856
            # Test density analysis
857
            logic.process(inputVolume, mask, maskLabel, lowerThreshold, upperThreshold, analysis, outputDirectory, manDICOM=True, DICOMOptions=options, wait=True)
858
            self.testFile(os.path.join(outputDirectory, "density.txt"), ["", "", 2.32469398135312, 147.00866960658377, 309.78804391297064, -255.03709872604347, 1198.6849313585226, "", "", "", "", "", "", "", ""])
859
        finally:
860
            # Clean up nodes
861
            slicer.mrmlScene.Clear()
862
863
        try:
864
            # Set test parameters
865
            inputVolume = SampleData.downloadSample('Intervertebral1')
866
            mask = SampleData.downloadSample('IntervertebralMask1')
867
            maskLabel = "'Segment_1, Segment_2'"
868
            lowerThreshold = 0
869
            upperThreshold = 0
870
            analysis="Intervertebral Disc"
871
            self.delayDisplay('Loaded test data set')
872
            # Test cancellous analysis
873
            logic.process(inputVolume, mask, maskLabel, lowerThreshold, upperThreshold, analysis, outputDirectory, manDICOM=True, DICOMOptions=options, wait=True)
874
            self.testFile(os.path.join(outputDirectory, "intervertebral.txt"), [str(date.today()), "Intervertebral1", 0.28008754758301957, 0.0694562859207484, 0.24798062791478137, 1.4070918728304844, 1.0306450189585161, 0.1775904, 0.12621094857350137, 0.0073996])
875
876
        finally:
877
            # Clean up nodes
878
            slicer.mrmlScene.Clear()
879
880
881
        self.delayDisplay('Test passed')
882
883
    # testFile
884
    # Tests that the newest line of an output file matches a specified input
885
    # fileName: The path to the output file. Output file should be a tsv file
886
    # data: A list of strings to compare the file to
887
    # Throws an assertion error if the data does not match
888
    def testFile(self, fileName, data):
889
        # Confirm that file exist
890
        assert os.path.exists(fileName), "Filename "+fileName+" does not exist."
891
        # Get the header from the first line of the file and the most recent data as the last line
892
        with open(fileName) as f:
893
            lines = f.read().splitlines()
894
            firstLine = lines[0]
895
            lastLine = lines[-1]
896
        header = firstLine.split("\t")
897
        testData = lastLine.split("\t")
898
        # Check that the number of rows is correct
899
        assert len(data) == len(testData), "Expected "+ str(len(data)) + " lines, got " + str(len(testData)) + " instead."
900
        for i in range(len(data)):
901
            try:
902
                # Checks that numeric data is within 5%
903
                float(testData[i]) # If the data is not convertable to a float, throws a value error and compares data as a string
904
                assert (float(data[i]) > float(testData[i])*.95 and float(data[i]) < float(testData[i])*1.05) or (float(data[i]) < float(testData[i])*.95 and float(data[i]) > float(testData[i])*1.05), "Value for " + header[i] + ", " + testData[i] + " outside of range of expected value " + str(data[i]) + "."
905
            except ValueError:
906
                # Checks that strings match
907
                assert data[i] == testData[i], "Value for " + header[i] + ", " + testData[i] + ", doesn't match expected value " + str(data[i]) + "."
908