Architecture¶
DroneTM helps teams plan drone flights, collect imagery and process it into maps. The main web app is built with React and Vite, and talks to a FastAPI backend. Postgres/PostGIS stores the project data, S3 holds imagery and other files, and ARQ workers handle background jobs through a Dragonfly (Redis-compatible) queue.
flowchart TB
subgraph Clients
FE[React frontend]
FP["Flight planner (/plan)"]
MESH[3D viewer]
QF[QField plugin]
end
subgraph Backend
API[FastAPI] --> Q[("Dragonfly (Redis) queue")] --> W[ARQ worker]
end
subgraph Storage
DB[(Postgres/PostGIS)]
S3[(S3)]
end
ODM[ScaleODM]
OAM["OpenAerialMap (TiTiler)"]
FE -->|/api| API
API --> DB
W --> DB
W --> S3
FE -.->|presigned uploads/downloads| S3
MESH -.-> S3
QF -.->|project zip| S3
W -->|submit job| ODM
ODM -->|read images / write outputs| S3
ODM -.->|webhook| API
W -->|DEM crops| OAM
FP -->|DEM crops| OAM
Imagery moves directly between the browser and S3 using presigned URLs. ScaleODM also reads images from S3 and writes its processed results there. The API handles the metadata and controls access to these files.
In production:
- Uploads go through S3 Transfer Acceleration, using AWS's edge network for faster uploads from anywhere in the world.
- Downloads are served through CloudFront.
- The bucket uses S3 Intelligent-Tiering, so old imagery moves to cheaper storage automatically.
S3 layout, per project:
| Key | Contents |
|---|---|
projects/{project}/dem.tif |
Terrain model for flightplans |
projects/{project}/user-uploads/ |
Raw uploads, before they're sorted |
projects/{project}/{task}/images/ |
A task's imagery, ready for ODM |
projects/{project}/{task}/odm/ |
ODM outputs for one task |
projects/{project}/odm/ |
ODM outputs for the whole project |
publicuploads/cloudnative/{project}/ |
Web-ready COG and 3D Tiles |
For local development, Docker Compose runs the stack. Kubernetes deployments
use the Helm chart in chart/.
Code layout¶
| Path | Purpose |
|---|---|
src/backend/app/ |
API, background jobs and database migrations |
src/backend/packages/drone-flightplan/ |
Python flightplan generator used by the backend |
src/frontend/src/ |
Main web app |
src/flight-planner/ |
Offline browser flightplan generator at /plan |
src/qfield-plugin/ |
Offline flightplan generator for QField on mobile, sharing code with the browser planner |
src/drone-mesh/ |
three.js viewer for processed 3D models |
src/gcp-editor/ |
Ground control point editor web component |
Backend¶
The backend is organised into domain folders such as projects/ and tasks/,
each with the same files:
| File | Role |
|---|---|
*_routes.py |
HTTP endpoints, kept thin |
*_logic.py |
Business logic |
*_schemas.py |
Pydantic models, plus database methods |
*_deps.py |
FastAPI dependencies for the routes |
Database queries use raw SQL through the async psycopg pool in db/database.py.
Each request gets a single connection and transaction, with class_row mapping
the query results directly to Pydantic models. The SQLAlchemy models in
db/db_models.py are used only for Alembic migrations. Shared enums live in
db/enums.py.
Authentication uses HOT single sign-on via Hanko, in users/user_deps.py
(legacy auth using Google OAuth only is also possible for now).
Background jobs live in arq/tasks.py. WorkerSettings lists every job
function and the cron schedules.
Main workflows¶
Projects and tasks¶
A project contains task polygons, which can be generated by splitting an area
or uploaded by the user. As work progresses, each task's state comes from the
latest entry in the append-only task_events log. The states are described
in State in db/enums.py.
Flightplans¶
Flightplans can be generated by the backend, in QField or in the offline browser
planner. The backend uses the Python drone-flightplan package, while QField
and the browser planner share the JavaScript in qfield-plugin/generate/*.js.
The Python and JavaScript implementations need to stay in sync.
qfield-plugin/test_compare.py and flight-planner/tests/ check parity.
flowchart LR
PY["drone-flightplan (Python)"] --> BE[Backend] --> WEB[Web app]
JS["qfield-plugin/generate/*.js"] --> QF[QField]
JS --> FP["Flight planner (/plan)"]
PY <-.->|keep in sync| JS
For terrain following, the generators sample a project's digital elevation model (DEM). New projects enable terrain following regardless of the selected outputs, unless the user explicitly turns it off. By default the DEM is GLO-30 served through OpenAerialMap (TiTiler), with JAXA or a user-uploaded DEM as alternatives. QField bundles the DEM, while the browser planner caches it so it is available offline.
No-fly zones are cut out of tasks, then enforced again by both flight plan
generators (drone-flightplan, and the JavaScript port shared by QField and the
browser planner), which must stay in step. Waypoints within 20 m of a zone are
dropped, and a no-photo waypoint is added if the straight line home is blocked.
Generation then fails if any leg, there and back from takeoff, still comes within
20 m. A takeoff point is required, and pilots are warned if returning home early
would cross a zone, as that can't be planned around. QField receives zones as an attribute on each task, as the
packager drops unknown project variables, so projects must be re-exported to
pick them up. The browser planner receives them with its flight parameters.
Imagery¶
After a flight, users upload their images to S3. Workers read the metadata and check image quality, and the images are matched to tasks for the user to review. Once approved, the images go to ScaleODM for processing. The workflow then continues with COG and 3D Tiles conversion and a final upload to OpenAerialMap.
flowchart TD
A[Upload to S3] --> B[process_uploaded_image]
B --> C[classify_project_images]
C --> D[User review]
D --> E[move_task_images_for_processing]
E --> F[process_drone_images]
F --> G["COG + 3D Tiles (arq/cloudnative.py)"]
G --> H[OpenAerialMap]
The upload workflow describes this in more detail.
Processing status¶
To keep the project status up to date, reconcile_project_processing in
projects/project_logic.py checks for the output orthophoto in S3, then asks
ScaleODM (web API) whether the job failed or is still running. It runs when:
- ScaleODM sends a webhook.
- A project page is opened or refreshed.
- The 30-minute cron runs.
A separate nightly job resumes stalled image transfers.
Frontend¶
The frontend keeps server data in TanStack Query, with hooks in src/api/
calling the axios services in src/services/. UI state is held in Redux Toolkit
slices under src/store/slices/, and routes are defined in
src/routes/appRoutes.ts.
Maps use MapLibre with mapbox-gl-draw for drawing geometries. Uppy handles
multipart uploads to S3, and there is an experimental WebADB (src/utils/adb.ts)
implementation for transferring flightplans to controllers (but it requires
USB debugging to be enabled in Android dev settings...).
Each frontend part is built a little differently:
| Part | Built with |
|---|---|
| Main web app | React, TanStack Query, Redux Toolkit |
| Flight planner | Vanilla TypeScript + MapLibre, a standalone offline PWA |
| GCP editor | Lit, compiled to a framework-free web component |
| 3D viewer | Vanilla JS + three.js |
| QField plugin | QML + plain JS |
The web parts build with Vite, except the GCP editor (esbuild). The main app,
flight planner and GCP editor share @hotosm/ui web components.
For local setup, see Development Setup. For the reasoning behind these choices, see the architecture decisions.