日々是好日

プログラミングについてのあれこれ、ムダ知識など

OpenCV for Androidで手書き文字をクロッピングする

概要

  • 写真をOpenCVで二値化して、手書き部分のみクロッピングする
  • Bitmapをアルファ付きPNGとして保存するとき、Bitmap#setHasAlphaをセットしないとアルファ値が捨てられる
    • 読み込んだときに背景が黒に置換される模様

動きとしてはこんな感じ。

f:id:kcpoipoi:20201101124152g:plain

OpenCV for Androidの導入

前記事参照。

OpenCVの初期化

まず、BaseLoaderCallbackを継承したクラスを作成する。

class LoaderCallback(activity: AppCompatActivity) : BaseLoaderCallback(activity) {
  companion object {
    init {
      // 執筆時点では4が最新だった
      System.loadLibrary("opencv_java4")
    }
  }
}

次にonCreateなど適当な場所で初期化する。 動作確認だけなので、MainActivityCoroutineScope実装している。

// 画像処理が重いため、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のみ使うことができる。

  1. getTransparentBinaryImage … 冒頭GIFの上側の画像を出力するために使用
  2. getBinaryPreview … 同、下側の画像を取得するために使用

MainActivityからの呼び出し

MainActivity#onCreateOpenCVの初期化以降で、シークバーや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の保存について

rooandqoo.hatenablog.com

appdev.blitz-time.com

akira-watson.com

BitmapのPixel操作を扱ったもの

android.takayukikoyama.com

weide-dev.blogspot.com

OpenCVの反転処理を扱ったもの

geekn-nerd.blogspot.com

OpenCVで透過処理を扱ったもの

tecsingularity.com

OpenCVのMatクラスについて

imagingsolution.blog.fc2.com

opencv.jp

gori-naru.blogspot.com

findContours等、輪郭を扱ったもの

water2litter.net

teratail.com

labs.eecs.tottori-u.ac.jp

平滑化処理を扱ったもの

optie.hatenablog.com

labs.eecs.tottori-u.ac.jp

qiita.com

膨張・収縮処理(Dilation/Elosion)

nobotta.dazoo.ne.jp

geekn-nerd.blogspot.com

OpenCVリファレンス

opencv.jp

https://book.mynavi.jp/support/pc/opencv2/c3/opencv_img.htmlbook.mynavi.jp

Core.splitとかmergeとかやってるけど全然分からなかったもの

stackoverrun.com

github.com

qiita.com

関係ないけど画像の暗黙的Intentについて

gist.github.com

OpenCV for Androidのインストール

blog.cfm-art.net

yebisupress.dac.co.jp

Android単体で画像の切り抜き

words-hiro.blog.ss-blog.jp