はじめに

Unity 6から正式版としてSentis(v2.0.0以降)がリリースされました。
Sentisとは学習済みのONNXモデルをUnityで実行できる機能です。内製パッケージなので、
PackageManagerからインポートして簡単に利用することができます。
3年ほど前にも、こちらの記事でBarracudaという似たような機能を紹介しましたが、
それに比べたら、扱いやすくなっていると感じました。

目次

開発環境

  • Unity 6(v6000.0.23f1)
  • Sentis(v2.1.0)
  • UniTask(v2.5.5)

処理フロー

初めて触る方は、公式ドキュメントにMNISTを動かすサンプルが用意されているため、
それを動かして理解するのが良いかと思います。処理フローは以下の段階に分かれます。

  1. ONNXモデルの読み込み
  2. 関数グラフをコンパイルする
  3. 実行するエンジンを作成する
  4. 入力データをテンソルに変換する
  5. モデルの実行
  6. 結果を受け取る
  7. エンジンを廃棄する
public class ClassifyHandwrittenDigit : MonoBehaviour
{
    public Texture2D inputTexture;
    public ModelAsset modelAsset;
    
    Model runtimeModel;
    Worker worker;
    
    public float[] results;

    private void Start()
    {
        //1.ONNXモデルの読み込み
        Model sourceModel = ModelLoader.Load(modelAsset);
        
        //2.関数グラフのコンパイル(入力モデルを実行し、出力にソフトマックスを適用)
        FunctionalGraph graph = new FunctionalGraph();
        FunctionalTensor[] inputs = graph.AddInputs(sourceModel);
        FunctionalTensor[] outputs = Functional.Forward(sourceModel, inputs);
        FunctionalTensor softmax = Functional.Softmax(outputs[0]);
        runtimeModel = graph.Compile(softmax);
        
        //3.実行するエンジンを作成する
        worker = new Worker(runtimeModel, BackendType.GPUCompute);

        //4.入力データをテンソルに変換する
        using Tensor inputTensor = TextureConverter.ToTensor(inputTexture, width: 28, height: 28, channels: 1);

        //5.入力データを使用してモデルを実行する
        worker.Schedule(inputTensor);
        
        //6.結果を受け取る
        Tensor<float> outputTensor = worker.PeekOutput() as Tensor<float>;
        results = outputTensor.DownloadToArray();
        foreach (var result in results)
        {
            Debug.Log($"結果:{result}");
        }
    }

    private void OnDisable()
    {
        //7.エンジンを廃棄する
        worker.Dispose();
    }
}

実際に運用する時は、初期化等で1~3を行いエンジンを作成して、画像やテキスト等の入力データを受け取ったら、4~6を実行して結果を受け取るような形になると思います。7のエンジンの廃棄も忘れずに行った方が良いでしょう。

ONNXモデルの読み込み方法に関して

インスペクタから設定したONNXモデルを直接読み込むのが楽ではありますが、公式ドキュメントではStreamingAssetsPathから読み込むことを推奨されています。ONNXモデルを.sentisに変換する必要がありますが、ONNXモデルを選択してSerializeToStreamingAssetsボタンをクリックして簡単に行えます。

しかしAndroid端末では、昔からStreamingAssetsPathから読む込むにはUnityWebRequest()を使う必要がありました。Sentisファイルはそのままでは読み込めないです。以下のコードのようにBytesファイルとして保存してから読み込む必要があります。

public static async UniTask<Stream> LoadSentisFileAsync(string filePath,string byteFileName)
{
    var saveDataPath = $"{Application.persistentDataPath}/{byteFileName}.bytes";
    
    //ファイルがすでにあればそのまま返す
    if (File.Exists(saveDataPath))
    {
        Debug.Log($"SentisData: 既に存在しています。{saveDataPath}" );
        return File.OpenRead(saveDataPath);
    }
    
    Debug.Log($"SentisData : 新規作成。{saveDataPath}" );
    string path = $"{Application.streamingAssetsPath}/{filePath}";
    UnityWebRequest request = UnityWebRequest.Get(path);
    await request.SendWebRequest();
    if (   request.result == UnityWebRequest.Result.ConnectionError 
           || request.result == UnityWebRequest.Result.ProtocolError)
    {
        Debug.LogError(request.error);
        return null;
    }
    await File.WriteAllBytesAsync(saveDataPath, request.downloadHandler.data);
    return File.OpenRead(saveDataPath);
}

それぞれの読み込み方のサンプル

const string MODEL_NAME = "mnist-12";
const string SENTIS_FILE = MODEL_NAME + ".sentis";

//ONNXモデルの読み込み
[SerializeField]
private ModelAsset modelAsset;
var model = ModelLoader.Load(modelAsset);

