20210508のUnityに関する記事は5件です。

【Unity】UniTask Coroutineとの違いとキャンセル処理の挙動

UniTaskとは? Unityでasync/awaitを使う場合ほぼ必須のプラグインです。 ライセンス : The MIT License (MIT) 著作者 : Yoshifumi Kawai / Cysharp, Inc. Coroutineとの違い 非同期処理する上での定番Coroutineとの置き換えを検討すると思いますが、多少挙動が違うので理解が必要です。 基本的な挙動の比較 Coroutine UniTask GameObjectがDestroyされると止まる GameObjectがDestroyされても止まらない GameObject.SetActive(false)されると止まる GameObject.SetActive(false)されても止まらない MonoBehaviourからしか呼べない MonoBehaviourでは無くても呼べる StopCoroutineで停止 CancellationTokenで停止 StopCoroutineで子も停止 CancellationTokenで子は停止しない 停止時例外を吐かない awaitした場合のみ停止時例外を吐く UniTaskをCoroutineの挙動に近づける GameObjectがGameObject.SetActive(false)、Destroyされると止まるようにしたい DestroyはGetCancellationTokenOnDestroy()を使うのが一番簡単です。 var token = this.GetCancellationTokenOnDestroy(); await UniTask.DelayFrame(1000, cancellationToken: token); CancellationTokenSourceを使いOnDestroy()内で明示的に停止する事も可能です。 GameObject.SetActive(false)時に止めたい場合は、OnDisableに記述します。 何もしないとawait中であれば例外が発生するので注意してください。解決方法は後述します。 CancellationTokenSource token = new CancellationTokenSource(); async void Start() { await UniTask.DelayFrame(1000, cancellationToken: token.Token); } void OnDestroy() { token.Cancel(); token.Dispose(); } 停止する際は子も停止してほしい 子にCancellationTokenを渡す事で止まってくれます。UniTaskを使う際はCancellationTokenを引き回す必要があるので必ず引数にCancellationTokenを取るべきです。 async void Start() { var token = this.GetCancellationTokenOnDestroy(); // 0.5f後にキャンセル Observable.Timer(TimeSpan.FromSeconds(0.5f)) .Subscribe(_ => token.Cancel()); await FooAsync(token); } async UniTask FooAsync(CancellationToken token) { await UniTask.Delay(1000, cancellationToken: token); // 毎回tokenを渡す } // await中に停止するとキャンセルした旨の例外が発生する // OperationCanceledException: The operation was canceled. [修正] await中の停止時に例外を発生させたくない SuppressCancellationThrow()で例外は発生しなくなりますが、使う前にawaitしないようにする方法を考えたほうが良いと思います。 使用の際は呼び出す大元のメソッドで使ってください。内部で利用すると処理の流れを追えなくなります。 async void Start() { var token = this.GetCancellationTokenOnDestroy(); // 0.5f後にキャンセル Observable.Timer(TimeSpan.FromSeconds(0.5f)) .Subscribe(_ => token.Cancel()); // 大元のFooAsyncにSuppressCancellationThrow()を追加 await FooAsync(token).SuppressCancellationThrow(); } async UniTask FooAsync(CancellationToken token) { await UniTask.Delay(1000, cancellationToken: token); } // await中に停止しても例外は発生しません キャンセル処理の挙動テスト [テスト1] await中にキャンセルした場合 [関数内] 次の処理まで進まず止まる [関数外] 次の処理まで進まず止まる キャンセルした旨の例外が発生する async void Start() { stopwatch.Start(); var token = new CancellationTokenSource(); // 0.5fで停止する Observable.Timer(TimeSpan.FromSeconds(0.5f)) .Subscribe(_ => token.Cancel()); await Simple(token.Token); await Simple(token.Token); stopwatch.Stop(); } async UniTask Simple(CancellationToken token) { Debug.Log($"[{stopwatch.Elapsed.Seconds}] Simple Start Token:{token.IsCancellationRequested}"); await UniTask.Delay(1000, cancellationToken: token); Debug.Log($"[{stopwatch.Elapsed.Seconds}] Simple 2 Token:{token.IsCancellationRequested}"); await UniTask.Delay(1000, cancellationToken: token); Debug.Log($"[{stopwatch.Elapsed.Seconds}] Simple End Token:{token.IsCancellationRequested}"); } // Console Log // [0] Simple Start Token:False // OperationCanceledException: The operation was canceled. [テスト2] await中にキャンセルした場合 + 戻り値がある テスト1と同じ挙動 //(省略) メソッド名をWithReturnValueにした以外[テスト1]と同じ async UniTask<int> WithReturnValue(CancellationToken token) { Debug.Log($"[{stopwatch.Elapsed.Seconds}] WithReturnValue Start Token:{token.IsCancellationRequested}"); await UniTask.Delay(1000, cancellationToken: token); Debug.Log($"[{stopwatch.Elapsed.Seconds}] WithReturnValue 2 Token:{token.IsCancellationRequested}"); await UniTask.Delay(1000, cancellationToken: token); Debug.Log($"[{stopwatch.Elapsed.Seconds}] WithReturnValue End Token:{token.IsCancellationRequested}"); return 100; } // Console Log // [0] WithReturnValue Start Token:False // OperationCanceledException: The operation was canceled. [テスト3] キャンセルしたトークンを引数に渡した場合 テスト1と同じ挙動 キャンセルされていてもawaitまでの処理は走る。走ってほしくない場合はCancellationToken.IsCancellationRequestedフラグを見て判断する async void Start() { stopwatch.Start(); var token = new CancellationTokenSource(); // 即キャンセル token.Cancel(); await Simple(token.Token); stopwatch.Stop(); } async UniTask Simple(CancellationToken token) { // ※ token.IsCancellationRequested == true Debug.Log($"[{stopwatch.Elapsed.Seconds}] Simple Start Token:{token.IsCancellationRequested}"); // ※ ここまでは走る await UniTask.Delay(1000, cancellationToken: token); Debug.Log($"[{stopwatch.Elapsed.Seconds}] Simple 2 Token:{token.IsCancellationRequested}"); await UniTask.Delay(1000, cancellationToken: token); Debug.Log($"[{stopwatch.Elapsed.Seconds}] Simple End Token:{token.IsCancellationRequested}"); } // Console Log // [0] Simple Start Token:True // OperationCanceledException: The operation was canceled. [テスト4] await中にシーンのロードをした場合 ・ 処理は止まらず次のシーンでも完了まで処理が走り続ける static bool isFirstTime = true; async void Start() { if (!isFirstTime) { return; } isFirstTime = false; stopwatch.Start(); // シーンをリロード Observable.Timer(TimeSpan.FromSeconds(0.5f)) .Subscribe(_ => SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex)); var token = new CancellationTokenSource(); VoidWithReference(transform, token.Token).Forget(); } async UniTaskVoid VoidWithReference(Transform tr, CancellationToken token) { Debug.Log($"[{stopwatch.Elapsed.Seconds}] VoidWithReference Start Token:{token.IsCancellationRequested}"); Debug.Log(tr); await UniTask.Delay(1000, cancellationToken: token); // ※ここ以降次のシーンで実行される Debug.Log($"[{stopwatch.Elapsed.Seconds}] VoidWithReference 2 Token:{token.IsCancellationRequested}"); Debug.Log(tr); await UniTask.Delay(1000, cancellationToken: token); Debug.Log($"[{stopwatch.Elapsed.Seconds}] VoidWithReference End Token:{token.IsCancellationRequested}"); Debug.Log(tr); } // Console // [0] VoidWithReference Start Token:False // Main Camera (UnityEngine.Transform) // [0] VoidWithReference 2 Token:False // null // [0] VoidWithReference End Token:False // null [テスト5] await中にシーンのロードをした場合 + Cancel + Dispose 次のシーンに持ち越さず完全に処理は止まる Dispose()を呼ぶだけではキャンセルされない。必ずCancel()を呼ぶ CancellationTokenSource token = new CancellationTokenSource(); // (省略)tokenを外に出した以外は[テスト4]と同じ // オブジェクトの破棄時に呼ぶ void OnDestroy() { token.Cancel(); token.Dispose(); } // Console // [0] VoidWithReference Start Token:False // Main Camera (UnityEngine.Transform) // ※ awaitしていないので例外は発生しない 基本的な使用方法など 下記記事が大変参考になりますのでオススメです。 ※ 記事内ではUnirx.Asyncというnamespaceを定義していますが、現バージョンではCysharp.Threading.Tasksというnamespaceに変更されています。 UniTask機能紹介 https://qiita.com/toRisouP/items/4445b6b9bf00e49eb147
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

異なるMeshのDrawCall最適化戦略

今回の主な話題:異なるMeshのDrawCall最適化戦略、AnimationClipメモリの最適化、レンダリング効果エラー、Unityコンパイル後のエラーをコールバックする可能性、UGUIアトラスの並べる問題。 DrawCall Q1: Voxelゲームを作成しています。現在、ゲーム内では、すべてのモデルが同じMaterialを共有し、各モデルパーツ(モンスター)は個別のMeshであり、Animatorで作成したアニメーションにはボーンがなく、キーフレームアニメーションであり、GPUインスタンスが有効になっています。 発生した問題: 例えば、一人のキャラクターには10以上のパーツがあり、マップに10種類のモンスターが同時に表示されると、モデルパーツのDrawCallは100以上に急上昇します。現在、特殊効果はあまり追加されていないため、実機(Mi8 Lite)でテストしてもスタッター効果はなく、GPUインスタンスも開いております。種類が少ない、数量が多い場合でも動作が悪くないです。 しかし、私はまだ皆にお聞きたいです。この高いDrawCall問題のための良い最適化方法はありますか?それとも最適化する必要がありますか? 感謝します! 自分でCulling+手動でDrawMeshInstancedをコールする(直接マテリアルでinstanceをチェックすると合併できない場合がよくあります)ことで、パーツ種類と同じ数のDrawCallに減らすことができます アニメーションの精度要求が高くなく、スペースを時間に変えられる場合、モデルを作成してフレームアニメーションごとにベイクして、DrawMeshInstancedを使用したら、モデル種類と同じ数のDrawCallに減らすことができます。 LWRP / HDRPを使用すると、1つ便利な点があります。SRP batcherを開き、マテリアルのenable instanceをチェックしない(「しない」と注意してください)、自有Shaderを使用する場合、DrawCallの急低下がすぐ見られます。カスタムShaderの場合、書き直すことも難しくないです。 Animation Q2: 何かアニメーションメモリ占用を最適化する良い方法ありませんか?よく見える精度の低下、圧縮、Scaleの削除などの一般的な操作以外に、frame Rateを下げることで行けますか? ① frame Rateに関して、以下のことを補充できます。 問題主の問題が「アニメーションがUnityにインポートした後、再生する時にframe Rateを変更します(アニメーションの再生speedを制御することにより、Unityにインポートした後に実にはアニメーションクリップの元来frame Rateを変更できません)」の場合、frame Rateの変更はメモリに影響しません。 しかし、もし問題主がアニメーション制作ソフトウェア(3ds Max、Mayaなど)でframe Rateを変更することを指す場合、Unityにインポートした後にメモリが実際に影響を受けます。このとき、2つのアニメーションが同じである場合、単純にフレームレートが異なります。 1、アニメーションの圧縮がオフにする場合、実際にキーフレームが多い段落(一般的にはframe Rateの高い部分)のメモリは少し大きくなります。そして他にいくつの影響要因もあります。 a、インポート時にresample curvesをオンにします(このオプションは、インポート時にUnityがフレームを自動的に挿入するかどうかを決定し、再生期間中にすべてのカーブがフルフレームになるようにします。)、frame Rateの高いメモリは高くなります。 b、resampleを開くない場合、frame Rateの高い部分は少し高くなります。 2、Keyframe Reductionなどのアニメーション圧縮を開くと、異なるframe Rateのメモリは再び近くなります(アニメーションが同じであり、実際のアニメーションカーブも類似であるため、圧縮する時に一部の繰り返しデータも同じように圧縮されます)。 結論は、つまり、アニメーション圧縮オプションがオンになっている場合、アニメーションを作成する時のframe Rateがメモリへの影響も大きくないので、特にframe Rateに最適化する必要がありません。 ② 一般的に使用されるiTween、DOTweenプラグインやUWAが推奨するXTweenなど、いくつかの線形アニメーションをシミュレートして、アニメーションキーフレーム設置のデータ量を減らすことができます。 レンダリング Q3: iPhoneでMetalAPIを使用し、MSAAもオンにします。不透明オブジェクトをレンダリングした後、透明オブジェクトをレンダリングする前のこの階段(例えばAfterForwardOpaque)に、任意1つのCommandBufferを挿入すると、透明隊列の最も前にあるオブジェクトをレンダリングさせ、ZTestはないようです。 注:OpenGL ES3.0の使用に問題はなく、Androidプラットフォームでも問題はありません。 再現用Demoを作成し、iPhoneに構築して実行すれば大丈夫です。MSAAとCommandBufferを同時に開くと、2つの赤いCubeが常に木の前にあることが分かりました。そのうちの1つを閉じたら問題ありません。 再現用Demoを添付します。 MASSDemo.zip 透明オブジェクトをレンダリングした後、不透明オブジェクトをレンダリングする前に1つのCommandBufferを挿入し、このCubeにUnityのディープテクスチャを現在FrameBufferのDeepthBufferに書き込んだら解決できます。 Script Q4: Unityで、コンパイルするスクリプトがエラーを報告する時にコールバックがあるかどうかを知りたいです。または、使用できるTrickはありますか? ① 効果を試しました。 public class CompilePostProcesser : AssetPostprocessor { static CompilePostProcesser() { EditorApplication.update += Update; } // すべてのコンパイルが完了した後のコールバック[再帰が発生するように注意してください。つまり、コールバックの操作によって再度コンパイルが発生します] // コンパイルエラーがある場合、このコールバックは実行されません [UnityEditor.Callbacks.DidReloadScripts] private static void OnCompiled() { // Debug.ClearDeveloperConsole(); Debug.LogError(“コンパイルが成功しました!”); SuccsccAction?.Invoke(); } private static void Update() { if (EditorUtility.scriptCompilationFailed) { EditorApplication.update -= Update; Debug.LogError(“コンパイルに失敗しました!”); FailAction?.Invoke(); } } } ② Trickは1つありますが、少し古いです。アイデアは、LogをClearしようとすることです。しかし、コンパイルエラーはClearできないため、コンパイルエラーが発生したかどうかを判断できます。 static void ClearLog() { Assembly assembly = Assembly.GetAssembly(typeof(SceneView)); Type logEntries = assembly.GetType("UnityEditorInternal.LogEntries"); logEntries.GetMethod("Clear").Invoke (new object (), null); int count = (int)logEntries.GetMethod("GetCount").Invoke(new object (), null); if (count > 0) throw new Exception("Cannot build because you have compile errors!"); } UI Q5: Unity 5.6.6バージョンでUGUIを使用してアトラスをパッケージします。スペースは512×512に十分し、各画像のサイズは101×101ですが、17枚の101は512×1024のアトラスにパッケージされます。 テストに使用した画像、 ① Paddingがあります。両側に1ピクセルのパディングがあるため、101は103になり、512は足りません。 ② Sprite atlasにはPaddingプロパティがあり、最小は2ピクセル、デフォルトは4ピクセルです。 2ピクセルを例にとると、上記のアトラス画像の高さは512で、4つのテクスチャが占める高さは次のとおりです。2+101+2+101+2+101+2+101=412、残り100ピクセルで、これは垂直方向の5行目のTexureにもう足りません(5行目と4行目の間に2ピクセルのパディングがあることは言うまでもありません)。 UWA Technologyは、モバイル/VRなど様々なゲーム開発者向け、パフォーマンス分析と最適化ソリューション及びコンサルティングサービスを提供している会社でございます。 UWA公式サイト:https://jp.uwa4d.com UWA公式ブログ:https://blog.jp.uwa4d.com
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Unityで3Dモデルを読み込むと、色合いが暗くなる理由

大抵の場合はDirectional LightのColorが白になっていないからです。 Directional LightのColorを白にすると通常の色合いになりますよ!
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UnityでSMB 1.0を扱う方法備忘録

UnityでSMB 1.0を扱う方法備忘録 SharpCifs.Stdをビルドする GitHubから SharpCifs.Std をクローンし、ビルドする。 https://github.com/ume05rw/SharpCifs.Std クローンしてそのままビルドすると下記のようなエラーが出る。 "{your solution path}\SharpCifs.Std\TestSharpCifs\secrets.json" は見つからなかったためコピーできません。 SharpCifs.Std\TestSharpCifs\secrets_novalue.json ファイルをコピーし、 SharpCifs.Std\TestSharpCifs\secrets.json としてペーストする。 これでビルドが通る。 ビルドされたdllをUnityプロジェクトに配置する SharpCifs.STD1.3\bin\Release\SharpCifs.Std.dll をUnityのPluginsディレクトリにコピーすれば使える。 基本的な使い方 リポジトリのドキュメントがよく待ってるのでそっち読む。 ちょっとハマったこと ディレクトリ操作するときとかはpathの末尾に / 入ってないとエラーが出る
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ARの中で物体認識してワードクラウドを表示する

作ったもの こういうものを作りました。 カメラに写っているものを物体認識して、その名称や関連語を空間内に散りばめて表示するものです。 ソースコードはこちらになります。 https://github.com/shibuiwilliam/ARWithWord 前置き ARアプリではスマホやグラスを通して現実空間に視覚的な演出を表示、操作しますが、そのほとんどが画像やエフェクトに限られているように思います。たとえばスマホでGoogle検索した動物の一部をARのオブジェクトとして仮想現実内に表示してみることができます。 もちろんこうした視覚的な効果は面白いのですが、ARの使い途はモノに限られないと感がています。モノそのもの以外にも言葉や文字を空間に表示して意味づけする用途があっても良い気がします。というわけで、ARにワードクラウドを表示するシステムを作ってみました。 ワードクラウドとは ワードクラウドは自然言語処理で使われるデータの可視化手法の一つです。テキストデータを解析して頻出単語やキーワードを2次元画像に表示します。以下の画像がその例ですが、テキストデータを分析して頻出語を表示することで、そのテキストの内容や重要な単語を可視化します。 簡単なワードクラウドは以下のサイトで作ることができます。 ワードクラウドは基本的には2次元で画像として作りますが、ARと組み合わせて3次元にしても良いと思いたって、今回アプリを作ってみました。 どう作るか AR ARアプリはUnityで作ります。AR Foundationでカメラを通した仮想現実のプレーンを認識し、文字を表示します。 今回はアプリはAndroidで動かしますが、Unity+AR FoundationなのでiOSでも動くと思います(未検証)。 物体認識 物体認識はUnity Barracudaにtiny YOLO v3を載せて使っています。本当はUnity Perceptionを試してみたかったのですが、ライブラリインストールが安定せず、Barracudaを使いました。 モデルはONNXによるtiny YOLO v3の学習済みモデルで、学習データはCOCOデータセットです。ですので認識できるものはここにある80カテゴリのみです。 自然言語処理 認識した物体に関連する単語はfastTextで検索しています。fastTextはFacebookが開発した単語をベクトル化する仕組みで、特定の単語を入力すると、その単語に近い関連語を得ることができます。今回はWikipediaでクローリングして作られた学習済みモデルを使っています。 単語分散表現のモデルは容量が大きい(数GB・・・)になるため、アプリには組み込まずクラウド側にREST APIサーバを作って関連語をリクエストできるようにします。 サイズは仕方ないにしても、word2vecやfastTextのような単語分散表現をONNX変換できるようになってほしいです。 全体像 全体像は以下になります。 UnityでARアプリのベースを開発し、その中でONNXベースのtiny YOLO v3の物体認識を実行します。認識した物体の関連語をKubernetesに作ったfastTextのREST APIにリクエストし、結果をAR Foundationでアンカーを差して表示するというものです。というわけで、システムとしてはUnityによるAndroidアプリとKubernetesに乗せるREST APIを開発しています。 アプリサイドとサーバサイド両方に、AR、Edge AI、サーバサイドのAIを組み合わせた仕組みになります。いろいろと組み合わさっていますが、基本的には既存のモデルとライブラリを組み合わせれば作れます。 もうちょい詳しく Unity Unityによるアプリ開発を説明します。 Unityアプリのコードは以下にあります。 https://github.com/shibuiwilliam/ARWithWord/tree/main/ARWithWord Unityでは最初に3Dプロジェクトを作ります。 作成したプロジェクトにはAR FoundationもBarracudaも入っていないので、Package Managerで必要なパッケージを追加します。 AR Foundation:Package Managerで検索してインストール。 Barracuda:ここ参照。Package Managerで見つからない場合は Add package from git URL で com.unity.barracuda を入力すればインストールできます。 Android Logcat:UnityでAndroidアプリのログを表示するツールです。 パッケージをインストールしたら必要なリソースを追加します。 AR Session Originを作成し、そのコンポーネントとして AR Raycast Manager, AR Anchor Manager, AR Plane Managerを Add Component します。加えて物体認識のコンポーネント(ObjectDetector)と関連語検索のクライアント(SimilarWordClient)を作っておきます。 AR Session Originにセッション中の実行コード(Spawn Manager Script)を実装します。コード全文は長いので省略しますが、以下のような内容になります。 // SpawnManager.cs using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; using UnityEngine; using UnityEngine.UI; using UnityEngine.XR.ARFoundation; using UnityEngine.XR.ARSubsystems; using Unity.Collections; using Unity.Collections.LowLevel.Unsafe; using Unity.Barracuda; public class DetectionTarget { public Texture2D CurrentTexture2D; public Pose HitPose; } public class Detected { public Pose HitPose; public IList<ItemDetected> ItemsDetected; } public class DetectedSimilarWords { public Pose HitPose; public SimilarWords SimilarWords; } public class SpawnManager : MonoBehaviour { [SerializeField] GameObject goText; [SerializeField] ARCameraManager m_arCameraManager; public ARCameraManager arCameraManager { get { return m_arCameraManager; } set { m_arCameraManager = value; } } [SerializeField] GameObject m_goObjectDetector; public GameObject goObjectDetector { get { return m_goObjectDetector; } set { m_goObjectDetector = value; } } private ObjectDetector objectDetector; [SerializeField] GameObject m_goSimilarWordClient; public GameObject goSimilarWordClient { get { return m_goSimilarWordClient; } set { m_goSimilarWordClient = value; } } private SimilarWordClient similarWordClient; public float shiftX = 0f; public float shiftY = 0f; public float scaleFactor = 1; private ARRaycastManager arRaycastManager; private List<ARRaycastHit> hits = new List<ARRaycastHit>(); private static Texture2D _texture; private bool isDetecting = false; Texture2D m_Texture; Queue<DetectionTarget> detectionTargetQueue = new Queue<DetectionTarget>(); Queue<Detected> detectedQueue = new Queue<Detected>(); Queue<DetectedSimilarWords> detectedSimilarWordsQueue = new Queue<DetectedSimilarWords>(); private List<Color32> colors = new List<Color32>() { new Color32(255, 115, 200, 255), new Color32(241, 233, 137, 255), new Color32(108, 109, 101, 255), new Color32(128, 244, 222, 255), new Color32(134, 222, 249, 255), new Color32(228, 178, 249, 255) }; private void OnEnable() { // アプリ起動時処理 Debug.Log("initialize AR camera manager frame received"); m_arCameraManager.frameReceived += OnCameraFrameReceived; _texture = new Texture2D(1, 1); _texture.SetPixel(0, 0, new Color(0.3843137f, 0, 0.9333333f)); _texture.Apply(); } void Start() { // AR起動時処理 this.arRaycastManager = GetComponent<ARRaycastManager>(); // 物体認識 this.objectDetector = goObjectDetector.GetComponent<TinyYolo3Detector>(); this.objectDetector.Start(); // 関連語REST APIクライアント this.similarWordClient = goSimilarWordClient.GetComponent<SimilarWordClient>(); this.similarWordClient.Start(); } void Update() { // 画面タッチでカメラに写っているものを物体認識する if (Input.touchCount > 0) { Touch touch = Input.GetTouch(0); if (touch.phase == TouchPhase.Began) { if (arRaycastManager.Raycast(touch.position, hits, TrackableType.PlaneWithinPolygon)) { Pose hitPose = hits[0].pose; var detectionTarget = new DetectionTarget { CurrentTexture2D = this.m_Texture, HitPose = hitPose }; this.detectionTargetQueue.Enqueue(detectionTarget); Debug.Log($"touched ({hitPose.position.x}, {hitPose.position.y}, {hitPose.position.z})!"); } } } // 関連語の表示 if (this.detectedSimilarWordsQueue.Count() > 0) { var detectedSimilarWord = this.detectedSimilarWordsQueue.Dequeue(); Debug.Log($"similar word for {detectedSimilarWord.SimilarWords.word} has {detectedSimilarWord.SimilarWords.predictions.Count()} predictions"); AllocateItem(detectedSimilarWord.SimilarWords.word, detectedSimilarWord.HitPose.position, detectedSimilarWord.HitPose.rotation); int i = 0; foreach (var prediction in detectedSimilarWord.SimilarWords.predictions) { Debug.Log($"{i} process {prediction.similar_word} with {prediction.similarity}"); var hitPoseRandom = new Vector3(detectedSimilarWord.HitPose.position.x, detectedSimilarWord.HitPose.position.y, detectedSimilarWord.HitPose.position.z); float rX = UnityEngine.Random.Range(-1.0f, 1.0f); float rY = UnityEngine.Random.Range(-1.0f, 1.0f); float rZ = UnityEngine.Random.Range(-1.0f, 1.0f); hitPoseRandom.x += rX; hitPoseRandom.y += rY; hitPoseRandom.z += rZ; AllocateItem(prediction.similar_word, hitPoseRandom, detectedSimilarWord.HitPose.rotation); } } RequestSimilarWord(); } private void AllocateItem(string word, Vector3 hitPose, Quaternion hitRotation) { Debug.Log($"allocate {word}"); this.goText.GetComponent<TextMesh>().text = word; int colorIndex = UnityEngine.Random.Range(0, colors.Count()); this.goText.GetComponent<TextMesh>().color = this.colors[colorIndex]; int characterSize = UnityEngine.Random.Range(6, 18); this.goText.GetComponent<TextMesh>().characterSize = characterSize; Instantiate(goText, hitPose, hitRotation); Debug.Log($"allocated {word} on {hitPose.x}, {hitPose.y}, {hitPose.z}"); } // ARカメラの画像を取得 unsafe void OnCameraFrameReceived(ARCameraFrameEventArgs eventArgs) { if (!arCameraManager.TryAcquireLatestCpuImage(out XRCpuImage image)) { return; } var conversionParams = new XRCpuImage.ConversionParams { inputRect = new RectInt(0, 0, image.width, image.height), outputDimensions = new Vector2Int(image.width, image.height), outputFormat = TextureFormat.RGBA32, transformation = XRCpuImage.Transformation.None }; int imageSize = image.GetConvertedDataSize(conversionParams); var buffer = new NativeArray<byte>(imageSize, Allocator.Temp); image.Convert(conversionParams, new IntPtr(buffer.GetUnsafePtr()), buffer.Length); image.Dispose(); this.m_Texture = new Texture2D( conversionParams.outputDimensions.x, conversionParams.outputDimensions.y, conversionParams.outputFormat, false ); this.m_Texture.LoadRawTextureData(buffer); this.m_Texture.Apply(); buffer.Dispose(); Detect(); } // 物体認識 private void Detect() { if (this.isDetecting) { return; } if (this.detectionTargetQueue.Count() == 0) { return; } var detectionTarget = this.detectionTargetQueue.Dequeue(); this.isDetecting = true; StartCoroutine( ProcessImage( this.objectDetector.IMAGE_SIZE, detectionTarget.CurrentTexture2D, picture => { StartCoroutine( this.objectDetector.Detect( picture, itemsDetected => { if (itemsDetected.Count > 0) { var detected = new Detected { HitPose = detectionTarget.HitPose, ItemsDetected = itemsDetected, }; this.detectedQueue.Enqueue(detected); } Resources.UnloadUnusedAssets(); this.isDetecting = false; } ) ); } ) ); } private IEnumerator ProcessImage(int inputSize, Texture2D texture2D, Action<Color32[]> callback) { Coroutine croped = StartCoroutine( TextureTools.CropSquare( texture2D, TextureTools.RectOptions.Center, snap => { var scaled = Scale(snap, inputSize); var rotated = Rotate(scaled.GetPixels32(), scaled.width, scaled.height); callback(rotated); } ) ); yield return croped; } private Texture2D Scale(Texture2D texture, int imageSize) { Texture2D scaled = TextureTools.scaled(texture, imageSize, imageSize, FilterMode.Bilinear); return scaled; } private Color32[] Rotate(Color32[] pixels, int width, int height) { Color32[] rotate = TextureTools.RotateImageMatrix(pixels, width, height, 90); return rotate; } // 関連語をリクエスト private void RequestSimilarWord() { if (this.detectedQueue.Count()==0) { return; } var detected = this.detectedQueue.Dequeue(); StartCoroutine( this.similarWordClient.SimilarWordAPI( detected.ItemsDetected[0].PredictedItem.Label, 20, results => { Debug.Log($"result {results}"); var detectedSimilarWords = new DetectedSimilarWords { HitPose = detected.HitPose, SimilarWords=results, }; this.detectedSimilarWordsQueue.Enqueue(detectedSimilarWords); } ) ); } } コードの中でやっているフローは以下のようになります。 各処理はQueueに入れてCoroutineで非同期に進めていきます。メモリは消費しますが、物体認識やRESTリクエストの遅延でUIに影響を与えない工夫です。 ARカメラに写っている画像は OnCameraFrameReceived メソッドで取得します。この方法は公式ドキュメントで説明されているものです。 画面タッチ時に OnCameraFrameReceived で取得した最新の画像をtiny YOLO v3で物体認識します。この時点で認識した物体の名称と位置を取得し、キューに溜めます。 物体認識のコードは以下のようなものになります。 // TinyYolo3Detector.cs using System; using UnityEngine; using Unity.Barracuda; using System.Linq; using System.Collections; using System.Collections.Generic; public class Parameters { public int ROW_COUNT; public int COL_COUNT; public int CELL_WIDTH; public int CELL_HEIGHT; public Parameters(int ROW_COUNT, int COL_COUNT, int CELL_WIDTH, int CELL_HEIGHT) { this.ROW_COUNT = ROW_COUNT; this.COL_COUNT = COL_COUNT; this.CELL_WIDTH = CELL_WIDTH; this.CELL_HEIGHT = CELL_HEIGHT; } } public class TinyYolo3Detector : MonoBehaviour, ObjectDetector { public NNModel modelFile; // ラベルの言語。英語or日本語 public enum LabelLanguages { EN, JP }; public LabelLanguages labelLanguage; public string inputName; public string outputNameL; public string outputNameM; private const int IMAGE_MEAN = 0; private const float IMAGE_STD = 255.0F; private const int _IMAGE_SIZE = 416; public int IMAGE_SIZE { get => _IMAGE_SIZE; } public float minConfidence = 0.25f; private IWorker worker; private Model model; public Parameters paramsL = new Parameters(13, 13, 32, 32); public Parameters paramsM = new Parameters(26, 26, 16, 16); public const int BOXES_PER_CELL = 3; public const int BOX_INFO_FEATURE_COUNT = 5; private int classLength; private string[] labels; public void Start() { switch(this.labelLanguage) { case LabelLanguages.EN: this.labels = Constants.cocoLabelEN; break; case LabelLanguages.JP: this.labels = Constants.cocoLabelJP; break; default: this.labels = Constants.cocoLabelEN; break; } this.classLength = this.labels.Length; this.model = ModelLoader.Load(this.modelFile); this.worker = GraphicsWorker.GetWorker(this.model); Debug.Log($"Initialized model and labels: {this.classLength} classes"); } public IEnumerator Detect(Color32[] picture, Action<IList<ItemDetected>> callback) { // Coroutineで物体認識を実行 Debug.Log("Run detection"); using (var tensor = TransformInput(picture, this.IMAGE_SIZE, this.IMAGE_SIZE)) { var inputs = new Dictionary<string, Tensor>(); inputs.Add(this.inputName, tensor); yield return StartCoroutine(this.worker.StartManualSchedule(inputs)); var outputL = this.worker.PeekOutput(this.outputNameL); var outputM = this.worker.PeekOutput(this.outputNameM); List<ItemDetected> results = ParseOutputs(outputL, outputM, this.paramsL, this.paramsM); Debug.Log($"yielded {results.Count()} results"); callback(results); } } public static Tensor TransformInput(Color32[] pic, int width, int height) { float[] floatValues = new float[width * height * 3]; for (int i = 0; i < pic.Length; ++i) { Color32 color = pic[i]; floatValues[i * 3 + 0] = (color.r - IMAGE_MEAN) / IMAGE_STD; floatValues[i * 3 + 1] = (color.g - IMAGE_MEAN) / IMAGE_STD; floatValues[i * 3 + 2] = (color.b - IMAGE_MEAN) / IMAGE_STD; } return new Tensor(1, height, width, 3, floatValues); } private List<ItemDetected> ParseOutputs(Tensor yoloModelOutputL, Tensor yoloModelOutputM, Parameters parametersL, Parameters parametersM) { var itemsInCenter = new List<ItemDetected>(); for (var box = 0; box < BOXES_PER_CELL; box++) { for (int cy = 0; cy < parametersL.COL_COUNT; cy++) { for (var cx = 0; cx < parametersL.ROW_COUNT; cx++) { var result = Parse(cx, cy, box, yoloModelOutputL); if (result != null) { itemsInCenter.Add(result); } } } for (int cy = 0; cy < parametersM.COL_COUNT; cy++) { for (var cx = 0; cx < parametersM.ROW_COUNT; cx++) { var result = Parse(cx, cy, box, yoloModelOutputM); if (result != null) { itemsInCenter.Add(result); } } } } return itemsInCenter; } private ItemDetected Parse(int cx, int cy, int box, Tensor yoloModelOutput) { var channel = (box * (this.classLength + BOX_INFO_FEATURE_COUNT)); float confidence = GetConfidence(yoloModelOutput, cx, cy, channel); if (confidence < this.minConfidence) { return null; } float[] predictedClasses = ExtractClasses(yoloModelOutput, cx, cy, channel); var (topResultIndex, topResultScore) = GetTopResult(predictedClasses); var topScore = topResultScore * confidence; if (topScore < this.minConfidence) { return null; } var itemInCenter = new ItemDetected { PredictedItem = new Prediction { Label = labels[topResultIndex], Confidence = topScore, } }; return itemInCenter; } private float Sigmoid(float value) { var k = (float)Math.Exp(value); return k / (1.0f + k); } private float[] Softmax(float[] values) { var maxVal = values.Max(); var exp = values.Select(v => Math.Exp(v - maxVal)); var sumExp = exp.Sum(); return exp.Select(v => (float)(v / sumExp)).ToArray(); } private float GetConfidence(Tensor modelOutput, int x, int y, int channel) { return Sigmoid(modelOutput[0, x, y, channel + 4]); } public float[] ExtractClasses(Tensor modelOutput, int x, int y, int channel) { float[] predictedClasses = new float[this.classLength]; int predictedClassOffset = channel + BOX_INFO_FEATURE_COUNT; for (var predictedClass = 0; predictedClass < this.classLength; predictedClass++) { predictedClasses[predictedClass] = modelOutput[0, x, y, predictedClass + predictedClassOffset]; } return Softmax(predictedClasses); } private ValueTuple<int, float> GetTopResult(float[] predictedClasses) { return predictedClasses .Select((predictedClass, index) => (Index: index, Value: predictedClass)) .OrderByDescending(result => result.Value) .First(); } private List<ValueTuple<int, float>> GetOrderedResult(float[] predictedClasses) { return predictedClasses .Select((predictedClass, index) => (Index: index, Value: predictedClass)) .OrderByDescending(result => result.Value) .ToList(); } } 物体認識では TransformInput で画像をテンソルに変換、リサイズします。PeekOutでtiny YOLO v3から2種類の推論結果を取得します。ONNXモデルはUnity画面内で内部のレイヤーや入出力を見ることができるのですが、以下のとおりOutputが2個(いずれも物体のバウンディングボックスとラベル)出力されます。 認識した物体名はREST APIクライアントで関連語をリクエストします。REST APIクライアントにはUnityのUnityWebRequestを使っています。実装は以下のとおりです。 やはりCoroutineで起動する仕組みになっています。リクエストもレスポンスもJSONを使います。 // SimilarWordClient.cs using System; using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Networking; [Serializable] public class SecretJson { public string url; public string secret; } [Serializable] public class PostData { public string word; public int topn; } [Serializable] public class SimilarWord { public string similar_word; public float similarity; } [Serializable] public class SimilarWords { public string word; public SimilarWord[] predictions; } public class SimilarWordClient : MonoBehaviour { public TextAsset secretFile; private string secretString; private SecretJson secretJson; private string similarWordUrl; public void Start() { this.secretString = Resources.Load<TextAsset>(this.secretFile.name).ToString(); this.secretJson = SecretJson.Deserialize(this.secretString); this.similarWordUrl = $"{this.secretJson.url}/similar-word/"; } public IEnumerator SimilarWordAPI(string word, int topn, Action<SimilarWords> callback) { using (var request = new UnityWebRequest(this.similarWordUrl, "POST")) { Debug.Log($"Request {word} for {topn}"); PostData postData = new PostData(); postData.word = word; postData.topn = topn; string postJson = PostData.Serialize(postData); byte[] byteData = System.Text.Encoding.UTF8.GetBytes(postJson); request.uploadHandler = (UploadHandler)new UploadHandlerRaw(byteData); request.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer(); request.SetRequestHeader("accept", "application/json"); request.SetRequestHeader("Content-Type", "application/json"); request.SetRequestHeader("X-API-KEY", this.secretJson.secret); yield return request.SendWebRequest(); if (request.result == UnityWebRequest.Result.ConnectionError || request.result == UnityWebRequest.Result.ProtocolError) { Debug.Log("Error POST request"); Debug.Log(request.error); } else { Debug.Log($"status code [{request.responseCode}]"); if (request.responseCode==200) { Debug.Log("POST request succeeded"); string json = request.downloadHandler.text; SimilarWords similarWords = SimilarWords.Deserialize(json); callback(similarWords); } else { Debug.Log("POST request failed"); } } } } public void Update() { } } Kubernetesと関連語REST APIサーバ 続いてサーバサイドの実装です。サーバサイドのコードは以下にあります。 https://github.com/shibuiwilliam/ARWithWord/tree/main/backend fastTextは既存のモデルを使いますが、1ファイルが圧縮状態で4GB、解凍して7GB、メモリに展開すると15GBが必要になるというデカいモデルです。金があれば64GBメモリのVMを使って起動しても良いですが、モデルの取得やメモリロードで15分以上かかって使い勝手がとても悪いです。なので単語表現の次元数を減らします。標準の次元数は300ですが、サイズと精度を測って100次元に減らします。これでモデルサイズは2GB、メモリに展開して4GBというサイズになりました。モデルロードも5分以内で済みます。 fastTextの次元削減方法は公式ドキュメントにあります。単語分散表現の次元数は各単語を表す数値の数になります。これが多いほうが単語を詳細に表現できますが、容量が大きく計算も重くなります。減らしても実用上問題なければ減らしても良いでしょう。 REST APIはFastAPIを使います。PythonのWeb APIはいろいろ試しましたが、FastAPIが一番安定して構造的に書けて使いやすいです。 REST APIの実装は以下のようになります。 import os from typing import List, Tuple, Dict import shutil import gzip from urllib.request import urlopen import fasttext import fasttext.util from gensim.models.fasttext import load_facebook_model from google.cloud import storage from fastapi import APIRouter, HTTPException, Security from fastapi.security.api_key import APIKeyHeader from starlette import status from logging import getLogger from src.constants import LANGUAGE_ENUM from src.data.schema import Prediction, Predictions, PredictionRequest logger = getLogger(__name__) # 関連語推論クラス class SimilarWordPredictor(object): def __init__( self, bucket_name: str, model_directory: str = "/opt/", language: LANGUAGE_ENUM = LANGUAGE_ENUM.ENGLISH, model_dimension: int = 100, threshold: float = 0.6, ): self.bucket_name = bucket_name self.client = storage.Client() self.bucket = self.client.get_bucket(self.bucket_name) self.model_directory = model_directory self.language = language self.model_dimension = model_dimension if self.model_dimension not in [100, 300]: raise ValueError("model dimension must be one of 100 or 300") self.file_path = self.download_model(force_download=False) self.fasttext_predictor = load_facebook_model(self.file_path) logger.info(f"loaded {self.file_path}") self.threshold = threshold # local cache self.cache: Dict[str, Predictions] = {} def predict( self, word: str, topn: int = 20, ) -> Predictions: logger.info(f"predict {word}") key = f"{word}_{topn}" if key in self.cache.keys(): return self.cache[key] results = self.fasttext_predictor.wv.most_similar( word, topn=topn, ) _predictions = [] for r in results: if r[1] < self.threshold: continue if repr(r[0]).startswith("'\\u"): continue _predictions.append( Prediction( similar_word=r[0], similarity=r[1], ) ) logger.info(f"{word} prediction: {_predictions}") predictions = Predictions( word=word, predictions=_predictions, ) self.cache[key] = predictions return predictions def download_model( self, force_download: bool = False, ): file_name = f"cc.{self.language.value}.{self.model_dimension}.bin" file_path = os.path.join(self.model_directory, file_name) logger.info(f"retrieve model {file_name}") if os.path.exists(file_path): if not force_download: logger.info(f"model {file_name} exists") return file_path blob = self.bucket.blob(file_name) blob.download_to_filename(file_path) logger.info(f"retrieved model {file_name}") return file_path similar_word_predictor = SimilarWordPredictor( bucket_name=os.environ["BUCKET_NAME"], model_directory=os.getenv("MODEL_DIRECTORY", "/opt/"), language=LANGUAGE_ENUM[os.getenv("LANGUAGE", "ENGLISH").upper()], model_dimension=int(os.getenv("MODEL_DIMENSION", 100)), threshold=float(os.getenv("THRESHOLD", 0.6)), ) router = APIRouter() api_key_header_auth = APIKeyHeader( name="X-API-KEY", auto_error=True, ) def get_api_key(api_key_header: str = Security(api_key_header_auth)): if api_key_header != os.environ["PASSPHRASE"]: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API Key", ) # 関連語API @router.post( "/", response_model=Predictions, dependencies=[Security(get_api_key)], ) def predict( prediction_request: PredictionRequest, ): predictions = similar_word_predictor.predict( word=prediction_request.word, topn=prediction_request.topn, ) return predictions # サンプル用API @router.get( "/sample/", response_model=Predictions, dependencies=[Security(get_api_key)], ) def predict_sample(): predictions = similar_word_predictor.predict( word="ネコ", topn=20, ) return predictions システムはGCPのGKE(Kubernetes)で動かす想定です。モデルファイルはGCPストレージに入れてあり、必要に応じてダウンロードします。 関連語は検索するごとに辞書に登録していき、2度目以降の検索は辞書からレスポンスすることで高速化を図ります。 これでDockerビルドしてKubernetesに乗せればAPIの完成です。 作ったもの 最初に書いたとおり、カメラに写っているものを認識して、その関連語をワードクラウドのように空間内に散りばめて表示します。 おわりに ご覧のとおり、これでなにかの課題を解決するものではありません。 しかしARで現実空間にエフェクトを出すとき、空間内に存在する物体の名称とその意味を認識し、次のアクションを共起するような使い方ができると思い、その試作品として作ってみました。 AIとARは相性が良い割りには組み合わせたプロダクトを作っている例はあまり見ないですし、さらに自然言語処理を組み込んでいるのを見たことがないので、ひとまず作ってみたシステムになります。 次はImage to TextやGPT-3を組み合わせて空間の状況に説明を表示するARを作りたいです(時間があれば)。 まあまあ楽しかったです。緊急事態宣言でGW中引きこもっていた暇潰しになりました。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む