@@ -222,6 +222,10 @@ class BboxBase(TransformNode):
222
222
is_bbox = True
223
223
is_affine = True
224
224
225
+ @staticmethod
226
+ def _empty_set_points ():
227
+ return np .array ([[np .inf , np .inf ], [- np .inf , - np .inf ]])
228
+
225
229
if DEBUG :
226
230
@staticmethod
227
231
def _check (points ):
@@ -653,7 +657,7 @@ def rotated(self, radians):
653
657
return bbox
654
658
655
659
@staticmethod
656
- def union (bboxes ):
660
+ def union (bboxes , null_as_empty = True ):
657
661
"""Return a `Bbox` that contains all of the given *bboxes*."""
658
662
if not len (bboxes ):
659
663
raise ValueError ("'bboxes' cannot be empty" )
@@ -664,19 +668,51 @@ def union(bboxes):
664
668
x1 = np .max ([bbox .x1 for bbox in bboxes ])
665
669
y0 = np .min ([bbox .y0 for bbox in bboxes ])
666
670
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 ])
667
692
return Bbox ([[x0 , y0 ], [x1 , y1 ]])
668
693
669
694
@staticmethod
670
- def intersection (bbox1 , bbox2 ):
695
+ def intersection (bbox1 , bbox2 , null_as_empty = True ):
671
696
"""
672
697
Return the intersection of *bbox1* and *bbox2* if they intersect, or
673
698
None if they don't.
674
699
"""
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 ]])
680
716
681
717
682
718
class Bbox (BboxBase ):
@@ -734,33 +770,54 @@ class Bbox(BboxBase):
734
770
default value of ``ignore`` can be changed at any time by code with
735
771
access to your Bbox, for example using the method `~.Bbox.ignore`.
736
772
737
- **Properties of the ``null`` bbox **
773
+ **Create from a set of constraints **
738
774
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**
740
789
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`).
745
792
746
- The null bbox is the identity for intersections
793
+ The unbounded bbox is the identity for intersections (the "multiplicative"
794
+ identity)
747
795
748
- >>> Bbox.intersection(Bbox([[1, 1], [3, 7]]), Bbox.null ())
796
+ >>> Bbox.intersection(Bbox([[1, 1], [3, 7]]), Bbox.unbounded ())
749
797
Bbox([[1.0, 1.0], [3.0, 7.0]])
750
798
751
- except with itself, where it returns the full space.
799
+ and union with the unbounded Bbox always returns the unbounded Bbox
752
800
753
- >>> Bbox.intersection( Bbox.null( ), Bbox.null() )
801
+ >>> Bbox.union([ Bbox([[0, 0], [0, 0]] ), Bbox.unbounded()] )
754
802
Bbox([[-inf, -inf], [inf, inf]])
755
803
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)
758
805
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]])``.
761
818
"""
762
819
763
- def __init__ (self , points , ** kwargs ):
820
+ def __init__ (self , points , null_as_empty = True , ** kwargs ):
764
821
"""
765
822
Parameters
766
823
----------
@@ -772,6 +829,15 @@ def __init__(self, points, **kwargs):
772
829
if points .shape != (2 , 2 ):
773
830
raise ValueError ('Bbox points must be of the form '
774
831
'"[[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
775
841
self ._points = points
776
842
self ._minpos = np .array ([np .inf , np .inf ])
777
843
self ._ignore = True
@@ -798,25 +864,33 @@ def unit():
798
864
@staticmethod
799
865
def null ():
800
866
"""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 ]])
802
873
803
874
@staticmethod
804
- def from_bounds (x0 , y0 , width , height ):
875
+ def from_bounds (x0 , y0 , width , height , null_as_empty = True ):
805
876
"""
806
877
Create a new `Bbox` from *x0*, *y0*, *width* and *height*.
807
878
808
879
*width* and *height* may be negative.
809
880
"""
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 )
811
885
812
886
@staticmethod
813
- def from_extents (* args ):
887
+ def from_extents (* args , null_as_empty = True ):
814
888
"""
815
889
Create a new Bbox from *left*, *bottom*, *right* and *top*.
816
890
817
891
The *y*-axis increases upwards.
818
892
"""
819
- return Bbox (np .reshape (args , (2 , 2 )))
893
+ return Bbox (np .reshape (args , (2 , 2 )), null_as_empty = null_as_empty )
820
894
821
895
def __format__ (self , fmt ):
822
896
return (
@@ -908,41 +982,57 @@ def update_from_data_xy(self, xy, ignore=None, updatex=True, updatey=True):
908
982
@BboxBase .x0 .setter
909
983
def x0 (self , val ):
910
984
self ._points [0 , 0 ] = val
985
+ if self ._null_as_empty and self .x0 > self .x1 :
986
+ self ._points = self ._empty_set_points ()
911
987
self .invalidate ()
912
988
913
989
@BboxBase .y0 .setter
914
990
def y0 (self , val ):
915
991
self ._points [0 , 1 ] = val
992
+ if self ._null_as_empty and self .y0 > self .y1 :
993
+ self ._points = self ._empty_set_points ()
916
994
self .invalidate ()
917
995
918
996
@BboxBase .x1 .setter
919
997
def x1 (self , val ):
920
998
self ._points [1 , 0 ] = val
999
+ if self ._null_as_empty and self .x0 > self .x1 :
1000
+ self ._points = self ._empty_set_points ()
921
1001
self .invalidate ()
922
1002
923
1003
@BboxBase .y1 .setter
924
1004
def y1 (self , val ):
925
1005
self ._points [1 , 1 ] = val
1006
+ if self ._null_as_empty and self .y0 > self .y1 :
1007
+ self ._points = self ._empty_set_points ()
926
1008
self .invalidate ()
927
1009
928
1010
@BboxBase .p0 .setter
929
1011
def p0 (self , val ):
930
1012
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 ()
931
1015
self .invalidate ()
932
1016
933
1017
@BboxBase .p1 .setter
934
1018
def p1 (self , val ):
935
1019
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 ()
936
1022
self .invalidate ()
937
1023
938
1024
@BboxBase .intervalx .setter
939
1025
def intervalx (self , interval ):
940
1026
self ._points [:, 0 ] = interval
1027
+ if self ._null_as_empty and self .x0 > self .x1 :
1028
+ self ._points = self ._empty_set_points ()
941
1029
self .invalidate ()
942
1030
943
1031
@BboxBase .intervaly .setter
944
1032
def intervaly (self , interval ):
945
1033
self ._points [:, 1 ] = interval
1034
+ if self ._null_as_empty and self .y0 > self .y1 :
1035
+ self ._points = self ._empty_set_points ()
946
1036
self .invalidate ()
947
1037
948
1038
@BboxBase .bounds .setter
@@ -951,6 +1041,9 @@ def bounds(self, bounds):
951
1041
points = np .array ([[l , b ], [l + w , b + h ]], float )
952
1042
if np .any (self ._points != points ):
953
1043
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 ()
954
1047
self .invalidate ()
955
1048
956
1049
@property
@@ -981,6 +1074,9 @@ def set_points(self, points):
981
1074
"""
982
1075
if np .any (self ._points != points ):
983
1076
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 ()
984
1080
self .invalidate ()
985
1081
986
1082
def set (self , other ):
@@ -989,6 +1085,9 @@ def set(self, other):
989
1085
"""
990
1086
if np .any (self ._points != other .get_points ()):
991
1087
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 ()
992
1091
self .invalidate ()
993
1092
994
1093
def mutated (self ):
0 commit comments