Source code for guiqwt.shapes

# -*- coding: utf-8 -*-
#
# Copyright © 2009-2010 CEA
# Pierre Raybaut
# Licensed under the terms of the CECILL License
# (see guiqwt/__init__.py for details)

"""
guiqwt.shapes
-------------

The `shapes` module provides geometrical shapes:
    * :py:class:`guiqwt.shapes.PolygonShape`
    * :py:class:`guiqwt.shapes.RectangleShape`
    * :py:class:`guiqwt.shapes.ObliqueRectangleShape`
    * :py:class:`guiqwt.shapes.PointShape`
    * :py:class:`guiqwt.shapes.SegmentShape`
    * :py:class:`guiqwt.shapes.EllipseShape`
    * :py:class:`guiqwt.shapes.Axes`
    * :py:class:`guiqwt.shapes.XRangeSelection`

A shape is a plot item (derived from QwtPlotItem) that may be displayed
on a 2D plotting widget like :py:class:`guiqwt.curve.CurvePlot`
or :py:class:`guiqwt.image.ImagePlot`.

.. seealso:: module :py:mod:`guiqwt.annotations`

Examples
~~~~~~~~

A shape may be created:
    * from the associated plot item class (e.g. `RectangleShape` to create a
      rectangle): the item properties are then assigned by creating the
      appropriate style parameters object
      (:py:class:`guiqwt.styles.ShapeParam`)

>>> from guiqwt.shapes import RectangleShape
>>> from guiqwt.styles import ShapeParam
>>> param = ShapeParam()
>>> param.title = 'My rectangle'
>>> rect_item = RectangleShape(0., 2., 4., 0., param)

    * or using the `plot item builder` (see :py:func:`guiqwt.builder.make`):

>>> from guiqwt.builder import make
>>> rect_item = make.rectangle(0., 2., 4., 0., title='My rectangle')

Reference
~~~~~~~~~

.. autoclass:: PolygonShape
   :members:
   :inherited-members:
.. autoclass:: RectangleShape
   :members:
   :inherited-members:
.. autoclass:: ObliqueRectangleShape
   :members:
   :inherited-members:
.. autoclass:: PointShape
   :members:
   :inherited-members:
.. autoclass:: SegmentShape
   :members:
   :inherited-members:
.. autoclass:: EllipseShape
   :members:
   :inherited-members:
.. autoclass:: Axes
   :members:
   :inherited-members:
.. autoclass:: XRangeSelection
   :members:
   :inherited-members:
"""

import os
import sys
import numpy as np
from math import fabs, sqrt, sin, cos, pi

from qtpy.QtGui import QPen, QBrush, QPolygonF, QTransform, QPainter
from qtpy.QtCore import Qt, QRectF, QPointF, QLineF

from guidata.utils import assert_interfaces_valid, update_dataset

# Local imports
from guiqwt.transitional import QwtPlotItem, QwtSymbol, QwtPlotMarker
from guiqwt.config import CONF, _
from guiqwt.interfaces import IBasePlotItem, IShapeItemType, ISerializableType
from guiqwt.styles import (
    MarkerParam,
    ShapeParam,
    RangeShapeParam,
    AxesShapeParam,
    MARKERSTYLES,
)
from guiqwt.geometry import (
    vector_norm,
    vector_projection,
    vector_rotation,
    compute_center,
)
from guiqwt.baseplot import canvas_to_axes

QT_API = os.environ["QT_API"]


class AbstractShape(QwtPlotItem):
    """Interface pour les objets manipulables
    il n'est pas nécessaire de dériver de QwtShape si on
    réutilise une autre classe dérivée de QwtPlotItem

    La classe de base
    """

    __implements__ = (IBasePlotItem,)

    _readonly = False
    _private = False
    _can_select = True
    _can_resize = True
    _can_rotate = False  # TODO: implement shape rotation?
    _can_move = True

    def __init__(self):
        super(AbstractShape, self).__init__()
        self.selected = False

    # ------IBasePlotItem API----------------------------------------------------
    def set_selectable(self, state):
        """Set item selectable state"""
        self._can_select = state

    def set_resizable(self, state):
        """Set item resizable state
        (or any action triggered when moving an handle, e.g. rotation)"""
        self._can_resize = state

    def set_movable(self, state):
        """Set item movable state"""
        self._can_move = state

    def set_rotatable(self, state):
        """Set item rotatable state"""
        self._can_rotate = state

    def can_select(self):
        return self._can_select

    def can_resize(self):
        return self._can_resize

    def can_rotate(self):
        return self._can_rotate

    def can_move(self):
        return self._can_move

    def types(self):
        """Returns a group or category for this item
        this should be a class object inheriting from
        IItemType
        """
        return (IShapeItemType,)

    def set_readonly(self, state):
        """Set object readonly state"""
        self._readonly = state

    def is_readonly(self):
        """Return object readonly state"""
        return self._readonly

    def set_private(self, state):
        """Set object as private"""
        self._private = state

    def is_private(self):
        """Return True if object is private"""
        return self._private

    def select(self):
        """Select item"""
        self.selected = True
        self.invalidate_plot()

    def unselect(self):
        """Unselect item"""
        self.selected = False
        self.invalidate_plot()

    def hit_test(self, pos):
        """
        Return a tuple with four elements:
        (distance, attach point, inside, other_object)

        distance : distance in pixels (canvas coordinates)
                   to the closest attach point
        attach point: handle of the attach point
        inside: True if the mouse button has been clicked inside the object
        other_object: if not None, reference of the object which
                      will be considered as hit instead of self
        """
        pass

    def update_item_parameters(self):
        """
        Update item parameters (dataset) from object properties
        """
        pass

    def get_item_parameters(self, itemparams):
        """
        Appends datasets to the list of DataSets describing the parameters
        used to customize apearance of this item
        """
        pass

    def set_item_parameters(self, itemparams):
        """
        Change the appearance of this item according
        to the parameter set provided

        params is a list of Datasets of the same types as those returned
        by get_item_parameters
        """
        pass

    def move_local_point_to(self, handle, pos, ctrl=None):
        """Move a handle as returned by hit_test to the new position pos
        ctrl: True if <Ctrl> button is being pressed, False otherwise"""
        pt = canvas_to_axes(self, pos)
        self.move_point_to(handle, pt, ctrl)

    def move_local_shape(self, old_pos, new_pos):
        """Translate the shape such that old_pos becomes new_pos
        in canvas coordinates"""
        old_pt = canvas_to_axes(self, old_pos)
        new_pt = canvas_to_axes(self, new_pos)
        self.move_shape(old_pt, new_pt)
        if self.plot():
            self.plot().SIG_ITEM_MOVED.emit(self, *(old_pt + new_pt))

    def move_with_selection(self, delta_x, delta_y):
        """
        Translate the shape together with other selected items
        delta_x, delta_y: translation in plot coordinates
        """
        self.move_shape([0, 0], [delta_x, delta_y])

    # ------Public API-----------------------------------------------------------
    def move_point_to(self, handle, pos, ctrl=None):
        pass

    def move_shape(self, old_pos, new_pos):
        """Translate the shape such that old_pos becomes new_pos
        in axis coordinates"""
        pass

    def invalidate_plot(self):
        plot = self.plot()
        if plot is not None:
            plot.invalidate()