//直接StreamingAssetsPathからSentisファイルの読み込み
var path = $"{Application.streamingAssetsPath}/{SENTIS_FILE}";
var model = ModelLoader.Load(path);

//Bytesファイルの読み込み
var modelStream = await LoadSentisFileAsync(SENTIS_FILE, MODEL_NAME);
var model = ModelLoader.Load(modelStream);

用意されてる読み込み方法は3種類ありますが、Bytesファイルの読み込みが個人的には一番おすすめです。容量が大きいモデルほど読み込み時に固まったりしますが、Bytesファイルなら固まらずに読め込めたりします。特にモバイル端末では体感的な違いを感じることが出来ます。

HuggingFaceのサンプル

私もなのですが、機会学習の知識が無いためモデルに合わせた関数グラフのコンパイル方法やデータの受け取り方などがわかりません。ONNXモデルがあるからといって本当に動かせるかの確認が困難です。そこで、HuggingFaceならUnityのC#サンプル付きでONNXモデルがあるため、そこからどんなことができそうか検証するのが良いと思います。

トラッキング画像分類文章生成音声入力/合成
BlazeFaceYOLOv8nPhi 1.5Whisper-Tiny
BlazePoseMobileNet V2Tiny StoriesJets
BlazeHandMNIST-12

ハンドトラッキング

BlazeHandをARFoundationのカメラ越しに、モバイル端末でもハンドトラッキングができるようになりました。Barracudaを利用した時にはできなかったためすごい進歩だと思います。
ARFoundationのカメラ画像の渡し方は変わっていないため、Barracudaの記事を参照してください。

  • 実機確認端末
    • Pixel6 (Android v15)
    • iPhone14 (iOS v18.0)
Pixel6で撮影
iPhone14で撮影

旧バージョンから最新版への移行

サンプルがあるとはいえ、ほとんどがまだ旧バージョンのSentisであるため、最新版の動かし方をMobileNetV2を例に紹介します。公式の移行ガイドはこちらより。

Tensor型 宣言
型指定はジェネリックに変更 : TensorFloat -> TensorFloat<float>

//旧バージョン
TensorFloat mulRGB = new TensorFloat(new TensorShape(1, 3, 1, 1), new float[] { 1 / 0.229f, 1 / 0.224f, 1 / 0.225f });
TensorFloat shiftRGB = new TensorFloat(new TensorShape(1, 3, 1, 1), new float[] { 0.485f, 0.456f, 0.406f });

↓

//新バージョン
Tensor<float> mulRGB = new Tensor<float>(new TensorShape(1, 3, 1, 1), new float[] { 1 / 0.229f, 1 / 0.224f, 1 / 0.225f });
Tensor<float> shiftRGB = new Tensor<float>(new TensorShape(1, 3, 1, 1), new float[] { 0.485f, 0.456f, 0.406f });

関数グラフをコンパイルしてエンジンの作成
関数グラフ : ラムダ式 -> 適宜FunctionalTensorを宣言
エンジンの作成 : WorkerFactory.CreateWorker(backend, model2); -> new Worker(model2, backend);

//旧バージョン
var model2 = FF.Compile( input =>
                {
                    var probability = model.Forward(NormaliseRGB(input))[0];
                    return (FF.ReduceMax(probability, 1), FF.ArgMax(probability, 1));
                },model.inputs[0]);
engine = WorkerFactory.CreateWorker(backend, model2);

↓

//新バージョン
FunctionalGraph graph = new FunctionalGraph();
FunctionalTensor[] inputs = graph.AddInputs(model);
FunctionalTensor[] outputs = Functional.Forward(model, NormaliseRGB(inputs));
FunctionalTensor probability = outputs[0];
FunctionalTensor reduceMax = Functional.ReduceMax(probability, 1);
FunctionalTensor argMax = Functional.ArgMax(probability, 1);
var model2 = graph.Compile(reduceMax, argMax,inputs[0]);
engine = new Worker(model2, backend);

エンジンを実行して結果の受け取り
エンジンの実行 : engine.Execute(input); -> engine.Schedule(input);
結果の受け取り : item.CompleteOperationsAndDownload(); -> item.ReadbackAndClone();

//旧コード
using var input = TextureConverter.ToTensor(inputImage, imageWidth, imageHeight, 3);
engine.Execute(input);
var probability = engine.PeekOutput("output_0") as TensorFloat;
var item = engine.PeekOutput("output_1") as TensorInt;
item.CompleteOperationsAndDownload();
probability.CompleteOperationsAndDownload();
var ID = item[0];
var accuracy = probability[0];

↓

//新コード
using var input = TextureConverter.ToTensor(inputImage, imageWidth, imageHeight, 3);
engine.Schedule(input);
var probability = engine.PeekOutput("output_0") as Tensor<float>;
var item = engine.PeekOutput("output_1") as Tensor<int>;
var ID = item.ReadbackAndClone()[0];
var accuracy = probability.ReadbackAndClone()[0];

TensorからFunctionalTensorへ変換
FunctionalTensor.FromTensor() -> Functional.Constant()

//旧コード
FunctionalTensor NormaliseRGB(FunctionalTensor image)
{
    return (image - FunctionalTensor.FromTensor(shiftRGB)) * FunctionalTensor.FromTensor(mulRGB);
}

↓

//新コード
FunctionalTensor NormaliseRGB(FunctionalTensor image)
{
    return (image - Functional.Constant(shiftRGB)) * Functional.Constant(mulRGB);
}

最新版の全ソースコード

public class RunMobileNet : MonoBehaviour
{
    //draw the sentis file here:
    public ModelAsset modelAsset;

    //The image to classify here:
    public Texture2D inputImage;

    //Link class_desc.txt here:
    public TextAsset labelsAsset;

    //All images are resized to these values to go into the model
    const int imageHeight = 224;
    const int imageWidth = 224;

    const BackendType backend = BackendType.GPUCompute;

    private Worker engine;
    private string[] labels;

    //Used to normalise the input RGB values
    Tensor<float> mulRGB = new Tensor<float>(new TensorShape(1, 3, 1, 1), new float[] { 1 / 0.229f, 1 / 0.224f, 1 / 0.225f });
    Tensor<float> shiftRGB = new Tensor<float>(new TensorShape(1, 3, 1, 1), new float[] { 0.485f, 0.456f, 0.406f });

    void Start()
    {

        //Parse neural net labels
        labels = labelsAsset.text.Split('\n');

        //Load model from file or asset
        var model = ModelLoader.Load(modelAsset);

        //We modify the model to normalise the input RGB values and select the highest prediction
        //probability and item number
        FunctionalGraph graph = new FunctionalGraph();
        FunctionalTensor[] inputs = graph.AddInputs(model);
        FunctionalTensor[] outputs = Functional.Forward(model, NormaliseRGB(inputs));
        FunctionalTensor probability = outputs[0];
        FunctionalTensor reduceMax = Functional.ReduceMax(probability, 1);
        FunctionalTensor argMax = Functional.ArgMax(probability, 1);
        var model2 = graph.Compile(reduceMax, argMax,inputs[0]);

        //Setup the engine to run the model
        engine = new Worker(model2, backend);

        //Execute inference
        ExecuteML();
    }

    public void ExecuteML()
    {
        //Preprocess image for input
        using var input = TextureConverter.ToTensor(inputImage, imageWidth, imageHeight, 3);
        
        //Execute neural net
        engine.Schedule(input);

        //Read output tensor
        var probability = engine.PeekOutput("output_0") as Tensor<float>;
        var item = engine.PeekOutput("output_1") as Tensor<int>;

        //Select the best output class and print the results
        var ID = item.ReadbackAndClone()[0];
        var accuracy = probability.ReadbackAndClone()[0];

        //The result is output to the console window
        int percent = Mathf.FloorToInt(accuracy * 100f + 0.5f);
        Debug.Log($"Prediction: {labels[ID]} {percent}﹪");

        //Clean memory
        Resources.UnloadUnusedAssets();
    }

    //This scales and shifts the RGB values for input into the model
    FunctionalTensor NormaliseRGB(FunctionalTensor image)
    {
        return (image - Functional.Constant(shiftRGB)) * Functional.Constant(mulRGB);
    }
    
    FunctionalTensor[] NormaliseRGB(FunctionalTensor[] images)
    {
        List<FunctionalTensor> normalised = new List<FunctionalTensor>();
        foreach (var image in images)
        {
            var normalisedImage = NormaliseRGB(image);
            normalised.Add(normalisedImage);
        }
        return normalised.ToArray();
    }
    
    private void OnDestroy()
    {
        mulRGB?.Dispose();
        shiftRGB?.Dispose();
        engine?.Dispose();      
    }
}

Quick,Draw!を動かしてみる

Quick,Draw!とは、描いたイラストが何かを判定するゲームです。Googleが提供しておりWebブラウザですぐに遊ぶことができます。
このデータセットが公開されているため、弊社の機会学習エンジニアに協力してもらい、MetaQuest 3/3Sで動く、Quick,Draw!風ゲームを作ってみました。

機会学習エンジニアによるモデル作成の記事はこちらです。

関数グラフ

