[ScanDoc]スキャンの前処理









電子文書管理システムは論文の作業を完全に排除すると考えられていますが、そうではありません。 文書の紙のコピーをデジタル化するために、それらは通常スキャナーを通過します。 文書の流れとスキャン品質の要件が特定のしきい値を超えると、プログラムで解決する必要のある多くの問題が発生します。



どのような問題を解決する必要があります:





タスクを解決するアルゴリズムは開発されており、おそらくインターネットに投稿されていますが、明確な説明が見つかりませんでした。 もちろん、高価なプロのスキャナーはこれらの問題を解決しますが、ファームウェアの使用は常に可能とは限りません。

この記事のアイデアは、これらの問題を解決するツールを開発する過程で生まれました。 ドキュメントのデジタル化に関する利用可能な情報を補完し、同様のタスクに携わっている開発者にとって役立つことを願っています。



古き良きFutjitsu fi-6140スキャナーを使用して受け取ったドキュメントの3回のスキャンを検討してください。

  1. Tinkoff Bankカードを受け取るための申請書のスキャン。
  2. パスポートのコピー;
  3. メールの封筒のスキャン。








傾き補正



ドキュメントのスキャンを受信した後、厳密に垂直または水平のビューにする必要があります。 傾きを調整できるマークのない任意の文書を入力できることが理解されます。 したがって、ドキュメントの水平および垂直コンポーネント(線、表の線、バーコード、さらには曲げ点)に自分自身を結び付けます。

最初のステップは、イメージの冗長性を排除することです。 アウトラインを選択します。 このために、境界検出器を使用します。

最高品質の結果が得られるため、Canny境界検出器を選択しました。







これで、画像に直線が表示されます。 これを行うには、コンピュータービジョンで使用される一般的なソリューションであるハフ変換を使用します。 その原理については詳しく説明しませんが、インターネットで見つけることができます。 変換の本質は、画像内の線のすべての可能なオプションを検索し、それらの応答を計算することです。 応答が大きいほど、ラインがより顕著になります。 変換の結果として、Yに対して傾斜角が取られ、Xに対して線までの距離が取られる位相平面が構築されます。







位相面の視覚化では、各ピクセルは一意の線に対応します。 座標は最もはっきりした線で計算されます。



明確にするために、元の画像の輪郭にそれらを示すために、最も強烈な5本の線を取ります。







線の傾斜角は、ドキュメントの座標軸の傾斜に対応していることがわかります。



画像を回転させる角度を取得するために、グローバル座標軸からの線のずれ角度(0度と90度から)を計算し、値を平均して画像の角度を取得します。 結果の角度にマイナス記号を付けて画像を回転させます。 この画像を使用して、さらに作業を進めることができます。



! いくつかの線の勾配は、他の線と非常に異なります。 それらは平行からはほど遠い。 そのような行を計算から除外して、結果が損なわれないようにすることをお勧めします。







グラフィックを扱うために、素晴らしいaforgenetライブラリを使用しました。 ドキュメントの角度を見つけるための上記のアルゴリズムの実装が既にあります。 その結果、準備が整うのは15行のコードと税の修正のみです。



! GetAverageBorderColor関数は、元の画像の周囲の平均色を返します。 定数または別のより高度な関数で置き換えることができます。

public static Bitmap DocumentAngleCorrection(Bitmap image) { var grayImage = Grayscale.CommonAlgorithms.RMY.Apply(image); var skewChecker = new DocumentSkewChecker(); var angle = skewChecker.GetSkewAngle(grayImage); while (angle >= 90) { angle -= 90; } while (angle <= -90) { angle += 90; } var rotator = new RotateBilinear(-angle, false); rotator.FillColor = GetAverageBorderColor(image); image = rotator.Apply(image); return image; }
      
      







フレーミング



画像を揃えました。 次に、その有益な部分をトリミングする必要があります。 この段階では、いくつかの機能を検討することが重要です。



アルゴリズムは適切に動作するはずです。



アルゴリズムの基礎としてこの仮定を採用しました。画像の情報領域では輝度に多くの違いがあり、空の領域ではほとんどありません。 したがって、問題の解決策は3つのアクションに限定されます。

  1. 画像をフラグメントに分割し、フラグメントごとに垂直および水平方向の輝度低下の数をカウントします。
  2. 多数の輝度低下を伴うフラグメントを探しています。
  3. 有益な領域を切り取りました。


