bboxkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bboxkit/__init__.py +31 -0
- bboxkit/__main__.py +6 -0
- bboxkit/_geo.py +222 -0
- bboxkit/_version.py +1 -0
- bboxkit/bbox.py +1029 -0
- bboxkit/cli.py +311 -0
- bboxkit/crs.py +380 -0
- bboxkit/formats.py +86 -0
- bboxkit/geohash.py +83 -0
- bboxkit/mgrs.py +136 -0
- bboxkit/nominatim.py +113 -0
- bboxkit/parsing.py +722 -0
- bboxkit/pluscode.py +125 -0
- bboxkit/py.typed +0 -0
- bboxkit/tiles.py +148 -0
- bboxkit-0.1.0.dist-info/METADATA +270 -0
- bboxkit-0.1.0.dist-info/RECORD +21 -0
- bboxkit-0.1.0.dist-info/WHEEL +5 -0
- bboxkit-0.1.0.dist-info/entry_points.txt +2 -0
- bboxkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- bboxkit-0.1.0.dist-info/top_level.txt +1 -0
bboxkit/pluscode.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Open Location Code ("Plus Code") areas, implemented with exact integers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import List, Tuple
|
|
7
|
+
|
|
8
|
+
__all__ = ["ALPHABET", "VALID_LENGTHS", "decode_bounds", "encode"]
|
|
9
|
+
|
|
10
|
+
ALPHABET = "23456789CFGHJMPQRVWX"
|
|
11
|
+
_INDEX = {c: i for i, c in enumerate(ALPHABET)}
|
|
12
|
+
_SEP = "+"
|
|
13
|
+
_SEP_POS = 8
|
|
14
|
+
_PAD = "0"
|
|
15
|
+
_PAIRS = 5
|
|
16
|
+
_GRID = 5
|
|
17
|
+
_ROWS = 5
|
|
18
|
+
_COLS = 4
|
|
19
|
+
# Integer units: 1/25,000,000 degree of latitude, 1/8,192,000 degree of longitude.
|
|
20
|
+
_LAT_UNITS = 8000 * _ROWS**_GRID
|
|
21
|
+
_LNG_UNITS = 8000 * _COLS**_GRID
|
|
22
|
+
|
|
23
|
+
#: Code lengths (digits, excluding '+' and padding) that describe an area.
|
|
24
|
+
VALID_LENGTHS = (2, 4, 6, 8, 10, 11, 12, 13, 14, 15)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _place_values(length: int) -> List[Tuple[int, int]]:
|
|
28
|
+
"""Latitude/longitude place value (integer units) of each digit position."""
|
|
29
|
+
places: List[Tuple[int, int]] = []
|
|
30
|
+
for k in range(_PAIRS):
|
|
31
|
+
lat = 20 ** (_PAIRS - 1 - k) * _ROWS**_GRID
|
|
32
|
+
lng = 20 ** (_PAIRS - 1 - k) * _COLS**_GRID
|
|
33
|
+
places.append((lat, lng))
|
|
34
|
+
places.append((lat, lng))
|
|
35
|
+
for j in range(_GRID):
|
|
36
|
+
places.append((_ROWS ** (_GRID - 1 - j), _COLS ** (_GRID - 1 - j)))
|
|
37
|
+
return places[:length]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _digits_of(code: str) -> str:
|
|
41
|
+
text = code.strip().upper()
|
|
42
|
+
if text.count(_SEP) != 1:
|
|
43
|
+
raise ValueError(f"{code!r} is not a Plus Code (needs exactly one '+')")
|
|
44
|
+
head, tail = text.split(_SEP)
|
|
45
|
+
if len(head) < _SEP_POS:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"{code!r} is a short Plus Code; recover the full code with a reference location first"
|
|
48
|
+
)
|
|
49
|
+
if len(head) != _SEP_POS:
|
|
50
|
+
raise ValueError(f"{code!r}: '+' must follow the 8th character")
|
|
51
|
+
if _PAD in head:
|
|
52
|
+
stripped = head.rstrip(_PAD)
|
|
53
|
+
if _PAD in stripped or len(stripped) % 2 or tail or not stripped:
|
|
54
|
+
raise ValueError(f"{code!r} has invalid '0' padding")
|
|
55
|
+
head = stripped
|
|
56
|
+
if len(tail) == 1:
|
|
57
|
+
raise ValueError(f"{code!r}: a single digit after '+' is not allowed")
|
|
58
|
+
digits = head + tail
|
|
59
|
+
if len(digits) > _PAIRS * 2 + _GRID:
|
|
60
|
+
raise ValueError(f"{code!r} is longer than 15 digits")
|
|
61
|
+
for ch in digits:
|
|
62
|
+
if ch not in _INDEX:
|
|
63
|
+
raise ValueError(f"invalid Plus Code character {ch!r} in {code!r}")
|
|
64
|
+
if _INDEX[digits[0]] > 8 or _INDEX[digits[1]] > 17:
|
|
65
|
+
raise ValueError(f"{code!r} lies outside the globe")
|
|
66
|
+
return digits
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def decode_bounds(code: str) -> Tuple[float, float, float, float]:
|
|
70
|
+
"""``(west, south, east, north)`` of a full Plus Code's area."""
|
|
71
|
+
digits = _digits_of(code)
|
|
72
|
+
places = _place_values(len(digits))
|
|
73
|
+
lat = lng = 0
|
|
74
|
+
for i, ch in enumerate(digits):
|
|
75
|
+
idx = _INDEX[ch]
|
|
76
|
+
lat_place, lng_place = places[i]
|
|
77
|
+
if i < _PAIRS * 2:
|
|
78
|
+
if i % 2 == 0:
|
|
79
|
+
lat += idx * lat_place
|
|
80
|
+
else:
|
|
81
|
+
lng += idx * lng_place
|
|
82
|
+
else:
|
|
83
|
+
lat += (idx // _COLS) * lat_place
|
|
84
|
+
lng += (idx % _COLS) * lng_place
|
|
85
|
+
lat_size, lng_size = places[-1]
|
|
86
|
+
# Subtract in integers first so each edge is a single correctly rounded division.
|
|
87
|
+
south = (lat - 90 * _LAT_UNITS) / _LAT_UNITS
|
|
88
|
+
west = (lng - 180 * _LNG_UNITS) / _LNG_UNITS
|
|
89
|
+
north = min(90.0, (lat + lat_size - 90 * _LAT_UNITS) / _LAT_UNITS)
|
|
90
|
+
east = min(180.0, (lng + lng_size - 180 * _LNG_UNITS) / _LNG_UNITS)
|
|
91
|
+
return (west, south, east, north)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def encode(lon: float, lat: float, length: int = 10) -> str:
|
|
95
|
+
"""Full Plus Code of a point with ``length`` digits (2, 4, 6, 8, 10 or 11-15)."""
|
|
96
|
+
if length not in VALID_LENGTHS:
|
|
97
|
+
raise ValueError(f"Plus Code length must be one of {VALID_LENGTHS}")
|
|
98
|
+
lat = max(-90.0, min(90.0, lat))
|
|
99
|
+
lon = (lon + 180.0) % 360.0 - 180.0
|
|
100
|
+
lat_val = int(math.floor(round((lat + 90.0) * _LAT_UNITS, 6)))
|
|
101
|
+
lng_val = int(math.floor(round((lon + 180.0) * _LNG_UNITS, 6)))
|
|
102
|
+
lat_val = min(lat_val, 180 * _LAT_UNITS - 1) # the north pole belongs to the top row
|
|
103
|
+
lng_val %= 360 * _LNG_UNITS
|
|
104
|
+
places = _place_values(_PAIRS * 2 + _GRID)
|
|
105
|
+
out = []
|
|
106
|
+
for i in range(length):
|
|
107
|
+
lat_place, lng_place = places[i]
|
|
108
|
+
if i < _PAIRS * 2:
|
|
109
|
+
if i % 2 == 0:
|
|
110
|
+
d = lat_val // lat_place
|
|
111
|
+
lat_val -= d * lat_place
|
|
112
|
+
else:
|
|
113
|
+
d = lng_val // lng_place
|
|
114
|
+
lng_val -= d * lng_place
|
|
115
|
+
out.append(ALPHABET[d])
|
|
116
|
+
else:
|
|
117
|
+
row = lat_val // lat_place
|
|
118
|
+
col = lng_val // lng_place
|
|
119
|
+
lat_val -= row * lat_place
|
|
120
|
+
lng_val -= col * lng_place
|
|
121
|
+
out.append(ALPHABET[row * _COLS + col])
|
|
122
|
+
digits = "".join(out)
|
|
123
|
+
if length < _SEP_POS:
|
|
124
|
+
return digits + _PAD * (_SEP_POS - length) + _SEP
|
|
125
|
+
return digits[:_SEP_POS] + _SEP + digits[_SEP_POS:]
|
bboxkit/py.typed
ADDED
|
File without changes
|
bboxkit/tiles.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""XYZ (slippy map) tiles and Bing quadkeys on the Web Mercator grid."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Iterator, List, NamedTuple, Tuple
|
|
7
|
+
|
|
8
|
+
from ._geo import MERCATOR_MAX_LAT
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Tile",
|
|
12
|
+
"MAX_ZOOM",
|
|
13
|
+
"tile_bounds",
|
|
14
|
+
"lonlat_to_tile",
|
|
15
|
+
"tile_to_quadkey",
|
|
16
|
+
"quadkey_to_tile",
|
|
17
|
+
"tile_ranges",
|
|
18
|
+
"tiles_covering",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
#: Deepest zoom level accepted (quadkeys and integers stay exact up to here).
|
|
22
|
+
MAX_ZOOM = 30
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Tile(NamedTuple):
|
|
26
|
+
"""An XYZ tile: ``x`` grows eastwards, ``y`` southwards, ``z`` is the zoom."""
|
|
27
|
+
|
|
28
|
+
x: int
|
|
29
|
+
y: int
|
|
30
|
+
z: int
|
|
31
|
+
|
|
32
|
+
def __str__(self) -> str:
|
|
33
|
+
return f"{self.z}/{self.x}/{self.y}"
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def quadkey(self) -> str:
|
|
37
|
+
return tile_to_quadkey(self.x, self.y, self.z)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def bounds(self) -> Tuple[float, float, float, float]:
|
|
41
|
+
"""``(west, south, east, north)`` in degrees."""
|
|
42
|
+
return tile_bounds(self.x, self.y, self.z)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _check(x: int, y: int, z: int) -> None:
|
|
46
|
+
if not 0 <= z <= MAX_ZOOM:
|
|
47
|
+
raise ValueError(f"zoom must be 0..{MAX_ZOOM}, got {z}")
|
|
48
|
+
n = 1 << z
|
|
49
|
+
if not (0 <= x < n and 0 <= y < n):
|
|
50
|
+
raise ValueError(f"tile {z}/{x}/{y} is outside the 2^{z} x 2^{z} grid")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _lat_of_y(y: float, n: int) -> float:
|
|
54
|
+
return math.degrees(math.atan(math.sinh(math.pi * (1.0 - 2.0 * y / n))))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def tile_bounds(x: int, y: int, z: int) -> Tuple[float, float, float, float]:
|
|
58
|
+
"""``(west, south, east, north)`` of tile ``z/x/y`` in degrees."""
|
|
59
|
+
_check(x, y, z)
|
|
60
|
+
n = 1 << z
|
|
61
|
+
return (
|
|
62
|
+
x / n * 360.0 - 180.0,
|
|
63
|
+
_lat_of_y(y + 1, n),
|
|
64
|
+
(x + 1) / n * 360.0 - 180.0,
|
|
65
|
+
_lat_of_y(y, n),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _frac_x(lon: float, z: int) -> float:
|
|
70
|
+
return (lon + 180.0) / 360.0 * (1 << z)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _frac_y(lat: float, z: int) -> float:
|
|
74
|
+
lat = max(-MERCATOR_MAX_LAT, min(MERCATOR_MAX_LAT, lat))
|
|
75
|
+
phi = math.radians(lat)
|
|
76
|
+
return (1.0 - math.asinh(math.tan(phi)) / math.pi) / 2.0 * (1 << z)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def lonlat_to_tile(lon: float, lat: float, z: int) -> Tile:
|
|
80
|
+
"""Tile containing a point (latitude is clamped to the Mercator limit)."""
|
|
81
|
+
n = 1 << z
|
|
82
|
+
x = min(n - 1, max(0, int(math.floor(_frac_x(lon, z)))))
|
|
83
|
+
y = min(n - 1, max(0, int(math.floor(_frac_y(lat, z)))))
|
|
84
|
+
_check(x, y, z)
|
|
85
|
+
return Tile(x, y, z)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def tile_to_quadkey(x: int, y: int, z: int) -> str:
|
|
89
|
+
"""Bing Maps quadkey of a tile (``""`` for zoom 0)."""
|
|
90
|
+
_check(x, y, z)
|
|
91
|
+
digits = []
|
|
92
|
+
for i in range(z, 0, -1):
|
|
93
|
+
mask = 1 << (i - 1)
|
|
94
|
+
digit = (1 if x & mask else 0) + (2 if y & mask else 0)
|
|
95
|
+
digits.append(str(digit))
|
|
96
|
+
return "".join(digits)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def quadkey_to_tile(quadkey: str) -> Tile:
|
|
100
|
+
"""Tile of a quadkey string made of the digits 0-3."""
|
|
101
|
+
if len(quadkey) > MAX_ZOOM:
|
|
102
|
+
raise ValueError(f"quadkey longer than {MAX_ZOOM} digits")
|
|
103
|
+
x = y = 0
|
|
104
|
+
for ch in quadkey:
|
|
105
|
+
if ch not in "0123":
|
|
106
|
+
raise ValueError(f"invalid quadkey digit {ch!r} in {quadkey!r}")
|
|
107
|
+
d = int(ch)
|
|
108
|
+
x = (x << 1) | (d & 1)
|
|
109
|
+
y = (y << 1) | (d >> 1)
|
|
110
|
+
return Tile(x, y, len(quadkey))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _index_range(lo: float, hi: float, n: int) -> Tuple[int, int]:
|
|
114
|
+
"""Indices of cells [i, i+1) overlapping [lo, hi], edges excluded."""
|
|
115
|
+
first = int(math.floor(lo))
|
|
116
|
+
last = int(math.ceil(hi)) - 1
|
|
117
|
+
if last < first: # degenerate (zero-width) range
|
|
118
|
+
last = first
|
|
119
|
+
return (max(0, min(n - 1, first)), max(0, min(n - 1, last)))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def tile_ranges(
|
|
123
|
+
west: float, south: float, east: float, north: float, z: int
|
|
124
|
+
) -> List[Tuple[int, int, int, int]]:
|
|
125
|
+
"""``(x_min, x_max, y_min, y_max)`` ranges of tiles covering a box.
|
|
126
|
+
|
|
127
|
+
An antimeridian-crossing box (``west > east``) yields two ranges.
|
|
128
|
+
"""
|
|
129
|
+
if not 0 <= z <= MAX_ZOOM:
|
|
130
|
+
raise ValueError(f"zoom must be 0..{MAX_ZOOM}, got {z}")
|
|
131
|
+
n = 1 << z
|
|
132
|
+
y0, y1 = _index_range(_frac_y(north, z), _frac_y(south, z), n)
|
|
133
|
+
lon_parts = [(west, east)] if west <= east else [(west, 180.0), (-180.0, east)]
|
|
134
|
+
out = []
|
|
135
|
+
for w, e in lon_parts:
|
|
136
|
+
x0, x1 = _index_range(_frac_x(w, z), _frac_x(e, z), n)
|
|
137
|
+
out.append((x0, x1, y0, y1))
|
|
138
|
+
if len(out) == 2 and out[1][1] >= out[0][0]: # the two halves overlap: whole row
|
|
139
|
+
out = [(0, n - 1, y0, y1)]
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def tiles_covering(west: float, south: float, east: float, north: float, z: int) -> Iterator[Tile]:
|
|
144
|
+
"""Yield every tile at zoom ``z`` that overlaps the box, row by row."""
|
|
145
|
+
for x0, x1, y0, y1 in tile_ranges(west, south, east, north, z):
|
|
146
|
+
for y in range(y0, y1 + 1):
|
|
147
|
+
for x in range(x0, x1 + 1):
|
|
148
|
+
yield Tile(x, y, z)
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bboxkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Zero-dependency bounding boxes: parse and format every bbox convention (WKT, GeoJSON, Overpass, GDAL, tiles, geohash, Plus Codes, MGRS), antimeridian-aware geometry, ellipsoidal area and reprojection.
|
|
5
|
+
Author-email: Yusuf Eminoğlu <yusufeminoglu@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/YusufEminoglu/bboxkit_sdk
|
|
8
|
+
Project-URL: Repository, https://github.com/YusufEminoglu/bboxkit_sdk
|
|
9
|
+
Project-URL: Issues, https://github.com/YusufEminoglu/bboxkit_sdk/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/YusufEminoglu/bboxkit_sdk/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: bbox,bounding-box,gis,geojson,wkt,overpass,openstreetmap,gdal,xyz-tiles,quadkey,geohash,plus-codes,mgrs,utm,web-mercator,antimeridian
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Intended Audience :: Science/Research
|
|
24
|
+
Classifier: Intended Audience :: Developers
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Provides-Extra: pyproj
|
|
30
|
+
Requires-Dist: pyproj>=3.4; extra == "pyproj"
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
34
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
35
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# bboxkit
|
|
39
|
+
|
|
40
|
+
**Bounding boxes, without the guesswork.** Parse a bbox from whatever tool handed it to you,
|
|
41
|
+
convert it to whatever the next tool wants, and do correct geometry on it, including boxes
|
|
42
|
+
that cross the antimeridian. Pure Python, zero dependencies, fully typed.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install bboxkit # zero dependencies
|
|
46
|
+
pip install "bboxkit[pyproj]" # optional: reproject to any CRS
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
>>> from bboxkit import BBox, parse
|
|
51
|
+
>>> box = parse("29.0,40.9,29.2,41.1", order="wsen") # Istanbul
|
|
52
|
+
>>> box.to_overpass()
|
|
53
|
+
'(40.9,29,41.1,29.2)'
|
|
54
|
+
>>> box.to_gdal_te()
|
|
55
|
+
'-te 29 40.9 29.2 41.1'
|
|
56
|
+
>>> round(box.area_km2(), 3) # exact on the WGS 84 ellipsoid
|
|
57
|
+
373.741
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Every tool agrees that a bounding box has four numbers. Almost nothing else is agreed:
|
|
61
|
+
GeoJSON and Shapely write `west,south,east,north`, Overpass and Leaflet write
|
|
62
|
+
`south,west,north,east`, `gdal_translate -projwin` wants `west,north,east,south`, Nominatim
|
|
63
|
+
returns `[south, north, west, east]` as strings, and a box around Fiji has `west > east`.
|
|
64
|
+
bboxkit is a small, dependency-free value type that knows all of these conventions, so other
|
|
65
|
+
packages can depend on it instead of re-implementing (and re-debugging) them.
|
|
66
|
+
|
|
67
|
+
## Supported formats
|
|
68
|
+
|
|
69
|
+
| Format | Example | Parse | Format |
|
|
70
|
+
| --- | --- | --- | --- |
|
|
71
|
+
| W,S,E,N (GeoJSON, Shapely, OSM API, STAC) | `29,40.9,29.2,41.1` | `parse(s, order="wsen")` | `to_wsen()` |
|
|
72
|
+
| S,W,N,E (lat-first) | `40.9,29,41.1,29.2` | `parse(s, order="swne")` | `to_swne()` |
|
|
73
|
+
| bboxfinder.com | `http://bboxfinder.com/#40.9,29,41.1,29.2` | auto | `to_bboxfinder()` |
|
|
74
|
+
| Overpass QL | `(40.9,29,41.1,29.2)`, `[bbox:40.9,29,41.1,29.2]` | auto | `to_overpass()` |
|
|
75
|
+
| OSM URL | `...?bbox=W,S,E,N`, `?minlon=...`, `#map=14/41.0/29.0` | auto (`viewport=`) | `to_osm_url()` |
|
|
76
|
+
| WKT / EWKT | `POLYGON ((...))`, `SRID=3857;MULTIPOINT(...)`, any geometry | auto | `to_wkt()` |
|
|
77
|
+
| ENVELOPE (CQL, Solr) | `ENVELOPE(29, 29.2, 41.1, 40.9)` | auto | `to_wkt_envelope()` |
|
|
78
|
+
| PostGIS | `ST_MakeEnvelope(29, 40.9, 29.2, 41.1, 4326)`, `BOX(29 40.9,29.2 41.1)` | auto | `to_postgis()` |
|
|
79
|
+
| GeoJSON | `bbox` member, or computed from any geometry / Feature / FeatureCollection | auto | `to_geojson()` |
|
|
80
|
+
| Shapely / GeoPandas | `geom.bounds`, `gdf.total_bounds`, `__geo_interface__` | `parse(obj)` | `to_shapely_bounds()` |
|
|
81
|
+
| GDAL / OGR | `-te 29 40.9 29.2 41.1`, `-projwin 29 41.1 29.2 40.9`, `-spat ...`, `-te_srs` | auto | `to_gdal_te()`, `to_gdal_projwin()` |
|
|
82
|
+
| Leaflet / Google | `[[40.9, 29], [41.1, 29.2]]` | `parse_leaflet()` | `to_leaflet()` |
|
|
83
|
+
| Mapbox / MapLibre | `[[29, 40.9], [29.2, 41.1]]` | `parse_mapbox()` | `to_mapbox()` |
|
|
84
|
+
| XYZ tile | `14/9375/6142`, `https://tile.../14/9375/6142.png` | auto | `to_tile()` |
|
|
85
|
+
| Quadkey (Bing) | `1202102332221212` | auto | `to_quadkey()` |
|
|
86
|
+
| Geohash | `sxk9` | auto | `to_geohash()` |
|
|
87
|
+
| Plus Code (OLC) | `8FVC9G8F+6W` | auto | `to_pluscode()` |
|
|
88
|
+
| MGRS square | `35TPF`, `31U DQ 48251 11932` | auto | `to_mgrs()` |
|
|
89
|
+
| Dictionaries | Nominatim result, ArcGIS extent (`wkid`), STAC collection, Leaflet `_southWest`, Google `southwest`, `{minx, ...}` | `parse(d)` | `to_dict()` |
|
|
90
|
+
| Place name | `"Istanbul"` | `nominatim.geocode_bbox()` (opt-in, network) | |
|
|
91
|
+
|
|
92
|
+
For cell formats (tile, quadkey, geohash, Plus Code, MGRS) parsing gives the cell's box and
|
|
93
|
+
formatting gives the **smallest cell that contains the whole box**.
|
|
94
|
+
|
|
95
|
+
Not included, on purpose: H3 (needs the icosahedral projection, i.e. the `h3` library) and
|
|
96
|
+
the polar UPS part of MGRS.
|
|
97
|
+
|
|
98
|
+
## One-liners
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from bboxkit import BBox, parse, union_all
|
|
102
|
+
|
|
103
|
+
parse("http://bboxfinder.com/#40.9,29,41.1,29.2") # BBox(29.0, 40.9, 29.2, 41.1)
|
|
104
|
+
parse("[bbox:40.9,29,41.1,29.2]") # Overpass setting
|
|
105
|
+
parse("gdalwarp -t_srs EPSG:32635 -te 500000 4500000 510000 4510000 in.tif out.tif")
|
|
106
|
+
parse("https://www.openstreetmap.org/#map=14/41.0082/28.9784", viewport=(1920, 1080))
|
|
107
|
+
parse("SRID=3857;POLYGON((0 0, 10 0, 10 10, 0 0))") # crs='EPSG:3857'
|
|
108
|
+
parse({"type": "Feature", "geometry": {"type": "LineString", "coordinates": [[179, 0], [-179, 1]]}})
|
|
109
|
+
parse({"boundingbox": ["40.9", "41.1", "29.0", "29.2"]}) # a Nominatim result
|
|
110
|
+
parse(shapely_polygon) # uses .bounds
|
|
111
|
+
parse("14/9375/6142"); parse("sxk9"); parse("8FVC9G8F+6W"); parse("35TPF")
|
|
112
|
+
|
|
113
|
+
box = BBox(29.0, 40.9, 29.2, 41.1)
|
|
114
|
+
box.to_wkt() # 'POLYGON ((29 40.9, 29.2 40.9, 29.2 41.1, 29 41.1, 29 40.9))'
|
|
115
|
+
box.to_geojson() # {'type': 'Polygon', 'coordinates': [...], 'bbox': [...]}
|
|
116
|
+
box.to_leaflet() # [[40.9, 29.0], [41.1, 29.2]]
|
|
117
|
+
box.to_gdal_projwin() # '-projwin 29 41.1 29.2 40.9'
|
|
118
|
+
box.to_osm_url() # 'https://www.openstreetmap.org/#map=11/41.00008/29.10000'
|
|
119
|
+
box.format("postgis") # 'ST_MakeEnvelope(29, 40.9, 29.2, 41.1, 4326)'
|
|
120
|
+
box.to_geohash(), box.to_tile(), box.to_mgrs() # ('sxk', Tile(x=2, y=1, z=2), '35TPF')
|
|
121
|
+
|
|
122
|
+
box.area_km2() # 373.741450485826 (exact for a lat/lon rectangle on WGS 84)
|
|
123
|
+
box.center # (29.1, 41.0)
|
|
124
|
+
box.width_m, box.height_m # (16827.0..., 22210.7...)
|
|
125
|
+
box.buffer(500) # grown by 500 m on every side
|
|
126
|
+
box.buffer(-500) # shrunk by 500 m
|
|
127
|
+
box.contains((29.1, 41.0)); box.intersects(other); box & other; box | other
|
|
128
|
+
box.grid(4, 3) # 12 cells, row by row from the north-west
|
|
129
|
+
box.split(1000) # ~1 km x 1 km cells
|
|
130
|
+
box.tiles(14) # [Tile(x=9511, y=6136, z=14), ...]
|
|
131
|
+
box.tile_count(14) # 130
|
|
132
|
+
box.expand_to_aspect(16 / 9) # fit a 16:9 web map (Mercator), never shrinks
|
|
133
|
+
BBox(29.03, 40.91, 29.17, 41.08).snap(0.1) # BBox(29.0, 40.9, 29.2, 41.1)
|
|
134
|
+
round(box, 2)
|
|
135
|
+
box.to_crs("EPSG:3857") # built in
|
|
136
|
+
box.to_utm() # built in: EPSG:32635 for Istanbul
|
|
137
|
+
box.to_crs("EPSG:3035") # any CRS with the [pyproj] extra
|
|
138
|
+
w, s, e, n = box # unpacks like a tuple
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## The antimeridian
|
|
142
|
+
|
|
143
|
+
For longitude/latitude boxes, `west > east` means the box crosses the 180th meridian, exactly
|
|
144
|
+
as in RFC 7946:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
>>> fiji = BBox(177, -20, -178, -16)
|
|
148
|
+
>>> fiji.crosses_antimeridian, fiji.width, fiji.center
|
|
149
|
+
(True, 5.0, (179.5, -18.0))
|
|
150
|
+
>>> fiji.contains((-179.5, -18))
|
|
151
|
+
True
|
|
152
|
+
>>> fiji.parts()
|
|
153
|
+
(BBox(177.0, -20.0, 180.0, -16.0), BBox(-180.0, -20.0, -178.0, -16.0))
|
|
154
|
+
>>> BBox(170, 0, 175, 1) | BBox(-175, 0, -170, 1)
|
|
155
|
+
BBox(170.0, 0.0, -170.0, 1.0)
|
|
156
|
+
>>> fiji.to_geojson()["type"] # split into two parts, as RFC 7946 recommends
|
|
157
|
+
'MultiPolygon'
|
|
158
|
+
>>> fiji.to_leaflet() # one contiguous box on a web map
|
|
159
|
+
[[-20.0, 177.0], [-16.0, 182.0]]
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
- `union()` / `union_all()` and bounding boxes computed from GeoJSON or WKT use the smallest
|
|
163
|
+
longitude range that covers everything (the complement of the largest gap), so the RFC 7946
|
|
164
|
+
Fiji example gives `[177, -20, -178, -16]`, not `[-178, -20, 177, -16]`.
|
|
165
|
+
- A line or ring whose consecutive vertices are more than 180 degrees apart is read as crossing
|
|
166
|
+
the antimeridian (the shorter way round).
|
|
167
|
+
- `intersection()` returns `None`, one box, or raises if the overlap is two separate pieces
|
|
168
|
+
(wide boxes overlapping at both ends); `intersection_parts()` always returns the list.
|
|
169
|
+
- Formats that cannot express a crossing box (`-te`, `-projwin`, `ST_MakeEnvelope`, Shapely
|
|
170
|
+
bounds) raise a `ValueError` that points to `parts()`.
|
|
171
|
+
- In Web Mercator a crossing box keeps contiguous x values (east beyond 20037508.34 m) and
|
|
172
|
+
converts back to a crossing box.
|
|
173
|
+
|
|
174
|
+
## W,S,E,N or S,W,N,E?
|
|
175
|
+
|
|
176
|
+
Four bare numbers are ambiguous. bboxkit reads them as `west,south,east,north` unless only the
|
|
177
|
+
lat-first reading is valid, and emits an `AmbiguousOrderWarning` whenever both readings are
|
|
178
|
+
possible. Say what you mean to make the warning go away:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
parse("40.9,29,41.1,29.2", order="swne")
|
|
182
|
+
parse("swne:40.9,29,41.1,29.2")
|
|
183
|
+
parse(text, fmt="leaflet")
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Area, buffers and grids
|
|
187
|
+
|
|
188
|
+
- `area_km2()` uses the closed-form area of a region bounded by two meridians and two parallels
|
|
189
|
+
on the WGS 84 ellipsoid (via the authalic latitude function). It matches a densely densified
|
|
190
|
+
`pyproj.Geod` polygon to ~1e-11. 1 x 1 degree at the equator is 12308.4639 km2, at 60 N
|
|
191
|
+
6123.1409 km2; the whole globe is 510065621.724 km2.
|
|
192
|
+
- `buffer(m)` uses the local radii of curvature; the longitude margin is taken at the most
|
|
193
|
+
poleward latitude, so every point within `m` metres of the box is inside the result. Boxes
|
|
194
|
+
that reach a pole get the full longitude range.
|
|
195
|
+
- `split(cell_m)` builds a regular lon/lat grid whose cells measure `cell_m` at the box's central
|
|
196
|
+
latitude; `grid(nx, ny)` splits evenly. Both work across the antimeridian.
|
|
197
|
+
- Projected boxes (UTM, any metric CRS) do all of this in CRS units.
|
|
198
|
+
|
|
199
|
+
## Reprojection
|
|
200
|
+
|
|
201
|
+
`to_crs()` transforms densified edges (21 points per edge by default) and takes the extent.
|
|
202
|
+
WGS 84, Web Mercator (`EPSG:3857` and its aliases 900913, 3785, 102100) and WGS 84 / UTM
|
|
203
|
+
(`EPSG:326xx`, `EPSG:327xx`, ETRS89 `EPSG:258xx`) are built in; UTM uses the 6th-order Krueger
|
|
204
|
+
series (agrees with PROJ to nanometres). Anything else goes to pyproj when it is installed.
|
|
205
|
+
`to_utm()` picks the zone of the centre, including the Norway and Svalbard exceptions.
|
|
206
|
+
|
|
207
|
+
## Command line
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
$ bboxkit convert "29.0,40.9,29.2,41.1" --to overpass
|
|
211
|
+
bboxkit: warning: 29,40.9,29.2,41.1 is ambiguous; read as west,south,east,north. ...
|
|
212
|
+
(40.9,29,41.1,29.2)
|
|
213
|
+
$ bboxkit convert "29.0,40.9,29.2,41.1" --order wsen --to all
|
|
214
|
+
$ bboxkit convert "http://bboxfinder.com/#40.9,29,41.1,29.2" --to gdal-projwin
|
|
215
|
+
$ bboxkit area "29.0,40.9,29.2,41.1" --order wsen
|
|
216
|
+
373.741
|
|
217
|
+
$ bboxkit tiles "29.0,40.9,29.2,41.1" --order wsen --zoom 14 --count
|
|
218
|
+
130
|
|
219
|
+
$ bboxkit tiles "29.0,40.9,29.2,41.1" --order wsen --zoom 14 --format quadkey
|
|
220
|
+
$ bboxkit split "29.0,40.9,29.2,41.1" --order wsen --cell 1km --to geojson > grid.geojson
|
|
221
|
+
$ bboxkit split "29.0,40.9,29.2,41.1" --order wsen --grid 4x3
|
|
222
|
+
$ bboxkit buffer "POINT(29 41)" --distance 2km --to wkt
|
|
223
|
+
$ bboxkit reproject "29.0,40.9,29.2,41.1" --order wsen --to-crs utm
|
|
224
|
+
$ bboxkit info "177,-20,-178,-16" --order wsen
|
|
225
|
+
$ bboxkit geocode "Kadikoy, Istanbul" --user-agent "my-app/1.0 (me@example.org)"
|
|
226
|
+
$ bboxkit formats
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Boxes that start with a minus sign (`-122.5,37.7,-122.3,37.8`, `-te 1 2 3 4`) work as they are;
|
|
230
|
+
`-` reads the box from standard input.
|
|
231
|
+
|
|
232
|
+
## Place names (optional, network)
|
|
233
|
+
|
|
234
|
+
`parse()` never touches the network. Place lookup is a separate, explicit call:
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
from bboxkit.nominatim import geocode_bbox
|
|
238
|
+
|
|
239
|
+
geocode_bbox("Kadikoy, Istanbul", user_agent="my-app/1.0 (me@example.org)", timeout=10)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
It follows the [Nominatim usage policy](https://operations.osmfoundation.org/policies/nominatim/):
|
|
243
|
+
at most one request per second per process, an identifying User-Agent (please set your own),
|
|
244
|
+
results cached in memory, and a `base_url=` for your own Nominatim instance when you need
|
|
245
|
+
more than occasional lookups. Results are (c) OpenStreetMap contributors, ODbL.
|
|
246
|
+
|
|
247
|
+
## Design
|
|
248
|
+
|
|
249
|
+
- `BBox` is immutable, hashable, picklable and slotted. `==` and `hash` are exact so boxes can
|
|
250
|
+
be dict keys; `equals(other, tol=1e-9)` compares with a tolerance (longitudes modulo 360).
|
|
251
|
+
- Validation on construction: finite numbers, `south <= north`, latitudes and longitudes in
|
|
252
|
+
range for geographic CRSs, `west <= east` for projected ones. `BBox.wrap()` accepts unwrapped
|
|
253
|
+
longitudes such as `170 .. 190`.
|
|
254
|
+
- CRS strings are normalised (`4326`, `"epsg:4326"`, URNs, OGC URLs, `CRS84` all become
|
|
255
|
+
`"EPSG:4326"`).
|
|
256
|
+
- Cell lookups (`to_geohash()`, `to_pluscode()`, ...) use exact integer arithmetic where the
|
|
257
|
+
format allows it, so round trips are stable.
|
|
258
|
+
|
|
259
|
+
## Development
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
pip install -e ".[dev,pyproj]"
|
|
263
|
+
pytest --cov=bboxkit
|
|
264
|
+
ruff check . && ruff format --check src tests
|
|
265
|
+
mypy --strict src
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## License
|
|
269
|
+
|
|
270
|
+
MIT, see [LICENSE](LICENSE). Copyright (c) 2026 Yusuf Eminoğlu.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
bboxkit/__init__.py,sha256=52gVQpRXoDDcjiTNPXkZ-ycbw4tycV6KF-Djc35ElKk,736
|
|
2
|
+
bboxkit/__main__.py,sha256=jnD5WmxMTKyGlvrBEWHxyesWykTYEBlGx_uTonn9mT8,129
|
|
3
|
+
bboxkit/_geo.py,sha256=hmHxx_zNlegxxz9pgz7A6fv_eB7sq6jud46tTRU59tw,7966
|
|
4
|
+
bboxkit/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
5
|
+
bboxkit/bbox.py,sha256=beML5HcakSYPeD_EpfeCUA2KDIE8Z0bWI3MzDXG1Okg,41500
|
|
6
|
+
bboxkit/cli.py,sha256=ki05YBrXakaF3egXqCkZnz81l8A--DKrcp7C6vh4ZPU,11359
|
|
7
|
+
bboxkit/crs.py,sha256=xDzpauNuQFyM4Ncvd8Wsugy3FadkOANqBCc7O4br_xo,13285
|
|
8
|
+
bboxkit/formats.py,sha256=P9jBXJWRbWg5EnGBd81QSfDnyCNeUKCUfyzC_AqGbHg,3300
|
|
9
|
+
bboxkit/geohash.py,sha256=3AglvokcXzc8apVMRhGdyUP0TU9ufmrjFxaOg79dI6c,2564
|
|
10
|
+
bboxkit/mgrs.py,sha256=CYyuujNwjI5ktYSd_vBdezPs79GTfR6UOURRsn8mbDQ,5305
|
|
11
|
+
bboxkit/nominatim.py,sha256=hQ2WwTZYzni20ch3QNlVrdoEMsVc4LHiciKqc4Lt7nI,3755
|
|
12
|
+
bboxkit/parsing.py,sha256=YUwaLs9bhwNtB12URHRlNFOGPnbL-RnSOPFao58GcA0,27069
|
|
13
|
+
bboxkit/pluscode.py,sha256=eVGEk9hVQ6MP49DDtkvTq0V4--sJvQxyROxREw3F7w8,4742
|
|
14
|
+
bboxkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
bboxkit/tiles.py,sha256=ZPcbAvNxTKI34xdNAOYXuHrlFXU5pSgVt6YSuQwhRSw,4632
|
|
16
|
+
bboxkit-0.1.0.dist-info/licenses/LICENSE,sha256=Z9meOk3opsRgHOqC5mA4B8NjZoU5WU1Kqhn82FL4ICs,1072
|
|
17
|
+
bboxkit-0.1.0.dist-info/METADATA,sha256=bJgR1beD5aX_MTMPhCAUkCy7MCtBJiFMOpJ49elODJQ,13569
|
|
18
|
+
bboxkit-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
19
|
+
bboxkit-0.1.0.dist-info/entry_points.txt,sha256=Fqc8gaNEQXsNXh3hoO43k01ksKesqnhVWeIH_9Smts0,45
|
|
20
|
+
bboxkit-0.1.0.dist-info/top_level.txt,sha256=HdhzcKSSFOcJdYZd599gtW2s0mDllpI2HN_b6BSS5zU,8
|
|
21
|
+
bboxkit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yusuf Eminoğlu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bboxkit
|