薄っぺらりん

厚くしていきたい

AWSでテーマパークの体験フォト処理基盤を構築 ― 自動モザイク×ヒューマンインザループ

テーマパーク等でアトラクション体験中の写真を撮影する「体験フォトサービス」、楽しい思い出を残すことができて嬉しいサービスですよね。体験フォトをWebサイトで提供するには、自分以外の写真が見られないことはもちろん、映り込んでいる他のゲストのプライバシーを保護する必要もあります。今回はそんな要件を解決する「体験フォト処理基盤」をAWSで構築してみました。
もちろん体験フォト以外の目的にも応用できます。

この記事は、AWSに関するある程度の知識を持っている次のような方におすすめです。

  • 簡単にML画像分析を行いたい方

    • Amazon Rekognition
    • 処理結果をヒューマンインザループで確認・修正
  • データの機密性を重視したシステム構築を行いたい方

    • Amazon WorkSpaces Applications
    • AWSからデータを持ち出さない
    • 公開データへの認証・認可制御
  • 実践的なアーキテクチャを学びたい方

    • イベント処理の排他制御
    • AWS Amplify Gen2で構築したシステムと他のシステムとのバックエンド連携
    • IaCやWebアプリを含む全てのソースコードを公開

目次

シナリオ

今回はテーマパークを舞台としたシナリオを考えてみました。

テーマパークの現状と要望

テーマパークには輪投げや射的などさまざまなアトラクションがあり、スタッフが体験フォトを撮影してゲストに渡すサービスを提供しています。

現在の体験フォトサービス

今は物理的な写真を提供していますが、現代のスマートフォン中心の生活スタイルに合うように、写真をデータとして配布したいと考えています。いつでも思い出を確認でき、簡単にシェアできる方がゲストに喜んでもらえるはずです。特にSNSでのシェアはテーマパークの宣伝にも繋がり、ビジネス的にも価値があります。
そこでテーマパークは体験フォト処理基盤を構築することにしました。

体験フォトを撮影し、アップロードするデバイスはすぐに手配できましたが、システム化にあたりいくつか要望が出てきました。

  1. プライバシー保護の観点から、体験フォトに映り込んでいる他のゲストの顔を自動でモザイク処理したい
  2. モザイク処理漏れは重大インシデントなので、最後は人間が確認・修正したい
  3. 機密性の高いデータなので、システム外へデータを出したくない
  4. ユーザーに対して、他のユーザーの体験フォトは絶対に見えてはいけない

ソリューション

次のようなソリューションを考えてみました。

Amazon Rekognitionを使用して体験フォトのメインとなるゲスト以外の顔を検出し、自動でモザイク処理します。
AWSからデータを出さずに人間が確認・修正するため、Amazon WorkSpaces Applicationsを使用して体験フォト確認アプリの画面のみを配信します。
テーマパークWebサイトはAWS Amplifyで構築し、Amazon Cognitoユーザープールを使用してユーザー認証を行い、Cognito IDプールを使用して体験フォト閲覧の認可制御を行います。

ソリューション

システムの使い方

はじめにユーザーがテーマパークのWebサイトにサインアップします。
まだ体験フォトは1枚もないため、一覧にはなにも表示されません。

サインアップと体験フォト一覧

ユーザーがテーマパークでアトラクションを体験すると、体験フォトがアップロードされます。
例として次のような写真がアップロードされたとします。
今回、写真はこちらこちらのものをお借りしました。

アップロードされた体験フォト

すると自動的に体験フォトのメイン人物以外の顔が識別され、モザイク処理されます。

モザイク処理された体験フォト

次に、テーマパークのオフィスで確認スタッフが「体験フォト確認アプリ」にアクセスします。WorkSpaces Applicationsでアプリの画面のみが配信される形になります。
「NEXT」ボタンを押すとモザイク処理された体験フォトが表示されるため、スタッフはモザイク漏れが無いか確認します。

配信される体験フォト確認アプリ

もしもモザイク漏れがある場合、画像上でドラッグアンドドロップすることで領域をモザイク処理できます。Rekognitionは精度が高いため今回の検証中にモザイク漏れが生じることはありませんでした。 以下の画像はわざとモザイク漏れを出しています。

モザイク漏れ領域を囲って手動モザイク処理

スタッフによる確認・修正完了後、「チェック済み」ボックスにチェックをすると「SUBMIT」ボタンが有効化されるのでクリックします。
するとユーザーに体験フォトが閲覧できるようになった旨のメールが届きます。

ユーザーへの通知メール

ユーザーがテーマパークWebサイトへアクセスすると、モザイク処理済みの体験フォトが一覧表示されます。

テーマパークWebサイトの体験フォト一覧

システム構築とポイント

はじめにアーキテクチャと大まかな流れを示し、その後ポイントを解説します。

アーキテクチャ

次のようなアーキテクチャを考えてみました。

アーキテクチャ

  1. ユーザーがテーマパークWebサイトへアクセスし、Cognitoユーザープールにサインアップします。そのまま初回サインイン時、DynamoDBユーザー情報テーブルにCognito IDプールのidentityIdなどを登録します。
  2. ユーザーがアトラクションを体験し、写真アップロードデバイスによってフォトIDなどの写真情報がAPIへPOSTされます。APIとしてLambda関数がトリガーされます。
  3. APIリクエストを受け、DynamoDB写真処理情報テーブルにアイテムを作成し、体験フォトアップロード用のS3署名付きURLを生成、写真アップロードデバイスへレスポンスとして返します。
  4. S3署名付きURLを用いて写真保存バケットに体験フォトをアップロードします。S3イベント通知でLambda関数がトリガーされます。
  5. イベントによる重複処理を防ぐため排他制御しつつ、Rekognitionを使用してメイン人物以外の顔をモザイク処理してS3へ保存します。写真レビューキューへメッセージを送信します。
  6. 確認スタッフがWorkSpaces Applicationsの写真確認Webアプリへアクセスします。
  7. 確認スタッフの操作に応じて写真確認Webアプリはキューからメッセージを取り出し、DynamoDBやAppSyncにアクセスします。
  8. 公開用S3へ体験フォトを保存します。
  9. DynamoDB公開写真情報テーブルにアイテムを作成します。
  10. SESでユーザーへメール通知します。
  11. ユーザーはメールを受けてテーマパークWebサイトへアクセスし、体験フォトを確認します。

IaCを含む全てのソースコード

Webアプリやバックエンドを含めた全てのソースコードは、以下Githubリポジトリに公開しています。
README.md に記載しているデプロイ方法から、AWS上にシステムをデプロイできます。

github.com

ディレクトリ構成は次のようになっています。

.
├── backend  # 体験フォト処理基盤のCDKプロジェクト
│   ├── bin
│   ├── lib
│   │   ├── config   # CIDRなどの設定値
│   │   ├── network  # VPCほか
│   │   ├── photo-privacy-protection-system-stack.ts  # メインスタック
│   │   └── service  # 体験フォト処理基盤メインサービス
│   └── resources
│       ├── lambda
│       │   ├── auto-mosaic-function     # 自動モザイク処理Lambda関数ソースコード
│       │   ├── layer                    # Lambdaレイヤー
│       │   └── photo-register-function  # 体験フォト登録処理Lambda関数ソースコード
│       └── workspaces-applications-image-webapp  # WorkSpaces ApplicationsでホストするWebサーバーとWebアプリ
├── photo-upload-device  # 写真アップロードデバイスを模するプログラム
│   ├── main.py
│   ├── sample-photo-01.png
│   └── sample-photo-02.png
└── user-web  # ユーザー向けテーマパークWebサイトのAmplify Gen2プロジェクト
    ├── amplify
    └── app

Rekognition APIを組み合わせて他のゲストの顔だけモザイク処理する

Rekognitionには様々な分析用APIが用意されていますが、今回使用するのは以下の2つです。

  • DetectLabels API
    • ラベルを指定することで画像に含まれる様々な物体を検出できます。ラベルリストは公式ドキュメントからダウンロードでき、その数は数千種類に上ります。
  • DetectFaces API
    • 画像に含まれる顔を検出できます。今回は使用しませんが、表情や感情も分析することができます。

これらのAPIを組み合わせ、体験フォトからメインとなる人物の顔を特定し、それ以外の顔をモザイク対象とします。ユースケースに応じて他のAPIを組み合わせたり、アルゴリズムを調整することで、様々なシチュエーションに応用することができます。
今回は体験フォトを撮影するカメラと人物の位置関係から、次のようなアルゴリズムとしました。

はじめに、DetectLabels APIでPersonラベルを使用し、画像内の人間を検出します。

体験フォトから人間を検出

検出する精度を指定できるため、スコアが80%以上のものについて、最も画像の中心に近いバウンディングボックスを、この体験フォトのメインの人物とします。

体験フォトのメインの人物を特定

次に、DetectFaces APIで画像内の顔を検出し、先ほどのメイン人物のバウンディングボックスに収まり、かつ最も大きなものをメイン人物の顔とします。

体験フォトのメイン人物の顔を特定

最後に、メイン人物以外の顔領域を縮小・拡大することによりモザイク処理を施します。

メイン人物以外の顔をモザイク処理

このようにRekognitionのAPIを組み合わせることでとても簡単に高度な画像処理を行うことができます。
もちろん世の中には、人間の顔と身体の領域を同時に検出できる高度なMLモデルもありますが、商用利用する場合にライセンスの確認が必要となったり、モデルをLambda関数に含めるためコンテナイメージを作成する必要があるなど、様々な条件や問題を考慮すると、Rekognitionのシンプルさと精度はとても魅力的です。

処理ステータス管理と排他制御にはDynamoDBを用いる

体験フォトに関するユーザーIDや撮影日などの情報と、処理がどこまで進んでいるのかを示すステータスはDynamoDBで管理します。スキーマは次のようになっています。各処理を体験フォト単位で行うため、PKはフォトIDです。運用や障害発生時にステータス別に古いものから復旧する等のため、GSIを設定しています。

photoId (PK) userId status (GSI PK) takenAt (GSI SK) createdAt updatedAt
6c9... b7d... CREATED 2025-12-21T14:16:41... 2025-12-21T14:16:40... 2025-12-21T14:16:40...

ステータスは以下の4段階です。モザイク処理以外の処理を追加する際はPROCESSINGの後にステータスが増えていきます。

  1. CREATED:写真登録処理時(アイテム作成時)
  2. PROCESSING:処理開始時
  3. AUTO_MOSAICED:モザイク処理完了時
  4. REVIEWED:レビュー完了時

今回のアーキテクチャでは、署名付きURLにより体験フォトがS3へアップロードされたことを契機に、S3イベント通知でモザイク処理Lambda関数が実行されます。S3イベント通知は少なくとも1回(at least once)の配信なので、同じ体験フォトの処理が複数回実行される可能性があります。

同一体験フォト処理の重複実行

重複実行を防ぐため、DynamoDBでステータス管理と条件付き書き込みを使用して排他制御を行います。Lambda関数での処理開始時にアイテムのロックを取得し、完了時に解放するイメージです。
ロックの条件としてステータスと有効期限を使用します。
順を追ってみていきましょう。

特定の体験フォトがはじめてLambda関数に流れてきたとき、アイテムは次のようにCREATEDとなっています。

photoId (PK) userId status (GSI PK) takenAt (GSI SK) createdAt updatedAt
6c9... b7d... CREATED 2025-12-21T14:16:40... 2025-12-21T14:16:43... 2025-12-21T14:16:43...

ロックを取得するため、次の条件でDynamoDBへ条件付き書き込みを実行します。

status = 'CREATED' OR (status = 'PROCESSING' AND processingExpiresAt < 現在時刻)

条件を満たした場合、statusにPROCESSINGを書き込み、processingExpiresAtとして現在時刻から15分後のエポック秒を書き込みます。Lambda関数の最大実行時間が15分間なのでロックの期限切れは15分後とします。
アイテムは次のようになります。赤字が変化点です。

photoId (PK) userId status (GSI PK) takenAt (GSI SK) createdAt updatedAt processingExpiresAt
6c9... b7d... PROCESSING 2025-12-21T14:16:40... 2025-12-21T14:16:43... 2025-12-21T14:16:45... 1766295104

このとき、イベント通知が重複し、同じ体験フォトが流れてきたとします。
するとロック取得のステータス条件が PROCESSING = CREATED で偽となり、以降の処理を中止することで重複処理を防ぐことができます。

