В проектах, связанных с машинным зрением и обучением приходится работать с сырым видеопотоком с камер. Чтобы принимать, предобрабатывать и передавать эти данные нейросетям необходим отдельный программный компонент, который мы условно называем «видеоридер». Это микросервис, который выполняет функцию декодирования RTSP-потоков с камер, отбирает определенные кадры и отправляет в базу данных для дальнейшего анализа. И все это в режиме реального времени.

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

От стабильности видеоридера зависит эффективность всей системы машинного зрения: если он некорректно декодирует видеопотоки или передает искаженные кадры — это снижает стабильность и качество распознавания объектов. 

Также важно, чтобы видеоридер работал без перебоев и непредсказуемых задержек, которые приводят к скачкам FPS. В свою очередь, для надежного трекинга объектов необходима стабильная подача кадров с правильными метаданными — временными метками (time-stamp), которые проставляются по каждому событию захвата или создания кадра. С помощью меток определяется время, когда произошло какое-то событие или началась запись, а еще они отвечают за согласованность между разными камерами. Непредсказуемое изменение частоты кадров сильно затрудняет работу с движущимися объектами.

Что такое хороший видеоридер:

  • это точно не монолит, далее объясним почему;

  • он должен гарантированно справляться с высокими нагрузками — тоже остановимся на этом подробнее ниже;

  • и соответственно, его нужно писать на «эффективном языке». 

В общем, создавать нужно хороший видеоридер, а плохой не создавать. Теперь, к подробностям.

Как работает видеоридер

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

Возьмем, например, распознавание направления движения грузовика с камеры, установленной на строительной площадке, — как минимум, одновременно работают процессы, отвечающие за декодирование видеопотока, детекцию объекта и трекинг. Если добавить к этому еще какие-то нейронки, уже получится значительная нагрузка на сервер, а ведь речь не об одной камере.

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

Процесс работы с RTSP-потоком

1. Видеоридер инициирует соединение с сервером, предоставляющим RTSP-поток по TCP. Есть и более быстрая альтернатива — UDP (User Datagram Protocol), но он плохо показал себя в тестах. Попробовав использовать UDP, мы столкнулись с проблемой: в некоторых случаях кадры приходили с артефактами, которые делали их непригодными для обработки или анализа. Пришлось использовать протокол TCP, который хоть и менее производителен по сравнению с UDP, но обеспечивает надежную передачу данных и допускает повторную передачу потерянных пакетов данных.

2. После установки соединения отправляется запрос на установку сессии для конкретного RTSP-потока. В этом запросе можно указать параметры потока (например, разрешение видео или битрейт).

3. Успешная установка сессии позволяет видеоридеру начать получать мультимедийные данные с камер, как раз они и составляют RTSP-поток.

4. Пакеты считываются и помещаются в буфер FFmpeg.

5. Далее идет подготовка к декодированию в сырое изображение: происходит «прокрутка буфера», то есть перерасчет пакетов из буфера относительно предыдущих пакетов и отбор пакетов. Этот этап необходим, чтобы отрегулировать частоту кадров. Например, если брать каждый 6 кадр из 60, то на выходе получим 10 FPS.

6. Декодирование избранных RTSP-пакетов в сырое изображение. 

5. Затем кадры кодируются в JPEG и передаются в сжатом виде в базу данных для дальнейшей обработки.

6. По завершении работы с RTSP-потоком видеоридер отправляет запрос на завершение сессии (или переподключение, в случае возникновения проблем). 

Для нормальной работы в составе высоконагруженной системы машинного зрения, видеоридер должен выполнять все эти операции одновременно с несколькими камерами. Мы ориентировались на то, что каждый экземпляр микросервиса будет параллельно обрабатывать более 40 видеопотоков. Это довольно высокая планка, и она вызвала множество проблем при разработке.

Как мы писали свой видеоридер

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

Почему не стоило выбирать Python

Как интерпретируемый язык, Python обычно менее эффективен по сравнению с компилируемыми альтернативами. К тому же в нем присутствует GIL. Глобальная блокировка ограничивает возможности многопоточной работы, что является существенным недостатком для задач, где требуется интенсивная параллельная обработка. Чтобы избавиться от этих недостатков, мы решили переписать видеоридер на Go.

Почему выбрали Golang

У этого языка есть свои плюсы: Go является компилируемым и обеспечивает высокую производительность там, где нужны быстрые вычисления. Goroutines и channels делают управление многозадачностью в Go более эффективным и удобным, чем в Python. К тому же язык достаточно прост и имеет чистую синтаксическую структуру, а это ускоряет разработку.

Но в этой бочке меда присутствует и деготь, ведь мы не учли ряд ограничений языка. Хотя мы использовали библиотеки, оптимизированные для многопоточности, они не позволяли задействовать GPU. Первоначально мы берегли видеокарты для нейронок и рассчитывали, что видеоридер будет работать исключительно на центральном процессоре, но никакие распространенные CPU не вывозили одновременно полсотни камер и другие микросервисы, задействованные в трекинге объектов. Видеоридеру оставалось не так уж много ресурсов: Intel Xeon Gold 5218R в тестовом стенде загружался под 100% уже после подключения восьми камер.

Никакие оптимизации не смогли бы ускорить видеоридер в пять раз. Нужно было разгружать центральный процессор и перекладывать декодирование RTSP на GPU. Тут-то и выяснилось, что мы не можем просто взять и перенести вычисления на видеокарту. У Go нет стандартной библиотеки для работы с GPU, нет нативной интеграции с GPU-драйверами, а попытка использовать с этой версией видеоридера OpenCV вызвала переполнение буфера и отставание от реалтайма.

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

Зачем переписали все на C++

Да, этот язык программирования не позволял переиспользовать уже готовые наработки, и все пришлось переписывать с нуля, но «выстрелило» преимущество выбранной архитектуры. Изменения ограничились внутренностями единственного микросервиса. Переход на C++ помог частично справиться с проблемой падения FPS при большом количестве подключенных камер. 

Как выглядит текущее решение:

  • Для выполнения различных операций, связанных с видеопотоками, включая захват, обработку и анализ видеоданных мы используем OpenCV. Библиотеку удалось оптимизировать для работы с GPU. Она работает совместно с FFmpeg.

  • FFmpeg-бэкенд выполняет роль буфера, куда складываются RTSP-пакеты, которые мы должны отобрать и декодировать.

  • Прокрутка буфера пакетов выполняется функцией OpenCV cv::VideoCapture::grab(), а декодирование функцией cv::VideoCapture::retrieve(cv::Mat)

  • Процесс повторного кодирования отдельных кадров в JPEG происходит с помощью модуля pynvjpeg, который задействует nvJPEG.

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

  • Кадры из видеоридера, кодированные в JPEG, складываем в Redis: отсюда мы забираем их для анализа нейронными сетями или для других задач (трекинг, определение смещения камеры) и здесь же храним до тех пор, пока не закончим работу с ними.

Этот набор технологий позволил снять большую часть нагрузки на CPU и, наконец, увеличить количество обрабатываемых потоков до целевого уровня в 40-50 камер. Единственный значимый минус такого решения в том, что у нас остаются сложности с управляемостью буфера FFmpeg-а. В случае если в буфер набилось слишком много пакетов, мы информируем систему, очищаем буфер и переподключаемся к камерам. 

Это происходит редко, но это узкое место, которое мешает подключать еще больше камер, и мы экспериментируем с ним прямо сейчас. Верхнеуровнево регулировать размер и «прокрутку буфера» не получается из-за ограничений архитектуры и используемых библиотек. Если рассуждать о возможных вариантах решения, можно рассмотреть еще два подхода:

  1. Обеспечить управляемость буфера, проанализировав и частично переписав код библиотеки, отвечающей за кодирование и декодирование видео. В таком случае можно будет выполнять обработку пакетов recv и декодирование кадров в одном потоке и подавать их в собственный поточно-безопасный круговой буфер. Это должно решить проблему переполнения и улучшить управляемость процесса, но у нас не реализован UDP, мы подключаемся по TCP-протоколу.

  2. Альтернативой может стать использование многопоточности, когда каждый кадр обрабатывается в отдельном потоке. Например, можно взять ThreadPool, который обычно применяют в приложениях с параллельной обработкой большого количества задач. Такой подход позволяет ускорить обработку кадров и эффективно управлять большим объемом данных. Однако, в случае неупорядоченных потоков, такого рода параллельная обработка может привести к трудностям с поддержанием правильного порядка кадров, не говоря о том, что этот вариант требует дополнительных усилий для управления синхронизацией.

На этот раз мы примем окончательный выбор только после дополнительных исследований.

Что мы вынесли из проекта

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

  • Детальное планирование и анализ — ключ к успеху, не стоит на них экономить, если проект нужно было сделать еще вчера. Отсутствие четкого плана может привести к неожиданным проблемам и задержкам в разработке.

  • Выбор языка программирования и библиотек играет критическую роль в производительности и успехе любого проекта.

  • Работа с видео и обработка данных в реальном времени требует высокой производительности. Не стоит размениваться по мелочам, сразу выбирайте наиболее оптимизированные библиотеки и закладывайте возможность переноса вычислений на GPU, даже если кажется, что без этого можно обойтись.

Из неочевидных моментов, связанных с переводом видеоридера на C++, приходится учитывать потенциальные риски, связанные с управлением памятью и низкоуровневым кодом. Их можно снизить, применяя специфические практики и инструменты, например, такие как RAII (Resource Acquisition Is Initialization). Этот подход заключается в том, что ресурсы, например, динамическую память, выделяют и освобождают в конструкторе и деструкторе объектов.

В случае видеоридера, мы не выделяли динамическую память, а полагались на интеллектуальные указатели типа std::shared_ptr. Они позволяют управлять временем жизни объектов и ресурсов, что помогает предотвратить утечки памяти и повышает стабильность микросервиса. Плюс, в деструкторе рабочего класса отдельно прописали отключение от камеры, отключение сессии Redis и освобождение GPU.

Для создания рабочих потоков использовали стандартный std::thread, а для их синхронизации с основным потоком std::mutex. Более сложные инструменты не потребовались. При этом при разработке классов приходилось держать в голове корректное использование семантики перемещения, value category и правило 5-ми/7-ми/0-ля.

Комментарии (13)


  1. osmanpasha
    05.10.2023 10:33
    +1

    В сторону gstreamer не смотрели? Он вроде как раз под потоковую работу с видео заточен



    1. DewT-Mag Автор
      05.10.2023 10:33

      В определенный момент косились в его сторону, но время серьезно поджимало и экспериментировать не стали. Сделали выбор в пользу знакомого стека. Решили, что иначе можем нарваться на новые, непонятные подводные камни.


    1. Vitk0
      05.10.2023 10:33

      Есть приблуда от nvidia и intel. DeepStream и DLStreamer соответственно.

      Советую использовать интеловские. в DS много багов лик и оч странных решений в ключевых плагинах.


  1. Ogoun
    05.10.2023 10:33

    Например, можно взять ThreadPool ... Такой подход позволяет ускорить обработку кадров и эффективно управлять большим объемом данных. Однако, в случае неупорядоченных потоков, такого рода параллельная обработка может привести к трудностям с поддержанием правильного порядка кадров, не говоря о том, что этот вариант требует дополнительных усилий для управления синхронизацией.

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


  1. evg_voronov
    05.10.2023 10:33

    Для выполнения различных операций, связанных с видеопотоками, включая захват, обработку и анализ видеоданных мы используем OpenCV. Библиотеку удалось оптимизировать для работы с GPU.

    Можете объяснить как вам удалость запустить функции библиотеки OpenCV на GPU?


    1. DewT-Mag Автор
      05.10.2023 10:33
      +2

      "для работы с GPU", а не на GPU.
      Тут речь о том, чтобы:
      1. собирать ffmpeg оптимизированный под gpu: https://docs.nvidia.com/video-technologies/video-codec-sdk/12.0/ffmpeg-with-nvidia-gpu/index.html
      2. Собрать OpenCV с включенным ffmpeg-ом в конфигурациях сборки. Если в системе несколько версий ffmpeg-а, то указать конкретно нашу в конфигурациях. Если в дальнейшем предполагается работа на питоне, то конфигурировать сборку питон версии.


  1. wl2776
    05.10.2023 10:33

    В статье из Википедии про RTSP, ссылку на которую Вы привели, сказано, что видео по RTSP не передаётся, для этого есть протокол RTP. Мой опыт разработки плеера для систем видеонаблюдения это подтверждает. По RTSP клиент и сервер договариваются о передаче данных и управляют ей командами PLAY, PAUSE и т.п. Сам видеопоток идет как раз по RTP. Что за пакеты Вы складывали в буфер FFmpeg? У FFmpeg есть библиотеки libavformat, libavcodec и т.п. и, соответственно, возможность встраивания в другой софт. Она прекрасно умеет парсить RTSP и даже декодировать видео на GPU через DirectX Video Acceleration (DXVA2). Поглядите исходники VLC, там всё есть. Единственная проблема, с которой мне пришлось столкнуться (лет 15 назад я это писал) - это какие-то глобальные константы внутри libavcodec, из-за чего вызов libavcodec_decode приходилось оборачивать в мьютексы. Возможно, уже все исправили, 15 лет прошло с тех пор.

    Ваше описание архитектуры приложения довольно поверхностное, но кажется, там много неоптимального.


    1. DewT-Mag Автор
      05.10.2023 10:33
      +2

      Это скорее статья про ошибки, которые могут наделать новички, они часто используют OpenCV на питоне. Если глубоко разбираться в видеонаблюдении можно сделать красивее. У нас такой задачи не было, на этом этапе просто нужен был более менее оптимальный ридер, который не убивает CPU, GPU и оперативку.

      В основе ffmpeg-а лежит libavcodec, и с ним можно работать напрямую. Мы об этом думали, но не хватало опыта конкретно в этом аспекте, опять же, все уперлось в сроки.

      Насчет буфера, мы действительно туда ничего не кладем, ffmpeg это делает сам. Кто конкретно создает пакеты (ffmpeg или libavcodec) в буфер с ходу не скажу, так как мы работаем с этими библиотеками не напрямую, а через OpenCV. Но буфер там есть, и его нужно прокручивать, так это реализовано в OpenCV.


  1. AlexeySi
    05.10.2023 10:33

    4218R всего 8 камер...????HEVC?FULL HD 25FPS?Както маловато...

    У меня DL380GEN8, 4x4650v2 120 камер вывозит без GPU с процессингом в OpenCv, на питоне но правда с трюком... Часть камер декодит только I фрэймы через PyAv, но в целом заключение верное Python,GIL, смерть. У меня LXC контейнер поднят под каждый процессинг. Нейронки.. Triton, TRT, ONNX на отдельном сервере. Но 600FPS nvdec с "1650" вы правы грех не использовать).


  1. buldo
    05.10.2023 10:33

    А какой у вас кодек на входе, раз вы позволяете себе играть с кадрами до ffmpeg?

    P. S. Кажется большую часть описанного можно заменить gstreamer пайплайном. Я бы даже сказал, что всё, кроме отправки в redis


  1. rusik2293
    05.10.2023 10:33

    Лет 5 назад видел подобное на c#, было что-то под 200 потоков в 2 мегабита на сервер, все на cpu, сколько ядер не помню


  1. ToSHiC
    05.10.2023 10:33

    Видеоридер - это же один микросервис? Из статьи это не слишком понятно. Если несколько - то зачем, и как между ними передаются сырые кадры?


    В целом, непонятно, зачем в этой задаче OpenCV. Навскидку, у вас 3 как минимум 3 опции было:
    1. Всю вашу логику прореживания кадров сделать в фильтр-чейне ffmpeg, и сделать на питоне запускалку ffmpeg-ов, по одному под кажду камеру, ну и jpeg-и потом закидывать в редис.
    2. Всё то же самое, только вместо ffpmeg взять gstreamer, как предлагали выше.
    3. Более сложный путь, но с максимальным контролем - взять/сделать cgo биндинги для libav и написать всю логику на гошке, а libav использовать для демультиплексирования, декодирования и кодирования.