# Copyright (c) 2025 The University of Washington
#
# This file is part of rapidtools.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors
# may be used to endorse or promote products derived from this software without
# specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# You should have received a copy of the BSD 3-Clause License along with
# rapidtools. If not, see <http://www.opensource.org/licenses/>.
#
# Contributors:
# Barbaros Cetiner
#
# Last updated:
# 09-22-2026
"""
Axis-aligned rectangular regions.
:class:`BoundingBox` wraps a rectangular Shapely polygon and adds constructors
from GeoJSON files, rasters, geometries and unions, plus tiling helpers used
by the data-source clients.
Example:
>>> from rapidtools.core import BoundingBox
>>> BoundingBox(0, 0, 2, 1).area
2.0
"""
from __future__ import annotations
import json
import logging
import math
from pathlib import Path
import rasterio
from rasterio.warp import transform_bounds
from shapely.geometry import box as shapely_box
from shapely.geometry import shape
from shapely.geometry.base import BaseGeometry
from .region import Region
logger = logging.getLogger(__name__)
[docs]
class BoundingBox(Region):
"""
Represents an axis-aligned rectangular region.
This class acts as a wrapper around a Shapely BaseGeometry that is strictly
rectangular. It provides specialized initialization methods and optimized
properties for rectangular math.
"""
def __init__(self, min_x: float, min_y: float, max_x: float, max_y: float):
"""
Initialize the BoundingBox from min/max coordinates.
The internal geometry is initialized as a Shapely Polygon.
Args:
min_x (float): The minimum X coordinate (e.g., min longitude).
min_y (float): The minimum Y coordinate (e.g., min latitude).
max_x (float): The maximum X coordinate (e.g., max longitude).
max_y (float): The maximum Y coordinate (e.g., max latitude).
Example:
>>> from rapidtools.core import BoundingBox
>>>
>>> bbox = BoundingBox(0.0, 0.0, 10.0, 20.0)
>>> bbox.width
10.0
"""
# Contract fulfillment: Initialize _geom with a Shapely Polygon
# Normalize coordinates automatically using shapely.geometry.box:
self._geom = shapely_box(min_x, min_y, max_x, max_y)
def __repr__(self) -> str:
"""
Return a specific representation showing the bounds clearly.
Overrides ``Region.__repr__`` to show coordinates instead of WKT.
Returns:
str:
A string formatted as
'BoundingBox(minx=..., miny=..., maxx=..., maxy=...)'.
Example:
>>> from rapidtools.core import BoundingBox
>>>
>>> bbox = BoundingBox(0, 0, 1, 1)
>>> repr(bbox)
'BoundingBox(minx=0.0, miny=0.0, maxx=1.0, maxy=1.0)'
"""
minx, miny, maxx, maxy = self.bounds
return f'BoundingBox({minx=}, {miny=}, {maxx=}, {maxy=})'
@property
def area(self) -> float:
"""
Return the area of the bounding box.
Overridden from ``Region`` to use simple float math instead of
Shapely logic for performance.
Returns:
float: The area in the squared units of the coordinate system.
Note:
- If coordinates are in meters (e.g., UTM), result is sq. meters.
- If coordinates are in degrees (e.g., WGS84), result is sq.
degrees. Square degrees are generally not useful for physical
size estimates.
Example:
>>> from rapidtools.core import BoundingBox
>>>
>>> bbox = BoundingBox(0, 0, 5, 5)
>>> bbox.area
25.0
"""
return self.width * self.height
@property
def centroid(self) -> tuple[float, float]:
"""
Return the center point of the bounding box.
Overridden from ``Region`` to use simple float math instead of
Shapely logic for performance.
Returns:
tuple[float, float]: The (longitude, latitude) center coordinates.
Example:
>>> from rapidtools.core import BoundingBox
>>>
>>> bbox = BoundingBox(0, 0, 10, 20)
>>> bbox.centroid
(5.0, 10.0)
"""
min_x, min_y, max_x, max_y = self.bounds
return ((min_x + max_x) / 2, (min_y + max_y) / 2)
[docs]
@classmethod
def from_geojson(cls, filepath: str | Path) -> BoundingBox:
"""
Create a BoundingBox covering the total spatial extent of a GeoJSON file.
The method uses a pre-computed top-level ``bbox`` member when one is
present (the fastest path) and otherwise streams the bounds of every
feature without building large in-memory geometry lists. Feature-level
``bbox`` members are honoured before falling back to the geometry.
Empty geometries are skipped. If the file declares a legacy ``crs``
member that is not EPSG:4326, the resulting bounds are reprojected to
WGS84.
Args:
filepath (str | Path):
Path to a GeoJSON file containing a ``FeatureCollection``, a
single ``Feature``, or a bare geometry object.
Returns:
BoundingBox:
A box covering the entire extent of the GeoJSON content, in
WGS84 (EPSG:4326) coordinates.
Raises:
ValueError:
If the top-level ``type`` is not a supported GeoJSON type, or
if the file contains no valid, non-empty geometries.
Example:
>>> from rapidtools.core import BoundingBox
>>>
>>> bbox = BoundingBox.from_geojson('data.geojson')
>>> print(bbox.bounds)
(-118.5, 34.0, -118.0, 34.5)
"""
with open(filepath, encoding='utf-8') as f:
data = json.load(f)
# 1. Fast path: a pre-computed top-level 'bbox' (2D or 3D):
if 'bbox' in data:
bbox = data['bbox']
if len(bbox) == 4:
return cls(bbox[0], bbox[1], bbox[2], bbox[3])
elif len(bbox) == 6:
return cls(bbox[0], bbox[1], bbox[3], bbox[4])
# Normalize the document into a list of feature-like items:
doc_type = data.get('type')
if doc_type == 'FeatureCollection':
items = data.get('features', [])
elif doc_type == 'Feature':
items = [data]
elif doc_type in [
'Point',
'MultiPoint',
'LineString',
'MultiLineString',
'Polygon',
'MultiPolygon',
'GeometryCollection',
]:
items = [{'geometry': data}]
else:
raise ValueError(f'Unsupported GeoJSON type: {doc_type}')
min_x = min_y = float('inf')
max_x = max_y = float('-inf')
found_valid = False
# 2. Stream the bounds without keeping every Shapely object in memory:
for item in items:
# Prefer a feature-level 'bbox' when one is present:
if 'bbox' in item and len(item['bbox']) in (4, 6):
b = item['bbox']
b_minx, b_miny = b[0], b[1]
b_maxx, b_maxy = (b[2], b[3]) if len(b) == 4 else (b[3], b[4])
found_valid = True
elif item.get('geometry'):
geom = shape(item['geometry'])
# Ignore empty geometries so NaN bounds cannot corrupt min/max:
if geom.is_empty:
continue
b_minx, b_miny, b_maxx, b_maxy = geom.bounds
found_valid = True
else:
continue
min_x = min(min_x, b_minx)
min_y = min(min_y, b_miny)
max_x = max(max_x, b_maxx)
max_y = max(max_y, b_maxy)
if not found_valid:
raise ValueError(
'No valid or non-empty geometries found in the GeoJSON to '
'calculate bounds.'
)
# 3. Reproject if a legacy GeoJSON CRS other than EPSG:4326 is declared:
crs_info = data.get('crs') or {}
crs_name = crs_info.get('properties', {}).get('name', '')
if crs_name and '4326' not in crs_name:
min_x, min_y, max_x, max_y = transform_bounds(
crs_name, 'EPSG:4326', min_x, min_y, max_x, max_y
)
return cls(min_x, min_y, max_x, max_y)
[docs]
@classmethod
def from_geometry(cls, geometry: BaseGeometry) -> BoundingBox:
"""
Create a BoundingBox that exactly encloses a geometry.
Args:
geometry (BaseGeometry):
Any Shapely geometry (Polygon, Point, LineString, etc.) to
wrap.
Returns:
BoundingBox:
A new instance covering the extent of the input geometry.
Example:
>>> from rapidtools.core import BoundingBox
>>>
>>> from shapely.geometry import LineString
>>> line = LineString([(0, 0), (10, 10)])
>>> bbox = BoundingBox.from_geometry(line)
>>> bbox.bounds
(0.0, 0.0, 10.0, 10.0)
"""
minx, miny, maxx, maxy = geometry.bounds
return cls(minx, miny, maxx, maxy)
[docs]
@classmethod
def from_raster(cls, filepath: str | Path) -> BoundingBox:
"""
Create a BoundingBox representing the spatial extent of a raster file.
Automatically reprojects the bounds to WGS84 (EPSG:4326) if the source
raster is in a different coordinate reference system (e.g., a localized
UTM zone). Rasters without a CRS are assumed to already be in WGS84.
Args:
filepath (str | Path): Path to the georeferenced raster file.
Returns:
BoundingBox: A WGS84 bounding box covering the extent of the raster.
Raises:
rasterio.errors.RasterioIOError: If the file cannot be opened.
Example:
>>> from rapidtools.core import BoundingBox
>>> from rapidtools import download_dataset
>>>
>>> # Download a sample drone flight:
>>> [raster_path] = download_dataset('eaton_patch1')
>>>
>>> # Extract its spatial bounds instantly:
>>> bbox = BoundingBox.from_raster(raster_path)
>>> print(bbox.bounds)
(-118.0934, 34.1842, -118.0911, 34.1858)
"""
with rasterio.open(filepath) as src:
bounds = src.bounds
crs = src.crs
# Reproject to WGS84 (EPSG:4326) if the raster uses a different CRS:
if crs and crs.to_string() != 'EPSG:4326':
min_x, min_y, max_x, max_y = transform_bounds(
crs,
'EPSG:4326',
bounds.left,
bounds.bottom,
bounds.right,
bounds.top,
)
else:
# Already in EPSG:4326 (or missing CRS entirely):
min_x, min_y, max_x, max_y = (
bounds.left,
bounds.bottom,
bounds.right,
bounds.top,
)
return cls(min_x=min_x, min_y=min_y, max_x=max_x, max_y=max_y)
[docs]
@classmethod
def from_union(cls, region1: Region, region2: Region) -> BoundingBox:
"""
Create a box covering the total extent of two regions.
This calculates the "envelope union" (min of mins, max of maxs) of two
regions
Args:
region1 (Region): The first region.
region2 (Region): The second region.
Returns:
BoundingBox: A new instance large enough to cover both inputs.
Example:
>>> from rapidtools.core import BoundingBox
>>>
>>> b1 = BoundingBox(0, 0, 1, 1)
>>> b2 = BoundingBox(10, 10, 11, 11)
>>> union_box = BoundingBox.from_union(b1, b2)
>>> union_box.bounds
(0.0, 0.0, 11.0, 11.0)
"""
minx1, miny1, maxx1, maxy1 = region1.bounds
minx2, miny2, maxx2, maxy2 = region2.bounds
return cls(
min(minx1, minx2), min(miny1, miny2), max(maxx1, maxx2), max(maxy1, maxy2)
)
[docs]
def buffer(self, distance: float) -> BoundingBox:
"""
Return a new BoundingBox expanded (or shrunk) by a constant distance.
This operation pushes the box boundaries outward by the specified
distance.
Args:
distance (float):
The amount to expand the box edges. Use negative values to
shrink the box.
Returns:
BoundingBox:
A new BoundingBox instance with adjusted boundaries.
Example:
>>> from rapidtools.core import BoundingBox
>>>
>>> bbox = BoundingBox(0, 0, 10, 10)
>>> expanded = bbox.buffer(1.0)
>>> expanded.bounds
(-1.0, -1.0, 11.0, 11.0)
"""
minx, miny, maxx, maxy = self.bounds
return BoundingBox(
minx - distance, miny - distance, maxx + distance, maxy + distance
)
[docs]
def get_bounding_box(self) -> BoundingBox:
"""
Return the bounding box of this region.
Since this object is already a BoundingBox, this method simply returns
``self``.
Returns:
BoundingBox: The object itself.
"""
return self
[docs]
def split(self) -> list[BoundingBox]:
"""
Splits the bounding box into four equal-sized quadrants.
This method is used by the API client for adaptive tiling when a tile
is too dense with features and needs to be subdivided.
Returns:
A list of four new BoundingBox objects for the quadrants:
[bottom-left, bottom-right, top-left, top-right].
Example:
>>> from rapidtools.core import BoundingBox
>>>
>>> bbox = BoundingBox(0, 0, 10, 10)
>>> quads = bbox.split()
>>> quads[0].bounds # Bottom-Left
(0.0, 0.0, 5.0, 5.0)
>>> quads[3].bounds # Top-Right
(5.0, 5.0, 10.0, 10.0)
"""
min_x, min_y, max_x, max_y = self.bounds
mid_x = (min_x + max_x) / 2
mid_y = (min_y + max_y) / 2
return [
BoundingBox(min_x, min_y, mid_x, mid_y), # Bottom-Left
BoundingBox(mid_x, min_y, max_x, mid_y), # Bottom-Right
BoundingBox(min_x, mid_y, mid_x, max_y), # Top-Left
BoundingBox(mid_x, mid_y, max_x, max_y), # Top-Right
]
[docs]
def tile_by_area(self, max_area: float = 0.01) -> list[BoundingBox]:
"""
Split the bounding box into a grid of smaller boxes.
The grid dimensions are calculated to maintain the aspect ratio of the
original box as closely as possible while ensuring every tile has an
area less than or equal to ``max_area``.
Args:
max_area (float):
The maximum allowed area for a single tile. Defaults to 0.01.
Returns:
list[BoundingBox]: A flattened list of the resulting sub-boxes.
Example:
Initialize a bounding box with an area of 100 square units:
>>> from rapidtools.core import BoundingBox
>>>
>>> bbox = BoundingBox(0, 0, 10, 10)
Split the bounding box into tiles with a maximum area of 25 square
units:
>>> tiles = bbox.tile_by_area(max_area=25)
INFO: Tiling BoundingBox into 2x2 grid (Target Area: 25, Total
Tiles: 4)
>>> len(tiles)
4
"""
# Protect against zero-division if height or width is 0:
if self.height <= 0 or self.width <= 0:
logger.warning('Cannot tile a BoundingBox with 0 width or height.')
return [self]
# If the bounding box area is within the maximum allowed, return it:
if self.area <= max_area:
return [self]
# Determine grid dimensions:
total_tiles_needed = math.ceil(self.area / max_area)
aspect_ratio = self.width / self.height
# Calculate splits maitaining the bounding box aspect ratio:
n_cols = math.ceil(math.sqrt(total_tiles_needed * aspect_ratio))
n_rows = math.ceil(math.sqrt(total_tiles_needed / aspect_ratio))
logger.info(
f'Tiling BoundingBox into {n_cols}x{n_rows} grid '
f'(Target Area: {max_area}, Total Tiles: {n_cols * n_rows})'
)
# Create tiles:
min_x, min_y, _, _ = self.bounds
step_x = self.width / n_cols
step_y = self.height / n_rows
tiles = []
for i in range(n_cols):
for j in range(n_rows):
# Calculate coordinates relative to origin to minimize float
# drift:
x1 = min_x + (i * step_x)
y1 = min_y + (j * step_y)
x2 = min_x + ((i + 1) * step_x)
y2 = min_y + ((j + 1) * step_y)
tiles.append(BoundingBox(x1, y1, x2, y2))
return tiles