👁️

GCPを活用して不適切な画像をAIで検出してぼかしてみた

2023/12/10に公開

はじめに

Cloud Storage, Cloud Pub/Sub, Cloud Run、Cloud Vision APIを使用して、Cloud Storage バケットにアップロードされた不適切な画像をAIで検出してぼかすサンプルサービスを作ってみました。

本記事は以下のチュートリアルに基づいています。Terraformを使って実装してみました。
https://cloud.google.com/run/docs/tutorials/image-processing?hl=ja#trying_it_out

今回のコードがまとまっているリポジトリ
https://github.com/TonkyH/imageMagick

完成イメージ

以下の画像のように、不適切な画像にはぼかしが入ります

処理の流れ

  1. ユーザーがCloud Storage バケットに画像をアップロードします。
  2. Cloud Storage が新しいファイルに関するメッセージを Pub/Sub にPublishします。
  3. Pub/Sub はメッセージを Cloud Run サービスにPushします。
  4. Cloud Run サービスが、Pub/Sub メッセージで参照されている画像ファイルを取得します。
  5. Cloud Run サービスが、Cloud Vision API を使用して画像を分析します。
  6. 暴力的なコンテンツやアダルトコンテンツが見つかった場合、Cloud Run サービスが ImageMagick を使用して画像をぼかします。
  7. Cloud Run サービスは、ぼかした画像を別の Cloud Storage バケットにアップロードします。

Cloud Storage

バケット作成

2つの Cloud Storage バケットを作成します。
一つは元の画像をアップロードするためのもので、もう一つは Cloud Run サービスがぼかしを入れた画像をアップロードするためのものです。

resource "random_id" "bucket_suffix" {
  byte_length = 8
}

resource "google_storage_bucket" "imageproc_input" {
  name          = "input-bucket-${random_id.bucket_suffix.hex}"
  location      = "asia-northeast1"
  force_destroy = true
}

output "input_bucket_name" {
  value = google_storage_bucket.imageproc_input.name
}

resource "google_storage_bucket" "imageproc_output" {
  name          = "output-bucket-${random_id.bucket_suffix.hex}"
  location      = "asia-northeast1"
  force_destroy = true
}

output "blurred_bucket_name" {
  value = google_storage_bucket.imageproc_output.name
}

terraform destoryでバケットを削除しやすいよう、force_destroy = trueの設定を追加しています

Cloud Pub/Sub

トピックの作成

今回のサンプルサービスはCloud StorageからPub/Sub トピックに公開されたメッセージ(Cloud Storageにアップロードされた新しいファイルに関するメッセージ)によってトリガーされるため、Pub/Sub でトピックを作成する必要があります。

resource "google_pubsub_topic" "default" {
  name = "pubsub_topic"
}

画像処理のコードを追加

go

リクエストを処理するサーバーのコードです

main.go
package main

import (
	"log"
	"net/http"
	"os"
	"sampleImageMagick/pubsub"
)

func main() {
	http.HandleFunc("/", pubsub.HelloPubSub)
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
		log.Printf("Defaulting to port %s", port)
	}

	log.Printf("Listening on port %s", port)
	if err := http.ListenAndServe(":"+port, nil); err != nil {
		log.Fatal(err)
	}
}

Pub/Sub メッセージをイベント オブジェクトとして受信し、画像処理を制御します。

pubsub/pubsub.go
package pubsub

import (
	"encoding/json"
	"io"
	"log"
	"net/http"
	"sampleImageMagick/imagemagick"
)

type PubSubMessage struct {
	Message struct {
		Data []byte `json:"data,omitempty"`
		ID   string `json:"id"`
	} `json:"message"`
	Subscription string `json:"subscription"`
}

func HelloPubSub(w http.ResponseWriter, r *http.Request) {
	var m PubSubMessage
	body, err := io.ReadAll(r.Body)
	if err != nil {
		log.Printf("io.ReadAll: %v", err)
		http.Error(w, "Bad Request", http.StatusBadRequest)
		return
	}

	if err := json.Unmarshal(body, &m); err != nil {
		log.Printf("json.Unmarshal: %v", err)
		http.Error(w, "Bad Request", http.StatusBadRequest)
		return
	}

	var e imagemagick.GCSEvent
	if err := json.Unmarshal(m.Message.Data, &e); err != nil {
		log.Printf("json.Unmarshal: %v", err)
		http.Error(w, "Bad Request", http.StatusBadRequest)
		return
	}

	if e.Name == "" || e.Bucket == "" {
		log.Printf("invalid GCSEvent: expected name and bucket")
		http.Error(w, "Bad Request", http.StatusBadRequest)
		return
	}

	if err := imagemagick.BlurOffensiveImages(r.Context(), e); err != nil {
		log.Printf("imagemagick.BlurOffensiveImages: %v", err)
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
	}
}

