ちょっと話題の記事

[ Computer Vision (Read API) ] AI-OCRでFAX送信された帳票をCSV化してみました

2023.07.03

1 はじめに

CX 事業本部 delivery部の平内(SIN)です。

一昔前まで、OCRによるテキスト化は、誤変換が多くて、なかなか実用が難しいというイメージがあったのですが、最近のAI-OCRは、日本語や手書きのものも結構な精度で読み取れるようになっています。 そして、モデルは、どんどん更新されているので、今後、ますます、精度は上がっていくでしょう。

今回は、AI-OCRを利用して、帳票をCSV化する作業を試してみました。

2 歪みの修正

FAXで受信した帳票は、やや斜めになったり、歪んでしまうことがあります。この状態では、帳票の枠組みを検出するのが難しいので、長方形になるように補正します。

修正の手順は、以下の通りです。

  • グレースケール変換
  • エッジ抽出
  • 膨張処理
  • 最大矩形検出
  • 射影変換

最初にサンプルとなったFAXの画像です。

fax.png

罫線の検出を簡単しやすくするために、グレースケールに変換します。

gray_image = cv2.cvtColor(org_img, cv2.COLOR_BGR2GRAY)

fax_glay.png

続いて、エッジの検出です。

edges_image = cv2.Canny(gray_image, 1, 100, apertureSize=3)

fax_edges.png

エッジ検出すると、やや線が細くなってしまって、取りこぼしが発生する可能性があるので、膨張処理を施します。

kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2))
dilate_image = cv2.dilate(edges_image, kernel)

fax_dilate.png

膨張処理された画像から、cv2.RETR_EXTERNALで、一番外側の輪郭のみ抽出しています。

contours, hierarchy = cv2.findContours(
    dilate_image, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)

複数検出された場合は、すべての中で、最も大きいものを抽出し、帳票の外枠であると仮定しています。

fax_rect.png

外枠を、長方形となるように、射影で変換します。

M = cv2.getPerspectiveTransform(pts2, pts1)
img = cv2.warpPerspective(
    org_img, M, (w2 + 100, h2 + 100), borderValue=(255, 255, 255)
)

こちらが、補正の終わった画像です。完全とは行きませんが、最初のものから比べると、概ね長方形に整形されていると思います。

fax_output.png

コードは、次のとおりです。

sample001.py
import cv2
import os
import numpy as np
import cv2


class ImageTool:
    def __init__(self, base_name):
        self.dir = os.path.dirname(os.path.abspath(__file__))
        self.base_name = base_name

    def read(self):
        return cv2.imread("{}/{}.png".format(self.dir, self.base_name))

    def write(self, prefix, img):
        cv2.imwrite("{}/{}_{}.png".format(self.dir, self.base_name, prefix), img)


def main():
    base_name = "fax"
    image_tool = ImageTool(base_name)
    org_img = image_tool.read()

    # グレースケール変換
    gray_image = cv2.cvtColor(org_img, cv2.COLOR_BGR2GRAY)
    image_tool.write("glay", gray_image)

    # エッジ抽出
    edges_image = cv2.Canny(gray_image, 1, 100, apertureSize=3)
    image_tool.write("edges", edges_image)

    # 膨張処理
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2))
    dilate_image = cv2.dilate(edges_image, kernel)
    image_tool.write("dilate", dilate_image)

    # 矩形検出
    contours, hierarchy = cv2.findContours(
        dilate_image, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
    )
    # 最大矩形を取得
    max_rect = 0
    max_area = 0
    for cnt, _ in zip(contours, hierarchy[0]):
        area = cv2.contourArea(cnt)
        if max_area < area:
            max_area = area
            max_rect = cv2.minAreaRect(cnt)
    rect_point = cv2.boxPoints(max_rect).astype(int)

    rect_image = org_img.copy()
    cv2.drawContours(rect_image, [rect_point], 0, (0, 0, 255), 5)
    image_tool.write("rect", rect_image)

    # 射影変換
    ((x1, y1), (x2, y2), (x3, y3), (x4, y4)) = rect_point
    margin = 100
    x1 -= margin
    x2 -= margin
    x3 += margin
    x4 += margin
    y1 += margin * 2
    y2 -= margin
    y3 -= margin
    y4 += margin * 2
    pts2 = [(x2, y2), (x1, y1), (x4, y4), (x3, y3)]

    w2 = max(pts2, key=lambda x: x[0])[0]
    h2 = max(pts2, key=lambda x: x[1])[1]
    h, w, _ = org_img.shape
    pts1 = np.float32([(0, 0), (0, h), (w, h), (w, 0)])
    pts2 = np.float32(pts2)

    M = cv2.getPerspectiveTransform(pts2, pts1)
    img = cv2.warpPerspective(
        org_img, M, (w2 + 100, h2 + 100), borderValue=(255, 255, 255)
    )
    image_tool.write("output", img)


