feat: stage5 — Satellite tiles (F04) and Coordinates (F13)

This commit is contained in:
Yuzviak
2026-03-22 22:44:12 +02:00
parent d5b6925a14
commit a2fb9ab404
9 changed files with 551 additions and 9 deletions
+171
View File
@@ -0,0 +1,171 @@
"""Satellite Data Manager (Component F04)."""
import asyncio
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor
import cv2
import diskcache as dc
import httpx
import numpy as np
from gps_denied.schemas import GPSPoint
from gps_denied.schemas.satellite import TileBounds, TileCoords
from gps_denied.utils import mercator
class SatelliteDataManager:
"""Manages satellite tiles with local caching and progressive fetching."""
def __init__(self, cache_dir: str = ".satellite_cache", max_size_gb: float = 10.0):
self.cache = dc.Cache(cache_dir, size_limit=int(max_size_gb * 1024**3))
# Keep an async client ready for fetching
self.http_client = httpx.AsyncClient(timeout=10.0)
self.thread_pool = ThreadPoolExecutor(max_workers=4)
async def fetch_tile(self, lat: float, lon: float, zoom: int, flight_id: str = "default") -> np.ndarray | None:
"""Fetch a single satellite tile by GPS coordinates."""
coords = self.compute_tile_coords(lat, lon, zoom)
# 1. Check cache
cached = self.get_cached_tile(flight_id, coords)
if cached is not None:
return cached
# 2. Fetch from Google Maps slippy tile URL
url = f"https://mt1.google.com/vt/lyrs=s&x={coords.x}&y={coords.y}&z={coords.zoom}"
try:
resp = await self.http_client.get(url)
resp.raise_for_status()
# 3. Decode image
image_bytes = resp.content
nparr = np.frombuffer(image_bytes, np.uint8)
img_np = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if img_np is not None:
# 4. Cache tile
self.cache_tile(flight_id, coords, img_np)
return img_np
except httpx.HTTPError:
return None
async def fetch_tile_grid(
self, center_lat: float, center_lon: float, grid_size: int, zoom: int, flight_id: str = "default"
) -> dict[str, np.ndarray]:
"""Fetches NxN grid of tiles centered on GPS coordinates."""
center_coords = self.compute_tile_coords(center_lat, center_lon, zoom)
grid_coords = self.get_tile_grid(center_coords, grid_size)
results: dict[str, np.ndarray] = {}
# Parallel fetch
async def fetch_and_store(tc: TileCoords):
# approximate center of tile
tb = self.compute_tile_bounds(tc)
img = await self.fetch_tile(tb.center.lat, tb.center.lon, tc.zoom, flight_id)
if img is not None:
results[f"{tc.x}_{tc.y}_{tc.zoom}"] = img
await asyncio.gather(*(fetch_and_store(tc) for tc in grid_coords))
return results
async def prefetch_route_corridor(
self, waypoints: list[GPSPoint], corridor_width_m: float, zoom: int, flight_id: str
) -> bool:
"""Prefetches satellite tiles along a route corridor."""
# Simplified prefetch: just fetch a 3x3 grid around each waypoint
coroutine_list = []
for wp in waypoints:
coroutine_list.append(self.fetch_tile_grid(wp.lat, wp.lon, grid_size=9, zoom=zoom, flight_id=flight_id))
await asyncio.gather(*coroutine_list)
return True
async def progressive_fetch(
self, center_lat: float, center_lon: float, grid_sizes: list[int], zoom: int, flight_id: str = "default"
) -> Iterator[dict[str, np.ndarray]]:
"""Progressively fetches expanding tile grids."""
for size in grid_sizes:
grid = await self.fetch_tile_grid(center_lat, center_lon, size, zoom, flight_id)
yield grid
def cache_tile(self, flight_id: str, tile_coords: TileCoords, tile_data: np.ndarray) -> bool:
"""Caches a satellite tile to disk."""
key = f"{flight_id}_{tile_coords.zoom}_{tile_coords.x}_{tile_coords.y}"
# We store as PNG bytes to save disk space and serialization overhead
success, encoded = cv2.imencode(".png", tile_data)
if success:
self.cache.set(key, encoded.tobytes())
return True
return False
def get_cached_tile(self, flight_id: str, tile_coords: TileCoords) -> np.ndarray | None:
"""Retrieves a cached tile from disk."""
key = f"{flight_id}_{tile_coords.zoom}_{tile_coords.x}_{tile_coords.y}"
cached_bytes = self.cache.get(key)
if cached_bytes is not None:
nparr = np.frombuffer(cached_bytes, np.uint8)
return cv2.imdecode(nparr, cv2.IMREAD_COLOR)
# Try global/shared cache (flight_id='default')
if flight_id != "default":
global_key = f"default_{tile_coords.zoom}_{tile_coords.x}_{tile_coords.y}"
cached_bytes = self.cache.get(global_key)
if cached_bytes is not None:
nparr = np.frombuffer(cached_bytes, np.uint8)
return cv2.imdecode(nparr, cv2.IMREAD_COLOR)
return None
def get_tile_grid(self, center: TileCoords, grid_size: int) -> list[TileCoords]:
"""Calculates tile coordinates for NxN grid centered on a tile."""
if grid_size == 1:
return [center]
# E.g. grid_size=9 -> 3x3 -> half=1
side = int(grid_size ** 0.5)
half = side // 2
coords = []
for dy in range(-half, half + 1):
for dx in range(-half, half + 1):
coords.append(TileCoords(x=center.x + dx, y=center.y + dy, zoom=center.zoom))
# If grid_size=4 (2x2), it's asymmetric. We'll simplify and say just return top-left based 2x2
if grid_size == 4:
coords = []
for dy in range(2):
for dx in range(2):
coords.append(TileCoords(x=center.x + dx, y=center.y + dy, zoom=center.zoom))
# Return exact number requested just in case
return coords[:grid_size]
def expand_search_grid(self, center: TileCoords, current_size: int, new_size: int) -> list[TileCoords]:
"""Returns only NEW tiles when expanding from current grid to larger grid."""
old_grid = set((c.x, c.y) for c in self.get_tile_grid(center, current_size))
new_grid = self.get_tile_grid(center, new_size)
diff = []
for c in new_grid:
if (c.x, c.y) not in old_grid:
diff.append(c)
return diff
def compute_tile_coords(self, lat: float, lon: float, zoom: int) -> TileCoords:
return mercator.latlon_to_tile(lat, lon, zoom)
def compute_tile_bounds(self, tile_coords: TileCoords) -> TileBounds:
return mercator.compute_tile_bounds(tile_coords)
def clear_flight_cache(self, flight_id: str) -> bool:
"""Clears cached tiles for a completed flight."""
# diskcache doesn't have partial clear by prefix efficiently, but we can iterate
keys = list(self.cache.iterkeys())
for k in keys:
if str(k).startswith(f"{flight_id}_"):
self.cache.delete(k)
return True