Spaces:
Runtime error
Runtime error
| """ | |
| Archivo: inference.py | |
| Fecha de modificación: 03/06/2026 | |
| Autor: Equipo AgroVisión | |
| Descripción: | |
| Adaptadores de inferencia del MVP, **agnósticos a la arquitectura**. Define una | |
| interfaz común y dos implementaciones: `OnnxInferenceAdapter` (carga el artefacto | |
| ONNX real con onnxruntime y delega el decode por arquitectura) y | |
| `MockInferenceAdapter` (genera detecciones de prueba para validar el flujo sin el | |
| modelo real). La fábrica `create_adapter` selecciona la implementación según el | |
| backend configurado (`mock` | `onnx`). | |
| Sustentación Científica: [Opcional] | |
| Los detectores objetivo (YOLO26, RF-DETR) son NMS-free, por lo que el conteo | |
| equivale al número de detecciones que superan el umbral de confianza. | |
| Acciones Principales: | |
| - Provee adaptadores de inferencia (real y mock) y su fábrica. | |
| Estructura Interna: | |
| - `ModelNotAvailableError`: error cuando el modelo real no está disponible. | |
| - `InferenceAdapter`: protocolo común (`predict`). | |
| - `MockInferenceAdapter`: detecta arbustos por color (blobs) o genera cajas aleatorias. | |
| - `OnnxInferenceAdapter`: inferencia ONNX real (decode pendiente de publicación). | |
| - `create_adapter`: fábrica que devuelve el adaptador según el backend. | |
| Entradas / Dependencias: | |
| - `cv2`, `numpy`, `onnxruntime` (import diferido), `backend.core.detection.Detection`. | |
| Salidas / Efectos: | |
| - Ninguno persistente; ejecuta inferencia en memoria. | |
| Ejemplo de Integración: | |
| from backend.core.inference import create_adapter | |
| adapter = create_adapter("mock", model_path="", architecture="yolo26n") | |
| detecciones = adapter.predict(imagen_bgr, confidence=0.25) | |
| """ | |
| from __future__ import annotations | |
| import random | |
| from pathlib import Path | |
| from typing import Protocol | |
| import cv2 | |
| import numpy as np | |
| from backend.core.detection import CLASS_PLANT, CLASS_WEED, Detection | |
| SUPPORTED_ARCHITECTURES: frozenset[str] = frozenset({"yolo26n", "rfdetr_nano"}) | |
| # Parámetros del fallback aleatorio (imágenes sin arbustos verdes reconocibles) | |
| _MOCK_MIN_PLANTS: int = 40 | |
| _MOCK_MAX_PLANTS: int = 130 | |
| _MOCK_BOX_SIZE_PX: int = 18 | |
| _MOCK_WEED_RATIO: float = 0.08 # malezas como fracción aproximada de las plantas | |
| # Parámetros de la detección por color (para el ortomosaico de arándano simulado) | |
| _MOCK_MIN_DETECTED_BLOBS: int = 8 # mínimo de arbustos para usar el modo "blobs" | |
| _BLOB_MIN_AREA_PX: int = 30 # área mínima de un blob válido | |
| # Rangos HSV (OpenCV: H en [0,179]). Verde = arbusto; amarillo = maleza. | |
| _GREEN_HSV_LOWER: np.ndarray = np.array([35, 40, 40], dtype=np.uint8) | |
| _GREEN_HSV_UPPER: np.ndarray = np.array([85, 255, 255], dtype=np.uint8) | |
| _YELLOW_HSV_LOWER: np.ndarray = np.array([20, 60, 60], dtype=np.uint8) | |
| _YELLOW_HSV_UPPER: np.ndarray = np.array([34, 255, 255], dtype=np.uint8) | |
| class ModelNotAvailableError(RuntimeError): | |
| """Se lanza cuando el modelo real no está disponible (backend desconocido o archivo ausente).""" | |
| class InferenceAdapter(Protocol): | |
| """Interfaz común de los adaptadores de inferencia (real y mock).""" | |
| def predict(self, image_bgr: np.ndarray, confidence: float) -> list[Detection]: | |
| """Devuelve las detecciones de la imagen que superan el umbral de confianza.""" | |
| ... | |
| class MockInferenceAdapter: | |
| """ | |
| Adaptador de inferencia **simulado** para validar el flujo sin el modelo real. | |
| Si la imagen contiene suficientes arbustos verdes reconocibles (p. ej. el | |
| ortomosaico de arándano simulado), los detecta por color (blobs) para que las | |
| cajas caigan sobre los arbustos. En cualquier otra imagen recurre a cajas | |
| aleatorias plausibles. Es **determinista por imagen** (la semilla deriva del | |
| contenido), respetando la convención de inferencia idempotente. | |
| """ | |
| def predict(self, image_bgr: np.ndarray, confidence: float) -> list[Detection]: | |
| """ | |
| Genera detecciones de prueba: por color si hay arbustos, o aleatorias si no. | |
| Args: | |
| image_bgr (np.ndarray): Imagen BGR como arreglo de NumPy. | |
| confidence (float): Umbral de confianza; las cajas simuladas lo superan. | |
| Returns: | |
| list[Detection]: Detecciones simuladas (plantas y malezas). | |
| """ | |
| seed = int(image_bgr.sum()) % (2**32) # determinista: misma imagen → mismo conteo | |
| rng = random.Random(seed) | |
| hsv = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2HSV) | |
| plants = self._detect_blobs( | |
| hsv, _GREEN_HSV_LOWER, _GREEN_HSV_UPPER, CLASS_PLANT, confidence, rng | |
| ) | |
| if len(plants) >= _MOCK_MIN_DETECTED_BLOBS: | |
| weeds = self._detect_blobs( | |
| hsv, _YELLOW_HSV_LOWER, _YELLOW_HSV_UPPER, CLASS_WEED, confidence, rng | |
| ) | |
| return plants + weeds | |
| return self._random_detections(image_bgr, confidence, rng) | |
| def _detect_blobs( | |
| hsv: np.ndarray, | |
| lower: np.ndarray, | |
| upper: np.ndarray, | |
| class_id: int, | |
| confidence: float, | |
| rng: random.Random, | |
| ) -> list[Detection]: | |
| """ | |
| Detecta blobs de un rango de color como cajas (clásico, sin modelo entrenado). | |
| Args: | |
| hsv (np.ndarray): Imagen en espacio HSV. | |
| lower (np.ndarray): Cota inferior del rango HSV. | |
| upper (np.ndarray): Cota superior del rango HSV. | |
| class_id (int): Clase asignada a los blobs encontrados. | |
| confidence (float): Umbral mínimo de confianza simulada. | |
| rng (random.Random): Generador semillado para confianzas reproducibles. | |
| Returns: | |
| list[Detection]: Una detección por blob que supere el área mínima. | |
| """ | |
| mask = cv2.inRange(hsv, lower, upper) | |
| mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8)) | |
| contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) | |
| detections: list[Detection] = [] | |
| for contour in contours: | |
| if cv2.contourArea(contour) < _BLOB_MIN_AREA_PX: | |
| continue | |
| x, y, width, height = cv2.boundingRect(contour) | |
| detections.append( | |
| Detection( | |
| x1=float(x), | |
| y1=float(y), | |
| x2=float(x + width), | |
| y2=float(y + height), | |
| confidence=round(rng.uniform(confidence, 1.0), 3), | |
| class_id=class_id, | |
| ) | |
| ) | |
| return detections | |
| def _random_detections( | |
| self, image_bgr: np.ndarray, confidence: float, rng: random.Random | |
| ) -> list[Detection]: | |
| """ | |
| Genera cajas aleatorias plausibles (fallback para imágenes sin arbustos). | |
| Args: | |
| image_bgr (np.ndarray): Imagen BGR (se usan sus dimensiones). | |
| confidence (float): Umbral mínimo de confianza simulada. | |
| rng (random.Random): Generador semillado por imagen. | |
| Returns: | |
| list[Detection]: Plantas y malezas en posiciones aleatorias. | |
| """ | |
| height, width = image_bgr.shape[:2] | |
| plant_count = rng.randint(_MOCK_MIN_PLANTS, _MOCK_MAX_PLANTS) | |
| weed_count = rng.randint(0, max(1, int(plant_count * _MOCK_WEED_RATIO))) | |
| detections: list[Detection] = [] | |
| for class_id, total in ((CLASS_PLANT, plant_count), (CLASS_WEED, weed_count)): | |
| for _ in range(total): | |
| detections.append(self._random_box(rng, width, height, confidence, class_id)) | |
| return detections | |
| def _random_box( | |
| rng: random.Random, width: int, height: int, confidence: float, class_id: int | |
| ) -> Detection: | |
| """ | |
| Construye una caja aleatoria dentro de los límites de la imagen. | |
| Args: | |
| rng (random.Random): Generador semillado por imagen. | |
| width (int): Ancho de la imagen en píxeles. | |
| height (int): Alto de la imagen en píxeles. | |
| confidence (float): Umbral mínimo de confianza simulada. | |
| class_id (int): Clase de la detección (planta o maleza). | |
| Returns: | |
| Detection: Detección aleatoria con caja, confianza y clase. | |
| """ | |
| max_x = max(_MOCK_BOX_SIZE_PX + 1, width - _MOCK_BOX_SIZE_PX) | |
| max_y = max(_MOCK_BOX_SIZE_PX + 1, height - _MOCK_BOX_SIZE_PX) | |
| center_x = rng.randint(_MOCK_BOX_SIZE_PX, max_x) | |
| center_y = rng.randint(_MOCK_BOX_SIZE_PX, max_y) | |
| half = _MOCK_BOX_SIZE_PX / 2 | |
| return Detection( | |
| x1=center_x - half, | |
| y1=center_y - half, | |
| x2=center_x + half, | |
| y2=center_y + half, | |
| confidence=round(rng.uniform(confidence, 1.0), 3), | |
| class_id=class_id, | |
| ) | |
| class OnnxInferenceAdapter: | |
| """ | |
| Adaptador de inferencia ONNX real, agnóstico a la arquitectura. | |
| Envuelve una sesión de onnxruntime y traduce su salida a `Detection`, delegando | |
| en el decode específico de cada arquitectura soportada. | |
| """ | |
| def __init__(self, model_path: str, architecture: str) -> None: | |
| """ | |
| Inicializa la sesión ONNX para el modelo indicado. | |
| Args: | |
| model_path (str): Ruta al artefacto `.onnx` del modelo de conteo. | |
| architecture (str): Arquitectura del modelo ('yolo26n' o 'rfdetr_nano'). | |
| Raises: | |
| ModelNotAvailableError: Si la arquitectura no está soportada. | |
| """ | |
| if architecture not in SUPPORTED_ARCHITECTURES: | |
| raise ModelNotAvailableError( | |
| f"Arquitectura no soportada: {architecture}. " | |
| f"Soportadas: {sorted(SUPPORTED_ARCHITECTURES)}." | |
| ) | |
| import onnxruntime as ort # import diferido: onnxruntime no se exige en mock/standby | |
| self._architecture = architecture | |
| self._session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"]) | |
| def predict(self, image_bgr: np.ndarray, confidence: float) -> list[Detection]: | |
| """ | |
| Ejecuta la inferencia real y devuelve las detecciones. | |
| Args: | |
| image_bgr (np.ndarray): Imagen BGR como arreglo de NumPy. | |
| confidence (float): Umbral mínimo de confianza. | |
| Returns: | |
| list[Detection]: Detecciones que superan el umbral (modelos NMS-free). | |
| """ | |
| if self._architecture == "yolo26n": | |
| return self._decode_yolo(image_bgr, confidence) | |
| return self._decode_detr(image_bgr, confidence) | |
| def _decode_yolo(self, image_bgr: np.ndarray, confidence: float) -> list[Detection]: | |
| """ | |
| Decodifica la salida de un modelo YOLO26 a detecciones. | |
| Raises: | |
| NotImplementedError: Mientras el modelo de conteo no esté publicado. | |
| """ | |
| raise NotImplementedError( | |
| "Decode YOLO26 pendiente: se implementa al publicar el modelo de conteo." | |
| ) | |
| def _decode_detr(self, image_bgr: np.ndarray, confidence: float) -> list[Detection]: | |
| """ | |
| Decodifica la salida de un modelo RF-DETR a detecciones. | |
| Raises: | |
| NotImplementedError: Mientras el modelo de conteo no esté publicado. | |
| """ | |
| raise NotImplementedError( | |
| "Decode RF-DETR pendiente: se implementa al publicar el modelo de conteo." | |
| ) | |
| def create_adapter(backend: str, model_path: str, architecture: str) -> InferenceAdapter: | |
| """ | |
| Fábrica que devuelve el adaptador de inferencia según el backend configurado. | |
| Args: | |
| backend (str): 'mock' (datos de prueba) u 'onnx' (modelo real). | |
| model_path (str): Ruta al artefacto `.onnx` (solo para backend 'onnx'). | |
| architecture (str): Arquitectura del modelo ('yolo26n' o 'rfdetr_nano'). | |
| Returns: | |
| InferenceAdapter: Adaptador listo para inferir. | |
| Raises: | |
| ModelNotAvailableError: Si el backend es desconocido o el modelo real no existe. | |
| """ | |
| if backend == "mock": | |
| return MockInferenceAdapter() | |
| if backend == "onnx": | |
| if not Path(model_path).exists(): | |
| raise ModelNotAvailableError( | |
| f"Modelo no encontrado en {model_path}. Se descarga de Hugging Face Hub " | |
| "en el build cuando el repo del modelo publique el artefacto." | |
| ) | |
| return OnnxInferenceAdapter(model_path, architecture) | |
| raise ModelNotAvailableError(f"Backend de modelo desconocido: {backend}. Use 'mock' u 'onnx'.") | |