tilepack 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.
- tilepack/__init__.py +0 -0
- tilepack/__main__.py +5 -0
- tilepack/cli.py +58 -0
- tilepack/convert.py +188 -0
- tilepack/selftest.py +101 -0
- tilepack/serve.py +211 -0
- tilepack/tms_utils.py +220 -0
- tilepack/verify.py +70 -0
- tilepack/wmts_utils.py +159 -0
- tilepack-0.1.0.dist-info/METADATA +120 -0
- tilepack-0.1.0.dist-info/RECORD +14 -0
- tilepack-0.1.0.dist-info/WHEEL +4 -0
- tilepack-0.1.0.dist-info/entry_points.txt +2 -0
- tilepack-0.1.0.dist-info/licenses/LICENSE +21 -0
tilepack/__init__.py
ADDED
|
File without changes
|
tilepack/__main__.py
ADDED
tilepack/cli.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""CLI entrypoint for tilepack."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.group()
|
|
9
|
+
@click.version_option(version("tilepack"), prog_name="tilepack")
|
|
10
|
+
def cli():
|
|
11
|
+
"""Convert TMS/XYZ tile folders to MBTiles/PMTiles and serve as TMS/WMTS endpoints."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@cli.command()
|
|
15
|
+
@click.argument("input_root", type=click.Path(exists=True, file_okay=False))
|
|
16
|
+
def verify(input_root):
|
|
17
|
+
"""Scan a TMS folder and report tile statistics."""
|
|
18
|
+
from tilepack.verify import run_verify
|
|
19
|
+
|
|
20
|
+
run_verify(input_root)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@cli.command()
|
|
24
|
+
@click.argument("input_root", type=click.Path(exists=True, file_okay=False))
|
|
25
|
+
@click.argument("output_file", type=click.Path())
|
|
26
|
+
@click.option(
|
|
27
|
+
"--scheme",
|
|
28
|
+
type=click.Choice(["tms", "xyz"]),
|
|
29
|
+
default=None,
|
|
30
|
+
help="Input tile scheme (auto-detected if omitted).",
|
|
31
|
+
)
|
|
32
|
+
def convert(input_root, output_file, scheme):
|
|
33
|
+
"""Convert a tile folder to MBTiles or PMTiles (inferred from extension)."""
|
|
34
|
+
from tilepack.convert import run_convert
|
|
35
|
+
|
|
36
|
+
run_convert(input_root, output_file, scheme=scheme)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@cli.command()
|
|
40
|
+
@click.argument("archive_file", type=click.Path(exists=True, dir_okay=False))
|
|
41
|
+
@click.option("--port", default=8000, help="Port to bind to.")
|
|
42
|
+
@click.option("--host", default="127.0.0.1", help="Host to bind to.")
|
|
43
|
+
def serve(archive_file, port, host):
|
|
44
|
+
"""Serve an MBTiles or PMTiles archive as a TMS endpoint."""
|
|
45
|
+
from tilepack.serve import run_serve
|
|
46
|
+
|
|
47
|
+
run_serve(archive_file, host, port)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@cli.command()
|
|
51
|
+
@click.argument("input_root", type=click.Path(exists=True, file_okay=False))
|
|
52
|
+
@click.option("--base-url", required=True, help="Base URL of the running TMS server.")
|
|
53
|
+
@click.option("--samples", default=200, help="Number of tiles to sample.")
|
|
54
|
+
def selftest(input_root, base_url, samples):
|
|
55
|
+
"""Validate a served TMS endpoint against the original tile folder."""
|
|
56
|
+
from tilepack.selftest import run_selftest
|
|
57
|
+
|
|
58
|
+
run_selftest(input_root, base_url, samples)
|
tilepack/convert.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Convert command: tile folder → MBTiles or PMTiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from tilepack.tms_utils import collect_zoom_stats, compute_bounds, detect_scheme, iter_tiles
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_convert(input_root: str, output_file: str, scheme: str | None = None) -> None:
|
|
15
|
+
root = Path(input_root).resolve()
|
|
16
|
+
out = Path(output_file).resolve()
|
|
17
|
+
ext = out.suffix.lower()
|
|
18
|
+
|
|
19
|
+
# Scan tiles
|
|
20
|
+
stats = collect_zoom_stats(root)
|
|
21
|
+
if not stats:
|
|
22
|
+
click.echo("No tiles found.", err=True)
|
|
23
|
+
raise SystemExit(1)
|
|
24
|
+
|
|
25
|
+
# Detect or use provided scheme
|
|
26
|
+
if scheme is None:
|
|
27
|
+
has_xml = (root / "tilemapresource.xml").exists()
|
|
28
|
+
detected, _, _ = detect_scheme(stats, has_tilemapresource=has_xml)
|
|
29
|
+
scheme = detected
|
|
30
|
+
click.echo(f"Detected input scheme: {scheme.upper()}")
|
|
31
|
+
else:
|
|
32
|
+
click.echo(f"Using specified scheme: {scheme.upper()}")
|
|
33
|
+
|
|
34
|
+
if ext == ".mbtiles":
|
|
35
|
+
_convert_mbtiles(root, out, stats=stats, scheme=scheme)
|
|
36
|
+
elif ext == ".pmtiles":
|
|
37
|
+
_convert_pmtiles(root, out, stats=stats, scheme=scheme)
|
|
38
|
+
else:
|
|
39
|
+
click.echo(f"Unknown output format: {ext} (expected .mbtiles or .pmtiles)", err=True)
|
|
40
|
+
raise SystemExit(1)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _convert_mbtiles(root: Path, out: Path, *, stats: dict, scheme: str) -> None:
|
|
44
|
+
click.echo("Converting tile folder → MBTiles")
|
|
45
|
+
click.echo(f" Input: {root}")
|
|
46
|
+
click.echo(f" Output: {out}\n")
|
|
47
|
+
|
|
48
|
+
t0 = time.perf_counter()
|
|
49
|
+
|
|
50
|
+
minzoom = min(stats)
|
|
51
|
+
maxzoom = max(stats)
|
|
52
|
+
bounds = compute_bounds(stats, scheme=scheme)
|
|
53
|
+
flip_y = scheme == "xyz"
|
|
54
|
+
|
|
55
|
+
# Create MBTiles database
|
|
56
|
+
if out.exists():
|
|
57
|
+
out.unlink()
|
|
58
|
+
|
|
59
|
+
conn = sqlite3.connect(str(out))
|
|
60
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
61
|
+
conn.execute("CREATE TABLE metadata (name TEXT, value TEXT)")
|
|
62
|
+
conn.execute(
|
|
63
|
+
"CREATE TABLE tiles ("
|
|
64
|
+
" zoom_level INTEGER,"
|
|
65
|
+
" tile_column INTEGER,"
|
|
66
|
+
" tile_row INTEGER,"
|
|
67
|
+
" tile_data BLOB"
|
|
68
|
+
")"
|
|
69
|
+
)
|
|
70
|
+
conn.execute("CREATE UNIQUE INDEX tile_index ON tiles (zoom_level, tile_column, tile_row)")
|
|
71
|
+
|
|
72
|
+
# Populate metadata
|
|
73
|
+
bounds_str = f"{bounds[0]:.6f},{bounds[1]:.6f},{bounds[2]:.6f},{bounds[3]:.6f}"
|
|
74
|
+
center_lon = (bounds[0] + bounds[2]) / 2
|
|
75
|
+
center_lat = (bounds[1] + bounds[3]) / 2
|
|
76
|
+
center_zoom = (minzoom + maxzoom) // 2
|
|
77
|
+
|
|
78
|
+
meta = {
|
|
79
|
+
"name": root.name,
|
|
80
|
+
"format": "png",
|
|
81
|
+
"bounds": bounds_str,
|
|
82
|
+
"center": f"{center_lon:.6f},{center_lat:.6f},{center_zoom}",
|
|
83
|
+
"minzoom": str(minzoom),
|
|
84
|
+
"maxzoom": str(maxzoom),
|
|
85
|
+
"type": "overlay",
|
|
86
|
+
"version": "1",
|
|
87
|
+
"description": f"Converted from TMS folder: {root.name}",
|
|
88
|
+
}
|
|
89
|
+
conn.executemany("INSERT INTO metadata VALUES (?, ?)", meta.items())
|
|
90
|
+
|
|
91
|
+
# Insert tiles in batches
|
|
92
|
+
batch_size = 1000
|
|
93
|
+
batch = []
|
|
94
|
+
inserted = 0
|
|
95
|
+
|
|
96
|
+
for z, x, y, tile_path in iter_tiles(root):
|
|
97
|
+
tile_data = tile_path.read_bytes()
|
|
98
|
+
# MBTiles uses TMS scheme — flip Y if input is XYZ
|
|
99
|
+
y_tms = (2**z - 1) - y if flip_y else y
|
|
100
|
+
batch.append((z, x, y_tms, tile_data))
|
|
101
|
+
|
|
102
|
+
if len(batch) >= batch_size:
|
|
103
|
+
conn.executemany("INSERT INTO tiles VALUES (?, ?, ?, ?)", batch)
|
|
104
|
+
conn.commit()
|
|
105
|
+
inserted += len(batch)
|
|
106
|
+
batch.clear()
|
|
107
|
+
|
|
108
|
+
if batch:
|
|
109
|
+
conn.executemany("INSERT INTO tiles VALUES (?, ?, ?, ?)", batch)
|
|
110
|
+
conn.commit()
|
|
111
|
+
inserted += len(batch)
|
|
112
|
+
|
|
113
|
+
conn.close()
|
|
114
|
+
elapsed = time.perf_counter() - t0
|
|
115
|
+
size_mb = out.stat().st_size / (1024 * 1024)
|
|
116
|
+
|
|
117
|
+
click.echo("Done.")
|
|
118
|
+
click.echo(f" Tiles inserted: {inserted:,}")
|
|
119
|
+
click.echo(f" File size: {size_mb:.1f} MB")
|
|
120
|
+
click.echo(f" Duration: {elapsed:.1f}s")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _convert_pmtiles(root: Path, out: Path, *, stats: dict, scheme: str) -> None:
|
|
124
|
+
click.echo("Converting tile folder → PMTiles")
|
|
125
|
+
click.echo(f" Input: {root}")
|
|
126
|
+
click.echo(f" Output: {out}\n")
|
|
127
|
+
|
|
128
|
+
t0 = time.perf_counter()
|
|
129
|
+
|
|
130
|
+
from pmtiles.tile import Compression, TileType, zxy_to_tileid
|
|
131
|
+
|
|
132
|
+
minzoom = min(stats)
|
|
133
|
+
maxzoom = max(stats)
|
|
134
|
+
bounds = compute_bounds(stats, scheme=scheme)
|
|
135
|
+
total = sum(s.count for s in stats.values())
|
|
136
|
+
flip_y = scheme == "tms"
|
|
137
|
+
|
|
138
|
+
# Build entries: list of (tileid, tile_data)
|
|
139
|
+
# PMTiles uses XYZ internally — flip Y only if input is TMS
|
|
140
|
+
def tile_entries():
|
|
141
|
+
for z, x, y, tile_path in iter_tiles(root):
|
|
142
|
+
y_xyz = (2**z - 1) - y if flip_y else y
|
|
143
|
+
tile_id = zxy_to_tileid(z, x, y_xyz)
|
|
144
|
+
yield tile_id, tile_path.read_bytes()
|
|
145
|
+
|
|
146
|
+
# Sort by tile_id (required by PMTiles writer)
|
|
147
|
+
click.echo("Reading and sorting tiles...")
|
|
148
|
+
entries = sorted(tile_entries(), key=lambda e: e[0])
|
|
149
|
+
|
|
150
|
+
# Write PMTiles using the low-level writer
|
|
151
|
+
from pmtiles.writer import Writer as PMTilesWriter
|
|
152
|
+
|
|
153
|
+
if out.exists():
|
|
154
|
+
out.unlink()
|
|
155
|
+
|
|
156
|
+
with open(out, "wb") as f:
|
|
157
|
+
writer = PMTilesWriter(f)
|
|
158
|
+
|
|
159
|
+
for tile_id, tile_data in entries:
|
|
160
|
+
writer.write_tile(tile_id, tile_data)
|
|
161
|
+
|
|
162
|
+
writer.finalize(
|
|
163
|
+
header={
|
|
164
|
+
"tile_type": TileType.PNG,
|
|
165
|
+
"min_zoom": minzoom,
|
|
166
|
+
"max_zoom": maxzoom,
|
|
167
|
+
"min_lon_e7": int(bounds[0] * 1e7),
|
|
168
|
+
"min_lat_e7": int(bounds[1] * 1e7),
|
|
169
|
+
"max_lon_e7": int(bounds[2] * 1e7),
|
|
170
|
+
"max_lat_e7": int(bounds[3] * 1e7),
|
|
171
|
+
"tile_compression": Compression.NONE,
|
|
172
|
+
},
|
|
173
|
+
metadata={
|
|
174
|
+
"name": root.name,
|
|
175
|
+
"format": "png",
|
|
176
|
+
"bounds": f"{bounds[0]:.6f},{bounds[1]:.6f},{bounds[2]:.6f},{bounds[3]:.6f}",
|
|
177
|
+
"minzoom": str(minzoom),
|
|
178
|
+
"maxzoom": str(maxzoom),
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
elapsed = time.perf_counter() - t0
|
|
183
|
+
size_mb = out.stat().st_size / (1024 * 1024)
|
|
184
|
+
|
|
185
|
+
click.echo("Done.")
|
|
186
|
+
click.echo(f" Tiles written: {total:,}")
|
|
187
|
+
click.echo(f" File size: {size_mb:.1f} MB")
|
|
188
|
+
click.echo(f" Duration: {elapsed:.1f}s")
|
tilepack/selftest.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Selftest command: validate served tiles against original TMS folder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from tilepack.tms_utils import PNG_SIGNATURE, iter_tiles
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_selftest(input_root: str, base_url: str, samples: int) -> None:
|
|
16
|
+
root = Path(input_root).resolve()
|
|
17
|
+
base = base_url.rstrip("/")
|
|
18
|
+
|
|
19
|
+
click.echo(f"Selftest: {root}")
|
|
20
|
+
click.echo(f"Server: {base}")
|
|
21
|
+
click.echo(f"Samples: {samples}\n")
|
|
22
|
+
|
|
23
|
+
# Collect all tile coordinates
|
|
24
|
+
all_tiles = [(z, x, y, p) for z, x, y, p in iter_tiles(root)]
|
|
25
|
+
if not all_tiles:
|
|
26
|
+
click.echo("No tiles found in input folder.", err=True)
|
|
27
|
+
raise SystemExit(1)
|
|
28
|
+
|
|
29
|
+
# Sample
|
|
30
|
+
if samples >= len(all_tiles):
|
|
31
|
+
selected = all_tiles
|
|
32
|
+
else:
|
|
33
|
+
selected = random.sample(all_tiles, samples)
|
|
34
|
+
|
|
35
|
+
passed = 0
|
|
36
|
+
failed = 0
|
|
37
|
+
errors = []
|
|
38
|
+
latencies = []
|
|
39
|
+
|
|
40
|
+
with httpx.Client(timeout=30.0) as client:
|
|
41
|
+
for z, x, y, tile_path in selected:
|
|
42
|
+
url = f"{base}/{z}/{x}/{y}.png"
|
|
43
|
+
original = tile_path.read_bytes()
|
|
44
|
+
|
|
45
|
+
t0 = time.perf_counter()
|
|
46
|
+
try:
|
|
47
|
+
resp = client.get(url)
|
|
48
|
+
except httpx.RequestError as e:
|
|
49
|
+
failed += 1
|
|
50
|
+
errors.append(f" {z}/{x}/{y}: connection error: {e}")
|
|
51
|
+
continue
|
|
52
|
+
latency_ms = (time.perf_counter() - t0) * 1000
|
|
53
|
+
latencies.append(latency_ms)
|
|
54
|
+
|
|
55
|
+
# Check status
|
|
56
|
+
if resp.status_code != 200:
|
|
57
|
+
failed += 1
|
|
58
|
+
errors.append(f" {z}/{x}/{y}: HTTP {resp.status_code}")
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
# Check content type
|
|
62
|
+
ct = resp.headers.get("content-type", "")
|
|
63
|
+
if "image/png" not in ct:
|
|
64
|
+
failed += 1
|
|
65
|
+
errors.append(f" {z}/{x}/{y}: wrong content-type: {ct}")
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
# Check PNG header
|
|
69
|
+
if resp.content[:8] != PNG_SIGNATURE:
|
|
70
|
+
failed += 1
|
|
71
|
+
errors.append(f" {z}/{x}/{y}: invalid PNG header")
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
# Byte comparison
|
|
75
|
+
if resp.content != original:
|
|
76
|
+
failed += 1
|
|
77
|
+
errors.append(
|
|
78
|
+
f" {z}/{x}/{y}: byte mismatch "
|
|
79
|
+
f"(original={len(original)}, served={len(resp.content)})"
|
|
80
|
+
)
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
passed += 1
|
|
84
|
+
|
|
85
|
+
# Report
|
|
86
|
+
avg_latency = sum(latencies) / len(latencies) if latencies else 0
|
|
87
|
+
|
|
88
|
+
click.echo(f"Total tested: {passed + failed}")
|
|
89
|
+
click.echo(f"Passed: {passed}")
|
|
90
|
+
click.echo(f"Failed: {failed}")
|
|
91
|
+
click.echo(f"Avg latency: {avg_latency:.1f}ms")
|
|
92
|
+
|
|
93
|
+
if errors:
|
|
94
|
+
click.echo("\nFailures:")
|
|
95
|
+
for e in errors[:20]:
|
|
96
|
+
click.echo(e)
|
|
97
|
+
if len(errors) > 20:
|
|
98
|
+
click.echo(f" ... and {len(errors) - 20} more")
|
|
99
|
+
|
|
100
|
+
if failed > 0:
|
|
101
|
+
raise SystemExit(1)
|
tilepack/serve.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Serve command: expose an MBTiles or PMTiles archive as a TMS and WMTS endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from fastapi import FastAPI, Query, Response
|
|
11
|
+
|
|
12
|
+
from tilepack.tms_utils import generate_tilemapresource_xml
|
|
13
|
+
from tilepack.wmts_utils import generate_wmts_capabilities_xml
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _add_wmts_routes(
|
|
17
|
+
app: FastAPI,
|
|
18
|
+
*,
|
|
19
|
+
wmts_xml: str,
|
|
20
|
+
layer_name: str,
|
|
21
|
+
get_tile_tms: Callable[[int, int, int], bytes | None],
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Add WMTS endpoints (capabilities, RESTful tiles, KVP) to a FastAPI app.
|
|
24
|
+
|
|
25
|
+
``get_tile_tms`` accepts (z, x, y_tms) and returns tile bytes or None.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
@app.get("/WMTSCapabilities.xml")
|
|
29
|
+
def wmts_capabilities():
|
|
30
|
+
return Response(content=wmts_xml, media_type="application/xml")
|
|
31
|
+
|
|
32
|
+
@app.get("/wmts/{layer}/{tilematrixset}/{z}/{row}/{col}.png")
|
|
33
|
+
def wmts_tile_rest(layer: str, tilematrixset: str, z: int, row: int, col: int):
|
|
34
|
+
# WMTS Row = XYZ y (row 0 at north). Convert to TMS y (y 0 at south).
|
|
35
|
+
y_tms = (2**z - 1) - row
|
|
36
|
+
data = get_tile_tms(z, col, y_tms)
|
|
37
|
+
if data is None:
|
|
38
|
+
return Response(status_code=404, content="Tile not found")
|
|
39
|
+
return Response(content=data, media_type="image/png")
|
|
40
|
+
|
|
41
|
+
@app.get("/wmts")
|
|
42
|
+
def wmts_kvp(
|
|
43
|
+
Service: str = Query("WMTS"),
|
|
44
|
+
Request: str = Query("GetTile"),
|
|
45
|
+
Layer: str = Query(""),
|
|
46
|
+
TileMatrixSet: str = Query(""),
|
|
47
|
+
TileMatrix: int = Query(0),
|
|
48
|
+
TileRow: int = Query(0),
|
|
49
|
+
TileCol: int = Query(0),
|
|
50
|
+
Format: str = Query("image/png"),
|
|
51
|
+
):
|
|
52
|
+
if Request == "GetCapabilities":
|
|
53
|
+
return Response(content=wmts_xml, media_type="application/xml")
|
|
54
|
+
|
|
55
|
+
# GetTile (default)
|
|
56
|
+
z = TileMatrix
|
|
57
|
+
y_tms = (2**z - 1) - TileRow
|
|
58
|
+
data = get_tile_tms(z, TileCol, y_tms)
|
|
59
|
+
if data is None:
|
|
60
|
+
return Response(status_code=404, content="Tile not found")
|
|
61
|
+
return Response(content=data, media_type="image/png")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _build_mbtiles_app(archive_path: Path, *, host: str, port: int) -> FastAPI:
|
|
65
|
+
app = FastAPI()
|
|
66
|
+
db_path = str(archive_path)
|
|
67
|
+
|
|
68
|
+
# Read metadata once at startup
|
|
69
|
+
conn = sqlite3.connect(db_path)
|
|
70
|
+
meta = dict(conn.execute("SELECT name, value FROM metadata").fetchall())
|
|
71
|
+
conn.close()
|
|
72
|
+
|
|
73
|
+
minzoom = int(meta.get("minzoom", "0"))
|
|
74
|
+
maxzoom = int(meta.get("maxzoom", "20"))
|
|
75
|
+
tile_format = meta.get("format", "png")
|
|
76
|
+
|
|
77
|
+
bounds_str = meta.get("bounds", "-180,-85,180,85")
|
|
78
|
+
parts = [float(v) for v in bounds_str.split(",")]
|
|
79
|
+
bounds = (parts[0], parts[1], parts[2], parts[3])
|
|
80
|
+
title = meta.get("name", "TMS Tiles")
|
|
81
|
+
|
|
82
|
+
# TMS capability document
|
|
83
|
+
xml = generate_tilemapresource_xml(
|
|
84
|
+
minzoom=minzoom,
|
|
85
|
+
maxzoom=maxzoom,
|
|
86
|
+
bounds=bounds,
|
|
87
|
+
tile_format=tile_format,
|
|
88
|
+
title=title,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
@app.get("/tilemapresource.xml")
|
|
92
|
+
def tilemapresource():
|
|
93
|
+
return Response(content=xml, media_type="application/xml")
|
|
94
|
+
|
|
95
|
+
def _get_tile_tms(z: int, x: int, y: int) -> bytes | None:
|
|
96
|
+
conn = sqlite3.connect(db_path)
|
|
97
|
+
row = conn.execute(
|
|
98
|
+
"SELECT tile_data FROM tiles WHERE zoom_level=? AND tile_column=? AND tile_row=?",
|
|
99
|
+
(z, x, y),
|
|
100
|
+
).fetchone()
|
|
101
|
+
conn.close()
|
|
102
|
+
return row[0] if row else None
|
|
103
|
+
|
|
104
|
+
@app.get("/{z}/{x}/{y}.png")
|
|
105
|
+
def get_tile(z: int, x: int, y: int):
|
|
106
|
+
data = _get_tile_tms(z, x, y)
|
|
107
|
+
if data is None:
|
|
108
|
+
return Response(status_code=404, content="Tile not found")
|
|
109
|
+
return Response(content=data, media_type="image/png")
|
|
110
|
+
|
|
111
|
+
# WMTS
|
|
112
|
+
wmts_xml = generate_wmts_capabilities_xml(
|
|
113
|
+
minzoom=minzoom,
|
|
114
|
+
maxzoom=maxzoom,
|
|
115
|
+
bounds=bounds,
|
|
116
|
+
tile_format=tile_format,
|
|
117
|
+
title=title,
|
|
118
|
+
base_url=f"http://{host}:{port}",
|
|
119
|
+
)
|
|
120
|
+
_add_wmts_routes(app, wmts_xml=wmts_xml, layer_name=title, get_tile_tms=_get_tile_tms)
|
|
121
|
+
|
|
122
|
+
return app
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _build_pmtiles_app(archive_path: Path, *, host: str, port: int) -> FastAPI:
|
|
126
|
+
app = FastAPI()
|
|
127
|
+
|
|
128
|
+
from pmtiles.reader import MmapSource
|
|
129
|
+
from pmtiles.reader import Reader as PMTilesReader
|
|
130
|
+
|
|
131
|
+
source = MmapSource(open(archive_path, "rb"))
|
|
132
|
+
reader = PMTilesReader(source)
|
|
133
|
+
header = reader.header()
|
|
134
|
+
|
|
135
|
+
minzoom = header.get("min_zoom", 0)
|
|
136
|
+
maxzoom = header.get("max_zoom", 20)
|
|
137
|
+
bounds = (
|
|
138
|
+
header.get("min_lon_e7", -1800000000) / 1e7,
|
|
139
|
+
header.get("min_lat_e7", -850000000) / 1e7,
|
|
140
|
+
header.get("max_lon_e7", 1800000000) / 1e7,
|
|
141
|
+
header.get("max_lat_e7", 850000000) / 1e7,
|
|
142
|
+
)
|
|
143
|
+
title = archive_path.stem
|
|
144
|
+
|
|
145
|
+
# TMS capability document
|
|
146
|
+
xml = generate_tilemapresource_xml(
|
|
147
|
+
minzoom=minzoom,
|
|
148
|
+
maxzoom=maxzoom,
|
|
149
|
+
bounds=bounds,
|
|
150
|
+
tile_format="png",
|
|
151
|
+
title=title,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@app.get("/tilemapresource.xml")
|
|
155
|
+
def tilemapresource():
|
|
156
|
+
return Response(content=xml, media_type="application/xml")
|
|
157
|
+
|
|
158
|
+
def _get_tile_tms(z: int, x: int, y_tms: int) -> bytes | None:
|
|
159
|
+
# PMTiles stores in XYZ — flip TMS y to XYZ y
|
|
160
|
+
y_xyz = (2**z - 1) - y_tms
|
|
161
|
+
return reader.get(z, x, y_xyz)
|
|
162
|
+
|
|
163
|
+
@app.get("/{z}/{x}/{y}.png")
|
|
164
|
+
def get_tile(z: int, x: int, y: int):
|
|
165
|
+
data = _get_tile_tms(z, x, y)
|
|
166
|
+
if data is None:
|
|
167
|
+
return Response(status_code=404, content="Tile not found")
|
|
168
|
+
return Response(content=data, media_type="image/png")
|
|
169
|
+
|
|
170
|
+
# WMTS
|
|
171
|
+
wmts_xml = generate_wmts_capabilities_xml(
|
|
172
|
+
minzoom=minzoom,
|
|
173
|
+
maxzoom=maxzoom,
|
|
174
|
+
bounds=bounds,
|
|
175
|
+
tile_format="png",
|
|
176
|
+
title=title,
|
|
177
|
+
base_url=f"http://{host}:{port}",
|
|
178
|
+
)
|
|
179
|
+
_add_wmts_routes(app, wmts_xml=wmts_xml, layer_name=title, get_tile_tms=_get_tile_tms)
|
|
180
|
+
|
|
181
|
+
return app
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def run_serve(archive_file: str, host: str, port: int) -> None:
|
|
185
|
+
archive_path = Path(archive_file).resolve()
|
|
186
|
+
ext = archive_path.suffix.lower()
|
|
187
|
+
|
|
188
|
+
if ext == ".mbtiles":
|
|
189
|
+
app = _build_mbtiles_app(archive_path, host=host, port=port)
|
|
190
|
+
elif ext == ".pmtiles":
|
|
191
|
+
app = _build_pmtiles_app(archive_path, host=host, port=port)
|
|
192
|
+
else:
|
|
193
|
+
click.echo(f"Unknown archive format: {ext}", err=True)
|
|
194
|
+
raise SystemExit(1)
|
|
195
|
+
|
|
196
|
+
click.echo(f"Serving {archive_path.name} on http://{host}:{port}")
|
|
197
|
+
click.echo(" TMS:")
|
|
198
|
+
click.echo(f" tilemapresource.xml → http://{host}:{port}/tilemapresource.xml")
|
|
199
|
+
click.echo(f" Tiles → http://{host}:{port}/{{z}}/{{x}}/{{y}}.png")
|
|
200
|
+
click.echo(" WMTS:")
|
|
201
|
+
click.echo(f" Capabilities → http://{host}:{port}/WMTSCapabilities.xml")
|
|
202
|
+
click.echo(
|
|
203
|
+
f" Tiles (REST) → http://{host}:{port}/wmts/{{Layer}}/{{TileMatrixSet}}/{{z}}/{{row}}/{{col}}.png"
|
|
204
|
+
)
|
|
205
|
+
click.echo(
|
|
206
|
+
f" Tiles (KVP) → http://{host}:{port}/wmts?Service=WMTS&Request=GetTile&...\n"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
import uvicorn
|
|
210
|
+
|
|
211
|
+
uvicorn.run(app, host=host, port=port, log_level="info")
|
tilepack/tms_utils.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Shared utilities for TMS tile iteration, bounds computation, and XML generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from xml.sax.saxutils import escape
|
|
12
|
+
|
|
13
|
+
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
14
|
+
EXT_RE = re.compile(r"\.(png|jpg|jpeg|webp)$", re.IGNORECASE)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ZoomStats:
|
|
19
|
+
min_x: int = field(default=10**18)
|
|
20
|
+
max_x: int = field(default=-(10**18))
|
|
21
|
+
min_y: int = field(default=10**18)
|
|
22
|
+
max_y: int = field(default=-(10**18))
|
|
23
|
+
count: int = 0
|
|
24
|
+
|
|
25
|
+
def update(self, x: int, y: int) -> None:
|
|
26
|
+
self.min_x = min(self.min_x, x)
|
|
27
|
+
self.max_x = max(self.max_x, x)
|
|
28
|
+
self.min_y = min(self.min_y, y)
|
|
29
|
+
self.max_y = max(self.max_y, y)
|
|
30
|
+
self.count += 1
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def iter_tiles(root: Path) -> Iterable[tuple[int, int, int, Path]]:
|
|
34
|
+
"""Yield (z, x, y, filepath) for tiles matching root/z/x/y.ext."""
|
|
35
|
+
for z_dir in sorted(root.iterdir()):
|
|
36
|
+
if not z_dir.is_dir():
|
|
37
|
+
continue
|
|
38
|
+
try:
|
|
39
|
+
z = int(z_dir.name)
|
|
40
|
+
except ValueError:
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
for x_dir in sorted(z_dir.iterdir()):
|
|
44
|
+
if not x_dir.is_dir():
|
|
45
|
+
continue
|
|
46
|
+
try:
|
|
47
|
+
x = int(x_dir.name)
|
|
48
|
+
except ValueError:
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
for f in x_dir.iterdir():
|
|
52
|
+
if not f.is_file() or not EXT_RE.search(f.name):
|
|
53
|
+
continue
|
|
54
|
+
try:
|
|
55
|
+
y = int(f.stem)
|
|
56
|
+
except ValueError:
|
|
57
|
+
continue
|
|
58
|
+
yield z, x, y, f
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def collect_zoom_stats(root: Path) -> dict[int, ZoomStats]:
|
|
62
|
+
"""Scan a TMS folder and return per-zoom statistics."""
|
|
63
|
+
stats: dict[int, ZoomStats] = defaultdict(ZoomStats)
|
|
64
|
+
for z, x, y, _ in iter_tiles(root):
|
|
65
|
+
stats[z].update(x, y)
|
|
66
|
+
return dict(stats)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _tile2lon(x: float, z: int) -> float:
|
|
70
|
+
return x / (2**z) * 360.0 - 180.0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _tile2lat_xyz(y_xyz: float, z: int) -> float:
|
|
74
|
+
"""Convert XYZ y coordinate to latitude. In XYZ, y=0 is at the north pole."""
|
|
75
|
+
n = math.pi - (2.0 * math.pi * y_xyz) / (2**z)
|
|
76
|
+
return math.degrees(math.atan(math.sinh(n)))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _compute_bounds_as_scheme(
|
|
80
|
+
zoom_stats: dict[int, ZoomStats], scheme: str
|
|
81
|
+
) -> tuple[float, float, float, float]:
|
|
82
|
+
"""Compute bounds assuming the Y values follow the given scheme ('tms' or 'xyz').
|
|
83
|
+
|
|
84
|
+
Returns (min_lon, min_lat, max_lon, max_lat).
|
|
85
|
+
"""
|
|
86
|
+
min_lon, min_lat = 180.0, 90.0
|
|
87
|
+
max_lon, max_lat = -180.0, -90.0
|
|
88
|
+
|
|
89
|
+
for z, st in zoom_stats.items():
|
|
90
|
+
lon_left = _tile2lon(st.min_x, z)
|
|
91
|
+
lon_right = _tile2lon(st.max_x + 1, z)
|
|
92
|
+
|
|
93
|
+
if scheme == "tms":
|
|
94
|
+
# TMS: y=0 at bottom (south). Convert to XYZ for the lat formula.
|
|
95
|
+
y_xyz_top = (2**z - 1) - (st.max_y + 1) # top edge
|
|
96
|
+
y_xyz_bottom = (2**z - 1) - st.min_y # bottom edge
|
|
97
|
+
else:
|
|
98
|
+
# XYZ: y values are already XYZ
|
|
99
|
+
y_xyz_top = st.min_y # top edge (smaller y = further north)
|
|
100
|
+
y_xyz_bottom = st.max_y + 1 # bottom edge
|
|
101
|
+
|
|
102
|
+
lat_top = _tile2lat_xyz(max(y_xyz_top, 0), z)
|
|
103
|
+
lat_bottom = _tile2lat_xyz(min(y_xyz_bottom, 2**z), z)
|
|
104
|
+
|
|
105
|
+
min_lon = min(min_lon, lon_left)
|
|
106
|
+
max_lon = max(max_lon, lon_right)
|
|
107
|
+
min_lat = min(min_lat, lat_bottom, lat_top)
|
|
108
|
+
max_lat = max(max_lat, lat_bottom, lat_top)
|
|
109
|
+
|
|
110
|
+
return min_lon, min_lat, max_lon, max_lat
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def compute_bounds(
|
|
114
|
+
zoom_stats: dict[int, ZoomStats],
|
|
115
|
+
scheme: str = "tms",
|
|
116
|
+
) -> tuple[float, float, float, float]:
|
|
117
|
+
"""Compute a union bounding box in EPSG:4326 from tile ranges.
|
|
118
|
+
|
|
119
|
+
``scheme`` indicates how to interpret Y values: ``"tms"`` (y=0 at south)
|
|
120
|
+
or ``"xyz"`` (y=0 at north). Returns (min_lon, min_lat, max_lon, max_lat).
|
|
121
|
+
"""
|
|
122
|
+
return _compute_bounds_as_scheme(zoom_stats, scheme)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def detect_scheme(
|
|
126
|
+
zoom_stats: dict[int, ZoomStats],
|
|
127
|
+
has_tilemapresource: bool = False,
|
|
128
|
+
) -> tuple[str, tuple[float, float, float, float], tuple[float, float, float, float]]:
|
|
129
|
+
"""Detect whether tile Y coordinates follow TMS or XYZ convention.
|
|
130
|
+
|
|
131
|
+
Uses two signals:
|
|
132
|
+
1. Presence of tilemapresource.xml in the folder (strong TMS indicator).
|
|
133
|
+
2. Y-coordinate position relative to the midpoint of the tile grid.
|
|
134
|
+
- TMS: y=0 at south, so northern-hemisphere data has y > 2^(z-1).
|
|
135
|
+
- XYZ: y=0 at north, so northern-hemisphere data has y < 2^(z-1).
|
|
136
|
+
Interpreting with the wrong scheme mirrors latitude across the equator.
|
|
137
|
+
|
|
138
|
+
Returns (detected_scheme, tms_bounds, xyz_bounds).
|
|
139
|
+
"""
|
|
140
|
+
tms_bounds = _compute_bounds_as_scheme(zoom_stats, "tms")
|
|
141
|
+
xyz_bounds = _compute_bounds_as_scheme(zoom_stats, "xyz")
|
|
142
|
+
|
|
143
|
+
# If tilemapresource.xml exists, it's definitively TMS
|
|
144
|
+
if has_tilemapresource:
|
|
145
|
+
return "tms", tms_bounds, xyz_bounds
|
|
146
|
+
|
|
147
|
+
# Heuristic: check if y values sit above or below the midpoint of the grid.
|
|
148
|
+
# Use the highest zoom level for best precision.
|
|
149
|
+
z = max(zoom_stats)
|
|
150
|
+
st = zoom_stats[z]
|
|
151
|
+
midpoint = 2 ** (z - 1) # half of 2^z
|
|
152
|
+
y_center = (st.min_y + st.max_y) / 2
|
|
153
|
+
|
|
154
|
+
# If y_center > midpoint, the data is in the northern hemisphere under TMS
|
|
155
|
+
# (and southern under XYZ). If y_center < midpoint, it's the reverse.
|
|
156
|
+
# For datasets on the equator (y_center ≈ midpoint), both schemes give
|
|
157
|
+
# nearly identical results, so the choice doesn't matter much.
|
|
158
|
+
#
|
|
159
|
+
# We can't know "ground truth" without external info, but we can report
|
|
160
|
+
# which interpretation places the data where, and make a best guess:
|
|
161
|
+
# For y_center > midpoint: likely TMS (data in northern hemisphere)
|
|
162
|
+
# For y_center < midpoint: likely XYZ (data in northern hemisphere)
|
|
163
|
+
# This works because most populated landmass is in the northern hemisphere.
|
|
164
|
+
if y_center > midpoint:
|
|
165
|
+
return "tms", tms_bounds, xyz_bounds
|
|
166
|
+
elif y_center < midpoint:
|
|
167
|
+
return "xyz", tms_bounds, xyz_bounds
|
|
168
|
+
else:
|
|
169
|
+
# Equatorial — default to TMS
|
|
170
|
+
return "tms", tms_bounds, xyz_bounds
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def generate_tilemapresource_xml(
|
|
174
|
+
minzoom: int,
|
|
175
|
+
maxzoom: int,
|
|
176
|
+
bounds: tuple[float, float, float, float],
|
|
177
|
+
tile_format: str = "png",
|
|
178
|
+
title: str = "TMS Tiles",
|
|
179
|
+
tile_size: int = 256,
|
|
180
|
+
srs: str = "EPSG:3857",
|
|
181
|
+
) -> str:
|
|
182
|
+
"""Generate a TMS tilemapresource.xml string."""
|
|
183
|
+
min_lon, min_lat, max_lon, max_lat = bounds
|
|
184
|
+
R = 6378137.0
|
|
185
|
+
|
|
186
|
+
if srs == "EPSG:4326":
|
|
187
|
+
bb_minx, bb_miny = min_lon, min_lat
|
|
188
|
+
bb_maxx, bb_maxy = max_lon, max_lat
|
|
189
|
+
origin_x, origin_y = -180.0, -90.0
|
|
190
|
+
tilesets_profile = "global-geodetic"
|
|
191
|
+
initial_resolution = 360.0 / tile_size
|
|
192
|
+
else: # EPSG:3857
|
|
193
|
+
bb_minx = min_lon * math.pi * R / 180.0
|
|
194
|
+
bb_miny = math.log(math.tan(math.pi / 4 + math.radians(min_lat) / 2)) * R
|
|
195
|
+
bb_maxx = max_lon * math.pi * R / 180.0
|
|
196
|
+
bb_maxy = math.log(math.tan(math.pi / 4 + math.radians(max_lat) / 2)) * R
|
|
197
|
+
origin_x, origin_y = -20037508.342789244, -20037508.342789244
|
|
198
|
+
tilesets_profile = "global-mercator"
|
|
199
|
+
initial_resolution = (2 * math.pi * R) / tile_size
|
|
200
|
+
|
|
201
|
+
tile_sets = []
|
|
202
|
+
for z in range(minzoom, maxzoom + 1):
|
|
203
|
+
res = initial_resolution / (2**z)
|
|
204
|
+
tile_sets.append(f' <TileSet href="{z}" units-per-pixel="{res:.14f}" order="{z}"/>')
|
|
205
|
+
|
|
206
|
+
mime = "image/jpeg" if tile_format in ("jpg", "jpeg") else f"image/{tile_format}"
|
|
207
|
+
|
|
208
|
+
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
209
|
+
<TileMap version="1.0.0" tilemapservice="1.0.0">
|
|
210
|
+
<Title>{escape(title)}</Title>
|
|
211
|
+
<Abstract/>
|
|
212
|
+
<SRS>{srs}</SRS>
|
|
213
|
+
<BoundingBox minx="{bb_minx:.10f}" miny="{bb_miny:.10f}" maxx="{bb_maxx:.10f}" maxy="{bb_maxy:.10f}"/>
|
|
214
|
+
<Origin x="{origin_x}" y="{origin_y}"/>
|
|
215
|
+
<TileFormat width="{tile_size}" height="{tile_size}" mime-type="{mime}" extension="{tile_format}"/>
|
|
216
|
+
<TileSets profile="{tilesets_profile}">
|
|
217
|
+
{chr(10).join(tile_sets)}
|
|
218
|
+
</TileSets>
|
|
219
|
+
</TileMap>
|
|
220
|
+
"""
|
tilepack/verify.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Verify command: scan a TMS folder and report tile statistics."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from tilepack.tms_utils import (
|
|
9
|
+
PNG_SIGNATURE,
|
|
10
|
+
collect_zoom_stats,
|
|
11
|
+
detect_scheme,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _fmt_bounds(bounds: tuple) -> str:
|
|
16
|
+
"""Format bounds as a readable string with lat/lon labels."""
|
|
17
|
+
min_lon, min_lat, max_lon, max_lat = bounds
|
|
18
|
+
return f"lon [{min_lon:.4f}, {max_lon:.4f}] lat [{min_lat:.4f}, {max_lat:.4f}]"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run_verify(input_root: str) -> None:
|
|
22
|
+
root = Path(input_root).resolve()
|
|
23
|
+
click.echo(f"Scanning: {root}\n")
|
|
24
|
+
|
|
25
|
+
stats = collect_zoom_stats(root)
|
|
26
|
+
if not stats:
|
|
27
|
+
click.echo("No tiles found.", err=True)
|
|
28
|
+
raise SystemExit(1)
|
|
29
|
+
|
|
30
|
+
minzoom = min(stats)
|
|
31
|
+
maxzoom = max(stats)
|
|
32
|
+
total = sum(s.count for s in stats.values())
|
|
33
|
+
|
|
34
|
+
click.echo(f"Zoom range: {minzoom} – {maxzoom}")
|
|
35
|
+
click.echo(f"{'Zoom':>6} {'Tiles':>10}")
|
|
36
|
+
click.echo(f"{'----':>6} {'-----':>10}")
|
|
37
|
+
for z in range(minzoom, maxzoom + 1):
|
|
38
|
+
s = stats.get(z)
|
|
39
|
+
click.echo(f"{z:>6} {s.count if s else 0:>10,}")
|
|
40
|
+
click.echo(f"{'Total':>6} {total:>10,}")
|
|
41
|
+
|
|
42
|
+
# PNG header check on a random sample tile
|
|
43
|
+
click.echo()
|
|
44
|
+
# Pick a random zoom, then a random x dir, then a random tile file
|
|
45
|
+
z_pick = random.choice(list(stats.keys()))
|
|
46
|
+
z_dir = root / str(z_pick)
|
|
47
|
+
x_dirs = [d for d in z_dir.iterdir() if d.is_dir()]
|
|
48
|
+
if x_dirs:
|
|
49
|
+
x_dir = random.choice(x_dirs)
|
|
50
|
+
tile_files = [f for f in x_dir.iterdir() if f.is_file() and f.suffix.lower() == ".png"]
|
|
51
|
+
if tile_files:
|
|
52
|
+
tile_path = random.choice(tile_files)
|
|
53
|
+
header = tile_path.read_bytes()[:8]
|
|
54
|
+
is_png = header == PNG_SIGNATURE
|
|
55
|
+
rel = tile_path.relative_to(root)
|
|
56
|
+
click.echo(f"Format check: {'PNG ✓' if is_png else 'NOT PNG ✗'} (sampled {rel})")
|
|
57
|
+
else:
|
|
58
|
+
click.echo("Format check: no tile files to sample")
|
|
59
|
+
else:
|
|
60
|
+
click.echo("Format check: no tile directories to sample")
|
|
61
|
+
|
|
62
|
+
# Scheme detection
|
|
63
|
+
has_xml = (root / "tilemapresource.xml").exists()
|
|
64
|
+
detected, tms_bounds, xyz_bounds = detect_scheme(stats, has_tilemapresource=has_xml)
|
|
65
|
+
|
|
66
|
+
click.echo("\nY-axis scheme detection:")
|
|
67
|
+
click.echo(f" tilemapresource.xml present: {'yes' if has_xml else 'no'}")
|
|
68
|
+
click.echo(f" If TMS (y=0 at south): {_fmt_bounds(tms_bounds)}")
|
|
69
|
+
click.echo(f" If XYZ (y=0 at north): {_fmt_bounds(xyz_bounds)}")
|
|
70
|
+
click.echo(f" Detected scheme: {detected.upper()}")
|
tilepack/wmts_utils.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Generate OGC WMTS GetCapabilities XML documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from xml.sax.saxutils import escape
|
|
7
|
+
|
|
8
|
+
# OGC WMTS constants for GoogleMapsCompatible tile matrix set
|
|
9
|
+
_R = 6378137.0 # WGS-84 Earth radius in metres
|
|
10
|
+
_TOP_LEFT_CORNER = f"-{math.pi * _R:.10f} {math.pi * _R:.10f}"
|
|
11
|
+
# OGC defines 1 pixel = 0.28 mm for scale denominator calculation
|
|
12
|
+
_PIXEL_SIZE_M = 0.00028
|
|
13
|
+
# Scale denominator at zoom 0 for 256px tiles in EPSG:3857
|
|
14
|
+
_SCALE_DENOM_Z0 = (2 * math.pi * _R) / (256 * _PIXEL_SIZE_M)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def generate_wmts_capabilities_xml(
|
|
18
|
+
*,
|
|
19
|
+
minzoom: int,
|
|
20
|
+
maxzoom: int,
|
|
21
|
+
bounds: tuple[float, float, float, float],
|
|
22
|
+
tile_format: str = "png",
|
|
23
|
+
title: str = "WMTS Tiles",
|
|
24
|
+
tile_size: int = 256,
|
|
25
|
+
base_url: str = "http://localhost:8000",
|
|
26
|
+
) -> str:
|
|
27
|
+
"""Generate an OGC WMTS 1.0.0 GetCapabilities XML string.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
minzoom, maxzoom : int
|
|
32
|
+
Zoom level range.
|
|
33
|
+
bounds : tuple
|
|
34
|
+
(min_lon, min_lat, max_lon, max_lat) in EPSG:4326.
|
|
35
|
+
tile_format : str
|
|
36
|
+
Tile image format (png, jpg, webp).
|
|
37
|
+
title : str
|
|
38
|
+
Layer/service title.
|
|
39
|
+
tile_size : int
|
|
40
|
+
Tile width/height in pixels (default 256).
|
|
41
|
+
base_url : str
|
|
42
|
+
Server base URL used for ResourceURL templates.
|
|
43
|
+
"""
|
|
44
|
+
min_lon, min_lat, max_lon, max_lat = bounds
|
|
45
|
+
layer_id = escape(title)
|
|
46
|
+
tms_id = "GoogleMapsCompatible"
|
|
47
|
+
mime = "image/jpeg" if tile_format in ("jpg", "jpeg") else f"image/{tile_format}"
|
|
48
|
+
|
|
49
|
+
# Build TileMatrix entries for each zoom level
|
|
50
|
+
tile_matrices = []
|
|
51
|
+
for z in range(minzoom, maxzoom + 1):
|
|
52
|
+
scale_denom = _SCALE_DENOM_Z0 / (2**z)
|
|
53
|
+
matrix_size = 2**z
|
|
54
|
+
tile_matrices.append(
|
|
55
|
+
f""" <TileMatrix>
|
|
56
|
+
<ows:Identifier>{z}</ows:Identifier>
|
|
57
|
+
<ScaleDenominator>{scale_denom:.10f}</ScaleDenominator>
|
|
58
|
+
<TopLeftCorner>{_TOP_LEFT_CORNER}</TopLeftCorner>
|
|
59
|
+
<TileWidth>{tile_size}</TileWidth>
|
|
60
|
+
<TileHeight>{tile_size}</TileHeight>
|
|
61
|
+
<MatrixWidth>{matrix_size}</MatrixWidth>
|
|
62
|
+
<MatrixHeight>{matrix_size}</MatrixHeight>
|
|
63
|
+
</TileMatrix>"""
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Bounding box in EPSG:3857 (metres)
|
|
67
|
+
bb_minx = min_lon * math.pi * _R / 180.0
|
|
68
|
+
bb_miny = math.log(math.tan(math.pi / 4 + math.radians(min_lat) / 2)) * _R
|
|
69
|
+
bb_maxx = max_lon * math.pi * _R / 180.0
|
|
70
|
+
bb_maxy = math.log(math.tan(math.pi / 4 + math.radians(max_lat) / 2)) * _R
|
|
71
|
+
|
|
72
|
+
resource_url = f"{base_url}/wmts/{title}/{tms_id}/{{TileMatrix}}/{{TileRow}}/{{TileCol}}.png"
|
|
73
|
+
|
|
74
|
+
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
75
|
+
<Capabilities xmlns="http://www.opengis.net/wmts/1.0"
|
|
76
|
+
xmlns:ows="http://www.opengis.net/ows/1.1"
|
|
77
|
+
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
78
|
+
version="1.0.0">
|
|
79
|
+
<ows:ServiceIdentification>
|
|
80
|
+
<ows:Title>{layer_id}</ows:Title>
|
|
81
|
+
<ows:ServiceType>OGC WMTS</ows:ServiceType>
|
|
82
|
+
<ows:ServiceTypeVersion>1.0.0</ows:ServiceTypeVersion>
|
|
83
|
+
</ows:ServiceIdentification>
|
|
84
|
+
<Contents>
|
|
85
|
+
<Layer>
|
|
86
|
+
<ows:Title>{layer_id}</ows:Title>
|
|
87
|
+
<ows:Identifier>{layer_id}</ows:Identifier>
|
|
88
|
+
<ows:WGS84BoundingBox>
|
|
89
|
+
<ows:LowerCorner>{min_lon:.10f} {min_lat:.10f}</ows:LowerCorner>
|
|
90
|
+
<ows:UpperCorner>{max_lon:.10f} {max_lat:.10f}</ows:UpperCorner>
|
|
91
|
+
</ows:WGS84BoundingBox>
|
|
92
|
+
<Style isDefault="true">
|
|
93
|
+
<ows:Identifier>default</ows:Identifier>
|
|
94
|
+
</Style>
|
|
95
|
+
<Format>{mime}</Format>
|
|
96
|
+
<TileMatrixSetLink>
|
|
97
|
+
<TileMatrixSet>{tms_id}</TileMatrixSet>
|
|
98
|
+
<TileMatrixSetLimits>
|
|
99
|
+
{_tile_matrix_set_limits(minzoom, maxzoom, bounds)}
|
|
100
|
+
</TileMatrixSetLimits>
|
|
101
|
+
</TileMatrixSetLink>
|
|
102
|
+
<ResourceURL format="{mime}" resourceType="tile"
|
|
103
|
+
template="{escape(resource_url)}"/>
|
|
104
|
+
</Layer>
|
|
105
|
+
<TileMatrixSet>
|
|
106
|
+
<ows:Identifier>{tms_id}</ows:Identifier>
|
|
107
|
+
<ows:SupportedCRS>urn:ogc:def:crs:EPSG::3857</ows:SupportedCRS>
|
|
108
|
+
<ows:BoundingBox crs="urn:ogc:def:crs:EPSG::3857">
|
|
109
|
+
<ows:LowerCorner>{bb_minx:.10f} {bb_miny:.10f}</ows:LowerCorner>
|
|
110
|
+
<ows:UpperCorner>{bb_maxx:.10f} {bb_maxy:.10f}</ows:UpperCorner>
|
|
111
|
+
</ows:BoundingBox>
|
|
112
|
+
{chr(10).join(tile_matrices)}
|
|
113
|
+
</TileMatrixSet>
|
|
114
|
+
</Contents>
|
|
115
|
+
</Capabilities>
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _lat_to_wmts_row(lat_deg: float, n: int) -> int:
|
|
120
|
+
"""Convert latitude (degrees) to WMTS tile row at grid size n."""
|
|
121
|
+
lat_rad = math.radians(lat_deg)
|
|
122
|
+
frac = (1.0 - math.log(math.tan(lat_rad) + 1.0 / math.cos(lat_rad)) / math.pi) / 2.0
|
|
123
|
+
return int(math.floor(frac * n))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _tile_matrix_set_limits(
|
|
127
|
+
minzoom: int,
|
|
128
|
+
maxzoom: int,
|
|
129
|
+
bounds: tuple[float, float, float, float],
|
|
130
|
+
) -> str:
|
|
131
|
+
"""Compute TileMatrixSetLimits for the given bounds at each zoom level.
|
|
132
|
+
|
|
133
|
+
Returns XML fragment with min/max row/col for each TileMatrix.
|
|
134
|
+
"""
|
|
135
|
+
min_lon, min_lat, max_lon, max_lat = bounds
|
|
136
|
+
lines = []
|
|
137
|
+
for z in range(minzoom, maxzoom + 1):
|
|
138
|
+
n = 2**z
|
|
139
|
+
# Column range (same as x in both TMS and XYZ)
|
|
140
|
+
min_col = int(math.floor((min_lon + 180.0) / 360.0 * n))
|
|
141
|
+
max_col = int(math.floor((max_lon + 180.0) / 360.0 * n))
|
|
142
|
+
max_col = min(max_col, n - 1)
|
|
143
|
+
|
|
144
|
+
# Row range (WMTS row = XYZ y, row 0 at north)
|
|
145
|
+
min_row = _lat_to_wmts_row(max_lat, n)
|
|
146
|
+
max_row = _lat_to_wmts_row(min_lat, n)
|
|
147
|
+
min_row = max(min_row, 0)
|
|
148
|
+
max_row = min(max_row, n - 1)
|
|
149
|
+
|
|
150
|
+
lines.append(
|
|
151
|
+
f""" <TileMatrixLimits>
|
|
152
|
+
<TileMatrix>{z}</TileMatrix>
|
|
153
|
+
<MinTileRow>{min_row}</MinTileRow>
|
|
154
|
+
<MaxTileRow>{max_row}</MaxTileRow>
|
|
155
|
+
<MinTileCol>{min_col}</MinTileCol>
|
|
156
|
+
<MaxTileCol>{max_col}</MaxTileCol>
|
|
157
|
+
</TileMatrixLimits>"""
|
|
158
|
+
)
|
|
159
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tilepack
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Convert TMS tile folders to MBTiles/PMTiles and serve them as TMS/WMTS endpoints
|
|
5
|
+
Author-email: Ashwin Nair <ashnair0007@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: click>=8.0
|
|
16
|
+
Requires-Dist: fastapi>=0.110
|
|
17
|
+
Requires-Dist: httpx>=0.27
|
|
18
|
+
Requires-Dist: pmtiles>=3.4
|
|
19
|
+
Requires-Dist: uvicorn[standard]>=0.29
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# tilepack
|
|
23
|
+
|
|
24
|
+
[](https://github.com/ashnair1/tilepack/actions/workflows/ci.yml)
|
|
25
|
+
[](https://pypi.org/project/tilepack/)
|
|
26
|
+
[](https://pypi.org/project/tilepack/)
|
|
27
|
+
[](LICENSE)
|
|
28
|
+
|
|
29
|
+
Pack raster tile folders (TMS or XYZ) into single-file archives (MBTiles / PMTiles) and serve them as TMS and WMTS endpoints over HTTP.
|
|
30
|
+
|
|
31
|
+
## Why
|
|
32
|
+
|
|
33
|
+
Raster tile folders contain thousands of small PNG files in deeply nested `z/x/y` directories. This makes them slow to copy, hard to manage, and fragile to transfer. Tilepack solves this by packing tiles into a single archive file while still exposing standard TMS and WMTS HTTP endpoints that clients like QGIS and CesiumForUnreal can consume directly.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
Requires Python 3.11+.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install tilepack
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
For development:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
git clone https://github.com/ashnair1/tilepack.git
|
|
47
|
+
cd tilepack
|
|
48
|
+
uv sync --group dev
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
### Verify a tile folder
|
|
54
|
+
|
|
55
|
+
Scan a tile folder and report zoom levels, tile counts, format, and detected Y-axis scheme (TMS vs XYZ).
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
tilepack verify ./path/to/tiles
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Convert to archive
|
|
62
|
+
|
|
63
|
+
The output format is inferred from the file extension. The input tile scheme (TMS or XYZ) is auto-detected, or can be specified explicitly.
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# Auto-detect input scheme
|
|
67
|
+
tilepack convert ./path/to/tiles output.mbtiles
|
|
68
|
+
tilepack convert ./path/to/tiles output.pmtiles
|
|
69
|
+
|
|
70
|
+
# Specify input scheme explicitly
|
|
71
|
+
tilepack convert ./path/to/tiles output.mbtiles --scheme xyz
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Serve as TMS + WMTS endpoint
|
|
75
|
+
|
|
76
|
+
Start a local HTTP server exposing both TMS and OGC WMTS 1.0.0 endpoints from an archive file.
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
tilepack serve output.mbtiles --port 8000
|
|
80
|
+
tilepack serve output.pmtiles --port 8000
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**TMS endpoints:**
|
|
84
|
+
- `http://localhost:8000/tilemapresource.xml`
|
|
85
|
+
- `http://localhost:8000/{z}/{x}/{y}.png`
|
|
86
|
+
|
|
87
|
+
**WMTS endpoints:**
|
|
88
|
+
- `http://localhost:8000/WMTSCapabilities.xml` — GetCapabilities
|
|
89
|
+
- `http://localhost:8000/wmts/{Layer}/{TileMatrixSet}/{z}/{row}/{col}.png` — RESTful tiles
|
|
90
|
+
- `http://localhost:8000/wmts?Service=WMTS&Request=GetTile&...` — KVP tiles
|
|
91
|
+
|
|
92
|
+
To load in QGIS: **Layer > Add WMS/WMTS Layer > New**, set URL to `http://localhost:8000/WMTSCapabilities.xml`, then Connect and Add.
|
|
93
|
+
|
|
94
|
+
### Validate correctness
|
|
95
|
+
|
|
96
|
+
Randomly sample tiles from the original folder, fetch them from the running server, and verify byte-exact matches.
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
# Start the server in one terminal, then in another:
|
|
100
|
+
tilepack selftest ./path/to/tiles --base-url http://127.0.0.1:8000 --samples 200
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## MBTiles vs PMTiles
|
|
104
|
+
|
|
105
|
+
| | MBTiles | PMTiles |
|
|
106
|
+
|---|---------|---------|
|
|
107
|
+
| Format | SQLite database | Cloud-optimised archive (Hilbert-curve index) |
|
|
108
|
+
| File count | 1 | 1 |
|
|
109
|
+
| Needs a tile server | Yes | No (supports HTTP range requests) |
|
|
110
|
+
| Best for | Local / on-prem serving | Cloud storage (S3, Azure Blob, GCS) |
|
|
111
|
+
|
|
112
|
+
**Use MBTiles** for local or on-prem serving (e.g. feeding CesiumForUnreal on the same machine or network). It's a SQLite file with fast tile lookups and no coordinate flipping at read time.
|
|
113
|
+
|
|
114
|
+
**Use PMTiles** if you plan to host tiles in cloud storage. PMTiles can be served directly from a bucket via HTTP range requests with no tile server needed. However, TMS clients like CesiumForUnreal cannot consume PMTiles directly — they still need a server translating to TMS endpoints.
|
|
115
|
+
|
|
116
|
+
**Either format** works identically when served through `tilepack serve` — clients see the same TMS and WMTS endpoints regardless of the backing archive.
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
tilepack/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
tilepack/__main__.py,sha256=Rd5tVnXo2vKtYrGXpzfaeG7m8kOw_YI-4QqukMfuZg4,80
|
|
3
|
+
tilepack/cli.py,sha256=2olgdpnIQPFEMPCoqHdaHipkTd6JlN2g-B6h7WvdXMU,1923
|
|
4
|
+
tilepack/convert.py,sha256=sVngsfGr2cesAItI9sMYZvTSPSK3MJt6NkxLE-gM3HM,6070
|
|
5
|
+
tilepack/selftest.py,sha256=5-8DZbKQkPEH9Y_-SMIm6XsT6zoajxC541EZCGh6kCQ,2969
|
|
6
|
+
tilepack/serve.py,sha256=wdSJc03KNmx9BzeP15DLRO-B8IbisDp9-V4-Jd0lyYA,6955
|
|
7
|
+
tilepack/tms_utils.py,sha256=tX56mghMH7aDIGVclwYL2eVuK5_kdMfxnB0Tn9eH950,7969
|
|
8
|
+
tilepack/verify.py,sha256=aSTIQO0aywcPIw_k-QMxMn3HHN-kEamldjMdz8KSwbE,2504
|
|
9
|
+
tilepack/wmts_utils.py,sha256=80bJaurnNS_7bp8YBZuVsbhcIx34G1_iOG8KUCecwow,5706
|
|
10
|
+
tilepack-0.1.0.dist-info/METADATA,sha256=leqttRORevOZueb_NYEFkGf4lLnXMWdKEdlfy-uj7gg,4433
|
|
11
|
+
tilepack-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
12
|
+
tilepack-0.1.0.dist-info/entry_points.txt,sha256=dQeecyuWc4Jri8YQ7PdprFBFY2UraVUVpwhUxETnDGI,46
|
|
13
|
+
tilepack-0.1.0.dist-info/licenses/LICENSE,sha256=XgQUG_tS3jb9Ona3ukK8Fxu3iULlAGEUQi2LvVcsnIM,1068
|
|
14
|
+
tilepack-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Ashwin Nair
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|