はじめに
個人開発のアプリでパンの消費期限をカメラで読み取りたいと考えOCR機能の実装にチャレンジしました。
開発環境
Ruby 3.2.2
Rails 7.1.6
デプロイ:Render
OCRとは
OCRとはOptical Character Recognition(またはReader)の略。
日本語では「光学的文字認識」と訳されます。
入力、手書き、印刷されたテキストを画像からマシンでエンコードされたテキストに変換する基本的な技術のことです。
OCRには Google Cloud Vision API を採用しました。
理由は、日本語OCRの精度が高く、APIとしてすぐ利用でき、短期間で安定した機能実装ができると判断したためです。
Cloud Vision APIによるOCR機能の実装
やりたいこと
流れ
撮影した画像をPOST
↓ ①受け取る(Controller)
Google Vision API で画像内の文字を抽出
↓ ②文字を読む(Service)
ノイズだらけのテキストから「日付だけ」を取り出す
↓ ③日付を抜き出す(Service)
日付をJSONで返し、入力欄に自動セット
実装内容
CloudVision導入
gemをインストール
gem "google-cloud-vision"
Google Cloud Consoleからダウンロードしたサービスアカウントの JSON鍵ファイル を、Railsプロジェクト内に配置します。(config/google-vision.json)
注意!鍵なのでGitHubに上げないこと
.gitignoreに追加して除外します。
鍵の渡し方(ローカルと本番で違う)
ここが少し躓きました。
本番環境(Render)では、Gitに含めていない秘密鍵ファイルは存在しません。
そのため、JSONの中身を環境変数に保存し、起動時に一時ファイルとして書き出して利用しています。
ローカル(Docker) ファイルがあるので、その場所を環境変数で指定します。
environment:
GOOGLE_APPLICATION_CREDENTIALS: /app/config/google-vision.json
本番(Render) ファイルが置けないので、JSONの中身そのものを環境変数に入れておき、起動時に一時ファイルへ書き出して使います。
GOOGLE_CREDENTIALS_JSON=(JSON鍵の中身をまるごと貼り付け)
controller
画像を受け取りOcrServiceに渡します。
expiration&.strftime("%Y-%m-%d")は、
日付データを「年(4桁)-月(2桁)-日(2桁)」という文字列に並び替え、もし nil だったら、右側の命令(strftime)は無視して、そのまま nil を返す という意味です。
class OcrsController < ApplicationController
skip_before_action :verify_authenticity_token, only: [:create]
def create
image = params[:image]
return render json: { error: "画像がありません" }, status: :bad_request if image.nil?
expiration = OcrService.extract_expiration(image.tempfile.path)
render json: { expiration: expiration&.strftime("%Y-%m-%d") }
end
end
OcrServiceクラスを作成し実装
services/ocr_service.rbを作成します。
ここにOCR結果をどう処理するかのロジックを書いていきます。
まず画像を渡し文字に変換するよう依頼します。
response = vision.text_detection image: image_path
これで画像に写っている文字を読み取り返してくれます。
画像

読み取り結果

おもしろい笑
文字は返ってきましたが、
完璧に読み取るのは難しいみたいです。
このパターンは肝心な消費期限の日付が読み取れておらず、この時点で失敗です![]()
今回は読み取れた前提で、次に進みます。
キーになるのは「消費期限」というテキストです。
その周辺に日付があればそれが消費期限である可能性が高いからです。
一応「消費」「賞味」という文字があればその行を優先してみるようにしました。
それから日付の表記は上の写真のように26.4.1のような表記か2026.4.1のような表記なので正規表現を使って抜き出します。
これもパンによっては微妙に表記が違うので対応できるようにするのが苦労しました。
#リクエスト部分省略してます
#消費期限っぽいものを選ぶ(候補から確定する)
def self.extract_best_date(text)
lines = text.split("\n")
# 消費期限がある行を優先
target_line = lines.find { |line| line.match?(/消費|賞味/) }
# なければ全体
target_line ||= text
# 余計な空白を削除
target_line = target_line.gsub(/\s+/, "")
dates = extract_dates(target_line)
parsed_dates = dates.map { |d| normalize_date(d) }.compact
return nil if parsed_dates.empty? #エラー回避、該当なしの場合は読み取り終了
# 消費期限は基本「これから来る日付」なので、今日以降を優先する
future_dates = parsed_dates.select { |d| d >= Time.zone.today }
return future_dates.min if future_dates.any?
parsed_dates.min # 未来日がなければ一番近い日付
end
#日付っぽい数字を探す(消費期限候補)
def self.extract_dates(text)
patterns = [
/\d{2}\.\d{1,2}\.\d{1,2}/, #2桁と1桁両方に対応できるよう修正
/\d{4}[\/\.\-]\d{1,2}[\/\.\-]\d{1,2}/
]
patterns.flat_map { |p| text.scan(p) }
end
抜き出した日付は、まだただの「文字(テキスト)」です。これを「日付」として扱える特別なデータ(Date型)に変換します。
def self.normalize_date(date_str)
if date_str =~ /^\d{2}\.\d{2}\.\d{2}$/
Date.strptime(date_str, "%y.%m.%d")
else
Date.parse(date_str)
end
rescue ArgumentError
nil
end
まとめ
CloudVisionAPI を使用してOCR機能を実装することができました!
APIの導入で、画像からテキストが返ってきて感動しました。
ただパンの種類によっては読み取りに時間がかかったり、エラーになったりしてしまいます。
精度としてはまだまだ改良の余地があるので色々試してみて改良していきます!
参考記事
