Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 46a6025

Browse files
committed
BUGFIX: Bbox union and transform, better semantics
1 parent 6ec39d5 commit 46a6025

File tree

2 files changed

+154
-44
lines changed

2 files changed

+154
-44
lines changed

lib/matplotlib/tests/test_transforms.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,7 @@ def test_bbox_intersection():
471471
# r3 contains r2
472472
assert_bbox_eq(inter(r1, r3), r3)
473473
# no intersection
474-
assert inter(r1, r4) is None
474+
assert_bbox_eq(inter(r1, r4), mtransforms.Bbox.null())
475475
# single point
476476
assert_bbox_eq(inter(r1, r5), bbox_from_ext(1, 1, 1, 1))
477477

@@ -569,8 +569,10 @@ def test_log_transform():
569569

570570
def test_nan_overlap():
571571
a = mtransforms.Bbox([[0, 0], [1, 1]])
572-
b = mtransforms.Bbox([[0, 0], [1, np.nan]])
573-
assert not a.overlaps(b)
572+
with pytest.warns(RuntimeWarning,
573+
match="invalid value encountered in less"):
574+
b = mtransforms.Bbox([[0, 0], [1, np.nan]])
575+
assert not a.overlaps(b)
574576

575577

576578
def test_transform_angles():

lib/matplotlib/transforms.py

Lines changed: 149 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,10 @@ class BboxBase(TransformNode):
224224
is_bbox = True
225225
is_affine = True
226226

227+
@staticmethod
228+
def _empty_set_points():
229+
return np.array([[np.inf, np.inf], [-np.inf, -np.inf]])
230+
227231
if DEBUG:
228232
@staticmethod
229233
def _check(points):
@@ -655,30 +659,60 @@ def rotated(self, radians):
655659
return bbox
656660

657661
@staticmethod
658-
def union(bboxes):
662+
def union(bboxes, null_as_empty=True):
659663
"""Return a `Bbox` that contains all of the given *bboxes*."""
660664
if not len(bboxes):
661665
raise ValueError("'bboxes' cannot be empty")
662-
# needed for 1.14.4 < numpy_version < 1.16
663-
# can remove once we are at numpy >= 1.16
664-
with np.errstate(invalid='ignore'):
665-
x0 = np.min([bbox.xmin for bbox in bboxes])
666-
x1 = np.max([bbox.xmax for bbox in bboxes])
667-
y0 = np.min([bbox.ymin for bbox in bboxes])
668-
y1 = np.max([bbox.ymax for bbox in bboxes])
666+
if not null_as_empty:
667+
cbook.warn_deprecated(
668+
3.4, message="Previously, Bboxs with negative width or "
669+
"height could cause surprising results when unioned. In order "
670+
"to fix this, any Bbox with negative width or height will be "
671+
"treated as Bbox.null() (the empty set) starting in the next "
672+
"point release. To upgrade to this behavior now, please pass "
673+
"null_as_empty=True.")
674+
# needed for 1.14.4 < numpy_version < 1.16
675+
# can remove once we are at numpy >= 1.16
676+
with np.errstate(invalid='ignore'):
677+
x0 = np.min([bbox.xmin for bbox in bboxes])
678+
x1 = np.max([bbox.xmax for bbox in bboxes])
679+
y0 = np.min([bbox.ymin for bbox in bboxes])
680+
y1 = np.max([bbox.ymax for bbox in bboxes])
681+
else:
682+
# needed for 1.14.4 < numpy_version < 1.16
683+
# can remove once we are at numpy >= 1.16
684+
with np.errstate(invalid='ignore'):
685+
x0 = np.min([bbox.x0 for bbox in bboxes])
686+
x1 = np.max([bbox.x1 for bbox in bboxes])
687+
y0 = np.min([bbox.y0 for bbox in bboxes])
688+
y1 = np.max([bbox.y1 for bbox in bboxes])
669689
return Bbox([[x0, y0], [x1, y1]])
670690

671691
@staticmethod
672-
def intersection(bbox1, bbox2):
692+
def intersection(bbox1, bbox2, null_as_empty=True):
673693
"""
674694
Return the intersection of *bbox1* and *bbox2* if they intersect, or
675695
None if they don't.
676696
"""
677-
x0 = np.maximum(bbox1.xmin, bbox2.xmin)
678-
x1 = np.minimum(bbox1.xmax, bbox2.xmax)
679-
y0 = np.maximum(bbox1.ymin, bbox2.ymin)
680-
y1 = np.minimum(bbox1.ymax, bbox2.ymax)
681-
return Bbox([[x0, y0], [x1, y1]]) if x0 <= x1 and y0 <= y1 else None
697+
if not null_as_empty:
698+
cbook.warn_deprecated(
699+
3.4, message="Previously, Bboxs with negative width or "
700+
"height could cause surprising results under intersection. To "
701+
"fix this, any Bbox with negative width or height will be "
702+
"treated as Bbox.null() (the empty set) starting in the next "
703+
"point release. To upgrade to this behavior now, please pass "
704+
"null_as_empty=True.")
705+
#TODO: should probably be nanmax...
706+
x0 = np.maximum(bbox1.xmin, bbox2.xmin)
707+
x1 = np.minimum(bbox1.xmax, bbox2.xmax)
708+
y0 = np.maximum(bbox1.ymin, bbox2.ymin)
709+
y1 = np.minimum(bbox1.ymax, bbox2.ymax)
710+
else:
711+
x0 = np.maximum(bbox1.x0, bbox2.x0)
712+
x1 = np.minimum(bbox1.x1, bbox2.x1)
713+
y0 = np.maximum(bbox1.y0, bbox2.y0)
714+
y1 = np.minimum(bbox1.y1, bbox2.y1)
715+
return Bbox([[x0, y0], [x1, y1]])
682716

683717

684718
class Bbox(BboxBase):
@@ -708,16 +742,16 @@ class Bbox(BboxBase):
708742
709743
**Create from collections of points**
710744
711-
The "empty" object for accumulating Bboxs is the null bbox, which is a
712-
stand-in for the empty set.
745+
The "empty" object for accumulating Bboxs is the null bbox, which
746+
represents the empty set.
713747
714748
>>> Bbox.null()
715749
Bbox([[inf, inf], [-inf, -inf]])
716750
717751
Adding points to the null bbox will give you the bbox of those points.
718752
719753
>>> box = Bbox.null()
720-
>>> box.update_from_data_xy([[1, 1]])
754+
>>> box.update_from_data_xy([[1, 1]], ignore=False)
721755
>>> box
722756
Bbox([[1.0, 1.0], [1.0, 1.0]])
723757
>>> box.update_from_data_xy([[2, 3], [3, 2]], ignore=False)
@@ -736,33 +770,54 @@ class Bbox(BboxBase):
736770
default value of ``ignore`` can be changed at any time by code with
737771
access to your Bbox, for example using the method `~.Bbox.ignore`.
738772
739-
**Properties of the ``null`` bbox**
773+
**Create from a set of constraints**
740774
741-
.. note::
775+
The null object for accumulating Bboxs from constraints is the entire plane
776+
777+
>>> Bbox.unbounded()
778+
Bbox([[-inf, -inf], [inf, inf]])
779+
780+
By repeatedly intersecting Bboxs, we can refine the Bbox as needed
781+
782+
>>> constraints = Bbox.unbounded()
783+
>>> for box in [Bbox([[0, 0], [1, 1]]), Bbox([[-1, 1], [1, 1]])]:
784+
... constraints = Bbox.intersection(box, constraints)
785+
>>> constraints
786+
Bbox([[0.0, 1.0], [1.0, 1.0]])
787+
788+
**Algebra of Bboxs**
742789
743-
The current behavior of `Bbox.null()` may be surprising as it does
744-
not have all of the properties of the "empty set", and as such does
745-
not behave like a "zero" object in the mathematical sense. We may
746-
change that in the future (with a deprecation period).
790+
The family of all BBoxs forms a ring of sets, once we include the empty set
791+
(`Bbox.null`) and the full space (`Bbox.unbounded`).
747792
748-
The null bbox is the identity for intersections
793+
The unbounded bbox is the identity for intersections (the "multiplicative"
794+
identity)
749795
750-
>>> Bbox.intersection(Bbox([[1, 1], [3, 7]]), Bbox.null())
796+
>>> Bbox.intersection(Bbox([[1, 1], [3, 7]]), Bbox.unbounded())
751797
Bbox([[1.0, 1.0], [3.0, 7.0]])
752798
753-
except with itself, where it returns the full space.
799+
and union with the unbounded Bbox always returns the unbounded Bbox
754800
755-
>>> Bbox.intersection(Bbox.null(), Bbox.null())
801+
>>> Bbox.union([Bbox([[0, 0], [0, 0]]), Bbox.unbounded()])
756802
Bbox([[-inf, -inf], [inf, inf]])
757803
758-
A union containing null will always return the full space (not the other
759-
set!)
804+
The null Bbox is the identity for unions (the "additive" identity)
760805
761-
>>> Bbox.union([Bbox([[0, 0], [0, 0]]), Bbox.null()])
762-
Bbox([[-inf, -inf], [inf, inf]])
806+
>>> Bbox.union([Bbox.null(), Bbox([[1, 1], [3, 7]])])
807+
Bbox([[1.0, 1.0], [3.0, 7.0]])
808+
809+
and intersection with the null Bbox always returns the null Bbox
810+
811+
>>> Bbox.intersection(Bbox.null(), Bbox.unbounded())
812+
Bbox([[inf, inf], [-inf, -inf]])
813+
814+
.. note::
815+
816+
In order to ensure that there is a unique "empty set", all empty Bboxs
817+
are automatically converted to ``Bbox([[inf, inf], [-inf, -inf]])``.
763818
"""
764819

765-
def __init__(self, points, **kwargs):
820+
def __init__(self, points, null_as_empty=True, **kwargs):
766821
"""
767822
Parameters
768823
----------
@@ -774,6 +829,18 @@ def __init__(self, points, **kwargs):
774829
if points.shape != (2, 2):
775830
raise ValueError('Bbox points must be of the form '
776831
'"[[x0, y0], [x1, y1]]".')
832+
if np.any(np.diff(points, axis=0) < 0):
833+
if null_as_empty:
834+
points = self._empty_set_points()
835+
else:
836+
cbook.warn_deprecated(
837+
3.4, message="In order to guarantee that Bbox union and "
838+
"intersection can work correctly, Bboxs with negative "
839+
"width or height will always be converted to Bbox.null "
840+
"starting in the next point release. To silence this "
841+
"warning, explicitly pass explicitly set null_as_empty to "
842+
"True to enable this behavior now.")
843+
self._null_as_empty = null_as_empty
777844
self._points = points
778845
self._minpos = np.array([np.inf, np.inf])
779846
self._ignore = True
@@ -793,32 +860,48 @@ def invalidate(self):
793860
TransformNode.invalidate(self)
794861

795862
@staticmethod
796-
def unit():
863+
def unit(null_as_empty=True):
797864
"""Create a new unit `Bbox` from (0, 0) to (1, 1)."""
798-
return Bbox([[0, 0], [1, 1]])
865+
return Bbox([[0, 0], [1, 1]], null_as_empty=null_as_empty)
799866

800867
@staticmethod
801-
def null():
868+
def null(null_as_empty=True):
802869
"""Create a new null `Bbox` from (inf, inf) to (-inf, -inf)."""
803-
return Bbox([[np.inf, np.inf], [-np.inf, -np.inf]])
870+
return Bbox(Bbox._empty_set_points(), null_as_empty=null_as_empty)
804871

805872
@staticmethod
806-
def from_bounds(x0, y0, width, height):
873+
def unbounded(null_as_empty=True):
874+
"""Create a new unbounded `Bbox` from (-inf, -inf) to (inf, inf)."""
875+
return Bbox([[-np.inf, -np.inf], [np.inf, np.inf]],
876+
null_as_empty=null_as_empty)
877+
878+
@staticmethod
879+
def from_bounds(x0, y0, width, height, null_as_empty=True):
807880
"""
808881
Create a new `Bbox` from *x0*, *y0*, *width* and *height*.
809882
810-
*width* and *height* may be negative.
883+
If *width* or *height* are negative, `Bbox.null` is returned.
811884
"""
812-
return Bbox.from_extents(x0, y0, x0 + width, y0 + height)
885+
if width < 0 or height < 0 or np.isnan(width) or np.isnan(height):
886+
if null_as_empty:
887+
return Bbox.null()
888+
else:
889+
cbook.warn_deprecated(
890+
3.4, message="As of the next point release, "
891+
"Bbox.from_bounds will return the null Bbox if *width* or "
892+
"*height* are negative. To enable this behavior now, pass "
893+
"null_as_empty=True.")
894+
return Bbox.from_extents(x0, y0, x0 + width, y0 + height,
895+
null_as_empty=null_as_empty)
813896

814897
@staticmethod
815-
def from_extents(*args):
898+
def from_extents(*args, null_as_empty=True):
816899
"""
817900
Create a new Bbox from *left*, *bottom*, *right* and *top*.
818901
819902
The *y*-axis increases upwards.
820903
"""
821-
return Bbox(np.reshape(args, (2, 2)))
904+
return Bbox(np.reshape(args, (2, 2)), null_as_empty=null_as_empty)
822905

823906
def __format__(self, fmt):
824907
return (
@@ -910,41 +993,57 @@ def update_from_data_xy(self, xy, ignore=None, updatex=True, updatey=True):
910993
@BboxBase.x0.setter
911994
def x0(self, val):
912995
self._points[0, 0] = val
996+
if self._null_as_empty and self.x0 > self.x1:
997+
self._points = self._empty_set_points()
913998
self.invalidate()
914999

9151000
@BboxBase.y0.setter
9161001
def y0(self, val):
9171002
self._points[0, 1] = val
1003+
if self._null_as_empty and self.y0 > self.y1:
1004+
self._points = self._empty_set_points()
9181005
self.invalidate()
9191006

9201007
@BboxBase.x1.setter
9211008
def x1(self, val):
9221009
self._points[1, 0] = val
1010+
if self._null_as_empty and self.x0 > self.x1:
1011+
self._points = self._empty_set_points()
9231012
self.invalidate()
9241013

9251014
@BboxBase.y1.setter
9261015
def y1(self, val):
9271016
self._points[1, 1] = val
1017+
if self._null_as_empty and self.y0 > self.y1:
1018+
self._points = self._empty_set_points()
9281019
self.invalidate()
9291020

9301021
@BboxBase.p0.setter
9311022
def p0(self, val):
9321023
self._points[0] = val
1024+
if self._null_as_empty and (self.y0 > self.y1 or self.x0 > self.x1):
1025+
self._points = self._empty_set_points()
9331026
self.invalidate()
9341027

9351028
@BboxBase.p1.setter
9361029
def p1(self, val):
9371030
self._points[1] = val
1031+
if self._null_as_empty and (self.y0 > self.y1 or self.x0 > self.x1):
1032+
self._points = self._empty_set_points()
9381033
self.invalidate()
9391034

9401035
@BboxBase.intervalx.setter
9411036
def intervalx(self, interval):
9421037
self._points[:, 0] = interval
1038+
if self._null_as_empty and self.x0 > self.x1:
1039+
self._points = self._empty_set_points()
9431040
self.invalidate()
9441041

9451042
@BboxBase.intervaly.setter
9461043
def intervaly(self, interval):
9471044
self._points[:, 1] = interval
1045+
if self._null_as_empty and self.y0 > self.y1:
1046+
self._points = self._empty_set_points()
9481047
self.invalidate()
9491048

9501049
@BboxBase.bounds.setter
@@ -953,6 +1052,9 @@ def bounds(self, bounds):
9531052
points = np.array([[l, b], [l + w, b + h]], float)
9541053
if np.any(self._points != points):
9551054
self._points = points
1055+
if self._null_as_empty and \
1056+
(self.y0 > self.y1 or self.x0 > self.x1):
1057+
self._points = self._empty_set_points()
9561058
self.invalidate()
9571059

9581060
@property
@@ -983,6 +1085,9 @@ def set_points(self, points):
9831085
"""
9841086
if np.any(self._points != points):
9851087
self._points = points
1088+
if self._null_as_empty \
1089+
and (self.y0 > self.y1 or self.x0 > self.x1):
1090+
self._points = self._empty_set_points()
9861091
self.invalidate()
9871092

9881093
def set(self, other):
@@ -991,6 +1096,9 @@ def set(self, other):
9911096
"""
9921097
if np.any(self._points != other.get_points()):
9931098
self._points = other.get_points()
1099+
if self._null_as_empty \
1100+
and (self.y0 > self.y1 or self.x0 > self.x1):
1101+
self._points = self._empty_set_points()
9941102
self.invalidate()
9951103

9961104
def mutated(self):

0 commit comments

Comments
 (0)