if __name__ == "__main__":
    main()

3 帳票の検出

帳票の枠組みを検出する手順は、以下の通りです。グレースケール変換、エッジ抽出、膨張処理については、先の手順と同じです。

  • グレースケール変換
  • エッジ抽出
  • 膨張処理
  • 最大矩形検出
  • 射影変換

グレースケール変換、エッジ抽出、膨張処理で得られた画像から、cv2.RETR_TREE で、すべての輪郭を抽出します。

contours, hierarchy = cv2.findContours(
        dilate_image, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE
)

取得したすべての輪郭から、外接する矩形を計算しますが、この時、一定の大きさでフィルタしています。このフィルタの敷居値はFAX画像の解像度や、帳票の枠組みに依存していますので、それぞれで調整が必要です。

# 面積でフィルタリング
rects = []
for cnt, hrchy in zip(contours, hierarchy[0]):
    if cv2.contourArea(cnt) < 3000:
        continue  # 面積が一定の大きさを満たさないものを除く
    if cv2.contourArea(cnt) > 20000:
        continue  # 面積が一定の大きさを超えるものを除く
    if hrchy[3] == -1:
        continue  # ルートノードは除く
    # 輪郭を囲む長方形を計算する。
    rect = cv2.minAreaRect(cnt)
    rect_points = cv2.boxPoints(rect).astype(int)
    rects.append(rect_points)

検出した矩形を表示すると次のようになります。

fax_output1.png

fax_output2.png

コードは、次のとおりです。

sample001.py
import cv2
import os
import numpy as np
import cv2


class ImageTool:
    def __init__(self, base_name):
        self.dir = os.path.dirname(os.path.abspath(__file__))
        self.base_name = base_name

    def read(self):
        return cv2.imread("{}/{}.png".format(self.dir, self.base_name))

    def write(self, prefix, img):
        cv2.imwrite("{}/{}_{}.png".format(self.dir, self.base_name, prefix), img)


# 矩形描画
def disp_rects(rects, img, thickness):
    image = img.copy()
    for i, rect in enumerate(rects):
        color = np.random.randint(0, 255, 3).tolist()
        cv2.drawContours(image, rects, i, color, thickness)
    return image


def create_white_image(org_img):
    h, w, c = org_img.shape
    black_img = np.zeros((h, w, c), np.uint8)
    return black_img + 255


def main():
    base_name = "fax"
    image_tool = ImageTool(base_name)
    org_img = image_tool.read()
    white_img = create_white_image(org_img)

    # グレースケール変換
    gray_image = cv2.cvtColor(org_img, cv2.COLOR_BGR2GRAY)

    # エッジ抽出
    edges_image = cv2.Canny(gray_image, 1, 100, apertureSize=3)

    # 膨張処理
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
    dilate_image = cv2.dilate(edges_image, kernel)

    # 輪郭抽出
    contours, hierarchy = cv2.findContours(
        dilate_image, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE
    )
    # 面積でフィルタリング
    rects = []
    for cnt, hrchy in zip(contours, hierarchy[0]):
        if cv2.contourArea(cnt) < 3000:
            continue  # 面積が一定の大きさを満たさないものを除く
        if cv2.contourArea(cnt) > 20000:
            continue  # 面積が一定の大きさを超えるものを除く
        if hrchy[3] == -1:
            continue  # ルートノードは除く
        # 輪郭を囲む長方形を計算する。
        rect = cv2.minAreaRect(cnt)
        rect_points = cv2.boxPoints(rect).astype(int)
        rects.append(rect_points)

    thickness = 3
    image_tool.write("output1", disp_rects(rects, org_img, thickness))
    image_tool.write("output2", disp_rects(rects, white_img, thickness))


if __name__ == "__main__":
    main()

4 帳票の座標検出

矩形のデータは、線自体の幅や、検出の誤差により、そのまま座標として利用するのは難しいので、近似値を集約することで帳票の座標としています。

# 近似座標の集約
def consolidation(list):
    result = []
    min = 0
    counter = 0
    for val in list:
        if min == 0:
            min = val
            keep = val
        else:
            if keep + 3 < val:  # 3ドット以内をまとめる
                if counter > 2:  # 得意な検出は排除する
                    result.append(int(min + (keep - min) / 2))
                min = val
                counter = 0
            counter += 1
            keep = val

    if counter > 2:  # 特異な検出は排除する
        result.append(int(min + (keep - min) / 2))

    return result


# 座標検出
def detect_point(rects):
    # 全X,Y検出
    x_list = []
    y_list = []
    for i, rect in enumerate(rects):
        for i in range(4):
            x, y = rect[i]
            if not x in x_list:
                x_list.append(x)
            if not y in y_list:
                y_list.append(y)

    x_list.sort()
    y_list.sort()

    # 近似値の集約
    x_list = consolidation(x_list)
    y_list = consolidation(y_list)

    return x_list, y_list

次の図は、検出した座標で罫線を引いたものです。

fax_output1.png

fax_output2.png

コードです。

sample001.py
import cv2
import os
import numpy as np
import cv2


class ImageTool:
    def __init__(self, base_name):
        self.dir = os.path.dirname(os.path.abspath(__file__))
        self.base_name = base_name

    def read(self):
        return cv2.imread("{}/{}.png".format(self.dir, self.base_name))

    def write(self, prefix, img):
        cv2.imwrite("{}/{}_{}.png".format(self.dir, self.base_name, prefix), img)


# 矩形描画
def disp_rects(rects, img, thickness):
    image = img.copy()
    for i, rect in enumerate(rects):
        color = np.random.randint(0, 255, 3).tolist()
        cv2.drawContours(image, rects, i, color, thickness)
    return image


def create_white_image(org_img):
    h, w, c = org_img.shape
    black_img = np.zeros((h, w, c), np.uint8)
    return black_img + 255


# 近似座標の集約
def consolidation(list):
    result = []
    min = 0
    counter = 0
    for val in list:
        if min == 0:
            min = val
            keep = val
        else:
            if keep + 3 < val:  # 3ドット以内をまとめる
                if counter > 2:  # 得意な検出は排除する
                    result.append(int(min + (keep - min) / 2))
                min = val
                counter = 0
            counter += 1
            keep = val

    if counter > 2:  # 特異な検出は排除する
        result.append(int(min + (keep - min) / 2))

    return result


# 座標検出
def detect_point(rects):
    # 全X,Y検出
    x_list = []
    y_list = []
    for i, rect in enumerate(rects):
        for i in range(4):
            x, y = rect[i]
            if not x in x_list:
                x_list.append(x)
            if not y in y_list:
                y_list.append(y)

    x_list.sort()
    y_list.sort()

    # 近似値の集約
    x_list = consolidation(x_list)
    y_list = consolidation(y_list)

    return x_list, y_list


# LINE描画
def disp_line(x_list, y_list, img):
    image = img.copy()

    x_min = min(x_list)
    x_max = max(x_list)
    y_min = min(y_list)
    y_max = max(y_list)

    for x in x_list:
        cv2.line(
            image,
            pt1=(x, y_min),
            pt2=(x, y_max),
            color=(0, 0, 255),
            thickness=1,
            lineType=cv2.LINE_4,
            shift=0,
        )

    for y in y_list:
        cv2.line(
            image,
            pt1=(x_min, y),
            pt2=(x_max, y),
            color=(0, 0, 255),
            thickness=1,
            lineType=cv2.LINE_4,
            shift=0,
        )

    return image


def main():
    base_name = "fax"
    image_tool = ImageTool(base_name)
    org_img = image_tool.read()
    white_img = create_white_image(org_img)

    # グレースケール変換
    gray_image = cv2.cvtColor(org_img, cv2.COLOR_BGR2GRAY)
    # エッジ抽出
    edges_image = cv2.Canny(gray_image, 1, 100, apertureSize=3)
    # 膨張処理
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
    dilate_image = cv2.dilate(edges_image, kernel)

    # 輪郭抽出
    contours, hierarchy = cv2.findContours(
        dilate_image, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE
    )
    # 面積でフィルタリング
    rects = []
    for cnt, hrchy in zip(contours, hierarchy[0]):
        if cv2.contourArea(cnt) < 3000:
            continue  # 面積が一定の大きさを満たさないものを除く
        if cv2.contourArea(cnt) > 20000:
            continue  # 面積が一定の大きさを超えるものを除く
        if hrchy[3] == -1:
            continue  # ルートノードは除く
        # 輪郭を囲む長方形を計算する。
        rect = cv2.minAreaRect(cnt)
        rect_points = cv2.boxPoints(rect).astype(int)
        rects.append(rect_points)

    # 座標検出
    x_list, y_list = detect_point(rects)
    image_tool.write("output1", disp_line(x_list, y_list, org_img))
    image_tool.write("output2", disp_line(x_list, y_list, white_img))


if __name__ == "__main__":
    main()

5 Computer Vision 3.2 Read API

AI-OCRとしては、MixrosoftのComputer Visionで提供されている、Read APIを使用させて頂きました。

最新のモデルは、2022-04-30となっており、日本語利用では、現時点で、このRead APIが、最も精度が高いかも知れません。


参考: Computer Vision 3.2 GA Read API を呼び出す

ReadAPIのレスポンスは、下記のようになっています。

{
  "status": "succeeded",
  "createdDateTime": "2023-07-02T06:47:56Z",
  "lastUpdatedDateTime": "2023-07-02T06:47:57Z",
  "analyzeResult": {
    "version": "3.2.0",
    "modelVersion": "2022-04-30",
    "readResults": [
      {
        "page": 1,
        "angle": 0,
        "width": 2009,
        "height": 1218,
        "unit": "pixel",
        "lines": [
          {
            "boundingBox": [
              113,
              96,
              246,
              97,
              246,
              122,
              113,
              121
            ],
            "text": "資材注文情報",
            "appearance": {
              "style": {
                "name": "other",
                "confidence": 0.972
              }
            },
            "words": [
              {
                "boundingBox": [
                  119,
                  96,
                  134,
                  97,
                  133,
                  122,
                  118,
                  122
                ],
                "text": "資",
                "confidence": 0.989
              },
              {
                "boundingBox": [
                  142,
                  97,
                  157,
                  97,
                  157,
                  122,
                  141,
                  122
                ],
                "text": "材",
                "confidence": 0.965
              },
・・・略・・・

上記のレスポンスから、行(lines)としての検出された位置(boundingBox)に、赤枠を引き、その連番を記した画像と、そのテキスト出力です。

fax_output1.png

0: 資材注文情報
1: 注文ID
2: 建設会社コード
3: 建設会社名
4: 注文日時
5: 資材コード
6: 資材名
7: 数量
8: 単価
9: 合計金額
10: 注文ステータス
11: 1
12: CMP001
13: 建設A株式会社
14: 2023-06-30 12:30
15: MTL001
16: セメント
17: 100
18: 500
19: 50000
20: 処理中
・・・略・・・

コードの全体は以下の通りです。

sample001.py
import cv2
import os
import cv2
import json
import requests
import time


class ImageTool:
    def __init__(self, base_name):
        self.dir = os.path.dirname(os.path.abspath(__file__))
        self.base_name = base_name

    def file_name(self):
        return "{}/{}.png".format(self.dir, self.base_name)

    def read(self):
        return cv2.imread("{}/{}.png".format(self.dir, self.base_name))

    def write(self, prefix, img):
        cv2.imwrite("{}/{}_{}.png".format(self.dir, self.base_name, prefix), img)


def readApi(imFilePath):
    with open(imFilePath, "rb") as f:
        data = f.read()

    subscription_key = "xxxxxxxxxxxxxxxxxxxxxxxxx"
    endpoint = "https://japaneast.api.cognitive.microsoft.com/"
    model_version = "2022-04-30"
    language = "ja"

    text_recognition_url = endpoint + "vision/v3.2/read/analyze"
    headers = {
        "Ocp-Apim-Subscription-Key": subscription_key,
        "Content-Type": "application/octet-stream",
    }
    params = {"language ": language, "model-version": model_version}

    response = requests.post(
        text_recognition_url, headers=headers, params=params, json=None, data=data
    )
    response.raise_for_status()

    analysis = {}
    poll = True

    while poll:
        response_final = requests.get(
            response.headers["Operation-Location"], headers=headers
        )
        analysis = response_final.json()

        print(json.dumps(analysis, indent=4, ensure_ascii=False))

        time.sleep(1)
        if "analyzeResult" in analysis:
            poll = False
        if "status" in analysis and analysis["status"] == "failed":
            poll = False
    return analysis


def getXY(x_list, y_list, boundingBox):
    x1 = boundingBox[0]
    x2 = boundingBox[4]
    y1 = boundingBox[1]
    y2 = boundingBox[5]
    for y in range(len(y_list) - 1):
        top = y_list[y]
        bottom = y_list[y + 1] + 1
        for x in range(len(x_list) - 1):
            left = x_list[x]
            right = x_list[x + 1] + 1
            if top <= y1 and y2 <= bottom and left <= x1 and x2 <= right:
                return x, y
    return -1, -1


def main():
    base_name = "fax"
    image_tool = ImageTool(base_name)
    org_img = image_tool.read()

    # Computer Vision 3.2 Read API によるOCR読み取り
    response = readApi(image_tool.file_name())
    # JSON ファイルを出力
    with codecs.open("output_read3.2.json", "w+", "utf-8") as fp:
        json.dump(response, fp, ensure_ascii=False, indent=2)

    output_img = org_img.copy()
    readResult = response["analyzeResult"]["readResults"][0]
    lines = readResult["lines"]

    output = ""
    for i, line in enumerate(lines):
        text = line["text"]
        p = line["boundingBox"]

        cv2.rectangle(
            output_img, [p[0], p[1], (p[4] - p[0]), (p[5] - p[1])], (0, 0, 255), 1
        )
        cv2.putText(
            output_img,
            str(i),
            (p[0] - 10, p[1]),
            cv2.FONT_HERSHEY_PLAIN,
            1,
            (0, 0, 255),
            1,
            cv2.LINE_AA,
        )

        output += "{}: {}\n".format(i, text)

    print(output)
    image_tool.write("output1", output_img)


if __name__ == "__main__":
    main()

6 CSV出力

ReadAPIのレスポンスには、検出位置(boundingBox)が含まれるので、既に検出できている帳票の座標と付き合わせることで、検出された文字列が、どのセルに該当するかが分かります。

def getXY(x_list, y_list, boundingBox):
    x1 = boundingBox[0]
    x2 = boundingBox[4]
    y1 = boundingBox[1]
    y2 = boundingBox[5]
    for y in range(len(y_list) - 1):
        top = y_list[y]
        bottom = y_list[y + 1] + 1
        for x in range(len(x_list) - 1):
            left = x_list[x]
            right = x_list[x + 1] + 1
            if top <= y1 and y2 <= bottom and left <= x1 and x2 <= right:
                return x, y
    return -1, -1

各セルへの挿入を行い、列ごとに、CSVとして出力したものです。

注文ID,建設会社コード,建設会社名,注文日時,資材コード,資材名,数量,単価,合計金額,注文ステータス,,
1,CMP001,建設A株式会社,2023-06-30 12:30,MTL001,セメント,100,500,50000,処理中,,
,,建設B株式会社,2023-06-30 13:00,MTL002,鉄筋,50,20,10000,出荷済み,,
3,CMP003,建設C株式会社,2023-06-30 13:30,MTL003,砂利,200,300,60000,処理中,,
4,CMP004,建設D株式会社,2023-06-30 14:00,MTL004,コンクリートブロッ,25,800,20000,キャンセル,,
,,建設E株式会社,2023-06-30 15:00-,MTL005,木材,100,1000,100000,出荷済み,,
6,CMP001,建設A株式会社,2023-06-30 15:30,MTL006,塗料,30,2000,60000,処理中,,
,,建設B株式会社,2023-06-30 16:00,MTL007,ネジ,500,10,5000,出荷済み,,
8,CMP003,建設C株式会社,2023-06-30 16:30,MTL008,釘,1000,5,5000,処理中,,
・・・略・・・

見やすいようにExcelで読み込んでみました。完璧ではないですが、結構正確に帳票が再現できているように思います。

コードです。

sample001.py
import cv2
import os
import numpy as np
import cv2
import json
import requests
import time
import codecs


class ImageTool:
    def __init__(self, base_name):
        self.dir = os.path.dirname(os.path.abspath(__file__))
        self.base_name = base_name

    def read(self):
        return cv2.imread("{}/{}.png".format(self.dir, self.base_name))

    def write(self, prefix, img):
        cv2.imwrite("{}/{}_{}.png".format(self.dir, self.base_name, prefix), img)

    def file_name(self):
        return "{}/{}.png".format(self.dir, self.base_name)


# 矩形描画
def disp_rects(rects, img, thickness):
    image = img.copy()
    for i, rect in enumerate(rects):
        color = np.random.randint(0, 255, 3).tolist()
        cv2.drawContours(image, rects, i, color, thickness)
    return image


def create_white_image(org_img):
    h, w, c = org_img.shape
    black_img = np.zeros((h, w, c), np.uint8)
    return black_img + 255


# 近似座標の集約
def consolidation(list):
    result = []
    min = 0
    counter = 0
    for val in list:
        if min == 0:
            min = val
            keep = val
        else:
            if keep + 3 < val:  # 3ドット以内をまとめる
                if counter > 2:  # 得意な検出は排除する
                    result.append(int(min + (keep - min) / 2))
                min = val
                counter = 0
            counter += 1
            keep = val

    if counter > 2:  # 特異な検出は排除する
        result.append(int(min + (keep - min) / 2))

    return result


# 座標検出
def detect_point(rects):
    # 全X,Y検出
    x_list = []
    y_list = []
    for i, rect in enumerate(rects):
        for i in range(4):
            x, y = rect[i]
            if not x in x_list:
                x_list.append(x)
            if not y in y_list:
                y_list.append(y)

    x_list.sort()
    y_list.sort()

    # 近似値の集約
    x_list = consolidation(x_list)
    y_list = consolidation(y_list)

    return x_list, y_list


# LINE描画
def disp_line(x_list, y_list, img):
    image = img.copy()

    x_min = min(x_list)
    x_max = max(x_list)
    y_min = min(y_list)
    y_max = max(y_list)

    for x in x_list:
        cv2.line(
            image,
            pt1=(x, y_min),
            pt2=(x, y_max),
            color=(0, 0, 255),
            thickness=1,
            lineType=cv2.LINE_4,
            shift=0,
        )

    for y in y_list:
        cv2.line(
            image,
            pt1=(x_min, y),
            pt2=(x_max, y),
            color=(0, 0, 255),
            thickness=1,
            lineType=cv2.LINE_4,
            shift=0,
        )

    return image


def readApi(imFilePath):
    with open(imFilePath, "rb") as f:
        data = f.read()

    subscription_key = "xxxxxxxxxxxxxxxxxxxxxxxxx"
    endpoint = "https://japaneast.api.cognitive.microsoft.com/"
    model_version = "2022-04-30"
    language = "ja"

    text_recognition_url = endpoint + "vision/v3.2/read/analyze"
    headers = {
        "Ocp-Apim-Subscription-Key": subscription_key,
        "Content-Type": "application/octet-stream",
    }
    params = {"language ": language, "model-version": model_version}

    response = requests.post(
        text_recognition_url, headers=headers, params=params, json=None, data=data
    )
    response.raise_for_status()

    analysis = {}
    poll = True

    while poll:
        response_final = requests.get(
            response.headers["Operation-Location"], headers=headers
        )
        analysis = response_final.json()

        print(json.dumps(analysis, indent=4, ensure_ascii=False))

        time.sleep(1)
        if "analyzeResult" in analysis:
            poll = False
        if "status" in analysis and analysis["status"] == "failed":
            poll = False
    return analysis


def getXY(x_list, y_list, boundingBox):
    x1 = boundingBox[0]
    x2 = boundingBox[4]
    y1 = boundingBox[1]
    y2 = boundingBox[5]
    for y in range(len(y_list) - 1):
        top = y_list[y]
        bottom = y_list[y + 1] + 1
        for x in range(len(x_list) - 1):
            left = x_list[x]
            right = x_list[x + 1] + 1
            if top <= y1 and y2 <= bottom and left <= x1 and x2 <= right:
                return x, y
    return -1, -1


def main():
    base_name = "fax"
    image_tool = ImageTool(base_name)
    org_img = image_tool.read()
    white_img = create_white_image(org_img)

    # グレースケール変換
    gray_image = cv2.cvtColor(org_img, cv2.COLOR_BGR2GRAY)
    # エッジ抽出
    edges_image = cv2.Canny(gray_image, 1, 100, apertureSize=3)
    # 膨張処理
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
    dilate_image = cv2.dilate(edges_image, kernel)

    # 輪郭抽出
    contours, hierarchy = cv2.findContours(
        dilate_image, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE
    )
    # 面積でフィルタリング
    rects = []
    for cnt, hrchy in zip(contours, hierarchy[0]):
        if cv2.contourArea(cnt) < 3000:
            continue  # 面積が一定の大きさを満たさないものを除く
        if cv2.contourArea(cnt) > 20000:
            continue  # 面積が一定の大きさを超えるものを除く
        if hrchy[3] == -1:
            continue  # ルートノードは除く
        # 輪郭を囲む長方形を計算する。
        rect = cv2.minAreaRect(cnt)
        rect_points = cv2.boxPoints(rect).astype(int)
        rects.append(rect_points)

    # 座標検出
    x_list, y_list = detect_point(rects)

    Computer Vision 3.2 Read API によるOCR読み取り
    response = readApi(image_tool.file_name())
    # JSON ファイルを出力
    with codecs.open("output_read3.2.json", "w+", "utf-8") as fp:
        json.dump(response, fp, ensure_ascii=False, indent=2)

    # 出力用バッファ
    csv = []
    for _ in range(len(y_list)):
        row = []
        for _ in range(len(x_list)):
            row.append("")
        csv.append(row)

    # BoundingBoxを罫線位置に紹介して、CSV化する
    readResult = response["analyzeResult"]["readResults"][0]
    lines = readResult["lines"]
    for line in lines:
        text = line["text"]
        boundingBox = line["boundingBox"]
        x, y = getXY(x_list, y_list, boundingBox)
        if x == -1:
            print(">> {} {}".format(text, boundingBox))
        else:
            print("[{},{}] {}".format(x, y, text))
            csv[y][x] = text

    # CSV出力
    lines = []
    for i in range(len(csv)):
        row = csv[i]
        line = ""
        for col in row:
            line += col
            line += ","
        lines.append(line)

    with open("output.csv", mode="w") as f:
        for line in lines:
            f.write(line)
            f.write("\n")

    
if __name__ == "__main__":
    main()

7 修正

Excelなどに展開すると分かりやすいですが、読み込んだ結果は、一部のセルが欠落してしまっています。

実は、この原因は、検出範囲が、セルをまたがってしまっていて、テキストは検出されているが、帳票座標との突き合わせに失敗したものです。

また、最初から帳票の外に配置されたテキストも存在します。 このように、いくつかの状況によっては、完全な自動化は難しく、手動での補正が必要となると思います。

そこで、手動で補正することを前提にして、帳票として処理できなかったものを出力してみました。

fax_output1.png

(1) 資材注文情報
(2) 2 CMP002
(3) 5 CMP005
(4) 7 CMP002
(5) 2023-06-30 22:30 MTL020
(6) 前回と同様の注文です。

CSV化すると同時に、このような出力を準備すると、手動による修正も捗るのではないかと思います。

コードです。

sample001.py
import cv2
import os
import numpy as np
import cv2
import json
import requests
import time

class ImageTool:
    def __init__(self, base_name):
        self.dir = os.path.dirname(os.path.abspath(__file__))
        self.base_name = base_name

    def read(self):
        return cv2.imread("{}/{}.png".format(self.dir, self.base_name))

    def write(self, prefix, img):
        cv2.imwrite("{}/{}_{}.png".format(self.dir, self.base_name, prefix), img)

    def file_name(self):
        return "{}/{}.png".format(self.dir, self.base_name)


# 矩形描画
def disp_rects(rects, img, thickness):
    image = img.copy()
    for i, rect in enumerate(rects):
        color = np.random.randint(0, 255, 3).tolist()
        cv2.drawContours(image, rects, i, color, thickness)
    return image


def create_white_image(org_img):
    h, w, c = org_img.shape
    black_img = np.zeros((h, w, c), np.uint8)
    return black_img + 255


# 近似座標の集約
def consolidation(list):
    result = []
    min = 0
    counter = 0
    for val in list:
        if min == 0:
            min = val
            keep = val
        else:
            if keep + 3 < val:  # 3ドット以内をまとめる
                if counter > 2:  # 得意な検出は排除する
                    result.append(int(min + (keep - min) / 2))
                min = val
                counter = 0
            counter += 1
            keep = val

    if counter > 2:  # 特異な検出は排除する
        result.append(int(min + (keep - min) / 2))

    return result


# 座標検出
def detect_point(rects):
    # 全X,Y検出
    x_list = []
    y_list = []
    for i, rect in enumerate(rects):
        for i in range(4):
            x, y = rect[i]
            if not x in x_list:
                x_list.append(x)
            if not y in y_list:
                y_list.append(y)

    x_list.sort()
    y_list.sort()

    # 近似値の集約
    x_list = consolidation(x_list)
    y_list = consolidation(y_list)

    return x_list, y_list


# LINE描画
def disp_line(x_list, y_list, img):
    image = img.copy()

    x_min = min(x_list)
    x_max = max(x_list)
    y_min = min(y_list)
    y_max = max(y_list)

    for x in x_list:
        cv2.line(
            image,
            pt1=(x, y_min),
            pt2=(x, y_max),
            color=(0, 0, 255),
            thickness=1,
            lineType=cv2.LINE_4,
            shift=0,
        )

    for y in y_list:
        cv2.line(
            image,
            pt1=(x_min, y),
            pt2=(x_max, y),
            color=(0, 0, 255),
            thickness=1,
            lineType=cv2.LINE_4,
            shift=0,
        )

    return image


def readApi(imFilePath):
    with open(imFilePath, "rb") as f:
        data = f.read()

    subscription_key = "xxxxxxxxxxxxxxxxxxxxxxxxx"
    endpoint = "https://japaneast.api.cognitive.microsoft.com/"
    model_version = "2022-04-30"
    language = "ja"

    text_recognition_url = endpoint + "vision/v3.2/read/analyze"
    headers = {
        "Ocp-Apim-Subscription-Key": subscription_key,
        "Content-Type": "application/octet-stream",
    }
    params = {"language ": language, "model-version": model_version}

    response = requests.post(
        text_recognition_url, headers=headers, params=params, json=None, data=data
    )
    response.raise_for_status()

    analysis = {}
    poll = True

    while poll:
        response_final = requests.get(
            response.headers["Operation-Location"], headers=headers
        )
        analysis = response_final.json()

        print(json.dumps(analysis, indent=4, ensure_ascii=False))

        time.sleep(1)
        if "analyzeResult" in analysis:
            poll = False
        if "status" in analysis and analysis["status"] == "failed":
            poll = False
    return analysis


def getXY(x_list, y_list, boundingBox):
    x1 = boundingBox[0]
    x2 = boundingBox[4]
    y1 = boundingBox[1]
    y2 = boundingBox[5]
    for y in range(len(y_list) - 1):
        top = y_list[y]
        bottom = y_list[y + 1] + 1
        for x in range(len(x_list) - 1):
            left = x_list[x]
            right = x_list[x + 1] + 1
            if top <= y1 and y2 <= bottom and left <= x1 and x2 <= right:
                return x, y
    return -1, -1


def main():
    base_name = "fax"
    image_tool = ImageTool(base_name)
    org_img = image_tool.read()
    white_img = create_white_image(org_img)

    # グレースケール変換
    gray_image = cv2.cvtColor(org_img, cv2.COLOR_BGR2GRAY)
    # エッジ抽出
    edges_image = cv2.Canny(gray_image, 1, 100, apertureSize=3)
    # 膨張処理
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
    dilate_image = cv2.dilate(edges_image, kernel)

    # 輪郭抽出
    contours, hierarchy = cv2.findContours(
        dilate_image, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE
    )
    # 面積でフィルタリング
    rects = []
    for cnt, hrchy in zip(contours, hierarchy[0]):
        if cv2.contourArea(cnt) < 3000:
            continue  # 面積が一定の大きさを満たさないものを除く
        if cv2.contourArea(cnt) > 20000:
            continue  # 面積が一定の大きさを超えるものを除く
        if hrchy[3] == -1:
            continue  # ルートノードは除く
        # 輪郭を囲む長方形を計算する。
        rect = cv2.minAreaRect(cnt)
        rect_points = cv2.boxPoints(rect).astype(int)
        rects.append(rect_points)

    # 座標検出
    x_list, y_list = detect_point(rects)

    # Computer Vision 3.2 Read API によるOCR読み取り
    response = readApi(image_tool.file_name())
    # JSON ファイルを出力
    with codecs.open("output_read3.2.json", "w+", "utf-8") as fp:
        json.dump(response, fp, ensure_ascii=False, indent=2)

    # 出力用バッファ
    csv = []
    for _ in range(len(y_list)):
        row = []
        for _ in range(len(x_list)):
            row.append("")
        csv.append(row)

    outline_text = ""
    outline_counter = 1
    output_img = org_img.copy()

    # BoundingBoxを罫線位置に紹介して、CSV化する
    readResult = response["analyzeResult"]["readResults"][0]
    lines = readResult["lines"]
    for line in lines:
        text = line["text"]
        boundingBox = line["boundingBox"]
        x, y = getXY(x_list, y_list, boundingBox)
        if x == -1:
            cv2.rectangle(
                output_img,
                [
                    boundingBox[0],
                    boundingBox[1],
                    (boundingBox[4] - boundingBox[0]),
                    (boundingBox[5] - boundingBox[1]),
                ],
                (0, 0, 255),
                2,
            )
            cv2.putText(
                output_img,
                "({})".format(outline_counter),
                (boundingBox[0] - 50, boundingBox[1] + 20),
                cv2.FONT_HERSHEY_PLAIN,
                2,
                (0, 0, 255),
                2,
                cv2.LINE_AA,
            )
            outline_text += "({}) {}\n".format(outline_counter, text)
            outline_counter += 1
        else:
            csv[y][x] = text

    # 帳票外の出力
    image_tool.write("output1", output_img)
    print(outline_text)

    # CSV出力
    lines = []
    for i in range(len(csv)):
        row = csv[i]
        line = ""
        for col in row:
            line += col
            line += ","
        lines.append(line)

    with open("output.csv", mode="w") as f:
        for line in lines:
            f.write(line)
            f.write("\n")


if __name__ == "__main__":
    main()

9 最後に

今回は、FAXで受信した帳票画像をCSV化してみました。

帳票には、結合セルや、縦書きなど、複雑なものもあるため、汎用的なプログラムには限界があると思います。

しかし、それぞれの帳票に適合させたプログラムや、手動のオペレーションをうまく組み込めれば、結構な工数削減が可能かも知れません。

FAXを利用している現場は、恐らく、かなり少なくなっているとは思いますが、もし、わずかに残ったFAXに工数を大きく割かれているような場合、このような検討も有効かも知れません。