Flatten Arma tiles over callback provider
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user