diff --git a/skimage/viewer/__init__.py b/skimage/viewer/__init__.py new file mode 100644 index 00000000..e20546b0 --- /dev/null +++ b/skimage/viewer/__init__.py @@ -0,0 +1 @@ +from viewers import ImageViewer diff --git a/skimage/viewer/plugins/__init__.py b/skimage/viewer/plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/skimage/viewer/plugins/base.py b/skimage/viewer/plugins/base.py new file mode 100644 index 00000000..fc2951b2 --- /dev/null +++ b/skimage/viewer/plugins/base.py @@ -0,0 +1,231 @@ +""" +Base class for Plugins that interact with ImageViewer. +""" +from PyQt4 import QtGui +from PyQt4.QtCore import Qt +import matplotlib as mpl + +from ..utils import RequiredAttr + + +class Plugin(QtGui.QDialog): + """Base class for plugins that interact with an ImageViewer. + + A plugin connects an image filter (or another function) to an image viewer. + Note that a Plugin is initialized *without* an image viewer and attached in + a later step. See example below for details. + + Parameters + ---------- + image_viewer : ImageViewer + Window containing image used in measurement/manipulation. + image_filter : function + Function that gets called to update image in image viewer. This value + can be `None` if, for example, you have a plugin that extracts + information from an image and doesn't manipulate it. Alternatively, + this function can be defined as a method in a Plugin subclass. + height, width : int + Size of plugin window in pixels. Note that Qt will automatically resize + a window to fit components. So if you're adding rows of components, you + can leave `height = 0` and just let Qt determine the final height. + useblit : bool + If True, use blitting to speed up animation. Only available on some + Matplotlib backends. If None, set to True when using Agg backend. + This only has an effect if you draw on top of an image viewer. + + Attributes + ---------- + image_viewer : ImageViewer + Window containing image used in measurement. + name : str + Name of plugin. This is displayed as the window title. + artist : list + List of Matplotlib artists. Any artists created by the plugin should + be added to this list so that it gets cleaned up on close. + + Examples + -------- + >>> def my_func(image, arg1, arg2, optional_arg=0): + >>> ... + >>> + >>> viewer = ImageViewer(image) + >>> + >>> plugin = Plugin(image_filter=my_func) + >>> plugin += Widget('arg1', ..., ptype='arg') + >>> plugin += Widget('arg2', ..., ptype='arg') + >>> plugin += Widget('optional_arg', ..., ptype='kwarg') + >>> + >>> viewer.show() + + The plugin will automatically delegate parameters to `image_filter` based + on its parameter type, i.e., `ptype` (widgets for required arguments must + be added in the order they appear in the function). The image attached + to the viewer is **automatically passed as the first argument** to the + filter function. + + #TODO: Add flag so image is not passed to filter function by default. + + `ptype = 'kwarg'` is the default for most widgets so it's unnecessary here. + + """ + name = 'Plugin' + image_viewer = RequiredAttr("%s is not attached to ImageViewer" % name) + draws_on_image = False + + def __init__(self, image_filter=None, height=0, width=400, useblit=None): + super(Plugin, self).__init__() + + self.image_viewer = None + # If subclass defines `image_filter` method ignore input. + if not hasattr(self, 'image_filter'): + self.image_filter = image_filter + + self.setWindowTitle(self.name) + self.layout = QtGui.QGridLayout(self) + self.resize(width, height) + self.row = 0 + + self.arguments = [] + self.keyword_arguments= {} + + if useblit is None: + useblit = True if mpl.backends.backend.endswith('Agg') else False + self.useblit = useblit + self.cids = [] + self.artists = [] + + def attach(self, image_viewer): + """Attach the plugin to an ImageViewer. + + Note that the ImageViewer will automatically call this method when the + plugin is added to the ImageViewer. For example: + + >>> viewer += Plugin(...) + + Also note that `attach` automatically calls the filter function so that + the image matches the filtered value specified by attached widgets. + """ + self.setParent(image_viewer) + self.setWindowFlags(Qt.Dialog) + + 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) + + if self.draws_on_image: + self.connect_image_event('draw_event', self.on_draw) + # Call filter so that filtered image matches widget values + self.filter_image() + + def add_widget(self, widget): + """Add widget to plugin. + + Alternatively, Plugin's `__add__` method is overloaded to add widgets: + + >>> plugin += Widget(...) + + Widgets can adjust required or optional arguments of filter function or + parameters for the plugin. This is specified by the Widget's `ptype'. + """ + if widget.ptype == 'kwarg': + name = widget.name.replace(' ', '_') + self.keyword_arguments[name] = widget + widget.callback = self.filter_image + elif widget.ptype == 'arg': + self.arguments.append(widget) + widget.callback = self.filter_image + elif widget.ptype == 'plugin': + widget.callback = self.update_plugin + widget.plugin = self + self.layout.addWidget(widget, self.row, 0) + self.row += 1 + + def __add__(self, widget): + self.add_widget(widget) + return self + + def on_draw(self, event): + """Save image background when blitting. + + The saved image is used to "clear" the figure before redrawing artists. + """ + if self.useblit: + bbox = self.image_viewer.ax.bbox + self.img_background = self.image_viewer.canvas.copy_from_bbox(bbox) + + def filter_image(self, *widget_arg): + """Call `image_filter` with widget args and kwargs + + Note: `display_filtered_image` is automatically called. + """ + # `widget_arg` is passed by the active widget but is unused since all + # filter arguments are pulled directly from attached the widgets. + + if self.image_filter is None: + return + arguments = [self._get_value(a) for a in self.arguments] + kwargs = dict([(name, self._get_value(a)) + for name, a in self.keyword_arguments.iteritems()]) + filtered = self.image_filter(*arguments, **kwargs) + self.display_filtered_image(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 display_filtered_image(self, image): + """Display the filtered image on image viewer. + + If you don't want to simply replace the displayed image with the + filtered image (e.g., you want to display a transparent overlay), + you can override this method. + """ + self.image_viewer.image = image + + def update_plugin(self, name, value): + """Update keyword parameters of the plugin itself. + + These parameters will typically be implemented as class properties so + that they update the image or some other component. + """ + setattr(self, name, value) + + def closeEvent(self, event): + """On close disconnect all artists and events from ImageViewer. + + Note that events must be connected using `self.connect_image_event` and + artists must be appended to `self.artists`. + """ + self.disconnect_image_events() + self.remove_image_artists() + self.image_viewer.plugins.remove(self) + self.image_viewer.reset_image() + self.image_viewer.redraw() + self.close() + + def connect_image_event(self, event, callback): + """Connect callback with an event in the image viewer. + + This should be used in lieu of `figure.canvas.mpl_connect` since this + function stores call back ids for later clean up. + + Parameters + ---------- + event : str + Matplotlib event. + callback : function + Callback function with a matplotlib Event object as its argument. + """ + cid = self.image_viewer.connect_event(event, callback) + self.cids.append(cid) + + def disconnect_image_events(self): + """Disconnect all events created by this widget.""" + for c in self.cids: + self.image_viewer.disconnect_event(c) + + def remove_image_artists(self): + """Disconnect artists that are connected to the image viewer.""" + for a in self.artists: + self.image_viewer.remove_artist(a) diff --git a/skimage/viewer/plugins/canny.py b/skimage/viewer/plugins/canny.py new file mode 100644 index 00000000..7c7bfb3b --- /dev/null +++ b/skimage/viewer/plugins/canny.py @@ -0,0 +1,18 @@ +from skimage.filter import canny + +from .overlayplugin import OverlayPlugin +from ..widgets import Slider, ComboBox + + +class CannyPlugin(OverlayPlugin): + """Canny filter plugin to show edges of an image.""" + + name = 'Canny Filter' + + def __init__(self, *args, **kwargs): + super(CannyPlugin, self).__init__(image_filter=canny, **kwargs) + + self.add_widget(Slider('sigma', 0, 5, update_on='release')) + self.add_widget(Slider('low threshold', 0, 255, update_on='release')) + self.add_widget(Slider('high threshold', 0, 255, update_on='release')) + self.add_widget(ComboBox('color', self.color_names, ptype='plugin')) diff --git a/skimage/viewer/plugins/overlayplugin.py b/skimage/viewer/plugins/overlayplugin.py new file mode 100644 index 00000000..11ca5ab5 --- /dev/null +++ b/skimage/viewer/plugins/overlayplugin.py @@ -0,0 +1,91 @@ +import numpy as np + +from skimage.util import dtype +from .base import Plugin +from ..utils import ClearColormap + + +#TODO: Maybe this bool definition should be moved to skimage.util.dtype. +dtype_range = dtype.dtype_range.copy() +dtype_range[np.bool_] = (False, True) + + +class OverlayPlugin(Plugin): + """Plugin for ImageViewer that displays an overlay on top of main image. + + The base Plugin class displays the filtered image directly on the viewer. + OverlayPlugin will instead overlay an image with a transparent colormap. + + See base Plugin class for additional details. + + Attributes + ---------- + overlay : array + Overlay displayed on top of image. This overlay defaults to a color map + with alpha values varying linearly from 0 to 1. + color : int + Color of overlay. + """ + colors = {'red': (1, 0, 0), + 'yellow': (1, 1, 0), + 'green': (0, 1, 0), + 'cyan': (0, 1, 1)} + + def __init__(self, **kwargs): + super(OverlayPlugin, self).__init__(**kwargs) + self._overlay_plot = None + self._overlay = None + self.cmap = None + self.color_names = self.colors.keys() + + def attach(self, image_viewer): + super(OverlayPlugin, self).attach(image_viewer) + #TODO: `color` doesn't update GUI widget when set manually. + self.color = 0 + + @property + def overlay(self): + return self._overlay + + @overlay.setter + def overlay(self, image): + self._overlay = image + ax = self.image_viewer.ax + if image is None: + ax.images.remove(self._overlay_plot) + self._overlay_plot = None + elif self._overlay_plot is None: + vmin, vmax = dtype_range[image.dtype.type] + self._overlay_plot = ax.imshow(image, cmap=self.cmap, + vmin=vmin, vmax=vmax) + else: + self._overlay_plot.set_array(image) + self.image_viewer.redraw() + + @property + def color(self): + return self._color + + @color.setter + def color(self, index): + # Update colormap whenever color is changed. + if isinstance(index, basestring) and index not in self.color_names: + raise ValueError("%s not defined in OverlayPlugin.colors" % index) + else: + name = self.color_names[index] + self._color = name + rgb = self.colors[name] + self.cmap = ClearColormap(rgb) + + if self._overlay_plot is not None: + self._overlay_plot.set_cmap(self.cmap) + self.image_viewer.redraw() + + def display_filtered_image(self, image): + """Display filtered image as an overlay on top of image in viewer.""" + self.overlay = image + + def closeEvent(self, event): + # clear overlay from ImageViewer on close + self.overlay = None + super(OverlayPlugin, self).closeEvent(event) diff --git a/skimage/viewer/utils/__init__.py b/skimage/viewer/utils/__init__.py new file mode 100644 index 00000000..5af24064 --- /dev/null +++ b/skimage/viewer/utils/__init__.py @@ -0,0 +1 @@ +from core import * diff --git a/skimage/viewer/utils/core.py b/skimage/viewer/utils/core.py new file mode 100644 index 00000000..36476df8 --- /dev/null +++ b/skimage/viewer/utils/core.py @@ -0,0 +1,106 @@ +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.colors import LinearSegmentedColormap +from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg +from PyQt4 import QtGui + + +__all__ = ['figimage', 'LinearColormap', 'ClearColormap', 'MatplotlibCanvas', + 'RequiredAttr'] + + +class RequiredAttr(object): + """A class attribute that must be set before use.""" + + def __init__(self, msg): + self.msg = msg + self.val = None + + def __get__(self, obj, objtype): + if self.val is None: + raise RuntimeError(self.msg) + return self.val + + def __set__(self, obj, val): + self.val = val + + +def figimage(image, scale=1, dpi=None, **kwargs): + """Return figure and axes with figure tightly surrounding image. + + Unlike pyplot.figimage, this actually plots onto an axes object, which + fills the figure. Plotting the image onto an axes allows for subsequent + overlays of axes artists. + + Parameters + ---------- + image : array + image to plot + scale : float + If scale is 1, the figure and axes have the same dimension as the + image. Smaller values of `scale` will shrink the figure. + dpi : int + Dots per inch for figure. If None, use the default rcParam. + """ + dpi = dpi if dpi is not None else plt.rcParams['figure.dpi'] + kwargs.setdefault('interpolation', 'nearest') + kwargs.setdefault('cmap', 'gray') + + h, w, d = np.atleast_3d(image).shape + figsize = np.array((w, h), dtype=float) / dpi * scale + + fig, ax = plt.subplots(figsize=figsize, dpi=dpi) + fig.subplots_adjust(left=0, bottom=0, right=1, top=1) + + ax.set_axis_off() + ax.imshow(image, **kwargs) + return fig, ax + + +class LinearColormap(LinearSegmentedColormap): + """LinearSegmentedColormap in which color varies smoothly. + + This class is a simplification of LinearSegmentedColormap, which doesn't + support jumps in color intensities. + + Parameters + ---------- + name : str + Name of colormap. + + segmented_data : dict + Dictionary of 'red', 'green', 'blue', and (optionally) 'alpha' values. + Each color key contains a list of `x`, `y` tuples. `x` must increase + monotonically from 0 to 1 and corresponds to input values for a mappable + object (e.g. an image). `y` corresponds to the color intensity. + + """ + def __init__(self, name, segmented_data, **kwargs): + segmented_data = dict((key, [(x, y, y) for x, y in value]) + for key, value in segmented_data.iteritems()) + LinearSegmentedColormap.__init__(self, name, segmented_data, **kwargs) + + +class ClearColormap(LinearColormap): + """Color map that varies linearly from alpha = 0 to 1 + """ + def __init__(self, rgb, max_alpha=1, name='clear_color'): + r, g, b = rgb + cg_speq = {'blue': [(0.0, b), (1.0, b)], + 'green': [(0.0, g), (1.0, g)], + 'red': [(0.0, r), (1.0, r)], + 'alpha': [(0.0, 0.0), (1.0, max_alpha)]} + LinearColormap.__init__(self, name, cg_speq) + + +class MatplotlibCanvas(FigureCanvasQTAgg): + """Canvas for displaying images.""" + def __init__(self, parent, figure, **kwargs): + self.fig = figure + FigureCanvasQTAgg.__init__(self, self.fig) + FigureCanvasQTAgg.setSizePolicy(self, + QtGui.QSizePolicy.Expanding, + QtGui.QSizePolicy.Expanding) + FigureCanvasQTAgg.updateGeometry(self) + # Note: `setParent` must be called after `FigureCanvasQTAgg.__init__`. + self.setParent(parent) diff --git a/skimage/viewer/viewers/__init__.py b/skimage/viewer/viewers/__init__.py new file mode 100644 index 00000000..bb67a43f --- /dev/null +++ b/skimage/viewer/viewers/__init__.py @@ -0,0 +1 @@ +from .core import * diff --git a/skimage/viewer/viewers/core.py b/skimage/viewer/viewers/core.py new file mode 100644 index 00000000..3775e511 --- /dev/null +++ b/skimage/viewer/viewers/core.py @@ -0,0 +1,188 @@ +""" +ImageViewer class for viewing and interacting with images. +""" +import sys + +from PyQt4 import QtGui, QtCore + +from ..utils import figimage, MatplotlibCanvas + + +qApp = None + + +class ImageCanvas(MatplotlibCanvas): + """Canvas for displaying images.""" + def __init__(self, parent, image, **kwargs): + self.fig, self.ax = figimage(image, **kwargs) + super(ImageCanvas, self).__init__(parent, self.fig, **kwargs) + + +class ImageViewer(QtGui.QMainWindow): + """Viewer for displaying images. + + This viewer is a simple container object that holds a Matplotlib axes + for showing images. `ImageViewer` doesn't subclass the Matplotlib axes (or + figure) because of the high probability of name collisions. + + Parameters + ---------- + image : array + Image being viewed. + + Attributes + ---------- + canvas, fig, ax : Matplotlib canvas, figure, and axes + Matplotlib canvas, figure, and axes used to display image. + image : array + Image being viewed. Setting this value will update the displayed frame. + original_image : array + Plugins typically operate on (but don't change) the *original* image. + plugins : list + List of attached plugins. + + Examples + -------- + >>> viewer = ImageViewer(image) + >>> viewer += SomePlugin() + >>> viewer.show() + + """ + def __init__(self, image): + # Start main loop + global qApp + if qApp is None: + qApp = QtGui.QApplication(sys.argv) + super(ImageViewer, self).__init__() + + #TODO: Add ImageViewer to skimage.io window manager + + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + self.setWindowTitle("Image Viewer") + + self.file_menu = QtGui.QMenu('&File', self) + self.file_menu.addAction('&Quit', self.close, + QtCore.Qt.CTRL + QtCore.Qt.Key_Q) + self.menuBar().addMenu(self.file_menu) + + self.main_widget = QtGui.QWidget() + self.setCentralWidget(self.main_widget) + + self.canvas = ImageCanvas(self.main_widget, image) + self.fig = self.canvas.fig + self.ax = self.canvas.ax + self.ax.autoscale(enable=False) + + self._image_plot = self.ax.images[0] + + self.original_image = image + self.image = image.copy() + self.plugins = [] + + # List of axes artists to check for removal. + self._axes_artists = [self.ax.artists, + self.ax.collections, + self.ax.images, + self.ax.lines, + self.ax.patches, + self.ax.texts] + + self.layout = QtGui.QVBoxLayout(self.main_widget) + self.layout.addWidget(self.canvas) + + status_bar = self.statusBar() + self.status_message = status_bar.showMessage + sb_size = status_bar.sizeHint() + cs_size = self.canvas.sizeHint() + self.resize(cs_size.width(), cs_size.height() + sb_size.height()) + + self.connect_event('motion_notify_event', self._update_status_bar) + + def __add__(self, plugin): + """Add plugin to ImageViewer""" + plugin.attach(self) + return self + + 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). + for p in self.plugins: + p.move(w, y) + y += p.geometry().height() + + def show(self): + """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() + sys.exit(qApp.exec_()) + + def redraw(self): + self.canvas.draw_idle() + + @property + def image(self): + return self._img + + @image.setter + def image(self, image): + self._img = image + self._image_plot.set_array(image) + self.redraw() + + def reset_image(self): + self.image = self.original_image.copy() + + def connect_event(self, event, callback): + """Connect callback function to matplotlib event and return id.""" + cid = self.canvas.mpl_connect(event, callback) + return cid + + def disconnect_event(self, callback_id): + """Disconnect callback by its id (returned by `connect_event`).""" + self.canvas.mpl_disconnect(callback_id) + + def remove_artist(self, artist): + """Disconnect matplotlib artist from image viewer. + + The `closeEvent` method of a Plugin should remove artists (Matplotlib + lines, markers, etc.) from the viewer so that they aren't stranded. + + Parameters + ---------- + artist : Matplotlib Artist + Artists created by Matplotlib functions (e.g., `plot` returns list + of `Line2D` artists) should be saved by the plugin for removal. + """ + # Note: an `add_artist` method is unnecessary since Matplotlib + + # There's probably a smarter way to find where the artist is stored. + for artist_list in self._axes_artists: + if artist in artist_list: + artist_list.remove(artist) + + def _update_status_bar(self, event): + if event.inaxes and event.inaxes.get_navigate(): + self.status_message(self._format_coord(event.xdata, event.ydata)) + else: + self.status_message('') + + def _format_coord(self, x, y): + # callback function to format coordinate display in status bar + x = int(x + 0.5) + y = int(y + 0.5) + try: + return "%4s @ [%4s, %4s]" % (self.image[y, x], x, y) + except IndexError: + return "" diff --git a/skimage/viewer/widgets/__init__.py b/skimage/viewer/widgets/__init__.py new file mode 100644 index 00000000..6552a313 --- /dev/null +++ b/skimage/viewer/widgets/__init__.py @@ -0,0 +1,2 @@ +from core import * +from history import * diff --git a/skimage/viewer/widgets/core.py b/skimage/viewer/widgets/core.py new file mode 100644 index 00000000..169a4401 --- /dev/null +++ b/skimage/viewer/widgets/core.py @@ -0,0 +1,224 @@ +""" +Widgets for interacting with ImageViewer. + +These widgets should be added to a Plugin subclass using its `add_widget` +method or calling:: + + plugin += Widget(...) + +on a Plugin instance. The Plugin will delegate action based on the widget's +parameter type specified by its `ptype` attribute, which can be: + + 'arg' : positional argument passed to Plugin's `filter_image` method. + 'kwarg' : keyword argument passed to Plugin's `filter_image` method. + 'plugin' : attribute of Plugin. You'll probably need to add a class + property of the same name that updates the display. + +""" +from PyQt4.QtCore import Qt +from PyQt4 import QtGui +from PyQt4 import QtCore + +from ..utils import RequiredAttr + + +__all__ = ['BaseWidget', 'Slider', 'ComboBox'] + + +class BaseWidget(QtGui.QWidget): + + plugin = RequiredAttr("Widget is not attached to a Plugin.") + + def __init__(self, name, ptype=None, callback=None): + super(BaseWidget, self).__init__() + self.name = name + self.ptype = ptype + self.callback = callback + self.plugin = None + + @property + def val(self): + msg = "Subclass of BaseWidget requires `val` property" + raise NotImplementedError(msg) + + def _value_changed(self, value): + self.callback(self.name, value) + + +class Slider(BaseWidget): + """Slider widget for adjusting numeric parameters. + + Parameters + ---------- + name : str + Name of slider parameter. If this parameter is passed as a keyword + argument, it must match the name of that keyword argument (spaces are + replaced with underscores). In addition, this name is displayed as the + name of the slider. + low, high : float + Range of slider values. + value : float + Default slider value. If None, use midpoint between `low` and `high`. + value : {'float' | 'int'} + Numeric type of slider value. + ptype : {'arg' | 'kwarg' | 'plugin'} + Parameter type. + callback : function + Callback function called in response to slider changes. This function + is typically set when the widget is added to a plugin. + orientation : {'horizontal' | 'vertical'} + Slider orientation. + update_on : {'move' | 'release'} + 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'): + super(Slider, self).__init__(name, ptype, callback) + + if value is None: + value = (high - low) / 2. + + # Set widget orientation + #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + if orientation == 'vertical': + self.slider = QtGui.QSlider(Qt.Vertical) + alignment = QtCore.Qt.AlignHCenter + align_text = QtCore.Qt.AlignHCenter + align_value = QtCore.Qt.AlignHCenter + self.layout = QtGui.QVBoxLayout(self) + elif orientation == 'horizontal': + self.slider = QtGui.QSlider(Qt.Horizontal) + alignment = QtCore.Qt.AlignVCenter + align_text = QtCore.Qt.AlignLeft + align_value = QtCore.Qt.AlignRight + self.layout = QtGui.QHBoxLayout(self) + else: + msg = "Unexpected value %s for 'orientation'" + raise ValueError(msg % orientation) + #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + # Set slider behavior for float and int values. + #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + if value_type == 'float': + # divide slider into 1000 discrete values + slider_max = 1000 + self._scale = float(high - low) / slider_max + self.slider.setRange(0, slider_max) + self.value_fmt = '%2.2f' + elif value_type == 'int': + self.slider.setRange(low, high) + self.value_fmt = '%d' + else: + msg = "Expected `value_type` to be 'float' or 'int'; received: %s" + raise ValueError(msg % value_type) + #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + self.value_type = value_type + self._low = low + self._high = high + # Update slider position to default value + self.val = value + + if update_on == 'move': + self.slider.valueChanged.connect(self._on_slider_changed) + elif update_on == 'release': + self.slider.sliderReleased.connect(self._on_slider_changed) + else: + raise ValueError("Unexpected value %s for 'update_on'" % update_on) + + self.name_label = QtGui.QLabel() + self.name_label.setText(self.name) + self.name_label.setAlignment(align_text) + + self.editbox = QtGui.QLineEdit() + self.editbox.setMaximumWidth(max_edit_width) + self.editbox.setText(self.value_fmt % self.val) + self.editbox.setAlignment(align_value) + self.editbox.editingFinished.connect(self._on_editbox_changed) + + self.layout.addWidget(self.name_label, alignment=align_text) + self.layout.addWidget(self.slider, alignment=alignment) + self.layout.addWidget(self.editbox, alignment=align_value) + + def _on_slider_changed(self): + """Call callback function with slider's name and value as parameters""" + value = self.val + self.editbox.setText(str(value)[:4]) + self.callback(self.name, value) + + def _on_editbox_changed(self): + """Validate input and set slider value""" + try: + value = float(self.editbox.text()) + except ValueError: + self._bad_editbox_input() + return + if not self._low <= value <= self._high: + self._bad_editbox_input() + return + + self.val = value + self._good_editbox_input() + self.callback(self.name, value) + + def _good_editbox_input(self): + self.editbox.setStyleSheet("background-color: rgb(255, 255, 255)") + + def _bad_editbox_input(self): + self.editbox.setStyleSheet("background-color: rgb(255, 200, 200)") + + @property + def val(self): + value = self.slider.value() + if self.value_type == 'float': + value = value * self._scale + self._low + return value + + @val.setter + def val(self, value): + if self.value_type == 'float': + value = (value - self._low) / self._scale + self.slider.setValue(value) + + +class ComboBox(BaseWidget): + """ComboBox widget for selecting among a list of choices. + + Parameters + ---------- + name : str + Name of slider parameter. If this parameter is passed as a keyword + argument, it must match the name of that keyword argument (spaces are + replaced with underscores). In addition, this name is displayed as the + name of the slider. + items: list + Allowed parameter values. + ptype : {'arg' | 'kwarg' | 'plugin'} + Parameter type. + callback : function + Callback function called in response to slider changes. This function + is typically set when the widget is added to a plugin. + """ + + def __init__(self, name, items, ptype='kwarg', callback=None): + super(ComboBox, self).__init__(name, ptype, callback) + + self.name_label = QtGui.QLabel() + self.name_label.setText(self.name) + self.name_label.setAlignment(QtCore.Qt.AlignLeft) + + self._combo_box = QtGui.QComboBox() + self._combo_box.addItems(items) + + self.layout = QtGui.QHBoxLayout(self) + self.layout.addWidget(self.name_label) + self.layout.addWidget(self._combo_box, alignment=QtCore.Qt.AlignLeft) + + self._combo_box.currentIndexChanged.connect(self._value_changed) + # self.connect(self._combo_box, + # SIGNAL("currentIndexChanged(int)"), self.updateUi) + + @property + def val(self): + return self._combo_box.value() diff --git a/skimage/viewer/widgets/history.py b/skimage/viewer/widgets/history.py new file mode 100644 index 00000000..bb65b7dd --- /dev/null +++ b/skimage/viewer/widgets/history.py @@ -0,0 +1,96 @@ +import os +from textwrap import dedent + +from PyQt4 import QtGui + +from skimage import io +from .core import BaseWidget + + +__all__ = ['OKCancelButtons', 'SaveButtons'] + + +class OKCancelButtons(BaseWidget): + """Buttons that close the parent plugin. + + OK will replace the original image with the current (filtered) image. + Cancel will just close the plugin. + """ + def __init__(self, button_width=80): + name = 'OK/Cancel' + super(OKCancelButtons, self).__init__(name) + + self.ok = QtGui.QPushButton('OK') + self.ok.clicked.connect(self.update_original_image) + self.ok.setMaximumWidth(button_width) + self.cancel = QtGui.QPushButton('Cancel') + self.cancel.clicked.connect(self.close_plugin) + self.cancel.setMaximumWidth(button_width) + + self.layout = QtGui.QHBoxLayout(self) + self.layout.addStretch() + self.layout.addWidget(self.cancel) + self.layout.addWidget(self.ok) + + def update_original_image(self): + image = self.plugin.image_viewer.image + self.plugin.image_viewer.original_image = image + self.plugin.close() + + def close_plugin(self): + # Image viewer will restore original image on close. + self.plugin.close() + + +class SaveButtons(BaseWidget): + """Buttons to save image to io.stack or to a file.""" + + def __init__(self, name='Save to:', default_format='png'): + super(SaveButtons, self).__init__(name) + + self.default_format = default_format + + self.name_label = QtGui.QLabel() + self.name_label.setText(name) + + self.save_file = QtGui.QPushButton('File') + self.save_file.clicked.connect(self.save_to_file) + self.save_stack = QtGui.QPushButton('Stack') + self.save_stack.clicked.connect(self.save_to_stack) + + self.layout = QtGui.QHBoxLayout(self) + self.layout.addWidget(self.name_label) + self.layout.addWidget(self.save_stack) + self.layout.addWidget(self.save_file) + + def save_to_stack(self): + image = self.plugin.image_viewer.image.copy() + io.push(image) + + msg = dedent('''\ + The image has been pushed to the io stack. + Use io.pop() to retrieve the most recently pushed image. + NOTE: The io stack only works in interactive sessions.''') + notify(msg) + + def save_to_file(self): + filename = str(QtGui.QFileDialog.getSaveFileName()) + if len(filename) == 0: + return + #TODO: io plugins should assign default image formats + basename, ext = os.path.splitext(filename) + if not ext: + filename = '%s.%s' % (filename, self.default_format) + io.imsave(filename, self.plugin.image_viewer.image) + + +def notify(msg): + msglabel = QtGui.QLabel(msg) + dialog = QtGui.QDialog() + ok = QtGui.QPushButton('OK', dialog) + ok.clicked.connect(dialog.accept) + ok.setDefault(True) + dialog.layout = QtGui.QGridLayout(dialog) + dialog.layout.addWidget(msglabel, 0, 0, 1, 3) + dialog.layout.addWidget(ok, 1, 1) + dialog.exec_() diff --git a/viewer_examples/plugins/canny.py b/viewer_examples/plugins/canny.py new file mode 100644 index 00000000..eaa33330 --- /dev/null +++ b/viewer_examples/plugins/canny.py @@ -0,0 +1,9 @@ +from skimage import data +from skimage.viewer import ImageViewer +from skimage.viewer.plugins.canny import CannyPlugin + + +image = data.camera() +viewer = ImageViewer(image) +viewer += CannyPlugin() +viewer.show() diff --git a/viewer_examples/plugins/canny_simple.py b/viewer_examples/plugins/canny_simple.py new file mode 100644 index 00000000..49bc15eb --- /dev/null +++ b/viewer_examples/plugins/canny_simple.py @@ -0,0 +1,20 @@ +from skimage import data +from skimage.filter import canny + +from skimage.viewer import ImageViewer +from skimage.viewer.widgets import Slider +from skimage.viewer.plugins.overlayplugin import OverlayPlugin + + +image = data.camera() +# Note: ImageViewer must be called before Plugin b/c it starts the event loop. +viewer = ImageViewer(image) +# 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') +# Finally, attach the plugin to the image viewer. +viewer += plugin +viewer.show() diff --git a/viewer_examples/plugins/median_filter.py b/viewer_examples/plugins/median_filter.py new file mode 100644 index 00000000..3a050382 --- /dev/null +++ b/viewer_examples/plugins/median_filter.py @@ -0,0 +1,19 @@ +from skimage import data +from skimage.filter import median_filter + +from skimage.viewer import ImageViewer +from skimage.viewer.widgets import Slider +from skimage.viewer.widgets.history import OKCancelButtons, SaveButtons +from skimage.viewer.plugins.base import Plugin + + +image = data.coins() +viewer = ImageViewer(image) + +plugin = Plugin(image_filter=median_filter) +plugin += Slider('radius', 2, 10, value_type='int', update_on='release') +plugin += SaveButtons() +plugin += OKCancelButtons() + +viewer += plugin +viewer.show() diff --git a/viewer_examples/viewers/image_viewer.py b/viewer_examples/viewers/image_viewer.py new file mode 100644 index 00000000..e0932787 --- /dev/null +++ b/viewer_examples/viewers/image_viewer.py @@ -0,0 +1,7 @@ +from skimage import data +from skimage.viewer import ImageViewer + + +image = data.camera() +viewer = ImageViewer(image) +viewer.show()