レシートの情報を読み取りCookPadのレシピを検索する(Python, Google OCR, requests)

Pocket

こんにちは、leckです。初のブログ投稿になります。

今日は、自分が買った食材をレシートから読み取り、その食材でCookPad上に検索をかけるプログラムを大学の授業の演習で作ったので少し紹介します。  

レポートからそのまま流用しているので文章が若干堅くなっているのは許してください。

普段はMacを使っているのですが、今回はUbuntu18.04 LTS 上で実行させてます。

動機、やりたかったこと

私は一人暮らしして約半年近く経ちますが、なかなか料理をする機会が増えない。やろうと思ってもレシピを思い浮かばずに終わってしまう。
そもそもお店で食材を買いながらどんな料理を作ろうか考えるのか、苦手だったのです。
だったら、発想の転換で、 適当に買った食材で検索をかけてレシピを探せばいい じゃないかと思いつきました。

そこで、以下のことを実装しようと考えました。

  • OpenCVを使ってレシートの画像を読み取る
  • そこから文字データを抽出
  • 文字データから食材のワードだけを選び出す
  • CookPad上で検索をかける、
  • ヒットしたレシピを表示させる

実装方法、利用したライブラリと工夫点

レシートを切り出す

まずは画像を読み込み、レシートの部分だけを切り取るプログラムを書く必要がありました。

これにはOpenCVを使って画像を読み取り、2値化処理を行ってからエッジ抽出を行いました。
実際、この方法でレシートを切り出すのは難しい、というより自分で好ましい画像を用意できなかったためこの部分の実装はほとんど役に立ちませんでした。
ここの実装は以下のサイトのコードから引っ張ってきました。
ちなみに今回デモ用に使っているレシートの画像はここのサイトから使わせてもらっています。

レシートの画像を矩形補正してOCRにかけてみた。 - 暇な女子高専生のブログ

携帯の水没とパソコンのWebカメラをうまく認識できなかったため自前で用意できなかった影響が大きかったです。

import numpy as np
import os
import glob
import matplotlib.pyplot as plt
import cv2
import google_api_ocr # 後に出てくるコードのファイル名です(googleのOCRのAPIを使った文字認識のプログラム)

def transform_by4(img, points):
  """
  4点を指定してトリミングする。
  source:http://blanktar.jp/blog/2015/07/python-opencv-crop-box.html
  """

  points = points[np.argsort(points, axis = 0)[:,1]]
# yが小さいもの順に並び替え。
  top = points[np.argsort(points[:2],axis = 0)[:, 0]]
# 前半二つは四角形の上。xで並び替えると左右も分かる。
  bottom = points[2:][np.argsort(points[2:],axis=0)[:,0]]
# 後半二つは四角形の下。同じくxで並び替え。
  points = np.vstack((top, bottom) )
# 分離した二つを再結合。

    width = (np.abs(points[0][0]-points[1][0])+\
                np.abs(points[2][0]-points[3][0]))/2.0
  height = (np.abs(points[0][1]-points[2][1])+\
                np.abs(points[1][1]-points[3][1]))/2.0
  width = int(width)
  height = int(height)
  points2 = np.float32([[0,0],[width, 0],[0,height],[width,height]])
  points1 = np.float32(points)
  M = cv2.getPerspectiveTransform(points1,points2)
# 変換前の座標と変換後の座標の対応を渡すと、透視変換行列を作ってくれる。
  return cv2.warpPerspective(img,M,(width, height)) # 透視変換行列を使って切り抜く。
# matplotlibで正常に画像が表示できるための関数。(モノクロ画像に対して)
def show_img(img):
    plt.figure()
    tmp = np.tile(img.reshape(img.shape[0],img.shape[1], -1),\
                  reps=3)
    plt.imshow(tmp)
    plt.show()

def cont_edge(im, filename):
    im_size = im.shape[0] * im.shape[1]
    im_gray = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY)
    cv2.imwrite(filename + '_gray.jpg', im_gray)
    print(filename + '_gray.jpg')
    im_blur = cv2.fastNlMeansDenoising(im_gray) # 画像のノイズを取り除く
    # _, im_th = cv2.threshold(im_blur, 127, 255, cv2.THRESH_BINARY)
    # 以下のコマンドで2値化をする
    im_th = cv2.adaptiveThreshold(im_blur, 255, cv2.ADAPTIVE_THRESH_MEAN_C,
                                 cv2.THRESH_BINARY, 15, 5)
    # im_th = cv2.Canny(im_blur, 50, 200)
    th_filename = "{:s}_th.jpg".format(filename)
    # 2値化させた画像を表示させる。
    show_img(im_th)
    print(th_filename)
    # 画像の保存
    cv2.imwrite(th_filename, im_th)
    print(filename + '_th.jpg')

    img, cnts, hierarchy = cv2.findContours(im_th, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
    # 輪郭の抽出
    # 輪郭画像、輪郭、階層情報の順に並んでいる。
    cnts.sort(key=cv2.contourArea, reverse=True)
    # 抽出された輪郭の面積が大きい順にソートをかける
    cnt = cnts[1]
    img = cv2.drawContours(img, [cnt], -1, (0,255,0), 3)
    cv2.imwrite(filename+"_drawcont.jpg", img)
    im_line = im.copy()
    warp = None
    flag = 1
    # 以下のループで抽出された輪郭を描画する
    for c in cnts[1:]:
        arclen = cv2.arcLength(c, True)
        approx = cv2.approxPolyDP(c, 0.02*arclen, True)
    # 輪郭を少ない点で表現(臨界点は0.02*arclen)
        if len(approx) == 4:
            cv2.drawContours(im_line, [approx], -1, (0, 0, 255), 2)
            if flag: # 1番面積が大きいものがレシートの輪郭だと考えられるのでその輪郭情報を保存
                warp = approx.copy()
                flag = 0
        else:
            cv2.drawContours(im_line, [approx], -1, (0, 255, 0), 2)
        for pos in approx:
            cv2.circle(im_line, tuple(pos[0]), 4, (255, 0, 0))
    # レシートと思われる輪郭の面積を算出。
    # 正しくレシートの輪郭を認識できないことがあるため、元の画像に対してある一定以上の大きさでないとトリミングをしないようにした。
    area = cv2.contourArea(warp)
    print("area = ", area)
    if area > im_size//5:
        print("now cutting....")
        im_rect = transform_by4(im, warp[:, 0, :])
        cv2.imwrite(filename + '_rect.jpg', im_rect)
    else:
        return im
    # 切り取った画像の表示
    plt.figure()
    plt.imshow(im_line)
    cv2.imwrite(filename + '_line.jpg', im_line)
    print("warp = \n",warp[:, 0, :])
    print(filename + '_rect.jp')
    return im_rect


def convert(filename = None, capture = False, CUT=False):
    if filename == None and capture == False:
        pass
    elif capture == True:
        # Webカメラで読み込むこともやりたかったがUbuntuがうまく認識してくれず、断念。
        cap = cv2.VideoCapture(0)
    elif filename:
        im = cv2.imread(filename)
    filename = filename[:-4]
    # 拡張子を取り除いた形で記録する
    if CUT:
        im = cont_edge(im, filename)
    im_rect_gray = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY)
    print(filename+'_rect_gray.jpg')
    im_rect_blur = cv2.fastNlMeansDenoising(im_rect_gray)
    im_rect_th = cv2.adaptiveThreshold(im_rect_blur, 255, \
                                      cv2.ADAPTIVE_THRESH_MEAN_C,\
                                      cv2.THRESH_BINARY, 63, 20)

    rect_th_filename = "{:s}_rect_th.jpg".format(filename)
    cv2.imwrite(rect_th_filename, im_rect_th)
    print(rect_th_filename)
    show_img(im_rect_th)
# 既存のoutput.txtファイルが存在すればそれを消去して新たにoutput.txtを作成
# 文字認識についてはGoogleのAPIを利用させてもらった。
    if glob.glob('output.txt'):
        os.remove('output.txt')
    # 以下のコメントを外せばtesseractでの実行もできる。
    # tesseractのインストールは
    # rect_th_filename = "{:s}_rect_th.jpg".format(filename)
    # os.system("tesseract {:s} output -l jpn".format(rect_th_filename))
    google_api_ocr.detect_text(rect_th_filename)

このプログラムをreceipt_read.pyの名前で保存します。

レシートの文字を読み込む

ここの部分はGoogleのAPIを大いに頼らせていただきました。
無料で使えるものとして tessearact というものがあるのですが、Googleの提供するOCRのAPIのほうが段違いに精度良く読み取れたのでこちらを採用。
tessearactのインストールは以下のページを参考に行いました。
Ubuntuにtesseract-ocrをインストール – Qiita
余裕があればこのあたりも学習して読み取るようにできたらいいと思っています。

以下のサイトのコードを流用しました。

【Python X OCR】Google Vision APIを利用して、表形式の画像からテキストを抽出してみた – Qiita

import base64
import json
import requests
import os
import glob
def detect_text(path):
    with open(path, 'rb') as image_file:
        content = base64.b64encode(image_file.read())
        content = content.decode('utf-8')

    api_key = "Googleから入手したAPI_KEY"
    url = "https://vision.googleapis.com/v1/images:annotate?key=" + api_key
    headers = { 'Content-Type': 'application/json'}
    request_body = {
        'requests' : [
        {
             'image': {
                  'content': content
             },
             'features':[
             {
                 'type': 'TEXT_DETECTION',
                 'maxResults': 10
             }
          ]
      }
    ]
    }
    response = requests.post(
        url,
        json.dumps(request_body),
        headers
    )
    result = response.json()
    print(result['responses'][0]['textAnnotations'][0]['description'])
    if glob.glob('output.txt'): # 毎回同じファイルに出力するのでもし前回のファイルが残っていたら削除する
        os.remove("output.txt")

    f = open("output.txt", "w")  # 読み込んだ文字データをoutput.txtに書き込む
    f.write(result['responses'][0]['textAnnotations'][0]['description'])
    f.close()

このプログラムを google_api_ocr.py の名前で保存しておきます。

食材一覧のテキストデータを用意する

文部科学省のサイトから食材と栄養素一覧のエクセルデータを入手しました。

ここの、第2章 日本食品標準成分表 Excel(日本語版) ページにある一括ダウンロードにあるファイル(リンクをクリックすればダウンロードできます)を

syokuzai.xlsx

の名前で先ほどまでのプログラムと同じディレクトリにおきます。
これをPandasを使って食材データだけを抜き出しました。
それからレシートの印字にはカタカナだけのものも混ざっているので食材の名前をひらがなに変換したものとカタカナに変換したもの両方を入れました。

このひらがなとカタカナの変換にはjavacというものを使いました。

$ pip install jaconv 

でインストールできます。
jaconv
コマンドも簡単だったし、すごい便利です。

以下のコマンドをPython3ののインタラクティブシェルで実行すると食材のリストであるfood_list.txtが出力されるようになっているはずです。


import pandas as pd import re import jaconv data = pd.read_excel("syokuzai.xlsx", skiprows=[0, 1, 2, 3, 4, 5, 6]) # エクセルファイルのいらないところはとばす a = data.iloc[:, 3] # 記号を取り除き、ひらがなとカタカナに変換した食材を入れる syokuzai_list = set() for i in range(len(a)): tmp = re.sub(r'[<>\[\]\(\)]',' ', a[i]) tmp = tmp.split() for j in range(len(tmp)): tmp1 = jaconv.hira2kata(tmp[j]) tmp2 = jaconv.kata2hira(tmp[j]) syokuzai_list.add(tmp1) syokuzai_list.add(tmp2) f = open('food_list.txt', 'w') # food_list.txtというファイル名で保存 for x in f_list: f.write(str(x) + "\n") f.close()

CookPadで検索する

requestslxmlを用いたクローリングとスクレイピングでCookPadのレシピの

  • レシピ名
  • 食材
  • 作り方
  • 料理の写真

の4つの情報を抜き出します。
requestslxmlもPythonのpipでインストールできます。

以下のファイルをreceipt_tabelog.pyの名前で保存します。
CookPadなのにtabelogの名前で保存してしまいましたが、そこは気にしません。

import numpy as np
import os
import cv2
import receipt_read
import matplotlib.pyplot as plt
import re
import sys
pic_name = sys.argv[1]


receipt_read.convert(pic_name, CUT=True)
# レシートデータから文字データを抽出する。出力ファイルは`output.txt`

# あらかじめ作っておいたfood_list.txtを呼び出す。
f = open('food_list.txt')
data1 = f.read()
f.close()
lines = data1.split('\n')
filename = "output.txt"
f = open(filename)
data2 = f.read()
receipt_data = data2.split()
print("read data txt is \n ",receipt_data)
# レシートから読み込んだ文字列を表示
search_words = []

# 食材リストと照らし合わせてリストに照合するものがレシートのデータに存在すれば
# その食材をsearch_wordsに加える
for word in lines:
    for receipt in receipt_data:
        if word in receipt:
            search_words.append(word)
            print("True")

print(search_words)
search_words.sort(key=len, reverse=True)
search_words = search_words[:3]
print("search_words is ....", search_words, "\n")
url = "https://cookpad.com/search/{:s}%E3%80%80{:s}%E3%80%80{:s}".format(search_words[0],
                                                     search_words[1],search_words[2])
# ここからスクレイピング
import requests
import lxml.html
import os
import cv2
import matplotlib.pyplot as plt


def save_image(filename, image):
    with open(filename, "wb") as fout:
        fout.write(image)

# 3つの検索ワードで検索する場合、このようなURLになる。
url = "https://cookpad.com/search/{:s}%E3%80%80{:s}%E3%80%80{:s}".format(search_words[0],
                                                     search_words[1],search_words[2])
# レシピ検索のhtmlを取得
response = requests.get(url)
root = lxml.html.fromstring(response.content)
root.make_links_absolute(response.url)
url_list = []
# 検索の上位にあるレシピのurlを獲得する
for a in root.cssselect('a.recipe-title'):
    url = a.get('href')
    url_list.append(url)

response = requests.get(url_list[0])
root = lxml.html.fromstring(response.content)
recipe_title = root.cssselect("title")[0].text_content()
print(recipe_title, "\n")
ingridient_name = root.cssselect("span.name")
ingridient_amount = root.cssselect("div.ingredient_quantity")
step = root.cssselect("p.step_text")

## os.system("wget {:s} -o picture.jpg".format(src))

print("必要な材料")
for i in range(len(ingridient_name)):
    print(ingridient_name[i].text_content() + "\t" + ingridient_amount[i].text_content())
print("作り方")
for i in range(len(step)):
    print(i, ",\t",step[i].text_content())

# 画像ファイルの読み込みと表示
src = root.cssselect("#main-photo > img")[0].get('src')
src = re.findall(r'https://.+\.jpg', src)[0]
img_response = requests.get(src, timeout=10, stream=False)
name_search = re.findall(r'\/([a-zA-Z0-9:.=_-]*jpg|jpeg|JPG|JPEG)', src)
img_name = name_search[0]
save_image('./pictures/' + img_name, img_response.content)
recipe_image = cv2.imread("./pictures/"+img_name)

plt.imshow(recipe_image[:,:,::-1])

plt.show()

実行結果

実行結果は以下のようになります。

無事、レシピを表示させることができました。

今後の課題

  • メインでMacOSを使っているので、まずはそこで動くように環境を整えたい。
  • まだまだやることは多いが、まずはカメラからレシートを読み込めるようにして、色々な画像を作っていく。
  • 検索ワードの設定の仕方、レシートの輪郭の抽出がまだまだ不十分なのでパラメーターをうまく調整してもっと精度よく 文字データを抽出したい。
Pocket