この記事は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.yaml の dependencies に画像取扱を追加。
dependencies:
image_picker: ^0.6.2+1
image: ^2.1.4
アルバムから画像を取得して、そのファイル名をネイティブコードへ渡します。
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 の中を適当に。
children: <Widget>[
_image == null
? Text('No image.')
: Image.file(_image),
],
#次にiOS側の実装
OpenCVを扱うために Podfile に以下の行を追加。
pod 'OpenCV'
アルバム、カメラアクセスの為に ios/Runner/Info.plist にアクセス許可定義を追加。
iOS10以降だと必須とのこと。実際、これなしで動かすとアプリが強制終了します。
<key>NSCameraUsageDescription</key>
<string>写真の取り込みのため使います</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>写真の取り込みのため使います</string>
Flutterからの呼び出される箇所。
引数を取得して画像を読み込み、OpenCVで変換して保存し直してパスを返値とします。
- (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++ で行います。
NS_ASSUME_NONNULL_BEGIN
@interface OpenCV : NSObject
- (UIImage *)toPerspectiveTransformationImg:(UIImage *)img;
@end
NS_ASSUME_NONNULL_END
#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 Studio
で android/
を読み込み、File > New > Import Module…
から、
/Downloads/OpenCV-android-sdk/sdk
を :opencv
としてインポートします。
build.gradle
に依存関係を追加。
dependencies {
implementation project(path: ':opencv')
}
あとは慣れない Kotlin の構文に戸惑いながらロジックを書き写していくだけです。
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)
}
}
#試してみる
##元画像
葛西臨海水族園でもらったオウサマペンギンのポストカードです。
ちょっと歪みがありますが、同じように動作しました。
あらためてリポジトリは以下を参照ください。
hidea/flutter_opencv_app