口上
こんにちは、愉快な温泉担当VTuberの御坂ようです。
昨日のエントリでOCRするアプリのソースをズバッと書いたんですが、わかる人にはわかるだろうけどわからない人には全くわからん! たぶん御坂もあのソースだけ見たら「これは何をするプログラム?」ってなりかねない。
そんなことを思って今回、そのあたりを少し掘り下げた投稿を書いてみようかなって思います。
良ければお付き合いくださいませ~
あ、全体図としては昨日のエントリを見てくださいね。
事前準備(これを済ませてからコードを動かすとスムーズです)
Tesseract OCR 用の言語データを準備する
まずは NuGetで Tesseract パッケージを導入します。プロンプト使うならこんな感じでインストールしましょう。
Install-Package Tesseract
そうしたら Tesseract が使う英語データ (eng.traineddata) をダウンロードして、アプリの実行フォルダ下に作った tessdata フォルダに配置します(本番は発行の時は旨い事フォルダをコピペとかしましょうね)。
例: bin\Debug\net8.0-windows\tessdata\eng.traineddata
OpenCvSharp を導入する(OCR前処理で使用します)
今度は NuGet で以下のパッケージをインストールしましょう。プロンプトでやるならこんな感じです。
Install-Package OpenCvSharp4
Install-Package OpenCvSharp4.runtime.win
そして必要な using を追加していきます。
using System.Drawing;
using Tesseract;
using OpenCvSharp;
using OpenCvSharp.Extensions;
using System.Text.RegularExpressions;
フォーム作成
そしたらメインフォーム(Form1.cs)をデザイナで開き、ProcessScreenCapture を呼び出せるボタンやtextBoxを作っていきます。
ProcessScreenCapture メソッドの解説
フォームを一時的に隠す
this.Visible = false;
まず、自分自身(Form)が画面キャプチャに写り込むのを防ぐため(あと画面キャプチャ範囲を妨害しないように、処理開始時にウィンドウを非表示にさせましょう。そして処理終了後、 finally ブロックで必ず再表示しています。
範囲選択ダイアログの表示
using (var selector = new ScreenCaptureSelector())
{
if (selector.ShowDialog() == DialogResult.OK)
{
Rectangle rect = selector.SelectedRectangle;
ScreenCaptureSelector はユーザーにマウス操作で範囲を選ばせるための独自クラスです。選ばれた矩形が rect に格納されて次の処理に進みます。
画面キャプチャの取得
Bitmap bmp = new Bitmap(rect.Width, rect.Height);
using (Graphics g = Graphics.FromImage(bmp))
g.CopyFromScreen(rect.Location, Point.Empty, rect.Size);
選択範囲の大きさでテンポラリのビットマップファイルを作成、CopyFromScreen でその範囲選択した部分(矩形)をビットマップファイルに流し込みます。
画像の前処理
Bitmap grayResized = PreprocessImage(bmp, scale: 6);
if (invert) grayResized = InvertBitmap(grayResized);
Bitmap processed = OpenCvPreprocess(grayResized);
まず PreprocessImage で 拡大 + グレースケール化 を行ってOCR精度を向上させます。
その上で invert が true の場合は 白黒反転(黒背景に白文字のようなケースで有効ですよぅ)、さらに OpenCV を使った二値化やノイズ除去などの加工を OpenCvPreprocess で施します。
OCR 実行(3回繰り返し)
using (var engine = new TesseractEngine(tessPath, "eng", EngineMode.Default))
{
engine.SetVariable("tessedit_char_whitelist", "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.,!?\"' ");
for (int i = 0; i < 3; i++)
{
...
using (var page = engine.Process(pix, PageSegMode.Auto))
{
string text = page.GetText();
Tesseract OCR を利用して文字認識を行います。
言語データ(eng.traineddata)は AppDomain.CurrentDomain.BaseDirectory\tessdata に配置して whitelist を設定することで、不要な文字(記号など)の誤認識を抑えます。
またデータ的には学習性がないですが、一応 OCR を3回実行して結果を比較できるようにして、処理の揺らぎに対応させました(この3つから好きなの選べパターン)。
認識結果の整形
text = Regex.Replace(text, @"[^\S\r\n]+", " ").Trim();
text = text.Replace("\n", "\r\n");
text = AiCorrectEnglishText(text);
余分な空白を正規表現で削除し改行コードを統一してます。あと最後の AiCorrectEnglishText はメソッドとして実装している部分(後述)ですね。
結果を UI に反映
textBox1.Text = results[0];
textBox2.Text = results[1];
textBox3.Text = results[2];
textBox3.SelectionStart = textBox3.Text.Length;
textBox3.ScrollToCaret();
OCR結果を3つの TextBox に表示させる部分。前述の通り、3回分を並べることで認識のブレを目視で確認でき、且つお好みのものを使用者が選べるようにしました。
と、まずはこんな感じになってます。
画面キャプチャの部分とか人によってはなじみ薄いかなー? って思ったですね。御坂にもなじみ薄かったですし。
さあ、ここからは各メソッドの方も見ていきますよー
AiCorrectEnglishText の解説
メソッドの目的
OCR の結果テキストは誤認識が多く、特に「似た形の文字」を間違える傾向があります。このメソッドでは、よくある誤認識を単純な文字置換で補正しています。
名前に「AI」とついていますが、この部分はまだシンプルなルール(置換)ベースの補正になっています。
まあ、これが逆に認識率の向上を妨げる可能性も当然ある諸刃の剣なんですけどね。
文字の置換処理
string text = raw
.Replace("0", "O")
.Replace("1", "I")
.Replace("l", "I")
.Replace("|", "I")
.Replace("@", "a");
0 → O
数字のゼロがアルファベット O と誤認されがち。逆パターンもあり得ますが、ここでは英字に寄せています。
1 → I
数字の1が大文字のアイに誤認されやすいため補正。
l → I
小文字のエルが大文字アイに化けるケースを吸収。
| → I
縦棒が文字と誤認されるケースを補正。
@ → a
OCR が小文字 a を @ と誤認する場合があるため、単純置換。
大文字連続の補正(仮置き)
text = Regex.Replace(text, @"([A-Z]{2,})", m =>
{
string s = m.Value;
return s;
});
2文字以上の大文字が連続した場合にマッチする。
現状では単純に return s; でそのまま返してるけど、今後のことを考えて、ここで「単語に分割する」「先頭だけ大文字に直す」などの処理を追加できるようにしています(たとえば OCR が「ThEQUICKBROWNFOX」とまとめてしまった場合に、将来的に "The quick brown fox" のように直せる余地を残す)。
以上、 OCR 誤認識でよく発生する「0/O」「1/I」「l/I」「|/I」「@/a」などを単純置換で補正するメソッドでした。同時に大文字が連続した部分に正規表現を当ててるので、将来的に自然な英文に整形するためのフックポイントです。
InvertBitmap の解説
メソッドの目的
OCR では元データに対して「黒背景に白文字」や「白背景に黒文字」の処理をすることで認識率が大きく変わるみたいです。そのため、画像を反転処理してから OCR にかけると精度が向上するケースが往々にあるらしいです。このメソッドではそんな処理を行っています。
新しい Bitmap の作成
Bitmap inverted = new Bitmap(bmp.Width, bmp.Height);
範囲選択した矩形と同じサイズで空の Bitmap を作成し、そこにピクセルを書き込んでいきます。
全ピクセルの走査
for (int y = 0; y < bmp.Height; y++)
for (int x = 0; x < bmp.Width; x++)
2重ループで x, y 座標すべてのピクセルにアクセスします。そこに GetPixel で取り出した色を加工し、SetPixel で書き戻す、という処理を行っています。
(実はこの辺りはChatGPTさんのおすすめのままになってます)
色の反転処理
Color px = bmp.GetPixel(x, y);
inverted.SetPixel(x, y, Color.FromArgb(
255 - px.R,
255 - px.G,
255 - px.B));
各チャンネル(R, G, B)を 255 - 値 で計算すると、その色の反転になるんです。OCR用途だと特に「背景と文字色を逆にしたい」場合に有効かな。
例:白(255,255,255) → 黒(0,0,0)、赤(255,0,0) → シアン(0,255,255)。
ちなみにChatGPTさん曰く「GetPixel / SetPixel は 非常に遅いので、大きな画像を扱うと処理が重くなります。実運用では LockBits + unsafe で直接メモリアクセスするか、OpenCV(Cv2.BitwiseNot)を使うほうが高速です。」との事です。
PreprocessImage の解説
メソッドの目的
private Bitmap PreprocessImage(Bitmap bmp, int scale = 3)
OCR の精度は入力画像の品質に強く依存するんです。なので、このメソッドでは、まず グレースケール化(色情報を落とす)を行い、次に 拡大(文字を大きくする)という処理を行って、OCR が認識しやすい画像に整えています。
グレースケール用 Bitmap の作成
Bitmap grayBmp = new Bitmap(bmp.Width, bmp.Height);
入力画像 (bmp) と同じサイズの空の画像を作成。この後に「白黒化した画像」をここに描画していきます。
Graphics と ColorMatrix を使ったグレースケール変換
var cm = new ColorMatrix(new float[][]
{
new float[]{0.3f,0.3f,0.3f,0,0},
new float[]{0.59f,0.59f,0.59f,0,0},
new float[]{0.11f,0.11f,0.11f,0,0},
new float[]{0,0,0,1,0},
new float[]{0,0,0,0,1}
});
ColorMatrix を利用して色を変換しています。行列の最初の3行はそれぞれ RGB 成分の加重平均で、輝度に基づいたグレースケール化を行っています。
- 赤成分 0.3
- 緑成分 0.59
- 青成分 0.11
この比率は人間の視覚特性(緑を一番明るく感じやすい)に基づいた標準的な係数です(とChatGPTさんが言ってます)。
var ia = new ImageAttributes();
ia.SetColorMatrix(cm);
g.DrawImage(bmp, new Rectangle(0, 0, bmp.Width, bmp.Height),
0, 0, bmp.Width, bmp.Height, GraphicsUnit.Pixel, ia);
DrawImage に ImageAttributes を渡すことで、描画時に色変換をかけられ、結果としてカラー画像が グレースケール画像 として grayBmp に描き込まれます。
画像の拡大
return new Bitmap(
grayBmp,
new Size(grayBmp.Width * scale, grayBmp.Height * scale)
);
最後に指定倍率 scale で拡大した画像を新しい Bitmap として返しています。OCR では小さい文字は誤認識されやすいため、拡大処理で認識精度を底上げします。
OpenCvPreprocess の解説
メソッドの目的
private Bitmap OpenCvPreprocess(Bitmap bmp)
OCR の前に ノイズ除去・二値化・強調処理 を行うことで、文字をはっきりさせて認識精度を上げることを目的としたメソッドですよぅ。
PreprocessImage で拡大&グレースケール化した画像を、さらに OpenCV で加工してます。
Bitmap を OpenCV の Mat に変換
using (Mat src = BitmapConverter.ToMat(bmp))
C# の Bitmap を OpenCV 用の Mat に変換します。以降の処理は OpenCV の画像処理関数を使って行われます。
グレースケール変換
using (Mat gray = new Mat())
Cv2.CvtColor(src, gray, ColorConversionCodes.BGR2GRAY);
念のためカラー画像をグレースケールに変換。PreprocessImage で既にグレースケール済みですが、ここで再度安全に統一しています。
適応的二値化(Adaptive Thresholding)
int blockSize = 21; if (blockSize % 2 == 0) blockSize += 1;
Cv2.AdaptiveThreshold(
gray, bin, 255,
AdaptiveThresholdTypes.GaussianC,
ThresholdTypes.Binary,
blockSize, 3
);
文字と背景を白黒に分ける処理です。
AdaptiveThreshold を使うことで、照明ムラや背景の濃淡に強い二値化が可能。また blockSize=21 は「局所領域のサイズ」で、必ず奇数にする必要があります。
3 は閾値補正の定数で、調整することで文字の残り方が変わります。
膨張処理(MorphologyEx Dilate)
Mat kernel = Cv2.GetStructuringElement(MorphShapes.Rect, new Size(4, 3));
Cv2.MorphologyEx(bin, bin, MorphTypes.Dilate, kernel);
OCR にかける前に文字を 太らせる処理。
細い線やかすれ文字を補完して認識しやすくする効果があります。ここでは横方向に少し強め(4x3)の矩形カーネルを使用しました。
Mat を Bitmap に戻す
return BitmapConverter.ToBitmap(bin);
加工済みの Mat を C# の Bitmap に戻して呼び出し元で利用できるようにしますよ。
と、以上のような感じで Form1.cs 側の解説です。
あとは画面をキャプチャさせる時の ScreenCaptureSelector.cs というクラスファイルがあるのでそちらの解説もまとめていってみましょー♪
ScreenCaptureSelector.cs の解説
フィールドとプロパティ
private Point startPoint;
private Rectangle selectionRect;
private bool isSelecting = false;
public Rectangle SelectedRectangle { get; private set; }
startPoint → マウスで選択を始めた座標。
selectionRect → 現在のドラッグで選択されている矩形領域。
isSelecting → 選択中かどうかのフラグ。
SelectedRectangle → 選択完了後に結果を受け取るための公開プロパティ。
コンストラクタ
public ScreenCaptureSelector()
{
this.FormBorderStyle = FormBorderStyle.None;
this.WindowState = FormWindowState.Maximized;
this.TopMost = true;
this.DoubleBuffered = true;
this.BackColor = Color.Black;
this.Opacity = 0.3;
this.Cursor = Cursors.Cross;
this.Load += (s, e) =>
{
int exStyle = (int)GetWindowLong(this.Handle, GWL_EXSTYLE);
exStyle &= ~WS_EX_NOACTIVATE; // クリックでアクティブ化可能
SetWindowLong(this.Handle, GWL_EXSTYLE, (IntPtr)exStyle);
};
}
実際の挙動としては WindowsForm に相当するフォームをソース側で作り、それを半透明で表示させています。
- フォーム設定
- 枠なし&最大化 -> 画面全体を覆う。
- TopMost = true -> 常に最前面に出す。
- 半透明黒 (Opacity = 0.3) -> デスクトップの上にうっすら黒いシートを敷くイメージ。
- Cursor = Cursors.Cross -> 選択用に十字カーソルに。
- Win32API 操作
- WS_EX_NOACTIVATE を外すことで、クリックしたときにフォームがちゃんとアクティブになるよう調整します。-> これがないと「クリックしても選択開始できない」状態になる場合があります。
マウス操作
- OnMouseDown
isSelecting = true;
startPoint = e.Location;
selectionRect = new Rectangle(e.Location, new Size(0, 0));
Invalidate();
ドラッグ開始点を記録し、Invalidate() で再描画要求 → OnPaint が呼ばれる。
- OnMouseMove
if (isSelecting)
{
selectionRect = new Rectangle(
Math.Min(startPoint.X, e.X),
Math.Min(startPoint.Y, e.Y),
Math.Abs(startPoint.X - e.X),
Math.Abs(startPoint.Y - e.Y)
);
Invalidate();
}
ドラッグ中のマウス座標から矩形領域を計算する。Math.Min / Math.Abs を使うことで、左上から右下だけでなく、どの方向にドラッグしても矩形が作れる。
- OnMouseUp
isSelecting = false;
SelectedRectangle = selectionRect;
DialogResult = DialogResult.OK;
Close();
選択完了時の値を SelectedRectangle に保存、ダイアログ結果を OK にして閉じる、と。
描画処理
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
if (isSelecting)
{
using (Brush blackBrush = new SolidBrush(Color.FromArgb(100, Color.Black)))
e.Graphics.FillRectangle(blackBrush, this.ClientRectangle);
Region originalRegion = e.Graphics.Clip;
e.Graphics.SetClip(selectionRect, System.Drawing.Drawing2D.CombineMode.Exclude);
using (Brush brush = new SolidBrush(Color.FromArgb(0, Color.Black)))
e.Graphics.FillRectangle(brush, this.ClientRectangle);
e.Graphics.SetClip(originalRegion, System.Drawing.Drawing2D.CombineMode.Replace);
using (Brush brush = new SolidBrush(Color.FromArgb(200, Color.White)))
e.Graphics.FillRectangle(brush, selectionRect);
using (Pen pen = new Pen(Color.FromArgb(255, 255, 0, 0), 2))
e.Graphics.DrawRectangle(pen, selectionRect);
}
}
全画面を半透明、レイヤーっぽく黒く塗り潰します。
ただし選択領域部分だけ透過して見えるように除外し、さらに選択部分を白でうっすら塗って、赤枠を描いてます。これでユーザーがどこを範囲選択しているのかを視覚的にわかりやすくしてます。
Win32 API 定義
const int GWL_EXSTYLE = -20;
const int WS_EX_NOACTIVATE = 0x08000000;
[DllImport("user32.dll")]
static extern IntPtr GetWindowLong(IntPtr hWnd, int nIndex);
[DllImport("user32.dll")]
static extern IntPtr SetWindowLong(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
ウィンドウの拡張スタイルを取得/設定するために Win32 API を呼んでいる部分です。
WS_EX_NOACTIVATE を操作するのがポイント!
ScreenCaptureSelector.cs のまとめ
この ScreenCaptureSelector の実態は、全画面を半透明に覆って範囲選択を可視化するフォームです。
それはマウスドラッグで矩形を決めて、離した時点で SelectedRectangle に座標を保存・・・という「範囲選択専用ダイアログ(フォーム)」。
大きなまとめ
といった感じでした。
双方合わせてのまとめとしてはこんな感じになりますね。
それぞれの役割
Form1.cs(メインフォーム側)
- 範囲選択をするために ScreenCaptureSelector を呼び出す。
- ユーザーがマウスで範囲を確定したら、戻り値として SelectedRectangle を受け取る。
- その範囲を元に Bitmap を作成し、実際のデスクトップ画面から CopyFromScreen で切り取る。 → 「結果を使う(OCRに渡す・保存するなど)」の処理はここで行う。
ScreenCaptureSelector.cs(範囲選択ウィンドウ)
- 全画面を半透明で覆う黒背景のフォームを作り、マウスで矩形を選ばせる。
- OnMouseDown で開始点を記録し、OnMouseMove でドラッグ中の矩形を描画、OnMouseUp で確定。
- 確定した矩形は SelectedRectangle に格納し、DialogResult.OK を返して閉じる。
- OnPaint で範囲外を黒く塗りつぶし、範囲内だけ明るく残すことで「選択している感」を演出。
- Win32 API(GetWindowLong / SetWindowLong)でフォームを前面にアクティブ化可能にしており、タスクバーやクリックで確実に操作できるようにしている。
処理の流れまとめ
- メインフォーム(Form1)
using (var selector = new ScreenCaptureSelector())-
selector.ShowDialog()→ ユーザーが範囲を選択
- 範囲選択ウィンドウ(ScreenCaptureSelector)
- フルスクリーン半透明フォームを表示
- マウス操作で矩形選択 →
SelectedRectangleに確定値を保存 -
DialogResult.OKを返して終了
- メインフォームに戻る
-
selector.SelectedRectangleを受け取る - そのサイズで
Bitmapを作成 -
Graphics.CopyFromScreenでデスクトップの該当範囲をキャプチャ - → OCR処理や保存処理に利用できる
-
大事なポイント
- 役割を分離している
-
Form1.cs… 「範囲を選んでもらって、その画像を使う」 -
ScreenCaptureSelector.cs… 「範囲選択 UI 専用」
-
- 描画の工夫
- 半透明で暗くした画面をベースにする
- 「選択範囲だけハイライト+赤枠」で直感的に見せる
- Win32 API の利用
- 通常のフォームだとアクティブ化されないケースがある
-
SetWindowLongを使って修正
つまり 「ScreenCaptureSelector はただのツール。Form1 がそれを使って本番のキャプチャを実行する」 という二段構え構造になってます。
では、次回のポストでもよろしくお願いしまーす。