0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ちょうどいいサイズの画像ウォーターマーク API を FastAPI で作った

0
Last updated at Posted at 2026-05-02

きっかけ

画像ウォーターマークは些細に見えて、気づくとロードベアリングになっている機能だ。写真マーケットプレイスの著作権スタンプ、ドキュメントプレビューの「DRAFT」オーバーレイ、プリントオンデマンドのスクリーンショット対策、求人サイトの履歴書ブランディング。どれも難しくないが、いずれもメインアプリにインラインで書かれ、そのまま居着く。

選択肢を検討した結果:

  • アプリにインライン: リクエストワーカーが画像デコードでブロックされ、Pillow の CVE 対応がメインサービスに影響
  • ImageMagick: CVE の歴史が長く、Docker イメージが 200 MB 超
  • SaaS (Cloudinary, Imgix 等): テキスト 6 ピクセルのためにリクエスト課金は割に合わない
  • Lambda + Sharp (Node): Python ショップに Node ビルドを持ち込み、コールドスタートを管理する必要

正しい形は小さな単目的 HTTP サービス。1 つのエンドポイントカテゴリ、1 つのライブラリ、1 つの Dockerfile、クラウドベンダーゼロ。

📦 GitHub: https://github.com/sen-ltd/watermark-api

Screenshot

作ったもの

watermark-api は FastAPI + Pillow のウォーターマークサービス。テキストまたは画像オーバーレイの 2 エンドポイント。フォーマット保持 (JPEG → JPEG, PNG → PNG, WebP → WebP) が契約。約 110 MB の Alpine イメージ。

curl -sS -F "file=@photo.jpg" \
     -F "text=© SEN 2026" \
     -F "position=bottom-right" \
     -F "opacity=0.7" \
     http://localhost:8000/watermark/text -o photo-wm.jpg

技術的なポイント

アルファ合成が本体

サービスの大半は配管 — マルチパート解析、フォーマット検出、HTTP レスポンス。唯一思考が必要なのはウォーターマークが画像と出会う瞬間だ。

テキストを透明な RGBA レイヤーに描画し、Image.alpha_composite でベース画像に合成する。直接 paste するとアルファが効かず、テキスト下に不透明な矩形が出る:

layer = Image.new("RGBA", base.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
alpha = int(round(opacity * 255))
fill = (color[0], color[1], color[2], alpha)
draw.text((x - bbox[0], y - bbox[1]), text, font=font, fill=fill)
composed = Image.alpha_composite(base, layer)

draw.textbbox で正確なバウンディングボックスを取り、ベースラインオフセットを補正。古い textsize API ではグリフの left/top ベアリングが考慮されない。

JPEG 出力時の RGBA フラッテニング

JPEG にはアルファチャンネルがない。テキストレイヤーは RGBA なので、JPEG 保存前に RGB にフラッテニングする必要がある。convert("RGB") だけだと黒背景に対してアルファがドロップされ、アンチエイリアスのグリフ周辺に暗いハロが出る。白背景の RGB 画像を作り、アルファチャンネルをマスクにして paste する:

flat = Image.new(orig_mode, composed.size, (255, 255, 255))
flat.paste(composed, mask=composed.split()[3])

DejaVuSans をバンドル

フォントを FONT_PATH でマウントさせると、「テキストが四角になる」というサポートチケットが来る。Alpine の font-dejavu は約 2 MB。「デプロイして忘れる」サービスなら、ゼロコンフィグで動くほうが正しい。

マジックバイトによるコンテンツ検証

def sniff_image(data: bytes) -> ImageKind:
    if data[:3] == b"\xff\xd8\xff":
        return JPEG
    if data[:8] == b"\x89PNG\r\n\x1a\n":
        return PNG
    if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
        return WEBP
    raise UnsupportedImageError(...)

WebP は RIFF コンテナなので、オフセット 0 の RIFF とオフセット 8 の WEBP の両方をチェックしないと AVI/WAV と誤判定する。

フォントサイズはパーセント指定

font_size_pct は画像の高さに対するパーセンテージ。400px のサムネイルでも 4000px のプリントでも比例的に正しく表示される。ピクセル絶対値だとサイズ階層ごとに別呼び出しが必要。

限界と割り切り

  • RTL テキスト (アラビア語・ヘブライ語) は非対応: BiDi リオーダリングが必要。アプリ側で前処理する想定で、README に記載
  • テキスト折り返しなし: 200 文字制限で安全弁。キャプションはエッセイではない
  • タイル/ストライプモードなし: 商業的な海賊対策には対角タイルが一般的だが、YAGNI
  • 白背景フラッテニングは「最善のデフォルト」: 夜景写真では黒のほうがきれいだが、商品写真・書類スキャン・ポートレートは白に近い

おわりに

サービスの全面積は Swagger UI (/docs)、ヘルスプローブ (GET /health)、stdout に 1 行の JSON ログ。それだけ。110 MB で起動 1 秒未満。このサービスのピッチは「もう考えなくていい」であり、その先には何もない。


SEN 合同会社100 本ポートフォリオ #110。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?