作業中のメモ

よく「計算機」を使って作業をする.知らなかったことを中心にまとめるつもり.

PiCameraを使った撮影・画像データの送受信

どうも,筆者です.

今回は,PiCameraを用いて,Raspberry Piで撮影用サーバを立てる.また,撮影したデータをslackにアップロードすることも考える.

利用したカメラは以下のものになる.これは,2017年頃に購入してほとんど利用していなかった.

https://www.amazon.co.jp/gp/product/B06XNW8TMF/ref=ppx_yo_dt_b_search_asin_title?ie=UTF8&psc=1

事前準備

今回も,dockerを用いて,環境を構築する.

まずは,Raspberry Piでカメラを利用できるように,取り付けと設定を行う.ここでは,下記のサイトを参考にした.

https://raspi-wannabe.com/digital-camera/raspi-wannabe.com

また,Slackにデータをアップロードするため,自分のワークスペースにAppをインストールした.利用したAppは以下.

slack.com

環境構築

まず,docker-compose.ymlの内容を以下に示す.

docker-compose.yml

version: '3.4'

services:
    websocket_server:
        build:
            context: ./websocket
            dockerfile: Dockerfile
        image: custom_slackbot
        container_name: websocket-server
        restart: always
        environment:
            SLACK_API_TOKEN: 'your-slackbot-token'
        volumes:
            - ./websocket/websocket_server.py:/websocket_server.py:ro
        devices:
            - /dev/vchiq:/dev/vchiq # コンテナ内でcameraを利用するため
        logging:
            driver: "json-file"
            options:
                max-size: "10m"
                max-file: "3"
        ports:
            - 8010:8010

以下のようなDockerfileから,docker imageを作成する.

Dockerfile

FROM alpine:3.11

ENV PYTHONUNBUFFERED 1
ENV PYTHONIOENCODING utf-8
ENV SLACK_API_TOKEN dummy_api_token
ENV LD_LIBRARY_PATH /opt/vc/lib
ENV WEBSOCKET_PORT 8010

COPY ./requirements.txt /

# install packages
RUN    apk update \
    && apk add --no-cache bash tzdata raspberrypi-libs \
    && cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime \
    # install temporary libraries
    && apk add --no-cache --virtual .build-deps \
               gcc musl-dev libffi-dev g++ libgcc libstdc++ libxslt-dev \
               python3-dev libc-dev linux-headers openssl-dev \
    # install python3
    && apk add --no-cache python3 \
    # install pip
    && python3 -m ensurepip \
    && rm -r /usr/lib/python*/ensurepip \
    && pip3 install --upgrade pip setuptools \
    # create symbolic link
    && ln -sf /usr/bin/python3 /usr/bin/python \
    && ln -sf /usr/bin/pip3 /usr/bin/pip \
    # install python libraries
    && pip install -r /requirements.txt \
    # delete temporary libraries
    && apk del .build-deps \
    && rm -rf /root/.cache /var/cache/apk/* /tmp/*

# add start script
COPY ./start.sh /start.sh
RUN chmod 755 /start.sh

EXPOSE ${WEBSOCKET_PORT}

CMD ["/start.sh"]
# build
docker-compose build

ここで,requirements.txtとstart.shは,以下のような内容になっている.

requirements.txt

requests==2.23.0
picamera==1.13
SimpleWebSocketServer==0.1.1

start.sh

#!/bin/bash

/usr/bin/python /websocket_server.py

サーバの構築

ここでは,Websocketのサーバを立て,クライアントから要求があった場合にカメラで撮影を行い,バイナリデータをbase64エンコードして返却する.

実際は,複数のリクエストが来た場合を考慮する必要があるが,今回は考えないものとする(Websocketのサーバである必要もない).

websocket_server.py

#!/usr/bin/python

from SimpleWebSocketServer import SimpleWebSocketServer, WebSocket
from picamera import PiCamera
from io import BytesIO
import base64
import json
import os
import requests
import signal
import time
import threading

# definition of constant
WEBSOCKET_HOST = '0.0.0.0'
WEBSOCKET_PORT = 8010

class SendToSlack():
    """
    Send to slack
    Attributes
    ----------
    __empty_bin : bytes
        empty binary data
    __binary_image : bytes
        binary image
    __empty_format : str
        empty file format
    __file_format : str
        file format
    __wait_time : float
        wait time[sec]
    __status : bool
        True:  running
        False: stopping
    __condition : Condition
        condition object
    __thread : Thread
        thread object
    __channel : str
        slack channel name
    __token : str
        slack token
    __slack_file_upload_url : str
        slack file upload url
    """

    def __init__(self):
        # set threading information
        self.__empty_bin = bytes(0)
        self.__binary_image = self.__empty_bin
        self.__empty_format = ''
        self.__file_format = self.__empty_format
        self.__wait_time = 0.01
        self.__status = False
        self.__condition = threading.Condition()
        self.__thread = threading.Thread(target=self.__send_to_slack)
        # set slack information
        self.__channel = 'your-channel-id'
        self.__token = os.getenv('SLACK_API_TOKEN')
        self.__slack_file_upload_url = 'https://slack.com/api/files.upload'

    # 排他制御を行いつつ,送信するバイナリデータと画像フォーマットを更新する
    def set_binary_image(self, binary_image, file_format):
        # exclusion control
        with self.__condition:
            # wait for removing old data
            while self.__file_format != self.__empty_format:
                self.__condition.wait()
            self.__binary_image = binary_image
            self.__file_format = file_format

    def start_thread(self):
        self.__binary_image = self.__empty_bin
        self.__file_format = self.__empty_format
        self.__status = True
        self.__thread.start()

    def stop_thread(self):
        self.__status = False

        with self.__condition:
            try:
                self.__condition.notify_all()
            except RuntimeError as e:
                print(e)
        self.__thread.join()

    def __send_to_slack(self):
        while self.__status:
            time.sleep(self.__wait_time)
            # if binary image is not empty binary data, execute process
            if self.__file_format != self.__empty_format:
                # exclusion control
                with self.__condition:
                    # get current time
                    current_time = time.strftime('%Y-%m-%d_%H-%M-%S', time.localtime())
                    # ==========================
                    # slackに送信するデータの設定
                    # ==========================
                    # set parameters and files
                    files = {'file': self.__binary_image}
                    params = {
                        'token': self.__token,
                        'channels': self.__channel,
                        'filename': '{}.{}'.format(current_time, self.__file_format),
                        'initial_comment': 'Upload captured image',
                        'title': current_time,
                    }
                    # ===================================
                    # 新たなリクエストに向けてデータを初期化
                    # ===================================
                    # set empty data
                    self.__binary_image = self.__empty_bin
                    self.__file_format = self.__empty_format
                    # wake up all threads
                    self.__condition.notify_all()
                # ===========
                # slackに投稿
                # ===========
                # upload to slack workspace
                requests.post(url=self.__slack_file_upload_url, params=params, files=files)

class CameraWrapper():
    """
    PiCamera Wrapper
    Attributes
    ----------
    __camera : PiCamera
        instance of PiCamera
    __stream : BytesIO
        BytesIO buffer
    __formats : list of str
        image formats
    __send_slack : SendToSlack
        instance of SendToSlack
    """

    def __init__(self, resolution):
        """
        Constructor
        Parameters
        ----------
        resolution : tuple of int
            image resolution
        """
        self.__camera = PiCamera()
        self.__camera.resolution = resolution
        self.__camera.rotation = 180    # 上下反転した状態で撮影されるため,180度回転させる
        self.__stream = BytesIO()       # 撮影したデータはメモリ上に保持する
        # image formats
        self.__formats = ['jpeg', 'png']
        self.__send_slack = SendToSlack()

    def initialize(self):
        print('Call initialize')
        self.__camera.start_preview()
        time.sleep(2)
        self.__send_slack.start_thread()

    def finalize(self):
        print('Call finalize')
        self.__camera.close()
        self.__stream.close()
        self.__send_slack.stop_thread()

    def execute(self, data):
        # json形式の文字列が送られてくるため,dict形式に変換
        data = json.loads(data)
        # get file format
        file_format = data.get('format', '')
        if file_format not in self.__formats:
            file_format = self.__formats[0]
        # get option
        send_to_slack = data.get('send_to_slack', False)
        # capture
        self.__stream.seek(0)
        self.__camera.capture(self.__stream, file_format)   # 撮影
        # get binary image
        self.__stream.seek(0)
        binary_image = self.__stream.getvalue()    # バイナリデータの読み出し

        if send_to_slack:
            # set binary image
            self.__send_slack.set_binary_image(binary_image, file_format)
        # convert binary image to string data encoded by base64
        response = 'data:image/{};base64,{}'.format(file_format, base64.b64encode(binary_image).decode())
        self.__stream.flush()

        return response

class ProcessStatus():
    """
    status of process
    Attributes
    ----------
    __status : bool
        True  : running
        False : stopping
    """

    def __init__(self):
        """
        Constructor
        """
        self.__status = True

    def change_status(self, signum, frame):
        """
        change status
        Parameters
        ----------
        signum : int
            signal number
        frame : str
            frame information
        """
        self.__status = False

    def get_status(self):
        """
        get current status
        """
        return self.__status

# ================
# = main routine =
# ================
if __name__ == '__main__':
    print('Start script')
    # create instance of process
    process_status = ProcessStatus()
    signal.signal(signal.SIGINT, process_status.change_status)
    signal.signal(signal.SIGTERM, process_status.change_status)

    # create instance of PiCamera
    resolution = (640, 360)
    app = CameraWrapper(resolution)

    try:
        # initialization
        app.initialize()

        # define class of SimpleWebSocketServer
        class WebSocketProcess(WebSocket):
            def handleMessage(self):
                # データは「{'format': 'jpeg', 'send_to_slack': True}」という形式のjsonデータ
                response = app.execute(self.data)
                self.sendMessage(response)

        # create instance of WebSocket server
        server = SimpleWebSocketServer(
            WEBSOCKET_HOST,
            WEBSOCKET_PORT,
            WebSocketProcess
        )

        # =============
        # = main loop =
        # =============
        while process_status.get_status():
            server.serveonce()
        server.close()

    except Exception as e:
        print(e)
    # finalize
    app.finalize()
    print('End script')

クライアント側

後は,クライアント側で呼び出せばbase64エンコードされた画像データを取得できる.

  • クライアント側でwebsocket clientをインストールする.
pip install websocket_client==0.57.0
from websocket import create_connection
import json

ws_host = 'localhost'
ws_port = 8010
request_data = json.dumps({'format': 'jpeg', 'send_to_slack': True})
# open connection
ws_conn = create_connection('ws://{}:{}/'.format(ws_host, ws_port))
# send request to websocket server
ws_conn.send(request_data)
# receive response from websocket server
response = ws_conn.recv()
# close connection
ws_conn.close()

筆者は,このスクリプトDjango側で作成し,templateファイルに書き出すことで,撮影した画像をWeb上で確認できるシステムを構築した.

(おまけ)Djangoによるリクエスト側の作成

今回は,FormViewを利用したが,ajaxを利用した方が良かったかもしれないと考えている.

  • urls.py
from django.urls import path
from django.views.generic import TemplateView
from . import views

app_name = 'sample'

urlpatterns = [
    path('picamera/', views.PiCameraView.as_view(), name='picamera'),
]
  • view.py
from django.views.generic import FormView
from .forms import PiCameraForm
from time import localtime, strftime

class PiCameraView(FormView):
    template_name = 'sample/picamera.html'
    form_class = PiCameraForm

    def form_valid(self, form):
        # get response
        response = form.get_image()
        # get context
        context = self.get_context_data(img_response=response, img_scale='100%')

        return self.render_to_response(context)

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['img_src'] = kwargs.get('img_response', '')
        context['img_scale'] = kwargs.get('img_scale', '')
        context['get_time'] = strftime('%Y/%m/%d %H:%M:%S', localtime())

        return context
  • forms.py
from django import forms
from websocket import create_connection
import json

class PiCameraForm(forms.Form):
    FORMATS = (('jpeg', 'jpeg'), ('png', 'png'))
    image_format = forms.fields.ChoiceField(
        label='Image format',
        choices=FORMATS,
        initial=['jpeg'],
        required=True,
        widget=forms.widgets.Select(attrs={'class': 'form-control'}),
    )
    send_slack = forms.BooleanField(
        label='Send to slack',
        required=False,
        widget=forms.CheckboxInput(attrs={'class': 'check', 'data-toggle': 'toggle'}),
    )

    def get_image(self):
        ws_host = 'localhost'
        ws_port = 8010
        image_format = self.cleaned_data['image_format']
        send_slack = self.cleaned_data['send_slack']
        request_data = json.dumps({'format': image_format, 'send_to_slack': send_slack})
        # open connection
        ws_conn = create_connection('ws://{}:{}/'.format(ws_host, ws_port))
        # send request to websocket server
        ws_conn.send(request_data)
        # receive response from websocket server
        response = ws_conn.recv()
        # close connection
        ws_conn.close()

        return response
  • base.html
{% load static %}
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    {# viewport meta #}
    <meta name="viewport" content="width=device-width, initial-scale=1">
    {# Bootstrap の CSS を読み込み #}
    <link rel="stylesheet" href="/static/css/bootstrap.min.css">
    <link rel="stylesheet" href="/static/css/bootstrap4-toggle.min.css">
    {# jQuery の読み込み #}
    <script src="/static/js/jquery-3.4.1.min.js"></script>
    {# Bootstrap の JS 読み込み(jQuery よりも後に読み込む) #}
    <script src="/static/js/bootstrap.bundle.min.js"></script>
    <script src="/static/js/bootstrap4-toggle.min.js"></script>
    <link rel="icon" type="image/png" href="/static/img/favicon.ico">
    <title>{% block title %}{% endblock %}</title>
    {% block style %}
    {% endblock %}
    {% block headerjs %}
    {% endblock %}
</head>
<body>
    <div class="container">
        {% block content %}
        {% endblock %}
    </div>
    {% block bodyjs %}
    {% endblock %}
</body>
</html>
  • sample/picamera.html
{% extends 'base.html' %}
{% block title %}
PiCamera
{% endblock %}

{% block content %}
<div class="row justify-content-center">
    <div class="col-12">
        <h3 class="h3 mt-3">PiCamera</h3>
    </div>
</div>
<div class="row justify-content-center m-1">
    <div class="col-12">
        <div class="row">
            <div class="col-12">
                {{ form.non_field_errors }}
            </div>
        </div>
        <form action="" method="POST">
            <div class="row">
                <div class="form-group col-8">
                    <div class="form-row">
                        {% with form.image_format as field %}
                        <div class="col-3">
                            <label for="{{ field.id_for_label }}">{{ field.label_tag }}</label>
                        </div>
                        <div class="col-9">
                            {{ field }}
                            {{ field.errors }}
                        </div>
                        {% endwith %}
                    </div>
                </div>
                <div class="form-group col-4">
                    <div class="form-row">
                        {% with form.send_slack as field %}
                        <div class="col-6">
                            <label for="{{ field.id_for_label }}">{{ field.label_tag }}</label>
                        </div>
                        <div clasS="col-6">
                            {{ field }}
                            {{ field.errors }}
                        </div>
                        {% endwith %}
                    </div>
                </div>
                {% csrf_token %}
            </div>
            <div class="row">
                <div class="col-6">
                    <button type="submit" class="btn btn-primary btn-lg btn-block">撮影結果の取得</button>
                </div>
                <div class="col-6">
                    <a href="#" class="btn btn-secondary btn-lg btn-block">戻る</a>
                </div>
            </div>
        </form>
    </div>
</div>
{% if img_src and img_scale %}
<div class="row justify-content-center m-1">
    <div class="col-12">
        <h4 class="h4">取得時間:{{ get_time }}</h4>
    </div>
</div>
<div class="row justify-content-center m-1">
    <div class="col-12">
        <div style="text-align: center;">
            <img src="{{ img_src }}" title="結果" width="{{ img_scale }}">
        </div>
    </div>
</div>
{% endif %}
{% endblock %}