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

в 5:33, , рубрики: bash, curl, ffmpeg, kurento, open source, Алгоритмы, обработка видео, Работа с видео, разработка, Разработка под Linux

В системе дистанционного надзора (СДН), обзор которой был сделан в предыдущей статье, для управления медиапотоками используется медиасервер 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-сервер можно удалять исходные файлы, таким образом осуществляется архивирование видео для последующего просмотра в удобочитаемом виде.

Автор: meefik

Источник

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


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