Merge pull request #575 from tonysyu/feature/viewer-linking

Linked image viewers and docked plugins
This commit is contained in:
Josh Warner
2013-06-28 08:32:34 -07:00
14 changed files with 304 additions and 63 deletions
+38 -4
View File
@@ -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.
+5 -4
View File
@@ -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]
+3 -2
View File
@@ -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
+12 -3
View File
@@ -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)
+15 -2
View File
@@ -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
+53 -9
View File
@@ -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))
+70 -31
View File
@@ -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:
+3 -3
View File
@@ -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:
+3 -3
View File
@@ -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:')
@@ -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()
@@ -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()
+1 -1
View File
@@ -5,5 +5,5 @@ from skimage import data
image = data.load('color.png')
viewer = ImageViewer(image)
viewer += ColorHistogram()
viewer += ColorHistogram(dock='right')
viewer.show()
+1 -1
View File
@@ -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()
@@ -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()