GCPを活用して不適切な画像をAIで検出してぼかしてみた
はじめに
Cloud Storage, Cloud Pub/Sub, Cloud Run、Cloud Vision APIを使用して、Cloud Storage バケットにアップロードされた不適切な画像をAIで検出してぼかすサンプルサービスを作ってみました。
本記事は以下のチュートリアルに基づいています。Terraformを使って実装してみました。
今回のコードがまとまっているリポジトリ
完成イメージ
以下の画像のように、不適切な画像にはぼかしが入ります
処理の流れ
- ユーザーがCloud Storage バケットに画像をアップロードします。
- Cloud Storage が新しいファイルに関するメッセージを Pub/Sub にPublishします。
- Pub/Sub はメッセージを Cloud Run サービスにPushします。
- Cloud Run サービスが、Pub/Sub メッセージで参照されている画像ファイルを取得します。
- Cloud Run サービスが、Cloud Vision API を使用して画像を分析します。
- 暴力的なコンテンツやアダルトコンテンツが見つかった場合、Cloud Run サービスが ImageMagick を使用して画像をぼかします。
- 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
リクエストを処理するサーバーのコードです
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 メッセージをイベント オブジェクトとして受信し、画像処理を制御します。
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 による暴力的なコンテンツやアダルト コンテンツの分析結果を確認することで、画像をぼかす必要があるかどうかを判断します。
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 を使って画像をぼかし効果で変換してから、結果を出力バケットにアップロードします。
// 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
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 /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]
}
動作確認
- ゾンビの画像をgcsにアップロードしてみます
gsutil cp zombie.jpg gs://INPUT_BUCKET_NAME
- ぼかし画像出力用のバケットにぼかし画像が追加されていました!🎉
おわりに
GCPのチュートリアルの内容がすごくわかりやすかったのでサクサク進めることができました。
本記事の内容はGCPのチュートリアルの二番煎じ記事になってしまいたが、手を動かしながら学んだことをアウトプットできてよかったです。
定期的に記事書けたらいいなーと考えています。
(頑張ります...!!)
以上、Applibot Advent Calendar 2023 の10日目の記事でした。
リポジトリ
冒頭にも書きましたが今回のコードは以下のリポジトリに置いてあります
Discussion