最終目標
android/iOS/OSX/windowsでDL推論アプリを単一プラットフォーム・単一言語でつくる。
今回の内容
- ローカルにある機械学習モデルファイルをNatMLで読み込むコードの解説のみ(1/n)。
- 今回は最低限必要なコードのみ。各種モデルファイルの準備や環境構築、他クロスプラットフォームend2end解説は連続記事として投稿する(予定)。
免責
- 基本pythonしか触らないのでオブジェクト指向言語がさっぱりわからない。実装・理解はChatGPTの利用のみで進めているため、もしかしたらC#ネイティブから見て首を傾げる実装があるかもしれないけれども、なにとぞ。
背景
-
DL推論をクロスプラットフォームでやりたいものの、多言語習得はつらい。Unity + NatMLを使うとandroid/iOSどころかOSXやWindowsも適宜ハードウェアアクセラレーションを利用して実装できるらしいと知ったものの、解説記事がほとんどない。公式ドキュメントも結構
大味玄人向けに書いてあって難しい。また、限りある解説記事も、NatMLHubと呼ばれるクラウドからモデルを取得する方法を選択している。公式ではローカルファイルをロードする方法も提示されているので、そちらの実装を目指す。 -
公式のドキュメントにあるgifではモデルをドラッグアンドドロップするだけでローカルモデルを使えるよととても簡単な例示がされているものの、namespace含めて1.0.11,1.0.19でコードがdeprecated(怒)なため、勉強がてら実装コードを作り直す。
ゴール
-
NatMLが提供するMLEdgeModelでモデルを読み込むことに成功すると、Debug.Log()で下のようにモデルの情報がとれるとある。よって、コンソールにそのような出力がとれるようなminimal codeを実装する。
Minimal Code
最低限必要なスクリプトファイルは2つ。
- DLモデル定義スクリプト
- DLモデル利用スクリプト
DLモデル定義
public class MobileNetV3LargePredictor : IMLPredictor<List<float>>{
public static async Task<MobileNetV3LargePredictor> Create(){
string path = Application.dataPath + "/mobilenetv3_large_pytorch.onnx";
var model = await MLEdgeModel.Create(path);
Debug.Log(model);
var predictor = new MobileNetV3LargePredictor(model);
return predictor;
}
private readonly MLEdgeModel model;
private MobileNetV3LargePredictor(MLEdgeModel model){
this.model = model;
}
public unsafe List<float> Predict(params MLFeature[] inputs){
if (inputs.Length != 1)
throw new ArgumentException(@"predictor expects a single feature", nameof(inputs));
var imageFeature = inputs[0] as MLImageFeature;
if (imageFeature == null)
throw new ArgumentException(@"predictor expects an image feature", nameof(inputs));
var inputType = model.inputs[0] as MLImageType;
using var inputFeature = (imageFeature as IMLEdgeFeature).Create(inputType);
// meanとstdによる前処理。次回以降に解説。ローカルでは任意の数値をここで入力しなければいけない
(imageFeature.mean, imageFeature.std) = model.normalization;
// resizeのモードも同様
imageFeature.aspectMode = model.aspectMode;
using var outputFeatures = model.Predict(inputFeature);
// こんなことしなくてよいかもしれないけれど要素1000のlogitを返すリストに変換しておく。
var outputList = new List<float>();
var outputFeatureData = (float*)outputFeatures[0].data;
for(int i = 0; i < 1000; i++)
{
outputList.Add(outputFeatureData[i]);
}
return outputList;
}
void IDisposable.Dispose () { }
}
解説
- IMLPredictorインターフェースを継承し、戻り値は任意の型を指定したクラス。IMLPredictorはCreate static methodとPredict instance methodを要求する。
- 例えば下記はImage Classificationを行うMobileNetV3Largeを実装する場合。戻り値として[1,1000]のlogitsを返して欲しいため、リスト型(dtype float)とする。もしクラス内部でsoftmaxやtop kなどの処理をして異なる戻り値を得たい場合は適宜変更する。Detectionなど他のモデルも然り。
public class MobileNetV3LargePredictor : IMLPredictor<List<float>>{}
Create method
- ここの部分がうまくいくと、Debug.Log(model);でコンソールにモデル情報が出力される。とれなければスクリプトがおかしいか、用意したモデルがおかしい。
public static async Task<MobileNetV3LargePredictor> Create(){
string path = Application.dataPath + "/mobilenetv3_large_pytorch.mlmodel";
var model = await MLEdgeModel.Create(path);
Debug.Log(model);
var predictor = new MobileNetV3LargePredictor(model);
return predictor;
}
- CreateメソッドではMLEdgeModel.Createを実装する。MLEdgeModel.Createにtag, configuration, accesskeyをいれればNatMLからプラットフォームにあわせたモデルファイルをfetchする仕様となっている。
var model = await MLEdgeModel.Create("@natsuite/yolox", configuration, accessKey);
- 一方でファイルパスのみをいれればローカルファイルをロードすることができる。例えば、以下はunityのAssetsフォルダ直下にモデルファイルがあってそれを読み出す場合の書き方。(Androidではapk内部でパスを指定できない問題があるので次回以降ではAssets/StreamingAssetsを参照するように変更していると思う。)
var model = await MLEdgeModel.Create(Application.dataPath + "/mobilenetv3_large_pytorch.mlmodel");
Predict method
public unsafe List<float> Predict(params MLFeature[] inputs){
if (inputs.Length != 1)
throw new ArgumentException(@"predictor expects a single feature", nameof(inputs));
var imageFeature = inputs[0] as MLImageFeature;
if (imageFeature == null)
throw new ArgumentException(@"predictor expects an image feature", nameof(inputs));
var inputType = model.inputs[0] as MLImageType;
using var inputFeature = (imageFeature as IMLEdgeFeature).Create(inputType);
// meanとstdによる前処理。次回以降に解説。ローカルでは任意の数値をここで入力しなければいけない
(imageFeature.mean, imageFeature.std) = model.normalization;
// resizeのモードも同様
imageFeature.aspectMode = model.aspectMode;
using var outputFeatures = model.Predict(inputFeature);
// こんなことしなくてよいかもしれないけれど要素1000のlogitを返すリストに変換しておく。
var outputList = new List<float>();
var outputFeatureData = (float*)outputFeatures[0].data;
for(int i = 0; i < 1000; i++)
{
outputList.Add(outputFeatureData[i]);
}
return outputList;
}
- 入力する型はMLFeature[]。戻り値は任意の型。今回はリスト。今回のような簡単な入出力を行う場合にはunsafeは必要ないと思われるものの、preprocess postprocessで複雑な処理を内挿する場合には使われていることが多いようである(多分)。
public unsafe List<float> Predict(params MLFeature[] inputs){
- ここでは入力のinputsの形をチェックしつつ、最終的にIMLEdgeFeatureにcastしている。モデルから取得したinputs情報を元に入力次元・サイズ、dtype、入力ノードのname(?)をMLImageTypeとして情報を付加する。
if (inputs.Length != 1)
throw new ArgumentException(@"predictor expects a single feature", nameof(inputs));
var imageFeature = inputs[0] as MLImageFeature;
if (imageFeature == null)
throw new ArgumentException(@"predictor expects an image feature", nameof(inputs));
var inputType = model.inputs[0] as MLImageType;
using var inputFeature = (imageFeature as IMLEdgeFeature).Create(inputType);
- 以下のコードではさらにimageFeatureにmean,stdとリサイズ時(?)のaspectmode情報を付加している。NatMLHubにモデルをアップロードする場合、meanやstd、aspectmodeなどのハイパラはウェブインターフェースを通じて定義しているものの、ローカルからモデルをロードする場合にはここを手動で書き換えねばならない。
- 何もさせたくない場合は明示的にmean 0, std 1と適当なaspectmodeを設定して、前処理コードを続くpredictの前で実装して、実質無効化させておくのもありかもしれない。
// meanとstdによる前処理。次回以降に解説。ローカルMLモデルをロードする場合、任意の数値をここで入力しなければいけない。Create時に設定しちゃうのも多分あり。
(imageFeature.mean, imageFeature.std) = model.normalization;
// resizeのモードも同様
imageFeature.aspectMode = model.aspectMode;
- ここまでようやく推論と後処理。上述した通り、IMLEdgeFeatureであるinputFeatureには入力サイズ、dtype、mean,std,aspectratioが付加されており、推論時にはそれら情報を利用し、自動的に入力情報を前処理してくれる。
- NHWC、NCHWの次元transposeもモデルに応じてやっていると思われる(要確認)。
- BGR/RGBの変換は自前で用意する必要あり(要確認)。
- 推論後は適当な後処理を実装する。ここにsoftmaxをいれてよいかもしれないが
using var outputFeatures = model.Predict(inputFeature);
// こんなことしなくてよいかもしれないけれど要素1000のlogitを返すリストに変換しておく。
var outputList = new List<float>();
var outputFeatureData = (float*)outputFeatures[0].data;
for(int i = 0; i < 1000; i++)
{
outputList.Add(outputFeatureData[i]);
}
return outputList;
- モデルロードスクリプトはここまで。
DLモデル利用スクリプト
- 次回以降に詳細を解説予定なため、今回は例外処理やカメラ起動保障などの些末は除外した。
- unityではゲーム起動時に1回のみ実行されるStartメソッドと毎フレーム実行されるUpdateメソッドがある。前者でモデルのロードを、後者で推論を行う。
public class CameraControllerMNV3 : MonoBehaviour
{
private WebCamTexture webCamTexture;
private Texture2D texture2DFrame;
private MobileNetV3LargePredictor predictor;
async void Start()
{
//カメラ起動
webCamTexture = new WebCamTexture();
webCamTexture.Play();
//モデル読み込み
predictor = await MobileNetV3LargePredictor.Create();
}
void Update()
{
//カメラ画像をtexture2dとして取得
texture2DFrame.SetPixels32(webCamTexture.GetPixels32());
texture2DFrame.Apply();
// 推論
var predictions = predictor.Predict(texture2DFrame);
// Debug.Log(predictions);
// foreach (var prediction in predictions){
// Debug.Log(prediction);
// }
}
}
- 適当なクラス名を作る。unityのゲームオブジェクトにアタッチするのでMonoBehavior利用が必須
public class CameraControllerMNV3 : MonoBehaviour
- ここではwebcamtextureを利用してカメラ画像を取得している。モデルのロードはasync awaitを利用して、モデルロードを待たずにアプリが進行するようにしているが、モデル読み込み前に他の依存するスクリプトが動き出すとエラーを起こすので対策が必要(次回以降解説)。シンプルにはasync awaitを除けばよいが重いとハングするように見えてUX下がる。awake使ってもよい?
async void Start()
{
//カメラ起動
webCamTexture = new WebCamTexture();
webCamTexture.Play();
//モデル読み込み
predictor = await MobileNetV3LargePredictor.Create();
}
- Updateメソッドで推論と後処理を行う。
void Update()
{
//カメラ画像をtexture2dとして取得
texture2DFrame.SetPixels32(webCamTexture.GetPixels32());
texture2DFrame.Apply();
// 推論
var predictions = predictor.Predict(texture2DFrame);
// Debug.Log(predictions);
// foreach (var prediction in predictions){
// Debug.Log(prediction);
// }
}
- 上に書いたように、推論モデル自体はMLFeatureという型式を要求する。ただMLFeatureの便利なところとして入力の型を推論に適したNatML入力型に変換してくれる。ソースコードを見る限りTexture2Dだけでなっく、WebCamTextureをも対応しているので、カメラ画像をそのまま突っ込むような実装もありなのかもしれない。
- 推論の戻り値は上のスクリプトで書いたようにリストなので、Debug.Logにわかりやすいようforeachで取り出してみている(コメントアウト部分)。
public unsafe List<float> Predict(params MLFeature[] inputs){}
以上がNatMLをローカルDLモデルで動かす最低限のコード。
次回以降に各種モデルの用意や周辺実装を紹介する。