あきろぐ

いろいろめもするよ🐈🐈🐈

Google Cloud Vision APIとOpenAIのFunction Callingを使ってみた

今回はGoogleのCloud Vision APIを使って画像からテキストを検出し、そのテキストとOpenAIのFunction Callingを用いて構造化データを抽出していきたいと思います。

Cloud Vision API

Googleが提供している画像を分析し情報を抽出できるサービスです。OCRによって画像からテキストを検出したり、ランドマークやロゴなども検出することが可能となっています。

cloud.google.com

テキスト検出の場合、最初の1000ユニット(画像)までは無料で使えます。

cloud.google.com

OpenAI Function Calling

OpenAIが提供しているChat APIに追加された機能です。

APIを呼び出すときに関数を定義することで構造化されたデータ、つまりJSONオブジェクトとしてレスポンスを受け取ることができます。

自分達のアプリケーションコードに組み込む時にJSONオブジェクトとしてレスポンスを受け取ることができれば、何かと便利ですよね。

https://platform.openai.com/docs/guides/function-callingplatform.openai.com

コストは使用するモデルによって異なりますが、InputとOutputの両方でコストがかかります。(1000トークンあたり)

openai.com

トークンはOpenAIの自然言語モデルでテキストを処理するときの単位のようなもので、以下のサイトからトークン数を確認することができます。

platform.openai.com

日本語の場合、ひらがなやカタカナは1文字=1トークンですが、漢字は2-3トークン、絵文字は3トークンほど消費するようです。

Cloud Vision APIOCR処理を実装する

では、実際にOCRでテキスト検出するためのコードをPython3.9で書いていきます。

まずCloud Visionクライアントライブラリをインストールし、Google Cloud上でサービスアカウントを発行し、APIキーをJSONファイルとしてローカルにダウンロードします。

サービスアカウントの発行手順等はこちらの記事は分かりやすかったです。

dev.classmethod.jp

$ pip install --upgrade google-cloud-vision

クレデンシャルファイルのパスを環境変数として設定します。

# .zshrc
export GOOGLE_APPLICATION_CREDENTIALS='<json_file_path>'

今回テキスト検出する対象は、私の好きな長谷川あかりさんのこちらのレシピ画像を使ってみました。(このレシピお気に入りの1つです)

実際に書いたコードはこちら。

from google.cloud import vision

client = vision.ImageAnnotatorClient()
file_paths = [
    '/Users/user_a/Downloads/recipe01.jpeg',
    '/Users/user_a/Downloads/recipe02.jpeg',
    '/Users/user_a/Downloads/recipe03.jpeg',
    '/Users/user_a/Downloads/recipe04.jpeg'
]

output_text = ''

for file_path in file_paths:
    with open(file_path, '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']}
    )
    output_text += response.full_text_annotation.text + '\n'

print(output_text)

出力結果はこちら。

パッケージの細かい文字まで検出してくれたようです。画像にない文字も検出されていることもありますが、これだけの精度で検出されれば問題ないですね。

ごはんに合う!
鶏むね肉とごぼうの柚子胡椒クリームシチュー
※2人分
鶏むね肉150g (1cmくらいの角切り)
ごぼう150g (四つ割りにして端から1cmに切る)
小麦粉大さじ2
バター10g
料理酒大さじ2
牛乳200ml
柚子胡椒小さじ1
塩適量
BAR
elna
000
100
日清
クッキング
粒 フラワー
薄力小麦粉
タイプの
M
Oxx
NET150g
柚子こしょう
24.10/8/201
S&B
牛乳
明治
ナチュラルテイスト
生乳100%使用
おいしい牛乳
新鮮な生乳のおいしさ、そのまま
要冷蔵(10℃以下)
450ml
450ml
鶏むね肉に
塩ひとつまみ (1g)を
ふって馴染ませたら、
小麦粉を満遍なくまぶす
1
水150mlと料理酒を加え、
煮立ったら鶏肉を加えて
よく混ぜる。
蓋をして弱めの中火で
5分煮込む。
フライパンに
バターを加えて
中火で溶かし
ごぼうを入れる。
塩ひとつまみをふって、
ごぼうのいい香りが
してくるまで炒める
2
4
牛乳と柚子胡椒を加えて
とろみがつくまで
強めの中火でおよそ
1分30秒~2分ほど煮詰めたら、
火を止める。
塩少々で味をつけたら完成。
※味を見て足りなければ
塩で調える。
ごぼうの香りとしっとり鶏むね肉、
文句なしのおいしさ!
柚子胡椒の塩気でごはんが進みます。

Function Callingで構造化データを抽出する

まず、必要なOpenAIのPythonライブラリをインストールします。

$ pip install --upgrade openai

APIキーを発行し、環境変数として設定します。

# .zshrc
export OPENAI_API_KEY='your-api-key-here'

OCRで得られたテキストデータを用いて、以下のようにFunctionを定義して構造化データを出力しています。

Functionの定義はこちらの記事を参考にしました。

gihyo.jp

import json
from openai import OpenAI

client = OpenAI()

# テキストデータからどのような構造化データを返してほしいか定義
functions = [
    {
        "name": "recipe_ingredients",
        "description": """これはテキストデータからレシピの情報を抽出する処理です。
        レシピ名、レシピに必要な材料名、材料の種類、材料の量を抽出します。
        """,
        "parameters": {
            "type": "object",
            "properties": {
                "recipe_name": {
                    "type": "string",
                    "description": "レシピ名"
                },
                "ingredients": {
                    "type": "array",
                    "description": "レシピに必要な材料一覧",
                    "items": {
                        "type": "object",
                        "properties": {
                            "name": {
                                "type": "string",
                                "description": "材料名"
                            },
                            "type": {
                                "type": "string",
                                "description": "材料の種類"
                            },
                            "amount": {
                                "type": "string",
                                "description": "材料の量"
                            }
                        }
                    }
                }
            },
            "required": ["recipe_name"]
        }
    }
]

# ここで渡すテキストデータを指定
# 先ほど出力したテキストデータを読み込む
messages = [
    {
      "role": "user",
      "content": output_text
    }
]

# openaiにモデル、function、messagesを指定しリクエスト送る
response = client.chat.completions.create(
  model="gpt-3.5-turbo-1106",
  messages=messages,
  functions=functions,
  temperature=1,
  max_tokens=2024,
  function_call={"name": "recipe_ingredients"}
)

# 構造化データ表示
str = response.choices[0].message.function_call.arguments
json_str = json.dumps(str)
print(json.loads(json_str))

結果はこちら。正確にレシピ名と材料を認識し、JSONオブジェクトを返してくれました! これはいいですね!

{
  "recipe_name": "鶏むね肉とごぼうの柚子胡椒クリームシチュー",
  "ingredients": [
    {
      "name": "鶏むね肉",
      "amount": "150g",
      "type": "肉"
    },
    {
      "name": "ごぼう",
      "amount": "150g",
      "type": "野菜"
    },
    {
      "name": "小麦粉",
      "amount": "大さじ2",
      "type": "調味料"
    },
    {
      "name": "バター",
      "amount": "10g",
      "type": "調味料"
    },
    {
      "name": "料理酒",
      "amount": "大さじ2",
      "type": "調味料"
    },
    {
      "name": "牛乳",
      "amount": "200ml",
      "type": "乳製品"
    },
    {
      "name": "柚子胡椒",
      "amount": "小さじ1",
      "type": "調味料"
    },
    {
      "name": "塩",
      "amount": "適量",
      "type": "調味料"
    }
  ]
}

期待以上の結果が得られたので、とても満足しました。様々な場面で活用できそうです。

/etc/environmentで環境変数を読み込む場合の制約について

1024bytesを超える環境変数は値が切り捨てられる

/etc/environment環境変数を設定する場合、値に入れる文字数が多いと正しく読み込まれないことがあります。具体的にいうと1024bytesを超える値を設定しようとすると切り捨てられ、ログファイルには以下のようなエラーが吐かれるようになります。

$ sudo tail -f /var/log/secure | grep pam_
Jan 17 07:23:04 ip-xxx sudo: pam_unix(sudo:session): session opened for user root by (uid=0)
Jan 17 07:23:04 ip-xxx su: pam_unix(su-l:session): session opened for user ec2-user by (uid=0)
Jan 17 07:23:16 ip-xxx CROND[30553]: pam_env(crond:setcred): non-alphanumeric key 'YwdvO1GIK7uTSudiYcUn' in /etc/environment', ignoring

