【Android】【OpenCV】OCRの前処理のための画像処理（二値化・角度補正）

# はじめに
Androidネイティブアプリで、カード状のものを写真で撮影してOCRを実行したかったのですが、
OCRの前処理として角度補正などの画像処理を行う必要がありました。

画像処理といえばOpenCV、ということで情報を探してみると、ちょうどやりたいこととぴったりな記事を発見しました。
[OpenCVを使って免許証を角度補正(射影変換)する-二値化の閾値も自動で決定-](https://qiita.com/nrnrk/items/7aecba89ffb02f0b8d12)

こちらの記事はPythonで書かれていたので、Android（Kotlin）向けに書き換えてみました。
※ PythonもOpenCVもほぼ初心者のため、誤りがあったらすみません。

# 環境

- Android Studio 3.6.2
- Kotlin 1.3.61
- OpenCV 4.5.0

Android StudioにOpenCVを導入する手順については、以下の記事を参考にさせていただきました。
[Android StudioでOpenCVを使う](https://qiita.com/tifa2chan/items/a2ec4f5d90eeb9cf1802)

# ソースコード
さっそくソースコードです。
処理内容についての解説は元記事をご参照いただければと思います。

```Kotlin:Sample.kt
import android.graphics.Bitmap
import org.opencv.android.Utils
import org.opencv.core.*
import org.opencv.imgproc.Imgproc
import kotlin.math.pow
import kotlin.math.roundToInt

object Sample {

    /**
     * 画像からカードを切り出す
     * @param bitmap 元画像
     * @return 切り出し後の画像
     */
    fun trimCard(bitmap: Bitmap): Bitmap? {
        // Bitmap -> Matに変換
        val imageMatOriginal = Mat()
        Utils.bitmapToMat(bitmap, imageMatOriginal)

        ////////////////////////////////
        // 画像を二値化する
        ////////////////////////////////
        val imageMat = imageMatOriginal.clone()
        // グレースケール化
        Imgproc.cvtColor(imageMat, imageMat, Imgproc.COLOR_RGB2GRAY)
        // 二値化の閾値算出
        val flatMat = arrayListOf<Double>()
        for (i in 0 until imageMat.rows()) {
            for (j in 0 until imageMat.cols()) {
                flatMat.add(imageMat[i, j][0])
            }
        }
        var thresholdValue = 100
        val cardLuminancePercentage = 0.2
        val numberThreshold = imageMat.width() * imageMat.height() * cardLuminancePercentage
        for (diffLuminance in 0 until 100) {
            val count = flatMat.count { it > (200 - diffLuminance).toDouble() }
            if (count >= numberThreshold) {
                thresholdValue = 200 - diffLuminance
                break
            }
        }
        println("** threshold: $thresholdValue **")
        // 二値化
        Imgproc.threshold(imageMat, imageMat, thresholdValue.toDouble(), 255.0, Imgproc.THRESH_BINARY)

        ////////////////////////////////
        // カードの輪郭を抽出する
        ////////////////////////////////
        val contours = ArrayList<MatOfPoint>()
        Imgproc.findContours(imageMat, contours, Mat(), Imgproc.RETR_TREE, Imgproc.CHAIN_APPROX_SIMPLE)
        // 面積が最大のものを選択
        val cardCnt = MatOfPoint2f()
        contours.maxBy { Imgproc.contourArea(it) }?.convertTo(cardCnt, CvType.CV_32F)

        ////////////////////////////////
        // 射影変換
        ////////////////////////////////
        // 輪郭を凸形で近似
        val epsilon = 0.1 * Imgproc.arcLength(cardCnt, true)
        val approx = MatOfPoint2f()
        Imgproc.approxPolyDP(cardCnt, approx, epsilon, true)

        if (approx.rows() != 4) {
            // 角が4つじゃない場合（四角形でない場合）は検出失敗としてnullを返す
            return null
        }

        // カードの横幅
        val cardImageLongSide = 2400.0
        val cardImageShortSide = (cardImageLongSide * (5.4 / 8.56)).roundToInt().toDouble()

        val line1Len = (approx[1, 0][0] - approx[0, 0][0]).pow(2) + (approx[1, 0][1] - approx[0, 0][1]).pow(2)
        val line2Len = (approx[3, 0][0] - approx[2, 0][0]).pow(2) + (approx[3, 0][1] - approx[2, 0][1]).pow(2)
        val line3Len = (approx[2, 0][0] - approx[1, 0][0]).pow(2) + (approx[2, 0][1] - approx[1, 0][1]).pow(2)
        val line4Len = (approx[0, 0][0] - approx[3, 0][0]).pow(2) + (approx[0, 0][1] - approx[3, 0][1]).pow(2)
        val targetLine1 = if (line1Len > line2Len) line1Len else line2Len
        val targetLine2 = if (line3Len > line4Len) line3Len else line4Len

        val cardImageWidth: Double
        val cardImageHeight: Double
        if (targetLine1 > targetLine2) {
            // 縦長
            cardImageWidth = cardImageShortSide
            cardImageHeight = cardImageLongSide
        } else {
            // 横長
            cardImageWidth = cardImageLongSide
            cardImageHeight = cardImageShortSide
        }

        val src = Mat(4, 2, CvType.CV_32F)
        for (i in 0 until 4) {
            src.put(i, 0, *(approx.get(i, 0)))
        }
        val dst = Mat(4, 2, CvType.CV_32F)
        dst.put(0, 0, 0.0, 0.0)
        dst.put(1, 0, 0.0, cardImageHeight)
        dst.put(2, 0, cardImageWidth, cardImageHeight)
        dst.put(3, 0, cardImageWidth, 0.0)

        val projectMatrix = Imgproc.getPerspectiveTransform(src, dst)

        val transformed = imageMatOriginal.clone()
        Imgproc.warpPerspective(imageMatOriginal, transformed, projectMatrix, Size(cardImageWidth, cardImageHeight))

        if (cardImageHeight > cardImageWidth) {
            // 縦長の場合は90度回転させる
            Core.rotate(transformed, transformed, Core.ROTATE_90_CLOCKWISE)
        }

        val newBitmap = Bitmap.createBitmap(transformed.width(), transformed.height(), Bitmap.Config.ARGB_8888)
        Utils.matToBitmap(transformed, newBitmap)
        return newBitmap
    }
}
```

一部元記事から改変している箇所もありますが、ほぼ同じ処理内容になっていると思います。

# 余談
PythonでもOpenCVをさわってみようと思いインストールしたのですが、
```import numpy``` でエラーが発生してしまいました。
結果としては、Pythonのバージョンを3.9.0から3.8.6に変更したら動きました。
なぜかはよくわかりませんが、、、