2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Cloud Vision API + ChatGPT APIを使用して画像にコメントするLINEボットを作る

Posted at

はじめに

ChatGPT API関連の記事は玉石混淆で見飽きた方もいらっしゃるかもしれないですが、テキストしか入力を受け付けないChatGPT APIに対して画像を無理やり認識させて(正確にはランドマークやラベルを抽出して渡して)、その画像に対してコメントをするLINEボットを作りましたのでメモとして残します。
現時点で何か活用できるようなものではないですが、プロンプトとアイディア次第では何かに活用できるかも?しれないで作り方を載せておきます。
(今後、GPT-4が画像を添付できる機能を正式に提供されたら必要なくなるのですが...)

対象ユーザー

言語はTypeScript(実行環境はNode.js)を使用するため、JavaScriptやTypeScript、Node.jsの開発経験がある方がコードを理解しやすいと思います。
また、AWSやGoogle Gloud、LINE、OpenAIのサービスを使用するため事前に登録をしておいてください。
一部コマンドを載せていますがmacOS上でのみ確認しています。Windowsの場合は多少異なるかもしれませんのでご注意ください。

システム構成

ユーザーとのインターフェースにはLINE(バックエンドはMessaging API)を使用し、このMessaging APIが受け取ったメッセージのイベント通知先(webhook)にはAmazon API Gateway + AWS Lambdaを使用します。
また、ユーザーはLINEを使用して画像を送りますが、このユーザーが送った画像を解析するためにGoogle CloudのCloud Vision APIを使用します。
このCloud Vision APIが画像からランドマークやラベルを抽出してくれるので、このランドマークやラベルをChatGPT APIに渡し、まるでChatGPT APIが画像を見ているかのようなコメントを返してくれる仕組みです。

なお、AWS Lambdaの関数のコードはローカルで開発したいのでAWS CDKを、言語はTypeScriptを使用します。

Untitled Diagram.drawio.png

各コンポーネントの紹介

有名なものも多いですが、システムの各コンポーネントについて簡単に紹介します。

Messaging API

LINEユーザーとの双方向コミュニケーションをするための機能を提供します。
お試しでやるにはフリープランで十分で、公式のSDKも提供されています。

AWS Lambda

以下、Lambdaと表記します。
サーバーレスでイベント駆動型の機能を提供するサービスです。関数と呼ばれる単位で作成し、実行トリガーによりこの関数が実行されます。
今回の実行トリガーはAPI Gatewayとなります。

Amazon API Gateway

以下、API Gatewayと表記します。
Messaging APIのWebhookにはインターネットに公開されているエンドポイントの設定が必要です。
そのため、このAPI Gatewayを介してLambdaの関数を実行します。

AWS CDK

以下、CDKと表記します。
プログラミング言語を使用してAWSのリソースを定義するIaCツールです。
以前までLambdaの関数を作成するにはAWSマネジメントコンソール上でコーディングするか、ローカルで開発したものをzipで圧縮してアップロードするなど非常に手間がかかりましたが、このCDKによりローカルで開発したものをコマンド1つでデプロイできるようになりました。
Lambda用のIaCにはsstなど他のサービスもありますが、公式で提供されており(デバッグなどしなければ)機能的にも十分なのでCDKを使用します。

Cloud Vision API

以下、Vision APIと表記します。
Googleが提供しているVision AIというサービスをAPI経由で利用できるようにしたものです。
事前トレーニング済みのMLモデルを使用して画像からランドマークやラベルの抽出ができます。

事前準備

LINEチャンネルの作成

  1. LINE Developers( https://developers.line.biz/ja/ )にログイン(新規の場合はアカウント登録)し、コンソールを表示します。
  2. プロバイダーを作成するか既存のプロバイダーを選択します。
  3. 「新規チャンネル作成」を選択し、「Messaging API」を選択します。
  4. 説明に沿ってチャンネル名やアイコンを設定し、規約に同意したら「作成」を選択してください。
  5. 「チャンネル設定」タブに表示されるチャンネルシークレットは後から使用しますのでメモしてください。
  6. 「Messaging API設定」タブに移動し、「応答メッセージ」の編集を選択するとLINE Official Account Managerに遷移するので、 そこで以下のように「あいさつメッセージ」と「応答メッセージ」を無効化し、「Webhook」を有効化してください。
    image.png
  7. 再びLINE Developersの「Messaging API設定」タブに戻ったらチャンネルアクセストークンを発行してください。チャンネルシークレット同様、このチャンネルアクセストークンも後から使用します。

OpenAI のAPIキー取得

OpenAI にログイン(新規の場合はアカウント登録)し、 https://platform.openai.com/docs/quickstart/build-your-application に移動したら「+ Create new secret key」ボタンがあるのでそこからAPIキーを発行してください。後から使用します。

Docker Desktop のインストールと起動

CDKを実行する際にDocker Desktopが必要です。(Docker Desktop無しで実行する方法もありますが、個人開発する分にはDocker Desktopを利用して実行した方が早いです。)
以下の手順でインストールできますので、インストール後に起動してください。

AWS CLIのインストール

下のリンクを参考にインストールしてください。

macOSでは以下です。

$ curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
$ sudo installer -pkg AWSCLIV2.pkg -target /
$ aws --version
aws-cli/2.11.5 Python/3.11.2 Darwin/21.3.0 exe/x86_64 prompt/off
# AWS IAMからアクセスキーを作成(IAMの作成などの説明は省略します)
$ aws configure

Vision APIを使用するためのプロジェクトを作成

説明が長くなるため、https://cloud.google.com/vision/docs/setup?hl=ja を参考のプロジェクトの作成と課金やAPIの有効化をしてください。
コンソール上で操作するだけであれば Google Cloud CLI のインストールは必須ではないと思います。
認証については次に説明します。

Workload Identity を使用してAWS IAMロールでVision APIの利用を許可する

Google Cloudのサービスアカウントキーを作成してLambdaに持たせることでLambdaからGoogle Cloudのサービスを実行することが可能ですが、なるべくセキュアなファイルを持ちたくないので今回はパスワードレスに使用できるWorkload Identityを使用します。
このWorkload Identityは、外部IDプロバイダによって発行された認証情報(今回はAWSのIAMロール)を使用して、サービスアカウントの権限を借用し、Google Cloudのリソースにアクセスできるようにするためのものです。

なお、認証については意図せずリソースにアクセスされてしまう可能性があるので、以下のページを確認しておくことをおすすめします。

https://cloud.google.com/resource-manager/docs/creating-managing-projects?hl=ja
https://cloud.google.com/docs/authentication?hl=ja

簡単にですが手順を載せます。

  1. https://cloud.google.com/iam/docs/service-accounts-create?hl=ja を参考にIAM APIを有効化し、Google Cloudコンソール上でサービスアカウントを作成します。「このサービス アカウントにプロジェクトへのアクセスを許可する」でロールに「Vision AI閲覧者」を設定してください。「ユーザーにこのサービス アカウントへのアクセスを許可」は指定不要です。
    (※ ロールを指定する部分についてGoogleのチュートリアル上は記載は見当たりませんでしたが、指定をしないとリクエストに失敗しましたのでVision AIの閲覧用ロールのみ付与しています。筆者はGoogle Cloudに明るくないので説明に不備があったらご指摘いただければと思います。)
    作成すると以下のようにメールアドレスが表示されます。
    スクリーンショット 2023-04-08 2.37.50.png
  2. https://cloud.google.com/apis/docs/getting-started?hl=ja#enabling_apis を参考に、プロジェクトを選択してからVision APIを有効化してください。
    image.png
  3. https://cloud.google.com/iam/docs/configuring-workload-identity-federation?hl=ja#aws を参考に必要なAPIを有効にして、Workload IdentityプールとWorkload Identityプールプロバイダを作成してください。プロバイダにはAWSを指定し、AWSアカウントID(AWSマネジメントコンソールにIAMロールでログインする際に指定する12桁の番号)を入力します。
    スクリーンショット 2023-04-08 2.44.08.png
  4. 属性条件には以下のように入力し、特定のAWS IAMロール以外からはリソースへのアクセスを拒否する設定をした上で保存します。(CDKで指定するIAMロール名を変更する場合はbot-lambda-role の部分も変更してください。)
    attribute.aws_role == "arn:aws:sts::{AWSアカウントID}:assumed-role/bot-lambda-role"
    
  5. 保存後は 「IAMと管理」の「WorkloadIdentity連携」タブが選択状態になっており、上部に「アクセスを許可」が表示されるので選択して先ほど作成したサービスアカウントを選択してアクセスを許可します。
  6. サービスアカウントとプロバイダを紐付けた後、「構成をダウンロード」が表示されるので選択してJSONファイルのダウンロードを行います。(もしここでダウンロードが漏れた場合、WorkloadIdentityプールを選択すると右側に「接続済みサービスアカウント」タブが表示されるので、ここからダウンロードできます。)
    スクリーンショット 2023-04-08 2.55.29.png

開発

ここからは実際に開発していきます。

CDKのインストールとセットアップ

下のリンクを参考にインストールしてください。

macOSでは以下でセットアップまで行います。

# CDKインストール済なら下の2行は省略してください。
$ npm install -g aws-cdk
$ cdk --version
2.70.0 (build c13a0f1)

$ mkdir image-commentator-gpt-bot
$ cd image-commentator-gpt-bot
$ cdk init --language typescript

# npm の代わりに yarn を使用(これは好みで選んで良いのでnpmのままでも問題ないです)
$ rm package-lock.json
$ rm -rf node_modules
$ yarn install

# 今回の開発で使用するライブラリをインストール
$ yarn add -D @types/aws-lambda @aws-cdk/aws-lambda @aws-cdk/aws-lambda-nodejs dotenv
$ yarn add @line/bot-sdk @google-cloud/vision openai

# WorkloadIdentityのクレデンシャルを置くディレクトリを作成しておく
$ mkdir .credentials
$ touch .credentials/.gitkeep

CDKを用いたAWSリソースの定義

上のセットアップが完了すると、ディレクトリやファイルが作成されます。この中で、 /lib/image-commentator-gpt-bot-stack.ts がAWSのリソースを定義するためのファイルです。
ここではLambdaやAPI Gatewayと併せてIAMロールも定義します。

Lambdaの関数を作成してインターネットに公開するだけであればIAMロールは自動作成されるので定義は不要ですが、CDKが自動作成するIAMロールを使用すると、IAMロール名が長すぎてWorkload Identityプロバイダと連携する際に以下のエラーが発生します。
(このエラー解消に時間溶かした...)

Error code invalid_request: The size of mapped attribute google.subject exceeds the 127 bytes limit. Either modify your attribute mapping or the incoming assertion to produce a mapped attribute that is less than 127 bytes.

そのため、今回はIAMロールも併せて定義します。

/lib/image-commentator-gpt-bot-stack.ts
import * as cdk from "aws-cdk-lib";
import { LambdaRestApi } from "aws-cdk-lib/aws-apigateway";
import { ManagedPolicy, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";
import { Runtime } from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Construct } from "constructs";
import { config } from "dotenv";

// .envファイル読み込み
config();

export class ImageCommentatorGptBotStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // IAMロール
    const lambdaRole = new Role(this, "lambdaRole", {
      roleName: "bot-lambda-role",
      assumedBy: new ServicePrincipal("lambda.amazonaws.com"),
      managedPolicies: [
        ManagedPolicy.fromAwsManagedPolicyName(
          "service-role/AWSLambdaBasicExecutionRole"
        ),
      ],
    });

    // Lambda関数
    const lambda = new NodejsFunction(this, "lambda", {
      entry: "src/lambda/index.ts",
      handler: "handler",
      runtime: Runtime.NODEJS_16_X,
      environment: {
        CHANNEL_ACCESS_TOKEN: process.env.CHANNEL_ACCESS_TOKEN ?? "",
        CHANNEL_SECRET: process.env.CHANNEL_SECRET ?? "",
        OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? "",
        GOOGLE_APPLICATION_CREDENTIALS: "./workloadIdentityCredentials.json",
      },
      role: lambdaRole,
      timeout: cdk.Duration.seconds(60),
      bundling: {
        commandHooks: {
          // デプロイ時に特定のファイルをアップロードするためにコマンドフックを使用。
          // 参考:https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda_nodejs-readme.html#command-hooks
          beforeBundling(inputDir: string, outputDir: string): string[] {
            return [
              `cp ${inputDir}/.credentials/workloadIdentityCredentials.json ${outputDir}/workloadIdentityCredentials.json`,
            ];
          },
          afterBundling() {
            return [];
          },
          beforeInstall() {
            return [];
          },
        },
      },
    });

    // API Gateway
    new LambdaRestApi(this, "gateway", {
      handler: lambda,
    });
  }
}

また、LINEチャンネルのシークレットなどはコードに漏洩しないように .env にまとめます。

.env
CHANNEL_ACCESS_TOKEN="{事前準備で取得したLINEのチャンネルアクセストークン}"
CHANNEL_SECRET="{事前準備で取得したLINEのチャンネルシークレット}"
OPENAI_API_KEY="{事前準備で取得したOpenAIのAPIキー}"

GitHubなどを使用してバージョン管理する場合はこれらの情報が漏れないよう必ず .gitignore.env を含めてください。

diff --git a/.gitignore b/.gitignore
index f60797b..cfa0e00 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,7 @@ node_modules
 # CDK asset staging directory
 .cdk.staging
 cdk.out
+.env
+yarn-error.log
+.credentials/*
+!.credentials/.gitkeep

事前準備でダウンロードしたWorkloadIdentityのJSONファイルは ./credentials/ 内に workloadIdentityCredentials.json という名前で保存します。

Vision APIを使用する関数を作成

Node.jsクライアントを使用してVision APIから画像のランドマークやオブジェクト、ラベルを取得します。

Buffer型の引数を受け取り、これをVision APIに渡してランドマークやオブジェクト、ラベルの情報を取得するコードは以下です。

src/lambda/visionClient.ts
import * as vision from "@google-cloud/vision";

export type ImageInformation = Readonly<{
  landmarks: ReadonlyArray<string>;
  localizedObjects: ReadonlyArray<string>;
  labels: ReadonlyArray<string>;
}>;

export const getImageInformation = async (
  buffer: Buffer
): Promise<ImageInformation> => {
  const client = new vision.ImageAnnotatorClient();
  // Node.js用SDKを使用する場合はAPI呼び出し1回で全て取得する方法がなさそうなので1つずつ呼び出し。
  const landmarks =
    (await client.landmarkDetection(buffer))?.[0]?.landmarkAnnotations ?? [];
  const localizedObjects = client.objectLocalization
    ? (await client.objectLocalization(buffer))?.[0]
        .localizedObjectAnnotations ?? []
    : [];
  const labels =
    (await client.labelDetection(buffer))?.[0].labelAnnotations ?? [];

  const info: ImageInformation = {
    landmarks: landmarks
      .map((item) => item.description ?? "")
      .filter((item) => !!item),
    localizedObjects: localizedObjects
      .map((item) => item.name ?? "")
      .filter((item) => !!item),
    labels: labels
      .map((item) => item.description ?? "")
      .filter((item) => !!item),
  };

  return info;
};

ChatGPT APIを使用する関数を作成

Node.jsクライアントを使用してChatGPT APIに画像に関する情報を渡し、画像についてのコメントを取得します。

src/lambda/chatGptClient.ts
import { Configuration, OpenAIApi } from "openai";
import { ImageInformation } from "./visionClient";

const config = new Configuration({
  apiKey: process.env.OPENAI_API_KEY,
});
const openai = new OpenAIApi(config);

const botRoleContent = `
あなたはユーザーから送付された画像や写真に関して面白い感想を言うAIです。
感想はユーモラスを交えながらもなるべく具体的に言うと共に、この画像や写真に写っている被写体に対して有益となるような情報を伝えてください。
ユーザーから送られた画像や写真について、以下の3種類をそれぞれ英語のテキストで伝えます。

- ランドマーク
- 画像に写り込んでいるもの
- 画像から読み取れるもの

この英語のテキストは複数ある場合はカンマで区切られます。もし存在しない場合は「なし」が設定されます。
この英語のテキストを日本語に訳した上で感想や情報を組み立ててください。

感想の例:「この写真には東京タワーが写っているようですね。周辺には多くの人が写っており、自動車も多いことから混雑している様子が伺えます。
東京タワーのバックには青空が写っており、昼間に撮られた良い写真ですね。
東京タワーといえば東京都港区芝公園にある総合電波塔で1958年12月23日竣工された東京のシンボルとも言える観光名所で、完成当初は世界一高い建造物だったらしいですよ。」
`;

export const getImageComment = async (
  content: ImageInformation,
  model = "gpt-3.5-turbo"
) => {
  const userText = `
- ランドマーク:${
    content.landmarks.length > 0 ? content.landmarks.join(",") : "なし"
  }
- 画像に写り込んでいるもの:${
    content.localizedObjects.length > 0
      ? content.localizedObjects.join(",")
      : "なし"
  }
- 画像から読み取れるもの:${
    content.labels.length > 0 ? content.labels.join(",") : "なし"
  }
`;
  const response = await openai.createChatCompletion({
    model: model,
    messages: [
      { role: "system", content: botRoleContent },
      { role: "user", content: userText },
    ],
  });

  return (
    response.data.choices[0].message?.content ??
    "すみません、画像のことがよく分かりませんでした。"
  );
};

Messaging APIを使用するLambda関数を作成

Node.jsクライアントを使用してMessaging APIにリクエストします。

ソースコードの例は以下です。

src/lambda/index.ts
import {
  APIGatewayEventRequestContextV2,
  APIGatewayProxyEventV2,
  APIGatewayProxyResultV2,
} from "aws-lambda";
import { Client, validateSignature, WebhookEvent } from "@line/bot-sdk";
import { Stream } from "stream";
import { getImageInformation } from "./visionClient";
import { getImageComment } from "./chatGptClient";

const webhookResponse = () => ({
  statusCode: 200,
  headers: { "Content-Type": "text/plain" },
  body: "OK",
});

const streamToBuffer = async (stream: Stream): Promise<Buffer> => {
  return new Promise<Buffer>((resolve, reject) => {
    const buffer = Array<any>();
    stream.on("data", (chunk) => buffer.push(chunk));
    stream.on("end", () => resolve(Buffer.concat(buffer)));
    stream.on("error", (err) => reject(`Error converting stream - ${err}`));
  });
};

const config = {
  channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN ?? "",
  channelSecret: process.env.CHANNEL_SECRET ?? "",
};

export const handler = async (
  event: APIGatewayProxyEventV2,
  context: APIGatewayEventRequestContextV2
): Promise<APIGatewayProxyResultV2> => {
  // Messaging APIから実行されたのかを検証する。
  if (
    !validateSignature(
      event.body ?? "",
      config.channelSecret,
      event.headers?.["x-line-signature"] ?? ""
    )
  ) {
    console.error("Invalid signature.");
    return webhookResponse();
  }

  const body = JSON.parse(event.body!);
  const messageEvent = body.events[0] as WebhookEvent;

  if (!messageEvent || messageEvent.type !== "message") {
    // メッセージに関するイベント以外は無視する。
    return webhookResponse();
  }

  const client = new Client(config);
  const replyToken = messageEvent.replyToken;

  if (messageEvent.message.type !== "image") {
    // 画像が添付されていなければメッセージを返して処理を終わる。
    await client.replyMessage(replyToken, {
      type: "text",
      text: "このメッセージは対応していません",
    });

    return webhookResponse();
  }

  const stream = await client.getMessageContent(messageEvent.message.id);
  const buffer = await streamToBuffer(stream);
  const ImageInformation = await getImageInformation(buffer);
  console.log(ImageInformation);
  const comment = await getImageComment(ImageInformation);

  await client.replyMessage(replyToken, {
    type: "text",
    text: comment,
  });
  return webhookResponse();
};

簡単にコードを説明します。
(1)LINEからMessaging APIがイベントを受け取ると、API Gatewayを介してこの handler() が呼ばれます。

(2)エンドポイントはインターネットに公開するので不正なリクエストを受け付ける可能性があるため、まずはリクエストがMessaging APIからのものなのかを検証する必要があります。

(3)Messaging APIには200を返す必要があるので、検証に失敗した場合でもエラーログだけ出力して200を返す実装にしています。

(4)次にMessaging APIからのリクエストが画像メッセージである場合は画像をMessaging APIからstreamで取得し、Bufferを作成して先ほど作成したVision APIから情報を取得する関数に渡します。

(5)この関数が返した情報をChatGPT API用に作成した関数に渡し、その結果を client.replyMessage() を使用してユーザーに返信します。

CDKを使用してデプロイ

aws configure が正しく行えている場合は以下のコマンドでAWSにデプロイできます。

$ cdk bootstrap
$ cdk deploy

デプロイに成功すると https://xxxxxxxxxxxxxxxxx.amazonaws.com/prod/ のようなエンドポイントが表示されますので、これをメモしておきます。

デプロイ後にAPI GatewayやIAMロール、Lambdaが正しく作成されていることを確認してください。

LINEのチャンネルにwebhookを登録

  1. LINE Developersの「Messaging API設定」タブにあるWebhookに、先ほどのエンドポイントを設定してWebhookの利用をONにします。
    スクリーンショット 2023-04-08 3.14.34.png
  2. 「検証」を選択してリクエストに成功するか確認してください。

動作確認

LINEから画像を送り、以下のようにコメントが返ってきたら成功です。
IMG_5688.jpg
この時にAmazon CloudWatchを確認すると以下のようにCloud Visionから画像の情報を受け取れていることが分かります。

2023-04-08T17:53:31.550Z	d93dbc04-d9ac-46b8-93df-9ece4a6d7f9f	INFO	{
  landmarks: [ 'Kamakura Daibutsu Buddha' ],
  localizedObjects: [
    'Person', 'Person',
    'Person', 'Person',
    'Person', 'Person',
    'Person', 'Person',
    'Person', 'Person'
  ],
  labels: [
    'Sky',       'Cloud',
    'Sculpture', 'Statue',
    'Temple',    'Tree',
    'Leisure',   'Travel',
    'Art',       'Landmark'
  ]
}

どうしても画像から取得できる情報が少ないので、斜め上のコメントを返すことも多いのですがこの画像のように明らかにわかるランドマークが写っていると比較的良いコメントを返します。

もしコメントが返ってこない場合はCloudWatchでエラーログを確認してください。

おわりに

今回はChatGPT APIを触るついでに画像を使って面白いことができないか考えながら作ったボットのメモを残させてもらいました。
応用することで、例えばVision APIの textDetection を使用してレシートから買い物傾向をChatGPTにコメントしてもらえたりなどもできそうです。(OCR部分がめんどくさそう)
READMEがデフォルトのままですが、今回作成したコードは以下のリポジトリに置いています。

分かりにくい説明の部分もあるかもしれませんが、どなたかの参考になれば幸いです。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?