Трансляция h264 видео без перекодирования и задержки

в 8:40, , рубрики: h264, Intel Media SDK, intel quick sync video, Minnowboard, MJPEG, MJPG-Streamer, opencv, video streaming, Блог компании Intel, Блог компании Singularis, БПЛА, видео, передача видео, Работа с видео

Не секрет, что при управлении летательными аппаратами часто используется передача видео с самого аппарата на землю. Обычно такую возможность предоставляют производители самих БПЛА. Однако что же делать, если дрон собран своими руками?

Перед нами и нашими швейцарскими партнёрами из компании Helvetis встала задача транслировать видео в режиме реального времени с web-камеры с маломощного embedded-устройства на дроне по WiFi на Windows-планшет. В идеале бы нам хотелось:

  • задержку < 0.3с;
  • низкую загрузку CPU на embedded-системе (меньше 10% на одно ядро);
  • разрешение хотя бы 480p (лучше 720p).

Казалось бы, что может пойти не так?
Трансляция h264 видео без перекодирования и задержки - 1

Итак, мы остановились на следующем списке оборудования:

  • Minnowboard 2-core, Atom E3826 @ 1.4 GHz, ОС: Ubuntu 16.04
  • Web-камера ELP USB100W04H, поддерживающая несколько форматов (YUV, MJPEG, H264)
  • Windows-планшет ASUS VivoTab Note 8

Попытки обойтись стандартными решениями

Простое решение с Python+OpenCV

Сначала мы попробовали использовать простой Python-скрипт, который с помощью OpenCV получал кадры с камеры, сжимал их, используя JPEG, и отдавал по HTTP приложению-клиенту.

Http mjpg стриминг на python

from flask import Flask, render_template, Response
import cv2

class VideoCamera(object):
    def __init__(self):
        self.video = cv2.VideoCapture(0)
    
    def __del__(self):
        self.video.release()
    
    def get_frame(self):
        success, image = self.video.read()
        ret, jpeg = cv2.imencode('.jpg', image)
        return jpeg.tobytes()

app = Flask(__name__)

@app.route('/')
def index():
    return render_template('index.html')

def gen(camera):
    while True:
        frame = camera.get_frame()
        yield (b'--framern'
               b'Content-Type: image/jpegrnrn' + frame + b'rnrn')

@app.route('/video_feed')
def video_feed():
    return Response(gen(VideoCamera()),
                    mimetype='multipart/x-mixed-replace; boundary=frame')

if __name__ == '__main__':
    app.run(host='0.0.0.0', debug=True)

Этот подход оказался (почти) работающим. В качестве приложения для просмотра можно было использовать любой web-браузер. Однако мы сразу заметили, что частота кадров была ниже ожидаемой, а уровень загрузки CPU на Minnowboard был постоянно на уровне 100%. Embedded-устройство просто не справлялось с кодированием кадров в режиме реального времени. Из плюсов данного решения стоит отметить очень небольшую задержку при передаче 480p видео с частотой не более 10 кадров в секунду.

В ходе обыска была обнаружена web-камера, которая помимо несжатых YUV-кадров могла выдавать кадры в формате MJPEG. Было решено воспользоваться такой полезной функцией, чтобы уменьшить нагрузку на CPU и найти способ передать видео без перекодирования.

FFmpeg / VLC

Первым делом мы попробовали всеми любимый open-source комбайн ffmpeg, позволяющий, среди прочего, считывать видео-поток с UVC-устройства, кодировать его и передавать. После небольшого погружения в мануал были найдены ключи командной строки, которые позволяли получить и передать сжатый MJPEG видеопоток без перекодирования.

ffmpeg -f v4l2 -s 640x480 -input_format mjpeg -i /dev/video0 -c:v copy -f mjpeg udp://ip:port

Уровень загрузки CPU был невысок. Обрадовавшись, мы с нетерпением открыли поток в плеере ffplay… К нашему разочарованию, уровень задержки видео был абсолютно неприемлемым (около 2 — 3 секунд). Попробовав все отсюда и прошерстив Интернет, мы так и не смогли добиться положительного результата и решили отказаться от ffmpeg.

После провала с ffmpeg пришла очередь медиаплеера VLC, а точнее консольной утилиты cvlc. VLC по умолчанию использует кучу всяких буферов, которые с одной стороны помогают добиться плавности изображения, но с другой дают серьезную задержку в несколько секунд. Изрядно помучавшись, мы подобрали параметры, с которыми стриминг выглядел достаточно сносно, т.е. задержка была не очень большой (около 0.5 с), не было перекодирования, и клиент показывал видео достаточно плавно (пришлось, правда, на клиенте оставить небольшой буфер в 150 мс).

Так выглядит итоговая строка для cvlc:

cvlc -v v4l2:///dev/video0:chroma="MJPG":width=640:height=480:fps=30 --sout="#rtp{sdp=rtsp://:port/live,caching=0}" --rtsp-timeout=-1 --sout-udp-caching=0 --network-caching=0 --live-caching=0

К сожалению, видео работало не вполне стабильно, да и задержка в 0.5 с была для нас неприемлема.

Mjpg-streamer

Наткнувшись на статью о практически нашей задаче, решили попробовать mjpg-streamer. Попробовали, понравилось! Абсолютно без изменений получилось использовать mjpg-streamer для наших нужд без существенной задержки видео на разрешении 480p.

На фоне предыдущих неудач мы довольно долго были счастливы, но потом мы захотели большего. А именно: чуть меньше забивать канал и повысить качество видео до 720p.

H264 стриминг

Чтобы уменьшить загрузку канала, мы решили поменять используемый кодек на h264 (найдя в наших запасах подходящую web-камеру). Mjpg-streamer не имел поддержки h264, так что было решено его доработать. Во время разработки мы использовали две камеры со встроенным кодеком h264, производства Logitech и ELP. Как оказалось, содержимое потока h264 у этих камер существенно различалось.

Камеры и структура потока

Поток h264 состоит из пакетов NAL (network abstraction layer) нескольких типов. Наши камеры генерировали 5 типов пакетов:

  • Picture parameter set (PPS)
  • Sequence parameter set (SPS)
  • Coded slice layer without partitioning, IDR picture
  • Coded slice layer without partitioning, non-IDR picture
  • Coded slice data partition

IDR (Instantaneous decoding refresh) — пакет, содержащий кодированное изображение. При этом все необходимые данные для декодирования изображения находятся в этом пакете. Этот пакет необходим декодеру, чтобы начать формировать изображение. Обычно первый кадр любого видео сжатого h264 — IDR picture.

Non-IDR — пакет, содержащий кодированное изображение, содержащее ссылки на другие кадры. Декодер не в состоянии восстановить изображение по одному Non-IDR кадру без наличия других пакетов.

Помимо IDR-кадра, декодеру нужны пакеты PPS и SPS для декодирования изображения. Эти пакеты содержат метаданные об изображении и потоке кадров.

Основываясь на коде mjpg-streamer, мы воспользовались API V4L2 (video4linux2) для считывания данных от камер. Как выяснилось, один “кадр” видео содержал несколько NAL пакетов.

Именно в содержимом “кадров” обнаружилось существенное различие между камерами. Мы воспользовались библиотекой h264bitstream для парсинга потока. Существуют standalone-утилиты, позволяющие просмотреть содержимое потока.

Трансляция h264 видео без перекодирования и задержки - 2
Поток кадров камеры Logitech состоял в основном из non-IDR кадров, к тому же разделенных на несколько data partition. Раз в 30 секунд камера генерировала пакет, содержащий IDR picture, SPS и PPS. Так как декодеру нужен IDR пакет для того, чтобы начать декодировать видео, нас эта ситуация сразу не устроила. К нашему сожалению, оказалось, что нет адекватного способа установить период, с которым камера генерирует IDR пакеты. Поэтому нам пришлось отказаться от использования этой камеры.

Трансляция h264 видео без перекодирования и задержки - 3
Камера производства ELP оказалась существенно удобнее. Каждый получаемый нами кадр содержал в себе пакеты PPS и SPS. К тому же, камера генерировала IDR пакет раз в 30 кадров (период ~1с). Это нас вполне устраивало и мы остановили свой выбор на этой камере.

Реализация сервера вещания на основе mjpg-streamer

За основу серверной части решено было взять вышеупомянутый mjpg-streamer. Его архитектура позволяла легко добавлять новые плагины ввода и вывода. Мы начали с добавления плагина для считывания потока h264 с устройства. В качестве плагина вывода выбрали уже имеющийся плагин http.

В V4L2 достаточно было указать что мы хотим получать кадры в формате V4L2_PIX_FMT_H264, чтобы начать получать поток h264.Так как для декодирования потока необходим IDR-кадр, мы парсили поток и ожидали IDR-кадр. Приложению-клиенту поток отправлялся по HTTP начиная с этого кадра.

На клиентской части решили воспользоваться libavformat и libavcodec из проекта ffmpeg для чтения и декодирования потока h264. В первом тестовом прототипе получение потока по сети, разбиение его на кадры и декодирование было возложено на ffmpeg, конвертирование получаемого декодированного изображения из формата NV12 в RGB и отображение было реализовано на OpenCV.

Первые тесты показали, что данный способ транслирования видео работоспособен, но имеется существенная задержка (около 1 секунды). Наше подозрение пало на протокол http, поэтому было решено использовать для передачи пакетов UDP.

Так как у нас не было необходимости поддержки существующих протоколов вроде RTP, мы реализовали свой простейший велосипед протокол, в котором внутри UDP-датаграмм передавались NAL-пакеты потока h264. После небольшой доработки принимающей части мы были приятно удивлены малой задержкой видео на настольном ПК. Однако первые же тесты на мобильном устройстве показали, что программное декодирование h264 — не конёк мобильных процессоров. Планшет просто не успевал обрабатывать кадры в режиме реального времени.

Так как процессор Atom Z3740, используемый на нашем планшете, поддерживает технологию Quick Sync Video (QSV), мы попробовали использовать QSV h264 декодер из libavcodec. К нашему удивлению, он не только не улучшил ситуацию, но и увеличил задержку до 1.5 секунд даже на мощном настольном ПК! Однако этот подход действительно существенно снизил нагрузку на CPU.

Перепробовав различные варианты конфигурации декодера в ffmpeg, было решено отказаться от libavcodec и использовать Intel Media SDK напрямую.

Первым сюрпризом для нас стал ужас, в который предлагается погрузиться человеку, решившему разрабатывать используя Media SDK. Официальный пример, предлагаемый разработчикам, представляет из себя мощный комбайн, который умеет всё, но в котором трудно разобраться. К счастью, на форумах Intel мы нашли единомышленников, также недовольных примером. Они нашли старые, но более легкоусвояемые туториалы. На основе пример simple_2_decode мы получили следующий код.

Декодирование стрима при помощи Intel Media SDK

mfxStatus sts = MFX_ERR_NONE;

// Буфер с содержимым потока h264
mfxBitstream mfx_bitstream;
memset(&mfx_bitstream, 0, sizeof(_mfxBS));
mfx_bitstream.MaxLength = 1 * 1024 * 1024; // 1MB
mfx_bitstream.Data = new mfxU8[mfx_bitstream.MaxLength];

// Реализация протокола на основе UDP
StreamReader *reader = new StreamReader(/*...*/);

MFXVideoDECODE *mfx_dec;
mfxVideoParam mfx_video_params;
MFXVideoSession session;
mfxFrameAllocator *mfx_allocator;

// Инициализация сессии MFX
mfxIMPL impl = MFX_IMPL_AUTO;
mfxVersion ver = { { 0, 1 } };
session.Init(sts, &ver);
if (sts < MFX_ERR_NONE)
    return 0; // :(

// Создаем декодер, устанавливаем кодек AVC (h.264)
mfx_dec = new MFXVideoDECODE(session);
memset(&mfx_video_params, 0, sizeof(mfx_video_params));
mfx_video_params.mfx.CodecId = MFX_CODEC_AVC;
// Декодируем в системную память
mfx_video_params.IOPattern = MFX_IOPATTERN_OUT_SYSTEM_MEMORY;
// Устанавливаем глубину очереди в минимальное значение
mfx_video_params.AsyncDepth = 1;

// получаем метаинформацию о видео
reader->ReadToBitstream(&mfx_bitstream);
sts = mfx_dec->DecodeHeader(&mfx_bitstream, &mfx_video_params);

if (sts < MFX_ERR_NONE)
    return 0; // :(

// Запросим информацию о размере кадров
mfxFrameAllocRequest request;
memset(&request, 0, sizeof(request));
sts = mfx_dec->QueryIOSurf(&mfx_video_params, &request);
if (sts < MFX_ERR_NONE)
    return 0; // :(

mfxU16 numSurfaces = request.NumFrameSuggested;

// Для декодера необходимо чтобы ширина и высота были кратны 32
mfxU16 width = (mfxU16)MSDK_ALIGN32(request.Info.Width);
mfxU16 height = (mfxU16)MSDK_ALIGN32(request.Info.Height);
// NV12 - формат YUV 4:2:0, 12 бит на пиксель
mfxU8 bitsPerPixel = 12;
mfxU32 surfaceSize = width * height * bitsPerPixel / 8;

// Выделим память для поверхностей в которые будут декодироваться кадры 
mfxU8* surfaceBuffers = new mfxU8[surfaceSize * numSurfaces];

// Метаинформация о поверхностях для декодера
mfxFrameSurface1** pmfxSurfaces = 
                new mfxFrameSurface1*[numSurfaces];
for(int i = 0; i < numSurfaces; i++)
{
    pmfxSurfaces[i] = new mfxFrameSurface1;
    memset(pmfxSurfaces[i], 0, sizeof(mfxFrameSurface1));
    memcpy(&(pmfxSurfaces[i]->Info), 
      &(_mfxVideoParams.mfx.FrameInfo), sizeof(mfxFrameInfo));
    pmfxSurfaces[i]->Data.Y = &surfaceBuffers[surfaceSize * i];
    pmfxSurfaces[i]->Data.U = 
                      pmfxSurfaces[i]->Data.Y + width * height;
    pmfxSurfaces[i]->Data.V = pmfxSurfaces[i]->Data.U + 1;
    pmfxSurfaces[i]->Data.Pitch = width;
}

sts = mfx_dec->Init(&mfx_video_params);
if (sts < MFX_ERR_NONE)
    return 0; // :(

mfxSyncPoint syncp;
mfxFrameSurface1* pmfxOutSurface = NULL;
mfxU32 nFrame = 0;

// Начало декодирования потока
while (reader->IsActive() &&
    (MFX_ERR_NONE <= sts
        || MFX_ERR_MORE_DATA == sts
        || MFX_ERR_MORE_SURFACE == sts))
{
    // Ждем если устройство было занято 
    if (MFX_WRN_DEVICE_BUSY == sts)
        Sleep(1);
    if (MFX_ERR_MORE_DATA == sts)
        reader->ReadToBitstream(mfx_bitstream);

    if (MFX_ERR_MORE_SURFACE == sts || MFX_ERR_NONE == sts)
    {
        nIndex = GetFreeSurfaceIndex(pmfxSurfaces, numSurfaces);
        if (nIndex == MFX_ERR_NOT_FOUND)
            break;
    }

    // Декодирование кадра
    // Декодер самостоятельно находит NAL-пакеты в потоке и забирает их
    sts = mfx_dec->DecodeFrameAsync(mfx_bitstream, 
            pmfxSurfaces[nIndex], &pmfxOutSurface, &syncp);

    // Игнорируем предупреждения
    if (MFX_ERR_NONE < sts && syncp)
        sts = MFX_ERR_NONE;

    // Ожидаем окончания декодирования кадра
    if (MFX_ERR_NONE == sts)
        sts = session.SyncOperation(syncp, 60000);

    if (MFX_ERR_NONE == sts)
    {
        // Кадр готов!
        mfxFrameInfo* pInfo = &pmfxOutSurface->Info;
        mfxFrameData* pData = &pmfxOutSurface->Data;

        // Декодированный кадр имеет формат NV12
        // плоскость Y: pData->Y, полное разрешение
        // плоскость UV: pData-UV, разрешение в 2 раза ниже чем у Y
    }
} // Конец цикла декодирования

После реализации декодирования видео при помощи Media SDK мы столкнулись с аналогичной ситуацией — задержка видео составила 1.5 секунды. Отчаявшись, мы обратились к форумам и нашли советы, которые должны были снизить задержку при декодировании видео.

Декодер h264 из состава Media SDK накапливает кадры прежде чем выдавать декодированное изображение. Было обнаружено, что если в структуре данных, передаваемых в декодер (mfxBitstream), установить флаг “конец потока”, то задержка снижается до ~0.5 секунд:

mfx_bitstream.DataFlag = MFX_BITSTREAM_EOS;

Далее экспериментальным путем было обнаружено, что декодер держит 5 кадров в очереди, даже если установлен флаг окончания потока. В итоге нам пришлось добавить код, который симулировал “окончательное окончание потока” и заставлял декодер выдавать кадры из этой очереди:

if( no_frames_in_queue )
    sts = mfx_dec->DecodeFrameAsync(mfx_bitstream, pmfxSurfaces[nIndex], 
                                    &pmfxOutSurface, &syncp);
else
    sts = mfx_dec->DecodeFrameAsync(0, pmfxSurfaces[nIndex], &pmfxOutSurface, &syncp);


if (sts == MFX_ERR_MORE_DATA)
{
    no_frames_in_queue = true;
}

После этого уровень задержки опустился до приемлемого, т.е. незаметного взглядом.

Выводы

Приступая к задаче трансляции видео в режиме реального времени, мы очень рассчитывали использовать существующие решения и обойтись без своих велосипедов.

Нашей главной надеждой были такие гиганты работы с видео, как FFmpeg и VLC. Несмотря на то, что вроде бы они умеют делать то, что нам надо (передавать видео без перекодирования), нам не удалось убрать получающуюся при передаче видео задержку.

Практически случайно наткнувшись на проект mjpg-streamer, мы были очарованы его простотой и четкой работой в деле трансляции видео в формате MJPG. Если вам вдруг понадобится передавать именно этот формат, то мы категорически рекомендуем его использовать. Неслучайно, что именно на его основе мы и реализовали свое решение.

В результате разработки мы получили достаточно легковесное решение для передачи видео без задержки, не требовательное к ресурсам ни передающей, ни принимающей стороны. В задаче декодирования видео нам сильно помогла библиотека Intel Media SDK, пусть и пришлось применить немного силы, чтобы заставить отдавать ее кадры без буферизации.

Автор: OShapovalov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js