Skip to content
908 changes: 653 additions & 255 deletions src/backend/app/images/flight_gap_identification.py

Large diffs are not rendered by default.

100 changes: 100 additions & 0 deletions src/backend/app/projects/classification_routes.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
from datetime import datetime
from typing import Annotated, Optional
from uuid import UUID
Expand All @@ -8,12 +9,19 @@
from psycopg import Connection
from pydantic import BaseModel

from drone_flightplan.drone_type import DroneType

from app.arq.tasks import get_redis_pool
from app.db import database
from app.models.enums import HTTPStatus, State
from app.images.image_classification import ImageClassifier
from app.images.flight_gap_identification import identify_flight_gaps
from app.users.user_deps import login_required
from app.users.user_schemas import AuthUser
from app.waypoints.flightplan_output import (
build_flightplan_download_response,
get_flightplan_output_config,
)


router = APIRouter(
Expand All @@ -37,6 +45,11 @@ class ClassificationStatusResponse(BaseModel):
images: list[dict]


class FlightGapDetectionRequest(BaseModel):
manual_gap_polygons: dict | None = None
drone_type: DroneType | None = None


@router.get("/{project_id}/latest-batch/", tags=["Image Classification"])
async def get_latest_batch(
project_id: UUID,
Expand Down Expand Up @@ -677,3 +690,90 @@ async def mark_task_verified(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Failed to mark task as verified: {e}",
)


@router.post(
"/{project_id}/imagery/task/{task_id}/find-gaps/",
tags=["Image Classification"],
)
async def detect_task_flight_gaps(
project_id: UUID,
task_id: UUID,
db: Annotated[Connection, Depends(database.get_db)],
user: Annotated[AuthUser, Depends(login_required)],
request: FlightGapDetectionRequest | None = None,
):
"""Conduct flight gap analysis across all uploaded imagery for a task."""
manual_gap_polygons = request.manual_gap_polygons if request else None
drone_type_override = request.drone_type if request else None
result = await identify_flight_gaps(
db,
project_id,
task_id,
manual_gap_polygons,
drone_type_override=drone_type_override,
)

if not result:
raise HTTPException(
status_code=404,
detail="Could not perform flight gap analysis for this task.",
)

drone_type = result.get("drone_type")
drone_type_value = drone_type.value if isinstance(drone_type, DroneType) else None

flightplan_url = None

if result.get("kmz_bytes") and drone_type_value:
flightplan_config = get_flightplan_output_config(drone_type)
file_path = f"/tmp/reflight_{task_id}{flightplan_config['suffix']}"
with open(file_path, "wb") as f:
f.write(result["kmz_bytes"])
flightplan_url = (
f"/api/projects/tasks/{task_id}/{drone_type_value}/download-reflight-plan/"
f"?project_id={project_id}"
)

return {
"task_id": str(task_id),
"message": result.get("message"),
"task_geometry": result.get("task_geometry"),
"gap_polygons": result.get("gap_polygons"),
"drone_type": drone_type_value,
"images": result.get("images"),
"flightplan_url": flightplan_url,
}


@router.get(
"/tasks/{task_id}/{drone_type}/download-reflight-plan/",
tags=["Image Classification"],
)
async def download_reflight_plan(project_id: UUID, task_id: UUID, drone_type: str):
"""Download a reconstructed flight plan based on identified flight gaps."""

try:
drone_model = drone_type.upper().replace(" ", "_")
flight_drone_type = DroneType(drone_model)
except Exception:
log.error(f"Could not find drone type {drone_type}")
raise HTTPException(
status_code=400, detail=f"Unsupported drone type: {drone_type}"
)

flightplan_config = get_flightplan_output_config(flight_drone_type)
file_path = f"/tmp/reflight_{task_id}{flightplan_config['suffix']}"

if not os.path.exists(file_path):
log.error(f"Flight plan file not found: {file_path}")
raise HTTPException(
status_code=404,
detail="Flight plan file not found. Please run 'Find Gaps' again.",
)

return build_flightplan_download_response(
file_path,
drone_type=flight_drone_type,
filename_stem=f"reflight_task_{task_id}_{drone_type}_project_{project_id}",
)
54 changes: 54 additions & 0 deletions src/backend/app/waypoints/flightplan_output.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from fastapi import HTTPException
from fastapi.responses import FileResponse

from drone_flightplan.drone_type import DRONE_PARAMS, DroneType


FLIGHTPLAN_OUTPUTS = {
"DJI_WMPL": {
"suffix": ".kmz",
"media_type": "application/vnd.google-earth.kmz",
},
"POTENSIC_SQLITE": {
"suffix": ".db",
"media_type": "application/vnd.sqlite3",
},
"POTENSIC_JSON": {
"suffix": ".zip",
"media_type": "application/zip",
},
"QGROUNDCONTROL": {
"suffix": ".plan",
"media_type": "application/json",
},
"LITCHI": {
"suffix": ".csv",
"media_type": "text/csv",
},
}


def get_flightplan_output_config(drone_type: DroneType) -> dict:
"""Look up the output file metadata for a drone type."""
output_format = DRONE_PARAMS[drone_type].get("OUTPUT_FORMAT")
config = FLIGHTPLAN_OUTPUTS.get(output_format)
if config is None:
raise HTTPException(
status_code=400,
detail=f"Unsupported output format / drone type: {output_format}",
)
return config


def build_flightplan_download_response(
outpath: str,
drone_type: DroneType,
filename_stem: str,
):
"""Wrap a generated flightplan file in the correct download response."""
config = get_flightplan_output_config(drone_type)
return FileResponse(
outpath,
media_type=config["media_type"],
filename=f"{filename_stem}{config['suffix']}",
)
63 changes: 11 additions & 52 deletions src/backend/app/waypoints/waypoint_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
create_placemarks,
create_waypoint,
)
from drone_flightplan.drone_type import DroneType, DRONE_PARAMS
from drone_flightplan.drone_type import DroneType
from drone_flightplan.enums import GimbalAngle, FlightMode
from drone_flightplan.output.dji import create_wpml

Expand All @@ -32,6 +32,7 @@
)
from app.utils import merge_multipolygon, calculate_flight_time_from_placemarks
from app.waypoints import waypoint_schemas
from app.waypoints.flightplan_output import build_flightplan_download_response
from app.waypoints.waypoint_logic import (
check_point_within_buffer,
)
Expand Down Expand Up @@ -183,55 +184,11 @@ async def get_task_flightplan(

# If the user needs a download, wrap in correct response
if download:
output_format = DRONE_PARAMS[drone_type].get("OUTPUT_FORMAT")

if output_format == "DJI_WMPL":
return FileResponse(
outpath,
media_type="application/vnd.google-earth.kmz",
filename=(
f"task-{project_task_index}-{mode.name}-project-{project_id}.kmz"
),
)

elif output_format == "POTENSIC_SQLITE":
return FileResponse(
outpath,
media_type="application/vnd.sqlite3",
filename="map.db",
)

elif output_format == "POTENSIC_JSON":
return FileResponse(
outpath,
media_type="application/zip",
filename=(
f"task-{project_task_index}-{mode.name}-project-{project_id}.zip"
),
)

elif output_format == "QGROUNDCONTROL":
return FileResponse(
outpath,
media_type="application/json",
filename=(
f"task-{project_task_index}-{mode.name}-project-{project_id}.plan"
),
)

elif output_format == "LITCHI":
return FileResponse(
outpath,
media_type="text/csv",
filename=(
f"task-{project_task_index}-{mode.name}-project-{project_id}.csv"
),
)

else:
msg = f"Unsupported output format / drone type: {output_format}"
log.error(msg)
raise HTTPException(status_code=400, detail=msg)
return build_flightplan_download_response(
outpath,
drone_type=drone_type,
filename_stem=f"task-{project_task_index}-{mode.name}-project-{project_id}",
)

# If not downloading, re-create placemarks for metadata calcs,
# as create_flightplan handles placemarks internally
Expand Down Expand Up @@ -383,8 +340,10 @@ async def generate_wmpl_kmz(
drone_type=drone_type,
)

return FileResponse(
output_file, media_type="application/zip", filename="output.kmz"
return build_flightplan_download_response(
output_file,
drone_type=drone_type,
filename_stem="output",
)


Expand Down
Loading
Loading