ハトマスクステッカーアプリを作ったよ

ハトマスクステッカーアプリって?

顔や全身が写っている写真をURL、または、ファイルで指定すると、適当な感じでステッカーと合成するアプリ。 Agile Japan 2020 ハトマスクステッカー(未認可・非公式)おみまいするぞ で遊ぶことができる。 Agile Japan 2020というイベントのちょっとしたお遊びとして作ろうと思ったのがきっかけ。

f:id:couger:20201123213035p:plain
ハトマスクステッカー「に」合成

f:id:couger:20201123213023p:plain
ハトマスクステッカー「を」合成

どう作った?

コマンドラインバージョン

最初はコマンドラインで、「ハトマスクステッカー合成」する機能を作ろうとしていた。

f:id:couger:20201123213035p:plain

最初の一歩として、写真の背景を切り抜く処理を探す。なるべく自動化したいので、APIが提供されているものを探して、見つけたのは以下の2つ。

残念だけど両方ともお財布に合わなかったので、別のものを探すことに。でもなかなか見つからない...。自分の財力ではムリがある。 GitHubにないかなぁと "github remove background" でググったら、トピックのリストが見つかった!!!

github.com

なんとなく使ってる人の多そうな danielgatis/rembg: Rembg is a tool to remove images background. を使うことにした。 GitHubスゴい。OSSスゴい。

説明を見ながらCLIで実行。キレイに切り抜きができた!!! スゴい。 更に、ステッカーに合成する簡単なプログラムを見よう見まねでPythonで作り実行。上手くいった!
※ RembgがPythonだったのでそれに合わせた。

その時、作ったコードが以下である。

curl -s $1 | rembg > work/target.png
python hatomask-gosei.py work/target.png
import sys
from PIL import Image

def resize_image(original, width):
  return original.resize((width, (int)(width * original.size[1] / original.size[0])))

hatomask = Image.open('hatomask-aj2020.png')

pathToTarget = sys.argv[1]

target = resize_image(Image.open(pathToTarget), 300)

hatomask.paste(target, (1024-300, 600), target)
hatomask.save('omimaisuruzo.png')

これで、合成したい写真のURLを指定すれば、ハトマスクステッカーに合成ができるようになった。ここで辞めておけばよかったのに、Webアプリにしたい欲求が湧いてきてしまう。
そういえば、最近プログラム全くしてなかったし。Azureも久しぶりに使ってみようかと軽い気持ちで始めたら、結局土日2日を潰すことになった。思いつきはだいたい高くつく...。

Webアプリにする

まずは軽いWebアプリフレームワークと、CSSフレームワークを探す。選んだのは以下の2つ。

初めて触るフレームワークだったので、以下のような感じで1ステップずつ進めていった。都度、App Serviceにデプロイして動作確認をしていたと思う。

  • 画面を表示する
  • 画像のURLが指定できるようにする
  • 指定したURLの画像を表示する

この後、Rembgを使った「指定したURLの画像の背景を切り抜く」処理を追加、App Serviceにデプロイしようとするとエラーが出た。 数回試したけどダメ。エラーメッセージにはメモリがないとかディスクのスペースがないとか出てくる。仕方がないので、プランをFreeから、B1にグレードアップ。やっぱり失敗する。

泣く泣くP1v2にして、デプロイが成功。ただし、デプロイに10分くらいかかる。ツラい。1ヶ月使うと1万円かかる。ツラい。
※ S1にしなかった理由は、P1v2と比べて1000円くらいしか違わないから。なんとなく性能の問題のような気もしたし。

とはいえ、のんびり調査している暇もないので、そのまま開発を続けて最初のバージョンをリリースした。 リリースした後「ハトマスクステッカーを合成」する方がニーズあるんじゃないかな? と思い、急遽機能を追加、次の日に再度リリース。

最終的なコードは以下(アプリ部分のみ)。

import sys
import io
import base64
import urllib.request
import hashlib
from PIL import Image
from flask import Flask, render_template, request
from rembg.bg import remove
app = Flask(__name__)

app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024

def resize_image(original, width):
  return original.resize((width, (int)(width * original.size[1] / original.size[0])))

def crop_image(original):
  # https://stackoverflow.com/questions/14211340/automatically-cropping-an-image-with-python-pil/51703287
  imageSize = original.size
  imageBox = original.getbbox()

  imageComponents = original.split()

  rgbImage = Image.new("RGB", imageSize, (0,0,0))
  rgbImage.paste(original, mask=imageComponents[3])
  croppedBox = rgbImage.getbbox()

  return original.crop(croppedBox)