あるいは、ロック取得後に処理が失敗し、アイテムがロックされたままになったとします。
運用あるいは自動化されたバッチ処理によって再実行された際、ステータスがPROCESSINGかつロック期限が切れているため、新たにロックが取得されて再実行されます。
今回は舞台がテーマパークなので閉園時間の15分後以降に実行すれば期限切れの条件は不要ですが、24時間イベントなども想定して期限切れも実装しておきましょう。

ロックの解放はステータスをAUTO_MOSAICEDに変更することで実現できます。不要になったprocessingExpiresAt項目もその際に削除します。
ロック解放後、アイテムは次のようになります。

photoId (PK) userId status (GSI PK) takenAt (GSI SK) createdAt updatedAt
6c9... b7d... AUTO_MOSAICED 2025-12-21T14:16:40... 2025-12-21T14:16:43... 2025-12-21T14:16:47...

この部分のソースファイルは以下です。

github.com

ロック取得部分の抜粋

ロック解放部分の抜粋

また、アーキテクチャ図からLambda関数がS3イベント通知を行うバケットに体験フォトを書き戻すため、無限ループを心配する方もいるかもしれません。
しかし、現在はLambda関数の再帰ループ検出・停止が可能となり、デフォルトで有効化されているため心配はご無用です。

docs.aws.amazon.com

AI処理結果データをAWSから出さずに人間が確認・修正する

体験フォトはプライバシーに関わる機密情報であるため、漏洩が許されません。一方で、モザイク処理をML画像処理として自動化しているため、処理結果を人間が確認する必要があります。生成AI全盛の今、こういったシチュエーションは今後増えていきそうです。
AWS上で処理したデータを確認・修正するために作業用PCへダウンロードする場合、以下のようなリスクが生じます。

  • 作業用PCのウイルス感染等によるデータ漏洩
  • 作業用PCのバックアップ機能等による意図しないデータ漏洩
  • 作業用PCの盗難によるデータ漏洩
  • 作業スタッフのデータ持ち出しによる漏洩

Amazon WorkSpaces Applicationsであれば、アプリケーションの画面のみを配信することにより、AWSからデータを出さずに確認・修正等の作業を行うことができます。

WorkSpaces Applicationsによるアプリ画面の配信

WorkSpaces Applicationsの構築の流れは次の通りです。

  1. Image builderを用いて仮想マシンイメージを作成
  2. イメージを動作させるFleetを作成
  3. ユーザーがアクセスするStackを作成
  4. StackにFleetを関連付けて完成

この中で、今回重要となる1番と3番のポイントを解説します。

Webサーバー・ブラウザ・Webアプリが自動起動するイメージを作成する

体験フォト確認アプリはWebアプリとして作成します。様々なAWSサービスと連携するため、WebサーバーとSPAアプリをWorkSpaces Applications内で動かします。

WorkSpaces Applications イメージの構成

Image builderのベースイメージには当然ランタイムなどの実行環境がインストールされていません。また、今回のアプリはPrivate subnetで実行され、インターネットへの経路はありません。Image builderでのイメージ作成工程にはアプリケーションのテストが含まれるため、実際の環境と同様の環境でイメージ作成することが重要です。
そのため、アプリ動作環境を構築するためのファイルやデータ一式を全てImage builderへ持ち込む必要があります。今回は以下のものをzipにまとめて持ち込みます。

  • node.jsのインストーラー
  • node_modules を含むビルド済みアプリケーション全体
  • 作成したWorkSpaces ApplicationsのSessionScripts設定ファイル(今回はconfig.jsonのみ)

Image builderへ持ち込むファイル

node.jsのインストールやアプリケーションの配置完了後、起動されるアプリケーションの設定とは別に、ユーザーのセッション開始時の設定を仕込んでおきます。WorkSpaces Applicationsではセッション開始時にC:\\AppStream\\SessionScripts\\config.jsonに基づいてセッションスクリプトと呼ばれるプログラムを実行させることができます。今回WorkSpacesとしてユーザーへ公開するアプリケーションはブラウザであるEdgeですが、裏でWebサーバーを起動させるため、セッションスクリプトとしてrun.batを実行させます。それぞれ次の通りです。

これでセッション開始時にWebサーバーが起動されます。Webサーバーはlocalhost:3000で待ち受け、体験フォト確認アプリのSPAを配信するため、WorkSpacesのアプリケーションとしてEdgeを指定する際に、Launch Parameterとしてhttp://localhost:3000を指定します。

Launch Parameterの設定

あとは通常通りイメージ作成を行うことで、WorkSpaces ApplicationsでWebアプリを配信することができます。

クリップボードやファイルのダウンロードを明示的に禁止する

WorkSpaces Applicationsでは外部ストレージや、クリップボード、ファイルダウンロードなど、アプリの画面配信以外にも便利な機能が沢山あります。そのうちのいくつかはデフォルトで有効化されているため、Stack作成時に無効化する必要があります。

まずはストレージの設定です。ホームフォルダーやクラウドストレージにチェックが入っていないことを確認します。

WorkSpaces Applications ストレージ設定

次に、クリップボードやデータ転送の設定にチェックは入っていないことを確認します。

WorkSpaces Applications クリップボードやデータ転送設定

ユーザー向けサービスとデータ処理基盤サービスは分断する

今回のアーキテクチャと処理の流れを見て、データベースやストレージをまとめることができる点に気付いた方もいると思います。DynamoDBの写真処理情報テーブルと公開写真情報テーブル、S3の写真保存バケットと公開写真保存バケットです。確かにリソース数や構成図の観点だけで見れば、それらはまとめた方がシンプルになります。しかし今回のシステムの重要な点として、扱っているデータがプライバシーに関する機密データであり、さらにデータをユーザーが参照するため、次のようなことが絶対に起こらないようにしなければなりません。

  • モザイク処理される前のオリジナルの体験フォトが見えてしまう

もしもテーブルやバケットをまとめたとしても、プログラムとしてクエリやアクセス先S3キーを制御することにより上記の問題は防ぐことができます。しかし、同じテーブルに「公開してはいけないオリジナル体験フォト」と「公開してもよいモザイク処理後の体験フォト」のS3キーが同居し、同じS3バケットにそれらデータも同居している状態になります。
プログラムにはバグが、人間にはオペレーションミスが付き物です。いつ何が切っ掛けとなり、テーブルの公開用アイテムのS3キーが、公開してはいけないオリジナル体験フォトに向いてしまうかもしれません。
その可能性を仕組みとして排除するため、今回のアーキテクチャでは、ユーザー向けサービスとデータ処理サービスとでデータベースやストレージを分離しています。

データやストレージを分離しリスク排除

AmplifyのCognito IDプールによる認可制御に合わせて外部からS3オブジェクトを作成する

ここまで非公開データを公開してしまうリスクに対応しました。次は他のユーザーの体験フォトが見えてしまうリスクへ対応します。
AmplifyはWebアプリの主要機能を抽象化して持っています。データアクセスはAmplify DataによりAppSyncとDynamoDBで行われ、ユーザー認証はAmplify AuthによりCognitoユーザープールで行われます。同様にストレージはAmplify StorageによりS3で実現されますが、ユーザーの認可制御を加える場合、Cognito IDプールが使用されます。ユーザーが参照する体験フォトを置く公開写真保存S3バケットの定義は次のようになります。

