Flutter で OpenCV

この記事は[Flutter #2 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/flutter-2) 15日目の記事です（投稿したのは、12月18日ですけど）
本当は、6日目に書く予定だったのですがバッドタイミングで [新刊.net](http://sinkan.net) の不具合報告が舞い込んだり12月の週末はサイクリング仲間の忘年会で埋まっていたりで10日以上遅れての公開とあいなりました。

サーバ経験の割合の方が多くスマホアプリの経験は低く、Kotlin は初めて、Objective-C も昔なにかで手伝ったなあ…という記憶がある程度です。

プロジェクトのフルソースは、以下のリポジトリ [hidea/flutter_opencv_app](https://github.com/hidea/flutter_opencv_app) にあげてあります。

#Flutter で OpenCV
最近、Flutter で OpenCV を利用したプロダクトを作っているのでネイティブコードとの連携部分をピックアップして紹介。 名刺等の矩形の輪郭をとって、パースペクティブ変換することにします。

まず、Flutter のバージョンは以下の通りです。

```terminal
$ flutter --version
Flutter 1.13.3-pre.23 • channel master • https://github.com/flutter/flutter.git
Framework • revision c06bf6503a (3 days ago) • 2019-12-13 17:42:35 -0500
Engine • revision e0e0ac0a68
Tools • Dart 2.8.0 (build 2.8.0-dev.0.0 45db297095)
```
iOS の install コマンドの不具合もあって（もうstableに来た？）stable ではなく master を利用しています。

Flutter のプロジェクトを生成。

```terminal
$ flutter create -i objc -a kotlin opencv_app
```
iOS/Android のネイティブコードは、それぞれ Objective-C と Kotlin で。
後からSwiftにしておけば…と思ったりもしたのですが、文字通り後の祭りです。

#まずは共通するFlutter部分から
*pubspec.yaml* の *dependencies* に画像取扱を追加。

```dart:pubspec.yaml
dependencies:
  image_picker: ^0.6.2+1
  image: ^2.1.4
```

アルバムから画像を取得して、そのファイル名をネイティブコードへ渡します。

```dart:lib/main.dart
  void _getGallery({BuildContext context}) async {
    // アルバムから画像を取得
    var image = await ImagePicker.pickImage(source: ImageSource.gallery);
    if (image == null) {
      return;
    }
    // ネイティブコードを呼び出し
    var result = await opencv.invokeMethod('toPerspectiveTransformation',
        <String, dynamic>{'srcPath': image.path});
    setState(() {
      _image = File(result);
    });
  }
```

表示側は *build* の中を適当に。

```dart:lib/main.dart
  children: <Widget>[
    _image == null
    ? Text('No image.')
    : Image.file(_image),
  ],
```

#次にiOS側の実装

OpenCVを扱うために *Podfile* に以下の行を追加。

``` ios:ios/Podfile
pod 'OpenCV'
```

アルバム、カメラアクセスの為に *ios/Runner/Info.plist* にアクセス許可定義を追加。
iOS10以降だと必須とのこと。実際、これなしで動かすとアプリが強制終了します。

```xml:ios/Runner/Info.plist
  <key>NSCameraUsageDescription</key>
  <string>写真の取り込みのため使います</string>
  <key>NSPhotoLibraryUsageDescription</key>	
  <string>写真の取り込みのため使います</string>
```

Flutterからの呼び出される箇所。
引数を取得して画像を読み込み、OpenCVで変換して保存し直してパスを返値とします。

``` objc:ios/Runner/AppDelegate.m
- (BOOL)application:(UIApplication *)application
    didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
  [GeneratedPluginRegistrant registerWithRegistry:self];

  FlutterViewController *controller = (FlutterViewController*)self.window.rootViewController;
  FlutterMethodChannel *opencvChannel = [FlutterMethodChannel
                                         methodChannelWithName:OPENCV_CHANNEL
                                         binaryMessenger:controller];

  [opencvChannel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
    if ([@"toPerspectiveTransformation" isEqualToString:call.method]) {
      // Flutterからの引数を取得
      NSString *srcpath = call.arguments[@"srcPath"];
      // OpenCVクラス
      OpenCV *cv = [OpenCV alloc]; 
      // 画像ファイルを読み込み
      UIImage *src = [UIImage imageWithContentsOfFile:srcpath];

      // exif等を考慮した画像の回転
      UIGraphicsBeginImageContext(src.size);
      [src drawInRect:CGRectMake(0, 0, src.size.width, src.size.height)];
      src = UIGraphicsGetImageFromCurrentImageContext();
      UIGraphicsEndImageContext();

      // 画像変換
      UIImage *dst = [cv toPerspectiveTransformationImg:src];
      // JPEGに変換
      NSData *dataSaveImage = UIImageJPEGRepresentation(dst, 1);
      // ユーザーローカルに新規ファイル生成
      NSString *path = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject];
      NSString *dstpath = [path stringByAppendingPathComponent:@"temp.jpeg"];
      [dataSaveImage writeToFile:dstpath atomically:YES];
      // ファイルパスを返す
      result(dstpath);
    }
  }];

  return [super application:application didFinishLaunchingWithOptions:launchOptions];
}
```

OpenCV の実装は Objective-C++ で行います。

```objc:ios/Runner/OpenCV.h
NS_ASSUME_NONNULL_BEGIN
@interface OpenCV : NSObject
- (UIImage *)toPerspectiveTransformationImg:(UIImage *)img;
@end
NS_ASSUME_NONNULL_END
```

```objc:ios/Runner/OpenCV.mm
#import <opencv2/opencv.hpp>
#import <opencv2/imgcodecs/ios.h>
#import "OpenCV.h"

@implementation OpenCV

- (UIImage *) toPerspectiveTransformationImg:(UIImage *)img{
    // UIImageをMatに変換
    cv::Mat matSource;
    UIImageToMat(img, matSource);

    // 前処理
    cv::Mat matDest;
    // グレースケール変換
    cv::cvtColor(matSource, matDest, cv::COLOR_BGR2GRAY);
    // 2値化
    cv::threshold(matDest, matDest, 0, 255, cv::THRESH_BINARY|cv::THRESH_OTSU);
    // Cannyアルゴリズムを使ったエッジ検出
    cv::Mat matCanny;
    cv::Canny(matDest, matCanny, 75, 200);
    // 膨張
    cv::Mat matKernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(9.0, 9.0));
    cv::dilate(matCanny, matCanny, matKernel);

    // 輪郭を取得
    std::vector< std::vector<cv::Point> > vctContours;
    std::vector< cv::Vec4i > hierarchy;
    cv::findContours(matCanny, vctContours, hierarchy, cv::RETR_LIST, cv::CHAIN_APPROX_SIMPLE);

    // 面積順にソート
    std::sort(vctContours.begin(), vctContours.end(), [](const std::vector<cv::Point>& c1, const std::vector<cv::Point>& c2){
        return cv::contourArea(c1, false) > cv::contourArea(c2, false);
    });
    // 最大の四角形を走査、変換元の矩形にする
    std::vector<cv::Point2f> ptSrc;    
    for (int i=0; i<vctContours.size(); ++i) {
        // 輪郭をまるめる
        std::vector<cv::Point> approxCurve;
        double arclen = cv::arcLength(vctContours[i], true);
        cv::approxPolyDP(vctContours[i], approxCurve, 0.025 * arclen, true);
        // 4辺の矩形なら採用
        if (approxCurve.size() == 4) {
            for (int j=0; j<4; ++j) {
                ptSrc.push_back(cv::Point2f(approxCurve[j].x, approxCurve[j].y));
            }
            break;
        }
    }
    if (ptSrc.empty()) {
        return nil;
    }

    // 変換先の矩形（元画像の幅を最大にした名刺比率にする）
    float width = img.size.width;
    float height = width / 1.654;
    std::vector<cv::Point2f> ptDst;
    ptDst.push_back(cv::Point2f(0, height));
    ptDst.push_back(cv::Point2f(width, height));
    ptDst.push_back(cv::Point2f(width, 0));
    ptDst.push_back(cv::Point2f(0, 0));
    
    // 変換行列
    cv::Mat matTrans = cv::getPerspectiveTransform(ptSrc, ptDst);
    // 変換
    cv::Mat matResult(width, height, matSource.type());
    cv::warpPerspective(matSource, matResult, matTrans, cv::Size(width, height));

    // MatをUIImageに変換する
    UIImage *resultImg = MatToUIImage(matResult);
    return resultImg;
}
@end
```

#最後はAndroid側へ移植

iOSのコードをAndroidへ移植します。

まず、「[OpenCV for Android SDK](https://opencv.org/android/)」から `opencv-3.4.3-android-sdk` をダウンロードします。
ファイルを展開して `Android Studio` で `android/` を読み込み、`File > New > Import Module…` から、

<img width="790" alt="スクリーンショット 2019-12-15 19.16.24.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4668/985cdebc-19f0-9363-9483-701ca1e7d62c.png">

`/Downloads/OpenCV-android-sdk/sdk`を `:opencv` としてインポートします。
`build.gradle` に依存関係を追加。

```gradle:android/app/build.gradle
dependencies {
    implementation project(path: ':opencv')
}
```

あとは慣れない Kotlin の構文に戸惑いながらロジックを書き写していくだけです。

```kotlin:android/app/src/main/kotlin/com/example/opencv_app/MainActivity.kt
package com.example.opencv_app

(中略)

class MainActivity: FlutterActivity() {
    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        GeneratedPluginRegistrant.registerWith(flutterEngine);

        OpenCVLoader.initDebug()

        MethodChannel(flutterEngine.getDartExecutor(),"api.opencv.dev/opencv")
                .setMethodCallHandler { call, result ->
            when(call.method) {
                "toPerspectiveTransformation" -> {
                    val srcpath : String? = call.argument("srcPath")
                    if (srcpath == null) {
                        result.error("error", "illigal arguments", null)
                    }
                    else {
                        result.success(toPerspectiveTransformationImg(srcpath))
                    }
                }
                else -> {
                  result.notImplemented()
                }
            }
        }
    }

    // Bitmap を回転
    private fun rotateImage(source: Bitmap, angle: Float): Bitmap? {
        val matrix = Matrix()
        matrix.postRotate(angle)
        return Bitmap.createBitmap(source, 0, 0, source.width, source.height, matrix, true)
    }

    // 画像を読み込み、exif に応じて回転
    private fun readImageFromFileWithRotate(path:String?): Bitmap? {
        try {
            val ei = ExifInterface(path)
            val orientation = ei.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED)
            val options = BitmapFactory.Options()
            options.inPreferredConfig = Bitmap.Config.ARGB_8888
            val bitmap = BitmapFactory.decodeFile(path, options)
            var rotatedBitmap: Bitmap? = null
            when (orientation) {
                ExifInterface.ORIENTATION_ROTATE_90 -> rotatedBitmap = rotateImage(bitmap, 90.0f)
                ExifInterface.ORIENTATION_ROTATE_180 -> rotatedBitmap = rotateImage(bitmap, 180.0f)
                ExifInterface.ORIENTATION_ROTATE_270 -> rotatedBitmap = rotateImage(bitmap, 270.0f)
                ExifInterface.ORIENTATION_NORMAL -> rotatedBitmap = bitmap
                else -> rotatedBitmap = bitmap
            }
            return rotatedBitmap
        } catch (e: IOException) {
            e.printStackTrace()
        }
        return null
    }

    // 画像を保存する
    private fun saveImageToFile(img:Bitmap): String? {
        try {
            // 一時ファイルを生成
            val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss").format(Date())
            val storageDir: File? = getExternalFilesDir(Environment.DIRECTORY_PICTURES)
            if (storageDir == null) {
                return null;
            }

            val file = File.createTempFile(
                "JPEG_${timeStamp}_",
                ".jpeg",
                storageDir)
            if (file == null) {
                return null;
            }

            // PNGファイルで保存
            val out = FileOutputStream(file)
            img.compress(Bitmap.CompressFormat.JPEG, 100, out)
            out.flush();
            out.close();

            return file.absolutePath;
        }
        catch (e: IOException) {
            e.printStackTrace()
            return null;
        }
    }

    // パースペクティブ変換
    private fun toPerspectiveTransformationImg(srcpath: String): String? {
        // Bitmapを読み込み
        val img = readImageFromFileWithRotate(srcpath)
        // BitmapをMatに変換する
        var matSource = Mat()
        Utils.bitmapToMat(img, matSource)

        // 前処理
        var matDest = Mat()
        // グレースケール変換
        Imgproc.cvtColor(matSource, matDest, Imgproc.COLOR_BGR2GRAY)
        // 2値化
        Imgproc.threshold(matDest, matDest, 0.0, 255.0, Imgproc.THRESH_OTSU)
        // Cannyアルゴリズムを使ったエッジ検出
        var matCanny = Mat()
        Imgproc.Canny(matDest, matCanny,75.0, 200.0)
        // 膨張
        var matKernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(9.0, 9.0))
        Imgproc.dilate(matCanny, matCanny, matKernel);

        // 輪郭を取得
        var vctContours = ArrayList<MatOfPoint>()
        var hierarchy = Mat()
        Imgproc.findContours(matCanny, vctContours, hierarchy, Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_SIMPLE);

        // 面積順にソート
        vctContours.sortByDescending {
            Imgproc.contourArea(it, false)
        }
        // 最大の四角形を走査、変換元の矩形にする
        var ptSrc = Mat(4, 2, CvType.CV_32F)
        for (vctContour in vctContours) {
            // 輪郭をまるめる
            val approxCurve = MatOfPoint2f()
            val contour2f = MatOfPoint2f()
            vctContour.convertTo(contour2f, CvType.CV_32FC2)
            var arclen = Imgproc.arcLength(contour2f, true)
            Imgproc.approxPolyDP(contour2f, approxCurve, 0.025 * arclen, true)
            // 4辺の矩形なら採用
            if (approxCurve.total() == 4L) {
                for (i in 0..3) {
                    val pt = approxCurve.get(i, 0)
                    ptSrc.put(i, 0, floatArrayOf(pt[0].toFloat(), pt[1].toFloat()))
                }
                break;
            }
        }

        // 変換先の矩形（元画像の幅を最大にした名刺比率にする）
        val width = img!!.width;
        val height = (width / 1.654).toInt();
        var ptDst = Mat(4, 2, CvType.CV_32F)
        ptDst.put(0, 0, floatArrayOf(0.0f, height.toFloat()))
        ptDst.put(1, 0, floatArrayOf(width.toFloat(), height.toFloat()))
        ptDst.put(2, 0, floatArrayOf(width.toFloat(), 0.0f))
        ptDst.put(3, 0, floatArrayOf(0.0f, 0.0f))
        // 変換行列
        var matTrans = Imgproc.getPerspectiveTransform(ptSrc, ptDst)
        // 変換
        var matResult = Mat(width, height, matSource.type());
        Imgproc.warpPerspective(matSource, matResult, matTrans, Size(width.toDouble(), height.toDouble()));

        // Mat を Bitmap に変換して保存
        var imgResult = Bitmap.createBitmap(width, height, img!!.config);
        Utils.matToBitmap(matResult, imgResult)

        return saveImageToFile(imgResult)
    }
}
```

#試してみる

##元画像
葛西臨海水族園でもらったオウサマペンギンのポストカードです。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4668/0c2b1da4-7c2a-611b-4b0b-1f2f16b8ea2b.jpeg" width="50%" border="1">

##iOS（エミュレーター）
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4668/60f0af5d-4096-b2a7-b96f-1485ee01738c.png" width="40%" border="1">

##Android（エミュレーター）
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4668/4af32155-7581-c308-fc05-d79dcd8d7145.png" width="40%" border="1">

ちょっと歪みがありますが、同じように動作しました。
あらためてリポジトリは以下を参照ください。
[hidea/flutter_opencv_app](https://github.com/hidea/flutter_opencv_app)