diff options
Diffstat (limited to 'src/silx/gui/plot/items')
-rw-r--r-- | src/silx/gui/plot/items/__init__.py | 44 | ||||
-rw-r--r-- | src/silx/gui/plot/items/_arc_roi.py | 256 | ||||
-rw-r--r-- | src/silx/gui/plot/items/_band_roi.py | 18 | ||||
-rw-r--r-- | src/silx/gui/plot/items/_roi_base.py | 168 | ||||
-rw-r--r-- | src/silx/gui/plot/items/axis.py | 88 | ||||
-rw-r--r-- | src/silx/gui/plot/items/complex.py | 65 | ||||
-rw-r--r-- | src/silx/gui/plot/items/core.py | 409 | ||||
-rw-r--r-- | src/silx/gui/plot/items/curve.py | 209 | ||||
-rw-r--r-- | src/silx/gui/plot/items/histogram.py | 139 | ||||
-rw-r--r-- | src/silx/gui/plot/items/image.py | 165 | ||||
-rw-r--r-- | src/silx/gui/plot/items/image_aggregated.py | 30 | ||||
-rwxr-xr-x | src/silx/gui/plot/items/marker.py | 95 | ||||
-rw-r--r-- | src/silx/gui/plot/items/roi.py | 320 | ||||
-rw-r--r-- | src/silx/gui/plot/items/scatter.py | 464 | ||||
-rw-r--r-- | src/silx/gui/plot/items/shape.py | 99 |
15 files changed, 1549 insertions, 1020 deletions
diff --git a/src/silx/gui/plot/items/__init__.py b/src/silx/gui/plot/items/__init__.py index 6e26c64..bbb4220 100644 --- a/src/silx/gui/plot/items/__init__.py +++ b/src/silx/gui/plot/items/__init__.py @@ -31,22 +31,50 @@ __authors__ = ["T. Vincent"] __license__ = "MIT" __date__ = "22/06/2017" -from .core import (Item, DataItem, # noqa - LabelsMixIn, DraggableMixIn, ColormapMixIn, # noqa - SymbolMixIn, ColorMixIn, YAxisMixIn, FillMixIn, # noqa - AlphaMixIn, LineMixIn, ScatterVisualizationMixIn, # noqa - ComplexMixIn, ItemChangedType, PointsBase) # noqa +from .core import ( + Item, + DataItem, # noqa + LabelsMixIn, + DraggableMixIn, + ColormapMixIn, + LineGapColorMixIn, # noqa + SymbolMixIn, + ColorMixIn, + YAxisMixIn, + FillMixIn, # noqa + AlphaMixIn, + LineMixIn, + ScatterVisualizationMixIn, # noqa + ComplexMixIn, + ItemChangedType, + PointsBase, +) # noqa from .complex import ImageComplexData # noqa from .curve import Curve, CurveStyle # noqa from .histogram import Histogram # noqa -from .image import ImageBase, ImageData, ImageDataBase, ImageRgba, ImageStack, MaskImageData # noqa +from .image import ( + ImageBase, + ImageData, + ImageDataBase, + ImageRgba, + ImageStack, + MaskImageData, +) # noqa from .image_aggregated import ImageDataAggregated # noqa from .shape import Line, Shape, BoundingRect, XAxisExtent, YAxisExtent # noqa from .scatter import Scatter # noqa from .marker import MarkerBase, Marker, XMarker, YMarker # noqa from .axis import Axis, XAxis, YAxis, YRightAxis -DATA_ITEMS = (ImageComplexData, Curve, Histogram, ImageBase, Scatter, - BoundingRect, XAxisExtent, YAxisExtent) +DATA_ITEMS = ( + ImageComplexData, + Curve, + Histogram, + ImageBase, + Scatter, + BoundingRect, + XAxisExtent, + YAxisExtent, +) """Classes of items representing data and to consider to compute data bounds. """ diff --git a/src/silx/gui/plot/items/_arc_roi.py b/src/silx/gui/plot/items/_arc_roi.py index 40711b7..658573a 100644 --- a/src/silx/gui/plot/items/_arc_roi.py +++ b/src/silx/gui/plot/items/_arc_roi.py @@ -1,6 +1,6 @@ # /*########################################################################## # -# Copyright (c) 2018-2022 European Synchrotron Radiation Facility +# Copyright (c) 2018-2023 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -30,6 +30,8 @@ __date__ = "28/06/2018" import logging import numpy +import enum +from typing import Tuple from ... import utils from .. import items @@ -50,8 +52,18 @@ class _ArcGeometry: The aim is is to switch between consistent state without dealing with intermediate values. """ - def __init__(self, center, startPoint, endPoint, radius, - weight, startAngle, endAngle, closed=False): + + def __init__( + self, + center, + startPoint, + endPoint, + radius, + weight, + startAngle, + endAngle, + closed=False, + ): """Constructor for a consistent arc geometry. There is also specific class method to create different kind of arc @@ -68,46 +80,59 @@ class _ArcGeometry: @classmethod def createEmpty(cls): - """Create an arc geometry from an empty shape - """ + """Create an arc geometry from an empty shape""" zero = numpy.array([0, 0]) return cls(zero, zero.copy(), zero.copy(), 0, 0, 0, 0) @classmethod def createRect(cls, startPoint, endPoint, weight): - """Create an arc geometry from a definition of a rectangle - """ + """Create an arc geometry from a definition of a rectangle""" return cls(None, startPoint, endPoint, None, weight, None, None, False) @classmethod - def createCircle(cls, center, startPoint, endPoint, radius, - weight, startAngle, endAngle): - """Create an arc geometry from a definition of a circle - """ - return cls(center, startPoint, endPoint, radius, - weight, startAngle, endAngle, True) + def createCircle( + cls, center, startPoint, endPoint, radius, weight, startAngle, endAngle + ): + """Create an arc geometry from a definition of a circle""" + return cls( + center, startPoint, endPoint, radius, weight, startAngle, endAngle, True + ) def withWeight(self, weight): - """Return a new geometry based on this object, with a specific weight - """ - return _ArcGeometry(self.center, self.startPoint, self.endPoint, - self.radius, weight, - self.startAngle, self.endAngle, self._closed) + """Return a new geometry based on this object, with a specific weight""" + return _ArcGeometry( + self.center, + self.startPoint, + self.endPoint, + self.radius, + weight, + self.startAngle, + self.endAngle, + self._closed, + ) def withRadius(self, radius): """Return a new geometry based on this object, with a specific radius. The weight and the center is conserved. """ - startPoint = self.center + (self.startPoint - self.center) / self.radius * radius + startPoint = ( + self.center + (self.startPoint - self.center) / self.radius * radius + ) endPoint = self.center + (self.endPoint - self.center) / self.radius * radius - return _ArcGeometry(self.center, startPoint, endPoint, - radius, self.weight, - self.startAngle, self.endAngle, self._closed) + return _ArcGeometry( + self.center, + startPoint, + endPoint, + radius, + self.weight, + self.startAngle, + self.endAngle, + self._closed, + ) def withStartAngle(self, startAngle): - """Return a new geometry based on this object, with a specific start angle - """ + """Return a new geometry based on this object, with a specific start angle""" vector = numpy.array([numpy.cos(startAngle), numpy.sin(startAngle)]) startPoint = self.center + vector * self.radius @@ -131,8 +156,7 @@ class _ArcGeometry: ) def withEndAngle(self, endAngle): - """Return a new geometry based on this object, with a specific end angle - """ + """Return a new geometry based on this object, with a specific end angle""" vector = numpy.array([numpy.cos(endAngle), numpy.sin(endAngle)]) endPoint = self.center + vector * self.radius @@ -161,9 +185,16 @@ class _ArcGeometry: center = None if self.center is None else self.center + delta startPoint = None if self.startPoint is None else self.startPoint + delta endPoint = None if self.endPoint is None else self.endPoint + delta - return _ArcGeometry(center, startPoint, endPoint, - self.radius, self.weight, - self.startAngle, self.endAngle, self._closed) + return _ArcGeometry( + center, + startPoint, + endPoint, + self.radius, + self.weight, + self.startAngle, + self.endAngle, + self._closed, + ) def getKind(self): """Returns the kind of shape defined""" @@ -191,14 +222,18 @@ class _ArcGeometry: return self._closed def __str__(self): - return str((self.center, - self.startPoint, - self.endPoint, - self.radius, - self.weight, - self.startAngle, - self.endAngle, - self._closed)) + return str( + ( + self.center, + self.startPoint, + self.endPoint, + self.radius, + self.weight, + self.startAngle, + self.endAngle, + self._closed, + ) + ) class ArcROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn): @@ -210,19 +245,37 @@ class ArcROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn): - 1 anchor to translate the shape. """ - ICON = 'add-shape-arc' - NAME = 'arc ROI' + ICON = "add-shape-arc" + NAME = "arc ROI" SHORT_NAME = "arc" """Metadata for this kind of ROI""" _plotShape = "line" """Plot shape which is used for the first interaction""" - ThreePointMode = RoiInteractionMode("3 points", "Provides 3 points to define the main radius circle") - PolarMode = RoiInteractionMode("Polar", "Provides anchors to edit the ROI in polar coords") + ThreePointMode = RoiInteractionMode( + "3 points", "Provides 3 points to define the main radius circle" + ) + PolarMode = RoiInteractionMode( + "Polar", "Provides anchors to edit the ROI in polar coords" + ) # FIXME: MoveMode was designed cause there is too much anchors # FIXME: It would be good replace it by a dnd on the shape - MoveMode = RoiInteractionMode("Translation", "Provides anchors to only move the ROI") + MoveMode = RoiInteractionMode( + "Translation", "Provides anchors to only move the ROI" + ) + + class Role(enum.Enum): + """Identify a set of roles which can be used for now to reach some positions""" + + START = 0 + """Location of the anchor at the start of the arc""" + STOP = 1 + """Location of the anchor at the stop of the arc""" + MIDDLE = 2 + """Location of the anchor at the middle of the arc""" + CENTER = 3 + """Location of the center of the circle""" def __init__(self, parent=None): HandleBasedROI.__init__(self, parent=parent) @@ -265,22 +318,28 @@ class ArcROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn): :param RoiInteractionMode modeId: """ if modeId is self.ThreePointMode: + self._handleStart.setVisible(True) + self._handleEnd.setVisible(True) + self._handleWeight.setVisible(True) self._handleStart.setSymbol("s") self._handleMid.setSymbol("s") self._handleEnd.setSymbol("s") self._handleWeight.setSymbol("d") self._handleMove.setSymbol("+") elif modeId is self.PolarMode: + self._handleStart.setVisible(True) + self._handleEnd.setVisible(True) + self._handleWeight.setVisible(True) self._handleStart.setSymbol("o") self._handleMid.setSymbol("o") self._handleEnd.setSymbol("o") self._handleWeight.setSymbol("d") self._handleMove.setSymbol("+") elif modeId is self.MoveMode: - self._handleStart.setSymbol("") + self._handleStart.setVisible(False) + self._handleEnd.setVisible(False) + self._handleWeight.setVisible(False) self._handleMid.setSymbol("+") - self._handleEnd.setSymbol("") - self._handleWeight.setSymbol("") self._handleMove.setSymbol("+") else: assert False @@ -302,7 +361,7 @@ class ArcROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn): self.__shape.setLineWidth(style.getLineWidth()) def setFirstShapePoints(self, points): - """"Initialize the ROI using the points from the first interaction. + """Initialize the ROI using the points from the first interaction. This interaction is constrained by the plot API and only supports few shapes. @@ -367,7 +426,9 @@ class ArcROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn): elif geometry.center is not None: midAngle = (geometry.startAngle + geometry.endAngle) * 0.5 vector = numpy.array([numpy.cos(midAngle), numpy.sin(midAngle)]) - weightPos = geometry.center + (geometry.radius + geometry.weight * 0.5) * vector + weightPos = ( + geometry.center + (geometry.radius + geometry.weight * 0.5) * vector + ) with utils.blockSignals(self._handleWeight): self._handleWeight.setPosition(*weightPos) @@ -393,7 +454,9 @@ class ArcROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn): self._updateWeightHandle() self._updateShape() - def _updateCurvature(self, start, mid, end, updateCurveHandles, checkClosed=False, updateStart=False): + def _updateCurvature( + self, start, mid, end, updateCurveHandles, checkClosed=False, updateStart=False + ): """Update the curvature using 3 control points in the curve :param bool updateCurveHandles: If False curve handles are already at @@ -418,7 +481,9 @@ class ArcROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn): self._handleEnd.setPosition(*end) weight = self._geometry.weight - geometry = self._createGeometryFromControlPoints(start, mid, end, weight, closed=closed) + geometry = self._createGeometryFromControlPoints( + start, mid, end, weight, closed=closed + ) self._geometry = geometry self._updateWeightHandle() @@ -433,10 +498,10 @@ class ArcROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn): sign = 1 if geometry.startAngle < geometry.endAngle else -1 if updateStart: geometry.startPoint = geometry.endPoint - geometry.startAngle = geometry.endAngle - sign * 2*numpy.pi + geometry.startAngle = geometry.endAngle - sign * 2 * numpy.pi else: geometry.endPoint = geometry.startPoint - geometry.endAngle = geometry.startAngle + sign * 2*numpy.pi + geometry.endAngle = geometry.startAngle + sign * 2 * numpy.pi def handleDragUpdated(self, handle, origin, previous, current): modeId = self.getInteractionMode() @@ -445,8 +510,12 @@ class ArcROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn): mid = numpy.array(self._handleMid.getPosition()) end = numpy.array(self._handleEnd.getPosition()) self._updateCurvature( - current, mid, end, checkClosed=True, updateStart=True, - updateCurveHandles=False + current, + mid, + end, + checkClosed=True, + updateStart=True, + updateCurveHandles=False, ) elif modeId is self.PolarMode: v = current - self._geometry.center @@ -477,8 +546,12 @@ class ArcROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn): start = numpy.array(self._handleStart.getPosition()) mid = numpy.array(self._handleMid.getPosition()) self._updateCurvature( - start, mid, current, checkClosed=True, updateStart=False, - updateCurveHandles=False + start, + mid, + current, + checkClosed=True, + updateStart=False, + updateCurveHandles=False, ) elif modeId is self.PolarMode: v = current - self._geometry.center @@ -511,8 +584,7 @@ class ArcROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn): return abs(point1[0] - point2[0]) + abs(point1[1] - point2[1]) < 15 def _normalizeGeometry(self): - """Keep the same phisical geometry, but with normalized parameters. - """ + """Keep the same phisical geometry, but with normalized parameters.""" geometry = self._geometry if geometry.weight * 0.5 >= geometry.radius: radius = (geometry.weight * 0.5 + geometry.radius) * 0.5 @@ -582,8 +654,9 @@ class ArcROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn): if endAngle > startAngle: endAngle -= 2 * numpy.pi - return _ArcGeometry(center, start, end, - radius, weight, startAngle, endAngle) + return _ArcGeometry( + center, start, end, radius, weight, startAngle, endAngle + ) def _createShapeFromGeometry(self, geometry): kind = geometry.getKind() @@ -595,11 +668,14 @@ class ArcROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn): distance = numpy.linalg.norm(normal) if distance != 0: normal /= distance - points = numpy.array([ - geometry.startPoint + normal * geometry.weight * 0.5, - geometry.endPoint + normal * geometry.weight * 0.5, - geometry.endPoint - normal * geometry.weight * 0.5, - geometry.startPoint - normal * geometry.weight * 0.5]) + points = numpy.array( + [ + geometry.startPoint + normal * geometry.weight * 0.5, + geometry.endPoint + normal * geometry.weight * 0.5, + geometry.endPoint - normal * geometry.weight * 0.5, + geometry.startPoint - normal * geometry.weight * 0.5, + ] + ) elif kind == "point": # It is not an arc # but we can display it as an intermediate shape @@ -712,7 +788,29 @@ class ArcROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn): geometry = self._geometry if geometry.center is None: raise ValueError("This ROI can't be represented as a section of circle") - return geometry.center, self.getInnerRadius(), self.getOuterRadius(), geometry.startAngle, geometry.endAngle + return ( + geometry.center, + self.getInnerRadius(), + self.getOuterRadius(), + geometry.startAngle, + geometry.endAngle, + ) + + def getPosition(self, role: Role = Role.CENTER) -> Tuple[float, float]: + """Returns a position by it's role. + + By default returns the center of the circle of the arc ROI. + """ + if role == self.Role.START: + return self._handleStart.getPosition() + if role == self.Role.STOP: + return self._handleEnd.getPosition() + if role == self.Role.MIDDLE: + return self._handleMid.getPosition() + if role == self.Role.CENTER: + p = self.getCenter() + return p[0], p[1] + raise ValueError(f"{role} is not supported") def isClosed(self): """Returns true if the arc is a closed shape, like a circle or a donut. @@ -795,9 +893,16 @@ class ArcROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn): vector = numpy.array([numpy.cos(endAngle), numpy.sin(endAngle)]) endPoint = center + vector * radius - geometry = _ArcGeometry(center, startPoint, endPoint, - radius, weight, - startAngle, endAngle, closed=None) + geometry = _ArcGeometry( + center, + startPoint, + endPoint, + radius, + weight, + startAngle, + endAngle, + closed=None, + ) self._geometry = geometry self._updateHandles() @@ -805,7 +910,9 @@ class ArcROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn): def contains(self, position): # first check distance, fastest center = self.getCenter() - distance = numpy.sqrt((position[1] - center[1]) ** 2 + ((position[0] - center[0])) ** 2) + distance = numpy.sqrt( + (position[1] - center[1]) ** 2 + ((position[0] - center[0])) ** 2 + ) is_in_distance = self.getInnerRadius() <= distance <= self.getOuterRadius() if not is_in_distance: return False @@ -871,8 +978,15 @@ class ArcROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn): def __str__(self): try: center, innerRadius, outerRadius, startAngle, endAngle = self.getGeometry() - params = center[0], center[1], innerRadius, outerRadius, startAngle, endAngle - params = 'center: %f %f; radius: %f %f; angles: %f %f' % params + params = ( + center[0], + center[1], + innerRadius, + outerRadius, + startAngle, + endAngle, + ) + params = "center: %f %f; radius: %f %f; angles: %f %f" % params except ValueError: params = "invalid" return "%s(%s)" % (self.__class__.__name__, params) diff --git a/src/silx/gui/plot/items/_band_roi.py b/src/silx/gui/plot/items/_band_roi.py index a60a177..0d2ad4e 100644 --- a/src/silx/gui/plot/items/_band_roi.py +++ b/src/silx/gui/plot/items/_band_roi.py @@ -100,7 +100,7 @@ class BandGeometry(NamedTuple): def slope(self) -> float: """Slope of the line (begin, end), infinity for a vertical line""" if self.begin.x == self.end.x: - return float('inf') + return float("inf") return (self.end.y - self.begin.y) / (self.end.x - self.begin.x) @property @@ -309,18 +309,20 @@ class BandROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn): ) @staticmethod - def __snap(point: Tuple[float, float], fixed: Tuple[float, float]) -> Tuple[float, float]: + def __snap( + point: Tuple[float, float], fixed: Tuple[float, float] + ) -> Tuple[float, float]: """Snap point so that vector [point, fixed] snap to direction 0, 45 or 90 degrees :return: the snapped point position. """ vector = point[0] - fixed[0], point[1] - fixed[1] angle = numpy.arctan2(vector[1], vector[0]) - snapAngle = numpy.pi/4 * numpy.round(angle / (numpy.pi/4)) + snapAngle = numpy.pi / 4 * numpy.round(angle / (numpy.pi / 4)) length = numpy.linalg.norm(vector) return ( fixed[0] + length * numpy.cos(snapAngle), - fixed[1] + length * numpy.sin(snapAngle) + fixed[1] + length * numpy.sin(snapAngle), ) def handleDragUpdated(self, handle, origin, previous, current): @@ -353,12 +355,16 @@ class BandROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn): def __handleWidthUpConstraint(self, x: float, y: float) -> Tuple[float, float]: geometry = self.getGeometry() - offset = max(0, numpy.dot(geometry.normal, numpy.array((x, y)) - geometry.center)) + offset = max( + 0, numpy.dot(geometry.normal, numpy.array((x, y)) - geometry.center) + ) return tuple(geometry.center + offset * numpy.array(geometry.normal)) def __handleWidthDownConstraint(self, x: float, y: float) -> Tuple[float, float]: geometry = self.getGeometry() - offset = max(0, -numpy.dot(geometry.normal, numpy.array((x, y)) - geometry.center)) + offset = max( + 0, -numpy.dot(geometry.normal, numpy.array((x, y)) - geometry.center) + ) return tuple(geometry.center - offset * numpy.array(geometry.normal)) @docstring(_RegionOfInterestBase) diff --git a/src/silx/gui/plot/items/_roi_base.py b/src/silx/gui/plot/items/_roi_base.py index 765a538..43c5381 100644 --- a/src/silx/gui/plot/items/_roi_base.py +++ b/src/silx/gui/plot/items/_roi_base.py @@ -1,6 +1,6 @@ # /*########################################################################## # -# Copyright (c) 2018-2020 European Synchrotron Radiation Facility +# Copyright (c) 2018-2023 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -37,14 +37,14 @@ __date__ = "28/06/2018" import logging import numpy import weakref +import functools +from typing import Optional from ....utils.weakref import WeakList from ... import qt from .. import items from ..items import core from ...colors import rgba -import silx.utils.deprecation -from ....utils.proxy import docstring logger = logging.getLogger(__name__) @@ -68,8 +68,10 @@ class _RegionOfInterestBase(qt.QObject): """ def __init__(self, parent=None): - qt.QObject.__init__(self, parent=parent) - self.__name = '' + qt.QObject.__init__(self) + if parent is not None: + self.setParent(parent) + self.__name = "" def getName(self): """Returns the name of the ROI @@ -120,10 +122,12 @@ class RoiInteractionMode(object): @property def label(self): + """Short name""" return self._label @property def description(self): + """Longer description of the interaction mode""" return self._description @@ -188,6 +192,28 @@ class InteractionModeMixIn(object): """ return self.__modeId + def createMenuForInteractionMode(self, parent: qt.QWidget) -> qt.QMenu: + """Create a menu providing access to the different interaction modes""" + availableModes = self.availableInteractionModes() + currentMode = self.getInteractionMode() + submenu = qt.QMenu(parent) + modeGroup = qt.QActionGroup(parent) + modeGroup.setExclusive(True) + for mode in availableModes: + action = qt.QAction(parent) + action.setText(mode.label) + action.setToolTip(mode.description) + action.setCheckable(True) + if mode is currentMode: + action.setChecked(True) + else: + callback = functools.partial(self.setInteractionMode, mode) + action.triggered.connect(callback) + modeGroup.addAction(action) + submenu.addAction(action) + submenu.setTitle("Interaction mode") + return submenu + class RegionOfInterest(_RegionOfInterestBase, core.HighlightedMixIn): """Object describing a region of interest in a plot. @@ -196,10 +222,10 @@ class RegionOfInterest(_RegionOfInterestBase, core.HighlightedMixIn): The RegionOfInterestManager that created this object """ - _DEFAULT_LINEWIDTH = 1. + _DEFAULT_LINEWIDTH = 1.0 """Default line width of the curve""" - _DEFAULT_LINESTYLE = '-' + _DEFAULT_LINESTYLE = "-" """Default line style of the curve""" _DEFAULT_HIGHLIGHT_STYLE = items.CurveStyle(linewidth=2) @@ -225,15 +251,18 @@ class RegionOfInterest(_RegionOfInterestBase, core.HighlightedMixIn): def __init__(self, parent=None): # Avoid circular dependency from ..tools import roi as roi_tools + assert parent is None or isinstance(parent, roi_tools.RegionOfInterestManager) + # Must be done before _RegionOfInterestBase.__init__ + self._child = WeakList() _RegionOfInterestBase.__init__(self, parent) core.HighlightedMixIn.__init__(self) - self._color = rgba('red') + self.__text = None + self._color = rgba("red") self._editable = False self._selectable = False self._focusProxy = None self._visible = True - self._child = WeakList() def _connectToPlot(self, plot): """Called after connection to a plot""" @@ -263,8 +292,11 @@ class RegionOfInterest(_RegionOfInterestBase, core.HighlightedMixIn): """ # Avoid circular dependency from ..tools import roi as roi_tools - if (parent is not None and not isinstance(parent, roi_tools.RegionOfInterestManager)): - raise ValueError('Unsupported parent') + + if parent is not None and not isinstance( + parent, roi_tools.RegionOfInterestManager + ): + raise ValueError("Unsupported parent") previousParent = self.parent() if previousParent is not None: @@ -292,7 +324,7 @@ class RegionOfInterest(_RegionOfInterestBase, core.HighlightedMixIn): """ assert item is not None self._child.append(item) - if item.getName() == '': + if item.getName() == "": self._setItemName(item) manager = self.parent() if manager is not None: @@ -352,26 +384,6 @@ class RegionOfInterest(_RegionOfInterestBase, core.HighlightedMixIn): self._color = color self._updated(items.ItemChangedType.COLOR) - @silx.utils.deprecation.deprecated(reason='API modification', - replacement='getName()', - since_version=0.12) - def getLabel(self): - """Returns the label displayed for this ROI. - - :rtype: str - """ - return self.getName() - - @silx.utils.deprecation.deprecated(reason='API modification', - replacement='setName(name)', - since_version=0.12) - def setLabel(self, label): - """Set the label displayed with this ROI. - - :param str label: The text label to display - """ - self.setName(name=label) - def isEditable(self): """Returns whether the ROI is editable by the user or not. @@ -457,6 +469,26 @@ class RegionOfInterest(_RegionOfInterestBase, core.HighlightedMixIn): self._visible = visible self._updated(items.ItemChangedType.VISIBLE) + def getText(self) -> str: + """Returns the currently displayed text for this ROI""" + return self.getName() if self.__text is None else self.__text + + def setText(self, text: Optional[str] = None) -> None: + """Set the displayed text for this ROI. + + If None (the default), the ROI name is used. + """ + if self.__text != text: + self.__text = text + self._updated(items.ItemChangedType.TEXT) + + def _updateText(self, text: str) -> None: + """Update the text displayed by this ROI + + Override in subclass to custom text display + """ + pass + @classmethod def showFirstInteractionShape(cls): """Returns True if the shape created by the first interaction and @@ -478,7 +510,7 @@ class RegionOfInterest(_RegionOfInterestBase, core.HighlightedMixIn): return cls._plotShape def setFirstShapePoints(self, points): - """"Initialize the ROI using the points from the first interaction. + """Initialize the ROI using the points from the first interaction. This interaction is constrained by the plot API and only supports few shapes. @@ -486,13 +518,11 @@ class RegionOfInterest(_RegionOfInterestBase, core.HighlightedMixIn): raise NotImplementedError() def creationStarted(self): - """"Called when the ROI creation interaction was started. - """ + """Called when the ROI creation interaction was started.""" pass def creationFinalized(self): - """"Called when the ROI creation interaction was finalized. - """ + """Called when the ROI creation interaction was finalized.""" pass def _updateItemProperty(self, event, source, destination): @@ -544,15 +574,23 @@ class RegionOfInterest(_RegionOfInterestBase, core.HighlightedMixIn): assert False def _updated(self, event=None, checkVisibility=True): - if event == items.ItemChangedType.HIGHLIGHTED: + if event == items.ItemChangedType.TEXT: + self._updateText(self.getText()) + elif event == items.ItemChangedType.HIGHLIGHTED: + for item in self.getItems(): + zoffset = 1000 if self.isHighlighted() else 0 + item.setZValue(item._DEFAULT_Z_LAYER + zoffset) + style = self.getCurrentStyle() self._updatedStyle(event, style) else: - styleEvents = [items.ItemChangedType.COLOR, - items.ItemChangedType.LINE_STYLE, - items.ItemChangedType.LINE_WIDTH, - items.ItemChangedType.SYMBOL, - items.ItemChangedType.SYMBOL_SIZE] + styleEvents = [ + items.ItemChangedType.COLOR, + items.ItemChangedType.LINE_STYLE, + items.ItemChangedType.LINE_WIDTH, + items.ItemChangedType.SYMBOL, + items.ItemChangedType.SYMBOL_SIZE, + ] if self.isHighlighted(): styleEvents.append(items.ItemChangedType.HIGHLIGHTED_STYLE) @@ -562,7 +600,11 @@ class RegionOfInterest(_RegionOfInterestBase, core.HighlightedMixIn): super(RegionOfInterest, self)._updated(event, checkVisibility) - def _updatedStyle(self, event, style): + # Displayed text has changed, send a text event + if event == items.ItemChangedType.NAME and self.__text is None: + self._updated(items.ItemChangedType.TEXT, checkVisibility) + + def _updatedStyle(self, event, style: items.CurveStyle): """Called when the current displayed style of the ROI was changed. :param event: The event responsible of the change of the style @@ -570,7 +612,7 @@ class RegionOfInterest(_RegionOfInterestBase, core.HighlightedMixIn): """ pass - def getCurrentStyle(self): + def getCurrentStyle(self) -> items.CurveStyle: """Returns the current curve style. Curve style depends on curve highlighting @@ -588,7 +630,7 @@ class RegionOfInterest(_RegionOfInterestBase, core.HighlightedMixIn): baseSymbol = self.getSymbol() baseSymbolsize = self.getSymbolSize() else: - baseSymbol = 'o' + baseSymbol = "o" baseSymbolsize = 1 if self.isHighlighted(): @@ -604,13 +646,16 @@ class RegionOfInterest(_RegionOfInterestBase, core.HighlightedMixIn): linestyle=baseLinestyle if linestyle is None else linestyle, linewidth=baseLinewidth if linewidth is None else linewidth, symbol=baseSymbol if symbol is None else symbol, - symbolsize=baseSymbolsize if symbolsize is None else symbolsize) + symbolsize=baseSymbolsize if symbolsize is None else symbolsize, + ) else: - return items.CurveStyle(color=baseColor, - linestyle=baseLinestyle, - linewidth=baseLinewidth, - symbol=baseSymbol, - symbolsize=baseSymbolsize) + return items.CurveStyle( + color=baseColor, + linestyle=baseLinestyle, + linewidth=baseLinewidth, + symbol=baseSymbol, + symbolsize=baseSymbolsize, + ) def _editingStarted(self): assert self._editable is True @@ -619,6 +664,10 @@ class RegionOfInterest(_RegionOfInterestBase, core.HighlightedMixIn): def _editingFinished(self): self.sigEditingFinished.emit() + def populateContextMenu(self, menu: qt.QMenu): + """Populate a menu used as a context menu""" + pass + class HandleBasedROI(RegionOfInterest): """Manage a ROI based on a set of handles""" @@ -730,9 +779,7 @@ class HandleBasedROI(RegionOfInterest): See :class:`~silx.gui.plot.items.Item._updated` """ - if event == items.ItemChangedType.NAME: - self._updateText(self.getName()) - elif event == items.ItemChangedType.VISIBLE: + if event == items.ItemChangedType.VISIBLE: for item, role in self._handles: visible = self.isVisible() editionVisible = visible and self.isEditable() @@ -754,9 +801,9 @@ class HandleBasedROI(RegionOfInterest): color = rgba(self.getColor()) handleColor = self._computeHandleColor(color) for item, role in self._handles: - if role == 'user': + if role == "user": pass - elif role == 'label': + elif role == "label": item.setColor(color) else: item.setColor(handleColor) @@ -825,10 +872,3 @@ class HandleBasedROI(RegionOfInterest): :rtype: Union[numpy.array,Tuple,List] """ return color[:3] + (0.5,) - - def _updateText(self, text): - """Update the text displayed by this ROI - - :param str text: A text - """ - pass diff --git a/src/silx/gui/plot/items/axis.py b/src/silx/gui/plot/items/axis.py index fa3f6d7..1ae1ef1 100644 --- a/src/silx/gui/plot/items/axis.py +++ b/src/silx/gui/plot/items/axis.py @@ -1,6 +1,6 @@ # /*########################################################################## # -# Copyright (c) 2017-2022 European Synchrotron Radiation Facility +# Copyright (c) 2017-2023 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -24,28 +24,28 @@ """This module provides the class for axes of the :class:`PlotWidget`. """ +from __future__ import annotations + __authors__ = ["V. Valls"] __license__ = "MIT" __date__ = "22/11/2018" import datetime as dt import enum -import logging +from typing import Optional import dateutil.tz -import numpy +from ....utils.proxy import docstring from ... import qt from .. import _utils -_logger = logging.getLogger(__name__) - - class TickMode(enum.Enum): """Determines if ticks are regular number or datetimes.""" - DEFAULT = 0 # Ticks are regular numbers - TIME_SERIES = 1 # Ticks are datetime objects + + DEFAULT = 0 # Ticks are regular numbers + TIME_SERIES = 1 # Ticks are datetime objects class Axis(qt.QObject): @@ -53,6 +53,7 @@ class Axis(qt.QObject): Note: This is an abstract class. """ + # States are half-stored on the backend of the plot, and half-stored on this # object. # TODO It would be good to store all the states of an axis in this object. @@ -91,10 +92,10 @@ class Axis(qt.QObject): self._scale = self.LINEAR self._isAutoScale = True # Store default labels provided to setGraph[X|Y]Label - self._defaultLabel = '' + self._defaultLabel = "" # Store currently displayed labels # Current label can differ from input one with active curve handling - self._currentLabel = '' + self._currentLabel = "" def _getPlot(self): """Returns the PlotWidget this Axis belongs to. @@ -150,7 +151,12 @@ class Axis(qt.QObject): :rtype: 2-tuple of float """ return _utils.checkAxisLimits( - vmin, vmax, isLog=self._isLogarithmic(), name=self._defaultLabel) + vmin, vmax, isLog=self._isLogarithmic(), name=self._defaultLabel + ) + + def _getDataRange(self) -> Optional[tuple[float, float]]: + """Returns the range of data items over this axis as (vmin, vmax)""" + raise NotImplementedError() def isInverted(self): """Return True if the axis is inverted (top to bottom for the y-axis), @@ -172,6 +178,10 @@ class Axis(qt.QObject): return raise NotImplementedError() + def isVisible(self) -> bool: + """Returns whether the axis is displayed or not""" + return True + def getLabel(self): """Return the current displayed label of this axis. @@ -199,10 +209,10 @@ class Axis(qt.QObject): :param str label: Currently displayed label """ - if label is None or label == '': + if label is None or label == "": label = self._defaultLabel if label is None: - label = '' + label = "" self._currentLabel = label self._internalSetCurrentLabel(label) @@ -218,7 +228,7 @@ class Axis(qt.QObject): :param str scale: Name of the scale ("log", or "linear") """ - assert(scale in self._SCALES) + assert scale in self._SCALES if self._scale == scale: return @@ -227,6 +237,8 @@ class Axis(qt.QObject): self._scale = scale + vmin, vmax = self.getLimits() + # TODO hackish way of forcing update of curves and images plot = self._getPlot() for item in plot.getItems(): @@ -235,13 +247,20 @@ class Axis(qt.QObject): if scale == self.LOGARITHMIC: self._internalSetLogarithmic(True) + if vmin <= 0: + dataRange = self._getDataRange() + if dataRange is None: + self.setLimits(1.0, 100.0) + else: + if vmax > 0 and dataRange[0] < vmax: + self.setLimits(dataRange[0], vmax) + else: + self.setLimits(*dataRange) elif scale == self.LINEAR: self._internalSetLogarithmic(False) else: raise ValueError("Scale %s unsupported" % scale) - plot._forceResetZoom() - self.sigScaleChanged.emit(self._scale) if emitLog: self._sigLogarithmicChanged.emit(self._scale == self.LOGARITHMIC) @@ -328,7 +347,7 @@ class Axis(qt.QObject): plot = self._getPlot() xMin, xMax = plot.getXAxis().getLimits() yMin, yMax = plot.getYAxis().getLimits() - y2Min, y2Max = plot.getYAxis('right').getLimits() + y2Min, y2Max = plot.getYAxis("right").getLimits() plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max) return updated @@ -351,7 +370,7 @@ class Axis(qt.QObject): plot = self._getPlot() xMin, xMax = plot.getXAxis().getLimits() yMin, yMax = plot.getYAxis().getLimits() - y2Min, y2Max = plot.getYAxis('right').getLimits() + y2Min, y2Max = plot.getYAxis("right").getLimits() plot.setLimits(xMin, xMax, yMin, yMax, y2Min, y2Max) return updated @@ -368,7 +387,7 @@ class XAxis(Axis): def setTimeZone(self, tz): if isinstance(tz, str) and tz.upper() == "UTC": tz = dateutil.tz.tzutc() - elif not(tz is None or isinstance(tz, dt.tzinfo)): + elif not (tz is None or isinstance(tz, dt.tzinfo)): raise TypeError("tz must be a dt.tzinfo object, None or 'UTC'.") self._getBackend().setXAxisTimeZone(tz) @@ -410,6 +429,11 @@ class XAxis(Axis): updated = constrains.update(minXRange=minRange, maxXRange=maxRange) return updated + @docstring(Axis) + def _getDataRange(self) -> Optional[tuple[float, float]]: + ranges = self._getPlot().getDataRange() + return ranges.x + class YAxis(Axis): """Axis class defining primitives for the Y axis""" @@ -418,13 +442,13 @@ class YAxis(Axis): # specialised implementations (prefixel by '_internal') def _internalSetCurrentLabel(self, label): - self._getBackend().setGraphYLabel(label, axis='left') + self._getBackend().setGraphYLabel(label, axis="left") def _internalGetLimits(self): - return self._getBackend().getGraphYLimits(axis='left') + return self._getBackend().getGraphYLimits(axis="left") def _internalSetLimits(self, ymin, ymax): - self._getBackend().setGraphYLimits(ymin, ymax, axis='left') + self._getBackend().setGraphYLimits(ymin, ymax, axis="left") def _internalSetLogarithmic(self, flag): self._getBackend().setYAxisLogarithmic(flag) @@ -462,6 +486,11 @@ class YAxis(Axis): updated = constrains.update(minYRange=minRange, maxYRange=maxRange) return updated + @docstring(Axis) + def _getDataRange(self) -> Optional[tuple[float, float]]: + ranges = self._getPlot().getDataRange() + return ranges.y + class YRightAxis(Axis): """Proxy axis for the secondary Y axes. It manages it own label and limit @@ -485,13 +514,13 @@ class YRightAxis(Axis): self.__mainAxis.sigAutoScaleChanged.connect(self.sigAutoScaleChanged.emit) def _internalSetCurrentLabel(self, label): - self._getBackend().setGraphYLabel(label, axis='right') + self._getBackend().setGraphYLabel(label, axis="right") def _internalGetLimits(self): - return self._getBackend().getGraphYLimits(axis='right') + return self._getBackend().getGraphYLimits(axis="right") def _internalSetLimits(self, ymin, ymax): - self._getBackend().setGraphYLimits(ymin, ymax, axis='right') + self._getBackend().setGraphYLimits(ymin, ymax, axis="right") def setInverted(self, flag=True): """Set the Y axis orientation. @@ -505,6 +534,10 @@ class YRightAxis(Axis): """Return True if Y axis goes from top to bottom, False otherwise.""" return self.__mainAxis.isInverted() + def isVisible(self) -> bool: + """Returns whether the axis is displayed or not""" + return self._getBackend().isYRightAxisVisible() + def getScale(self): """Return the name of the scale used by this axis. @@ -541,3 +574,8 @@ class YRightAxis(Axis): False to disable it. """ return self.__mainAxis.setAutoScale(flag) + + @docstring(Axis) + def _getDataRange(self) -> Optional[tuple[float, float]]: + ranges = self._getPlot().getDataRange() + return ranges.y2 diff --git a/src/silx/gui/plot/items/complex.py b/src/silx/gui/plot/items/complex.py index 82d821f..d10767f 100644 --- a/src/silx/gui/plot/items/complex.py +++ b/src/silx/gui/plot/items/complex.py @@ -1,6 +1,6 @@ # /*########################################################################## # -# Copyright (c) 2017-2021 European Synchrotron Radiation Facility +# Copyright (c) 2017-2023 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -34,7 +34,6 @@ import logging import numpy from ....utils.proxy import docstring -from ....utils.deprecation import deprecated from ...colors import Colormap from .core import ColormapMixIn, ComplexMixIn, ItemChangedType from .image import ImageBase @@ -45,6 +44,7 @@ _logger = logging.getLogger(__name__) # Complex colormap functions + def _phase2rgb(colormap, data): """Creates RGBA image with colour-coded phase. @@ -60,7 +60,7 @@ def _phase2rgb(colormap, data): return colormap.applyToData(phase) -def _complex2rgbalog(phaseColormap, data, amin=0., dlogs=2, smax=None): +def _complex2rgbalog(phaseColormap, data, amin=0.0, dlogs=2, smax=None): """Returns RGBA colors: colour-coded phases and log10(amplitude) in alpha. :param Colormap phaseColormap: Colormap to use for the phase @@ -117,7 +117,8 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn): ComplexMixIn.ComplexMode.IMAGINARY, ComplexMixIn.ComplexMode.AMPLITUDE_PHASE, ComplexMixIn.ComplexMode.LOG10_AMPLITUDE_PHASE, - ComplexMixIn.ComplexMode.SQUARE_AMPLITUDE) + ComplexMixIn.ComplexMode.SQUARE_AMPLITUDE, + ) """Overrides supported ComplexMode""" def __init__(self): @@ -130,10 +131,7 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn): # Use default from ColormapMixIn colormap = super(ImageComplexData, self).getColormap() - phaseColormap = Colormap( - name='hsv', - vmin=-numpy.pi, - vmax=numpy.pi) + phaseColormap = Colormap(name="hsv", vmin=-numpy.pi, vmax=numpy.pi) self._colormaps = { # Default colormaps for all modes self.ComplexMode.ABSOLUTE: colormap, @@ -154,8 +152,10 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn): return None mode = self.getComplexMode() - if mode in (self.ComplexMode.AMPLITUDE_PHASE, - self.ComplexMode.LOG10_AMPLITUDE_PHASE): + if mode in ( + self.ComplexMode.AMPLITUDE_PHASE, + self.ComplexMode.LOG10_AMPLITUDE_PHASE, + ): # For those modes, compute RGBA image here colormap = None data = self.getRgbaImageData(copy=False) @@ -171,11 +171,13 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn): if data.size == 0: return None # No data to display - return backend.addImage(data, - origin=self.getOrigin(), - scale=self.getScale(), - colormap=colormap, - alpha=self.getAlpha()) + return backend.addImage( + data, + origin=self.getOrigin(), + scale=self.getScale(), + colormap=colormap, + alpha=self.getAlpha(), + ) @docstring(ComplexMixIn) def setComplexMode(self, mode): @@ -247,7 +249,7 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn): return self._colormaps[mode] def setData(self, data, copy=True): - """"Set the image complex data + """Set the image complex data :param numpy.ndarray data: 2D array of complex with 2 dimensions (h, w) :param bool copy: True (Default) to get a copy, @@ -257,7 +259,8 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn): assert data.ndim == 2 if not numpy.issubdtype(data.dtype, numpy.complexfloating): _logger.warning( - 'Image is not complex, converting it to complex to plot it.') + "Image is not complex, converting it to complex to plot it." + ) data = numpy.array(data, dtype=numpy.complex64) # Compute current mode data and set colormap data @@ -274,8 +277,9 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn): if event in (ItemChangedType.DATA, ItemChangedType.MASK): # Color-mapped data is NOT the `getValueData` for some modes if self.getComplexMode() in ( - self.ComplexMode.AMPLITUDE_PHASE, - self.ComplexMode.LOG10_AMPLITUDE_PHASE): + self.ComplexMode.AMPLITUDE_PHASE, + self.ComplexMode.LOG10_AMPLITUDE_PHASE, + ): data = self.getData(copy=False, mode=self.ComplexMode.PHASE) mask = self.getMaskData(copy=False) if mask is not None: @@ -308,16 +312,18 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn): return numpy.real(data) elif mode is self.ComplexMode.IMAGINARY: return numpy.imag(data) - elif mode in (self.ComplexMode.ABSOLUTE, - self.ComplexMode.LOG10_AMPLITUDE_PHASE, - self.ComplexMode.AMPLITUDE_PHASE): + elif mode in ( + self.ComplexMode.ABSOLUTE, + self.ComplexMode.LOG10_AMPLITUDE_PHASE, + self.ComplexMode.AMPLITUDE_PHASE, + ): return numpy.absolute(data) elif mode is self.ComplexMode.SQUARE_AMPLITUDE: return numpy.absolute(data) ** 2 else: _logger.error( - 'Unsupported conversion mode: %s, fallback to absolute', - str(mode)) + "Unsupported conversion mode: %s, fallback to absolute", str(mode) + ) return numpy.absolute(data) def getData(self, copy=True, mode=None): @@ -340,7 +346,8 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn): if mode not in self._dataByModesCache: self._dataByModesCache[mode] = self.__convertComplexData( - self.getComplexData(copy=False), mode) + self.getComplexData(copy=False), mode + ) return numpy.array(self._dataByModesCache[mode], copy=copy) @@ -373,11 +380,3 @@ class ImageComplexData(ImageBase, ColormapMixIn, ComplexMixIn): # Backward compatibility Mode = ComplexMixIn.ComplexMode - - @deprecated(replacement='setComplexMode', since_version='0.11.0') - def setVisualizationMode(self, mode): - return self.setComplexMode(mode) - - @deprecated(replacement='getComplexMode', since_version='0.11.0') - def getVisualizationMode(self): - return self.getComplexMode() diff --git a/src/silx/gui/plot/items/core.py b/src/silx/gui/plot/items/core.py index 074c168..7d754a7 100644 --- a/src/silx/gui/plot/items/core.py +++ b/src/silx/gui/plot/items/core.py @@ -1,6 +1,6 @@ # /*########################################################################## # -# Copyright (c) 2017-2022 European Synchrotron Radiation Facility +# Copyright (c) 2017-2023 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -23,16 +23,14 @@ # ###########################################################################*/ """This module provides the base class for items of the :class:`Plot`. """ +from __future__ import annotations + __authors__ = ["T. Vincent"] __license__ = "MIT" __date__ = "08/12/2020" -import collections -try: - from collections import abc -except ImportError: # Python2 support - import collections as abc +from collections import abc from copy import deepcopy import logging import enum @@ -41,13 +39,12 @@ import weakref import numpy -from ....utils.deprecation import deprecated from ....utils.proxy import docstring from ....utils.enum import Enum as _Enum from ....math.combo import min_max from ... import qt from ... import colors -from ...colors import Colormap +from ...colors import Colormap, _Colormappable from ._pick import PickingResult from silx import config @@ -58,98 +55,109 @@ _logger = logging.getLogger(__name__) @enum.unique class ItemChangedType(enum.Enum): """Type of modification provided by :attr:`Item.sigItemChanged` signal.""" + # Private setters and setInfo are not emitting sigItemChanged signal. # Signals to consider: # COLORMAP_SET emitted when setColormap is called but not forward colormap object signal # CURRENT_COLOR_CHANGED emitted current color changed because highlight changed, # highlighted color changed or color changed depending on hightlight state. - VISIBLE = 'visibleChanged' + VISIBLE = "visibleChanged" """Item's visibility changed flag.""" - ZVALUE = 'zValueChanged' + ZVALUE = "zValueChanged" """Item's Z value changed flag.""" - COLORMAP = 'colormapChanged' # Emitted when set + forward events from the colormap object + COLORMAP = ( + "colormapChanged" # Emitted when set + forward events from the colormap object + ) """Item's colormap changed flag. This is emitted both when setting a new colormap and when the current colormap object is updated. """ - SYMBOL = 'symbolChanged' + SYMBOL = "symbolChanged" """Item's symbol changed flag.""" - SYMBOL_SIZE = 'symbolSizeChanged' + SYMBOL_SIZE = "symbolSizeChanged" """Item's symbol size changed flag.""" - LINE_WIDTH = 'lineWidthChanged' + LINE_WIDTH = "lineWidthChanged" """Item's line width changed flag.""" - LINE_STYLE = 'lineStyleChanged' + LINE_STYLE = "lineStyleChanged" """Item's line style changed flag.""" - COLOR = 'colorChanged' + COLOR = "colorChanged" """Item's color changed flag.""" - LINE_BG_COLOR = 'lineBgColorChanged' - """Item's line background color changed flag.""" + LINE_BG_COLOR = "lineBgColorChanged" # Deprecated, use LINE_GAP_COLOR + + LINE_GAP_COLOR = "lineGapColorChanged" + """Item's dashed line gap color changed flag.""" - YAXIS = 'yAxisChanged' + YAXIS = "yAxisChanged" """Item's Y axis binding changed flag.""" - FILL = 'fillChanged' + FILL = "fillChanged" """Item's fill changed flag.""" - ALPHA = 'alphaChanged' + ALPHA = "alphaChanged" """Item's transparency alpha changed flag.""" - DATA = 'dataChanged' + DATA = "dataChanged" """Item's data changed flag""" - MASK = 'maskChanged' + MASK = "maskChanged" """Item's mask changed flag""" - HIGHLIGHTED = 'highlightedChanged' + HIGHLIGHTED = "highlightedChanged" """Item's highlight state changed flag.""" - HIGHLIGHTED_COLOR = 'highlightedColorChanged' + HIGHLIGHTED_COLOR = "highlightedColorChanged" """Deprecated, use HIGHLIGHTED_STYLE instead.""" - HIGHLIGHTED_STYLE = 'highlightedStyleChanged' + HIGHLIGHTED_STYLE = "highlightedStyleChanged" """Item's highlighted style changed flag.""" - SCALE = 'scaleChanged' + SCALE = "scaleChanged" """Item's scale changed flag.""" - TEXT = 'textChanged' + TEXT = "textChanged" """Item's text changed flag.""" - POSITION = 'positionChanged' + POSITION = "positionChanged" """Item's position changed flag. This is emitted when a marker position changed and when an image origin changed. """ - OVERLAY = 'overlayChanged' + OVERLAY = "overlayChanged" """Item's overlay state changed flag.""" - VISUALIZATION_MODE = 'visualizationModeChanged' + VISUALIZATION_MODE = "visualizationModeChanged" """Item's visualization mode changed flag.""" - COMPLEX_MODE = 'complexModeChanged' + COMPLEX_MODE = "complexModeChanged" """Item's complex data visualization mode changed flag.""" - NAME = 'nameChanged' + NAME = "nameChanged" """Item's name changed flag.""" - EDITABLE = 'editableChanged' + EDITABLE = "editableChanged" """Item's editable state changed flags.""" - SELECTABLE = 'selectableChanged' + SELECTABLE = "selectableChanged" """Item's selectable state changed flags.""" + FONT = "fontChanged" + """Item's text font changed flag.""" + + BACKGROUND_COLOR = "backgroundColorChanged" + """Item's text background color changed flag.""" + class Item(qt.QObject): """Description of an item of the plot""" @@ -184,7 +192,7 @@ class Item(qt.QObject): self._info = None self._xlabel = None self._ylabel = None - self.__name = '' + self.__name = "" self.__visibleBoundsTracking = False self.__previousVisibleBounds = None @@ -206,7 +214,7 @@ class Item(qt.QObject): :param Union[~silx.gui.plot.PlotWidget,None] plot: The Plot instance. """ if plot is not None and self._plotRef is not None: - raise RuntimeError('Trying to add a node at two places.') + raise RuntimeError("Trying to add a node at two places.") self.__disconnectFromPlotWidget() self._plotRef = None if plot is None else weakref.ref(plot) self.__connectToPlotWidget() @@ -240,8 +248,7 @@ class Item(qt.QObject): if visible != self._visible: self._visible = visible # When visibility has changed, always mark as dirty - self._updated(ItemChangedType.VISIBLE, - checkVisibility=False) + self._updated(ItemChangedType.VISIBLE, checkVisibility=False) if visible: self._visibleBoundsChanged() @@ -268,8 +275,7 @@ class Item(qt.QObject): name = str(name) if self.__name != name: if self.getPlot() is not None: - raise RuntimeError( - "Cannot change name while item is in a PlotWidget") + raise RuntimeError("Cannot change name while item is in a PlotWidget") self.__name = name self._updated(ItemChangedType.NAME) @@ -277,11 +283,6 @@ class Item(qt.QObject): def getLegend(self): # Replaced by getName for API consistency return self.getName() - @deprecated(replacement='setName', since_version='0.13') - def _setLegend(self, legend): - legend = str(legend) if legend is not None else '' - self.setName(legend) - def isSelectable(self): """Returns true if item is selectable (bool)""" return self._selectable @@ -332,7 +333,8 @@ class Item(qt.QObject): xmin, xmax = numpy.clip(bounds[:2], *plot.getXAxis().getLimits()) ymin, ymax = numpy.clip( - bounds[2:], *plot.getYAxis(self.__getYAxis()).getLimits()) + bounds[2:], *plot.getYAxis(self.__getYAxis()).getLimits() + ) if xmin == xmax or ymin == ymax: # Outside the plot area return None @@ -360,7 +362,7 @@ class Item(qt.QObject): def __getYAxis(self) -> str: """Returns current Y axis ('left' or 'right')""" - return self.getYAxis() if isinstance(self, YAxisMixIn) else 'left' + return self.getYAxis() if isinstance(self, YAxisMixIn) else "left" def __connectToPlotWidget(self) -> None: """Connect to PlotWidget signals and install event filter""" @@ -486,7 +488,7 @@ class Item(qt.QObject): class DataItem(Item): """Item with a data extent in the plot""" - def _boundsChanged(self, checkVisibility: bool=True) -> None: + def _boundsChanged(self, checkVisibility: bool = True) -> None: """Call this method in subclass when data bounds has changed. :param bool checkVisibility: @@ -506,6 +508,7 @@ class DataItem(Item): self._boundsChanged(checkVisibility=False) super().setVisible(visible) + # Mix-in classes ############################################################## @@ -522,8 +525,7 @@ class ItemMixInBase(object): :param bool checkVisibility: True to only mark as dirty if visible, False to always mark as dirty. """ - raise RuntimeError( - "Issue with Mix-In class inheritance order") + raise RuntimeError("Issue with Mix-In class inheritance order") class LabelsMixIn(ItemMixInBase): @@ -597,7 +599,7 @@ class DraggableMixIn(ItemMixInBase): raise NotImplementedError("Must be implemented in subclass") -class ColormapMixIn(ItemMixInBase): +class ColormapMixIn(_Colormappable, ItemMixInBase): """Mix-in class for items with colormap""" def __init__(self): @@ -631,8 +633,9 @@ class ColormapMixIn(ItemMixInBase): """Handle updates of the colormap""" self._updated(ItemChangedType.COLORMAP) - def _setColormappedData(self, data, copy=True, - min_=None, minPositive=None, max_=None): + def _setColormappedData( + self, data, copy=True, min_=None, minPositive=None, max_=None + ): """Set the data used to compute the colormapped display. It also resets the cache of data ranges. @@ -653,7 +656,10 @@ class ColormapMixIn(ItemMixInBase): if min_ is not None and numpy.isfinite(min_): self.__cacheColormapRange[Colormap.LINEAR, Colormap.MINMAX] = min_, max_ if minPositive is not None and numpy.isfinite(minPositive): - self.__cacheColormapRange[Colormap.LOGARITHM, Colormap.MINMAX] = minPositive, max_ + self.__cacheColormapRange[Colormap.LOGARITHM, Colormap.MINMAX] = ( + minPositive, + max_, + ) colormap = self.getColormap() if None in (colormap.getVMin(), colormap.getVMax()): @@ -705,26 +711,29 @@ class SymbolMixIn(ItemMixInBase): _DEFAULT_SYMBOL_SIZE = config.DEFAULT_PLOT_SYMBOL_SIZE """Default marker size of the item""" - _SUPPORTED_SYMBOLS = collections.OrderedDict(( - ('o', 'Circle'), - ('d', 'Diamond'), - ('s', 'Square'), - ('+', 'Plus'), - ('x', 'Cross'), - ('.', 'Point'), - (',', 'Pixel'), - ('|', 'Vertical line'), - ('_', 'Horizontal line'), - ('tickleft', 'Tick left'), - ('tickright', 'Tick right'), - ('tickup', 'Tick up'), - ('tickdown', 'Tick down'), - ('caretleft', 'Caret left'), - ('caretright', 'Caret right'), - ('caretup', 'Caret up'), - ('caretdown', 'Caret down'), - (u'\u2665', 'Heart'), - ('', 'None'))) + _SUPPORTED_SYMBOLS = dict( + ( + ("o", "Circle"), + ("d", "Diamond"), + ("s", "Square"), + ("+", "Plus"), + ("x", "Cross"), + (".", "Point"), + (",", "Pixel"), + ("|", "Vertical line"), + ("_", "Horizontal line"), + ("tickleft", "Tick left"), + ("tickright", "Tick right"), + ("tickup", "Tick up"), + ("tickdown", "Tick down"), + ("caretleft", "Caret left"), + ("caretright", "Caret right"), + ("caretup", "Caret up"), + ("caretdown", "Caret down"), + ("\u2665", "Heart"), + ("", "None"), + ) + ) """Dict of supported symbols""" def __init__(self): @@ -799,7 +808,7 @@ class SymbolMixIn(ItemMixInBase): symbol = symbolCode break else: - raise ValueError('Unsupported symbol %s' % str(symbol)) + raise ValueError("Unsupported symbol %s" % str(symbol)) if symbol != self._symbol: self._symbol = symbol @@ -826,50 +835,74 @@ class SymbolMixIn(ItemMixInBase): self._updated(ItemChangedType.SYMBOL_SIZE) +LineStyleType = Union[ + str, + Tuple[Union[float, int], None], + Tuple[Union[float, int], Tuple[Union[float, int], Union[float, int]]], + Tuple[Union[float, int], Tuple[Union[float, int], Union[float, int], Union[float, int], Union[float, int]]], +] +"""Type for :class:`LineMixIn`'s line style""" + + class LineMixIn(ItemMixInBase): """Mix-in class for item with line""" - _DEFAULT_LINEWIDTH = 1. + _DEFAULT_LINEWIDTH: float = 1.0 """Default line width""" - _DEFAULT_LINESTYLE = '-' + _DEFAULT_LINESTYLE: LineStyleType = "-" """Default line style""" - _SUPPORTED_LINESTYLE = '', ' ', '-', '--', '-.', ':', None + _SUPPORTED_LINESTYLE = "", " ", "-", "--", "-.", ":", None """Supported line styles""" def __init__(self): - self._linewidth = self._DEFAULT_LINEWIDTH - self._linestyle = self._DEFAULT_LINESTYLE + self._linewidth: float = self._DEFAULT_LINEWIDTH + self._linestyle: LineStyleType = self._DEFAULT_LINESTYLE @classmethod - def getSupportedLineStyles(cls): - """Returns list of supported line styles. - - :rtype: List[str,None] - """ + def getSupportedLineStyles(cls) -> tuple[str | None]: + """Returns list of supported constant line styles.""" return cls._SUPPORTED_LINESTYLE - def getLineWidth(self): - """Return the curve line width in pixels - - :rtype: float - """ + def getLineWidth(self) -> float: + """Return the curve line width in pixels""" return self._linewidth - def setLineWidth(self, width): + def setLineWidth(self, width: float): """Set the width in pixel of the curve line See :meth:`getLineWidth`. - - :param float width: Width in pixels """ width = float(width) if width != self._linewidth: self._linewidth = width self._updated(ItemChangedType.LINE_WIDTH) - def getLineStyle(self): + @classmethod + def isValidLineStyle(cls, style: LineStyleType | None) -> bool: + """Returns True for valid styles""" + if style is None or style in cls.getSupportedLineStyles(): + return True + if not isinstance(style, tuple): + return False + if ( + len(style) == 2 + and isinstance(style[0], (float, int)) + and ( + style[1] is None + or style[1] == () + or ( + isinstance(style[1], tuple) + and len(style[1]) in (2, 4) + and all(map(lambda item: isinstance(item, (float, int)), style[1])) + ) + ) + ): + return True + return False + + def getLineStyle(self) -> LineStyleType: """Return the type of the line Type of line:: @@ -879,20 +912,19 @@ class LineMixIn(ItemMixInBase): - '--' dashed line - '-.' dash-dot line - ':' dotted line - - :rtype: str + - (offset, (dash pattern)) """ return self._linestyle - def setLineStyle(self, style): + def setLineStyle(self, style: LineStyleType | None): """Set the style of the curve line. See :meth:`getLineStyle`. - :param str style: Line style + :param style: Line style """ - style = str(style) - assert style in self.getSupportedLineStyles() + if not self.isValidLineStyle(style): + raise ValueError(f"No a valid line style: {style}") if style is None: style = self._DEFAULT_LINESTYLE if style != self._linestyle: @@ -903,7 +935,7 @@ class LineMixIn(ItemMixInBase): class ColorMixIn(ItemMixInBase): """Mix-in class for item with color""" - _DEFAULT_COLOR = (0., 0., 0., 1.) + _DEFAULT_COLOR = (0.0, 0.0, 0.0, 1.0) """Default color of the item""" def __init__(self): @@ -941,10 +973,43 @@ class ColorMixIn(ItemMixInBase): self._updated(ItemChangedType.COLOR) +class LineGapColorMixIn(ItemMixInBase): + """Mix-in class for dashed line gap color""" + + _DEFAULT_LINE_GAP_COLOR = None + """Default dashed line gap color of the item""" + + def __init__(self): + self.__lineGapColor = self._DEFAULT_LINE_GAP_COLOR + + def getLineGapColor(self): + """Returns the RGBA color of dashed line gap of the item + + :rtype: 4-tuple of float in [0, 1] or None + """ + return self.__lineGapColor + + def setLineGapColor(self, color): + """Set dashed line gap color + + It supports: + - color names: e.g., 'green' + - color codes: '#RRGGBB' and '#RRGGBBAA' + - indexed color names: e.g., 'C0' + - RGB(A) sequence of uint8 in [0, 255] or float in [0, 1] + - QColor + + :param color: line background color to be used + :type color: Union[str, List[int], List[float], QColor, None] + """ + self.__lineGapColor = None if color is None else colors.rgba(color) + self._updated(ItemChangedType.LINE_GAP_COLOR) + + class YAxisMixIn(ItemMixInBase): """Mix-in class for item with yaxis""" - _DEFAULT_YAXIS = 'left' + _DEFAULT_YAXIS = "left" """Default Y axis the item belongs to""" def __init__(self): @@ -965,7 +1030,7 @@ class YAxisMixIn(ItemMixInBase): :param str yaxis: 'left' or 'right' """ yaxis = str(yaxis) - assert yaxis in ('left', 'right') + assert yaxis in ("left", "right") if yaxis != self._yaxis: self._yaxis = yaxis # Handle data extent changed for DataItem @@ -977,11 +1042,13 @@ class YAxisMixIn(ItemMixInBase): # Switch Y axis signal connection plot = self.getPlot() if plot is not None: - previousYAxis = 'left' if self.getXAxis() == 'right' else 'right' + previousYAxis = "left" if self.getXAxis() == "right" else "right" plot.getYAxis(previousYAxis).sigLimitsChanged.disconnect( - self._visibleBoundsChanged) + self._visibleBoundsChanged + ) plot.getYAxis(self.getYAxis()).sigLimitsChanged.connect( - self._visibleBoundsChanged) + self._visibleBoundsChanged + ) self._visibleBoundsChanged() self._updated(ItemChangedType.YAXIS) @@ -1015,7 +1082,7 @@ class AlphaMixIn(ItemMixInBase): """Mix-in class for item with opacity""" def __init__(self): - self._alpha = 1. + self._alpha = 1.0 def getAlpha(self): """Returns the opacity of the item @@ -1038,7 +1105,7 @@ class AlphaMixIn(ItemMixInBase): :type alpha: float """ alpha = float(alpha) - alpha = max(0., min(alpha, 1.)) # Clip alpha to [0., 1.] range + alpha = max(0.0, min(alpha, 1.0)) # Clip alpha to [0., 1.] range if alpha != self._alpha: self._alpha = alpha self._updated(ItemChangedType.ALPHA) @@ -1052,14 +1119,15 @@ class ComplexMixIn(ItemMixInBase): class ComplexMode(_Enum): """Identify available display mode for complex""" - NONE = 'none' - ABSOLUTE = 'amplitude' - PHASE = 'phase' - REAL = 'real' - IMAGINARY = 'imaginary' - AMPLITUDE_PHASE = 'amplitude_phase' - LOG10_AMPLITUDE_PHASE = 'log10_amplitude_phase' - SQUARE_AMPLITUDE = 'square_amplitude' + + NONE = "none" + ABSOLUTE = "amplitude" + PHASE = "phase" + REAL = "real" + IMAGINARY = "imaginary" + AMPLITUDE_PHASE = "amplitude_phase" + LOG10_AMPLITUDE_PHASE = "log10_amplitude_phase" + SQUARE_AMPLITUDE = "square_amplitude" def __init__(self): self.__complex_mode = self.ComplexMode.ABSOLUTE @@ -1115,7 +1183,7 @@ class ComplexMixIn(ItemMixInBase): elif mode is self.ComplexMode.SQUARE_AMPLITUDE: return numpy.absolute(data) ** 2 else: - raise ValueError('Unsupported conversion mode: %s', str(mode)) + raise ValueError("Unsupported conversion mode: %s", str(mode)) @classmethod def supportedComplexModes(cls): @@ -1141,22 +1209,22 @@ class ScatterVisualizationMixIn(ItemMixInBase): class Visualization(_Enum): """Different modes of scatter plot visualizations""" - POINTS = 'points' + POINTS = "points" """Display scatter plot as a point cloud""" - LINES = 'lines' + LINES = "lines" """Display scatter plot as a wireframe. This is based on Delaunay triangulation """ - SOLID = 'solid' + SOLID = "solid" """Display scatter plot as a set of filled triangles. This is based on Delaunay triangulation """ - REGULAR_GRID = 'regular_grid' + REGULAR_GRID = "regular_grid" """Display scatter plot as an image. It expects the points to be the intersection of a regular grid, @@ -1165,7 +1233,7 @@ class ScatterVisualizationMixIn(ItemMixInBase): (either all lines from left to right or all from right to left). """ - IRREGULAR_GRID = 'irregular_grid' + IRREGULAR_GRID = "irregular_grid" """Display scatter plot as contiguous quadrilaterals. It expects the points to be the intersection of an irregular grid, @@ -1174,7 +1242,7 @@ class ScatterVisualizationMixIn(ItemMixInBase): (either all lines from left to right or all from right to left). """ - BINNED_STATISTIC = 'binned_statistic' + BINNED_STATISTIC = "binned_statistic" """Display scatter plot as 2D binned statistic (i.e., generalized histogram). """ @@ -1182,13 +1250,13 @@ class ScatterVisualizationMixIn(ItemMixInBase): class VisualizationParameter(_Enum): """Different parameter names for scatter plot visualizations""" - GRID_MAJOR_ORDER = 'grid_major_order' + GRID_MAJOR_ORDER = "grid_major_order" """The major order of points in the regular grid. Either 'row' (row-major, fast X) or 'column' (column-major, fast Y). """ - GRID_BOUNDS = 'grid_bounds' + GRID_BOUNDS = "grid_bounds" """The expected range in data coordinates of the regular grid. A 2-tuple of 2-tuple: (begin (x, y), end (x, y)). @@ -1197,24 +1265,24 @@ class ScatterVisualizationMixIn(ItemMixInBase): As for `GRID_SHAPE`, this can be wider than the current data. """ - GRID_SHAPE = 'grid_shape' + GRID_SHAPE = "grid_shape" """The expected size of the regular grid (height, width). The given shape can be wider than the number of points, in which case the grid is not fully filled. """ - BINNED_STATISTIC_SHAPE = 'binned_statistic_shape' + BINNED_STATISTIC_SHAPE = "binned_statistic_shape" """The number of bins in each dimension (height, width). """ - BINNED_STATISTIC_FUNCTION = 'binned_statistic_function' + BINNED_STATISTIC_FUNCTION = "binned_statistic_function" """The reduction function to apply to each bin (str). Available reduction functions are: 'mean' (default), 'count', 'sum'. """ - DATA_BOUNDS_HINT = 'data_bounds_hint' + DATA_BOUNDS_HINT = "data_bounds_hint" """The expected bounds of the data in data coordinates. A 2-tuple of 2-tuple: ((ymin, ymax), (xmin, xmax)). @@ -1225,8 +1293,8 @@ class ScatterVisualizationMixIn(ItemMixInBase): """ _SUPPORTED_VISUALIZATION_PARAMETER_VALUES = { - VisualizationParameter.GRID_MAJOR_ORDER: ('row', 'column'), - VisualizationParameter.BINNED_STATISTIC_FUNCTION: ('mean', 'count', 'sum'), + VisualizationParameter.GRID_MAJOR_ORDER: ("row", "column"), + VisualizationParameter.BINNED_STATISTIC_FUNCTION: ("mean", "count", "sum"), } """Supported visualization parameter values. @@ -1235,9 +1303,12 @@ class ScatterVisualizationMixIn(ItemMixInBase): def __init__(self): self.__visualization = self.Visualization.POINTS - self.__parameters = dict(# Init parameters to None - (parameter, None) for parameter in self.VisualizationParameter) - self.__parameters[self.VisualizationParameter.BINNED_STATISTIC_FUNCTION] = 'mean' + self.__parameters = dict( # Init parameters to None + (parameter, None) for parameter in self.VisualizationParameter + ) + self.__parameters[ + self.VisualizationParameter.BINNED_STATISTIC_FUNCTION + ] = "mean" @classmethod def supportedVisualizations(cls): @@ -1263,8 +1334,7 @@ class ScatterVisualizationMixIn(ItemMixInBase): :returns: tuple of supported of values or None if not defined. """ parameter = cls.VisualizationParameter(parameter) - return cls._SUPPORTED_VISUALIZATION_PARAMETER_VALUES.get( - parameter, None) + return cls._SUPPORTED_VISUALIZATION_PARAMETER_VALUES.get(parameter, None) def setVisualization(self, mode): """Set the scatter plot visualization mode to use. @@ -1351,6 +1421,7 @@ class ScatterVisualizationMixIn(ItemMixInBase): class PointsBase(DataItem, SymbolMixIn, AlphaMixIn): """Base class for :class:`Curve` and :class:`Scatter`""" + # note: _logFilterData must be overloaded if you overload # getData to change its signature @@ -1398,22 +1469,18 @@ class PointsBase(DataItem, SymbolMixIn, AlphaMixIn): errorClipped[mask] = valueMinusError[mask] <= 0 if numpy.any(errorClipped): # Need filtering - # expand errorbars to 2xN if error.size == 1: # Scalar - error = numpy.full( - (2, len(value)), error, dtype=numpy.float64) + error = numpy.full((2, len(value)), error, dtype=numpy.float64) elif error.ndim == 1: # N array - newError = numpy.empty((2, len(value)), - dtype=numpy.float64) - newError[0,:] = error - newError[1,:] = error + newError = numpy.empty((2, len(value)), dtype=numpy.float64) + newError[0, :] = error + newError[1, :] = error error = newError elif error.size == 2 * len(value): # 2xN array - error = numpy.array( - error, copy=True, dtype=numpy.float64) + error = numpy.array(error, copy=True, dtype=numpy.float64) else: _logger.error("Unhandled error array") @@ -1437,16 +1504,17 @@ class PointsBase(DataItem, SymbolMixIn, AlphaMixIn): if xPositive: x = self.getXData(copy=False) - with numpy.errstate(invalid='ignore'): # Ignore NaN warnings + with numpy.errstate(invalid="ignore"): # Ignore NaN warnings xclipped = x <= 0 if yPositive: y = self.getYData(copy=False) - with numpy.errstate(invalid='ignore'): # Ignore NaN warnings + with numpy.errstate(invalid="ignore"): # Ignore NaN warnings yclipped = y <= 0 - self._clippedCache[(xPositive, yPositive)] = \ - numpy.logical_or(xclipped, yclipped) + self._clippedCache[(xPositive, yPositive)] = numpy.logical_or( + xclipped, yclipped + ) return self._clippedCache[(xPositive, yPositive)] def _logFilterData(self, xPositive, yPositive): @@ -1484,7 +1552,7 @@ class PointsBase(DataItem, SymbolMixIn, AlphaMixIn): def __minMaxDataWithError( data: numpy.ndarray, error: Optional[Union[float, numpy.ndarray]], - positiveOnly: bool + positiveOnly: bool, ) -> Tuple[float]: if error is None: min_, max_ = min_max(data, finite=True) @@ -1532,9 +1600,12 @@ class PointsBase(DataItem, SymbolMixIn, AlphaMixIn): xmin, xmax = self.__minMaxDataWithError(x, xerror, xPositive) ymin, ymax = self.__minMaxDataWithError(y, yerror, yPositive) - self._boundsCache[(xPositive, yPositive)] = tuple([ - (bound if bound is not None else numpy.nan) - for bound in (xmin, xmax, ymin, ymax)]) + self._boundsCache[(xPositive, yPositive)] = tuple( + [ + (bound if bound is not None else numpy.nan) + for bound in (xmin, xmax, ymin, ymax) + ] + ) return self._boundsCache[(xPositive, yPositive)] def _getCachedData(self): @@ -1548,8 +1619,9 @@ class PointsBase(DataItem, SymbolMixIn, AlphaMixIn): if xPositive or yPositive: # At least one axis has log scale, filter data if (xPositive, yPositive) not in self._filteredCache: - self._filteredCache[(xPositive, yPositive)] = \ - self._logFilterData(xPositive, yPositive) + self._filteredCache[(xPositive, yPositive)] = self._logFilterData( + xPositive, yPositive + ) return self._filteredCache[(xPositive, yPositive)] return None @@ -1570,10 +1642,12 @@ class PointsBase(DataItem, SymbolMixIn, AlphaMixIn): if cached_data is not None: return cached_data - return (self.getXData(copy), - self.getYData(copy), - self.getXErrorData(copy), - self.getYErrorData(copy)) + return ( + self.getXData(copy), + self.getYData(copy), + self.getXErrorData(copy), + self.getYErrorData(copy), + ) def getXData(self, copy=True): """Returns the x coordinates of the data points @@ -1640,12 +1714,10 @@ class PointsBase(DataItem, SymbolMixIn, AlphaMixIn): # Convert complex data if numpy.iscomplexobj(x): - _logger.warning( - 'Converting x data to absolute value to plot it.') + _logger.warning("Converting x data to absolute value to plot it.") x = numpy.absolute(x) if numpy.iscomplexobj(y): - _logger.warning( - 'Converting y data to absolute value to plot it.') + _logger.warning("Converting y data to absolute value to plot it.") y = numpy.absolute(y) if xerror is not None: @@ -1653,7 +1725,8 @@ class PointsBase(DataItem, SymbolMixIn, AlphaMixIn): xerror = numpy.array(xerror, copy=copy) if numpy.iscomplexobj(xerror): _logger.warning( - 'Converting xerror data to absolute value to plot it.') + "Converting xerror data to absolute value to plot it." + ) xerror = numpy.absolute(xerror) else: xerror = float(xerror) @@ -1662,7 +1735,8 @@ class PointsBase(DataItem, SymbolMixIn, AlphaMixIn): yerror = numpy.array(yerror, copy=copy) if numpy.iscomplexobj(yerror): _logger.warning( - 'Converting yerror data to absolute value to plot it.') + "Converting yerror data to absolute value to plot it." + ) yerror = numpy.absolute(yerror) else: yerror = float(yerror) @@ -1691,7 +1765,7 @@ class BaselineMixIn(object): :param baseline: baseline value(s) :type: Union[None,float,numpy.ndarray] """ - if (isinstance(baseline, abc.Iterable)): + if isinstance(baseline, abc.Iterable): baseline = numpy.array(baseline) self._baseline = baseline @@ -1713,7 +1787,6 @@ class _Style: class HighlightedMixIn(ItemMixInBase): - def __init__(self): self._highlightStyle = self._DEFAULT_HIGHLIGHT_STYLE self._highlighted = False diff --git a/src/silx/gui/plot/items/curve.py b/src/silx/gui/plot/items/curve.py index 93e4719..e8d0d52 100644 --- a/src/silx/gui/plot/items/curve.py +++ b/src/silx/gui/plot/items/curve.py @@ -1,6 +1,6 @@ # /*########################################################################## # -# Copyright (c) 2017-2021 European Synchrotron Radiation Facility +# Copyright (c) 2017-2023 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -23,6 +23,7 @@ # ###########################################################################*/ """This module provides the :class:`Curve` item of the :class:`Plot`. """ +from __future__ import annotations __authors__ = ["T. Vincent"] __license__ = "MIT" @@ -33,11 +34,22 @@ import logging import numpy -from ....utils.deprecation import deprecated +from ....utils.deprecation import deprecated_warning from ... import colors -from .core import (PointsBase, LabelsMixIn, ColorMixIn, YAxisMixIn, - FillMixIn, LineMixIn, SymbolMixIn, ItemChangedType, - BaselineMixIn, HighlightedMixIn, _Style) +from .core import ( + PointsBase, + LabelsMixIn, + ColorMixIn, + YAxisMixIn, + FillMixIn, + LineMixIn, + LineGapColorMixIn, + LineStyleType, + SymbolMixIn, + BaselineMixIn, + HighlightedMixIn, + _Style, +) _logger = logging.getLogger(__name__) @@ -49,14 +61,22 @@ class CurveStyle(_Style): 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 + :param linestyle: Style of the line + :param linewidth: Width of the line + :param symbol: Symbol for markers + :param symbolsize: Size of the markers + :param gapcolor: Color of gaps of dashed line """ - def __init__(self, color=None, linestyle=None, linewidth=None, - symbol=None, symbolsize=None): + def __init__( + self, + color: colors.RGBAColorType | None = None, + linestyle: LineStyleType | None = None, + linewidth: float | None = None, + symbol: str | None = None, + symbolsize: float | None = None, + gapcolor: colors.RGBAColorType | None = None, + ): if color is None: self._color = None else: @@ -68,8 +88,8 @@ class CurveStyle(_Style): color = colors.rgba(color) self._color = color - if linestyle is not None: - assert linestyle in LineMixIn.getSupportedLineStyles() + if not LineMixIn.isValidLineStyle(linestyle): + raise ValueError(f"Not a valid line style: {linestyle}") self._linestyle = linestyle self._linewidth = None if linewidth is None else float(linewidth) @@ -80,6 +100,8 @@ class CurveStyle(_Style): self._symbolsize = None if symbolsize is None else float(symbolsize) + self._gapcolor = None if gapcolor is None else colors.rgba(gapcolor) + def getColor(self, copy=True): """Returns the color or None if not set. @@ -93,7 +115,14 @@ class CurveStyle(_Style): else: return self._color - def getLineStyle(self): + def getLineGapColor(self): + """Returns the color of dashed line gaps or None if not set. + + :rtype: Union[List[float],None] + """ + return self._gapcolor + + def getLineStyle(self) -> LineStyleType | None: """Return the type of the line or None if not set. Type of line:: @@ -103,8 +132,7 @@ class CurveStyle(_Style): - '--' dashed line - '-.' dash-dot line - ':' dotted line - - :rtype: Union[str,None] + - (offset, (dash pattern)) """ return self._linestyle @@ -141,17 +169,29 @@ class CurveStyle(_Style): 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()) + 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() + and self.getLineGapColor() == other.getLineGapColor() + ) else: return False -class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, - LineMixIn, BaselineMixIn, HighlightedMixIn): +class Curve( + PointsBase, + ColorMixIn, + YAxisMixIn, + FillMixIn, + LabelsMixIn, + LineMixIn, + LineGapColorMixIn, + BaselineMixIn, + HighlightedMixIn, +): """Description of a curve""" _DEFAULT_Z_LAYER = 1 @@ -160,13 +200,13 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, _DEFAULT_SELECTABLE = True """Default selectable state for curves""" - _DEFAULT_LINEWIDTH = 1. + _DEFAULT_LINEWIDTH = 1.0 """Default line width of the curve""" - _DEFAULT_LINESTYLE = '-' + _DEFAULT_LINESTYLE = "-" """Default line style of the curve""" - _DEFAULT_HIGHLIGHT_STYLE = CurveStyle(color='black') + _DEFAULT_HIGHLIGHT_STYLE = CurveStyle(color="black") """Default highlight style of the item""" _DEFAULT_BASELINE = None @@ -178,6 +218,7 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, FillMixIn.__init__(self) LabelsMixIn.__init__(self) LineMixIn.__init__(self) + LineGapColorMixIn.__init__(self) BaselineMixIn.__init__(self) HighlightedMixIn.__init__(self) @@ -186,29 +227,38 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, def _addBackendRenderer(self, backend): """Update backend renderer""" # Filter-out values <= 0 - xFiltered, yFiltered, xerror, yerror = self.getData( - copy=False, displayed=True) + xFiltered, yFiltered, xerror, yerror = self.getData(copy=False, displayed=True) 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, - color=style.getColor(), - symbol=style.getSymbol(), - linestyle=style.getLineStyle(), - linewidth=style.getLineWidth(), - yaxis=self.getYAxis(), - xerror=xerror, - yerror=yerror, - fill=self.isFill(), - alpha=self.getAlpha(), - symbolsize=style.getSymbolSize(), - baseline=self.getBaseline(copy=False)) + return backend.addCurve( + xFiltered, + yFiltered, + color=style.getColor(), + gapcolor=style.getLineGapColor(), + symbol=style.getSymbol(), + linestyle=style.getLineStyle(), + linewidth=style.getLineWidth(), + yaxis=self.getYAxis(), + xerror=xerror, + yerror=yerror, + fill=self.isFill(), + alpha=self.getAlpha(), + symbolsize=style.getSymbolSize(), + baseline=self.getBaseline(copy=False), + ) def __getitem__(self, item): """Compatibility with PyMca and silx <= 0.4.0""" + deprecated_warning( + "Attributes", + "__getitem__", + since_version="2.0.0", + replacement="Use Curve methods", + ) if isinstance(item, slice): return [self[index] for index in range(*item.indices(5))] elif item == 0: @@ -222,44 +272,24 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, return {} if info is None else info elif item == 4: params = { - 'info': self.getInfo(), - 'color': self.getColor(), - 'symbol': self.getSymbol(), - 'linewidth': self.getLineWidth(), - 'linestyle': self.getLineStyle(), - 'xlabel': self.getXLabel(), - 'ylabel': self.getYLabel(), - 'yaxis': self.getYAxis(), - 'xerror': self.getXErrorData(copy=False), - 'yerror': self.getYErrorData(copy=False), - 'z': self.getZValue(), - 'selectable': self.isSelectable(), - 'fill': self.isFill(), + "info": self.getInfo(), + "color": self.getColor(), + "symbol": self.getSymbol(), + "linewidth": self.getLineWidth(), + "linestyle": self.getLineStyle(), + "xlabel": self.getXLabel(), + "ylabel": self.getYLabel(), + "yaxis": self.getYAxis(), + "xerror": self.getXErrorData(copy=False), + "yerror": self.getYErrorData(copy=False), + "z": self.getZValue(), + "selectable": self.isSelectable(), + "fill": self.isFill(), } return params else: raise IndexError("Index out of range: %s", str(item)) - @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 float in [0, 1] - """ - return self.getHighlightedStyle().getColor() - - @deprecated(replacement='Curve.setHighlightedStyle()', - since_version='0.9.0') - def setHighlightedColor(self, color): - """Set the color to use when highlighted - - :param color: color(s) to be used for highlight - :type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or - one of the predefined color names defined in colors.py - """ - self.setHighlightedStyle(CurveStyle(color)) - def getCurrentStyle(self): """Returns the current curve style. @@ -274,32 +304,26 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, linewidth = style.getLineWidth() symbol = style.getSymbol() symbolsize = style.getSymbolSize() + gapcolor = style.getLineGapColor() 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) + symbolsize=self.getSymbolSize() if symbolsize is None else symbolsize, + gapcolor=self.getLineGapColor() if gapcolor is None else gapcolor, + ) 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 float in [0, 1] - """ - return self.getCurrentStyle().getColor() + return CurveStyle( + color=self.getColor(), + linestyle=self.getLineStyle(), + linewidth=self.getLineWidth(), + symbol=self.getSymbol(), + symbolsize=self.getSymbolSize(), + gapcolor=self.getLineGapColor(), + ) def setData(self, x, y, xerror=None, yerror=None, baseline=None, copy=True): """Set the data of the curve. @@ -319,6 +343,5 @@ class Curve(PointsBase, ColorMixIn, YAxisMixIn, FillMixIn, LabelsMixIn, :param bool copy: True make a copy of the data (default), False to use provided arrays. """ - PointsBase.setData(self, x=x, y=y, xerror=xerror, yerror=yerror, - copy=copy) + PointsBase.setData(self, x=x, y=y, xerror=xerror, yerror=yerror, copy=copy) self._setBaseline(baseline=baseline) diff --git a/src/silx/gui/plot/items/histogram.py b/src/silx/gui/plot/items/histogram.py index 007f0c7..1dc851b 100644 --- a/src/silx/gui/plot/items/histogram.py +++ b/src/silx/gui/plot/items/histogram.py @@ -1,6 +1,6 @@ # /*########################################################################## # -# Copyright (c) 2017-2021 European Synchrotron Radiation Facility +# Copyright (c) 2017-2023 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -32,15 +32,20 @@ import logging import typing import numpy -from collections import OrderedDict, namedtuple -try: - from collections import abc -except ImportError: # Python2 support - import collections as abc +from collections import abc from ....utils.proxy import docstring -from .core import (DataItem, AlphaMixIn, BaselineMixIn, ColorMixIn, FillMixIn, - LineMixIn, YAxisMixIn, ItemChangedType, Item) +from .core import ( + DataItem, + AlphaMixIn, + BaselineMixIn, + ColorMixIn, + FillMixIn, + LineMixIn, + LineGapColorMixIn, + YAxisMixIn, + ItemChangedType, +) from ._pick import PickingResult _logger = logging.getLogger(__name__) @@ -62,17 +67,17 @@ def _computeEdges(x, histogramType): """ # for now we consider that the spaces between xs are constant edges = x.copy() - if histogramType == 'left': + if histogramType == "left": width = 1 if len(x) > 1: width = x[1] - x[0] edges = numpy.append(x[0] - width, edges) - if histogramType == 'center': - edges = _computeEdges(edges, 'right') + if histogramType == "center": + edges = _computeEdges(edges, "right") widths = (edges[1:] - edges[0:-1]) / 2.0 widths = numpy.append(widths, widths[-1]) edges = edges - widths - if histogramType == 'right': + if histogramType == "right": width = 1 if len(x) > 1: width = x[-1] - x[-2] @@ -102,8 +107,16 @@ def _getHistogramCurve(histogram, edges): # TODO: Yerror, test log scale -class Histogram(DataItem, AlphaMixIn, ColorMixIn, FillMixIn, - LineMixIn, YAxisMixIn, BaselineMixIn): +class Histogram( + DataItem, + AlphaMixIn, + ColorMixIn, + FillMixIn, + LineMixIn, + LineGapColorMixIn, + YAxisMixIn, + BaselineMixIn, +): """Description of an histogram""" _DEFAULT_Z_LAYER = 1 @@ -112,10 +125,10 @@ class Histogram(DataItem, AlphaMixIn, ColorMixIn, FillMixIn, _DEFAULT_SELECTABLE = False """Default selectable state for histograms""" - _DEFAULT_LINEWIDTH = 1. + _DEFAULT_LINEWIDTH = 1.0 """Default line width of the histogram""" - _DEFAULT_LINESTYLE = '-' + _DEFAULT_LINESTYLE = "-" """Default line style of the histogram""" _DEFAULT_BASELINE = None @@ -127,6 +140,7 @@ class Histogram(DataItem, AlphaMixIn, ColorMixIn, FillMixIn, ColorMixIn.__init__(self) FillMixIn.__init__(self) LineMixIn.__init__(self) + LineGapColorMixIn.__init__(self) YAxisMixIn.__init__(self) self._histogram = () @@ -156,26 +170,30 @@ class Histogram(DataItem, AlphaMixIn, ColorMixIn, FillMixIn, if xPositive or yPositive: clipped = numpy.logical_or( - (x <= 0) if xPositive else False, - (y <= 0) if yPositive else False) + (x <= 0) if xPositive else False, (y <= 0) if yPositive else False + ) # Make a copy and replace negative points by NaN x = numpy.array(x, dtype=numpy.float64) y = numpy.array(y, dtype=numpy.float64) x[clipped] = numpy.nan y[clipped] = numpy.nan - return backend.addCurve(x, y, - color=self.getColor(), - symbol='', - linestyle=self.getLineStyle(), - linewidth=self.getLineWidth(), - yaxis=self.getYAxis(), - xerror=None, - yerror=None, - fill=self.isFill(), - alpha=self.getAlpha(), - baseline=baseline, - symbolsize=1) + return backend.addCurve( + x, + y, + color=self.getColor(), + gapcolor=self.getLineGapColor(), + symbol="", + linestyle=self.getLineStyle(), + linewidth=self.getLineWidth(), + yaxis=self.getYAxis(), + xerror=None, + yerror=None, + fill=self.isFill(), + alpha=self.getAlpha(), + baseline=baseline, + symbolsize=1, + ) def _getBounds(self): values, edges, baseline = self.getData(copy=False) @@ -193,11 +211,10 @@ class Histogram(DataItem, AlphaMixIn, ColorMixIn, FillMixIn, if xPositive: # Replace edges <= 0 by NaN and corresponding values by NaN - clipped_edges = (edges <= 0) + clipped_edges = edges <= 0 edges = numpy.array(edges, copy=True, dtype=numpy.float64) edges[clipped_edges] = numpy.nan - clipped_values = numpy.logical_or(clipped_edges[:-1], - clipped_edges[1:]) + clipped_values = numpy.logical_or(clipped_edges[:-1], clipped_edges[1:]) else: clipped_values = numpy.zeros_like(values, dtype=bool) @@ -208,20 +225,26 @@ class Histogram(DataItem, AlphaMixIn, ColorMixIn, FillMixIn, values[clipped_values] = numpy.nan if yPositive: - return (numpy.nanmin(edges), - numpy.nanmax(edges), - numpy.nanmin(values), - numpy.nanmax(values)) + return ( + numpy.nanmin(edges), + numpy.nanmax(edges), + numpy.nanmin(values), + numpy.nanmax(values), + ) else: # No log scale on y axis, include 0 in bounds if numpy.all(numpy.isnan(values)): return None - return (numpy.nanmin(edges), - numpy.nanmax(edges), - min(0, numpy.nanmin(values)), - max(0, numpy.nanmax(values))) - - def __pickFilledHistogram(self, x: float, y: float) -> typing.Optional[PickingResult]: + return ( + numpy.nanmin(edges), + numpy.nanmax(edges), + min(0, numpy.nanmin(values)), + max(0, numpy.nanmax(values)), + ) + + def __pickFilledHistogram( + self, x: float, y: float + ) -> typing.Optional[PickingResult]: """Picking implementation for filled histogram :param x: X position in pixels @@ -241,7 +264,7 @@ class Histogram(DataItem, AlphaMixIn, ColorMixIn, FillMixIn, # Check x edges = self.getBinEdgesData(copy=False) - index = numpy.searchsorted(edges, (xData,), side='left')[0] - 1 + index = numpy.searchsorted(edges, (xData,), side="left")[0] - 1 # Safe indexing in histogram values index = numpy.clip(index, 0, len(edges) - 2) @@ -251,8 +274,9 @@ class Histogram(DataItem, AlphaMixIn, ColorMixIn, FillMixIn, baseline = 0 # Default value value = self.getValueData(copy=False)[index] - if ((baseline <= value and baseline <= yData <= value) or - (value < baseline and value <= yData <= baseline)): + if (baseline <= value and baseline <= yData <= value) or ( + value < baseline and value <= yData <= baseline + ): return PickingResult(self, numpy.array([index])) else: return None @@ -296,12 +320,13 @@ class Histogram(DataItem, AlphaMixIn, ColorMixIn, FillMixIn, :returns: (N histogram value, N+1 bin edges) :rtype: 2-tuple of numpy.nadarray """ - return (self.getValueData(copy), - self.getBinEdgesData(copy), - self.getBaseline(copy)) + return ( + self.getValueData(copy), + self.getBinEdgesData(copy), + self.getBaseline(copy), + ) - def setData(self, histogram, edges, align='center', baseline=None, - copy=True): + def setData(self, histogram, edges, align="center", baseline=None, copy=True): """Set the histogram values and bin edges. :param numpy.ndarray histogram: The values of the histogram. @@ -324,7 +349,7 @@ class Histogram(DataItem, AlphaMixIn, ColorMixIn, FillMixIn, assert histogram.ndim == 1 assert edges.ndim == 1 assert edges.size in (histogram.size, histogram.size + 1) - assert align in ('center', 'left', 'right') + assert align in ("center", "left", "right") if histogram.size == 0: # No data self._histogram = () @@ -338,12 +363,12 @@ class Histogram(DataItem, AlphaMixIn, ColorMixIn, FillMixIn, edgesDiff = edgesDiff[numpy.logical_not(numpy.isnan(edgesDiff))] assert numpy.all(edgesDiff >= 0) or numpy.all(edgesDiff <= 0) # manage baseline - if (isinstance(baseline, abc.Iterable)): + if isinstance(baseline, abc.Iterable): baseline = numpy.array(baseline) if baseline.size == histogram.size: new_baseline = numpy.empty(baseline.shape[0] * 2) for i_value, value in enumerate(baseline): - new_baseline[i_value*2:i_value*2+2] = value + new_baseline[i_value * 2 : i_value * 2 + 2] = value baseline = new_baseline self._histogram = histogram self._edges = edges @@ -376,11 +401,11 @@ class Histogram(DataItem, AlphaMixIn, ColorMixIn, FillMixIn, """ # for now we consider that the spaces between xs are constant edges = x.copy() - if histogramType == 'left': + if histogramType == "left": return edges[1:] - if histogramType == 'center': + if histogramType == "center": edges = (edges[1:] + edges[:-1]) / 2.0 - if histogramType == 'right': + if histogramType == "right": width = 1 if len(x) > 1: width = x[-1] + x[-2] diff --git a/src/silx/gui/plot/items/image.py b/src/silx/gui/plot/items/image.py index eaee05a..18310d9 100644 --- a/src/silx/gui/plot/items/image.py +++ b/src/silx/gui/plot/items/image.py @@ -1,6 +1,6 @@ # /*########################################################################## # -# Copyright (c) 2017-2021 European Synchrotron Radiation Facility +# Copyright (c) 2017-2023 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -29,17 +29,21 @@ __authors__ = ["T. Vincent"] __license__ = "MIT" __date__ = "08/12/2020" -try: - from collections import abc -except ImportError: # Python2 support - import collections as abc +from collections import abc import logging import numpy from ....utils.proxy import docstring -from .core import (DataItem, LabelsMixIn, DraggableMixIn, ColormapMixIn, - AlphaMixIn, ItemChangedType) +from ....utils.deprecation import deprecated_warning +from .core import ( + DataItem, + LabelsMixIn, + DraggableMixIn, + ColormapMixIn, + AlphaMixIn, + ItemChangedType, +) _logger = logging.getLogger(__name__) @@ -62,23 +66,22 @@ def _convertImageToRgba32(image, copy=True): assert image.shape[-1] in (3, 4) # Convert type to uint8 - if image.dtype.name != 'uint8': - if image.dtype.kind == 'f': # Float in [0, 1] - image = (numpy.clip(image, 0., 1.) * 255).astype(numpy.uint8) - elif image.dtype.kind == 'b': # boolean + if image.dtype.name != "uint8": + if image.dtype.kind == "f": # Float in [0, 1] + image = (numpy.clip(image, 0.0, 1.0) * 255).astype(numpy.uint8) + elif image.dtype.kind == "b": # boolean image = image.astype(numpy.uint8) * 255 - elif image.dtype.kind in ('i', 'u'): # int, uint + elif image.dtype.kind in ("i", "u"): # int, uint image = numpy.clip(image, 0, 255).astype(numpy.uint8) else: - raise ValueError('Unsupported image dtype: %s', image.dtype.name) + raise ValueError("Unsupported image dtype: %s", image.dtype.name) copy = False # A copy as already been done, avoid next one # Convert RGB to RGBA if image.shape[-1] == 3: - new_image = numpy.empty((image.shape[0], image.shape[1], 4), - dtype=numpy.uint8) - new_image[:,:,:3] = image - new_image[:,:, 3] = 255 + new_image = numpy.empty((image.shape[0], image.shape[1], 4), dtype=numpy.uint8) + new_image[:, :, :3] = image + new_image[:, :, 3] = 255 return new_image # This is a copy anyway else: return numpy.array(image, copy=copy) @@ -100,11 +103,17 @@ class ImageBase(DataItem, LabelsMixIn, DraggableMixIn, AlphaMixIn): self._data = data self._mask = mask self.__valueDataCache = None # Store default data - self._origin = (0., 0.) - self._scale = (1., 1.) + self._origin = (0.0, 0.0) + self._scale = (1.0, 1.0) def __getitem__(self, item): """Compatibility with PyMca and silx <= 0.4.0""" + deprecated_warning( + "Attributes", + "__getitem__", + since_version="2.0.0", + replacement="Use ImageBase methods", + ) if isinstance(item, slice): return [self[index] for index in range(*item.indices(5))] elif item == 0: @@ -118,15 +127,15 @@ class ImageBase(DataItem, LabelsMixIn, DraggableMixIn, AlphaMixIn): return None elif item == 4: params = { - 'info': self.getInfo(), - 'origin': self.getOrigin(), - 'scale': self.getScale(), - 'z': self.getZValue(), - 'selectable': self.isSelectable(), - 'draggable': self.isDraggable(), - 'colormap': None, - 'xlabel': self.getXLabel(), - 'ylabel': self.getYLabel(), + "info": self.getInfo(), + "origin": self.getOrigin(), + "scale": self.getScale(), + "z": self.getZValue(), + "selectable": self.isSelectable(), + "draggable": self.isDraggable(), + "colormap": None, + "xlabel": self.getXLabel(), + "ylabel": self.getYLabel(), } return params else: @@ -167,8 +176,7 @@ class ImageBase(DataItem, LabelsMixIn, DraggableMixIn, AlphaMixIn): @docstring(DraggableMixIn) def drag(self, from_, to): origin = self.getOrigin() - self.setOrigin((origin[0] + to[0] - from_[0], - origin[1] + to[1] - from_[1])) + self.setOrigin((origin[0] + to[0] - from_[0], origin[1] + to[1] - from_[1])) def getData(self, copy=True): """Returns the image data @@ -190,8 +198,10 @@ class ImageBase(DataItem, LabelsMixIn, DraggableMixIn, AlphaMixIn): self._boundsChanged() self._updated(ItemChangedType.DATA) - if (self.getMaskData(copy=False) is not None and - previousShape != self._data.shape): + if ( + self.getMaskData(copy=False) is not None + and previousShape != self._data.shape + ): # Data shape changed, so mask shape changes. # Send event, mask is lazily updated in getMaskData self._updated(ItemChangedType.MASK) @@ -211,7 +221,9 @@ class ImageBase(DataItem, LabelsMixIn, DraggableMixIn, AlphaMixIn): if self._mask.shape != shape: # Clip/extend mask to match data newMask = numpy.zeros(shape, dtype=self._mask.dtype) - newMask[:self._mask.shape[0], :self._mask.shape[1]] = self._mask[:shape[0], :shape[1]] + newMask[: self._mask.shape[0], : self._mask.shape[1]] = self._mask[ + : shape[0], : shape[1] + ] self._mask = newMask return numpy.array(self._mask, copy=copy) @@ -228,7 +240,9 @@ class ImageBase(DataItem, LabelsMixIn, DraggableMixIn, AlphaMixIn): shape = self.getData(copy=False).shape[:2] if mask.shape != shape: - _logger.warning("Inconsistent shape between mask and data %s, %s", mask.shape, shape) + _logger.warning( + "Inconsistent shape between mask and data %s, %s", mask.shape, shape + ) # Clip/extent is done lazily in getMaskData elif self._mask is None: return # No update @@ -278,7 +292,7 @@ class ImageBase(DataItem, LabelsMixIn, DraggableMixIn, AlphaMixIn): False to use internal representation (do not modify!) :returns: numpy.ndarray of uint8 of shape (height, width, 4) """ - raise NotImplementedError('This MUST be implemented in sub-class') + raise NotImplementedError("This MUST be implemented in sub-class") def getOrigin(self): """Returns the offset from origin at which to display the image. @@ -336,9 +350,11 @@ class ImageDataBase(ImageBase, ColormapMixIn): def _getColormapForRendering(self): colormap = self.getColormap() if colormap.isAutoscale(): + # NOTE: Make sure getColormapRange comes from the original object + vrange = colormap.getColormapRange(self) # Avoid backend to compute autoscale: use item cache colormap = colormap.copy() - colormap.setVRange(*colormap.getColormapRange(self)) + colormap.setVRange(*vrange) return colormap def getRgbaImageData(self, copy=True): @@ -350,7 +366,7 @@ class ImageDataBase(ImageBase, ColormapMixIn): return self.getColormap().applyToData(self) def setData(self, data, copy=True): - """"Set the image data + """Set the image data :param numpy.ndarray data: Data array with 2 dimensions (h, w) :param bool copy: True (Default) to get a copy, @@ -358,13 +374,11 @@ class ImageDataBase(ImageBase, ColormapMixIn): """ data = numpy.array(data, copy=copy) assert data.ndim == 2 - if data.dtype.kind == 'b': - _logger.warning( - 'Converting boolean image to int8 to plot it.') + if data.dtype.kind == "b": + _logger.warning("Converting boolean image to int8 to plot it.") data = numpy.array(data, copy=False, dtype=numpy.int8) elif numpy.iscomplexobj(data): - _logger.warning( - 'Converting complex image to absolute value to plot it.') + _logger.warning("Converting complex image to absolute value to plot it.") data = numpy.absolute(data) super().setData(data) @@ -391,8 +405,10 @@ class ImageData(ImageDataBase): # Do not render with non linear scales return None - if (self.getAlternativeImageData(copy=False) is not None or - self.getAlphaData(copy=False) is not None): + if ( + self.getAlternativeImageData(copy=False) is not None + or self.getAlphaData(copy=False) is not None + ): dataToUse = self.getRgbaImageData(copy=False) else: dataToUse = self.getData(copy=False) @@ -400,20 +416,28 @@ class ImageData(ImageDataBase): if dataToUse.size == 0: return None # No data to display - return backend.addImage(dataToUse, - origin=self.getOrigin(), - scale=self.getScale(), - colormap=self._getColormapForRendering(), - alpha=self.getAlpha()) + return backend.addImage( + dataToUse, + origin=self.getOrigin(), + scale=self.getScale(), + colormap=self._getColormapForRendering(), + alpha=self.getAlpha(), + ) def __getitem__(self, item): """Compatibility with PyMca and silx <= 0.4.0""" + deprecated_warning( + "Attributes", + "__getitem__", + since_version="2.0.0", + replacement="Use ImageData methods", + ) if item == 3: return self.getAlternativeImageData(copy=False) params = ImageBase.__getitem__(self, item) if item == 4: - params['colormap'] = self.getColormap() + params["colormap"] = self.getColormap() return params @@ -431,7 +455,7 @@ class ImageData(ImageDataBase): alphaImage = self.getAlphaData(copy=False) if alphaImage is not None: # Apply transparency - image[:,:, 3] = image[:,:, 3] * alphaImage + image[:, :, 3] = image[:, :, 3] * alphaImage return image def getAlternativeImageData(self, copy=True): @@ -459,7 +483,7 @@ class ImageData(ImageDataBase): return numpy.array(self.__alpha, copy=copy) def setData(self, data, alternative=None, alpha=None, copy=True): - """"Set the image data and optionally an alternative RGB(A) representation + """Set the image data and optionally an alternative RGB(A) representation :param numpy.ndarray data: Data array with 2 dimensions (h, w) :param alternative: RGB(A) image to display instead of data, @@ -484,10 +508,10 @@ class ImageData(ImageDataBase): if alpha is not None: alpha = numpy.array(alpha, copy=copy) assert alpha.shape == data.shape - if alpha.dtype.kind != 'f': + 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.) + if numpy.any(numpy.logical_or(alpha < 0.0, alpha > 1.0)): + alpha = numpy.clip(alpha, 0.0, 1.0) self.__alpha = alpha super().setData(data) @@ -512,11 +536,13 @@ class ImageRgba(ImageBase): if data.size == 0: return None # No data to display - return backend.addImage(data, - origin=self.getOrigin(), - scale=self.getScale(), - colormap=None, - alpha=self.getAlpha()) + return backend.addImage( + data, + origin=self.getOrigin(), + scale=self.getScale(), + colormap=None, + alpha=self.getAlpha(), + ) def getRgbaImageData(self, copy=True): """Get the displayed RGB(A) image @@ -533,8 +559,14 @@ class ImageRgba(ImageBase): False to use internal representation (do not modify!) """ data = numpy.array(data, copy=copy) - assert data.ndim == 3 - assert data.shape[-1] in (3, 4) + if data.ndim != 3: + raise ValueError( + f"RGB(A) image is expected to be a 3D dataset. Got {data.ndim} dimensions" + ) + if data.shape[-1] not in (3, 4): + raise ValueError( + f"RGB(A) image is expected to have 3 or 4 elements as last dimension. Got {data.shape[-1]}" + ) super().setData(data) def _getValueData(self, copy=True): @@ -545,10 +577,10 @@ class ImageRgba(ImageBase): :param bool copy: """ rgba = self.getRgbaImageData(copy=False).astype(numpy.float32) - intensity = (rgba[:, :, 0] * 0.299 + - rgba[:, :, 1] * 0.587 + - rgba[:, :, 2] * 0.114) - intensity *= rgba[:, :, 3] / 255. + intensity = ( + rgba[:, :, 0] * 0.299 + rgba[:, :, 1] * 0.587 + rgba[:, :, 2] * 0.114 + ) + intensity *= rgba[:, :, 3] / 255.0 return intensity @@ -558,6 +590,7 @@ class MaskImageData(ImageData): This class is used to flag mask items. This information is used to improve internal silx widgets. """ + pass diff --git a/src/silx/gui/plot/items/image_aggregated.py b/src/silx/gui/plot/items/image_aggregated.py index ffd41b2..b35e00a 100644 --- a/src/silx/gui/plot/items/image_aggregated.py +++ b/src/silx/gui/plot/items/image_aggregated.py @@ -1,6 +1,6 @@ # /*########################################################################## # -# Copyright (c) 2021 European Synchrotron Radiation Facility +# Copyright (c) 2021-2023 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -31,6 +31,7 @@ __date__ = "07/07/2021" import enum import logging from typing import Tuple, Union +import warnings import numpy @@ -68,7 +69,7 @@ class ImageDataAggregated(ImageDataBase): self.__currentLOD = 0, 0 self.__aggregationMode = self.Aggregation.NONE - def setAggregationMode(self, mode: Union[str,Aggregation]): + def setAggregationMode(self, mode: Union[str, Aggregation]): """Set the aggregation method used to reduce the data to screen resolution. :param Aggregation mode: The aggregation method @@ -115,12 +116,14 @@ class ImageDataAggregated(ImageDataBase): if (lodx, lody) not in self.__cacheLODData: height, width = data.shape - self.__cacheLODData[(lodx, lody)] = aggregator( - data[: (height // lody) * lody, : (width // lodx) * lodx].reshape( - height // lody, lody, width // lodx, lodx - ), - axis=(1, 3), - ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=RuntimeWarning) + self.__cacheLODData[(lodx, lody)] = aggregator( + data[ + : (height // lody) * lody, : (width // lodx) * lodx + ].reshape(height // lody, lody, width // lodx, lodx), + axis=(1, 3), + ) self.__currentLOD = lodx, lody displayedData = self.__cacheLODData[self.__currentLOD] @@ -153,10 +156,7 @@ class ImageDataAggregated(ImageDataBase): xaxis = plot.getXAxis() yaxis = plot.getYAxis(axis) - if ( - xaxis.getScale() != Axis.LINEAR - or yaxis.getScale() != Axis.LINEAR - ): + if xaxis.getScale() != Axis.LINEAR or yaxis.getScale() != Axis.LINEAR: raise RuntimeError("Only available with linear axes") xmin, xmax = xaxis.getLimits() @@ -200,8 +200,10 @@ class ImageDataAggregated(ImageDataBase): def __plotLimitsChanged(self): """Trigger update if level of details has changed""" - if (self.getAggregationMode() != self.Aggregation.NONE and - self.__currentLOD != self._getLevelOfDetails()): + if ( + self.getAggregationMode() != self.Aggregation.NONE + and self.__currentLOD != self._getLevelOfDetails() + ): self._updated() @docstring(ImageDataBase) diff --git a/src/silx/gui/plot/items/marker.py b/src/silx/gui/plot/items/marker.py index 7596eb0..b3da451 100755 --- a/src/silx/gui/plot/items/marker.py +++ b/src/silx/gui/plot/items/marker.py @@ -1,6 +1,6 @@ # /*########################################################################## # -# Copyright (c) 2017-2020 European Synchrotron Radiation Facility +# Copyright (c) 2017-2023 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -23,6 +23,7 @@ # ###########################################################################*/ """This module provides markers item of the :class:`Plot`. """ +from __future__ import annotations __authors__ = ["T. Vincent"] __license__ = "MIT" @@ -30,11 +31,22 @@ __date__ = "06/03/2017" import logging +import numpy from ....utils.proxy import docstring -from .core import (Item, DraggableMixIn, ColorMixIn, LineMixIn, SymbolMixIn, - ItemChangedType, YAxisMixIn) +from .core import ( + Item, + DraggableMixIn, + ColorMixIn, + LineMixIn, + SymbolMixIn, + ItemChangedType, + YAxisMixIn, +) +from silx import config from silx.gui import qt +from silx.gui import colors + _logger = logging.getLogger(__name__) @@ -47,7 +59,7 @@ class MarkerBase(Item, DraggableMixIn, ColorMixIn, YAxisMixIn): sigDragFinished = qt.Signal() """Signal emitted when the marker is released""" - _DEFAULT_COLOR = (0., 0., 0., 1.) + _DEFAULT_COLOR = (0.0, 0.0, 0.0, 1.0) """Default color of the markers""" def __init__(self): @@ -56,14 +68,21 @@ class MarkerBase(Item, DraggableMixIn, ColorMixIn, YAxisMixIn): ColorMixIn.__init__(self) YAxisMixIn.__init__(self) - self._text = '' + self._text = "" + self._font = None + if config.DEFAULT_PLOT_MARKER_TEXT_FONT_SIZE is not None: + self._font = qt.QFont( + qt.QApplication.instance().font().family(), + config.DEFAULT_PLOT_MARKER_TEXT_FONT_SIZE, + ) + self._x = None self._y = None + self._bgColor: colors.RGBAColorType | None = None self._constraint = self._defaultConstraint self.__isBeingDragged = False - def _addRendererCall(self, backend, - symbol=None, linestyle='-', linewidth=1): + def _addRendererCall(self, backend, symbol=None, linestyle="-", linewidth=1): """Perform the update of the backend renderer""" return backend.addMarker( x=self.getXPosition(), @@ -74,7 +93,10 @@ class MarkerBase(Item, DraggableMixIn, ColorMixIn, YAxisMixIn): linestyle=linestyle, linewidth=linewidth, constraint=self.getConstraint(), - yaxis=self.getYAxis()) + yaxis=self.getYAxis(), + font=self._font, # Do not use getFont to spare creating a new QFont + bgcolor=self.getBackgroundColor(), + ) def _addBackendRenderer(self, backend): """Update backend renderer""" @@ -108,6 +130,39 @@ class MarkerBase(Item, DraggableMixIn, ColorMixIn, YAxisMixIn): self._text = text self._updated(ItemChangedType.TEXT) + def getFont(self) -> qt.QFont | None: + """Returns a copy of the QFont used to render text. + + To modify the text font, use :meth:`setFont`. + """ + return None if self._font is None else qt.QFont(self._font) + + def setFont(self, font: qt.QFont | None): + """Set the QFont used to render text, use None for default. + + A copy is stored, so further modification of the provided font are not taken into account. + """ + if font != self._font: + self._font = None if font is None else qt.QFont(font) + self._updated(ItemChangedType.FONT) + + def getBackgroundColor(self) -> colors.RGBAColorType | None: + """Returns the RGBA background color of the item""" + return self._bgColor + + def setBackgroundColor(self, color): + """Set item text background color + + :param color: color(s) to be used as a str ("#RRGGBB") or (npoints, 4) + unsigned byte array or one of the predefined color names + defined in colors.py + """ + if color is not None: + color = colors.rgba(color) + if self._bgColor != color: + self._bgColor = color + self._updated(ItemChangedType.BACKGROUND_COLOR) + def getXPosition(self): """Returns the X position of the marker line in data coordinates @@ -122,14 +177,14 @@ class MarkerBase(Item, DraggableMixIn, ColorMixIn, YAxisMixIn): """ return self._y - def getPosition(self): + def getPosition(self) -> tuple[float | None, float | None]: """Returns the (x, y) position of the marker in data coordinates :rtype: 2-tuple of float or None """ return self._x, self._y - def setPosition(self, x, y): + def setPosition(self, x: float, y: float): """Set marker position in data coordinates Constraint are applied if any. @@ -188,15 +243,15 @@ class MarkerBase(Item, DraggableMixIn, ColorMixIn, YAxisMixIn): class Marker(MarkerBase, SymbolMixIn): """Description of a marker""" - _DEFAULT_SYMBOL = '+' + _DEFAULT_SYMBOL = "+" """Default symbol of the marker""" def __init__(self): MarkerBase.__init__(self) SymbolMixIn.__init__(self) - self._x = 0. - self._y = 0. + self._x = 0.0 + self._y = 0.0 def _addBackendRenderer(self, backend): return self._addRendererCall(backend, symbol=self.getSymbol()) @@ -209,9 +264,9 @@ class Marker(MarkerBase, SymbolMixIn): :param constraint: The constraint of the dragging of this marker :type: constraint: callable or str """ - if constraint == 'horizontal': + if constraint == "horizontal": constraint = self._horizontalConstraint - elif constraint == 'vertical': + elif constraint == "vertical": constraint = self._verticalConstraint super(Marker, self)._setConstraint(constraint) @@ -231,9 +286,9 @@ class _LineMarker(MarkerBase, LineMixIn): LineMixIn.__init__(self) def _addBackendRenderer(self, backend): - return self._addRendererCall(backend, - linestyle=self.getLineStyle(), - linewidth=self.getLineWidth()) + return self._addRendererCall( + backend, linestyle=self.getLineStyle(), linewidth=self.getLineWidth() + ) class XMarker(_LineMarker): @@ -241,7 +296,7 @@ class XMarker(_LineMarker): def __init__(self): _LineMarker.__init__(self) - self._x = 0. + self._x = 0.0 def setPosition(self, x, y): """Set marker line position in data coordinates @@ -263,7 +318,7 @@ class YMarker(_LineMarker): def __init__(self): _LineMarker.__init__(self) - self._y = 0. + self._y = 0.0 def setPosition(self, x, y): """Set marker line position in data coordinates diff --git a/src/silx/gui/plot/items/roi.py b/src/silx/gui/plot/items/roi.py index 559e7e0..7390b88 100644 --- a/src/silx/gui/plot/items/roi.py +++ b/src/silx/gui/plot/items/roi.py @@ -35,6 +35,7 @@ __date__ = "28/06/2018" import logging import numpy +from typing import Tuple from ... import utils from .. import items @@ -60,15 +61,15 @@ logger = logging.getLogger(__name__) class PointROI(RegionOfInterest, items.SymbolMixIn): """A ROI identifying a point in a 2D plot.""" - ICON = 'add-shape-point' - NAME = 'point markers' + ICON = "add-shape-point" + NAME = "point markers" SHORT_NAME = "point" """Metadata for this kind of ROI""" _plotShape = "point" """Plot shape which is used for the first interaction""" - _DEFAULT_SYMBOL = '+' + _DEFAULT_SYMBOL = "+" """Default symbol of the PointROI It overwrite the `SymbolMixIn` class attribte. @@ -88,30 +89,26 @@ class PointROI(RegionOfInterest, items.SymbolMixIn): self.setPosition(points[0]) def _updated(self, event=None, checkVisibility=True): - if event == items.ItemChangedType.NAME: - label = self.getName() - self._marker.setText(label) - elif event == items.ItemChangedType.EDITABLE: + if event == items.ItemChangedType.EDITABLE: self._marker._setDraggable(self.isEditable()) - elif event in [items.ItemChangedType.VISIBLE, - items.ItemChangedType.SELECTABLE]: + elif event in [items.ItemChangedType.VISIBLE, items.ItemChangedType.SELECTABLE]: self._updateItemProperty(event, self, self._marker) super(PointROI, self)._updated(event, checkVisibility) + def _updateText(self, text: str): + self._marker.setText(text) + def _updatedStyle(self, event, style): self._marker.setColor(style.getColor()) - def getPosition(self): - """Returns the position of this ROI - - :rtype: numpy.ndarray - """ + def getPosition(self) -> Tuple[float, float]: + """Returns the position of this ROI""" return self._marker.getPosition() def setPosition(self, pos): """Set the position of this ROI - :param numpy.ndarray pos: 2d-coordinate of this point + :param pos: 2d-coordinate of this point """ self._marker.setPosition(*pos) @@ -126,16 +123,15 @@ class PointROI(RegionOfInterest, items.SymbolMixIn): self.sigRegionChanged.emit() def __str__(self): - params = '%f %f' % self.getPosition() + params = "%f %f" % self.getPosition() return "%s(%s)" % (self.__class__.__name__, params) class CrossROI(HandleBasedROI, items.LineMixIn): - """A ROI identifying a point in a 2D plot and displayed as a cross - """ + """A ROI identifying a point in a 2D plot and displayed as a cross""" - ICON = 'add-shape-cross' - NAME = 'cross marker' + ICON = "add-shape-cross" + NAME = "cross marker" SHORT_NAME = "cross" """Metadata for this kind of ROI""" @@ -177,17 +173,14 @@ class CrossROI(HandleBasedROI, items.LineMixIn): pos = points[0] self.setPosition(pos) - def getPosition(self): - """Returns the position of this ROI - - :rtype: numpy.ndarray - """ + def getPosition(self) -> Tuple[float, float]: + """Returns the position of this ROI""" return self._handle.getPosition() - def setPosition(self, pos): + def setPosition(self, pos: Tuple[float, float]): """Set the position of this ROI - :param numpy.ndarray pos: 2d-coordinate of this point + :param pos: 2d-coordinate of this point """ self._handle.setPosition(*pos) @@ -213,8 +206,8 @@ class LineROI(HandleBasedROI, items.LineMixIn): in the center to translate the full ROI. """ - ICON = 'add-shape-diagonal' - NAME = 'line ROI' + ICON = "add-shape-diagonal" + NAME = "line ROI" SHORT_NAME = "line" """Metadata for this kind of ROI""" @@ -244,11 +237,12 @@ class LineROI(HandleBasedROI, items.LineMixIn): self._updateItemProperty(event, self, self.__shape) super(LineROI, self)._updated(event, checkVisibility) - def _updatedStyle(self, event, style): + def _updatedStyle(self, event, style: items.CurveStyle): super(LineROI, self)._updatedStyle(event, style) self.__shape.setColor(style.getColor()) self.__shape.setLineStyle(style.getLineStyle()) self.__shape.setLineWidth(style.getLineWidth()) + self.__shape.setLineGapColor(style.getLineGapColor()) def setFirstShapePoints(self, points): assert len(points) == 2 @@ -257,7 +251,7 @@ class LineROI(HandleBasedROI, items.LineMixIn): def _updateText(self, text): self._handleLabel.setText(text) - def setEndPoints(self, startPoint, endPoint): + def setEndPoints(self, startPoint: numpy.ndarray, endPoint: numpy.ndarray): """Set this line location using the ending points :param numpy.ndarray startPoint: Staring bounding point of the line @@ -266,7 +260,7 @@ class LineROI(HandleBasedROI, items.LineMixIn): if not numpy.array_equal((startPoint, endPoint), self.getEndPoints()): self.__updateEndPoints(startPoint, endPoint) - def __updateEndPoints(self, startPoint, endPoint): + def __updateEndPoints(self, startPoint: numpy.ndarray, endPoint: numpy.ndarray): """Update marker and shape to match given end points :param numpy.ndarray startPoint: Staring bounding point of the line @@ -328,28 +322,44 @@ class LineROI(HandleBasedROI, items.LineMixIn): return False return ( - segments_intersection(seg1_start_pt=line_pt1, seg1_end_pt=line_pt2, - seg2_start_pt=bottom_left, seg2_end_pt=bottom_right) or - segments_intersection(seg1_start_pt=line_pt1, seg1_end_pt=line_pt2, - seg2_start_pt=bottom_right, seg2_end_pt=top_right) or - segments_intersection(seg1_start_pt=line_pt1, seg1_end_pt=line_pt2, - seg2_start_pt=top_right, seg2_end_pt=top_left) or - segments_intersection(seg1_start_pt=line_pt1, seg1_end_pt=line_pt2, - seg2_start_pt=top_left, seg2_end_pt=bottom_left) + segments_intersection( + seg1_start_pt=line_pt1, + seg1_end_pt=line_pt2, + seg2_start_pt=bottom_left, + seg2_end_pt=bottom_right, + ) + or segments_intersection( + seg1_start_pt=line_pt1, + seg1_end_pt=line_pt2, + seg2_start_pt=bottom_right, + seg2_end_pt=top_right, + ) + or segments_intersection( + seg1_start_pt=line_pt1, + seg1_end_pt=line_pt2, + seg2_start_pt=top_right, + seg2_end_pt=top_left, + ) + or segments_intersection( + seg1_start_pt=line_pt1, + seg1_end_pt=line_pt2, + seg2_start_pt=top_left, + seg2_end_pt=bottom_left, + ) ) is not None def __str__(self): start, end = self.getEndPoints() params = start[0], start[1], end[0], end[1] - params = 'start: %f %f; end: %f %f' % params + params = "start: %f %f; end: %f %f" % params return "%s(%s)" % (self.__class__.__name__, params) class HorizontalLineROI(RegionOfInterest, items.LineMixIn): """A ROI identifying an horizontal line in a 2D plot.""" - ICON = 'add-shape-horizontal' - NAME = 'horizontal line ROI' + ICON = "add-shape-horizontal" + NAME = "horizontal line ROI" SHORT_NAME = "hline" """Metadata for this kind of ROI""" @@ -366,16 +376,15 @@ class HorizontalLineROI(RegionOfInterest, items.LineMixIn): self.addItem(self._marker) def _updated(self, event=None, checkVisibility=True): - if event == items.ItemChangedType.NAME: - label = self.getName() - self._marker.setText(label) - elif event == items.ItemChangedType.EDITABLE: + if event == items.ItemChangedType.EDITABLE: self._marker._setDraggable(self.isEditable()) - elif event in [items.ItemChangedType.VISIBLE, - items.ItemChangedType.SELECTABLE]: + elif event in [items.ItemChangedType.VISIBLE, items.ItemChangedType.SELECTABLE]: self._updateItemProperty(event, self, self._marker) super(HorizontalLineROI, self)._updated(event, checkVisibility) + def _updateText(self, text: str): + self._marker.setText(text) + def _updatedStyle(self, event, style): self._marker.setColor(style.getColor()) self._marker.setLineStyle(style.getLineStyle()) @@ -387,18 +396,15 @@ class HorizontalLineROI(RegionOfInterest, items.LineMixIn): return self.setPosition(pos) - def getPosition(self): - """Returns the position of this line if the horizontal axis - - :rtype: float - """ + def getPosition(self) -> float: + """Returns the position of this line if the horizontal axis""" pos = self._marker.getPosition() return pos[1] - def setPosition(self, pos): + def setPosition(self, pos: float): """Set the position of this ROI - :param float pos: Horizontal position of this line + :param pos: Horizontal position of this line """ self._marker.setPosition(0, pos) @@ -412,15 +418,15 @@ class HorizontalLineROI(RegionOfInterest, items.LineMixIn): self.sigRegionChanged.emit() def __str__(self): - params = 'y: %f' % self.getPosition() + params = "y: %f" % self.getPosition() return "%s(%s)" % (self.__class__.__name__, params) class VerticalLineROI(RegionOfInterest, items.LineMixIn): """A ROI identifying a vertical line in a 2D plot.""" - ICON = 'add-shape-vertical' - NAME = 'vertical line ROI' + ICON = "add-shape-vertical" + NAME = "vertical line ROI" SHORT_NAME = "vline" """Metadata for this kind of ROI""" @@ -437,16 +443,15 @@ class VerticalLineROI(RegionOfInterest, items.LineMixIn): self.addItem(self._marker) def _updated(self, event=None, checkVisibility=True): - if event == items.ItemChangedType.NAME: - label = self.getName() - self._marker.setText(label) - elif event == items.ItemChangedType.EDITABLE: + if event == items.ItemChangedType.EDITABLE: self._marker._setDraggable(self.isEditable()) - elif event in [items.ItemChangedType.VISIBLE, - items.ItemChangedType.SELECTABLE]: + elif event in [items.ItemChangedType.VISIBLE, items.ItemChangedType.SELECTABLE]: self._updateItemProperty(event, self, self._marker) super(VerticalLineROI, self)._updated(event, checkVisibility) + def _updateText(self, text: str): + self._marker.setText(text) + def _updatedStyle(self, event, style): self._marker.setColor(style.getColor()) self._marker.setLineStyle(style.getLineStyle()) @@ -456,15 +461,12 @@ class VerticalLineROI(RegionOfInterest, items.LineMixIn): pos = points[0, 0] self.setPosition(pos) - def getPosition(self): - """Returns the position of this line if the horizontal axis - - :rtype: float - """ + def getPosition(self) -> float: + """Returns the position of this line if the horizontal axis""" pos = self._marker.getPosition() return pos[0] - def setPosition(self, pos): + def setPosition(self, pos: float): """Set the position of this ROI :param float pos: Horizontal position of this line @@ -481,7 +483,7 @@ class VerticalLineROI(RegionOfInterest, items.LineMixIn): self.sigRegionChanged.emit() def __str__(self): - params = 'x: %f' % self.getPosition() + params = "x: %f" % self.getPosition() return "%s(%s)" % (self.__class__.__name__, params) @@ -492,8 +494,8 @@ class RectangleROI(HandleBasedROI, items.LineMixIn): center to translate the full ROI. """ - ICON = 'add-shape-rectangle' - NAME = 'rectangle ROI' + ICON = "add-shape-rectangle" + NAME = "rectangle ROI" SHORT_NAME = "rectangle" """Metadata for this kind of ROI""" @@ -530,6 +532,7 @@ class RectangleROI(HandleBasedROI, items.LineMixIn): self.__shape.setColor(style.getColor()) self.__shape.setLineStyle(style.getLineStyle()) self.__shape.setLineWidth(style.getLineWidth()) + self.__shape.setLineGapColor(style.getLineGapColor()) def setFirstShapePoints(self, points): assert len(points) == 2 @@ -598,11 +601,12 @@ class RectangleROI(HandleBasedROI, items.LineMixIn): self.setGeometry(center=position, size=size) def setGeometry(self, origin=None, size=None, center=None): - """Set the geometry of the ROI - """ - if ((origin is None or numpy.array_equal(origin, self.getOrigin())) and - (center is None or numpy.array_equal(center, self.getCenter())) and - numpy.array_equal(size, self.getSize())): + """Set the geometry of the ROI""" + if ( + (origin is None or numpy.array_equal(origin, self.getOrigin())) + and (center is None or numpy.array_equal(center, self.getCenter())) + and numpy.array_equal(size, self.getSize()) + ): return # Nothing has changed self._updateGeometry(origin, size, center) @@ -661,17 +665,38 @@ class RectangleROI(HandleBasedROI, items.LineMixIn): points = numpy.array([current, current2]) # Switch handles if they were crossed by interaction - if self._handleBottomLeft.getXPosition() > self._handleBottomRight.getXPosition(): - self._handleBottomLeft, self._handleBottomRight = self._handleBottomRight, self._handleBottomLeft + if ( + self._handleBottomLeft.getXPosition() + > self._handleBottomRight.getXPosition() + ): + self._handleBottomLeft, self._handleBottomRight = ( + self._handleBottomRight, + self._handleBottomLeft, + ) if self._handleTopLeft.getXPosition() > self._handleTopRight.getXPosition(): - self._handleTopLeft, self._handleTopRight = self._handleTopRight, self._handleTopLeft - - if self._handleBottomLeft.getYPosition() > self._handleTopLeft.getYPosition(): - self._handleBottomLeft, self._handleTopLeft = self._handleTopLeft, self._handleBottomLeft - - if self._handleBottomRight.getYPosition() > self._handleTopRight.getYPosition(): - self._handleBottomRight, self._handleTopRight = self._handleTopRight, self._handleBottomRight + self._handleTopLeft, self._handleTopRight = ( + self._handleTopRight, + self._handleTopLeft, + ) + + if ( + self._handleBottomLeft.getYPosition() + > self._handleTopLeft.getYPosition() + ): + self._handleBottomLeft, self._handleTopLeft = ( + self._handleTopLeft, + self._handleBottomLeft, + ) + + if ( + self._handleBottomRight.getYPosition() + > self._handleTopRight.getYPosition() + ): + self._handleBottomRight, self._handleTopRight = ( + self._handleTopRight, + self._handleBottomRight, + ) self._setBound(points) @@ -679,7 +704,7 @@ class RectangleROI(HandleBasedROI, items.LineMixIn): origin = self.getOrigin() w, h = self.getSize() params = origin[0], origin[1], w, h - params = 'origin: %f %f; width: %f; height: %f' % params + params = "origin: %f %f; width: %f; height: %f" % params return "%s(%s)" % (self.__class__.__name__, params) @@ -690,8 +715,8 @@ class CircleROI(HandleBasedROI, items.LineMixIn): and one anchor on the perimeter to change the radius. """ - ICON = 'add-shape-circle' - NAME = 'circle ROI' + ICON = "add-shape-circle" + NAME = "circle ROI" SHORT_NAME = "circle" """Metadata for this kind of ROI""" @@ -731,6 +756,7 @@ class CircleROI(HandleBasedROI, items.LineMixIn): self.__shape.setColor(style.getColor()) self.__shape.setLineStyle(style.getLineStyle()) self.__shape.setLineWidth(style.getLineWidth()) + self.__shape.setLineGapColor(style.getLineGapColor()) def setFirstShapePoints(self, points): assert len(points) == 2 @@ -779,8 +805,7 @@ class CircleROI(HandleBasedROI, items.LineMixIn): self._updateGeometry() def setGeometry(self, center, radius): - """Set the geometry of the ROI - """ + """Set the geometry of the ROI""" if numpy.array_equal(center, self.getCenter()): self.setRadius(radius) else: @@ -797,8 +822,9 @@ class CircleROI(HandleBasedROI, items.LineMixIn): nbpoints = 27 angles = numpy.arange(nbpoints) * 2.0 * numpy.pi / nbpoints - circleShape = numpy.array((numpy.cos(angles) * self.__radius, - numpy.sin(angles) * self.__radius)).T + circleShape = numpy.array( + (numpy.cos(angles) * self.__radius, numpy.sin(angles) * self.__radius) + ).T circleShape += center self.__shape.setPoints(circleShape) self.sigRegionChanged.emit() @@ -821,7 +847,7 @@ class CircleROI(HandleBasedROI, items.LineMixIn): center = self.getCenter() radius = self.getRadius() params = center[0], center[1], radius - params = 'center: %f %f; radius: %f;' % params + params = "center: %f %f; radius: %f;" % params return "%s(%s)" % (self.__class__.__name__, params) @@ -833,8 +859,8 @@ class EllipseROI(HandleBasedROI, items.LineMixIn): minor-radius. These two anchors also allow to change the orientation. """ - ICON = 'add-shape-ellipse' - NAME = 'ellipse ROI' + ICON = "add-shape-ellipse" + NAME = "ellipse ROI" SHORT_NAME = "ellipse" """Metadata for this kind of ROI""" @@ -860,8 +886,10 @@ class EllipseROI(HandleBasedROI, items.LineMixIn): self.__shape = shape self.addItem(shape) - self._radius = 0., 0. - self._orientation = 0. # angle in radians between the X-axis and the _handleAxis0 + self._radius = 0.0, 0.0 + self._orientation = ( + 0.0 # angle in radians between the X-axis and the _handleAxis0 + ) def _updated(self, event=None, checkVisibility=True): if event == items.ItemChangedType.VISIBLE: @@ -873,6 +901,7 @@ class EllipseROI(HandleBasedROI, items.LineMixIn): self.__shape.setColor(style.getColor()) self.__shape.setLineStyle(style.getLineStyle()) self.__shape.setLineWidth(style.getLineWidth()) + self.__shape.setLineGapColor(style.getLineGapColor()) def setFirstShapePoints(self, points): assert len(points) == 2 @@ -905,9 +934,9 @@ class EllipseROI(HandleBasedROI, items.LineMixIn): center = points[0] radius = numpy.linalg.norm(points[0] - points[1]) orientation = self._calculateOrientation(points[0], points[1]) - self.setGeometry(center=center, - radius=(radius, radius), - orientation=orientation) + self.setGeometry( + center=center, radius=(radius, radius), orientation=orientation + ) def _updateText(self, text): self._handleLabel.setText(text) @@ -1007,10 +1036,11 @@ class EllipseROI(HandleBasedROI, items.LineMixIn): # ensure that we store the orientation in range [0, 2*pi orientation = numpy.mod(orientation, 2 * numpy.pi) - if (numpy.array_equal(center, self.getCenter()) or - radius != self._radius or - orientation != self._orientation): - + if ( + numpy.array_equal(center, self.getCenter()) + or radius != self._radius + or orientation != self._orientation + ): # Update parameters directly self._radius = radius self._orientation = orientation @@ -1030,10 +1060,18 @@ class EllipseROI(HandleBasedROI, items.LineMixIn): # _handleAxis1 is the major axis orientation -= numpy.pi / 2 - point0 = numpy.array([center[0] + self._radius[0] * numpy.cos(orientation), - center[1] + self._radius[0] * numpy.sin(orientation)]) - point1 = numpy.array([center[0] - self._radius[1] * numpy.sin(orientation), - center[1] + self._radius[1] * numpy.cos(orientation)]) + point0 = numpy.array( + [ + center[0] + self._radius[0] * numpy.cos(orientation), + center[1] + self._radius[0] * numpy.sin(orientation), + ] + ) + point1 = numpy.array( + [ + center[0] - self._radius[1] * numpy.sin(orientation), + center[1] + self._radius[1] * numpy.cos(orientation), + ] + ) with utils.blockSignals(self._handleAxis0): self._handleAxis0.setPosition(*point0) with utils.blockSignals(self._handleAxis1): @@ -1043,10 +1081,12 @@ class EllipseROI(HandleBasedROI, items.LineMixIn): nbpoints = 27 angles = numpy.arange(nbpoints) * 2.0 * numpy.pi / nbpoints - X = (self._radius[0] * numpy.cos(angles) * numpy.cos(orientation) - - self._radius[1] * numpy.sin(angles) * numpy.sin(orientation)) - Y = (self._radius[0] * numpy.cos(angles) * numpy.sin(orientation) - + self._radius[1] * numpy.sin(angles) * numpy.cos(orientation)) + X = self._radius[0] * numpy.cos(angles) * numpy.cos(orientation) - self._radius[ + 1 + ] * numpy.sin(angles) * numpy.sin(orientation) + Y = self._radius[0] * numpy.cos(angles) * numpy.sin(orientation) + self._radius[ + 1 + ] * numpy.sin(angles) * numpy.cos(orientation) ellipseShape = numpy.array((X, Y)).T ellipseShape += center @@ -1083,8 +1123,10 @@ class EllipseROI(HandleBasedROI, items.LineMixIn): major, minor = self.getMajorRadius(), self.getMinorRadius() delta = self.getOrientation() x, y = position - self.getCenter() - return ((x*numpy.cos(delta) + y*numpy.sin(delta))**2/major**2 + - (x*numpy.sin(delta) - y*numpy.cos(delta))**2/minor**2) <= 1 + return ( + (x * numpy.cos(delta) + y * numpy.sin(delta)) ** 2 / major**2 + + (x * numpy.sin(delta) - y * numpy.cos(delta)) ** 2 / minor**2 + ) <= 1 def __str__(self): center = self.getCenter() @@ -1092,7 +1134,10 @@ class EllipseROI(HandleBasedROI, items.LineMixIn): minor = self.getMinorRadius() orientation = self.getOrientation() params = center[0], center[1], major, minor, orientation - params = 'center: %f %f; major radius: %f: minor radius: %f; orientation: %f' % params + params = ( + "center: %f %f; major radius: %f: minor radius: %f; orientation: %f" + % params + ) return "%s(%s)" % (self.__class__.__name__, params) @@ -1102,8 +1147,8 @@ class PolygonROI(HandleBasedROI, items.LineMixIn): This ROI provides 1 anchor for each point of the polygon. """ - ICON = 'add-shape-polygon' - NAME = 'polygon ROI' + ICON = "add-shape-polygon" + NAME = "polygon ROI" SHORT_NAME = "polygon" """Metadata for this kind of ROI""" @@ -1134,6 +1179,7 @@ class PolygonROI(HandleBasedROI, items.LineMixIn): self.__shape.setColor(style.getColor()) self.__shape.setLineStyle(style.getLineStyle()) self.__shape.setLineWidth(style.getLineWidth()) + self.__shape.setLineGapColor(style.getLineGapColor()) if self._handleClose is not None: color = self._computeHandleColor(style.getColor()) self._handleClose.setColor(color) @@ -1156,8 +1202,7 @@ class PolygonROI(HandleBasedROI, items.LineMixIn): self.setPoints(points) def creationStarted(self): - """"Called when the ROI creation interaction was started. - """ + """Called when the ROI creation interaction was started.""" # Handle to see where to close the polygon self._handleClose = self.addUserHandle() self._handleClose.setSymbol("o") @@ -1178,8 +1223,7 @@ class PolygonROI(HandleBasedROI, items.LineMixIn): return self._handleClose is not None def creationFinalized(self): - """"Called when the ROI creation interaction was finalized. - """ + """Called when the ROI creation interaction was finalized.""" self.removeHandle(self._handleClose) self._handleClose = None self.removeItem(self.__shape) @@ -1206,7 +1250,7 @@ class PolygonROI(HandleBasedROI, items.LineMixIn): :param numpy.ndarray pos: 2d-coordinate of this point """ - assert(len(points.shape) == 2 and points.shape[1] == 2) + assert len(points.shape) == 2 and points.shape[1] == 2 if numpy.array_equal(points, self._points): return # Nothing has changed @@ -1277,7 +1321,7 @@ class PolygonROI(HandleBasedROI, items.LineMixIn): def __str__(self): points = self._points - params = '; '.join('%f %f' % (pt[0], pt[1]) for pt in points) + params = "; ".join("%f %f" % (pt[0], pt[1]) for pt in points) return "%s(%s)" % (self.__class__.__name__, params) @docstring(HandleBasedROI) @@ -1300,8 +1344,8 @@ class PolygonROI(HandleBasedROI, items.LineMixIn): class HorizontalRangeROI(RegionOfInterest, items.LineMixIn): """A ROI identifying an horizontal range in a 1D plot.""" - ICON = 'add-range-horizontal' - NAME = 'horizontal range ROI' + ICON = "add-range-horizontal" + NAME = "horizontal range ROI" SHORT_NAME = "hrange" _plotShape = "line" @@ -1333,16 +1377,13 @@ class HorizontalRangeROI(RegionOfInterest, items.LineMixIn): self._updatePos(vmin, vmax) def _updated(self, event=None, checkVisibility=True): - if event == items.ItemChangedType.NAME: - self._updateText() - elif event == items.ItemChangedType.EDITABLE: + if event == items.ItemChangedType.EDITABLE: self._updateEditable() - self._updateText() + self._updateText(self.getText()) elif event == items.ItemChangedType.LINE_STYLE: markers = [self._markerMin, self._markerMax] self._updateItemProperty(event, self, markers) - elif event in [items.ItemChangedType.VISIBLE, - items.ItemChangedType.SELECTABLE]: + elif event in [items.ItemChangedType.VISIBLE, items.ItemChangedType.SELECTABLE]: markers = [self._markerMin, self._markerMax, self._markerCen] self._updateItemProperty(event, self, markers) super(HorizontalRangeROI, self)._updated(event, checkVisibility) @@ -1353,8 +1394,7 @@ class HorizontalRangeROI(RegionOfInterest, items.LineMixIn): m.setColor(style.getColor()) m.setLineWidth(style.getLineWidth()) - def _updateText(self): - text = self.getName() + def _updateText(self, text: str): if self.isEditable(): self._markerMin.setText("") self._markerCen.setText(text) @@ -1409,8 +1449,10 @@ class HorizontalRangeROI(RegionOfInterest, items.LineMixIn): err = "Can't set vmin or vmax to None" raise ValueError(err) if vmin > vmax: - err = "Can't set vmin and vmax because vmin >= vmax " \ - "vmin = %s, vmax = %s" % (vmin, vmax) + err = ( + "Can't set vmin and vmax because vmin >= vmax " + "vmin = %s, vmax = %s" % (vmin, vmax) + ) raise ValueError(err) self._updatePos(vmin, vmax) @@ -1515,5 +1557,5 @@ class HorizontalRangeROI(RegionOfInterest, items.LineMixIn): def __str__(self): vrange = self.getRange() - params = 'min: %f; max: %f' % vrange + params = "min: %f; max: %f" % vrange return "%s(%s)" % (self.__class__.__name__, params) diff --git a/src/silx/gui/plot/items/scatter.py b/src/silx/gui/plot/items/scatter.py index 96fb311..c46b60c 100644 --- a/src/silx/gui/plot/items/scatter.py +++ b/src/silx/gui/plot/items/scatter.py @@ -1,6 +1,6 @@ # /*########################################################################## # -# Copyright (c) 2017-2022 European Synchrotron Radiation Facility +# Copyright (c) 2017-2023 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -33,6 +33,7 @@ from collections import namedtuple import logging import threading import numpy +from matplotlib.tri import LinearTriInterpolator, Triangulation from collections import defaultdict from concurrent.futures import ThreadPoolExecutor, CancelledError @@ -41,7 +42,6 @@ from ....utils.proxy import docstring from ....math.combo import min_max from ....math.histogram import Histogramnd from ....utils.weakref import WeakList -from .._utils.delaunay import delaunay from .core import PointsBase, ColormapMixIn, ScatterVisualizationMixIn from .axis import Axis from ._pick import PickingResult @@ -51,8 +51,7 @@ _logger = logging.getLogger(__name__) class _GreedyThreadPoolExecutor(ThreadPoolExecutor): - """:class:`ThreadPoolExecutor` with an extra :meth:`submit_greedy` method. - """ + """:class:`ThreadPoolExecutor` with an extra :meth:`submit_greedy` method.""" def __init__(self, *args, **kwargs): super(_GreedyThreadPoolExecutor, self).__init__(*args, **kwargs) @@ -76,8 +75,7 @@ class _GreedyThreadPoolExecutor(ThreadPoolExecutor): if not future.done(): future.cancel() - future = super(_GreedyThreadPoolExecutor, self).submit( - fn, *args, **kwargs) + future = super(_GreedyThreadPoolExecutor, self).submit(fn, *args, **kwargs) self.__futures[queue].append(future) return future @@ -85,6 +83,7 @@ class _GreedyThreadPoolExecutor(ThreadPoolExecutor): # Functions to guess grid shape from coordinates + def _get_z_line_length(array): """Return length of line if array is a Z-like 2D regular grid. @@ -97,7 +96,7 @@ def _get_z_line_length(array): if len(sign) == 0 or sign[0] == 0: # We don't handle that return 0 # Check this way to account for 0 sign (i.e., diff == 0) - beginnings = numpy.where(sign == - sign[0])[0] + 1 + beginnings = numpy.where(sign == -sign[0])[0] + 1 if len(beginnings) == 0: return 0 length = beginnings[0] @@ -121,11 +120,11 @@ def _guess_z_grid_shape(x, y): """ width = _get_z_line_length(x) if width != 0: - return 'row', (int(numpy.ceil(len(x) / width)), width) + return "row", (int(numpy.ceil(len(x) / width)), width) else: height = _get_z_line_length(y) if height != 0: - return 'column', (height, int(numpy.ceil(len(y) / height))) + return "column", (height, int(numpy.ceil(len(y) / height))) return None @@ -139,7 +138,7 @@ def is_monotonic(array): :rtype: int """ diff = numpy.diff(numpy.ravel(array)) - with numpy.errstate(invalid='ignore'): + with numpy.errstate(invalid="ignore"): if numpy.all(diff >= 0): return 1 elif numpy.all(diff <= 0): @@ -168,7 +167,7 @@ def _guess_grid(x, y): else: # Cannot guess a regular grid # Let's assume it's a single line - order = 'row' # or 'column' doesn't matter for a single line + order = "row" # or 'column' doesn't matter for a single line y_monotonic = is_monotonic(y) if is_monotonic(x) or y_monotonic: # we can guess a line x_min, x_max = min_max(x) @@ -211,18 +210,24 @@ def _quadrilateral_grid_coords(points): neighbour_view = numpy.lib.stride_tricks.as_strided( points, shape=(dim0 - 1, dim1 - 1, 2, 2, points.shape[2]), - strides=points.strides[:2] + points.strides[:2] + points.strides[-1:], writeable=False) + strides=points.strides[:2] + points.strides[:2] + points.strides[-1:], + writeable=False, + ) inner_points = numpy.mean(neighbour_view, axis=(2, 3)) grid_points[1:-1, 1:-1] = inner_points # Compute 'vertical' sides # Alternative: grid_points[1:-1, [0, -1]] = points[:-1, [0, -1]] + points[1:, [0, -1]] - inner_points[:, [0, -1]] - grid_points[1:-1, [0, -1], 0] = points[:-1, [0, -1], 0] + points[1:, [0, -1], 0] - inner_points[:, [0, -1], 0] + grid_points[1:-1, [0, -1], 0] = ( + points[:-1, [0, -1], 0] + points[1:, [0, -1], 0] - inner_points[:, [0, -1], 0] + ) grid_points[1:-1, [0, -1], 1] = inner_points[:, [0, -1], 1] # Compute 'horizontal' sides grid_points[[0, -1], 1:-1, 0] = inner_points[[0, -1], :, 0] - grid_points[[0, -1], 1:-1, 1] = points[[0, -1], :-1, 1] + points[[0, -1], 1:, 1] - inner_points[[0, -1], :, 1] + grid_points[[0, -1], 1:-1, 1] = ( + points[[0, -1], :-1, 1] + points[[0, -1], 1:, 1] - inner_points[[0, -1], :, 1] + ) # Compute corners d0, d1 = [0, 0, -1, -1], [0, -1, -1, 0] @@ -259,11 +264,13 @@ def _quadrilateral_grid_as_triangles(points): _RegularGridInfo = namedtuple( - '_RegularGridInfo', ['bounds', 'origin', 'scale', 'shape', 'order']) + "_RegularGridInfo", ["bounds", "origin", "scale", "shape", "order"] +) _HistogramInfo = namedtuple( - '_HistogramInfo', ['mean', 'count', 'sum', 'origin', 'scale', 'shape']) + "_HistogramInfo", ["mean", "count", "sum", "origin", "scale", "shape"] +) class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): @@ -278,7 +285,7 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): ScatterVisualizationMixIn.Visualization.REGULAR_GRID, ScatterVisualizationMixIn.Visualization.IRREGULAR_GRID, ScatterVisualizationMixIn.Visualization.BINNED_STATISTIC, - ) + ) """Overrides supported Visualizations""" def __init__(self): @@ -288,7 +295,7 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): self._value = () self.__alpha = None # Cache Delaunay triangulation future object - self.__delaunayFuture = None + self.__triangulationFuture = None # Cache interpolator future object self.__interpolatorFuture = None self.__executor = None @@ -310,7 +317,9 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): data = getattr( histoInfo, self.getVisualizationParameter( - self.VisualizationParameter.BINNED_STATISTIC_FUNCTION)) + self.VisualizationParameter.BINNED_STATISTIC_FUNCTION + ), + ) else: data = self.getValueData(copy=False) self._setColormappedData(data, copy=False) @@ -319,8 +328,9 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): def setVisualization(self, mode): previous = self.getVisualization() if super().setVisualization(mode): - if (bool(mode is self.Visualization.BINNED_STATISTIC) ^ - bool(previous is self.Visualization.BINNED_STATISTIC)): + if bool(mode is self.Visualization.BINNED_STATISTIC) ^ bool( + previous is self.Visualization.BINNED_STATISTIC + ): self._updateColormappedData() return True else: @@ -331,16 +341,22 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): parameter = self.VisualizationParameter.from_value(parameter) if super(Scatter, self).setVisualizationParameter(parameter, value): - if parameter in (self.VisualizationParameter.GRID_BOUNDS, - self.VisualizationParameter.GRID_MAJOR_ORDER, - self.VisualizationParameter.GRID_SHAPE): + if parameter in ( + self.VisualizationParameter.GRID_BOUNDS, + self.VisualizationParameter.GRID_MAJOR_ORDER, + self.VisualizationParameter.GRID_SHAPE, + ): self.__cacheRegularGridInfo = None - if parameter in (self.VisualizationParameter.BINNED_STATISTIC_SHAPE, - self.VisualizationParameter.BINNED_STATISTIC_FUNCTION, - self.VisualizationParameter.DATA_BOUNDS_HINT): - if parameter in (self.VisualizationParameter.BINNED_STATISTIC_SHAPE, - self.VisualizationParameter.DATA_BOUNDS_HINT): + if parameter in ( + self.VisualizationParameter.BINNED_STATISTIC_SHAPE, + self.VisualizationParameter.BINNED_STATISTIC_FUNCTION, + self.VisualizationParameter.DATA_BOUNDS_HINT, + ): + if parameter in ( + self.VisualizationParameter.BINNED_STATISTIC_SHAPE, + self.VisualizationParameter.DATA_BOUNDS_HINT, + ): self.__cacheHistogramInfo = None # Clean-up cache if self.getVisualization() is self.Visualization.BINNED_STATISTIC: self._updateColormappedData() @@ -351,14 +367,16 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): @docstring(ScatterVisualizationMixIn) def getCurrentVisualizationParameter(self, parameter): value = self.getVisualizationParameter(parameter) - if (parameter is self.VisualizationParameter.DATA_BOUNDS_HINT or - value is not None): + if ( + parameter is self.VisualizationParameter.DATA_BOUNDS_HINT + or value is not None + ): return value # Value has been set, return it elif parameter is self.VisualizationParameter.GRID_BOUNDS: grid = self.__getRegularGridInfo() return None if grid is None else grid.bounds - + elif parameter is self.VisualizationParameter.GRID_MAJOR_ORDER: grid = self.__getRegularGridInfo() return None if grid is None else grid.order @@ -378,15 +396,19 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): """Get grid info""" if self.__cacheRegularGridInfo is None: shape = self.getVisualizationParameter( - self.VisualizationParameter.GRID_SHAPE) + self.VisualizationParameter.GRID_SHAPE + ) order = self.getVisualizationParameter( - self.VisualizationParameter.GRID_MAJOR_ORDER) + self.VisualizationParameter.GRID_MAJOR_ORDER + ) if shape is None or order is None: - guess = _guess_grid(self.getXData(copy=False), - self.getYData(copy=False)) + guess = _guess_grid( + self.getXData(copy=False), self.getYData(copy=False) + ) if guess is None: _logger.warning( - 'Cannot guess a grid: Cannot display as regular grid image') + "Cannot guess a grid: Cannot display as regular grid image" + ) return None if shape is None: shape = guess[1] @@ -397,16 +419,18 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): if nbpoints > shape[0] * shape[1]: # More data points that provided grid shape: enlarge grid _logger.warning( - "More data points than provided grid shape size: extends grid") + "More data points than provided grid shape size: extends grid" + ) dim0, dim1 = shape - if order == 'row': # keep dim1, enlarge dim0 + if order == "row": # keep dim1, enlarge dim0 dim0 = nbpoints // dim1 + (1 if nbpoints % dim1 else 0) else: # keep dim0, enlarge dim1 dim1 = nbpoints // dim0 + (1 if nbpoints % dim0 else 0) shape = dim0, dim1 bounds = self.getVisualizationParameter( - self.VisualizationParameter.GRID_BOUNDS) + self.VisualizationParameter.GRID_BOUNDS + ) if bounds is None: x, y = self.getXData(copy=False), self.getYData(copy=False) min_, max_ = min_max(x) @@ -416,10 +440,12 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): bounds = (xRange[0], yRange[0]), (xRange[1], yRange[1]) begin, end = bounds - scale = ((end[0] - begin[0]) / max(1, shape[1] - 1), - (end[1] - begin[1]) / max(1, shape[0] - 1)) + scale = ( + (end[0] - begin[0]) / max(1, shape[1] - 1), + (end[1] - begin[1]) / max(1, shape[0] - 1), + ) if scale[0] == 0 and scale[1] == 0: - scale = 1., 1. + scale = 1.0, 1.0 elif scale[0] == 0: scale = scale[1], scale[1] elif scale[1] == 0: @@ -428,7 +454,8 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): origin = begin[0] - 0.5 * scale[0], begin[1] - 0.5 * scale[1] self.__cacheRegularGridInfo = _RegularGridInfo( - bounds=bounds, origin=origin, scale=scale, shape=shape, order=order) + bounds=bounds, origin=origin, scale=scale, shape=shape, order=order + ) return self.__cacheRegularGridInfo @@ -436,9 +463,10 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): """Get histogram info""" if self.__cacheHistogramInfo is None: shape = self.getVisualizationParameter( - self.VisualizationParameter.BINNED_STATISTIC_SHAPE) + self.VisualizationParameter.BINNED_STATISTIC_SHAPE + ) if shape is None: - shape = 100, 100 # TODO compute auto shape + shape = 100, 100 # TODO compute auto shape x, y, values = self.getData(copy=False)[:3] if len(x) == 0: # No histogram @@ -451,31 +479,40 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): if not numpy.issubdtype(values.dtype, numpy.floating): values = values.astype(numpy.float64) - ranges = (tuple(min_max(y, finite=True)), - tuple(min_max(x, finite=True))) + ranges = (tuple(min_max(y, finite=True)), tuple(min_max(x, finite=True))) rangesHint = self.getVisualizationParameter( - self.VisualizationParameter.DATA_BOUNDS_HINT) + self.VisualizationParameter.DATA_BOUNDS_HINT + ) if rangesHint is not None: - ranges = tuple((min(dataMin, hintMin), max(dataMax, hintMax)) - for (dataMin, dataMax), (hintMin, hintMax) in zip(ranges, rangesHint)) + ranges = tuple( + (min(dataMin, hintMin), max(dataMax, hintMax)) + for (dataMin, dataMax), (hintMin, hintMax) in zip( + ranges, rangesHint + ) + ) points = numpy.transpose(numpy.array((y, x))) counts, sums, bin_edges = Histogramnd( - points, - histo_range=ranges, - n_bins=shape, - weights=values) + points, histo_range=ranges, n_bins=shape, weights=values + ) yEdges, xEdges = bin_edges origin = xEdges[0], yEdges[0] - scale = ((xEdges[-1] - xEdges[0]) / (len(xEdges) - 1), - (yEdges[-1] - yEdges[0]) / (len(yEdges) - 1)) + scale = ( + (xEdges[-1] - xEdges[0]) / (len(xEdges) - 1), + (yEdges[-1] - yEdges[0]) / (len(yEdges) - 1), + ) - with numpy.errstate(divide='ignore', invalid='ignore'): + with numpy.errstate(divide="ignore", invalid="ignore"): histo = sums / counts self.__cacheHistogramInfo = _HistogramInfo( - mean=histo, count=counts, sum=sums, - origin=origin, scale=scale, shape=shape) + mean=histo, + count=counts, + sum=sums, + origin=origin, + scale=scale, + shape=shape, + ) return self.__cacheHistogramInfo @@ -495,7 +532,8 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): """Update backend renderer""" # Filter-out values <= 0 xFiltered, yFiltered, valueFiltered, xerror, yerror = self.getData( - copy=False, displayed=True) + copy=False, displayed=True + ) # Remove not finite numbers (this includes filtered out x, y <= 0) mask = numpy.logical_and(numpy.isfinite(xFiltered), numpy.isfinite(yFiltered)) @@ -509,62 +547,79 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): if visualization is self.Visualization.BINNED_STATISTIC: plot = self.getPlot() - if (plot is None or - plot.getXAxis().getScale() != Axis.LINEAR or - plot.getYAxis().getScale() != Axis.LINEAR): + if ( + plot is None + or plot.getXAxis().getScale() != Axis.LINEAR + or plot.getYAxis().getScale() != Axis.LINEAR + ): # Those visualizations are not available with log scaled axes return None histoInfo = self.__getHistogramInfo() if histoInfo is None: return None - data = getattr(histoInfo, self.getVisualizationParameter( - self.VisualizationParameter.BINNED_STATISTIC_FUNCTION)) + data = getattr( + histoInfo, + self.getVisualizationParameter( + self.VisualizationParameter.BINNED_STATISTIC_FUNCTION + ), + ) return backend.addImage( data=data, origin=histoInfo.origin, scale=histoInfo.scale, colormap=self.getColormap(), - alpha=self.getAlpha()) + alpha=self.getAlpha(), + ) elif visualization is self.Visualization.POINTS: rgbacolors = self.__applyColormapToData() - return backend.addCurve(xFiltered, yFiltered, - color=rgbacolors[mask], - symbol=self.getSymbol(), - linewidth=0, - linestyle="", - yaxis='left', - xerror=xerror, - yerror=yerror, - fill=False, - alpha=self.getAlpha(), - symbolsize=self.getSymbolSize(), - baseline=None) + return backend.addCurve( + xFiltered, + yFiltered, + color=rgbacolors[mask], + gapcolor=None, + symbol=self.getSymbol(), + linewidth=0, + linestyle="", + yaxis="left", + xerror=xerror, + yerror=yerror, + fill=False, + alpha=self.getAlpha(), + symbolsize=self.getSymbolSize(), + baseline=None, + ) else: plot = self.getPlot() - if (plot is None or - plot.getXAxis().getScale() != Axis.LINEAR or - plot.getYAxis().getScale() != Axis.LINEAR): + if ( + plot is None + or plot.getXAxis().getScale() != Axis.LINEAR + or plot.getYAxis().getScale() != Axis.LINEAR + ): # Those visualizations are not available with log scaled axes return None if visualization is self.Visualization.SOLID: - triangulation = self._getDelaunay().result() - if triangulation is None: + try: + triangulation = self._getTriangulationFuture().result() + except (RuntimeError, ValueError): _logger.warning( - 'Cannot get a triangulation: Cannot display as solid surface') + "Cannot get a triangulation: Cannot display as solid surface" + ) return None else: rgbacolors = self.__applyColormapToData() - triangles = triangulation.simplices.astype(numpy.int32) - return backend.addTriangles(xFiltered, - yFiltered, - triangles, - color=rgbacolors[mask], - alpha=self.getAlpha()) + triangles = triangulation.triangles.astype(numpy.int32) + return backend.addTriangles( + xFiltered, + yFiltered, + triangles, + color=rgbacolors[mask], + alpha=self.getAlpha(), + ) elif visualization is self.Visualization.REGULAR_GRID: gridInfo = self.__getRegularGridInfo() @@ -572,7 +627,7 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): return None dim0, dim1 = gridInfo.shape - if gridInfo.order == 'column': # transposition needed + if gridInfo.order == "column": # transposition needed dim0, dim1 = dim1, dim0 values = self.getValueData(copy=False) @@ -580,20 +635,21 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): image = values.reshape(dim0, dim1) else: # The points do not fill the whole image - if (self.__alpha is None and - numpy.issubdtype(values.dtype, numpy.floating)): + if self.__alpha is None and numpy.issubdtype( + values.dtype, numpy.floating + ): image = numpy.empty(dim0 * dim1, dtype=values.dtype) - image[:len(values)] = values - image[len(values):] = float('nan') # Transparent pixels + image[: len(values)] = values + image[len(values) :] = float("nan") # Transparent pixels image.shape = dim0, dim1 else: # Per value alpha or no NaN, so convert to RGBA rgbacolors = self.__applyColormapToData() image = numpy.empty((dim0 * dim1, 4), dtype=numpy.uint8) - image[:len(rgbacolors)] = rgbacolors - image[len(rgbacolors):] = (0, 0, 0, 0) # Transparent pixels + image[: len(rgbacolors)] = rgbacolors + image[len(rgbacolors) :] = (0, 0, 0, 0) # Transparent pixels image.shape = dim0, dim1, 4 - if gridInfo.order == 'column': + if gridInfo.order == "column": if image.ndim == 2: image = numpy.transpose(image) else: @@ -613,7 +669,8 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): origin=gridInfo.origin, scale=gridInfo.scale, colormap=colormap, - alpha=self.getAlpha()) + alpha=self.getAlpha(), + ) elif visualization is self.Visualization.IRREGULAR_GRID: gridInfo = self.__getRegularGridInfo() @@ -629,33 +686,37 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): nbpoints = len(xFiltered) if nbpoints == 1: # single point, render as a square points - return backend.addCurve(xFiltered, yFiltered, - color=rgbacolors[mask], - symbol='s', - linewidth=0, - linestyle="", - yaxis='left', - xerror=None, - yerror=None, - fill=False, - alpha=self.getAlpha(), - symbolsize=7, - baseline=None) + return backend.addCurve( + xFiltered, + yFiltered, + color=rgbacolors[mask], + gapcolor=None, + symbol="s", + linewidth=0, + linestyle="", + yaxis="left", + xerror=None, + yerror=None, + fill=False, + alpha=self.getAlpha(), + symbolsize=7, + baseline=None, + ) # Make shape include all points gridOrder = gridInfo.order if nbpoints != numpy.prod(shape): - if gridOrder == 'row': + if gridOrder == "row": shape = int(numpy.ceil(nbpoints / shape[1])), shape[1] - else: # column-major order + else: # column-major order shape = shape[0], int(numpy.ceil(nbpoints / shape[0])) if shape[0] < 2 or shape[1] < 2: # Single line, at least 2 points points = numpy.ones((2, nbpoints, 2), dtype=numpy.float64) # Use row/column major depending on shape, not on info value - gridOrder = 'row' if shape[0] == 1 else 'column' + gridOrder = "row" if shape[0] == 1 else "column" - if gridOrder == 'row': + if gridOrder == "row": points[0, :, 0] = xFiltered points[0, :, 1] = yFiltered else: # column-major order @@ -663,35 +724,51 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): points[0, :, 1] = xFiltered # Add a second line that will be clipped in the end - points[1, :-1] = points[0, :-1] + numpy.cross( - points[0, 1:] - points[0, :-1], (0., 0., 1.))[:, :2] - points[1, -1] = points[0, -1] + numpy.cross( - points[0, -1] - points[0, -2], (0., 0., 1.))[:2] + points[1, :-1] = ( + points[0, :-1] + + numpy.cross(points[0, 1:] - points[0, :-1], (0.0, 0.0, 1.0))[ + :, :2 + ] + ) + points[1, -1] = ( + points[0, -1] + + numpy.cross(points[0, -1] - points[0, -2], (0.0, 0.0, 1.0))[ + :2 + ] + ) points.shape = 2, nbpoints, 2 # Use same shape for both orders coords, indices = _quadrilateral_grid_as_triangles(points) - elif gridOrder == 'row': # row-major order + elif gridOrder == "row": # row-major order if nbpoints != numpy.prod(shape): - points = numpy.empty((numpy.prod(shape), 2), dtype=numpy.float64) + points = numpy.empty( + (numpy.prod(shape), 2), dtype=numpy.float64 + ) points[:nbpoints, 0] = xFiltered points[:nbpoints, 1] = yFiltered # Index of last element of last fully filled row index = (nbpoints // shape[1]) * shape[1] - points[nbpoints:, 0] = xFiltered[index - (numpy.prod(shape) - nbpoints):index] + points[nbpoints:, 0] = xFiltered[ + index - (numpy.prod(shape) - nbpoints) : index + ] points[nbpoints:, 1] = yFiltered[-1] else: points = numpy.transpose((xFiltered, yFiltered)) points.shape = shape[0], shape[1], 2 - else: # column-major order + else: # column-major order if nbpoints != numpy.prod(shape): - points = numpy.empty((numpy.prod(shape), 2), dtype=numpy.float64) + points = numpy.empty( + (numpy.prod(shape), 2), dtype=numpy.float64 + ) points[:nbpoints, 0] = yFiltered points[:nbpoints, 1] = xFiltered # Index of last element of last fully filled column index = (nbpoints // shape[0]) * shape[0] - points[nbpoints:, 0] = yFiltered[index - (numpy.prod(shape) - nbpoints):index] + points[nbpoints:, 0] = yFiltered[ + index - (numpy.prod(shape) - nbpoints) : index + ] points[nbpoints:, 1] = xFiltered[-1] else: points = numpy.transpose((yFiltered, xFiltered)) @@ -700,25 +777,24 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): coords, indices = _quadrilateral_grid_as_triangles(points) # Remove unused extra triangles - coords = coords[:4*nbpoints] - indices = indices[:2*nbpoints] + coords = coords[: 4 * nbpoints] + indices = indices[: 2 * nbpoints] - if gridOrder == 'row': + if gridOrder == "row": x, y = coords[:, 0], coords[:, 1] else: # column-major order y, x = coords[:, 0], coords[:, 1] rgbacolors = rgbacolors[mask] # Filter-out not finite points gridcolors = numpy.empty( - (4 * nbpoints, rgbacolors.shape[-1]), dtype=rgbacolors.dtype) + (4 * nbpoints, rgbacolors.shape[-1]), dtype=rgbacolors.dtype + ) for first in range(4): gridcolors[first::4] = rgbacolors[:nbpoints] - return backend.addTriangles(x, - y, - indices, - color=gridcolors, - alpha=self.getAlpha()) + return backend.addTriangles( + x, y, indices, color=gridcolors, alpha=self.getAlpha() + ) else: _logger.error("Unhandled visualization %s", visualization) @@ -747,11 +823,13 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): if gridInfo is None: return None - if gridInfo.order == 'row': + if gridInfo.order == "row": index = row * gridInfo.shape[1] + column else: index = row + column * gridInfo.shape[0] - if index >= len(self.getXData(copy=False)): # OK as long as not log scale + if index >= len( + self.getXData(copy=False) + ): # OK as long as not log scale return None # Image can be larger than scatter result = PickingResult(self, (index,)) @@ -768,9 +846,16 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): ox, oy = histoInfo.origin xdata = self.getXData(copy=False) ydata = self.getYData(copy=False) - indices = numpy.nonzero(numpy.logical_and( - numpy.logical_and(xdata >= ox + sx * col, xdata < ox + sx * (col + 1)), - numpy.logical_and(ydata >= oy + sy * row, ydata < oy + sy * (row + 1))))[0] + indices = numpy.nonzero( + numpy.logical_and( + numpy.logical_and( + xdata >= ox + sx * col, xdata < ox + sx * (col + 1) + ), + numpy.logical_and( + ydata >= oy + sy * row, ydata < oy + sy * (row + 1) + ), + ) + )[0] result = None if len(indices) == 0 else PickingResult(self, indices) return result @@ -784,69 +869,43 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): self.__executor = _GreedyThreadPoolExecutor(max_workers=2) return self.__executor - def _getDelaunay(self): - """Returns a :class:`Future` which result is the Delaunay object. + def _getTriangulationFuture(self): + """Returns a :class:`Future` which result is the Triangulation object. :rtype: concurrent.futures.Future """ - if self.__delaunayFuture is None or self.__delaunayFuture.cancelled(): + if self.__triangulationFuture is None or self.__triangulationFuture.cancelled(): # Need to init a new delaunay x, y = self.getData(copy=False)[:2] # Remove not finite points mask = numpy.logical_and(numpy.isfinite(x), numpy.isfinite(y)) - self.__delaunayFuture = self.__getExecutor().submit_greedy( - 'delaunay', delaunay, x[mask], y[mask]) + self.__triangulationFuture = self.__getExecutor().submit_greedy( + "Triangulation", Triangulation, x[mask], y[mask] + ) - return self.__delaunayFuture + return self.__triangulationFuture @staticmethod - def __initInterpolator(delaunayFuture, values): + def __initInterpolator(triangulationFuture, values): """Returns an interpolator for the given data points - :param concurrent.futures.Future delaunayFuture: - Future object which result is a Delaunay object + :param concurrent.futures.Future triangulationFuture: + Future object which result is a Triangulation object :param numpy.ndarray values: The data value of valid points. :rtype: Union[callable,None] """ - # Wait for Delaunay to complete + # Wait for Triangulation to complete try: - triangulation = delaunayFuture.result() + triangulation = triangulationFuture.result() + except (RuntimeError, ValueError): + return None # triangulation failed except CancelledError: - triangulation = None - - if triangulation is None: - interpolator = None # Error case - else: - # Lazy-loading of interpolator - try: - from scipy.interpolate import LinearNDInterpolator - except ImportError: - LinearNDInterpolator = None - - if LinearNDInterpolator is not None: - interpolator = LinearNDInterpolator(triangulation, values) - - # First call takes a while, do it here - interpolator([(0., 0.)]) - - else: - # Fallback using matplotlib interpolator - import matplotlib.tri - - x, y = triangulation.points.T - tri = matplotlib.tri.Triangulation( - x, y, triangles=triangulation.simplices) - mplInterpolator = matplotlib.tri.LinearTriInterpolator( - tri, values) - - # Wrap interpolator to have same API as scipy's one - def interpolator(points): - return mplInterpolator(*points.T) + return None - return interpolator + return LinearTriInterpolator(triangulation, values) - def _getInterpolator(self): + def _getInterpolatorFuture(self): """Returns a :class:`Future` which result is the interpolator. The interpolator is a callable taking an array Nx2 of points @@ -856,8 +915,7 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): :rtype: concurrent.futures.Future """ - if (self.__interpolatorFuture is None or - self.__interpolatorFuture.cancelled()): + if self.__interpolatorFuture is None or self.__interpolatorFuture.cancelled(): # Need to init a new interpolator x, y, values = self.getData(copy=False)[:3] # Remove not finite points @@ -865,8 +923,11 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): x, y, values = x[mask], y[mask], values[mask] self.__interpolatorFuture = self.__getExecutor().submit_greedy( - 'interpolator', - self.__initInterpolator, self._getDelaunay(), values) + "interpolator", + self.__initInterpolator, + self._getTriangulationFuture(), + values, + ) return self.__interpolatorFuture def _logFilterData(self, xPositive, yPositive): @@ -928,11 +989,13 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): assert len(data) == 5 return data - return (self.getXData(copy), - self.getYData(copy), - self.getValueData(copy), - self.getXErrorData(copy), - self.getYErrorData(copy)) + return ( + self.getXData(copy), + self.getYData(copy), + self.getValueData(copy), + self.getXErrorData(copy), + self.getYErrorData(copy), + ) # reimplemented from PointsBase to handle `value` def setData(self, x, y, value, xerror=None, yerror=None, alpha=None, copy=True): @@ -951,7 +1014,7 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): :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 + :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. """ @@ -961,14 +1024,13 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): # Convert complex data if numpy.iscomplexobj(value): - _logger.warning( - 'Converting value data to absolute value to plot it.') + _logger.warning("Converting value data to absolute value to plot it.") value = numpy.absolute(value) # Reset triangulation and interpolator - if self.__delaunayFuture is not None: - self.__delaunayFuture.cancel() - self.__delaunayFuture = None + if self.__triangulationFuture is not None: + self.__triangulationFuture.cancel() + self.__triangulationFuture = None if self.__interpolatorFuture is not None: self.__interpolatorFuture.cancel() self.__interpolatorFuture = None @@ -984,10 +1046,10 @@ class Scatter(PointsBase, ColormapMixIn, ScatterVisualizationMixIn): alpha = numpy.array(alpha, copy=copy) assert alpha.ndim == 1 assert len(x) == len(alpha) - if alpha.dtype.kind != 'f': + 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.) + if numpy.any(numpy.logical_or(alpha < 0.0, alpha > 1.0)): + alpha = numpy.clip(alpha, 0.0, 1.0) self.__alpha = alpha # set x, y, xerror, yerror diff --git a/src/silx/gui/plot/items/shape.py b/src/silx/gui/plot/items/shape.py index dc35864..c911924 100644 --- a/src/silx/gui/plot/items/shape.py +++ b/src/silx/gui/plot/items/shape.py @@ -33,11 +33,18 @@ import logging import numpy -from ... import colors -from ..utils.intersections import lines_intersection from .core import ( - Item, DataItem, - AlphaMixIn, ColorMixIn, FillMixIn, ItemChangedType, ItemMixInBase, LineMixIn, YAxisMixIn) + Item, + DataItem, + AlphaMixIn, + ColorMixIn, + FillMixIn, + ItemChangedType, + LineMixIn, + LineGapColorMixIn, + YAxisMixIn, +) +from ....utils.deprecation import deprecated _logger = logging.getLogger(__name__) @@ -65,41 +72,20 @@ class _OverlayItem(Item): self._updated(ItemChangedType.OVERLAY) -class _TwoColorsLineMixIn(LineMixIn): +class _TwoColorsLineMixIn(LineMixIn, LineGapColorMixIn): """Mix-in class for items with a background color for dashes""" def __init__(self): LineMixIn.__init__(self) - self.__backgroundColor = None + LineGapColorMixIn.__init__(self) + @deprecated(replacement="getLineGapColor", since_version="2.0.0") def getLineBgColor(self): - """Returns the RGBA background color of dash line + return self.getLineGapColor() - :rtype: 4-tuple of float in [0, 1] or array of colors - """ - return self.__backgroundColor - - def setLineBgColor(self, color, copy: bool=True): - """Set dash line background color - - :param color: color(s) to be used - :type color: str ("#RRGGBB") or (npoints, 4) unsigned byte array or - one of the predefined color names defined in colors.py - :param copy: True (Default) to get a copy, - False to use internal representation (do not modify!) - """ - if color is not None: - if isinstance(color, str): - color = colors.rgba(color) - else: - color = numpy.array(color, copy=copy) - # TODO more checks + improve color array support - if color.ndim == 1: # Single RGBA color - color = colors.rgba(color) - else: # Array of colors - assert color.ndim == 2 - - self.__backgroundColor = color + @deprecated(replacement="setLineGapColor", since_version="2.0.0") + def setLineBgColor(self, color, copy: bool = True): + self.setLineGapColor(color) self._updated(ItemChangedType.LINE_BG_COLOR) @@ -117,7 +103,7 @@ class Shape(_OverlayItem, ColorMixIn, FillMixIn, _TwoColorsLineMixIn): ColorMixIn.__init__(self) FillMixIn.__init__(self) _TwoColorsLineMixIn.__init__(self) - assert type_ in ('hline', 'polygon', 'rectangle', 'vline', 'polylines') + assert type_ in ("hline", "polygon", "rectangle", "vline", "polylines") self._type = type_ self._points = () self._handle = None @@ -126,15 +112,17 @@ class Shape(_OverlayItem, ColorMixIn, FillMixIn, _TwoColorsLineMixIn): """Update backend renderer""" points = self.getPoints(copy=False) x, y = points.T[0], points.T[1] - return backend.addShape(x, - y, - shape=self.getType(), - color=self.getColor(), - fill=self.isFill(), - overlay=self.isOverlay(), - linestyle=self.getLineStyle(), - linewidth=self.getLineWidth(), - linebgcolor=self.getLineBgColor()) + return backend.addShape( + x, + y, + shape=self.getType(), + color=self.getColor(), + fill=self.isFill(), + overlay=self.isOverlay(), + linestyle=self.getLineStyle(), + linewidth=self.getLineWidth(), + gapcolor=self.getLineGapColor(), + ) def getType(self): """Returns the type of shape to draw. @@ -226,11 +214,11 @@ class _BaseExtent(DataItem): :param str axis: Either 'x' or 'y'. """ - def __init__(self, axis='x'): - assert axis in ('x', 'y') + def __init__(self, axis="x"): + assert axis in ("x", "y") DataItem.__init__(self) self.__axis = axis - self.__range = 1., 100. + self.__range = 1.0, 100.0 def setRange(self, min_, max_): """Set the range of the extent of this item in data coordinates. @@ -262,17 +250,17 @@ class _BaseExtent(DataItem): plot = self.getPlot() if plot is not None: - axis = plot.getXAxis() if self.__axis == 'x' else plot.getYAxis() + axis = plot.getXAxis() if self.__axis == "x" else plot.getYAxis() if axis._isLogarithmic(): if max_ <= 0: return None if min_ <= 0: min_ = max_ - if self.__axis == 'x': - return min_, max_, float('nan'), float('nan') + if self.__axis == "x": + return min_, max_, float("nan"), float("nan") else: - return float('nan'), float('nan'), min_, max_ + return float("nan"), float("nan"), min_, max_ class XAxisExtent(_BaseExtent): @@ -282,8 +270,9 @@ class XAxisExtent(_BaseExtent): item with a horizontal extent regarding plot data bounds, i.e., :meth:`PlotWidget.resetZoom` will take this horizontal extent into account. """ + def __init__(self): - _BaseExtent.__init__(self, axis='x') + _BaseExtent.__init__(self, axis="x") class YAxisExtent(_BaseExtent, YAxisMixIn): @@ -295,7 +284,7 @@ class YAxisExtent(_BaseExtent, YAxisMixIn): """ def __init__(self): - _BaseExtent.__init__(self, axis='y') + _BaseExtent.__init__(self, axis="y") YAxisMixIn.__init__(self) @@ -305,7 +294,7 @@ class Line(_OverlayItem, AlphaMixIn, ColorMixIn, _TwoColorsLineMixIn): Warning: If slope is not finite, then the line is x = intercept. """ - def __init__(self, slope: float=0, intercept: float=0): + def __init__(self, slope: float = 0, intercept: float = 0): assert numpy.isfinite(intercept) _OverlayItem.__init__(self) @@ -378,7 +367,7 @@ class Line(_OverlayItem, AlphaMixIn, ColorMixIn, _TwoColorsLineMixIn): """Set slope and intercept from 2 (x, y) points""" x0, y0 = point0 x1, y1 = point1 - if x0 == x1: # Special case: vertical line + if x0 == x1: # Special case: vertical line self.setSlope(float("inf")) self.setIntercept(x0) return @@ -394,11 +383,11 @@ class Line(_OverlayItem, AlphaMixIn, ColorMixIn, _TwoColorsLineMixIn): return backend.addShape( *self.__coordinates, - shape='polylines', + shape="polylines", color=self.getColor(), fill=False, overlay=self.isOverlay(), linestyle=self.getLineStyle(), linewidth=self.getLineWidth(), - linebgcolor=self.getLineBgColor(), + gapcolor=self.getLineGapColor(), ) |