tilegrab 1.1.0__py3-none-any.whl → 1.2.0b1__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.0b1"
2
2
  # from tilegrab import downloader
3
3
  # from tilegrab import sources
4
4
  # from tilegrab import mosaic
tilegrab/cli.py CHANGED
@@ -1,91 +1,16 @@
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
7
5
  from tilegrab.downloader import Downloader
8
6
  from tilegrab.images import TileImageCollection
7
+ from tilegrab.logs import setup_logging
9
8
  from tilegrab.tiles import TilesByShape, TilesByBBox
10
9
  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
- )
10
+ from tilegrab import __version__
86
11
 
87
- logger = logging.getLogger(__name__)
88
12
 
13
+ logger = logging.getLogger(__name__)
89
14
 
90
15
  def parse_args() -> argparse.Namespace:
91
16
  p = argparse.ArgumentParser(
@@ -127,13 +52,22 @@ def parse_args() -> argparse.Namespace:
127
52
  "--key", type=str, default=None, help="API key where required by source"
128
53
  )
129
54
 
55
+ # Create a named group for merged output format
56
+ mosaic_out_group = p.add_argument_group(
57
+ title="Mosaic export formats", description="Formats for the output mosaic image"
58
+ )
59
+ mosaic_group = mosaic_out_group.add_mutually_exclusive_group(required=True)
60
+ mosaic_group.add_argument("--jpg", action="store_true", help="JPG image; no geo-reference")
61
+ mosaic_group.add_argument("--png", action="store_true", help="PNG image; no geo-reference")
62
+ mosaic_group.add_argument("--tiff", action="store_true", help="GeoTiff image; with geo-reference")
63
+
130
64
  # other options
131
65
  p.add_argument("--zoom", type=int, required=True, help="Zoom level (integer)")
132
66
  p.add_argument(
133
- "--out",
67
+ "--tiles-out",
134
68
  type=Path,
135
69
  default=Path.cwd() / "saved_tiles",
136
- help="Output directory (default: ./saved_tiles)",
70
+ help="Output directory for downloaded tiles (default: ./saved_tiles)",
137
71
  )
138
72
  p.add_argument(
139
73
  "--download-only",
@@ -148,56 +82,47 @@ def parse_args() -> argparse.Namespace:
148
82
  p.add_argument(
149
83
  "--no-progress", action="store_false", help="Hide download progress bar"
150
84
  )
85
+ p.add_argument("--quiet", action="store_true", help="Hide all prints")
86
+ p.add_argument("--debug", action="store_true", help="Enable debug logging")
151
87
  p.add_argument(
152
- "--quiet", action="store_true", help="Hide all prints"
153
- )
154
- p.add_argument(
155
- "--debug", action="store_true", help="Enable debug logging"
156
- )
157
- p.add_argument(
158
- "--test", action="store_true", help="Only for testing purposes, not for normal use"
88
+ "--test",
89
+ action="store_true",
90
+ help="Only for testing purposes, not for normal use",
159
91
  )
160
92
 
161
93
  return p.parse_args()
162
94
 
163
95
 
164
96
  def main():
97
+ LOG_LEVEL = logging.INFO
98
+ ENABLE_CLI_LOG = True
99
+ ENABLE_FILE_LOG = True
100
+
165
101
  args = parse_args()
166
-
167
- # Adjust logging level
168
102
  if args.debug:
169
- logging.getLogger().setLevel(logging.DEBUG)
170
- # logger.debug("Debug logging enabled")
171
- elif args.quiet:
172
- console.close()
173
-
174
-
103
+ LOG_LEVEL = logging.DEBUG
104
+ if args.quiet:
105
+ ENABLE_CLI_LOG = False
175
106
 
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
- ])
107
+ setup_logging(ENABLE_CLI_LOG, ENABLE_FILE_LOG, LOG_LEVEL)
185
108
 
186
109
  if not args.quiet:
187
110
  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
-
111
+ print(f"\033[37m " + ("-" * 60) + "\033[0m")
112
+ print(f"\033[97m TileGrab v{__version__}\033[0m".rjust(50))
113
+ print(f"\033[37m " + ("-" * 60) + "\033[0m")
114
+
192
115
  try:
193
116
  dataset = GeoDataset(args.source)
194
117
  logger.info(f"Dataset loaded successfully from {args.source}")
195
118
 
196
119
  _tmp = "bbox" if args.bbox else "shape" if args.shape else "DnE"
197
- logger.info(f"""Downloading tiles using {_tmp}
120
+ logger.info(
121
+ f"""Downloading tiles using {_tmp}
198
122
  - minX: {dataset.bbox.minx:.4f} - minY: {dataset.bbox.miny:.4f}
199
123
  - maxX: {dataset.bbox.maxx:.4f} - maxY: {dataset.bbox.maxy:.4f}
200
- - zoom: {args.zoom}""")
124
+ - zoom: {args.zoom}"""
125
+ )
201
126
 
202
127
  if args.shape:
203
128
  tiles = TilesByShape(dataset, zoom=args.zoom)
@@ -210,34 +135,39 @@ def main():
210
135
  # Choose source provider
211
136
  if args.osm:
212
137
  from tilegrab.sources import OSM
138
+
213
139
  logger.info("Using OpenStreetMap (OSM) as tile source")
214
140
  source = OSM(api_key=args.key) if args.key else OSM()
215
141
  elif args.google_sat:
216
142
  from tilegrab.sources import GoogleSat
143
+
217
144
  logger.info("Using Google Satellite as tile source")
218
145
  source = GoogleSat(api_key=args.key) if args.key else GoogleSat()
219
146
  elif args.esri_sat:
220
147
  from tilegrab.sources import ESRIWorldImagery
148
+
221
149
  logger.info("Using ESRI World Imagery as tile source")
222
- source = ESRIWorldImagery(api_key=args.key) if args.key else ESRIWorldImagery()
150
+ source = (
151
+ ESRIWorldImagery(api_key=args.key) if args.key else ESRIWorldImagery()
152
+ )
223
153
  else:
224
154
  logger.error("No tile source selected")
225
155
  raise SystemExit("No tile source selected")
226
-
227
- downloader = Downloader(tiles, source, args.out)
156
+
157
+ downloader = Downloader(tiles, source, args.tiles_out)
228
158
  result: TileImageCollection
229
159
 
230
160
  if args.mosaic_only:
231
- result = TileImageCollection(args.out)
232
- result.load(tiles)
161
+ result = TileImageCollection(args.tiles_out)
162
+ result.load(tiles)
233
163
  else:
234
164
  result = downloader.run(show_progress=args.no_progress)
235
165
  logger.info(f"Download result: {result}")
236
-
237
- if not args.download_only: result.mosaic()
166
+
167
+ if not args.download_only:
168
+ result.mosaic(tiff=args.tiff, png=args.png)
238
169
  logger.info("Done")
239
170
 
240
-
241
171
  except Exception as e:
242
172
  logger.exception("Fatal error during execution")
243
173
  raise SystemExit(1)
tilegrab/dataset.py CHANGED
@@ -6,6 +6,7 @@ from typing import Union
6
6
 
7
7
  logger = logging.getLogger(__name__)
8
8
 
9
+ TILE_EPSG = 4326 #Web Mercator - 3857 | 4326 - WGS84
9
10
  class GeoDataset:
10
11
 
11
12
  @property
@@ -63,14 +64,14 @@ class GeoDataset:
63
64
  logger.critical("Dataset has no CRS defined")
64
65
  raise RuntimeError("Missing CRS")
65
66
 
66
- if epsg != 4326:
67
- logger.info(f"Reprojecting dataset from EPSG:{epsg} to EPSG:4326")
68
- gdf = gdf.to_crs(epsg=4326)
67
+ if epsg != TILE_EPSG: #Web Mercator
68
+ logger.info(f"Reprojecting dataset from EPSG:{epsg} to EPSG:{TILE_EPSG}")
69
+ gdf = gdf.to_crs(epsg=TILE_EPSG)
69
70
  else:
70
- logger.debug("Dataset already in EPSG:4326")
71
+ logger.debug(f"Dataset already in EPSG:{TILE_EPSG}")
71
72
 
72
73
  self.original_epsg = epsg
73
- self.current_epsg = 4326
74
+ self.current_epsg = TILE_EPSG
74
75
  self.source = gdf
75
76
  self.source_path = source_path
76
77
  logger.info(f"GeoDataset initialized successfully: {len(gdf)} features")
tilegrab/images.py CHANGED
@@ -3,7 +3,7 @@ from dataclasses import dataclass
3
3
  from io import BytesIO