sourceコマンドを用いて手動で/etc/environmentを読み込む場合は1024bytesを超える環境変数でも問題なく設定でき呼び出すことができるため、特に制約がないように見えます。

しかし、ユーザーログイン時にPAMによってユーザー認証が行われる際、pam_env.soというPAMモジュールが環境変数の初期設定のために呼びだされ、環境変数を読み込んでいますが、古いバージョンのPAMモジュールだと環境変数の長さの制約(1024bytes)があるため、この値を超える環境変数は切り捨てられてしまいます。

切り捨てられると上記のようにnon-alphanumeric keyとしてログに書き込まれるようです。

この問題についてはlinux-pamのレポジトリにIssueが上がっていました。

BUF_SIZEという変数で/etc/environmentに設定できる環境変数の長さが制限されているようです。

github.com

BUF_SIZEは以下のコミットで8192まで上限が上げられているため、PAMのバージョンが1.5.3以降であれば問題なさそうです。

github.com

PAMのバージョンは、以下のコマンドを使って確認できます。

$ rpm -qa | grep pam

回避策

v1.5.3未満のPAMが使われているLinuxの場合の回避策としては、以下2つが挙げられるかなと思います。

  1. BUF_SIZEの値を変更してPAMをリビルドする
  2. /etc/environment以外のファイルに環境変数を設定する

1に関しては、リビルドする方法がこちらに記載されています。Ubuntuを使用しているのであればこちらのリンクのスクリプトを用いることができそうです。

unix.stackexchange.com

2に関しては、私が実際に試した方法となります。

例えば、.bash_profileに長い環境変数を保存しておき、アプリケーション等で.bash_profileに記載された環境変数が読み込まれるようにしておけば、上記の問題は回避することが可能です。

AWS LambdaでExifToolを使う Ruby編

何をしようとしたか

Lambda上で動画や画像のメタデータ取得するためにExifToolのRubyラッパーツールを導入しました。 しかし、ExifToolをLambda上で実行するには一筋縄ではいかなかったので、その解決方法をまとめてみます。

環境

  • AWS Lambda
  • Ruby3.2
  • serverless framework

ExifToolをRubyで扱うために以下のgemを使用しました。

github.com

発生した問題:Exiftoolの実行ファイルが存在しないと怒られる

READMEを参考にGemfile内にexiftool_vendoredを追加した後、

gem 'exiftool_vendored'

以下のコマンドで必要なgemを指定パスにインストールしました。

bundle install --path

Exiftoolを使用して動画や画像のメタデータを取得するコードを記述します。

# sample code
require 'exiftool_vendored`

exiftool = Exiftool.new("tmp/test.jpg").to_hash
# 画像の縦横の長さを取得
width = exiftool[:width]
height = exiftool[:height]

serverless frameworkを用いてLambdaにデプロイし、実際にLambdaを実行すると、以下のようなExiftool::ExiftoolNotInstalledエラーが発生します。

Exiftool::ExiftoolNotInstalled
"/var/task/vendor/bundle/ruby/3.2.0/gems/exiftool-1.2.4/lib/exiftool.rb:60:in `initialize'"
...
...
...

該当するソースコードを参照するとcmdの中身が空である場合ExiftoolNotInstalledエラーになっているようです。

    # I'd like to use -dateformat, but it doesn't support timezone offsets properly,
    # nor sub-second timestamps.
    cmd = "#{self.class.command} #{exiftool_opts} -j -coordFormat \"%.8f\" #{escaped_filenames} 2> /dev/null"
    json = `#{cmd}`.chomp
    raise ExiftoolNotInstalled if json == ''

https://github.com/exiftool-rb/exiftool.rb/blob/011d12b4f51e0e86a0d73c43ba2efe5138b6d698/lib/exiftool.rb#L60

Exiftool.commandでExiftoolの実行ファイルパスを出力してみましたが、以下のように実行ファイルパスが返ってきますし、実行ファイルはLambdaにアップロードしたファイル群にも含まれています。

"/var/task/vendor/bundle/ruby/3.2.0/gems/exiftool_vendored-12.68.0/bin/exiftool"

しかし、Exiftool.exiftool_installed?を実行するとfalseが返ってくる謎の状態となっていました。

原因

ExifToolは、Perlライブラリであり実行環境にPerlがインストールされていないといけません。すなわち、Lambdaの実行環境(ランタイムがRubyの場合)にはデフォルトでPerlが入っていかなったため上記のエラーが発生していました。

ExifTool is a platform-independent Perl library plus a command-line application for reading, writing and editing meta information in a wide variety of files.

exiftool.org

そのため、ExifToolを実行するにはLambda上にPerlをインストールする必要があります。

解決方法

今回は、Lambda Layerを使ってPerlとExiftoolのレイヤーを作成しました。レイヤーの作成方法は以下のブログを参考にしました。

dev.to

上記のブログと異なるのは、デプロイするリージョンはTokyoリージョンなので、Tokyoリージョンのパブリックレイヤー(arn:aws:lambda:ap-northeast-1:445285296882:layer:perl-5-38-runtime-al2-x86_64:2)を使用しました。

shogo82148.github.io

serveless.ymlには対象のhandlerにlayersを追加すればOKです。

    handler: handler.main
    layers:
      - arn:aws:lambda:ap-northeast-1:445285296882:layer:perl-5-38-runtime-al2-x86_64:2 # public layer
      - arn:aws:lambda:ap-northeast-1:<account_id>:layer:exiftool:1 # custom layer

そして、ExifToolを実行するために参照するパスをExiftool.commandで指定しておきます。

# sample code
require 'exiftool_vendored`

#exiftoolはperlが必要なためlambda layer上のexiftoolを指定
Exiftool.command = '/opt/bin/perl /opt/bin/exiftool' 

exiftool = Exiftool.new("tmp/test.jpg").to_hash
# 画像の縦横の長さを取得
width = exiftool[:width]
height = exiftool[:height]

この状態でLambdaをデプロイ&実行すれば、問題なく画像のメタデータを取得することができました。

参考文献

対象日から過去1週間のユニークユーザー数を出したい

何かしらのイベントのデータ分析をする際に、特定イベントのデイリーのユニークユーザー数を出すだけでなく、その日から過去1週間のユニークユーザー数を出したい場合があるかと思います。 例えば、2023-04-16のユニークユーザー数と2023-04-16から過去1週間のユニークユーザー数(2023-04-09~2023-04-16)を出したいという場合などです。

このようなケースで、どのようにクエリを書けば求めたい数値が出せるか悩んだので、備忘録的に残しておきます。

環境

  • ツール:Redash
  • データソース:Snowflake

データ構造

シンプルに以下のようなデータ構造であった場合を想定します。

イベント日 (event_date) ユーザーID (user_id) イベント名 (event_name)
yyyy-mm-dd xxxx-xxxxx-xxxx test_event1
yyyy-mm-dd yyyy-yyyyy-yyyy test_event1
yyyy-mm-dd zzzz-zzzzz-zzzz test_event2

解決策

対象日から過去1週間のデータを引っ張るために、SnowflakeDATEADD関数を使います。

docs.snowflake.com

-- with句を使ってサブクエリを書く

WITH target_events AS (
  SELECT
    user_id,
    event_date
  FROM
    events
  WHERE
    event_name = 'test_event1'
),
target_dates AS (
  SELECT
    event_date
  FROM
    events
  GROUP BY
    event_date
)

-- target_datesのevent_dateカラムの値を使ってUUを算出
SELECT
  event_date,
  (SELECT COUNT(DISTINCT user_id) FROM target_events WHERE event_date = t.event_date) AS daily_uu, -- デイリーのユニークユーザー数
  (SELECT COUNT(DISTINCT user_id) FROM target_events WHERE event_date between DATEADD(day, -(7), t.event_date) and t.event_date) AS weekly_uu -- ウィークリーのユニークユーザー数(過去1週間なので-7)
FROM
  target_dates t
WHERE
  t.event_date between '{{ start_date }}'
  and '{{ end_date }}' -- redashのパラメータを埋め込む

もっとシンプルなクエリが書けそうではありますが、これで取得したいデータを得ることができます。

イベント日 daily_uu weekly_uu
yyyy-mm-dd 100 300
yyyy-mm-dd 200 400
yyyy-mm-dd 300 500