Ultralytics YOLO27:

Предварительная обработка с ускорением на GPU с помощью NVIDIA DALI#

При развертывании моделей Ultralytics YOLO в production-среде предварительная обработка часто становится узким местом. Хотя TensorRT может выполнять инференс модели всего за несколько миллисекунд, предварительная обработка на CPU (изменение размера, добавление полей, нормализация) может занимать 2–10 мс на изображение, особенно при высоком разрешении. NVIDIA DALI (библиотека загрузки данных) решает эту проблему, перенося весь конвейер предварительной обработки на GPU.

В этом руководстве показано, как создавать конвейеры DALI, точно повторяющие предварительную обработку Ultralytics YOLO, интегрировать их с model.predict(), обрабатывать видеопотоки и развертывать решение целиком с помощью Triton Inference Server.

Для кого это руководство?

Руководство предназначено для инженеров, которые развертывают модели YOLO в production-средах, где измерения показывают, что предварительная обработка на CPU становится узким местом. Обычно это развертывания с помощью TensorRT на GPU NVIDIA, высокопроизводительные видеоконвейеры или конфигурации Triton Inference Server. Если ты выполняешь стандартный инференс с помощью model.predict() и предварительная обработка не становится узким местом, стандартный конвейер на CPU работает хорошо.

Краткое содержание
  • Создаешь конвейер DALI? Используй fn.resize(mode="not_larger") + fn.crop(out_of_bounds_policy="pad") + fn.crop_mirror_normalize, чтобы повторить предварительную обработку YOLO с letterbox на GPU.
  • Интегрируешь решение с Ultralytics? Передай результат DALI как torch.Tensor в model.predict() — Ultralytics автоматически пропустит предварительную обработку изображения.
  • Развертываешь решение с помощью Triton? Используй бэкенд DALI вместе с ансамблем TensorRT, чтобы полностью исключить предварительную обработку на CPU.

Зачем использовать DALI для предварительной обработки YOLO#

В типичном конвейере инференса YOLO этапы предварительной обработки выполняются на CPU:

  1. Декодирование изображения (JPEG/PNG)
  2. Изменение размера с сохранением соотношения сторон
  3. Добавление полей до целевого размера (letterbox)
  4. Нормализация значений пикселей от [0, 255] до [0, 1]
  5. Преобразование формата из HWC в CHW

С DALI все эти операции выполняются на GPU, устраняя узкое место на CPU. Это особенно полезно в следующих случаях:

СценарийПреимущества DALI
Быстрый инференс на GPUДвижки TensorRT с инференсом менее чем за миллисекунду превращают предварительную обработку на CPU в основную статью затрат
Изображения с высоким разрешениемДля видеопотоков в разрешении 1080p и 4K требуются ресурсоемкие операции изменения размера
Большие размеры пакетовИнференс на сервере с параллельной обработкой множества изображений
Ограниченное число ядер CPUПериферийные устройства, например NVIDIA Jetson, или серверы с большим количеством GPU и малым числом ядер CPU на каждый GPU

Предварительные требования#

Только Linux

NVIDIA DALI поддерживается только в Linux. В Windows и macOS он недоступен.

Установи необходимые пакеты:

pip install ultralytics
pip install --extra-index-url https://pypi.nvidia.com nvidia-dali-cuda130

Требования:

  • GPU NVIDIA (вычислительная способность 5.0+ / Maxwell или новее)
  • CUDA 11.0+, 12.0+ или 13.0+
  • Python 3.10–3.14
  • Операционная система Linux

Как устроена предварительная обработка YOLO#

Перед созданием конвейера DALI стоит разобраться, что именно Ultralytics делает при предварительной обработке. Ключевой класс — LetterBox из ultralytics/data/augment.py:

from ultralytics.data.augment import LetterBox

letterbox = LetterBox(
    new_shape=(640, 640),  # Целевой размер
    center=True,  # Расположить изображение по центру (добавить одинаковые поля с обеих сторон)
    stride=32,  # Выравнивание по шагу
    padding_value=114,  # Серые поля (114, 114, 114)
)

Полный конвейер предварительной обработки в ultralytics/engine/predictor.py выполняет следующие шаги:

ШагОперацияФункция CPUЭквивалент в DALI
1Изменение размера с letterboxcv2.resizefn.resize(mode="not_larger")
2Добавление полей по центруcv2.copyMakeBorderfn.crop(out_of_bounds_policy="pad")
3BGR → RGBim[..., ::-1]fn.decoders.image(output_type=types.RGB)
4HWC → CHW + нормализация /255np.transpose + tensor / 255fn.crop_mirror_normalize(std=[255,255,255])

Операция letterbox сохраняет соотношение сторон следующим образом:

  1. Вычисление масштаба: r = min(target_h / h, target_w / w)
  2. Изменение размера до (round(w * r), round(h * r))
  3. Заполнение оставшегося пространства серым цветом (114) до целевого размера
  4. Размещение изображения по центру, чтобы поля с обеих сторон были одинаковыми

Конвейер DALI для YOLO#

Рекомендуемый конвейер DALI повторяет стандартное поведение LetterBox(center=True) в Ultralytics, используемое при обычном инференсе YOLO.

Конвейер с центрированием (рекомендуется, соответствует LetterBox в Ultralytics)#

Эта версия точно повторяет стандартную предварительную обработку Ultralytics с полями по центру, соответствуя LetterBox(center=True):

Конвейер DALI с полями по центру (рекомендуется)
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.
    """
    # Считать и декодировать изображения на 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)

    # Изменение размера с сохранением соотношения сторон
    resized = fn.resize(
        images,
        resize_x=target_size,
        resize_y=target_size,
        mode="not_larger",
        interp_type=types.INTERP_LINEAR,
        antialias=False,  # Соответствует cv2.INTER_LINEAR (без сглаживания)
    )

    # Добавление полей по центру с помощью fn.crop и out_of_bounds_policy
    # Если размер обрезки больше размера изображения, fn.crop размещает изображение по центру и симметрично добавляет поля
    padded = fn.crop(
        resized,
        crop=(target_size, target_size),
        out_of_bounds_policy="pad",
        fill_values=114,  # Значение заполнения полей YOLO
    )

    # Нормализация и преобразование формата
    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
Когда достаточно `fn.pad`?

Если тебе не требуется точное соответствие LetterBox(center=True), можно упростить этап добавления полей, используя fn.pad(...) вместо fn.crop(..., out_of_bounds_policy="pad"). Этот вариант добавляет поля только справа и снизу. Это может подойти для пользовательских конвейеров развертывания, но он не будет в точности соответствовать стандартному поведению letterbox с центрированием в Ultralytics.

Зачем использовать `fn.crop` для добавления полей по центру?

Оператор fn.pad в DALI добавляет поля только справа и снизу. Чтобы разместить поля по центру (как в LetterBox(center=True) Ultralytics), используй fn.crop вместе с out_of_bounds_policy="pad". При стандартных значениях crop_pos_x=0.5 и crop_pos_y=0.5 изображение автоматически размещается по центру, а поля добавляются симметрично.

Несоответствие в сглаживании

По умолчанию fn.resize в DALI включает сглаживание (antialias=True), тогда как cv2.resize в OpenCV с INTER_LINEAR не применяет сглаживание. Чтобы конвейер соответствовал варианту на CPU, всегда устанавливай в DALI antialias=False. Если этого не сделать, появятся небольшие численные расхождения, которые могут повлиять на точность модели.

Запуск конвейера#

Создание и запуск конвейера DALI
# 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}]")

Использование DALI с Ultralytics Predict#

Ты можешь напрямую передать предварительно обработанный тензор PyTorch в model.predict(). Если передан torch.Tensor, Ultralytics пропускает предварительную обработку изображения (letterbox, BGR→RGB, HWC→CHW и нормализацию /255) и перед передачей изображения модели выполняет только перенос на устройство и приведение типа данных.

В этом случае Ultralytics не имеет доступа к исходным размерам изображения, поэтому координаты рамок обнаружения возвращаются в пространстве letterbox 640×640. Чтобы преобразовать их обратно в координаты исходного изображения, используй scale_boxes: этот метод учитывает точную логику округления, используемую в LetterBox.

from ultralytics.utils.ops import scale_boxes

# boxes: тензор формы (N, 4) в формате xyxy, координаты letterbox 640x640
# Масштабировать рамки из пространства letterbox (640, 640) обратно в исходный размер (orig_h, orig_w)
boxes = scale_boxes((640, 640), boxes, (orig_h, orig_w))

Это относится ко всем внешним путям предварительной обработки — прямой передаче тензора, видеопотокам и развертыванию Triton.

DALI + Ultralytics predict
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")
Нулевые затраты на предварительную обработку

При передаче torch.Tensor в model.predict() этап предварительной обработки изображения занимает ~0.004 мс (практически ноль) по сравнению с ~1–10 мс при предварительной обработке на CPU. Тензор должен иметь формат BCHW, тип float32 (или float16) и быть нормализован до [0, 1]. Ultralytics по-прежнему автоматически выполняет перенос на устройство и приведение типа данных.

DALI с видеопотоками#

Для обработки видео в реальном времени используй fn.external_source, чтобы передавать кадры из любого источника — OpenCV, GStreamer или пользовательских библиотек захвата:

Конвейер DALI для предварительной обработки видеопотока
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."""
    # Внешний источник для передачи кадров из OpenCV, GStreamer и т. д.
    frames = fn.external_source(device="cpu", name="input")
    frames = fn.reshape(frames, layout="HWC")

    # Перенос на GPU и предварительная обработка
    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 output

