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/cli.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Command-line interface: ``bboxkit convert|info|area|tiles|split|buffer|reproject|geocode``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
import warnings
|
|
10
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, TextIO
|
|
11
|
+
|
|
12
|
+
from ._version import __version__
|
|
13
|
+
from .bbox import BBox
|
|
14
|
+
from .formats import FORMAT_ALIASES, FORMATS, format_bbox
|
|
15
|
+
from .parsing import PARSERS, parse
|
|
16
|
+
|
|
17
|
+
_UNITS = {"m": 1.0, "km": 1000.0, "mi": 1609.344, "ft": 0.3048, "nmi": 1852.0}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_distance(text: str) -> float:
|
|
21
|
+
"""``"1km"``, ``"500 m"``, ``"2mi"``, ``"250"`` (metres) to metres."""
|
|
22
|
+
m = re.fullmatch(r"\s*([-+]?\d+(?:\.\d*)?|[-+]?\.\d+)\s*([a-zA-Z]*)\s*", text)
|
|
23
|
+
if not m:
|
|
24
|
+
raise ValueError(f"cannot read distance {text!r}; try 500m or 1.5km")
|
|
25
|
+
unit = m.group(2).lower() or "m"
|
|
26
|
+
if unit not in _UNITS:
|
|
27
|
+
raise ValueError(f"unknown distance unit {unit!r}; use one of {', '.join(_UNITS)}")
|
|
28
|
+
return float(m.group(1)) * _UNITS[unit]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _grid_spec(text: str) -> List[int]:
|
|
32
|
+
m = re.fullmatch(r"\s*(\d+)\s*[xX*,]\s*(\d+)\s*", text)
|
|
33
|
+
if not m:
|
|
34
|
+
raise ValueError(f"cannot read grid {text!r}; use e.g. 3x2 (columns x rows)")
|
|
35
|
+
return [int(m.group(1)), int(m.group(2))]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load(args: argparse.Namespace) -> BBox:
|
|
39
|
+
text = " ".join(args.bbox).strip()
|
|
40
|
+
if text == "-":
|
|
41
|
+
text = sys.stdin.read().strip()
|
|
42
|
+
return parse(text, fmt=args.source_format, order=args.order, crs=args.crs)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _out(box: BBox, fmt: str, precision: Optional[int]) -> str:
|
|
46
|
+
kwargs: Dict[str, Any] = {}
|
|
47
|
+
if precision is not None and fmt in (
|
|
48
|
+
"wsen",
|
|
49
|
+
"swne",
|
|
50
|
+
"bboxfinder",
|
|
51
|
+
"overpass",
|
|
52
|
+
"wkt",
|
|
53
|
+
"envelope",
|
|
54
|
+
"postgis",
|
|
55
|
+
"gdal-te",
|
|
56
|
+
"gdal-projwin",
|
|
57
|
+
"bbox",
|
|
58
|
+
"csv",
|
|
59
|
+
"latlon",
|
|
60
|
+
"gdal",
|
|
61
|
+
"te",
|
|
62
|
+
"projwin",
|
|
63
|
+
):
|
|
64
|
+
kwargs["precision"] = precision
|
|
65
|
+
return format_bbox(box, fmt, **kwargs)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _cmd_convert(args: argparse.Namespace, out: TextIO) -> None:
|
|
69
|
+
box = _load(args)
|
|
70
|
+
if args.to == "all":
|
|
71
|
+
for name in FORMATS:
|
|
72
|
+
try:
|
|
73
|
+
value = _out(box, name, args.precision)
|
|
74
|
+
except (ValueError, ImportError) as exc:
|
|
75
|
+
value = f"(n/a: {exc})"
|
|
76
|
+
out.write(f"{name:<13} {value}\n")
|
|
77
|
+
return
|
|
78
|
+
out.write(_out(box, args.to, args.precision) + "\n")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _cmd_info(args: argparse.Namespace, out: TextIO) -> None:
|
|
82
|
+
box = _load(args)
|
|
83
|
+
lon, lat = box.center
|
|
84
|
+
info: Dict[str, Any] = {
|
|
85
|
+
"bbox": list(box.bounds),
|
|
86
|
+
"crs": box.crs,
|
|
87
|
+
"crosses_antimeridian": box.crosses_antimeridian,
|
|
88
|
+
"width": box.width,
|
|
89
|
+
"height": box.height,
|
|
90
|
+
"center": [lon, lat],
|
|
91
|
+
"area_km2": box.area_km2(),
|
|
92
|
+
}
|
|
93
|
+
if box.is_geographic:
|
|
94
|
+
info["width_m"] = box.width_m
|
|
95
|
+
info["height_m"] = box.height_m
|
|
96
|
+
out.write(json.dumps(info, indent=2) + "\n")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _cmd_area(args: argparse.Namespace, out: TextIO) -> None:
|
|
100
|
+
area = _load(args).area_km2()
|
|
101
|
+
out.write(f"{area:.{args.digits}f}\n")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _cmd_tiles(args: argparse.Namespace, out: TextIO) -> None:
|
|
105
|
+
box = _load(args)
|
|
106
|
+
if args.count:
|
|
107
|
+
out.write(f"{box.tile_count(args.zoom)}\n")
|
|
108
|
+
return
|
|
109
|
+
for t in box.tiles(args.zoom, max_tiles=args.max):
|
|
110
|
+
if args.format == "quadkey":
|
|
111
|
+
out.write(t.quadkey + "\n")
|
|
112
|
+
elif args.format == "wsen":
|
|
113
|
+
out.write(BBox(*t.bounds).to_wsen() + "\n")
|
|
114
|
+
else:
|
|
115
|
+
out.write(f"{t}\n")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _write_boxes(boxes: Sequence[BBox], fmt: str, precision: Optional[int], out: TextIO) -> None:
|
|
119
|
+
if fmt == "geojson":
|
|
120
|
+
fc = {
|
|
121
|
+
"type": "FeatureCollection",
|
|
122
|
+
"features": [
|
|
123
|
+
b.to_geojson(feature=True, properties={"id": i}) for i, b in enumerate(boxes)
|
|
124
|
+
],
|
|
125
|
+
}
|
|
126
|
+
out.write(json.dumps(fc) + "\n")
|
|
127
|
+
return
|
|
128
|
+
for b in boxes:
|
|
129
|
+
out.write(_out(b, fmt, precision) + "\n")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _cmd_split(args: argparse.Namespace, out: TextIO) -> None:
|
|
133
|
+
box = _load(args)
|
|
134
|
+
if args.grid:
|
|
135
|
+
nx, ny = _grid_spec(args.grid)
|
|
136
|
+
cells = box.grid(nx, ny)
|
|
137
|
+
else:
|
|
138
|
+
cy = parse_distance(args.cell_y) if args.cell_y else None
|
|
139
|
+
cells = box.split(parse_distance(args.cell), cy, max_cells=args.max)
|
|
140
|
+
_write_boxes(cells, args.to, args.precision, out)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _cmd_buffer(args: argparse.Namespace, out: TextIO) -> None:
|
|
144
|
+
box = _load(args).buffer(parse_distance(args.distance))
|
|
145
|
+
out.write(_out(box, args.to, args.precision) + "\n")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _cmd_reproject(args: argparse.Namespace, out: TextIO) -> None:
|
|
149
|
+
box = _load(args)
|
|
150
|
+
if args.target.lower() == "utm":
|
|
151
|
+
res = box.to_utm()
|
|
152
|
+
else:
|
|
153
|
+
res = box.to_crs(args.target, densify=args.densify)
|
|
154
|
+
text = _out(res, args.to, args.precision)
|
|
155
|
+
out.write(f"{text}\n" if args.quiet else f"{text} ({res.crs})\n")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _cmd_geocode(args: argparse.Namespace, out: TextIO) -> None:
|
|
159
|
+
from .nominatim import geocode_bbox
|
|
160
|
+
|
|
161
|
+
box = geocode_bbox(
|
|
162
|
+
" ".join(args.query),
|
|
163
|
+
user_agent=args.user_agent,
|
|
164
|
+
email=args.email,
|
|
165
|
+
timeout=args.timeout,
|
|
166
|
+
base_url=args.server,
|
|
167
|
+
)
|
|
168
|
+
out.write(_out(box, args.to, args.precision) + "\n")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _cmd_formats(args: argparse.Namespace, out: TextIO) -> None:
|
|
172
|
+
out.write("Output formats (--to):\n")
|
|
173
|
+
for name, desc in FORMATS.items():
|
|
174
|
+
out.write(f" {name:<13} {desc}\n")
|
|
175
|
+
out.write("Aliases: " + ", ".join(f"{a}={b}" for a, b in FORMAT_ALIASES.items()) + "\n")
|
|
176
|
+
out.write("Input formats (--from; detected automatically when omitted):\n")
|
|
177
|
+
out.write(" " + ", ".join(sorted(PARSERS)) + ", wsen, swne\n")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _format_choice(text: str) -> str:
|
|
181
|
+
key = text.lower().replace("_", "-")
|
|
182
|
+
if key != "all" and key not in FORMATS and key not in FORMAT_ALIASES:
|
|
183
|
+
raise argparse.ArgumentTypeError(f"unknown format {text!r} (see 'bboxkit formats')")
|
|
184
|
+
return key
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
188
|
+
parser = argparse.ArgumentParser(
|
|
189
|
+
prog="bboxkit",
|
|
190
|
+
description="Parse, convert and operate on bounding boxes.",
|
|
191
|
+
epilog="Boxes starting with '-' (e.g. -122.5,37.7,...) work as they are.",
|
|
192
|
+
)
|
|
193
|
+
parser.add_argument("--version", action="version", version=f"bboxkit {__version__}")
|
|
194
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
195
|
+
sub.required = True
|
|
196
|
+
|
|
197
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
198
|
+
common.add_argument("bbox", nargs="+", help="bounding box in any format ('-' reads stdin)")
|
|
199
|
+
common.add_argument(
|
|
200
|
+
"--from",
|
|
201
|
+
dest="source_format",
|
|
202
|
+
metavar="FMT",
|
|
203
|
+
help="input format, e.g. wsen, swne, wkt, tile (default: detect)",
|
|
204
|
+
)
|
|
205
|
+
common.add_argument(
|
|
206
|
+
"--order",
|
|
207
|
+
choices=["wsen", "swne"],
|
|
208
|
+
help="order of four plain numbers (default: guess, with a warning)",
|
|
209
|
+
)
|
|
210
|
+
common.add_argument("--crs", help="CRS of the input coordinates (default EPSG:4326)")
|
|
211
|
+
common.add_argument("--precision", type=int, metavar="N", help="decimal places in the output")
|
|
212
|
+
|
|
213
|
+
def add(
|
|
214
|
+
name: str,
|
|
215
|
+
func: Callable[[argparse.Namespace, TextIO], None],
|
|
216
|
+
help_: str,
|
|
217
|
+
with_bbox: bool = True,
|
|
218
|
+
) -> argparse.ArgumentParser:
|
|
219
|
+
p = sub.add_parser(
|
|
220
|
+
name, parents=[common] if with_bbox else [], help=help_, description=help_
|
|
221
|
+
)
|
|
222
|
+
p.set_defaults(func=func)
|
|
223
|
+
return p
|
|
224
|
+
|
|
225
|
+
p = add("convert", _cmd_convert, "convert a box to another format")
|
|
226
|
+
p.add_argument(
|
|
227
|
+
"--to", type=_format_choice, default="wsen", help="output format, or 'all' (default wsen)"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
add("info", _cmd_info, "print size, centre and area as JSON")
|
|
231
|
+
|
|
232
|
+
p = add("area", _cmd_area, "area in square kilometres (ellipsoidal)")
|
|
233
|
+
p.add_argument("--digits", type=int, default=3, help="decimals to print (default 3)")
|
|
234
|
+
|
|
235
|
+
p = add("tiles", _cmd_tiles, "list XYZ tiles covering the box")
|
|
236
|
+
p.add_argument("--zoom", "-z", type=int, required=True)
|
|
237
|
+
p.add_argument("--format", choices=["zxy", "quadkey", "wsen"], default="zxy")
|
|
238
|
+
p.add_argument("--count", action="store_true", help="only print the number of tiles")
|
|
239
|
+
p.add_argument("--max", type=int, default=100_000, help="refuse more tiles (default 100000)")
|
|
240
|
+
|
|
241
|
+
p = add("split", _cmd_split, "split into cells of a given size or an n x m grid")
|
|
242
|
+
g = p.add_mutually_exclusive_group(required=True)
|
|
243
|
+
g.add_argument("--cell", help="cell size, e.g. 1km or 500m")
|
|
244
|
+
g.add_argument("--grid", help="columns x rows, e.g. 4x3")
|
|
245
|
+
p.add_argument("--cell-y", help="cell height if different from --cell")
|
|
246
|
+
p.add_argument("--to", type=_format_choice, default="wsen")
|
|
247
|
+
p.add_argument("--max", type=int, default=100_000, help="refuse more cells (default 100000)")
|
|
248
|
+
|
|
249
|
+
p = add("buffer", _cmd_buffer, "grow (or shrink) the box by a distance")
|
|
250
|
+
p.add_argument("--distance", "-d", required=True, help="e.g. 500m, 2km, -100m")
|
|
251
|
+
p.add_argument("--to", type=_format_choice, default="wsen")
|
|
252
|
+
|
|
253
|
+
p = add("reproject", _cmd_reproject, "reproject to EPSG:3857, UTM or (with pyproj) any CRS")
|
|
254
|
+
p.add_argument(
|
|
255
|
+
"--to-crs",
|
|
256
|
+
dest="target",
|
|
257
|
+
required=True,
|
|
258
|
+
help="target CRS, e.g. EPSG:3857, EPSG:32635 or 'utm' for the local zone",
|
|
259
|
+
)
|
|
260
|
+
p.add_argument("--densify", type=int, default=21)
|
|
261
|
+
p.add_argument("--to", type=_format_choice, default="wsen")
|
|
262
|
+
p.add_argument("--quiet", "-q", action="store_true", help="do not print the CRS")
|
|
263
|
+
|
|
264
|
+
p = add(
|
|
265
|
+
"geocode",
|
|
266
|
+
_cmd_geocode,
|
|
267
|
+
"look up a place name on Nominatim (network; usage policy applies)",
|
|
268
|
+
with_bbox=False,
|
|
269
|
+
)
|
|
270
|
+
p.add_argument("query", nargs="+", help="place name")
|
|
271
|
+
p.add_argument("--to", type=_format_choice, default="wsen")
|
|
272
|
+
p.add_argument("--precision", type=int, metavar="N")
|
|
273
|
+
p.add_argument("--user-agent", help="identify your application (recommended)")
|
|
274
|
+
p.add_argument("--email", help="contact address sent to Nominatim")
|
|
275
|
+
p.add_argument("--timeout", type=float, default=10.0)
|
|
276
|
+
p.add_argument("--server", default="https://nominatim.openstreetmap.org/search")
|
|
277
|
+
|
|
278
|
+
add("formats", _cmd_formats, "list supported formats", with_bbox=False)
|
|
279
|
+
return parser
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
_DASH_VALUE = re.compile(r"^-(?:\d|\.\d|te\s|projwin\s|spat\s)")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _protect(argv: Sequence[str]) -> List[str]:
|
|
286
|
+
"""Keep argparse from mistaking '-122.5,37.7,...' or '-te 1 2 3 4' for options."""
|
|
287
|
+
return [
|
|
288
|
+
f" {a}" if _DASH_VALUE.match(a) and (" " in a or "," in a or ";" in a) else a for a in argv
|
|
289
|
+
]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def main(argv: Optional[Sequence[str]] = None, out: Optional[TextIO] = None) -> int:
|
|
293
|
+
"""Entry point; returns the process exit code."""
|
|
294
|
+
stream = out if out is not None else sys.stdout
|
|
295
|
+
args = build_parser().parse_args(_protect(sys.argv[1:] if argv is None else argv))
|
|
296
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
297
|
+
warnings.simplefilter("always")
|
|
298
|
+
try:
|
|
299
|
+
args.func(args, stream)
|
|
300
|
+
except (ValueError, TypeError, LookupError, ImportError, OSError) as exc:
|
|
301
|
+
for w in caught:
|
|
302
|
+
sys.stderr.write(f"bboxkit: warning: {w.message}\n")
|
|
303
|
+
sys.stderr.write(f"bboxkit: error: {exc}\n")
|
|
304
|
+
return 2
|
|
305
|
+
for w in caught:
|
|
306
|
+
sys.stderr.write(f"bboxkit: warning: {w.message}\n")
|
|
307
|
+
return 0
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
if __name__ == "__main__": # pragma: no cover
|
|
311
|
+
raise SystemExit(main())
|
bboxkit/crs.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Coordinate reference systems: names, Web Mercator and UTM, optional pyproj.
|
|
2
|
+
|
|
3
|
+
Built in (no dependencies):
|
|
4
|
+
|
|
5
|
+
* geographic WGS 84 (``EPSG:4326``, also written ``OGC:CRS84``, ``WGS84``),
|
|
6
|
+
* Web Mercator (``EPSG:3857`` and its historical aliases),
|
|
7
|
+
* UTM on WGS 84 (``EPSG:32601``-``32660`` north, ``EPSG:32701``-``32760`` south)
|
|
8
|
+
using the 6th-order Krueger series (nanometre-level accuracy within a zone).
|
|
9
|
+
|
|
10
|
+
Any other CRS is handed to :mod:`pyproj` when it is installed
|
|
11
|
+
(``pip install bboxkit[pyproj]``).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import functools
|
|
17
|
+
import math
|
|
18
|
+
import re
|
|
19
|
+
from typing import Any, Callable, List, Optional, Tuple, Union
|
|
20
|
+
|
|
21
|
+
from ._geo import MERCATOR_MAX_LAT, WGS84_A, WGS84_E, WGS84_N
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"WGS84",
|
|
25
|
+
"WEB_MERCATOR",
|
|
26
|
+
"normalize_crs",
|
|
27
|
+
"is_geographic",
|
|
28
|
+
"is_builtin",
|
|
29
|
+
"utm_zone",
|
|
30
|
+
"utm_crs",
|
|
31
|
+
"utm_zone_of_crs",
|
|
32
|
+
"lonlat_to_mercator",
|
|
33
|
+
"mercator_to_lonlat",
|
|
34
|
+
"lonlat_to_utm",
|
|
35
|
+
"utm_to_lonlat",
|
|
36
|
+
"transform_point",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
WGS84 = "EPSG:4326"
|
|
40
|
+
WEB_MERCATOR = "EPSG:3857"
|
|
41
|
+
|
|
42
|
+
CRSLike = Union[str, int]
|
|
43
|
+
|
|
44
|
+
_ALIASES = {
|
|
45
|
+
"WGS84": WGS84,
|
|
46
|
+
"WGS 84": WGS84,
|
|
47
|
+
"CRS84": WGS84,
|
|
48
|
+
"OGC:CRS84": WGS84,
|
|
49
|
+
"EPSG:4326": WGS84,
|
|
50
|
+
"EPSG:3857": WEB_MERCATOR,
|
|
51
|
+
"EPSG:900913": WEB_MERCATOR,
|
|
52
|
+
"EPSG:3785": WEB_MERCATOR,
|
|
53
|
+
"EPSG:102100": WEB_MERCATOR,
|
|
54
|
+
"EPSG:102113": WEB_MERCATOR,
|
|
55
|
+
"ESRI:102100": WEB_MERCATOR,
|
|
56
|
+
"ESRI:102113": WEB_MERCATOR,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Geographic (lon/lat degree) CRSs recognised without pyproj. Only EPSG:4326
|
|
60
|
+
# can be transformed without pyproj; the others are known to use degrees, so
|
|
61
|
+
# bounding boxes in them get latitude checks and antimeridian semantics.
|
|
62
|
+
_GEOGRAPHIC = {
|
|
63
|
+
WGS84,
|
|
64
|
+
"EPSG:4258", # ETRS89
|
|
65
|
+
"EPSG:4269", # NAD83
|
|
66
|
+
"EPSG:4283", # GDA94
|
|
67
|
+
"EPSG:7844", # GDA2020
|
|
68
|
+
"EPSG:4167", # NZGD2000
|
|
69
|
+
"EPSG:4490", # CGCS2000
|
|
70
|
+
"EPSG:4612", # JGD2000
|
|
71
|
+
"EPSG:6668", # JGD2011
|
|
72
|
+
"EPSG:4674", # SIRGAS 2000
|
|
73
|
+
"EPSG:4019", # GRS 1980 unknown datum
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_URN = re.compile(r"^urn:ogc:def:crs:(EPSG|OGC):[^:]*:(\w+)$", re.IGNORECASE)
|
|
77
|
+
_HTTP = re.compile(r"^https?://www\.opengis\.net/def/crs/(EPSG|OGC)/[^/]+/(\w+)$", re.IGNORECASE)
|
|
78
|
+
_AUTH = re.compile(r"^(EPSG|ESRI|OGC)\s*:{1,2}\s*(\w+)$", re.IGNORECASE)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def normalize_crs(crs: CRSLike) -> str:
|
|
82
|
+
"""Return a canonical ``AUTHORITY:CODE`` string for common CRS spellings.
|
|
83
|
+
|
|
84
|
+
``4326``, ``"epsg:4326"``, ``"urn:ogc:def:crs:EPSG::4326"``,
|
|
85
|
+
``"http://www.opengis.net/def/crs/OGC/1.3/CRS84"`` and ``"WGS84"`` all map to
|
|
86
|
+
``"EPSG:4326"`` (bounding boxes in this package are always x/lon first, so
|
|
87
|
+
CRS84 and EPSG:4326 are the same thing here). Unknown strings such as
|
|
88
|
+
PROJ or WKT definitions are returned stripped but otherwise unchanged.
|
|
89
|
+
"""
|
|
90
|
+
if isinstance(crs, bool):
|
|
91
|
+
raise TypeError("crs must be a string or an integer EPSG code")
|
|
92
|
+
if isinstance(crs, int):
|
|
93
|
+
text = f"EPSG:{crs}"
|
|
94
|
+
elif isinstance(crs, str):
|
|
95
|
+
text = crs.strip()
|
|
96
|
+
if not text:
|
|
97
|
+
raise ValueError("crs must not be empty")
|
|
98
|
+
else:
|
|
99
|
+
raise TypeError("crs must be a string or an integer EPSG code")
|
|
100
|
+
for pattern in (_URN, _HTTP, _AUTH):
|
|
101
|
+
m = pattern.match(text)
|
|
102
|
+
if m:
|
|
103
|
+
text = f"{m.group(1).upper()}:{m.group(2).upper()}"
|
|
104
|
+
break
|
|
105
|
+
else:
|
|
106
|
+
if text.isdigit():
|
|
107
|
+
text = f"EPSG:{text}"
|
|
108
|
+
return _ALIASES.get(text.upper(), _ALIASES.get(text, text))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@functools.lru_cache(maxsize=256)
|
|
112
|
+
def _pyproj_is_geographic(crs: str) -> bool:
|
|
113
|
+
try:
|
|
114
|
+
from pyproj import CRS
|
|
115
|
+
except ImportError:
|
|
116
|
+
return False
|
|
117
|
+
try:
|
|
118
|
+
return bool(CRS.from_user_input(crs).is_geographic)
|
|
119
|
+
except Exception: # noqa: BLE001 - any parse failure means "not geographic"
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def is_geographic(crs: CRSLike) -> bool:
|
|
124
|
+
"""True if coordinates in ``crs`` are longitude/latitude degrees."""
|
|
125
|
+
c = normalize_crs(crs)
|
|
126
|
+
if c in _GEOGRAPHIC:
|
|
127
|
+
return True
|
|
128
|
+
if c == WEB_MERCATOR or utm_zone_of_crs(c) is not None:
|
|
129
|
+
return False
|
|
130
|
+
return _pyproj_is_geographic(c)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def is_builtin(crs: CRSLike) -> bool:
|
|
134
|
+
"""True if ``crs`` can be transformed without pyproj."""
|
|
135
|
+
c = normalize_crs(crs)
|
|
136
|
+
return c in (WGS84, WEB_MERCATOR) or utm_zone_of_crs(c) is not None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# --------------------------------------------------------------------------
|
|
140
|
+
# UTM zones
|
|
141
|
+
# --------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def utm_zone(lon: float, lat: float) -> int:
|
|
145
|
+
"""UTM zone number for a point, with the Norway and Svalbard exceptions."""
|
|
146
|
+
if not -180.0 <= lon <= 180.0:
|
|
147
|
+
lon = (lon + 180.0) % 360.0 - 180.0
|
|
148
|
+
zone = int((lon + 180.0) // 6.0) % 60 + 1
|
|
149
|
+
if 56.0 <= lat < 64.0 and 3.0 <= lon < 12.0:
|
|
150
|
+
return 32
|
|
151
|
+
if 72.0 <= lat <= 84.0 and 0.0 <= lon < 42.0:
|
|
152
|
+
if lon < 9.0:
|
|
153
|
+
return 31
|
|
154
|
+
if lon < 21.0:
|
|
155
|
+
return 33
|
|
156
|
+
if lon < 33.0:
|
|
157
|
+
return 35
|
|
158
|
+
return 37
|
|
159
|
+
return zone
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def utm_crs(zone: int, south: bool = False) -> str:
|
|
163
|
+
"""EPSG code string of a WGS 84 / UTM zone."""
|
|
164
|
+
if not 1 <= zone <= 60:
|
|
165
|
+
raise ValueError(f"UTM zone must be 1..60, got {zone}")
|
|
166
|
+
return f"EPSG:{32700 + zone if south else 32600 + zone}"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def utm_zone_of_crs(crs: str) -> Optional[Tuple[int, bool]]:
|
|
170
|
+
"""``(zone, south)`` if ``crs`` is a WGS 84 or ETRS89 UTM CRS, else None."""
|
|
171
|
+
m = re.match(r"^EPSG:(\d+)$", crs)
|
|
172
|
+
if not m:
|
|
173
|
+
return None
|
|
174
|
+
code = int(m.group(1))
|
|
175
|
+
if 32601 <= code <= 32660:
|
|
176
|
+
return (code - 32600, False)
|
|
177
|
+
if 32701 <= code <= 32760:
|
|
178
|
+
return (code - 32700, True)
|
|
179
|
+
if 25828 <= code <= 25838: # ETRS89 / UTM 28N-38N, identical to WGS 84 at this level
|
|
180
|
+
return (code - 25800, False)
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# --------------------------------------------------------------------------
|
|
185
|
+
# Web Mercator
|
|
186
|
+
# --------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def lonlat_to_mercator(lon: float, lat: float) -> Tuple[float, float]:
|
|
190
|
+
"""Project to EPSG:3857 metres. Latitude is clamped to +-85.0511 degrees.
|
|
191
|
+
|
|
192
|
+
Longitudes are not wrapped, so values beyond +-180 give x beyond the
|
|
193
|
+
usual +-20037508.34 m, which keeps antimeridian-crossing boxes contiguous.
|
|
194
|
+
"""
|
|
195
|
+
lat = max(-MERCATOR_MAX_LAT, min(MERCATOR_MAX_LAT, lat))
|
|
196
|
+
x = WGS84_A * math.radians(lon)
|
|
197
|
+
y = WGS84_A * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
|
|
198
|
+
return (x, y)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def mercator_to_lonlat(x: float, y: float) -> Tuple[float, float]:
|
|
202
|
+
"""Inverse of :func:`lonlat_to_mercator` (longitude is not wrapped)."""
|
|
203
|
+
lon = math.degrees(x / WGS84_A)
|
|
204
|
+
lat = math.degrees(2.0 * math.atan(math.exp(y / WGS84_A)) - math.pi / 2.0)
|
|
205
|
+
return (lon, lat)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# --------------------------------------------------------------------------
|
|
209
|
+
# Transverse Mercator (Krueger series, Karney 2011) for UTM
|
|
210
|
+
# --------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
_K0 = 0.9996
|
|
213
|
+
_n = WGS84_N
|
|
214
|
+
_A = WGS84_A / (1.0 + _n) * (1.0 + _n**2 / 4.0 + _n**4 / 64.0 + _n**6 / 256.0)
|
|
215
|
+
_ALPHA = (
|
|
216
|
+
_n / 2 - 2 * _n**2 / 3 + 5 * _n**3 / 16 + 41 * _n**4 / 180 - 127 * _n**5 / 288
|
|
217
|
+
+ 7891 * _n**6 / 37800,
|
|
218
|
+
13 * _n**2 / 48 - 3 * _n**3 / 5 + 557 * _n**4 / 1440 + 281 * _n**5 / 630
|
|
219
|
+
- 1983433 * _n**6 / 1935360,
|
|
220
|
+
61 * _n**3 / 240 - 103 * _n**4 / 140 + 15061 * _n**5 / 26880 + 167603 * _n**6 / 181440,
|
|
221
|
+
49561 * _n**4 / 161280 - 179 * _n**5 / 168 + 6601661 * _n**6 / 7257600,
|
|
222
|
+
34729 * _n**5 / 80640 - 3418889 * _n**6 / 1995840,
|
|
223
|
+
212378941 * _n**6 / 319334400,
|
|
224
|
+
) # fmt: skip
|
|
225
|
+
_BETA = (
|
|
226
|
+
_n / 2 - 2 * _n**2 / 3 + 37 * _n**3 / 96 - _n**4 / 360 - 81 * _n**5 / 512
|
|
227
|
+
+ 96199 * _n**6 / 604800,
|
|
228
|
+
_n**2 / 48 + _n**3 / 15 - 437 * _n**4 / 1440 + 46 * _n**5 / 105 - 1118711 * _n**6 / 3870720,
|
|
229
|
+
17 * _n**3 / 480 - 37 * _n**4 / 840 - 209 * _n**5 / 4480 + 5569 * _n**6 / 90720,
|
|
230
|
+
4397 * _n**4 / 161280 - 11 * _n**5 / 504 - 830251 * _n**6 / 7257600,
|
|
231
|
+
4583 * _n**5 / 161280 - 108847 * _n**6 / 3991680,
|
|
232
|
+
20648693 * _n**6 / 638668800,
|
|
233
|
+
) # fmt: skip
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _tm_forward(dlon: float, lat: float) -> Tuple[float, float]:
|
|
237
|
+
"""Transverse Mercator x, y (unscaled by k0) for a longitude offset."""
|
|
238
|
+
phi = math.radians(lat)
|
|
239
|
+
lam = math.radians(dlon)
|
|
240
|
+
if abs(lat) >= 90.0:
|
|
241
|
+
tau_p = math.copysign(math.inf, lat)
|
|
242
|
+
else:
|
|
243
|
+
tau = math.tan(phi)
|
|
244
|
+
sigma = math.sinh(WGS84_E * math.atanh(WGS84_E * tau / math.sqrt(1.0 + tau * tau)))
|
|
245
|
+
tau_p = tau * math.sqrt(1.0 + sigma * sigma) - sigma * math.sqrt(1.0 + tau * tau)
|
|
246
|
+
cos_lam = math.cos(lam)
|
|
247
|
+
if math.isinf(tau_p):
|
|
248
|
+
xi_p = math.copysign(math.pi / 2.0, tau_p)
|
|
249
|
+
eta_p = 0.0
|
|
250
|
+
else:
|
|
251
|
+
xi_p = math.atan2(tau_p, cos_lam)
|
|
252
|
+
eta_p = math.asinh(math.sin(lam) / math.sqrt(tau_p * tau_p + cos_lam * cos_lam))
|
|
253
|
+
xi = xi_p
|
|
254
|
+
eta = eta_p
|
|
255
|
+
for j, a in enumerate(_ALPHA, start=1):
|
|
256
|
+
xi += a * math.sin(2 * j * xi_p) * math.cosh(2 * j * eta_p)
|
|
257
|
+
eta += a * math.cos(2 * j * xi_p) * math.sinh(2 * j * eta_p)
|
|
258
|
+
return (_A * eta, _A * xi)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _tm_inverse(x: float, y: float) -> Tuple[float, float]:
|
|
262
|
+
"""Inverse of :func:`_tm_forward`: returns (longitude offset, latitude)."""
|
|
263
|
+
eta = x / _A
|
|
264
|
+
xi = y / _A
|
|
265
|
+
xi_p = xi
|
|
266
|
+
eta_p = eta
|
|
267
|
+
for j, b in enumerate(_BETA, start=1):
|
|
268
|
+
xi_p -= b * math.sin(2 * j * xi) * math.cosh(2 * j * eta)
|
|
269
|
+
eta_p -= b * math.cos(2 * j * xi) * math.sinh(2 * j * eta)
|
|
270
|
+
sinh_eta = math.sinh(eta_p)
|
|
271
|
+
sin_xi = math.sin(xi_p)
|
|
272
|
+
cos_xi = math.cos(xi_p)
|
|
273
|
+
denom = math.sqrt(sinh_eta * sinh_eta + cos_xi * cos_xi)
|
|
274
|
+
if denom == 0.0:
|
|
275
|
+
return (0.0, math.copysign(90.0, sin_xi))
|
|
276
|
+
tau_p = sin_xi / denom
|
|
277
|
+
tau = tau_p
|
|
278
|
+
e2 = WGS84_E * WGS84_E
|
|
279
|
+
for _ in range(20):
|
|
280
|
+
sigma = math.sinh(WGS84_E * math.atanh(WGS84_E * tau / math.sqrt(1.0 + tau * tau)))
|
|
281
|
+
tau_i = tau * math.sqrt(1.0 + sigma * sigma) - sigma * math.sqrt(1.0 + tau * tau)
|
|
282
|
+
d_tau = (
|
|
283
|
+
(tau_p - tau_i)
|
|
284
|
+
/ math.sqrt(1.0 + tau_i * tau_i)
|
|
285
|
+
* (1.0 + (1.0 - e2) * tau * tau)
|
|
286
|
+
/ ((1.0 - e2) * math.sqrt(1.0 + tau * tau))
|
|
287
|
+
)
|
|
288
|
+
tau += d_tau
|
|
289
|
+
if abs(d_tau) < 1e-14:
|
|
290
|
+
break
|
|
291
|
+
lat = math.degrees(math.atan(tau))
|
|
292
|
+
dlon = math.degrees(math.atan2(sinh_eta, cos_xi))
|
|
293
|
+
return (dlon, lat)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _central_meridian(zone: int) -> float:
|
|
297
|
+
return zone * 6.0 - 183.0
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def lonlat_to_utm(lon: float, lat: float, zone: int, south: bool) -> Tuple[float, float]:
|
|
301
|
+
"""WGS 84 lon/lat to UTM easting/northing in the given zone."""
|
|
302
|
+
dlon = (lon - _central_meridian(zone) + 180.0) % 360.0 - 180.0
|
|
303
|
+
x, y = _tm_forward(dlon, lat)
|
|
304
|
+
return (500000.0 + _K0 * x, _K0 * y + (10000000.0 if south else 0.0))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def utm_to_lonlat(easting: float, northing: float, zone: int, south: bool) -> Tuple[float, float]:
|
|
308
|
+
"""UTM easting/northing to lon/lat. Longitude is CM + offset (not wrapped)."""
|
|
309
|
+
x = (easting - 500000.0) / _K0
|
|
310
|
+
y = (northing - (10000000.0 if south else 0.0)) / _K0
|
|
311
|
+
dlon, lat = _tm_inverse(x, y)
|
|
312
|
+
return (_central_meridian(zone) + dlon, lat)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# --------------------------------------------------------------------------
|
|
316
|
+
# Point transforms between built-in CRSs
|
|
317
|
+
# --------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
Point = Tuple[float, float]
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _to_lonlat_fn(crs: str) -> Callable[[float, float], Point]:
|
|
323
|
+
if crs == WGS84:
|
|
324
|
+
return lambda x, y: (x, y)
|
|
325
|
+
if crs == WEB_MERCATOR:
|
|
326
|
+
return mercator_to_lonlat
|
|
327
|
+
utm = utm_zone_of_crs(crs)
|
|
328
|
+
if utm is not None:
|
|
329
|
+
zone, south = utm
|
|
330
|
+
return lambda x, y: utm_to_lonlat(x, y, zone, south)
|
|
331
|
+
raise ValueError(f"{crs} is not a built-in CRS")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _from_lonlat_fn(crs: str) -> Callable[[float, float], Point]:
|
|
335
|
+
if crs == WGS84:
|
|
336
|
+
return lambda x, y: (x, y)
|
|
337
|
+
if crs == WEB_MERCATOR:
|
|
338
|
+
return lonlat_to_mercator
|
|
339
|
+
utm = utm_zone_of_crs(crs)
|
|
340
|
+
if utm is not None:
|
|
341
|
+
zone, south = utm
|
|
342
|
+
return lambda x, y: lonlat_to_utm(x, y, zone, south)
|
|
343
|
+
raise ValueError(f"{crs} is not a built-in CRS")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def transform_point(x: float, y: float, src: CRSLike, dst: CRSLike) -> Point:
|
|
347
|
+
"""Transform one point between CRSs (built-in, else pyproj)."""
|
|
348
|
+
s, d = normalize_crs(src), normalize_crs(dst)
|
|
349
|
+
if s == d:
|
|
350
|
+
return (x, y)
|
|
351
|
+
if is_builtin(s) and is_builtin(d):
|
|
352
|
+
lon, lat = _to_lonlat_fn(s)(x, y)
|
|
353
|
+
return _from_lonlat_fn(d)(lon, lat)
|
|
354
|
+
t = _pyproj_transformer(s, d)
|
|
355
|
+
tx, ty = t.transform(x, y)
|
|
356
|
+
return (float(tx), float(ty))
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def transform_points(points: List[Point], src: str, dst: str) -> List[Point]:
|
|
360
|
+
"""Transform many points between two built-in CRSs via lon/lat."""
|
|
361
|
+
to_ll = _to_lonlat_fn(src)
|
|
362
|
+
from_ll = _from_lonlat_fn(dst)
|
|
363
|
+
return [from_ll(*to_ll(x, y)) for x, y in points]
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def require_pyproj() -> Any:
|
|
367
|
+
"""Import pyproj or raise an ImportError with installation advice."""
|
|
368
|
+
try:
|
|
369
|
+
import pyproj
|
|
370
|
+
except ImportError as exc: # pragma: no cover - exercised only without pyproj
|
|
371
|
+
raise ImportError(
|
|
372
|
+
"this CRS needs pyproj; install it with: pip install bboxkit[pyproj]"
|
|
373
|
+
) from exc
|
|
374
|
+
return pyproj
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@functools.lru_cache(maxsize=64)
|
|
378
|
+
def _pyproj_transformer(src: str, dst: str) -> Any:
|
|
379
|
+
pyproj = require_pyproj()
|
|
380
|
+
return pyproj.Transformer.from_crs(src, dst, always_xy=True)
|