Skip to content

Commit

Permalink
ENH: Improve ultrasound reading (#7649)
Browse files Browse the repository at this point in the history
* ENH: Add DICOM patcher rule to split ultrasound series

On some ultrasound systems, all ultrasound images in a study get saved with the same series instance UID.
This makes it very difficult to browser images, because when the series is opened then all the (potentially dozens or even hundreds) acquisitions are loaded, each one as a new sequence.

The new rule allows the user to split an ultrasound series based on the "Instance number" attribute. Each instance number within a series is split out into a new series.

* ENH: Read pixel spacing for single-region ultrasound images
  • Loading branch information
lassoan committed Mar 20, 2024
1 parent d52e09a commit d092720
Show file tree
Hide file tree
Showing 2 changed files with 75 additions and 5 deletions.
47 changes: 47 additions & 0 deletions Modules/Scripted/DICOMPatcher/DICOMPatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ def setup(self):
" (see the 'exposure fiasco' - http://dclunie.blogspot.com/2008/11/dicom-exposure-attribute-fiasco.html).")
parametersFormLayout.addRow("Fix invalid exposure tags", self.fixExposureFiascoCheckBox)

self.splitUltrasoundSeriesByInstanceNumberCheckBox = qt.QCheckBox()
self.splitUltrasoundSeriesByInstanceNumberCheckBox.checked = False
self.splitUltrasoundSeriesByInstanceNumberCheckBox.setToolTip(_("If checked, then ultrasound image series are split by instance number."
" Useful if many ultrasound acquisitions appear in the same series."))
parametersFormLayout.addRow("Split ultrasound series by instance number", self.splitUltrasoundSeriesByInstanceNumberCheckBox)

characterSetLayout = qt.QHBoxLayout()

self.specifyCharacterSetCheckBox = qt.QCheckBox()
Expand Down Expand Up @@ -174,6 +180,8 @@ def onPatchButton(self):
self.logic.clearRules()
if self.fixExposureFiascoCheckBox.checked:
self.logic.addRule("FixExposureFiasco")
if self.splitUltrasoundSeriesByInstanceNumberCheckBox.checked:
self.logic.addRule("SplitUltrasoundSeriesByInstanceNumber")
if self.forceSamePatientNameIdInEachDirectoryCheckBox.checked:
self.logic.addRule("ForceSamePatientNameIdInEachDirectory")
if self.forceSameSeriesInstanceUidInEachDirectoryCheckBox.checked:
Expand Down Expand Up @@ -596,6 +604,44 @@ def generateOutputFilePath(self, ds, filepath):
return filePath


#
#
#


class SplitUltrasoundSeriesByInstanceNumber(DICOMPatcherRule):
def __init__(self, parameters=None):
super().__init__(parameters)
self.requiredTags = ["SOPClassUID", "SeriesInstanceUID", "SeriesNumber", "InstanceNumber"]
self.supportedSOPClassUIDs = [
"1.2.840.10008.5.1.4.1.1.3.1", # Ultrasound Multiframe Image Storage
"1.2.840.10008.5.1.4.1.1.6.1", # Ultrasound Image Storage
]

def processStart(self, inputRootDir, outputRootDir):
self.seriesInstanceUidAndInstanceNumberToNewSeriesInstanceUidMap = {}

def processDataSet(self, ds):
import pydicom

# Return if this is not an ultrasound series or there is no instance number
for tag in self.requiredTags:
if not hasattr(ds, tag):
return
if ds.SOPClassUID not in self.supportedSOPClassUIDs:
return

# Get the new series instance UID for this series instance UID and instance number
seriesInstanceUidAndInstanceNumber = (ds.SeriesInstanceUID, ds.InstanceNumber)
if seriesInstanceUidAndInstanceNumber not in self.seriesInstanceUidAndInstanceNumberToNewSeriesInstanceUidMap:
self.seriesInstanceUidAndInstanceNumberToNewSeriesInstanceUidMap[seriesInstanceUidAndInstanceNumber] = pydicom.uid.generate_uid(None)

# Set the new series instance UID
ds.SeriesInstanceUID = self.seriesInstanceUidAndInstanceNumberToNewSeriesInstanceUidMap[seriesInstanceUidAndInstanceNumber]
# Generate new series number (otherwise it would be difficult to identify the series)
ds.SeriesNumber = ds.SeriesNumber * 1000 + ds.InstanceNumber


#
# DICOMPatcherLogic
#
Expand Down Expand Up @@ -792,6 +838,7 @@ def test_DICOMPatcher1(self):
logic.addRule("UseCharacterSet", {"CharacterSet": "cp1251"})
logic.addRule("Anonymize")
logic.addRule("NormalizeFileNames")
logic.addRule("SplitUltrasoundSeriesByInstanceNumber")
logic.patchDicomDir(inputTestDir, outputTestDir)

