From d31d0cbeed69a981bf48fae89bdc114ddf3f981e Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Tue, 17 Mar 2020 15:08:05 -0700 Subject: [PATCH 1/7] refactor Bezier so it doesn't depend on Path --- doc/api/next_api_changes/deprecations.rst | 14 ++-- doc/api/next_api_changes/removals.rst | 2 +- lib/matplotlib/bezier.py | 90 +++++------------------ lib/matplotlib/patches.py | 43 +++++++---- lib/matplotlib/path.py | 63 +++++++++++++++- 5 files changed, 116 insertions(+), 96 deletions(-) diff --git a/doc/api/next_api_changes/deprecations.rst b/doc/api/next_api_changes/deprecations.rst index f5005552cb85..8410bfaadc85 100644 --- a/doc/api/next_api_changes/deprecations.rst +++ b/doc/api/next_api_changes/deprecations.rst @@ -374,13 +374,13 @@ also be accessible as ``toolbar.parent()``. Path helpers in :mod:`.bezier` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -``bezier.make_path_regular`` is deprecated. Use ``Path.cleaned()`` (or -``Path.cleaned(curves=True)``, etc.) instead (but note that these methods add a -``STOP`` code at the end of the path). - -``bezier.concatenate_paths`` is deprecated. Use ``Path.make_compound_path()`` -instead. +- ``bezier.make_path_regular`` is deprecated. Use ``Path.cleaned()`` (or + ``Path.cleaned(curves=True)``, etc.) instead (but note that these methods add + a ``STOP`` code at the end of the path). +- ``bezier.concatenate_paths`` is deprecated. Use ``Path.make_compound_path()`` + instead. +- ``bezier.split_path_inout`` (use ``Path.split_path_inout`` instead) +- ``bezier.inside_circle()`` (no replacement) ``animation.html_args`` rcParam ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/api/next_api_changes/removals.rst b/doc/api/next_api_changes/removals.rst index c2dab6f14c5f..8d50ae6e54b8 100644 --- a/doc/api/next_api_changes/removals.rst +++ b/doc/api/next_api_changes/removals.rst @@ -103,7 +103,7 @@ Classes, methods and attributes - ``image.BboxImage.interp_at_native`` property (no replacement) - ``lines.Line2D.verticalOffset`` property (no replacement) -- ``bezier.find_r_to_boundary_of_closedpath()`` (no relacement) +- ``bezier.find_r_to_boundary_of_closedpath()`` (no replacement) - ``quiver.Quiver.color()`` (use ``Quiver.get_facecolor()`` instead) - ``quiver.Quiver.keyvec`` property (no replacement) diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index d82539a7f90c..50f3bdd15dd5 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -7,12 +7,12 @@ import numpy as np import matplotlib.cbook as cbook -from matplotlib.path import Path class NonIntersectingPathException(ValueError): pass + # some functions @@ -68,6 +68,15 @@ def get_normal_points(cx, cy, cos_t, sin_t, length): return x1, y1, x2, y2 +@cbook.deprecated("3.3", alternative="Path.split_path_inout()") +def split_path_inout(path, inside, tolerance=0.01, reorder_inout=False): + """ + Divide a path into two segments at the point where ``inside(x, y)`` + becomes False. + """ + return path.split_path_inout(inside, tolerance, reorder_inout) + + # BEZIER routines # subdividing bezier curve @@ -222,69 +231,7 @@ def split_bezier_intersecting_with_closedpath( return _left, _right -# matplotlib specific - - -def split_path_inout(path, inside, tolerance=0.01, reorder_inout=False): - """ - Divide a path into two segments at the point where ``inside(x, y)`` becomes - False. - """ - path_iter = path.iter_segments() - - ctl_points, command = next(path_iter) - begin_inside = inside(ctl_points[-2:]) # true if begin point is inside - - ctl_points_old = ctl_points - - iold = 0 - i = 1 - - for ctl_points, command in path_iter: - iold = i - i += len(ctl_points) // 2 - if inside(ctl_points[-2:]) != begin_inside: - bezier_path = np.concatenate([ctl_points_old[-2:], ctl_points]) - break - ctl_points_old = ctl_points - else: - raise ValueError("The path does not intersect with the patch") - - bp = bezier_path.reshape((-1, 2)) - left, right = split_bezier_intersecting_with_closedpath( - bp, inside, tolerance) - if len(left) == 2: - codes_left = [Path.LINETO] - codes_right = [Path.MOVETO, Path.LINETO] - elif len(left) == 3: - codes_left = [Path.CURVE3, Path.CURVE3] - codes_right = [Path.MOVETO, Path.CURVE3, Path.CURVE3] - elif len(left) == 4: - codes_left = [Path.CURVE4, Path.CURVE4, Path.CURVE4] - codes_right = [Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4] - else: - raise AssertionError("This should never be reached") - - verts_left = left[1:] - verts_right = right[:] - - if path.codes is None: - path_in = Path(np.concatenate([path.vertices[:i], verts_left])) - path_out = Path(np.concatenate([verts_right, path.vertices[i:]])) - - else: - path_in = Path(np.concatenate([path.vertices[:iold], verts_left]), - np.concatenate([path.codes[:iold], codes_left])) - - path_out = Path(np.concatenate([verts_right, path.vertices[i:]]), - np.concatenate([codes_right, path.codes[i:]])) - - if reorder_inout and not begin_inside: - path_in, path_out = path_out, path_in - - return path_in, path_out - - +@cbook.deprecated("3.3") def inside_circle(cx, cy, r): """ Return a function that checks whether a point is in a circle with center @@ -294,16 +241,13 @@ def inside_circle(cx, cy, r): f(xy: Tuple[float, float]) -> bool """ - r2 = r ** 2 - - def _f(xy): - x, y = xy - return (x - cx) ** 2 + (y - cy) ** 2 < r2 - return _f + from .patches import _inside_circle + return _inside_circle(cx, cy, r) # quadratic Bezier lines + def get_cos_sin(x0, y0, x1, y1): dx, dy = x1 - x0, y1 - y0 d = (dx * dx + dy * dy) ** .5 @@ -486,6 +430,7 @@ def make_path_regular(p): with ``codes`` set to (MOVETO, LINETO, LINETO, ..., LINETO); otherwise return *p* itself. """ + from .path import Path c = p.codes if c is None: c = np.full(len(p.vertices), Path.LINETO, dtype=Path.code_type) @@ -498,6 +443,5 @@ def make_path_regular(p): @cbook.deprecated("3.3", alternative="Path.make_compound_path()") def concatenate_paths(paths): """Concatenate a list of paths into a single path.""" - vertices = np.concatenate([p.vertices for p in paths]) - codes = np.concatenate([make_path_regular(p).codes for p in paths]) - return Path(vertices, codes) + from .path import Path + return Path.make_compound_path(*paths) diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index dd99e16c0c7e..771111d98081 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -10,12 +10,28 @@ import matplotlib as mpl from . import artist, cbook, colors, docstring, lines as mlines, transforms from .bezier import ( - NonIntersectingPathException, get_cos_sin, get_intersection, - get_parallels, inside_circle, make_wedged_bezier2, - split_bezier_intersecting_with_closedpath, split_path_inout) + NonIntersectingPathException, get_cos_sin, get_intersection, get_parallels, + make_wedged_bezier2, split_bezier_intersecting_with_closedpath) from .path import Path +def _inside_circle(cx, cy, r): + """ + Return a function that checks whether a point is in a circle with center + (*cx*, *cy*) and radius *r*. + + The returned function has the signature:: + + f(xy: Tuple[float, float]) -> bool + """ + r2 = r ** 2 + + def _f(xy): + x, y = xy + return (x - cx) ** 2 + (y - cy) ** 2 < r2 + return _f + + @cbook._define_aliases({ "antialiased": ["aa"], "edgecolor": ["ec"], @@ -2414,7 +2430,7 @@ def insideA(xy_display): return patchA.contains(xy_event)[0] try: - left, right = split_path_inout(path, insideA) + left, right = path.split_path_inout(insideA) except ValueError: right = path @@ -2426,7 +2442,7 @@ def insideB(xy_display): return patchB.contains(xy_event)[0] try: - left, right = split_path_inout(path, insideB) + left, right = path.split_path_inout(insideB) except ValueError: left = path @@ -2439,15 +2455,15 @@ def _shrink(self, path, shrinkA, shrinkB): Shrink the path by fixed size (in points) with shrinkA and shrinkB. """ if shrinkA: - insideA = inside_circle(*path.vertices[0], shrinkA) + insideA = _inside_circle(*path.vertices[0], shrinkA) try: - left, path = split_path_inout(path, insideA) + left, path = path.split_path_inout(insideA) except ValueError: pass if shrinkB: - insideB = inside_circle(*path.vertices[-1], shrinkB) + insideB = _inside_circle(*path.vertices[-1], shrinkB) try: - path, right = split_path_inout(path, insideB) + path, right = path.split_path_inout(insideB) except ValueError: pass return path @@ -2872,7 +2888,6 @@ def __call__(self, path, mutation_size, linewidth, The __call__ method is a thin wrapper around the transmute method and takes care of the aspect ratio. """ - if aspect_ratio is not None: # Squeeze the given height by the aspect_ratio vertices = path.vertices / [1, aspect_ratio] @@ -3337,7 +3352,7 @@ def transmute(self, path, mutation_size, linewidth): # divide the path into a head and a tail head_length = self.head_length * mutation_size - in_f = inside_circle(x2, y2, head_length) + in_f = _inside_circle(x2, y2, head_length) arrow_path = [(x0, y0), (x1, y1), (x2, y2)] try: @@ -3420,7 +3435,7 @@ def transmute(self, path, mutation_size, linewidth): arrow_path = [(x0, y0), (x1, y1), (x2, y2)] # path for head - in_f = inside_circle(x2, y2, head_length) + in_f = _inside_circle(x2, y2, head_length) try: path_out, path_in = split_bezier_intersecting_with_closedpath( arrow_path, in_f, tolerance=0.01) @@ -3435,7 +3450,7 @@ def transmute(self, path, mutation_size, linewidth): path_head = path_in # path for head - in_f = inside_circle(x2, y2, head_length * .8) + in_f = _inside_circle(x2, y2, head_length * .8) path_out, path_in = split_bezier_intersecting_with_closedpath( arrow_path, in_f, tolerance=0.01) path_tail = path_out @@ -3453,7 +3468,7 @@ def transmute(self, path, mutation_size, linewidth): w1=1., wm=0.6, w2=0.3) # path for head - in_f = inside_circle(x0, y0, tail_width * .3) + in_f = _inside_circle(x0, y0, tail_width * .3) path_in, path_out = split_bezier_intersecting_with_closedpath( arrow_path, in_f, tolerance=0.01) tail_start = path_in[-1] diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index 16e77e95b45e..cf030a553d8a 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -17,6 +17,7 @@ import matplotlib as mpl from . import _path, cbook from .cbook import _to_unmasked_float_array, simple_linear_interpolation +from .bezier import split_bezier_intersecting_with_closedpath class Path: @@ -585,6 +586,65 @@ def interpolated(self, steps): new_codes = None return Path(vertices, new_codes) + def split_path_inout(self, inside, tolerance=0.01, reorder_inout=False): + """ + Divide a path into two segments at the point where ``inside(x, y)`` + becomes False. + """ + path_iter = self.iter_segments() + + ctl_points, command = next(path_iter) + begin_inside = inside(ctl_points[-2:]) # true if begin point is inside + + ctl_points_old = ctl_points + + iold = 0 + i = 1 + + for ctl_points, command in path_iter: + iold = i + i += len(ctl_points) // 2 + if inside(ctl_points[-2:]) != begin_inside: + bezier_path = np.concatenate([ctl_points_old[-2:], ctl_points]) + break + ctl_points_old = ctl_points + else: + raise ValueError("The path does not intersect with the patch") + + bp = bezier_path.reshape((-1, 2)) + left, right = split_bezier_intersecting_with_closedpath( + bp, inside, tolerance) + if len(left) == 2: + codes_left = [Path.LINETO] + codes_right = [Path.MOVETO, Path.LINETO] + elif len(left) == 3: + codes_left = [Path.CURVE3, Path.CURVE3] + codes_right = [Path.MOVETO, Path.CURVE3, Path.CURVE3] + elif len(left) == 4: + codes_left = [Path.CURVE4, Path.CURVE4, Path.CURVE4] + codes_right = [Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4] + else: + raise AssertionError("This should never be reached") + + verts_left = left[1:] + verts_right = right[:] + + if self.codes is None: + path_in = Path(np.concatenate([self.vertices[:i], verts_left])) + path_out = Path(np.concatenate([verts_right, self.vertices[i:]])) + + else: + path_in = Path(np.concatenate([self.vertices[:iold], verts_left]), + np.concatenate([self.codes[:iold], codes_left])) + + path_out = Path(np.concatenate([verts_right, self.vertices[i:]]), + np.concatenate([codes_right, self.codes[i:]])) + + if reorder_inout and not begin_inside: + path_in, path_out = path_out, path_in + + return path_in, path_out + def to_polygons(self, transform=None, width=0, height=0, closed_only=True): """ Convert this path to a list of polygons or polylines. Each @@ -647,7 +707,8 @@ def unit_rectangle(cls): def unit_regular_polygon(cls, numVertices): """ Return a :class:`Path` instance for a unit regular polygon with the - given *numVertices* and radius of 1.0, centered at (0, 0). + given *numVertices* such that the circumscribing circle has radius 1.0, + centered at (0, 0). """ if numVertices <= 16: path = cls._unit_regular_polygons.get(numVertices) From 9774f8c861b524f3dc43bfb4b8842c81050b4359 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Tue, 17 Mar 2020 15:08:05 -0700 Subject: [PATCH 2/7] correctly compute bounding box for path --- lib/matplotlib/bezier.py | 111 +++++++++++++++++++++++++++--- lib/matplotlib/path.py | 89 +++++++++++++++++++++++- lib/matplotlib/tests/test_path.py | 6 ++ 3 files changed, 197 insertions(+), 9 deletions(-) diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index 50f3bdd15dd5..ddd5293f3034 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -3,11 +3,19 @@ """ import math +import warnings import numpy as np import matplotlib.cbook as cbook +# same algorithm as 3.8's math.comb +@np.vectorize +def _comb(n, k): + k = min(k, n - k) + i = np.arange(1, k + 1) + return np.prod((n + 1 - i)/i).astype(int) + class NonIntersectingPathException(ValueError): pass @@ -177,27 +185,114 @@ def find_bezier_t_intersecting_with_closedpath( class BezierSegment: """ - A D-dimensional Bezier segment. + A d-dimensional Bezier segment. Parameters ---------- - control_points : (N, D) array + control_points : (N, d) array Location of the *N* control points. """ def __init__(self, control_points): - n = len(control_points) - self._orders = np.arange(n) - coeff = [math.factorial(n - 1) - // (math.factorial(i) * math.factorial(n - 1 - i)) - for i in range(n)] - self._px = np.asarray(control_points).T * coeff + self._cpoints = np.asarray(control_points) + self._N, self._d = self._cpoints.shape + self._orders = np.arange(self._N) + coeff = [math.factorial(self._N - 1) + // (math.factorial(i) * math.factorial(self._N - 1 - i)) + for i in range(self._N)] + self._px = self._cpoints.T * coeff + + def __call__(self, t): + return self.point_at_t(t) def point_at_t(self, t): """Return the point on the Bezier curve for parameter *t*.""" return tuple( self._px @ (((1 - t) ** self._orders)[::-1] * t ** self._orders)) + @property + def control_points(self): + """The control points of the curve.""" + return self._cpoints + + @property + def dimension(self): + """The dimension of the curve.""" + return self._d + + @property + def degree(self): + """The number of control points in the curve.""" + return self._N - 1 + + @property + def polynomial_coefficients(self): + r"""The polynomial coefficients of the Bezier curve. + + Returns + ------- + coefs : float, (n+1, d) array_like + Coefficients after expanding in polynomial basis, where :math:`n` + is the degree of the bezier curve and :math:`d` its dimension. + These are the numbers (:math:`C_j`) such that the curve can be + written :math:`\sum_{j=0}^n C_j t^j`. + + Notes + ----- + The coefficients are calculated as + + .. math:: + + {n \choose j} \sum_{i=0}^j (-1)^{i+j} {j \choose i} P_i + + where :math:`P_i` are the control points of the curve. + """ + n = self.degree + # matplotlib uses n <= 4. overflow plausible starting around n = 15. + if n > 10: + warnings.warn("Polynomial coefficients formula unstable for high " + "order Bezier curves!", RuntimeWarning) + d = self.dimension + P = self.control_points + coefs = np.zeros((n+1, d)) + for j in range(n+1): + i = np.arange(j+1) + prefactor = np.power(-1, i + j) * _comb(j, i) + prefactor = np.tile(prefactor, (d, 1)).T + coefs[j] = _comb(n, j) * np.sum(prefactor*P[i], axis=0) + return coefs + + @property + def axis_aligned_extrema(self): + """ + Return the location along the curve's interior where its partial + derivative is zero, along with the dimension along which it is zero for + each such instance. + + Returns + ------- + dims : int, array_like + dimension :math:`i` along which the corresponding zero occurs + dzeros : float, array_like + of same size as dims. the :math:`t` such that :math:`d/dx_i B(t) = + 0` + """ + n = self.degree + Cj = self.polynomial_coefficients + dCj = np.atleast_2d(np.arange(1, n+1)).T * Cj[1:] + if len(dCj) == 0: + return np.array([]), np.array([]) + dims = [] + roots = [] + for i, pi in enumerate(dCj.T): + r = np.roots(pi[::-1]) + roots.append(r) + dims.append(i*np.ones_like(r)) + roots = np.concatenate(roots) + dims = np.concatenate(dims) + in_range = np.isreal(roots) & (roots >= 0) & (roots <= 1) + return dims[in_range], np.real(roots)[in_range] + def split_bezier_intersecting_with_closedpath( bezier, inside_closedpath, tolerance=0.01): diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index cf030a553d8a..7091118b87b9 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -17,7 +17,18 @@ import matplotlib as mpl from . import _path, cbook from .cbook import _to_unmasked_float_array, simple_linear_interpolation -from .bezier import split_bezier_intersecting_with_closedpath +from .bezier import BezierSegment, split_bezier_intersecting_with_closedpath + + +def _update_extents(extents, point): + dim = len(point) + for i, xi in enumerate(point): + if xi < extents[i]: + extents[i] = xi + # elif here would fail to correctly update from "null" extents of + # np.array([np.inf, np.inf, -np.inf, -np.inf]) + if extents[i+dim] < xi: + extents[i+dim] = xi class Path: @@ -421,6 +432,53 @@ def iter_segments(self, transform=None, remove_nans=True, clip=None, curr_vertices = np.append(curr_vertices, next(vertices)) yield curr_vertices, code + def iter_bezier(self, **kwargs): + """ + Iterate over each bezier curve (lines included) in a Path. + + Parameters + ---------- + kwargs : Dict[str, object] + Forwareded to iter_segments. + + Yields + ------ + B : matplotlib.bezier.BezierSegment + The bezier curves that make up the current path. Note in particular + that freestanding points are bezier curves of order 0, and lines + are bezier curves of order 1 (with two control points). + code : Path.code_type + The code describing what kind of curve is being returned. + Path.MOVETO, Path.LINETO, Path.CURVE3, Path.CURVE4 correspond to + bezier curves with 1, 2, 3, and 4 control points (respectively). + Path.CLOSEPOLY is a Path.LINETO with the control points correctly + chosen based on the start/end points of the current stroke. + """ + first_vert = None + prev_vert = None + for vertices, code in self.iter_segments(**kwargs): + if first_vert is None: + if code != Path.MOVETO: + raise ValueError("Malformed path, must start with MOVETO.") + if code == Path.MOVETO: # a point is like "CURVE1" + first_vert = vertices + yield BezierSegment(np.array([first_vert])), code + elif code == Path.LINETO: # "CURVE2" + yield BezierSegment(np.array([prev_vert, vertices])), code + elif code == Path.CURVE3: + yield BezierSegment(np.array([prev_vert, vertices[:2], + vertices[2:]])), code + elif code == Path.CURVE4: + yield BezierSegment(np.array([prev_vert, vertices[:2], + vertices[2:4], vertices[4:]])), code + elif code == Path.CLOSEPOLY: + yield BezierSegment(np.array([prev_vert, first_vert])), code + elif code == Path.STOP: + return + else: + raise ValueError("Invalid Path.code_type: " + str(code)) + prev_vert = vertices[-2:] + @cbook._delete_parameter("3.3", "quantize") def cleaned(self, transform=None, remove_nans=False, clip=None, quantize=False, simplify=False, curves=False, @@ -546,6 +604,35 @@ def get_extents(self, transform=None): transform = None return Bbox(_path.get_path_extents(path, transform)) + def get_exact_extents(self, **kwargs): + """Get size of Bbox of curve (instead of Bbox of control points). + + Parameters + ---------- + kwargs : Dict[str, object] + Forwarded to self.iter_bezier. + + Returns + ------- + extents : (4,) float, array_like + The extents of the path (xmin, ymin, xmax, ymax). + """ + maxi = 2 # [xmin, ymin, *xmax, ymax] + # return value for empty paths to match _path.h + extents = np.array([np.inf, np.inf, -np.inf, -np.inf]) + for curve, code in self.iter_bezier(**kwargs): + # start and endpoints can be extrema of the curve + _update_extents(extents, curve(0)) # start point + _update_extents(extents, curve(1)) # end point + # interior extrema where d/ds B(s) == 0 + _, dzeros = curve.axis_aligned_extrema + if len(dzeros) == 0: + continue + for zero in dzeros: + potential_extrema = curve.point_at_t(zero) + _update_extents(extents, potential_extrema) + return extents + def intersects_path(self, other, filled=True): """ Return whether if this path intersects another given path. diff --git a/lib/matplotlib/tests/test_path.py b/lib/matplotlib/tests/test_path.py index b61a92654dc3..dc066c93d046 100644 --- a/lib/matplotlib/tests/test_path.py +++ b/lib/matplotlib/tests/test_path.py @@ -49,6 +49,12 @@ def test_contains_points_negative_radius(): np.testing.assert_equal(result, [True, False, False]) +def test_exact_extents_cubic(): + hard_curve = Path([[0, 0], [1, 0], [1, 1], [0, 1]], + [Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4]) + np.testing.assert_equal(hard_curve.get_exact_extents(), [0., 0., 0.75, 1.]) + + def test_point_in_path_nan(): box = np.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]) p = Path(box) From 4c80f38d35870d35f6b05fa2ba880b28b462c9c3 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Fri, 20 Mar 2020 01:59:28 -0700 Subject: [PATCH 3/7] add function to compute (signed) area of path --- lib/matplotlib/bezier.py | 108 ++++++++++++++++++++++++++++-- lib/matplotlib/path.py | 35 ++++++++++ lib/matplotlib/tests/test_path.py | 16 +++++ 3 files changed, 155 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index ddd5293f3034..dde12b39dfa1 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -200,15 +200,114 @@ def __init__(self, control_points): coeff = [math.factorial(self._N - 1) // (math.factorial(i) * math.factorial(self._N - 1 - i)) for i in range(self._N)] - self._px = self._cpoints.T * coeff + self._px = (self._cpoints.T * coeff).T def __call__(self, t): - return self.point_at_t(t) + t = np.array(t) + orders_shape = (1,)*t.ndim + self._orders.shape + t_shape = t.shape + (1,) # self._orders.ndim == 1 + orders = np.reshape(self._orders, orders_shape) + rev_orders = np.reshape(self._orders[::-1], orders_shape) + t = np.reshape(t, t_shape) + return ((1 - t)**rev_orders * t**orders) @ self._px def point_at_t(self, t): """Return the point on the Bezier curve for parameter *t*.""" - return tuple( - self._px @ (((1 - t) ** self._orders)[::-1] * t ** self._orders)) + return tuple(self(t)) + + def arc_area(self): + r""" + (Signed) area swept out by ray from origin to curve. + + Notes + ----- + A simple, analytical formula is possible for arbitrary bezier curves. + + Given a bezier curve B(t), in order to calculate the area of the arc + swept out by the ray from the origin to the curve, we simply need to + compute :math:`\frac{1}{2}\int_0^1 B(t) \cdot n(t) dt`, where + :math:`n(t) = u^{(1)}(t) \hat{x}_0 - u{(0)}(t) \hat{x}_1` is the normal + vector oriented away from the origin and :math:`u^{(i)}(t) = + \frac{d}{dt} B^{(i)}(t)` is the :math:`i`th component of the curve's + tangent vector. (This formula can be found by applying the divergence + theorem to :math:`F(x,y) = [x, y]/2`, and calculates the *signed* area + for a counter-clockwise curve, by the right hand rule). + + The control points of the curve are just its coefficients in a + Bernstein expansion, so if we let :math:`P_i = [P^{(0)}_i, P^{(1)}_i]` + be the :math:`i`'th control point, then + + .. math:: + + \frac{1}{2}\int_0^1 B(t) \cdot n(t) dt + &= \frac{1}{2}\int_0^1 B^{(0)}(t) \frac{d}{dt} B^{(1)}(t) + - B^{(1)}(t) \frac{d}{dt} B^{(0)}(t) + dt \\ + &= \frac{1}{2}\int_0^1 + \left( \sum_{j=0}^n P_j^{(0)} b_{j,n} \right) + \left( n \sum_{k=0}^{n-1} (P_{k+1}^{(1)} - + P_{k}^{(1)}) b_{j,n} \right) + \\ + &\hspace{1em} - \left( \sum_{j=0}^n P_j^{(1)} b_{j,n} + \right) \left( n \sum_{k=0}^{n-1} (P_{k+1}^{(0)} + - P_{k}^{(0)}) b_{j,n} \right) + dt, + + where :math:`b_{\nu, n}(t) = {n \choose \nu} t^\nu {(1 - t)}^{n-\nu}` + is the :math:`\nu`'th Bernstein polynomial of degree :math:`n`. + + Grouping :math:`t^l(1-t)^m` terms together for each :math:`l`, + :math:`m`, we get that the integrand becomes + + .. math:: + + \sum_{j=0}^n \sum_{k=0}^{n-1} + {n \choose j} {{n - 1} \choose k} + &\left[P_j^{(0)} (P_{k+1}^{(1)} - P_{k}^{(1)}) + - P_j^{(1)} (P_{k+1}^{(0)} - P_{k}^{(0)})\right] \\ + &\hspace{1em}\times{}t^{j + k} {(1 - t)}^{2n - 1 - j - k} + + or just + + .. math:: + + \sum_{j=0}^n \sum_{k=0}^{n-1} + \frac{{n \choose j} {{n - 1} \choose k}} + {{{2n - 1} \choose {j+k}}} + [P_j^{(0)} (P_{k+1}^{(1)} - P_{k}^{(1)}) + - P_j^{(1)} (P_{k+1}^{(0)} - P_{k}^{(0)})] + b_{j+k,2n-1}(t). + + Interchanging sum and integral, and using the fact that :math:`\int_0^1 + b_{\nu, n}(t) dt = \frac{1}{n + 1}`, we conclude that the + original integral can + simply be written as + + .. math:: + + \frac{1}{2}&\int_0^1 B(t) \cdot n(t) dt + \\ + &= \frac{1}{4}\sum_{j=0}^n \sum_{k=0}^{n-1} + \frac{{n \choose j} {{n - 1} \choose k}} + {{{2n - 1} \choose {j+k}}} + [P_j^{(0)} (P_{k+1}^{(1)} - P_{k}^{(1)}) + - P_j^{(1)} (P_{k+1}^{(0)} - P_{k}^{(0)})] + """ + n = self.degree + area = 0 + P = self.control_points + dP = np.diff(P, axis=0) + for j in range(n + 1): + for k in range(n): + area += _comb(n, j)*_comb(n-1, k)/_comb(2*n - 1, j + k) \ + * (P[j, 0]*dP[k, 1] - P[j, 1]*dP[k, 0]) + return (1/4)*area + + @classmethod + def differentiate(cls, B): + """Return the derivative of a BezierSegment, itself a BezierSegment""" + dcontrol_points = B.degree*np.diff(B.control_points, axis=0) + return cls(dcontrol_points) @property def control_points(self): @@ -279,6 +378,7 @@ def axis_aligned_extrema(self): """ n = self.degree Cj = self.polynomial_coefficients + # much faster than .differentiate(self).polynomial_coefficients dCj = np.atleast_2d(np.arange(1, n+1)).T * Cj[1:] if len(dCj) == 0: return np.array([]), np.array([]) diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index 7091118b87b9..f3aeee9cfccf 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -654,6 +654,41 @@ def intersects_bbox(self, bbox, filled=True): return _path.path_intersects_rectangle( self, bbox.x0, bbox.y0, bbox.x1, bbox.y1, filled) + def signed_area(self, **kwargs): + """ + Get signed area filled by path. + + All sub paths are treated as if they had been closed. That is, if there + is a MOVETO without a preceding CLOSEPOLY, one is added. + + Signed area means that if a path is self-intersecting, the drawing rule + "even-odd" is used and only the filled area is counted. + + Returns + ------- + area : float + The (signed) enclosed area of the path. + """ + area = 0 + prev_point = None + prev_code = None + start_point = None + for B, code in self.iter_bezier(**kwargs): + if code == Path.MOVETO: + if prev_code is not None and prev_code is not Path.CLOSEPOLY: + Bclose = BezierSegment(np.array([prev_point, start_point])) + area += Bclose.arc_area() + start_point = B.control_points[0] + area += B.arc_area() + prev_point = B.control_points[-1] + prev_code = code + # add final implied CLOSEPOLY, if necessary + if start_point is not None \ + and not np.all(np.isclose(start_point, prev_point)): + B = BezierSegment(np.array([prev_point, start_point])) + area += B.arc_area() + return area + def interpolated(self, steps): """ Return a new path resampled to length N x steps. diff --git a/lib/matplotlib/tests/test_path.py b/lib/matplotlib/tests/test_path.py index dc066c93d046..e3ee50acb7fb 100644 --- a/lib/matplotlib/tests/test_path.py +++ b/lib/matplotlib/tests/test_path.py @@ -55,6 +55,22 @@ def test_exact_extents_cubic(): np.testing.assert_equal(hard_curve.get_exact_extents(), [0., 0., 0.75, 1.]) +def test_signed_area_unit_circle(): + circ = Path.unit_circle() + # not quite pi...since it's not quite a circle! + assert(np.isclose(circ.signed_area(), 3.1415935732517166)) + # now counter-clockwise + rverts = circ.vertices[-2::-1] + rverts = np.append(rverts, np.atleast_2d(circ.vertices[0]), axis=0) + rcirc = Path(rverts, circ.codes) + assert(np.isclose(rcirc.signed_area(), -3.1415935732517166)) + + +def test_signed_area_unit_rectangle(): + rect = Path.unit_rectangle() + assert(np.isclose(rect.signed_area(), 1)) + + def test_point_in_path_nan(): box = np.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]) p = Path(box) From a76069bc69903f504843496fcbdbd723f3fab65d Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Mon, 23 Mar 2020 11:45:53 -0700 Subject: [PATCH 4/7] code to compute bezier segment / path lengths --- lib/matplotlib/bezier.py | 91 +++++++++++++++++++++++++++++++ lib/matplotlib/path.py | 21 +++++++ lib/matplotlib/tests/test_path.py | 6 ++ 3 files changed, 118 insertions(+) diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index dde12b39dfa1..0b2fbf9dca91 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -4,6 +4,7 @@ import math import warnings +from collections import deque import numpy as np @@ -215,6 +216,96 @@ def point_at_t(self, t): """Return the point on the Bezier curve for parameter *t*.""" return tuple(self(t)) + def split_at_t(self, t): + """Split into two Bezier curves using de casteljau's algorithm. + + Parameters + ---------- + t : float + Point in [0,1] at which to split into two curves + + Returns + ------- + B1, B2 : BezierSegment + The two sub-curves. + """ + new_cpoints = split_de_casteljau(self._cpoints, t) + return BezierSegment(new_cpoints[0]), BezierSegment(new_cpoints[1]) + + def control_net_length(self): + """Sum of lengths between control points""" + L = 0 + N, d = self._cpoints.shape + for i in range(N - 1): + L += np.linalg.norm(self._cpoints[i+1] - self._cpoints[i]) + return L + + def arc_length(self, rtol=None, atol=None): + """Estimate the length using iterative refinement. + + Our estimate is just the average between the length of the chord and + the length of the control net. + + Since the chord length and control net give lower and upper bounds + (respectively) on the length, this maximum possible error is tested + against an absolute tolerance threshold at each subdivision. + + However, sometimes this estimator converges much faster than this error + esimate would suggest. Therefore, the relative change in the length + estimate between subdivisions is compared to a relative error tolerance + after each set of subdivisions. + + Parameters + ---------- + rtol : float, default 1e-4 + If :code:`abs(est[i+1] - est[i]) <= rtol * est[i+1]`, we return + :code:`est[i+1]`. + atol : float, default 1e-6 + If the distance between chord length and control length at any + point falls below this number, iteration is terminated. + """ + if rtol is None: + rtol = 1e-4 + if atol is None: + atol = 1e-6 + + chord = np.linalg.norm(self._cpoints[-1] - self._cpoints[0]) + net = self.control_net_length() + max_err = (net - chord)/2 + curr_est = chord + max_err + # early exit so we don't try to "split" paths of zero length + if max_err < atol: + return curr_est + + prev_est = np.inf + curves = deque([self]) + errs = deque([max_err]) + lengths = deque([curr_est]) + while np.abs(curr_est - prev_est) > rtol * curr_est: + # subdivide the *whole* curve before checking relative convergence + # again + prev_est = curr_est + num_curves = len(curves) + for i in range(num_curves): + curve = curves.popleft() + new_curves = curve.split_at_t(0.5) + max_err -= errs.popleft() + curr_est -= lengths.popleft() + for ncurve in new_curves: + chord = np.linalg.norm( + ncurve._cpoints[-1] - ncurve._cpoints[0]) + net = ncurve.control_net_length() + nerr = (net - chord)/2 + nlength = chord + nerr + max_err += nerr + curr_est += nlength + curves.append(ncurve) + errs.append(nerr) + lengths.append(nlength) + if max_err < atol: + return curr_est + return curr_est + def arc_area(self): r""" (Signed) area swept out by ray from origin to curve. diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index f3aeee9cfccf..abb6e404d4be 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -654,6 +654,27 @@ def intersects_bbox(self, bbox, filled=True): return _path.path_intersects_rectangle( self, bbox.x0, bbox.y0, bbox.x1, bbox.y1, filled) + def length(self, rtol=None, atol=None, **kwargs): + """Get length of Path. + + Equivalent to (but not computed as) + + .. math:: + + \sum_{j=1}^N \int_0^1 ||B'_j(t)|| dt + + where the sum is over the :math:`N` Bezier curves that comprise the + Path. Notice that this measure of length will assign zero weight to all + isolated points on the Path. + + Returns + ------- + length : float + The path length. + """ + return np.sum([B.arc_length(rtol, atol) + for B, code in self.iter_bezier(**kwargs)]) + def signed_area(self, **kwargs): """ Get signed area filled by path. diff --git a/lib/matplotlib/tests/test_path.py b/lib/matplotlib/tests/test_path.py index e3ee50acb7fb..9b561378c013 100644 --- a/lib/matplotlib/tests/test_path.py +++ b/lib/matplotlib/tests/test_path.py @@ -66,6 +66,12 @@ def test_signed_area_unit_circle(): assert(np.isclose(rcirc.signed_area(), -3.1415935732517166)) +def test_length_unit_circl(): + circ = Path.unit_circle() + # not quite 2*pi...since it's not quite a circle! + assert(np.isclose(circ.length(), 6.283186229058933)) + + def test_signed_area_unit_rectangle(): rect = Path.unit_rectangle() assert(np.isclose(rect.signed_area(), 1)) From 4b5fc446e4d8de0eb3f0e4816ba04e9954673d45 Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Sat, 21 Mar 2020 14:37:48 -0700 Subject: [PATCH 5/7] add function to get center of mass of path --- lib/matplotlib/bezier.py | 76 +++++++++++++++++++++ lib/matplotlib/path.py | 141 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 215 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index 0b2fbf9dca91..ea5235db248b 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -306,6 +306,73 @@ def arc_length(self, rtol=None, atol=None): return curr_est return curr_est + def arc_center_of_mass(self): + r""" + Center of mass of the (even-odd-rendered) area swept out by the ray + from the origin to the path. + + Summing this vector for each segment along a closed path will produce + that area's center of mass. + + Returns + ------- + r_cm : (2,) np.array + the "arc's center of mass" + + Notes + ----- + A simple analytical form can be derived for general Bezier curves. + Suppose the curve was closed, so :math:`B(0) = B(1)`. Call the area + enclosed by :math:`B(t)` :math:`B_\text{int}`. The center of mass of + :math:`B_\text{int}` is defined by the expected value of the position + vector `\vec{r}` + + .. math:: + + \vec{R}_\text{cm} = \int_{B_\text{int}} \vec{r} \left( \frac{1}{ + \int_{B_\text{int}}} d\vec{r} \right) d\vec{r} + + where :math:`(1/\text{Area}(B_\text{int})` can be interpreted as a + probability density. + + In order to compute this integral, we choose two functions + :math:`F_0(x,y) = [x^2/2, 0]` and :math:`F_1(x,y) = [0, y^2/2]` such + that :math:`[\div \cdot F_0, \div \cdot F_1] = \vec{r}`. Then, applying + the divergence integral (componentwise), we get that + + .. math:: + \vec{R}_\text{cm} &= \oint_{B(t)} F \cdot \vec{n} dt \\ + &= \int_0^1 \left[ \begin{array}{1} + B^{(0)}(t) \frac{dB^{(1)}(t)}{dt} \\ + - B^{(1)}(t) \frac{dB^{(0)}(t)}{dt} \end{array} \right] dt + + After expanding in Berstein polynomials and moving the integral inside + all the sums, we get that + + .. math:: + \vec{R}_\text{cm} = \frac{1}{6} \sum_{i,j=0}^n\sum_{k=0}^{n-1} + \frac{{n \choose i}{n \choose j}{{n-1} \choose k}} + {{3n - 1} \choose {i + j + k}} + \left(\begin{array}{1} + P^{(0)}_i P^{(0)}_j (P^{(1)}_{k+1} - P^{(1)}_k) + - P^{(1)}_i P^{(1)}_j (P^{(0)}_{k+1} - P^{(0)}_k) + \right) \end{array} + + where :math:`P_i = [P^{(0)}_i, P^{(1)}_i]` is the :math:`i`'th control + point of the curve and :math:`n` is the degree of the curve. + """ + n = self.degree + r_cm = np.zeros(2) + P = self.control_points + dP = np.diff(P, axis=0) + Pn = np.array([[1, -1]])*dP[:, ::-1] # n = [y, -x] + for i in range(n + 1): + for j in range(n + 1): + for k in range(n): + r_cm += _comb(n, i) * _comb(n, j) * _comb(n - 1, k) \ + * P[i]*P[j]*Pn[k] / _comb(3*n - 1, i + j + k) + return r_cm/6 + def arc_area(self): r""" (Signed) area swept out by ray from origin to curve. @@ -394,6 +461,15 @@ def arc_area(self): * (P[j, 0]*dP[k, 1] - P[j, 1]*dP[k, 0]) return (1/4)*area + def center_of_mass(self): + """Return the center of mass of the curve (not the filled curve!) + + Notes + ----- + Computed as the mean of the control points. + """ + return np.mean(self._cpoints, axis=0) + @classmethod def differentiate(cls, B): """Return the derivative of a BezierSegment, itself a BezierSegment""" diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index abb6e404d4be..b13bd25bec42 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -706,10 +706,147 @@ def signed_area(self, **kwargs): # add final implied CLOSEPOLY, if necessary if start_point is not None \ and not np.all(np.isclose(start_point, prev_point)): - B = BezierSegment(np.array([prev_point, start_point])) - area += B.arc_area() + Bclose = BezierSegment(np.array([prev_point, start_point])) + area += Bclose.arc_area() return area + def center_of_mass(self, dimension=None, **kwargs): + r""" + Center of mass of the path, assuming constant density. + + The center of mass is defined to be the expected value of a vector + located uniformly within either the filled area of the path + (:code:`dimension=2`) or the along path's edge (:code:`dimension=1`) or + along isolated points of the path (:code:`dimension=0`). Notice in + particular that for this definition, if the filled area is used, then + any 0- or 1-dimensional components of the path will not contribute to + the center of mass. Similarly, for if *dimension* is 1, then isolated + points in the path (i.e. "0-dimensional" strokes made up of only + :code:`Path.MOVETO`'s) will not contribute to the center of mass. + + For the 2d case, the center of mass is computed using the same + filling strategy as `signed_area`. So, if a path is self-intersecting, + the drawing rule "even-odd" is used and only the filled area is + counted, and all sub paths are treated as if they had been closed. That + is, if there is a MOVETO without a preceding CLOSEPOLY, one is added. + + For the 1d measure, the curve is averaged as-is (the implied CLOSEPOLY + is not added). + + For the 0d measure, any non-isolated points are ignored. + + Parameters + ---------- + dimension : 2, 1, or 0 (optional) + Whether to compute the center of mass by taking the expected value + of a position uniformly distributed within the filled path + (2D-measure), the path's edge (1D-measure), or between the + discrete, isolated points of the path (0D-measure), respectively. + By default, the intended dimension of the path is inferred by + checking first if `Path.signed_area` is non-zero (implying a + *dimension* of 2), then if the `Path.arc_length` is non-zero + (implying a *dimension* of 1), and finally falling back to the + counting measure (*dimension* of 0). + kwargs : Dict[str, object] + Passed thru to `Path.cleaned` via `Path.iter_bezier`. + + Returns + ------- + r_cm : (2,) np.array + The center of mass of the path. + + Raises + ------ + ValueError + An empty path has no well-defined center of mass. + + In addition, if a specific *dimension* is requested and that + dimension is not well-defined, an error is raised. This can happen + if:: + + 1) 2D expected value was requested but the path has zero area + 2) 1D expected value was requested but the path has only + `Path.MOVETO` directives + 3) 0D expected value was requested but the path has NO + subsequent `Path.MOVETO` directives. + + This error cannot be raised if the function is allowed to infer + what *dimension* to use. + """ + area = None + cleaned = self.cleaned(**kwargs) + move_codes = cleaned.codes == Path.MOVETO + if len(cleaned.codes) == 0: + raise ValueError("An empty path has no center of mass.") + if dimension is None: + dimension = 2 + area = cleaned.signed_area() + if not np.isclose(area, 0): + dimension -= 1 + if np.all(move_codes): + dimension = 0 + if dimension == 2: + # area computation can be expensive, make sure we don't repeat it + if area is None: + area = cleaned.signed_area() + if np.isclose(area, 0): + raise ValueError("2d expected value over empty area is " + "ill-defined.") + return cleaned._2d_center_of_mass(area) + if dimension == 1: + if np.all(move_codes): + raise ValueError("1d expected value over empty arc-length is " + "ill-defined.") + return cleaned._1d_center_of_mass() + if dimension == 0: + adjacent_moves = (move_codes[1:] + move_codes[:-1]) == 2 + if len(move_codes) > 1 and not np.any(adjacent_moves): + raise ValueError("0d expected value with no isolated points " + "is ill-defined.") + return cleaned._0d_center_of_mass() + + def _2d_center_of_mass(self, normalization=None): + #TODO: refactor this and signed_area (and maybe others, with + # close= parameter)? + if normalization is None: + normalization = self.signed_area() + r_cm = np.zeros(2) + prev_point = None + prev_code = None + start_point = None + for B, code in self.iter_bezier(): + if code == Path.MOVETO: + if prev_code is not None and prev_code is not Path.CLOSEPOLY: + Bclose = BezierSegment(np.array([prev_point, start_point])) + r_cm += Bclose.arc_center_of_mass() + start_point = B.control_points[0] + r_cm += B.arc_center_of_mass() + prev_point = B.control_points[-1] + prev_code = code + # add final implied CLOSEPOLY, if necessary + if start_point is not None \ + and not np.all(np.isclose(start_point, prev_point)): + Bclose = BezierSegment(np.array([prev_point, start_point])) + r_cm += Bclose.arc_center_of_mass() + return r_cm / normalization + + def _1d_center_of_mass(self): + r_cm = np.zeros(2) + Bs = list(self.iter_bezier()) + arc_lengths = np.array([B.arc_length() for B in Bs]) + r_cms = np.array([B.center_of_mass() for B in Bs]) + total_length = np.sum(arc_lengths) + return np.sum(r_cms*arc_lengths)/total_length + + def _0d_center_of_mass(self): + move_verts = self.codes + isolated_verts = move_verts.copy() + if len(move_verts) > 1: + isolated_verts[:-1] = (move_verts[:-1] + move_verts[1:]) == 2 + isolated_verts[-1] = move_verts[-1] + num_verts = np.sum(isolated_verts) + return np.sum(self.vertices[isolated_verts], axis=0)/num_verts + def interpolated(self, steps): """ Return a new path resampled to length N x steps. From c4628b7eddcd31786279eca897c2cc6f9fe684dc Mon Sep 17 00:00:00 2001 From: ImportanceOfBeingErnest Date: Tue, 10 Mar 2020 02:54:46 +0100 Subject: [PATCH 6/7] marker-transforms --- .../2020-03-16_markerstyle_normalization.rst | 12 ++++ .../scatter_piecharts.py | 65 +++++++++++++++++-- lib/matplotlib/markers.py | 30 +++++++-- lib/matplotlib/tests/test_marker.py | 24 ++++++- 4 files changed, 117 insertions(+), 14 deletions(-) create mode 100644 doc/users/next_whats_new/2020-03-16_markerstyle_normalization.rst diff --git a/doc/users/next_whats_new/2020-03-16_markerstyle_normalization.rst b/doc/users/next_whats_new/2020-03-16_markerstyle_normalization.rst new file mode 100644 index 000000000000..dce8f34e67bf --- /dev/null +++ b/doc/users/next_whats_new/2020-03-16_markerstyle_normalization.rst @@ -0,0 +1,12 @@ +Allow for custom marker scaling +------------------------------- +`~.markers.MarkerStyle` gained a keyword argument *normalization*, which may be +set to *"none"* to allow for custom paths to not be scaled.:: + + MarkerStyle(Path(...), normalization="none") + +`~.markers.MarkerStyle` also gained a `~.markers.MarkerStyle.set_transform` +method to set affine transformations to existing markers.:: + + m = MarkerStyle("d") + m.set_transform(m.get_transform() + Affine2D().rotate_deg(30)) diff --git a/examples/lines_bars_and_markers/scatter_piecharts.py b/examples/lines_bars_and_markers/scatter_piecharts.py index 6b2b4aa88824..b24f5fd2af8a 100644 --- a/examples/lines_bars_and_markers/scatter_piecharts.py +++ b/examples/lines_bars_and_markers/scatter_piecharts.py @@ -3,15 +3,19 @@ Scatter plot with pie chart markers =================================== -This example makes custom 'pie charts' as the markers for a scatter plot. - -Thanks to Manuel Metz for the example. +This example shows two methods to make custom 'pie charts' as the markers +for a scatter plot. """ +########################################################################## +# Manually creating marker vertices +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# + import numpy as np import matplotlib.pyplot as plt -# first define the ratios +# first define the cumulative ratios r1 = 0.2 # 20% r2 = r1 + 0.4 # 40% @@ -36,10 +40,55 @@ s3 = np.abs(xy3).max() fig, ax = plt.subplots() -ax.scatter(range(3), range(3), marker=xy1, s=s1**2 * sizes, facecolor='blue') -ax.scatter(range(3), range(3), marker=xy2, s=s2**2 * sizes, facecolor='green') -ax.scatter(range(3), range(3), marker=xy3, s=s3**2 * sizes, facecolor='red') +ax.scatter(range(3), range(3), marker=xy1, s=s1**2 * sizes, facecolor='C0') +ax.scatter(range(3), range(3), marker=xy2, s=s2**2 * sizes, facecolor='C1') +ax.scatter(range(3), range(3), marker=xy3, s=s3**2 * sizes, facecolor='C2') + +plt.show() + + +########################################################################## +# Using wedges as markers +# ~~~~~~~~~~~~~~~~~~~~~~~ +# +# An alternative is to create custom markers from the `~.path.Path` of a +# `~.patches.Wedge`, which might be more versatile. +# + +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.patches import Wedge +from matplotlib.markers import MarkerStyle + +# first define the ratios +r1 = 0.2 # 20% +r2 = r1 + 0.3 # 50% +r3 = 1 - r1 - r2 # 30% + + +def markers_from_ratios(ratios, width=1): + markers = [] + angles = 360*np.concatenate(([0], np.cumsum(ratios))) + for i in range(len(angles)-1): + # create a Wedge within the unit square in between the given angles... + w = Wedge((0, 0), 0.5, angles[i], angles[i+1], width=width/2) + # ... and create a custom Marker from its path. + markers.append(MarkerStyle(w.get_path(), normalization="none")) + return markers + +# define some sizes of the scatter marker +sizes = np.array([100, 200, 400, 800]) +# collect the markers and some colors +markers = markers_from_ratios([r1, r2, r3], width=0.6) +colors = plt.cm.tab10.colors[:len(markers)] + +fig, ax = plt.subplots() + +for marker, color in zip(markers, colors): + ax.scatter(range(len(sizes)), range(len(sizes)), marker=marker, s=sizes, + edgecolor="none", facecolor=color) +ax.margins(0.1) plt.show() ############################################################################# @@ -55,3 +104,5 @@ import matplotlib matplotlib.axes.Axes.scatter matplotlib.pyplot.scatter +matplotlib.patches.Wedge +matplotlib.markers.MarkerStyle diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index ccbdce01116b..3d36bb0c947c 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -201,7 +201,8 @@ class MarkerStyle: # TODO: Is this ever used as a non-constant? _point_size_reduction = 0.5 - def __init__(self, marker=None, fillstyle=None): + def __init__(self, marker=None, fillstyle=None, *, + normalization="classic"): """ Attributes ---------- @@ -213,12 +214,23 @@ def __init__(self, marker=None, fillstyle=None): Parameters ---------- - marker : str or array-like, optional, default: None + marker : str, array-like, `~.path.Path`, or `~.markers.MarkerStyle`, \ + default: None See the descriptions of possible markers in the module docstring. fillstyle : str, optional, default: 'full' 'full', 'left", 'right', 'bottom', 'top', 'none' + + normalization : str, {'classic', 'none'}, optional, default: "classic" + The normalization of the marker size. Only applies to custom paths + that are provided as array of vertices or `~.path.Path`. + Can take two values: + *'classic'*, being the default, makes sure the marker path is + normalized to fit within a unit-square by affine scaling. + *'none'*, in which case no scaling is performed on the marker path. """ + cbook._check_in_list(["classic", "none"], normalization=normalization) + self._normalize = normalization self._marker_function = None self.set_fillstyle(fillstyle) self.set_marker(marker) @@ -303,6 +315,13 @@ def get_path(self): def get_transform(self): return self._transform.frozen() + def set_transform(self, transform): + """ + Sets the transform of the marker. This is the transform by which the + marker path is transformed. + """ + self._transform = transform + def get_alt_path(self): return self._alt_path @@ -316,8 +335,9 @@ def _set_nothing(self): self._filled = False def _set_custom_marker(self, path): - rescale = np.max(np.abs(path.vertices)) # max of x's and y's. - self._transform = Affine2D().scale(0.5 / rescale) + if self._normalize == "classic": + rescale = np.max(np.abs(path.vertices)) # max of x's and y's. + self._transform = Affine2D().scale(0.5 / rescale) self._path = path def _set_path_marker(self): @@ -350,8 +370,6 @@ def _set_tuple_marker(self): def _set_mathtext_path(self): """ Draws mathtext markers '$...$' using TextPath object. - - Submitted by tcb """ from matplotlib.text import TextPath diff --git a/lib/matplotlib/tests/test_marker.py b/lib/matplotlib/tests/test_marker.py index e50746165792..f34c27896701 100644 --- a/lib/matplotlib/tests/test_marker.py +++ b/lib/matplotlib/tests/test_marker.py @@ -2,8 +2,9 @@ import matplotlib.pyplot as plt from matplotlib import markers from matplotlib.path import Path -from matplotlib.testing.decorators import check_figures_equal +from matplotlib.transforms import Affine2D +from matplotlib.testing.decorators import check_figures_equal import pytest @@ -133,3 +134,24 @@ def draw_ref_marker(y, style, size): ax_test.set(xlim=(-0.5, 1.5), ylim=(-0.5, 1.5)) ax_ref.set(xlim=(-0.5, 1.5), ylim=(-0.5, 1.5)) + + +@check_figures_equal(extensions=["png"]) +def test_marker_normalization(fig_test, fig_ref): + plt.style.use("mpl20") + + ax = fig_ref.subplots() + ax.margins(0.3) + ax.scatter([0, 1], [0, 0], s=400, marker="s", c="C2") + + ax = fig_test.subplots() + ax.margins(0.3) + # test normalize + p = Path([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]], closed=True) + p1 = p.transformed(Affine2D().translate(-.5, -.5).scale(20)) + m1 = markers.MarkerStyle(p1, normalization="none") + ax.scatter([0], [0], s=1, marker=m1, c="C2") + # test transform + m2 = markers.MarkerStyle("s") + m2.set_transform(m2.get_transform() + Affine2D().scale(20)) + ax.scatter([1], [0], s=1, marker=m2, c="C2") From f4ab8a2844afde475c8a197e52fb05c1fd31b9fe Mon Sep 17 00:00:00 2001 From: Bruno Beltran Date: Fri, 20 Mar 2020 15:56:42 -0700 Subject: [PATCH 7/7] proposed norm/center specs in MarkerStyle.__init__ --- lib/matplotlib/markers.py | 40 ++++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/lib/matplotlib/markers.py b/lib/matplotlib/markers.py index 3d36bb0c947c..447210bc6614 100644 --- a/lib/matplotlib/markers.py +++ b/lib/matplotlib/markers.py @@ -143,6 +143,11 @@ _empty_path = Path(np.empty((0, 2))) +normalization_options = ["none", "classic", "bbox", "bbox-width", "bbox-area", + "area"] +centering_options = ["none", "classic", "bbox", "mass"] + + class MarkerStyle: markers = { @@ -202,7 +207,7 @@ class MarkerStyle: _point_size_reduction = 0.5 def __init__(self, marker=None, fillstyle=None, *, - normalization="classic"): + normalization="classic", centering="classic"): """ Attributes ---------- @@ -221,16 +226,30 @@ def __init__(self, marker=None, fillstyle=None, *, fillstyle : str, optional, default: 'full' 'full', 'left", 'right', 'bottom', 'top', 'none' - normalization : str, {'classic', 'none'}, optional, default: "classic" - The normalization of the marker size. Only applies to custom paths - that are provided as array of vertices or `~.path.Path`. - Can take two values: - *'classic'*, being the default, makes sure the marker path is - normalized to fit within a unit-square by affine scaling. + normalization : str, optional, default: "classic" + The normalization of the marker size. Can take several values: + *'classic'*, being the default, makes sure custom marker paths are + normalized to fit within a unit-square by affine scaling (but + leaves built-in markers as-is). + *'bbox-width'*, ensure marker path fits in the unit square. + *'area'*, rescale so the marker path has unit "signed_area". + *'bbox-area'*, rescale so that the marker path's bbox has unit + area. *'none'*, in which case no scaling is performed on the marker path. + + centering : str, optional, default: "classic" + The centering of the marker. Can take several values: + *'none'*, being the default, does not translate the marker path. + The origin in path coordinates is the marker center in this case. + *'bbox'*, translates the marker path so that its bbox's center is + at the origin. + *'center-of-mass'*, translates the marker path so that its center + of mass it as the origin. See Path.center_of_mass for details. """ cbook._check_in_list(["classic", "none"], normalization=normalization) + cbook._check_in_list(["centering", "none"], centering=centering) self._normalize = normalization + self._center = centering self._marker_function = None self.set_fillstyle(fillstyle) self.set_marker(marker) @@ -319,6 +338,13 @@ def set_transform(self, transform): """ Sets the transform of the marker. This is the transform by which the marker path is transformed. + + In order to change the marker relative to its current state, make sure + to compose with the current transform. Remember that the transform on + the left of the addition side is applied first. For example: + + >>> spin = mpl.transforms.Affine2D().rotate_deg(90) + >>> marker.set_transform(marker.get_transform() + spin) """ self._transform = transform