Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 52 additions & 119 deletions src/backend/app/images/image_classification.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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->>'RelativeAltitude',''),
'[^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
Expand All @@ -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(
"""
Expand All @@ -2409,125 +2432,32 @@ 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.
# 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": altitude or img.get("altitude_m"),
}
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}")

Expand Down Expand Up @@ -2563,6 +2493,7 @@ async def get_task_verification_data_project(
}
for img in images
],
# task boundary polygon
"task_geometry": {
"type": "Feature",
"geometry": task["geometry"],
Expand All @@ -2572,6 +2503,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,
}

Expand Down
155 changes: 155 additions & 0 deletions src/backend/app/images/image_footprints.py
Original file line number Diff line number Diff line change
@@ -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"]))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be an EPSG:3857 (web mercator) coordination, but the footprint calculated below is true ground meters, so the calculations as we get closer to the equator will be increasingly incorrect.

I would propose a simple solution is to correct by the 1/cos(latitude) adjustment that web mercator uses:

center = transform(projector.transform, shape(image["location"]))
x, y = center.x, center.y

# EPSG:3857 stretches ground distance by 1/cos(lat). Scale the rectangle
# so its footprint is stretched to match the mercator projection.
lat_deg = image["location"]["coordinates"][1]
mercator_scale = 1 / math.cos(math.radians(lat_deg))
half_width = (width_m / 2) * mercator_scale
half_height = (height_m / 2) * mercator_scale

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

**any way we could consolidate these two footprint calcs, so we only have to do it once per 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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great πŸ‘

We calculate coverage in this endpoint, but also for the entire project in get_project_coverage.

It would be great to refactor the usage there to also use this updated code, so the project level coverage is calculated correctly (merging this as-is would cause two different values between the two implementations. The old implementation can be removed / cleaned up)

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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading
Loading