AWS Lambda で画像の背景除去 API を作ろう

こんにちは。たーせるです。

実は先日、クラウド系のテックカンファレンスにて登壇しました。

スライド資料100ページ強、数十点にも及ぶ図表を駆使したビジュアルでダイナミックなプレゼンが好評を博し、オフライン会場でご聴講いただいた皆様からもポジティブなフィードバックをたくさん頂戴しました。

しかるにその舞台裏を明かすと、登壇の準備には気の遠くなるほどの手間暇がかかっており、たとえば「プレゼン用の画像素材から背景をできるだけ綺麗に取り除く」という地道なしたごしらえにはほとほと手を焼いたものです。

── ここでようやく本題なのですが、「手間のかかる反復作業」ときけば、ツール化したくなるのがプログラマさがであります。

そこで今日は、「画像から背景を消し去る API」を作って AWS Lambda にデプロイする方法をご紹介しましょう。

画像から背景を取り除く

早速ですが、完成イメージは下図の通り。 ちなみに馬に乗っているのは幼き日の僕です。


技術要素

まず、表題にもある通り AWS Lambda を使って API を作ります。

ただし、処理の一つひとつを手書きで実装するのではなく、Amazon Bedrock という生成 AI 基盤サービスからイイ感じの AI モデルを選んで利用します。

今回のようなケースに強いモデルとして、画像処理特化型のAmazon Nova Canvasを採用します。

Amazon Nova Canvas は、画像生成というよりも、既存の画像を編集することに強みがあるとされており、代表的な機能として以下のようなものが挙げられます。

BACKGROUND_REMOVAL 背景除去
IMAGE_EDIT 消しゴムマジックみたいなやつ
INPAINT 画像内の欠けている領域を補完する
OUTPAINT 画像の外の領域を描き足す

まさしく BACKGROUND_REMOVALが今回の用途にぴったりなので、あとはそれを Lambda 関数から利用する方法が分かりさえすれば良さそうです。

今回は話を簡単にするため Web API として作りますが、たとえば S3 への PutObject イベントを契機に起動するバッチとして実装するのもよいでしょう。

また、エラーハンドリングなどは最小限にして、できるだけ本質のコードだけに絞っています。

API仕様

ここまでの話を踏まえつつ、今回作る API の仕様を以下のように定めました。

  • 画像の背景を除去して透過 PNG を返します。
  • 入力画像は Base64 形式の PNG/JPEG/WEBP を JSON に埋め込んで POST します。
  • 背景除去処理には Amazon Bedrock(Nova Canvas / BACKGROUND_REMOVAL) を利用します。
メソッド POST
Content-Type application/json; charset=utf-8
認証 なし
API リクエスト
  • 背景を除去したい画像を、以下の形式で API に与えます。
{ "data" : base64エンコードされた画像データ }
  • data(必須)
    • Base64 文字列(dataURL 形式は不可)。
    • PNG / JPEG / WEBP をサポートします。
    • 画像サイズは後述の制約を満たす必要があります。

実際のリクエスト(body)は以下のようになります。

{ "data" : "/9j/4AAQSkZJRgABAQEBLAEsAAD...(略)" }
制限

Bedrock(Nova Canvas BACKGROUND_REMOVAL)の制限として、背景除去をしたい画像は以下の規格に従う必要があります。

項目 制限
画像フォーマット PNG / JPEG / WEBP
画像サイズ(幅) 320 ~ 4096 px
画像サイズ(高さ) 320 ~ 4096 px
Base64 文字列長 5MB 以下

最近はスマホカメラの解像度が高く、撮影した画像をそのまま API に食べさせてしまうと高確率で413: Request Entity Too Large エラーになります。これは Bedrock というより、そもそも入口である API Gateway のサービスクォータに引っかかるためです。

これを避けるためには、画像のアップロード方式を S3 に対するマルチパートアップロードに切り替え、かつ、Bedrock の Nova Canvas が処理できる範囲に画像をリサイズするといった前処理を考慮することになります。

なお、その際に必要となる署名付き URL の発行方法や、S3 へのオブジェクト格納を契機に画像をリサイズする Lambda の具体的な実装方法は、以下で紹介している書籍に詳しいので興味のある方は取り組んでみるとよいでしょう。

tercel-tech.hatenablog.com

API レスポンス
  • 背景除去後の PNGBase64 文字列で返します。
{ "data" : base64エンコードされた画像データ }
  • フォーマットは常に PNG(RGBA)です(JPEGをアップロードした場合も PNG に変換されます)。
  • 一般的な <img src="data:image/png;base64,...."> として表示可能です。
  • 実際のレスポンスは以下のようになります。
{ "data": "iVBORw0KGgoAAAANSUhEUgAABAAAAAMAC...(略)" }

SAMプロジェクトの作成

今回はおなじみ AWS SAM の Hello World テンプレートから始めます。

SAM プロジェクトの作成方法はこちらの記事の手順がまだかろうじて有効です(Cloud9は使えなくなったけど)。

ただし、ランタイムは最新の Python 3.13 を選択しましょう。

tercel-tech.hatenablog.com

$ sam init

ボイラープレートとして作成される hello_world 等のディレクトリは、敢えて名前を変更せずそのまま用いることとします(ここを変えてしまうと、template.yaml も合わせて修正する必要があり、手順がやや煩雑になるため)。

Lambda 関数の実装

それでは Lambda 本体を実装していきます。

異なるオリジンからのリクエストに対しても応答できるよう、レスポンスヘッダに CORS 対応を入れておくことにします。

boto3.client()の第二引数に指定しているリージョンが us-east-1バージニア北部)固定になっていますが、これは AI 系のモデルが最も充実しているリージョンであるためです。

この Lambda 自体は ap-northeast-1(東京)にデプロイしても全く問題ありません。

hello_world/app.py

import json
import base64
import boto3

bedrock = boto3.client("bedrock-runtime", region_name="us-east-1")

def _cors_headers():
    return {
        "Content-Type": "application/json",
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Headers": "Content-Type",
        "Access-Control-Allow-Methods": "OPTIONS,POST",
    }

def lambda_handler(event, context):
    # HTTPリクエストbodyから、base64エンコードされた画像データを取得
    http_request_body = json.loads(event["body"])
    img_base64_data = http_request_body["data"]

    # Bedrockを呼び出すためのリクエストを組み立てる
    bedrock_request_body = {
        "taskType": "BACKGROUND_REMOVAL",
        "backgroundRemovalParams": {
            "image": img_base64_data
        }
    }

    try:
        # 背景除去を呼び出し
        response = bedrock.invoke_model(
            modelId="amazon.nova-canvas-v1:0",
            body=json.dumps(bedrock_request_body),
            accept="application/json",
            contentType="application/json",
        )
    except Exception as e:
        return _error(500, f"Bedrock error: {str(e)}")

    # 結果取得
    try:
        resp_body = json.loads(response["body"].read())
        resp_base64_data = resp_body["images"][0]   # 背景除去済みの base64 PNG
    except Exception as e:
        return _error(500, f"Invalid Bedrock response: {str(e)}")

    # JSON を組み立てて返却
    return {
        "statusCode": 200,
        "headers": _cors_headers(),
        "body": json.dumps({"data": resp_base64_data})
    }

def _error(status, message):
    return {
        "statusCode": status,
        "headers": _cors_headers(),
        "body": json.dumps({"error": message})
    }

SAM テンプレートの実装

続いて、SAM テンプレートを実装していきましょう。

ポイントはいくつかありますが、特に以下に注意が必要です。

  • Amazon Bedrock の実行にそれなりに時間がかかるため、関数のタイムアウト時間を30秒に延ばすこと。
  • API を CORS 対応させるため、BackgroundRemovalApiのリソース定義を追加すること。
    • CORS 設定はそこで行うこと。
    • Outputs セクションの HelloWorld APIValueも併せて書き換えること。
  • HelloWorldFunctionリソース定義の下にあるHTTPメソッドを get から post に変えること。
  • Bedrock を利用するため、Lambda にbedrock:InvokeModelポリシーをアタッチすること。

実装は以下の通りです。

template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  background-removal-api

  Sample SAM Template for background-removal-api

Globals:
  Function:
    Timeout: 30  # タイムアウト時間を延ばす

Resources:
  # CORS対応のため、AWS::Serverless::Apiのリソースを定義
  BackgroundRemovalApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: Prod
      Cors:
        AllowOrigin: "'*'"
        AllowHeaders: "'Content-Type'"
        AllowMethods: "'OPTIONS,POST'"

  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.13
      Architectures:
        - x86_64
      Events:
        HelloWorld:
          Type: Api
          Properties:
            RestApiId: !Ref BackgroundRemovalApi   # 追加
            Path: /hello
            Method: post  # postに変える
      # 以下追加(Bedrockを利用するため、Lambdaに権限を追加)
      Policies:
        - Statement:
          - Effect: Allow
            Action:
              - bedrock:InvokeModel
            Resource:
              - arn:aws:bedrock:*::foundation-model/amazon.nova-canvas-v1:0

Outputs:
  HelloWorldApi:
    Description: "API Gateway endpoint URL for Prod stage for Hello World function"
#   Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"
    Value: !Sub "https://${BackgroundRemovalApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello"
  HelloWorldFunction:
    Description: "Hello World Lambda Function ARN"
    Value: !GetAtt HelloWorldFunction.Arn
  HelloWorldFunctionIamRole:
    Description: "Implicit IAM Role created for Hello World function"
    Value: !GetAtt HelloWorldFunctionRole.Arn
デプロイと API URLの確認

Lambda 関数(と、API Gateway)をデプロイします。

$ sam build -u
$ sam deploy --guided

デプロイに成功すると、以下のように API URL が表示されます(下図赤枠)。 この URL 情報は後で利用するので、大切に控えておきましょう。


テスト用フロントエンド

最後に、テスト用のフロントエンドを作りましょう。

先に出来栄えを示しておくと、下図のようなイメージになります。 観光地の写真から、被写体のクリスマスツリーだけがうまく切り抜かれているのが分かります。

