From 328032e2317e3ac4859196bbf12bdb71795302fe Mon Sep 17 00:00:00 2001 From: Alexandre Marie Date: Tue, 21 Jul 2020 14:45:14 +0200 Subject: New upstream version 0.13.0+dfsg --- silx/gui/plot/ImageStack.py | 586 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 586 insertions(+) create mode 100644 silx/gui/plot/ImageStack.py (limited to 'silx/gui/plot/ImageStack.py') diff --git a/silx/gui/plot/ImageStack.py b/silx/gui/plot/ImageStack.py new file mode 100644 index 0000000..c620d6d --- /dev/null +++ b/silx/gui/plot/ImageStack.py @@ -0,0 +1,586 @@ +# coding: utf-8 +# /*########################################################################## +# +# Copyright (c) 2020 European Synchrotron Radiation Facility +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# ###########################################################################*/ +"""Image stack view with data prefetch capabilty.""" + +__authors__ = ["H. Payno"] +__license__ = "MIT" +__date__ = "04/03/2019" + + +from silx.gui import icons, qt +from silx.gui.plot import Plot2D +from silx.gui.utils import concurrent +from silx.io.url import DataUrl +from silx.io.utils import get_data +from collections import OrderedDict +from silx.gui.widgets.FrameBrowser import HorizontalSliderWithBrowser +import time +import threading +import typing +import logging + +_logger = logging.getLogger(__file__) + + +class _PlotWithWaitingLabel(qt.QWidget): + """Image plot widget with an overlay 'waiting' status. + """ + + class AnimationThread(threading.Thread): + def __init__(self, label): + self.running = True + self._label = label + self.animated_icon = icons.getWaitIcon() + self.animated_icon.register(self._label) + super(_PlotWithWaitingLabel.AnimationThread, self).__init__() + + def run(self): + while self.running: + time.sleep(0.05) + icon = self.animated_icon.currentIcon() + self.future_result = concurrent.submitToQtMainThread( + self._label.setPixmap, icon.pixmap(30, state=qt.QIcon.On)) + + def stop(self): + """Stop the update thread""" + self.animated_icon.unregister(self._label) + self.running = False + self.join(2) + + def __init__(self, parent): + super(_PlotWithWaitingLabel, self).__init__(parent=parent) + layout = qt.QStackedLayout(self) + layout.setStackingMode(qt.QStackedLayout.StackAll) + + self._waiting_label = qt.QLabel(parent=self) + self._waiting_label.setAlignment(qt.Qt.AlignHCenter | qt.Qt.AlignVCenter) + layout.addWidget(self._waiting_label) + + self._plot = Plot2D(parent=self) + layout.addWidget(self._plot) + + self.updateThread = _PlotWithWaitingLabel.AnimationThread(self._waiting_label) + self.updateThread.start() + + def close(self) -> bool: + super(_PlotWithWaitingLabel, self).close() + self.updateThread.stop() + + def setWaiting(self, activate=True): + if activate is True: + self._plot.clear() + self._waiting_label.show() + else: + self._waiting_label.hide() + + def setData(self, data): + self.setWaiting(activate=False) + self._plot.addImage(data=data) + + def clear(self): + self._plot.clear() + self.setWaiting(False) + + def getPlotWidget(self): + return self._plot + + +class _HorizontalSlider(HorizontalSliderWithBrowser): + + sigCurrentUrlIndexChanged = qt.Signal(int) + + def __init__(self, parent): + super(_HorizontalSlider, self).__init__(parent=parent) + # connect signal / slot + self.valueChanged.connect(self._urlChanged) + + def setUrlIndex(self, index): + self.setValue(index) + self.sigCurrentUrlIndexChanged.emit(index) + + def _urlChanged(self, value): + self.sigCurrentUrlIndexChanged.emit(value) + + +class UrlList(qt.QWidget): + """List of URLs the user to select an URL""" + + sigCurrentUrlChanged = qt.Signal(str) + """Signal emitted when the active/current url change""" + + def __init__(self, parent=None): + super(UrlList, self).__init__(parent) + self.setLayout(qt.QVBoxLayout()) + self.layout().setSpacing(0) + self.layout().setContentsMargins(0, 0, 0, 0) + self._listWidget = qt.QListWidget(parent=self) + self.layout().addWidget(self._listWidget) + + # connect signal / Slot + self._listWidget.currentItemChanged.connect(self._notifyCurrentUrlChanged) + + # expose API + self.currentItem = self._listWidget.currentItem + + def setUrls(self, urls: list) -> None: + url_names = [] + [url_names.append(url.path()) for url in urls] + self._listWidget.addItems(url_names) + + def _notifyCurrentUrlChanged(self, current, previous): + self.sigCurrentUrlChanged.emit(current.text()) + + def setUrl(self, url: DataUrl) -> None: + assert isinstance(url, DataUrl) + sel_items = self._listWidget.findItems(url.path(), qt.Qt.MatchExactly) + if sel_items is None: + _logger.warning(url.path(), ' is not registered in the list.') + else: + assert len(sel_items) == 1 + item = sel_items[0] + self._listWidget.setCurrentItem(item) + self.sigCurrentUrlChanged.emit(item.text()) + + +class _ToggleableUrlSelectionTable(qt.QWidget): + + _BUTTON_ICON = qt.QStyle.SP_ToolBarHorizontalExtensionButton # noqa + + sigCurrentUrlChanged = qt.Signal(str) + """Signal emitted when the active/current url change""" + + def __init__(self, parent=None) -> None: + qt.QWidget.__init__(self, parent) + self.setLayout(qt.QGridLayout()) + self._toggleButton = qt.QPushButton(parent=self) + self.layout().addWidget(self._toggleButton, 0, 2, 1, 1) + self._toggleButton.setSizePolicy(qt.QSizePolicy.Fixed, + qt.QSizePolicy.Fixed) + + self._urlsTable = UrlList(parent=self) + self.layout().addWidget(self._urlsTable, 1, 1, 1, 2) + + # set up + self._setButtonIcon(show=True) + + # Signal / slot connection + self._toggleButton.clicked.connect(self.toggleUrlSelectionTable) + self._urlsTable.sigCurrentUrlChanged.connect(self._propagateSignal) + + # expose API + self.setUrls = self._urlsTable.setUrls + self.setUrl = self._urlsTable.setUrl + self.currentItem = self._urlsTable.currentItem + + def toggleUrlSelectionTable(self): + visible = not self.urlSelectionTableIsVisible() + self._setButtonIcon(show=visible) + self._urlsTable.setVisible(visible) + + def _setButtonIcon(self, show): + style = qt.QApplication.instance().style() + # return a QIcon + icon = style.standardIcon(self._BUTTON_ICON) + if show is False: + pixmap = icon.pixmap(32, 32).transformed(qt.QTransform().scale(-1, 1)) + icon = qt.QIcon(pixmap) + self._toggleButton.setIcon(icon) + + def urlSelectionTableIsVisible(self): + return self._urlsTable.isVisible() + + def _propagateSignal(self, url): + self.sigCurrentUrlChanged.emit(url) + + +class UrlLoader(qt.QThread): + """ + Thread use to load DataUrl + """ + def __init__(self, parent, url): + super(UrlLoader, self).__init__(parent=parent) + assert isinstance(url, DataUrl) + self.url = url + self.data = None + + def run(self): + try: + self.data = get_data(self.url) + except IOError: + self.data = None + + +class ImageStack(qt.QMainWindow): + """Widget loading on the fly images contained the given urls. + + It prefetches images close to the displayed one. + """ + + N_PRELOAD = 10 + + sigLoaded = qt.Signal(str) + """Signal emitted when new data is available""" + + sigCurrentUrlChanged = qt.Signal(str) + """Signal emitted when the current url change""" + + def __init__(self, parent=None) -> None: + super(ImageStack, self).__init__(parent) + self.__n_prefetch = ImageStack.N_PRELOAD + self._loadingThreads = [] + self.setWindowFlags(qt.Qt.Widget) + self._current_url = None + self._url_loader = UrlLoader + "class to instantiate for loading urls" + + # main widget + self._plot = _PlotWithWaitingLabel(parent=self) + self._plot.setAttribute(qt.Qt.WA_DeleteOnClose, True) + self.setWindowTitle("Image stack") + self.setCentralWidget(self._plot) + + # dock widget: url table + self._tableDockWidget = qt.QDockWidget(parent=self) + self._urlsTable = _ToggleableUrlSelectionTable(parent=self) + self._tableDockWidget.setWidget(self._urlsTable) + self._tableDockWidget.setFeatures(qt.QDockWidget.DockWidgetMovable) + self.addDockWidget(qt.Qt.RightDockWidgetArea, self._tableDockWidget) + # dock widget: qslider + self._sliderDockWidget = qt.QDockWidget(parent=self) + self._slider = _HorizontalSlider(parent=self) + self._sliderDockWidget.setWidget(self._slider) + self.addDockWidget(qt.Qt.BottomDockWidgetArea, self._sliderDockWidget) + self._sliderDockWidget.setFeatures(qt.QDockWidget.DockWidgetMovable) + + self.reset() + + # connect signal / slot + self._urlsTable.sigCurrentUrlChanged.connect(self.setCurrentUrl) + self._slider.sigCurrentUrlIndexChanged.connect(self.setCurrentUrlIndex) + + def close(self) -> bool: + self._freeLoadingThreads() + self._plot.close() + super(ImageStack, self).close() + + def setUrlLoaderClass(self, urlLoader: typing.Type[UrlLoader]) -> None: + """ + + :param urlLoader: define the class to call for loading urls. + warning: this should be a class object and not a + class instance. + """ + assert isinstance(urlLoader, type(UrlLoader)) + self._url_loader = urlLoader + + def getUrlLoaderClass(self): + """ + + :return: class to instantiate for loading urls + :rtype: typing.Type[UrlLoader] + """ + return self._url_loader + + def _freeLoadingThreads(self): + for thread in self._loadingThreads: + thread.blockSignals(True) + thread.wait(5) + self._loadingThreads.clear() + + def getPlotWidget(self) -> Plot2D: + """ + Returns the PlotWidget contained in this window + + :return: PlotWidget contained in this window + :rtype: Plot2D + """ + return self._plot.getPlotWidget() + + def reset(self) -> None: + """Clear the plot and remove any link to url""" + self._freeLoadingThreads() + self._urls = None + self._urlIndexes = None + self._urlData = OrderedDict({}) + self._current_url = None + self._plot.clear() + + def _preFetch(self, urls: list) -> None: + """Pre-fetch the given urls if necessary + + :param urls: list of DataUrl to prefetch + :type: list + """ + for url in urls: + if url.path() not in self._urlData: + self._load(url) + + def _load(self, url): + """ + Launch background load of a DataUrl + + :param url: + :type: DataUrl + """ + assert isinstance(url, DataUrl) + url_path = url.path() + assert url_path in self._urlIndexes + loader = self._url_loader(parent=self, url=url) + loader.finished.connect(self._urlLoaded, qt.Qt.QueuedConnection) + self._loadingThreads.append(loader) + loader.start() + + def _urlLoaded(self) -> None: + """ + + :param url: restul of DataUrl.path() function + :return: + """ + sender = self.sender() + assert isinstance(sender, UrlLoader) + url = sender.url.path() + if url in self._urlIndexes: + self._urlData[url] = sender.data + if self.getCurrentUrl().path() == url: + self._plot.setData(self._urlData[url]) + if sender in self._loadingThreads: + self._loadingThreads.remove(sender) + self.sigLoaded.emit(url) + + def setNPrefetch(self, n: int) -> None: + """ + Define the number of url to prefetch around + + :param int n: number of url to prefetch on left and right sides. + In total n*2 DataUrl will be prefetch + """ + self.__n_prefetch = n + current_url = self.getCurrentUrl() + if current_url is not None: + self.setCurrentUrl(current_url) + + def getNPrefetch(self) -> int: + """ + + :return: number of url to prefetch on left and right sides. In total + will load 2* NPrefetch DataUrls + """ + return self.__n_prefetch + + def setUrls(self, urls: list) -> None: + """list of urls within an index. Warning: urls should contain an image + compatible with the silx.gui.plot.Plot class + + :param urls: urls we want to set in the stack. Key is the index + (position in the stack), value is the DataUrl + :type: list + """ + def createUrlIndexes(): + indexes = OrderedDict() + for index, url in enumerate(urls): + indexes[index] = url + return indexes + + urls_with_indexes = createUrlIndexes() + urlsToIndex = self._urlsToIndex(urls_with_indexes) + self.reset() + self._urls = urls_with_indexes + self._urlIndexes = urlsToIndex + + old_url_table = self._urlsTable.blockSignals(True) + self._urlsTable.setUrls(urls=list(self._urls.values())) + self._urlsTable.blockSignals(old_url_table) + + old_slider = self._slider.blockSignals(True) + self._slider.setMaximum(len(self._urls) - 1) + self._slider.blockSignals(old_slider) + + if self.getCurrentUrl() in self._urls: + self.setCurrentUrl(self.getCurrentUrl()) + else: + first_url = self._urls[list(self._urls.keys())[0]] + self.setCurrentUrl(first_url) + + def getUrls(self) -> tuple: + """ + + :return: tuple of urls + :rtype: tuple + """ + return tuple(self._urlIndexes.keys()) + + def _getNextUrl(self, url: DataUrl) -> typing.Union[None, DataUrl]: + """ + return the next url in the stack + + :param url: url for which we want the next url + :type: DataUrl + :return: next url in the stack or None if `url` is the last one + :rtype: Union[None, DataUrl] + """ + assert isinstance(url, DataUrl) + if self._urls is None: + return None + else: + index = self._urlIndexes[url.path()] + indexes = list(self._urls.keys()) + res = list(filter(lambda x: x > index, indexes)) + if len(res) == 0: + return None + else: + return self._urls[res[0]] + + def _getPreviousUrl(self, url: DataUrl) -> typing.Union[None, DataUrl]: + """ + return the previous url in the stack + + :param url: url for which we want the previous url + :type: DataUrl + :return: next url in the stack or None if `url` is the last one + :rtype: Union[None, DataUrl] + """ + if self._urls is None: + return None + else: + index = self._urlIndexes[url.path()] + indexes = list(self._urls.keys()) + res = list(filter(lambda x: x < index, indexes)) + if len(res) == 0: + return None + else: + return self._urls[res[-1]] + + def _getNNextUrls(self, n: int, url: DataUrl) -> list: + """ + Deduce the next urls in the stack after `url` + + :param n: the number of url store after `url` + :type: int + :param url: url for which we want n next url + :type: DataUrl + :return: list of next urls. + :rtype: list + """ + res = [] + next_free = self._getNextUrl(url=url) + while len(res) < n and next_free is not None: + assert isinstance(next_free, DataUrl) + res.append(next_free) + next_free = self._getNextUrl(res[-1]) + return res + + def _getNPreviousUrls(self, n: int, url: DataUrl): + """ + Deduce the previous urls in the stack after `url` + + :param n: the number of url store after `url` + :type: int + :param url: url for which we want n previous url + :type: DataUrl + :return: list of previous urls. + :rtype: list + """ + res = [] + next_free = self._getPreviousUrl(url=url) + while len(res) < n and next_free is not None: + res.insert(0, next_free) + next_free = self._getPreviousUrl(res[0]) + return res + + def setCurrentUrlIndex(self, index: int): + """ + Define the url to be displayed + + :param index: url to be displayed + :type: int + """ + if index >= len(self._urls): + raise ValueError('requested index out of bounds') + else: + return self.setCurrentUrl(self._urls[index]) + + def setCurrentUrl(self, url: typing.Union[DataUrl, str]) -> None: + """ + Define the url to be displayed + + :param url: url to be displayed + :type: DataUrl + """ + assert isinstance(url, (DataUrl, str)) + if isinstance(url, str): + url = DataUrl(path=url) + if url != self._current_url: + self._current_url = url + self.sigCurrentUrlChanged.emit(url.path()) + + old_url_table = self._urlsTable.blockSignals(True) + old_slider = self._slider.blockSignals(True) + + self._urlsTable.setUrl(url) + self._slider.setUrlIndex(self._urlIndexes[url.path()]) + if self._current_url is None: + self._plot.clear() + else: + if self._current_url.path() in self._urlData: + self._plot.setData(self._urlData[url.path()]) + else: + self._load(url) + self._notifyLoading() + self._preFetch(self._getNNextUrls(self.__n_prefetch, url)) + self._preFetch(self._getNPreviousUrls(self.__n_prefetch, url)) + self._urlsTable.blockSignals(old_url_table) + self._slider.blockSignals(old_slider) + + def getCurrentUrl(self) -> typing.Union[None, DataUrl]: + """ + + :return: url currently displayed + :rtype: Union[None, DataUrl] + """ + return self._current_url + + def getCurrentUrlIndex(self) -> typing.Union[None, int]: + """ + + :return: index of the url currently displayed + :rtype: Union[None, int] + """ + if self._current_url is None: + return None + else: + return self._urlIndexes[self._current_url.path()] + + @staticmethod + def _urlsToIndex(urls): + """util, return a dictionary with url as key and index as value""" + res = {} + for index, url in urls.items(): + res[url.path()] = index + return res + + def _notifyLoading(self): + """display a simple image of loading...""" + self._plot.setWaiting(activate=True) + -- cgit v1.2.3