diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11b0d93abf..0ce7297e1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,38 +22,47 @@ jobs: - os: ubuntu-20.04 python-version: "3.8" QT_API: PyQt5 + with_opencl: false - os: ubuntu-latest python-version: "3.11" QT_API: PyQt6 + with_opencl: true - os: ubuntu-latest python-version: "3.12" QT_API: PySide6 + with_opencl: true - os: macos-13 python-version: "3.10" QT_API: PyQt5 + with_opencl: true - os: macos-13 python-version: "3.12" QT_API: PyQt6 + with_opencl: true - os: macos-13 python-version: "3.9" QT_API: PySide6 + with_opencl: true - os: windows-latest python-version: "3.9" QT_API: PyQt5 + with_opencl: false - os: windows-latest python-version: "3.12" QT_API: PyQt6 + with_opencl: false - os: windows-latest python-version: "3.10" QT_API: PySide6 + with_opencl: false steps: - uses: actions/checkout@v4 # Install packages: - # OpenCL lib and icd + # OpenCL lib # xvfb to run the GUI test headless # libegl1-mesa: Required by Qt xcb platform plugin # libgl1-mesa-glx: For OpenGL @@ -63,13 +72,24 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install ocl-icd-opencl-dev intel-opencl-icd xvfb libegl1-mesa libgl1-mesa-glx xserver-xorg-video-dummy libxkbcommon-x11-0 libxkbcommon0 libxkbcommon-dev libxcb-icccm4 libxcb-image0 libxcb-shm0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-render0 libxcb-shape0 libxcb-sync1 libxcb-xfixes0 libxcb-xinerama0 libxcb-xkb1 libxcb-cursor0 libxcb1 + sudo apt-get install ocl-icd-opencl-dev xvfb libegl1-mesa libgl1-mesa-glx xserver-xorg-video-dummy libxkbcommon-x11-0 libxkbcommon0 libxkbcommon-dev libxcb-icccm4 libxcb-image0 libxcb-shm0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-render0 libxcb-shape0 libxcb-sync1 libxcb-xfixes0 libxcb-xinerama0 libxcb-xkb1 libxcb-cursor0 libxcb1 + + - name: Setup Intel OpenCL ICD + if: runner.os == 'Linux' + run: | + wget -nv http://www.silx.org/pub/OpenCL/intel_opencl_icd-6.4.0.38.tar.gz -O - | tar -xzvf - + echo $(pwd)/intel_opencl_icd/icd/libintelocl.so > intel_opencl_icd/vendors/intel64.icd - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: "pip" + - name: Setup OpenGL + if: runner.os == 'Windows' + run: | + C:\\msys64\\usr\\bin\\wget.exe -nv -O $(python -c 'import sys, os.path; print(os.path.dirname(sys.executable))')\\opengl32.dll http://www.silx.org/pub/silx/continuous_integration/opengl32_mingw-mesa-x86_64.dll + - name: Install build dependencies run: | pip install --upgrade --pre build cython setuptools wheel @@ -87,6 +107,9 @@ jobs: pip install -r ci/requirements-pinned.txt pip install --pre "${{ matrix.QT_API }}" pip install --pre "$(ls dist/silx*.whl)[full,test]" + if [ ${{ runner.os }} == 'Linux' ]; then + export OCL_ICD_VENDORS=$(pwd)/intel_opencl_icd/vendors + fi python ./ci/info_platform.py pip list @@ -94,8 +117,9 @@ jobs: env: QT_API: ${{ matrix.QT_API }} SILX_TEST_LOW_MEM: "False" + SILX_OPENCL: ${{ matrix.with_opencl && 'True' || 'False' }} run: | - if [ ${{ runner.os }} == 'Windows' ]; then - export WITH_GL_TEST=False + if [ ${{ runner.os }} == 'Linux' ]; then + export OCL_ICD_VENDORS=$(pwd)/intel_opencl_icd/vendors fi python -c "import silx.test, sys; sys.exit(silx.test.run_tests(verbosity=1, args=['--qt-binding=${{ matrix.QT_API }}']));" diff --git a/README.rst b/README.rst index af3b2f2ede..5c93469a89 100644 --- a/README.rst +++ b/README.rst @@ -82,8 +82,7 @@ Testing *silx* features a comprehensive test-suite used in continuous integration for all major operating systems: -- Github Actions CI status: |Github Actions Status| -- Appveyor CI status: |Appveyor Status| +|Github Actions Status| Please refer to the `documentation on testing `_ for details. @@ -109,7 +108,5 @@ Citation .. |Github Actions Status| image:: https://github.com/silx-kit/silx/workflows/CI/badge.svg :target: https://github.com/silx-kit/silx/actions -.. |Appveyor Status| image:: https://ci.appveyor.com/api/projects/status/qgox9ei0wxwfagrb/branch/master?svg=true - :target: https://ci.appveyor.com/project/ESRF/silx?branch=master .. |zenodo DOI| image:: https://zenodo.org/badge/DOI/10.5281/zenodo.591709.svg :target: https://doi.org/10.5281/zenodo.591709 diff --git a/ci/appveyor.yml b/ci/appveyor.yml deleted file mode 100644 index 4b5118dbd2..0000000000 --- a/ci/appveyor.yml +++ /dev/null @@ -1,133 +0,0 @@ -version: 2.1-dev{build} - -# fetch repository as zip archive -shallow_clone: true - -notifications: -- provider: Email - to: - - silx-ci@edna-site.org - subject: '[CI] appveyor' - on_build_success: false - on_build_failure: false - on_build_status_changed: true - -image: Visual Studio 2019 - -cache: - - '%LOCALAPPDATA%\pip\Cache' - -environment: - global: - WIN_SDK_ROOT: "C:\\Program Files\\Microsoft SDKs\\Windows" - VENV_BUILD_DIR: "venv_build" - VENV_TEST_DIR: "..\\venv_test" - - matrix: - # Python 3.9 - - PYTHON_DIR: "C:\\Python39-x64" - QT_API: "PyQt5" - PIP_OPTIONS: "-q --pre" - - # Python 3.12 - - PYTHON_DIR: "C:\\Python312-x64" - QT_API: "PySide6" - PIP_OPTIONS: "-q --pre" - - # Python 3.11 - - PYTHON_DIR: "C:\\Python311-x64" - QT_API: "PyQt6" - PIP_OPTIONS: "-q" - - -branches: - only: - - main - - /\d*\.\d*/ - -install: - # Add Python to PATH - - "SET PATH=%PYTHON_DIR%;%PYTHON_DIR%\\Scripts;%PATH%" - - # Upgrade/install distribution modules - - "python -m pip install %PIP_OPTIONS% --upgrade pip" - - # Download Mesa OpenGL in Python directory when testing OpenGL - - curl -fsS -o %PYTHON_DIR%\\opengl32.dll https://www.silx.org/pub/silx/continuous_integration/opengl32_mingw-mesa-x86_64.dll - -build_script: - # Create build virtualenv - - "python -m venv --clear %VENV_BUILD_DIR%" - - "%VENV_BUILD_DIR%\\Scripts\\activate.bat" - - # Install build dependencies - - "pip install %PIP_OPTIONS% --upgrade build" - - "pip install %PIP_OPTIONS% --upgrade setuptools" - - "pip install %PIP_OPTIONS% --upgrade wheel" - - "pip install %PIP_OPTIONS% --upgrade cython" - - # Print Python info - - "python ci\\info_platform.py" - - "pip list --format=columns" - - # Build - - "python -m build --no-isolation --wheel" - - ps: "ls dist" - - # Leave build virtualenv - - "%VENV_BUILD_DIR%\\Scripts\\deactivate.bat" - - "rmdir %VENV_BUILD_DIR% /s /q" - -before_test: - # Create test virtualenv - - "python -m venv --clear %VENV_TEST_DIR%" - - "%VENV_TEST_DIR%\\Scripts\\activate.bat" - - "python -m pip install %PIP_OPTIONS% --upgrade pip" - - # First install any temporary pinned/additional requirements - - pip install %PIP_OPTIONS% -r ci\requirements-pinned.txt - - # Install dependencies - - pip install %PIP_OPTIONS% -r requirements.txt - - # Install selected Qt binding - - pip install %PIP_OPTIONS% "%QT_API%" - - # Install pytest - - "pip install %PIP_OPTIONS% pytest" - - "pip install %PIP_OPTIONS% pytest-xvfb" - - "pip install %PIP_OPTIONS% pytest-mock" - - # Install the generated wheel package to test it - # Make sure silx does not come from cache or pypi - # At this point all install_requires dependencies MUST be installed - # as this is installing only from dist/ - - "pip install --pre --find-links dist/ --no-cache-dir --no-index silx" - - # Print Python info - - "python ci\\info_platform.py" - - "pip list --format=columns" - - # Try to close popups - #- "pip install --upgrade pynput" - #- "python ./ci/close_popup.py" - -test_script: - # Run tests with selected Qt binding and without OpenCL - - python -c "import silx.test, sys; sys.exit(silx.test.run_tests(verbosity=1, args=('--no-opencl', '--low-mem', '--qt-binding=%QT_API%')));" - -after_test: - # Leave test virtualenv - - "%VENV_TEST_DIR%\\Scripts\\deactivate.bat" - - "rmdir %VENV_TEST_DIR% /s /q" - -on_failure: - # Push test-debug files as artefact - - ps: >- - if (Test-Path -LiteralPath "build\test-debug") { - Get-ChildItem .\build\test-debug\* | % { Push-AppveyorArtifact $_.FullName -FileName $_.Name } - } - -artifacts: - # Archive the generated wheel package in the ci.appveyor.com build report. - - path: dist\* diff --git a/ci/close_popup.py b/ci/close_popup.py deleted file mode 100755 index b51801a2d6..0000000000 --- a/ci/close_popup.py +++ /dev/null @@ -1,254 +0,0 @@ -# /*########################################################################## -# -# 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. -# -# ###########################################################################*/ -"""Try to close system popup before testing our application. - -The application can be tested with a set of samples: - ->>> scp -r www.silx.org:/data/distributions/ci_popups . ->>> python ./ci/close_popup.py ci_popups -""" - -__authors__ = ["V. Valls"] -__license__ = "MIT" -__date__ = "05/09/2017" - - -import os -import sys -import logging -import time -import pynput - -try: - from PyQt5 import Qt as qt -except ImportError: - qt = None - - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger("close_popup") - - -def getScreenShot(qapp): - """ - Get a screenshot of the full screen. - - :rtype: qt.QImage - """ - if not hasattr(qapp, "primaryScreen"): - # Qt4 - winId = qt.QApplication.desktop().winId() - pixmap = qt.QPixmap.grabWindow(winId) - else: - # Qt5 - screen = qapp.primaryScreen() - pixmap = screen.grabWindow(0) - image = pixmap.toImage() - return image - - -class CheckPopup(object): - """Generic class to detect a popup and the location of the button to - close it""" - - def check(self, image): - """Return true if the popup is there - - :param qt.QImage image: Image of the screen - """ - raise NotImplementedError() - - def clickPosition(self): - """Return the x,y coord defined to close the popup - - :rtype: tuple(int, int) - """ - raise NotImplementedError() - - def isMostlySameColor(self, color1, color2): - """ - Returns true if color1 and color2 are mostly the same. - - The delta is based on the sum of the difference between each RBG - components. - - :rtype: bool - """ - delta = 0 - delta += abs(color1.red() - color2.red()) - delta += abs(color1.green() - color2.green()) - delta += abs(color1.blue() - color2.blue()) - return delta < 10 - - def checkColors(self, image, pixelsDescription): - """ - Returns true if the pixel description match with the image. - - :param qt.QImage image: Image to check - :param pixelsDescription: List of pixel expectation containing a - position, a text description, and an expected color. - :rtype: bool - """ - for description in pixelsDescription: - pos, _description, expectedColor = description - rgb = image.pixel(pos[0], pos[1]) - color = qt.QColor(rgb) - if not self.isMostlySameColor(color, expectedColor): - return False - return True - - -class CheckWindowsPopup_NetworkDeviceDiscovery(CheckPopup): - platform = "win32" - name = "network device discovery" - - def check(self, image): - screenSize = image.width(), image.height() - if screenSize != (1024, 768): - return False - - expectedPixelColors = [ - ((926, 88), "popup", qt.QColor("#061f5e")), - ((798, 372), "button", qt.QColor("#0077c6")), - ((726, 165), "text", qt.QColor("#ffffff")), - ] - return self.checkColors(image, expectedPixelColors) - - def clickPosition(self): - return (798, 372) - - -class CheckMacOsXPopup_NameAsBeenChanged(CheckPopup): - platform = "darwin" - name = "computer renamed" - - def check(self, image): - screenSize = image.width(), image.height() - if screenSize != (1024, 768): - return False - - delta_locations = [-5, 0, 5, 10, 15, 20] - - for delta in delta_locations: - expectedPixelColors = [ - ((430, 150 + delta), "header", qt.QColor("#f6f6f6")), - ((388, 190 + delta), "popup", qt.QColor("#ececec")), - ((637, 324 + delta), "yes button", qt.QColor("#ffffff")), - ((364, 213 + delta), "logo", qt.QColor("#ecc520")), - ] - detected = self.checkColors(image, expectedPixelColors) - if detected: - self.delta = delta - return True - return False - - def clickPosition(self): - return (660, 324 + self.delta) - - -def checkPopups(popupList, filename): - """Check if an image contains a popup from the provided list - - :param str filename: Name of the file to check or a directory. - """ - if os.path.isdir(filename): - base_dir = filename - filenames = os.listdir(base_dir) - filenames = [os.path.join(base_dir, filename) for filename in filenames] - else: - filenames = [filename] - - for filename in filenames: - print(filename) - pixmap = qt.QPixmap(filename) - if pixmap.isNull(): - logger.debug("File %s skipped.", filename) - continue - image = pixmap.toImage() - detected = False - for popup in popupList: - if popup.check(image): - print("- Popup '%s' is visible." % popup.name) - detected = True - if not detected: - print("- No popups detected.") - - -def closePopup(qapp, popupList): - """Check the list of popups and close them - - :param qt.QApplication qapp: Qt application - :param list popupList: List of popup definitions - """ - popup_found_count = 0 - for _ in range(10): - image = getScreenShot(qapp) - - popup_found = False - for popup in popupList: - if sys.platform != popup.platform: - logger.debug("Popup %s skipped, wrong platform.", popup.name) - continue - - if popup.check(image): - logger.info("Popup '%s' found. Try to close it.", popup.name) - mouse = pynput.mouse.Controller() - mouse.position = popup.clickPosition() - mouse.click(pynput.mouse.Button.left) - time.sleep(5) - popup_found = True - popup_found_count += 1 - - if not popup_found: - break - - if popup_found_count == 0: - logger.info("No popup found.") - else: - logger.info("No more popup found.") - - -def main(): - if qt is None: - logger.info("Qt is not available.") - return - - popupList = [ - CheckWindowsPopup_NetworkDeviceDiscovery(), - CheckMacOsXPopup_NameAsBeenChanged(), - ] - logger.info("Popup database: %d.", len(popupList)) - - qapp = qt.QApplication([]) - - if len(sys.argv) == 2: - logger.info("Check input path.") - checkPopups(popupList, sys.argv[1]) - else: - logger.info("Check and close popups.") - closePopup(qapp, popupList) - - -if __name__ == "__main__": - main() diff --git a/doc/source/overview.rst b/doc/source/overview.rst index 729bdec851..d722666774 100644 --- a/doc/source/overview.rst +++ b/doc/source/overview.rst @@ -37,10 +37,7 @@ Project - To register from outside ESRF, send an email to `silx-subscribe@esrf.fr `_. - Continuous integration: *silx* is continuously tested on all three major - operating systems: - - - Linux, MacOS, Windows: `GitHub Actions `_ - - Windows: `AppVeyor `_ + operating systems (Linux, MacOS, Windows): `GitHub Actions `_ Additional Material ------------------- diff --git a/src/silx/gui/colors.py b/src/silx/gui/colors.py index 471725276b..9704cddc04 100755 --- a/src/silx/gui/colors.py +++ b/src/silx/gui/colors.py @@ -331,7 +331,10 @@ class Colormap(qt.QObject): """constant for autoscale using mean +/- 3*std(data) with a clamp on min/max of the data""" - AUTOSCALE_MODES = (MINMAX, STDDEV3) + PERCENTILE_1_99 = "percentile_1_99" + """constant for autoscale using 1st and 99th percentile of data""" + + AUTOSCALE_MODES = (MINMAX, STDDEV3, PERCENTILE_1_99) """Tuple of managed auto scale algorithms""" sigChanged = qt.Signal() diff --git a/src/silx/gui/dialog/ColormapDialog.py b/src/silx/gui/dialog/ColormapDialog.py index 7dadfd05b4..5c26b948f2 100644 --- a/src/silx/gui/dialog/ColormapDialog.py +++ b/src/silx/gui/dialog/ColormapDialog.py @@ -234,6 +234,7 @@ class _AutoscaleModeComboBox(qt.QComboBox): DATA = { Colormap.MINMAX: ("Min/max", "Use the data min/max"), Colormap.STDDEV3: ("Mean±3std", "Use the data mean ± 3 × standard deviation"), + Colormap.PERCENTILE_1_99: ("Percentile 1-99", "Use 1st to 99th percentile of data"), } def __init__(self, parent: qt.QWidget): diff --git a/src/silx/gui/dialog/test/test_datafiledialog.py b/src/silx/gui/dialog/test/test_datafiledialog.py index 7f6b17c09a..188c77d55e 100644 --- a/src/silx/gui/dialog/test/test_datafiledialog.py +++ b/src/silx/gui/dialog/test/test_datafiledialog.py @@ -92,7 +92,7 @@ def tearDownModule(): for _ in range(10): try: shutil.rmtree(_tmpDirectory) - except PermissionError: # Might fail on appveyor + except PermissionError: # Might fail on Windows testutils.TestCaseQt.qWait(500) else: break diff --git a/src/silx/gui/dialog/test/test_imagefiledialog.py b/src/silx/gui/dialog/test/test_imagefiledialog.py index 44a3929b8e..d9f67a5fff 100644 --- a/src/silx/gui/dialog/test/test_imagefiledialog.py +++ b/src/silx/gui/dialog/test/test_imagefiledialog.py @@ -99,7 +99,7 @@ def tearDownModule(): for _ in range(10): try: shutil.rmtree(_tmpDirectory) - except PermissionError: # Might fail on appveyor + except PermissionError: # Might fail on Windows testutils.TestCaseQt.qWait(500) else: break diff --git a/src/silx/gui/plot/ImageStack.py b/src/silx/gui/plot/ImageStack.py index 175d6e438d..6ca82955be 100644 --- a/src/silx/gui/plot/ImageStack.py +++ b/src/silx/gui/plot/ImageStack.py @@ -242,7 +242,7 @@ def reset(self) -> None: """Clear the plot and remove any link to url""" self._freeLoadingThreads() self._urls = None - self._urlIndexes = None + self._urlIndexes = {} self._urlData = {} self._current_url = None self._plot.clear() diff --git a/src/silx/gui/plot/tools/profile/manager.py b/src/silx/gui/plot/tools/profile/manager.py index 6f4ba358ef..8ed2eae981 100644 --- a/src/silx/gui/plot/tools/profile/manager.py +++ b/src/silx/gui/plot/tools/profile/manager.py @@ -1055,9 +1055,11 @@ def initProfileWindow(self, profileWindow, roi): # Trick to avoid blinking while retrieving the right window size # Display the window, hide it and wait for some event loops + eventLoop = qt.QEventLoop(self) profileWindow.show() + # Handle PyQt 5.15. Fix issue #4135 + eventLoop.processEvents() profileWindow.hide() - eventLoop = qt.QEventLoop(self) for _ in range(10): if not eventLoop.processEvents(): break diff --git a/src/silx/gui/test/test_colors.py b/src/silx/gui/test/test_colors.py index 8c252a70ea..ce86095208 100755 --- a/src/silx/gui/test/test_colors.py +++ b/src/silx/gui/test/test_colors.py @@ -430,6 +430,8 @@ def testAutoscaleMode(self): self.assertEqual(colormap.getAutoscaleMode(), Colormap.STDDEV3) colormap.setAutoscaleMode(Colormap.MINMAX) self.assertEqual(colormap.getAutoscaleMode(), Colormap.MINMAX) + colormap.setAutoscaleMode(Colormap.PERCENTILE_1_99) + self.assertEqual(colormap.getAutoscaleMode(), Colormap.PERCENTILE_1_99) def testStoreRestore(self): colormaps = [Colormap(name="viridis"), Colormap(normalization=Colormap.SQRT)] @@ -592,6 +594,8 @@ def testAutoscaleRange(self): ), (Colormap.LINEAR, Colormap.STDDEV3, numpy.array([10, 100]), (10, 100)), (Colormap.LOGARITHM, Colormap.STDDEV3, numpy.array([10, 100]), (10, 100)), + (Colormap.LINEAR, Colormap.PERCENTILE_1_99, numpy.array([10, 100]), (10.9, 99.1)), + (Colormap.LOGARITHM, Colormap.PERCENTILE_1_99, numpy.array([10, 100]), (10.9, 99.1)), # With nan ( Colormap.LINEAR, @@ -617,6 +621,18 @@ def testAutoscaleRange(self): data_std_inside_nan, (1, 1.6733506885453602), ), + ( + Colormap.LINEAR, + Colormap.PERCENTILE_1_99, + numpy.array([10, 20, 50, nan]), + (10.2, 49.4), + ), + ( + Colormap.LOGARITHM, + Colormap.PERCENTILE_1_99, + numpy.array([10, 50, 100, nan]), + (10.8, 99.), + ), # With negative ( Colormap.LOGARITHM, @@ -630,6 +646,19 @@ def testAutoscaleRange(self): numpy.array([10, 100, -10]), (10, 100), ), + ( + Colormap.LOGARITHM, + Colormap.PERCENTILE_1_99, + numpy.array([10, 50, 100, -50]), + (10.8, 99.), + ), + # With inf + ( + Colormap.LOGARITHM, + Colormap.PERCENTILE_1_99, + numpy.array([10, 50, 100, float("inf")]), + (10.8, 99.), + ), ] for norm, mode, array, expectedRange in data: with self.subTest(norm=norm, mode=mode, array=array): diff --git a/src/silx/gui/widgets/FrameBrowser.py b/src/silx/gui/widgets/FrameBrowser.py index c03b2a8121..8275a3657c 100644 --- a/src/silx/gui/widgets/FrameBrowser.py +++ b/src/silx/gui/widgets/FrameBrowser.py @@ -21,6 +21,7 @@ # THE SOFTWARE. # # ###########################################################################*/ +from __future__ import annotations """This module defines two main classes: - :class:`FrameBrowser`: a widget with 4 buttons (first, previous, next, @@ -218,6 +219,70 @@ def setValue(self, value): self._textChangedSlot() +class _SliderPlayWidgetAction(qt.QWidgetAction): + + sigValueChanged = qt.Signal(int) + + def __init__( + self, + parent: qt.QWidget | None = None, + label: str | None = None, + tooltip: str | None = None, + ): + super().__init__(parent) + self._build(label=label, tooltip=tooltip) + + def _build(self, label: str, tooltip: str): + widget = qt.QWidget() + layout = qt.QHBoxLayout() + widget.setLayout(layout) + self._spinbox = qt.QSpinBox() + self._spinbox.setToolTip(tooltip) + self._spinbox.setRange(1,1000000) + self._spinbox.valueChanged.connect(self.sigValueChanged) + label = qt.QLabel(label) + label.setToolTip(tooltip) + layout.addWidget(label) + layout.addWidget(self._spinbox) + self.setDefaultWidget(widget) + + def value(self) -> int: + return self._spinbox.value() + + def setValue(self, value: int): + self._spinbox.setValue(value) + + +class _PlayButtonContextMenu(qt.QMenu): + + sigFrameRateChanged = qt.Signal(int) + + def __init__(self, parent: qt.QWidget | None = None): + super().__init__(parent) + self._build() + + def _build(self): + self._framerateAction = _SliderPlayWidgetAction(self, label="FPS:", tooltip="Display speed in frames per second") + self._framerateAction.sigValueChanged.connect(self.sigFrameRateChanged) + self._stepAction = _SliderPlayWidgetAction(self, label="Step:", tooltip="Step between displayed frames") + self.addAction(self._framerateAction) + self._framerateAction.setValue(10) + self.addAction(self._stepAction) + self._stepAction.setValue(1) + + def getFrameRate(self) -> int: + return self._framerateAction.value() + + def setFrameRate(self, rate: int): + self._framerateAction.setValue(rate) + + def getStep(self) -> int: + return self._stepAction.value() + + def setStep(self, interval: int): + self._stepAction.setValue(interval) + + class HorizontalSliderWithBrowser(qt.QAbstractSlider): """ Slider widget combining a :class:`QSlider` and a :class:`FrameBrowser`. @@ -254,6 +319,27 @@ def __init__(self, parent=None): self._slider.valueChanged[int].connect(self._sliderSlot) self._browser.sigIndexChanged.connect(self._browserSlot) + fontMetric = self.fontMetrics() + iconSize = qt.QSize(fontMetric.height(), fontMetric.height()) + + self.__timer = qt.QTimer(self) + self.__timer.timeout.connect(self._updateState) + + self._playButton = qt.QToolButton(self) + self._playButton.setToolTip("Display dataset movie.") + self._playButton.setIcon(icons.getQIcon("camera")) + self._playButton.setIconSize(iconSize) + self._playButton.setCheckable(True) + self.mainLayout.addWidget(self._playButton) + + self._playButton.toggled.connect(self._playButtonToggled) + self._menuPlaySlider = _PlayButtonContextMenu(self) + self._menuPlaySlider.sigFrameRateChanged.connect(self._frameRateChanged) + self._frameRateChanged(self.getFrameRate()) + self._playButton.setMenu(self._menuPlaySlider) + self._playButton.setPopupMode(qt.QToolButton.MenuButtonPopup) + + def lineEdit(self): """Returns the line edit provided by this widget. @@ -312,3 +398,38 @@ def setValue(self, value): def value(self): """Get selected value""" return self._slider.value() + + def setFrameRate(self, value: int): + """Set the frame rate at which images are displayed""" + self._menuPlaySlider.setFrameRate(value) + + def getFrameRate(self) -> int: + """Get the frame rate at which images are displayed""" + return self._menuPlaySlider.getFrameRate() + + def setPlayImageStep(self, value: int): + """Set the step between displayed images when playing""" + self._menuPlaySlider.setStep(value) + + def getPlayImageStep(self) -> int: + """Returns the step between displayed images""" + return self._menuPlaySlider.getStep() + + def _frameRateChanged(self, framerate: int): + """Update the timer interval""" + self.__timer.setInterval(int(1 / framerate * 1e3)) + + def _playButtonToggled(self, checked: bool): + """Start/Stop the slider sequence.""" + if checked: + self.__timer.start() + return + self.__timer.stop() + + def _updateState(self): + """Advance an interval number of frames in the browser sequence.""" + currentIndex = self._browser.getValue() + if currentIndex < self._browser.getRange()[-1]: + self.setValue(currentIndex + self.getPlayImageStep()) + else: + self._playButton.setChecked(False) diff --git a/src/silx/io/fioh5.py b/src/silx/io/fioh5.py index a88d35b836..75751978c8 100644 --- a/src/silx/io/fioh5.py +++ b/src/silx/io/fioh5.py @@ -166,6 +166,21 @@ "BOOLEAN": "?", } +def _bytestobool (val): + """Convert bytes of a truth value to bool. + + Raises ValueError if 'val' is not supported. + """ + if isinstance(val, bytes): + val = val.decode() + val.lower() + if val in ('y', 'yes', 't', 'true', 'on', '1'): + return True + elif val in ('n', 'no', 'f', 'false', 'off', '0'): + return False + else: + raise ValueError("Invalid truth value %r" % val) + def is_fiofile(filename): """Test if a file is a FIO file, by checking if three consecutive lines @@ -256,11 +271,22 @@ def __init__(self, filepath): "Invalid fio file: Found no data " "after %s lines" % ABORTLINENO ) + np_datatype = \ + numpy.dtype([(n, t) for (n,t) in zip(self.names, self.dtypes)]) + + converter = {} + for i, t in enumerate(self.dtypes): + if t == dtypeConverter['BOOLEAN']: + converter[i] = _bytestobool - self.data = numpy.loadtxt( + self.data = numpy.genfromtxt( fiof, - dtype={"names": tuple(self.names), "formats": tuple(self.dtypes)}, + dtype=np_datatype, comments="!", + invalid_raise=True, + names=None, + deletechars='', + converters=converter ) # ToDo: read only last line of file, @@ -359,7 +385,7 @@ def __init__(self, filename, order=1): try: fiof = FioFile(filename) # reads complete file except Exception as e: - raise IOError("FIO file %s cannot be read.") from e + raise IOError("FIO file %s cannot be read." % filename) from e attrs = { "NX_class": to_h5py_utf8("NXroot"), diff --git a/src/silx/io/h5py_utils.py b/src/silx/io/h5py_utils.py index 84a29f965c..b6f937a16b 100644 --- a/src/silx/io/h5py_utils.py +++ b/src/silx/io/h5py_utils.py @@ -1,5 +1,5 @@ # /*########################################################################## -# Copyright (C) 2016-2023 European Synchrotron Radiation Facility +# Copyright (C) 2016-2024 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 @@ -20,6 +20,7 @@ # THE SOFTWARE. # # ############################################################################*/ +from __future__ import annotations """ This module provides utility methods on top of h5py, mainly to handle parallel writing and reading. @@ -34,6 +35,7 @@ import sys import traceback import logging +from typing import Sequence import h5py from .._version import calc_hexversion @@ -79,7 +81,13 @@ def _libver_low_bound_is_v108(libver) -> bool: return low == "v108" -def _hdf5_file_locking(mode="r", locking=None, swmr=None, libver=None, **_): +def _hdf5_file_locking( + mode: str | None = "r", + locking: bool | str | None = None, + swmr: bool | None = None, + libver: str | Sequence[str] | None = None, + **_ + ) -> str | bool | None: """Concurrent access by disabling file locking is not supported in these cases: @@ -88,17 +96,16 @@ def _hdf5_file_locking(mode="r", locking=None, swmr=None, libver=None, **_): * libver > v108 and file already locked: does not work * windows and HDF5_HAS_LOCKING_ARGUMENT and file already locked: does not work - :param str or None mode: read-only by default - :param bool or None locking: by default it is disabled for `mode='r'` - and `swmr=False` and enabled for all - other modes. - :param bool or None swmr: try both modes when `mode='r'` and `swmr=None` - :param None or str or tuple libver: - :returns bool: + :param mode: read-only by default + :param locking: by default it is disabled for `mode='r'` + and `swmr=False` and enabled when supported + for all other modes. + :param swmr: try both modes when `mode='r'` and `swmr=None` + :param libver: """ - if locking is None: - locking = bool(mode != "r" or swmr) - if not locking: + if locking is None and mode == "r" and not swmr: + locking = False + if locking in (False, "false"): if mode != "r": raise ValueError("Locking is mandatory for HDF5 writing") if swmr: @@ -341,25 +348,25 @@ class File(h5py.File): def __init__( self, - filename, - mode=None, - locking=None, - enable_file_locking=None, - swmr=None, - libver=None, + filename: str, + mode: str | None = None, + locking: bool | str | None = None, + enable_file_locking: bool | None = None, + swmr: bool | None = None, + libver: str | Sequence[str] | None = None, **kwargs, ): r"""The arguments `locking` and `swmr` should not be specified explicitly for normal use cases. - :param str filename: - :param str or None mode: read-only by default - :param bool or None locking: by default it is disabled for `mode='r'` - and `swmr=False` and enabled for all - other modes. - :param bool or None enable_file_locking: deprecated - :param bool or None swmr: try both modes when `mode='r'` and `swmr=None` - :param None or str or tuple libver: + :param filename: + :param mode: read-only by default + :param locking: by default it is disabled for `mode='r'` + and `swmr=False` and enabled when supported + for all other modes. + :param enable_file_locking: deprecated + :param swmr: try both modes when `mode='r'` and `swmr=None` + :param libver: :param \**kwargs: see `h5py.File.__init__` """ # File locking behavior has changed in recent versions of libhdf5 diff --git a/src/silx/io/specfile.pyx b/src/silx/io/specfile.pyx index cbbe7fa61c..67e770655b 100644 --- a/src/silx/io/specfile.pyx +++ b/src/silx/io/specfile.pyx @@ -279,7 +279,7 @@ class MCA(object): if not len(self): raise IndexError("No MCA spectrum found in this scan") - if isinstance(key, (int, long)): + if isinstance(key, int): mca_index = key # allow negative index, like lists if mca_index < 0: diff --git a/src/silx/io/test/test_utils.py b/src/silx/io/test/test_utils.py index a9c7f6af0b..55a0d9cb43 100644 --- a/src/silx/io/test/test_utils.py +++ b/src/silx/io/test/test_utils.py @@ -1,5 +1,5 @@ # /*########################################################################## -# Copyright (C) 2016-2022 European Synchrotron Radiation Facility +# Copyright (C) 2016-2024 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 @@ -328,37 +328,32 @@ def testHdf5(self): os.unlink(self.h5_fname) - # Following test case disabled d/t errors on AppVeyor: - # os.unlink(spec_fname) - # PermissionError: [WinError 32] The process cannot access the file because - # it is being used by another process: 'C:\\...\\savespec.dat' - - # def testSpec(self): - # tempdir = tempfile.mkdtemp() - # spec_fname = os.path.join(tempdir, "savespec.dat") - # - # x = [1, 2, 3] - # xlab = "Abscissa" - # y = [[4, 5, 6], [7, 8, 9]] - # ylabs = ["Ordinate1", "Ordinate2"] - # utils.save1D(spec_fname, x, y, xlabel=xlab, - # ylabels=ylabs, filetype="spec", - # fmt=["%d", "%.2f"]) - # - # rep = h5ls(spec_fname) - # lines = rep.split("\n") - # self.assertIn("+1.1", lines) - # self.assertIn("\t+instrument", lines) - # - # self.assertMatchAnyStringInList( - # r'\t\t\t', - # lines) - # self.assertMatchAnyStringInList( - # r'\t\t', - # lines) - # - # os.unlink(spec_fname) - # shutil.rmtree(tempdir) + def testSpec(self): + tempdir = tempfile.mkdtemp() + spec_fname = os.path.join(tempdir, "savespec.dat") + + x = [1, 2, 3] + xlab = "Abscissa" + y = [[4, 5, 6], [7, 8, 9]] + ylabs = ["Ordinate1", "Ordinate2"] + utils.save1D(spec_fname, x, y, xlabel=xlab, + ylabels=ylabs, filetype="spec", + fmt=["%d", "%.2f"]) + + rep = h5ls(spec_fname) + lines = rep.split("\n") + self.assertIn("+1.1", lines) + self.assertIn("\t+instrument", lines) + + self.assertMatchAnyStringInList( + r'\t\t\t', + lines) + self.assertMatchAnyStringInList( + r'\t\t', + lines) + + os.unlink(spec_fname) + shutil.rmtree(tempdir) class TestOpen(unittest.TestCase): diff --git a/src/silx/math/colormap.py b/src/silx/math/colormap.py index 8dd12f3281..ede4fb92a1 100644 --- a/src/silx/math/colormap.py +++ b/src/silx/math/colormap.py @@ -265,6 +265,8 @@ def autoscale(self, data, mode): vmax = dmax else: vmax = min(dmax, stdmax) + elif mode == "percentile_1_99": + vmin, vmax = self.autoscale_percentile_1_99(data) else: raise ValueError("Unsupported mode: %s" % mode) @@ -319,6 +321,15 @@ def autoscale_mean3std(self, data): mean + 3 * std, 0.0, 1.0 ) + def autoscale_percentile_1_99(self, data): + """Autoscale using [1st, 99th] percentiles""" + data = data[self.is_valid(data)] + if data.dtype.kind == "f": # Strip +/-inf + data = data[numpy.isfinite(data)] + if data.size == 0: + return None, None + return numpy.nanpercentile(data, (1, 99)) + class _LinearNormalizationMixIn(_NormalizationMixIn): """Colormap normalization mix-in class specific to autoscale taken from initial range""" diff --git a/src/silx/opencl/common.py b/src/silx/opencl/common.py index 69dfc99de4..da9db2dcd7 100644 --- a/src/silx/opencl/common.py +++ b/src/silx/opencl/common.py @@ -38,6 +38,7 @@ import os import logging +from packaging.version import Version import numpy from .utils import get_opencl_code @@ -757,18 +758,25 @@ def allocate_texture(ctx, shape, hostbuf=None, support_1D=False): do not support 1D images, so 1D images are handled as 2D with one row :param support_1D: force the image to be 1D if the shape has only one dim """ + # TODO "shape" as optional parameter (kwarg) ? if len(shape) == 1 and not (support_1D): shape = (1,) + shape - return pyopencl.Image( + if hostbuf is None: + hostbuf = numpy.zeros(shape[::-1], dtype=numpy.float32) + + if Version(pyopencl.version.VERSION_TEXT) >= Version("2024.3"): + texture_creation_function = pyopencl.create_image + else: + texture_creation_function = pyopencl.Image + return texture_creation_function( ctx, pyopencl.mem_flags.READ_ONLY | pyopencl.mem_flags.USE_HOST_PTR, pyopencl.ImageFormat( pyopencl.channel_order.INTENSITY, pyopencl.channel_type.FLOAT ), - hostbuf=numpy.zeros(shape[::-1], dtype=numpy.float32), + hostbuf=hostbuf, ) - def check_textures_availability(ctx): """ Check whether textures are supported on the current OpenCL context. diff --git a/src/silx/opencl/projection.py b/src/silx/opencl/projection.py index cf4b625cfa..ff5c776965 100644 --- a/src/silx/opencl/projection.py +++ b/src/silx/opencl/projection.py @@ -222,14 +222,8 @@ def allocate_slice(self): self.add_to_cl_mem({"d_slice": ary}) def allocate_textures(self): - self.d_image_tex = pyopencl.Image( - self.ctx, - mf.READ_ONLY | mf.USE_HOST_PTR, - pyopencl.ImageFormat( - pyopencl.channel_order.INTENSITY, pyopencl.channel_type.FLOAT - ), - hostbuf=np.ascontiguousarray(self._tmp_extended_img.T), - ) + hostbuf = np.ascontiguousarray(self._tmp_extended_img.T) + self.d_image_tex = self.allocate_texture(hostbuf.shape, hostbuf) def transfer_to_texture(self, image): image2 = image diff --git a/src/silx/test/__init__.py b/src/silx/test/__init__.py index 98faae32b2..3df251d379 100644 --- a/src/silx/test/__init__.py +++ b/src/silx/test/__init__.py @@ -68,6 +68,7 @@ def run_tests( "-Wignore:tostring() is deprecated. Use tobytes() instead.:DeprecationWarning:OpenGL.GL.VERSION.GL_2_0", "-Wignore:Jupyter is migrating its paths to use standard platformdirs:DeprecationWarning", "-Wignore:Unable to import recommended hash 'siphash24.siphash13', falling back to 'hashlib.sha256'. Run 'python3 -m pip install siphash24' to install the recommended hash.:UserWarning:pytools.persistent_dict", + "-Wignore:Non-empty compiler output encountered. Set the environment variable PYOPENCL_COMPILER_OUTPUT=1 to see more.:UserWarning", # Remove __array__ ignore once h5py v3.12 is released "-Wignore:__array__ implementation doesn't accept a copy keyword, so passing copy=False failed. __array__ must implement 'dtype' and 'copy' keyword arguments.:DeprecationWarning", ] + list(args)