From b2cb813f10a73ffbb91ee57cbfa14b51635178a9 Mon Sep 17 00:00:00 2001
From: shreya-hegde <78434422+shreya-hegde@users.noreply.github.com>
Date: Mon, 13 Jul 2026 13:53:14 +0530
Subject: [PATCH 1/3] Task image footprint coverage
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
drone_type.py: add FC9313 -> DJI_MINI_5_PRO.
image_footprints.py: new helper functions to calculate one image’s rectangle on the ground, build footprint GeoJSON, and compute its union coverage percentage.
image_classification.py: call image_footprints.py instead of circle ST_Buffer logic, then return coverage_percentage and image_footprints.
classification.ts: add image_footprints to the frontend response type.
TaskVerificationModal.tsx: draw image_footprints as thin rectangle outlines on the map. check if the overlap is too much/hard to see
test_flight_gap_detection.py: test FC9313 works
test_image_footprints: test the footprint code
---
.../app/images/image_classification.py | 170 ++++++------------
src/backend/app/images/image_footprints.py | 155 ++++++++++++++++
.../drone_flightplan/drone_type.py | 4 +-
.../tests/test_flight_gap_detection.py | 27 +++
src/backend/tests/test_image_footprints.py | 54 ++++++
.../TaskVerificationModal.tsx | 23 +++
src/frontend/src/services/classification.ts | 2 +
7 files changed, 315 insertions(+), 120 deletions(-)
create mode 100644 src/backend/app/images/image_footprints.py
create mode 100644 src/backend/tests/test_image_footprints.py
diff --git a/src/backend/app/images/image_classification.py b/src/backend/app/images/image_classification.py
index 52f6d649d..e09351a4e 100644
--- a/src/backend/app/images/image_classification.py
+++ b/src/backend/app/images/image_classification.py
@@ -22,6 +22,10 @@
derive_utc_datetime_from_exif,
solar_elevation_deg,
)
+from app.images.image_footprints import (
+ coverage_percentage_from_footprints,
+ image_footprints_feature_collection,
+)
from app.s3 import (
get_obj_from_bucket,
maybe_presign_s3_key,
@@ -2360,6 +2364,7 @@ async def get_task_verification_data_project(
raise ValueError(f"Task {task_id} not found in project {project_id}")
# Get ALL assigned images for this task across all batches
+ # Extra EXIF fields here feed the footprint rectangle helper.
images_query = """
SELECT
id,
@@ -2368,7 +2373,23 @@ async def get_task_verification_data_project(
thumbnail_url,
status,
rejection_reason,
- ST_AsGeoJSON(location)::json as location
+ ST_AsGeoJSON(location)::json as location,
+ -- Heading rotates the footprint to match the drone/camera direction.
+ NULLIF(exif->>'FlightYawDegree', '')::double precision AS yaw_deg,
+ -- Altitude sizes the footprint when project GSD is unavailable.
+ NULLIF(regexp_replace(
+ COALESCE(exif->>'AbsoluteAltitude',''),
+ '[^0-9+\\-.]+', '', 'g'
+ ), '')::double precision AS altitude_m,
+ -- Image dimensions keep the footprint aspect close to the photo.
+ COALESCE(
+ NULLIF(exif->>'ImageWidth', '')::double precision,
+ NULLIF(exif->>'ExifImageWidth', '')::double precision
+ ) AS image_width,
+ COALESCE(
+ NULLIF(exif->>'ImageHeight', '')::double precision,
+ NULLIF(exif->>'ExifImageHeight', '')::double precision
+ ) AS image_height
FROM project_images
WHERE task_id = %(task_id)s
AND project_id = %(project_id)s
@@ -2387,9 +2408,11 @@ async def get_task_verification_data_project(
)
images = await cur.fetchall()
- # Determine buffer radius from GSD + image dimensions, or altitude + FOV.
- buffer_radius = COVERAGE_BUFFER_METERS_FALLBACK
+ # Determine project-level GSD/altitude fallbacks for footprint estimation.
+ altitude = None
+ gsd = None
try:
+ # These are defaults used only when an image row lacks its own metadata.
async with db.cursor(row_factory=dict_row) as cur:
await cur.execute(
"""
@@ -2409,125 +2432,31 @@ async def get_task_verification_data_project(
if proj_row and proj_row.get("gsd_cm_px")
else None
)
-
- # Get average image dimensions from EXIF for this task
- async with db.cursor(row_factory=dict_row) as cur:
- await cur.execute(
- """
- SELECT
- AVG(COALESCE(
- (exif->>'ImageWidth')::double precision,
- (exif->>'ExifImageWidth')::double precision
- )) AS avg_w,
- AVG(COALESCE(
- (exif->>'ImageHeight')::double precision,
- (exif->>'ExifImageHeight')::double precision
- )) AS avg_h
- FROM project_images
- WHERE task_id = %(task_id)s
- AND project_id = %(project_id)s
- AND status = %(status)s
- AND location IS NOT NULL
- """,
- {
- "task_id": str(task_id),
- "project_id": str(project_id),
- "status": ImageStatus.ASSIGNED.value,
- },
- )
- dim_row = await cur.fetchone()
- avg_w = (
- float(dim_row["avg_w"])
- if dim_row and dim_row.get("avg_w")
- else None
- )
- avg_h = (
- float(dim_row["avg_h"])
- if dim_row and dim_row.get("avg_h")
- else None
- )
-
- if altitude is None:
- async with db.cursor(row_factory=dict_row) as cur:
- await cur.execute(
- """
- SELECT AVG(
- NULLIF(regexp_replace(
- COALESCE(exif->>'AbsoluteAltitude',''),
- '[^0-9+\\-.]+', '', 'g'
- ), '')::double precision
- ) AS avg_alt
- FROM project_images
- WHERE task_id = %(task_id)s
- AND project_id = %(project_id)s
- AND status = %(status)s
- AND location IS NOT NULL
- """,
- {
- "task_id": str(task_id),
- "project_id": str(project_id),
- "status": ImageStatus.ASSIGNED.value,
- },
- )
- alt_row = await cur.fetchone()
- if alt_row and alt_row.get("avg_alt"):
- altitude = float(alt_row["avg_alt"])
-
- buffer_radius = _coverage_buffer_radius(gsd, altitude, avg_w, avg_h)
except Exception as e:
- log.warning(f"Could not determine coverage buffer radius: {e}")
+ log.warning(f"Could not determine footprint metadata: {e}")
- # Calculate coverage using PostGIS with altitude-derived buffer
- coverage_query = """
- WITH image_points AS (
- SELECT location
- FROM project_images
- WHERE task_id = %(task_id)s
- AND project_id = %(project_id)s
- AND status = %(status)s
- AND location IS NOT NULL
- ),
- task_polygon AS (
- SELECT outline
- FROM tasks
- WHERE id = %(task_id)s
- ),
- buffered_points AS (
- SELECT ST_Union(
- ST_Buffer(location::geography, %(buffer_radius)s)::geometry
- ) as coverage
- FROM image_points
- )
- SELECT
- CASE
- WHEN (SELECT COUNT(*) FROM image_points) = 0 THEN 0
- ELSE LEAST(100, (
- ST_Area(
- ST_Intersection(
- (SELECT coverage FROM buffered_points),
- (SELECT outline FROM task_polygon)
- )::geography
- ) /
- NULLIF(ST_Area((SELECT outline FROM task_polygon)::geography), 0)
- ) * 100)
- END as coverage_percentage
- """
-
- coverage_percentage = 0
+ # Add footprint inputs to every image.
+ # This keeps the map outlines and the percentage using the same assumptions.
+ # Each image can still use its own EXIF altitude when present.
+ footprint_images = [
+ {
+ **dict(img),
+ "gsd_cm_px": gsd,
+ "altitude_m": img.get("altitude_m") or altitude,
+ }
+ for img in images
+ ]
+ # Turn rectangles into GeoJSON so the browser can draw them.
+ # This is the "hairline squares" part of the issue.
+ image_footprints = image_footprints_feature_collection(footprint_images)
+ coverage_percentage = 0.0
try:
- async with db.cursor(row_factory=dict_row) as cur:
- await cur.execute(
- coverage_query,
- {
- "task_id": str(task_id),
- "project_id": str(project_id),
- "status": ImageStatus.ASSIGNED.value,
- "buffer_radius": buffer_radius,
- },
- )
- coverage_result = await cur.fetchone()
- if coverage_result and coverage_result.get("coverage_percentage"):
- coverage_percentage = float(coverage_result["coverage_percentage"])
+ # Use those same rectangles to calculate the modal coverage percentage.
+ # Overlaps are unioned, so repeated coverage is counted once.
+ coverage_percentage = coverage_percentage_from_footprints(
+ task["geometry"],
+ footprint_images,
+ )
except Exception as e:
log.warning(f"Could not calculate coverage: {e}")
@@ -2563,6 +2492,7 @@ async def get_task_verification_data_project(
}
for img in images
],
+ # task boundary polygon
"task_geometry": {
"type": "Feature",
"geometry": task["geometry"],
@@ -2572,6 +2502,8 @@ async def get_task_verification_data_project(
},
},
"coverage_percentage": coverage_percentage,
+ # Frontend draws these as thin footprint outlines on the verification map.
+ "image_footprints": image_footprints,
"is_verified": is_verified,
}
diff --git a/src/backend/app/images/image_footprints.py b/src/backend/app/images/image_footprints.py
new file mode 100644
index 000000000..a9f9362b9
--- /dev/null
+++ b/src/backend/app/images/image_footprints.py
@@ -0,0 +1,155 @@
+import math
+
+import pyproj
+from shapely.affinity import rotate
+from shapely.geometry import Polygon, mapping, shape
+from shapely.ops import transform, unary_union
+
+
+# Convert lon/lat to meters for footprint and area math.
+# A degree is not a fixed ground distance, so we avoid area math in EPSG:4326.
+projector = pyproj.Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
+inverse_projector = pyproj.Transformer.from_crs(
+ "EPSG:3857", "EPSG:4326", always_xy=True
+)
+
+# Fallbacks when EXIF/project metadata is missing.
+DEFAULT_DIAGONAL_FOV_DEG = 82.1
+DEFAULT_IMAGE_WIDTH = 4000
+DEFAULT_IMAGE_HEIGHT = 3000
+
+
+def _as_float(value) -> float | None:
+ # EXIF values can be messey as strings, blanks, or missing.
+ # This function converts values into numbers or None.
+ if value is None:
+ return None
+
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return None
+
+
+def _footprint_size_meters(
+ gsd_cm_px: float | None,
+ altitude_m: float | None,
+ image_width: float | None,
+ image_height: float | None,
+) -> tuple[float, float] | None:
+ """Estimate the ground width/height covered by one image."""
+ # Prefer GSD: it tells us directly how much ground each pixel covers.
+ gsd_cm_px = _as_float(gsd_cm_px)
+ altitude_m = _as_float(altitude_m)
+ image_width = _as_float(image_width) or DEFAULT_IMAGE_WIDTH
+ image_height = _as_float(image_height) or DEFAULT_IMAGE_HEIGHT
+
+ if gsd_cm_px and gsd_cm_px > 0:
+ # Example: 2 cm/px * 4000 px / 100 = 80m on the ground.
+ return (
+ gsd_cm_px * image_width / 100,
+ gsd_cm_px * image_height / 100,
+ )
+
+ # If no GSD: altitude + camera FOV gives an approximate ground rectangle.
+ if altitude_m and altitude_m > 0:
+ # Keep the rectangle shaped like the image, usually 4:3.
+ aspect_ratio = image_width / image_height
+ diagonal_m = 2 * altitude_m * math.tan(
+ math.radians(DEFAULT_DIAGONAL_FOV_DEG) / 2
+ )
+ # Convert the estimated diagonal into width and height.
+ height_m = diagonal_m / math.sqrt(1 + aspect_ratio**2)
+ width_m = aspect_ratio * height_m
+ return width_m, height_m
+
+ return None
+
+
+def image_footprint_polygon(image: dict) -> Polygon | None:
+ """Build one image footprint as a yaw-rotated rectangle in meters."""
+ # No GPS point means no map footprint.
+ if not image.get("location"):
+ return None
+
+ # Calculate how big this image is on the ground.
+ # GSD preferred, altitude/FOV is the second choice.
+ footprint_size = _footprint_size_meters(
+ image.get("gsd_cm_px"),
+ image.get("altitude_m"),
+ image.get("image_width"),
+ image.get("image_height"),
+ )
+ if not footprint_size:
+ return None
+
+ # Build in meters first, then convert back to lon/lat only for display.
+ width_m, height_m = footprint_size
+ # Convert image GPS point into meter coordinates
+ center = transform(projector.transform, shape(image["location"]))
+ x, y = center.x, center.y
+ half_width = width_m / 2
+ half_height = height_m / 2
+
+ # Draw rectangle corners (not rotated)
+ footprint = Polygon(
+ [
+ (x - half_width, y - half_height),
+ (x + half_width, y - half_height),
+ (x + half_width, y + half_height),
+ (x - half_width, y + half_height),
+ (x - half_width, y - half_height),
+ ]
+ )
+
+ # Rotate it using drone/camera heading when EXIF has yaw.
+ yaw_deg = _as_float(image.get("yaw_deg"))
+ if yaw_deg is not None:
+ # Shapely rotates counter-clockwise from east; yaw is clockwise from north.
+ footprint = rotate(footprint, 90 - yaw_deg, origin=center)
+
+ return footprint
+
+
+def image_footprints_feature_collection(images: list[dict]) -> dict:
+ """Return map-ready GeoJSON outlines for all image footprints."""
+ # Frontend map layers need GeoJSON in lon/lat.
+ features = []
+ for image in images:
+ footprint = image_footprint_polygon(image)
+ if footprint is None:
+ continue
+
+ # Convert meters back to lon/lat. MapLibre expects GeoJSON coordinates in that.
+ footprint_wgs84 = transform(inverse_projector.transform, footprint)
+ features.append(
+ {
+ "type": "Feature",
+ "geometry": mapping(footprint_wgs84),
+ "properties": {"image_id": str(image["id"])},
+ }
+ )
+
+ return {"type": "FeatureCollection", "features": features}
+
+
+def coverage_percentage_from_footprints(
+ coverage_geometry: dict,
+ images: list[dict],
+) -> float:
+ """Calculate covered area percentage from unioned rectangular footprints."""
+ # Merge rectangles, clip to the task, then divide by task area.
+ target_m = transform(projector.transform, shape(coverage_geometry))
+ footprints = [
+ footprint
+ for footprint in (image_footprint_polygon(image) for image in images)
+ if footprint is not None
+ ]
+
+ if not footprints or target_m.is_empty or target_m.area <= 0:
+ return 0.0
+
+ # unary_union counts overlapping photo footprints only once
+ # photos outside the task area don't count
+ covered_m = unary_union(footprints).intersection(target_m)
+ return min(100.0, (covered_m.area / target_m.area) * 100)
diff --git a/src/backend/packages/drone-flightplan/drone_flightplan/drone_type.py b/src/backend/packages/drone-flightplan/drone_flightplan/drone_type.py
index f0e870157..a0acff71c 100644
--- a/src/backend/packages/drone-flightplan/drone_flightplan/drone_type.py
+++ b/src/backend/packages/drone-flightplan/drone_flightplan/drone_type.py
@@ -192,7 +192,9 @@ class DroneType(StrEnum):
# Mapping and keeping track of known FC codes / camera identifiers to drone types
CAMERA_MODEL_ALIASES = {
- "FC8482": DroneType.DJI_MINI_4_PRO
+ "FC8482": DroneType.DJI_MINI_4_PRO,
+ # DJI Mini 5 Pro photos can report the camera model as FC9313 in EXIF.
+ "FC9313": DroneType.DJI_MINI_5_PRO,
# Add more as we encounter them from supported drones
}
diff --git a/src/backend/tests/test_flight_gap_detection.py b/src/backend/tests/test_flight_gap_detection.py
index f09978866..6a58d1b45 100644
--- a/src/backend/tests/test_flight_gap_detection.py
+++ b/src/backend/tests/test_flight_gap_detection.py
@@ -222,6 +222,33 @@ async def test_gap_detection_falls_back_to_exif_drone_model(db, load_freetown_in
assert_is_valid_flightplan(result["kmz_bytes"])
+@pytest.mark.asyncio
+async def test_gap_detection_maps_fc9313_camera_alias(db, load_freetown_into_db):
+ # Load a task with real-ish gap data so flight-gap generation has something to do.
+ project_id, batch_id, task_id = await load_freetown_into_db(apply_gaps=True)
+
+ async with db.cursor() as cur:
+ # Remove the selected drone from the DB so the code must fall back to EXIF.
+ await cur.execute("DELETE FROM drone_flights WHERE task_id = %s", (task_id,))
+ await cur.execute(
+ """
+ UPDATE project_images
+ SET exif = exif || '{"Model":"FC9313"}'::jsonb
+ WHERE project_id = %s AND task_id = %s
+ """,
+ (project_id, task_id),
+ )
+ # Pretend every uploaded image came from a DJI camera that reports FC9313.
+ await db.commit()
+
+ result = await identify_flight_gaps(db, project_id, task_id)
+ # If the alias works, FC9313 becomes the supported DJI Mini 5 Pro enum.
+
+ assert batch_id is not None
+ assert result["drone_type"] == DroneType.DJI_MINI_5_PRO
+ assert_is_valid_flightplan(result["kmz_bytes"])
+
+
@pytest.mark.asyncio
async def test_gap_detection_without_drone_metadata_returns_clean_response(
db, load_freetown_into_db
diff --git a/src/backend/tests/test_image_footprints.py b/src/backend/tests/test_image_footprints.py
new file mode 100644
index 000000000..0f988530f
--- /dev/null
+++ b/src/backend/tests/test_image_footprints.py
@@ -0,0 +1,54 @@
+from shapely.geometry import mapping
+from shapely.ops import transform
+
+from app.images.image_footprints import (
+ coverage_percentage_from_footprints,
+ image_footprint_polygon,
+ image_footprints_feature_collection,
+ inverse_projector,
+)
+
+
+def test_image_footprint_feature_collection_contains_polygon():
+ # A tiny fake image where GSD makes the footprint exactly 10m x 10m.
+ image = {
+ "id": "image-1",
+ "location": {"type": "Point", "coordinates": [0, 0]},
+ "gsd_cm_px": 100,
+ "image_width": 10,
+ "image_height": 10,
+ "altitude_m": None,
+ "yaw_deg": 0,
+ }
+
+ footprints = image_footprints_feature_collection([image])
+ # The frontend expects a GeoJSON FeatureCollection with polygon features.
+
+ assert footprints["type"] == "FeatureCollection"
+ assert len(footprints["features"]) == 1
+ assert footprints["features"][0]["geometry"]["type"] == "Polygon"
+ assert footprints["features"][0]["properties"]["image_id"] == "image-1"
+
+
+def test_coverage_percentage_from_footprints_counts_overlap_once():
+ # Two identical image footprints should still cover only one footprint area.
+ image = {
+ "id": "image-1",
+ "location": {"type": "Point", "coordinates": [0, 0]},
+ "gsd_cm_px": 100,
+ "image_width": 10,
+ "image_height": 10,
+ "altitude_m": None,
+ "yaw_deg": 0,
+ }
+ footprint_m = image_footprint_polygon(image)
+ # Use the first image footprint itself as the target area.
+ target_wgs84 = transform(inverse_projector.transform, footprint_m)
+
+ coverage = coverage_percentage_from_footprints(
+ mapping(target_wgs84),
+ [image, {**image, "id": "image-2"}],
+ )
+ # If overlap is counted twice this would be wrong, but union keeps it at 100%.
+
+ assert coverage == 100.0
diff --git a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DroneImageProcessingWorkflow/TaskVerificationModal.tsx b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DroneImageProcessingWorkflow/TaskVerificationModal.tsx
index ef58d9ff2..6d0df426c 100644
--- a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DroneImageProcessingWorkflow/TaskVerificationModal.tsx
+++ b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DroneImageProcessingWorkflow/TaskVerificationModal.tsx
@@ -604,6 +604,29 @@ const TaskVerificationModal = ({
/>
)}
+ {/* Image footprint outlines */}
+ {map &&
+ isMapLoaded &&
+ isStyleReady &&
+ verificationData?.image_footprints &&
+ verificationData.image_footprints.features.length > 0 && (
+
+ )}
+
{/* Image points */}
{map &&
isMapLoaded &&
diff --git a/src/frontend/src/services/classification.ts b/src/frontend/src/services/classification.ts
index 3910e9526..960a95986 100644
--- a/src/frontend/src/services/classification.ts
+++ b/src/frontend/src/services/classification.ts
@@ -345,6 +345,8 @@ export interface TaskVerificationData {
image_count: number;
images: TaskImageData[];
task_geometry: GeoJSON.Feature;
+ // Backend sends these so the map can draw what each photo roughly covers.
+ image_footprints?: GeoJSON.FeatureCollection;
coverage_percentage?: number;
is_verified: boolean;
}
From 4afd0eb881c3c3c30757636b44c1457d2b41a55e Mon Sep 17 00:00:00 2001
From: shreya-hegde <78434422+shreya-hegde@users.noreply.github.com>
Date: Thu, 16 Jul 2026 18:17:50 +0530
Subject: [PATCH 2/3] More changes
Changed absolute altitude to relative altitude
Highlight the rectangles based on image selected
---
.../app/images/image_classification.py | 7 ++--
.../TaskVerificationModal.tsx | 41 +++++++++++++++++--
src/pnpm-workspace.yaml | 6 +++
3 files changed, 48 insertions(+), 6 deletions(-)
diff --git a/src/backend/app/images/image_classification.py b/src/backend/app/images/image_classification.py
index e09351a4e..d87a39c9c 100644
--- a/src/backend/app/images/image_classification.py
+++ b/src/backend/app/images/image_classification.py
@@ -2378,7 +2378,7 @@ async def get_task_verification_data_project(
NULLIF(exif->>'FlightYawDegree', '')::double precision AS yaw_deg,
-- Altitude sizes the footprint when project GSD is unavailable.
NULLIF(regexp_replace(
- COALESCE(exif->>'AbsoluteAltitude',''),
+ COALESCE(exif->>'RelativeAltitude',''),
'[^0-9+\\-.]+', '', 'g'
), '')::double precision AS altitude_m,
-- Image dimensions keep the footprint aspect close to the photo.
@@ -2437,12 +2437,13 @@ async def get_task_verification_data_project(
# Add footprint inputs to every image.
# This keeps the map outlines and the percentage using the same assumptions.
- # Each image can still use its own EXIF altitude when present.
+ # Prefer the planned project altitude: GPS absolute altitude can be sea-level height.
+ # If the project has no value, fall back to the image's relative altitude.
footprint_images = [
{
**dict(img),
"gsd_cm_px": gsd,
- "altitude_m": img.get("altitude_m") or altitude,
+ "altitude_m": altitude or img.get("altitude_m"),
}
for img in images
]
diff --git a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DroneImageProcessingWorkflow/TaskVerificationModal.tsx b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DroneImageProcessingWorkflow/TaskVerificationModal.tsx
index 6d0df426c..672728105 100644
--- a/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DroneImageProcessingWorkflow/TaskVerificationModal.tsx
+++ b/src/frontend/src/components/DroneOperatorTask/DescriptionSection/DroneImageProcessingWorkflow/TaskVerificationModal.tsx
@@ -343,6 +343,22 @@ const TaskVerificationModal = ({
};
}, [verificationData]);
+ // Draw a stronger outline for the footprint belonging to the selected image.
+ const selectedFootprintGeoJson = useMemo(() => {
+ if (!selectedImageId || !verificationData?.image_footprints?.features) return null;
+
+ const selectedFootprint = verificationData.image_footprints.features.find(
+ (feature) => feature.properties?.image_id === selectedImageId,
+ );
+
+ if (!selectedFootprint) return null;
+
+ return {
+ type: "FeatureCollection" as const,
+ features: [selectedFootprint],
+ };
+ }, [selectedImageId, verificationData?.image_footprints]);
+
// Mark as verified mutation
const verifyMutation = useMutation({
mutationFn: () => markTaskAsVerified(projectId, taskId),
@@ -619,14 +635,33 @@ const TaskVerificationModal = ({
layerOptions={{
type: "line",
paint: {
- "line-color": "#f59e0b",
- "line-width": 1,
- "line-opacity": 0.35,
+ "line-color": "#f97316",
+ "line-width": 2,
+ "line-opacity": 0.9,
},
}}
/>
)}
+ {/* Selected image footprint */}
+ {map && isMapLoaded && isStyleReady && selectedFootprintGeoJson && (
+
+ )}
+
{/* Image points */}
{map &&
isMapLoaded &&
diff --git a/src/pnpm-workspace.yaml b/src/pnpm-workspace.yaml
index 2c09694a5..a6289718d 100644
--- a/src/pnpm-workspace.yaml
+++ b/src/pnpm-workspace.yaml
@@ -1,3 +1,9 @@
packages:
- "frontend"
- "gcp-editor"
+allowBuilds:
+ canvas: false
+ core-js: false
+ core-js-pure: false
+ esbuild: true
+ exifreader: false
From c64345025adecb2a416a8d49b104f97fe41e812d Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
<66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Thu, 16 Jul 2026 13:18:22 +0000
Subject: [PATCH 3/3] [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---
src/backend/app/images/image_footprints.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/backend/app/images/image_footprints.py b/src/backend/app/images/image_footprints.py
index a9f9362b9..493f0e2b8 100644
--- a/src/backend/app/images/image_footprints.py
+++ b/src/backend/app/images/image_footprints.py
@@ -55,8 +55,8 @@ def _footprint_size_meters(
if altitude_m and altitude_m > 0:
# Keep the rectangle shaped like the image, usually 4:3.
aspect_ratio = image_width / image_height
- diagonal_m = 2 * altitude_m * math.tan(
- math.radians(DEFAULT_DIAGONAL_FOV_DEG) / 2
+ diagonal_m = (
+ 2 * altitude_m * math.tan(math.radians(DEFAULT_DIAGONAL_FOV_DEG) / 2)
)
# Convert the estimated diagonal into width and height.
height_m = diagonal_m / math.sqrt(1 + aspect_ratio**2)
@@ -86,7 +86,7 @@ def image_footprint_polygon(image: dict) -> Polygon | None:
# Build in meters first, then convert back to lon/lat only for display.
width_m, height_m = footprint_size
# Convert image GPS point into meter coordinates
- center = transform(projector.transform, shape(image["location"]))
+ center = transform(projector.transform, shape(image["location"]))
x, y = center.x, center.y
half_width = width_m / 2
half_height = height_m / 2