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 ADDED
File without changes
tilepack/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m tilepack"""
2
+
3
+ from tilepack.cli import cli
4
+
5
+ cli()
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
+ [![CI](https://github.com/ashnair1/tilepack/actions/workflows/ci.yml/badge.svg)](https://github.com/ashnair1/tilepack/actions/workflows/ci.yml)
25
+ [![PyPI](https://img.shields.io/pypi/v/tilepack)](https://pypi.org/project/tilepack/)
26
+ [![Python](https://img.shields.io/pypi/pyversions/tilepack)](https://pypi.org/project/tilepack/)
27
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tilepack = tilepack.cli:cli
@@ -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.