Help us understand the problem. What is going on with this article?

RaspberryPiで監視カメラ(カメラモジュール+USB Audioのデータをブラウザで表示+再生)

はじめに

前回、WebSocketで受信した画像をブラウザで表示する仕組みを調べたので、RaspberryPiのカメラモジュールと繋げてみました。

あと、USB Audioデバイスを買ったのでWebSocketでデータを流して、ブラウザで再生する処理も入れてみました。

簡易的な監視カメラができたので、記事としてまとめておきます。

ハードウェア

  • Raspberry Pi 3 Model B+

  • カメラモジュール

  • USB audio

    秋月で400円くらい。lsusbで認識された情報だとIntel製。ボリュームが小さくでちょっと不満。もっと高いのにすればよかった。

raspai.jpg

  • クライアント

    chromeで動作確認

仕様

  1. Imageサーバーは、ソケット接続されたらカメラの画像を送信し続ける
  2. Audioサーバーは、ソケット接続されたらAudio INから取得したデータを送信し続ける
  3. ブラウザで画像データを受信して、canvasに描画する
  4. ブラウザで音声データを受信して、db値をcanvasに描画する
  5. ボタンをクリックすると、WebAudioで音声を再生する

anime_monitor.gif

音声再生中はタブにスピーカーのアイコンが表示されます。

クライアント側

ブラウザで表示するためのhtmlとjsを用意します。

HTML

音声データ表示用のcanvasと再生ボタン、画像用のcanvasを用意します。

WebAudioはユーザー操作のイベントで開始する必要があるので、ボタンを用意する必要があります。

index.html
<html>
  <head>
    <script src="./client.js"></script>
  </head>
  <body onload="on_load();">
    <div>
      <canvas id="canvas_audio" width="500" height="40"></canvas>
    </div>
    <input type="button" value="play audio" onclick="on_button_play_audio();">
    <div>
      <canvas id="canvas_image" width="640" height="480"></canvas>
    </div>
  </body>
</html>

javascript

画像データ受信ハンドラでcanvasに描画、音声データ受信ハンドラでcanvasにグラフを表示する&音声再生します。

  • 画像データ

前回のサンプルのままだと大きい画像のbase64文字列の生成でエラーになりました。調査時はシンプルなPNGにしたので圧縮率が高かったけど、写真のjpegになったらサイズが小さくならなかったと推測。かっこ悪いですがループで文字列を結合してbase64変換しています。

  • 音声データ

WebAudioを使用して音声を再生します。受信データがfloat32なのでそのまま流せば再生できます。

ユーザー操作しないと音声再生できないので、クリックのイベントを用意します。

データが受信できているかを確認できるようにdb値をcanvasに表示します。db値の計算はネットで調べてて適当に実装したから合ってるか自信ないです。

client.js
var image_socket = null;

var audio_socket = null;
var audio_ctx = null;
var scheduled_time = 0;
var delay_sec = 1;

function on_load(){
  // ここではaudioの再生に関する処理は許可されないのでWebSock処理のみ行う

  // 画像通信用WebSocket接続
  img_url = "ws://" + location.hostname + ":60002"
  image_socket = new WebSocket(img_url);
  image_socket.binaryType = 'arraybuffer';
  image_socket.onmessage = on_image_message;

  // 音声通信用WebSocket接続
  audio_url = "ws://" + location.hostname + ":60003"
  audio_socket = new WebSocket(audio_url);
  audio_socket.binaryType = 'arraybuffer';
  audio_socket.onmessage = on_audio_message;

}

function on_button_play_audio(){
  // ユーザー操作イベントでAudioの再生操作を行う

  if(audio_ctx == null){
    audio_ctx = new (window.AudioContext||window.webkitAudioContext)
  }
}

function on_image_message(recv_data){
  // 受信したデータをbase64文字列に変換
  var recv_image_data = new Uint8Array(recv_data.data);
  var base64_data = ""
  for (var i=0; i < recv_image_data.length; i++) {
      base64_data += String.fromCharCode(recv_image_data[i]);
  }

  // 画像をcanvasに描画
  var canvas_image = document.getElementById('canvas_image');
  var ctx = canvas_image.getContext('2d');
  var image = new Image();
  image.onload = function() {
    ctx.drawImage(image, 0, 0);
  }
  image.src = 'data:image/jpeg;base64,' + window.btoa(base64_data);
}

function on_audio_message(recv_data){
  audio_f32 = new Float32Array(recv_data.data);
  if(audio_ctx != null){
    play_audio(audio_f32);
  }
  draw_audio_graph(audio_f32);
}

function play_audio(data){
  var audio_buffer = audio_ctx.createBuffer(1, data.length, 44100);
  var buffer_source = audio_ctx.createBufferSource();
  var current_time = audio_ctx.currentTime;

  audio_buffer.getChannelData(0).set(data);
  buffer_source.buffer = audio_buffer;
  buffer_source.connect(audio_ctx.destination);

  if (current_time < scheduled_time) {
    buffer_source.start(scheduled_time);
    scheduled_time += audio_buffer.duration;
  } else {
    buffer_source.start(current_time);
    scheduled_time = current_time + audio_buffer.duration + delay_sec;
  }
}

function draw_audio_graph(data){
  var canvas_image = document.getElementById('canvas_audio');
  var ctx_2d = canvas_image.getContext('2d');

  // db値計算(自信ない)
  sum_data = data.reduce((a,b)=>Math.abs(a)+Math.abs(b));
  mean_data = sum_data / data.length;
  dB = 20 * Math.log10(mean_data);

  // 前回描画をクリア
  ctx_2d.fillStyle = '#ffffff';
  ctx_2d.fillRect(0,0,500,40);

  var rate = 2;
  var graph_x = Math.abs(dB) * rate;
  ctx_2d.fillStyle = '#000000';
  ctx_2d.fillRect(0,0,graph_x,40);

  // 値表示
  ctx_2d.fillStyle = '#ff0000';
  ctx_2d.font = "10px serif";
  ctx_2d.fillText("db:" + dB , 5 , 10 );
}

サーバー側

画像用と音声用で2つのWebSoket通信を行うので、2つWebSoketのサーバーを用意します。

httpのサーバーも必要になるので、まとめて起動する処理も用意します。

Imageサーバー

PiCameraで取得した画像をブラウザに送信します。

imageServer.py
import asyncio
import websockets
import io
from picamera import PiCamera

class ImageServer:

    def __init__(self, loop, address , port):
        self.loop = loop
        self.address = address
        self.port = port
        self.camera = PiCamera()

    async def _handler(self, websocket, path):
        with io.BytesIO() as stream:
            for _ in self.camera.capture_continuous(stream, format='jpeg', use_video_port=True, resize=(640,480)):
                stream.seek(0)

                try:
                    await websocket.send(stream.read())
                except:
                    print('image send Error.')
                    break

                stream.seek(0)
                stream.truncate()

    def run(self):
        self._server = websockets.serve(self._handler, self.address, self.port)
        self.loop.run_until_complete(self._server)
        self.loop.run_forever()

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    ws_is = ImageServer(loop, '0.0.0.0', 60002)
    ws_is.run()


Audioサーバー

pyaudioで取得した音声データをブラウザに送信します。クライアントであるWebAudioにfloat32で渡すのでpaFloat32を指定します。

同一スレッドで読み込みと送信を行ったらバッファ不足のエラーが出たので読み込みと送信は別スレッドで行うようにしています。

複数接続は考慮していない実装です。

AudioServer()クラスに、デバイス名を指定するので環境に合わせた文字列を設定してください。

AudioServer.py
import asyncio
import websockets
import pyaudio
import threading
import queue

class AudioServer:

    def __init__(self, loop, address, port, device_name):
        self.loop = loop
        self.address = address
        self.port = port

        self.chunk = 1024 * 4
        self.device_index = self._find_device_index(device_name)

        self.rec_loop = False
        self.q = queue.Queue()

    def _rec_thread(self):

        audio = pyaudio.PyAudio()
        stream = audio.open(
                format = pyaudio.paFloat32,
                channels = 1,
                rate = 44100,
                input = True,
                frames_per_buffer = self.chunk,
                input_device_index=self.device_index)

        while self.rec_loop:
            audio_data = stream.read(self.chunk)
            self.q.put(audio_data)

        stream.close()
        audio.terminate()


    async def _handler(self, websocket, path):
        if self.rec_loop:
            print("_rec_thread is exists.")
            return

        # Audio INから読み出すスレッドを起動
        self.rec_loop = True
        send_thread = threading.Thread(target=self._rec_thread)
        send_thread.start()

        # スレッドで読み込んだデータを送信する
        send_loop = True
        while send_loop:
            try:
                send_data = self.q.get(timeout=1)
                await websocket.send(send_data)
            except:
                print('audio send Error.')
                send_loop = False
                break

        self.rec_loop = False
        send_thread.join()

    def run(self):
        self._server = websockets.serve(self._handler, self.address, self.port)
        self.loop.run_until_complete(self._server)
        self.loop.run_forever()

    @staticmethod
    def get_devices():
        '''使用可能なデバイスを列挙'''
        device_name_list = []
        audio = pyaudio.PyAudio()
        for i in range(audio.get_device_count()):
            device = audio.get_device_info_by_index(i)
            device_name_list.append(device['name'])

        return device_name_list


    @staticmethod
    def _find_device_index(find_name):
        '''引数のデバイス名に対応するindexを返す'''
        device_index = -1
        audio = pyaudio.PyAudio()
        for i in range(audio.get_device_count()):
            device = audio.get_device_info_by_index(i)
            if device['name'] == find_name:
                device_index = i
                break

        return device_index

if __name__ == '__main__':
    device_name = None
    devices = AudioServer.get_devices()

    print("devices len = {}".format(len(devices)))
    if len(devices) > 0:
        device_name = devices[0]
        print("device_name = {}".format(device_name))

    loop = asyncio.get_event_loop()
    ws_as = AudioServer(loop, '0.0.0.0', 60003, device_name)
    ws_as.run()

httpサーバー

まとめて起動する仕組みも用意しましたが終了時のことは考慮していないので、ちゃんと実装する人は他の方法がいいと思います。

以下のスクリプトを実行して http://localhost:60001/ にアクセスすれば動作確認できます。

monitor_run.py
import threading
import http.server
import socketserver
import asyncio
from ImageServer import ImageServer
from AudioServer import AudioServer


def _http_thread():
    _handler = http.server.SimpleHTTPRequestHandler
    httpd = socketserver.TCPServer(("", 60001), _handler)
    httpd.serve_forever()


def _image_thread():
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)

    ws_is = ImageServer(loop, '0.0.0.0', 60002)
    ws_is.run()


def _audio_thread():
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)

    device_name = None
    devices = AudioServer.get_devices()

    print("devices len = {}".format(len(devices)))
    if len(devices) > 0:
        device_name = devices[0]
        print("device_name = {}".format(device_name))

    loop = asyncio.get_event_loop()
    ws_as = AudioServer(loop, '0.0.0.0', 60003, device_name)
    ws_as.run()


if __name__ == '__main__':
    imageThread = threading.Thread(target=_image_thread)
    imageThread.start()

    audioThread = threading.Thread(target=_audio_thread)
    audioThread.start()

    httpThread = threading.Thread(target=_http_thread)
    httpThread.start()


    imageThread.join()
    audioThread.join()
    httpThread.join()

おわりに

WebAudio+pyaudioの組み合わせをやってる人がいなくて試行錯誤でした。

dB値の計算は昔やったことあるけど細かいこと忘れた。正しいdB値が欲しいわけじゃないから適当で。

激しく寝返りするようになった娘のベビーモニターとして利用するつもりですが、暗い部屋だと映らないので赤外線カメラに変更するか、USBライトの点灯制御するか悩むところ。
100均のUSBライトの点灯制御ができたら更新記事をアップしようと思います。

2019/10/02更新
暗い部屋の撮影編をアップしました。

offshot.jpg

参考

magiclib
夢見がちサラリーペンギン
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした