スマホのカメラで料理のカロリーを計算するアプリをサクっと作ってみた【Amplify,Bedrock,Flutter】

# 背景

生成AIを使って何か作りたいなと思っていたので、写真からカロリーを計算するアプリを作ってみました。


アプリはFlutterを使って作成し、バックエンドはAWSのAmazon BedrockとAWS Amplify Gen2を使って作成しました。FlutterとAmplifyによってコード量少なくかなりサクッと作ることができました！

モデルは`Claude 3 Sonnet`を使いました。(`Claude 3.5 Sonnet`は自分のアカウントで使えませんでした...)


https://www.anthropic.com/claude

Flutterに詳しい人からすると、Flutter 3.19から使えるようになった [google_generative_ai](https://pub.dev/packages/google_generative_ai)というGeminiを簡単に呼び出せるライブラリを使った方が楽では？という意見もあると思います。しかし、以下の注意事項にあるように通常のアプリでは使えないので、採用していません。

> Using the Google AI SDK for Dart (Flutter) to call the Google AI Gemini API directly from your app is recommended for prototyping only. 

# 作ったもの

1. スマートフォンで料理の写真を撮る
1. Amazon BedrockのClaudeを呼び出して画像を解析
1. 料理名と推定カロリーが返ってくる


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600358/d852f51e-b650-2006-f6f3-31e69883f164.gif" width="250px"/>


構成は以下のようになっています。

AWS AppSync(GraphQLサーバー)からLambdaを使ってBedrockを呼び出します。
画像ファイルは前処理（後述）をしてbase64形式で送信します。

![architecture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600358/f181db60-2ad5-253c-adc3-ad737a196f0b.png)


# 使っている技術

## Flutter

Flutter は 複数のプラットフォームに対応したオープンソースなフレームワークです。現在では、Android・iOS・Web・Windows・MacOS・Linuxに対応しており、単一のコードで複数のプラットフォーム上で動作可能なアプリケーションを作成することができます。

https://flutter.dev/

## Amazon Bedrock

AnthropicのClaudeやAmazonのTitanなど様々なAPIを呼び出すことができるサービスです。
AWSから生成AIを使いたい時に便利なサービスです。

https://aws.amazon.com/jp/bedrock/

## AWS Amplify

「アイデアから始めて、数時間でアプリケーションを完成」と記載がある通り、コマンド一発でAPIやデータベース、認証などのサービスをデプロイできるサービスです。

https://aws.amazon.com/jp/amplify/

# 実装

使用しているバージョンは以下の通りです。

Flutter: 3.22.2
Node.JS: 20.13.1
@aws-amplify/backend-cli: 1.0.4

実装したコードは以下のリポジトリを上げています。

https://github.com/tm-hirai/flutter_amplify_bedrock


## 1. FlutterとAmplifyのプロジェクト作成

FlutterとNodeJSを公式の手順に従ってインストールしてください。
その後、以下のサイトを参考にプロジェクトを作成します。

https://docs.amplify.aws/flutter/start/quickstart/

```sh
flutter create flutter_amplify_bedrock --platform=android,ios
cd flutter_amplify_bedrock
npm create amplify@latest
```

上記のコマンドを実行すると`amplify`フォルダが作成され、以下のコマンドを実行することで開発環境を立ち上げることができます。
```
npx ampx sandbox --outputs-format dart --outputs-out-dir lib
```

## 2. バックエンドの実装

以下のページを参考にLambdaからBedrockを呼び出すためのAPIを作成します。
Lambdaを使う方法とAppSync JavaScript resolversを使う方法が紹介されていますが、今回はLambdaを使います。

https://docs.amplify.aws/react/build-a-backend/data/custom-business-logic/connect-bedrock/

### Lambdaの実装

以下はLambdaにデプロイするコードです。
Bedrockを使ってClaudeを呼び出す部分になります。

```typescript:amplify/data/calorieCalculation.ts
import type { Schema } from "./resource";
import {
  BedrockRuntimeClient,
  InvokeModelCommand,
  InvokeModelCommandInput,
} from "@aws-sdk/client-bedrock-runtime";

// 東京リージョン(ap-northeast-1)のモデルは少ないのでバージニア北部リージョン(us-east-1)を使う。
const client = new BedrockRuntimeClient({ region: "us-east-1" });

export const handler: Schema["calorieCalculation"]["functionHandler"] = async (
  event,
  _
) => {
  // base64形式の画像
  const base64String = event.arguments.base64String;

  // Bedrockへのリクエスト
  const input = {
    modelId: process.env.MODEL_ID,
    contentType: "application/json",
    accept: "application/json",
    body: JSON.stringify({
      anthropic_version: "bedrock-2023-05-31",
      system:
        "You are a calorie analysis expert capable of estimating the calories of any dish shown in an image.",
      messages: [
        {
          role: "user",
          content: [
            {
              type: "image",
              source: {
                type: "base64",
                media_type: "image/jpeg",
                data: base64String,
              },
            },
            {
              type: "text",
              text: `Estimate calories of the food in this image. Use JSON format with "food" (dish name in Japanese) and "calorie" (in kcal) as keys.
              Do not output anything other than JSON.
              <example>
              {
                "food": "寿司",
                "calorie": 300
              }
              </example>
              `,
            },
          ],
        },
      ],
      max_tokens: 1000,
      temperature: 0,
    }),
  } as InvokeModelCommandInput;

  const command = new InvokeModelCommand(input);
  const response = await client.send(command);
  const data = JSON.parse(Buffer.from(response.body).toString());
  return data.content[0].text;
};
```

プロンプトについて解説します。
`system`には「あなたはカロリー分析のエキスパートです。」といった内容の文章をいれました。

入力には`type: image`で画像と`type: text`で命令文を入れています。命令文には`<example>`タグの中に出力してほしいJSONの形式を指定しました。
例を入れることで、想定通りのレスポンスが返ってくるようになります。



### スキーマ定義

以下のファイルでLambdaの設定やGraphQLのスキーマ定義などを実施します。

```typescript:amplify/data/resource.ts
import {
  type ClientSchema,
  a,
  defineData,
  defineFunction,
} from "@aws-amplify/backend";

export const MODEL_ID = "anthropic.claude-3-sonnet-20240229-v1:0";

// Lambdaの定義
// NodeJS v20、タイムアウトを10秒に設定
export const calorieCalculationFunction = defineFunction({
  entry: "./calorieCalculation.ts",
  environment: {
    MODEL_ID,
  },
  runtime: 20,
  timeoutSeconds: 10,
});

const schema = a.schema({
  calorieCalculation: a
    .query()
    // Lambdaの引数
    .arguments({ base64String: a.string().required() })
    // 返り値
    .returns(a.string())
    // API Keyでのアクセスを許可
    .authorization((allow) => [allow.publicApiKey()])
    .handler(a.handler.function(calorieCalculationFunction)),
});

export type Schema = ClientSchema<typeof schema>;

export const data = defineData({
  schema,
  // 有効期限30日のAPI Key
  authorizationModes: {
    defaultAuthorizationMode: "apiKey",
    apiKeyAuthorizationMode: {
      expiresInDays: 30,
    },
  },
});
```

Lambdaのランタイムやタイムアウト、環境変数などの設定を行います。

`a.schema`の部分はGraphQLのスキーマ定義部分です。
最終的に以下のようなスキーマが自動で生成されます。今回、呼び出し権限には30日間有効のAPI Keyを設定しました。

```graphql
type Query {
  calorieCalculation(base64String: String!): String @aws_iam @aws_api_key
}
```

### Backendの定義

これまで作成したファイルを元にバックエンドを定義します。
LambdaにBedrockを実行するための権限が必要なので、定義しておきます。

これを元に`AWS CDK`のファイルが自動生成され、AWSで必要なリソースが全て作成されます。

```typescript:amplify/backend.ts
import { defineBackend } from "@aws-amplify/backend";
import { data, MODEL_ID, calorieCalculationFunction } from "./data/resource";
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";

export const backend = defineBackend({
  data,
  calorieCalculationFunction,
});

// LambdaからBedrockを呼び出すための権限を付与
backend.calorieCalculationFunction.resources.lambda.addToRolePolicy(
  new PolicyStatement({
    effect: Effect.ALLOW,
    actions: ["bedrock:InvokeModel"],
    resources: [`arn:aws:bedrock:us-east-1::foundation-model/${MODEL_ID}`],
  })
);
```

### 開発環境にデプロイ

以下のコマンドを実行するとAWS上に必要なリソースが全てデプロイされます。
これでバックエンドの準備が全て整いました！

```sh
npx ampx sandbox --outputs-format dart --outputs-out-dir lib --outputs-version 0 --profile my-account
```

デプロイ完了後、AppSyncのエンドポイントなどが記載されたDartファイルが自動生成されるので、この情報を元にアプリからAPIに接続します。

```dart:lib/amplifyconfiguration.dart
const amplifyConfig = '''{
  "UserAgent": "@aws-amplify/client-config/1.0.4",
  "Version": "1.0",
  "api": {
    "plugins": {
      "awsAPIPlugin": {
        "data": {
          "endpointType": "GraphQL",
          "endpoint": "https://xxx.appsync-api.ap-northeast-1.amazonaws.com/graphql",
          "region": "ap-northeast-1",
          "authorizationType": "API_KEY",
          "apiKey": "xxx"
        }
      }
    }
  }
}''';
```

## 3.アプリの実装

### カメラの実装

`camera`パッケージをインストールします。
ReadMeに従って`ios/Runner/Info.plist`や`android/app/build.gradle`にも設定を追加しておきます。

https://pub.dev/packages/camera

```yaml
dependencies:
  flutter:
    sdk: flutter
  camera: ^0.11.0+1
```

`main.dart`で利用可能なカメラを取得しておきます。

```dart: main.dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // 利用可能なカメラの取得
  final cameras = await availableCameras();
  runApp(MyApp(
    cameras: cameras,
  ));
}
```

以下はカメラプレビューの表示と撮影処理の実装です。

```dart
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:image/image.dart' as img;

class CameraScreen extends StatefulWidget {
  const CameraScreen({super.key, required this.cameras});
  final List<CameraDescription> cameras;

  @override
  State<CameraScreen> createState() => _CameraScreenState();
}

class _CameraScreenState extends State<CameraScreen> {
  late CameraController controller;

  @override
  void initState() {
    super.initState();
    // カメラコントローラの作成
    controller = CameraController(widget.cameras[0], ResolutionPreset.max,
        enableAudio: false);
    // カメラの初期化
    controller.initialize().then((_) {
      if (!mounted) {
        return;
      }
      setState(() {});
    });
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (!controller.value.isInitialized) {
      return Container();
    }
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // 写真を撮影
          controller.takePicture().then((XFile file) async {});
        },
        child: const Icon(Icons.camera),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
      body: SafeArea(
        // カメラのプレビューを表示
        child: CameraPreview(
          controller,
        ),
      ),
    );
  }
}

```

ここまででカメラの実装は完了です。以下のようにプレビューと撮影ボタンが表示されます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600358/4ed7391b-7f91-7187-6832-d6f1ad26c8a8.png" width="300px" />


### 写真の加工

画像をBedrockへ送信する前に加工を行います。
加工する内容は以下の通りです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600358/1547a914-8917-c0d2-e2ca-a35d1bd8328a.png" width="600px"/>

加工を行っている理由は、高精細な画像をそのまま送ってしまうと生成AIの料金が高くなってしまうからです。また、デバイスによって異なるアスペクト比を考慮して正方形にしています。
そのまま送ってもリサイズしていい感じにやってくれるようですが、請求が怖かったので小さめの画像にしています。

Claudeの場合だとトークンを以下の式で計算することができます。
`tokens = (width px * height px)/750`
500x500の場合であれば画像1枚あたりおよそ333トークンを消費します。解像度をどうするかについては求める精度と料金で決めましょう。

https://docs.anthropic.com/en/docs/build-with-claude/vision

それでは実際に加工していきます。
まずはプレビュー画像を正方形にします。
`ClipRRect`と`SizedOverflowBox`を使って正方形にすることができます。

端末が縦向きであることを想定して縦横それぞれデバイスの横幅と同じにしています。横向きで撮影する場合には別途対応が必要です。

```dart
class _CameraScreenState extends State<CameraScreen> {
  late CameraController controller;

  @override
  Widget build(BuildContext context) {
    if (!controller.value.isInitialized) {
      return Container();
    }
    return Scaffold(
      // ...
      // プレビューを正方形で表示
      body: SafeArea(
        child: ClipRRect(
          child: SizedOverflowBox(
            alignment: Alignment.topLeft,
            size: Size(MediaQuery.of(context).size.width,
                MediaQuery.of(context).size.width),
            child: CameraPreview(
              controller,
            ),
          ),
        ),
      ),
    );
  }
}
```

|加工前|加工後|
|-|-|
|![Screenshot_20240630-233615.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600358/35459c6a-199c-f8d3-aed8-ebda2474c477.png)|![Screenshot_20240630-233646.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600358/e6afbaeb-7b6d-2039-878e-a003d1bff832.png)|

ここまでだとプレビュー画面の加工しかしていないので、次に実際に撮影した画像ファイルを加工します。
`image`というパッケージを使って画像を加工します。写真撮影時に呼び出される`takePicture`のコールバック関数内で`copyCrop`で切り取り、`copyResize`でリサイズを実施します。


https://pub.dev/packages/image

```dart
controller.takePicture().then((XFile file) async {
  final image = await img.decodeImageFile(file.path);
  if (image == null) {
    if (!context.mounted) {
      return;
    }
    return Navigator.pop(context, null);
  }
  // 正方形に切り抜いて 500x500 にリサイズ
  final cmd = img.Command()
    ..image(image)
    ..copyCrop(x: 0, y: 0, width: image.width, height: image.width)
    ..copyResize(width: 500, height: 500);
  final image2 = await cmd.getImageThread();
  if (context.mounted) Navigator.pop(context, image2);
});
```

加工した画像は以下のように表示できます。
`image`のオブジェクトを`convertImageToFlutterUi`に渡して`RawImage`で変換後のデータを表示します。

```dart
class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  img.Image? _image;

  // 画像ファイルを表示するための関数
  // https://github.com/brendan-duncan/image/blob/9edfa0e54d70d8c03effe61b18bdd70ff01ccf7b/doc/flutter.md#convert-a-dart-image-library-image-to-a-flutter-ui-image
  Future<ui.Image> convertImageToFlutterUi(img.Image image) async {
    if (image.format != img.Format.uint8 || image.numChannels != 4) {
      final cmd = img.Command()
        ..image(image)
        ..convert(format: img.Format.uint8, numChannels: 4);
      final rgba8 = await cmd.getImageThread();
      if (rgba8 != null) {
        image = rgba8;
      }
    }

    ui.ImmutableBuffer buffer =
        await ui.ImmutableBuffer.fromUint8List(image.toUint8List());

    ui.ImageDescriptor id = ui.ImageDescriptor.raw(buffer,
        height: image.height,
        width: image.width,
        pixelFormat: ui.PixelFormat.rgba8888);

    ui.Codec codec = await id.instantiateCodec(
        targetHeight: image.height, targetWidth: image.width);

    ui.FrameInfo fi = await codec.getNextFrame();
    ui.Image uiImage = fi.image;

    return uiImage;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // ...
      body: SafeArea(
        child: FutureBuilder(
          future: convertImageToFlutterUi(_image!),
            builder: (context, snapshot) {
              if (snapshot.connectionState == ConnectionState.waiting) {
                return const CircularProgressIndicator();
              }
              return RawImage(image: snapshot.data as ui.Image);
            }
        )
    );
  }
}

```

### APIの実行

画像が用意できたのでいよいよAPIを実行します。
必要なパッケージをインストールします。

```yaml
dependencies:
  flutter:
    sdk: flutter
  amplify_api: ^2.1.0
  amplify_flutter: ^2.1.0
  camera: ^0.11.0+1
  image: ^4.2.0
```

AndroidとiOSで個別に必要な設定は以下のページに記載されています。
`Podfile`や`build.gradle`などの設定が必要です。

https://docs.amplify.aws/flutter/start/platform-setup/

`main.dart`でamplifyの初期設定を実施します。

```dart
void main() async {
  try {
    WidgetsFlutterBinding.ensureInitialized();
    await _configureAmplify();

    final cameras = await availableCameras();
    runApp(MyApp(
      cameras: cameras,
    ));
  } on AmplifyException catch (e) {
    runApp(Text("Error configuring Amplify: ${e.message}"));
  }
}

Future<void> _configureAmplify() async {
  try {
    await Amplify.addPlugin(AmplifyAPI());
    await Amplify.configure(amplifyConfig);
    safePrint('Successfully configured');
  } on Exception catch (e) {
    safePrint('Error configuring Amplify: $e');
  }
}
```

GraphQLのリクエストを実行します。
imageのオブジェクトはbase64形式に変更し、variablesに設定します。
レスポンスはJSON形式で帰ってくるはずなので、JSONでデコードします。

```dart
const graphQLDocument = '''
  query calorieCalculation(\$base64String: String!) {
    calorieCalculation(base64String: \$base64String) 
  }
''';
// imageのオブジェクトをjpg形式でbase64エンコード
final base64String = base64Encode(img.encodeJpg(_image!));
final request = GraphQLRequest<String>(
  document: graphQLDocument,
  variables: <String, String>{
    "base64String": base64String
  },
  authorizationMode: APIAuthorizationType.apiKey);

/// API実行
final response = await Amplify.API.query(request: request).response;
Map<String, dynamic> jsonMap = json.decode(response.data!);
```

それを画面に表示すれば出来上がりです！

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600358/537a6cd5-aa7a-7e84-ab36-7fff00fed060.png" width="300px" />

# 精度について
料理名がわかりやすいものはいい感じに出してくれていましたが、料理名がわかりづらいものは変な答えが出力されました。

手元に料理がなかったのでPCに料理の写真を写してそれを撮っていたことや料金をケチって解像度を落としていることが原因かもしれないです。

また、料理名が難しい創作料理のようなものは変な料理を推定してしまい、カロリーおかしくなってしまうようでした。料理名を答えさせずにカロリーだけ計算させるようにプロンプトを変えた方が良いかもしれません。

以下、いい感じに出してくれたものとそうではないものです。
NGは前菜盛り合わせの小鉢のようなものなので、お雑煮ではありません。

|OK|OK|NG|
|-|-|-|
|![Screenshot_20240701-003004.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600358/6ce4ea3a-7974-cf97-8a8c-f564a324f1a2.png)|![Screenshot_20240701-002932.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600358/64399228-cac4-d593-0d38-06bbc45dfe10.png)|![Screenshot_20240701-003202.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600358/5016f6b0-695a-2b68-f6af-c0ad627d2aed.png)|

# まとめ

Flutter, AWS Amplify, Amazon Bedrockを駆使して生成AIアプリを作ってみました。

FlutterとAWS Amplifyのおかげでかなりコード量少なく実装することができたので、サクッと実装することができました。

今回作ったアプリも2~3時間で主要な機能は作ることができたのでこれらのツールは改めて偉大だなーと思いました。これからも生成AIで面白いアプリを作っていきたいです！