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
  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)
class BoundingBox:
 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).
BoundingBox(bounds: numpy.ndarray[typing.Any, numpy.dtype[numpy.float64]])
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.
bounds: numpy.ndarray[typing.Any, numpy.dtype[numpy.float64]]
corners: numpy.ndarray
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.

@classmethod
def from_centroid_bearing_extents( cls, centroid: Union[Buffer, numpy._typing._array_like._SupportsArray[numpy.dtype[Any]], numpy._typing._nested_sequence._NestedSequence[numpy._typing._array_like._SupportsArray[numpy.dtype[Any]]], bool, int, float, complex, str, bytes, numpy._typing._nested_sequence._NestedSequence[Union[bool, int, float, complex, str, bytes]]], bearing: float, extent_x: float, extent_y: float) -> Self:
 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.
@classmethod
def bounding_box_for_geometry(cls, geometry: shapely.lib.Geometry, axis_aligned: bool = False) -> Self:
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).
@classmethod
def from_wgs84_coordinates( cls, corner_coordinates: Union[Buffer, numpy._typing._array_like._SupportsArray[numpy.dtype[Any]], numpy._typing._nested_sequence._NestedSequence[numpy._typing._array_like._SupportsArray[numpy.dtype[Any]]], bool, int, float, complex, str, bytes, numpy._typing._nested_sequence._NestedSequence[Union[bool, int, float, complex, str, bytes]]]) -> Self:
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.
origin: numpy.ndarray[typing.Any, numpy.dtype[numpy.float64]]
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.

extent_x: numpy.float64
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).

extent_y: numpy.float64
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).

bearing: numpy.float64
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.

great_circle_bearing: numpy.float64
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).

area: numpy.float64
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.

polygon: shapely.geometry.polygon.Polygon
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.

def contains( self, points: Union[Buffer, numpy._typing._array_like._SupportsArray[numpy.dtype[Any]], numpy._typing._nested_sequence._NestedSequence[numpy._typing._array_like._SupportsArray[numpy.dtype[Any]]], bool, int, float, complex, str, bytes, numpy._typing._nested_sequence._NestedSequence[Union[bool, int, float, complex, str, bytes]]]) -> bool | numpy.ndarray[typing.Any, numpy.dtype[numpy.bool_]]:
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.
def local_coordinates_to_wgs_depth( self, local_coords: Union[Buffer, numpy._typing._array_like._SupportsArray[numpy.dtype[Any]], numpy._typing._nested_sequence._NestedSequence[numpy._typing._array_like._SupportsArray[numpy.dtype[Any]]], bool, int, float, complex, str, bytes, numpy._typing._nested_sequence._NestedSequence[Union[bool, int, float, complex, str, bytes]]]) -> numpy.ndarray[typing.Any, numpy.dtype[numpy.float64]]:
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.
def wgs_depth_coordinates_to_local_coordinates( self, global_coords: Union[Buffer, numpy._typing._array_like._SupportsArray[numpy.dtype[Any]], numpy._typing._nested_sequence._NestedSequence[numpy._typing._array_like._SupportsArray[numpy.dtype[Any]]], bool, int, float, complex, str, bytes, numpy._typing._nested_sequence._NestedSequence[Union[bool, int, float, complex, str, bytes]]]) -> numpy.ndarray[typing.Any, numpy.dtype[numpy.float64]]:
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.
def minimum_area_bounding_box_for_polygons_masked( must_include: list[shapely.geometry.polygon.Polygon], may_include: list[shapely.geometry.polygon.Polygon], mask: shapely.geometry.polygon.Polygon) -> BoundingBox:
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 of may_include that lie within the bounds of mask.