From dd759dba74a2a4fe5570e2963bea9e1bdb913e36 Mon Sep 17 00:00:00 2001 From: Valmo Date: Sat, 25 Jul 2026 18:19:00 -0300 Subject: [PATCH] Flatten Arma tiles over callback provider --- AGENTS.md | 2 +- README.md | 17 ++++---------- nginx/default.conf | 5 ----- scripts/build-tiles.py | 51 +++++++++++++++++++++++++++++++----------- 4 files changed, 43 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cd1f248..3ac0fc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index ff036e2..8ea3b57 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/nginx/default.conf b/nginx/default.conf index 89d09cc..f4a2267 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -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/(?[0-9]+)/(?[0-9]+)/(?[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. diff --git a/scripts/build-tiles.py b/scripts/build-tiles.py index 5e56567..a3dbc07 100755 --- a/scripts/build-tiles.py +++ b/scripts/build-tiles.py @@ -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() - 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 = callback_background(relative, provider, callback_cache) + base.alpha_composite(layer) + 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),