サーバーサイドで動的にOGP画像をシュッと作る方法 - FastAPIとCairoSVGで作る画像生成API

JX通信社シニア・エンジニアの@shinyorke(しんよーく)です.

最近は色んなエンジニアリングをしつつ, イベントの司会業をしています(詳細は最後の方を見てね).

開発しているサービス・プロダクトの要件で,

  • TwitterやLINE, FacebookでシェアするOGP*1コンテンツ(タイトル・本文・画像)が欲しい
  • コンテンツはユーザーさんの操作で動的に変わる
  • テキストだけじゃなくて, 画像も変えたい←これ

なんて事は非常によくある話だと思います.

私はちょっと前に開発したAIワクチン接種予測でそれがありました.

f:id:shinyorke:20210521183040j:plain
こういうやつです

例えば上記画像のテキスト(地域・年齢・接種可能時期)は予測の結果を動的に画像テンプレートに入れて都度作っています.

上記のOGPを生成するために必要なことはこういう感じだろうなー, と以下の絵の通り整理し,

f:id:shinyorke:20210528180851j:plain
やったこと

結果的に, OGPを生成するためのサービスを200行にも満たない小さいAPIサーバーとして実現しました.

このエントリーでは,

  • CairoSVGを使った画像生成
  • FastAPI + CairoSVG + GCSを使ったAPI構築
  • Cloud Runでの運用

で上記のサービスを作った事例を紹介します.

なおこのエントリーは,

  • PythonでWebアプリを作ったことがある
  • FastAPIおよびJinja2を使える/使ったことがある
  • ラスタ画像とベクター画像の意味がわかる

程度の前提知識を読者の皆さまが持っている, と想定して執筆しています(Pythonアプリ開発初級みたいなノリです).

TL;DR

  • FastAPIとCairoSVGで画像の動的生成はいい感じにできちゃいます
  • テンプレートエンジンは迷ったらJinja2
  • フォント指定はハマるので気をつけよう

おしながき

ベクター素材から画像を作る🐍

前述の通り, やりたいことは

  • 地域・年齢・接種可能時期といったテキストをAIさんが準備し
  • 予め用意したテンプレートのベクター素材にテキストを埋め込み
  • pngなどのラスタ形式に変換(いわゆるラスタライズ)

です.

自分で調べたり, 社内で色々聞き回った結果,

  • SVGで素材を準備して(デザイナーさんにお願いして)それを使ってサーバーサイドでラスタライズする
  • SVGからのラスタライズはPythonのライブラリでできるっぽい

というのがわかったので早速やりました.

CairoSVGで画像を作る

SVGをラスタライズする手段は意外とあっさりで, CairoSVGというライブラリで解決しました.

cairosvg.org

github.com

公式サイトのサンプルを見る限り, やりたいことは出来そうとわかりました.

# pip install cairosvg で普通に入ります
import cairosvg

# SVGからPDF
cairosvg.svg2pdf(url='sample.svg', write_to='sample.pdf')


# SVGからpng(これが本命でやりたかったこと)
cairosvg.svg2png(url='sample.svg', write_to='sample.png')

コードもすごく短いですし, とても良さそうです👍

SVG素材をJinja2テンプレにする

やりたいことはCairoSVGでできそうとわかったのですが, 前述の通り今回やりたいのは

  • 地域・年齢・接種可能時期といったテキストをAIさんが準備(できている)
  • 予め用意したテンプレートのベクター素材にテキストを埋め込み
  • pngなどのラスタ形式に変換(CairoSVGでやれる)

で, 肝心の「テンプレートのベクター素材にテキストを埋め込み」が解決していません.

ちなみにやりたいことをコードに起こすとこんな感じです.

# 実際のコードとは異なります(あくまでサンプルです)

import cairosvg

def convert_ogp(area: str, age: int, period: str):
    # 何かしらの方法でSVGフォーマットなテキストを入手
   ogp_context = 'TODO: ここにSVGの中身が入る'
   # TemporaryFileとして書き出して後byteでもらう
    with NamedTemporaryFile('w') as f:
        f.write(ogp_context)
        f.flush()
        image_bytes = svg2png(url=f.name, write_to=None)
    return image_bytes

image = convert_ogp('東京都', 40, '10月下旬〜2月上旬')

一番単純な方法はヒアドキュメントとしてSVGテキストを用意して置換することなのでしょうが, デザイン素材を(文字列とはいえ)Pythonコードに書くのは若干気が引けた*2ので,

  • SVG素材をJinja2テンプレとして書き直す
  • 上記Jinja2テンプレを元に画像生成する

という方法でいい感じにやりました.

# 実際のコードとは異なります(あくまでサンプルです)

import cairosvg
from jinja2 import Environment, FileSystemLoader


def convert_ogp(area: str, age: int, period: str, template_file: str):
    # テンプレファイルと引数からテキストを生成
    env = Environment(loader=FileSystemLoader(os.path.dirname(template_file)))
    ogp_template = env.get_template(os.path.basename(template_file))
    ogp_context = ogp_template.render(area=area, age=age, period=period)

   # TemporaryFileとして書き出して後byteでもらう
    with NamedTemporaryFile('w') as f:
        f.write(ogp_context)
        f.flush()
        image_bytes = svg2png(url=f.name, write_to=None)
    return image_bytes

