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 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` в эту же директорию).
|
- Тайлы ожидаются по пути `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
|
- fastapi — REST и WebSocket
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
"""GNSS backend package."""
|
"""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 asyncio
|
||||||
import math
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import AsyncIterator, Optional
|
from typing import AsyncIterator, Optional
|
||||||
|
|
||||||
import pynmea2
|
import pynmea2
|
||||||
import serial
|
import serial
|
||||||
|
|
||||||
from .gnss_utils import current_utc_time
|
from ..models.gnss import GNSSPoint, move_point
|
||||||
|
from ..utils.time 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
|
|
||||||
|
|
||||||
|
|
||||||
class BaseGNSSSource:
|
class BaseGNSSSource:
|
||||||
"""Общий интерфейс источника координат."""
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
@ -47,8 +20,6 @@ class BaseGNSSSource:
|
||||||
|
|
||||||
|
|
||||||
class SerialNMEASource(BaseGNSSSource):
|
class SerialNMEASource(BaseGNSSSource):
|
||||||
"""Читает NMEA-строки с последовательного порта."""
|
|
||||||
|
|
||||||
def __init__(self, port: str, baudrate: int, timeout: float):
|
def __init__(self, port: str, baudrate: int, timeout: float):
|
||||||
self.port = port
|
self.port = port
|
||||||
self.baudrate = baudrate
|
self.baudrate = baudrate
|
||||||
|
|
@ -90,27 +61,7 @@ class SerialNMEASource(BaseGNSSSource):
|
||||||
return GNSSPoint(lat=lat, lon=lon, alt=alt, timestamp=current_utc_time(), source="serial")
|
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):
|
class SimulatedPathSource(BaseGNSSSource):
|
||||||
"""Генерирует координаты по окружности вокруг указанной точки."""
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
center_lat: float,
|
center_lat: float,
|
||||||
|
|
@ -131,7 +82,7 @@ class SimulatedPathSource(BaseGNSSSource):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def next_point(self) -> Optional[GNSSPoint]:
|
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
|
self._angle = (self._angle + self.angle_step_deg) % 360.0
|
||||||
return GNSSPoint(
|
return GNSSPoint(
|
||||||
lat=lat,
|
lat=lat,
|
||||||
|
|
@ -150,8 +101,6 @@ def build_status_payload(status: str, detail: Optional[str] = None) -> dict:
|
||||||
|
|
||||||
|
|
||||||
class GNSSService:
|
class GNSSService:
|
||||||
"""Обёртка над источником, выдающая сообщения для WebSocket."""
|
|
||||||
|
|
||||||
def __init__(self, source: BaseGNSSSource, broadcast_interval: float = 1.0):
|
def __init__(self, source: BaseGNSSSource, broadcast_interval: float = 1.0):
|
||||||
self.source = source
|
self.source = source
|
||||||
self.broadcast_interval = broadcast_interval
|
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:
|
def current_utc_time() -> str:
|
||||||
"""Возвращает текущее UTC-время, для синхронизации с RTC"""
|
|
||||||
return datetime.now(timezone.utc).isoformat()
|
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]]
|
[[package]]
|
||||||
name = "annotated-types"
|
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)"]
|
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)"]
|
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]]
|
[[package]]
|
||||||
name = "h11"
|
name = "h11"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
|
|
@ -830,4 +844,4 @@ files = [
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.1"
|
lock-version = "2.1"
|
||||||
python-versions = ">=3.9,<4.0"
|
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"
|
pynmea2 = ">=1.19.0,<2.0.0"
|
||||||
python-dateutil = ">=2.9.0.post0,<3.0.0"
|
python-dateutil = ">=2.9.0.post0,<3.0.0"
|
||||||
uvicorn = {extras = ["standard"], version = ">=0.30.0,<0.31.0"}
|
uvicorn = {extras = ["standard"], version = ">=0.30.0,<0.31.0"}
|
||||||
|
gdal2tiles = "^0.1.9"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue