AndroidでML Kitのベースモデルを使って画像にラベル付けする

<dl>
<dt>システム要件</dt>
<dd>Android API レベル 19 以降</dd>
<dt>言語</dt>
<dd>Java</dd>
</dl>

[ML Kit](https://developers.google.com/ml-kit?hl=ja)は、Googleの機械学習用のSDKだが、[ここ](https://developers.google.com/ml-kit/guides?hl=ja)に書いてある通り、データをスマホのみで処理するSDKである。

> ML Kit は、Google のオンデバイスの機械学習のエキスパティーズを Android と iOS のアプリに提供するモバイル SDK です。

以前は確かクラウド型のものもあったのだがと思ったら、[移行ガイド](https://developers.google.com/ml-kit/migration?hl=ja)に以下のような記述があり、ML Kitはオンデバイスのみになったと書かれている。

> 2020 年 6 月 3 日に、デバイス上の API とクラウドベースの API を区別しやすくするために、Firebase 向け ML Kit に一部変更を加えました。現在の API セットは、次の 2 つのプロダクトに分割されました。

と言うわけで、ML Kitを使えば、Googleに画像を送信することなく、ラベル付けができる。
ML Kitによる画像のラベル付けは、[画像のラベル付け](https://developers.google.com/ml-kit/vision/image-labeling?hl=ja)に従うだけなので、ここでまとめるまでも無いのだが、備忘録としてまとめた。
ML Kitには、ベースモデルとTensorFlow Liteによるカスタムモデルがあるのだが、ここでは、まずお手軽に[ベースモデル](https://developers.google.com/ml-kit/vision/image-labeling/android?hl=ja)でのラベル付けを試した。

まず、ML Kitの依存関係を`build.gradle`に記述する。依存関係は、学習モデルをアプリにバンドルする場合とGoogle Play開発者サービスを使う場合とで異なる。後者の場合は、以下のように記述する。

```app/build.gradle
dependencies {
...
    // Use this dependency to use the dynamically downloaded model in Google Play Services
    implementation 'com.google.android.gms:play-services-mlkit-image-labeling:16.0.8'
}
```

APIレベルを19以上に設定する。以下の例では24に設定している。後で使うので`viewBinding`も設定している。

```app/build.gradle
android {
...
    defaultConfig {
        minSdk 24
    }

    buildFeatures {
        viewBinding true
    }
}
```

モデルを自動ダウンロードするために、`AndroidManifest.xml`に以下の記述を追加する。

```AndroidManifest.xml
    <application>
        <meta-data
           android:name="com.google.mlkit.vision.DEPENDENCIES"
            android:value="ica" />
    </application>
```

もっとも簡単な例として、デバイス内の画像にラベル付けするコードを書いてみる。ML Kitでは、画像を`InputImage`オブジェクトにしなければならない。画像ファイルのURIから`InputImage`を作成するには以下のようにする。

```
InputImage image = InputImage.fromFilePath(this, uri);
```

ラベル付けするためには、画像ラベラーのインスタンスを作成する。

```
ImageLabeler labeler = ImageLabeling.getClient(ImageLabelerOptions.DEFAULT_OPTIONS);
```

次に画像ラベラーインスタンスの`process`メソッドに`InputImage`を渡せば完了である。結果は`OnSuccessListener`と`OnFailureListener`で処理する。
`onCreate`の中で`registerForActivityResult`を使って画像ファイルをダイアローグで選択してファイルのURIを取得し、`process`メソッドに投げるコードは以下の通りである。結果は`ImageLabel`のリストで返される。`ImageLabel`にはつけられたラベルのテキストとインデックス番号と確信値が格納されている。以下の例では、テキストと確信値を`TextView`に表示している。

```Java:MainActivity.java
        binding = ActivityMainBinding.inflate(getLayoutInflater());

        mResultView = binding.resultView;

        ActivityResultLauncher<String> mGetContent = registerForActivityResult(
                new ActivityResultContracts.GetContent(),
                result -> {
                    if (result != null) {
                        try {
                            InputImage image = InputImage.fromFilePath(this, result);
                            labeler.process(image)
                                    .addOnSuccessListener(new OnSuccessListener<List<ImageLabel>>() {
                                        @Override
                                        public void onSuccess(List<ImageLabel> labels) {
                                            StringBuilder sb = new StringBuilder();
                                            if (labels.size() == 0)
                                                sb.append("no label found");
                                            else
                                                for (ImageLabel label : labels) {
                                                    String text = label.getText();
                                                    float confidence = label.getConfidence();
//                                                int index = label.getIndex();
                                                    sb.append(String.format("%s: %.3f%n", text, confidence));
                                                }
                                            mResultView.setText(sb.toString());
                                        }
                                    })
                                    .addOnFailureListener(new OnFailureListener() {
                                        @Override
                                        public void onFailure(@NonNull Exception e) {
                                            // Task failed with an exception
                                            mResultView.setText("failed to label");
                                        }
                                    });
                        } catch (IOException e) {
                            throw new RuntimeException(e);
                        }
                    }
                }
        );
```

あとは、ボタンを押すと、このが起動するように設定すれば終了である。以下の例では、画像を選択したいので、URIには`"image/*"`を指定している。

```MainActivity.java
        binding.button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                mGetContent.launch("image/*");
            }
        });
```

実行例を以下に示す。正しく **「猫」** と判定している。

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2180229/1ac2bfcf-2edb-b368-12ad-04fb00e0594b.png">

ソース全体を以下に示す。ちなみに、アプリ情報によるとストレージ使用量は **22.06MB** となった。

```activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <ImageView
        android:id="@+id/imageView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:srcCompat="@tools:sample/avatars" />

    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:layout_marginBottom="10dp"
        android:text="Label Image"
        app:layout_constraintBottom_toTopOf="@+id/textView"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/imageView" />

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="10dp"
        android:text="TextView"
        app:layout_constraintBottom_toTopOf="@+id/resultView"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/button" />

    <TextView
        android:id="@+id/resultView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="TextView"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/textView" />

</androidx.constraintlayout.widget.ConstraintLayout>
```

```MainActivity.java
package com.yoo6309.imagelabelerbymlkit;

import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;

import android.os.Bundle;
import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;

import com.google.android.gms.tasks.OnFailureListener;
import com.google.android.gms.tasks.OnSuccessListener;
import com.google.mlkit.vision.common.InputImage;
import com.google.mlkit.vision.label.*;
import com.google.mlkit.vision.label.defaults.ImageLabelerOptions;
import com.yoo6309.imagelabelerbymlkit.databinding.ActivityMainBinding;

import java.io.IOException;
import java.util.List;

public class MainActivity extends AppCompatActivity {
    TextView mResultView;

    private ActivityMainBinding binding;
    private ImageLabeler labeler;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        mResultView = binding.resultView;
        labeler = ImageLabeling.getClient(ImageLabelerOptions.DEFAULT_OPTIONS);
        ActivityResultLauncher<String> mGetContent = registerForActivityResult(
                new ActivityResultContracts.GetContent(),
                result -> {
                    if (result != null) {
                        binding.textView.setText(result.toString());
                        binding.imageView.setImageURI(result);
                        try {
                            InputImage image = InputImage.fromFilePath(this, result);
                            labeler.process(image)
                                    .addOnSuccessListener(new OnSuccessListener<List<ImageLabel>>() {
                                        @Override
                                        public void onSuccess(List<ImageLabel> labels) {
                                            StringBuilder sb = new StringBuilder();
                                            if (labels.size() == 0)
                                                sb.append("no label found");
                                            else
                                                for (ImageLabel label : labels) {
                                                    String text = label.getText();
                                                    float confidence = label.getConfidence();
//                                                int index = label.getIndex();
                                                    sb.append(String.format("%s: %.3f%n", text, confidence));
                                                }
                                            mResultView.setText(sb.toString());
                                        }
                                    })
                                    .addOnFailureListener(new OnFailureListener() {
                                        @Override
                                        public void onFailure(@NonNull Exception e) {
                                            // Task failed with an exception
                                            mResultView.setText("failed to label");
                                        }
                                    });
                        } catch (IOException e) {
                            throw new RuntimeException(e);
                        }
                    }
                }
        );
        binding.button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                mGetContent.launch("image/*");
            }
        });
    }
}
```