はじめに
スキャンした書類やスクリーンショットの中から「あの文字列、どこに書いてあったっけ?」を探すのは意外と面倒です。
最近は何でもかんでも画像をAI(マルチモーダルLLM)に読み込ませてOCRさせる、という手法をよく見かけます。確かに精度は高いのですが、単純な文字検索のためにAPIを叩いて大きなモデルに画像を丸ごと読み込ませるのは、正直過剰にリソースを食っているだけに見えました。そこで今回は、OpenCV + Tesseractという古典的な構成で、画像内の文字をOCRで認識し、検索文字列にヒットした箇所を黄色くハイライト表示するツールを作ってみました。
この記事でわかること
- OpenCV + pytesseract を組み合わせた文字検索・ハイライトの実装方法
- カラー画像/グレースケール画像のどちらにも対応させる前処理の工夫
- OCR前に「文字っぽい領域」だけを事前検出してグルーピングする手法
完成イメージ
from image_search_kit import ImageTextSearcher
searcher = ImageTextSearcher("path/to/image.png")
highlighted_image, boxes = searcher.search("検索したい文字")
search() を呼ぶだけで、ヒットした箇所を矩形でハイライトした画像と、その座標リスト [(x, y, w, h), ...] が返ってきます。
技術スタック
| 項目 | 内容 |
|---|---|
| 言語 | Python 3.x |
| 画像処理 | OpenCV (cv2) |
| OCRエンジン | Tesseract OCR(pytesseract) |
| GUI | OpenCV標準機能(imshow / setMouseCallback / createTrackbar 等) |
アーキテクチャ
処理ロジックを役割ごとに分割し、ImageTextSearcher クラスでオーケストレーションする構成にしました。
image_search_kit.py # ライブラリ本体(ImageTextSearcher)
logic/
├── preprocess.py # 画像読み込み・前処理・文字領域検出
├── ocr_engine.py # pytesseractによるOCR実行
├── searcher.py # 検索文字列とのマッチング
└── highlighter.py # ハイライト描画
ImageTextSearcher のコンストラクタで画像を受け取り、内部で前処理とOCRまで済ませてしまうことで、search() メソッド自体は「文字列マッチング+描画」だけを行うシンプルな作りにしています。
class ImageTextSearcher:
def __init__(self, image, highlight_color=(0, 255, 255), lang="eng"):
self._lang = lang
self._highlight_color = highlight_color
raw_image = load_image(image)
self._display_bgr, self._gray = to_display_and_gray(raw_image)
self._text_groups = extract_features(self._gray)
for group in self._text_groups:
group["text"] = run_ocr_text(group["image"], lang=self._lang)
def search(self, text, case_sensitive=False):
boxes = find_matches(self._text_groups, text, case_sensitive=case_sensitive)
highlighted_image = draw_highlights(
self._display_bgr, boxes, color=self._highlight_color
)
return highlighted_image, boxes
ポイント1: カラー/グレースケール両対応の前処理
入力画像がカラーかグレースケールか、あるいはアルファチャンネル付きかによって処理を分岐させています(logic/preprocess.py の to_display_and_gray)。
def to_display_and_gray(image):
if image.ndim == 2:
gray = image
display_bgr = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
elif image.ndim == 3 and image.shape[2] == 1:
gray = image[:, :, 0]
display_bgr = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
elif image.ndim == 3 and image.shape[2] == 4:
display_bgr = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
gray = cv2.cvtColor(display_bgr, cv2.COLOR_BGR2GRAY)
elif image.ndim == 3 and image.shape[2] == 3:
display_bgr = image
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
else:
raise ValueError(f"サポートされていない画像形式です: shape={image.shape}")
return display_bgr, gray
こうすることで「表示・ハイライト用のカラー画像」と「OCR用のグレースケール画像」を常にセットで用意でき、後続処理がどちらの入力形式でも同じロジックで動きます。
ポイント2: OCR前に文字領域をグルーピングする
pytesseract.image_to_data を画像全体にそのままかけることもできますが、今回は精度と処理範囲をコントロールしやすくするため、Canny法によるエッジ検出→膨張処理→連結成分解析で「文字が集まっていそうな領域」を先に矩形として切り出し、その領域ごとにOCRをかける方式にしました(logic/preprocess.py の extract_features / detect_text_groups)。
def extract_features(img):
# 2値化
_, img_binary = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY)
# エッジ検出
edge = cv2.Canny(img_binary, 50, 150)
# ぼかしを入れて近い文字同士をつなげやすくする
edge = blur_to_white(edge, kernel_size=21, sigma=0, intensity=1.5)
# 再度2値化
_, edge = cv2.threshold(edge, 85, 255, cv2.THRESH_BINARY)
# 近い文字同士をグループ化
groups = detect_text_groups(edge)
for group in groups:
group["image"] = img[group["startY"]:group["endY"], group["startX"]:group["endX"]]
return groups
detect_text_groups では cv2.dilate で文字同士を横方向・縦方向にくっつけたあと、cv2.connectedComponentsWithStats で連結成分(=ひとかたまりの文字列領域)を抽出し、小さすぎるノイズは min_area で除外しています。
def detect_text_groups(text_mask, horizontal_gap=20, vertical_gap=5, min_area=100, padding=5):
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (horizontal_gap, vertical_gap))
grouped = cv2.dilate(text_mask, kernel, iterations=1)
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(grouped, connectivity=8)
...
この方式のメリットは、OCRを画像全体ではなく候補領域ごとに分けて実行できる点です。ノイズの多い背景があっても、ある程度文字の塊だけにフォーカスしてOCR精度を上げやすくなります。
ポイント3: 検索とハイライト
グループごとに run_ocr_text でテキスト化した後は、単純な部分一致検索で候補を絞り込みます(logic/searcher.py)。
def find_matches(groups, query, case_sensitive=False):
if not query:
return []
target = query if case_sensitive else query.lower()
boxes = []
for group in groups:
text = group.get("text", "")
text = text if case_sensitive else text.lower()
if target in text:
x, y = group["startX"], group["startY"]
w, h = group["endX"] - x, group["endY"] - y
boxes.append((x, y, w, h))
return boxes
ヒットした座標は highlighter.py の draw_highlights で cv2.rectangle を使い、元画像を破壊しないようコピーした上に矩形を描画します。ハイライト色はコンストラクタ引数の他、set_highlight_color() で後から変更することも可能です。
def draw_highlights(image_bgr, boxes, color=(0, 255, 255), thickness=2):
result = image_bgr.copy()
for (x, y, w, h) in boxes:
cv2.rectangle(result, (x, y), (x + w, y + h), color, thickness)
return result
つまずいたポイント・工夫
-
OCRの前処理をどこまでやるか:画像全体に対してそのまま
pytesseractをかけると、背景ノイズや罫線を文字と誤認識することがありました。Canny+膨張+連結成分による事前グルーピングを挟むことで、文字らしい領域だけに絞ってOCRをかけられるようにしています。 -
カラー/グレースケールの両対応:入力の次元数・チャンネル数で分岐する
to_display_and_grayを用意し、以降の処理では常に「表示用カラー画像」と「OCR用グレースケール画像」の2つが揃っている前提でロジックを組めるようにしました。
今後の課題
- 部分一致だけでなく、あいまい検索や正規表現検索への対応
- 複数ヒット時の一覧表示・ページ送りUI
- OCR精度向上のためのTesseract以外のエンジン(例: EasyOCR)との比較検討
- 精度の向上(2値化の閾値、Cannyのしきい値、
min_areaやpaddingなどのパラメータ調整)
おわりに
OpenCVとTesseractという枯れた技術の組み合わせでも、事前の文字領域検出を工夫することでそれなりに実用的な検索ツールを作ることができました。ソースコード全体は以下のような構成になっているので、興味があれば参考にしてみてください。
img-test/
├── document/
│ └── 要件定義.md
├── src/
│ ├── image_search_kit.py
│ ├── demo.py
│ └── logic/
│ ├── preprocess.py
│ ├── ocr_engine.py
│ ├── searcher.py
│ └── highlighter.py
├── requirements.txt
└── README.md




