こんにちは、買い物事業部サービス開発部にてトクバイAndroidアプリの開発をしている石川です。
トクバイでは、OCR周りの技術を使ったチラシ検索機能を一部のユーザーに公開するテストを何回か行っています。このブログでは、今年の2月にリリースしたバージョン6.23アプリにてテストリリースしていたチラシ検索機能の実装に使用していたOpenCVによる矩形検出の実装方法、結果を紹介したいと思います。
AndroidでのOpenCVの導入方法はネット上にあまり情報がなかったり、実際にリリースしたからこそわかる問題点等も紹介できるかと思うので、その辺りが参考になればと思います。
OpenCVを使った矩形検出をAndroidで実装する理由
内容に入っていく前に、そもそも「何に矩形検出を使うの?」「クライアントアプリでOpenCVを使った機能を実装するのは邪道ではないのか?」という疑問があると思うので、それらについて話したいと思います。(正直いうと、作ってみたいと思ったから作ったという側面も大きいのですが..)
「何に矩形検出を使うの?」
今回のチラシ検索のテストはOCR用のMLモデルがベースとなった機能です。OCRモデルはアウトプットとして、チラシ内のテキストと、そのテキストのチラシ内での座標をリストにして返します。(他にも何個かプロパティがありますが簡単なイメージとして)
なので、OCRモデルで取得したテキストの座標を画像からクロップしてそのまま画面上に表示するようなことをしてしまうと、チラシ画像のテキスト部分だけがクロップされて商品画像部分が含まれないという状態になります。理想としては、クロップされた画像の中心に商品があり、OCRで取得したテキストが画像内のどこかに入っているという状態だと思います。
理想 | OCRのテキスト座標のみクロップ |
---|---|
商品が画像の中心にありつつもテキストが表示される | 商品部分は除外されてしまう |
そこで、何個か良い感じに画像をクロップする方法を考えた内の一つがチラシ内の矩形を検出してその矩形に合わせて画像をクロップするという方法です。 チラシを見てみると、商品ごとに枠線で囲まれていたり、セクションごとに背景色がわけられているので、この商品ごとの枠やセクションごとの背景の異なりを矩形として検出し、その矩形とOCRのテキスト座標を上手く利用し画像をクロップすることで、商品部分と商品に対応するテキストの両方が含まれた部分をチラシからクロップできるのでは?という憶測の元、矩形検出を実装することにしました。
「クライアントアプリでOpenCVを使った機能を実装するのは邪道ではないのか?」
では、次はAndroidアプリ側で矩形検出を実装する理由についてです。 一般的にこういったリアルタイム性が必要ない重そうな処理はバックエンド側で実行するものだと思います。また、OCRモデルを動かすのがバックエンド側であれば、その処理の後に矩形検出をやってしまえば良いというのもあると思います。
では、なぜAndroid側で実装するのか? 今回のチラシ検索のテストリリースは実はバックエンドの開発をせずにアプリとFirebaseを利用したテストリリースだったからというようなところがあります。トクバイのアプリ開発ではこのように最小限の機能で色々な機能をABテストやテストリリースすることが多いです。 (それでもアプリ側で実装するに十分な理由でない気もしますが、ちょっとした工夫を入れるのも楽しいと思うので、時にはこういうことをやってみても良いかなと思っています。)
実装方法
OpenCVでの矩形検出は以下の手順で実装しました
- OpenCVをAndroidのプロジェクトに入れる
- 矩形検出を実装する
- OCRのテキスト座標と矩形を使ってクロップする部分を決める
- coilのTransformationに以上の機能を組み込んでクロップした画像を表示する
手順3, 4に関しては汎用性の低い内容なので、このブログでの詳細の紹介は省略して、1, 2の手順についてのみ紹介したいと思います。
1. OpenCVをAndroidのプロジェクトに入れる
まずはOpenCVのsdkをダウンロードして、APIをAndoridアプリのコードから呼び出せるようにしていきます。 OpenCVのリリースページに行って、AndroidのSDKをダウンロード。
次に、ダウンロードしたsdkをimportします。Android StdioのFile>File>New>ImportmoduleFile>New>Import Moduleにてダウンロードしたフォルダの中のsdkを選択してimport。ktsやgradle8以降を使っているプロジェクトではここで必ずエラーが発生するのでちょっとした修正をします。
- (settings.gradle.ktsを使用している場合)自動生成されるsettings.gradleの削除と、settings.gradle.ktsの更新
include(":app", ":opencv") // <- :opencvを追加
- (gradle8以降を使用している場合) opencv/build.gradleにnamespaceを追加
namespace "opencv"
<- androidブロックの中に追加- opencvモジュールのマニフェストから
package="org.opencv"
を削除
- OpenCV初期化コードを入れる
- OpenCVの関数が呼び出される前に、OpenCVを初期化するようにしておきます。どこでも良いのですが、OpenCVの関数を呼び出す前に以下のコードを実行するようにします。
isOpenCVInitialized = OpenCVLoader.initDebug()
- OpenCVの関数が呼び出される前に、OpenCVを初期化するようにしておきます。どこでも良いのですが、OpenCVの関数を呼び出す前に以下のコードを実行するようにします。
- opencvを使用するモジュールのbuild.gradle.ktsに
implementation(project(":opencv"))
を追加します。これでOpenCVのAPIを使えるようになりました。
2. 矩形検出を実装する
では、本題の矩形検出の方を紹介したいと思います。 チラシ画像のBitmapを受け取って、その画像の中の矩形をリストとして返すような機能を作っていきます。 (OpenCVを本業としている人間ではないので、間違った理解や明らかにベストではないことをしている可能性があります。また、テスト機能なので結構雑に作ってますがその点はご容赦ください。) 矩形検出の大まかな手順は以下です。 - 2-1. BitmapをMatに変換する(MatはOpenCVで画像を扱うための型) - 2-2. 画像の二値化(適応的二値化) - 2-3. 画像から輪郭のリストを取得 - 2-4. 輪郭のリストを輪郭の外接矩形リストに変換 (ちなみに、矩形検出周りのコードはopenaiあたりのaiを使ってもそこそこ良いコードを作ってくれるので、その辺りを試してみるのも良いかもしれません。)
2-1. BitmapをMatに変換
val mat = Mat() Utils.bitmapToMat(bitmap, mat, true) // <- bitmapの型はBitmap
2-2. 画像の二値化(適応的二値化)
2-1で作ったmatを受け取って、二値化された画像(Mat)を返す関数を作ります。
private fun binarization(mat: Mat): Mat { val grayMat = Mat() val binaryMat = Mat() Imgproc.cvtColor(mat, grayMat, Imgproc.COLOR_RGB2GRAY) // 適応的閾値処理による二値化 Imgproc.adaptiveThreshold( grayMat, binaryMat, 255.0, Imgproc.ADAPTIVE_THRESH_MEAN_C, Imgproc.THRESH_BINARY, 41, 5.0 ) return binaryMat }
二値化の種類として適応的二値化という方法を使っています。
論理的な理由があるわけではなく、何個か試した中で一番綺麗に枠線やチラシ背景の色の境界を取れていそうだったからという理由で採用しています。
ですが、後から考えてみると適応的二値化は背景色が画像全体を通して一定ではないチラシを二値化するのには適していそうです。
サンプルとして、適応的二値化と大津の二値化を比較してみたいと思います。
下の表の白黒の画像は、上の画像を適応的二値化と大津の二値化にかけたもので、カラーの画像は二値化画像を使って矩形検出を行なったものです。
適応的二値化 | 大津の二値化 |
---|---|
適応的二値化を使っている方は、画像下部のオレンジ色の背景色の区切れをうまく取れています。
2-3. 画像から輪郭のリストを取得
2-2の関数を使って二値化された画像を受け取って、輪郭のリスト(List<MatOfPoint>
)を返す関数を作成します。
private fun getContour(mat: Mat): List<MatOfPoint> { val contour: MutableList<MatOfPoint> = ArrayList() /* 二値画像中の輪郭を検出 */ val tmpContours: List<MatOfPoint> = ArrayList() val hierarchy = Mat.zeros(Size(5.0, 5.0), CvType.CV_8UC1) Imgproc.findContours( mat, tmpContours, hierarchy, Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_TC89_L1 ) tmpContours.forEach { tmpContour -> val area = Imgproc.contourArea(tmpContour) // サイズが小さいエリアは無視(相対的) if (area < mat.size() .area() / 500) { return@forEach } val ptmat2 = MatOfPoint2f(*tmpContour.toArray()) val approx = MatOfPoint2f() val approxf1 = MatOfPoint() // 輪郭線の周囲長を取得 val arclen = Imgproc.arcLength(ptmat2, true) // 直線近似 Imgproc.approxPolyDP(ptmat2, approx, 0.02 * arclen, false) approx.convertTo(approxf1, CvType.CV_32S) if (approxf1.size() .area() < 4) { // 三角形以下は無視 return@forEach } // 輪郭情報を登録 contour.add(approxf1) } return contour }
2-4. 輪郭のリストを輪郭の外接矩形リストに変換
OpenCVのImgproc.boundingRectという関数を使うと、輪郭の外接矩形に変換できるので、それを利用して2-4.で作成される輪郭のリスト(contours: List<MatOfPoint>
)を輪郭への外接矩形リストに変換します。
contours.map { Imgproc.boundingRect(it) }
2-5. ここまでの手順をまとめて
2-1から2-4までの手順をまとめると、以下のようなBitmapを受け取って、その中の矩形のリストを返すような関数を作成できます。
private fun detectRectangles(bitmap: Bitmap): List<Rect> { // 画像をMatに変換する val mat = Mat() Utils.bitmapToMat(bitmap, mat, true) // 画像を二値化 val binaryMat = binarization(mat) // 輪郭の座標を取得 val contours = getContour(binaryMat) return contours.map { Imgproc.boundingRect(it) } }
矩形検出の実装は以上です。どうだったでしょうか? OpenCVをAndroidで使うとなると、「ライフサイクルとかどうなる?」、「コールバックとか使わないといけなくて無駄に複雑なコードになるのでは?」というようなことを思われるかもしれないですが、意外と何も考えずに簡単に呼び出せるような関数が出来上がりました。ちょっとメインスレッドで動作させるのはやめといた方が良いと思いますが..
結果
では、OpenCVの矩形検出を使って作ったチラシ検索のクロップ後画像はどのようになるのか、OpenCVを使わない倍率調整バージョンと比較しながら見ていきましょう。
ちなみに、倍率調整のみの画像クロップはOCRで取得した文字列の長さをもとに、画像をクロップする範囲(テキスト領域のサイズに対する倍率)を決めるものです。倍率の計算式は以下のようにしました。
- cropSquareScale: クロップする倍率
- textLength: OCRで抽出した文字列の長さ
倍率調整バージョンも良い感じにクロップされる努力はしていて、
長いテキストほどクロップ範囲の倍率を小さくする - 計算は感覚で決めたもの(文字数が大きくなればなるほど、文字数変化の影響が小さくなるように、0.8乗しています)
といった意図を反映した式になっています。
OpenCV矩形検出ver vs 倍率調整ver
では、OpenCVの矩形検出を使ったクロップ画像と倍率調整のみのクロップ画像を比較していきましょう。
表の中の画像は、クロップした画像
とそれに対応するOCRモデルが抽出したテキスト
が横並びになったものです。
品目 | OpenCV矩形検出ver | 倍率調整ver |
---|---|---|
牛乳 | ||
キャペツ | ||
きゅうり | ||
マヨネーズ | ||
スープパスタ | ||
フジパン |
どうでしょうか?OpenCV使用パターンと倍率調整を比べるとOpenCVの方はかなり良い感じに画像がクロップできているように見えます。 商品がクロップされた画像の中心に配置されながらも、値段や商品名のテキストも画像内に収まっています!
OpenCVの矩形検出が適用できないパターンも多い
OpenCV矩形検出クロップ画像と倍率調整クロップ画像を並べると、かなりOpenCV矩形検出verは良い感じに見えるのですが、実は、テスト時の実装ではOpenCVで取得した矩形によるクロップを適用できている部分は4割くらいしかなかったです。
なので、適用できない部分に対しては倍率調整を適用してました。
もう少しパラメータいじったり矩形検出の部分をいじれば6,7割くらいはOpenCVの矩形検出使えるんじゃ無いかな?という感覚ではあります。
OpenCVを入れた時の問題点
ここまで、実装内容とその結果を紹介してきたのですが、OpenCVを含んだアプリをリリースするにあたって、問題もあったのでその紹介もしたいと思います。 今回のテストリリース時に懸念として上がったのは1つで、アプリサイズが大きくなったことです。 元々、トクバイのアプリのダウンロードサイズは20mb強だったのですが、OpenCVを入れると10mbほどサイズアップして、30mb強になってしまうというものです。100mbくらいあるアプリもある中、10mbを大きいとするかどうかは微妙なところですが、私の個人的な意見としては、得られる効果に対してアプリサイズアップのデメリットが大きすぎるという印象を受けました。(AABではなくAPKでアプリを配信していれば、70mbくらいサイズアップしたような..) このような問題もありましたが、一旦、v6.23ではリリースして様子見することになりました。 ちなみに、v6.24あたりではテストも終了したのでOpenCVはアプリから落としています。
OpenCVを使った矩形検出を実装してみて
最終的にはOpenCVを利用した実装はすぐに削除する形にはなりましたが、こういったほんのちょっとチャレンジングなことをやってみるのも楽しいので時には良いかなと思っています。 また、このブログでは実装内容とか矩形検出後の結果も少し紹介できたので、そういった内容が参考になれば良いなと思っています。 Androidでは、OpenCV以外にもMLKitやTensorflow(触ったことないけどARCore?)を使って、画像解析系の機能を思いの外簡単に作ることができます。 特に、リアルタイム性やマシンパワーを無料で使えるという利点が活かせる局面ではCameraXなんかと組み合わせて他のプラットフォームでは実現できないようなこともできると思うので、機会があれば今度は本腰を入れてそういった機能の開発をしてみたいなと思っています。
最後に
トクバイアプリでは機能を小さく実装し、リリースして、改善して、という形で色々な施策を短期間でサイクルを回すというような形で開発をしています。
ユーザー数の多いアプリで色々新しい機能を作ってテストできるというのは、アプリエンジニアとして1つの醍醐味だと思います。
今回の矩形検出もその一つの例として雰囲気を少しでも感じていただけると嬉しいです。
ここまで読んでいただきありがとうございました。
くふうAIスタジオではアプリエンジニアを募集中です。