assert_interfaces_valid(AbstractShape)


class Marker(QwtPlotMarker):
    """
    A marker that has two callbacks
    for restraining it's coordinates and
    displaying it's label
    we want to derive from QwtPlotMarker so
    we have to reimplement some of AbstractShape's methods
    (and PyQt doesn't really like multiple inheritance...)
    """

    __implements__ = (IBasePlotItem,)
    _readonly = False
    _private = False
    _can_select = True
    _can_resize = True
    _can_rotate = False
    _can_move = True

    def __init__(self, label_cb=None, constraint_cb=None, markerparam=None):
        super(Marker, self).__init__()
        self._pending_center_handle = None
        self.selected = False
        self.label_cb = label_cb
        if constraint_cb is None:
            constraint_cb = self.center_handle
        self.constraint_cb = constraint_cb
        if markerparam is None:
            self.markerparam = MarkerParam(_("Marker"))
            self.markerparam.read_config(CONF, "plot", "marker/cursor")
        else:
            self.markerparam = markerparam
        self.markerparam.update_marker(self)

    def __reduce__(self):
        self.markerparam.update_param(self)
        state = (self.markerparam, self.xValue(), self.yValue(), self.z())
        return (Marker, (), state)

    def __setstate__(self, state):
        self.markerparam, xvalue, yvalue, z = state
        self.setXValue(xvalue)
        self.setYValue(yvalue)
        self.setZ(z)
        self.markerparam.update_marker(self)

    def serialize(self, writer):
        """Serialize object to HDF5 writer"""
        self.markerparam.update_param(self)
        writer.write(self.markerparam, group_name="markerparam")
        writer.write(self.xValue(), group_name="x")
        writer.write(self.yValue(), group_name="y")
        writer.write(self.z(), group_name="z")

    def deserialize(self, reader):
        """Deserialize object from HDF5 reader"""
        self.markerparam = MarkerParam(_("Marker"), icon="marker.png")
        reader.read("markerparam", instance=self.markerparam)
        self.markerparam.update_marker(self)
        self.setXValue(reader.read("x"))
        self.setYValue(reader.read("y"))
        self.setZ(reader.read("z"))

    # ------QwtPlotItem API------------------------------------------------------
    def draw(self, painter, xmap, ymap, canvasrect):
        """Reimplemented to update label and (eventually) center handle"""
        if self._pending_center_handle:
            x, y = self.center_handle(self.xValue(), self.yValue())
            self.setValue(x, y)
        self.update_label()
        QwtPlotMarker.draw(self, painter, xmap, ymap, canvasrect)

    # ------IBasePlotItem API----------------------------------------------------
    def set_selectable(self, state):
        """Set item selectable state"""
        self._can_select = state

    def set_resizable(self, state):
        """Set item resizable state
        (or any action triggered when moving an handle, e.g. rotation)"""
        self._can_resize = state

    def set_movable(self, state):
        """Set item movable state"""
        self._can_move = state

    def set_rotatable(self, state):
        """Set item rotatable state"""
        self._can_rotate = state

    def can_select(self):
        return self._can_select

    def can_resize(self):
        return self._can_resize

    def can_rotate(self):
        return self._can_rotate

    def can_move(self):
        return self._can_move

    def types(self):
        """Returns a group or category for this item
        this should be a class object inheriting from
        IItemType
        """
        return (IShapeItemType,)

    def set_readonly(self, state):
        """Set object readonly state"""
        self._readonly = state

    def is_readonly(self):
        """Return object readonly state"""
        return self._readonly

    def set_private(self, state):
        """Set object as private"""
        self._private = state

    def is_private(self):
        """Return True if object is private"""
        return self._private

    def select(self):
        """
        Select the object and eventually change its appearance to highlight the
        fact that it's selected
        """
        if self.selected:
            # Already selected
            return
        self.selected = True
        self.markerparam.update_marker(self)
        self.invalidate_plot()

    def unselect(self):
        """
        Unselect the object and eventually restore its original appearance to
        highlight the fact that it's not selected anymore
        """
        self.selected = False
        self.markerparam.update_marker(self)
        self.invalidate_plot()

    def hit_test(self, pos):
        """
        Return a tuple with four elements:
        (distance, attach point, inside, other_object)

        distance : distance in pixels (canvas coordinates)
                   to the closest attach point
        attach point: handle of the attach point
        inside: True if the mouse button has been clicked inside the object
        other_object: if not None, reference of the object which
                      will be considered as hit instead of self
        """
        plot = self.plot()
        xc, yc = pos.x(), pos.y()
        x = plot.transform(self.xAxis(), self.xValue())
        y = plot.transform(self.yAxis(), self.yValue())
        ms = self.markerparam.markerstyle
        # The following assert has no purpose except reminding that the
        # markerstyle is one of the MARKERSTYLES dictionary values, in case
        # this dictionary evolves in the future (this should not fail):
        assert ms in list(MARKERSTYLES.values())
        if ms == "NoLine":
            return sqrt((x - xc) ** 2 + (y - yc) ** 2), 0, False, None
        elif ms == "HLine":
            return sqrt((y - yc) ** 2), 0, False, None
        elif ms == "VLine":
            return sqrt((x - xc) ** 2), 0, False, None
        elif ms == "Cross":
            return sqrt(min((x - xc) ** 2, (y - yc) ** 2)), 0, False, None

    def update_item_parameters(self):
        self.markerparam.update_param(self)

    def get_item_parameters(self, itemparams):
        """
        Appends datasets to the list of DataSets describing the parameters
        used to customize apearance of this item
        """
        self.update_item_parameters()
        itemparams.add("MarkerParam", self, self.markerparam)

    def set_item_parameters(self, itemparams):
        """
        Change the appearance of this item according
        to the parameter set provided

        params is a list of Datasets of the same types as those returned
        by get_item_parameters
        """
        update_dataset(
            self.markerparam, itemparams.get("MarkerParam"), visible_only=True
        )
        self.markerparam.update_marker(self)
        if self.selected:
            self.select()

    def move_local_point_to(self, handle, pos, ctrl=None):
        """Move a handle as returned by hit_test to the new position pos
        ctrl: True if <Ctrl> button is being pressed, False otherwise"""
        x, y = canvas_to_axes(self, pos)
        self.set_pos(x, y)

    def move_local_shape(self, old_pos, new_pos):
        """Translate the shape such that old_pos becomes new_pos
        in canvas coordinates"""
        old_pt = canvas_to_axes(self, old_pos)
        new_pt = canvas_to_axes(self, new_pos)
        self.move_shape(old_pt, new_pt)

    def move_with_selection(self, delta_x, delta_y):
        """
        Translate the shape together with other selected items
        delta_x, delta_y: translation in plot coordinates
        """
        self.move_shape([0, 0], [delta_x, delta_y])

    # ------Public API-----------------------------------------------------------
    def set_style(self, section, option):
        self.markerparam.read_config(CONF, section, option)
        self.markerparam.update_marker(self)

    def set_pos(self, x=None, y=None):
        if x is None:
            x = self.xValue()
        if y is None:
            y = self.yValue()
        if self.constraint_cb:
            x, y = self.constraint_cb(x, y)
        self.setValue(x, y)
        if self.plot():
            self.plot().SIG_MARKER_CHANGED.emit(self)

    def get_pos(self):
        return self.xValue(), self.yValue()

    def set_markerstyle(self, style):
        param = self.markerparam
        param.set_markerstyle(style)
        param.update_marker(self)

    def is_vertical(self):
        """Return True if this is a vertical cursor"""
        return self.lineStyle() == QwtPlotMarker.VLine

    def is_horizontal(self):
        """Return True if this is an horizontal cursor"""
        return self.lineStyle() == QwtPlotMarker.HLine

    def center_handle(self, x, y):
        """Center cursor handle depending on marker style (|, -)"""
        plot = self.plot()
        if plot is None:
            self._pending_center_handle = True
        else:
            self._pending_center_handle = False
            if self.is_vertical():
                ymap = plot.canvasMap(self.yAxis())
                y_top, y_bottom = ymap.s1(), ymap.s2()
                y = 0.5 * (y_top + y_bottom)
            elif self.is_horizontal():
                xmap = plot.canvasMap(self.xAxis())
                x_left, x_right = xmap.s1(), xmap.s2()
                x = 0.5 * (x_left + x_right)
        return x, y

    def move_shape(self, old_pos, new_pos):
        """Translate the shape such that old_pos becomes new_pos
        in canvas coordinates"""
        dx = new_pos[0] - old_pos[0]
        dy = new_pos[1] - old_pos[1]
        x, y = self.xValue(), self.yValue()
        return self.move_point_to(0, (x + dx, y + dy))

    def invalidate_plot(self):
        plot = self.plot()
        if plot is not None:
            plot.invalidate()

    def update_label(self):
        x, y = self.xValue(), self.yValue()
        if self.label_cb:
            label = self.label_cb(x, y)
            if label is None:
                return
        elif self.is_vertical():
            label = "x = %g" % x
        elif self.is_horizontal():
            label = "y = %g" % y
        else:
            label = "x = %g<br>y = %g" % (x, y)
        text = self.label()
        text.setText(label)
        self.setLabel(text)
        plot = self.plot()
        if plot is not None:
            xaxis = plot.axisScaleDiv(self.xAxis())
            if x < (xaxis.upperBound() + xaxis.lowerBound()) / 2:
                hor_alignment = Qt.AlignRight
            else:
                hor_alignment = Qt.AlignLeft
            yaxis = plot.axisScaleDiv(self.yAxis())
            ymap = plot.canvasMap(self.yAxis())
            y_top, y_bottom = ymap.s1(), ymap.s2()
            if y < 0.5 * (yaxis.upperBound() + yaxis.lowerBound()):
                if y_top > y_bottom:
                    ver_alignment = Qt.AlignBottom
                else:
                    ver_alignment = Qt.AlignTop
            else:
                if y_top > y_bottom:
                    ver_alignment = Qt.AlignTop
                else:
                    ver_alignment = Qt.AlignBottom
            self.setLabelAlignment(hor_alignment | ver_alignment)


