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 (redimensionar, rellenar, normalizar) puede tardar de 2 a 10 ms por imagen, especialmente a altas resoluciones. NVIDIA DALI (Data Loading Library) soluciona esto trasladando todo el flujo de preprocesamiento a la GPU.
Esta guía te muestra cómo construir flujos de DALI que repliquen exactamente el preprocesamiento de Ultralytics YOLO, integrándolos con model.predict(), procesando flujos de vídeo y desplegándolos de extremo a extremo con Triton Inference Server.
Esta guía está dirigida a ingenieros que despliegan modelos YOLO en entornos de producción donde el preprocesamiento en CPU es un cuello de botella medido — típicamente despliegues de TensorRT en GPUs de NVIDIA, flujos de vídeo de alto rendimiento o configuraciones de Triton Inference Server. Si ejecutas una inferencia estándar con model.predict() y no tienes un cuello de botella en el preprocesamiento, el flujo de CPU predeterminado funciona bien.
- ¿Estás construyendo un flujo de DALI? Usa
fn.resize(mode="not_larger")+fn.crop(out_of_bounds_policy="pad")+fn.crop_mirror_normalizepara replicar el preprocesamiento letterbox de YOLO en la GPU. - ¿Te estás integrando con Ultralytics? Pasa la salida de DALI como un
torch.Tensoramodel.predict()— Ultralytics omite el preprocesamiento de imágenes automáticamente. - ¿Implementando con Triton? Utiliza el backend de DALI con un ensemble de TensorRT para un preprocesamiento con cero uso de CPU.
Por qué utilizar DALI para el preprocesamiento de YOLO#
En un pipeline de inferencia YOLO típico, los pasos de preprocesamiento se ejecutan en la CPU:
- Decodifica la imagen (JPEG/PNG)
- Cambia el tamaño manteniendo la relación de aspecto
- Rellena hasta alcanzar el tamaño objetivo (letterbox)
- Normaliza los valores de píxel de
[0, 255]a[0, 1] - Convierte el diseño de HWC a CHW
Con DALI, todas estas operaciones se ejecutan en la GPU, eliminando el cuello de botella de la CPU. Esto es especialmente valioso cuando:
| Escenario | Por qué DALI ayuda |
|---|---|
| Inferencia rápida en GPU | Los motores de TensorRT con inferencia inferior a un milisegundo hacen que el preprocesamiento en CPU sea el coste principal |
| Entradas de alta resolución | Las secuencias de vídeo 1080p y 4K requieren costosas operaciones de cambio de tamaño |
| Tamaños de lote grandes | Procesamiento de inferencia del lado del servidor que maneja muchas imágenes en paralelo |
| Núcleos de CPU limitados | Dispositivos perimetrales como NVIDIA Jetson, o servidores de GPU densos con pocos núcleos de CPU por GPU |
Requisitos previos#
NVIDIA DALI solo es compatible con Linux. No está disponible en Windows ni en macOS.
Instala los paquetes necesarios:
pip install ultralytics
pip install --extra-index-url https://pypi.nvidia.com nvidia-dali-cuda130Requisitos:
- GPU NVIDIA (capacidad de cálculo 5.0+ / Maxwell o posterior)
- CUDA 11.0+, 12.0+ o 13.0+
- Python 3.10-3.14
- Sistema operativo Linux
Comprender el preprocesamiento de YOLO#
Antes de construir un flujo de DALI, vale la pena 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), # Target size
center=True, # Center the image (pad equally on both sides)
stride=32, # Stride alignment
padding_value=114, # Gray padding (114, 114, 114)
)El flujo de preprocesamiento completo en ultralytics/engine/predictor.py realiza estos pasos:
| Paso | Operación | Función de CPU | Equivalente en DALI |
|---|---|---|---|
| 1 | Cambio de tamaño letterbox | cv2.resize | fn.resize(mode="not_larger") |
| 2 | Relleno centrado | cv2.copyMakeBorder | fn.crop(out_of_bounds_policy="pad") |
| 3 | BGR → RGB | im[..., ::-1] | fn.decoders.image(output_type=types.RGB) |
| 4 | HWC → CHW + normalizar /255 | np.transpose + tensor / 255 | fn.crop_mirror_normalize(std=[255,255,255]) |
La operación letterbox conserva la relación de aspecto mediante:
- Cálculo de la escala:
r = min(target_h / h, target_w / w) - Redimensionamiento a
(round(w * r), round(h * r)) - Relleno del espacio restante con gris (
114) para alcanzar el tamaño objetivo - Centrado de la imagen para que el relleno se distribuya equitativamente en ambos lados
Pipeline de DALI para YOLO#
El flujo de DALI recomendado replica el comportamiento predeterminado LetterBox(center=True) de Ultralytics, que es el que utiliza la inferencia estándar de YOLO.
Pipeline centrado (Recomendado, coincide con el LetterBox de Ultralytics)#
Esta versión replica exactamente el preprocesamiento predeterminado de Ultralytics con relleno centrado, coincidiendo con LetterBox(center=True):
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.
"""
# Read and decode images on 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)
# Aspect-ratio-preserving resize
resized = fn.resize(
images,
resize_x=target_size,
resize_y=target_size,
mode="not_larger",
interp_type=types.INTERP_LINEAR,
antialias=False, # Match cv2.INTER_LINEAR (no antialiasing)
)
# Centered padding using fn.crop with out_of_bounds_policy
# When crop size > image size, fn.crop centers the image and pads symmetrically
padded = fn.crop(
resized,
crop=(target_size, target_size),
out_of_bounds_policy="pad",
fill_values=114, # YOLO padding value
)
# Normalize and convert layout
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 outputSi 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 rellena únicamente los bordes derecho e inferior, lo cual puede ser aceptable para flujos de despliegue personalizados, pero no coincidirá exactamente con el comportamiento letterbox centrado predeterminado de Ultralytics.
El operador fn.pad de DALI solo añade relleno a 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 crop_pos_x=0.5 y crop_pos_y=0.5, la imagen se centra automáticamente con un relleno simétrico.
El operador fn.resize de DALI habilita el suavizado de contornos (antialiasing) de forma predeterminada (antialias=True), mientras que el operador cv2.resize de OpenCV con INTER_LINEAR no aplica antialiasing. Configura siempre antialias=False en DALI para que coincida con el flujo de CPU. Omitir esto provoca sutiles diferencias numéricas que pueden afectar a la precisión del modelo.
Ejecución del pipeline#
# 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}]")Uso de DALI con Ultralytics Predict#
Puedes pasar un tensor de PyTorch preprocesado directamente a model.predict(). Cuando se pasa un torch.Tensor, Ultralytics omite el preprocesamiento de la imagen (letterbox, BGR→RGB, HWC→CHW y normalización /255) y solo realiza la transferencia al dispositivo y la conversión de tipo de datos antes de enviarlo al modelo.
Dado que en este caso Ultralytics no tiene acceso a las dimensiones originales de la imagen, las coordenadas de las cajas de detección se devuelven en el espacio letterbox de 640×640. Para mapearlas de vuelta a las coordenadas originales de la imagen, utiliza scale_boxes, que gestiona la lógica de redondeo exacta utilizada por LetterBox:
from ultralytics.utils.ops import scale_boxes
# boxes: tensor of shape (N, 4) in xyxy format, in 640x640 letterboxed coords
# Scale boxes from letterboxed (640, 640) back to original (orig_h, orig_w)
boxes = scale_boxes((640, 640), boxes, (orig_h, orig_w))Esto se aplica a todos los caminos de preprocesamiento externos: entrada directa de tensor, secuencias de vídeo e implementación en Triton.
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")Cuando pasas un torch.Tensor a model.predict(), el paso de preprocesamiento de la imagen tarda ~0,004 ms (prácticamente cero) en comparación con los ~1-10 ms del preprocesamiento en CPU. El tensor debe estar en formato BCHW, float32 (o float16) y normalizado a [0, 1]. Ultralytics seguirá gestionando la transferencia al dispositivo y la conversión de tipo de datos automáticamente.
DALI con secuencias de vídeo#
Para el procesamiento de vídeo en tiempo real, utiliza fn.external_source para introducir fotogramas desde cualquier fuente: OpenCV, GStreamer o bibliotecas de captura personalizadas:
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."""
# External source for feeding frames from OpenCV, GStreamer, etc.
frames = fn.external_source(device="cpu", name="input")
frames = fn.reshape(frames, layout="HWC")
# Move to GPU and preprocess
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 outputTriton Inference Server con DALI#
Para un despliegue en producción, combina el preprocesamiento de DALI con la inferencia de TensorRT en Triton Inference Server utilizando un modelo de ensamblaje. Esto elimina por completo el preprocesamiento en CPU: entran bytes JPEG sin procesar y salen detecciones, procesándose todo 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.pbtxtPaso 1: Crea el pipeline de DALI#
Serializa el pipeline de DALI para el backend de DALI en 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."""
# Input: raw encoded image bytes from 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
# Serialize pipeline to model repository
pipe = triton_dali_pipeline()
pipe.serialize(filename="model_repository/dali_preprocessing/1/model.dali")Paso 2: Exporta YOLO a TensorRT#
from ultralytics import YOLO
model = YOLO("yolo26n.pt")
model.export(format="engine", imgsz=640, quantize=16, batch=8)
# Copy the .engine file to model_repository/yolo_trt/1/model.planPaso 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"
}
}
]
}El ensamblaje conecta modelos a través de nombres de tensores virtuales. El valor de output_map "preprocessed_image" en el paso de DALI coincide con el valor de input_map "preprocessed_image" en el paso de TensorRT. Estos son nombres arbitrarios que enlazan la salida de un paso con la entrada del siguiente; no necesitan coincidir con los nombres internos de los tensores de ningún modelo.
Paso 4: Envía peticiones de inferencia#
Ultralytics cuenta con soporte integrado para Triton que gestiona el preprocesamiento y postprocesamiento de forma automática. Sin embargo, no funcionará con el ensamblaje de DALI porque YOLO() envía un tensor float32 preprocesado, mientras que el ensamblaje espera bytes JPEG sin procesar. Utiliza tritonclient directamente para los ensamblajes de DALI y la integración integrada para despliegues estándar sin DALI.
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 — YOLO26 is end-to-end)
detections = detections[0] # First image
detections = detections[detections[:, 4] > 0.25] # Confidence threshold
print(f"Detected {len(detections)} objects")Al enviar un lote de imágenes JPEG a Triton, rellena (pad) todas las matrices de bytes codificadas a la misma longitud (el recuento máximo de bytes en el lote). Triton requiere formas de lote homogéneas para el tensor de entrada.
Tareas compatibles#
El preprocesamiento de DALI funciona con todas las tareas de YOLO que utilizan el flujo estándar LetterBox:
| Tarea | Compatible | Notas |
|---|---|---|
| Detection | ✅ | Preprocesamiento estándar de letterbox |
| Instance Segmentation | ✅ | Mismo preprocesamiento que la detección |
| Semantic Segmentation | ✅ | Mismo preprocesamiento de imagen que la detección |
| Classification | ❌ | Utiliza transformaciones de torchvision (recorte central), no letterbox |
| Pose Estimation | ✅ | Mismo preprocesamiento que la detección |
| Detección orientada (OBB) | ✅ | Mismo preprocesamiento que la detección |
Limitaciones#
- Solo Linux: DALI no es compatible con Windows ni macOS
- Se requiere GPU NVIDIA: No hay alternativa que funcione solo con CPU
- Canalización estática: La estructura de la canalización se define en tiempo de compilación y no puede cambiar dinámicamente
fn.pades solo para la derecha/inferior: Utilizafn.cropconout_of_bounds_policy="pad"para obtener un relleno centrado- Sin modo rect: Los flujos de DALI producen salidas de tamaño fijo (por ejemplo, 640×640). El modo rect de
auto=True, que produce salidas de tamaño variable (por ejemplo, 384×640), no es compatible. Ten en cuenta que, aunque TensorRT sí admite formas de entrada dinámicas, un flujo de DALI de tamaño fijo se combina de manera natural con un motor de tamaño fijo para lograr el máximo rendimiento - Memoria con múltiples instancias: El uso de
instance_groupconcount> 1 en Triton puede provocar un alto consumo de memoria. Utiliza el grupo de instancias predeterminado para el modelo DALI
FAQ#
El beneficio depende de tu flujo. Cuando la inferencia en GPU ya es rápida con TensorRT, el preprocesamiento en CPU de 2 a 10 ms puede convertirse en el coste principal. DALI elimina este cuello de botella ejecutando el preprocesamiento en la GPU. Las mayores ganancias se obtienen con entradas de alta resolución (1080p, 4K), tamaños de lote grandes y sistemas con un número limitado de núcleos de CPU por GPU.
Sí. Utiliza
DALIGenericIteratorpara obtener salidas preprocesadas detorch.Tensory, a continuación, pásalas amodel.predict(). Sin embargo, el beneficio de rendimiento es mayor con modelos de TensorRT en los que la inferencia ya es muy rápida y el preprocesamiento en CPU se convierte en el cuello de botella.fn.padañade relleno únicamente a los bordes derecho e inferior.fn.cropconout_of_bounds_policy="pad"centra la imagen y añade relleno de forma simétrica en todos los lados, coincidiendo con el comportamientoLetterBox(center=True)de Ultralytics.Prácticamente idénticos. Configura
antialias=Falseenfn.resizepara que coincida concv2.INTER_LINEARde OpenCV. Pueden producirse pequeñas diferencias de coma flotante (< 0,001) debido a la aritmética de la GPU frente a la de la CPU, pero estas no tienen un impacto medible 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 de flujo de DALI. El operador
cvcuda.copymakeborder()de CV-CUDA admite un relleno explícito por lado, lo que simplifica el letterbox centrado. Elige DALI para flujos basados en canalizaciones (especialmente con Triton), y CV-CUDA para un control detallado a nivel de operador en código de inferencia personalizado.