diff --git a/examples/camviewer/camviewer.ui b/examples/camviewer/camviewer.ui index 03b3dd646..5f686903b 100644 --- a/examples/camviewer/camviewer.ui +++ b/examples/camviewer/camviewer.ui @@ -44,6 +44,9 @@ Displays an image from a PV. + + 30 + diff --git a/examples/image_processing/image_view.py b/examples/image_processing/image_view.py index bde5119fa..0b4e1a9cb 100644 --- a/examples/image_processing/image_view.py +++ b/examples/image_processing/image_view.py @@ -1,49 +1,47 @@ -from pydm.PyQt.QtCore import QObject, pyqtSlot, pyqtSignal -from pydm.widgets.channel import PyDMChannel from pydm import Display -import numpy as np +import threading from os import path from skimage.feature import blob_doh from marker import ImageMarker from pyqtgraph import mkPen + class ImageViewer(Display): + def __init__(self, parent=None, args=None): super(ImageViewer, self).__init__(parent=parent, args=args) - self.image_channel = "ca://MTEST:TwoSpotImage" - self.image_width_channel = "ca://MTEST:ImageWidth" - self.ui.imageView.widthChannel = self.image_width_channel - self.ui.imageView.image_channel = "" - self.markers = [] - + self.markers_lock = threading.Lock() + self.ui.imageView.process_image = self.process_image + self.ui.imageView.newImageSignal.connect(self.draw_markers) + self.markers = list() + self.blobs = list() + def ui_filename(self): return 'image_view.ui' - + def ui_filepath(self): return path.join(path.dirname(path.realpath(__file__)), self.ui_filename()) - - @pyqtSlot(np.ndarray) - def new_image_received(self, new_waveform): - #Reshape the 1D waveform into 2D - img = new_waveform.reshape((int(512),-1), order='C') - #Find blobs in the image with scikit-image - blobs= blob_doh(img, max_sigma=512, min_sigma=64, threshold=.02) - #Remove any existing blob markers - for m in self.markers: - self.ui.imageView.getView().removeItem(m) - self.markers = [] - #For each blob, add a blob marker to the image - for blob in blobs: - x, y, size = blob - m = ImageMarker((y,x), size=size, pen=mkPen((100,100,255), width=3)) - self.ui.imageView.getView().addItem(m) - self.markers.append(m) - #Show number of blobs in text label - self.ui.numBlobsLabel.setText(str(len(blobs))) - #Send the original image data to the image widget - self.ui.imageView.image_value_changed(new_waveform) - - def channels(self): - return [PyDMChannel(address=self.image_channel, value_slot=self.new_image_received)] + + def draw_markers(self, *args, **kwargs): + with self.markers_lock: + for m in self.markers: + if m in self.ui.imageView.getView().addedItems: + self.ui.imageView.getView().removeItem(m) + + for blob in self.blobs: + x, y, size = blob + m = ImageMarker((y, x), size=size, pen=mkPen((100, 100, 255), width=3)) + self.markers.append(m) + self.ui.imageView.getView().addItem(m) + + self.ui.numBlobsLabel.setText(str(len(self.blobs))) + + def process_image(self, new_image): + # Find blobs in the image with scikit-image + self.blobs = blob_doh(new_image, max_sigma=512, min_sigma=64, threshold=.02) + + # Send the original image data to the image widget + return new_image + intelclass = ImageViewer diff --git a/examples/image_processing/image_view.ui b/examples/image_processing/image_view.ui index a999b4e32..a1a1d7fcc 100644 --- a/examples/image_processing/image_view.ui +++ b/examples/image_processing/image_view.ui @@ -22,11 +22,17 @@ + + PyDMImageView::Clike + - + ca://MTEST:TwoSpotImage - + ca://MTEST:ImageWidth + + + 4 diff --git a/examples/image_view/image.ui b/examples/image_view/image.ui index 8bdc510f7..92303857e 100644 --- a/examples/image_view/image.ui +++ b/examples/image_view/image.ui @@ -31,6 +31,9 @@ ca://MTEST:ImageWidth + + 30 + diff --git a/pydm/widgets/image.py b/pydm/widgets/image.py index a7af081b7..0513ac42f 100644 --- a/pydm/widgets/image.py +++ b/pydm/widgets/image.py @@ -1,15 +1,19 @@ from ..PyQt.QtGui import QActionGroup -from ..PyQt.QtCore import pyqtSlot, pyqtProperty, QTimer, Q_ENUMS +from ..PyQt.QtCore import pyqtSignal, pyqtSlot, pyqtProperty, QTimer, Q_ENUMS, QThread from pyqtgraph import ImageView from pyqtgraph import ColorMap from pyqtgraph.graphicsItems.ViewBox.ViewBoxMenu import ViewBoxMenu import numpy as np +import threading +import logging from .channel import PyDMChannel from .colormaps import cmaps, cmap_names, PyDMColorMap from .base import PyDMWidget import pyqtgraph pyqtgraph.setConfigOption('imageAxisOrder', 'row-major') +logger = logging.getLogger(__name__) + class ReadingOrder(object): """Class to build ReadingOrder ENUM property.""" @@ -18,6 +22,56 @@ class ReadingOrder(object): Clike = 1 +class ImageUpdateThread(QThread): + updateSignal = pyqtSignal(list) + + def __init__(self, image_view): + QThread.__init__(self) + self.image_view = image_view + + def run(self): + img = self.image_view.image_waveform + needs_redraw = self.image_view.needs_redraw + image_dimensions = len(img.shape) + width = self.image_view.imageWidth + reading_order = self.image_view.readingOrder + normalize_data = self.image_view._normalize_data + cm_min = self.image_view.cm_min + cm_max = self.image_view.cm_max + + if not needs_redraw: + logging.debug("ImageUpdateThread - needs redraw is False. Aborting.") + return + if image_dimensions == 1: + if width < 1: + # We don't have a width for this image yet, so we can't draw it + logging.debug( + "ImageUpdateThread - no width available. Aborting.") + return + try: + if reading_order == ReadingOrder.Clike: + img = img.reshape((-1, width), order='C') + else: + img = img.reshape((width, -1), order='F') + except ValueError: + logger.error("Invalid width for image during reshape: %d", width) + + if len(img) <= 0: + return + logging.debug("ImageUpdateThread - Will Process Image") + img = self.image_view.process_image(img) + if normalize_data: + mini = img.min() + maxi = img.max() + else: + mini = cm_min + maxi = cm_max + logging.debug("ImageUpdateThread - Emit Update Signal") + self.updateSignal.emit([mini, maxi, img]) + logging.debug("ImageUpdateThread - Set Needs Redraw -> False") + self.image_view.needs_redraw = False + + class PyDMImageView(ImageView, PyDMWidget, PyDMColorMap, ReadingOrder): """ A PyQtGraph ImageView with support for Channels and more from PyDM. @@ -29,6 +83,9 @@ class PyDMImageView(ImageView, PyDMWidget, PyDMColorMap, ReadingOrder): relative to the :attr:`colorMapMin` and :attr:`colorMapMax` property or to the minimum and maximum values of the image. + Use the :attr:`newImageSignal` to hook up to a signal that is emitted when a new + image is rendered in the widget. + Parameters ---------- parent : QWidget @@ -51,12 +108,14 @@ def __init__(self, parent=None, image_channel=None, width_channel=None): """Initialize widget.""" ImageView.__init__(self, parent) PyDMWidget.__init__(self) + self.thread = None self.axes = dict({'t': None, "x": 0, "y": 1, "c": None}) self._imagechannel = image_channel self._widthchannel = width_channel self.image_waveform = np.zeros(0) self._image_width = 0 self._normalize_data = False + self._auto_downsample = True # Hide some itens of the widget. self.ui.histogram.hide() @@ -91,6 +150,7 @@ def __init__(self, parent=None, image_channel=None, width_channel=None): self.redraw_timer.timeout.connect(self.redrawImage) self._redraw_rate = 30 self.maxRedrawRate = self._redraw_rate + self.newImageSignal = self.getImageItem().sigImageChanged def widget_ctx_menu(self): """ @@ -270,6 +330,7 @@ def image_value_changed(self, new_image): """ if new_image is None or new_image.size == 0: return + logging.debug("ImageView Received New Image - Needs Redraw -> True") self.image_waveform = new_image self.needs_redraw = True @@ -287,42 +348,78 @@ def image_width_changed(self, new_width): return self._image_width = int(new_width) + def process_image(self, image): + """ + Boilerplate method to be used by applications in order to + add calculations and also modify the image before it is + displayed at the widget. + + .. warning:: + This code runs in a separated QThread so it **MUST** not try to write + to QWidgets. + + Parameters + ---------- + image : np.ndarray + The Image Data as a 2D numpy array + + Returns + ------- + np.ndarray + The Image Data as a 2D numpy array after processing. + """ + return image + def redrawImage(self): """ Set the image data into the ImageItem, if needed. If necessary, reshape the image to 2D first. """ - if not self.needs_redraw: + if self.thread is not None and not self.thread.isFinished(): + logger.warning( + "Image processing has taken longer than the refresh rate.") return - image_dimensions = len(self.image_waveform.shape) - if image_dimensions == 1: - if self.imageWidth < 1: - # We don't have a width for this image yet, so we can't draw it - return - if self.readingOrder == ReadingOrder.Clike: - img = self.image_waveform.reshape((-1, self.imageWidth), - order='C') - else: - img = self.image_waveform.reshape((self.imageWidth, -1), - order='F') - else: - img = self.image_waveform - - if len(img) <= 0: - return - if self._normalize_data: - mini = self.image_waveform.min() - maxi = self.image_waveform.max() - else: - mini = self.cm_min - maxi = self.cm_max + self.thread = ImageUpdateThread(self) + self.thread.updateSignal.connect(self.__updateDisplay) + logging.debug("ImageView RedrawImage Thread Launched") + self.thread.start() + + @pyqtSlot(list) + def __updateDisplay(self, data): + logging.debug("ImageView Update Display with new image") + mini, maxi = data[0], data[1] + img = data[2] self.getImageItem().setLevels([mini, maxi]) self.getImageItem().setImage( img, autoLevels=False, - autoDownsample=True) - self.needs_redraw = False + autoDownsample=self.autoDownsample) + + @pyqtProperty(bool) + def autoDownsample(self): + """ + Return if we should or not apply the + autoDownsample option to PyQtGraph. + + Return + ------ + bool + """ + return self._auto_downsample + + @autoDownsample.setter + def autoDownsample(self, new_value): + """ + Whether we should or not apply the + autoDownsample option to PyQtGraph. + + Parameters + ---------- + new_value: bool + """ + if new_value != self._auto_downsample: + self._auto_downsample = new_value @pyqtProperty(int) def imageWidth(self): @@ -371,9 +468,8 @@ def normalizeData(self, new_norm): ---------- new_norm: bool """ - if self._normalize_data == new_norm: - return - self._normalize_data = new_norm + if self._normalize_data != new_norm: + self._normalize_data = new_norm @pyqtProperty(ReadingOrder) def readingOrder(self): @@ -504,4 +600,4 @@ def maxRedrawRate(self, redraw_rate): redraw_rate : int """ self._redraw_rate = redraw_rate - self.redraw_timer.setInterval(int((1.0/self._redraw_rate)*1000)) + self.redraw_timer.setInterval(int((1.0 / self._redraw_rate) * 1000))