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 CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "1.2.0b1"
1
+ __version__ = "1.2.0b2"
2
2
  # from tilegrab import downloader
3
3
  # from tilegrab import sources
4
4
  # from tilegrab import mosaic
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", description="Download and mosaic map tiles"
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=True)
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
- "--no-progress", action="store_false", help="Hide download progress bar"
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
- "--test",
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(tiles, source, args.tiles_out)
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(show_progress=args.no_progress)
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
- result.mosaic(tiff=args.tiff, png=args.png)
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
- from PIL import Image
11
+
16
12
 
17
13
  logger = logging.getLogger(__name__)
18
14
 
19
15
  @dataclass
20
16
  class Downloader:
21
- tiles: TileCollection
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 = 8,
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.tiles)} tiles, workers={workers}, show_progress={show_progress}")
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
- pbar = tqdm(total=len(self.tiles), desc=f"Downloading", unit="tile")
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
- for tile in self.tiles.to_list:
106
- res = self.download_tile(tile)
107
- results.append(res)
108
- if pbar:
109
- pbar.update(1)
110
-
111
- # with ThreadPoolExecutor(max_workers=workers) as exe:
112
- # future_to_tile = {
113
- # exe.submit(self.download_tile, tile): tile for tile in self.tiles.to_list
114
- # }
115
- # for fut in as_completed(future_to_tile):
116
- # try:
117
- # results.append(fut.result())
118
- # except Exception:
119
- # results.append(False)
120
-
121
- # if pbar:
122
- # pbar.update(1)
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.tiles)
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 PLIImage
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 = PLIImage.open(BytesIO(image))
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(f"Failed to open image for tile z={tile.z},x={tile.x},y={tile.y}", exc_info=True)
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'\x89PNG\r\n\x1a\n'):
52
+ if b.startswith(b"\x89PNG\r\n\x1a\n"):
44
53
  logger.debug(f"Image detected as PNG")
45
- return 'png'
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'\xff\xd8':
57
+ if len(b) >= 2 and b[0:2] == b"\xff\xd8":
49
58
  logger.debug(f"Image detected as JPG")
50
- return 'jpg'
59
+ return "jpg"
51
60
 
52
61
  # BMP: starts with 'BM' (0x42 0x4D)
53
- if len(b) >= 2 and b[0:2] == b'BM':
62
+ if len(b) >= 2 and b[0:2] == b"BM":
54
63
  logger.debug(f"Image detected as BMP")
55
- return 'bmp'
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) -> PLIImage.Image:
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 ** z
188
+ n = 2**z
144
189
  tile_size_m = 2 * WEB_MERCATOR_EXTENT / n
145
190
 
146
- xmin = (WEB_MERCATOR_EXTENT*-1) + x_min * tile_size_m
147
- xmax = (WEB_MERCATOR_EXTENT*-1) + (x_max + 1) * tile_size_m
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'^([0-9]+)_([0-9]+)_([0-9]+)\.[A-Za-z0-9]+$')
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, 'rb') as f:
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: logger.warning(f"Missing ImageTile x={tile.x} y={tile.y} z={tile.z}")
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
- def __init__(self, path: Union[Path, str]) -> None:
189
- self.path = Path(path)
190
- logger.info(f"TileImageCollection initialized at {self.path}")
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(f"Mosaicking {len(self.images)} images into {self.width}x{self.height}")
224
- merged_image = PLIImage.new("RGB", (self.width, self.height))
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 tiff:
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 png:
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
- pass
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
- URL_TEMPLATE = ""
8
- name = None
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.URL_TEMPLATE.format(x=x, y=y, z=z)
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
- URL_TEMPLATE = "https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}"
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
- URL_TEMPLATE = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
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
- URL_TEMPLATE = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
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
- URL_TEMPLATE = "https://api.nearmap.com/tiles/v3/Vert/{z}/{x}/{y}.png?apikey={token}"
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.URL_TEMPLATE.format(x=x, y=y, z=z, token=self.api_key)
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 Any, Dict, Generator, Iterator, List, Tuple
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 shapely.geometry import box
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: z={self.z}, x={self.x}, y={self.y}")
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, feature: GeoDataset, zoom: int, SAFE_LIMIT: int = 250):
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.feature = feature
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 feature.bbox.minx < feature.bbox.maxx
86
- assert feature.bbox.miny < feature.bbox.maxy
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.feature.bbox.minx}-{self.feature.bbox.maxx}); y-extent=({self.feature.bbox.miny}-{self.feature.bbox.maxy})"
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.feature.bbox
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.feature.shape.geometry).any():
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.feature.bbox
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.feature.bbox
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.0b1
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
- <!-- 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">
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 raster. Built for automation, reproducibility, and real GIS work — not GUI clicking.
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 fall into one of two traps:
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**, or full pipelines
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
- - Automatic tile mosaicking
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 https://test.pypi.org/simple/tilegrab
103
+ pip install -i tilegrab
103
104
  ````
104
105
 
105
- > [!NOTE]
106
- > A stable PyPI release will follow once the API is finalized.
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 OUT] [--download-only] [--mosaic-only] [--no-progress] [--quiet]
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 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
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 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
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 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 |
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
- | Flag | Source |
182
- | -------------- | ------------------ |
183
- | `--osm` | OpenStreetMap |
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
- URL_TEMPLATE = "https://MyCustomSource/{z}/{x}/{y}.png"
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.URL_TEMPLATE.format(x=x, y=y, z=z, token=self.api_key)
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
- tiles = TilesByShape(dataset, zoom=16)
303
-
304
- source = MyCustomSource(api_key="XYZ")
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,,