Flutter で画像認識 - リアルタイムで認識

「Flutter で画像認識」の続きになります.
前回はカメラ撮った画像を一旦保存して、それに対して認識をしていました。今回はリアルタイム認識を試してみます。

ユーザに助けてもらう

実際に画像に文字認識をかけると、スマホの傾きや位置関係などによって、認識率があまり上がらないことがあるというのは前回書いた通りです。 またカメラで撮影する場合のフォーカスや周囲の明るさなどにも左右されることがわかります。

認識前の画像の傾きや明るさを自動で前処理したりすることもできますが、今回はユーザに助けてもらう方法にします。 カメラ画像にリアルタイムで認識した結果を表示して、読み取れる様にユーザにカメラ位置や環境を調整してもらうことを狙います。

(まあ、この辺の前処理で認識率を稼ぐのが機械学習のエンジニアのスキルの一部なんだと思いますが、それはまた別の機会に。 もしくは対応できる様にモデルを鍛えるのが正統派なのかな)

あと、デバイスでリアルタイムで画像認識をさせるテストですが、実機は Pixel3 を使っています。 クラウドに情報を送って認識させているわけじゃないので、デバイスの速度が影響すると思います。

リアルタイムプレビュー

では実際にリアルタイムの画像認識をやってみましょう。

ソースコードは以下にあります。feature/realtimeブランチです。

github - flutter_read_thermo feature/realtime

前回のものをベースにして、プレビュー画面を修正します。細かい変更はソースの方を参照してください。 大きな変更点だけ、説明します。

まず、カメラからリアルタイムで画像を受け取って処理するところです。

  void _start() {
    _controller?.startImageStream(_processImage);
    setState(() {
      _isStreaming = true;
    });
  }

_controllerは、カメラです。そこにstartImageStream()で関数をセットします。 カメラが画像を準備できるたびに、_processImageがリアルタイムで呼び出される様になります。

その中では、今まで静止画像に行っていた画像認識処理を行います。かなり長くなるので、順番に。 (本当は、処理単位で関数分た方が良いです。今回はテストプログラムなのでって言い訳しておきます)

  void _processImage(CameraImage cameraImage) async {
    if (!_isDetecting && mounted) {
      _isDetecting = true;
      try {
        final rotation = _rotationIntToImageRotation(widget.camera.sensorOrientation);
        final RegExp regEx = RegExp(r"[0-9\.]*");

前処理としては、カメラのRotationをみます。 この値は、デバイスのセンサーから取得できて、端末の向きによって、0,90,180,270になります。

カメラの画像は、当然ですが、その時の端末の向きのままです。ただし、認識エンジンは正像に対して行うことが前提になっているので、 この値を使って、適宜回転します (実際には、角度を引数で与えると、ライブラリが自動で補正してくれます)。

        final RecognisedText recognisedText =
            await _detect(cameraImage, _detector.processImage, rotation!);
        List<TextElement> _detectedElements = [];
        for (TextBlock block in recognisedText.blocks) {
          // print('block: ${block.text}');
          for (TextLine line in block.lines) {
            // print('text: ${line.text}');
            for (TextElement element in line.elements) {
              if (regEx.hasMatch(line.text)) {
                final matches = regEx.allMatches(line.text);
                final matchStrings = matches.map((element) => element.group(0));
                final String result = matchStrings.join();
                if (result.length > 0) {
                  final digits = double.parse(result);
                  print('result: "$digits" ${element.rect}');
                  if (digits > 100 && _detectedDigit != result) {
                    final croppedImage = _getCroppedImage(
                        cameraImage, widget.camera.sensorOrientation, element.rect);
                    debugPrint('${croppedImage.width} x ${croppedImage.height}');
                    final decodedBytes = Uint8List.fromList(imglib.encodePng(croppedImage));
                    setState(() {
                      _detectedImage = decodedBytes;
                      _detectedDigit = result;
                    });
                  }
                }
              }
              _detectedElements.add(element);
            }
          }
        }

認識部分は、前回のものとロジックとしては同じです。 _detect()は後で見ますが、_detector.processImage()を呼び出して、画像の中のテキストを認識しています。

結果がrecognisedTextに返されますが、あとはその中を block(節) -> line(行) -> element(単語) でたどっていって 認識された結果を、_detectedElementsにセットしていきます。

今回は、数値だけを取り出したいので、結果に対してフィルターを掛けています。 if (regEx.hasMatch(line.text))のブロックの中は、フィルター処理です。

また、数値が認識されたら、その時に使った画像を_detectedImageに保持する様にしています。

        setState(() {
          _imageSize = Size(cameraImage.height.toDouble(), cameraImage.width.toDouble());
          _elements = _detectedElements;
        });
        // 250msスリープさせて負荷を下げる. (本来なら、backpressure掛ける方が良いかも)
        await Future.delayed(Duration(milliseconds: 250));
      } catch (ex, stack) {
        debugPrint('$ex, $stack');
      }
      _isDetecting = false;
    }
  }

認識できたら、setState()でステートを変更して、UIに描画します。

その後のFuture.delayedは、スリープの処理です。awaitすると実行終了まで処理がブロックされます。 (ここですが、負荷を下げるのにスリープ処理するのが正しいか、まだ調べてません)

実際の画面の方ですが、カメラのプレビュー画面の上に、前回作ったTextDetectorPainterを重ねて、認識結果を描画しています。 また、数値が認識されると、その時の画像と共に、画面の下の方に結果を残す様にしています。

detect digits

サポート関数

utils.dart にサポート用の関数をいくつか追加しています。

そちらの方を簡単に。 まずは、認識部分から。

Future<T> _detect<T>(
  CameraImage image,
  HandleDetection<T> handleDetection,
  InputImageRotation rotation,
) async {
  return handleDetection(
    InputImage.fromBytes(
      bytes: _concatenatePlanes(image.planes),
      inputImageData: _buildImageData(image, rotation),
    ),
  );
}

InputImage.fromBytes()はメモリ中の画像を認識する場合に使います。

bytesは、画像データで、カメラのプレーン(YUV420形式)の生データを使います。

また、inputImageDataには、その画像の属性メタデータをセットします。

imglib.Image _convertYUV420(CameraImage image) {

_convertYUV420()は、YUV420をRGB画像に変換して、image パッケージで扱える様にします。

imglib.Image _getCroppedImage(CameraImage cameraImage, int rotation, Rect rect) {

_getCroppedImage()は、カメラ画像の中で、数値認識した部分の画像を保存するために、切り抜き処理をします。 カメラ画像は、デバイスの向きのままになっているので、処理前に回転して正像に戻したりしています。

終わりに

MLKitの画像認識は、デバイス内で行われているので、そこそこリアルタイムで動きます。 (テキスト認識だけではなくて、顔認識や人物のポーズ等もちょっとの変更で可能です)

実際に色々なものを認識してみるとわかりますが、誤認識も多いです。 今回のアプリですが、数字の認識に特化したものにしようと思っていますので、やはりそちらに合わせてモデルの学習とかが必要なのかもしれません。