diff options
Diffstat (limited to 'src/silx/gui/plot/ImageStack.py')
-rw-r--r-- | src/silx/gui/plot/ImageStack.py | 348 |
1 files changed, 146 insertions, 202 deletions
diff --git a/src/silx/gui/plot/ImageStack.py b/src/silx/gui/plot/ImageStack.py index e2bed9d..175d6e4 100644 --- a/src/silx/gui/plot/ImageStack.py +++ b/src/silx/gui/plot/ImageStack.py @@ -1,6 +1,6 @@ # /*########################################################################## # -# Copyright (c) 2020-2021 European Synchrotron Radiation Facility +# Copyright (c) 2020-2023 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 @@ -23,118 +23,35 @@ # ###########################################################################*/ """Image stack view with data prefetch capabilty.""" +from __future__ import annotations + __authors__ = ["H. Payno"] __license__ = "MIT" __date__ = "04/03/2019" -from silx.gui import icons, qt +from silx.gui import 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 +from silx.gui.widgets.UrlList import UrlList +from silx.gui.utils import blockSignals +from silx.utils.deprecation import deprecated + import typing import logging +from silx.gui.widgets.WaitingOverlay import WaitingOverlay +from collections.abc import Iterable _logger = logging.getLogger(__name__) -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""" - if self.running: - self.animated_icon.unregister(self._label) - self.running = False - self.join(2) - - def __init__(self, parent): - super(_PlotWithWaitingLabel, self).__init__(parent=parent) - self._autoResetZoom = True - 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.stopUpdateThread() - - def stopUpdateThread(self): - self.updateThread.stop() - - def setAutoResetZoom(self, reset): - """ - Should we reset the zoom when adding an image (eq. when browsing) - - :param bool reset: - """ - self._autoResetZoom = reset - if self._autoResetZoom: - self._plot.resetZoom() - - def isAutoResetZoom(self): - """ - - :return: True if a reset is done when the image change - :rtype: bool - """ - return self._autoResetZoom - - 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, resetzoom=self._autoResetZoom) - - 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) + super().__init__(parent=parent) # connect signal / slot self.valueChanged.connect(self._urlChanged) @@ -146,67 +63,23 @@ class _HorizontalSlider(HorizontalSliderWithBrowser): 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): - if current is None: - pass - else: - 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.') - elif len(sel_items) > 0: - item = sel_items[0] - self._listWidget.setCurrentItem(item) - self.sigCurrentUrlChanged.emit(item.text()) - - def clear(self): - self._listWidget.clear() - - class _ToggleableUrlSelectionTable(qt.QWidget): - _BUTTON_ICON = qt.QStyle.SP_ToolBarHorizontalExtensionButton # noqa sigCurrentUrlChanged = qt.Signal(str) """Signal emitted when the active/current url change""" + sigUrlRemoved = qt.Signal(str) + def __init__(self, parent=None) -> None: - qt.QWidget.__init__(self, parent) + super().__init__(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._toggleButton.setSizePolicy(qt.QSizePolicy.Fixed, qt.QSizePolicy.Fixed) self._urlsTable = UrlList(parent=self) + self.layout().addWidget(self._urlsTable, 1, 1, 1, 2) # set up @@ -214,12 +87,8 @@ class _ToggleableUrlSelectionTable(qt.QWidget): # 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 + self._urlsTable.sigCurrentUrlChanged.connect(self.sigCurrentUrlChanged) + self._urlsTable.sigUrlRemoved.connect(self.sigUrlRemoved) def toggleUrlSelectionTable(self): visible = not self.urlSelectionTableIsVisible() @@ -236,21 +105,36 @@ class _ToggleableUrlSelectionTable(qt.QWidget): self._toggleButton.setIcon(icon) def urlSelectionTableIsVisible(self): - return self._urlsTable.isVisible() - - def _propagateSignal(self, url): - self.sigCurrentUrlChanged.emit(url) + return self._urlsTable.isVisibleTo(self) def clear(self): self._urlsTable.clear() + # expose UrlList API + @deprecated(replacement="addUrls", since_version="2.0") + def setUrls(self, urls: Iterable[DataUrl]): + self._urlsTable.addUrls(urls=urls) + + def addUrls(self, urls: Iterable[DataUrl]): + self._urlsTable.addUrls(urls=urls) + + def setUrl(self, url: typing.Optional[DataUrl]): + self._urlsTable.setUrl(url=url) + + def removeUrl(self, url: str): + self._urlsTable.removeUrl(url) + + def currentItem(self): + return self._urlsTable.currentItem() + class UrlLoader(qt.QThread): """ Thread use to load DataUrl """ + def __init__(self, parent, url): - super(UrlLoader, self).__init__(parent=parent) + super().__init__(parent=parent) assert isinstance(url, DataUrl) self.url = url self.data = None @@ -277,17 +161,21 @@ class ImageStack(qt.QMainWindow): """Signal emitted when the current url change""" def __init__(self, parent=None) -> None: - super(ImageStack, self).__init__(parent) + super().__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" + self._autoResetZoom = True # main widget - self._plot = _PlotWithWaitingLabel(parent=self) + self._plot = Plot2D(parent=self) self._plot.setAttribute(qt.Qt.WA_DeleteOnClose, True) + self._waitingOverlay = WaitingOverlay(self._plot) + self._waitingOverlay.setIconSize(qt.QSize(30, 30)) + self._waitingOverlay.hide() self.setWindowTitle("Image stack") self.setCentralWidget(self._plot) @@ -308,12 +196,14 @@ class ImageStack(qt.QMainWindow): # connect signal / slot self._urlsTable.sigCurrentUrlChanged.connect(self.setCurrentUrl) + self._urlsTable.sigUrlRemoved.connect(self.removeUrl) self._slider.sigCurrentUrlIndexChanged.connect(self.setCurrentUrlIndex) def close(self) -> bool: self._freeLoadingThreads() + self._waitingOverlay.close() self._plot.close() - super(ImageStack, self).close() + super().close() def setUrlLoaderClass(self, urlLoader: typing.Type[UrlLoader]) -> None: """ @@ -346,14 +236,14 @@ class ImageStack(qt.QMainWindow): :return: PlotWidget contained in this window :rtype: Plot2D """ - return self._plot.getPlotWidget() + return self._plot 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._urlData = {} self._current_url = None self._plot.clear() self._urlsTable.clear() @@ -396,7 +286,8 @@ class ImageStack(qt.QMainWindow): if url in self._urlIndexes: self._urlData[url] = sender.data if self.getCurrentUrl().path() == url: - self._plot.setData(self._urlData[url]) + self._waitingOverlay.setVisible(False) + self._plot.addImage(self._urlData[url], resetzoom=self._autoResetZoom) if sender in self._loadingThreads: self._loadingThreads.remove(sender) self.sigLoaded.emit(url) @@ -421,6 +312,29 @@ class ImageStack(qt.QMainWindow): """ return self.__n_prefetch + def setUrlsEditable(self, editable: bool): + self._urlsTable._urlsTable.setEditable(editable) + if editable: + selection_mode = qt.QAbstractItemView.ExtendedSelection + else: + selection_mode = qt.QAbstractItemView.SingleSelection + self._urlsTable._urlsTable.setSelectionMode(selection_mode) + + @staticmethod + def createUrlIndexes(urls: tuple): + indexes = {} + for index, url in enumerate(urls): + assert isinstance( + url, DataUrl + ), f"url is expected to be a DataUrl. Get {type(url)}" + indexes[index] = url + return indexes + + def _resetSlider(self): + with blockSignals(self._slider): + self._slider.setMinimum(0) + self._slider.setMaximum(len(self._urls) - 1) + 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 @@ -429,26 +343,16 @@ class ImageStack(qt.QMainWindow): (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() + urls_with_indexes = self.createUrlIndexes(urls=urls) 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) + with blockSignals(self._urlsTable): + self._urlsTable.addUrls(urls=list(self._urls.values())) - old_slider = self._slider.blockSignals(True) - self._slider.setMinimum(0) - self._slider.setMaximum(len(self._urls) - 1) - self._slider.blockSignals(old_slider) + self._resetSlider() if self.getCurrentUrl() in self._urls: self.setCurrentUrl(self.getCurrentUrl()) @@ -457,6 +361,35 @@ class ImageStack(qt.QMainWindow): first_url = self._urls[list(self._urls.keys())[0]] self.setCurrentUrl(first_url) + def removeUrl(self, url: str) -> None: + """ + Remove provided URL from the table + + :param url: URL as str + """ + # remove the given urls from self._urls and self._urlIndexes + if not isinstance(url, str): + raise TypeError("url is expected to be the str representation of the url") + + # try to get reset the url displayed + current_url = self.getCurrentUrl() + with blockSignals(self._urlsTable): + self._urlsTable.removeUrl(url) + # update urls + urls_with_indexes = self.createUrlIndexes( + filter( + lambda a: a.path() != url, + self._urls.values(), + ) + ) + urlsToIndex = self._urlsToIndex(urls_with_indexes) + self._urls = urls_with_indexes + self._urlIndexes = urlsToIndex + self._resetSlider() + + if current_url != url: + self.setCurrentUrl(current_url) + def getUrls(self) -> tuple: """ @@ -555,41 +488,46 @@ class ImageStack(qt.QMainWindow): if self._urls is None: return elif index >= len(self._urls): - raise ValueError('requested index out of bounds') + raise ValueError("requested index out of bounds") else: return self.setCurrentUrl(self._urls[index]) - def setCurrentUrl(self, url: typing.Union[DataUrl, str]) -> None: + def setCurrentUrl(self, url: typing.Optional[typing.Union[DataUrl, str]]) -> None: """ Define the url to be displayed :param url: url to be displayed :type: DataUrl + :raises KeyError: raised if the url is not know """ - assert isinstance(url, (DataUrl, str)) - if isinstance(url, str): + assert isinstance(url, (DataUrl, str, type(None))) + if url == "": + url = None + elif isinstance(url, str): url = DataUrl(path=url) - if url != self._current_url: + if url is not None and 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) + with blockSignals(self._urlsTable): + with blockSignals(self._slider): + self._urlsTable.setUrl(url) + if url is not None: + 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._waitingOverlay.setVisible(False) + self._plot.addImage( + self._urlData[url.path()], resetzoom=self._autoResetZoom + ) + else: + self._plot.clear() + self._load(url) + self._waitingOverlay.setVisible(True) + self._preFetch(self._getNNextUrls(self.__n_prefetch, url)) + self._preFetch(self._getNPreviousUrls(self.__n_prefetch, url)) def getCurrentUrl(self) -> typing.Union[None, DataUrl]: """ @@ -618,17 +556,15 @@ class ImageStack(qt.QMainWindow): res[url.path()] = index return res - def _notifyLoading(self): - """display a simple image of loading...""" - self._plot.setWaiting(activate=True) - def setAutoResetZoom(self, reset): """ Should we reset the zoom when adding an image (eq. when browsing) :param bool reset: """ - self._plot.setAutoResetZoom(reset) + self._autoResetZoom = reset + if self._autoResetZoom: + self._plot.resetZoom() def isAutoResetZoom(self) -> bool: """ @@ -636,4 +572,12 @@ class ImageStack(qt.QMainWindow): :return: True if a reset is done when the image change :rtype: bool """ - return self._plot.isAutoResetZoom() + return self._autoResetZoom + + def getWaiterOverlay(self): + """ + + :return: Return the instance of `WaitingOverlay` used to display if processing or not + :rtype: WaitingOverlay + """ + return self._waitingOverlay |