生成 AI を「使う」だけでなく「アプリに組み込む」とどうなるのか。これを手を動かして理解したくて、人物写真をアップロードすると顔の属性(性別的な見た目)だけを別の性別に変換する学習用 Web アプリを個人で作りました。外部の画像生成 API には一切頼らず、推論まですべてローカルで完結させているのが特徴です。本記事では、その構成と実装上の判断、つまずいた点を技術寄りに整理して共有します。
なぜ「ローカル完結」にこだわったのか
顔写真は最もセンシティブな個人データのひとつです。学習目的とはいえ、自分や知人の顔画像を外部の画像生成サービスへ送るのは気が進みませんでした。そこで最初の制約として「画像は外部に送らない。検出も生成もすべて自分のマシン上で行う」と決めました。
この制約は副次的に良い学習効果も生みました。クラウド API を叩くだけでは、モデルのロードコスト、推論の重さ、デバイス(CUDA / MPS / CPU)ごとの挙動差といった「現実の運用で効いてくる部分」が見えません。ローカルに閉じることで、これらと正面から向き合うことになりました。
全体構成
アプリはシンプルな 3 ステップのフローで動きます。画像アップロード → 人物・顔の検出 → 顔属性を選んで変換、という流れを 1 枚の SPA から試せるようにしています。
技術スタックは以下の通りです。
| カテゴリ | 採用技術 |
|---|---|
| バックエンド | Python 3.12, FastAPI, Uvicorn |
| 顔検出 | MediaPipe, OpenCV |
| 画像変換 | Diffusers, Transformers, PyTorch |
| パッケージ管理 | uv |
| インフラ | Docker, Docker Compose, devcontainer |
| Lint / テスト | Ruff, pytest |
API は 3 本だけです。ヘルスチェックの GET /health、画像を受け取って検出結果を返す POST /upload、性別を指定して変換画像を返す POST /transform。変換は非同期ポーリングではなく、1 リクエストで結果を返す同期方式にしました。学習アプリとして「リクエストを送る → 結果が返る」という因果関係を体感しやすくするためです。
入口を固める: バリデーションと検出
AI に画像を渡す前に、まず入力を厳しく検証します。受け付けるのは JPEG / PNG / WebP のみ、サイズは最大 5 MB。拡張子・MIME タイプ・サイズを「AI 処理が始まる前」にチェックするのがポイントで、ここを通過しないものは一切モデルに触れさせません。
検出は人物検出と顔検出の 2 段構えにしています。顔検出には MediaPipe の FaceDetection を使い、検出器はモジュールレベルでキャッシュしてリクエストごとの初期化コストを避けています。
_detector: mp.solutions.face_detection.FaceDetection | None = None
def _get_detector() -> mp.solutions.face_detection.FaceDetection:
global _detector
if _detector is None:
_detector = mp.solutions.face_detection.FaceDetection(
min_detection_confidence=0.5
)
return _detector
顔のバウンディングボックスは相対座標で返ってくるので、画像サイズを掛けてピクセル座標に変換し、画像の境界をはみ出さないようにクランプしています。この bbox は検出だけでなく、後段のマスク生成でもそのまま再利用します。
なお人物の人数カウントは現状プレースホルダ実装(常に 1 人とみなす)で、YOLO による厳密な人数判定は次フェーズの宿題として切り出しています。学習アプリでは「動く最小構成を先に通す」ことを優先し、検出の差し替えポイントだけ用意しておく、という割り切りです。
中核: 顔領域だけを inpaint する
最初は画像全体を StableDiffusionImg2ImgPipeline で変換する方式を試しました。これは手軽ですが、背景や構図、服装まで一緒に変わってしまい「同じ人の顔属性だけ変えた」感が出ません。
そこで主実装は StableDiffusionInpaintPipeline による顔領域インペインティングに切り替えました。顔の bbox から白黒マスクを生成し、白い部分(顔)だけを再生成、黒い部分(背景・構図・照明)は維持する、という発想です。
マスクは矩形ではなく楕円で、しかも 1.5 倍に拡張して描いています。検出矩形ぴったりだと髪の生え際やあごのラインで継ぎ目が目立つため、余白を持たせて自然に馴染ませる狙いです。
def generate_face_mask(
image: Image.Image,
bbox_px: tuple[int, int, int, int],
expand_ratio: float = 1.5,
) -> Image.Image:
img_w, img_h = image.size
x, y, w, h = bbox_px
cx = x + w / 2
cy = y + h / 2
half_w = w * expand_ratio / 2
half_h = h * expand_ratio / 2
x1 = max(0, int(cx - half_w))
y1 = max(0, int(cy - half_h))
x2 = min(img_w, int(cx + half_w))
y2 = min(img_h, int(cy + half_h))
mask = Image.new("L", (img_w, img_h), 0)
draw = ImageDraw.Draw(mask)
draw.ellipse([x1, y1, x2, y2], fill=255)
return mask
プロンプトも試行錯誤の塊でした。「同じ人物・同じポーズ・同じ背景・同じ照明」を繰り返し指示しつつ、ネガティブプロンプトで「複数人物」「西洋的な顔立ち」「CG・アニメ調」「過度なエアブラシ肌」を強く抑制しています。inpaint 側は strength=0.75, guidance_scale=8.0, num_inference_steps=30 で、顔をしっかり作り替えつつ破綻しないバランスを探りました。
モデルのロードコストとどう付き合うか
Diffusers のパイプラインはロードが重く、リクエストのたびに読み込んでいたら使い物になりません。対策として、パイプラインのロードを最初の transform() 呼び出しまで遅延させ、一度ロードしたサービスインスタンスはリクエストをまたいでキャッシュしています。
_service_cache: dict[tuple[str, str], GenderTransformService] = {}
def get_transform_service(
settings: Settings = Depends(get_settings),
) -> GenderTransformService:
key = (settings.LOCAL_INPAINT_MODEL_ID, settings.LOCAL_DEVICE)
if key not in _service_cache:
_service_cache[key] = LocalInpaintTransformService(
model_id=settings.LOCAL_INPAINT_MODEL_ID,
device=settings.LOCAL_DEVICE,
)
return _service_cache[key]
FastAPI の Depends をファクトリにしておくと、テスト時に dependency_overrides でスタブサービスへ差し替えられるのも利点です。実際、CI ではモデルを一切ロードしないスタブ実装(元画像をコピーして返すだけ)に差し替えて、API の入出力契約だけを高速に検証しています。
デバイスまわりも素直にはいきませんでした。CUDA では float16 が効きますが、Apple Silicon の MPS では float16 だと精度問題が出やすく、float32 の方が安定します。ここはデバイスを見て dtype を出し分けています。
dtype = torch.float16 if self._device == "cuda" else torch.float32
エラーは「ユーザーが次に何をすべきか」で分類する
学習アプリとはいえ、失敗時の体験は丁寧に設計しました。変換失敗を一括りにせず、画像起因(ValueError)とサーバー起因(RuntimeError)に分けて投げ分けています。画像が壊れている・顔が検出できない場合は「別の画像を試してください」と再アップロードへ誘導し、モデルのロードや推論が落ちた場合は「しばらく待って再試行してください」とリトライへ誘導する。原因の所在によって、ユーザーに促すアクションを変えるという方針です。
加えてログポリシーとして「画像バイナリや base64 はログに出さない。記録するのはファイル名・サイズ・モデル名・デバイス名などメタデータのみ」と決めています。ローカル完結という当初の方針を、ログの粒度でも一貫させた形です。
学んだこと
一番の収穫は、生成 AI を組み込んだアプリの難所が「モデルそのもの」よりも「その周辺」に集中していると体感できたことです。前処理(検出・マスク生成)、重いリソースのライフサイクル管理、デバイス差の吸収、そして失敗時の導線設計。これらはどんな AI 機能を作るときにも付いて回ります。
また「全体 img2img から顔 inpaint への切り替え」のように、最初の素朴な実装が体験として物足りないと分かってから設計を見直す、という流れそのものが良い学習でした。マスクを楕円で 1.5 倍に拡げる、といった細部のチューニングは、実際に出力を見比べないと出てこない判断です。
次フェーズでは、プレースホルダのままの人物検出を YOLO で本実装し、複数人物の扱いや顔の向き(横顔・後ろ向き)への対応を進める予定です。小さく作って動かし、物足りなさを起点に育てていく。学習用アプリだからこそ取れるこの進め方は、結果的に「現実の AI アプリ開発で効く勘所」を一通りなぞる良い教材になりました。
本記事のコードは筆者が個人の学習目的で実装したものです。アプリは人物写真をローカルで処理し、外部サービスへ画像を送信しません。
参考リンク
Author

Ryuji Takano
ソリューションアーキテクト/エンジニアリングマネージャー


