summaryrefslogtreecommitdiff
path: root/silx/gui
diff options
context:
space:
mode:
Diffstat (limited to 'silx/gui')
-rw-r--r--silx/gui/_glutils/font.py2
-rw-r--r--silx/gui/colors.py9
-rw-r--r--silx/gui/data/Hdf5TableView.py78
-rw-r--r--silx/gui/data/NXdataWidgets.py3
-rw-r--r--silx/gui/data/RecordTableView.py24
-rw-r--r--silx/gui/data/TextFormatter.py10
-rw-r--r--silx/gui/data/test/test_arraywidget.py2
-rw-r--r--silx/gui/data/test/test_dataviewer.py16
-rw-r--r--silx/gui/data/test/test_numpyaxesselector.py4
-rw-r--r--silx/gui/data/test/test_textformatter.py52
-rw-r--r--silx/gui/dialog/AbstractDataFileDialog.py2
-rw-r--r--silx/gui/dialog/ColormapDialog.py5
-rw-r--r--silx/gui/dialog/DatasetDialog.py122
-rw-r--r--silx/gui/dialog/GroupDialog.py161
-rw-r--r--silx/gui/dialog/SafeFileIconProvider.py4
-rw-r--r--silx/gui/dialog/SafeFileSystemModel.py2
-rw-r--r--silx/gui/dialog/test/test_colormapdialog.py4
-rw-r--r--silx/gui/dialog/test/test_datafiledialog.py120
-rw-r--r--silx/gui/dialog/test/test_imagefiledialog.py86
-rw-r--r--silx/gui/fit/FitConfig.py9
-rw-r--r--silx/gui/fit/FitWidget.py10
-rw-r--r--silx/gui/fit/test/testBackgroundWidget.py2
-rw-r--r--silx/gui/fit/test/testFitConfig.py2
-rw-r--r--silx/gui/fit/test/testFitWidget.py2
-rw-r--r--silx/gui/hdf5/Hdf5Item.py41
-rw-r--r--silx/gui/hdf5/Hdf5LoadingItem.py11
-rw-r--r--silx/gui/hdf5/Hdf5Node.py9
-rw-r--r--silx/gui/hdf5/Hdf5TreeModel.py15
-rw-r--r--silx/gui/hdf5/NexusSortFilterProxyModel.py20
-rw-r--r--silx/gui/hdf5/_utils.py11
-rw-r--r--silx/gui/hdf5/test/test_hdf5.py122
-rw-r--r--silx/gui/icons.py38
-rw-r--r--silx/gui/plot/ColorBar.py12
-rw-r--r--silx/gui/plot/CompareImages.py1190
-rw-r--r--silx/gui/plot/ImageView.py9
-rw-r--r--silx/gui/plot/LegendSelector.py134
-rw-r--r--silx/gui/plot/MaskToolsWidget.py129
-rw-r--r--silx/gui/plot/PlotToolButtons.py56
-rw-r--r--silx/gui/plot/PlotWidget.py183
-rw-r--r--silx/gui/plot/PlotWindow.py5
-rw-r--r--silx/gui/plot/PrintPreviewToolButton.py5
-rw-r--r--silx/gui/plot/Profile.py89
-rw-r--r--silx/gui/plot/ProfileMainWindow.py16
-rw-r--r--silx/gui/plot/ScatterMaskToolsWidget.py61
-rw-r--r--silx/gui/plot/ScatterView.py10
-rw-r--r--silx/gui/plot/StackView.py100
-rw-r--r--silx/gui/plot/StatsWidget.py18
-rw-r--r--silx/gui/plot/_BaseMaskToolsWidget.py6
-rw-r--r--silx/gui/plot/_utils/test/testColormap.py648
-rw-r--r--silx/gui/plot/actions/PlotToolAction.py150
-rw-r--r--silx/gui/plot/actions/control.py1
-rw-r--r--silx/gui/plot/actions/fit.py63
-rw-r--r--silx/gui/plot/actions/histogram.py88
-rw-r--r--silx/gui/plot/actions/io.py12
-rw-r--r--silx/gui/plot/actions/medfilt.py38
-rw-r--r--silx/gui/plot/backends/BackendBase.py17
-rw-r--r--silx/gui/plot/backends/BackendMatplotlib.py28
-rw-r--r--silx/gui/plot/backends/BackendOpenGL.py17
-rw-r--r--silx/gui/plot/backends/glutils/GLText.py46
-rw-r--r--silx/gui/plot/items/core.py23
-rw-r--r--silx/gui/plot/items/curve.py213
-rw-r--r--silx/gui/plot/items/histogram.py7
-rw-r--r--silx/gui/plot/items/marker.py42
-rw-r--r--silx/gui/plot/items/scatter.py30
-rw-r--r--silx/gui/plot/matplotlib/ModestImage.py174
-rw-r--r--silx/gui/plot/test/__init__.py6
-rw-r--r--silx/gui/plot/test/testAlphaSlider.py2
-rw-r--r--silx/gui/plot/test/testColorBar.py2
-rw-r--r--silx/gui/plot/test/testCompareImages.py117
-rw-r--r--silx/gui/plot/test/testCurvesROIWidget.py2
-rw-r--r--silx/gui/plot/test/testImageView.py2
-rw-r--r--silx/gui/plot/test/testItem.py2
-rw-r--r--silx/gui/plot/test/testLegendSelector.py2
-rw-r--r--silx/gui/plot/test/testMaskToolsWidget.py8
-rw-r--r--silx/gui/plot/test/testPixelIntensityHistoAction.py2
-rw-r--r--silx/gui/plot/test/testPlotWidget.py203
-rw-r--r--silx/gui/plot/test/testPlotWindow.py2
-rw-r--r--silx/gui/plot/test/testProfile.py198
-rw-r--r--silx/gui/plot/test/testScatterMaskToolsWidget.py8
-rw-r--r--silx/gui/plot/test/testScatterView.py19
-rw-r--r--silx/gui/plot/test/testStackView.py7
-rw-r--r--silx/gui/plot/test/testStats.py3
-rw-r--r--silx/gui/plot/test/testUtilsAxis.py2
-rw-r--r--silx/gui/plot/test/utils.py2
-rw-r--r--silx/gui/plot/tools/CurveLegendsWidget.py247
-rw-r--r--silx/gui/plot/tools/profile/ImageProfileToolBar.py271
-rw-r--r--silx/gui/plot/tools/test/__init__.py2
-rw-r--r--silx/gui/plot/tools/test/testCurveLegendsWidget.py125
-rw-r--r--silx/gui/plot/tools/test/testROI.py2
-rw-r--r--silx/gui/plot/tools/test/testScatterProfileToolBar.py2
-rw-r--r--silx/gui/plot/tools/test/testTools.py2
-rw-r--r--silx/gui/plot/utils/axis.py9
-rw-r--r--silx/gui/plot3d/ParamTreeView.py11
-rw-r--r--silx/gui/plot3d/Plot3DWidget.py2
-rw-r--r--silx/gui/plot3d/SFViewParamTree.py64
-rw-r--r--silx/gui/plot3d/SceneWidget.py28
-rw-r--r--silx/gui/plot3d/_model/items.py2
-rw-r--r--silx/gui/plot3d/actions/io.py2
-rw-r--r--silx/gui/plot3d/items/_pick.py292
-rw-r--r--silx/gui/plot3d/items/clipplane.py90
-rw-r--r--silx/gui/plot3d/items/core.py201
-rw-r--r--silx/gui/plot3d/items/image.py68
-rw-r--r--silx/gui/plot3d/items/mesh.py176
-rw-r--r--silx/gui/plot3d/items/scatter.py182
-rw-r--r--silx/gui/plot3d/items/volume.py173
-rw-r--r--silx/gui/plot3d/scene/event.py4
-rw-r--r--silx/gui/plot3d/scene/function.py4
-rw-r--r--silx/gui/plot3d/scene/primitives.py2
-rw-r--r--silx/gui/plot3d/scene/transform.py42
-rw-r--r--silx/gui/plot3d/scene/utils.py180
-rw-r--r--silx/gui/plot3d/setup.py4
-rw-r--r--silx/gui/plot3d/test/__init__.py25
-rw-r--r--silx/gui/plot3d/test/testGL.py2
-rw-r--r--silx/gui/plot3d/test/testScalarFieldView.py29
-rw-r--r--silx/gui/plot3d/test/testSceneWidgetPicking.py267
-rw-r--r--silx/gui/plot3d/tools/PositionInfoWidget.py209
-rw-r--r--silx/gui/plot3d/tools/test/__init__.py (renamed from silx/gui/plot3d/scene/setup.py)24
-rw-r--r--silx/gui/plot3d/tools/test/testPositionInfoWidget.py101
-rw-r--r--silx/gui/qt/__init__.py7
-rw-r--r--silx/gui/qt/_pyside_dynamic.py55
-rw-r--r--silx/gui/qt/_qt.py43
-rw-r--r--silx/gui/qt/_utils.py5
-rw-r--r--silx/gui/qt/inspect.py82
-rw-r--r--silx/gui/test/test_colors.py18
-rw-r--r--silx/gui/test/test_console.py2
-rw-r--r--silx/gui/test/test_icons.py2
-rw-r--r--silx/gui/test/test_qt.py63
-rw-r--r--silx/gui/test/utils.py507
-rw-r--r--silx/gui/utils/_image.py104
-rw-r--r--silx/gui/utils/image.py143
-rw-r--r--silx/gui/utils/test/test_async.py2
-rw-r--r--silx/gui/utils/test/test_image.py50
-rw-r--r--silx/gui/utils/testutils.py520
-rw-r--r--silx/gui/widgets/FloatEdit.py4
-rw-r--r--silx/gui/widgets/FlowLayout.py177
-rw-r--r--silx/gui/widgets/PrintPreview.py4
-rw-r--r--silx/gui/widgets/RangeSlider.py627
-rw-r--r--silx/gui/widgets/__init__.py8
-rw-r--r--silx/gui/widgets/test/__init__.py4
-rw-r--r--silx/gui/widgets/test/test_boxlayoutdockwidget.py2
-rw-r--r--silx/gui/widgets/test/test_flowlayout.py77
-rw-r--r--silx/gui/widgets/test/test_framebrowser.py2
-rw-r--r--silx/gui/widgets/test/test_hierarchicaltableview.py2
-rw-r--r--silx/gui/widgets/test/test_periodictable.py2
-rw-r--r--silx/gui/widgets/test/test_printpreview.py2
-rw-r--r--silx/gui/widgets/test/test_rangeslider.py114
-rw-r--r--silx/gui/widgets/test/test_tablewidget.py2
-rw-r--r--silx/gui/widgets/test/test_threadpoolpushbutton.py4
148 files changed, 7989 insertions, 2696 deletions
diff --git a/silx/gui/_glutils/font.py b/silx/gui/_glutils/font.py
index b5bd6b5..8403c5a 100644
--- a/silx/gui/_glutils/font.py
+++ b/silx/gui/_glutils/font.py
@@ -32,7 +32,7 @@ __date__ = "13/10/2016"
import logging
import numpy
-from ..utils._image import convertQImageToArray
+from ..utils.image import convertQImageToArray
from .. import qt
_logger = logging.getLogger(__name__)
diff --git a/silx/gui/colors.py b/silx/gui/colors.py
index 028609b..a51bcdc 100644
--- a/silx/gui/colors.py
+++ b/silx/gui/colors.py
@@ -29,7 +29,7 @@ from __future__ import absolute_import
__authors__ = ["T. Vincent", "H.Payno"]
__license__ = "MIT"
-__date__ = "14/06/2018"
+__date__ = "05/10/2018"
from silx.gui import qt
import copy as copy_mdl
@@ -234,6 +234,8 @@ class Colormap(qt.QObject):
self._colors = None
else:
colors = numpy.array(colors, copy=False)
+ if colors.shape == ():
+ raise TypeError("An array is expected for 'colors' argument. '%s' was found." % type(colors))
colors.shape = -1, colors.shape[-1]
if colors.dtype.kind == 'f':
colors = self._convertColorsFromFloatToUint8(colors)
@@ -518,6 +520,11 @@ class Colormap(qt.QObject):
raise NotEditableError('Colormap is not editable')
name = dic['name'] if 'name' in dic else None
colors = dic['colors'] if 'colors' in dic else None
+ if name is not None and colors is not None:
+ if isinstance(colors, int):
+ # Filter out argument which was supported but never used
+ _logger.info("Unused 'colors' from colormap dictionary filterer.")
+ colors = None
vmin = dic['vmin'] if 'vmin' in dic else None
vmax = dic['vmax'] if 'vmax' in dic else None
if 'normalization' in dic:
diff --git a/silx/gui/data/Hdf5TableView.py b/silx/gui/data/Hdf5TableView.py
index 04199b2..9e28fbf 100644
--- a/silx/gui/data/Hdf5TableView.py
+++ b/silx/gui/data/Hdf5TableView.py
@@ -30,7 +30,7 @@ from __future__ import division
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "23/05/2018"
+__date__ = "05/07/2018"
import collections
import functools
@@ -101,6 +101,9 @@ class _CellData(object):
def invalidateToolTip(self):
self.__tooltip = None
+ def data(self, role):
+ return None
+
class _TableData(object):
"""Modelize a table with header, row and column span.
@@ -183,6 +186,46 @@ class _TableData(object):
self.__data.append(row)
+class _CellFilterAvailableData(_CellData):
+ """Cell rendering for availability of a filter"""
+
+ _states = {
+ True: ("Available", qt.QColor(0x000000), None, None),
+ False: ("Not available", qt.QColor(0xFFFFFF), qt.QColor(0xFF0000),
+ "You have to install this filter on your system to be able to read this dataset"),
+ "na": ("n.a.", qt.QColor(0x000000), None,
+ "This version of h5py/hdf5 is not able to display the information"),
+ }
+
+ def __init__(self, filterId):
+ import h5py.version
+ if h5py.version.hdf5_version_tuple >= (1, 10, 2):
+ # Previous versions only returns True if the filter was first used
+ # to decode a dataset
+ import h5py.h5z
+ self.__availability = h5py.h5z.filter_avail(filterId)
+ else:
+ self.__availability = "na"
+ _CellData.__init__(self)
+
+ def value(self):
+ state = self._states[self.__availability]
+ return state[0]
+
+ def tooltip(self):
+ state = self._states[self.__availability]
+ return state[3]
+
+ def data(self, role=qt.Qt.DisplayRole):
+ state = self._states[self.__availability]
+ if role == qt.Qt.TextColorRole:
+ return state[1]
+ elif role == qt.Qt.BackgroundColorRole:
+ return state[2]
+ else:
+ return None
+
+
class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
"""This data model provides access to HDF5 node content (File, Group,
Dataset). Main info, like name, file, attributes... are displayed
@@ -198,7 +241,7 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
super(Hdf5TableModel, self).__init__(parent)
self.__obj = None
- self.__data = _TableData(columnCount=4)
+ self.__data = _TableData(columnCount=5)
self.__formatter = None
self.__hdf5Formatter = Hdf5Formatter(self)
formatter = TextFormatter(self)
@@ -245,6 +288,8 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
cell.invalidateToolTip()
raise
return value
+ else:
+ return cell.data(role)
return None
def flags(self, index):
@@ -394,14 +439,16 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
hdf5id = _CellData(value="HDF5 ID", isHeader=True)
name = _CellData(value="Name", isHeader=True)
options = _CellData(value="Options", isHeader=True)
- self.__data.addRow(pos, hdf5id, name, options)
+ availability = _CellData(value="", isHeader=True)
+ self.__data.addRow(pos, hdf5id, name, options, availability)
for index in range(dcpl.get_nfilters()):
- callback = lambda index, dataIndex, x: self.__get_filter_info(x, index)[dataIndex]
- pos = _CellData(value=functools.partial(callback, index, 0))
- hdf5id = _CellData(value=functools.partial(callback, index, 1))
- name = _CellData(value=functools.partial(callback, index, 2))
- options = _CellData(value=functools.partial(callback, index, 3))
- self.__data.addRow(pos, hdf5id, name, options)
+ filterId, name, options = self.__getFilterInfo(obj, index)
+ pos = _CellData(value=index)
+ hdf5id = _CellData(value=filterId)
+ name = _CellData(value=name)
+ options = _CellData(value=options)
+ availability = _CellFilterAvailableData(filterId=filterId)
+ self.__data.addRow(pos, hdf5id, name, options, availability)
if hasattr(obj, "attrs"):
if len(obj.attrs) > 0:
@@ -413,7 +460,7 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
value=functools.partial(callback, key),
tooltip=functools.partial(callbackTooltip, key))
- def __get_filter_info(self, dataset, filterIndex):
+ def __getFilterInfo(self, dataset, filterIndex):
"""Get a tuple of readable info from dataset filters
:param h5py.Dataset dataset: A h5py dataset
@@ -425,10 +472,10 @@ class Hdf5TableModel(HierarchicalTableView.HierarchicalTableModel):
filterId, _flags, cdValues, name = info
name = self.__formatter.toString(name)
options = " ".join([self.__formatter.toString(i) for i in cdValues])
- return (filterIndex, filterId, name, options)
+ return (filterId, name, options)
except Exception:
_logger.debug("Backtrace", exc_info=True)
- return [filterIndex, None, None, None]
+ return (None, None, None)
def object(self):
"""Returns the internal object modelized.
@@ -503,5 +550,8 @@ class Hdf5TableView(HierarchicalTableView.HierarchicalTableView):
else:
setResizeMode = header.setSectionResizeMode
setResizeMode(0, qt.QHeaderView.Fixed)
- setResizeMode(1, qt.QHeaderView.Stretch)
- header.setStretchLastSection(True)
+ setResizeMode(1, qt.QHeaderView.ResizeToContents)
+ setResizeMode(2, qt.QHeaderView.Stretch)
+ setResizeMode(3, qt.QHeaderView.ResizeToContents)
+ setResizeMode(4, qt.QHeaderView.ResizeToContents)
+ header.setStretchLastSection(False)
diff --git a/silx/gui/data/NXdataWidgets.py b/silx/gui/data/NXdataWidgets.py
index 1bf5425..f7c479d 100644
--- a/silx/gui/data/NXdataWidgets.py
+++ b/silx/gui/data/NXdataWidgets.py
@@ -26,7 +26,7 @@
"""
__authors__ = ["P. Knobel"]
__license__ = "MIT"
-__date__ = "24/04/2018"
+__date__ = "10/10/2018"
import numpy
@@ -337,6 +337,7 @@ class ArrayImagePlot(qt.QWidget):
self._plot.setDefaultColormap(Colormap(name="viridis",
vmin=None, vmax=None,
normalization=Colormap.LINEAR))
+ self._plot.getIntensityHistogramAction().setVisible(True)
self.selectorDock = qt.QDockWidget("Data selector", self._plot)
# not closable
diff --git a/silx/gui/data/RecordTableView.py b/silx/gui/data/RecordTableView.py
index 54881b7..b1b7dcd 100644
--- a/silx/gui/data/RecordTableView.py
+++ b/silx/gui/data/RecordTableView.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 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
@@ -37,7 +37,7 @@ from silx.gui.widgets.TableWidget import CopySelectedCellsAction
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "02/10/2017"
+__date__ = "29/08/2018"
class _MultiLineItem(qt.QItemDelegate):
@@ -205,10 +205,13 @@ class RecordTableModel(qt.QAbstractTableModel):
if len(key) > 1:
data = data[key[1]]
+ # no dtype in case of 1D array of unicode objects (#2093)
+ dtype = getattr(data, "dtype", None)
+
if role == qt.Qt.DisplayRole:
- return self.__formatter.toString(data, dtype=self.__data.dtype)
+ return self.__formatter.toString(data, dtype=dtype)
elif role == qt.Qt.EditRole:
- return self.__editFormatter.toString(data, dtype=self.__data.dtype)
+ return self.__editFormatter.toString(data, dtype=dtype)
return None
def headerData(self, section, orientation, role=qt.Qt.DisplayRole):
@@ -382,8 +385,10 @@ class RecordTableView(qt.QTableView):
qt.QTableView.__init__(self, parent)
model = _ShowEditorProxyModel(self)
- model.setSourceModel(RecordTableModel())
+ self._model = RecordTableModel()
+ model.setSourceModel(self._model)
self.setModel(model)
+
self.__multilineView = _MultiLineItem(self)
self.setEditTriggers(qt.QAbstractItemView.AllEditTriggers)
self._copyAction = CopySelectedCellsAction(self)
@@ -393,13 +398,16 @@ class RecordTableView(qt.QTableView):
self._copyAction.trigger()
def setArrayData(self, data):
- self.model().sourceModel().setArrayData(data)
+ model = self.model()
+ sourceModel = model.sourceModel()
+ sourceModel.setArrayData(data)
+
if data is not None:
if issubclass(data.dtype.type, (numpy.string_, numpy.unicode_)):
# TODO it would be nice to also fix fields
# but using it only for string array is already very useful
self.setItemDelegateForColumn(0, self.__multilineView)
- self.model().forceCellEditor(True)
+ model.forceCellEditor(True)
else:
self.setItemDelegateForColumn(0, None)
- self.model().forceCellEditor(False)
+ model.forceCellEditor(False)
diff --git a/silx/gui/data/TextFormatter.py b/silx/gui/data/TextFormatter.py
index 8440509..1401634 100644
--- a/silx/gui/data/TextFormatter.py
+++ b/silx/gui/data/TextFormatter.py
@@ -27,7 +27,7 @@ data module to format data as text in the same way."""
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "25/06/2018"
+__date__ = "24/07/2018"
import numpy
import numbers
@@ -206,7 +206,13 @@ class TextFormatter(qt.QObject):
if six.PY2:
data = [ord(d) for d in data.data]
else:
- data = data.item().astype(numpy.uint8)
+ data = data.item()
+ if isinstance(data, numpy.ndarray):
+ # Before numpy 1.15.0 the item API was returning a numpy array
+ data = data.astype(numpy.uint8)
+ else:
+ # Now it is supposed to be a bytes type
+ pass
elif six.PY2:
data = [ord(d) for d in data]
# In python3 data is already a bytes array
diff --git a/silx/gui/data/test/test_arraywidget.py b/silx/gui/data/test/test_arraywidget.py
index bbd7ee5..50ffc84 100644
--- a/silx/gui/data/test/test_arraywidget.py
+++ b/silx/gui/data/test/test_arraywidget.py
@@ -34,7 +34,7 @@ import numpy
from silx.gui import qt
from silx.gui.data import ArrayTableWidget
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
try:
import h5py
diff --git a/silx/gui/data/test/test_dataviewer.py b/silx/gui/data/test/test_dataviewer.py
index f3c2808..a681f33 100644
--- a/silx/gui/data/test/test_dataviewer.py
+++ b/silx/gui/data/test/test_dataviewer.py
@@ -39,8 +39,8 @@ from .. import DataViews
from silx.gui import qt
from silx.gui.data.DataViewerFrame import DataViewerFrame
-from silx.gui.test.utils import SignalListener
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import SignalListener
+from silx.gui.utils.testutils import TestCaseQt
try:
import h5py
@@ -183,7 +183,7 @@ class AbstractDataViewerTests(TestCaseQt):
widget.dataChanged.connect(listener)
widget.setData(10)
widget.setData(None)
- self.assertEquals(listener.callCount(), 2)
+ self.assertEqual(listener.callCount(), 2)
def test_display_mode_event(self):
listener = SignalListener()
@@ -192,7 +192,7 @@ class AbstractDataViewerTests(TestCaseQt):
widget.setData(10)
widget.setData(None)
modes = [v.modeId() for v in listener.arguments(argumentIndex=0)]
- self.assertEquals(modes, [DataViews.RAW_MODE, DataViews.EMPTY_MODE])
+ self.assertEqual(modes, [DataViews.RAW_MODE, DataViews.EMPTY_MODE])
listener.clear()
def test_change_display_mode(self):
@@ -201,13 +201,13 @@ class AbstractDataViewerTests(TestCaseQt):
widget = self.create_widget()
widget.setData(data)
widget.setDisplayMode(DataViews.PLOT1D_MODE)
- self.assertEquals(widget.displayedView().modeId(), DataViews.PLOT1D_MODE)
+ self.assertEqual(widget.displayedView().modeId(), DataViews.PLOT1D_MODE)
widget.setDisplayMode(DataViews.IMAGE_MODE)
- self.assertEquals(widget.displayedView().modeId(), DataViews.IMAGE_MODE)
+ self.assertEqual(widget.displayedView().modeId(), DataViews.IMAGE_MODE)
widget.setDisplayMode(DataViews.RAW_MODE)
- self.assertEquals(widget.displayedView().modeId(), DataViews.RAW_MODE)
+ self.assertEqual(widget.displayedView().modeId(), DataViews.RAW_MODE)
widget.setDisplayMode(DataViews.EMPTY_MODE)
- self.assertEquals(widget.displayedView().modeId(), DataViews.EMPTY_MODE)
+ self.assertEqual(widget.displayedView().modeId(), DataViews.EMPTY_MODE)
def test_create_default_views(self):
widget = self.create_widget()
diff --git a/silx/gui/data/test/test_numpyaxesselector.py b/silx/gui/data/test/test_numpyaxesselector.py
index 6ce5119..6b7b58c 100644
--- a/silx/gui/data/test/test_numpyaxesselector.py
+++ b/silx/gui/data/test/test_numpyaxesselector.py
@@ -34,8 +34,8 @@ from contextlib import contextmanager
import numpy
from silx.gui.data.NumpyAxesSelector import NumpyAxesSelector
-from silx.gui.test.utils import SignalListener
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import SignalListener
+from silx.gui.utils.testutils import TestCaseQt
try:
import h5py
diff --git a/silx/gui/data/test/test_textformatter.py b/silx/gui/data/test/test_textformatter.py
index 06a29ba..850aa00 100644
--- a/silx/gui/data/test/test_textformatter.py
+++ b/silx/gui/data/test/test_textformatter.py
@@ -31,8 +31,8 @@ import shutil
import tempfile
import numpy
-from silx.gui.test.utils import TestCaseQt
-from silx.gui.test.utils import SignalListener
+from silx.gui.utils.testutils import TestCaseQt
+from silx.gui.utils.testutils import SignalListener
from ..TextFormatter import TextFormatter
from silx.third_party import six
@@ -49,10 +49,10 @@ class TestTextFormatter(TestCaseQt):
copy = TextFormatter(formatter=formatter)
self.assertIsNot(formatter, copy)
copy.setFloatFormat("%.3f")
- self.assertEquals(formatter.integerFormat(), copy.integerFormat())
+ self.assertEqual(formatter.integerFormat(), copy.integerFormat())
self.assertNotEquals(formatter.floatFormat(), copy.floatFormat())
- self.assertEquals(formatter.useQuoteForText(), copy.useQuoteForText())
- self.assertEquals(formatter.imaginaryUnit(), copy.imaginaryUnit())
+ self.assertEqual(formatter.useQuoteForText(), copy.useQuoteForText())
+ self.assertEqual(formatter.imaginaryUnit(), copy.imaginaryUnit())
def test_event(self):
listener = SignalListener()
@@ -62,19 +62,19 @@ class TestTextFormatter(TestCaseQt):
formatter.setIntegerFormat("%03i")
formatter.setUseQuoteForText(False)
formatter.setImaginaryUnit("z")
- self.assertEquals(listener.callCount(), 4)
+ self.assertEqual(listener.callCount(), 4)
def test_int(self):
formatter = TextFormatter()
formatter.setIntegerFormat("%05i")
result = formatter.toString(512)
- self.assertEquals(result, "00512")
+ self.assertEqual(result, "00512")
def test_float(self):
formatter = TextFormatter()
formatter.setFloatFormat("%.3f")
result = formatter.toString(1.3)
- self.assertEquals(result, "1.300")
+ self.assertEqual(result, "1.300")
def test_complex(self):
formatter = TextFormatter()
@@ -82,25 +82,25 @@ class TestTextFormatter(TestCaseQt):
formatter.setImaginaryUnit("i")
result = formatter.toString(1.0 + 5j)
result = result.replace(" ", "")
- self.assertEquals(result, "1.0+5.0i")
+ self.assertEqual(result, "1.0+5.0i")
def test_string(self):
formatter = TextFormatter()
formatter.setIntegerFormat("%.1f")
formatter.setImaginaryUnit("z")
result = formatter.toString("toto")
- self.assertEquals(result, '"toto"')
+ self.assertEqual(result, '"toto"')
def test_numpy_void(self):
formatter = TextFormatter()
result = formatter.toString(numpy.void(b"\xFF"))
- self.assertEquals(result, 'b"\\xFF"')
+ self.assertEqual(result, 'b"\\xFF"')
def test_char_cp1252(self):
# degree character in cp1252
formatter = TextFormatter()
result = formatter.toString(numpy.bytes_(b"\xB0"))
- self.assertEquals(result, u'"\u00B0"')
+ self.assertEqual(result, u'"\u00B0"')
class TestTextFormatterWithH5py(TestCaseQt):
@@ -130,74 +130,74 @@ class TestTextFormatterWithH5py(TestCaseQt):
def testAscii(self):
d = self.create_dataset(data=b"abc")
result = self.formatter.toString(d[()], dtype=d.dtype)
- self.assertEquals(result, '"abc"')
+ self.assertEqual(result, '"abc"')
def testUnicode(self):
d = self.create_dataset(data=u"i\u2661cookies")
result = self.formatter.toString(d[()], dtype=d.dtype)
- self.assertEquals(len(result), 11)
- self.assertEquals(result, u'"i\u2661cookies"')
+ self.assertEqual(len(result), 11)
+ self.assertEqual(result, u'"i\u2661cookies"')
def testBadAscii(self):
d = self.create_dataset(data=b"\xF0\x9F\x92\x94")
result = self.formatter.toString(d[()], dtype=d.dtype)
- self.assertEquals(result, 'b"\\xF0\\x9F\\x92\\x94"')
+ self.assertEqual(result, 'b"\\xF0\\x9F\\x92\\x94"')
def testVoid(self):
d = self.create_dataset(data=numpy.void(b"abc\xF0"))
result = self.formatter.toString(d[()], dtype=d.dtype)
- self.assertEquals(result, 'b"\\x61\\x62\\x63\\xF0"')
+ self.assertEqual(result, 'b"\\x61\\x62\\x63\\xF0"')
def testEnum(self):
dtype = h5py.special_dtype(enum=('i', {"RED": 0, "GREEN": 1, "BLUE": 42}))
d = numpy.array(42, dtype=dtype)
d = self.create_dataset(data=d)
result = self.formatter.toString(d[()], dtype=d.dtype)
- self.assertEquals(result, 'BLUE(42)')
+ self.assertEqual(result, 'BLUE(42)')
def testRef(self):
dtype = h5py.special_dtype(ref=h5py.Reference)
d = numpy.array(self.h5File.ref, dtype=dtype)
d = self.create_dataset(data=d)
result = self.formatter.toString(d[()], dtype=d.dtype)
- self.assertEquals(result, 'REF')
+ self.assertEqual(result, 'REF')
def testArrayAscii(self):
d = self.create_dataset(data=[b"abc"])
result = self.formatter.toString(d[()], dtype=d.dtype)
- self.assertEquals(result, '["abc"]')
+ self.assertEqual(result, '["abc"]')
def testArrayUnicode(self):
dtype = h5py.special_dtype(vlen=six.text_type)
d = numpy.array([u"i\u2661cookies"], dtype=dtype)
d = self.create_dataset(data=d)
result = self.formatter.toString(d[()], dtype=d.dtype)
- self.assertEquals(len(result), 13)
- self.assertEquals(result, u'["i\u2661cookies"]')
+ self.assertEqual(len(result), 13)
+ self.assertEqual(result, u'["i\u2661cookies"]')
def testArrayBadAscii(self):
d = self.create_dataset(data=[b"\xF0\x9F\x92\x94"])
result = self.formatter.toString(d[()], dtype=d.dtype)
- self.assertEquals(result, '[b"\\xF0\\x9F\\x92\\x94"]')
+ self.assertEqual(result, '[b"\\xF0\\x9F\\x92\\x94"]')
def testArrayVoid(self):
d = self.create_dataset(data=numpy.void([b"abc\xF0"]))
result = self.formatter.toString(d[()], dtype=d.dtype)
- self.assertEquals(result, '[b"\\x61\\x62\\x63\\xF0"]')
+ self.assertEqual(result, '[b"\\x61\\x62\\x63\\xF0"]')
def testArrayEnum(self):
dtype = h5py.special_dtype(enum=('i', {"RED": 0, "GREEN": 1, "BLUE": 42}))
d = numpy.array([42, 1, 100], dtype=dtype)
d = self.create_dataset(data=d)
result = self.formatter.toString(d[()], dtype=d.dtype)
- self.assertEquals(result, '[BLUE(42) GREEN(1) 100]')
+ self.assertEqual(result, '[BLUE(42) GREEN(1) 100]')
def testArrayRef(self):
dtype = h5py.special_dtype(ref=h5py.Reference)
d = numpy.array([self.h5File.ref, None], dtype=dtype)
d = self.create_dataset(data=d)
result = self.formatter.toString(d[()], dtype=d.dtype)
- self.assertEquals(result, '[REF NULL_REF]')
+ self.assertEqual(result, '[REF NULL_REF]')
def suite():
diff --git a/silx/gui/dialog/AbstractDataFileDialog.py b/silx/gui/dialog/AbstractDataFileDialog.py
index cb6711c..40045fe 100644
--- a/silx/gui/dialog/AbstractDataFileDialog.py
+++ b/silx/gui/dialog/AbstractDataFileDialog.py
@@ -1170,7 +1170,7 @@ class AbstractDataFileDialog(qt.QDialog):
def __filterSelected(self, index):
filters = self.__fileTypeCombo.itemExtensions(index)
- self.__fileModel.setNameFilters(filters)
+ self.__fileModel.setNameFilters(list(filters))
def __setData(self, data):
self.__data = data
diff --git a/silx/gui/dialog/ColormapDialog.py b/silx/gui/dialog/ColormapDialog.py
index ed10728..cbbfa5a 100644
--- a/silx/gui/dialog/ColormapDialog.py
+++ b/silx/gui/dialog/ColormapDialog.py
@@ -710,8 +710,9 @@ class ColormapDialog(qt.QDialog):
self._updateMinMaxData()
def getColormap(self):
- """Return the colormap description as a :class:`.Colormap`.
+ """Return the colormap description.
+ :rtype: ~silx.gui.colors.Colormap
"""
if self._colormap is None:
return None
@@ -811,7 +812,7 @@ class ColormapDialog(qt.QDialog):
def setColormap(self, colormap):
"""Set the colormap description
- :param :class:`Colormap` colormap: the colormap to edit
+ :param ~silx.gui.colors.Colormap colormap: the colormap to edit
"""
assert colormap is None or isinstance(colormap, Colormap)
if self._ignoreColormapChange is True:
diff --git a/silx/gui/dialog/DatasetDialog.py b/silx/gui/dialog/DatasetDialog.py
new file mode 100644
index 0000000..87fc89d
--- /dev/null
+++ b/silx/gui/dialog/DatasetDialog.py
@@ -0,0 +1,122 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018 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.
+#
+# ###########################################################################*/
+"""This module provides a dialog widget to select a HDF5 dataset in a
+tree.
+
+.. autoclass:: DatasetDialog
+ :members: addFile, addGroup, getSelectedDataUrl, setMode
+
+"""
+from .GroupDialog import _Hdf5ItemSelectionDialog
+import silx.io
+from silx.io.url import DataUrl
+
+
+__authors__ = ["P. Knobel"]
+__license__ = "MIT"
+__date__ = "05/09/2018"
+
+
+class DatasetDialog(_Hdf5ItemSelectionDialog):
+ """This :class:`QDialog` uses a :class:`silx.gui.hdf5.Hdf5TreeView` to
+ provide a HDF5 dataset selection dialog.
+
+ The information identifying the selected node is provided as a
+ :class:`silx.io.url.DataUrl`.
+
+ Example:
+
+ .. code-block:: python
+
+ dialog = DatasetDialog()
+ dialog.addFile(filepath1)
+ dialog.addFile(filepath2)
+
+ if dialog.exec_():
+ print("File path: %s" % dialog.getSelectedDataUrl().file_path())
+ print("HDF5 dataset path : %s " % dialog.getSelectedDataUrl().data_path())
+ else:
+ print("Operation cancelled :(")
+
+ """
+ def __init__(self, parent=None):
+ _Hdf5ItemSelectionDialog.__init__(self, parent)
+
+ # customization for groups
+ self.setWindowTitle("HDF5 dataset selection")
+
+ self._header.setSections([self._model.NAME_COLUMN,
+ self._model.NODE_COLUMN,
+ self._model.LINK_COLUMN,
+ self._model.TYPE_COLUMN,
+ self._model.SHAPE_COLUMN])
+ self._selectDatasetStatusText = "Select a dataset or type a new dataset name"
+
+ def setMode(self, mode):
+ """Set dialog mode DatasetDialog.SaveMode or DatasetDialog.LoadMode
+
+ :param mode: DatasetDialog.SaveMode or DatasetDialog.LoadMode
+ """
+ _Hdf5ItemSelectionDialog.setMode(self, mode)
+ if mode == DatasetDialog.SaveMode:
+ self._selectDatasetStatusText = "Select a dataset or type a new dataset name"
+ elif mode == DatasetDialog.LoadMode:
+ self._selectDatasetStatusText = "Select a dataset"
+
+ def _onActivation(self, idx):
+ # double-click or enter press: filter for datasets
+ nodes = list(self._tree.selectedH5Nodes())
+ node = nodes[0]
+ if silx.io.is_dataset(node.h5py_object):
+ self.accept()
+
+ def _updateUrl(self):
+ # overloaded to filter for datasets
+ nodes = list(self._tree.selectedH5Nodes())
+ newDatasetName = self._lineEditNewItem.text()
+ isDatasetSelected = False
+ if nodes:
+ node = nodes[0]
+ if silx.io.is_dataset(node.h5py_object):
+ data_path = node.local_name
+ isDatasetSelected = True
+ elif silx.io.is_group(node.h5py_object):
+ data_path = node.local_name
+ if newDatasetName.lstrip("/"):
+ if not data_path.endswith("/"):
+ data_path += "/"
+ data_path += newDatasetName.lstrip("/")
+ isDatasetSelected = True
+
+ if isDatasetSelected:
+ self._selectedUrl = DataUrl(file_path=node.local_filename,
+ data_path=data_path)
+ self._okButton.setEnabled(True)
+ self._labelSelection.setText(
+ self._selectedUrl.path())
+ else:
+ self._selectedUrl = None
+ self._okButton.setEnabled(False)
+ self._labelSelection.setText(self._selectDatasetStatusText)
diff --git a/silx/gui/dialog/GroupDialog.py b/silx/gui/dialog/GroupDialog.py
index 71235d2..217a03c 100644
--- a/silx/gui/dialog/GroupDialog.py
+++ b/silx/gui/dialog/GroupDialog.py
@@ -26,9 +26,7 @@
tree.
.. autoclass:: GroupDialog
- :show-inheritance:
- :members:
-
+ :members: addFile, addGroup, getSelectedDataUrl, setMode
"""
from silx.gui import qt
@@ -41,31 +39,18 @@ __license__ = "MIT"
__date__ = "22/03/2018"
-class GroupDialog(qt.QDialog):
- """This :class:`QDialog` uses a :class:`silx.gui.hdf5.Hdf5TreeView` to
- provide a HDF5 group selection dialog.
-
- The information identifying the selected node is provided as a
- :class:`silx.io.url.DataUrl`.
-
- Example:
-
- .. code-block:: python
-
- dialog = GroupDialog()
- dialog.addFile(filepath1)
- dialog.addFile(filepath2)
+class _Hdf5ItemSelectionDialog(qt.QDialog):
+ SaveMode = 1
+ """Mode used to set the HDF5 item selection dialog to *save* mode.
+ This adds a text field to type in a new item name."""
- if dialog.exec_():
- print("File path: %s" % dialog.getSelectedDataUrl().file_path())
- print("HDF5 group path : %s " % dialog.getSelectedDataUrl().data_path())
- else:
- print("Operation cancelled :(")
+ LoadMode = 2
+ """Mode used to set the HDF5 item selection dialog to *load* mode.
+ Only existing items of the HDF5 file can be selected in this mode."""
- """
def __init__(self, parent=None):
qt.QDialog.__init__(self, parent)
- self.setWindowTitle("HDF5 group selection")
+ self.setWindowTitle("HDF5 item selection")
self._tree = Hdf5TreeView(self)
self._tree.setSelectionMode(qt.QAbstractItemView.SingleSelection)
@@ -76,25 +61,26 @@ class GroupDialog(qt.QDialog):
self._model = self._tree.findHdf5TreeModel()
self._header = self._tree.header()
- self._header.setSections([self._model.NAME_COLUMN,
- self._model.NODE_COLUMN,
- self._model.LINK_COLUMN])
- _labelSubgroup = qt.QLabel(self)
- _labelSubgroup.setText("Subgroup name (optional)")
- self._lineEditSubgroup = qt.QLineEdit(self)
- self._lineEditSubgroup.setToolTip(
- "Specify the name of a new subgroup "
+ self._newItemWidget = qt.QWidget(self)
+ newItemLayout = qt.QVBoxLayout(self._newItemWidget)
+ self._labelNewItem = qt.QLabel(self._newItemWidget)
+ self._labelNewItem.setText("Create new item in selected group (optional):")
+ self._lineEditNewItem = qt.QLineEdit(self._newItemWidget)
+ self._lineEditNewItem.setToolTip(
+ "Specify the name of a new item "
"to be created in the selected group.")
- self._lineEditSubgroup.textChanged.connect(
- self._onSubgroupNameChange)
+ self._lineEditNewItem.textChanged.connect(
+ self._onNewItemNameChange)
+ newItemLayout.addWidget(self._labelNewItem)
+ newItemLayout.addWidget(self._lineEditNewItem)
_labelSelectionTitle = qt.QLabel(self)
_labelSelectionTitle.setText("Current selection")
self._labelSelection = qt.QLabel(self)
self._labelSelection.setStyleSheet("color: gray")
self._labelSelection.setWordWrap(True)
- self._labelSelection.setText("Select a group")
+ self._labelSelection.setText("Select an item")
buttonBox = qt.QDialogButtonBox()
self._okButton = buttonBox.addButton(qt.QDialogButtonBox.Ok)
@@ -106,8 +92,7 @@ class GroupDialog(qt.QDialog):
vlayout = qt.QVBoxLayout(self)
vlayout.addWidget(self._tree)
- vlayout.addWidget(_labelSubgroup)
- vlayout.addWidget(self._lineEditSubgroup)
+ vlayout.addWidget(self._newItemWidget)
vlayout.addWidget(_labelSelectionTitle)
vlayout.addWidget(self._labelSelection)
vlayout.addWidget(buttonBox)
@@ -117,6 +102,30 @@ class GroupDialog(qt.QDialog):
self._selectedUrl = None
+ def _onSelectionChange(self, old, new):
+ self._updateUrl()
+
+ def _onNewItemNameChange(self, text):
+ self._updateUrl()
+
+ def _onActivation(self, idx):
+ # double-click or enter press
+ self.accept()
+
+ def setMode(self, mode):
+ """Set dialog mode DatasetDialog.SaveMode or DatasetDialog.LoadMode
+
+ :param mode: DatasetDialog.SaveMode or DatasetDialog.LoadMode
+ """
+ if mode == self.LoadMode:
+ # hide "Create new item" field
+ self._lineEditNewItem.clear()
+ self._newItemWidget.hide()
+ elif mode == self.SaveMode:
+ self._newItemWidget.show()
+ else:
+ raise ValueError("Invalid DatasetDialog mode %s" % mode)
+
def addFile(self, path):
"""Add a HDF5 file to the tree.
All groups it contains will be selectable in the dialog.
@@ -133,22 +142,75 @@ class GroupDialog(qt.QDialog):
"""
self._model.insertH5pyObject(group)
+ def _updateUrl(self):
+ nodes = list(self._tree.selectedH5Nodes())
+ subgroupName = self._lineEditNewItem.text()
+ if nodes:
+ node = nodes[0]
+ data_path = node.local_name
+ if subgroupName.lstrip("/"):
+ if not data_path.endswith("/"):
+ data_path += "/"
+ data_path += subgroupName.lstrip("/")
+ self._selectedUrl = DataUrl(file_path=node.local_filename,
+ data_path=data_path)
+ self._okButton.setEnabled(True)
+ self._labelSelection.setText(
+ self._selectedUrl.path())
+
+ def getSelectedDataUrl(self):
+ """Return a :class:`DataUrl` with a file path and a data path.
+ Return None if the dialog was cancelled.
+
+ :return: :class:`silx.io.url.DataUrl` object pointing to the
+ selected HDF5 item.
+ """
+ return self._selectedUrl
+
+
+class GroupDialog(_Hdf5ItemSelectionDialog):
+ """This :class:`QDialog` uses a :class:`silx.gui.hdf5.Hdf5TreeView` to
+ provide a HDF5 group selection dialog.
+
+ The information identifying the selected node is provided as a
+ :class:`silx.io.url.DataUrl`.
+
+ Example:
+
+ .. code-block:: python
+
+ dialog = GroupDialog()
+ dialog.addFile(filepath1)
+ dialog.addFile(filepath2)
+
+ if dialog.exec_():
+ print("File path: %s" % dialog.getSelectedDataUrl().file_path())
+ print("HDF5 group path : %s " % dialog.getSelectedDataUrl().data_path())
+ else:
+ print("Operation cancelled :(")
+
+ """
+ def __init__(self, parent=None):
+ _Hdf5ItemSelectionDialog.__init__(self, parent)
+
+ # customization for groups
+ self.setWindowTitle("HDF5 group selection")
+
+ self._header.setSections([self._model.NAME_COLUMN,
+ self._model.NODE_COLUMN,
+ self._model.LINK_COLUMN])
+
def _onActivation(self, idx):
- # double-click or enter press
+ # double-click or enter press: filter for groups
nodes = list(self._tree.selectedH5Nodes())
node = nodes[0]
if silx.io.is_group(node.h5py_object):
self.accept()
- def _onSelectionChange(self, old, new):
- self._updateUrl()
-
- def _onSubgroupNameChange(self, text):
- self._updateUrl()
-
def _updateUrl(self):
+ # overloaded to filter for groups
nodes = list(self._tree.selectedH5Nodes())
- subgroupName = self._lineEditSubgroup.text()
+ subgroupName = self._lineEditNewItem.text()
if nodes:
node = nodes[0]
if silx.io.is_group(node.h5py_object):
@@ -166,12 +228,3 @@ class GroupDialog(qt.QDialog):
self._selectedUrl = None
self._okButton.setEnabled(False)
self._labelSelection.setText("Select a group")
-
- def getSelectedDataUrl(self):
- """Return a :class:`DataUrl` with a file path and a data path.
- Return None if the dialog was cancelled.
-
- :return: :class:`silx.io.url.DataUrl` object pointing to the
- selected group.
- """
- return self._selectedUrl
diff --git a/silx/gui/dialog/SafeFileIconProvider.py b/silx/gui/dialog/SafeFileIconProvider.py
index 7fac7c0..1e06b64 100644
--- a/silx/gui/dialog/SafeFileIconProvider.py
+++ b/silx/gui/dialog/SafeFileIconProvider.py
@@ -115,6 +115,10 @@ class SafeFileIconProvider(qt.QFileIconProvider):
return driveInfo[0]
def icon(self, info):
+ if isinstance(info, qt.QFileIconProvider.IconType):
+ # It's another C++ method signature:
+ # QIcon QFileIconProvider::icon(QFileIconProvider::IconType type)
+ return super(SafeFileIconProvider, self).icon(info)
style = qt.QApplication.instance().style()
path = info.filePath()
if path in ["", "/"]:
diff --git a/silx/gui/dialog/SafeFileSystemModel.py b/silx/gui/dialog/SafeFileSystemModel.py
index 8a97974..198e089 100644
--- a/silx/gui/dialog/SafeFileSystemModel.py
+++ b/silx/gui/dialog/SafeFileSystemModel.py
@@ -749,7 +749,7 @@ class SafeFileSystemModel(qt.QSortFilterProxyModel):
index = self.mapToSource(index)
filters = sourceModel.flags(index)
- if self.__nameFilterDisables:
+ if self.__nameFilterDisables and not sourceModel.isDir(index):
item = sourceModel._item(index)
if not self.__nameFiltersAccepted(item):
filters &= ~qt.Qt.ItemIsEnabled
diff --git a/silx/gui/dialog/test/test_colormapdialog.py b/silx/gui/dialog/test/test_colormapdialog.py
index 6f0ceea..6e50193 100644
--- a/silx/gui/dialog/test/test_colormapdialog.py
+++ b/silx/gui/dialog/test/test_colormapdialog.py
@@ -32,10 +32,10 @@ __date__ = "23/05/2018"
import doctest
import unittest
-from silx.gui.test.utils import qWaitForWindowExposedAndActivate
+from silx.gui.utils.testutils import qWaitForWindowExposedAndActivate
from silx.gui import qt
from silx.gui.dialog import ColormapDialog
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui.colors import Colormap, preferredColormaps
from silx.utils.testutils import ParametricTestCase
from silx.gui.plot.PlotWindow import PlotWindow
diff --git a/silx/gui/dialog/test/test_datafiledialog.py b/silx/gui/dialog/test/test_datafiledialog.py
index 38fa03b..aff6bc4 100644
--- a/silx/gui/dialog/test/test_datafiledialog.py
+++ b/silx/gui/dialog/test/test_datafiledialog.py
@@ -26,7 +26,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "03/07/2018"
+__date__ = "05/10/2018"
import unittest
@@ -48,7 +48,7 @@ except ImportError:
import silx.io.url
from silx.gui import qt
-from silx.gui.test import utils
+from silx.gui.utils import testutils
from ..DataFileDialog import DataFileDialog
from silx.gui.hdf5 import Hdf5TreeModel
@@ -134,7 +134,7 @@ class _UtilsMixin(object):
path2_ = os.path.normcase(path2)
if path1_ != path2_:
# Use the unittest API to log and display error
- self.assertEquals(path1, path2)
+ self.assertEqual(path1, path2)
def assertNotSamePath(self, path1, path2):
path1_ = os.path.normcase(path1)
@@ -144,11 +144,11 @@ class _UtilsMixin(object):
self.assertNotEquals(path1, path2)
-class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
+class TestDataFileDialogInteraction(testutils.TestCaseQt, _UtilsMixin):
def tearDown(self):
self._deleteDialog()
- utils.TestCaseQt.tearDown(self)
+ testutils.TestCaseQt.tearDown(self)
def testDisplayAndKeyEscape(self):
dialog = self.createDialog()
@@ -158,7 +158,7 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
self.keyClick(dialog, qt.Qt.Key_Escape)
self.assertFalse(dialog.isVisible())
- self.assertEquals(dialog.result(), qt.QDialog.Rejected)
+ self.assertEqual(dialog.result(), qt.QDialog.Rejected)
def testDisplayAndClickCancel(self):
dialog = self.createDialog()
@@ -166,11 +166,11 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
self.qWaitForWindowExposed(dialog)
self.assertTrue(dialog.isVisible())
- button = utils.findChildren(dialog, qt.QPushButton, name="cancel")[0]
+ button = testutils.findChildren(dialog, qt.QPushButton, name="cancel")[0]
self.mouseClick(button, qt.Qt.LeftButton)
self.assertFalse(dialog.isVisible())
self.assertFalse(dialog.isVisible())
- self.assertEquals(dialog.result(), qt.QDialog.Rejected)
+ self.assertEqual(dialog.result(), qt.QDialog.Rejected)
def testDisplayAndClickLockedOpen(self):
dialog = self.createDialog()
@@ -178,17 +178,17 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
self.qWaitForWindowExposed(dialog)
self.assertTrue(dialog.isVisible())
- button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0]
self.mouseClick(button, qt.Qt.LeftButton)
# open button locked, dialog is not closed
self.assertTrue(dialog.isVisible())
- self.assertEquals(dialog.result(), qt.QDialog.Rejected)
+ self.assertEqual(dialog.result(), qt.QDialog.Rejected)
def testSelectRoot_Activate(self):
if fabio is None:
self.skipTest("fabio is missing")
dialog = self.createDialog()
- browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0]
dialog.show()
self.qWaitForWindowExposed(dialog)
self.assertTrue(dialog.isVisible())
@@ -202,19 +202,19 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
browser.activated.emit(index)
self.qWaitForPendingActions(dialog)
- button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0]
self.assertTrue(button.isEnabled())
self.mouseClick(button, qt.Qt.LeftButton)
url = silx.io.url.DataUrl(dialog.selectedUrl())
self.assertTrue(url.data_path() is not None)
self.assertFalse(dialog.isVisible())
- self.assertEquals(dialog.result(), qt.QDialog.Accepted)
+ self.assertEqual(dialog.result(), qt.QDialog.Accepted)
def testSelectGroup_Activate(self):
if fabio is None:
self.skipTest("fabio is missing")
dialog = self.createDialog()
- browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0]
dialog.show()
self.qWaitForWindowExposed(dialog)
self.assertTrue(dialog.isVisible())
@@ -234,19 +234,19 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
browser.activated.emit(index)
self.qWaitForPendingActions(dialog)
- button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0]
self.assertTrue(button.isEnabled())
self.mouseClick(button, qt.Qt.LeftButton)
url = silx.io.url.DataUrl(dialog.selectedUrl())
self.assertEqual(url.data_path(), "/group")
self.assertFalse(dialog.isVisible())
- self.assertEquals(dialog.result(), qt.QDialog.Accepted)
+ self.assertEqual(dialog.result(), qt.QDialog.Accepted)
def testSelectDataset_Activate(self):
if fabio is None:
self.skipTest("fabio is missing")
dialog = self.createDialog()
- browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0]
dialog.show()
self.qWaitForWindowExposed(dialog)
self.assertTrue(dialog.isVisible())
@@ -266,13 +266,13 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
browser.activated.emit(index)
self.qWaitForPendingActions(dialog)
- button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0]
self.assertTrue(button.isEnabled())
self.mouseClick(button, qt.Qt.LeftButton)
url = silx.io.url.DataUrl(dialog.selectedUrl())
self.assertEqual(url.data_path(), "/scalar")
self.assertFalse(dialog.isVisible())
- self.assertEquals(dialog.result(), qt.QDialog.Accepted)
+ self.assertEqual(dialog.result(), qt.QDialog.Accepted)
def testClickOnBackToParentTool(self):
if h5py is None:
@@ -281,9 +281,9 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
dialog.show()
self.qWaitForWindowExposed(dialog)
- url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0]
- action = utils.findChildren(dialog, qt.QAction, name="toParentAction")[0]
- toParentButton = utils.getQToolButtonFromAction(action)
+ url = testutils.findChildren(dialog, qt.QLineEdit, name="url")[0]
+ action = testutils.findChildren(dialog, qt.QAction, name="toParentAction")[0]
+ toParentButton = testutils.getQToolButtonFromAction(action)
filename = _tmpDirectory + "/data/data.h5"
# init state
@@ -313,9 +313,9 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
dialog.show()
self.qWaitForWindowExposed(dialog)
- url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0]
- action = utils.findChildren(dialog, qt.QAction, name="toRootFileAction")[0]
- button = utils.getQToolButtonFromAction(action)
+ url = testutils.findChildren(dialog, qt.QLineEdit, name="url")[0]
+ action = testutils.findChildren(dialog, qt.QAction, name="toRootFileAction")[0]
+ button = testutils.getQToolButtonFromAction(action)
filename = _tmpDirectory + "/data.h5"
# init state
@@ -338,9 +338,9 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
dialog.show()
self.qWaitForWindowExposed(dialog)
- url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0]
- action = utils.findChildren(dialog, qt.QAction, name="toDirectoryAction")[0]
- button = utils.getQToolButtonFromAction(action)
+ url = testutils.findChildren(dialog, qt.QLineEdit, name="url")[0]
+ action = testutils.findChildren(dialog, qt.QAction, name="toDirectoryAction")[0]
+ button = testutils.getQToolButtonFromAction(action)
filename = _tmpDirectory + "/data.h5"
# init state
@@ -367,9 +367,9 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
dialog.show()
self.qWaitForWindowExposed(dialog)
- url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0]
- forwardAction = utils.findChildren(dialog, qt.QAction, name="forwardAction")[0]
- backwardAction = utils.findChildren(dialog, qt.QAction, name="backwardAction")[0]
+ url = testutils.findChildren(dialog, qt.QLineEdit, name="url")[0]
+ forwardAction = testutils.findChildren(dialog, qt.QAction, name="forwardAction")[0]
+ backwardAction = testutils.findChildren(dialog, qt.QAction, name="backwardAction")[0]
filename = _tmpDirectory + "/data.h5"
dialog.setDirectory(_tmpDirectory)
@@ -387,14 +387,14 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
self.assertFalse(forwardAction.isEnabled())
self.assertTrue(backwardAction.isEnabled())
- button = utils.getQToolButtonFromAction(backwardAction)
+ button = testutils.getQToolButtonFromAction(backwardAction)
self.mouseClick(button, qt.Qt.LeftButton)
self.qWaitForPendingActions(dialog)
self.assertTrue(forwardAction.isEnabled())
self.assertTrue(backwardAction.isEnabled())
self.assertSamePath(url.text(), path2)
- button = utils.getQToolButtonFromAction(forwardAction)
+ button = testutils.getQToolButtonFromAction(forwardAction)
self.mouseClick(button, qt.Qt.LeftButton)
self.qWaitForPendingActions(dialog)
self.assertFalse(forwardAction.isEnabled())
@@ -494,7 +494,7 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
# init state
dialog.selectUrl(_tmpDirectory)
self.qWaitForPendingActions(dialog)
- browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0]
filename = _tmpDirectory + "/data.h5"
path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path()
index = browser.rootIndex().model().index(filename)
@@ -514,7 +514,7 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
# init state
dialog.selectUrl(_tmpDirectory)
self.qWaitForPendingActions(dialog)
- browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0]
filename = _tmpDirectory + "/badformat.h5"
index = browser.rootIndex().model().index(filename)
browser.activated.emit(index)
@@ -538,7 +538,7 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
if fabio is None:
self.skipTest("fabio is missing")
dialog = self.createDialog()
- browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0]
dialog.show()
self.qWaitForWindowExposed(dialog)
dialog.selectUrl(_tmpDirectory)
@@ -546,11 +546,11 @@ class TestDataFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 4)
-class TestDataFileDialog_FilterDataset(utils.TestCaseQt, _UtilsMixin):
+class TestDataFileDialog_FilterDataset(testutils.TestCaseQt, _UtilsMixin):
def tearDown(self):
self._deleteDialog()
- utils.TestCaseQt.tearDown(self)
+ testutils.TestCaseQt.tearDown(self)
def _createDialog(self):
dialog = DataFileDialog()
@@ -561,7 +561,7 @@ class TestDataFileDialog_FilterDataset(utils.TestCaseQt, _UtilsMixin):
if fabio is None:
self.skipTest("fabio is missing")
dialog = self.createDialog()
- browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0]
dialog.show()
self.qWaitForWindowExposed(dialog)
self.assertTrue(dialog.isVisible())
@@ -581,14 +581,14 @@ class TestDataFileDialog_FilterDataset(utils.TestCaseQt, _UtilsMixin):
browser.activated.emit(index)
self.qWaitForPendingActions(dialog)
- button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0]
self.assertFalse(button.isEnabled())
def testSelectDataset_Activate(self):
if fabio is None:
self.skipTest("fabio is missing")
dialog = self.createDialog()
- browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0]
dialog.show()
self.qWaitForWindowExposed(dialog)
self.assertTrue(dialog.isVisible())
@@ -608,23 +608,23 @@ class TestDataFileDialog_FilterDataset(utils.TestCaseQt, _UtilsMixin):
browser.activated.emit(index)
self.qWaitForPendingActions(dialog)
- button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0]
self.assertTrue(button.isEnabled())
self.mouseClick(button, qt.Qt.LeftButton)
url = silx.io.url.DataUrl(dialog.selectedUrl())
self.assertEqual(url.data_path(), "/scalar")
self.assertFalse(dialog.isVisible())
- self.assertEquals(dialog.result(), qt.QDialog.Accepted)
+ self.assertEqual(dialog.result(), qt.QDialog.Accepted)
data = dialog.selectedData()
self.assertEqual(data, 10)
-class TestDataFileDialog_FilterGroup(utils.TestCaseQt, _UtilsMixin):
+class TestDataFileDialog_FilterGroup(testutils.TestCaseQt, _UtilsMixin):
def tearDown(self):
self._deleteDialog()
- utils.TestCaseQt.tearDown(self)
+ testutils.TestCaseQt.tearDown(self)
def _createDialog(self):
dialog = DataFileDialog()
@@ -635,7 +635,7 @@ class TestDataFileDialog_FilterGroup(utils.TestCaseQt, _UtilsMixin):
if fabio is None:
self.skipTest("fabio is missing")
dialog = self.createDialog()
- browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0]
dialog.show()
self.qWaitForWindowExposed(dialog)
self.assertTrue(dialog.isVisible())
@@ -655,13 +655,13 @@ class TestDataFileDialog_FilterGroup(utils.TestCaseQt, _UtilsMixin):
browser.activated.emit(index)
self.qWaitForPendingActions(dialog)
- button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0]
self.assertTrue(button.isEnabled())
self.mouseClick(button, qt.Qt.LeftButton)
url = silx.io.url.DataUrl(dialog.selectedUrl())
self.assertEqual(url.data_path(), "/group")
self.assertFalse(dialog.isVisible())
- self.assertEquals(dialog.result(), qt.QDialog.Accepted)
+ self.assertEqual(dialog.result(), qt.QDialog.Accepted)
self.assertRaises(Exception, dialog.selectedData)
@@ -669,7 +669,7 @@ class TestDataFileDialog_FilterGroup(utils.TestCaseQt, _UtilsMixin):
if fabio is None:
self.skipTest("fabio is missing")
dialog = self.createDialog()
- browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0]
dialog.show()
self.qWaitForWindowExposed(dialog)
self.assertTrue(dialog.isVisible())
@@ -689,15 +689,15 @@ class TestDataFileDialog_FilterGroup(utils.TestCaseQt, _UtilsMixin):
browser.activated.emit(index)
self.qWaitForPendingActions(dialog)
- button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0]
self.assertFalse(button.isEnabled())
-class TestDataFileDialog_FilterNXdata(utils.TestCaseQt, _UtilsMixin):
+class TestDataFileDialog_FilterNXdata(testutils.TestCaseQt, _UtilsMixin):
def tearDown(self):
self._deleteDialog()
- utils.TestCaseQt.tearDown(self)
+ testutils.TestCaseQt.tearDown(self)
def _createDialog(self):
def customFilter(obj):
@@ -714,7 +714,7 @@ class TestDataFileDialog_FilterNXdata(utils.TestCaseQt, _UtilsMixin):
if fabio is None:
self.skipTest("fabio is missing")
dialog = self.createDialog()
- browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0]
dialog.show()
self.qWaitForWindowExposed(dialog)
self.assertTrue(dialog.isVisible())
@@ -734,7 +734,7 @@ class TestDataFileDialog_FilterNXdata(utils.TestCaseQt, _UtilsMixin):
browser.activated.emit(index)
self.qWaitForPendingActions(dialog)
- button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0]
self.assertFalse(button.isEnabled())
self.assertRaises(Exception, dialog.selectedData)
@@ -743,7 +743,7 @@ class TestDataFileDialog_FilterNXdata(utils.TestCaseQt, _UtilsMixin):
if fabio is None:
self.skipTest("fabio is missing")
dialog = self.createDialog()
- browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0]
dialog.show()
self.qWaitForWindowExposed(dialog)
self.assertTrue(dialog.isVisible())
@@ -763,20 +763,20 @@ class TestDataFileDialog_FilterNXdata(utils.TestCaseQt, _UtilsMixin):
browser.activated.emit(index)
self.qWaitForPendingActions(dialog)
- button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0]
self.assertTrue(button.isEnabled())
self.mouseClick(button, qt.Qt.LeftButton)
url = silx.io.url.DataUrl(dialog.selectedUrl())
self.assertEqual(url.data_path(), "/nxdata")
self.assertFalse(dialog.isVisible())
- self.assertEquals(dialog.result(), qt.QDialog.Accepted)
+ self.assertEqual(dialog.result(), qt.QDialog.Accepted)
-class TestDataFileDialogApi(utils.TestCaseQt, _UtilsMixin):
+class TestDataFileDialogApi(testutils.TestCaseQt, _UtilsMixin):
def tearDown(self):
self._deleteDialog()
- utils.TestCaseQt.tearDown(self)
+ testutils.TestCaseQt.tearDown(self)
def _createDialog(self):
dialog = DataFileDialog()
@@ -949,7 +949,7 @@ class TestDataFileDialogApi(utils.TestCaseQt, _UtilsMixin):
dialog = self.createDialog()
self.qWaitForPendingActions(dialog)
- browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0]
filename = _tmpDirectory + "/data.h5"
url = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/foobar")
diff --git a/silx/gui/dialog/test/test_imagefiledialog.py b/silx/gui/dialog/test/test_imagefiledialog.py
index 8fef3c5..66469f3 100644
--- a/silx/gui/dialog/test/test_imagefiledialog.py
+++ b/silx/gui/dialog/test/test_imagefiledialog.py
@@ -26,7 +26,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "03/07/2018"
+__date__ = "05/10/2018"
import unittest
@@ -48,7 +48,7 @@ except ImportError:
import silx.io.url
from silx.gui import qt
-from silx.gui.test import utils
+from silx.gui.utils import testutils
from ..ImageFileDialog import ImageFileDialog
from silx.gui.colors import Colormap
from silx.gui.hdf5 import Hdf5TreeModel
@@ -141,7 +141,7 @@ class _UtilsMixin(object):
path2_ = os.path.normcase(path2)
if path1_ != path2_:
# Use the unittest API to log and display error
- self.assertEquals(path1, path2)
+ self.assertEqual(path1, path2)
def assertNotSamePath(self, path1, path2):
path1_ = os.path.normcase(path1)
@@ -151,11 +151,11 @@ class _UtilsMixin(object):
self.assertNotEquals(path1, path2)
-class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
+class TestImageFileDialogInteraction(testutils.TestCaseQt, _UtilsMixin):
def tearDown(self):
self._deleteDialog()
- utils.TestCaseQt.tearDown(self)
+ testutils.TestCaseQt.tearDown(self)
def testDisplayAndKeyEscape(self):
dialog = self.createDialog()
@@ -165,7 +165,7 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
self.keyClick(dialog, qt.Qt.Key_Escape)
self.assertFalse(dialog.isVisible())
- self.assertEquals(dialog.result(), qt.QDialog.Rejected)
+ self.assertEqual(dialog.result(), qt.QDialog.Rejected)
def testDisplayAndClickCancel(self):
dialog = self.createDialog()
@@ -173,11 +173,11 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
self.qWaitForWindowExposed(dialog)
self.assertTrue(dialog.isVisible())
- button = utils.findChildren(dialog, qt.QPushButton, name="cancel")[0]
+ button = testutils.findChildren(dialog, qt.QPushButton, name="cancel")[0]
self.mouseClick(button, qt.Qt.LeftButton)
self.assertFalse(dialog.isVisible())
self.assertFalse(dialog.isVisible())
- self.assertEquals(dialog.result(), qt.QDialog.Rejected)
+ self.assertEqual(dialog.result(), qt.QDialog.Rejected)
def testDisplayAndClickLockedOpen(self):
dialog = self.createDialog()
@@ -185,11 +185,11 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
self.qWaitForWindowExposed(dialog)
self.assertTrue(dialog.isVisible())
- button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0]
self.mouseClick(button, qt.Qt.LeftButton)
# open button locked, dialog is not closed
self.assertTrue(dialog.isVisible())
- self.assertEquals(dialog.result(), qt.QDialog.Rejected)
+ self.assertEqual(dialog.result(), qt.QDialog.Rejected)
def testDisplayAndClickOpen(self):
if fabio is None:
@@ -202,20 +202,20 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
dialog.selectFile(filename)
self.qWaitForPendingActions(dialog)
- button = utils.findChildren(dialog, qt.QPushButton, name="open")[0]
+ button = testutils.findChildren(dialog, qt.QPushButton, name="open")[0]
self.assertTrue(button.isEnabled())
self.mouseClick(button, qt.Qt.LeftButton)
self.assertFalse(dialog.isVisible())
- self.assertEquals(dialog.result(), qt.QDialog.Accepted)
+ self.assertEqual(dialog.result(), qt.QDialog.Accepted)
def testClickOnShortcut(self):
dialog = self.createDialog()
dialog.show()
self.qWaitForWindowExposed(dialog)
- sidebar = utils.findChildren(dialog, qt.QListView, name="sidebar")[0]
- url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0]
- browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ sidebar = testutils.findChildren(dialog, qt.QListView, name="sidebar")[0]
+ url = testutils.findChildren(dialog, qt.QLineEdit, name="url")[0]
+ browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0]
dialog.setDirectory(_tmpDirectory)
self.qWaitForPendingActions(dialog)
@@ -248,13 +248,13 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
dialog.show()
self.qWaitForWindowExposed(dialog)
- action = utils.findChildren(dialog, qt.QAction, name="detailModeAction")[0]
- detailModeButton = utils.getQToolButtonFromAction(action)
+ action = testutils.findChildren(dialog, qt.QAction, name="detailModeAction")[0]
+ detailModeButton = testutils.getQToolButtonFromAction(action)
self.mouseClick(detailModeButton, qt.Qt.LeftButton)
self.assertEqual(dialog.viewMode(), qt.QFileDialog.Detail)
- action = utils.findChildren(dialog, qt.QAction, name="listModeAction")[0]
- listModeButton = utils.getQToolButtonFromAction(action)
+ action = testutils.findChildren(dialog, qt.QAction, name="listModeAction")[0]
+ listModeButton = testutils.getQToolButtonFromAction(action)
self.mouseClick(listModeButton, qt.Qt.LeftButton)
self.assertEqual(dialog.viewMode(), qt.QFileDialog.List)
@@ -265,9 +265,9 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
dialog.show()
self.qWaitForWindowExposed(dialog)
- url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0]
- action = utils.findChildren(dialog, qt.QAction, name="toParentAction")[0]
- toParentButton = utils.getQToolButtonFromAction(action)
+ url = testutils.findChildren(dialog, qt.QLineEdit, name="url")[0]
+ action = testutils.findChildren(dialog, qt.QAction, name="toParentAction")[0]
+ toParentButton = testutils.getQToolButtonFromAction(action)
filename = _tmpDirectory + "/data/data.h5"
# init state
@@ -275,23 +275,19 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
dialog.selectUrl(path)
self.qWaitForPendingActions(dialog)
path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/image").path()
- print(url.text())
self.assertSamePath(url.text(), path)
# test
self.mouseClick(toParentButton, qt.Qt.LeftButton)
self.qWaitForPendingActions(dialog)
path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path()
- print(url.text())
self.assertSamePath(url.text(), path)
self.mouseClick(toParentButton, qt.Qt.LeftButton)
self.qWaitForPendingActions(dialog)
- print(url.text())
self.assertSamePath(url.text(), _tmpDirectory + "/data")
self.mouseClick(toParentButton, qt.Qt.LeftButton)
self.qWaitForPendingActions(dialog)
- print(url.text())
self.assertSamePath(url.text(), _tmpDirectory)
def testClickOnBackToRootTool(self):
@@ -301,9 +297,9 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
dialog.show()
self.qWaitForWindowExposed(dialog)
- url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0]
- action = utils.findChildren(dialog, qt.QAction, name="toRootFileAction")[0]
- button = utils.getQToolButtonFromAction(action)
+ url = testutils.findChildren(dialog, qt.QLineEdit, name="url")[0]
+ action = testutils.findChildren(dialog, qt.QAction, name="toRootFileAction")[0]
+ button = testutils.getQToolButtonFromAction(action)
filename = _tmpDirectory + "/data.h5"
# init state
@@ -326,9 +322,9 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
dialog.show()
self.qWaitForWindowExposed(dialog)
- url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0]
- action = utils.findChildren(dialog, qt.QAction, name="toDirectoryAction")[0]
- button = utils.getQToolButtonFromAction(action)
+ url = testutils.findChildren(dialog, qt.QLineEdit, name="url")[0]
+ action = testutils.findChildren(dialog, qt.QAction, name="toDirectoryAction")[0]
+ button = testutils.getQToolButtonFromAction(action)
filename = _tmpDirectory + "/data.h5"
# init state
@@ -355,9 +351,9 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
dialog.show()
self.qWaitForWindowExposed(dialog)
- url = utils.findChildren(dialog, qt.QLineEdit, name="url")[0]
- forwardAction = utils.findChildren(dialog, qt.QAction, name="forwardAction")[0]
- backwardAction = utils.findChildren(dialog, qt.QAction, name="backwardAction")[0]
+ url = testutils.findChildren(dialog, qt.QLineEdit, name="url")[0]
+ forwardAction = testutils.findChildren(dialog, qt.QAction, name="forwardAction")[0]
+ backwardAction = testutils.findChildren(dialog, qt.QAction, name="backwardAction")[0]
filename = _tmpDirectory + "/data.h5"
dialog.setDirectory(_tmpDirectory)
@@ -375,14 +371,14 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
self.assertFalse(forwardAction.isEnabled())
self.assertTrue(backwardAction.isEnabled())
- button = utils.getQToolButtonFromAction(backwardAction)
+ button = testutils.getQToolButtonFromAction(backwardAction)
self.mouseClick(button, qt.Qt.LeftButton)
self.qWaitForPendingActions(dialog)
self.assertTrue(forwardAction.isEnabled())
self.assertTrue(backwardAction.isEnabled())
self.assertSamePath(url.text(), path2)
- button = utils.getQToolButtonFromAction(forwardAction)
+ button = testutils.getQToolButtonFromAction(forwardAction)
self.mouseClick(button, qt.Qt.LeftButton)
self.qWaitForPendingActions(dialog)
self.assertFalse(forwardAction.isEnabled())
@@ -415,7 +411,7 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
# init state
dialog.selectUrl(_tmpDirectory)
self.qWaitForPendingActions(dialog)
- browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0]
filename = _tmpDirectory + "/singleimage.edf"
path = silx.io.url.DataUrl(scheme="fabio", file_path=filename).path()
index = browser.rootIndex().model().index(filename)
@@ -489,7 +485,7 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
# init state
dialog.selectUrl(_tmpDirectory)
self.qWaitForPendingActions(dialog)
- browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0]
filename = _tmpDirectory + "/data.h5"
path = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/").path()
index = browser.rootIndex().model().index(filename)
@@ -526,7 +522,7 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
# init state
dialog.selectUrl(_tmpDirectory)
self.qWaitForPendingActions(dialog)
- browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0]
filename = _tmpDirectory + "/badformat.edf"
index = browser.rootIndex().model().index(filename)
browser.activated.emit(index)
@@ -550,8 +546,8 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
if fabio is None:
self.skipTest("fabio is missing")
dialog = self.createDialog()
- browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
- filters = utils.findChildren(dialog, qt.QWidget, name="fileTypeCombo")[0]
+ browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ filters = testutils.findChildren(dialog, qt.QWidget, name="fileTypeCombo")[0]
dialog.show()
self.qWaitForWindowExposed(dialog)
dialog.selectUrl(_tmpDirectory)
@@ -573,11 +569,11 @@ class TestImageFileDialogInteraction(utils.TestCaseQt, _UtilsMixin):
self.assertEqual(self._countSelectableItems(browser.model(), browser.rootIndex()), 2)
-class TestImageFileDialogApi(utils.TestCaseQt, _UtilsMixin):
+class TestImageFileDialogApi(testutils.TestCaseQt, _UtilsMixin):
def tearDown(self):
self._deleteDialog()
- utils.TestCaseQt.tearDown(self)
+ testutils.TestCaseQt.tearDown(self)
def testSaveRestoreState(self):
dialog = self.createDialog()
@@ -782,7 +778,7 @@ class TestImageFileDialogApi(utils.TestCaseQt, _UtilsMixin):
dialog = self.createDialog()
self.qWaitForPendingActions(dialog)
- browser = utils.findChildren(dialog, qt.QWidget, name="browser")[0]
+ browser = testutils.findChildren(dialog, qt.QWidget, name="browser")[0]
filename = _tmpDirectory + "/data.h5"
url = silx.io.url.DataUrl(scheme="silx", file_path=filename, data_path="/group/foobar")
diff --git a/silx/gui/fit/FitConfig.py b/silx/gui/fit/FitConfig.py
index 04e411b..479e469 100644
--- a/silx/gui/fit/FitConfig.py
+++ b/silx/gui/fit/FitConfig.py
@@ -1,6 +1,6 @@
# coding: utf-8
# /*##########################################################################
-# Copyright (C) 2004-2016 V.A. Sole, European Synchrotron Radiation Facility
+# Copyright (C) 2004-2018 V.A. Sole, European Synchrotron Radiation Facility
#
# This file is part of the PyMca X-ray Fluorescence Toolkit developed at
# the ESRF by the Software group.
@@ -60,7 +60,7 @@ class TabsDialog(qt.QDialog):
# layout2.addWidget(self.buttonHelp)
self.buttonDefault = qt.QPushButton(self)
- self.buttonDefault.setText("Default")
+ self.buttonDefault.setText("Undo changes")
layout2.addWidget(self.buttonDefault)
spacer = qt.QSpacerItem(20, 20,
@@ -138,7 +138,7 @@ class TabsDialogData(TabsDialog):
self.default = {} if default is None else default
- self.buttonDefault.clicked.connect(self.setDefault)
+ self.buttonDefault.clicked.connect(self._resetDefault)
# self.keyPressEvent(qt.Qt.Key_Enter).
def keyPressEvent(self, event):
@@ -174,6 +174,9 @@ class TabsDialogData(TabsDialog):
self.setDefault()
super(TabsDialogData, self).reject()
+ def _resetDefault(self, checked):
+ self.setDefault()
+
def setDefault(self, newdefault=None):
"""Reinitialize :attr:`output` with :attr:`default` or with
new dictionary ``newdefault`` if provided.
diff --git a/silx/gui/fit/FitWidget.py b/silx/gui/fit/FitWidget.py
index 7012b63..78230b1 100644
--- a/silx/gui/fit/FitWidget.py
+++ b/silx/gui/fit/FitWidget.py
@@ -38,7 +38,7 @@ be user defined, or by default are loaded from
__authors__ = ["V.A. Sole", "P. Knobel"]
__license__ = "MIT"
-__date__ = "15/02/2017"
+__date__ = "17/07/2018"
import logging
import sys
@@ -315,8 +315,8 @@ class FitWidget(qt.QWidget):
configuration.update(self.configure())
def setdata(self, x, y, sigmay=None, xmin=None, xmax=None):
- warnings.warn("Method renamed to setData",
- DeprecationWarning)
+ warnings.warning("Method renamed to setData",
+ DeprecationWarning)
self.setData(x, y, sigmay, xmin, xmax)
def setData(self, x, y, sigmay=None, xmin=None, xmax=None):
@@ -525,8 +525,8 @@ class FitWidget(qt.QWidget):
self._emitSignal(ddict)
def startfit(self):
- warnings.warn("Method renamed to startFit",
- DeprecationWarning)
+ warnings.warning("Method renamed to startFit",
+ DeprecationWarning)
self.startFit()
def startFit(self):
diff --git a/silx/gui/fit/test/testBackgroundWidget.py b/silx/gui/fit/test/testBackgroundWidget.py
index 2e366e4..03b17b9 100644
--- a/silx/gui/fit/test/testBackgroundWidget.py
+++ b/silx/gui/fit/test/testBackgroundWidget.py
@@ -24,7 +24,7 @@
# ###########################################################################*/
import unittest
-from ...test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from .. import BackgroundWidget
diff --git a/silx/gui/fit/test/testFitConfig.py b/silx/gui/fit/test/testFitConfig.py
index eea35cc..f89c099 100644
--- a/silx/gui/fit/test/testFitConfig.py
+++ b/silx/gui/fit/test/testFitConfig.py
@@ -30,7 +30,7 @@ __date__ = "05/12/2016"
import unittest
-from ...test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from .. import FitConfig
diff --git a/silx/gui/fit/test/testFitWidget.py b/silx/gui/fit/test/testFitWidget.py
index d542fd0..cfd2bc9 100644
--- a/silx/gui/fit/test/testFitWidget.py
+++ b/silx/gui/fit/test/testFitWidget.py
@@ -26,7 +26,7 @@
import unittest
-from ...test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from ... import qt
from .. import FitWidget
diff --git a/silx/gui/hdf5/Hdf5Item.py b/silx/gui/hdf5/Hdf5Item.py
index 9804907..b3c313e 100644
--- a/silx/gui/hdf5/Hdf5Item.py
+++ b/silx/gui/hdf5/Hdf5Item.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 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
@@ -25,7 +25,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "10/10/2017"
+__date__ = "03/09/2018"
import logging
@@ -37,9 +37,8 @@ from .Hdf5Node import Hdf5Node
import silx.io.utils
from silx.gui.data.TextFormatter import TextFormatter
from ..hdf5.Hdf5Formatter import Hdf5Formatter
-
+from ...third_party import six
_logger = logging.getLogger(__name__)
-
_formatter = TextFormatter()
_hdf5Formatter = Hdf5Formatter(textFormatter=_formatter)
# FIXME: The formatter should be an attribute of the Hdf5Model
@@ -63,8 +62,16 @@ class Hdf5Item(Hdf5Node):
self.__error = None
self.__text = text
self.__linkClass = linkClass
+ self.__nx_class = None
Hdf5Node.__init__(self, parent, populateAll=populateAll)
+ def _getCanonicalName(self):
+ parent = self.parent
+ if parent is None:
+ return self.__text
+ else:
+ return "%s/%s" % (parent._getCanonicalName(), self.__text)
+
@property
def obj(self):
if self.__key:
@@ -152,8 +159,7 @@ class Hdf5Item(Hdf5Node):
try:
obj = parent_obj.get(self.__key)
except Exception as e:
- lib_name = self.obj.__class__.__module__.split(".")[0]
- _logger.debug("Internal %s error", lib_name, exc_info=True)
+ _logger.error("Internal error while reaching HDF5 object: %s", str(e))
_logger.debug("Backtrace", exc_info=True)
try:
self.__obj = parent_obj.get(self.__key, getlink=True)
@@ -184,7 +190,7 @@ class Hdf5Item(Hdf5Node):
elif class_ == silx.io.utils.H5Type.SOFT_LINK:
message = "Soft link broken. Path %s does not exist" % (self.__obj.path)
else:
- name = self.obj.__class__.__name__.split(".")[-1].capitalize()
+ name = self.__obj.__class__.__name__.split(".")[-1].capitalize()
message = "%s broken" % (name)
self.__error = message
self.__isBroken = True
@@ -293,6 +299,8 @@ class Hdf5Item(Hdf5Node):
attributeDict["Data type"] = self._getFormatter().humanReadableType(self.obj, full=True)
elif self.h5Class == silx.io.utils.H5Type.GROUP:
attributeDict["#Title"] = "HDF5 Group"
+ if self.nexusClassName:
+ attributeDict["NX_class"] = self.nexusClassName
attributeDict["Name"] = self.basename
attributeDict["Path"] = self.obj.name
elif self.h5Class == silx.io.utils.H5Type.FILE:
@@ -332,6 +340,20 @@ class Hdf5Item(Hdf5Node):
return tooltip
+ @property
+ def nexusClassName(self):
+ """Returns the Nexus class name"""
+ if self.__nx_class is None:
+ self.__nx_class = self.obj.attrs.get("NX_class", None)
+ if self.__nx_class is None:
+ self.__nx_class = ""
+ else:
+ if six.PY2:
+ self.__nx_class = self.__nx_class.decode()
+ elif not isinstance(self.__nx_class, str):
+ self.__nx_class = str(self.__nx_class, "UTF-8")
+ return self.__nx_class
+
def dataName(self, role):
"""Data for the name column"""
if role == qt.Qt.TextAlignmentRole:
@@ -354,12 +376,13 @@ class Hdf5Item(Hdf5Node):
if self.__error is not None:
return ""
class_ = self.h5Class
- if class_ == silx.io.utils.H5Type.DATASET:
+ if self.isGroupObj():
+ text = self.nexusClassName
+ elif class_ == silx.io.utils.H5Type.DATASET:
text = self._getFormatter().humanReadableType(self.obj)
else:
text = ""
return text
-
return None
def dataShape(self, role):
diff --git a/silx/gui/hdf5/Hdf5LoadingItem.py b/silx/gui/hdf5/Hdf5LoadingItem.py
index 4467366..f11d252 100644
--- a/silx/gui/hdf5/Hdf5LoadingItem.py
+++ b/silx/gui/hdf5/Hdf5LoadingItem.py
@@ -25,11 +25,12 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "23/09/2016"
+__date__ = "06/07/2018"
from .. import qt
from .Hdf5Node import Hdf5Node
+import silx.io.utils
class Hdf5LoadingItem(Hdf5Node):
@@ -49,6 +50,14 @@ class Hdf5LoadingItem(Hdf5Node):
def obj(self):
return None
+ @property
+ def h5Class(self):
+ """Returns the class of the stored object.
+
+ :rtype: silx.io.utils.H5Type
+ """
+ return silx.io.utils.H5Type.FILE
+
def dataName(self, role):
if role == qt.Qt.DecorationRole:
return self.__animatedIcon.currentIcon()
diff --git a/silx/gui/hdf5/Hdf5Node.py b/silx/gui/hdf5/Hdf5Node.py
index 0fcb407..be16535 100644
--- a/silx/gui/hdf5/Hdf5Node.py
+++ b/silx/gui/hdf5/Hdf5Node.py
@@ -25,7 +25,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "16/06/2017"
+__date__ = "24/07/2018"
import weakref
@@ -52,6 +52,13 @@ class Hdf5Node(object):
self.__child = []
self._populateChild(populateAll=True)
+ def _getCanonicalName(self):
+ parent = self.parent
+ if parent is None:
+ return "root"
+ else:
+ return "%s/?" % (parent._getCanonicalName())
+
@property
def parent(self):
"""Parent of the node, or None if the node is a root
diff --git a/silx/gui/hdf5/Hdf5TreeModel.py b/silx/gui/hdf5/Hdf5TreeModel.py
index 835708a..438200b 100644
--- a/silx/gui/hdf5/Hdf5TreeModel.py
+++ b/silx/gui/hdf5/Hdf5TreeModel.py
@@ -25,7 +25,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "11/06/2018"
+__date__ = "08/10/2018"
import os
@@ -591,6 +591,13 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
filename = node.obj.filename
self.insertFileAsync(filename, index.row(), synchronizingNode=node)
+ def h5pyObjectRow(self, h5pyObject):
+ for row in range(self.__root.childCount()):
+ item = self.__root.child(row)
+ if item.obj == h5pyObject:
+ return row
+ return -1
+
def synchronizeH5pyObject(self, h5pyObject):
"""
Synchronize a h5py object in all the tree.
@@ -602,7 +609,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
index = 0
while index < self.__root.childCount():
item = self.__root.child(index)
- if item.obj is h5pyObject:
+ if item.obj == h5pyObject:
qindex = self.index(index, 0, qt.QModelIndex())
self.synchronizeIndex(qindex)
index += 1
@@ -614,7 +621,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
:param qt.QModelIndex index: Index of the item to remove
"""
node = self.nodeFromIndex(index)
- if node.parent is not self.__root:
+ if node.parent != self.__root:
return
self._closeFileIfOwned(node)
self.beginRemoveRows(qt.QModelIndex(), index.row(), index.row())
@@ -632,7 +639,7 @@ class Hdf5TreeModel(qt.QAbstractItemModel):
index = 0
while index < self.__root.childCount():
item = self.__root.child(index)
- if item.obj is h5pyObject:
+ if item.obj == h5pyObject:
qindex = self.index(index, 0, qt.QModelIndex())
self.removeIndex(qindex)
else:
diff --git a/silx/gui/hdf5/NexusSortFilterProxyModel.py b/silx/gui/hdf5/NexusSortFilterProxyModel.py
index 3f2cf8d..216e992 100644
--- a/silx/gui/hdf5/NexusSortFilterProxyModel.py
+++ b/silx/gui/hdf5/NexusSortFilterProxyModel.py
@@ -25,7 +25,7 @@
__authors__ = ["V. Valls"]
__license__ = "MIT"
-__date__ = "25/06/2018"
+__date__ = "24/07/2018"
import logging
@@ -48,6 +48,24 @@ class NexusSortFilterProxyModel(qt.QSortFilterProxyModel):
self.__split = re.compile("(\\d+|\\D+)")
self.__iconCache = {}
+ def hasChildren(self, parent):
+ """Returns true if parent has any children; otherwise returns false.
+
+ :param qt.QModelIndex parent: Index of the item to check
+ :rtype: bool
+ """
+ parent = self.mapToSource(parent)
+ return self.sourceModel().hasChildren(parent)
+
+ def rowCount(self, parent):
+ """Returns the number of rows under the given parent.
+
+ :param qt.QModelIndex parent: Index of the item to check
+ :rtype: int
+ """
+ parent = self.mapToSource(parent)
+ return self.sourceModel().rowCount(parent)
+
def lessThan(self, sourceLeft, sourceRight):
"""Returns True if the value of the item referred to by the given
index `sourceLeft` is less than the value of the item referred to by
diff --git a/silx/gui/hdf5/_utils.py b/silx/gui/hdf5/_utils.py
index 8385129..6a34933 100644
--- a/silx/gui/hdf5/_utils.py
+++ b/silx/gui/hdf5/_utils.py
@@ -178,10 +178,11 @@ class H5Node(object):
elif obj.name.startswith("/"):
elements.pop(0)
path = ""
+ subpath = ""
while len(elements) > 0:
e = elements.pop(0)
- path = path + "/" + e
- link = obj.parent.get(path, getlink=True)
+ subpath = path + "/" + e
+ link = obj.parent.get(subpath, getlink=True)
classlink = silx.io.utils.get_h5_class(link)
if classlink == silx.io.utils.H5Type.EXTERNAL_LINK:
@@ -190,14 +191,18 @@ class H5Node(object):
return self.__get_target(external_obj)
elif classlink == silx.io.utils.H5Type.SOFT_LINK:
# Restart from this stat
- path = ""
root_elements = link.path.split("/")
if link.path == "/":
+ path = ""
root_elements = []
elif link.path.startswith("/"):
+ path = ""
root_elements.pop(0)
+
for name in reversed(root_elements):
elements.insert(0, name)
+ else:
+ path = subpath
return obj.file[path]
diff --git a/silx/gui/hdf5/test/test_hdf5.py b/silx/gui/hdf5/test/test_hdf5.py
index fc27f6b..1751a21 100644
--- a/silx/gui/hdf5/test/test_hdf5.py
+++ b/silx/gui/hdf5/test/test_hdf5.py
@@ -37,9 +37,9 @@ import numpy
import shutil
from contextlib import contextmanager
from silx.gui import qt
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui import hdf5
-from silx.gui.test.utils import SignalListener
+from silx.gui.utils.testutils import SignalListener
from silx.io import commonh5
import weakref
@@ -123,9 +123,9 @@ class TestHdf5TreeModel(TestCaseQt):
def testAppendFilename(self):
filename = _tmpDirectory + "/data.h5"
model = hdf5.Hdf5TreeModel()
- self.assertEquals(model.rowCount(qt.QModelIndex()), 0)
+ self.assertEqual(model.rowCount(qt.QModelIndex()), 0)
model.appendFile(filename)
- self.assertEquals(model.rowCount(qt.QModelIndex()), 1)
+ self.assertEqual(model.rowCount(qt.QModelIndex()), 1)
# clean up
index = model.index(0, 0, qt.QModelIndex())
h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE)
@@ -141,9 +141,9 @@ class TestHdf5TreeModel(TestCaseQt):
filename = _tmpDirectory + "/data.h5"
try:
model = hdf5.Hdf5TreeModel()
- self.assertEquals(model.rowCount(qt.QModelIndex()), 0)
+ self.assertEqual(model.rowCount(qt.QModelIndex()), 0)
model.insertFile(filename)
- self.assertEquals(model.rowCount(qt.QModelIndex()), 1)
+ self.assertEqual(model.rowCount(qt.QModelIndex()), 1)
# clean up
index = model.index(0, 0, qt.QModelIndex())
h5File = model.data(index, hdf5.Hdf5TreeModel.H5PY_OBJECT_ROLE)
@@ -157,7 +157,7 @@ class TestHdf5TreeModel(TestCaseQt):
filename = _tmpDirectory + "/data.h5"
try:
model = hdf5.Hdf5TreeModel()
- self.assertEquals(model.rowCount(qt.QModelIndex()), 0)
+ self.assertEqual(model.rowCount(qt.QModelIndex()), 0)
model.insertFileAsync(filename)
index = model.index(0, 0, qt.QModelIndex())
self.assertIsInstance(model.nodeFromIndex(index), hdf5.Hdf5LoadingItem.Hdf5LoadingItem)
@@ -172,25 +172,25 @@ class TestHdf5TreeModel(TestCaseQt):
def testInsertObject(self):
h5 = commonh5.File("/foo/bar/1.mock", "w")
model = hdf5.Hdf5TreeModel()
- self.assertEquals(model.rowCount(qt.QModelIndex()), 0)
+ self.assertEqual(model.rowCount(qt.QModelIndex()), 0)
model.insertH5pyObject(h5)
- self.assertEquals(model.rowCount(qt.QModelIndex()), 1)
+ self.assertEqual(model.rowCount(qt.QModelIndex()), 1)
def testRemoveObject(self):
h5 = commonh5.File("/foo/bar/1.mock", "w")
model = hdf5.Hdf5TreeModel()
- self.assertEquals(model.rowCount(qt.QModelIndex()), 0)
+ self.assertEqual(model.rowCount(qt.QModelIndex()), 0)
model.insertH5pyObject(h5)
- self.assertEquals(model.rowCount(qt.QModelIndex()), 1)
+ self.assertEqual(model.rowCount(qt.QModelIndex()), 1)
model.removeH5pyObject(h5)
- self.assertEquals(model.rowCount(qt.QModelIndex()), 0)
+ self.assertEqual(model.rowCount(qt.QModelIndex()), 0)
def testSynchronizeObject(self):
filename = _tmpDirectory + "/data.h5"
h5 = h5py.File(filename)
model = hdf5.Hdf5TreeModel()
model.insertH5pyObject(h5)
- self.assertEquals(model.rowCount(qt.QModelIndex()), 1)
+ self.assertEqual(model.rowCount(qt.QModelIndex()), 1)
index = model.index(0, 0, qt.QModelIndex())
node1 = model.nodeFromIndex(index)
model.synchronizeH5pyObject(h5)
@@ -220,15 +220,15 @@ class TestHdf5TreeModel(TestCaseQt):
def testFileMoveState(self):
model = hdf5.Hdf5TreeModel()
- self.assertEquals(model.isFileMoveEnabled(), True)
+ self.assertEqual(model.isFileMoveEnabled(), True)
model.setFileMoveEnabled(False)
- self.assertEquals(model.isFileMoveEnabled(), False)
+ self.assertEqual(model.isFileMoveEnabled(), False)
def testFileDropState(self):
model = hdf5.Hdf5TreeModel()
- self.assertEquals(model.isFileDropEnabled(), True)
+ self.assertEqual(model.isFileDropEnabled(), True)
model.setFileDropEnabled(False)
- self.assertEquals(model.isFileDropEnabled(), False)
+ self.assertEqual(model.isFileDropEnabled(), False)
def testSupportedDrop(self):
model = hdf5.Hdf5TreeModel()
@@ -236,7 +236,7 @@ class TestHdf5TreeModel(TestCaseQt):
model.setFileMoveEnabled(False)
model.setFileDropEnabled(False)
- self.assertEquals(model.supportedDropActions(), 0)
+ self.assertEqual(model.supportedDropActions(), 0)
model.setFileMoveEnabled(False)
model.setFileDropEnabled(True)
@@ -252,7 +252,7 @@ class TestHdf5TreeModel(TestCaseQt):
mimeData = qt.QMimeData()
mimeData.setUrls([qt.QUrl.fromLocalFile(filename)])
model.dropMimeData(mimeData, qt.Qt.CopyAction, 0, 0, qt.QModelIndex())
- self.assertEquals(model.rowCount(qt.QModelIndex()), 1)
+ self.assertEqual(model.rowCount(qt.QModelIndex()), 1)
# after sync
self.waitForPendingOperations(model)
index = model.index(0, 0, qt.QModelIndex())
@@ -285,13 +285,13 @@ class TestHdf5TreeModel(TestCaseQt):
model = hdf5.Hdf5TreeModel()
model.insertH5pyObject(h5)
displayed = self.getRowDataAsDict(model, row=0)
- self.assertEquals(displayed[hdf5.Hdf5TreeModel.NAME_COLUMN, qt.Qt.DisplayRole], "1.mock")
+ self.assertEqual(displayed[hdf5.Hdf5TreeModel.NAME_COLUMN, qt.Qt.DisplayRole], "1.mock")
self.assertIsInstance(displayed[hdf5.Hdf5TreeModel.NAME_COLUMN, qt.Qt.DecorationRole], qt.QIcon)
- self.assertEquals(displayed[hdf5.Hdf5TreeModel.TYPE_COLUMN, qt.Qt.DisplayRole], "")
- self.assertEquals(displayed[hdf5.Hdf5TreeModel.SHAPE_COLUMN, qt.Qt.DisplayRole], "")
- self.assertEquals(displayed[hdf5.Hdf5TreeModel.VALUE_COLUMN, qt.Qt.DisplayRole], "")
- self.assertEquals(displayed[hdf5.Hdf5TreeModel.DESCRIPTION_COLUMN, qt.Qt.DisplayRole], "")
- self.assertEquals(displayed[hdf5.Hdf5TreeModel.NODE_COLUMN, qt.Qt.DisplayRole], "File")
+ self.assertEqual(displayed[hdf5.Hdf5TreeModel.TYPE_COLUMN, qt.Qt.DisplayRole], "")
+ self.assertEqual(displayed[hdf5.Hdf5TreeModel.SHAPE_COLUMN, qt.Qt.DisplayRole], "")
+ self.assertEqual(displayed[hdf5.Hdf5TreeModel.VALUE_COLUMN, qt.Qt.DisplayRole], "")
+ self.assertEqual(displayed[hdf5.Hdf5TreeModel.DESCRIPTION_COLUMN, qt.Qt.DisplayRole], "")
+ self.assertEqual(displayed[hdf5.Hdf5TreeModel.NODE_COLUMN, qt.Qt.DisplayRole], "File")
def testGroupData(self):
h5 = commonh5.File("/foo/bar/1.mock", "w")
@@ -301,13 +301,13 @@ class TestHdf5TreeModel(TestCaseQt):
model = hdf5.Hdf5TreeModel()
model.insertH5pyObject(d)
displayed = self.getRowDataAsDict(model, row=0)
- self.assertEquals(displayed[hdf5.Hdf5TreeModel.NAME_COLUMN, qt.Qt.DisplayRole], "1.mock::foo")
+ self.assertEqual(displayed[hdf5.Hdf5TreeModel.NAME_COLUMN, qt.Qt.DisplayRole], "1.mock::foo")
self.assertIsInstance(displayed[hdf5.Hdf5TreeModel.NAME_COLUMN, qt.Qt.DecorationRole], qt.QIcon)
- self.assertEquals(displayed[hdf5.Hdf5TreeModel.TYPE_COLUMN, qt.Qt.DisplayRole], "")
- self.assertEquals(displayed[hdf5.Hdf5TreeModel.SHAPE_COLUMN, qt.Qt.DisplayRole], "")
- self.assertEquals(displayed[hdf5.Hdf5TreeModel.VALUE_COLUMN, qt.Qt.DisplayRole], "")
- self.assertEquals(displayed[hdf5.Hdf5TreeModel.DESCRIPTION_COLUMN, qt.Qt.DisplayRole], "fooo")
- self.assertEquals(displayed[hdf5.Hdf5TreeModel.NODE_COLUMN, qt.Qt.DisplayRole], "Group")
+ self.assertEqual(displayed[hdf5.Hdf5TreeModel.TYPE_COLUMN, qt.Qt.DisplayRole], "")
+ self.assertEqual(displayed[hdf5.Hdf5TreeModel.SHAPE_COLUMN, qt.Qt.DisplayRole], "")
+ self.assertEqual(displayed[hdf5.Hdf5TreeModel.VALUE_COLUMN, qt.Qt.DisplayRole], "")
+ self.assertEqual(displayed[hdf5.Hdf5TreeModel.DESCRIPTION_COLUMN, qt.Qt.DisplayRole], "fooo")
+ self.assertEqual(displayed[hdf5.Hdf5TreeModel.NODE_COLUMN, qt.Qt.DisplayRole], "Group")
def testDatasetData(self):
h5 = commonh5.File("/foo/bar/1.mock", "w")
@@ -317,13 +317,13 @@ class TestHdf5TreeModel(TestCaseQt):
model = hdf5.Hdf5TreeModel()
model.insertH5pyObject(d)
displayed = self.getRowDataAsDict(model, row=0)
- self.assertEquals(displayed[hdf5.Hdf5TreeModel.NAME_COLUMN, qt.Qt.DisplayRole], "1.mock::foo")
+ self.assertEqual(displayed[hdf5.Hdf5TreeModel.NAME_COLUMN, qt.Qt.DisplayRole], "1.mock::foo")
self.assertIsInstance(displayed[hdf5.Hdf5TreeModel.NAME_COLUMN, qt.Qt.DecorationRole], qt.QIcon)
- self.assertEquals(displayed[hdf5.Hdf5TreeModel.TYPE_COLUMN, qt.Qt.DisplayRole], value.dtype.name)
- self.assertEquals(displayed[hdf5.Hdf5TreeModel.SHAPE_COLUMN, qt.Qt.DisplayRole], "3")
- self.assertEquals(displayed[hdf5.Hdf5TreeModel.VALUE_COLUMN, qt.Qt.DisplayRole], "[1 2 3]")
- self.assertEquals(displayed[hdf5.Hdf5TreeModel.DESCRIPTION_COLUMN, qt.Qt.DisplayRole], "")
- self.assertEquals(displayed[hdf5.Hdf5TreeModel.NODE_COLUMN, qt.Qt.DisplayRole], "Dataset")
+ self.assertEqual(displayed[hdf5.Hdf5TreeModel.TYPE_COLUMN, qt.Qt.DisplayRole], value.dtype.name)
+ self.assertEqual(displayed[hdf5.Hdf5TreeModel.SHAPE_COLUMN, qt.Qt.DisplayRole], "3")
+ self.assertEqual(displayed[hdf5.Hdf5TreeModel.VALUE_COLUMN, qt.Qt.DisplayRole], "[1 2 3]")
+ self.assertEqual(displayed[hdf5.Hdf5TreeModel.DESCRIPTION_COLUMN, qt.Qt.DisplayRole], "")
+ self.assertEqual(displayed[hdf5.Hdf5TreeModel.NODE_COLUMN, qt.Qt.DisplayRole], "Dataset")
def testDropLastAsFirst(self):
model = hdf5.Hdf5TreeModel()
@@ -331,13 +331,13 @@ class TestHdf5TreeModel(TestCaseQt):
h5_2 = commonh5.File("/foo/bar/2.mock", "w")
model.insertH5pyObject(h5_1)
model.insertH5pyObject(h5_2)
- self.assertEquals(self.getItemName(model, 0), "1.mock")
- self.assertEquals(self.getItemName(model, 1), "2.mock")
+ self.assertEqual(self.getItemName(model, 0), "1.mock")
+ self.assertEqual(self.getItemName(model, 1), "2.mock")
index = model.index(1, 0, qt.QModelIndex())
mimeData = model.mimeData([index])
model.dropMimeData(mimeData, qt.Qt.MoveAction, 0, 0, qt.QModelIndex())
- self.assertEquals(self.getItemName(model, 0), "2.mock")
- self.assertEquals(self.getItemName(model, 1), "1.mock")
+ self.assertEqual(self.getItemName(model, 0), "2.mock")
+ self.assertEqual(self.getItemName(model, 1), "1.mock")
def testDropFirstAsLast(self):
model = hdf5.Hdf5TreeModel()
@@ -345,13 +345,13 @@ class TestHdf5TreeModel(TestCaseQt):
h5_2 = commonh5.File("/foo/bar/2.mock", "w")
model.insertH5pyObject(h5_1)
model.insertH5pyObject(h5_2)
- self.assertEquals(self.getItemName(model, 0), "1.mock")
- self.assertEquals(self.getItemName(model, 1), "2.mock")
+ self.assertEqual(self.getItemName(model, 0), "1.mock")
+ self.assertEqual(self.getItemName(model, 1), "2.mock")
index = model.index(0, 0, qt.QModelIndex())
mimeData = model.mimeData([index])
model.dropMimeData(mimeData, qt.Qt.MoveAction, 2, 0, qt.QModelIndex())
- self.assertEquals(self.getItemName(model, 0), "2.mock")
- self.assertEquals(self.getItemName(model, 1), "1.mock")
+ self.assertEqual(self.getItemName(model, 0), "2.mock")
+ self.assertEqual(self.getItemName(model, 1), "1.mock")
def testRootParent(self):
model = hdf5.Hdf5TreeModel()
@@ -359,7 +359,7 @@ class TestHdf5TreeModel(TestCaseQt):
model.insertH5pyObject(h5_1)
index = model.index(0, 0, qt.QModelIndex())
index = model.parent(index)
- self.assertEquals(index, qt.QModelIndex())
+ self.assertEqual(index, qt.QModelIndex())
class TestHdf5TreeModelSignals(TestCaseQt):
@@ -397,27 +397,27 @@ class TestHdf5TreeModelSignals(TestCaseQt):
filename = _tmpDirectory + "/data.h5"
h5 = h5py.File(filename)
self.model.insertH5pyObject(h5)
- self.assertEquals(self.listener.callCount(), 0)
+ self.assertEqual(self.listener.callCount(), 0)
def testLoaded(self):
filename = _tmpDirectory + "/data.h5"
self.model.insertFile(filename)
- self.assertEquals(self.listener.callCount(), 1)
- self.assertEquals(self.listener.karguments(argumentName="signal")[0], "loaded")
+ self.assertEqual(self.listener.callCount(), 1)
+ self.assertEqual(self.listener.karguments(argumentName="signal")[0], "loaded")
self.assertIsNot(self.listener.arguments(callIndex=0)[0], self.h5)
- self.assertEquals(self.listener.arguments(callIndex=0)[0].filename, filename)
+ self.assertEqual(self.listener.arguments(callIndex=0)[0].filename, filename)
def testRemoved(self):
self.model.removeH5pyObject(self.h5)
- self.assertEquals(self.listener.callCount(), 1)
- self.assertEquals(self.listener.karguments(argumentName="signal")[0], "removed")
+ self.assertEqual(self.listener.callCount(), 1)
+ self.assertEqual(self.listener.karguments(argumentName="signal")[0], "removed")
self.assertIs(self.listener.arguments(callIndex=0)[0], self.h5)
def testSynchonized(self):
self.model.synchronizeH5pyObject(self.h5)
self.waitForPendingOperations(self.model)
- self.assertEquals(self.listener.callCount(), 1)
- self.assertEquals(self.listener.karguments(argumentName="signal")[0], "synchronized")
+ self.assertEqual(self.listener.callCount(), 1)
+ self.assertEqual(self.listener.karguments(argumentName="signal")[0], "synchronized")
self.assertIs(self.listener.arguments(callIndex=0)[0], self.h5)
self.assertIsNot(self.listener.arguments(callIndex=0)[1], self.h5)
@@ -595,6 +595,7 @@ class TestH5Node(TestCaseQt):
h5["link/soft_link_to_group"] = h5py.SoftLink("/group")
h5["link/soft_link_to_link"] = h5py.SoftLink("/link/soft_link")
h5["link/soft_link_to_file"] = h5py.SoftLink("/")
+ h5["group/soft_link_relative"] = h5py.SoftLink("dataset")
h5["link/external_link"] = h5py.ExternalLink(externalFilename, "/target/dataset")
h5["link/external_link_to_link"] = h5py.ExternalLink(externalFilename, "/target/link")
h5["broken_link/external_broken_file"] = h5py.ExternalLink(externalFilename + "_not_exists", "/target/link")
@@ -697,6 +698,17 @@ class TestH5Node(TestCaseQt):
self.assertEqual(h5node.local_basename, "soft_link_to_link")
self.assertEqual(h5node.local_name, "/link/soft_link_to_link")
+ def testSoftLinkRelative(self):
+ path = ["base.h5", "group", "soft_link_relative"]
+ h5node = self.getH5NodeFromPath(self.model, path)
+
+ self.assertEqual(h5node.physical_filename, h5node.local_filename)
+ self.assertIn("base.h5", h5node.physical_filename)
+ self.assertEqual(h5node.physical_basename, "dataset")
+ self.assertEqual(h5node.physical_name, "/group/dataset")
+ self.assertEqual(h5node.local_basename, "soft_link_relative")
+ self.assertEqual(h5node.local_name, "/group/soft_link_relative")
+
def testExternalLink(self):
path = ["base.h5", "link", "external_link"]
h5node = self.getH5NodeFromPath(self.model, path)
@@ -895,7 +907,7 @@ class TestHdf5TreeView(TestCaseQt):
view.setSelectedH5Node(tree)
selection = list(view.selectedH5Nodes())
- self.assertEquals(len(selection), 0)
+ self.assertEqual(len(selection), 0)
def testSelection_Tree(self):
tree1 = commonh5.File("/foo/bar/1.mock", "w")
diff --git a/silx/gui/icons.py b/silx/gui/icons.py
index bd10300..ef99591 100644
--- a/silx/gui/icons.py
+++ b/silx/gui/icons.py
@@ -29,7 +29,7 @@ Use :func:`getQIcon` to create Qt QIcon from the name identifying an icon.
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "19/06/2018"
+__date__ = "05/10/2018"
import os
@@ -45,10 +45,30 @@ _logger = logging.getLogger(__name__)
"""Module logger"""
-_cached_icons = weakref.WeakValueDictionary()
+_cached_icons = None
"""Cache loaded icons in a weak structure"""
+def getIconCache():
+ """Get access to all cached icons
+
+ :rtype: dict
+ """
+ global _cached_icons
+ if _cached_icons is None:
+ _cached_icons = weakref.WeakValueDictionary()
+ # Clean up the cache before leaving the application
+ # See https://github.com/silx-kit/silx/issues/1771
+ qt.QApplication.instance().aboutToQuit.connect(cleanIconCache)
+ return _cached_icons
+
+
+def cleanIconCache():
+ """Clean up the icon cache"""
+ _logger.debug("Clean up icon cache")
+ _cached_icons.clear()
+
+
_supported_formats = None
"""Order of file format extension to check"""
@@ -285,7 +305,8 @@ def getAnimatedIcon(name):
:raises: ValueError when name is not known
"""
key = name + "__anim"
- if key not in _cached_icons:
+ cached_icons = getIconCache()
+ if key not in cached_icons:
qtMajorVersion = int(qt.qVersion().split(".")[0])
icon = None
@@ -306,9 +327,9 @@ def getAnimatedIcon(name):
if icon is None:
raise ValueError("Not an animated icon name: %s", name)
- _cached_icons[key] = icon
+ cached_icons[key] = icon
else:
- icon = _cached_icons[key]
+ icon = cached_icons[key]
return icon
@@ -329,12 +350,13 @@ def getQIcon(name):
:return: Corresponding QIcon
:raises: ValueError when name is not known
"""
- if name not in _cached_icons:
+ cached_icons = getIconCache()
+ if name not in cached_icons:
qfile = getQFile(name)
icon = qt.QIcon(qfile.fileName())
- _cached_icons[name] = icon
+ cached_icons[name] = icon
else:
- icon = _cached_icons[name]
+ icon = cached_icons[name]
return icon
diff --git a/silx/gui/plot/ColorBar.py b/silx/gui/plot/ColorBar.py
index 0941e82..fd4d34e 100644
--- a/silx/gui/plot/ColorBar.py
+++ b/silx/gui/plot/ColorBar.py
@@ -155,19 +155,17 @@ class ColorBarWidget(qt.QWidget):
self._disconnectPlot()
def getColormap(self):
- """
-
- :return: the :class:`.Colormap` colormap displayed in the colorbar.
+ """Returns the colormap displayed in the colorbar.
+ :rtype: ~silx.gui.colors.Colormap
"""
return self.getColorScaleBar().getColormap()
def setColormap(self, colormap, data=None):
"""Set the colormap to be displayed.
- :param colormap: The colormap to apply on the
- ColorBarWidget
- :type colormap: :class:`.Colormap`
+ :param ~silx.gui.colors.Colormap colormap:
+ The colormap to apply on the ColorBarWidget
:param numpy.ndarray data: the data to display, needed if the colormap
require an autoscale
"""
@@ -207,7 +205,7 @@ class ColorBarWidget(qt.QWidget):
:return: return the legend displayed along the colorbar
:rtype: str
"""
- return self.legend.getText()
+ return self.legend.text()
def _activeScatterChanged(self, previous, legend):
"""Handle plot active scatter changed"""
diff --git a/silx/gui/plot/CompareImages.py b/silx/gui/plot/CompareImages.py
new file mode 100644
index 0000000..88b257d
--- /dev/null
+++ b/silx/gui/plot/CompareImages.py
@@ -0,0 +1,1190 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018 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.
+#
+# ###########################################################################*/
+"""A widget dedicated to compare 2 images.
+"""
+
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "23/07/2018"
+
+
+import logging
+import numpy
+import weakref
+import collections
+import math
+
+import silx.image.bilinear
+from silx.gui import qt
+from silx.gui import plot
+from silx.gui import icons
+from silx.gui.colors import Colormap
+from silx.gui.plot import tools
+from silx.third_party import enum
+
+_logger = logging.getLogger(__name__)
+
+from silx.opencl import ocl
+if ocl is not None:
+ from silx.opencl import sift
+else: # No OpenCL device or no pyopencl
+ sift = None
+
+
+@enum.unique
+class VisualizationMode(enum.Enum):
+ """Enum for each visualization mode available."""
+ ONLY_A = 'a'
+ ONLY_B = 'b'
+ VERTICAL_LINE = 'vline'
+ HORIZONTAL_LINE = 'hline'
+ COMPOSITE_RED_BLUE_GRAY = "rbgchannel"
+ COMPOSITE_RED_BLUE_GRAY_NEG = "rbgnegchannel"
+
+
+@enum.unique
+class AlignmentMode(enum.Enum):
+ """Enum for each alignment mode available."""
+ ORIGIN = 'origin'
+ CENTER = 'center'
+ STRETCH = 'stretch'
+ AUTO = 'auto'
+
+
+AffineTransformation = collections.namedtuple("AffineTransformation",
+ ["tx", "ty", "sx", "sy", "rot"])
+"""Contains a 2D affine transformation: translation, scale and rotation"""
+
+
+class CompareImagesToolBar(qt.QToolBar):
+ """ToolBar containing specific tools to custom the configuration of a
+ :class:`CompareImages` widget
+
+ Use :meth:`setCompareWidget` to connect this toolbar to a specific
+ :class:`CompareImages` widget.
+
+ :param Union[qt.QWidget,None] parent: Parent of this widget.
+ """
+ def __init__(self, parent=None):
+ qt.QToolBar.__init__(self, parent)
+
+ self.__compareWidget = None
+
+ menu = qt.QMenu(self)
+ self.__visualizationAction = qt.QAction(self)
+ self.__visualizationAction.setMenu(menu)
+ self.__visualizationAction.setCheckable(False)
+ self.addAction(self.__visualizationAction)
+ self.__visualizationGroup = qt.QActionGroup(self)
+ self.__visualizationGroup.setExclusive(True)
+ self.__visualizationGroup.triggered.connect(self.__visualizationModeChanged)
+
+ icon = icons.getQIcon("compare-mode-a")
+ action = qt.QAction(icon, "Display the first image only", self)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ action.setShortcut(qt.QKeySequence(qt.Qt.Key_A))
+ action.setProperty("mode", VisualizationMode.ONLY_A)
+ menu.addAction(action)
+ self.__aModeAction = action
+ self.__visualizationGroup.addAction(action)
+
+ icon = icons.getQIcon("compare-mode-b")
+ action = qt.QAction(icon, "Display the second image only", self)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ action.setShortcut(qt.QKeySequence(qt.Qt.Key_B))
+ action.setProperty("mode", VisualizationMode.ONLY_B)
+ menu.addAction(action)
+ self.__bModeAction = action
+ self.__visualizationGroup.addAction(action)
+
+ icon = icons.getQIcon("compare-mode-vline")
+ action = qt.QAction(icon, "Vertical compare mode", self)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ action.setShortcut(qt.QKeySequence(qt.Qt.Key_V))
+ action.setProperty("mode", VisualizationMode.VERTICAL_LINE)
+ menu.addAction(action)
+ self.__vlineModeAction = action
+ self.__visualizationGroup.addAction(action)
+
+ icon = icons.getQIcon("compare-mode-hline")
+ action = qt.QAction(icon, "Horizontal compare mode", self)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ action.setShortcut(qt.QKeySequence(qt.Qt.Key_H))
+ action.setProperty("mode", VisualizationMode.HORIZONTAL_LINE)
+ menu.addAction(action)
+ self.__hlineModeAction = action
+ self.__visualizationGroup.addAction(action)
+
+ icon = icons.getQIcon("compare-mode-rb-channel")
+ action = qt.QAction(icon, "Blue/red compare mode (additive mode)", self)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ action.setShortcut(qt.QKeySequence(qt.Qt.Key_C))
+ action.setProperty("mode", VisualizationMode.COMPOSITE_RED_BLUE_GRAY)
+ menu.addAction(action)
+ self.__brChannelModeAction = action
+ self.__visualizationGroup.addAction(action)
+
+ icon = icons.getQIcon("compare-mode-rbneg-channel")
+ action = qt.QAction(icon, "Yellow/cyan compare mode (subtractive mode)", self)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ action.setShortcut(qt.QKeySequence(qt.Qt.Key_W))
+ action.setProperty("mode", VisualizationMode.COMPOSITE_RED_BLUE_GRAY_NEG)
+ menu.addAction(action)
+ self.__ycChannelModeAction = action
+ self.__visualizationGroup.addAction(action)
+
+ menu = qt.QMenu(self)
+ self.__alignmentAction = qt.QAction(self)
+ self.__alignmentAction.setMenu(menu)
+ self.__alignmentAction.setIconVisibleInMenu(True)
+ self.addAction(self.__alignmentAction)
+ self.__alignmentGroup = qt.QActionGroup(self)
+ self.__alignmentGroup.setExclusive(True)
+ self.__alignmentGroup.triggered.connect(self.__alignmentModeChanged)
+
+ icon = icons.getQIcon("compare-align-origin")
+ action = qt.QAction(icon, "Align images on their upper-left pixel", self)
+ action.setProperty("mode", AlignmentMode.ORIGIN)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ self.__originAlignAction = action
+ menu.addAction(action)
+ self.__alignmentGroup.addAction(action)
+
+ icon = icons.getQIcon("compare-align-center")
+ action = qt.QAction(icon, "Center images", self)
+ action.setProperty("mode", AlignmentMode.CENTER)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ self.__centerAlignAction = action
+ menu.addAction(action)
+ self.__alignmentGroup.addAction(action)
+
+ icon = icons.getQIcon("compare-align-stretch")
+ action = qt.QAction(icon, "Stretch the second image on the first one", self)
+ action.setProperty("mode", AlignmentMode.STRETCH)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ self.__stretchAlignAction = action
+ menu.addAction(action)
+ self.__alignmentGroup.addAction(action)
+
+ icon = icons.getQIcon("compare-align-auto")
+ action = qt.QAction(icon, "Auto-alignment of the second image", self)
+ action.setProperty("mode", AlignmentMode.AUTO)
+ action.setIconVisibleInMenu(True)
+ action.setCheckable(True)
+ self.__autoAlignAction = action
+ menu.addAction(action)
+ if sift is None:
+ action.setEnabled(False)
+ action.setToolTip("Sift module is not available")
+ self.__alignmentGroup.addAction(action)
+
+ icon = icons.getQIcon("compare-keypoints")
+ action = qt.QAction(icon, "Display/hide alignment keypoints", self)
+ action.setCheckable(True)
+ action.triggered.connect(self.__keypointVisibilityChanged)
+ self.addAction(action)
+ self.__displayKeypoints = action
+
+ def setCompareWidget(self, widget):
+ """
+ Connect this tool bar to a specific :class:`CompareImages` widget.
+
+ :param Union[None,CompareImages] widget: The widget to connect with.
+ """
+ compareWidget = self.getCompareWidget()
+ if compareWidget is not None:
+ compareWidget.sigConfigurationChanged.disconnect(self.__updateSelectedActions)
+ compareWidget = widget
+ if compareWidget is None:
+ self.__compareWidget = None
+ else:
+ self.__compareWidget = weakref.ref(compareWidget)
+ if compareWidget is not None:
+ widget.sigConfigurationChanged.connect(self.__updateSelectedActions)
+ self.__updateSelectedActions()
+
+ def getCompareWidget(self):
+ """Returns the connected widget.
+
+ :rtype: CompareImages
+ """
+ if self.__compareWidget is None:
+ return None
+ else:
+ return self.__compareWidget()
+
+ def __updateSelectedActions(self):
+ """
+ Update the state of this tool bar according to the state of the
+ connected :class:`CompareImages` widget.
+ """
+ widget = self.getCompareWidget()
+ if widget is None:
+ return
+
+ mode = widget.getVisualizationMode()
+ action = None
+ for a in self.__visualizationGroup.actions():
+ actionMode = a.property("mode")
+ if mode == actionMode:
+ action = a
+ break
+ old = self.__visualizationGroup.blockSignals(True)
+ if action is not None:
+ # Check this action
+ action.setChecked(True)
+ else:
+ action = self.__visualizationGroup.checkedAction()
+ if action is not None:
+ # Uncheck this action
+ action.setChecked(False)
+ self.__updateVisualizationMenu()
+ self.__visualizationGroup.blockSignals(old)
+
+ mode = widget.getAlignmentMode()
+ action = None
+ for a in self.__alignmentGroup.actions():
+ actionMode = a.property("mode")
+ if mode == actionMode:
+ action = a
+ break
+ old = self.__alignmentGroup.blockSignals(True)
+ if action is not None:
+ # Check this action
+ action.setChecked(True)
+ else:
+ action = self.__alignmentGroup.checkedAction()
+ if action is not None:
+ # Uncheck this action
+ action.setChecked(False)
+ self.__updateAlignmentMenu()
+ self.__alignmentGroup.blockSignals(old)
+
+ def __visualizationModeChanged(self, selectedAction):
+ """Called when user requesting changes of the visualization mode.
+ """
+ self.__updateVisualizationMenu()
+ widget = self.getCompareWidget()
+ if widget is not None:
+ mode = selectedAction.property("mode")
+ widget.setVisualizationMode(mode)
+
+ def __updateVisualizationMenu(self):
+ """Update the state of the action containing visualization menu.
+ """
+ selectedAction = self.__visualizationGroup.checkedAction()
+ if selectedAction is not None:
+ self.__visualizationAction.setText(selectedAction.text())
+ self.__visualizationAction.setIcon(selectedAction.icon())
+ self.__visualizationAction.setToolTip(selectedAction.toolTip())
+ else:
+ self.__visualizationAction.setText("")
+ self.__visualizationAction.setIcon(qt.QIcon())
+ self.__visualizationAction.setToolTip("")
+
+ def __alignmentModeChanged(self, selectedAction):
+ """Called when user requesting changes of the alignment mode.
+ """
+ self.__updateAlignmentMenu()
+ widget = self.getCompareWidget()
+ if widget is not None:
+ mode = selectedAction.property("mode")
+ widget.setAlignmentMode(mode)
+
+ def __updateAlignmentMenu(self):
+ """Update the state of the action containing alignment menu.
+ """
+ selectedAction = self.__alignmentGroup.checkedAction()
+ if selectedAction is not None:
+ self.__alignmentAction.setText(selectedAction.text())
+ self.__alignmentAction.setIcon(selectedAction.icon())
+ self.__alignmentAction.setToolTip(selectedAction.toolTip())
+ else:
+ self.__alignmentAction.setText("")
+ self.__alignmentAction.setIcon(qt.QIcon())
+ self.__alignmentAction.setToolTip("")
+
+ def __keypointVisibilityChanged(self):
+ """Called when action managing keypoints visibility changes"""
+ widget = self.getCompareWidget()
+ if widget is not None:
+ keypointsVisible = self.__displayKeypoints.isChecked()
+ widget.setKeypointsVisible(keypointsVisible)
+
+
+class CompareImagesStatusBar(qt.QStatusBar):
+ """StatusBar containing specific information contained in a
+ :class:`CompareImages` widget
+
+ Use :meth:`setCompareWidget` to connect this toolbar to a specific
+ :class:`CompareImages` widget.
+
+ :param Union[qt.QWidget,None] parent: Parent of this widget.
+ """
+ def __init__(self, parent=None):
+ qt.QStatusBar.__init__(self, parent)
+ self.setSizeGripEnabled(False)
+ self.layout().setSpacing(0)
+ self.__compareWidget = None
+ self._label1 = qt.QLabel(self)
+ self._label1.setFrameShape(qt.QFrame.WinPanel)
+ self._label1.setFrameShadow(qt.QFrame.Sunken)
+ self._label2 = qt.QLabel(self)
+ self._label2.setFrameShape(qt.QFrame.WinPanel)
+ self._label2.setFrameShadow(qt.QFrame.Sunken)
+ self._transform = qt.QLabel(self)
+ self._transform.setFrameShape(qt.QFrame.WinPanel)
+ self._transform.setFrameShadow(qt.QFrame.Sunken)
+ self.addWidget(self._label1)
+ self.addWidget(self._label2)
+ self.addWidget(self._transform)
+ self._pos = None
+ self._updateStatusBar()
+
+ def setCompareWidget(self, widget):
+ """
+ Connect this tool bar to a specific :class:`CompareImages` widget.
+
+ :param Union[None,CompareImages] widget: The widget to connect with.
+ """
+ compareWidget = self.getCompareWidget()
+ if compareWidget is not None:
+ compareWidget.getPlot().sigPlotSignal.disconnect(self.__plotSignalReceived)
+ compareWidget.sigConfigurationChanged.disconnect(self.__dataChanged)
+ compareWidget = widget
+ if compareWidget is None:
+ self.__compareWidget = None
+ else:
+ self.__compareWidget = weakref.ref(compareWidget)
+ if compareWidget is not None:
+ compareWidget.getPlot().sigPlotSignal.connect(self.__plotSignalReceived)
+ compareWidget.sigConfigurationChanged.connect(self.__dataChanged)
+
+ def getCompareWidget(self):
+ """Returns the connected widget.
+
+ :rtype: CompareImages
+ """
+ if self.__compareWidget is None:
+ return None
+ else:
+ return self.__compareWidget()
+
+ def __plotSignalReceived(self, event):
+ """Called when old style signals at emmited from the plot."""
+ if event["event"] == "mouseMoved":
+ x, y = event["x"], event["y"]
+ self.__mouseMoved(x, y)
+
+ def __mouseMoved(self, x, y):
+ """Called when mouse move over the plot."""
+ self._pos = x, y
+ self._updateStatusBar()
+
+ def __dataChanged(self):
+ """Called when internal data from the connected widget changes."""
+ self._updateStatusBar()
+
+ def _formatData(self, data):
+ """Format pixel of an image.
+
+ It supports intensity, RGB, and RGBA.
+
+ :param Union[int,float,numpy.ndarray,str]: Value of a pixel
+ :rtype: str
+ """
+ if data is None:
+ return "No data"
+ if isinstance(data, (int, numpy.integer)):
+ return "%d" % data
+ if isinstance(data, (float, numpy.floating)):
+ return "%f" % data
+ if isinstance(data, numpy.ndarray):
+ # RGBA value
+ if data.shape == (3,):
+ return "R:%d G:%d B:%d" % (data[0], data[1], data[2])
+ elif data.shape == (4,):
+ return "R:%d G:%d B:%d A:%d" % (data[0], data[1], data[2], data[3])
+ _logger.debug("Unsupported data format %s. Cast it to string.", type(data))
+ return str(data)
+
+ def _updateStatusBar(self):
+ """Update the content of the status bar"""
+ widget = self.getCompareWidget()
+ if widget is None:
+ self._label1.setText("Image1: NA")
+ self._label2.setText("Image2: NA")
+ self._transform.setVisible(False)
+ else:
+ transform = widget.getTransformation()
+ self._transform.setVisible(transform is not None)
+ if transform is not None:
+ has_notable_translation = not numpy.isclose(transform.tx, 0.0, atol=0.01) \
+ or not numpy.isclose(transform.ty, 0.0, atol=0.01)
+ has_notable_scale = not numpy.isclose(transform.sx, 1.0, atol=0.01) \
+ or not numpy.isclose(transform.sy, 1.0, atol=0.01)
+ has_notable_rotation = not numpy.isclose(transform.rot, 0.0, atol=0.01)
+
+ strings = []
+ if has_notable_translation:
+ strings.append("Translation")
+ if has_notable_scale:
+ strings.append("Scale")
+ if has_notable_rotation:
+ strings.append("Rotation")
+ if strings == []:
+ has_translation = not numpy.isclose(transform.tx, 0.0) \
+ or not numpy.isclose(transform.ty, 0.0)
+ has_scale = not numpy.isclose(transform.sx, 1.0) \
+ or not numpy.isclose(transform.sy, 1.0)
+ has_rotation = not numpy.isclose(transform.rot, 0.0)
+ if has_translation or has_scale or has_rotation:
+ text = "No big changes"
+ else:
+ text = "No changes"
+ else:
+ text = "+".join(strings)
+ self._transform.setText("Align: " + text)
+
+ strings = []
+ if not numpy.isclose(transform.ty, 0.0):
+ strings.append("Translation x: %0.3fpx" % transform.tx)
+ if not numpy.isclose(transform.ty, 0.0):
+ strings.append("Translation y: %0.3fpx" % transform.ty)
+ if not numpy.isclose(transform.sx, 1.0):
+ strings.append("Scale x: %0.3f" % transform.sx)
+ if not numpy.isclose(transform.sy, 1.0):
+ strings.append("Scale y: %0.3f" % transform.sy)
+ if not numpy.isclose(transform.rot, 0.0):
+ strings.append("Rotation: %0.3fdeg" % (transform.rot * 180 / numpy.pi))
+ if strings == []:
+ text = "No transformation"
+ else:
+ text = "\n".join(strings)
+ self._transform.setToolTip(text)
+
+ if self._pos is None:
+ self._label1.setText("Image1: NA")
+ self._label2.setText("Image2: NA")
+ else:
+ data1, data2 = widget.getRawPixelData(self._pos[0], self._pos[1])
+ if isinstance(data1, str):
+ self._label1.setToolTip(data1)
+ text1 = "NA"
+ else:
+ self._label1.setToolTip("")
+ text1 = self._formatData(data1)
+ if isinstance(data2, str):
+ self._label2.setToolTip(data2)
+ text2 = "NA"
+ else:
+ self._label2.setToolTip("")
+ text2 = self._formatData(data2)
+ self._label1.setText("Image1: %s" % text1)
+ self._label2.setText("Image2: %s" % text2)
+
+
+class CompareImages(qt.QMainWindow):
+ """Widget providing tools to compare 2 images.
+
+ .. image:: img/CompareImages.png
+
+ :param Union[qt.QWidget,None] parent: Parent of this widget.
+ :param backend: The backend to use, in:
+ 'matplotlib' (default), 'mpl', 'opengl', 'gl', 'none'
+ or a :class:`BackendBase.BackendBase` class
+ :type backend: str or :class:`BackendBase.BackendBase`
+ """
+
+ VisualizationMode = VisualizationMode
+ """Available visualization modes"""
+
+ AlignmentMode = AlignmentMode
+ """Available alignment modes"""
+
+ sigConfigurationChanged = qt.Signal()
+ """Emitted when the configuration of the widget (visualization mode,
+ alignement mode...) have changed."""
+
+ def __init__(self, parent=None, backend=None):
+ qt.QMainWindow.__init__(self, parent)
+
+ if parent is None:
+ self.setWindowTitle('Compare images')
+ else:
+ self.setWindowFlags(qt.Qt.Widget)
+
+ self.__transformation = None
+ self.__raw1 = None
+ self.__raw2 = None
+ self.__data1 = None
+ self.__data2 = None
+ self.__previousSeparatorPosition = None
+
+ self.__plot = plot.PlotWidget(parent=self, backend=backend)
+ self.__plot.getXAxis().setLabel('Columns')
+ self.__plot.getYAxis().setLabel('Rows')
+ if silx.config.DEFAULT_PLOT_IMAGE_Y_AXIS_ORIENTATION == 'downward':
+ self.__plot.getYAxis().setInverted(True)
+
+ self.__plot.setKeepDataAspectRatio(True)
+ self.__plot.sigPlotSignal.connect(self.__plotSlot)
+ self.__plot.setAxesDisplayed(False)
+
+ self.setCentralWidget(self.__plot)
+
+ legend = VisualizationMode.VERTICAL_LINE.name
+ self.__plot.addXMarker(
+ 0,
+ legend=legend,
+ text='',
+ draggable=True,
+ color='blue',
+ constraint=self.__separatorConstraint)
+ self.__vline = self.__plot._getMarker(legend)
+
+ legend = VisualizationMode.HORIZONTAL_LINE.name
+ self.__plot.addYMarker(
+ 0,
+ legend=legend,
+ text='',
+ draggable=True,
+ color='blue',
+ constraint=self.__separatorConstraint)
+ self.__hline = self.__plot._getMarker(legend)
+
+ # default values
+ self.__visualizationMode = ""
+ self.__alignmentMode = ""
+ self.__keypointsVisible = True
+
+ self.setAlignmentMode(AlignmentMode.ORIGIN)
+ self.setVisualizationMode(VisualizationMode.VERTICAL_LINE)
+ self.setKeypointsVisible(False)
+
+ # Toolbars
+
+ self._createToolBars(self.__plot)
+ if self._interactiveModeToolBar is not None:
+ self.addToolBar(self._interactiveModeToolBar)
+ if self._imageToolBar is not None:
+ self.addToolBar(self._imageToolBar)
+ if self._compareToolBar is not None:
+ self.addToolBar(self._compareToolBar)
+
+ # Statusbar
+
+ self._createStatusBar(self.__plot)
+ if self._statusBar is not None:
+ self.setStatusBar(self._statusBar)
+
+ def _createStatusBar(self, plot):
+ self._statusBar = CompareImagesStatusBar(self)
+ self._statusBar.setCompareWidget(self)
+
+ def _createToolBars(self, plot):
+ """Create tool bars displayed by the widget"""
+ toolBar = tools.InteractiveModeToolBar(parent=self, plot=plot)
+ self._interactiveModeToolBar = toolBar
+ toolBar = tools.ImageToolBar(parent=self, plot=plot)
+ self._imageToolBar = toolBar
+ toolBar = CompareImagesToolBar(self)
+ toolBar.setCompareWidget(self)
+ self._compareToolBar = toolBar
+
+ def getPlot(self):
+ """Returns the plot which is used to display the images.
+
+ :rtype: silx.gui.plot.PlotWidget
+ """
+ return self.__plot
+
+ def getRawPixelData(self, x, y):
+ """Return the raw pixel of each image data from axes positions.
+
+ If the coordinate is outside of the image it returns None element in
+ the tuple.
+
+ The pixel is reach from the raw data image without filter or
+ transformation. But the coordinate x and y are in the reference of the
+ current displayed mode.
+
+ :param float x: X-coordinate of the pixel in the current displayed plot
+ :param float y: Y-coordinate of the pixel in the current displayed plot
+ :return: A tuple of for each images containing pixel information. It
+ could be a scalar value or an array in case of RGB/RGBA informations.
+ It also could be a string containing information is some cases.
+ :rtype: Tuple(Union[int,float,numpy.ndarray,str],Union[int,float,numpy.ndarray,str])
+ """
+ data2 = None
+ alignmentMode = self.__alignmentMode
+ raw1, raw2 = self.__raw1, self.__raw2
+ if alignmentMode == AlignmentMode.ORIGIN:
+ x1 = x
+ y1 = y
+ x2 = x
+ y2 = y
+ elif alignmentMode == AlignmentMode.CENTER:
+ yy = max(raw1.shape[0], raw2.shape[0])
+ xx = max(raw1.shape[1], raw2.shape[1])
+ x1 = x - (xx - raw1.shape[1]) * 0.5
+ x2 = x - (xx - raw2.shape[1]) * 0.5
+ y1 = y - (yy - raw1.shape[0]) * 0.5
+ y2 = y - (yy - raw2.shape[0]) * 0.5
+ elif alignmentMode == AlignmentMode.STRETCH:
+ x1 = x
+ y1 = y
+ x2 = x * raw2.shape[1] / raw1.shape[1]
+ y2 = x * raw2.shape[1] / raw1.shape[1]
+ elif alignmentMode == AlignmentMode.AUTO:
+ x1 = x
+ y1 = y
+ # Not implemented
+ data2 = "Not implemented with sift"
+ else:
+ assert(False)
+
+ x1, y1 = int(x1), int(y1)
+ if raw1 is None or y1 < 0 or y1 >= raw1.shape[0] or x1 < 0 or x1 >= raw1.shape[1]:
+ data1 = None
+ else:
+ data1 = raw1[y1, x1]
+
+ if data2 is None:
+ x2, y2 = int(x2), int(y2)
+ if raw2 is None or y2 < 0 or y2 >= raw2.shape[0] or x2 < 0 or x2 >= raw2.shape[1]:
+ data2 = None
+ else:
+ data2 = raw2[y2, x2]
+
+ return data1, data2
+
+ def setVisualizationMode(self, mode):
+ """Set the visualization mode.
+
+ :param str mode: New visualization to display the image comparison
+ """
+ if self.__visualizationMode == mode:
+ return
+ self.__visualizationMode = mode
+ mode = self.getVisualizationMode()
+ self.__vline.setVisible(mode == VisualizationMode.VERTICAL_LINE)
+ self.__hline.setVisible(mode == VisualizationMode.HORIZONTAL_LINE)
+ self.__updateData()
+ self.sigConfigurationChanged.emit()
+
+ def getVisualizationMode(self):
+ """Returns the current interaction mode."""
+ return self.__visualizationMode
+
+ def setAlignmentMode(self, mode):
+ """Set the alignment mode.
+
+ :param str mode: New alignement to apply to images
+ """
+ if self.__alignmentMode == mode:
+ return
+ self.__alignmentMode = mode
+ self.__updateData()
+ self.sigConfigurationChanged.emit()
+
+ def getAlignmentMode(self):
+ """Returns the current selected alignemnt mode."""
+ return self.__alignmentMode
+
+ def setKeypointsVisible(self, isVisible):
+ """Set keypoints visibility.
+
+ :param bool isVisible: If True, keypoints are displayed (if some)
+ """
+ if self.__keypointsVisible == isVisible:
+ return
+ self.__keypointsVisible = isVisible
+ self.__updateKeyPoints()
+ self.sigConfigurationChanged.emit()
+
+ def __setDefaultAlignmentMode(self):
+ """Reset the alignemnt mode to the default value"""
+ self.setAlignmentMode(AlignmentMode.ORIGIN)
+
+ def __plotSlot(self, event):
+ """Handle events from the plot"""
+ if event['event'] in ('markerMoving', 'markerMoved'):
+ mode = self.getVisualizationMode()
+ legend = mode.name
+ if event['label'] == legend:
+ if mode == VisualizationMode.VERTICAL_LINE:
+ value = int(float(str(event['xdata'])))
+ elif mode == VisualizationMode.HORIZONTAL_LINE:
+ value = int(float(str(event['ydata'])))
+ else:
+ assert(False)
+ if self.__previousSeparatorPosition != value:
+ self.__separatorMoved(value)
+ self.__previousSeparatorPosition = value
+
+ def __separatorConstraint(self, x, y):
+ """Manage contains on the separators to clamp them inside the images."""
+ if self.__data1 is None:
+ return 0, 0
+ x = int(x)
+ if x < 0:
+ x = 0
+ elif x > self.__data1.shape[1]:
+ x = self.__data1.shape[1]
+ y = int(y)
+ if y < 0:
+ y = 0
+ elif y > self.__data1.shape[0]:
+ y = self.__data1.shape[0]
+ return x, y
+
+ def __updateSeparators(self):
+ """Redraw images according to the current state of the separators.
+ """
+ mode = self.getVisualizationMode()
+ if mode == VisualizationMode.VERTICAL_LINE:
+ pos = self.__vline.getXPosition()
+ self.__separatorMoved(pos)
+ self.__previousSeparatorPosition = pos
+ elif mode == VisualizationMode.HORIZONTAL_LINE:
+ pos = self.__hline.getYPosition()
+ self.__separatorMoved(pos)
+ self.__previousSeparatorPosition = pos
+ else:
+ self.__image1.setOrigin((0, 0))
+ self.__image2.setOrigin((0, 0))
+
+ def __separatorMoved(self, pos):
+ """Called when vertical or horizontal separators have moved.
+
+ Update the displayed images.
+ """
+ if self.__data1 is None:
+ return
+
+ mode = self.getVisualizationMode()
+ if mode == VisualizationMode.VERTICAL_LINE:
+ pos = int(pos)
+ if pos <= 0:
+ pos = 0
+ elif pos >= self.__data1.shape[1]:
+ pos = self.__data1.shape[1]
+ data1 = self.__data1[:, 0:pos]
+ data2 = self.__data2[:, pos:]
+ self.__image1.setData(data1, copy=False)
+ self.__image2.setData(data2, copy=False)
+ self.__image2.setOrigin((pos, 0))
+ elif mode == VisualizationMode.HORIZONTAL_LINE:
+ pos = int(pos)
+ if pos <= 0:
+ pos = 0
+ elif pos >= self.__data1.shape[0]:
+ pos = self.__data1.shape[0]
+ data1 = self.__data1[0:pos, :]
+ data2 = self.__data2[pos:, :]
+ self.__image1.setData(data1, copy=False)
+ self.__image2.setData(data2, copy=False)
+ self.__image2.setOrigin((0, pos))
+ else:
+ assert(False)
+
+ def setData(self, image1, image2):
+ """Set images to compare.
+
+ Images can contains floating-point or integer values, or RGB and RGBA
+ values, but should have comparable intensities.
+
+ RGB and RGBA images are provided as an array as `[width,height,channels]`
+ of usigned integer 8-bits or floating-points between 0.0 to 1.0.
+
+ :param numpy.ndarray image1: The first image
+ :param numpy.ndarray image2: The second image
+ """
+ self.__raw1 = image1
+ self.__raw2 = image2
+ self.__updateData()
+ self.__plot.resetZoom()
+
+ def setImage1(self, image1):
+ """Set image1 to be compared.
+
+ Images can contains floating-point or integer values, or RGB and RGBA
+ values, but should have comparable intensities.
+
+ RGB and RGBA images are provided as an array as `[width,height,channels]`
+ of usigned integer 8-bits or floating-points between 0.0 to 1.0.
+
+ :param numpy.ndarray image1: The first image
+ """
+ self.__raw1 = image1
+ self.__updateData()
+ self.__plot.resetZoom()
+
+ def setImage2(self, image2):
+ """Set image2 to be compared.
+
+ Images can contains floating-point or integer values, or RGB and RGBA
+ values, but should have comparable intensities.
+
+ RGB and RGBA images are provided as an array as `[width,height,channels]`
+ of usigned integer 8-bits or floating-points between 0.0 to 1.0.
+
+ :param numpy.ndarray image2: The second image
+ """
+ self.__raw2 = image2
+ self.__updateData()
+ self.__plot.resetZoom()
+
+ def __updateKeyPoints(self):
+ """Update the displayed keypoints using cached keypoints.
+ """
+ if self.__keypointsVisible:
+ data = self.__matching_keypoints
+ else:
+ data = [], [], []
+ self.__plot.addScatter(x=data[0],
+ y=data[1],
+ z=1,
+ value=data[2],
+ legend="keypoints",
+ colormap=Colormap("spring"))
+
+ def __updateData(self):
+ """Compute aligned image when the alignement mode changes.
+
+ This function cache input images which are used when
+ vertical/horizontal separators moves.
+ """
+ raw1, raw2 = self.__raw1, self.__raw2
+ if raw1 is None or raw2 is None:
+ return
+
+ alignmentMode = self.getAlignmentMode()
+ self.__transformation = None
+
+ if alignmentMode == AlignmentMode.ORIGIN:
+ yy = max(raw1.shape[0], raw2.shape[0])
+ xx = max(raw1.shape[1], raw2.shape[1])
+ size = yy, xx
+ data1 = self.__createMarginImage(raw1, size, transparent=True)
+ data2 = self.__createMarginImage(raw2, size, transparent=True)
+ self.__matching_keypoints = [0.0], [0.0], [1.0]
+ elif alignmentMode == AlignmentMode.CENTER:
+ yy = max(raw1.shape[0], raw2.shape[0])
+ xx = max(raw1.shape[1], raw2.shape[1])
+ size = yy, xx
+ data1 = self.__createMarginImage(raw1, size, transparent=True, center=True)
+ data2 = self.__createMarginImage(raw2, size, transparent=True, center=True)
+ self.__matching_keypoints = ([data1.shape[1] // 2],
+ [data1.shape[0] // 2],
+ [1.0])
+ elif alignmentMode == AlignmentMode.STRETCH:
+ data1 = raw1
+ data2 = self.__rescaleImage(raw2, data1.shape)
+ self.__matching_keypoints = ([0, data1.shape[1], data1.shape[1], 0],
+ [0, 0, data1.shape[0], data1.shape[0]],
+ [1.0, 1.0, 1.0, 1.0])
+ elif alignmentMode == AlignmentMode.AUTO:
+ # TODO: sift implementation do not support RGBA images
+ yy = max(raw1.shape[0], raw2.shape[0])
+ xx = max(raw1.shape[1], raw2.shape[1])
+ size = yy, xx
+ data1 = self.__createMarginImage(raw1, size)
+ data2 = self.__createMarginImage(raw2, size)
+ self.__matching_keypoints = [0.0], [0.0], [1.0]
+ try:
+ data1, data2 = self.__createSiftData(data1, data2)
+ if data2 is None:
+ raise ValueError("Unexpected None value")
+ except Exception as e:
+ # TODO: Display it on the GUI
+ _logger.error(e)
+ self.__setDefaultAlignmentMode()
+ return
+ else:
+ assert(False)
+
+ mode = self.getVisualizationMode()
+ if mode == VisualizationMode.COMPOSITE_RED_BLUE_GRAY_NEG:
+ data1 = self.__composeImage(data1, data2, mode)
+ data2 = numpy.empty((0, 0))
+ elif mode == VisualizationMode.COMPOSITE_RED_BLUE_GRAY:
+ data1 = self.__composeImage(data1, data2, mode)
+ data2 = numpy.empty((0, 0))
+ elif mode == VisualizationMode.ONLY_A:
+ data2 = numpy.empty((0, 0))
+ elif mode == VisualizationMode.ONLY_B:
+ data1 = numpy.empty((0, 0))
+
+ self.__data1, self.__data2 = data1, data2
+ self.__plot.addImage(data1, z=0, legend="image1", resetzoom=False)
+ self.__plot.addImage(data2, z=0, legend="image2", resetzoom=False)
+ self.__image1 = self.__plot.getImage("image1")
+ self.__image2 = self.__plot.getImage("image2")
+ self.__updateKeyPoints()
+
+ # Set the separator into the middle
+ if self.__previousSeparatorPosition is None:
+ value = self.__data1.shape[1] // 2
+ self.__vline.setPosition(value, 0)
+ value = self.__data1.shape[0] // 2
+ self.__hline.setPosition(0, value)
+ self.__updateSeparators()
+
+ # Avoid to change the colormap range when the separator is moving
+ # TODO: The colormap histogram will still be wrong
+ mode1 = self.__getImageMode(data1)
+ mode2 = self.__getImageMode(data2)
+ if mode1 == "intensity" and mode1 == mode2:
+ if self.__data1.size == 0:
+ vmin = self.__data2.min()
+ vmax = self.__data2.max()
+ elif self.__data2.size == 0:
+ vmin = self.__data1.min()
+ vmax = self.__data1.max()
+ else:
+ vmin = min(self.__data1.min(), self.__data2.min())
+ vmax = max(self.__data1.max(), self.__data2.max())
+ colormap = Colormap(vmin=vmin, vmax=vmax)
+ self.__image1.setColormap(colormap)
+ self.__image2.setColormap(colormap)
+
+ def __getImageMode(self, image):
+ """Returns a value identifying the way the image is stored in the
+ array.
+
+ :param numpy.ndarray image: Image to check
+ :rtype: str
+ """
+ if len(image.shape) == 2:
+ return "intensity"
+ elif len(image.shape) == 3:
+ if image.shape[2] == 3:
+ return "rgb"
+ elif image.shape[2] == 4:
+ return "rgba"
+ raise TypeError("'image' argument is not an image.")
+
+ def __rescaleImage(self, image, shape):
+ """Rescale an image to the requested shape.
+
+ :rtype: numpy.ndarray
+ """
+ mode = self.__getImageMode(image)
+ if mode == "intensity":
+ data = self.__rescaleArray(image, shape)
+ elif mode == "rgb":
+ data = numpy.empty((shape[0], shape[1], 3), dtype=image.dtype)
+ for c in range(3):
+ data[:, :, c] = self.__rescaleArray(image[:, :, c], shape)
+ elif mode == "rgba":
+ data = numpy.empty((shape[0], shape[1], 4), dtype=image.dtype)
+ for c in range(4):
+ data[:, :, c] = self.__rescaleArray(image[:, :, c], shape)
+ return data
+
+ def __composeImage(self, data1, data2, mode):
+ """Returns an RBG image containing composition of data1 and data2 in 2
+ different channels
+
+ :param numpy.ndarray data1: First image
+ :param numpy.ndarray data1: Second image
+ :param VisualizationMode mode: Composition mode.
+ :rtype: numpy.ndarray
+ """
+ assert(data1.shape[0:2] == data2.shape[0:2])
+ mode1 = self.__getImageMode(data1)
+ if mode1 in ["rgb", "rgba"]:
+ intensity1 = self.__luminosityImage(data1)
+ vmin1, vmax1 = 0.0, 1.0
+ else:
+ intensity1 = data1
+ vmin1, vmax1 = data1.min(), data1.max()
+
+ mode2 = self.__getImageMode(data2)
+ if mode2 in ["rgb", "rgba"]:
+ intensity2 = self.__luminosityImage(data2)
+ vmin2, vmax2 = 0.0, 1.0
+ else:
+ intensity2 = data2
+ vmin2, vmax2 = data2.min(), data2.max()
+
+ vmin, vmax = min(vmin1, vmin2) * 1.0, max(vmax1, vmax2) * 1.0
+ shape = data1.shape
+ result = numpy.empty((shape[0], shape[1], 3), dtype=numpy.uint8)
+ a = (intensity1 - vmin) * (1.0 / (vmax - vmin)) * 255.0
+ b = (intensity2 - vmin) * (1.0 / (vmax - vmin)) * 255.0
+ if mode == VisualizationMode.COMPOSITE_RED_BLUE_GRAY:
+ result[:, :, 0] = a
+ result[:, :, 1] = (a + b) / 2
+ result[:, :, 2] = b
+ elif mode == VisualizationMode.COMPOSITE_RED_BLUE_GRAY_NEG:
+ result[:, :, 0] = 255 - b
+ result[:, :, 1] = 255 - (a + b) / 2
+ result[:, :, 2] = 255 - a
+ return result
+
+ def __luminosityImage(self, image):
+ """Returns the luminosity channel from an RBG(A) image.
+ The alpha channel is ignored.
+
+ :rtype: numpy.ndarray
+ """
+ mode = self.__getImageMode(image)
+ assert(mode in ["rgb", "rgba"])
+ is_uint8 = image.dtype.type == numpy.uint8
+ # luminosity
+ image = 0.21 * image[..., 0] + 0.72 * image[..., 1] + 0.07 * image[..., 2]
+ if is_uint8:
+ image = image / 255.0
+ return image
+
+ def __rescaleArray(self, image, shape):
+ """Rescale a 2D array to the requested shape.
+
+ :rtype: numpy.ndarray
+ """
+ y, x = numpy.ogrid[:shape[0], :shape[1]]
+ y, x = y * 1.0 * (image.shape[0] - 1) / (shape[0] - 1), x * 1.0 * (image.shape[1] - 1) / (shape[1] - 1)
+ b = silx.image.bilinear.BilinearImage(image)
+ # TODO: could be optimized using strides
+ x2d = numpy.zeros_like(y) + x
+ y2d = numpy.zeros_like(x) + y
+ result = b.map_coordinates((y2d, x2d))
+ return result
+
+ def __createMarginImage(self, image, size, transparent=False, center=False):
+ """Returns a new image with margin to respect the requested size.
+
+ :rtype: numpy.ndarray
+ """
+ assert(image.shape[0] <= size[0])
+ assert(image.shape[1] <= size[1])
+ if image.shape == size:
+ return image
+ mode = self.__getImageMode(image)
+
+ if center:
+ pos0 = size[0] // 2 - image.shape[0] // 2
+ pos1 = size[1] // 2 - image.shape[1] // 2
+ else:
+ pos0, pos1 = 0, 0
+
+ if mode == "intensity":
+ data = numpy.zeros(size, dtype=image.dtype)
+ data[pos0:pos0 + image.shape[0], pos1:pos1 + image.shape[1]] = image
+ # TODO: It is maybe possible to put NaN on the margin
+ else:
+ if transparent:
+ data = numpy.zeros((size[0], size[1], 4), dtype=numpy.uint8)
+ else:
+ data = numpy.zeros((size[0], size[1], 3), dtype=numpy.uint8)
+ depth = min(data.shape[2], image.shape[2])
+ data[pos0:pos0 + image.shape[0], pos1:pos1 + image.shape[1], 0:depth] = image[:, :, 0:depth]
+ if transparent and depth == 3:
+ data[pos0:pos0 + image.shape[0], pos1:pos1 + image.shape[1], 3] = 255
+ return data
+
+ def __toAffineTransformation(self, sift_result):
+ """Returns an affine transformation from the sift result.
+
+ :param dict sift_result: Result of sift when using `all_result=True`
+ :rtype: AffineTransformation
+ """
+ offset = sift_result["offset"]
+ matrix = sift_result["matrix"]
+
+ tx = offset[0]
+ ty = offset[1]
+ a = matrix[0, 0]
+ b = matrix[0, 1]
+ c = matrix[1, 0]
+ d = matrix[1, 1]
+ rot = math.atan2(-b, a)
+ sx = (-1.0 if a < 0 else 1.0) * math.sqrt(a**2 + b**2)
+ sy = (-1.0 if d < 0 else 1.0) * math.sqrt(c**2 + d**2)
+ return AffineTransformation(tx, ty, sx, sy, rot)
+
+ def getTransformation(self):
+ """Retuns the affine transformation applied to the second image to align
+ it to the first image.
+
+ This result is only valid for sift alignment.
+
+ :rtype: Union[None,AffineTransformation]
+ """
+ return self.__transformation
+
+ def __createSiftData(self, image, second_image):
+ """Generate key points and aligned images from 2 images.
+
+ If no keypoints matches, unaligned data are anyway returns.
+
+ :rtype: Tuple(numpy.ndarray,numpy.ndarray)
+ """
+ devicetype = "GPU"
+
+ # Compute base image
+ sift_ocl = sift.SiftPlan(template=image, devicetype=devicetype)
+ keypoints = sift_ocl(image)
+
+ # Check image compatibility
+ second_keypoints = sift_ocl(second_image)
+ mp = sift.MatchPlan()
+ match = mp(keypoints, second_keypoints)
+ _logger.info("Number of Keypoints within image 1: %i" % keypoints.size)
+ _logger.info(" within image 2: %i" % second_keypoints.size)
+
+ self.__matching_keypoints = (match[:].x[:, 0],
+ match[:].y[:, 0],
+ match[:].scale[:, 0])
+ matching_keypoints = match.shape[0]
+ _logger.info("Matching keypoints: %i" % matching_keypoints)
+ if matching_keypoints == 0:
+ return image, second_image
+
+ # TODO: Problem here is we have to compute 2 time sift
+ # The first time to extract matching keypoints, second time
+ # to extract the aligned image.
+
+ # Normalize the second image
+ sa = sift.LinearAlign(image, devicetype=devicetype)
+ data1 = image
+ # TODO: Create a sift issue: if data1 is RGB and data2 intensity
+ # it returns None, while extracting manually keypoints (above) works
+ result = sa.align(second_image, return_all=True)
+ data2 = result["result"]
+ self.__transformation = self.__toAffineTransformation(result)
+ return data1, data2
diff --git a/silx/gui/plot/ImageView.py b/silx/gui/plot/ImageView.py
index c28ffca..eba9bc6 100644
--- a/silx/gui/plot/ImageView.py
+++ b/silx/gui/plot/ImageView.py
@@ -315,7 +315,7 @@ class ImageView(PlotWindow):
def _initWidgets(self, backend):
"""Set-up layout and plots."""
- self._histoHPlot = PlotWidget(backend=backend)
+ self._histoHPlot = PlotWidget(backend=backend, parent=self)
self._histoHPlot.getWidgetHandle().setMinimumHeight(
self.HISTOGRAMS_HEIGHT)
self._histoHPlot.getWidgetHandle().setMaximumHeight(
@@ -330,7 +330,7 @@ class ImageView(PlotWindow):
self.getYAxis().sigInvertedChanged.connect(self._updateYAxisInverted)
self.sigActiveImageChanged.connect(self._activeImageChangedSlot)
- self._histoVPlot = PlotWidget(backend=backend)
+ self._histoVPlot = PlotWidget(backend=backend, parent=self)
self._histoVPlot.getWidgetHandle().setMinimumWidth(
self.HISTOGRAMS_HEIGHT)
self._histoVPlot.getWidgetHandle().setMaximumWidth(
@@ -338,14 +338,15 @@ class ImageView(PlotWindow):
self._histoVPlot.setInteractiveMode('zoom')
self._histoVPlot.sigPlotSignal.connect(self._histoVPlotCB)
- self._radarView = RadarView()
+ self._radarView = RadarView(parent=self)
self._radarView.visibleRectDragged.connect(self._radarViewCB)
layout = qt.QGridLayout()
layout.addWidget(self.getWidgetHandle(), 0, 0)
layout.addWidget(self._histoVPlot.getWidgetHandle(), 0, 1)
layout.addWidget(self._histoHPlot.getWidgetHandle(), 1, 0)
- layout.addWidget(self._radarView, 1, 1)
+ layout.addWidget(self._radarView, 1, 1, 1, 2)
+ layout.addWidget(self.getColorBarWidget(), 0, 2)
layout.setColumnMinimumWidth(0, self.IMAGE_MIN_SIZE)
layout.setColumnStretch(0, 1)
diff --git a/silx/gui/plot/LegendSelector.py b/silx/gui/plot/LegendSelector.py
index e9cfd1d..b9d0fd3 100644
--- a/silx/gui/plot/LegendSelector.py
+++ b/silx/gui/plot/LegendSelector.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2018 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
@@ -35,7 +35,10 @@ __data__ = "16/10/2017"
import logging
import weakref
-from .. import qt
+import numpy
+
+from .. import qt, colors
+from . import items
_logger = logging.getLogger(__name__)
@@ -92,10 +95,16 @@ NoLineStyle = (None, 'None', 'none', '', ' ')
class LegendIcon(qt.QWidget):
- """Object displaying a curve linestyle and symbol."""
+ """Object displaying a curve linestyle and symbol.
+
+ :param QWidget parent: See :class:`QWidget`
+ :param Union[~silx.gui.plot.items.Curve,None] curve:
+ Curve with which to synchronize
+ """
- def __init__(self, parent=None):
+ def __init__(self, parent=None, curve=None):
super(LegendIcon, self).__init__(parent)
+ self._curveRef = None
# Visibilities
self.showLine = True
@@ -118,9 +127,85 @@ class LegendIcon(qt.QWidget):
self.setSizePolicy(qt.QSizePolicy.Fixed,
qt.QSizePolicy.Fixed)
+ self.setCurve(curve)
+
def sizeHint(self):
return qt.QSize(50, 15)
+ # Synchronize with a curve
+
+ def getCurve(self):
+ """Returns curve associated to this widget
+
+ :rtype: Union[~silx.gui.plot.items.Curve,None]
+ """
+ return None if self._curveRef is None else self._curveRef()
+
+ def setCurve(self, curve):
+ """Set the curve with which to synchronize this widget.
+
+ :param curve: Union[~silx.gui.plot.items.Curve,None]
+ """
+ assert curve is None or isinstance(curve, items.Curve)
+
+ previousCurve = self.getCurve()
+ if curve == previousCurve:
+ return
+
+ if previousCurve is not None:
+ previousCurve.sigItemChanged.disconnect(self._curveChanged)
+
+ self._curveRef = None if curve is None else weakref.ref(curve)
+
+ if curve is not None:
+ curve.sigItemChanged.connect(self._curveChanged)
+
+ self._update()
+
+ def _update(self):
+ """Update widget according to current curve state.
+ """
+ curve = self.getCurve()
+ if curve is None:
+ _logger.error('Curve no more exists')
+ self.setEnabled(False)
+ return
+
+ style = curve.getCurrentStyle()
+
+ self.setEnabled(curve.isVisible())
+ self.setSymbol(style.getSymbol())
+ self.setLineWidth(style.getLineWidth())
+ self.setLineStyle(style.getLineStyle())
+
+ color = style.getColor()
+ if numpy.array(color, copy=False).ndim != 1:
+ # array of colors, use transparent black
+ color = 0., 0., 0., 0.
+ color = colors.rgba(color) # Make sure it is float in [0, 1]
+ alpha = curve.getAlpha()
+ color = qt.QColor.fromRgbF(
+ color[0], color[1], color[2], color[3] * alpha)
+ self.setLineColor(color)
+ self.setSymbolColor(color)
+ self.update() # TODO this should not be needed
+
+ def _curveChanged(self, event):
+ """Handle update of curve item
+
+ :param event: Kind of change
+ """
+ if event in (items.ItemChangedType.VISIBLE,
+ items.ItemChangedType.SYMBOL,
+ items.ItemChangedType.SYMBOL_SIZE,
+ items.ItemChangedType.LINE_WIDTH,
+ items.ItemChangedType.LINE_STYLE,
+ items.ItemChangedType.COLOR,
+ items.ItemChangedType.ALPHA,
+ items.ItemChangedType.HIGHLIGHTED,
+ items.ItemChangedType.HIGHLIGHTED_STYLE):
+ self._update()
+
# Modify Symbol
def setSymbol(self, symbol):
symbol = str(symbol)
@@ -185,6 +270,14 @@ class LegendIcon(qt.QWidget):
symbolOffset = qt.QPointF(.5 * (ratio - 1.), 0.)
# Determine and scale offset
offset = qt.QPointF(float(rect.left()) / scale, float(rect.top()) / scale)
+
+ # Override color when disabled
+ if self.isEnabled():
+ overrideColor = None
+ else:
+ overrideColor = palette.color(qt.QPalette.Disabled,
+ qt.QPalette.WindowText)
+
# Draw BG rectangle (for debugging)
# bottomRight = qt.QPointF(
# float(rect.right())/scale,
@@ -197,15 +290,15 @@ class LegendIcon(qt.QWidget):
linePath.moveTo(0., 0.5)
linePath.lineTo(ratio, 0.5)
# linePath.lineTo(2.5, 0.5)
+ lineBrush = qt.QBrush(
+ self.lineColor if overrideColor is None else overrideColor)
linePen = qt.QPen(
- qt.QBrush(self.lineColor),
+ lineBrush,
(self.lineWidth / self.height()),
self.lineStyle,
qt.Qt.FlatCap
)
- llist.append((linePath,
- linePen,
- qt.QBrush(self.lineColor)))
+ llist.append((linePath, linePen, lineBrush))
if (self.showSymbol and len(self.symbol) and
self.symbol not in NoSymbols):
# PITFALL ahead: Let this be a warning to others
@@ -214,9 +307,8 @@ class LegendIcon(qt.QWidget):
symbolPath = qt.QPainterPath(Symbols[self.symbol])
symbolPath.translate(symbolOffset)
symbolBrush = qt.QBrush(
- self.symbolColor,
- self.symbolStyle
- )
+ self.symbolColor if overrideColor is None else overrideColor,
+ self.symbolStyle)
symbolPen = qt.QPen(
self.symbolOutlineBrush, # Brush
1. / self.height(), # Width
@@ -1062,18 +1154,18 @@ class LegendsDockWidget(qt.QDockWidget):
for curve in self.plot.getAllCurves(withhidden=True):
legend = curve.getLegend()
# Use active color if curve is active
- if legend == self.plot.getActiveCurve(just_legend=True):
- color = qt.QColor(self.plot.getActiveCurveColor())
- isActive = True
- else:
- color = qt.QColor.fromRgbF(*curve.getColor())
- isActive = False
+ isActive = legend == self.plot.getActiveCurve(just_legend=True)
+ style = curve.getCurrentStyle()
+ color = style.getColor()
+ if numpy.array(color, copy=False).ndim != 1:
+ # array of colors, use transparent black
+ color = 0., 0., 0., 0.
curveInfo = {
- 'color': color,
- 'linewidth': curve.getLineWidth(),
- 'linestyle': curve.getLineStyle(),
- 'symbol': curve.getSymbol(),
+ 'color': qt.QColor.fromRgbF(*color),
+ 'linewidth': style.getLineWidth(),
+ 'linestyle': style.getLineStyle(),
+ 'symbol': style.getSymbol(),
'selected': not self.plot.isCurveHidden(legend),
'active': isActive}
legendList.append((legend, curveInfo))
diff --git a/silx/gui/plot/MaskToolsWidget.py b/silx/gui/plot/MaskToolsWidget.py
index 797068e..990e479 100644
--- a/silx/gui/plot/MaskToolsWidget.py
+++ b/silx/gui/plot/MaskToolsWidget.py
@@ -35,7 +35,7 @@ from __future__ import division
__authors__ = ["T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "24/04/2018"
+__date__ = "29/08/2018"
import os
@@ -43,8 +43,11 @@ import sys
import numpy
import logging
import collections
+import h5py
from silx.image import shapes
+from silx.io.utils import NEXUS_HDF5_EXT, is_dataset
+from silx.gui.dialog.DatasetDialog import DatasetDialog
from ._BaseMaskToolsWidget import BaseMask, BaseMaskToolsWidget, BaseMaskToolsDockWidget
from . import items
@@ -63,6 +66,27 @@ except ImportError:
_logger = logging.getLogger(__name__)
+_HDF5_EXT_STR = ' '.join(['*' + ext for ext in NEXUS_HDF5_EXT])
+
+
+def _selectDataset(filename, mode=DatasetDialog.SaveMode):
+ """Open a dialog to prompt the user to select a dataset in
+ a hdf5 file.
+
+ :param str filename: name of an existing HDF5 file
+ :param mode: DatasetDialog.SaveMode or DatasetDialog.LoadMode
+ :rtype: str
+ :return: Name of selected dataset
+ """
+ dialog = DatasetDialog()
+ dialog.addFile(filename)
+ dialog.setWindowTitle("Select a 2D dataset")
+ dialog.setMode(mode)
+ if not dialog.exec_():
+ return None
+ return dialog.getSelectedDataUrl().data_path()
+
+
class ImageMask(BaseMask):
"""A 2D mask field with update operations.
@@ -89,7 +113,7 @@ class ImageMask(BaseMask):
"""Save current mask in a file
:param str filename: The file where to save to mask
- :param str kind: The kind of file to save in 'edf', 'tif', 'npy',
+ :param str kind: The kind of file to save in 'edf', 'tif', 'npy', 'h5'
or 'msk' (if FabIO is installed)
:raise Exception: Raised if the file writing fail
"""
@@ -107,6 +131,9 @@ class ImageMask(BaseMask):
except IOError:
raise RuntimeError("Mask file can't be written")
+ elif ("." + kind) in NEXUS_HDF5_EXT:
+ self._saveToHdf5(filename, self.getMask(copy=False))
+
elif kind == 'msk':
if fabio is None:
raise ImportError("Fit2d mask files can't be written: Fabio module is not available")
@@ -118,10 +145,41 @@ class ImageMask(BaseMask):
except Exception:
_logger.debug("Backtrace", exc_info=True)
raise RuntimeError("Mask file can't be written")
-
else:
raise ValueError("Format '%s' is not supported" % kind)
+ @staticmethod
+ def _saveToHdf5(filename, mask):
+ """Save a mask array to a HDF5 file.
+
+ :param str filename: name of an existing HDF5 file
+ :param numpy.ndarray mask: Mask array.
+ :returns: True if operation succeeded, False otherwise.
+ """
+ if not os.path.exists(filename):
+ # create new file
+ with h5py.File(filename, "w") as _h5f:
+ pass
+ dataPath = _selectDataset(filename)
+ if dataPath is None:
+ return False
+ with h5py.File(filename, "a") as h5f:
+ existing_ds = h5f.get(dataPath)
+ if existing_ds is not None:
+ reply = qt.QMessageBox.question(
+ None,
+ "Confirm overwrite",
+ "Do you want to overwrite an existing dataset?",
+ qt.QMessageBox.Yes | qt.QMessageBox.No)
+ if reply != qt.QMessageBox.Yes:
+ return False
+ del h5f[dataPath]
+ try:
+ h5f.create_dataset(dataPath, data=mask)
+ except Exception:
+ return False
+ return True
+
# Drawing operations
def updateRectangle(self, level, row, col, height, width, mask=True):
"""Mask/Unmask a rectangle of the given mask level.
@@ -310,8 +368,9 @@ class MaskToolsWidget(BaseMaskToolsWidget):
self._activeImageChanged)
except (RuntimeError, TypeError):
pass
- if not self.browseAction.isChecked():
- self.browseAction.trigger() # Disable drawing tool
+ if self.isMaskInteractionActivated():
+ # Disable drawing tool
+ self.browseAction.trigger()
if self.getSelectionMask(copy=False) is not None:
self.plot.sigActiveImageChanged.connect(
@@ -450,6 +509,10 @@ class MaskToolsWidget(BaseMaskToolsWidget):
_logger.error("Can't load fit2d mask file")
_logger.debug("Backtrace", exc_info=True)
raise e
+ elif ("." + extension) in NEXUS_HDF5_EXT:
+ mask = self._loadFromHdf5(filename)
+ if mask is None:
+ raise IOError("Could not load mask from HDF5 dataset")
else:
msg = "Extension '%s' is not supported."
raise RuntimeError(msg % extension)
@@ -472,6 +535,7 @@ class MaskToolsWidget(BaseMaskToolsWidget):
extensions["EDF files"] = "*.edf"
extensions["TIFF files"] = "*.tif *.tiff"
extensions["NumPy binary files"] = "*.npy"
+ extensions["HDF5 files"] = _HDF5_EXT_STR
# Fit2D mask is displayed anyway fabio is here or not
# to show to the user that the option exists
extensions["Fit2D mask files"] = "*.msk"
@@ -508,15 +572,37 @@ class MaskToolsWidget(BaseMaskToolsWidget):
msg.setText("Cannot load mask from file. " + message)
msg.exec_()
+ @staticmethod
+ def _loadFromHdf5(filename):
+ """Load a mask array from a HDF5 file.
+
+ :param str filename: name of an existing HDF5 file
+ :returns: A mask as a numpy array, or None if the interactive dialog
+ was cancelled
+ """
+ dataPath = _selectDataset(filename, mode=DatasetDialog.LoadMode)
+ if dataPath is None:
+ return None
+
+ with h5py.File(filename, "r") as h5f:
+ dataset = h5f.get(dataPath)
+ if not is_dataset(dataset):
+ raise IOError("%s is not a dataset" % dataPath)
+ mask = dataset[()]
+ return mask
+
def _saveMask(self):
"""Open Save mask dialog"""
dialog = qt.QFileDialog(self)
dialog.setWindowTitle("Save Mask")
+ dialog.setOption(dialog.DontUseNativeDialog)
dialog.setModal(1)
+ hdf5Filter = 'HDF5 (%s)' % _HDF5_EXT_STR
filters = [
'EDF (*.edf)',
'TIFF (*.tif)',
'NumPy binary file (*.npy)',
+ hdf5Filter,
# Fit2D mask is displayed anyway fabio is here or not
# to show to the user that the option exists
'Fit2D mask (*.msk)',
@@ -525,19 +611,41 @@ class MaskToolsWidget(BaseMaskToolsWidget):
dialog.setFileMode(qt.QFileDialog.AnyFile)
dialog.setAcceptMode(qt.QFileDialog.AcceptSave)
dialog.setDirectory(self.maskFileDir)
+
+ def onFilterSelection(filt_):
+ # disable overwrite confirmation for HDF5,
+ # because we append the data to existing files
+ if filt_ == hdf5Filter:
+ dialog.setOption(dialog.DontConfirmOverwrite)
+ else:
+ dialog.setOption(dialog.DontConfirmOverwrite, False)
+
+ dialog.filterSelected.connect(onFilterSelection)
if not dialog.exec_():
dialog.close()
return
- # convert filter name to extension name with the .
- extension = dialog.selectedNameFilter().split()[-1][2:-1]
+ nameFilter = dialog.selectedNameFilter()
filename = dialog.selectedFiles()[0]
dialog.close()
- if not filename.lower().endswith(extension):
- filename += extension
+ if "HDF5" in nameFilter:
+ has_allowed_ext = False
+ for ext in NEXUS_HDF5_EXT:
+ if (len(filename) > len(ext) and
+ filename[-len(ext):].lower() == ext.lower()):
+ has_allowed_ext = True
+ extension = ext
+ if not has_allowed_ext:
+ extension = ".h5"
+ filename += ".h5"
+ else:
+ # convert filter name to extension name with the .
+ extension = nameFilter.split()[-1][2:-1]
+ if not filename.lower().endswith(extension):
+ filename += extension
- if os.path.exists(filename):
+ if os.path.exists(filename) and "HDF5" not in nameFilter:
try:
os.remove(filename)
except IOError:
@@ -552,6 +660,7 @@ class MaskToolsWidget(BaseMaskToolsWidget):
try:
self.save(filename, extension[1:])
except Exception as e:
+ raise
msg = qt.QMessageBox(self)
msg.setIcon(qt.QMessageBox.Critical)
msg.setText("Cannot save file %s\n%s" % (filename, e.args[0]))
diff --git a/silx/gui/plot/PlotToolButtons.py b/silx/gui/plot/PlotToolButtons.py
index e354877..f6291b5 100644
--- a/silx/gui/plot/PlotToolButtons.py
+++ b/silx/gui/plot/PlotToolButtons.py
@@ -240,6 +240,62 @@ class YAxisOriginToolButton(PlotToolButton):
self.setToolTip(toolTip)
+class ProfileOptionToolButton(PlotToolButton):
+ """Button to define option on the profile"""
+ sigMethodChanged = qt.Signal(str)
+
+ def __init__(self, parent=None, plot=None):
+ PlotToolButton.__init__(self, parent=parent, plot=plot)
+
+ self.STATE = {}
+ # is down
+ self.STATE['sum', "icon"] = icons.getQIcon('math-sigma')
+ self.STATE['sum', "state"] = "compute profile sum"
+ self.STATE['sum', "action"] = "compute profile sum"
+ # keep ration
+ self.STATE['mean', "icon"] = icons.getQIcon('math-mean')
+ self.STATE['mean', "state"] = "compute profile mean"
+ self.STATE['mean', "action"] = "compute profile mean"
+
+ sumAction = self._createAction('sum')
+ sumAction.triggered.connect(self.setSum)
+ sumAction.setIconVisibleInMenu(True)
+
+ meanAction = self._createAction('mean')
+ meanAction.triggered.connect(self.setMean)
+ meanAction.setIconVisibleInMenu(True)
+
+ menu = qt.QMenu(self)
+ menu.addAction(sumAction)
+ menu.addAction(meanAction)
+ self.setMenu(menu)
+ self.setPopupMode(qt.QToolButton.InstantPopup)
+ self.setMean()
+
+ def _createAction(self, method):
+ icon = self.STATE[method, "icon"]
+ text = self.STATE[method, "action"]
+ return qt.QAction(icon, text, self)
+
+ def setSum(self):
+ """Configure the plot to use y-axis upward"""
+ self._method = 'sum'
+ self.sigMethodChanged.emit(self._method)
+ self._update()
+
+ def _update(self):
+ icon = self.STATE[self._method, "icon"]
+ toolTip = self.STATE[self._method, "state"]
+ self.setIcon(icon)
+ self.setToolTip(toolTip)
+
+ def setMean(self):
+ """Configure the plot to use y-axis downward"""
+ self._method = 'mean'
+ self.sigMethodChanged.emit(self._method)
+ self._update()
+
+
class ProfileToolButton(PlotToolButton):
"""Button used in Profile3DToolbar to switch between 2D profile
and 1D profile."""
diff --git a/silx/gui/plot/PlotWidget.py b/silx/gui/plot/PlotWidget.py
index 2f7132c..e023a21 100644
--- a/silx/gui/plot/PlotWidget.py
+++ b/silx/gui/plot/PlotWidget.py
@@ -31,7 +31,7 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent"]
__license__ = "MIT"
-__date__ = "14/06/2018"
+__date__ = "12/10/2018"
from collections import OrderedDict, namedtuple
@@ -58,7 +58,8 @@ from .LimitsHistory import LimitsHistory
from . import _utils
from . import items
-from .items.axis import TickMode
+from .items.curve import CurveStyle
+from .items.axis import TickMode # noqa
from .. import qt
from ._utils.panzoom import ViewConstraints
@@ -68,27 +69,7 @@ _logger = logging.getLogger(__name__)
_COLORDICT = colors.COLORDICT
-_COLORLIST = [_COLORDICT['black'],
- _COLORDICT['blue'],
- _COLORDICT['red'],
- _COLORDICT['green'],
- _COLORDICT['pink'],
- _COLORDICT['yellow'],
- _COLORDICT['brown'],
- _COLORDICT['cyan'],
- _COLORDICT['magenta'],
- _COLORDICT['orange'],
- _COLORDICT['violet'],
- # _COLORDICT['bluegreen'],
- _COLORDICT['grey'],
- _COLORDICT['darkBlue'],
- _COLORDICT['darkRed'],
- _COLORDICT['darkGreen'],
- _COLORDICT['darkCyan'],
- _COLORDICT['darkMagenta'],
- _COLORDICT['darkYellow'],
- _COLORDICT['darkBrown']]
-
+_COLORLIST = silx.config.DEFAULT_PLOT_CURVE_COLORS
"""
Object returned when requesting the data range.
@@ -193,6 +174,25 @@ class PlotWidget(qt.QMainWindow):
It provides the source as passed to :meth:`setInteractiveMode`.
"""
+ sigItemAdded = qt.Signal(items.Item)
+ """Signal emitted when an item was just added to the plot
+
+ It provides the added item.
+ """
+
+ sigItemAboutToBeRemoved = qt.Signal(items.Item)
+ """Signal emitted right before an item is removed from the plot.
+
+ It provides the item that will be removed.
+ """
+
+ sigVisibilityChanged = qt.Signal(bool)
+ """Signal emitted when the widget becomes visible (or invisible).
+ This happens when the widget is hidden or shown.
+
+ It provides the visible state.
+ """
+
def __init__(self, parent=None, backend=None,
legends=False, callback=None, **kw):
self._autoreplot = False
@@ -253,8 +253,8 @@ class PlotWidget(qt.QMainWindow):
self._colorIndex = 0
self._styleIndex = 0
- self._activeCurveHandling = True
- self._activeCurveColor = "#000000"
+ self._activeCurveSelectionMode = "atmostone"
+ self._activeCurveStyle = CurveStyle(color='#000000')
self._activeLegend = {'curve': None, 'image': None,
'scatter': None}
@@ -346,8 +346,18 @@ class PlotWidget(qt.QMainWindow):
else:
self._dirty = True
- if self._autoreplot and not wasDirty:
+ if self._autoreplot and not wasDirty and self.isVisible():
+ self._backend.postRedisplay()
+
+ def showEvent(self, event):
+ if self._autoreplot and self._dirty:
self._backend.postRedisplay()
+ super(PlotWidget, self).showEvent(event)
+ self.sigVisibilityChanged.emit(True)
+
+ def hideEvent(self, event):
+ super(PlotWidget, self).hideEvent(event)
+ self.sigVisibilityChanged.emit(False)
def _invalidateDataRange(self):
"""
@@ -447,6 +457,7 @@ class PlotWidget(qt.QMainWindow):
self._invalidateDataRange() # TODO handle this automatically
self._notifyContentChanged(item)
+ self.sigItemAdded.emit(item)
def _notifyContentChanged(self, item):
legend, kind = self._itemKey(item)
@@ -461,6 +472,8 @@ class PlotWidget(qt.QMainWindow):
if key not in self._content:
raise RuntimeError('Item not in the plot')
+ self.sigItemAboutToBeRemoved.emit(item)
+
legend, kind = key
if kind in self._ACTIVE_ITEM_KINDS:
@@ -721,6 +734,12 @@ class PlotWidget(qt.QMainWindow):
if wasActive:
self.setActiveCurve(curve.getLegend())
+ elif self.getActiveCurveSelectionMode() == "legacy":
+ if self.getActiveCurve(just_legend=True) is None:
+ if len(self.getAllCurves(just_legend=True,
+ withhidden=False)) == 1:
+ if curve.isVisible():
+ self.setActiveCurve(curve.getLegend())
if resetzoom:
# We ask for a zoom reset in order to handle the plot scaling
@@ -840,10 +859,9 @@ class PlotWidget(qt.QMainWindow):
(default: False)
:param bool draggable: Indicate if the image can be moved.
(default: False)
- :param colormap: Description of the :class:`.Colormap` to use
- (or None).
- This is ignored if data is a RGB(A) image.
- :type colormap: Union[silx.gui.colors.Colormap, dict]
+ :param colormap: Colormap object to use (or None).
+ This is ignored if data is a RGB(A) image.
+ :type colormap: Union[~silx.gui.colors.Colormap, dict]
:param pixmap: Pixmap representation of the data (if any)
:type pixmap: (nrows, ncolumns, RGBA) ubyte array or None (default)
:param str xlabel: X axis label to show when this curve is active,
@@ -986,8 +1004,8 @@ class PlotWidget(qt.QMainWindow):
:param numpy.ndarray y: The data corresponding to the y coordinates
:param numpy.ndarray value: The data value associated with each point
:param str legend: The legend to be associated to the scatter (or None)
- :param silx.gui.colors.Colormap colormap:
- The :class:`.Colormap`. to be used for the scatter (or None)
+ :param ~silx.gui.colors.Colormap colormap:
+ Colormap object to be used for the scatter (or None)
:param info: User-defined information associated to the curve
:param str symbol: Symbol to be drawn at each (x, y) position::
@@ -1560,26 +1578,59 @@ class PlotWidget(qt.QMainWindow):
# Active Curve/Image
def isActiveCurveHandling(self):
- """Returns True if active curve selection is enabled."""
- return self._activeCurveHandling
+ """Returns True if active curve selection is enabled.
+
+ :rtype: bool
+ """
+ return self.getActiveCurveSelectionMode() != 'none'
def setActiveCurveHandling(self, flag=True):
"""Enable/Disable active curve selection.
- :param bool flag: True (the default) to enable active curve selection.
+ :param bool flag: True to enable 'atmostone' active curve selection,
+ False to disable active curve selection.
+ """
+ self.setActiveCurveSelectionMode('atmostone' if flag else 'none')
+
+ def getActiveCurveStyle(self):
+ """Returns the current style applied to active curve
+
+ :rtype: CurveStyle
"""
- if not flag:
- self.setActiveCurve(None) # Reset active curve
+ return self._activeCurveStyle
- self._activeCurveHandling = bool(flag)
+ def setActiveCurveStyle(self,
+ color=None,
+ linewidth=None,
+ linestyle=None,
+ symbol=None,
+ symbolsize=None):
+ """Set the style of active curve
+ :param color: Color
+ :param Union[str,None] linestyle: Style of the line
+ :param Union[float,None] linewidth: Width of the line
+ :param Union[str,None] symbol: Symbol of the markers
+ :param Union[float,None] symbolsize: Size of the symbols
+ """
+ self._activeCurveStyle = CurveStyle(color=color,
+ linewidth=linewidth,
+ linestyle=linestyle,
+ symbol=symbol,
+ symbolsize=symbolsize)
+ curve = self.getActiveCurve()
+ if curve is not None:
+ curve.setHighlightedStyle(self.getActiveCurveStyle())
+
+ @deprecated(replacement="getActiveCurveStyle", since_version="0.9")
def getActiveCurveColor(self):
"""Get the color used to display the currently active curve.
See :meth:`setActiveCurveColor`.
"""
- return self._activeCurveColor
+ return self._activeCurveStyle.getColor()
+ @deprecated(replacement="setActiveCurveStyle", since_version="0.9")
def setActiveCurveColor(self, color="#000000"):
"""Set the color to use to display the currently active curve.
@@ -1590,7 +1641,7 @@ class PlotWidget(qt.QMainWindow):
color = "black"
if color in self.colorDict:
color = self.colorDict[color]
- self._activeCurveColor = color
+ self.setActiveCurveStyle(color=color)
def getActiveCurve(self, just_legend=False):
"""Return the currently active curve.
@@ -1621,9 +1672,43 @@ class PlotWidget(qt.QMainWindow):
if not self.isActiveCurveHandling():
return
+ if legend is None and self.getActiveCurveSelectionMode() == "legacy":
+ _logger.info(
+ 'setActiveCurve(None) ignored due to active curve selection mode')
+ return
return self._setActiveItem(kind='curve', legend=legend)
+ def setActiveCurveSelectionMode(self, mode):
+ """Sets the current selection mode.
+
+ :param str mode: The active curve selection mode to use.
+ It can be: 'legacy', 'atmostone' or 'none'.
+ """
+ assert mode in ('legacy', 'atmostone', 'none')
+
+ if mode != self._activeCurveSelectionMode:
+ self._activeCurveSelectionMode = mode
+ if mode == 'none': # reset active curve
+ self._setActiveItem(kind='curve', legend=None)
+
+ elif mode == 'legacy' and self.getActiveCurve() is None:
+ # Select an active curve
+ curves = self.getAllCurves(just_legend=False,
+ withhidden=False)
+ if len(curves) == 1:
+ if curves[0].isVisible():
+ self.setActiveCurve(curves[0].getLegend())
+
+ def getActiveCurveSelectionMode(self):
+ """Returns the current selection mode.
+
+ It can be "atmostone", "legacy" or "none".
+
+ :rtype: str
+ """
+ return self._activeCurveSelectionMode
+
def getActiveImage(self, just_legend=False):
"""Returns the currently active image.
@@ -1707,7 +1792,7 @@ class PlotWidget(qt.QMainWindow):
# Curve specific: handle highlight
if kind == 'curve':
- item.setHighlightedColor(self.getActiveCurveColor())
+ item.setHighlightedStyle(self.getActiveCurveStyle())
item.setHighlighted(True)
if isinstance(item, items.LabelsMixIn):
@@ -1761,6 +1846,13 @@ class PlotWidget(qt.QMainWindow):
# Getters
+ def getItems(self):
+ """Returns the list of items in the plot
+
+ :rtype: List[silx.gui.plot.items.Item]
+ """
+ return tuple(self._content.values())
+
def getAllCurves(self, just_legend=False, withhidden=False):
"""Returns all curves legend or info and data.
@@ -2273,8 +2365,9 @@ class PlotWidget(qt.QMainWindow):
curve.setLineStyle(linestyle)
def getDefaultColormap(self):
- """Return the default :class:`.Colormap` used by :meth:`addImage`.
+ """Return the default colormap used by :meth:`addImage`.
+ :rtype: ~silx.gui.colors.Colormap
"""
return self._defaultColormap
@@ -2286,9 +2379,9 @@ class PlotWidget(qt.QMainWindow):
It only affects future calls to :meth:`addImage` without the colormap
parameter.
- :param silx.gui.colors.Colormap colormap:
+ :param ~silx.gui.colors.Colormap colormap:
The description of the default colormap, or
- None to set the :class:`.Colormap` to a linear
+ None to set the colormap to a linear
autoscale gray colormap.
"""
if colormap is None:
@@ -2328,7 +2421,7 @@ class PlotWidget(qt.QMainWindow):
self._styleIndex = (self._styleIndex + 1) % len(self._styleList)
# If color is the one of active curve, take the next one
- if color == self.getActiveCurveColor():
+ if colors.rgba(color) == self.getActiveCurveStyle().getColor():
color, style = self._getColorAndStyle()
if not self._plotLines:
diff --git a/silx/gui/plot/PlotWindow.py b/silx/gui/plot/PlotWindow.py
index 459ffdc..23ea399 100644
--- a/silx/gui/plot/PlotWindow.py
+++ b/silx/gui/plot/PlotWindow.py
@@ -29,7 +29,7 @@ The :class:`PlotWindow` is a subclass of :class:`.PlotWidget`.
__authors__ = ["V.A. Sole", "T. Vincent"]
__license__ = "MIT"
-__date__ = "05/06/2018"
+__date__ = "12/10/2018"
import collections
import logging
@@ -439,7 +439,7 @@ class PlotWindow(PlotWidget):
# The first created dock widget must be added to a Widget area
width = self.centralWidget().width()
height = self.centralWidget().height()
- if width > (2.0 * height) and width > 1000:
+ if width > (1.25 * height):
area = qt.Qt.RightDockWidgetArea
else:
area = qt.Qt.BottomDockWidgetArea
@@ -520,6 +520,7 @@ class PlotWindow(PlotWidget):
dockWidget.setWindowTitle("Curves stats")
dockWidget.layout().setContentsMargins(0, 0, 0, 0)
self._statsWidget = BasicStatsWidget(parent=self, plot=self)
+ self._statsWidget.sigVisibilityChanged.connect(self.getStatsAction().setChecked)
dockWidget.setWidget(self._statsWidget)
dockWidget.hide()
self.addTabbedDockWidget(dockWidget)
diff --git a/silx/gui/plot/PrintPreviewToolButton.py b/silx/gui/plot/PrintPreviewToolButton.py
index c5479b8..b48505d 100644
--- a/silx/gui/plot/PrintPreviewToolButton.py
+++ b/silx/gui/plot/PrintPreviewToolButton.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 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
@@ -175,7 +175,8 @@ class PrintPreviewToolButton(qt.QToolButton):
def _plotToPrintPreview(self):
"""Grab the plot widget and send it to the print preview dialog.
Make sure the print preview dialog is shown and raised."""
- self.printPreviewDialog.ensurePrinterIsSet()
+ if not self.printPreviewDialog.ensurePrinterIsSet():
+ return
if qt.HAS_SVG:
svgRenderer, viewBox = self._getSvgRendererAndViewbox()
diff --git a/silx/gui/plot/Profile.py b/silx/gui/plot/Profile.py
index 5a733fe..182cf60 100644
--- a/silx/gui/plot/Profile.py
+++ b/silx/gui/plot/Profile.py
@@ -28,7 +28,7 @@ and stacks of images"""
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel", "H. Payno"]
__license__ = "MIT"
-__date__ = "24/04/2018"
+__date__ = "24/07/2018"
import weakref
@@ -42,13 +42,13 @@ from .. import qt
from . import items
from ..colors import cursorColorForColormap
from . import actions
-from .PlotToolButtons import ProfileToolButton
+from .PlotToolButtons import ProfileToolButton, ProfileOptionToolButton
from .ProfileMainWindow import ProfileMainWindow
from silx.utils.deprecation import deprecated
-def _alignedFullProfile(data, origin, scale, position, roiWidth, axis):
+def _alignedFullProfile(data, origin, scale, position, roiWidth, axis, method):
"""Get a profile along one axis on a stack of images
:param numpy.ndarray data: 3D volume (stack of 2D images)
@@ -59,10 +59,12 @@ def _alignedFullProfile(data, origin, scale, position, roiWidth, axis):
on the axis orthogonal to the profile direction.
:param int roiWidth: Width of the profile in image pixels.
:param int axis: 0 for horizontal profile, 1 for vertical.
+ :param str method: method to compute the profile. Can be 'mean' or 'sum'
:return: profile image + effective ROI area corners in plot coords
"""
assert axis in (0, 1)
assert len(data.shape) == 3
+ assert method in ('mean', 'sum')
# Convert from plot to image coords
imgPos = int((position - origin[1 - axis]) / scale[1 - axis])
@@ -81,8 +83,13 @@ def _alignedFullProfile(data, origin, scale, position, roiWidth, axis):
end = start + roiWidth
if start < height and end > 0:
- profile = data[:, max(0, start):min(end, height), :].mean(
- axis=1, dtype=numpy.float32)
+ if method == 'mean':
+ _fct = numpy.mean
+ elif method == 'sum':
+ _fct = numpy.sum
+ else:
+ raise ValueError('method not managed')
+ profile = _fct(data[:, max(0, start):min(end, height), :], axis=1).astype(numpy.float32)
else:
profile = numpy.zeros((nimages, width), dtype=numpy.float32)
@@ -102,7 +109,7 @@ def _alignedFullProfile(data, origin, scale, position, roiWidth, axis):
return profile, area
-def _alignedPartialProfile(data, rowRange, colRange, axis):
+def _alignedPartialProfile(data, rowRange, colRange, axis, method):
"""Mean of a rectangular region (ROI) of a stack of images
along a given axis.
@@ -117,6 +124,7 @@ def _alignedPartialProfile(data, rowRange, colRange, axis):
:param int axis: The axis along which to take the profile of the ROI.
0: Sum rows along columns.
1: Sum columns along rows.
+ :param str method: method to compute the profile. Can be 'mean' or 'sum'
:return: Profile image along the ROI as the mean of the intersection
of the ROI and the image.
"""
@@ -124,6 +132,7 @@ def _alignedPartialProfile(data, rowRange, colRange, axis):
assert len(data.shape) == 3
assert rowRange[0] < rowRange[1]
assert colRange[0] < colRange[1]
+ assert method in ('mean', 'sum')
nimages, height, width = data.shape
@@ -138,8 +147,15 @@ def _alignedPartialProfile(data, rowRange, colRange, axis):
colStart = min(max(0, colRange[0]), width)
colEnd = min(max(0, colRange[1]), width)
- imgProfile = numpy.mean(data[:, rowStart:rowEnd, colStart:colEnd],
- axis=axis + 1, dtype=numpy.float32)
+ if method == 'mean':
+ _fct = numpy.mean
+ elif method == 'sum':
+ _fct = numpy.sum
+ else:
+ raise ValueError('method not managed')
+
+ imgProfile = _fct(data[:, rowStart:rowEnd, colStart:colEnd], axis=axis + 1,
+ dtype=numpy.float32)
# Profile including out of bound area
profile = numpy.zeros((nimages, profileLength), dtype=numpy.float32)
@@ -151,7 +167,7 @@ def _alignedPartialProfile(data, rowRange, colRange, axis):
return profile
-def createProfile(roiInfo, currentData, origin, scale, lineWidth):
+def createProfile(roiInfo, currentData, origin, scale, lineWidth, method):
"""Create the profile line for the the given image.
:param roiInfo: information about the ROI: start point, end point and
@@ -163,6 +179,7 @@ def createProfile(roiInfo, currentData, origin, scale, lineWidth):
:param scale: (sx, sy) the scale to use
:type scale: 2-tuple of float
:param int lineWidth: width of the profile line
+ :param str method: method to compute the profile. Can be 'mean' or 'sum'
:return: `profile, area, profileName, xLabel`, where:
- profile is a 2D array of the profiles of the stack of images.
For a single image, the profile is a curve, so this parameter
@@ -192,7 +209,8 @@ def createProfile(roiInfo, currentData, origin, scale, lineWidth):
profile, area = _alignedFullProfile(currentData3D,
origin, scale,
roiStart[1], roiWidth,
- axis=0)
+ axis=0,
+ method=method)
yMin, yMax = min(area[1]), max(area[1]) - 1
if roiWidth <= 1:
@@ -205,7 +223,8 @@ def createProfile(roiInfo, currentData, origin, scale, lineWidth):
profile, area = _alignedFullProfile(currentData3D,
origin, scale,
roiStart[0], roiWidth,
- axis=1)
+ axis=1,
+ method=method)
xMin, xMax = min(area[0]), max(area[0]) - 1
if roiWidth <= 1:
@@ -240,7 +259,8 @@ def createProfile(roiInfo, currentData, origin, scale, lineWidth):
colRange = startPt[1], endPt[1] + 1
profile = _alignedPartialProfile(currentData3D,
rowRange, colRange,
- axis=0)
+ axis=0,
+ method=method)
else: # Column aligned
rowRange = startPt[0], endPt[0] + 1
@@ -248,7 +268,8 @@ def createProfile(roiInfo, currentData, origin, scale, lineWidth):
int(startPt[1] + 0.5 + 0.5 * roiWidth))
profile = _alignedPartialProfile(currentData3D,
rowRange, colRange,
- axis=1)
+ axis=1,
+ method=method)
# Convert ranges to plot coords to draw ROI area
area = (
@@ -273,7 +294,8 @@ def createProfile(roiInfo, currentData, origin, scale, lineWidth):
profile.append(bilinear.profile_line(
(startPt[0] - 0.5, startPt[1] - 0.5),
(endPt[0] - 0.5, endPt[1] - 0.5),
- roiWidth))
+ roiWidth,
+ method=method))
profile = numpy.array(profile)
# Extend ROI with half a pixel on each end, and
@@ -346,6 +368,8 @@ class ProfileToolBar(qt.QToolBar):
_POLYGON_LEGEND = '__ProfileToolBar_ROI_Polygon'
+ DEFAULT_PROF_METHOD = 'mean'
+
def __init__(self, parent=None, plot=None, profileWindow=None,
title='Profile Selection'):
super(ProfileToolBar, self).__init__(title, parent)
@@ -354,6 +378,7 @@ class ProfileToolBar(qt.QToolBar):
self._overlayColor = None
self._defaultOverlayColor = 'red' # update when active image change
+ self._method = self.DEFAULT_PROF_METHOD
self._roiInfo = None # Store start and end points and type of ROI
@@ -426,12 +451,17 @@ class ProfileToolBar(qt.QToolBar):
# Add width spin box to toolbar
self.addWidget(qt.QLabel('W:'))
self.lineWidthSpinBox = qt.QSpinBox(self)
- self.lineWidthSpinBox.setRange(0, 1000)
+ self.lineWidthSpinBox.setRange(1, 1000)
self.lineWidthSpinBox.setValue(1)
self.lineWidthSpinBox.valueChanged[int].connect(
self._lineWidthSpinBoxValueChangedSlot)
self.addWidget(self.lineWidthSpinBox)
+ self.methodsButton = ProfileOptionToolButton(parent=self, plot=self)
+ self.addWidget(self.methodsButton)
+ # TODO: add connection with the signal
+ self.methodsButton.sigMethodChanged.connect(self.setProfileMethod)
+
self.plot.sigInteractiveModeChanged.connect(
self._interactiveModeChanged)
@@ -602,9 +632,10 @@ class ProfileToolBar(qt.QToolBar):
origin=image.getOrigin(),
scale=image.getScale(),
colormap=None, # Not used for 2D data
- z=image.getZValue())
+ z=image.getZValue(),
+ method=self.getProfileMethod())
- def _createProfile(self, currentData, origin, scale, colormap, z):
+ def _createProfile(self, currentData, origin, scale, colormap, z, method):
"""Create the profile line for the the given image.
:param numpy.ndarray currentData: the image or the stack of images
@@ -624,7 +655,8 @@ class ProfileToolBar(qt.QToolBar):
currentData=currentData,
origin=origin,
scale=scale,
- lineWidth=self.lineWidthSpinBox.value())
+ lineWidth=self.lineWidthSpinBox.value(),
+ method=method)
self.getProfilePlot().setGraphTitle(profileName)
@@ -692,6 +724,14 @@ class ProfileToolBar(qt.QToolBar):
if self.getProfileMainWindow() is not None:
self.getProfileMainWindow().hide()
+ def setProfileMethod(self, method):
+ assert method in ('sum', 'mean')
+ self._method = method
+ self.updateProfile()
+
+ def getProfileMethod(self):
+ return self._method
+
class Profile3DToolBar(ProfileToolBar):
def __init__(self, parent=None, stackview=None,
@@ -720,6 +760,7 @@ class Profile3DToolBar(ProfileToolBar):
# create the 3D toolbar
self._profileType = None
self._setProfileType(2)
+ self._method3D = 'sum'
def _setProfileType(self, dimensions):
"""Set the profile type: "1D" for a curve (profile on a single image)
@@ -750,12 +791,20 @@ class Profile3DToolBar(ProfileToolBar):
self.getProfilePlot().setGraphTitle('')
self.getProfilePlot().getXAxis().setLabel('X')
self.getProfilePlot().getYAxis().setLabel('Y')
-
self._createProfile(currentData=stackData[0],
origin=stackData[1]['origin'],
scale=stackData[1]['scale'],
colormap=stackData[1]['colormap'],
- z=stackData[1]['z'])
+ z=stackData[1]['z'],
+ method=self.getProfileMethod())
else:
raise ValueError(
"Profile type must be 1D or 2D, not %s" % self._profileType)
+
+ def setProfileMethod(self, method):
+ assert method in ('sum', 'mean')
+ self._method3D = method
+ self.updateProfile()
+
+ def getProfileMethod(self):
+ return self._method3D
diff --git a/silx/gui/plot/ProfileMainWindow.py b/silx/gui/plot/ProfileMainWindow.py
index 3738511..caa076c 100644
--- a/silx/gui/plot/ProfileMainWindow.py
+++ b/silx/gui/plot/ProfileMainWindow.py
@@ -47,6 +47,10 @@ class ProfileMainWindow(qt.QMainWindow):
"""Emitted by :meth:`closeEvent` (e.g. when the window is closed
through the window manager's close icon)."""
+ sigProfileMethodChanged = qt.Signal(str)
+ """Emitted when the method to compute the profile changed (for now can be
+ sum or mean)"""
+
def __init__(self, parent=None):
qt.QMainWindow.__init__(self, parent=parent)
@@ -57,6 +61,7 @@ class ProfileMainWindow(qt.QMainWindow):
# by default, profile is assumed to be a 1D curve
self._profileType = None
self.setProfileType("1D")
+ self.setProfileMethod('sum')
def setProfileType(self, profileType):
"""Set which profile plot widget (1D or 2D) is to be used
@@ -67,7 +72,6 @@ class ProfileMainWindow(qt.QMainWindow):
# import here to avoid circular import
from .PlotWindow import Plot1D, Plot2D # noqa
self._profileType = profileType
-
if self._profileType == "1D":
if self._plot2D is not None:
self._plot2D.setParent(None) # necessary to avoid widget destruction
@@ -99,3 +103,13 @@ class ProfileMainWindow(qt.QMainWindow):
def closeEvent(self, qCloseEvent):
self.sigClose.emit()
qCloseEvent.accept()
+
+ def setProfileMethod(self, method):
+ """
+
+ :param str method: method to manage the 'width' in the profile
+ (computing mean or sum).
+ """
+ assert method in ('sum', 'mean')
+ self._method = method
+ self.sigProfileMethodChanged.emit(self._method)
diff --git a/silx/gui/plot/ScatterMaskToolsWidget.py b/silx/gui/plot/ScatterMaskToolsWidget.py
index 2a10f6d..de645be 100644
--- a/silx/gui/plot/ScatterMaskToolsWidget.py
+++ b/silx/gui/plot/ScatterMaskToolsWidget.py
@@ -207,6 +207,13 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
The mask can be cropped or padded to fit active scatter,
the returned shape is that of the scatter data.
"""
+ if self._data_scatter is None:
+ # this can happen if the mask tools widget has never been shown
+ self._data_scatter = self.plot._getActiveItem(kind="scatter")
+ if self._data_scatter is None:
+ return None
+ self._adjustColorAndBrushSize(self._data_scatter)
+
if mask is None:
self.resetSelectionMask()
return self._data_scatter.getXData(copy=False).shape
@@ -261,6 +268,26 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
self.plot.sigActiveScatterChanged.connect(
self._activeScatterChangedAfterCare)
+ def _adjustColorAndBrushSize(self, activeScatter):
+ colormap = activeScatter.getColormap()
+ self._defaultOverlayColor = rgba(cursorColorForColormap(colormap['name']))
+ self._setMaskColors(self.levelSpinBox.value(),
+ self.transparencySlider.value() /
+ self.transparencySlider.maximum())
+ self._z = activeScatter.getZValue() + 1
+ self._data_scatter = activeScatter
+
+ # Adjust brush size to data range
+ xData = self._data_scatter.getXData(copy=False)
+ yData = self._data_scatter.getYData(copy=False)
+ # Adjust brush size to data range
+ if xData.size > 0 and yData.size > 0:
+ xMin, xMax = min_max(xData)
+ yMin, yMax = min_max(yData)
+ self._data_extent = max(xMax - xMin, yMax - yMin)
+ else:
+ self._data_extent = None
+
def _activeScatterChangedAfterCare(self, previous, next):
"""Check synchro of active scatter and mask when mask widget is hidden.
@@ -278,19 +305,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
self._data_scatter = None
else:
- colormap = activeScatter.getColormap()
- self._defaultOverlayColor = rgba(cursorColorForColormap(colormap['name']))
- self._setMaskColors(self.levelSpinBox.value(),
- self.transparencySlider.value() /
- self.transparencySlider.maximum())
-
- self._z = activeScatter.getZValue() + 1
- self._data_scatter = activeScatter
-
- # Adjust brush size to data range
- xMin, xMax = min_max(self._data_scatter.getXData(copy=False))
- yMin, yMax = min_max(self._data_scatter.getYData(copy=False))
- self._data_extent = max(xMax - xMin, yMax - yMin)
+ self._adjustColorAndBrushSize(activeScatter)
if self._data_scatter.getXData(copy=False).shape != self._mask.getMask(copy=False).shape:
# scatter has not the same size, remove mask and stop listening
@@ -322,25 +337,7 @@ class ScatterMaskToolsWidget(BaseMaskToolsWidget):
else: # There is an active scatter
self.setEnabled(True)
-
- colormap = activeScatter.getColormap()
- self._defaultOverlayColor = rgba(cursorColorForColormap(colormap['name']))
- self._setMaskColors(self.levelSpinBox.value(),
- self.transparencySlider.value() /
- self.transparencySlider.maximum())
-
- self._z = activeScatter.getZValue() + 1
- self._data_scatter = activeScatter
-
- # Adjust brush size to data range
- xData = self._data_scatter.getXData(copy=False)
- yData = self._data_scatter.getYData(copy=False)
- if xData.size > 0 and yData.size > 0:
- xMin, xMax = min_max(xData)
- yMin, yMax = min_max(yData)
- self._data_extent = max(xMax - xMin, yMax - yMin)
- else:
- self._data_extent = None
+ self._adjustColorAndBrushSize(activeScatter)
self._mask.setDataItem(self._data_scatter)
if self._data_scatter.getXData(copy=False).shape != self._mask.getMask(copy=False).shape:
diff --git a/silx/gui/plot/ScatterView.py b/silx/gui/plot/ScatterView.py
index f830cb3..ae79cf9 100644
--- a/silx/gui/plot/ScatterView.py
+++ b/silx/gui/plot/ScatterView.py
@@ -268,16 +268,16 @@ class ScatterView(qt.QMainWindow):
self.getPlotWidget().setDefaultColormap(colormap)
def getColormap(self):
- """Return the :class:`.Colormap` in use.
+ """Return the colormap object in use.
:return: Colormap currently in use
:rtype: ~silx.gui.colors.Colormap
"""
- self.getScatterItem().getColormap()
+ return self.getScatterItem().getColormap()
# Control displayed scatter plot
- def setData(self, x, y, value, xerror=None, yerror=None, copy=True):
+ def setData(self, x, y, value, xerror=None, yerror=None, alpha=None, copy=True):
"""Set the data of the scatter plot.
To reset the scatter plot, set x, y and value to None.
@@ -295,6 +295,8 @@ class ScatterView(qt.QMainWindow):
:param yerror: Values with the uncertainties on the y values
:type yerror: A float, or a numpy.ndarray of float32. See xerror.
+ :param alpha: Values with the transparency (between 0 and 1)
+ :type alpha: A float, or a numpy.ndarray of float32
:param bool copy: True make a copy of the data (default),
False to use provided arrays.
"""
@@ -303,7 +305,7 @@ class ScatterView(qt.QMainWindow):
value = () if value is None else value
self.getScatterItem().setData(
- x=x, y=y, value=value, xerror=xerror, yerror=yerror, copy=copy)
+ x=x, y=y, value=value, xerror=xerror, yerror=yerror, alpha=alpha, copy=copy)
def getData(self, *args, **kwargs):
return self.getScatterItem().getData(*args, **kwargs)
diff --git a/silx/gui/plot/StackView.py b/silx/gui/plot/StackView.py
index d1e8e3c..72b6cd4 100644
--- a/silx/gui/plot/StackView.py
+++ b/silx/gui/plot/StackView.py
@@ -69,7 +69,7 @@ Example::
__authors__ = ["P. Knobel", "H. Payno"]
__license__ = "MIT"
-__date__ = "26/04/2018"
+__date__ = "10/10/2018"
import numpy
import logging
@@ -202,6 +202,10 @@ class StackView(qt.QMainWindow):
"""Function returning the plot title based on the frame index.
It can be set to a custom function using :meth:`setTitleCallback`"""
+ self.calibrations3D = (calibration.NoCalibration(),
+ calibration.NoCalibration(),
+ calibration.NoCalibration())
+
central_widget = qt.QWidget(self)
self._plot = PlotWindow(parent=central_widget, backend=backend,
@@ -212,6 +216,7 @@ class StackView(qt.QMainWindow):
copy=copy, save=save, print_=print_,
control=control, position=position,
roi=False, mask=mask)
+ self._plot.getIntensityHistogramAction().setVisible(True)
self.sigInteractiveModeChanged = self._plot.sigInteractiveModeChanged
self.sigActiveImageChanged = self._plot.sigActiveImageChanged
self.sigPlotSignal = self._plot.sigPlotSignal
@@ -229,7 +234,7 @@ class StackView(qt.QMainWindow):
self._plot.sigPlotSignal.connect(self._plotCallback)
self.__planeSelection = PlanesWidget(self._plot)
- self.__planeSelection.sigPlaneSelectionChanged.connect(self.__setPerspective)
+ self.__planeSelection.sigPlaneSelectionChanged.connect(self.setPerspective)
self._browser_label = qt.QLabel("Image index (Dim0):")
@@ -287,12 +292,23 @@ class StackView(qt.QMainWindow):
self.valueChanged.emit(float(x), float(y),
None)
- def __setPerspective(self, perspective):
- """Function called when the browsed/orthogonal dimension changes.
- Updates :attr:`_perspective`, transposes data, updates the plot,
- emits :attr:`sigPlaneSelectionChanged` and :attr:`sigStackChanged`.
+ def getPerspective(self):
+ """Returns the index of the dimension the stack is browsed with
+
+ Possible values are: 0, 1, or 2.
- :param int perspective: the new browsed dimension
+ :rtype: int
+ """
+ return self._perspective
+
+ def setPerspective(self, perspective):
+ """Set the index of the dimension the stack is browsed with:
+
+ - slice plane Dim1-Dim2: perspective 0
+ - slice plane Dim0-Dim2: perspective 1
+ - slice plane Dim0-Dim1: perspective 2
+
+ :param int perspective: Orthogonal dimension number (0, 1, or 2)
"""
if perspective == self._perspective:
return
@@ -301,17 +317,21 @@ class StackView(qt.QMainWindow):
raise ValueError(
"Perspective must be 0, 1 or 2, not %s" % perspective)
- self._perspective = perspective
+ self._perspective = int(perspective)
self.__createTransposedView()
self.__updateFrameNumber(self._browser.value())
self._plot.resetZoom()
self.__updatePlotLabels()
+ self._updateTitle()
self._browser_label.setText("Image index (Dim%d):" %
(self._first_stack_dimension + perspective))
self.sigPlaneSelectionChanged.emit(perspective)
self.sigStackChanged.emit(self._stack.size if
self._stack is not None else 0)
+ self.__planeSelection.sigPlaneSelectionChanged.disconnect(self.setPerspective)
+ self.__planeSelection.setPerspective(self._perspective)
+ self.__planeSelection.sigPlaneSelectionChanged.connect(self.setPerspective)
def __updatePlotLabels(self):
"""Update plot axes labels depending on perspective"""
@@ -391,39 +411,47 @@ class StackView(qt.QMainWindow):
i)
self.calibrations3D.append(calib)
- def _getXYZCalibs(self):
- """Return calibrations sorted in the XYZ graph order.
+ def getCalibrations(self, order='array'):
+ """Returns currently used calibrations for each axis
- If the X or Y calibration is not linear, it will be replaced
- with a :class:`calibration.NoCalibration` object
- and as a result the corresponding axis will not be scaled."""
- xy_dims = [0, 1, 2]
- xy_dims.remove(self._perspective)
+ Returned calibrations might differ from the ones that were set as
+ non-linear calibrations used for image axes are temporarily ignored.
- xcalib = self.calibrations3D[max(xy_dims)]
- ycalib = self.calibrations3D[min(xy_dims)]
- zcalib = self.calibrations3D[self._perspective]
+ :param str order:
+ 'array' to sort calibrations as data array (dim0, dim1, dim2),
+ 'axes' to sort calibrations as currently selected x, y and z axes.
+ :return: Calibrations ordered depending on order
+ :rtype: List[~silx.math.calibration.AbstractCalibration]
+ """
+ assert order in ('array', 'axes')
+ calibs = []
# filter out non-linear calibration for graph axes
- if not xcalib.is_affine():
- xcalib = calibration.NoCalibration()
- if not ycalib.is_affine():
- ycalib = calibration.NoCalibration()
+ for index, calib in enumerate(self.calibrations3D):
+ if index != self._perspective and not calib.is_affine():
+ calib = calibration.NoCalibration()
+ calibs.append(calib)
+
+ if order == 'axes': # Move 'z' axis to the end
+ xy_dims = [d for d in (0, 1, 2) if d != self._perspective]
+ calibs = [calibs[max(xy_dims)],
+ calibs[min(xy_dims)],
+ calibs[self._perspective]]
- return xcalib, ycalib, zcalib
+ return tuple(calibs)
def _getImageScale(self):
"""
:return: 2-tuple (XScale, YScale) for current image view
"""
- xcalib, ycalib, _zcalib = self._getXYZCalibs()
+ xcalib, ycalib, _zcalib = self.getCalibrations(order='axes')
return xcalib.get_slope(), ycalib.get_slope()
def _getImageOrigin(self):
"""
:return: 2-tuple (XOrigin, YOrigin) for current image view
"""
- xcalib, ycalib, _zcalib = self._getXYZCalibs()
+ xcalib, ycalib, _zcalib = self.getCalibrations(order='axes')
return xcalib(0), ycalib(0)
def _getImageZ(self, index):
@@ -431,7 +459,7 @@ class StackView(qt.QMainWindow):
:param idx: 0-based image index in the stack
:return: calibrated Z value corresponding to the image idx
"""
- _xcalib, _ycalib, zcalib = self._getXYZCalibs()
+ _xcalib, _ycalib, zcalib = self.getCalibrations(order='axes')
return zcalib(index)
def _updateTitle(self):
@@ -442,7 +470,7 @@ class StackView(qt.QMainWindow):
return "Image z=%g" % self._getImageZ(index)
# public API, stack specific methods
- def setStack(self, stack, perspective=0, reset=True, calibrations=None):
+ def setStack(self, stack, perspective=None, reset=True, calibrations=None):
"""Set the 3D stack.
The perspective parameter is used to define which dimension of the 3D
@@ -454,8 +482,7 @@ class StackView(qt.QMainWindow):
:type stack: 3D numpy.ndarray, or 3D h5py.Dataset, or list/tuple of 2D
numpy arrays, or None.
:param int perspective: Dimension for the frame index: 0, 1 or 2.
- By default, the dimension for the image index is the first
- dimension of the 3D stack (``perspective=0``).
+ Use ``None`` to keep the current perspective (default).
:param bool reset: Whether to reset zoom or not.
:param calibrations: Sequence of 3 calibration objects for each axis.
These objects can be a subclass of :class:`AbstractCalibration`,
@@ -488,8 +515,10 @@ class StackView(qt.QMainWindow):
self._stack = stack
self.__createTransposedView()
- if perspective != self._perspective:
- self.__setPerspective(perspective)
+ perspective_changed = False
+ if perspective not in [None, self._perspective]:
+ perspective_changed = True
+ self.setPerspective(perspective)
# This call to setColormap redefines the meaning of autoscale
# for 3D volume: take global min/max rather than frame min/max
@@ -505,8 +534,8 @@ class StackView(qt.QMainWindow):
replace=True,
resetzoom=False)
self._plot.setActiveImage(self.__imageLegend)
- self._plot.setGraphTitle("Image z=%g" % self._getImageZ(0))
self.__updatePlotLabels()
+ self._updateTitle()
if reset:
self._plot.resetZoom()
@@ -514,12 +543,7 @@ class StackView(qt.QMainWindow):
# enable and init browser
self._browser.setEnabled(True)
- if perspective != self._perspective:
- self.__planeSelection.setPerspective(perspective)
- # this causes self.__setPerspective to be called, which emits
- # sigStackChanged and sigPlaneSelectionChanged
-
- else:
+ if not perspective_changed: # avoid double signal (see self.setPerspective)
self.sigStackChanged.emit(stack.size)
def getStack(self, copy=True, returnNumpyArray=False):
diff --git a/silx/gui/plot/StatsWidget.py b/silx/gui/plot/StatsWidget.py
index a36dd9f..bb66613 100644
--- a/silx/gui/plot/StatsWidget.py
+++ b/silx/gui/plot/StatsWidget.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 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
@@ -28,7 +28,7 @@ Module containing widgets displaying stats from items of a plot.
__authors__ = ["H. Payno"]
__license__ = "MIT"
-__date__ = "12/06/2018"
+__date__ = "24/07/2018"
import functools
@@ -63,6 +63,8 @@ class StatsWidget(qt.QWidget):
:param plot: the plot containing items on which we want statistics.
"""
+ sigVisibilityChanged = qt.Signal(bool)
+
NUMBER_FORMAT = '{0:.3f}'
class OptionsWidget(qt.QToolBar):
@@ -151,6 +153,14 @@ class StatsWidget(qt.QWidget):
self.setDisplayOnlyActiveItem = self._statsTable.setDisplayOnlyActiveItem
self.setStatsOnVisibleData = self._statsTable.setStatsOnVisibleData
+ def showEvent(self, event):
+ self.sigVisibilityChanged.emit(True)
+ qt.QWidget.showEvent(self, event)
+
+ def hideEvent(self, event):
+ self.sigVisibilityChanged.emit(False)
+ qt.QWidget.hideEvent(self, event)
+
def _optSelectionChanged(self, action=None):
self._statsTable.setDisplayOnlyActiveItem(self._options.isActiveItemMode())
@@ -366,7 +376,7 @@ class StatsTable(TableWidget):
self.setRowCount(0)
# It have to called befor3e accessing to the header items
- self.setHorizontalHeaderLabels(self._columns)
+ self.setHorizontalHeaderLabels(list(self._columns))
if self._statsHandler is not None:
for columnId, name in enumerate(self._columns):
@@ -539,7 +549,7 @@ class StatsTable(TableWidget):
self._statsOnVisibleData = b
self._updateCurrentStats()
- def _activeItemChanged(self, kind):
+ def _activeItemChanged(self, kind, previous, current):
"""Callback used when plotting only the active item"""
assert kind in ('curve', 'image', 'scatter', 'histogram')
self._updateItemObserve()
diff --git a/silx/gui/plot/_BaseMaskToolsWidget.py b/silx/gui/plot/_BaseMaskToolsWidget.py
index da0dbf5..e087354 100644
--- a/silx/gui/plot/_BaseMaskToolsWidget.py
+++ b/silx/gui/plot/_BaseMaskToolsWidget.py
@@ -29,7 +29,7 @@ from __future__ import division
__authors__ = ["T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "24/04/2018"
+__date__ = "29/08/2018"
import os
import weakref
@@ -596,6 +596,10 @@ class BaseMaskToolsWidget(qt.QWidget):
maskGroup.setLayout(layout)
return maskGroup
+ def isMaskInteractionActivated(self):
+ """Returns true if any mask interaction is activated"""
+ return self.drawActionGroup.checkedAction() is not None
+
def _initDrawGroupBox(self):
"""Init drawing tools widgets"""
layout = qt.QVBoxLayout()
diff --git a/silx/gui/plot/_utils/test/testColormap.py b/silx/gui/plot/_utils/test/testColormap.py
deleted file mode 100644
index d77fa65..0000000
--- a/silx/gui/plot/_utils/test/testColormap.py
+++ /dev/null
@@ -1,648 +0,0 @@
-# coding: utf-8
-# /*##########################################################################
-#
-# Copyright (c) 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.
-#
-# ###########################################################################*/
-
-import logging
-import time
-import unittest
-
-import numpy
-from PyMca5 import spslut
-
-from silx.image.colormap import dataToRGBAColormap
-
-_logger = logging.getLogger(__name__)
-
-# TODOs:
-# what to do with max < min: as SPS LUT or also invert outside boundaries?
-# test usedMin and usedMax
-# benchmark
-
-
-# common ######################################################################
-
-class _TestColormap(unittest.TestCase):
- # Array data types to test
- FLOATING_DTYPES = numpy.float16, numpy.float32, numpy.float64
- SIGNED_DTYPES = FLOATING_DTYPES + (numpy.int8, numpy.int16,
- numpy.int32, numpy.int64)
- UNSIGNED_DTYPES = numpy.uint8, numpy.uint16, numpy.uint32, numpy.uint64
- DTYPES = SIGNED_DTYPES + UNSIGNED_DTYPES
-
- # Array sizes to test
- SIZES = 2, 10, 256, 1024 # , 2048, 4096
-
- # Colormaps definitions
- _LUT_RED_256 = numpy.zeros((256, 4), dtype=numpy.uint8)
- _LUT_RED_256[:, 0] = numpy.arange(256, dtype=numpy.uint8)
- _LUT_RED_256[:, 3] = 255
-
- _LUT_RGB_3 = numpy.array(((255, 0, 0, 255),
- (0, 255, 0, 255),
- (0, 0, 255, 255)), dtype=numpy.uint8)
-
- _LUT_RGB_768 = numpy.zeros((768, 4), dtype=numpy.uint8)
- _LUT_RGB_768[0:256, 0] = numpy.arange(256, dtype=numpy.uint8)
- _LUT_RGB_768[256:512, 1] = numpy.arange(256, dtype=numpy.uint8)
- _LUT_RGB_768[512:768, 1] = numpy.arange(256, dtype=numpy.uint8)
- _LUT_RGB_768[:, 3] = 255
-
- COLORMAPS = {
- 'red 256': _LUT_RED_256,
- 'rgb 3': _LUT_RGB_3,
- 'rgb 768': _LUT_RGB_768,
- }
-
- @staticmethod
- def _log(*args):
- """Logging used by test for debugging."""
- _logger.debug(str(args))
-
- @staticmethod
- def buildControlPixmap(data, colormap, start=None, end=None,
- isLog10=False):
- """Generate a pixmap used to test C pixmap."""
- if isLog10: # Convert to log
- if start is None:
- posValue = data[numpy.nonzero(data > 0)]
- if posValue.size != 0:
- start = numpy.nanmin(posValue)
- else:
- start = 0.
-
- if end is None:
- end = numpy.nanmax(data)
-
- start = 0. if start <= 0. else numpy.log10(start,
- dtype=numpy.float64)
- end = 0. if end <= 0. else numpy.log10(end,
- dtype=numpy.float64)
-
- data = numpy.log10(data, dtype=numpy.float64)
- else:
- if start is None:
- start = numpy.nanmin(data)
- if end is None:
- end = numpy.nanmax(data)
-
- start, end = float(start), float(end)
- min_, max_ = min(start, end), max(start, end)
-
- if start == end:
- indices = numpy.asarray((len(colormap) - 1) * (data >= max_),
- dtype=numpy.int)
- else:
- clipData = numpy.clip(data, min_, max_) # Clip first avoid overflow
- scale = len(colormap) / (end - start)
- normData = scale * (numpy.asarray(clipData, numpy.float64) - start)
-
- # Clip again to makes sure <= len(colormap) - 1
- indices = numpy.asarray(numpy.clip(normData,
- 0, len(colormap) - 1),
- dtype=numpy.uint32)
-
- pixmap = numpy.take(colormap, indices, axis=0)
- pixmap.shape = data.shape + (4,)
- return numpy.ascontiguousarray(pixmap)
-
- @staticmethod
- def buildSPSLUTRedPixmap(data, start=None, end=None, isLog10=False):
- """Generate a pixmap with SPS LUT.
- Only supports red colormap with 256 colors.
- """
- colormap = spslut.RED
- mapping = spslut.LOG if isLog10 else spslut.LINEAR
-
- if start is None and end is None:
- autoScale = 1
- start, end = 0, 1
- else:
- autoScale = 0
- if start is None:
- start = data.min()
- if end is None:
- end = data.max()
-
- pixmap, size, minMax = spslut.transform(data,
- (1, 0),
- (mapping, 3.0),
- 'RGBX',
- colormap,
- autoScale,
- (start, end),
- (0, 255),
- 1)
- pixmap.shape = data.shape[0], data.shape[1], 4
-
- return pixmap
-
- def _testColormap(self, data, colormap, start, end, control=None,
- isLog10=False, nanColor=None):
- """Test pixmap built with C code against SPS LUT if possible,
- else against Python control code."""
- startTime = time.time()
- pixmap = dataToRGBAColormap(data,
- colormap,
- start,
- end,
- isLog10,
- nanColor)
- duration = time.time() - startTime
-
- # Compare with result
- controlType = 'array'
- if control is None:
- startTime = time.time()
-
- # Compare with SPS LUT if possible
- if (colormap.shape == self.COLORMAPS['red 256'].shape and
- numpy.all(numpy.equal(colormap, self.COLORMAPS['red 256'])) and
- data.size % 2 == 0 and
- data.dtype in (numpy.float32, numpy.float64)):
- # Only works with red colormap and even size
- # as it needs 2D data
- if len(data.shape) == 1:
- data.shape = data.size // 2, -1
- pixmap.shape = data.shape + (4,)
- control = self.buildSPSLUTRedPixmap(data, start, end, isLog10)
- controlType = 'SPS LUT'
-
- # Compare with python test implementation
- else:
- control = self.buildControlPixmap(data, colormap, start, end,
- isLog10)
- controlType = 'Python control code'
-
- controlDuration = time.time() - startTime
- if duration >= controlDuration:
- self._log('duration', duration, 'control', controlDuration)
- # Allows duration to be 20% over SPS LUT duration
- # self.assertTrue(duration < 1.2 * controlDuration)
-
- difference = numpy.fabs(numpy.asarray(pixmap, dtype=numpy.float64) -
- numpy.asarray(control, dtype=numpy.float64))
- if numpy.any(difference != 0.0):
- self._log('control', controlType)
- self._log('data', data)
- self._log('pixmap', pixmap)
- self._log('control', control)
- self._log('errors', numpy.ravel(difference))
- self._log('errors', difference[difference != 0])
- self._log('in pixmap', pixmap[difference != 0])
- self._log('in control', control[difference != 0])
- self._log('Max error', difference.max())
-
- # Allows a difference of 1 per channel
- self.assertTrue(numpy.all(difference <= 1.0))
-
- return duration
-
-
-# TestColormap ################################################################
-
-class TestColormap(_TestColormap):
- """Test common limit case for colormap in C with both linear and log mode.
-
- Test with different: data types, sizes, colormaps (with different sizes),
- mapping range.
- """
-
- def testNoData(self):
- """Test pixmap generation with empty data."""
- self._log("TestColormap.testNoData")
- cmapName = 'red 256'
- colormap = self.COLORMAPS[cmapName]
-
- for dtype in self.DTYPES:
- for isLog10 in (False, True):
- data = numpy.array((), dtype=dtype)
- result = numpy.array((), dtype=numpy.uint8)
- result.shape = 0, 4
- duration = self._testColormap(data, colormap,
- None, None, result, isLog10)
- self._log('No data', 'red 256', dtype, len(data), (None, None),
- 'isLog10:', isLog10, duration)
-
- def testNaN(self):
- """Test pixmap generation with NaN values and no NaN color."""
- self._log("TestColormap.testNaN")
- cmapName = 'red 256'
- colormap = self.COLORMAPS[cmapName]
-
- for dtype in self.FLOATING_DTYPES:
- for isLog10 in (False, True):
- # All NaNs
- data = numpy.array((float('nan'),) * 4, dtype=dtype)
- result = numpy.array(((0, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, colormap,
- None, None, result, isLog10)
- self._log('All NaNs', 'red 256', dtype, len(data),
- (None, None), 'isLog10:', isLog10, duration)
-
- # Some NaNs
- data = numpy.array((1., float('nan'), 0., float('nan')),
- dtype=dtype)
- result = numpy.array(((255, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, colormap,
- None, None, result, isLog10)
- self._log('Some NaNs', 'red 256', dtype, len(data),
- (None, None), 'isLog10:', isLog10, duration)
-
- def testNaNWithColor(self):
- """Test pixmap generation with NaN values with a NaN color."""
- self._log("TestColormap.testNaNWithColor")
- cmapName = 'red 256'
- colormap = self.COLORMAPS[cmapName]
-
- for dtype in self.FLOATING_DTYPES:
- for isLog10 in (False, True):
- # All NaNs
- data = numpy.array((float('nan'),) * 4, dtype=dtype)
- result = numpy.array(((128, 128, 128, 255),
- (128, 128, 128, 255),
- (128, 128, 128, 255),
- (128, 128, 128, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, colormap,
- None, None, result, isLog10,
- nanColor=(128, 128, 128, 255))
- self._log('All NaNs', 'red 256', dtype, len(data),
- (None, None), 'isLog10:', isLog10, duration)
-
- # Some NaNs
- data = numpy.array((1., float('nan'), 0., float('nan')),
- dtype=dtype)
- result = numpy.array(((255, 0, 0, 255),
- (128, 128, 128, 255),
- (0, 0, 0, 255),
- (128, 128, 128, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, colormap,
- None, None, result, isLog10,
- nanColor=(128, 128, 128, 255))
- self._log('Some NaNs', 'red 256', dtype, len(data),
- (None, None), 'isLog10:', isLog10, duration)
-
-
-# TestLinearColormap ##########################################################
-
-class TestLinearColormap(_TestColormap):
- """Test fill pixmap with colormap in C with linear mode.
-
- Test with different: data types, sizes, colormaps (with different sizes),
- mapping range.
- """
-
- # Colormap ranges to map
- RANGES = (None, None), (1, 10)
-
- def test1DData(self):
- """Test pixmap generation for 1D data of different size and types."""
- self._log("TestLinearColormap.test1DData")
- for cmapName, colormap in self.COLORMAPS.items():
- for size in self.SIZES:
- for dtype in self.DTYPES:
- for start, end in self.RANGES:
- # Increasing values
- data = numpy.arange(size, dtype=dtype)
- duration = self._testColormap(data, colormap,
- start, end)
-
- self._log('1D', cmapName, dtype, size, (start, end),
- duration)
-
- # Reverse order
- data = data[::-1]
- duration = self._testColormap(data, colormap,
- start, end)
-
- self._log('1D', cmapName, dtype, size, (start, end),
- duration)
-
- def test2DData(self):
- """Test pixmap generation for 2D data of different size and types."""
- self._log("TestLinearColormap.test2DData")
- for cmapName, colormap in self.COLORMAPS.items():
- for size in self.SIZES:
- for dtype in self.DTYPES:
- for start, end in self.RANGES:
- # Increasing values
- data = numpy.arange(size * size, dtype=dtype)
- data = numpy.nan_to_num(data)
- data.shape = size, size
- duration = self._testColormap(data, colormap,
- start, end)
-
- self._log('2D', cmapName, dtype, size, (start, end),
- duration)
-
- # Reverse order
- data = data[::-1, ::-1]
- duration = self._testColormap(data, colormap,
- start, end)
-
- self._log('2D', cmapName, dtype, size, (start, end),
- duration)
-
- def testInf(self):
- """Test pixmap generation with Inf values."""
- self._log("TestLinearColormap.testInf")
-
- for dtype in self.FLOATING_DTYPES:
- # All positive Inf
- data = numpy.array((float('inf'),) * 4, dtype=dtype)
- result = numpy.array(((255, 0, 0, 255),
- (255, 0, 0, 255),
- (255, 0, 0, 255),
- (255, 0, 0, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, self.COLORMAPS['red 256'],
- None, None, result)
- self._log('All +Inf', 'red 256', dtype, len(data), (None, None),
- duration)
-
- # All negative Inf
- data = numpy.array((float('-inf'),) * 4, dtype=dtype)
- result = numpy.array(((255, 0, 0, 255),
- (255, 0, 0, 255),
- (255, 0, 0, 255),
- (255, 0, 0, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, self.COLORMAPS['red 256'],
- None, None, result)
- self._log('All -Inf', 'red 256', dtype, len(data), (None, None),
- duration)
-
- # All +/-Inf
- data = numpy.array((float('inf'), float('-inf'),
- float('-inf'), float('inf')), dtype=dtype)
- result = numpy.array(((255, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255),
- (255, 0, 0, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, self.COLORMAPS['red 256'],
- None, None, result)
- self._log('All +/-Inf', 'red 256', dtype, len(data), (None, None),
- duration)
-
- # Some +/-Inf
- data = numpy.array((float('inf'), 0., float('-inf'), -10.),
- dtype=dtype)
- result = numpy.array(((255, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, self.COLORMAPS['red 256'],
- None, None,
- result) # Seg Fault with SPS
- self._log('Some +/-Inf', 'red 256', dtype, len(data), (None, None),
- duration)
-
- @unittest.skip("Not for reproductible tests")
- def test1DDataRandom(self):
- """Test pixmap generation for 1D data of different size and types."""
- self._log("TestLinearColormap.test1DDataRandom")
- for cmapName, colormap in self.COLORMAPS.items():
- for size in self.SIZES:
- for dtype in self.DTYPES:
- for start, end in self.RANGES:
- try:
- dtypeMax = numpy.iinfo(dtype).max
- except ValueError:
- dtypeMax = numpy.finfo(dtype).max
- data = numpy.asarray(numpy.random.rand(size) * dtypeMax,
- dtype=dtype)
- duration = self._testColormap(data, colormap,
- start, end)
-
- self._log('1D Random', cmapName, dtype, size,
- (start, end), duration)
-
-
-# TestLog10Colormap ###########################################################
-
-class TestLog10Colormap(_TestColormap):
- """Test fill pixmap with colormap in C with log mode.
-
- Test with different: data types, sizes, colormaps (with different sizes),
- mapping range.
- """
- # Colormap ranges to map
- RANGES = (None, None), (1, 10) # , (10, 1)
-
- def test1DDataAllPositive(self):
- """Test pixmap generation for all positive 1D data."""
- self._log("TestLog10Colormap.test1DDataAllPositive")
- for cmapName, colormap in self.COLORMAPS.items():
- for size in self.SIZES:
- for dtype in self.DTYPES:
- for start, end in self.RANGES:
- # Increasing values
- data = numpy.arange(size, dtype=dtype) + 1
- duration = self._testColormap(data, colormap,
- start, end,
- isLog10=True)
-
- self._log('1D', cmapName, dtype, size, (start, end),
- duration)
-
- # Reverse order
- data = data[::-1]
- duration = self._testColormap(data, colormap,
- start, end,
- isLog10=True)
-
- self._log('1D', cmapName, dtype, size, (start, end),
- duration)
-
- def test2DDataAllPositive(self):
- """Test pixmap generation for all positive 2D data."""
- self._log("TestLog10Colormap.test2DDataAllPositive")
- for cmapName, colormap in self.COLORMAPS.items():
- for size in self.SIZES:
- for dtype in self.DTYPES:
- for start, end in self.RANGES:
- # Increasing values
- data = numpy.arange(size * size, dtype=dtype) + 1
- data = numpy.nan_to_num(data)
- data.shape = size, size
- duration = self._testColormap(data, colormap,
- start, end,
- isLog10=True)
-
- self._log('2D', cmapName, dtype, size, (start, end),
- duration)
-
- # Reverse order
- data = data[::-1, ::-1]
- duration = self._testColormap(data, colormap,
- start, end,
- isLog10=True)
-
- self._log('2D', cmapName, dtype, size, (start, end),
- duration)
-
- def testAllNegative(self):
- """Test pixmap generation for all negative 1D data."""
- self._log("TestLog10Colormap.testAllNegative")
- for cmapName, colormap in self.COLORMAPS.items():
- for size in self.SIZES:
- for dtype in self.SIGNED_DTYPES:
- for start, end in self.RANGES:
- # Increasing values
- data = numpy.arange(-size, 0, dtype=dtype)
- duration = self._testColormap(data, colormap,
- start, end,
- isLog10=True)
-
- self._log('1D', cmapName, dtype, size, (start, end),
- duration)
-
- # Reverse order
- data = data[::-1]
- duration = self._testColormap(data, colormap,
- start, end,
- isLog10=True)
-
- self._log('1D', cmapName, dtype, size, (start, end),
- duration)
-
- def testCrossingZero(self):
- """Test pixmap generation for 1D data with negative and zero."""
- self._log("TestLog10Colormap.testCrossingZero")
- for cmapName, colormap in self.COLORMAPS.items():
- for size in self.SIZES:
- for dtype in self.SIGNED_DTYPES:
- for start, end in self.RANGES:
- # Increasing values
- data = numpy.arange(-size/2, size/2 + 1, dtype=dtype)
- duration = self._testColormap(data, colormap,
- start, end,
- isLog10=True)
-
- self._log('1D', cmapName, dtype, size, (start, end),
- duration)
-
- # Reverse order
- data = data[::-1]
- duration = self._testColormap(data, colormap,
- start, end,
- isLog10=True)
-
- self._log('1D', cmapName, dtype, size, (start, end),
- duration)
-
- @unittest.skip("Not for reproductible tests")
- def test1DDataRandom(self):
- """Test pixmap generation for 1D data of different size and types."""
- self._log("TestLog10Colormap.test1DDataRandom")
- for cmapName, colormap in self.COLORMAPS.items():
- for size in self.SIZES:
- for dtype in self.DTYPES:
- for start, end in self.RANGES:
- try:
- dtypeMax = numpy.iinfo(dtype).max
- dtypeMin = numpy.iinfo(dtype).min
- except ValueError:
- dtypeMax = numpy.finfo(dtype).max
- dtypeMin = numpy.finfo(dtype).min
- if dtypeMin < 0:
- data = numpy.asarray(-dtypeMax/2. +
- numpy.random.rand(size) * dtypeMax,
- dtype=dtype)
- else:
- data = numpy.asarray(numpy.random.rand(size) * dtypeMax,
- dtype=dtype)
-
- duration = self._testColormap(data, colormap,
- start, end,
- isLog10=True)
-
- self._log('1D Random', cmapName, dtype, size,
- (start, end), duration)
-
- def testInf(self):
- """Test pixmap generation with Inf values."""
- self._log("TestLog10Colormap.testInf")
-
- for dtype in self.FLOATING_DTYPES:
- # All positive Inf
- data = numpy.array((float('inf'),) * 4, dtype=dtype)
- result = numpy.array(((255, 0, 0, 255),
- (255, 0, 0, 255),
- (255, 0, 0, 255),
- (255, 0, 0, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, self.COLORMAPS['red 256'],
- None, None, result, isLog10=True)
- self._log('All +Inf', 'red 256', dtype, len(data), (None, None),
- duration)
-
- # All negative Inf
- data = numpy.array((float('-inf'),) * 4, dtype=dtype)
- result = numpy.array(((0, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, self.COLORMAPS['red 256'],
- None, None, result, isLog10=True)
- self._log('All -Inf', 'red 256', dtype, len(data), (None, None),
- duration)
-
- # All +/-Inf
- data = numpy.array((float('inf'), float('-inf'),
- float('-inf'), float('inf')), dtype=dtype)
- result = numpy.array(((255, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255),
- (255, 0, 0, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, self.COLORMAPS['red 256'],
- None, None, result, isLog10=True)
- self._log('All +/-Inf', 'red 256', dtype, len(data), (None, None),
- duration)
-
- # Some +/-Inf
- data = numpy.array((float('inf'), 0., float('-inf'), -10.),
- dtype=dtype)
- result = numpy.array(((255, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255),
- (0, 0, 0, 255)), dtype=numpy.uint8)
- duration = self._testColormap(data, self.COLORMAPS['red 256'],
- None, None, result, isLog10=True)
- self._log('Some +/-Inf', 'red 256', dtype, len(data), (None, None),
- duration)
-
-
-def suite():
- testSuite = unittest.TestSuite()
- for testClass in (TestColormap, TestLinearColormap): # , TestLog10Colormap):
- testSuite.addTest(
- unittest.defaultTestLoader.loadTestsFromTestCase(testClass))
- return testSuite
-
-
-if __name__ == '__main__':
- unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/actions/PlotToolAction.py b/silx/gui/plot/actions/PlotToolAction.py
new file mode 100644
index 0000000..77e8be2
--- /dev/null
+++ b/silx/gui/plot/actions/PlotToolAction.py
@@ -0,0 +1,150 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2004-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.
+#
+# ###########################################################################*/
+"""
+The class :class:`.PlotToolAction` help the creation of a qt.QAction associating
+a tool window with a :class:`.PlotWidget`.
+"""
+
+from __future__ import division
+
+
+__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
+__license__ = "MIT"
+__date__ = "10/10/2018"
+
+
+import weakref
+
+from .PlotAction import PlotAction
+from silx.gui import qt
+
+
+class PlotToolAction(PlotAction):
+ """Base class for QAction that maintain a tool window operating on a
+ PlotWidget."""
+
+ def __init__(self, plot, icon, text, tooltip=None,
+ triggered=None, checkable=False, parent=None):
+ PlotAction.__init__(self,
+ plot=plot,
+ icon=icon,
+ text=text,
+ tooltip=tooltip,
+ triggered=self._triggered,
+ parent=parent,
+ checkable=True)
+ self._previousGeometry = None
+ self._toolWindow = None
+
+ def _triggered(self, checked):
+ """Update the plot of the histogram visibility status
+
+ :param bool checked: status of the action button
+ """
+ self._setToolWindowVisible(checked)
+
+ def _setToolWindowVisible(self, visible):
+ """Set the tool window visible or hidden."""
+ tool = self._getToolWindow()
+ if tool.isVisible() == visible:
+ # Nothing to do
+ return
+
+ if visible:
+ self._connectPlot(tool)
+ tool.show()
+ if self._previousGeometry is not None:
+ # Restore the geometry
+ tool.setGeometry(self._previousGeometry)
+ else:
+ self._disconnectPlot(tool)
+ # Save the geometry
+ self._previousGeometry = tool.geometry()
+ tool.hide()
+
+ def _connectPlot(self, window):
+ """Called if the tool is visible and have to be updated according to
+ event of the plot.
+
+ :param qt.QWidget window: The tool window
+ """
+ pass
+
+ def _disconnectPlot(self, window):
+ """Called if the tool is not visible and dont have anymore to be updated
+ according to event of the plot.
+
+ :param qt.QWidget window: The tool window
+ """
+ pass
+
+ def _isWindowInUse(self):
+ """Returns true if the tool window is currently in use."""
+ if not self.isChecked():
+ return False
+ return self._toolWindow is not None
+
+ def _ownerVisibilityChanged(self, isVisible):
+ """Called when the visibility of the parent of the tool window changes
+
+ :param bool isVisible: True if the parent became visible
+ """
+ if self._isWindowInUse():
+ self._setToolWindowVisible(isVisible)
+
+ def eventFilter(self, qobject, event):
+ """Observe when the close event is emitted then
+ simply uncheck the action button
+
+ :param qobject: the object observe
+ :param event: the event received by qobject
+ """
+ if event.type() == qt.QEvent.Close:
+ if self._toolWindow is not None:
+ window = self._toolWindow()
+ self._previousGeometry = window.geometry()
+ window.hide()
+ self.setChecked(False)
+
+ return PlotAction.eventFilter(self, qobject, event)
+
+ def _getToolWindow(self):
+ """Returns the window containg tohe tool.
+
+ It uses lazy loading to create this tool..
+ """
+ if self._toolWindow is None:
+ window = self._createToolWindow()
+ if self._previousGeometry is not None:
+ window.setGeometry(self._previousGeometry)
+ window.installEventFilter(self)
+ plot = self.plot
+ plot.sigVisibilityChanged.connect(self._ownerVisibilityChanged)
+ self._toolWindow = weakref.ref(window)
+ return self._toolWindow()
+
+ def _createToolWindow(self):
+ """Create the tool window managing the plot."""
+ raise NotImplementedError()
diff --git a/silx/gui/plot/actions/control.py b/silx/gui/plot/actions/control.py
index 6e08f21..10df130 100644
--- a/silx/gui/plot/actions/control.py
+++ b/silx/gui/plot/actions/control.py
@@ -601,3 +601,4 @@ class ShowAxisAction(PlotAction):
def _actionTriggered(self, checked=False):
self.plot.setAxesDisplayed(checked)
+
diff --git a/silx/gui/plot/actions/fit.py b/silx/gui/plot/actions/fit.py
index 5ca649c..cb70733 100644
--- a/silx/gui/plot/actions/fit.py
+++ b/silx/gui/plot/actions/fit.py
@@ -36,9 +36,9 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "03/01/2018"
+__date__ = "10/10/2018"
-from . import PlotAction
+from .PlotToolAction import PlotToolAction
import logging
from silx.gui import qt
from silx.gui.plot.ItemsSelectionDialog import ItemsSelectionDialog
@@ -86,7 +86,7 @@ def _getUniqueHistogram(plt):
return histograms[0]
-class FitAction(PlotAction):
+class FitAction(PlotToolAction):
"""QAction to open a :class:`FitWidget` and set its data to the
active curve if any, or to the first curve.
@@ -97,21 +97,38 @@ class FitAction(PlotAction):
super(FitAction, self).__init__(
plot, icon='math-fit', text='Fit curve',
tooltip='Open a fit dialog',
- triggered=self._getFitWindow,
- checkable=False, parent=parent)
- self.fit_window = None
-
- def _getFitWindow(self):
- self.xlabel = self.plot.getXAxis().getLabel()
- self.ylabel = self.plot.getYAxis().getLabel()
- self.xmin, self.xmax = self.plot.getXAxis().getLimits()
+ parent=parent)
+ self.fit_widget = None
+
+ def _createToolWindow(self):
+ window = qt.QMainWindow(parent=self.plot)
+ # import done here rather than at module level to avoid circular import
+ # FitWidget -> BackgroundWidget -> PlotWindow -> actions -> fit -> FitWidget
+ from ...fit.FitWidget import FitWidget
+ fit_widget = FitWidget(parent=window)
+ window.setCentralWidget(fit_widget)
+ fit_widget.guibuttons.DismissButton.clicked.connect(window.close)
+ fit_widget.sigFitWidgetSignal.connect(self.handle_signal)
+ self.fit_widget = fit_widget
+ return window
+
+ def _connectPlot(self, window):
+ # Wait for the next iteration, else the plot is not yet initialized
+ # No curve available
+ qt.QTimer.singleShot(10, lambda: self._initFit(window))
+
+ def _initFit(self, window):
+ plot = self.plot
+ self.xlabel = plot.getXAxis().getLabel()
+ self.ylabel = plot.getYAxis().getLabel()
+ self.xmin, self.xmax = plot.getXAxis().getLimits()
histo = _getUniqueHistogram(self.plot)
curve = _getUniqueCurve(self.plot)
if histo is None and curve is None:
# ambiguous case, we need to ask which plot item to fit
- isd = ItemsSelectionDialog(parent=self.plot, plot=self.plot)
+ isd = ItemsSelectionDialog(parent=plot, plot=self.plot)
isd.setWindowTitle("Select item to be fitted")
isd.setItemsSelectionMode(qt.QTableWidget.SingleSelection)
isd.setAvailableKinds(["curve", "histogram"])
@@ -141,29 +158,9 @@ class FitAction(PlotAction):
self.x = item.getXData(copy=False)
self.y = item.getYData(copy=False)
- # open a window with a FitWidget
- if self.fit_window is None:
- self.fit_window = qt.QMainWindow()
- # import done here rather than at module level to avoid circular import
- # FitWidget -> BackgroundWidget -> PlotWindow -> actions -> fit -> FitWidget
- from ...fit.FitWidget import FitWidget
- self.fit_widget = FitWidget(parent=self.fit_window)
- self.fit_window.setCentralWidget(
- self.fit_widget)
- self.fit_widget.guibuttons.DismissButton.clicked.connect(
- self.fit_window.close)
- self.fit_widget.sigFitWidgetSignal.connect(
- self.handle_signal)
- self.fit_window.show()
- else:
- if self.fit_window.isHidden():
- self.fit_window.show()
- self.fit_widget.show()
- self.fit_window.raise_()
-
self.fit_widget.setData(self.x, self.y,
xmin=self.xmin, xmax=self.xmax)
- self.fit_window.setWindowTitle(
+ window.setWindowTitle(
"Fitting " + self.legend +
" on x range %f-%f" % (self.xmin, self.xmax))
diff --git a/silx/gui/plot/actions/histogram.py b/silx/gui/plot/actions/histogram.py
index d6e3269..9181f53 100644
--- a/silx/gui/plot/actions/histogram.py
+++ b/silx/gui/plot/actions/histogram.py
@@ -34,10 +34,10 @@ The following QAction are available:
from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
-__date__ = "30/04/2018"
+__date__ = "10/10/2018"
__license__ = "MIT"
-from . import PlotAction
+from .PlotToolAction import PlotToolAction
from silx.math.histogram import Histogramnd
from silx.math.combo import min_max
import numpy
@@ -47,7 +47,7 @@ from silx.gui import qt
_logger = logging.getLogger(__name__)
-class PixelIntensitiesHistoAction(PlotAction):
+class PixelIntensitiesHistoAction(PlotToolAction):
"""QAction to plot the pixels intensities diagram
:param plot: :class:`.PlotWidget` instance on which to operate
@@ -55,43 +55,33 @@ class PixelIntensitiesHistoAction(PlotAction):
"""
def __init__(self, plot, parent=None):
- PlotAction.__init__(self,
- plot,
- icon='pixel-intensities',
- text='pixels intensity',
- tooltip='Compute image intensity distribution',
- triggered=self._triggered,
- parent=parent,
- checkable=True)
- self._plotHistogram = None
+ PlotToolAction.__init__(self,
+ plot,
+ icon='pixel-intensities',
+ text='pixels intensity',
+ tooltip='Compute image intensity distribution',
+ parent=parent)
self._connectedToActiveImage = False
self._histo = None
- def _triggered(self, checked):
- """Update the plot of the histogram visibility status
-
- :param bool checked: status of the action button
- """
- if checked:
- if not self._connectedToActiveImage:
- self.plot.sigActiveImageChanged.connect(
- self._activeImageChanged)
- self._connectedToActiveImage = True
- self.computeIntensityDistribution()
-
- self.getHistogramPlotWidget().show()
-
- else:
- if self._connectedToActiveImage:
- self.plot.sigActiveImageChanged.disconnect(
- self._activeImageChanged)
- self._connectedToActiveImage = False
+ def _connectPlot(self, window):
+ if not self._connectedToActiveImage:
+ self.plot.sigActiveImageChanged.connect(
+ self._activeImageChanged)
+ self._connectedToActiveImage = True
+ self.computeIntensityDistribution()
+ PlotToolAction._connectPlot(self, window)
- self.getHistogramPlotWidget().hide()
+ def _disconnectPlot(self, window):
+ if self._connectedToActiveImage:
+ self.plot.sigActiveImageChanged.disconnect(
+ self._activeImageChanged)
+ self._connectedToActiveImage = False
+ PlotToolAction._disconnectPlot(self, window)
def _activeImageChanged(self, previous, legend):
"""Handle active image change: toggle enabled toolbar, update curve"""
- if self.isChecked():
+ if self._isWindowInUse():
self.computeIntensityDistribution()
def computeIntensityDistribution(self):
@@ -132,35 +122,21 @@ class PixelIntensitiesHistoAction(PlotAction):
color='#66aad7')
plot.resetZoom()
- def eventFilter(self, qobject, event):
- """Observe when the close event is emitted then
- simply uncheck the action button
-
- :param qobject: the object observe
- :param event: the event received by qobject
- """
- if event.type() == qt.QEvent.Close:
- if self._plotHistogram is not None:
- self._plotHistogram.hide()
- self.setChecked(False)
-
- return PlotAction.eventFilter(self, qobject, event)
-
def getHistogramPlotWidget(self):
"""Create the plot histogram if needed, otherwise create it
:return: the PlotWidget showing the histogram of the pixel intensities
"""
+ return self._getToolWindow()
+
+ def _createToolWindow(self):
from silx.gui.plot.PlotWindow import Plot1D
- if self._plotHistogram is None:
- self._plotHistogram = Plot1D(parent=self.plot)
- self._plotHistogram.setWindowFlags(qt.Qt.Window)
- self._plotHistogram.setWindowTitle('Image Intensity Histogram')
- self._plotHistogram.installEventFilter(self)
- self._plotHistogram.getXAxis().setLabel("Value")
- self._plotHistogram.getYAxis().setLabel("Count")
-
- return self._plotHistogram
+ window = Plot1D(parent=self.plot)
+ window.setWindowFlags(qt.Qt.Window)
+ window.setWindowTitle('Image Intensity Histogram')
+ window.getXAxis().setLabel("Value")
+ window.getYAxis().setLabel("Count")
+ return window
def getHistogram(self):
"""Return the last computed histogram
diff --git a/silx/gui/plot/actions/io.py b/silx/gui/plot/actions/io.py
index ac06942..97de527 100644
--- a/silx/gui/plot/actions/io.py
+++ b/silx/gui/plot/actions/io.py
@@ -37,10 +37,10 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "02/02/2018"
+__date__ = "12/07/2018"
from . import PlotAction
-from silx.io.utils import save1D, savespec
+from silx.io.utils import save1D, savespec, NEXUS_HDF5_EXT
from silx.io.nxdata import save_NXdata
import logging
import sys
@@ -53,7 +53,7 @@ from silx.gui import qt, printer
from silx.gui.dialog.GroupDialog import GroupDialog
from silx.third_party.EdfFile import EdfFile
from silx.third_party.TiffIO import TiffIO
-from ...utils._image import convertArrayToQImage
+from ...utils.image import convertArrayToQImage
if sys.version_info[0] == 3:
from io import BytesIO
else:
@@ -62,9 +62,7 @@ else:
_logger = logging.getLogger(__name__)
-
-_NEXUS_HDF5_EXT = [".h5", ".nx5", ".nxs", ".hdf", ".hdf5", ".cxi"]
-_NEXUS_HDF5_EXT_STR = ' '.join(['*' + ext for ext in _NEXUS_HDF5_EXT])
+_NEXUS_HDF5_EXT_STR = ' '.join(['*' + ext for ext in NEXUS_HDF5_EXT])
def selectOutputGroup(h5filename):
@@ -546,7 +544,7 @@ class SaveAction(PlotAction):
if (self.plot.getActiveCurve() is not None or
len(self.plot.getAllCurves()) == 1):
filters.update(self._filters['curve'].items())
- if len(self.plot.getAllCurves()) > 1:
+ if len(self.plot.getAllCurves()) >= 1:
filters.update(self._filters['curves'].items())
# Add scatter filters if there is a scatter
diff --git a/silx/gui/plot/actions/medfilt.py b/silx/gui/plot/actions/medfilt.py
index 4284a8b..276f970 100644
--- a/silx/gui/plot/actions/medfilt.py
+++ b/silx/gui/plot/actions/medfilt.py
@@ -39,9 +39,9 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent", "P. Knobel"]
__license__ = "MIT"
-__date__ = "03/01/2018"
+__date__ = "10/10/2018"
-from . import PlotAction
+from .PlotToolAction import PlotToolAction
from silx.gui.widgets.MedianFilterDialog import MedianFilterDialog
from silx.math.medianfilter import medfilt2d
import logging
@@ -49,7 +49,7 @@ import logging
_logger = logging.getLogger(__name__)
-class MedianFilterAction(PlotAction):
+class MedianFilterAction(PlotToolAction):
"""QAction to plot the pixels intensities diagram
:param plot: :class:`.PlotWidget` instance on which to operate
@@ -57,27 +57,29 @@ class MedianFilterAction(PlotAction):
"""
def __init__(self, plot, parent=None):
- PlotAction.__init__(self,
- plot,
- icon='median-filter',
- text='median filter',
- tooltip='Apply a median filter on the image',
- triggered=self._triggered,
- parent=parent)
+ PlotToolAction.__init__(self,
+ plot,
+ icon='median-filter',
+ text='median filter',
+ tooltip='Apply a median filter on the image',
+ parent=parent)
self._originalImage = None
self._legend = None
self._filteredImage = None
- self._popup = MedianFilterDialog(parent=plot)
- self._popup.sigFilterOptChanged.connect(self._updateFilter)
+
+ def _createToolWindow(self):
+ popup = MedianFilterDialog(parent=self.plot)
+ popup.sigFilterOptChanged.connect(self._updateFilter)
+ return popup
+
+ def _connectPlot(self, window):
+ PlotToolAction._connectPlot(self, window)
self.plot.sigActiveImageChanged.connect(self._updateActiveImage)
self._updateActiveImage()
- def _triggered(self, checked):
- """Update the plot of the histogram visibility status
-
- :param bool checked: status of the action button
- """
- self._popup.show()
+ def _disconnectPlot(self, window):
+ PlotToolAction._disconnectPlot(self, window)
+ self.plot.sigActiveImageChanged.disconnect(self._updateActiveImage)
def _updateActiveImage(self):
"""Set _activeImageLegend and _originalImage from the active image"""
diff --git a/silx/gui/plot/backends/BackendBase.py b/silx/gui/plot/backends/BackendBase.py
index 8352ea0..7fb8be0 100644
--- a/silx/gui/plot/backends/BackendBase.py
+++ b/silx/gui/plot/backends/BackendBase.py
@@ -163,9 +163,8 @@ class BackendBase(object):
:param int z: Layer on which to draw the image
:param bool selectable: indicate if the image can be selected
:param bool draggable: indicate if the image can be moved
- :param colormap: :class:`.Colormap` describing the colormap to use.
- Ignored if data is RGB(A).
- :type colormap: :class:`.Colormap`
+ :param ~silx.gui.colors.Colormap colormap: Colormap object to use.
+ Ignored if data is RGB(A).
:param float alpha: Opacity of the image, as a float in range [0, 1].
:returns: The handle used by the backend to univocally access the image
"""
@@ -189,7 +188,7 @@ class BackendBase(object):
def addMarker(self, x, y, legend, text, color,
selectable, draggable,
- symbol, constraint):
+ symbol, linestyle, linewidth, constraint):
"""Add a point, vertical line or horizontal line marker to the plot.
:param float x: Horizontal position of the marker in graph coordinates.
@@ -212,7 +211,17 @@ class BackendBase(object):
- 'x' x-cross
- 'd' diamond
- 's' square
+ :param str linestyle: Style of the line.
+ Only relevant for line markers where X or Y is None.
+ Value in:
+ - ' ' no line
+ - '-' solid line
+ - '--' dashed line
+ - '-.' dash-dot line
+ - ':' dotted line
+ :param float linewidth: Width of the line.
+ Only relevant for line markers where X or Y is None.
:param constraint: A function filtering marker displacement by
dragging operations or None for no filter.
This function is called each time a marker is
diff --git a/silx/gui/plot/backends/BackendMatplotlib.py b/silx/gui/plot/backends/BackendMatplotlib.py
index 49c4540..3b1d6dd 100644
--- a/silx/gui/plot/backends/BackendMatplotlib.py
+++ b/silx/gui/plot/backends/BackendMatplotlib.py
@@ -28,7 +28,7 @@ from __future__ import division
__authors__ = ["V.A. Sole", "T. Vincent, H. Payno"]
__license__ = "MIT"
-__date__ = "18/10/2017"
+__date__ = "01/08/2018"
import logging
@@ -56,8 +56,7 @@ from matplotlib.collections import PathCollection, LineCollection
from matplotlib.ticker import Formatter, ScalarFormatter, Locator
-
-from ..matplotlib.ModestImage import ModestImage
+from ....third_party.modest_image import ModestImage
from . import BackendBase
from .._utils import FLOAT32_MINPOS
from .._utils.dtime_ticklayout import calcTicks, bestFormatString, timestamp
@@ -520,7 +519,7 @@ class BackendMatplotlib(BackendBase.BackendBase):
def addMarker(self, x, y, legend, text, color,
selectable, draggable,
- symbol, constraint):
+ symbol, linestyle, linewidth, constraint):
legend = "__MARKER__" + legend
textArtist = None
@@ -548,7 +547,11 @@ class BackendMatplotlib(BackendBase.BackendBase):
verticalalignment=valign)
elif x is not None:
- line = self.ax.axvline(x, label=legend, color=color)
+ line = self.ax.axvline(x,
+ label=legend,
+ color=color,
+ linewidth=linewidth,
+ linestyle=linestyle)
if text is not None:
# Y position will be updated in updateMarkerText call
textArtist = self.ax.text(x, 1., " " + text,
@@ -557,7 +560,11 @@ class BackendMatplotlib(BackendBase.BackendBase):
verticalalignment='top')
elif y is not None:
- line = self.ax.axhline(y, label=legend, color=color)
+ line = self.ax.axhline(y,
+ label=legend,
+ color=color,
+ linewidth=linewidth,
+ linestyle=linestyle)
if text is not None:
# X position will be updated in updateMarkerText call
@@ -1117,7 +1124,6 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
# cursor
_QT_CURSORS = {
- None: qt.Qt.ArrowCursor,
BackendBase.CURSOR_DEFAULT: qt.Qt.ArrowCursor,
BackendBase.CURSOR_POINTING: qt.Qt.PointingHandCursor,
BackendBase.CURSOR_SIZE_HOR: qt.Qt.SizeHorCursor,
@@ -1126,6 +1132,8 @@ class BackendMatplotlibQt(FigureCanvasQTAgg, BackendMatplotlib):
}
def setGraphCursorShape(self, cursor):
- cursor = self._QT_CURSORS[cursor]
-
- FigureCanvasQTAgg.setCursor(self, qt.QCursor(cursor))
+ if cursor is None:
+ FigureCanvasQTAgg.unsetCursor(self)
+ else:
+ cursor = self._QT_CURSORS[cursor]
+ FigureCanvasQTAgg.setCursor(self, qt.QCursor(cursor))
diff --git a/silx/gui/plot/backends/BackendOpenGL.py b/silx/gui/plot/backends/BackendOpenGL.py
index 0001bb9..9e2cb73 100644
--- a/silx/gui/plot/backends/BackendOpenGL.py
+++ b/silx/gui/plot/backends/BackendOpenGL.py
@@ -28,7 +28,7 @@ from __future__ import division
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "24/04/2018"
+__date__ = "01/08/2018"
from collections import OrderedDict, namedtuple
from ctypes import c_void_p
@@ -1161,11 +1161,15 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
def addMarker(self, x, y, legend, text, color,
selectable, draggable,
- symbol, constraint):
+ symbol, linestyle, linewidth, constraint):
if symbol is None:
symbol = '+'
+ if linestyle != '-' or linewidth != 1:
+ _logger.warning(
+ 'OpenGL backend does not support marker line style and width.')
+
behaviors = set()
if selectable:
behaviors.add('selectable')
@@ -1223,7 +1227,6 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
# Interaction methods
_QT_CURSORS = {
- None: qt.Qt.ArrowCursor,
BackendBase.CURSOR_DEFAULT: qt.Qt.ArrowCursor,
BackendBase.CURSOR_POINTING: qt.Qt.PointingHandCursor,
BackendBase.CURSOR_SIZE_HOR: qt.Qt.SizeHorCursor,
@@ -1232,9 +1235,11 @@ class BackendOpenGL(BackendBase.BackendBase, glu.OpenGLWidget):
}
def setGraphCursorShape(self, cursor):
- cursor = self._QT_CURSORS[cursor]
-
- super(BackendOpenGL, self).setCursor(qt.QCursor(cursor))
+ if cursor is None:
+ super(BackendOpenGL, self).unsetCursor()
+ else:
+ cursor = self._QT_CURSORS[cursor]
+ super(BackendOpenGL, self).setCursor(qt.QCursor(cursor))
def setGraphCursor(self, flag, color, linewidth, linestyle):
if linestyle is not '-':
diff --git a/silx/gui/plot/backends/glutils/GLText.py b/silx/gui/plot/backends/glutils/GLText.py
index 1540e26..3d262bc 100644
--- a/silx/gui/plot/backends/glutils/GLText.py
+++ b/silx/gui/plot/backends/glutils/GLText.py
@@ -32,6 +32,7 @@ __license__ = "MIT"
__date__ = "03/04/2017"
+from collections import OrderedDict
import numpy
from ...._glutils import font, gl, getGLContext, Program, Texture
@@ -41,6 +42,45 @@ from .GLSupport import mat4Translate
# TODO: Font should be configurable by the main program: using mpl.rcParams?
+class _Cache(object):
+ """LRU (Least Recent Used) cache.
+
+ :param int maxsize: Maximum number of (key, value) pairs in the cache
+ :param callable callback:
+ Called when a (key, value) pair is removed from the cache.
+ It must take 2 arguments: key and value.
+ """
+
+ def __init__(self, maxsize=128, callback=None):
+ self._maxsize = int(maxsize)
+ self._callback = callback
+ self._cache = OrderedDict()
+
+ def __contains__(self, item):
+ return item in self._cache
+
+ def __getitem__(self, key):
+ if key in self._cache:
+ # Remove/add key from ordered dict to store last access info
+ value = self._cache.pop(key)
+ self._cache[key] = value
+ return value
+ else:
+ raise KeyError
+
+ def __setitem__(self, key, value):
+ """Add a key, value pair to the cache.
+
+ :param key: The key to set
+ :param value: The corresponding value
+ """
+ if key not in self._cache and len(self._cache) >= self._maxsize:
+ removedKey, removedValue = self._cache.popitem(last=False)
+ if self._callback is not None:
+ self._callback(removedKey, removedValue)
+ self._cache[key] = value
+
+
# Text2D ######################################################################
LEFT, CENTER, RIGHT = 'left', 'center', 'right'
@@ -87,11 +127,11 @@ class Text2D(object):
_SHADERS['fragment'],
attrib0='position')
- _textures = {}
+ # Discard texture objects when removed from the cache
+ _textures = _Cache(callback=lambda key, value: value[0].discard())
"""Cache already created textures"""
- # TODO limit cache size and discard least recent used
- _sizes = {}
+ _sizes = _Cache()
"""Cache already computed sizes"""
def __init__(self, text, x=0, y=0,
diff --git a/silx/gui/plot/items/core.py b/silx/gui/plot/items/core.py
index 4ed0914..e000751 100644
--- a/silx/gui/plot/items/core.py
+++ b/silx/gui/plot/items/core.py
@@ -98,7 +98,10 @@ class ItemChangedType(enum.Enum):
"""Item's highlight state changed flag."""
HIGHLIGHTED_COLOR = 'highlightedColorChanged'
- """Item's highlighted color changed flag."""
+ """Deprecated, use HIGHLIGHTED_STYLE instead."""
+
+ HIGHLIGHTED_STYLE = 'highlightedStyleChanged'
+ """Item's highlighted style changed flag."""
SCALE = 'scaleChanged'
"""Item's scale changed flag."""
@@ -548,12 +551,26 @@ class LineMixIn(ItemMixInBase):
_DEFAULT_LINESTYLE = '-'
"""Default line style"""
+ _SUPPORTED_LINESTYLE = '', ' ', '-', '--', '-.', ':', None
+ """Supported line styles"""
+
def __init__(self):
self._linewidth = self._DEFAULT_LINEWIDTH
self._linestyle = self._DEFAULT_LINESTYLE
+ @classmethod
+ def getSupportedLineStyles(cls):
+ """Returns list of supported line styles.
+
+ :rtype: List[str,None]
+ """
+ return cls._SUPPORTED_LINESTYLE
+
def getLineWidth(self):
- """Return the curve line width in pixels (int)"""
+ """Return the curve line width in pixels
+
+ :rtype: float
+ """
return self._linewidth
def setLineWidth(self, width):
@@ -591,7 +608,7 @@ class LineMixIn(ItemMixInBase):
:param str style: Line style
"""
style = str(style)
- assert style in ('', ' ', '-', '--', '-.', ':', None)
+ assert style in self.getSupportedLineStyles()
if style is None:
style = self._DEFAULT_LINESTYLE
if style != self._linestyle:
diff --git a/silx/gui/plot/items/curve.py b/silx/gui/plot/items/curve.py
index 50ad86d..80d9dea 100644
--- a/silx/gui/plot/items/curve.py
+++ b/silx/gui/plot/items/curve.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 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
@@ -33,14 +33,123 @@ __date__ = "24/04/2018"
import logging
import numpy
+from silx.third_party import six
+from ....utils.deprecation import deprecated
from ... import colors
from .core import (Points, LabelsMixIn, ColorMixIn, YAxisMixIn,
- FillMixIn, LineMixIn, ItemChangedType)
+ FillMixIn, LineMixIn, SymbolMixIn, ItemChangedType)
_logger = logging.getLogger(__name__)
+class CurveStyle(object):
+ """Object storing the style of a curve.
+
+ Set a value to None to use the default
+
+ :param color: Color
+ :param Union[str,None] linestyle: Style of the line
+ :param Union[float,None] linewidth: Width of the line
+ :param Union[str,None] symbol: Symbol for markers
+ :param Union[float,None] symbolsize: Size of the markers
+ """
+
+ def __init__(self, color=None, linestyle=None, linewidth=None,
+ symbol=None, symbolsize=None):
+ if color is None:
+ self._color = None
+ else:
+ if isinstance(color, six.string_types):
+ color = colors.rgba(color)
+ else: # array-like expected
+ color = numpy.array(color, copy=False)
+ if color.ndim == 1: # Array is 1D, this is a single color
+ color = colors.rgba(color)
+ self._color = color
+
+ if linestyle is not None:
+ assert linestyle in LineMixIn.getSupportedLineStyles()
+ self._linestyle = linestyle
+
+ self._linewidth = None if linewidth is None else float(linewidth)
+
+ if symbol is not None:
+ assert symbol in SymbolMixIn.getSupportedSymbols()
+ self._symbol = symbol
+
+ self._symbolsize = None if symbolsize is None else float(symbolsize)
+
+ def getColor(self, copy=True):
+ """Returns the color or None if not set.
+
+ :param bool copy: True to get a copy (default),
+ False to get internal representation (do not modify!)
+
+ :rtype: Union[List[float],None]
+ """
+ if isinstance(self._color, numpy.ndarray):
+ return numpy.array(self._color, copy=copy)
+ else:
+ return self._color
+
+ def getLineStyle(self):
+ """Return the type of the line or None if not set.
+
+ Type of line::
+
+ - ' ' no line
+ - '-' solid line
+ - '--' dashed line
+ - '-.' dash-dot line
+ - ':' dotted line
+
+ :rtype: Union[str,None]
+ """
+ return self._linestyle
+
+ def getLineWidth(self):
+ """Return the curve line width in pixels or None if not set.
+
+ :rtype: Union[float,None]
+ """
+ return self._linewidth
+
+ def getSymbol(self):
+ """Return the point marker type.
+
+ Marker type::
+
+ - 'o' circle
+ - '.' point
+ - ',' pixel
+ - '+' cross
+ - 'x' x-cross
+ - 'd' diamond
+ - 's' square
+
+ :rtype: Union[str,None]
+ """
+ return self._symbol
+
+ def getSymbolSize(self):
+ """Return the point marker size in points.
+
+ :rtype: Union[float,None]
+ """
+ return self._symbolsize
+
+ def __eq__(self, other):
+ if isinstance(other, CurveStyle):
+ return (numpy.array_equal(self.getColor(), other.getColor()) and
+ self.getLineStyle() == other.getLineStyle() and
+ self.getLineWidth() == other.getLineWidth() and
+ self.getSymbol() == other.getSymbol() and
+ self.getSymbolSize() == other.getSymbolSize())
+ else:
+ return False
+
+
class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn):
"""Description of a curve"""
@@ -56,8 +165,8 @@ class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn):
_DEFAULT_LINESTYLE = '-'
"""Default line style of the curve"""
- _DEFAULT_HIGHLIGHT_COLOR = (0, 0, 0, 255)
- """Default highlight color of the item"""
+ _DEFAULT_HIGHLIGHT_STYLE = CurveStyle(color='black')
+ """Default highlight style of the item"""
def __init__(self):
Points.__init__(self)
@@ -67,9 +176,18 @@ class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn):
LabelsMixIn.__init__(self)
LineMixIn.__init__(self)
- self._highlightColor = self._DEFAULT_HIGHLIGHT_COLOR
+ self._highlightStyle = self._DEFAULT_HIGHLIGHT_STYLE
self._highlighted = False
+ self.sigItemChanged.connect(self.__itemChanged)
+
+ def __itemChanged(self, event):
+ if event == ItemChangedType.YAXIS:
+ # TODO hackish data range implementation
+ plot = self.getPlot()
+ if plot is not None:
+ plot._invalidateDataRange()
+
def _addBackendRenderer(self, backend):
"""Update backend renderer"""
# Filter-out values <= 0
@@ -79,11 +197,13 @@ class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn):
if len(xFiltered) == 0 or not numpy.any(numpy.isfinite(xFiltered)):
return None # No data to display, do not add renderer to backend
+ style = self.getCurrentStyle()
+
return backend.addCurve(xFiltered, yFiltered, self.getLegend(),
- color=self.getCurrentColor(),
- symbol=self.getSymbol(),
- linestyle=self.getLineStyle(),
- linewidth=self.getLineWidth(),
+ color=style.getColor(),
+ symbol=style.getSymbol(),
+ linestyle=style.getLineStyle(),
+ linewidth=style.getLineWidth(),
yaxis=self.getYAxis(),
xerror=xerror,
yerror=yerror,
@@ -91,7 +211,7 @@ class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn):
selectable=self.isSelectable(),
fill=self.isFill(),
alpha=self.getAlpha(),
- symbolsize=self.getSymbolSize())
+ symbolsize=style.getSymbolSize())
def __getitem__(self, item):
"""Compatibility with PyMca and silx <= 0.4.0"""
@@ -158,13 +278,39 @@ class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn):
# TODO inefficient: better to use backend's setCurveColor
self._updated(ItemChangedType.HIGHLIGHTED)
+ def getHighlightedStyle(self):
+ """Returns the highlighted style in use
+
+ :rtype: CurveStyle
+ """
+ return self._highlightStyle
+
+ def setHighlightedStyle(self, style):
+ """Set the style to use for highlighting
+
+ :param CurveStyle style: New style to use
+ """
+ previous = self.getHighlightedStyle()
+ if style != previous:
+ assert isinstance(style, CurveStyle)
+ self._highlightStyle = style
+ self._updated(ItemChangedType.HIGHLIGHTED_STYLE)
+
+ # Backward compatibility event
+ if previous.getColor() != style.getColor():
+ self._updated(ItemChangedType.HIGHLIGHTED_COLOR)
+
+ @deprecated(replacement='Curve.getHighlightedStyle().getColor()',
+ since_version='0.9.0')
def getHighlightedColor(self):
"""Returns the RGBA highlight color of the item
- :rtype: 4-tuple of int in [0, 255]
+ :rtype: 4-tuple of float in [0, 1]
"""
- return self._highlightColor
+ return self.getHighlightedStyle().getColor()
+ @deprecated(replacement='Curve.setHighlightedStyle()',
+ since_version='0.9.0')
def setHighlightedColor(self, color):
"""Set the color to use when highlighted
@@ -172,20 +318,45 @@ class Curve(Points, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, LineMixIn):
:type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or
one of the predefined color names defined in colors.py
"""
- color = colors.rgba(color)
- if color != self._highlightColor:
- self._highlightColor = color
- self._updated(ItemChangedType.HIGHLIGHTED_COLOR)
+ self.setHighlightedStyle(CurveStyle(color))
+
+ def getCurrentStyle(self):
+ """Returns the current curve style.
+
+ Curve style depends on curve highlighting
+
+ :rtype: CurveStyle
+ """
+ if self.isHighlighted():
+ style = self.getHighlightedStyle()
+ color = style.getColor()
+ linestyle = style.getLineStyle()
+ linewidth = style.getLineWidth()
+ symbol = style.getSymbol()
+ symbolsize = style.getSymbolSize()
+
+ return CurveStyle(
+ color=self.getColor() if color is None else color,
+ linestyle=self.getLineStyle() if linestyle is None else linestyle,
+ linewidth=self.getLineWidth() if linewidth is None else linewidth,
+ symbol=self.getSymbol() if symbol is None else symbol,
+ symbolsize=self.getSymbolSize() if symbolsize is None else symbolsize)
+ else:
+ return CurveStyle(color=self.getColor(),
+ linestyle=self.getLineStyle(),
+ linewidth=self.getLineWidth(),
+ symbol=self.getSymbol(),
+ symbolsize=self.getSymbolSize())
+
+ @deprecated(replacement='Curve.getCurrentStyle()',
+ since_version='0.9.0')
def getCurrentColor(self):
"""Returns the current color of the curve.
This color is either the color of the curve or the highlighted color,
depending on the highlight state.
- :rtype: 4-tuple of int in [0, 255]
+ :rtype: 4-tuple of float in [0, 1]
"""
- if self.isHighlighted():
- return self.getHighlightedColor()
- else:
- return self.getColor()
+ return self.getCurrentStyle().getColor()
diff --git a/silx/gui/plot/items/histogram.py b/silx/gui/plot/items/histogram.py
index 3545345..389e8a6 100644
--- a/silx/gui/plot/items/histogram.py
+++ b/silx/gui/plot/items/histogram.py
@@ -27,7 +27,7 @@
__authors__ = ["H. Payno", "T. Vincent"]
__license__ = "MIT"
-__date__ = "27/06/2017"
+__date__ = "28/08/2018"
import logging
@@ -290,6 +290,11 @@ class Histogram(Item, AlphaMixIn, ColorMixIn, FillMixIn,
self._edges = edges
self._alignement = align
+ if self.isVisible():
+ plot = self.getPlot()
+ if plot is not None:
+ plot._invalidateDataRange()
+
self._updated(ItemChangedType.DATA)
def getAlignment(self):
diff --git a/silx/gui/plot/items/marker.py b/silx/gui/plot/items/marker.py
index 8f79033..09767a5 100644
--- a/silx/gui/plot/items/marker.py
+++ b/silx/gui/plot/items/marker.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 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
@@ -32,7 +32,7 @@ __date__ = "06/03/2017"
import logging
-from .core import (Item, DraggableMixIn, ColorMixIn, SymbolMixIn,
+from .core import (Item, DraggableMixIn, ColorMixIn, LineMixIn, SymbolMixIn,
ItemChangedType)
@@ -55,11 +55,9 @@ class _BaseMarker(Item, DraggableMixIn, ColorMixIn):
self._y = None
self._constraint = self._defaultConstraint
- def _addBackendRenderer(self, backend):
- """Update backend renderer"""
- # TODO not very nice way to do it, but simple
- symbol = self.getSymbol() if isinstance(self, Marker) else None
-
+ def _addRendererCall(self, backend,
+ symbol=None, linestyle='-', linewidth=1):
+ """Perform the update of the backend renderer"""
return backend.addMarker(
x=self.getXPosition(),
y=self.getYPosition(),
@@ -69,8 +67,14 @@ class _BaseMarker(Item, DraggableMixIn, ColorMixIn):
selectable=self.isSelectable(),
draggable=self.isDraggable(),
symbol=symbol,
+ linestyle=linestyle,
+ linewidth=linewidth,
constraint=self.getConstraint())
+ def _addBackendRenderer(self, backend):
+ """Update backend renderer"""
+ raise NotImplementedError()
+
def isOverlay(self):
"""Return true if marker is drawn as an overlay.
@@ -175,6 +179,9 @@ class Marker(_BaseMarker, SymbolMixIn):
self._x = 0.
self._y = 0.
+ def _addBackendRenderer(self, backend):
+ return self._addRendererCall(backend, symbol=self.getSymbol())
+
def _setConstraint(self, constraint):
"""Set the constraint function of the marker drag.
@@ -197,11 +204,24 @@ class Marker(_BaseMarker, SymbolMixIn):
return x, self.getYPosition()
-class XMarker(_BaseMarker):
- """Description of a marker"""
+class _LineMarker(_BaseMarker, LineMixIn):
+ """Base class for line markers"""
def __init__(self):
_BaseMarker.__init__(self)
+ LineMixIn.__init__(self)
+
+ def _addBackendRenderer(self, backend):
+ return self._addRendererCall(backend,
+ linestyle=self.getLineStyle(),
+ linewidth=self.getLineWidth())
+
+
+class XMarker(_LineMarker):
+ """Description of a marker"""
+
+ def __init__(self):
+ _LineMarker.__init__(self)
self._x = 0.
def setPosition(self, x, y):
@@ -219,11 +239,11 @@ class XMarker(_BaseMarker):
self._updated(ItemChangedType.POSITION)
-class YMarker(_BaseMarker):
+class YMarker(_LineMarker):
"""Description of a marker"""
def __init__(self):
- _BaseMarker.__init__(self)
+ _LineMarker.__init__(self)
self._y = 0.
def setPosition(self, x, y):
diff --git a/silx/gui/plot/items/scatter.py b/silx/gui/plot/items/scatter.py
index 72b8496..acc74b4 100644
--- a/silx/gui/plot/items/scatter.py
+++ b/silx/gui/plot/items/scatter.py
@@ -53,7 +53,8 @@ class Scatter(Points, ColormapMixIn):
Points.__init__(self)
ColormapMixIn.__init__(self)
self._value = ()
-
+ self.__alpha = None
+
def _addBackendRenderer(self, backend):
"""Update backend renderer"""
# Filter-out values <= 0
@@ -66,6 +67,9 @@ class Scatter(Points, ColormapMixIn):
cmap = self.getColormap()
rgbacolors = cmap.applyToData(self._value)
+ if self.__alpha is not None:
+ rgbacolors[:, -1] = (rgbacolors[:, -1] * self.__alpha).astype(numpy.uint8)
+
return backend.addCurve(xFiltered, yFiltered, self.getLegend(),
color=rgbacolors,
symbol=self.getSymbol(),
@@ -112,6 +116,15 @@ class Scatter(Points, ColormapMixIn):
"""
return numpy.array(self._value, copy=copy)
+ def getAlphaData(self, copy=True):
+ """Returns the alpha (transparency) assigned to the scatter data points.
+
+ :param copy: True (Default) to get a copy,
+ False to use internal representation (do not modify!)
+ :rtype: numpy.ndarray
+ """
+ return numpy.array(self.__alpha, copy=copy)
+
def getData(self, copy=True, displayed=False):
"""Returns the x, y coordinates and the value of the data points
@@ -137,7 +150,7 @@ class Scatter(Points, ColormapMixIn):
self.getYErrorData(copy))
# reimplemented from Points to handle `value`
- def setData(self, x, y, value, xerror=None, yerror=None, copy=True):
+ def setData(self, x, y, value, xerror=None, yerror=None, alpha=None, copy=True):
"""Set the data of the scatter.
:param numpy.ndarray x: The data corresponding to the x coordinates.
@@ -152,6 +165,8 @@ class Scatter(Points, ColormapMixIn):
row 1 for negative errors.
:param yerror: Values with the uncertainties on the y values
:type yerror: A float, or a numpy.ndarray of float32. See xerror.
+ :param alpha: Values with the transparency (between 0 and 1)
+ :type alpha: A float, or a numpy.ndarray of float32
:param bool copy: True make a copy of the data (default),
False to use provided arrays.
"""
@@ -161,6 +176,17 @@ class Scatter(Points, ColormapMixIn):
self._value = value
+ if alpha is not None:
+ # Make sure alpha is an array of float in [0, 1]
+ alpha = numpy.array(alpha, copy=copy)
+ assert alpha.ndim == 1
+ assert len(x) == len(alpha)
+ if alpha.dtype.kind != 'f':
+ alpha = alpha.astype(numpy.float32)
+ if numpy.any(numpy.logical_or(alpha < 0., alpha > 1.)):
+ alpha = numpy.clip(alpha, 0., 1.)
+ self.__alpha = alpha
+
# set x, y, xerror, yerror
# call self._updated + plot._invalidateDataRange()
diff --git a/silx/gui/plot/matplotlib/ModestImage.py b/silx/gui/plot/matplotlib/ModestImage.py
deleted file mode 100644
index e4a72d5..0000000
--- a/silx/gui/plot/matplotlib/ModestImage.py
+++ /dev/null
@@ -1,174 +0,0 @@
-# coding: utf-8
-# /*##########################################################################
-#
-# Copyright (c) 2004-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.
-#
-# ############################################################################*/
-"""Matplotlib computationally modest image class."""
-
-__authors__ = ["V.A. Sole", "T. Vincent"]
-__license__ = "MIT"
-__date__ = "03/05/2017"
-
-
-import numpy
-
-from matplotlib import cbook
-from matplotlib.image import AxesImage
-
-
-class ModestImage(AxesImage):
- """Computationally modest image class.
-
-Customization of https://github.com/ChrisBeaumont/ModestImage to allow
-extent support.
-
-ModestImage is an extension of the Matplotlib AxesImage class
-better suited for the interactive display of larger images. Before
-drawing, ModestImage resamples the data array based on the screen
-resolution and view window. This has very little affect on the
-appearance of the image, but can substantially cut down on
-computation since calculations of unresolved or clipped pixels
-are skipped.
-
-The interface of ModestImage is the same as AxesImage. However, it
-does not currently support setting the 'extent' property. There
-may also be weird coordinate warping operations for images that
-I'm not aware of. Don't expect those to work either.
-"""
- def __init__(self, *args, **kwargs):
- self._full_res = None
- self._sx, self._sy = None, None
- self._bounds = (None, None, None, None)
- self._origExtent = None
- super(ModestImage, self).__init__(*args, **kwargs)
- if 'extent' in kwargs and kwargs['extent'] is not None:
- self.set_extent(kwargs['extent'])
-
- def set_extent(self, extent):
- super(ModestImage, self).set_extent(extent)
- if self._origExtent is None:
- self._origExtent = self.get_extent()
-
- def get_image_extent(self):
- """Returns the extent of the whole image.
-
- get_extent returns the extent of the drawn area and not of the full
- image.
-
- :return: Bounds of the image (x0, x1, y0, y1).
- :rtype: Tuple of 4 floats.
- """
- if self._origExtent is not None:
- return self._origExtent
- else:
- return self.get_extent()
-
- def set_data(self, A):
- """
- Set the image array
-
- ACCEPTS: numpy/PIL Image A
- """
-
- self._full_res = A
- self._A = A
-
- if (self._A.dtype != numpy.uint8 and
- not numpy.can_cast(self._A.dtype, numpy.float)):
- raise TypeError("Image data can not convert to float")
-
- if (self._A.ndim not in (2, 3) or
- (self._A.ndim == 3 and self._A.shape[-1] not in (3, 4))):
- raise TypeError("Invalid dimensions for image data")
-
- self._imcache = None
- self._rgbacache = None
- self._oldxslice = None
- self._oldyslice = None
- self._sx, self._sy = None, None
-
- def get_array(self):
- """Override to return the full-resolution array"""
- return self._full_res
-
- def _scale_to_res(self):
- """ Change self._A and _extent to render an image whose
-resolution is matched to the eventual rendering."""
- # extent has to be set BEFORE set_data
- if self._origExtent is None:
- if self.origin == "upper":
- self._origExtent = (0, self._full_res.shape[1],
- self._full_res.shape[0], 0)
- else:
- self._origExtent = (0, self._full_res.shape[1],
- 0, self._full_res.shape[0])
-
- if self.origin == "upper":
- origXMin, origXMax, origYMax, origYMin = self._origExtent[0:4]
- else:
- origXMin, origXMax, origYMin, origYMax = self._origExtent[0:4]
- ax = self.axes
- ext = ax.transAxes.transform([1, 1]) - ax.transAxes.transform([0, 0])
- xlim, ylim = ax.get_xlim(), ax.get_ylim()
- xlim = max(xlim[0], origXMin), min(xlim[1], origXMax)
- if ylim[0] > ylim[1]:
- ylim = max(ylim[1], origYMin), min(ylim[0], origYMax)
- else:
- ylim = max(ylim[0], origYMin), min(ylim[1], origYMax)
- # print("THOSE LIMITS ARE TO BE COMPARED WITH THE EXTENT")
- # print("IN ORDER TO KNOW WHAT IT IS LIMITING THE DISPLAY")
- # print("IF THE AXES OR THE EXTENT")
- dx, dy = xlim[1] - xlim[0], ylim[1] - ylim[0]
-
- y0 = max(0, ylim[0] - 5)
- y1 = min(self._full_res.shape[0], ylim[1] + 5)
- x0 = max(0, xlim[0] - 5)
- x1 = min(self._full_res.shape[1], xlim[1] + 5)
- y0, y1, x0, x1 = [int(a) for a in [y0, y1, x0, x1]]
-
- sy = int(max(1, min((y1 - y0) / 5., numpy.ceil(dy / ext[1]))))
- sx = int(max(1, min((x1 - x0) / 5., numpy.ceil(dx / ext[0]))))
-
- # have we already calculated what we need?
- if (self._sx is not None) and (self._sy is not None):
- if (sx >= self._sx and sy >= self._sy and
- x0 >= self._bounds[0] and x1 <= self._bounds[1] and
- y0 >= self._bounds[2] and y1 <= self._bounds[3]):
- return
-
- self._A = self._full_res[y0:y1:sy, x0:x1:sx]
- self._A = cbook.safe_masked_invalid(self._A)
- x1 = x0 + self._A.shape[1] * sx
- y1 = y0 + self._A.shape[0] * sy
-
- if self.origin == "upper":
- self.set_extent([x0, x1, y1, y0])
- else:
- self.set_extent([x0, x1, y0, y1])
- self._sx = sx
- self._sy = sy
- self._bounds = (x0, x1, y0, y1)
- self.changed()
-
- def draw(self, renderer, *args, **kwargs):
- self._scale_to_res()
- super(ModestImage, self).draw(renderer, *args, **kwargs)
diff --git a/silx/gui/plot/test/__init__.py b/silx/gui/plot/test/__init__.py
index 1428bad..89c10c6 100644
--- a/silx/gui/plot/test/__init__.py
+++ b/silx/gui/plot/test/__init__.py
@@ -24,7 +24,7 @@
# ###########################################################################*/
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "24/04/2018"
+__date__ = "23/07/2018"
import unittest
@@ -52,6 +52,7 @@ from . import testImageView
from . import testSaveAction
from . import testScatterView
from . import testPixelIntensityHistoAction
+from . import testCompareImages
def suite():
@@ -83,6 +84,7 @@ def suite():
testImageView.suite(),
testSaveAction.suite(),
testScatterView.suite(),
- testPixelIntensityHistoAction.suite()
+ testPixelIntensityHistoAction.suite(),
+ testCompareImages.suite()
])
return test_suite
diff --git a/silx/gui/plot/test/testAlphaSlider.py b/silx/gui/plot/test/testAlphaSlider.py
index 304a562..63de441 100644
--- a/silx/gui/plot/test/testAlphaSlider.py
+++ b/silx/gui/plot/test/testAlphaSlider.py
@@ -33,7 +33,7 @@ import numpy
import unittest
from silx.gui import qt
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui.plot import PlotWidget
from silx.gui.plot import AlphaSlider
diff --git a/silx/gui/plot/test/testColorBar.py b/silx/gui/plot/test/testColorBar.py
index 0d1c952..9a02e04 100644
--- a/silx/gui/plot/test/testColorBar.py
+++ b/silx/gui/plot/test/testColorBar.py
@@ -29,7 +29,7 @@ __license__ = "MIT"
__date__ = "24/04/2018"
import unittest
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui.plot.ColorBar import _ColorScale
from silx.gui.plot.ColorBar import ColorBarWidget
from silx.gui.colors import Colormap
diff --git a/silx/gui/plot/test/testCompareImages.py b/silx/gui/plot/test/testCompareImages.py
new file mode 100644
index 0000000..ed6942a
--- /dev/null
+++ b/silx/gui/plot/test/testCompareImages.py
@@ -0,0 +1,117 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# 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.
+#
+# ###########################################################################*/
+"""Tests for CompareImages widget"""
+
+__authors__ = ["H. Payno"]
+__license__ = "MIT"
+__date__ = "23/07/2018"
+
+import unittest
+import numpy
+import weakref
+
+from silx.gui.utils.testutils import TestCaseQt
+from silx.gui.plot.CompareImages import CompareImages
+
+
+class TestCompareImages(TestCaseQt):
+ """Test that CompareImages widget is working in some cases"""
+
+ def setUp(self):
+ super(TestCompareImages, self).setUp()
+ self.widget = CompareImages()
+
+ def tearDown(self):
+ ref = weakref.ref(self.widget)
+ self.widget = None
+ self.qWaitForDestroy(ref)
+ super(TestCompareImages, self).tearDown()
+
+ def testIntensityImage(self):
+ image1 = numpy.random.rand(10, 10)
+ image2 = numpy.random.rand(10, 10)
+ self.widget.setData(image1, image2)
+
+ def testRgbImage(self):
+ image1 = numpy.random.randint(0, 255, size=(10, 10, 3))
+ image2 = numpy.random.randint(0, 255, size=(10, 10, 3))
+ self.widget.setData(image1, image2)
+
+ def testRgbaImage(self):
+ image1 = numpy.random.randint(0, 255, size=(10, 10, 4))
+ image2 = numpy.random.randint(0, 255, size=(10, 10, 4))
+ self.widget.setData(image1, image2)
+
+ def testVizualisations(self):
+ image1 = numpy.random.rand(10, 10)
+ image2 = numpy.random.rand(10, 10)
+ self.widget.setData(image1, image2)
+ for mode in CompareImages.VisualizationMode:
+ self.widget.setVisualizationMode(mode)
+
+ def testAlignemnt(self):
+ image1 = numpy.random.rand(10, 10)
+ image2 = numpy.random.rand(5, 5)
+ self.widget.setData(image1, image2)
+ for mode in CompareImages.AlignmentMode:
+ self.widget.setAlignmentMode(mode)
+
+ def testGetPixel(self):
+ image1 = numpy.random.rand(11, 11)
+ image2 = numpy.random.rand(5, 5)
+ image1[5, 5] = 111.111
+ image2[2, 2] = 222.222
+ self.widget.setData(image1, image2)
+ expectedValue = {}
+ expectedValue[CompareImages.AlignmentMode.CENTER] = 222.222
+ expectedValue[CompareImages.AlignmentMode.STRETCH] = 222.222
+ expectedValue[CompareImages.AlignmentMode.ORIGIN] = None
+ for mode in expectedValue.keys():
+ self.widget.setAlignmentMode(mode)
+ data = self.widget.getRawPixelData(11 / 2.0, 11 / 2.0)
+ data1, data2 = data
+ self.assertEqual(data1, 111.111)
+ self.assertEqual(data2, expectedValue[mode])
+
+ def testImageEmpty(self):
+ self.widget.setData(image1=None, image2=None)
+ self.assertTrue(self.widget.getRawPixelData(11 / 2.0, 11 / 2.0) == (None, None))
+
+ def testSetImageSeparately(self):
+ self.widget.setImage1(numpy.random.rand(10, 10))
+ self.widget.setImage2(numpy.random.rand(10, 10))
+ for mode in CompareImages.VisualizationMode:
+ self.widget.setVisualizationMode(mode)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ loadTests = unittest.defaultTestLoader.loadTestsFromTestCase
+ test_suite.addTest(loadTests(TestCompareImages))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/test/testCurvesROIWidget.py b/silx/gui/plot/test/testCurvesROIWidget.py
index 7a2e3d1..0704779 100644
--- a/silx/gui/plot/test/testCurvesROIWidget.py
+++ b/silx/gui/plot/test/testCurvesROIWidget.py
@@ -36,7 +36,7 @@ from collections import OrderedDict
import numpy
from silx.gui import qt
from silx.test.utils import temp_dir
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui.plot import PlotWindow, CurvesROIWidget
diff --git a/silx/gui/plot/test/testImageView.py b/silx/gui/plot/test/testImageView.py
index 5059a0b..3c8d84c 100644
--- a/silx/gui/plot/test/testImageView.py
+++ b/silx/gui/plot/test/testImageView.py
@@ -33,7 +33,7 @@ import unittest
import numpy
from silx.gui import qt
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui.plot import ImageView
from silx.gui.colors import Colormap
diff --git a/silx/gui/plot/test/testItem.py b/silx/gui/plot/test/testItem.py
index 1ba09c6..993cce7 100644
--- a/silx/gui/plot/test/testItem.py
+++ b/silx/gui/plot/test/testItem.py
@@ -33,7 +33,7 @@ import unittest
import numpy
-from silx.gui.test.utils import SignalListener
+from silx.gui.utils.testutils import SignalListener
from silx.gui.plot.items import ItemChangedType
from .utils import PlotWidgetTestCase
diff --git a/silx/gui/plot/test/testLegendSelector.py b/silx/gui/plot/test/testLegendSelector.py
index 9d4ada7..de5ffde 100644
--- a/silx/gui/plot/test/testLegendSelector.py
+++ b/silx/gui/plot/test/testLegendSelector.py
@@ -33,7 +33,7 @@ import logging
import unittest
from silx.gui import qt
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui.plot import LegendSelector
diff --git a/silx/gui/plot/test/testMaskToolsWidget.py b/silx/gui/plot/test/testMaskToolsWidget.py
index 40c1db3..6912ea3 100644
--- a/silx/gui/plot/test/testMaskToolsWidget.py
+++ b/silx/gui/plot/test/testMaskToolsWidget.py
@@ -38,7 +38,7 @@ import numpy
from silx.gui import qt
from silx.test.utils import temp_dir
from silx.utils.testutils import ParametricTestCase
-from silx.gui.test.utils import getQToolButtonFromAction
+from silx.gui.utils.testutils import getQToolButtonFromAction
from silx.gui.plot import PlotWindow, MaskToolsWidget
from .utils import PlotWidgetTestCase
@@ -87,10 +87,10 @@ class TestMaskToolsWidget(PlotWidgetTestCase, ParametricTestCase):
self.mouseMove(plot, pos=(0, 0))
self.mouseMove(plot, pos=pos0)
- self.mousePress(plot, qt.Qt.LeftButton, pos=pos0)
+ self.mouseClick(plot, qt.Qt.LeftButton, pos=pos0)
self.mouseMove(plot, pos=(0, 0))
self.mouseMove(plot, pos=pos1)
- self.mouseRelease(plot, qt.Qt.LeftButton, pos=pos1)
+ self.mouseClick(plot, qt.Qt.LeftButton, pos=pos1)
def _drawPolygon(self):
"""Draw a star polygon in the plot"""
@@ -108,7 +108,9 @@ class TestMaskToolsWidget(PlotWidgetTestCase, ParametricTestCase):
self.mouseMove(plot, pos=(0, 0))
for pos in star:
self.mouseMove(plot, pos=pos)
+ self.qapp.processEvents()
self.mouseClick(plot, qt.Qt.LeftButton, pos=pos)
+ self.qapp.processEvents()
def _drawPencil(self):
"""Draw a star polygon in the plot"""
diff --git a/silx/gui/plot/test/testPixelIntensityHistoAction.py b/silx/gui/plot/test/testPixelIntensityHistoAction.py
index 987e5b2..20d1ea2 100644
--- a/silx/gui/plot/test/testPixelIntensityHistoAction.py
+++ b/silx/gui/plot/test/testPixelIntensityHistoAction.py
@@ -33,7 +33,7 @@ import numpy
import unittest
from silx.utils.testutils import ParametricTestCase
-from silx.gui.test.utils import TestCaseQt, getQToolButtonFromAction
+from silx.gui.utils.testutils import TestCaseQt, getQToolButtonFromAction
from silx.gui import qt
from silx.gui.plot import Plot2D
diff --git a/silx/gui/plot/test/testPlotWidget.py b/silx/gui/plot/test/testPlotWidget.py
index dac6580..857b9bc 100644
--- a/silx/gui/plot/test/testPlotWidget.py
+++ b/silx/gui/plot/test/testPlotWidget.py
@@ -26,7 +26,7 @@
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "24/04/2018"
+__date__ = "21/09/2018"
import unittest
@@ -34,8 +34,8 @@ import logging
import numpy
from silx.utils.testutils import ParametricTestCase, parameterize
-from silx.gui.test.utils import SignalListener
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import SignalListener
+from silx.gui.utils.testutils import TestCaseQt
from silx.utils import testutils
from silx.utils import deprecation
@@ -43,6 +43,7 @@ from silx.test.utils import test_options
from silx.gui import qt
from silx.gui.plot import PlotWidget
+from silx.gui.plot.items.curve import CurveStyle
from silx.gui.colors import Colormap
from .utils import PlotWidgetTestCase
@@ -118,6 +119,7 @@ class TestPlotWidget(PlotWidgetTestCase, ParametricTestCase):
"""Test resizing the widget and receiving limitsChanged events"""
self.plot.resize(200, 200)
self.qapp.processEvents()
+ self.qWait(100)
xlim = self.plot.getXAxis().getLimits()
ylim = self.plot.getYAxis().getLimits()
@@ -129,18 +131,58 @@ class TestPlotWidget(PlotWidgetTestCase, ParametricTestCase):
# Resize without aspect ratio
self.plot.resize(200, 300)
self.qapp.processEvents()
+ self.qWait(100)
self._checkLimits(expectedXLim=xlim, expectedYLim=ylim)
self.assertEqual(listener.callCount(), 0)
# Resize with aspect ratio
self.plot.setKeepDataAspectRatio(True)
self.qapp.processEvents()
+ self.qWait(1000)
listener.clear() # Clean-up received signal
self.plot.resize(200, 200)
self.qapp.processEvents()
+ self.qWait(100)
self.assertNotEqual(listener.callCount(), 0)
+ def testAddRemoveItemSignals(self):
+ """Test sigItemAdded and sigItemAboutToBeRemoved"""
+ listener = SignalListener()
+ self.plot.sigItemAdded.connect(listener.partial('add'))
+ self.plot.sigItemAboutToBeRemoved.connect(listener.partial('remove'))
+
+ self.plot.addCurve((1, 2, 3), (3, 2, 1), legend='curve')
+ self.assertEqual(listener.callCount(), 1)
+
+ curve = self.plot.getCurve('curve')
+ self.plot.remove('curve')
+ self.assertEqual(listener.callCount(), 2)
+ self.assertEqual(listener.arguments(callIndex=0), ('add', curve))
+ self.assertEqual(listener.arguments(callIndex=1), ('remove', curve))
+
+ def testGetItems(self):
+ """Test getItems method"""
+ curve_x = 1, 2
+ self.plot.addCurve(curve_x, (3, 4))
+ image = (0, 1), (2, 3)
+ self.plot.addImage(image)
+ scatter_x = 10, 11
+ self.plot.addScatter(scatter_x, (12, 13), (0, 1))
+ marker_pos = 5, 5
+ self.plot.addMarker(*marker_pos)
+ marker_x = 6
+ self.plot.addXMarker(marker_x)
+ self.plot.addItem((0, 5), (2, 10), shape='rectangle')
+
+ items = self.plot.getItems()
+ self.assertEqual(len(items), 6)
+ self.assertTrue(numpy.all(numpy.equal(items[0].getXData(), curve_x)))
+ self.assertTrue(numpy.all(numpy.equal(items[1].getData(), image)))
+ self.assertTrue(numpy.all(numpy.equal(items[2].getXData(), scatter_x)))
+ self.assertTrue(numpy.all(numpy.equal(items[3].getPosition(), marker_pos)))
+ self.assertTrue(numpy.all(numpy.equal(items[4].getPosition()[0], marker_x)))
+ self.assertEqual(items[5].getType(), 'rectangle')
class TestPlotImage(PlotWidgetTestCase, ParametricTestCase):
"""Basic tests for addImage"""
@@ -270,10 +312,10 @@ class TestPlotImage(PlotWidgetTestCase, ParametricTestCase):
self.plot.setKeepDataAspectRatio(True)
xmin, xmax = self.plot.getXAxis().getLimits()
ymin, ymax = self.plot.getYAxis().getLimits()
- self.assertTrue(xmin <= min(xbounds))
- self.assertTrue(xmax >= max(xbounds))
- self.assertTrue(ymin <= min(ybounds))
- self.assertTrue(ymax >= max(ybounds))
+ self.assertTrue(round(xmin, 7) <= min(xbounds))
+ self.assertTrue(round(xmax, 7) >= max(xbounds))
+ self.assertTrue(round(ymin, 7) <= min(ybounds))
+ self.assertTrue(round(ymax, 7) >= max(ybounds))
self.plot.setKeepDataAspectRatio(False) # Reset aspect ratio
self.plot.clear()
@@ -390,8 +432,7 @@ class TestPlotCurve(PlotWidgetTestCase):
self.plot.addCurve(self.xData, self.yData,
legend="curve 2",
replace=False, resetzoom=False,
- color=color, symbol='o')
-
+ color=color, symbol='o')
class TestPlotMarker(PlotWidgetTestCase):
"""Basic tests for add*Marker"""
@@ -562,7 +603,15 @@ class TestPlotItem(PlotWidgetTestCase):
class TestPlotActiveCurveImage(PlotWidgetTestCase):
- """Basic tests for active image handling"""
+ """Basic tests for active curve and image handling"""
+ xData = numpy.arange(1000)
+ yData = -500 + 100 * numpy.sin(xData)
+ xData2 = xData + 1000
+ yData2 = xData - 1000 + 200 * numpy.random.random(1000)
+
+ def tearDown(self):
+ self.plot.setActiveCurveHandling(False)
+ super(TestPlotActiveCurveImage, self).tearDown()
def testActiveCurveAndLabels(self):
# Active curve handling off, no label change
@@ -589,6 +638,7 @@ class TestPlotActiveCurveImage(PlotWidgetTestCase):
# labels changed as active curve
self.plot.addCurve((1, 2), (1, 2), legend='1',
xlabel='x1', ylabel='y1')
+ self.plot.setActiveCurve('1')
self.assertEqual(self.plot.getXAxis().getLabel(), 'x1')
self.assertEqual(self.plot.getYAxis().getLabel(), 'y1')
@@ -610,6 +660,110 @@ class TestPlotActiveCurveImage(PlotWidgetTestCase):
self.assertEqual(self.plot.getXAxis().getLabel(), 'XLabel')
self.assertEqual(self.plot.getYAxis().getLabel(), 'YLabel')
+ def testPlotActiveCurveSelectionMode(self):
+ self.plot.clear()
+ self.plot.setActiveCurveHandling(True)
+ legend = "curve 1"
+ self.plot.addCurve(self.xData, self.yData,
+ legend=legend,
+ color="green")
+
+ # active curve should be None
+ self.assertEqual(self.plot.getActiveCurve(just_legend=True), None)
+
+ # active curve should be None when None is set as active curve
+ self.plot.setActiveCurve(legend)
+ current = self.plot.getActiveCurve(just_legend=True)
+ self.assertEqual(current, legend)
+ self.plot.setActiveCurve(None)
+ current = self.plot.getActiveCurve(just_legend=True)
+ self.assertEqual(current, None)
+
+ # testing it automatically toggles if there is only one
+ self.plot.setActiveCurveSelectionMode("legacy")
+ current = self.plot.getActiveCurve(just_legend=True)
+ self.assertEqual(current, legend)
+
+ # active curve should not change when None set as active curve
+ self.assertEqual(self.plot.getActiveCurveSelectionMode(), "legacy")
+ self.plot.setActiveCurve(None)
+ current = self.plot.getActiveCurve(just_legend=True)
+ self.assertEqual(current, legend)
+
+ # situation where no curve is active
+ self.plot.clear()
+ self.plot.setActiveCurveHandling(True)
+ self.assertEqual(self.plot.getActiveCurveSelectionMode(), "atmostone")
+ self.plot.addCurve(self.xData, self.yData,
+ legend=legend,
+ color="green")
+ self.assertEqual(self.plot.getActiveCurve(just_legend=True), None)
+ self.plot.addCurve(self.xData2, self.yData2,
+ legend="curve 2",
+ color="red")
+ self.assertEqual(self.plot.getActiveCurve(just_legend=True), None)
+ self.plot.setActiveCurveSelectionMode("legacy")
+ self.assertEqual(self.plot.getActiveCurve(just_legend=True), None)
+
+ # the first curve added should be active
+ self.plot.clear()
+ self.plot.addCurve(self.xData, self.yData,
+ legend=legend,
+ color="green")
+ self.assertEqual(self.plot.getActiveCurve(just_legend=True), legend)
+ self.plot.addCurve(self.xData2, self.yData2,
+ legend="curve 2",
+ color="red")
+ self.assertEqual(self.plot.getActiveCurve(just_legend=True), legend)
+
+ def testActiveCurveStyle(self):
+ """Test change of active curve style"""
+ self.plot.setActiveCurveHandling(True)
+ self.plot.setActiveCurveStyle(color='black')
+ style = self.plot.getActiveCurveStyle()
+ self.assertEqual(style.getColor(), (0., 0., 0., 1.))
+ self.assertIsNone(style.getLineStyle())
+ self.assertIsNone(style.getLineWidth())
+ self.assertIsNone(style.getSymbol())
+ self.assertIsNone(style.getSymbolSize())
+
+ self.plot.addCurve(x=self.xData, y=self.yData, legend="curve1")
+ curve = self.plot.getCurve("curve1")
+ curve.setColor('blue')
+ curve.setLineStyle('-')
+ curve.setLineWidth(1)
+ curve.setSymbol('o')
+ curve.setSymbolSize(5)
+
+ # Check default current style
+ defaultStyle = curve.getCurrentStyle()
+ self.assertEqual(defaultStyle, CurveStyle(color='blue',
+ linestyle='-',
+ linewidth=1,
+ symbol='o',
+ symbolsize=5))
+
+ # Activate curve with highlight color=black
+ self.plot.setActiveCurve("curve1")
+ style = curve.getCurrentStyle()
+ self.assertEqual(style.getColor(), (0., 0., 0., 1.))
+ self.assertEqual(style.getLineStyle(), '-')
+ self.assertEqual(style.getLineWidth(), 1)
+ self.assertEqual(style.getSymbol(), 'o')
+ self.assertEqual(style.getSymbolSize(), 5)
+
+ # Change highlight to linewidth=2
+ self.plot.setActiveCurveStyle(linewidth=2)
+ style = curve.getCurrentStyle()
+ self.assertEqual(style.getColor(), (0., 0., 1., 1.))
+ self.assertEqual(style.getLineStyle(), '-')
+ self.assertEqual(style.getLineWidth(), 2)
+ self.assertEqual(style.getSymbol(), 'o')
+ self.assertEqual(style.getSymbolSize(), 5)
+
+ self.plot.setActiveCurve(None)
+ self.assertEqual(curve.getCurrentStyle(), defaultStyle)
+
def testActiveImageAndLabels(self):
# Active image handling always on, no API for toggling it
self.plot.getXAxis().setLabel('XLabel')
@@ -881,7 +1035,7 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
self.plot.getYAxis(axis="right").sigLimitsChanged.connect(listener.partial(axis="y2"))
self.plot.setLimits(0, 1, 0, 1, 0, 1)
# at least one event per axis
- self.assertEquals(len(set(listener.karguments(argumentName="axis"))), 3)
+ self.assertEqual(len(set(listener.karguments(argumentName="axis"))), 3)
def testLimitsChanged_resetZoom(self):
self.plot.addCurve(self.xData, self.yData,
@@ -894,7 +1048,7 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
self.plot.getYAxis(axis="right").sigLimitsChanged.connect(listener.partial(axis="y2"))
self.plot.resetZoom()
# at least one event per axis
- self.assertEquals(len(set(listener.karguments(argumentName="axis"))), 3)
+ self.assertEqual(len(set(listener.karguments(argumentName="axis"))), 3)
def testLimitsChanged_setXLimit(self):
self.plot.addCurve(self.xData, self.yData,
@@ -906,8 +1060,8 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
axis.sigLimitsChanged.connect(listener)
axis.setLimits(20, 30)
# at least one event per axis
- self.assertEquals(listener.arguments(callIndex=-1), (20.0, 30.0))
- self.assertEquals(axis.getLimits(), (20.0, 30.0))
+ self.assertEqual(listener.arguments(callIndex=-1), (20.0, 30.0))
+ self.assertEqual(axis.getLimits(), (20.0, 30.0))
def testLimitsChanged_setYLimit(self):
self.plot.addCurve(self.xData, self.yData,
@@ -919,8 +1073,8 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
axis.sigLimitsChanged.connect(listener)
axis.setLimits(20, 30)
# at least one event per axis
- self.assertEquals(listener.arguments(callIndex=-1), (20.0, 30.0))
- self.assertEquals(axis.getLimits(), (20.0, 30.0))
+ self.assertEqual(listener.arguments(callIndex=-1), (20.0, 30.0))
+ self.assertEqual(axis.getLimits(), (20.0, 30.0))
def testLimitsChanged_setYRightLimit(self):
self.plot.addCurve(self.xData, self.yData,
@@ -932,8 +1086,8 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
axis.sigLimitsChanged.connect(listener)
axis.setLimits(20, 30)
# at least one event per axis
- self.assertEquals(listener.arguments(callIndex=-1), (20.0, 30.0))
- self.assertEquals(axis.getLimits(), (20.0, 30.0))
+ self.assertEqual(listener.arguments(callIndex=-1), (20.0, 30.0))
+ self.assertEqual(axis.getLimits(), (20.0, 30.0))
def testScaleProxy(self):
listener = SignalListener()
@@ -943,9 +1097,9 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
yright.sigScaleChanged.connect(listener.partial("right"))
yright.setScale(yright.LOGARITHMIC)
- self.assertEquals(y.getScale(), y.LOGARITHMIC)
+ self.assertEqual(y.getScale(), y.LOGARITHMIC)
events = listener.arguments()
- self.assertEquals(len(events), 2)
+ self.assertEqual(len(events), 2)
self.assertIn(("left", y.LOGARITHMIC), events)
self.assertIn(("right", y.LOGARITHMIC), events)
@@ -957,9 +1111,9 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
yright.sigAutoScaleChanged.connect(listener.partial("right"))
yright.setAutoScale(False)
- self.assertEquals(y.isAutoScale(), False)
+ self.assertEqual(y.isAutoScale(), False)
events = listener.arguments()
- self.assertEquals(len(events), 2)
+ self.assertEqual(len(events), 2)
self.assertIn(("left", False), events)
self.assertIn(("right", False), events)
@@ -971,9 +1125,9 @@ class TestPlotAxes(TestCaseQt, ParametricTestCase):
yright.sigInvertedChanged.connect(listener.partial("right"))
yright.setInverted(True)
- self.assertEquals(y.isInverted(), True)
+ self.assertEqual(y.isInverted(), True)
events = listener.arguments()
- self.assertEquals(len(events), 2)
+ self.assertEqual(len(events), 2)
self.assertIn(("left", True), events)
self.assertIn(("right", True), events)
@@ -1363,6 +1517,7 @@ class TestPlotItemLog(PlotWidgetTestCase):
def suite():
testClasses = (TestPlotWidget, TestPlotImage, TestPlotCurve,
TestPlotMarker, TestPlotItem, TestPlotAxes,
+ TestPlotActiveCurveImage,
TestPlotEmptyLog, TestPlotCurveLog, TestPlotImageLog,
TestPlotMarkerLog, TestPlotItemLog)
diff --git a/silx/gui/plot/test/testPlotWindow.py b/silx/gui/plot/test/testPlotWindow.py
index 24d840b..6d3eb8f 100644
--- a/silx/gui/plot/test/testPlotWindow.py
+++ b/silx/gui/plot/test/testPlotWindow.py
@@ -32,7 +32,7 @@ __date__ = "27/06/2017"
import doctest
import unittest
-from silx.gui.test.utils import TestCaseQt, getQToolButtonFromAction
+from silx.gui.utils.testutils import TestCaseQt, getQToolButtonFromAction
from silx.gui import qt
from silx.gui.plot import PlotWindow
diff --git a/silx/gui/plot/test/testProfile.py b/silx/gui/plot/test/testProfile.py
index 28d9669..847f404 100644
--- a/silx/gui/plot/test/testProfile.py
+++ b/silx/gui/plot/test/testProfile.py
@@ -32,7 +32,7 @@ import numpy
import unittest
from silx.utils.testutils import ParametricTestCase
-from silx.gui.test.utils import (
+from silx.gui.utils.testutils import (
TestCaseQt, getQToolButtonFromAction)
from silx.gui import qt
from silx.gui.plot import PlotWindow, Plot1D, Plot2D, Profile
@@ -75,58 +75,168 @@ class TestProfileToolBar(TestCaseQt, ParametricTestCase):
"""Test horizontal and vertical profile, without and with image"""
# Use Plot backend widget to submit mouse events
widget = self.plot.getWidgetHandle()
+ for method in ('sum', 'mean'):
+ with self.subTest(method=method):
+ # 2 positions to use for mouse events
+ pos1 = widget.width() * 0.4, widget.height() * 0.4
+ pos2 = widget.width() * 0.6, widget.height() * 0.6
+
+ for action in (self.toolBar.hLineAction, self.toolBar.vLineAction):
+ with self.subTest(mode=action.text()):
+ # Trigger tool button for mode
+ toolButton = getQToolButtonFromAction(action)
+ self.assertIsNot(toolButton, None)
+ self.mouseMove(toolButton)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+
+ # Without image
+ self.mouseMove(widget, pos=pos1)
+ self.mouseClick(widget, qt.Qt.LeftButton, pos=pos1)
+
+ # with image
+ self.plot.addImage(
+ numpy.arange(100 * 100).reshape(100, -1))
+ self.mousePress(widget, qt.Qt.LeftButton, pos=pos1)
+ self.mouseMove(widget, pos=pos2)
+ self.mouseRelease(widget, qt.Qt.LeftButton, pos=pos2)
+
+ self.mouseMove(widget)
+ self.mouseClick(widget, qt.Qt.LeftButton)
- # 2 positions to use for mouse events
- pos1 = widget.width() * 0.4, widget.height() * 0.4
- pos2 = widget.width() * 0.6, widget.height() * 0.6
+ def testDiagonalProfile(self):
+ """Test diagonal profile, without and with image"""
+ # Use Plot backend widget to submit mouse events
+ widget = self.plot.getWidgetHandle()
- for action in (self.toolBar.hLineAction, self.toolBar.vLineAction):
- with self.subTest(mode=action.text()):
- # Trigger tool button for mode
- toolButton = getQToolButtonFromAction(action)
- self.assertIsNot(toolButton, None)
- self.mouseMove(toolButton)
- self.mouseClick(toolButton, qt.Qt.LeftButton)
+ for method in ('sum', 'mean'):
+ with self.subTest(method=method):
+ self.toolBar.setProfileMethod(method)
+
+ # 2 positions to use for mouse events
+ pos1 = widget.width() * 0.4, widget.height() * 0.4
+ pos2 = widget.width() * 0.6, widget.height() * 0.6
+
+ for image in (False, True):
+ with self.subTest(image=image):
+ if image:
+ self.plot.addImage(
+ numpy.arange(100 * 100).reshape(100, -1))
+
+ # Trigger tool button for diagonal profile mode
+ toolButton = getQToolButtonFromAction(
+ self.toolBar.lineAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseMove(toolButton)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+ self.toolBar.lineWidthSpinBox.setValue(3)
+
+ # draw profile line
+ self.mouseMove(widget, pos=pos1)
+ self.mousePress(widget, qt.Qt.LeftButton, pos=pos1)
+ self.mouseMove(widget, pos=pos2)
+ self.mouseRelease(widget, qt.Qt.LeftButton, pos=pos2)
+
+ if image is True:
+ profileCurve = self.toolBar.getProfilePlot().getAllCurves()[0]
+ if method == 'sum':
+ self.assertTrue(profileCurve.getData()[1].max() > 10000)
+ elif method == 'mean':
+ self.assertTrue(profileCurve.getData()[1].max() < 10000)
+ self.plot.clear()
+
+
+class TestProfile3DToolBar(TestCaseQt):
+ """Tests for Profile3DToolBar widget.
+ """
+ def setUp(self):
+ super(TestProfile3DToolBar, self).setUp()
+ self.plot = StackView()
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
- # Without image
- self.mouseMove(widget, pos=pos1)
- self.mouseClick(widget, qt.Qt.LeftButton, pos=pos1)
+ self.plot.setStack(numpy.array([
+ [[0, 1, 2], [3, 4, 5]],
+ [[6, 7, 8], [9, 10, 11]],
+ [[12, 13, 14], [15, 16, 17]]
+ ]))
- # with image
- self.plot.addImage(numpy.arange(100 * 100).reshape(100, -1))
- self.mousePress(widget, qt.Qt.LeftButton, pos=pos1)
- self.mouseMove(widget, pos=pos2)
- self.mouseRelease(widget, qt.Qt.LeftButton, pos=pos2)
+ def tearDown(self):
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ self.plot = None
- self.mouseMove(widget)
- self.mouseClick(widget, qt.Qt.LeftButton)
+ super(TestProfile3DToolBar, self).tearDown()
- def testDiagonalProfile(self):
- """Test diagonal profile, without and with image"""
- # Use Plot backend widget to submit mouse events
- widget = self.plot.getWidgetHandle()
+ def testMethodProfile1DAnd2D(self):
+ """Test that the profile can have a different method if we want to
+ compute then in 1D or in 2D"""
- # 2 positions to use for mouse events
- pos1 = widget.width() * 0.4, widget.height() * 0.4
- pos2 = widget.width() * 0.6, widget.height() * 0.6
+ _3DProfileToolbar = self.plot.getProfileToolbar()
+ _2DProfilePlot = _3DProfileToolbar.getProfilePlot()
+ self.plot.getProfileToolbar().setProfileMethod('mean')
+ self.plot.getProfileToolbar().lineWidthSpinBox.setValue(3)
+ self.assertTrue(_3DProfileToolbar.getProfileMethod() == 'mean')
- # Trigger tool button for diagonal profile mode
- toolButton = getQToolButtonFromAction(self.toolBar.lineAction)
+ # check 2D 'mean' profile
+ _3DProfileToolbar.profile3dAction.computeProfileIn2D()
+ toolButton = getQToolButtonFromAction(_3DProfileToolbar.vLineAction)
self.assertIsNot(toolButton, None)
self.mouseMove(toolButton)
self.mouseClick(toolButton, qt.Qt.LeftButton)
+ plot2D = self.plot.getPlot().getWidgetHandle()
+ pos1 = plot2D.width() * 0.5, plot2D.height() * 0.5
+ self.mouseClick(plot2D, qt.Qt.LeftButton, pos=pos1)
+ self.assertTrue(numpy.array_equal(
+ _2DProfilePlot.getActiveImage().getData(),
+ numpy.array([[1, 4], [7, 10], [13, 16]])
+ ))
+
+ # check 1D 'sum' profile
+ _2DProfileToolbar = _2DProfilePlot.getProfileToolbar()
+ _2DProfileToolbar.setProfileMethod('sum')
+ self.assertTrue(_2DProfileToolbar.getProfileMethod() == 'sum')
+ _1DProfilePlot = _2DProfileToolbar.getProfilePlot()
+
+ _2DProfileToolbar.lineWidthSpinBox.setValue(3)
+ toolButton = getQToolButtonFromAction(_2DProfileToolbar.vLineAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseMove(toolButton)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+ plot1D = _2DProfilePlot.getWidgetHandle()
+ pos1 = plot1D.width() * 0.5, plot1D.height() * 0.5
+ self.mouseClick(plot1D, qt.Qt.LeftButton, pos=pos1)
+ self.assertTrue(numpy.array_equal(
+ _1DProfilePlot.getAllCurves()[0].getData()[1],
+ numpy.array([5, 17, 29])
+ ))
+
+ def testMethodSumLine(self):
+ """Simple interaction test to make sure the sum is correctly computed
+ """
+ _3DProfileToolbar = self.plot.getProfileToolbar()
+ _2DProfilePlot = _3DProfileToolbar.getProfilePlot()
+ self.plot.getProfileToolbar().setProfileMethod('sum')
+ self.plot.getProfileToolbar().lineWidthSpinBox.setValue(3)
+ self.assertTrue(_3DProfileToolbar.getProfileMethod() == 'sum')
+
+ # check 2D 'mean' profile
+ _3DProfileToolbar.profile3dAction.computeProfileIn2D()
+ toolButton = getQToolButtonFromAction(_3DProfileToolbar.lineAction)
+ self.assertIsNot(toolButton, None)
+ self.mouseMove(toolButton)
+ self.mouseClick(toolButton, qt.Qt.LeftButton)
+ plot2D = self.plot.getPlot().getWidgetHandle()
+ pos1 = plot2D.width() * 0.5, plot2D.height() * 0.2
+ pos2 = plot2D.width() * 0.5, plot2D.height() * 0.8
- for image in (False, True):
- with self.subTest(image=image):
- if image:
- self.plot.addImage(numpy.arange(100 * 100).reshape(100, -1))
-
- self.mouseMove(widget, pos=pos1)
- self.mousePress(widget, qt.Qt.LeftButton, pos=pos1)
- self.mouseMove(widget, pos=pos2)
- self.mouseRelease(widget, qt.Qt.LeftButton, pos=pos2)
-
- self.plot.clear()
+ self.mouseMove(plot2D, pos=pos1)
+ self.mousePress(plot2D, qt.Qt.LeftButton, pos=pos1)
+ self.mouseMove(plot2D, pos=pos2)
+ self.mouseRelease(plot2D, qt.Qt.LeftButton, pos=pos2)
+ self.assertTrue(numpy.array_equal(
+ _2DProfilePlot.getActiveImage().getData(),
+ numpy.array([[3, 12], [21, 30], [39, 48]])
+ ))
class TestGetProfilePlot(TestCaseQt):
@@ -157,8 +267,6 @@ class TestGetProfilePlot(TestCaseQt):
self.assertIsInstance(plot.getProfileToolbar().getProfileMainWindow(),
qt.QMainWindow)
- # plot.getProfileToolbar().profile3dAction.computeProfileIn2D() # default
-
self.assertIsInstance(plot.getProfileToolbar().getProfilePlot(),
Plot2D)
plot.getProfileToolbar().profile3dAction.computeProfileIn1D()
@@ -172,8 +280,8 @@ class TestGetProfilePlot(TestCaseQt):
def suite():
test_suite = unittest.TestSuite()
- # test_suite.addTest(positionInfoTestSuite)
- for testClass in (TestProfileToolBar, TestGetProfilePlot):
+ for testClass in (TestProfileToolBar, TestGetProfilePlot,
+ TestProfile3DToolBar):
test_suite.addTest(unittest.defaultTestLoader.loadTestsFromTestCase(
testClass))
return test_suite
diff --git a/silx/gui/plot/test/testScatterMaskToolsWidget.py b/silx/gui/plot/test/testScatterMaskToolsWidget.py
index 0342c8f..a446911 100644
--- a/silx/gui/plot/test/testScatterMaskToolsWidget.py
+++ b/silx/gui/plot/test/testScatterMaskToolsWidget.py
@@ -38,7 +38,7 @@ import numpy
from silx.gui import qt
from silx.test.utils import temp_dir
from silx.utils.testutils import ParametricTestCase
-from silx.gui.test.utils import getQToolButtonFromAction
+from silx.gui.utils.testutils import getQToolButtonFromAction
from silx.gui.plot import PlotWindow, ScatterMaskToolsWidget
from .utils import PlotWidgetTestCase
@@ -89,10 +89,10 @@ class TestScatterMaskToolsWidget(PlotWidgetTestCase, ParametricTestCase):
self.mouseMove(plot, pos=(0, 0))
self.mouseMove(plot, pos=pos0)
- self.mousePress(plot, qt.Qt.LeftButton, pos=pos0)
+ self.mouseClick(plot, qt.Qt.LeftButton, pos=pos0)
self.mouseMove(plot, pos=(0, 0))
self.mouseMove(plot, pos=pos1)
- self.mouseRelease(plot, qt.Qt.LeftButton, pos=pos1)
+ self.mouseClick(plot, qt.Qt.LeftButton, pos=pos1)
def _drawPolygon(self):
"""Draw a star polygon in the plot"""
@@ -110,7 +110,9 @@ class TestScatterMaskToolsWidget(PlotWidgetTestCase, ParametricTestCase):
self.mouseMove(plot, pos=[0, 0])
for pos in star:
self.mouseMove(plot, pos=pos)
+ self.qapp.processEvents()
self.mouseClick(plot, qt.Qt.LeftButton, pos=pos)
+ self.qapp.processEvents()
def _drawPencil(self):
"""Draw a star polygon in the plot"""
diff --git a/silx/gui/plot/test/testScatterView.py b/silx/gui/plot/test/testScatterView.py
index 40fdac6..583e3ed 100644
--- a/silx/gui/plot/test/testScatterView.py
+++ b/silx/gui/plot/test/testScatterView.py
@@ -103,6 +103,25 @@ class TestScatterView(PlotWidgetTestCase):
self.assertIsNone(data[3]) # xerror
self.assertIsNone(data[4]) # yerror
+ def testAlpha(self):
+ """Test alpha transparency in setData"""
+ _pts = 100
+ _levels = 100
+ _fwhm = 50
+ x = numpy.random.rand(_pts)*_levels
+ y = numpy.random.rand(_pts)*_levels
+ value = numpy.random.rand(_pts)*_levels
+ x0 = x[int(_pts/2)]
+ y0 = x[int(_pts/2)]
+ #2D Gaussian kernel
+ alpha = numpy.exp(-4*numpy.log(2) * ((x-x0)**2 + (y-y0)**2) / _fwhm**2)
+
+ self.plot.setData(x, y, value, alpha=alpha)
+ self.qapp.processEvents()
+
+ alphaData = self.plot.getScatterItem().getAlphaData()
+ self.assertTrue(numpy.all(numpy.equal(alpha, alphaData)))
+
def suite():
test_suite = unittest.TestSuite()
diff --git a/silx/gui/plot/test/testStackView.py b/silx/gui/plot/test/testStackView.py
index 3dcea36..a5f649c 100644
--- a/silx/gui/plot/test/testStackView.py
+++ b/silx/gui/plot/test/testStackView.py
@@ -32,7 +32,7 @@ __date__ = "20/03/2017"
import unittest
import numpy
-from silx.gui.test.utils import TestCaseQt, SignalListener
+from silx.gui.utils.testutils import TestCaseQt, SignalListener
from silx.gui import qt
from silx.gui.plot import StackView
@@ -123,8 +123,9 @@ class TestStackView(TestCaseQt):
"Plane selection combobox not updating perspective")
self.stackview.setStack(numpy.arange(6).reshape((1, 2, 3)))
- self.assertEqual(self.stackview._perspective, 0,
- "Default perspective not restored in setStack.")
+ self.assertEqual(self.stackview._perspective, 1,
+ "Perspective not preserved when calling setStack "
+ "without specifying the perspective parameter.")
self.stackview.setStack(numpy.arange(24).reshape((2, 3, 4)), perspective=2)
self.assertEqual(self.stackview._perspective, 2,
diff --git a/silx/gui/plot/test/testStats.py b/silx/gui/plot/test/testStats.py
index 123eb89..faedcff 100644
--- a/silx/gui/plot/test/testStats.py
+++ b/silx/gui/plot/test/testStats.py
@@ -33,7 +33,7 @@ from silx.gui import qt
from silx.gui.plot.stats import stats
from silx.gui.plot import StatsWidget
from silx.gui.plot.stats import statshandler
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui.plot import Plot1D, Plot2D
import unittest
import logging
@@ -361,6 +361,7 @@ class TestStatsWidgetWithCurves(TestCaseQt):
def setUp(self):
TestCaseQt.setUp(self)
self.plot = Plot1D()
+ self.plot.show()
x = range(20)
y = range(20)
self.plot.addCurve(x, y, legend='curve0')
diff --git a/silx/gui/plot/test/testUtilsAxis.py b/silx/gui/plot/test/testUtilsAxis.py
index 3f19dcd..016fafe 100644
--- a/silx/gui/plot/test/testUtilsAxis.py
+++ b/silx/gui/plot/test/testUtilsAxis.py
@@ -31,7 +31,7 @@ __date__ = "14/02/2018"
import unittest
from silx.gui.plot import PlotWidget
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui.plot.utils.axis import SyncAxes
diff --git a/silx/gui/plot/test/utils.py b/silx/gui/plot/test/utils.py
index efba39c..ed1917a 100644
--- a/silx/gui/plot/test/utils.py
+++ b/silx/gui/plot/test/utils.py
@@ -31,7 +31,7 @@ __date__ = "26/01/2018"
import logging
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui import qt
from silx.gui.plot import PlotWidget
diff --git a/silx/gui/plot/tools/CurveLegendsWidget.py b/silx/gui/plot/tools/CurveLegendsWidget.py
new file mode 100644
index 0000000..7b63b29
--- /dev/null
+++ b/silx/gui/plot/tools/CurveLegendsWidget.py
@@ -0,0 +1,247 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018 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.
+#
+# ###########################################################################*/
+"""This module provides a widget to display :class:`PlotWidget` curve legends.
+"""
+
+from __future__ import division
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "20/07/2018"
+
+
+import logging
+import weakref
+
+
+from ... import qt
+from ...widgets.FlowLayout import FlowLayout as _FlowLayout
+from ..LegendSelector import LegendIcon as _LegendIcon
+from .. import items
+
+
+_logger = logging.getLogger(__name__)
+
+
+class _LegendWidget(qt.QWidget):
+ """Widget displaying curve style and its legend
+
+ :param QWidget parent: See :class:`QWidget`
+ :param ~silx.gui.plot.items.Curve curve: Associated curve
+ """
+
+ def __init__(self, parent, curve):
+ super(_LegendWidget, self).__init__(parent)
+ layout = qt.QHBoxLayout(self)
+ layout.setContentsMargins(10, 0, 10, 0)
+
+ curve.sigItemChanged.connect(self._curveChanged)
+
+ icon = _LegendIcon(curve=curve)
+ layout.addWidget(icon)
+
+ label = qt.QLabel(curve.getLegend())
+ label.setAlignment(qt.Qt.AlignLeft | qt.Qt.AlignVCenter)
+ layout.addWidget(label)
+
+ self._update()
+
+ def getCurve(self):
+ """Returns curve associated to this widget
+
+ :rtype: Union[~silx.gui.plot.items.Curve,None]
+ """
+ icon = self.findChild(_LegendIcon)
+ return icon.getCurve()
+
+ def _update(self):
+ """Update widget according to current curve state.
+ """
+ curve = self.getCurve()
+ if curve is None:
+ _logger.error('Curve no more exists')
+ self.setVisible(False)
+ return
+
+ self.setEnabled(curve.isVisible())
+
+ label = self.findChild(qt.QLabel)
+ if curve.isHighlighted():
+ label.setStyleSheet("border: 1px solid black")
+ else:
+ label.setStyleSheet("")
+
+ def _curveChanged(self, event):
+ """Handle update of curve item
+
+ :param event: Kind of change
+ """
+ if event in (items.ItemChangedType.VISIBLE,
+ items.ItemChangedType.HIGHLIGHTED,
+ items.ItemChangedType.HIGHLIGHTED_STYLE):
+ self._update()
+
+
+class CurveLegendsWidget(qt.QWidget):
+ """Widget displaying curves legends in a plot
+
+ :param QWidget parent: See :class:`QWidget`
+ """
+
+ sigCurveClicked = qt.Signal(object)
+ """Signal emitted when the legend of a curve is clicked
+
+ It provides the corresponding curve.
+ """
+
+ def __init__(self, parent=None):
+ super(CurveLegendsWidget, self).__init__(parent)
+ self._clicked = None
+ self._legends = {}
+ self._plotRef = None
+
+ def layout(self):
+ layout = super(CurveLegendsWidget, self).layout()
+ if layout is None:
+ # Lazy layout initialization to allow overloading
+ layout = _FlowLayout()
+ layout.setHorizontalSpacing(0)
+ self.setLayout(layout)
+ return layout
+
+ def getPlotWidget(self):
+ """Returns the associated :class:`PlotWidget`
+
+ :rtype: Union[~silx.gui.plot.PlotWidget,None]
+ """
+ return None if self._plotRef is None else self._plotRef()
+
+ def setPlotWidget(self, plot):
+ """Set the associated :class:`PlotWidget`
+
+ :param ~silx.gui.plot.PlotWidget plot: Plot widget to attach
+ """
+ previousPlot = self.getPlotWidget()
+ if previousPlot is not None:
+ previousPlot.sigItemAdded.disconnect( self._itemAdded)
+ previousPlot.sigItemAboutToBeRemoved.disconnect(self._itemRemoved)
+ for legend in list(self._legends.keys()):
+ self._removeLegend(legend)
+
+ self._plotRef = None if plot is None else weakref.ref(plot)
+
+ if plot is not None:
+ plot.sigItemAdded.connect(self._itemAdded)
+ plot.sigItemAboutToBeRemoved.connect(self._itemRemoved)
+
+ for legend in plot.getAllCurves(just_legend=True):
+ self._addLegend(legend)
+
+ def curveAt(self, *args):
+ """Returns the curve object represented at the given position
+
+ Either takes a QPoint or x and y as input in widget coordinates.
+
+ :rtype: Union[~silx.gui.plot.items.Curve,None]
+ """
+ if len(args) == 1:
+ point = args[0]
+ elif len(args) == 2:
+ point = qt.QPoint(*args)
+ else:
+ raise ValueError('Unsupported arguments')
+ assert isinstance(point, qt.QPoint)
+
+ widget = self.childAt(point)
+ while widget not in (self, None):
+ if isinstance(widget, _LegendWidget):
+ return widget.getCurve()
+ widget = widget.parent()
+ return None # No widget or not in _LegendWidget
+
+ def _itemAdded(self, item):
+ """Handle item added to the plot content"""
+ if isinstance(item, items.Curve):
+ self._addLegend(item.getLegend())
+
+ def _itemRemoved(self, item):
+ """Handle item removed from the plot content"""
+ if isinstance(item, items.Curve):
+ self._removeLegend(item.getLegend())
+
+ def _addLegend(self, legend):
+ """Add a curve to the legends
+
+ :param str legend: Curve's legend
+ """
+ if legend in self._legends:
+ return # Can happen when changing curve's y axis
+
+ plot = self.getPlotWidget()
+ if plot is None:
+ return None
+
+ curve = plot.getCurve(legend)
+ if curve is None:
+ _logger.error('Curve not found: %s' % legend)
+ return
+
+ widget = _LegendWidget(parent=self, curve=curve)
+ self.layout().addWidget(widget)
+ self._legends[legend] = widget
+
+ def _removeLegend(self, legend):
+ """Remove a curve from the legends if it exists
+
+ :param str legend: The curve's legend
+ """
+ widget = self._legends.pop(legend, None)
+ if widget is None:
+ _logger.warning('Unknown legend: %s' % legend)
+ else:
+ self.layout().removeWidget(widget)
+ widget.setParent(None)
+
+ def mousePressEvent(self, event):
+ if event.button() == qt.Qt.LeftButton:
+ self._clicked = event.pos()
+
+ _CLICK_THRESHOLD = 5
+ """Threshold for clicks"""
+
+ def mouseMoveEvent(self, event):
+ if self._clicked is not None:
+ dx = abs(self._clicked.x() - event.pos().x())
+ dy = abs(self._clicked.y() - event.pos().y())
+ if dx > self._CLICK_THRESHOLD or dy > self._CLICK_THRESHOLD:
+ self._clicked = None # Click is cancelled
+
+ def mouseReleaseEvent(self, event):
+ if event.button() == qt.Qt.LeftButton and self._clicked is not None:
+ curve = self.curveAt(event.pos())
+ if curve is not None:
+ self.sigCurveClicked.emit(curve)
+
+ self._clicked = None
diff --git a/silx/gui/plot/tools/profile/ImageProfileToolBar.py b/silx/gui/plot/tools/profile/ImageProfileToolBar.py
deleted file mode 100644
index 207a2e2..0000000
--- a/silx/gui/plot/tools/profile/ImageProfileToolBar.py
+++ /dev/null
@@ -1,271 +0,0 @@
-# TODO quick & dirty proof of concept
-
-import numpy
-
-from silx.gui.plot.tools.profile.ScatterProfileToolBar import _BaseProfileToolBar
-from .. import items
-from ...colors import cursorColorForColormap
-from ....image.bilinear import BilinearImage
-
-
-def _alignedPartialProfile(data, rowRange, colRange, axis):
- """Mean of a rectangular region (ROI) of a stack of images
- along a given axis.
-
- Returned values and all parameters are in image coordinates.
-
- :param numpy.ndarray data: 3D volume (stack of 2D images)
- The first dimension is the image index.
- :param rowRange: [min, max[ of ROI rows (upper bound excluded).
- :type rowRange: 2-tuple of int (min, max) with min < max
- :param colRange: [min, max[ of ROI columns (upper bound excluded).
- :type colRange: 2-tuple of int (min, max) with min < max
- :param int axis: The axis along which to take the profile of the ROI.
- 0: Sum rows along columns.
- 1: Sum columns along rows.
- :return: Profile image along the ROI as the mean of the intersection
- of the ROI and the image.
- """
- assert axis in (0, 1)
- assert len(data.shape) == 3
- assert rowRange[0] < rowRange[1]
- assert colRange[0] < colRange[1]
-
- nimages, height, width = data.shape
-
- # Range aligned with the integration direction
- profileRange = colRange if axis == 0 else rowRange
-
- profileLength = abs(profileRange[1] - profileRange[0])
-
- # Subset of the image to use as intersection of ROI and image
- rowStart = min(max(0, rowRange[0]), height)
- rowEnd = min(max(0, rowRange[1]), height)
- colStart = min(max(0, colRange[0]), width)
- colEnd = min(max(0, colRange[1]), width)
-
- imgProfile = numpy.mean(data[:, rowStart:rowEnd, colStart:colEnd],
- axis=axis + 1, dtype=numpy.float32)
-
- # Profile including out of bound area
- profile = numpy.zeros((nimages, profileLength), dtype=numpy.float32)
-
- # Place imgProfile in full profile
- offset = - min(0, profileRange[0])
- profile[:, offset:offset + imgProfile.shape[1]] = imgProfile
-
- return profile
-
-
-def createProfile(points, data, origin, scale, lineWidth):
- """Create the profile line for the the given image.
-
- :param points: Coords of profile end points: (x0, y0, x1, y1)
- :param numpy.ndarray data: the 2D image or the 3D stack of images
- on which we compute the profile.
- :param origin: (ox, oy) the offset from origin
- :type origin: 2-tuple of float
- :param scale: (sx, sy) the scale to use
- :type scale: 2-tuple of float
- :param int lineWidth: width of the profile line
- :return: `profile, area`, where:
- - profile is a 2D array of the profiles of the stack of images.
- For a single image, the profile is a curve, so this parameter
- has a shape *(1, len(curve))*
- - area is a tuple of two 1D arrays with 4 values each. They represent
- the effective ROI area corners in plot coords.
-
- :rtype: tuple(ndarray, (ndarray, ndarray), str, str)
- """
- if data is None or points is None or lineWidth is None:
- raise ValueError("createProfile called with invalid arguments")
-
- # force 3D data (stack of images)
- if len(data.shape) == 2:
- data3D = data.reshape((1,) + data.shape)
- elif len(data.shape) == 3:
- data3D = data
-
- roiWidth = max(1, lineWidth)
- x0, y0, x1, y1 = points
-
- # Convert start and end points in image coords as (row, col)
- startPt = ((y0 - origin[1]) / scale[1],
- (x0 - origin[0]) / scale[0])
- endPt = ((y1 - origin[1]) / scale[1],
- (x1 - origin[0]) / scale[0])
-
- if (int(startPt[0]) == int(endPt[0]) or
- int(startPt[1]) == int(endPt[1])):
- # Profile is aligned with one of the axes
-
- # Convert to int
- startPt = int(startPt[0]), int(startPt[1])
- endPt = int(endPt[0]), int(endPt[1])
-
- # Ensure startPt <= endPt
- if startPt[0] > endPt[0] or startPt[1] > endPt[1]:
- startPt, endPt = endPt, startPt
-
- if startPt[0] == endPt[0]: # Row aligned
- rowRange = (int(startPt[0] + 0.5 - 0.5 * roiWidth),
- int(startPt[0] + 0.5 + 0.5 * roiWidth))
- colRange = startPt[1], endPt[1] + 1
- profile = _alignedPartialProfile(data3D,
- rowRange, colRange,
- axis=0)
-
- else: # Column aligned
- rowRange = startPt[0], endPt[0] + 1
- colRange = (int(startPt[1] + 0.5 - 0.5 * roiWidth),
- int(startPt[1] + 0.5 + 0.5 * roiWidth))
- profile = _alignedPartialProfile(data3D,
- rowRange, colRange,
- axis=1)
-
- # Convert ranges to plot coords to draw ROI area
- area = (
- numpy.array(
- (colRange[0], colRange[1], colRange[1], colRange[0]),
- dtype=numpy.float32) * scale[0] + origin[0],
- numpy.array(
- (rowRange[0], rowRange[0], rowRange[1], rowRange[1]),
- dtype=numpy.float32) * scale[1] + origin[1])
-
- else: # General case: use bilinear interpolation
-
- # Ensure startPt <= endPt
- if (startPt[1] > endPt[1] or (
- startPt[1] == endPt[1] and startPt[0] > endPt[0])):
- startPt, endPt = endPt, startPt
-
- profile = []
- for slice_idx in range(data3D.shape[0]):
- bilinear = BilinearImage(data3D[slice_idx, :, :])
-
- profile.append(bilinear.profile_line(
- (startPt[0] - 0.5, startPt[1] - 0.5),
- (endPt[0] - 0.5, endPt[1] - 0.5),
- roiWidth))
- profile = numpy.array(profile)
-
- # Extend ROI with half a pixel on each end, and
- # Convert back to plot coords (x, y)
- length = numpy.sqrt((endPt[0] - startPt[0]) ** 2 +
- (endPt[1] - startPt[1]) ** 2)
- dRow = (endPt[0] - startPt[0]) / length
- dCol = (endPt[1] - startPt[1]) / length
-
- # Extend ROI with half a pixel on each end
- startPt = startPt[0] - 0.5 * dRow, startPt[1] - 0.5 * dCol
- endPt = endPt[0] + 0.5 * dRow, endPt[1] + 0.5 * dCol
-
- # Rotate deltas by 90 degrees to apply line width
- dRow, dCol = dCol, -dRow
-
- area = (
- numpy.array((startPt[1] - 0.5 * roiWidth * dCol,
- startPt[1] + 0.5 * roiWidth * dCol,
- endPt[1] + 0.5 * roiWidth * dCol,
- endPt[1] - 0.5 * roiWidth * dCol),
- dtype=numpy.float32) * scale[0] + origin[0],
- numpy.array((startPt[0] - 0.5 * roiWidth * dRow,
- startPt[0] + 0.5 * roiWidth * dRow,
- endPt[0] + 0.5 * roiWidth * dRow,
- endPt[0] - 0.5 * roiWidth * dRow),
- dtype=numpy.float32) * scale[1] + origin[1])
-
- xProfile = numpy.arange(len(profile[0]), dtype=numpy.float64)
-
- return (xProfile, profile[0]), area
-
-
-class ImageProfileToolBar(_BaseProfileToolBar):
-
- def __init__(self, parent=None, plot=None, title='Image Profile'):
- super(ImageProfileToolBar, self).__init__(parent, plot, title)
- plot.sigActiveImageChanged.connect(self.__activeImageChanged)
-
- roiManager = self._getRoiManager()
- if roiManager is None:
- _logger.error(
- "Error during scatter profile toolbar initialisation")
- else:
- roiManager.sigInteractiveModeStarted.connect(
- self.__interactionStarted)
- roiManager.sigInteractiveModeFinished.connect(
- self.__interactionFinished)
- if roiManager.isStarted():
- self.__interactionStarted(roiManager.getRegionOfInterestKind())
-
- def __interactionStarted(self, kind):
- """Handle start of ROI interaction"""
- plot = self.getPlotWidget()
- if plot is None:
- return
-
- plot.sigActiveImageChanged.connect(self.__activeImageChanged)
-
- image = plot.getActiveImage()
- legend = None if image is None else image.getLegend()
- self.__activeImageChanged(None, legend)
-
- def __interactionFinished(self, rois):
- """Handle end of ROI interaction"""
- plot = self.getPlotWidget()
- if plot is None:
- return
-
- plot.sigActiveImageChanged.disconnect(self.__activeImageChanged)
-
- image = plot.getActiveImage()
- legend = None if image is None else image.getLegend()
- self.__activeImageChanged(legend, None)
-
- def __activeImageChanged(self, previous, legend):
- """Handle active image change: toggle enabled toolbar, update curve"""
- plot = self.getPlotWidget()
- if plot is None:
- return
-
- activeImage = plot.getActiveImage()
- if activeImage is None:
- self.setEnabled(False)
- else:
- # Disable for empty image
- self.setEnabled(activeImage.getData(copy=False).size > 0)
-
- # Update default profile color
- if isinstance(activeImage, items.ColormapMixIn):
- self.setColor(cursorColorForColormap(
- activeImage.getColormap()['name'])) # TODO change thsi
- else:
- self.setColor('black')
-
- self.updateProfile()
-
- def computeProfile(self, x0, y0, x1, y1):
- """Compute corresponding profile
-
- :param float x0: Profile start point X coord
- :param float y0: Profile start point Y coord
- :param float x1: Profile end point X coord
- :param float y1: Profile end point Y coord
- :return: (x, y) profile data or None
- """
- plot = self.getPlotWidget()
- if plot is None:
- return None
-
- image = plot.getActiveImage()
- if image is None:
- return None
-
- profile, area = createProfile(
- points=(x0, y0, x1, y1),
- data=image.getData(copy=False),
- origin=image.getOrigin(),
- scale=image.getScale(),
- lineWidth=1) # TODO
-
- return profile \ No newline at end of file
diff --git a/silx/gui/plot/tools/test/__init__.py b/silx/gui/plot/tools/test/__init__.py
index 79301ab..9cede27 100644
--- a/silx/gui/plot/tools/test/__init__.py
+++ b/silx/gui/plot/tools/test/__init__.py
@@ -32,6 +32,7 @@ import unittest
from . import testROI
from . import testTools
from . import testScatterProfileToolBar
+from . import testCurveLegendsWidget
def suite():
@@ -40,6 +41,7 @@ def suite():
[testROI.suite(),
testTools.suite(),
testScatterProfileToolBar.suite(),
+ testCurveLegendsWidget.suite(),
])
return test_suite
diff --git a/silx/gui/plot/tools/test/testCurveLegendsWidget.py b/silx/gui/plot/tools/test/testCurveLegendsWidget.py
new file mode 100644
index 0000000..4824dd7
--- /dev/null
+++ b/silx/gui/plot/tools/test/testCurveLegendsWidget.py
@@ -0,0 +1,125 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018 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.
+#
+# ###########################################################################*/
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "02/08/2018"
+
+
+import unittest
+
+from silx.gui import qt
+from silx.utils.testutils import ParametricTestCase
+from silx.gui.utils.testutils import TestCaseQt
+from silx.gui.plot import PlotWindow
+from silx.gui.plot.tools import CurveLegendsWidget
+
+
+class TestCurveLegendsWidget(TestCaseQt, ParametricTestCase):
+ """Tests for CurveLegendsWidget class"""
+
+ def setUp(self):
+ super(TestCurveLegendsWidget, self).setUp()
+ self.plot = PlotWindow()
+
+ self.legends = CurveLegendsWidget.CurveLegendsWidget()
+ self.legends.setPlotWidget(self.plot)
+
+ dock = qt.QDockWidget()
+ dock.setWindowTitle('Curve Legends')
+ dock.setWidget(self.legends)
+ self.plot.addTabbedDockWidget(dock)
+
+ self.plot.show()
+ self.qWaitForWindowExposed(self.plot)
+
+ def tearDown(self):
+ del self.legends
+ self.qapp.processEvents()
+ self.plot.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.plot.close()
+ del self.plot
+ super(TestCurveLegendsWidget, self).tearDown()
+
+ def _assertNbLegends(self, count):
+ """Check the number of legends in the CurveLegendsWidget"""
+ children = self.legends.findChildren(CurveLegendsWidget._LegendWidget)
+ self.assertEqual(len(children), count)
+
+ def testAddRemoveCurves(self):
+ """Test CurveLegendsWidget while adding/removing curves"""
+ self.plot.addCurve((0, 1), (1, 2), legend='a')
+ self._assertNbLegends(1)
+ self.plot.addCurve((0, 1), (2, 3), legend='b')
+ self._assertNbLegends(2)
+
+ # Detached/attach
+ self.legends.setPlotWidget(None)
+ self._assertNbLegends(0)
+
+ self.legends.setPlotWidget(self.plot)
+ self._assertNbLegends(2)
+
+ self.plot.clear()
+ self._assertNbLegends(0)
+
+ def testUpdateCurves(self):
+ """Test CurveLegendsWidget while updating curves """
+ self.plot.addCurve((0, 1), (1, 2), legend='a')
+ self._assertNbLegends(1)
+ self.plot.addCurve((0, 1), (2, 3), legend='b')
+ self._assertNbLegends(2)
+
+ # Activate curve
+ self.plot.setActiveCurve('a')
+ self.qapp.processEvents()
+ self.plot.setActiveCurve('b')
+ self.qapp.processEvents()
+
+ # Change curve style
+ curve = self.plot.getCurve('a')
+ curve.setLineWidth(2)
+ for linestyle in (':', '', '--', '-'):
+ with self.subTest(linestyle=linestyle):
+ curve.setLineStyle(linestyle)
+ self.qapp.processEvents()
+ self.qWait(1000)
+
+ for symbol in ('o', 'd', '', 's'):
+ with self.subTest(symbol=symbol):
+ curve.setSymbol(symbol)
+ self.qapp.processEvents()
+ self.qWait(1000)
+
+
+def suite():
+ test_suite = unittest.TestSuite()
+ test_suite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(
+ TestCurveLegendsWidget))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot/tools/test/testROI.py b/silx/gui/plot/tools/test/testROI.py
index 5032036..8aec1d9 100644
--- a/silx/gui/plot/tools/test/testROI.py
+++ b/silx/gui/plot/tools/test/testROI.py
@@ -32,7 +32,7 @@ import numpy.testing
from silx.gui import qt
from silx.utils.testutils import ParametricTestCase
-from silx.gui.test.utils import TestCaseQt, SignalListener
+from silx.gui.utils.testutils import TestCaseQt, SignalListener
from silx.gui.plot import PlotWindow
import silx.gui.plot.items.roi as roi_items
from silx.gui.plot.tools import roi
diff --git a/silx/gui/plot/tools/test/testScatterProfileToolBar.py b/silx/gui/plot/tools/test/testScatterProfileToolBar.py
index 16972f9..b99cac7 100644
--- a/silx/gui/plot/tools/test/testScatterProfileToolBar.py
+++ b/silx/gui/plot/tools/test/testScatterProfileToolBar.py
@@ -32,7 +32,7 @@ import numpy
from silx.gui import qt
from silx.utils.testutils import ParametricTestCase
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui.plot import PlotWindow
from silx.gui.plot.tools import profile
import silx.gui.plot.items.roi as roi_items
diff --git a/silx/gui/plot/tools/test/testTools.py b/silx/gui/plot/tools/test/testTools.py
index 810b933..f4adda0 100644
--- a/silx/gui/plot/tools/test/testTools.py
+++ b/silx/gui/plot/tools/test/testTools.py
@@ -34,7 +34,7 @@ import unittest
import numpy
from silx.utils.testutils import TestLogging
-from silx.gui.test.utils import qWaitForWindowExposedAndActivate
+from silx.gui.utils.testutils import qWaitForWindowExposedAndActivate
from silx.gui import qt
from silx.gui.plot import PlotWindow
from silx.gui.plot import tools
diff --git a/silx/gui/plot/utils/axis.py b/silx/gui/plot/utils/axis.py
index fae50b4..bd19996 100644
--- a/silx/gui/plot/utils/axis.py
+++ b/silx/gui/plot/utils/axis.py
@@ -35,6 +35,13 @@ from contextlib import contextmanager
import weakref
import silx.utils.weakref as silxWeakref
+try:
+ from ...qt.inspect import isValid as _isQObjectValid
+except ImportError: # PySide(1) fallback
+ def _isQObjectValid(obj):
+ return True
+
+
_logger = logging.getLogger(__name__)
@@ -135,7 +142,7 @@ class SyncAxes(object):
raise RuntimeError("Axes not synchronized")
for ref, callbacks in self.__callbacks.items():
axis = ref()
- if axis is not None:
+ if axis is not None and _isQObjectValid(axis):
for sigName, callback in callbacks:
sig = getattr(axis, sigName)
sig.disconnect(callback)
diff --git a/silx/gui/plot3d/ParamTreeView.py b/silx/gui/plot3d/ParamTreeView.py
index a352627..ee0c876 100644
--- a/silx/gui/plot3d/ParamTreeView.py
+++ b/silx/gui/plot3d/ParamTreeView.py
@@ -40,6 +40,7 @@ __license__ = "MIT"
__date__ = "05/12/2017"
+import numbers
import sys
from silx.third_party import six
@@ -362,7 +363,7 @@ class ParameterTreeDelegate(qt.QStyledItemDelegate):
assert isinstance(editor, qt.QWidget)
editor.setParent(parent)
- elif isinstance(data, (int, float)) and editorHint is not None:
+ elif isinstance(data, numbers.Number) and editorHint is not None:
# Use a slider
editor = IntSliderEditor(parent)
range_ = editorHint
@@ -394,7 +395,11 @@ class ParameterTreeDelegate(qt.QStyledItemDelegate):
if hasattr(notifySignal, 'signature'): # Qt4
signature = notifySignal.signature()
else:
- signature = bytes(notifySignal.methodSignature())
+ signature = notifySignal.methodSignature()
+ if qt.BINDING == 'PySide2':
+ signature = signature.data()
+ else:
+ signature = bytes(signature)
if hasattr(signature, 'decode'): # For PySide with python3
signature = signature.decode('ascii')
@@ -472,7 +477,7 @@ class ParamTreeView(qt.QTreeView):
editorHint = index.data(qt.Qt.UserRole)
if (isinstance(data, bool) or
callable(editorHint) or
- (isinstance(data, (float, int)) and editorHint)):
+ (isinstance(data, numbers.Number) and editorHint)):
self.openPersistentEditor(index)
self.__persistentEditors.add(index)
diff --git a/silx/gui/plot3d/Plot3DWidget.py b/silx/gui/plot3d/Plot3DWidget.py
index 53ff895..eed4438 100644
--- a/silx/gui/plot3d/Plot3DWidget.py
+++ b/silx/gui/plot3d/Plot3DWidget.py
@@ -36,7 +36,7 @@ import logging
from silx.gui import qt
from silx.gui.colors import rgba
from . import actions
-from ..utils._image import convertArrayToQImage
+from ..utils.image import convertArrayToQImage
from .. import _glutils as glu
from .scene import interaction, primitives, transform
diff --git a/silx/gui/plot3d/SFViewParamTree.py b/silx/gui/plot3d/SFViewParamTree.py
index bb81465..a2b771c 100644
--- a/silx/gui/plot3d/SFViewParamTree.py
+++ b/silx/gui/plot3d/SFViewParamTree.py
@@ -694,6 +694,10 @@ class IsoSurfaceRootItem(SubjectItem):
Root (i.e : column index 0) Isosurface item.
"""
+ def __init__(self, subject, normalization, *args):
+ self._isoLevelSliderNormalization = normalization
+ super(IsoSurfaceRootItem, self).__init__(subject, *args)
+
def getSignals(self):
subject = self.subject
return [subject.sigColorChanged,
@@ -717,7 +721,8 @@ class IsoSurfaceRootItem(SubjectItem):
self.setCheckState((visible and qt.Qt.Checked) or qt.Qt.Unchecked)
nameItem = qt.QStandardItem('Level')
- sliderItem = IsoSurfaceLevelSlider(self.subject)
+ sliderItem = IsoSurfaceLevelSlider(self.subject,
+ self._isoLevelSliderNormalization)
self.appendRow([nameItem, sliderItem])
nameItem = qt.QStandardItem('Color')
@@ -788,12 +793,22 @@ class IsoSurfaceLevelItem(SubjectItem):
class _IsoLevelSlider(qt.QSlider):
- """QSlider used for iso-surface level"""
+ """QSlider used for iso-surface level with linear scale"""
- def __init__(self, parent, subject):
+ def __init__(self, parent, subject, normalization):
super(_IsoLevelSlider, self).__init__(parent=parent)
self.subject = subject
+ if normalization == 'arcsinh':
+ self.__norm = numpy.arcsinh
+ self.__invNorm = numpy.sinh
+ elif normalization == 'linear':
+ self.__norm = lambda x: x
+ self.__invNorm = lambda x: x
+ else:
+ raise ValueError(
+ "Unsupported normalization %s", normalization)
+
self.sliderReleased.connect(self.__sliderReleased)
self.subject.sigLevelChanged.connect(self.setLevel)
@@ -804,10 +819,13 @@ class _IsoLevelSlider(qt.QSlider):
dataRange = self.subject.parent().getDataRange()
if dataRange is not None:
- width = dataRange[-1] - dataRange[0]
+ min_ = self.__norm(dataRange[0])
+ max_ = self.__norm(dataRange[-1])
+
+ width = max_ - min_
if width > 0:
sliderWidth = self.maximum() - self.minimum()
- sliderPosition = sliderWidth * (level - dataRange[0]) / width
+ sliderPosition = sliderWidth * (self.__norm(level) - min_) / width
self.setValue(sliderPosition)
def __dataChanged(self):
@@ -818,11 +836,12 @@ class _IsoLevelSlider(qt.QSlider):
value = self.value()
dataRange = self.subject.parent().getDataRange()
if dataRange is not None:
- min_, _, max_ = dataRange
+ min_ = self.__norm(dataRange[0])
+ max_ = self.__norm(dataRange[-1])
width = max_ - min_
sliderWidth = self.maximum() - self.minimum()
level = min_ + width * value / sliderWidth
- self.subject.setLevel(level)
+ self.subject.setLevel(self.__invNorm(level))
class IsoSurfaceLevelSlider(IsoSurfaceLevelItem):
@@ -832,8 +851,12 @@ class IsoSurfaceLevelSlider(IsoSurfaceLevelItem):
nTicks = 1000
persistent = True
+ def __init__(self, subject, normalization):
+ self.normalization = normalization
+ super(IsoSurfaceLevelSlider, self).__init__(subject)
+
def getEditor(self, parent, option, index):
- editor = _IsoLevelSlider(parent, self.subject)
+ editor = _IsoLevelSlider(parent, self.subject, self.normalization)
editor.setOrientation(qt.Qt.Horizontal)
editor.setMinimum(0)
editor.setMaximum(self.nTicks)
@@ -1067,6 +1090,11 @@ class IsoSurfaceGroup(SubjectItem):
"""
Root item for the list of isosurface items.
"""
+
+ def __init__(self, subject, normalization, *args):
+ self._isoLevelSliderNormalization = normalization
+ super(IsoSurfaceGroup, self).__init__(subject, *args)
+
def getSignals(self):
subject = self.subject
return [subject.sigIsosurfaceAdded, subject.sigIsosurfaceRemoved]
@@ -1090,7 +1118,9 @@ class IsoSurfaceGroup(SubjectItem):
raise ValueError('Expected an isosurface instance.')
def __addIsosurface(self, isosurface):
- valueItem = IsoSurfaceRootItem(subject=isosurface)
+ valueItem = IsoSurfaceRootItem(
+ subject=isosurface,
+ normalization=self._isoLevelSliderNormalization)
nameItem = IsoSurfaceLevelItem(subject=isosurface)
self.insertRow(max(0, self.rowCount() - 1), [valueItem, nameItem])
@@ -1570,6 +1600,7 @@ class TreeView(qt.QTreeView):
def __init__(self, parent=None):
super(TreeView, self).__init__(parent)
self.__openedIndex = None
+ self._isoLevelSliderNormalization = 'linear'
self.setIconSize(qt.QSize(16, 16))
@@ -1607,7 +1638,10 @@ class TreeView(qt.QTreeView):
item = IsoSurfaceCount(sfView)
item.setEditable(False)
- model.appendRow([IsoSurfaceGroup(sfView, 'Isosurfaces'), item])
+ model.appendRow([IsoSurfaceGroup(sfView,
+ self._isoLevelSliderNormalization,
+ 'Isosurfaces'),
+ item])
item = qt.QStandardItem()
item.setEditable(False)
@@ -1771,3 +1805,13 @@ class TreeView(qt.QTreeView):
def __delegateEvent(self, task):
if task == 'remove_iso':
self.__removeIsosurfaces()
+
+ def setIsoLevelSliderNormalization(self, normalization):
+ """Set the normalization for iso level slider
+
+ This MUST be called *before* :meth:`setSfView` to have an effect.
+
+ :param str normalization: Either 'linear' or 'arcsinh'
+ """
+ assert normalization in ('linear', 'arcsinh')
+ self._isoLevelSliderNormalization = normalization
diff --git a/silx/gui/plot3d/SceneWidget.py b/silx/gui/plot3d/SceneWidget.py
index f005dec..4a824d7 100644
--- a/silx/gui/plot3d/SceneWidget.py
+++ b/silx/gui/plot3d/SceneWidget.py
@@ -39,6 +39,7 @@ from ..colors import rgba
from .Plot3DWidget import Plot3DWidget
from . import items
+from .items.core import RootGroupWithAxesItem
from .scene import interaction
from ._model import SceneModel, visitQAbstractItemModel
from ._model.items import Item3DRow
@@ -363,10 +364,11 @@ class SceneWidget(Plot3DWidget):
self._foregroundColor = 1., 1., 1., 1.
self._highlightColor = 0.7, 0.7, 0., 1.
- self._sceneGroup = items.GroupWithAxesItem(parent=self)
+ self._sceneGroup = RootGroupWithAxesItem(parent=self)
self._sceneGroup.setLabel('Data')
- self.viewport.scene.children.append(self._sceneGroup._getScenePrimitive())
+ self.viewport.scene.children.append(
+ self._sceneGroup._getScenePrimitive())
def model(self):
"""Returns the model corresponding the scene of this widget
@@ -395,6 +397,28 @@ class SceneWidget(Plot3DWidget):
"""
return self._sceneGroup
+ def pickItems(self, x, y, condition=None):
+ """Iterator over picked items in the scene at given position.
+
+ Each picked item yield a
+ :class:`~silx.gui.plot3d.items._pick.PickingResult` object
+ holding the picking information.
+
+ It traverses the scene tree in a left-to-right top-down way.
+
+ :param int x: X widget coordinate
+ :param int y: Y widget coordinate
+ :param callable condition: Optional test called for each item
+ checking whether to process it or not.
+ """
+ if not self.isValid() or not self.isVisible():
+ return # Empty iterator
+
+ devicePixelRatio = self.getDevicePixelRatio()
+ for result in self.getSceneGroup().pickItems(
+ x * devicePixelRatio, y * devicePixelRatio, condition):
+ yield result
+
# Interactive modes
def _handleSelectionChanged(self, current, previous):
diff --git a/silx/gui/plot3d/_model/items.py b/silx/gui/plot3d/_model/items.py
index 02485fe..b09f29a 100644
--- a/silx/gui/plot3d/_model/items.py
+++ b/silx/gui/plot3d/_model/items.py
@@ -41,7 +41,7 @@ import numpy
from silx.third_party import six
-from ...utils._image import convertArrayToQImage
+from ...utils.image import convertArrayToQImage
from ...colors import preferredColormaps
from ... import qt, icons
from .. import items
diff --git a/silx/gui/plot3d/actions/io.py b/silx/gui/plot3d/actions/io.py
index f30abeb..4020d6f 100644
--- a/silx/gui/plot3d/actions/io.py
+++ b/silx/gui/plot3d/actions/io.py
@@ -43,7 +43,7 @@ from silx.gui import qt, printer
from silx.gui.icons import getQIcon
from .Plot3DAction import Plot3DAction
from ..utils import mng
-from ...utils._image import convertQImageToArray
+from ...utils.image import convertQImageToArray
_logger = logging.getLogger(__name__)
diff --git a/silx/gui/plot3d/items/_pick.py b/silx/gui/plot3d/items/_pick.py
new file mode 100644
index 0000000..b35ef0d
--- /dev/null
+++ b/silx/gui/plot3d/items/_pick.py
@@ -0,0 +1,292 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018 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.
+#
+# ###########################################################################*/
+"""This module provides classes supporting item picking.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "24/09/2018"
+
+import logging
+import numpy
+
+from ..scene import Viewport, Base
+
+
+_logger = logging.getLogger(__name__)
+
+
+class PickContext(object):
+ """Store information related to current picking
+
+ :param int x: Widget coordinate
+ :param int y: Widget coordinate
+ :param ~silx.gui.plot3d.scene.Viewport viewport:
+ Viewport where picking occurs
+ :param Union[None,callable] condition:
+ Test whether each item needs to be picked or not.
+ """
+
+ def __init__(self, x, y, viewport, condition):
+ self._widgetPosition = x, y
+ assert isinstance(viewport, Viewport)
+ self._viewport = viewport
+ self._ndcZRange = -1., 1.
+ self._enabled = True
+ self._condition = condition
+
+ def copy(self):
+ """Returns a copy
+
+ :rtype: PickContent
+ """
+ x, y = self.getWidgetPosition()
+ context = PickContext(x, y, self.getViewport(), self._condition)
+ context.setNDCZRange(*self._ndcZRange)
+ context.setEnabled(self.isEnabled())
+ return context
+
+ def isItemPickable(self, item):
+ """Check condition for the given item.
+
+ :param Item3D item:
+ :return: Whether to process the item (True) or to skip it (False)
+ :rtype: bool
+ """
+ return self._condition is None or self._condition(item)
+
+ def getViewport(self):
+ """Returns viewport where picking occurs
+
+ :rtype: ~silx.gui.plot3d.scene.Viewport
+ """
+ return self._viewport
+
+ def getWidgetPosition(self):
+ """Returns (x, y) position in pixel in the widget
+
+ Origin is at the top-left corner of the widget,
+ X from left to right, Y goes downward.
+
+ :rtype: List[int]
+ """
+ return self._widgetPosition
+
+ def setEnabled(self, enabled):
+ """Set whether picking is enabled or not
+
+ :param bool enabled: True to enable picking, False otherwise
+ """
+ self._enabled = bool(enabled)
+
+ def isEnabled(self):
+ """Returns True if picking is currently enabled, False otherwise.
+
+ :rtype: bool
+ """
+ return self._enabled
+
+ def setNDCZRange(self, near=-1., far=1.):
+ """Set near and far Z value in normalized device coordinates
+
+ This allows to clip the ray to a subset of the NDC range
+
+ :param float near: Near segment end point Z coordinate
+ :param float far: Far segment end point Z coordinate
+ """
+ self._ndcZRange = near, far
+
+ def getNDCPosition(self):
+ """Return Normalized device coordinates of picked point.
+
+ :return: (x, y) in NDC coordinates or None if outside viewport.
+ :rtype: Union[None,List[float]]
+ """
+ if not self.isEnabled():
+ return None
+
+ # Convert x, y from window to NDC
+ x, y = self.getWidgetPosition()
+ return self.getViewport().windowToNdc(x, y, checkInside=True)
+
+ def getPickingSegment(self, frame):
+ """Returns picking segment in requested coordinate frame.
+
+ :param Union[str,Base] frame:
+ The frame in which to get the picking segment,
+ either a keyword: 'ndc', 'camera', 'scene' or a scene
+ :class:`~silx.gui.plot3d.scene.Base` object.
+ :return: Near and far points of the segment as (x, y, z, w)
+ or None if picked point is outside viewport
+ :rtype: Union[None,numpy.ndarray]
+ """
+ assert frame in ('ndc', 'camera', 'scene') or isinstance(frame, Base)
+
+ positionNdc = self.getNDCPosition()
+ if positionNdc is None:
+ return None
+
+ near, far = self._ndcZRange
+ rayNdc = numpy.array((positionNdc + (near, 1.),
+ positionNdc + (far, 1.)),
+ dtype=numpy.float64)
+ if frame == 'ndc':
+ return rayNdc
+
+ viewport = self.getViewport()
+
+ rayCamera = viewport.camera.intrinsic.transformPoints(
+ rayNdc,
+ direct=False,
+ perspectiveDivide=True)
+ if frame == 'camera':
+ return rayCamera
+
+ rayScene = viewport.camera.extrinsic.transformPoints(
+ rayCamera, direct=False)
+ if frame == 'scene':
+ return rayScene
+
+ # frame is a scene Base object
+ rayObject = frame.objectToSceneTransform.transformPoints(
+ rayScene, direct=False)
+ return rayObject
+
+
+class PickingResult(object):
+ """Class to access picking information in a 3D scene.
+ """
+
+ def __init__(self, item, positions, indices=None, fetchdata=None):
+ """Init
+
+ :param ~silx.gui.plot3d.items.Item3D item: The picked item
+ :param numpy.ndarray positions:
+ Nx3 array-like of picked positions (x, y, z) in item coordinates.
+ :param numpy.ndarray indices: Array-like of indices of picked data.
+ Either 1D or 2D with dim0: data dimension and dim1: indices.
+ No copy is made.
+ :param callable fetchdata: Optional function with a bool copy argument
+ to provide an alternative function to access item data.
+ Default is to use `item.getData`.
+ """
+ self._item = item
+ self._objectPositions = numpy.array(
+ positions, copy=False, dtype=numpy.float)
+
+ # Store matrices to generate positions on demand
+ primitive = item._getScenePrimitive()
+ self._objectToSceneTransform = primitive.objectToSceneTransform
+ self._objectToNDCTransform = primitive.objectToNDCTransform
+ self._scenePositions = None
+ self._ndcPositions = None
+
+ if indices is None:
+ self._indices = None
+ else:
+ self._indices = numpy.array(indices, copy=False, dtype=numpy.int)
+
+ self._fetchdata = fetchdata
+
+ def getItem(self):
+ """Returns the item this results corresponds to.
+
+ :rtype: ~silx.gui.plot3d.items.Item3D
+ """
+ return self._item
+
+ def getIndices(self, copy=True):
+ """Returns indices of picked data.
+
+ If data is 1D, it returns a numpy.ndarray, otherwise
+ it returns a tuple with as many numpy.ndarray as there are
+ dimensions in the data.
+
+ :param bool copy: True (default) to get a copy,
+ False to return internal arrays
+ :rtype: Union[None,numpy.ndarray,List[numpy.ndarray]]
+ """
+ if self._indices is None:
+ return None
+ indices = numpy.array(self._indices, copy=copy)
+ return indices if indices.ndim == 1 else tuple(indices)
+
+ def getData(self, copy=True):
+ """Returns picked data values
+
+ :param bool copy: True (default) to get a copy,
+ False to return internal arrays
+ :rtype: Union[None,numpy.ndarray]
+ """
+
+ indices = self.getIndices(copy=False)
+ if indices is None or len(indices) == 0:
+ return None
+
+ item = self.getItem()
+ if self._fetchdata is None:
+ if hasattr(item, 'getData'):
+ data = item.getData(copy=False)
+ else:
+ return None
+ else:
+ data = self._fetchdata(copy=False)
+
+ return numpy.array(data[indices], copy=copy)
+
+ def getPositions(self, frame='scene', copy=True):
+ """Returns picking positions in item coordinates.
+
+ :param str frame: The frame in which the positions are returned
+ Either 'scene' for world space,
+ 'ndc' for normalized device coordinates or 'object' for item frame.
+ :param bool copy: True (default) to get a copy,
+ False to return internal arrays
+ :return: Nx3 array of (x, y, z) coordinates
+ :rtype: numpy.ndarray
+ """
+ if frame == 'ndc':
+ if self._ndcPositions is None: # Lazy-loading
+ self._ndcPositions = self._objectToNDCTransform.transformPoints(
+ self._objectPositions, perspectiveDivide=True)
+
+ positions = self._ndcPositions
+
+ elif frame == 'scene':
+ if self._scenePositions is None: # Lazy-loading
+ self._scenePositions = self._objectToSceneTransform.transformPoints(
+ self._objectPositions)
+
+ positions = self._scenePositions
+
+ elif frame == 'object':
+ positions = self._objectPositions
+
+ else:
+ raise ValueError('Unsupported frame argument: %s' % str(frame))
+
+ return numpy.array(positions, copy=copy)
diff --git a/silx/gui/plot3d/items/clipplane.py b/silx/gui/plot3d/items/clipplane.py
index a5ba0e6..3e819d0 100644
--- a/silx/gui/plot3d/items/clipplane.py
+++ b/silx/gui/plot3d/items/clipplane.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 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
@@ -32,8 +32,11 @@ __license__ = "MIT"
__date__ = "15/11/2017"
-from ..scene import primitives
+import numpy
+from ..scene import primitives, utils
+
+from ._pick import PickingResult
from .core import Item3D
from .mixins import PlaneMixIn
@@ -48,3 +51,86 @@ class ClipPlane(Item3D, PlaneMixIn):
plane = primitives.ClipPlane()
Item3D.__init__(self, parent=parent, primitive=plane)
PlaneMixIn.__init__(self, plane=plane)
+
+ def __pickPreProcessing(self, context):
+ """Common processing for :meth:`_pickPostProcess` and :meth:`_pickFull`
+
+ :param PickContext context: Current picking context
+ :return None or (bounds, intersection points, rayObject)
+ """
+ plane = self._getPlane()
+ planeParent = plane.parent
+ if planeParent is None:
+ return None
+
+ rayObject = context.getPickingSegment(frame=plane)
+ if rayObject is None:
+ return None
+
+ bounds = planeParent.bounds(dataBounds=True)
+ rayClip = utils.clipSegmentToBounds(rayObject[:, :3], bounds)
+ if rayClip is None:
+ return None # Ray is outside parent's bounding box
+
+ points = utils.segmentPlaneIntersect(
+ rayObject[0, :3],
+ rayObject[1, :3],
+ planeNorm=self.getNormal(),
+ planePt=self.getPoint())
+
+ # A single intersection inside bounding box
+ picked = (len(points) == 1 and
+ numpy.all(bounds[0] <= points[0]) and
+ numpy.all(points[0] <= bounds[1]))
+
+ return picked, points, rayObject
+
+ def _pick(self, context):
+ # Perform picking before modifying context
+ result = super(ClipPlane, self)._pick(context)
+
+ # Modify context if needed
+ if self.isVisible() and context.isEnabled():
+ info = self.__pickPreProcessing(context)
+ if info is not None:
+ picked, points, rayObject = info
+ plane = self._getPlane()
+
+ if picked: # A single intersection inside bounding box
+ # Clip NDC z range for following brother items
+ ndcIntersect = plane.objectToNDCTransform.transformPoint(
+ points[0], perspectiveDivide=True)
+ ndcNormal = plane.objectToNDCTransform.transformNormal(
+ self.getNormal())
+ if ndcNormal[2] < 0:
+ context.setNDCZRange(-1., ndcIntersect[2])
+ else:
+ context.setNDCZRange(ndcIntersect[2], 1.)
+
+ else:
+ # TODO check this might not be correct
+ rayObject[:, 3] = 1. # Make sure 4h coordinate is one
+ if numpy.sum(rayObject[0] * self.getParameters()) < 0.:
+ # Disable picking for remaining brothers
+ context.setEnabled(False)
+
+ return result
+
+ def _pickFastCheck(self, context):
+ return True
+
+ def _pickFull(self, context):
+ """Perform picking in this item at given widget position.
+
+ :param PickContext context: Current picking context
+ :return: Object holding the results or None
+ :rtype: Union[None,PickingResult]
+ """
+ info = self.__pickPreProcessing(context)
+ if info is not None:
+ picked, points, _ = info
+
+ if picked:
+ return PickingResult(self, positions=[points[0]])
+
+ return None
diff --git a/silx/gui/plot3d/items/core.py b/silx/gui/plot3d/items/core.py
index e549e59..0aefced 100644
--- a/silx/gui/plot3d/items/core.py
+++ b/silx/gui/plot3d/items/core.py
@@ -41,6 +41,7 @@ from ... import qt
from ...plot.items import ItemChangedType
from .. import scene
from ..scene import axes, primitives, transform
+from ._pick import PickContext
@enum.unique
@@ -219,6 +220,53 @@ class Item3D(qt.QObject):
self._setForegroundColor(
widget.getForegroundColor().getRgbF())
+ # picking
+
+ def _pick(self, context):
+ """Implement picking on this item.
+
+ :param PickContext context: Current picking context
+ :return: Data indices at picked position or None
+ :rtype: Union[None,PickingResult]
+ """
+ if (self.isVisible() and
+ context.isEnabled() and
+ context.isItemPickable(self) and
+ self._pickFastCheck(context)):
+ return self._pickFull(context)
+ return None
+
+ def _pickFastCheck(self, context):
+ """Approximate item pick test (e.g., bounding box-based picking).
+
+ :param PickContext context: Current picking context
+ :return: True if item might be picked
+ :rtype: bool
+ """
+ primitive = self._getScenePrimitive()
+
+ positionNdc = context.getNDCPosition()
+ if positionNdc is None: # No picking outside viewport
+ return False
+
+ bounds = primitive.bounds(transformed=False, dataBounds=False)
+ if bounds is None: # primitive has no bounds
+ return False
+
+ bounds = primitive.objectToNDCTransform.transformBounds(bounds)
+
+ return (bounds[0, 0] <= positionNdc[0] <= bounds[1, 0] and
+ bounds[0, 1] <= positionNdc[1] <= bounds[1, 1])
+
+ def _pickFull(self, context):
+ """Perform precise picking in this item at given widget position.
+
+ :param PickContext context: Current picking context
+ :return: Object holding the results or None
+ :rtype: Union[None,PickingResult]
+ """
+ return None
+
class DataItem3D(Item3D):
"""Base class representing a data item with transform in the scene.
@@ -256,12 +304,14 @@ class DataItem3D(Item3D):
self._rotationCenter = 0., 0., 0.
- self._getScenePrimitive().transforms = [
+ self.__transforms = transform.TransformList([
self._translate,
self._rotateForwardTranslation,
self._rotate,
self._rotateBackwardTranslation,
- self._transformObjectToRotate]
+ self._transformObjectToRotate])
+
+ self._getScenePrimitive().transforms = self.__transforms
def _updated(self, event=None):
"""Handle MixIn class updates.
@@ -274,6 +324,13 @@ class DataItem3D(Item3D):
# Transformations
+ def _getSceneTransforms(self):
+ """Return TransformList corresponding to current transforms
+
+ :rtype: TransformList
+ """
+ return self.__transforms
+
def setScale(self, sx=1., sy=1., sz=1.):
"""Set the scale of the item in the scene.
@@ -452,7 +509,92 @@ class DataItem3D(Item3D):
self._updated(Item3DChangedType.BOUNDING_BOX_VISIBLE)
-class _BaseGroupItem(DataItem3D):
+class BaseNodeItem(DataItem3D):
+ """Base class for data item having children (e.g., group, 3d volume)."""
+
+ def __init__(self, parent=None, group=None):
+ """Base class representing a group of items in the scene.
+
+ :param parent: The View widget this item belongs to.
+ :param Union[GroupBBox, None] group:
+ The scene group to use for rendering
+ """
+ DataItem3D.__init__(self, parent=parent, group=group)
+
+ def getItems(self):
+ """Returns the list of items currently present in the group.
+
+ :rtype: tuple
+ """
+ raise NotImplementedError('getItems must be implemented in subclass')
+
+ def visit(self, included=True):
+ """Generator visiting the group content.
+
+ It traverses the group sub-tree in a top-down left-to-right way.
+
+ :param bool included: True (default) to include self in visit
+ """
+ if included:
+ yield self
+ for child in self.getItems():
+ yield child
+ if hasattr(child, 'visit'):
+ for item in child.visit(included=False):
+ yield item
+
+ def pickItems(self, x, y, condition=None):
+ """Iterator over picked items in the group at given position.
+
+ Each picked item yield a :class:`PickingResult` object
+ holding the picking information.
+
+ It traverses the group sub-tree in a left-to-right top-down way.
+
+ :param int x: X widget device pixel coordinate
+ :param int y: Y widget device pixel coordinate
+ :param callable condition: Optional test called for each item
+ checking whether to process it or not.
+ """
+ viewport = self._getScenePrimitive().viewport
+ if viewport is None:
+ raise RuntimeError(
+ 'Cannot perform picking: Item not attached to a widget')
+
+ context = PickContext(x, y, viewport, condition)
+ for result in self._pickItems(context):
+ yield result
+
+ def _pickItems(self, context):
+ """Implement :meth:`pickItems`
+
+ :param PickContext context: Current picking context
+ """
+ if not self.isVisible() or not context.isEnabled():
+ return # empty iterator
+
+ # Use a copy to discard context changes once this returns
+ context = context.copy()
+
+ if not self._pickFastCheck(context):
+ return # empty iterator
+
+ result = self._pick(context)
+ if result is not None:
+ yield result
+
+ for child in self.getItems():
+ if isinstance(child, BaseNodeItem):
+ for result in child._pickItems(context):
+ yield result # Flatten result
+
+ else:
+ result = child._pick(context)
+ if result is not None:
+ yield result
+
+
+class _BaseGroupItem(BaseNodeItem):
"""Base class for group of items sharing a common transform."""
sigItemAdded = qt.Signal(object)
@@ -474,9 +616,16 @@ class _BaseGroupItem(DataItem3D):
:param Union[GroupBBox, None] group:
The scene group to use for rendering
"""
- DataItem3D.__init__(self, parent=parent, group=group)
+ BaseNodeItem.__init__(self, parent=parent, group=group)
self._items = []
+ def _getGroupPrimitive(self):
+ """Returns the group for which to handle children.
+
+ This allows this group to be different from the primitive.
+ """
+ return self._getScenePrimitive()
+
def addItem(self, item, index=None):
"""Add an item to the group
@@ -493,11 +642,11 @@ class _BaseGroupItem(DataItem3D):
item.setParent(self)
if index is None:
- self._getScenePrimitive().children.append(
+ self._getGroupPrimitive().children.append(
item._getScenePrimitive())
self._items.append(item)
else:
- self._getScenePrimitive().children.insert(
+ self._getGroupPrimitive().children.insert(
index, item._getScenePrimitive())
self._items.insert(index, item)
self.sigItemAdded.emit(item)
@@ -518,7 +667,7 @@ class _BaseGroupItem(DataItem3D):
if item not in self.getItems():
raise ValueError("Item3D not in group: %s" % str(item))
- self._getScenePrimitive().children.remove(item._getScenePrimitive())
+ self._getGroupPrimitive().children.remove(item._getScenePrimitive())
self._items.remove(item)
item.setParent(None)
self.sigItemRemoved.emit(item)
@@ -528,21 +677,6 @@ class _BaseGroupItem(DataItem3D):
for item in self.getItems():
self.removeItem(item)
- def visit(self, included=True):
- """Generator visiting the group content.
-
- It traverses the group sub-tree in a top-down left-to-right way.
-
- :param bool included: True (default) to include self in visit
- """
- if included:
- yield self
- for child in self.getItems():
- yield child
- if hasattr(child, 'visit'):
- for item in child.visit(included=False):
- yield item
-
class GroupItem(_BaseGroupItem):
"""Group of items sharing a common transform."""
@@ -620,3 +754,26 @@ class GroupWithAxesItem(_BaseGroupItem):
return self._Labels((labelledAxes.xlabel,
labelledAxes.ylabel,
labelledAxes.zlabel))
+
+
+class RootGroupWithAxesItem(GroupWithAxesItem):
+ """Special group with axes item for root of the scene.
+
+ Uses 2 groups so that axes take transforms into account.
+ """
+
+ def __init__(self, parent=None):
+ super(RootGroupWithAxesItem, self).__init__(parent)
+ self.__group = scene.Group()
+ self.__group.transforms = self._getSceneTransforms()
+
+ groupWithAxes = self._getScenePrimitive()
+ groupWithAxes.transforms = [] # Do not apply transforms here
+ groupWithAxes.children.append(self.__group)
+
+ def _getGroupPrimitive(self):
+ """Returns the group for which to handle children.
+
+ This allows this group to be different from the primitive.
+ """
+ return self.__group
diff --git a/silx/gui/plot3d/items/image.py b/silx/gui/plot3d/items/image.py
index 9e8bf1e..210f2f3 100644
--- a/silx/gui/plot3d/items/image.py
+++ b/silx/gui/plot3d/items/image.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 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
@@ -33,22 +33,72 @@ __date__ = "15/11/2017"
import numpy
-from ..scene import primitives
+from ..scene import primitives, utils
from .core import DataItem3D, ItemChangedType
from .mixins import ColormapMixIn, InterpolationMixIn
+from ._pick import PickingResult
-class ImageData(DataItem3D, ColormapMixIn, InterpolationMixIn):
- """Description of a 2D image data.
+class _Image(DataItem3D, InterpolationMixIn):
+ """Base class for images
:param parent: The View widget this item belongs to.
"""
def __init__(self, parent=None):
DataItem3D.__init__(self, parent=parent)
- ColormapMixIn.__init__(self)
InterpolationMixIn.__init__(self)
+ def _setPrimitive(self, primitive):
+ InterpolationMixIn._setPrimitive(self, primitive)
+
+ def getData(self, copy=True):
+ raise NotImplementedError()
+
+ def _pickFull(self, context):
+ """Perform picking in this item at given widget position.
+
+ :param PickContext context: Current picking context
+ :return: Object holding the results or None
+ :rtype: Union[None,PickingResult]
+ """
+ rayObject = context.getPickingSegment(frame=self._getScenePrimitive())
+ if rayObject is None:
+ return None
+
+ points = utils.segmentPlaneIntersect(
+ rayObject[0, :3],
+ rayObject[1, :3],
+ planeNorm=numpy.array((0., 0., 1.), dtype=numpy.float64),
+ planePt=numpy.array((0., 0., 0.), dtype=numpy.float64))
+
+ if len(points) == 1: # Single intersection
+ if points[0][0] < 0. or points[0][1] < 0.:
+ return None # Outside image
+ row, column = int(points[0][1]), int(points[0][0])
+ data = self.getData(copy=False)
+ height, width = data.shape[:2]
+ if row < height and column < width:
+ return PickingResult(
+ self,
+ positions=[(points[0][0], points[0][1], 0.)],
+ indices=([row], [column]))
+ else:
+ return None # Outside image
+ else: # Either no intersection or segment and image are coplanar
+ return None
+
+
+class ImageData(_Image, ColormapMixIn):
+ """Description of a 2D image data.
+
+ :param parent: The View widget this item belongs to.
+ """
+
+ def __init__(self, parent=None):
+ _Image.__init__(self, parent=parent)
+ ColormapMixIn.__init__(self)
+
self._data = numpy.zeros((0, 0), dtype=numpy.float32)
self._image = primitives.ImageData(self._data)
@@ -56,7 +106,7 @@ class ImageData(DataItem3D, ColormapMixIn, InterpolationMixIn):
# Connect scene primitive to mix-in class
ColormapMixIn._setSceneColormap(self, self._image.colormap)
- InterpolationMixIn._setPrimitive(self, self._image)
+ _Image._setPrimitive(self, self._image)
def setData(self, data, copy=True):
"""Set the image data to display.
@@ -83,14 +133,14 @@ class ImageData(DataItem3D, ColormapMixIn, InterpolationMixIn):
return self._image.getData(copy=copy)
-class ImageRgba(DataItem3D, InterpolationMixIn):
+class ImageRgba(_Image, InterpolationMixIn):
"""Description of a 2D data RGB(A) image.
:param parent: The View widget this item belongs to.
"""
def __init__(self, parent=None):
- DataItem3D.__init__(self, parent=parent)
+ _Image.__init__(self, parent=parent)
InterpolationMixIn.__init__(self)
self._data = numpy.zeros((0, 0, 3), dtype=numpy.float32)
@@ -99,7 +149,7 @@ class ImageRgba(DataItem3D, InterpolationMixIn):
self._getScenePrimitive().children.append(self._image)
# Connect scene primitive to mix-in class
- InterpolationMixIn._setPrimitive(self, self._image)
+ _Image._setPrimitive(self, self._image)
def setData(self, data, copy=True):
"""Set the RGB(A) image data to display.
diff --git a/silx/gui/plot3d/items/mesh.py b/silx/gui/plot3d/items/mesh.py
index 12a3941..21936ea 100644
--- a/silx/gui/plot3d/items/mesh.py
+++ b/silx/gui/plot3d/items/mesh.py
@@ -29,13 +29,19 @@ from __future__ import absolute_import
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "15/11/2017"
+__date__ = "17/07/2018"
+
+import logging
import numpy
-from ..scene import primitives
-from .core import DataItem3D, ItemChangedType
+from ..scene import primitives, utils
from ..scene.transform import Rotate
+from .core import DataItem3D, ItemChangedType
+from ._pick import PickingResult
+
+
+_logger = logging.getLogger(__name__)
class Mesh(DataItem3D):
@@ -56,11 +62,7 @@ class Mesh(DataItem3D):
copy=True):
"""Set mesh geometry data.
- Supported drawing modes are:
-
- - For points: 'points'
- - For lines: 'lines', 'line_strip', 'loop'
- - For triangles: 'triangles', 'triangle_strip', 'fan'
+ Supported drawing modes are: 'triangles', 'triangle_strip', 'fan'
:param numpy.ndarray position:
Position (x, y, z) of each vertex as a (N, 3) array
@@ -73,7 +75,7 @@ class Mesh(DataItem3D):
self._getScenePrimitive().children = [] # Remove any previous mesh
if position is None or len(position) == 0:
- self._mesh = 0
+ self._mesh = None
else:
self._mesh = primitives.Mesh3D(
position, color, normal, mode=mode, copy=copy)
@@ -145,6 +147,72 @@ class Mesh(DataItem3D):
"""
return self._mesh.drawMode
+ def _pickFull(self, context):
+ """Perform precise picking in this item at given widget position.
+
+ :param PickContext context: Current picking context
+ :return: Object holding the results or None
+ :rtype: Union[None,PickingResult]
+ """
+ rayObject = context.getPickingSegment(frame=self._getScenePrimitive())
+ if rayObject is None: # No picking outside viewport
+ return None
+ rayObject = rayObject[:, :3]
+
+ positions = self.getPositionData(copy=False)
+ if positions.size == 0:
+ return None
+
+ mode = self.getDrawMode()
+ if mode == 'triangles':
+ triangles = positions.reshape(-1, 3, 3)
+
+ elif mode == 'triangle_strip':
+ # Expand strip
+ triangles = numpy.empty((len(positions) - 2, 3, 3),
+ dtype=positions.dtype)
+ triangles[:, 0] = positions[:-2]
+ triangles[:, 1] = positions[1:-1]
+ triangles[:, 2] = positions[2:]
+
+ elif mode == 'fan':
+ # Expand fan
+ triangles = numpy.empty((len(positions) - 2, 3, 3),
+ dtype=positions.dtype)
+ triangles[:, 0] = positions[0]
+ triangles[:, 1] = positions[1:-1]
+ triangles[:, 2] = positions[2:]
+
+ else:
+ _logger.warning("Unsupported draw mode: %s" % mode)
+ return None
+
+ trianglesIndices, t, barycentric = utils.segmentTrianglesIntersection(
+ rayObject, triangles)
+
+ if len(trianglesIndices) == 0:
+ return None
+
+ points = t.reshape(-1, 1) * (rayObject[1] - rayObject[0]) + rayObject[0]
+
+ # Get vertex index from triangle index and closest point in triangle
+ closest = numpy.argmax(barycentric, axis=1)
+
+ if mode == 'triangles':
+ indices = trianglesIndices * 3 + closest
+
+ elif mode == 'triangle_strip':
+ indices = trianglesIndices + closest
+
+ elif mode == 'fan':
+ indices = trianglesIndices + closest # For corners 1 and 2
+ indices[closest == 0] = 0 # For first corner (common)
+
+ return PickingResult(self,
+ positions=points,
+ indices=indices,
+ fetchdata=self.getPositionData)
+
class _CylindricalVolume(DataItem3D):
"""Class that represents a volume with a rotational symmetry along z
@@ -155,6 +223,18 @@ class _CylindricalVolume(DataItem3D):
def __init__(self, parent=None):
DataItem3D.__init__(self, parent=parent)
self._mesh = None
+ self._nbFaces = 0
+
+ def getPosition(self, copy=True):
+ """Get primitive positions.
+
+ :param bool copy:
+ True (default) to get a copy,
+ False to get internal representation (do not modify!).
+ :return: Position of the primitives as a (N, 3) array.
+ :rtype: numpy.ndarray
+ """
+ raise NotImplementedError("Must be implemented in subclass")
def _setData(self, position, radius, height, angles, color, flatFaces,
rotation):
@@ -173,30 +253,31 @@ class _CylindricalVolume(DataItem3D):
self._getScenePrimitive().children = [] # Remove any previous mesh
if position is None or len(position) == 0:
- self._mesh = 0
+ self._mesh = None
+ self._nbFaces = 0
else:
+ self._nbFaces = len(angles) - 1
+
volume = numpy.empty(shape=(len(angles) - 1, 12, 3),
dtype=numpy.float32)
normal = numpy.empty(shape=(len(angles) - 1, 12, 3),
dtype=numpy.float32)
for i in range(0, len(angles) - 1):
- """
- c6
- /\
- / \
- / \
- c4|------|c5
- | \ |
- | \ |
- | \ |
- | \ |
- c2|------|c3
- \ /
- \ /
- \/
- c1
- """
+ # c6
+ # /\
+ # / \
+ # / \
+ # c4|------|c5
+ # | \ |
+ # | \ |
+ # | \ |
+ # | \ |
+ # c2|------|c3
+ # \ /
+ # \ /
+ # \/
+ # c1
c1 = numpy.array([0, 0, -height/2])
c1 = rotation.transformPoint(c1)
c2 = numpy.array([radius * numpy.cos(angles[i]),
@@ -266,6 +347,49 @@ class _CylindricalVolume(DataItem3D):
self.sigItemChanged.emit(ItemChangedType.DATA)
+ def _pickFull(self, context):
+ """Perform precise picking in this item at given widget position.
+
+ :param PickContext context: Current picking context
+ :return: Object holding the results or None
+ :rtype: Union[None,PickingResult]
+ """
+ if self._mesh is None or self._nbFaces == 0:
+ return None
+
+ rayObject = context.getPickingSegment(frame=self._getScenePrimitive())
+ if rayObject is None: # No picking outside viewport
+ return None
+ rayObject = rayObject[:, :3]
+
+ positions = self._mesh.getAttribute('position', copy=False)
+ triangles = positions.reshape(-1, 3, 3) # 'triangle' draw mode
+
+ trianglesIndices, t = utils.segmentTrianglesIntersection(
+ rayObject, triangles)[:2]
+
+ if len(trianglesIndices) == 0:
+ return None
+
+ # Get object index from triangle index
+ indices = trianglesIndices // (4 * self._nbFaces)
+
+ # Select closest intersection point for each primitive
+ indices, firstIndices = numpy.unique(indices, return_index=True)
+ t = t[firstIndices]
+
+ # Resort along t as result of numpy.unique is not sorted by t
+ sortedIndices = numpy.argsort(t)
+ t = t[sortedIndices]
+ indices = indices[sortedIndices]
+
+ points = t.reshape(-1, 1) * (rayObject[1] - rayObject[0]) + rayObject[0]
+
+ return PickingResult(self,
+ positions=points,
+ indices=indices,
+ fetchdata=self.getPosition)
+
class Box(_CylindricalVolume):
"""Description of a box.
diff --git a/silx/gui/plot3d/items/scatter.py b/silx/gui/plot3d/items/scatter.py
index 5eea455..a13c3db 100644
--- a/silx/gui/plot3d/items/scatter.py
+++ b/silx/gui/plot3d/items/scatter.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 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
@@ -40,6 +40,7 @@ from ..scene import function, primitives, utils
from .core import DataItem3D, Item3DChangedType, ItemChangedType
from .mixins import ColormapMixIn, SymbolMixIn
+from ._pick import PickingResult
_logger = logging.getLevelName(__name__)
@@ -116,7 +117,7 @@ class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn):
:return: X coordinates
:rtype: numpy.ndarray
"""
- return self._scatter.getAttribute('x', copy=copy)
+ return self._scatter.getAttribute('x', copy=copy).reshape(-1)
def getYData(self, copy=True):
"""Returns Y data coordinates.
@@ -126,7 +127,7 @@ class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn):
:return: Y coordinates
:rtype: numpy.ndarray
"""
- return self._scatter.getAttribute('y', copy=copy)
+ return self._scatter.getAttribute('y', copy=copy).reshape(-1)
def getZData(self, copy=True):
"""Returns Z data coordinates.
@@ -136,7 +137,7 @@ class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn):
:return: Z coordinates
:rtype: numpy.ndarray
"""
- return self._scatter.getAttribute('z', copy=copy)
+ return self._scatter.getAttribute('z', copy=copy).reshape(-1)
def getValues(self, copy=True):
"""Returns data values.
@@ -146,7 +147,64 @@ class Scatter3D(DataItem3D, ColormapMixIn, SymbolMixIn):
:return: data values
:rtype: numpy.ndarray
"""
- return self._scatter.getAttribute('value', copy=copy)
+ return self._scatter.getAttribute('value', copy=copy).reshape(-1)
+
+ def _pickFull(self, context, threshold=0., sort='depth'):
+ """Perform picking in this item at given widget position.
+
+ :param PickContext context: Current picking context
+ :param float threshold: Picking threshold in pixel.
+ Perform picking in a square of size threshold x threshold.
+ :param str sort: How returned indices are sorted:
+
+ - 'index' (default): sort by the value of the indices
+ - 'depth': Sort by the depth of the points from the current
+ camera point of view.
+ :return: Object holding the results or None
+ :rtype: Union[None,PickingResult]
+ """
+ assert sort in ('index', 'depth')
+
+ rayNdc = context.getPickingSegment(frame='ndc')
+ if rayNdc is None: # No picking outside viewport
+ return None
+
+ # Project data to NDC
+ xData = self.getXData(copy=False)
+ if len(xData) == 0: # No data in the scatter
+ return None
+
+ primitive = self._getScenePrimitive()
+
+ dataPoints = numpy.transpose((xData,
+ self.getYData(copy=False),
+ self.getZData(copy=False),
+ numpy.ones_like(xData)))
+
+ pointsNdc = primitive.objectToNDCTransform.transformPoints(
+ dataPoints, perspectiveDivide=True)
+
+ # Perform picking
+ distancesNdc = numpy.abs(pointsNdc[:, :2] - rayNdc[0, :2])
+ # TODO issue with symbol size: using pixel instead of points
+ threshold += self.getSymbolSize()
+ thresholdNdc = 2. * threshold / numpy.array(primitive.viewport.size)
+ picked = numpy.where(numpy.logical_and(
+ numpy.all(distancesNdc < thresholdNdc, axis=1),
+ numpy.logical_and(rayNdc[0, 2] <= pointsNdc[:, 2],
+ pointsNdc[:, 2] <= rayNdc[1, 2])))[0]
+
+ if sort == 'depth':
+ # Sort picked points from front to back
+ picked = picked[numpy.argsort(pointsNdc[picked, 2])]
+
+ if picked.size > 0:
+ return PickingResult(self,
+ positions=dataPoints[picked, :3],
+ indices=picked,
+ fetchdata=self.getValues)
+ else:
+ return None
class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn):
@@ -373,6 +431,120 @@ class Scatter2D(DataItem3D, ColormapMixIn, SymbolMixIn):
"""
return numpy.array(self._value, copy=copy)
+ def _pickPoints(self, context, points, threshold=1., sort='depth'):
+ """Perform picking while in 'points' visualization mode
+
+ :param PickContext context: Current picking context
+ :param float threshold: Picking threshold in pixel.
+ Perform picking in a square of size threshold x threshold.
+ :param str sort: How returned indices are sorted:
+
+ - 'index' (default): sort by the value of the indices
+ - 'depth': Sort by the depth of the points from the current
+ camera point of view.
+ :return: Object holding the results or None
+ :rtype: Union[None,PickingResult]
+ """
+ assert sort in ('index', 'depth')
+
+ rayNdc = context.getPickingSegment(frame='ndc')
+ if rayNdc is None: # No picking outside viewport
+ return None
+
+ # Project data to NDC
+ primitive = self._getScenePrimitive()
+ pointsNdc = primitive.objectToNDCTransform.transformPoints(
+ points, perspectiveDivide=True)
+
+ # Perform picking
+ distancesNdc = numpy.abs(pointsNdc[:, :2] - rayNdc[0, :2])
+ thresholdNdc = threshold / numpy.array(primitive.viewport.size)
+ picked = numpy.where(numpy.logical_and(
+ numpy.all(distancesNdc < thresholdNdc, axis=1),
+ numpy.logical_and(rayNdc[0, 2] <= pointsNdc[:, 2],
+ pointsNdc[:, 2] <= rayNdc[1, 2])))[0]
+
+ if sort == 'depth':
+ # Sort picked points from front to back
+ picked = picked[numpy.argsort(pointsNdc[picked, 2])]
+
+ if picked.size > 0:
+ return PickingResult(self,
+ positions=points[picked, :3],
+ indices=picked,
+ fetchdata=self.getValues)
+ else:
+ return None
+
+ def _pickSolid(self, context, points):
+ """Perform picking while in 'solid' visualization mode
+
+ :param PickContext context: Current picking context
+ """
+ if self._cachedTrianglesIndices is None:
+ _logger.info("Picking on Scatter2D before rendering")
+ return None
+
+ rayObject = context.getPickingSegment(frame=self._getScenePrimitive())
+ if rayObject is None: # No picking outside viewport
+ return None
+ rayObject = rayObject[:, :3]
+
+ trianglesIndices = self._cachedTrianglesIndices.reshape(-1, 3)
+ triangles = points[trianglesIndices, :3]
+ selectedIndices, t, barycentric = utils.segmentTrianglesIntersection(
+ rayObject, triangles)
+ closest = numpy.argmax(barycentric, axis=1)
+
+ indices = trianglesIndices.reshape(-1, 3)[selectedIndices, closest]
+
+ if len(indices) == 0: # No point is picked
+ return None
+
+ # Compute intersection points and get closest data point
+ positions = t.reshape(-1, 1) * (rayObject[1] - rayObject[0]) + rayObject[0]
+
+ return PickingResult(self,
+ positions=positions,
+ indices=indices,
+ fetchdata=self.getValues)
+
+ def _pickFull(self, context):
+ """Perform picking in this item at given widget position.
+
+ :param PickContext context: Current picking context
+ :return: Object holding the results or None
+ :rtype: Union[None,PickingResult]
+ """
+ xData = self.getXData(copy=False)
+ if len(xData) == 0: # No data in the scatter
+ return None
+
+ if self.isHeightMap():
+ zData = self.getValues(copy=False)
+ else:
+ zData = numpy.zeros_like(xData)
+
+ points = numpy.transpose((xData,
+ self.getYData(copy=False),
+ zData,
+ numpy.ones_like(xData)))
+
+ mode = self.getVisualization()
+ if mode == 'points':
+ # TODO issue with symbol size: using pixel instead of points
+ # Get "corrected" symbol size
+ _, threshold = self._getSceneSymbol()
+ return self._pickPoints(
+ context, points, threshold=max(3., threshold))
+
+ elif mode == 'lines':
+ # Picking only at point
+ return self._pickPoints(context, points, threshold=5.)
+
+ else: # mode == 'solid'
+ return self._pickSolid(context, points)
+
def _updateScene(self):
self._getScenePrimitive().children = [] # Remove previous primitives
diff --git a/silx/gui/plot3d/items/volume.py b/silx/gui/plot3d/items/volume.py
index a7b5923..ca22f1f 100644
--- a/silx/gui/plot3d/items/volume.py
+++ b/silx/gui/plot3d/items/volume.py
@@ -41,10 +41,11 @@ from silx.math.marchingcubes import MarchingCubes
from ... import qt
from ...colors import rgba
-from ..scene import cutplane, primitives, transform
+from ..scene import cutplane, primitives, transform, utils
-from .core import DataItem3D, Item3D, ItemChangedType, Item3DChangedType
+from .core import BaseNodeItem, Item3D, ItemChangedType, Item3DChangedType
from .mixins import ColormapMixIn, InterpolationMixIn, PlaneMixIn
+from ._pick import PickingResult
_logger = logging.getLogger(__name__)
@@ -77,7 +78,8 @@ class CutPlane(Item3D, ColormapMixIn, InterpolationMixIn, PlaneMixIn):
def _parentChanged(self, event):
"""Handle data change in the parent this plane belongs to"""
if event == ItemChangedType.DATA:
- self._getPlane().setData(self.sender().getData(), copy=False)
+ self._getPlane().setData(self.sender().getData(copy=False),
+ copy=False)
# Store data range info as 3-tuple of values
self._dataRange = self.sender().getDataRange()
@@ -113,6 +115,53 @@ class CutPlane(Item3D, ColormapMixIn, InterpolationMixIn, PlaneMixIn):
"""
return self._dataRange
+ def getData(self, copy=True):
+ """Return 3D dataset.
+
+ :param bool copy:
+ True (default) to get a copy,
+ False to get the internal data (DO NOT modify!)
+ :return: The data set (or None if not set)
+ """
+ parent = self.parent()
+ return None if parent is None else parent.getData(copy=copy)
+
+ def _pickFull(self, context):
+ """Perform picking in this item at given widget position.
+
+ :param PickContext context: Current picking context
+ :return: Object holding the results or None
+ :rtype: Union[None,PickingResult]
+ """
+ rayObject = context.getPickingSegment(frame=self._getScenePrimitive())
+ if rayObject is None:
+ return None
+
+ points = utils.segmentPlaneIntersect(
+ rayObject[0, :3],
+ rayObject[1, :3],
+ planeNorm=self.getNormal(),
+ planePt=self.getPoint())
+
+ if len(points) == 1: # Single intersection
+ if numpy.any(points[0] < 0.):
+ return None # Outside volume
+ z, y, x = int(points[0][2]), int(points[0][1]), int(points[0][0])
+
+ data = self.getData(copy=False)
+ if data is None:
+ return None # No dataset
+
+ depth, height, width = data.shape
+ if z < depth and y < height and x < width:
+ return PickingResult(self,
+ positions=[points[0]],
+ indices=([z], [y], [x]))
+ else:
+ return None # Outside image
+ else: # Either no intersection or segment and image are coplanar
+ return None
+
class Isosurface(Item3D):
"""Class representing an iso-surface in a :class:`ScalarField3D` item.
@@ -122,24 +171,28 @@ class Isosurface(Item3D):
def __init__(self, parent):
Item3D.__init__(self, parent=parent)
+ assert isinstance(parent, ScalarField3D)
+ parent.sigItemChanged.connect(self._scalarField3DChanged)
self._level = float('nan')
self._autoLevelFunction = None
self._color = rgba('#FFD700FF')
- self._data = None
+ self._updateScenePrimitive()
- # TODO register to ScalarField3D signal instead?
- def _setData(self, data, copy=True):
- """Set the data set from which to build the iso-surface.
+ def _scalarField3DChanged(self, event):
+ """Handle parent's ScalarField3D sigItemChanged"""
+ if event == ItemChangedType.DATA:
+ self._updateScenePrimitive()
- :param numpy.ndarray data: The 3D data set or None
- :param bool copy: True to make a copy, False to use as is if possible
- """
- if data is None:
- self._data = None
- else:
- self._data = numpy.array(data, copy=copy, order='C')
+ def getData(self, copy=True):
+ """Return 3D dataset.
- self._updateScenePrimitive()
+ :param bool copy:
+ True (default) to get a copy,
+ False to get the internal data (DO NOT modify!)
+ :return: The data set (or None if not set)
+ """
+ parent = self.parent()
+ return None if parent is None else parent.getData(copy=copy)
def getLevel(self):
"""Return the level of this iso-surface (float)"""
@@ -203,7 +256,9 @@ class Isosurface(Item3D):
"""Update underlying mesh"""
self._getScenePrimitive().children = []
- if self._data is None:
+ data = self.getData(copy=False)
+
+ if data is None:
if self.isAutoLevel():
self._level = float('nan')
@@ -211,7 +266,7 @@ class Isosurface(Item3D):
if self.isAutoLevel():
st = time.time()
try:
- level = float(self.getAutoLevelFunction()(self._data))
+ level = float(self.getAutoLevelFunction()(data))
except Exception:
module_ = self.getAutoLevelFunction().__module__
@@ -236,7 +291,7 @@ class Isosurface(Item3D):
st = time.time()
vertices, normals, indices = MarchingCubes(
- self._data,
+ data,
isolevel=self._level)
_logger.info('Computed iso-surface in %f s.', time.time() - st)
@@ -250,15 +305,73 @@ class Isosurface(Item3D):
indices=indices)
self._getScenePrimitive().children = [mesh]
+ def _pickFull(self, context):
+ """Perform picking in this item at given widget position.
+
+ :param PickContext context: Current picking context
+ :return: Object holding the results or None
+ :rtype: Union[None,PickingResult]
+ """
+ rayObject = context.getPickingSegment(frame=self._getScenePrimitive())
+ if rayObject is None:
+ return None
+ rayObject = rayObject[:, :3]
+
+ data = self.getData(copy=False)
+ bins = utils.segmentVolumeIntersect(
+ rayObject, numpy.array(data.shape) - 1)
+ if bins is None:
+ return None
-class ScalarField3D(DataItem3D):
+ # gather bin data
+ offsets = [(i, j, k) for i in (0, 1) for j in (0, 1) for k in (0, 1)]
+ indices = bins[:, numpy.newaxis, :] + offsets
+ binsData = data[indices[:, :, 0], indices[:, :, 1], indices[:, :, 2]]
+ # binsData.shape = nbins, 8
+ # TODO up-to this point everything can be done once for all isosurfaces
+
+ # check bin candidates
+ level = self.getLevel()
+ mask = numpy.logical_and(numpy.nanmin(binsData, axis=1) <= level,
+ level <= numpy.nanmax(binsData, axis=1))
+ bins = bins[mask]
+ binsData = binsData[mask]
+
+ if len(bins) == 0:
+ return None # No bin candidate
+
+ # do picking on candidates
+ intersections = []
+ depths = []
+ for currentBin, data in zip(bins, binsData):
+ mc = MarchingCubes(data.reshape(2, 2, 2), isolevel=level)
+ points = mc.get_vertices() + currentBin
+ triangles = points[mc.get_indices()]
+ t = utils.segmentTrianglesIntersection(rayObject, triangles)[1]
+ t = numpy.unique(t) # Duplicates happen on triangle edges
+ if len(t) != 0:
+ # Compute intersection points and get closest data point
+ points = t.reshape(-1, 1) * (rayObject[1] - rayObject[0]) + rayObject[0]
+ # Get closest data points by rounding to int
+ intersections.extend(points)
+ depths.extend(t)
+
+ if len(intersections) == 0:
+ return None # No intersected triangles
+
+ intersections = numpy.array(intersections)[numpy.argsort(depths)]
+ indices = numpy.transpose(numpy.round(intersections).astype(numpy.int))
+ return PickingResult(self, positions=intersections, indices=indices)
+
+
+class ScalarField3D(BaseNodeItem):
"""3D scalar field on a regular grid.
:param parent: The View widget this item belongs to.
"""
def __init__(self, parent=None):
- DataItem3D.__init__(self, parent=parent)
+ BaseNodeItem.__init__(self, parent=parent)
# Gives this item the shape of the data, no matter
# of the isosurface/cut plane size
@@ -327,10 +440,6 @@ class ScalarField3D(DataItem3D):
self._boundedGroup.shape = self._data.shape
- # Update iso-surfaces
- for isosurface in self.getIsosurfaces():
- isosurface._setData(self._data, copy=False)
-
self._updated(ItemChangedType.DATA)
def getData(self, copy=True):
@@ -401,7 +510,6 @@ class ScalarField3D(DataItem3D):
isosurface.setAutoLevelFunction(level)
else:
isosurface.setLevel(level)
- isosurface._setData(self._data, copy=False)
isosurface.sigItemChanged.connect(self._isosurfaceItemChanged)
self._isosurfaces.append(isosurface)
@@ -448,16 +556,11 @@ class ScalarField3D(DataItem3D):
key=lambda isosurface: - isosurface.getLevel())
self._isogroup.children = [iso._getScenePrimitive() for iso in sortedIso]
- def visit(self, included=True):
- """Generator visiting the ScalarField3D content.
+ # BaseNodeItem
- It first access cut planes and then isosurface
+ def getItems(self):
+ """Returns the list of items currently present in the ScalarField3D.
- :param bool included: True (default) to include self in visit
+ :rtype: tuple
"""
- if included:
- yield self
- for cutPlane in self.getCutPlanes():
- yield cutPlane
- for isosurface in self.getIsosurfaces():
- yield isosurface
+ return self.getCutPlanes() + self.getIsosurfaces()
diff --git a/silx/gui/plot3d/scene/event.py b/silx/gui/plot3d/scene/event.py
index 7b85434..98f8f8b 100644
--- a/silx/gui/plot3d/scene/event.py
+++ b/silx/gui/plot3d/scene/event.py
@@ -28,7 +28,7 @@ from __future__ import absolute_import, division, unicode_literals
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "25/07/2016"
+__date__ = "17/07/2018"
import logging
@@ -66,7 +66,7 @@ class Notifier(object):
try:
self._listeners.remove(listener)
except ValueError:
- _logger.warn('Trying to remove a listener that is not registered')
+ _logger.warning('Trying to remove a listener that is not registered')
def notify(self, *args, **kwargs):
"""Notify all registered listeners with the given parameters.
diff --git a/silx/gui/plot3d/scene/function.py b/silx/gui/plot3d/scene/function.py
index ba4c4ca..2921d48 100644
--- a/silx/gui/plot3d/scene/function.py
+++ b/silx/gui/plot3d/scene/function.py
@@ -28,7 +28,7 @@ from __future__ import absolute_import, division, unicode_literals
__authors__ = ["T. Vincent"]
__license__ = "MIT"
-__date__ = "08/11/2016"
+__date__ = "17/07/2018"
import contextlib
@@ -428,7 +428,7 @@ class Colormap(event.Notifier, ProgramFunction):
range_ = float(range_[0]), float(range_[1])
if self.norm == 'log' and (range_[0] <= 0. or range_[1] <= 0.):
- _logger.warn(
+ _logger.warning(
"Log normalization and negative range: updating range.")
minPos = numpy.finfo(numpy.float32).tiny
range_ = max(range_[0], minPos), max(range_[1], minPos)
diff --git a/silx/gui/plot3d/scene/primitives.py b/silx/gui/plot3d/scene/primitives.py
index af00b6d..474581a 100644
--- a/silx/gui/plot3d/scene/primitives.py
+++ b/silx/gui/plot3d/scene/primitives.py
@@ -201,7 +201,7 @@ class Geometry(core.Elem):
array = self._glReadyArray(array, copy=copy)
if name not in self._ATTR_INFO:
- _logger.info('Not checking attribute %s dimensions', name)
+ _logger.debug('Not checking attribute %s dimensions', name)
else:
checks = self._ATTR_INFO[name]
diff --git a/silx/gui/plot3d/scene/transform.py b/silx/gui/plot3d/scene/transform.py
index 4061e81..1b82397 100644
--- a/silx/gui/plot3d/scene/transform.py
+++ b/silx/gui/plot3d/scene/transform.py
@@ -305,6 +305,44 @@ class Transform(event.Notifier):
# Multiplication with vectors
+ def transformPoints(self, points, direct=True, perspectiveDivide=False):
+ """Apply the transform to an array of points.
+
+ :param points: 2D array of N vectors of 3 or 4 coordinates
+ :param bool direct: Whether to apply the direct (True, the default)
+ or inverse (False) transform.
+ :param bool perspectiveDivide: Whether to apply the perspective divide
+ (True) or not (False, the default).
+ :return: The transformed points.
+ :rtype: numpy.ndarray of same shape as points.
+ """
+ if direct:
+ matrix = self.getMatrix(copy=False)
+ else:
+ matrix = self.getInverseMatrix(copy=False)
+
+ points = numpy.array(points, copy=False)
+ assert points.ndim == 2
+
+ points = numpy.transpose(points)
+
+ dimension = points.shape[0]
+ assert dimension in (3, 4)
+
+ if dimension == 3: # Add 4th coordinate
+ points = numpy.append(
+ points,
+ numpy.ones((1, points.shape[1]), dtype=points.dtype),
+ axis=0)
+
+ result = numpy.transpose(numpy.dot(matrix, points))
+
+ if perspectiveDivide:
+ mask = result[:, 3] != 0.
+ result[mask] /= result[mask, 3][:, numpy.newaxis]
+
+ return result[:, :3] if dimension == 3 else result
+
@staticmethod
def _prepareVector(vector, w):
"""Add 4th coordinate (w) to vector if missing."""
@@ -317,8 +355,6 @@ class Transform(event.Notifier):
def transformPoint(self, point, direct=True, perspectiveDivide=False):
"""Apply the transform to a point.
- If len(point) == 3, apply perspective divide if possible.
-
:param point: Array-like vector of 3 or 4 coordinates.
:param bool direct: Whether to apply the direct (True, the default)
or inverse (False) transform.
@@ -373,7 +409,7 @@ class Transform(event.Notifier):
_CUBE_CORNERS = numpy.array(list(itertools.product((0., 1.), repeat=3)),
dtype=numpy.float32)
- """Unit cube corners used by :meth:`transformRectangularBox`"""
+ """Unit cube corners used by :meth:`transformBounds`"""
def transformBounds(self, bounds, direct=True):
"""Apply the transform to an axes-aligned rectangular box.
diff --git a/silx/gui/plot3d/scene/utils.py b/silx/gui/plot3d/scene/utils.py
index 3752289..1224f5e 100644
--- a/silx/gui/plot3d/scene/utils.py
+++ b/silx/gui/plot3d/scene/utils.py
@@ -435,6 +435,186 @@ def boxPlaneIntersect(boxVertices, boxLineIndices, planeNorm, planePt):
return points
+def clipSegmentToBounds(segment, bounds):
+ """Clip segment to volume aligned with axes.
+
+ :param numpy.ndarray segment: (p0, p1)
+ :param numpy.ndarray bounds: (lower corner, upper corner)
+ :return: Either clipped (p0, p1) or None if outside volume
+ :rtype: Union[None,List[numpy.ndarray]]
+ """
+ segment = numpy.array(segment, copy=False)
+ bounds = numpy.array(bounds, copy=False)
+
+ p0, p1 = segment
+ # Get intersection points of ray with volume boundary planes
+ # Line equation: P = offset * delta + p0
+ delta = p1 - p0
+ deltaNotZero = numpy.array(delta, copy=True)
+ deltaNotZero[deltaNotZero == 0] = numpy.nan # Invalidated to avoid division by zero
+ offsets = ((bounds - p0) / deltaNotZero).reshape(-1)
+ points = offsets.reshape(-1, 1) * delta + p0
+
+ # Avoid precision errors by using bounds value
+ points.shape = 2, 3, 3 # Reshape 1 point per bound value
+ for dim in range(3):
+ points[:, dim, dim] = bounds[:, dim]
+ points.shape = -1, 3 # Set back to 2D array
+
+ # Find intersection points that are included in the volume
+ mask = numpy.logical_and(numpy.all(bounds[0] <= points, axis=1),
+ numpy.all(points <= bounds[1], axis=1))
+ intersections = numpy.unique(offsets[mask])
+ if len(intersections) != 2:
+ return None
+
+ intersections.sort()
+ # Do p1 first as p0 is need to compute it
+ if intersections[1] < 1: # clip p1
+ segment[1] = intersections[1] * delta + p0
+ if intersections[0] > 0: # clip p0
+ segment[0] = intersections[0] * delta + p0
+ return segment
+
+
+def segmentVolumeIntersect(segment, nbins):
+ """Get bin indices intersecting with segment
+
+ It should work with N dimensions.
+ Coordinate convention (z, y, x) or (x, y, z) should not matter
+ as long as segment and nbins are consistent.
+
+ :param numpy.ndarray segment:
+ Segment end points as a 2xN array of coordinates
+ :param numpy.ndarray nbins:
+ Shape of the volume with same coordinates order as segment
+ :return: List of bins indices as a 2D array or None if no bins
+ :rtype: Union[None,numpy.ndarray]
+ """
+ segment = numpy.asarray(segment)
+ nbins = numpy.asarray(nbins)
+
+ assert segment.ndim == 2
+ assert segment.shape[0] == 2
+ assert nbins.ndim == 1
+ assert segment.shape[1] == nbins.size
+
+ dim = len(nbins)
+
+ bounds = numpy.array((numpy.zeros_like(nbins), nbins))
+ segment = clipSegmentToBounds(segment, bounds)
+ if segment is None:
+ return None # Segment outside volume
+ p0, p1 = segment
+
+ # Get intersections
+
+ # Get coordinates of bin edges crossing the segment
+ clipped = numpy.ceil(numpy.clip(segment, 0, nbins))
+ start = numpy.min(clipped, axis=0)
+ stop = numpy.max(clipped, axis=0) # stop is NOT included
+ edgesByDim = [numpy.arange(start[i], stop[i]) for i in range(dim)]
+
+ # Line equation: P = t * delta + p0
+ delta = p1 - p0
+
+ # Get bin edge/line intersections as sorted points along the line
+ # Get corresponding line parameters
+ t = []
+ if numpy.all(0 <= p0) and numpy.all(p0 <= nbins):
+ t.append([0.]) # p0 within volume, add it
+ t += [(edgesByDim[i] - p0[i]) / delta[i] for i in range(dim) if delta[i] != 0]
+ if numpy.all(0 <= p1) and numpy.all(p1 <= nbins):
+ t.append([1.]) # p1 within volume, add it
+ t = numpy.concatenate(t)
+ t.sort(kind='mergesort')
+
+ # Remove duplicates
+ unique = numpy.ones((len(t),), dtype=bool)
+ numpy.not_equal(t[1:], t[:-1], out=unique[1:])
+ t = t[unique]
+
+ if len(t) < 2:
+ return None # Not enough intersection points
+
+ # bin edges/line intersection points
+ points = t.reshape(-1, 1) * delta + p0
+ centers = (points[:-1] + points[1:]) / 2.
+ bins = numpy.floor(centers).astype(numpy.int)
+ return bins
+
+
+def segmentTrianglesIntersection(segment, triangles):
+ """Check for segment/triangles intersection.
+
+ This is based on signed tetrahedron volume comparison.
+
+ See A. Kensler, A., Shirley, P.
+ Optimizing Ray-Triangle Intersection via Automated Search.
+ Symposium on Interactive Ray Tracing, vol. 0, p33-38 (2006)
+
+ :param numpy.ndarray segment:
+ Segment end points as a 2x3 array of coordinates
+ :param numpy.ndarray triangles:
+ Nx3x3 array of triangles
+ :return: (triangle indices, segment parameter, barycentric coord)
+ Indices of intersected triangles, "depth" along the segment
+ of the intersection point and barycentric coordinates of intersection
+ point in the triangle.
+ :rtype: List[numpy.ndarray]
+ """
+ # TODO triangles from vertices + indices
+ # TODO early rejection? e.g., check segment bbox vs triangle bbox
+ segment = numpy.asarray(segment)
+ assert segment.ndim == 2
+ assert segment.shape == (2, 3)
+
+ triangles = numpy.asarray(triangles)
+ assert triangles.ndim == 3
+ assert triangles.shape[1] == 3
+
+ # Test line/triangles intersection
+ d = segment[1] - segment[0]
+ t0s0 = segment[0] - triangles[:, 0, :]
+ edge01 = triangles[:, 1, :] - triangles[:, 0, :]
+ edge02 = triangles[:, 2, :] - triangles[:, 0, :]
+
+ dCrossEdge02 = numpy.cross(d, edge02)
+ t0s0CrossEdge01 = numpy.cross(t0s0, edge01)
+ volume = numpy.sum(dCrossEdge02 * edge01, axis=1)
+ del edge01
+ subVolumes = numpy.empty((len(triangles), 3), dtype=triangles.dtype)
+ subVolumes[:, 1] = numpy.sum(dCrossEdge02 * t0s0, axis=1)
+ del dCrossEdge02
+ subVolumes[:, 2] = numpy.sum(t0s0CrossEdge01 * d, axis=1)
+ subVolumes[:, 0] = volume - subVolumes[:, 1] - subVolumes[:, 2]
+ intersect = numpy.logical_or(
+ numpy.all(subVolumes >= 0., axis=1), # All positive
+ numpy.all(subVolumes <= 0., axis=1)) # All negative
+ intersect = numpy.where(intersect)[0] # Indices of intersected triangles
+
+ # Get barycentric coordinates
+ barycentric = subVolumes[intersect] / volume[intersect].reshape(-1, 1)
+ del subVolumes
+
+ # Test segment/triangles intersection
+ volAlpha = numpy.sum(t0s0CrossEdge01[intersect] * edge02[intersect], axis=1)
+ t = volAlpha / volume[intersect] # segment parameter of intersected triangles
+ del t0s0CrossEdge01
+ del edge02
+ del volAlpha
+ del volume
+
+ inSegmentMask = numpy.logical_and(t >= 0., t <= 1.)
+ intersect = intersect[inSegmentMask]
+ t = t[inSegmentMask]
+ barycentric = barycentric[inSegmentMask]
+
+ # Sort intersecting triangles by t
+ indices = numpy.argsort(t)
+ return intersect[indices], t[indices], barycentric[indices]
+
+
# Plane #######################################################################
class Plane(event.Notifier):
diff --git a/silx/gui/plot3d/setup.py b/silx/gui/plot3d/setup.py
index c477919..59c0230 100644
--- a/silx/gui/plot3d/setup.py
+++ b/silx/gui/plot3d/setup.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 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
@@ -36,7 +36,9 @@ def configuration(parent_package='', top_path=None):
config.add_subpackage('actions')
config.add_subpackage('items')
config.add_subpackage('scene')
+ config.add_subpackage('scene.test')
config.add_subpackage('tools')
+ config.add_subpackage('tools.test')
config.add_subpackage('test')
config.add_subpackage('utils')
return config
diff --git a/silx/gui/plot3d/test/__init__.py b/silx/gui/plot3d/test/__init__.py
index bd2f7c3..c58f307 100644
--- a/silx/gui/plot3d/test/__init__.py
+++ b/silx/gui/plot3d/test/__init__.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2015-2018 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
@@ -30,7 +30,6 @@ __date__ = "09/11/2017"
import logging
-import os
import unittest
from silx.test.utils import test_options
@@ -39,7 +38,7 @@ _logger = logging.getLogger(__name__)
def suite():
- test_suite = unittest.TestSuite()
+ testsuite = unittest.TestSuite()
if not test_options.WITH_GL_TEST:
# Explicitly disabled tests
@@ -50,17 +49,21 @@ def suite():
def runTest(self):
self.skipTest(test_options.WITH_GL_TEST_REASON)
- test_suite.addTest(SkipPlot3DTest())
- return test_suite
+ testsuite.addTest(SkipPlot3DTest())
+ return testsuite
# Import here to avoid loading modules if tests are disabled
- from ..scene import test as test_scene
+ from ..scene.test import suite as sceneTestSuite
+ from ..tools.test import suite as toolsTestSuite
from .testGL import suite as testGLSuite
from .testScalarFieldView import suite as testScalarFieldViewSuite
+ from .testSceneWidgetPicking import suite as testSceneWidgetPickingSuite
- test_suite = unittest.TestSuite()
- test_suite.addTest(testGLSuite())
- test_suite.addTest(test_scene.suite())
- test_suite.addTest(testScalarFieldViewSuite())
- return test_suite
+ testsuite = unittest.TestSuite()
+ testsuite.addTest(testGLSuite())
+ testsuite.addTest(sceneTestSuite())
+ testsuite.addTest(testScalarFieldViewSuite())
+ testsuite.addTest(testSceneWidgetPickingSuite())
+ testsuite.addTest(toolsTestSuite())
+ return testsuite
diff --git a/silx/gui/plot3d/test/testGL.py b/silx/gui/plot3d/test/testGL.py
index 70f197f..ae167ab 100644
--- a/silx/gui/plot3d/test/testGL.py
+++ b/silx/gui/plot3d/test/testGL.py
@@ -32,7 +32,7 @@ import logging
import unittest
from silx.gui._glutils import gl, OpenGLWidget
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui import qt
diff --git a/silx/gui/plot3d/test/testScalarFieldView.py b/silx/gui/plot3d/test/testScalarFieldView.py
index 43d401f..d9c743b 100644
--- a/silx/gui/plot3d/test/testScalarFieldView.py
+++ b/silx/gui/plot3d/test/testScalarFieldView.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2017 European Synchrotron Radiation Facility
+# Copyright (c) 2017-2018 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
@@ -34,7 +34,7 @@ import unittest
import numpy
from silx.utils.testutils import ParametricTestCase
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui import qt
from silx.gui.plot3d.ScalarFieldView import ScalarFieldView
@@ -52,6 +52,13 @@ class TestScalarFieldView(TestCaseQt, ParametricTestCase):
self.widget = ScalarFieldView()
self.widget.show()
+ paramTreeWidget = TreeView()
+ paramTreeWidget.setSfView(self.widget)
+
+ dock = qt.QDockWidget()
+ dock.setWidget(paramTreeWidget)
+ self.widget.addDockWidget(qt.Qt.BottomDockWidgetArea, dock)
+
# Commented as it slows down the tests
# self.qWaitForWindowExposed(self.widget)
@@ -102,6 +109,24 @@ class TestScalarFieldView(TestCaseQt, ParametricTestCase):
self.widget.setData(data, copy=True)
self.qapp.processEvents()
+ def testIsoSliderNormalization(self):
+ """Test set TreeView with a different isoslider normalization"""
+ data = self._buildData(size=32)
+
+ self.widget.setData(data)
+ self.widget.addIsosurface(0.5, (1., 0., 0., 0.5))
+ self.widget.addIsosurface(0.7, qt.QColor('green'))
+ self.qapp.processEvents()
+
+ # Add a second TreeView
+ paramTreeWidget = TreeView(self.widget)
+ paramTreeWidget.setIsoLevelSliderNormalization('arcsinh')
+ paramTreeWidget.setSfView(self.widget)
+
+ dock = qt.QDockWidget()
+ dock.setWidget(paramTreeWidget)
+ self.widget.addDockWidget(qt.Qt.BottomDockWidgetArea, dock)
+
def suite():
test_suite = unittest.TestSuite()
diff --git a/silx/gui/plot3d/test/testSceneWidgetPicking.py b/silx/gui/plot3d/test/testSceneWidgetPicking.py
new file mode 100644
index 0000000..d0c6467
--- /dev/null
+++ b/silx/gui/plot3d/test/testSceneWidgetPicking.py
@@ -0,0 +1,267 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018 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.
+# ###########################################################################*/
+"""Test SceneWidget picking feature"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "03/10/2018"
+
+
+import unittest
+
+import numpy
+
+from silx.utils.testutils import ParametricTestCase
+from silx.gui.utils.testutils import TestCaseQt
+from silx.gui import qt
+
+from silx.gui.plot3d.SceneWidget import SceneWidget, items
+
+
+class TestSceneWidgetPicking(TestCaseQt, ParametricTestCase):
+ """Tests SceneWidget picking feature"""
+
+ def setUp(self):
+ super(TestSceneWidgetPicking, self).setUp()
+ self.widget = SceneWidget()
+ self.widget.resize(300, 300)
+ self.widget.show()
+ # self.qWaitForWindowExposed(self.widget)
+
+ def tearDown(self):
+ self.qapp.processEvents()
+ self.widget.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.widget.close()
+ del self.widget
+ super(TestSceneWidgetPicking, self).tearDown()
+
+ def _widgetCenter(self):
+ """Returns widget center"""
+ size = self.widget.size()
+ return size.width() // 2, size.height() // 2
+
+ def testPickImage(self):
+ """Test picking of ImageData and ImageRgba items"""
+ imageData = items.ImageData()
+ imageData.setData(numpy.arange(100).reshape(10, 10))
+
+ imageRgba = items.ImageRgba()
+ imageRgba.setData(
+ numpy.arange(300, dtype=numpy.uint8).reshape(10, 10, 3))
+
+ for item in (imageData, imageRgba):
+ with self.subTest(item=item.__class__.__name__):
+ # Add item
+ self.widget.clearItems()
+ self.widget.addItem(item)
+ self.widget.resetZoom('front')
+ self.qapp.processEvents()
+
+ # Picking on data (at widget center)
+ picking = list(self.widget.pickItems(*self._widgetCenter()))
+
+ self.assertEqual(len(picking), 1)
+ self.assertIs(picking[0].getItem(), item)
+ self.assertEqual(picking[0].getPositions('ndc').shape, (1, 3))
+ data = picking[0].getData()
+ self.assertEqual(len(data), 1)
+ self.assertTrue(numpy.array_equal(
+ data,
+ item.getData()[picking[0].getIndices()]))
+
+ # Picking outside data
+ picking = list(self.widget.pickItems(1, 1))
+ self.assertEqual(len(picking), 0)
+
+ def testPickScatter(self):
+ """Test picking of Scatter2D and Scatter3D items"""
+ data = numpy.arange(100)
+
+ scatter2d = items.Scatter2D()
+ scatter2d.setData(x=data, y=data, value=data)
+
+ scatter3d = items.Scatter3D()
+ scatter3d.setData(x=data, y=data, z=data, value=data)
+
+ for item in (scatter2d, scatter3d):
+ with self.subTest(item=item.__class__.__name__):
+ # Add item
+ self.widget.clearItems()
+ self.widget.addItem(item)
+ self.widget.resetZoom('front')
+ self.qapp.processEvents()
+
+ # Picking on data (at widget center)
+ picking = list(self.widget.pickItems(*self._widgetCenter()))
+
+ self.assertEqual(len(picking), 1)
+ self.assertIs(picking[0].getItem(), item)
+ nbPos = len(picking[0].getPositions('ndc'))
+ data = picking[0].getData()
+ self.assertEqual(nbPos, len(data))
+ self.assertTrue(numpy.array_equal(
+ data,
+ item.getValues()[picking[0].getIndices()]))
+
+ # Picking outside data
+ picking = list(self.widget.pickItems(1, 1))
+ self.assertEqual(len(picking), 0)
+
+ def testPickScalarField3D(self):
+ """Test picking of volume CutPlane and Isosurface items"""
+ volume = self.widget.add3DScalarField(
+ numpy.arange(10**3, dtype=numpy.float32).reshape(10, 10, 10))
+ self.widget.resetZoom('front')
+
+ cutplane = volume.getCutPlanes()[0]
+ cutplane.getColormap().setVRange(0, 100)
+ cutplane.setNormal((0, 0, 1))
+
+ # Picking on data without anything displayed
+ cutplane.setVisible(False)
+ picking = list(self.widget.pickItems(*self._widgetCenter()))
+ self.assertEqual(len(picking), 0)
+
+ # Picking on data with the cut plane
+ cutplane.setVisible(True)
+ picking = list(self.widget.pickItems(*self._widgetCenter()))
+
+ self.assertEqual(len(picking), 1)
+ self.assertIs(picking[0].getItem(), cutplane)
+ data = picking[0].getData()
+ self.assertEqual(len(data), 1)
+ self.assertEqual(picking[0].getPositions().shape, (1, 3))
+ self.assertTrue(numpy.array_equal(
+ data,
+ volume.getData(copy=False)[picking[0].getIndices()]))
+
+ # Picking on data with an isosurface
+ isosurface = volume.addIsosurface(level=500, color=(1., 0., 0., .5))
+ picking = list(self.widget.pickItems(*self._widgetCenter()))
+ self.assertEqual(len(picking), 2)
+ self.assertIs(picking[0].getItem(), cutplane)
+ self.assertIs(picking[1].getItem(), isosurface)
+ self.assertEqual(picking[1].getPositions().shape, (1, 3))
+ data = picking[1].getData()
+ self.assertEqual(len(data), 1)
+ self.assertTrue(numpy.array_equal(
+ data,
+ volume.getData(copy=False)[picking[1].getIndices()]))
+
+ # Picking outside data
+ picking = list(self.widget.pickItems(1, 1))
+ self.assertEqual(len(picking), 0)
+
+ def testPickMesh(self):
+ """Test picking of Mesh items"""
+
+ triangles = items.Mesh()
+ triangles.setData(
+ position=((0, 0, 0), (1, 0, 0), (1, 1, 0),
+ (0, 0, 0), (1, 1, 0), (0, 1, 0)),
+ color=(1, 0, 0, 1),
+ mode='triangles')
+ triangleStrip = items.Mesh()
+ triangleStrip.setData(
+ position=(((1, 0, 0), (0, 0, 0), (1, 1, 0), (0, 1, 0))),
+ color=(0, 1, 0, 1),
+ mode='triangle_strip')
+ triangleFan = items.Mesh()
+ triangleFan.setData(
+ position=((0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)),
+ color=(0, 0, 1, 1),
+ mode='fan')
+
+ for item in (triangles, triangleStrip, triangleFan):
+ with self.subTest(mode=item.getDrawMode()):
+ # Add item
+ self.widget.clearItems()
+ self.widget.addItem(item)
+ self.widget.resetZoom('front')
+ self.qapp.processEvents()
+
+ # Picking on data (at widget center)
+ picking = list(self.widget.pickItems(*self._widgetCenter()))
+
+ self.assertEqual(len(picking), 1)
+ self.assertIs(picking[0].getItem(), item)
+ nbPos = len(picking[0].getPositions())
+ data = picking[0].getData()
+ self.assertEqual(nbPos, len(data))
+ self.assertTrue(numpy.array_equal(
+ data,
+ item.getPositionData()[picking[0].getIndices()]))
+
+ # Picking outside data
+ picking = list(self.widget.pickItems(1, 1))
+ self.assertEqual(len(picking), 0)
+
+ def testPickCylindricalMesh(self):
+ """Test picking of Box, Cylinder and Hexagon items"""
+
+ positions = numpy.array(((0., 0., 0.), (1., 1., 0.), (2., 2., 0.)))
+ box = items.Box()
+ box.setData(position=positions)
+ cylinder = items.Cylinder()
+ cylinder.setData(position=positions)
+ hexagon = items.Hexagon()
+ hexagon.setData(position=positions)
+
+ for item in (box, cylinder, hexagon):
+ with self.subTest(item=item.__class__.__name__):
+ # Add item
+ self.widget.clearItems()
+ self.widget.addItem(item)
+ self.widget.resetZoom('front')
+ self.qapp.processEvents()
+
+ # Picking on data (at widget center)
+ picking = list(self.widget.pickItems(*self._widgetCenter()))
+
+ self.assertEqual(len(picking), 1)
+ self.assertIs(picking[0].getItem(), item)
+ nbPos = len(picking[0].getPositions())
+ data = picking[0].getData()
+ print(item.__class__.__name__, [positions[1]], data)
+ self.assertTrue(numpy.all(numpy.equal(positions[1], data)))
+ self.assertEqual(nbPos, len(data))
+ self.assertTrue(numpy.array_equal(
+ data,
+ item.getPosition()[picking[0].getIndices()]))
+
+ # Picking outside data
+ picking = list(self.widget.pickItems(1, 1))
+ self.assertEqual(len(picking), 0)
+
+
+def suite():
+ testsuite = unittest.TestSuite()
+ testsuite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(
+ TestSceneWidgetPicking))
+ return testsuite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/plot3d/tools/PositionInfoWidget.py b/silx/gui/plot3d/tools/PositionInfoWidget.py
new file mode 100644
index 0000000..b4d2c05
--- /dev/null
+++ b/silx/gui/plot3d/tools/PositionInfoWidget.py
@@ -0,0 +1,209 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018 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.
+#
+# ###########################################################################*/
+"""This module provides a widget that displays data values of a SceneWidget.
+"""
+
+from __future__ import absolute_import
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "01/10/2018"
+
+
+import logging
+import weakref
+
+from ... import qt
+from .. import items
+from ..items import volume
+from ..SceneWidget import SceneWidget
+
+
+_logger = logging.getLogger(__name__)
+
+
+class PositionInfoWidget(qt.QWidget):
+ """Widget displaying information about picked position
+
+ :param QWidget parent: See :class:`QWidget`
+ """
+
+ def __init__(self, parent=None):
+ super(PositionInfoWidget, self).__init__(parent)
+ self._sceneWidgetRef = None
+
+ self.setToolTip("Double-click on a data point to show its value")
+ layout = qt.QBoxLayout(qt.QBoxLayout.LeftToRight, self)
+
+ self._xLabel = self._addInfoField('X')
+ self._yLabel = self._addInfoField('Y')
+ self._zLabel = self._addInfoField('Z')
+ self._dataLabel = self._addInfoField('Data')
+ self._itemLabel = self._addInfoField('Item')
+
+ layout.addStretch(1)
+
+ def _addInfoField(self, label):
+ """Add a description: info widget to this widget
+
+ :param str label: Description label
+ :return: The QLabel used to display the info
+ :rtype: QLabel
+ """
+ subLayout = qt.QHBoxLayout()
+ subLayout.setContentsMargins(0, 0, 0, 0)
+
+ subLayout.addWidget(qt.QLabel(label + ':'))
+
+ widget = qt.QLabel('-')
+ widget.setAlignment(qt.Qt.AlignLeft | qt.Qt.AlignVCenter)
+ widget.setTextInteractionFlags(qt.Qt.TextSelectableByMouse)
+ widget.setMinimumWidth(widget.fontMetrics().width('#######'))
+ subLayout.addWidget(widget)
+
+ subLayout.addStretch(1)
+
+ layout = self.layout()
+ layout.addLayout(subLayout)
+ return widget
+
+ def getSceneWidget(self):
+ """Returns the associated :class:`SceneWidget` or None.
+
+ :rtype: Union[None,~silx.gui.plot3d.SceneWidget.SceneWidget]
+ """
+ if self._sceneWidgetRef is None:
+ return None
+ else:
+ return self._sceneWidgetRef()
+
+ def setSceneWidget(self, widget):
+ """Set the associated :class:`SceneWidget`
+
+ :param ~silx.gui.plot3d.SceneWidget.SceneWidget widget:
+ 3D scene for which to display information
+ """
+ if widget is not None and not isinstance(widget, SceneWidget):
+ raise ValueError("widget must be a SceneWidget or None")
+
+ previous = self.getSceneWidget()
+ if previous is not None:
+ previous.removeEventFilter(self)
+
+ if widget is None:
+ self._sceneWidgetRef = None
+ else:
+ widget.installEventFilter(self)
+ self._sceneWidgetRef = weakref.ref(widget)
+
+ def eventFilter(self, watched, event):
+ # Filter events of SceneWidget to react on mouse events.
+ if (event.type() == qt.QEvent.MouseButtonDblClick and
+ event.button() == qt.Qt.LeftButton):
+ self.pick(event.x(), event.y())
+
+ return super(PositionInfoWidget, self).eventFilter(watched, event)
+
+ def clear(self):
+ """Clean-up displayed values"""
+ for widget in (self._xLabel, self._yLabel, self._zLabel,
+ self._dataLabel, self._itemLabel):
+ widget.setText('-')
+
+ _SUPPORTED_ITEMS = (items.Scatter3D,
+ items.Scatter2D,
+ items.ImageData,
+ items.ImageRgba,
+ items.Mesh,
+ items.Box,
+ items.Cylinder,
+ items.Hexagon,
+ volume.CutPlane,
+ volume.Isosurface)
+ """Type of items that are picked"""
+
+ def _isSupportedItem(self, item):
+ """Returns True if item is of supported type
+
+ :param Item3D item: The Item3D to check
+ :rtype: bool
+ """
+ return isinstance(item, self._SUPPORTED_ITEMS)
+
+ def pick(self, x, y):
+ """Pick items in the associated SceneWidget and display result
+
+ Only the closest point is displayed.
+
+ :param int x: X coordinate in pixel in the SceneWidget
+ :param int y: Y coordinate in pixel in the SceneWidget
+ """
+ self.clear()
+
+ sceneWidget = self.getSceneWidget()
+ if sceneWidget is None: # No associated widget
+ _logger.info('Picking without associated SceneWidget')
+ return
+
+ # Find closest (and latest in the tree) supported item
+ closestNdcZ = float('inf')
+ picking = None
+ for result in sceneWidget.pickItems(x, y,
+ condition=self._isSupportedItem):
+ ndcZ = result.getPositions('ndc', copy=False)[0, 2]
+ if ndcZ <= closestNdcZ:
+ closestNdcZ = ndcZ
+ picking = result
+
+ if picking is None:
+ return # No picked item
+
+ item = picking.getItem()
+ self._itemLabel.setText(item.getLabel())
+ positions = picking.getPositions('scene', copy=False)
+ x, y, z = positions[0]
+ self._xLabel.setText("%g" % x)
+ self._yLabel.setText("%g" % y)
+ self._zLabel.setText("%g" % z)
+
+ data = picking.getData(copy=False)
+ if data is not None:
+ data = data[0]
+ if hasattr(data, '__len__'):
+ text = ' '.join(["%.3g"] * len(data)) % tuple(data)
+ else:
+ text = "%g" % data
+ self._dataLabel.setText(text)
+
+ def updateInfo(self):
+ """Update information according to cursor position"""
+ widget = self.getSceneWidget()
+ if widget is None:
+ _logger.info('Update without associated SceneWidget')
+ self.clear()
+ return
+
+ position = widget.mapFromGlobal(qt.QCursor.pos())
+ self.pick(position.x(), position.y())
diff --git a/silx/gui/plot3d/scene/setup.py b/silx/gui/plot3d/tools/test/__init__.py
index ff4c0a6..2dbc0ab 100644
--- a/silx/gui/plot3d/scene/setup.py
+++ b/silx/gui/plot3d/tools/test/__init__.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2015-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2018 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
@@ -22,20 +22,20 @@
# THE SOFTWARE.
#
# ###########################################################################*/
-__authors__ = ["T. Vincent"]
-__license__ = "MIT"
-__date__ = "25/07/2016"
+"""plot3d tools test suite."""
-from numpy.distutils.misc_util import Configuration
+from __future__ import absolute_import
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "03/10/2018"
-def configuration(parent_package='', top_path=None):
- config = Configuration('scene', parent_package, top_path)
- config.add_subpackage('test')
- return config
+import unittest
+from .testPositionInfoWidget import suite as testPositionInfoWidgetSuite
-if __name__ == "__main__":
- from numpy.distutils.core import setup
- setup(configuration=configuration)
+def suite():
+ testsuite = unittest.TestSuite()
+ testsuite.addTest(testPositionInfoWidgetSuite())
+ return testsuite
diff --git a/silx/gui/plot3d/tools/test/testPositionInfoWidget.py b/silx/gui/plot3d/tools/test/testPositionInfoWidget.py
new file mode 100644
index 0000000..4520a2a
--- /dev/null
+++ b/silx/gui/plot3d/tools/test/testPositionInfoWidget.py
@@ -0,0 +1,101 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018 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.
+# ###########################################################################*/
+"""Test PositionInfoWidget"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "03/10/2018"
+
+
+import unittest
+
+import numpy
+
+from silx.gui.utils.testutils import TestCaseQt
+from silx.gui import qt
+
+from silx.gui.plot3d.SceneWidget import SceneWidget
+from silx.gui.plot3d.tools.PositionInfoWidget import PositionInfoWidget
+
+
+class TestPositionInfoWidget(TestCaseQt):
+ """Tests PositionInfoWidget"""
+
+ def setUp(self):
+ super(TestPositionInfoWidget, self).setUp()
+ self.sceneWidget = SceneWidget()
+ self.sceneWidget.resize(300, 300)
+ self.sceneWidget.show()
+
+ self.positionInfoWidget = PositionInfoWidget()
+ self.positionInfoWidget.setSceneWidget(self.sceneWidget)
+ self.positionInfoWidget.show()
+ self.qWaitForWindowExposed(self.positionInfoWidget)
+
+ # self.qWaitForWindowExposed(self.widget)
+
+ def tearDown(self):
+ self.qapp.processEvents()
+
+ self.sceneWidget.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.sceneWidget.close()
+ del self.sceneWidget
+
+ self.positionInfoWidget.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.positionInfoWidget.close()
+ del self.positionInfoWidget
+ super(TestPositionInfoWidget, self).tearDown()
+
+ def test(self):
+ """Test PositionInfoWidget"""
+ self.assertIs(self.positionInfoWidget.getSceneWidget(),
+ self.sceneWidget)
+
+ data = numpy.arange(100)
+ self.sceneWidget.add2DScatter(x=data, y=data, value=data)
+ self.sceneWidget.resetZoom('front')
+
+ # Double click at the center
+ self.mouseDClick(self.sceneWidget, button=qt.Qt.LeftButton)
+
+ # Clear displayed value
+ self.positionInfoWidget.clear()
+
+ # Update info from API
+ self.positionInfoWidget.pick(x=10, y=10)
+
+ # Remove SceneWidget
+ self.positionInfoWidget.setSceneWidget(None)
+
+
+def suite():
+ testsuite = unittest.TestSuite()
+ testsuite.addTest(
+ unittest.defaultTestLoader.loadTestsFromTestCase(
+ TestPositionInfoWidget))
+ return testsuite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/qt/__init__.py b/silx/gui/qt/__init__.py
index f7bc916..b8c6cdd 100644
--- a/silx/gui/qt/__init__.py
+++ b/silx/gui/qt/__init__.py
@@ -25,12 +25,11 @@
"""Common wrapper over Python Qt bindings:
- `PyQt5 <http://pyqt.sourceforge.net/Docs/PyQt5/>`_
+- `PySide2 <https://wiki.qt.io/Qt_for_Python>`_
- `PyQt4 <http://pyqt.sourceforge.net/Docs/PyQt4/>`_
-- `PySide <http://www.pyside.org>`_
-- `PySide2 <https://wiki.qt.io/PySide2>`_
If a Qt binding is already loaded, it will use it, otherwise the different
-Qt bindings are tried in this order: PyQt5, PyQt4, PySide, PySide2.
+Qt bindings are tried in this order: PyQt5, PyQt4, PySide2.
The name of the loaded Qt binding is stored in the BINDING variable.
@@ -48,7 +47,7 @@ Example of using :mod:`silx.gui.qt` module:
For an alternative solution providing a structured namespace,
see `qtpy <https://pypi.python.org/pypi/QtPy/>`_ which
-provides the namespace of PyQt5 over PyQt4 and PySide.
+provides the namespace of PyQt5 over PyQt4, PySide and PySide2.
"""
from ._qt import * # noqa
diff --git a/silx/gui/qt/_pyside_dynamic.py b/silx/gui/qt/_pyside_dynamic.py
index a9246b9..13d1a9d 100644
--- a/silx/gui/qt/_pyside_dynamic.py
+++ b/silx/gui/qt/_pyside_dynamic.py
@@ -39,11 +39,15 @@ from __future__ import (print_function, division, unicode_literals,
absolute_import)
import logging
+import sys
-from PySide.QtCore import QMetaObject
-from PySide.QtUiTools import QUiLoader
-from PySide.QtGui import QMainWindow
-
+if "PySide.QtCore" in sys.modules:
+ from PySide.QtCore import QMetaObject
+ from PySide.QtUiTools import QUiLoader
+else: # PySide2
+ from PySide2.QtCore import QMetaObject, Property, Qt
+ from PySide2.QtWidgets import QFrame
+ from PySide2.QtUiTools import QUiLoader
_logger = logging.getLogger(__name__)
@@ -57,7 +61,7 @@ class UiLoader(QUiLoader):
create a new instance of the top-level widget, but creates the user
interface in an existing instance of the top-level class.
- This mimics the behaviour of :func:`PyQt4.uic.loadUi`.
+ This mimics the behaviour of :func:`PyQt*.uic.loadUi`.
"""
def __init__(self, baseinstance, customWidgets=None):
@@ -113,7 +117,7 @@ class UiLoader(QUiLoader):
if self.baseinstance:
# set an attribute for the new child widget on the base
- # instance, just like PyQt4.uic.loadUi does.
+ # instance, just like PyQt*.uic.loadUi does.
setattr(self.baseinstance, name, widget)
# this outputs the various widget names, e.g.
@@ -123,6 +127,43 @@ class UiLoader(QUiLoader):
return widget
+if "PySide2.QtCore" in sys.modules:
+
+ class _Line(QFrame):
+ """Widget to use as 'Line' Qt designer"""
+ def __init__(self, parent=None):
+ super(_Line, self).__init__(parent)
+ self.setFrameShape(QFrame.HLine)
+ self.setFrameShadow(QFrame.Sunken)
+
+ def getOrientation(self):
+ shape = self.frameShape()
+ if shape == QFrame.HLine:
+ return Qt.Horizontal
+ elif shape == QFrame.VLine:
+ return Qt.Vertical
+ else:
+ raise RuntimeError("Wrong shape: %d", shape)
+
+ def setOrientation(self, orientation):
+ if orientation == Qt.Horizontal:
+ self.setFrameShape(QFrame.HLine)
+ elif orientation == Qt.Vertical:
+ self.setFrameShape(QFrame.VLine)
+ else:
+ raise ValueError("Unsupported orientation %s" % str(orientation))
+
+ orientation = Property("Qt::Orientation", getOrientation, setOrientation)
+
+
+ CUSTOM_WIDGETS = {"Line": _Line}
+ """Default custom widgets for `loadUi`"""
+
+else: # PySide support
+ CUSTOM_WIDGETS = {}
+ """Default custom widgets for `loadUi`"""
+
+
def loadUi(uifile, baseinstance=None, package=None, resource_suffix=None):
"""
Dynamically load a user interface from the given ``uifile``.
@@ -152,7 +193,7 @@ def loadUi(uifile, baseinstance=None, package=None, resource_suffix=None):
_logger.warning(
"loadUi resource_suffix parameter not implemented with PySide")
- loader = UiLoader(baseinstance)
+ loader = UiLoader(baseinstance, customWidgets=CUSTOM_WIDGETS)
widget = loader.load(uifile)
QMetaObject.connectSlotsByName(widget)
return widget
diff --git a/silx/gui/qt/_qt.py b/silx/gui/qt/_qt.py
index 6bf7d93..a4b9007 100644
--- a/silx/gui/qt/_qt.py
+++ b/silx/gui/qt/_qt.py
@@ -22,21 +22,7 @@
# THE SOFTWARE.
#
# ###########################################################################*/
-"""Common wrapper over Python Qt bindings:
-
-- `PyQt5 <http://pyqt.sourceforge.net/Docs/PyQt5/>`_,
-- `PyQt4 <http://pyqt.sourceforge.net/Docs/PyQt4/>`_ or
-- `PySide <http://www.pyside.org>`_.
-
-If a Qt binding is already loaded, it will use it, otherwise the different
-Qt bindings are tried in this order: PySide2, PyQt4, PySide, PyQt5.
-
-The name of the loaded Qt binding is stored in the BINDING variable.
-
-For an alternative solution providing a structured namespace,
-see `qtpy <https://pypi.python.org/pypi/QtPy/>`_ which
-provides the namespace of PyQt5 over PyQt4, PySide and PySide2.
-"""
+"""Load Qt binding"""
__authors__ = ["V.A. Sole"]
__license__ = "MIT"
@@ -47,15 +33,17 @@ import logging
import sys
import traceback
+from ...utils.deprecation import deprecated_warning
+
_logger = logging.getLogger(__name__)
BINDING = None
-"""The name of the Qt binding in use: PyQt5, 'PyQt4, PySide2 or PySide."""
+"""The name of the Qt binding in use: PyQt5, PyQt4 or PySide2."""
QtBinding = None # noqa
-"""The Qt binding module in use: PyQt5, PyQt4, PySide2 or PySide."""
+"""The Qt binding module in use: PyQt5, PyQt4 or PySide2."""
HAS_SVG = False
"""True if Qt provides support for Scalable Vector Graphics (QtSVG)."""
@@ -84,17 +72,17 @@ else: # Then try Qt bindings
import PyQt4 # noqa
except ImportError:
try:
- import PySide # noqa
+ import PySide2 # noqa
except ImportError:
try:
- import PySide2 # noqa
+ import PySide # noqa
except ImportError:
raise ImportError(
'No Qt wrapper found. Install PyQt5, PyQt4 or PySide2.')
else:
- BINDING = 'PySide2'
+ BINDING = 'PySide'
else:
- BINDING = 'PySide'
+ BINDING = 'PySide2'
else:
BINDING = 'PyQt4'
else:
@@ -103,6 +91,9 @@ else: # Then try Qt bindings
if BINDING == 'PyQt4':
_logger.debug('Using PyQt4 bindings')
+ deprecated_warning("Qt Binding", "PyQt4",
+ replacement='PyQt5',
+ since_version='0.9.0')
if sys.version_info < (3, ):
try:
@@ -110,6 +101,11 @@ if BINDING == 'PyQt4':
sip.setapi("QString", 2)
sip.setapi("QVariant", 2)
+ sip.setapi('QDate', 2)
+ sip.setapi('QDateTime', 2)
+ sip.setapi('QTextStream', 2)
+ sip.setapi('QTime', 2)
+ sip.setapi('QUrl', 2)
except:
_logger.warning("Cannot set sip API")
@@ -144,6 +140,9 @@ if BINDING == 'PyQt4':
elif BINDING == 'PySide':
_logger.debug('Using PySide bindings')
+ deprecated_warning("Qt Binding", "PySide",
+ replacement='PySide2',
+ since_version='0.9.0')
import PySide as QtBinding # noqa
@@ -238,7 +237,7 @@ elif BINDING == 'PySide2':
HAS_SVG = True
# Import loadUi wrapper for PySide2
- # TODO from ._pyside_dynamic import loadUi # noqa
+ from ._pyside_dynamic import loadUi # noqa
pyqtSignal = Signal
diff --git a/silx/gui/qt/_utils.py b/silx/gui/qt/_utils.py
index be55465..912f08c 100644
--- a/silx/gui/qt/_utils.py
+++ b/silx/gui/qt/_utils.py
@@ -36,8 +36,11 @@ from . import _qt as qt
def supportedImageFormats():
"""Return a set of string of file format extensions supported by the
Qt runtime."""
- if sys.version_info[0] < 3 or qt.BINDING in ('PySide', 'PySide2'):
+ if sys.version_info[0] < 3 or qt.BINDING == 'PySide':
convert = str
+ elif qt.BINDING == 'PySide2':
+ def convert(data):
+ return str(data.data(), 'ascii')
else:
convert = lambda data: str(data, 'ascii')
formats = qt.QImageReader.supportedImageFormats()
diff --git a/silx/gui/qt/inspect.py b/silx/gui/qt/inspect.py
new file mode 100644
index 0000000..c6c2cbe
--- /dev/null
+++ b/silx/gui/qt/inspect.py
@@ -0,0 +1,82 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018 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.
+#
+# ###########################################################################*/
+"""This module provides functions to access Qt C++ object state:
+
+- :func:`isValid` to check whether a QObject C++ pointer is valid.
+- :func:`createdByPython` to check if a QObject was created from Python.
+- :func:`ownedByPython` to check if a QObject is currently owned by Python.
+"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "08/10/2018"
+
+
+from . import _qt as qt
+
+
+if qt.BINDING in ('PyQt4', 'PyQt5'):
+ if qt.BINDING == 'PyQt5':
+ try:
+ from PyQt5.sip import isdeleted as _isdeleted # noqa
+ from PyQt5.sip import ispycreated as createdByPython # noqa
+ from PyQt5.sip import ispyowned as ownedByPython # noqa
+ except ImportError:
+ from sip import isdeleted as _isdeleted # noqa
+ from sip import ispycreated as createdByPython # noqa
+ from sip import ispyowned as ownedByPython # noqa
+
+ else: # PyQt4
+ from sip import isdeleted as _isdeleted # noqa
+ from sip import ispycreated as createdByPython # noqa
+ from sip import ispyowned as ownedByPython # noqa
+
+ def isValid(obj):
+ """Returns True if underlying C++ object is valid.
+
+ :param QObject obj:
+ :rtype: bool
+ """
+ return not _isdeleted(obj)
+
+elif qt.BINDING == 'PySide2':
+ from PySide2.shiboken2 import isValid # noqa
+ from PySide2.shiboken2 import createdByPython # noqa
+ from PySide2.shiboken2 import ownedByPython # noqa
+
+elif qt.BINDING == 'PySide':
+ try: # Available through PySide
+ from PySide.shiboken import isValid # noqa
+ from PySide.shiboken import createdByPython # noqa
+ from PySide.shiboken import ownedByPython # noqa
+ except ImportError: # Available through standalone shiboken package
+ from Shiboken.shiboken import isValid # noqa
+ from Shiboken.shiboken import createdByPython # noqa
+ from Shiboken.shiboken import ownedByPython # noqa
+
+else:
+ raise ImportError("Unsupported Qt binding %s" % qt.BINDING)
+
+__all__ = ['isValid', 'createdByPython', 'ownedByPython']
diff --git a/silx/gui/test/test_colors.py b/silx/gui/test/test_colors.py
index d7c205e..e980068 100644
--- a/silx/gui/test/test_colors.py
+++ b/silx/gui/test/test_colors.py
@@ -29,7 +29,7 @@ from __future__ import absolute_import
__authors__ = ["H.Payno"]
__license__ = "MIT"
-__date__ = "24/04/2018"
+__date__ = "05/10/2018"
import unittest
import numpy
@@ -184,6 +184,17 @@ class TestDictAPI(unittest.TestCase):
with self.assertRaises(ValueError):
Colormap._fromDict(clm_dict)
+ def testNumericalColors(self):
+ """Make sure the old API using colors=int was supported"""
+ clm_dict = {
+ 'name': 'temperature',
+ 'vmin': 1.0,
+ 'vmax': 2.0,
+ 'colors': 256,
+ 'autoscale': False
+ }
+ Colormap._fromDict(clm_dict)
+
class TestObjectAPI(ParametricTestCase):
"""Test the new Object API of the colormap"""
@@ -357,6 +368,11 @@ class TestObjectAPI(ParametricTestCase):
with self.assertRaises(NotEditableError):
colormap.restoreState(state)
+ def testBadColorsType(self):
+ """Make sure colors can't be something else than an array"""
+ with self.assertRaises(TypeError):
+ Colormap(name='temperature', colors=256)
+
class TestPreferredColormaps(unittest.TestCase):
"""Test get|setPreferredColormaps functions"""
diff --git a/silx/gui/test/test_console.py b/silx/gui/test/test_console.py
index 7c25372..7db5f12 100644
--- a/silx/gui/test/test_console.py
+++ b/silx/gui/test/test_console.py
@@ -33,7 +33,7 @@ __date__ = "05/12/2016"
import unittest
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui import qt
try:
diff --git a/silx/gui/test/test_icons.py b/silx/gui/test/test_icons.py
index d747761..1757f30 100644
--- a/silx/gui/test/test_icons.py
+++ b/silx/gui/test/test_icons.py
@@ -38,7 +38,7 @@ import os
import silx.resources
from silx.gui import qt
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui import icons
diff --git a/silx/gui/test/test_qt.py b/silx/gui/test/test_qt.py
index 3a89a33..0d10620 100644
--- a/silx/gui/test/test_qt.py
+++ b/silx/gui/test/test_qt.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 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
@@ -33,9 +33,13 @@ import os.path
import unittest
from silx.test.utils import temp_dir
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui import qt
+try:
+ from silx.gui.qt import inspect as qt_inspect
+except ImportError:
+ qt_inspect = None
class TestQtWrapper(unittest.TestCase):
@@ -92,6 +96,32 @@ class TestLoadUi(TestCaseQt):
<string>Button 2</string>
</property>
</widget>
+ <widget class="Line" name="line">
+ <property name="geometry">
+ <rect>
+ <x>10</x>
+ <y>90</y>
+ <width>118</width>
+ <height>3</height>
+ </rect>
+ </property>
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ </widget>
+ <widget class="Line" name="line_2">
+ <property name="geometry">
+ <rect>
+ <x>150</x>
+ <y>20</y>
+ <width>3</width>
+ <height>61</height>
+ </rect>
+ </property>
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ </widget>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
@@ -110,6 +140,7 @@ class TestLoadUi(TestCaseQt):
</ui>
"""
+ @unittest.skipIf(qt.BINDING == "PySide", "Not fully working with PySide")
def testLoadUi(self):
"""Create a QMainWindow from an ui file"""
with temp_dir() as tmp:
@@ -132,9 +163,35 @@ class TestLoadUi(TestCaseQt):
testMainWindow.close()
+class TestQtInspect(unittest.TestCase):
+ """Test functions of silx.gui.qt.inspect module"""
+
+ # shiboken module is not always available
+ @unittest.skipIf(qt.BINDING == 'PySide' and qt_inspect is None,
+ reason="shiboken module not available")
+ def test(self):
+ """Test functions of silx.gui.qt.inspect module"""
+ self.assertIsNotNone(qt_inspect)
+
+ parent = qt.QObject()
+
+ self.assertTrue(qt_inspect.isValid(parent))
+ self.assertTrue(qt_inspect.createdByPython(parent))
+ self.assertTrue(qt_inspect.ownedByPython(parent))
+
+ obj = qt.QObject(parent)
+
+ self.assertTrue(qt_inspect.isValid(obj))
+ self.assertTrue(qt_inspect.createdByPython(obj))
+ self.assertFalse(qt_inspect.ownedByPython(obj))
+
+ del parent
+ self.assertFalse(qt_inspect.isValid(obj))
+
+
def suite():
test_suite = unittest.TestSuite()
- for TestCaseCls in (TestQtWrapper, TestLoadUi):
+ for TestCaseCls in (TestQtWrapper, TestLoadUi, TestQtInspect):
test_suite.addTest(
unittest.defaultTestLoader.loadTestsFromTestCase(TestCaseCls))
return test_suite
diff --git a/silx/gui/test/utils.py b/silx/gui/test/utils.py
index 3eee474..db4c0ee 100644
--- a/silx/gui/test/utils.py
+++ b/silx/gui/test/utils.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 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
@@ -22,499 +22,22 @@
# THE SOFTWARE.
#
# ###########################################################################*/
-"""Helper class to write Qt widget unittests."""
+"""Color conversion function, color dictionary and colormap tools."""
-__authors__ = ["T. Vincent"]
-__license__ = "MIT"
-__date__ = "27/02/2018"
-
-
-import gc
-import logging
-import unittest
-import time
-import functools
-import sys
-import os
-
-_logger = logging.getLogger(__name__)
-
-from silx.gui import qt
-
-if qt.BINDING == 'PySide':
- from PySide.QtTest import QTest
-elif qt.BINDING == 'PySide2':
- from PySide2.QtTest import QTest
-elif qt.BINDING == 'PyQt5':
- from PyQt5.QtTest import QTest
-elif qt.BINDING == 'PyQt4':
- from PyQt4.QtTest import QTest
-else:
- raise ImportError('Unsupported Qt bindings')
-
-# Qt4/Qt5 compatibility wrapper
-if qt.BINDING in ('PySide', 'PyQt4'):
- _logger.info("QTest.qWaitForWindowExposed not available," +
- "using QTest.qWaitForWindowShown instead.")
-
- def qWaitForWindowExposed(window, timeout=None):
- """Mimic QTest.qWaitForWindowExposed for Qt4."""
- QTest.qWaitForWindowShown(window)
- return True
-else:
- qWaitForWindowExposed = QTest.qWaitForWindowExposed
-
-
-def qWaitForWindowExposedAndActivate(window, timeout=None):
- """Waits until the window is shown in the screen.
-
- It also activates the window and raises it.
-
- See QTest.qWaitForWindowExposed for details.
- """
- if timeout is None:
- result = qWaitForWindowExposed(window)
- else:
- result = qWaitForWindowExposed(window, timeout)
-
- if result:
- # Makes sure window is active and on top
- window.activateWindow()
- window.raise_()
-
- return result
-
-
-# Placeholder for QApplication
-_qapp = None
-
-
-class TestCaseQt(unittest.TestCase):
- """Base class to write test for Qt stuff.
-
- It creates a QApplication before running the tests.
- WARNING: The QApplication is shared by all tests, which might have side
- effects.
-
- After each test, this class is checking for widgets remaining alive.
- To allow some widgets to remain alive at the end of a test, set the
- allowedLeakingWidgets attribute to the number of widgets that can remain
- alive at the end of the test.
- With PySide, this test is not run for now as it seems PySide
- is leaking widgets internally.
-
- All keyboard and mouse event simulation methods call qWait(20) after
- simulating the event (as QTest does on Mac OSX).
- This was introduced to fix issues with continuous integration tests
- running with Xvfb on Linux.
- """
-
- DEFAULT_TIMEOUT_WAIT = 100
- """Default timeout for qWait"""
-
- TIMEOUT_WAIT = 0
- """Extra timeout in millisecond to add to qSleep, qWait and
- qWaitForWindowExposed.
-
- Intended purpose is for debugging, to add extra time to waits in order to
- allow to view the tested widgets.
- """
-
- @classmethod
- def exceptionHandler(cls, exceptionClass, exception, stack):
- import traceback
- message = (''.join(traceback.format_tb(stack)))
- template = 'Traceback (most recent call last):\n{2}{0}: {1}'
- message = template.format(exceptionClass.__name__, exception, message)
- cls._exceptions.append(message)
-
- @classmethod
- def setUpClass(cls):
- """Makes sure Qt is inited"""
- cls._oldExceptionHook = sys.excepthook
- sys.excepthook = cls.exceptionHandler
-
- global _qapp
- if _qapp is None:
- # Makes sure a QApplication exists and do it once for all
- _qapp = qt.QApplication.instance() or qt.QApplication([])
-
- # Makes sure QDesktopWidget is init
- # Otherwise it happens randomly during the tests
- cls._desktopWidget = _qapp.desktop()
- _qapp.processEvents()
-
- @classmethod
- def tearDownClass(cls):
- sys.excepthook = cls._oldExceptionHook
-
- def setUp(self):
- """Get the list of existing widgets."""
- self.allowedLeakingWidgets = 0
- self.__previousWidgets = self.qapp.allWidgets()
- self.__class__._exceptions = []
-
- def _currentTestSucceeded(self):
- if hasattr(self, '_outcome'):
- # For Python >= 3.4
- result = self.defaultTestResult() # these 2 methods have no side effects
- self._feedErrorsToResult(result, self._outcome.errors)
- else:
- # For Python < 3.4
- result = getattr(self, '_outcomeForDoCleanups', self._resultForDoCleanups)
-
- skipped = self.id() in [case.id() for case, _ in result.skipped]
- error = self.id() in [case.id() for case, _ in result.errors]
- failure = self.id() in [case.id() for case, _ in result.failures]
- return not error and not failure and not skipped
-
- def _checkForUnreleasedWidgets(self):
- """Test fixture checking that no more widgets exists."""
- gc.collect()
-
- widgets = [widget for widget in self.qapp.allWidgets()
- if widget not in self.__previousWidgets]
- del self.__previousWidgets
-
- if qt.BINDING in ('PySide', 'PySide2'):
- return # Do not test for leaking widgets with PySide
-
- allowedLeakingWidgets = self.allowedLeakingWidgets
- self.allowedLeakingWidgets = 0
-
- if widgets and len(widgets) <= allowedLeakingWidgets:
- _logger.info(
- '%s: %d remaining widgets after test' % (self.id(),
- len(widgets)))
-
- if len(widgets) > allowedLeakingWidgets:
- raise RuntimeError(
- "Test ended with widgets alive: %s" % str(widgets))
-
- def tearDown(self):
- if len(self.__class__._exceptions) > 0:
- messages = "\n".join(self.__class__._exceptions)
- raise AssertionError("Exception occured in Qt thread:\n" + messages)
-
- if self._currentTestSucceeded():
- self._checkForUnreleasedWidgets()
-
- @property
- def qapp(self):
- """The QApplication currently running."""
- return qt.QApplication.instance()
-
- # Proxy to QTest
-
- Press = QTest.Press
- """Key press action code"""
-
- Release = QTest.Release
- """Key release action code"""
-
- Click = QTest.Click
- """Key click action code"""
-
- QTest = property(lambda self: QTest,
- doc="""The Qt QTest class from the used Qt binding.""")
-
- def keyClick(self, widget, key, modifier=qt.Qt.NoModifier, delay=-1):
- """Simulate clicking a key.
-
- See QTest.keyClick for details.
- """
- QTest.keyClick(widget, key, modifier, delay)
- self.qWait(20)
-
- def keyClicks(self, widget, sequence, modifier=qt.Qt.NoModifier, delay=-1):
- """Simulate clicking a sequence of keys.
-
- See QTest.keyClick for details.
- """
- QTest.keyClicks(widget, sequence, modifier, delay)
- self.qWait(20)
-
- def keyEvent(self, action, widget, key,
- modifier=qt.Qt.NoModifier, delay=-1):
- """Sends a Qt key event.
+from __future__ import absolute_import
- See QTest.keyEvent for details.
- """
- QTest.keyEvent(action, widget, key, modifier, delay)
- self.qWait(20)
-
- def keyPress(self, widget, key, modifier=qt.Qt.NoModifier, delay=-1):
- """Sends a Qt key press event.
-
- See QTest.keyPress for details.
- """
- QTest.keyPress(widget, key, modifier, delay)
- self.qWait(20)
-
- def keyRelease(self, widget, key, modifier=qt.Qt.NoModifier, delay=-1):
- """Sends a Qt key release event.
-
- See QTest.keyRelease for details.
- """
- QTest.keyRelease(widget, key, modifier, delay)
- self.qWait(20)
-
- def mouseClick(self, widget, button, modifier=None, pos=None, delay=-1):
- """Simulate clicking a mouse button.
-
- See QTest.mouseClick for details.
- """
- if modifier is None:
- modifier = qt.Qt.KeyboardModifiers()
- pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint()
- QTest.mouseClick(widget, button, modifier, pos, delay)
- self.qWait(20)
-
- def mouseDClick(self, widget, button, modifier=None, pos=None, delay=-1):
- """Simulate double clicking a mouse button.
-
- See QTest.mouseDClick for details.
- """
- if modifier is None:
- modifier = qt.Qt.KeyboardModifiers()
- pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint()
- QTest.mouseDClick(widget, button, modifier, pos, delay)
- self.qWait(20)
-
- def mouseMove(self, widget, pos=None, delay=-1):
- """Simulate moving the mouse.
-
- See QTest.mouseMove for details.
- """
- pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint()
- QTest.mouseMove(widget, pos, delay)
- self.qWait(20)
-
- def mousePress(self, widget, button, modifier=None, pos=None, delay=-1):
- """Simulate pressing a mouse button.
-
- See QTest.mousePress for details.
- """
- if modifier is None:
- modifier = qt.Qt.KeyboardModifiers()
- pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint()
- QTest.mousePress(widget, button, modifier, pos, delay)
- self.qWait(20)
-
- def mouseRelease(self, widget, button, modifier=None, pos=None, delay=-1):
- """Simulate releasing a mouse button.
-
- See QTest.mouseRelease for details.
- """
- if modifier is None:
- modifier = qt.Qt.KeyboardModifiers()
- pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint()
- QTest.mouseRelease(widget, button, modifier, pos, delay)
- self.qWait(20)
-
- def qSleep(self, ms):
- """Sleep for ms milliseconds, blocking the execution of the test.
-
- See QTest.qSleep for details.
- """
- QTest.qSleep(ms + self.TIMEOUT_WAIT)
-
- @classmethod
- def qWait(cls, ms=None):
- """Waits for ms milliseconds, events will be processed.
-
- See QTest.qWait for details.
- """
- if ms is None:
- ms = cls.DEFAULT_TIMEOUT_WAIT
-
- if qt.BINDING == 'PySide':
- # PySide has no qWait, provide a replacement
- timeout = int(ms)
- endTimeMS = int(time.time() * 1000) + timeout
- while timeout > 0:
- _qapp.processEvents(qt.QEventLoop.AllEvents,
- maxtime=timeout)
- timeout = endTimeMS - int(time.time() * 1000)
- else:
- QTest.qWait(ms + cls.TIMEOUT_WAIT)
-
- def qWaitForWindowExposed(self, window, timeout=None):
- """Waits until the window is shown in the screen.
-
- See QTest.qWaitForWindowExposed for details.
- """
- result = qWaitForWindowExposedAndActivate(window, timeout)
-
- if self.TIMEOUT_WAIT:
- QTest.qWait(self.TIMEOUT_WAIT)
-
- return result
-
- _qobject_destroyed = False
-
- @classmethod
- def _aboutToDestroy(cls):
- cls._qobject_destroyed = True
-
- @classmethod
- def qWaitForDestroy(cls, ref):
- """
- Wait for Qt object destruction.
-
- Use a weakref as parameter to avoid any strong references to the
- object.
-
- It have to be used as following. Removing the reference to the object
- before calling the function looks to be expected, else
- :meth:`deleteLater` will not work.
-
- .. code-block:: python
-
- ref = weakref.ref(self.obj)
- self.obj = None
- self.qWaitForDestroy(ref)
-
- :param weakref ref: A weakref to an object to avoid any reference
- :return: True if the object was destroyed
- :rtype: bool
- """
- cls._qobject_destroyed = False
- if qt.BINDING == 'PyQt4':
- # Without this, QWidget will be still alive on PyQt4
- # (at least on Windows Python 2.7)
- # If it is not skipped on PySide, silx.gui.dialog tests will
- # segfault (at least on Windows Python 2.7)
- import gc
- gc.collect()
- qobject = ref()
- if qobject is None:
- return True
- qobject.destroyed.connect(cls._aboutToDestroy)
- qobject.deleteLater()
- qobject = None
- for _ in range(10):
- if cls._qobject_destroyed:
- break
- cls.qWait(10)
- else:
- _logger.debug("Object was not destroyed")
-
- return ref() is None
-
- def logScreenShot(self, level=logging.ERROR):
- """Take a screenshot and log it into the logging system if the
- logger is enabled for the expected level.
-
- The screenshot is stored in the directory "./build/test-debug", and
- the logging system only log the path to this file.
-
- :param level: Logging level
- """
- if not _logger.isEnabledFor(level):
- return
- basedir = os.path.abspath(os.path.join("build", "test-debug"))
- if not os.path.exists(basedir):
- os.makedirs(basedir)
- filename = "Screenshot_%s.png" % self.id()
- filename = os.path.join(basedir, filename)
-
- if not hasattr(self.qapp, "primaryScreen"):
- # Qt4
- winId = qt.QApplication.desktop().winId()
- pixmap = qt.QPixmap.grabWindow(winId)
- else:
- # Qt5
- screen = self.qapp.primaryScreen()
- pixmap = screen.grabWindow(0)
- pixmap.save(filename)
- _logger.log(level, "Screenshot saved at %s", filename)
-
-
-class SignalListener(object):
- """Util to listen a Qt event and store parameters
- """
-
- def __init__(self):
- self.__calls = []
-
- def __call__(self, *args, **kargs):
- self.__calls.append((args, kargs))
-
- def clear(self):
- """Clear stored data"""
- self.__calls = []
-
- def callCount(self):
- """
- Returns how many times the listener was called.
-
- :rtype: int
- """
- return len(self.__calls)
-
- def arguments(self, callIndex=None, argumentIndex=None):
- """Returns positional arguments optionally filtered by call count id
- or argument index.
-
- :param int callIndex: Index of the called data
- :param int argumentIndex: Index of the positional argument.
- """
- if callIndex is not None:
- result = self.__calls[callIndex][0]
- if argumentIndex is not None:
- result = result[argumentIndex]
- else:
- result = [x[0] for x in self.__calls]
- if argumentIndex is not None:
- result = [x[argumentIndex] for x in result]
- return result
-
- def karguments(self, callIndex=None, argumentName=None):
- """Returns positional arguments optionally filtered by call count id
- or name of the keyword argument.
-
- :param int callIndex: Index of the called data
- :param int argumentName: Name of the keyword argument.
- """
- if callIndex is not None:
- result = self.__calls[callIndex][1]
- if argumentName is not None:
- result = result[argumentName]
- else:
- result = [x[1] for x in self.__calls]
- if argumentName is not None:
- result = [x[argumentName] for x in result]
- return result
-
- def partial(self, *args, **kargs):
- """Returns a new partial object which when called will behave like this
- listener called with the positional arguments args and keyword
- arguments keywords. If more arguments are supplied to the call, they
- are appended to args. If additional keyword arguments are supplied,
- they extend and override keywords.
- """
- return functools.partial(self, *args, **kargs)
-
-
-def getQToolButtonFromAction(action):
- """Return a QToolButton corresponding to a QAction.
+__authors__ = ["V. Valls"]
+__license__ = "MIT"
+__date__ = "05/10/2018"
- :param QAction action: The QAction from which to get QToolButton.
- :return: A QToolButton associated to action or None.
- """
- for widget in action.associatedWidgets():
- if isinstance(widget, qt.QToolButton):
- return widget
- return None
+import silx.utils.deprecation
+silx.utils.deprecation.deprecated_warning("Module",
+ name="silx.gui.test.utils",
+ reason="moved",
+ replacement="silx.gui.utils.testutils",
+ since_version="0.9.0",
+ only_once=True,
+ skip_backtrace_count=1)
-def findChildren(parent, kind, name=None):
- if qt.BINDING == "PySide" and name is not None:
- result = []
- for obj in parent.findChildren(kind):
- if obj.objectName() == name:
- result.append(obj)
- return result
- else:
- return parent.findChildren(kind, name=name)
+from ..utils.testutils import * # noqa
diff --git a/silx/gui/utils/_image.py b/silx/gui/utils/_image.py
deleted file mode 100644
index 260aac3..0000000
--- a/silx/gui/utils/_image.py
+++ /dev/null
@@ -1,104 +0,0 @@
-# coding: utf-8
-# /*##########################################################################
-#
-# Copyright (c) 2017-2018 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.
-#
-# ###########################################################################*/
-"""This module provides convenient functions to use with Qt objects.
-
-It provides:
-- conversion between numpy and QImage:
- :func:`convertArrayToQImage`, :func:`convertQImageToArray`
-"""
-
-from __future__ import division
-
-
-__authors__ = ["T. Vincent"]
-__license__ = "MIT"
-__date__ = "16/01/2017"
-
-
-import sys
-import numpy
-
-from .. import qt
-
-
-def convertArrayToQImage(image):
- """Convert an array-like RGB888 image to a QImage.
-
- The created QImage is using a copy of the array data.
-
- Limitation: Only supports RGB888 format.
-
- :param image: Array-like image data
- :type image: numpy.ndarray of uint8 of dimension HxWx3
- :return: Corresponding Qt image
- :rtype: QImage
- """
- # Possible extension: add a format argument to support more formats
-
- image = numpy.array(image, copy=False, order='C', dtype=numpy.uint8)
-
- height, width, depth = image.shape
- assert depth == 3
-
- qimage = qt.QImage(
- image.data,
- width,
- height,
- image.strides[0], # bytesPerLine
- qt.QImage.Format_RGB888)
-
- return qimage.copy() # Making a copy of the image and its data
-
-
-def convertQImageToArray(image):
- """Convert a RGB888 QImage to a numpy array.
-
- Limitation: Only supports RGB888 format.
- If QImage is not RGB888 it gets converted to this format.
-
- :param QImage: The QImage to convert.
- :return: The image array
- :rtype: numpy.ndarray of uint8 of shape HxWx3
- """
- # Possible extension: avoid conversion to support more formats
-
- if image.format() != qt.QImage.Format_RGB888:
- # Convert to RGB888 if needed
- image = image.convertToFormat(qt.QImage.Format_RGB888)
-
- ptr = image.bits()
- if qt.BINDING not in ('PySide', 'PySide2'):
- ptr.setsize(image.byteCount())
- if qt.BINDING == 'PyQt4' and sys.version_info[0] == 2:
- ptr = ptr.asstring()
- elif sys.version_info[0] == 3: # PySide with Python3
- ptr = ptr.tobytes()
-
- array = numpy.fromstring(ptr, dtype=numpy.uint8)
-
- # Lines are 32 bits aligned: remove padding bytes
- array = array.reshape(image.height(), -1)[:, :image.width() * 3]
- array.shape = image.height(), image.width(), 3
- return array
diff --git a/silx/gui/utils/image.py b/silx/gui/utils/image.py
new file mode 100644
index 0000000..3ac737f
--- /dev/null
+++ b/silx/gui/utils/image.py
@@ -0,0 +1,143 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2017-2018 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.
+#
+# ###########################################################################*/
+"""This module provides conversions between numpy.ndarray and QImage
+
+- :func:`convertArrayToQImage`
+- :func:`convertQImageToArray`
+"""
+
+from __future__ import division
+
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "04/09/2018"
+
+
+import sys
+import numpy
+from numpy.lib.stride_tricks import as_strided as _as_strided
+
+from .. import qt
+
+
+def convertArrayToQImage(array):
+ """Convert an array-like image to a QImage.
+
+ The created QImage is using a copy of the array data.
+
+ Limitation: Only RGB or RGBA images with 8 bits per channel are supported.
+
+ :param array: Array-like image data of shape (height, width, channels)
+ Channels are expected to be either RGB or RGBA.
+ :type array: numpy.ndarray of uint8
+ :return: Corresponding Qt image with RGB888 or ARGB32 format.
+ :rtype: QImage
+ """
+ array = numpy.array(array, copy=False, order='C', dtype=numpy.uint8)
+
+ if array.ndim != 3 or array.shape[2] not in (3, 4):
+ raise ValueError(
+ 'Image must be a 3D array with 3 or 4 channels per pixel')
+
+ if array.shape[2] == 4:
+ format_ = qt.QImage.Format_ARGB32
+ # RGBA -> ARGB + take care of endianness
+ if sys.byteorder == 'little': # RGBA -> BGRA
+ array = array[:, :, (2, 1, 0, 3)]
+ else: # big endian: RGBA -> ARGB
+ array = array[:, :, (3, 0, 1, 2)]
+
+ array = numpy.array(array, order='C') # Make a contiguous array
+
+ else: # array.shape[2] == 3
+ format_ = qt.QImage.Format_RGB888
+
+ height, width, depth = array.shape
+ qimage = qt.QImage(
+ array.data,
+ width,
+ height,
+ array.strides[0], # bytesPerLine
+ format_)
+
+ return qimage.copy() # Making a copy of the image and its data
+
+
+def convertQImageToArray(image):
+ """Convert a QImage to a numpy array.
+
+ If QImage format is not Format_RGB888, Format_RGBA8888 or Format_ARGB32,
+ it is first converted to one of this format depending on
+ the presence of an alpha channel.
+
+ The created numpy array is using a copy of the QImage data.
+
+ :param QImage image: The QImage to convert.
+ :return: The image array of RGB or RGBA channels of shape
+ (height, width, channels (3 or 4))
+ :rtype: numpy.ndarray of uint8
+ """
+ rgba8888 = getattr(qt.QImage, 'Format_RGBA8888', None) # Only in Qt5
+
+ # Convert to supported format if needed
+ if image.format() not in (qt.QImage.Format_ARGB32,
+ qt.QImage.Format_RGB888,
+ rgba8888):
+ if image.hasAlphaChannel():
+ image = image.convertToFormat(
+ rgba8888 if rgba8888 is not None else qt.QImage.Format_ARGB32)
+ else:
+ image = image.convertToFormat(qt.QImage.Format_RGB888)
+
+ format_ = image.format()
+ channels = 3 if format_ == qt.QImage.Format_RGB888 else 4
+
+ ptr = image.bits()
+ if qt.BINDING not in ('PySide', 'PySide2'):
+ ptr.setsize(image.byteCount())
+ if qt.BINDING == 'PyQt4' and sys.version_info[0] == 2:
+ ptr = ptr.asstring()
+ elif sys.version_info[0] == 3: # PySide with Python3
+ ptr = ptr.tobytes()
+
+ # Create an array view on QImage internal data
+ view = _as_strided(
+ numpy.frombuffer(ptr, dtype=numpy.uint8),
+ shape=(image.height(), image.width(), channels),
+ strides=(image.bytesPerLine(), channels, 1))
+
+ if format_ == qt.QImage.Format_ARGB32:
+ # Convert from ARGB to RGBA
+ # Not a byte-ordered format: do care about endianness
+ if sys.byteorder == 'little': # BGRA -> RGBA
+ view = view[:, :, (2, 1, 0, 3)]
+ else: # big endian: ARGB -> RGBA
+ view = view[:, :, (1, 2, 3, 0)]
+
+ # Format_RGB888 and Format_RGBA8888 do not need reshuffling channels:
+ # They are byte-ordered and already in the right order
+
+ return numpy.array(view, copy=True, order='C')
diff --git a/silx/gui/utils/test/test_async.py b/silx/gui/utils/test/test_async.py
index fd32a3f..dabfb3c 100644
--- a/silx/gui/utils/test/test_async.py
+++ b/silx/gui/utils/test/test_async.py
@@ -35,7 +35,7 @@ import unittest
from silx.third_party.concurrent_futures import wait
from silx.gui import qt
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui.utils import concurrent
diff --git a/silx/gui/utils/test/test_image.py b/silx/gui/utils/test/test_image.py
index 7cba1b0..cda7d95 100644
--- a/silx/gui/utils/test/test_image.py
+++ b/silx/gui/utils/test/test_image.py
@@ -32,35 +32,51 @@ import numpy
import unittest
from silx.gui import qt
-from silx.gui.test.utils import TestCaseQt
-from silx.gui.utils import _image
+from silx.utils.testutils import ParametricTestCase
+from silx.gui.utils.testutils import TestCaseQt
+from silx.gui.utils.image import convertArrayToQImage, convertQImageToArray
-class TestQImageConversion(TestCaseQt):
+class TestQImageConversion(TestCaseQt, ParametricTestCase):
"""Tests conversion of QImage to/from numpy array."""
def testConvertArrayToQImage(self):
"""Test conversion of numpy array to QImage"""
- image = numpy.ones((3, 3, 3), dtype=numpy.uint8)
- qimage = _image.convertArrayToQImage(image)
+ for format_, channels in [('Format_RGB888', 3),
+ ('Format_ARGB32', 4)]:
+ with self.subTest(format_):
+ image = numpy.arange(
+ 3*3*channels, dtype=numpy.uint8).reshape(3, 3, channels)
+ qimage = convertArrayToQImage(image)
- self.assertEqual(qimage.height(), image.shape[0])
- self.assertEqual(qimage.width(), image.shape[1])
- self.assertEqual(qimage.format(), qt.QImage.Format_RGB888)
+ self.assertEqual(qimage.height(), image.shape[0])
+ self.assertEqual(qimage.width(), image.shape[1])
+ self.assertEqual(qimage.format(), getattr(qt.QImage, format_))
+
+ for row in range(3):
+ for col in range(3):
+ # Qrgb has no alpha channel, not compared
+ # Qt uses x,y while array is row,col...
+ self.assertEqual(qt.QColor(qimage.pixel(col, row)),
+ qt.QColor(*image[row, col, :3]))
- color = qt.QColor(1, 1, 1).rgb()
- self.assertEqual(qimage.pixel(1, 1), color)
def testConvertQImageToArray(self):
"""Test conversion of QImage to numpy array"""
- qimage = qt.QImage(3, 3, qt.QImage.Format_RGB888)
- qimage.fill(0x010101)
- image = _image.convertQImageToArray(qimage)
+ for format_, channels in [
+ ('Format_RGB888', 3), # Native support
+ ('Format_ARGB32', 4), # Native support
+ ('Format_RGB32', 3)]: # Conversion to RGB
+ with self.subTest(format_):
+ color = numpy.arange(channels) # RGB(A) values
+ qimage = qt.QImage(3, 3, getattr(qt.QImage, format_))
+ qimage.fill(qt.QColor(*color))
+ image = convertQImageToArray(qimage)
- self.assertEqual(qimage.height(), image.shape[0])
- self.assertEqual(qimage.width(), image.shape[1])
- self.assertEqual(image.shape[2], 3)
- self.assertTrue(numpy.all(numpy.equal(image, 1)))
+ self.assertEqual(qimage.height(), image.shape[0])
+ self.assertEqual(qimage.width(), image.shape[1])
+ self.assertEqual(image.shape[2], len(color))
+ self.assertTrue(numpy.all(numpy.equal(image, color)))
def suite():
diff --git a/silx/gui/utils/testutils.py b/silx/gui/utils/testutils.py
new file mode 100644
index 0000000..35085fc
--- /dev/null
+++ b/silx/gui/utils/testutils.py
@@ -0,0 +1,520 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2016-2018 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.
+#
+# ###########################################################################*/
+"""Helper class to write Qt widget unittests."""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "05/10/2018"
+
+
+import gc
+import logging
+import unittest
+import time
+import functools
+import sys
+import os
+
+_logger = logging.getLogger(__name__)
+
+from silx.gui import qt
+
+if qt.BINDING == 'PySide':
+ from PySide.QtTest import QTest
+elif qt.BINDING == 'PySide2':
+ from PySide2.QtTest import QTest
+elif qt.BINDING == 'PyQt5':
+ from PyQt5.QtTest import QTest
+elif qt.BINDING == 'PyQt4':
+ from PyQt4.QtTest import QTest
+else:
+ raise ImportError('Unsupported Qt bindings')
+
+# Qt4/Qt5 compatibility wrapper
+if qt.BINDING in ('PySide', 'PyQt4'):
+ _logger.info("QTest.qWaitForWindowExposed not available," +
+ "using QTest.qWaitForWindowShown instead.")
+
+ def qWaitForWindowExposed(window, timeout=None):
+ """Mimic QTest.qWaitForWindowExposed for Qt4."""
+ QTest.qWaitForWindowShown(window)
+ return True
+else:
+ qWaitForWindowExposed = QTest.qWaitForWindowExposed
+
+
+def qWaitForWindowExposedAndActivate(window, timeout=None):
+ """Waits until the window is shown in the screen.
+
+ It also activates the window and raises it.
+
+ See QTest.qWaitForWindowExposed for details.
+ """
+ if timeout is None:
+ result = qWaitForWindowExposed(window)
+ else:
+ result = qWaitForWindowExposed(window, timeout)
+
+ if result:
+ # Makes sure window is active and on top
+ window.activateWindow()
+ window.raise_()
+
+ return result
+
+
+# Placeholder for QApplication
+_qapp = None
+
+
+class TestCaseQt(unittest.TestCase):
+ """Base class to write test for Qt stuff.
+
+ It creates a QApplication before running the tests.
+ WARNING: The QApplication is shared by all tests, which might have side
+ effects.
+
+ After each test, this class is checking for widgets remaining alive.
+ To allow some widgets to remain alive at the end of a test, set the
+ allowedLeakingWidgets attribute to the number of widgets that can remain
+ alive at the end of the test.
+ With PySide, this test is not run for now as it seems PySide
+ is leaking widgets internally.
+
+ All keyboard and mouse event simulation methods call qWait(20) after
+ simulating the event (as QTest does on Mac OSX).
+ This was introduced to fix issues with continuous integration tests
+ running with Xvfb on Linux.
+ """
+
+ DEFAULT_TIMEOUT_WAIT = 100
+ """Default timeout for qWait"""
+
+ TIMEOUT_WAIT = 0
+ """Extra timeout in millisecond to add to qSleep, qWait and
+ qWaitForWindowExposed.
+
+ Intended purpose is for debugging, to add extra time to waits in order to
+ allow to view the tested widgets.
+ """
+
+ @classmethod
+ def exceptionHandler(cls, exceptionClass, exception, stack):
+ import traceback
+ message = (''.join(traceback.format_tb(stack)))
+ template = 'Traceback (most recent call last):\n{2}{0}: {1}'
+ message = template.format(exceptionClass.__name__, exception, message)
+ cls._exceptions.append(message)
+
+ @classmethod
+ def setUpClass(cls):
+ """Makes sure Qt is inited"""
+ cls._oldExceptionHook = sys.excepthook
+ sys.excepthook = cls.exceptionHandler
+
+ global _qapp
+ if _qapp is None:
+ # Makes sure a QApplication exists and do it once for all
+ _qapp = qt.QApplication.instance() or qt.QApplication([])
+
+ # Makes sure QDesktopWidget is init
+ # Otherwise it happens randomly during the tests
+ cls._desktopWidget = _qapp.desktop()
+ _qapp.processEvents()
+
+ @classmethod
+ def tearDownClass(cls):
+ sys.excepthook = cls._oldExceptionHook
+
+ def setUp(self):
+ """Get the list of existing widgets."""
+ self.allowedLeakingWidgets = 0
+ self.__previousWidgets = self.qapp.allWidgets()
+ self.__class__._exceptions = []
+
+ def _currentTestSucceeded(self):
+ if hasattr(self, '_outcome'):
+ # For Python >= 3.4
+ result = self.defaultTestResult() # these 2 methods have no side effects
+ self._feedErrorsToResult(result, self._outcome.errors)
+ else:
+ # For Python < 3.4
+ result = getattr(self, '_outcomeForDoCleanups', self._resultForDoCleanups)
+
+ skipped = self.id() in [case.id() for case, _ in result.skipped]
+ error = self.id() in [case.id() for case, _ in result.errors]
+ failure = self.id() in [case.id() for case, _ in result.failures]
+ return not error and not failure and not skipped
+
+ def _checkForUnreleasedWidgets(self):
+ """Test fixture checking that no more widgets exists."""
+ gc.collect()
+
+ widgets = [widget for widget in self.qapp.allWidgets()
+ if widget not in self.__previousWidgets]
+ del self.__previousWidgets
+
+ if qt.BINDING in ('PySide', 'PySide2'):
+ return # Do not test for leaking widgets with PySide
+
+ allowedLeakingWidgets = self.allowedLeakingWidgets
+ self.allowedLeakingWidgets = 0
+
+ if widgets and len(widgets) <= allowedLeakingWidgets:
+ _logger.info(
+ '%s: %d remaining widgets after test' % (self.id(),
+ len(widgets)))
+
+ if len(widgets) > allowedLeakingWidgets:
+ raise RuntimeError(
+ "Test ended with widgets alive: %s" % str(widgets))
+
+ def tearDown(self):
+ if len(self.__class__._exceptions) > 0:
+ messages = "\n".join(self.__class__._exceptions)
+ raise AssertionError("Exception occured in Qt thread:\n" + messages)
+
+ if self._currentTestSucceeded():
+ self._checkForUnreleasedWidgets()
+
+ @property
+ def qapp(self):
+ """The QApplication currently running."""
+ return qt.QApplication.instance()
+
+ # Proxy to QTest
+
+ Press = QTest.Press
+ """Key press action code"""
+
+ Release = QTest.Release
+ """Key release action code"""
+
+ Click = QTest.Click
+ """Key click action code"""
+
+ QTest = property(lambda self: QTest,
+ doc="""The Qt QTest class from the used Qt binding.""")
+
+ def keyClick(self, widget, key, modifier=qt.Qt.NoModifier, delay=-1):
+ """Simulate clicking a key.
+
+ See QTest.keyClick for details.
+ """
+ QTest.keyClick(widget, key, modifier, delay)
+ self.qWait(20)
+
+ def keyClicks(self, widget, sequence, modifier=qt.Qt.NoModifier, delay=-1):
+ """Simulate clicking a sequence of keys.
+
+ See QTest.keyClick for details.
+ """
+ QTest.keyClicks(widget, sequence, modifier, delay)
+ self.qWait(20)
+
+ def keyEvent(self, action, widget, key,
+ modifier=qt.Qt.NoModifier, delay=-1):
+ """Sends a Qt key event.
+
+ See QTest.keyEvent for details.
+ """
+ QTest.keyEvent(action, widget, key, modifier, delay)
+ self.qWait(20)
+
+ def keyPress(self, widget, key, modifier=qt.Qt.NoModifier, delay=-1):
+ """Sends a Qt key press event.
+
+ See QTest.keyPress for details.
+ """
+ QTest.keyPress(widget, key, modifier, delay)
+ self.qWait(20)
+
+ def keyRelease(self, widget, key, modifier=qt.Qt.NoModifier, delay=-1):
+ """Sends a Qt key release event.
+
+ See QTest.keyRelease for details.
+ """
+ QTest.keyRelease(widget, key, modifier, delay)
+ self.qWait(20)
+
+ def mouseClick(self, widget, button, modifier=None, pos=None, delay=-1):
+ """Simulate clicking a mouse button.
+
+ See QTest.mouseClick for details.
+ """
+ if modifier is None:
+ modifier = qt.Qt.KeyboardModifiers()
+ pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint()
+ QTest.mouseClick(widget, button, modifier, pos, delay)
+ self.qWait(20)
+
+ def mouseDClick(self, widget, button, modifier=None, pos=None, delay=-1):
+ """Simulate double clicking a mouse button.
+
+ See QTest.mouseDClick for details.
+ """
+ if modifier is None:
+ modifier = qt.Qt.KeyboardModifiers()
+ pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint()
+ QTest.mouseDClick(widget, button, modifier, pos, delay)
+ self.qWait(20)
+
+ def mouseMove(self, widget, pos=None, delay=-1):
+ """Simulate moving the mouse.
+
+ See QTest.mouseMove for details.
+ """
+ pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint()
+ QTest.mouseMove(widget, pos, delay)
+ self.qWait(20)
+
+ def mousePress(self, widget, button, modifier=None, pos=None, delay=-1):
+ """Simulate pressing a mouse button.
+
+ See QTest.mousePress for details.
+ """
+ if modifier is None:
+ modifier = qt.Qt.KeyboardModifiers()
+ pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint()
+ QTest.mousePress(widget, button, modifier, pos, delay)
+ self.qWait(20)
+
+ def mouseRelease(self, widget, button, modifier=None, pos=None, delay=-1):
+ """Simulate releasing a mouse button.
+
+ See QTest.mouseRelease for details.
+ """
+ if modifier is None:
+ modifier = qt.Qt.KeyboardModifiers()
+ pos = qt.QPoint(pos[0], pos[1]) if pos is not None else qt.QPoint()
+ QTest.mouseRelease(widget, button, modifier, pos, delay)
+ self.qWait(20)
+
+ def qSleep(self, ms):
+ """Sleep for ms milliseconds, blocking the execution of the test.
+
+ See QTest.qSleep for details.
+ """
+ QTest.qSleep(ms + self.TIMEOUT_WAIT)
+
+ @classmethod
+ def qWait(cls, ms=None):
+ """Waits for ms milliseconds, events will be processed.
+
+ See QTest.qWait for details.
+ """
+ if ms is None:
+ ms = cls.DEFAULT_TIMEOUT_WAIT
+
+ if qt.BINDING in ('PySide', 'PySide2'):
+ # PySide has no qWait, provide a replacement
+ timeout = int(ms)
+ endTimeMS = int(time.time() * 1000) + timeout
+ while timeout > 0:
+ _qapp.processEvents(qt.QEventLoop.AllEvents,
+ maxtime=timeout)
+ timeout = endTimeMS - int(time.time() * 1000)
+ else:
+ QTest.qWait(ms + cls.TIMEOUT_WAIT)
+
+ def qWaitForWindowExposed(self, window, timeout=None):
+ """Waits until the window is shown in the screen.
+
+ See QTest.qWaitForWindowExposed for details.
+ """
+ result = qWaitForWindowExposedAndActivate(window, timeout)
+
+ if self.TIMEOUT_WAIT:
+ QTest.qWait(self.TIMEOUT_WAIT)
+
+ return result
+
+ _qobject_destroyed = False
+
+ @classmethod
+ def _aboutToDestroy(cls):
+ cls._qobject_destroyed = True
+
+ @classmethod
+ def qWaitForDestroy(cls, ref):
+ """
+ Wait for Qt object destruction.
+
+ Use a weakref as parameter to avoid any strong references to the
+ object.
+
+ It have to be used as following. Removing the reference to the object
+ before calling the function looks to be expected, else
+ :meth:`deleteLater` will not work.
+
+ .. code-block:: python
+
+ ref = weakref.ref(self.obj)
+ self.obj = None
+ self.qWaitForDestroy(ref)
+
+ :param weakref ref: A weakref to an object to avoid any reference
+ :return: True if the object was destroyed
+ :rtype: bool
+ """
+ cls._qobject_destroyed = False
+ if qt.BINDING == 'PyQt4':
+ # Without this, QWidget will be still alive on PyQt4
+ # (at least on Windows Python 2.7)
+ # If it is not skipped on PySide, silx.gui.dialog tests will
+ # segfault (at least on Windows Python 2.7)
+ import gc
+ gc.collect()
+ qobject = ref()
+ if qobject is None:
+ return True
+ qobject.destroyed.connect(cls._aboutToDestroy)
+ qobject.deleteLater()
+ qobject = None
+ for _ in range(10):
+ if cls._qobject_destroyed:
+ break
+ cls.qWait(10)
+ else:
+ _logger.debug("Object was not destroyed")
+
+ return ref() is None
+
+ def logScreenShot(self, level=logging.ERROR):
+ """Take a screenshot and log it into the logging system if the
+ logger is enabled for the expected level.
+
+ The screenshot is stored in the directory "./build/test-debug", and
+ the logging system only log the path to this file.
+
+ :param level: Logging level
+ """
+ if not _logger.isEnabledFor(level):
+ return
+ basedir = os.path.abspath(os.path.join("build", "test-debug"))
+ if not os.path.exists(basedir):
+ os.makedirs(basedir)
+ filename = "Screenshot_%s.png" % self.id()
+ filename = os.path.join(basedir, filename)
+
+ if not hasattr(self.qapp, "primaryScreen"):
+ # Qt4
+ winId = qt.QApplication.desktop().winId()
+ pixmap = qt.QPixmap.grabWindow(winId)
+ else:
+ # Qt5
+ screen = self.qapp.primaryScreen()
+ pixmap = screen.grabWindow(0)
+ pixmap.save(filename)
+ _logger.log(level, "Screenshot saved at %s", filename)
+
+
+class SignalListener(object):
+ """Util to listen a Qt event and store parameters
+ """
+
+ def __init__(self):
+ self.__calls = []
+
+ def __call__(self, *args, **kargs):
+ self.__calls.append((args, kargs))
+
+ def clear(self):
+ """Clear stored data"""
+ self.__calls = []
+
+ def callCount(self):
+ """
+ Returns how many times the listener was called.
+
+ :rtype: int
+ """
+ return len(self.__calls)
+
+ def arguments(self, callIndex=None, argumentIndex=None):
+ """Returns positional arguments optionally filtered by call count id
+ or argument index.
+
+ :param int callIndex: Index of the called data
+ :param int argumentIndex: Index of the positional argument.
+ """
+ if callIndex is not None:
+ result = self.__calls[callIndex][0]
+ if argumentIndex is not None:
+ result = result[argumentIndex]
+ else:
+ result = [x[0] for x in self.__calls]
+ if argumentIndex is not None:
+ result = [x[argumentIndex] for x in result]
+ return result
+
+ def karguments(self, callIndex=None, argumentName=None):
+ """Returns positional arguments optionally filtered by call count id
+ or name of the keyword argument.
+
+ :param int callIndex: Index of the called data
+ :param int argumentName: Name of the keyword argument.
+ """
+ if callIndex is not None:
+ result = self.__calls[callIndex][1]
+ if argumentName is not None:
+ result = result[argumentName]
+ else:
+ result = [x[1] for x in self.__calls]
+ if argumentName is not None:
+ result = [x[argumentName] for x in result]
+ return result
+
+ def partial(self, *args, **kargs):
+ """Returns a new partial object which when called will behave like this
+ listener called with the positional arguments args and keyword
+ arguments keywords. If more arguments are supplied to the call, they
+ are appended to args. If additional keyword arguments are supplied,
+ they extend and override keywords.
+ """
+ return functools.partial(self, *args, **kargs)
+
+
+def getQToolButtonFromAction(action):
+ """Return a QToolButton corresponding to a QAction.
+
+ :param QAction action: The QAction from which to get QToolButton.
+ :return: A QToolButton associated to action or None.
+ """
+ for widget in action.associatedWidgets():
+ if isinstance(widget, qt.QToolButton):
+ return widget
+ return None
+
+
+def findChildren(parent, kind, name=None):
+ if qt.BINDING in ("PySide", "PySide2") and name is not None:
+ result = []
+ for obj in parent.findChildren(kind):
+ if obj.objectName() == name:
+ result.append(obj)
+ return result
+ else:
+ return parent.findChildren(kind, name=name)
diff --git a/silx/gui/widgets/FloatEdit.py b/silx/gui/widgets/FloatEdit.py
index fd6d8a7..36a39a7 100644
--- a/silx/gui/widgets/FloatEdit.py
+++ b/silx/gui/widgets/FloatEdit.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2004-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2004-2018 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
@@ -61,5 +61,5 @@ class FloatEdit(qt.QLineEdit):
:param float value: The value to set the QLineEdit to.
"""
- text = self.validator().locale().toString(value)
+ text = self.validator().locale().toString(float(value))
self.setText(text)
diff --git a/silx/gui/widgets/FlowLayout.py b/silx/gui/widgets/FlowLayout.py
new file mode 100644
index 0000000..14c8ab2
--- /dev/null
+++ b/silx/gui/widgets/FlowLayout.py
@@ -0,0 +1,177 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018 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.
+#
+# ###########################################################################*/
+"""This module provides a flow layout for QWidget: :class:`FlowLayout`.
+"""
+
+from __future__ import division
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "20/07/2018"
+
+
+from .. import qt
+
+
+class FlowLayout(qt.QLayout):
+ """Layout widgets on (possibly) multiple lines in the available width.
+
+ See Qt :class:`QLayout` for API documentation.
+
+ Adapted from C++ `Qt FlowLayout example
+ <http://doc.qt.io/qt-5/qtwidgets-layouts-flowlayout-example.html>`_
+
+ :param QWidget parent: See :class:`QLayout`
+ """
+
+ def __init__(self, parent=None):
+ super(FlowLayout, self).__init__(parent)
+ self._items = []
+ self._horizontalSpacing = -1
+ self._verticalSpacing = -1
+
+ def addItem(self, item):
+ self._items.append(item)
+
+ def count(self):
+ return len(self._items)
+
+ def itemAt(self, index):
+ if 0 <= index < len(self._items):
+ return self._items[index]
+ else:
+ return None
+
+ def takeAt(self, index):
+ if 0 <= index < len(self._items):
+ return self._items.pop(index)
+ else:
+ return None
+
+ def expandingDirections(self):
+ return 0
+
+ def hasHeightForWidth(self):
+ return True
+
+ def heightForWidth(self, width):
+ return self._layout(qt.QRect(0, 0, width, 0), test=True)
+
+ def setGeometry(self, rect):
+ super(FlowLayout, self).setGeometry(rect)
+ self._layout(rect)
+
+ def sizeHint(self):
+ return self.minimumSize()
+
+ def minimumSize(self):
+ size = qt.QSize()
+ for item in self._items:
+ size = size.expandedTo(item.minimumSize())
+
+ left, top, right, bottom = self.getContentsMargins()
+ size += qt.QSize(left + right, top + bottom)
+ return size
+
+ def _layout(self, rect, test=False):
+ left, top, right, bottom = self.getContentsMargins()
+ effectiveRect = rect.adjusted(left, top, -right, -bottom)
+ x, y = effectiveRect.x(), effectiveRect.y()
+ lineHeight = 0
+
+ for item in self._items:
+ widget = item.widget()
+ spaceX = self.horizontalSpacing()
+ if spaceX == -1:
+ spaceX = widget.style().layoutSpacing(
+ qt.QSizePolicy.PushButton,
+ qt.QSizePolicy.PushButton,
+ qt.Qt.Horizontal)
+ spaceY = self.verticalSpacing()
+ if spaceY == -1:
+ spaceY = widget.style().layoutSpacing(
+ qt.QSizePolicy.PushButton,
+ qt.QSizePolicy.PushButton,
+ qt.Qt.Vertical)
+
+ nextX = x + item.sizeHint().width() + spaceX
+ if (nextX - spaceX) > effectiveRect.right() and lineHeight > 0:
+ x = effectiveRect.x()
+ y += lineHeight + spaceY
+ nextX = x + item.sizeHint().width() + spaceX
+ lineHeight = 0
+
+ if not test:
+ item.setGeometry(qt.QRect(qt.QPoint(x, y), item.sizeHint()))
+
+ x = nextX
+ lineHeight = max(lineHeight, item.sizeHint().height())
+
+ return y + lineHeight - rect.y() + bottom
+
+ def setHorizontalSpacing(self, spacing):
+ """Set the horizontal spacing between widgets laid out side by side
+
+ :param int spacing:
+ """
+ self._horizontalSpacing = spacing
+ self.update()
+
+ def horizontalSpacing(self):
+ """Returns the horizontal spacing between widgets laid out side by side
+
+ :rtype: int
+ """
+ if self._horizontalSpacing >= 0:
+ return self._horizontalSpacing
+ else:
+ return self._smartSpacing(qt.QStyle.PM_LayoutHorizontalSpacing)
+
+ def setVerticalSpacing(self, spacing):
+ """Set the vertical spacing between lines
+
+ :param int spacing:
+ """
+ self._verticalSpacing = spacing
+ self.update()
+
+ def verticalSpacing(self):
+ """Returns the vertical spacing between lines
+
+ :rtype: int
+ """
+ if self._verticalSpacing >= 0:
+ return self._verticalSpacing
+ else:
+ return self._smartSpacing(qt.QStyle.PM_LayoutVerticalSpacing)
+
+ def _smartSpacing(self, pm):
+ parent = self.parent()
+ if parent is None:
+ return -1
+ if parent.isWidgetType():
+ return parent.style().pixelMetric(pm, None, parent)
+ else:
+ return parent.spacing()
diff --git a/silx/gui/widgets/PrintPreview.py b/silx/gui/widgets/PrintPreview.py
index 78d1bd7..94a8ed4 100644
--- a/silx/gui/widgets/PrintPreview.py
+++ b/silx/gui/widgets/PrintPreview.py
@@ -411,6 +411,9 @@ class PrintPreviewDialog(qt.QDialog):
"""If the printer is not already set, try to interactively
setup the printer using a QPrintDialog.
In case of failure, hide widget and log a warning.
+
+ :return: True if printer was set. False if it failed or if the
+ selection dialog was canceled.
"""
if self.printer is None:
self.setup()
@@ -418,6 +421,7 @@ class PrintPreviewDialog(qt.QDialog):
self.hide()
_logger.warning("Printer setup failed or was cancelled, " +
"but printer is required.")
+ return self.printer is not None
def setOutputFileName(self, name):
"""Set output filename.
diff --git a/silx/gui/widgets/RangeSlider.py b/silx/gui/widgets/RangeSlider.py
new file mode 100644
index 0000000..0b72e71
--- /dev/null
+++ b/silx/gui/widgets/RangeSlider.py
@@ -0,0 +1,627 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2015-2018 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.
+#
+# ###########################################################################*/
+"""This module provides a :class:`RangeSlider` widget.
+
+.. image:: img/RangeSlider.png
+ :align: center
+"""
+from __future__ import absolute_import, division
+
+__authors__ = ["D. Naudet", "T. Vincent"]
+__license__ = "MIT"
+__date__ = "02/08/2018"
+
+
+import numpy as numpy
+
+from silx.gui import qt, icons, colors
+from silx.gui.utils.image import convertArrayToQImage
+
+
+class RangeSlider(qt.QWidget):
+ """Range slider with 2 thumbs and an optional colored groove.
+
+ The position of the slider thumbs can be retrieved either as values
+ in the slider range or as a number of steps or pixels.
+
+ :param QWidget parent: See QWidget
+ """
+
+ _SLIDER_WIDTH = 10
+ """Width of the slider rectangle"""
+
+ _PIXMAP_VOFFSET = 7
+ """Vertical groove pixmap offset"""
+
+ sigRangeChanged = qt.Signal(float, float)
+ """Signal emitted when the value range has changed.
+
+ It provides the new range (min, max).
+ """
+
+ sigValueChanged = qt.Signal(float, float)
+ """Signal emitted when the value of the sliders has changed.
+
+ It provides the slider values (first, second).
+ """
+
+ sigPositionCountChanged = qt.Signal(object)
+ """This signal is emitted when the number of steps has changed.
+
+ It provides the new position count.
+ """
+
+ sigPositionChanged = qt.Signal(int, int)
+ """Signal emitted when the position of the sliders has changed.
+
+ It provides the slider positions in steps or pixels (first, second).
+ """
+
+ def __init__(self, parent=None):
+ self.__pixmap = None
+ self.__positionCount = None
+ self.__firstValue = 0.
+ self.__secondValue = 1.
+ self.__minValue = 0.
+ self.__maxValue = 1.
+
+ self.__focus = None
+ self.__moving = None
+
+ self.__icons = {
+ 'first': icons.getQIcon('previous'),
+ 'second': icons.getQIcon('next')
+ }
+
+ # call the super constructor AFTER defining all members that
+ # are used in the "paint" method
+ super(RangeSlider, self).__init__(parent)
+
+ self.setFocusPolicy(qt.Qt.ClickFocus)
+
+ self.setMinimumSize(qt.QSize(50, 20))
+ self.setMaximumHeight(20)
+
+ # Broadcast value changed signal
+ self.sigValueChanged.connect(self.__emitPositionChanged)
+
+ # Position <-> Value conversion
+
+ def __positionToValue(self, position):
+ """Returns value corresponding to position
+
+ :param int position:
+ :rtype: float
+ """
+ min_, max_ = self.getMinimum(), self.getMaximum()
+ maxPos = self.__getCurrentPositionCount() - 1
+ return min_ + (max_ - min_) * int(position) / maxPos
+
+ def __valueToPosition(self, value):
+ """Returns closest position corresponding to value
+
+ :param float value:
+ :rtype: int
+ """
+ min_, max_ = self.getMinimum(), self.getMaximum()
+ maxPos = self.__getCurrentPositionCount() - 1
+ return int(0.5 + maxPos * (float(value) - min_) / (max_ - min_))
+
+ # Position (int) API
+
+ def __getCurrentPositionCount(self):
+ """Return current count (either position count or widget width
+
+ :rtype: int
+ """
+ count = self.getPositionCount()
+ if count is not None:
+ return count
+ else:
+ return max(2, self.width() - self._SLIDER_WIDTH)
+
+ def getPositionCount(self):
+ """Returns the number of positions.
+
+ :rtype: Union[int,None]"""
+ return self.__positionCount
+
+ def setPositionCount(self, count):
+ """Set the number of positions.
+
+ Slider values are eventually adjusted.
+
+ :param Union[int,None] count:
+ Either the number of possible positions or
+ None to allow any values.
+ :raise ValueError: If count <= 1
+ """
+ count = None if count is None else int(count)
+ if count != self.getPositionCount():
+ if count is not None and count <= 1:
+ raise ValueError("Position count must be higher than 1")
+ self.__positionCount = count
+ emit = self.__setValues(*self.getValues())
+ self.sigPositionCountChanged.emit(count)
+ if emit:
+ self.sigValueChanged.emit(*self.getValues())
+
+ def getFirstPosition(self):
+ """Returns first slider position
+
+ :rtype: int
+ """
+ return self.__valueToPosition(self.getFirstValue())
+
+ def setFirstPosition(self, position):
+ """Set the position of the first slider
+
+ The position is adjusted to valid values
+
+ :param int position:
+ """
+ self.setFirstValue(self.__positionToValue(position))
+
+ def getSecondPosition(self):
+ """Returns second slider position
+
+ :rtype: int
+ """
+ return self.__valueToPosition(self.getSecondValue())
+
+ def setSecondPosition(self, position):
+ """Set the position of the second slider
+
+ The position is adjusted to valid values
+
+ :param int position:
+ """
+ self.setSecondValue(self.__positionToValue(position))
+
+ def getPositions(self):
+ """Returns slider positions (first, second)
+
+ :rtype: List[int]
+ """
+ return self.getFirstPosition(), self.getSecondPosition()
+
+ def setPositions(self, first, second):
+ """Set the position of both sliders at once
+
+ First is clipped to the slider range: [0, max].
+ Second is clipped to valid values: [first, max]
+
+ :param int first:
+ :param int second:
+ """
+ self.setValues(self.__positionToValue(first),
+ self.__positionToValue(second))
+
+ # Value (float) API
+
+ def __emitPositionChanged(self, *args, **kwargs):
+ self.sigPositionChanged.emit(*self.getPositions())
+
+ def __rangeChanged(self):
+ """Handle change of value range"""
+ emit = self.__setValues(*self.getValues())
+ self.sigRangeChanged.emit(*self.getRange())
+ if emit:
+ self.sigValueChanged.emit(*self.getValues())
+
+ def getMinimum(self):
+ """Returns the minimum value of the slider range
+
+ :rtype: float
+ """
+ return self.__minValue
+
+ def setMinimum(self, minimum):
+ """Set the minimum value of the slider range.
+
+ It eventually adjusts maximum.
+ Slider positions remains unchanged and slider values are modified.
+
+ :param float minimum:
+ """
+ minimum = float(minimum)
+ if minimum != self.getMinimum():
+ if minimum > self.getMaximum():
+ self.__maxValue = minimum
+ self.__minValue = minimum
+ self.__rangeChanged()
+
+ def getMaximum(self):
+ """Returns the maximum value of the slider range
+
+ :rtype: float
+ """
+ return self.__maxValue
+
+ def setMaximum(self, maximum):
+ """Set the maximum value of the slider range
+
+ It eventually adjusts minimum.
+ Slider positions remains unchanged and slider values are modified.
+
+ :param float maximum:
+ """
+ maximum = float(maximum)
+ if maximum != self.getMaximum():
+ if maximum < self.getMinimum():
+ self.__minValue = maximum
+ self.__maxValue = maximum
+ self.__rangeChanged()
+
+ def getRange(self):
+ """Returns the range of values (min, max)
+
+ :rtype: List[float]
+ """
+ return self.getMinimum(), self.getMaximum()
+
+ def setRange(self, minimum, maximum):
+ """Set the range of values.
+
+ If maximum is lower than minimum, minimum is the only valid value.
+ Slider positions remains unchanged and slider values are modified.
+
+ :param float minimum:
+ :param float maximum:
+ """
+ minimum, maximum = float(minimum), float(maximum)
+ if minimum != self.getMinimum() or maximum != self.getMaximum():
+ self.__minValue = minimum
+ self.__maxValue = max(maximum, minimum)
+ self.__rangeChanged()
+
+ def getFirstValue(self):
+ """Returns the value of the first slider
+
+ :rtype: float
+ """
+ return self.__firstValue
+
+ def __clipFirstValue(self, value, max_=None):
+ """Clip first value to range and steps
+
+ :param float value:
+ :param float max_: Alternative maximum to use
+ """
+ if max_ is None:
+ max_ = self.getSecondValue()
+ value = min(max(self.getMinimum(), float(value)), max_)
+ if self.getPositionCount() is not None: # Clip to steps
+ value = self.__positionToValue(self.__valueToPosition(value))
+ return value
+
+ def setFirstValue(self, value):
+ """Set the value of the first slider
+
+ Value is clipped to valid values.
+
+ :param float value:
+ """
+ value = self.__clipFirstValue(value)
+ if value != self.getFirstValue():
+ self.__firstValue = value
+ self.update()
+ self.sigValueChanged.emit(*self.getValues())
+
+ def getSecondValue(self):
+ """Returns the value of the second slider
+
+ :rtype: float
+ """
+ return self.__secondValue
+
+ def __clipSecondValue(self, value):
+ """Clip second value to range and steps
+
+ :param float value:
+ """
+ value = min(max(self.getFirstValue(), float(value)), self.getMaximum())
+ if self.getPositionCount() is not None: # Clip to steps
+ value = self.__positionToValue(self.__valueToPosition(value))
+ return value
+
+ def setSecondValue(self, value):
+ """Set the value of the second slider
+
+ Value is clipped to valid values.
+
+ :param float value:
+ """
+ value = self.__clipSecondValue(value)
+ if value != self.getSecondValue():
+ self.__secondValue = value
+ self.update()
+ self.sigValueChanged.emit(*self.getValues())
+
+ def getValues(self):
+ """Returns value of both sliders at once
+
+ :return: (first value, second value)
+ :rtype: List[float]
+ """
+ return self.getFirstValue(), self.getSecondValue()
+
+ def setValues(self, first, second):
+ """Set values for both sliders at once
+
+ First is clipped to the slider range: [minimum, maximum].
+ Second is clipped to valid values: [first, maximum]
+
+ :param float first:
+ :param float second:
+ """
+ if self.__setValues(first, second):
+ self.sigValueChanged.emit(*self.getValues())
+
+ def __setValues(self, first, second):
+ """Set values for both sliders at once
+
+ First is clipped to the slider range: [minimum, maximum].
+ Second is clipped to valid values: [first, maximum]
+
+ :param float first:
+ :param float second:
+ :return: True if values has changed, False otherwise
+ :rtype: bool
+ """
+ first = self.__clipFirstValue(first, self.getMaximum())
+ second = self.__clipSecondValue(second)
+ values = first, second
+
+ if self.getValues() != values:
+ self.__firstValue, self.__secondValue = values
+ self.update()
+ return True
+ return False
+
+ # Groove API
+
+ def getGroovePixmap(self):
+ """Returns the pixmap displayed in the slider groove if any.
+
+ :rtype: Union[QPixmap,None]
+ """
+ return self.__pixmap
+
+ def setGroovePixmap(self, pixmap):
+ """Set the pixmap displayed in the slider groove.
+
+ :param Union[QPixmap,None] pixmap: The QPixmap to use or None to unset.
+ """
+ assert pixmap is None or isinstance(pixmap, qt.QPixmap)
+ self.__pixmap = pixmap
+ self.update()
+
+ def setGroovePixmapFromProfile(self, profile, colormap=None):
+ """Set the pixmap displayed in the slider groove from histogram values.
+
+ :param Union[numpy.ndarray,None] profile:
+ 1D array of values to display
+ :param Union[Colormap,str] colormap:
+ The colormap name or object to convert profile values to colors
+ """
+ if profile is None:
+ self.setSliderPixmap(None)
+ return
+
+ profile = numpy.array(profile, copy=False)
+
+ if profile.size == 0:
+ self.setSliderPixmap(None)
+ return
+
+ if colormap is None:
+ colormap = colors.Colormap()
+ elif isinstance(colormap, str):
+ colormap = colors.Colormap(name=colormap)
+ assert isinstance(colormap, colors.Colormap)
+
+ rgbImage = colormap.applyToData(profile.reshape(1, -1))[:, :, :3]
+ qimage = convertArrayToQImage(rgbImage)
+ qpixmap = qt.QPixmap.fromImage(qimage)
+ self.setGroovePixmap(qpixmap)
+
+ # Handle interaction
+
+ def mousePressEvent(self, event):
+ super(RangeSlider, self).mousePressEvent(event)
+
+ if event.buttons() == qt.Qt.LeftButton:
+ picked = None
+ for name in ('first', 'second'):
+ area = self.__sliderRect(name)
+ if area.contains(event.pos()):
+ picked = name
+ break
+
+ self.__moving = picked
+ self.__focus = picked
+ self.update()
+
+ def mouseMoveEvent(self, event):
+ super(RangeSlider, self).mouseMoveEvent(event)
+
+ if self.__moving is not None:
+ position = self.__xPixelToPosition(event.pos().x())
+ if self.__moving == 'first':
+ self.setFirstPosition(position)
+ else:
+ self.setSecondPosition(position)
+
+ def mouseReleaseEvent(self, event):
+ super(RangeSlider, self).mouseReleaseEvent(event)
+
+ if event.button() == qt.Qt.LeftButton and self.__moving is not None:
+ self.__moving = None
+ self.update()
+
+ def focusOutEvent(self, event):
+ if self.__focus is not None:
+ self.__focus = None
+ self.update()
+ super(RangeSlider, self).focusOutEvent(event)
+
+ def keyPressEvent(self, event):
+ key = event.key()
+ if event.modifiers() == qt.Qt.NoModifier and self.__focus is not None:
+ if key in (qt.Qt.Key_Left, qt.Qt.Key_Down):
+ if self.__focus == 'first':
+ self.setFirstPosition(self.getFirstPosition() - 1)
+ else:
+ self.setSecondPosition(self.getSecondPosition() - 1)
+ return # accept event
+ elif key in (qt.Qt.Key_Right, qt.Qt.Key_Up):
+ if self.__focus == 'first':
+ self.setFirstPosition(self.getFirstPosition() + 1)
+ else:
+ self.setSecondPosition(self.getSecondPosition() + 1)
+ return # accept event
+
+ super(RangeSlider, self).keyPressEvent(event)
+
+ # Handle resize
+
+ def resizeEvent(self, event):
+ super(RangeSlider, self).resizeEvent(event)
+
+ # If no step, signal position update when width change
+ if (self.getPositionCount() is None and
+ event.size().width() != event.oldSize().width()):
+ self.sigPositionChanged.emit(*self.getPositions())
+
+ # Handle repaint
+
+ def __xPixelToPosition(self, x):
+ """Convert position in pixel to slider position
+
+ :param int x: X in pixel coordinates
+ :rtype: int
+ """
+ sliderArea = self.__sliderAreaRect()
+ maxPos = self.__getCurrentPositionCount() - 1
+ position = maxPos * (x - sliderArea.left()) / (sliderArea.width() - 1)
+ return int(position + 0.5)
+
+ def __sliderRect(self, name):
+ """Returns rectangle corresponding to slider in pixels
+
+ :param str name: 'first' or 'second'
+ :rtype: QRect
+ :raise ValueError: If wrong name
+ """
+ assert name in ('first', 'second')
+ if name == 'first':
+ offset = - self._SLIDER_WIDTH
+ position = self.getFirstPosition()
+ elif name == 'second':
+ offset = 0
+ position = self.getSecondPosition()
+ else:
+ raise ValueError('Unknown name')
+
+ sliderArea = self.__sliderAreaRect()
+
+ maxPos = self.__getCurrentPositionCount() - 1
+ xOffset = int((sliderArea.width() - 1) * position / maxPos)
+ xPos = sliderArea.left() + xOffset + offset
+
+ return qt.QRect(xPos,
+ sliderArea.top(),
+ self._SLIDER_WIDTH,
+ sliderArea.height())
+
+ def __drawArea(self):
+ return self.rect().adjusted(self._SLIDER_WIDTH, 0,
+ -self._SLIDER_WIDTH, 0)
+
+ def __sliderAreaRect(self):
+ return self.__drawArea().adjusted(self._SLIDER_WIDTH / 2.,
+ 0,
+ -self._SLIDER_WIDTH / 2.,
+ 0)
+
+ def __pixMapRect(self):
+ return self.__sliderAreaRect().adjusted(0,
+ self._PIXMAP_VOFFSET,
+ 0,
+ -self._PIXMAP_VOFFSET)
+
+ def paintEvent(self, event):
+ painter = qt.QPainter(self)
+
+ style = qt.QApplication.style()
+
+ area = self.__drawArea()
+ pixmapRect = self.__pixMapRect()
+
+ option = qt.QStyleOptionProgressBar()
+ option.initFrom(self)
+ option.rect = area
+ option.state = ((self.isEnabled() and qt.QStyle.State_Enabled)
+ or qt.QStyle.State_None)
+ style.drawControl(qt.QStyle.CE_ProgressBarGroove,
+ option,
+ painter,
+ self)
+
+ painter.save()
+ pen = painter.pen()
+ pen.setWidth(1)
+ pen.setColor(qt.Qt.black if self.isEnabled() else qt.Qt.gray)
+ painter.setPen(pen)
+ painter.drawRect(pixmapRect.adjusted(-1, -1, 1, 1))
+ painter.restore()
+
+ if self.isEnabled() and self.__pixmap is not None:
+ painter.drawPixmap(area.adjusted(self._SLIDER_WIDTH / 2,
+ self._PIXMAP_VOFFSET,
+ -self._SLIDER_WIDTH / 2 + 1,
+ -self._PIXMAP_VOFFSET + 1),
+ self.__pixmap,
+ self.__pixmap.rect())
+
+ for name in ('first', 'second'):
+ rect = self.__sliderRect(name)
+ option = qt.QStyleOptionButton()
+ option.initFrom(self)
+ option.icon = self.__icons[name]
+ option.iconSize = rect.size() * 0.7
+ if option.state & qt.QStyle.State_MouseOver:
+ option.state ^= qt.QStyle.State_MouseOver
+ if self.__focus == name:
+ option.state |= qt.QStyle.State_HasFocus
+ elif option.state & qt.QStyle.State_HasFocus:
+ option.state ^= qt.QStyle.State_HasFocus
+ option.rect = rect
+ style.drawControl(
+ qt.QStyle.CE_PushButton, option, painter, self)
+
+ def sizeHint(self):
+ return qt.QSize(200, self.minimumHeight())
diff --git a/silx/gui/widgets/__init__.py b/silx/gui/widgets/__init__.py
index 034f4d3..9d0299d 100644
--- a/silx/gui/widgets/__init__.py
+++ b/silx/gui/widgets/__init__.py
@@ -1,7 +1,7 @@
# coding: utf-8
# /*##########################################################################
#
-# Copyright (c) 2016-2017 European Synchrotron Radiation Facility
+# Copyright (c) 2016-2018 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
@@ -22,6 +22,6 @@
# THE SOFTWARE.
#
# ###########################################################################*/
-"""This package provides a few simple Qt widgets that rely only on a Qt wrapper
-for Python (PyQt5, PyQt4 or PySide). No other optional dependencies of *silx*
-should be required."""
+"""This package provides a few simple Qt widgets that rely only on a Qt binding for Python.
+
+No other optional dependencies of *silx* should be required."""
diff --git a/silx/gui/widgets/test/__init__.py b/silx/gui/widgets/test/__init__.py
index 5e62393..8d179bc 100644
--- a/silx/gui/widgets/test/__init__.py
+++ b/silx/gui/widgets/test/__init__.py
@@ -31,6 +31,8 @@ from . import test_hierarchicaltableview
from . import test_printpreview
from . import test_framebrowser
from . import test_boxlayoutdockwidget
+from . import test_rangeslider
+from . import test_flowlayout
__authors__ = ["V. Valls", "P. Knobel"]
__license__ = "MIT"
@@ -47,5 +49,7 @@ def suite():
test_hierarchicaltableview.suite(),
test_framebrowser.suite(),
test_boxlayoutdockwidget.suite(),
+ test_rangeslider.suite(),
+ test_flowlayout.suite(),
])
return test_suite
diff --git a/silx/gui/widgets/test/test_boxlayoutdockwidget.py b/silx/gui/widgets/test/test_boxlayoutdockwidget.py
index 0df262b..9a93ca1 100644
--- a/silx/gui/widgets/test/test_boxlayoutdockwidget.py
+++ b/silx/gui/widgets/test/test_boxlayoutdockwidget.py
@@ -32,7 +32,7 @@ import unittest
from silx.gui.widgets.BoxLayoutDockWidget import BoxLayoutDockWidget
from silx.gui import qt
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
class TestBoxLayoutDockWidget(TestCaseQt):
diff --git a/silx/gui/widgets/test/test_flowlayout.py b/silx/gui/widgets/test/test_flowlayout.py
new file mode 100644
index 0000000..1497945
--- /dev/null
+++ b/silx/gui/widgets/test/test_flowlayout.py
@@ -0,0 +1,77 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018 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.
+#
+# ###########################################################################*/
+"""Tests for FlowLayout"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "02/08/2018"
+
+import unittest
+
+from silx.gui.widgets.FlowLayout import FlowLayout
+from silx.gui import qt
+from silx.gui.utils.testutils import TestCaseQt
+
+
+class TestFlowLayout(TestCaseQt):
+ """Tests for FlowLayout"""
+
+ def setUp(self):
+ """Create and show a widget"""
+ self.widget = qt.QWidget()
+ self.widget.show()
+ self.qWaitForWindowExposed(self.widget)
+
+ def tearDown(self):
+ """Delete widget"""
+ self.widget.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.widget.close()
+ del self.widget
+ self.qapp.processEvents()
+
+ def test(self):
+ """Basic tests"""
+ layout = FlowLayout()
+ self.widget.setLayout(layout)
+
+ layout.addWidget(qt.QLabel('first'))
+ layout.addWidget(qt.QLabel('second'))
+ self.assertEqual(layout.count(), 2)
+
+ layout.setHorizontalSpacing(10)
+ self.assertEqual(layout.horizontalSpacing(), 10)
+ layout.setVerticalSpacing(5)
+ self.assertEqual(layout.verticalSpacing(), 5)
+
+
+def suite():
+ loader = unittest.defaultTestLoader.loadTestsFromTestCase
+ test_suite = unittest.TestSuite()
+ test_suite.addTest(loader(TestFlowLayout))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/widgets/test/test_framebrowser.py b/silx/gui/widgets/test/test_framebrowser.py
index 9988d16..2dfd302 100644
--- a/silx/gui/widgets/test/test_framebrowser.py
+++ b/silx/gui/widgets/test/test_framebrowser.py
@@ -29,7 +29,7 @@ __date__ = "23/03/2018"
import unittest
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui.widgets.FrameBrowser import FrameBrowser
diff --git a/silx/gui/widgets/test/test_hierarchicaltableview.py b/silx/gui/widgets/test/test_hierarchicaltableview.py
index b3d37ed..9fad54d 100644
--- a/silx/gui/widgets/test/test_hierarchicaltableview.py
+++ b/silx/gui/widgets/test/test_hierarchicaltableview.py
@@ -29,7 +29,7 @@ __date__ = "07/04/2017"
import unittest
from .. import HierarchicalTableView
-from ...test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui import qt
diff --git a/silx/gui/widgets/test/test_periodictable.py b/silx/gui/widgets/test/test_periodictable.py
index c6bed81..3e7eb16 100644
--- a/silx/gui/widgets/test/test_periodictable.py
+++ b/silx/gui/widgets/test/test_periodictable.py
@@ -29,7 +29,7 @@ __date__ = "05/12/2016"
import unittest
from .. import PeriodicTable
-from ...test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui import qt
diff --git a/silx/gui/widgets/test/test_printpreview.py b/silx/gui/widgets/test/test_printpreview.py
index ecb165a..3c29171 100644
--- a/silx/gui/widgets/test/test_printpreview.py
+++ b/silx/gui/widgets/test/test_printpreview.py
@@ -30,7 +30,7 @@ __date__ = "19/07/2017"
import unittest
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui.widgets.PrintPreview import PrintPreviewDialog
from silx.gui import qt
diff --git a/silx/gui/widgets/test/test_rangeslider.py b/silx/gui/widgets/test/test_rangeslider.py
new file mode 100644
index 0000000..2829050
--- /dev/null
+++ b/silx/gui/widgets/test/test_rangeslider.py
@@ -0,0 +1,114 @@
+# coding: utf-8
+# /*##########################################################################
+#
+# Copyright (c) 2018 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.
+#
+# ###########################################################################*/
+"""Tests for RangeSlider"""
+
+__authors__ = ["T. Vincent"]
+__license__ = "MIT"
+__date__ = "01/08/2018"
+
+import unittest
+
+from silx.gui import qt, colors
+from silx.gui.widgets.RangeSlider import RangeSlider
+from silx.gui.utils.testutils import TestCaseQt
+from silx.utils.testutils import ParametricTestCase
+
+
+class TestRangeSlider(TestCaseQt, ParametricTestCase):
+ """Tests for TestRangeSlider"""
+
+ def setUp(self):
+ self.slider = RangeSlider()
+ self.slider.show()
+ self.qWaitForWindowExposed(self.slider)
+
+ def tearDown(self):
+ self.slider.setAttribute(qt.Qt.WA_DeleteOnClose)
+ self.slider.close()
+ del self.slider
+ self.qapp.processEvents()
+
+ def testRangeValue(self):
+ """Test slider range and values"""
+
+ # Play with range
+ self.slider.setRange(1, 2)
+ self.assertEqual(self.slider.getRange(), (1., 2.))
+ self.assertEqual(self.slider.getValues(), (1., 1.))
+
+ self.slider.setMinimum(-1)
+ self.assertEqual(self.slider.getRange(), (-1., 2.))
+ self.assertEqual(self.slider.getValues(), (1., 1.))
+
+ self.slider.setMaximum(0)
+ self.assertEqual(self.slider.getRange(), (-1., 0.))
+ self.assertEqual(self.slider.getValues(), (0., 0.))
+
+ # Play with values
+ self.slider.setFirstValue(-2.)
+ self.assertEqual(self.slider.getValues(), (-1., 0.))
+
+ self.slider.setFirstValue(-0.5)
+ self.assertEqual(self.slider.getValues(), (-0.5, 0.))
+
+ self.slider.setSecondValue(2.)
+ self.assertEqual(self.slider.getValues(), (-0.5, 0.))
+
+ self.slider.setSecondValue(-0.1)
+ self.assertEqual(self.slider.getValues(), (-0.5, -0.1))
+
+ def testStepCount(self):
+ """Test related to step count"""
+ self.slider.setPositionCount(11)
+ self.assertEqual(self.slider.getPositionCount(), 11)
+ self.slider.setFirstValue(0.32)
+ self.assertEqual(self.slider.getFirstValue(), 0.3)
+ self.assertEqual(self.slider.getFirstPosition(), 3)
+
+ self.slider.setPositionCount(3) # Value is adjusted
+ self.assertEqual(self.slider.getValues(), (0.5, 1.))
+ self.assertEqual(self.slider.getPositions(), (1, 2))
+
+ def testGroove(self):
+ """Test Groove pixmap"""
+ profile = list(range(100))
+
+ for cmap in ('jet', colors.Colormap('viridis')):
+ with self.subTest(str(cmap)):
+ self.slider.setGroovePixmapFromProfile(profile, cmap)
+ pixmap = self.slider.getGroovePixmap()
+ self.assertIsInstance(pixmap, qt.QPixmap)
+ self.assertEqual(pixmap.width(), len(profile))
+
+
+def suite():
+ loader = unittest.defaultTestLoader.loadTestsFromTestCase
+ test_suite = unittest.TestSuite()
+ test_suite.addTest(loader(TestRangeSlider))
+ return test_suite
+
+
+if __name__ == '__main__':
+ unittest.main(defaultTest='suite')
diff --git a/silx/gui/widgets/test/test_tablewidget.py b/silx/gui/widgets/test/test_tablewidget.py
index 5ad0a06..6822aef 100644
--- a/silx/gui/widgets/test/test_tablewidget.py
+++ b/silx/gui/widgets/test/test_tablewidget.py
@@ -30,7 +30,7 @@ __date__ = "05/12/2016"
import unittest
-from silx.gui.test.utils import TestCaseQt
+from silx.gui.utils.testutils import TestCaseQt
from silx.gui.widgets.TableWidget import TableWidget
diff --git a/silx/gui/widgets/test/test_threadpoolpushbutton.py b/silx/gui/widgets/test/test_threadpoolpushbutton.py
index a8618a4..e92eb02 100644
--- a/silx/gui/widgets/test/test_threadpoolpushbutton.py
+++ b/silx/gui/widgets/test/test_threadpoolpushbutton.py
@@ -32,8 +32,8 @@ __date__ = "17/01/2018"
import unittest
import time
from silx.gui import qt
-from silx.gui.test.utils import TestCaseQt
-from silx.gui.test.utils import SignalListener
+from silx.gui.utils.testutils import TestCaseQt
+from silx.gui.utils.testutils import SignalListener
from silx.gui.widgets.ThreadPoolPushButton import ThreadPoolPushButton
from silx.utils.testutils import TestLogging