2023/05/05(Fri)低遅延&お手軽なWebcamストリーミングシステムの作り方

2023/05/05 17:47 Software::Linux
最近ネットワークカメラもIoT機器としてメジャーになり安くなったもので、大手通販サイト3~4000円から購入できるけど、一部の商品については某国にデータが流れてるとか、セキュリティ上脆弱で第三者に侵入を許してしまうことがあるとか、スマホから専用アプリが必要だったりと、善し悪し。
動体検知とか、カメラ移動操作とか、双方向音声とかそういうのはナシで、単にカメラ映像を手軽にストリーミングし、低遅延でブラウザから確認できるシステムをDIYしてみたメモ。

要求仕様

  • カメラに映った映像・音声を低遅延(数秒程度)でライブストリーミングする
  • 映像には撮影時刻を埋め込む
  • 付けっぱなしの連続稼働に耐える
  • Firefox, Chrome等のブラウザがあれば再生できる
  • アプリ不要でスマホにも対応する

そろえる物

  • 適当なThinkPad等のラップトップ*1
  • 適当なWebカメラ*2
  • 適当なミニ三脚*3
  • 通信環境*4

*1 : Intel QSVが使えることが望ましい

*2 : UVCに対応したもの。基本的にUSBしか接続ケーブルが無く、ドライバー不要で使えると明記のあるカメラは対応していると思う。デジカメやビデオカメラの中には一部対応しているものもある

*3 : 固定のために、必ず三脚対応穴があるものを選ぶこと!ゴリラポッド等もよいかも

*4 : LANでもWi-FiでもLTE/5Gでも。ただし24時間辺り数ギガ程度のトラフィックが流れる可能性があるので、通信制限に要注意。LAN内で閉じていれば問題無い

筆者の環境

  • PC: Lenovo ThinkPad X250
    • Intel Core i5-5300U (2C4T, 2.3GHz, TDP 15W, Intel QSV対応)
    • DDR3L 16GB
    • Crucial SSD M4-CT128M4SSD2 128GB
  • Webcam: ELECOM UCAM-C820ABBK / FHD 200万画素 マイク有 3,100円くらい
asin:B08LN1LZX1
  • 三脚: HAKUBA ミニ三脚 eポッド3 三段伸縮 1,800円くらい
asin:B077VS54NR
  • 既設Wi-Fi 2.4G/5GHz
Webcam以外は手持ちを流用できたので、かかったのは実質3,000円位。

基本方針

どういう組み立てで作るか色々試してみた結果、
  • OSは再起動不要で長時間安定稼働すること、サービスの常駐化のしやすさを優先しLinux(Fedora)を採用
  • カメラからの映像は、HLSでストリーミングさせる
  • ストリーミングに際して必要な再変換はffmpegを使う
という流れに落ち着いた。

環境整備

まずはOSのインストール。Fedoraの最新版を取ってきて、USB bootしてインストールするだけ。
もうこれだけでGUI desktopが上がる環境がサクッと出来てしまうので凄い。
LANケーブルを繋ぐなり、Wi-Fiにログインするなり、通信環境を整えておく。

独自の設定が必要なのは、クラムシェル運用時やアイドル時にサスペンドしないようにするくらい。

まずは、液晶の蓋を閉じてもサスペンドしないように設定。
$ sudo vim /etc/systemd/logind.conf
HandleLidSwitch=ignore
HandleLidSwitchExternalPower=ignore
$ sudo systemctl restart systemd-login
続いて、AC接続時に時間経過でサスペンドしないように設定。
gdmユーザで実行するのは、ログイン画面でこの設定が有効になるようにするためで、ユーザがログインしてGUIで設定した変更は、そのユーザがログインしているときにしか有効ではないので注意。
$ sudo -u gdm dbus-launch gsettings set org.gnome.settings-daemon.plugins.power sleep-inactive-ac-type 'nothing'
$ sudo systemctl restart gdm
あとは、必要なソフトウェア類。HTTPサーバ、UVCを扱うためのVideo4Linuxツール、ffmpeg、そしてrpmfusion経由でQSV支援のためのパッケージを入れる。
$ wget https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-37.noarch.rpm
$ sudo rpm -i rpmfusion-free-release-37.noarch.rpm
$ sudo dnf install httpd v4l2-utils ffmpeg
$ sudo dnf --enablerepo=rpmfusion-nonfree-steam install intel-media-driver
httpdが/homeに置いたファイルを読めるようにSELinuxを設定しておく。
$ sudo setsebool -P httpd_enable_homedirs 1
カメラをUSBに接続し、利用可能であることを確認。(今回はThinkPad自体にも内蔵カメラがあるので2台分見えている。)
$ v4l2-ctl --list-devices
FHD Camera Microphone: FHD Came (usb-0000:00:14.0-2):
     /dev/video2
     /dev/video3
     /dev/media1

Integrated Camera: Integrated C (usb-0000:00:14.0-8):
     /dev/video0
     /dev/video1
     /dev/media0
実行ユーザをpermitしておき、再ログインで反映。
$ sudo gpasswd -a `whoami` video
$ sudo gpasswd -a `whoami` audio
$ sudo gpasswd -a `whoami` render
$ logout
QSVを利用するためにVA-APIが権限的に使えることを確認しておく。
まず/dev/dri/renderD128が見えることを確認。
$ ls -l /dev/dri
total 0
drwxr-xr-x. 2 root root      80 Feb  6 21:27 by-path/
crw-rw----+ 1 root video  226,   0 Feb  6 21:27 card0
crw-rw-rw-. 1 root render 226, 128 Feb  6 21:27 renderD128
そして、コマンドでも確認。
$ vainfo
Trying display: wayland
Trying display: x11
error: can't connect to X server!
Trying display: drm
libva info: VA-API version 1.16.0
libva info: Trying to open /usr/lib64/dri/iHD_drv_video.so
libva info: Found init function __vaDriverInit_1_16
libva info: va_openDriver() returns 0
vainfo: VA-API version: 1.16 (libva 2.16.0)
vainfo: Driver version: Intel iHD driver for Intel(R) Gen Graphics - 22.5.4 ()
vainfo: Supported profile and entrypoints
      VAProfileNone     : VAEntrypointVideoProc
      VAProfileNone     : VAEntrypointStats
      VAProfileMPEG2Simple     : VAEntrypointVLD
      VAProfileMPEG2Simple     : VAEntrypointEncSlice
      VAProfileMPEG2Main       : VAEntrypointVLD
      VAProfileMPEG2Main       : VAEntrypointEncSlice
      VAProfileH264Main    : VAEntrypointVLD
      VAProfileH264Main    : VAEntrypointEncSlice
      VAProfileH264Main    : VAEntrypointFEI
      VAProfileH264High    : VAEntrypointVLD
      VAProfileH264High    : VAEntrypointEncSlice
      VAProfileH264High    : VAEntrypointFEI
      VAProfileVC1Simple       : VAEntrypointVLD
      VAProfileVC1Main      : VAEntrypointVLD
      VAProfileVC1Advanced     : VAEntrypointVLD
      VAProfileJPEGBaseline    : VAEntrypointVLD
      VAProfileH264ConstrainedBaseline: VAEntrypointVLD
      VAProfileH264ConstrainedBaseline: VAEntrypointEncSlice
      VAProfileH264ConstrainedBaseline: VAEntrypointFEI
      VAProfileVP8Version0_3       : VAEntrypointVLD
ffmpeg側も、VA-API経由のH.264エンコードなどが利用可能であることを確認。
$ ffmpeg -hide_banner -encoders | grep vaapi
 V....D h264_vaapi    H.264/AVC (VAAPI) (codec h264)
 V....D hevc_vaapi    H.265/HEVC (VAAPI) (codec hevc)
 V....D mjpeg_vaapi       MJPEG (VAAPI) (codec mjpeg)
 V....D mpeg2_vaapi       MPEG-2 (VAAPI) (codec mpeg2video)
 V....D vp8_vaapi     VP8 (VAAPI) (codec vp8)
 V....D vp9_vaapi     VP9 (VAAPI) (codec vp9)

ライブストリーミング準備

前述のセットアップで、USBが/dev/video*としてアクセス可能で、かつVA-API有効なffmpegを準備できたので、これをffmpegに入力し、HLSを出力にする。
まず準備として出力先を用意し、httpdのDocumentRootに設定しておく。特にCGI等の仕掛けは不要で、置いたファイルが単純にHTTP経由で開ける状態にしておけばよい。
$ sudo vim /etc/httpd/conf/httpd.conf
- DocumentRoot "/var/www/html"
+ DocumentRoot "/home/kero/www"
+ <Directory "/home/kero/www">
+     AllowOverride None
+     Require all granted
+ </Directory>

$ sudo systemctl restart httpd

次にキャプチャしたいデバイスが、認識順によって/dev/video*が変わっても問題無く参照できるように名前で見つけられるようにしておく。(カメラのデバイス名はお使いの環境に合わせて修正のこと。)
$ cat > getdev.sh
#!/bin/sh

for dev in /sys/class/video4linux/*; do
     devname=`cat $dev/name`
     if [[ "$devname" == "FHD Camera"* ]]; then
      echo "/dev/$(basename $dev)"
      break
     fi
done
^D
$ chmod +x getdev.sh
$ ./getdev.sh
/dev/video2
これを使ってカメラのキャプチャ可能な性能を確認しておく。
$ v4l2-ctl --device `./getdev.sh` --list-formats-ext
ioctl: VIDIOC_ENUM_FMT
     Type: Video Capture

     [0]: 'MJPG' (Motion-JPEG, compressed)
      Size: Discrete 640x480
       Interval: Discrete 0.033s (30.000 fps)
      Size: Discrete 1600x896
       Interval: Discrete 0.033s (30.000 fps)
      Size: Discrete 1280x720
       Interval: Discrete 0.033s (30.000 fps)
      Size: Discrete 1024x576
       Interval: Discrete 0.033s (30.000 fps)
      Size: Discrete 800x600
       Interval: Discrete 0.033s (30.000 fps)
      Size: Discrete 800x480
       Interval: Discrete 0.033s (30.000 fps)
      Size: Discrete 640x360
       Interval: Discrete 0.033s (30.000 fps)
      Size: Discrete 424x240
       Interval: Discrete 0.033s (30.000 fps)
      Size: Discrete 352x288
       Interval: Discrete 0.033s (30.000 fps)
      Size: Discrete 1920x1080
       Interval: Discrete 0.033s (30.000 fps)
     [1]: 'YUYV' (YUYV 4:2:2)
      Size: Discrete 640x480
       Interval: Discrete 0.033s (30.000 fps)
      Size: Discrete 1600x896
       Interval: Discrete 0.133s (7.500 fps)
      Size: Discrete 1280x720
       Interval: Discrete 0.100s (10.000 fps)
      Size: Discrete 1024x576
       Interval: Discrete 0.100s (10.000 fps)
      Size: Discrete 800x600
       Interval: Discrete 0.067s (15.000 fps)
      Size: Discrete 800x480
       Interval: Discrete 0.040s (25.000 fps)
      Size: Discrete 640x360
       Interval: Discrete 0.033s (30.000 fps)
      Size: Discrete 424x240
       Interval: Discrete 0.033s (30.000 fps)
      Size: Discrete 352x288
       Interval: Discrete 0.033s (30.000 fps)
      Size: Discrete 1920x1080
       Interval: Discrete 0.200s (5.000 fps)
これによると、ピクセルフォーマットはMotion JPEGとYUYV(いわゆるRAW)で取り出すことが出来、YUYVのカラーサンプリング方式は4:2:2であることが分かる。
Motion JPEGでは全ての解像度で30fpsを取り出せるものの、YUYVのFHD等ではfpsに制約が出てしまうことに注意。(YUYVは非圧縮なので、性能的・データ量の兼ね合いで致し方ない気も…)
カメラによっては、H264でそのまま出てくるものもあり、ストリーミングへのトラスコード負荷が減るかも…

今回はQSVを利用するので、YUYVで取り出してみる。

オーディオは、ALSA経由でデバイス名を調べておく。
$ arecord -L
null
    Discard all samples (playback) or generate zero samples (capture)
pipewire
    PipeWire Sound Server
default
    Default ALSA Output (currently PipeWire Media Server)
sysdefault:CARD=PCH
    HDA Intel PCH, ALC3232 Analog
    Default Audio Device
front:CARD=PCH,DEV=0
    HDA Intel PCH, ALC3232 Analog
    Front output / input
sysdefault:CARD=Microphone
    FHD Camera Microphone, USB Audio
    Default Audio Device
front:CARD=Microphone,DEV=0
    FHD Camera Microphone, USB Audio
    Front output / input
今回の場合、一番最後の"CARD=Microphone,DEV=0"がカメラ経由のマイクということがわかったのでメモしておく。

で、出来上がったコマンドがこちら。
一番苦労したのは、QSVをVA-API経由で動作させつつ、drawtextを組み込むところ。drawtextを使うと基本的にソフトウェアレンダリングになるらしく、負荷が爆増してしまうので絶対に避けたかった。
$ cat > live.sh
#!/bin/sh
CURDIR=$(cd $(dirname "$0") && pwd)

for dev in /sys/class/video4linux/*; do
    devname=`cat $dev/name`
    if [[ "$devname" == "FHD Camera"* ]]; then
    DEVICE="/dev/$(basename $dev)"
    break
    fi
done
if [ -z "$DEVICE" ]; then
    echo "USB Camera is not connected"
    exit 1
fi

rm -f $CURDIR/www/*.m3u8 $CURDIR/www/*.ts
exec ffmpeg -y -hide_banner \
    -vaapi_device /dev/dri/renderD128 -hwaccel vaapi -hwaccel_output_format nv12 \
    -f v4l2 -thread_queue_size 16384 -pixel_format yuv420p -video_size 800x600 -framerate 15 -i $DEVICE \
    -f alsa -thread_queue_size 16384 -ac 1 -channel_layout mono -ar 44100 -i plughw:CARD=Microphone,DEV=0 \
    -vf "drawtext=fontfile=/usr/share/fonts/google-roboto/Roboto-Bold.ttf: fontsize=20: text='%{localtime\:%Y/%m/%d %X}': fontcolor=white@0.9: x=10: y=10,format=nv12|vaapi,hwupload" \
    -c:v h264_vaapi -pix_fmt yuv420p -fflags nobuffer -tune zerolatency -g 20 -c:a aac -b:a 48k -ac 1 -ar 44100 \
    -f hls \
    -hls_time 2  -hls_list_size 30 -hls_allow_cache 0 \
    -hls_segment_filename $CURDIR/www/stream_%d.ts \
    -hls_base_url ./ \
    -hls_flags delete_segments \
    $CURDIR/www/playlist.m3u8
^D
$ chmod +x live.sh
冒頭は先ほどのgetdev.shと同じく、カメラデバイス名から/dev/video*を得るロジック。未接続の場合は処理を抜ける。
そして、前回のゴミファイルを削除してからffmpegの処理へ。以下パラメータを順に説明すると
-y
上書き確認不要
-hide_banner
冒頭のバージョン情報などの出力を削る
-vaapi_device /dev/dri/renderD128
QSV出力へ送るためのVA-APIデバイスファイル
-hwaccel vaapi
ハードウェアアクセラレーションとしてVA-API使用する。ここにqsvを直接指定しなかったのは、ffmpegをQSV有効で再コンパイルするのが面倒だったので。
-hwaccel_output_format nv12
Hardware/VAAPI - FFmpegによれば、『一般的にハードウェアはnv12を期待する』らしいので、例に倣って指定。勿論cuda等が使えればそちらでもよい
-f v4l2
映像ソースとしてVideo for Linuxから入力
-thread_queue_size 16384
データのバッファサイズらしいのだが、巷でこの値がよく使われていたので真似
pixel_format yuv420p
nv12に入力するためにはyuv420pでなくてならないのと、HLSもYUV420pを期待するようなので明示指定する。(yuyv422のままts化してしまうと、playlistを受信しているのに映像が再生されなかった)
-video_size 800x600
ビデオ入力サイズ。お好みで
-framerate 15
ビデオFPS。お好みで
-i $DEVICE
ビデオデバイス名
-f alsa
音声ソースとしてALSAから入力
-thread_queue_size 16384
こちらも同様に指定
-ac 1
オーディオチャネルはモノラルで十分なので1chに
-channel_layout mono
モノラルソースをFront Centerに配置し、再生側ではLR両方から鳴るように
-ar 44100
サンプリングレート 44.1kHz
-i plughw
CARD=Microphone,DEV=0:前述のarecord -Lで調べたオーディオデバイス名
-vf "drawtext=..."
映像中にタイムスタンプを埋め込む
fontfile=/path/to/ttf
フォントファイル
fontsize=20
フォントサイズ
text=
出力内容。%localtime\:%Y/%m/%d %Xと指定すると、2023/01/01 00:00:00のような出力になる
fontcolor=white@0.9
フォント色と透過率
x=10, y=10
文字の描画位置(左上が原点)
format=nv12|vaapi,hwupload
デコーダがVA-APIを使える・使えないに関わらずうまいこと処理させるための条件分岐らしい。詳細はVAAPIに書いてある
-c:v h264_vaapi
コーデック指定でビデオはH.264をVA-APIで処理させる
-pix_fmt yuv420p
ピクセルフォーマットはHLSのためにyuv420p固定
-fflags nobuffer
読み込みバッファを無くし即時処理を行う(低遅延のため)
-tune zerolatency
低遅延ストリーミング向けの設定を指示
-g 20
GOPサイズ。デフォルト12で、小さいほどシークがスムーズになるが、ストリーミングなのであまり関係無いかもしれない
-c:a aac
オーディオのコーデックはaacとする
-b:a 48k
オーディオのビットレート
-ac 1
オーディオのチャンネル
-ar 44100
オーディオのサンプリングレート
-f hls
出力先をHLSにする
-hls_time 2
HLSのチャンク(分割)サイズを2秒ごとにする。あまり大きすぎると再生開始までに時間が掛かりすぎる。小さすぎるとダウンロード回数が多くなるためオーバヘッドが大きい
-hls_list_size 30
保持するHLSのチャンク数。つまり2秒x30個で1分間分のtsが保管させることになる。0を指定すると全て
-hls_allow_cache 0
キャッシュを許可しない
-hls_segment_filename $CURDIR/www/stream_%d.ts
HLSストリームの保存場所
-hls_base_url ./
playlistから見たストリーム保管場所への相対パス
-hls_flags delete_segments
playlistから消えたチャンクを削除する(ディスク溢れ対策)
$CURDIR/www/playlist.m3u8
playlistの出力先

これをコンソールで動かし、playlist.m3u8とstream_nnn.tsが順番に出来ていたら動作OK。
次にサービス化しておく。

ライブストリーミングサービス化

サービス化するにあたり、以下の要件を満たせるようにしておく
  • PCの電源が入ったら自動的にストリーミングを開始する
  • カメラが途中で抜けるか、起動が終わってからカメラが接続されるかもしれないので、/dev/video*を取得できなかったり、ffmpegが異常終了しても繰り返し処理を試みる
$ sudo vim /etc/systemd/system/live.service
[Unit]
Description=Live Camera
Requires=network-online.target
StartLimitIntervalSec=30
StartLimitBurst=10

[Service]
Type=simple
ExecStart=/bin/sh /home/kero/live.sh
ExecStop=/bin/kill -TERM ${MAINPID}
Restart=always
RestartSec=5s
User=kero
Group=kero

[Install]
WantedBy=multi-user.target

$ sudo systemctl daemon-reload
$ sudo systemctl enable live
$ sudo systemctl start live
こうしておくと、システム起動時に30秒ごとに10回試行してくれる上、途中でffmpegが落ちても5秒後にrestartがかかるようになっている。

配信ページ

最後に、ストリーミングを再生するページを作る。
HLSの再生にはhls.jsを利用させていただいたので、基本的にはscriptタグで一発読み込み、playlistを指定して再生させるだけで良い。
他に色々と書いたものの、結局Chromeとかだとautoplayと書いても自動再生されない(ミュートで自動再生か、クリックして音ありで再生開始かの二択)ので、あんまり意味無し。
application/vnd.apple.mpegurlの判定は、iOSデバイスへの対応。こちらは単にvideo.srcにセットするだけで再生できるし、MediaSource Extensionsに未対応なので、Hls.isSupported()はfalseになるようなので…
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <meta http-equiv="X-UA-Compatible" content="ie=edge">
        <title>Live stream</title>
    </head>
    <body>
        <script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
        <video id="container" autoplay playsinline controls></video>
        <script>
            var video = document.getElementById('container');
            var videoSrc = './playlist.m3u8';
            if(Hls.isSupported()) {
                var hls = new Hls();
                hls.attachMedia(video);
                hls.on(Hls.Events.MEDIA_ATTACHED, function () {
                    hls.loadSource(videoSrc);
                    hls.on(Hls.Events.MANIFEST_PARSED, function (event, data) {
                        video.play();
                    });
                });
            }else if(video.canPlayType('application/vnd.apple.mpegurl')){
                video.src = videoSrc;
                video.play();
            }
        </script>
        <style>
        * {
            padding: 0;
            margin: 0;
            box-sizing: border-box;
        }
        body {
            overflow: hidden;
        }
        #container {
            position: absolute;
            width: 100%;
            height: 100%;
            background-color: #333;
        }
        </style>
    </body>
</html>

安定性など

こんな感じで3ヶ月弱問題無く動作中。CPU負荷も3割程度と、常にブン回し状態になっていないので心持ち安心。(iGPUに負荷はかかっているだろうけど…)
再生側は単にstaticファイルを読み出すだけなので、httpd側の負荷はあまりない。ディスクは消耗するだろうけど、殆ど用済みのSSDを再利用しているだけなので、チップの劣化はあまり気にしていないけど、メモリが潤沢であればメモリデバイスへtsを書き出す方が若干安心かもしれない。
遅延については、配信・再生側ともにWi-Fiの環境で10秒弱と、ほぼリアルタイムに近い感じに仕上がったので満足!
top - 18:51:17 up 85 days, 21:24,  1 user,  load average: 0.50, 0.41, 0.61
Tasks: 192 total,   3 running, 189 sleeping,   0 stopped,   0 zombie
%Cpu0  : 11.6 us,  1.0 sy,  0.0 ni, 86.9 id,  0.0 wa,  0.5 hi,  0.0 si,  0.0 st
%Cpu1  :  4.9 us,  0.0 sy,  0.0 ni, 94.6 id,  0.0 wa,  0.5 hi,  0.0 si,  0.0 st
%Cpu2  :  3.5 us,  1.0 sy,  0.0 ni, 88.9 id,  0.0 wa,  3.5 hi,  3.0 si,  0.0 st
%Cpu3  :  3.5 us,  1.0 sy,  0.0 ni, 95.0 id,  0.0 wa,  0.5 hi,  0.0 si,  0.0 st
KiB Mem : 16071528 total,  5162936 free,  1006648 used,  9901944 buff/cache
KiB Swap:  8388604 total,  8388604 free,        0 used. 14619244 avail Mem

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
 113469 kero      20   0  583800  97860  80596 R  24.9   0.6   8989:31 ffmpeg
 247703 root       0 -20       0      0      0 R   1.0   0.0   0:03.57 kworker/u17:1+uvcvideo
    643 systemd+  20   0   16296   6416   5548 S   0.5   0.0 211:49.08 systemd-oomd
   8028 kero      20   0   16244   6908   5116 S   0.5   0.0   0:14.32 sshd
 236688 apache    20   0 2420184   8524   5328 S   0.5   0.1   1:03.15 httpd
 247742 kero      20   0  224880   3948   3124 R   0.5   0.0   0:00.35 top
      1 root      20   0  248252  19984  10772 S   0.0   0.1   3:57.39 systemd

参考文献

結局Linux向けにQSV組み込み済ffmpegをビルドするのが面倒で使わず…以下は本当に参考にしただけ。