Cutt maps for tiles. Clean project architecture
This commit is contained in:
parent
74977ccbfb
commit
b2eaaccbb8
17 changed files with 367 additions and 185 deletions
|
|
@ -5,8 +5,14 @@
|
|||
- Выполните `poetry install` (ставит зависимости и проект как пакет `gnss`).
|
||||
|
||||
## Запуск
|
||||
- `poetry run uvicorn gnss:app --host 0.0.0.0 --port 8000`
|
||||
- `poetry run uvicorn gnss.main:app --host 0.0.0.0 --port 8000`
|
||||
- Тайлы ожидаются по пути `gnss/tiles/{zoom}/{x}/{y}.png` (можно положить заглушку `placeholder.png` в эту же директорию).
|
||||
- API для нарезки: `POST /tiles/slice` с JSON `{ "source": "/path/input.tif", "destination": "/path/to/tiles", "min_zoom": 10, "max_zoom": 16 }`.
|
||||
|
||||
## Нарезка тайлов (GDAL)
|
||||
- Требуется установленный `gdal2tiles` (например, пакет `gdal-bin` в apt).
|
||||
- Нарезка через API `POST /tiles/slice` или вызов сервиса `TileService.slice_to_tiles` (внутри backend).
|
||||
- Поддерживаются входы, которые понимает GDAL (GeoTIFF, JPEG, MBTiles и др.). Нарезка идёт в профиле Web Mercator (XYZ) по умолчанию.
|
||||
|
||||
## Ключевые зависимости
|
||||
- fastapi — REST и WebSocket
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
"""GNSS backend package."""
|
||||
from .app import app
|
||||
from .main import app
|
||||
|
|
|
|||
3
gnss/api/dependencies.py
Normal file
3
gnss/api/dependencies.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .state import get_gnss_service, get_tile_service
|
||||
|
||||
__all__ = ["get_gnss_service", "get_tile_service"]
|
||||
14
gnss/api/routes/config_route.py
Normal file
14
gnss/api/routes/config_route.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
from ... import config
|
||||
|
||||
router = APIRouter(tags=["config"])
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_config() -> dict:
|
||||
return {
|
||||
"tiles_url": "/tiles/{z}/{x}/{y}.png",
|
||||
"ws_url": "/ws/track",
|
||||
"gnss_source": config.GNSS_SOURCE,
|
||||
}
|
||||
14
gnss/api/routes/health.py
Normal file
14
gnss/api/routes/health.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
from ... import config
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"tiles_dir": str(config.TILES_DIR),
|
||||
"gnss_source": config.GNSS_SOURCE,
|
||||
}
|
||||
34
gnss/api/routes/tiles.py
Normal file
34
gnss/api/routes/tiles.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from ...api.dependencies import get_tile_service
|
||||
from ...models.tiles import TileSliceRequest
|
||||
from ...services.tiles import TileCutError, TileService
|
||||
|
||||
router = APIRouter(tags=["tiles"])
|
||||
|
||||
|
||||
@router.get("/tiles/{zoom}/{x}/{y}.png")
|
||||
async def serve_tile(zoom: int, x: int, y: int, tile_service: TileService = Depends(get_tile_service)):
|
||||
return tile_service.get_tile_response(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,
|
||||
)
|
||||
except TileCutError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return {"status": "ok", "tiles_dir": str(destination), "source": str(source)}
|
||||
18
gnss/api/routes/ws.py
Normal file
18
gnss/api/routes/ws.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
|
||||
|
||||
from ...api.dependencies import get_gnss_service
|
||||
from ...services.gnss import GNSSService
|
||||
|
||||
router = APIRouter(tags=["ws"])
|
||||
|
||||
|
||||
@router.websocket("/ws/track")
|
||||
async def websocket_track(websocket: WebSocket, gnss_service: GNSSService = Depends(get_gnss_service)):
|
||||
await websocket.accept()
|
||||
try:
|
||||
async for payload in gnss_service.stream():
|
||||
await websocket.send_json(payload)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
except Exception:
|
||||
await websocket.close(code=1011)
|
||||
25
gnss/api/state.py
Normal file
25
gnss/api/state.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from typing import Optional
|
||||
|
||||
from ..services.gnss import GNSSService
|
||||
from ..services.tiles import TileService
|
||||
|
||||
_gnss_service: Optional[GNSSService] = None
|
||||
_tile_service: Optional[TileService] = None
|
||||
|
||||
|
||||
def set_services(gnss: GNSSService, tiles: TileService) -> None:
|
||||
global _gnss_service, _tile_service
|
||||
_gnss_service = gnss
|
||||
_tile_service = tiles
|
||||
|
||||
|
||||
def get_gnss_service() -> GNSSService:
|
||||
if _gnss_service is None:
|
||||
raise RuntimeError("GNSS service is not initialized")
|
||||
return _gnss_service
|
||||
|
||||
|
||||
def get_tile_service() -> TileService:
|
||||
if _tile_service is None:
|
||||
raise RuntimeError("Tile service is not initialized")
|
||||
return _tile_service
|
||||
126
gnss/app.py
126
gnss/app.py
|
|
@ -1,126 +0,0 @@
|
|||
import base64
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, Response
|
||||
|
||||
from . import config
|
||||
from .gnss_core import (
|
||||
GNSSService,
|
||||
SerialNMEASource,
|
||||
SimulatedPathSource,
|
||||
build_status_payload,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("gnss")
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
DEFAULT_PLACEHOLDER_PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/6Xq3lYAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
TILES_DIR = Path(config.TILES_DIR)
|
||||
PLACEHOLDER_TILE = Path(config.TILE_PLACEHOLDER)
|
||||
|
||||
|
||||
def create_gnss_service() -> GNSSService:
|
||||
source_type = (config.GNSS_SOURCE or "simulated").lower()
|
||||
if source_type == "serial":
|
||||
source = SerialNMEASource(
|
||||
port=config.GNSS_PORT,
|
||||
baudrate=config.GNSS_BAUD,
|
||||
timeout=config.GNSS_TIMEOUT,
|
||||
)
|
||||
logger.info(
|
||||
"GNSS source: serial (%s @ %s baud)", config.GNSS_PORT, config.GNSS_BAUD
|
||||
)
|
||||
else:
|
||||
start_lat, start_lon = config.SIM_START_POINT
|
||||
source = SimulatedPathSource(
|
||||
center_lat=start_lat,
|
||||
center_lon=start_lon,
|
||||
radius_m=config.SIM_RADIUS_METERS,
|
||||
)
|
||||
logger.info(
|
||||
"GNSS source: simulator (center=%s, %s, radius=%sm)",
|
||||
start_lat,
|
||||
start_lon,
|
||||
config.SIM_RADIUS_METERS,
|
||||
)
|
||||
|
||||
return GNSSService(source=source, broadcast_interval=config.WS_BROADCAST_INTERVAL)
|
||||
|
||||
|
||||
gnss_service = create_gnss_service()
|
||||
app = FastAPI(title="GNSS Backend", version="0.1.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup() -> None:
|
||||
TILES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
await gnss_service.start()
|
||||
except Exception as exc:
|
||||
logger.error("Failed to start GNSS source: %s", exc)
|
||||
logger.info("Backend started, tiles dir: %s", TILES_DIR)
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
|
||||
async def on_shutdown() -> None:
|
||||
await gnss_service.stop()
|
||||
logger.info("Backend stopped")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"tiles_dir": str(TILES_DIR),
|
||||
"gnss_source": config.GNSS_SOURCE,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/tiles/{zoom}/{x}/{y}.png")
|
||||
async def serve_tile(zoom: int, x: int, y: int):
|
||||
tile_path = TILES_DIR / str(zoom) / str(x) / f"{y}.png"
|
||||
if tile_path.is_file():
|
||||
return FileResponse(tile_path, media_type="image/png")
|
||||
|
||||
if PLACEHOLDER_TILE.is_file():
|
||||
return FileResponse(PLACEHOLDER_TILE, media_type="image/png")
|
||||
|
||||
return Response(content=DEFAULT_PLACEHOLDER_PNG, media_type="image/png")
|
||||
|
||||
|
||||
@app.websocket("/ws/track")
|
||||
async def websocket_track(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
logger.info("WebSocket client connected: %s", websocket.client)
|
||||
try:
|
||||
async for payload in gnss_service.stream():
|
||||
await websocket.send_json(payload)
|
||||
except WebSocketDisconnect:
|
||||
logger.info("WebSocket disconnected: %s", websocket.client)
|
||||
except Exception as exc:
|
||||
logger.exception("WebSocket error: %s", exc)
|
||||
await websocket.close(code=1011)
|
||||
|
||||
|
||||
@app.get("/config")
|
||||
async def get_config() -> dict:
|
||||
return {
|
||||
"tiles_url": "/tiles/{z}/{x}/{y}.png",
|
||||
"ws_url": "/ws/track",
|
||||
"gnss_source": config.GNSS_SOURCE,
|
||||
}
|
||||
70
gnss/main.py
Normal file
70
gnss/main.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from . import config
|
||||
from .api import state
|
||||
from .api.routes import config_route, health, tiles, ws
|
||||
from .services.gnss import GNSSService, SerialNMEASource, SimulatedPathSource
|
||||
from .services.tiles import TileService
|
||||
|
||||
logger = logging.getLogger("gnss")
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
def create_gnss_service() -> GNSSService:
|
||||
source_type = (config.GNSS_SOURCE or "simulated").lower()
|
||||
if source_type == "serial":
|
||||
source = SerialNMEASource(
|
||||
port=config.GNSS_PORT,
|
||||
baudrate=config.GNSS_BAUD,
|
||||
timeout=config.GNSS_TIMEOUT,
|
||||
)
|
||||
else:
|
||||
start_lat, start_lon = config.SIM_START_POINT
|
||||
source = SimulatedPathSource(
|
||||
center_lat=start_lat,
|
||||
center_lon=start_lon,
|
||||
radius_m=config.SIM_RADIUS_METERS,
|
||||
)
|
||||
return GNSSService(source=source, broadcast_interval=config.WS_BROADCAST_INTERVAL)
|
||||
|
||||
|
||||
tiles_dir = Path(config.TILES_DIR)
|
||||
placeholder_tile = Path(config.TILE_PLACEHOLDER)
|
||||
tile_service = TileService(tiles_dir=tiles_dir, placeholder=placeholder_tile)
|
||||
gnss_service = create_gnss_service()
|
||||
state.set_services(gnss=gnss_service, tiles=tile_service)
|
||||
|
||||
app = FastAPI(title="GNSS Backend", version="0.2.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(config_route.router)
|
||||
app.include_router(tiles.router)
|
||||
app.include_router(ws.router)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup() -> None:
|
||||
tile_service.ensure_tiles_dir()
|
||||
try:
|
||||
await gnss_service.start()
|
||||
except Exception as exc:
|
||||
logger.error("Failed to start GNSS source: %s", exc)
|
||||
logger.info("Backend started, tiles dir: %s", tiles_dir)
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def on_shutdown() -> None:
|
||||
await gnss_service.stop()
|
||||
logger.info("Backend stopped")
|
||||
43
gnss/models/gnss.py
Normal file
43
gnss/models/gnss.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
EARTH_RADIUS_M = 6_378_137
|
||||
|
||||
|
||||
@dataclass
|
||||
class GNSSPoint:
|
||||
lat: float
|
||||
lon: float
|
||||
alt: Optional[float]
|
||||
timestamp: str
|
||||
source: str
|
||||
|
||||
def to_payload(self, fresh: bool = True) -> dict:
|
||||
return {
|
||||
"type": "fix",
|
||||
"lat": self.lat,
|
||||
"lon": self.lon,
|
||||
"alt": self.alt,
|
||||
"timestamp": self.timestamp,
|
||||
"source": self.source,
|
||||
"fresh": fresh,
|
||||
}
|
||||
|
||||
|
||||
def move_point(lat: float, lon: float, distance_m: float, bearing_deg: float) -> tuple[float, float]:
|
||||
lat_rad = math.radians(lat)
|
||||
lon_rad = math.radians(lon)
|
||||
bearing = math.radians(bearing_deg)
|
||||
angular_distance = distance_m / EARTH_RADIUS_M
|
||||
|
||||
new_lat = math.asin(
|
||||
math.sin(lat_rad) * math.cos(angular_distance)
|
||||
+ math.cos(lat_rad) * math.sin(angular_distance) * math.cos(bearing)
|
||||
)
|
||||
new_lon = lon_rad + math.atan2(
|
||||
math.sin(bearing) * math.sin(angular_distance) * math.cos(lat_rad),
|
||||
math.cos(angular_distance) - math.sin(lat_rad) * math.sin(new_lat),
|
||||
)
|
||||
return math.degrees(new_lat), math.degrees(new_lon)
|
||||
39
gnss/models/tiles.py
Normal file
39
gnss/models/tiles.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class TileSliceRequest(BaseModel):
|
||||
source: str
|
||||
destination: str | None = None
|
||||
min_zoom: int = Field(ge=0)
|
||||
max_zoom: int = Field(ge=0)
|
||||
resampling: str = "bilinear"
|
||||
tile_format: str = "png"
|
||||
web_mercator: bool = True
|
||||
extra_args: list[str] | None = None
|
||||
|
||||
@field_validator("tile_format")
|
||||
@classmethod
|
||||
def validate_format(cls, value: str) -> str:
|
||||
allowed = {"png", "jpeg", "jpg"}
|
||||
normalized = value.lower()
|
||||
if normalized not in allowed:
|
||||
raise ValueError(f"tile_format must be one of {allowed}")
|
||||
return normalized
|
||||
|
||||
@field_validator("max_zoom")
|
||||
@classmethod
|
||||
def validate_zoom(cls, v: int, info):
|
||||
min_zoom = info.data.get("min_zoom")
|
||||
if min_zoom is not None and v < min_zoom:
|
||||
raise ValueError("max_zoom must be >= min_zoom")
|
||||
return v
|
||||
|
||||
def source_path(self) -> Path:
|
||||
return Path(self.source).expanduser().resolve()
|
||||
|
||||
def destination_path(self, default: Path) -> Path:
|
||||
if self.destination:
|
||||
return Path(self.destination).expanduser().resolve()
|
||||
return default.resolve()
|
||||
|
|
@ -1,41 +1,14 @@
|
|||
import asyncio
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import AsyncIterator, Optional
|
||||
|
||||
import pynmea2
|
||||
import serial
|
||||
|
||||
from .gnss_utils import current_utc_time
|
||||
|
||||
|
||||
EARTH_RADIUS_M = 6_378_137
|
||||
|
||||
|
||||
@dataclass
|
||||
class GNSSPoint:
|
||||
lat: float
|
||||
lon: float
|
||||
alt: Optional[float]
|
||||
timestamp: str
|
||||
source: str
|
||||
|
||||
def to_payload(self, fresh: bool = True) -> dict:
|
||||
payload = {
|
||||
"type": "fix",
|
||||
"lat": self.lat,
|
||||
"lon": self.lon,
|
||||
"alt": self.alt,
|
||||
"timestamp": self.timestamp,
|
||||
"source": self.source,
|
||||
"fresh": fresh,
|
||||
}
|
||||
return payload
|
||||
from ..models.gnss import GNSSPoint, move_point
|
||||
from ..utils.time import current_utc_time
|
||||
|
||||
|
||||
class BaseGNSSSource:
|
||||
"""Общий интерфейс источника координат."""
|
||||
|
||||
async def start(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
|
@ -47,8 +20,6 @@ class BaseGNSSSource:
|
|||
|
||||
|
||||
class SerialNMEASource(BaseGNSSSource):
|
||||
"""Читает NMEA-строки с последовательного порта."""
|
||||
|
||||
def __init__(self, port: str, baudrate: int, timeout: float):
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
|
|
@ -90,27 +61,7 @@ class SerialNMEASource(BaseGNSSSource):
|
|||
return GNSSPoint(lat=lat, lon=lon, alt=alt, timestamp=current_utc_time(), source="serial")
|
||||
|
||||
|
||||
def _move_point(lat: float, lon: float, distance_m: float, bearing_deg: float) -> tuple[float, float]:
|
||||
"""Смещение точки на заданное расстояние и азимут (в градусах)."""
|
||||
lat_rad = math.radians(lat)
|
||||
lon_rad = math.radians(lon)
|
||||
bearing = math.radians(bearing_deg)
|
||||
angular_distance = distance_m / EARTH_RADIUS_M
|
||||
|
||||
new_lat = math.asin(
|
||||
math.sin(lat_rad) * math.cos(angular_distance)
|
||||
+ math.cos(lat_rad) * math.sin(angular_distance) * math.cos(bearing)
|
||||
)
|
||||
new_lon = lon_rad + math.atan2(
|
||||
math.sin(bearing) * math.sin(angular_distance) * math.cos(lat_rad),
|
||||
math.cos(angular_distance) - math.sin(lat_rad) * math.sin(new_lat),
|
||||
)
|
||||
return math.degrees(new_lat), math.degrees(new_lon)
|
||||
|
||||
|
||||
class SimulatedPathSource(BaseGNSSSource):
|
||||
"""Генерирует координаты по окружности вокруг указанной точки."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
center_lat: float,
|
||||
|
|
@ -131,7 +82,7 @@ class SimulatedPathSource(BaseGNSSSource):
|
|||
return None
|
||||
|
||||
async def next_point(self) -> Optional[GNSSPoint]:
|
||||
lat, lon = _move_point(self.center_lat, self.center_lon, self.radius_m, self._angle)
|
||||
lat, lon = move_point(self.center_lat, self.center_lon, self.radius_m, self._angle)
|
||||
self._angle = (self._angle + self.angle_step_deg) % 360.0
|
||||
return GNSSPoint(
|
||||
lat=lat,
|
||||
|
|
@ -150,8 +101,6 @@ def build_status_payload(status: str, detail: Optional[str] = None) -> dict:
|
|||
|
||||
|
||||
class GNSSService:
|
||||
"""Обёртка над источником, выдающая сообщения для WebSocket."""
|
||||
|
||||
def __init__(self, source: BaseGNSSSource, broadcast_interval: float = 1.0):
|
||||
self.source = source
|
||||
self.broadcast_interval = broadcast_interval
|
||||
79
gnss/services/tiles.py
Normal file
79
gnss/services/tiles.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import base64
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from fastapi.responses import FileResponse, Response
|
||||
|
||||
|
||||
DEFAULT_PLACEHOLDER_PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/6Xq3lYAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
|
||||
class TileCutError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class TileService:
|
||||
def __init__(self, tiles_dir: Path, placeholder: Path):
|
||||
self.tiles_dir = Path(tiles_dir)
|
||||
self.placeholder = Path(placeholder)
|
||||
|
||||
def ensure_tiles_dir(self) -> None:
|
||||
self.tiles_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _find_gdal2tiles(self) -> str:
|
||||
for name in ("gdal2tiles.py", "gdal2tiles"):
|
||||
found = shutil.which(name)
|
||||
if found:
|
||||
return found
|
||||
raise TileCutError("gdal2tiles not found in PATH. Install GDAL (e.g., apt install gdal-bin).")
|
||||
|
||||
def slice_to_tiles(
|
||||
self,
|
||||
source: Path,
|
||||
destination: Path,
|
||||
min_zoom: int,
|
||||
max_zoom: int,
|
||||
resampling: str = "bilinear",
|
||||
tile_format: str = "png",
|
||||
web_mercator: bool = True,
|
||||
extra_args: Optional[Iterable[str]] = None,
|
||||
) -> 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}")
|
||||
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
gdal2tiles = self._find_gdal2tiles()
|
||||
cmd = [
|
||||
gdal2tiles,
|
||||
"-z",
|
||||
f"{min_zoom}-{max_zoom}",
|
||||
"-r",
|
||||
resampling,
|
||||
"-f",
|
||||
tile_format.lower(),
|
||||
]
|
||||
if web_mercator:
|
||||
cmd += ["-p", "mercator"]
|
||||
if extra_args:
|
||||
cmd += list(extra_args)
|
||||
cmd += [str(source), str(destination)]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise TileCutError(f"gdal2tiles failed: {result.stderr or result.stdout}")
|
||||
return destination
|
||||
|
||||
def get_tile_response(self, zoom: int, x: int, y: int):
|
||||
tile_path = self.tiles_dir / str(zoom) / str(x) / f"{y}.png"
|
||||
if tile_path.is_file():
|
||||
return FileResponse(tile_path, media_type="image/png")
|
||||
if self.placeholder.is_file():
|
||||
return FileResponse(self.placeholder, media_type="image/png")
|
||||
return Response(content=DEFAULT_PLACEHOLDER_PNG, media_type="image/png")
|
||||
|
|
@ -2,5 +2,4 @@ from datetime import datetime, timezone
|
|||
|
||||
|
||||
def current_utc_time() -> str:
|
||||
"""Возвращает текущее UTC-время, для синхронизации с RTC"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
18
poetry.lock
generated
18
poetry.lock
generated
|
|
@ -1,4 +1,4 @@
|
|||
# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
|
|
@ -100,6 +100,20 @@ typing-extensions = ">=4.8.0"
|
|||
all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
|
||||
standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "gdal2tiles"
|
||||
version = "0.1.9"
|
||||
description = "A python library for generating map tiles based on gdal2tiles.py script."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "gdal2tiles-0.1.9.tar.gz", hash = "sha256:84b7ede6af85933501588a1238ba2214721d3c2a4a026f0b94e5eab992b86d42"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
billiard = ["billiard"]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
|
|
@ -830,4 +844,4 @@ files = [
|
|||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.9,<4.0"
|
||||
content-hash = "c8a21901fa2e58fd95fca1ba6bdcdcaa31d10e3df52a8d9ed138c985d2bb076a"
|
||||
content-hash = "c4ff58d446b5029eec92e43ce423659f61acd50c886dc19184cadf9bafa20b8d"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ pyserial = ">=3.5,<4.0"
|
|||
pynmea2 = ">=1.19.0,<2.0.0"
|
||||
python-dateutil = ">=2.9.0.post0,<3.0.0"
|
||||
uvicorn = {extras = ["standard"], version = ">=0.30.0,<0.31.0"}
|
||||
gdal2tiles = "^0.1.9"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue