Spaces:
Sleeping
Sleeping
| """ | |
| 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) | |