画像のピクセルにすばやくアクセスするには、バイトの配列を使用することをお勧めします。 次のように取得できます。

 var bitmapData = sourceBitmap.LockBits(new Rectangle(0, 0, sourceBitmap.Width, sourceBitmap.Height), ImageLockMode.ReadWrite, PixelFormat.Format24bppRgb); var bytes = bitmapData.Stride * sourceBitmap.Height; var sourceBytes = new byte[bytes]; System.Runtime.InteropServices.Marshal.Copy(bitmapData.Scan0, sourceBytes, 0, bytes);
      
      





アルゴリズムは次のようになります。

 const int sensitivity = 25; const int widthQuantum = 100; var regionSize = bitmapData.Width / widthQuantum; for (var y = 0; y < bitmapData.Height + regionSize; y += regionSize) { // x processing for (var x = 0; x < bitmapData.Width + regionSize; x += regionSize) { // y processing var value = 0; for (var yy = y; (yy < y + regionSize) && (yy < bitmapData.Height); yy++) { // Horosontal counting var pixel = GetGrayPixel(sourceBytes, bitmapData.Width, x, yy); for (var xx = x; (xx < x + regionSize) && (xx < bitmapData.Width); xx++) { var nextPixel = GetGrayPixel(sourceBytes, bitmapData.Width, xx, yy); if (Math.Abs(pixel - nextPixel) > sensitivity) { value++; } pixel = nextPixel; } } for (var xx = x; (xx < x + regionSize) && (xx < bitmapData.Width); xx++) { // Vertical counting var pixel = GetGrayPixel(sourceBytes, bitmapData.Width, xx, y); for (var yy = y; (yy < y + regionSize) && (yy < bitmapData.Height); yy++) { var nextPixel = GetGrayPixel(sourceBytes, bitmapData.Width, xx, yy); if (Math.Abs(pixel - nextPixel) > sensitivity) { value++; } pixel = nextPixel; } } // value TODO } }
      
      







指定された場所の変数値には、処理されたフラグメントの垂直および水平方向の輝度低下の数が含まれます。 この値とフラグメント座標は、たとえばリストに保存できます。



! GetGrayPixel関数は、ピクセル強度の平均値を返します。

 private static byte GetGrayPixel(byte[] src, int w, int x, int y) { var s = GetShift(w, x, y); if ((s + 3 > src.Length) || (s < 0)) { return 127; } int b = src[s++]; b += src[s++]; b += src[s]; b = (int)(b / 3.0); return (byte)b; }
      
      







アルゴリズムを適用した後、画像の明るさの違いのマップを得ました。 最大のスイングを含むエリアを選択します。







! リソースを節約するには、画像のコピーを減らして作業することをお勧めします。 次に、結果をスケーリングし、元の画像に適用します。







結果を見ます。 アルゴリズムは正しく機能しました-余分なものを残したり、カットしたりしませんでした。







ほとんどの場合、この結果が得られました。



空白のページを削除する



ドキュメントの情報部分を抽出するアルゴリズムを開発した後、ドキュメントの空白ページを削除するタスクがすでに出現していることがありました。 したがって、空白ページを削除するために、同じアルゴリズムを使用し、わずかに変更しました。 画像の明るさの差のマップを作成する代わりに、明るさの差が大きいフラグメントと小さいフラグメントの数をカウントしました。 高周波ブロックが多数ある場合、画像には貴重な情報が含まれており、空ではありません。

処理時間を短縮するために、不要なページを削除し、1つの方法で画像をトリミングできます。 1サイクルのみ。 しかし、位置合わせ後にのみ画像をトリミングできることは明らかです。 この場合、差異マップを有効にする必要があります。 したがって、私たちの生活を複雑にしないために、次の手順を決定しました。

空白ページを削除->傾き補正->切り抜き

周波数をカウントするために、追加のピクセルパスが1つありました。 しかし、これは最近ではゴードン・ムーアの法則のおかげで問題ではありません。



フルチーズ
 public static Bitmap DocumentAngleCorrection(Bitmap image) { var grayImage = Grayscale.CommonAlgorithms.RMY.Apply(image); var skewChecker = new DocumentSkewChecker(); var angle = skewChecker.GetSkewAngle(grayImage); while (angle >= 90) { angle -= 90; } while (angle <= -90) { angle += 90; } var rotator = new RotateBilinear(-angle, false); rotator.FillColor = GetAverageBorderColor(image); image = rotator.Apply(image); return image; } private static Color GetAverageBorderColor(Bitmap bitmap) { var widthProcImage = (double)200; var sourceImage = bitmap; var sizeFactor = widthProcImage / sourceImage.Width; var procBtmp = new Bitmap(sourceImage, (int)Math.Round(sourceImage.Width * sizeFactor), (int)Math.Round(sourceImage.Height * sizeFactor)); var bitmapData = procBtmp.LockBits(new Rectangle(0, 0, procBtmp.Width, procBtmp.Height), ImageLockMode.ReadWrite, PixelFormat.Format24bppRgb); var bytes = Math.Abs(bitmapData.Stride) * procBtmp.Height; var sourceBytes = new byte[bytes]; System.Runtime.InteropServices.Marshal.Copy(bitmapData.Scan0, sourceBytes, 0, bytes); var channels = new Dictionary<char, int>(); channels.Add('r', 0); channels.Add('g', 0); channels.Add('b', 0); var cnt = 0; for (var y = 0; y < bitmapData.Height; y++) { // vertical var c = GetColorPixel(sourceBytes, bitmapData.Width, 0, y); channels['r'] += cR; channels['g'] += cG; channels['b'] += cB; cnt++; c = GetColorPixel(sourceBytes, bitmapData.Width, bitmapData.Width - 1, y); channels['r'] += cR; channels['g'] += cG; channels['b'] += cB; cnt++; } for (var x = 0; x < bitmapData.Width; x++) { // horisontal var c = GetColorPixel(sourceBytes, bitmapData.Width, x, 0); channels['r'] += cR; channels['g'] += cG; channels['b'] += cB; cnt++; c = GetColorPixel(sourceBytes, bitmapData.Width, x, bitmapData.Height - 1); channels['r'] += cR; channels['g'] += cG; channels['b'] += cB; cnt++; } procBtmp.UnlockBits(bitmapData); var r = (int)Math.Round(((double)channels['r']) / cnt); var g = (int)Math.Round(((double)channels['g']) / cnt); var b = (int)Math.Round(((double)channels['b']) / cnt); var color = Color.FromArgb(r > 255 ? 255 : r, g > 255 ? 255 : g, b > 255 ? 255 : b); return color; } private static byte GetGrayPixel(byte[] src, int w, int x, int y) { var s = GetShift(w, x, y); if ((s + 3 > src.Length) || (s < 0)) { return 127; } int b = src[s++]; b += src[s++]; b += src[s]; b = (int)(b / 3.0); return (byte)b; } private static Color GetColorPixel(byte[] src, int w, int x, int y) { var s = GetShift(w, x, y); if ((s + 3 > src.Length) || (s < 0)) { return Color.Gray; } byte r = src[s++]; byte b = src[s++]; byte g = src[s]; var c = Color.FromArgb(r, g, b); return c; } private static int GetShift(int width, int x, int y) { return y * width * 3 + x * 3; } public static bool DocumentDetectInfo(Bitmap image) { const double widthProcImage = 200; const int sens = 15; const int treshold = 25; const int widthQuantum = 10; var sourceImage = image; var sizeFactor = widthProcImage / sourceImage.Width; var procBtmp = new Bitmap(sourceImage, (int)Math.Round(sourceImage.Width * sizeFactor), (int)Math.Round(sourceImage.Height * sizeFactor)); var bd = procBtmp.LockBits(new Rectangle(0, 0, procBtmp.Width, procBtmp.Height), ImageLockMode.ReadWrite, PixelFormat.Format24bppRgb); var bytes = Math.Abs(bd.Stride) * procBtmp.Height; var source = new byte[bytes]; System.Runtime.InteropServices.Marshal.Copy(bd.Scan0, source, 0, bytes); var maxV = 0; var size = bd.Width / widthQuantum; var hight = 0; var low = 0; for (var y = 0; y < bd.Height + size; y += size) { // x processing for (var x = 0; x < bd.Width + size; x += size) { // y processing var value = 0; for (var yy = y; (yy < y + size) && (yy < bd.Height); yy++) { // Horosontal counting var pixel = GetGrayPixel(source, bd.Width, x, yy); for (var xx = x; (xx < x + size) && (xx < bd.Width); xx++) { var point = GetGrayPixel(source, bd.Width, xx, yy); if (Math.Abs(pixel - point) > sens) { value++; } pixel = point; } } for (var xx = x; (xx < x + size) && (xx < bd.Width); xx++) { // Vertical counting var pixel = GetGrayPixel(source, bd.Width, xx, y); for (var yy = y; (yy < y + size) && (yy < bd.Height); yy++) { var point = GetGrayPixel(source, bd.Width, xx, yy); if (Math.Abs(pixel - point) > sens) { value++; } pixel = point; } } maxV = Math.Max(maxV, value); if (value > treshold) { hight++; } else { low++; } } } double cnt = hight + low; hight = (int)Math.Round(hight / cnt * 100); procBtmp.UnlockBits(bd); return (hight > treshold); } public static Bitmap DocumentCropInfo(Bitmap image) { const double widthProcImage = 1000; const int sensitivity = 25; const int treshold = 50; const int widthQuantum = 100; var sourceImage = image; var sizeFactor = widthProcImage / sourceImage.Width; var procBtmp = new Bitmap(sourceImage, (int)Math.Round(sourceImage.Width * sizeFactor), (int)Math.Round(sourceImage.Height * sizeFactor)); var bitmapData = procBtmp.LockBits(new Rectangle(0, 0, procBtmp.Width, procBtmp.Height), ImageLockMode.ReadWrite, PixelFormat.Format24bppRgb); var bytes = Math.Abs(bitmapData.Stride) * procBtmp.Height; var sourceBytes = new byte[bytes]; System.Runtime.InteropServices.Marshal.Copy(bitmapData.Scan0, sourceBytes, 0, bytes); var x1 = procBtmp.Width; var y1 = procBtmp.Height; var x2 = 0; var y2 = 0; var maxV = 0; var pointList = new List<Point>(); var regionSize = bitmapData.Width / widthQuantum; for (var y = 0; y < bitmapData.Height + regionSize; y += regionSize) { // x processing for (var x = 0; x < bitmapData.Width + regionSize; x += regionSize) { // y processing var value = 0; for (var yy = y; (yy < y + regionSize) && (yy < bitmapData.Height); yy++) { // Horosontal counting var pixel = GetGrayPixel(sourceBytes, bitmapData.Width, x, yy); for (var xx = x; (xx < x + regionSize) && (xx < bitmapData.Width); xx++) { var nextPixel = GetGrayPixel(sourceBytes, bitmapData.Width, xx, yy); if (Math.Abs(pixel - nextPixel) > sensitivity) { value++; } pixel = nextPixel; } } for (var xx = x; (xx < x + regionSize) && (xx < bitmapData.Width); xx++) { // Vertical counting var pixel = GetGrayPixel(sourceBytes, bitmapData.Width, xx, y); for (var yy = y; (yy < y + regionSize) && (yy < bitmapData.Height); yy++) { var nextPixel = GetGrayPixel(sourceBytes, bitmapData.Width, xx, yy); if (Math.Abs(pixel - nextPixel) > sensitivity) { value++; } pixel = nextPixel; } } pointList.Add(new Point() { V = value, X = x, Y = y }); maxV = Math.Max(maxV, value); } } var vFactor = 255.0 / maxV; foreach (var point in pointList) { var v = (byte)(point.V * vFactor); if (v > treshold) { x1 = Math.Min(x1, point.X); y1 = Math.Min(y1, point.Y); x2 = Math.Max(x2, point.X + regionSize); y2 = Math.Max(y2, point.Y + regionSize); } } procBtmp.UnlockBits(bitmapData); x1 = (int)Math.Round((x1 - regionSize) / sizeFactor); x2 = (int)Math.Round((x2 + regionSize) / sizeFactor); y1 = (int)Math.Round((y1 - regionSize) / sizeFactor); y2 = (int)Math.Round((y2 + regionSize) / sizeFactor); var bigRect = new Rectangle(x1, y1, x2 - x1, y2 - y1); var clippedImg = CropImage(sourceImage, bigRect); return clippedImg; } public static Bitmap CropImage(Bitmap source, Rectangle section) { section.X = Math.Max(0, section.X); section.Y = Math.Max(0, section.Y); section.Width = Math.Min(source.Width, section.Width); section.Height = Math.Min(source.Height, section.Height); var bmp = new Bitmap(section.Width, section.Height); var g = Graphics.FromImage(bmp); g.DrawImage(source, 0, 0, section, GraphicsUnit.Pixel); return bmp; } private class Point { public int X; public int Y; public int V; }
      
      









おわりに



アルゴリズムは今日まで完璧に機能しています。 これは最も効果的なソリューションではない可能性があります。 したがって、特定の問題を解決するために考えられる他のアルゴリズムとアプローチを議論するために、コメントを募集しています。



じゃあね!



All Articles