関数グラフは以下のコードのようにしました。
SoftMax関数でイラストの認識率を取得し、TopK関数で上位5種類を取得するようにしています。
※ONNXモデルによって合わせる必要があります。

const string MODEL_NAME = "quickdraw-small";
const string SENTIS_FILE = MODEL_NAME + ".sentis";
const int TOP_K = 5;

/// <summary>
/// 関数グラフの作成
/// </summary>
/// <param name="model"></param>
/// <returns></returns>
private Model addPostProcessing(Model model) {
    
    FunctionalGraph graph = new FunctionalGraph();

    FunctionalTensor[] inputs = graph.AddInputs(model);
    FunctionalTensor[] outputs = Functional.Forward(model, inputs);
    //SoftMax関数を適用し、上位5種類を取得
    FunctionalTensor probabilities = Functional.Softmax(outputs[0]);
    FunctionalTensor[] valuesAndindices = Functional.TopK(probabilities, TOP_K);
    
    var newModel = graph.Compile(valuesAndindices);
    return newModel;
}

/// <summary>
/// モデルの読み込み(非同期)
/// </summary>
public async UniTask LoadModelAsync()
{
    var modelStream = await LoadSentisFileAsync(SENTIS_FILE, MODEL_NAME);
    var model = ModelLoader.Load(modelStream);

    model = addPostProcessing(model);
    _worker = new Worker(model, Backend);
    Debug.Log("Quickdrawモデル読み込み完了");
}

OpenCVForUnityの活用

描かれたイラスト画像をグレースケールで正規化するのに、OpenCVForUnityを活用しました。
以下の様にしてTensor用のデータに変換しました。画像のリサイズはONNXモデルのInputに合わせて32×32にしています。

/// <summary>
/// OpenCVで入力用データに変換
/// </summary>
/// <param name="source">イラスト画像</param>
/// <returns>Tensor用データ</returns>
private float[] convertOpenCV(Texture2D source)
{
    Mat img = new Mat(source.height, source.width, CvType.CV_8UC4);
    Utils.texture2DToMat(source, img);
    
    // 色の反転
    Core.bitwise_not(img, img);

    // 画像のリサイズ(imageWidth = 32, imageHeight = 32)
    Imgproc.resize(img, img, new Size(imageWidth, imageHeight));

    // グレースケール変換
    Mat grayImg = new Mat();
    Imgproc.cvtColor(img, grayImg, Imgproc.COLOR_BGR2GRAY);
    
    // データ型の変換 (float32)
    grayImg.convertTo(grayImg, CvType.CV_32F);
    
    int channels = grayImg.channels();
    float[] floatArray = new float[grayImg.total() * channels];
    grayImg.get(0, 0, floatArray);

    //正規化
    for (int i = 0; i < floatArray.Length; i++)
    {
        floatArray[i] = floatArray[i] / 255.0f;
    }

    return floatArray;
}

この関数を利用して、モデル実行して結果の取得は以下の様になります。

/// <summary>
/// 実行
/// </summary>
/// <param name="inputTexture">イラスト画像</param>>
public void ExecuteModel(Texture2D inputTexture)
{
    //(imageWidth = 32, imageHeight = 32)
    var inputData = convertOpenCV(inputTexture);
    using var input = new Tensor<float>(new TensorShape(1, imageWidth,imageHeight,1), inputData);
    
    //モデル実行
    _worker.Schedule(input);

    //結果取得
    using Tensor<float> output_0 = _worker.PeekOutput("output_0").ReadbackAndClone() as Tensor<float>;
    using Tensor<int> output_1 = _worker.PeekOutput("output_1").ReadbackAndClone() as Tensor<int>;
    var probabilities = output_0.DownloadToArray();
    var indices = output_1.DownloadToArray();
    
    //結果表示(_labelDictにはイラストのラベルが入っています)
    var str = new StringBuilder("結果表示");
    str.AppendLine("");
    for (int i = 0; i < TOP_K; i++)
    {
        var label = _labelDict[indices[i]];
        str.AppendLine($"{label}:{probabilities[i]}");
    }
    Debug.Log(str.ToString());
}

quickdraw-small

精度はかなり低いですが、HuggingFaceにONNXモデルがアップロードされています。
前項までに紹介した、モデルの読み込みと実行がそのまま使えますので、動かしてみたい方は試してみてください。
※弊社の機会学習エンジニアが作成したものではありません。

まとめ

機会学習の知識がないと扱うのは難しいですが、Unityでできることの幅が広がったように感じます。
機会学習エンジニアと協力して何かアプリを作るという経験もなかったため、今回試してみて面白かったです。みなさんもチャレンジしてみてください。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる