From b2eaaccbb8c6414c533c15e675a1553eb7244119 Mon Sep 17 00:00:00 2001 From: "s.galanskiy" Date: Fri, 12 Dec 2025 15:34:27 +0500 Subject: [PATCH] Cutt maps for tiles. Clean project architecture --- README.md | 8 +- gnss/__init__.py | 2 +- gnss/api/dependencies.py | 3 + gnss/api/routes/config_route.py | 14 +++ gnss/api/routes/health.py | 14 +++ gnss/api/routes/tiles.py | 34 +++++++ gnss/api/routes/ws.py | 18 ++++ gnss/api/state.py | 25 +++++ gnss/app.py | 126 ------------------------ gnss/main.py | 70 +++++++++++++ gnss/models/gnss.py | 43 ++++++++ gnss/models/tiles.py | 39 ++++++++ gnss/{gnss_core.py => services/gnss.py} | 57 +---------- gnss/services/tiles.py | 79 +++++++++++++++ gnss/{gnss_utils.py => utils/time.py} | 1 - poetry.lock | 18 +++- pyproject.toml | 1 + 17 files changed, 367 insertions(+), 185 deletions(-) create mode 100644 gnss/api/dependencies.py create mode 100644 gnss/api/routes/config_route.py create mode 100644 gnss/api/routes/health.py create mode 100644 gnss/api/routes/tiles.py create mode 100644 gnss/api/routes/ws.py create mode 100644 gnss/api/state.py delete mode 100644 gnss/app.py create mode 100644 gnss/main.py create mode 100644 gnss/models/gnss.py create mode 100644 gnss/models/tiles.py rename gnss/{gnss_core.py => services/gnss.py} (68%) create mode 100644 gnss/services/tiles.py rename gnss/{gnss_utils.py => utils/time.py} (54%) diff --git a/README.md b/README.md index 6e0ea87..e566cdd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/gnss/__init__.py b/gnss/__init__.py index e0de528..3956632 100644 --- a/gnss/__init__.py +++ b/gnss/__init__.py @@ -1,2 +1,2 @@ """GNSS backend package.""" -from .app import app +from .main import app diff --git a/gnss/api/dependencies.py b/gnss/api/dependencies.py new file mode 100644 index 0000000..b20f33c --- /dev/null +++ b/gnss/api/dependencies.py @@ -0,0 +1,3 @@ +from .state import get_gnss_service, get_tile_service + +__all__ = ["get_gnss_service", "get_tile_service"] diff --git a/gnss/api/routes/config_route.py b/gnss/api/routes/config_route.py new file mode 100644 index 0000000..09dc487 --- /dev/null +++ b/gnss/api/routes/config_route.py @@ -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, + } diff --git a/gnss/api/routes/health.py b/gnss/api/routes/health.py new file mode 100644 index 0000000..87ceb56 --- /dev/null +++ b/gnss/api/routes/health.py @@ -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, + } diff --git a/gnss/api/routes/tiles.py b/gnss/api/routes/tiles.py new file mode 100644 index 0000000..034a773 --- /dev/null +++ b/gnss/api/routes/tiles.py @@ -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)} diff --git a/gnss/api/routes/ws.py b/gnss/api/routes/ws.py new file mode 100644 index 0000000..145a9c1 --- /dev/null +++ b/gnss/api/routes/ws.py @@ -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) diff --git a/gnss/api/state.py b/gnss/api/state.py new file mode 100644 index 0000000..f5a171e --- /dev/null +++ b/gnss/api/state.py @@ -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 diff --git a/gnss/app.py b/gnss/app.py deleted file mode 100644 index aa4cd6f..0000000 --- a/gnss/app.py +++ /dev/null @@ -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, - } diff --git a/gnss/main.py b/gnss/main.py new file mode 100644 index 0000000..26b8426 --- /dev/null +++ b/gnss/main.py @@ -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") diff --git a/gnss/models/gnss.py b/gnss/models/gnss.py new file mode 100644 index 0000000..ea7aad9 --- /dev/null +++ b/gnss/models/gnss.py @@ -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) diff --git a/gnss/models/tiles.py b/gnss/models/tiles.py new file mode 100644 index 0000000..56f1af6 --- /dev/null +++ b/gnss/models/tiles.py @@ -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() diff --git a/gnss/gnss_core.py b/gnss/services/gnss.py similarity index 68% rename from gnss/gnss_core.py rename to gnss/services/gnss.py index 3fd7674..84ec11a 100644 --- a/gnss/gnss_core.py +++ b/gnss/services/gnss.py @@ -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 diff --git a/gnss/services/tiles.py b/gnss/services/tiles.py new file mode 100644 index 0000000..be12085 --- /dev/null +++ b/gnss/services/tiles.py @@ -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") diff --git a/gnss/gnss_utils.py b/gnss/utils/time.py similarity index 54% rename from gnss/gnss_utils.py rename to gnss/utils/time.py index a35360c..db4c796 100644 --- a/gnss/gnss_utils.py +++ b/gnss/utils/time.py @@ -2,5 +2,4 @@ from datetime import datetime, timezone def current_utc_time() -> str: - """Возвращает текущее UTC-время, для синхронизации с RTC""" return datetime.now(timezone.utc).isoformat() diff --git a/poetry.lock b/poetry.lock index c53374c..02409d0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 7ae2aa4..79e34b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"]