LoginSignup
3

More than 1 year has passed since last update.

posted at

updated at

ラズパイZeroとWebSocketとサーバーでCNNカメラ的なものを作ってみる

概要

QiitaのRaspberry Pi Advent Calendar 2020への参加記事です。
今更感はありますが、RaspberryPiZeroのカメラ画像をWebSocketでサーバーに転送し、オブジェクト・ディテクションを行います。

処理の流れ

処理は、だいたい以下のような流れで実行します。
サーバーはJavaのマイクロフレームワークの一つ、Sparkを使用し、ラズパイ側はPython3を使用します。
また、オブジェクト・ディテクションは、Yolov3をOpenCV4で使用します。
002.jpg

作成するクラス等

    今回は以下のクラス等を作成しました。
  • Main.java:SparkFrameWorkの組み込みサーバーを起動するメインクラス
  • CameraHandler.java:サーバーにラズパイが接続した時の処理クラス
  • WebHandler.java:サーバーにブラウザが接続した時の処理クラス
  • SessionList.java:接続したセッションを管理するクラス
  • Yolo.java:Yolov3によりオブジェクト・ディテクションを行うクラス
  • YoloSolver.java:オブジェクトディテクションを実行するクラス
  • SolverThread.java:オブジェクト・ディテクションのスレッドクラス
  • index.html:ラズパイカメラ画像を表示するHTML
  • main.js/main.css:index.htmlで使用するjs/css
  • camera_server.py:ラズパイ側のカメラ画像をサーバーに転送するPythonコード

サーバー側の処理/Webocket通信

ラズパイからの接続時、接続終了時、メッセージ受領時の処理のため、以下のCameraHandlerクラスを作成しました。
Camerahandlerクラスでは、カメラからメッセージ(カメラ画像等)を受け取るとYolo.getInstance().addSolverメソッドでオブジェクト・ディテクションを実行するクラスへ画像を引き渡します。

CameraHandler.java
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

import javax.imageio.ImageIO;

import org.eclipse.jetty.websocket.api.Session;
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
import org.eclipse.jetty.websocket.api.annotations.WebSocket;

import com.google.gson.Gson;

import yolo.Yolo;

@WebSocket
public class CameraHandler {
    private static Gson gson=new Gson();

    @OnWebSocketConnect
    public void onConnect(Session session)throws IOException {
        SessionList.getInstance().addCameraSession(session);
        Map<String,Object> obj=new HashMap<>();
        String key=Integer.toString(session.hashCode());
        obj.put("id", key);
        obj.put("act", "add");
        obj.put("data", "");
        SessionList.getInstance().broadcastWeb(gson.toJson(obj));
        session.getRemote().sendString("O.K.");
    }

    @OnWebSocketClose
    public void onClose(Session session, int statusCode, String reason)throws IOException {
        SessionList.getInstance().removeCameraSession(session);
        Map<String,Object> obj=new HashMap<>();
        String key=Integer.toString(session.hashCode());
        obj.put("id", key);
        obj.put("act", "remove");
        obj.put("data", "");
        SessionList.getInstance().broadcastWeb(gson.toJson(obj));
    }

    @OnWebSocketMessage
    public void onMessage(Session session, String message) throws IOException {
        String key=Integer.toString(session.hashCode());
        if(Main.doYolo()){
            Yolo.getInstance().addSolver(key, message,session);
        }else{
            Map<String,Object> obj=new HashMap<>();
            obj.put("id", key);
            obj.put("act", "update");
            obj.put("data", message);
            SessionList.getInstance().broadcastWeb(gson.toJson(obj));
            session.getRemote().sendString("O.K.");
        }
    }

    @OnWebSocketMessage
    public void onBinary(Session session, byte[] buffer, int offset, int length) throws IOException {}

    public void createImage(String str)throws IOException{
        try{
            byte[] decodedBytes = Base64.getDecoder().decode(str);
            BufferedImage img = ImageIO.read(new ByteArrayInputStream(decodedBytes));
            ImageIO.write(img, "jpg", new File("test.jpg"));
        }catch(Exception e){
            e.printStackTrace();
        }
    }
}

ブラウザからの接続時、接続終了時の処理のため、以下のWebHandlerクラスを作成しました。

WebHandler.java
import java.io.IOException;

import org.eclipse.jetty.websocket.api.Session;
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
import org.eclipse.jetty.websocket.api.annotations.WebSocket;

@WebSocket
public class WebHandler {

    @OnWebSocketConnect
    public void onConnect(Session session){
        SessionList.getInstance().addWebSession(session);
    }

    @OnWebSocketClose
    public void onClose(Session session, int statusCode, String reason){
        SessionList.getInstance().removeWebSession(session);
    }

    @OnWebSocketMessage
    public void onMessage(Session session, String message) throws IOException {

    }

    @OnWebSocketMessage
    public void onBinary(Session session, byte[] buffer, int offset, int length) throws IOException {
//      session.getRemote().sendBytes(ByteBuffer.wrap(buffer));
    }
}

サーバー側の処理/オブジェクト・ディテクション

オブジェクト・ディテクションを行うYoloクラスを作成しました。
Yolov3のモデルをOpenCV4.0のDNNに読み込んで解析を行っています。

Yolo.java
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferByte;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;

import javax.imageio.ImageIO;

import org.eclipse.jetty.websocket.api.Session;
import org.opencv.core.CvType;
import org.opencv.core.Mat;
import org.opencv.core.MatOfByte;
import org.opencv.dnn.Dnn;
import org.opencv.dnn.Net;
import org.opencv.imgcodecs.Imgcodecs;

public class Yolo {
    private static Yolo yolo=null;
    private List<String> outputNames;
    private Net net;

    public static Yolo getInstance(){
        if(yolo==null){
            yolo=new Yolo();
            return yolo;
        }else{
            return yolo;
        }
    }

    public Yolo(){
        nu.pattern.OpenCV.loadShared();
        String modelWeights = "yolov3_320.weights";
        String modelConfiguration = "yolov3_320.cfg";
        net=Dnn.readNetFromDarknet(modelConfiguration, modelWeights);
        net.setPreferableBackend(Dnn.DNN_BACKEND_CUDA);
        net.setPreferableTarget(Dnn.DNN_TARGET_CUDA);
        outputNames=createOutputNames(net);
        SolverThread st=new SolverThread(net,0.6f);
        st.start();
    }

    public void setListener(PostProcessingListener l){
        SolverThread.setListsner(l);
    }

    public List<String> getOutputNames(){
        return outputNames;
    }

    private List<String> createOutputNames(Net net) {
        List<String> names = new ArrayList<>();
        List<Integer> outLayers = net.getUnconnectedOutLayers().toList();
        List<String> layersNames = net.getLayerNames();
        outLayers.forEach((item) -> names.add(layersNames.get(item - 1)));
        return names;
    }

    public void addSolver(String name,String base64){
        try{
            BufferedImage bi=createImage(base64);
            YoloSolver sol=new YoloSolver(name,bi);
            SolverThread.add(sol);
        }catch(Exception e){
            e.printStackTrace();
        }
    }

    public void addSolver(String name,String base64,Session session){
        try{
            BufferedImage bi=createImage(base64);
            YoloSolver sol=new YoloSolver(name,bi,session);
            SolverThread.add(sol);
        }catch(Exception e){
            e.printStackTrace();
        }
    }

    public void addSolver(String name,BufferedImage bi){
        try{
            YoloSolver sol=new YoloSolver(name,bi);
            SolverThread.add(sol);
        }catch(Exception e){
            e.printStackTrace();
        }
    }

    public void addSolver(String name,BufferedImage bi,Session session){
        try{
            YoloSolver sol=new YoloSolver(name,bi,session);
            SolverThread.add(sol);
        }catch(Exception e){
            e.printStackTrace();
        }
    }

    public void addSolver(String name,Mat mat){
        try{
            YoloSolver sol=new YoloSolver(name,mat);
            SolverThread.add(sol);
        }catch(Exception e){
            e.printStackTrace();
        }
    }

    public void addSolver(String name,Mat mat,Session session){
        try{
            YoloSolver sol=new YoloSolver(name,mat,session);
            SolverThread.add(sol);
        }catch(Exception e){
            e.printStackTrace();
        }
    }

    public static BufferedImage createImage(String str)throws IOException{
        try{
            byte[] decodedBytes = Base64.getDecoder().decode(str);
            BufferedImage img = ImageIO.read(new ByteArrayInputStream(decodedBytes));
            return img;
        }catch(Exception e){
            e.printStackTrace();
            return null;
        }
    }

    public static BufferedImage matToBi(Mat image){
        MatOfByte bytemat = new MatOfByte();
        Imgcodecs.imencode(".jpg", image, bytemat);
        byte[] bytes = bytemat.toArray();
        InputStream in = new ByteArrayInputStream(bytes);
        BufferedImage img = null;
        try {
            img = ImageIO.read(in);
        } catch (IOException e) {
            e.printStackTrace();
        }
        return img;
    }

    public static Mat biToMat(BufferedImage image) {
        image = convertTo3ByteBGRType(image);
        byte[] data = ((DataBufferByte) image.getRaster().getDataBuffer()).getData();
        Mat mat = new Mat(image.getHeight(), image.getWidth(), CvType.CV_8UC3);
        mat.put(0, 0, data);
        return mat;
    }

    private static BufferedImage convertTo3ByteBGRType(BufferedImage image) {
        BufferedImage convertedImage = new BufferedImage(image.getWidth(), image.getHeight(),
                BufferedImage.TYPE_3BYTE_BGR);
        convertedImage.getGraphics().drawImage(image, 0, 0, null);
        return convertedImage;
    }

実際のオブジェクト・ディテクションは、以下のYoloSolver.javaで実行しています。

YoloSolver.java

import java.awt.image.BufferedImage;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Observable;

import org.eclipse.jetty.websocket.api.Session;
import org.opencv.core.Core;
import org.opencv.core.Mat;
import org.opencv.core.MatOfFloat;
import org.opencv.core.MatOfInt;
import org.opencv.core.MatOfRect2d;
import org.opencv.core.Point;
import org.opencv.core.Rect2d;
import org.opencv.core.Scalar;
import org.opencv.core.Size;
import org.opencv.dnn.Dnn;
import org.opencv.dnn.Net;
import org.opencv.imgproc.Imgproc;
import org.opencv.utils.Converters;

public class YoloSolver extends Observable{
    private BufferedImage bi;
    private String name;
    private long time;
    private BufferedImage dst;
    private List<Integer> clsIds;
    private List<Float> confs;
    private List<Rect2d> rects;
    private Mat frame;
    private Session session;

    public YoloSolver(String name,BufferedImage bi){
        time=System.currentTimeMillis();
        this.name=name;
        this.bi=bi;
        this.frame=Yolo.biToMat(bi);
    }

    public YoloSolver(String name,BufferedImage bi,Session session){
        this(name,bi);
        this.session=session;
    }

    public YoloSolver(String name,Mat f){
        time=System.currentTimeMillis();
        this.name=name;
        this.bi=Yolo.matToBi(f);
        this.frame=f;
    }

    public YoloSolver(String name,Mat f,Session session){
        this(name,f);
        this.session=session;
    }

    public void solve(Net net,float confThreshold){
        Size sz = new Size(288,288);
        List<Mat> result = new ArrayList<>();
        List<String> outBlobNames = Yolo.getInstance().getOutputNames();
        Mat blob = Dnn.blobFromImage(frame, 0.00392, sz, new Scalar(0), true, false);
        net.setInput(blob);
        net.forward(result, outBlobNames);
        clsIds = new ArrayList<>();
        confs = new ArrayList<>();
        rects = new ArrayList<>();
        for (int i=0;i<result.size();++i){
            Mat level = result.get(i);
            for (int j=0;j<level.rows();++j){
                Mat row = level.row(j);
                Mat scores = row.colRange(5, level.cols());
                Core.MinMaxLocResult mm = Core.minMaxLoc(scores);
                float confidence = (float)mm.maxVal;
                Point classIdPoint = mm.maxLoc;
                if (confidence > confThreshold){
                    int centerX = (int)(row.get(0,0)[0] * frame.cols());
                    int centerY = (int)(row.get(0,1)[0] * frame.rows());
                    int width   = (int)(row.get(0,2)[0] * frame.cols());
                    int height  = (int)(row.get(0,3)[0] * frame.rows());
                    int left    = centerX - width  / 2;
                    int top     = centerY - height / 2;
                    clsIds.add((int)classIdPoint.x);
                    confs.add((float)confidence);
                    rects.add(new Rect2d(left, top, width, height));
                }
            }
        }
        float nmsThresh = 0.5f;
        if(confs.size()>0){
            MatOfFloat confidences = new MatOfFloat(Converters.vector_float_to_Mat(confs));
            Rect2d[] boxesArray = rects.toArray(new Rect2d[0]);
            MatOfRect2d boxes = new MatOfRect2d(boxesArray);
            MatOfInt indices = new MatOfInt();
            Dnn.NMSBoxes(boxes, confidences, confThreshold, nmsThresh, indices);
            int [] ind = indices.toArray();
            for (int i = 0; i < ind.length; ++i){
                int idx = ind[i];
                Rect2d box = boxesArray[idx];
                Imgproc.rectangle(frame, box.tl(), box.br(), new Scalar(0,255,255), 2);
            }
            dst=Yolo.matToBi(frame);
        }
    }

    public BufferedImage getDstImage() {
        return dst;
    }

    public List<Integer> getClsIds() {
        return clsIds;
    }

    public List<Float> getConfs() {
        return confs;
    }

    public List<Rect2d> getRects() {
        return rects;
    }

    public BufferedImage getSrcImage(){
        return bi;
    }

    public Date getDate(){
        return new Date(time);
    }

    public String getName(){
        return name;
    }

    public Session getSession() {
        return session;
    }

}

オブジェクト・ディテクションのためのスレッドとして、SolverThreadクラスを作成しました。
SolverTheradクラスは、キューに登録されたYoloSolverインスタンスを順次処理します。
また、解析終了後の処理のため、PostProcessingListenerインターフェイスを定義しました。

SolverThread.java

import java.util.Collections;
import java.util.LinkedList;
import java.util.List;

import org.opencv.dnn.Net;

public class SolverThread implements Runnable{
    private static List<YoloSolver> queue=Collections.synchronizedList(new LinkedList<YoloSolver>());
    private Thread thread;
    private Net net;
    private static PostProcessingListener listener;
    private float threshold=0.6f;


    public SolverThread(Net net,float confThreshold){
        this.net=net;
        this.threshold=confThreshold;
    }

    public static void add(YoloSolver s){
        synchronized (queue){
            if(queue.contains(s)){
                queue.notifyAll();
            }else{
                queue.add(s);
                queue.notifyAll();
            }
        }
      }

    public static void remove(YoloSolver s){
        synchronized (queue){
            queue.remove(s);
            queue.notifyAll();
        }
    }

    public void start(){
        thread=new Thread(this);
        thread.start();
    }

    public void stop(){
        thread=null;
    }

    public void run() {
        while(thread!=null){
            YoloSolver solver=null;
            synchronized(queue){
                while(queue.isEmpty()){
                    try{
                        queue.wait();
                    }catch (InterruptedException e){}
                }
                if(thread!=null){
                    solver=(YoloSolver)queue.get(0);
                    queue.remove(0);
                }
            }
            try{
                if(solver!=null){
                    try{
                        solver.solve(net,threshold);
                        if(listener!=null)listener.postProcessing(solver);
                    }catch(Exception e){
                        e.printStackTrace();
                        remove(solver);
                    }
                }
            }catch(Exception e){
                e.printStackTrace();
            }
        }
    }

    public static void setListsner(PostProcessingListener l){
        listener=l;
    }
}

サーバー側の処理/Mainクラス

サーバー全体の処理を行うMainクラスを作成しました。
また、通信セッションを管理するSessionListクラスを作成しました。
/cameraがラズパイカメラの接続アドレス、/webがブラウザの接続アドレスで、/session/cameraは接続中のカメラセッション一覧を取得するアドレスです。
/cameraに接続したラズパイカメラは逐次を画像をサーバーに送信し、CameraHanderクラスで画像をCNN解析を行うYoloクラスへ渡し、Yoloクラス内でYoloSolverクラスを生成し、SolverThreadのキューへ登録されます。
解析が終わったデータは、Mainクラス内で定義したPostProcessingListenerにより、全てのWebブラウザセッションに更新画像をブロードキャストすると共に、ラズパイカメラセッションに処理終了を通知します。
画像を含むデータのやりとりはJsonで行っています。

Main.java
import static spark.Spark.get;
import static spark.Spark.init;
import static spark.Spark.port;
import static spark.Spark.staticFileLocation;
import static spark.Spark.webSocket;

import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Base64;

import javax.imageio.ImageIO;

import com.google.gson.Gson;

import yolo.PostProcessingListener;
import yolo.Yolo;
import yolo.YoloSolver;

public class Main {
    private static SessionList list=SessionList.getInstance();
    private static Gson gson=new Gson();
    private static boolean doYolo=false;

    public static void main(String[] args) {
        setYolo(true);
        staticFileLocation("/public");
        webSocket("/camera", CameraHandler.class);
        webSocket("/web", WebHandler.class);
        port(8080);
        init();
        get("/session/camera", (request, response) -> {
            try{
                String[] ret=list.getCameraId();
                response.type("application/json");
                response.status(200);
                return new Gson().toJson(ret);
            }catch(Exception e){
                e.printStackTrace();
                response.status(400);
                response.type("application/json");
                return gson.toJson(new String[0]);
            }
        });
    }

    public static boolean doYolo(){
        return doYolo;
    }

    public static void setYolo(boolean flg){
        doYolo=flg;
        if(doYolo){
            PostProcessingListener li=new PostProcessingListener(){
                @Override
                public void postProcessing(YoloSolver sol) {
                    try{
                        Map<String,Object> obj=new HashMap<>();
                        obj.put("id", sol.getName());
                        obj.put("act", "update");
                        if(sol.getDstImage()==null){
                            String message=jpgToStr(sol.getSrcImage());
                            obj.put("data", message);
                        }else{
                            String message=jpgToStr(sol.getDstImage());
                            obj.put("data", message);
                        }
                        SessionList.getInstance().broadcastWeb(gson.toJson(obj));
                    }catch(Exception e){
                        e.printStackTrace();
                    }
                    try{
                        sol.getSession().getRemote().sendString("O.K.");
                    }catch(IOException e){
                        e.printStackTrace();
                    }
                }
            };
            Yolo.getInstance().setListener(li);
        }
    }

    private static String jpgToStr(BufferedImage img){
          final ByteArrayOutputStream os = new ByteArrayOutputStream();
          try{
            ImageIO.write(img, "jpg", os);
            return Base64.getEncoder().encodeToString(os.toByteArray());
          } catch (final IOException ioe){
            throw new UncheckedIOException(ioe);
          }
    }
}
SessionList.java
import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;

import org.eclipse.jetty.websocket.api.Session;

public class SessionList {
    private Map<String,Session> camera;
    private Map<String,Session> web;
    private static SessionList list=null;

    private SessionList(){
        camera=new HashMap<>();
        web=new HashMap<>();
    }

    public static SessionList getInstance(){
        if(list==null){
            list=new SessionList();
            return list;
        }else{
            return list;
        }
    }

    public String[] getCameraId(){
        String[] keys=camera.keySet().toArray(new String[camera.keySet().size()]);
        Arrays.sort(keys);
        return keys;
    }

    public void addCameraSession(Session s){
        String key=Integer.toString(s.hashCode());
        camera.put(key, s);
    }

    public void removeCameraSession(Session s){
        String key=Integer.toString(s.hashCode());
        camera.remove(key);
    }

    public void addWebSession(Session s){
        String key=Integer.toString(s.hashCode());
        web.put(key, s);
    }

    public void removeWebSession(Session s){
        String key=Integer.toString(s.hashCode());
        web.remove(key);
    }

    public void broadcastWeb(String mes)throws IOException{
        for(Session s : web.values()){
            s.getRemote().sendString(mes);
        }
    }
}

ラスパイ側の処理

ラズパイZero側では、逐次カメラ画像をサーバーに転送するCameraServer.pyを作成しました。

CameraServer.py
import cv2
import websocket
import base64

url="ws://アドレス:ポート/camera"
WIDTH=640
HEIGHT=480
FPS=6

camera_id=0
cap = cv2.VideoCapture(camera_id)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, WIDTH)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, HEIGHT)
cap.set(cv2.CAP_PROP_FPS, FPS)
encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), 85]

def on_message(ws, message):
    if message=="O.K.":
        try:
            send_data(ws,cap)
        except:
            print('Error')

def on_error(ws, error):
    print(error)

def on_close(ws):
    print("### closed ###")

def on_open(ws):
    print('### open ###')

ws = websocket.WebSocketApp(url,
        on_open=on_open,
        on_message = on_message,
        on_error = on_error,
        on_close = on_close)

def send_data(ws,cap):
    ret, frame = cap.read()
    res, enc = cv2.imencode('.jpg', frame, encode_param)
    dst=base64.b64encode(enc).decode('utf-8')
    ws.send(dst)

try:
    ws.run_forever()
except KeyboardInterrupt:
    ws.close()
    cap.release()

Web接続用HTML

最後にラズパイカメラ画像を表示するHtmlを作成しました。
以下のindex.htmlとmain.js、main.cssが実装です。

index.html
<!doctype html>
<html lang="ja" >
<head>
    <meta charset="UTF-8" />
    <title>Iot-Camera-Test</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.bundle.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-dragdata@0.1.0/dist/chartjs-plugin-dragData.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@1.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
    <script src="/js/chartjs-plugin-labels.min.js"></script>
    <script src="js/main.js"></script>
    <link rel="stylesheet" type="text/css" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" />
    <link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.min.css" />
    <link rel="stylesheet" type="text/css" href="css/main.css" />
</head>
<body>
    <header>
        <div class="collapse bg-dark" id="navbarHeader">
            <div class="container">
                <div class="row">
                    <div class="col-sm-8 col-md-7 py-4">
                        <h4 class="text-white">About</h4>
                        <p class="text-white">テストサイトです。</p>
                    </div>
                </div>
            </div>
        </div>
        <div class="navbar navbar-dark bg-dark shadow-sm">
            <input type="checkbox" class="openSidebarMenu" id="openSidebarMenu">
            <label for="openSidebarMenu" class="sidebarIconToggle">
                <div class="spinner diagonal part-1"></div>
                <div class="spinner horizontal"></div>
                <div class="spinner diagonal part-2"></div>
            </label>
            <div id="sidebarMenu">
                <ul class="sidebarMenuInner" id="log">
                    <li><a href="#">サイドパネル</a></li>
                    <hr />
                    <li>接続状況 <span>Web/CAMERA</span></li>
                </ul>
            </div>
            <div class="container d-flex justify-content-between">
                <a href="#" class="navbar-brand d-flex align-items-center">
                    <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path><circle cx="12" cy="13" r="4"></circle></svg>
                    <strong>Bingo-IoT-Camera</strong>
                </a>
                <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarHeader" aria-controls="navbarHeader" aria-expanded="false" aria-label="Toggle navigation">
                    <span class="navbar-toggler-icon"></span>
                </button>
            </div>
        </div>
    </header>
    <main role="main">
        <section class="jumbotron text-center">
            <div class="container">
                <h2 class="jumbotron-heading">IoTカメラ映像(仮)</h2>
                <p class="lead text-muted">IoTカメラの映像を確認することができます。</p>
            </div>
            <a href="#" class="btn btn-primary my-2" onclick="addCardTest();">カード追加テスト</a>
            <a href="#" class="btn btn-primary my-2" onclick="removeCardTest()">カード削除テスト</a>
        </section>

        <div class="container">
                <div class="row justify-content-center">
                        <div class="col-auto mb-3 card-columns" id="card_deck">
                        </div>
                </div>
            <footer class="pt-4 my-md-5 pt-md-5 border-top">
                <div class="row">
                    <div class="col-12 col-md">
                        <!-- <img class="mb-2" src="./img/ft_img_logo.png" alt="" height="40"> -->
                        <h5>Logo</h5>
                        <ul class="list-unstyled text-small">
                            <li><a class="text-muted" href="#">Cool stuff</a></li>
                            <li><a class="text-muted" href="#">Random feature</a></li>
                        </ul>
                    </div>
                    <div class="col-6 col-md">
                        <h5>Features</h5>
                        <ul class="list-unstyled text-small">
                            <li><a class="text-muted" href="#">Cool stuff</a></li>
                            <li><a class="text-muted" href="#">Random feature</a></li>
                        </ul>
                    </div>
                    <div class="col-6 col-md">
                        <h5>Resources</h5>
                        <ul class="list-unstyled text-small">
                            <li><a class="text-muted" href="#">Resource</a></li>
                            <li><a class="text-muted" href="#">Resource name</a></li>
                        </ul>
                    </div>
                    <div class="col-6 col-md">
                        <h5>About</h5>
                        <ul class="list-unstyled text-small">
                            <li><a class="text-muted" href="#">Team</a></li>
                            <li><a class="text-muted" href="#">Locations</a></li>
                        </ul>
                    </div>
                </div>
            </footer>
        </div>
    </main>
</body>
</html>
main.js
const socket=new WebSocket("ws://"+window.location.host+"/web");
socket.onopen = (e)=>{
    $.get("/session/camera", function(data){
        data.forEach(id => addCard(id));
    });
};

socket.onmessage = (e)=>{
    const obj=JSON.parse(e.data);
    const id=obj.id;
    const act=obj.act;
    const data=obj.data;
    if(act=="update"){
        let canvas=document.getElementById(id+'_img');
        let ctx=canvas.getContext('2d');
        let img=new Image();
        img.src='data:image/jepg;base64,'+data;
        img.onload = function(){
            ctx.drawImage(img, 0, 0,640,480,0,0,480,360);
        }
    }else if(act=="remove"){
        removeCard(id);
        const li=$("<li />");
        li.text("camera("+id+") close");
        li.attr("class","text-muted");
        li.css("font-size","14");
        li.css("line-height","18px");
        $("#log").append(li);
    }else if(act=="add"){
        addCard(id);
        const li=$("<li />");
        li.text("camera("+id+") open");
        li.attr("class","text-muted");
        li.css("font-size","14");
        li.css("line-height","18px");
        $("#log").append(li);
    }
};

const removeCard=(id_name)=>{
    const div=$("#"+id_name);
    div.remove();
};

let count=0;
const addCardTest=()=>{
    addCard("Test"+String(count));
    count++;
};

const removeCardTest=()=>{
    if(count>0){
        removeCard("Test"+String(count-1));
        count--;
    }
};

const addCard =(id_name) =>{
    const container=$("<div />");
    container.attr("class","card shadow-sm");
    container.attr("id",id_name);
    container.css("width","482px");
    container.css("padding","0px");
    const header=$("<div />");
    header.attr("class","card-header");
    container.append(header);
    const title=$("<h4 />");
    title.attr("class","my-0 font-weight-normal");
    title.text(id_name);
    header.append(title);
    const body=$("<div />");
    body.attr("class","card-body");
    body.css("margin","0px");
    body.css("padding","0px");
    container.append(body);
    canvas=$("<canvas />");
    canvas.attr("id",id_name+"_img");
    canvas.attr("width","480px");
    canvas.attr("height","360px");
    body.append(canvas);
    const footer=$("<div />");
    footer.attr("class","card-footer")
    container.append(footer);
    const bt=$("<a />");
    bt.attr("class","btn btn-primary btn-sm" );
    bt.attr("role","button");
    bt.attr("onclick","addChart('"+id_name+"');");
    bt.text("統計情報");
    footer.append(bt);
    $("#card_deck").append(container);
};

const addChart =(id_name) =>{
    const container=$("<div />");
    container.attr("class","card shadow-sm");
    container.attr("id",id_name+"_chartcard");
    container.css("width","482px");
    container.css("padding","0px");
    const header=$("<div />");
    header.attr("class","card-header");
    container.append(header);
    const title=$("<h4 />");
    title.attr("class","my-0 font-weight-normal");
    title.text(" "+id_name);
    header.append(title);
    const body=$("<div />");
    body.attr("class","card-body");
    body.css("margin","0px");
    body.css("padding","0px");
    const close=$("<a />");
    close.attr("class","btn btn-secondary btn-sm" );
    close.attr("role","button");
    close.attr("onclick","$('#"+id_name+"_chartcard').remove()");
    close.text("×");
    close.css("padding","0px")
    close.css("width","24px");
    close.css("height","24px");
    close.css("font-size","14px");
    title.prepend(close);

    container.append(body);
    canvas=$("<canvas />");
    canvas.attr("id",id_name+"_chart");
    canvas.attr("width","480px");
    canvas.attr("height","360px");
    body.append(canvas);
    const footer=$("<div />");
    footer.attr("class","card-footer")
    container.append(footer);
    const bt=$("<a />");
    bt.attr("class","btn btn-primary btn-sm" );
    bt.attr("role","button");
    bt.attr("onclick","alert('Test');");
    bt.text("日間変動");
    footer.append(bt);
//  $("#card_deck").append(container);
    $("#"+id_name).after(container);

    const ctx = document.getElementById(id_name+"_chart").getContext("2d");
    const start=new Date(2020, 12, 1);
    dates=[];
    vals=[];
    for(let i=0;i<31;i++){
        dates.push(new Date(start.getTime()+i*60*60*24*1000));
        let v=Math.round(Math.random()*50);
        vals.push(v);
    }
    var data= {
        labels: dates,
        datasets: [
          {
            label: '検出回数',
            data:vals,
            backgroundColor: "rgba(219,39,91,0.5)"
          }
        ]
    };
    var options={
        scales: {
            xAxes: [{
                type: 'time',
                time: {
                    unit: 'day'
                }
            }]
        }
    };
    var mChart = new Chart(ctx, {
        type: 'line',
        data: data,
        options: options
    });

};
main.css

input[type=checkbox]:checked ~ .sidebarIconToggle > .horizontal {
    transition: all 0.3s;
    box-sizing: border-box;
    opacity: 0;
}

input[type=checkbox]:checked ~ .sidebarIconToggle > .diagonal.part-1 {
    transition: all 0.3s;
    box-sizing: border-box;
    transform: rotate(135deg);
    margin-top: 8px;
}

input[type=checkbox]:checked ~ .sidebarIconToggle > .diagonal.part-2 {
    transition: all 0.3s;
    box-sizing: border-box;
    transform: rotate(-135deg);
    margin-top: -9px;
}

.card-columns {
    column-count:2;
}

.spinner {
    transition: all 0.3s;
    box-sizing: border-box;
    position: absolute;
    height: 3px;
    width: 100%;
    background-color: #fff;
}

.horizontal {
    transition: all 0.3s;
    box-sizing: border-box;
    position: relative;
    float: left;
    margin-top: 3px;
}

.diagonal.part-1 {
    position: relative;
    transition: all 0.3s;
    box-sizing: border-box;
    float: left;
}

.diagonal.part-2 {
    transition: all 0.3s;
    box-sizing: border-box;
    position: relative;
    float: left;
    margin-top: 3px;
}

input[type="checkbox"]:checked ~ #sidebarMenu {
    transform: translateX(0);
}

input[type=checkbox] {
    transition: all 0.3s;
    box-sizing: border-box;
    display: none;
}

.sidebarIconToggle {
    transition: all 0.3s;
    box-sizing: border-box;
    cursor: pointer;
    position: absolute;
    z-index: 99;
    height: 100%;
    width: 100%;
    top: 22px;
    left: 15px;
    height: 22px;
    width: 22px;
}

#sidebarMenu {
    float: left;
    height: 100%;
    position: fixed;
    left: 0;
    top: 58px;
    width: 250px;
    z-index: 50;
    transform: translateX(-250px);
    transition: transform 250ms ease-in-out;
    background: linear-gradient(180deg, #f0ffff 0%, #f0ffff 100%);
}

.sidebarMenuInner{
    margin:0;
    padding:0;
    border-top: 1px solid rgba(255, 255, 255, 0.10);
}

.sidebarMenuInner li{
    list-style: none;
    color: #666;
    text-transform: uppercase;
    font-weight: bold;
    padding: 20px;
    cursor: pointer;
    border-bottom: 1px solid rgba(255, 255, 255, 0.10);
}

.sidebarMenuInner li span{
    display: block;
    font-size: 14px;
    color: rgba(180, 180, 180, 0.50);
}

.sidebarMenuInner li a{
    color: #666;
    text-transform: uppercase;
    font-weight: bold;
    cursor: pointer;
    text-decoration: none;
}

実装結果

ラズパイZeroの性能やシステムの仕様の関係もあり、かなり遅延が生じますが、一応、CNNカメラ的に機能します。
また、サーバー側に余裕があれば、ラズパイZero(カメラ)複数台を同時接続することができます。

ezgif-6-54168fe7130e.gif

最後に

もともとは、RaspberryPiZeroを野生動物などの野外調査に利用できないかと思い、実験したコードです。
なかなか、使用する機会もないので、アドベントカレンダーのネタとして消費しました。
そのうち、Raspberry Pi4+Coral USB Acceleratorを使い、カメラ側でのCNNも試してみたいと思います。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
3