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は以下.
環境構築
まず,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 %}