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/parsing.py
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
"""Parse bounding boxes from strings, URLs, GeoJSON, WKT, tiles and grid codes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import warnings
|
|
11
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple
|
|
12
|
+
from urllib.parse import parse_qs, urlsplit
|
|
13
|
+
|
|
14
|
+
from . import geohash as _geohash
|
|
15
|
+
from . import mgrs as _mgrs
|
|
16
|
+
from . import pluscode as _pluscode
|
|
17
|
+
from . import tiles as _tiles
|
|
18
|
+
from ._geo import MERCATOR_MAX_LAT, Arc, component_arc, covering_arc, lon_arc, snap_arc_edges
|
|
19
|
+
from .bbox import BBox
|
|
20
|
+
from .crs import WGS84, CRSLike, is_geographic, normalize_crs
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"AmbiguousOrderWarning",
|
|
24
|
+
"parse",
|
|
25
|
+
"parse_numbers",
|
|
26
|
+
"parse_bboxfinder",
|
|
27
|
+
"parse_overpass",
|
|
28
|
+
"parse_osm_url",
|
|
29
|
+
"parse_wkt",
|
|
30
|
+
"parse_geojson",
|
|
31
|
+
"parse_gdal",
|
|
32
|
+
"parse_leaflet",
|
|
33
|
+
"parse_mapbox",
|
|
34
|
+
"parse_tile",
|
|
35
|
+
"parse_quadkey",
|
|
36
|
+
"parse_geohash",
|
|
37
|
+
"parse_pluscode",
|
|
38
|
+
"parse_mgrs",
|
|
39
|
+
"parse_postgis",
|
|
40
|
+
"PARSERS",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
Viewport = Tuple[int, int]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_PKG_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _warn(message: str) -> None:
|
|
50
|
+
"""Warn with the location of the first caller outside bboxkit."""
|
|
51
|
+
level = 1
|
|
52
|
+
frame = sys._getframe(0)
|
|
53
|
+
while frame is not None and os.path.abspath(frame.f_code.co_filename).startswith(_PKG_DIR):
|
|
54
|
+
frame = frame.f_back # type: ignore[assignment]
|
|
55
|
+
level += 1
|
|
56
|
+
warnings.warn(message, AmbiguousOrderWarning, stacklevel=level)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AmbiguousOrderWarning(UserWarning):
|
|
60
|
+
"""Four numbers could be read both as W,S,E,N and as S,W,N,E.
|
|
61
|
+
|
|
62
|
+
Pass ``order="wsen"`` or ``order="swne"`` to state the order explicitly
|
|
63
|
+
(and silence the warning).
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_NUM = r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?"
|
|
68
|
+
_NUM_RE = re.compile(_NUM)
|
|
69
|
+
_NUMS_ONLY = re.compile(rf"^\s*{_NUM}(?:\s*[,;\s]\s*{_NUM})*\s*$")
|
|
70
|
+
_CRS_TAIL = re.compile(
|
|
71
|
+
r"^(?P<body>.*?)[\s,;]+(?P<crs>(?:EPSG|ESRI|OGC|CRS84|urn:ogc|https?://www\.opengis)\S*)\s*$",
|
|
72
|
+
re.IGNORECASE,
|
|
73
|
+
)
|
|
74
|
+
_WKT_RE = re.compile(
|
|
75
|
+
r"^\s*(?:SRID=(?P<srid>\d+)\s*;)?\s*(?P<kind>POINT|LINESTRING|POLYGON|MULTIPOINT|"
|
|
76
|
+
r"MULTILINESTRING|MULTIPOLYGON|GEOMETRYCOLLECTION|ENVELOPE|BOX3D|BOX2D|BOX)\b",
|
|
77
|
+
re.IGNORECASE,
|
|
78
|
+
)
|
|
79
|
+
_OVERPASS_RE = re.compile(
|
|
80
|
+
rf"^\s*(?:\[\s*bbox\s*:\s*(?P<a>{_NUM}(?:\s*,\s*{_NUM}){{3}})\s*\]\s*;?"
|
|
81
|
+
rf"|\(\s*(?P<b>{_NUM}(?:\s*,\s*{_NUM}){{3}})\s*\))\s*$",
|
|
82
|
+
re.IGNORECASE,
|
|
83
|
+
)
|
|
84
|
+
_TILE_RE = re.compile(r"^\s*(?:tile:)?(\d{1,2})/(\d+)/(\d+)(?:@\dx)?(?:\.\w+)?\s*$", re.IGNORECASE)
|
|
85
|
+
_TILE_URL_RE = re.compile(r"/(\d{1,2})/(\d+)/(\d+)(?:@\dx)?(?:\.\w+)?(?:\?.*)?$")
|
|
86
|
+
_PLUS_RE = re.compile(r"^\s*[23456789CFGHJMPQRVWX0]{2,8}\+[23456789CFGHJMPQRVWX]*\s*$", re.I)
|
|
87
|
+
_QUADKEY_RE = re.compile(r"^[0-3]{1,30}$")
|
|
88
|
+
_GEOHASH_RE = re.compile(r"^[0-9b-hjkmnp-z]{1,22}$")
|
|
89
|
+
_GDAL_RE = re.compile(r"(?:^|\s)-(?:te|projwin|spat)\s")
|
|
90
|
+
_POSTGIS_RE = re.compile(r"^\s*(?:ST_)?MakeEnvelope\s*\((?P<args>[^)]*)\)\s*;?\s*$", re.I)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --------------------------------------------------------------------------
|
|
94
|
+
# Plain numbers and the W,S,E,N / S,W,N,E question
|
|
95
|
+
# --------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _plausible(w: float, s: float, e: float, n: float, unwrapped: bool) -> bool:
|
|
99
|
+
lon_ok = -180.0 <= w <= 180.0 and (-180.0 <= e <= 180.0 or (unwrapped and w <= e <= w + 360.0))
|
|
100
|
+
return lon_ok and -90.0 <= s <= n <= 90.0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _build(w: float, s: float, e: float, n: float, crs: str, unwrapped: bool = False) -> BBox:
|
|
104
|
+
if unwrapped and e > 180.0 and w <= e:
|
|
105
|
+
return BBox.wrap(w, s, e, n, crs=crs)
|
|
106
|
+
return BBox(w, s, e, n, crs=crs)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _from_four(
|
|
110
|
+
vals: Sequence[float],
|
|
111
|
+
order: Optional[str],
|
|
112
|
+
crs: str,
|
|
113
|
+
default: str = "wsen",
|
|
114
|
+
unwrapped: bool = False,
|
|
115
|
+
) -> BBox:
|
|
116
|
+
if len(vals) != 4:
|
|
117
|
+
raise ValueError(f"expected 4 numbers, got {len(vals)}")
|
|
118
|
+
a, b, c, d = (float(v) for v in vals)
|
|
119
|
+
readings = {"wsen": (a, b, c, d), "swne": (b, a, d, c)}
|
|
120
|
+
if order is not None:
|
|
121
|
+
key = order.lower().replace(",", "").replace(" ", "")
|
|
122
|
+
if key not in readings:
|
|
123
|
+
raise ValueError("order must be 'wsen' or 'swne'")
|
|
124
|
+
return _build(*readings[key], crs=crs, unwrapped=unwrapped)
|
|
125
|
+
if not is_geographic(crs):
|
|
126
|
+
return _build(*readings["wsen"], crs=crs)
|
|
127
|
+
|
|
128
|
+
def score(key: str) -> Tuple[bool, bool]:
|
|
129
|
+
w, s, e, n = readings[key]
|
|
130
|
+
ok = _plausible(w, s, e, n, unwrapped)
|
|
131
|
+
return (ok, ok and w <= e)
|
|
132
|
+
|
|
133
|
+
other = "swne" if default == "wsen" else "wsen"
|
|
134
|
+
s_def, s_oth = score(default), score(other)
|
|
135
|
+
chosen = other if s_oth > s_def else default
|
|
136
|
+
loser = other if chosen == default else default
|
|
137
|
+
ambiguous = score(loser)[0] and readings["wsen"] != readings["swne"]
|
|
138
|
+
if ambiguous:
|
|
139
|
+
names = {"wsen": "west,south,east,north", "swne": "south,west,north,east"}
|
|
140
|
+
_warn(
|
|
141
|
+
f"{a:g},{b:g},{c:g},{d:g} is ambiguous; read as {names[chosen]}. "
|
|
142
|
+
f"Pass order='{chosen}' or order='{loser}' to be explicit."
|
|
143
|
+
)
|
|
144
|
+
return _build(*readings[chosen], crs=crs, unwrapped=unwrapped)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _numbers(text: str) -> List[float]:
|
|
148
|
+
return [float(x) for x in _NUM_RE.findall(text)]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def parse_numbers(text: Any, order: Optional[str] = None, crs: CRSLike = WGS84) -> BBox:
|
|
152
|
+
"""Four numbers as text (``"29,40.9,29.2,41.1"``) or a sequence.
|
|
153
|
+
|
|
154
|
+
Without ``order`` the reading that gives a valid, non-crossing box wins;
|
|
155
|
+
W,S,E,N is assumed when both orders are valid, with an
|
|
156
|
+
:class:`AmbiguousOrderWarning`.
|
|
157
|
+
"""
|
|
158
|
+
c = normalize_crs(crs)
|
|
159
|
+
if isinstance(text, str):
|
|
160
|
+
m = _CRS_TAIL.match(text)
|
|
161
|
+
if m:
|
|
162
|
+
text, c = m.group("body"), normalize_crs(m.group("crs"))
|
|
163
|
+
vals = _numbers(text)
|
|
164
|
+
else:
|
|
165
|
+
vals = [float(v) for v in text]
|
|
166
|
+
return _from_four(vals, order, c)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# --------------------------------------------------------------------------
|
|
170
|
+
# Web formats
|
|
171
|
+
# --------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def parse_bboxfinder(text: str) -> BBox:
|
|
175
|
+
"""bboxfinder.com URL; its fragment is ``#south,west,north,east``."""
|
|
176
|
+
frag = urlsplit(text.strip()).fragment or text
|
|
177
|
+
return _from_four(_numbers(frag), "swne", WGS84)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def parse_overpass(text: str) -> BBox:
|
|
181
|
+
"""Overpass QL ``(s,w,n,e)`` filter or ``[bbox:s,w,n,e]`` setting."""
|
|
182
|
+
m = _OVERPASS_RE.match(text)
|
|
183
|
+
if not m:
|
|
184
|
+
raise ValueError(f"{text!r} is not an Overpass bbox")
|
|
185
|
+
return _from_four(_numbers(m.group("a") or m.group("b")), "swne", WGS84)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _map_view_bbox(zoom: float, lat: float, lon: float, viewport: Viewport) -> BBox:
|
|
189
|
+
vw, vh = viewport
|
|
190
|
+
if vw <= 0 or vh <= 0:
|
|
191
|
+
raise ValueError("viewport must be positive")
|
|
192
|
+
scale = 256.0 * 2.0**zoom
|
|
193
|
+
dx = vw / 2.0 / scale * 360.0
|
|
194
|
+
lat_c = max(-MERCATOR_MAX_LAT, min(MERCATOR_MAX_LAT, lat))
|
|
195
|
+
yc = (1.0 - math.asinh(math.tan(math.radians(lat_c))) / math.pi) / 2.0
|
|
196
|
+
half = vh / 2.0 / scale
|
|
197
|
+
y_top, y_bot = max(0.0, yc - half), min(1.0, yc + half)
|
|
198
|
+
|
|
199
|
+
def lat_of(y: float) -> float:
|
|
200
|
+
return math.degrees(math.atan(math.sinh(math.pi * (1.0 - 2.0 * y))))
|
|
201
|
+
|
|
202
|
+
return BBox.wrap(lon - dx, lat_of(y_bot), lon + dx, lat_of(y_top))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def parse_osm_url(text: str, viewport: Viewport = (1024, 768)) -> BBox:
|
|
206
|
+
"""OpenStreetMap-style URL.
|
|
207
|
+
|
|
208
|
+
Understands ``bbox=W,S,E,N`` (OSM API, many WFS/REST services),
|
|
209
|
+
``minlon=&minlat=&maxlon=&maxlat=`` and ``map=zoom/lat/lon`` in the query
|
|
210
|
+
or fragment; a ``map=`` view is turned into the area a ``viewport``
|
|
211
|
+
(pixels, default 1024 x 768) shows at that zoom.
|
|
212
|
+
"""
|
|
213
|
+
parts = urlsplit(text.strip())
|
|
214
|
+
params: Dict[str, List[str]] = {}
|
|
215
|
+
for chunk in (parts.query, parts.fragment):
|
|
216
|
+
for k, v in parse_qs(chunk).items():
|
|
217
|
+
params.setdefault(k.lower(), v)
|
|
218
|
+
if "bbox" in params:
|
|
219
|
+
return _from_four(_numbers(params["bbox"][0]), "wsen", WGS84)
|
|
220
|
+
keys = ("minlon", "minlat", "maxlon", "maxlat")
|
|
221
|
+
if all(k in params for k in keys):
|
|
222
|
+
w, s, e, n = (float(params[k][0]) for k in keys)
|
|
223
|
+
return BBox(w, s, e, n)
|
|
224
|
+
if "map" in params:
|
|
225
|
+
nums = _numbers(params["map"][0])
|
|
226
|
+
if len(nums) >= 3:
|
|
227
|
+
return _map_view_bbox(nums[0], nums[1], nums[2], viewport)
|
|
228
|
+
raise ValueError(f"no bbox=, minlon/minlat/maxlon/maxlat or map= found in {text!r}")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _pairs(value: Any) -> List[float]:
|
|
232
|
+
if isinstance(value, str):
|
|
233
|
+
value = json.loads(value)
|
|
234
|
+
if (
|
|
235
|
+
isinstance(value, (list, tuple))
|
|
236
|
+
and len(value) == 2
|
|
237
|
+
and all(isinstance(p, (list, tuple)) and len(p) == 2 for p in value)
|
|
238
|
+
):
|
|
239
|
+
return [float(value[0][0]), float(value[0][1]), float(value[1][0]), float(value[1][1])]
|
|
240
|
+
raise ValueError("expected a pair of pairs like [[a, b], [c, d]]")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def parse_leaflet(value: Any) -> BBox:
|
|
244
|
+
"""Leaflet / Google ``[[south, west], [north, east]]`` (east may exceed 180)."""
|
|
245
|
+
return _from_four(_pairs(value), "swne", WGS84, unwrapped=True)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def parse_mapbox(value: Any) -> BBox:
|
|
249
|
+
"""Mapbox / MapLibre ``[[west, south], [east, north]]`` (east may exceed 180)."""
|
|
250
|
+
return _from_four(_pairs(value), "wsen", WGS84, unwrapped=True)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def parse_gdal(text: str, crs: Optional[CRSLike] = None) -> BBox:
|
|
254
|
+
"""GDAL/OGR arguments: ``-te xmin ymin xmax ymax``, ``-projwin ulx uly lrx lry``
|
|
255
|
+
or ``-spat xmin ymin xmax ymax``, optionally with ``-te_srs`` / ``-projwin_srs``
|
|
256
|
+
/ ``-spat_srs`` (``-t_srs`` is used for ``-te`` when no ``-te_srs`` is given).
|
|
257
|
+
A whole command line works too.
|
|
258
|
+
"""
|
|
259
|
+
tokens = text.split()
|
|
260
|
+
found: Optional[Tuple[str, List[float]]] = None
|
|
261
|
+
srs: Dict[str, str] = {}
|
|
262
|
+
for i, tok in enumerate(tokens):
|
|
263
|
+
key = tok.lower()
|
|
264
|
+
if key in ("-te", "-projwin", "-spat") and found is None:
|
|
265
|
+
nums = tokens[i + 1 : i + 5]
|
|
266
|
+
if len(nums) < 4:
|
|
267
|
+
raise ValueError(f"{tok} needs four numbers")
|
|
268
|
+
found = (key, [float(x) for x in nums])
|
|
269
|
+
elif key in ("-te_srs", "-projwin_srs", "-spat_srs", "-t_srs") and i + 1 < len(tokens):
|
|
270
|
+
srs[key] = tokens[i + 1].strip("'\"")
|
|
271
|
+
if found is None:
|
|
272
|
+
raise ValueError("no -te, -projwin or -spat option found")
|
|
273
|
+
key, v = found
|
|
274
|
+
src = srs.get(key + "_srs") or (srs.get("-t_srs") if key == "-te" else None)
|
|
275
|
+
c = normalize_crs(src) if src else normalize_crs(crs if crs is not None else WGS84)
|
|
276
|
+
if key == "-projwin":
|
|
277
|
+
return BBox(v[0], v[3], v[2], v[1], crs=c)
|
|
278
|
+
return BBox(v[0], v[1], v[2], v[3], crs=c)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def parse_postgis(text: str, crs: Optional[CRSLike] = None) -> BBox:
|
|
282
|
+
"""PostGIS ``ST_MakeEnvelope(xmin, ymin, xmax, ymax[, srid])``."""
|
|
283
|
+
m = _POSTGIS_RE.match(text)
|
|
284
|
+
if not m:
|
|
285
|
+
raise ValueError(f"{text!r} is not an ST_MakeEnvelope call")
|
|
286
|
+
nums = _numbers(m.group("args"))
|
|
287
|
+
if len(nums) not in (4, 5):
|
|
288
|
+
raise ValueError("ST_MakeEnvelope needs 4 numbers and an optional SRID")
|
|
289
|
+
c: CRSLike = crs if crs is not None else WGS84
|
|
290
|
+
if len(nums) == 5 and int(nums[4]) > 0:
|
|
291
|
+
c = int(nums[4])
|
|
292
|
+
return BBox(nums[0], nums[1], nums[2], nums[3], crs=c)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# --------------------------------------------------------------------------
|
|
296
|
+
# Geometry: GeoJSON and WKT
|
|
297
|
+
# --------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class _Extent:
|
|
301
|
+
"""Accumulates longitude arcs and latitudes of geometry components."""
|
|
302
|
+
|
|
303
|
+
def __init__(self, geographic: bool) -> None:
|
|
304
|
+
self.geographic = geographic
|
|
305
|
+
self.arcs: List[Arc] = []
|
|
306
|
+
self.xs: List[float] = []
|
|
307
|
+
self.ys: List[float] = []
|
|
308
|
+
|
|
309
|
+
def add_component(self, coords: Sequence[Sequence[float]]) -> None:
|
|
310
|
+
if any(len(p) < 2 for p in coords):
|
|
311
|
+
raise ValueError("every position needs at least two coordinates")
|
|
312
|
+
pts = [(float(p[0]), float(p[1])) for p in coords]
|
|
313
|
+
if not pts:
|
|
314
|
+
return
|
|
315
|
+
xs = [p[0] for p in pts]
|
|
316
|
+
self.ys.extend(p[1] for p in pts)
|
|
317
|
+
self.xs.extend(xs)
|
|
318
|
+
if self.geographic:
|
|
319
|
+
self.arcs.append(component_arc(xs))
|
|
320
|
+
|
|
321
|
+
def add_bbox(self, w: float, s: float, e: float, n: float) -> None:
|
|
322
|
+
self.ys.extend((s, n))
|
|
323
|
+
self.xs.extend((w, e))
|
|
324
|
+
if self.geographic:
|
|
325
|
+
self.arcs.append(lon_arc(w, e))
|
|
326
|
+
|
|
327
|
+
def result(self, crs: str) -> BBox:
|
|
328
|
+
if not self.ys:
|
|
329
|
+
raise ValueError("geometry is empty")
|
|
330
|
+
s, n = min(self.ys), max(self.ys)
|
|
331
|
+
if not self.geographic:
|
|
332
|
+
return BBox(min(self.xs), s, max(self.xs), n, crs=crs)
|
|
333
|
+
w, e = snap_arc_edges(covering_arc(self.arcs), self.xs, self.xs)
|
|
334
|
+
return BBox(w, s, e, n, crs=crs)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _geojson_bbox(value: Sequence[Any]) -> Tuple[float, float, float, float]:
|
|
338
|
+
vals = [float(v) for v in value]
|
|
339
|
+
if len(vals) == 4:
|
|
340
|
+
return (vals[0], vals[1], vals[2], vals[3])
|
|
341
|
+
if len(vals) == 6:
|
|
342
|
+
return (vals[0], vals[1], vals[3], vals[4])
|
|
343
|
+
raise ValueError(f"a GeoJSON bbox has 4 or 6 numbers, got {len(vals)}")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _collect_geojson(obj: Any, ext: _Extent) -> None:
|
|
347
|
+
if not isinstance(obj, Mapping):
|
|
348
|
+
raise ValueError("GeoJSON objects must be JSON objects")
|
|
349
|
+
if obj.get("bbox") is not None:
|
|
350
|
+
ext.add_bbox(*_geojson_bbox(obj["bbox"]))
|
|
351
|
+
return
|
|
352
|
+
kind = obj.get("type")
|
|
353
|
+
coords = obj.get("coordinates")
|
|
354
|
+
if kind == "FeatureCollection":
|
|
355
|
+
for feat in obj.get("features") or []:
|
|
356
|
+
_collect_geojson(feat, ext)
|
|
357
|
+
elif kind == "Feature":
|
|
358
|
+
if obj.get("geometry") is not None:
|
|
359
|
+
_collect_geojson(obj["geometry"], ext)
|
|
360
|
+
elif kind == "GeometryCollection":
|
|
361
|
+
for geom in obj.get("geometries") or []:
|
|
362
|
+
_collect_geojson(geom, ext)
|
|
363
|
+
elif kind == "Point":
|
|
364
|
+
if coords:
|
|
365
|
+
ext.add_component([coords])
|
|
366
|
+
elif kind == "MultiPoint":
|
|
367
|
+
for p in coords or []:
|
|
368
|
+
ext.add_component([p])
|
|
369
|
+
elif kind == "LineString":
|
|
370
|
+
ext.add_component(coords or [])
|
|
371
|
+
elif kind == "MultiLineString":
|
|
372
|
+
for line in coords or []:
|
|
373
|
+
ext.add_component(line)
|
|
374
|
+
elif kind == "Polygon":
|
|
375
|
+
if coords:
|
|
376
|
+
ext.add_component(coords[0])
|
|
377
|
+
elif kind == "MultiPolygon":
|
|
378
|
+
for poly in coords or []:
|
|
379
|
+
if poly:
|
|
380
|
+
ext.add_component(poly[0])
|
|
381
|
+
else:
|
|
382
|
+
raise ValueError(f"unsupported GeoJSON type {kind!r}")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def parse_geojson(obj: Any, crs: Optional[CRSLike] = None) -> BBox:
|
|
386
|
+
"""Bounding box of any GeoJSON object (text or parsed).
|
|
387
|
+
|
|
388
|
+
A ``bbox`` member is used as is (``west > east`` crosses the antimeridian).
|
|
389
|
+
Otherwise the box is computed from the coordinates: a line or ring whose
|
|
390
|
+
consecutive vertices are more than 180 degrees apart crosses the
|
|
391
|
+
antimeridian, and separate parts are combined with the smallest covering
|
|
392
|
+
longitude range (RFC 7946, section 5.2).
|
|
393
|
+
"""
|
|
394
|
+
if isinstance(obj, str):
|
|
395
|
+
obj = json.loads(obj)
|
|
396
|
+
c = normalize_crs(crs) if crs is not None else WGS84
|
|
397
|
+
ext = _Extent(is_geographic(c))
|
|
398
|
+
_collect_geojson(obj, ext)
|
|
399
|
+
return ext.result(c)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _wkt_points(group: str) -> List[Tuple[float, float]]:
|
|
403
|
+
pts = []
|
|
404
|
+
for chunk in group.split(","):
|
|
405
|
+
nums = _numbers(chunk)
|
|
406
|
+
if len(nums) < 2:
|
|
407
|
+
raise ValueError(f"bad WKT coordinate {chunk.strip()!r}")
|
|
408
|
+
pts.append((nums[0], nums[1]))
|
|
409
|
+
return pts
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def parse_wkt(text: str, crs: Optional[CRSLike] = None) -> BBox:
|
|
413
|
+
"""Bounding box of WKT/EWKT, ``ENVELOPE(minx, maxx, maxy, miny)`` or ``BOX(...)``.
|
|
414
|
+
|
|
415
|
+
``SRID=xxxx;`` sets the CRS. Antimeridian handling follows
|
|
416
|
+
:func:`parse_geojson`.
|
|
417
|
+
"""
|
|
418
|
+
m = _WKT_RE.match(text)
|
|
419
|
+
if not m:
|
|
420
|
+
raise ValueError(f"{text[:40]!r} is not WKT")
|
|
421
|
+
kind = m.group("kind").upper()
|
|
422
|
+
c = normalize_crs(int(m.group("srid"))) if m.group("srid") else normalize_crs(crs or WGS84)
|
|
423
|
+
body = text[m.end() :]
|
|
424
|
+
if re.search(r"\bEMPTY\b", body, re.IGNORECASE):
|
|
425
|
+
raise ValueError("geometry is empty")
|
|
426
|
+
if kind == "ENVELOPE":
|
|
427
|
+
nums = _numbers(body)
|
|
428
|
+
if len(nums) != 4:
|
|
429
|
+
raise ValueError("ENVELOPE needs 4 numbers: minX, maxX, maxY, minY")
|
|
430
|
+
return BBox(nums[0], nums[3], nums[1], nums[2], crs=c)
|
|
431
|
+
if kind.startswith("BOX"):
|
|
432
|
+
groups = re.findall(r"\(([^()]*)\)", body)
|
|
433
|
+
if len(groups) != 1:
|
|
434
|
+
raise ValueError("BOX needs one coordinate list")
|
|
435
|
+
pts = _wkt_points(groups[0])
|
|
436
|
+
if len(pts) != 2:
|
|
437
|
+
raise ValueError("BOX needs two corners")
|
|
438
|
+
return BBox(pts[0][0], pts[0][1], pts[1][0], pts[1][1], crs=c)
|
|
439
|
+
ext = _Extent(is_geographic(c))
|
|
440
|
+
groups = re.findall(r"\(([^()]*)\)", body)
|
|
441
|
+
if not groups:
|
|
442
|
+
raise ValueError("no coordinates found in WKT")
|
|
443
|
+
if kind == "MULTIPOINT":
|
|
444
|
+
for g in groups:
|
|
445
|
+
for p in _wkt_points(g):
|
|
446
|
+
ext.add_component([p])
|
|
447
|
+
else:
|
|
448
|
+
for g in groups:
|
|
449
|
+
ext.add_component(_wkt_points(g))
|
|
450
|
+
return ext.result(c)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# --------------------------------------------------------------------------
|
|
454
|
+
# Grid cells
|
|
455
|
+
# --------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def parse_tile(text: Any) -> BBox:
|
|
459
|
+
"""XYZ tile ``"z/x/y"`` (also a tile URL, or a ``(x, y, z)`` Tile)."""
|
|
460
|
+
if isinstance(text, _tiles.Tile):
|
|
461
|
+
return BBox(*text.bounds)
|
|
462
|
+
if not isinstance(text, str):
|
|
463
|
+
raise TypeError("expected 'z/x/y' text or a Tile")
|
|
464
|
+
m = _TILE_RE.match(text) or _TILE_URL_RE.search(text.strip())
|
|
465
|
+
if not m:
|
|
466
|
+
raise ValueError(f"{text!r} is not a z/x/y tile")
|
|
467
|
+
z, x, y = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
468
|
+
return BBox(*_tiles.tile_bounds(x, y, z))
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def parse_quadkey(text: str) -> BBox:
|
|
472
|
+
"""Bing Maps quadkey (digits 0-3)."""
|
|
473
|
+
t = _tiles.quadkey_to_tile(_strip_prefix(text))
|
|
474
|
+
return BBox(*t.bounds)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def parse_geohash(text: str) -> BBox:
|
|
478
|
+
"""Geohash cell."""
|
|
479
|
+
return BBox(*_geohash.decode_bounds(_strip_prefix(text)))
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def parse_pluscode(text: str) -> BBox:
|
|
483
|
+
"""Full Open Location Code (Plus Code) area, e.g. ``"8FVC9G8F+6W"``."""
|
|
484
|
+
return BBox(*_pluscode.decode_bounds(_strip_prefix(text)))
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def parse_mgrs(text: str) -> BBox:
|
|
488
|
+
"""MGRS grid square, e.g. ``"35TPF"`` (100 km) or ``"35TPF6654"`` (1 km)."""
|
|
489
|
+
return BBox(*_mgrs.square_bounds(_strip_prefix(text)))
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _strip_prefix(text: str) -> str:
|
|
493
|
+
t = text.strip()
|
|
494
|
+
head, sep, tail = t.partition(":")
|
|
495
|
+
return tail.strip() if sep and head.lower() in _PREFIXES else t
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# --------------------------------------------------------------------------
|
|
499
|
+
# Dictionaries
|
|
500
|
+
# --------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _lower_keys(obj: Mapping[str, Any]) -> Dict[str, Any]:
|
|
504
|
+
return {str(k).lower(): v for k, v in obj.items()}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _latlng(obj: Any) -> Tuple[float, float]:
|
|
508
|
+
d = _lower_keys(obj)
|
|
509
|
+
lat = d.get("lat", d.get("latitude"))
|
|
510
|
+
lng = d.get("lng", d.get("lon", d.get("longitude")))
|
|
511
|
+
if lat is None or lng is None:
|
|
512
|
+
raise ValueError("expected an object with lat and lng")
|
|
513
|
+
return (float(lng), float(lat))
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _arcgis_crs(sr: Any) -> Optional[CRSLike]:
|
|
517
|
+
if isinstance(sr, Mapping):
|
|
518
|
+
wkid = sr.get("latestWkid") or sr.get("wkid")
|
|
519
|
+
if wkid:
|
|
520
|
+
return int(wkid)
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def parse_mapping(
|
|
525
|
+
obj: Mapping[str, Any], order: Optional[str] = None, crs: Optional[CRSLike] = None
|
|
526
|
+
) -> BBox:
|
|
527
|
+
"""Dictionaries: GeoJSON, STAC, Nominatim results, ArcGIS extents,
|
|
528
|
+
Leaflet ``{_southWest, _northEast}``, Google ``{south, west, north, east}`` /
|
|
529
|
+
``{southwest, northeast}`` and ``{minx, miny, maxx, maxy}``-style keys."""
|
|
530
|
+
d = _lower_keys(obj)
|
|
531
|
+
c = crs if crs is not None else WGS84
|
|
532
|
+
if "boundingbox" in d: # Nominatim: [south, north, west, east] as strings
|
|
533
|
+
s, n, w, e = (float(v) for v in d["boundingbox"])
|
|
534
|
+
return BBox(w, s, e, n)
|
|
535
|
+
if "extent" in d and isinstance(d["extent"], Mapping): # STAC collection
|
|
536
|
+
spatial = d["extent"].get("spatial", {})
|
|
537
|
+
boxes = spatial.get("bbox") if isinstance(spatial, Mapping) else None
|
|
538
|
+
if boxes:
|
|
539
|
+
return BBox(*_geojson_bbox(boxes[0]))
|
|
540
|
+
if "type" in d or "bbox" in d or "coordinates" in d:
|
|
541
|
+
return parse_geojson(obj, crs=crs)
|
|
542
|
+
for keys in (
|
|
543
|
+
("west", "south", "east", "north"),
|
|
544
|
+
("minx", "miny", "maxx", "maxy"),
|
|
545
|
+
("xmin", "ymin", "xmax", "ymax"),
|
|
546
|
+
("minlon", "minlat", "maxlon", "maxlat"),
|
|
547
|
+
("min_lon", "min_lat", "max_lon", "max_lat"),
|
|
548
|
+
("left", "bottom", "right", "top"),
|
|
549
|
+
):
|
|
550
|
+
if all(k in d for k in keys):
|
|
551
|
+
sr = _arcgis_crs(d.get("spatialreference"))
|
|
552
|
+
w, s, e, n = (float(d[k]) for k in keys)
|
|
553
|
+
return BBox(w, s, e, n, crs=sr if sr is not None else c)
|
|
554
|
+
for sw, ne in (("_southwest", "_northeast"), ("southwest", "northeast"), ("sw", "ne")):
|
|
555
|
+
if sw in d and ne in d:
|
|
556
|
+
w, s = _latlng(d[sw])
|
|
557
|
+
e, n = _latlng(d[ne])
|
|
558
|
+
return _build(w, s, e, n, normalize_crs(c), unwrapped=True)
|
|
559
|
+
raise ValueError(f"unrecognised bbox mapping with keys {sorted(d)}")
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
# --------------------------------------------------------------------------
|
|
563
|
+
# Dispatch
|
|
564
|
+
# --------------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _from_json_value(
|
|
568
|
+
value: Any, order: Optional[str], crs: Optional[CRSLike], pair_default: str
|
|
569
|
+
) -> BBox:
|
|
570
|
+
if isinstance(value, Mapping):
|
|
571
|
+
return parse_mapping(value, order=order, crs=crs)
|
|
572
|
+
if isinstance(value, (list, tuple)):
|
|
573
|
+
if len(value) == 2 and all(isinstance(p, (list, tuple)) for p in value):
|
|
574
|
+
vals = _pairs(value)
|
|
575
|
+
c = normalize_crs(crs if crs is not None else WGS84)
|
|
576
|
+
return _from_four(vals, order, c, default=pair_default, unwrapped=True)
|
|
577
|
+
if len(value) == 6 and order is None:
|
|
578
|
+
return BBox(*_geojson_bbox(value), crs=crs if crs is not None else WGS84)
|
|
579
|
+
return parse_numbers(value, order=order, crs=crs if crs is not None else WGS84)
|
|
580
|
+
raise ValueError(f"cannot read a bbox from {type(value).__name__}")
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _parse_text(
|
|
584
|
+
text: str, order: Optional[str], crs: Optional[CRSLike], viewport: Viewport
|
|
585
|
+
) -> BBox:
|
|
586
|
+
t = text.strip()
|
|
587
|
+
if not t:
|
|
588
|
+
raise ValueError("empty bbox text")
|
|
589
|
+
head, sep, tail = t.partition(":")
|
|
590
|
+
if sep and head.lower() in _PREFIXES and not tail.startswith("//"):
|
|
591
|
+
return parse(tail.strip(), fmt=head.lower(), order=order, crs=crs, viewport=viewport)
|
|
592
|
+
if t[0] in "[{":
|
|
593
|
+
try:
|
|
594
|
+
value = json.loads(t)
|
|
595
|
+
except ValueError:
|
|
596
|
+
value = None
|
|
597
|
+
if value is not None:
|
|
598
|
+
return _from_json_value(value, order, crs, pair_default="swne")
|
|
599
|
+
low = t.lower()
|
|
600
|
+
if "bboxfinder.com" in low:
|
|
601
|
+
return parse_bboxfinder(t)
|
|
602
|
+
if _OVERPASS_RE.match(t):
|
|
603
|
+
return parse_overpass(t)
|
|
604
|
+
if _GDAL_RE.search(" " + t):
|
|
605
|
+
return parse_gdal(t, crs=crs)
|
|
606
|
+
if _POSTGIS_RE.match(t):
|
|
607
|
+
return parse_postgis(t, crs=crs)
|
|
608
|
+
if _WKT_RE.match(t):
|
|
609
|
+
return parse_wkt(t, crs=crs)
|
|
610
|
+
if re.match(r"^[a-z][a-z0-9+.-]*://", low) or low.startswith(("www.", "?", "#")):
|
|
611
|
+
if re.search(r"(?:[?#&]|^)(?:bbox|minlon|map)=", low):
|
|
612
|
+
return parse_osm_url(t, viewport=viewport)
|
|
613
|
+
if _TILE_URL_RE.search(t):
|
|
614
|
+
return parse_tile(t)
|
|
615
|
+
raise ValueError(f"no bounding box found in URL {t!r}")
|
|
616
|
+
if _TILE_RE.match(t):
|
|
617
|
+
return parse_tile(t)
|
|
618
|
+
if _PLUS_RE.match(t):
|
|
619
|
+
return parse_pluscode(t)
|
|
620
|
+
if _mgrs._PATTERN.match(t):
|
|
621
|
+
return parse_mgrs(t)
|
|
622
|
+
if (_NUMS_ONLY.match(t) and len(_NUM_RE.findall(t)) > 1) or _CRS_TAIL.match(t):
|
|
623
|
+
return parse_numbers(t, order=order, crs=crs if crs is not None else WGS84)
|
|
624
|
+
if _QUADKEY_RE.match(t):
|
|
625
|
+
return parse_quadkey(t)
|
|
626
|
+
if _GEOHASH_RE.match(t):
|
|
627
|
+
return parse_geohash(t)
|
|
628
|
+
raise ValueError(
|
|
629
|
+
f"could not recognise {t[:60]!r} as a bounding box. Place names need an explicit "
|
|
630
|
+
"lookup: bboxkit.nominatim.geocode_bbox(...)"
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def parse(
|
|
635
|
+
value: Any,
|
|
636
|
+
*,
|
|
637
|
+
fmt: Optional[str] = None,
|
|
638
|
+
order: Optional[str] = None,
|
|
639
|
+
crs: Optional[CRSLike] = None,
|
|
640
|
+
viewport: Viewport = (1024, 768),
|
|
641
|
+
) -> BBox:
|
|
642
|
+
"""Parse a bounding box from (almost) anything.
|
|
643
|
+
|
|
644
|
+
Accepts a :class:`BBox`, a string in any supported format (detected
|
|
645
|
+
automatically unless ``fmt`` names one of :data:`PARSERS`), a GeoJSON-like
|
|
646
|
+
or other bbox dictionary, an object with ``__geo_interface__``, a Shapely
|
|
647
|
+
or GeoPandas object (``.bounds`` / ``.total_bounds``), a :class:`Tile`, or
|
|
648
|
+
a sequence of four numbers. Never touches the network.
|
|
649
|
+
|
|
650
|
+
``order`` (``"wsen"`` or ``"swne"``) settles four-number inputs; without it
|
|
651
|
+
the order is guessed and an :class:`AmbiguousOrderWarning` is emitted when
|
|
652
|
+
the guess could be wrong.
|
|
653
|
+
"""
|
|
654
|
+
if isinstance(value, BBox):
|
|
655
|
+
return value if crs is None else value.to_crs(crs)
|
|
656
|
+
if isinstance(value, bytes):
|
|
657
|
+
value = value.decode("utf-8")
|
|
658
|
+
if fmt is not None:
|
|
659
|
+
name = fmt.lower().replace("_", "-")
|
|
660
|
+
if name in ("wsen", "swne") and order is None:
|
|
661
|
+
order = name
|
|
662
|
+
key = _PREFIXES.get(name, name)
|
|
663
|
+
if key not in PARSERS:
|
|
664
|
+
raise ValueError(f"unknown format {fmt!r}; choose from {', '.join(sorted(PARSERS))}")
|
|
665
|
+
if key == "numbers":
|
|
666
|
+
return parse_numbers(value, order=order, crs=crs if crs is not None else WGS84)
|
|
667
|
+
if key in ("osm",):
|
|
668
|
+
return parse_osm_url(value, viewport=viewport)
|
|
669
|
+
if key in ("wkt", "geojson", "gdal", "postgis"):
|
|
670
|
+
return PARSERS[key](value, crs=crs)
|
|
671
|
+
return PARSERS[key](value)
|
|
672
|
+
if isinstance(value, str):
|
|
673
|
+
return _parse_text(value, order, crs, viewport)
|
|
674
|
+
if isinstance(value, _tiles.Tile):
|
|
675
|
+
return parse_tile(value)
|
|
676
|
+
for attr in ("total_bounds", "bounds"):
|
|
677
|
+
bounds = getattr(value, attr, None)
|
|
678
|
+
if bounds is not None and not callable(bounds):
|
|
679
|
+
vals = [float(v) for v in bounds]
|
|
680
|
+
if len(vals) == 4 and all(math.isfinite(v) for v in vals):
|
|
681
|
+
return BBox(
|
|
682
|
+
vals[0], vals[1], vals[2], vals[3], crs=crs if crs is not None else WGS84
|
|
683
|
+
)
|
|
684
|
+
geo = getattr(value, "__geo_interface__", None)
|
|
685
|
+
if isinstance(geo, Mapping):
|
|
686
|
+
return parse_geojson(geo, crs=crs)
|
|
687
|
+
return _from_json_value(value, order, crs, pair_default="swne")
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
PARSERS: Dict[str, Callable[..., BBox]] = {
|
|
691
|
+
"numbers": parse_numbers,
|
|
692
|
+
"bboxfinder": parse_bboxfinder,
|
|
693
|
+
"overpass": parse_overpass,
|
|
694
|
+
"osm": parse_osm_url,
|
|
695
|
+
"wkt": parse_wkt,
|
|
696
|
+
"geojson": parse_geojson,
|
|
697
|
+
"gdal": parse_gdal,
|
|
698
|
+
"postgis": parse_postgis,
|
|
699
|
+
"leaflet": parse_leaflet,
|
|
700
|
+
"mapbox": parse_mapbox,
|
|
701
|
+
"tile": parse_tile,
|
|
702
|
+
"quadkey": parse_quadkey,
|
|
703
|
+
"geohash": parse_geohash,
|
|
704
|
+
"pluscode": parse_pluscode,
|
|
705
|
+
"mgrs": parse_mgrs,
|
|
706
|
+
}
|
|
707
|
+
"""Explicit parsers by format name (the ``fmt=`` argument of :func:`parse`)."""
|
|
708
|
+
|
|
709
|
+
_PREFIXES: Dict[str, str] = {name: name for name in PARSERS}
|
|
710
|
+
_PREFIXES.update(
|
|
711
|
+
{
|
|
712
|
+
"wsen": "numbers",
|
|
713
|
+
"swne": "numbers",
|
|
714
|
+
"xyz": "tile",
|
|
715
|
+
"plus": "pluscode",
|
|
716
|
+
"olc": "pluscode",
|
|
717
|
+
"gdal-te": "gdal",
|
|
718
|
+
"gdal-projwin": "gdal",
|
|
719
|
+
"envelope": "wkt",
|
|
720
|
+
"osm-url": "osm",
|
|
721
|
+
}
|
|
722
|
+
)
|