tilegrab 1.1.0__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.1.0"
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
@@ -1,95 +1,22 @@
1
1
  #!/usr/bin/env python3
2
2
  import logging
3
3
  import argparse
4
- import random
5
- import sys
6
4
  from pathlib import Path
5
+ from typing import List
7
6
  from tilegrab.downloader import Downloader
8
- from tilegrab.images import TileImageCollection
7
+ from tilegrab.images import TileImageCollection, ExportType
8
+ from tilegrab.logs import setup_logging
9
9
  from tilegrab.tiles import TilesByShape, TilesByBBox
10
10
  from tilegrab.dataset import GeoDataset
11
- from tilegrab import version
12
-
13
- # Configure root logger
14
- # logging.basicConfig(
15
- # level=logging.INFO,
16
- # format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
17
- # handlers=[
18
- # logging.StreamHandler(sys.stdout),
19
- # logging.FileHandler('tilegrab.log')
20
- # ]
21
- # )
22
-
23
- # Normal colors
24
- BLACK = "\033[30m"
25
- RED = "\033[31m"
26
- GREEN = "\033[32m"
27
- YELLOW = "\033[33m"
28
- BLUE = "\033[34m"
29
- MAGENTA = "\033[35m"
30
- CYAN = "\033[36m"
31
- GRAY = "\033[90m"
32
- WHITE = "\033[37m"
33
-
34
- # Bright colors
35
- BBLACK = "\033[90m"
36
- BRED = "\033[91m"
37
- BGREEN = "\033[92m"
38
- BYELLOW = "\033[93m"
39
- BBLUE = "\033[94m"
40
- BMAGENTA = "\033[95m"
41
- BCYAN = "\033[96m"
42
- BGRAY = "\033[97m"
43
- BWHITE = "\033[97m"
44
-
45
- RESET = "\033[0m"
46
-
47
-
48
- class LogFormatter(logging.Formatter):
49
- NAME_WIDTH = 14
50
-
51
- LEVEL_MAP = {
52
- logging.CRITICAL: f'{RED}‼ {RESET}',
53
- logging.ERROR: f'{RED}✖ {RESET}',
54
- logging.WARNING: f'{YELLOW}⚠ {RESET}',
55
- logging.INFO: f'{BLUE}• {RESET}',
56
- logging.DEBUG: f'{GRAY}· {RESET}',
57
- logging.NOTSET: f'{CYAN}- {RESET}',
58
- }
59
-
60
- def format(self, record):
61
- record.level_letter = self.LEVEL_MAP.get(record.levelno, '?')
62
-
63
- short = record.name.rsplit('.', 1)[-1]
64
- record.short_name = f"{short:<{self.NAME_WIDTH}}"
65
-
66
- return super().format(record)
67
-
68
-
69
- console_formatter = LogFormatter(
70
- f' %(level_letter)s %(message)s'
71
- )
72
- file_formatter = logging.Formatter(
73
- '%(asctime)s %(levelname)s %(name)s - %(message)s'
74
- )
75
-
76
- console = logging.StreamHandler(sys.stdout)
77
- console.setFormatter(console_formatter )
78
-
79
- file = logging.FileHandler('tilegrab.log')
80
- file.setFormatter(file_formatter)
81
-
82
- logging.basicConfig(
83
- level=logging.INFO,
84
- handlers=[console, file],
85
- )
11
+ from tilegrab import __version__
86
12
 
87
- logger = logging.getLogger(__name__)
88
13
 
14
+ logger = logging.getLogger(__name__)
89
15
 
90
16
  def parse_args() -> argparse.Namespace:
91
17
  p = argparse.ArgumentParser(
92
- prog="tilegrab", description="Download and mosaic map tiles"
18
+ prog="tilegrab",
19
+ description="Download and mosaic map tiles"
93
20
  )
94
21
 
95
22
  # Create a named group for the vector polygon source
@@ -127,13 +54,23 @@ def parse_args() -> argparse.Namespace:
127
54
  "--key", type=str, default=None, help="API key where required by source"
128
55
  )
129
56
 
57
+ # Create a named group for merged output format
58
+ mosaic_out_group = p.add_argument_group(
59
+ title="Mosaic export formats", description="Formats for the output mosaic image"
60
+ )
61
+ mosaic_group = mosaic_out_group.add_mutually_exclusive_group(required=False)
62
+ mosaic_group.add_argument("--jpg", action="store_true", help="JPG image; no geo-reference")
63
+ mosaic_group.add_argument("--png", action="store_true", help="PNG image; no geo-reference")
64
+ mosaic_group.add_argument("--tiff", action="store_true", help="GeoTiff image; with geo-reference")
65
+ mosaic_group.set_defaults(tiff=True)
66
+
130
67
  # other options
131
- 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)")
132
69
  p.add_argument(
133
- "--out",
70
+ "--tiles-out",
134
71
  type=Path,
135
72
  default=Path.cwd() / "saved_tiles",
136
- help="Output directory (default: ./saved_tiles)",
73
+ help="Output directory for downloaded tiles (default: ./saved_tiles)",
137
74
  )
138
75
  p.add_argument(
139
76
  "--download-only",
@@ -146,63 +83,69 @@ def parse_args() -> argparse.Namespace:
146
83
  help="Only mosaic tiles; do not download",
147
84
  )
148
85
  p.add_argument(
149
- "--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"
150
87
  )
151
88
  p.add_argument(
152
- "--quiet", action="store_true", help="Hide all prints"
89
+ "--group-overlap", action="store_true", help="Overlap with the next consecutive tile when grouping"
153
90
  )
154
91
  p.add_argument(
155
- "--debug", action="store_true", help="Enable debug logging"
92
+ "--tile-limit", type=int, default=250, help="Override maximum tile limit that can download (use with caution)"
156
93
  )
157
94
  p.add_argument(
158
- "--test", action="store_true", help="Only for testing purposes, not for normal use"
95
+ "--workers", type=int, default=None, help="Max number of threads to use when parallel downloading"
159
96
  )
160
-
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")
161
111
  return p.parse_args()
162
112
 
163
113
 
164
114
  def main():
115
+ LOG_LEVEL = logging.INFO
116
+ ENABLE_CLI_LOG = True
117
+ ENABLE_FILE_LOG = True
118
+
165
119
  args = parse_args()
166
-
167
- # Adjust logging level
168
120
  if args.debug:
169
- logging.getLogger().setLevel(logging.DEBUG)
170
- # logger.debug("Debug logging enabled")
171
- elif args.quiet:
172
- console.close()
173
-
121
+ LOG_LEVEL = logging.DEBUG
122
+ if args.quiet:
123
+ ENABLE_CLI_LOG = False
174
124
 
175
-
176
- BANNER_NORMAL, BANNER_BRIGHT = random.choice([
177
- (RED, BRED),
178
- (GREEN, BGREEN),
179
- (YELLOW, BYELLOW),
180
- (BLUE, BBLUE),
181
- (MAGENTA, BMAGENTA),
182
- (CYAN, BCYAN),
183
- (GRAY, BGRAY),
184
- ])
125
+ setup_logging(ENABLE_CLI_LOG, ENABLE_FILE_LOG, LOG_LEVEL)
185
126
 
186
127
  if not args.quiet:
187
128
  print()
188
- print(f"{WHITE} " + ("-" * 60) + f"{RESET}")
189
- print(f"{BWHITE} TileGrab v{version}{RESET}".rjust(50))
190
- print(f"{WHITE} " + ("-" * 60) + f"{RESET}")
191
-
129
+ print(f"\033[37m " + ("-" * 60) + "\033[0m")
130
+ print(f"\033[97m TileGrab v{__version__}\033[0m".rjust(50))
131
+ print(f"\033[37m " + ("-" * 60) + "\033[0m")
132
+
192
133
  try:
193
134
  dataset = GeoDataset(args.source)
194
135
  logger.info(f"Dataset loaded successfully from {args.source}")
195
136
 
196
137
  _tmp = "bbox" if args.bbox else "shape" if args.shape else "DnE"
197
- logger.info(f"""Downloading tiles using {_tmp}
138
+ logger.info(
139
+ f"""Downloading tiles using {_tmp}
198
140
  - minX: {dataset.bbox.minx:.4f} - minY: {dataset.bbox.miny:.4f}
199
141
  - maxX: {dataset.bbox.maxx:.4f} - maxY: {dataset.bbox.maxy:.4f}
200
- - zoom: {args.zoom}""")
142
+ - zoom: {args.zoom}"""
143
+ )
201
144
 
202
145
  if args.shape:
203
- tiles = TilesByShape(dataset, zoom=args.zoom)
146
+ tiles = TilesByShape(geo_dataset=dataset, zoom=args.zoom, SAFE_LIMIT=args.tile_limit)
204
147
  elif args.bbox:
205
- tiles = TilesByBBox(dataset, zoom=args.zoom)
148
+ tiles = TilesByBBox(geo_dataset=dataset, zoom=args.zoom, SAFE_LIMIT=args.tile_limit)
206
149
  else:
