RaspberryPi
RaspberryPiZeroW

RPi0wにCameraを付けてWebカメラにする

RPi0wにCameraを付けてWebカメラにする

Pre-conditions

  • RPi0wは2018-03-13-raspbian-stretch-lite.imgからセットアップ済み

MEMO: RPi3と同様にセットアップ可能でした。

Camera装着

CameraはRaspberry Pi PiNoir Camera V2を使用します。

NOTICE: RPi0のカメラケーブルはRPi3等よりも小型で、カメラに付属するケーブルは使用できません。Raspberry Pi公式のRPi0用ケースに付属する短めのカメラケーブルを使用するか、別途RPi0用の長めのカメラケーブルが必要になります。

最初にカメラを有効にします。

pi $ sudo raspi-config

からの5 Interfacing Options > P1 Cameraと進んでカメラを有効にします。その後、再起動を要求されます。

次のコマンドでカメラを使用可能か確認できます。

pi $ vcgencmd get_camera
supported=1 detected=1

テスト撮影

静止画撮影用のコマンドがOS標準で用意されているため、とりあえず撮影します。

pi $ raspistill -o /tmp/still.jpg

ちなみに、/tmpはRamDisk化してますが、問題なく3280x2464(5.3MB)のJPEGが作成できました。

タイムアウト引数を省略すると5秒待たされるようです。(-t 5000)

pi $ time raspistill -o /tmp/still.jpg -t 1

real    0m1.020s
user    0m0.072s
sys 0m0.059s

これで待ち時間がなくなります。しかし、コマンド完了まで1秒かかってるのが気になります。。。

ライブビュー

RPi0wでPythonのサーバーを実行します。このサーバーはhttp(8080)に応答し、実行ディレクトリ基準の./web以下を静的ウェブサイトホスティングし、GET /api/camera.liveiew.jpgで640x480の画像を返します。

server-v1.py:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import os
import http.server
import socketserver
import json

import subprocess
import sys

PORT = 8080


class RequestHandler(http.server.SimpleHTTPRequestHandler):
    protocol_version = 'HTTP/1.1'

    def do_HEAD(self):
        self.send_error(405)


    def do_POST(self):
        if self.path == '/api' or self.path.startswith('/api/'):
            self.do_api('POST')
            return
        self.send_error(404)


    def do_GET(self):
        if self.path == '/api' or self.path.startswith('/api/'):
            self.do_api('GET')
            return
        super().do_GET()


    def do_api(self, method):
        if method == 'GET' and self.path == '/api/camera.liveview.jpg':
            subargs = 'raspistill -w 640 -h 480 -q 20 -o - -t 1 -n'
            try:
                res = subprocess.run(subargs, shell=True, stdout=subprocess.PIPE).stdout
                self.send_response(200)
                self.send_header('Content-Type', 'image/jpeg')
                self.send_header('Content-Length', len(res))
                self.end_headers()
                self.wfile.write(res)
            except:
                self.send_error(503)
        else:
            self.send_error(404)


    def send_response(self, code, message=None):
        super().send_response_only(code, message)


    def send_error(self, code, message=None, explain=None):
        try:
            shortmsg, _ = self.responses[code]
        except KeyError:
            shortmsg, longmsg = '???', '???'
        if message is None:
            message = shortmsg
        self.log_error('code {0}, message {1}'.format(code, message))
        response = {
            'code': code,
            'message': message
        }
        response_bytes = json.dumps(response).encode('utf-8')
        self.send_response(code, message)
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Content-Length', len(response_bytes))
        self.end_headers()
        self.wfile.write(response_bytes)


class ThreadingServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
    pass


if __name__ == '__main__':
    os.chdir('./web')
    socketserver.TCPServer.allow_reuse_address = True
    server_address = ("", PORT)

    httpd = ThreadingServer(server_address, RequestHandler)
    print("serving at port", PORT)
    httpd.serve_forever()

./web/index.html:

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"><!-- ✅ あ keeps utf-8 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<style>
html, body {
    width: 100%;
    height: 100%;
    padding: 0;
    margin: 0;
}
img {
    display: block;
}
.flex-box {
    display: flex;
    width: 100%;
    height: 100%;
    justify-content: center;
    align-items: center;
    background-color: black;
}
.flex-item {
    width: 100%;
    height: 100%;
    object-fit: contain;
}
</style>
<script>
class IndexPage {
    constructor() {
        this.img = document.querySelector('.flex-item')
        this.start_liveview()
    }

    start_liveview() {
        var is_updating = false
        let update_liveview = async () => {
            if (is_updating) {
                return
            }
            is_updating = true

            let webapi = new WebAPIRequest()
            try {
                await webapi.request('GET', '/api/camera.liveview.jpg')
                if (webapi.status == 200) {
                    this.img.src = await webapi.response_as('image/jpeg')
                }
                else {
                    console.log(await webapi.response_as('json'))
                }
            }
            catch (e) {
            }

            is_updating = false
        }
        setInterval(update_liveview, 100)
        update_liveview()
    }
}

let indexPage
window.addEventListener('load', () => {
    indexPage = new IndexPage()
})

class WebAPIRequest {
    constructor() {
        this.xhr = new XMLHttpRequest()
        this.xhr.responseType = 'arraybuffer'
    }

    get status() {
        return this.xhr.status
    }

    async request(method, uri, timeout = 3000) {
        return new Promise((resolve, reject) => {
            this.xhr.ontimeout = (e) => { reject(e) }
            this.xhr.onerror = (e) => { reject(e) }
            this.xhr.onload = (e) => {
                if (this.xhr.readyState == 4) {
                    resolve()
                }
            }

            this.xhr.open(method, uri)
            this.xhr.setRequestHeader('Pragma', 'no-cache')
            this.xhr.setRequestHeader('Cache-Control', 'no-cache')
            this.xhr.send()
        })
    }

    async response_as(type) {
        switch (type) {
        case 'image/jpeg':
            return new Promise((resolve) => {
                let reader = new FileReader()
                reader.onloadend = () => {
                    resolve(reader.result)
                }
                reader.readAsDataURL(new Blob([this.xhr.response], {type: 'image/jpeg'}))
            })
        case 'json':
            return JSON.parse(new TextDecoder('utf-8').decode(this.xhr.response))
        default:
            return this.xhr.response
        }
    }
}
</script>
</head>
<body>
<div class="flex-box">
  <img class="flex-item">
</div>
</body>
</html>

これを実行してWebブラウザでhttp://<You RPi IP>:8080/にアクセスするとライブビューが見れます。

pi $ ./server-v1.py
serving at port 8080

しかし、Chromeで確認するとFPSが1程度。。。例えば

  • Request sent: 84 us
  • Waiting (TTFB): 861.90 ms
  • Content Download 29.93 ms

こんな感じです。

pi $ time raspistill -w 640 -h 480 -q 20 -o /tmp/still.jpg -t 1 -n

real    0m0.845s
user    0m0.019s
sys 0m0.034s

やはり、raspistillの実行に時間がかかっているのは明白です。

ライブビュー with mjpg-streamer

mjpg-streamerはカメラの映像をMJPG(Motion JPEG)で簡単に配信できる素晴らしいソフトウェアです。

サクッとmakeして、このソフトからライブビューを取得してみます。

pi $ sudo cat /etc/apt/sources.list
deb http://raspbian.raspberrypi.org/raspbian/ stretch main contrib non-free rpi
# Uncomment line below then 'apt-get update' to enable 'apt-get source'
#deb-src http://raspbian.raspberrypi.org/raspbian/ stretch main contrib non-free rpi

deb http://ftp.jaist.ac.jp/raspbian stretch main contrib non-free
pi $ sudo apt-get install cmake libjpeg8-dev

pi $ cd ~
pi $ curl -L https://github.com/jacksonliam/mjpg-streamer/archive/master.zip > mjpg-streamer-master.zip
pi $ unzip mjpg-streamer-master.zip
pi $ cd mjpg-streamer-master/mjpg-streamer-experimental
pi $ make

簡単に実行するために、スクリプトを作成しました。

mjpg-server.sh:

#!/bin/bash

MJPEG_STREAMER_PATH=/home/pi/mjpg-streamer-master/mjpg-streamer-experimental

export LD_LIBRARY_PATH=$MJPEG_STREAMER_PATH
$MJPEG_STREAMER_PATH/mjpg_streamer -o "output_http.so -p 8081" -i "input_raspicam.so -x 640 -y 480 -quality 20"

また、Pythonのサーバーを一部変更して、GET /api/camera.liveview.jpghttp://localhost:8081/?action=snapshotのリバースプロキシにします。(CORS対応が面倒なので)

server-v2.py:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import os
import http.server
import socketserver
import json

import urllib.request

PORT = 8080


class RequestHandler(http.server.SimpleHTTPRequestHandler):
    protocol_version = 'HTTP/1.1'

    def do_HEAD(self):
        self.send_error(405)


    def do_POST(self):
        if self.path == '/api' or self.path.startswith('/api/'):
            self.do_api('POST')
            return
        self.send_error(404)


    def do_GET(self):
        if self.path == '/api' or self.path.startswith('/api/'):
            self.do_api('GET')
            return
        super().do_GET()


    def do_api(self, method):
        if method == 'GET' and self.path == '/api/camera.liveview.jpg':
            with urllib.request.urlopen('http://localhost:8081/?action=snapshot') as f:
                res = f.read()
                self.send_response(200)
                self.send_header('Content-Type', 'image/jpeg')
                self.send_header('Content-Length', len(res))
                self.end_headers()
                self.wfile.write(res)
        else:
            self.send_error(404)


    def send_response(self, code, message=None):
        super().send_response_only(code, message)


    def send_error(self, code, message=None, explain=None):
        try:
            shortmsg, _ = self.responses[code]
        except KeyError:
            shortmsg, longmsg = '???', '???'
        if message is None:
            message = shortmsg
        self.log_error('code {0}, message {1}'.format(code, message))
        response = {
            'code': code,
            'message': message
        }
        response_bytes = json.dumps(response).encode('utf-8')
        self.send_response(code, message)
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Content-Length', len(response_bytes))
        self.end_headers()
        self.wfile.write(response_bytes)


class ThreadingServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
    pass


if __name__ == '__main__':
    os.chdir('./web')
    socketserver.TCPServer.allow_reuse_address = True
    server_address = ("", PORT)

    httpd = ThreadingServer(server_address, RequestHandler)
    print("serving at port", PORT)
    httpd.serve_forever()

この場合ではライブビューのFPSが5程度になるのが確認できました。

pi $ pwd
/home/pi
pi $ ./mjpg-server.sh &
MJPG Streamer Version.: 2.0
 i: fps.............: 5
 i: resolution........: 640 x 480
 i: camera parameters..............:

Sharpness 0, Contrast 0, Brightness 50
Saturation 0, ISO 0, Video Stabilisation No, Exposure compensation 0
Exposure Mode 'auto', AWB Mode 'auto', Image Effect 'none'
Metering Mode 'average', Colour Effect Enabled No with U = 128, V = 128
Rotation 0, hflip No, vflip No
ROI x 0.000000, y 0.000000, w 1.000000 h 1.000000
 o: www-folder-path......: disabled
 o: HTTP TCP port........: 8081
 o: HTTP Listen Address..: (null)
 o: username:password....: disabled
 o: commands.............: enabled
 i: Starting Camera
Encoder Buffer Size 81920

pi $ ./server-v2.py
serving at port 8080

まとめと今後の課題

RPi0wにCameraを付けて、Webページ上でカメラのライブビューを実現することができましたが、ライブビューの性能があまりよろしくなかったため、撮影機能の実現はペンディングです。

mjpg-streamerを実行中はraspistillを実行することができないため、VGA画質ライブビューからのフルサイズ撮影を行うためには、一度mjpg-streamerを停止する必要があります。この停止には数秒要するため、撮影までのラグが長くなってしまうのがネックです。また、ライブビュー中に露光条件やWB等を変更することも難しそうです。

一方raspistillだけを利用する場合は、VGA画質ライブビューからのフルサイズ撮影に余計な待ち時間は発生しませんが、そもそもraspistillの実行に時間がかかるためライブビューのFPSが出ません。

結論として、別の方法でカメラを制御する必要がありそうですが、Raspberry Pi用のカメラはMMAL (Multi-Media Abstraction Layer)というAPIで制御可能なことがわかりました。mjpg-streamerもraspistillもMMALでカメラを制御してます。

ということで今後の課題はMMALということになりますが、iPhone, AndroidにWebカメラアプリを作る方が簡単そう。。。その前にRPi3の性能とも比較したい。。。