iamhelitha's picture
Deploy from GitHub Actions (branch: main, sha: 4887b449)
fc23b8c verified
Raw
History Blame Contribute Delete
41.3 kB
"""
EleFind - Aerial Elephant Detection
=====================================
A Gradio 6 web interface for detecting elephants in aerial/drone imagery
using YOLOv11 with SAHI (Slicing Aided Hyper Inference).
Features:
- Upload aerial images and detect elephants with bounding boxes
- Adjustable SAHI parameters (confidence, slice size, overlap)
- Runtime CPU, CUDA, and Apple MPS selection
- True Grad-CAM model explanations
- Automatic model download from HuggingFace Hub
- Confidence bar chart and detection data table
Author: Helitha Guruge
Project: EleFind (Undergraduate Research Project)
"""
from __future__ import annotations
import hashlib
import os
import threading
import uuid
import warnings
from pathlib import Path
import cv2
import gradio as gr
import numpy as np
from packaging import version as _pkg_version
from PIL import Image
warnings.filterwarnings("ignore")
# Gradio 6.x replaced show_fullscreen_button / show_download_button with buttons=
_GRADIO_6 = _pkg_version.parse(gr.__version__) >= _pkg_version.parse("5.0.0")
_IMG_BUTTONS = {"buttons": ["fullscreen"]} if _GRADIO_6 else {"show_fullscreen_button": True}
_IMG_BUTTONS_DL = (
{"buttons": ["download"]}
if _GRADIO_6
else {"show_fullscreen_button": False, "show_download_button": True}
)
_VIEWER_HEAD = r"""
<style>
.elefind-viewer-toolbar {
display: flex; gap: 0.45rem; align-items: center; flex-wrap: wrap;
margin: 0.5rem 0 0.75rem;
}
.elefind-viewer-toolbar button {
border: 1px solid var(--border-color-primary); border-radius: 8px;
background: var(--button-secondary-background-fill); padding: 0.45rem 0.7rem;
color: var(--body-text-color); cursor: pointer; font-weight: 600;
}
.elefind-viewer-toolbar button:hover { filter: brightness(0.96); }
.elefind-zoom-value { min-width: 3.5rem; text-align: center; font-variant-numeric: tabular-nums; }
#detection-output, #gradcam-output { overflow: hidden; }
#hero {
display: flex; align-items: center; justify-content: space-between; gap: 1rem;
padding: 0.15rem 0.25rem 0.35rem;
}
.elefind-brand { display: flex; align-items: center; min-width: 0; }
.elefind-title { margin: 0; font-size: 1.55rem; line-height: 1; font-weight: 800; }
.elefind-stack {
padding: 0.35rem 0.65rem; border: 1px solid var(--border-color-primary);
border-radius: 999px; font-size: 0.76rem; font-weight: 700; white-space: nowrap;
background: color-mix(in srgb, var(--block-background-fill) 88%, #10b981 12%);
}
#hardware-panel { padding: 0.55rem 0.65rem !important; }
#hardware-panel .prose { font-size: 0.78rem; line-height: 1.25; }
#hardware-panel .form { gap: 0.45rem !important; }
#detect-button { min-height: 2.7rem; font-weight: 800; }
#sahi-parameters {
--block-label-background-fill: transparent;
--block-label-border-color: transparent;
--block-label-border-width: 0px;
--block-label-shadow: none;
--block-label-padding: 0;
--block-label-radius: 0;
--block-label-text-color: var(--body-text-color);
}
#sahi-parameters .parameter-section {
padding: 0.55rem 0.65rem !important; gap: 0.45rem !important;
border: 1px solid var(--border-color-primary); border-radius: 10px;
}
#sahi-parameters .parameter-title p {
margin: 0 !important; font-size: 0.78rem; font-weight: 800;
color: var(--body-text-color-subdued);
}
#sahi-parameters .parameter-section .info {
font-size: 0.7rem !important; line-height: 1.15 !important;
}
#sahi-parameters [data-testid="block-info"] {
background: transparent !important; border: 0 !important;
border-radius: 0 !important; box-shadow: none !important;
color: var(--body-text-color) !important; padding: 0 !important;
}
#result-tabs { min-height: 0; }
#examples-panel {
flex: 0 0 auto; max-height: 8.25rem; overflow-y: auto;
scrollbar-width: thin;
}
#examples-panel .gallery { min-height: 0 !important; }
@media (min-width: 900px) {
html, body { min-height: 100%; overflow-y: auto !important; }
.gradio-container {
width: 100% !important; max-width: none !important; height: auto !important;
min-height: 0 !important; padding: 0.6rem 1rem !important; overflow: visible !important;
}
.gradio-container > .main, .gradio-container > main { height: auto !important; min-height: 0 !important; }
#hero { flex: 0 0 2.25rem; min-height: 0; }
#workspace {
height: auto !important; min-height: 0 !important;
max-height: none !important; overflow: visible !important;
gap: 0.75rem !important;
}
#input-panel, #output-panel {
height: auto !important; min-height: 0 !important; gap: 0.45rem !important;
}
#input-panel, #output-panel { overflow: visible !important; }
#input-image {
flex: 0 0 auto; min-height: 175px !important; height: 200px !important;
}
#input-image > div, #input-image .image-container { height: 100% !important; min-height: 0 !important; }
#result-tabs { height: auto !important; overflow: visible !important; }
#detection-output, #gradcam-output {
height: 500px !important; min-height: 260px !important;
}
#detection-output > div, #gradcam-output > div,
#detection-output .image-container, #gradcam-output .image-container {
height: 100% !important; min-height: 0 !important;
}
.elefind-viewer-toolbar { margin: 0.35rem 0 0 !important; }
.elefind-viewer-toolbar button { padding: 0.36rem 0.6rem; font-size: 0.78rem; }
}
@media (max-width: 899px) {
#hero { align-items: flex-start; }
.elefind-stack { display: none; }
#workspace { flex-direction: column; }
}
</style>
<script>
(() => {
const scales = new Map();
function imageFor(targetId) {
const target = document.getElementById(targetId);
if (!target) return null;
const images = [...target.querySelectorAll('img')].filter(img => img.src);
return images.length ? images[images.length - 1] : null;
}
function update(targetId, nextScale) {
const img = imageFor(targetId);
if (!img) return;
const scale = Math.max(0.5, Math.min(8, nextScale));
scales.set(targetId, scale);
const scrollHost = img.parentElement;
scrollHost.style.overflow = 'auto';
scrollHost.style.maxHeight = '70vh';
scrollHost.style.cursor = scale > 1 ? 'grab' : 'default';
img.style.maxWidth = 'none';
img.style.width = `${scale * 100}%`;
img.style.height = 'auto';
img.style.objectFit = 'contain';
const value = document.querySelector(`[data-zoom-value="${targetId}"]`);
if (value) value.textContent = `${Math.round(scale * 100)}%`;
}
document.addEventListener('click', event => {
const button = event.target.closest('[data-viewer-action]');
if (!button) return;
event.preventDefault();
const targetId = button.dataset.viewerTarget;
const action = button.dataset.viewerAction;
const current = scales.get(targetId) || 1;
if (action === 'in') update(targetId, current * 1.25);
if (action === 'out') update(targetId, current / 1.25);
if (action === 'reset') update(targetId, 1);
if (action === 'open') {
const img = imageFor(targetId);
if (img) window.open(img.src, '_blank', 'noopener,noreferrer');
}
});
})();
</script>
"""
def _viewer_toolbar(target_id: str) -> str:
"""Return controls for in-page image zooming and browser-tab viewing."""
return f"""
<div class="elefind-viewer-toolbar" aria-label="Image viewer controls">
<button type="button" data-viewer-action="out" data-viewer-target="{target_id}">βˆ’ Zoom out</button>
<span class="elefind-zoom-value" data-zoom-value="{target_id}">100%</span>
<button type="button" data-viewer-action="in" data-viewer-target="{target_id}">+ Zoom in</button>
<button type="button" data-viewer-action="reset" data-viewer-target="{target_id}">Reset</button>
<button type="button" data-viewer-action="open" data-viewer-target="{target_id}">Open image in browser β†—</button>
</div>
"""
# Optional pandas for chart data
try:
import pandas as pd
_PANDAS = True
_EMPTY_CHART = pd.DataFrame({"Elephant": pd.Series([], dtype=str), "Confidence": pd.Series([], dtype=float)})
except ImportError:
_PANDAS = False
_EMPTY_CHART = None
# ---------------------------------------------------------------------------
# Imports: detection libraries
# ---------------------------------------------------------------------------
try:
import torch
from sahi import AutoDetectionModel
from sahi.predict import get_sliced_prediction
except ImportError as e:
raise SystemExit(
f"Missing required packages: {e}\n"
"Install with: pip install -r requirements.txt"
)
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
# HuggingFace model repository (update after you create the HF repo)
HF_MODEL_REPO = os.environ.get("HF_MODEL_REPO", "iamhelitha/EleFind-yolo11-elephant")
HF_MODEL_FILE = os.environ.get("HF_MODEL_FILE", "best.pt")
# Local fallback: look for model in common locations
LOCAL_MODEL_PATHS = [
Path(__file__).parent / "best.pt",
Path(__file__).parent / "models" / "best.pt",
Path(__file__).parent / "meeting_materials" / "models" / "best.pt",
]
# Default SAHI parameters (optimized for elephant detection)
DEFAULT_CONF = 0.30
DEFAULT_SLICE = 1024
DEFAULT_OVERLAP = 0.30
DEFAULT_IOU = 0.40
# Image size limit for CPU inference (avoid timeouts on free Spaces)
MAX_IMAGE_DIMENSION = 6000
# SHA256 of the trusted best.pt β€” update this if you retrain or replace the model
EXPECTED_MODEL_SHA256 = "7d6be7308bc11a58c32086345d8d09fb495630faa11039a43fd66e7f5750c4ff"
# ---------------------------------------------------------------------------
# Device detection
# ---------------------------------------------------------------------------
def get_available_devices() -> list[tuple[str, str]]:
"""Return Gradio choices for every usable accelerator plus CPU."""
devices: list[tuple[str, str]] = []
try:
if torch.cuda.is_available():
for index in range(torch.cuda.device_count()):
name = torch.cuda.get_device_name(index)
devices.append((f"GPU {index}: {name} (CUDA)", f"cuda:{index}"))
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
devices.append(("GPU: Apple Silicon (MPS)", "mps"))
except Exception:
pass
devices.append(("CPU", "cpu"))
return devices
def is_gpu_available() -> bool:
"""Return True when PyTorch can use a CUDA or Apple MPS GPU."""
return any(value != "cpu" for _, value in get_available_devices())
def get_device(requested: str | None = None) -> str:
"""Resolve and validate a requested device, or choose the fastest available."""
available = [value for _, value in get_available_devices()]
if requested in (None, "", "auto"):
return next((device for device in available if device != "cpu"), "cpu")
if requested not in available:
raise ValueError(
f"Device '{requested}' is not available. Choose one of: {', '.join(available)}"
)
return requested
def get_device_status() -> tuple[str, str]:
"""Return a user-facing hardware report and the recommended device."""
choices = get_available_devices()
selected = get_device()
gpu_choices = [label for label, value in choices if value != "cpu"]
if gpu_choices:
summary = f"**GPU ready** Β· {', '.join(gpu_choices)}"
else:
summary = "**CPU mode** Β· No compatible GPU detected"
return f"{summary} Β· Recommended: `{selected}`", selected
# ---------------------------------------------------------------------------
# Model loading
# ---------------------------------------------------------------------------
def _resolve_model_path() -> str:
"""Resolve the model path: try HuggingFace Hub first, then local."""
# 1. Try downloading from HuggingFace Hub
if HF_MODEL_REPO:
try:
from huggingface_hub import hf_hub_download
print(f"Downloading model from HuggingFace: {HF_MODEL_REPO}/{HF_MODEL_FILE}")
path = hf_hub_download(
repo_id=HF_MODEL_REPO,
filename=HF_MODEL_FILE,
repo_type="model",
)
print(f"Model downloaded to: {path}")
return path
except Exception as e:
print(f"HuggingFace download failed: {e}. Trying local paths...")
# 2. Try local paths
for local_path in LOCAL_MODEL_PATHS:
if local_path.exists():
print(f"Using local model: {local_path}")
return str(local_path)
raise FileNotFoundError(
"Model not found. Set HF_MODEL_REPO env var or place best.pt "
"in the project root or models/ directory."
)
def _verify_model_checksum(path: str) -> None:
"""Abort if the model file doesn't match the expected SHA256 hash."""
sha256 = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
sha256.update(chunk)
digest = sha256.hexdigest()
if digest != EXPECTED_MODEL_SHA256:
raise RuntimeError(
f"Model checksum mismatch!\n"
f" Expected: {EXPECTED_MODEL_SHA256}\n"
f" Got: {digest}\n"
"The model file may be corrupt or tampered with. "
"Delete the cached file and restart to re-download."
)
print(f"Model checksum verified: {digest[:16]}...")
_MODEL_CACHE: dict[str, AutoDetectionModel] = {}
_MODEL_PATH: str | None = None
_MODEL_LOCK = threading.RLock()
def load_model(device: str | None = None) -> AutoDetectionModel:
"""Load the SAHI-wrapped detection model on the requested device."""
global _MODEL_PATH
device = get_device(device)
if _MODEL_PATH is None:
_MODEL_PATH = _resolve_model_path()
_verify_model_checksum(_MODEL_PATH)
print(f"Loading model on device: {device}")
model = AutoDetectionModel.from_pretrained(
model_type="yolov8", # SAHI uses 'yolov8' for YOLOv8/v11 models
model_path=_MODEL_PATH,
confidence_threshold=DEFAULT_CONF,
device=device,
)
print("Model loaded successfully!")
return model
def get_detection_model(device: str | None = None) -> AutoDetectionModel:
"""Lazily create and cache one model per compute device."""
selected = get_device(device)
with _MODEL_LOCK:
if selected not in _MODEL_CACHE:
_MODEL_CACHE[selected] = load_model(selected)
return _MODEL_CACHE[selected]
# ---------------------------------------------------------------------------
# Detection functions
# ---------------------------------------------------------------------------
def validate_image(image_np: np.ndarray) -> np.ndarray:
"""Validate and optionally resize image to avoid CPU timeouts."""
h, w = image_np.shape[:2]
if max(h, w) > MAX_IMAGE_DIMENSION:
scale = MAX_IMAGE_DIMENSION / max(h, w)
new_w, new_h = int(w * scale), int(h * scale)
image_np = cv2.resize(image_np, (new_w, new_h), interpolation=cv2.INTER_AREA)
print(f"Image resized from {w}x{h} to {new_w}x{new_h}")
return image_np
def run_detection(
image_np: np.ndarray,
conf_threshold: float = DEFAULT_CONF,
slice_size: int = DEFAULT_SLICE,
overlap_ratio: float = DEFAULT_OVERLAP,
iou_threshold: float = DEFAULT_IOU,
device: str | None = None,
) -> list:
"""Run SAHI sliced prediction and return a list of detection dicts."""
detection_model = get_detection_model(device)
detection_model.confidence_threshold = conf_threshold
# Save temp image for SAHI (requires file path)
temp_path = Path(__file__).parent / f"temp_input_{uuid.uuid4().hex}.jpg"
cv2.imwrite(str(temp_path), cv2.cvtColor(image_np, cv2.COLOR_RGB2BGR))
try:
result = get_sliced_prediction(
image=str(temp_path),
detection_model=detection_model,
slice_height=slice_size,
slice_width=slice_size,
overlap_height_ratio=overlap_ratio,
overlap_width_ratio=overlap_ratio,
postprocess_type="NMS",
postprocess_match_threshold=iou_threshold,
verbose=0,
)
predictions = []
for obj in result.object_prediction_list:
bbox = obj.bbox
predictions.append(
{
"x1": int(bbox.minx),
"y1": int(bbox.miny),
"x2": int(bbox.maxx),
"y2": int(bbox.maxy),
"confidence": round(obj.score.value, 4),
}
)
finally:
if temp_path.exists():
temp_path.unlink()
return predictions
def create_gradcam(
image: np.ndarray,
predictions: list | None = None,
device: str | None = None,
opacity: float = 0.45,
input_size: int = 640,
) -> np.ndarray:
"""Create detection-targeted Grad-CAM overlays at the resolution used by SAHI."""
predictions = predictions or []
if not predictions:
return image.copy()
selected = get_device(device)
detection_model = get_detection_model(selected)
yolo = detection_model.model
torch_model = yolo.model
torch_model.eval()
detect_head = torch_model.model[-1]
feature_indexes = getattr(detect_head, "f", None)
target_index = feature_indexes[0] if isinstance(feature_indexes, list) else -2
target_layer = torch_model.model[target_index]
def explain_crop(crop: np.ndarray, target_box: tuple[int, int, int, int]) -> np.ndarray:
"""Explain candidates whose decoded centers fall inside one detection box."""
crop_h, crop_w = crop.shape[:2]
scale = min(input_size / crop_w, input_size / crop_h)
resized_w = max(1, round(crop_w * scale))
resized_h = max(1, round(crop_h * scale))
resized = cv2.resize(crop, (resized_w, resized_h), interpolation=cv2.INTER_LINEAR)
canvas = np.full((input_size, input_size, 3), 114, dtype=np.uint8)
left = (input_size - resized_w) // 2
top = (input_size - resized_h) // 2
canvas[top : top + resized_h, left : left + resized_w] = resized
bx1, by1, bx2, by2 = target_box
target_input = (
bx1 * scale + left,
by1 * scale + top,
bx2 * scale + left,
by2 * scale + top,
)
input_tensor = torch.from_numpy(canvas).permute(2, 0, 1).unsqueeze(0)
input_tensor = input_tensor.to(selected, dtype=torch.float32) / 255.0
input_tensor.requires_grad_(True)
activations: dict[str, torch.Tensor] = {}
gradients: dict[str, torch.Tensor] = {}
def save_features(_module, _inputs, output):
if not isinstance(output, torch.Tensor):
raise RuntimeError("Grad-CAM target layer did not return a tensor")
activations["value"] = output
output.register_hook(lambda grad: gradients.__setitem__("value", grad))
hook = target_layer.register_forward_hook(save_features)
try:
with torch.enable_grad():
output = torch_model(input_tensor)
decoded = output[0] if isinstance(output, (tuple, list)) else output
raw = output[1] if isinstance(output, (tuple, list)) and len(output) > 1 else None
if not isinstance(raw, dict) or "scores" not in raw:
raise RuntimeError("This Ultralytics model does not expose Grad-CAM score logits")
feature_map = activations["value"]
candidate_count = feature_map.shape[2] * feature_map.shape[3]
scores = raw["scores"].sigmoid()[0, :, :candidate_count].amax(dim=0)
centers = decoded[0, :2, :candidate_count]
tx1, ty1, tx2, ty2 = target_input
inside = (
(centers[0] >= tx1) & (centers[0] <= tx2)
& (centers[1] >= ty1) & (centers[1] <= ty2)
)
candidate_indexes = torch.nonzero(inside, as_tuple=False).flatten()
if candidate_indexes.numel() == 0:
target_x, target_y = (tx1 + tx2) / 2, (ty1 + ty2) / 2
distances = (centers[0] - target_x).square() + (centers[1] - target_y).square()
candidate_indexes = distances.argmin().reshape(1)
candidate_scores = scores[candidate_indexes]
top_k = min(5, candidate_scores.numel())
target = torch.topk(candidate_scores, top_k).values.sum()
torch_model.zero_grad(set_to_none=True)
target.backward()
gradient = gradients["value"]
# HiResCAM's element-wise gradient weighting preserves the location
# of small objects better than globally averaged Grad-CAM weights.
cam = torch.relu((gradient * feature_map).sum(dim=1))[0]
cam = cam.detach().float().cpu().numpy()
finally:
hook.remove()
torch_model.zero_grad(set_to_none=True)
cam = cv2.resize(cam, (input_size, input_size), interpolation=cv2.INTER_CUBIC)
cam = cam[top : top + resized_h, left : left + resized_w]
cam = cv2.resize(cam, (crop_w, crop_h), interpolation=cv2.INTER_CUBIC)
cam -= cam.min()
peak = cam.max()
return cam / peak if peak > 0 else cam
h, w = image.shape[:2]
composite = np.zeros((h, w), dtype=np.float32)
for pred in sorted(predictions, key=lambda item: item["confidence"], reverse=True)[:25]:
box_w = max(1, pred["x2"] - pred["x1"])
box_h = max(1, pred["y2"] - pred["y1"])
context_size = int(np.clip(max(box_w, box_h) * 8, 256, 1024))
center_x = (pred["x1"] + pred["x2"]) // 2
center_y = (pred["y1"] + pred["y2"]) // 2
crop_x1 = max(0, center_x - context_size // 2)
crop_y1 = max(0, center_y - context_size // 2)
crop_x2 = min(w, crop_x1 + context_size)
crop_y2 = min(h, crop_y1 + context_size)
crop_x1 = max(0, crop_x2 - context_size)
crop_y1 = max(0, crop_y2 - context_size)
crop = image[crop_y1:crop_y2, crop_x1:crop_x2]
local_box = (
pred["x1"] - crop_x1,
pred["y1"] - crop_y1,
pred["x2"] - crop_x1,
pred["y2"] - crop_y1,
)
crop_cam = explain_crop(crop, local_box)
region = composite[crop_y1:crop_y2, crop_x1:crop_x2]
np.maximum(region, crop_cam, out=region)
heatmap = cv2.applyColorMap(np.uint8(composite * 255), cv2.COLORMAP_JET)
heatmap = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB)
# Keep this output visually distinct from the labeled detection image:
# a dim grayscale context with saturated color only where gradients activate.
gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
gray_rgb = cv2.cvtColor(gray, cv2.COLOR_GRAY2RGB).astype(np.float32)
# Suppress weak background responses so crop boundaries do not appear.
activation = np.sqrt(np.clip((composite - 0.15) / 0.85, 0.0, 1.0))[..., None]
output = gray_rgb * 0.32 + heatmap.astype(np.float32) * activation * (0.68 + opacity)
return np.clip(output, 0, 255).astype(np.uint8)
def draw_detections(image: np.ndarray, predictions: list) -> np.ndarray:
"""Draw bounding boxes and labels on the image."""
img = image.copy()
for pred in predictions:
x1, y1, x2, y2 = pred["x1"], pred["y1"], pred["x2"], pred["y2"]
conf = pred["confidence"]
# Green bounding box
cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 3)
# Label background + text
label = f"Elephant {conf:.0%}"
(lw, lh), baseline = cv2.getTextSize(
label, cv2.FONT_HERSHEY_SIMPLEX, 0.7, 2
)
cv2.rectangle(img, (x1, y1 - lh - 10), (x1 + lw + 5, y1), (0, 255, 0), -1)
cv2.putText(
img, label, (x1 + 2, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 0), 2
)
return img
# ---------------------------------------------------------------------------
# Normalisation helpers (handle various Gradio input types)
# ---------------------------------------------------------------------------
def _to_numpy_rgb(image):
"""Convert Gradio image input (PIL or numpy) to numpy RGB array."""
if image is None:
return None
if isinstance(image, Image.Image):
return np.array(image.convert("RGB"))
if isinstance(image, np.ndarray):
if image.ndim == 2:
return cv2.cvtColor(image, cv2.COLOR_GRAY2RGB)
if image.shape[2] == 4:
return cv2.cvtColor(image, cv2.COLOR_RGBA2RGB)
return image
return None
# ---------------------------------------------------------------------------
# Main processing function
# ---------------------------------------------------------------------------
def process_image(
image,
conf_threshold: float,
slice_size: int,
overlap_ratio: float,
iou_threshold: float,
device: str,
generate_gradcam: bool,
progress=gr.Progress(),
):
"""Run detection and optional Grad-CAM on the user-selected device."""
image_np = _to_numpy_rgb(image)
if image_np is None:
return None, None, 0, 0.0, 0.0, 0.0, "Upload an image first.", None, None
try:
selected_device = get_device(device)
progress(0.05, desc="Validating image")
image_np = validate_image(image_np)
h, w = image_np.shape[:2]
progress(0.10, desc=f"Running SAHI detection ({w}Γ—{h})")
predictions = run_detection(
image_np,
conf_threshold=conf_threshold,
slice_size=int(slice_size),
overlap_ratio=overlap_ratio,
iou_threshold=iou_threshold,
device=selected_device,
)
progress(0.72, desc="Drawing detections")
det_image = draw_detections(image_np, predictions)
gradcam_image = None
if generate_gradcam:
progress(0.78, desc="Computing Grad-CAM explanation")
gradcam_image = create_gradcam(
image_np,
predictions=predictions,
device=selected_device,
)
except Exception as e:
import traceback
err_msg = f"Error: {e}\n\n```\n{traceback.format_exc()}\n```"
return None, None, 0, 0.0, 0.0, 0.0, err_msg, None, None
# Compute stats
n = len(predictions)
avg_conf = sum(p["confidence"] for p in predictions) / n if n else 0.0
max_conf = max((p["confidence"] for p in predictions), default=0.0)
min_conf = min((p["confidence"] for p in predictions), default=0.0)
params_text = (
f"**Parameters:** Slice {int(slice_size)}x{int(slice_size)} px Β· "
f"Overlap {overlap_ratio:.0%} Β· Confidence >= {conf_threshold:.0%} Β· "
f"IoU {iou_threshold:.0%} Β· Image {w}x{h} px Β· Device `{selected_device}` Β· "
f"Grad-CAM {'on' if generate_gradcam else 'off'}"
)
progress(1.0, desc="Done")
# Build chart / table data (pandas optional)
conf_chart = None
det_table = None
if _PANDAS and predictions:
det_table = pd.DataFrame(
[
{
"ID": i + 1,
"Confidence": f"{p['confidence']:.1%}",
"BBox (x1,y1,x2,y2)": f"({p['x1']},{p['y1']},{p['x2']},{p['y2']})",
"Width (px)": p["x2"] - p["x1"],
"Height (px)": p["y2"] - p["y1"],
}
for i, p in enumerate(predictions)
]
)
conf_chart = pd.DataFrame(
{
"Elephant": [f"#{i+1}" for i in range(len(predictions))],
"Confidence": [round(p["confidence"] * 100, 1) for p in predictions],
}
)
return (
Image.fromarray(det_image.astype(np.uint8)),
Image.fromarray(gradcam_image) if gradcam_image is not None else None,
n,
avg_conf,
max_conf,
min_conf,
params_text,
conf_chart,
det_table,
)
# ---------------------------------------------------------------------------
# Gradio UI – Gradio 6.x
# ---------------------------------------------------------------------------
_THEME = gr.themes.Soft(
primary_hue="emerald",
secondary_hue="green",
neutral_hue="gray",
font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "ui-monospace", "monospace"],
)
def build_ui() -> gr.Blocks:
"""Construct the Gradio Blocks interface."""
blocks_kwargs = dict(
theme=_THEME,
title="EleFind – Aerial Elephant Detection",
fill_width=True,
fill_height=False,
)
if not _GRADIO_6:
blocks_kwargs["head"] = _VIEWER_HEAD
with gr.Blocks(**blocks_kwargs) as demo:
# ── Compact application header ──────────────────────────────────────
gr.HTML(
"""
<header id="hero">
<div class="elefind-brand">
<h1 class="elefind-title">EleFind</h1>
</div>
<div class="elefind-stack">YOLO11 Β· SAHI Β· Grad-CAM</div>
</header>
"""
)
# ── Main two-column layout ─────────────────────────────────────────
with gr.Row(equal_height=True, elem_id="workspace"):
# ── LEFT: Input panel ─────────────────────────────────────────
with gr.Column(scale=4, min_width=320, elem_id="input-panel"):
input_image = gr.Image(
label="Upload Aerial / Drone Image",
type="pil",
sources=["upload", "clipboard"],
height=320,
elem_id="input-image",
**_IMG_BUTTONS,
)
# ── Example images directly below the upload area ─────────
example_dir = Path(__file__).parent / "examples"
example_files = sorted(example_dir.glob("*.jpg")) if example_dir.exists() else []
if example_files:
with gr.Accordion(
"Example aerial images", open=True, elem_id="examples-panel"
):
gr.Examples(
examples=[[str(f)] for f in example_files],
inputs=[input_image],
label=None,
)
with gr.Group(elem_id="hardware-panel"):
device_status, recommended_device = get_device_status()
hardware_status = gr.Markdown(device_status)
with gr.Row(equal_height=True):
device_selector = gr.Dropdown(
choices=get_available_devices(),
value=recommended_device,
label="Processing device",
interactive=True,
scale=3,
min_width=170,
)
refresh_devices_btn = gr.Button(
"↻ Refresh", size="sm", scale=0, min_width=88
)
gradcam_toggle = gr.Checkbox(
value=True,
label="Grad-CAM",
scale=1,
min_width=105,
)
with gr.Accordion(
"SAHI Detection Parameters", open=False, elem_id="sahi-parameters"
):
with gr.Row(equal_height=True):
with gr.Group(elem_classes=["parameter-section"]):
gr.Markdown(
"Detection filtering",
elem_classes=["parameter-title"],
)
conf_slider = gr.Slider(
minimum=0.05,
maximum=0.95,
value=DEFAULT_CONF,
step=0.05,
label="Confidence threshold",
info="Minimum score retained",
)
iou_slider = gr.Slider(
minimum=0.10,
maximum=0.80,
value=DEFAULT_IOU,
step=0.05,
label="IoU threshold",
info="Duplicate suppression overlap",
)
with gr.Group(elem_classes=["parameter-section"]):
gr.Markdown(
"SAHI tiling",
elem_classes=["parameter-title"],
)
slice_slider = gr.Slider(
minimum=256,
maximum=2048,
value=DEFAULT_SLICE,
step=128,
label="Slice size (px)",
info="Tile width and height",
)
overlap_slider = gr.Slider(
minimum=0.05,
maximum=0.50,
value=DEFAULT_OVERLAP,
step=0.05,
label="Tile overlap ratio",
info="Overlap between adjacent tiles",
)
detect_btn = gr.Button(
"Detect Elephants β†’",
variant="primary",
size="lg",
elem_id="detect-button",
)
# ── RIGHT: Output tabs ────────────────────────────────────────
with gr.Column(scale=6, min_width=400, elem_id="output-panel"):
with gr.Tabs(elem_id="result-tabs") as result_tabs:
# ── Tab 1: Detection image ────────────────────────────
with gr.Tab("Detections", id="tab_det"):
detection_output = gr.Image(
label="Annotated detections",
type="pil",
interactive=False,
height=420,
elem_id="detection-output",
**_IMG_BUTTONS_DL,
)
gr.HTML(_viewer_toolbar("detection-output"))
with gr.Tab("Grad-CAM", id="tab_gradcam"):
gradcam_output = gr.Image(
label="Gradient-weighted activation map",
type="pil",
interactive=False,
height=420,
elem_id="gradcam-output",
**_IMG_BUTTONS_DL,
)
gr.HTML(_viewer_toolbar("gradcam-output"))
gr.Markdown(
"This view is intentionally different from the labeled detection image: "
"the context is dark grayscale and only gradient activations are colored. "
"Use the controls above to zoom or open the full-resolution image in a browser tab."
)
# ── Tab 2: Statistics ─────────────────────────────────
with gr.Tab("Statistics", id="tab_stats"):
with gr.Row():
stat_count = gr.Number(
label="Elephants Detected", value=0,
interactive=False,
)
stat_avg = gr.Number(
label="Avg Confidence", value=0.0,
interactive=False, precision=2,
)
stat_max = gr.Number(
label="Highest Confidence", value=0.0,
interactive=False, precision=2,
)
stat_min = gr.Number(
label="Lowest Confidence", value=0.0,
interactive=False, precision=2,
)
params_md = gr.Markdown()
with gr.Accordion("Detection Table", open=True):
det_table_out = gr.Dataframe(
headers=["ID", "Confidence", "BBox (x1,y1,x2,y2)",
"Width (px)", "Height (px)"],
label=None,
interactive=False,
wrap=True,
)
with gr.Accordion("Confidence Chart", open=True):
if _PANDAS:
conf_chart_out = gr.BarPlot(
value=_EMPTY_CHART,
x="Elephant",
y="Confidence",
title="Detection Confidence per Elephant",
x_title="Elephant ID",
y_title="Confidence (%)",
color="Confidence",
height=280,
label="",
show_label=False,
)
else:
conf_chart_out = gr.Markdown(
"_Install pandas for the confidence chart._"
)
# ── Event wiring ───────────────────────────────────────────────────
_outputs = [
detection_output,
gradcam_output,
stat_count,
stat_avg,
stat_max,
stat_min,
params_md,
conf_chart_out,
det_table_out,
]
detect_btn.click(
fn=process_image,
inputs=[
input_image,
conf_slider,
slice_slider,
overlap_slider,
iou_slider,
device_selector,
gradcam_toggle,
],
outputs=_outputs,
concurrency_limit=1,
api_name="detect",
)
def refresh_devices():
status, selected = get_device_status()
return gr.Dropdown(choices=get_available_devices(), value=selected), status
refresh_devices_btn.click(
fn=refresh_devices,
outputs=[device_selector, hardware_status],
concurrency_limit=1,
)
return demo
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
demo = build_ui()
if __name__ == "__main__":
demo.queue(max_size=10)
launch_kwargs = dict(
server_name="0.0.0.0",
show_error=True,
)
if _GRADIO_6:
launch_kwargs["head"] = _VIEWER_HEAD
demo.launch(**launch_kwargs)