Preprocesamiento acelerado por GPU con NVIDIA DALI#
Al implementar modelos Ultralytics YOLO en producción, el preprocesamiento suele convertirse en el cuello de botella. Aunque TensorRT puede ejecutar la inferencia del modelo en tan solo unos milisegundos, el preprocesamiento 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.
Esta guía te explica cómo crear cadenas de procesamiento de DALI que reproduzcan exactamente el preprocesamiento letterbox de Ultralytics YOLO, integrarlas con model.predict(), procesar secuencias de vídeo e implementar la solución de extremo a extremo con Triton Inference Server.
Esta guía está dirigida a ingenieros que implementan modelos YOLO en entornos de producción donde el preprocesamiento en CPU es un cuello de botella medido, normalmente en implementaciones 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 el preprocesamiento no es un cuello de botella, la cadena de procesamiento predeterminada en CPU funciona bien.
- ¿Vas a crear una cadena de procesamiento de DALI? Utiliza
fn.resize(mode="not_larger")+fn.crop(out_of_bounds_policy="pad")+fn.crop_mirror_normalizepara reproducir en la GPU el preprocesamiento letterbox de YOLO. - ¿Vas a integrarla con Ultralytics? Pasa la salida de DALI como
torch.Tensoramodel.predict(); Ultralytics omitirá automáticamente el preprocesamiento de imágenes. - ¿Vas a implementarla con Triton? Utiliza el backend de DALI con un conjunto de modelos TensorRT para realizar el preprocesamiento sin usar la CPU.
Por qué usar DALI para el preprocesamiento de YOLO#
En una cadena de procesamiento de inferencia YOLO habitual, los pasos de preprocesamiento se ejecutan en la CPU:
- Decodifica la imagen (JPEG/PNG)
- Cambia el tamaño sin alterar la relación de aspecto
- Ajusta el tamaño al objetivo (con letterbox)
- Normaliza los valores de píxel de
[0, 255]a[0, 1] - Convierte el formato de HWC a CHW
Con DALI, todas estas operaciones se ejecutan en la GPU, lo que elimina el cuello de botella de la CPU. Esto resulta especialmente útil cuando:
| Escenario | Por qué DALI es útil |
|---|---|
| Inferencia rápida en GPU | Los motores TensorRT con inferencia inferior al milisegundo hacen que el preprocesamiento en la CPU sea el principal coste |
| Entradas de alta resolución | Los flujos de vídeo en 1080p y 4K requieren operaciones de redimensionamiento costosas |
| Tamaños de lote grandes | Inferencia en servidor que procesa muchas imágenes en paralelo |
| Número limitado de núcleos de CPU | Dispositivos periféricos como NVIDIA Jetson o servidores con GPU de alta densidad y pocos núcleos de CPU por GPU |
Requisitos previos#
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-cuda130Requisitos:
- 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 canalización 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, # Centra la imagen (añade el mismo relleno a ambos lados)
stride=32, # Alineación con el stride
padding_value=114, # Relleno gris (114, 114, 114)
)La canalización completa de preprocesamiento de ultralytics/engine/predictor.py realiza estos pasos:
| Paso | Operación | Función de CPU | Equivalente en DALI |
|---|---|---|---|
| 1 | Redimensionamiento con 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 + normalización /255 | np.transpose + tensor / 255 | fn.crop_mirror_normalize(std=[255,255,255]) |
La operación letterbox conserva la relación de aspecto mediante estos pasos:
- Calcular la escala:
r = min(target_h / h, target_w / w) - Redimensionar a
(round(w * r), round(h * r)) - Rellenar el espacio restante con gris (
114) hasta alcanzar el tamaño objetivo - Centrar la imagen para distribuir el relleno por igual a ambos lados
Canalización de DALI para YOLO#
La canalización de DALI recomendada reproduce el comportamiento predeterminado LetterBox(center=True) de Ultralytics, que es el que utiliza la inferencia estándar de YOLO.
Canalización centrada (recomendada; coincide con LetterBox de Ultralytics)#
Esta versión reproduce exactamente el preprocesamiento predeterminado de Ultralytics con relleno centrado, y coincide 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.
"""
# 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)
# Redimensionamiento que conserva 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, # Iguala cv2.INTER_LINEAR (sin antialiasing)
)
# Relleno centrado mediante fn.crop con out_of_bounds_policy
# Cuando el tamaño del recorte supera 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 outputSi no necesitas una equivalencia exacta con LetterBox(center=True), puedes simplificar el paso de relleno usando fn.pad(...) en lugar de fn.crop(..., out_of_bounds_policy="pad"). Esta variante solo añade relleno a los bordes derecho e inferior, lo que puede ser aceptable en canalizaciones de despliegue personalizadas, pero no reproducirá exactamente 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 (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.
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 canalización de CPU. Si lo omites, se producen pequeñas diferencias numéricas que pueden afectar a la precisión del modelo.
Ejecutar 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}]")Usar DALI con Predict de Ultralytics#
Puedes pasar directamente un tensor PyTorch preprocesado 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 del tipo de datos antes de enviarla al modelo.
Como Ultralytics no tiene acceso a las dimensiones de la imagen original en este caso, las coordenadas de las cajas de detección se devuelven en el espacio de letterbox de 640×640. Para volver a asignarlas a las coordenadas de la imagen original, usa scale_boxes, que aplica la misma lógica de redondeo exacta que LetterBox:
from ultralytics.utils.ops import scale_boxes
# cajas: tensor de forma (N, 4) en formato xyxy, con coordenadas en el espacio de letterbox de 640x640
# Escalar las cajas de las coordenadas de letterbox (640, 640) a las originales (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 tensores, flujos de vídeo y despliegue con 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.004ms (prácticamente nada), frente a ~1-10ms con el preprocesamiento en la 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 al dispositivo 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 alimentar fotogramas desde cualquier origen: 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."""
# Origen externo para introducir fotogramas desde OpenCV, GStreamer, etc.
frames = fn.external_source(device="cpu", name="input")
frames = fn.reshape(frames, layout="HWC")
# Mover 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 outputServidor de inferencia Triton con DALI#
Para un despliegue de 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, todo procesado en la GPU.
Estructura del repositorio del modelo#
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: Crear la canalización de DALI#
Serializa la canalización de DALI para el backend de DALI de 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 sin procesar codificados procedentes 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
# Serializar la canalización en el repositorio del modelo
pipe = triton_dali_pipeline()
pipe.serialize(filename="model_repository/dali_preprocessing/1/model.dali")Paso 2: Exportar YOLO a 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 antepone una cabecera de metadatos a los archivos .engine; elimínala 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 de la cabecera 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: Configurar 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 ensemble conecta modelos mediante nombres de tensor virtuales. El valor output_map "preprocessed_image" en el paso DALI coincide con el valor input_map "preprocessed_image" en el paso TensorRT. Son nombres arbitrarios que enlazan la salida de un paso con la entrada del siguiente; no tienen que coincidir con los nombres de tensor internos de ningún modelo.
Paso 4: enviar solicitudes de inferencia#
Ultralytics ofrece compatibilidad integrada con Triton, que gestiona automáticamente el preprocesamiento y el posprocesamiento. Sin embargo, no funcionará con el ensemble de DALI porque YOLO() envía un tensor float32 preprocesado, mientras que el ensemble espera bytes JPEG sin procesar. Usa tritonclient directamente para los ensembles de DALI y la integración integrada para implementaciones 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 for the nms=False export)
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 todos los arrays de bytes codificados hasta que tengan la misma longitud (el número máximo de bytes del lote). Triton requiere formas homogéneas en los lotes del tensor de entrada.
Tareas compatibles#
El preprocesamiento de DALI funciona con todas las tareas YOLO que usan la canalización estándar LetterBox:
| Tarea | Compatible | Notas |
|---|---|---|
| Detección | ✅ | Preprocesamiento letterbox estándar |
| Segmentación de instancias | ✅ | El mismo preprocesamiento que en la detección |
| Segmentación semántica | ✅ | El mismo preprocesamiento de imagen que en la detección |
| Estimación de profundidad | ✅ | El mismo preprocesamiento de imagen que en la detección |
| Clasificación | ❌ | Usa transformaciones de torchvision (recorte central), no letterbox |
| Estimación de pose | ✅ | El mismo preprocesamiento que en la detección |
| Detección orientada (OBB) | ✅ | El mismo preprocesamiento que en la detección |
Limitaciones#
- Solo Linux: DALI no es compatible con Windows ni macOS
- Se requiere una GPU NVIDIA: no hay alternativa solo con CPU
- Canalización estática: la estructura de la canalización se define durante la compilación y no se puede cambiar dinámicamente
fn.padsolo rellena por la derecha y por abajo: usafn.cropconout_of_bounds_policy="pad"para aplicar un relleno centrado- Sin modo rect: las canalizaciones 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 DALI de tamaño fijo se combina de forma natural con un motor de tamaño fijo para lograr el máximo rendimiento - Memoria con varias instancias: usar
instance_groupconcount> 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 GPU ya es rápida con TensorRT, el preprocesamiento en CPU, que tarda entre 2 y 10 ms, puede convertirse en el principal coste. 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
DALIGenericIteratorpara obtener resultadostorch.Tensorpreprocesados y, después, pásalos amodel.predict(). Sin embargo, la mejora de rendimiento es mayor con modelos TensorRT, cuya inferencia ya es muy rápida y en los que el preprocesamiento en CPU se convierte en el cuello de botella.fn.padañade relleno solo en los bordes derecho e inferior.fn.cropconout_of_bounds_policy="pad"centra la imagen y añade relleno simétricamente en todos los lados, igual que el comportamiento deLetterBox(center=True)de Ultralytics.Prácticamente idénticos. Configura
antialias=Falseenfn.resizepara que coincida concv2.INTER_LINEARde OpenCV. Puede haber pequeñas diferencias de coma flotante (< 0.001) debido a las operaciones aritméticas en GPU frente a CPU, pero 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 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.