Help us understand the problem. What is going on with this article?

Flutter で OpenCV

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

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

プロジェクトのフルソースは、以下のリポジトリ hidea/flutter_opencv_app にあげてあります。

Flutter で OpenCV

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

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

$ 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 のプロジェクトを生成。

$ flutter create -i objc -a kotlin opencv_app

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

まずは共通するFlutter部分から

pubspec.yamldependencies に画像取扱を追加。

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

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

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 の中を適当に。

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

次にiOS側の実装

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

ios/Podfile
pod 'OpenCV'

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

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

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

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++ で行います。

ios/Runner/OpenCV.h
NS_ASSUME_NONNULL_BEGIN
@interface OpenCV : NSObject
- (UIImage *)toPerspectiveTransformationImg:(UIImage *)img;
@end
NS_ASSUME_NONNULL_END
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」から opencv-3.4.3-android-sdk をダウンロードします。
ファイルを展開して Android Studioandroid/ を読み込み、File > New > Import Module… から、

スクリーンショット 2019-12-15 19.16.24.png

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

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

あとは慣れない 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)
    }
}

試してみる

元画像

葛西臨海水族園でもらったオウサマペンギンのポストカードです。

iOS(エミュレーター)

Android(エミュレーター)

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

hidea
細々とウェブやクライアントのコードを書いています。 なにかありましたらお声がけしていていただければ。 C/C++/C#/PHP/Java/Laravel/Unity etc.
http://hidea.hatenablog.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした