Skip to content

Commit b7de141

Browse files
authored
feat: automated project creation option using image upload and EXIF scan to determine AOI (#802)
* feat: allow automated creation of drone-tm project based on scanned image exif Assisted by: Opus 4.6 LLM * fix: ensure created project aoi via import is square * fix: ensure import button is visible, fix project metrics logic after creation * fix: add additional validation checks on import, omit bad data from far outside aoi
1 parent e3b533a commit b7de141

8 files changed

Lines changed: 667 additions & 4 deletions

File tree

src/backend/app/arq/tasks.py

Lines changed: 412 additions & 3 deletions
Large diffs are not rendered by default.

src/backend/app/projects/classification_routes.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@
1212

1313
from drone_flightplan.drone_type import DroneType
1414

15-
from app.arq.tasks import get_redis_pool
15+
from app.arq.tasks import get_redis_pool, validate_s3_access
1616
from app.db import database
1717
from app.models.enums import HTTPStatus, State
1818
from app.images.image_classification import ImageClassifier
1919
from app.images.flight_gap_identification import identify_flight_gaps
20+
from app.projects import project_schemas
2021
from app.users.user_deps import login_required
2122
from app.users.user_schemas import AuthUser
2223
from app.waypoints.flightplan_output import (
@@ -204,6 +205,54 @@ async def ingest_existing_uploads(
204205
}
205206

206207

208+
@router.post("/project-from-imagery-exif/", tags=["Image Classification"])
209+
async def create_project_from_imagery_exif(
210+
body: project_schemas.ProjectFromImageryExifIn,
211+
redis: Annotated[ArqRedis, Depends(get_redis_pool)],
212+
user: Annotated[AuthUser, Depends(login_required)],
213+
):
214+
"""Create a drone-tm project by scanning EXIF GPS from a remote S3 path.
215+
216+
Lists JPEG files (depth <=3 from the given prefix) in a public S3-compatible
217+
bucket, extracts GPS via exiftool on the first ~128KB of each, builds a
218+
100m-buffered convex hull as the AOI, then creates the project with a 600m
219+
task split. Imagery transfer and ingestion are run separately afterwards
220+
(existing justfile + ingest endpoint).
221+
"""
222+
try:
223+
await validate_s3_access(body.endpoint, body.bucket_name, body.path)
224+
except ValueError as e:
225+
raise HTTPException(status_code=HTTPStatus.UNPROCESSABLE_ENTITY, detail=str(e))
226+
227+
job = await redis.enqueue_job(
228+
"create_project_from_imagery_exif",
229+
user.id,
230+
body.endpoint,
231+
body.bucket_name,
232+
body.path,
233+
body.project_name,
234+
_queue_name="default_queue",
235+
)
236+
237+
if job is None:
238+
raise HTTPException(
239+
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
240+
detail="Failed to enqueue project creation job",
241+
)
242+
243+
log.info(
244+
f"create_project_from_imagery_exif: queued job {job.job_id} "
245+
f"for user {user.id} ({body.bucket_name}/{body.path})"
246+
)
247+
248+
return {
249+
"message": (
250+
"Please be patient while the project is created in the background. "
251+
"For many images, this may take several hours."
252+
),
253+
}
254+
255+
207256
@router.get("/{project_id}/imagery/status/", tags=["Image Classification"])
208257
async def get_project_imagery_status(
209258
project_id: UUID,

src/backend/app/projects/project_logic.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,10 @@ async def process_task_metrics(db, tasks_data, project):
540540
gsd = project.gsd_cm_px
541541
altitude = project.altitude_from_ground
542542

543+
if altitude is None or gsd is None:
544+
task_updates.append((total_area_sqkm, None, None, task_id))
545+
continue
546+
543547
parameters = calculate_parameters(
544548
forward_overlap, side_overlap, altitude, gsd, 2
545549
)

src/backend/app/projects/project_schemas.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,31 @@ def enum_to_str(value: Union[IntEnum, str]) -> str:
100100
return value
101101

102102

103+
class ProjectFromImageryExifIn(BaseModel):
104+
"""Request body for creating a project by scanning EXIF GPS from a remote S3 path.
105+
106+
The endpoint, bucket, and path are kept as separate params so we work
107+
against any S3-compatible provider (AWS S3, Wasabi, MinIO, B2, etc).
108+
"""
109+
110+
endpoint: str = Field(
111+
...,
112+
description=(
113+
"S3-compatible host, e.g. 's3.amazonaws.com', "
114+
"'s3.us-west-1.wasabisys.com'. May include scheme; defaults to https."
115+
),
116+
)
117+
bucket_name: str = Field(..., description="Bucket containing the imagery")
118+
path: str = Field(
119+
"",
120+
description=(
121+
"Prefix within the bucket. Subdirectories are scanned up to 3 "
122+
"levels deep from this prefix."
123+
),
124+
)
125+
project_name: str = Field(..., min_length=1, description="Project name")
126+
127+
103128
class ProjectIn(BaseModel):
104129
"""Upload new project."""
105130

src/backend/app/tasks/task_splitter.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import logging
5+
import time
56
from pathlib import Path
67
from typing import Optional, Union
78

@@ -218,6 +219,18 @@ def splitBySquare(self, meters: int) -> FeatureCollection:
218219
shapely_transform(transformer_to_mercator.transform, self.aoi)
219220
)
220221
xmin, ymin, xmax, ymax = aoi_mercator.bounds
222+
est_cols = int((xmax - xmin) / meters) + 1
223+
est_rows = int((ymax - ymin) / meters) + 1
224+
log.debug(
225+
f"splitBySquare: AOI bounds {xmin:.0f},{ymin:.0f}{xmax:.0f},{ymax:.0f} (~{est_cols}×{est_rows} grid)"
226+
)
227+
_MAX_CELLS = 50_000
228+
if est_cols * est_rows > _MAX_CELLS:
229+
raise GeometryValidationError(
230+
f"AOI grid would require {est_cols * est_rows:,} cells at {meters}m - "
231+
"this is almost certainly caused by invalid GPS coordinates in the imagery. "
232+
f"AOI spans {(xmax - xmin) / 1000:.1f} km × {(ymax - ymin) / 1000:.1f} km."
233+
)
221234

222235
# Generate grid columns and rows based on AOI bounds and specified square length in meters
223236
def frange(start: float, stop: float, step: float):
@@ -235,6 +248,7 @@ def frange(start: float, stop: float, step: float):
235248
small_polygons = []
236249

237250
area_threshold = (meters**2) / 3
251+
_t0 = time.perf_counter()
238252

239253
# Create a grid of square cells in Web Mercator
240254
for x in cols[:-1]:
@@ -259,6 +273,12 @@ def frange(start: float, stop: float, step: float):
259273
else:
260274
polygons.append(clipped_polygon)
261275

276+
log.debug(
277+
f"splitBySquare: grid intersections done in {time.perf_counter() - _t0:.2f}s "
278+
f"({len(polygons)} full cells, {len(small_polygons)} slivers to merge)"
279+
)
280+
_t1 = time.perf_counter()
281+
262282
for small_polygon in small_polygons:
263283
while True:
264284
adjacent_polygons = [
@@ -307,6 +327,11 @@ def frange(start: float, stop: float, step: float):
307327
polygons.append(small_polygon)
308328
break
309329

330+
log.debug(
331+
f"splitBySquare: sliver merge done in {time.perf_counter() - _t1:.2f}s; "
332+
f"total {time.perf_counter() - _t0:.2f}s → {len(polygons)} final polygons"
333+
)
334+
310335
# Transform all polygons back to WGS84 for final output
311336
polygons_wgs84 = [
312337
shapely_transform(transformer_to_wgs84.transform, p)

src/frontend/src/routes/appRoutes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const TaskDescription = lazy(() => import("@Views/TaskDescription"));
1515
const UpdateUserProfile = lazy(() => import("@Views/UpdateUserProfile"));
1616
const RegulatorsApprovalPage = lazy(() => import("@Views/RegulatorsApprovalPage"));
1717
const Tutorials = lazy(() => import("@Views/Tutorial"));
18+
const ImportPage = lazy(() => import("@Views/Import"));
1819

1920
const appRoutes: IRoute[] = [
2021
...userRoutes,
@@ -90,6 +91,12 @@ const appRoutes: IRoute[] = [
9091
component: RegulatorsApprovalPage,
9192
authenticated: false,
9293
},
94+
{
95+
path: "/import",
96+
name: "Import Imagery",
97+
component: ImportPage,
98+
authenticated: true,
99+
},
93100
];
94101

95102
export default appRoutes;

src/frontend/src/services/classification.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,32 @@ export const ingestExistingUploads = async (
108108
return response.data;
109109
};
110110

111+
export interface ProjectFromImageryExifRequest {
112+
endpoint: string;
113+
bucket_name: string;
114+
path: string;
115+
project_name: string;
116+
}
117+
118+
/**
119+
* Submit a job to create a drone-tm project by scanning EXIF GPS from a remote
120+
* S3-compatible bucket. The backend handles listing, GPS extraction, AOI
121+
* computation, and project + task creation. Imagery transfer + ingest are
122+
* performed separately afterwards.
123+
*
124+
* Fire-and-forget: the backend runs the job in the background and may take
125+
* several hours for large datasets. Users can find the project on their
126+
* dashboard once it has been created.
127+
*/
128+
export const createProjectFromImageryExif = async (
129+
body: ProjectFromImageryExifRequest,
130+
): Promise<{ message: string }> => {
131+
const response = await authenticated(api).post(`/projects/project-from-imagery-exif/`, body, {
132+
headers: { "Content-Type": "application/json" },
133+
});
134+
return response.data;
135+
};
136+
111137
/**
112138
* Reset images stuck in 'classifying' state back to 'uploaded' so they can be re-classified.
113139
* Resets all images stuck in 'classifying' state back to 'uploaded'.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { useState } from "react";
2+
import { toast } from "react-toastify";
3+
4+
import { Button } from "@Components/RadixComponents/Button";
5+
import { FormControl, Input, Label } from "@Components/common/FormUI";
6+
import { createProjectFromImageryExif } from "@Services/classification";
7+
import hasErrorBoundary from "@Utils/hasErrorBoundary";
8+
9+
const Import = () => {
10+
const [endpoint, setEndpoint] = useState("s3.amazonaws.com");
11+
const [bucketName, setBucketName] = useState("");
12+
const [path, setPath] = useState("");
13+
const [projectName, setProjectName] = useState("");
14+
const [submitting, setSubmitting] = useState(false);
15+
const [submitted, setSubmitted] = useState(false);
16+
17+
const handleSubmit = async (e: React.FormEvent) => {
18+
e.preventDefault();
19+
if (!endpoint || !bucketName || !projectName) {
20+
toast.error("Endpoint, bucket name, and project name are required");
21+
return;
22+
}
23+
setSubmitting(true);
24+
try {
25+
await createProjectFromImageryExif({
26+
endpoint,
27+
bucket_name: bucketName,
28+
path,
29+
project_name: projectName,
30+
});
31+
setSubmitted(true);
32+
toast.success("Project creation job submitted");
33+
} catch (err: any) {
34+
const detail = err?.response?.data?.detail || err?.message || "Request failed";
35+
toast.error(detail);
36+
} finally {
37+
setSubmitting(false);
38+
}
39+
};
40+
41+
return (
42+
<section className="naxatw-flex naxatw-min-h-screen-nav naxatw-flex-col naxatw-items-center naxatw-px-4 naxatw-py-8 md:naxatw-px-16">
43+
<div className="naxatw-w-full naxatw-max-w-2xl naxatw-rounded-md naxatw-bg-white naxatw-p-6 naxatw-shadow-md">
44+
<h4 className="naxatw-mb-2 naxatw-font-bold">Import imagery from S3</h4>
45+
<p className="naxatw-mb-6 naxatw-text-body-md naxatw-text-grey-600">
46+
Point at a public S3-compatible bucket. We&apos;ll scan EXIF GPS from each JPEG
47+
(subdirectories up to 3 levels deep), build a buffered AOI, and create a drone-tm project.
48+
Imagery transfer and ingestion are run separately afterwards.
49+
</p>
50+
51+
<form onSubmit={handleSubmit} className="naxatw-flex naxatw-flex-col naxatw-gap-4">
52+
<FormControl>
53+
<Label>Project name</Label>
54+
<Input
55+
placeholder="My drone survey"
56+
value={projectName}
57+
onChange={(e) => setProjectName(e.target.value)}
58+
disabled={submitting}
59+
required
60+
/>
61+
</FormControl>
62+
63+
<FormControl>
64+
<Label>S3 endpoint</Label>
65+
<Input
66+
placeholder="s3.amazonaws.com"
67+
value={endpoint}
68+
onChange={(e) => setEndpoint(e.target.value)}
69+
disabled={submitting}
70+
required
71+
/>
72+
</FormControl>
73+
74+
<FormControl>
75+
<Label>Bucket name</Label>
76+
<Input
77+
placeholder="my-bucket"
78+
value={bucketName}
79+
onChange={(e) => setBucketName(e.target.value)}
80+
disabled={submitting}
81+
required
82+
/>
83+
</FormControl>
84+
85+
<FormControl>
86+
<Label>Path / prefix (optional)</Label>
87+
<Input
88+
placeholder="surveys/2025-01/site-a"
89+
value={path}
90+
onChange={(e) => setPath(e.target.value)}
91+
disabled={submitting}
92+
/>
93+
</FormControl>
94+
95+
<Button
96+
className="naxatw-bg-red naxatw-mt-2 naxatw-self-end"
97+
type="submit"
98+
disabled={submitting || !endpoint || !bucketName || !projectName}
99+
withLoader
100+
isLoading={submitting}
101+
>
102+
Create project
103+
</Button>
104+
</form>
105+
106+
{submitted && (
107+
<div className="naxatw-mt-6 naxatw-rounded-md naxatw-border naxatw-border-grey-200 naxatw-bg-grey-50 naxatw-p-4 naxatw-text-body-md">
108+
Please be patient while the project is created in the background. For many images, this
109+
may take several hours. The new project will appear on your projects list once it has
110+
been created.
111+
</div>
112+
)}
113+
</div>
114+
</section>
115+
);
116+
};
117+
118+
export default hasErrorBoundary(Import);

0 commit comments

Comments
 (0)