HoloLens2 × Azure Cognitive Services（CustomVisionで物体検出）

# はじめに
[HoloLensアドベントカレンダー2020](https://qiita.com/advent-calendar/2020/hololens)の19日目の記事です。
[前回](https://qiita.com/SatoshiGachiFujimoto/items/9ff6be18b1df70e37b88)は「文字を読んで」と言うと、画像からテキスト抽出し読み上げました。今回は、Custom Visionを用いて小銭を検出し、いくらか答えてくれるようにしました。「ヨンシル、これいくら？」

# 開発環境
- Azure 
    - Custom Vision
    - Speech SDK 1.14.0
- Unity 2019.4.1f1
- MRTK 2.5.1
- Windows 10 PC
- HoloLens2 

# 導入
1．[前回](https://qiita.com/SatoshiGachiFujimoto/items/9ff6be18b1df70e37b88)の記事まで終わらせてください。

2．まずは、Custom Visionで小銭を学習します。手元にあった1円、10円、100円のみを学習します。

3．[Azureポータル](https://portal.azure.com/)から「Custom Vision」を作成。キーをメモっておきます。

| | |
|:--|:--|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63863/0238cf38-bc71-59fe-b5bf-c76b827ef2b9.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63863/6ecaefa1-83c8-b57b-a769-1cf65b3fb0d4.png)|

4．[Custom Vision](https://www.customvision.ai/)にサインインし、新しくプロジェクトを作成します。プロジェクトタイプはObject Detection、学習したモデルをエクスポートしてエッジ推論もできるようにGeneral(compact)、Export CapabilitiesをBasic platformsに設定します。

| | |
|:--|:--|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63863/003511b9-f263-f164-e04d-ff1c97a496c4.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63863/df391d73-7a5f-d6c2-8350-65168a110f8a.png)|

5．小銭を撮影し、学習データをアップロード、タグを付けます。

| | |
|:--|:--|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63863/8b2f7bb0-9212-d32a-2753-dc187a2412f7.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63863/f94fcd38-2a4b-f72d-e4b2-5f847f20943c.png)|

6．Advanced Trainingで1時間学習させました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63863/0de0f91d-0a51-f64a-a0b9-7c2c38e6454c.png)

7．学習した結果がこちらです。作ったモデルはPublishし、画像ファイルから推論するエンドポイントをメモっておきます。

| | |
|:--|:--|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63863/8295c7e8-dea7-ff7d-5e53-d88dc1dba620.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63863/678d1ce1-28b9-db57-2658-03d2b70b9315.png)|

8．Unityのプロジェクトはこんな感じ。前回のMySpeechRecognizerのActionワードに「いくら」を追加します。新しく「TapToCaptureObjectDetection.cs」をAdd Componentし、「いくら」を音声認識すると、画像をキャプチャし、物体検出、読み上げという流れになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63863/c3d5ef53-2e6e-861e-e01d-37ebc860eb4e.png)

9．MySpeechRecognizer.csのUpdate関数を次のように編集し、「いくら」を音声認識するとTapToCaptureObjectDetection.csのAirTap関数を実行します。

~~~MySpeechRecognizer.cs
    async void Update()
    {
        if (recognizedString != "")
        {
            // Debug.Log(recognizedString);
            if (action){
                foreach(string ActionWord in ActionWords){
                    if (recognizedString.ToLower().Contains(ActionWord.ToLower()))
                    {
                        Debug.Log("Action");
                        if(ActionWord == "何が見える"){
                            Debug.Log("Analyze Image");
                            this.GetComponent<TapToCaptureAnalyzeAPI>().AirTap();
                        }else if(ActionWord == "文字を読んで"){
                            Debug.Log("Read");
                            this.GetComponent<TapToCaptureReadAPI>().AirTap();
                        }else if(ActionWord == "いくら"){
                            Debug.Log("Custom Vision");
                            this.GetComponent<TapToCaptureObjectDetection>().AirTap();
                        }
                        action = false;
                    }
                }
            }else if (recognizedString.ToLower().Contains(WakeWord.ToLower()))
            {
                Debug.Log("Wake");
                await this.GetComponent<TapToCaptureAnalyzeAPI>().SynthesizeAudioAsync("はい");
                action = true;
            }
        }
    }
~~~

9．「TapToCaptureObjectDetection.cs」スクリプトはこちらになります。

~~~TapToCaptureObjectDetection.cs
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System;
using UnityEngine;
using Microsoft.MixedReality.Toolkit.Utilities;
using System.Threading.Tasks;
using OpenCVForUnity.CoreModule;
using OpenCVForUnity.UnityUtils;
using OpenCVForUnity.ImgprocModule;

// SpeechSDK ここから
using System.IO;
using System.Text;
using Microsoft.CognitiveServices.Speech;
using Microsoft.CognitiveServices.Speech.Audio;
// SpeechSDK ここまで

public class TapToCaptureObjectDetection : MonoBehaviour
{
    // CustomVision ここから
    private string cv_endpoint = "<Insert Your Prediction URL>";
    private string cv_subscription_key = "<Insert Your Key>";

    [System.Serializable]
    public class CustomVisionResult
    {
        public string id;
        public string project;
        public string iteration;
        public string created;
        public Predictions[] predictions;

        // https://baba-s.hatenablog.com/entry/2016/01/20/100000
        public override string ToString()
        {
            return JsonUtility.ToJson( this, true );
        }
    }

    [System.Serializable]
    public class Predictions
    {
        public float probability;
        public string tagId;
        public string tagName;
        public BoundingBox boundingBox;
    }

    [System.Serializable]
    public class BoundingBox
    {
        public float left;
        public float top;
        public float width;
        public float height;
    }

    // https://mathwords.net/iou
    public float CalculateIOU(BoundingBox box0, BoundingBox box1)
    {
        var x1 = Math.Max(box0.left, box1.left);
        var y1 = Math.Max(box0.top, box1.top);
        var x2 = Math.Min(box0.left + box0.width, box1.left + box1.width);
        var y2 = Math.Min(box0.top + box0.height, box1.top + box1.height);
        var w = Math.Max(0, x2 - x1);
        var h = Math.Max(0, y2 - y1);

        return w * h / ((box0.width * box0.height) + (box1.width * box1.height) - (w * h));
    }
    // Custom Vision ここまで

    // SpeechSDK ここから
    public AudioSource audioSource;

    public async Task SynthesizeAudioAsync(string text) 
    {
        var config = SpeechConfig.FromSubscription("YourSubscriptionKey", "YourServiceRegion");
        var synthesizer = new SpeechSynthesizer(config, null); // nullを省略するとPCのスピーカーから出力されるが、HoloLensでは出力されない。
        
        string ssml = "<speak version=\"1.0\" xmlns=\"https://www.w3.org/2001/10/synthesis\" xml:lang=\"ja-JP\"> <voice name=\"ja-JP-Ichiro\">" + text + "</voice> </speak>";

        // Starts speech synthesis, and returns after a single utterance is synthesized.
        // using (var result = synthesizer.SpeakTextAsync(text).Result)
        using (var result = synthesizer.SpeakSsmlAsync(ssml).Result)
        {
            // Checks result.
            if (result.Reason == ResultReason.SynthesizingAudioCompleted)
            {
                // Native playback is not supported on Unity yet (currently only supported on Windows/Linux Desktop).
                // Use the Unity API to play audio here as a short term solution.
                // Native playback support will be added in the future release.
                var sampleCount = result.AudioData.Length / 2;
                var audioData = new float[sampleCount];
                for (var i = 0; i < sampleCount; ++i)
                {
                    audioData[i] = (short)(result.AudioData[i * 2 + 1] << 8 | result.AudioData[i * 2]) / 32768.0F;
                }

                // The output audio format is 16K 16bit mono
                var audioClip = AudioClip.Create("SynthesizedAudio", sampleCount, 1, 16000, false);
                audioClip.SetData(audioData, 0);
                audioSource.clip = audioClip;
                audioSource.Play();

                // newMessage = "Speech synthesis succeeded!";
            }
            else if (result.Reason == ResultReason.Canceled)
            {
                var cancellation = SpeechSynthesisCancellationDetails.FromResult(result);
                // newMessage = $"CANCELED:\nReason=[{cancellation.Reason}]\nErrorDetails=[{cancellation.ErrorDetails}]\nDid you update the subscription info?";
            }
        }
    }
    // SpeechSDK ここまで

    public GameObject quad;
    UnityEngine.Windows.WebCam.PhotoCapture photoCaptureObject = null;
    Texture2D targetTexture = null;
    private bool waitingForCapture;

    void Start(){
        waitingForCapture = false;
    }
    public void AirTap()
    {
        if (waitingForCapture) return;
        waitingForCapture = true;

        Resolution cameraResolution = UnityEngine.Windows.WebCam.PhotoCapture.SupportedResolutions.OrderByDescending((res) => res.width * res.height).First();
        targetTexture = new Texture2D(cameraResolution.width, cameraResolution.height);

        // PhotoCapture オブジェクトを作成します
        UnityEngine.Windows.WebCam.PhotoCapture.CreateAsync(false, delegate (UnityEngine.Windows.WebCam.PhotoCapture captureObject) {
            photoCaptureObject = captureObject;
            UnityEngine.Windows.WebCam.CameraParameters cameraParameters = new UnityEngine.Windows.WebCam.CameraParameters();
            cameraParameters.hologramOpacity = 0.0f;
            cameraParameters.cameraResolutionWidth = cameraResolution.width;
            cameraParameters.cameraResolutionHeight = cameraResolution.height;
            cameraParameters.pixelFormat = UnityEngine.Windows.WebCam.CapturePixelFormat.BGRA32;

            // カメラをアクティベートします
            photoCaptureObject.StartPhotoModeAsync(cameraParameters, delegate (UnityEngine.Windows.WebCam.PhotoCapture.PhotoCaptureResult result) {
                // 写真を撮ります
                photoCaptureObject.TakePhotoAsync(OnCapturedPhotoToMemoryAsync);
            });
        });
    }

    async void OnCapturedPhotoToMemoryAsync(UnityEngine.Windows.WebCam.PhotoCapture.PhotoCaptureResult result, UnityEngine.Windows.WebCam.PhotoCaptureFrame photoCaptureFrame)
    {
        // ターゲットテクスチャに RAW 画像データをコピーします
        photoCaptureFrame.UploadImageDataToTexture(targetTexture);
        byte[] bodyData = targetTexture.EncodeToJPG();

        Response response = new Response();
        Dictionary<string, string> headers = new Dictionary<string, string>();
        headers.Add("Prediction-key", cv_subscription_key);

        try
        {
            string query = cv_endpoint;
            // headers.Add("Content-Type": "application/octet-stream");
            response = await Rest.PostAsync(query, bodyData, headers, -1, true);
        }
        catch (Exception e)
        {
            photoCaptureObject.StopPhotoModeAsync(OnStoppedPhotoMode);
            return;
        }

        if (!response.Successful)
        {
            photoCaptureObject.StopPhotoModeAsync(OnStoppedPhotoMode);
            return;
        }

        Debug.Log(response.ResponseCode);
        // Debug.Log(response.ResponseBody);
        
        CustomVisionResult results = JsonUtility.FromJson<CustomVisionResult>(response.ResponseBody);
        Debug.Log(results);
        int coin = 0;
        Mat imgMat = new Mat(targetTexture.height, targetTexture.width, CvType.CV_8UC4);
        Utils.texture2DToMat(targetTexture, imgMat);

        for(int i = 0; i < results.predictions.Length; i++){ // probabilityは降順
            if (results.predictions[i].probability > 0.8f){
                for (int j = i+1; j < results.predictions.Length; j++){
                    if(CalculateIOU(results.predictions[i].boundingBox, results.predictions[j].boundingBox) > 0.2f){ // だいぶ被ってたら消す
                        results.predictions[j].probability = 0.0f;
                    }
                }
                // Debug.Log(results.predictions[i].tagName);
                coin += Int32.Parse(results.predictions[i].tagName);
                Imgproc.putText(imgMat, results.predictions[i].tagName, new Point(results.predictions[i].boundingBox.left*targetTexture.width, results.predictions[i].boundingBox.top*targetTexture.height-10), Imgproc.FONT_HERSHEY_SIMPLEX, 2, new Scalar(255, 255, 0, 255), 4, Imgproc.LINE_AA, false);
                Imgproc.rectangle(imgMat, new Point(results.predictions[i].boundingBox.left*targetTexture.width, results.predictions[i].boundingBox.top*targetTexture.height), new Point(results.predictions[i].boundingBox.left*targetTexture.width + results.predictions[i].boundingBox.width*targetTexture.width, results.predictions[i].boundingBox.top*targetTexture.height + results.predictions[i].boundingBox.height*targetTexture.height), new Scalar(255, 255, 0, 255), 4);
            }
        }

        Texture2D texture = new Texture2D(imgMat.cols(), imgMat.rows(), TextureFormat.RGBA32, false);
        Utils.matToTexture2D(imgMat, texture);

        Renderer quadRenderer = quad.GetComponent<Renderer>() as Renderer;
        quadRenderer.material.SetTexture("_MainTex", texture);

        // SpeechSDK 追加分ここから
        if (coin == 0){
            await SynthesizeAudioAsync("すみません、わかりませんでした。"); // jp             
        }else{
            Debug.Log(coin.ToString()+"円です。");
            await SynthesizeAudioAsync(coin.ToString()+"円です。"); // jp             
        }
        // SpeechSDK 追加分ここまで

        // カメラを非アクティブにします
        photoCaptureObject.StopPhotoModeAsync(OnStoppedPhotoMode);
    }

    void OnStoppedPhotoMode(UnityEngine.Windows.WebCam.PhotoCapture.PhotoCaptureResult result)
    {
        // photo capture のリソースをシャットダウンします
        photoCaptureObject.Dispose();
        photoCaptureObject = null;
        waitingForCapture = false;
    }
}
~~~

10．エンドポイントとキーをメモっておいたものを貼りつけます。MRTKのRestライブラリを用いて、キャプチャした画像をPOSTします。

11．レスポンスは次のような形で返ってくるので、CustomVisionResultクラス、Predictionsクラス、BoundingBoxクラスを作成しました。

~~~json
{"id":"8498c190-caae-4dc0-b98f-55d95239ac8c","project":"2b7ff8c6-64d3-42d8-a9cf-df60a99eec38","iteration":"ea198606-c388-4ec7-99bf-b7badbfda81d","created":"2020-12-20T16:15:59.129Z","predictions":[{"probability":0.9034805,"tagId":"8faabbcc-452a-4bf7-8f1b-fdacad8c923e","tagName":"100","boundingBox":{"left":0.46884796,"top":0.39544287,"width":0.09181544,"height":0.13678041}},{"probability":0.8434237,"tagId":"8faabbcc-452a-4bf7-8f1b-fdacad8c923e","tagName":"100","boundingBox":{"left":0.27559033,"top":0.2615706,"width":0.067119986,"height":0.093027055}},{"probability":0.8418253,"tagId":"8faabbcc-452a-4bf7-8f1b-fdacad8c923e","tagName":"100","boundingBox":{"left":0.34035426,"top":0.2708075,"width":0.06956527,"height":0.0960823}},...
~~~

12．検出できたら、Probabilityが0.8以上のものを選びます。複数検出されている場合はIoUを計算し、BoundingBoxがだいぶ重なっているもの＆Probabilityの低い方は削除します。

13．検出結果からいくらか計算して読み上げます。

# 実行

動画のように、小銭を数えられるようになりました！結構間違えるので、学習データを増やす必要があります。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">小銭数えれるようになった <a href="https://twitter.com/hashtag/HoloLens2?src=hash&amp;ref_src=twsrc%5Etfw">#HoloLens2</a> <a href="https://twitter.com/hashtag/MRTK?src=hash&amp;ref_src=twsrc%5Etfw">#MRTK</a> <a href="https://twitter.com/hashtag/Azure?src=hash&amp;ref_src=twsrc%5Etfw">#Azure</a> <a href="https://twitter.com/hashtag/CognitiveServices?src=hash&amp;ref_src=twsrc%5Etfw">#CognitiveServices</a> <a href="https://twitter.com/hashtag/CustomVision?src=hash&amp;ref_src=twsrc%5Etfw">#CustomVision</a> <a href="https://twitter.com/hashtag/Unity?src=hash&amp;ref_src=twsrc%5Etfw">#Unity</a> <a href="https://t.co/2MI94x9zvF">pic.twitter.com/2MI94x9zvF</a></p>&mdash; がちもとさん@熊本 (@sotongshi) <a href="https://twitter.com/sotongshi/status/1340387332696887296?ref_src=twsrc%5Etfw">December 19, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

お疲れ様でした。

# 参考
- [3.Azure Custom Vision の統合](https://docs.microsoft.com/ja-jp/windows/mixed-reality/develop/unity/tutorials/mr-learning-azure-03)
- [HoloLensでAzure Custom Vision Service - ObjectDetectionをWindows MLを使ってオフライン推論してみた。](https://qiita.com/miyaura/items/1b9210b5c5f75d8722a3)