Case Iterator with segment editor

I have a folder full of nifti files that I would like to convert them to 3D models after a threshold effect. I am trying to use Case Iterator to help me with the naming and saving, but so far wasn’t able get very far with it. I can read the input files, go back and forth between cases, but it doesn’t do anything about the saving.

Here is a screenshot of the data module with one of the cases loaded:
image
I would like to have both Segment_1 (the segmentation) and Segment_1 (the model) to be saved with the original nifti file name.

Is there a way to configure Case Iterator to do that?

Currently, not without some programming.

If you have checked the “save new masks” and “save loaded masks”, case iterator should at least save your segmentation, though your model will be discarded.

In the github repository, I also have a dev branch, which has multiple additional features, both for the user interface and “under-the-hood” stuff. This includes a more easy way of defining a customized iterator, and auto-updating the layout to accomodate the loaded volumes, aligned on the slices (instead of reformatted volumes to truly transversal/sagittal/coronal).

So if you want to use caseIterator to include saving your models, you’ll have to customize the version you have. You can either update the current master (this would involve adding a function for saving models, and calling that function when the case closes). Alternatively, if you also want to use the newer features, I can help you write a customized iterator which also stores models as part of the workflow.

For updating the current version in the Slicer Extensions index, do the following:

Add this piece of code to CsvTableIterator.py, just after saveMask() function (i.e. make it part of the CaseTableIteratorLogic class:

  def saveModel(self, node, reader, overwrite_existing=False):
    storage_node = node.GetStorageNode()
    if storage_node is not None and storage_node.GetFileName() is not None:
      # mask was loaded, save the updated mask in the same directory
      target_dir = os.path.dirname(storage_node.GetFileName())
    else:
      target_dir = self.currentCaseFolder

    if not os.path.isdir(target_dir):
      self.logger.debug('Creating output directory at %s', target_dir)
      os.makedirs(target_dir)

    nodename = node.GetName()
    # Add the readername if set
    if reader is not None:
      nodename += '_' + reader
    filename = os.path.join(target_dir, nodename)

    # Prevent overwriting existing files
    if os.path.exists(filename + '.vtk') and not overwrite_existing:
      self.logger.debug('Filename exists! Generating unique name...')
      idx = 1
      filename += '(%d).vtk'
      while os.path.exists(filename % idx):
        idx += 1
      filename = filename % idx
    else:
      filename += '.vtk'

    # Save the node
    slicer.util.saveNode(node, filename)
    self.logger.info('Saved node %s in %s', nodename, filename)

Add the call to this function in SlicerCaseIterator.py, in function _closeCase() (L423-L446). You can do this by inserting this piece of code in L435 (just after the if self.saveNew: block:

for n in slicer.util.getNodesByClass('vtkMRMLModelNode'):
  self.iterator.saveModel(n, self.reader)

Thank you it worked to save the segments and models. But it still retains the default naming (segment_1, etc), as oppose to remaining it to the original input volume. I will try to fiddle and see if I can get the original volume name in the input table as the name of files being saved.

Ah, I may be able to help with that too. I assume you create new segmenations for each case right?

This is handled in CsvTableIterator.py:L296-300:

   ma = self._getColumnValue('mask', case_idx)
   if ma is not None:
     ma_node = self._loadMaskNode(root, ma, im_node)
   else:
     ma_node = None

Basically, if you don’t specify a mask, none is loaded and when the segment editor module is entered, it creates one for you, with the name ‘Segmentation’.

If you replace update this block to the following:

    ma = self._getColumnValue('mask', case_idx)
    if ma is not None:
      ma_node = self._loadMaskNode(root, ma, im_node)
    else:
      ma_node = slicer.vtkMRMLSegmentationNode()
      slicer.mrmlScene.AddNode(ma_node)
      ma_node.SetReferenceImageGeometryParameterFromVolumeNode(im_node)
      ma_node.SetName(im_node.GetName() + '_Segmentation')

This will now add a segmentation node if no mask was specified to load, and set it’s name to match to input volume, with ‘_Segmentation’ appended. It is possible to use exactly the same name as your image volume, but IMHO appending a suffix like ‘segmentation’ keeps it nice and clear that this node represents a segmentation that belongs to your image, and will also be reflected as such in the filesystem upon saving…

@JoostJM. Thank you very much for all your help.

I did the change and restarted the Slicer, but as far as I can tell behavior is still the same. It keeps the default segment names. My initial table did not have field called mask, only the images and path column so I add it an empty column with name mask, but that didn’t make a difference either?

Ah I think we were talking about 2 different things here. If I’m correct, it should have added a new segmentation Node, with the name set as described above. However, this still results in the individual segments receiving names like ‘segment_1’, ‘segment_2’, etc.

Can you please clarify why you also want to control the naming of the segments? When stored on the disk, the filename is derived from the node name (i.e. the segmentation node, which contains all your segments). Individual segment names are only visible inside slicer (added in the nrrd header, but as far as I know ignored by most other programs).
I assumed you meant the segmentation node name when you were writing about setting it to the volume name, as the segmentation node is linked to the volume, therefore setting the individual segment’s name to the volume name seemed a bit superfluous to me. When I change the individual segments name, I usually do so to indicate what the ROI designates (like ‘tumour’, ‘lymphnode’, ‘liver’, etc.)

Typing a segment name each time seems quite error-prone (typos, differences in spelling, etc. can all cause issues). It would be useful to initialize each new segmentation node by copying empty segments with pre-populated names (and terminology) from a “template” segmentation node.

Recent Slicer Preview Releases facilitate this workflow by adding “completion state” (new, being edited, completed, flagged) to each segment - see details here: New feature: Search and filter in segments table.

I want to control the naming, because I ultimately I want to export them as ply volumes. If every model from a different volume gets the segment_1 name, they will overwrite each other at the time of saving. As Andras mentioned below, manually changing this is tedious (for large number of volumes) and is error prone.

I was looking for a behavior similar to the fiducial list. If I create a fiducial list Murat, then the first point gets the name Murat-1. So along those lines, if my segmentation node is automatically set to the name of the volume, then first segment gets the name volume_Segment_1 (or something along those lines, or the suffices can be managed through a template). Keeping everything in a hierarchy works well when everything is managed within the scene, but when saving/exporting individual elements generic names like Segment_1 causes issues.

@lassoan very interesting! Great to see how segmentation keeps evolving in Slicer :slight_smile:
@muratmaga, when something like a template node gets implemented, I’ll probably use that to add prefixes for new segments.

In the meantime, I’ve hacked together a piece of code that does more or less the same. It’ll only work for segmentation nodes that have been added by the case iterator, as it needs to set up observers and keep track of some data to work.

These are the new/adapted functions (all in SlicerCaseIteratorLogic class):

  import re

  (...)

  def __init__(self, iterator, start, redirect, saveNew=False, saveLoaded=False, multiViewer=False):

    self.logger = logging.getLogger('SlicerCaseIterator.logic')

    self.mask_node_observers = []
    self.prefixes = {}

    (...)  # Rest of the __init__ function

  def __del__(self):
    # Free up the references to the nodes to allow GC and prevent memory leaks
    self.logger.debug('Destroying Case Iterator Logic instance')
    self.currentCase = None
    self.iterator = None

    # New part:
    self.remove_mask_node_observers()

  def observe_mask_node(self, node):
    if node is None:
      self.logger.debug("No node to observe passed. Skipping adding observer")
      return

    # Observe the SegmentAdded event to enable changing the auto-naming behaviour
    segmentation = node.GetSegmentation()
    self.mask_node_observers.append((node, segmentation.AddObserver(segmentation.SegmentAdded, self.onSegmentAdded)))

    # Store the prefix we want to use, as the event only passes the segmentation,
    # not the segmentationNode
    self.prefixes[segmentation.GetAddressAsString(None)] = node.GetName()

  def remove_mask_node_observers(self):
    if len(self.mask_node_observers) == 0:
      self.logger.debug("Not observing any node!")

    for node, obs in self.mask_node_observers:
      segmentation = node.GetSegmentation()
      segmentation.RemoveObserver(obs)
      seg_addr = segmentation.GetAddressAsString(None)
      if seg_addr in self.prefixes:
        del self.prefixes[seg_addr]
    self.mask_node_observers= []

  def onSegmentAdded(self, caller, event):
    # caller is vtkSegment, not vtkMRMLSegmentationNode!
    try:
      # Get the last added segment, and check if it is a new empty segment with standard name
      new_segment = caller.GetNthSegment(caller.GetNumberOfSegments() - 1)
      name_match = re.match(r'Segment_(?P<seg_no>\d+)', new_segment.GetName())
      seg_addr = caller.GetAddressAsString(None)  # Needed to look up prefix

      if seg_addr not in self.prefixes:
        self.logger.debug('Segment added, but segmentation does not have a prefix set. Skipping setting name')
      elif name_match is None:
        self.logger.debug('Segment added, but non-standard name. Possibly imported segment. Skipping setting name')
      else:
        new_name = self.prefixes[seg_addr] + '_%s' % name_match.groupdict()['seg_no']
        self.logger.debug('Segment added, Auto-setting name to %s', new_name)
        new_segment.SetName(new_name)
    except Exception:
      self.logger.warning('Error setting new name for segment!', exc_info=True)

  def _loadCase(self, new_idx):
    
    (...)

      if self.redirect:
        self.iterator.backend.enter_module(im, ma)
        # Observer the master mask to enable auto-renaming new segments
        self.iterator.backend.observe_mask_node(ma)

  def _closeCase(self, new_idx):

    (...)

          if mask is not None:
            self.iterator.saveMask(mask,)
            self.iterator.backend.remove_mask_node_observers()  # clean-up observers

This will keep track of your master segmentation node, and each time you add a new segment (which will be named Segment_<N>, Case Iterator catches the event, and updates the name to <Prefix>_<N>, where <Prefix> is the segmentation name as it is set during loading.