かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

Xamarin.Forms で AI をアプリに組み込んでみよう(UWP, Android, iOS)

Android は TensorFlow、iOS は CoreML、Windows 10 は onnx という感じで各 OS でディープラーニングの学習モデルをサポートするような API が追加されてきてますね!

Xamarin.Forms を使えば Android, iOS, UWP アプリの開発が同時に出来る(UI を各 OS ごとに凝るなら Xamarin Native を選んだ方が最終的に楽なケースもあるけど)ので、うまいことやればインターネット接続不要で画像判別とかを AI ちっくにやるようなアプリが全部 C# で書けそうなのでやってみましょう。

やりながら書くので、最終的にダメでしたになる可能性もありますがとりあえずね。

モデルの作成

Tensorflow とか CNTK とか etc... を使ってディープラーニングするのが一番いんでしょうが、そこらへんから勉強してたらブログ記事書くのに何か月もかかるので今回は Microsoft Cognitive Services の Custom Vision を使ってみたいと思います。

これは、ポータルサイトで画像登録してタグづけしてトレーニングさせると WebAPI や CoreML や onnx や TensorFlow などの形で公開できる素敵なやつです。

docs.microsoft.com

そこに、今回は Microsoft の Drew さんが結構前に作ってくれたハンズオンにある揚げ物かどうかを判定する学習用の画像セットを使って学習されたものをエクスポートして使ってみたいと思います。

github.com

余談ですが Drew さんが先日行われた TechSummit でセッションしたのですが、最初に powerpoint.exe をデリートしてデモとコードメインで色んな人とやりとりしながらやってたセッションは面白かったです。

そうこうしてるうちにリポジトリのクローンが終わったので https://customvision.ai にアクセスしてプロジェクトを作ります。

プロジェクトを作るときに Project Types は Classification、Domains は General (compact) を選びます。

Add images を選択してダウンロードしたファイルの中の assets/Training/Fries 以下の画像を Fries とタグ付けしてアップロードします。 NotFries フォルダの画像を NotFries でタグ付けしてアップロードします。

アップロードが完了したら Train をして Performance から Export を選んでモデルをダウンロードします。

f:id:okazuki:20181217155056p:plain

ONNX だけバージョンが選択できるのですが、今回は最新の Windows 10 SDK を使おうと思うので ONNX 1.2 を選択しました。

Xamarin.Forms アプリの作成

Xamarin.Forms でアプリを作りましょう。Blank で Android, iOS, UWP で .NET Standard を選択して OK を選びます。

f:id:okazuki:20181217155559p:plain

画像撮影機能の追加

前は Xamarin Plugins を使うのが一般的でしたが今は Xamarin.Essentials を使う方がいいのかな?

docs.microsoft.com

と思ってみたらカメラ無い??えっ???まじで???ということで Media Plugin for Xamarin and Windows を使います。

www.nuget.org

全プロジェクトに上記パッケージをインストールします。インストールすると readme.txt が出てくるので、それに従って各プロジェクトの設定を変更します。

設定が終わったら MainPage.xaml を以下のようにします。画像表示用コントロールと写真をとるためのボタンを置いてます。

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage
    x:Class="AIApp.MainPage"
    xmlns="http://xamarin.com/schemas/2014/forms"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:ios="clr-namespace:Xamarin.Forms.PlatformConfiguration.iOSSpecific;assembly=Xamarin.Forms.Core"
    xmlns:local="clr-namespace:AIApp"
    Title="Safe Area"
    ios:Page.UseSafeArea="True">

    <StackLayout>
        <Image
            x:Name="picture"
            Aspect="AspectFill"
            VerticalOptions="FillAndExpand" />
        <Label x:Name="output" HorizontalOptions="CenterAndExpand" />
        <StackLayout Orientation="Horizontal">
            <Button
                Clicked="PickPhotoButton_Clicked"
                HorizontalOptions="FillAndExpand"
                Text="Pick a picture" />
            <Button
                Clicked="TakePhotoButton_Clicked"
                HorizontalOptions="FillAndExpand"
                Text="Take a picture" />
        </StackLayout>
    </StackLayout>

</ContentPage>

として、MainPage.xaml.cs を以下のようにして AI を使う手前まで実装します。

using Plugin.Media;
using Plugin.Media.Abstractions;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Xamarin.Forms;

namespace AIApp
{
    public partial class MainPage : ContentPage
    {
        public MainPage()
        {
            InitializeComponent();
        }

        private async void TakePhotoButton_Clicked(object sender, EventArgs e)
        {
            await ProcessPhotoAsync(true);
        }

        private async void PickPhotoButton_Clicked(object sender, EventArgs e)
        {
            await ProcessPhotoAsync(false);
        }

        private async Task ProcessPhotoAsync(bool useCamera)
        {
            await CrossMedia.Current.Initialize();
            if (useCamera ? !CrossMedia.Current.IsTakePhotoSupported : !CrossMedia.Current.IsPickPhotoSupported)
            {
                await DisplayAlert("Info", "Your phone doesn't support photo feature.", "OK");
                return;
            }

            var photo = useCamera ? 
                await CrossMedia.Current.TakePhotoAsync(new StoreCameraMediaOptions()) : 
                await CrossMedia.Current.PickPhotoAsync();
            if (photo == null)
            {
                picture.Source = null;
                return;
            }

            picture.Source = ImageSource.FromFile(photo.Path);
            // TODO: using AI.
        }
    }
}

これで撮影して画像を表示するところまで出来ました。Android 9 のエミュレーターで動かすと以下のように動きます。

f:id:okazuki:20181217165621p:plain
起動直後

f:id:okazuki:20181217165654p:plain
カメラ起動中

f:id:okazuki:20181217165725p:plain
撮影後

では、ここに AI の機能を追加していきましょう。

インターフェースを考える

ここから先は各プラットフォームごとの実装が必要になります。.NET Standard のプロジェクトにはインターフェースを作成します。画像のストリームを受け取って Fries か NotFries の enum を返すようにしました。

using System.IO;
using System.Threading.Tasks;

namespace AIApp
{
    public interface IPhotoDetector
    {
        Task<FriesOrNotFriesTag> DetectAsync(Stream photo);
    }

    public enum FriesOrNotFriesTag
    {
        None,
        Fries,
        NotFries,
    }
}

そして MainPage.xaml.cs に、このインターフェースを使った処理を書きます。

using Plugin.Media;
using Plugin.Media.Abstractions;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Xamarin.Forms;

namespace AIApp
{
    public partial class MainPage : ContentPage
    {
        public MainPage()
        {
            InitializeComponent();
        }

        private async void TakePhotoButton_Clicked(object sender, EventArgs e)
        {
            await ProcessPhotoAsync(true);
        }

        private async void PickPhotoButton_Clicked(object sender, EventArgs e)
        {
            await ProcessPhotoAsync(false);
        }

        private async Task ProcessPhotoAsync(bool useCamera)
        {
            await CrossMedia.Current.Initialize();
            if (useCamera ? !CrossMedia.Current.IsTakePhotoSupported : !CrossMedia.Current.IsPickPhotoSupported)
            {
                await DisplayAlert("Info", "Your phone doesn't support photo feature.", "OK");
                return;
            }

            var photo = useCamera ? 
                await CrossMedia.Current.TakePhotoAsync(new StoreCameraMediaOptions()) : 
                await CrossMedia.Current.PickPhotoAsync();
            if (photo == null)
            {
                picture.Source = null;
                return;
            }

            picture.Source = ImageSource.FromFile(photo.Path);

            var service = DependencyService.Get<IPhotoDetector>();
            if (service == null)
            {
                await DisplayAlert("Info", "Not implemented the feature on your device.", "OK");
                return;
            }

            using (var s = photo.GetStream())
            {
                var result = await service.DetectAsync(s);
                output.Text = $"It looks like a {result}";
            }
        }
    }
}

この状態で実行すると、まだ実装がないので以下のようになります。

f:id:okazuki:20181217171239p:plain

プラットフォームごとの実装

では、各プラットフォームごとに実装していきます。

UWP

まずは UWP からいってみましょう。UWP では October 2018 Update で追加されたものを使うので UWP プロジェクトのプロパティの Application の Target version と Min version を 1809 に設定します。

f:id:okazuki:20181217171515p:plain

Windows 10 で使う API は Windows Machine Learning です。

docs.microsoft.com

