[Python]Grpcでカメラ映像をストリームで共有する

:::note info
更新日 2021/01/23
ソースコード変更
変更前は、実はストリームに見せかけたCall & Responceだったので正しく一度のリクエストでデータを送信し続けるように変更
python 3.10.2 環境で動作確認
:::

Pythonでカメラのストリームサーバーを作ってみます。
いやまずGrpcってなんぞや？ていうのは他記事に任せるとして、動くプログラムを作る方に専念します。
さっくり説明するとGoogleが開発した通信プロトコルでProtocolBuffersというやり方を用いて高速通信を可能にしたものです。一回のやりとりで４MBまでという制約があります。
XMLやJSONのようなスキーマ言語で".proto"という拡張子のファイルに、事前にサーバー側とクライアント側でやりとりするデータの形式を決めておきます。
また、C++、C#、Python、Java、NodeJS、PHP、GO、などなど様々な言語に対応しているので使い勝手がかなりいいです。
これ以上の説明は他記事に任せるとして（本人もざっくりとしかわかってない）、実際に動くプログラムを見ていきましょう。
今回はサーバー側もクライアント側もPython3.7で動作させます。
なぜPythonかって？環境構築が楽だったからです。~~ただそれだけ~~。

また、今回のプロジェクトはGithubにあげてあるので、何か困ったらそちらも参考あそばせ。
[https://github.com/Iwanaka/Python_Grpc_VideoStream_Sample](https://github.com/Iwanaka/Python_Grpc_VideoStream_Sample)


##protoファイルとgen.pyの作成
```protobuf:Datas.proto
syntax = "proto3";

// リクエストデータ
message Request{
	string msg = 1;
}

// リプライデータ
message Reply{
	bytes datas = 1;
}

// サーバー
service MainServer{
	rpc getStream (stream Request) returns (stream Reply) {}
}
```

```python:gen.py
# Grpcモジュールのインポート
from grpc.tools import protoc

# 生成オプション
protoc.main(
	(
		'',
		'-I.',
		'--python_out=.', #書き出し先指定
		'--grpc_python_out=.', #書き出し先指定
		'./Datas.proto' #書き出し元のファイル指定
	)
)
```

Datas.protoが、Grpcを用いてやりとりするためのデータ型を決める、いわば通信一覧表になります。
そして、gen.pyが、そのprotoファイルを用いてPython用にモジュールを作り出すための指示書になっています。


##パッケージをインストールしてgenファイルのコンパイルする
'grpcio'と'grpcio-tools'というパッケージが必要な為、まだインストールしていない方は、

```bash
$ pip install grpcio
$ pip install grpcio-tools
```

でパッケージをインストールしちゃいましょう。
そして、先ほど作ったファイルを使って、

```bash
$ python gen.py
```

して、エラーなく２つのDatas_pb2_grpc.pyとDatas_pb2.pyが書き出されれば正しい挙動です。
この生成された2つのファイルが、自分のプロジェクトで直接使うことになるファイルです。(主にDatas_pb2_grpc.pyの方)。


##サーバー、クライアントのスクリプト作成
```python:Server.py
# カメラ映像を接続されたクライアントに送信する

#============================================================
# import packages
#============================================================
from concurrent import futures
import grpc
import Datas_pb2
import Datas_pb2_grpc
import time
import cv2
import base64
import sys

#============================================================
# property
#============================================================
# カメラを設定
cap = cv2.VideoCapture(0)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)

captureBuffer = None


#============================================================
# class
#============================================================
# サーバークラス
class Greeter(Datas_pb2_grpc.MainServerServicer):

	#==========
	def __init__(self):
		pass

	#==========
	def getStream(self, request_iterator, context):

		for req in request_iterator:

			# リクエストメッセージを表示
			print("request message = ", req.msg)

			while True:

				# グレースケールに変換
				gray = cv2.cvtColor(captureBuffer, cv2.COLOR_BGR2GRAY)

				# jpgとしてデータをエンコード
				ret, buf = cv2.imencode('.jpg', gray)
				if ret != 1:
					return
				
				# 画像を文字列などで扱いたい場合はbase64でエンコード
				# b64e = base64.b64encode(buf)
				#print("base64 encode size : ", sys.getsizeof(b64e))

				# データを送信
				yield Datas_pb2.Reply(datas = buf.tobytes())

				# 60FPSに設定
				time.sleep(1/ 60)

#============================================================
# functions
#============================================================
def serve():

	# サーバーを生成
	server = grpc.server(futures.ThreadPoolExecutor(max_workers = 10))
	Datas_pb2_grpc.add_MainServerServicer_to_server(Greeter(), server)

	# ポートを設定
	server.add_insecure_port('[::]:50051')

	# 動作開始
	server.start()

	print('server start')

	while True:
		try:
			# カメラ映像から読み込み
			ret, frame = cap.read()
			if ret != 1:
				continue

			global captureBuffer
			captureBuffer = frame

			# 確認用ウィンドウ表示
			cv2.imshow('Capture Image', captureBuffer)

			# ESCキーで抜ける
			k = cv2.waitKey(1)
			if k == 27:
				server.stop(0)
				break

			time.sleep(0)

		except KeyboardInterrupt:
			server.stop(0)

#============================================================
# main
#============================================================
if __name__ == '__main__':
	serve()

#============================================================
# after the App exit
#============================================================
cap.release()
cv2.destroyAllWindows()
```


```python:Client.py
# サーバーから送信されるカメラ映像を表示する

#============================================================
# import packages
#============================================================
from concurrent import futures
import time
import cv2
import grpc
import base64
import numpy as np
import Datas_pb2
import Datas_pb2_grpc
import sys

#============================================================
# class
#============================================================
# ***

#============================================================
# property
#============================================================
# ***

#============================================================
# functions
#============================================================
def run():

	# サーバーの宛先
	channel = grpc.insecure_channel('127.0.0.1:50051')
	stub = Datas_pb2_grpc.MainServerStub(channel)
	
	try:

		# リクエストデータを作成
		message = []
		message.append(Datas_pb2.Request(msg = 'give me the stream!!'))
		responses = stub.getStream(iter(message))

		for res in responses:
			# print(res.datas)

			# 画像を文字列などで扱いたい場合
			# b64d = base64.b64decode(res.datas)

			# バッファを作成
			dBuf = np.frombuffer(res.datas, dtype = np.uint8)

			# 作成したバッファにデータを入れる
			dst = cv2.imdecode(dBuf, cv2.IMREAD_COLOR)

			# 確認用Window
			cv2.imshow('Capture Image', dst)
			
			# ESCキーで抜ける
			k = cv2.waitKey(1)
			if k == 27:
				break

	except grpc.RpcError as e:
		print(e.details())
		#break

#============================================================
# Awake
#============================================================
# ***

#============================================================
# main
#============================================================
if __name__ == '__main__':
	run()

#============================================================
# after the App exit
#============================================================
cv2.destroyAllWindows()
```

##実行

```bash
$ python Server.py
$ python Clinet.py
```

で順番に起動すると、映像がストリームで来ていると思います。当然Webカメラが繋がっていないと動かないのでご注意を。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">[Python]Grpcでカメラ映像をストリームで共有する<a href="https://t.co/lm0SrgosLE">https://t.co/lm0SrgosLE</a> <a href="https://t.co/C7wuCJDNs9">pic.twitter.com/C7wuCJDNs9</a></p>&mdash; iwax (@iwax51101141) <a href="https://twitter.com/iwax51101141/status/1180825052675072000?ref_src=twsrc%5Etfw">October 6, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

##お疲れさまでした
はい、Grpc便利ですね。４MBという結構大きな容量までいけるのであらゆるところで活躍しそうです。
今回はWebカメラの映像を共有するというサンプルを用いました。
「いやいや、だったら普通に産業用とかのIPカメラ使ったほうが高fpsだし解像度高いじゃん」、と思われた方、その通りです。でもそれは産業用カメラという高価なものを購入できる前提のお話ですね。今回のサンプルを用いれば、ラズパイにサーバーとなるカメラを持たせるとして、Webカメラとラズパイ本体だけで考えれば一万円以内で済みます。
もちろん、中fps、中解像度でニーズに応えられるなら、ですが。
既にある高機能高価格のものを買えば早く済む話であっても、金銭面等のある程度制約のある中でどうやってシステムを動かすかを考える方のも、以外と楽しかったりするものです。
高解像度な映像送る場合は当然4MBで済まないので、その場合は素直にSpoutなりNDIなりを使うのがいいでしょう。