ancient-map-tiler 0.1.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.
|
File without changes
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from argparse import Namespace
|
|
4
|
+
from math import ceil, log
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import numpy.typing as npt
|
|
9
|
+
from PIL import Image
|
|
10
|
+
from tqdm import tqdm
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def pad_image_to_fit_tile_size_and_make_transparent(
|
|
14
|
+
source: npt.NDArray, tile_size: int
|
|
15
|
+
) -> npt.NDArray:
|
|
16
|
+
"""
|
|
17
|
+
Pad an image to fit a tile size with transparent pixels. Make the image transparent if it was not already.
|
|
18
|
+
:param source: the source image
|
|
19
|
+
:param tile_size: the size of the tiles the image will be split into
|
|
20
|
+
:return: An image whose width and height are multiples of the tile size
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
height, width, _ = source.shape
|
|
24
|
+
missing_in_y = -height % tile_size
|
|
25
|
+
missing_in_x = -width % tile_size
|
|
26
|
+
|
|
27
|
+
# Make the image transparent if it was not already
|
|
28
|
+
if source.shape[2] == 3:
|
|
29
|
+
transparency_layer = np.full((height, width, 1), 255, dtype=source.dtype)
|
|
30
|
+
source = np.concatenate([source, transparency_layer], axis=2) # (H, W, 4)
|
|
31
|
+
|
|
32
|
+
# right padding
|
|
33
|
+
right_pad = np.full((height, missing_in_x, 4), [0, 0, 0, 0], dtype=source.dtype)
|
|
34
|
+
source = np.concatenate((source, right_pad), axis=1)
|
|
35
|
+
|
|
36
|
+
# bottom padding
|
|
37
|
+
bottom_pad = np.full(
|
|
38
|
+
(missing_in_y, width + missing_in_x, 4), [0, 0, 0, 0], dtype=source.dtype
|
|
39
|
+
)
|
|
40
|
+
return np.concatenate((source, bottom_pad), axis=0)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_tiles(source: npt.NDArray, tile_size: int) -> npt.NDArray:
|
|
44
|
+
"""
|
|
45
|
+
Generate square tiles from an image
|
|
46
|
+
If the image width or height is not a multiple of tile_size the right side tiles and bottom tiles will be padded with black pixels
|
|
47
|
+
:param source: The source image
|
|
48
|
+
:param tile_size: The size of the tile in pixels
|
|
49
|
+
:return: An array with shape (nb_tiles_y, nb_tiles_x, tile_size, tile_size [, pixel size (3 if rgb)] )
|
|
50
|
+
"""
|
|
51
|
+
source = pad_image_to_fit_tile_size_and_make_transparent(source, tile_size)
|
|
52
|
+
height, width, nb_colors = source.shape
|
|
53
|
+
nb_tiles_x = width // tile_size
|
|
54
|
+
nb_tiles_y = height // tile_size
|
|
55
|
+
return source.reshape(
|
|
56
|
+
(nb_tiles_y, tile_size, nb_tiles_x, tile_size, nb_colors)
|
|
57
|
+
).swapaxes(1, 2)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def save_to_directory(
|
|
61
|
+
tiled_image: npt.NDArray,
|
|
62
|
+
target_directory: Path,
|
|
63
|
+
image_format: str,
|
|
64
|
+
progress_bar: tqdm | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""
|
|
67
|
+
Given a tiled map obtained via get_tiles, save it to a directory that can later be used by mapping libraries such as leaflet
|
|
68
|
+
Resulting directory structure:
|
|
69
|
+
└── target_directory
|
|
70
|
+
├── x0
|
|
71
|
+
│ ├── y0.png
|
|
72
|
+
│ └── y1.png
|
|
73
|
+
└── x1
|
|
74
|
+
├── y0.png
|
|
75
|
+
└── y1.png
|
|
76
|
+
:param tiled_image: The tiled image to save
|
|
77
|
+
:param target_directory: The target directory where the tiles will be saved. Must not exist.
|
|
78
|
+
:param image_format: The image format of the tiles. Tested for .webp and .png
|
|
79
|
+
:param progress_bar: Optional, if a progress bar is given it will be ticked for each image saved
|
|
80
|
+
"""
|
|
81
|
+
target_directory.mkdir()
|
|
82
|
+
|
|
83
|
+
# Tiled_image format is row, col, tile (y,x)
|
|
84
|
+
# Tiling system usually work with col, row, tile (x,y)
|
|
85
|
+
# Let's swap x and y
|
|
86
|
+
tiled_image_x_then_y = tiled_image.swapaxes(0, 1)
|
|
87
|
+
for x_index, column_of_tiles in enumerate(tiled_image_x_then_y):
|
|
88
|
+
subdirectory = target_directory / str(x_index)
|
|
89
|
+
subdirectory.mkdir()
|
|
90
|
+
for y_index, tile in enumerate(column_of_tiles):
|
|
91
|
+
Image.fromarray(tile).save(subdirectory / f"{y_index}{image_format}")
|
|
92
|
+
if progress_bar:
|
|
93
|
+
progress_bar.update(1)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_maximum_zoom_level(source_map_shape: tuple, tile_size: int) -> int:
|
|
97
|
+
"""
|
|
98
|
+
Compute the maximum zoom level to use with a given map
|
|
99
|
+
Knowing that the maximum zoom level should contain the map in its original size
|
|
100
|
+
:param source_map_shape: the shape of the source map
|
|
101
|
+
:param tile_size: the tile size
|
|
102
|
+
"""
|
|
103
|
+
# At zoom level z, the world is split into 2^z columns (x = 0 … 2^z - 1) and 2^z rows (y = 0 … 2^z - 1).
|
|
104
|
+
# If the map (in full size) should be split into only 1 column and 1 row because the tile size is lower than the original then the max zoom level should be 0
|
|
105
|
+
# It it should be split into 2 columns or 2 rows then the max zoom level should be 1
|
|
106
|
+
# And so on
|
|
107
|
+
largest_side_length_in_px = max(source_map_shape)
|
|
108
|
+
nb_tiles_max = ceil(largest_side_length_in_px / tile_size)
|
|
109
|
+
return ceil(log(nb_tiles_max) / log(2))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def resize_image_for_zoom_level(
|
|
113
|
+
source_map: npt.NDArray, zoom_level: int, maximum_zoom_level: int
|
|
114
|
+
) -> npt.NDArray:
|
|
115
|
+
"""
|
|
116
|
+
Resize an image for a zoom level, assuming the maximum zoom level is the zoom at which the image will not be resized
|
|
117
|
+
At maximum_zoom_level - 1, each image side length in pixels will be divided by 2
|
|
118
|
+
At maximum_zoom_level -2, each image side will be divided by 4
|
|
119
|
+
And so on
|
|
120
|
+
:param source_map: the source map image
|
|
121
|
+
:param zoom_level: the zoom level the image must be resized to
|
|
122
|
+
:param maximum_zoom_level: The maximum_zoom_level for the map
|
|
123
|
+
:return: the image resized to match the zoom level
|
|
124
|
+
"""
|
|
125
|
+
resize_factor = 2 ** (maximum_zoom_level - zoom_level)
|
|
126
|
+
if resize_factor == 1:
|
|
127
|
+
return source_map
|
|
128
|
+
|
|
129
|
+
original_height, original_width, _ = source_map.shape
|
|
130
|
+
target_height = original_height // resize_factor
|
|
131
|
+
target_width = original_width // resize_factor
|
|
132
|
+
return np.asarray(
|
|
133
|
+
Image.fromarray(source_map).resize((target_width, target_height)), dtype="uint8"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_tiles_for_all_zoom_levels(
|
|
138
|
+
source_map: npt.NDArray, tile_size: int
|
|
139
|
+
) -> list[npt.NDArray]:
|
|
140
|
+
"""
|
|
141
|
+
Generate the tiles for a source map for all zoom levels
|
|
142
|
+
:param source_map: The map we want to generate tiles from
|
|
143
|
+
:param tile_size: each tile size
|
|
144
|
+
:return: An array containing all tiles for all required zoom_levels, of shape (nb_zooms, nb_tiles_y, nb_tiles_x, tile_size, tile_size [, pixel_size (3 if RGB)]
|
|
145
|
+
"""
|
|
146
|
+
maximum_zoom_level = get_maximum_zoom_level(source_map.shape, tile_size)
|
|
147
|
+
res = []
|
|
148
|
+
for zoom_level in tqdm(
|
|
149
|
+
range(maximum_zoom_level + 1), desc="Generating map for each zoom level"
|
|
150
|
+
):
|
|
151
|
+
image_resized_for_zoom_level = resize_image_for_zoom_level(
|
|
152
|
+
source_map, zoom_level, maximum_zoom_level
|
|
153
|
+
)
|
|
154
|
+
res.append(get_tiles(image_resized_for_zoom_level, tile_size))
|
|
155
|
+
return res
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def parse_args() -> Namespace:
|
|
159
|
+
parser = argparse.ArgumentParser(description="Create map tiles from a map source.")
|
|
160
|
+
parser.add_argument(
|
|
161
|
+
"map_source",
|
|
162
|
+
help="Path or identifier of the map source to generate tiles from",
|
|
163
|
+
)
|
|
164
|
+
parser.add_argument(
|
|
165
|
+
"--tile-size", "-t", help="Size of a tile", default=256, type=int
|
|
166
|
+
)
|
|
167
|
+
parser.add_argument(
|
|
168
|
+
"--output-directory",
|
|
169
|
+
"-o",
|
|
170
|
+
help="Output directory where to store the tiles, must not exist",
|
|
171
|
+
required=True,
|
|
172
|
+
)
|
|
173
|
+
parser.add_argument(
|
|
174
|
+
"--image-format",
|
|
175
|
+
"-f",
|
|
176
|
+
help="Output image format. Tested for .png and .webp",
|
|
177
|
+
default=".webp",
|
|
178
|
+
)
|
|
179
|
+
return parser.parse_args()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def main() -> None:
|
|
183
|
+
args = parse_args()
|
|
184
|
+
map_source: str = args.map_source
|
|
185
|
+
map_image = np.asarray(Image.open(map_source), dtype="uint8")
|
|
186
|
+
tiled_maps_by_zoom_level = get_tiles_for_all_zoom_levels(map_image, args.tile_size)
|
|
187
|
+
Path.mkdir(args.output_directory)
|
|
188
|
+
nb_images_to_save = sum(
|
|
189
|
+
tiled_map.shape[0] * tiled_map.shape[1]
|
|
190
|
+
for tiled_map in tiled_maps_by_zoom_level
|
|
191
|
+
)
|
|
192
|
+
with tqdm(desc="Saving generated tiles", total=nb_images_to_save) as progress_bar:
|
|
193
|
+
for zoom_level, tiled_map in enumerate(tiled_maps_by_zoom_level):
|
|
194
|
+
save_to_directory(
|
|
195
|
+
tiled_map,
|
|
196
|
+
Path(args.output_directory) / str(zoom_level),
|
|
197
|
+
image_format=args.image_format,
|
|
198
|
+
progress_bar=progress_bar,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
main()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ancient-map-tiler
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary:
|
|
5
|
+
Author: Noan Cloarec
|
|
6
|
+
Author-email: noan.cloarec@gmail.com
|
|
7
|
+
Requires-Python: >=3.13
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
11
|
+
Requires-Dist: numpy (>=2.3.5,<3.0.0)
|
|
12
|
+
Requires-Dist: tqdm (>=4.67.1,<5.0.0)
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# Ancient-map-tiler
|
|
16
|
+
This python project aims to provide tiles data for a web view of an ancient map
|
|
17
|
+
The source image of the map may be very large. To be usable in a web view such as Leaflet it must be splitted into tiles at different zooms
|
|
18
|
+
## Prerequisites
|
|
19
|
+
- python (tested on 3.13)
|
|
20
|
+
- poetry
|
|
21
|
+
## Usage
|
|
22
|
+
```shell
|
|
23
|
+
cd ancient-map-tiler
|
|
24
|
+
poetry install
|
|
25
|
+
# Download a large map from wikipedia
|
|
26
|
+
wget https://upload.wikimedia.org/wikipedia/commons/5/50/TabulaPeutingeriana.jpg
|
|
27
|
+
# Generate the tiles into a directory named tiles
|
|
28
|
+
poetry run make_tiles TabulaPeutingeriana.jpg -o tiles
|
|
29
|
+
# Open the preview in your web browser
|
|
30
|
+
open simple_map_preview.html
|
|
31
|
+
```
|
|
32
|
+
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
ancient_map_tiler/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
ancient_map_tiler/tiles.py,sha256=cPpE4PTycdRSmVT-scLilc4g-Xz6XEJat3c7xxM-ah8,7889
|
|
3
|
+
ancient_map_tiler-0.1.0.dist-info/METADATA,sha256=IxFxtnnUuTtwyAPf0kU_B4nTkTWaIjJbJvGoRKlDL1U,1065
|
|
4
|
+
ancient_map_tiler-0.1.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
5
|
+
ancient_map_tiler-0.1.0.dist-info/entry_points.txt,sha256=8g5jcREn9ACPtgJuvUtNSs3U1jviizdx0tBD772Brqc,59
|
|
6
|
+
ancient_map_tiler-0.1.0.dist-info/RECORD,,
|