算数ドリルを秒で解くプログラムに Amazon Textract を使って挑戦してみる

2020-11-02
デベロッパーのためのクラウド活用方法

呉 和仁

tl;dr

記事を書き始めたら思ったより長くなってしまったので、冒頭にどんなことが書いてある記事なのか、結論だけまとめておきます。ちなみに、tl;dr はブログ界では有名な略語らしいのですが、too long, didn’t read の略で長すぎて読まないよ!という意味から転じて要約とかの意味を表すそうです。

本記事は、Amazon Textract というサービスと Python を使って、左の画像から筆算の情報を読み取って計算し、筆算の答えが入った右の画像を自動で生成する仕組みを作るまでの長い旅路を記載したものです。

Before

img_textract-math-drill_01

After

img_textract-math-drill_02

ご注意

本記事で紹介する AWS サービスを起動する際には、料金がかかります。builders.flash メールメンバー特典の、クラウドレシピ向けクレジットコードプレゼントの入手をお勧めします。

builders.flash メールメンバーへの登録・特典の入手はこちら »

*ハンズオン記事およびソースコードにおける免責事項 »


1. 算数ドリルはお好きですか ?

Builder な皆様、こんにちは ! 機械学習ソリューションアーキテクトの呉です。

前回の記事では、Amazon Comprehend を用いて読書感想文を書くことに挑戦してみましたが、読書感想文で悩んでいる小学生の皆さま、ご両親の皆さま、挑戦していただけたでしょうか ? おそらく皆様は、本を読んで、咀嚼して、自分の感想を書き下ろすより、Amazon Comprehend の API を叩いて出てきた単語とグラフを見ながら書いたほうが圧倒的に楽なことに気づいていただけたかと思います。と、戯れはそこまでにしておき・・・。

さて、私は小学生時代、もう一つ嫌なことがありました。それは算数ドリルです。

めちゃくちゃ簡単で、似たような問題を何回も解かされるのにうんざりした方も多いのではないでしょうか ?

1 + 1 は何回やっても答えは 2 ですし、5 + 2 は何回やっても 7 なのです (プロレス好きな方は 1+1 が 200 の場合もございますがさておき)。それを修行僧による写経のごとくわかり切った答えを鉛筆で答案用紙にぐりぐり書いていくのは苦痛でした。

私゛は゛も゛う゛そ゛ん゛な゛こ゛と゛は゛や゛り゛た゛く゛な゛い゛!!!

と思ったので、こちらも Amazon の AI サービスを利用してなにかできないか、ということを試してみました。

余談ですが、こういう単純だけれども面倒くさいことをコンピュータにやらせるのは素晴らしいことですが、コンピュータにやらせると、意外と正確な答えを出すのが難しいので使い所や使い方って大切ですよね。浮動小数点演算の精度ってひょんな事で問題になったり・・・ 

img_textract-math-drill_03

2. Amazon Textract とは ?

Amazon Textract は、「実質的にどのドキュメントからでもテキストやデータを簡単に抽出」することができるサービスです。

と、サービス紹介に記載されております。

しかしピンと来ない方もいらっしゃるかと思いますのでもう少し具体的に説明すると、画像や pdf から記載内容とその座標を抽出することができます。AWS マネジメントコンソールの画面には以下のサンプル (日本語でいうところのエントリーシート) が配置してあるので、こちらをご覧になるのがわかりやすいかと思います。
(※ Amazon Textract は 2020 年 11 月現在、英数字のみの対応です)

img_textract-math-drill_04

サンプルドキュメント

Amazon Textract には主に 3 種類の機能があります。 

1 つ目 はテキスト検出です。下記のようにドキュメントの中にあるテキストを抽出することができます。エントリーシートの中に書かれている記載内容が抽出できていることがわかります。 

img_textract-math-drill_05

クリックすると拡大します

2 つ目はフォーム検出です。ここで言うフォームは入力項目のことを指し、項目に対してどのような値が入っているのか、項目とキーがセットで検出できます。例えば Full Name (フルネーム) という項目に対して Jane Doe という値が取れていることがわかります。

img_textract-math-drill_06

クリックすると拡大します

3 つ目はテーブル検出です。ドキュメントに表があった場合は表形式でそのデータを取得することができます。 

img_textract-math-drill_07

クリックすると拡大します

主な Amazon Textract としての主な機能は上記 3 つですが、一番右のタブに Human Review (new) と書かれたタブが見えるかと思います。 

これは、確実さが求められる場合や、Amazon Textract の結果の信頼度が低い場合 (Amazon Textract は API を叩いた場合、抽出結果と合わせて信頼度も出力します) などに、人のレビューを組み込むことができます。用途としては、審査 (住宅ローンなど) などで、書類に記入された事項の過不足や不備がないか、などのチェック機構として組み込むことができます。信頼度が高ければ半自動的に審査をすることもできますし、必ず人を挟む、など自由にワークフローを組み込むことが可能です。

img_textract-math-drill_08

クリックすると拡大します


3. 算数ドリルをやってみよう

前置きが前回同様長くなりましたが、算数ドリルを解いてみましょう。まずは問題が必要です。

今回は秒でやってみることが目的なので、問題も秒で作成すべく、さくっとコンピュータに作らせましょう。小学生のお子様がいらっしゃる方はぜひご利用いただければと思います。

下記の Python コードを実行すると、最大 4 桁までの足し算引き算の問題を 10 問含む画像が、 5 枚生成されます。乱数の seed を 1234 に固定しているので、毎度別の問題を出力する際は seed(1234) を削除してください。また、もっと問題が欲しいというかたは range(5) の 5 の部分を 100 なり 1000 なり必要とする数値に書き換えていただければ、ストレージの許す限り問題を作成できるかと思います。

from random import randint,seed
from PIL import Image,ImageDraw,ImageFont
from os import makedirs
output_dir = './question/'
makedirs(output_dir, exist_ok=True)
seed(1234) # 結果を再現できるようにするために乱数のシードを固定
blocksize = (400,240)
font = ImageFont.truetype("DejaVuSansMono.ttf", 30)
for i in range(5): # 問題を5枚作成する
    im = Image.new('L', (blocksize[0]*2,blocksize[1]*5),(255))
    d = ImageDraw.Draw(im)
    q_num = 0
    for r in range(5):
        for c in range(2):
            q_num += 1;q_num_text = '(' + str(q_num) + ')'
            d.text((c*blocksize[0],r*blocksize[1]), q_num_text, font=font, fill=(0))# 問題番号記入
            # 問題作成
            x,y = randint(1,9999),randint(1,9999)
            operator = '+' if randint(0,1) == 0 else '-'
            if operator=='-' and x < y:
                x,y = y,x
            x = str(x).rjust(5);y = str(y).rjust(5)
            d.text((c*blocksize[0]+200, r*blocksize[1]+50), x, font=font, fill=(0))# 上の数字
            d.text((c*blocksize[0]+200, r*blocksize[1]+90), y, font=font, fill=(0))# 下の数字
            d.text((c*blocksize[0]+100, r*blocksize[1]+90), operator, font=font, fill=(0))# 演算子
            d.line((c*blocksize[0]+100, r*blocksize[1]+124, c*blocksize[0]+300, r*blocksize[1]+124), fill=(0),width=1)# 線を描く
    file_path = output_dir + str(i).zfill(5) + '.png'
    im.save(file_path)

生成された画像例 
./question/00000.png

img_textract-math-drill_09

生成された画像例
./question/00001.png

img_textract-math-drill_10

3-1. 使用する API

Textract で検出に利用する API として、テキスト検出 API とドキュメント分析 API があります。
※ここからは AWS SDK for Python (Boto3) に絞ったお話をしていきたいと思います。(他の SDK を普段ご利用の方は適宜読み替えてください)

テキスト検出 API は boto3 だと detect_document_text で、単純にドキュメントに含まれるテキストを検出してくれます。こちらのように右端に「Hello Amazon Textract」と書かれた画像を読み込んで detect_document_text API を叩いてみました。

img_textract-math-drill_11

コード

from PIL import Image
import boto3
img_file = './Hello_Amazon_Textract.png'
with open(img_file,'rb') as f:
    img_bytes = f.read()
textract = boto3.client('textract',region_name='us-east-1') # 東京リージョンでは Textract が使えないので、バージニア北部を利用
textract.detect_document_text(
    Document={
        'Bytes': img_bytes,
    }
)

出力結果

{'DocumentMetadata': {'Pages': 1},
 'Blocks': [{'BlockType': 'PAGE', # ページを検出
   # Geometryでページが画像のどこにあるかを指している。この場合は画像全体
   'Geometry': {'BoundingBox': {'Width': 1.0,'Height': 1.0,'Left': 0.0,'Top': 0.0},
    'Polygon': [{'X': 0.0, 'Y': 0.0062385001219809055},
     {'X': 0.9988686442375183, 'Y': 0.0},
     {'X': 1.0, 'Y': 1.0},
     {'X': 0.0011617843993008137, 'Y': 1.0}]},
   'Id': '33487931-8ea7-4150-846c-a136d17ff7e7',
   'Relationships': [{'Type': 'CHILD',
     'Ids': ['184e1993-8497-4f57-9f1f-ce26d484af9f']}]}, # 子要素のID → 下のLINE(行)を指している
  {'BlockType': 'LINE',
   'Confidence': 99.92082214355469,
   'Text': 'Hello Amazon Textract', # Hello Amazon Textract という行を検出
   'Geometry': {'BoundingBox': {'Width': 0.36586683988571167,
     'Height': 0.41139984130859375,
     'Left': 0.6142960786819458,
     'Top': 0.4278397262096405},
    'Polygon': [{'X': 0.6142960786819458, 'Y': 0.5191991925239563},
     {'X': 0.978757917881012, 'Y': 0.4278397262096405},
     {'X': 0.9801629185676575, 'Y': 0.7478801012039185},
     {'X': 0.6157010793685913, 'Y': 0.8392395973205566}]},
   'Id': '184e1993-8497-4f57-9f1f-ce26d484af9f', # ページの子要素から指し示された ID
   'Relationships': [{'Type': 'CHILD',
     # 行の子要素として 3 つのIDを持つ → 下の Word (単語) 3 つを指している
     'Ids': ['ee52f921-e926-44e6-8a6f-3082023797da',
      '8ad19799-7055-4da3-a1ad-6e6f2020e5d4',
      '1695052c-0212-4427-b836-ec8719bf0d6d']}]},
  # 以下、Hello, Amazon, Texract という単語の検出をした結果と場所などを表示
  {'BlockType': 'WORD',
   'Confidence': 99.88762664794922,
   'Text': 'Hello',
   'Geometry': {'BoundingBox': {'Width': 0.08201014250516891,
     'Height': 0.2605866491794586,
     'Left': 0.6142960786819458,
     'Top': 0.4989061951637268},
    'Polygon': [{'X': 0.6142960786819458, 'Y': 0.5191991925239563},
     {'X': 0.6952512860298157, 'Y': 0.4989061951637268},
     {'X': 0.6963062286376953, 'Y': 0.7391998767852783},
     {'X': 0.6153509616851807, 'Y': 0.7594928741455078}]},
   'Id': 'ee52f921-e926-44e6-8a6f-3082023797da'},
  {'BlockType': 'WORD',
   'Confidence': 99.95772552490234,
   'Text': 'Amazon',
   'Geometry': {'BoundingBox': {'Width': 0.13044267892837524,
     'Height': 0.31211456656455994,
     'Left': 0.7037955522537231,
     'Top': 0.46519193053245544},
    'Polygon': [{'X': 0.7037955522537231, 'Y': 0.4975821077823639},
     {'X': 0.8330102562904358, 'Y': 0.46519193053245544},
     {'X': 0.8342382907867432, 'Y': 0.7449163198471069},
     {'X': 0.7050235867500305, 'Y': 0.7773064970970154}]},
   'Id': '8ad19799-7055-4da3-a1ad-6e6f2020e5d4'},
  {'BlockType': 'WORD',
   'Confidence': 99.9171142578125,
   'Text': 'Textract',
   'Geometry': {'BoundingBox': {'Width': 0.1349683552980423,
     'Height': 0.3111570477485657,
     'Left': 0.8451945781707764,
     'Top': 0.4702499806880951},
    'Polygon': [{'X': 0.8451945781707764, 'Y': 0.5037769079208374},
     {'X': 0.9789441227912903, 'Y': 0.4702499806880951},
     {'X': 0.9801629781723022, 'Y': 0.7478801012039185},
     {'X': 0.8464134335517883, 'Y': 0.7814069986343384}]},
   'Id': '1695052c-0212-4427-b836-ec8719bf0d6d'}],
 'DetectDocumentTextModelVersion': '1.0',
 'ResponseMetadata': {'RequestId': '2ed02889-cd52-4a4f-8f19-c022234a2eac',
  'HTTPStatusCode': 200,
  'HTTPHeaders': {'date': 'Fri, 18 Sep 2020 02:35:41 GMT',
   'content-type': 'application/x-amz-json-1.1',
   'content-length': '2424',
   'connection': 'keep-alive',
   'x-amzn-requestid': '2ed02889-cd52-4a4f-8f19-c022234a2eac'},
  'RetryAttempts': 0}}

dict 形式で結果が表示されるため、人間だと読みづらいですが、まずページがあり、その中に行とその記載内容を検出し、行の中に単語 3 つとそれぞれの単語の内容が検出できていることがわかります。これを使えば、算数ドリルの問題をコンピュータが認識できそうですので、こちらを使っていきたいと思います。

一方、ドキュメント分析 API には analyze_document があり、テーブルとフォームを抽出する機能がありますが、算数ドリルはテーブルやフォームではないので使用しません。

余談ですが、 detect_document_text や analyze_document は同期処理のため、実行すると結果が返ってくるまで API を叩いた側のコンピュータは待機することになりますが、巨大データだと待つ時間が長くなり、API をコールしている側のコンピューティングリソースがもったいないです。これに対して、テキスト検出もドキュメント分析もそれぞれ非同期で実行する API があります。実行完了を待つのではなく後で処理結果を確認したい、といったユースケースの場合は、そちらをご利用ください。

また、Amazon Textract へのデータのインプット方法ですが、上の例だと画像のバイト文字列を引数に detect_document_text をコールしていますが、Amazon S3 のパスを指定することも可能です。特にバッチ処理の時は利用する Amazon Textract と同一リージョンの S3 バケットの利用を検討ください。

3-2. 算数ドリルの画像からテキスト検出をしてみる

まずは先程作成した計算ドリルのデータを API に投げてみましょう。検出結果は dict 形式だと見づらいので、画像に検出結果を描画してみます。 

Python コード

# 必要なライブラリを読み込む
from PIL import Image, ImageDraw, ImageFont
import boto3, requests, os
from math import ceil
from matplotlib import pyplot
# textract を利用するために boto3 で textract クライアントを生成
# 東京リージョンではまだ利用できないため、バージニア北部リージョンを利用
textract = boto3.client('textract',region_name='us-east-1')

# 問題を格納しているディレクトリ
IMG_DIR = './question/'
# 試してみるファイル
IMG_FILE = '00000.png'
IMG_PATH = IMG_DIR + IMG_FILE

# まずはテキスト検出 API を実行し、その結果を画像に描画する
with open(IMG_PATH,'rb') as f:
    img_bytes = f.read()
response = textract.detect_document_text(
    Document={
        'Bytes': img_bytes,
    }
)
img = Image.open(IMG_PATH).convert('RGB')
d = ImageDraw.Draw(img)
w,h = img.size
font = ImageFont.truetype("DejaVuSansMono.ttf", 24)
for block in response['Blocks']:
    if block['BlockType'] == 'WORD':
        left = int(w*block['Geometry']['BoundingBox']['Left'])
        top = int(h*block['Geometry']['BoundingBox']['Top'])
        right = ceil(w*block['Geometry']['BoundingBox']['Left']) + w*block['Geometry']['BoundingBox']['Width']
        bottom = ceil(h*block['Geometry']['BoundingBox']['Top']) + h*block['Geometry']['BoundingBox']['Height']
        d.rectangle([(left, top), (right, bottom)], outline='lime', width=2)
        d.text((left, top+20), block['Text'], fill='red', align='center',font=font)
# 画像を表示 ( jupyter 環境を想定)
img

出力結果

img_textract-math-drill_12

しっかりと数字や演算子を検出できていることがわかります。これらを利用して演算を行い、解答場所に答えを記載すればよさそうです。 

3-3. 計算式の解釈

さて、問題の記載内容をテキストで検出することはできましたが、ここから問題の意味を解釈する必要があります。

例えば (1) は 「7221 + 1915」 ですが、現状では、「7221」 と 「+」 と 「1915」 は個別の単語として認識されています。これを 「7221 + 1915」 という問題に解釈するためにはどうすればよいでしょうか?

やり方はいくつかあるかと思いますが、筆算形式の問題に特化して考えるならば、例えば演算子を起点に同じ高さにある一番近い数字を検出し (7221 + 1915 という問題の場合は、+ の演算子と同じ高さにある 1915)、またその数字の上にある一番近い数字(1915 の場合は 7221)を抽出すれば、処理すべき演算が作れそうです。(7221 + 1915 という式の完成)

テキスト検出した結果から問題文を作成してみましょう。まずは演算子とその位置を取得します。

Python コード

img = Image.open(IMG_PATH)
w,h = img.size
questions = []
# 演算子とその位置を取得
for block in response['Blocks']:
    if block['BlockType'] == 'WORD':
        if block['Text'] in ['-','+']: # - か + を取得
            left = int(w*block['Geometry']['BoundingBox']['Left'])
            top = int(h*block['Geometry']['BoundingBox']['Top'])
            right = ceil(w*block['Geometry']['BoundingBox']['Left'] + w*block['Geometry']['BoundingBox']['Width'])
            bottom = ceil(h*block['Geometry']['BoundingBox']['Top'] + h*block['Geometry']['BoundingBox']['Height'])
            center = ((left+right)//2,(top+bottom)//2)
            operator_symbol = block['Text']
            questions.append({
                'operator_symbol':{
                    'left': left,
                    'top': top,
                    'right': right,
                    'bottom': bottom,
                    'center': center,
                    'operator_symbol': operator_symbol
                }
            })

# 結果確認
for question in questions:
    print(question)

出力結果

{'operator_symbol': {'left': 99, 'top': 99, 'right': 119, 'bottom': 119, 'center': (109, 109), 'operator_symbol': '+'}}
{'operator_symbol': {'left': 499, 'top': 99, 'right': 519, 'bottom': 120, 'center': (509, 109), 'operator_symbol': '+'}}
{'operator_symbol': {'left': 103, 'top': 347, 'right': 115, 'bottom': 353, 'center': (109, 350), 'operator_symbol': '-'}}
{'operator_symbol': {'left': 499, 'top': 339, 'right': 519, 'bottom': 360, 'center': (509, 349), 'operator_symbol': '+'}}
{'operator_symbol': {'left': 103, 'top': 587, 'right': 115, 'bottom': 593, 'center': (109, 590), 'operator_symbol': '-'}}
{'operator_symbol': {'left': 499, 'top': 579, 'right': 519, 'bottom': 599, 'center': (509, 589), 'operator_symbol': '+'}}
{'operator_symbol': {'left': 99, 'top': 819, 'right': 119, 'bottom': 839, 'center': (109, 829), 'operator_symbol': '+'}}
{'operator_symbol': {'left': 499, 'top': 819, 'right': 519, 'bottom': 839, 'center': (509, 829), 'operator_symbol': '+'}}
{'operator_symbol': {'left': 103, 'top': 1067, 'right': 115, 'bottom': 1073, 'center': (109, 1070), 'operator_symbol': '-'}}
{'operator_symbol': {'left': 499, 'top': 1059, 'right': 518, 'bottom': 1080, 'center': (508, 1069), 'operator_symbol': '+'}}

演算子の座標 (左端、上端、右端、下端、中央) を無事取得できました。
次に取りたい情報は演算子の右にある加算/減算する数値です。例えば、演算子と同じ高さ (±20px まで許容) にあり、演算子の右にある一番近い数字を取得する、というロジックでやってみましょう。

Python コード

# 検出した数値のみのリスト
extract_numbers = []
for block in response['Blocks']:
    if block['BlockType'] == 'WORD' and block['Text'].isnumeric():
        number = block['Text']
        left = int(w*block['Geometry']['BoundingBox']['Left'])
        top = int(h*block['Geometry']['BoundingBox']['Top'])
        right = ceil(w*block['Geometry']['BoundingBox']['Left'] + w*block['Geometry']['BoundingBox']['Width'])
        bottom = ceil(h*block['Geometry']['BoundingBox']['Top'] + h*block['Geometry']['BoundingBox']['Height'])
        center = ((left+right)//2,(top+bottom)//2)
        extract_numbers.append({
                'left': left,
                'top': top,
                'right': right,
                'bottom': bottom,
                'center': center,
                'number': number
        })

# 演算子と ± 20 px にある高さの、一番近い数字を取得(加算減算する数字)
margin = 20
diff = 99999 # 演算子と数値の横方向の距離を大きい数字で初期化、小さければその値を採用する
for i,question in enumerate(questions):
    diff = 99999
    candidates = []
    for extract_number in extract_numbers:
        if question['operator_symbol']['center'][1] - margin <= extract_number['center'][1] <= question['operator_symbol']['center'][1] + margin:
            candidates.append(extract_number)
    for candidate in candidates:
        if diff > candidate['center'][0] - question['operator_symbol']['center'][0] > 0:
            diff = question['operator_symbol']['center'][0] - candidate['center'][0]
            questions[i]['y'] = candidate
# y キー を持たない(= 計算問題ではない演算子)を除去する
for question in questions:
    if 'y' in question:
        pass
    else:
        questions.remove(question)

# 加算減算する数値として使用したものをextract_numbersから除外
for question in questions:
    if question['y'] in extract_numbers:
        extract_numbers.remove(question['y'])

# 結果確認
for question in questions:
    formula = 'X' + ' ' + question['operator_symbol']['operator_symbol'] + ' ' + question['y']['number']
    print(formula)

出力結果

X + 1915
X + 9540
X - 1376
X + 286
X - 260
X + 2434
X + 1875
X + 7975
X - 1058
X + 9855

加算減算する数値がうまく抽出できました ! あとは加算減算される側の数値を抽出できれば、問題文の解釈ができますね !
抽出の仕方は、引く数値と左右が ±20px 以内の数値かつ、引く数値より座標が上にあって一番距離が近い数値にしましょう。

コード

margin = 20
diff = 99999 # 演算子と数値の横方向の距離を大きい数字で初期化、小さければその値を採用する
for i,question in enumerate(questions):
    diff = 99999
    candidates = []
    for extract_number in extract_numbers:
        # 筆算において数字は右詰めされるため、右端の px 値で評価する
        if question['y']['right'] - margin <= extract_number['right'] <= question['y']['right'] + margin:
            candidates.append(extract_number)
    for candidate in candidates:
        if diff > question['y']['center'][1] - candidate['center'][1] > 0:
            diff = question['y']['center'][1] - candidate['center'][1]
            questions[i]['x'] = candidate
# 結果確認
for question in questions:
    formula = question['x']['number'] + ' ' + question['operator_symbol']['operator_symbol'] + ' ' + question['y']['number']
    print(formula)

出力結果

7221 + 1915
1486 + 9540
1613 - 1376
3880 + 286
5671 - 260
7595 + 2434
3000 + 1875
8243 + 7975
8866 - 1058
1124 + 9855

うまく解きたい計算式が作れました !

3-4. 計算

ここまでできていれば計算は簡単ですね ! コンピュータがもっとも得意とするところです。

Python では eval というメソッドを使うと、文字列をスクリプトとして実行することができます。先程の実行結果を 1 行ずつ eval にかけてあげればよさそうです。

コード

# eval メソッドを使って計算を解く
for i,question in enumerate(questions):
    formula = str(question['x']['number']) + ' ' + question['operator_symbol']['operator_symbol'] + ' ' + question['y']['number']
    questions[i]['formula'] = formula
    questions[i]['answer'] = {'number':eval(formula)}
# 結果確認
for question in questions:
    print(question['formula'] + ' = ' + str(question['answer']['number']))

出力結果

7221 + 1915 = 9136
1486 + 9540 = 11026
1613 - 1376 = 237
3880 + 286 = 4166
5671 - 260 = 5411
7595 + 2434 = 10029
3000 + 1875 = 4875
8243 + 7975 = 16218
8866 - 1058 = 7808
1124 + 9855 = 10979

答案のデータが出来上がりました !

3-5. 答案の記入

さて、問題用紙に書き込むデータが出来上がりましたが、答えを答案用紙に書き込まなければいけません。

書き込みですが、単純に記入する、という話だけに絞れば、先程抽出結果を画像に載せたように、ImageDraw ライブラリを利用すれば簡単にできそうです。

記入する場所については、引かれる数値と引く数値の間隔と同じ間隔を空けて、引く数値の下に記入すればよさそうです。

書く文字の大きさについては、Amazon Textract 側で文字の大きさを直接とれないものの、矩形で文字が書いてある部分の大きさはわかるので、それと同じフォントサイズを見つければよさそうです。

Python コード

img = Image.open(IMG_PATH)
d = ImageDraw.Draw(img)
for i,question in enumerate(questions):
    questions[i]['answer']['top'] = question['y']['top'] + (question['y']['top'] - question['x']['top'])

    # 文字数によって開始位置を変更する
    # 1 文字あたりどれくらいの幅を取るかを width_unit に格納し、その幅分で文字の左端位置を調整する
    width_unit = ((question['y']['right'] - question['y']['left']) / len(str(question['y']['number'])))
    questions[i]['answer']['left'] = int(question['y']['left'] - (len(str(question['answer']['number'])) - len(str(question['y']['number'])))*width_unit)

    font = ImageFont.truetype("DejaVuSansMono.ttf", round(round(width_unit)*(10/6))) # 10/6 はフォントと幅を変換する定数
    d.text((questions[i]['answer']['left'], questions[i]['answer']['top']), str(questions[i]['answer']['number']), font=font,fill='black',align='right')
img

出力結果

img_textract-math-drill_13

よい感じで出力できました !

3-6. 通しのプログラムを作成

さて、ここまで各機能ごとにコードを書いてきましたが、これらをつなげて動くプログラムにしていきましょう。

また、"自分で書いた感" を出すために、答案用紙への記入に手書き文字のフォントを用いてみました。こちらの Web サイトからダウンロードが可能です。

併せてタイトルの通り ”秒” で解けているかどうかを確認するために、実行時間の計測処理も入れました。

main.py

from PIL import Image, ImageDraw, ImageFont
import boto3, requests, os, time, sys
from math import ceil
from glob import glob

def calc_drill(IMG_DIR='./question/',OUT_DIR='./answer/',MARGIN=20):
    textract = boto3.client('textract',region_name='us-east-1')
    os.makedirs(OUT_DIR, exist_ok=True)
    
    print(IMG_DIR,OUT_DIR,MARGIN)
    
    # 画像の枚数分ループ
    for img_file in glob(IMG_DIR+'*.png'):
        with open(img_file,'rb') as f:
            img_bytes = f.read()
        # Textract の API を実行
        response = textract.detect_document_text(Document={'Bytes': img_bytes})
        
        # PIL で画像を開く
        img = Image.open(img_file)
        # 画像サイズを格納
        w,h = img.size
        # 問題を格納する list
        questions = []
        # 演算子とその位置を取得
        for block in response['Blocks']:
            if block['BlockType'] == 'WORD':
                if block['Text'] in ['-','+']: # - か + を取得
                    left = int(w*block['Geometry']['BoundingBox']['Left'])
                    top = int(h*block['Geometry']['BoundingBox']['Top'])
                    right = ceil(w*block['Geometry']['BoundingBox']['Left'] + w*block['Geometry']['BoundingBox']['Width'])
                    bottom = ceil(h*block['Geometry']['BoundingBox']['Top'] + h*block['Geometry']['BoundingBox']['Height'])
                    center = ((left+right)//2,(top+bottom)//2)
                    operator_symbol = block['Text']
                    questions.append({
                        'operator_symbol':{
                            'left': left,
                            'top': top,
                            'right': right,
                            'bottom': bottom,
                            'center': center,
                            'operator_symbol': operator_symbol
                        }
                    })
        # 検出した数値のみのリスト
        extract_numbers = []
        for block in response['Blocks']:
            if block['BlockType'] == 'WORD' and block['Text'].isnumeric():
                number = block['Text']
                left = int(w*block['Geometry']['BoundingBox']['Left'])
                top = int(h*block['Geometry']['BoundingBox']['Top'])
                right = ceil(w*block['Geometry']['BoundingBox']['Left'] + w*block['Geometry']['BoundingBox']['Width'])
                bottom = ceil(h*block['Geometry']['BoundingBox']['Top'] + h*block['Geometry']['BoundingBox']['Height'])
                center = ((left+right)//2,(top+bottom)//2)
                extract_numbers.append({
                        'left': left,
                        'top': top,
                        'right': right,
                        'bottom': bottom,
                        'center': center,
                        'number': number
                })
        diff = 99999 # 演算子と数値の横方向の距離を大きい数字で初期化、小さければその値を採用する
        for i,question in enumerate(questions):
            diff = 99999
            candidates = []
            for extract_number in extract_numbers:
                if question['operator_symbol']['center'][1] - MARGIN <= extract_number['center'][1] <= question['operator_symbol']['center'][1] + MARGIN:
                    candidates.append(extract_number)
            for candidate in candidates:
                if diff > candidate['center'][0] - question['operator_symbol']['center'][0] > 0:
                    diff = question['operator_symbol']['center'][0] - candidate['center'][0]
                    questions[i]['y'] = candidate
        # y キー を持たない(= 計算問題ではない演算子)を除去する
        for question in questions:
            if 'y' in question:
                pass
            else:
                questions.remove(question)
        # 引く数値として使用したものをextract_numbersから除外
        for question in questions:
            if question['y'] in extract_numbers:
                extract_numbers.remove(question['y'])
                
        diff = 99999 # 演算子と数値の横方向の距離を大きい数字で初期化、小さければその値を採用する
        for i,question in enumerate(questions):
            diff = 99999
            candidates = []
            for extract_number in extract_numbers:
                # 筆算において数字は右詰めされるため、右端の px 値で評価する
                if question['y']['right'] - MARGIN <= extract_number['right'] <= question['y']['right'] + MARGIN:
                    candidates.append(extract_number)
            for candidate in candidates:
                if diff > question['y']['center'][1] - candidate['center'][1] > 0:
                    diff = question['y']['center'][1] - candidate['center'][1]
                    questions[i]['x'] = candidate

        # eval メソッドを使って計算を解く
        for i,question in enumerate(questions):
            formula = str(question['x']['number']) + ' ' + question['operator_symbol']['operator_symbol'] + ' ' + question['y']['number']
            questions[i]['formula'] = formula
            questions[i]['answer'] = {'number':eval(formula)}
                    
        # 解答を記載
        d = ImageDraw.Draw(img)
        for i,question in enumerate(questions):
            questions[i]['answer']['top'] = question['y']['top'] + (question['y']['top'] - question['x']['top'])

            # 文字数によって開始位置を変更する
            # 1 文字あたりどれくらいの幅を取るかを width_unit に格納し、その幅分で文字の左端位置を調整する
            width_unit = ((question['y']['right'] - question['y']['left']) / len(str(question['y']['number'])))
            questions[i]['answer']['left'] = int(question['y']['left'] - (len(str(question['answer']['number'])) - len(str(question['y']['number'])))*width_unit)

            font = ImageFont.truetype("ZenjidoJP-FeltPenLMT-TTF.ttf", round(round(width_unit)*(10/6))) # 10/6 はフォントと幅を変換する定数
            d.text((questions[i]['answer']['left']+10, questions[i]['answer']['top']), str(questions[i]['answer']['number']), font=font,fill='black',align='right') # +10 は手書き文字フォント用マジックナンバー
        # 解答を保存
        img.save(img_file.replace(IMG_DIR,OUT_DIR))
    return 0

if __name__ == "__main__":
    img_dir = sys.argv[1]
    out_dir = sys.argv[2]
    margin = int(sys.argv[3])
    start = time.time()
    calc_drill(img_dir, out_dir, margin)
    exec_sec = time.time() - start
    print (f"exec_sec: {exec_sec}")
    exit()

実行

$ python main.py ./question/ ./answer/ 20

実行結果

exec_sec: 15.99182677268982

5枚の問題を解いて 16 秒弱でしたので、 1 枚あたり 3.2 秒程度で解いていることがわかります。秒で解いたと言っても過言ではないでしょう (実行環境やネットワーク環境に依ります)。計算結果も確認してみましょう。

./answer/00000.png

img_textract-math-drill_14

./answer/00001.png

img_textract-math-drill_15

./answer/00002.png

img_textract-math-drill_16

./answer/00003.png

img_textract-math-drill_17

./answer/00004.png

img_textract-math-drill_18

手書き文字フォントでしっかりと解答できていますね ! また、Textract は今までの問題のように固定位置に問題がなくても対応可能ですので、例えば下記のようなレイアウトが崩れた問題でもこのプログラムで対応可能です。

問題作成コード

from random import randint,seed
from PIL import Image,ImageDraw,ImageFont
from os import makedirs
output_dir = 'question2/'
makedirs(output_dir, exist_ok=True)
seed(1234) # 結果を再現できるようにするために入れています
blocksize = (400,240)
font = ImageFont.truetype("DejaVuSansMono.ttf", 30)
for i in range(1): # 問題を1枚作成する
    im = Image.new('L', (blocksize[0]*2,blocksize[1]*5),(255))
    d = ImageDraw.Draw(im)
    q_num = 0
    for r in range(5):
        for c in range(2):
            rand_x = randint(-50,50);rand_y = randint(-50,50)
            q_num += 1;q_num_text = '(' + str(q_num) + ')'
            d.text((c*blocksize[0],r*blocksize[1]), q_num_text, font=font, fill=(0))# 問題番号記入
            # 問題作成
            x,y = randint(1,999999),randint(1,999999)
            operator = '+' if randint(0,1) == 0 else '-'
            if operator=='-' and x < y:
                x,y = y,x
            x = str(x).rjust(7);y = str(y).rjust(7)
            d.text((c*blocksize[0]+200+rand_y, r*blocksize[1]+50+rand_x), x, font=font, fill=(0))# 上の数字
            d.text((c*blocksize[0]+200+rand_y, r*blocksize[1]+90+rand_x), y, font=font, fill=(0))# 下の数字
            d.text((c*blocksize[0]+100+rand_y, r*blocksize[1]+90+rand_x), operator, font=font, fill=(0))# 演算子
            d.line((c*blocksize[0]+100+rand_y, r*blocksize[1]+124+rand_x, c*blocksize[0]+350+rand_y, r*blocksize[1]+124+rand_x), fill=(0),width=1)# 線を描く
    file_path = output_dir + str(i).zfill(5) + '.png'
    im.save(file_path)

実行

$ python main.py ./question2/ ./answer2/ 20

Before

img_textract-math-drill_19

After

img_textract-math-drill_20

4. まとめ

さて、いかがでしょうか。秒で算数ドリルを解くプログラムが無事できあがりました。

残る改良余地としては、乗算に対応してみたり、繰り上がり、繰り下がりの計算跡を残したり、問題に氏名記入欄を作成して氏名を記入したり、といったところでしょうか。あるいは Amazon API Gateway + AWS Lambda を使って 問題の画像 を POST すると解答画像を返してくれるような Web API にしてみてもよいかと思います。興味がある方はぜひ挑戦してみてください。

このように Amazon Textract を利用することで、問題認識→解答を作る→解答を書く、というフローのうちの問題認識の大部分を肩代わりしてくれました。あとは Python 初心者でもいけるレベルの if や for 、dict 、 list 、あとはライブラリで Pillow などを使うだけのコーディングで、問題を解けるようになりました。

ちなみに、Amazon Textract がなかったらどうしなければならなかったかを、一人ブレインストーミングしてみるとおそらく

  1. 文字矩形抽出機械学習モデルを作る
    a. 大量の計算ドリルからどこに文字があるかを矩形でラベル付けする (所要時間 2 か月想定)
    b. 画像のどこに文字があるかを検出する機械学習モデルを作成する (所要時間 1 か月想定)
  2. 文字認識機械学習モデルを作る
    a. で切り取った画像から1文字ずつそれがなんの文字かをラベリングする (所要時間 1 ヶ月半想定)
    b. 画像に何が書かれているかを判別する機械学習モデルを作成する (所要時間 2 週間)
  3. 単語認識ロジックを作る
    a. 書かれている高さとフォントサイズも近く、横方向だけ少しずれている文字を、1 単語として扱うかどうかを分類するロジックを検討 (機械学習なのか、ルールベースなのかはやりながら検討、所要時間不明)

のようなアプローチを試みると思います。なかなかしんどそうです。Amazon Textract があるととても楽です !

機械学習エンジニアのはしくれとして、モデルを自分で作れる能力も大切ですが、よくあるユースケースについては AWS がサービスとして用意しているので、ぜひ積極的に利用していただければと思います。

主流の Amazon Textract のユースケースとしては、ドキュメントからテキストを抽出して検索のインデックスに利用したり、ワークフローの自動化ではございますが、ぜひ皆様も遊び心でいろんなことに使っていただければと思います。

それではよき Builder’s Life を!  

筆者プロフィール

photo_go_kazuhito

呉 和仁 (Go Kazuhito / @kazuneet
アマゾン ウェブ サービス ジャパン株式会社 機械学習ソリューションアーキテクト。

IoT の DWH 開発、データサイエンティスト兼業務コンサルタントを経て現職。
プログラマの三大美徳である怠惰だけを極めてしまい、モデル構築を怠けられる AWS の AI サービスをこよなく愛す。

AWS のベストプラクティスを毎月無料でお試しいただけます

さらに最新記事・デベロッパー向けイベントを検索

下記の項目で絞り込む
絞り込みを解除 ≫
フィルタ
フィルタ
1

AWS を無料でお試しいただけます

AWS 無料利用枠の詳細はこちら ≫
5 ステップでアカウント作成できます
無料サインアップ ≫
ご不明な点がおありですか?
日本担当チームへ相談する