4
4
  from pathlib import Path, PosixPath, WindowsPath
5
5
  import re
6
- from typing import Any, List, Optional, Tuple, Union
6
+ from typing import Any, List, Union
7
7
  from PIL import Image as PLIImage
8
8
  from box import Box
9
9
  from tilegrab.tiles import Tile, TileCollection
@@ -132,16 +132,30 @@ class TileImage:
132
132
  def url(self) -> Union[str, None]:
133
133
  return self._tile.url
134
134
 
135
-
135
+ WEB_MERCATOR_EXTENT = 20037508.342789244
136
+ EPSG = 3857
136
137
  class TileImageCollection:
137
138
  images: List[TileImage] = []
138
139
  width: int = 0
139
140
  height: int = 0
140
141
 
142
+ def mosaic_bounds(self, x_min, y_min, x_max, y_max, z):
143
+ n = 2 ** z
144
+ tile_size_m = 2 * WEB_MERCATOR_EXTENT / n
145
+
146
+ xmin = (WEB_MERCATOR_EXTENT*-1) + x_min * tile_size_m
147
+ xmax = (WEB_MERCATOR_EXTENT*-1) + (x_max + 1) * tile_size_m
148
+
149
+ ymax = WEB_MERCATOR_EXTENT - y_min * tile_size_m
150
+ ymin = WEB_MERCATOR_EXTENT - (y_max + 1) * tile_size_m
151
+
152
+ return xmin, ymin, xmax, ymax
153
+
141
154
  def load(self, tile_collection:TileCollection):
142
155
  logger.info("Start loading saved ImageTiles")
143
156
  pat = re.compile(r'^([0-9]+)_([0-9]+)_([0-9]+)\.[A-Za-z0-9]+$')
144
157
  image_col = [p for p in self.path.glob(f"*.*") if p.is_file()]
158
+ self.zoom = tile_collection.to_list[0].z
145
159
 
146
160
  for tile in tile_collection.to_list:
147
161
  found_matching_image = False
@@ -169,6 +183,8 @@ class TileImageCollection:
169
183
  logger.debug(f"Image appended to collection: {img.name}")
170
184
  img.save()
171
185
 
186
+ self.zoom = img.tile.z
187
+
172
188
  def __init__(self, path: Union[Path, str]) -> None:
173
189
  self.path = Path(path)
174
190
  logger.info(f"TileImageCollection initialized at {self.path}")
@@ -195,12 +211,15 @@ class TileImageCollection:
195
211
 
196
212
  self.width = int((maxx - minx + 1) * self.images[0].width)
197
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
+
198
217
  logger.info(f"Collection dimensions calculated: {self.width}x{self.height}")
199
218
 
200
- def mosaic(self):
219
+ def mosaic(self, tiff:bool=True, png:bool=False):
201
220
  logger.info("Starting mosaic creation")
202
221
  self._update_collection_dim()
203
-
222
+
204
223
  logger.info(f"Mosaicking {len(self.images)} images into {self.width}x{self.height}")
205
224
  merged_image = PLIImage.new("RGB", (self.width, self.height))
206
225
 
@@ -210,9 +229,44 @@ class TileImageCollection:
210
229
  logger.debug(f"Pasting image at position ({px}, {py}): {image.name}")
211
230
  merged_image.paste(image.image, (px, py))
212
231
 
213
- output_path = "merged_output.png"
214
- merged_image.save(output_path)
215
- logger.info(f"Mosaic saved to {output_path}")
232
+ # TODO: fix this monkey patch
233
+ if tiff:
234
+ output_path = "mosaic.tiff"
235
+ import numpy as np
236
+ from rasterio.transform import from_bounds
237
+ import rasterio
238
+
239
+ data = np.array(merged_image)
240
+ data = data.transpose(2, 0, 1)
241
+
242
+ width_px, height_px = merged_image.size
243
+ xmin, ymin, xmax, ymax = self.mosaic_bounds(
244
+ self.minx, self.miny, self.maxx, self.maxy, self.images[0].tile.z
245
+ )
246
+
247
+ transform = from_bounds(
248
+ xmin, ymin, xmax, ymax,
249
+ width_px, height_px
250
+ )
251
+
252
+ with rasterio.open(
253
+ output_path,
254
+ "w",
255
+ driver="GTiff",
256
+ height=height_px,
257
+ width=width_px,
258
+ count=data.shape[0],
259
+ dtype=data.dtype,
260
+ crs=f"EPSG:{EPSG}",
261
+ transform=transform,
262
+ ) as dst:
263
+ dst.write(data)
264
+
265
+ logger.info(f"Mosaic saved to {output_path}")
266
+ if png:
267
+ output_path = "mosaic.png"
268
+ merged_image.save(output_path)
269
+ logger.info(f"Mosaic saved to {output_path}")
216
270
 
217
271
  def export_collection(self, type: ExportType):
218
272
  logger.info(f"Exporting collection as type {type}")
tilegrab/logs.py ADDED
@@ -0,0 +1,84 @@
1
+ import logging
2
+ import sys
3
+
4
+ # Normal colors
5
+ BLACK = "\033[30m"
6
+ RED = "\033[31m"
7
+ GREEN = "\033[32m"
8
+ YELLOW = "\033[33m"
9
+ BLUE = "\033[34m"
10
+ MAGENTA = "\033[35m"
11
+ CYAN = "\033[36m"
12
+ GRAY = "\033[90m"
13
+ WHITE = "\033[37m"
14
+
15
+ # Bright colors
16
+ BBLACK = "\033[90m"
17
+ BRED = "\033[91m"
18
+ BGREEN = "\033[92m"
19
+ BYELLOW = "\033[93m"
20
+ BBLUE = "\033[94m"
21
+ BMAGENTA = "\033[95m"
22
+ BCYAN = "\033[96m"
23
+ BGRAY = "\033[97m"
24
+ BWHITE = "\033[97m"
25
+
26
+ RESET = "\033[0m"
27
+
28
+
29
+ class CLILogFormatter(logging.Formatter):
30
+ NAME_WIDTH = 20
31
+ LEVEL_MAP = {
32
+ logging.CRITICAL: f'{RED}‼ {RESET}',
33
+ logging.ERROR: f'{RED}✖ {RESET}',
34
+ logging.WARNING: f'{YELLOW}⚠ {RESET}',
35
+ logging.INFO: f'{BLUE}• {RESET}',
36
+ logging.DEBUG: f'{GRAY}· {RESET}',
37
+ logging.NOTSET: f'{CYAN}- {RESET}',
38
+ }
39
+
40
+ def __init__(self, fmt=None):
41
+ super().__init__(fmt or ' %(level_icon)s %(message)s')
42
+
43
+ def format(self, record):
44
+ record.level_icon = self.LEVEL_MAP.get(record.levelno, '?')
45
+ short = record.name.rsplit('.', 1)[-1]
46
+ record.short_name = f"{short:<{self.NAME_WIDTH}}"
47
+
48
+ return super().format(record)
49
+
50
+ class FileLogFormatter(logging.Formatter):
51
+ def __init__(self):
52
+ super().__init__(
53
+ '%(asctime)s %(levelname)s %(name)s - %(message)s'
54
+ )
55
+
56
+ def create_cli_handler(level=logging.INFO):
57
+ handler = logging.StreamHandler(sys.stdout)
58
+ handler.setLevel(level)
59
+ handler.setFormatter(CLILogFormatter())
60
+ return handler
61
+
62
+ def create_file_handler(path="tilegrab.log", level=logging.DEBUG):
63
+ handler = logging.FileHandler(path)
64
+ handler.setLevel(level)
65
+ handler.setFormatter(FileLogFormatter())
66
+ return handler
67
+
68
+ def setup_logging(
69
+ enable_cli=True,
70
+ enable_file=True,
71
+ level=logging.INFO
72
+ ):
73
+ handlers = []
74
+
75
+ if enable_cli:
76
+ handlers.append(create_cli_handler(level))
77
+
78
+ if enable_file:
79
+ handlers.append(create_file_handler(level=level))
80
+
81
+ logging.basicConfig(
82
+ level=level,
83
+ handlers=handlers,
84
+ )
tilegrab/tiles.py CHANGED
@@ -1,15 +1,20 @@
1
1
  import logging
2
2
  import math
3
- from typing import Any, List, Tuple
4
- import mercantile
3
+ from typing import Any, Dict, Generator, Iterator, List, Tuple
5
4
  from abc import ABC, abstractmethod
6
5
  from dataclasses import dataclass
7
6
  from typing import Union
