null

Мы создадим свою систему видеонаблюдения, с архивом и обнаружением движения!

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

В первую очередь, запись видео с камер, желательно, с motion detection. Во-вторых, просмотр камер онлайн без дополнительных утилит, а в идеале и на мобильных устройствах. Отсюда безальтернативное решение - просмотр в браузере с помощью HTML5-плеера.

Что если попробовать реализовать собственную систему, подыскав готовые модули для решения отдельных задач?

Выбор технологий

Итак, нам нужно:

  • Принимать RTSP-поток с камеры
  • Передавать его на систему обнаружения движения и записи архивов
  • Предоставлять доступ к оригинальному потоку для отображения в реальном времени
  • Отдавать видео-архив по HTTP через веб-интерфейс

Наиболее распространённые технологии стриминга с помощью браузера - RTMP и HLS.

  • HLS (HTTP Live Streaming) - это по сути набор файлов-кусочков видео-трансляции, объединённые вместе обычным .m3u8 плейлистом. Отсюда очевидный минус: задержка будет равна как минимум продолжительности каждого эпизода. Зато поддерживается повсюду и нативно вплоть до iOS.
  • RTMP (Real Time Messaging Protocol) увы, поддерживается только в плеерах на основе Flash, но зато задержки минимальны.

Будем использовать оба протокола, тем более, что они реализованы в RTMP-модуле для nginx.

Для обнаружение движения подойдёт утилита motion. Она умеет забирать RTSP-поток с камер, детектировать движение и записывать в файл по шаблону.

Так как некоторые бюджетные китайские IP-камеры не умеют отдавать видеопоток сразу двум клиентам, придётся использовать RTSP-сервер, забирать видео с камер и отправлять на motion и RTMP-сервер через него.
Более-менее вменяемых чистых RTSP-серверов, похоже, не существует в природе - все находятся в составе крупных стриминг-комплексов. Остаётся FFserver, но он не разрабатывается с лета 2016.

Для просмотра в браузере будем использовать Video.js - гибридный HTML5/Flash видеоплеер с поддержкой RTMP.

Пересылать данные между модулями будем с помощью FFmpeg в исходном формате без декодирования, что существенно снизит нагрузку на CPU.

Cхема системы видеонаблюдения:

 __________       __________       __________       __________
|          |     |          |     |          |     |          |
|  camera  |-----| ffserver |-----|  nginx   |-----|   HLS    |
|__________|     |__________|     |__________|\    |__________|
                         \                     \
                          \                     \   __________
                           \                     \ |          |
                            \                     \|   RTMP   |
                             \                     |__________|
                              \
                               \   __________       __________
                                \ |          |     |          |
                                 \|  motion  |-----| archive  |
                                  |__________|     |__________|

Запуск ffmpeg может осуществляться как самим ffserver'ом (только для потоков от камеры к серверу), так и RTMP-модулем для nginx (возможно для всех потоков).
Практика показала, что более стабильное решение - директива exec_static модуля nginx-rtmp-module.

Настройка ffserver

Для нормального функционирования достаточно такого конфигурационного файла:

RTSPPort 554
HTTPPort 8080
HTTPBindAddress 0.0.0.0
RTSPBindAddress 0.0.0.0

MaxClients 2000    # Default is 5
MaxBandwidth 50000 # In kbit/sec. Default is 1000

CustomLog -        # Log to stdout
NoDefaults         # Do not use default codec options for all streams

<Feed cam1.ffm>    # Input feed from cam 1
</Feed>

<Feed cam2.ffm>    # Input feed from cam 2
</Feed>

<Stream cam1>      # Output stream for cam 1
Feed cam1.ffm
Format rtp         # RTSP-stream
NoAudio
VideoSize 1280x720 # Camera resolution
</Stream>

<Stream cam2>      # Output stream for cam 2
Feed cam2.ffm
Format rtp
NoAudio
VideoSize 1920x1080
</Stream>

 

Настройка motion

Берём дефолтный конфиг и подправляем следующие параметры:

# Comment out default capture device
;videodevice /dev/video0

# Event Gap is the seconds of no motion detection that triggers the end of an event.
event_gap 10

# Disable image file output
output_pictures off

# Set video format
ffmpeg_video_codec mp4

# Drop motion-less frames
ffmpeg_duplicate_frames false

# Disable own JPEG-stream server
stream_port 0

# Enable web control on port
webcontrol_port 8080

# Allow access to all
webcontrol_localhost off

# Include cameras configurations
camera_dir /etc/motion/conf.d

Настройки для каждой камеры вынесены в отдельный каталог /etc/motion/conf.d:

camera_id = 1

