はじめに
この記事はFlutter Advent Calendar 2021の17日目の記事です。
今回は、Flutterアプリでリアルタイム物体検出をやります
こんな感じです
(PCに画像を映してカメラで撮ってます、GIFにしたかったんですが圧縮しても上手くいきませんでした)
リアルタイム物体検出自体は、tfliteというパッケージのExampleや、@welchi さんの昨年のアドベントカレンダー「FlutterとTensorFlow Lite(TFLite)でリアルタイム物体検出をしてみる」でも紹介されています。
しかし、これらの記事ではYOLOv2や、SSD(Single shot multibox detector)といった深層学習モデルが使われています。YOLOv2は少し古く、SSDはリアルタイム性より精度に重きをおいたモデルです。そこで今回は新しいかつ、リアルタイム性を重視したYOLOv5を使いました。
また、Flutter × YOLOv5 はポリリズムの提案可能技術紹介 - FlutterとYOLOv5による物体認識で既に実装されてる方もいるようです。しかし、こちらは一部のコードをSwiftで書いているらしく、Macを持っていない僕からすると
Flutterオンリーでやってくれぇよぉおお!!
と思わずにはいられません。
そこで今回は、僕のようなMacを持ってない方でもできるよう、Flutterオンリーで実装したのでご紹介します
今回実装したアプリは以下のリポジトリで公開しています
https://github.com/syu-kwsk/flutter_yolov5_app
動作環境
- Ubuntu20.04
- Android Studio v2020.3
- Flutter v2.8.0
- Dart v2.15.0
- Pixel5 Android12
本編
カメラの起動やUI部分は先ほど紹介させて頂いた@welchiさんの記事「FlutterとTensorFlow Lite(TFLite)でリアルタイム物体検出をしてみる」を大変参考にしています。なのでtfliteパッケージのインストールやuiの部分は割愛させて頂きます。一方で、去年の記事なこともありFlutter2系、特にnull safetyのコードではない部分が多かったのでその部分は自分のリポジトリでは直しています。
以下では、自分が記事を参考にする中でYOLOv5特有のものやハマったところを書いていきます。
この記事を読む前に一度@welchiさんの記事を読んでもらって、コードなどさぁ実装するぞ!ってときにこれを参考にしていただければ幸いです。
① YOLOv5のモデルを用意(Python)
いきなりFlutterじゃないのが出てきて、すみません(さっきまでFlutterオンリーとか言ってたくせに)
YOLOv5はPytorchというフレームワークで実装されており、そのままではFlutterで動かすことはできません。そこでTensor Flow Liteというモバイル向けの軽量な形式に変換する必要があります。さらに、tflite_flutterというFlutterのパッケージを用いてdart:ffiでC++のAPIを動かすことでFlutterから推論を行うことができます。
Tensor Flow Liteの形式にはYOLOv5のリポジトリ内にexport.pyという変換用スクリプトが置いてあるのでカスタムモデルをPytorchで学習したらすぐにtfliteのモデルも作れます(歓喜)
このアプリのリポジトリのassetsディレクトリにcoco128の学習済みモデルを置いているので、学習環境がなくても試すことはできます(Flutterファースト)
② --enable-isolate-groups=true
Androidの場合、カメラから取得した画像がyuv420形式のため、識別器に渡す前にRGB形式に変換します
class ImageUtils {
/// Converts a [CameraImage] in YUV420 format to
/// [image_lib.Image] in RGB format
static image_lib.Image convertYUV420ToImage(CameraImage cameraImage) {
final width = cameraImage.width;
final height = cameraImage.height;
final uvRowStride = cameraImage.planes[1].bytesPerRow;
final uvPixelStride = cameraImage.planes[1].bytesPerPixel;
final image = image_lib.Image(width, height);
for (var w = 0; w < width; w++) {
for (var h = 0; h < height; h++) {
final uvIndex =
uvPixelStride! * (w / 2).floor() + uvRowStride * (h / 2).floor();
final index = h * width + w;
final y = cameraImage.planes[0].bytes[index];
final u = cameraImage.planes[1].bytes[uvIndex];
final v = cameraImage.planes[2].bytes[uvIndex];
image.data[index] = ImageUtils.yuv2rgb(y, u, v);
}
}
return image;
}
/// Convert a single YUV pixel to RGB
static int yuv2rgb(int y, int u, int v) {
// Convert yuv pixel to rgb
var r = (y + v * 1436 / 1024 - 179).round();
var g = (y - u * 46549 / 131072 + 44 - v * 93604 / 131072 + 91).round();
var b = (y + u * 1814 / 1024 - 227).round();
// Clipping RGB values to be inside boundaries [ 0 , 255 ]
r = r.clamp(0, 255).toInt();
g = g.clamp(0, 255).toInt();
b = b.clamp(0, 255).toInt();
return 0xff000000 |
((b << 16) & 0xff0000) |
((g << 8) & 0xff00) |
(r & 0xff);
}
}
しかし、ここで
E/flutter (19516): [ERROR:flutter/runtime/dart_isolate.cc(1138)] Unhandled exception:
E/flutter (19516): RangeError (index): Index out of range: index should be less than 345600: 10
E/flutter (19516): #0 _Uint8ArrayView.[] (dart:typed_data-patch/typed_data_patch.dart:4070:7)
E/flutter (19516): #1 ImageUtils.convertYUV420ToImage (package:anh_flutter/recognition/ImageUtils.dart:42:46)
E/flutter (19516): #2 ImageUtils.convertCameraImage (package:anh_flutter/recognition/ImageUtils.dart:13:14)
E/flutter (19516): #3 IsolateUtils.entryPoint (package:anh_flutter/recognition/IsolateUtils.dart:47:30)
みたいなエラーが出てくるときがあります。インデックスエラーなんですが明らかに範囲内なんですよね(345600以内であるべきなのに対し10)
これ、どうやらDartのバージョンによってデフォルト値が変わる--enable-isolate-groups
フラグが原因みたいです。
以下で議論がされていました。
https://issueexplorer.com/issue/flutter/flutter/89584
対策としてはdart2.15.0ではデフォルト値が変更されたので特にすることはありません。2.15.0以前では
flutter run --enable-isolate-groups=true
とオプションをつけてビルドする必要があります。
③ 入力行列に正規化が必要
SSDの場合、入力画像に対する前処理は元々のものだけでいいのですがYOLOv5の場合、各画素値を0~1に正規化する必要がありました。これをしないとモデル内の計算で値がオーバーしてしまい出力がすべてNaNということになりました(何ということでしょう)
具体的にはpredict関数内で以下のようにしています。
var inputImage = TensorImage.fromImage(image);
inputImage = getProcessedImage(inputImage);
/// normalize from zero to one
List<double> normalizedInputImage = [];
for (var pixel in inputImage.tensorBuffer.getDoubleList()) {
normalizedInputImage.add(pixel / 255.0);
}
var normalizedTensorBuffer = TensorBuffer.createDynamic(TfLiteType.float32);
normalizedTensorBuffer.loadList(normalizedInputImage, shape: [inputSize, inputSize, 3]);
final inputs = [normalizedTensorBuffer.buffer];
RGBが8ビットなので255で割ってしまえば必ず0~1に収まりますね
④ 出力行列からRecognitionの作成
次にYOLOv5の出力形式なんですが、これが厄介でした。
普通、物体検出では出力行列の形式が、検出座標・確信度・クラス名などで次元が分かれたものとなっています。
以下はもとの記事における出力行列の処理です
// これらのTensorBufferで、推論結果を受け取る
final outputLocations = TensorBufferFloat(_outputShapes[0]);
final outputClasses = TensorBufferFloat(_outputShapes[1]);
final outputScores = TensorBufferFloat(_outputShapes[2]);
final numLocations = TensorBufferFloat(_outputShapes[3]);
// runForMultipleInputsへの入力オブジェクト
final inputs = [inputImage.buffer];
final outputs = {
0: outputLocations.buffer,
1: outputClasses.buffer,
2: outputScores.buffer,
3: numLocations.buffer,
};
// 推論!
_interpreter.runForMultipleInputs(inputs, outputs);
// 推論結果をいくつ返すか
final resultCount = min(numResults, numLocations.getIntValue(0));
const labelOffset = 1;
// バウンディングボックスを表す値を矩形に変換
final locations = BoundingBoxUtils.convert(
tensor: outputLocations,
valueIndex: [1, 0, 3, 2],
boundingBoxAxis: 2,
boundingBoxType: BoundingBoxType.BOUNDARIES,
coordinateType: CoordinateType.RATIO,
height: inputSize,
width: inputSize,
);
// 推論結果からRecognitionを作成
final recognitions = <Recognition>[];
for (var i = 0; i < resultCount; i++) {
final score = outputScores.getDoubleValue(i);
final labelIndex = outputClasses.getIntValue(i) + labelOffset;
final label = _labels.elementAt(labelIndex);
if (score > threshold) {
final transformRect = imageProcessor.inverseTransformRect(
locations[i],
image.height,
image.width,
);
recognitions.add(
Recognition(i, label, score, transformRect),
);
}
}
outputXXXXなどにそれぞれの座標やクラスなどが入れられ、それをもとにバウンディックボックスがつくられます。
一方でYOLOv5の場合は、
/// tensor for results of inference
final outputLocations = TensorBufferFloat(_outputShapes[0]);
final outputs = {
0: outputLocations.buffer,
};
_interpreter!.runForMultipleInputs(inputs, outputs);
/// make recognition
final recognitions = <Recognition>[];
List<double> results = outputLocations.getDoubleList();
for (var i = 0; i < results.length; i += (5 + clsNum)) {
// check obj conf
if (results[i + 4] < objConfTh) continue;
/// check cls conf
// double maxClsConf = results[i + 5];
double maxClsConf = results.sublist(i + 5, i + 5 + clsNum - 1).reduce(max);
if (maxClsConf < clsConfTh) continue;
/// add detects
// int cls = 0;
int cls = results.sublist(i + 5, i + 5 + clsNum - 1).indexOf(maxClsConf) % clsNum;
Rect outputRect = Rect.fromCenter(
center: Offset(
results[i] * inputSize,
results[i + 1] * inputSize,
),
width: results[i + 2] * inputSize,
height: results[i + 3] * inputSize,
);
Rect transformRect = imageProcessor!.inverseTransformRect(outputRect, image.height, image.width);
recognitions.add(
Recognition(i, cls, maxClsConf, transformRect)
);
}
と、なんだか一気にめんどくさそうに見えます。
まず、第一にYOLOv5の出力次元は1つにまとめられます。座標も、確信度もクラスもです。でさらに、ややこしいのですがYOLOv5の確信度は「物体である確からしさ」と「そのクラスである確からしさ」の2つがあります。さっきまではクラスの確からしさが最も高いもののみを結果としていたのですが、そうではなくすべてのクラスの確信度が結果に入ります
例を上げると、人(person)を検出した場合に、personである確率だけでなく
person: 0.98, dog: 0.01, cat: 0.01, car: 0.02,
のように犬や猫、車なんかも出してきます。
一つの検出位置に対し
最初の4要素が座標、次に物体の確信度、それぞれのクラスの確信度と続きます
[0:3 x_center, y_center, w, h; 4 conf; 5: class0 class1 .....]
座標や物体である確信度も一つの次元に収まっているので、結局それらを取得するならListのindexで指定します(汚い)
また、バウンディックボックスの作成もSSDの場合はtflite_flutter
パッケージでBoundingBoxUtils.convert()
という関数が用意されており出力結果の行列をそのまま抜き出せばいいのですが、今回それは使えなかったので無理やりRectを生成しています(汚い×2)
ここらへんの実装が脳筋な気がしているので、効率のいいやり方を知っていましたら教えてください(喜びます)
⑤ カメラのアスペクト比
ここまで来たら、「あとは検出結果をStackで画面に出せばいいだけ…」と思ってたんですが
バウンディックボックスが映らねぇえええ!loggerで検出位置とかクラスは出てるのにっ!
という謎に陥りました。
原因はCameraプラグインの破壊的変更でした
https://pub.dev/packages/camera/changelog#070 にかいてあるのですが、
CameraValue.aspectRatio now returns width / height rather than height / width
とアスペクト比の定数が分母分子入れ替わったみたいです(なんやその、地味に辛い変更)
カメラプレビューを表示する際、以下のようにアスペクト比をかけて正しい位置に表示されるようにしてたのですが、まさかこれが変わってるとは…(大事なのでコメントに同じリンク入れました)
class CameraView extends StatelessWidget {
const CameraView({
Key? key,
required this.cameraController,
}) : super(key: key);
final CameraController cameraController;
@override
Widget build(BuildContext context) {
return AspectRatio(
/// from camera 0.7.0, change aspect ratio
/// https://pub.dev/packages/camera/changelog#070
aspectRatio: 1 / cameraController.value.aspectRatio,
child: CameraPreview(cameraController),
);
}
}
結局これで3時間ぐらい溶けた
最後に
だいたい以上が、ハマったところというか今回話したかった内容です。
先人たちに大変お世話になったのですが、FlutterオンリーでYOLOv5を動かしたのは世界初です(タイトル釣りしようか迷ったけど、僕がググった中ではってだけなんでやめといた)
長々とお付き合い頂きありがとうございました。
次は @taitai9847 さんの記事です、お楽しみに!