UI は見栄えがするよう Tailwind CSS を利用していますが、それ以外の処理はできる限りライブラリに依存せずに実装しています。

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>背景除去APIテスト</title>
  <!-- Tailwind CSS CDN -->
  <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-slate-100 text-slate-900">
  <div class="max-w-4xl mx-auto py-10 px-4">
    <header class="mb-8">
      <h1 class="text-2xl font-bold text-slate-800 mb-2">
        背景除去 API テスト
      </h1>
      <p class="text-sm text-slate-600">
        画像ファイルを選択して、Bedrock + Lambda の背景除去 API を叩く簡易テストページです。
      </p>
    </header>

    <!-- 設定カード -->
    <section class="bg-white rounded-xl shadow-sm border border-slate-200 p-5 mb-6">
      <label class="block text-sm font-medium text-slate-700 mb-2">
        API URL
      </label>
      <input
        type="text"
        id="apiUrl"
        class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
        value="https://**********.execute-api.ap-northeast-1.amazonaws.com/Prod/hello"
      />

      <div class="mt-4 flex flex-col sm:flex-row gap-3 items-start sm:items-center">
        <div>
          <input
            type="file"
            id="fileInput"
            accept="image/*"
            class="block text-sm text-slate-700 file:mr-3 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
          />
        </div>

        <button
          id="runBtn"
          class="inline-flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
        >
          背景を消す!
        </button>
      </div>

      <div
        id="log"
        class="mt-4 text-xs text-slate-600 whitespace-pre-wrap bg-slate-50 border border-slate-200 rounded-lg p-3 h-28 overflow-auto"
      ></div>
    </section>

    <!-- プレビュー -->
    <section class="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
      <h2 class="text-sm font-semibold text-slate-700 mb-4">
        プレビュー
      </h2>

      <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
        <div class="flex flex-col items-center">
          <h3 class="text-sm font-medium text-slate-700 mb-2">
            元画像
          </h3>
          <div class="w-full max-w-xs aspect-square bg-slate-50 border border-dashed border-slate-300 rounded-lg flex items-center justify-center overflow-hidden">
            <img id="originalImg" class="max-h-full max-w-full object-contain" />
          </div>
        </div>

        <div class="flex flex-col items-center">
          <h3 class="text-sm font-medium text-slate-700 mb-2">
            変換後
          </h3>
          <div class="w-full max-w-xs aspect-square bg-slate-50 border border-dashed border-slate-300 rounded-lg flex items-center justify-center overflow-hidden">
            <img id="resultImg" class="max-h-full max-w-full object-contain" />
          </div>
        </div>
      </div>
    </section>
  </div>

  <script>
    const fileInput = document.getElementById('fileInput');
    const runBtn = document.getElementById('runBtn');
    const originalImg = document.getElementById('originalImg');
    const resultImg = document.getElementById('resultImg');
    const logEl = document.getElementById('log');
    const apiUrlInput = document.getElementById('apiUrl');

    function log(msg) {
      console.log(msg);
      logEl.textContent += msg + "\n";
      logEl.scrollTop = logEl.scrollHeight;
    }

    // File を Base64 文字列に変換(data URL の "base64," 以降だけ取り出す)
    function fileToBase64(file) {
      return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onload = () => {
          const result = reader.result; // "data:image/png;base64,...."
          const base64 = result.split(',')[1];
          resolve(base64);
        };
        reader.onerror = reject;
        reader.readAsDataURL(file);
      });
    }

    runBtn.addEventListener('click', async () => {
      logEl.textContent = '';

      const file = fileInput.files[0];
      if (!file) {
        alert('画像ファイルを選択してください。');
        return;
      }

      const apiUrl = apiUrlInput.value.trim();
      if (!apiUrl) {
        alert('API URL を入力してください。');
        return;
      }

      try {
        log('画像を Base64 に変換中...');
        const base64 = await fileToBase64(file);
        originalImg.src = URL.createObjectURL(file);

        log('API にリクエスト送信中...');
        const response = await fetch(apiUrl, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({ data: base64 }),
        });

        if (!response.ok) {
          const text = await response.text();
          log(`エラー: HTTP ${response.status}\n${text}`);
          alert('API エラー: ' + response.status);
          return;
        }

        const json = await response.json();
        if (!json.data) {
          log('レスポンスに data フィールドがありません。');
          return;
        }

        log('変換成功!結果画像を表示します。');
        resultImg.src = 'data:image/png;base64,' + json.data;
      } catch (e) {
        console.error(e);
        log('エラー: ' + e.message);
      }
    });
  </script>
</body>
</html>

まとめ

今日は Bedrock + Nova Canvas の力を借りて、画像から背景を除去するツールを作りました。 精度もじゅうぶん実用に耐えうるレベルです。

本編のソースコードで示した通り、やっていることは単に受け取ったリクエストパラメータを Bedrock に引き渡しているだけ。

いよいよ専門知識なしにソリューションを生める時代になったなぁとしみじみ思いました。

さて、一つの大きなゴール(ここでは、プレゼンテーションの成功)の手前には、多くの微小なサブタスクがあったわけですが、それらに気を取られてばかりいてはいつまでも成果物は完成しません。

こうしたサブタスクが自動的に片付く仕組みをいかに爆速で手に入れるかが重要であると学び、Bedrock はそのための強力なツールであると確信した次第です。

それでは、今日はこの辺で。

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.