Pub/Sub メッセージには、最初にアップロードされた画像に関するデータが含まれます。
imagemagick.goではCloud Vision による暴力的なコンテンツやアダルト コンテンツの分析結果を確認することで、画像をぼかす必要があるかどうかを判断します。

imagemagic/imagemagick.go
package imagemagick

import (
	"context"
	"errors"
	"fmt"
	"log"
	"os"
	"os/exec"

	"cloud.google.com/go/storage"
	vision "cloud.google.com/go/vision/apiv1"
	"cloud.google.com/go/vision/v2/apiv1/visionpb"
)

var (
	storageClient *storage.Client
	visionClient  *vision.ImageAnnotatorClient
)

func init() {
	var err error

	storageClient, err = storage.NewClient(context.Background())
	if err != nil {
		log.Fatalf("storage.NewClient: %v", err)
	}

	visionClient, err = vision.NewImageAnnotatorClient(context.Background())
	if err != nil {
		log.Fatalf("vision.NewAnnotatorClient: %v", err)
	}
}

// GCSEvent GCS eventのペイロード部分
type GCSEvent struct {
	Bucket string `json:"bucket"`
	Name   string `json:"name"`
}

// BlurOffensiveImages GCSにアップロードされた不適切な画像(暴力的なコンテンツやアダルトなコンテンツ)をぼかす
func BlurOffensiveImages(ctx context.Context, e GCSEvent) error {
	outputBucket := os.Getenv("BLURRED_BUCKET_NAME")
	if outputBucket == "" {
		return errors.New("BLURRED_BUCKET_NAME must be set")
	}

	img := vision.NewImageFromURI(fmt.Sprintf("gs://%s/%s", e.Bucket, e.Name))

	resp, err := visionClient.DetectSafeSearch(ctx, img, nil)
	if err != nil {
		return fmt.Errorf("AnnotateImage: %w", err)
	}

	if resp.GetAdult() == visionpb.Likelihood_VERY_LIKELY ||
		resp.GetViolence() == visionpb.Likelihood_VERY_LIKELY {
		return blur(ctx, e.Bucket, outputBucket, e.Name)
	}
	log.Printf("The image %q was detected as OK.", e.Name)
	return nil
}

Cloud Storage 入力バケットから参照画像を取得し、ImageMagick を使って画像をぼかし効果で変換してから、結果を出力バケットにアップロードします。

pubsub/pubsub.go

// blur gs://inputBucket/name に格納された画像をぼかし、その結果を gs://outputBucket/name に格納します。
func blur(ctx context.Context, inputBucket, outputBucket, name string) error {
	inputBlob := storageClient.Bucket(inputBucket).Object(name)
	r, err := inputBlob.NewReader(ctx)
	if err != nil {
		return fmt.Errorf("NewReader: %w", err)
	}

	outputBlob := storageClient.Bucket(outputBucket).Object(name)
	w := outputBlob.NewWriter(ctx)
	defer w.Close()

	// Use - as input and output to use stdin and stdout.
	cmd := exec.Command("convert", "-", "-blur", "0x8", "-")
	cmd.Stdin = r
	cmd.Stdout = w

	if err := cmd.Run(); err != nil {
		return fmt.Errorf("cmd.Run: %w", err)
	}

	log.Printf("Blurred image uploaded to gs://%s/%s", outputBlob.BucketName(), outputBlob.ObjectName())

	return nil
}

docker

Dockerfile
FROM golang:1.21-bookworm as builder

WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . ./
# Build the binary.
RUN CGO_ENABLED=0 go build -v -o server

FROM alpine:3
RUN apk add --no-cache imagemagick
RUN apk add --no-cache ca-certificates
COPY --from=builder /app/server .

CMD ["/server"]

Container Registry

Cloud Build でコンテナイメージをビルドし、Container Registry に公開します。

gcloud builds submit --tag gcr.io/PROJECT_ID/pubsub