self.delayDisplay("Verify generated files")
Expand Down
33 changes: 28 additions & 5 deletions Modules/Scripted/DICOMPlugins/DICOMImageSequencePlugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ class DICOMImageSequencePluginClass(DICOMPlugin):
loads frames as a single-slice-volume sequence (and not as a 3D volume),
it accepts color images, and handles multiple instances within a series
(e.g., multiple independent acquisitions and synchronized biplane acquisitions).
Limitation: ultrasound calibrated regions are not supported (each calibrated region
would need to be split out to its own volume sequence).
Limitation: on ultrasound images, only a single calibrated region is supported
(to support more, each calibrated region could be split out to its own volume sequence).
"""

def __init__(self):
Expand Down Expand Up @@ -151,7 +151,25 @@ def examineFiles(self, files):
loadable.singleSequence = False # put each instance in a separate sequence
loadable.files = [filePath]
loadable.name = name.strip() # remove leading and trailing spaces, if any
loadable.warning = _("Image spacing may need to be calibrated for accurate size measurements.")
# Read image spacing from SequenceOfUltrasoundRegions
loadable.spacingMmPerPixel = None
if modality == "US":
ds = dicom.read_file(filePath, stop_before_pixels=True)
if hasattr(ds, "SequenceOfUltrasoundRegions"):
if len(ds.SequenceOfUltrasoundRegions) == 1:
region = ds.SequenceOfUltrasoundRegions[0]
UNITS_CM = 3 # PhysicalDeltaX and PhysicalDeltaY are in cm
TISSUE_2D = 1
TISSUE = 1
COLOR_FLOW = 2
if (hasattr(region, "RegionSpatialFormat") and region.RegionSpatialFormat == TISSUE_2D
and hasattr(region, "RegionDataType") and (region.RegionDataType == TISSUE or region.RegionDataType == COLOR_FLOW)
and hasattr(region, "PhysicalDeltaX") and hasattr(region, "PhysicalDeltaY")
and hasattr(region, "PhysicalUnitsXDirection") and region.PhysicalUnitsXDirection == UNITS_CM
and hasattr(region, "PhysicalUnitsYDirection") and region.PhysicalUnitsYDirection == UNITS_CM):
loadable.spacingMmPerPixel = [region.PhysicalDeltaX * 10.0, region.PhysicalDeltaY * 10.0]

loadable.warning = "" if hasattr(loadable, "spacingMmPerPixel") else _("Image spacing may need to be calibrated for accurate size measurements.")
loadable.tooltip = _("{modality} image sequence").format(modality=modality)
loadable.selected = True
# Confidence is slightly larger than default scalar volume plugin's (0.5)
Expand Down Expand Up @@ -268,14 +286,18 @@ def addSequenceBrowserNode(self, name, outputSequenceNodes, playbackRateFps, loa
# Show sequence browser toolbar
slicer.modules.sequences.showSequenceBrowser(outputSequenceBrowserNode)

def addSequenceFromImageData(self, imageData, tempFrameVolume, filePath, name, singleFileInLoadable):
def addSequenceFromImageData(self, imageData, tempFrameVolume, filePath, name, singleFileInLoadable, spacingMmPerPixel):
# Rotate 180deg, otherwise the image would appear upside down
ijkToRas = vtk.vtkMatrix4x4()
ijkToRas.SetElement(0, 0, -1.0)
ijkToRas.SetElement(1, 1, -1.0)
tempFrameVolume.SetIJKToRASMatrix(ijkToRas)
# z axis is time
[spacingX, spacingY, frameTimeMsec] = imageData.GetSpacing()
# Override spacing in image for ultrasound images
if spacingMmPerPixel:
spacingX = spacingMmPerPixel[0]
spacingY = spacingMmPerPixel[1]
imageData.SetSpacing(1.0, 1.0, 1.0)
tempFrameVolume.SetSpacing(spacingX, spacingY, 1.0)
tempFrameVolume.SetAttribute("DICOM.instanceUIDs", slicer.dicomDatabase.instanceForFile(filePath))
Expand Down Expand Up @@ -372,8 +394,9 @@ def load(self, loadable):
outputSequenceNode.SetDataNodeAtValue(tempFrameVolume, str(instanceNumber))
else:
# each file is a new sequence
spacingMmPerPixel = loadable.spacingMmPerPixel if hasattr(loadable, "spacingMmPerPixel") else None
outputSequenceNode, playbackRateFps = self.addSequenceFromImageData(
imageData, tempFrameVolume, filePath, loadable.name, (len(loadable.files) == 1))
imageData, tempFrameVolume, filePath, loadable.name, (len(loadable.files) == 1), spacingMmPerPixel)
outputSequenceNodes.append(outputSequenceNode)

# Delete temporary volume node
Expand Down

0 comments on commit d092720

Please sign in to comment.