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