width 1280
height 720
framerate 5

# Number of frames to capture after motion is no longer detected (default: 0)
post_capture 50

netcam_url rtsp://127.0.0.1/cam248

;text_left CAMERA %t

# Threshold for number of changed pixels in an image that
# triggers motion detection (default: 1500)
threshold 4000

target_dir /var/www/archive/
movie_filename cam%t/%Y-%m-%d_%H-%M-%S

 

Настройка nginx

В соответствующую секцию server добавляем следующий конфиг:

location /hls {
    types {
        application/vnd.apple.mpegurl m3u8;
        video/mp2t ts;
    }
    index index.m3u8;
    add_header Cache-Control no-cache;
    add_header Access-Control-Allow-Origin *;
    autoindex on;
    autoindex_format json;
}

# RTMP server statistics page
location /stats.html {
    rtmp_stat all;
    rtmp_stat_stylesheet stats.xsl;
    # Uncomment to allow connections only from specified IPs
    #deny all;
    #allow 192.168.2.2;
}

location /archive {
    autoindex on;
    autoindex_format json;
}

В основной конфиг (/etc/nginx/nginx.conf) рядом с секцией http добавляем конфиг модуля RTMP:

rtmp {
    server {
        listen 1935;
        chunk_size 256;
        wait_key on;
        sync 10ms;
        interleave on;
        buflen 1s;

        application stream {
            live on;
            hls on;
            hls_fragment_naming system;
            hls_fragment 1s;
            hls_playlist_length 3s;
            hls_path /srv/http/hls;
            hls_nested on;

            allow publish 127.0.0.0/8;
            deny publish all;

            # cam > ffserver
            exec_static ffmpeg -r 5 -fflags nobuffer -probesize 32 -loglevel quiet -re -rtsp_transport tcp -i rtsp://192.168.20.100/channel=1_stream=0 -override_ffserver -c copy http://127.0.0.1:8080/cam1.ffm;
            exec_static ffmpeg -r 5 -fflags nobuffer -probesize 32 -loglevel quiet -re -rtsp_transport tcp -i rtsp://192.168.20.101/channel=1_stream=0 -override_ffserver -c copy http://127.0.0.1:8080/cam2.ffm;
            # increase probesize in case of stream instability

            # ffserver > nginx_rtmp
            exec_static ffmpeg -r 5 -fflags nobuffer -probesize 500000 -loglevel quiet -re -rtsp_transport tcp -i rtsp://127.0.0.1/cam1 -c copy -f flv rtmp://127.0.0.1:1935/stream/cam1;
            exec_static ffmpeg -r 5 -fflags nobuffer -probesize 500000 -loglevel quiet -re -rtsp_transport tcp -i rtsp://127.0.0.1/cam2 -c copy -f flv rtmp://127.0.0.1:1935/stream/cam2;
        }
    }
}

 

Страница просмотра

Теперь займёмся простенькой страничкой для просмотра видео и архива.

HLS трансляция представляет собой набор видео-файлов в отдельных каталогах для каждой камеры. То есть можно с помощью nginx получить содержимое каталогов через autoindex и далее обращаться к когкретным трансляциясм. Для вывода в удобном JSON-формате используем директиву autoindex_format.

Страница онлайн-просмотра

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="robots" content="noindex">
<title>Videoserver - Live</title>
<link href="video-js.min.css" rel="stylesheet"> 
<script src="video.min.js"></script>
<script src="videojs-contrib-hls.min.js"></script>
<style>
.vjs-big-play-button {
    display: none !important;
}
</style>
</head>
<body>

<table style="border-spacing: 0">
    <tr>
        <td>
            <video id="player" width="640" height="360" class="video-js vjs-default-skin" controls></video>
        <td>
            <div id="playlist" style="overflow: auto; height: 360px; width: 250px"></div>
</table>
<a href="archive.html">archive</a><br>
<a href="stats.html">stats</a>

<script>
var base_url_rtmp = "rtmp://192.168.20.1/stream/";
var base_url_hls = "hls/";

var playlist = document.getElementById("playlist");

var player = videojs("player", {
    sourceOrder: true,
    techOrder: ["flash", "html5"],
    flash: {
        swf: "video-js.swf"
    }
});

var getJSON = function(url, callback) {
    var xhr = new XMLHttpRequest();
    xhr.open("GET", url, true);
    xhr.responseType = "json";
    xhr.onload = function() {
        var status = xhr.status;
        if (status == 200) {
            callback(null, xhr.response);
        } else {
            callback(status);
        }
    };
    xhr.send();
};

var getCameras = function() {
    getJSON(base_url_hls, function(err, data) {
        if(err != null) {
            alert("getCameras " + err);
        } else {
            for(i = 0; i < data.length; i++) {
                var link = document.createElement("a");
                link.appendChild(document.createTextNode(data[i].name));
                link.setAttribute("href", '#' + data[i].name);
                link.setAttribute("data-camera", data[i].name);
                link.onclick = function() {
                    playCamera(this.dataset.camera);
                    return false;
                }
                playlist.appendChild(link);
                playlist.appendChild(document.createElement("br"));
            }
            
        }
    });
};

var playCamera = function(camera) {
    player.src([{
        src: base_url_rtmp + camera,
        type: "rtmp/mp4"
    }, {
        src: base_url_hls + camera + "/index.m3u8",
        type: "application/x-mpegURL"
    }]);

    if(player.paused()) {
        player.play();
    }
}

getCameras();
player.ready(function() {
    if(window.location.hash) {
        playCamera(window.location.hash.substring(1));
    }
});
</script>
</body>
</html>

Не забудьте поменять адрес 192.168.20.1 на адрес видео-сервера!

Страница архива

По такой же логике отображаем содержимое архива.

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="robots" content="noindex">
<title>Videoserver - Archive</title>
<link href="video-js.min.css" rel="stylesheet"> 
<script src="video.min.js"></script>
<script src="videojs-contrib-hls.min.js"></script>
<style>
.vjs-big-play-button {
    display: none !important;
}
</style>
</head>
<body>

<table style="border-spacing: 0">
    <tr>
        <td>
            <video id="archive_player" width="640" height="360" class="video-js vjs-default-skin" controls></video>
        <td>
            <div id="archive_playlist" style="overflow: auto; height: 360px; width: 250px;"></div>
</table>
<a href="./">live</a><br>
<a href="stats.html">stats</a>

<script>
var base_url_archive = "archive/";

var getJSON = function(url, callback) {
    var xhr = new XMLHttpRequest();
    xhr.open('GET', url, true);
    xhr.responseType = 'json';
    xhr.onload = function() {
        var status = xhr.status;
        if (status == 200) {
            callback(null, xhr.response);
        } else {
            callback(status);
        }
    };
    xhr.send();
};

var archive_player = videojs('archive_player');
var archive_playlist = document.getElementById("archive_playlist");

var getCameras = function() {
getJSON(base_url_archive + "/", function(err, data) {
    if(err != null) {
        alert('getCameras' + err);
    } else {
        for(i = 0; i < data.length; i++) {
            var link = document.createElement("a");
            link.appendChild(document.createTextNode(data[i].name));
            link.setAttribute("href", "#" + data[i].name);
            link.setAttribute("data-camera", data[i].name);
            /*link.onclick = function() {
                clearPlaylist();
                setCamera(this.dataset.camera);
                return false;
            }*/
            archive_playlist.appendChild(link);
            archive_playlist.appendChild(document.createElement("br"));
        }
        
    }
})};

var playFile = function(camera, file) {
    archive_player.src(base_url_archive + "/" + camera + "/" + file);
    archive_player.play();
}

var clearPlaylist = function() {
    archive_playlist.innerHTML = '';
}

var setCamera = function(camera) {
getJSON(base_url_archive + "/" + camera + "/", function(err, data) {
    if(err != null) {
        alert("setCamera" + err);
    } else {
        var link = document.createElement("a");
        link.appendChild(document.createTextNode(".."));
        link.setAttribute("href", "#");
        link.onclick = function() {
            clearPlaylist();
            getCameras();
            history.pushState("", document.title, window.location.pathname + window.location.search);
            return false;
        }
        archive_playlist.appendChild(link);
        archive_playlist.appendChild(document.createElement("br"));
        for(i = data.length - 1; i >= 0; i--) {
            var link = document.createElement("a");
            link.appendChild(document.createTextNode(data[i].name));
            link.setAttribute("href", base_url_archive + camera + "/" + data[i].name);
            link.setAttribute("data-camera", camera);
            link.setAttribute("data-file", data[i].name);
            link.onclick = function() {
                playFile(this.dataset.camera, this.dataset.file);
                return false;
            }
            archive_playlist.appendChild(link);
            archive_playlist.appendChild(document.createElement("br"));
        }
    }
});
}

window.onhashchange = function() {
    if(!window.location.hash) {
        clearPlaylist();
        getCameras();
    } else {
        clearPlaylist();
        setCamera(window.location.hash.substring(1));
    }
}
if(window.location.hash) {
    setCamera(window.location.hash.substring(1));
} else {
    getCameras();
}
</script>
</body>
</html>
Назад