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

View file

@ -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

View file

@ -1,2 +1,2 @@
"""GNSS backend package."""
from .app import app
from .main import app

3
gnss/api/dependencies.py Normal file
View file

@ -0,0 +1,3 @@
from .state import get_gnss_service, get_tile_service
__all__ = ["get_gnss_service", "get_tile_service"]

View 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
View 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
View 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
View 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
View 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

View file

@ -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
View 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
View 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
View 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()

View file

@ -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
View 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")

View file

@ -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
View file

@ -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"

View file

@ -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"]