gemf-map 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gemf/tiles.py ADDED
@@ -0,0 +1,510 @@
1
+ """
2
+ This submodule contains supportive functionality to work with tiles contained in a `.gemf` file.
3
+ """
4
+
5
+ import math
6
+ import os
7
+ import re
8
+ from copy import copy
9
+
10
+ import itertools
11
+ from typing import List, Literal, Optional, Union, Tuple
12
+
13
+ from gemf.utils import BYTE_STR_JPG_END, BYTE_STR_JPG_START, BYTE_STR_PNG, FORMAT_PATTERNS, get_image_format, listfiles
14
+
15
+
16
+ def validate_zxy(z: int, x: int, y: int):
17
+ """All values need to be castable to int, non-negative. `x` and `y` must be smaller than 602**`z`."""
18
+ n = 2**z
19
+ assert z >= 0 and x >= 0 and y >= 0, "z, x, y must be non-negative."
20
+ assert (0 <= x < n) and (0 <= y < n), f"At zoom {z}, x ({x}) and y ({y}) must be smaller than 2**z = {2**z}."
21
+ assert isinstance(z, int) and isinstance(x, int) and isinstance(y, int), f"All values must be of type `int`"
22
+ return z, x, y
23
+
24
+
25
+ class TileBase:
26
+ """Base class for tiles."""
27
+ def __init__(self, z: int, x: int, y: int, allow_invalid: bool = False, *args, **kwargs) -> None:
28
+ if not allow_invalid: z, x, y = validate_zxy(z, x, y)
29
+
30
+ self.z = z
31
+ self.x = x
32
+ self.y = y
33
+
34
+ def __str__(self): return f"Tile (Z={self.z}, X={self.x}, Y={self.y})"
35
+ def __repr__(self): return f"Tile ({self.z}, {self.x}, {self.y})"
36
+ def __eq__(self, other): return (self.z == other.z) and (self.x == other.x) and (self.y == other.y)
37
+ def __lt__(self, other): return (self.z < other.z) or (self.z == other.z and (self.x < other.x)) or ((self.z == other.z and (self.x == other.x)) and self.y < other.y)
38
+
39
+ def neighbor(self, dx: int = 0, dy: int = 0, allow_invalid: bool = False):
40
+ """
41
+ Get a neighboring tile.
42
+
43
+ # Parameters
44
+ - `dx`: neighbor distance in x-direction
45
+ - `dy`: neighbor distance in y-direction
46
+ - `allow_invalid`: whether tiles outside of the range [0, 2**z) should be allowed
47
+ """
48
+ if not allow_invalid:
49
+ n = 2**self.z
50
+ if not ((0 <= (self.x+dx) < n) and (0 <= (self.y+dy) < n)): raise ValueError(f"Invalid neighbor: at zoom {self.z}, x ({self.x}) and y ({self.y}) must be smaller than 2**z = {2**self.z}.")
51
+
52
+ # TODO: copy safe? should probably use TileBase(self.x+dx, self.y+dy, ...)
53
+ n = copy(self)
54
+ n.x = self.x+dx
55
+ n.y = self.y+dy
56
+ return n
57
+
58
+
59
+ class NamedTileBase(TileBase):
60
+ """Base class for named tiles, for tiles that either are already present on disk, or tiles that are intended to be saved to disk."""
61
+ def __init__(self, z: int, x: int, y: int, name: str, format: str, allow_invalid: bool = False) -> None:
62
+ super().__init__(z, x, y, allow_invalid)
63
+ self.name = name
64
+ self.format = format
65
+
66
+ # @staticmethod
67
+ # def parse_tilename(tilename: str):
68
+ # """Extract name, z, x, y and tile format from a file. Expected format: `TILENAME_Z_X_Y.FORMAT`."""
69
+ # name, z, x, y, format = re.search(r"(.+?)_(\d+?)_(\d+?)_(\d+?)[.](.+)", os.path.basename(tilename)).groups()
70
+ # return name, int(z), int(x), int(y), format
71
+
72
+ def get_filename(self) -> str:
73
+ """Re-build filename."""
74
+ filename = f"{self.name}_{self.z}_{self.x}_{self.y}.{self.format}"
75
+ return filename
76
+
77
+ def save(self, tiledir: str, determine_image_format: bool = True):
78
+ """Save tile contents to file. Classes inheriting from `NamedTileBase` are expected to implement the `load_bytes()` method, returning the image contents as a byte object."""
79
+
80
+ data_bytes = self.load_bytes()
81
+ if determine_image_format and self.format is None:
82
+ self.format = get_image_format(data_bytes, FORMAT_PATTERNS)
83
+
84
+ with open(os.path.join(tiledir, self.get_filename()), "wb") as f:
85
+ f.write(data_bytes)
86
+
87
+
88
+ class Tile(NamedTileBase):
89
+ """A regular tile, present on disk."""
90
+ def __init__(self, z: int, x: int, y: int, tiledir: str, name: str, format: str, allow_invalid: bool = False) -> None:
91
+ self.name = name
92
+ self.format = format
93
+ self.tiledir = tiledir
94
+ super().__init__(z, x, y, name, format, allow_invalid)
95
+
96
+ @classmethod
97
+ def from_file(cls, tile_file: str) -> None:
98
+ """Create a `Tile` object from a file on disk."""
99
+ return cls(**Tile.parse_filepath(tile_file), allow_invalid=False)
100
+
101
+ # TODO: what does python staticmethod even do?
102
+ @staticmethod
103
+ def parse_filepath(tile_file: str) -> tuple:
104
+ """Parse filepath of a tile into its components, in the order necessary for the `Tile` constructor."""
105
+ tiledir = os.path.dirname(tile_file)
106
+ name, z, x, y, format = re.search(r"(.+?)_(\d+?)_(\d+?)_(\d+?)[.](.+)", os.path.basename(tile_file)).groups()
107
+ return {"z": int(z), "x": int(x), "y": int(y), "tiledir": tiledir, "name": name, "format": format}
108
+
109
+ @staticmethod
110
+ def compose_filepath(z, x, y, tiledir, name, format):
111
+ filename = f"{name}_{z}_{x}_{y}.{format}"
112
+ return os.path.join(tiledir, filename)
113
+
114
+ def load_bytes(self):
115
+ """Load the tile's image content as bytes."""
116
+ with open(self.get_filepath(), "rb") as f:
117
+ data_bytes = f.read()
118
+ return data_bytes
119
+
120
+ def get_filepath(self) -> str:
121
+ """Return the whole filepath to the tile file."""
122
+ return os.path.join(self.tiledir, self.get_filename())
123
+
124
+
125
+ class BufferTile(NamedTileBase):
126
+ """
127
+ A tile with its image contents in memory. Useful for reading tile images from map files and storing them.
128
+
129
+ # Parameters
130
+ - `z, x, y`: tile coordinates
131
+ - `data`: tile data as bytes
132
+ - `name`: an optional name for storing the tile contents to file
133
+ - `format`: an optional image format used as file ending when storing the tile to file
134
+ - `allow_invalid`: whether to raise an error when the tile coordinates are invalid; by default raises an error
135
+ """
136
+ def __init__(self, z: int, x: int, y: int, data: bytes, name: str = "tile", format: Optional[str] = None, allow_invalid: bool = False) -> None:
137
+
138
+ # if format == "get_image_format": format = get_image_format(data, format_patterns=FORMAT_PATTERNS, raise_errors=True)
139
+ super().__init__(z, x, y, name, format, allow_invalid)
140
+
141
+ self.data = data
142
+
143
+ def load_bytes(self):
144
+ """Return the binary image data."""
145
+ return self.data
146
+
147
+
148
+ class EmptyTile(BufferTile):
149
+ """Represents an empty tile. Optionally, magic numbers or other "empty tile data" may be passed via the `data` parameter."""
150
+ def __init__(self, z: int, x: int, y: int, data: bytes = bytes(), name: str = "tile", allow_invalid: bool = False) -> None:
151
+ super().__init__(z, x, y, data, name, allow_invalid)
152
+
153
+
154
+ class EmptyPNG(EmptyTile):
155
+ # TODO: remove?
156
+ """Represents a tile with zero-length PNG data."""
157
+ def __init__(self, z, x, y, allow_invalid: bool = False) -> None:
158
+ super().__init__(z, x, y, bytes(), name="empty_png", allow_invalid=allow_invalid)
159
+
160
+ def load_bytes(self): return BYTE_STR_PNG
161
+
162
+
163
+ class EmptyJPG(EmptyTile):
164
+ # TODO: remove?
165
+ """Represents a tile with zero-length JPG data."""
166
+ def __init__(self, z, x, y, allow_invalid: bool = False) -> None:
167
+ super().__init__(z, x, y, bytes(), name="empty_jpg", allow_invalid=allow_invalid)
168
+
169
+ def load_bytes(self): return BYTE_STR_JPG_START + BYTE_STR_JPG_END
170
+
171
+
172
+
173
+ def get_tile_row(start: Tile, end: Tile, allow_invalid: bool = False):
174
+ """Get a row of tiles. Used to determine rectangular tile areas."""
175
+ return [Tile(start.z, x_, start.y, start.tiledir, start.name, start.format, allow_invalid) for x_ in range(start.x, end.x+1)]
176
+
177
+
178
+ class TileCollection:
179
+ """An arbitrary collection of tiles."""
180
+ def __init__(self, tiles: List[Tile], sort: bool = True) -> None:
181
+ if sort:
182
+ self.tiles = sorted(tiles, key=sort if (sort is not True) else None)
183
+ else:
184
+ self.tiles = tiles
185
+ self.is_sorted = True if sort else False
186
+
187
+ def __len__(self): return len(self.tiles)
188
+ def __iter__(self): return iter(self.tiles)
189
+ def __repr__(self): return f"{type(self).__name__}: {str(self.tiles)}"
190
+
191
+ def __add__(self, other): return TileCollection([*self.tiles, *other.tiles], sort=self.is_sorted and other.is_sorted)
192
+
193
+ def __getitem__(self, index: Union[Tuple[int], int]):
194
+
195
+ if isinstance(index, int):
196
+ return self.tiles[index]
197
+ else:
198
+ z, x, y = index
199
+ return self.get_tile(z, x, y)
200
+
201
+ @classmethod
202
+ def from_tiledir(cls, tile_dir):
203
+ tile_files = listfiles(tile_dir, formats=["png", "jpg"])
204
+ tiles = [Tile.from_file(os.path.join(tile_dir, tile_file)) for tile_file in tile_files]
205
+ return cls(tiles)
206
+
207
+ @staticmethod
208
+ def grid_xy2idx(z: int, x: int, y: int):
209
+ z, x, y = validate_zxy(z, x, y)
210
+ n = 2**z
211
+ return y*n + x
212
+
213
+ def get_index(self, z: int, x: int, y: int):
214
+ try:
215
+ return self.tiles.index(tile := Tile(z,x,y))
216
+ except AttributeError as exc:
217
+ raise AttributeError(f"{tile} not in {(type(self).__name__)}") from exc
218
+
219
+ def get_tile(self, z: int, x: int, y: int):
220
+ return self.tiles[self.get_index(z, x, y)]
221
+
222
+ def to_tile_levels(self):
223
+ levels = []
224
+ zs = {tile.z for tile in self.tiles}
225
+ for z in zs:
226
+ tiles_z = self.filter_tiles("z", z)
227
+ tilelevel = TileLevel(z, tiles_z)
228
+ levels.append(tilelevel)
229
+ return levels
230
+
231
+ def to_tileranges(self, mode: Literal["split", "fill"] = "fill", **kwargs):
232
+ """
233
+ Convert an unstructured `TileCollection` into rectangular `TileRange` object(s).
234
+ First, the collection will be split into different (z) levels, then each level is split into one or more ranges,
235
+ where the conversion behavior is controlled by the parameter `mode`. Further `kwargs` are passed to `TileLevel.make_range()`.
236
+ """
237
+ levels = self.to_tile_levels()
238
+ return [range for level in levels for range in level.make_range(mode, **kwargs)]
239
+
240
+ def filter_tiles(self, key, val, tiles=None): return [tile for tile in (tiles or self.tiles) if getattr(tile, key) == val]
241
+
242
+
243
+ class TileLevel(TileCollection):
244
+ """A collection of tiles of equal zoom level."""
245
+ def __init__(self, z: int, tiles: List[Tile], sort: bool = True) -> None:
246
+ self.z = z
247
+
248
+ xs = [tile.x for tile in tiles]
249
+ ys = [tile.y for tile in tiles]
250
+
251
+ self.xmin = min(xs)
252
+ self.xmax = max(xs)
253
+ self.ymin = min(ys)
254
+ self.ymax = max(ys)
255
+ super().__init__(tiles, sort)
256
+
257
+ def __repr__(self): return f"{type(self).__name__} (z={self.z}): {str(self.tiles)}"
258
+
259
+ @classmethod
260
+ def from_tiledir(cls, tile_dir: str):
261
+ """Create a `TileLevel` from all tile images present in `tile_dir`."""
262
+ tc = TileCollection.from_tiledir(tile_dir)
263
+ return tc.to_tile_levels()
264
+
265
+ def is_rect(self):
266
+ """Whether the tiles in the `TileLevel` form a complete rectangular area."""
267
+ tile_coords = {(tile.x, tile.y) for tile in self.tiles}
268
+ tiles_rect = itertools.product(range(self.xmin, self.xmax+1), range(self.ymin, self.ymax+1))
269
+ return all([(xy in tile_coords) for xy in tiles_rect])
270
+
271
+ def _split_level(self, **kwargs):
272
+ """Split the `TileLevel` into (multiple) `TileRange` objects by splitting."""
273
+ rects = []
274
+ tiles_non_assigned = sorted(copy(self.tiles))
275
+
276
+ while len(tiles_non_assigned):
277
+
278
+ curr_rect = []
279
+ curr_tile = tiles_non_assigned[0]
280
+ curr_start = curr_tile
281
+ curr_rect.append(curr_tile)
282
+ tiles_non_assigned.remove(curr_tile)
283
+
284
+ while (curr_tile := curr_tile.neighbor(dx=1, allow_invalid=True)) in tiles_non_assigned:
285
+ curr_rect.append(curr_tile)
286
+ tiles_non_assigned.remove(curr_tile)
287
+ next_row = get_tile_row(curr_start.neighbor(dy=1, allow_invalid=True), curr_tile.neighbor(dx=-1, dy=1, allow_invalid=True), allow_invalid=True)
288
+ while all([(tile_ in tiles_non_assigned) for tile_ in next_row]):
289
+ curr_rect.extend(next_row)
290
+ for tile_ in next_row: tiles_non_assigned.remove(tile_)
291
+ next_row = get_tile_row(next_row[0].neighbor(dy=1), next_row[-1].neighbor(dy=1))
292
+
293
+ rects.append(TileRange(self.z, curr_rect))
294
+
295
+ return rects
296
+
297
+ def _fill_level(self, **kwargs):
298
+ # def _fill_level(self, empty_format: Literal["none", "png", "jpg"]):
299
+ """
300
+ Fill the `TileLevel` by adding empty filler tiles to create a single `TileRange`.
301
+ File content of
302
+ """
303
+ # empty_class = getattr(sys.modules[__name__], f"Empty{empty_format.upper()}")
304
+ ar = AbstractRange(self.z, self.xmin, self.xmax, self.ymin, self.ymax)
305
+ tiles = []
306
+ for tile in ar:
307
+ if tile in self:
308
+ tiles.append(tile)
309
+ else:
310
+ tiles.append(EmptyTile(tile.z, tile.x, tile.y, **kwargs))
311
+ # tiles.append(empty_class(tile.z, tile.x, tile.y))
312
+
313
+ return TileRange(self.z, tiles)
314
+
315
+
316
+ def make_range(self, mode: Literal["split", "fill"] = "fill", **kwargs):
317
+ """
318
+ Convert a `TileLevel` into (multiple) `TileRange` objects.
319
+
320
+ # Modes:
321
+ - fill: complete the `TileLevel` with empty dummy tiles. Further `kwargs` are passed to the constructor of `EmptyTile`.
322
+ - split: split the `TileLevel` into multiple smaller, rectangular sets. Further `kwargs` are ignored.
323
+ """
324
+ # if len(self.tiles) == 1: return [self]
325
+ # TODO: test if works with single tile TileLevel?
326
+
327
+ if mode == "split":
328
+ return self._split_level(**kwargs)
329
+ elif mode == "fill":
330
+ return self._fill_level(**kwargs)
331
+ else:
332
+ raise ValueError(f"Mode {mode} not supported.")
333
+
334
+
335
+ def print_details(self):
336
+ """Print a comprehensive summary of the `TileLevel`."""
337
+ print(f"{type(self).__name__} ({len(self.tiles)}) | Z = {self.z} | is_rect = {self.is_rect()}")
338
+ print(f" X: {self.xmin} - {self.xmax}")
339
+ print(f" Y: {self.ymin} - {self.ymax}")
340
+ for tile in self.tiles: print(" ", tile)
341
+
342
+
343
+ class TileRange(TileLevel):
344
+ """A collection of tiles which form a rectangular area."""
345
+ def __repr__(self): return f"{type(self).__name__} z={self.z} lim={((self.xmin, self.xmax, self.ymin, self.ymax))}"
346
+
347
+ def __init__(self, z: int, tiles: List[Tile], sort: bool = True) -> None:
348
+ super().__init__(z, tiles, sort)
349
+
350
+ if not self.is_rect(): raise RuntimeError("Tried to instantiate `TileRange` with non-rectangular set of tiles.")
351
+
352
+
353
+ # TODO: unify abstract range and slippytlemap
354
+ class AbstractRange:
355
+ """
356
+ Class to collect range arithmetic operations. Tiles are not stored explicitly.
357
+
358
+ # Params
359
+ - `z`: the range's zoom level
360
+ - `xmin`: the range's minimum x coordinate
361
+ - `xmax`: the range's maximum x coordinate
362
+ - `ymin`: the range's minimum y coordinate
363
+ - `ymax`: the range's maximum y coordinate
364
+ - `major`: range traversion mode, either 'row' or 'col'; default 'row'
365
+
366
+
367
+ #### Traversing the range:
368
+ - row: An order like (x0, y0), (x1, y0), ..., (xN, y0) is assumed
369
+ - col: An order like (x0, y0), (x0, y1), ..., (x0, yN) is assumed
370
+ """
371
+ @staticmethod
372
+ def index2xy(index: int, xmin: int, xmax: int, ymin: int, ymax: int, major: Literal["row", "col"] = "row"):
373
+
374
+ if index > (length := AbstractRange.len(xmin, xmax, ymin, ymax)):
375
+ raise IndexError(f"Index {index} larger than number of tiles in range {length}")
376
+
377
+ if major == "row":
378
+ cols = xmax - xmin + 1
379
+ y_rel, x_rel = divmod(index, cols)
380
+ elif major == "col":
381
+ rows = ymax - ymin + 1
382
+ x_rel, y_rel = divmod(index, rows)
383
+ else:
384
+ raise ValueError("Only supports row-major or column-major ordering.")
385
+
386
+ x = xmin + x_rel
387
+ y = ymin + y_rel
388
+ return x, y
389
+
390
+ @staticmethod
391
+ def len(xmin: int, xmax: int, ymin: int, ymax: int) -> int:
392
+ cols = xmax - xmin + 1
393
+ rows = ymax - ymin + 1
394
+ return cols * rows
395
+
396
+ def __init__(self, z: int, xmin: int, xmax: int, ymin: int, ymax: int, major: Literal["row", "col"] = "row") -> None:
397
+ self.z = z
398
+ self.xmin = xmin
399
+ self.xmax = xmax
400
+ self.ymin = ymin
401
+ self.ymax = ymax
402
+
403
+ self.major = major
404
+
405
+ def __len__(self) -> int:
406
+ cols = self.xmax - self.xmin + 1
407
+ rows = self.ymax - self.ymin + 1
408
+ return cols * rows
409
+
410
+ def __iter__(self):
411
+ for index in range(len(self)):
412
+ yield self[index]
413
+
414
+ def __getitem__(self, index: int) -> TileBase:
415
+ if self.major == "row":
416
+ cols = self.xmax - self.xmin + 1
417
+ y_rel, x_rel = divmod(index, cols)
418
+ elif self.major == "col":
419
+ rows = self.ymax - self.ymin + 1
420
+ x_rel, y_rel = divmod(index, rows)
421
+ else:
422
+ raise ValueError("Only supports row-major or column-major ordering.")
423
+
424
+ x = self.xmin + x_rel
425
+ y = self.ymin + y_rel
426
+ return TileBase(self.z, x, y)
427
+
428
+ @staticmethod
429
+ def get_tiles(z: int, xmin: int, xmax: int, ymin: int, ymax: int, **kwargs) -> List[Tuple[int, int, int]]:
430
+ if not (0 <= xmin <= xmax < 2**z) or not (0 <= ymin <= ymax < 2**z):
431
+ raise ValueError(f"Tile coordinates out of range for zoom {z}.")
432
+
433
+ return [(z, x, y) for x in range(xmin, xmax + 1) for y in range(ymin, ymax + 1)]
434
+
435
+ @staticmethod
436
+ def intersection(
437
+ coords1: Tuple[int, int, int, int],
438
+ coords2: Tuple[int, int, int, int]
439
+ ) -> Tuple[int, int, int, int]:
440
+ xmin1, xmax1, ymin1, ymax1 = coords1
441
+ xmin2, xmax2, ymin2, ymax2 = coords2
442
+ xmin = max(xmin1, xmin2)
443
+ xmax = min(xmax1, xmax2)
444
+ ymin = max(ymin1, ymin2)
445
+ ymax = min(ymax1, ymax2)
446
+
447
+ if xmin > xmax or ymin > ymax:
448
+ return None # No intersection
449
+
450
+ return xmin, xmax, ymin, ymax
451
+
452
+
453
+
454
+ class RangeCollection:
455
+ def __call__(self, ranges: List[TileRange]):
456
+ self.ranges = ranges
457
+
458
+
459
+ def reindex(self, method: Literal["max_area"]):
460
+ # TODO: implement (maybe max_area, row_major, col_major?)
461
+ pass
462
+
463
+ def _reindex_max_area(self):
464
+ # google algo: find rectangular regions of max area in binary mask
465
+ pass
466
+
467
+
468
+ class SlippyTileMap:
469
+ # 0,0 at top left
470
+ def lonlat_to_tile(zoom: int, lon: float, lat: float):
471
+ lat_rad = math.radians(lat)
472
+ n = 2 ** zoom
473
+ x_tile = int((lon + 180.0) / 360.0 * n)
474
+ y_tile = int((1.0 - math.log(math.tan(lat_rad) + 1 / math.cos(lat_rad)) / math.pi) / 2.0 * n)
475
+ return x_tile, y_tile
476
+
477
+ def tile_to_lonlat(zoom: int, x_tile: int, y_tile: int):
478
+ # of top_left_corner
479
+ n = 2 ** zoom
480
+ lon = x_tile / n * 360.0 - 180.0
481
+ lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * y_tile / n)))
482
+ lat = math.degrees(lat_rad)
483
+ return lat, lon
484
+
485
+ def get_parent_tile(zoom: int, x_tile: int, y_tile: int):
486
+ # TODO: test
487
+ return zoom-1, x_tile//2, y_tile//2
488
+
489
+ def get_subtiles(zoom: int, x_tile: int, y_tile: int):
490
+ # order tl, tr, bl, br
491
+ return [(zoom+1, 2*x_tile+dx, 2*y_tile+dy) for dy in [0, 1] for dx in [0, 1]]
492
+
493
+ def get_parent_tile_at_zoom(zoom: int, zoom_target: int, x_tile: int, y_tile: int):
494
+ if zoom < zoom_target:
495
+ raise ValueError("Target zoom can't be smaller than source zoom")
496
+
497
+ while zoom > zoom_target:
498
+ zoom, x_tile, y_tile = SlippyTileMap.get_parent_tile(zoom, x_tile, y_tile)
499
+ zoom -= 1
500
+ return zoom, x_tile, y_tile
501
+
502
+ def get_subtile_at_zoom(zoom: int, zoom_target: int, x_tile: int, y_tile: int, subtile: Literal["tl", "tr", "bl", "br"]):
503
+ if zoom > zoom_target:
504
+ raise ValueError("Target zoom can't be larger than source zoom")
505
+
506
+ subtile_idx = ["tl", "tr", "bl", "br"].index(subtile)
507
+ while zoom < zoom_target:
508
+ zoom, x_tile, y_tile = SlippyTileMap.get_subtiles(zoom, x_tile, y_tile)[subtile_idx]
509
+ zoom += 1
510
+ return zoom, x_tile, y_tile
gemf/utils.py ADDED
@@ -0,0 +1,100 @@
1
+ """
2
+ This submodule contains miscellaneous utilities for the `gemf` main module.
3
+ """
4
+
5
+ import inspect
6
+ import json
7
+ import os
8
+ import re
9
+ from typing import Any, Dict, List, Type
10
+
11
+
12
+ # io
13
+ def listfiles(directory: str, formats: List[str] = []):
14
+ """List all files in a directory. Optionally only list files of certain formats."""
15
+ all_items = os.listdir(directory)
16
+ files = [item for item in all_items if os.path.isfile(os.path.join(directory, item))]
17
+
18
+ if formats:
19
+ files = [file for file in files if any([file.endswith(format_) for format_ in formats])]
20
+
21
+ return files
22
+
23
+ def listdirs(directory: str):
24
+ """List all subdirectories in a directory."""
25
+ all_items = os.listdir(directory)
26
+ dirs = [item for item in all_items if os.path.isdir(os.path.join(directory, item))]
27
+ return dirs
28
+
29
+
30
+ # check types
31
+ def can_cast(value: Any, target_type: Type):
32
+ try:
33
+ _ = target_type(value)
34
+ return True
35
+ except:
36
+ return False
37
+
38
+
39
+ def is_jsonable(obj):
40
+ """Whether an object can be serialized to json."""
41
+ try:
42
+ json.dumps(obj)
43
+ return True
44
+ except:
45
+ return False
46
+
47
+ def to_json(obj):
48
+ """If an object can be serialized to json, it will be returned unchanged. Else, a string representation of the object is returned."""
49
+ if is_jsonable(obj):
50
+ return obj
51
+ else:
52
+ return str(obj)
53
+
54
+
55
+ # formats
56
+ BYTE_STR_PNG = b'\x89PNG\r\n\x1a\n'; """Start byte sequence of a PNG file."""
57
+
58
+ BYTE_STR_JPG_START = b'\xff\xd8'; """Start byte sequence of a JPG file."""
59
+ BYTE_STR_JPG_END = b'\xff\xd9'; """End byte sequence of a JPG file."""
60
+
61
+ FORMAT_PATTERNS = {
62
+ "empty": re.compile(b"^$"),
63
+ "png": re.compile(re.escape(BYTE_STR_PNG)),
64
+ "jpg": re.compile(BYTE_STR_JPG_START + b'.*?' + BYTE_STR_JPG_END, re.DOTALL)
65
+ }
66
+ """Regex patterns to detect image formats in image byte data."""
67
+
68
+ def get_image_format(img_bytes, format_patterns: Dict[str, re.Pattern] = FORMAT_PATTERNS, raise_errors: bool = True):
69
+ """Detect byte structure of image formats and return the corresponding format."""
70
+ for format, format_pattern in format_patterns.items():
71
+ if format_pattern.search(img_bytes):
72
+ return format
73
+ if raise_errors: raise ValueError(f"Could not identify image format in byte data. Supported formats: {list(format_patterns.keys())}")
74
+ return None
75
+
76
+
77
+ # other
78
+ def kwargify(loc: dict, ignore: List[str] = []):
79
+ """
80
+ Automatically pass arguments as keyword arguments within `__init__` functions for `super()` calls.
81
+
82
+ Particularly useful for multiple inheritance, supporting automatic dispatch of arguments to the
83
+ respective superclasses (**assuming no duplicate parameter names**).
84
+
85
+ # Usage
86
+ ```python
87
+ class C(A, B):
88
+ def __init__(self, a, b):
89
+ super().__init__(
90
+ **kwargify(locals())
91
+ )
92
+ ```
93
+ """
94
+ # TODO: also inspect superclasses?
95
+ ignore.append("self")
96
+
97
+ curr_init = getattr(loc["__class__"], "__init__")
98
+ params = inspect.signature(curr_init).parameters
99
+ kwargs = {key: loc[key] for key in params if key not in ignore}
100
+ return kwargs
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: gemf_map
3
+ Version: 0.3.0
4
+ Summary: GEMF, an efficient tile-based map format.
5
+ Author-email: Colin Moldenhauer <colin.moldenhauer@tum.de>
6
+ Project-URL: Homepage, https://github.com/ColinMoldenhauer/GEMF
7
+ Project-URL: Issues, https://github.com/ColinMoldenhauer/GEMF/issues
8
+ Keywords: gemf,tiles,map,mobile map,locus
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Provides-Extra: test
16
+ Requires-Dist: pytest>=4.6; extra == "test"
17
+ Requires-Dist: pytest-cov; extra == "test"
18
+ Requires-Dist: mercantile; extra == "test"
19
+ Provides-Extra: plot
20
+ Requires-Dist: matplotlib; extra == "plot"
21
+ Requires-Dist: folium; extra == "plot"
22
+ Dynamic: license-file
23
+
24
+ # GEMF
25
+
26
+ Python package for the GEMF map format. From the format [specification](https://www.cgtk.co.uk/gemf):
27
+
28
+ > This tile store format is intended to provide a static (i.e. cannot be updated without regenerating from scratch) file containing a large number of tiles, stored
29
+ > in a manner that makes efficient use of SD cards and with which it is easy to access individual tiles very quickly. It is intended to overcome the existing issues
30
+ > with the way tiles are stored in most existing Android map applications as these are not very scalable.
31
+
32
+
33
+ # Installation
34
+ ```cmd
35
+ pip install gemf-map
36
+ ```
37
+
38
+
39
+ # Features
40
+ Core features are...
41
+ - reading `.gemf` map files via the `GEMF.from_file()` classmethod
42
+ - creating a GEMF object from PNG or JPG tiles via the `GEMF.from_tiles()` classmethod
43
+ - writing a newly created GEMF object to file via the `write()` method
44
+
45
+ Further features are...
46
+ - extracting tiles (PNG or JPG) from binary `.gemf` files via the `save_tiles()` method
47
+ - adding tiles to an existing `.gemf` file (TODO)
48
+
49
+
50
+ # Usage
51
+ ```python
52
+ from gemf import GEMF
53
+
54
+
55
+ my_gemf = GEMF.from_file("MY_GEMF.gemf") # load an existing .gemf file
56
+
57
+ new_gemf = GEMF.from_tiles("PATH/TO/TILEDIR") # create a GEMF object from tiles on disk
58
+ new_gemf.write("PATH/TO/GEMF_FILE.gemf") # write GEMF object to .gemf file
59
+ ```
60
+
61
+ `.gemf` files may be used in mobile mapping applications like [Locus](https://www.locusmap.app/)
@@ -0,0 +1,11 @@
1
+ gemf/__init__.py,sha256=PxzHvNCVWlYDo3Xfrd9eNUJC7tc3aD0oAG5qL5NKwTk,77
2
+ gemf/dump.py,sha256=btwWfyg1_O5Y8_bX-gwil0M1SufQJJuWz-46SSC1yAg,4564
3
+ gemf/gemf.py,sha256=YTesIaFz0b5geh0QxO20Nfmk7uRADYsbtt0ufgfvMy0,35048
4
+ gemf/gemf_dump.py,sha256=Z8SnU1SiK5W2XUaaaB7RoJdPFGPhi94jTJ7Pu-w1P4M,31535
5
+ gemf/tiles.py,sha256=mCM2ab3OHcc0iS--fdf6be_J9XGJHj06iwK4XM6ylkg,20666
6
+ gemf/utils.py,sha256=DW0K54XzVhv1VQkjmFi6oMElsYWhsoKrZqqlHGzdgE8,3216
7
+ gemf_map-0.3.0.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
8
+ gemf_map-0.3.0.dist-info/METADATA,sha256=P2hBVOLHaHQFXPq0vKkd1LKHixKR-UI9Q_bB9iuwc4s,2360
9
+ gemf_map-0.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
10
+ gemf_map-0.3.0.dist-info/top_level.txt,sha256=K1zxX4buZWWAHL9TSx4digcfKGT6LnOWrVDJp12lc0U,5
11
+ gemf_map-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+