mirrorworld 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.
- mirrorworld/__init__.py +1 -0
- mirrorworld/aerial/__init__.py +16 -0
- mirrorworld/aerial/__main__.py +62 -0
- mirrorworld/aerial/arcgis.py +74 -0
- mirrorworld/aerial/bing/__init__.py +3 -0
- mirrorworld/aerial/bing/api.py +22 -0
- mirrorworld/aerial/bing/bing.py +135 -0
- mirrorworld/aerial/bing/util.py +45 -0
- mirrorworld/aerial/dataclasses.py +47 -0
- mirrorworld/aerial/esri/__init__.py +3 -0
- mirrorworld/aerial/esri/api.py +35 -0
- mirrorworld/aerial/esri/esri.py +139 -0
- mirrorworld/aerial/esri/util.py +12 -0
- mirrorworld/aerial/google/__init__.py +3 -0
- mirrorworld/aerial/google/api.py +18 -0
- mirrorworld/aerial/google/google.py +136 -0
- mirrorworld/aerial/google/util.py +11 -0
- mirrorworld/aerial/pdok/__init__.py +2 -0
- mirrorworld/aerial/pdok/pdok.py +105 -0
- mirrorworld/aerial/usgs/__init__.py +2 -0
- mirrorworld/aerial/usgs/api.py +35 -0
- mirrorworld/aerial/usgs/usgs.py +142 -0
- mirrorworld/aerial/util.py +327 -0
- mirrorworld/aerial/wayback/__init__.py +2 -0
- mirrorworld/aerial/wayback/api.py +50 -0
- mirrorworld/aerial/wayback/release.py +40 -0
- mirrorworld/aerial/wayback/wayback.py +245 -0
- mirrorworld/baidu/__init__.py +2 -0
- mirrorworld/baidu/api.py +39 -0
- mirrorworld/baidu/baidu.py +199 -0
- mirrorworld/baidu/panorama.py +243 -0
- mirrorworld/baidu/parse.py +235 -0
- mirrorworld/baidu/util.py +19 -0
- mirrorworld/dataclasses.py +32 -0
- mirrorworld/elevation/__init__.py +7 -0
- mirrorworld/elevation/ahn.py +109 -0
- mirrorworld/elevation/copernicus.py +85 -0
- mirrorworld/elevation/ea.py +140 -0
- mirrorworld/elevation/usgs3dep.py +61 -0
- mirrorworld/elevation/wales.py +88 -0
- mirrorworld/exif.py +157 -0
- mirrorworld/geo.py +169 -0
- mirrorworld/ja/__init__.py +2 -0
- mirrorworld/ja/api.py +27 -0
- mirrorworld/ja/ja.py +158 -0
- mirrorworld/ja/panorama.py +103 -0
- mirrorworld/ja/parse.py +71 -0
- mirrorworld/ja/util.py +24 -0
- mirrorworld/kakao/__init__.py +2 -0
- mirrorworld/kakao/api.py +28 -0
- mirrorworld/kakao/kakao.py +261 -0
- mirrorworld/kakao/panorama.py +106 -0
- mirrorworld/kakao/parse.py +51 -0
- mirrorworld/kakao/util.py +35 -0
- mirrorworld/kartaview/__init__.py +3 -0
- mirrorworld/kartaview/api.py +46 -0
- mirrorworld/kartaview/kartaview.py +210 -0
- mirrorworld/kartaview/panorama.py +73 -0
- mirrorworld/kartaview/parse.py +76 -0
- mirrorworld/kartaview/util.py +10 -0
- mirrorworld/lidar/__init__.py +8 -0
- mirrorworld/lidar/ept.py +228 -0
- mirrorworld/lidar/usgs3dep.py +136 -0
- mirrorworld/lookaround/__init__.py +8 -0
- mirrorworld/lookaround/api.py +50 -0
- mirrorworld/lookaround/auth.py +69 -0
- mirrorworld/lookaround/geo.py +128 -0
- mirrorworld/lookaround/lookaround.py +161 -0
- mirrorworld/lookaround/panorama.py +175 -0
- mirrorworld/lookaround/parse.py +60 -0
- mirrorworld/lookaround/proto/GroundMetadataTile.proto +111 -0
- mirrorworld/lookaround/proto/GroundMetadataTile_pb2.py +44 -0
- mirrorworld/lookaround/proto/MuninViewState.proto +21 -0
- mirrorworld/lookaround/proto/MuninViewState_pb2.py +30 -0
- mirrorworld/lookaround/proto/__init__.py +0 -0
- mirrorworld/lookaround/proto/generate.bat +2 -0
- mirrorworld/lookaround/reproject.py +118 -0
- mirrorworld/lookaround/util.py +31 -0
- mirrorworld/mapillary/__init__.py +3 -0
- mirrorworld/mapillary/api.py +127 -0
- mirrorworld/mapillary/features.py +87 -0
- mirrorworld/mapillary/mapillary.py +410 -0
- mirrorworld/mapillary/panorama.py +87 -0
- mirrorworld/mapillary/parse.py +156 -0
- mirrorworld/mapillary/util.py +39 -0
- mirrorworld/mapy/__init__.py +2 -0
- mirrorworld/mapy/api.py +82 -0
- mirrorworld/mapy/mapy.py +266 -0
- mirrorworld/mapy/panorama.py +83 -0
- mirrorworld/mapy/parse.py +75 -0
- mirrorworld/mapy/util.py +38 -0
- mirrorworld/naver/__init__.py +2 -0
- mirrorworld/naver/api.py +106 -0
- mirrorworld/naver/model.py +120 -0
- mirrorworld/naver/naver.py +423 -0
- mirrorworld/naver/panorama.py +170 -0
- mirrorworld/naver/parse.py +138 -0
- mirrorworld/naver/proto/Model.proto +8 -0
- mirrorworld/naver/proto/Model_pb2.py +26 -0
- mirrorworld/naver/proto/__init__.py +0 -0
- mirrorworld/naver/proto/generate.bat +2 -0
- mirrorworld/naver/util.py +21 -0
- mirrorworld/panoramax/__init__.py +3 -0
- mirrorworld/panoramax/api.py +27 -0
- mirrorworld/panoramax/panorama.py +81 -0
- mirrorworld/panoramax/panoramax.py +222 -0
- mirrorworld/panoramax/parse.py +67 -0
- mirrorworld/panoramax/util.py +19 -0
- mirrorworld/raster.py +247 -0
- mirrorworld/satellite/__init__.py +8 -0
- mirrorworld/satellite/landsat.py +100 -0
- mirrorworld/satellite/naip.py +86 -0
- mirrorworld/satellite/scene.py +33 -0
- mirrorworld/satellite/sentinel1.py +87 -0
- mirrorworld/satellite/sentinel2.py +101 -0
- mirrorworld/satellite/stac.py +305 -0
- mirrorworld/satellite/worldcover.py +90 -0
- mirrorworld/services/__init__.py +14 -0
- mirrorworld/services/arcgis.py +103 -0
- mirrorworld/services/util.py +121 -0
- mirrorworld/services/wcs.py +158 -0
- mirrorworld/services/wms.py +158 -0
- mirrorworld/services/wmts.py +304 -0
- mirrorworld/streetside/__init__.py +2 -0
- mirrorworld/streetside/api.py +34 -0
- mirrorworld/streetside/panorama.py +62 -0
- mirrorworld/streetside/parse.py +46 -0
- mirrorworld/streetside/streetside.py +259 -0
- mirrorworld/streetside/util.py +44 -0
- mirrorworld/streetview/__init__.py +2 -0
- mirrorworld/streetview/api.py +234 -0
- mirrorworld/streetview/depth.py +124 -0
- mirrorworld/streetview/panorama.py +355 -0
- mirrorworld/streetview/parse.py +296 -0
- mirrorworld/streetview/protobuf.py +97 -0
- mirrorworld/streetview/streetview.py +284 -0
- mirrorworld/streetview/util.py +47 -0
- mirrorworld/util.py +387 -0
- mirrorworld/vector/__init__.py +2 -0
- mirrorworld/vector/osm.py +209 -0
- mirrorworld/yandex/__init__.py +2 -0
- mirrorworld/yandex/api.py +36 -0
- mirrorworld/yandex/panorama.py +133 -0
- mirrorworld/yandex/parse.py +161 -0
- mirrorworld/yandex/util.py +38 -0
- mirrorworld/yandex/yandex.py +176 -0
- mirrorworld-0.1.0.dist-info/METADATA +387 -0
- mirrorworld-0.1.0.dist-info/RECORD +150 -0
- mirrorworld-0.1.0.dist-info/WHEEL +4 -0
- mirrorworld-0.1.0.dist-info/licenses/LICENSE +22 -0
mirrorworld/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Mirrorworld addition (not in upstream streetlevel).
|
|
2
|
+
"""
|
|
3
|
+
Aerial and satellite orthoimagery from web map tile services.
|
|
4
|
+
|
|
5
|
+
One subpackage per provider, following the layout of the street-level providers
|
|
6
|
+
(``api.py`` builds and sends requests, ``<provider>.py`` holds the public functions,
|
|
7
|
+
``util.py`` holds provider-specific helpers). Shared tile math, stitching and saving
|
|
8
|
+
live in :mod:`mirrorworld.aerial.util`.
|
|
9
|
+
|
|
10
|
+
Usage::
|
|
11
|
+
|
|
12
|
+
from mirrorworld.aerial import bing
|
|
13
|
+
|
|
14
|
+
image = bing.get_image(47.6070, -122.3345, 47.6055, -122.3320, zoom=20)
|
|
15
|
+
bing.download_image(47.6070, -122.3345, 47.6055, -122.3320, "seattle.jpg", zoom=20)
|
|
16
|
+
"""
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Mirrorworld addition (not in upstream streetlevel).
|
|
2
|
+
"""
|
|
3
|
+
Command line entry point::
|
|
4
|
+
|
|
5
|
+
python -m mirrorworld.aerial PROVIDER NORTH WEST SOUTH EAST OUT [--zoom Z | --resolution M] [--max-zoom]
|
|
6
|
+
|
|
7
|
+
PROVIDER is one of bing, google, esri, usgs. ``--max-zoom`` only prints the highest available zoom level
|
|
8
|
+
at the center of the box.
|
|
9
|
+
"""
|
|
10
|
+
import argparse
|
|
11
|
+
import importlib
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
from .util import ground_resolution, zoom_for_resolution
|
|
15
|
+
|
|
16
|
+
PROVIDERS = ("bing", "google", "esri", "usgs")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main():
|
|
20
|
+
parser = argparse.ArgumentParser(prog="python -m mirrorworld.aerial",
|
|
21
|
+
description="Download aerial imagery for a bounding box.")
|
|
22
|
+
parser.add_argument("provider", choices=PROVIDERS)
|
|
23
|
+
parser.add_argument("north", type=float)
|
|
24
|
+
parser.add_argument("west", type=float)
|
|
25
|
+
parser.add_argument("south", type=float)
|
|
26
|
+
parser.add_argument("east", type=float)
|
|
27
|
+
parser.add_argument("out", nargs="?", help="output .jpg, .png or .tif")
|
|
28
|
+
group = parser.add_mutually_exclusive_group()
|
|
29
|
+
group.add_argument("--zoom", type=int, help="zoom level")
|
|
30
|
+
group.add_argument("--resolution", type=float, help="target ground resolution in m/px; picks the zoom level")
|
|
31
|
+
group.add_argument("--max-zoom", action="store_true", help="print the highest available zoom level and exit")
|
|
32
|
+
parser.add_argument("--max-tiles", type=int, default=20000)
|
|
33
|
+
parser.add_argument("--max-concurrency", type=int, default=8)
|
|
34
|
+
args = parser.parse_args()
|
|
35
|
+
|
|
36
|
+
provider = importlib.import_module(f"mirrorworld.aerial.{args.provider}")
|
|
37
|
+
lat, lon = (args.north + args.south) / 2, (args.west + args.east) / 2
|
|
38
|
+
|
|
39
|
+
if args.max_zoom:
|
|
40
|
+
zoom = provider.find_max_zoom(lat, lon)
|
|
41
|
+
res = None if zoom is None else round(ground_resolution(lat, zoom), 4)
|
|
42
|
+
print(json.dumps({"provider": provider.PROVIDER, "max_zoom": zoom, "ground_resolution_m": res}))
|
|
43
|
+
return
|
|
44
|
+
if not args.out:
|
|
45
|
+
parser.error("OUT is required unless --max-zoom is given")
|
|
46
|
+
|
|
47
|
+
zoom = args.zoom
|
|
48
|
+
if args.resolution is not None:
|
|
49
|
+
zoom = zoom_for_resolution(lat, args.resolution)
|
|
50
|
+
aerial = provider.download_image(args.north, args.west, args.south, args.east, args.out, zoom=zoom,
|
|
51
|
+
max_tiles=args.max_tiles, max_concurrency=args.max_concurrency)
|
|
52
|
+
print(json.dumps({"provider": aerial.provider, "zoom": aerial.zoom,
|
|
53
|
+
"ground_resolution_m": round(aerial.ground_resolution, 4),
|
|
54
|
+
"size_px": list(aerial.image.size), "tiles": aerial.tile_count,
|
|
55
|
+
"missing_tiles": len(aerial.missing_tiles), "metadata": aerial.metadata}))
|
|
56
|
+
if aerial.missing_tiles:
|
|
57
|
+
print(f"warning: {len(aerial.missing_tiles)} of {aerial.tile_count} tiles have no imagery "
|
|
58
|
+
f"at zoom {aerial.zoom} (left black)")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
main()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Mirrorworld addition (not in upstream streetlevel).
|
|
2
|
+
"""Requests against ArcGIS MapServer tile caches, shared by the Esri and USGS providers."""
|
|
3
|
+
from typing import Set, Tuple
|
|
4
|
+
|
|
5
|
+
from aiohttp import ClientSession
|
|
6
|
+
|
|
7
|
+
from .util import HEADERS, TILE_SIZE
|
|
8
|
+
|
|
9
|
+
TILEMAP_BLOCK = 64
|
|
10
|
+
"""Largest tilemap window the server answers without shrinking it."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_tile_url(service_url: str, x: int, y: int, zoom: int) -> str:
|
|
14
|
+
return f"{service_url}/tile/{zoom}/{y}/{x}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_tilemap_url(service_url: str, x: int, y: int, zoom: int, width: int, height: int) -> str:
|
|
18
|
+
return f"{service_url}/tilemap/{zoom}/{y}/{x}/{height}/{width}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def tile_exists(service_url: str, x: int, y: int, zoom: int, session=None) -> bool:
|
|
22
|
+
"""
|
|
23
|
+
Asks the tile cache whether a tile exists at this zoom level, without downloading it.
|
|
24
|
+
"""
|
|
25
|
+
import requests
|
|
26
|
+
requester = session if session else requests
|
|
27
|
+
response = requester.get(build_tilemap_url(service_url, x, y, zoom, 1, 1), headers=HEADERS, timeout=60)
|
|
28
|
+
if response.status_code != 200:
|
|
29
|
+
# USGS answers 422 "Tiles not present" for zoom levels outside its cache.
|
|
30
|
+
return False
|
|
31
|
+
data = response.json()
|
|
32
|
+
return bool(data.get("data") and data["data"][0])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def tile_exists_async(service_url: str, x: int, y: int, zoom: int, session: ClientSession) -> bool:
|
|
36
|
+
"""
|
|
37
|
+
Asks the tile cache whether a tile exists at this zoom level, without downloading it.
|
|
38
|
+
"""
|
|
39
|
+
async with session.get(build_tilemap_url(service_url, x, y, zoom, 1, 1), headers=HEADERS) as response:
|
|
40
|
+
if response.status != 200:
|
|
41
|
+
# USGS answers 422 "Tiles not present" for zoom levels outside its cache.
|
|
42
|
+
return False
|
|
43
|
+
data = await response.json(content_type=None)
|
|
44
|
+
return bool(data.get("data") and data["data"][0])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def find_missing_tiles_async(service_url: str, left: int, top: int, right: int, bottom: int,
|
|
48
|
+
zoom: int, session: ClientSession) -> Set[Tuple[int, int]]:
|
|
49
|
+
"""
|
|
50
|
+
Lists the tiles inside a global pixel window that the cache does not have, using the tilemap endpoint,
|
|
51
|
+
which answers for a block of up to 64x64 tiles per request.
|
|
52
|
+
|
|
53
|
+
:return: X/Y coordinates of missing tiles.
|
|
54
|
+
"""
|
|
55
|
+
tx0, ty0 = left // TILE_SIZE, top // TILE_SIZE
|
|
56
|
+
tx1, ty1 = (right - 1) // TILE_SIZE, (bottom - 1) // TILE_SIZE
|
|
57
|
+
missing = set()
|
|
58
|
+
for by in range(ty0, ty1 + 1, TILEMAP_BLOCK):
|
|
59
|
+
for bx in range(tx0, tx1 + 1, TILEMAP_BLOCK):
|
|
60
|
+
width = min(TILEMAP_BLOCK, tx1 - bx + 1)
|
|
61
|
+
height = min(TILEMAP_BLOCK, ty1 - by + 1)
|
|
62
|
+
url = build_tilemap_url(service_url, bx, by, zoom, width, height)
|
|
63
|
+
async with session.get(url, headers=HEADERS) as response:
|
|
64
|
+
if response.status != 200:
|
|
65
|
+
missing.update((bx + i, by + j) for j in range(height) for i in range(width))
|
|
66
|
+
continue
|
|
67
|
+
data = await response.json(content_type=None)
|
|
68
|
+
flags = data.get("data") or []
|
|
69
|
+
for j in range(height):
|
|
70
|
+
for i in range(width):
|
|
71
|
+
k = j * width + i
|
|
72
|
+
if k >= len(flags) or not flags[k]:
|
|
73
|
+
missing.add((bx + i, by + j))
|
|
74
|
+
return missing
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Mirrorworld addition (not in upstream streetlevel).
|
|
2
|
+
from aiohttp import ClientSession
|
|
3
|
+
from requests import Session
|
|
4
|
+
|
|
5
|
+
from .util import to_quadkey
|
|
6
|
+
from ..util import TileResponse, get_tile_response, get_tile_response_async
|
|
7
|
+
|
|
8
|
+
DEFAULT_GENERATION = 15000
|
|
9
|
+
"""Imagery generation (``g`` parameter). Requests without one are rejected with HTTP 400."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_tile_url(x: int, y: int, zoom: int, generation: int = DEFAULT_GENERATION) -> str:
|
|
13
|
+
return f"https://ecn.t{(x + y) % 4}.tiles.virtualearth.net/tiles/a{to_quadkey(x, y, zoom)}.jpeg" \
|
|
14
|
+
f"?g={generation}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_tile(x: int, y: int, zoom: int, session: Session = None) -> TileResponse:
|
|
18
|
+
return get_tile_response(build_tile_url(x, y, zoom), x, y, session)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def get_tile_async(x: int, y: int, zoom: int, session: ClientSession) -> TileResponse:
|
|
22
|
+
return await get_tile_response_async(build_tile_url(x, y, zoom), x, y, session)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Mirrorworld addition (not in upstream streetlevel).
|
|
2
|
+
"""Bing Maps aerial imagery, worldwide."""
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from PIL import Image
|
|
6
|
+
from aiohttp import ClientSession
|
|
7
|
+
from requests import Session
|
|
8
|
+
|
|
9
|
+
from . import api
|
|
10
|
+
from ..dataclasses import AerialImage
|
|
11
|
+
from ..util import TileResponse, get_tiled_image_async, run_sync, save_aerial_image, tile_to_image, \
|
|
12
|
+
get_header, wgs84_to_pixel, TILE_SIZE, DEFAULT_MAX_TILES, DEFAULT_MAX_CONCURRENCY
|
|
13
|
+
|
|
14
|
+
PROVIDER = "Bing Maps"
|
|
15
|
+
MAX_ZOOM = 21
|
|
16
|
+
METADATA_HEADERS = ("X-VE-TILEMETA-CaptureDatesRange", "X-VE-TILEMETA-Product-IDs")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_tile(x: int, y: int, zoom: int, session: Session = None) -> Optional[Image.Image]:
|
|
20
|
+
"""
|
|
21
|
+
Downloads one aerial tile.
|
|
22
|
+
|
|
23
|
+
:param x: X coordinate of the tile.
|
|
24
|
+
:param y: Y coordinate of the tile.
|
|
25
|
+
:param zoom: Zoom level.
|
|
26
|
+
:param session: *(optional)* A requests session.
|
|
27
|
+
:return: The tile as PIL image, or None if Bing has no imagery for it at this zoom level.
|
|
28
|
+
"""
|
|
29
|
+
response = api.get_tile(x, y, zoom, session)
|
|
30
|
+
return None if _is_missing(response) else tile_to_image(response)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def get_tile_async(x: int, y: int, zoom: int, session: ClientSession) -> Optional[Image.Image]:
|
|
34
|
+
response = await api.get_tile_async(x, y, zoom, session)
|
|
35
|
+
return None if _is_missing(response) else tile_to_image(response)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def find_max_zoom(lat: float, lon: float, session: Session = None) -> Optional[int]:
|
|
39
|
+
"""
|
|
40
|
+
Finds the highest zoom level with imagery at a location.
|
|
41
|
+
|
|
42
|
+
:param lat: Latitude.
|
|
43
|
+
:param lon: Longitude.
|
|
44
|
+
:param session: *(optional)* A requests session.
|
|
45
|
+
:return: The zoom level, e.g. 20 (≈0.10 m/px) in Seattle, or None if there is no imagery.
|
|
46
|
+
"""
|
|
47
|
+
for zoom in range(MAX_ZOOM, 0, -1):
|
|
48
|
+
x, y = _tile_at(lat, lon, zoom)
|
|
49
|
+
if not _is_missing(api.get_tile(x, y, zoom, session)):
|
|
50
|
+
return zoom
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def find_max_zoom_async(lat: float, lon: float, session: ClientSession) -> Optional[int]:
|
|
55
|
+
for zoom in range(MAX_ZOOM, 0, -1):
|
|
56
|
+
x, y = _tile_at(lat, lon, zoom)
|
|
57
|
+
if not _is_missing(await api.get_tile_async(x, y, zoom, session)):
|
|
58
|
+
return zoom
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_image(north: float, west: float, south: float, east: float, zoom: int = None,
|
|
63
|
+
max_tiles: int = DEFAULT_MAX_TILES,
|
|
64
|
+
max_concurrency: int = DEFAULT_MAX_CONCURRENCY) -> AerialImage:
|
|
65
|
+
"""
|
|
66
|
+
Downloads the aerial imagery inside a bounding box and stitches it into one image.
|
|
67
|
+
|
|
68
|
+
Capture date ranges reported by Bing are collected in ``AerialImage.metadata``.
|
|
69
|
+
|
|
70
|
+
:param north: Latitude of the top edge.
|
|
71
|
+
:param west: Longitude of the left edge.
|
|
72
|
+
:param south: Latitude of the bottom edge.
|
|
73
|
+
:param east: Longitude of the right edge.
|
|
74
|
+
:param zoom: *(optional)* Zoom level. Defaults to the highest one available at the center of the box.
|
|
75
|
+
:param max_tiles: *(optional)* Refuse requests covering more tiles than this. Defaults to 20000.
|
|
76
|
+
:param max_concurrency: *(optional)* Maximum number of simultaneous requests. Defaults to 8.
|
|
77
|
+
:return: The stitched image.
|
|
78
|
+
"""
|
|
79
|
+
return run_sync(get_image_async, north, west, south, east, zoom=zoom, max_tiles=max_tiles,
|
|
80
|
+
max_concurrency=max_concurrency)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def get_image_async(north: float, west: float, south: float, east: float, session: ClientSession,
|
|
84
|
+
zoom: int = None, max_tiles: int = DEFAULT_MAX_TILES,
|
|
85
|
+
max_concurrency: int = DEFAULT_MAX_CONCURRENCY) -> AerialImage:
|
|
86
|
+
if zoom is None:
|
|
87
|
+
zoom = await find_max_zoom_async((north + south) / 2, (west + east) / 2, session)
|
|
88
|
+
if zoom is None:
|
|
89
|
+
raise ValueError("No imagery at the center of the bounding box")
|
|
90
|
+
return await get_tiled_image_async(
|
|
91
|
+
north, west, south, east, zoom, PROVIDER,
|
|
92
|
+
lambda x, y: api.build_tile_url(x, y, zoom), _is_missing, session,
|
|
93
|
+
metadata_headers=METADATA_HEADERS, max_tiles=max_tiles, max_concurrency=max_concurrency)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def download_image(north: float, west: float, south: float, east: float, path: str, zoom: int = None,
|
|
97
|
+
pil_args: dict = None, max_tiles: int = DEFAULT_MAX_TILES,
|
|
98
|
+
max_concurrency: int = DEFAULT_MAX_CONCURRENCY) -> AerialImage:
|
|
99
|
+
"""
|
|
100
|
+
Downloads the aerial imagery inside a bounding box to a georeferenced file
|
|
101
|
+
(world file, ``.aux.xml`` and ``.json`` beside it; see :func:`mirrorworld.aerial.util.save_aerial_image`).
|
|
102
|
+
|
|
103
|
+
:param north: Latitude of the top edge.
|
|
104
|
+
:param west: Longitude of the left edge.
|
|
105
|
+
:param south: Latitude of the bottom edge.
|
|
106
|
+
:param east: Longitude of the right edge.
|
|
107
|
+
:param path: Output path.
|
|
108
|
+
:param zoom: *(optional)* Zoom level. Defaults to the highest one available at the center of the box.
|
|
109
|
+
:param pil_args: *(optional)* Additional arguments for PIL's ``Image.save`` method.
|
|
110
|
+
:param max_tiles: *(optional)* Refuse requests covering more tiles than this. Defaults to 20000.
|
|
111
|
+
:param max_concurrency: *(optional)* Maximum number of simultaneous requests. Defaults to 8.
|
|
112
|
+
:return: The stitched image.
|
|
113
|
+
"""
|
|
114
|
+
aerial = get_image(north, west, south, east, zoom, max_tiles, max_concurrency)
|
|
115
|
+
save_aerial_image(aerial, path, pil_args)
|
|
116
|
+
return aerial
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def download_image_async(north: float, west: float, south: float, east: float, path: str,
|
|
120
|
+
session: ClientSession, zoom: int = None, pil_args: dict = None,
|
|
121
|
+
max_tiles: int = DEFAULT_MAX_TILES,
|
|
122
|
+
max_concurrency: int = DEFAULT_MAX_CONCURRENCY) -> AerialImage:
|
|
123
|
+
aerial = await get_image_async(north, west, south, east, session, zoom, max_tiles, max_concurrency)
|
|
124
|
+
save_aerial_image(aerial, path, pil_args)
|
|
125
|
+
return aerial
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _is_missing(response: TileResponse) -> bool:
|
|
129
|
+
# Bing answers 200 with a placeholder PNG and flags it in a header.
|
|
130
|
+
return get_header(response.headers, "X-VE-Tile-Info") == "no-tile"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _tile_at(lat: float, lon: float, zoom: int):
|
|
134
|
+
px, py = wgs84_to_pixel(lat, lon, zoom)
|
|
135
|
+
return int(px // TILE_SIZE), int(py // TILE_SIZE)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Mirrorworld addition (not in upstream streetlevel).
|
|
2
|
+
def to_quadkey(x: int, y: int, zoom: int) -> str:
|
|
3
|
+
"""
|
|
4
|
+
Converts XYZ tile coordinates to a Bing Maps quadkey.
|
|
5
|
+
|
|
6
|
+
:param x: X coordinate.
|
|
7
|
+
:param y: Y coordinate.
|
|
8
|
+
:param zoom: Zoom level.
|
|
9
|
+
:return: The quadkey, which has one digit per zoom level.
|
|
10
|
+
"""
|
|
11
|
+
digits = []
|
|
12
|
+
for i in range(zoom, 0, -1):
|
|
13
|
+
mask = 1 << (i - 1)
|
|
14
|
+
digits.append(str((1 if x & mask else 0) + (2 if y & mask else 0)))
|
|
15
|
+
return "".join(digits)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def from_quadkey(quadkey: str):
|
|
19
|
+
"""
|
|
20
|
+
Converts a Bing Maps quadkey to XYZ tile coordinates.
|
|
21
|
+
|
|
22
|
+
:param quadkey: The quadkey.
|
|
23
|
+
:return: X, Y and zoom.
|
|
24
|
+
"""
|
|
25
|
+
x = y = 0
|
|
26
|
+
zoom = len(quadkey)
|
|
27
|
+
for i, digit in enumerate(quadkey):
|
|
28
|
+
mask = 1 << (zoom - i - 1)
|
|
29
|
+
if digit in "13":
|
|
30
|
+
x |= mask
|
|
31
|
+
if digit in "23":
|
|
32
|
+
y |= mask
|
|
33
|
+
return x, y, zoom
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_permalink(lat: float, lon: float, zoom: int = 18) -> str:
|
|
37
|
+
"""
|
|
38
|
+
Creates a Bing Maps link showing aerial imagery at a location.
|
|
39
|
+
|
|
40
|
+
:param lat: Latitude.
|
|
41
|
+
:param lon: Longitude.
|
|
42
|
+
:param zoom: *(optional)* Zoom level. Defaults to 18.
|
|
43
|
+
:return: A Bing Maps URL.
|
|
44
|
+
"""
|
|
45
|
+
return f"https://www.bing.com/maps?cp={lat}%7E{lon}&lvl={zoom}&style=a"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Mirrorworld addition (not in upstream streetlevel).
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import List, Tuple
|
|
4
|
+
|
|
5
|
+
from PIL import Image
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class AerialImage:
|
|
10
|
+
"""
|
|
11
|
+
A stitched aerial image covering a bounding box, with the information needed to georeference it.
|
|
12
|
+
|
|
13
|
+
The image is in Web Mercator (EPSG:3857). Its edges lie on pixel boundaries of the tile grid at ``zoom``,
|
|
14
|
+
so ``north``/``west``/``south``/``east`` can differ from the requested box by less than one pixel.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
image: Image.Image
|
|
18
|
+
"""The stitched image."""
|
|
19
|
+
provider: str
|
|
20
|
+
"""Name of the imagery provider."""
|
|
21
|
+
zoom: int
|
|
22
|
+
"""Zoom level of the tiles the image was stitched from."""
|
|
23
|
+
|
|
24
|
+
north: float
|
|
25
|
+
"""Latitude of the top edge."""
|
|
26
|
+
west: float
|
|
27
|
+
"""Longitude of the left edge."""
|
|
28
|
+
south: float
|
|
29
|
+
"""Latitude of the bottom edge."""
|
|
30
|
+
east: float
|
|
31
|
+
"""Longitude of the right edge."""
|
|
32
|
+
|
|
33
|
+
origin_x: float
|
|
34
|
+
"""X coordinate of the top left corner in EPSG:3857 meters."""
|
|
35
|
+
origin_y: float
|
|
36
|
+
"""Y coordinate of the top left corner in EPSG:3857 meters."""
|
|
37
|
+
mercator_resolution: float
|
|
38
|
+
"""Pixel size in EPSG:3857 meters. Larger than the ground resolution away from the equator."""
|
|
39
|
+
ground_resolution: float
|
|
40
|
+
"""Approximate pixel size on the ground in meters at the center of the image."""
|
|
41
|
+
|
|
42
|
+
tile_count: int
|
|
43
|
+
"""Number of tiles the image was stitched from."""
|
|
44
|
+
missing_tiles: List[Tuple[int, int]] = field(default_factory=list)
|
|
45
|
+
"""X/Y coordinates of tiles the provider has no imagery for at this zoom level. These areas are black."""
|
|
46
|
+
metadata: dict = field(default_factory=dict)
|
|
47
|
+
"""Provider-specific metadata, e.g. capture date ranges reported by Bing."""
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Mirrorworld addition (not in upstream streetlevel).
|
|
2
|
+
from typing import Set, Tuple
|
|
3
|
+
|
|
4
|
+
from aiohttp import ClientSession
|
|
5
|
+
from requests import Session
|
|
6
|
+
|
|
7
|
+
from .. import arcgis
|
|
8
|
+
from ..util import TileResponse, get_tile_response, get_tile_response_async
|
|
9
|
+
|
|
10
|
+
SERVICE_URL = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_tile_url(x: int, y: int, zoom: int) -> str:
|
|
14
|
+
return arcgis.build_tile_url(SERVICE_URL, x, y, zoom)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_tile(x: int, y: int, zoom: int, session: Session = None) -> TileResponse:
|
|
18
|
+
return get_tile_response(build_tile_url(x, y, zoom), x, y, session)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def get_tile_async(x: int, y: int, zoom: int, session: ClientSession) -> TileResponse:
|
|
22
|
+
return await get_tile_response_async(build_tile_url(x, y, zoom), x, y, session)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def tile_exists(x: int, y: int, zoom: int, session: Session = None) -> bool:
|
|
26
|
+
return arcgis.tile_exists(SERVICE_URL, x, y, zoom, session)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def tile_exists_async(x: int, y: int, zoom: int, session: ClientSession) -> bool:
|
|
30
|
+
return await arcgis.tile_exists_async(SERVICE_URL, x, y, zoom, session)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def find_missing_tiles_async(left: int, top: int, right: int, bottom: int, zoom: int,
|
|
34
|
+
session: ClientSession) -> Set[Tuple[int, int]]:
|
|
35
|
+
return await arcgis.find_missing_tiles_async(SERVICE_URL, left, top, right, bottom, zoom, session)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Mirrorworld addition (not in upstream streetlevel).
|
|
2
|
+
"""Esri World Imagery (a mosaic of satellite and aerial imagery), worldwide."""
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from PIL import Image
|
|
6
|
+
from aiohttp import ClientSession
|
|
7
|
+
from requests import Session
|
|
8
|
+
|
|
9
|
+
from . import api
|
|
10
|
+
from ..dataclasses import AerialImage
|
|
11
|
+
from ..util import TileResponse, bbox_to_pixels, get_tiled_image_async, run_sync, save_aerial_image, \
|
|
12
|
+
tile_to_image, wgs84_to_pixel, TILE_SIZE, DEFAULT_MAX_TILES, DEFAULT_MAX_CONCURRENCY
|
|
13
|
+
|
|
14
|
+
PROVIDER = "Esri World Imagery"
|
|
15
|
+
MAX_ZOOM = 23
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_tile(x: int, y: int, zoom: int, session: Session = None) -> Optional[Image.Image]:
|
|
19
|
+
"""
|
|
20
|
+
Downloads one aerial tile.
|
|
21
|
+
|
|
22
|
+
:param x: X coordinate of the tile.
|
|
23
|
+
:param y: Y coordinate of the tile.
|
|
24
|
+
:param zoom: Zoom level.
|
|
25
|
+
:param session: *(optional)* A requests session.
|
|
26
|
+
:return: The tile as PIL image, or None if Esri has no imagery for it at this zoom level.
|
|
27
|
+
"""
|
|
28
|
+
if not api.tile_exists(x, y, zoom, session):
|
|
29
|
+
return None
|
|
30
|
+
response = api.get_tile(x, y, zoom, session)
|
|
31
|
+
return None if _is_missing(response) else tile_to_image(response)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def get_tile_async(x: int, y: int, zoom: int, session: ClientSession) -> Optional[Image.Image]:
|
|
35
|
+
# Beyond its coverage the server returns a "Map data not yet available" placeholder with status 200,
|
|
36
|
+
# so availability is checked with the tilemap endpoint first.
|
|
37
|
+
if not await api.tile_exists_async(x, y, zoom, session):
|
|
38
|
+
return None
|
|
39
|
+
response = await api.get_tile_async(x, y, zoom, session)
|
|
40
|
+
return None if _is_missing(response) else tile_to_image(response)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def find_max_zoom(lat: float, lon: float, session: Session = None) -> Optional[int]:
|
|
44
|
+
"""
|
|
45
|
+
Finds the highest zoom level with imagery at a location.
|
|
46
|
+
|
|
47
|
+
:param lat: Latitude.
|
|
48
|
+
:param lon: Longitude.
|
|
49
|
+
:param session: *(optional)* A requests session.
|
|
50
|
+
:return: The zoom level, e.g. 19 (≈0.20 m/px) in Seattle, or None if there is no imagery.
|
|
51
|
+
"""
|
|
52
|
+
for zoom in range(MAX_ZOOM, -1, -1):
|
|
53
|
+
x, y = _tile_at(lat, lon, zoom)
|
|
54
|
+
if api.tile_exists(x, y, zoom, session):
|
|
55
|
+
return zoom
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def find_max_zoom_async(lat: float, lon: float, session: ClientSession) -> Optional[int]:
|
|
60
|
+
for zoom in range(MAX_ZOOM, -1, -1):
|
|
61
|
+
x, y = _tile_at(lat, lon, zoom)
|
|
62
|
+
if await api.tile_exists_async(x, y, zoom, session):
|
|
63
|
+
return zoom
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_image(north: float, west: float, south: float, east: float, zoom: int = None,
|
|
68
|
+
max_tiles: int = DEFAULT_MAX_TILES,
|
|
69
|
+
max_concurrency: int = DEFAULT_MAX_CONCURRENCY) -> AerialImage:
|
|
70
|
+
"""
|
|
71
|
+
Downloads the aerial imagery inside a bounding box and stitches it into one image.
|
|
72
|
+
|
|
73
|
+
:param north: Latitude of the top edge.
|
|
74
|
+
:param west: Longitude of the left edge.
|
|
75
|
+
:param south: Latitude of the bottom edge.
|
|
76
|
+
:param east: Longitude of the right edge.
|
|
77
|
+
:param zoom: *(optional)* Zoom level. Defaults to the highest one available at the center of the box.
|
|
78
|
+
:param max_tiles: *(optional)* Refuse requests covering more tiles than this. Defaults to 20000.
|
|
79
|
+
:param max_concurrency: *(optional)* Maximum number of simultaneous requests. Defaults to 8.
|
|
80
|
+
:return: The stitched image.
|
|
81
|
+
"""
|
|
82
|
+
return run_sync(get_image_async, north, west, south, east, zoom=zoom, max_tiles=max_tiles,
|
|
83
|
+
max_concurrency=max_concurrency)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def get_image_async(north: float, west: float, south: float, east: float, session: ClientSession,
|
|
87
|
+
zoom: int = None, max_tiles: int = DEFAULT_MAX_TILES,
|
|
88
|
+
max_concurrency: int = DEFAULT_MAX_CONCURRENCY) -> AerialImage:
|
|
89
|
+
if zoom is None:
|
|
90
|
+
zoom = await find_max_zoom_async((north + south) / 2, (west + east) / 2, session)
|
|
91
|
+
if zoom is None:
|
|
92
|
+
raise ValueError("No imagery at the center of the bounding box")
|
|
93
|
+
left, top, right, bottom = bbox_to_pixels(north, west, south, east, zoom)
|
|
94
|
+
missing = await api.find_missing_tiles_async(left, top, right, bottom, zoom, session)
|
|
95
|
+
return await get_tiled_image_async(
|
|
96
|
+
north, west, south, east, zoom, PROVIDER,
|
|
97
|
+
lambda x, y: api.build_tile_url(x, y, zoom), _is_missing, session,
|
|
98
|
+
skip_tiles=missing, max_tiles=max_tiles, max_concurrency=max_concurrency)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def download_image(north: float, west: float, south: float, east: float, path: str, zoom: int = None,
|
|
102
|
+
pil_args: dict = None, max_tiles: int = DEFAULT_MAX_TILES,
|
|
103
|
+
max_concurrency: int = DEFAULT_MAX_CONCURRENCY) -> AerialImage:
|
|
104
|
+
"""
|
|
105
|
+
Downloads the aerial imagery inside a bounding box to a georeferenced file
|
|
106
|
+
(world file, ``.aux.xml`` and ``.json`` beside it; see :func:`mirrorworld.aerial.util.save_aerial_image`).
|
|
107
|
+
|
|
108
|
+
:param north: Latitude of the top edge.
|
|
109
|
+
:param west: Longitude of the left edge.
|
|
110
|
+
:param south: Latitude of the bottom edge.
|
|
111
|
+
:param east: Longitude of the right edge.
|
|
112
|
+
:param path: Output path.
|
|
113
|
+
:param zoom: *(optional)* Zoom level. Defaults to the highest one available at the center of the box.
|
|
114
|
+
:param pil_args: *(optional)* Additional arguments for PIL's ``Image.save`` method.
|
|
115
|
+
:param max_tiles: *(optional)* Refuse requests covering more tiles than this. Defaults to 20000.
|
|
116
|
+
:param max_concurrency: *(optional)* Maximum number of simultaneous requests. Defaults to 8.
|
|
117
|
+
:return: The stitched image.
|
|
118
|
+
"""
|
|
119
|
+
aerial = get_image(north, west, south, east, zoom, max_tiles, max_concurrency)
|
|
120
|
+
save_aerial_image(aerial, path, pil_args)
|
|
121
|
+
return aerial
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def download_image_async(north: float, west: float, south: float, east: float, path: str,
|
|
125
|
+
session: ClientSession, zoom: int = None, pil_args: dict = None,
|
|
126
|
+
max_tiles: int = DEFAULT_MAX_TILES,
|
|
127
|
+
max_concurrency: int = DEFAULT_MAX_CONCURRENCY) -> AerialImage:
|
|
128
|
+
aerial = await get_image_async(north, west, south, east, session, zoom, max_tiles, max_concurrency)
|
|
129
|
+
save_aerial_image(aerial, path, pil_args)
|
|
130
|
+
return aerial
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _is_missing(response: TileResponse) -> bool:
|
|
134
|
+
return response.status == 404
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _tile_at(lat: float, lon: float, zoom: int):
|
|
138
|
+
px, py = wgs84_to_pixel(lat, lon, zoom)
|
|
139
|
+
return int(px // TILE_SIZE), int(py // TILE_SIZE)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Mirrorworld addition (not in upstream streetlevel).
|
|
2
|
+
def build_permalink(lat: float, lon: float, zoom: int = 18) -> str:
|
|
3
|
+
"""
|
|
4
|
+
Creates a link to Esri World Imagery Wayback, which shows the current World Imagery
|
|
5
|
+
and its earlier releases, at a location.
|
|
6
|
+
|
|
7
|
+
:param lat: Latitude.
|
|
8
|
+
:param lon: Longitude.
|
|
9
|
+
:param zoom: *(optional)* Zoom level. Defaults to 18.
|
|
10
|
+
:return: A Wayback URL.
|
|
11
|
+
"""
|
|
12
|
+
return f"https://livingatlas.arcgis.com/wayback/#mapCenter={lon}%2C{lat}%2C{zoom}"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Mirrorworld addition (not in upstream streetlevel).
|
|
2
|
+
from aiohttp import ClientSession
|
|
3
|
+
from requests import Session
|
|
4
|
+
|
|
5
|
+
from ..util import TileResponse, get_tile_response, get_tile_response_async
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_tile_url(x: int, y: int, zoom: int) -> str:
|
|
9
|
+
# lyrs=s: satellite/aerial without labels.
|
|
10
|
+
return f"https://mt{(x + y) % 4}.google.com/vt/lyrs=s&x={x}&y={y}&z={zoom}"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_tile(x: int, y: int, zoom: int, session: Session = None) -> TileResponse:
|
|
14
|
+
return get_tile_response(build_tile_url(x, y, zoom), x, y, session)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def get_tile_async(x: int, y: int, zoom: int, session: ClientSession) -> TileResponse:
|
|
18
|
+
return await get_tile_response_async(build_tile_url(x, y, zoom), x, y, session)
|