"""Run the first GeoAI object-detection baseline on drone images.
|
|
This baseline uses the opengeos/geoai environment plus a general-purpose
|
Ultralytics model. It intentionally keeps georeferenced GeoTIFF processing for
|
the next stage, because the current JPEGs do not carry usable GPS metadata.
|
"""
|
|
from __future__ import annotations
|
|
import argparse
|
import json
|
import os
|
import sys
|
import tempfile
|
import time
|
from datetime import UTC, datetime
|
from pathlib import Path
|
from typing import Any
|
|
from PIL import Image
|
|
|
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png"}
|
COCO_TARGET_CLASS_IDS = [0, 1, 2, 3, 5, 7]
|
|
|
def parse_args() -> argparse.Namespace:
|
root = Path(__file__).resolve().parents[2]
|
default_input = root / "shared" / "data" / "raw" / "01-object-detection"
|
default_output = root / "shared" / "outputs" / "01-object-detection"
|
parser = argparse.ArgumentParser(description="Detect objects in drone images.")
|
parser.add_argument("--input", type=Path, default=default_input)
|
parser.add_argument("--output", type=Path, default=default_output)
|
parser.add_argument("--model", default="yolo11n.pt", help="Ultralytics model name or path.")
|
parser.add_argument("--confidence", type=float, default=0.20)
|
parser.add_argument("--image-size", type=int, default=1024, help="Model input size for each tile.")
|
parser.add_argument("--tile-size", type=int, default=1024, help="Pixel size of the sliding detection window.")
|
parser.add_argument("--tile-overlap", type=float, default=0.20, help="Overlap ratio between adjacent windows.")
|
return parser.parse_args()
|
|
|
def load_image_paths(input_dir: Path) -> tuple[list[Path], list[dict[str, str]]]:
|
paths: list[Path] = []
|
skipped: list[dict[str, str]] = []
|
candidates = [input_dir] if input_dir.is_file() else sorted(input_dir.iterdir())
|
for path in candidates:
|
if not path.is_file() or path.suffix.lower() not in IMAGE_SUFFIXES:
|
continue
|
try:
|
with Image.open(path) as image:
|
image.verify()
|
if image.format == "MPO":
|
skipped.append({"file": path.name, "reason": "MPO is not supported in baseline"})
|
continue
|
except Exception as exc: # pragma: no cover - depends on source files
|
skipped.append({"file": path.name, "reason": f"unreadable: {exc}"})
|
continue
|
paths.append(path)
|
return paths, skipped
|
|
|
def tile_starts(length: int, tile_size: int, overlap: float) -> list[int]:
|
if not 0 <= overlap < 1:
|
raise ValueError("tile-overlap must be between 0 and 1")
|
if length <= tile_size:
|
return [0]
|
stride = max(1, int(tile_size * (1 - overlap)))
|
starts = list(range(0, length - tile_size + 1, stride))
|
last = length - tile_size
|
if starts[-1] != last:
|
starts.append(last)
|
return starts
|
|
|
def intersection_over_union(box_a: list[float], box_b: list[float]) -> float:
|
left = max(box_a[0], box_b[0])
|
top = max(box_a[1], box_b[1])
|
right = min(box_a[2], box_b[2])
|
bottom = min(box_a[3], box_b[3])
|
intersection = max(0.0, right - left) * max(0.0, bottom - top)
|
area_a = max(0.0, box_a[2] - box_a[0]) * max(0.0, box_a[3] - box_a[1])
|
area_b = max(0.0, box_b[2] - box_b[0]) * max(0.0, box_b[3] - box_b[1])
|
union = area_a + area_b - intersection
|
return intersection / union if union else 0.0
|
|
|
def intersection_over_smaller(box_a: list[float], box_b: list[float]) -> float:
|
left = max(box_a[0], box_b[0])
|
top = max(box_a[1], box_b[1])
|
right = min(box_a[2], box_b[2])
|
bottom = min(box_a[3], box_b[3])
|
intersection = max(0.0, right - left) * max(0.0, bottom - top)
|
area_a = max(0.0, box_a[2] - box_a[0]) * max(0.0, box_a[3] - box_a[1])
|
area_b = max(0.0, box_b[2] - box_b[0]) * max(0.0, box_b[3] - box_b[1])
|
smaller_area = min(area_a, area_b)
|
return intersection / smaller_area if smaller_area else 0.0
|
|
|
def class_aware_nms(
|
detections: list[dict[str, Any]],
|
iou_threshold: float = 0.50,
|
containment_threshold: float = 0.80,
|
) -> list[dict[str, Any]]:
|
kept: list[dict[str, Any]] = []
|
for candidate in sorted(detections, key=lambda item: item["confidence"], reverse=True):
|
if all(
|
candidate["class_id"] != existing["class_id"]
|
or (
|
intersection_over_union(candidate["bbox_xyxy"], existing["bbox_xyxy"]) < iou_threshold
|
and intersection_over_smaller(candidate["bbox_xyxy"], existing["bbox_xyxy"])
|
< containment_threshold
|
)
|
for existing in kept
|
):
|
kept.append(candidate)
|
return kept
|
|
|
def predict_tiled(model: Any, image_path: Path, args: argparse.Namespace, np: Any) -> tuple[list[dict[str, Any]], int, int]:
|
with Image.open(image_path) as source:
|
image = source.convert("RGB")
|
width, height = image.size
|
detections: list[dict[str, Any]] = []
|
names: dict[int, str] = {}
|
for y0 in tile_starts(height, args.tile_size, args.tile_overlap):
|
for x0 in tile_starts(width, args.tile_size, args.tile_overlap):
|
tile = image.crop((x0, y0, min(x0 + args.tile_size, width), min(y0 + args.tile_size, height)))
|
result = model.predict(
|
source=np.asarray(tile),
|
device="cpu",
|
imgsz=args.image_size,
|
conf=args.confidence,
|
classes=COCO_TARGET_CLASS_IDS,
|
max_det=100,
|
verbose=False,
|
)[0]
|
names = result.names
|
if result.boxes is None:
|
continue
|
for box in result.boxes:
|
class_id = int(box.cls.item())
|
local_box = [float(value) for value in box.xyxy[0].tolist()]
|
detections.append(
|
{
|
"class_id": class_id,
|
"class_name": names[class_id],
|
"confidence": round(float(box.conf.item()), 4),
|
"bbox_xyxy": [
|
round(local_box[0] + x0, 2),
|
round(local_box[1] + y0, 2),
|
round(local_box[2] + x0, 2),
|
round(local_box[3] + y0, 2),
|
],
|
}
|
)
|
return class_aware_nms(detections), width, height
|
|
|
def draw_detections(image_path: Path, detections: list[dict[str, Any]], output_path: Path) -> None:
|
import cv2
|
import numpy as np
|
|
canvas = cv2.cvtColor(np.asarray(Image.open(image_path).convert("RGB")), cv2.COLOR_RGB2BGR)
|
for detection in detections:
|
x1, y1, x2, y2 = [int(round(value)) for value in detection["bbox_xyxy"]]
|
label = f"{detection['class_name']} {detection['confidence']:.2f}"
|
cv2.rectangle(canvas, (x1, y1), (x2, y2), (255, 80, 0), 4)
|
cv2.putText(canvas, label, (x1, max(30, y1 - 8)), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255, 80, 0), 2)
|
cv2.imwrite(str(output_path), canvas)
|
|
|
def main() -> int:
|
args = parse_args()
|
input_dir = args.input.resolve()
|
output_dir = args.output.resolve()
|
annotated_dir = output_dir / "annotated"
|
annotated_dir.mkdir(parents=True, exist_ok=True)
|
cache_root = Path(tempfile.gettempdir()) / "geoai-object-detection"
|
os.environ.setdefault("YOLO_CONFIG_DIR", str(cache_root / "ultralytics"))
|
os.environ.setdefault("MPLCONFIGDIR", str(cache_root / "matplotlib"))
|
if not input_dir.exists():
|
print(f"Input directory does not exist: {input_dir}", file=sys.stderr)
|
return 2
|
|
image_paths, skipped = load_image_paths(input_dir)
|
if not image_paths:
|
print("No supported images found.", file=sys.stderr)
|
return 2
|
|
# Imports happen after argument validation so the CLI can explain path
|
# mistakes without requiring the heavyweight ML stack.
|
import geoai
|
import numpy as np
|
import torch
|
import ultralytics
|
from ultralytics import YOLO
|
|
started = time.perf_counter()
|
model = YOLO(args.model)
|
detections: list[dict[str, Any]] = []
|
for image_path in image_paths:
|
image_detections, width, height = predict_tiled(model, image_path, args, np)
|
annotated_path = annotated_dir / image_path.name
|
draw_detections(image_path, image_detections, annotated_path)
|
detections.append(
|
{
|
"file": image_path.name,
|
"annotated_file": str(annotated_path.relative_to(output_dir)),
|
"width": width,
|
"height": height,
|
"detections": image_detections,
|
}
|
)
|
|
elapsed = round(time.perf_counter() - started, 3)
|
(output_dir / "detections.json").write_text(
|
json.dumps({"images": detections}, ensure_ascii=False, indent=2), encoding="utf-8"
|
)
|
metadata = {
|
"created_at": datetime.now(UTC).isoformat(),
|
"geoai_package": getattr(geoai, "__version__", "unknown"),
|
"ultralytics": ultralytics.__version__,
|
"torch": torch.__version__,
|
"device": "cpu",
|
"cuda_available": bool(torch.cuda.is_available()),
|
"model": args.model,
|
"confidence": args.confidence,
|
"image_size": args.image_size,
|
"tile_size": args.tile_size,
|
"tile_overlap": args.tile_overlap,
|
"merge_iou_threshold": 0.50,
|
"merge_containment_threshold": 0.80,
|
"target_class_ids": COCO_TARGET_CLASS_IDS,
|
"target_class_note": "COCO person, bicycle, car, motorcycle, bus and truck; tree is not a COCO class.",
|
"input_dir": str(input_dir),
|
"processed_images": len(image_paths),
|
"skipped_images": skipped,
|
"detection_count": sum(len(item["detections"]) for item in detections),
|
"elapsed_seconds": elapsed,
|
"notes": [
|
"This is a baseline for people and common vehicle classes.",
|
"The default COCO model does not provide a tree class.",
|
"JPEG inputs have no usable GPS metadata; GeoJSON is deferred to GeoTIFF stage.",
|
],
|
}
|
(output_dir / "run_metadata.json").write_text(
|
json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8"
|
)
|
print(f"Processed {len(image_paths)} images; detections={metadata['detection_count']}; elapsed={elapsed}s")
|
print(f"Annotated images: {annotated_dir}")
|
print(f"JSON: {output_dir / 'detections.json'}")
|
return 0
|
|
|
if __name__ == "__main__":
|
raise SystemExit(main())
|