Python, Go или… готовим сырой видеопоток с полсотни камер

2d67230c3bf2fa25a1d86cc27c9acebd.jpeg

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

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

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

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

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

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

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

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

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

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

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

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

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

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

7805a429dfa0bbd9b98094b3820b3fd2.jpeg

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-потоком видеоридер отправляет запрос на завершение сессии (или переподключение, в случае возникновения проблем). 

c74239f7c4eb18afef16091994a9a8c7.jpeg

Для нормальной работы в составе высоконагруженной системы машинного зрения, видеоридер должен выполнять все эти операции одновременно с несколькими камерами. Мы ориентировались на то, что каждый экземпляр микросервиса будет параллельно обрабатывать более 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 при большом количестве подключенных камер. 

a30789c25258b8009dd9326e8bfeba40.jpeg

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

  • Для выполнения различных операций, связанных с видеопотоками, включая захват, обработку и анализ видеоданных мы используем 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-ля.

© Habrahabr.ru