Custom Vision からダウンロードした onnx ファイルを FriesOrNotFries.onnx` にリネームして UWP プロジェクトの Assets フォルダに追加します。 ビルドアクションはコンテンツにしておきましょう。

f:id:okazuki:20181217172253p:plain

こんな感じのコードが生成されているはずです。

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Windows.Media;
using Windows.Storage;
using Windows.Storage.Streams;
using Windows.AI.MachineLearning;
namespace AIApp.UWP
{
    
    public sealed class FriesOrNotFriesInput
    {
        public ImageFeatureValue data; // BitmapPixelFormat: Bgra8, BitmapAlphaMode: Premultiplied, width: 227, height: 227
    }
    
    public sealed class FriesOrNotFriesOutput
    {
        public TensorString classLabel; // shape(-1,1)
        public IList<Dictionary<string,float>> loss;
    }
    
    public sealed class FriesOrNotFriesModel
    {
        private LearningModel model;
        private LearningModelSession session;
        private LearningModelBinding binding;
        public static async Task<FriesOrNotFriesModel> CreateFromStreamAsync(IRandomAccessStreamReference stream)
        {
            FriesOrNotFriesModel learningModel = new FriesOrNotFriesModel();
            learningModel.model = await LearningModel.LoadFromStreamAsync(stream);
            learningModel.session = new LearningModelSession(learningModel.model);
            learningModel.binding = new LearningModelBinding(learningModel.session);
            return learningModel;
        }
        public async Task<FriesOrNotFriesOutput> EvaluateAsync(FriesOrNotFriesInput input)
        {
            binding.Bind("data", input.data);
            var result = await session.EvaluateAsync(binding, "0");
            var output = new FriesOrNotFriesOutput();
            output.classLabel = result.Outputs["classLabel"] as TensorString;
            output.loss = result.Outputs["loss"] as IList<Dictionary<string,float>>;
            return output;
        }
    }
}

では、これを使って先ほど作ったインターフェースの実装を作っていきます。

PhotoDetector.cs を UWP プロジェクトに作成して以下のようなコードを書きます。先ほどの自動生成されたファイルを使って画像を判別しています。

using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Windows.AI.MachineLearning;
using Windows.Graphics.Imaging;
using Windows.Media;
using Windows.Storage;
using Xamarin.Forms;

[assembly: Dependency(typeof(AIApp.UWP.PhotoDetector))]
namespace AIApp.UWP
{
    public class PhotoDetector : IPhotoDetector
    {
        private FriesOrNotFriesModel _model;
        public async Task<FriesOrNotFriesTag> DetectAsync(Stream photo)
        {
            await InitializeModelAsync();
            var bitmapDecoder = await BitmapDecoder.CreateAsync(photo.AsRandomAccessStream());
            var output = await _model.EvaluateAsync(new FriesOrNotFriesInput
            {
                data = ImageFeatureValue.CreateFromVideoFrame(VideoFrame.CreateWithSoftwareBitmap(await bitmapDecoder.GetSoftwareBitmapAsync())),
            });
            var label = output.classLabel.GetAsVectorView().FirstOrDefault();
            return Enum.Parse<FriesOrNotFriesTag>(label);
        }

        private async Task InitializeModelAsync()
        {
            if (_model != null)
            {
                return;
            }

            var onnx = await StorageFile.GetFileFromApplicationUriAsync(new Uri("ms-appx:///Assets/FriesOrNotFries.onnx"));
            _model = await FriesOrNotFriesModel.CreateFromStreamAsync(onnx);
        }
    }
}

この状態で UWP のプロジェクトをスタートアッププロジェクトにすると以下のように画像認識機能が動きます。

f:id:okazuki:20181217174955p:plain
ちらし寿司はフライじゃないと判定された様子

f:id:okazuki:20181217175041p:plain
ポテトフライがフライと認識された様子

Android

Android で使う API は…、ネイティブの Android だと以下のライブラリになるみたいです。

Maven Repository: org.tensorflow » tensorflow-android

実際に blog.xamarin.com の中に、これをネイティブバインディングしてるライブラリの Xam.Android.Tensorflow を使っている記事があります。

Using TensorFlow and Azure to Add Image Classification to Your Android Apps

NuGet はこちら。

www.nuget.org

実際に Android プロジェクトに Xam.Android.Tensorflow を追加します。そして、上記ブログと以下の Java のサンプルを参考にしてコードを書きます。

github.com

まず、model.pb と labels.txt を Android プロジェクトの Assets フォルダーに追加して、ビルドアクションを AndroidAsset に設定します。PhotoDetector.cs を Android プロジェクトに追加して以下のように実装します。

using Android.Graphics;
using Org.Tensorflow.Contrib.Android;
using Plugin.CurrentActivity;
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Xamarin.Forms;

[assembly: Dependency(typeof(AIApp.Droid.PhotoDetector))]
namespace AIApp.Droid
{
    public class PhotoDetector : IPhotoDetector
    {
        private static readonly string ModelFile = "model.pb";
        private static readonly string LabelFile = "labels.txt";
        private static readonly string InputName = "Placeholder";
        private static readonly string OutputName = "loss";
        private static readonly int InputSize = 227;
        private readonly TensorFlowInferenceInterface _inferenceInterface;
        private readonly string[] _labels;

        public PhotoDetector()
        {
            _inferenceInterface = new TensorFlowInferenceInterface(CrossCurrentActivity.Current.Activity.Assets, ModelFile);
            using (var sr = new StreamReader(CrossCurrentActivity.Current.Activity.Assets.Open(LabelFile)))
            {
                _labels = sr.ReadToEnd().Split('\n').Select(x => x.Trim()).Where(x => !string.IsNullOrEmpty(x)).ToArray();
            }
        }

        public async Task<FriesOrNotFriesTag> DetectAsync(Stream photo)
        {
            var bitmap = await BitmapFactory.DecodeStreamAsync(photo);
            var floatValues = GetBitmapPixels(bitmap);
            var outputs = new float[_labels.Length];
            _inferenceInterface.Feed(InputName, floatValues, 1, InputSize, InputSize, 3);
            _inferenceInterface.Run(new[] { OutputName });
            _inferenceInterface.Fetch(OutputName, outputs);
            var index = Array.IndexOf(outputs, outputs.Max());
            return (FriesOrNotFriesTag)Enum.Parse(typeof(FriesOrNotFriesTag), _labels[index]);
        }

        private async Task<byte[]> LoadByteArrayFromAssetsAsync(string name)
        {
            using (var s = CrossCurrentActivity.Current.Activity.Assets.Open(name))
            using (var ms = new MemoryStream())
            {
                await s.CopyToAsync(ms);
                ms.Seek(0, SeekOrigin.Begin);
                return ms.ToArray();
            }
        }

        private static float[] GetBitmapPixels(Bitmap bitmap)
        {
            var floatValues = new float[InputSize * InputSize * 3];
            using (var scaledBitmap = Bitmap.CreateScaledBitmap(bitmap, InputSize, InputSize, false))
            {
                using (var resizedBitmap = scaledBitmap.Copy(Bitmap.Config.Argb8888, false))
                {
                    var intValues = new int[InputSize * InputSize];
                    resizedBitmap.GetPixels(intValues, 0, resizedBitmap.Width, 0, 0, resizedBitmap.Width, resizedBitmap.Height);
                    for (int i = 0; i < intValues.Length; ++i)
                    {
                        var val = intValues[i];
                        floatValues[i * 3 + 0] = ((val & 0xFF) - 104);
                        floatValues[i * 3 + 1] = (((val >> 8) & 0xFF) - 117);
                        floatValues[i * 3 + 2] = (((val >> 16) & 0xFF) - 123);
                    }
                    resizedBitmap.Recycle();
                }
                scaledBitmap.Recycle();
            }

            return floatValues;
        }
    }
}

Android のエミュレーターで実行すると…

f:id:okazuki:20181218121351p:plain
フライとして認識された

f:id:okazuki:20181218121531p:plain
フライじゃないと認識された

iOS

続いて iOS の CoreML をしていきます。Xamarin の公式ドキュメントはこちら。

docs.microsoft.com

前に試したときは mac に mlmodel を持っていってコマンドを打ってとかしてましたが、そこらへんの手順は簡略化されてるみたいですね。iOS プロジェクトの Resources フォルダに mlmodel を追加します。そしてビルドアクションを CoreMLModel にします。

そして、PhotoDetector.cs を iOS プロジェクトに追加して以下のように実装します。

using CoreFoundation;
using CoreImage;
using CoreML;
using Foundation;
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Vision;
using Xamarin.Forms;

[assembly: Dependency(typeof(AIApp.iOS.PhotoDetector))]
namespace AIApp.iOS
{
    public class PhotoDetector : IPhotoDetector
    {
        private readonly MLModel _mlModel;
        private readonly VNCoreMLModel _model;

        public PhotoDetector()
        {
            var assetPath = NSBundle.MainBundle.GetUrlForResource("FriesOrNotFries", "mlmodelc");
            _mlModel = MLModel.Create(assetPath, out var _);
            _model = VNCoreMLModel.FromMLModel(_mlModel, out var __);
        }

        public Task<FriesOrNotFriesTag> DetectAsync(Stream photo)
        {
            var taskCompletionSource = new TaskCompletionSource<FriesOrNotFriesTag>();
            void handleClassification(VNRequest request, NSError error)
            {
                var observations = request.GetResults<VNClassificationObservation>();
                if (observations == null)
                {
                    taskCompletionSource.SetException(new Exception("Unexpected result type from VNCoreMLRequest"));
                    return;
                }

                if (!observations.Any())
                {
                    taskCompletionSource.SetResult(FriesOrNotFriesTag.None);
                    return;
                }

                var best = observations.First();
                taskCompletionSource.SetResult((FriesOrNotFriesTag)Enum.Parse(typeof(FriesOrNotFriesTag), best.Identifier));
            }

            using (var data = NSData.FromStream(photo))
            {
                var ciImage = new CIImage(data);
                var handler = new VNImageRequestHandler(ciImage, new VNImageOptions());
                DispatchQueue.DefaultGlobalQueue.DispatchAsync(() =>
                {
                    handler.Perform(new VNRequest[] { new VNCoreMLRequest(_model, handleClassification) }, out var _);
                });
            }

            return taskCompletionSource.Task;
        }
    }
}

実行すると…

f:id:okazuki:20181218163815p:plain
iPhone でフライを選んだ場合

f:id:okazuki:20181218163855p:plain
フライじゃないものを選択した場合

動いてますね!

ソースコード

GitHub に置いてます。

github.com