def convert_image_to_base64(image):
  image_buffered = io.BytesIO()
  image.save(image_buffered, format="PNG")
  return base64.b64encode(image_buffered.getvalue()).decode("utf-8")

def remove_background(imageStream):
  r = lambda i: i.buffer.read() if hasattr(i, "buffer") else i.read()
  w = lambda o, data: o.buffer.write(data) if hasattr(o, "buffer") else o.write(data)

  result = io.BytesIO()

  w(
    result,
    remove(
      r(imageStream),
      model_name="u2net",
      alpha_matting=False,
      alpha_matting_foreground_threshold=240,
      alpha_matting_background_threshold=10,
      alpha_matting_erode_structure_size=10,
    ),
  )

  return Image.open(result)

def hatomask_omimaisuruzo(target):
  result = Image.open('static/images/hatomask-aj2020.png')
  target_resized = resize_image(target, 300)
  result.paste(target_resized, (1024-300, 600), target_resized)
  return result

def sticker_ga_ikuzo(target):
  sticker = Image.open('static/images/hatomask-aj2020-rembg.png')
  sticker_resized = resize_image(sticker, (int)(target.size[0] / 3))

  paste_lefttop = (target.size[0] - sticker_resized.size[0], target.size[1] - sticker_resized.size[1])
  target.paste(sticker_resized, paste_lefttop, sticker_resized)
  return target


@app.after_request
def add_header(r):
    r.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
    r.headers["Pragma"] = "no-cache"
    r.headers["Expires"] = "0"
    r.headers['Cache-Control'] = 'public, max-age=0'
    return r

@app.route("/")
def hello():
  return render_template('aj2020.html')

@app.route('/sareruzo_url', methods=['POST'])
def sareruzo_url():
  imageUrl = request.form.get('imageUrl')
  f = io.BytesIO(urllib.request.urlopen(imageUrl).read())
  target_removebg = remove_background(f)
  target_cropped = crop_image(target_removebg)

  hatomasked = hatomask_omimaisuruzo(target_cropped)
  return render_template('aj2020-omimaishitazo.html', imageBase64=convert_image_to_base64(hatomasked))


@app.route('/sareruzo_upload', methods=['POST'])
def sareruzo_upload():
  if 'imageFile' not in request.files:
    return render_template('aj2020-omimaishitazo.html', imageFile="images/hatomask-aj2020.png")

  imageFile = request.files['imageFile']
  imageFileName = imageFile.filename

  if '' == imageFileName:
    return render_template('aj2020-omimaishitazo.html', imageFile="images/hatomask-aj2020.png")

  target_removebg = remove_background(imageFile.stream)
  target_cropped = crop_image(target_removebg)

  hatomasked = hatomask_omimaisuruzo(target_cropped)
  return render_template('aj2020-omimaishitazo.html', imageBase64=convert_image_to_base64(hatomasked))

@app.route('/suruzo_url', methods=['POST'])
def suruzo_url():
  imageUrl = request.form.get('imageUrl')
  imageFile = Image.open(io.BytesIO(urllib.request.urlopen(imageUrl).read()))

  hatomasked = sticker_ga_ikuzo(imageFile)
  return render_template('aj2020-omimaishitazo.html', imageBase64=convert_image_to_base64(hatomasked))


@app.route('/suruzo_upload', methods=['POST'])
def suruzo_upload():
  if 'imageFile' not in request.files:
    return render_template('aj2020-omimaishitazo.html', imageFile="images/hatomask-aj2020.png")

  imageFile = request.files['imageFile']
  imageFileName = imageFile.filename

  if '' == imageFileName:
    return render_template('aj2020-omimaishitazo.html', imageFile="images/hatomask-aj2020.png")

  hatomasked = sticker_ga_ikuzo(Image.open(io.BytesIO(imageFile.read())))
  return render_template('aj2020-omimaishitazo.html', imageBase64=convert_image_to_base64(hatomasked))

ハマったところは?

Azure App ServiceのFreeプランでは動かない

B2プランなら大丈夫。Freeプランでは、Rembgを動かすためのリソースが足りないのだろうと推測。

やりたい処理によって画像ファイルを扱うクラスを変える必要がある

コピペ優先でホイホイ作ってたから、この辺を理解しておらず、追加機能を作る際に苦労した。
ちなみに以下の3つを覚えておけば多分いいはず。(それくらい最初に調べとけってツッコミは甘んじで受けます...)

  • アップロードされたファイル: werkzeug.datastructures.FileStorage
  • 画像の透過、貼り付け: PIL.Image
  • Rembgを使った背景除去: io.BytesIO

今後は?

ステッカーの種類を増やしたり、位置や大きさを変えられるようにしようかなーと思ってたりするんだけども。さて。