Spaces:
Runtime error
Runtime error
feat(backend): API FastAPI de conteo con modo standby
Browse filesImplementa el backend del MVP: configuración por entorno, esquemas Pydantic, dominio de detección, métricas, adaptador de inferencia agnóstico (YOLO26/RF-DETR vía ONNX) y endpoints /api/status y /api/count. El conteo arranca en standby (COUNTING_ENABLED=false) y responde 503 hasta publicar el modelo.
- backend/__init__.py +1 -0
- backend/api/__init__.py +1 -0
- backend/api/count.py +139 -0
- backend/config.py +98 -0
- backend/core/__init__.py +1 -0
- backend/core/detection.py +47 -0
- backend/core/inference.py +144 -0
- backend/core/metrics.py +80 -0
- backend/main.py +102 -0
- backend/schemas.py +71 -0
backend/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Paquete backend del MVP de AgroVisión (gateway FastAPI + inferencia de conteo)."""
|
backend/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Rutas HTTP del backend del MVP (healthcheck y conteo)."""
|
backend/api/count.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Archivo: count.py
|
| 3 |
+
Fecha de modificación: 03/06/2026
|
| 4 |
+
Autor: Equipo AgroVisión
|
| 5 |
+
|
| 6 |
+
Descripción:
|
| 7 |
+
Define las rutas HTTP del MVP: el healthcheck `/api/status` y el conteo síncrono
|
| 8 |
+
`/api/count`. El conteo respeta el modo **standby**: mientras `COUNTING_ENABLED`
|
| 9 |
+
sea falso o el modelo no esté cargado, responde 503 con un mensaje claro en vez
|
| 10 |
+
de intentar inferir.
|
| 11 |
+
|
| 12 |
+
Acciones Principales:
|
| 13 |
+
- Expone `/api/status` (estado + bandera de conteo).
|
| 14 |
+
- Expone `/api/count` (inferencia síncrona, gateada por standby).
|
| 15 |
+
|
| 16 |
+
Estructura Interna:
|
| 17 |
+
- `router`: APIRouter con las rutas del MVP.
|
| 18 |
+
- `_draw_overlay`: dibuja las cajas detectadas sobre la imagen.
|
| 19 |
+
|
| 20 |
+
Entradas / Dependencias:
|
| 21 |
+
- `fastapi`, `numpy`, `opencv-python`, `backend.config`, `backend.core.*`, `backend.schemas`.
|
| 22 |
+
|
| 23 |
+
Salidas / Efectos:
|
| 24 |
+
- Ninguno persistente; la respuesta es efímera (overlay en base64).
|
| 25 |
+
|
| 26 |
+
Integración UI:
|
| 27 |
+
- Este router es montado por `backend.main:app`.
|
| 28 |
+
- La UI Shiny consume `/api/status` y `/api/count` vía HTTPS.
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
from __future__ import annotations
|
| 32 |
+
|
| 33 |
+
import base64
|
| 34 |
+
|
| 35 |
+
import cv2
|
| 36 |
+
import numpy as np
|
| 37 |
+
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile, status
|
| 38 |
+
|
| 39 |
+
from backend.config import get_settings
|
| 40 |
+
from backend.core.detection import CLASS_PLANT, Detection
|
| 41 |
+
from backend.core.metrics import compute_metrics
|
| 42 |
+
from backend.schemas import CountResponse, StatusResponse
|
| 43 |
+
|
| 44 |
+
router = APIRouter()
|
| 45 |
+
|
| 46 |
+
_OVERLAY_COLOR_BGR: tuple[int, int, int] = (61, 128, 21) # verde Deep Canopy (#15803D) en BGR
|
| 47 |
+
_OVERLAY_THICKNESS: int = 2
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _draw_overlay(image_bgr: np.ndarray, detections: list[Detection]) -> str:
|
| 51 |
+
"""
|
| 52 |
+
Dibuja las cajas detectadas sobre la imagen y la codifica como PNG en base64.
|
| 53 |
+
|
| 54 |
+
Args:
|
| 55 |
+
image_bgr (np.ndarray): Imagen original en formato BGR.
|
| 56 |
+
detections (list[Detection]): Detecciones a superponer.
|
| 57 |
+
|
| 58 |
+
Returns:
|
| 59 |
+
str: Imagen anotada (PNG) codificada en base64.
|
| 60 |
+
"""
|
| 61 |
+
overlay = image_bgr.copy()
|
| 62 |
+
for detection in detections:
|
| 63 |
+
top_left = (int(detection.x1), int(detection.y1))
|
| 64 |
+
bottom_right = (int(detection.x2), int(detection.y2))
|
| 65 |
+
cv2.rectangle(overlay, top_left, bottom_right, _OVERLAY_COLOR_BGR, _OVERLAY_THICKNESS)
|
| 66 |
+
_, buffer = cv2.imencode(".png", overlay)
|
| 67 |
+
return base64.b64encode(buffer.tobytes()).decode("ascii")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.get("/api/status", response_model=StatusResponse)
|
| 71 |
+
def get_status() -> StatusResponse:
|
| 72 |
+
"""
|
| 73 |
+
Devuelve el estado del backend y si el módulo de conteo está activo.
|
| 74 |
+
|
| 75 |
+
Returns:
|
| 76 |
+
StatusResponse: Estado, nombre/versión del modelo y bandera de conteo.
|
| 77 |
+
"""
|
| 78 |
+
settings = get_settings()
|
| 79 |
+
return StatusResponse(
|
| 80 |
+
status="ok",
|
| 81 |
+
model="agrovision-plantcount",
|
| 82 |
+
version=settings.model_version,
|
| 83 |
+
counting_enabled=settings.counting_enabled,
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@router.post("/api/count", response_model=CountResponse)
|
| 88 |
+
async def post_count(
|
| 89 |
+
request: Request,
|
| 90 |
+
file: UploadFile = File(...),
|
| 91 |
+
area_ha: float = Form(default=1.0),
|
| 92 |
+
) -> CountResponse:
|
| 93 |
+
"""
|
| 94 |
+
Ejecuta el conteo síncrono sobre un ortomosaico, respetando el modo standby.
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
request (Request): Petición; expone el adaptador en `request.app.state`.
|
| 98 |
+
file (UploadFile): Ortomosaico RGB en formato JPG/PNG/TIFF.
|
| 99 |
+
area_ha (float): Área del lote en hectáreas para calcular densidad.
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
CountResponse: Conteo, densidad, malezas, fallas, confianza y overlay.
|
| 103 |
+
|
| 104 |
+
Raises:
|
| 105 |
+
HTTPException: 503 si el conteo está en standby; 400 si la imagen es inválida.
|
| 106 |
+
"""
|
| 107 |
+
settings = get_settings()
|
| 108 |
+
adapter = getattr(request.app.state, "adapter", None)
|
| 109 |
+
|
| 110 |
+
if not settings.counting_enabled or adapter is None:
|
| 111 |
+
raise HTTPException(
|
| 112 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 113 |
+
detail=(
|
| 114 |
+
"Módulo de conteo en standby: el modelo aún no está disponible. "
|
| 115 |
+
"Se activará cuando el repo del modelo publique el artefacto."
|
| 116 |
+
),
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
raw_bytes = await file.read()
|
| 120 |
+
image_bgr = cv2.imdecode(np.frombuffer(raw_bytes, np.uint8), cv2.IMREAD_COLOR)
|
| 121 |
+
if image_bgr is None:
|
| 122 |
+
raise HTTPException(
|
| 123 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 124 |
+
detail="No se pudo decodificar la imagen. Use JPG, PNG o TIFF válido.",
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
detections = adapter.predict(image_bgr, confidence=settings.confidence_threshold)
|
| 128 |
+
metrics = compute_metrics(detections, area_ha=area_ha)
|
| 129 |
+
plant_detections = [d for d in detections if d.class_id == CLASS_PLANT]
|
| 130 |
+
overlay_b64 = _draw_overlay(image_bgr, plant_detections)
|
| 131 |
+
|
| 132 |
+
return CountResponse(
|
| 133 |
+
count=metrics["count"],
|
| 134 |
+
density=metrics["density"],
|
| 135 |
+
weeds=metrics["weeds"],
|
| 136 |
+
failures=metrics["failures"],
|
| 137 |
+
confidence=metrics["confidence"],
|
| 138 |
+
overlay_b64=overlay_b64,
|
| 139 |
+
)
|
backend/config.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Archivo: config.py
|
| 3 |
+
Fecha de modificación: 03/06/2026
|
| 4 |
+
Autor: Equipo AgroVisión
|
| 5 |
+
|
| 6 |
+
Descripción:
|
| 7 |
+
Centraliza la configuración del backend del MVP leyéndola de variables de
|
| 8 |
+
entorno, evitando valores mágicos dispersos por el código y facilitando el
|
| 9 |
+
despliegue en distintos entornos (local, Render).
|
| 10 |
+
|
| 11 |
+
Acciones Principales:
|
| 12 |
+
- Expone `get_settings`, que construye y cachea la configuración del entorno.
|
| 13 |
+
|
| 14 |
+
Estructura Interna:
|
| 15 |
+
- `Settings`: dataclass inmutable con los parámetros del backend.
|
| 16 |
+
- `get_settings`: lee el entorno y devuelve la configuración cacheada.
|
| 17 |
+
|
| 18 |
+
Entradas / Dependencias:
|
| 19 |
+
- Variables de entorno: APP_ENV, MODEL_PATH, MODEL_VERSION, MODEL_ARCHITECTURE,
|
| 20 |
+
ALLOWED_ORIGINS, COUNTING_ENABLED, MAX_UPLOAD_MB, CONFIDENCE_THRESHOLD.
|
| 21 |
+
|
| 22 |
+
Salidas / Efectos:
|
| 23 |
+
- No genera efectos secundarios; únicamente lee variables de entorno.
|
| 24 |
+
|
| 25 |
+
Ejemplo de Integración:
|
| 26 |
+
from backend.config import get_settings
|
| 27 |
+
settings = get_settings()
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
import os
|
| 33 |
+
from dataclasses import dataclass
|
| 34 |
+
from functools import lru_cache
|
| 35 |
+
|
| 36 |
+
from dotenv import load_dotenv
|
| 37 |
+
|
| 38 |
+
load_dotenv() # carga variables desde .env en desarrollo local (no sobreescribe el entorno real)
|
| 39 |
+
|
| 40 |
+
DEFAULT_CONFIDENCE_THRESHOLD: float = 0.25
|
| 41 |
+
DEFAULT_MAX_UPLOAD_MB: int = 50
|
| 42 |
+
DEFAULT_MODEL_PATH: str = "./models/agrovision-plantcount-v2.0.0.onnx"
|
| 43 |
+
DEFAULT_MODEL_VERSION: str = "2.0.0"
|
| 44 |
+
DEFAULT_MODEL_ARCHITECTURE: str = "yolo26n"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _parse_bool(raw: str | None, default: bool = False) -> bool:
|
| 48 |
+
"""
|
| 49 |
+
Convierte una variable de entorno textual en un booleano.
|
| 50 |
+
|
| 51 |
+
Args:
|
| 52 |
+
raw (str | None): Valor crudo de la variable de entorno.
|
| 53 |
+
default (bool, opcional): Valor a devolver si `raw` es None. Por defecto False.
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
bool: True si el texto representa un valor verdadero ('1', 'true', 'yes', 'on').
|
| 57 |
+
"""
|
| 58 |
+
if raw is None:
|
| 59 |
+
return default
|
| 60 |
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@dataclass(frozen=True)
|
| 64 |
+
class Settings:
|
| 65 |
+
"""Configuración inmutable del backend del MVP, derivada del entorno."""
|
| 66 |
+
|
| 67 |
+
app_env: str
|
| 68 |
+
model_path: str
|
| 69 |
+
model_version: str
|
| 70 |
+
model_architecture: str
|
| 71 |
+
allowed_origins: tuple[str, ...]
|
| 72 |
+
counting_enabled: bool
|
| 73 |
+
max_upload_mb: int
|
| 74 |
+
confidence_threshold: float
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@lru_cache
|
| 78 |
+
def get_settings() -> Settings:
|
| 79 |
+
"""
|
| 80 |
+
Construye la configuración del backend a partir de variables de entorno.
|
| 81 |
+
|
| 82 |
+
Returns:
|
| 83 |
+
Settings: Instancia cacheada con la configuración activa del backend.
|
| 84 |
+
"""
|
| 85 |
+
origins_raw = os.getenv("ALLOWED_ORIGINS", "http://localhost:8001")
|
| 86 |
+
origins = tuple(origin.strip() for origin in origins_raw.split(",") if origin.strip())
|
| 87 |
+
return Settings(
|
| 88 |
+
app_env=os.getenv("APP_ENV", "development"),
|
| 89 |
+
model_path=os.getenv("MODEL_PATH", DEFAULT_MODEL_PATH),
|
| 90 |
+
model_version=os.getenv("MODEL_VERSION", DEFAULT_MODEL_VERSION),
|
| 91 |
+
model_architecture=os.getenv("MODEL_ARCHITECTURE", DEFAULT_MODEL_ARCHITECTURE),
|
| 92 |
+
allowed_origins=origins,
|
| 93 |
+
counting_enabled=_parse_bool(os.getenv("COUNTING_ENABLED"), default=False),
|
| 94 |
+
max_upload_mb=int(os.getenv("MAX_UPLOAD_MB", str(DEFAULT_MAX_UPLOAD_MB))),
|
| 95 |
+
confidence_threshold=float(
|
| 96 |
+
os.getenv("CONFIDENCE_THRESHOLD", str(DEFAULT_CONFIDENCE_THRESHOLD))
|
| 97 |
+
),
|
| 98 |
+
)
|
backend/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Lógica núcleo del backend: dominio de detección, métricas e inferencia."""
|
backend/core/detection.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Archivo: detection.py
|
| 3 |
+
Fecha de modificación: 03/06/2026
|
| 4 |
+
Autor: Equipo AgroVisión
|
| 5 |
+
|
| 6 |
+
Descripción:
|
| 7 |
+
Define el tipo de dominio `Detection` y las constantes de clases del modelo de
|
| 8 |
+
conteo. Es un módulo puro (sin dependencias pesadas) compartido por la inferencia
|
| 9 |
+
y por el cálculo de métricas, evitando importaciones circulares.
|
| 10 |
+
|
| 11 |
+
Acciones Principales:
|
| 12 |
+
- Provee la estructura inmutable de una detección y el catálogo de clases.
|
| 13 |
+
|
| 14 |
+
Estructura Interna:
|
| 15 |
+
- `Detection`: dataclass con caja, confianza y clase de una detección.
|
| 16 |
+
- Constantes `CLASS_*` y `CLASS_NAMES`: catálogo de clases del modelo.
|
| 17 |
+
|
| 18 |
+
Entradas / Dependencias:
|
| 19 |
+
- Solo librería estándar.
|
| 20 |
+
|
| 21 |
+
Salidas / Efectos:
|
| 22 |
+
- Ninguno; expone tipos y constantes.
|
| 23 |
+
|
| 24 |
+
Ejemplo de Integración:
|
| 25 |
+
from backend.core.detection import Detection, CLASS_PLANT
|
| 26 |
+
deteccion = Detection(0, 0, 10, 10, 0.9, CLASS_PLANT)
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
from dataclasses import dataclass
|
| 32 |
+
|
| 33 |
+
CLASS_PLANT: int = 0
|
| 34 |
+
CLASS_WEED: int = 1
|
| 35 |
+
CLASS_NAMES: dict[int, str] = {CLASS_PLANT: "planta", CLASS_WEED: "maleza"}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass(frozen=True)
|
| 39 |
+
class Detection:
|
| 40 |
+
"""Detección individual con caja delimitadora, confianza y clase."""
|
| 41 |
+
|
| 42 |
+
x1: float
|
| 43 |
+
y1: float
|
| 44 |
+
x2: float
|
| 45 |
+
y2: float
|
| 46 |
+
confidence: float
|
| 47 |
+
class_id: int
|
backend/core/inference.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Archivo: inference.py
|
| 3 |
+
Fecha de modificación: 03/06/2026
|
| 4 |
+
Autor: Equipo AgroVisión
|
| 5 |
+
|
| 6 |
+
Descripción:
|
| 7 |
+
Adaptador de inferencia **agnóstico a la arquitectura**. Carga el artefacto ONNX
|
| 8 |
+
del modelo de conteo con onnxruntime y delega el decode según la arquitectura
|
| 9 |
+
declarada (YOLO26 o RF-DETR), manteniendo la app desacoplada del modelo concreto
|
| 10 |
+
publicado por el repo del modelo. En el MVP el conteo está en standby, por lo que
|
| 11 |
+
el decode permanece como contrato pendiente hasta que el modelo se publique.
|
| 12 |
+
|
| 13 |
+
Sustentación Científica: [Opcional]
|
| 14 |
+
Los detectores objetivo (YOLO26, RF-DETR) son NMS-free, por lo que el conteo
|
| 15 |
+
equivale al número de detecciones que superan el umbral de confianza.
|
| 16 |
+
|
| 17 |
+
Acciones Principales:
|
| 18 |
+
- Carga un modelo ONNX y expone `predict` para obtener detecciones.
|
| 19 |
+
|
| 20 |
+
Estructura Interna:
|
| 21 |
+
- `ModelNotAvailableError`: error cuando el modelo no está disponible.
|
| 22 |
+
- `InferenceAdapter`: envuelve la sesión ONNX y despacha el decode por arquitectura.
|
| 23 |
+
- `load_adapter`: valida la ruta y construye el adaptador.
|
| 24 |
+
|
| 25 |
+
Entradas / Dependencias:
|
| 26 |
+
- `numpy`, `onnxruntime` (import diferido), `backend.core.detection.Detection`.
|
| 27 |
+
|
| 28 |
+
Salidas / Efectos:
|
| 29 |
+
- Ninguno persistente; ejecuta inferencia en memoria.
|
| 30 |
+
|
| 31 |
+
Ejemplo de Integración:
|
| 32 |
+
from backend.core.inference import load_adapter
|
| 33 |
+
adapter = load_adapter("models/agrovision-plantcount-v2.0.0.onnx", "yolo26n")
|
| 34 |
+
detecciones = adapter.predict(imagen_bgr, confidence=0.25)
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
from __future__ import annotations
|
| 38 |
+
|
| 39 |
+
from pathlib import Path
|
| 40 |
+
|
| 41 |
+
import numpy as np
|
| 42 |
+
|
| 43 |
+
from backend.core.detection import Detection
|
| 44 |
+
|
| 45 |
+
SUPPORTED_ARCHITECTURES: frozenset[str] = frozenset({"yolo26n", "rfdetr_nano"})
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class ModelNotAvailableError(RuntimeError):
|
| 49 |
+
"""Se lanza cuando el modelo de conteo no está disponible (standby o archivo ausente)."""
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class InferenceAdapter:
|
| 53 |
+
"""
|
| 54 |
+
Adaptador de inferencia agnóstico a la arquitectura del modelo de conteo.
|
| 55 |
+
|
| 56 |
+
Envuelve una sesión de onnxruntime y traduce su salida a una lista de
|
| 57 |
+
`Detection`, delegando en el decode específico de cada arquitectura soportada.
|
| 58 |
+
"""
|
| 59 |
+
|
| 60 |
+
def __init__(self, model_path: str, architecture: str) -> None:
|
| 61 |
+
"""
|
| 62 |
+
Inicializa la sesión ONNX para el modelo indicado.
|
| 63 |
+
|
| 64 |
+
Args:
|
| 65 |
+
model_path (str): Ruta al artefacto `.onnx` del modelo de conteo.
|
| 66 |
+
architecture (str): Arquitectura del modelo ('yolo26n' o 'rfdetr_nano').
|
| 67 |
+
|
| 68 |
+
Raises:
|
| 69 |
+
ModelNotAvailableError: Si la arquitectura no está soportada.
|
| 70 |
+
"""
|
| 71 |
+
if architecture not in SUPPORTED_ARCHITECTURES:
|
| 72 |
+
raise ModelNotAvailableError(
|
| 73 |
+
f"Arquitectura no soportada: {architecture}. "
|
| 74 |
+
f"Soportadas: {sorted(SUPPORTED_ARCHITECTURES)}."
|
| 75 |
+
)
|
| 76 |
+
import onnxruntime as ort # import diferido: onnxruntime no se exige en modo standby
|
| 77 |
+
|
| 78 |
+
self._architecture = architecture
|
| 79 |
+
self._session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
|
| 80 |
+
|
| 81 |
+
def predict(self, image_bgr: np.ndarray, confidence: float) -> list[Detection]:
|
| 82 |
+
"""
|
| 83 |
+
Ejecuta la inferencia sobre una imagen y devuelve las detecciones.
|
| 84 |
+
|
| 85 |
+
Args:
|
| 86 |
+
image_bgr (np.ndarray): Imagen RGB/BGR como arreglo de NumPy.
|
| 87 |
+
confidence (float): Umbral mínimo de confianza para conservar detecciones.
|
| 88 |
+
|
| 89 |
+
Returns:
|
| 90 |
+
list[Detection]: Detecciones que superan el umbral (sin NMS, modelos NMS-free).
|
| 91 |
+
"""
|
| 92 |
+
if self._architecture == "yolo26n":
|
| 93 |
+
return self._decode_yolo(image_bgr, confidence)
|
| 94 |
+
return self._decode_detr(image_bgr, confidence)
|
| 95 |
+
|
| 96 |
+
def _decode_yolo(self, image_bgr: np.ndarray, confidence: float) -> list[Detection]:
|
| 97 |
+
"""
|
| 98 |
+
Decodifica la salida de un modelo YOLO26 a una lista de detecciones.
|
| 99 |
+
|
| 100 |
+
Nota: el decode concreto se implementa cuando el repo del modelo publique el
|
| 101 |
+
artefacto YOLO26 y se conozca el layout exacto del tensor de salida.
|
| 102 |
+
|
| 103 |
+
Raises:
|
| 104 |
+
NotImplementedError: Mientras el modelo de conteo esté en standby.
|
| 105 |
+
"""
|
| 106 |
+
raise NotImplementedError(
|
| 107 |
+
"Decode YOLO26 pendiente: se implementa al publicar el modelo de conteo."
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
def _decode_detr(self, image_bgr: np.ndarray, confidence: float) -> list[Detection]:
|
| 111 |
+
"""
|
| 112 |
+
Decodifica la salida de un modelo RF-DETR a una lista de detecciones.
|
| 113 |
+
|
| 114 |
+
Nota: el decode concreto se implementa cuando el repo del modelo publique el
|
| 115 |
+
artefacto RF-DETR y se conozca el layout exacto del tensor de salida.
|
| 116 |
+
|
| 117 |
+
Raises:
|
| 118 |
+
NotImplementedError: Mientras el modelo de conteo esté en standby.
|
| 119 |
+
"""
|
| 120 |
+
raise NotImplementedError(
|
| 121 |
+
"Decode RF-DETR pendiente: se implementa al publicar el modelo de conteo."
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def load_adapter(model_path: str, architecture: str) -> InferenceAdapter:
|
| 126 |
+
"""
|
| 127 |
+
Valida la existencia del artefacto y construye el adaptador de inferencia.
|
| 128 |
+
|
| 129 |
+
Args:
|
| 130 |
+
model_path (str): Ruta al artefacto `.onnx` del modelo de conteo.
|
| 131 |
+
architecture (str): Arquitectura del modelo ('yolo26n' o 'rfdetr_nano').
|
| 132 |
+
|
| 133 |
+
Returns:
|
| 134 |
+
InferenceAdapter: Adaptador listo para inferir.
|
| 135 |
+
|
| 136 |
+
Raises:
|
| 137 |
+
ModelNotAvailableError: Si el archivo del modelo no existe en `model_path`.
|
| 138 |
+
"""
|
| 139 |
+
if not Path(model_path).exists():
|
| 140 |
+
raise ModelNotAvailableError(
|
| 141 |
+
f"Modelo no encontrado en {model_path}. Se descarga de Hugging Face Hub "
|
| 142 |
+
"en el build cuando el repo del modelo publique el artefacto."
|
| 143 |
+
)
|
| 144 |
+
return InferenceAdapter(model_path, architecture)
|
backend/core/metrics.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Archivo: metrics.py
|
| 3 |
+
Fecha de modificación: 03/06/2026
|
| 4 |
+
Autor: Equipo AgroVisión
|
| 5 |
+
|
| 6 |
+
Descripción:
|
| 7 |
+
Convierte el resultado crudo de la detección (lista de cajas) en los indicadores
|
| 8 |
+
agronómicos de negocio: conteo de plantas, densidad por hectárea, número de
|
| 9 |
+
malezas, porcentaje de fallas de siembra y confianza media. Se mantiene aislado
|
| 10 |
+
de la inferencia para poder probarlo de forma determinista.
|
| 11 |
+
|
| 12 |
+
Acciones Principales:
|
| 13 |
+
- Calcula las métricas de negocio a partir de una lista de detecciones.
|
| 14 |
+
|
| 15 |
+
Estructura Interna:
|
| 16 |
+
- `compute_metrics`: agrega las detecciones en métricas de negocio.
|
| 17 |
+
- `_estimate_failures`: heurística de fallas de siembra (placeholder del MVP).
|
| 18 |
+
|
| 19 |
+
Entradas / Dependencias:
|
| 20 |
+
- `backend.core.detection.Detection`.
|
| 21 |
+
|
| 22 |
+
Salidas / Efectos:
|
| 23 |
+
- Ninguno; función pura que retorna un diccionario de métricas.
|
| 24 |
+
|
| 25 |
+
Ejemplo de Integración:
|
| 26 |
+
from backend.core.metrics import compute_metrics
|
| 27 |
+
metricas = compute_metrics(detecciones, area_ha=1.0)
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
from backend.core.detection import CLASS_PLANT, CLASS_WEED, Detection
|
| 33 |
+
|
| 34 |
+
SQUARE_METERS_PER_HECTARE: int = 10_000
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _estimate_failures(plant_count: int) -> float:
|
| 38 |
+
"""
|
| 39 |
+
Estima el porcentaje de fallas de siembra (huecos en hilera).
|
| 40 |
+
|
| 41 |
+
Nota: en el MVP devuelve 0.0 como marcador. La heurística real (detección de
|
| 42 |
+
huecos en hileras) se incorpora cuando el modelo de conteo esté publicado y se
|
| 43 |
+
disponga de la disposición espacial de las plantas.
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
plant_count (int): Número de plantas detectadas.
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
float: Porcentaje estimado de fallas de siembra en el rango [0, 100].
|
| 50 |
+
"""
|
| 51 |
+
return 0.0
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def compute_metrics(detections: list[Detection], area_ha: float) -> dict[str, float]:
|
| 55 |
+
"""
|
| 56 |
+
Agrega una lista de detecciones en los indicadores agronómicos de negocio.
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
detections (list[Detection]): Detecciones producidas por el modelo.
|
| 60 |
+
area_ha (float): Área del lote en hectáreas, usada para la densidad.
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
dict[str, float]: Diccionario con las claves 'count', 'weeds', 'density',
|
| 64 |
+
'failures' y 'confidence'.
|
| 65 |
+
"""
|
| 66 |
+
plant_count = sum(1 for detection in detections if detection.class_id == CLASS_PLANT)
|
| 67 |
+
weed_count = sum(1 for detection in detections if detection.class_id == CLASS_WEED)
|
| 68 |
+
|
| 69 |
+
confidences = [detection.confidence for detection in detections]
|
| 70 |
+
mean_confidence = round(sum(confidences) / len(confidences), 2) if confidences else 0.0
|
| 71 |
+
|
| 72 |
+
density = round(plant_count / area_ha, 1) if area_ha > 0 else 0.0
|
| 73 |
+
|
| 74 |
+
return {
|
| 75 |
+
"count": plant_count,
|
| 76 |
+
"weeds": weed_count,
|
| 77 |
+
"density": density,
|
| 78 |
+
"failures": _estimate_failures(plant_count),
|
| 79 |
+
"confidence": mean_confidence,
|
| 80 |
+
}
|
backend/main.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Archivo: main.py
|
| 3 |
+
Fecha de modificación: 03/06/2026
|
| 4 |
+
Autor: Equipo AgroVisión
|
| 5 |
+
|
| 6 |
+
Descripción:
|
| 7 |
+
Punto de entrada del backend FastAPI del MVP. Configura CORS, carga el modelo de
|
| 8 |
+
conteo en el `lifespan` (si está habilitado y disponible) y monta las rutas del
|
| 9 |
+
MVP. Soporta el modo **standby**: si el conteo está deshabilitado o el modelo no
|
| 10 |
+
existe, el backend arranca igual y el endpoint de conteo responde 503.
|
| 11 |
+
|
| 12 |
+
Acciones Principales:
|
| 13 |
+
- Crea la app FastAPI, configura CORS y carga (opcionalmente) el modelo.
|
| 14 |
+
|
| 15 |
+
Estructura Interna:
|
| 16 |
+
- `lifespan`: gestiona la carga/descarga del adaptador de inferencia.
|
| 17 |
+
- `create_app`: construye y configura la instancia de FastAPI.
|
| 18 |
+
- `app`: instancia ASGI servida por uvicorn.
|
| 19 |
+
|
| 20 |
+
Entradas / Dependencias:
|
| 21 |
+
- `fastapi`, `backend.config`, `backend.core.inference`, `backend.api.count`.
|
| 22 |
+
|
| 23 |
+
Salidas / Efectos:
|
| 24 |
+
- Expone un servicio ASGI en el puerto configurado.
|
| 25 |
+
|
| 26 |
+
Ejecución:
|
| 27 |
+
uv run uvicorn backend.main:app --host 0.0.0.0 --port 8000
|
| 28 |
+
|
| 29 |
+
Ejemplo de Uso:
|
| 30 |
+
uv run uvicorn backend.main:app --reload --port 8000
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
import logging
|
| 36 |
+
from collections.abc import AsyncIterator
|
| 37 |
+
from contextlib import asynccontextmanager
|
| 38 |
+
|
| 39 |
+
from fastapi import FastAPI
|
| 40 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 41 |
+
|
| 42 |
+
from backend.api.count import router as count_router
|
| 43 |
+
from backend.config import get_settings
|
| 44 |
+
from backend.core.inference import ModelNotAvailableError, load_adapter
|
| 45 |
+
|
| 46 |
+
_logger = logging.getLogger("agrovision.backend")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@asynccontextmanager
|
| 50 |
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
| 51 |
+
"""
|
| 52 |
+
Carga el adaptador de inferencia al iniciar y lo libera al terminar.
|
| 53 |
+
|
| 54 |
+
En modo standby (conteo deshabilitado o modelo ausente) deja el adaptador en
|
| 55 |
+
None y registra el motivo, permitiendo que el backend arranque de todas formas.
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
app (FastAPI): Instancia de la aplicación cuyo estado se inicializa.
|
| 59 |
+
|
| 60 |
+
Yields:
|
| 61 |
+
None: Cede el control mientras la app está en ejecución.
|
| 62 |
+
"""
|
| 63 |
+
settings = get_settings()
|
| 64 |
+
app.state.adapter = None
|
| 65 |
+
|
| 66 |
+
if settings.counting_enabled:
|
| 67 |
+
try:
|
| 68 |
+
app.state.adapter = load_adapter(settings.model_path, settings.model_architecture)
|
| 69 |
+
_logger.info("Modelo de conteo cargado: %s", settings.model_architecture)
|
| 70 |
+
except ModelNotAvailableError as error:
|
| 71 |
+
_logger.warning("Conteo en standby (modelo no disponible): %s", error)
|
| 72 |
+
else:
|
| 73 |
+
_logger.info("Conteo en standby (COUNTING_ENABLED=false).")
|
| 74 |
+
|
| 75 |
+
yield
|
| 76 |
+
app.state.adapter = None
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def create_app() -> FastAPI:
|
| 80 |
+
"""
|
| 81 |
+
Construye y configura la instancia de FastAPI del MVP.
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
FastAPI: Aplicación con CORS y rutas montadas.
|
| 85 |
+
"""
|
| 86 |
+
settings = get_settings()
|
| 87 |
+
app = FastAPI(
|
| 88 |
+
title="AgroVisión MVP — Backend",
|
| 89 |
+
version=settings.model_version,
|
| 90 |
+
lifespan=lifespan,
|
| 91 |
+
)
|
| 92 |
+
app.add_middleware(
|
| 93 |
+
CORSMiddleware,
|
| 94 |
+
allow_origins=list(settings.allowed_origins),
|
| 95 |
+
allow_methods=["*"],
|
| 96 |
+
allow_headers=["*"],
|
| 97 |
+
)
|
| 98 |
+
app.include_router(count_router)
|
| 99 |
+
return app
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
app = create_app()
|
backend/schemas.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Archivo: schemas.py
|
| 3 |
+
Fecha de modificación: 03/06/2026
|
| 4 |
+
Autor: Equipo AgroVisión
|
| 5 |
+
|
| 6 |
+
Descripción:
|
| 7 |
+
Define los contratos de datos (Pydantic) que entran y salen de la API del MVP.
|
| 8 |
+
Validan los límites del sistema, documentan el acuerdo UI↔backend y rechazan
|
| 9 |
+
payloads inválidos antes de que contaminen la lógica.
|
| 10 |
+
|
| 11 |
+
Acciones Principales:
|
| 12 |
+
- Declara los esquemas de respuesta del healthcheck y del conteo.
|
| 13 |
+
|
| 14 |
+
Estructura Interna:
|
| 15 |
+
- `StatusResponse`: respuesta del endpoint de salud.
|
| 16 |
+
- `CountResponse`: respuesta del endpoint de conteo.
|
| 17 |
+
|
| 18 |
+
Entradas / Dependencias:
|
| 19 |
+
- `pydantic`.
|
| 20 |
+
|
| 21 |
+
Salidas / Efectos:
|
| 22 |
+
- Ninguno; expone modelos de validación/serialización.
|
| 23 |
+
|
| 24 |
+
Ejemplo de Integración:
|
| 25 |
+
from backend.schemas import CountResponse
|
| 26 |
+
respuesta = CountResponse(count=124, density=72400, weeds=12,
|
| 27 |
+
failures=1.2, confidence=0.91, overlay_b64="...")
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
from pydantic import BaseModel, Field, field_validator
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class StatusResponse(BaseModel):
|
| 36 |
+
"""Respuesta del healthcheck del backend, incluye el estado del módulo de conteo."""
|
| 37 |
+
|
| 38 |
+
status: str = Field(default="ok")
|
| 39 |
+
model: str = Field(description="Nombre de marca del modelo de conteo")
|
| 40 |
+
version: str = Field(description="Versión semántica del modelo predeterminado")
|
| 41 |
+
counting_enabled: bool = Field(description="Indica si el conteo está activo o en standby")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class CountResponse(BaseModel):
|
| 45 |
+
"""Resultado del conteo de plantas sobre un ortomosaico RGB."""
|
| 46 |
+
|
| 47 |
+
count: int = Field(ge=0, description="Total de plantas/arbustos detectados")
|
| 48 |
+
density: float = Field(ge=0, description="Plantas por hectárea")
|
| 49 |
+
weeds: int = Field(ge=0, description="Número de malezas detectadas")
|
| 50 |
+
failures: float = Field(ge=0, le=100, description="Porcentaje de fallas de siembra")
|
| 51 |
+
confidence: float = Field(ge=0, le=1, description="Confianza media de las detecciones")
|
| 52 |
+
overlay_b64: str = Field(description="Imagen anotada (PNG) codificada en base64")
|
| 53 |
+
|
| 54 |
+
@field_validator("confidence")
|
| 55 |
+
@classmethod
|
| 56 |
+
def _validar_confianza(cls, value: float) -> float:
|
| 57 |
+
"""
|
| 58 |
+
Verifica que la confianza media esté estrictamente en el rango [0, 1].
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
value (float): Confianza media propuesta.
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
float: La misma confianza si es válida.
|
| 65 |
+
|
| 66 |
+
Raises:
|
| 67 |
+
ValueError: Si la confianza queda fuera del rango [0, 1].
|
| 68 |
+
"""
|
| 69 |
+
if not 0.0 <= value <= 1.0:
|
| 70 |
+
raise ValueError("La confianza debe estar en el rango [0, 1].")
|
| 71 |
+
return value
|