YOLOv8 tiene una API de Python limpia. Entrenar un modelo, hacer inferencia en local, visualizar resultados — todo funciona en diez líneas. Desplegar ese modelo en producción para servir requests de múltiples usuarios concurrentes con latencia aceptable y trazabilidad de resultados es un problema diferente.
Este post es el diseño que usé para llevar un modelo YOLOv8 de detección de objetos a producción — con todos los componentes que los tutoriales de Ultralytics no cubren.
El problema de arquitectura
El sistema del que forma parte usa .NET para la API principal. El modelo YOLOv8 corre en Python. Dos opciones para integrar:
- ONNX Runtime en .NET: exportar el modelo a ONNX, cargarlo desde el runtime de .NET. Sin Python en producción.
- Microservicio Python: API .NET llama a un microservicio Python que hace la inferencia. Python en producción, pero con aislamiento limpio.
Elegí la opción 2. La razón: el preprocessing y postprocessing del modelo (normalización de imágenes, NMS, filtrado por clase y confidence) es más fácil de mantener en Python donde el ecosistema de computer vision es más maduro. Y ONNX Runtime en .NET con YOLOv8 requiere implementar manualmente el preprocessing que Ultralytics hace automáticamente.
Arquitectura del sistema
Cliente HTTP
↓
API .NET (controller)
↓ HTTP/gRPC interno
Microservicio Python (FastAPI + YOLOv8)
↓
Resultado JSON {detecciones, confianza, clase, bbox}
↓
API .NET procesa y retorna al cliente
El microservicio Python expone un endpoint de inferencia. La API .NET actúa como orquestador — recibe la imagen, la envía al microservicio, procesa el resultado, y retorna al cliente.
Microservicio Python: FastAPI + YOLOv8
from fastapi import FastAPI, UploadFile, HTTPException
from ultralytics import YOLO
from pydantic import BaseModel
import numpy as np
from PIL import Image
import io
import logging
logger = logging.getLogger(__name__)
class Detection(BaseModel):
class_id: int
class_name: str
confidence: float
bbox: list[float] # [x1, y1, x2, y2] normalized 0-1
class InferenceResult(BaseModel):
detections: list[Detection]
model_version: str
inference_time_ms: float
app = FastAPI()
model: YOLO | None = None
MODEL_VERSION = "v1.2.0"
@app.on_event("startup")
async def load_model():
global model
logger.info(f"Loading model version {MODEL_VERSION}")
model = YOLO("models/detector_v1.2.0.pt")
# Warm-up: primera inferencia es lenta por inicialización
dummy = np.zeros((640, 640, 3), dtype=np.uint8)
model.predict(dummy, verbose=False)
logger.info("Model loaded and warmed up")
@app.post("/infer", response_model=InferenceResult)
async def infer(file: UploadFile):
if model is None:
raise HTTPException(503, "Model not loaded")
if not file.content_type.startswith("image/"):
raise HTTPException(400, f"Expected image, got {file.content_type}")
import time
start = time.perf_counter()
# Leer imagen
image_bytes = await file.read()
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
# Inferencia
results = model.predict(
source=image,
conf=0.45, # Umbral de confidence
iou=0.5, # Umbral NMS
verbose=False,
device="cpu", # CPU para inferencia en container sin GPU
)
elapsed_ms = (time.perf_counter() - start) * 1000
# Procesar resultados
detections = []
img_w, img_h = image.size
for result in results:
if result.boxes is None:
continue
for box in result.boxes:
x1, y1, x2, y2 = box.xyxy[0].tolist()
detections.append(Detection(
class_id=int(box.cls[0]),
class_name=model.names[int(box.cls[0])],
confidence=float(box.conf[0]),
bbox=[
x1 / img_w, y1 / img_h,
x2 / img_w, y2 / img_h,
],
))
return InferenceResult(
detections=detections,
model_version=MODEL_VERSION,
inference_time_ms=elapsed_ms,
)
El warm-up es obligatorio
La primera inferencia de YOLOv8 es 3-5x más lenta que las siguientes — el modelo inicializa las capas, el compilador JIT de PyTorch compila las operaciones. Sin warm-up en startup, el primer request real tiene latencia de 2-5 segundos.
# Warm-up con imagen dummy en startup
dummy = np.zeros((640, 640, 3), dtype=np.uint8)
model.predict(dummy, verbose=False)
Versionado de modelos
Los modelos en producción cambian. Necesitas saber qué versión hizo cada predicción — para debugging, para comparar performance entre versiones, para auditoría.
El patrón: versionar el modelo como artifact (semver), guardar la versión en cada resultado:
models/
├── detector_v1.0.0.pt # Primera versión en producción
├── detector_v1.1.0.pt # Mejora F1 en clase X
└── detector_v1.2.0.pt # Versión activa
La versión del modelo se retorna en cada response. El caller (API .NET) puede guardar la versión en el log de la predicción para trazabilidad.
Para cambiar de versión sin downtime: el microservicio lee MODEL_VERSION de variable de entorno. Un nuevo deploy con la variable actualizada carga el modelo nuevo.
API .NET: llamar al microservicio con HttpClient
public class DetectorService
{
private readonly HttpClient _httpClient;
private readonly ILogger<DetectorService> _logger;
public DetectorService(HttpClient httpClient, ILogger<DetectorService> logger)
{
_httpClient = httpClient;
_logger = logger;
}
public async Task<InferenceResult> DetectAsync(
Stream imageStream,
string contentType,
CancellationToken cancellationToken = default)
{
using var content = new MultipartFormDataContent();
var streamContent = new StreamContent(imageStream);
streamContent.Headers.ContentType = new MediaTypeHeaderValue(contentType);
content.Add(streamContent, "file", "image.jpg");
var response = await _httpClient.PostAsync(
"/infer", content, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogError("Inference failed: {Status} {Error}",
response.StatusCode, error);
throw new InferenceException($"Model service error: {response.StatusCode}");
}
return await response.Content.ReadFromJsonAsync<InferenceResult>(
cancellationToken: cancellationToken)
?? throw new InferenceException("Empty response from model service");
}
}
En Program.cs, registrar HttpClient con política de retry y circuit breaker:
builder.Services.AddHttpClient<DetectorService>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["ModelService:BaseUrl"]!);
client.Timeout = TimeSpan.FromSeconds(30);
})
.AddTransientHttpErrorPolicy(policy =>
policy.WaitAndRetryAsync(
retryCount: 2,
sleepDurationProvider: retry => TimeSpan.FromSeconds(retry)))
.AddTransientHttpErrorPolicy(policy =>
policy.CircuitBreakerAsync(
handledEventsAllowedBeforeBreaking: 5,
durationOfBreak: TimeSpan.FromSeconds(30)));
El circuit breaker evita que una falla del microservicio de Python bloquee todos los requests de la API .NET con timeouts.
Preprocessing: por qué el tamaño de imagen importa
YOLOv8 espera imágenes de tamaño fijo (por defecto 640x640). El modelo las redimensiona internamente, pero la relación de aspecto afecta la calidad de detección.
Para imágenes con objetos pequeños (por ejemplo, texto en documentos o componentes industriales pequeños), usar tamaño 1280x1280 puede mejorar la detección a costo de 4x más tiempo de inferencia.
results = model.predict(
source=image,
imgsz=640, # o 1280 para mayor precisión en objetos pequeños
conf=0.45,
)
El tradeoff: mayor tamaño = mayor latencia = mayor memoria. Para producción con CPU, 640 es el punto de equilibrio habitual.
Logging de inferencias para auditoría
Cada predicción con su imagen, resultado, y metadata va a un log estructurado:
@app.post("/infer", response_model=InferenceResult)
async def infer(file: UploadFile, request: Request):
request_id = request.headers.get("X-Request-ID", str(uuid4()))
# ...inferencia...
logger.info("inference.completed", extra={
"request_id": request_id,
"model_version": MODEL_VERSION,
"detections_count": len(detections),
"inference_time_ms": elapsed_ms,
"image_size": f"{img_w}x{img_h}",
"classes_detected": list({d.class_name for d in detections}),
})
El request_id propagado desde la API .NET correlaciona el log de inferencia con el log del request principal.
Lo que aprendí
El warm-up no está en la documentación de YOLOv8. Lo descubrí porque el primer request siempre tardaba 4 segundos y los siguientes 200ms. Un health check endpoint que hace una inferencia dummy en startup resuelve esto.
ONNX vs microservicio Python: depende del equipo. ONNX en .NET elimina la dependencia Python, pero el preprocessing manual es propenso a errores que son difíciles de detectar (resultados incorrectos, no crashes). Si el equipo conoce Python mejor, el microservicio es más seguro.
Versionar modelos como artifacts, no como código. Los modelos .pt no van en git (son binarios de varios GB). Van en un blob storage versionado. El código solo referencia la versión por nombre.
Circuit breaker es obligatorio en llamadas a microservicios de ML. Los modelos de ML tienen latencia variable. Sin circuit breaker, un spike de latencia en el modelo puede hacer queue a cientos de requests en la API principal.