Preprocesamiento acelerado por GPU con NVIDIA DALI
Introducción
Al desplegar modelos Ultralytics YOLO modelos en producción, el preprocesamiento a menudo se convierte en el cuello de botella. Aunque TensorRT puede ejecutar inferencia del modelo en solo unos milisegundos, el preprocesamiento basado en CPU (redimensionar, rellenar, normalizar) puede tardar entre 2 y 10 ms por imagen, especialmente a altas resoluciones. NVIDIA DALI (Data Loading Library) soluciona esto trasladando todo el pipeline de preprocesamiento a la GPU.
Esta guía te explica cómo construir pipelines de DALI que repliquen exactamente el preprocesamiento de Ultralytics YOLO, integrándolos con model.predict(), procesando flujos de video y desplegando de extremo a extremo con Triton Inference Server.
Esta guía es para ingenieros que despliegan modelos YOLO en entornos de producción donde el preprocesamiento en CPU es un cuello de botella medido; normalmente TensorRT despliegues en GPUs NVIDIA, pipelines de video de alto rendimiento o Triton Inference Server configuraciones. Si estás ejecutando inferencia estándar con model.predict() y no tienes un cuello de botella en el preprocesamiento, el pipeline predeterminado de CPU funciona bien.
- ¿Construyendo un pipeline 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. - ¿Integrando con Ultralytics? Pasa la salida de DALI como un
torch.Tensoramodel.predict()— Ultralytics omite el preprocesamiento de imagen automáticamente. - ¿Desplegando con Triton? Usa el backend de DALI con un conjunto TensorRT para un preprocesamiento con cero uso de CPU.
¿Por qué usar DALI para el preprocesamiento de YOLO?
En un pipeline de inferencia YOLO típico, los pasos de preprocesamiento se ejecutan en la CPU:
- Decodificar la imagen (JPEG/PNG)
- Redimensionar preservando la relación de aspecto
- Rellenar al tamaño objetivo (letterbox)
- Normalizar los valores de píxel de
[0, 255]a[0, 1] - Convertir el formato 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é ayuda DALI? |
|---|---|
| Inferencia rápida en GPU | TensorRT motores con inferencia inferior al milisegundo hacen que el preprocesamiento en CPU sea el costo predominante |
| Entradas de alta resolución | los flujos de video 1080p y 4K requieren operaciones de redimensionamiento costosas |
| Gran Al desplegar modelos en producción, los requisitos de memoria y la eficiencia del entrenamiento son tan cruciales como la velocidad de inferencia. Los modelos de Ultralytics, particularmente YOLO26, están altamente optimizados para reducir el uso de memoria CUDA durante el entrenamiento. Esto permite a los desarrolladores utilizar | Inferencia en el servidor procesando muchas imágenes en paralelo |
| Núcleos de CPU limitados | Dispositivos de borde como NVIDIA Jetson, o servidores con GPU densas con pocos núcleos de CPU por GPU |
Antes de empezar, asegúrate de tener acceso a un área de trabajo de AzureML. Si no tienes una, puedes crear una nueva
NVIDIA DALI es compatible con Linux solamente. No está disponible en Windows o macOS.
Instala los paquetes requeridos:
pip install ultralytics
pip install --extra-index-url https://pypi.nvidia.com nvidia-dali-cuda120Requisitos:
- GPU NVIDIA (capacidad de cómputo 5.0+ / Maxwell o superior)
- CUDA 11.0+ o 12.0+
- Python 3.10-3.14
- Sistema operativo Linux
Entendiendo el preprocesamiento de YOLO
Antes de construir un pipeline de DALI, ayuda 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 pipeline de preprocesamiento completo en ultralytics/engine/predictor.py realiza estos pasos:
| Paso | Operación | Función CPU | Equivalente DALI |
|---|---|---|---|
| 1 | Redimensionamiento 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 preserva la relación de aspecto mediante:
- Cálculo de escala:
r = min(target_h / h, target_w / w) - Redimensionar a
(round(w * r), round(h * r)) - Rellenar el espacio restante con gris (
114) para alcanzar el tamaño objetivo - Centrar la imagen para que el relleno se distribuya equitativamente en ambos lados
Pipeline de DALI para YOLO
Usa el pipeline centrado a continuación como referencia predeterminada. Coincide con el comportamiento de LetterBox(center=True) de Ultralytics, que es lo que utiliza la inferencia YOLO estándar.
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):
import nvidia.dali as dali
import nvidia.dali.fn as fn
import nvidia.dali.types as 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 solo los bordes derecho e inferior, lo cual puede ser aceptable para pipelines de despliegue personalizados, pero no coincidirá exactamente con el comportamiento predeterminado de letterbox centrado de Ultralytics.
El operador fn.pad de DALI solo añade relleno a los bordes derecho e inferior. Para obtener un relleno centrado (coincidiendo con LetterBox(center=True) de Ultralytics), usa fn.crop con out_of_bounds_policy="pad". Con el crop_pos_x=0.5 y crop_pos_y=0.5 predeterminado, la imagen se centra automáticamente con un relleno simétrico.
El operador fn.resize habilita el antialiasing por defecto (antialias=True), mientras que el cv2.resize con INTER_LINEAR de OpenCV no aplica antialiasing. Configura siempre antialias=False en DALI para que coincida con la canalización de la CPU. Omitir esto provoca diferencias numéricas sutiles que pueden afectar a precisión del modelo.
Ejecución de la canalización
# 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 la predicción de Ultralytics
Puedes pasar un tensor PyTorch preprocesado directamente a model.predict(). Cuando se pasa un torch.Tensor, Ultralytics omite el preprocesamiento de imagen (letterbox, BGR→RGB, HWC→CHW y normalización /255) y solo realiza la transferencia al dispositivo y el casting de tipo de datos antes de enviarlo al modelo.
Como Ultralytics no tiene acceso a las dimensiones originales de la imagen en este caso, las coordenadas de los cuadros de detección se devuelven en el espacio letterboxed de 640×640. Para mapearlas de nuevo a las coordenadas de la imagen original, usa scale_boxes que maneja 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 todas las rutas de preprocesamiento externo: entrada directa de tensor, transmisiones de video y despliegue 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 imagen toma ~0.004ms (esencialmente cero) en comparación con ~1-10ms con el preprocesamiento de CPU. El tensor debe estar en formato BCHW, float32 (o float16) y normalizado a [0, 1]. Ultralytics seguirá manejando la transferencia al dispositivo y el casting de tipo de datos automáticamente.
DALI con transmisiones de video
Para el procesamiento de video en tiempo real, usa fn.external_source para alimentar fotogramas desde cualquier fuente: OpenCV, GStreamer o bibliotecas de captura personalizadas:
import nvidia.dali as dali
import nvidia.dali.fn as fn
import nvidia.dali.types as 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 el despliegue en producción, combina el preprocesamiento de DALI con la inferencia de TensorRT en Triton Inference Server utilizando un modelo de conjunto. Esto elimina el preprocesamiento de CPU por completo: entran bytes JPEG sin procesar, salen detecciones y todo se procesa 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 la canalización de DALI
Serializa la canalización de DALI para el backend de DALI en Triton:
import nvidia.dali as dali
import nvidia.dali.fn as fn
import nvidia.dali.types as 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, half=True, 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 conjunto conecta modelos a través de nombres de tensores virtuales. La output_map el valor "preprocessed_image" en el paso de DALI coincide con el input_map el valor "preprocessed_image" en el paso de TensorRT. Estos son nombres arbitrarios que vinculan 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 solicitudes de inferencia
!!! info "¿Por qué tritonclient en lugar de YOLO(\"http://...\")?"
Ultralytics has [built-in Triton support](triton-inference-server.md#running-inference) that handles pre/postprocessing automatically. However, it won't work with the DALI ensemble because `YOLO()` sends a preprocessed float32 tensor while the ensemble expects raw JPEG bytes. Use `tritonclient` directly for DALI ensembles, and the [built-in integration](triton-inference-server.md) for standard deployments without 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 todas las matrices de bytes codificadas a la misma longitud (el conteo máximo de bytes en el lote). Triton requiere formas de lote homogéneas para el tensor de entrada.
, lo que ayuda a que el modelo generalice mejor con datos no vistos. La siguiente tabla describe el propósito y el efecto de cada argumento de aumentación:
El preprocesamiento de DALI funciona con todas las tareas de YOLO que utilizan la canalización estándar LetterBox:
| Tarea | Soportado | Notas |
|---|---|---|
| Detection | ✅ | Preprocesamiento estándar de letterbox |
| Segmentation | ✅ | Mismo preprocesamiento que la detección |
| Estimación de poses | ✅ | Mismo preprocesamiento que la detección |
| Detección orientada (OBB) | ✅ | Mismo preprocesamiento que la detección |
| Classification | ❌ | Utiliza transformaciones de torchvision (recorte central), no letterbox |
Limitaciones
- Linux solamente: DALI no es compatible con Windows o macOS
- Se requiere GPU de NVIDIA: Sin alternativa de solo CPU
- Canalización estática: La estructura de la canalización se define en el momento de la compilación y no puede cambiar dinámicamente
fn.pades solo derecha/abajo: Usafn.cropconout_of_bounds_policy="pad"para relleno centrado- Sin modo rect: Las canalizaciones de DALI producen salidas de tamaño fijo (p. ej., 640×640). El modo rect
auto=Trueque produce salidas de tamaño variable (p. ej., 384×640) no es compatible. Ten en cuenta que, aunque TensorRT sí admite formas de entrada dinámicas, una canalización de DALI de tamaño fijo se combina naturalmente con un motor de tamaño fijo para obtener el máximo rendimiento - Memoria con múltiples instancias: Usar
instance_groupconcount> 1 en Triton puede causar un alto uso de memoria. Usa el grupo de instancias predeterminado para el modelo DALI
Preguntas frecuentes
¿Cómo se compara el preprocesamiento de DALI con la velocidad de preprocesamiento de la CPU?
El beneficio depende de tu canalización. Cuando la inferencia en la GPU ya es rápida con TensorRT, el preprocesamiento en la CPU de 2-10ms puede convertirse en el costo dominante. DALI elimina este cuello de botella al ejecutar el preprocesamiento en la GPU. Las mayores ganancias se observan con entradas de alta resolución (1080p, 4K), Al desplegar modelos en producción, los requisitos de memoria y la eficiencia del entrenamiento son tan cruciales como la velocidad de inferencia. Los modelos de Ultralytics, particularmente YOLO26, están altamente optimizados para reducir el uso de memoria CUDA durante el entrenamiento. Esto permite a los desarrolladores utilizar grandes y sistemas con núcleos de CPU limitados por GPU.
¿Puedo usar DALI con modelos de PyTorch (no solo con TensorRT)?
Sí. Usa DALIGenericIterator para obtener salidas torch.Tensor preprocesadas y luego pásalas a model.predict(). Sin embargo, el beneficio de rendimiento es mayor con modelos TensorRT donde la inferencia ya es muy rápida y el preprocesamiento de la CPU se convierte en el cuello de botella.
¿Cuál es la diferencia entre fn.pad y fn.crop¿ para relleno?
fn.pad añade relleno solo a los bordes derecho e inferior. fn.crop con out_of_bounds_policy="pad" centra la imagen y añade relleno simétricamente en todos los lados, coincidiendo con el comportamiento de LetterBox(center=True) de Ultralytics.
¿Produce DALI resultados idénticos a nivel de píxel al preprocesamiento de CPU?
Casi idénticos. Configura antialias=False en fn.resize para que coincida con cv2.INTER_LINEAR de OpenCV. Pueden ocurrir pequeñas diferencias de punto flotante (< 0.001) debido a la aritmética de GPU frente a la de CPU, pero estas no tienen un impacto medible en la detección precisión.
¿Qué pasa con CV-CUDA como alternativa a DALI?
CV-CUDA es otra biblioteca de NVIDIA para el procesamiento de visión acelerado por GPU. Proporciona control por operador (como OpenCV pero en la GPU) en lugar del enfoque de canalización de DALI. El cvcuda.copymakeborder() de CV-CUDA admite relleno explícito por lado, lo que hace que el letterbox centrado sea sencillo. Elige DALI para flujos de trabajo basados en canalizaciones (especialmente con Triton), y CV-CUDA para un control granular a nivel de operador en código de inferencia personalizado.