OCRとElasticsearchで画像ファイルの検索エンジンを作る

## はじめに
こちらは[ABEJAアドベントカレンダー2021](https://qiita.com/advent-calendar/2021/abeja)の17日目の記事です。


この記事では、文字認識用のGoogleの**Cloug Vision API**(OCR)と、全文検索エンジンの**Elasticsearch**(以下ES)を使用して、画像ファイルの文章内容からワード検索を行えるようにします。


過去の大量の文書ファイルや資料などから、特定のファイルを探したいけどなかなか見つけられない。。というよう方に紹介したい内容になっております。
OCRとESを使用することで、ファイル内の文章の内容からより効率的に検索することができるようになります。
そもそも紙の書類が多いから効率化を諦めている。。といった場合もスキャンやカメラで画像化することで同じことが再現できます。


## ディレクトリ構成

最終的なディレクトリ構成です。

```
.
├── docker-compose.yml
├── docker
│   ├── python3
│   │   └── Dockerfile 
│   └── elasticsearch
│       └── Dockerfile 
└── src
    ├── images
    │   └── input
    │   └── archive
    └── main.py
```

## Cloud Vision APIを使用する準備
Cloud Vision APIを使用するために、下記の手順が必要になります。

- Google Cloud Platformの登録
- Cloud Vision APIを有効にする設定
- APIを実行するためのサービスアカウントの作成
- 認証用のJSONファイルをダウンロードし、任意のディレクトリに配置(以降`credential.json`とします。)
- 認証用のJSONファイルへのpathを通す


[こちら](https://qiita.com/ysda/items/67c983510f4d7d1c6982)の記事を参考にさせていただきました！

## 実行環境の準備

Pythonの実行環境とElasticsearchの環境をDockerを使用して作成します。

`docker/python3/`にDockerfileを作成します。
Pythonの実行環境のセットアップと、Cloud Vision APIを実行するために必要な`google-cloud-vision`、Elasticsearchを使用するために必要なライブラリのインストールなどを行なっています。

```dockerfile:python3/Dockerfile
FROM python:3.9
USER root

RUN apt-get update
RUN apt-get -y install locales && \
    localedef -f UTF-8 -i ja_JP ja_JP.UTF-8
ENV LANG ja_JP.UTF-8
ENV LANGUAGE ja_JP:ja
ENV LC_ALL ja_JP.UTF-8
ENV TZ JST-9
ENV TERM xterm

RUN mkdir /app

RUN apt-get install -y vim
RUN pip install --upgrade pip
RUN pip install --upgrade setuptools

RUN python -m pip install google-cloud-vision \
                          elasticsearch
```

`docker/elasticsearch/`にDockerfileを作成します。
Elasticsearchのイメージの指定と、日本語の全文検索に対応させるための`analysis-kuromoji`をインストールします。

```dockerfile:elasticsearch/Dockerfile
FROM docker.elastic.co/elasticsearch/elasticsearch:7.15.0
RUN elasticsearch-plugin install analysis-kuromoji
```

docker-compose.ymlファイルの作成をします。
今回はElasticsearchは1つのコンテナ(ノード)のみ使用しますが、用途に応じてネットワーク内に複数のコンテナ(ノード)を使用してください。
また、environmentの`GOOGLE_APPLICATION_CREDENTIALS`には、credential.jsonを配置したpathを指定してください。

```yaml:docker-compose.yml
version: '3'
services:
  elasticsearch:
    build: ./docker/elasticsearch
    container_name: elasticsearch
    environment:
      - discovery.type=single-node
      - cluster.name=es-docker-cluster
      - bootstrap.memory_lock=true
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - ./docker/elasticsearch:/usr/share/elasticsearch/data
    ports:
      - 9200:9200
    networks:
      - esnet
  python3:
    build: ./docker/python3
    container_name: python3
    working_dir: '/app'
    tty: true
    environment:
      GOOGLE_APPLICATION_CREDENTIALS: /tmp/keys/credential.json
    volumes:
      - ./src:/app
      - ${GOOGLE_APPLICATION_CREDENTIALS}:/tmp/keys/credential.json:ro
    networks:
      - esnet
networks:
  esnet:
```

各ファイルを配置後に下記のコマンドを実行すると、今回使用する環境作成が完了します。

```
# 起動
> docker-comopse up -d

# 確認
> docker-compose ps   
    Name                   Command               State                Ports              
-----------------------------------------------------------------------------------------
elasticsearch   /bin/tini -- /usr/local/bi ...   Up      0.0.0.0:9200->9200/tcp, 9300/tcp
python3         python3                          Up 
```


## Cloud Vision APIの使い方

実行するコードは[こちら](https://cloud.google.com/vision/docs/handwriting)のドキュメントを参考にしております。
テスト用に、下記のコードを実行します。

```python:src/main.py
import io
import os
from google.cloud import vision


def detect_document(file_path):
    client = vision.ImageAnnotatorClient()

    file_name = os.path.abspath(file_path)

    with io.open(file_name, 'rb') as image_file:
        content = image_file.read()

    image = vision.Image(content=content)

    response = client.document_text_detection(
        image=image,
        image_context={'language_hints': ['ja']}
    )

    text = ''
    for page in response.full_text_annotation.pages:
        for block in page.blocks:
            for paragraph in block.paragraphs:
                for word in paragraph.words:
                    text += ''.join([
                        symbol.text for symbol in word.symbols
                    ])
                text += '\n'
    print(text)


if __name__ == "__main__":
    file_path = 'images/input/wiki-ocr-short.png'
    detect_document(file_path)
```

文字認識をする画像ファイルを用意しましょう。
webページのスクリーンショット、書類をスキャンしたもの、手書き文字の紙を撮影したものなど文字が入っている画像でしたらなんでも大丈夫です。

以下のような画像(wiki-ocr-short.png)で試してみます。`src/images/input`に配置してください。
![wiki_ocr_short.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488269/ba9778c6-e777-2ef8-3e82-f9f898ffa74a.png)


コマンドを実行すると文字認識された結果が返ってくると思います。

```
> docker-compose exec python3 python main.py

光学文字認識出典:フリー百科事典『ウィキペディア(Wikipedia)』
光学文字認識(こうがくもじにんしき、英:Opticalcharacterrecognition)は、活字、手書きテキストの画像を文字コードの列に変換するソフトウェアである。画像はイメージスキャナーや写真で取り込まれた文書、風景写真(風景内の看板の文字など)、画像内の字幕(テレビ放送画像内など)が使われる[1]。一般にOCRと略記される。
パスポート、請求書、銀行取引明細書、レシート、名刺、メール、データや文書の印刷物など、紙に記載されたデータをデータ入力する手法として広く使われ、紙に印刷された文書をデジタイズし、よりコンパクトな形で記録するのに必要とされる。さらに、文字コードに変換することでコグニティブコンピューティング、機械翻訳や音声合成の入力にも使えるようになり、テキストマイニングも可能となる。研究分野としては、パターン認識、人工知能、コンピュータビジョンが対応する。
初期のシステムは特定の書体を読むための「トレーニング」が必要であった(事前にその書体のサンプルを読ませることを意味する)。現在では、ほとんどの書体を高い識字率で変換することが可能である。いくつかのシステムでは読み込まれた画像からそれとほぼ同じになるようフォーマットされた出力(例えばワードプロセッサのファイルのようなもの)を生成することが可能であり、中には画像などの文書以外の部分が含まれていても正しく認識するものもある。
```

このように、スクショ画像など綺麗な文字の場合はほぼ完璧に読み取ることができます！

手書きの文字でも。。
![gcp.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/488269/20bdc6c6-f353-888c-8348-5e74e2698acf.png)

```
> docker-compose exec python3 python main.py

GoogleCloudPlatform
GCPとは、Googleが提供しているクラウドコンピューティングサービスである。
```
読み取ることができました！

今回の2枚はうまく認識できましたが、以下のような文字や画像の場合精度が落ちてしまうこともあります。

- スキャンした際に多くのノイズが入ってしまっている画像
- 極端に明るかったり、暗かったりする写真などの画像
- 文字の背景に柄が入っている場合
- etc

また、手書き文字はある程度丁寧に書かないと、完璧に読み取るのは厳しいと思います。
うまく文字が認識できない際には、ノイズを削除したり、輝度を調整したりという画像の前処理を行うことで、精度を高めることができます。


## Elasticsearchへの登録

ESにデータを登録するためのインデックスを作成します。RDBでいうとデータベースのようなものです。

```shell
# インデックス登録
> curl -XPUT 'http://localhost:9200/document?pretty'

# インデックス一覧表示
> curl 'http://localhost:9200/_cat/indices?v'     
health status index            uuid                   pri rep docs.count docs.deleted store.size pri.store.size
green  open   .geoip_databases FR2MULeESTOjuvrjO56-0g   1   0         42            0     41.2mb         41.2mb
yellow open   document         xApyPIshRMe9kNk4tvnu0g   1   1          0            0       208b           208b
```

Pythonを使用してESへデータを登録するコードです。
`elasticsearch:9200`にはコンテナ名とポート番号を指定します。

```
es = Elasticsearch('elasticsearch:9200')
es.index(index='document', document=doc)
```

以下、`src/images/input/`配下にある画像ファイルを全て取得し、順番にOCRをかけた後に、ESに登録するという処理になります。

```python:main.py
import io
import os
import glob
import shutil

from elasticsearch import Elasticsearch
from google.cloud import vision


def detect_document(file_path):
    # 省略


def es_create(doc):
    es = Elasticsearch('elasticsearch:9200')
    return es.index(index='document', document=doc)


def handler():
    images = glob.glob("./images/input/*")
    for file_path in images:
        text = detect_document(file_path)

        doc = {
            'file_name': os.path.basename(file_path),
            'text': text
        }

        result = es_create(doc)
        print(result)
        shutil.move(file_path, "images/archive/")


if __name__ == "__main__":
    handler()

```

登録処理が書けたら、ESに登録したい画像ファイルを`src/images/input`に配置し、以下のコマンドを実行します。
今回は、wikipediaのページのキャプチャを10枚分登録しました。
登録したページは、[Docker](https://ja.wikipedia.org/wiki/Docker)、[Python](https://ja.wikipedia.org/wiki/Python)、[光学文字認識](https://ja.wikipedia.org/wiki/%E5%85%89%E5%AD%A6%E6%96%87%E5%AD%97%E8%AA%8D%E8%AD%98)、[アドベントカレンダー](https://ja.wikipedia.org/wiki/%E3%82%A2%E3%83%89%E3%83%99%E3%83%B3%E3%83%88%E3%82%AB%E3%83%AC%E3%83%B3%E3%83%80%E3%83%BC)、[人工知能](https://ja.wikipedia.org/wiki/%E4%BA%BA%E5%B7%A5%E7%9F%A5%E8%83%BD)、[ケーキ](https://ja.wikipedia.org/wiki/%E3%82%B1%E3%83%BC%E3%82%AD)、[Elasticsearch](https://ja.wikipedia.org/wiki/Elasticsearch)、[クリスマス](https://ja.wikipedia.org/wiki/%E3%82%AF%E3%83%AA%E3%82%B9%E3%83%9E%E3%82%B9)、[GCP](https://ja.wikipedia.org/wiki/Google_Cloud_Platform)、[サンタクロース](https://ja.wikipedia.org/wiki/%E3%82%B5%E3%83%B3%E3%82%BF%E3%82%AF%E3%83%AD%E3%83%BC%E3%82%B9)です！

ファイルの配置が終わったら、再度実行してみましょう。

```shell
docker-compose exec python3 python main.py
# このように出力されていたら成功です
{'_index': 'document', '_type': '_doc', '_id': 'NaHhyX0BxArSaCGZYWO_', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 0, '_primary_term': 1}
{'_index': 'document', '_type': '_doc', '_id': 'NqHhyX0BxArSaCGZi2Nv', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 1, '_primary_term': 1}
{'_index': 'document', '_type': '_doc', '_id': 'N6HhyX0BxArSaCGZvWN5', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 2, '_primary_term': 1}
{'_index': 'document', '_type': '_doc', '_id': 'OKHhyX0BxArSaCGZ6mM7', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 3, '_primary_term': 1}
{'_index': 'document', '_type': '_doc', '_id': 'OaHiyX0BxArSaCGZHmMH', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 4, '_primary_term': 1}
{'_index': 'document', '_type': '_doc', '_id': 'OqHiyX0BxArSaCGZK2Mm', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 5, '_primary_term': 1}
{'_index': 'document', '_type': '_doc', '_id': 'O6HiyX0BxArSaCGZWGNL', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 6, '_primary_term': 1}
{'_index': 'document', '_type': '_doc', '_id': 'PKHiyX0BxArSaCGZhmOv', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 7, '_primary_term': 1}
{'_index': 'document', '_type': '_doc', '_id': 'PaHiyX0BxArSaCGZnmMi', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 8, '_primary_term': 1}
{'_index': 'document', '_type': '_doc', '_id': 'PqHiyX0BxArSaCGZrGN4', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 9, '_primary_term': 1}
{'_index': 'document', '_type': '_doc', '_id': 'P6HiyX0BxArSaCGZ0GPh', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, '_seq_no': 10, '_primary_term': 1}
```

## Elasticsearchから検索

最後に、ESに登録した内容からワード検索をしてみましょう。まずは「プログラミング」というワードで検索してみます。

```shell
 > curl -XPOST http://localhost:9200/document/_search -H "Content-type: application/json" -d '{
    "_source": "file_name",
    "query": {
    "term": {
      "text": "プログラミング"
    }
  }
}'     

# 出力
{
  "took": 5,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 6,
      "relation": "eq"
    },
    "max_score": 1.0072792,
    "hits": [
      {
        "_index": "document",
        "_type": "_doc",
        "_id": "QaHyyX0BxArSaCGZB2Pl",
        "_score": 1.0072792,
        "_ignored": [
          "text.keyword"
        ],
        "_source": {
          "file_name": "wiki-python.png"
        }
        ...
        "_source": {
          "file_name": "wiki-advent-calendar.png"
        }
        ...
        "_source": {
          "file_name": "wiki-ai.png"
        }
        ...
        "_source": {
          "file_name": "wiki-gcp.png"
        }
        ...
        "_source": {
          "file_name": "wiki-elasticsearch.png"
        }
        ...
        "_source": {
          "file_name": "wiki-docker.png"
        }
      }
    ]
  }
}

```

Python、アドベントカレンダー、人工知能、GCP、Elasticsearch、Dockerの画像が出力結果に出てきました。
次に、「クリスマス」というワードで検索してみます。

```shell
> curl -XPOST http://localhost:9200/document/_search -H "Content-type: application/json" -d '{
    "_source": "file_name",
    "query": {
    "term": {
      "text": "クリスマス"    
    }
  }
}'

# 出力
{
  "took": 4,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 2,
      "relation": "eq"
    },
    "max_score": 2.8236375,
    "hits": [
      {
        "_index": "document",
        "_type": "_doc",
        "_id": "P6HiyX0BxArSaCGZ0GPh",
        "_score": 2.8236375,
        "_ignored": [
          "text.keyword"
        ],
        "_source": {
          "file_path": "wiki-santa.png"
        }
        ...
        "_source": {
          "file_path": "wiki-advent-calendar.png"
        }
        ...
        "_source": {
          "file_path": "wiki-cake.png"
        }
      }
    ]
  }
}

```

サンタクロース、アドベントカレンダー、ケーキという出力結果になりました。
正しく取得できてそうです！

今回はシンプルなワード検索のみ実行しましたが、ES他にも複数ワードからの検索や様々な条件での検索が可能です。
以上で、OCRを使用して画像から文字を抽出、抽出した文字をElasticsearchへ登録し、Elasticsearchからワード検索でファイルを取得するという処理が完了になります！

この記事では、文字認識しやすい画像ファイルから簡単な検索のみを行いましたが、実際に数千、数万ファイルで行おうとするとうまく認識できなかったり、欲しい情報が取ってこれないということは多いと思います。
その場合は、画像の前処理や、文字を読み取った後の処理や、検索方法をいろいろと試すことで用途に特化した検索エンジンを作ってみてください。
ABEJAでは、資料の中に含まれる画像の認識や、図面から各パーツの検出、OCRで抽出した文字に自然言語処理を加えることで、さらに高性能な検索エンジンを構築しています！

## 終わりに
現在ABEJAでは一緒にAIの社会実装を進める仲間を募集しています。
ABEJA Advent Calendar 2021を読んで少しでも興味を持っていただければ、まずはカジュアルにお話しさせていただくこともできます。
[【現在募集中の職種】](https://hrmos.co/pages/abeja/jobs)はこちらから確認できます。ご応募をお待ちしております！！