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/__init__.py +5 -0
- gemf/dump.py +124 -0
- gemf/gemf.py +916 -0
- gemf/gemf_dump.py +806 -0
- gemf/tiles.py +510 -0
- gemf/utils.py +100 -0
- gemf_map-0.3.0.dist-info/METADATA +61 -0
- gemf_map-0.3.0.dist-info/RECORD +11 -0
- gemf_map-0.3.0.dist-info/WHEEL +5 -0
- gemf_map-0.3.0.dist-info/licenses/LICENSE +674 -0
- gemf_map-0.3.0.dist-info/top_level.txt +1 -0
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,,
|