Androidでの画像処理と認識方法の実装例

Android OS用のアプリケーションの開発中に、試してみたい、または理論的知識があり、実践したいという興味深いアイデアが発生します。これらの要因の組み合わせから、説明したプロジェクトのアイデアが生まれました。



テキスト認識、コンピュータービジョン、および個々の認識アルゴリズムに関する多くの記事があります。 同じ出版物は、テキスト画像でキーワードを見つけるタスクを達成する試みを示しています。これにより、たとえば、テキスト自体を認識せずにDjVuのテキストを読む適切な場所を見つけることができます。



実装例はAndroidアプリケーションの形式で提示され、ソース画像はキーワードが入力されたテキストのスクリーンショットです。画像を処理および認識するためのさまざまなアルゴリズムを使用して問題を解決します。



挑戦する



写真、スキャンした画像、またはスクリーンショットなどのテキストの画像があるとします。この画像では、情報の必要な部分をすばやく抽出するために、できるだけ早くフレーズや単語を見つける必要があります。ここでアルゴリズムが役立ちます画像処理と認識。



ここでは、Androidアプリケーションの作成段階について詳しく説明しません。また、アルゴリズムの詳細な理論的説明も示しません。 最小限のテストアプリケーションインターフェイスで、次の主な目的は次のとおりです。





画像取得



調査中の画像を取得するには、3つの要素のみが含まれるアクティビティを作成します。



1. EditText-キーワードを入力します。

2. TextView-この単語を検索するテキストを表示します。

3.スクリーンショットを撮って別の画面に移動するボタン。



すべてのコードはデモンストレーションのみを目的としており、アクションの正しい指示ではありません。



1ポイントと2ポイントのXMLコード
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/main_content_layout" android:layout_width="match_parent" android:layout_height="match_parent" android:background="#ddd" android:orientation="vertical" android:paddingBottom="@dimen/activity_vertical_margin" android:paddingLeft="@dimen/activity_horizontal_margin" android:paddingRight="@dimen/activity_horizontal_margin" android:paddingTop="@dimen/activity_vertical_margin" app:layout_behavior="@string/appbar_scrolling_view_behavior" tools:context=".MainActivity" tools:showIn="@layout/activity_main"> <EditText android:layout_width="fill_parent" android:layout_height="40dp" android:singleLine="true" android:textColor="#000" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/main_text" android:textColor="#000" android:ellipsize="end"/> </LinearLayout>
      
      







上記のレイアウトへのリンクを含むボタンのあるレイアウト
 <?xml version="1.0" encoding="utf-8"?> <android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:fitsSystemWindows="true" tools:context=".MainActivity"> <android.support.design.widget.AppBarLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:theme="@style/AppTheme.AppBarOverlay"> <android.support.v7.widget.Toolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:background="?attr/colorPrimary" app:popupTheme="@style/AppTheme.PopupOverlay" /> </android.support.design.widget.AppBarLayout> <include layout="@layout/content_main" /> <android.support.design.widget.FloatingActionButton android:id="@+id/fab" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="bottom|end" android:layout_margin="@dimen/fab_margin" android:src="@android:drawable/ic_menu_camera" /> </android.support.design.widget.CoordinatorLayout>
      
      







次のようになります。







たとえば、検索するには、「夢」という単語を入力します。







この方法では、1つのキーワードに続いて以下のテキストを取得します。 キーワードとテキスト自体のフォントサイズが異なることに注意する価値があります(タスクを複雑にするために、そのようにしましょう)。



ボタンをクリックして、キーワードとテキストを含む領域のスクリーンショットを取得します。



