From f70df4c7103069099a307053586252b755de213b Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 2 Oct 2020 21:43:26 -0400 Subject: [PATCH 1/5] Return minpos from _path.get_path_collection_extents. This is already calculated by the internal C++ code, but discarded at the end of the Python function. --- lib/matplotlib/path.py | 5 +++-- src/_path_wrapper.cpp | 10 ++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index a9a220479759..b0e984e17abc 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -1039,6 +1039,7 @@ def get_path_collection_extents( from .transforms import Bbox if len(paths) == 0: raise ValueError("No paths provided") - return Bbox.from_extents(*_path.get_path_collection_extents( + extents, minpos = _path.get_path_collection_extents( master_transform, paths, np.atleast_3d(transforms), - offsets, offset_transform)) + offsets, offset_transform) + return Bbox.from_extents(*extents) diff --git a/src/_path_wrapper.cpp b/src/_path_wrapper.cpp index 708d7d36e67a..e28043363047 100644 --- a/src/_path_wrapper.cpp +++ b/src/_path_wrapper.cpp @@ -250,7 +250,8 @@ static PyObject *Py_update_path_extents(PyObject *self, PyObject *args, PyObject "NNi", outextents.pyobj(), outminpos.pyobj(), changed); } -const char *Py_get_path_collection_extents__doc__ = "get_path_collection_extents("; +const char *Py_get_path_collection_extents__doc__ = "get_path_collection_extents(" + "master_transform, paths, transforms, offsets, offset_transform)"; static PyObject *Py_get_path_collection_extents(PyObject *self, PyObject *args, PyObject *kwds) { @@ -295,7 +296,12 @@ static PyObject *Py_get_path_collection_extents(PyObject *self, PyObject *args, extents(1, 0) = e.x1; extents(1, 1) = e.y1; - return extents.pyobj(); + npy_intp minposdims[] = { 2 }; + numpy::array_view minpos(minposdims); + minpos(0) = e.xm; + minpos(1) = e.ym; + + return Py_BuildValue("NN", extents.pyobj(), minpos.pyobj()); } const char *Py_point_in_path_collection__doc__ = From 7e69d180d7ee1c01c845db9c0233d92b5bf7ae96 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 2 Oct 2020 22:13:32 -0400 Subject: [PATCH 2/5] Propagate minpos from Collections to Axes.dataLim. This ensures that autoscaling on log scales is correct. --- lib/matplotlib/axes/_base.py | 8 +++++++- lib/matplotlib/collections.py | 12 ++++++------ lib/matplotlib/path.py | 2 +- lib/matplotlib/transforms.py | 10 +++++++--- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 26bc28dea71a..aa3d74757ce6 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -2000,7 +2000,13 @@ def add_collection(self, collection, autolim=True): # Make sure viewLim is not stale (mostly to match # pre-lazy-autoscale behavior, which is not really better). self._unstale_viewLim() - self.update_datalim(collection.get_datalim(self.transData)) + datalim = collection.get_datalim(self.transData) + # By definition, p0 <= minpos <= p1, so minpos would be + # unnecessary. However, we add minpos to the call so that + # self.dataLim will update its own minpos. This ensures that log + # scales see the correct minimum. + self.update_datalim( + np.row_stack([datalim.p0, datalim.minpos, datalim.p1])) self.stale = True return collection diff --git a/lib/matplotlib/collections.py b/lib/matplotlib/collections.py index 51c6c50a0305..c6ae27390d0e 100644 --- a/lib/matplotlib/collections.py +++ b/lib/matplotlib/collections.py @@ -274,11 +274,11 @@ def get_datalim(self, transData): # can properly have the axes limits set by their shape + # offset. LineCollections that have no offsets can # also use this algorithm (like streamplot). - result = mpath.get_path_collection_extents( - transform.get_affine(), paths, self.get_transforms(), + return mpath.get_path_collection_extents( + transform.get_affine() - transData, paths, + self.get_transforms(), transOffset.transform_non_affine(offsets), transOffset.get_affine().frozen()) - return result.transformed(transData.inverted()) if not self._offsetsNone: # this is for collections that have their paths (shapes) # in physical, axes-relative, or figure-relative units @@ -290,9 +290,9 @@ def get_datalim(self, transData): # note A-B means A B^{-1} offsets = np.ma.masked_invalid(offsets) if not offsets.mask.all(): - points = np.row_stack((offsets.min(axis=0), - offsets.max(axis=0))) - return transforms.Bbox(points) + bbox = transforms.Bbox.null() + bbox.update_from_data_xy(offsets) + return bbox return transforms.Bbox.null() def get_window_extent(self, renderer): diff --git a/lib/matplotlib/path.py b/lib/matplotlib/path.py index b0e984e17abc..c37e3026cc53 100644 --- a/lib/matplotlib/path.py +++ b/lib/matplotlib/path.py @@ -1042,4 +1042,4 @@ def get_path_collection_extents( extents, minpos = _path.get_path_collection_extents( master_transform, paths, np.atleast_3d(transforms), offsets, offset_transform) - return Bbox.from_extents(*extents) + return Bbox.from_extents(*extents, minpos=minpos) diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py index ba29e6719740..651ed09876c5 100644 --- a/lib/matplotlib/transforms.py +++ b/lib/matplotlib/transforms.py @@ -808,13 +808,17 @@ def from_bounds(x0, y0, width, height): return Bbox.from_extents(x0, y0, x0 + width, y0 + height) @staticmethod - def from_extents(*args): + def from_extents(*args, minpos=None): """ Create a new Bbox from *left*, *bottom*, *right* and *top*. - The *y*-axis increases upwards. + The *y*-axis increases upwards. Optionally, passing *minpos* will set + that property on the returned Bbox. """ - return Bbox(np.reshape(args, (2, 2))) + bbox = Bbox(np.reshape(args, (2, 2))) + if minpos is not None: + bbox._minpos[:] = minpos + return bbox def __format__(self, fmt): return ( From 279ec452a0f0b18a464a3b0280074cadf2cb2192 Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 2 Oct 2020 22:28:58 -0400 Subject: [PATCH 3/5] Add a test for scatter autolim on log scale. This test is a distilled out of #16552. --- lib/matplotlib/tests/test_collections.py | 28 +++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/tests/test_collections.py b/lib/matplotlib/tests/test_collections.py index f125f771f913..1f5deddda1ff 100644 --- a/lib/matplotlib/tests/test_collections.py +++ b/lib/matplotlib/tests/test_collections.py @@ -11,7 +11,7 @@ import matplotlib.transforms as mtransforms from matplotlib.collections import (Collection, LineCollection, EventCollection, PolyCollection) -from matplotlib.testing.decorators import image_comparison +from matplotlib.testing.decorators import check_figures_equal, image_comparison def generate_EventCollection_plot(): @@ -301,6 +301,32 @@ def test_add_collection(): assert ax.dataLim.bounds == bounds +@pytest.mark.style('mpl20') +@check_figures_equal(extensions=['png']) +def test_collection_log_datalim(fig_test, fig_ref): + # Data limits should respect the minimum x/y when using log scale. + x_vals = [4.38462e-6, 5.54929e-6, 7.02332e-6, 8.88889e-6, 1.12500e-5, + 1.42383e-5, 1.80203e-5, 2.28070e-5, 2.88651e-5, 3.65324e-5, + 4.62363e-5, 5.85178e-5, 7.40616e-5, 9.37342e-5, 1.18632e-4] + y_vals = [0.0, 0.1, 0.182, 0.332, 0.604, 1.1, 2.0, 3.64, 6.64, 12.1, 22.0, + 39.6, 71.3] + + x, y = np.meshgrid(x_vals, y_vals) + x = x.flatten() + y = y.flatten() + + ax_test = fig_test.subplots() + ax_test.set_xscale('log') + ax_test.set_yscale('log') + ax_test.margins = 0 + ax_test.scatter(x, y) + + ax_ref = fig_ref.subplots() + ax_ref.set_xscale('log') + ax_ref.set_yscale('log') + ax_ref.plot(x, y, marker="o", ls="") + + def test_quiver_limits(): ax = plt.axes() x, y = np.arange(8), np.arange(10) From 06786f792a0d756c474e357883e5d4b759ad62fb Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 2 Oct 2020 22:48:44 -0400 Subject: [PATCH 4/5] Only propagate minpos if it's been set. This is mostly for the sake of third-party `Collection` subclasses that might have overridden `get_datalim`. --- lib/matplotlib/axes/_base.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index aa3d74757ce6..39d7484c74d6 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -2001,12 +2001,15 @@ def add_collection(self, collection, autolim=True): # pre-lazy-autoscale behavior, which is not really better). self._unstale_viewLim() datalim = collection.get_datalim(self.transData) - # By definition, p0 <= minpos <= p1, so minpos would be - # unnecessary. However, we add minpos to the call so that - # self.dataLim will update its own minpos. This ensures that log - # scales see the correct minimum. - self.update_datalim( - np.row_stack([datalim.p0, datalim.minpos, datalim.p1])) + points = datalim.get_points() + if not np.isinf(datalim.minpos).all(): + # By definition, if minpos (minimum positive value) is set + # (i.e., non-inf), then min(points) <= minpos <= max(points), + # and minpos would be superfluous. However, we add minpos to + # the call so that self.dataLim will update its own minpos. + # This ensures that log scales see the correct minimum. + points = np.concatenate([points, [datalim.minpos]]) + self.update_datalim(points) self.stale = True return collection From e85ea1bbc6d75f51e2aa6cb1fee76c7b0977792b Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Fri, 16 Oct 2020 02:37:04 -0400 Subject: [PATCH 5/5] Add documentation for Bbox.minpos*. --- lib/matplotlib/transforms.py | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py index 651ed09876c5..b96c03a018e2 100644 --- a/lib/matplotlib/transforms.py +++ b/lib/matplotlib/transforms.py @@ -812,8 +812,17 @@ def from_extents(*args, minpos=None): """ Create a new Bbox from *left*, *bottom*, *right* and *top*. - The *y*-axis increases upwards. Optionally, passing *minpos* will set - that property on the returned Bbox. + 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: @@ -957,14 +966,35 @@ def bounds(self, bounds): @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):