Add import TMS tiles (directory/zip) and tif formatter

This commit is contained in:
parent e39361bd81
commit a5de6f5c91
6 changed files with 127 additions and 19 deletions

View file

@ -13,6 +13,7 @@
- Требуется установленный `gdal2tiles` (например, пакет `gdal-bin` в apt). - Требуется установленный `gdal2tiles` (например, пакет `gdal-bin` в apt).
- Нарезка через API `POST /tiles/slice` или вызов сервиса `TileService.slice_to_tiles` (внутри backend). - Нарезка через API `POST /tiles/slice` или вызов сервиса `TileService.slice_to_tiles` (внутри backend).
- Поддерживаются входы, которые понимает GDAL (GeoTIFF, JPEG, MBTiles и др.). Нарезка идёт в профиле Web Mercator (XYZ) по умолчанию. - Поддерживаются входы, которые понимает GDAL (GeoTIFF, JPEG, MBTiles и др.). Нарезка идёт в профиле Web Mercator (XYZ) по умолчанию.
- Для заранее подготовленных TMS/XYZ наборов (папка или zip/tar архив) укажите `source_format=tms|xyz` — они будут скопированы без запуска gdal2tiles.
## Фронтенд ## Фронтенд
- В `frontend` лежит Tauri/React приложение для проверки бекенда (карта Leaflet, WebSocket трек, форма для запуска нарезки тайлов). - В `frontend` лежит Tauri/React приложение для проверки бекенда (карта Leaflet, WebSocket трек, форма для запуска нарезки тайлов).

View file

@ -26,6 +26,7 @@ type IncomingMessage = FixMessage | StatusMessage;
type AppConfig = { type AppConfig = {
tiles_url: string; tiles_url: string;
tiles_url_tms?: string;
ws_url: string; ws_url: string;
gnss_source: string; gnss_source: string;
}; };
@ -80,6 +81,8 @@ export default function App() {
min_zoom: 10, min_zoom: 10,
max_zoom: 16, max_zoom: 16,
tile_format: "png", tile_format: "png",
scheme: "xyz" as "xyz" | "tms",
source_format: "geotiff" as "geotiff" | "mbtiles" | "tms" | "xyz",
}); });
const [sliceStatus, setSliceStatus] = useState<string>("idle"); const [sliceStatus, setSliceStatus] = useState<string>("idle");
@ -191,7 +194,12 @@ export default function App() {
); );
const tileUrl = 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 ( return (
<div className="layout"> <div className="layout">
@ -230,7 +238,7 @@ export default function App() {
<section className="panel map-panel"> <section className="panel map-panel">
{tileUrl ? ( {tileUrl ? (
<MapContainer center={defaultCenter} zoom={13} scrollWheelZoom className="map"> <MapContainer center={defaultCenter} zoom={13} scrollWheelZoom className="map">
<TileLayer url={tileUrl} attribution="Local tiles" /> <TileLayer url={tileUrl} attribution="Local tiles" tms={sliceParams.scheme === "tms"} />
{positions.length > 0 && ( {positions.length > 0 && (
<> <>
<Polyline positions={positions} pathOptions={{ color: "#7dd3fc", weight: 5, opacity: 0.9 }} /> <Polyline positions={positions} pathOptions={{ color: "#7dd3fc", weight: 5, opacity: 0.9 }} />
@ -268,6 +276,8 @@ export default function App() {
min_zoom: Number(sliceParams.min_zoom), min_zoom: Number(sliceParams.min_zoom),
max_zoom: Number(sliceParams.max_zoom), max_zoom: Number(sliceParams.max_zoom),
tile_format: sliceParams.tile_format, 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)))) .then((r) => (r.ok ? r.json() : r.json().then((j) => Promise.reject(j.detail || r.status))))
@ -309,6 +319,33 @@ export default function App() {
</div> </div>
{selectedFileName && <small className="muted">Файл: {selectedFileName}</small>} {selectedFileName && <small className="muted">Файл: {selectedFileName}</small>}
</label> </label>
<label className="field">
<span>Схема тайлов</span>
<select
value={sliceParams.scheme}
onChange={(e) => setSliceParams((p) => ({ ...p, scheme: e.target.value as "xyz" | "tms" }))}
>
<option value="xyz">XYZ (Y сверху, Leaflet)</option>
<option value="tms">TMS (Y снизу)</option>
</select>
</label>
<label className="field">
<span>Формат исходника</span>
<select
value={sliceParams.source_format}
onChange={(e) =>
setSliceParams((p) => ({
...p,
source_format: e.target.value as "geotiff" | "mbtiles" | "tms" | "xyz",
}))
}
>
<option value="geotiff">GeoTIFF/растровый TIFF</option>
<option value="mbtiles">MBTiles (растровый)</option>
<option value="tms">Папка TMS</option>
<option value="xyz">Папка XYZ</option>
</select>
</label>
<label className="field"> <label className="field">
<span>Папка тайлов (опц.)</span> <span>Папка тайлов (опц.)</span>
<input <input
@ -339,13 +376,12 @@ export default function App() {
/> />
</label> </label>
<label className="field"> <label className="field">
<span>Формат</span> <span>Формат тайлов (сейчас поддерживается PNG)</span>
<select <select
value={sliceParams.tile_format} value={sliceParams.tile_format}
onChange={(e) => setSliceParams((p) => ({ ...p, tile_format: e.target.value }))} onChange={(e) => setSliceParams((p) => ({ ...p, tile_format: e.target.value }))}
> >
<option value="png">png</option> <option value="png">png</option>
<option value="jpeg">jpeg</option>
</select> </select>
</label> </label>
<div className="field actions"> <div className="field actions">
@ -362,6 +398,8 @@ export default function App() {
min_zoom: 10, min_zoom: 10,
max_zoom: 16, max_zoom: 16,
tile_format: "png", tile_format: "png",
scheme: "xyz",
source_format: "geotiff",
}) })
} }
> >

View file

@ -9,6 +9,7 @@ router = APIRouter(tags=["config"])
async def get_config() -> dict: async def get_config() -> dict:
return { return {
"tiles_url": "/tiles/{z}/{x}/{y}.png", "tiles_url": "/tiles/{z}/{x}/{y}.png",
"tiles_url_tms": "/tiles/tms/{z}/{x}/{y}.png",
"ws_url": "/ws/track", "ws_url": "/ws/track",
"gnss_source": config.GNSS_SOURCE, "gnss_source": config.GNSS_SOURCE,
} }

View file

@ -16,11 +16,20 @@ async def serve_tile(zoom: int, x: int, y: int, tile_service: TileService = Depe
return tile_service.get_tile_response(zoom, x, y) 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") @router.post("/tiles/slice")
async def slice_tiles(payload: TileSliceRequest, tile_service: TileService = Depends(get_tile_service)) -> dict: async def slice_tiles(payload: TileSliceRequest, tile_service: TileService = Depends(get_tile_service)) -> dict:
source = payload.source_path() source = payload.source_path()
destination = payload.destination_path(tile_service.tiles_dir) destination = payload.destination_path(tile_service.tiles_dir)
try: try:
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( tile_service.slice_to_tiles(
source=source, source=source,
destination=destination, destination=destination,
@ -30,10 +39,11 @@ async def slice_tiles(payload: TileSliceRequest, tile_service: TileService = Dep
tile_format=payload.tile_format, tile_format=payload.tile_format,
web_mercator=payload.web_mercator, web_mercator=payload.web_mercator,
extra_args=payload.extra_args, extra_args=payload.extra_args,
scheme=payload.scheme,
) )
except TileCutError as exc: except TileCutError as exc:
raise HTTPException(status_code=400, detail=str(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") @router.post("/tiles/upload")

View file

@ -12,11 +12,13 @@ class TileSliceRequest(BaseModel):
tile_format: str = "png" tile_format: str = "png"
web_mercator: bool = True web_mercator: bool = True
extra_args: list[str] | None = None extra_args: list[str] | None = None
scheme: str = "xyz" # xyz или tms
source_format: str = "geotiff" # geotiff | mbtiles | tms | xyz
@field_validator("tile_format") @field_validator("tile_format")
@classmethod @classmethod
def validate_format(cls, value: str) -> str: def validate_format(cls, value: str) -> str:
allowed = {"png", "jpeg", "jpg"} allowed = {"png"}
normalized = value.lower() normalized = value.lower()
if normalized not in allowed: if normalized not in allowed:
raise ValueError(f"tile_format must be one of {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") raise ValueError("max_zoom must be >= min_zoom")
return v 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: def source_path(self) -> Path:
return Path(self.source).expanduser().resolve() return Path(self.source).expanduser().resolve()

View file

@ -1,6 +1,8 @@
import base64 import base64
import shutil import shutil
import subprocess import subprocess
import tarfile
import zipfile
from pathlib import Path from pathlib import Path
from typing import Iterable, Optional from typing import Iterable, Optional
@ -45,11 +47,14 @@ class TileService:
tile_format: str = "png", tile_format: str = "png",
web_mercator: bool = True, web_mercator: bool = True,
extra_args: Optional[Iterable[str]] = None, extra_args: Optional[Iterable[str]] = None,
scheme: str = "xyz",
) -> Path: ) -> Path:
if not source.exists(): if not source.exists():
raise TileCutError(f"source does not exist: {source}") raise TileCutError(f"source does not exist: {source}")
if min_zoom < 0 or max_zoom < min_zoom: if min_zoom < 0 or max_zoom < min_zoom:
raise TileCutError(f"invalid zoom range: {min_zoom}-{max_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) destination.mkdir(parents=True, exist_ok=True)
@ -60,11 +65,11 @@ class TileService:
f"{min_zoom}-{max_zoom}", f"{min_zoom}-{max_zoom}",
"-r", "-r",
resampling, resampling,
"-f",
tile_format.lower(),
] ]
if web_mercator: if web_mercator:
cmd += ["-p", "mercator"] cmd += ["-p", "mercator"]
if scheme.lower() == "xyz":
cmd += ["--xyz"]
if extra_args: if extra_args:
cmd += list(extra_args) cmd += list(extra_args)
cmd += [str(source), str(destination)] cmd += [str(source), str(destination)]
@ -82,4 +87,37 @@ class TileService:
return FileResponse(self.placeholder, media_type="image/png") return FileResponse(self.placeholder, media_type="image/png")
return Response(content=DEFAULT_PLACEHOLDER_PNG, media_type="image/png") return Response(content=DEFAULT_PLACEHOLDER_PNG, media_type="image/png")
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)