Triton Inference Server с DALI#

Для развёртывания в production объедини предобработку DALI с инференсом TensorRT в Triton Inference Server, используя ансамблевую модель. Так ты полностью исключишь предобработку на CPU: на вход будут подаваться исходные байты JPEG, а на выходе будут детекции — вся обработка выполняется на GPU.

Структура репозитория моделей#

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

Шаг 1. Создай конвейер DALI#

Сериализуй конвейер DALI для бэкенда Triton DALI:

Сериализация конвейера DALI для 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."""
    # Вход: необработанные байты закодированного изображения из 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

# Сериализация конвейера в репозиторий моделей
pipe = triton_dali_pipeline()
pipe.serialize(filename="model_repository/dali_preprocessing/1/model.dali")

Шаг 2. Экспортируй YOLO в TensorRT#

Экспорт модели YOLO в движок 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
)  # Без NMS (N, 300, 6); TensorRT >= 8.5

# Ultralytics добавляет в начало файлов .engine заголовок с метаданными; удали его, чтобы Triton мог загрузить исходный план TensorRT
with open(engine_path, "rb") as f:
    meta_len = int.from_bytes(f.read(4), byteorder="little")  # длина заголовка с метаданными 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)

Шаг 3. Настрой 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"
      }
    }
  ]
}
Как работает сопоставление в ансамбле

Ансамбль соединяет модели с помощью виртуальных имён тензоров. Значение output_map "preprocessed_image" на шаге DALI совпадает со значением input_map "preprocessed_image" на шаге TensorRT. Это произвольные имена, которые связывают выход одного шага со входом следующего — они не обязаны совпадать с внутренними именами тензоров какой-либо модели.

Шаг 4. Отправь запросы на инференс#

Почему `tritonclient`, а не `YOLO('http://...')`?

В Ultralytics есть встроенная поддержка Triton, которая автоматически выполняет пред- и постобработку. Однако она не будет работать с ансамблем DALI, потому что YOLO() отправляет тензор float32 после предобработки, а ансамбль ожидает исходные байты JPEG. Для ансамблей DALI используй напрямую tritonclient, а для стандартных развёртываний без DALI — встроенную интеграцию.

Отправка изображений в ансамбль Triton
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")
Пакетная обработка изображений JPEG

При отправке пакета изображений JPEG в Triton дополни все массивы закодированных байтов до одинаковой длины (максимального числа байтов в пакете). Triton требует, чтобы формы входного тензора в пакете были одинаковыми.

Поддерживаемые задачи#

Предобработка DALI работает со всеми задачами YOLO, использующими стандартный конвейер LetterBox:

ЗадачаПоддерживаетсяПримечания
Обнаружение объектов✅Стандартная предобработка с letterbox
Сегментация экземпляров✅Та же предобработка, что и для детекции
Семантическая сегментация✅Та же предобработка изображений, что и для детекции
Оценка глубины✅Та же предобработка изображений, что и для детекции
Классификация❌Используются преобразования torchvision (центральное кадрирование), а не letterbox
Оценка позы✅Та же предобработка, что и для детекции
Детекция ориентированных объектов (OBB)✅Та же предобработка, что и для детекции

Ограничения#

  • Только Linux: DALI не поддерживает Windows и macOS
  • Требуется GPU NVIDIA: варианта для CPU нет
  • Статический конвейер: структура конвейера задаётся при сборке и не может динамически изменяться
  • fn.pad дополняет только справа и снизу: для центрированного дополнения используй fn.crop с out_of_bounds_policy="pad"
  • Режим rect не поддерживается: конвейеры DALI выдают изображения фиксированного размера (например, 640×640). Режим rect auto=True, который выдаёт изображения переменного размера (например, 384×640), не поддерживается. Учти: хотя TensorRT поддерживает динамические формы входных данных, конвейер DALI фиксированного размера хорошо сочетается с движком фиксированного размера и обеспечивает максимальную пропускную способность.
  • Память при использовании нескольких экземпляров: использование instance_group с count > 1 в Triton может привести к высокому потреблению памяти. Используй группу экземпляров по умолчанию для модели DALI.

Часто задаваемые вопросы#

  • Преимущество зависит от твоего конвейера. Когда инференс на GPU и так выполняется быстро с помощью TensorRT, предобработка на CPU, занимающая 2–10 мс, может стать основным узким местом. DALI устраняет это узкое место, выполняя предобработку на GPU. Наибольший прирост производительности заметен при обработке изображений высокого разрешения (1080p, 4K), больших размерах пакетов и ограниченном числе ядер CPU на каждый GPU.

  • Да. Используй DALIGenericIterator, чтобы получить предварительно обработанные выходные данные torch.Tensor, а затем передай их в model.predict(). Однако наибольший прирост производительности достигается с моделями TensorRT: инференс в них и так выполняется очень быстро, поэтому узким местом становится предобработка на CPU.

  • fn.pad добавляет поля только справа и снизу. fn.crop с out_of_bounds_policy="pad" центрирует изображение и симметрично добавляет поля со всех сторон, как и LetterBox(center=True) в Ultralytics.

  • Результаты почти идентичны. Задай antialias=False в fn.resize, чтобы соответствовать cv2.INTER_LINEAR в OpenCV. Из-за различий в вычислениях на GPU и CPU возможны небольшие расхождения с плавающей точкой (< 0.001), но они никак не влияют на точность детекции.

  • CV-CUDA — ещё одна библиотека NVIDIA для ускорения обработки изображений на GPU. Она позволяет управлять отдельными операциями (как в OpenCV, но на GPU), а не использует подход DALI с конвейерами. cvcuda.copymakeborder() в CV-CUDA поддерживает явное задание полей для каждой стороны, поэтому настроить центрированный letterbox просто. Выбирай DALI для конвейерных рабочих процессов (особенно с Triton), а CV-CUDA — для детального управления отдельными операциями в пользовательском коде инференса.

Комментарии