alexp97 commited on
Commit
53371fd
·
1 Parent(s): a63ac15

feat(backend): API FastAPI de conteo con modo standby

Browse files

Implementa 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 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