diff options
Diffstat (limited to 'src/silx/app/compare')
-rw-r--r-- | src/silx/app/compare/CompareImagesWindow.py | 254 | ||||
-rw-r--r-- | src/silx/app/compare/__init__.py | 27 | ||||
-rw-r--r-- | src/silx/app/compare/main.py | 105 | ||||
-rw-r--r-- | src/silx/app/compare/test/__init__.py | 23 | ||||
-rw-r--r-- | src/silx/app/compare/test/test_compare.py | 49 | ||||
-rw-r--r-- | src/silx/app/compare/test/test_launcher.py | 142 |
6 files changed, 600 insertions, 0 deletions
diff --git a/src/silx/app/compare/CompareImagesWindow.py b/src/silx/app/compare/CompareImagesWindow.py new file mode 100644 index 0000000..7a509ae --- /dev/null +++ b/src/silx/app/compare/CompareImagesWindow.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python +# /*########################################################################## +# +# Copyright (c) 2016-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 +# 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. +# +# ###########################################################################*/ +"""Main window used to compare images +""" + +import logging +import numpy +import typing +import os.path + +import silx.io +from silx.gui import icons +from silx.gui import qt +from silx.gui.plot.CompareImages import CompareImages +from silx.gui.widgets.UrlSelectionTable import UrlSelectionTable +from ..utils import parseutils +from silx.gui.plot.tools.profile.manager import ProfileManager +from silx.gui.plot.tools.compare.profile import ProfileImageDirectedLineROI + +try: + import PIL +except ImportError: + PIL = None + + +_logger = logging.getLogger(__name__) + + +def _get_image_from_file(urlPath: str) -> typing.Optional[numpy.ndarray]: + """Returns a dataset from an image file. + + The returned layout shape is supposed to be `rows, columns, channels (rgb[a])`. + """ + if PIL is None: + return None + return numpy.asarray(PIL.Image.open(urlPath)) + + +class CompareImagesWindow(qt.QMainWindow): + def __init__(self, backend=None, settings=None): + qt.QMainWindow.__init__(self, parent=None) + self.setWindowTitle("Silx compare") + + silxIcon = icons.getQIcon("silx") + self.setWindowIcon(silxIcon) + + self._plot = CompareImages(parent=self, backend=backend) + self._plot.setAutoResetZoom(False) + + self.__manager = ProfileManager(self, self._plot.getPlot()) + virtualItem = self._plot._getVirtualPlotItem() + self.__manager.setPlotItem(virtualItem) + + directedLineAction = self.__manager.createProfileAction( + ProfileImageDirectedLineROI, self + ) + + profileToolBar = qt.QToolBar(self) + profileToolBar.setWindowTitle("Profile") + profileToolBar.addAction(directedLineAction) + self.__profileToolBar = profileToolBar + self._plot.addToolBar(profileToolBar) + + self._selectionTable = UrlSelectionTable(parent=self) + self._selectionTable.setAcceptDrops(True) + + self.__settings = settings + if settings: + self.restoreSettings(settings) + + spliter = qt.QSplitter(self) + spliter.addWidget(self._selectionTable) + spliter.addWidget(self._plot) + spliter.setStretchFactor(1, 1) + spliter.setCollapsible(0, False) + spliter.setCollapsible(1, False) + self.__splitter = spliter + + self.setCentralWidget(spliter) + + self._selectionTable.sigImageAChanged.connect(self._updateImageA) + self._selectionTable.sigImageBChanged.connect(self._updateImageB) + + def setUrls(self, urls): + self.clear() + for url in urls: + self._selectionTable.addUrl(url) + url1 = urls[0].path() if len(urls) >= 1 else None + url2 = urls[1].path() if len(urls) >= 2 else None + self._selectionTable.setUrlSelection(url_img_a=url1, url_img_b=url2) + self._plot.resetZoom() + self._plot.centerLines() + + def clear(self): + self._plot.clear() + self._selectionTable.clear() + + def _updateImageA(self, urlPath): + try: + data = self.readData(urlPath) + except Exception as e: + _logger.error("Error while loading URL %s", urlPath, exc_info=True) + self._selectionTable.setError(urlPath, e.args[0]) + data = None + self._plot.setImage1(data) + + def _updateImageB(self, urlPath): + try: + data = self.readData(urlPath) + except Exception as e: + _logger.error("Error while loading URL %s", urlPath, exc_info=True) + self._selectionTable.setError(urlPath, e.args[0]) + data = None + self._plot.setImage2(data) + + def readData(self, urlPath: str): + """Read an URL as an image""" + if urlPath in ("", None): + return None + + data = None + _, ext = os.path.splitext(urlPath) + if ext in {".jpg", ".jpeg", ".png"}: + try: + data = _get_image_from_file(urlPath) + except Exception: + _logger.debug("Error while loading image with PIL", exc_info=True) + + if data is None: + try: + data = silx.io.utils.get_data(urlPath) + except Exception: + raise ValueError(f"Data from '{urlPath}' is not readable") + + if not isinstance(data, numpy.ndarray): + raise ValueError(f"URL '{urlPath}' does not link to a numpy array") + if data.dtype.kind not in set(["f", "u", "i", "b"]): + raise ValueError(f"URL '{urlPath}' does not link to a numeric numpy array") + + if data.ndim == 2: + return data + if data.ndim == 3 and data.shape[2] in {3, 4}: + return data + + raise ValueError(f"URL '{urlPath}' does not link to an numpy image") + + def closeEvent(self, event): + settings = self.__settings + if settings: + self.saveSettings(self.__settings) + + def saveSettings(self, settings): + """Save the window settings to this settings object + + :param qt.QSettings settings: Initialized settings + """ + isFullScreen = bool(self.windowState() & qt.Qt.WindowFullScreen) + if isFullScreen: + # show in normal to catch the normal geometry + self.showNormal() + + settings.beginGroup("comparewindow") + settings.setValue("size", self.size()) + settings.setValue("pos", self.pos()) + settings.setValue("full-screen", isFullScreen) + settings.setValue("spliter", self.__splitter.sizes()) + # NOTE: isInverted returns a numpy bool + settings.setValue( + "y-axis-inverted", bool(self._plot.getPlot().getYAxis().isInverted()) + ) + + settings.setValue("visualization-mode", self._plot.getVisualizationMode().name) + settings.setValue("alignment-mode", self._plot.getAlignmentMode().name) + settings.setValue("display-keypoints", self._plot.getKeypointsVisible()) + + displayKeypoints = settings.value("display-keypoints", False) + displayKeypoints = parseutils.to_bool(displayKeypoints, False) + + # self._plot.getAlignmentMode() + # self._plot.getVisualizationMode() + # self._plot.getKeypointsVisible() + settings.endGroup() + + if isFullScreen: + self.showFullScreen() + + def restoreSettings(self, settings): + """Restore the window settings using this settings object + + :param qt.QSettings settings: Initialized settings + """ + settings.beginGroup("comparewindow") + size = settings.value("size", qt.QSize(640, 480)) + pos = settings.value("pos", qt.QPoint()) + isFullScreen = settings.value("full-screen", False) + isFullScreen = parseutils.to_bool(isFullScreen, False) + yAxisInverted = settings.value("y-axis-inverted", False) + yAxisInverted = parseutils.to_bool(yAxisInverted, False) + + visualizationMode = settings.value("visualization-mode", "") + visualizationMode = parseutils.to_enum( + visualizationMode, + CompareImages.VisualizationMode, + CompareImages.VisualizationMode.VERTICAL_LINE, + ) + alignmentMode = settings.value("alignment-mode", "") + alignmentMode = parseutils.to_enum( + alignmentMode, + CompareImages.AlignmentMode, + CompareImages.AlignmentMode.ORIGIN, + ) + displayKeypoints = settings.value("display-keypoints", False) + displayKeypoints = parseutils.to_bool(displayKeypoints, False) + + try: + data = settings.value("spliter") + data = [int(d) for d in data] + self.__splitter.setSizes(data) + except Exception: + _logger.debug("Backtrace", exc_info=True) + settings.endGroup() + + if not pos.isNull(): + self.move(pos) + if not size.isNull(): + self.resize(size) + if isFullScreen: + self.showFullScreen() + self._plot.setVisualizationMode(visualizationMode) + self._plot.setAlignmentMode(alignmentMode) + self._plot.setKeypointsVisible(displayKeypoints) + self._plot.getPlot().getYAxis().setInverted(yAxisInverted) diff --git a/src/silx/app/compare/__init__.py b/src/silx/app/compare/__init__.py new file mode 100644 index 0000000..e5ec4c6 --- /dev/null +++ b/src/silx/app/compare/__init__.py @@ -0,0 +1,27 @@ +# /*########################################################################## +# Copyright (C) 2022-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 +# 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. +# +# ############################################################################*/ +"""Package containing source code of the `silx compare` application""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "04/13/2023" diff --git a/src/silx/app/compare/main.py b/src/silx/app/compare/main.py new file mode 100644 index 0000000..79c33f1 --- /dev/null +++ b/src/silx/app/compare/main.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python +# /*########################################################################## +# +# Copyright (c) 2016-2021 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. +# +# ###########################################################################*/ +"""GUI to compare images""" + +import sys +import logging +import argparse +import silx +from silx.gui import qt +from silx.app.utils import parseutils +from silx.app.compare.CompareImagesWindow import CompareImagesWindow + +_logger = logging.getLogger(__name__) + + +file_description = """ +Image data to compare (HDF5 file with path, EDF files, JPEG/PNG image files). +Data from HDF5 files can be accessed using dataset path and slicing as an URL: silx:../my_file.h5?path=/entry/data&slice=10 +EDF file frames also can can be accessed using URL: fabio:../my_file.edf?slice=10 +Using URL in command like usually have to be quoted: "URL". +""" + + +def createParser(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("files", nargs=argparse.ZERO_OR_MORE, help=file_description) + parser.add_argument( + "--debug", + dest="debug", + action="store_true", + default=False, + help="Set logging system in debug mode", + ) + parser.add_argument( + "--use-opengl-plot", + dest="use_opengl_plot", + action="store_true", + default=False, + help="Use OpenGL for plots (instead of matplotlib)", + ) + return parser + + +def mainQt(options): + """Part of the main depending on Qt""" + if options.debug: + logging.root.setLevel(logging.DEBUG) + + if options.use_opengl_plot: + backend = "gl" + else: + backend = "mpl" + + settings = qt.QSettings( + qt.QSettings.IniFormat, qt.QSettings.UserScope, "silx", "silx-compare", None + ) + + urls = list(parseutils.filenames_to_dataurls(options.files)) + + if options.use_opengl_plot: + # It have to be done after the settings (after the Viewer creation) + silx.config.DEFAULT_PLOT_BACKEND = "opengl" + + app = qt.QApplication([]) + window = CompareImagesWindow(backend=backend, settings=settings) + window.setAttribute(qt.Qt.WA_DeleteOnClose, True) + + # Note: Have to be before setUrls to have a proper resetZoom + window.setVisible(True) + + window.setUrls(urls) + + app.exec() + + +def main(argv): + parser = createParser() + options = parser.parse_args(argv[1:]) + mainQt(options) + + +if __name__ == "__main__": + main(sys.argv) diff --git a/src/silx/app/compare/test/__init__.py b/src/silx/app/compare/test/__init__.py new file mode 100644 index 0000000..1d8207b --- /dev/null +++ b/src/silx/app/compare/test/__init__.py @@ -0,0 +1,23 @@ +# /*########################################################################## +# +# Copyright (c) 2016-2017 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. +# +# ###########################################################################*/ diff --git a/src/silx/app/compare/test/test_compare.py b/src/silx/app/compare/test/test_compare.py new file mode 100644 index 0000000..45c6838 --- /dev/null +++ b/src/silx/app/compare/test/test_compare.py @@ -0,0 +1,49 @@ +# /*########################################################################## +# +# Copyright (c) 2016-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. +# +# ###########################################################################*/ +"""Module testing silx.app.view""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "07/06/2023" + + +import weakref +import pytest +from silx.app.compare.CompareImagesWindow import CompareImagesWindow +from silx.gui.utils.testutils import TestCaseQt + + +@pytest.mark.usefixtures("qapp") +class TestCompare(TestCaseQt): + """Test for Viewer class""" + + def testConstruct(self): + widget = CompareImagesWindow() + self.qWaitForWindowExposed(widget) + + def testDestroy(self): + widget = CompareImagesWindow() + ref = weakref.ref(widget) + widget = None + self.qWaitForDestroy(ref) diff --git a/src/silx/app/compare/test/test_launcher.py b/src/silx/app/compare/test/test_launcher.py new file mode 100644 index 0000000..a42b762 --- /dev/null +++ b/src/silx/app/compare/test/test_launcher.py @@ -0,0 +1,142 @@ +# /*########################################################################## +# +# Copyright (c) 2016-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 +# 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. +# +# ###########################################################################*/ +"""Module testing silx.app.view""" + +__authors__ = ["V. Valls"] +__license__ = "MIT" +__date__ = "07/06/2023" + + +import os +import sys +import shutil +import logging +import subprocess +import pytest + +from .. import main +from silx import __main__ as silx_main + +_logger = logging.getLogger(__name__) + + +def test_help(qapp): + # option -h must cause a raise SystemExit or a return 0 + try: + parser = main.createParser() + parser.parse_args(["compare", "--help"]) + result = 0 + except SystemExit as e: + result = e.args[0] + assert result == 0 + + +def test_wrong_option(qapp): + try: + parser = main.createParser() + parser.parse_args(["compare", "--foo"]) + assert False + except SystemExit as e: + result = e.args[0] + assert result != 0 + + +def test_wrong_file(qapp): + try: + parser = main.createParser() + result = parser.parse_args(["compare", "__file.not.found__"]) + result = 0 + except SystemExit as e: + result = e.args[0] + assert result == 0 + + +def _create_test_env(): + """ + Returns an associated environment with a working project. + """ + env = dict((str(k), str(v)) for k, v in os.environ.items()) + env["PYTHONPATH"] = os.pathsep.join(sys.path) + return env + + +@pytest.fixture +def execute_as_script(tmp_path): + """Execute a command line. + + Log output as debug in case of bad return code. + """ + + def execute_as_script(filename, *args): + env = _create_test_env() + + # Copy file to temporary dir to avoid import from current dir. + script = os.path.join(tmp_path, "launcher.py") + shutil.copyfile(filename, script) + command_line = [sys.executable, script] + list(args) + + _logger.info("Execute: %s", " ".join(command_line)) + p = subprocess.Popen( + command_line, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env + ) + out, err = p.communicate() + _logger.info("Return code: %d", p.returncode) + try: + out = out.decode("utf-8") + except UnicodeError: + pass + try: + err = err.decode("utf-8") + except UnicodeError: + pass + + if p.returncode != 0: + _logger.error("stdout:") + _logger.error("%s", out) + _logger.error("stderr:") + _logger.error("%s", err) + else: + _logger.debug("stdout:") + _logger.debug("%s", out) + _logger.debug("stderr:") + _logger.debug("%s", err) + assert p.returncode == 0 + + return execute_as_script + + +def test_execute_compare_help(qapp, execute_as_script): + """Test if the main module is well connected. + + Uses subprocess to avoid to parasite the current environment. + """ + execute_as_script(main.__file__, "--help") + + +def test_execute_silx_compare_help(qapp, execute_as_script): + """Test if the main module is well connected. + + Uses subprocess to avoid to parasite the current environment. + """ + execute_as_script(silx_main.__file__, "view", "--help") |