Объединение видеофрагментов с нескольких камер и синхронизация их по времени

    В системе дистанционного надзора (СДН), обзор которой был сделан в предыдущей статье, для управления медиапотоками используется медиасервер Kurento, позволяющий записывать потоки, где каждый поток — это отдельный файл. Проблема заключается в том, что при просмотре протокола экзамена нужно воспроизводить три потока одновременно с синхронизацией потоков по времени (веб-камера испытуемого со звуком, веб-камера проктора со звуком и рабочий стол испытуемого), причем на протяжении всего экзамена каждый поток может быть разбит на несколько фрагментов. Эта статья о том, как удалось решить данную проблему, а также организовать сохранение видеозаписей на WebDAV сервер всего одним bash-сценарием.

    Воспроизведение видеоархива СДН

    Медиасервер Kurento сохраняет медиапотоки в оригинальном виде, как они передаются с клиента, фактически осуществляется дамп потока в файл формата webm, используются кодеки vp8 и vorbis (также есть поддержка формата mp4). Это приводит к тому, что сохраненные файлы имеют переменное разрешение видео и переменный битрейт, т.к. WebRTC динамически меняет параметры кодирования видео- и аудиопотков в зависимости от качества каналов связи. В течении каждой сессии прокторинга клиенты могут несколько раз устанавливать связь и прерывать соединение, что приводит к появлению множества файлов для каждой камеры и экрана, а также появляется рассинхронизация во времени, если потом все эти фрагменты склеить вместе.

    Для корректного воспроизведения таких видеозаписей необходимо выполнить следующие шаги:

    • перекодировать все видеопотоки, указав статическое разрешение для каждой камеры (у каждой камеры свое разрешение, у всех фрагментов одной камеры одно разрешение);
    • добавить недостающие фрагменты видео, чтобы компенсировать рассинхронизацию при последующем объединении фрагментов;
    • склеить все фрагменты каждой камеры, чтобы получилось три видеофайла;
    • объединить три видеофайла в один комплексный экран.

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

    Каждая сессия прокторинга в СДН имеет свой уникальный идентификатор, который передается Kurento при установлении соединения между испытуемым и проктором. В рамках этой сессии создаются три потока, которые могут прерываться и возобновляться по техническим причинам или по инициативе проктора. Для именования видеофайлов, которые сохраняются Kurento, был выбран формат “timestamp_camera-session.webm” (маска в виде регулярного выражения ^[0-9]+_[a-z0-9]+-[0-9a-f]{24}.webm$), где timestamp — временная метка создания файла в миллисекундах; camera — идентификатор камеры, чтобы отличать потоки с веб-камеры испытуемого (camera1), веб-камеры проктора (camera2) и поток с картинкой рабочего стола (screen); session — идентификатор сессии прокторинга. После каждой сессии прокторинга сохраняется множество видеофрагментов, возможные варианты фрагментации видеозаписей приведены на рисунке ниже.

    Возможные варианты фрагментации видеозаписей

    Числа 1-12 это некие временные метки; жирная линия — это видеофрагменты различной продолжительности; пунктирная линия — недостающие фрагменты, которые нужно добавить; пустые промежутки — интервалы времени, в которых нет никаких видеофрагментов, должны быть исключены из итоговой видеозаписи.

    Выходной видеофайл представляет собой блок из трех частей, две камеры с разрешением 320x240 (4:3) и один экран с разрешением 768x480 (16:10). Исходное изображение следует масштабировать до заданного размера. Если соотношение сторон не соответствует данному формату, то уместить всё изображение в центре заданного прямоугольника, пустые области закрасить черным цветом. В итоге расположение камер должно выглядеть как на картинке ниже (синий и зеленый — веб-камеры, красный — рабочий стол).

    Расположение камер на комплексном экране

    В итоге каждая сессия прокторинга вместо множества отрывков, имеет только один видеофайл с записью всей сессии. Помимо всего прочего, выходной файл занимает меньше места, т.к. уменьшается частота кадров видео до минимального приемлемого числа 1-5 кадров/с. Получившийся файл загружается на WebDAV-сервер, куда СДН обращается за этим файлом через соответствующий интерфейс с учетом необходимых прав доступа. Протокол WebDAV достаточно распространенный, потому хранилище может быть чем угодно, для этих целей можно даже использовать Яндекс.Диск.

    Реализацию всех этих функций удалось уместить в небольшой bash-сценарий, для которого дополнительно понадобятся утилиты ffmpeg и curl. Для начала нужно перекодировать видеофайлы с динамическим разрешением и битрейтом, задав необходимые параметры для каждой камеры. Функция перекодирования исходного видеофайла с заданным разрешением и числом кадров в секунду выглядит так:

    scale_video_file()
    {
        local in_file="$1"
        local out_file="$2"
        local width="$3"
        local height="$4"
        ffmpeg -i "$in_file" -c:v vp8 -r:v ${FRAME_RATE} -filter:v scale="'if(gte(a,4/3),${width},-1)':'if(gt(a,4/3),-1,${height})'",pad="${width}:${height}:(${width}-iw)/2:(${height}-ih)/2" -c:a libvorbis -q:a 0 "${out_file}"
    }

    Особое внимание стоит уделить scale-фильтру ffmpeg, он позволяет подогнать картинку под заданное разрешение, даже если соотношение сторон различается, заполнив образовавшееся пустое пространство черным цветом. FRAME_RATE — глобальная переменная, в которой задается частота кадров.

    Далее нужна функция, которая создаст файл-заглушку для заполнения пропусков между видеофайлами:

    write_blank_file()
    {
        local out_file="$1"
        [ -e "${out_file}" ] && return;
        local duration=$(echo $2 | LC_NUMERIC="C" awk '{printf("%.3f", $1 / 1000)}')
        local width="$3"
        local height="$4"
        ffmpeg -f lavfi -i "color=c=black:s=${width}x${height}:d=${duration}" -c:v vp8 -r:v ${FRAME_RATE} -f lavfi -i "aevalsrc=0|0:d=${duration}:s=48k" -c:a libvorbis -q:a 0 "${out_file}"
    }

    Здесь создается видеодорожка заданного разрешения, продолжительности (в миллисекундах) и частоты кадров, а также звуковая дорожка с тишиной. Все это кодируется теме же кодеками, что и основные видеофрагменты.

    Получившиеся видеофрагменты каждой камеры нужно объединить, для этого используется следующая функция (OUTPUT_DIR — глобальная переменная, содержащая путь к директории с видеофрагментами):

    concat_video_group()
    {
        local video_group="$1"
        ffmpeg -f concat -i <(ls "${OUTPUT_DIR}" | grep -oe "^[0-9]\+_${video_group}$" | xargs -I FILE echo "file ${OUTPUT_DIR%/}/FILE") -c copy "${OUTPUT_DIR}/${video_group}"
        ls "${OUTPUT_DIR}" | grep -oe "^[0-9]\+_${video_group}$" | xargs -I FILE rm "${OUTPUT_DIR%/}/FILE"
    }

    Также понадобится функция для определения продолжительности видеофайла в миллисекундах, здесь используется утилита ffprobe из пакета ffmpeg:

    get_video_duration()
    {
        local in_file="$1"
        ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${in_file}" | LC_NUMERIC="C" awk '{printf("%.0f", $1 * 1000)}'
    }

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

    1. Получить список файлов с видеофрагментами, отсортированный с учетом их временной метки, которая составляет первую часть имени файла.
    2. Просмотреть список сверху вниз, попутно создавая другой список вида “отметка_времени: флаг: имя_файла”. Суть этого списка — отметить все точки начала и окончания каждого видеофайла (см. картинку с иллюстрацией фрагментации видеозаписей). Для нашего примера это будет следующий список:
      1:1:camera1-session.webm
      3:-1:camera1-session.webm
      7:1:camera1-session.webm
      10:-1:camera1-session.webm
      2:1:camera2-session.webm
      5:-1:camera2-session.webm
      8:1:camera2-session.webm
      10:-1:camera2-session.webm
      3:1:screen-session.webm
      6:-1:screen-session.webm
      8:1:screen-session.webm
      12:-1:screen-session.webm
    3. Полученный список дополнить записями с нулевой продолжительностью (одинаковыми отметками времени) для первого и последнего файла исходного списка видеофрагментов. Это понадобится на этапе расчета недостающих промежуточных видеофрагментов.
    4. Дополнить полученный список записями, которые соответствуют началу и окончанию фрагментов, когда нет видео ни с одной из камер. В нашем примере это будут записи “6:1:...” и “7:-1:...”.
    5. Полученный список разбить на три части, получаем для каждой камеры свой список. Пройтись по каждому списку и инвертировать его, т.е. вместо списка существующих фрагментов должен получиться список недостающих фрагментов.
    6. Преобразовать полученный список к формату “отметка_времени: продолжительность: имя_файла”, чтобы на основе него можно было создать недостающие видеофрагменты.

    Данный алгоритм реализуется следующим набором функций:

    # преобразование меток
    # input: timestamp:flag:filename
    # output: timestamp:duration:filename
    find_spaces()
    {
        local state=0 prev=0
        sort -n | while read item
        do
            arr=(${item//:/ })
            timestamp=${arr[0]}
            flag=${arr[1]}
            let state=state+flag
            if [ ${state} -eq 0 ]
            then
                let prev=timestamp
            elif [ ${prev} -gt 0 ]
            then
                let duration=timestamp-prev
                if [ ${duration} -gt 0 ]
                then
                    echo ${prev}:${duration}:${arr[2]}
                fi
                prev=0
            fi
        done
    }
    # добавление первой и последней метки с нулевой продолжительностью
    zero_marks()
    {
        sort -n | sed '1!{$!d}' | while read item
        do
            arr=(${item//:/ })
            timestamp=${arr[0]}
            for video_group in ${VIDEO_GROUPS}
            do
                echo ${timestamp}:1:${video_group}
                echo ${timestamp}:-1:${video_group}
            done
        done
    }
    # добавить фрагменты, на которых нет видео ни с одной камеры
    blank_marks()
    {
        find_spaces | while read item
        do
            arr=(${item//:/ })
            first_time=${arr[0]}
            duration=${arr[1]}
            let last_time=first_time+duration
            for video_group in ${VIDEO_GROUPS}
            do
                echo ${first_time}:1:${video_group}
                echo ${last_time}:-1:${video_group}
            done
        done
    }
    # генерирование меток в формате: timestamp:duration:filename
    generate_marks()
    {
        ls "${OUTPUT_DIR}" | grep "^[0-9]\+_" | sort -n | while read video_file
        do
            filename=${video_file#*_}
            timestamp=${video_file%%_*}
            duration=$(get_video_duration "${OUTPUT_DIR%/}/${video_file}")
            echo ${timestamp}:1:${filename}
            echo $((timestamp+duration)):-1:${filename}
        done | tee >(zero_marks) >(blank_marks)
    }
    # поиск фрагментов по каждой камере, на которых нет видео
    fragments_by_groups()
    {
        local cmd="tee"
        for video_group in ${VIDEO_GROUPS}
        do
            cmd="${cmd} >(grep :${video_group}$ | find_spaces)"
        done
        eval "${cmd} >/dev/null"
    }
    # запись недостающих видеофрагментов
    write_fragments()
    {
        while read item
        do
            arr=(${item//:/ })
            timestamp=${arr[0]}
            duration=${arr[1]}
            video_file=${arr[2]}
            write_blank_file "${OUTPUT_DIR%/}/${timestamp}_${video_file}" "${duration}" $(get_video_resolution "${video_file}")
        done
    }
    # воссоздать недостающие видеофрагменты
    generate_marks | fragments_by_groups | write_fragments

    После того, как воссозданы недостающие видеофрагменты, можно приступить к их объединению. Для этого понадобится следующая функция, которая объединяет все видеофайлы одной группы (т.е. с одним идентификатором камеры):

    concat_video_group()
    {
        local video_group="$1"
        ffmpeg -f concat -i <(ls "${OUTPUT_DIR}" | grep -oe "^[0-9]\+_${video_group}$" | sort -n | xargs -I FILE echo "file ${OUTPUT_DIR%/}/FILE") -c copy "${OUTPUT_DIR}/${video_group}"
    }

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

    encode_video_complex()
    {
        local video_file="$1"
        local camera1="$2"
        local camera2="$3"
        local camera3="$4"
        ffmpeg \
            -i "${OUTPUT_DIR%/}/${camera1}" \
            -i "${OUTPUT_DIR%/}/${camera2}" \
            -i "${OUTPUT_DIR%/}/${camera3}" \
            -threads ${NCPU} -c:v vp8 -r:v ${FRAME_RATE} -c:a libvorbis -q:a 0 \
            -filter_complex "
                pad=1088:480 [base];
                [0:v] setpts=PTS-STARTPTS, scale=320:240 [camera1];
                [1:v] setpts=PTS-STARTPTS, scale=320:240 [camera2];
                [2:v] setpts=PTS-STARTPTS, scale=768:480 [camera3];
                [base][camera1] overlay=x=0:y=0 [tmp1];
                [tmp1][camera2] overlay=x=0:y=240 [tmp2];
                [tmp2][camera3] overlay=x=320:y=0;
                [0:a][1:a] amix" "${OUTPUT_DIR%/}/${video_file}"
    }

    Здесь с помощью фильтра ffmpeg создается пустая область черного цвета (pad), затем на ней размещаются в заданном порядке камеры. Звук с первых двух камер микшируется.

    После обработки видео и получения выходного файла, закачаем его на сервер (глобальные переменные STORAGE_URL, STORAGE_USER и STORAGE_PASS содержат адрес сервера WebDAV, имя пользователя и пароль к нему соответственно):

    upload()
    {
        local video_file="$1"
        [ -n "${video_file}" ] || return 1
        [ -z "${STORAGE_URL}" ] && return 0
        local http_code=$(curl -o /dev/null -w "%{http_code}" --digest --user ${STORAGE_USER}:${STORAGE_PASS} -T "${OUTPUT_DIR%/}/${video_file}" "${STORAGE_URL%/}/${video_file}")
        # если файл создан, то код ответа 201, если обновлен - 204
        test "${http_code}" = "201" -o "${http_code}" = "204"
    }

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

    #!/bin/bash
    STORAGE_DIR="./storage"
    write_blank_video()
    {
        local width="$1"
        local height="$2"
        local color="$3"
        local duration="$4"
        local frequency="$5"
        local out_file="$6-56a8a7e3f9adc29c4dd74295.webm"
        ffmpeg -y -f lavfi -i "color=c=${color}:s=${width}x${height}:d=${duration}" -f lavfi -i "sine=frequency=${frequency}:duration=${duration}:sample_rate=48000,pan=stereo|c0=c0|c1=c0" -c:a libvorbis -vf "drawtext=fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf: timecode='00\:00\:00\:00': r=30: x=10: y=10: fontsize=24: fontcolor=black: box=1: boxcolor=white@0.7" -c:v vp8 -r:v 30 "${STORAGE_DIR%/}/${out_file}" </dev/null >/dev/null
    }
    # camera1
    write_blank_video 320 200 blue 2 1000 1000_camera1
    write_blank_video 320 200 blue 3 1000 7000_camera1
    # camera2
    write_blank_video 320 240 green 3 2000 2000_camera2
    write_blank_video 320 240 green 2 2000 8000_camera2
    # screen
    write_blank_video 800 480 red 3 3000 3000_screen
    write_blank_video 800 480 red 4 3000 8000_screen


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

    Similar posts

    Comments 7

      0
      спасибо вам, отличная статья, хабр выдает только два материала по запросу kurento и оба ваши :) как сейчас платформа развивается, есть ли претензии к kurento и вообще к выбранной архитектуре?
        +1
        Конкретно этим проектом сейчас занимаюсь уже не я. Однако я успешно использовал Kurento еще в двух других проектах, серьезных нареканий пока нет. Единственное, чтобы корректно воспроизводились видеозаписи, требуется их постобработка. Иначе не работает позиционирование без полной предварительной загрузки видеозаписи в плеер.
          0
          под постобработкой вы имеете ввиду, в частности, то, чем описанный в статье скрипт занимается? или еще что-то? а какие еще проекты на kurento делали, если не секрет? пробовали ли под kurento свои модули писать?
            +1
            Под постобработкой имею ввиду перекодирование видеофайлов через ffmpeg, можно не склеивать их как в статье, а просто перекодировать в тот же формат (vp8 + opus/vorbis) с заданным разрешением и частотой кадров. Модули Kurento писать не пробовал, необходимую обработку обычно выполняю на стороне клиента.
              0
              спасибо, да, я так и понял в общем-то. А какую обработку на стороне клиента вы выполняете? Вроде с видео потоком в браузере не поработаешь особо. Меня интересует тема анонимайзера, что-то типа blur на изображение, distortion на звук.
                +1
                Мои задачи связаны с анализом изображений и звука, изменять сам видеопоток не требуется. В эти задачи входит детекция движения и лиц, распознавание лиц, детекция голоса. Учитывая, что на клиентах у меня JS, то для анализа изображений использую Canvas, а для анализа звука — Web Audio API. Чтобы менять сам видеопоток, видимо, нужно обработку делать на сервере. Думаю, обработку такого плана можно добавить как через модули Kurento, так и через постобработку тем же ffmpeg (там есть функция blur, только нужно указывать координаты области). Видел в примерах Kurento модули, которые используют OpenCV для детекции лиц и наложения эффектов.
                  0
                  понятно, спасибо. С анализом все и правда проще, да

      Only users with full accounts can post comments. Log in, please.