diff --git a/README.md b/README.md index bae012c..76e4807 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ - Требуется установленный `gdal2tiles` (например, пакет `gdal-bin` в apt). - Нарезка через API `POST /tiles/slice` или вызов сервиса `TileService.slice_to_tiles` (внутри backend). - Поддерживаются входы, которые понимает GDAL (GeoTIFF, JPEG, MBTiles и др.). Нарезка идёт в профиле Web Mercator (XYZ) по умолчанию. +- Для заранее подготовленных TMS/XYZ наборов (папка или zip/tar архив) укажите `source_format=tms|xyz` — они будут скопированы без запуска gdal2tiles. ## Фронтенд - В `frontend` лежит Tauri/React приложение для проверки бекенда (карта Leaflet, WebSocket трек, форма для запуска нарезки тайлов). diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e064e91..492c113 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,7 @@ type IncomingMessage = FixMessage | StatusMessage; type AppConfig = { tiles_url: string; + tiles_url_tms?: string; ws_url: string; gnss_source: string; }; @@ -80,6 +81,8 @@ export default function App() { min_zoom: 10, max_zoom: 16, tile_format: "png", + scheme: "xyz" as "xyz" | "tms", + source_format: "geotiff" as "geotiff" | "mbtiles" | "tms" | "xyz", }); const [sliceStatus, setSliceStatus] = useState("idle"); @@ -191,7 +194,12 @@ export default function App() { ); const tileUrl = - config && `${backendBase.replace(/\/+$/, "")}${config.tiles_url || "/tiles/{z}/{x}/{y}.png"}`; + config && + `${backendBase.replace(/\/+$/, "")}${ + sliceParams.scheme === "tms" + ? config.tiles_url_tms || "/tiles/tms/{z}/{x}/{y}.png" + : config.tiles_url || "/tiles/{z}/{x}/{y}.png" + }`; return (
@@ -230,7 +238,7 @@ export default function App() {
{tileUrl ? ( - + {positions.length > 0 && ( <> @@ -268,6 +276,8 @@ export default function App() { min_zoom: Number(sliceParams.min_zoom), max_zoom: Number(sliceParams.max_zoom), tile_format: sliceParams.tile_format, + scheme: sliceParams.scheme, + source_format: sliceParams.source_format, }), }) .then((r) => (r.ok ? r.json() : r.json().then((j) => Promise.reject(j.detail || r.status)))) @@ -309,6 +319,33 @@ export default function App() {
{selectedFileName && Файл: {selectedFileName}} + +
@@ -362,6 +398,8 @@ export default function App() { min_zoom: 10, max_zoom: 16, tile_format: "png", + scheme: "xyz", + source_format: "geotiff", }) } > diff --git a/gnss/api/routes/config_route.py b/gnss/api/routes/config_route.py index 09dc487..17cffd8 100644 --- a/gnss/api/routes/config_route.py +++ b/gnss/api/routes/config_route.py @@ -9,6 +9,7 @@ router = APIRouter(tags=["config"]) async def get_config() -> dict: return { "tiles_url": "/tiles/{z}/{x}/{y}.png", + "tiles_url_tms": "/tiles/tms/{z}/{x}/{y}.png", "ws_url": "/ws/track", "gnss_source": config.GNSS_SOURCE, } diff --git a/gnss/api/routes/tiles.py b/gnss/api/routes/tiles.py index 0e74160..fe50d9b 100644 --- a/gnss/api/routes/tiles.py +++ b/gnss/api/routes/tiles.py @@ -16,24 +16,34 @@ async def serve_tile(zoom: int, x: int, y: int, tile_service: TileService = Depe return tile_service.get_tile_response(zoom, x, y) +@router.get("/tiles/tms/{zoom}/{x}/{y}.png") +async def serve_tile_tms(zoom: int, x: int, y: int, tile_service: TileService = Depends(get_tile_service)): + return tile_service.get_tile_response_tms(zoom, x, y) + + @router.post("/tiles/slice") async def slice_tiles(payload: TileSliceRequest, tile_service: TileService = Depends(get_tile_service)) -> dict: source = payload.source_path() destination = payload.destination_path(tile_service.tiles_dir) try: - tile_service.slice_to_tiles( - source=source, - destination=destination, - min_zoom=payload.min_zoom, - max_zoom=payload.max_zoom, - resampling=payload.resampling, - tile_format=payload.tile_format, - web_mercator=payload.web_mercator, - extra_args=payload.extra_args, - ) + if payload.source_format in {"tms", "xyz"}: + src = tile_service.unpack_if_archive(source) + tile_service.copy_tile_tree(src, destination) + else: + tile_service.slice_to_tiles( + source=source, + destination=destination, + min_zoom=payload.min_zoom, + max_zoom=payload.max_zoom, + resampling=payload.resampling, + tile_format=payload.tile_format, + web_mercator=payload.web_mercator, + extra_args=payload.extra_args, + scheme=payload.scheme, + ) except TileCutError as exc: raise HTTPException(status_code=400, detail=str(exc)) - return {"status": "ok", "tiles_dir": str(destination), "source": str(source)} + return {"status": "ok", "tiles_dir": str(destination), "source": str(source), "scheme": payload.scheme} @router.post("/tiles/upload") diff --git a/gnss/models/tiles.py b/gnss/models/tiles.py index 56f1af6..55c01d6 100644 --- a/gnss/models/tiles.py +++ b/gnss/models/tiles.py @@ -12,11 +12,13 @@ class TileSliceRequest(BaseModel): tile_format: str = "png" web_mercator: bool = True extra_args: list[str] | None = None + scheme: str = "xyz" # xyz или tms + source_format: str = "geotiff" # geotiff | mbtiles | tms | xyz @field_validator("tile_format") @classmethod def validate_format(cls, value: str) -> str: - allowed = {"png", "jpeg", "jpg"} + allowed = {"png"} normalized = value.lower() if normalized not in allowed: raise ValueError(f"tile_format must be one of {allowed}") @@ -30,6 +32,24 @@ class TileSliceRequest(BaseModel): raise ValueError("max_zoom must be >= min_zoom") return v + @field_validator("scheme") + @classmethod + def validate_scheme(cls, v: str) -> str: + allowed = {"xyz", "tms"} + v_lower = v.lower() + if v_lower not in allowed: + raise ValueError(f"scheme must be one of {allowed}") + return v_lower + + @field_validator("source_format") + @classmethod + def validate_source_format(cls, v: str) -> str: + allowed = {"geotiff", "mbtiles", "tms", "xyz"} + v_lower = v.lower() + if v_lower not in allowed: + raise ValueError(f"source_format must be one of {allowed}") + return v_lower + def source_path(self) -> Path: return Path(self.source).expanduser().resolve() diff --git a/gnss/services/tiles.py b/gnss/services/tiles.py index 596fd2a..e83d61f 100644 --- a/gnss/services/tiles.py +++ b/gnss/services/tiles.py @@ -1,6 +1,8 @@ import base64 import shutil import subprocess +import tarfile +import zipfile from pathlib import Path from typing import Iterable, Optional @@ -45,11 +47,14 @@ class TileService: tile_format: str = "png", web_mercator: bool = True, extra_args: Optional[Iterable[str]] = None, + scheme: str = "xyz", ) -> Path: if not source.exists(): raise TileCutError(f"source does not exist: {source}") if min_zoom < 0 or max_zoom < min_zoom: raise TileCutError(f"invalid zoom range: {min_zoom}-{max_zoom}") + if tile_format.lower() != "png": + raise TileCutError("current gdal2tiles does not support custom format here; use png") destination.mkdir(parents=True, exist_ok=True) @@ -60,11 +65,11 @@ class TileService: f"{min_zoom}-{max_zoom}", "-r", resampling, - "-f", - tile_format.lower(), ] if web_mercator: cmd += ["-p", "mercator"] + if scheme.lower() == "xyz": + cmd += ["--xyz"] if extra_args: cmd += list(extra_args) cmd += [str(source), str(destination)] @@ -82,4 +87,37 @@ class TileService: return FileResponse(self.placeholder, media_type="image/png") return Response(content=DEFAULT_PLACEHOLDER_PNG, media_type="image/png") - \ No newline at end of file + def get_tile_response_tms(self, zoom: int, x: int, y: int): + y_converted = (2**zoom - 1) - y + return self.get_tile_response(zoom, x, y_converted) + + def unpack_if_archive(self, source: Path) -> Path: + lower = source.suffix.lower() + if lower == ".zip": + dest = self.upload_dir / source.stem + dest.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(source, "r") as zf: + zf.extractall(dest) + return dest + if lower in {".tar", ".tgz", ".gz", ".bz2", ".xz"} or source.name.endswith((".tar.gz", ".tar.bz2", ".tar.xz")): + dest = self.upload_dir / source.stem + dest.mkdir(parents=True, exist_ok=True) + with tarfile.open(source, "r:*") as tf: + tf.extractall(dest) + return dest + return source + + def copy_tile_tree(self, source_dir: Path, destination: Path) -> None: + if not source_dir.is_dir(): + raise TileCutError( + f"tiles directory not found: {source_dir}. Expected folder with {Path('z/x/y.png')} structure or a zip/tar archive." + ) + destination.mkdir(parents=True, exist_ok=True) + for item in source_dir.iterdir(): + target = destination / item.name + if item.is_dir(): + shutil.copytree(item, target, dirs_exist_ok=True) + else: + shutil.copy2(item, target) + +