207
150
  logger.error("No extent selector selected")
208
151
  raise SystemExit("No extent selector selected")
@@ -210,34 +153,65 @@ def main():
210
153
  # Choose source provider
211
154
  if args.osm:
212
155
  from tilegrab.sources import OSM
156
+
213
157
  logger.info("Using OpenStreetMap (OSM) as tile source")
214
158
  source = OSM(api_key=args.key) if args.key else OSM()
215
159
  elif args.google_sat:
216
160
  from tilegrab.sources import GoogleSat
161
+
217
162
  logger.info("Using Google Satellite as tile source")
218
163
  source = GoogleSat(api_key=args.key) if args.key else GoogleSat()
219
164
  elif args.esri_sat:
220
165
  from tilegrab.sources import ESRIWorldImagery
166
+
221
167
  logger.info("Using ESRI World Imagery as tile source")
222
- source = ESRIWorldImagery(api_key=args.key) if args.key else ESRIWorldImagery()
168
+ source = (
169
+ ESRIWorldImagery(api_key=args.key) if args.key else ESRIWorldImagery()
170
+ )
223
171
  else:
224
172
  logger.error("No tile source selected")
225
173
  raise SystemExit("No tile source selected")
174
+
175
+ downloader = Downloader(
176
+ tile_collection=tiles,
177
+ tile_source=source,
178
+ temp_tile_dir=args.tiles_out)
226
179
 
227
- downloader = Downloader(tiles, source, args.out)
228
180
  result: TileImageCollection
229
-
230
181
  if args.mosaic_only:
231
- result = TileImageCollection(args.out)
232
- result.load(tiles)
182
+ result = TileImageCollection(path=args.tiles_out)
183
+ result.load(tile_collection=tiles)
233
184
  else:
234
- 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)
235
189
  logger.info(f"Download result: {result}")
190
+
191
+ ex_types: List[int] = []
236
192
 
237
- if not args.download_only: result.mosaic()
193
+ if not args.download_only:
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
+
238
213
  logger.info("Done")
239
214
 
240
-
241
215
  except Exception as e:
242
216
  logger.exception("Fatal error during execution")
243
217
  raise SystemExit(1)
tilegrab/dataset.py CHANGED
@@ -1,14 +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
- class GeoDataset:
10
+ TILE_EPSG = 4326 #Web Mercator - 3857 | 4326 - WGS84
10
11
 
12
+ class GeoDataset:
11
13
  @property
14
+ @cache
12
15
  def bbox(self):
13
16
  minx, miny, maxx, maxy = self.source.total_bounds
14
17
  bbox_dict = Box({"minx": minx, "miny": miny, "maxx": maxx, "maxy": maxy})
@@ -21,14 +24,14 @@ class GeoDataset:
21
24
 
22
25
  @property
23
26
  def x_extent(self):
24
- minx, maxx = self.source.total_bounds
27
+ minx, _, maxx, _ = self.source.total_bounds
25
28
  extent = (maxx - minx) + 1
26
29
  logger.debug(f"X extent calculated: {extent}")
27
30
  return extent
28
31
 
29
32
  @property
30
33
  def y_extent(self):
31
- miny, maxy = self.source.total_bounds
34
+ _, miny, _, maxy = self.source.total_bounds
32
35
  extent = (maxy - miny) + 1
33
36
  logger.debug(f"Y extent calculated: {extent}")
34
37
  return extent
@@ -63,14 +66,14 @@ class GeoDataset:
63
66
  logger.critical("Dataset has no CRS defined")
64
67
  raise RuntimeError("Missing CRS")
65
68
 
66
- if epsg != 4326:
67
- logger.info(f"Reprojecting dataset from EPSG:{epsg} to EPSG:4326")
68
- gdf = gdf.to_crs(epsg=4326)
69
+ if epsg != TILE_EPSG: #Web Mercator
70
+ logger.info(f"Reprojecting dataset from EPSG:{epsg} to EPSG:{TILE_EPSG}")
71
+ gdf = gdf.to_crs(epsg=TILE_EPSG)
69
72
  else:
70
- logger.debug("Dataset already in EPSG:4326")
73
+ logger.debug(f"Dataset already in EPSG:{TILE_EPSG}")
71
74
 
72
75
  self.original_epsg = epsg
73
- self.current_epsg = 4326
76
+ self.current_epsg = TILE_EPSG
74
77
  self.source = gdf
75
78
  self.source_path = source_path
76
79
  logger.info(f"GeoDataset initialized successfully: {len(gdf)} features")
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")