diff --git a/skimage/viewer/plugins/base.py b/skimage/viewer/plugins/base.py index 2d8c7175..50b7601c 100644 --- a/skimage/viewer/plugins/base.py +++ b/skimage/viewer/plugins/base.py @@ -1,9 +1,12 @@ """ Base class for Plugins that interact with ImageViewer. """ -from ..qt import QtGui -from ..qt.QtCore import Qt +from warnings import warn +import numpy as np + +from ..qt import QtGui +from ..qt.QtCore import Qt, Signal from ..utils import RequiredAttr, init_qtapp @@ -71,14 +74,24 @@ class Plugin(QtGui.QDialog): name = 'Plugin' image_viewer = RequiredAttr("%s is not attached to ImageViewer" % name) - def __init__(self, image_filter=None, height=0, width=400, useblit=True): + # Signals used when viewers are linked to the Plugin output. + image_changed = Signal(np.ndarray) + _started = Signal(int) + + def __init__(self, image_filter=None, height=0, width=400, useblit=True, + dock='bottom'): init_qtapp() super(Plugin, self).__init__() + self.dock = dock + self.image_viewer = None # If subclass defines `image_filter` method ignore input. if not hasattr(self, 'image_filter'): self.image_filter = image_filter + elif image_filter is not None: + warn("If the Plugin class defines an `image_filter` method, " + "then the `image_filter` argument is ignored.") self.setWindowTitle(self.name) self.layout = QtGui.QGridLayout(self) @@ -109,7 +122,7 @@ class Plugin(QtGui.QDialog): self.image_viewer = image_viewer self.image_viewer.plugins.append(self) #TODO: Always passing image as first argument may be bad assumption. - self.arguments.append(self.image_viewer.original_image) + self.arguments = [self.image_viewer.original_image] # Call filter so that filtered image matches widget values self.filter_image() @@ -155,12 +168,22 @@ class Plugin(QtGui.QDialog): kwargs = dict([(name, self._get_value(a)) for name, a in self.keyword_arguments.items()]) filtered = self.image_filter(*arguments, **kwargs) + self.display_filtered_image(filtered) + self.image_changed.emit(filtered) def _get_value(self, param): # If param is a widget, return its `val` attribute. return param if not hasattr(param, 'val') else param.val + def _update_original_image(self, image): + """Update the original image argument passed to the filter function. + + This method is called by the viewer when the original image is updated. + """ + self.arguments[0] = image + self.filter_image() + @property def filtered_image(self): """Return filtered image.""" @@ -183,6 +206,17 @@ class Plugin(QtGui.QDialog): """ setattr(self, name, value) + def show(self, main_window=True): + """Show plugin.""" + super(Plugin, self).show() + self.activateWindow() + self.raise_() + + # Emit signal with x-hint so new windows can be displayed w/o overlap. + size = self.frameGeometry() + x_hint = size.x() + size.width() + self._started.emit(x_hint) + def closeEvent(self, event): """On close disconnect all artists and events from ImageViewer. diff --git a/skimage/viewer/plugins/color_histogram.py b/skimage/viewer/plugins/color_histogram.py index 166b2805..39a75004 100644 --- a/skimage/viewer/plugins/color_histogram.py +++ b/skimage/viewer/plugins/color_histogram.py @@ -10,8 +10,9 @@ from ..canvastools import RectangleTool class ColorHistogram(PlotPlugin): name = 'Color Histogram' - def __init__(self, **kwargs): + def __init__(self, max_pct=0.99, **kwargs): super(ColorHistogram, self).__init__(height=400, **kwargs) + self.max_pct = max_pct print(self.help()) @@ -30,7 +31,7 @@ class ColorHistogram(PlotPlugin): normed=True) # Clip bin heights that dominate a-b histogram - max_val = pct_total_area(hist, percentile=99) + max_val = pct_total_area(hist, percentile=self.max_pct) hist = exposure.rescale_intensity(hist, in_range=(0, max_val)) self.ax.imshow(hist, extent=ab_extents, cmap=plt.cm.gray) @@ -55,12 +56,12 @@ class ColorHistogram(PlotPlugin): self.image_viewer.image = color.lab2rgb(lab_masked) -def pct_total_area(image, percentile=80): +def pct_total_area(image, percentile=0.80): """Return threshold value based on percentage of total area. The specified percent of pixels less than the given intensity threshold. """ - idx = int((image.size - 1) * percentile / 100.0) + idx = int((image.size - 1) * percentile) sorted_pixels = np.sort(image.flat) return sorted_pixels[idx] diff --git a/skimage/viewer/plugins/overlayplugin.py b/skimage/viewer/plugins/overlayplugin.py index 278f072c..25ea3b00 100644 --- a/skimage/viewer/plugins/overlayplugin.py +++ b/skimage/viewer/plugins/overlayplugin.py @@ -2,7 +2,7 @@ from warnings import warn from skimage.util.dtype import dtype_range from .base import Plugin -from ..utils import ClearColormap +from ..utils import ClearColormap, update_axes_image __all__ = ['OverlayPlugin'] @@ -66,7 +66,8 @@ class OverlayPlugin(Plugin): self._overlay_plot = ax.imshow(image, cmap=self.cmap, vmin=vmin, vmax=vmax) else: - self._overlay_plot.set_array(image) + update_axes_image(self._overlay_plot, image) + self.image_viewer.redraw() @property diff --git a/skimage/viewer/plugins/plotplugin.py b/skimage/viewer/plugins/plotplugin.py index c120756c..0ce5df73 100644 --- a/skimage/viewer/plugins/plotplugin.py +++ b/skimage/viewer/plugins/plotplugin.py @@ -17,6 +17,13 @@ class PlotPlugin(Plugin): See base Plugin class for additional details. """ + def __init__(self, image_filter=None, height=150, width=400, **kwargs): + super(PlotPlugin, self).__init__(image_filter=image_filter, + height=height, width=width, **kwargs) + + self._height = height + self._width = width + def attach(self, image_viewer): super(PlotPlugin, self).attach(image_viewer) # Add plot for displaying intensity profile. @@ -26,10 +33,12 @@ class PlotPlugin(Plugin): """Redraw plot.""" self.canvas.draw_idle() - def add_plot(self, height=4, width=4): - self.fig, self.ax = new_plot(figsize=(height, width)) + def add_plot(self): + self.fig, self.ax = new_plot() + self.fig.set_figwidth(self._width / float(self.fig.dpi)) + self.fig.set_figheight(self._height / float(self.fig.dpi)) + self.canvas = self.fig.canvas - self.canvas.setMinimumHeight(150) #TODO: Converted color is slightly different than Qt background. qpalette = QtGui.QPalette() qcolor = qpalette.color(QtGui.QPalette.Window) diff --git a/skimage/viewer/qt/QtCore.py b/skimage/viewer/qt/QtCore.py index 897b8760..19b92a53 100644 --- a/skimage/viewer/qt/QtCore.py +++ b/skimage/viewer/qt/QtCore.py @@ -4,6 +4,19 @@ if qt_api == 'pyside': from PySide.QtCore import * elif qt_api == 'pyqt': from PyQt4.QtCore import * + # Use pyside names for signals and slots + Signal = pyqtSignal + Slot = pyqtSlot else: - # Mock objects - Qt = None + # Mock objects for buildbot (which doesn't have Qt, but imports viewer). + class Qt(object): + TopDockWidgetArea = None + BottomDockWidgetArea = None + LeftDockWidgetArea = None + RightDockWidgetArea = None + + def Signal(*args, **kwargs): + pass + + def Slot(*args, **kwargs): + pass diff --git a/skimage/viewer/utils/core.py b/skimage/viewer/utils/core.py index e4985a9e..3b9b33fb 100644 --- a/skimage/viewer/utils/core.py +++ b/skimage/viewer/utils/core.py @@ -23,7 +23,8 @@ from ..qt import QtGui __all__ = ['init_qtapp', 'start_qtapp', 'RequiredAttr', 'figimage', - 'LinearColormap', 'ClearColormap', 'FigureCanvas', 'new_plot'] + 'LinearColormap', 'ClearColormap', 'FigureCanvas', 'new_plot', + 'update_axes_image'] QApp = None @@ -35,29 +36,53 @@ def init_qtapp(): The QApplication needs to be initialized before creating any QWidgets """ global QApp + QApp = QtGui.QApplication.instance() if QApp is None: QApp = QtGui.QApplication([]) + return QApp -def start_qtapp(): +def is_event_loop_running(app=None): + """Return True if event loop is running.""" + if app is None: + app = init_qtapp() + if hasattr(app, '_in_event_loop'): + return app._in_event_loop + else: + return False + + +def start_qtapp(app=None): """Start Qt mainloop""" - QApp.exec_() + if app is None: + app = init_qtapp() + if not is_event_loop_running(app): + app._in_event_loop = True + app.exec_() + app._in_event_loop = False + else: + app._in_event_loop = True class RequiredAttr(object): """A class attribute that must be set before use.""" - def __init__(self, msg): + instances = dict() + + def __init__(self, msg='Required attribute not set', init_val=None): + self.instances[self, None] = init_val self.msg = msg - self.val = None def __get__(self, obj, objtype): - if self.val is None: + value = self.instances[self, obj] + if value is None: + # Should raise an error but that causes issues with the buildbot. warnings.warn(self.msg) - return self.val + self.__set__(obj, self.init_val) + return value - def __set__(self, obj, val): - self.val = val + def __set__(self, obj, value): + self.instances[self, obj] = value class LinearColormap(LinearSegmentedColormap): @@ -179,3 +204,22 @@ def figimage(image, scale=1, dpi=None, **kwargs): ax.set_axis_off() ax.imshow(image, **kwargs) return fig, ax + + +def update_axes_image(image_axes, image): + """Update the image displayed by an image plot. + + This sets the image plot's array and updates its shape appropriately + + Parameters + ---------- + image_axes : `matplotlib.image.AxesImage` + Image axes to update. + image : array + Image array. + """ + image_axes.set_array(image) + + # Adjust size if new image shape doesn't match the original + h, w = image.shape[:2] + image_axes.set_extent((0, w, h, 0)) diff --git a/skimage/viewer/viewers/core.py b/skimage/viewer/viewers/core.py index 9d6261c6..c3ffeb1e 100644 --- a/skimage/viewer/viewers/core.py +++ b/skimage/viewer/viewers/core.py @@ -2,7 +2,7 @@ ImageViewer class for viewing and interacting with images. """ from ..qt import QtGui -from ..qt import QtCore +from ..qt.QtCore import Qt, Signal from skimage import io, img_as_float from skimage.util.dtype import dtype_range @@ -11,6 +11,7 @@ import numpy as np from .. import utils from ..widgets import Slider from ..utils import dialogs +from ..plugins.base import Plugin __all__ = ['ImageViewer', 'CollectionViewer'] @@ -59,6 +60,15 @@ class ImageViewer(QtGui.QMainWindow): >>> # viewer.show() """ + + dock_areas = {'top': Qt.TopDockWidgetArea, + 'bottom': Qt.BottomDockWidgetArea, + 'left': Qt.LeftDockWidgetArea, + 'right': Qt.RightDockWidgetArea} + + # Signal that the original image has been changed + original_image_changed = Signal(np.ndarray) + def __init__(self, image): # Start main loop utils.init_qtapp() @@ -66,21 +76,28 @@ class ImageViewer(QtGui.QMainWindow): #TODO: Add ImageViewer to skimage.io window manager - self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + self.setAttribute(Qt.WA_DeleteOnClose) self.setWindowTitle("Image Viewer") self.file_menu = QtGui.QMenu('&File', self) self.file_menu.addAction('Open file', self.open_file, - QtCore.Qt.CTRL + QtCore.Qt.Key_O) + Qt.CTRL + Qt.Key_O) self.file_menu.addAction('Save to file', self.save_to_file, - QtCore.Qt.CTRL + QtCore.Qt.Key_S) + Qt.CTRL + Qt.Key_S) self.file_menu.addAction('Quit', self.close, - QtCore.Qt.CTRL + QtCore.Qt.Key_Q) + Qt.CTRL + Qt.Key_Q) self.menuBar().addMenu(self.file_menu) self.main_widget = QtGui.QWidget() self.setCentralWidget(self.main_widget) + if isinstance(image, Plugin): + plugin = image + image = plugin.filtered_image + plugin.image_changed.connect(self._update_original_image) + # When plugin is started, start + plugin._started.connect(self._show) + self.fig, self.ax = utils.figimage(image) self.canvas = self.fig.canvas self.canvas.setParent(self) @@ -88,9 +105,7 @@ class ImageViewer(QtGui.QMainWindow): self.ax.autoscale(enable=False) self._image_plot = self.ax.images[0] - - self.original_image = image - self.image = image.copy() + self._update_original_image(image) self.plugins = [] self.layout = QtGui.QVBoxLayout(self.main_widget) @@ -107,16 +122,48 @@ class ImageViewer(QtGui.QMainWindow): def __add__(self, plugin): """Add plugin to ImageViewer""" plugin.attach(self) + self.original_image_changed.connect(plugin._update_original_image) + + if plugin.dock: + location = self.dock_areas[plugin.dock] + dock_location = Qt.DockWidgetArea(location) + dock = QtGui.QDockWidget() + dock.setWidget(plugin) + dock.setWindowTitle(plugin.name) + self.addDockWidget(dock_location, dock) + + horiz = (self.dock_areas['left'], self.dock_areas['right']) + dimension = 'width' if location in horiz else 'height' + self._add_widget_size(plugin, dimension=dimension) + return self + def _add_widget_size(self, widget, dimension='width'): + widget_size = widget.sizeHint() + viewer_size = self.frameGeometry() + + dx = dy = 0 + if dimension == 'width': + dx = widget_size.width() + elif dimension == 'height': + dy = widget_size.height() + + w = viewer_size.width() + h = viewer_size.height() + self.resize(w + dx, h + dy) + def open_file(self): """Open image file and display in viewer.""" filename = dialogs.open_file_dialog() if filename is None: return image = io.imread(filename) + self._update_original_image(image) + + def _update_original_image(self, image): self.original_image = image # update saved image - self.image = image # update displayed image + self.image = image.copy() # update displayed image + self.original_image_changed.emit(image) def save_to_file(self): """Save current image to file. @@ -149,27 +196,22 @@ class ImageViewer(QtGui.QMainWindow): def closeEvent(self, event): self.close() - def auto_layout(self): - """Move viewer to top-left and align plugin on right edge of viewer.""" - size = self.geometry() - self.move(0, 0) - w = size.width() - y = 0 - #TODO: Layout isn't quite correct for multiple plugins (overlaps). + def _show(self, x=0): + self.move(x, 0) for p in self.plugins: - p.move(w, y) - y += p.geometry().height() + p.show() + super(ImageViewer, self).show() + self.activateWindow() + self.raise_() - def show(self): + def show(self, main_window=True): """Show ImageViewer and attached plugins. This behaves much like `matplotlib.pyplot.show` and `QWidget.show`. """ - self.auto_layout() - for p in self.plugins: - p.show() - super(ImageViewer, self).show() - utils.start_qtapp() + self._show() + if main_window: + utils.start_qtapp() def redraw(self): self.canvas.draw_idle() @@ -181,13 +223,10 @@ class ImageViewer(QtGui.QMainWindow): @image.setter def image(self, image): self._img = image - self._image_plot.set_array(image) + utils.update_axes_image(self._image_plot, image) - # Adjust size if new image shape doesn't match the original - h, w = image.shape[:2] - # update data coordinates (otherwise pixel coordinates are off) - self._image_plot.set_extent((0, w, h, 0)) # update display (otherwise image doesn't fill the canvas) + h, w = image.shape[:2] self.ax.set_xlim(0, w) self.ax.set_ylim(h, 0) @@ -248,7 +287,7 @@ class CollectionViewer(ImageViewer): ---------- image_collection : list of images List of images to be displayed. - update_on : {'on_slide' | 'on_release'} + update_on : {'move' | 'release'} Control whether image is updated on slide or release of the image slider. Using 'on_release' will give smoother behavior when displaying large images or when writing a plugin/subclass that requires heavy @@ -296,7 +335,7 @@ class CollectionViewer(ImageViewer): This method can be overridden or extended in subclasses and plugins to react to image changes. """ - self.image = image + self._update_original_image(image) def keyPressEvent(self, event): if type(event) == QtGui.QKeyEvent: diff --git a/skimage/viewer/widgets/core.py b/skimage/viewer/widgets/core.py index ad69bfcf..b9714d38 100644 --- a/skimage/viewer/widgets/core.py +++ b/skimage/viewer/widgets/core.py @@ -81,7 +81,7 @@ class Slider(BaseWidget): Range of slider values. value : float Default slider value. If None, use midpoint between `low` and `high`. - value : {'float' | 'int'} + value_type : {'float' | 'int'} Numeric type of slider value. ptype : {'arg' | 'kwarg' | 'plugin'} Parameter type. @@ -90,12 +90,12 @@ class Slider(BaseWidget): is typically set when the widget is added to a plugin. orientation : {'horizontal' | 'vertical'} Slider orientation. - update_on : {'move' | 'release'} + update_on : {'release' | 'move'} Control when callback function is called: on slider move or release. """ def __init__(self, name, low=0.0, high=1.0, value=None, value_type='float', ptype='kwarg', callback=None, max_edit_width=60, - orientation='horizontal', update_on='move'): + orientation='horizontal', update_on='release'): super(Slider, self).__init__(name, ptype, callback) if value is None: diff --git a/viewer_examples/plugins/canny_simple.py b/viewer_examples/plugins/canny_simple.py index 912401e1..c26ca08d 100644 --- a/viewer_examples/plugins/canny_simple.py +++ b/viewer_examples/plugins/canny_simple.py @@ -12,9 +12,9 @@ image = data.camera() # You can create a UI for a filter just by passing a filter function... plugin = OverlayPlugin(image_filter=canny) # ... and adding widgets to adjust parameter values. -plugin += Slider('sigma', 0, 5, update_on='release') -plugin += Slider('low threshold', 0, 255, update_on='release') -plugin += Slider('high threshold', 0, 255, update_on='release') +plugin += Slider('sigma', 0, 5) +plugin += Slider('low threshold', 0, 255) +plugin += Slider('high threshold', 0, 255) # ... and we can also add buttons to save the overlay: plugin += SaveButtons(name='Save overlay to:') diff --git a/viewer_examples/plugins/collection_overlay.py b/viewer_examples/plugins/collection_overlay.py new file mode 100644 index 00000000..4c7002f2 --- /dev/null +++ b/viewer_examples/plugins/collection_overlay.py @@ -0,0 +1,21 @@ +""" +============================================== +``CollectionViewer`` with an ``OverlayPlugin`` +============================================== + +Demo of a CollectionViewer for viewing collections of images with an +overlay plugin. + +""" +from skimage import data + +from skimage.viewer import CollectionViewer +from skimage.viewer.plugins.canny import CannyPlugin + + +img_collection = [data.camera(), data.coins(), data.text()] + +viewer = CollectionViewer(img_collection) +viewer += CannyPlugin() + +viewer.show() diff --git a/viewer_examples/plugins/collection_plugin.py b/viewer_examples/plugins/collection_plugin.py new file mode 100644 index 00000000..65ff1f21 --- /dev/null +++ b/viewer_examples/plugins/collection_plugin.py @@ -0,0 +1,33 @@ +""" +================================== +``CollectionViewer`` with a plugin +================================== + +Demo of a CollectionViewer for viewing collections of images with the +`autolevel` rank filter connected as a plugin. + +""" +from skimage import data +from skimage.filter import rank +from skimage.morphology import disk + +from skimage.viewer import CollectionViewer +from skimage.viewer.widgets import Slider +from skimage.viewer.plugins.base import Plugin + + +# Wrap autolevel function to make the disk size a filter argument. +def autolevel(image, disk_size): + return rank.autolevel(image, disk(disk_size)) + + +img_collection = [data.camera(), data.coins(), data.text()] + +plugin = Plugin(image_filter=autolevel) +plugin += Slider('disk_size', 2, 8, value_type='int') +plugin.name = "Autolevel" + +viewer = CollectionViewer(img_collection) +viewer += plugin + +viewer.show() diff --git a/viewer_examples/plugins/color_histogram.py b/viewer_examples/plugins/color_histogram.py index 0b654b3e..6b091d69 100644 --- a/viewer_examples/plugins/color_histogram.py +++ b/viewer_examples/plugins/color_histogram.py @@ -5,5 +5,5 @@ from skimage import data image = data.load('color.png') viewer = ImageViewer(image) -viewer += ColorHistogram() +viewer += ColorHistogram(dock='right') viewer.show() diff --git a/viewer_examples/plugins/median_filter.py b/viewer_examples/plugins/median_filter.py index a20ad8f3..36593c3d 100644 --- a/viewer_examples/plugins/median_filter.py +++ b/viewer_examples/plugins/median_filter.py @@ -10,7 +10,7 @@ image = data.coins() viewer = ImageViewer(image) plugin = Plugin(image_filter=median_filter) -plugin += Slider('radius', 2, 10, value_type='int', update_on='release') +plugin += Slider('radius', 2, 10, value_type='int') plugin += SaveButtons() plugin += OKCancelButtons() diff --git a/viewer_examples/plugins/probabilistic_hough.py b/viewer_examples/plugins/probabilistic_hough.py new file mode 100644 index 00000000..98052f87 --- /dev/null +++ b/viewer_examples/plugins/probabilistic_hough.py @@ -0,0 +1,46 @@ +import numpy as np + +from skimage import data +from skimage import draw +from skimage.transform import probabilistic_hough_line + +from skimage.viewer import ImageViewer +from skimage.viewer.widgets import Slider +from skimage.viewer.plugins.overlayplugin import OverlayPlugin +from skimage.viewer.plugins.canny import CannyPlugin + + +def line_image(shape, lines): + image = np.zeros(shape, dtype=bool) + for end_points in lines: + # hough lines returns (x, y) points, draw.line wants (row, columns) + end_points = np.asarray(end_points)[:, ::-1] + image[draw.line(*np.ravel(end_points))] = 1 + return image + + +def hough_lines(image, *args, **kwargs): + # Set threshold to 0.5 since we're working with a binary image (from canny) + lines = probabilistic_hough_line(image, threshold=0.5, *args, **kwargs) + image = line_image(image.shape, lines) + return image + + +image = data.camera() +canny_viewer = ImageViewer(image) +canny_plugin = CannyPlugin() +canny_viewer += canny_plugin + +hough_plugin = OverlayPlugin(image_filter=hough_lines) +hough_plugin.name = 'Hough Lines' + +hough_plugin += Slider('line length', 0, 100) +hough_plugin += Slider('line gap', 0, 20) + +# Passing a plugin to a viewer connects the output of the plugin to the viewer. +hough_viewer = ImageViewer(canny_plugin) +hough_viewer += hough_plugin + +# Show viewers displays both viewers since `hough_viewer` is connected to +# `canny_viewer` through `canny_plugin` +canny_viewer.show()