例: gcloud builds submit --tag gcr.io/hoge-project-123456/pubsub

Cloud Run

Serviceの作成

Cloud Run サービスを作成します。

# Cloud Run APIを有効化する
resource "google_project_service" "cloudrun_api" {
  service            = "run.googleapis.com"
  disable_on_destroy = false
}

resource "google_cloud_run_v2_service" "default" {
  name     = "pusub-tutorial"
  location = "asia-northeast1"
  template {
    containers {
      # 作成したコンテナイメージに置き換えてください gcr.io/<project_id>/pubsub
      image = var.application_image
      env {
        name  = "BLURRED_BUCKET_NAME"
        value = google_storage_bucket.imageproc_output.name
      }
      ports {
        container_port = 8080
      }
    }
  }
  depends_on = [google_project_service.cloudrun_api, google_storage_bucket.imageproc_output]
}

Pub/Sub → Cloud Run の連携

サービスアカウントを作成します

resource "google_service_account" "sa" {
  account_id   = "cloud-run-pubsub-invoker"
  display_name = "Cloud Run Pub/Sub Invoker"
}

サービスアカウントへ権限の付与を行います。

# 呼び出し元のサービスアカウントに、pubsub-tutorial サービスを呼び出すための権限を付与します。
resource "google_cloud_run_service_iam_binding" "binding" {
  location = google_cloud_run_v2_service.default.location
  service  = google_cloud_run_v2_service.default.name
  role     = "roles/run.invoker"
  members  = ["serviceAccount:${google_service_account.sa.email}"]
}

Pub/Sub がプロジェクトで認証トークンを作成できるようにします。

resource "google_project_service_identity" "pubsub_agent" {
  provider = google-beta
  project  = var.project_id
  service  = "pubsub.googleapis.com"
}

resource "google_project_iam_binding" "project_token_creator" {
  project = var.project_id
  role    = "roles/iam.serviceAccountTokenCreator"
  members = ["serviceAccount:${google_project_service_identity.pubsub_agent.email}"]
}

作成したサービスアカウントを使用し, Pub/Sub サブスクリプションを作成します。

# サービスアカウントを使用して Pub/Sub サブスクリプションを作成します。
resource "google_pubsub_subscription" "subscription" {
  name  = "pubsub_subscription"
  topic = google_pubsub_topic.default.name
  push_config {
    push_endpoint = google_cloud_run_v2_service.default.uri
    oidc_token {
      service_account_email = google_service_account.sa.email
    }
    attributes = {
      x-goog-version = "v1"
    }
  }
  depends_on = [google_cloud_run_v2_service.default]
}

Cloud Storageからの通知を有効化する

ファイルがCloud Storageにアップロードまたは変更されるたびに、Pub/Sub トピックにメッセージを公開するように設定します
以前に作成したトピックに通知を送信して、新しいファイルのアップロードでCloud Runサービスが起動するようにします。

そのためにはPub/Sub トピックに対する IAM 権限 pubsub.publisher が付与されている必要があります。

data "google_storage_project_service_account" "gcs_account" {}

resource "google_pubsub_topic_iam_binding" "binding" {
  topic   = google_pubsub_topic.default.name
  role    = "roles/pubsub.publisher"
  members = ["serviceAccount:${data.google_storage_project_service_account.gcs_account.email_address}"]
}

resource "google_storage_notification" "notification" {
  bucket         = google_storage_bucket.imageproc_input.name
  payload_format = "JSON_API_V1"
  topic          = google_pubsub_topic.default.id
  depends_on     = [google_pubsub_topic_iam_binding.binding]
}

動作確認

  1. ゾンビの画像をgcsにアップロードしてみます
gsutil cp zombie.jpg gs://INPUT_BUCKET_NAME

  1. ぼかし画像出力用のバケットにぼかし画像が追加されていました!🎉

おわりに

GCPのチュートリアルの内容がすごくわかりやすかったのでサクサク進めることができました。
本記事の内容はGCPのチュートリアルの二番煎じ記事になってしまいたが、手を動かしながら学んだことをアウトプットできてよかったです。

定期的に記事書けたらいいなーと考えています。
(頑張ります...!!)

以上、Applibot Advent Calendar 2023 の10日目の記事でした。

リポジトリ

冒頭にも書きましたが今回のコードは以下のリポジトリに置いてあります
https://github.com/TonkyH/imageMagick

Discussion