Пандемия COVID-19 стала катализатором появления новых онлайн-сервисов. Например, Zoom стал настолько успешным, что в этом месяце обогнал IBM по капитализации. Программные инженеры Promwad были вдохновлены этим успехом и решили пойти еще дальше: как насчет реализации видеоконференцсвязи на Smart TV и STB? Тогда у пользователей такого приложения будет возможность не только общаться на работе, но и наслаждаться удаленными встречами с друзьями, болеть за футбольную команду, вместе смотреть фильм или заниматься спортом с тренером.
Почему-то у большинства операторов цифрового ТВ нет такой услуги, хотя с инженерной точки зрения все эти функции могут быть реализованы на приставках на базе Linux/Android и RDK.
Давайте проанализируем архитектуру Zoom-подобного приложения для видеоконференцсвязи для Smart TV и реализуем кодирование видеопотока с помощью мультимедийной платформы с открытым исходным кодом GStreamer. Мы собирали информацию для работы с этим фреймворком по частям, но оно того стоило.
Проблемы и архитектура программного обеспечения
При разработке архитектуры приложений для телевизионных приставок важно учитывать все возможные программные и аппаратные ограничения. Инженеры, создающие программы для настольных ПК, как правило, не сталкиваются с этими проблемами, в то время как для разработчиков встраиваемых систем это обычное дело.
Итак, что нас ждет на приставках:
- Ограниченные ресурсы процессора и самих устройств. В большинстве случаев устройства STB используют различные процессоры ARM, что подразумевает несколько ограничений и дополнительных задач, таких как необходимость использования аппаратного кодирования/декодирования для видеопотока. Итак, производительность – “узкое место”.
- Разные архитектуры в приставках разных производителей. Некоторые из них основаны на Android; другие используют дистрибутивы на основе RDK или Linux с их ограничениями и нюансами. Поэтому на старте процесса разработки лучше выбрать наиболее распространенные и кроссплатформенные решения в разных программных модулях. Не говоря уже о поддержке десктоп-версии. А затем перейдем к частным случаям.
- Сетевые ограничения. Многие приставки работают как через Ethernet, так и через Wi-Fi. Сжатие и передача видео/аудиопотоков – еще одно “узкое место” в приложениях такого типа.
- Безопасность потоковой передачи и другие вопросы безопасности данных.
- Поддержка камер и микрофонов на встраиваемых платформах.
Теперь мы можем кластеризовать саму архитектуру. Наше приложение для видеоконференцсвязи для приставок будет состоять из нескольких крупных компонентов и модулей:
- Захват видеопотока
- Захват аудиопотока
- Сетевой модуль
- Модуль кодирования видео / аудиопотока
- Модуль декодирования видео / аудиопотока
- Отображение видеоконференции на экране
- Вывод звука
- Цветовые преобразования
- Несколько других второстепенных компонентов
В упрощенном виде архитектуру можно изобразить следующим образом:
В этом обзоре мы сосредоточимся на декодировании/кодировании видеопотока и возможной реализации с помощью фреймворка GStreamer, поскольку это один из критических моментов в разработке приложений для видеоконференцсвязи.
Кодирование и декодирование аудио/видеопотока
Преимущества GStreamer для видеоконференцсвязи
Как мы уже отмечали, потоковое видео – одно из “узких мест”. Предположим, у вас есть камера, которая выводит кадры со скоростью 30 кадров в секунду при небольшом разрешении 640 × 480. В сумме получается в RGB24: 640 * 480 * 3 * 30 = 27 648 000 байт в секунду, то есть более 26 мегабайт в секунду, что не работает по очевидным причинам, в частности, по пропускной способности сети.
Одно из решений – реализовать кодирование видео с помощью какой-нибудь библиотеки. Как можно догадаться из названия обзора, наш выбор пал на фреймворк GStreamer. Почему именно эта библиотека? Вот некоторые из его преимуществ перед другими решениями:
- Хорошее кроссплатформенное решение с отличной поддержкой Linux и Android.
- В RDK Gstreamer – это стандарт кодирования/декодирования, включенный в дистрибутив по умолчанию.
- Он поддерживает широкий спектр модулей, фильтров и кодеков. Например, FFmpeg, который можно использовать для тех же целей, является одним из модулей GStreamer.
- Легко построить конвейер. Легко создать цепочку кодирования/декодирования, а конвейерный подход позволяет плавно заменять кодеки, фильтры и т. д. без необходимости переписывать код.
- C/C++ API включен.
- Поддержка аппаратных кодеров/декодеров, в частности OpenMAX API – важная вещь для работы с приставками.
Изучение GStreamer и конвейеров
Прежде чем переходить к обзору кода, давайте посмотрим, что мы можем без этого сделать. GStreamer включает полезные утилиты для работы, в частности:
- gst-inspect-1.0 позволит вам увидеть список доступных кодеков и модулей, чтобы вы могли сразу увидеть, что с ним делать, и выбрать набор фильтров и кодеков.
- gst-launch-1.0 позволяет запускать любой конвейер.
GStreamer использует схему декодирования, при которой поток последовательно проходит через различные компоненты, от источника к выходу приемника. Вы можете выбрать что угодно в качестве источника: файл, устройство, выход (приемник), также может быть файл, экран, сетевые выходы и протоколы (например, RTP).
Классический пример воспроизведения файла mp4:
1 |
gst-launch-1.0 filesrc location=file.mp4 ! qtdemux ! h264parse ! avdec_h264 ! videoconvert ! autovideosink |
Вход принимает файл mp4, который проходит через демультиплексор mp4 – qtdemux, затем через синтаксический анализатор h264, затем через декодер, конвертер и, наконец, вывод.
Вы можете заменить autovideosink на filesink с параметром файла и вывести декодированный поток непосредственно в файл.
Программирование приложения с помощью GStreamer C/C ++ API. Попробуем расшифровать.
Теперь, когда мы знаем, как использовать gst-launch-1.0, мы делаем то же самое в нашем приложении. Принцип остается прежним: мы строим конвейер декодирования, но теперь мы используем библиотеку GStreamer и glib-events.
Мы рассмотрим живой пример декодирования H264.
Инициализация приложения GStreamer происходит один раз с помощью
1 |
gst_init (NULL, NULL); |
Если вы хотите подробно узнать, что происходит, вы можете настроить уровень ведения журнала перед инициализацией.
1 2 3 4 |
gst_debug_set_active(TRUE); gst_debug_set_default_threshold(GST_LEVEL_LOG); |
Примечание: независимо от того, сколько конвейеров у вас в приложении, достаточно один раз инициализировать gst_init.
Давайте создадим новый цикл событий, в котором будут обрабатываться события:
1 2 |
GMainLoop *loop; loop = g_main_loop_new (NULL, FALSE); |
И теперь мы можем приступить к созданию нашего конвейера. Назовем необходимые элементы, в частности, сам конвейер GstElement:
1 2 3 4 5 6 7 8 9 |
GstElement *pipeline, *source, *demuxer, *parser, *decoder, *conv, *sink; pipeline = gst_pipeline_new ("video-decoder"); source = gst_element_factory_make ("filesrc", "file-source"); demuxer = gst_element_factory_make ("qtdemux", "h264-demuxer"); parser = gst_element_factory_make ("h264parse", "h264-parser"); decoder = gst_element_factory_make ("avdec_h264", "h264-decoder"); conv = gst_element_factory_make ("videoconvert", "converter"); sink = gst_element_factory_make ("appsink", "video-output"); |
Каждый элемент конвейера создается через gst_element_factory_make, где первый параметр – это тип, а второй – его условное имя для GStreamer, на которое он позже будет полагаться (например, при выдаче ошибок).
Также было бы неплохо проверить, что все компоненты найдены, иначе gst_element_factory_make вернет NULL.
1 2 3 4 |
if (!pipeline || !source || !demuxer || !parser || !decoder || !conv || !sink) { // one element is not initialized - stop return; } |
Мы устанавливаем тот же параметр местоположения через g_object_set:
1 2 |
g_object_set (G_OBJECT (source), "location", argv[1], NULL); |
Таким же образом можно установить другие параметры в других элементах.
Теперь нам нужен обработчик сообщений GStreamer, давайте создадим соответствующий bus_call:
1 2 3 4 5 6 |
GstBus *bus; guint bus_watch_id; bus = gst_pipeline_get_bus (GST_PIPELINE (pipeline)); bus_watch_id = gst_bus_add_watch (bus, bus_call, loop); gst_object_unref (bus); |
gst_object_unref и другие подобные вызовы необходимы для очистки выбранных объектов.
Затем назовем сам обработчик сообщений:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
static gboolean bus_call (GstBus *bus, GstMessage *msg, gpointer data) { GMainLoop *loop = (GMainLoop *) data; switch (GST_MESSAGE_TYPE (msg)) { case GST_MESSAGE_EOS: LOGI ("End of stream\n"); g_main_loop_quit (loop); break; case GST_MESSAGE_ERROR: { gchar *debug; GError *error; gst_message_parse_error (msg, &error, &debug); g_free (debug); LOGE ("Error: %s\n", error->message); g_error_free (error); g_main_loop_quit (loop); break; } default: break; } return TRUE; } |
А теперь самое главное: мы собираем и складываем все созданные элементы в единый конвейер, построенный через gst-launch. Конечно, важен порядок добавления:
1 2 |
gst_bin_add_many (GST_BIN (pipeline), source, demuxer, parser, decoder, conv, sink, NULL); gst_element_link_many (source, demuxer, parser, decoder, conv, sink, NULL); |
Также стоит отметить, что такое связывание элементов отлично работает для потокового вывода, но в случае воспроизведения (автосвязка) требует дополнительной синхронизации и динамического связывания демультиплексора и парсера:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
gst_element_link (source, demuxer); gst_element_link_many (parser, decoder, conv, sink, NULL); g_signal_connect (demuxer, "pad-added", G_CALLBACK (on_pad_added), parser); static void on_pad_added (GstElement *element, GstPad *pad, gpointer data) { GstPad *sinkpad; GstElement *decoder = (GstElement *) data; /* We can now link this pad with the sink pad */ g_print ("Dynamic pad created, linking demuxer/decoder\n"); sinkpad = gst_element_get_static_pad (decoder, "sink"); gst_pad_link (pad, sinkpad); gst_object_unref (sinkpad); } |
Динамическое соединение позволяет определять тип и количество потоков в отличие от статического и будет работать в некоторых случаях, когда это необходимо.
И, наконец, превратим статус конвейера в воспроизведение:
1 |
gst_element_set_state (pipeline, GST_STATE_PLAYING); |
И давайте запустим цикл обработки событий:
1 |
g_main_loop_run (loop); |
После этой процедуры нужно очистить все:
1 2 3 4 |
gst_element_set_state (pipeline, GST_STATE_NULL); gst_object_unref (GST_OBJECT (pipeline)); g_source_remove (bus_watch_id); g_main_loop_unref (loop); |
Выбор кодеров и декодеров. Запасные варианты.
В документации можно подробнее рассказать о полезных, но редко упоминаемых вещах: как легко организовать резервный декодер или кодировщик.
В этом нам поможет функция gst_element_factory_find, проверив, есть ли у нас кодек в фабрике элементов:
1 2 3 4 5 |
if(gst_element_factory_find("omxh264dec")) decoder = gst_element_factory_make ("omxh264dec", "h264-decoder"); else decoder = gst_element_factory_make ("avdec_h264", "h264-decoder"); |
В этом примере мы отдали приоритет выбору аппаратного декодера OMX на платформе RDK, а в случае его отсутствия выберем программную реализацию.
Еще одна чрезвычайно полезная, но еще более редко используемая функция – это проверить, что мы фактически инициализировали в GstElement (какой из многих кодеков):
1 |
gst_plugin_feature_get_name(gst_element_get_factory(encoder)) |
Вы можете сделать это таким простым способом и вернуть имя инициализированного кодека.
Цветовые модели видео
Нельзя не упомянуть и цветовые модели, поскольку речь идет о кодировании видео с камер. И тогда на сцену выходит YUV (гораздо чаще, чем RGB).
Камерам просто нравится цветовая модель YUYV. Но GStreamer гораздо больше любит работать с обычной моделью I420. Если речь идет не о выводе в gl-кадре, у нас также будут кадры I420. Приготовьтесь настроить нужные фильтры и выполнить преобразования.
Некоторые кодировщики могут работать и с другими цветовыми моделями, но чаще всего это исключения из правил.
Также стоит отметить, что у GStreamer есть собственный модуль для приема видеопотоков с вашей камеры, и его можно использовать для построения конвейера, но мы поговорим об этом в другой раз.
Давайте разберемся с буферами и будем получать данные на лету
Входной буфер
Пришло время разобраться с потоками данных. До сих пор мы просто кодировали через filesrc то, что находится в файле, и отображали все в той же файловой ссылке или на экране.
Теперь мы будем работать с буферами и входами и выходами appsrc/appsink. Почему-то в официальной документации этот вопрос практически не рассматривался.
Так как же организовать постоянный поток данных в созданных конвейерах, а если точнее – в выходной буфер и получить закодированный или декодированный выходной буфер? Допустим, мы получили изображение с камеры и нам нужно его закодировать. Мы уже решили, что нам нужен кадр в формате I420. Допустим, у нас есть, что дальше? Как пропустить картинку через весь поток конвейера?
Во-первых, давайте настроим обработчик события need-data, который будет запускаться, когда необходимо передать данные в конвейер и начать загрузку входного буфера:
1 2 |
g_signal_connect (source, "need-data", G_CALLBACK (encoder_cb_need_data), NULL); |
Сам обработчик имеет следующий вид:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
encoder_cb_need_data (GstElement *appsrc, guint unused_size, gpointer user_data) { GstBuffer *buffer; GstFlowReturn ret; GstMapInfo map; int size; uint8_t* image; // get image buffer = gst_buffer_new_allocate (NULL, size, NULL); gst_buffer_map (buffer, &map, GST_MAP_WRITE); memcpy((guchar *)map.data, image, gst_buffer_get_size( buffer ) ); gst_buffer_unmap(buffer, &map); g_signal_emit_by_name (appsrc, "push-buffer", buffer, &ret); gst_buffer_unref(buffer); } |
Можно сказать, что «изображение» – это псевдокод нашего буфера изображений в I420.
Далее через gst_buffer_new_allocate создаем буфер необходимого размера, который будет соответствовать размеру буфера изображения.
С помощью gst_buffer_map мы устанавливаем буфер в режим записи и используем memcpy для копирования нашего изображения в созданный буфер.
И наконец, мы сигнализируем GStream, что буфер готов.
Примечание: важно использовать gst_buffer_unmap после записи и очищать буфер после использования gst_buffer_unref. Иначе будет утечка памяти. В небольшом количестве доступных примеров никто особо не беспокоился об использовании памяти, хотя это очень важно.
Теперь, когда мы закончили с обработчиком, еще одна вещь, которую нужно сделать, – это настроить ограничения при получении нашего ожидаемого формата.
Это делается перед установкой обработчика сигнала need-data:
1 2 3 4 5 6 7 8 9 10 11 12 |
g_object_set (G_OBJECT (source), "stream-type", 0, "format", GST_FORMAT_TIME, NULL); g_object_set (G_OBJECT (source), "caps", gst_caps_new_simple ("video/x-raw", "format", G_TYPE_STRING, "I420", "width", G_TYPE_INT, 640, "height", G_TYPE_INT, 480, "framerate", GST_TYPE_FRACTION, 30, 1, NULL), NULL); |
Как и все параметры GstElement, параметры устанавливаются через g_object_set.
В данном случае мы определили тип потока – формат данных. Мы указываем, что вывод appsrc будет получать данные I420 с разрешением 640 × 480 и 30 кадрами в секунду.
Частота в нашем случае, да и вообще, никакой роли не играет. Во время работы мы не заметили, что GStreamer каким-то образом ограничивает вызовы необходимых данных по частоте.
Готово, теперь наши кадры загружены в кодировщик.
Выходной буфер
Теперь давайте узнаем, как получить закодированный выходной поток.
Подключаем обработчик к устройству вывода:
1 2 3 |
GstPad *pad = gst_element_get_static_pad (sink, "sink"); gst_pad_add_probe (pad, GST_PAD_PROBE_TYPE_BUFFER, encoder_cb_have_data, NULL, NULL); gst_object_unref (pad); |
Точно так же мы подключились к другому устройству вывода, GST_PAD_PROBE_TYPE_BUFFER, которое будет запускаться, когда буфер данных войдет в панель приемника.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
static GstPadProbeReturn encoder_cb_have_data (GstPad * pad, GstPadProbeInfo * info, gpointer user_data) { GstBuffer *buf = gst_pad_probe_info_get_buffer (info); GstMemory *bufMem = gst_buffer_get_memory(buf, 0); GstMapInfo bufInfo; gst_memory_map(bufMem, &bufInfo, GST_MAP_READ); // bufInfo.data, bufInfo.size gst_memory_unmap(bufMem, &bufInfo); return GST_PAD_PROBE_OK; } |
Обратный вызов имеет аналогичную структуру. Теперь нам нужно добраться до буферной памяти. Сначала мы получаем GstBuffer, затем указатель его памяти с использованием gst_buffer_get_memory по индексу 0 (как правило, он единственный задействован). Наконец, используя gst_memory_map, мы получаем адрес буфера данных bufInfo.data и его размер bufInfo.size.
Фактически, мы достигли цели – получить буфер с закодированными данными и их размером. Итак, мы рассмотрели ключевые и наиболее интересные компоненты нашего Zoom-подобного приложения для видеоконференцсвязи для Smart TV и телевизионных приставок: архитектура, модули кодирования/декодирования с GStreamer, буферы ввода/вывода и используемые преобразования цвета.
Для операторов цифрового телевидения такая программная платформа может стать новой абонентской услугой. А для нас, инженеров, это новый интересный встраиваемый проект для реализации различных приставок на базе RDK, Linux и Android. Что касается всех остальных, это возможность вместе провести время, смотреть фильмы и спортивные матчи, заниматься спортом и встречаться с близкими во время карантина COVID-19 или удаленной работы.
Эта идея с услугой видеоконференцсвязи на Smart TV может получить дальнейшее развитие, как с точки зрения инженерных решений, так и с точки зрения бизнес-сценариев. Так что не стесняйтесь делиться своими мыслями в комментариях ниже.
Выражаем свою благодарность источнику из которого взята и переведена статья, сайту cnx-software.com.
Оригинал статьи вы можете прочитать здесь.
“…хотя с инженерной точки зрения все эти функции могут быть реализованы на приставках на базе Linux/Android и RDK.”
На Андроид приставку можно поставить Скайт и вести видеоконференцию.