Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
55
Help us understand the problem. What is going on with this article?
@iwatake2222

Google Cloud AutoML Visionによる物体検出モデルの開発(とCoral Dev Boardへのデプロイ)

More than 1 year has passed since last update.

この記事について

やること

  • Google Cloud Platfrm (GCP) で提供されているAutoML Visionを使用して、物体検出モデルを作ります
    • 各種類のビール(発泡酒)が、画像上のどの位置にあるかを検知します
  • 作成したモデルを、Edge TPU用に変換し、Coral Dev Board上で動かそうとしました (⇒ 失敗?)
    • 最終的に、カメラに写っている画像上でビールを検出したかったです

overview.jpg

前提知識

基本的な流れは、前回の識別モデル作成 (https://qiita.com/iwatake2222/items/f41946cbfa9cdd998185 )と同じです。
先にこちらの記事を読んでおくとよろしいかと思います。

環境

  • モデル開発、ホストPC
    • Windows 10 (ブラウザが使えれば何でもいいはず)
    • Google Chrome (FireFoxだと、ブラウザ上で行うアノテーションツールがうまく動きませんでした)
  • Coral Dev Board (ターゲット環境) <- モデルを作るだけなら不要

    mendel@orange-eft:~$ cat /etc/os-release
    PRETTY_NAME="Mendel GNU/Linux 3 (Chef)"
    NAME="Mendel GNU/Linux"
    VERSION_ID="3"
    VERSION="3 (chef)"
    ID=mendel
    ID_LIKE=debian
    HOME_URL="https://coral.withgoogle.com/"
    SUPPORT_URL="https://coral.withgoogle.com/"
    BUG_REPORT_URL="https://coral.withgoogle.com/"
    

検知モデルの開発

画像集め

検知する対象となる画像を集めます。
今回は、ビール(発泡酒)を検知します。そのため、ビールがたくさん写った画像が必要になります。
アノテーション数として、最低でもそれぞれ100個必要です。理想的には数千枚は欲しいです。

ありもののデータセットではなく、自分でデータ収集をするのは大変です。サンプルの収集に加え、撮影にも色々な工夫が必要でした。
今回は1万円近い私財を費やし、ビール漬けの生活になるという健康リスクを冒して、何とかデータを集めました。

以下、画像集めの歴史 ネタ画像
Clipboard01.jpg

学習用画像の選び方、撮影方法

こればっかりはノウハウ、経験、勘が求められるところですが、今回は以下の点に注意して学習用画像を撮影しました。
重要なのは、とにかく色々なバリエーションの画像を用意することです。以下は僕がこうした、というだけで効果があったもの、効果がなかったもの、逆効果だったものが混じっていると思います。

  • 色々な角度から撮影
  • 缶そのものを色々な角度に配置
    • 反転や時計方向の回転は、おそらく学習時に自動的に水増し(Data Augmentation)されると思います。が、円錐方向への回転は実際に撮影が必要です
  • 色々な明るさ、色合いで撮影
    • 色々な照明に加えて、太陽光の反射なども重要
    • 今回は実施できず
  • 色々なカメラで撮影
    • カメラによって色味なども変わると思います(色味のData Augmentationがされるかは不明)。あと、どれだけ影響あるか分かりませんが、レンズ歪の影響などもカメラによって異なりますので、出来るだけ色々な種別が必要です
    • 今回は実施できず
  • 色々なサイズで撮影
    • 恐らくサイズに関してもData Augmentationされると思います。が、どのようにされるかが不明。また、そもそも学習データがどのように使われるかが不明だったため、色々なサイズで撮影しました。
    • 例えば、1000x1000[px]内に100x100[px]のビール缶がある画像の場合、検知するときも100x100[px]前後の缶を検知するのか、モデルサイズの1/10前後の缶を検知するのか? が不明でした(おそらく前者だと思いますが)。そのため、念のため色々なサイズで撮影しました。
  • 背景も色々変える
    • 背景が単調だと、実使用時に使い物にならなくなる可能性があります(背景が無地上のビールしか検知できなくなるリスク)。そのため、家にある適当なものをしたに敷いて撮影しました
  • ネガティブデータ
    • ビールの検知がしたいからと、ビールばっかりが写った画像だけを使うのも問題です。「ビールではない」ものを学習できなくなります。
    • 通常は、画像内のビール以外の場所がネガティブデータ(ビールではない画像)として使われます。理想的には、ビールが自然体に写っている画像を用意すべきでしたが、今回はアノテーション作業を簡単にするために、ビールだけが大量に写った画像が多くなってしまいました。そのため、急遽ビールが一切写っていない適当な画像をフリー素材サイトから持ってきて、ネガティブデータとして使いました。

b000.jpg
b001.jpg
b002.jpg
b003.jpg

  • 反省点
    • 横着して一枚の画像にビールを詰めすぎてしまいました
    • 後でアノテーション作業をする際に、四角形の枠を付けるのですが、間隔が狭いのでうまくつけられませんでした
    • できるだけ色々な角度のビールを撮影したかったので、ビールはランダムに置いたつもりなのですが、人間の性なのかピッタリ綺麗に置かれてしまい似たような画像が多くなってしまいました
    • 単調ではない背景を撮りたかったので、手元にあったビールの紙ケースを敷きました。が、そのパッケージにビールが写っているというアホらしいミスに撮影後に気付きました。その部分はペイントで削除しました。これが、どれだけ悪影響が出るかは不明です。
    • 画像内に、「ビールではない」部分が少ないことに撮影後に気付きました。これではネガティブデータが少なく、誤検知が増加するリスクがあります。そのため、ビールが全く写っていない画像をフリー素材サイトから用意しました。が、本当にネガティブデータとして使ってくれているかは不明です。

今回はトータル197枚の画像を用意しました。枚数としては少ないのですが、1枚の画像の中に多くのビールが含まれているので、1ラベル当たり数百個のタグは付けられそうです。

データセットの用意

撮影した画像を基にGCP上でデータセットを作ります。

データセットの作成

000.jpg

https://console.cloud.google.com/home/ の左上の三本線をクリックして、Vision を選びます。

001.jpg

AutoML Visionのホーム画面です。今回は検知モデルを作るので、「オブジェクト検出」の「開始」をクリックします。

002.jpg

一般的なソフトウェア開発ですと、「プロジェクト」や「ソリューション」という単位で管理されますが、AutoMLでは「Dataset」として管理されるようです。
上部の「+ 新しいデータセット」をクリックして、新しいデータセットを作成します。
名前は適当にbeerとしました。モデルの種類として「オブジェクト検出」を選んでください。

003.jpg
004.jpg

データセット作成画面です。

画像の追加方法として、以下の2つがあります

  1. PC上の画像をアップロードする
  2. Google Cloud Storage上のCSVファイルを参照する

既存のデータセットを使用する際には2のCSVファイル使用の方が便利だと思うのですが、今回は何が起きているかを理解するためにも1の画像をアップロードする方式を使います。

「パソコンから画像をアップロード」を選び、「ファイルを選択」をクリックしてPC上の画像を複数選択します。
アップロード先はGoogle Cloud Storageになります。適当なディレクトリ(ここではimg_beer)を作り、そこを指定します。最後に「続行」をクリックすると画像のアップロードが始まります。
完了するまでしばらく待ちます。

アノテーション作業

前回の識別と違い、物体検知では、物体が画面上のどこにあるか、が出力になります。GCP上でアノテーション作業と呼ばれる、画面上のどこにどういうビールがあるか、を抽出する作業を行います。
(このアノテーション作業はローカルPC上でやっても大丈夫ですし、実際はその方が効率的です。ただ、その場合はアノテーションツールの用意、データの変換なども必要となるため、今回は簡単にできるGCP上で作業します)

006.jpg

この画面で、このデータセット内の画像を管理します。
画像の追加、削除、アノテーションが行えます。

007.jpg

今回は、ビール(発泡酒)として全9種類を検知します。まずはそのためのラベルを追加します。
左下の新規ラベルを追加 をクリックして以下のラベルを追加します

  • Hon_Kirin
  • SAPPORO
  • ClearAsahi
  • AsahiSuperDry
  • Nodogoshi
  • PREMIUM_MALTS
  • KIRIN_ICHI
  • YEBISU
  • Kinmugi

008.jpg

その後、適当な画像を一枚クリックします

009.jpg

すると、上記のようにアノテーション(タグ付け)を行う画面になります。

010.jpg

ビールの輪郭に合わせてマウスをドラッグアンドドロップします。その後、そのビールの種類を選択します。

011.jpg

画像内の全ビールにタグが付け終わったら、「保存」して次の画像へ移ります。
これをアップロードした全画像に対して行います。

簡単に書いていますが、この作業が最も時間と根気がいるところです
Google Cloudではこのアノテーション作業を手動で代行してくれるサービスも提供しているらしいです。

注意

  • 枠のサイズが小さすぎたりすると、保存時にエラーが出ます。そして、その画像内の問題の無い枠の情報も全て消えてしまいました。
    • フィードバックで報告済み
  • タグ付け、保存後に再度開くと、画面下部の枠がある位置でクロップされたようになってしまうバグがありました。作業完了後は必ず確認するようにした方がよさそうです。
    • フィードバックで報告済み
  • 画面の切り替わりが遅くストレスを感じると思います。そこは改善希望です。

012.jpg

元の画面に戻ると、サムネイル上でも、どのようにタグが付いているかを確認できます。ラベル別に確認することもできます。
全作業が完了したらこの画面で確認するのが良いと思います。
また、画面左にある各ラベルの数字は、タグ数ではなく画像枚数です。実際の学習に使われるタグの数は「ラベルの統計データ」をクリックすることで確認できます。今回は各ラベルについて約300枚用意できているので、個人の仕事としてはまずまずといったところだと思います。

(ちなみに、撮影用ビールを買い足すときに「のどごし生」と間違えて「金麦」の色違いを買ってしまいました。そのため、のどごし生の個数は非常に少なく、逆に金麦は他のものに比べて2倍の数となっています)

学習

学習

013.jpg

トレーニング のタブを選び、学習用の画面に移ります。
新しいモデルをトレーニング をクリックして学習を開始します。

モデルの名前は適当にbeer_detection としました。今回、最終目標はエッジデバイス上で動かすことなので、モデルの種類はEdge を選び、いったん続行 をクリックします。

014.jpg

次に、作成するモデルの最適化オプションを選びます。
ひとまず最初はBest tradeoff モデルを作ります。

015.jpg

最後に使用するコンピュータリソース量のバジェット(上限)を設定します。
20コンピューティング時間まで無料なのですが、面倒なのでデフォルトの24のままにしました。24に達する前でも学習が完了したら良い感じに切り上げてくれます。

全ての設定が完了したら、最後にトレーニングを開始 をクリックします。
後は学習完了を待ちます。
学習が完了するとメールが届きます。

コスト

今回は、学習に約4~5時間かかりました。
実際に使われたコンピュータ時間(ノード時間?) は分からないのですがプロモーションクレジット(¥29,041)から約6,000~7,000円引かれていました。

ちなみに、プロモーション枠の金額は、「アカウントあたり 15 ノード時間の無料トレーニング」で「1 時間あたり USD 18.00」なので、15 x 18 x 110円/USD = 約29,041円 だと思います。

あれ、でも https://cloud.google.com/vision/automl/pricing?hl=ja には「アカウントあたり 15 ノード時間の無料トレーニング」と書かれていますが、学習時に表示される画面には「20コンピューティング時間が無料」と書かれています。何が違うんだろう。。

性能

016.jpg

学習完了後、評価 タブに行くとモデルの性能が表示されます。 (上記キャプチャと本記事で記載のモデル名は異なります)
しきい値として0.5を設定した場合(「検知スコアが0.5以上のものを○○のビールとする」とした場合)、再現率(ようは検知率)は99.27%、適合率(検知結果の中での正解の割合。ようは精度)は91.92%とかなり良い結果を得られました。
が、これは今回用意したデータセット内のテストデータを用いた評価結果です。色々工夫したつもりですが、やはり撮影環境が限られているので、似たような画像ばかりになってしまっています。そのため、実際よりかは良い結果になっていると思われます。実環境では精度はもっと悪くなると思われます。
また、やはりアノテーション数の少ない「のどごし生」の性能は悪くなりました。

モデル出力

テストと使用 タブに移ります

作成したモデルをオンライン上で試す

017.jpg
018.jpg

識別モデルでは何もしないでも画像をアップロードすることで、作成したモデルでの識別処理を試せました。
が、検知モデルではデプロイする必要があるようです。

先ほど作成したモデルを選び(上記キャプチャでは名前が異なります)、モデルをデプロイ をクリックします。その後、デプロイするノード数を設定できますが、試すだけなので1を設定してデプロイ をクリックします。

5分くらいで完了します。

019.jpg

デプロイ完了後、UPLOAD IMAGES をクリックして、試したい画像をアップロードします。

020.jpg
021.jpg

アップロード後、数秒で、このような検知結果が表示されます。

  • 〇: 色が似ている、YEBISUとプレモル、金麦(赤)と本麒麟もしっかり区別できていました
  • 〇: 金麦は青い缶と赤い缶を同じラベルで学習したが、どちらも検知できていました
  • 〇: 手で持っていても、検知できました
  • 〇: 物体の一部が見えなくても(オクルージョン)、検知できました
  • 〇: 多少斜めになっていても検知できました
  • ×: 学習数の少ない見た目(正面を向いていない缶、ぼけている缶)は検知できませんでした
  • ×: 奥の方にある缶は検知できませんでした

注意

オンライン上でのデプロイをしてると、その分料金がかかります。Object Detection Online Prediction 用にも個別に約7,800円の無料枠(プロモーション)が付与されますので、そこから引かれます。
が、実際に検知処理を動かさなくてもデプロイしているだけで時間に応じて料金がかかります。個別のプロモーション枠が無くなったら、GCPの$300の無料トライアル枠から自動的に引かれてしまいます。
使用しないときはデプロイメントを削除 することをお勧めします。

エッジデバイス用にモデルを出力する

022.jpg

作成したモデルを、エッジデバイス (Android/iOSスマホ)で動かすためにtfliteモデルを出力します。(2019年10月05日現在、EdgeTPU用に出力するオプションはありません)
テストと使用 タブ下部の、TFLite をクリックします。BROWSE でCloud Storage上の適当な場所を指定して、EXPORT します。その後、OPEN IN GCS をクリックすると、モデルの出力先が開かれます。

023.jpg

生成物は、tfliteモデル(.tflite)、ラベル用テキストファイル(.txt)、モデルのメタ情報を記載したファイル(.json)の3つです。

モデルをCoral Dev Board上で使う(未完)

色々と問題があり、まだ未完成です。

tfliteモデルをEdge TPU用にコンパイルする

作成されたモデルは、量子化はされているっぽいのですが、通常のtfliteモデルです。これをEdge TPU用にコンパイルします。
が、コンパイル時にabortエラーが出てしまいました。まあ、そもそもコンパイルできるのであればGCPでモデル出力する際に最初からEdge TPU用が選べるはずなので、それがないということはまだサポートしていないのだと思います(識別モデルは最初からEdge TPU用モデルを出力できます)。

~/Desktop/win_share$ edgetpu_compiler model-export_iod_tflite-beer_20190922094112-2019-09-22T15_03_35.572Z_model.tflite 
Edge TPU Compiler version 2.0.267685300

Internal compiler error. Aborting! 

その後色々試したら、モデル作成時のモデル最適化のオプション で、Faster predictions を選ぶことでコンパイル成功しました。ちなみに、Higher accuracy だと同様にabortエラーが出ます。

Coral Dev Board上でモデルを動かす

まずは、engine.detect_with_image で画像入力で簡単に試してみました。ちなみに、Edge TPU用に変換前のモデルもCPU実行にはなりますが動かすことが出来ます。
結果、

  • Best trade-off モデル(CPU): 一応検知は出来ている? が、オンライン版と同じ画像を食わせても精度が非常に悪いように見える。推論時間は数350msec
  • Faster predictions モデル(CPU): 検知が全くできず誤検知だらけ。(誤検知というか、画面の色々な場所を検知してノイズのようになる)。推論時間は130msec程度
  • Faster predictions モデル(EdgeTPU): 検知が全くできず誤検知だらけ。(誤検知というか、画面の色々な場所を検知してノイズのようになる)。しかも同じ画像を食わせても実行するたびに結果が異なる。推論時間は30msec程度

と、微妙な結果になりました。Best trade-off モデルは一応動いているので、やり方を間違えているのではなさそうです。また、出力Tensorの生値を見たら、フォーマットとしてはいずれもそれっぽい値が入っていたので、後処理が違う、というわけでもなさそうです。

ちなみに、Edge TPUライブラリではなく、TensorFlowのtf.lite.Interpreter で処理しても同じ結果になりました。

Android/iOS用のモデルなので、使い方が違ったり、何か前処理が必要なのかもしれません。

サンプルコード

モデルは正しく動いていないっぽいのですが、確認に使用したコードを貼り付けておきます。
恐らくコードには問題はないはずです。

一番シンプルなコード

最新のEdge TPUインストーラだとobject_detection.py が無くなっているようなので、簡単に試せるスクリプトを用意しました。

test_detection.py
from edgetpu.detection.engine import DetectionEngine
from PIL import Image
from PIL import ImageDraw

MODEL_FILENAME = "model_tradeoff.tflite"
# MODEL_FILENAME = "model_faster.tflite"

engine = DetectionEngine(MODEL_FILENAME)
img = Image.open("input.jpg")
draw = ImageDraw.Draw(img)
for result in engine.detect_with_image(img, threshold=0.1, keep_aspect_ratio=False, relative_coord=False, top_k=10):
    box = result.bounding_box.flatten().tolist()
    print("label: " + str(result.label_id))
    print("score: " + str(result.score))
    print("box: " + str(box))
    draw.rectangle(box, outline='red')
img.save("output.jpg")

EdgeTPUライブラリ使用

精度確認用にカメラではなく画像読み込みにしています。モデルが正しく動いたらカメラ読み込みに切り替える予定

beer_detection.py
import time
import cv2
from PIL import Image
from edgetpu.detection.engine import DetectionEngine
from edgetpu.utils import dataset_utils

MODEL_FILENAME = "model_tradeoff.tflite"
# MODEL_FILENAME = "model_faster.tflite"
# MODEL_FILENAME = "model_faster_edgetpu.tflite"

LABEL_FILENAME = "dict.txt"

def cv2pil(image_cv):
    image_cv = cv2.cvtColor(image_cv, cv2.COLOR_BGR2RGB)
    image_pil = Image.fromarray(image_cv)
    image_pil = image_pil.convert('RGB')
    return image_pil


if __name__ == '__main__':
    # Load model and prepare TPU engine
    engine = DetectionEngine(MODEL_FILENAME)
    labels = dataset_utils.read_label_file(LABEL_FILENAME)
    # cap = cv2.VideoCapture(0)
    # cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
    # cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
    while True:
        start = time.time()
        # capture image
        # ret, img_org = cap.read()
        img_org = cv2.imread("input.jpg")
        # img_org = cv2.resize(img_org, None, fx=0.3, fy=0.3)
        pil_img = cv2pil(img_org)
        # pil_img = Image.open("input.jpg")

        # Run inference
        ans = engine.detect_with_image(pil_img, threshold=0.2, keep_aspect_ratio=False, relative_coord=True, top_k=5)

        # Retrieve results
        print ('-----------------------------------------')
        if ans:
            for obj in ans:
                box = obj.bounding_box.flatten().tolist()
                print(labels[obj.label_id] + "({0:.3f}) ".format(obj.score)
                 + "({0:.3f}, ".format(box[0]) + "{0:.3f}, ".format(box[1]) + "{0:.3f}, ".format(box[2]) + "{0:.3f})".format(box[3]))
                x0 = int(box[0] * img_org.shape[1])
                y0 = int(box[1] * img_org.shape[0])
                x1 = int(box[2] * img_org.shape[1])
                y1 = int(box[3] * img_org.shape[0])
                cv2.rectangle(img_org, (x0, y0), (x1, y1), (255, 0, 0), 2)
                cv2.rectangle(img_org, (x0, y0), (x0 + 100, y0 - 30), (255, 0, 0), -1)
                cv2.putText(img_org,
                        str(labels[obj.label_id]),
                        (x0, y0),
                        cv2.FONT_HERSHEY_SIMPLEX,
                        1,
                        (255, 255, 255),
                        2)

        # Draw the result
        cv2.imshow('image', img_org)
        if cv2.waitKey(1) & 0xFF == ord("q"):
            break

        elapsed_time = time.time() - start
        print('inference time = ', "{0:.2f}".format(engine.get_inference_time()) , '[msec]')
        print('total time = ', "{0:.2f}".format(elapsed_time * 1000), '[msec] (', "{0:.2f}".format(1 / elapsed_time), ' fps)')

    cv2.destroyAllWindows()

TensorFlow使用

beer_detection_tf_opencv.py
# -*- coding: utf-8 -*-
import cv2
import tensorflow as tf
import numpy as np
from PIL import Image

MODEL_FILENAME = "model_tradeoff.tflite"
# MODEL_FILENAME = "model_faster.tflite"
# MODEL_FILENAME = "model_faster_edgetpu.tflite"

if __name__ == '__main__':
    # prepara input image
    img = cv2.imread('input.jpg')
    img = cv2.resize(img, (320,320))
    # img = cv2.resize(img, (192,192))
    # cv2.imshow('image', img)
    img = img.reshape(1, img.shape[0], img.shape[1], img.shape[2])
    img = img.astype(np.uint8)

    # load model
    interpreter = tf.lite.Interpreter(model_path=MODEL_FILENAME)
    interpreter.allocate_tensors()
    input_details = interpreter.get_input_details()
    output_details = interpreter.get_output_details()

    # set input tensor
    interpreter.set_tensor(input_details[0]['index'], img)

    # run
    interpreter.invoke()

    # get outpu tensor
    boxes = interpreter.get_tensor(output_details[0]['index'])
    labels = interpreter.get_tensor(output_details[1]['index'])
    scores = interpreter.get_tensor(output_details[2]['index'])
    num = interpreter.get_tensor(output_details[3]['index'])
    print(boxes)
    print(labels)
    print(scores)
    print(num)
beer_detection_tf_pil.py
import tensorflow as tf
import numpy as np
from PIL import Image
from PIL import ImageDraw

MODEL_FILENAME = "model_tradeoff.tflite"
# MODEL_FILENAME = "model_faster.tflite"

if __name__ == '__main__':
    # prepara input image
    img_org = Image.open("input.jpg")
    draw = ImageDraw.Draw(img_org)
    img = img_org.resize((320, 320))
    # img = img_org.resize((192, 192))
    img = np.asarray(img, dtype=np.uint8)
    img = img.reshape(1, img.shape[0], img.shape[1], img.shape[2])

    # load model
    interpreter = tf.lite.Interpreter(model_path=MODEL_FILENAME)
    interpreter.allocate_tensors()
    input_details = interpreter.get_input_details()
    output_details = interpreter.get_output_details()

    # set input tensor
    interpreter.set_tensor(input_details[0]['index'], img)

    # run
    interpreter.invoke()

    # get outpu tensor
    boxes = interpreter.get_tensor(output_details[0]['index'])
    labels = interpreter.get_tensor(output_details[1]['index'])
    scores = interpreter.get_tensor(output_details[2]['index'])
    num = interpreter.get_tensor(output_details[3]['index'])
    print(boxes)
    print(labels)
    print(scores)
    print(num)

    for box in boxes[0]:
        y1 = max(0.0, box[0]) * img_org.size[1]
        x1 = max(0.0, box[1]) * img_org.size[0]
        y2 = min(1.0, box[2]) * img_org.size[1]
        x2 = min(1.0, box[3]) * img_org.size[0]
        bounding_box = np.array([[x1, y1], [x2, y2]])
        draw.rectangle(bounding_box.flatten().tolist(), outline='red')
    img_org.save("output.jpg")

結論

しばらくは保留。
どなたか情報ありましたら教えてください!!

おわりに

前回の識別モデルに続いて、検知モデルを作る方法をまとめました。
アノテーションの方法が異なるだけで、流れは大体同じでした。
が、学習時間(∝お金) が大きくかかるので、ご注意ください。(識別モデル作成は1回数百円。検知モデル作成は1回数千円)
もちろん、数回試すだけであれば全て無料枠内で可能です。

55
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
iwatake2222
Embedded software engineer

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
55
Help us understand the problem. What is going on with this article?