tilegrab 1.2.0b1__py3-none-any.whl → 1.2.0b2__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.
- tilegrab/__init__.py +1 -1
- tilegrab/cli.py +63 -19
- tilegrab/dataset.py +6 -4
- tilegrab/downloader.py +32 -28
- tilegrab/images.py +135 -68
- tilegrab/sources.py +14 -8
- tilegrab/tiles.py +18 -14
- {tilegrab-1.2.0b1.dist-info → tilegrab-1.2.0b2.dist-info}/METADATA +64 -99
- tilegrab-1.2.0b2.dist-info/RECORD +15 -0
- tilegrab-1.2.0b1.dist-info/RECORD +0 -15
- {tilegrab-1.2.0b1.dist-info → tilegrab-1.2.0b2.dist-info}/WHEEL +0 -0
- {tilegrab-1.2.0b1.dist-info → tilegrab-1.2.0b2.dist-info}/entry_points.txt +0 -0
- {tilegrab-1.2.0b1.dist-info → tilegrab-1.2.0b2.dist-info}/licenses/LICENSE +0 -0
- {tilegrab-1.2.0b1.dist-info → tilegrab-1.2.0b2.dist-info}/top_level.txt +0 -0
tilegrab/__init__.py
CHANGED
tilegrab/cli.py
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
import logging
|
|
3
3
|
import argparse
|
|
4
4
|
from pathlib import Path
|
|
5
|
+
from typing import List
|
|
5
6
|
from tilegrab.downloader import Downloader
|
|
6
|
-
from tilegrab.images import TileImageCollection
|
|
7
|
+
from tilegrab.images import TileImageCollection, ExportType
|
|
7
8
|
from tilegrab.logs import setup_logging
|
|
8
9
|
from tilegrab.tiles import TilesByShape, TilesByBBox
|
|
9
10
|
from tilegrab.dataset import GeoDataset
|
|
@@ -14,7 +15,8 @@ logger = logging.getLogger(__name__)
|
|
|
14
15
|
|
|
15
16
|
def parse_args() -> argparse.Namespace:
|
|
16
17
|
p = argparse.ArgumentParser(
|
|
17
|
-
prog="tilegrab",
|
|
18
|
+
prog="tilegrab",
|
|
19
|
+
description="Download and mosaic map tiles"
|
|
18
20
|
)
|
|
19
21
|
|
|
20
22
|
# Create a named group for the vector polygon source
|
|
@@ -56,13 +58,14 @@ def parse_args() -> argparse.Namespace:
|
|
|
56
58
|
mosaic_out_group = p.add_argument_group(
|
|
57
59
|
title="Mosaic export formats", description="Formats for the output mosaic image"
|
|
58
60
|
)
|
|
59
|
-
mosaic_group = mosaic_out_group.add_mutually_exclusive_group(required=
|
|
61
|
+
mosaic_group = mosaic_out_group.add_mutually_exclusive_group(required=False)
|
|
60
62
|
mosaic_group.add_argument("--jpg", action="store_true", help="JPG image; no geo-reference")
|
|
61
63
|
mosaic_group.add_argument("--png", action="store_true", help="PNG image; no geo-reference")
|
|
62
64
|
mosaic_group.add_argument("--tiff", action="store_true", help="GeoTiff image; with geo-reference")
|
|
65
|
+
mosaic_group.set_defaults(tiff=True)
|
|
63
66
|
|
|
64
67
|
# other options
|
|
65
|
-
p.add_argument("--zoom", type=int, required=True, help="Zoom level (integer)")
|
|
68
|
+
p.add_argument("--zoom", type=int, required=True, help="Zoom level (integer between 1 and 22)")
|
|
66
69
|
p.add_argument(
|
|
67
70
|
"--tiles-out",
|
|
68
71
|
type=Path,
|
|
@@ -80,16 +83,31 @@ def parse_args() -> argparse.Namespace:
|
|
|
80
83
|
help="Only mosaic tiles; do not download",
|
|
81
84
|
)
|
|
82
85
|
p.add_argument(
|
|
83
|
-
"--
|
|
86
|
+
"--group-tiles", type=str, default=None, help="Mosaic tiles but according to given WxH into ./grouped_tiles"
|
|
84
87
|
)
|
|
85
|
-
p.add_argument("--quiet", action="store_true", help="Hide all prints")
|
|
86
|
-
p.add_argument("--debug", action="store_true", help="Enable debug logging")
|
|
87
88
|
p.add_argument(
|
|
88
|
-
"--
|
|
89
|
-
action="store_true",
|
|
90
|
-
help="Only for testing purposes, not for normal use",
|
|
89
|
+
"--group-overlap", action="store_true", help="Overlap with the next consecutive tile when grouping"
|
|
91
90
|
)
|
|
92
|
-
|
|
91
|
+
p.add_argument(
|
|
92
|
+
"--tile-limit", type=int, default=250, help="Override maximum tile limit that can download (use with caution)"
|
|
93
|
+
)
|
|
94
|
+
p.add_argument(
|
|
95
|
+
"--workers", type=int, default=None, help="Max number of threads to use when parallel downloading"
|
|
96
|
+
)
|
|
97
|
+
p.add_argument(
|
|
98
|
+
"--parallel",
|
|
99
|
+
action=argparse.BooleanOptionalAction,
|
|
100
|
+
default=False,
|
|
101
|
+
help="Download tiles sequentially, no parallel downloading"
|
|
102
|
+
)
|
|
103
|
+
p.add_argument(
|
|
104
|
+
"--progress",
|
|
105
|
+
action=argparse.BooleanOptionalAction,
|
|
106
|
+
default=False,
|
|
107
|
+
help="Hide tile download progress bar"
|
|
108
|
+
)
|
|
109
|
+
p.add_argument("--quiet", action="store_true", help="Hide all prints")
|
|
110
|
+
p.add_argument("--debug", action="store_true", help="Enable debug logging")
|
|
93
111
|
return p.parse_args()
|
|
94
112
|
|
|
95
113
|
|
|
@@ -125,9 +143,9 @@ def main():
|
|
|
125
143
|
)
|
|
126
144
|
|
|
127
145
|
if args.shape:
|
|
128
|
-
tiles = TilesByShape(dataset, zoom=args.zoom)
|
|
146
|
+
tiles = TilesByShape(geo_dataset=dataset, zoom=args.zoom, SAFE_LIMIT=args.tile_limit)
|
|
129
147
|
elif args.bbox:
|
|
130
|
-
tiles = TilesByBBox(dataset, zoom=args.zoom)
|
|
148
|
+
tiles = TilesByBBox(geo_dataset=dataset, zoom=args.zoom, SAFE_LIMIT=args.tile_limit)
|
|
131
149
|
else:
|
|
132
150
|
logger.error("No extent selector selected")
|
|
133
151
|
raise SystemExit("No extent selector selected")
|
|
@@ -154,18 +172,44 @@ def main():
|
|
|
154
172
|
logger.error("No tile source selected")
|
|
155
173
|
raise SystemExit("No tile source selected")
|
|
156
174
|
|
|
157
|
-
downloader = Downloader(
|
|
175
|
+
downloader = Downloader(
|
|
176
|
+
tile_collection=tiles,
|
|
177
|
+
tile_source=source,
|
|
178
|
+
temp_tile_dir=args.tiles_out)
|
|
179
|
+
|
|
158
180
|
result: TileImageCollection
|
|
159
|
-
|
|
160
181
|
if args.mosaic_only:
|
|
161
|
-
result = TileImageCollection(args.tiles_out)
|
|
162
|
-
result.load(tiles)
|
|
182
|
+
result = TileImageCollection(path=args.tiles_out)
|
|
183
|
+
result.load(tile_collection=tiles)
|
|
163
184
|
else:
|
|
164
|
-
result = downloader.run(
|
|
185
|
+
result = downloader.run(
|
|
186
|
+
workers=args.workers,
|
|
187
|
+
show_progress=args.no_progress,
|
|
188
|
+
parallel_download=args.no_parallel)
|
|
165
189
|
logger.info(f"Download result: {result}")
|
|
166
190
|
|
|
191
|
+
ex_types: List[int] = []
|
|
192
|
+
|
|
167
193
|
if not args.download_only:
|
|
168
|
-
|
|
194
|
+
if args.tiff:
|
|
195
|
+
ex_types.append(ExportType.TIFF)
|
|
196
|
+
if args.png:
|
|
197
|
+
ex_types.append(ExportType.PNG)
|
|
198
|
+
if args.jpg:
|
|
199
|
+
ex_types.append(ExportType.JPG)
|
|
200
|
+
|
|
201
|
+
if args.group_tiles:
|
|
202
|
+
w,h = args.group_tiles.lower().split("x")
|
|
203
|
+
result.group(
|
|
204
|
+
width=int(w),
|
|
205
|
+
height=int(h),
|
|
206
|
+
export_types=ex_types,
|
|
207
|
+
overlap=args.group_overlap)
|
|
208
|
+
else:
|
|
209
|
+
result.mosaic(export_types=ex_types)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
|
|
169
213
|
logger.info("Done")
|
|
170
214
|
|
|
171
215
|
except Exception as e:
|
tilegrab/dataset.py
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
1
2
|
import logging
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
from box import Box
|
|
4
5
|
from typing import Union
|
|
5
|
-
|
|
6
|
+
from functools import cache
|
|
6
7
|
|
|
7
8
|
logger = logging.getLogger(__name__)
|
|
8
9
|
|
|
9
10
|
TILE_EPSG = 4326 #Web Mercator - 3857 | 4326 - WGS84
|
|
10
|
-
class GeoDataset:
|
|
11
11
|
|
|
12
|
+
class GeoDataset:
|
|
12
13
|
@property
|
|
14
|
+
@cache
|
|
13
15
|
def bbox(self):
|
|
14
16
|
minx, miny, maxx, maxy = self.source.total_bounds
|
|
15
17
|
bbox_dict = Box({"minx": minx, "miny": miny, "maxx": maxx, "maxy": maxy})
|
|
@@ -22,14 +24,14 @@ class GeoDataset:
|
|
|
22
24
|
|
|
23
25
|
@property
|
|
24
26
|
def x_extent(self):
|
|
25
|
-
minx, maxx = self.source.total_bounds
|
|
27
|
+
minx, _, maxx, _ = self.source.total_bounds
|
|
26
28
|
extent = (maxx - minx) + 1
|
|
27
29
|
logger.debug(f"X extent calculated: {extent}")
|
|
28
30
|
return extent
|
|
29
31
|
|
|
30
32
|
@property
|
|
31
33
|
def y_extent(self):
|
|
32
|
-
miny, maxy = self.source.total_bounds
|
|
34
|
+
_, miny, _, maxy = self.source.total_bounds
|
|
33
35
|
extent = (maxy - miny) + 1
|
|
34
36
|
logger.debug(f"Y extent calculated: {extent}")
|
|
35
37
|
return extent
|
tilegrab/downloader.py
CHANGED
|
@@ -1,24 +1,20 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import os
|
|
3
|
-
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
4
3
|
from dataclasses import dataclass
|
|
5
4
|
from typing import List, Optional, Union
|
|
6
5
|
import requests
|
|
7
|
-
from requests.adapters import HTTPAdapter, Retry
|
|
8
|
-
from tqdm import tqdm
|
|
9
|
-
import tempfile
|
|
10
6
|
from pathlib import Path
|
|
11
7
|
|
|
12
8
|
from tilegrab.sources import TileSource
|
|
13
9
|
from tilegrab.tiles import TileCollection, Tile
|
|
14
10
|
from tilegrab.images import TileImageCollection, TileImage
|
|
15
|
-
|
|
11
|
+
|
|
16
12
|
|
|
17
13
|
logger = logging.getLogger(__name__)
|
|
18
14
|
|
|
19
15
|
@dataclass
|
|
20
16
|
class Downloader:
|
|
21
|
-
|
|
17
|
+
tile_collection: TileCollection
|
|
22
18
|
tile_source: TileSource
|
|
23
19
|
temp_tile_dir: Optional[Union[str, Path]] = None
|
|
24
20
|
session: Optional[requests.Session] = None
|
|
@@ -29,6 +25,7 @@ class Downloader:
|
|
|
29
25
|
|
|
30
26
|
def __post_init__(self):
|
|
31
27
|
if not self.temp_tile_dir:
|
|
28
|
+
import tempfile
|
|
32
29
|
tmpdir = tempfile.mkdtemp()
|
|
33
30
|
self.temp_tile_dir = Path(tmpdir)
|
|
34
31
|
logger.debug(f"Created temporary directory: {tmpdir}")
|
|
@@ -41,6 +38,8 @@ class Downloader:
|
|
|
41
38
|
logger.info(f"Downloader initialized: source={self.tile_source.name}, timeout={self.REQUEST_TIMEOUT}s, max_retries={self.MAX_RETRIES}")
|
|
42
39
|
|
|
43
40
|
def _init_session(self) -> requests.Session:
|
|
41
|
+
from requests.adapters import HTTPAdapter, Retry
|
|
42
|
+
|
|
44
43
|
logger.debug("Initializing HTTP session with retry strategy")
|
|
45
44
|
session = requests.Session()
|
|
46
45
|
retries = Retry(
|
|
@@ -90,36 +89,41 @@ class Downloader:
|
|
|
90
89
|
|
|
91
90
|
def run(
|
|
92
91
|
self,
|
|
93
|
-
workers: int =
|
|
92
|
+
workers: Union[int, None] = None,
|
|
94
93
|
show_progress: bool = True,
|
|
94
|
+
parallel_download: bool = True
|
|
95
95
|
) -> TileImageCollection:
|
|
96
|
-
logger.info(f"Starting download run: {len(self.
|
|
96
|
+
logger.info(f"Starting download run: {len(self.tile_collection)} tiles, workers={workers}, show_progress={show_progress}")
|
|
97
97
|
|
|
98
98
|
results = []
|
|
99
99
|
|
|
100
100
|
if show_progress:
|
|
101
|
-
|
|
101
|
+
from tqdm import tqdm
|
|
102
|
+
pbar = tqdm(total=len(self.tile_collection), desc=f" Downloading", unit="tile")
|
|
102
103
|
else:
|
|
103
104
|
pbar = None
|
|
104
105
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
106
|
+
|
|
107
|
+
if parallel_download:
|
|
108
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
109
|
+
with ThreadPoolExecutor(max_workers=workers) as exe:
|
|
110
|
+
future_to_tile = {
|
|
111
|
+
exe.submit(self.download_tile, tile): tile for tile in self.tile_collection.to_list
|
|
112
|
+
}
|
|
113
|
+
for fut in as_completed(future_to_tile):
|
|
114
|
+
try:
|
|
115
|
+
results.append(fut.result())
|
|
116
|
+
except Exception:
|
|
117
|
+
results.append(False)
|
|
118
|
+
|
|
119
|
+
if pbar:
|
|
120
|
+
pbar.update(1)
|
|
121
|
+
else:
|
|
122
|
+
for tile in self.tile_collection.to_list:
|
|
123
|
+
res = self.download_tile(tile)
|
|
124
|
+
results.append(res)
|
|
125
|
+
if pbar:
|
|
126
|
+
pbar.update(1)
|
|
123
127
|
|
|
124
128
|
if pbar:
|
|
125
129
|
pbar.close()
|
|
@@ -129,7 +133,7 @@ class Downloader:
|
|
|
129
133
|
|
|
130
134
|
def _evaluate_result(self, result: List):
|
|
131
135
|
success = sum(1 for v in result if v)
|
|
132
|
-
total = len(self.
|
|
136
|
+
total = len(self.tile_collection)
|
|
133
137
|
logger.info(f"Download completed: {success}/{total} successful ({100*success/total:.1f}%)")
|
|
134
138
|
if success < total:
|
|
135
139
|
logger.warning(f"Failed to download {total - success} tiles")
|
tilegrab/images.py
CHANGED
|
@@ -1,35 +1,44 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from functools import cache
|
|
1
3
|
import logging
|
|
2
4
|
from dataclasses import dataclass
|
|
3
|
-
from io import BytesIO
|
|
4
5
|
from pathlib import Path, PosixPath, WindowsPath
|
|
5
|
-
import re
|
|
6
6
|
from typing import Any, List, Union
|
|
7
|
-
from PIL import Image as
|
|
7
|
+
from PIL import Image as PILImage
|
|
8
8
|
from box import Box
|
|
9
9
|
from tilegrab.tiles import Tile, TileCollection
|
|
10
10
|
import os
|
|
11
|
+
import numpy as np
|
|
12
|
+
from numpy.lib.stride_tricks import sliding_window_view
|
|
11
13
|
|
|
12
14
|
logger = logging.getLogger(__name__)
|
|
13
15
|
|
|
16
|
+
|
|
14
17
|
class ExportType:
|
|
15
18
|
PNG: int = 1
|
|
16
19
|
JPG: int = 2
|
|
17
20
|
TIFF: int = 3
|
|
18
21
|
|
|
22
|
+
|
|
19
23
|
@dataclass
|
|
20
24
|
class TileImage:
|
|
21
25
|
width: int = 256
|
|
22
26
|
height: int = 256
|
|
23
27
|
|
|
24
28
|
def __init__(self, tile: Tile, image: Union[bytes, bytearray]) -> None:
|
|
29
|
+
from io import BytesIO
|
|
30
|
+
|
|
25
31
|
self._tile = tile
|
|
26
32
|
try:
|
|
27
|
-
self._img =
|
|
33
|
+
self._img = PILImage.open(BytesIO(image))
|
|
28
34
|
logger.debug(f"TileImage created for z={tile.z},x={tile.x},y={tile.y}")
|
|
29
35
|
except Exception as e:
|
|
30
|
-
logger.error(
|
|
36
|
+
logger.error(
|
|
37
|
+
f"Failed to open image for tile z={tile.z},x={tile.x},y={tile.y}",
|
|
38
|
+
exc_info=True,
|
|
39
|
+
)
|
|
31
40
|
raise
|
|
32
|
-
|
|
41
|
+
|
|
33
42
|
self._path: Union[Path, None] = None
|
|
34
43
|
self._ext: str = self._get_image_type(image)
|
|
35
44
|
|
|
@@ -40,23 +49,23 @@ class TileImage:
|
|
|
40
49
|
b = bytes(data)
|
|
41
50
|
|
|
42
51
|
# PNG: 8 bytes
|
|
43
|
-
if b.startswith(b
|
|
52
|
+
if b.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
44
53
|
logger.debug(f"Image detected as PNG")
|
|
45
|
-
return
|
|
54
|
+
return "png"
|
|
46
55
|
|
|
47
56
|
# JPEG / JPG: files start with FF D8 and end with FF D9
|
|
48
|
-
if len(b) >= 2 and b[0:2] == b
|
|
57
|
+
if len(b) >= 2 and b[0:2] == b"\xff\xd8":
|
|
49
58
|
logger.debug(f"Image detected as JPG")
|
|
50
|
-
return
|
|
59
|
+
return "jpg"
|
|
51
60
|
|
|
52
61
|
# BMP: starts with 'BM' (0x42 0x4D)
|
|
53
|
-
if len(b) >= 2 and b[0:2] == b
|
|
62
|
+
if len(b) >= 2 and b[0:2] == b"BM":
|
|
54
63
|
logger.debug(f"Image detected as BMP")
|
|
55
|
-
return
|
|
64
|
+
return "bmp"
|
|
56
65
|
|
|
57
66
|
logger.warning(f"Unknown image format, defaulting to PNG")
|
|
58
67
|
return "png"
|
|
59
|
-
|
|
68
|
+
|
|
60
69
|
def save(self):
|
|
61
70
|
try:
|
|
62
71
|
self._img.save(self.path)
|
|
@@ -68,16 +77,16 @@ class TileImage:
|
|
|
68
77
|
@property
|
|
69
78
|
def name(self) -> str:
|
|
70
79
|
return f"{self._tile.z}_{self._tile.x}_{self._tile.y}.{self._ext}"
|
|
71
|
-
|
|
80
|
+
|
|
72
81
|
@property
|
|
73
82
|
def tile(self) -> Tile:
|
|
74
83
|
return self._tile
|
|
75
|
-
|
|
84
|
+
|
|
76
85
|
@property
|
|
77
|
-
def image(self) ->
|
|
86
|
+
def image(self) -> PILImage.Image:
|
|
78
87
|
self._img.load()
|
|
79
88
|
return self._img
|
|
80
|
-
|
|
89
|
+
|
|
81
90
|
@property
|
|
82
91
|
def path(self) -> Path:
|
|
83
92
|
if self._path is None:
|
|
@@ -132,30 +141,69 @@ class TileImage:
|
|
|
132
141
|
def url(self) -> Union[str, None]:
|
|
133
142
|
return self._tile.url
|
|
134
143
|
|
|
144
|
+
|
|
135
145
|
WEB_MERCATOR_EXTENT = 20037508.342789244
|
|
136
146
|
EPSG = 3857
|
|
147
|
+
|
|
148
|
+
|
|
137
149
|
class TileImageCollection:
|
|
138
150
|
images: List[TileImage] = []
|
|
139
151
|
width: int = 0
|
|
140
152
|
height: int = 0
|
|
141
153
|
|
|
154
|
+
def __init__(self, path: Union[Path, str]) -> None:
|
|
155
|
+
self.path = Path(path)
|
|
156
|
+
logger.info(f"TileImageCollection initialized at {self.path}")
|
|
157
|
+
|
|
158
|
+
def __len__(self):
|
|
159
|
+
return sum(1 for _ in self)
|
|
160
|
+
|
|
161
|
+
def __iter__(self):
|
|
162
|
+
for i in self.images:
|
|
163
|
+
yield i
|
|
164
|
+
|
|
165
|
+
def __repr__(self) -> str:
|
|
166
|
+
return f"ImageCollection; len={len(self)}"
|
|
167
|
+
|
|
168
|
+
def _update_collection_dim(self):
|
|
169
|
+
if not self.images:
|
|
170
|
+
logger.warning("Attempting to update collection dimensions with no images")
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
x = [img.tile.x for img in self.images]
|
|
174
|
+
y = [img.tile.y for img in self.images]
|
|
175
|
+
minx, maxx = min(x), max(x)
|
|
176
|
+
miny, maxy = min(y), max(y)
|
|
177
|
+
self.minx, self.maxx = minx, maxx
|
|
178
|
+
self.miny, self.maxy = miny, maxy
|
|
179
|
+
logger.debug(f"Tile range x=({self.minx}, {self.maxx}); y=({self.miny}, {self.maxy})")
|
|
180
|
+
|
|
181
|
+
self.width = int((maxx - minx + 1) * self.images[0].width)
|
|
182
|
+
self.height = int((maxy - miny + 1) * self.images[0].height)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
logger.info(f"Collection dimensions calculated: {self.width}x{self.height}")
|
|
186
|
+
|
|
142
187
|
def mosaic_bounds(self, x_min, y_min, x_max, y_max, z):
|
|
143
|
-
n = 2
|
|
188
|
+
n = 2**z
|
|
144
189
|
tile_size_m = 2 * WEB_MERCATOR_EXTENT / n
|
|
145
190
|
|
|
146
|
-
xmin = (WEB_MERCATOR_EXTENT
|
|
147
|
-
xmax = (WEB_MERCATOR_EXTENT
|
|
191
|
+
xmin = (WEB_MERCATOR_EXTENT * -1) + x_min * tile_size_m
|
|
192
|
+
xmax = (WEB_MERCATOR_EXTENT * -1) + (x_max + 1) * tile_size_m
|
|
148
193
|
|
|
149
194
|
ymax = WEB_MERCATOR_EXTENT - y_min * tile_size_m
|
|
150
195
|
ymin = WEB_MERCATOR_EXTENT - (y_max + 1) * tile_size_m
|
|
151
196
|
|
|
152
197
|
return xmin, ymin, xmax, ymax
|
|
153
198
|
|
|
154
|
-
def load(self, tile_collection:TileCollection):
|
|
199
|
+
def load(self, tile_collection: TileCollection):
|
|
200
|
+
import re
|
|
201
|
+
|
|
155
202
|
logger.info("Start loading saved ImageTiles")
|
|
156
|
-
pat = re.compile(r
|
|
203
|
+
pat = re.compile(r"^([0-9]+)_([0-9]+)_([0-9]+)\.[A-Za-z0-9]+$")
|
|
157
204
|
image_col = [p for p in self.path.glob(f"*.*") if p.is_file()]
|
|
158
205
|
self.zoom = tile_collection.to_list[0].z
|
|
206
|
+
logger.info(f"Found {len(image_col)} images at {self.path}")
|
|
159
207
|
|
|
160
208
|
for tile in tile_collection.to_list:
|
|
161
209
|
found_matching_image = False
|
|
@@ -168,15 +216,20 @@ class TileImageCollection:
|
|
|
168
216
|
|
|
169
217
|
if tile.x == x and tile.y == y and tile.z == z:
|
|
170
218
|
logger.debug(f"Processing ImageTile x={x} y={y} z={z}")
|
|
171
|
-
with open(image_path,
|
|
219
|
+
with open(image_path, "rb") as f:
|
|
172
220
|
tile_image = TileImage(tile, f.read())
|
|
173
221
|
tile_image.path = image_path
|
|
174
222
|
self.images.append(tile_image)
|
|
175
223
|
found_matching_image = True
|
|
176
224
|
continue
|
|
177
225
|
|
|
178
|
-
if not found_matching_image:
|
|
226
|
+
if not found_matching_image:
|
|
227
|
+
logger.warning(f"Missing ImageTile x={tile.x} y={tile.y} z={tile.z}")
|
|
179
228
|
|
|
229
|
+
logger.info(f"{len(self.images)} images loaded, {len(image_col) - len(self.images)} skipped")
|
|
230
|
+
|
|
231
|
+
self._update_collection_dim()
|
|
232
|
+
|
|
180
233
|
def append(self, img: TileImage):
|
|
181
234
|
img.path = os.path.join(self.path, img.name)
|
|
182
235
|
self.images.append(img)
|
|
@@ -185,57 +238,34 @@ class TileImageCollection:
|
|
|
185
238
|
|
|
186
239
|
self.zoom = img.tile.z
|
|
187
240
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
logger.info(
|
|
191
|
-
|
|
192
|
-
def __len__(self):
|
|
193
|
-
return sum(1 for _ in self)
|
|
194
|
-
|
|
195
|
-
def __iter__(self):
|
|
196
|
-
for i in self.images:
|
|
197
|
-
yield i
|
|
198
|
-
|
|
199
|
-
def __repr__(self) -> str:
|
|
200
|
-
return f"ImageCollection; len={len(self)}"
|
|
201
|
-
|
|
202
|
-
def _update_collection_dim(self):
|
|
203
|
-
if not self.images:
|
|
204
|
-
logger.warning("Attempting to update collection dimensions with no images")
|
|
205
|
-
return
|
|
206
|
-
|
|
207
|
-
x = [img.tile.x for img in self.images]
|
|
208
|
-
y = [img.tile.y for img in self.images]
|
|
209
|
-
minx, maxx = min(x), max(x)
|
|
210
|
-
miny, maxy = min(y), max(y)
|
|
211
|
-
|
|
212
|
-
self.width = int((maxx - minx + 1) * self.images[0].width)
|
|
213
|
-
self.height = int((maxy - miny + 1) * self.images[0].height)
|
|
214
|
-
self.minx, self.maxx = minx, maxx
|
|
215
|
-
self.miny, self.maxy = miny, maxy
|
|
216
|
-
|
|
217
|
-
logger.info(f"Collection dimensions calculated: {self.width}x{self.height}")
|
|
218
|
-
|
|
219
|
-
def mosaic(self, tiff:bool=True, png:bool=False):
|
|
220
|
-
logger.info("Starting mosaic creation")
|
|
241
|
+
@cache
|
|
242
|
+
def _create_mosaic(self) -> PILImage.Image:
|
|
243
|
+
logger.info("Start mosaicing TileImageCollection")
|
|
221
244
|
self._update_collection_dim()
|
|
222
|
-
|
|
223
|
-
logger.info(
|
|
224
|
-
|
|
245
|
+
|
|
246
|
+
logger.info(
|
|
247
|
+
f"Mosaicking {len(self.images)} images into {self.width}x{self.height}"
|
|
248
|
+
)
|
|
249
|
+
merged_image = PILImage.new("RGB", (self.width, self.height))
|
|
225
250
|
|
|
226
251
|
for image in self.images:
|
|
227
252
|
px = int((image.position.x) * image.width)
|
|
228
253
|
py = int((image.position.y) * image.height)
|
|
229
254
|
logger.debug(f"Pasting image at position ({px}, {py}): {image.name}")
|
|
230
255
|
merged_image.paste(image.image, (px, py))
|
|
256
|
+
|
|
257
|
+
return merged_image
|
|
231
258
|
|
|
259
|
+
def mosaic(self, export_types: List[int]):
|
|
260
|
+
|
|
261
|
+
merged_image = self._create_mosaic()
|
|
232
262
|
# TODO: fix this monkey patch
|
|
233
|
-
if
|
|
263
|
+
if ExportType.TIFF in export_types:
|
|
234
264
|
output_path = "mosaic.tiff"
|
|
235
265
|
import numpy as np
|
|
236
266
|
from rasterio.transform import from_bounds
|
|
237
267
|
import rasterio
|
|
238
|
-
|
|
268
|
+
|
|
239
269
|
data = np.array(merged_image)
|
|
240
270
|
data = data.transpose(2, 0, 1)
|
|
241
271
|
|
|
@@ -244,10 +274,7 @@ class TileImageCollection:
|
|
|
244
274
|
self.minx, self.miny, self.maxx, self.maxy, self.images[0].tile.z
|
|
245
275
|
)
|
|
246
276
|
|
|
247
|
-
transform = from_bounds(
|
|
248
|
-
xmin, ymin, xmax, ymax,
|
|
249
|
-
width_px, height_px
|
|
250
|
-
)
|
|
277
|
+
transform = from_bounds(xmin, ymin, xmax, ymax, width_px, height_px)
|
|
251
278
|
|
|
252
279
|
with rasterio.open(
|
|
253
280
|
output_path,
|
|
@@ -263,11 +290,51 @@ class TileImageCollection:
|
|
|
263
290
|
dst.write(data)
|
|
264
291
|
|
|
265
292
|
logger.info(f"Mosaic saved to {output_path}")
|
|
266
|
-
if
|
|
293
|
+
if ExportType.PNG in export_types:
|
|
267
294
|
output_path = "mosaic.png"
|
|
268
295
|
merged_image.save(output_path)
|
|
269
296
|
logger.info(f"Mosaic saved to {output_path}")
|
|
297
|
+
if ExportType.JPG in export_types:
|
|
298
|
+
output_path = "mosaic.jpg"
|
|
299
|
+
merged_image.save(output_path)
|
|
300
|
+
logger.info(f"Mosaic saved to {output_path}")
|
|
301
|
+
|
|
302
|
+
def group(self, width: int, height: int, export_types:List[int], overlap: bool):
|
|
303
|
+
if width <= 0 or height <= 0:
|
|
304
|
+
raise ValueError("width and height must be positive integers")
|
|
305
|
+
|
|
306
|
+
out_dir = "grouped_tiles"
|
|
307
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
308
|
+
logger.info(f"Start grouping {len(self.images)} TileImages into {width}x{height} groups")
|
|
309
|
+
|
|
310
|
+
merged_image= self._create_mosaic()
|
|
311
|
+
merged_image_arr = np.asanyarray(merged_image)
|
|
312
|
+
|
|
313
|
+
image_px_width = 256
|
|
314
|
+
image_px_height = 256
|
|
315
|
+
kh, kw = image_px_height*height, image_px_width*width
|
|
316
|
+
view = sliding_window_view(merged_image_arr, (kh, kw, merged_image_arr.shape[2]))
|
|
317
|
+
|
|
318
|
+
view = view[..., 0, :, :, :]
|
|
319
|
+
OH, OW = view.shape[:2]
|
|
320
|
+
logger.info(f"Saving groups into ./{out_dir}")
|
|
321
|
+
for i in range(0, OH, height*image_px_height):
|
|
322
|
+
for j in range(0, OW, width*image_px_width):
|
|
323
|
+
patch = view[i, j]
|
|
324
|
+
if not (patch == 0).all():
|
|
325
|
+
img = PILImage.fromarray(patch)
|
|
326
|
+
if ExportType.PNG in export_types:
|
|
327
|
+
logger.debug(f"Saving group {i}{j} as {i}{j}.png")
|
|
328
|
+
img.save(os.path.join(out_dir, f"{i}{j}.png"))
|
|
329
|
+
if ExportType.JPG in export_types:
|
|
330
|
+
logger.debug(f"Saving group {i}{j} as {i}{j}.jpg")
|
|
331
|
+
img.save(os.path.join(out_dir, f"{i}{j}.jpg"))
|
|
332
|
+
if ExportType.TIFF in export_types:
|
|
333
|
+
logger.error(f"TIFF exports for tile groups not implemented yet")
|
|
334
|
+
raise NotImplementedError
|
|
335
|
+
else:
|
|
336
|
+
logger.debug(f"Skip no-data group {i}{j}")
|
|
270
337
|
|
|
271
338
|
def export_collection(self, type: ExportType):
|
|
272
339
|
logger.info(f"Exporting collection as type {type}")
|
|
273
|
-
|
|
340
|
+
raise NotImplementedError
|
tilegrab/sources.py
CHANGED
|
@@ -4,8 +4,10 @@ from typing import Dict, Optional
|
|
|
4
4
|
logger = logging.getLogger(__name__)
|
|
5
5
|
|
|
6
6
|
class TileSource:
|
|
7
|
-
|
|
8
|
-
name
|
|
7
|
+
url_template: str
|
|
8
|
+
name: str
|
|
9
|
+
description: str
|
|
10
|
+
output_dir: str
|
|
9
11
|
|
|
10
12
|
def __init__(
|
|
11
13
|
self,
|
|
@@ -17,7 +19,7 @@ class TileSource:
|
|
|
17
19
|
logger.debug(f"Initializing TileSource: {self.name}, has_api_key={api_key is not None}")
|
|
18
20
|
|
|
19
21
|
def get_url(self, z: int, x: int, y: int) -> str:
|
|
20
|
-
url = self.
|
|
22
|
+
url = self.url_template.format(x=x, y=y, z=z)
|
|
21
23
|
logger.debug(f"Generated URL for {self.name}: z={z}, x={x}, y={y}")
|
|
22
24
|
return url
|
|
23
25
|
|
|
@@ -33,30 +35,34 @@ class TileSource:
|
|
|
33
35
|
|
|
34
36
|
|
|
35
37
|
class GoogleSat(TileSource):
|
|
38
|
+
output_dir = "ggl_sat"
|
|
36
39
|
name = "GoogleSat"
|
|
37
40
|
description = "Google satellite imageries"
|
|
38
|
-
|
|
41
|
+
url_template = "https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}"
|
|
39
42
|
|
|
40
43
|
|
|
41
44
|
class OSM(TileSource):
|
|
45
|
+
output_dir = "osm"
|
|
42
46
|
name = "OSM"
|
|
43
47
|
description = "OpenStreetMap imageries"
|
|
44
|
-
|
|
48
|
+
url_template = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
45
49
|
|
|
46
50
|
class ESRIWorldImagery(TileSource):
|
|
51
|
+
output_dir = "esri_world"
|
|
47
52
|
name = "ESRIWorldImagery"
|
|
48
53
|
description = "ESRI satellite imageries"
|
|
49
|
-
|
|
54
|
+
url_template = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
|
50
55
|
|
|
51
56
|
class Nearmap(TileSource):
|
|
57
|
+
output_dir = "nearmap_sat"
|
|
52
58
|
name = "NearmapSat"
|
|
53
59
|
description = "Nearmap satellite imageries"
|
|
54
|
-
|
|
60
|
+
url_template = "https://api.nearmap.com/tiles/v3/Vert/{z}/{x}/{y}.png?apikey={token}"
|
|
55
61
|
|
|
56
62
|
def get_url(self, z: int, x: int, y: int) -> str:
|
|
57
63
|
if not self.api_key:
|
|
58
64
|
logger.error("Nearmap API key is required but not provided")
|
|
59
65
|
raise AssertionError("API key required for Nearmap")
|
|
60
|
-
url = self.
|
|
66
|
+
url = self.url_template.format(x=x, y=y, z=z, token=self.api_key)
|
|
61
67
|
logger.debug(f"Generated Nearmap URL: z={z}, x={x}, y={y}")
|
|
62
68
|
return url
|
tilegrab/tiles.py
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import math
|
|
3
|
-
from typing import
|
|
3
|
+
from typing import Iterator, List, Tuple, Union
|
|
4
4
|
from abc import ABC, abstractmethod
|
|
5
5
|
from dataclasses import dataclass
|
|
6
|
-
from typing import Union
|
|
7
6
|
from .dataset import GeoDataset
|
|
8
7
|
from box import Box
|
|
9
|
-
from
|
|
10
|
-
from shapely import intersects
|
|
8
|
+
from functools import cache
|
|
11
9
|
|
|
12
10
|
logger = logging.getLogger(__name__)
|
|
13
11
|
|
|
@@ -23,7 +21,7 @@ class Tile:
|
|
|
23
21
|
|
|
24
22
|
def __post_init__(self):
|
|
25
23
|
self._position = None
|
|
26
|
-
logger.debug(f"Tile created:
|
|
24
|
+
logger.debug(f"Tile created: x={self.x}, y={self.y}, z={self.z}")
|
|
27
25
|
|
|
28
26
|
# @classmethod
|
|
29
27
|
# def from_tuple(cls, t: tuple[int, int, int]) -> "Tile":
|
|
@@ -73,17 +71,21 @@ class TileCollection(ABC):
|
|
|
73
71
|
for t in self._cache:
|
|
74
72
|
yield t
|
|
75
73
|
|
|
76
|
-
def __init__(self,
|
|
74
|
+
def __init__(self,
|
|
75
|
+
geo_dataset: GeoDataset,
|
|
76
|
+
zoom: int,
|
|
77
|
+
SAFE_LIMIT: int = 250
|
|
78
|
+
):
|
|
77
79
|
self.zoom = zoom
|
|
78
80
|
self.SAFE_LIMIT = SAFE_LIMIT
|
|
79
|
-
self.
|
|
81
|
+
self.geo_dataset = geo_dataset
|
|
80
82
|
|
|
81
83
|
logger.info(
|
|
82
84
|
f"Initializing TileCollection: zoom={zoom}, safe_limit={SAFE_LIMIT}"
|
|
83
85
|
)
|
|
84
86
|
|
|
85
|
-
assert
|
|
86
|
-
assert
|
|
87
|
+
assert geo_dataset.bbox.minx < geo_dataset.bbox.maxx
|
|
88
|
+
assert geo_dataset.bbox.miny < geo_dataset.bbox.maxy
|
|
87
89
|
|
|
88
90
|
self._build_tile_cache()
|
|
89
91
|
|
|
@@ -96,7 +98,7 @@ class TileCollection(ABC):
|
|
|
96
98
|
logger.info(f"TileCollection initialized with {len(self)} tiles")
|
|
97
99
|
|
|
98
100
|
def __repr__(self) -> str:
|
|
99
|
-
return f"TileCollection; len={len(self)}; x-extent=({self.
|
|
101
|
+
return f"TileCollection; len={len(self)}; x-extent=({self.geo_dataset.bbox.minx}-{self.geo_dataset.bbox.maxx}); y-extent=({self.geo_dataset.bbox.miny}-{self.geo_dataset.bbox.maxy})"
|
|
100
102
|
|
|
101
103
|
def tile_bounds(self, tile: Union[Tile, Box]) -> Tuple[float, float, float, float]:
|
|
102
104
|
x, y, z = tile.x, tile.y, tile.z
|
|
@@ -114,6 +116,7 @@ class TileCollection(ABC):
|
|
|
114
116
|
return lon_min, lat_min, lon_max, lat_max
|
|
115
117
|
|
|
116
118
|
@property
|
|
119
|
+
@cache
|
|
117
120
|
def to_list(self) -> List[Tile]:
|
|
118
121
|
cache = list(self._cache)
|
|
119
122
|
if cache is None or len(cache) < 1:
|
|
@@ -162,7 +165,7 @@ class TileCollection(ABC):
|
|
|
162
165
|
|
|
163
166
|
def _tiles_in_bounds(self, clip_to_shape=False) -> Iterator[Tile]:
|
|
164
167
|
|
|
165
|
-
bbox = self.
|
|
168
|
+
bbox = self.geo_dataset.bbox
|
|
166
169
|
|
|
167
170
|
def tile(lng, lat, zoom):
|
|
168
171
|
logger.debug(f"Creating new Tile; lat={lat}; lng={lng}")
|
|
@@ -221,8 +224,9 @@ class TileCollection(ABC):
|
|
|
221
224
|
for i in range(ul_tile.x, lr_tile.x + 1):
|
|
222
225
|
for j in range(ul_tile.y, lr_tile.y + 1):
|
|
223
226
|
if clip_to_shape:
|
|
227
|
+
from shapely.geometry import box
|
|
224
228
|
tb = box(*self.tile_bbox(i,j,self.zoom))
|
|
225
|
-
if not tb.intersects(self.
|
|
229
|
+
if not tb.intersects(self.geo_dataset.shape.geometry).any():
|
|
226
230
|
logger.debug(f"Tile excluded: z={self.zoom}, x={i}, y={j}")
|
|
227
231
|
continue
|
|
228
232
|
self._tile_count += 1
|
|
@@ -235,7 +239,7 @@ class TilesByBBox(TileCollection):
|
|
|
235
239
|
|
|
236
240
|
def _build_tile_cache(self) -> List[Tile]:
|
|
237
241
|
logger.info(f"Building tiles by bounding box at zoom level {self.zoom}")
|
|
238
|
-
bbox = self.
|
|
242
|
+
bbox = self.geo_dataset.bbox
|
|
239
243
|
logger.debug(
|
|
240
244
|
f"BBox coordinates: minx={bbox.minx}, miny={bbox.miny}, maxx={bbox.maxx}, maxy={bbox.maxy}"
|
|
241
245
|
)
|
|
@@ -251,7 +255,7 @@ class TilesByShape(TileCollection):
|
|
|
251
255
|
|
|
252
256
|
logger.info(f"Building tiles by shape intersection at zoom level {self.zoom}")
|
|
253
257
|
|
|
254
|
-
bbox = self.
|
|
258
|
+
bbox = self.geo_dataset.bbox
|
|
255
259
|
logger.debug(
|
|
256
260
|
f"Checking tiles within bbox: minx={bbox.minx}, miny={bbox.miny}, maxx={bbox.maxx}, maxy={bbox.maxy}"
|
|
257
261
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tilegrab
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.0b2
|
|
4
4
|
Summary: Fast geospatial map tile downloader and mosaicker
|
|
5
5
|
Author: Thiwanka Munasinghe
|
|
6
6
|
License-Expression: MIT
|
|
@@ -32,13 +32,13 @@ Dynamic: license-file
|
|
|
32
32
|
|
|
33
33
|
<div align="center">
|
|
34
34
|
<h1 align="center">TileGrab 🧩</h1>
|
|
35
|
-
|
|
36
|
-
<img alt="
|
|
37
|
-
|
|
38
|
-
<img alt="
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
<img alt="
|
|
35
|
+
<img alt="TileGrab" src="https://img.shields.io/pypi/v/tilegrab.svg">
|
|
36
|
+
<img alt="PyPI - Python Version" src="https://img.shields.io/pypi/pyversions/tilegrab.svg">
|
|
37
|
+
<img alt="PyPI - Wheel" src="https://img.shields.io/pypi/wheel/tilegrab">
|
|
38
|
+
<img alt="Test Status" src="https://img.shields.io/github/actions/workflow/status/thiwaK/tilegrab/test.yml?branch=main&event=push&style=flat&label=CI">
|
|
39
|
+
<img alt="PyPI - Implementation" src="https://img.shields.io/pypi/implementation/tilegrab">
|
|
40
|
+
<img alt="GitHub code size in bytes" src="https://img.shields.io/github/languages/code-size/thiwaK/tilegrab">
|
|
41
|
+
<img alt="GitHub License" src="https://img.shields.io/github/license/thiwaK/tilegrab">
|
|
42
42
|
<br/>
|
|
43
43
|
<br/>
|
|
44
44
|
</div>
|
|
@@ -47,7 +47,7 @@ Dynamic: license-file
|
|
|
47
47
|
**Fast, scriptable map tile downloader and mosaicker for geospatial workflows.**
|
|
48
48
|
|
|
49
49
|
|
|
50
|
-
`tilegrab` downloads raster map tiles from common providers (OSM, Google Satellite, ESRI World Imagery) using a **vector extent** (polygon or bounding box), then optionally mosaics them into a single
|
|
50
|
+
`tilegrab` downloads raster map tiles from common providers (OSM, Google Satellite, ESRI World Imagery) using a **vector extent** (polygon shape or bounding box), then optionally mosaics them into a single or multiple rasters.
|
|
51
51
|
|
|
52
52
|
---
|
|
53
53
|
|
|
@@ -62,20 +62,19 @@ Dynamic: license-file
|
|
|
62
62
|
|
|
63
63
|
## Why tilegrab?
|
|
64
64
|
|
|
65
|
-
Most tile downloaders
|
|
65
|
+
Most tile downloaders have two major drawbacks:
|
|
66
66
|
- GUI tools that don’t scale or automate
|
|
67
67
|
- Scripts that only support bounding boxes and break on real geometries
|
|
68
68
|
|
|
69
69
|
`tilegrab` is different:
|
|
70
70
|
|
|
71
|
-
- Uses **actual vector geometries**, not just extents
|
|
71
|
+
- Uses **actual vector geometries**, not only just extents
|
|
72
|
+
- Scalable API
|
|
72
73
|
- Clean CLI, easy to script and integrate
|
|
73
74
|
- Works with **Shapefiles, GeoPackages, GeoJSON**
|
|
74
|
-
- Supports **download-only**, **mosaic-only
|
|
75
|
+
- Supports **download-only**, **mosaic-only** or full pipelines
|
|
75
76
|
- Designed for **GIS, remote sensing, and map production workflows**
|
|
76
77
|
|
|
77
|
-
No magic. No black boxes.
|
|
78
|
-
|
|
79
78
|
---
|
|
80
79
|
|
|
81
80
|
## Features
|
|
@@ -86,8 +85,9 @@ No magic. No black boxes.
|
|
|
86
85
|
- Multiple tile providers
|
|
87
86
|
- OpenStreetMap
|
|
88
87
|
- Google Satellite
|
|
89
|
-
- ESRI World Imagery
|
|
90
|
-
-
|
|
88
|
+
- ESRI World Imagery
|
|
89
|
+
- or Custom providers
|
|
90
|
+
- Tile mosaicking
|
|
91
91
|
- Progress reporting (optional)
|
|
92
92
|
- API-key support where required
|
|
93
93
|
- Sensible defaults, strict CLI validation
|
|
@@ -98,12 +98,15 @@ No magic. No black boxes.
|
|
|
98
98
|
|
|
99
99
|
### From TestPyPI
|
|
100
100
|
|
|
101
|
+
#### Stable version
|
|
101
102
|
```bash
|
|
102
|
-
pip install -i
|
|
103
|
+
pip install -i tilegrab
|
|
103
104
|
````
|
|
104
105
|
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
#### Beta version
|
|
107
|
+
```bash
|
|
108
|
+
pip install tilegrab==1.2.0b2
|
|
109
|
+
````
|
|
107
110
|
|
|
108
111
|
---
|
|
109
112
|
|
|
@@ -134,76 +137,55 @@ tilegrab \
|
|
|
134
137
|
## CLI Usage
|
|
135
138
|
|
|
136
139
|
```bash
|
|
137
|
-
usage: tilegrab [-h] --source SOURCE (--shape | --bbox) (--osm | --google_sat | --esri_sat | --key KEY) --zoom ZOOM [--out
|
|
140
|
+
usage: tilegrab [-h] --source SOURCE (--shape | --bbox) (--osm | --google_sat | --esri_sat | --key KEY) (--jpg | --png | --tiff) --zoom ZOOM [--tiles-out TILES_OUT] [--download-only] [--mosaic-only]
|
|
141
|
+
[--group-tiles GROUP_TILES] [--group-overlap] [--tile-limit TILE_LIMIT] [--workers WORKERS] [--no-parallel] [--no-progress] [--quiet] [--debug]
|
|
138
142
|
|
|
139
143
|
Download and mosaic map tiles
|
|
140
144
|
|
|
141
145
|
options:
|
|
142
|
-
-h, --help
|
|
143
|
-
--zoom ZOOM
|
|
144
|
-
--out
|
|
145
|
-
|
|
146
|
-
--
|
|
147
|
-
--
|
|
148
|
-
--
|
|
146
|
+
-h, --help show this help message and exit
|
|
147
|
+
--zoom ZOOM Zoom level (integer between 1 and 22)
|
|
148
|
+
--tiles-out TILES_OUT
|
|
149
|
+
Output directory for downloaded tiles (default: ./saved_tiles)
|
|
150
|
+
--download-only Only download tiles; do not run mosaicking or postprocessing
|
|
151
|
+
--mosaic-only Only mosaic tiles; do not download
|
|
152
|
+
--group-tiles GROUP_TILES
|
|
153
|
+
Mosaic tiles but according to given WxH into ./grouped_tiles
|
|
154
|
+
--group-overlap Overlap with the next consecutive tile when grouping
|
|
155
|
+
--tile-limit TILE_LIMIT
|
|
156
|
+
Override maximum tile limit that can download (use with caution)
|
|
157
|
+
--workers WORKERS Max number of threads to use when parallel downloading
|
|
158
|
+
--no-parallel Download tiles sequentially, no parallel downloading
|
|
159
|
+
--no-progress Hide tile download progress bar
|
|
160
|
+
--quiet Hide all prints
|
|
161
|
+
--debug Enable debug logging
|
|
149
162
|
|
|
150
163
|
Source options(Extent):
|
|
151
164
|
Options for the vector polygon source
|
|
152
165
|
|
|
153
|
-
--source SOURCE
|
|
154
|
-
--shape
|
|
155
|
-
--bbox
|
|
166
|
+
--source SOURCE The vector polygon source for filter tiles
|
|
167
|
+
--shape Use actual shape to derive tiles
|
|
168
|
+
--bbox Use shape's bbox to derive tiles
|
|
156
169
|
|
|
157
170
|
Source options(Map tiles):
|
|
158
171
|
Options for the map tile source
|
|
159
172
|
|
|
160
|
-
--osm
|
|
161
|
-
--google_sat
|
|
162
|
-
--esri_sat
|
|
163
|
-
--key KEY
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
---
|
|
167
|
-
<!--
|
|
168
|
-
## Required Arguments
|
|
169
|
-
|
|
170
|
-
| Argument | Description |
|
|
171
|
-
| ---------- | ----------------------------------------- |
|
|
172
|
-
| `--source` | Vector dataset used to derive tile extent |
|
|
173
|
-
| `--shape` | Use exact geometry to select tiles |
|
|
174
|
-
| `--bbox` | Use geometry bounding box |
|
|
175
|
-
| `--zoom` | Web map zoom level |
|
|
173
|
+
--osm OpenStreetMap
|
|
174
|
+
--google_sat Google Satellite
|
|
175
|
+
--esri_sat ESRI World Imagery
|
|
176
|
+
--key KEY API key where required by source
|
|
176
177
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
## Tile Sources (CLI)
|
|
178
|
+
Mosaic export formats:
|
|
179
|
+
Formats for the output mosaic image
|
|
180
180
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
| `--google_sat` | Google Satellite |
|
|
185
|
-
| `--esri_sat` | ESRI World Imagery |
|
|
181
|
+
--jpg JPG image; no geo-reference
|
|
182
|
+
--png PNG image; no geo-reference
|
|
183
|
+
--tiff GeoTiff image; with geo-reference
|
|
186
184
|
|
|
187
|
-
Optional API key:
|
|
188
|
-
|
|
189
|
-
```bash
|
|
190
|
-
--key YOUR_API_KEY
|
|
191
185
|
```
|
|
192
186
|
|
|
193
187
|
---
|
|
194
188
|
|
|
195
|
-
## Output & Processing Options
|
|
196
|
-
|
|
197
|
-
| Option | Description |
|
|
198
|
-
| ----------------- | ------------------------------------------- |
|
|
199
|
-
| `--out <dir>` | Output directory (default: `./saved_tiles`) |
|
|
200
|
-
| `--download-only` | Download tiles only |
|
|
201
|
-
| `--mosaic-only` | Mosaic existing tiles only |
|
|
202
|
-
| `--no-progress` | Disable progress bar |
|
|
203
|
-
| `--quiet` | Suppress console output |
|
|
204
|
-
|
|
205
|
-
---
|
|
206
|
-
-->
|
|
207
189
|
|
|
208
190
|
## Supported Vector Formats
|
|
209
191
|
|
|
@@ -222,9 +204,6 @@ Any format readable by **GeoPandas**, including:
|
|
|
222
204
|
|
|
223
205
|
If a tile service follows the standard `{z}/{x}/{y}` pattern, you can add it in **one small class** by extending `TileSource`.
|
|
224
206
|
|
|
225
|
-
No registration. No plugin system. No magic.
|
|
226
|
-
|
|
227
|
-
---
|
|
228
207
|
|
|
229
208
|
### Example
|
|
230
209
|
|
|
@@ -234,12 +213,9 @@ from tilegrab.sources import TileSource
|
|
|
234
213
|
class MyCustomSource(TileSource):
|
|
235
214
|
name = "MyCustomSource name"
|
|
236
215
|
description = "MyCustomSource description"
|
|
237
|
-
|
|
216
|
+
url_template = "https://MyCustomSource/{z}/{x}/{y}.png"
|
|
238
217
|
```
|
|
239
218
|
|
|
240
|
-
That’s it.
|
|
241
|
-
|
|
242
|
-
Once instantiated, the source works exactly like built-in providers.
|
|
243
219
|
|
|
244
220
|
---
|
|
245
221
|
|
|
@@ -251,28 +227,19 @@ You can change how the url is generate by override `get_url` function, inside yo
|
|
|
251
227
|
```python
|
|
252
228
|
def get_url(self, z: int, x: int, y: int) -> str:
|
|
253
229
|
assert self.api_key
|
|
254
|
-
return self.
|
|
230
|
+
return self.url_template.format(x=x, y=y, z=z, token=self.api_key)
|
|
255
231
|
```
|
|
256
232
|
|
|
257
233
|
### URL Template Rules
|
|
258
234
|
|
|
259
235
|
Your tile source **must** define:
|
|
260
|
-
|
|
261
|
-
* `URL_TEMPLATE`
|
|
236
|
+
* `url_template`
|
|
262
237
|
Must contain `{z}`, `{x}`, `{y}` placeholders.
|
|
263
238
|
|
|
264
239
|
Optional but recommended:
|
|
265
|
-
|
|
266
240
|
* `name` – Human-readable name
|
|
267
241
|
* `description` – Short description of the imagery
|
|
268
242
|
|
|
269
|
-
Example templates:
|
|
270
|
-
|
|
271
|
-
```text
|
|
272
|
-
https://server/{z}/{x}/{y}.png
|
|
273
|
-
https://tiles.example.com/{z}/{x}/{y}.jpg
|
|
274
|
-
https://api.provider.com/tiles/{z}/{x}/{y}?key={token}
|
|
275
|
-
```
|
|
276
243
|
|
|
277
244
|
---
|
|
278
245
|
|
|
@@ -284,8 +251,6 @@ If your provider requires an API key, pass it during instantiation:
|
|
|
284
251
|
source = MyCustomSource(api_key="YOUR_KEY")
|
|
285
252
|
```
|
|
286
253
|
|
|
287
|
-
`TileSource` already handles key injection — you don’t need to reinvent it.
|
|
288
|
-
|
|
289
254
|
---
|
|
290
255
|
|
|
291
256
|
|
|
@@ -299,10 +264,9 @@ from tilegrab.tiles import TilesByShape
|
|
|
299
264
|
from tilegrab.dataset import GeoDataset
|
|
300
265
|
|
|
301
266
|
dataset = GeoDataset("area.gpkg")
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
downloader = Downloader(tiles, source, "output")
|
|
267
|
+
tile_collection = TilesByShape(dataset, zoom=16)
|
|
268
|
+
tile_source = MyCustomSource(api_key="XYZ")
|
|
269
|
+
downloader = Downloader(tile_collection, tile_source, "output")
|
|
306
270
|
downloader.run()
|
|
307
271
|
```
|
|
308
272
|
|
|
@@ -321,7 +285,7 @@ If you need full flexibility, use the Python API.
|
|
|
321
285
|
|
|
322
286
|
---
|
|
323
287
|
|
|
324
|
-
## Project Structure
|
|
288
|
+
<!-- ## Project Structure
|
|
325
289
|
|
|
326
290
|
```text
|
|
327
291
|
tilegrab/
|
|
@@ -331,11 +295,12 @@ tilegrab/
|
|
|
331
295
|
├── downloader.py # Tile download engine
|
|
332
296
|
├── mosaic.py # Tile mosaicking
|
|
333
297
|
└── sources.py # Tile providers
|
|
334
|
-
```
|
|
298
|
+
```
|
|
335
299
|
|
|
336
300
|
---
|
|
301
|
+
-->
|
|
337
302
|
|
|
338
|
-
## Who This Is For
|
|
303
|
+
<!-- ## Who This Is For
|
|
339
304
|
|
|
340
305
|
* GIS analysts automating basemap generation
|
|
341
306
|
* Remote sensing workflows needing tiled imagery
|
|
@@ -345,7 +310,7 @@ tilegrab/
|
|
|
345
310
|
If you want a GUI, this isn’t it.
|
|
346
311
|
If you want control, repeatability, and speed — it is.
|
|
347
312
|
|
|
348
|
-
---
|
|
313
|
+
--- -->
|
|
349
314
|
|
|
350
315
|
## Roadmap
|
|
351
316
|
|
|
@@ -353,9 +318,9 @@ Planned (not promises):
|
|
|
353
318
|
|
|
354
319
|
* Additional tile providers
|
|
355
320
|
* Parallel download tuning
|
|
356
|
-
* Cloud-optimized raster output
|
|
357
321
|
* Raster reprojection and resampling options
|
|
358
322
|
* Expanded Python API documentation
|
|
323
|
+
* Test implementation
|
|
359
324
|
|
|
360
325
|
---
|
|
361
326
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
tilegrab/__init__.py,sha256=Ti-EFZrLJA9WtEN3ZwvstVIBAsceEuZgjV7gw9JOoyk,295
|
|
2
|
+
tilegrab/__main__.py,sha256=wT62aXCuN7iwRPQoswnmeGiBS5Cou0CIBvMxbOTfvFs,33
|
|
3
|
+
tilegrab/cli.py,sha256=XaSC-qhANE7Y_73k18Izjpi_pGiZu7Qey1s4zP_JQAU,8099
|
|
4
|
+
tilegrab/dataset.py,sha256=kzLMBbLLjdnlKN7Ac-qB7k7e63VCDs3T23CweRdvKLQ,2709
|
|
5
|
+
tilegrab/downloader.py,sha256=ApQLgDjBo-RYrUuaavbulGbHaZ__G9xVQvkPO35RoP4,5371
|
|
6
|
+
tilegrab/images.py,sha256=RAv_gsW5HYOk7cAcu00tHILEFBpF4tKmQ-xDXPtaNmw,11991
|
|
7
|
+
tilegrab/logs.py,sha256=serS8_0bhxoTO0F03t-BDSdkMYR_eX78vI2y1sSVpUk,2151
|
|
8
|
+
tilegrab/sources.py,sha256=rQshFLjzbJu4x0AXTLa3YXhfK07719bMoZrSoOiAWG8,2344
|
|
9
|
+
tilegrab/tiles.py,sha256=XeFxO1i4kXokyK0mg_6f7FeHOkPXOMk8n2m-4C6H87Y,9044
|
|
10
|
+
tilegrab-1.2.0b2.dist-info/licenses/LICENSE,sha256=bcZProekTTcPtnEpRKB2i1kUJ6ujlaxBZchNWKeoXc8,1094
|
|
11
|
+
tilegrab-1.2.0b2.dist-info/METADATA,sha256=363_bNbE-ONRebVSiEX1TGwsmT0KQ4uRowALZhKsVs8,9684
|
|
12
|
+
tilegrab-1.2.0b2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
13
|
+
tilegrab-1.2.0b2.dist-info/entry_points.txt,sha256=z-WoKN8NnA5_ZsWXucSeq-r4TeEscu0xjWYu560uNTk,47
|
|
14
|
+
tilegrab-1.2.0b2.dist-info/top_level.txt,sha256=lVio8bCk3r4Bu_INLgaj4PZCrhFY2UcxHjnFBdHwyPo,9
|
|
15
|
+
tilegrab-1.2.0b2.dist-info/RECORD,,
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
tilegrab/__init__.py,sha256=unu6CU3corZS1SmNsFCi-o6od2C-yIzCfVm82ADJTXM,295
|
|
2
|
-
tilegrab/__main__.py,sha256=wT62aXCuN7iwRPQoswnmeGiBS5Cou0CIBvMxbOTfvFs,33
|
|
3
|
-
tilegrab/cli.py,sha256=RH787UzO4pGpjPMq_BHnTO_A9qhuJnurtvn2LG4sn4U,6395
|
|
4
|
-
tilegrab/dataset.py,sha256=tmbvR7wd9NYWCPsDLo9SP0x0_3edyRURQHo-6EnEIAM,2623
|
|
5
|
-
tilegrab/downloader.py,sha256=ge3o4PS0v3_npaiOvFmlNSx62VqL8nwjcefy4Jr_UQo,5120
|
|
6
|
-
tilegrab/images.py,sha256=VUE9Wsswkl2wAzkQnwDtncgKVU-wl3xGWagmx3u6Dk8,9278
|
|
7
|
-
tilegrab/logs.py,sha256=serS8_0bhxoTO0F03t-BDSdkMYR_eX78vI2y1sSVpUk,2151
|
|
8
|
-
tilegrab/sources.py,sha256=GzldtFGndziw9NNB1YMG4WUrD0fTRET1FjGCfIMMDlQ,2188
|
|
9
|
-
tilegrab/tiles.py,sha256=nCJL4tOKKQoZPJbGgDfL-I1DxYjo2Ajk69McvtAoEWM,8960
|
|
10
|
-
tilegrab-1.2.0b1.dist-info/licenses/LICENSE,sha256=bcZProekTTcPtnEpRKB2i1kUJ6ujlaxBZchNWKeoXc8,1094
|
|
11
|
-
tilegrab-1.2.0b1.dist-info/METADATA,sha256=UHVyRZO5L1yo1Upe7jQoig0KG4Af_p6gzv6cJAfl4AA,10197
|
|
12
|
-
tilegrab-1.2.0b1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
13
|
-
tilegrab-1.2.0b1.dist-info/entry_points.txt,sha256=z-WoKN8NnA5_ZsWXucSeq-r4TeEscu0xjWYu560uNTk,47
|
|
14
|
-
tilegrab-1.2.0b1.dist-info/top_level.txt,sha256=lVio8bCk3r4Bu_INLgaj4PZCrhFY2UcxHjnFBdHwyPo,9
|
|
15
|
-
tilegrab-1.2.0b1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|