Posted at

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


はじめに

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

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

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


ハードウェア


  • Raspberry Pi 3 Model B+


  • カメラモジュール



  • USB audio

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





  • クライアント

    chromeで動作確認




仕様


  1. Imageサーバーは、ソケット接続されたらカメラの画像を送信し続ける

  2. Audioサーバーは、ソケット接続されたらAudio INから取得したデータを送信し続ける

  3. ブラウザで画像データを受信して、canvasに描画する

  4. ブラウザで音声データを受信して、db値をcanvasに描画する

  5. ボタンをクリックすると、WebAudioで音声を再生する

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


クライアント側

ブラウザで表示するための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ライトの点灯制御ができたら更新記事をアップしようと思います。


参考