diff --git a/README.md b/README.md new file mode 100644 index 0000000..53f19bc --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# GPS-Denied Onboard + +Сервіс геолокалізації знімків БПЛА в умовах відсутності GPS-сигналу. + +Система використовує візуальну одометрію (VO), співставлення з супутниковими картами (cross-view matching) та оптимізацію траєкторії через фактор-графи для визначення координат дрона в реальному часі. + +## Стек + +| Компонент | Технологія | +|-----------|------------| +| API | FastAPI + Pydantic v2 | +| Стрім подій | SSE (sse-starlette) | +| БД | SQLite + SQLAlchemy 2 + Alembic | +| Граф поз | GTSAM (Python) | +| Карти | Google Maps API (Strategy-патерн) | + +## Швидкий старт + +```bash +# Клонувати та перейти в гілку stage1 +git clone https://github.com/azaion/gps-denied-onboard.git +cd gps-denied-onboard +git checkout stage1 + +# Створити віртуальне середовище +python3 -m venv .venv +source .venv/bin/activate + +# Встановити залежності +pip install -e ".[dev]" + +# Запустити сервер +python -m gps_denied +``` + +Сервер стартує на `http://127.0.0.1:8000`. Health check: `GET /health`. + +## Тести + +```bash +python -m pytest tests/ -v +``` + +## Структура проєкту + +``` +gps-denied-onboard/ +├── src/gps_denied/ # Основний пакет +│ ├── __init__.py +│ ├── __main__.py # Entry point (uvicorn) +│ └── app.py # FastAPI application +├── tests/ # Тести +├── docs-Lokal/ # Локальна документація та план +├── _docs/ # Архітектурна документація +├── pyproject.toml # Залежності та конфігурація +└── .gitignore +``` + +## Ліцензія + +Приватний репозиторій. Усі права захищено. diff --git a/docs-Lokal/LOCAL_EXECUTION_PLAN.md b/docs-Lokal/LOCAL_EXECUTION_PLAN.md index 0ae48c3..5e07a0a 100644 --- a/docs-Lokal/LOCAL_EXECUTION_PLAN.md +++ b/docs-Lokal/LOCAL_EXECUTION_PLAN.md @@ -54,10 +54,10 @@ ## 5. Поетапний план виконання (детально) -### Етап 0 — Ініціалізація репозиторію коду (локально) -- Створити структуру пакета, `pyproject.toml`, залежності. -- Налаштувати `.gitignore`: `.env`, `data/`, `weights/`, `__pycache__`, `*.db`. -- **Перевірка:** порожній сервіс запускається, health endpoint працює. +### Етап 0 — Ініціалізація репозиторію коду (локально) ✅ +- Створено структуру пакета `src/gps_denied/`, `pyproject.toml`, залежності. +- Налаштовано `.gitignore`: `.env`, `data/`, `weights/`, `__pycache__`, `*.db`. +- **Результат:** сервіс запускається, health endpoint працює, тест пройдено. ### Етап 0.5 — Підготовка даних та моделей (Data Provisioning) - **Токени:** Зафіксувати Google Maps API key у `.env`. diff --git a/src/gps_denied/config.py b/src/gps_denied/config.py new file mode 100644 index 0000000..a2dd04d --- /dev/null +++ b/src/gps_denied/config.py @@ -0,0 +1,121 @@ +"""Application configuration loaded from environment / YAML.""" + +from __future__ import annotations + +from pathlib import Path + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class DatabaseConfig(BaseSettings): + """Database connection settings.""" + + model_config = SettingsConfigDict(env_prefix="DB_") + + url: str = Field( + default="sqlite+aiosqlite:///./flight_data.db", + description="SQLAlchemy async connection URL", + ) + echo: bool = False + + +class APIConfig(BaseSettings): + """API server settings.""" + + model_config = SettingsConfigDict(env_prefix="API_") + + host: str = "127.0.0.1" + port: int = 8000 + reload: bool = True + cors_origins: list[str] = Field(default_factory=lambda: ["*"]) + + +class TileProviderConfig(BaseSettings): + """Satellite tile provider settings (Google Maps by default).""" + + model_config = SettingsConfigDict(env_prefix="TILES_") + + provider: str = "google" + api_key: str = "" + cache_dir: Path = Path("tile_cache") + zoom_level: int = 18 + max_concurrent_requests: int = 4 + + +class ModelPaths(BaseSettings): + """Paths to ML model weights.""" + + model_config = SettingsConfigDict(env_prefix="MODEL_") + + weights_dir: Path = Path("weights") + superpoint_path: str = "superpoint.pt" + lightglue_path: str = "lightglue.pt" + litesam_path: str = "litesam.pt" + + +class OperationalArea(BaseSettings): + """Default operational boundaries.""" + + model_config = SettingsConfigDict(env_prefix="AREA_") + + name: str = "Eastern Ukraine" + min_lat: float = 45.0 + max_lat: float = 52.0 + min_lon: float = 22.0 + max_lon: float = 40.0 + + +class RecoveryConfig(BaseSettings): + """Failure recovery and progressive search settings.""" + + model_config = SettingsConfigDict(env_prefix="RECOVERY_") + + search_grid_sizes: list[int] = Field(default_factory=lambda: [1, 4, 9, 16, 25]) + min_chunk_frames: int = 5 + max_chunk_frames: int = 20 + user_input_threshold_tiles: int = 25 + confidence_threshold_good: float = 0.7 + confidence_threshold_degraded: float = 0.5 + min_inlier_count_good: int = 50 + min_inlier_count_tracking: int = 20 + + +class RotationConfig(BaseSettings): + """Image rotation and heading tracking settings.""" + + model_config = SettingsConfigDict(env_prefix="ROTATION_") + + step_degrees: float = 30.0 + litesam_max_tolerance: float = 45.0 + sharp_turn_threshold: float = 45.0 + heading_history_size: int = 10 + confidence_threshold: float = 0.7 + + @property + def rotation_iterations(self) -> int: + return int(360 / self.step_degrees) + + +class AppSettings(BaseSettings): + """Root settings — aggregates all sub-configs.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + env_nested_delimiter="__", + extra="ignore", + ) + + db: DatabaseConfig = Field(default_factory=DatabaseConfig) + api: APIConfig = Field(default_factory=APIConfig) + tiles: TileProviderConfig = Field(default_factory=TileProviderConfig) + models: ModelPaths = Field(default_factory=ModelPaths) + area: OperationalArea = Field(default_factory=OperationalArea) + recovery: RecoveryConfig = Field(default_factory=RecoveryConfig) + rotation: RotationConfig = Field(default_factory=RotationConfig) + + +def get_settings() -> AppSettings: + """Singleton-like factory for application settings.""" + return AppSettings() diff --git a/src/gps_denied/schemas/__init__.py b/src/gps_denied/schemas/__init__.py new file mode 100644 index 0000000..65964a3 --- /dev/null +++ b/src/gps_denied/schemas/__init__.py @@ -0,0 +1,39 @@ +"""Domain models — GPS, Camera, Geometry.""" + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, Field + + +class GPSPoint(BaseModel): + """WGS-84 coordinate.""" + + lat: float = Field(..., ge=-90, le=90, description="Latitude") + lon: float = Field(..., ge=-180, le=180, description="Longitude") + + +class CameraParameters(BaseModel): + """Intrinsic camera parameters.""" + + focal_length: float = Field(..., gt=0, description="Focal length in mm") + sensor_width: float = Field(..., gt=0, description="Sensor width in mm") + sensor_height: float = Field(..., gt=0, description="Sensor height in mm") + resolution_width: int = Field(..., gt=0, description="Image width in pixels") + resolution_height: int = Field(..., gt=0, description="Image height in pixels") + principal_point: tuple[float, float] | None = None + distortion_coefficients: list[float] | None = None + + +class Polygon(BaseModel): + """Bounding box defined by NW and SE corners.""" + + north_west: GPSPoint + south_east: GPSPoint + + +class Geofences(BaseModel): + """Collection of geofence polygons.""" + + polygons: list[Polygon] = Field(default_factory=list) diff --git a/src/gps_denied/schemas/events.py b/src/gps_denied/schemas/events.py new file mode 100644 index 0000000..365a4c3 --- /dev/null +++ b/src/gps_denied/schemas/events.py @@ -0,0 +1,66 @@ +"""SSE event schemas.""" + +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Any + +from pydantic import BaseModel + +from gps_denied.schemas import GPSPoint + + +class SSEEventType(str, Enum): + """Server-Sent Event types emitted during flight processing.""" + + FRAME_PROCESSED = "frame_processed" + FRAME_REFINED = "frame_refined" + SEARCH_EXPANDED = "search_expanded" + USER_INPUT_NEEDED = "user_input_needed" + PROCESSING_BLOCKED = "processing_blocked" + FLIGHT_COMPLETED = "flight_completed" + + +class FrameProcessedEvent(BaseModel): + """Payload for frame_processed / frame_refined events.""" + + frame_id: int + gps: GPSPoint + altitude: float | None = None + confidence: float + heading: float | None = None + timestamp: datetime + + +class SearchExpandedEvent(BaseModel): + """Payload for search_expanded event.""" + + frame_id: int + grid_size: int + message: str | None = None + + +class UserInputNeededEvent(BaseModel): + """Payload for user_input_needed event.""" + + frame_id: int + reason: str + consecutive_failures: int = 0 + + +class FlightCompletedEvent(BaseModel): + """Payload for flight_completed event.""" + + frames_total: int + frames_processed: int + duration_seconds: float | None = None + + +class SSEMessage(BaseModel): + """Generic SSE message wrapper.""" + + event: SSEEventType + data: dict[str, Any] + id: str | None = None + retry: int | None = None diff --git a/src/gps_denied/schemas/flight.py b/src/gps_denied/schemas/flight.py new file mode 100644 index 0000000..063f4f4 --- /dev/null +++ b/src/gps_denied/schemas/flight.py @@ -0,0 +1,162 @@ +"""Schemas for Flight API requests and responses.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + +from gps_denied.schemas import CameraParameters, Geofences, GPSPoint + + +# --------------------------------------------------------------------------- +# Waypoint +# --------------------------------------------------------------------------- + +class Waypoint(BaseModel): + """Single waypoint with optional refinement flag.""" + + id: str + lat: float = Field(..., ge=-90, le=90) + lon: float = Field(..., ge=-180, le=180) + altitude: float | None = None + confidence: float = Field(..., ge=0, le=1) + timestamp: datetime + refined: bool = False + + +# --------------------------------------------------------------------------- +# Flight — Requests +# --------------------------------------------------------------------------- + +class FlightCreateRequest(BaseModel): + """POST /flights body.""" + + name: str = Field(..., min_length=1, max_length=255) + description: str = "" + start_gps: GPSPoint + rough_waypoints: list[GPSPoint] = Field(default_factory=list) + geofences: Geofences = Field(default_factory=Geofences) + camera_params: CameraParameters + altitude: float = Field(..., gt=0) + + +class UserFixRequest(BaseModel): + """POST /flights/{flightId}/user-fix body.""" + + frame_id: int = Field(..., ge=0) + uav_pixel: tuple[float, float] + satellite_gps: GPSPoint + + +class ObjectToGPSRequest(BaseModel): + """POST /flights/{flightId}/frames/{frameId}/object-to-gps body.""" + + pixel_x: float = Field(..., ge=0) + pixel_y: float = Field(..., ge=0) + + +# --------------------------------------------------------------------------- +# Flight — Responses +# --------------------------------------------------------------------------- + +class FlightResponse(BaseModel): + """Response after flight creation.""" + + flight_id: str + status: str + message: str | None = None + created_at: datetime + + +class FlightDetailResponse(BaseModel): + """GET /flights/{flightId} response.""" + + flight_id: str + name: str + description: str + start_gps: GPSPoint + waypoints: list[Waypoint] = Field(default_factory=list) + geofences: Geofences + camera_params: CameraParameters + altitude: float + status: str + frames_processed: int = 0 + frames_total: int = 0 + created_at: datetime + updated_at: datetime + + +class FlightStatusResponse(BaseModel): + """GET /flights/{flightId}/status response.""" + + status: str + frames_processed: int = 0 + frames_total: int = 0 + current_frame: int | None = None + current_heading: float | None = None + blocked: bool = False + search_grid_size: int | None = None + message: str | None = None + created_at: datetime + updated_at: datetime + + +class DeleteResponse(BaseModel): + deleted: bool + flight_id: str + + +class UpdateResponse(BaseModel): + updated: bool + waypoint_id: str + + +class BatchUpdateResponse(BaseModel): + success: bool + updated_count: int + failed_ids: list[str] = Field(default_factory=list) + errors: dict[str, str] | None = None + + +# --------------------------------------------------------------------------- +# Image Batch +# --------------------------------------------------------------------------- + +class BatchMetadata(BaseModel): + """Metadata accompanying an image batch upload.""" + + start_sequence: int = Field(..., ge=0) + end_sequence: int = Field(..., ge=0) + batch_number: int = Field(..., ge=0) + + +class BatchResponse(BaseModel): + """Response after batch upload.""" + + accepted: bool + sequences: list[int] = Field(default_factory=list) + next_expected: int = 0 + message: str | None = None + + +# --------------------------------------------------------------------------- +# User Fix +# --------------------------------------------------------------------------- + +class UserFixResponse(BaseModel): + accepted: bool + processing_resumed: bool = False + message: str | None = None + + +# --------------------------------------------------------------------------- +# Object → GPS +# --------------------------------------------------------------------------- + +class ObjectGPSResponse(BaseModel): + gps: GPSPoint + accuracy_meters: float + frame_id: int + pixel: tuple[float, float] diff --git a/tests/test_schemas.py b/tests/test_schemas.py new file mode 100644 index 0000000..e807fb9 --- /dev/null +++ b/tests/test_schemas.py @@ -0,0 +1,172 @@ +"""Tests for domain schemas and configuration.""" + +from datetime import datetime, timezone + +import pytest +from pydantic import ValidationError + +from gps_denied.config import AppSettings, get_settings +from gps_denied.schemas import CameraParameters, Geofences, GPSPoint, Polygon +from gps_denied.schemas.events import ( + FlightCompletedEvent, + FrameProcessedEvent, + SSEEventType, + SSEMessage, +) +from gps_denied.schemas.flight import ( + BatchMetadata, + BatchResponse, + FlightCreateRequest, + FlightDetailResponse, + FlightResponse, + FlightStatusResponse, + UserFixRequest, + Waypoint, +) + + +# ── GPSPoint ────────────────────────────────────────────────────────────── + +class TestGPSPoint: + def test_valid(self): + p = GPSPoint(lat=48.275, lon=37.385) + assert p.lat == 48.275 + + def test_lat_out_of_range(self): + with pytest.raises(ValidationError): + GPSPoint(lat=91, lon=0) + + def test_lon_out_of_range(self): + with pytest.raises(ValidationError): + GPSPoint(lat=0, lon=181) + + def test_serialization_roundtrip(self): + p = GPSPoint(lat=-12.5, lon=130.0) + assert GPSPoint.model_validate_json(p.model_dump_json()) == p + + +# ── CameraParameters ───────────────────────────────────────────────────── + +class TestCameraParameters: + def test_valid(self): + cam = CameraParameters( + focal_length=25, + sensor_width=23.5, + sensor_height=15.6, + resolution_width=6252, + resolution_height=4168, + ) + assert cam.resolution_width == 6252 + + def test_negative_focal_length(self): + with pytest.raises(ValidationError): + CameraParameters( + focal_length=-1, + sensor_width=23.5, + sensor_height=15.6, + resolution_width=6252, + resolution_height=4168, + ) + + +# ── FlightCreateRequest ────────────────────────────────────────────────── + +class TestFlightCreateRequest: + def test_minimal_valid(self): + req = FlightCreateRequest( + name="Test_Flight", + start_gps=GPSPoint(lat=48.275, lon=37.385), + camera_params=CameraParameters( + focal_length=25, + sensor_width=23.5, + sensor_height=15.6, + resolution_width=6252, + resolution_height=4168, + ), + altitude=400, + ) + assert req.name == "Test_Flight" + assert req.description == "" + assert req.geofences.polygons == [] + + def test_empty_name_rejected(self): + with pytest.raises(ValidationError): + FlightCreateRequest( + name="", + start_gps=GPSPoint(lat=0, lon=0), + camera_params=CameraParameters( + focal_length=25, + sensor_width=23.5, + sensor_height=15.6, + resolution_width=6252, + resolution_height=4168, + ), + altitude=400, + ) + + +# ── Waypoint ────────────────────────────────────────────────────────────── + +class TestWaypoint: + def test_valid(self): + wp = Waypoint( + id="wp_001", + lat=48.123, + lon=37.456, + confidence=0.95, + timestamp=datetime.now(tz=timezone.utc), + ) + assert wp.refined is False + + def test_confidence_out_of_range(self): + with pytest.raises(ValidationError): + Waypoint( + id="wp_001", + lat=48.123, + lon=37.456, + confidence=1.5, + timestamp=datetime.now(tz=timezone.utc), + ) + + +# ── SSE Events ──────────────────────────────────────────────────────────── + +class TestSSEEvents: + def test_event_types(self): + assert SSEEventType.FRAME_PROCESSED == "frame_processed" + assert SSEEventType.FLIGHT_COMPLETED == "flight_completed" + + def test_frame_event_serialization(self): + evt = FrameProcessedEvent( + frame_id=237, + gps=GPSPoint(lat=48.123, lon=37.456), + confidence=0.95, + timestamp=datetime.now(tz=timezone.utc), + ) + data = evt.model_dump() + assert data["frame_id"] == 237 + + def test_sse_message(self): + msg = SSEMessage( + event=SSEEventType.FLIGHT_COMPLETED, + data={"frames_total": 100, "frames_processed": 100}, + ) + assert msg.event == "flight_completed" + + +# ── Configuration ───────────────────────────────────────────────────────── + +class TestConfig: + def test_defaults(self): + settings = get_settings() + assert "sqlite" in settings.db.url + assert settings.api.port == 8000 + assert settings.tiles.provider == "google" + + def test_rotation_iterations(self): + settings = get_settings() + assert settings.rotation.rotation_iterations == 12 # 360 / 30 + + def test_recovery_defaults(self): + settings = get_settings() + assert settings.recovery.search_grid_sizes == [1, 4, 9, 16, 25]