Skip to content

Commit c7df9f0

Browse files
committed
BUGFIX: unique empty set and "full" set
1 parent 9214657 commit c7df9f0

File tree

2 files changed

+131
-30
lines changed

2 files changed

+131
-30
lines changed

lib/matplotlib/tests/test_transforms.py

+5-3
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

+126-27
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,10 @@ class BboxBase(TransformNode):
222222
is_bbox = True
223223
is_affine = True
224224

225+
@staticmethod
226+
def _empty_set_points():
227+
return np.array([[np.inf, np.inf], [-np.inf, -np.inf]])
228+
225229
if DEBUG:
226230
@staticmethod
227231
def _check(points):
@@ -653,7 +657,7 @@ def rotated(self, radians):
653657
return bbox
654658

655659
@staticmethod
656-
def union(bboxes):
660+
def union(bboxes, null_as_empty=True):
657661
"""Return a `Bbox` that contains all of the given *bboxes*."""
658662
if not len(bboxes):
659663
raise ValueError("'bboxes' cannot be empty")
@@ -664,19 +668,51 @@ def union(bboxes):
664668
x1 = np.max([bbox.x1 for bbox in bboxes])
665669
y0 = np.min([bbox.y0 for bbox in bboxes])
666670
y1 = np.max([bbox.y1 for bbox in bboxes])
671+
if not null_as_empty:
672+
cbook.warn_deprecated(
673+
3.4, message="Bboxs will soon change their behavior to "
674+
"correctly treat empty Bboxs as empty sets in unions and "
675+
"intersections. Explicitly set null_as_empty=True to enable "
676+
"this behavior now.")
677+
# needed for 1.14.4 < numpy_version < 1.16
678+
# can remove once we are at numpy >= 1.16
679+
with np.errstate(invalid='ignore'):
680+
x0 = np.min([bbox.xmin for bbox in bboxes])
681+
x1 = np.max([bbox.xmax for bbox in bboxes])
682+
y0 = np.min([bbox.ymin for bbox in bboxes])
683+
y1 = np.max([bbox.ymax for bbox in bboxes])
684+
else:
685+
# needed for 1.14.4 < numpy_version < 1.16
686+
# can remove once we are at numpy >= 1.16
687+
with np.errstate(invalid='ignore'):
688+
x0 = np.min([bbox.x0 for bbox in bboxes])
689+
x1 = np.max([bbox.x1 for bbox in bboxes])
690+
y0 = np.min([bbox.y0 for bbox in bboxes])
691+
y1 = np.max([bbox.y1 for bbox in bboxes])
667692
return Bbox([[x0, y0], [x1, y1]])
668693

669694
@staticmethod
670-
def intersection(bbox1, bbox2):
695+
def intersection(bbox1, bbox2, null_as_empty=True):
671696
"""
672697
Return the intersection of *bbox1* and *bbox2* if they intersect, or
673698
None if they don't.
674699
"""
675-
x0 = np.maximum(bbox1.x0, bbox2.x0)
676-
x1 = np.minimum(bbox1.x1, bbox2.x1)
677-
y0 = np.maximum(bbox1.y0, bbox2.y0)
678-
y1 = np.minimum(bbox1.y1, bbox2.y1)
679-
return Bbox([[x0, y0], [x1, y1]]) if x0 <= x1 and y0 <= y1 else None
700+
if not null_as_empty:
701+
cbook.warn_deprecated(
702+
3.4, message="Bboxs will soon change their behavior to "
703+
"correctly treat empty Bboxs as empty sets in unions and "
704+
"intersections. Explicitly set null_as_empty=True to enable "
705+
"this behavior now.")
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]])
680716

681717

682718
class Bbox(BboxBase):
@@ -734,33 +770,54 @@ class Bbox(BboxBase):
734770
default value of ``ignore`` can be changed at any time by code with
735771
access to your Bbox, for example using the method `~.Bbox.ignore`.
736772
737-
**Properties of the ``null`` bbox**
773+
**Create from a set of constraints**
738774
739-
.. note::
775+
The null object for accumulating Bboxs from constrains 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**
740789
741-
The current behavior of `Bbox.null()` may be surprising as it does
742-
not have all of the properties of the "empty set", and as such does
743-
not behave like a "zero" object in the mathematical sense. We may
744-
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`).
745792
746-
The null bbox is the identity for intersections
793+
The unbounded bbox is the identity for intersections (the "multiplicative"
794+
identity)
747795
748-
>>> Bbox.intersection(Bbox([[1, 1], [3, 7]]), Bbox.null())
796+
>>> Bbox.intersection(Bbox([[1, 1], [3, 7]]), Bbox.unbounded())
749797
Bbox([[1.0, 1.0], [3.0, 7.0]])
750798
751-
except with itself, where it returns the full space.
799+
and union with the unbounded Bbox always returns the unbounded Bbox
752800
753-
>>> Bbox.intersection(Bbox.null(), Bbox.null())
801+
>>> Bbox.union([Bbox([[0, 0], [0, 0]]), Bbox.unbounded()])
754802
Bbox([[-inf, -inf], [inf, inf]])
755803
756-
A union containing null will always return the full space (not the other
757-
set!)
804+
The null Bbox is the identity for unions (the "additive" identity)
758805
759-
>>> Bbox.union([Bbox([[0, 0], [0, 0]]), Bbox.null()])
760-
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]])``.
761818
"""
762819

763-
def __init__(self, points, **kwargs):
820+
def __init__(self, points, null_as_empty=True, **kwargs):
764821
"""
765822
Parameters
766823
----------
@@ -772,6 +829,15 @@ def __init__(self, points, **kwargs):
772829
if points.shape != (2, 2):
773830
raise ValueError('Bbox points must be of the form '
774831
'"[[x0, y0], [x1, y1]]".')
832+
if not null_as_empty:
833+
cbook.warn_deprecated(
834+
3.4, message="Bboxs will soon change their behavior to "
835+
"correctly treat empty Bboxs as empty sets in unions and "
836+
"intersections. Explicitly set null_as_empty=True to enable "
837+
"this behavior now.")
838+
if null_as_empty and np.any(np.diff(points, axis=0) < 0):
839+
points = self._empty_set_points()
840+
self._null_as_empty = null_as_empty
775841
self._points = points
776842
self._minpos = np.array([np.inf, np.inf])
777843
self._ignore = True
@@ -798,25 +864,33 @@ def unit():
798864
@staticmethod
799865
def null():
800866
"""Create a new null `Bbox` from (inf, inf) to (-inf, -inf)."""
801-
return Bbox([[np.inf, np.inf], [-np.inf, -np.inf]])
867+
return Bbox(Bbox._empty_set_points())
868+
869+
@staticmethod
870+
def unbounded():
871+
"""Create a new unbounded `Bbox` from (-inf, -inf) to (inf, inf)."""
872+
return Bbox([[-np.inf, -np.inf], [np.inf, np.inf]])
802873

803874
@staticmethod
804-
def from_bounds(x0, y0, width, height):
875+
def from_bounds(x0, y0, width, height, null_as_empty=True):
805876
"""
806877
Create a new `Bbox` from *x0*, *y0*, *width* and *height*.
807878
808879
*width* and *height* may be negative.
809880
"""
810-
return Bbox.from_extents(x0, y0, x0 + width, y0 + height)
881+
if null_as_empty and width < 0 or height < 0:
882+
return Bbox.null()
883+
return Bbox.from_extents(x0, y0, x0 + width, y0 + height,
884+
null_as_empty=null_as_empty)
811885

812886
@staticmethod
813-
def from_extents(*args):
887+
def from_extents(*args, null_as_empty=True):
814888
"""
815889
Create a new Bbox from *left*, *bottom*, *right* and *top*.
816890
817891
The *y*-axis increases upwards.
818892
"""
819-
return Bbox(np.reshape(args, (2, 2)))
893+
return Bbox(np.reshape(args, (2, 2)), null_as_empty=null_as_empty)
820894

821895
def __format__(self, fmt):
822896
return (
@@ -908,41 +982,57 @@ def update_from_data_xy(self, xy, ignore=None, updatex=True, updatey=True):
908982
@BboxBase.x0.setter
909983
def x0(self, val):
910984
self._points[0, 0] = val
985+
if self._null_as_empty and self.x0 > self.x1:
986+
self._points = self._empty_set_points()
911987
self.invalidate()
912988

913989
@BboxBase.y0.setter
914990
def y0(self, val):
915991
self._points[0, 1] = val
992+
if self._null_as_empty and self.y0 > self.y1:
993+
self._points = self._empty_set_points()
916994
self.invalidate()
917995

918996
@BboxBase.x1.setter
919997
def x1(self, val):
920998
self._points[1, 0] = val
999+
if self._null_as_empty and self.x0 > self.x1:
1000+
self._points = self._empty_set_points()
9211001
self.invalidate()
9221002

9231003
@BboxBase.y1.setter
9241004
def y1(self, val):
9251005
self._points[1, 1] = val
1006+
if self._null_as_empty and self.y0 > self.y1:
1007+
self._points = self._empty_set_points()
9261008
self.invalidate()
9271009

9281010
@BboxBase.p0.setter
9291011
def p0(self, val):
9301012
self._points[0] = val
1013+
if self._null_as_empty and (self.y0 > self.y1 or self.x0 > self.x1):
1014+
self._points = self._empty_set_points()
9311015
self.invalidate()
9321016

9331017
@BboxBase.p1.setter
9341018
def p1(self, val):
9351019
self._points[1] = val
1020+
if self._null_as_empty and (self.y0 > self.y1 or self.x0 > self.x1):
1021+
self._points = self._empty_set_points()
9361022
self.invalidate()
9371023

9381024
@BboxBase.intervalx.setter
9391025
def intervalx(self, interval):
9401026
self._points[:, 0] = interval
1027+
if self._null_as_empty and self.x0 > self.x1:
1028+
self._points = self._empty_set_points()
9411029
self.invalidate()
9421030

9431031
@BboxBase.intervaly.setter
9441032
def intervaly(self, interval):
9451033
self._points[:, 1] = interval
1034+
if self._null_as_empty and self.y0 > self.y1:
1035+
self._points = self._empty_set_points()
9461036
self.invalidate()
9471037

9481038
@BboxBase.bounds.setter
@@ -951,6 +1041,9 @@ def bounds(self, bounds):
9511041
points = np.array([[l, b], [l + w, b + h]], float)
9521042
if np.any(self._points != points):
9531043
self._points = points
1044+
if self._null_as_empty and \
1045+
(self.y0 > self.y1 or self.x0 > self.x1):
1046+
self._points = self._empty_set_points()
9541047
self.invalidate()
9551048

9561049
@property
@@ -981,6 +1074,9 @@ def set_points(self, points):
9811074
"""
9821075
if np.any(self._points != points):
9831076
self._points = points
1077+
if self._null_as_empty \
1078+
and (self.y0 > self.y1 or self.x0 > self.x1):
1079+
self._points = self._empty_set_points()
9841080
self.invalidate()
9851081

9861082
def set(self, other):
@@ -989,6 +1085,9 @@ def set(self, other):
9891085
"""
9901086
if np.any(self._points != other.get_points()):
9911087
self._points = other.get_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()
9921091
self.invalidate()
9931092

9941093
def mutated(self):

0 commit comments

Comments
 (0)