ボタンをクリックして呼び出されるスクリーンショットを撮る方法
 private void takeScreenshot() { //      Date now = new Date(); // /   android.text.format.DateFormat.format("yyyy-MM-dd_hh:mm:ss", now); try { //        String path = Environment.getExternalStorageDirectory().toString() + "/" + now + ".jpg"; File imageFile = new File(path); //  layout'a,        View v1 = findViewById(R.id.main_content_layout); //  v1.setDrawingCacheEnabled(true); Bitmap bitmap = Bitmap.createBitmap(v1.getDrawingCache()); v1.setDrawingCacheEnabled(false); //    FileOutputStream outputStream = new FileOutputStream(imageFile); int quality = 100; bitmap.compress(Bitmap.CompressFormat.PNG, quality, outputStream); outputStream.flush(); outputStream.close(); //   Activity openScreenshotActivity(now); } catch (Throwable e) { e.printStackTrace(); } }
      
      







結果のスクリーンショットが新しいアクティビティで開き、シーケンシャルアクションの機能がNavigationDrawerに収集されます。 実際のアプリケーションでは、一部の操作を1つに結合して、イメージ内の不要なパスを排除できます。



結果の画像の前処理



最初に、結果のカラー画像をグレースケールに変換する必要があります。



カラーからハーフトーン画像への変換


変換には、RGBからYUVへのスキームが使用されます。



私たちの場合、必要なのは輝度(明るさ)だけであり、フォーラムで取得できます。



Y = 0.299 * R + 0.587 * G + 0.114 * B、ここで、R、G、Bはそれぞれ赤、緑、青のチャンネルです。



奇妙に思えるかもしれませんが、Colorクラスは色、特に静的メソッドである赤、緑、青の操作に役立ちます。このメソッドでは、ピクセル単位のint値から目的の色チャネルを選択するためにビット単位のシフト操作が実装されます。



カラーピクセルを輝度に変換するサンプルコード:



 //     pixels (size =   = width*height ) for (int i = 0; i < size; i++) { int color = pixels[i]; //    int r = Color.red(color); //    int g = Color.green(color); //    int b = Color.blue(color); //      RGB to YUV double luminance = (0.299 * r + 0.0f + 0.587 * g + 0.0f + 0.114 * b + 0.0f); }
      
      





グレースケール画像のセグメンテーションはカラーの場合ほど簡単ではないため、次のステップはグレースケール画像をバイナリに変換することです(ピクセル輝度値は2つの値0と1のみを持ちます)。



ハーフトーン二値化


この問題では、デフォルトのしきい値が128の基本しきい値法で2値化に十分です。



将来的には、結果を調整するために、実験的にしきい値を選択できます(アプリケーションは、ユーザーがしきい値を設定する機能を実装します)。



バイナリ輝度値を取得するには、以前に取得したグレースケール輝度を確認します。



  pixels[i] = luminance > threshold ? Color.WHITE : Color.BLACK;
      
      





thresholdがしきい値である場合、Color.WHITEとColor.BLACKは便宜上0と1と混同しないように定数です。



ハーフトーン変換とさらに二値化の後、次の結果が得られます。







これら2つの画像処理方法は非常に近いため、1つの方法にまとめられています。



画像のグレースケールと二値化に変換する方法の例
 /** * imagePath -    * threshold -   */ public void binarizeByThreshold(String imagePath, int threshold) { //     Bitmap bitmap = BitmapFactory.decodeFile(imagePath); //   int width = bitmap.getWidth(); int height = bitmap.getHeight(); int size = width * height; //    int[] pixels = new int[size]; bitmap.getPixels(pixels, 0, width, 0, 0, width, height); bitmap.recycle(); //     ,          for (int i = 0; i < size; i++) { int color = pixels[i]; int r = Color.red(color); int g = Color.green(color); int b = Color.blue(color); double luminance = (0.299 * r + 0.0f + 0.587 * g + 0.0f + 0.114 * b + 0.0f); pixels[i] = luminance > threshold ? Color.WHITE : Color.BLACK; } Utils.saveBitmap(imagePath, width, height, pixels); }
      
      







セグメンテーション



キーワードを見つけるには、2値化後に、テキスト内の行の検索、行内の単語の検索、文字数で単語に見つかった単語の申請者への割り当て(認識のために単語の輪を狭める)を含むいくつかのセグメンテーションステップを実行する必要があります。



行分割


テキストの行を決定するには、ゼロのピクセルを持つヒストグラムの部分の間にある、ゼロより大きい黒のピクセルの数を持つヒストグラムの部分を見つける必要があります。 これを行うために、各単一ピクセル画像ラインの黒ピクセル数のヒストグラムがコンパイルされ、その後、処理されてラインの垂直座標(実際には、ヒストグラムのライン番号)が取得されます。 この座標と画像全体のサイズがわかれば、単語のある線を含む領域を選択できます。



わかりやすくするための「インライン」ヒストグラム(左側)は、たとえば次のようになります。







文字列のヒストグラムを取得する方法
 public ArrayList<GystMember> getRowsGystogram(String imagePath) { // ,       Bitmap bitmap = BitmapFactory.decodeFile(imagePath); int width = bitmap.getWidth(); int height = bitmap.getHeight(); int size = width * height; int[] pixels = new int[size]; bitmap.getPixels(pixels, 0, width, 0, 0, width, height); bitmap.recycle(); ArrayList<GystMember> gystogram = new ArrayList<>(); //  for (int x = 0; x < height; x++) { gystogram.add(new GystMember(x)); for (int y = 0; y < width; y++) { int color = pixels[y + x * width]; if (color == Color.BLACK) { gystogram.get(x).add(); } } } return gystogram; } /*,             */ public class GystMember implements Serializable { public int grayValue; public int count; public GystMember(int grayValue) { this.grayValue = grayValue; this.count = 0; } public void add() { count++; } }
      
      









便宜上、ヒストグラムは正規化されています。



onDrawカスタムビューでのヒストグラムの正規化の例
 if (mGystogram != null) { float max = Integer.MIN_VALUE; for (GystMember gystMember : mGystogram) { if (gystMember.count > max) { max = gystMember.count; } } int pixelSize = getWidth(); int coef = (int) (max / pixelSize); if (coef == 0){ coef = 1; } int y = 0; for (GystMember gystMember : mGystogram) { int value = gystMember.count; canvas.drawLine(0, y, value / coef, y, mPaint); y++; } }
      
      







画像内の線の位置がわかったら、単語の検索に進むことができます。



単語分割


単語がどこにあり、どこがちょうど隣の文字であるかを理解するために、文字間の距離が単語間の距離と見なされ、同じ単語内の文字間の距離が何であるかを決定する必要があります。 ここでは、ほとんどの場合、このメソッドは2値化にありますが、その本質はこのような状況に適用できるため、modメソッドは、レスキュー、またはむしろその適応バージョンになります。



そもそも、行に関しては、行内にヒストグラムを作成するのと同じ方法で(実際は垂直で、現在は水平になっています)、文字がどこにあり、空白がどこにあるかを理解する必要があります。



このようなヒストグラムを取得する例は、以前のものよりも低く、類似しています。



このヒストグラムを処理すると、新しいヒストグラムを取得できます。黒いピクセル間の間隔、つまり、最初のグラデーションは幅1ピクセルの「スペース」の数になり、2番目のグラデーションは幅2ピクセルの「スペース」の数になります。



ギャップのヒストグラムを取得する例
 public ArrayList<GystMember> getSpacesInRowsGystogram(String imagePath, ArrayList<GystMember> rowsGystogram) { // ,       Bitmap bitmap = BitmapFactory.decodeFile(imagePath); int width = bitmap.getWidth(); int height = bitmap.getHeight(); int size = width * height; int[] pixels = new int[size]; bitmap.getPixels(pixels, 0, width, 0, 0, width, height); bitmap.recycle(); //     GystMember,     . ArrayList<GystMember> oneRowGystogram = new ArrayList<>(); ArrayList<Integer> spaces = new ArrayList<>(); ArrayList<GystMember> spacesInRowsGystogram = new ArrayList<>(); int yStart = 0, yEnd = 0, yIter = -1; boolean inLine = false; //   for (GystMember gystMember : rowsGystogram) { yIter++; //    (    ),  " " if (gystMember.count > 0 && !inLine) { inLine = true; yStart = yIter; } else if (gystMember.count == 0 && inLine) { //    (  ), " " inLine = false; yEnd = yIter; //   ,    for (int x = 0; x < width; x++) { GystMember member = new GystMember(x); for (int y = yStart; y < yEnd; y++) { int color = pixels[x + y * width]; if (color == Color.BLACK) { member.add(); } } oneRowGystogram.add(member); } int xStart = 0, xEnd = 0, xIter = -1; boolean inRow = false; //    for (GystMember oneRowMember : oneRowGystogram) { xIter++; //     if (oneRowMember.count == 0 && !inRow) { inRow = true; xStart = xIter; } else if ((oneRowMember.count > 0 || xIter == oneRowGystogram.size()-1) && inRow) { inRow = false; xEnd = xIter; //           int xValue = xEnd - xStart; spaces.add(xValue); } } } } //      Collections.sort(spaces); int lastSpace = -1; GystMember gystMember = null; for (Integer space : spaces) { if (space > lastSpace) { if (gystMember != null) { spacesInRowsGystogram.add(gystMember); } gystMember = new GystMember(space); } gystMember.add(); lastSpace = space; } return spacesInRowsGystogram; }
      
      







次のようなものが得られます。







モード法のロジックに基づいて、2つの顕著なピークが見つかります(上の画像では明らかです)。1つのピークについてのすべては、単語内の文字間の距離、2番目について-単語間の距離です。



「スペース」と線のヒストグラムに関するこのような情報を使用して、単語が配置されている画像の部分を選択し、これらの単語に含まれる文字数を計算できます。



完全なプロセスコードについては、ソースコードを参照してください。



上記のアルゴリズムセットの作業を視覚的に評価するために、テキスト内の単語は交互の色で塗られ、応募者の単語(キーワードの文字数と一致する文字数の単語)は黒のままです。







そのため、この段階では、キーワードと文字数に基づく応募者の単語を含む画像の断片(より正確には、これらの断片の座標)があります。応募者の中からキーワードを見つけてみてください。



認識



ここで、認識とは、応募者の間でキーワードを認識することを指しますが、どのキャラクターで構成されているかは問題ではありません。



認識のために一連の有益な機能が選択されます。これは、エンドポイント、ノードポイント、および3、4、5個の黒の隣接ピクセルなどのピクセル数です。 多数の記号が互いに「重なり合う」ため、意味を失うことが実験的に確立されています。



この段階では、エンドポイントの数に焦点を当て、それらの位置を考慮します(画像の上部と下部-部分ごとに記号は別々に考慮されます)。



各単語について、記号の数が計算され、その後、ユークリッド距離に基づいて最近傍の方法を使用して検索が実行されます。



計算された特性を持つ構造の形成の例
  private void generateRecognizeMembers() { mRecognizeMembers.clear(); for (PartImageMember pretendent : mPretendents) { RecognizeMember recognizeMember = new RecognizeMember(pretendent); int pretendentWidth = pretendent.endX - pretendent.startX; int pretendentHeight = pretendent.endY - pretendent.startY; int[][] workPixels = new int[pretendentWidth][pretendentHeight]; //        for (int pY = pretendent.startY, py1 = 0; pY < pretendent.endY; pY++, py1++) for (int pX = pretendent.startX, px1 = 0; pX < pretendent.endX; pX++, px1++) { workPixels[px1][py1] = mImagePixels[pX + pY * mImageWidth]; } int half = pretendentHeight / 2; //   for (int ly = 0; ly < pretendentHeight; ly++) { //   for (int lx = 0; lx < pretendentWidth; lx++) { int currentColor = workPixels[lx][ly]; //        . if (currentColor != Color.WHITE) { // 33     int[][] pixelNeibours = Utils.fill3x3Matrix(pretendentWidth, pretendentHeight, workPixels, ly, lx); int[] pixelsLine = Utils.getLineFromMatrixByCircle(pixelNeibours); //     ,     int A4 = getA4(pixelsLine); int A8 = getA8(pixelsLine); int B8 = getB8(pixelsLine); int C8 = getC8(pixelsLine); int Nc4 = A4 - C8; int CN = A8 - B8; recognizeMember.A4.add(A4); recognizeMember.A8.add(A8); recognizeMember.Cn.add(CN); //       if (A8 == 1 && Nc4 == 1 && CN == 1) { if (ly < half) { recognizeMember.endsCount++; } else { recognizeMember.endsCount2++; } } } } } mRecognizeMembers.add(recognizeMember); } } private int getA4(int[] pixelsLine) { int result = 0; for (int k = 1; k < 5; k++) { result += pixelsLine[2 * k - 2]; } return result; } private int getA8(int[] pixelsLine) { int result = 0; for (int k = 1; k < 9; k++) { result += pixelsLine[k - 1]; } return result; } private int getB8(int[] pixelsLine) { int result = 0; for (int k = 1; k < 9; k++) { result += pixelsLine[k - 1] * pixelsLine[k]; } return result; } private int getC8(int[] pixelsLine) { int result = 0; for (int k = 1; k < 5; k++) { result += pixelsLine[2 * k - 2] * pixelsLine[2 * k - 1] * pixelsLine[2 * k]; } return result; }
      
      







A4、A8などのすべての特性番号について 追加情報を見つけることができます。



ユークリッド距離認識コード
 private void recognize() { //   ( ) generateRecognizeMembers(); mResultPartImageMembers.clear(); ArrayList<Double> keys = new ArrayList<>(); //     RecognizeMember firstMember = mRecognizeMembers.get(0); mRecognizeMembers.remove(firstMember); for (RecognizeMember recognizeMember : mRecognizeMembers) { if (recognizeMember.getPretendent() != firstMember.getPretendent()) { double keyR = firstMember.equalsR(recognizeMember); recognizeMember.R = keyR; keys.add(keyR); } } //   Collections.sort(mRecognizeMembers, new Comparator<RecognizeMember>() { @Override public int compare(RecognizeMember lhs, RecognizeMember rhs) { return (int) Math.round(lhs.R - rhs.R); } }); //    ,   (  )     double firstKey = -1; double secondKey = -1; for (RecognizeMember member : mRecognizeMembers) { double key = member.R; if (firstKey == -1) { firstKey = key; mResultPartImageMembers.add(member.getPretendent()); } else if (key == firstKey) { mResultPartImageMembers.add(member.getPretendent()); } else if (secondKey == -1) { secondKey = key; mResultPartImageMembers.add(member.getPretendent()); } else if (secondKey == key) { mResultPartImageMembers.add(member.getPretendent()); } } } <...> //          (2)   public double equalsR(RecognizeMember o) { return Math.sqrt(Math.pow(this.endsCount - o.endsCount, 2) + Math.pow(this.endsCount2 - o.endsCount2, 2)); } <...>
      
      







結果として、単語とエラーの再現性を考えると、2〜3個の最近傍を取得でき、その中からキーワードが見つかります(図で赤で強調表示されています)。







図では、赤い単語の中に「夢」という検索単語があることもわかります。



認識の品質を向上させるために、異なる二値化しきい値を選択したり、たとえばプローブを追加して他の有益な機能を選択したりすることができます。



おわりに



私たちは目標を達成することができました。画像処理と認識のいくつかの方法がテストされましたが、Androidでの実装は追加の困難を課しませんが、メモリ消費を考慮するだけでいくつかの大きなビットマップを同時に保存する必要はありません。



この情報が、Android向けの画像の操作に関する問題を解決する旅の初心者に役立つことを願っています。



使用されているすべてのアルゴリズムの説明、特性番号の計算など。 habrahabrのパブリックドメイン、および多数の教科書やオンラインリソースで簡単に見つけることができます。



GitHubで完全に利用可能なプロジェクト



PS:与えられたアルゴリズムの実装は最適ではなく、さらなる調査と最適化が必要であり、Androidで上記のメソッドの動作を理解、視覚化、評価するための最小限必要な例としてのみ機能します。



All Articles