feat: stage6 — Image Pipeline (F05) and Rotation Manager (F06)

This commit is contained in:
Yuzviak
2026-03-22 22:51:00 +02:00
parent a2fb9ab404
commit 9ef046d623
9 changed files with 653 additions and 26 deletions
+204
View File
@@ -0,0 +1,204 @@
"""Image Input Pipeline (Component F05)."""
import asyncio
import os
import re
from datetime import datetime, timezone
import cv2
import numpy as np
from gps_denied.schemas.image import (
ImageBatch, ImageData, ImageMetadata, ProcessedBatch, ProcessingStatus, ValidationResult
)
class QueueFullError(Exception):
pass
class ValidationError(Exception):
pass
class ImageInputPipeline:
"""Manages ingestion, disk storage, and queuing of UAV image batches."""
def __init__(self, storage_dir: str = "image_storage", max_queue_size: int = 50):
self.storage_dir = storage_dir
# flight_id -> asyncio.Queue of ImageBatch
self._queues: dict[str, asyncio.Queue] = {}
self.max_queue_size = max_queue_size
# In-memory tracking (in a real system, sync this with DB)
self._status: dict[str, dict] = {}
def _get_queue(self, flight_id: str) -> asyncio.Queue:
if flight_id not in self._queues:
self._queues[flight_id] = asyncio.Queue(maxsize=self.max_queue_size)
return self._queues[flight_id]
def _init_status(self, flight_id: str):
if flight_id not in self._status:
self._status[flight_id] = {
"total_images": 0,
"processed_images": 0,
"current_sequence": 1,
}
def validate_batch(self, batch: ImageBatch) -> ValidationResult:
"""Validates batch integrity and sequence continuity."""
errors = []
num_images = len(batch.images)
if num_images < 10:
errors.append("Batch is empty")
elif num_images > 100:
errors.append("Batch too large")
if len(batch.filenames) != num_images:
errors.append("Mismatch between filenames and images count")
# Naming convention ADxxxxxx.jpg or similar
pattern = re.compile(r"^[A-Za-z0-9_-]+\.(jpg|jpeg|png)$", re.IGNORECASE)
for fn in batch.filenames:
if not pattern.match(fn):
errors.append(f"Invalid filename: {fn}")
break
if batch.start_sequence > batch.end_sequence:
errors.append("Start sequence greater than end sequence")
return ValidationResult(valid=len(errors) == 0, errors=errors)
def queue_batch(self, flight_id: str, batch: ImageBatch) -> bool:
"""Queues a batch of images for processing."""
val = self.validate_batch(batch)
if not val.valid:
raise ValidationError(f"Batch validation failed: {val.errors}")
q = self._get_queue(flight_id)
if q.full():
raise QueueFullError(f"Queue for flight {flight_id} is full")
q.put_nowait(batch)
self._init_status(flight_id)
self._status[flight_id]["total_images"] += len(batch.images)
return True
async def process_next_batch(self, flight_id: str) -> ProcessedBatch | None:
"""Dequeues and processing the next batch."""
q = self._get_queue(flight_id)
if q.empty():
return None
batch: ImageBatch = await q.get()
processed_images = []
for i, raw_bytes in enumerate(batch.images):
# Decode
nparr = np.frombuffer(raw_bytes, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if img is None:
continue # skip corrupted
seq = batch.start_sequence + i
fn = batch.filenames[i]
h, w = img.shape[:2]
meta = ImageMetadata(
sequence=seq,
filename=fn,
dimensions=(w, h),
file_size=len(raw_bytes),
timestamp=datetime.now(timezone.utc),
)
img_data = ImageData(
flight_id=flight_id,
sequence=seq,
filename=fn,
image=img,
metadata=meta
)
processed_images.append(img_data)
# Store to disk
self.store_images(flight_id, processed_images)
self._status[flight_id]["processed_images"] += len(processed_images)
q.task_done()
return ProcessedBatch(
images=processed_images,
batch_id=f"batch_{batch.batch_number}",
start_sequence=batch.start_sequence,
end_sequence=batch.end_sequence
)
def store_images(self, flight_id: str, images: list[ImageData]) -> bool:
"""Persists images to disk."""
flight_dir = os.path.join(self.storage_dir, flight_id)
os.makedirs(flight_dir, exist_ok=True)
for img in images:
path = os.path.join(flight_dir, img.filename)
cv2.imwrite(path, img.image)
return True
def get_next_image(self, flight_id: str) -> ImageData | None:
"""Gets the next image in sequence for processing."""
self._init_status(flight_id)
seq = self._status[flight_id]["current_sequence"]
img = self.get_image_by_sequence(flight_id, seq)
if img:
self._status[flight_id]["current_sequence"] += 1
return img
def get_image_by_sequence(self, flight_id: str, sequence: int) -> ImageData | None:
"""Retrieves a specific image by sequence number."""
# For simplicity, we assume filenames follow "frame_{sequence:06d}.jpg"
# But if the user uploaded custom files, we'd need a DB lookup.
# Let's use a local map for this prototype if it's strictly required,
# or search the directory.
flight_dir = os.path.join(self.storage_dir, flight_id)
if not os.path.exists(flight_dir):
return None
# search
for fn in os.listdir(flight_dir):
# very rough matching
if str(sequence) in fn or fn.endswith(f"_{sequence:06d}.jpg"):
path = os.path.join(flight_dir, fn)
img = cv2.imread(path)
if img is not None:
h, w = img.shape[:2]
meta = ImageMetadata(
sequence=sequence,
filename=fn,
dimensions=(w, h),
file_size=os.path.getsize(path),
timestamp=datetime.now(timezone.utc)
)
return ImageData(flight_id, sequence, fn, img, meta)
return None
def get_processing_status(self, flight_id: str) -> ProcessingStatus:
self._init_status(flight_id)
s = self._status[flight_id]
q = self._get_queue(flight_id)
return ProcessingStatus(
flight_id=flight_id,
total_images=s["total_images"],
processed_images=s["processed_images"],
current_sequence=s["current_sequence"],
queued_batches=q.qsize(),
processing_rate=0.0 # mock
)
+23 -18
View File
@@ -5,6 +5,8 @@ from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from gps_denied.core.pipeline import ImageInputPipeline
from gps_denied.core.results import ResultManager
from gps_denied.core.sse import SSEEventStreamer
from gps_denied.db.repository import FlightRepository
from gps_denied.schemas import GPSPoint
@@ -23,17 +25,20 @@ from gps_denied.schemas.flight import (
UserFixResponse,
Waypoint,
)
from gps_denied.schemas.image import ImageBatch
class FlightProcessor:
"""Orchestrates flight business logic."""
"""Manages business logic and background processing for flights."""
def __init__(self, repo: FlightRepository, sse: SSEEventStreamer) -> None:
self.repo = repo
self.sse = sse
def __init__(self, repository: FlightRepository, streamer: SSEEventStreamer) -> None:
self.repository = repository
self.streamer = streamer
self.result_manager = ResultManager(repository, streamer)
self.pipeline = ImageInputPipeline(storage_dir=".image_storage", max_queue_size=50)
async def create_flight(self, req: FlightCreateRequest) -> FlightResponse:
flight = await self.repo.insert_flight(
flight = await self.repository.insert_flight(
name=req.name,
description=req.description,
start_lat=req.start_gps.lat,
@@ -42,7 +47,7 @@ class FlightProcessor:
camera_params=req.camera_params.model_dump(),
)
for poly in req.geofences.polygons:
await self.repo.insert_geofence(
await self.repository.insert_geofence(
flight.id,
nw_lat=poly.north_west.lat,
nw_lon=poly.north_west.lon,
@@ -50,7 +55,7 @@ class FlightProcessor:
se_lon=poly.south_east.lon,
)
for w in req.rough_waypoints:
await self.repo.insert_waypoint(flight.id, lat=w.lat, lon=w.lon)
await self.repository.insert_waypoint(flight.id, lat=w.lat, lon=w.lon)
return FlightResponse(
flight_id=flight.id,
@@ -60,11 +65,11 @@ class FlightProcessor:
)
async def get_flight(self, flight_id: str) -> FlightDetailResponse | None:
flight = await self.repo.get_flight(flight_id)
flight = await self.repository.get_flight(flight_id)
if not flight:
return None
wps = await self.repo.get_waypoints(flight_id)
state = await self.repo.load_flight_state(flight_id)
wps = await self.repository.get_waypoints(flight_id)
state = await self.repository.load_flight_state(flight_id)
waypoints = [
Waypoint(
@@ -103,13 +108,13 @@ class FlightProcessor:
)
async def delete_flight(self, flight_id: str) -> DeleteResponse:
deleted = await self.repo.delete_flight(flight_id)
deleted = await self.repository.delete_flight(flight_id)
return DeleteResponse(deleted=deleted, flight_id=flight_id)
async def update_waypoint(
self, flight_id: str, waypoint_id: str, waypoint: Waypoint
) -> UpdateResponse:
ok = await self.repo.update_waypoint(
ok = await self.repository.update_waypoint(
flight_id,
waypoint_id,
lat=waypoint.lat,
@@ -126,7 +131,7 @@ class FlightProcessor:
failed = []
updated = 0
for wp in waypoints:
ok = await self.repo.update_waypoint(
ok = await self.repository.update_waypoint(
flight_id,
wp.id,
lat=wp.lat,
@@ -144,10 +149,10 @@ class FlightProcessor:
async def queue_images(
self, flight_id: str, metadata: BatchMetadata, file_count: int
) -> BatchResponse:
state = await self.repo.load_flight_state(flight_id)
state = await self.repository.load_flight_state(flight_id)
if state:
total = state.frames_total + file_count
await self.repo.save_flight_state(flight_id, frames_total=total, status="processing")
await self.repository.save_flight_state(flight_id, frames_total=total, status="processing")
next_seq = metadata.end_sequence + 1
seqs = list(range(metadata.start_sequence, metadata.end_sequence + 1))
@@ -159,13 +164,13 @@ class FlightProcessor:
)
async def handle_user_fix(self, flight_id: str, req: UserFixRequest) -> UserFixResponse:
await self.repo.save_flight_state(flight_id, blocked=False, status="processing")
await self.repository.save_flight_state(flight_id, blocked=False, status="processing")
return UserFixResponse(
accepted=True, processing_resumed=True, message="Fix applied."
)
async def get_flight_status(self, flight_id: str) -> FlightStatusResponse | None:
state = await self.repo.load_flight_state(flight_id)
state = await self.repository.load_flight_state(flight_id)
if not state:
return None
return FlightStatusResponse(
@@ -194,5 +199,5 @@ class FlightProcessor:
async def stream_events(self, flight_id: str, client_id: str):
"""Async generator for SSE stream."""
# Yield from the real SSE streamer generator
async for event in self.sse.stream_generator(flight_id, client_id):
async for event in self.streamer.stream_generator(flight_id, client_id):
yield event
+139
View File
@@ -0,0 +1,139 @@
"""Image Rotation Manager (Component F06)."""
import math
from datetime import datetime
from abc import ABC, abstractmethod
import cv2
import numpy as np
from gps_denied.schemas.rotation import HeadingHistory, RotationResult
from gps_denied.schemas.satellite import TileBounds
class IImageMatcher(ABC):
"""Dependency injection interface for Metric Refinement."""
@abstractmethod
def align_to_satellite(self, uav_image: np.ndarray, satellite_tile: np.ndarray, tile_bounds: TileBounds) -> RotationResult:
pass
class ImageRotationManager:
"""Handles 360-degree rotations, heading tracking, and sweeps."""
def __init__(self):
# flight_id -> HeadingHistory
self._history: dict[str, HeadingHistory] = {}
def _init_flight(self, flight_id: str):
if flight_id not in self._history:
self._history[flight_id] = HeadingHistory(flight_id=flight_id)
def rotate_image_360(self, image: np.ndarray, angle: float) -> np.ndarray:
"""Rotates an image by specified angle around center."""
if angle == 0.0 or angle == 360.0:
return image
h, w = image.shape[:2]
center = (w / 2, h / 2)
# Get rotation matrix. Negative angle for standard counter-clockwise interpretation in some math
# or positive for OpenCV's coordinate system.
matrix = cv2.getRotationMatrix2D(center, angle, 1.0)
rotated = cv2.warpAffine(
image, matrix, (w, h),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT,
borderValue=(0, 0, 0)
)
return rotated
def rotate_chunk_360(self, chunk_images: list[np.ndarray], angle: float) -> list[np.ndarray]:
"""Rotates all images in a chunk by the same angle."""
if angle == 0.0 or angle == 360.0:
return chunk_images
return [self.rotate_image_360(img, angle) for img in chunk_images]
def try_rotation_steps(
self,
flight_id: str,
frame_id: int,
image: np.ndarray,
satellite_tile: np.ndarray,
tile_bounds: TileBounds,
timestamp: datetime,
matcher: IImageMatcher
) -> RotationResult | None:
"""Performs 30° rotation sweep to find matching orientation."""
# 12 steps: 0, 30, 60... 330
for angle in range(0, 360, 30):
rotated = self.rotate_image_360(image, float(angle))
result = matcher.align_to_satellite(rotated, satellite_tile, tile_bounds)
if result.matched:
precise_angle = self.calculate_precise_angle(result.homography, float(angle))
result.precise_angle = precise_angle
result.initial_angle = float(angle)
self.update_heading(flight_id, frame_id, precise_angle, timestamp)
return result
return None
def calculate_precise_angle(self, homography: np.ndarray | None, initial_angle: float) -> float:
"""Calculates precise rotation angle from homography matrix."""
if homography is None:
return initial_angle
# Extract rotation angle from 2D affine component of homography
# h00, h01 = homography[0, 0], homography[0, 1]
# angle_delta = math.degrees(math.atan2(h01, h00))
# For simplicity in mock, just return initial
return initial_angle
def get_current_heading(self, flight_id: str) -> float | None:
"""Gets current UAV heading angle."""
self._init_flight(flight_id)
return self._history[flight_id].current_heading
def update_heading(self, flight_id: str, frame_id: int, heading: float, timestamp: datetime) -> bool:
"""Updates UAV heading angle."""
self._init_flight(flight_id)
# Normalize to 0-360
normalized = heading % 360.0
hist = self._history[flight_id]
hist.current_heading = normalized
hist.last_update = timestamp
hist.heading_history.append(normalized)
if len(hist.heading_history) > 10:
hist.heading_history.pop(0)
return True
def detect_sharp_turn(self, flight_id: str, new_heading: float) -> bool:
"""Detects if UAV made a sharp turn (>45°)."""
current = self.get_current_heading(flight_id)
if current is None:
return False
delta = abs(new_heading - current)
if delta > 180:
delta = 360 - delta
return delta > 45.0
def requires_rotation_sweep(self, flight_id: str) -> bool:
"""Determines if rotation sweep is needed for current frame."""
self._init_flight(flight_id)
hist = self._history[flight_id]
# First frame scenario
if hist.current_heading is None:
return True
return False