###############################################################################
# ilastik: interactive learning and segmentation toolkit
#
# Copyright (C) 2011-2014, the ilastik developers
# <team@ilastik.org>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# In addition, as a special exception, the copyright holders of
# ilastik give you permission to combine ilastik with applets,
# workflows and plugins which are not covered under the GNU
# General Public License.
#
# See the LICENSE file for details. License information is also available
# on the ilastik web site at:
# http://ilastik.org/license.html
###############################################################################
#Python
import os
from functools import partial
import logging
logger = logging.getLogger(__name__)
traceLogger = logging.getLogger('TRACE.' + __name__)
# SciPy
import numpy
#PyQt
from PyQt4.QtCore import Qt
from PyQt4.QtGui import *
from PyQt4 import uic
import vigra
#lazyflow
from lazyflow.stype import ArrayLike
from lazyflow.operators import OpSingleChannelSelector, OpWrapSlot
from lazyflow.operators.opReorderAxes import OpReorderAxes
#volumina
from volumina.api import LazyflowSource, GrayscaleLayer, RGBALayer, ColortableLayer, AlphaModulatedLayer, LayerStackModel, generateRandomColors
from volumina.volumeEditor import VolumeEditor
from volumina.utility import ShortcutManager
from volumina.interpreter import ClickReportingInterpreter
#ilastik
from ilastik.utility import bind
from ilastik.utility.gui import ThreadRouter, threadRouted
from ilastik.config import cfg as ilastik_config
from ilastik.widgets.viewerControls import ViewerControls
#===----------------------------------------------------------------------------------------------------------------===
class LayerViewerGuiMetaclass(type(QWidget)):
"""
Custom metaclass to enable the _after_init function.
"""
def __call__(cls, *args, **kwargs):
"""
This is where __init__ is called.
Here we can call code to execute immediately before or after the *subclass* __init__ function.
"""
# Base class first. (type is our baseclass)
# type.__call__ calls instance.__init__ internally
instance = super(LayerViewerGuiMetaclass, cls).__call__(*args,**kwargs)
instance._after_init()
return instance
[docs]class LayerViewerGui(QWidget):
"""
Implements an applet GUI whose central widget is a VolumeEditor
and whose layer controls simply contains a layer list widget.
Intended to be used as a subclass for applet GUI objects.
Provides: Central widget (viewer), View Menu, and Layer controls
Provides an EMPTY applet drawer widget. Subclasses should replace it with their own applet drawer.
"""
__metaclass__ = LayerViewerGuiMetaclass
###########################################
### AppletGuiInterface Concrete Methods ###
###########################################
def centralWidget( self ):
return self
def appletDrawer(self):
return self._drawer
def menus( self ):
debug_mode = ilastik_config.getboolean("ilastik", "debug")
return [ self.volumeEditorWidget.getViewMenu(debug_mode) ]
def viewerControlWidget(self):
return self.__viewerControlWidget
def stopAndCleanUp(self):
self._stopped = True
# Remove all layers
self.layerstack.clear()
# Unsubscribe to all signals
for fn in self.__cleanup_fns:
fn()
for op in self._orphanOperators:
op.cleanUp()
###########################################
###########################################
[docs] def __init__(self, parentApplet, topLevelOperatorView, additionalMonitoredSlots=[], centralWidgetOnly=False, crosshair=True):
"""
Constructor. **All** slots of the provided *topLevelOperatorView* will be monitored for changes.
Changes include slot resize events, and slot ready/unready status changes.
When a change is detected, the `setupLayers()` function is called, and the result is used to update the list of layers shown in the central widget.
:param topLevelOperatorView: The top-level operator for the applet this GUI belongs to.
:param additionalMonitoredSlots: Optional. Can be used to add additional slots to the set of viewable layers (all slots from the top-level operator are already monitored).
:param centralWidgetOnly: If True, provide only a central widget without drawer or viewer controls.
"""
super(LayerViewerGui, self).__init__()
self._stopped = False
self._initialized = False
self.__cleanup_fns = []
self.threadRouter = ThreadRouter(self) # For using @threadRouted
self.topLevelOperatorView = topLevelOperatorView
observedSlots = []
for slot in topLevelOperatorView.inputs.values() + topLevelOperatorView.outputs.values():
if slot.level == 0 or slot.level == 1:
observedSlots.append(slot)
observedSlots += additionalMonitoredSlots
self._orphanOperators = [] # Operators that are owned by this GUI directly (not owned by the top-level operator)
self.observedSlots = []
for slot in observedSlots:
if slot.level == 0:
if not isinstance(slot.stype, ArrayLike):
# We don't support visualization of non-Array slots.
continue
# To be monitored and updated correctly by this GUI, slots must have level=1, but this slot is of level 0.
# Pass it through a trivial "up-leveling" operator so it will have level 1 for our purposes.
opPromoteInput = OpWrapSlot(parent=slot.getRealOperator().parent)
opPromoteInput.Input.connect(slot)
slot = opPromoteInput.Output
self._orphanOperators.append( opPromoteInput )
# Each slot should now be indexed as slot[layer_index]
assert slot.level == 1
self.observedSlots.append( slot )
slot.notifyInserted( bind(self._handleLayerInsertion) )
self.__cleanup_fns.append( partial( slot.unregisterInserted, bind(self._handleLayerInsertion) ) )
slot.notifyRemoved( bind(self._handleLayerRemoval) )
self.__cleanup_fns.append( partial( slot.unregisterRemoved, bind(self._handleLayerRemoval) ) )
for i in range(len(slot)):
self._handleLayerInsertion(slot, i)
self.layerstack = LayerStackModel()
self._initCentralUic()
self._initEditor(crosshair=crosshair)
self.__viewerControlWidget = None
if not centralWidgetOnly:
self.initViewerControlUi() # Might be overridden in a subclass. Default implementation loads a standard layer widget.
#self._drawer = QWidget( self )
self.initAppletDrawerUi() # Default implementation loads a blank drawer from drawer.ui.
self._up_to_date = False
# By default, we start out disabled until we have at least one layer.
self.centralWidget().setEnabled(False)
def _after_init(self):
self._initialized = True
self.updateAllLayers()
def setNeedUpdate(self, slot=None):
self._need_update = True
if self.isVisible():
if slot.graph:
slot.graph.call_when_setup_finished( self.updateAllLayers )
else:
self.updateAllLayers()
def showEvent(self, event):
if self._need_update:
self.updateAllLayers()
super( LayerViewerGui, self ).showEvent(event)
[docs] def setupLayers( self ):
"""
Create a list of layers to be displayed in the central widget.
Subclasses should override this method to create the list of layers that can be displayed.
For debug and development purposes, the base class implementation simply generates layers for all topLevelOperatorView slots.
"""
layers = []
for multiLayerSlot in self.observedSlots:
for j, slot in enumerate(multiLayerSlot):
has_space = slot.meta.axistags and slot.meta.axistags.axisTypeCount(vigra.AxisType.Space) > 2
if slot.ready() and has_space:
layer = self.createStandardLayerFromSlot(slot)
# Name the layer after the slot name.
if isinstance( multiLayerSlot.getRealOperator(), OpWrapSlot ):
# We attached an 'upleveling' operator, so look upstream for the real slot.
layer.name = multiLayerSlot.getRealOperator().Input.partner.name
else:
layer.name = multiLayerSlot.name + " " + str(j)
layers.append(layer)
return layers
def _handleLayerInsertion(self, slot, slotIndex):
"""
The multislot providing our layers has a new item.
Make room for it in the layer GUI and subscribe to updates.
"""
# When the slot is ready, we'll replace the blank layer with real data
slot[slotIndex].notifyReady( bind(self.setNeedUpdate) )
slot[slotIndex].notifyUnready( bind(self.setNeedUpdate) )
self.__cleanup_fns.append( partial( slot[slotIndex].unregisterReady, bind(self.setNeedUpdate) ) )
self.__cleanup_fns.append( partial( slot[slotIndex].unregisterUnready, bind(self.setNeedUpdate) ) )
def _handleLayerRemoval(self, slot, slotIndex):
"""
An item is about to be removed from the multislot that is providing our layers.
Remove the layer from the GUI.
"""
self.setNeedUpdate(slot)
def generateAlphaModulatedLayersFromChannels(self, slot):
# TODO
assert False
@classmethod
[docs] def createStandardLayerFromSlot(cls, slot, lastChannelIsAlpha=False):
"""
Convenience function.
Generates a volumina layer using the given slot.
Will be either a GrayscaleLayer or RGBALayer, depending on the channel metadata.
:param slot: The slot to generate a layer from
:param lastChannelIsAlpha: If True, the last channel in the slot is assumed to be an alpha channel.
"""
numChannels = 1
display_mode = "default"
c_index = slot.meta.axistags.index('c')
if c_index < len(slot.meta.axistags):
numChannels = slot.meta.shape[c_index]
display_mode = slot.meta.display_mode
if display_mode == "" or display_mode == "default":
## Figure out whether the default should be rgba or grayscale
if lastChannelIsAlpha:
assert numChannels <= 4, \
"This function doesn't support alpha for slots with more than 4 channels. "\
"Your image has {} channels.".format(numChannels)
# Automatically select Grayscale or RGBA based on number of channels
if numChannels == 2 or numChannels == 3:
display_mode = "rgba"
elif slot.meta.dtype == numpy.uint64:
display_mode = "random-colortable"
else:
display_mode = "grayscale"
# Override RGBA --> Grayscale if there's only 1 channel.
if display_mode == "rgba" and numChannels == 1:
display_mode = "grayscale"
if display_mode == "grayscale":
assert not lastChannelIsAlpha, "Can't have an alpha channel if there is no color channel"
return cls._create_grayscale_layer_from_slot( slot, numChannels )
elif display_mode == "rgba":
assert numChannels > 2 or (numChannels == 2 and not lastChannelIsAlpha), \
"Unhandled combination of channels. numChannels={}, lastChannelIsAlpha={}, axistags={}"\
.format( numChannels, lastChannelIsAlpha, slot.meta.axistags )
return cls._create_rgba_layer_from_slot(slot, numChannels, lastChannelIsAlpha)
elif display_mode == "random-colortable":
return cls._create_random_colortable_layer_from_slot(slot)
elif display_mode == "alpha-modulated":
return cls._create_alpha_modulated_layer_from_slot(slot)
elif display_mode == "binary-mask":
return cls._create_binary_mask_layer_from_slot(slot)
else:
raise RuntimeError("unknown channel display mode: " + display_mode )
@classmethod
def _create_grayscale_layer_from_slot(cls, slot, n_channels):
source = LazyflowSource(slot)
layer = GrayscaleLayer(source)
layer.numberOfChannels = n_channels
layer.set_range(0, slot.meta.drange)
normalize = cls._should_normalize_display(slot)
layer.set_normalize( 0, normalize )
return layer
@classmethod
def _create_random_colortable_layer_from_slot(cls, slot, num_colors=256):
colortable = generateRandomColors(num_colors, clamp={'v': 1.0, 's' : 0.5}, zeroIsTransparent=True)
layer = ColortableLayer(LazyflowSource(slot), colortable)
layer.colortableIsRandom = True
return layer
@classmethod
def _create_alpha_modulated_layer_from_slot(cls, slot):
layer = AlphaModulatedLayer( LazyflowSource(slot),
tintColor=QColor( Qt.cyan ),
range=(0.0, 1.0),
normalize=(0.0, 1.0) )
return layer
@classmethod
def _create_binary_mask_layer_from_slot(cls, slot):
# 0: black, 1-255: transparent
# This works perfectly for uint8.
# For uint32, etc., values of 256,512, etc. will be appear 'off'.
# But why would you use uint32 for a binary mask anyway?
colortable = [QColor(0,0,0,255).rgba()]
colortable += 255*[QColor(0,0,0,0).rgba()]
layer = ColortableLayer(LazyflowSource(slot), colortable)
return layer
@classmethod
def _create_rgba_layer_from_slot(cls, slot, numChannels, lastChannelIsAlpha):
bindex = aindex = None
rindex, gindex = 0,1
if numChannels > 3 or (numChannels == 3 and not lastChannelIsAlpha):
bindex = 2
if lastChannelIsAlpha:
aindex = numChannels-1
if numChannels>=2:
gindex = 1
if numChannels>=3:
bindex = 2
if numChannels>=4:
aindex = numChannels-1
redSource = None
if rindex is not None:
redProvider = OpSingleChannelSelector(parent=slot.getRealOperator().parent)
redProvider.Input.connect(slot)
redProvider.Index.setValue( rindex )
redSource = LazyflowSource( redProvider.Output )
redSource.additional_owned_ops.append( redProvider )
greenSource = None
if gindex is not None:
greenProvider = OpSingleChannelSelector(parent=slot.getRealOperator().parent)
greenProvider.Input.connect(slot)
greenProvider.Index.setValue( gindex )
greenSource = LazyflowSource( greenProvider.Output )
greenSource.additional_owned_ops.append( greenProvider )
blueSource = None
if bindex is not None:
blueProvider = OpSingleChannelSelector(parent=slot.getRealOperator().parent)
blueProvider.Input.connect(slot)
blueProvider.Index.setValue( bindex )
blueSource = LazyflowSource( blueProvider.Output )
blueSource.additional_owned_ops.append( blueProvider )
alphaSource = None
if aindex is not None:
alphaProvider = OpSingleChannelSelector(parent=slot.getRealOperator().parent)
alphaProvider.Input.connect(slot)
alphaProvider.Index.setValue( aindex )
alphaSource = LazyflowSource( alphaProvider.Output )
alphaSource.additional_owned_ops.append( alphaProvider )
layer = RGBALayer( red=redSource, green=greenSource, blue=blueSource, alpha=alphaSource)
normalize = cls._should_normalize_display(slot)
for i in xrange(4):
if [redSource,greenSource,blueSource,alphaSource][i]:
layer.set_range(i, slot.meta.drange)
layer.set_normalize(i, normalize)
return layer
def _get_numchannels(self, slot, n_channels):
pass
@classmethod
def _should_normalize_display(cls, slot):
if slot.meta.drange is not None and slot.meta.normalizeDisplay is False:
# do not normalize if the user provided a range and set normalization to False
return False
else:
# If we don't know the range of the data and normalization is allowed
# by the user, create a layer that is auto-normalized.
# See volumina.pixelpipeline.datasources for details.
#
# Even in the case of integer data, which has more than 255 possible values,
# (like uint16), it seems reasonable to use this setting as default
return None # means autoNormalize
@threadRouted
def updateAllLayers(self, slot=None):
if self._stopped or not self._initialized:
return
if slot is not None and slot.ready() and slot.meta.axistags is None:
# Don't update in response to value slots.
return
self._need_update = False
# Ask for the updated layer list (usually provided by the subclass)
newGuiLayers = self.setupLayers()
for layer in newGuiLayers:
assert not filter( lambda l: l is layer, self.layerstack ), \
"You are attempting to re-use a layer ({}). " \
"Your setupOutputs() function may not re-use layer objects. " \
"The layerstack retains ownership of the layers you provide and " \
"may choose to clean and delete them without your knowledge.".format( layer.name )
newNames = set(l.name for l in newGuiLayers)
if len(newNames) != len(newGuiLayers):
msg = "All layers must have unique names.\n"
msg += "You're attempting to use these layer names:\n"
msg += str( [l.name for l in newGuiLayers] )
raise RuntimeError(msg)
# If the datashape changed, tell the editor
# FIXME: This may not be necessary now that this gui doesn't handle the multi-image case...
newDataShape = self.determineDatashape()
if newDataShape is not None and self.editor.dataShape != newDataShape:
self.editor.dataShape = newDataShape
# Find the xyz midpoint
midpos5d = [x//2 for x in newDataShape]
# center viewer there
self.setViewerPos(midpos5d)
if not (self.editor.cropModel._crop_extents[0][0] == None or self.editor.cropModel.cropZero()):
cropMidPos = [(b+a)//2 for [a,b] in self.editor.cropModel._crop_extents]
for i in range(3):
self.editor.navCtrl.changeSliceAbsolute(cropMidPos[i],i)
# Old layers are deleted if
# (1) They are not in the new set or
# (2) Their data has changed
for index, oldLayer in reversed(list(enumerate(self.layerstack))):
if oldLayer.name not in newNames:
needDelete = True
else:
newLayer = filter(lambda l: l.name == oldLayer.name, newGuiLayers)[0]
needDelete = newLayer.isDifferentEnough(oldLayer)
if needDelete:
layer = self.layerstack[index]
if hasattr(layer, 'shortcutRegistration'):
action_info = layer.shortcutRegistration[1]
ShortcutManager().unregister( action_info )
self.layerstack.selectRow(index)
self.layerstack.deleteSelected()
# Insert all layers that aren't already in the layerstack
# (Identified by the name attribute)
existingNames = set(l.name for l in self.layerstack)
for index, layer in enumerate(newGuiLayers):
if layer.name not in existingNames:
# Insert new
self.layerstack.insert( index, layer )
# If this layer has an associated shortcut, register it with the shortcut manager
if hasattr(layer, 'shortcutRegistration'):
ShortcutManager().register( *layer.shortcutRegistration )
else:
# Clean up the layer instance that the client just gave us.
# We don't want to use it.
layer.clean_up()
# Move existing layer to the correct position
stackIndex = self.layerstack.findMatchingIndex(lambda l: l.name == layer.name)
self.layerstack.selectRow(stackIndex)
while stackIndex > index:
self.layerstack.moveSelectedUp()
stackIndex -= 1
while stackIndex < index:
self.layerstack.moveSelectedDown()
stackIndex += 1
if len(self.layerstack) > 0:
self.centralWidget().setEnabled( True )
def determineDatashape(self):
newDataShape = None
for provider in self.observedSlots:
for i, slot in enumerate(provider):
if newDataShape is None:
newDataShape = self.getVoluminaShapeForSlot(slot)
return newDataShape
def getLayerByName(self, name):
matches = filter(lambda l: l.name == name, list(self.layerstack))
if not matches:
return None
if len(matches) == 1:
return matches[0]
assert False, "Found more than one matching layer with name {}".format( name )
@threadRouted
def setViewerPos(self, pos, setTime=False, setChannel=False):
try:
pos5d = self.validatePos(pos, dims=5)
# set xyz position
pos3d = pos5d[1:4]
self.editor.posModel.slicingPos = pos3d
# set time and channel if requested
if setTime:
self.editor.posModel.time = pos5d[0]
if setChannel:
self.editor.posModel.channel = pos5d[4]
self.editor.navCtrl.panSlicingViews( pos3d, [0,1,2] )
for i in range(3):
self.editor.navCtrl.changeSliceAbsolute(pos3d[i],i)
if not (self.editor.cropModel._crop_extents[0][0] == None or self.editor.cropModel.cropZero()):
cropMidPos = [(b+a)//2 for [a,b] in self.editor.cropModel._crop_extents]
for i in range(3):
self.editor.navCtrl.changeSliceAbsolute(cropMidPos[i],i)
except Exception, e:
logger.warn("Failed to navigate to position (%s): %s" % (pos, e))
return
def validatePos(self, pos, dims=5):
if not isinstance(pos, list):
raise Exception("Wrong data format")
if not len(pos) == dims:
raise Exception("Wrong data format")
ds = self.editor.dataShape
for i in range(dims):
try:
pos[i] = max(0, min(int(pos[i]), ds[i]-1))
except:
pos[i] = 0
return pos
@classmethod
def getVoluminaShapeForSlot(self, slot):
shape = None
if slot.ready() and slot.meta.axistags is not None:
# Use an OpReorderAxes adapter to transpose the shape for us.
op5 = OpReorderAxes( parent=slot.getRealOperator().parent )
op5.Input.connect( slot )
shape = op5.Output.meta.shape
# We just needed the operator to determine the transposed shape.
# Disconnect it so it can be garbage collected.
op5.Input.disconnect()
op5.cleanUp()
return shape
[docs] def initViewerControlUi(self):
"""
Load the viewer controls GUI, which appears below the applet bar.
In our case, the viewer control GUI consists mainly of a layer list.
Subclasses should override this if they provide their own viewer control widget.
"""
localDir = os.path.split(__file__)[0]
self.__viewerControlWidget = ViewerControls()
# The editor's layerstack is in charge of which layer movement buttons are enabled
model = self.editor.layerStack
if self.__viewerControlWidget is not None:
self.__viewerControlWidget.setupConnections(model)
[docs] def initAppletDrawerUi(self):
"""
By default, this base class provides a blank applet drawer.
Override this in a subclass to get a real applet drawer.
"""
# Load the ui file (find it in our own directory)
localDir = os.path.split(__file__)[0]
self._drawer = uic.loadUi(localDir+"/drawer.ui")
def getAppletDrawerUi(self):
return self._drawer
def _initCentralUic(self):
"""
Load the GUI from the ui file into this class and connect it with event handlers.
"""
# Load the ui file into this class (find it in our own directory)
localDir = os.path.split(__file__)[0]
uic.loadUi(localDir+"/centralWidget.ui", self)
def _initEditor(self, crosshair):
"""
Initialize the Volume Editor GUI.
"""
self.editor = VolumeEditor(self.layerstack, parent=self, crosshair=crosshair)
# Replace the editor's navigation interpreter with one that has extra functionality
self.clickReporter = ClickReportingInterpreter( self.editor.navInterpret, self.editor.posModel )
self.editor.setNavigationInterpreter( self.clickReporter )
self.clickReporter.rightClickReceived.connect( self._handleEditorRightClick )
self.clickReporter.leftClickReceived.connect( self._handleEditorLeftClick )
clickReporter2 = ClickReportingInterpreter( self.editor.brushingInterpreter, self.editor.posModel )
clickReporter2.rightClickReceived.connect( self._handleEditorRightClick )
self.editor.brushingInterpreter = clickReporter2
self.editor.setInteractionMode( 'navigation' )
self.volumeEditorWidget.init(self.editor)
self.editor._lastImageViewFocus = 0
# Zoom at a 1-1 scale to avoid loading big datasets entirely...
for view in self.editor.imageViews:
view.doScaleTo(1)
# Should we default to 2D?
prefer_2d = False
for multislot in self.observedSlots:
for slot in multislot:
if slot.ready() and slot.meta.prefer_2d:
prefer_2d = True
break
if prefer_2d:
# Default to Z (axis 2 in the editor)
self.volumeEditorWidget.quadview.ensureMaximized(2)
def _convertPositionToDataSpace(self, voluminaPosition):
taggedPosition = {k:p for k,p in zip('txyzc', voluminaPosition)}
# Find the first lazyflow layer in the stack
# We assume that all lazyflow layers have the same axistags
dataTags = None
for layer in self.layerstack:
for datasource in layer.datasources:
try: # not all datasources have the dataSlot property, find out by trying
dataTags = datasource.dataSlot.meta.axistags
if dataTags is not None:
break
except AttributeError:
pass
if(dataTags is None):
raise RuntimeError("Can't convert mouse click coordinates from volumina-5d: Could not find a lazyflow data source in any layer.")
position = ()
for tag in dataTags:
position += (taggedPosition[tag.key],)
return position
def _handleEditorRightClick(self, position5d, globalWindowCoordinate):
if len(self.layerstack) > 0:
dataPosition = self._convertPositionToDataSpace(position5d)
self.handleEditorRightClick(dataPosition, globalWindowCoordinate)
def _handleEditorLeftClick(self, position5d, globalWindowCoordinate):
if len(self.layerstack) > 0:
dataPosition = self._convertPositionToDataSpace(position5d)
self.handleEditorLeftClick(dataPosition, globalWindowCoordinate)
def handleEditorRightClick(self, position5d, globalWindowCoordinate):
# Override me
pass
def handleEditorLeftClick(self, position5d, globalWindowCoordiante):
# Override me
pass