防犯カメラ映像の配信・録画サーバーの構築

以前より使用しているTENVIS JPT3815W(2013版)は、既にサポートが停止して久しく、(元々中国製なのであまり信用はしていなかったが)セキュリティー面での懸念が大きくなっていた。 そこで、Raspberry Pi(以下ラズパイ)を使ってカメラ映像の中継サーバーを作成し、JPT3815Wを直接インターネットへ接続しない仕組みを構築する。また中継する映像を録画する機能も追加する。

※実際に構築した配信サーバーでは、https+クライアント認証を行い接続可能なユーザーを限定しているが、本記事の趣旨とは異なるので言及しない。 またネットワーク環境に依存するFirewallやrouterの設定や構成についても言及しない。セキュリティー対策は実行者の責任で行うこと。

中継サーバーにするラズパイはRaspberry Pi 4 Model B 4GBを使用する。

関係するソフトウェアのバージョンは以下の通り。

まずOS。

pi@raspberrypi:~ $ lsb_release -a
No LSB modules are available.
Distributor ID: Raspbian
Description:    Raspbian GNU/Linux 10 (buster)
Release:        10
Codename:       buster
pi@raspberrypi:~ $ uname -a
Linux raspberrypi 5.4.51-v7l+ #1333 SMP Mon Aug 10 16:51:40 BST 2020 armv7l GNU/Linux

配信用httpdにNginx

pi@raspberrypi:~ $ nginx -v
nginx version: nginx/1.14.2

映像の録画に利用するffmpeg

pi@raspberrypi:~ $ ffmpeg -version
ffmpeg version 4.1.6-1~deb10u1+rpt1 Copyright (c) 2000-2020 the FFmpeg developers
built with gcc 8 (Raspbian 8.3.0-6+rpi1)
configuration: --prefix=/usr --extra-version='1~deb10u1+rpt1' --toolchain=hardened --incdir=/usr/include/arm-linux-gnueabihf --enable-gpl --disable-stripping --enable-avresample --disable-filter=resample --enable-avisynth --enable-gnutls --enable-ladspa --enable-libaom --enable-libass --enable-libbluray --enable-libbs2b --enable-libcaca --enable-libcdio --enable-libcodec2 --enable-libflite --enable-libfontconfig --enable-libfreetype --enable-libfribidi --enable-libgme --enable-libgsm --enable-libjack --enable-libmp3lame --enable-libmysofa --enable-libopenjpeg --enable-libopenmpt --enable-libopus --enable-libpulse --enable-librsvg --enable-librubberband --enable-libshine --enable-libsnappy --enable-libsoxr --enable-libspeex --enable-libssh --enable-libtheora --enable-libtwolame --enable-libvidstab --enable-libvorbis --enable-libvpx --enable-libwavpack --enable-libwebp --enable-libx265 --enable-libxml2 --enable-libxvid --enable-libzmq --enable-libzvbi --enable-lv2 --enable-omx --enable-openal --enable-opengl --enable-sdl2 --enable-omx-rpi --enable-mmal --enable-neon --enable-rpi --enable-libdc1394 --enable-libdrm --enable-libiec61883 --enable-chromaprint --enable-frei0r --enable-libx264 --enable-shared --libdir=/usr/lib/arm-linux-gnueabihf --cpu=arm1176jzf-s --arch=arm
libavutil      56. 22.100 / 56. 22.100
libavcodec     58. 35.100 / 58. 35.100
libavformat    58. 20.100 / 58. 20.100
libavdevice    58.  5.100 / 58.  5.100
libavfilter     7. 40.101 /  7. 40.101
libavresample   4.  0.  0 /  4.  0.  0
libswscale      5.  3.100 /  5.  3.100
libswresample   3.  3.100 /  3.  3.100
libpostproc    55.  3.100 / 55.  3.100

映像履歴の配信用定義ファイルの生成に利用するperl

pi@raspberrypi:~ $ perl -v

This is perl 5, version 28, subversion 1 (v5.28.1) built for arm-linux-gnueabihf-thread-multi-64int
(with 61 registered patches, see perl -V for more detail)

Copyright 1987-2018, Larry Wall

Perl may be copied only under the terms of either the Artistic License or the
GNU General Public License, which may be found in the Perl 5 source kit.

Complete documentation for Perl, including FAQ lists, should be found on
this system using "man perl" or "perldoc perl".  If you have access to the
Internet, point your browser at http://www.perl.org/, the Perl Home Page.

Nginxのインストールと起動確認

配信はwebサーバーを通して行う。ここではNginxを利用する。

pi@raspberrypi:~ $ sudo apt install nginx
pi@raspberrypi:~ $ sudo /etc/init.d/nginx start

同一ネットワーク上のPCなどからブラウザでhttp://raspberrypi.local/へアクセスする。以下のような画面が表示されれば完了。

※通常mDNSによってraspberrypi.localでラズパイにアクセスできるが、複数台のラズパイがネットワークに存在する場合など正しくアクセスできない場合は、直接IPアドレスを指定するなどネットワークの状況に応じて対処する。

Nginx初期画面

配信画面の準備

配信画面となるDOCUMENT_ROOTを/var/www/stream_rootとして、このアーカイブのファイル、ディレクトリを展開する。 ファイルの配置は以下の通り。

/var/www/stream_root/
  +--video/
  |    (empty)
  +--index.html
  +--stream.js
  +--stream.css

次にNginxからDOCUMENT_ROOTとして参照されるように設定ファイルを作成する。デフォルトで設定されているシンボリックリンク/etc/nginx/sites-enable/defaultは削除する。

pi@raspberrypi:~ $ sudo vi /etc/nginx/sites-available/stream.conf
server {
    listen      80;
    access_log  /var/log/nginx/stream.access.log;
    error_log   /var/log/nginx/stream.error.log;

    root        /var/www/stream_root;
    index       index.html;
}
pi@raspberrypi:~ $ sudo ln -s /etc/nginx/sites-available/stream.conf /etc/nginx/sites-enable/stream.conf
pi@raspberrypi:~ $ sudo /etc/init.d/nginx restart

改めて同一ネットワーク上のPCなどからブラウザでhttp://raspberrypi.local/へアクセスする。以下のような画面が表示されれば完了。

配信画面確認

カメラ映像の配信

ffmpegを利用して同一ネットワーク上に接続されているIPカメラの映像をファイルへ出力する。 映像の配信にはHLS(HTTP Live Streamming)形式を利用する。

ffmpegはパラメータが多いのでshell scriptにまとめる。

#!/bin/sh

pid=`ps -ea | grep ff[m]peg | awk '{print $1}'`
if [ -n "${pid}" ]; then
    kill ${pid}
fi

VIDEO_ROOT=/var/www/stream_root/video
cd ${VIDEO_ROOT}
rm -r ${VIDEO_ROOT}/*

HOST=[your IPCamera ip address:port]
USER_ID=[your IPCamera user id]
USER_PWD=[your IPCamera user password]
INPUT_URL="http://${HOST}/videostream.asf?user=${USER_ID}&pwd=${USER_PWD}"
FONT_FILE=/usr/share/fonts/truetype/freefont/FreeSans.ttf

/usr/bin/ffmpeg -hide_banner -nostdin -loglevel fatal \
-i ${INPUT_URL} -vf "drawtext=text='%{localtime\:%F %T}':fontfile=${FONT_FILE}:fontcolor=white@1:fontsize=24:x=12:y=12" -c:v h264_omx \
-f hls -hls_time 5 -hls_list_size 120 -strftime 1 -strftime_mkdir 1 -hls_flags second_level_segment_index -hls_segment_filename "%Y%m%d/%H/%M_%%03d.ts" -movflags faststart \
playlist.m3u8 &

/home/pi/Documentsなど適当なディレクトリに保存し、実行すると録画が開始される。

スクリプトの大まかな流れとしては、

  1. 既に起動しているプロセスがあれば停止させる。
  2. 出力先ディレクトリをカレントとして変更し、既に出力されているものは削除する。
  3. バックグラウンドでffmpegを起動する。

※カレントディレクトリを変更するのは、ffmpegが出力するインデックスファイルに記述されるセグメントファイルのパスの制御方法が判らなかったため。

INPUT_URLで指定するCGI名やパラメータは、IPカメラのファームウェアによって異なる。最近のものは判らないが当時のTENVIS製カメラではだいたいvideostream.asfで取れるようだ。ストリーミング形式はASF(Advanced Systems/Streaming Format)で、映像と音声が含まれる。またuser/pwdはIPカメラへブラウザを通してアクセスする際のBASIC認証に用いるものを指定する。従って、パラメータではなくhttp://${USER_ID}:${USER_PWD}@${HOST}としても良い。

FONT_FILEは動画に録画日時をオーバーレイする際に使用するフォントを指定する。当初、フォントを指定せずにデフォルトまま試したところ、いわゆる"豆腐"表示になってしまったためRaspbianに搭載されていた別のものを明示した。よって環境によって変更する必要があるかもしれない。

ffmpegで指定するパラメータとしては、以下の通り。

  • -hide_banner -nostdin -loglevel fatal: 余計な出力を無くし、標準入力を無効にする。ffmpegは(中断に'q'キーを押すなど)対話側のプログラムなので無効にする必要がある
  • -vf "drawtext~": 録画日時のオーバーレイに関する設定。
    • text='%{localtime\:%F %T}': 表示するテキスト。ここではローカル時間で"年-月-日 時:分:秒"と表示するように指定している
    • fontfile=${FONT_FILE}: 表示に使用するフォントファイルへのパス
    • fontcolor=white@1: テキストの色と不透明度。0で透明、1で不透明
    • fontsize=24: テキストのサイズ
    • x=12:y=12: テキストの左上からの座標
  • -c:v h264_omx: ハードウェアエンコーダを利用する。あまり画質は良くないがCPU負荷が全然違うので必須。
  • -f hls: HLS形式で出力する。関連する後続パラメータは以下の通り
    • -hls_time 5: セグメントあたりの秒数
    • -hls_list_size 120: m3u8ファイルに記述するセグメント数。ここでは最新10分間(hls_time×hls_list_size)の分だけm3u8ファイルに記述するように指定している
    • -strftime 1 -strftime_mkdir 1: セグメントのファイル名やディレクトリに日時を使用できるようにする
    • -hls_flags second_level_segment_index: 上記strftimeを有効にした場合にシーケンス番号を指定できるようセカンダリレベルセグメント%%dを有効にする
    • -hls_segment_filename "%Y%m%d/%H/%M_%%03d.ts": セグメントのファイル名。上記オプションとの組み合わせで日付/時間ごとにディレクトリを分け、セグメントごとに秒_シーケンス番号.tsと名付けている
  • -movflags faststart: メタデータを先頭に付けるための指定だが…TS形式では無意味かもしれない(バイナリは確認してない)

ここまででブラウザを通してカメラ映像を確認することができる。

配信確認

古い映像を参照できるようにする

最後に、最新10分間より古い時間の映像も見られるように、撮り溜めているセグメントファイルから時間帯毎のm3u8ファイルを生成し、ブラウザから参照できるようにする。

ここではperlを使用して、時間帯別のディレクトリ毎にm3u8ファイルとサムネイルを生成し、参照用のjsonファイルにまとめる。

#!/usr/bin/perl

use strict;
use File::Find;
use FindBin;

my $video_root = "/var/www/stream_root/video";
our @lists = ();

find(\&process, "${video_root}");

sub get_playlist {
    my %data = ();
    if(open(PLAYLIST, "< ${_[0]}")) {
        my @existing_playlist = <PLAYLIST>;
        my $length = @existing_playlist;
        for(my $i = 3; $i < $length; $i += 2) {
            my $duration = $1 if $existing_playlist[$i] =~ /#EXTINF:([\d\.]+)/;
            my $path = $existing_playlist[$i + 1];
            $duration += 0;
            next unless $path;
            chomp($path);
            next unless $path =~ /^\d{2}_\d+\.ts$/;
            $data{$path} = $duration;
        }
        close(PLAYLIST);
    }
    return %data;
}

sub process {
    if($File::Find::name =~ /^.+\/(\d{8})\/(\d{2})$/) {
        my $date = $1;
        my $hour = $2;

        my %data = &get_playlist("${hour}/playlist.m3u8");
        print $date . '/' . $hour . ": " . %data . ' add ';

        my $add_item = 0;
        my @files = glob "${hour}/*.ts";
        for (@files) {
            my $target_file = $_;
            my $tsfile = substr($target_file, 3);
            next if exists($data{$tsfile});
            my $duration = `ffprobe -hide_banner -loglevel quiet -show_entries format=duration ${target_file}`;
            if($duration =~ /duration=(\d+\.\d+)/) {
                $data{$tsfile} = $1;
                ++$add_item;
            }
        }
        if($add_item) {
            my $max_duration = 0;
            for my $dur(values %data) {
                next if $dur < $max_duration;
                $max_duration = $dur;
            }
            $max_duration = int($max_duration);
            my $playlist = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:${max_duration}\n";
            for my $key(sort keys %data) {
                $playlist .= "#EXTINF:${data{$key}},\n${key}\n";
            }
            $playlist .= "#EXT-X-ENDLIST\n";
            open(DATAFILE, "> ${hour}/playlist.m3u8") or die qw/Can't open file "playlist.m3u8": $!/;
            print DATAFILE $playlist;
            close(DATAFILE);
        }
        print $add_item . "\n";

        unless(-f "${hour}/thumb.jpg") {
            system "ffmpeg -hide_banner -loglevel quiet -i ${files[0]} -ss 0 -vframes 1 -f image2 -s 160x120 ${hour}/thumb.jpg";
        }

        my %item = (path => "${date}/${hour}/playlist.m3u8", thumb => "${date}/${hour}/thumb.jpg");
        push(@lists, \%item);
    }
}

# build JSON
my $out = "[";
my $now_date = -1;
my $now_hour = -1;
for my $item (@lists) {
    my $path = @{$item}{"path"};
    my $thumb = @{$item}{"thumb"};
    my $date = substr($path, 0, 8) + 0;
    my $hour = substr($path, 9, 2) + 0;
    if($date != $now_date) {
        if($now_date >= 0) {$out .= "]},";}
        $out .= "{\"date\":${date},\"hours\":[";
        $now_date = $date;
        $now_hour = -1;
    }
    if($now_hour >= 0) {$out .= ",";}
    $out .= "{\"hour\":${hour},\"path\":\"${path}\",\"thumb\":\"${thumb}\"}";
    $now_hour = $hour;
}
$out .= "]}]";

# output
open(JSONFILE, "> ${video_root}/record.json") or die qw/Can't open file "record.json": $!/;
print JSONFILE $out;
close(JSONFILE);

あとはこのスクリプトをcronで定期的に実行する。

ブラウザ側のJavaScriptで1分毎に履歴を読み込んでいるため、それに合わせてこのスクリプトも1分毎に実行することを想定している。

最終的な配信画面