Prétraitement accéléré par GPU avec NVIDIA DALI#
Lors du déploiement des modèles Ultralytics YOLO en production, le prétraitement devient souvent le goulot d’étranglement. Alors que TensorRT peut exécuter l’inférence d’un modèle en quelques millisecondes seulement, le prétraitement effectué sur CPU (redimensionnement, ajout de marges, normalisation) peut prendre de 2 à 10 ms par image, en particulier à haute résolution. 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 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 lorsque le prétraitement sur CPU constitue un goulot d’étranglement avéré — 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 exécutes 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 intègres DALI à Ultralytics ? Transmets la sortie DALI sous forme de
torch.Tensoràmodel.predict(): Ultralytics ignore automatiquement le prétraitement des images. - Tu déploies avec Triton ? Utilise le backend DALI avec un ensemble TensorRT pour effectuer le prétraitement sans CPU.
Pourquoi utiliser DALI pour le prétraitement 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 tout en préservant le rapport hauteur/largeur
- Ajouter des marges pour atteindre la taille cible (letterbox)
- Normaliser les valeurs des pixels de
[0, 255]à[0, 1] - Convertir le format des données de HWC à 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 lorsque :
| Scénario | Pourquoi DALI est utile |
|---|---|
| Inférence rapide sur GPU | Les moteurs TensorRT offrant une inférence en 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 élevées | Inférence côté serveur traitant de nombreuses images en parallèle |
| Nombre limité de cœurs CPU | Appareils périphériques 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 YOLO#
Avant de créer un pipeline DALI, il est utile de comprendre précisément ce qu’Ultralytics effectue 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, # Marge grise (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 avec letterbox | cv2.resize | fn.resize(mode="not_larger") |
| 2 | 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 le rapport hauteur/largeur 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 de répartir les marges également des deux côtés
Pipeline DALI pour YOLO#
Le pipeline DALI recommandé reproduit le comportement par défaut LetterBox(center=True) d’Ultralytics, utilisé par l’inférence YOLO standard.
Pipeline centré (recommandé, conforme à LetterBox d’Ultralytics)#
Cette version reproduit exactement le prétraitement par défaut d’Ultralytics 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 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 le rapport hauteur/largeur
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)
)
# Marges centrées à l’aide de fn.crop avec 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 des données
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 correspondance exacte avec LetterBox(center=True), tu peux simplifier l’étape d’ajout de marges en utilisant fn.pad(...) plutôt que fn.crop(..., out_of_bounds_policy="pad"). Cette variante ajoute des marges uniquement sur les bords droit et inférieur, ce qui peut convenir aux pipelines de déploiement personnalisés, mais ne reproduira pas exactement le comportement letterbox centré par défaut d’Ultralytics.
L’opérateur fn.pad de DALI ajoute des marges uniquement sur les bords droit et inférieur. Pour obtenir des marges centrées (conformes à LetterBox(center=True) d’Ultralytics), utilise fn.crop avec out_of_bounds_policy="pad". Avec les valeurs 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 légères 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 Predict d’Ultralytics#
Tu peux transmettre directement à model.predict() un tenseur PyTorch prétraité. Lorsqu’un torch.Tensor est transmis, Ultralytics ignore le prétraitement de l’image (letterbox, BGR→RGB, HWC→CHW et normalisation /255) et effectue uniquement le transfert vers le périphérique et la conversion du type de données avant de le transmettre au modèle.
Comme Ultralytics n’a pas accès aux dimensions de l’image d’origine dans ce cas, les coordonnées des boîtes de détection sont renvoyées dans l’espace letterbox 640×640. Pour les convertir aux coordonnées de l’image d’origine, utilise scale_boxes, qui applique exactement la logique d’arrondi utilisée par LetterBox :
from ultralytics.utils.ops import scale_boxes
# boîtes : tenseur de forme (N, 4) au format xyxy, coordonnées letterbox 640x640
# Mettre à l’échelle les boîtes de l’image letterbox (640, 640) à l’image d’origine (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 d’un tenseur, 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.004ms (pratiquement rien), contre ~1-10ms avec un prétraitement sur CPU. Le tenseur doit être au format BCHW, en float32 (ou float16) et normalisé à [0, 1]. Ultralytics gère toujours automatiquement le transfert vers le périphérique 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 fournir 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 fournir des images depuis OpenCV, GStreamer, etc.
frames = fn.external_source(device="cpu", name="input")
frames = fn.reshape(frames, layout="HWC")
# Transférer vers le GPU et effectuer le prétraitement
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 outputServeur d’inférence Triton avec DALI#
Pour un déploiement en production, combine le prétraitement DALI et l’inférence TensorRT dans Triton Inference Server avec un modèle d’ensemble. Cela élimine complètement le prétraitement sur CPU : des octets JPEG bruts sont fournis en entrée, et les détections sont renvoyées en sortie, le tout étant traité 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 bruts de l’image encodée 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 relie les modèles à l’aide de noms de tenseurs virtuels. La valeur output_map "preprocessed_image" à l’étape DALI correspond à la valeur input_map "preprocessed_image" à l’étape TensorRT. Ces noms arbitraires relient la sortie d’une étape à l’entrée de la suivante : ils n’ont pas besoin de correspondre aux noms internes des tenseurs 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 fonctionnalité ne fonctionne 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 standard 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 tous les tableaux d’octets encodés pour qu’ils aient 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 standard avec letterbox |
| Segmentation d'instances | ✅ | Même prétraitement que pour la détection |
| Segmentation sémantique | ✅ | Même prétraitement des images que pour la détection |
| Estimation de profondeur | ✅ | Même prétraitement des images 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 |
Limites#
- Linux uniquement : DALI ne prend pas en charge Windows ni macOS
- GPU NVIDIA requis : aucun mode de secours limité au CPU
- Pipeline statique : la structure du pipeline est définie au moment de la compilation et ne peut pas changer dynamiquement
fn.pads’applique uniquement à droite et en bas : utilisefn.cropavecout_of_bounds_policy="pad"pour un remplissage centré- Pas de mode rect : les pipelines DALI produisent des sorties de taille fixe (par exemple, 640×640). Le mode rect
auto=True, qui produit des sorties de taille variable (par exemple, 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 un débit maximal - Mémoire avec plusieurs instances : utiliser
instance_groupaveccount> 1 dans Triton peut entraîner une forte consommation mémoire. Utilise le groupe d’instances par défaut pour le modèle DALI
FAQ#
Le gain dépend de ton pipeline. Lorsque l’inférence GPU est déjà rapide avec TensorRT, le prétraitement CPU, qui prend de 2 à 10 ms, peut devenir le principal coût. DALI élimine ce goulot d’étranglement en exécutant le prétraitement sur le GPU. Les gains sont les plus importants 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.
Oui. Utilise
DALIGenericIteratorpour obtenir des sortiestorch.Tensorprétraitées, puis transmets-les àmodel.predict(). Toutefois, le gain de performances est maximal avec les modèles TensorRT, pour lesquels l’inférence est déjà très rapide et le prétraitement CPU devient le goulot d’étranglement.fn.padajoute du remplissage uniquement sur les bords droit et inférieur.fn.cropavecout_of_bounds_policy="pad"centre l’image et ajoute du remplissage de manière symétrique sur tous les côtés, conformément au comportementLetterBox(center=True)d’Ultralytics.Les résultats sont presque identiques. Définis
antialias=Falsedansfn.resizepour correspondre àcv2.INTER_LINEARd’OpenCV. De légères différences en virgule flottante (< 0.001) peuvent apparaître en raison des calculs sur GPU plutôt que sur CPU, mais elles n’ont aucun effet mesurable sur la précision de 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), contrairement à l’approche par pipeline de DALI.
cvcuda.copymakeborder()de CV-CUDA prend en charge le remplissage 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é.