# area, age, periodという変数を持ったjinja2テンプレを事前に準備
image = convert_ogp('東京都', 40, '10月下旬〜2月上旬', 'templates/ogp.svg.j2')

テキスト(ベクター)として扱う時はまずJinja2ってくらいよく使ってるのでこれはあっさり思いついてすぐ実現できました.

何かしらのフォーマット(HTMLでもXMLでもYAMLでもJSONでも)で動的にコンテンツを生産したい時はJinja2便利です.

流石Pythonを代表するテンプレートエンジンなだけあります*3.

APIサーバーにする

画像生成の仕組みはこれで目処が付いたので, API化します.

FastAPIサーバーとして用意する

やることは,

  • 予測結果を受け取って画像をGoogle Cloud StorageにuploadするAPIを作る
  • 予測結果はPOSTパラメーターとして受け取る

です.

APIはFlask, bottle, FastAPIと比較的軽めなFrameworkなら何でも大丈夫*4なのですが今回は(社内でよく使ってる)FastAPIで実現しました.

# 実際のAPIはもっとちゃんと作ってます(イメージを掴むためのサンプルです)
from fastapi import FastAPI
from pydantic import BaseModel

# さっきのOGP生成関数
from sample_image import convert_ogp


class OgpContext(BaseModel):
    """
    OGPで使う項目
    """
    age: int
    area: str
    period: str

# docは公開する必要ない(しちゃ🙅)なので使いません
app = FastAPI(docs_url=None, redoc_url=None)


@app.post('/creage')
def create(form: OgpContext):
    image_byte = convert_ogp(form.area, form.age, form.period, 'templates/ogp.svg.j2')    
    # 取得したimageを何かしらの方法でアップロード
    upload(image_byte)
    return {'status': 'ok'}


if __name__ == '__main__':
    import os
    import uvicorn

    uvicorn.run(app, host='0.0.0.0', port=os.getenv('PORT', 8080))

画像作ってアップロードするだけなら(上記のコードでは端折ってる画像アップロード関数含めて)ほんの100〜200行で終わります.

Cloud Runでホスティング

実サービスのホスティングはCloud Runで行いました.

cloud.google.com

Dockerコンテナ一つで動く薄い画像生成APIとして作っていたので,

  • gcloudコマンド2回でデプロイまでいける
    • Cloud Buildでimage作ってレジストリ(GCR)にpush
    • Cloud Runにデプロイ
  • トラフィックに応じてインスタンスを増やす(減らす)がGUIコンソールをポチポチするだけでいける

という利便性を重視してCloud Runにしました.

ちなみに「コマンド2回でデプロイまで」はこんな感じです

# Docker buildとimage push
gcloud builds submit --tag asia.gcr.io/example-test-prj/ogp-generator

# 上記imageをdeploy
gcloud run deploy --image asia.gcr.io/example-test-prj/ogp-generator --platform managed

たったこれだけで終わるの最高です.

ハマったこと

ちなみに開発中ハマったこととして, 文字化けがありました.

  • 開発環境(自分のMac)では文字化けしない
  • Docker imageから作った環境だと文字化けする

原因は明確で, 「Macには存在するフォントだけどDocker image(Debian)には存在しないフォント」でやったのでこの差がでました.

FROM python:3.9

# install
COPY poetry.lock pyproject.toml ./
RUN pip install poetry
RUN poetry config virtualenvs.create false \
  && poetry install --no-dev

# app
COPY app.py ./
ADD templates templates

# templates/fonts配下のフォントを然るべき所にコピー
COPY templates/fonts /usr/share/fonts/truetype/dejavu

CMD  python app.py

SVGフォーマット上で使ってるフォントを洗い出し, 見つけて追加して無事解決しました.

運用の結果&結び

というわけで, このエントリーではサーバーサイドで動的に画像を作る方法について紹介しました.

AIワクチン接種予測がTVに出たときもこの仕組みが動いていたのですが, 大きなトラブルもなくトラフィックに耐えきったのでやり方として正解だった様に思えます.

なお, このエントリーは(このブログを書く前に)社内のWin Sessionでネタを披露したところ, ウケが良かったので今回ブログとして公開することにしました.

これで似たような課題を抱えている皆さまの手助けになると幸いです.

JX Press Tech Talk でPythonの話やります!

最後にちょこっと宣伝です.

jxpress.connpass.com

好評だった前回に続きまして, JX Press Tech Talk第二弾を6/23(水)にやることになりました!

私は前回に続いて司会をやると同時に「StreamlitとFlaskでPoCから本番運用までやりきったやで📺」的なお話をさせていただきます.

二刀流で頑張りたいと思います, お時間ある方ぜひいらしてください!

*1:Open Graph Protocolのことで, かんたんに言うとSNSシェアする時に出てくる文章や画像を定義するための仕様・お決まりのことです. ちなみにTwitterはTwitter Cardという独自のお決まりがあります(やろうとしてることはOGPと一緒)

*2:「明日にもリリースしなきゃ」という状況だったらこの方法は全然アリです. 今回は(タイトなスケジュールだったとはいえ)そこまでじゃなかったのでテンプレとコードはちゃんと分離しました.

*3:Flaskのデフォルトテンプレートエンジンだったりしますし, Djangoでも使ったりします. また, AnsibleのplaybookはJinja2がベースだったりします.

*4:もちろんDjango(厳密にはDjango REST framework)も候補になると思います. が今回はホントに薄いラッパーなので軽量FWを中心に考えました.