Prétraitement accéléré par GPU avec NVIDIA DALI#
Lors du déploiement de modèles Ultralytics YOLO en production, le prétraitement devient souvent le goulot d’étranglement. Alors que TensorRT peut effectuer l’inférence d’un modèle en quelques millisecondes seulement, le prétraitement exécuté sur le CPU (redimensionnement, ajout de marges, normalisation) peut prendre de 2 à 10 ms par image, en particulier aux résolutions élevées. NVIDIA DALI (bibliothèque de chargement des données) résout ce problème en transférant l’ensemble du pipeline de prétraitement sur le GPU.
Ce guide t’explique comment créer des pipelines DALI qui reproduisent exactement le prétraitement d’Ultralytics YOLO, les intégrer à model.predict(), traiter des flux vidéo et effectuer un déploiement de bout en bout avec Triton Inference Server.
Ce guide s’adresse aux ingénieurs qui déploient des modèles YOLO en production, là où le prétraitement sur CPU constitue un goulot d’étranglement mesuré — généralement dans le cadre de déploiements TensorRT sur des GPU NVIDIA, de pipelines vidéo à haut débit ou de configurations Triton Inference Server. Si tu effectues une inférence standard avec model.predict() et que le prétraitement ne constitue pas un goulot d’étranglement, le pipeline CPU par défaut fonctionne bien.
- Tu crées un pipeline DALI ? Utilise
fn.resize(mode="not_larger")+fn.crop(out_of_bounds_policy="pad")+fn.crop_mirror_normalizepour reproduire sur GPU le prétraitement letterbox de YOLO. - Tu l’intègres à Ultralytics ? Transmets la sortie DALI sous forme de
torch.Tensoràmodel.predict()— Ultralytics ignore automatiquement le prétraitement des images. - Tu effectues un déploiement avec Triton ? Utilise le backend DALI avec un ensemble TensorRT pour un prétraitement sans solliciter le CPU.
Pourquoi utiliser DALI pour le prétraitement de YOLO#
Dans un pipeline d’inférence YOLO classique, les étapes de prétraitement s’exécutent sur le CPU :
- Décoder l’image (JPEG/PNG)
- Redimensionner en préservant les proportions
- Ajouter des marges pour atteindre la taille cible (letterbox)
- Normaliser les valeurs des pixels de
[0, 255]à[0, 1] - Convertir le format de HWC en CHW
Avec DALI, toutes ces opérations s’exécutent sur le GPU, ce qui élimine le goulot d’étranglement du CPU. C’est particulièrement utile dans les cas suivants :
| Scénario | Avantages de DALI |
|---|---|
| Inférence rapide sur GPU | Les moteurs TensorRT dont l’inférence prend moins d’une milliseconde font du prétraitement sur CPU le principal coût |
| Entrées haute résolution | Les flux vidéo 1080p et 4K nécessitent des opérations de redimensionnement coûteuses |
| Tailles de lot importantes | Inférence côté serveur traitant de nombreuses images en parallèle |
| Nombre de cœurs CPU limité | Appareils en périphérie comme NVIDIA Jetson, ou serveurs GPU denses disposant de peu de cœurs CPU par GPU |
Prérequis#
NVIDIA DALI prend en charge Linux uniquement. Il n’est pas disponible sous Windows ni macOS.
Installe les paquets requis :
pip install ultralytics
pip install --extra-index-url https://pypi.nvidia.com nvidia-dali-cuda130Prérequis :
- GPU NVIDIA (capacité de calcul 5.0+ / Maxwell ou version ultérieure)
- CUDA 11.0+, 12.0+ ou 13.0+
- Python 3.10-3.14
- Système d’exploitation Linux
Comprendre le prétraitement de YOLO#
Avant de créer un pipeline DALI, il est utile de comprendre précisément ce qu’Ultralytics fait pendant le prétraitement. La classe clé est LetterBox dans ultralytics/data/augment.py :
from ultralytics.data.augment import LetterBox
letterbox = LetterBox(
new_shape=(640, 640), # Taille cible
center=True, # Centrer l’image (ajouter des marges égales des deux côtés)
stride=32, # Alignement sur le pas
padding_value=114, # Marges grises (114, 114, 114)
)Le pipeline de prétraitement complet dans ultralytics/engine/predictor.py effectue les étapes suivantes :
| Étape | Opération | Fonction CPU | Équivalent DALI |
|---|---|---|---|
| 1 | Redimensionnement letterbox | cv2.resize | fn.resize(mode="not_larger") |
| 2 | Ajout de marges centrées | cv2.copyMakeBorder | fn.crop(out_of_bounds_policy="pad") |
| 3 | BGR → RGB | im[..., ::-1] | fn.decoders.image(output_type=types.RGB) |
| 4 | HWC → CHW + normalisation /255 | np.transpose + tensor / 255 | fn.crop_mirror_normalize(std=[255,255,255]) |
L’opération letterbox préserve les proportions en :
- Calculant l’échelle :
r = min(target_h / h, target_w / w) - Redimensionnant à
(round(w * r), round(h * r)) - Ajoutant des marges grises à l’espace restant (
114) pour atteindre la taille cible - Centrant l’image afin que les marges soient réparties également des deux côtés
Pipeline DALI pour YOLO#
Le pipeline DALI recommandé reproduit le comportement LetterBox(center=True) par défaut d’Ultralytics, utilisé par l’inférence YOLO standard.
Pipeline centré (recommandé, conforme à Ultralytics LetterBox)#
Cette version reproduit exactement le prétraitement Ultralytics par défaut avec des marges centrées, conformément à 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.
"""
# Lire et décoder les images sur le 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)
# Redimensionnement préservant les proportions
resized = fn.resize(
images,
resize_x=target_size,
resize_y=target_size,
mode="not_larger",
interp_type=types.INTERP_LINEAR,
antialias=False, # Correspond à cv2.INTER_LINEAR (sans anticrénelage)
)
# Ajout de marges centrées avec fn.crop et out_of_bounds_policy
# Lorsque la taille du recadrage est supérieure à celle de l’image, fn.crop centre l’image et ajoute des marges symétriques
padded = fn.crop(
resized,
crop=(target_size, target_size),
out_of_bounds_policy="pad",
fill_values=114, # Valeur des marges YOLO
)
# Normaliser et convertir le format
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 tu n’as pas besoin d’une parité exacte avec LetterBox(center=True), tu peux simplifier l’étape d’ajout des marges en utilisant fn.pad(...) au lieu de fn.crop(..., out_of_bounds_policy="pad"). Cette variante ajoute des marges uniquement à droite et en bas, ce qui peut convenir à des pipelines de déploiement personnalisés, mais ne reproduit pas exactement le comportement letterbox centré par défaut d’Ultralytics.
L’opérateur fn.pad de DALI ajoute des marges uniquement à droite et en bas. Pour centrer les marges (comme dans LetterBox(center=True) d’Ultralytics), utilise fn.crop avec out_of_bounds_policy="pad". Avec les paramètres par défaut crop_pos_x=0.5 et crop_pos_y=0.5, l’image est automatiquement centrée avec des marges symétriques.
L’opérateur fn.resize de DALI active l’anticrénelage par défaut (antialias=True), tandis que cv2.resize d’OpenCV avec INTER_LINEAR n’applique pas d’anticrénelage. Définis toujours antialias=False dans DALI pour correspondre au pipeline CPU. Si tu omets ce paramètre, de subtiles différences numériques peuvent affecter la précision du modèle.
Exécuter le 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}]")Utiliser DALI avec Ultralytics Predict#
Tu peux transmettre directement un tenseur [PyTorch](https://ultralytics-translation-0.invalid prétraité à model.predict(). Lorsqu’un torch.Tensor est transmis, Ultralytics ignore le prétraitement des images (letterbox, BGR→RGB, HWC→CHW et normalisation /255) et effectue uniquement le transfert vers l’appareil et la conversion du type de données avant de transmettre le tenseur au modèle.
Comme Ultralytics n’a pas accès aux dimensions originales de l’image dans ce cas, les coordonnées des boîtes de détection sont renvoyées dans l’espace letterbox 640×640. Pour les convertir en coordonnées de l’image originale, utilise scale_boxes, qui applique exactement la logique d’arrondi utilisée par LetterBox :
from ultralytics.utils.ops import scale_boxes
# boxes: tenseur de forme (N, 4) au format xyxy, coordonnées letterbox 640x640
# Mettre à l’échelle les boîtes depuis les coordonnées letterbox (640, 640) vers les dimensions originales (orig_h, orig_w)
boxes = scale_boxes((640, 640), boxes, (orig_h, orig_w))Cela s’applique à tous les chemins de prétraitement externes : entrée directe de tenseurs, flux vidéo et déploiement 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")Lorsque tu transmets un torch.Tensor à model.predict(), l’étape de prétraitement de l’image prend ~0.004 ms (pratiquement zéro), contre ~1–10 ms avec le prétraitement sur CPU. Le tenseur doit être au format BCHW, de type float32 (ou float16) et normalisé à [0, 1]. Ultralytics gère toujours automatiquement le transfert vers l’appareil et la conversion du type de données.
DALI avec des flux vidéo#
Pour le traitement vidéo en temps réel, utilise fn.external_source pour transmettre des images provenant de n’importe quelle source — OpenCV, GStreamer ou des bibliothèques de capture personnalisées :
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."""
# Source externe pour transmettre des images depuis OpenCV, GStreamer, etc.
frames = fn.external_source(device="cpu", name="input")
frames = fn.reshape(frames, layout="HWC")
# Transférer sur le GPU et prétraiter
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 avec DALI#
Pour un déploiement en production, combine le prétraitement DALI avec l'inférence TensorRT dans Triton Inference Server à l'aide d'un modèle d'ensemble. Cela élimine complètement le prétraitement sur CPU : les octets JPEG bruts sont envoyés en entrée, les détections sont renvoyées en sortie, et tout le traitement s'effectue sur le GPU.
Structure du dépôt de modèles#
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Étape 1 : créer le pipeline DALI#
Sérialise le pipeline DALI pour le backend 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."""
# Entrée : octets d'image bruts encodés provenant 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
# Sérialiser le pipeline dans le dépôt de modèles
pipe = triton_dali_pipeline()
pipe.serialize(filename="model_repository/dali_preprocessing/1/model.dali")Étape 2 : exporter YOLO vers 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
) # Sans NMS (N, 300, 6) ; TensorRT >= 8.5
# Ultralytics ajoute un en-tête de métadonnées aux fichiers .engine ; supprime-le pour que Triton puisse charger le plan TensorRT brut
with open(engine_path, "rb") as f:
meta_len = int.from_bytes(f.read(4), byteorder="little") # longueur de l'en-tête de métadonnées 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)Étape 3 : configurer 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"
}
}
]
}L'ensemble connecte les modèles au moyen de noms de tenseurs virtuels. La valeur "preprocessed_image" de output_map à l'étape DALI correspond à la valeur "preprocessed_image" de input_map à l'étape TensorRT. Ce sont des noms arbitraires qui relient la sortie d'une étape à l'entrée de l'étape suivante : ils n'ont pas besoin de correspondre aux noms de tenseurs internes d'un modèle.
Étape 4 : envoyer des requêtes d'inférence#
Ultralytics intègre la prise en charge de Triton, qui gère automatiquement le prétraitement et le post-traitement. Cependant, cette prise en charge ne fonctionnera pas avec l'ensemble DALI, car YOLO() envoie un tenseur float32 prétraité alors que l'ensemble attend des octets JPEG bruts. Utilise directement tritonclient pour les ensembles DALI, et l'intégration intégrée pour les déploiements standards sans 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")Lorsque tu envoies un lot d'images JPEG à Triton, complète toutes les séquences d'octets encodées jusqu'à la même longueur (le nombre maximal d'octets du lot). Triton exige que les formes des lots soient homogènes pour le tenseur d'entrée.
Tâches prises en charge#
Le prétraitement DALI fonctionne avec toutes les tâches YOLO qui utilisent le pipeline standard LetterBox :
| Tâche | Pris en charge | Remarques |
|---|---|---|
| Détection | ✅ | Prétraitement letterbox standard |
| Segmentation d’instances | ✅ | Même prétraitement que pour la détection |
| Segmentation sémantique | ✅ | Même prétraitement d'image que pour la détection |
| Estimation de profondeur | ✅ | Même prétraitement d'image que pour la détection |
| Classification | ❌ | Utilise les transformations torchvision (recadrage centré), et non le letterbox |
| Estimation de pose | ✅ | Même prétraitement que pour la détection |
| Détection orientée (OBB) | ✅ | Même prétraitement que pour la détection |
Limitations#
- Linux uniquement : DALI n'est pas compatible avec Windows ou macOS
- GPU NVIDIA requis : aucun mode de secours avec CPU uniquement
- Pipeline statique : la structure du pipeline est définie lors de sa création et ne peut pas être modifiée dynamiquement
fn.padajoute la marge uniquement à droite et en bas : utilisefn.cropavecout_of_bounds_policy="pad"pour un padding centré- Pas de mode rect : les pipelines DALI produisent des sorties de taille fixe (par ex. 640×640). Le mode rect
auto=True, qui produit des sorties de taille variable (par ex. 384×640), n'est pas pris en charge. Note que, même si TensorRT prend en charge les formes d'entrée dynamiques, un pipeline DALI de taille fixe s'associe naturellement à un moteur de taille fixe pour offrir un débit maximal. - Mémoire avec plusieurs instances : utiliser
instance_groupaveccount> 1 dans Triton peut entraîner une forte consommation de mémoire. Utilise le groupe d'instances par défaut pour le modèle DALI.
FAQ#
Oui. Utilise
DALIGenericIteratorpour obtenir des sortiestorch.Tensorprétraitées, puis transmets-les àmodel.predict(). Cependant, le gain de performances est maximal avec les modèles TensorRT, dont l'inférence est déjà très rapide et pour lesquels le prétraitement sur CPU devient le goulot d'étranglement.fn.padajoute du padding uniquement aux bords droit et inférieur.fn.cropavecout_of_bounds_policy="pad"centre l'image et ajoute du padding symétriquement sur tous les côtés, conformément au comportementLetterBox(center=True)d'Ultralytics.Presque identiques. Définis
antialias=Falsedansfn.resizepour reproduire le comportementcv2.INTER_LINEARd'OpenCV. De légères différences en virgule flottante (< 0.001) peuvent survenir en raison des calculs sur GPU par rapport au CPU, mais elles n'ont aucun effet mesurable sur la précision de la détection.CV-CUDA est une autre bibliothèque NVIDIA pour le traitement de la vision accéléré par GPU. Elle offre un contrôle opérateur par opérateur (comme OpenCV, mais sur GPU), plutôt que l'approche par pipeline de DALI.
cvcuda.copymakeborder()de CV-CUDA prend en charge le padding explicite de chaque côté, ce qui simplifie le letterbox centré. Choisis DALI pour les flux de travail basés sur des pipelines (en particulier avec Triton), et CV-CUDA pour un contrôle précis au niveau des opérateurs dans du code d'inférence personnalisé.
Le gain dépend de ton pipeline. Lorsque l'inférence sur GPU est déjà rapide avec TensorRT, un prétraitement sur CPU de 2 à 10 ms peut devenir le principal facteur limitant. DALI élimine ce goulot d'étranglement en exécutant le prétraitement sur le GPU. Les gains les plus importants sont observés avec des entrées haute résolution (1080p, 4K), de grandes tailles de lot et des systèmes disposant de peu de cœurs CPU par GPU.