Flatten Arma tiles over callback provider

This commit is contained in:
2026-07-25 18:19:00 -03:00
parent dd987de699
commit dd759dba74
4 changed files with 43 additions and 32 deletions
+1 -1
View File
@@ -4,6 +4,6 @@
- The generated result belongs in `tiles/`; do not hand-edit it. Run `docker compose up --build` or `./scripts/build-tiles.sh` to regenerate it.
- Preserve the public Arma endpoint format: `/tiles/{x}/{y}/{z}`. Internal files remain `tiles/{z}/{x}/{y}.png`.
- `CALLBACK_PROVIDER` is optional. When set, it must be an HTTPS template containing exactly `{z}`, `{x}`, and `{y}` in its path. Validate it before a tile build; no provider-specific host or branding belongs in application code or documentation.
- With a callback provider, configure MapLibre with `/base/{x}/{y}/{z}` as its raster layer below `/tiles/{x}/{y}/{z}`. This makes the provider visible through transparent edges of Arma overlays.
- With a callback provider, MapLibre uses only `/tiles/{x}/{y}/{z}`. The builder flattens Arma overlays over callback tiles, so transparent edges never expose black pixels.
- Keep source processing idempotent, do not modify input files, and preserve per-source error reporting in `tiles/metadata.json`.
- Before handing off changes, run `python3 tests/test_build_tiles.py`, `docker compose config --quiet`, and `docker compose build tiles` when Docker is available.
+4 -13
View File
@@ -34,17 +34,11 @@ CALLBACK_PROVIDER=https://tiles.example-provider.com/{z}/{x}/{y}.png
The template is validated before source processing begins. It must use `https`, cannot include a query, credentials, or a private IP address, and must contain exactly one `{z}`, `{x}`, and `{y}` in its path. Leave the variable empty or unset to use the white `empty.png`.
To show the provider both outside map coverage and through the transparent edges of partially covered tiles, place the local base layer below the overlay layer:
During generation, every tile with Arma coverage is flattened over the callback tile. Therefore MapLibre needs only one source:
```js
sources: {
providerBase: {
type: "raster",
tiles: ["http://localhost:9000/base/{x}/{y}/{z}"],
tileSize: 256,
scheme: "xyz"
},
overlays: {
arma: {
type: "raster",
tiles: ["http://localhost:9000/tiles/{x}/{y}/{z}"],
tileSize: 256,
@@ -53,13 +47,10 @@ sources: {
maxzoom: 17
}
},
layers: [
{ id: "provider-base", type: "raster", source: "providerBase" },
{ id: "overlays", type: "raster", source: "overlays" }
]
layers: [{ id: "arma", type: "raster", source: "arma" }]
```
`/base/{x}/{y}/{z}` and the `/tiles/` fallback request the callback on demand and share a persistent local cache. Display attribution and comply with the selected provider's terms.
Only `/tiles/{x}/{y}/{z}` is public. Tiles without Arma coverage are fetched on demand from the callback and cached by Nginx. The builder also caches callback tiles while flattening covered areas. Display attribution and comply with the selected provider's terms.
## Inputs
-5
View File
@@ -21,11 +21,6 @@ server {
try_files /tiles/$tile_z/$tile_x/$tile_y.png @callback_provider_tile;
}
# A callback-provider base layer fills transparent edges when configured.
location ~ ^/base/(?<base_x>[0-9]+)/(?<base_y>[0-9]+)/(?<base_z>[0-9]+)$ {
try_files /__callback_provider_base__ @callback_provider_base;
}
include /etc/nginx/includes/callback-provider.conf;
# metadata.json and internal z/x/y.png paths are intentionally not public.
+38 -13
View File
@@ -15,8 +15,10 @@ import tempfile
import uuid
import zipfile
from datetime import UTC, datetime
from io import BytesIO
from pathlib import Path
from urllib.parse import unquote, urlparse
from urllib.request import Request, urlopen
from xml.etree import ElementTree as ET
from PIL import Image
@@ -346,7 +348,34 @@ def alpha_is_empty(image: Image.Image) -> bool:
return image.getchannel("A").getbbox() is None
def compose_layer(source_tiles: Path, final_tiles: Path) -> None:
def callback_background(relative: Path, provider, cache_root: Path) -> Image.Image:
"""Return a cached callback tile, or an opaque white tile when disabled."""
if provider is None:
return Image.new("RGBA", (256, 256), (255, 255, 255, 255))
z, x, filename = relative.parts
y = filename.removesuffix(".png")
cached = cache_root / z / x / filename
if cached.exists():
with Image.open(cached) as opened:
image = opened.convert("RGBA")
image.load()
return image
url = provider.origin + provider.path_template.replace("{z}", z).replace("{x}", x).replace("{y}", y)
try:
request = Request(url, headers={"User-Agent": "arma-tiles-builder/1.0"})
with urlopen(request, timeout=30) as response:
payload = response.read()
with Image.open(BytesIO(payload)) as opened:
image = opened.convert("RGBA")
image.load()
except Exception as error:
raise SourceError(f"callback provider falhou para {z}/{x}/{y}: {error}") from error
cached.parent.mkdir(parents=True, exist_ok=True)
image.save(cached, format="PNG", optimize=False)
return image
def compose_layer(source_tiles: Path, final_tiles: Path, provider, callback_cache: Path) -> None:
for tile in sorted(source_tiles.rglob("*.png")):
relative = tile.relative_to(source_tiles)
destination = final_tiles / relative
@@ -360,21 +389,17 @@ def compose_layer(source_tiles: Path, final_tiles: Path) -> None:
with Image.open(destination) as opened:
base = opened.convert("RGBA")
base.load()
else:
base = callback_background(relative, provider, callback_cache)
base.alpha_composite(layer)
result = base
else:
result = layer
if alpha_is_empty(result):
destination.unlink(missing_ok=True)
else:
result.save(destination, format="PNG", optimize=False)
base.save(destination, format="PNG", optimize=False)
def write_empty_tile(tiles_dir: Path) -> None:
Image.new("RGBA", (256, 256), (255, 255, 255, 255)).save(tiles_dir / "empty.png", format="PNG", optimize=False)
def tile_overlay(overlay: dict, work_root: Path, final_tiles: Path, processes: int) -> None:
def tile_overlay(overlay: dict, work_root: Path, final_tiles: Path, processes: int, provider, callback_cache: Path) -> None:
layer_root = work_root / f"layer-{overlay['priority']:04d}-{overlay['index']}"
help_result = subprocess.run(["gdal2tiles.py", "--help"], text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
legacy = ["--legacy"] if "--legacy" in help_result.stdout else []
@@ -382,7 +407,7 @@ def tile_overlay(overlay: dict, work_root: Path, final_tiles: Path, processes: i
"gdal2tiles.py", *legacy, "-q", "--profile=mercator", "--xyz", "--exclude", "--webviewer=none", "--resampling=bilinear",
f"--zoom={overlay['minzoom']}-{overlay['maxzoom']}", f"--processes={processes}", overlay["warped"], str(layer_root),
])
compose_layer(layer_root, final_tiles)
compose_layer(layer_root, final_tiles, provider, callback_cache)
shutil.rmtree(layer_root, ignore_errors=True)
@@ -431,7 +456,7 @@ def source_record(descriptor: dict, config: dict) -> dict:
def build(arguments: argparse.Namespace) -> None:
# Validate before reading or warping any source, so a malformed provider never wastes a full build.
validate_callback_provider(os.environ.get("CALLBACK_PROVIDER"))
provider = validate_callback_provider(os.environ.get("CALLBACK_PROVIDER"))
source_dir = Path(arguments.source_dir).resolve()
data_dir = Path(arguments.data_dir).resolve()
tiles_dir = Path(arguments.tiles_dir).resolve()
@@ -476,7 +501,7 @@ def build(arguments: argparse.Namespace) -> None:
# alphabetical winner is painted last.
for overlay in sorted(overlays, key=lambda item: (item["area_m2"], item["source"], item["index"]), reverse=True):
try:
tile_overlay(overlay, work_root, stage, arguments.processes)
tile_overlay(overlay, work_root, stage, arguments.processes, provider, cache_root / "callback-provider")
except SourceError as error:
errors.append({"source": overlay["source"], "overlay": overlay["index"], "stage": "tile", "reason": str(error)})
global_min = min((item["minzoom"] for item in overlays), default=None)
@@ -486,7 +511,7 @@ def build(arguments: argparse.Namespace) -> None:
"generated_at": datetime.now(UTC).isoformat(),
"minzoom": global_min,
"maxzoom": global_max,
"tile_background": "transparent",
"tile_background": "callback_provider" if provider else "white",
"processed_sources": records,
"sources_with_error": errors,
"source_count": len(records),