🤖

TypeChatとCloud Vision APIを使用したレシート解析

2025/02/03に公開

はじめに

過去に家計簿系のアプリの個人開発をしている中で、レシートの読み取り機能を実装した際に試したことがあったので、簡単にまとめてみたいと思います。

処理の流れ

処理の流れは画像のままですが、以下のようになります。

  1. ユーザーがレシートの画像をアップロード
  2. 1の画像をCloud Vision APIで画像解析
  3. 2で取得したデータをTypeChatに渡す
  4. JSONとして出力

OCR系のOSSなどのライブラリは何個かありましたが、今回はCloud Vision APIを選択しました。そこまで深い理由はないです。なるべく早く検証してみたかったという気持ちが強かったです。

TypeChatについて

TypeChatは、microsoftのOSSであり、型の力を活用してAIとのやりとりをより正確かつ安全に行うためのライブラリです。従来のチャットAIでは、自然言語の解釈に曖昧さがつきものですが、TypeChatでは型を定義することで、出力されるデータの構造を保証し、開発者が扱いやすくすることができます。

https://github.com/microsoft/TypeChat

特徴

  • TypeScriptの型を使ってAIの出力を制約する
  • 型の制約を利用してAIの出力を制御できるため、意図しないデータの生成を防ぎやすくなる

実装

実装に使用したコードは以下です。
https://github.com/hayawata3626/typechat-ocr-example

事前に必要な情報としては、以下になります。

OPENAI_API_KEY=xxxxx
OPENAI_MODEL=xxxxx
GOOGLE_APPLICATION_CREDENTIALS=xxxxx

主要な箇所に絞って解説していきます。

CloudVisionClientの作成

import { ImageAnnotatorClient } from '@google-cloud/vision';
import fs from 'fs';

export class CloudVisionClient {
  private readonly client: ImageAnnotatorClient;

  constructor() {
    this.client = new ImageAnnotatorClient();
  }

  async fetchImageToText(path: string): Promise<string | undefined> {
    const request = {
      image: {
        content: fs.readFileSync(path),
      },
      imageContext: {
        languageHints: ['ja'],
      },
    };
    const [result] = await this.client.textDetection(request);
    const detections = result.textAnnotations;
    const description = detections?.[0].description;
    return description ?? undefined;
  }
}

これは単純にCloud Vision APIと通信をするためのものです。特に珍しくないですが、imageContextlanguageHintsには日本語を指定しています。

interfaceの定義

TypeChatでinterfaceを定義する理由は、AIの出力を型で制約し、意図しないデータを防ぐためです。型を指定することによって、構造化されたJSONが手に入り、処理の一貫性を保てます。また、開発者が期待するフォーマットに従ったデータを受け取れるのが嬉しいポイントです。

export interface Product {
  price: number;
  name: string;
  count: number;
  category: 'food' | 'drink' | 'snack' | 'other';
}

export interface Products {
  products: Product[];
}

今回自分のケースでは、スーパーで購入した商品などが対象となるので以下のようにしています。

  • price...値段
  • name...商品名
  • count...個数
  • category...カテゴリ(カテゴリ分けできない場合は、otherに分類されます)

メイン部分の実装

import * as fs from 'fs';
import * as path from 'path';
import * as typechat from 'typechat';
import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import { Products } from './interface.js';
import { CloudVisionClient } from 'cloud-vision-client.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

dotenv.config({ path: path.join(__dirname, '../.env') });

const cloudVisionClient = new CloudVisionClient();
const model = typechat.createLanguageModel(process.env);
const schema = fs.readFileSync(path.join(__dirname, 'interface.ts'), 'utf8');
const translator = typechat.createJsonTranslator<Products>(
  model,
  schema,
  'Products'
);
const getOcrResult = async () => {
  const result = await cloudVisionClient.fetchImageToText(
    `${__dirname}/images/image.jpg`
  );
  return result;
};

const text = await getOcrResult();
const response = await translator.translate(text as string);

console.log(JSON.stringify(response.data, null, 2));

このコードの中ではtypechatを使用した箇所がポイントです。

const model = typechat.createLanguageModel(process.env);

createLanguageModelにはどのモデルを使用するかを指定します。(OpenAI or Azure OpenAIのみが対象です。)

以下該当のコードになります。
https://github.com/microsoft/TypeChat/blob/fb4bdeb8eefc5ed33718c5ed83417f38f83f1480/typescript/src/model.ts#L94

環境変数がOpenAIの環境変数の場合(OPENAI_API_KEY)は、createOpenAILanguageModelという関数が呼ばれ、Azure OpenAIの場合(AZURE_OPENAI_API_KEY)は、createAzureOpenAILanguageModelという関数が呼ばれます。

その後、スキーマを指定します。

const schema = fs.readFileSync(path.join(__dirname, 'interface.ts'), 'utf8');

先ほど定義したinterfaceが対象となります。

const translator = typechat.createJsonTranslator<Products>(
  model,
  schema,
  'Products'
);

createJsonTranslatorに先ほど指定した、model, schemaを渡します。
createJsonTranslatorは、自然言語のテキストを指定した型のJSONに変換するTypeChatの関数です。指定したTypeScriptスキーマのtypeNameに対応するJSONを生成し、バリデーションを行う TypeChatJsonTranslator<T>を返却します。

const response = await translator.translate(text as string);

translateは、自然言語のテキスト(request)を指定した型のJSONに変換するメソッドです。変換結果がスキーマに適合しない場合、attemptRepairがtrueならエラー情報を基に再試行します(参考)。

実際に試す

この画像が対象です。(完全に酒のあてです)


出力されたJSON

{
  "products": [
    {
      "price": 3,
      "name": "レジ袋L (バイオマス 30%",
      "count": 1,
      "category": "other"
    },
    {
      "price": 128,
      "name": "かっぱえびせん",
      "count": 1,
      "category": "snack"
    },
    {
      "price": 148,
      "name": "サントリー ジムビーム",
      "count": 1,
      "category": "drink"
    },
    {
      "price": 140,
      "name": "キリン淡麗グリーンラ",
      "count": 1,
      "category": "drink"
    },
    {
      "price": 105,
      "name": "サントリーこだわり酒場",
      "count": 1,
      "category": "drink"
    }
  ]
}

まとめ

商品名やcatgoryなどちゃんと意図した通りになっています。分類しづらい商品や画質の違いなども試してみたいと思います。今回は検証という形でやってみましたが、レシート解析や家計簿アプリへの応用がしやすいと感じました。

Discussion