Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: VectorToScalar optionally extract single components. #967

Closed
wants to merge 2 commits into from

Conversation

phcerdan
Copy link

@phcerdan phcerdan commented Jun 7, 2018

This commit improves the user interface adding support for choosing
the conversion mode.

Available conversion modes are now:

  • vector (RGB, RGBA) images to luminance (default mode keeping backward
    compatibility with original module implementation)
  • vector images to single component (user select which component to extract)

To support reusing of components by other scripted modules, a logic
and widget have also been created:

  • VectorToScalarVolumeConversionModeWidget
  • VectorToScalarVolumeLogic

Co-authored-by: Jean-Christophe Fillion-Robin [email protected]

@phcerdan phcerdan force-pushed the enhance_vector_to_scalar branch 2 times, most recently from 681ebd0 to 31a4d45 Compare June 7, 2018 21:52
Copy link
Member

@cpinter cpinter left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, except for the checkboxes that disable each other (see comment in code).

def onRGBCheckBox(self, state):
if (state == 2): # Checked
# Disable other box.
self.singleComponentBox.setChecked(False)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know exactly how the UI looks like, but if you have to do this, then probably radio button would be a better widget to use.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely right, thanks @cpinter

Copy link
Member

@jcfr jcfr Jun 8, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can then use two QRadioButton, if they have the same parent, the radio buttons will be autoExclusive by default. Only one will be checked at a time.

Copy link
Author

@phcerdan phcerdan Jun 8, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't get the ratio buttons to be exclusive just with the parent (maybe because there are frames?). Anyway, I created an exclusive QButtonGroup, and add the buttons there. It is a few lines more verbose but explicit. Let me know what you think.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Common parent and setAutoExclusive(true) usually does the trick. Maybe you forgot the latter? Jc says it's not needed, but maybe it depends on the order you do things. What happens if you set it to true explicitly? That said I wouldn't mind the button group if that's how you can make it work.

@pieper
Copy link
Member

pieper commented Jun 7, 2018

LGTM - thanks! 👍

self.componentsComboBox.setEnabled(False)

def onSingleComponentCheckBox(self, state):
if (state == 2): # Checked
Copy link
Member

@jcfr jcfr Jun 8, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case like this one, parenthesis are not needed in python.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For future reference, Qt enum value can be accessed using qt.Qt.Checked

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

qt.Qt.Checked good to know.

@@ -76,6 +103,26 @@ def setup(self):
# Add vertical spacer
self.layout.addStretch(1)

def onRGBCheckBox(self, state):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would then be named onRGBRadioButtonToggled(self, checked):

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed one slot, and just work with a RadioButton toggle, checking the state of the buttons themselves, not the boolean signal.

@phcerdan
Copy link
Author

phcerdan commented Jun 8, 2018

@cpinter @jcfr I have added QRadialButtons, see my replies to @jcfr for extra details. Thanks for the reviews!

@cpinter
Copy link
Member

cpinter commented Jun 8, 2018

Excellent, thanks for the contribution!

@phcerdan
Copy link
Author

phcerdan commented Jun 8, 2018

The GUI looks like:

peekslicervectortoscalar

@lassoan
Copy link
Contributor

lassoan commented Jun 8, 2018

If you only select between integers then use a spinbox instead of a combobox. But I think it would be even better to make Luminance option the first option in the combobox. Then we could get rid of all the radiobuttons and have a simple label (Component) and a combobox (Luminance, 0, 1, 2,...).

Note that luminance is not the same as mean(r, g, b). I think we use the latter. If you really need luminance then it could be an additional entry in the combobox, which is only displayed for 3 or maybe 4 component images (assuming rgb and rgba).

Number of components should be updated whenever a new volume is selected. We could also add an observer to the image change event of the node so that we can update the widget when some module changes the volume in the background or the user changes things in the Python interactor.

@phcerdan
Copy link
Author

phcerdan commented Jun 9, 2018

@lassoan thanks a lot for the input.

Number of components should be updated whenever a new volume is selected.

That is/was already implemented.

We could also add an observer to the image change event of the node so that we can update the widget when some module changes the volume in the background or the user changes things in the Python interactor.

Sounds good, maybe in another PR?

Note that luminance is not the same as mean(r, g, b). I think we use the latter.

This module was using the vtkLuminanceImage, so it was computing luminance, not mean. But adding mean or any other function that reduces dimensionality to scalar sounds good.

If you only select between integers then use a spinbox instead of a combobox.

Nice, I'll do.

But I think it would be even better to make Luminance option the first option in the combobox. Then we could get rid of all the radiobuttons and have a simple label (Component) and a combobox (Luminance, 0, 1, 2,...).

Yeah, I liked the idea, and @jcfr also mentioned it.
But my reasoning was to keep the extract single components separated from any other operator. Mainly, because it doesn't require any extra checking or logic of the input vector volume, it always work for any dimension of the vector. On the contrary, Luminance requires at least 3 components, and other functions might impose other conditions.
And also from a coding point of view, it was convenient to link the current index of the combo/spin directly to the component. Adding entries will require tracking and extra mapping.
I am not against this change, but separating the extraction of a single component from other operations working on all the dimensions seemed sensible. Let me know what you think.

@phcerdan
Copy link
Author

phcerdan commented Jun 9, 2018

Current state:

peekslicervectortoscalarupdatejc

@lassoan
Copy link
Contributor

lassoan commented Jun 9, 2018

The GUI looks nice now, so you can leave it like this.

If you consider using a single combobox: Using combox is easy, as you can set custom data for each item, so you don't need to maintain an external mapping.

https://github.com/Slicer/Slicer/blob/f051e24c803ae0a067e04ea2b2a9a178040d7415/Modules/Loadable/Segmentations/EditorEffects/Python/SegmentEditorSmoothingEffect.py#L39-L45

https://github.com/Slicer/Slicer/blob/f051e24c803ae0a067e04ea2b2a9a178040d7415/Modules/Loadable/Segmentations/EditorEffects/Python/SegmentEditorSmoothingEffect.py#L102-L103

Observing image data should be quite simple, too (about 6-8 lines of code) and all core modules have this kind of always-up-to-date GUI (you don't need to click around the GUI to get it updated). If you don't have the time to add this small amount of logic then use a spinbox with very high max value instead of enumerating specific values.

Unfortunately, this scripted module has other fundamental issues (it does not use parameter node, so user selections are not saved into the scene; there is no logic class - everything is just dumped into the widget class), which should be addressed at some point in the future.

Thank you for working on this and sorry for being picky, but we try to keep Slicer core modules quality as high as possible, as they may be used in many other modules and extensions; and they also often serve as example for custom modules.

@jcfr
Copy link
Member

jcfr commented Jun 11, 2018

we try to keep Slicer core modules quality as high as possible, as they may be used in many other modules and extensions; and they also often serve as example for custom modules.

@lassoan Thanks for the careful review 👍

(1) Observing image data should be quite simple
(2) use parameter node

@phcerdan I suggest you add support for these two features. Let me know if you need help.

there is no logic class - everything is just dumped into the widget class), which should be addressed at some point in the future.

Good news is that this PR now add a logic class

@phcerdan
Copy link
Author

@jcfr @lassoan thanks for the inputs and review. I am working on (1) and (2) and learning slicer guts at the same time. I'll update this soon.

@phcerdan
Copy link
Author

New commit because there was quite a few changes, and didn't want to lose the original changes. I have added:
(1) Observing image data should be quite simple
(2) use parameter node

And the new method to compute the average of all components.

@phcerdan
Copy link
Author

Current state

peekslicervectortoscalarupdateaverage

@phcerdan
Copy link
Author

I have an issue with the automatic visualization of the generated output.
The new average conversion method returns a ScalarVolume with ScalarType Double, independently of the ScalarType of the components of the input, so, when I re-use that ScalarVolume for any of the other computation (that have the same ScalarType than the input, in this case unsigned char), the visualization range is not updated for the new type, and the following happens:

peekslicervectortoscalarissueview

Is there any way to force re-compute the map between pixel-value range <-> visualization at the application level, or any method of the VolumeNode? Thanks!

@lassoan
Copy link
Contributor

lassoan commented Jun 17, 2018

You can add a vtkImageCast filter to convert double pixel type to the same pixel type as the input vector volume.

If automatic window width/leve (brightness/contrast) settings are not good then you can copy window width/level values from the display node of the input vector volume.

This commit improves the user interface adding support for choosing
the conversion mode.

Available conversion modes are now:
* vector (RGB, RGBA) images to luminance (default mode keeping backward
  compatibility with original module implementation)
* vector images to single component (user select which component to extract)

To support reusing of components by other scripted modules, a logic
and widget have also been created:
* VectorToScalarVolumeConversionModeWidget
* VectorToScalarVolumeLogic

Co-authored-by: Jean-Christophe Fillion-Robin <[email protected]>
Reviewed-by: Andras Lasso <[email protected]>
Reviewed-by: Csaba Pinter <[email protected]>
Reviewed-by: Steve Pieper <[email protected]>
@phcerdan phcerdan force-pushed the enhance_vector_to_scalar branch 2 times, most recently from 90104c6 to 6016aab Compare June 18, 2018 16:03
@phcerdan
Copy link
Author

phcerdan commented Jun 18, 2018

Thanks @lassoan, I casted it to the same Scalar Type than input for consistency with the other methods. And now it uses a good window with/level when changing methods.

@phcerdan
Copy link
Author

@cpinter @lassoan let me know if I can do anything else to push this forward. Thanks!

Copy link
Contributor

@lassoan lassoan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, the module is very nice and a great improvement over how it was implemented before. There are a couple of smaller things that would be great if you could improve - see in the in the code comments.

@contextmanager
def ScopedQtPropertySetter(qobject, properties):
""" Context manager to set/reset properties"""
previsousValues = {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo - previsous

if not (inputVolume and outputVolume):
slicer.util.errorDisplay('Input and output volumes are required for conversion', windowTitle='Luminance')

with ScopedQtPropertySetter(self.applyButton, {"enabled": False, "text": "Working..."}):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a very interesting idea. I would normally use try/except/finally for this sort of thing, but this setter seems simpler to use. It might make sense to move it to slicer.util so that it can be used elsewhere.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move it to slicer.util so that it can be used elsewhere.

That was indeed the plan. I suggest we do not use this call in this PR and introduced it only later.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just renamed it (it is too handy now to not use it!). If you want to move it to slicer.util afterwards with the good name, I can remove the local function here afterwards.


def __init__(self, parent=None):
qt.QWidget.__init__(self, parent)
self._inputVolumeNode = None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The widget already stores the nodes in node selectors and component to extract in components combobox. It is redundant to also store them in member variables. I would remove them.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

return self._inputVolumeNode

def setInputVolumeNode(self, node):
if isinstance(node, basestring):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This kind of ambiguity - accepting a node or some string (node id? node name? what if there are multiple nodes with the same name?) - is acceptable in testing and development tools for convenience, but not in production code.

For example, slicer.util.getNode has this behavior exactly because it is often used in the Python console to interactively get a node. However, in modules you would use slicer.mrmlScene.GetNode()or slicer.mrmlScene.GetFirstNodeByName().

Since the widget is not supposed to be used from any other modules (you only interact through MRML nodes and sometimes by using another module's logic), I would remove this method completely.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getNode function only lookup node ID (defined at the top of the file). There are no risk of finding a node by name.

The issue is that the name of the function is the same as slicer.util.getNode

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK then. Still, I find all these set/get functions unnecessary, as the updateMRML… and updateGUI… methods take care of keeping the widget and MRML node in sync.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The widget was originally designed to reusable. But I agree this is overkill in that situation and truly reusable widget should be implemented in c++.

👍 for simplifying and removing the extra book keeping

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed variables in widget, the values are only stored in the ComboBoxes. And the 'upper' widget makes the connections to keep updated the parameterNode.

node = getNode(node)
self._inputVolumeNode = node

def componentToExtract(self):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need for get/set methods - widget is not supposed to be used from anywhere else

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The time invested here was motivated to integrate this functionality in a external module. So I do need to reuse the GUI. Kept the get method for convenience, but removed the set methods.

def cleanup(self):
self.removeObservers()

def setDefaultParameters(self):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be possible to create a parameter node (with default values) by only using the logic class, so default value initialization should be done in the logic class. See createParameterNode method.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, moved to logic overriding the createParameterNode.

self.applyButton.enabled = all([self.inputVolumeNode(), self.outputVolumeNode(), self.parameterNode()])
if self.parameterNode is None:
return
self.setInputVolumeNode(self._parameterNode.GetParameter("InputVectorVolume"))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to call blocksignals for each widget when modifying from parameter node to make sure updateMRMLFromGUI method is not called in the middle of updateGUIFromMRML.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the widget is setting the value of the parameter node to what it is already, no node modified event should be invoked.

As a precautionary measure, I suggest we introduce a context managed named like BlockedObjectSignal

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't block anything, but created a context manager to block signals in a list of qobjects.

@staticmethod
def isValidInputOutputData(inputVolumeNode, outputVolumeNode, conversionMethod, componentToExtract):
"""
Validate parameters using the parameterNode.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

document what is the return value, especially when it is not trivial (not just a simple Boolean)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

return True, None


def run(self, inputVolumeNode, outputVolumeNode, conversionMethod, componentToExtract):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we use a parameter node then the primary method for running the processing should take a parameter node as input.

In addition to that method, you may add a convenience method, which takes, inputVolume, outputVolume, conversionMethod, etc., creates a parameter node and calls the primary method with that.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

weightedSum = vtk.vtkImageWeightedSum()
weights = vtk.vtkDoubleArray()
weights.SetNumberOfValues(nrbOfComponents)
evenWeight = 1.0/nrbOfComponents
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

numberOfComponents

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@phcerdan
Copy link
Author

Thanks a lot @lassoan for the careful review, really appreciated, let me know what you think about my latest change. I think I addressed all your suggestions, except the blocking signals. I have tested it, and there is no recursive calling in either case, changing from MRML or from GUI.

@phcerdan
Copy link
Author

phcerdan commented Jun 20, 2018

As a note, I really do need to reuse the GUI widget to justify the time spent on this. I have removed the internal variables, but I am sure it is still usable from external modules with a little bit of plumbing and connections.

@lassoan
Copy link
Contributor

lassoan commented Jun 21, 2018

It all looks good! Thank you very much for taking the time to implement this so nicely.

One last thing: I did not understand what XXX: meant. If it is a todo then rename to TODO: (if something else then rename to something that is more clear or more standard).

@jcfr
Copy link
Member

jcfr commented Jun 21, 2018

XXX: meant

I guess I influenced this. This is an other convention. See https://en.wikipedia.org/wiki/Comment_(computer_programming)#Tags

@phcerdan
Copy link
Author

Changed XXX to TODO.

@lassoan
Copy link
Contributor

lassoan commented Jun 21, 2018

There are hundreds of TODO tags in VTK and ITK, and maybe 4-5 XXX. I think it is better to use TODO if that code must be changed, or use plain comments if you just want to tell something to future developers.

@lassoan
Copy link
Contributor

lassoan commented Jun 21, 2018

Thank you, I have no more comments!

@phcerdan
Copy link
Author

phcerdan commented Jun 21, 2018

Thanks @lassoan and @jcfr for your time. I learnt quite a bit!

@cpinter the changes you requested were solved (I implemented only one combo-box to select the conversion method), but github doesn't know that yet. Let me know if you have any other suggestion.

@cpinter
Copy link
Member

cpinter commented Jun 21, 2018

Thanks for all the work, LGTM!

phcerdan referenced this pull request in phcerdan/BoneTextureExtension Jun 22, 2018
phcerdan referenced this pull request in phcerdan/BoneTextureExtension Jun 23, 2018
phcerdan referenced this pull request in phcerdan/BoneTextureExtension Jun 23, 2018
phcerdan referenced this pull request in phcerdan/BoneTextureExtension Jun 23, 2018
@phcerdan
Copy link
Author

I have successfully integrated the conversion method widget in an external module! It wasn't that much plumbing, and looks good!

peekslicervectortoscalarexternal

Add new method to compute the average of all the components of a
VectorVolume. Also add parameterNode awareness, and general reformat that
deserve a new commit.

Co-authored-by: Jean-Christophe Fillion-Robin <[email protected]>
phcerdan referenced this pull request in phcerdan/BoneTextureExtension Jun 23, 2018
phcerdan referenced this pull request in phcerdan/BoneTextureExtension Jun 26, 2018
@phcerdan
Copy link
Author

phcerdan commented Jun 29, 2018

@lassoan @cpinter @jcfr may I help in something for this to be finally merged?
Thanks!

@jcfr
Copy link
Member

jcfr commented Jun 29, 2018

@phcerdan In the gif above, it looks like the input combobox is updated ? Is this expected ?

@phcerdan
Copy link
Author

phcerdan commented Jun 29, 2018 via email

@jcfr
Copy link
Member

jcfr commented Jun 29, 2018 via email

@lassoan
Copy link
Contributor

lassoan commented Jul 11, 2018

Thank you! Integrated in 27278.

@lassoan lassoan closed this Jul 11, 2018
phcerdan referenced this pull request in phcerdan/BoneTextureExtension Jul 13, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
5 participants