Ultralytics YOLO27:

Preprocesamiento acelerado por GPU con NVIDIA DALI#

Al desplegar modelos de Ultralytics YOLO en producción, el preprocesamiento suele convertirse en el cuello de botella. Aunque TensorRT puede ejecutar la inferencia del modelo en apenas unos milisegundos, el preprocesamiento basado en CPU (cambio de tamaño, relleno y normalización) puede tardar entre 2 y 10 ms por imagen, especialmente con resoluciones altas. NVIDIA DALI (biblioteca de carga de datos) resuelve este problema al trasladar toda la cadena de preprocesamiento a la GPU.

En esta guía aprenderás a crear cadenas de procesamiento de DALI que replican exactamente el preprocesamiento de Ultralytics YOLO, a integrarlas con model.predict(), a procesar flujos de vídeo y a realizar un despliegue integral con Triton Inference Server.

¿A quién va dirigida esta guía?

Esta guía está dirigida a ingenieros que despliegan modelos YOLO en entornos de producción donde el preprocesamiento en CPU supone un cuello de botella medido, normalmente en despliegues de TensorRT en GPU NVIDIA, cadenas de procesamiento de vídeo de alto rendimiento o configuraciones de Triton Inference Server. Si ejecutas inferencias estándar con model.predict() y no tienes un cuello de botella en el preprocesamiento, la cadena predeterminada de CPU funciona bien.

Resumen rápido
  • ¿Estás creando una cadena de procesamiento de DALI? Usa fn.resize(mode="not_larger") + fn.crop(out_of_bounds_policy="pad") + fn.crop_mirror_normalize para replicar en la GPU el preprocesamiento letterbox de YOLO.
  • ¿Vas a integrarlo con Ultralytics? Pasa la salida de DALI como torch.Tensor a model.predict(): Ultralytics omite automáticamente el preprocesamiento de imágenes.
  • ¿Vas a desplegarlo con Triton? Usa el backend de DALI con un conjunto de modelos TensorRT para prescindir del preprocesamiento en CPU.

Por qué usar DALI para el preprocesamiento de YOLO#

En una cadena de procesamiento de inferencia YOLO típica, los pasos de preprocesamiento se ejecutan en la CPU:

  1. Decodificar la imagen (JPEG/PNG)
  2. Cambiar el tamaño conservando la relación de aspecto
  3. Añadir relleno hasta alcanzar el tamaño objetivo (letterbox)
  4. Normalizar los valores de píxel de [0, 255] a [0, 1]
  5. Convertir el formato de HWC a CHW

Con DALI, todas estas operaciones se ejecutan en la GPU y se elimina el cuello de botella de la CPU. Esto resulta especialmente útil cuando:

SituaciónVentajas de DALI
Inferencia rápida en GPULos motores TensorRT con inferencia inferior a un milisegundo hacen que el preprocesamiento en CPU sea el coste predominante
Entradas de alta resoluciónLos flujos de vídeo de 1080p y 4K requieren operaciones de cambio de tamaño costosas
Tamaños de lote grandesInferencia en servidor que procesa muchas imágenes en paralelo
Número limitado de núcleos de CPUDispositivos periféricos como NVIDIA Jetson o servidores con GPU de alta densidad y pocos núcleos de CPU por GPU

Requisitos previos#

Solo Linux

NVIDIA DALI solo es compatible con Linux. No está disponible en Windows ni macOS.

Instala los paquetes necesarios:

pip install ultralytics
pip install --extra-index-url https://pypi.nvidia.com nvidia-dali-cuda130

Requisitos:

  • GPU NVIDIA (capacidad de cálculo 5.0 o superior / Maxwell o posterior)
  • CUDA 11.0+, 12.0+ o 13.0+
  • Python 3.10-3.14
  • Sistema operativo Linux

Cómo funciona el preprocesamiento de YOLO#

Antes de crear una cadena de procesamiento de DALI, conviene entender exactamente qué hace Ultralytics durante el preprocesamiento. La clase clave es LetterBox en ultralytics/data/augment.py:

from ultralytics.data.augment import LetterBox

letterbox = LetterBox(
    new_shape=(640, 640),  # Tamaño objetivo
    center=True,  # Centrar la imagen (añadir la misma cantidad de relleno a ambos lados)
    stride=32,  # Alineación de paso
    padding_value=114,  # Relleno gris (114, 114, 114)
)

La cadena completa de preprocesamiento de ultralytics/engine/predictor.py realiza estos pasos:

PasoOperaciónFunción de CPUEquivalente en DALI
1Cambio de tamaño letterboxcv2.resizefn.resize(mode="not_larger")
2Relleno centradocv2.copyMakeBorderfn.crop(out_of_bounds_policy="pad")
3BGR → RGBim[..., ::-1]fn.decoders.image(output_type=types.RGB)
4HWC → CHW + normalización /255np.transpose + tensor / 255fn.crop_mirror_normalize(std=[255,255,255])

La operación letterbox conserva la relación de aspecto mediante estos pasos:

  1. Calcular la escala: r = min(target_h / h, target_w / w)
  2. Cambiar el tamaño a (round(w * r), round(h * r))
  3. Rellenar el espacio restante de gris (114) para alcanzar el tamaño objetivo
  4. Centrar la imagen para distribuir el relleno por igual a ambos lados

Cadena de procesamiento de DALI para YOLO#

La cadena de procesamiento de DALI recomendada replica el comportamiento predeterminado de LetterBox(center=True) de Ultralytics, que es el que se usa en la inferencia estándar de YOLO.

Cadena centrada (recomendada; coincide con LetterBox de Ultralytics)#

Esta versión replica exactamente el preprocesamiento predeterminado de Ultralytics con relleno centrado, que coincide con LetterBox(center=True):

Cadena de procesamiento de DALI con relleno centrado (recomendada)
from nvidia import dali
from nvidia.dali import fn, types

@dali.pipeline_def(batch_size=8, num_threads=4, device_id=0)
def yolo_dali_pipeline_centered(image_dir, target_size=640):
    """DALI pipeline replicating YOLO preprocessing with centered padding.

    Matches Ultralytics LetterBox(center=True) behavior exactly.
    """
    # Leer y decodificar imágenes en la GPU
    jpegs, _ = fn.readers.file(file_root=image_dir, random_shuffle=False, name="Reader")
    images = fn.decoders.image(jpegs, device="mixed", output_type=types.RGB)

    # Cambiar el tamaño conservando la relación de aspecto
    resized = fn.resize(
        images,
        resize_x=target_size,
        resize_y=target_size,
        mode="not_larger",
        interp_type=types.INTERP_LINEAR,
        antialias=False,  # Coincidir con cv2.INTER_LINEAR (sin antialiasing)
    )

    # Relleno centrado con fn.crop y out_of_bounds_policy
    # Cuando el tamaño del recorte es mayor que el de la imagen, fn.crop centra la imagen y añade relleno simétrico
    padded = fn.crop(
        resized,
        crop=(target_size, target_size),
        out_of_bounds_policy="pad",
        fill_values=114,  # Valor de relleno de YOLO
    )

    # Normalizar y convertir el formato
    output = fn.crop_mirror_normalize(
        padded,
        dtype=types.FLOAT,
        output_layout="CHW",
        mean=[0.0, 0.0, 0.0],
        std=[255.0, 255.0, 255.0],
    )
    return output
¿Cuándo basta con `fn.pad`?

Si no necesitas una paridad exacta con LetterBox(center=True), puedes simplificar el paso de relleno usando fn.pad(...) en lugar de fn.crop(..., out_of_bounds_policy="pad"). Esa variante añade relleno solo en los bordes derecho e inferior, lo que puede ser aceptable en cadenas de despliegue personalizadas, pero no coincidirá exactamente con el comportamiento letterbox centrado predeterminado de Ultralytics.

¿Por qué usar `fn.crop` para el relleno centrado?

El operador fn.pad de DALI solo añade relleno en los bordes derecho e inferior. Para obtener un relleno centrado (que coincida con LetterBox(center=True) de Ultralytics), usa fn.crop con out_of_bounds_policy="pad". Con los valores predeterminados de crop_pos_x=0.5 y crop_pos_y=0.5, la imagen se centra automáticamente con un relleno simétrico.

Diferencias en el antialiasing

fn.resize de DALI activa el antialiasing de forma predeterminada (antialias=True), mientras que cv2.resize de OpenCV con INTER_LINEAR no aplica antialiasing. Configura siempre antialias=False en DALI para que coincida con la cadena de procesamiento de CPU. Si lo omites, se producen diferencias numéricas sutiles que pueden afectar a la precisión del modelo.

Ejecutar la cadena de procesamiento#

Crear y ejecutar una cadena de procesamiento de DALI
# Build and run the pipeline
pipe = yolo_dali_pipeline_centered(image_dir="/path/to/images", target_size=640)
pipe.build()

# Get a batch of preprocessed images
(output,) = pipe.run()

# Convert to numpy or PyTorch tensors
batch_np = output.as_cpu().as_array()  # Shape: (batch_size, 3, 640, 640)
print(f"Output shape: {batch_np.shape}, dtype: {batch_np.dtype}")
print(f"Value range: [{batch_np.min():.4f}, {batch_np.max():.4f}]")

Usar DALI con Predict de Ultralytics#

Puedes pasar directamente a model.predict() un tensor de [PyTorch](https://ultralytics-translation-0.invalid preprocesado. Cuando se pasa un torch.Tensor, Ultralytics omite el preprocesamiento de imágenes (letterbox, BGR→RGB, HWC→CHW y normalización /255) y solo transfiere el dispositivo y convierte el tipo de datos antes de enviar el tensor al modelo.

En este caso, como Ultralytics no tiene acceso a las dimensiones de la imagen original, las coordenadas de las cajas de detección se devuelven en el espacio letterbox de 640×640. Para volver a asignarlas a las coordenadas de la imagen original, usa scale_boxes, que aplica la lógica exacta de redondeo utilizada por LetterBox:

from ultralytics.utils.ops import scale_boxes

# cajas: tensor de forma (N, 4) en formato xyxy, en coordenadas letterbox de 640x640
# Escalar las cajas de letterbox (640, 640) a las dimensiones originales (orig_h, orig_w)
boxes = scale_boxes((640, 640), boxes, (orig_h, orig_w))

Esto se aplica a todas las vías de preprocesamiento externo: entrada directa de tensores, flujos de vídeo y despliegue de Triton.

Predicción con DALI + Ultralytics
from nvidia.dali.plugin.pytorch import DALIGenericIterator

from ultralytics import YOLO

# Load model
model = YOLO("yolo26n.pt")

# Create DALI iterator
pipe = yolo_dali_pipeline_centered(image_dir="/path/to/images", target_size=640)
pipe.build()
dali_iter = DALIGenericIterator(pipe, ["images"], reader_name="Reader")

# Run inference with DALI-preprocessed tensors
for batch in dali_iter:
    images = batch[0]["images"]  # Already on GPU, shape (B, 3, 640, 640)
    results = model.predict(images, verbose=False)
    for result in results:
        print(f"Detected {len(result.boxes)} objects")
Sin sobrecoste de preprocesamiento

Cuando pasas un torch.Tensor a model.predict(), el paso de preprocesamiento de la imagen tarda ~0.004 ms (prácticamente nada), frente a ~1-10 ms con el preprocesamiento en CPU. El tensor debe tener formato BCHW, ser de tipo float32 (o float16) y estar normalizado a [0, 1]. Ultralytics seguirá gestionando automáticamente la transferencia entre dispositivos y la conversión del tipo de datos.

DALI con flujos de vídeo#

Para procesar vídeo en tiempo real, usa fn.external_source para proporcionar fotogramas desde cualquier fuente: OpenCV, GStreamer o bibliotecas de captura personalizadas:

Cadena de procesamiento de DALI para preprocesar flujos de vídeo
from nvidia import dali
from nvidia.dali import fn, types

@dali.pipeline_def(batch_size=1, num_threads=4, device_id=0)
def yolo_video_pipeline(target_size=640):
    """DALI pipeline for processing video frames from external source."""
    # Fuente externa para proporcionar fotogramas desde OpenCV, GStreamer, etc.
    frames = fn.external_source(device="cpu", name="input")
    frames = fn.reshape(frames, layout="HWC")

    # Transferir a la GPU y preprocesar
    frames_gpu = frames.gpu()
    resized = fn.resize(
        frames_gpu,
        resize_x=target_size,
        resize_y=target_size,
        mode="not_larger",
        interp_type=types.INTERP_LINEAR,
        antialias=False,
    )
    padded = fn.crop(
        resized,
        crop=(target_size, target_size),
        out_of_bounds_policy="pad",
        fill_values=114,
    )
    output = fn.crop_mirror_normalize(
        padded,
        dtype=types.FLOAT,
        output_layout="CHW",
        mean=[0.0, 0.0, 0.0],
        std=[255.0, 255.0, 255.0],
    )
    return output

Triton Inference Server con DALI#

Para el despliegue en producción, combina el preprocesamiento de DALI con la inferencia de TensorRT en Triton Inference Server mediante un modelo de conjunto. Así se elimina por completo el preprocesamiento en la CPU: entran bytes JPEG sin procesar y salen detecciones, con todo el procesamiento realizado en la GPU.

Estructura del repositorio de modelos#

model_repository/
├── dali_preprocessing/
│   ├── 1/
│   │   └── model.dali
│   └── config.pbtxt
├── yolo_trt/
│   ├── 1/
│   │   └── model.plan
│   └── config.pbtxt
└── ensemble_dali_yolo/
    ├── 1/                  # Empty directory (required by Triton)
    └── config.pbtxt

Paso 1: Crea la canalización de DALI#

Serializa la canalización de DALI para el backend DALI de Triton:

Serializa la canalización de DALI para Triton
from nvidia import dali
from nvidia.dali import fn, types

@dali.pipeline_def(batch_size=8, num_threads=4, device_id=0)
def triton_dali_pipeline():
    """DALI preprocessing pipeline for Triton deployment."""
    # Entrada: bytes de imagen codificada sin procesar de Triton
    images = fn.external_source(device="cpu", name="DALI_INPUT_0")
    images = fn.decoders.image(images, device="mixed", output_type=types.RGB)

    resized = fn.resize(
        images,
        resize_x=640,
        resize_y=640,
        mode="not_larger",
        interp_type=types.INTERP_LINEAR,
        antialias=False,
    )
    padded = fn.crop(
        resized,
        crop=(640, 640),
        out_of_bounds_policy="pad",
        fill_values=114,
    )
    output = fn.crop_mirror_normalize(
        padded,
        dtype=types.FLOAT,
        output_layout="CHW",
        mean=[0.0, 0.0, 0.0],
        std=[255.0, 255.0, 255.0],
    )
    return output

# Serializa la canalización en el repositorio de modelos
pipe = triton_dali_pipeline()
pipe.serialize(filename="model_repository/dali_preprocessing/1/model.dali")

Paso 2: Exporta YOLO a TensorRT#

Exporta el modelo YOLO a un motor TensorRT
from pathlib import Path

from ultralytics import YOLO

model = YOLO("yolo26n.pt")
engine_path = model.export(
    format="engine", imgsz=640, quantize=16, batch=8, dynamic=True, nms=False
)  # Sin NMS (N, 300, 6); TensorRT >= 8.5

# Ultralytics añade un encabezado de metadatos a los archivos .engine; quítalo para que Triton pueda cargar el plan TensorRT sin procesar
with open(engine_path, "rb") as f:
    meta_len = int.from_bytes(f.read(4), byteorder="little")  # longitud del encabezado de metadatos JSON
    f.seek(4 + meta_len)
    plan = f.read()
Path("model_repository/yolo_trt/1").mkdir(parents=True, exist_ok=True)
Path("model_repository/yolo_trt/1/model.plan").write_bytes(plan)

Paso 3: Configura Triton#

dali_preprocessing/config.pbtxt:

name: "dali_preprocessing"
backend: "dali"
max_batch_size: 8
input [
  {
    name: "DALI_INPUT_0"
    data_type: TYPE_UINT8
    dims: [ -1 ]
  }
]
output [
  {
    name: "DALI_OUTPUT_0"
    data_type: TYPE_FP32
    dims: [ 3, 640, 640 ]
  }
]

yolo_trt/config.pbtxt:

name: "yolo_trt"
platform: "tensorrt_plan"
max_batch_size: 8
input [
  {
    name: "images"
    data_type: TYPE_FP32
    dims: [ 3, 640, 640 ]
  }
]
output [
  {
    name: "output0"
    data_type: TYPE_FP32
    dims: [ 300, 6 ]
  }
]

ensemble_dali_yolo/config.pbtxt:

name: "ensemble_dali_yolo"
platform: "ensemble"
max_batch_size: 8
input [
  {
    name: "INPUT"
    data_type: TYPE_UINT8
    dims: [ -1 ]
  }
]
output [
  {
    name: "OUTPUT"
    data_type: TYPE_FP32
    dims: [ 300, 6 ]
  }
]
ensemble_scheduling {
  step [
    {
      model_name: "dali_preprocessing"
      model_version: -1
      input_map {
        key: "DALI_INPUT_0"
        value: "INPUT"
      }
      output_map {
        key: "DALI_OUTPUT_0"
        value: "preprocessed_image"
      }
    },
    {
      model_name: "yolo_trt"
      model_version: -1
      input_map {
        key: "images"
        value: "preprocessed_image"
      }
      output_map {
        key: "output0"
        value: "OUTPUT"
      }
    }
  ]
}
Cómo funciona la asignación del conjunto

El conjunto conecta los modelos mediante nombres de tensores virtuales. El valor "preprocessed_image" de output_map en el paso de DALI coincide con el valor "preprocessed_image" de input_map en el paso de TensorRT. Son nombres arbitrarios que conectan la salida de un paso con la entrada del siguiente; no tienen que coincidir con los nombres de los tensores internos de ningún modelo.

Paso 4: Envía solicitudes de inferencia#

¿Por qué usar `tritonclient` en lugar de `YOLO('http://...')`?

Ultralytics incluye compatibilidad integrada con Triton, que gestiona automáticamente el preprocesamiento y el posprocesamiento. Sin embargo, no funcionará con el conjunto de DALI porque YOLO() envía un tensor float32 preprocesado, mientras que el conjunto espera bytes JPEG sin procesar. Usa tritonclient directamente para los conjuntos de DALI y la integración integrada para los despliegues estándar sin DALI.

Envía imágenes al conjunto de Triton
import numpy as np
import tritonclient.http as httpclient

client = httpclient.InferenceServerClient(url="localhost:8000")

# Load image as raw bytes (JPEG/PNG encoded)
image_data = np.fromfile("image.jpg", dtype="uint8")
image_data = np.expand_dims(image_data, axis=0)  # Add batch dimension

# Create input
input_tensor = httpclient.InferInput("INPUT", image_data.shape, "UINT8")
input_tensor.set_data_from_numpy(image_data)

# Run inference through the ensemble
result = client.infer(model_name="ensemble_dali_yolo", inputs=[input_tensor])
detections = result.as_numpy("OUTPUT")  # Shape: (1, 300, 6) -> [x1, y1, x2, y2, conf, class_id]

# Filter by confidence (no NMS needed for the nms=False export)
detections = detections[0]  # First image
detections = detections[detections[:, 4] > 0.25]  # Confidence threshold
print(f"Detected {len(detections)} objects")
Procesamiento por lotes de imágenes JPEG

Al enviar un lote de imágenes JPEG a Triton, rellena todos los arrays de bytes codificados hasta que tengan la misma longitud (el número máximo de bytes del lote). Triton requiere que las formas del tensor de entrada sean homogéneas en el lote.

Tareas compatibles#

El preprocesamiento de DALI funciona con todas las tareas de YOLO que usan la canalización estándar LetterBox:

TareaCompatibleNotas
Detección✅Preprocesamiento estándar con letterbox
Segmentación de instancias✅El mismo preprocesamiento que para la detección
Segmentación semántica✅El mismo preprocesamiento de imagen que para la detección
Estimación de profundidad✅El mismo preprocesamiento de imagen que para la detección
Clasificación❌Usa transformaciones de torchvision (recorte centrado), no letterbox
Estimación de pose✅El mismo preprocesamiento que para la detección
Detección orientada (OBB)✅El mismo preprocesamiento que para la detección

Limitaciones#

  • Solo Linux: DALI no es compatible con Windows ni macOS
  • Se requiere una GPU NVIDIA: no hay alternativa solo para CPU
  • Canalización estática: la estructura de la canalización se define durante la compilación y no puede cambiar dinámicamente
  • fn.pad solo aplica el relleno a la derecha y abajo: usa fn.crop con out_of_bounds_policy="pad" para centrar el relleno
  • Sin modo rect: las canalizaciones de DALI generan resultados de tamaño fijo (p. ej., 640×640). El modo rect de auto=True, que genera resultados de tamaño variable (p. ej., 384×640), no es compatible. Ten en cuenta que, aunque TensorRT admite formas de entrada dinámicas, una canalización de DALI de tamaño fijo se combina de forma natural con un motor de tamaño fijo para obtener el máximo rendimiento
  • Memoria con varias instancias: usar instance_group con count > 1 en Triton puede provocar un uso elevado de memoria. Usa el grupo de instancias predeterminado para el modelo DALI

Preguntas frecuentes#

  • La ventaja depende de tu canalización. Cuando la inferencia en la GPU ya es rápida con TensorRT, el preprocesamiento en la CPU, que tarda entre 2 y 10 ms, puede convertirse en el coste dominante. DALI elimina este cuello de botella al ejecutar el preprocesamiento en la GPU. Las mayores mejoras se observan con entradas de alta resolución (1080p, 4K), tamaños de lote grandes y sistemas con pocos núcleos de CPU por GPU.

  • Sí. Usa DALIGenericIterator para obtener resultados torch.Tensor preprocesados y, después, pásalos a model.predict(). Sin embargo, la mejora de rendimiento es mayor con modelos de TensorRT, cuya inferencia ya es muy rápida y en los que el preprocesamiento en la CPU se convierte en el cuello de botella.

  • fn.pad añade relleno solo en los bordes derecho e inferior. fn.crop con out_of_bounds_policy="pad" centra la imagen y añade relleno simétrico en todos los lados, igual que el comportamiento LetterBox(center=True) de Ultralytics.

  • Son casi idénticos. Establece antialias=False en fn.resize para que coincida con cv2.INTER_LINEAR de OpenCV. Puede haber pequeñas diferencias de coma flotante (< 0.001) debido a las operaciones aritméticas en la GPU frente a la CPU, pero no tienen un impacto apreciable en la precisión de la detección.

  • CV-CUDA es otra biblioteca de NVIDIA para el procesamiento de visión acelerado por GPU. Ofrece control por operador (como OpenCV, pero en la GPU), en lugar del enfoque basado en canalizaciones de DALI. cvcuda.copymakeborder() de CV-CUDA admite el relleno explícito en cada lado, lo que facilita el letterbox centrado. Elige DALI para flujos de trabajo basados en canalizaciones (especialmente con Triton) y CV-CUDA para un control detallado a nivel de operador en código de inferencia personalizado.

Comentarios