OpenCV for Androidで手書き文字をクロッピングする
概要
- 写真をOpenCVで二値化して、手書き部分のみクロッピングする
- Bitmapをアルファ付きPNGとして保存するとき、
Bitmap#setHasAlpha
をセットしないとアルファ値が捨てられる- 読み込んだときに背景が黒に置換される模様
動きとしてはこんな感じ。
OpenCV for Androidの導入
前記事参照。
OpenCVの初期化
まず、BaseLoaderCallback
を継承したクラスを作成する。
class LoaderCallback(activity: AppCompatActivity) : BaseLoaderCallback(activity) { companion object { init { // 執筆時点では4が最新だった System.loadLibrary("opencv_java4") } } }
次にonCreate
など適当な場所で初期化する。
動作確認だけなので、MainActivity
にCoroutineScope
実装している。
// 画像処理が重いため、Coroutinesで非同期化 // 非同期処理部分は適宜切り分けのこと class MainActivity : AppCompatActivity(), CoroutineScope { override val coroutineContext: CoroutineContext get() = Dispatchers.Main override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.main_activity) // OpenCVの初期化 OpenCVLoader.initDebug() LoaderCallback(this).onManagerConnected(LoaderCallbackInterface.SUCCESS) } }
PNGをBitmapとして取り込む
簡単のため、drawable
フォルダに対象のPNGを配置して読み込む。
// リソースから読み込み val bmp = BitmapFactory.decodeResource(resources, R.drawable.calligraphy)
画像処理テスト用にクラスを作成する
ImageEditUseCase (ImageEditInteractor)
及びBaseImageEdit
を作成する。
画像処理系はめちゃ重いので全てsuspend functionにする。
interface ImageEditUseCase { // 二値化プレビュー画像の取得 // thresh … 二値化するときの閾値 suspend fun getBinaryPreview(thresh: Double): Bitmap // 背景透過を施したBitmap suspend fun getTransparentBinaryImage(): Bitmap } abstract class BaseImageEdit() { abstract val srcBitmap: Bitmap // 処理する元画像 protected abstract suspend fun opening(iteration: Int) // オープニング処理 protected abstract suspend fun closing(iteration: Int) // クロージング処理 protected abstract suspend fun binarize(thresh: Double) // 二値化処理 protected abstract suspend fun inverse(dstMat: Mat) // 白黒反転処理 } class ImageEditInteractor(override val srcBitmap: Bitmap) : BaseImageEdit(), ImageEditUseCase, CoroutineScope { override val coroutineContext: CoroutineContext get() = Dispatchers.Default /** srcBmpを平滑化しただけのMat */ private val rawMat = Mat() /** rawMat をグレースケール化 */ private val grayMat: Mat /** grayMat を二値化 */ private val binMat = Mat() init { // Matに変換 Utils.bitmapToMat(srcBitmap, rawMat) // 平滑化(メディアンフィルタ) Imgproc.medianBlur(rawMat, rawMat, 13) grayMat = rawMat.clone() // グレースケールに変換 Imgproc.cvtColor(grayMat, grayMat, Imgproc.COLOR_RGBA2GRAY) } suspend fun getBinaryPreview(thresh: Double): Bitmap { // rawMatから二値化Mat(binMat)を生成 binarize(thresh) // 収縮処理 closing(3) // 膨張処理 opening(2) // 出力用Bitmapを生成 val dstBitmap = createBitmap(srcBitmap.width, srcBitmap.height, Bitmap.Config.ARGB_8888) // 写真を二値化したプレビューを生成 Utils.matToBitmap(binMat, dstBitmap, true) return@withContext dstBitmap } suspend fun getTransparentBinaryImage(): Bitmap { // 符号なし8bit 4チャネル(BGRA)ゼロ埋めMatの生成(アルファチャネルがゼロのため完全透過) val resultMat = Mat.zeros(rawMat.rows(), rawMat.cols(), CvType.CV_8UC4) // マスク生成のため二値化したMatをクローン val mask = binMat.clone() // 二値化Matを反転(文字部分が白、背景部分が黒になる) inverse(mask) val resultBitmap = createBitmap(srcBitmap.width, srcBitmap.height, Bitmap.Config.ARGB_8888) // rawMatにマスクをかけてresultMat上にコピー(手書き部分のみクロッピング) rawMat.copyTo(resultMat, mask) Utils.matToBitmap(resultMat, resultBitmap, true) return resultBitmap } // 閾値に応じて二値化 override suspend fun binarize(thresh: Double): Unit = withContext(coroutineContext) { Imgproc.threshold(grayMat, binMat, thresh, 255.0, Imgproc.THRESH_BINARY) } // 膨張処理(白ごまノイズの除去) override suspend fun opening(iteration: Int) = withContext(coroutineContext) { Imgproc.morphologyEx(binMat, binMat, Imgproc.MORPH_OPEN, Mat.ones(5, 5, CvType.CV_8U)) } // 収縮処理(黒ごまノイズの除去) override suspend fun closing(iteration: Int) = withContext(coroutineContext) { Imgproc.morphologyEx(binMat, binMat, Imgproc.MORPH_CLOSE, Mat.ones(5, 5, CvType.CV_8U)) } // 二値化の白黒を反転 override suspend fun inverse(dstMat: Mat) = withContext(coroutineContext) { Core.bitwise_not(binMat, dstMat) } }
呼び出し側はImageEditUseCase
に従ってgetBinaryPreview
及びgetTransparentBinaryImage
のみ使うことができる。
getTransparentBinaryImage
… 冒頭GIFの上側の画像を出力するために使用getBinaryPreview
… 同、下側の画像を取得するために使用
MainActivityからの呼び出し
MainActivity#onCreate
のOpenCVの初期化以降で、シークバーやFABのイベントとImageEditUseCase
を紐づける。
// drawableリソースから読み込み val bmp = BitmapFactory.decodeResource(resources, R.drawable.calligraphy) // ImageEditInteractorの生成 val editor: ImageEditUseCase = ImageEditInteractor(bmp) // 閾値の初期値として 128を渡す onThresholdChanged(editor, 128.0) fab.setOnClickListener { launch { val fileIO = FileIO(this@MainActivity ) // 透過出力ファイル val output = editor.getTransparentBinaryImage() fileIO.saveFile(output) Toast.makeText(this@MainActivity, "Succeed", Toast.LENGTH_SHORT).show() val readBitmap = fileIO.readFile() savedImage.setImageBitmap(readBitmap) } } seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { override fun onProgressChanged(p0: SeekBar?, p1: Int, p2: Boolean) { onThresholdChanged(editor, p1 * 1.0) } ... }) private fun onThresholdChanged(editor: ImageEditUseCase, thresh: Double) { launch { val newBmp = editor.getBinaryPreview(thresh) editImage.setImageBitmap(newBmp) } }
ファイルの入出力クラスの作成
単にFileInput/OutputStream
を呼んでいるだけだけど、保存するときにBitmap#setHasAlpha(true)
をセットしないとアルファが捨てられるので注意が必要。
class FileIO(private val context: Context) : CoroutineScope { override val coroutineContext: CoroutineContext get() = Dispatchers.IO suspend fun saveFile(bitmap: Bitmap) = withContext(coroutineContext) { context.openFileOutput("calligraphy", Context.MODE_PRIVATE).use { bitmap.setHasAlpha(true) bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } } suspend fun readFile(): Bitmap = withContext(coroutineContext) { context.openFileInput("calligraphy").use { BitmapFactory.decodeStream(it) } } }
二値化の方法について
最初にグレースケールからの二値化の手法を取ったため上記のような実装になっているが、 より汎用的にするならRGB各成分に分解したのち、各色ごとに二値化⇒マージという流れを取った方が はっきり切り取れる模様。できたらやる。
調べたものリスト
Bitmapの保存について
BitmapのPixel操作を扱ったもの
OpenCVの反転処理を扱ったもの
OpenCVで透過処理を扱ったもの
OpenCVのMatクラスについて
findContours等、輪郭を扱ったもの
平滑化処理を扱ったもの
膨張・収縮処理(Dilation/Elosion)
OpenCVリファレンス
https://book.mynavi.jp/support/pc/opencv2/c3/opencv_img.htmlbook.mynavi.jp