tilegrab 1.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.
- tilegrab/__init__.py +14 -0
- tilegrab/__main__.py +3 -0
- tilegrab/cli.py +247 -0
- tilegrab/dataset.py +76 -0
- tilegrab/downloader.py +137 -0
- tilegrab/images.py +219 -0
- tilegrab/mosaic.py +75 -0
- tilegrab/sources.py +62 -0
- tilegrab/tiles.py +179 -0
- tilegrab-1.1.0.dist-info/METADATA +375 -0
- tilegrab-1.1.0.dist-info/RECORD +15 -0
- tilegrab-1.1.0.dist-info/WHEEL +5 -0
- tilegrab-1.1.0.dist-info/entry_points.txt +2 -0
- tilegrab-1.1.0.dist-info/licenses/LICENSE +21 -0
- tilegrab-1.1.0.dist-info/top_level.txt +1 -0
tilegrab/mosaic.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List
|
|
4
|
+
from PIL import Image
|
|
5
|
+
import re
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from tilegrab.tiles import TileCollection
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Mosaic:
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self, directory: str = "saved_tiles", ext: str = ".png", recursive: bool = False
|
|
15
|
+
):
|
|
16
|
+
self.directory = directory
|
|
17
|
+
self.ext = ext
|
|
18
|
+
self.recursive = recursive
|
|
19
|
+
|
|
20
|
+
self.image_col = self._get_images()
|
|
21
|
+
assert len(self.image_col) > 0
|
|
22
|
+
|
|
23
|
+
pat = re.compile(
|
|
24
|
+
r"^([0-9]+)_([0-9]+)_([0-9]+)\.[A-Za-z0-9]+$"
|
|
25
|
+
)
|
|
26
|
+
self.image_data = {}
|
|
27
|
+
|
|
28
|
+
for i in self.image_col:
|
|
29
|
+
m = pat.match(os.path.basename(str(i)))
|
|
30
|
+
if m:
|
|
31
|
+
first = m.group(1)
|
|
32
|
+
second = m.group(2)
|
|
33
|
+
third = m.group(3)
|
|
34
|
+
|
|
35
|
+
self.image_data[i] = [int(second), int(third), int(first)]
|
|
36
|
+
|
|
37
|
+
assert len(self.image_data.keys()) > 0
|
|
38
|
+
|
|
39
|
+
print(f"Processing {len(self.image_data.keys())} tiles...")
|
|
40
|
+
|
|
41
|
+
def _get_images(self) -> List[Path]:
|
|
42
|
+
|
|
43
|
+
directory = Path(self.directory)
|
|
44
|
+
ext = self.ext
|
|
45
|
+
|
|
46
|
+
if not ext.startswith("."):
|
|
47
|
+
ext = "." + self.ext
|
|
48
|
+
|
|
49
|
+
if self.recursive:
|
|
50
|
+
return [p for p in directory.rglob(f"*{ext}") if p.is_file()]
|
|
51
|
+
else:
|
|
52
|
+
return [p for p in directory.glob(f"*{ext}") if p.is_file()]
|
|
53
|
+
|
|
54
|
+
def merge(self, tiles: TileCollection, tile_size: int = 256):
|
|
55
|
+
|
|
56
|
+
img_w = int((tiles.MAX_X - tiles.MIN_X + 1) * tile_size)
|
|
57
|
+
img_h = int((tiles.MAX_Y - tiles.MIN_Y + 1) * tile_size)
|
|
58
|
+
print(f"Image size: {img_w}x{img_h}")
|
|
59
|
+
|
|
60
|
+
merged_image = Image.new("RGB", (img_w, img_h))
|
|
61
|
+
|
|
62
|
+
for img_path, img_id in self.image_data.items():
|
|
63
|
+
x, y, _ = img_id
|
|
64
|
+
|
|
65
|
+
print(x - tiles.MIN_X + 1, "x" ,y - tiles.MIN_Y + 1)
|
|
66
|
+
|
|
67
|
+
img = Image.open(img_path)
|
|
68
|
+
img.load()
|
|
69
|
+
|
|
70
|
+
px = int((x - tiles.MIN_X) * tile_size)
|
|
71
|
+
py = int((y - tiles.MIN_Y) * tile_size)
|
|
72
|
+
|
|
73
|
+
merged_image.paste(img, (px, py))
|
|
74
|
+
|
|
75
|
+
merged_image.save(os.path.join(self.directory, "merged_output.png"))
|
tilegrab/sources.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Dict, Optional
|
|
3
|
+
|
|
4
|
+
logger = logging.getLogger(__name__)
|
|
5
|
+
|
|
6
|
+
class TileSource:
|
|
7
|
+
URL_TEMPLATE = ""
|
|
8
|
+
name = None
|
|
9
|
+
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
api_key: Optional[str] = None,
|
|
13
|
+
headers: Optional[Dict[str, str]] = None
|
|
14
|
+
) -> None:
|
|
15
|
+
self._headers = headers
|
|
16
|
+
self.api_key = api_key
|
|
17
|
+
logger.debug(f"Initializing TileSource: {self.name}, has_api_key={api_key is not None}")
|
|
18
|
+
|
|
19
|
+
def get_url(self, z: int, x: int, y: int) -> str:
|
|
20
|
+
url = self.URL_TEMPLATE.format(x=x, y=y, z=z)
|
|
21
|
+
logger.debug(f"Generated URL for {self.name}: z={z}, x={x}, y={y}")
|
|
22
|
+
return url
|
|
23
|
+
|
|
24
|
+
def headers(self) -> Dict[str, str]:
|
|
25
|
+
return self._headers or {
|
|
26
|
+
"referer": "",
|
|
27
|
+
"accept": "*/*",
|
|
28
|
+
"user-agent": "Mozilla/5.0 QGIS/34202/Windows 11 Version 2009",
|
|
29
|
+
"connection": "Keep-Alive ",
|
|
30
|
+
"accept-encoding": "gzip, deflate",
|
|
31
|
+
"accept-language": "en-US,*",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GoogleSat(TileSource):
|
|
36
|
+
name = "GoogleSat"
|
|
37
|
+
description = "Google satellite imageries"
|
|
38
|
+
URL_TEMPLATE = "https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class OSM(TileSource):
|
|
42
|
+
name = "OSM"
|
|
43
|
+
description = "OpenStreetMap imageries"
|
|
44
|
+
URL_TEMPLATE = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
45
|
+
|
|
46
|
+
class ESRIWorldImagery(TileSource):
|
|
47
|
+
name = "ESRIWorldImagery"
|
|
48
|
+
description = "ESRI satellite imageries"
|
|
49
|
+
URL_TEMPLATE = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
|
50
|
+
|
|
51
|
+
class Nearmap(TileSource):
|
|
52
|
+
name = "NearmapSat"
|
|
53
|
+
description = "Nearmap satellite imageries"
|
|
54
|
+
URL_TEMPLATE = "https://api.nearmap.com/tiles/v3/Vert/{z}/{x}/{y}.png?apikey={token}"
|
|
55
|
+
|
|
56
|
+
def get_url(self, z: int, x: int, y: int) -> str:
|
|
57
|
+
if not self.api_key:
|
|
58
|
+
logger.error("Nearmap API key is required but not provided")
|
|
59
|
+
raise AssertionError("API key required for Nearmap")
|
|
60
|
+
url = self.URL_TEMPLATE.format(x=x, y=y, z=z, token=self.api_key)
|
|
61
|
+
logger.debug(f"Generated Nearmap URL: z={z}, x={x}, y={y}")
|
|
62
|
+
return url
|
tilegrab/tiles.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import math
|
|
3
|
+
from typing import Any, List, Tuple
|
|
4
|
+
import mercantile
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Union
|
|
8
|
+
from .dataset import GeoDataset
|
|
9
|
+
from box import Box
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Tile:
|
|
15
|
+
x: int = 0
|
|
16
|
+
y: int = 0
|
|
17
|
+
z: int = 0
|
|
18
|
+
|
|
19
|
+
def __post_init__(self):
|
|
20
|
+
self._position = None
|
|
21
|
+
logger.debug(f"Tile created: z={self.z}, x={self.x}, y={self.y}")
|
|
22
|
+
|
|
23
|
+
# @classmethod
|
|
24
|
+
# def from_tuple(cls, t: tuple[int, int, int]) -> "Tile":
|
|
25
|
+
# logger.debug(f"Creating Tile from tuple: {t}")
|
|
26
|
+
# return cls(*t)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def url(self) -> Union[str, None]:
|
|
30
|
+
return self._url
|
|
31
|
+
|
|
32
|
+
@url.setter
|
|
33
|
+
def url(self, value: str):
|
|
34
|
+
logger.debug(f"Tile URL set for z={self.z},x={self.x},y={self.y}")
|
|
35
|
+
self._url = value
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def position(self) -> Box:
|
|
39
|
+
if self._position is None:
|
|
40
|
+
logger.error(f"Tile position not set: z={self.z}, x={self.x}, y={self.y}")
|
|
41
|
+
raise RuntimeError("Image does not have an position")
|
|
42
|
+
return self._position
|
|
43
|
+
|
|
44
|
+
@position.setter
|
|
45
|
+
def position(self, value: Tuple[float, float]):
|
|
46
|
+
x, y = self.x - value[0], self.y - value[1]
|
|
47
|
+
self._position = Box({'x': x, 'y': y})
|
|
48
|
+
logger.debug(f"Tile position calculated: x={x}, y={y}")
|
|
49
|
+
|
|
50
|
+
class TileCollection(ABC):
|
|
51
|
+
|
|
52
|
+
MIN_X: float = 0
|
|
53
|
+
MAX_X: float = 0
|
|
54
|
+
MIN_Y: float = 0
|
|
55
|
+
MAX_Y: float = 0
|
|
56
|
+
_cache: Union[List[Tile], None] = None
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def _build_tile_cache(self) -> list[Tile]:
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
|
|
62
|
+
def __len__(self):
|
|
63
|
+
return sum(1 for _ in self)
|
|
64
|
+
|
|
65
|
+
def __iter__(self):
|
|
66
|
+
for t in self._cache: # type: ignore
|
|
67
|
+
yield t.z, t.x, t.y
|
|
68
|
+
|
|
69
|
+
def __init__(self, feature: GeoDataset, zoom: int, SAFE_LIMIT: int = 250):
|
|
70
|
+
self.zoom = zoom
|
|
71
|
+
self.SAFE_LIMIT = SAFE_LIMIT
|
|
72
|
+
self.feature = feature
|
|
73
|
+
|
|
74
|
+
logger.info(f"Initializing TileCollection: zoom={zoom}, safe_limit={SAFE_LIMIT}")
|
|
75
|
+
|
|
76
|
+
assert feature.bbox.minx < feature.bbox.maxx
|
|
77
|
+
assert feature.bbox.miny < feature.bbox.maxy
|
|
78
|
+
|
|
79
|
+
self._build_tile_cache()
|
|
80
|
+
self._update_min_max()
|
|
81
|
+
|
|
82
|
+
if len(self) > SAFE_LIMIT:
|
|
83
|
+
logger.error(f"Tile count exceeds safe limit: {len(self)} > {SAFE_LIMIT}")
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"Your query excedes the hard limit {len(self)} > {SAFE_LIMIT}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
logger.info(f"TileCollection initialized with {len(self)} tiles")
|
|
89
|
+
|
|
90
|
+
def __repr__(self) -> str:
|
|
91
|
+
return f"TileCollection; len={len(self)}; x-extent=({self.feature.bbox.minx}-{self.feature.bbox.maxx}); y-extent=({self.feature.bbox.miny}-{self.feature.bbox.maxy})"
|
|
92
|
+
|
|
93
|
+
def tile_bounds(self, x, y, z) -> Tuple[float, float, float, float]:
|
|
94
|
+
n = 2**z
|
|
95
|
+
|
|
96
|
+
lon_min = x / n * 360.0 - 180.0
|
|
97
|
+
lon_max = (x + 1) / n * 360.0 - 180.0
|
|
98
|
+
|
|
99
|
+
lat_min = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n))))
|
|
100
|
+
lat_max = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n))))
|
|
101
|
+
|
|
102
|
+
logger.debug(f"Tile bounds calculated for z={z},x={x},y={y}: ({lon_min},{lat_min},{lon_max},{lat_max})")
|
|
103
|
+
return lon_min, lat_min, lon_max, lat_max
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def to_list(self) -> list[Tile]:
|
|
107
|
+
if self._cache is None:
|
|
108
|
+
logger.debug("Building tile cache from to_list property")
|
|
109
|
+
self._build_tile_cache()
|
|
110
|
+
self._update_min_max()
|
|
111
|
+
if len(self) > self.SAFE_LIMIT:
|
|
112
|
+
logger.error(f"Tile count exceeds safe limit in to_list: {len(self)} > {self.SAFE_LIMIT}")
|
|
113
|
+
raise ValueError("Too many tiles")
|
|
114
|
+
|
|
115
|
+
assert self._cache
|
|
116
|
+
return self._cache
|
|
117
|
+
|
|
118
|
+
def _update_min_max(self):
|
|
119
|
+
assert self._cache
|
|
120
|
+
x = [t.x for t in self._cache]
|
|
121
|
+
y = [t.y for t in self._cache]
|
|
122
|
+
self.MAX_X, self.MIN_X = max(x), min(x)
|
|
123
|
+
self.MAX_Y, self.MIN_Y = max(y), min(y)
|
|
124
|
+
|
|
125
|
+
logger.info(f"TileCollection bounds: x=({self.MIN_X}, {self.MAX_X}) y=({self.MIN_Y}, {self.MAX_Y})")
|
|
126
|
+
|
|
127
|
+
for i in range(len(self._cache)):
|
|
128
|
+
self._cache[i].position = self.MIN_X, self.MIN_Y
|
|
129
|
+
|
|
130
|
+
class TilesByBBox(TileCollection):
|
|
131
|
+
|
|
132
|
+
def _build_tile_cache(self) -> list[Tile]:
|
|
133
|
+
logger.info(f"Building tiles by bounding box at zoom level {self.zoom}")
|
|
134
|
+
bbox = self.feature.bbox
|
|
135
|
+
logger.debug(f"BBox coordinates: minx={bbox.minx}, miny={bbox.miny}, maxx={bbox.maxx}, maxy={bbox.maxy}")
|
|
136
|
+
|
|
137
|
+
self._cache = [
|
|
138
|
+
Tile(t.x, t.y, t.z)
|
|
139
|
+
for t in mercantile.tiles(
|
|
140
|
+
bbox.minx,
|
|
141
|
+
bbox.miny,
|
|
142
|
+
bbox.maxx,
|
|
143
|
+
bbox.maxy,
|
|
144
|
+
self.zoom,
|
|
145
|
+
)
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
logger.debug(f"Generated {len(self._cache)} tiles from bounding box")
|
|
149
|
+
return self._cache
|
|
150
|
+
|
|
151
|
+
class TilesByShape(TileCollection):
|
|
152
|
+
|
|
153
|
+
def _build_tile_cache(self) -> list[Tile]:
|
|
154
|
+
from shapely.geometry import box
|
|
155
|
+
|
|
156
|
+
logger.info(f"Building tiles by shape intersection at zoom level {self.zoom}")
|
|
157
|
+
geometry = self.feature.shape
|
|
158
|
+
if hasattr(geometry, "geometry"):
|
|
159
|
+
geometry = geometry.geometry.unary_union
|
|
160
|
+
logger.debug("Converted GeoDataFrame geometry to unary_union")
|
|
161
|
+
|
|
162
|
+
tiles = []
|
|
163
|
+
bbox = self.feature.bbox
|
|
164
|
+
logger.debug(f"Checking tiles within bbox: minx={bbox.minx}, miny={bbox.miny}, maxx={bbox.maxx}, maxy={bbox.maxy}")
|
|
165
|
+
|
|
166
|
+
for t in mercantile.tiles(
|
|
167
|
+
bbox.minx,
|
|
168
|
+
bbox.miny,
|
|
169
|
+
bbox.maxx,
|
|
170
|
+
bbox.maxy,
|
|
171
|
+
self.zoom,
|
|
172
|
+
):
|
|
173
|
+
tb = box(*self.tile_bounds(t.x, t.y, t.z))
|
|
174
|
+
if tb.intersects(geometry):
|
|
175
|
+
tiles.append(Tile(t.x, t.y, t.z))
|
|
176
|
+
|
|
177
|
+
self._cache = tiles
|
|
178
|
+
logger.info(f"Generated {len(tiles)} tiles from shape intersection")
|
|
179
|
+
return tiles
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tilegrab
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Fast geospatial map tile downloader and mosaicker
|
|
5
|
+
Author: Thiwanka Munasinghe
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Changelog, https://github.com/thiwaK/tilegrab
|
|
8
|
+
Project-URL: Contact, https://github.com/thiwaK
|
|
9
|
+
Project-URL: Homepage, https://github.com/thiwaK/tilegrab
|
|
10
|
+
Project-URL: Source, https://github.com/thiwaK/tilegrab
|
|
11
|
+
Project-URL: Tracker, https://github.com/thiwaK/tilegrab/issues
|
|
12
|
+
Keywords: tilegrab,Tilegrab,TileGrab,tileGrab
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: requests
|
|
17
|
+
Requires-Dist: mercantile
|
|
18
|
+
Requires-Dist: pillow
|
|
19
|
+
Requires-Dist: tqdm
|
|
20
|
+
Requires-Dist: python-box
|
|
21
|
+
Requires-Dist: geopandas
|
|
22
|
+
Requires-Dist: pyproj
|
|
23
|
+
Requires-Dist: shapely
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-mock; extra == "dev"
|
|
27
|
+
Requires-Dist: setuptools; extra == "dev"
|
|
28
|
+
Requires-Dist: mock; extra == "dev"
|
|
29
|
+
Requires-Dist: tox; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
<div align="center">
|
|
34
|
+
<h1 align="center">TileGrab 🧩</h1>
|
|
35
|
+
<!-- until publish on PyPi -->
|
|
36
|
+
<img alt="TileGrab" src="https://img.shields.io/badge/testpypi-1.0.0-blue">
|
|
37
|
+
<!-- <img alt="TileGrab" src="https://img.shields.io/pypi/v/tilegrab.svg"> -->
|
|
38
|
+
<img alt="TileGrab - Python Versions" src="https://img.shields.io/badge/python-3.9%20|%203.10%20|%203.11|%203.12|%203.13-blue">
|
|
39
|
+
<!-- <img alt="TileGrab - Python Versions" src="https://img.shields.io/pypi/pyversions/tilegrab.svg"> -->
|
|
40
|
+
<!-- -->
|
|
41
|
+
<img alt="Test Status" src="https://img.shields.io/github/actions/workflow/status/thiwaK/tilegrab/test.yml?branch=main&event=push&style=flat&label=test">
|
|
42
|
+
<br/>
|
|
43
|
+
<br/>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
**Fast, scriptable map tile downloader and mosaicker for geospatial workflows.**
|
|
48
|
+
|
|
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 raster. Built for automation, reproducibility, and real GIS work — not GUI clicking.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
<!-- <p align="center">
|
|
55
|
+
<picture align="center">
|
|
56
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/astral-sh/uv/assets/1309177/03aa9163-1c79-4a87-a31d-7a9311ed9310">
|
|
57
|
+
<source media="(prefers-color-scheme: light)" srcset="https://github.com/astral-sh/uv/assets/1309177/629e59c0-9c6e-4013-9ad4-adb2bcf5080d">
|
|
58
|
+
<img alt="Shows a bar chart with benchmark results." src="https://github.com/astral-sh/uv/assets/1309177/629e59c0-9c6e-4013-9ad4-adb2bcf5080d">
|
|
59
|
+
</picture>
|
|
60
|
+
</p> -->
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
## Why tilegrab?
|
|
64
|
+
|
|
65
|
+
Most tile downloaders fall into one of two traps:
|
|
66
|
+
- GUI tools that don’t scale or automate
|
|
67
|
+
- Scripts that only support bounding boxes and break on real geometries
|
|
68
|
+
|
|
69
|
+
`tilegrab` is different:
|
|
70
|
+
|
|
71
|
+
- Uses **actual vector geometries**, not just extents
|
|
72
|
+
- Clean CLI, easy to script and integrate
|
|
73
|
+
- Works with **Shapefiles, GeoPackages, GeoJSON**
|
|
74
|
+
- Supports **download-only**, **mosaic-only**, or full pipelines
|
|
75
|
+
- Designed for **GIS, remote sensing, and map production workflows**
|
|
76
|
+
|
|
77
|
+
No magic. No black boxes.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Features
|
|
82
|
+
|
|
83
|
+
- Vector-driven tile selection
|
|
84
|
+
- Exact geometry-based tile filtering
|
|
85
|
+
- Or fast bounding-box-based selection
|
|
86
|
+
- Multiple tile providers
|
|
87
|
+
- OpenStreetMap
|
|
88
|
+
- Google Satellite
|
|
89
|
+
- ESRI World Imagery
|
|
90
|
+
- Automatic tile mosaicking
|
|
91
|
+
- Progress reporting (optional)
|
|
92
|
+
- API-key support where required
|
|
93
|
+
- Sensible defaults, strict CLI validation
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Installation
|
|
98
|
+
|
|
99
|
+
### From TestPyPI
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
pip install -i https://test.pypi.org/simple/tilegrab
|
|
103
|
+
````
|
|
104
|
+
|
|
105
|
+
> [!NOTE]
|
|
106
|
+
> A stable PyPI release will follow once the API is finalized.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Quick Start
|
|
111
|
+
|
|
112
|
+
### Download and mosaic tiles using a polygon
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
tilegrab \
|
|
116
|
+
--source boundary.shp \
|
|
117
|
+
--shape \
|
|
118
|
+
--osm \
|
|
119
|
+
--zoom 16
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Use bounding box instead of exact geometry
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
tilegrab \
|
|
126
|
+
--source boundary.geojson \
|
|
127
|
+
--bbox \
|
|
128
|
+
--esri_sat \
|
|
129
|
+
--zoom 17
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## CLI Usage
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
usage: tilegrab [-h] --source SOURCE (--shape | --bbox) (--osm | --google_sat | --esri_sat | --key KEY) --zoom ZOOM [--out OUT] [--download-only] [--mosaic-only] [--no-progress] [--quiet]
|
|
138
|
+
|
|
139
|
+
Download and mosaic map tiles
|
|
140
|
+
|
|
141
|
+
options:
|
|
142
|
+
-h, --help show this help message and exit
|
|
143
|
+
--zoom ZOOM Zoom level (integer)
|
|
144
|
+
--out OUT Output directory (default: ./saved_tiles)
|
|
145
|
+
--download-only Only download tiles; do not run mosaicking or postprocessing
|
|
146
|
+
--mosaic-only Only mosaic tiles; do not download
|
|
147
|
+
--no-progress Hide download progress bar
|
|
148
|
+
--quiet Hide all prints
|
|
149
|
+
|
|
150
|
+
Source options(Extent):
|
|
151
|
+
Options for the vector polygon source
|
|
152
|
+
|
|
153
|
+
--source SOURCE The vector polygon source for filter tiles
|
|
154
|
+
--shape Use actual shape to derive tiles
|
|
155
|
+
--bbox Use shape's bounding box to derive tiles
|
|
156
|
+
|
|
157
|
+
Source options(Map tiles):
|
|
158
|
+
Options for the map tile source
|
|
159
|
+
|
|
160
|
+
--osm OpenStreetMap
|
|
161
|
+
--google_sat Google Satellite
|
|
162
|
+
--esri_sat ESRI World Imagery
|
|
163
|
+
--key KEY API key where required by source
|
|
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 |
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Tile Sources (CLI)
|
|
180
|
+
|
|
181
|
+
| Flag | Source |
|
|
182
|
+
| -------------- | ------------------ |
|
|
183
|
+
| `--osm` | OpenStreetMap |
|
|
184
|
+
| `--google_sat` | Google Satellite |
|
|
185
|
+
| `--esri_sat` | ESRI World Imagery |
|
|
186
|
+
|
|
187
|
+
Optional API key:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
--key YOUR_API_KEY
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
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
|
+
|
|
208
|
+
## Supported Vector Formats
|
|
209
|
+
|
|
210
|
+
Any format readable by **GeoPandas**, including:
|
|
211
|
+
|
|
212
|
+
* Shapefile (`.shp`)
|
|
213
|
+
* GeoPackage (`.gpkg`)
|
|
214
|
+
* GeoJSON (`.geojson`)
|
|
215
|
+
* Spatial databases (via supported drivers)
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## Custom Tile Sources (Bring Your Own Provider)
|
|
220
|
+
|
|
221
|
+
`tilegrab` is **not limited** to built-in providers.
|
|
222
|
+
|
|
223
|
+
If a tile service follows the standard `{z}/{x}/{y}` pattern, you can add it in **one small class** by extending `TileSource`.
|
|
224
|
+
|
|
225
|
+
No registration. No plugin system. No magic.
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
### Example
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
from tilegrab.sources import TileSource
|
|
233
|
+
|
|
234
|
+
class MyCustomSource(TileSource):
|
|
235
|
+
name = "MyCustomSource name"
|
|
236
|
+
description = "MyCustomSource description"
|
|
237
|
+
URL_TEMPLATE = "https://MyCustomSource/{z}/{x}/{y}.png"
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
That’s it.
|
|
241
|
+
|
|
242
|
+
Once instantiated, the source works exactly like built-in providers.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
### get_url Function
|
|
248
|
+
|
|
249
|
+
You can change how the url is generate by override `get_url` function, inside your Custom Tile Sources. If you are planning to use API key, you must override this function.
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
def get_url(self, z: int, x: int, y: int) -> str:
|
|
253
|
+
assert self.api_key
|
|
254
|
+
return self.URL_TEMPLATE.format(x=x, y=y, z=z, token=self.api_key)
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### URL Template Rules
|
|
258
|
+
|
|
259
|
+
Your tile source **must** define:
|
|
260
|
+
|
|
261
|
+
* `URL_TEMPLATE`
|
|
262
|
+
Must contain `{z}`, `{x}`, `{y}` placeholders.
|
|
263
|
+
|
|
264
|
+
Optional but recommended:
|
|
265
|
+
|
|
266
|
+
* `name` – Human-readable name
|
|
267
|
+
* `description` – Short description of the imagery
|
|
268
|
+
|
|
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
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
### API Keys
|
|
280
|
+
|
|
281
|
+
If your provider requires an API key, pass it during instantiation:
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
source = MyCustomSource(api_key="YOUR_KEY")
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
`TileSource` already handles key injection — you don’t need to reinvent it.
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
### Using a Custom Source in Code
|
|
293
|
+
|
|
294
|
+
Custom sources are intended for **programmatic use** (not CLI flags):
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
from tilegrab.downloader import Downloader
|
|
298
|
+
from tilegrab.tiles import TilesByShape
|
|
299
|
+
from tilegrab.dataset import GeoDataset
|
|
300
|
+
|
|
301
|
+
dataset = GeoDataset("area.gpkg")
|
|
302
|
+
tiles = TilesByShape(dataset, zoom=16)
|
|
303
|
+
|
|
304
|
+
source = MyCustomSource(api_key="XYZ")
|
|
305
|
+
downloader = Downloader(tiles, source, "output")
|
|
306
|
+
downloader.run()
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
This keeps the CLI clean while giving developers full control.
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
313
|
+
### Why This Design?
|
|
314
|
+
|
|
315
|
+
* Zero configuration overhead
|
|
316
|
+
* No registry or plugin boilerplate
|
|
317
|
+
* Easy to vendor in private or internal tile servers
|
|
318
|
+
* Safe default for public CLI usage
|
|
319
|
+
|
|
320
|
+
If you need full flexibility, use the Python API.
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## Project Structure
|
|
325
|
+
|
|
326
|
+
```text
|
|
327
|
+
tilegrab/
|
|
328
|
+
├── cli.py # CLI entry point
|
|
329
|
+
├── dataset.py # Vector dataset handling
|
|
330
|
+
├── tiles.py # Tile calculation logic
|
|
331
|
+
├── downloader.py # Tile download engine
|
|
332
|
+
├── mosaic.py # Tile mosaicking
|
|
333
|
+
└── sources.py # Tile providers
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## Who This Is For
|
|
339
|
+
|
|
340
|
+
* GIS analysts automating basemap generation
|
|
341
|
+
* Remote sensing workflows needing tiled imagery
|
|
342
|
+
* Developers building spatial data pipelines
|
|
343
|
+
* Anyone tired of manual tile grabbing
|
|
344
|
+
|
|
345
|
+
If you want a GUI, this isn’t it.
|
|
346
|
+
If you want control, repeatability, and speed — it is.
|
|
347
|
+
|
|
348
|
+
---
|
|
349
|
+
|
|
350
|
+
## Roadmap
|
|
351
|
+
|
|
352
|
+
Planned (not promises):
|
|
353
|
+
|
|
354
|
+
* Additional tile providers
|
|
355
|
+
* Parallel download tuning
|
|
356
|
+
* Cloud-optimized raster output
|
|
357
|
+
* Raster reprojection and resampling options
|
|
358
|
+
* Expanded Python API documentation
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## License
|
|
363
|
+
|
|
364
|
+
MIT License.
|
|
365
|
+
Do whatever you want — just don’t pretend you wrote it.
|
|
366
|
+
|
|
367
|
+
---
|
|
368
|
+
|
|
369
|
+
## Author
|
|
370
|
+
|
|
371
|
+
**Thiwanka Munasinghe**
|
|
372
|
+
GitHub: [https://github.com/thiwaK](https://github.com/thiwaK)
|
|
373
|
+
|
|
374
|
+
```
|
|
375
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
tilegrab/__init__.py,sha256=0L3afZyDycCUGuBO0w7DQ9fOd798dUyinbGsM77aQOg,289
|
|
2
|
+
tilegrab/__main__.py,sha256=wT62aXCuN7iwRPQoswnmeGiBS5Cou0CIBvMxbOTfvFs,33
|
|
3
|
+
tilegrab/cli.py,sha256=4AxEpxBhl8REUFAc7Dqa-bCxCo5oUNJGZ1Om3QnTHV0,7700
|
|
4
|
+
tilegrab/dataset.py,sha256=dNZ52mTkX_FC4e-hKAdEsvL5JVeo-MXhhAHpxKjwyGE,2525
|
|
5
|
+
tilegrab/downloader.py,sha256=ge3o4PS0v3_npaiOvFmlNSx62VqL8nwjcefy4Jr_UQo,5120
|
|
6
|
+
tilegrab/images.py,sha256=rld-BLuGA8LPcq0QbGsVY48mbMRivvzxdyfAwVfe0dk,7466
|
|
7
|
+
tilegrab/mosaic.py,sha256=H2-Mr2j9AmwSicPGwYE5z5t0wdAfj2m42J0G7IxE0zA,2191
|
|
8
|
+
tilegrab/sources.py,sha256=GzldtFGndziw9NNB1YMG4WUrD0fTRET1FjGCfIMMDlQ,2188
|
|
9
|
+
tilegrab/tiles.py,sha256=WEWv96fCeHlZs_Gxzg0m3wYAy_qsHfAwkCQHcWEFwf4,6137
|
|
10
|
+
tilegrab-1.1.0.dist-info/licenses/LICENSE,sha256=bcZProekTTcPtnEpRKB2i1kUJ6ujlaxBZchNWKeoXc8,1094
|
|
11
|
+
tilegrab-1.1.0.dist-info/METADATA,sha256=ddmKHnd5A9LaTsjOIxSics7kovf_AXKC92bN3rrhdYY,10197
|
|
12
|
+
tilegrab-1.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
13
|
+
tilegrab-1.1.0.dist-info/entry_points.txt,sha256=z-WoKN8NnA5_ZsWXucSeq-r4TeEscu0xjWYu560uNTk,47
|
|
14
|
+
tilegrab-1.1.0.dist-info/top_level.txt,sha256=lVio8bCk3r4Bu_INLgaj4PZCrhFY2UcxHjnFBdHwyPo,9
|
|
15
|
+
tilegrab-1.1.0.dist-info/RECORD,,
|