8
7
  from .dataset import GeoDataset
9
8
  from box import Box
9
+ from shapely.geometry import box
10
+ from shapely import intersects
10
11
 
11
12
  logger = logging.getLogger(__name__)
12
13
 
14
+ EPSILON = 1e-14
15
+ LL_EPSILON = 1e-11
16
+
17
+
13
18
  @dataclass
14
19
  class Tile:
15
20
  x: int = 0
@@ -24,7 +29,7 @@ class Tile:
24
29
  # def from_tuple(cls, t: tuple[int, int, int]) -> "Tile":
25
30
  # logger.debug(f"Creating Tile from tuple: {t}")
26
31
  # return cls(*t)
27
-
32
+
28
33
  @property
29
34
  def url(self) -> Union[str, None]:
30
35
  return self._url
@@ -38,59 +43,63 @@ class Tile:
38
43
  def position(self) -> Box:
39
44
  if self._position is None:
40
45
  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")
46
+ raise RuntimeError("Image does not have a position")
42
47
  return self._position
43
48
 
44
49
  @position.setter
45
50
  def position(self, value: Tuple[float, float]):
46
51
  x, y = self.x - value[0], self.y - value[1]
47
- self._position = Box({'x': x, 'y': y})
52
+ self._position = Box({"x": x, "y": y})
48
53
  logger.debug(f"Tile position calculated: x={x}, y={y}")
49
54
 
55
+
50
56
  class TileCollection(ABC):
51
-
57
+
52
58
  MIN_X: float = 0
53
59
  MAX_X: float = 0
54
60
  MIN_Y: float = 0
55
61
  MAX_Y: float = 0
56
- _cache: Union[List[Tile], None] = None
62
+ _cache: List[Tile]
63
+ _tile_count: int = 0
57
64
 
58
65
  @abstractmethod
59
- def _build_tile_cache(self) -> list[Tile]:
66
+ def _build_tile_cache(self) -> List[Tile]:
60
67
  raise NotImplementedError
61
68
 
62
69
  def __len__(self):
63
- return sum(1 for _ in self)
70
+ return self._tile_count
64
71
 
65
72
  def __iter__(self):
66
- for t in self._cache: # type: ignore
67
- yield t.z, t.x, t.y
73
+ for t in self._cache:
74
+ yield t
68
75
 
69
76
  def __init__(self, feature: GeoDataset, zoom: int, SAFE_LIMIT: int = 250):
70
77
  self.zoom = zoom
71
78
  self.SAFE_LIMIT = SAFE_LIMIT
72
79
  self.feature = feature
73
80
 
74
- logger.info(f"Initializing TileCollection: zoom={zoom}, safe_limit={SAFE_LIMIT}")
81
+ logger.info(
82
+ f"Initializing TileCollection: zoom={zoom}, safe_limit={SAFE_LIMIT}"
83
+ )
75
84
 
76
85
  assert feature.bbox.minx < feature.bbox.maxx
77
86
  assert feature.bbox.miny < feature.bbox.maxy
78
87
 
79
88
  self._build_tile_cache()
80
- self._update_min_max()
81
-
89
+
82
90
  if len(self) > SAFE_LIMIT:
83
91
  logger.error(f"Tile count exceeds safe limit: {len(self)} > {SAFE_LIMIT}")
84
92
  raise ValueError(
85
93
  f"Your query excedes the hard limit {len(self)} > {SAFE_LIMIT}"
86
94
  )
87
-
95
+
88
96
  logger.info(f"TileCollection initialized with {len(self)} tiles")
89
97
 
90
98
  def __repr__(self) -> str:
91
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})"
92
100
 
93
- def tile_bounds(self, x, y, z) -> Tuple[float, float, float, float]:
101
+ def tile_bounds(self, tile: Union[Tile, Box]) -> Tuple[float, float, float, float]:
102
+ x, y, z = tile.x, tile.y, tile.z
94
103
  n = 2**z
95
104
 
96
105
  lon_min = x / n * 360.0 - 180.0
@@ -99,81 +108,154 @@ class TileCollection(ABC):
99
108
  lat_min = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n))))
100
109
  lat_max = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n))))
101
110
 
102
- logger.debug(f"Tile bounds calculated for z={z},x={x},y={y}: ({lon_min},{lat_min},{lon_max},{lat_max})")
111
+ logger.debug(
112
+ f"Tile bounds calculated for z={z},x={x},y={y}: ({lon_min},{lat_min},{lon_max},{lat_max})"
113
+ )
103
114
  return lon_min, lat_min, lon_max, lat_max
104
115
 
105
116
  @property
106
- def to_list(self) -> list[Tile]:
107
- if self._cache is None:
117
+ def to_list(self) -> List[Tile]:
118
+ cache = list(self._cache)
119
+ if cache is None or len(cache) < 1:
108
120
  logger.debug("Building tile cache from to_list property")
109
121
  self._build_tile_cache()
110
- self._update_min_max()
111
122
  if len(self) > self.SAFE_LIMIT:
112
- logger.error(f"Tile count exceeds safe limit in to_list: {len(self)} > {self.SAFE_LIMIT}")
123
+ logger.error(
124
+ f"Tile count exceeds safe limit in to_list: {len(self)} > {self.SAFE_LIMIT}"
125
+ )
113
126
  raise ValueError("Too many tiles")
114
-
115
127
  assert self._cache
116
- return self._cache
128
+ return list(self._cache)
117
129
 
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)
130
+ def tile_bbox_geojson(self, x: int, y: int, z: int) -> dict:
131
+ min_lon, min_lat, max_lon, max_lat = self.tile_bbox(x, y, z)
132
+ return {
133
+ "type": "Polygon",
134
+ "coordinates": [[
135
+ [min_lon, min_lat],
136
+ [min_lon, max_lat],
137
+ [max_lon, max_lat],
138
+ [max_lon, min_lat],
139
+ [min_lon, min_lat]
140
+ ]]
141
+ }
142
+
143
+ def tile_bbox(self, x: int, y: int, z: int) -> Tuple[float, float, float, float]:
144
+ """
145
+ Return bounding box of a Slippy Map tile as (min_lon, min_lat, max_lon, max_lat).
146
+ x, y are tile indices at zoom z.
147
+ """
148
+ n = 2.0 ** z
149
+ min_lon = x / n * 360.0 - 180.0
150
+ max_lon = (x + 1) / n * 360.0 - 180.0
151
+
152
+ def tile_y_to_lat(yt: float) -> float:
153
+ # convert fractional tile y to latitude in degrees
154
+ merc_y = math.pi * (1 - 2 * yt / n)
155
+ lat_rad = math.atan(math.sinh(merc_y))
156
+ return math.degrees(lat_rad)
157
+
158
+ max_lat = tile_y_to_lat(y) # top edge (smaller y => larger lat)
159
+ min_lat = tile_y_to_lat(y + 1) # bottom edge
160
+
161
+ return (min_lon, min_lat, max_lon, max_lat)
162
+
163
+ def _tiles_in_bounds(self, clip_to_shape=False) -> Iterator[Tile]:
164
+
165
+ bbox = self.feature.bbox
166
+
167
+ def tile(lng, lat, zoom):
168
+ logger.debug(f"Creating new Tile; lat={lat}; lng={lng}")
169
+
170
+ x = lng / 360.0 + 0.5
171
+ sinlat = math.sin(math.radians(lat))
124
172
 
125
- logger.info(f"TileCollection bounds: x=({self.MIN_X}, {self.MAX_X}) y=({self.MIN_Y}, {self.MAX_Y})")
173
+ try:
174
+ y = 0.5 - 0.25 * math.log((1.0 + sinlat) / (1.0 - sinlat)) / math.pi
175
+ except:
176
+ raise
177
+
178
+ Z2 = math.pow(2, zoom)
179
+
180
+ if x <= 0:
181
+ xtile = 0
182
+ elif x >= 1:
183
+ xtile = int(Z2 - 1)
184
+ else:
185
+ # To address loss of precision in round-tripping between tile
186
+ # and lng/lat, points within EPSILON of the right side of a tile
187
+ # are counted in the next tile over.
188
+ xtile = int(math.floor((x + EPSILON) * Z2))
189
+
190
+ if y <= 0:
191
+ ytile = 0
192
+ elif y >= 1:
193
+ ytile = int(Z2 - 1)
194
+ else:
195
+ ytile = int(math.floor((y + EPSILON) * Z2))
196
+
197
+
198
+ return Box({"x": xtile, "y": ytile})
199
+
200
+ w, s, e, n = bbox.minx, bbox.miny, bbox.maxx, bbox.maxy
201
+ if s < -85.051129 or n > 85.051129:
202
+ logger.warning("Your geometry bounds exceed the Web Mercator's limits")
203
+ logger.info("Clipping bounds for Web Mercator's limits")
204
+
205
+ w = max(-180.0, w)
206
+ s = max(-85.051129, s)
207
+ e = min(180.0, e)
208
+ n = min(85.051129, n)
209
+
210
+ ul_tile = tile(w, n, self.zoom)
211
+ lr_tile = tile(e - LL_EPSILON, s + LL_EPSILON, self.zoom)
212
+ logger.debug(f"UpperLeft Tile=({ul_tile}); LowerRight Tile=({lr_tile})")
213
+
214
+ self._tile_count = 0
215
+ self.MAX_X, self.MIN_X = lr_tile.x, ul_tile.x
216
+ self.MAX_Y, self.MIN_Y = lr_tile.y, ul_tile.y
217
+ logger.info(
218
+ f"TileCollection bounds: x=({self.MIN_X}, {self.MAX_X}) y=({self.MIN_Y}, {self.MAX_Y})"
219
+ )
220
+
221
+ for i in range(ul_tile.x, lr_tile.x + 1):
222
+ for j in range(ul_tile.y, lr_tile.y + 1):
223
+ if clip_to_shape:
224
+ tb = box(*self.tile_bbox(i,j,self.zoom))
225
+ if not tb.intersects(self.feature.shape.geometry).any():
226
+ logger.debug(f"Tile excluded: z={self.zoom}, x={i}, y={j}")
227
+ continue
228
+ self._tile_count += 1
229
+ t = Tile(i, j, self.zoom)
230
+ t.position = self.MIN_X, self.MIN_Y
231
+ yield t
126
232
 
127
- for i in range(len(self._cache)):
128
- self._cache[i].position = self.MIN_X, self.MIN_Y
129
233
 
130
234
  class TilesByBBox(TileCollection):
131
-
132
- def _build_tile_cache(self) -> list[Tile]:
235
+
236
+ def _build_tile_cache(self) -> List[Tile]:
133
237
  logger.info(f"Building tiles by bounding box at zoom level {self.zoom}")
134
238
  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")
239
+ logger.debug(
240
+ f"BBox coordinates: minx={bbox.minx}, miny={bbox.miny}, maxx={bbox.maxx}, maxy={bbox.maxy}"
241
+ )
242
+
243
+ self._cache = list(self._tiles_in_bounds(True))
244
+ logger.debug(f"Generated {len(self)} tiles from bounding box")
149
245
  return self._cache
150
246
 
247
+
151
248
  class TilesByShape(TileCollection):
152
249
 
153
- def _build_tile_cache(self) -> list[Tile]:
154
- from shapely.geometry import box
250
+ def _build_tile_cache(self) -> List[Tile]:
155
251
 
156
252
  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
253
 
177
- self._cache = tiles
178
- logger.info(f"Generated {len(tiles)} tiles from shape intersection")
179
- return tiles
254
+ bbox = self.feature.bbox
255
+ logger.debug(
256
+ f"Checking tiles within bbox: minx={bbox.minx}, miny={bbox.miny}, maxx={bbox.maxx}, maxy={bbox.maxy}"
257
+ )
258
+
259
+ self._cache = list(self._tiles_in_bounds(True))
260
+ logger.info(f"Generated {len(self)} tiles from shape intersection")
261
+ return self._cache
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tilegrab
3
- Version: 1.1.0
3
+ Version: 1.2.0b1
4
4
  Summary: Fast geospatial map tile downloader and mosaicker
5
5
  Author: Thiwanka Munasinghe
6
6
  License-Expression: MIT
@@ -14,7 +14,7 @@ Requires-Python: >=3.9
14
14
  Description-Content-Type: text/markdown
15
15
  License-File: LICENSE
16
16
  Requires-Dist: requests
17
- Requires-Dist: mercantile
17
+ Requires-Dist: rasterio
18
18
  Requires-Dist: pillow
19
19
  Requires-Dist: tqdm
20
20
  Requires-Dist: python-box
@@ -0,0 +1,15 @@
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,,
tilegrab/mosaic.py DELETED
@@ -1,75 +0,0 @@
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"))
@@ -1,15 +0,0 @@
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,,