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/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
- from typing import Any, List, Optional, Tuple, Union
7
- from PIL import Image as PLIImage
6
+ from typing import Any, List, Union
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:
@@ -133,15 +142,68 @@ class TileImage:
133
142
  return self._tile.url
134
143
 
135
144
 
145
+ WEB_MERCATOR_EXTENT = 20037508.342789244
146
+ EPSG = 3857
147
+
148
+
136
149
  class TileImageCollection:
137
150
  images: List[TileImage] = []
138
151
  width: int = 0
139
152
  height: int = 0
140
153
 
141
- def load(self, tile_collection:TileCollection):
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
+
187
+ def mosaic_bounds(self, x_min, y_min, x_max, y_max, z):
188
+ n = 2**z
189
+ tile_size_m = 2 * WEB_MERCATOR_EXTENT / n
190
+
191
+ xmin = (WEB_MERCATOR_EXTENT * -1) + x_min * tile_size_m
192
+ xmax = (WEB_MERCATOR_EXTENT * -1) + (x_max + 1) * tile_size_m
193
+
194
+ ymax = WEB_MERCATOR_EXTENT - y_min * tile_size_m
195
+ ymin = WEB_MERCATOR_EXTENT - (y_max + 1) * tile_size_m
196
+
197
+ return xmin, ymin, xmax, ymax
198
+
199
+ def load(self, tile_collection: TileCollection):
200
+ import re
201
+
142
202
  logger.info("Start loading saved ImageTiles")
143
- 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]+$")
144
204
  image_col = [p for p in self.path.glob(f"*.*") if p.is_file()]
205
+ self.zoom = tile_collection.to_list[0].z
206
+ logger.info(f"Found {len(image_col)} images at {self.path}")
145
207
 
146
208
  for tile in tile_collection.to_list:
147
209
  found_matching_image = False
@@ -154,66 +216,125 @@ class TileImageCollection:
154
216
 
155
217
  if tile.x == x and tile.y == y and tile.z == z:
156
218
  logger.debug(f"Processing ImageTile x={x} y={y} z={z}")
157
- with open(image_path, 'rb') as f:
219
+ with open(image_path, "rb") as f:
158
220
  tile_image = TileImage(tile, f.read())
159
221
  tile_image.path = image_path
160
222
  self.images.append(tile_image)
161
223
  found_matching_image = True
162
224
  continue
163
225
 
164
- 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}")
165
228
 
229
+ logger.info(f"{len(self.images)} images loaded, {len(image_col) - len(self.images)} skipped")
230
+
231
+ self._update_collection_dim()
232
+
166
233
  def append(self, img: TileImage):
167
234
  img.path = os.path.join(self.path, img.name)
168
235
  self.images.append(img)
169
236
  logger.debug(f"Image appended to collection: {img.name}")
170
237
  img.save()
171
238
 
172
- def __init__(self, path: Union[Path, str]) -> None:
173
- self.path = Path(path)
174
- logger.info(f"TileImageCollection initialized at {self.path}")
239
+ self.zoom = img.tile.z
175
240
 
176
- def __len__(self):
177
- return sum(1 for _ in self)
178
-
179
- def __iter__(self):
180
- for i in self.images:
181
- yield i
182
-
183
- def __repr__(self) -> str:
184
- return f"ImageCollection; len={len(self)}"
185
-
186
- def _update_collection_dim(self):
187
- if not self.images:
188
- logger.warning("Attempting to update collection dimensions with no images")
189
- return
190
-
191
- x = [img.tile.x for img in self.images]
192
- y = [img.tile.y for img in self.images]
193
- minx, maxx = min(x), max(x)
194
- miny, maxy = min(y), max(y)
195
-
196
- self.width = int((maxx - minx + 1) * self.images[0].width)
197
- self.height = int((maxy - miny + 1) * self.images[0].height)
198
- logger.info(f"Collection dimensions calculated: {self.width}x{self.height}")
199
-
200
- def mosaic(self):
201
- logger.info("Starting mosaic creation")
241
+ @cache
242
+ def _create_mosaic(self) -> PILImage.Image:
243
+ logger.info("Start mosaicing TileImageCollection")
202
244
  self._update_collection_dim()
203
245
 
204
- logger.info(f"Mosaicking {len(self.images)} images into {self.width}x{self.height}")
205
- merged_image = PLIImage.new("RGB", (self.width, self.height))
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))
206
250
 
207
251
  for image in self.images:
208
252
  px = int((image.position.x) * image.width)
209
253
  py = int((image.position.y) * image.height)
210
254
  logger.debug(f"Pasting image at position ({px}, {py}): {image.name}")
211
255
  merged_image.paste(image.image, (px, py))
256
+
257
+ return merged_image
212
258
 
213
- output_path = "merged_output.png"
214
- merged_image.save(output_path)
215
- logger.info(f"Mosaic saved to {output_path}")
259
+ def mosaic(self, export_types: List[int]):
260
+
261
+ merged_image = self._create_mosaic()
262
+ # TODO: fix this monkey patch
263
+ if ExportType.TIFF in export_types:
264
+ output_path = "mosaic.tiff"
265
+ import numpy as np
266
+ from rasterio.transform import from_bounds
267
+ import rasterio
268
+
269
+ data = np.array(merged_image)
270
+ data = data.transpose(2, 0, 1)
271
+
272
+ width_px, height_px = merged_image.size
273
+ xmin, ymin, xmax, ymax = self.mosaic_bounds(
274
+ self.minx, self.miny, self.maxx, self.maxy, self.images[0].tile.z
275
+ )
276
+
277
+ transform = from_bounds(xmin, ymin, xmax, ymax, width_px, height_px)
278
+
279
+ with rasterio.open(
280
+ output_path,
281
+ "w",
282
+ driver="GTiff",
283
+ height=height_px,
284
+ width=width_px,
285
+ count=data.shape[0],
286
+ dtype=data.dtype,
287
+ crs=f"EPSG:{EPSG}",
288
+ transform=transform,
289
+ ) as dst:
290
+ dst.write(data)
291
+
292
+ logger.info(f"Mosaic saved to {output_path}")
293
+ if ExportType.PNG in export_types:
294
+ output_path = "mosaic.png"
295
+ merged_image.save(output_path)
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}")
216
337
 
217
338
  def export_collection(self, type: ExportType):
218
339
  logger.info(f"Exporting collection as type {type}")
219
- pass
340
+ raise NotImplementedError
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/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