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
|
|
@ -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 трек, форма для запуска нарезки тайлов).
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue