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

##概要
Qiitaの[Raspberry Pi Advent Calendar 2020](https://qiita.com/advent-calendar/2020/raspberry-pi)への参加記事です。
今更感はありますが、RaspberryPiZeroのカメラ画像をWebSocketでサーバーに転送し、オブジェクト・ディテクションを行います。

##処理の流れ
処理は、だいたい以下のような流れで実行します。
サーバーはJavaのマイクロフレームワークの一つ、<a href="https://sparkjava.com/" target="_blank">Spark</a>を使用し、ラズパイ側はPython3を使用します。
また、オブジェクト・ディテクションは、Yolov3をOpenCV4で使用します。
![002.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/55735/a137914e-4d61-b9d6-4203-0d7a38ce80e1.jpeg)

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


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

```java: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クラスを作成しました。

```java: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クラスを作成しました。
<a href="https://pjreddie.com/darknet/yolo/" target="_blank">Yolov3</a>のモデルを<a href="https://opencv.org/opencv-4-0/" target="_blank">OpenCV4.0</a>のDNNに読み込んで解析を行っています。

```java: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で実行しています。

```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インターフェイスを定義しました。

```java: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で行っています。

```java: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);
		  }
	}
}
```

```java: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を作成しました。

```Python: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が実装です。

```html: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>
```

```javascript: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
	});

};
```
```css: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/55735/a09af3c9-2a81-fd2c-8d37-0886d4e3b6e2.gif)


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