assert_interfaces_valid(Marker)


[docs]class PolygonShape(AbstractShape): __implements__ = (IBasePlotItem, ISerializableType) ADDITIONNAL_POINTS = 0 # Number of points which are not part of the shape LINK_ADDITIONNAL_POINTS = False # Link additionnal points with dotted lines CLOSED = True def __init__(self, points=None, closed=None, shapeparam=None): super(PolygonShape, self).__init__() self.closed = self.CLOSED if closed is None else closed self.selected = False if shapeparam is None: self.shapeparam = ShapeParam(_("Shape"), icon="rectangle.png") else: self.shapeparam = shapeparam self.shapeparam.update_shape(self) self.pen = QPen() self.brush = QBrush() self.symbol = QwtSymbol.NoSymbol self.sel_pen = QPen() self.sel_brush = QBrush() self.sel_symbol = QwtSymbol.NoSymbol self.points = np.zeros((0, 2), float) if points is not None: self.set_points(points)
[docs] def types(self): return (IShapeItemType, ISerializableType)
def __reduce__(self): self.shapeparam.update_param(self) state = (self.shapeparam, self.points, self.closed, self.z()) return (PolygonShape, (), state) def __setstate__(self, state): self.shapeparam, self.points, self.closed, z = state self.setZ(z) self.shapeparam.update_shape(self)
[docs] def serialize(self, writer): """Serialize object to HDF5 writer""" self.shapeparam.update_param(self) writer.write(self.shapeparam, group_name="shapeparam") writer.write(self.points, group_name="points") writer.write(self.closed, group_name="closed") writer.write(self.z(), group_name="z")
[docs] def deserialize(self, reader): """Deserialize object from HDF5 reader""" self.closed = reader.read("closed") self.shapeparam = ShapeParam(_("Shape"), icon="rectangle.png") reader.read("shapeparam", instance=self.shapeparam) self.shapeparam.update_shape(self) self.points = reader.read(group_name="points", func=reader.read_array) self.setZ(reader.read("z"))
# ----Public API------------------------------------------------------------- def set_style(self, section, option): self.shapeparam.read_config(CONF, section, option) self.shapeparam.update_shape(self) def set_points(self, points): self.points = np.array(points, float) assert self.points.shape[1] == 2
[docs] def get_points(self): """Return polygon points""" return self.points
[docs] def get_bounding_rect_coords(self): """Return bounding rectangle coordinates (in plot coordinates)""" poly = QPolygonF() shape_points = self.points[: -self.ADDITIONNAL_POINTS] for i in range(shape_points.shape[0]): poly.append(QPointF(shape_points[i, 0], shape_points[i, 1])) return poly.boundingRect().getCoords()
def transform_points(self, xMap, yMap): points = QPolygonF() for i in range(self.points.shape[0]): points.append( QPointF( xMap.transform(self.points[i, 0]), yMap.transform(self.points[i, 1]) ) ) return points def get_reference_point(self): if self.points.size: return self.points.mean(axis=0) def get_pen_brush(self, xMap, yMap): if self.selected: pen = self.sel_pen brush = self.sel_brush sym = self.sel_symbol else: pen = self.pen brush = self.brush sym = self.symbol if self.points.size > 0: x0, y0 = self.get_reference_point() xx0 = xMap.transform(x0) yy0 = yMap.transform(y0) try: # Optimized version in PyQt >= v4.5 t0 = QTransform.fromTranslate(xx0, yy0) except AttributeError: # Fallback for PyQt <= v4.4 t0 = QTransform().translate(xx0, yy0) tr = brush.transform() tr = tr * t0 brush = QBrush(brush) brush.setTransform(tr) return pen, brush, sym def draw(self, painter, xMap, yMap, canvasRect): pen, brush, symbol = self.get_pen_brush(xMap, yMap) painter.setRenderHint(QPainter.Antialiasing) painter.setPen(pen) painter.setBrush(brush) points = self.transform_points(xMap, yMap) if QT_API.startswith("pyside"): points = list(points) if self.ADDITIONNAL_POINTS: shape_points = points[: -self.ADDITIONNAL_POINTS] other_points = points[-self.ADDITIONNAL_POINTS :] else: shape_points = points other_points = [] if self.closed: painter.drawPolygon(shape_points) else: painter.drawPolyline(shape_points) if symbol != QwtSymbol.NoSymbol: symbol.drawSymbols(painter, points) if self.LINK_ADDITIONNAL_POINTS and other_points: pen2 = painter.pen() pen2.setStyle(Qt.DotLine) painter.setPen(pen2) painter.drawPolyline(other_points) def poly_hit_test(self, plot, ax, ay, pos): pos = QPointF(pos) dist = sys.maxsize handle = -1 Cx, Cy = pos.x(), pos.y() poly = QPolygonF() pts = self.points for i in range(pts.shape[0]): # On calcule la distance dans le repère du canvas px = plot.transform(ax, pts[i, 0]) py = plot.transform(ay, pts[i, 1]) if i < pts.shape[0] - self.ADDITIONNAL_POINTS: poly.append(QPointF(px, py)) d = (Cx - px) ** 2 + (Cy - py) ** 2 if d < dist: dist = d handle = i inside = poly.containsPoint(QPointF(Cx, Cy), Qt.OddEvenFill) return sqrt(dist), handle, inside, None
[docs] def hit_test(self, pos): """return (dist, handle, inside)""" if not self.plot(): return sys.maxsize, 0, False, None return self.poly_hit_test(self.plot(), self.xAxis(), self.yAxis(), pos)
def add_local_point(self, pos): pt = canvas_to_axes(self, pos) return self.add_point(pt) def add_point(self, pt): N, _ = self.points.shape self.points = np.resize(self.points, (N + 1, 2)) self.points[N, :] = pt return N def del_point(self, handle): self.points = np.delete(self.points, handle, 0) if handle < len(self.points): return handle else: return self.points.shape[0] - 1 def move_point_to(self, handle, pos, ctrl=None): self.points[handle, :] = pos
[docs] def move_shape(self, old_pos, new_pos): dx = new_pos[0] - old_pos[0] dy = new_pos[1] - old_pos[1] self.points += np.array([[dx, dy]])
[docs] def update_item_parameters(self): self.shapeparam.update_param(self)
[docs] def get_item_parameters(self, itemparams): self.update_item_parameters() itemparams.add("ShapeParam", self, self.shapeparam)
[docs] def set_item_parameters(self, itemparams): update_dataset(self.shapeparam, itemparams.get("ShapeParam"), visible_only=True) self.shapeparam.update_shape(self)
assert_interfaces_valid(PolygonShape)
[docs]class PointShape(PolygonShape): CLOSED = False def __init__(self, x=0, y=0, shapeparam=None): super(PointShape, self).__init__(shapeparam=shapeparam) self.set_pos(x, y)
[docs] def set_pos(self, x, y): """Set the point coordinates to (x, y)""" self.set_points([(x, y)])
[docs] def get_pos(self): """Return the point coordinates""" return tuple(self.points[0])
def move_point_to(self, handle, pos, ctrl=None): nx, ny = pos self.points[0] = (nx, ny) def __reduce__(self): state = (self.shapeparam, self.points, self.z()) return (self.__class__, (), state) def __setstate__(self, state): self.shapeparam, self.points, z = state self.setZ(z) self.shapeparam.update_shape(self)
assert_interfaces_valid(PointShape)
[docs]class SegmentShape(PolygonShape): CLOSED = False ADDITIONNAL_POINTS = 1 # Number of points which are not part of the shape def __init__(self, x1=0, y1=0, x2=0, y2=0, shapeparam=None): super(SegmentShape, self).__init__(shapeparam=shapeparam) self.set_rect(x1, y1, x2, y2)
[docs] def set_rect(self, x1, y1, x2, y2): """ Set the start point of this segment to (x1, y1) and the end point of this line to (x2, y2) """ self.set_points([(x1, y1), (x2, y2), (0.5 * (x1 + x2), 0.5 * (y1 + y2))])
def get_rect(self): return tuple(self.points[0]) + tuple(self.points[1]) def move_point_to(self, handle, pos, ctrl=None): nx, ny = pos x1, y1, x2, y2 = self.get_rect() if handle == 0: self.set_rect(nx, ny, x2, y2) elif handle == 1: self.set_rect(x1, y1, nx, ny) elif handle in (2, -1): delta = (nx, ny) - self.points.mean(axis=0) self.points += delta def __reduce__(self): state = (self.shapeparam, self.points, self.z()) return (self.__class__, (), state) def __setstate__(self, state): param, points, z = state # ---------------------------------------------------------------------- # compatibility with previous version of SegmentShape: x1, y1, x2, y2, x3, y3 = points.ravel() v12 = np.array((x2 - x1, y2 - y1)) v13 = np.array((x3 - x1, y3 - y1)) if np.linalg.norm(v12) < np.linalg.norm(v13): # old pickle format points = np.flipud(np.roll(points, -1, axis=0)) # ---------------------------------------------------------------------- self.points = points self.setZ(z) self.shapeparam = param self.shapeparam.update_shape(self)
assert_interfaces_valid(SegmentShape)
[docs]class RectangleShape(PolygonShape): CLOSED = True def __init__(self, x1=0, y1=0, x2=0, y2=0, shapeparam=None): super(RectangleShape, self).__init__(shapeparam=shapeparam) self.set_rect(x1, y1, x2, y2)
[docs] def set_rect(self, x1, y1, x2, y2): """ Set the coordinates of the rectangle's top-left corner to (x1, y1), and of its bottom-right corner to (x2, y2). """ self.set_points([(x1, y1), (x2, y1), (x2, y2), (x1, y2)])
def get_rect(self): return tuple(self.points[0]) + tuple(self.points[2])
[docs] def get_center(self): """Return center coordinates: (xc, yc)""" return compute_center(*self.get_rect())
def move_point_to(self, handle, pos, ctrl=None): nx, ny = pos x1, y1, x2, y2 = self.get_rect() if handle == 0: self.set_rect(nx, ny, x2, y2) elif handle == 1: self.set_rect(x1, ny, nx, y2) elif handle == 2: self.set_rect(x1, y1, nx, ny) elif handle == 3: self.set_rect(nx, y1, x2, ny) elif handle == -1: delta = (nx, ny) - self.points.mean(axis=0) self.points += delta def __reduce__(self): state = (self.shapeparam, self.points, self.z()) return (self.__class__, (), state) def __setstate__(self, state): self.shapeparam, self.points, z = state self.setZ(z) self.shapeparam.update_shape(self)
assert_interfaces_valid(RectangleShape) def _no_null_vector(x0, y0, x1, y1, x2, y2, x3, y3): return ( vector_norm(x0, y0, x1, y1) and vector_norm(x0, y0, x2, y2) and vector_norm(x0, y0, x3, y3) and vector_norm(x1, y1, x2, y2) and vector_norm(x1, y1, x3, y3) and vector_norm(x2, y2, x3, y3) )
[docs]class ObliqueRectangleShape(PolygonShape): CLOSED = True ADDITIONNAL_POINTS = 2 # Number of points which are not part of the shape LINK_ADDITIONNAL_POINTS = True # Link additionnal points with dotted lines def __init__(self, x0=0, y0=0, x1=0, y1=0, x2=0, y2=0, x3=0, y3=0, shapeparam=None): super(ObliqueRectangleShape, self).__init__(shapeparam=shapeparam) self.set_rect(x0, y0, x1, y1, x2, y2, x3, y3)
[docs] def set_rect(self, x0, y0, x1, y1, x2, y2, x3, y3): """ Set the rectangle corners coordinates: (x0, y0): top-left corner (x1, y1): top-right corner (x2, y2): bottom-right corner (x3, y3): bottom-left corner :: x: additionnal points (handles used for rotation -- other handles being used for rectangle resizing) (x0, y0)------>(x1, y1) ↑ | | | x x | | | ↓ (x3, y3)<------(x2, y2) """ self.set_points( [ (x0, y0), (x1, y1), (x2, y2), (x3, y3), (0.5 * (x0 + x3), 0.5 * (y0 + y3)), (0.5 * (x1 + x2), 0.5 * (y1 + y2)), ] )
def get_rect(self): return self.points.ravel()[: -self.ADDITIONNAL_POINTS * 2]
[docs] def get_center(self): """Return center coordinates: (xc, yc)""" rect = tuple(self.points[0]) + tuple(self.points[2]) return compute_center(*rect)
def move_point_to(self, handle, pos, ctrl=None): nx, ny = pos x0, y0, x1, y1, x2, y2, x3, y3 = self.get_rect() if handle == 0: if vector_norm(x2, y2, x3, y3) and vector_norm(x2, y2, x1, y1): v0n = np.array((nx - x0, ny - y0)) x3, y3 = vector_projection(v0n, x2, y2, x3, y3) x1, y1 = vector_projection(v0n, x2, y2, x1, y1) x0, y0 = nx, ny if _no_null_vector(x0, y0, x1, y1, x2, y2, x3, y3): self.set_rect(x0, y0, x1, y1, x2, y2, x3, y3) elif handle == 1: if vector_norm(x3, y3, x0, y0) and vector_norm(x3, y3, x2, y2): v1n = np.array((nx - x1, ny - y1)) x0, y0 = vector_projection(v1n, x3, y3, x0, y0) x2, y2 = vector_projection(v1n, x3, y3, x2, y2) x1, y1 = nx, ny if _no_null_vector(x0, y0, x1, y1, x2, y2, x3, y3): self.set_rect(x0, y0, x1, y1, x2, y2, x3, y3) elif handle == 2: if vector_norm(x0, y0, x1, y1) and vector_norm(x0, y0, x3, y3): v2n = np.array((nx - x2, ny - y2)) x1, y1 = vector_projection(v2n, x0, y0, x1, y1) x3, y3 = vector_projection(v2n, x0, y0, x3, y3) x2, y2 = nx, ny if _no_null_vector(x0, y0, x1, y1, x2, y2, x3, y3): self.set_rect(x0, y0, x1, y1, x2, y2, x3, y3) elif handle == 3: if vector_norm(x1, y1, x0, y0) and vector_norm(x1, y1, x2, y2): v3n = np.array((nx - x3, ny - y3)) x0, y0 = vector_projection(v3n, x1, y1, x0, y0) x2, y2 = vector_projection(v3n, x1, y1, x2, y2) x3, y3 = nx, ny if _no_null_vector(x0, y0, x1, y1, x2, y2, x3, y3): self.set_rect(x0, y0, x1, y1, x2, y2, x3, y3) elif handle == 4: x4, y4 = 0.5 * (x0 + x3), 0.5 * (y0 + y3) x5, y5 = 0.5 * (x1 + x2), 0.5 * (y1 + y2) nx, ny = x0 + nx - x4, y0 + ny - y4 # moving handle #4 to handle #0 v10 = np.array((x0 - x1, y0 - y1)) v12 = np.array((x2 - x1, y2 - y1)) v10n = np.array((nx - x1, ny - y1)) k = np.linalg.norm(v12) / np.linalg.norm(v10) v12n = vector_rotation(-np.pi / 2, *v10n) * k x2, y2 = v12n + np.array([x1, y1]) x3, y3 = v12n + v10n + np.array([x1, y1]) x0, y0 = nx, ny dx = x5 - 0.5 * (x1 + x2) dy = y5 - 0.5 * (y1 + y2) x0, y0 = x0 + dx, y0 + dy x1, y1 = x1 + dx, y1 + dy x2, y2 = x2 + dx, y2 + dy x3, y3 = x3 + dx, y3 + dy self.set_rect(x0, y0, x1, y1, x2, y2, x3, y3) elif handle == 5: x4, y4 = 0.5 * (x0 + x3), 0.5 * (y0 + y3) x5, y5 = 0.5 * (x1 + x2), 0.5 * (y1 + y2) nx, ny = x1 + nx - x5, y1 + ny - y5 # moving handle #5 to handle #1 v01 = np.array((x1 - x0, y1 - y0)) v03 = np.array((x3 - x0, y3 - y0)) v01n = np.array((nx - x0, ny - y0)) k = np.linalg.norm(v03) / np.linalg.norm(v01) v03n = vector_rotation(np.pi / 2, *v01n) * k x3, y3 = v03n + np.array([x0, y0]) x2, y2 = v03n + v01n + np.array([x0, y0]) x1, y1 = nx, ny dx = x4 - 0.5 * (x0 + x3) dy = y4 - 0.5 * (y0 + y3) x0, y0 = x0 + dx, y0 + dy x1, y1 = x1 + dx, y1 + dy x2, y2 = x2 + dx, y2 + dy x3, y3 = x3 + dx, y3 + dy self.set_rect(x0, y0, x1, y1, x2, y2, x3, y3) elif handle == -1: delta = (nx, ny) - self.points.mean(axis=0) self.points += delta def __reduce__(self): state = (self.shapeparam, self.points, self.z()) return (self.__class__, (), state) def __setstate__(self, state): self.shapeparam, self.points, z = state self.setZ(z) self.shapeparam.update_shape(self)
assert_interfaces_valid(ObliqueRectangleShape) # FIXME: EllipseShape's ellipse drawing is invalid when aspect_ratio != 1
[docs]class EllipseShape(PolygonShape): CLOSED = True def __init__(self, x1=0, y1=0, x2=0, y2=0, shapeparam=None): super(EllipseShape, self).__init__(shapeparam=shapeparam) self.is_ellipse = False self.set_xdiameter(x1, y1, x2, y2) def switch_to_ellipse(self): self.is_ellipse = True
[docs] def set_xdiameter(self, x0, y0, x1, y1): """Set the coordinates of the ellipse's X-axis diameter""" xline = QLineF(x0, y0, x1, y1) yline = xline.normalVector() yline.translate(xline.pointAt(0.5) - xline.p1()) if self.is_ellipse: yline.setLength(self.get_yline().length()) else: yline.setLength(xline.length()) yline.translate(yline.pointAt(0.5) - yline.p2()) self.set_points( [(x0, y0), (x1, y1), (yline.x1(), yline.y1()), (yline.x2(), yline.y2())] )
[docs] def get_xdiameter(self): """Return the coordinates of the ellipse's X-axis diameter""" return tuple(self.points[0]) + tuple(self.points[1])
[docs] def set_ydiameter(self, x2, y2, x3, y3): """Set the coordinates of the ellipse's Y-axis diameter""" yline = QLineF(x2, y2, x3, y3) xline = yline.normalVector() xline.translate(yline.pointAt(0.5) - yline.p1()) if self.is_ellipse: xline.setLength(self.get_xline().length()) xline.translate(xline.pointAt(0.5) - xline.p2()) self.set_points( [(xline.x1(), xline.y1()), (xline.x2(), xline.y2()), (x2, y2), (x3, y3)] )
[docs] def get_ydiameter(self): """Return the coordinates of the ellipse's Y-axis diameter""" return tuple(self.points[2]) + tuple(self.points[3])
[docs] def get_rect(self): """Circle only!""" (x0, y0), (x1, y1) = self.points[0], self.points[1] xc, yc = 0.5 * (x0 + x1), 0.5 * (y0 + y1) radius = 0.5 * np.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2) return xc - radius, yc - radius, xc + radius, yc + radius
[docs] def get_center(self): """Return center coordinates: (xc, yc)""" return compute_center(*self.get_xdiameter())
[docs] def set_rect(self, x0, y0, x1, y1): """Circle only!""" self.set_xdiameter(x0, 0.5 * (y0 + y1), x1, 0.5 * (y0 + y1))
[docs] def compute_elements(self, xMap, yMap): """Return points, lines and ellipse rect""" points = self.transform_points(xMap, yMap) line0 = QLineF(points[0], points[1]) line1 = QLineF(points[2], points[3]) rect = QRectF() rect.setWidth(line0.length()) rect.setHeight(line1.length()) rect.moveCenter(line0.pointAt(0.5)) return points, line0, line1, rect
[docs] def hit_test(self, pos): """return (dist, handle, inside)""" if not self.plot(): return sys.maxsize, 0, False, None dist, handle, inside, other = self.poly_hit_test( self.plot(), self.xAxis(), self.yAxis(), pos ) if not inside: xMap = self.plot().canvasMap(self.xAxis()) yMap = self.plot().canvasMap(self.yAxis()) _points, _line0, _line1, rect = self.compute_elements(xMap, yMap) inside = rect.contains(QPointF(pos)) return dist, handle, inside, other
def draw(self, painter, xMap, yMap, canvasRect): points, line0, line1, rect = self.compute_elements(xMap, yMap) pen, brush, symbol = self.get_pen_brush(xMap, yMap) painter.setRenderHint(QPainter.Antialiasing) painter.setPen(pen) painter.setBrush(brush) painter.drawLine(line0) painter.drawLine(line1) painter.save() painter.translate(rect.center()) painter.rotate(-line0.angle()) painter.translate(-rect.center()) painter.drawEllipse(rect.toRect()) painter.restore() if symbol != QwtSymbol.NoSymbol: for i in range(points.size()): symbol.drawSymbol(painter, points[i].toPoint()) def get_xline(self): return QLineF(*(tuple(self.points[0]) + tuple(self.points[1]))) def get_yline(self): return QLineF(*(tuple(self.points[2]) + tuple(self.points[3]))) def move_point_to(self, handle, pos, ctrl=None): nx, ny = pos if handle == 0: x1, y1 = self.points[1] if ctrl: # When <Ctrl> is pressed, the center position is unchanged x0, y0 = self.points[0] x1, y1 = x1 + x0 - nx, y1 + y0 - ny self.set_xdiameter(nx, ny, x1, y1) elif handle == 1: x0, y0 = self.points[0] if ctrl: # When <Ctrl> is pressed, the center position is unchanged x1, y1 = self.points[1] x0, y0 = x0 + x1 - nx, y0 + y1 - ny self.set_xdiameter(x0, y0, nx, ny) elif handle == 2: x3, y3 = self.points[3] if ctrl: # When <Ctrl> is pressed, the center position is unchanged x2, y2 = self.points[2] x3, y3 = x3 + x2 - nx, y3 + y2 - ny self.set_ydiameter(nx, ny, x3, y3) elif handle == 3: x2, y2 = self.points[2] if ctrl: # When <Ctrl> is pressed, the center position is unchanged x3, y3 = self.points[3] x2, y2 = x2 + x3 - nx, y2 + y3 - ny self.set_ydiameter(x2, y2, nx, ny) elif handle == -1: delta = (nx, ny) - self.points.mean(axis=0) self.points += delta def __reduce__(self): state = (self.shapeparam, self.points, self.z()) return (self.__class__, (), state) def __setstate__(self, state): self.shapeparam, self.points, z = state self.setZ(z) self.shapeparam.update_shape(self)
assert_interfaces_valid(EllipseShape)
[docs]class Axes(PolygonShape): """Axes( (0,1), (1,1), (0,0) )""" CLOSED = True def __init__( self, p0=(0, 0), p1=(0, 0), p2=(0, 0), axesparam=None, shapeparam=None ): super(Axes, self).__init__(shapeparam=shapeparam) self.set_rect(p0, p1, p2) self.arrow_angle = 15 # degrees self.arrow_size = 0.05 # % of axe length self.x_pen = self.pen self.x_brush = self.brush self.y_pen = self.pen self.y_brush = self.brush if axesparam is None: self.axesparam = AxesShapeParam(_("Axes"), icon="gtaxes.png") else: self.axesparam = axesparam self.axesparam.update_param(self) def __reduce__(self): self.axesparam.update_param(self) state = (self.shapeparam, self.axesparam, self.points, self.z()) return (self.__class__, (), state) def __setstate__(self, state): shapeparam, axesparam, points, z = state self.points = points self.setZ(z) self.shapeparam = shapeparam self.shapeparam.update_shape(self) self.axesparam = axesparam self.axesparam.update_axes(self)
[docs] def serialize(self, writer): """Serialize object to HDF5 writer""" super(Axes, self).serialize(writer) self.axesparam.update_param(self) writer.write(self.axesparam, group_name="axesparam")
[docs] def deserialize(self, reader): """Deserialize object from HDF5 reader""" super(Axes, self).deserialize(reader) self.axesparam = AxesShapeParam(_("Axes"), icon="gtaxes.png") reader.read("axesparam", instance=self.axesparam) self.axesparam.update_axes(self)
def get_transform_matrix(self, dx=1.0, dy=1.0): p0, p1, _p3, p2 = [np.array([p[0], p[1], 1.0]) for p in self.points] matrix = np.array([(p1 - p0) / dx, (p2 - p0) / dy, p0]) if abs(np.linalg.det(matrix)) > 1e-12: return np.linalg.inv(matrix) def set_rect(self, p0, p1, p2): p3x = p1[0] + p2[0] - p0[0] p3y = p1[1] + p2[1] - p0[1] self.set_points([p0, p1, (p3x, p3y), p2]) def set_style(self, section, option): PolygonShape.set_style(self, section, option + "/border") self.axesparam.read_config(CONF, section, option) self.axesparam.update_axes(self) def move_point_to(self, handle, pos, ctrl=None): _nx, _ny = pos p0, p1, _p3, p2 = list(self.points) d1x = p1[0] - p0[0] d1y = p1[1] - p0[1] d2x = p2[0] - p0[0] d2y = p2[1] - p0[1] if handle == 0: pp0 = pos pp1 = pos[0] + d1x, pos[1] + d1y pp2 = pos[0] + d2x, pos[1] + d2y elif handle == 1: pp0 = p0 pp1 = pos pp2 = p2 elif handle == 3: pp0 = p0 pp1 = p1 pp2 = pos elif handle == 2: # find (a,b) such that p3 = a*d1 + b*d2 + p0 d3x = pos[0] - p0[0] d3y = pos[1] - p0[1] det = d1x * d2y - d2x * d1y if abs(det) < 1e-6: # reset d1x = d2y = 1.0 d1y = d2x = 0.0 det = 1.0 a = (d2y * d3x - d2x * d3y) / det b = (-d1y * d3x + d1x * d3y) / det _pp3 = pos pp1 = p0[0] + a * d1x, p0[1] + a * d1y pp2 = p0[0] + b * d2x, p0[1] + b * d2y pp0 = p0 self.set_rect(pp0, pp1, pp2) if self.plot(): self.plot().SIG_AXES_CHANGED.emit(self)
[docs] def move_shape(self, old_pos, new_pos): """Overriden to emit the axes_changed signal""" PolygonShape.move_shape(self, old_pos, new_pos) if self.plot(): self.plot().SIG_AXES_CHANGED.emit(self)
def draw(self, painter, xMap, yMap, canvasRect): PolygonShape.draw(self, painter, xMap, yMap, canvasRect) p0, p1, _, p2 = list(self.points) painter.setPen(self.x_pen) painter.setBrush(self.x_brush) self.draw_arrow(painter, xMap, yMap, p0, p1) painter.setPen(self.y_pen) painter.setBrush(self.y_brush) self.draw_arrow(painter, xMap, yMap, p0, p2) def draw_arrow(self, painter, xMap, yMap, p0, p1): sz = self.arrow_size angle = pi * self.arrow_angle / 180.0 ca, sa = cos(angle), sin(angle) d1x = xMap.transform(p1[0]) - xMap.transform(p0[0]) d1y = yMap.transform(p1[1]) - yMap.transform(p0[1]) norm = sqrt(d1x**2 + d1y**2) if abs(norm) < 1e-6: return d1x *= sz / norm d1y *= sz / norm n1x = -d1y n1y = d1x # arrow : a0 - a1 == p1 - a2 a1x = xMap.transform(p1[0]) a1y = yMap.transform(p1[1]) a0x = a1x - ca * d1x + sa * n1x a0y = a1y - ca * d1y + sa * n1y a2x = a1x - ca * d1x - sa * n1x a2y = a1y - ca * d1y - sa * n1y poly = QPolygonF() poly.append(QPointF(a0x, a0y)) poly.append(QPointF(a1x, a1y)) poly.append(QPointF(a2x, a2y)) painter.drawPolygon(poly)
[docs] def update_item_parameters(self): self.axesparam.update_param(self)
[docs] def get_item_parameters(self, itemparams): PolygonShape.get_item_parameters(self, itemparams) self.update_item_parameters() itemparams.add("AxesShapeParam", self, self.axesparam)
[docs] def set_item_parameters(self, itemparams): PolygonShape.set_item_parameters(self, itemparams) update_dataset( self.axesparam, itemparams.get("AxesShapeParam"), visible_only=True ) self.axesparam.update_axes(self)
assert_interfaces_valid(Axes)
[docs]class XRangeSelection(AbstractShape): def __init__(self, _min, _max, shapeparam=None): super(XRangeSelection, self).__init__() self._min = _min self._max = _max if shapeparam is None: self.shapeparam = RangeShapeParam(_("Range"), icon="xrange.png") self.shapeparam.read_config(CONF, "histogram", "range") else: self.shapeparam = shapeparam self.pen = None self.sel_pen = None self.brush = None self.handle = None self.symbol = None self.sel_symbol = None self.shapeparam.update_range(self) # creates all the above QObjects def get_handles_pos(self): plot = self.plot() rct = plot.canvas().contentsRect() y = rct.center().y() x0 = plot.transform(self.xAxis(), self._min) x1 = plot.transform(self.xAxis(), self._max) return x0, x1, y def draw(self, painter, xMap, yMap, canvasRect): plot = self.plot() if not plot: return if self.selected: pen = self.sel_pen sym = self.sel_symbol else: pen = self.pen sym = self.symbol rct = plot.canvas().contentsRect() rct2 = QRectF(rct) rct2.setLeft(xMap.transform(self._min)) rct2.setRight(xMap.transform(self._max)) painter.fillRect(rct2, self.brush) painter.setPen(pen) painter.drawLine(rct2.topLeft(), rct2.bottomLeft()) painter.drawLine(rct2.topRight(), rct2.bottomRight()) dash = QPen(pen) dash.setStyle(Qt.DashLine) dash.setWidth(1) painter.setPen(dash) painter.drawLine( QLineF(rct2.center().x(), rct2.top(), rct2.center().x(), rct2.bottom()) ) painter.setPen(pen) x0, x1, y = self.get_handles_pos() sym.drawSymbol(painter, QPointF(x0, y)) sym.drawSymbol(painter, QPointF(x1, y))
[docs] def hit_test(self, pos): x, _y = pos.x(), pos.y() x0, x1, _yp = self.get_handles_pos() d0 = fabs(x0 - x) d1 = fabs(x1 - x) d2 = fabs((x0 + x1) / 2 - x) z = np.array([d0, d1, d2]) dist = z.min() handle = z.argmin() inside = bool(x0 < x < x1) return dist, handle, inside, None
[docs] def move_local_point_to(self, handle, pos, ctrl=None): """Move a handle as returned by hit_test to the new position pos ctrl: True if <Ctrl> button is being pressed, False otherwise""" x, _y = canvas_to_axes(self, pos) self.move_point_to(handle, (x, 0))
def move_point_to(self, hnd, pos, ctrl=None): val, _ = pos if hnd == 0: self._min = val elif hnd == 1: self._max = val elif hnd == 2: move = val - (self._max + self._min) / 2 self._min += move self._max += move self.plot().SIG_RANGE_CHANGED.emit(self, self._min, self._max) # self.plot().replot() def get_range(self): return self._min, self._max def set_range(self, _min, _max, dosignal=True): self._min = _min self._max = _max if dosignal: self.plot().SIG_RANGE_CHANGED.emit(self, self._min, self._max)
[docs] def move_shape(self, old_pos, new_pos): dx = new_pos[0] - old_pos[0] self._min += dx self._max += dx self.plot().SIG_RANGE_CHANGED.emit(self, self._min, self._max) self.plot().replot()
[docs] def update_item_parameters(self): self.shapeparam.update_param(self)
[docs] def get_item_parameters(self, itemparams): self.update_item_parameters() itemparams.add("ShapeParam", self, self.shapeparam)
[docs] def set_item_parameters(self, itemparams): update_dataset(self.shapeparam, itemparams.get("ShapeParam"), visible_only=True) self.shapeparam.update_range(self) self.sel_brush = QBrush(self.brush)
assert_interfaces_valid(XRangeSelection)