きっかけ
画像ウォーターマークは些細に見えて、気づくとロードベアリングになっている機能だ。写真マーケットプレイスの著作権スタンプ、ドキュメントプレビューの「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
作ったもの
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。