photoProtectionバケットexperiencePhotoForUser/{entity_id}/*というプレフィックスでオブジェクトを作成・参照します。プレフィックスに含まれるentity_idは、Cognito IDプールのidentityIdになります。
そのため、Cognito IDプールの承認されたロールのポリシーは次のようになります。

Cognito IDプールのidentityIdをIAMポリシーの変数として利用し、S3のプレフィックス単位でユーザーごとの認可制御を行う仕組みになっています。
そのため、AmplifyアプリからのS3オブジェクトの操作にはidentityIdを指定することになります。テーマパークWebサイトでユーザーの体験フォト取得部分の抜粋は次のようになります。

getUrl()はS3署名付きURLを返してくれるAmplifyライブラリの便利な関数です。 AmplifyアプリからS3オブジェクトへアクセスするだけの場合はここまでの内容で大丈夫ですが、今回は体験フォト処理基盤があるため、Amplify Storageで作成したS3バケットへAmplifyで構築した以外のシステムからアクセスする必要があります。

Amplify Storageへの外部システムからのアクセス

WorkSpaces Applicationsの体験フォト確認アプリからAmplify StorageのS3バケットへ公開用体験フォトを書き込みます。この際に書き込んだ体験フォトも、Amplifyアプリからのアクセスは上記Cognito IDプールを使用するため、認可制御の対象となります。
つまりWorkSpaces Applicationsから体験フォトを配置する際にも、experiencePhotoForUser/{entity_id}/* のプレフィックスルールに従う必要があります。
しかし、WorkSpaces ApplicationsのアプリはユーザーIDとIDプールのidentityIdを紐づけることができません。そのために今回は、ユーザーIDとidentityIdの対応表としてDynamoDBユーザー情報テーブルを作成し、Amplifyアプリへのサインアップ時(初回サインイン時)にそのテーブルへアイテムを作成しています。これでAmplify Storageへの外部システムからの書き込みが実現できます。
残る考慮点として、identityIdが変わった場合の対応があります。今回は実装していませんが、ログイン時にチェックして対応したり、バッチ的に対応するなど、長期的な運用を行う場合は検討する必要があります。

外部システムからAmplify DataのDynamoDB書き込みはAppSyncを必ず通す

Amplify Storageへの書き込みと同様に、Amplify Dataへの書き込みもAmplifyの仕組みに合わせてあげる必要があります。もう少し正確に言うと、AmplifyがAppSyncに定義したスキーマやGraphQLの仕様に合わせる必要があります。DynamoDBへアイテムを直接書き込むことはできますが、Amplifyアプリからのクエリは定義されたスキーマや仕様に基づくクエリになるため、書き込んだアイテムがヒットしない場合があります。また、直接書き込んだアイテムに対するエラーなどは特に出ないため、他のアイテムはヒットするが実は一部欠落しているなど、特定が難しい状況に陥ることもあります。
そのため、Amplify Dataで作成されたDynamoDBへの書き込みは必ずAppSyncを通して行う必要があります。

外部システムからAmplify Dataへのアクセス

VPCからAmplify作成AppSyncへのクエリはProxyが必要

外部システムからAmplify DataへのアクセスはAppSyncを通すと書きましたが、状況によっては問題が生じます。今回のようにVPC内部のリソースからアクセスする場合です。AppSync APIはPublic APIとPrivate APIの2種類あり、API作成時にいずれかを選択します。VPC内部からアクセスする場合、VPCエンドポイントを介してAppSyncへアクセスしますが、Public APIへはアクセスできず、Private APIのみアクセスすることができます。
Amplify Dataにより作成されるAppSync APIはPublic APIであるため、今回のようにインターネットフェーシングでないWorkSpaces Applicationsからはアクセスすることができません。そのため、今回はLambda関数でクエリをプロキシします。

AppSync Public APIへのアクセスをLambda関数でプロキシ

GraphQLクエリをそのままプロキシするだけなので、複雑な実装は必要なく、QueryもMutationも同じように扱うことができます。ソースコードは次のようになります。

メール送信のためSES本稼働に必要となる対応

最後に、ユーザーへのメール通知に用いるSESについても触れておきます。SESは様々な制約があるサンドボックス環境と、本稼働環境に分けられており、本稼働環境へ移るにはAWSへリクエストを行う必要があります。ユーザーがいるようなシステムで使用する場合は本稼働リクエストが必須です。
バウンスや申し立て、受信者リストのメンテナンスなどを考慮すると、開発環境の段階で本稼働リクエストが必要になります。今回、このシステムを検証するに当たり、実際にSES本稼働リクエストを行い、必要となる対応や考慮点をまとめました。以下の発表資料として公開しています。

発表資料

中心的な要素であるRekognitionによる自動モザイク処理と、WorkSpaces Applicationsによるセキュアなヒューマンインザループについて、社内イベントで発表しました。
発表資料を以下で公開しています。

おわりに

今回のシステムは実際に友達とテーマパークに行った際に思いつきました。アイデアを思い付いたときに、AWSで実現するならどうするかを考えるのはとても楽しいです。AWSの、思いついたアイデアをBuilding Blockを使ってすぐに実現できるところが気に入っています。テクノロジー的な部分以外に、コミュニティの温かさもAWSが好きな理由になっています。JAWSやTop Engineerのイベントで仲間が沢山でき、人生の豊かさを感じました。