@@ -224,6 +224,10 @@ class BboxBase(TransformNode):
224
224
is_bbox = True
225
225
is_affine = True
226
226
227
+ @staticmethod
228
+ def _empty_set_points ():
229
+ return np .array ([[np .inf , np .inf ], [- np .inf , - np .inf ]])
230
+
227
231
if DEBUG :
228
232
@staticmethod
229
233
def _check (points ):
@@ -655,30 +659,60 @@ def rotated(self, radians):
655
659
return bbox
656
660
657
661
@staticmethod
658
- def union (bboxes ):
662
+ def union (bboxes , null_as_empty = True ):
659
663
"""Return a `Bbox` that contains all of the given *bboxes*."""
660
664
if not len (bboxes ):
661
665
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 ])
669
689
return Bbox ([[x0 , y0 ], [x1 , y1 ]])
670
690
671
691
@staticmethod
672
- def intersection (bbox1 , bbox2 ):
692
+ def intersection (bbox1 , bbox2 , null_as_empty = True ):
673
693
"""
674
694
Return the intersection of *bbox1* and *bbox2* if they intersect, or
675
695
None if they don't.
676
696
"""
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 ]])
682
716
683
717
684
718
class Bbox (BboxBase ):
@@ -708,16 +742,16 @@ class Bbox(BboxBase):
708
742
709
743
**Create from collections of points**
710
744
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.
713
747
714
748
>>> Bbox.null()
715
749
Bbox([[inf, inf], [-inf, -inf]])
716
750
717
751
Adding points to the null bbox will give you the bbox of those points.
718
752
719
753
>>> box = Bbox.null()
720
- >>> box.update_from_data_xy([[1, 1]])
754
+ >>> box.update_from_data_xy([[1, 1]], ignore=False )
721
755
>>> box
722
756
Bbox([[1.0, 1.0], [1.0, 1.0]])
723
757
>>> box.update_from_data_xy([[2, 3], [3, 2]], ignore=False)
@@ -736,33 +770,54 @@ class Bbox(BboxBase):
736
770
default value of ``ignore`` can be changed at any time by code with
737
771
access to your Bbox, for example using the method `~.Bbox.ignore`.
738
772
739
- **Properties of the ``null`` bbox **
773
+ **Create from a set of constraints **
740
774
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**
742
789
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`).
747
792
748
- The null bbox is the identity for intersections
793
+ The unbounded bbox is the identity for intersections (the "multiplicative"
794
+ identity)
749
795
750
- >>> Bbox.intersection(Bbox([[1, 1], [3, 7]]), Bbox.null ())
796
+ >>> Bbox.intersection(Bbox([[1, 1], [3, 7]]), Bbox.unbounded ())
751
797
Bbox([[1.0, 1.0], [3.0, 7.0]])
752
798
753
- except with itself, where it returns the full space.
799
+ and union with the unbounded Bbox always returns the unbounded Bbox
754
800
755
- >>> Bbox.intersection( Bbox.null( ), Bbox.null() )
801
+ >>> Bbox.union([ Bbox([[0, 0], [0, 0]] ), Bbox.unbounded()] )
756
802
Bbox([[-inf, -inf], [inf, inf]])
757
803
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)
760
805
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]])``.
763
818
"""
764
819
765
- def __init__ (self , points , ** kwargs ):
820
+ def __init__ (self , points , null_as_empty = True , ** kwargs ):
766
821
"""
767
822
Parameters
768
823
----------
@@ -774,6 +829,18 @@ def __init__(self, points, **kwargs):
774
829
if points .shape != (2 , 2 ):
775
830
raise ValueError ('Bbox points must be of the form '
776
831
'"[[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
777
844
self ._points = points
778
845
self ._minpos = np .array ([np .inf , np .inf ])
779
846
self ._ignore = True
@@ -793,32 +860,48 @@ def invalidate(self):
793
860
TransformNode .invalidate (self )
794
861
795
862
@staticmethod
796
- def unit ():
863
+ def unit (null_as_empty = True ):
797
864
"""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 )
799
866
800
867
@staticmethod
801
- def null ():
868
+ def null (null_as_empty = True ):
802
869
"""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 )
804
871
805
872
@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 ):
807
880
"""
808
881
Create a new `Bbox` from *x0*, *y0*, *width* and *height*.
809
882
810
- *width* and *height* may be negative.
883
+ If *width* or *height* are negative, `Bbox.null` is returned .
811
884
"""
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 )
813
896
814
897
@staticmethod
815
- def from_extents (* args ):
898
+ def from_extents (* args , null_as_empty = True ):
816
899
"""
817
900
Create a new Bbox from *left*, *bottom*, *right* and *top*.
818
901
819
902
The *y*-axis increases upwards.
820
903
"""
821
- return Bbox (np .reshape (args , (2 , 2 )))
904
+ return Bbox (np .reshape (args , (2 , 2 )), null_as_empty = null_as_empty )
822
905
823
906
def __format__ (self , fmt ):
824
907
return (
@@ -910,41 +993,57 @@ def update_from_data_xy(self, xy, ignore=None, updatex=True, updatey=True):
910
993
@BboxBase .x0 .setter
911
994
def x0 (self , val ):
912
995
self ._points [0 , 0 ] = val
996
+ if self ._null_as_empty and self .x0 > self .x1 :
997
+ self ._points = self ._empty_set_points ()
913
998
self .invalidate ()
914
999
915
1000
@BboxBase .y0 .setter
916
1001
def y0 (self , val ):
917
1002
self ._points [0 , 1 ] = val
1003
+ if self ._null_as_empty and self .y0 > self .y1 :
1004
+ self ._points = self ._empty_set_points ()
918
1005
self .invalidate ()
919
1006
920
1007
@BboxBase .x1 .setter
921
1008
def x1 (self , val ):
922
1009
self ._points [1 , 0 ] = val
1010
+ if self ._null_as_empty and self .x0 > self .x1 :
1011
+ self ._points = self ._empty_set_points ()
923
1012
self .invalidate ()
924
1013
925
1014
@BboxBase .y1 .setter
926
1015
def y1 (self , val ):
927
1016
self ._points [1 , 1 ] = val
1017
+ if self ._null_as_empty and self .y0 > self .y1 :
1018
+ self ._points = self ._empty_set_points ()
928
1019
self .invalidate ()
929
1020
930
1021
@BboxBase .p0 .setter
931
1022
def p0 (self , val ):
932
1023
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 ()
933
1026
self .invalidate ()
934
1027
935
1028
@BboxBase .p1 .setter
936
1029
def p1 (self , val ):
937
1030
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 ()
938
1033
self .invalidate ()
939
1034
940
1035
@BboxBase .intervalx .setter
941
1036
def intervalx (self , interval ):
942
1037
self ._points [:, 0 ] = interval
1038
+ if self ._null_as_empty and self .x0 > self .x1 :
1039
+ self ._points = self ._empty_set_points ()
943
1040
self .invalidate ()
944
1041
945
1042
@BboxBase .intervaly .setter
946
1043
def intervaly (self , interval ):
947
1044
self ._points [:, 1 ] = interval
1045
+ if self ._null_as_empty and self .y0 > self .y1 :
1046
+ self ._points = self ._empty_set_points ()
948
1047
self .invalidate ()
949
1048
950
1049
@BboxBase .bounds .setter
@@ -953,6 +1052,9 @@ def bounds(self, bounds):
953
1052
points = np .array ([[l , b ], [l + w , b + h ]], float )
954
1053
if np .any (self ._points != points ):
955
1054
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 ()
956
1058
self .invalidate ()
957
1059
958
1060
@property
@@ -983,6 +1085,9 @@ def set_points(self, points):
983
1085
"""
984
1086
if np .any (self ._points != points ):
985
1087
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 ()
986
1091
self .invalidate ()
987
1092
988
1093
def set (self , other ):
@@ -991,6 +1096,9 @@ def set(self, other):
991
1096
"""
992
1097
if np .any (self ._points != other .get_points ()):
993
1098
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 ()
994
1102
self .invalidate ()
995
1103
996
1104
def mutated (self ):
0 commit comments