2976 lines
97 KiB
Python
2976 lines
97 KiB
Python
"""
|
|
Matplotlib includes a framework for arbitrary geometric
|
|
transformations that is used determine the final position of all
|
|
elements drawn on the canvas.
|
|
|
|
Transforms are composed into trees of `TransformNode` objects
|
|
whose actual value depends on their children. When the contents of
|
|
children change, their parents are automatically invalidated. The
|
|
next time an invalidated transform is accessed, it is recomputed to
|
|
reflect those changes. This invalidation/caching approach prevents
|
|
unnecessary recomputations of transforms, and contributes to better
|
|
interactive performance.
|
|
|
|
For example, here is a graph of the transform tree used to plot data
|
|
to the graph:
|
|
|
|
.. image:: ../_static/transforms.png
|
|
|
|
The framework can be used for both affine and non-affine
|
|
transformations. However, for speed, we want to use the backend
|
|
renderers to perform affine transformations whenever possible.
|
|
Therefore, it is possible to perform just the affine or non-affine
|
|
part of a transformation on a set of data. The affine is always
|
|
assumed to occur after the non-affine. For any transform::
|
|
|
|
full transform == non-affine part + affine part
|
|
|
|
The backends are not expected to handle non-affine transformations
|
|
themselves.
|
|
|
|
See the tutorial :ref:`transforms_tutorial` for examples
|
|
of how to use transforms.
|
|
"""
|
|
|
|
# Note: There are a number of places in the code where we use `np.min` or
|
|
# `np.minimum` instead of the builtin `min`, and likewise for `max`. This is
|
|
# done so that `nan`s are propagated, instead of being silently dropped.
|
|
|
|
import copy
|
|
import functools
|
|
import textwrap
|
|
import weakref
|
|
import math
|
|
|
|
import numpy as np
|
|
from numpy.linalg import inv
|
|
|
|
from matplotlib import _api
|
|
from matplotlib._path import (
|
|
affine_transform, count_bboxes_overlapping_bbox, update_path_extents)
|
|
from .path import Path
|
|
|
|
DEBUG = False
|
|
|
|
|
|
def _make_str_method(*args, **kwargs):
|
|
"""
|
|
Generate a ``__str__`` method for a `.Transform` subclass.
|
|
|
|
After ::
|
|
|
|
class T:
|
|
__str__ = _make_str_method("attr", key="other")
|
|
|
|
``str(T(...))`` will be
|
|
|
|
.. code-block:: text
|
|
|
|
{type(T).__name__}(
|
|
{self.attr},
|
|
key={self.other})
|
|
"""
|
|
indent = functools.partial(textwrap.indent, prefix=" " * 4)
|
|
def strrepr(x): return repr(x) if isinstance(x, str) else str(x)
|
|
return lambda self: (
|
|
type(self).__name__ + "("
|
|
+ ",".join([*(indent("\n" + strrepr(getattr(self, arg)))
|
|
for arg in args),
|
|
*(indent("\n" + k + "=" + strrepr(getattr(self, arg)))
|
|
for k, arg in kwargs.items())])
|
|
+ ")")
|
|
|
|
|
|
class TransformNode:
|
|
"""
|
|
The base class for anything that participates in the transform tree
|
|
and needs to invalidate its parents or be invalidated. This includes
|
|
classes that are not really transforms, such as bounding boxes, since some
|
|
transforms depend on bounding boxes to compute their values.
|
|
"""
|
|
|
|
# Invalidation may affect only the affine part. If the
|
|
# invalidation was "affine-only", the _invalid member is set to
|
|
# INVALID_AFFINE_ONLY
|
|
INVALID_NON_AFFINE = _api.deprecated("3.8")(_api.classproperty(lambda cls: 1))
|
|
INVALID_AFFINE = _api.deprecated("3.8")(_api.classproperty(lambda cls: 2))
|
|
INVALID = _api.deprecated("3.8")(_api.classproperty(lambda cls: 3))
|
|
|
|
# Possible values for the _invalid attribute.
|
|
_VALID, _INVALID_AFFINE_ONLY, _INVALID_FULL = range(3)
|
|
|
|
# Some metadata about the transform, used to determine whether an
|
|
# invalidation is affine-only
|
|
is_affine = False
|
|
is_bbox = False
|
|
|
|
pass_through = False
|
|
"""
|
|
If pass_through is True, all ancestors will always be
|
|
invalidated, even if 'self' is already invalid.
|
|
"""
|
|
|
|
def __init__(self, shorthand_name=None):
|
|
"""
|
|
Parameters
|
|
----------
|
|
shorthand_name : str
|
|
A string representing the "name" of the transform. The name carries
|
|
no significance other than to improve the readability of
|
|
``str(transform)`` when DEBUG=True.
|
|
"""
|
|
self._parents = {}
|
|
# Initially invalid, until first computation.
|
|
self._invalid = self._INVALID_FULL
|
|
self._shorthand_name = shorthand_name or ''
|
|
|
|
if DEBUG:
|
|
def __str__(self):
|
|
# either just return the name of this TransformNode, or its repr
|
|
return self._shorthand_name or repr(self)
|
|
|
|
def __getstate__(self):
|
|
# turn the dictionary with weak values into a normal dictionary
|
|
return {**self.__dict__,
|
|
'_parents': {k: v() for k, v in self._parents.items()}}
|
|
|
|
def __setstate__(self, data_dict):
|
|
self.__dict__ = data_dict
|
|
# turn the normal dictionary back into a dictionary with weak values
|
|
# The extra lambda is to provide a callback to remove dead
|
|
# weakrefs from the dictionary when garbage collection is done.
|
|
self._parents = {
|
|
k: weakref.ref(v, lambda _, pop=self._parents.pop, k=k: pop(k))
|
|
for k, v in self._parents.items() if v is not None}
|
|
|
|
def __copy__(self):
|
|
other = copy.copy(super())
|
|
# If `c = a + b; a1 = copy(a)`, then modifications to `a1` do not
|
|
# propagate back to `c`, i.e. we need to clear the parents of `a1`.
|
|
other._parents = {}
|
|
# If `c = a + b; c1 = copy(c)`, then modifications to `a` also need to
|
|
# be propagated to `c1`.
|
|
for key, val in vars(self).items():
|
|
if isinstance(val, TransformNode) and id(self) in val._parents:
|
|
other.set_children(val) # val == getattr(other, key)
|
|
return other
|
|
|
|
def invalidate(self):
|
|
"""
|
|
Invalidate this `TransformNode` and triggers an invalidation of its
|
|
ancestors. Should be called any time the transform changes.
|
|
"""
|
|
return self._invalidate_internal(
|
|
level=self._INVALID_AFFINE_ONLY if self.is_affine else self._INVALID_FULL,
|
|
invalidating_node=self)
|
|
|
|
def _invalidate_internal(self, level, invalidating_node):
|
|
"""
|
|
Called by :meth:`invalidate` and subsequently ascends the transform
|
|
stack calling each TransformNode's _invalidate_internal method.
|
|
"""
|
|
# If we are already more invalid than the currently propagated invalidation,
|
|
# then we don't need to do anything.
|
|
if level <= self._invalid and not self.pass_through:
|
|
return
|
|
self._invalid = level
|
|
for parent in list(self._parents.values()):
|
|
parent = parent() # Dereference the weak reference.
|
|
if parent is not None:
|
|
parent._invalidate_internal(level=level, invalidating_node=self)
|
|
|
|
def set_children(self, *children):
|
|
"""
|
|
Set the children of the transform, to let the invalidation
|
|
system know which transforms can invalidate this transform.
|
|
Should be called from the constructor of any transforms that
|
|
depend on other transforms.
|
|
"""
|
|
# Parents are stored as weak references, so that if the
|
|
# parents are destroyed, references from the children won't
|
|
# keep them alive.
|
|
id_self = id(self)
|
|
for child in children:
|
|
# Use weak references so this dictionary won't keep obsolete nodes
|
|
# alive; the callback deletes the dictionary entry. This is a
|
|
# performance improvement over using WeakValueDictionary.
|
|
ref = weakref.ref(
|
|
self, lambda _, pop=child._parents.pop, k=id_self: pop(k))
|
|
child._parents[id_self] = ref
|
|
|
|
def frozen(self):
|
|
"""
|
|
Return a frozen copy of this transform node. The frozen copy will not
|
|
be updated when its children change. Useful for storing a previously
|
|
known state of a transform where ``copy.deepcopy()`` might normally be
|
|
used.
|
|
"""
|
|
return self
|
|
|
|
|
|
class BboxBase(TransformNode):
|
|
"""
|
|
The base class of all bounding boxes.
|
|
|
|
This class is immutable; `Bbox` is a mutable subclass.
|
|
|
|
The canonical representation is as two points, with no
|
|
restrictions on their ordering. Convenience properties are
|
|
provided to get the left, bottom, right and top edges and width
|
|
and height, but these are not stored explicitly.
|
|
"""
|
|
|
|
is_bbox = True
|
|
is_affine = True
|
|
|
|
if DEBUG:
|
|
@staticmethod
|
|
def _check(points):
|
|
if isinstance(points, np.ma.MaskedArray):
|
|
_api.warn_external("Bbox bounds are a masked array.")
|
|
points = np.asarray(points)
|
|
if any((points[1, :] - points[0, :]) == 0):
|
|
_api.warn_external("Singular Bbox.")
|
|
|
|
def frozen(self):
|
|
return Bbox(self.get_points().copy())
|
|
frozen.__doc__ = TransformNode.__doc__
|
|
|
|
def __array__(self, *args, **kwargs):
|
|
return self.get_points()
|
|
|
|
@property
|
|
def x0(self):
|
|
"""
|
|
The first of the pair of *x* coordinates that define the bounding box.
|
|
|
|
This is not guaranteed to be less than :attr:`x1` (for that, use
|
|
:attr:`xmin`).
|
|
"""
|
|
return self.get_points()[0, 0]
|
|
|
|
@property
|
|
def y0(self):
|
|
"""
|
|
The first of the pair of *y* coordinates that define the bounding box.
|
|
|
|
This is not guaranteed to be less than :attr:`y1` (for that, use
|
|
:attr:`ymin`).
|
|
"""
|
|
return self.get_points()[0, 1]
|
|
|
|
@property
|
|
def x1(self):
|
|
"""
|
|
The second of the pair of *x* coordinates that define the bounding box.
|
|
|
|
This is not guaranteed to be greater than :attr:`x0` (for that, use
|
|
:attr:`xmax`).
|
|
"""
|
|
return self.get_points()[1, 0]
|
|
|
|
@property
|
|
def y1(self):
|
|
"""
|
|
The second of the pair of *y* coordinates that define the bounding box.
|
|
|
|
This is not guaranteed to be greater than :attr:`y0` (for that, use
|
|
:attr:`ymax`).
|
|
"""
|
|
return self.get_points()[1, 1]
|
|
|
|
@property
|
|
def p0(self):
|
|
"""
|
|
The first pair of (*x*, *y*) coordinates that define the bounding box.
|
|
|
|
This is not guaranteed to be the bottom-left corner (for that, use
|
|
:attr:`min`).
|
|
"""
|
|
return self.get_points()[0]
|
|
|
|
@property
|
|
def p1(self):
|
|
"""
|
|
The second pair of (*x*, *y*) coordinates that define the bounding box.
|
|
|
|
This is not guaranteed to be the top-right corner (for that, use
|
|
:attr:`max`).
|
|
"""
|
|
return self.get_points()[1]
|
|
|
|
@property
|
|
def xmin(self):
|
|
"""The left edge of the bounding box."""
|
|
return np.min(self.get_points()[:, 0])
|
|
|
|
@property
|
|
def ymin(self):
|
|
"""The bottom edge of the bounding box."""
|
|
return np.min(self.get_points()[:, 1])
|
|
|
|
@property
|
|
def xmax(self):
|
|
"""The right edge of the bounding box."""
|
|
return np.max(self.get_points()[:, 0])
|
|
|
|
@property
|
|
def ymax(self):
|
|
"""The top edge of the bounding box."""
|
|
return np.max(self.get_points()[:, 1])
|
|
|
|
@property
|
|
def min(self):
|
|
"""The bottom-left corner of the bounding box."""
|
|
return np.min(self.get_points(), axis=0)
|
|
|
|
@property
|
|
def max(self):
|
|
"""The top-right corner of the bounding box."""
|
|
return np.max(self.get_points(), axis=0)
|
|
|
|
@property
|
|
def intervalx(self):
|
|
"""
|
|
The pair of *x* coordinates that define the bounding box.
|
|
|
|
This is not guaranteed to be sorted from left to right.
|
|
"""
|
|
return self.get_points()[:, 0]
|
|
|
|
@property
|
|
def intervaly(self):
|
|
"""
|
|
The pair of *y* coordinates that define the bounding box.
|
|
|
|
This is not guaranteed to be sorted from bottom to top.
|
|
"""
|
|
return self.get_points()[:, 1]
|
|
|
|
@property
|
|
def width(self):
|
|
"""The (signed) width of the bounding box."""
|
|
points = self.get_points()
|
|
return points[1, 0] - points[0, 0]
|
|
|
|
@property
|
|
def height(self):
|
|
"""The (signed) height of the bounding box."""
|
|
points = self.get_points()
|
|
return points[1, 1] - points[0, 1]
|
|
|
|
@property
|
|
def size(self):
|
|
"""The (signed) width and height of the bounding box."""
|
|
points = self.get_points()
|
|
return points[1] - points[0]
|
|
|
|
@property
|
|
def bounds(self):
|
|
"""Return (:attr:`x0`, :attr:`y0`, :attr:`width`, :attr:`height`)."""
|
|
(x0, y0), (x1, y1) = self.get_points()
|
|
return (x0, y0, x1 - x0, y1 - y0)
|
|
|
|
@property
|
|
def extents(self):
|
|
"""Return (:attr:`x0`, :attr:`y0`, :attr:`x1`, :attr:`y1`)."""
|
|
return self.get_points().flatten() # flatten returns a copy.
|
|
|
|
def get_points(self):
|
|
raise NotImplementedError
|
|
|
|
def containsx(self, x):
|
|
"""
|
|
Return whether *x* is in the closed (:attr:`x0`, :attr:`x1`) interval.
|
|
"""
|
|
x0, x1 = self.intervalx
|
|
return x0 <= x <= x1 or x0 >= x >= x1
|
|
|
|
def containsy(self, y):
|
|
"""
|
|
Return whether *y* is in the closed (:attr:`y0`, :attr:`y1`) interval.
|
|
"""
|
|
y0, y1 = self.intervaly
|
|
return y0 <= y <= y1 or y0 >= y >= y1
|
|
|
|
def contains(self, x, y):
|
|
"""
|
|
Return whether ``(x, y)`` is in the bounding box or on its edge.
|
|
"""
|
|
return self.containsx(x) and self.containsy(y)
|
|
|
|
def overlaps(self, other):
|
|
"""
|
|
Return whether this bounding box overlaps with the other bounding box.
|
|
|
|
Parameters
|
|
----------
|
|
other : `.BboxBase`
|
|
"""
|
|
ax1, ay1, ax2, ay2 = self.extents
|
|
bx1, by1, bx2, by2 = other.extents
|
|
if ax2 < ax1:
|
|
ax2, ax1 = ax1, ax2
|
|
if ay2 < ay1:
|
|
ay2, ay1 = ay1, ay2
|
|
if bx2 < bx1:
|
|
bx2, bx1 = bx1, bx2
|
|
if by2 < by1:
|
|
by2, by1 = by1, by2
|
|
return ax1 <= bx2 and bx1 <= ax2 and ay1 <= by2 and by1 <= ay2
|
|
|
|
def fully_containsx(self, x):
|
|
"""
|
|
Return whether *x* is in the open (:attr:`x0`, :attr:`x1`) interval.
|
|
"""
|
|
x0, x1 = self.intervalx
|
|
return x0 < x < x1 or x0 > x > x1
|
|
|
|
def fully_containsy(self, y):
|
|
"""
|
|
Return whether *y* is in the open (:attr:`y0`, :attr:`y1`) interval.
|
|
"""
|
|
y0, y1 = self.intervaly
|
|
return y0 < y < y1 or y0 > y > y1
|
|
|
|
def fully_contains(self, x, y):
|
|
"""
|
|
Return whether ``x, y`` is in the bounding box, but not on its edge.
|
|
"""
|
|
return self.fully_containsx(x) and self.fully_containsy(y)
|
|
|
|
def fully_overlaps(self, other):
|
|
"""
|
|
Return whether this bounding box overlaps with the other bounding box,
|
|
not including the edges.
|
|
|
|
Parameters
|
|
----------
|
|
other : `.BboxBase`
|
|
"""
|
|
ax1, ay1, ax2, ay2 = self.extents
|
|
bx1, by1, bx2, by2 = other.extents
|
|
if ax2 < ax1:
|
|
ax2, ax1 = ax1, ax2
|
|
if ay2 < ay1:
|
|
ay2, ay1 = ay1, ay2
|
|
if bx2 < bx1:
|
|
bx2, bx1 = bx1, bx2
|
|
if by2 < by1:
|
|
by2, by1 = by1, by2
|
|
return ax1 < bx2 and bx1 < ax2 and ay1 < by2 and by1 < ay2
|
|
|
|
def transformed(self, transform):
|
|
"""
|
|
Construct a `Bbox` by statically transforming this one by *transform*.
|
|
"""
|
|
pts = self.get_points()
|
|
ll, ul, lr = transform.transform(np.array(
|
|
[pts[0], [pts[0, 0], pts[1, 1]], [pts[1, 0], pts[0, 1]]]))
|
|
return Bbox([ll, [lr[0], ul[1]]])
|
|
|
|
coefs = {'C': (0.5, 0.5),
|
|
'SW': (0, 0),
|
|
'S': (0.5, 0),
|
|
'SE': (1.0, 0),
|
|
'E': (1.0, 0.5),
|
|
'NE': (1.0, 1.0),
|
|
'N': (0.5, 1.0),
|
|
'NW': (0, 1.0),
|
|
'W': (0, 0.5)}
|
|
|
|
def anchored(self, c, container=None):
|
|
"""
|
|
Return a copy of the `Bbox` anchored to *c* within *container*.
|
|
|
|
Parameters
|
|
----------
|
|
c : (float, float) or {'C', 'SW', 'S', 'SE', 'E', 'NE', ...}
|
|
Either an (*x*, *y*) pair of relative coordinates (0 is left or
|
|
bottom, 1 is right or top), 'C' (center), or a cardinal direction
|
|
('SW', southwest, is bottom left, etc.).
|
|
container : `Bbox`, optional
|
|
The box within which the `Bbox` is positioned.
|
|
|
|
See Also
|
|
--------
|
|
.Axes.set_anchor
|
|
"""
|
|
if container is None:
|
|
_api.warn_deprecated(
|
|
"3.8", message="Calling anchored() with no container bbox "
|
|
"returns a frozen copy of the original bbox and is deprecated "
|
|
"since %(since)s.")
|
|
container = self
|
|
l, b, w, h = container.bounds
|
|
L, B, W, H = self.bounds
|
|
cx, cy = self.coefs[c] if isinstance(c, str) else c
|
|
return Bbox(self._points +
|
|
[(l + cx * (w - W)) - L,
|
|
(b + cy * (h - H)) - B])
|
|
|
|
def shrunk(self, mx, my):
|
|
"""
|
|
Return a copy of the `Bbox`, shrunk by the factor *mx*
|
|
in the *x* direction and the factor *my* in the *y* direction.
|
|
The lower left corner of the box remains unchanged. Normally
|
|
*mx* and *my* will be less than 1, but this is not enforced.
|
|
"""
|
|
w, h = self.size
|
|
return Bbox([self._points[0],
|
|
self._points[0] + [mx * w, my * h]])
|
|
|
|
def shrunk_to_aspect(self, box_aspect, container=None, fig_aspect=1.0):
|
|
"""
|
|
Return a copy of the `Bbox`, shrunk so that it is as
|
|
large as it can be while having the desired aspect ratio,
|
|
*box_aspect*. If the box coordinates are relative (i.e.
|
|
fractions of a larger box such as a figure) then the
|
|
physical aspect ratio of that figure is specified with
|
|
*fig_aspect*, so that *box_aspect* can also be given as a
|
|
ratio of the absolute dimensions, not the relative dimensions.
|
|
"""
|
|
if box_aspect <= 0 or fig_aspect <= 0:
|
|
raise ValueError("'box_aspect' and 'fig_aspect' must be positive")
|
|
if container is None:
|
|
container = self
|
|
w, h = container.size
|
|
H = w * box_aspect / fig_aspect
|
|
if H <= h:
|
|
W = w
|
|
else:
|
|
W = h * fig_aspect / box_aspect
|
|
H = h
|
|
return Bbox([self._points[0],
|
|
self._points[0] + (W, H)])
|
|
|
|
def splitx(self, *args):
|
|
"""
|
|
Return a list of new `Bbox` objects formed by splitting the original
|
|
one with vertical lines at fractional positions given by *args*.
|
|
"""
|
|
xf = [0, *args, 1]
|
|
x0, y0, x1, y1 = self.extents
|
|
w = x1 - x0
|
|
return [Bbox([[x0 + xf0 * w, y0], [x0 + xf1 * w, y1]])
|
|
for xf0, xf1 in zip(xf[:-1], xf[1:])]
|
|
|
|
def splity(self, *args):
|
|
"""
|
|
Return a list of new `Bbox` objects formed by splitting the original
|
|
one with horizontal lines at fractional positions given by *args*.
|
|
"""
|
|
yf = [0, *args, 1]
|
|
x0, y0, x1, y1 = self.extents
|
|
h = y1 - y0
|
|
return [Bbox([[x0, y0 + yf0 * h], [x1, y0 + yf1 * h]])
|
|
for yf0, yf1 in zip(yf[:-1], yf[1:])]
|
|
|
|
def count_contains(self, vertices):
|
|
"""
|
|
Count the number of vertices contained in the `Bbox`.
|
|
Any vertices with a non-finite x or y value are ignored.
|
|
|
|
Parameters
|
|
----------
|
|
vertices : (N, 2) array
|
|
"""
|
|
if len(vertices) == 0:
|
|
return 0
|
|
vertices = np.asarray(vertices)
|
|
with np.errstate(invalid='ignore'):
|
|
return (((self.min < vertices) &
|
|
(vertices < self.max)).all(axis=1).sum())
|
|
|
|
def count_overlaps(self, bboxes):
|
|
"""
|
|
Count the number of bounding boxes that overlap this one.
|
|
|
|
Parameters
|
|
----------
|
|
bboxes : sequence of `.BboxBase`
|
|
"""
|
|
return count_bboxes_overlapping_bbox(
|
|
self, np.atleast_3d([np.array(x) for x in bboxes]))
|
|
|
|
def expanded(self, sw, sh):
|
|
"""
|
|
Construct a `Bbox` by expanding this one around its center by the
|
|
factors *sw* and *sh*.
|
|
"""
|
|
width = self.width
|
|
height = self.height
|
|
deltaw = (sw * width - width) / 2.0
|
|
deltah = (sh * height - height) / 2.0
|
|
a = np.array([[-deltaw, -deltah], [deltaw, deltah]])
|
|
return Bbox(self._points + a)
|
|
|
|
@_api.rename_parameter("3.8", "p", "w_pad")
|
|
def padded(self, w_pad, h_pad=None):
|
|
"""
|
|
Construct a `Bbox` by padding this one on all four sides.
|
|
|
|
Parameters
|
|
----------
|
|
w_pad : float
|
|
Width pad
|
|
h_pad : float, optional
|
|
Height pad. Defaults to *w_pad*.
|
|
|
|
"""
|
|
points = self.get_points()
|
|
if h_pad is None:
|
|
h_pad = w_pad
|
|
return Bbox(points + [[-w_pad, -h_pad], [w_pad, h_pad]])
|
|
|
|
def translated(self, tx, ty):
|
|
"""Construct a `Bbox` by translating this one by *tx* and *ty*."""
|
|
return Bbox(self._points + (tx, ty))
|
|
|
|
def corners(self):
|
|
"""
|
|
Return the corners of this rectangle as an array of points.
|
|
|
|
Specifically, this returns the array
|
|
``[[x0, y0], [x0, y1], [x1, y0], [x1, y1]]``.
|
|
"""
|
|
(x0, y0), (x1, y1) = self.get_points()
|
|
return np.array([[x0, y0], [x0, y1], [x1, y0], [x1, y1]])
|
|
|
|
def rotated(self, radians):
|
|
"""
|
|
Return the axes-aligned bounding box that bounds the result of rotating
|
|
this `Bbox` by an angle of *radians*.
|
|
"""
|
|
corners = self.corners()
|
|
corners_rotated = Affine2D().rotate(radians).transform(corners)
|
|
bbox = Bbox.unit()
|
|
bbox.update_from_data_xy(corners_rotated, ignore=True)
|
|
return bbox
|
|
|
|
@staticmethod
|
|
def union(bboxes):
|
|
"""Return a `Bbox` that contains all of the given *bboxes*."""
|
|
if not len(bboxes):
|
|
raise ValueError("'bboxes' cannot be empty")
|
|
x0 = np.min([bbox.xmin for bbox in bboxes])
|
|
x1 = np.max([bbox.xmax for bbox in bboxes])
|
|
y0 = np.min([bbox.ymin for bbox in bboxes])
|
|
y1 = np.max([bbox.ymax for bbox in bboxes])
|
|
return Bbox([[x0, y0], [x1, y1]])
|
|
|
|
@staticmethod
|
|
def intersection(bbox1, bbox2):
|
|
"""
|
|
Return the intersection of *bbox1* and *bbox2* if they intersect, or
|
|
None if they don't.
|
|
"""
|
|
x0 = np.maximum(bbox1.xmin, bbox2.xmin)
|
|
x1 = np.minimum(bbox1.xmax, bbox2.xmax)
|
|
y0 = np.maximum(bbox1.ymin, bbox2.ymin)
|
|
y1 = np.minimum(bbox1.ymax, bbox2.ymax)
|
|
return Bbox([[x0, y0], [x1, y1]]) if x0 <= x1 and y0 <= y1 else None
|
|
|
|
_default_minpos = np.array([np.inf, np.inf])
|
|
|
|
|
|
class Bbox(BboxBase):
|
|
"""
|
|
A mutable bounding box.
|
|
|
|
Examples
|
|
--------
|
|
**Create from known bounds**
|
|
|
|
The default constructor takes the boundary "points" ``[[xmin, ymin],
|
|
[xmax, ymax]]``.
|
|
|
|
>>> Bbox([[1, 1], [3, 7]])
|
|
Bbox([[1.0, 1.0], [3.0, 7.0]])
|
|
|
|
Alternatively, a Bbox can be created from the flattened points array, the
|
|
so-called "extents" ``(xmin, ymin, xmax, ymax)``
|
|
|
|
>>> Bbox.from_extents(1, 1, 3, 7)
|
|
Bbox([[1.0, 1.0], [3.0, 7.0]])
|
|
|
|
or from the "bounds" ``(xmin, ymin, width, height)``.
|
|
|
|
>>> Bbox.from_bounds(1, 1, 2, 6)
|
|
Bbox([[1.0, 1.0], [3.0, 7.0]])
|
|
|
|
**Create from collections of points**
|
|
|
|
The "empty" object for accumulating Bboxs is the null bbox, which is a
|
|
stand-in for the empty set.
|
|
|
|
>>> Bbox.null()
|
|
Bbox([[inf, inf], [-inf, -inf]])
|
|
|
|
Adding points to the null bbox will give you the bbox of those points.
|
|
|
|
>>> box = Bbox.null()
|
|
>>> box.update_from_data_xy([[1, 1]])
|
|
>>> box
|
|
Bbox([[1.0, 1.0], [1.0, 1.0]])
|
|
>>> box.update_from_data_xy([[2, 3], [3, 2]], ignore=False)
|
|
>>> box
|
|
Bbox([[1.0, 1.0], [3.0, 3.0]])
|
|
|
|
Setting ``ignore=True`` is equivalent to starting over from a null bbox.
|
|
|
|
>>> box.update_from_data_xy([[1, 1]], ignore=True)
|
|
>>> box
|
|
Bbox([[1.0, 1.0], [1.0, 1.0]])
|
|
|
|
.. warning::
|
|
|
|
It is recommended to always specify ``ignore`` explicitly. If not, the
|
|
default value of ``ignore`` can be changed at any time by code with
|
|
access to your Bbox, for example using the method `~.Bbox.ignore`.
|
|
|
|
**Properties of the ``null`` bbox**
|
|
|
|
.. note::
|
|
|
|
The current behavior of `Bbox.null()` may be surprising as it does
|
|
not have all of the properties of the "empty set", and as such does
|
|
not behave like a "zero" object in the mathematical sense. We may
|
|
change that in the future (with a deprecation period).
|
|
|
|
The null bbox is the identity for intersections
|
|
|
|
>>> Bbox.intersection(Bbox([[1, 1], [3, 7]]), Bbox.null())
|
|
Bbox([[1.0, 1.0], [3.0, 7.0]])
|
|
|
|
except with itself, where it returns the full space.
|
|
|
|
>>> Bbox.intersection(Bbox.null(), Bbox.null())
|
|
Bbox([[-inf, -inf], [inf, inf]])
|
|
|
|
A union containing null will always return the full space (not the other
|
|
set!)
|
|
|
|
>>> Bbox.union([Bbox([[0, 0], [0, 0]]), Bbox.null()])
|
|
Bbox([[-inf, -inf], [inf, inf]])
|
|
"""
|
|
|
|
def __init__(self, points, **kwargs):
|
|
"""
|
|
Parameters
|
|
----------
|
|
points : `~numpy.ndarray`
|
|
A (2, 2) array of the form ``[[x0, y0], [x1, y1]]``.
|
|
"""
|
|
super().__init__(**kwargs)
|
|
points = np.asarray(points, float)
|
|
if points.shape != (2, 2):
|
|
raise ValueError('Bbox points must be of the form '
|
|
'"[[x0, y0], [x1, y1]]".')
|
|
self._points = points
|
|
self._minpos = _default_minpos.copy()
|
|
self._ignore = True
|
|
# it is helpful in some contexts to know if the bbox is a
|
|
# default or has been mutated; we store the orig points to
|
|
# support the mutated methods
|
|
self._points_orig = self._points.copy()
|
|
if DEBUG:
|
|
___init__ = __init__
|
|
|
|
def __init__(self, points, **kwargs):
|
|
self._check(points)
|
|
self.___init__(points, **kwargs)
|
|
|
|
def invalidate(self):
|
|
self._check(self._points)
|
|
super().invalidate()
|
|
|
|
def frozen(self):
|
|
# docstring inherited
|
|
frozen_bbox = super().frozen()
|
|
frozen_bbox._minpos = self.minpos.copy()
|
|
return frozen_bbox
|
|
|
|
@staticmethod
|
|
def unit():
|
|
"""Create a new unit `Bbox` from (0, 0) to (1, 1)."""
|
|
return Bbox([[0, 0], [1, 1]])
|
|
|
|
@staticmethod
|
|
def null():
|
|
"""Create a new null `Bbox` from (inf, inf) to (-inf, -inf)."""
|
|
return Bbox([[np.inf, np.inf], [-np.inf, -np.inf]])
|
|
|
|
@staticmethod
|
|
def from_bounds(x0, y0, width, height):
|
|
"""
|
|
Create a new `Bbox` from *x0*, *y0*, *width* and *height*.
|
|
|
|
*width* and *height* may be negative.
|
|
"""
|
|
return Bbox.from_extents(x0, y0, x0 + width, y0 + height)
|
|
|
|
@staticmethod
|
|
def from_extents(*args, minpos=None):
|
|
"""
|
|
Create a new Bbox from *left*, *bottom*, *right* and *top*.
|
|
|
|
The *y*-axis increases upwards.
|
|
|
|
Parameters
|
|
----------
|
|
left, bottom, right, top : float
|
|
The four extents of the bounding box.
|
|
minpos : float or None
|
|
If this is supplied, the Bbox will have a minimum positive value
|
|
set. This is useful when dealing with logarithmic scales and other
|
|
scales where negative bounds result in floating point errors.
|
|
"""
|
|
bbox = Bbox(np.reshape(args, (2, 2)))
|
|
if minpos is not None:
|
|
bbox._minpos[:] = minpos
|
|
return bbox
|
|
|
|
def __format__(self, fmt):
|
|
return (
|
|
'Bbox(x0={0.x0:{1}}, y0={0.y0:{1}}, x1={0.x1:{1}}, y1={0.y1:{1}})'.
|
|
format(self, fmt))
|
|
|
|
def __str__(self):
|
|
return format(self, '')
|
|
|
|
def __repr__(self):
|
|
return 'Bbox([[{0.x0}, {0.y0}], [{0.x1}, {0.y1}]])'.format(self)
|
|
|
|
def ignore(self, value):
|
|
"""
|
|
Set whether the existing bounds of the box should be ignored
|
|
by subsequent calls to :meth:`update_from_data_xy`.
|
|
|
|
value : bool
|
|
- When ``True``, subsequent calls to `update_from_data_xy` will
|
|
ignore the existing bounds of the `Bbox`.
|
|
- When ``False``, subsequent calls to `update_from_data_xy` will
|
|
include the existing bounds of the `Bbox`.
|
|
"""
|
|
self._ignore = value
|
|
|
|
def update_from_path(self, path, ignore=None, updatex=True, updatey=True):
|
|
"""
|
|
Update the bounds of the `Bbox` to contain the vertices of the
|
|
provided path. After updating, the bounds will have positive *width*
|
|
and *height*; *x0* and *y0* will be the minimal values.
|
|
|
|
Parameters
|
|
----------
|
|
path : `~matplotlib.path.Path`
|
|
ignore : bool, optional
|
|
- When ``True``, ignore the existing bounds of the `Bbox`.
|
|
- When ``False``, include the existing bounds of the `Bbox`.
|
|
- When ``None``, use the last value passed to :meth:`ignore`.
|
|
updatex, updatey : bool, default: True
|
|
When ``True``, update the x/y values.
|
|
"""
|
|
if ignore is None:
|
|
ignore = self._ignore
|
|
|
|
if path.vertices.size == 0:
|
|
return
|
|
|
|
points, minpos, changed = update_path_extents(
|
|
path, None, self._points, self._minpos, ignore)
|
|
|
|
if changed:
|
|
self.invalidate()
|
|
if updatex:
|
|
self._points[:, 0] = points[:, 0]
|
|
self._minpos[0] = minpos[0]
|
|
if updatey:
|
|
self._points[:, 1] = points[:, 1]
|
|
self._minpos[1] = minpos[1]
|
|
|
|
def update_from_data_x(self, x, ignore=None):
|
|
"""
|
|
Update the x-bounds of the `Bbox` based on the passed in data. After
|
|
updating, the bounds will have positive *width*, and *x0* will be the
|
|
minimal value.
|
|
|
|
Parameters
|
|
----------
|
|
x : `~numpy.ndarray`
|
|
Array of x-values.
|
|
ignore : bool, optional
|
|
- When ``True``, ignore the existing bounds of the `Bbox`.
|
|
- When ``False``, include the existing bounds of the `Bbox`.
|
|
- When ``None``, use the last value passed to :meth:`ignore`.
|
|
"""
|
|
x = np.ravel(x)
|
|
self.update_from_data_xy(np.column_stack([x, np.ones(x.size)]),
|
|
ignore=ignore, updatey=False)
|
|
|
|
def update_from_data_y(self, y, ignore=None):
|
|
"""
|
|
Update the y-bounds of the `Bbox` based on the passed in data. After
|
|
updating, the bounds will have positive *height*, and *y0* will be the
|
|
minimal value.
|
|
|
|
Parameters
|
|
----------
|
|
y : `~numpy.ndarray`
|
|
Array of y-values.
|
|
ignore : bool, optional
|
|
- When ``True``, ignore the existing bounds of the `Bbox`.
|
|
- When ``False``, include the existing bounds of the `Bbox`.
|
|
- When ``None``, use the last value passed to :meth:`ignore`.
|
|
"""
|
|
y = np.ravel(y)
|
|
self.update_from_data_xy(np.column_stack([np.ones(y.size), y]),
|
|
ignore=ignore, updatex=False)
|
|
|
|
def update_from_data_xy(self, xy, ignore=None, updatex=True, updatey=True):
|
|
"""
|
|
Update the `Bbox` bounds based on the passed in *xy* coordinates.
|
|
|
|
After updating, the bounds will have positive *width* and *height*;
|
|
*x0* and *y0* will be the minimal values.
|
|
|
|
Parameters
|
|
----------
|
|
xy : (N, 2) array-like
|
|
The (x, y) coordinates.
|
|
ignore : bool, optional
|
|
- When ``True``, ignore the existing bounds of the `Bbox`.
|
|
- When ``False``, include the existing bounds of the `Bbox`.
|
|
- When ``None``, use the last value passed to :meth:`ignore`.
|
|
updatex, updatey : bool, default: True
|
|
When ``True``, update the x/y values.
|
|
"""
|
|
if len(xy) == 0:
|
|
return
|
|
|
|
path = Path(xy)
|
|
self.update_from_path(path, ignore=ignore,
|
|
updatex=updatex, updatey=updatey)
|
|
|
|
@BboxBase.x0.setter
|
|
def x0(self, val):
|
|
self._points[0, 0] = val
|
|
self.invalidate()
|
|
|
|
@BboxBase.y0.setter
|
|
def y0(self, val):
|
|
self._points[0, 1] = val
|
|
self.invalidate()
|
|
|
|
@BboxBase.x1.setter
|
|
def x1(self, val):
|
|
self._points[1, 0] = val
|
|
self.invalidate()
|
|
|
|
@BboxBase.y1.setter
|
|
def y1(self, val):
|
|
self._points[1, 1] = val
|
|
self.invalidate()
|
|
|
|
@BboxBase.p0.setter
|
|
def p0(self, val):
|
|
self._points[0] = val
|
|
self.invalidate()
|
|
|
|
@BboxBase.p1.setter
|
|
def p1(self, val):
|
|
self._points[1] = val
|
|
self.invalidate()
|
|
|
|
@BboxBase.intervalx.setter
|
|
def intervalx(self, interval):
|
|
self._points[:, 0] = interval
|
|
self.invalidate()
|
|
|
|
@BboxBase.intervaly.setter
|
|
def intervaly(self, interval):
|
|
self._points[:, 1] = interval
|
|
self.invalidate()
|
|
|
|
@BboxBase.bounds.setter
|
|
def bounds(self, bounds):
|
|
l, b, w, h = bounds
|
|
points = np.array([[l, b], [l + w, b + h]], float)
|
|
if np.any(self._points != points):
|
|
self._points = points
|
|
self.invalidate()
|
|
|
|
@property
|
|
def minpos(self):
|
|
"""
|
|
The minimum positive value in both directions within the Bbox.
|
|
|
|
This is useful when dealing with logarithmic scales and other scales
|
|
where negative bounds result in floating point errors, and will be used
|
|
as the minimum extent instead of *p0*.
|
|
"""
|
|
return self._minpos
|
|
|
|
@property
|
|
def minposx(self):
|
|
"""
|
|
The minimum positive value in the *x*-direction within the Bbox.
|
|
|
|
This is useful when dealing with logarithmic scales and other scales
|
|
where negative bounds result in floating point errors, and will be used
|
|
as the minimum *x*-extent instead of *x0*.
|
|
"""
|
|
return self._minpos[0]
|
|
|
|
@property
|
|
def minposy(self):
|
|
"""
|
|
The minimum positive value in the *y*-direction within the Bbox.
|
|
|
|
This is useful when dealing with logarithmic scales and other scales
|
|
where negative bounds result in floating point errors, and will be used
|
|
as the minimum *y*-extent instead of *y0*.
|
|
"""
|
|
return self._minpos[1]
|
|
|
|
def get_points(self):
|
|
"""
|
|
Get the points of the bounding box as an array of the form
|
|
``[[x0, y0], [x1, y1]]``.
|
|
"""
|
|
self._invalid = 0
|
|
return self._points
|
|
|
|
def set_points(self, points):
|
|
"""
|
|
Set the points of the bounding box directly from an array of the form
|
|
``[[x0, y0], [x1, y1]]``. No error checking is performed, as this
|
|
method is mainly for internal use.
|
|
"""
|
|
if np.any(self._points != points):
|
|
self._points = points
|
|
self.invalidate()
|
|
|
|
def set(self, other):
|
|
"""
|
|
Set this bounding box from the "frozen" bounds of another `Bbox`.
|
|
"""
|
|
if np.any(self._points != other.get_points()):
|
|
self._points = other.get_points()
|
|
self.invalidate()
|
|
|
|
def mutated(self):
|
|
"""Return whether the bbox has changed since init."""
|
|
return self.mutatedx() or self.mutatedy()
|
|
|
|
def mutatedx(self):
|
|
"""Return whether the x-limits have changed since init."""
|
|
return (self._points[0, 0] != self._points_orig[0, 0] or
|
|
self._points[1, 0] != self._points_orig[1, 0])
|
|
|
|
def mutatedy(self):
|
|
"""Return whether the y-limits have changed since init."""
|
|
return (self._points[0, 1] != self._points_orig[0, 1] or
|
|
self._points[1, 1] != self._points_orig[1, 1])
|
|
|
|
|
|
class TransformedBbox(BboxBase):
|
|
"""
|
|
A `Bbox` that is automatically transformed by a given
|
|
transform. When either the child bounding box or transform
|
|
changes, the bounds of this bbox will update accordingly.
|
|
"""
|
|
|
|
def __init__(self, bbox, transform, **kwargs):
|
|
"""
|
|
Parameters
|
|
----------
|
|
bbox : `Bbox`
|
|
transform : `Transform`
|
|
"""
|
|
if not bbox.is_bbox:
|
|
raise ValueError("'bbox' is not a bbox")
|
|
_api.check_isinstance(Transform, transform=transform)
|
|
if transform.input_dims != 2 or transform.output_dims != 2:
|
|
raise ValueError(
|
|
"The input and output dimensions of 'transform' must be 2")
|
|
|
|
super().__init__(**kwargs)
|
|
self._bbox = bbox
|
|
self._transform = transform
|
|
self.set_children(bbox, transform)
|
|
self._points = None
|
|
|
|
__str__ = _make_str_method("_bbox", "_transform")
|
|
|
|
def get_points(self):
|
|
# docstring inherited
|
|
if self._invalid:
|
|
p = self._bbox.get_points()
|
|
# Transform all four points, then make a new bounding box
|
|
# from the result, taking care to make the orientation the
|
|
# same.
|
|
points = self._transform.transform(
|
|
[[p[0, 0], p[0, 1]],
|
|
[p[1, 0], p[0, 1]],
|
|
[p[0, 0], p[1, 1]],
|
|
[p[1, 0], p[1, 1]]])
|
|
points = np.ma.filled(points, 0.0)
|
|
|
|
xs = min(points[:, 0]), max(points[:, 0])
|
|
if p[0, 0] > p[1, 0]:
|
|
xs = xs[::-1]
|
|
|
|
ys = min(points[:, 1]), max(points[:, 1])
|
|
if p[0, 1] > p[1, 1]:
|
|
ys = ys[::-1]
|
|
|
|
self._points = np.array([
|
|
[xs[0], ys[0]],
|
|
[xs[1], ys[1]]
|
|
])
|
|
|
|
self._invalid = 0
|
|
return self._points
|
|
|
|
if DEBUG:
|
|
_get_points = get_points
|
|
|
|
def get_points(self):
|
|
points = self._get_points()
|
|
self._check(points)
|
|
return points
|
|
|
|
def contains(self, x, y):
|
|
# Docstring inherited.
|
|
return self._bbox.contains(*self._transform.inverted().transform((x, y)))
|
|
|
|
def fully_contains(self, x, y):
|
|
# Docstring inherited.
|
|
return self._bbox.fully_contains(*self._transform.inverted().transform((x, y)))
|
|
|
|
|
|
class LockableBbox(BboxBase):
|
|
"""
|
|
A `Bbox` where some elements may be locked at certain values.
|
|
|
|
When the child bounding box changes, the bounds of this bbox will update
|
|
accordingly with the exception of the locked elements.
|
|
"""
|
|
def __init__(self, bbox, x0=None, y0=None, x1=None, y1=None, **kwargs):
|
|
"""
|
|
Parameters
|
|
----------
|
|
bbox : `Bbox`
|
|
The child bounding box to wrap.
|
|
|
|
x0 : float or None
|
|
The locked value for x0, or None to leave unlocked.
|
|
|
|
y0 : float or None
|
|
The locked value for y0, or None to leave unlocked.
|
|
|
|
x1 : float or None
|
|
The locked value for x1, or None to leave unlocked.
|
|
|
|
y1 : float or None
|
|
The locked value for y1, or None to leave unlocked.
|
|
|
|
"""
|
|
if not bbox.is_bbox:
|
|
raise ValueError("'bbox' is not a bbox")
|
|
|
|
super().__init__(**kwargs)
|
|
self._bbox = bbox
|
|
self.set_children(bbox)
|
|
self._points = None
|
|
fp = [x0, y0, x1, y1]
|
|
mask = [val is None for val in fp]
|
|
self._locked_points = np.ma.array(fp, float, mask=mask).reshape((2, 2))
|
|
|
|
__str__ = _make_str_method("_bbox", "_locked_points")
|
|
|
|
def get_points(self):
|
|
# docstring inherited
|
|
if self._invalid:
|
|
points = self._bbox.get_points()
|
|
self._points = np.where(self._locked_points.mask,
|
|
points,
|
|
self._locked_points)
|
|
self._invalid = 0
|
|
return self._points
|
|
|
|
if DEBUG:
|
|
_get_points = get_points
|
|
|
|
def get_points(self):
|
|
points = self._get_points()
|
|
self._check(points)
|
|
return points
|
|
|
|
@property
|
|
def locked_x0(self):
|
|
"""
|
|
float or None: The value used for the locked x0.
|
|
"""
|
|
if self._locked_points.mask[0, 0]:
|
|
return None
|
|
else:
|
|
return self._locked_points[0, 0]
|
|
|
|
@locked_x0.setter
|
|
def locked_x0(self, x0):
|
|
self._locked_points.mask[0, 0] = x0 is None
|
|
self._locked_points.data[0, 0] = x0
|
|
self.invalidate()
|
|
|
|
@property
|
|
def locked_y0(self):
|
|
"""
|
|
float or None: The value used for the locked y0.
|
|
"""
|
|
if self._locked_points.mask[0, 1]:
|
|
return None
|
|
else:
|
|
return self._locked_points[0, 1]
|
|
|
|
@locked_y0.setter
|
|
def locked_y0(self, y0):
|
|
self._locked_points.mask[0, 1] = y0 is None
|
|
self._locked_points.data[0, 1] = y0
|
|
self.invalidate()
|
|
|
|
@property
|
|
def locked_x1(self):
|
|
"""
|
|
float or None: The value used for the locked x1.
|
|
"""
|
|
if self._locked_points.mask[1, 0]:
|
|
return None
|
|
else:
|
|
return self._locked_points[1, 0]
|
|
|
|
@locked_x1.setter
|
|
def locked_x1(self, x1):
|
|
self._locked_points.mask[1, 0] = x1 is None
|
|
self._locked_points.data[1, 0] = x1
|
|
self.invalidate()
|
|
|
|
@property
|
|
def locked_y1(self):
|
|
"""
|
|
float or None: The value used for the locked y1.
|
|
"""
|
|
if self._locked_points.mask[1, 1]:
|
|
return None
|
|
else:
|
|
return self._locked_points[1, 1]
|
|
|
|
@locked_y1.setter
|
|
def locked_y1(self, y1):
|
|
self._locked_points.mask[1, 1] = y1 is None
|
|
self._locked_points.data[1, 1] = y1
|
|
self.invalidate()
|
|
|
|
|
|
class Transform(TransformNode):
|
|
"""
|
|
The base class of all `TransformNode` instances that
|
|
actually perform a transformation.
|
|
|
|
All non-affine transformations should be subclasses of this class.
|
|
New affine transformations should be subclasses of `Affine2D`.
|
|
|
|
Subclasses of this class should override the following members (at
|
|
minimum):
|
|
|
|
- :attr:`input_dims`
|
|
- :attr:`output_dims`
|
|
- :meth:`transform`
|
|
- :meth:`inverted` (if an inverse exists)
|
|
|
|
The following attributes may be overridden if the default is unsuitable:
|
|
|
|
- :attr:`is_separable` (defaults to True for 1D -> 1D transforms, False
|
|
otherwise)
|
|
- :attr:`has_inverse` (defaults to True if :meth:`inverted` is overridden,
|
|
False otherwise)
|
|
|
|
If the transform needs to do something non-standard with
|
|
`matplotlib.path.Path` objects, such as adding curves
|
|
where there were once line segments, it should override:
|
|
|
|
- :meth:`transform_path`
|
|
"""
|
|
|
|
input_dims = None
|
|
"""
|
|
The number of input dimensions of this transform.
|
|
Must be overridden (with integers) in the subclass.
|
|
"""
|
|
|
|
output_dims = None
|
|
"""
|
|
The number of output dimensions of this transform.
|
|
Must be overridden (with integers) in the subclass.
|
|
"""
|
|
|
|
is_separable = False
|
|
"""True if this transform is separable in the x- and y- dimensions."""
|
|
|
|
has_inverse = False
|
|
"""True if this transform has a corresponding inverse transform."""
|
|
|
|
def __init_subclass__(cls):
|
|
# 1d transforms are always separable; we assume higher-dimensional ones
|
|
# are not but subclasses can also directly set is_separable -- this is
|
|
# verified by checking whether "is_separable" appears more than once in
|
|
# the class's MRO (it appears once in Transform).
|
|
if (sum("is_separable" in vars(parent) for parent in cls.__mro__) == 1
|
|
and cls.input_dims == cls.output_dims == 1):
|
|
cls.is_separable = True
|
|
# Transform.inverted raises NotImplementedError; we assume that if this
|
|
# is overridden then the transform is invertible but subclass can also
|
|
# directly set has_inverse.
|
|
if (sum("has_inverse" in vars(parent) for parent in cls.__mro__) == 1
|
|
and hasattr(cls, "inverted")
|
|
and cls.inverted is not Transform.inverted):
|
|
cls.has_inverse = True
|
|
|
|
def __add__(self, other):
|
|
"""
|
|
Compose two transforms together so that *self* is followed by *other*.
|
|
|
|
``A + B`` returns a transform ``C`` so that
|
|
``C.transform(x) == B.transform(A.transform(x))``.
|
|
"""
|
|
return (composite_transform_factory(self, other)
|
|
if isinstance(other, Transform) else
|
|
NotImplemented)
|
|
|
|
# Equality is based on object identity for `Transform`s (so we don't
|
|
# override `__eq__`), but some subclasses, such as TransformWrapper &
|
|
# AffineBase, override this behavior.
|
|
|
|
def _iter_break_from_left_to_right(self):
|
|
"""
|
|
Return an iterator breaking down this transform stack from left to
|
|
right recursively. If self == ((A, N), A) then the result will be an
|
|
iterator which yields I : ((A, N), A), followed by A : (N, A),
|
|
followed by (A, N) : (A), but not ((A, N), A) : I.
|
|
|
|
This is equivalent to flattening the stack then yielding
|
|
``flat_stack[:i], flat_stack[i:]`` where i=0..(n-1).
|
|
"""
|
|
yield IdentityTransform(), self
|
|
|
|
@property
|
|
def depth(self):
|
|
"""
|
|
Return the number of transforms which have been chained
|
|
together to form this Transform instance.
|
|
|
|
.. note::
|
|
|
|
For the special case of a Composite transform, the maximum depth
|
|
of the two is returned.
|
|
|
|
"""
|
|
return 1
|
|
|
|
def contains_branch(self, other):
|
|
"""
|
|
Return whether the given transform is a sub-tree of this transform.
|
|
|
|
This routine uses transform equality to identify sub-trees, therefore
|
|
in many situations it is object id which will be used.
|
|
|
|
For the case where the given transform represents the whole
|
|
of this transform, returns True.
|
|
"""
|
|
if self.depth < other.depth:
|
|
return False
|
|
|
|
# check that a subtree is equal to other (starting from self)
|
|
for _, sub_tree in self._iter_break_from_left_to_right():
|
|
if sub_tree == other:
|
|
return True
|
|
return False
|
|
|
|
def contains_branch_seperately(self, other_transform):
|
|
"""
|
|
Return whether the given branch is a sub-tree of this transform on
|
|
each separate dimension.
|
|
|
|
A common use for this method is to identify if a transform is a blended
|
|
transform containing an Axes' data transform. e.g.::
|
|
|
|
x_isdata, y_isdata = trans.contains_branch_seperately(ax.transData)
|
|
|
|
"""
|
|
if self.output_dims != 2:
|
|
raise ValueError('contains_branch_seperately only supports '
|
|
'transforms with 2 output dimensions')
|
|
# for a non-blended transform each separate dimension is the same, so
|
|
# just return the appropriate shape.
|
|
return [self.contains_branch(other_transform)] * 2
|
|
|
|
def __sub__(self, other):
|
|
"""
|
|
Compose *self* with the inverse of *other*, cancelling identical terms
|
|
if any::
|
|
|
|
# In general:
|
|
A - B == A + B.inverted()
|
|
# (but see note regarding frozen transforms below).
|
|
|
|
# If A "ends with" B (i.e. A == A' + B for some A') we can cancel
|
|
# out B:
|
|
(A' + B) - B == A'
|
|
|
|
# Likewise, if B "starts with" A (B = A + B'), we can cancel out A:
|
|
A - (A + B') == B'.inverted() == B'^-1
|
|
|
|
Cancellation (rather than naively returning ``A + B.inverted()``) is
|
|
important for multiple reasons:
|
|
|
|
- It avoids floating-point inaccuracies when computing the inverse of
|
|
B: ``B - B`` is guaranteed to cancel out exactly (resulting in the
|
|
identity transform), whereas ``B + B.inverted()`` may differ by a
|
|
small epsilon.
|
|
- ``B.inverted()`` always returns a frozen transform: if one computes
|
|
``A + B + B.inverted()`` and later mutates ``B``, then
|
|
``B.inverted()`` won't be updated and the last two terms won't cancel
|
|
out anymore; on the other hand, ``A + B - B`` will always be equal to
|
|
``A`` even if ``B`` is mutated.
|
|
"""
|
|
# we only know how to do this operation if other is a Transform.
|
|
if not isinstance(other, Transform):
|
|
return NotImplemented
|
|
for remainder, sub_tree in self._iter_break_from_left_to_right():
|
|
if sub_tree == other:
|
|
return remainder
|
|
for remainder, sub_tree in other._iter_break_from_left_to_right():
|
|
if sub_tree == self:
|
|
if not remainder.has_inverse:
|
|
raise ValueError(
|
|
"The shortcut cannot be computed since 'other' "
|
|
"includes a non-invertible component")
|
|
return remainder.inverted()
|
|
# if we have got this far, then there was no shortcut possible
|
|
if other.has_inverse:
|
|
return self + other.inverted()
|
|
else:
|
|
raise ValueError('It is not possible to compute transA - transB '
|
|
'since transB cannot be inverted and there is no '
|
|
'shortcut possible.')
|
|
|
|
def __array__(self, *args, **kwargs):
|
|
"""Array interface to get at this Transform's affine matrix."""
|
|
return self.get_affine().get_matrix()
|
|
|
|
def transform(self, values):
|
|
"""
|
|
Apply this transformation on the given array of *values*.
|
|
|
|
Parameters
|
|
----------
|
|
values : array-like
|
|
The input values as an array of length :attr:`input_dims` or
|
|
shape (N, :attr:`input_dims`).
|
|
|
|
Returns
|
|
-------
|
|
array
|
|
The output values as an array of length :attr:`output_dims` or
|
|
shape (N, :attr:`output_dims`), depending on the input.
|
|
"""
|
|
# Ensure that values is a 2d array (but remember whether
|
|
# we started with a 1d or 2d array).
|
|
values = np.asanyarray(values)
|
|
ndim = values.ndim
|
|
values = values.reshape((-1, self.input_dims))
|
|
|
|
# Transform the values
|
|
res = self.transform_affine(self.transform_non_affine(values))
|
|
|
|
# Convert the result back to the shape of the input values.
|
|
if ndim == 0:
|
|
assert not np.ma.is_masked(res) # just to be on the safe side
|
|
return res[0, 0]
|
|
if ndim == 1:
|
|
return res.reshape(-1)
|
|
elif ndim == 2:
|
|
return res
|
|
raise ValueError(
|
|
"Input values must have shape (N, {dims}) or ({dims},)"
|
|
.format(dims=self.input_dims))
|
|
|
|
def transform_affine(self, values):
|
|
"""
|
|
Apply only the affine part of this transformation on the
|
|
given array of values.
|
|
|
|
``transform(values)`` is always equivalent to
|
|
``transform_affine(transform_non_affine(values))``.
|
|
|
|
In non-affine transformations, this is generally a no-op. In
|
|
affine transformations, this is equivalent to
|
|
``transform(values)``.
|
|
|
|
Parameters
|
|
----------
|
|
values : array
|
|
The input values as an array of length :attr:`input_dims` or
|
|
shape (N, :attr:`input_dims`).
|
|
|
|
Returns
|
|
-------
|
|
array
|
|
The output values as an array of length :attr:`output_dims` or
|
|
shape (N, :attr:`output_dims`), depending on the input.
|
|
"""
|
|
return self.get_affine().transform(values)
|
|
|
|
def transform_non_affine(self, values):
|
|
"""
|
|
Apply only the non-affine part of this transformation.
|
|
|
|
``transform(values)`` is always equivalent to
|
|
``transform_affine(transform_non_affine(values))``.
|
|
|
|
In non-affine transformations, this is generally equivalent to
|
|
``transform(values)``. In affine transformations, this is
|
|
always a no-op.
|
|
|
|
Parameters
|
|
----------
|
|
values : array
|
|
The input values as an array of length :attr:`input_dims` or
|
|
shape (N, :attr:`input_dims`).
|
|
|
|
Returns
|
|
-------
|
|
array
|
|
The output values as an array of length :attr:`output_dims` or
|
|
shape (N, :attr:`output_dims`), depending on the input.
|
|
"""
|
|
return values
|
|
|
|
def transform_bbox(self, bbox):
|
|
"""
|
|
Transform the given bounding box.
|
|
|
|
For smarter transforms including caching (a common requirement in
|
|
Matplotlib), see `TransformedBbox`.
|
|
"""
|
|
return Bbox(self.transform(bbox.get_points()))
|
|
|
|
def get_affine(self):
|
|
"""Get the affine part of this transform."""
|
|
return IdentityTransform()
|
|
|
|
def get_matrix(self):
|
|
"""Get the matrix for the affine part of this transform."""
|
|
return self.get_affine().get_matrix()
|
|
|
|
def transform_point(self, point):
|
|
"""
|
|
Return a transformed point.
|
|
|
|
This function is only kept for backcompatibility; the more general
|
|
`.transform` method is capable of transforming both a list of points
|
|
and a single point.
|
|
|
|
The point is given as a sequence of length :attr:`input_dims`.
|
|
The transformed point is returned as a sequence of length
|
|
:attr:`output_dims`.
|
|
"""
|
|
if len(point) != self.input_dims:
|
|
raise ValueError("The length of 'point' must be 'self.input_dims'")
|
|
return self.transform(point)
|
|
|
|
def transform_path(self, path):
|
|
"""
|
|
Apply the transform to `.Path` *path*, returning a new `.Path`.
|
|
|
|
In some cases, this transform may insert curves into the path
|
|
that began as line segments.
|
|
"""
|
|
return self.transform_path_affine(self.transform_path_non_affine(path))
|
|
|
|
def transform_path_affine(self, path):
|
|
"""
|
|
Apply the affine part of this transform to `.Path` *path*, returning a
|
|
new `.Path`.
|
|
|
|
``transform_path(path)`` is equivalent to
|
|
``transform_path_affine(transform_path_non_affine(values))``.
|
|
"""
|
|
return self.get_affine().transform_path_affine(path)
|
|
|
|
def transform_path_non_affine(self, path):
|
|
"""
|
|
Apply the non-affine part of this transform to `.Path` *path*,
|
|
returning a new `.Path`.
|
|
|
|
``transform_path(path)`` is equivalent to
|
|
``transform_path_affine(transform_path_non_affine(values))``.
|
|
"""
|
|
x = self.transform_non_affine(path.vertices)
|
|
return Path._fast_from_codes_and_verts(x, path.codes, path)
|
|
|
|
def transform_angles(self, angles, pts, radians=False, pushoff=1e-5):
|
|
"""
|
|
Transform a set of angles anchored at specific locations.
|
|
|
|
Parameters
|
|
----------
|
|
angles : (N,) array-like
|
|
The angles to transform.
|
|
pts : (N, 2) array-like
|
|
The points where the angles are anchored.
|
|
radians : bool, default: False
|
|
Whether *angles* are radians or degrees.
|
|
pushoff : float
|
|
For each point in *pts* and angle in *angles*, the transformed
|
|
angle is computed by transforming a segment of length *pushoff*
|
|
starting at that point and making that angle relative to the
|
|
horizontal axis, and measuring the angle between the horizontal
|
|
axis and the transformed segment.
|
|
|
|
Returns
|
|
-------
|
|
(N,) array
|
|
"""
|
|
# Must be 2D
|
|
if self.input_dims != 2 or self.output_dims != 2:
|
|
raise NotImplementedError('Only defined in 2D')
|
|
angles = np.asarray(angles)
|
|
pts = np.asarray(pts)
|
|
_api.check_shape((None, 2), pts=pts)
|
|
_api.check_shape((None,), angles=angles)
|
|
if len(angles) != len(pts):
|
|
raise ValueError("There must be as many 'angles' as 'pts'")
|
|
# Convert to radians if desired
|
|
if not radians:
|
|
angles = np.deg2rad(angles)
|
|
# Move a short distance away
|
|
pts2 = pts + pushoff * np.column_stack([np.cos(angles),
|
|
np.sin(angles)])
|
|
# Transform both sets of points
|
|
tpts = self.transform(pts)
|
|
tpts2 = self.transform(pts2)
|
|
# Calculate transformed angles
|
|
d = tpts2 - tpts
|
|
a = np.arctan2(d[:, 1], d[:, 0])
|
|
# Convert back to degrees if desired
|
|
if not radians:
|
|
a = np.rad2deg(a)
|
|
return a
|
|
|
|
def inverted(self):
|
|
"""
|
|
Return the corresponding inverse transformation.
|
|
|
|
It holds ``x == self.inverted().transform(self.transform(x))``.
|
|
|
|
The return value of this method should be treated as
|
|
temporary. An update to *self* does not cause a corresponding
|
|
update to its inverted copy.
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
|
|
class TransformWrapper(Transform):
|
|
"""
|
|
A helper class that holds a single child transform and acts
|
|
equivalently to it.
|
|
|
|
This is useful if a node of the transform tree must be replaced at
|
|
run time with a transform of a different type. This class allows
|
|
that replacement to correctly trigger invalidation.
|
|
|
|
`TransformWrapper` instances must have the same input and output dimensions
|
|
during their entire lifetime, so the child transform may only be replaced
|
|
with another child transform of the same dimensions.
|
|
"""
|
|
|
|
pass_through = True
|
|
|
|
def __init__(self, child):
|
|
"""
|
|
*child*: A `Transform` instance. This child may later
|
|
be replaced with :meth:`set`.
|
|
"""
|
|
_api.check_isinstance(Transform, child=child)
|
|
super().__init__()
|
|
self.set(child)
|
|
|
|
def __eq__(self, other):
|
|
return self._child.__eq__(other)
|
|
|
|
__str__ = _make_str_method("_child")
|
|
|
|
def frozen(self):
|
|
# docstring inherited
|
|
return self._child.frozen()
|
|
|
|
def set(self, child):
|
|
"""
|
|
Replace the current child of this transform with another one.
|
|
|
|
The new child must have the same number of input and output
|
|
dimensions as the current child.
|
|
"""
|
|
if hasattr(self, "_child"): # Absent during init.
|
|
self.invalidate()
|
|
new_dims = (child.input_dims, child.output_dims)
|
|
old_dims = (self._child.input_dims, self._child.output_dims)
|
|
if new_dims != old_dims:
|
|
raise ValueError(
|
|
f"The input and output dims of the new child {new_dims} "
|
|
f"do not match those of current child {old_dims}")
|
|
self._child._parents.pop(id(self), None)
|
|
|
|
self._child = child
|
|
self.set_children(child)
|
|
|
|
self.transform = child.transform
|
|
self.transform_affine = child.transform_affine
|
|
self.transform_non_affine = child.transform_non_affine
|
|
self.transform_path = child.transform_path
|
|
self.transform_path_affine = child.transform_path_affine
|
|
self.transform_path_non_affine = child.transform_path_non_affine
|
|
self.get_affine = child.get_affine
|
|
self.inverted = child.inverted
|
|
self.get_matrix = child.get_matrix
|
|
# note we do not wrap other properties here since the transform's
|
|
# child can be changed with WrappedTransform.set and so checking
|
|
# is_affine and other such properties may be dangerous.
|
|
|
|
self._invalid = 0
|
|
self.invalidate()
|
|
self._invalid = 0
|
|
|
|
input_dims = property(lambda self: self._child.input_dims)
|
|
output_dims = property(lambda self: self._child.output_dims)
|
|
is_affine = property(lambda self: self._child.is_affine)
|
|
is_separable = property(lambda self: self._child.is_separable)
|
|
has_inverse = property(lambda self: self._child.has_inverse)
|
|
|
|
|
|
class AffineBase(Transform):
|
|
"""
|
|
The base class of all affine transformations of any number of dimensions.
|
|
"""
|
|
is_affine = True
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self._inverted = None
|
|
|
|
def __array__(self, *args, **kwargs):
|
|
# optimises the access of the transform matrix vs. the superclass
|
|
return self.get_matrix()
|
|
|
|
def __eq__(self, other):
|
|
if getattr(other, "is_affine", False) and hasattr(other, "get_matrix"):
|
|
return (self.get_matrix() == other.get_matrix()).all()
|
|
return NotImplemented
|
|
|
|
def transform(self, values):
|
|
# docstring inherited
|
|
return self.transform_affine(values)
|
|
|
|
def transform_affine(self, values):
|
|
# docstring inherited
|
|
raise NotImplementedError('Affine subclasses should override this '
|
|
'method.')
|
|
|
|
@_api.rename_parameter("3.8", "points", "values")
|
|
def transform_non_affine(self, values):
|
|
# docstring inherited
|
|
return values
|
|
|
|
def transform_path(self, path):
|
|
# docstring inherited
|
|
return self.transform_path_affine(path)
|
|
|
|
def transform_path_affine(self, path):
|
|
# docstring inherited
|
|
return Path(self.transform_affine(path.vertices),
|
|
path.codes, path._interpolation_steps)
|
|
|
|
def transform_path_non_affine(self, path):
|
|
# docstring inherited
|
|
return path
|
|
|
|
def get_affine(self):
|
|
# docstring inherited
|
|
return self
|
|
|
|
|
|
class Affine2DBase(AffineBase):
|
|
"""
|
|
The base class of all 2D affine transformations.
|
|
|
|
2D affine transformations are performed using a 3x3 numpy array::
|
|
|
|
a c e
|
|
b d f
|
|
0 0 1
|
|
|
|
This class provides the read-only interface. For a mutable 2D
|
|
affine transformation, use `Affine2D`.
|
|
|
|
Subclasses of this class will generally only need to override a
|
|
constructor and `~.Transform.get_matrix` that generates a custom 3x3 matrix.
|
|
"""
|
|
input_dims = 2
|
|
output_dims = 2
|
|
|
|
def frozen(self):
|
|
# docstring inherited
|
|
return Affine2D(self.get_matrix().copy())
|
|
|
|
@property
|
|
def is_separable(self):
|
|
mtx = self.get_matrix()
|
|
return mtx[0, 1] == mtx[1, 0] == 0.0
|
|
|
|
def to_values(self):
|
|
"""
|
|
Return the values of the matrix as an ``(a, b, c, d, e, f)`` tuple.
|
|
"""
|
|
mtx = self.get_matrix()
|
|
return tuple(mtx[:2].swapaxes(0, 1).flat)
|
|
|
|
@_api.rename_parameter("3.8", "points", "values")
|
|
def transform_affine(self, values):
|
|
mtx = self.get_matrix()
|
|
if isinstance(values, np.ma.MaskedArray):
|
|
tpoints = affine_transform(values.data, mtx)
|
|
return np.ma.MaskedArray(tpoints, mask=np.ma.getmask(values))
|
|
return affine_transform(values, mtx)
|
|
|
|
if DEBUG:
|
|
_transform_affine = transform_affine
|
|
|
|
@_api.rename_parameter("3.8", "points", "values")
|
|
def transform_affine(self, values):
|
|
# docstring inherited
|
|
# The major speed trap here is just converting to the
|
|
# points to an array in the first place. If we can use
|
|
# more arrays upstream, that should help here.
|
|
if not isinstance(values, np.ndarray):
|
|
_api.warn_external(
|
|
f'A non-numpy array of type {type(values)} was passed in '
|
|
f'for transformation, which results in poor performance.')
|
|
return self._transform_affine(values)
|
|
|
|
def inverted(self):
|
|
# docstring inherited
|
|
if self._inverted is None or self._invalid:
|
|
mtx = self.get_matrix()
|
|
shorthand_name = None
|
|
if self._shorthand_name:
|
|
shorthand_name = '(%s)-1' % self._shorthand_name
|
|
self._inverted = Affine2D(inv(mtx), shorthand_name=shorthand_name)
|
|
self._invalid = 0
|
|
return self._inverted
|
|
|
|
|
|
class Affine2D(Affine2DBase):
|
|
"""
|
|
A mutable 2D affine transformation.
|
|
"""
|
|
|
|
def __init__(self, matrix=None, **kwargs):
|
|
"""
|
|
Initialize an Affine transform from a 3x3 numpy float array::
|
|
|
|
a c e
|
|
b d f
|
|
0 0 1
|
|
|
|
If *matrix* is None, initialize with the identity transform.
|
|
"""
|
|
super().__init__(**kwargs)
|
|
if matrix is None:
|
|
# A bit faster than np.identity(3).
|
|
matrix = IdentityTransform._mtx
|
|
self._mtx = matrix.copy()
|
|
self._invalid = 0
|
|
|
|
_base_str = _make_str_method("_mtx")
|
|
|
|
def __str__(self):
|
|
return (self._base_str()
|
|
if (self._mtx != np.diag(np.diag(self._mtx))).any()
|
|
else f"Affine2D().scale({self._mtx[0, 0]}, {self._mtx[1, 1]})"
|
|
if self._mtx[0, 0] != self._mtx[1, 1]
|
|
else f"Affine2D().scale({self._mtx[0, 0]})")
|
|
|
|
@staticmethod
|
|
def from_values(a, b, c, d, e, f):
|
|
"""
|
|
Create a new Affine2D instance from the given values::
|
|
|
|
a c e
|
|
b d f
|
|
0 0 1
|
|
|
|
.
|
|
"""
|
|
return Affine2D(
|
|
np.array([a, c, e, b, d, f, 0.0, 0.0, 1.0], float).reshape((3, 3)))
|
|
|
|
def get_matrix(self):
|
|
"""
|
|
Get the underlying transformation matrix as a 3x3 array::
|
|
|
|
a c e
|
|
b d f
|
|
0 0 1
|
|
|
|
.
|
|
"""
|
|
if self._invalid:
|
|
self._inverted = None
|
|
self._invalid = 0
|
|
return self._mtx
|
|
|
|
def set_matrix(self, mtx):
|
|
"""
|
|
Set the underlying transformation matrix from a 3x3 array::
|
|
|
|
a c e
|
|
b d f
|
|
0 0 1
|
|
|
|
.
|
|
"""
|
|
self._mtx = mtx
|
|
self.invalidate()
|
|
|
|
def set(self, other):
|
|
"""
|
|
Set this transformation from the frozen copy of another
|
|
`Affine2DBase` object.
|
|
"""
|
|
_api.check_isinstance(Affine2DBase, other=other)
|
|
self._mtx = other.get_matrix()
|
|
self.invalidate()
|
|
|
|
def clear(self):
|
|
"""
|
|
Reset the underlying matrix to the identity transform.
|
|
"""
|
|
# A bit faster than np.identity(3).
|
|
self._mtx = IdentityTransform._mtx.copy()
|
|
self.invalidate()
|
|
return self
|
|
|
|
def rotate(self, theta):
|
|
"""
|
|
Add a rotation (in radians) to this transform in place.
|
|
|
|
Returns *self*, so this method can easily be chained with more
|
|
calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate`
|
|
and :meth:`scale`.
|
|
"""
|
|
a = math.cos(theta)
|
|
b = math.sin(theta)
|
|
mtx = self._mtx
|
|
# Operating and assigning one scalar at a time is much faster.
|
|
(xx, xy, x0), (yx, yy, y0), _ = mtx.tolist()
|
|
# mtx = [[a -b 0], [b a 0], [0 0 1]] * mtx
|
|
mtx[0, 0] = a * xx - b * yx
|
|
mtx[0, 1] = a * xy - b * yy
|
|
mtx[0, 2] = a * x0 - b * y0
|
|
mtx[1, 0] = b * xx + a * yx
|
|
mtx[1, 1] = b * xy + a * yy
|
|
mtx[1, 2] = b * x0 + a * y0
|
|
self.invalidate()
|
|
return self
|
|
|
|
def rotate_deg(self, degrees):
|
|
"""
|
|
Add a rotation (in degrees) to this transform in place.
|
|
|
|
Returns *self*, so this method can easily be chained with more
|
|
calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate`
|
|
and :meth:`scale`.
|
|
"""
|
|
return self.rotate(math.radians(degrees))
|
|
|
|
def rotate_around(self, x, y, theta):
|
|
"""
|
|
Add a rotation (in radians) around the point (x, y) in place.
|
|
|
|
Returns *self*, so this method can easily be chained with more
|
|
calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate`
|
|
and :meth:`scale`.
|
|
"""
|
|
return self.translate(-x, -y).rotate(theta).translate(x, y)
|
|
|
|
def rotate_deg_around(self, x, y, degrees):
|
|
"""
|
|
Add a rotation (in degrees) around the point (x, y) in place.
|
|
|
|
Returns *self*, so this method can easily be chained with more
|
|
calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate`
|
|
and :meth:`scale`.
|
|
"""
|
|
# Cast to float to avoid wraparound issues with uint8's
|
|
x, y = float(x), float(y)
|
|
return self.translate(-x, -y).rotate_deg(degrees).translate(x, y)
|
|
|
|
def translate(self, tx, ty):
|
|
"""
|
|
Add a translation in place.
|
|
|
|
Returns *self*, so this method can easily be chained with more
|
|
calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate`
|
|
and :meth:`scale`.
|
|
"""
|
|
self._mtx[0, 2] += tx
|
|
self._mtx[1, 2] += ty
|
|
self.invalidate()
|
|
return self
|
|
|
|
def scale(self, sx, sy=None):
|
|
"""
|
|
Add a scale in place.
|
|
|
|
If *sy* is None, the same scale is applied in both the *x*- and
|
|
*y*-directions.
|
|
|
|
Returns *self*, so this method can easily be chained with more
|
|
calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate`
|
|
and :meth:`scale`.
|
|
"""
|
|
if sy is None:
|
|
sy = sx
|
|
# explicit element-wise scaling is fastest
|
|
self._mtx[0, 0] *= sx
|
|
self._mtx[0, 1] *= sx
|
|
self._mtx[0, 2] *= sx
|
|
self._mtx[1, 0] *= sy
|
|
self._mtx[1, 1] *= sy
|
|
self._mtx[1, 2] *= sy
|
|
self.invalidate()
|
|
return self
|
|
|
|
def skew(self, xShear, yShear):
|
|
"""
|
|
Add a skew in place.
|
|
|
|
*xShear* and *yShear* are the shear angles along the *x*- and
|
|
*y*-axes, respectively, in radians.
|
|
|
|
Returns *self*, so this method can easily be chained with more
|
|
calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate`
|
|
and :meth:`scale`.
|
|
"""
|
|
rx = math.tan(xShear)
|
|
ry = math.tan(yShear)
|
|
mtx = self._mtx
|
|
# Operating and assigning one scalar at a time is much faster.
|
|
(xx, xy, x0), (yx, yy, y0), _ = mtx.tolist()
|
|
# mtx = [[1 rx 0], [ry 1 0], [0 0 1]] * mtx
|
|
mtx[0, 0] += rx * yx
|
|
mtx[0, 1] += rx * yy
|
|
mtx[0, 2] += rx * y0
|
|
mtx[1, 0] += ry * xx
|
|
mtx[1, 1] += ry * xy
|
|
mtx[1, 2] += ry * x0
|
|
self.invalidate()
|
|
return self
|
|
|
|
def skew_deg(self, xShear, yShear):
|
|
"""
|
|
Add a skew in place.
|
|
|
|
*xShear* and *yShear* are the shear angles along the *x*- and
|
|
*y*-axes, respectively, in degrees.
|
|
|
|
Returns *self*, so this method can easily be chained with more
|
|
calls to :meth:`rotate`, :meth:`rotate_deg`, :meth:`translate`
|
|
and :meth:`scale`.
|
|
"""
|
|
return self.skew(math.radians(xShear), math.radians(yShear))
|
|
|
|
|
|
class IdentityTransform(Affine2DBase):
|
|
"""
|
|
A special class that does one thing, the identity transform, in a
|
|
fast way.
|
|
"""
|
|
_mtx = np.identity(3)
|
|
|
|
def frozen(self):
|
|
# docstring inherited
|
|
return self
|
|
|
|
__str__ = _make_str_method()
|
|
|
|
def get_matrix(self):
|
|
# docstring inherited
|
|
return self._mtx
|
|
|
|
@_api.rename_parameter("3.8", "points", "values")
|
|
def transform(self, values):
|
|
# docstring inherited
|
|
return np.asanyarray(values)
|
|
|
|
@_api.rename_parameter("3.8", "points", "values")
|
|
def transform_affine(self, values):
|
|
# docstring inherited
|
|
return np.asanyarray(values)
|
|
|
|
@_api.rename_parameter("3.8", "points", "values")
|
|
def transform_non_affine(self, values):
|
|
# docstring inherited
|
|
return np.asanyarray(values)
|
|
|
|
def transform_path(self, path):
|
|
# docstring inherited
|
|
return path
|
|
|
|
def transform_path_affine(self, path):
|
|
# docstring inherited
|
|
return path
|
|
|
|
def transform_path_non_affine(self, path):
|
|
# docstring inherited
|
|
return path
|
|
|
|
def get_affine(self):
|
|
# docstring inherited
|
|
return self
|
|
|
|
def inverted(self):
|
|
# docstring inherited
|
|
return self
|
|
|
|
|
|
class _BlendedMixin:
|
|
"""Common methods for `BlendedGenericTransform` and `BlendedAffine2D`."""
|
|
|
|
def __eq__(self, other):
|
|
if isinstance(other, (BlendedAffine2D, BlendedGenericTransform)):
|
|
return (self._x == other._x) and (self._y == other._y)
|
|
elif self._x == self._y:
|
|
return self._x == other
|
|
else:
|
|
return NotImplemented
|
|
|
|
def contains_branch_seperately(self, transform):
|
|
return (self._x.contains_branch(transform),
|
|
self._y.contains_branch(transform))
|
|
|
|
__str__ = _make_str_method("_x", "_y")
|
|
|
|
|
|
class BlendedGenericTransform(_BlendedMixin, Transform):
|
|
"""
|
|
A "blended" transform uses one transform for the *x*-direction, and
|
|
another transform for the *y*-direction.
|
|
|
|
This "generic" version can handle any given child transform in the
|
|
*x*- and *y*-directions.
|
|
"""
|
|
input_dims = 2
|
|
output_dims = 2
|
|
is_separable = True
|
|
pass_through = True
|
|
|
|
def __init__(self, x_transform, y_transform, **kwargs):
|
|
"""
|
|
Create a new "blended" transform using *x_transform* to transform the
|
|
*x*-axis and *y_transform* to transform the *y*-axis.
|
|
|
|
You will generally not call this constructor directly but use the
|
|
`blended_transform_factory` function instead, which can determine
|
|
automatically which kind of blended transform to create.
|
|
"""
|
|
Transform.__init__(self, **kwargs)
|
|
self._x = x_transform
|
|
self._y = y_transform
|
|
self.set_children(x_transform, y_transform)
|
|
self._affine = None
|
|
|
|
@property
|
|
def depth(self):
|
|
return max(self._x.depth, self._y.depth)
|
|
|
|
def contains_branch(self, other):
|
|
# A blended transform cannot possibly contain a branch from two
|
|
# different transforms.
|
|
return False
|
|
|
|
is_affine = property(lambda self: self._x.is_affine and self._y.is_affine)
|
|
has_inverse = property(
|
|
lambda self: self._x.has_inverse and self._y.has_inverse)
|
|
|
|
def frozen(self):
|
|
# docstring inherited
|
|
return blended_transform_factory(self._x.frozen(), self._y.frozen())
|
|
|
|
@_api.rename_parameter("3.8", "points", "values")
|
|
def transform_non_affine(self, values):
|
|
# docstring inherited
|
|
if self._x.is_affine and self._y.is_affine:
|
|
return values
|
|
x = self._x
|
|
y = self._y
|
|
|
|
if x == y and x.input_dims == 2:
|
|
return x.transform_non_affine(values)
|
|
|
|
if x.input_dims == 2:
|
|
x_points = x.transform_non_affine(values)[:, 0:1]
|
|
else:
|
|
x_points = x.transform_non_affine(values[:, 0])
|
|
x_points = x_points.reshape((len(x_points), 1))
|
|
|
|
if y.input_dims == 2:
|
|
y_points = y.transform_non_affine(values)[:, 1:]
|
|
else:
|
|
y_points = y.transform_non_affine(values[:, 1])
|
|
y_points = y_points.reshape((len(y_points), 1))
|
|
|
|
if (isinstance(x_points, np.ma.MaskedArray) or
|
|
isinstance(y_points, np.ma.MaskedArray)):
|
|
return np.ma.concatenate((x_points, y_points), 1)
|
|
else:
|
|
return np.concatenate((x_points, y_points), 1)
|
|
|
|
def inverted(self):
|
|
# docstring inherited
|
|
return BlendedGenericTransform(self._x.inverted(), self._y.inverted())
|
|
|
|
def get_affine(self):
|
|
# docstring inherited
|
|
if self._invalid or self._affine is None:
|
|
if self._x == self._y:
|
|
self._affine = self._x.get_affine()
|
|
else:
|
|
x_mtx = self._x.get_affine().get_matrix()
|
|
y_mtx = self._y.get_affine().get_matrix()
|
|
# We already know the transforms are separable, so we can skip
|
|
# setting b and c to zero.
|
|
mtx = np.array([x_mtx[0], y_mtx[1], [0.0, 0.0, 1.0]])
|
|
self._affine = Affine2D(mtx)
|
|
self._invalid = 0
|
|
return self._affine
|
|
|
|
|
|
class BlendedAffine2D(_BlendedMixin, Affine2DBase):
|
|
"""
|
|
A "blended" transform uses one transform for the *x*-direction, and
|
|
another transform for the *y*-direction.
|
|
|
|
This version is an optimization for the case where both child
|
|
transforms are of type `Affine2DBase`.
|
|
"""
|
|
|
|
is_separable = True
|
|
|
|
def __init__(self, x_transform, y_transform, **kwargs):
|
|
"""
|
|
Create a new "blended" transform using *x_transform* to transform the
|
|
*x*-axis and *y_transform* to transform the *y*-axis.
|
|
|
|
Both *x_transform* and *y_transform* must be 2D affine transforms.
|
|
|
|
You will generally not call this constructor directly but use the
|
|
`blended_transform_factory` function instead, which can determine
|
|
automatically which kind of blended transform to create.
|
|
"""
|
|
is_affine = x_transform.is_affine and y_transform.is_affine
|
|
is_separable = x_transform.is_separable and y_transform.is_separable
|
|
is_correct = is_affine and is_separable
|
|
if not is_correct:
|
|
raise ValueError("Both *x_transform* and *y_transform* must be 2D "
|
|
"affine transforms")
|
|
|
|
Transform.__init__(self, **kwargs)
|
|
self._x = x_transform
|
|
self._y = y_transform
|
|
self.set_children(x_transform, y_transform)
|
|
|
|
Affine2DBase.__init__(self)
|
|
self._mtx = None
|
|
|
|
def get_matrix(self):
|
|
# docstring inherited
|
|
if self._invalid:
|
|
if self._x == self._y:
|
|
self._mtx = self._x.get_matrix()
|
|
else:
|
|
x_mtx = self._x.get_matrix()
|
|
y_mtx = self._y.get_matrix()
|
|
# We already know the transforms are separable, so we can skip
|
|
# setting b and c to zero.
|
|
self._mtx = np.array([x_mtx[0], y_mtx[1], [0.0, 0.0, 1.0]])
|
|
self._inverted = None
|
|
self._invalid = 0
|
|
return self._mtx
|
|
|
|
|
|
def blended_transform_factory(x_transform, y_transform):
|
|
"""
|
|
Create a new "blended" transform using *x_transform* to transform
|
|
the *x*-axis and *y_transform* to transform the *y*-axis.
|
|
|
|
A faster version of the blended transform is returned for the case
|
|
where both child transforms are affine.
|
|
"""
|
|
if (isinstance(x_transform, Affine2DBase) and
|
|
isinstance(y_transform, Affine2DBase)):
|
|
return BlendedAffine2D(x_transform, y_transform)
|
|
return BlendedGenericTransform(x_transform, y_transform)
|
|
|
|
|
|
class CompositeGenericTransform(Transform):
|
|
"""
|
|
A composite transform formed by applying transform *a* then
|
|
transform *b*.
|
|
|
|
This "generic" version can handle any two arbitrary
|
|
transformations.
|
|
"""
|
|
pass_through = True
|
|
|
|
def __init__(self, a, b, **kwargs):
|
|
"""
|
|
Create a new composite transform that is the result of
|
|
applying transform *a* then transform *b*.
|
|
|
|
You will generally not call this constructor directly but write ``a +
|
|
b`` instead, which will automatically choose the best kind of composite
|
|
transform instance to create.
|
|
"""
|
|
if a.output_dims != b.input_dims:
|
|
raise ValueError("The output dimension of 'a' must be equal to "
|
|
"the input dimensions of 'b'")
|
|
self.input_dims = a.input_dims
|
|
self.output_dims = b.output_dims
|
|
|
|
super().__init__(**kwargs)
|
|
self._a = a
|
|
self._b = b
|
|
self.set_children(a, b)
|
|
|
|
def frozen(self):
|
|
# docstring inherited
|
|
self._invalid = 0
|
|
frozen = composite_transform_factory(
|
|
self._a.frozen(), self._b.frozen())
|
|
if not isinstance(frozen, CompositeGenericTransform):
|
|
return frozen.frozen()
|
|
return frozen
|
|
|
|
def _invalidate_internal(self, level, invalidating_node):
|
|
# When the left child is invalidated at AFFINE_ONLY level and the right child is
|
|
# non-affine, the composite transform is FULLY invalidated.
|
|
if invalidating_node is self._a and not self._b.is_affine:
|
|
level = Transform._INVALID_FULL
|
|
super()._invalidate_internal(level, invalidating_node)
|
|
|
|
def __eq__(self, other):
|
|
if isinstance(other, (CompositeGenericTransform, CompositeAffine2D)):
|
|
return self is other or (self._a == other._a
|
|
and self._b == other._b)
|
|
else:
|
|
return False
|
|
|
|
def _iter_break_from_left_to_right(self):
|
|
for left, right in self._a._iter_break_from_left_to_right():
|
|
yield left, right + self._b
|
|
for left, right in self._b._iter_break_from_left_to_right():
|
|
yield self._a + left, right
|
|
|
|
depth = property(lambda self: self._a.depth + self._b.depth)
|
|
is_affine = property(lambda self: self._a.is_affine and self._b.is_affine)
|
|
is_separable = property(
|
|
lambda self: self._a.is_separable and self._b.is_separable)
|
|
has_inverse = property(
|
|
lambda self: self._a.has_inverse and self._b.has_inverse)
|
|
|
|
__str__ = _make_str_method("_a", "_b")
|
|
|
|
@_api.rename_parameter("3.8", "points", "values")
|
|
def transform_affine(self, values):
|
|
# docstring inherited
|
|
return self.get_affine().transform(values)
|
|
|
|
@_api.rename_parameter("3.8", "points", "values")
|
|
def transform_non_affine(self, values):
|
|
# docstring inherited
|
|
if self._a.is_affine and self._b.is_affine:
|
|
return values
|
|
elif not self._a.is_affine and self._b.is_affine:
|
|
return self._a.transform_non_affine(values)
|
|
else:
|
|
return self._b.transform_non_affine(self._a.transform(values))
|
|
|
|
def transform_path_non_affine(self, path):
|
|
# docstring inherited
|
|
if self._a.is_affine and self._b.is_affine:
|
|
return path
|
|
elif not self._a.is_affine and self._b.is_affine:
|
|
return self._a.transform_path_non_affine(path)
|
|
else:
|
|
return self._b.transform_path_non_affine(
|
|
self._a.transform_path(path))
|
|
|
|
def get_affine(self):
|
|
# docstring inherited
|
|
if not self._b.is_affine:
|
|
return self._b.get_affine()
|
|
else:
|
|
return Affine2D(np.dot(self._b.get_affine().get_matrix(),
|
|
self._a.get_affine().get_matrix()))
|
|
|
|
def inverted(self):
|
|
# docstring inherited
|
|
return CompositeGenericTransform(
|
|
self._b.inverted(), self._a.inverted())
|
|
|
|
|
|
class CompositeAffine2D(Affine2DBase):
|
|
"""
|
|
A composite transform formed by applying transform *a* then transform *b*.
|
|
|
|
This version is an optimization that handles the case where both *a*
|
|
and *b* are 2D affines.
|
|
"""
|
|
def __init__(self, a, b, **kwargs):
|
|
"""
|
|
Create a new composite transform that is the result of
|
|
applying `Affine2DBase` *a* then `Affine2DBase` *b*.
|
|
|
|
You will generally not call this constructor directly but write ``a +
|
|
b`` instead, which will automatically choose the best kind of composite
|
|
transform instance to create.
|
|
"""
|
|
if not a.is_affine or not b.is_affine:
|
|
raise ValueError("'a' and 'b' must be affine transforms")
|
|
if a.output_dims != b.input_dims:
|
|
raise ValueError("The output dimension of 'a' must be equal to "
|
|
"the input dimensions of 'b'")
|
|
self.input_dims = a.input_dims
|
|
self.output_dims = b.output_dims
|
|
|
|
super().__init__(**kwargs)
|
|
self._a = a
|
|
self._b = b
|
|
self.set_children(a, b)
|
|
self._mtx = None
|
|
|
|
@property
|
|
def depth(self):
|
|
return self._a.depth + self._b.depth
|
|
|
|
def _iter_break_from_left_to_right(self):
|
|
for left, right in self._a._iter_break_from_left_to_right():
|
|
yield left, right + self._b
|
|
for left, right in self._b._iter_break_from_left_to_right():
|
|
yield self._a + left, right
|
|
|
|
__str__ = _make_str_method("_a", "_b")
|
|
|
|
def get_matrix(self):
|
|
# docstring inherited
|
|
if self._invalid:
|
|
self._mtx = np.dot(
|
|
self._b.get_matrix(),
|
|
self._a.get_matrix())
|
|
self._inverted = None
|
|
self._invalid = 0
|
|
return self._mtx
|
|
|
|
|
|
def composite_transform_factory(a, b):
|
|
"""
|
|
Create a new composite transform that is the result of applying
|
|
transform a then transform b.
|
|
|
|
Shortcut versions of the blended transform are provided for the
|
|
case where both child transforms are affine, or one or the other
|
|
is the identity transform.
|
|
|
|
Composite transforms may also be created using the '+' operator,
|
|
e.g.::
|
|
|
|
c = a + b
|
|
"""
|
|
# check to see if any of a or b are IdentityTransforms. We use
|
|
# isinstance here to guarantee that the transforms will *always*
|
|
# be IdentityTransforms. Since TransformWrappers are mutable,
|
|
# use of equality here would be wrong.
|
|
if isinstance(a, IdentityTransform):
|
|
return b
|
|
elif isinstance(b, IdentityTransform):
|
|
return a
|
|
elif isinstance(a, Affine2D) and isinstance(b, Affine2D):
|
|
return CompositeAffine2D(a, b)
|
|
return CompositeGenericTransform(a, b)
|
|
|
|
|
|
class BboxTransform(Affine2DBase):
|
|
"""
|
|
`BboxTransform` linearly transforms points from one `Bbox` to another.
|
|
"""
|
|
|
|
is_separable = True
|
|
|
|
def __init__(self, boxin, boxout, **kwargs):
|
|
"""
|
|
Create a new `BboxTransform` that linearly transforms
|
|
points from *boxin* to *boxout*.
|
|
"""
|
|
if not boxin.is_bbox or not boxout.is_bbox:
|
|
raise ValueError("'boxin' and 'boxout' must be bbox")
|
|
|
|
super().__init__(**kwargs)
|
|
self._boxin = boxin
|
|
self._boxout = boxout
|
|
self.set_children(boxin, boxout)
|
|
self._mtx = None
|
|
self._inverted = None
|
|
|
|
__str__ = _make_str_method("_boxin", "_boxout")
|
|
|
|
def get_matrix(self):
|
|
# docstring inherited
|
|
if self._invalid:
|
|
inl, inb, inw, inh = self._boxin.bounds
|
|
outl, outb, outw, outh = self._boxout.bounds
|
|
x_scale = outw / inw
|
|
y_scale = outh / inh
|
|
if DEBUG and (x_scale == 0 or y_scale == 0):
|
|
raise ValueError(
|
|
"Transforming from or to a singular bounding box")
|
|
self._mtx = np.array([[x_scale, 0.0 , (-inl*x_scale+outl)],
|
|
[0.0 , y_scale, (-inb*y_scale+outb)],
|
|
[0.0 , 0.0 , 1.0 ]],
|
|
float)
|
|
self._inverted = None
|
|
self._invalid = 0
|
|
return self._mtx
|
|
|
|
|
|
class BboxTransformTo(Affine2DBase):
|
|
"""
|
|
`BboxTransformTo` is a transformation that linearly transforms points from
|
|
the unit bounding box to a given `Bbox`.
|
|
"""
|
|
|
|
is_separable = True
|
|
|
|
def __init__(self, boxout, **kwargs):
|
|
"""
|
|
Create a new `BboxTransformTo` that linearly transforms
|
|
points from the unit bounding box to *boxout*.
|
|
"""
|
|
if not boxout.is_bbox:
|
|
raise ValueError("'boxout' must be bbox")
|
|
|
|
super().__init__(**kwargs)
|
|
self._boxout = boxout
|
|
self.set_children(boxout)
|
|
self._mtx = None
|
|
self._inverted = None
|
|
|
|
__str__ = _make_str_method("_boxout")
|
|
|
|
def get_matrix(self):
|
|
# docstring inherited
|
|
if self._invalid:
|
|
outl, outb, outw, outh = self._boxout.bounds
|
|
if DEBUG and (outw == 0 or outh == 0):
|
|
raise ValueError("Transforming to a singular bounding box.")
|
|
self._mtx = np.array([[outw, 0.0, outl],
|
|
[ 0.0, outh, outb],
|
|
[ 0.0, 0.0, 1.0]],
|
|
float)
|
|
self._inverted = None
|
|
self._invalid = 0
|
|
return self._mtx
|
|
|
|
|
|
class BboxTransformToMaxOnly(BboxTransformTo):
|
|
"""
|
|
`BboxTransformTo` is a transformation that linearly transforms points from
|
|
the unit bounding box to a given `Bbox` with a fixed upper left of (0, 0).
|
|
"""
|
|
def get_matrix(self):
|
|
# docstring inherited
|
|
if self._invalid:
|
|
xmax, ymax = self._boxout.max
|
|
if DEBUG and (xmax == 0 or ymax == 0):
|
|
raise ValueError("Transforming to a singular bounding box.")
|
|
self._mtx = np.array([[xmax, 0.0, 0.0],
|
|
[ 0.0, ymax, 0.0],
|
|
[ 0.0, 0.0, 1.0]],
|
|
float)
|
|
self._inverted = None
|
|
self._invalid = 0
|
|
return self._mtx
|
|
|
|
|
|
class BboxTransformFrom(Affine2DBase):
|
|
"""
|
|
`BboxTransformFrom` linearly transforms points from a given `Bbox` to the
|
|
unit bounding box.
|
|
"""
|
|
is_separable = True
|
|
|
|
def __init__(self, boxin, **kwargs):
|
|
if not boxin.is_bbox:
|
|
raise ValueError("'boxin' must be bbox")
|
|
|
|
super().__init__(**kwargs)
|
|
self._boxin = boxin
|
|
self.set_children(boxin)
|
|
self._mtx = None
|
|
self._inverted = None
|
|
|
|
__str__ = _make_str_method("_boxin")
|
|
|
|
def get_matrix(self):
|
|
# docstring inherited
|
|
if self._invalid:
|
|
inl, inb, inw, inh = self._boxin.bounds
|
|
if DEBUG and (inw == 0 or inh == 0):
|
|
raise ValueError("Transforming from a singular bounding box.")
|
|
x_scale = 1.0 / inw
|
|
y_scale = 1.0 / inh
|
|
self._mtx = np.array([[x_scale, 0.0 , (-inl*x_scale)],
|
|
[0.0 , y_scale, (-inb*y_scale)],
|
|
[0.0 , 0.0 , 1.0 ]],
|
|
float)
|
|
self._inverted = None
|
|
self._invalid = 0
|
|
return self._mtx
|
|
|
|
|
|
class ScaledTranslation(Affine2DBase):
|
|
"""
|
|
A transformation that translates by *xt* and *yt*, after *xt* and *yt*
|
|
have been transformed by *scale_trans*.
|
|
"""
|
|
def __init__(self, xt, yt, scale_trans, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self._t = (xt, yt)
|
|
self._scale_trans = scale_trans
|
|
self.set_children(scale_trans)
|
|
self._mtx = None
|
|
self._inverted = None
|
|
|
|
__str__ = _make_str_method("_t")
|
|
|
|
def get_matrix(self):
|
|
# docstring inherited
|
|
if self._invalid:
|
|
# A bit faster than np.identity(3).
|
|
self._mtx = IdentityTransform._mtx.copy()
|
|
self._mtx[:2, 2] = self._scale_trans.transform(self._t)
|
|
self._invalid = 0
|
|
self._inverted = None
|
|
return self._mtx
|
|
|
|
|
|
class AffineDeltaTransform(Affine2DBase):
|
|
r"""
|
|
A transform wrapper for transforming displacements between pairs of points.
|
|
|
|
This class is intended to be used to transform displacements ("position
|
|
deltas") between pairs of points (e.g., as the ``offset_transform``
|
|
of `.Collection`\s): given a transform ``t`` such that ``t =
|
|
AffineDeltaTransform(t) + offset``, ``AffineDeltaTransform``
|
|
satisfies ``AffineDeltaTransform(a - b) == AffineDeltaTransform(a) -
|
|
AffineDeltaTransform(b)``.
|
|
|
|
This is implemented by forcing the offset components of the transform
|
|
matrix to zero.
|
|
|
|
This class is experimental as of 3.3, and the API may change.
|
|
"""
|
|
|
|
def __init__(self, transform, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self._base_transform = transform
|
|
|
|
__str__ = _make_str_method("_base_transform")
|
|
|
|
def get_matrix(self):
|
|
if self._invalid:
|
|
self._mtx = self._base_transform.get_matrix().copy()
|
|
self._mtx[:2, -1] = 0
|
|
return self._mtx
|
|
|
|
|
|
class TransformedPath(TransformNode):
|
|
"""
|
|
A `TransformedPath` caches a non-affine transformed copy of the
|
|
`~.path.Path`. This cached copy is automatically updated when the
|
|
non-affine part of the transform changes.
|
|
|
|
.. note::
|
|
|
|
Paths are considered immutable by this class. Any update to the
|
|
path's vertices/codes will not trigger a transform recomputation.
|
|
|
|
"""
|
|
def __init__(self, path, transform):
|
|
"""
|
|
Parameters
|
|
----------
|
|
path : `~.path.Path`
|
|
transform : `Transform`
|
|
"""
|
|
_api.check_isinstance(Transform, transform=transform)
|
|
super().__init__()
|
|
self._path = path
|
|
self._transform = transform
|
|
self.set_children(transform)
|
|
self._transformed_path = None
|
|
self._transformed_points = None
|
|
|
|
def _revalidate(self):
|
|
# only recompute if the invalidation includes the non_affine part of
|
|
# the transform
|
|
if (self._invalid == self._INVALID_FULL
|
|
or self._transformed_path is None):
|
|
self._transformed_path = \
|
|
self._transform.transform_path_non_affine(self._path)
|
|
self._transformed_points = \
|
|
Path._fast_from_codes_and_verts(
|
|
self._transform.transform_non_affine(self._path.vertices),
|
|
None, self._path)
|
|
self._invalid = 0
|
|
|
|
def get_transformed_points_and_affine(self):
|
|
"""
|
|
Return a copy of the child path, with the non-affine part of
|
|
the transform already applied, along with the affine part of
|
|
the path necessary to complete the transformation. Unlike
|
|
:meth:`get_transformed_path_and_affine`, no interpolation will
|
|
be performed.
|
|
"""
|
|
self._revalidate()
|
|
return self._transformed_points, self.get_affine()
|
|
|
|
def get_transformed_path_and_affine(self):
|
|
"""
|
|
Return a copy of the child path, with the non-affine part of
|
|
the transform already applied, along with the affine part of
|
|
the path necessary to complete the transformation.
|
|
"""
|
|
self._revalidate()
|
|
return self._transformed_path, self.get_affine()
|
|
|
|
def get_fully_transformed_path(self):
|
|
"""
|
|
Return a fully-transformed copy of the child path.
|
|
"""
|
|
self._revalidate()
|
|
return self._transform.transform_path_affine(self._transformed_path)
|
|
|
|
def get_affine(self):
|
|
return self._transform.get_affine()
|
|
|
|
|
|
class TransformedPatchPath(TransformedPath):
|
|
"""
|
|
A `TransformedPatchPath` caches a non-affine transformed copy of the
|
|
`~.patches.Patch`. This cached copy is automatically updated when the
|
|
non-affine part of the transform or the patch changes.
|
|
"""
|
|
|
|
def __init__(self, patch):
|
|
"""
|
|
Parameters
|
|
----------
|
|
patch : `~.patches.Patch`
|
|
"""
|
|
# Defer to TransformedPath.__init__.
|
|
super().__init__(patch.get_path(), patch.get_transform())
|
|
self._patch = patch
|
|
|
|
def _revalidate(self):
|
|
patch_path = self._patch.get_path()
|
|
# Force invalidation if the patch path changed; otherwise, let base
|
|
# class check invalidation.
|
|
if patch_path != self._path:
|
|
self._path = patch_path
|
|
self._transformed_path = None
|
|
super()._revalidate()
|
|
|
|
|
|
def nonsingular(vmin, vmax, expander=0.001, tiny=1e-15, increasing=True):
|
|
"""
|
|
Modify the endpoints of a range as needed to avoid singularities.
|
|
|
|
Parameters
|
|
----------
|
|
vmin, vmax : float
|
|
The initial endpoints.
|
|
expander : float, default: 0.001
|
|
Fractional amount by which *vmin* and *vmax* are expanded if
|
|
the original interval is too small, based on *tiny*.
|
|
tiny : float, default: 1e-15
|
|
Threshold for the ratio of the interval to the maximum absolute
|
|
value of its endpoints. If the interval is smaller than
|
|
this, it will be expanded. This value should be around
|
|
1e-15 or larger; otherwise the interval will be approaching
|
|
the double precision resolution limit.
|
|
increasing : bool, default: True
|
|
If True, swap *vmin*, *vmax* if *vmin* > *vmax*.
|
|
|
|
Returns
|
|
-------
|
|
vmin, vmax : float
|
|
Endpoints, expanded and/or swapped if necessary.
|
|
If either input is inf or NaN, or if both inputs are 0 or very
|
|
close to zero, it returns -*expander*, *expander*.
|
|
"""
|
|
|
|
if (not np.isfinite(vmin)) or (not np.isfinite(vmax)):
|
|
return -expander, expander
|
|
|
|
swapped = False
|
|
if vmax < vmin:
|
|
vmin, vmax = vmax, vmin
|
|
swapped = True
|
|
|
|
# Expand vmin, vmax to float: if they were integer types, they can wrap
|
|
# around in abs (abs(np.int8(-128)) == -128) and vmax - vmin can overflow.
|
|
vmin, vmax = map(float, [vmin, vmax])
|
|
|
|
maxabsvalue = max(abs(vmin), abs(vmax))
|
|
if maxabsvalue < (1e6 / tiny) * np.finfo(float).tiny:
|
|
vmin = -expander
|
|
vmax = expander
|
|
|
|
elif vmax - vmin <= maxabsvalue * tiny:
|
|
if vmax == 0 and vmin == 0:
|
|
vmin = -expander
|
|
vmax = expander
|
|
else:
|
|
vmin -= expander*abs(vmin)
|
|
vmax += expander*abs(vmax)
|
|
|
|
if swapped and not increasing:
|
|
vmin, vmax = vmax, vmin
|
|
return vmin, vmax
|
|
|
|
|
|
def interval_contains(interval, val):
|
|
"""
|
|
Check, inclusively, whether an interval includes a given value.
|
|
|
|
Parameters
|
|
----------
|
|
interval : (float, float)
|
|
The endpoints of the interval.
|
|
val : float
|
|
Value to check is within interval.
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
Whether *val* is within the *interval*.
|
|
"""
|
|
a, b = interval
|
|
if a > b:
|
|
a, b = b, a
|
|
return a <= val <= b
|
|
|
|
|
|
def _interval_contains_close(interval, val, rtol=1e-10):
|
|
"""
|
|
Check, inclusively, whether an interval includes a given value, with the
|
|
interval expanded by a small tolerance to admit floating point errors.
|
|
|
|
Parameters
|
|
----------
|
|
interval : (float, float)
|
|
The endpoints of the interval.
|
|
val : float
|
|
Value to check is within interval.
|
|
rtol : float, default: 1e-10
|
|
Relative tolerance slippage allowed outside of the interval.
|
|
For an interval ``[a, b]``, values
|
|
``a - rtol * (b - a) <= val <= b + rtol * (b - a)`` are considered
|
|
inside the interval.
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
Whether *val* is within the *interval* (with tolerance).
|
|
"""
|
|
a, b = interval
|
|
if a > b:
|
|
a, b = b, a
|
|
rtol = (b - a) * rtol
|
|
return a - rtol <= val <= b + rtol
|
|
|
|
|
|
def interval_contains_open(interval, val):
|
|
"""
|
|
Check, excluding endpoints, whether an interval includes a given value.
|
|
|
|
Parameters
|
|
----------
|
|
interval : (float, float)
|
|
The endpoints of the interval.
|
|
val : float
|
|
Value to check is within interval.
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
Whether *val* is within the *interval*.
|
|
"""
|
|
a, b = interval
|
|
return a < val < b or a > val > b
|
|
|
|
|
|
def offset_copy(trans, fig=None, x=0.0, y=0.0, units='inches'):
|
|
"""
|
|
Return a new transform with an added offset.
|
|
|
|
Parameters
|
|
----------
|
|
trans : `Transform` subclass
|
|
Any transform, to which offset will be applied.
|
|
fig : `~matplotlib.figure.Figure`, default: None
|
|
Current figure. It can be None if *units* are 'dots'.
|
|
x, y : float, default: 0.0
|
|
The offset to apply.
|
|
units : {'inches', 'points', 'dots'}, default: 'inches'
|
|
Units of the offset.
|
|
|
|
Returns
|
|
-------
|
|
`Transform` subclass
|
|
Transform with applied offset.
|
|
"""
|
|
_api.check_in_list(['dots', 'points', 'inches'], units=units)
|
|
if units == 'dots':
|
|
return trans + Affine2D().translate(x, y)
|
|
if fig is None:
|
|
raise ValueError('For units of inches or points a fig kwarg is needed')
|
|
if units == 'points':
|
|
x /= 72.0
|
|
y /= 72.0
|
|
# Default units are 'inches'
|
|
return trans + ScaledTranslation(x, y, fig.dpi_scale_trans)
|