velocity_modelling.bounding_box
Module for handling bounding boxes in 2D space.
This module provides classes and functions for working with bounding boxes in 2D space, including calculating axis-aligned and minimum area bounding boxes, and computing various properties such as area and bearing. The bounding box dimensions are in metres except where otherwise mentioned.
Classes
- BoundingBox: Represents a 2D bounding box with properties and methods for calculations.
Functions
- axis_aligned_bounding_box: Returns an axis-aligned bounding box containing points.
- rotation_matrix: Returns the 2D rotation matrix for a given angle.
- minimum_area_bounding_box: Returns the smallest rectangle bounding points.
- minimum_area_bounding_box_for_polygons_masked: Returns a bounding box around masked polygons.
References
- BoundingBox wiki page: https://github.com/ucgmsim/qcore/wiki/BoundingBox
1""" 2Module for handling bounding boxes in 2D space. 3 4This module provides classes and functions for working with bounding boxes in 52D space, including calculating axis-aligned and minimum area bounding boxes, 6and computing various properties such as area and bearing. The bounding box 7dimensions are in metres except where otherwise mentioned. 8 9Classes 10------- 11- BoundingBox: Represents a 2D bounding box with properties and methods for calculations. 12 13Functions 14--------- 15- axis_aligned_bounding_box: Returns an axis-aligned bounding box containing points. 16- rotation_matrix: Returns the 2D rotation matrix for a given angle. 17- minimum_area_bounding_box: Returns the smallest rectangle bounding points. 18- minimum_area_bounding_box_for_polygons_masked: Returns a bounding box around masked polygons. 19 20References 21---------- 22- BoundingBox wiki page: https://github.com/ucgmsim/qcore/wiki/BoundingBox 23""" 24 25from typing import Self 26 27import numpy as np 28import numpy.typing as npt 29import shapely 30from shapely import Polygon 31 32from qcore import coordinates, geo 33 34 35class BoundingBox: 36 """Represents a 2D bounding box with properties and methods for calculations. 37 38 Attributes 39 ---------- 40 corners : np.ndarray 41 The corners of the bounding box in cartesian coordinates. The 42 order of the corners should be counter clock-wise from the bottom-left point 43 (minimum x, minimum y). 44 """ 45 46 bounds: npt.NDArray[np.float64] 47 48 def __init__(self, bounds: npt.NDArray[np.float64]): 49 """Create a bounding box from bounds in NZTM coordinates. 50 51 Parameters 52 ---------- 53 bounds : npt.NDArray[np.float64] 54 The bounds of the box. 55 """ 56 bottom_left_index = (bounds - np.mean(bounds, axis=0)).sum(axis=1).argmin() 57 bounds = np.copy(bounds) 58 bounds[[0, bottom_left_index]] = bounds[[bottom_left_index, 0]] 59 angles = np.arctan2(*(bounds[1:] - bounds[0]).T) 60 61 indices = np.argsort(angles, kind="stable") + 1 62 self.bounds = np.vstack([bounds[0], bounds[indices]]) 63 64 @property 65 def corners(self) -> np.ndarray: 66 """np.ndarray: the corners of the bounding box in (lat, lon) format.""" 67 return coordinates.nztm_to_wgs_depth(self.bounds) 68 69 @classmethod 70 def from_centroid_bearing_extents( 71 cls, 72 centroid: npt.ArrayLike, 73 bearing: float, 74 extent_x: float, 75 extent_y: float, 76 ) -> Self: 77 """Create a bounding box from a centroid, bearing, and size. 78 79 The x and y-directions are determined relative to the bearing, 80 81 N y-direction = bearing 82 │ / 83 │ / 84 │ / 85 │ / 86 │/ 87 ■ 88 ╲ 89 ╲ 90 ╲ 91 ╲ x-direction = bearing + 90 92 93 Parameters 94 ---------- 95 centroid : np.ndarray 96 The centre of the bounding box (lat, lon). 97 bearing : float 98 A bearing from north for the bounding box, in degrees. 99 extent_x : float 100 The length along the x-direction of the bounding box, in 101 kilometres. 102 extent_y : float 103 The length along the y-direction of the bounding box, in 104 kilometres. 105 106 Returns 107 ------- 108 Self 109 The bounding box with the given centre, bearing, and 110 length along the x and y-directions. 111 """ 112 centroid = np.asarray(centroid) 113 corner_offset = ( 114 np.array( 115 [[-1 / 2, -1 / 2], [1 / 2, -1 / 2], [1 / 2, 1 / 2], [-1 / 2, 1 / 2]] 116 ) 117 * np.array([extent_y, extent_x]) 118 * 1000 119 ) @ geo.rotation_matrix(np.radians(-bearing)) 120 return cls(coordinates.wgs_depth_to_nztm(centroid) + corner_offset) 121 122 @classmethod 123 def bounding_box_for_geometry( 124 cls, geometry: shapely.Geometry, axis_aligned: bool = False 125 ) -> Self: 126 """Return a bounding box that minimally encloses a geometry. 127 128 Parameters 129 ---------- 130 geometry : shapely.Geometry 131 The geometry to enclose. 132 axis_aligned : bool 133 If True, ensure that the bounding box is axis-aligned. 134 135 Returns 136 ------- 137 Self 138 The bounding box for this geometry. 139 140 Raises 141 ------ 142 ValueError 143 If the geometry does not have a well-defined bounding box. 144 This occurs if the geometry is degenerate (either a line 145 or a point). 146 """ 147 if axis_aligned: 148 bounding_box_polygon = shapely.envelope(geometry).normalize() 149 else: 150 bounding_box_polygon = shapely.oriented_envelope(geometry).normalize() 151 if not ( 152 isinstance(bounding_box_polygon, shapely.Polygon) 153 and len(bounding_box_polygon.exterior.coords) - 1 == 4 154 ): 155 raise ValueError("Ill-defined geometry for bounding box.") 156 return cls(np.array(bounding_box_polygon.exterior.coords)[:-1]) 157 158 @classmethod 159 def from_wgs84_coordinates(cls, corner_coordinates: npt.ArrayLike) -> Self: 160 """Construct a bounding box from a list of corners. 161 162 Parameters 163 ---------- 164 corner_coordinates : np.ndarray 165 The corners in (lat, lon) format. 166 167 Returns 168 ------- 169 Self 170 The bounding box represented by these corners. 171 """ 172 return cls(np.asarray(coordinates.wgs_depth_to_nztm(corner_coordinates))) 173 174 @property 175 def origin(self) -> npt.NDArray[np.float64]: 176 """np.ndarray: The origin of the bounding box.""" 177 return coordinates.nztm_to_wgs_depth(np.mean(self.bounds, axis=0)) 178 179 @property 180 def extent_x(self) -> np.float64: 181 """float: The extent along the x-axis of the bounding box (in km).""" 182 return np.linalg.norm(self.bounds[1] - self.bounds[0]) / 1000 183 184 @property 185 def extent_y(self) -> np.float64: 186 """float: The extent along the y-axis of the bounding box (in km).""" 187 return np.linalg.norm(self.bounds[2] - self.bounds[1]) / 1000 188 189 @property 190 def bearing(self) -> np.float64: 191 """float: The bearing of the bounding box.""" 192 north_direction = np.array([1, 0, 0]) 193 up_direction = np.array([0, 0, 1]) 194 vertical_direction = np.append(self.bounds[-1] - self.bounds[0], 0) 195 return geo.oriented_bearing_wrt_normal( 196 north_direction, vertical_direction, up_direction 197 ) 198 199 @property 200 def great_circle_bearing(self) -> np.float64: 201 """float: The great-circle bearing of the bounding box. 202 203 This returns the bearing of the bounding box in WGS84 204 coordinate space (as opposed to in the NZTM coordinate space). 205 """ 206 return coordinates.nztm_bearing_to_great_circle_bearing( 207 self.origin, self.extent_y / 2, self.bearing 208 ) 209 210 @property 211 def area(self) -> np.float64: 212 """float: The area of the bounding box.""" 213 return self.extent_x * self.extent_y 214 215 @property 216 def polygon(self) -> Polygon: 217 """Polygon: The shapely geometry for the bounding box.""" 218 return Polygon(np.append(self.bounds, np.atleast_2d(self.bounds[0]), axis=0)) 219 220 def contains(self, points: npt.ArrayLike) -> bool | npt.NDArray[np.bool_]: 221 """Filter a list of points by whether they are contained in the bounding box. 222 223 Parameters 224 ---------- 225 points : np.array 226 The points to filter. 227 228 Returns 229 ------- 230 bool or array of bools 231 A boolean mask of the points in the bounding box. 232 """ 233 points = np.asarray(points) 234 offset = coordinates.wgs_depth_to_nztm(points) - self.bounds[0] 235 frame = np.array( 236 [self.bounds[1] - self.bounds[0], self.bounds[-1] - self.bounds[0]] 237 ) 238 if offset.ndim > 1: 239 offset = offset.T 240 local_coordinates = np.linalg.solve(frame.T, offset) 241 242 return np.all( 243 ((local_coordinates > 0) | np.isclose(local_coordinates, 0, atol=1e-6)) 244 & ((local_coordinates < 1) | np.isclose(local_coordinates, 1, atol=1e-6)), 245 axis=0, 246 ) 247 248 def local_coordinates_to_wgs_depth( 249 self, 250 local_coords: npt.ArrayLike, 251 ) -> npt.NDArray[np.float64]: 252 """Convert bounding box coordinates to global coordinates. 253 254 Parameters 255 ---------- 256 local_coordinates : np.ndarray 257 Local coordinates to convert. Local coordinates are 2D 258 coordinates (x, y) given for a bounding box, where x 259 represents displacement along the x-direction, and y 260 displacement along the y-direction (see diagram below). 261 262 1 1 263 ┌─────────────────────┐ ^ 264 │ │ │ 265 │ │ │ 266 │ │ │ +y 267 │ │ │ 268 │ │ │ 269 └─────────────────────┘ │ 270 0 0 ─────────────────> 271 +x 272 Returns 273 ------- 274 np.ndarray 275 An vector of (lat, lon) transformed coordinates. 276 """ 277 frame = np.array( 278 [self.bounds[1] - self.bounds[0], self.bounds[-1] - self.bounds[0]] 279 ) 280 nztm_coords = self.bounds[0] + local_coords @ frame 281 return coordinates.nztm_to_wgs_depth(nztm_coords) 282 283 def wgs_depth_coordinates_to_local_coordinates( 284 self, global_coords: npt.ArrayLike 285 ) -> npt.NDArray[np.float64]: 286 """Convert coordinates (lat, lon) to bounding box coordinates (x, y). 287 288 See `BoundingBox.local_coordinates_to_wgs_depth` for a description of 289 bounding box coordinates. 290 291 Parameters 292 ---------- 293 global_coordinates : np.ndarray 294 Global coordinates to convert. 295 296 Returns 297 ------- 298 np.ndarray 299 Coordinates (x, y) representing the position of 300 global_coordinates in bounding box coordinates. 301 302 Raises 303 ------ 304 ValueError 305 If the given coordinates do not lie in the bounding box. 306 """ 307 global_coords = np.asarray(global_coords) 308 309 frame = np.array( 310 [self.bounds[1] - self.bounds[0], self.bounds[-1] - self.bounds[0]] 311 ) 312 offset = coordinates.wgs_depth_to_nztm(global_coords) - self.bounds[0] 313 if offset.ndim > 1: 314 offset = offset.T 315 local_coordinates = np.linalg.solve(frame.T, offset) 316 if not np.all( 317 ((local_coordinates > 0) | np.isclose(local_coordinates, 0, atol=1e-6)) 318 & ((local_coordinates < 1) | np.isclose(local_coordinates, 1, atol=1e-6)) 319 ): 320 raise ValueError("Specified coordinates do not lie in bounding box.") 321 local_coordinates = np.clip(local_coordinates, 0, 1) 322 return local_coordinates.T 323 324 def __repr__(self): 325 """A representation of the bounding box.""" 326 cls = self.__class__.__name__ 327 return f"{cls}(centre={self.origin}, bearing={self.bearing}, extent_x={self.extent_x}, extent_y={self.extent_y}, corners={self.corners})" 328 329 330def minimum_area_bounding_box_for_polygons_masked( 331 must_include: list[Polygon], may_include: list[Polygon], mask: Polygon 332) -> BoundingBox: 333 """Find a minimum area bounding box for the points must_include ∪ (may_include ∩ mask). 334 335 Parameters 336 ---------- 337 must_include : list[Polygon] 338 List of polygons the bounding box must include. 339 may_include : list[Polygon] 340 List of polygons the bounding box will include portions of, when inside of mask. 341 mask : Polygon 342 The masking polygon. 343 344 Returns 345 ------- 346 BoundingBox 347 The smallest box containing all the points of `must_include`, and all the 348 points of `may_include` that lie within the bounds of `mask`. 349 350 """ 351 may_include_polygon = shapely.normalize(shapely.union_all(may_include)) 352 must_include_polygon = shapely.normalize(shapely.union_all(must_include)) 353 bounding_polygon = shapely.normalize( 354 shapely.union( 355 must_include_polygon, shapely.intersection(may_include_polygon, mask) 356 ) 357 ) 358 return BoundingBox.bounding_box_for_geometry(bounding_polygon)
36class BoundingBox: 37 """Represents a 2D bounding box with properties and methods for calculations. 38 39 Attributes 40 ---------- 41 corners : np.ndarray 42 The corners of the bounding box in cartesian coordinates. The 43 order of the corners should be counter clock-wise from the bottom-left point 44 (minimum x, minimum y). 45 """ 46 47 bounds: npt.NDArray[np.float64] 48 49 def __init__(self, bounds: npt.NDArray[np.float64]): 50 """Create a bounding box from bounds in NZTM coordinates. 51 52 Parameters 53 ---------- 54 bounds : npt.NDArray[np.float64] 55 The bounds of the box. 56 """ 57 bottom_left_index = (bounds - np.mean(bounds, axis=0)).sum(axis=1).argmin() 58 bounds = np.copy(bounds) 59 bounds[[0, bottom_left_index]] = bounds[[bottom_left_index, 0]] 60 angles = np.arctan2(*(bounds[1:] - bounds[0]).T) 61 62 indices = np.argsort(angles, kind="stable") + 1 63 self.bounds = np.vstack([bounds[0], bounds[indices]]) 64 65 @property 66 def corners(self) -> np.ndarray: 67 """np.ndarray: the corners of the bounding box in (lat, lon) format.""" 68 return coordinates.nztm_to_wgs_depth(self.bounds) 69 70 @classmethod 71 def from_centroid_bearing_extents( 72 cls, 73 centroid: npt.ArrayLike, 74 bearing: float, 75 extent_x: float, 76 extent_y: float, 77 ) -> Self: 78 """Create a bounding box from a centroid, bearing, and size. 79 80 The x and y-directions are determined relative to the bearing, 81 82 N y-direction = bearing 83 │ / 84 │ / 85 │ / 86 │ / 87 │/ 88 ■ 89 ╲ 90 ╲ 91 ╲ 92 ╲ x-direction = bearing + 90 93 94 Parameters 95 ---------- 96 centroid : np.ndarray 97 The centre of the bounding box (lat, lon). 98 bearing : float 99 A bearing from north for the bounding box, in degrees. 100 extent_x : float 101 The length along the x-direction of the bounding box, in 102 kilometres. 103 extent_y : float 104 The length along the y-direction of the bounding box, in 105 kilometres. 106 107 Returns 108 ------- 109 Self 110 The bounding box with the given centre, bearing, and 111 length along the x and y-directions. 112 """ 113 centroid = np.asarray(centroid) 114 corner_offset = ( 115 np.array( 116 [[-1 / 2, -1 / 2], [1 / 2, -1 / 2], [1 / 2, 1 / 2], [-1 / 2, 1 / 2]] 117 ) 118 * np.array([extent_y, extent_x]) 119 * 1000 120 ) @ geo.rotation_matrix(np.radians(-bearing)) 121 return cls(coordinates.wgs_depth_to_nztm(centroid) + corner_offset) 122 123 @classmethod 124 def bounding_box_for_geometry( 125 cls, geometry: shapely.Geometry, axis_aligned: bool = False 126 ) -> Self: 127 """Return a bounding box that minimally encloses a geometry. 128 129 Parameters 130 ---------- 131 geometry : shapely.Geometry 132 The geometry to enclose. 133 axis_aligned : bool 134 If True, ensure that the bounding box is axis-aligned. 135 136 Returns 137 ------- 138 Self 139 The bounding box for this geometry. 140 141 Raises 142 ------ 143 ValueError 144 If the geometry does not have a well-defined bounding box. 145 This occurs if the geometry is degenerate (either a line 146 or a point). 147 """ 148 if axis_aligned: 149 bounding_box_polygon = shapely.envelope(geometry).normalize() 150 else: 151 bounding_box_polygon = shapely.oriented_envelope(geometry).normalize() 152 if not ( 153 isinstance(bounding_box_polygon, shapely.Polygon) 154 and len(bounding_box_polygon.exterior.coords) - 1 == 4 155 ): 156 raise ValueError("Ill-defined geometry for bounding box.") 157 return cls(np.array(bounding_box_polygon.exterior.coords)[:-1]) 158 159 @classmethod 160 def from_wgs84_coordinates(cls, corner_coordinates: npt.ArrayLike) -> Self: 161 """Construct a bounding box from a list of corners. 162 163 Parameters 164 ---------- 165 corner_coordinates : np.ndarray 166 The corners in (lat, lon) format. 167 168 Returns 169 ------- 170 Self 171 The bounding box represented by these corners. 172 """ 173 return cls(np.asarray(coordinates.wgs_depth_to_nztm(corner_coordinates))) 174 175 @property 176 def origin(self) -> npt.NDArray[np.float64]: 177 """np.ndarray: The origin of the bounding box.""" 178 return coordinates.nztm_to_wgs_depth(np.mean(self.bounds, axis=0)) 179 180 @property 181 def extent_x(self) -> np.float64: 182 """float: The extent along the x-axis of the bounding box (in km).""" 183 return np.linalg.norm(self.bounds[1] - self.bounds[0]) / 1000 184 185 @property 186 def extent_y(self) -> np.float64: 187 """float: The extent along the y-axis of the bounding box (in km).""" 188 return np.linalg.norm(self.bounds[2] - self.bounds[1]) / 1000 189 190 @property 191 def bearing(self) -> np.float64: 192 """float: The bearing of the bounding box.""" 193 north_direction = np.array([1, 0, 0]) 194 up_direction = np.array([0, 0, 1]) 195 vertical_direction = np.append(self.bounds[-1] - self.bounds[0], 0) 196 return geo.oriented_bearing_wrt_normal( 197 north_direction, vertical_direction, up_direction 198 ) 199 200 @property 201 def great_circle_bearing(self) -> np.float64: 202 """float: The great-circle bearing of the bounding box. 203 204 This returns the bearing of the bounding box in WGS84 205 coordinate space (as opposed to in the NZTM coordinate space). 206 """ 207 return coordinates.nztm_bearing_to_great_circle_bearing( 208 self.origin, self.extent_y / 2, self.bearing 209 ) 210 211 @property 212 def area(self) -> np.float64: 213 """float: The area of the bounding box.""" 214 return self.extent_x * self.extent_y 215 216 @property 217 def polygon(self) -> Polygon: 218 """Polygon: The shapely geometry for the bounding box.""" 219 return Polygon(np.append(self.bounds, np.atleast_2d(self.bounds[0]), axis=0)) 220 221 def contains(self, points: npt.ArrayLike) -> bool | npt.NDArray[np.bool_]: 222 """Filter a list of points by whether they are contained in the bounding box. 223 224 Parameters 225 ---------- 226 points : np.array 227 The points to filter. 228 229 Returns 230 ------- 231 bool or array of bools 232 A boolean mask of the points in the bounding box. 233 """ 234 points = np.asarray(points) 235 offset = coordinates.wgs_depth_to_nztm(points) - self.bounds[0] 236 frame = np.array( 237 [self.bounds[1] - self.bounds[0], self.bounds[-1] - self.bounds[0]] 238 ) 239 if offset.ndim > 1: 240 offset = offset.T 241 local_coordinates = np.linalg.solve(frame.T, offset) 242 243 return np.all( 244 ((local_coordinates > 0) | np.isclose(local_coordinates, 0, atol=1e-6)) 245 & ((local_coordinates < 1) | np.isclose(local_coordinates, 1, atol=1e-6)), 246 axis=0, 247 ) 248 249 def local_coordinates_to_wgs_depth( 250 self, 251 local_coords: npt.ArrayLike, 252 ) -> npt.NDArray[np.float64]: 253 """Convert bounding box coordinates to global coordinates. 254 255 Parameters 256 ---------- 257 local_coordinates : np.ndarray 258 Local coordinates to convert. Local coordinates are 2D 259 coordinates (x, y) given for a bounding box, where x 260 represents displacement along the x-direction, and y 261 displacement along the y-direction (see diagram below). 262 263 1 1 264 ┌─────────────────────┐ ^ 265 │ │ │ 266 │ │ │ 267 │ │ │ +y 268 │ │ │ 269 │ │ │ 270 └─────────────────────┘ │ 271 0 0 ─────────────────> 272 +x 273 Returns 274 ------- 275 np.ndarray 276 An vector of (lat, lon) transformed coordinates. 277 """ 278 frame = np.array( 279 [self.bounds[1] - self.bounds[0], self.bounds[-1] - self.bounds[0]] 280 ) 281 nztm_coords = self.bounds[0] + local_coords @ frame 282 return coordinates.nztm_to_wgs_depth(nztm_coords) 283 284 def wgs_depth_coordinates_to_local_coordinates( 285 self, global_coords: npt.ArrayLike 286 ) -> npt.NDArray[np.float64]: 287 """Convert coordinates (lat, lon) to bounding box coordinates (x, y). 288 289 See `BoundingBox.local_coordinates_to_wgs_depth` for a description of 290 bounding box coordinates. 291 292 Parameters 293 ---------- 294 global_coordinates : np.ndarray 295 Global coordinates to convert. 296 297 Returns 298 ------- 299 np.ndarray 300 Coordinates (x, y) representing the position of 301 global_coordinates in bounding box coordinates. 302 303 Raises 304 ------ 305 ValueError 306 If the given coordinates do not lie in the bounding box. 307 """ 308 global_coords = np.asarray(global_coords) 309 310 frame = np.array( 311 [self.bounds[1] - self.bounds[0], self.bounds[-1] - self.bounds[0]] 312 ) 313 offset = coordinates.wgs_depth_to_nztm(global_coords) - self.bounds[0] 314 if offset.ndim > 1: 315 offset = offset.T 316 local_coordinates = np.linalg.solve(frame.T, offset) 317 if not np.all( 318 ((local_coordinates > 0) | np.isclose(local_coordinates, 0, atol=1e-6)) 319 & ((local_coordinates < 1) | np.isclose(local_coordinates, 1, atol=1e-6)) 320 ): 321 raise ValueError("Specified coordinates do not lie in bounding box.") 322 local_coordinates = np.clip(local_coordinates, 0, 1) 323 return local_coordinates.T 324 325 def __repr__(self): 326 """A representation of the bounding box.""" 327 cls = self.__class__.__name__ 328 return f"{cls}(centre={self.origin}, bearing={self.bearing}, extent_x={self.extent_x}, extent_y={self.extent_y}, corners={self.corners})"
Represents a 2D bounding box with properties and methods for calculations.
Attributes
- corners (np.ndarray): The corners of the bounding box in cartesian coordinates. The order of the corners should be counter clock-wise from the bottom-left point (minimum x, minimum y).
49 def __init__(self, bounds: npt.NDArray[np.float64]): 50 """Create a bounding box from bounds in NZTM coordinates. 51 52 Parameters 53 ---------- 54 bounds : npt.NDArray[np.float64] 55 The bounds of the box. 56 """ 57 bottom_left_index = (bounds - np.mean(bounds, axis=0)).sum(axis=1).argmin() 58 bounds = np.copy(bounds) 59 bounds[[0, bottom_left_index]] = bounds[[bottom_left_index, 0]] 60 angles = np.arctan2(*(bounds[1:] - bounds[0]).T) 61 62 indices = np.argsort(angles, kind="stable") + 1 63 self.bounds = np.vstack([bounds[0], bounds[indices]])
Create a bounding box from bounds in NZTM coordinates.
Parameters
- bounds (npt.NDArray[np.float64]): The bounds of the box.
65 @property 66 def corners(self) -> np.ndarray: 67 """np.ndarray: the corners of the bounding box in (lat, lon) format.""" 68 return coordinates.nztm_to_wgs_depth(self.bounds)
np.ndarray: the corners of the bounding box in (lat, lon) format.
70 @classmethod 71 def from_centroid_bearing_extents( 72 cls, 73 centroid: npt.ArrayLike, 74 bearing: float, 75 extent_x: float, 76 extent_y: float, 77 ) -> Self: 78 """Create a bounding box from a centroid, bearing, and size. 79 80 The x and y-directions are determined relative to the bearing, 81 82 N y-direction = bearing 83 │ / 84 │ / 85 │ / 86 │ / 87 │/ 88 ■ 89 ╲ 90 ╲ 91 ╲ 92 ╲ x-direction = bearing + 90 93 94 Parameters 95 ---------- 96 centroid : np.ndarray 97 The centre of the bounding box (lat, lon). 98 bearing : float 99 A bearing from north for the bounding box, in degrees. 100 extent_x : float 101 The length along the x-direction of the bounding box, in 102 kilometres. 103 extent_y : float 104 The length along the y-direction of the bounding box, in 105 kilometres. 106 107 Returns 108 ------- 109 Self 110 The bounding box with the given centre, bearing, and 111 length along the x and y-directions. 112 """ 113 centroid = np.asarray(centroid) 114 corner_offset = ( 115 np.array( 116 [[-1 / 2, -1 / 2], [1 / 2, -1 / 2], [1 / 2, 1 / 2], [-1 / 2, 1 / 2]] 117 ) 118 * np.array([extent_y, extent_x]) 119 * 1000 120 ) @ geo.rotation_matrix(np.radians(-bearing)) 121 return cls(coordinates.wgs_depth_to_nztm(centroid) + corner_offset)
Create a bounding box from a centroid, bearing, and size.
The x and y-directions are determined relative to the bearing,
N y-direction = bearing
│ /
│ /
│ /
│ /
│/
■
╲
╲
╲
╲ x-direction = bearing + 90
Parameters
- centroid (np.ndarray): The centre of the bounding box (lat, lon).
- bearing (float): A bearing from north for the bounding box, in degrees.
- extent_x (float): The length along the x-direction of the bounding box, in kilometres.
- extent_y (float): The length along the y-direction of the bounding box, in kilometres.
Returns
- Self: The bounding box with the given centre, bearing, and length along the x and y-directions.
123 @classmethod 124 def bounding_box_for_geometry( 125 cls, geometry: shapely.Geometry, axis_aligned: bool = False 126 ) -> Self: 127 """Return a bounding box that minimally encloses a geometry. 128 129 Parameters 130 ---------- 131 geometry : shapely.Geometry 132 The geometry to enclose. 133 axis_aligned : bool 134 If True, ensure that the bounding box is axis-aligned. 135 136 Returns 137 ------- 138 Self 139 The bounding box for this geometry. 140 141 Raises 142 ------ 143 ValueError 144 If the geometry does not have a well-defined bounding box. 145 This occurs if the geometry is degenerate (either a line 146 or a point). 147 """ 148 if axis_aligned: 149 bounding_box_polygon = shapely.envelope(geometry).normalize() 150 else: 151 bounding_box_polygon = shapely.oriented_envelope(geometry).normalize() 152 if not ( 153 isinstance(bounding_box_polygon, shapely.Polygon) 154 and len(bounding_box_polygon.exterior.coords) - 1 == 4 155 ): 156 raise ValueError("Ill-defined geometry for bounding box.") 157 return cls(np.array(bounding_box_polygon.exterior.coords)[:-1])
Return a bounding box that minimally encloses a geometry.
Parameters
- geometry (shapely.Geometry): The geometry to enclose.
- axis_aligned (bool): If True, ensure that the bounding box is axis-aligned.
Returns
- Self: The bounding box for this geometry.
Raises
- ValueError: If the geometry does not have a well-defined bounding box. This occurs if the geometry is degenerate (either a line or a point).
159 @classmethod 160 def from_wgs84_coordinates(cls, corner_coordinates: npt.ArrayLike) -> Self: 161 """Construct a bounding box from a list of corners. 162 163 Parameters 164 ---------- 165 corner_coordinates : np.ndarray 166 The corners in (lat, lon) format. 167 168 Returns 169 ------- 170 Self 171 The bounding box represented by these corners. 172 """ 173 return cls(np.asarray(coordinates.wgs_depth_to_nztm(corner_coordinates)))
Construct a bounding box from a list of corners.
Parameters
- corner_coordinates (np.ndarray): The corners in (lat, lon) format.
Returns
- Self: The bounding box represented by these corners.
175 @property 176 def origin(self) -> npt.NDArray[np.float64]: 177 """np.ndarray: The origin of the bounding box.""" 178 return coordinates.nztm_to_wgs_depth(np.mean(self.bounds, axis=0))
np.ndarray: The origin of the bounding box.
180 @property 181 def extent_x(self) -> np.float64: 182 """float: The extent along the x-axis of the bounding box (in km).""" 183 return np.linalg.norm(self.bounds[1] - self.bounds[0]) / 1000
float: The extent along the x-axis of the bounding box (in km).
185 @property 186 def extent_y(self) -> np.float64: 187 """float: The extent along the y-axis of the bounding box (in km).""" 188 return np.linalg.norm(self.bounds[2] - self.bounds[1]) / 1000
float: The extent along the y-axis of the bounding box (in km).
190 @property 191 def bearing(self) -> np.float64: 192 """float: The bearing of the bounding box.""" 193 north_direction = np.array([1, 0, 0]) 194 up_direction = np.array([0, 0, 1]) 195 vertical_direction = np.append(self.bounds[-1] - self.bounds[0], 0) 196 return geo.oriented_bearing_wrt_normal( 197 north_direction, vertical_direction, up_direction 198 )
float: The bearing of the bounding box.
200 @property 201 def great_circle_bearing(self) -> np.float64: 202 """float: The great-circle bearing of the bounding box. 203 204 This returns the bearing of the bounding box in WGS84 205 coordinate space (as opposed to in the NZTM coordinate space). 206 """ 207 return coordinates.nztm_bearing_to_great_circle_bearing( 208 self.origin, self.extent_y / 2, self.bearing 209 )
float: The great-circle bearing of the bounding box.
This returns the bearing of the bounding box in WGS84 coordinate space (as opposed to in the NZTM coordinate space).
211 @property 212 def area(self) -> np.float64: 213 """float: The area of the bounding box.""" 214 return self.extent_x * self.extent_y
float: The area of the bounding box.
216 @property 217 def polygon(self) -> Polygon: 218 """Polygon: The shapely geometry for the bounding box.""" 219 return Polygon(np.append(self.bounds, np.atleast_2d(self.bounds[0]), axis=0))
Polygon: The shapely geometry for the bounding box.
221 def contains(self, points: npt.ArrayLike) -> bool | npt.NDArray[np.bool_]: 222 """Filter a list of points by whether they are contained in the bounding box. 223 224 Parameters 225 ---------- 226 points : np.array 227 The points to filter. 228 229 Returns 230 ------- 231 bool or array of bools 232 A boolean mask of the points in the bounding box. 233 """ 234 points = np.asarray(points) 235 offset = coordinates.wgs_depth_to_nztm(points) - self.bounds[0] 236 frame = np.array( 237 [self.bounds[1] - self.bounds[0], self.bounds[-1] - self.bounds[0]] 238 ) 239 if offset.ndim > 1: 240 offset = offset.T 241 local_coordinates = np.linalg.solve(frame.T, offset) 242 243 return np.all( 244 ((local_coordinates > 0) | np.isclose(local_coordinates, 0, atol=1e-6)) 245 & ((local_coordinates < 1) | np.isclose(local_coordinates, 1, atol=1e-6)), 246 axis=0, 247 )
Filter a list of points by whether they are contained in the bounding box.
Parameters
- points (np.array): The points to filter.
Returns
- bool or array of bools: A boolean mask of the points in the bounding box.
249 def local_coordinates_to_wgs_depth( 250 self, 251 local_coords: npt.ArrayLike, 252 ) -> npt.NDArray[np.float64]: 253 """Convert bounding box coordinates to global coordinates. 254 255 Parameters 256 ---------- 257 local_coordinates : np.ndarray 258 Local coordinates to convert. Local coordinates are 2D 259 coordinates (x, y) given for a bounding box, where x 260 represents displacement along the x-direction, and y 261 displacement along the y-direction (see diagram below). 262 263 1 1 264 ┌─────────────────────┐ ^ 265 │ │ │ 266 │ │ │ 267 │ │ │ +y 268 │ │ │ 269 │ │ │ 270 └─────────────────────┘ │ 271 0 0 ─────────────────> 272 +x 273 Returns 274 ------- 275 np.ndarray 276 An vector of (lat, lon) transformed coordinates. 277 """ 278 frame = np.array( 279 [self.bounds[1] - self.bounds[0], self.bounds[-1] - self.bounds[0]] 280 ) 281 nztm_coords = self.bounds[0] + local_coords @ frame 282 return coordinates.nztm_to_wgs_depth(nztm_coords)
Convert bounding box coordinates to global coordinates.
Parameters
local_coordinates (np.ndarray): Local coordinates to convert. Local coordinates are 2D coordinates (x, y) given for a bounding box, where x represents displacement along the x-direction, and y displacement along the y-direction (see diagram below).
1 1 ┌─────────────────────┐ ^ │ │ │ │ │ │ │ │ │ +y │ │ │ │ │ │ └─────────────────────┘ │0 0 ─────────────────> +x
Returns
- np.ndarray: An vector of (lat, lon) transformed coordinates.
284 def wgs_depth_coordinates_to_local_coordinates( 285 self, global_coords: npt.ArrayLike 286 ) -> npt.NDArray[np.float64]: 287 """Convert coordinates (lat, lon) to bounding box coordinates (x, y). 288 289 See `BoundingBox.local_coordinates_to_wgs_depth` for a description of 290 bounding box coordinates. 291 292 Parameters 293 ---------- 294 global_coordinates : np.ndarray 295 Global coordinates to convert. 296 297 Returns 298 ------- 299 np.ndarray 300 Coordinates (x, y) representing the position of 301 global_coordinates in bounding box coordinates. 302 303 Raises 304 ------ 305 ValueError 306 If the given coordinates do not lie in the bounding box. 307 """ 308 global_coords = np.asarray(global_coords) 309 310 frame = np.array( 311 [self.bounds[1] - self.bounds[0], self.bounds[-1] - self.bounds[0]] 312 ) 313 offset = coordinates.wgs_depth_to_nztm(global_coords) - self.bounds[0] 314 if offset.ndim > 1: 315 offset = offset.T 316 local_coordinates = np.linalg.solve(frame.T, offset) 317 if not np.all( 318 ((local_coordinates > 0) | np.isclose(local_coordinates, 0, atol=1e-6)) 319 & ((local_coordinates < 1) | np.isclose(local_coordinates, 1, atol=1e-6)) 320 ): 321 raise ValueError("Specified coordinates do not lie in bounding box.") 322 local_coordinates = np.clip(local_coordinates, 0, 1) 323 return local_coordinates.T
Convert coordinates (lat, lon) to bounding box coordinates (x, y).
See BoundingBox.local_coordinates_to_wgs_depth for a description of
bounding box coordinates.
Parameters
- global_coordinates (np.ndarray): Global coordinates to convert.
Returns
- np.ndarray: Coordinates (x, y) representing the position of global_coordinates in bounding box coordinates.
Raises
- ValueError: If the given coordinates do not lie in the bounding box.
331def minimum_area_bounding_box_for_polygons_masked( 332 must_include: list[Polygon], may_include: list[Polygon], mask: Polygon 333) -> BoundingBox: 334 """Find a minimum area bounding box for the points must_include ∪ (may_include ∩ mask). 335 336 Parameters 337 ---------- 338 must_include : list[Polygon] 339 List of polygons the bounding box must include. 340 may_include : list[Polygon] 341 List of polygons the bounding box will include portions of, when inside of mask. 342 mask : Polygon 343 The masking polygon. 344 345 Returns 346 ------- 347 BoundingBox 348 The smallest box containing all the points of `must_include`, and all the 349 points of `may_include` that lie within the bounds of `mask`. 350 351 """ 352 may_include_polygon = shapely.normalize(shapely.union_all(may_include)) 353 must_include_polygon = shapely.normalize(shapely.union_all(must_include)) 354 bounding_polygon = shapely.normalize( 355 shapely.union( 356 must_include_polygon, shapely.intersection(may_include_polygon, mask) 357 ) 358 ) 359 return BoundingBox.bounding_box_for_geometry(bounding_polygon)
Find a minimum area bounding box for the points must_include ∪ (may_include ∩ mask).
Parameters
- must_include (list[Polygon]): List of polygons the bounding box must include.
- may_include (list[Polygon]): List of polygons the bounding box will include portions of, when inside of mask.
- mask (Polygon): The masking polygon.
Returns
- BoundingBox: The smallest box containing all the points of
must_include, and all the points ofmay_includethat lie within the bounds ofmask.