# 実装仕様書：moroバーチャル試着 — 顔＋首合成（ローカルモード）

**変更ファイル：** `/mnt/gxo_volume_01/kimono-pipeline/main.py`  
**Python：** `/mnt/gxo_volume_01/kimono-pipeline/venv/bin/python`  
**目的：** 現在の `provider=local`（顔のみスワップ）を、首まで含めた自然な合成に置き換える。

---

## 背景・なぜこの変更が必要か

現在のローカルモードは InsightFace `inswapper_128` で顔バウンディングボックス領域だけを合成しています。そのため：

- 顎・耳の境界でマスクが途切れ、継ぎ目が見える
- ユーザーの肌色が着物モデルと異なると「別人の顔を貼った」ような不自然さが出る

新しい処理では合成マスクを顎の下（衿元）まで拡張し、肌色補正でなじませます。

**追加ライブラリは不要です。** 既存の `insightface`・`onnxruntime`・`opencv-python`・`numpy` のみで実装します。

---

## 処理フロー

```
POST /swap  (provider=local)
        │
        ├─ _decode()               画像バイト → BGR配列（EXIF回転・Alpha対応）
        ├─ _biggest()              両画像から最大の顔を検出
        │
        ├─ inswapper.get()         ← 既存の顔スワップ（変更なし）
        │
        ├─ _build_neck_mask()      ← 【新規】顔bbox＋首領域マスク生成
        ├─ _color_harmonize()      ← 【新規】LAB色空間で肌色補正
        ├─ _composite()            ← 【新規】マスクを使ってアルファ合成
        │
        └─ _enhance()              ← 既存のGFPGAN（変更なし）
```

---

## 実装手順（詳細）

### Step 1 — `_build_neck_mask()` の実装

顔bboxを基準に、顎の下まで伸びるテーパー状のマスク（uint8, 0〜255）を生成します。

```python
def _build_neck_mask(
    img_shape: tuple,
    bbox: np.ndarray,
    neck_ratio: float = 0.65,
    neck_width_ratio: float = 0.42,
    blur_ksize: int = 61,
) -> np.ndarray:
    """
    img_shape:        対象画像の shape (H, W, C)
    bbox:             InsightFaceの顔bbox [x1, y1, x2, y2]
    neck_ratio:       顔高さに対する首伸長量の割合（0.65 = 顔高さの65%分下へ伸ばす）
    neck_width_ratio: 衿元での首幅 / 顔幅の比率（0.42 が標準）
    blur_ksize:       ガウシアンブラーのカーネルサイズ（奇数）
    """
    h, w = img_shape[:2]
    x1, y1, x2, y2 = (int(v) for v in bbox)

    face_h = y2 - y1
    face_w = x2 - x1
    cx = (x1 + x2) // 2

    mask = np.zeros((h, w), dtype=np.uint8)

    # 顔の楕円マスク（耳・髪際との境界を少し外側にパディング）
    pad = 10
    cv2.ellipse(
        mask,
        (cx, (y1 + y2) // 2),
        (face_w // 2 + pad, face_h // 2 + pad),
        0, 0, 360, 255, -1,
    )

    # 首の台形領域（顎で広く、衿元で狭くなる）
    chin_half_w   = int(face_w * 0.36)      # 顎の位置での幅
    collar_half_w = max(int(face_w * neck_width_ratio / 2), 4)  # 衿元での幅
    neck_bottom   = min(y2 + int(face_h * neck_ratio), h - 1)   # 衿元のY座標

    pts = np.array([
        [max(cx - chin_half_w, 0),       y2],           # 顎左
        [min(cx + chin_half_w, w - 1),   y2],           # 顎右
        [min(cx + collar_half_w, w - 1), neck_bottom],  # 衿右
        [max(cx - collar_half_w, 0),     neck_bottom],  # 衿左
    ], dtype=np.int32)
    cv2.fillPoly(mask, [pts], 255)

    # 衿元の楕円（下端を丸くフェードアウト）
    collar_oval_h = max(int(face_h * 0.08), 6)
    cv2.ellipse(mask, (cx, neck_bottom), (collar_half_w, collar_oval_h), 0, 0, 360, 255, -1)

    # エッジをぼかしてハードエッジを除去
    mask = cv2.GaussianBlur(mask, (blur_ksize, blur_ksize), 0)
    return mask
```

**主要パラメータ（ロジックを変えずに調整可能）：**

| パラメータ | デフォルト | 大きくすると |
|-----------|-----------|------------|
| `neck_ratio` | 0.65 | 首の合成範囲が下へ広がる |
| `neck_width_ratio` | 0.42 | 衿元での幅が広くなる |
| `blur_ksize` | 61 | 境界がよりソフトになる（奇数のみ指定可） |

---

### Step 2 — `_color_harmonize()` の実装

LAB色空間でスワップ済み画像の色統計を着物モデルの肌色に近づけます。  
**Reinhardメソッド**（チャネルごとに平均・標準偏差を揃える）を使用。  
`blend=0.35` とすることで35%だけ補正し、過補正を防ぎます。

```python
def _color_harmonize(
    swapped: np.ndarray,
    target: np.ndarray,
    mask: np.ndarray,
    blend: float = 0.35,
) -> np.ndarray:
    """
    swapped: inswapper.get() の結果（BGR uint8）
    target:  元の着物モデル画像（BGR uint8）
    mask:    _build_neck_mask() のマスク（uint8 0〜255）
    blend:   0.0=補正なし、1.0=完全にtargetの色に揃える
    """
    if blend <= 0.0:
        return swapped

    mask_bool = mask > 64
    if mask_bool.sum() < 100:
        return swapped

    src_lab = cv2.cvtColor(swapped, cv2.COLOR_BGR2LAB).astype(np.float32)
    tgt_lab = cv2.cvtColor(target,  cv2.COLOR_BGR2LAB).astype(np.float32)
    result  = src_lab.copy()

    for ch in range(3):
        src_vals = src_lab[:, :, ch][mask_bool]
        tgt_vals = tgt_lab[:, :, ch][mask_bool]

        src_std = src_vals.std()
        if src_std < 1e-6:
            continue  # 均一チャネルはスキップ

        src_mean = src_vals.mean()
        tgt_mean = tgt_vals.mean()
        tgt_std  = tgt_vals.std()

        # 肌色差が極端な場合（LAB距離>40）は補正量を半減（Case 7対応）
        mean_delta = abs(tgt_mean - src_mean)
        eff_blend  = blend if mean_delta < 40 else blend * 0.5

        scaled = (src_lab[:, :, ch] - src_mean) * (tgt_std / max(src_std, 1e-6)) + tgt_mean
        result[:, :, ch] = np.clip(
            src_lab[:, :, ch] * (1 - eff_blend) + scaled * eff_blend,
            0, 255,
        )

    return cv2.cvtColor(result.astype(np.uint8), cv2.COLOR_LAB2BGR)
```

---

### Step 3 — `_composite()` の実装

マスクをアルファ値として、スワップ済み画像を元の着物画像に合成します。

```python
def _composite(swapped: np.ndarray, target: np.ndarray, mask: np.ndarray) -> np.ndarray:
    """マスク[0-255]をアルファとして swapped を target に重ねる。"""
    alpha   = mask.astype(np.float32)[:, :, np.newaxis] / 255.0
    blended = swapped.astype(np.float32) * alpha + target.astype(np.float32) * (1.0 - alpha)
    return np.clip(blended, 0, 255).astype(np.uint8)
```

---

### Step 4 — `/swap` エンドポイントへの組み込み

`provider == local` のブロックを以下に置き換えます。

**変更前（現在のコード）：**
```python
result = swapper.get(tgt.copy(), tf, sf, paste_back=True)
result = _enhance(gfpgan, result, _biggest(fa, result) or tf)
```

**変更後：**
```python
# 顔スワップ（変更なし）
swapped = swapper.get(tgt.copy(), tf, sf, paste_back=True)

# 画像ジオメトリに応じてneck_ratioを調整（Case 3, 5対応）
tgt_face_h      = tf.bbox[3] - tf.bbox[1]
tgt_face_bottom = tf.bbox[3]
img_h           = tgt.shape[0]
face_fraction   = tgt_face_h / img_h
bottom_fraction = tgt_face_bottom / img_h

if bottom_fraction > 0.70:
    neck_ratio = 0.30   # タイトな構図 → 衿が近い
elif face_fraction > 0.25:
    neck_ratio = 0.65   # 顔が大きく写っている → 首が多く見える
else:
    neck_ratio = 0.50   # 標準

# 顔＋首マスク生成（Case 2の境界クリップは関数内で処理）
neck_mask = _build_neck_mask(tgt.shape, tf.bbox, neck_ratio=neck_ratio)

# 色補正
swapped_harmonized = _color_harmonize(swapped, tgt, neck_mask)

# 合成
result = _composite(swapped_harmonized, tgt, neck_mask)

# GFPGAN（変更なし）
result_face = _biggest(fa, result)
result = _enhance(gfpgan, result, result_face if result_face is not None else tf)
```

---

## イレギュラーケース一覧

### Case 1 — ユーザー画像で顔が検出できない

**原因：** ブレ・極端な角度・サングラス・顔が小さすぎる。

**対応：** `sf is None` の 422 エラーは既存処理で対応済み。  
追加で **顔の最小サイズチェック** を実装する：

```python
FACE_MIN_PX = 80  # ファイル先頭の定数として定義

# sf is None チェックの直後に追加
src_face_w = sf.bbox[2] - sf.bbox[0]
src_face_h = sf.bbox[3] - sf.bbox[1]
if src_face_w < FACE_MIN_PX or src_face_h < FACE_MIN_PX:
    raise HTTPException(
        status_code=422,
        detail=f"顔が小さすぎます（{int(src_face_w)}×{int(src_face_h)}px）。"
               "顔が大きく写った写真をアップロードしてください。",
    )
```

---

### Case 2 — 首マスクが画像の外にはみ出す

**原因：** 着物モデル画像が上半身のタイトなクロップで、顔が画像の下端に近い。

**対応：** `_build_neck_mask()` の中で座標を全てクリップ済み：
- `neck_bottom = min(y2 + int(face_h * neck_ratio), h - 1)`
- `pts` の各頂点で `max(値, 0)` と `min(値, w-1)` を適用

**追加コード不要。** `_build_neck_mask()` の `pts` 定義を上記の通りに書けばOK。

---

### Case 3 — ユーザーの髪が長く首を覆っている

**原因：** 首マスク領域に髪が入り込み、髪の毛の上に着物モデルの肌を合成する。

**症状：** 首・肩の境界付近に二重になった髪の毛のような縞が見える。

**対応 — `neck_ratio` を自動調整：**

```python
# 既にStep 4のコードに含まれているface_fractionを使う
# 顔が画像に大きく写っている場合 → ユーザーは近くにいる → 首が見えやすい
# 顔が小さい場合 → 遠距離 → 髪が首に掛かりやすい
neck_ratio = 0.65 if face_fraction > 0.25 else 0.45
```

**運用ガイドラインとして** フロントエンド側でも案内する：  
「顔写真は髪を後ろにまとめ、首元が見えるものをご使用ください」

---

### Case 4 — 顔が横を向いている（横顔・大きく傾いた顔）

**原因：** ユーザー写真の顔が正面向きでない（ヨー角 > 30°）。

**InsightFace の pose 属性で検出：**

```python
MAX_YAW_DEG = 35  # ファイル先頭の定数として定義

# 顔サイズチェックの直後に追加
if hasattr(sf, "pose") and sf.pose is not None:
    yaw = abs(float(sf.pose[1]))
    if yaw > MAX_YAW_DEG:
        raise HTTPException(
            status_code=422,
            detail=f"顔の角度が大きすぎます（{int(yaw)}°）。"
                   "正面向きの写真をお使いください。",
        )
```

`sf.pose` が `None` の場合（古いモデル）はスキップで問題なし。

---

### Case 5 — 着物モデルの衿が非常に高い（首がほぼ見えない）

**原因：** 打掛・白無垢など、衿が顎近くまで来るスタイル。  
首マスクを伸ばしすぎると衿の布地の上にユーザーの肌を合成してしまう。

**対応 — 顔の画面内位置で `neck_ratio` を自動調整：**

```python
# bottom_fraction = 顔の下端が画像高さの何割か
# 0.70以上 → タイトな構図 or 高衿 → neck_ratioを絞る
if bottom_fraction > 0.70:
    neck_ratio = 0.30
```

既に Step 4 のコードに含まれているため追加実装は不要。

---

### Case 6 — 画像に複数の顔がある

**原因：** 商品紹介の集合写真や、背景に別の人物が写ったユーザー写真。

**対応：** `_biggest()` が最大の顔を選ぶため、すでに正しく動作する。

**コード変更不要。** コメントとして意図を明記する：

```python
# _biggest() はバウンディングボックス面積が最大の顔を返す。
# モデル画像に複数人いる場合 → 最も大きく写っているメインモデルを使用。
# ユーザー写真に友人が写っている場合 → 最も手前の顔（通常最大）を使用。
sf = _biggest(fa, src)
tf = _biggest(fa, tgt)
```

---

### Case 7 — ユーザーとモデルの肌色が大きく異なる

**原因：** 肌の色調が大幅に異なるケース（例：非常に色白のモデル画像と濃い肌色のユーザー）。

**症状：** `_color_harmonize` が過補正し、顔が不自然に白くなったり黒くなったりする。

**対応：** `_color_harmonize` の中でLABの平均差が 40 を超えたとき `blend` を半分にする：

```python
mean_delta = abs(tgt_mean - src_mean)
eff_blend  = blend if mean_delta < 40 else blend * 0.5
```

既に実装コードに含まれているため追加実装不要。

---

### Case 8 — PNG（透過あり）またはグレースケール画像が送られてくる

**原因：** ユーザーがアルファチャンネル付きPNGやモノクロ写真をアップロード。

**対応 — `_decode()` に変換処理を追加：**

```python
def _decode(data: bytes) -> np.ndarray:
    # EXIF回転処理（Case 10と共通）
    try:
        from PIL import Image, ImageOps
        pil_img = ImageOps.exif_transpose(Image.open(_io.BytesIO(data)))
        buf = _io.BytesIO()
        pil_img.convert("RGB").save(buf, format="JPEG", quality=95)
        data = buf.getvalue()
    except Exception:
        pass

    img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR)
    if img is None:
        raise HTTPException(status_code=400, detail="invalid image")

    # IMREAD_COLOR でほぼ対応できるが念のため明示的に変換
    if img.ndim == 2:
        img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
    elif img.shape[2] == 4:
        img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
    return img
```

---

### Case 9 — GFPGANモデルファイルが存在しない

**原因：** `gfpgan_1.4.onnx` がサーバーに配置されていない。

**動作：** 既存コードで `gfpgan = None` となり、`_enhance()` は何もせず画像をそのまま返す。クラッシュしない。

**追加実装：** 起動時に警告ログを出すように `_models()` を修正する：

```python
if not os.path.exists(GFPGAN_PATH):
    logger.warning(
        "GFPGANモデルが見つかりません: %s — 顔強化処理が無効です", GFPGAN_PATH
    )
```

---

### Case 10 — スマートフォン写真のEXIF回転

**原因：** スマートフォンカメラで撮影した画像はEXIFに回転情報を埋め込む。  
OpenCVの `imdecode` はEXIFを無視するため、画像が横向きのまま処理される。

**対応 — `_decode()` でPillowを使ってEXIF適用後に渡す：**

Case 8 と同じ `_decode()` コードで対応（Pillowの `ImageOps.exif_transpose` が自動回転）。

**事前に Pillow をインストール：**
```bash
/mnt/gxo_volume_01/kimono-pipeline/venv/bin/pip install Pillow
```

---

## 完成後の `main.py` 全文

以下を既存ファイルと **丸ごと置き換えて** ください。

```python
"""
MORO Kimono Face-Swap Service — 顔＋首合成（ローカルモード）

POST /swap  (multipart: source=<ユーザー顔写真>, target=<着物モデル画像>,
             provider=local|chatgpt)

ローカルモード:
  InsightFace inswapper_128  — 顔スワップ
  _build_neck_mask()         — 首まで合成マスクを拡張
  _color_harmonize()         — LAB色転送で肌色をなじませる
  _composite()               — マスクでアルファ合成
  GFPGAN 1.4                 — 顔・境界の補正強化

ChatGPTモード:
  OpenAI gpt-image-2 edit API（変更なし）
"""
import base64
import io as _io
import logging
import os
from functools import lru_cache

import cv2
import numpy as np
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import Response

logger = logging.getLogger("kimono-pipeline")

# ---------------------------------------------------------------------------
# モデルパス
# ---------------------------------------------------------------------------

INSWAPPER_CANDIDATES = (
    os.getenv("INSWAPPER_MODEL_PATH", ""),
    os.path.expanduser("~/.insightface/models/inswapper_128.onnx"),
    "/var/www/.insightface/models/inswapper_128.onnx",
)
GFPGAN_PATH = os.getenv(
    "GFPGAN_MODEL_PATH", "data/private/model_assets/restore/gfpgan_1.4.onnx"
)

FACE_MIN_PX = 80   # これ以下の顔サイズは拒否
MAX_YAW_DEG = 35   # これ以上の横向き角度は拒否


def _inswapper_path() -> str:
    for p in INSWAPPER_CANDIDATES:
        if p and os.path.exists(p):
            return p
    raise RuntimeError("inswapper_128.onnx が見つかりません。INSWAPPER_MODEL_PATH を設定してください。")


@lru_cache(maxsize=1)
def _models():
    import insightface
    import onnxruntime as ort
    from insightface.app import FaceAnalysis

    fa = FaceAnalysis(
        name="buffalo_l",
        allowed_modules=["detection", "landmark_2d_106", "recognition"],
        providers=["CPUExecutionProvider"],
    )
    fa.prepare(ctx_id=0, det_size=(640, 640))
    swapper = insightface.model_zoo.get_model(
        _inswapper_path(), providers=["CPUExecutionProvider"]
    )
    if not os.path.exists(GFPGAN_PATH):
        logger.warning("GFPGANモデルが見つかりません: %s — 顔強化処理が無効です", GFPGAN_PATH)
    gfpgan = (
        ort.InferenceSession(GFPGAN_PATH, providers=["CPUExecutionProvider"])
        if os.path.exists(GFPGAN_PATH)
        else None
    )
    return fa, swapper, gfpgan


# ---------------------------------------------------------------------------
# 画像ユーティリティ
# ---------------------------------------------------------------------------

def _decode(data: bytes) -> np.ndarray:
    """バイト列をBGR配列に変換。EXIF回転・Alpha・グレースケールを自動補正。"""
    try:
        from PIL import Image, ImageOps
        pil_img = ImageOps.exif_transpose(Image.open(_io.BytesIO(data)))
        buf = _io.BytesIO()
        pil_img.convert("RGB").save(buf, format="JPEG", quality=95)
        data = buf.getvalue()
    except Exception:
        pass

    img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR)
    if img is None:
        raise HTTPException(status_code=400, detail="invalid image")

    if img.ndim == 2:
        img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
    elif img.shape[2] == 4:
        img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
    return img


def _biggest(fa, img):
    """画像内で最大の顔を返す。顔が検出されなければ None。"""
    faces = fa.get(img)
    if not faces:
        return None
    return max(faces, key=lambda f: (f.bbox[2] - f.bbox[0]) * (f.bbox[3] - f.bbox[1]))


# ---------------------------------------------------------------------------
# GFPGAN 顔強化（変更なし）
# ---------------------------------------------------------------------------

def _enhance(gfpgan, img, face):
    if gfpgan is None:
        return img
    from insightface.utils import face_align

    M = face_align.estimate_norm(face.kps, 512)
    aligned = cv2.warpAffine(img, M, (512, 512), borderMode=cv2.BORDER_REPLICATE)
    x = cv2.cvtColor(aligned, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
    x = ((x - 0.5) / 0.5).transpose(2, 0, 1)[None]
    out = gfpgan.run(None, {gfpgan.get_inputs()[0].name: x})[0][0]
    out = np.clip((out.transpose(1, 2, 0) + 1) / 2, 0, 1) * 255
    restored = cv2.cvtColor(out.astype(np.uint8), cv2.COLOR_RGB2BGR)
    IM = cv2.invertAffineTransform(M)
    h, w = img.shape[:2]
    back = cv2.warpAffine(restored, IM, (w, h), borderMode=cv2.BORDER_TRANSPARENT)
    mask = np.zeros((512, 512), np.uint8)
    cv2.ellipse(mask, (256, 256), (190, 235), 0, 0, 360, 255, -1)
    mask = cv2.GaussianBlur(mask, (31, 31), 0)
    mb = cv2.warpAffine(mask, IM, (w, h))[..., None].astype(np.float32) / 255.0
    return (img * (1 - mb) + back * mb).astype(np.uint8)


# ---------------------------------------------------------------------------
# 顔＋首合成（新規追加）
# ---------------------------------------------------------------------------

def _build_neck_mask(
    img_shape: tuple,
    bbox: np.ndarray,
    neck_ratio: float = 0.65,
    neck_width_ratio: float = 0.42,
    blur_ksize: int = 61,
) -> np.ndarray:
    """
    顔bbox＋首領域のマスク（uint8, 0〜255）を生成する。

    neck_ratio:       顔高さに対する首伸長割合
    neck_width_ratio: 衿元での首幅 / 顔幅
    blur_ksize:       ガウシアンブラーサイズ（奇数）
    """
    h, w = img_shape[:2]
    x1, y1, x2, y2 = (int(v) for v in bbox)

    face_h = y2 - y1
    face_w = x2 - x1
    cx = (x1 + x2) // 2

    mask = np.zeros((h, w), dtype=np.uint8)

    # 顔の楕円
    pad = 10
    cv2.ellipse(
        mask,
        (cx, (y1 + y2) // 2),
        (face_w // 2 + pad, face_h // 2 + pad),
        0, 0, 360, 255, -1,
    )

    # 首の台形（顎で広く、衿元で狭くなる）
    chin_half_w   = int(face_w * 0.36)
    collar_half_w = max(int(face_w * neck_width_ratio / 2), 4)
    neck_bottom   = min(y2 + int(face_h * neck_ratio), h - 1)

    pts = np.array([
        [max(cx - chin_half_w, 0),       y2],
        [min(cx + chin_half_w, w - 1),   y2],
        [min(cx + collar_half_w, w - 1), neck_bottom],
        [max(cx - collar_half_w, 0),     neck_bottom],
    ], dtype=np.int32)
    cv2.fillPoly(mask, [pts], 255)

    # 衿元の楕円（下端フェードアウト）
    collar_oval_h = max(int(face_h * 0.08), 6)
    cv2.ellipse(mask, (cx, neck_bottom), (collar_half_w, collar_oval_h), 0, 0, 360, 255, -1)

    mask = cv2.GaussianBlur(mask, (blur_ksize, blur_ksize), 0)
    return mask


def _color_harmonize(
    swapped: np.ndarray,
    target: np.ndarray,
    mask: np.ndarray,
    blend: float = 0.35,
) -> np.ndarray:
    """
    LAB色空間でReinhard転送を用い、スワップ画像の色をモデル画像に近づける。
    blend: 0.0=補正なし, 1.0=完全補正（0.35が標準）
    """
    if blend <= 0.0:
        return swapped

    mask_bool = mask > 64
    if mask_bool.sum() < 100:
        return swapped

    src_lab = cv2.cvtColor(swapped, cv2.COLOR_BGR2LAB).astype(np.float32)
    tgt_lab = cv2.cvtColor(target,  cv2.COLOR_BGR2LAB).astype(np.float32)
    result  = src_lab.copy()

    for ch in range(3):
        src_vals = src_lab[:, :, ch][mask_bool]
        tgt_vals = tgt_lab[:, :, ch][mask_bool]

        src_std = src_vals.std()
        if src_std < 1e-6:
            continue

        src_mean = src_vals.mean()
        tgt_mean = tgt_vals.mean()
        tgt_std  = tgt_vals.std()

        # 肌色差が極端な場合は補正量を半減
        mean_delta = abs(tgt_mean - src_mean)
        eff_blend  = blend if mean_delta < 40 else blend * 0.5

        scaled = (src_lab[:, :, ch] - src_mean) * (tgt_std / max(src_std, 1e-6)) + tgt_mean
        result[:, :, ch] = np.clip(
            src_lab[:, :, ch] * (1 - eff_blend) + scaled * eff_blend,
            0, 255,
        )

    return cv2.cvtColor(result.astype(np.uint8), cv2.COLOR_LAB2BGR)


def _composite(swapped: np.ndarray, target: np.ndarray, mask: np.ndarray) -> np.ndarray:
    """マスク[0-255]を使ってswappedをtargetにアルファ合成する。"""
    alpha   = mask.astype(np.float32)[:, :, np.newaxis] / 255.0
    blended = swapped.astype(np.float32) * alpha + target.astype(np.float32) * (1.0 - alpha)
    return np.clip(blended, 0, 255).astype(np.uint8)


# ---------------------------------------------------------------------------
# ChatGPTプロバイダー（変更なし）
# ---------------------------------------------------------------------------

GPT_IMAGE_MODEL  = os.getenv("GPT_IMAGE_MODEL", "gpt-image-2")
GPT_IMAGE_PROMPT = (
    "Replace the face and neck of the woman in the first image with the exact face and identity of "
    "the person in the second image. Keep her eyes, nose, mouth, eyebrows and face shape so the "
    "result is clearly recognizable as the second person. Keep everything else in the first image "
    "identical: the kimono, obi, sleeves, hands, body, pose, hairstyle and the full background. "
    "CRITICAL — make it look natural and seamless: regrade the new face and neck so their skin tone, "
    "color, brightness, contrast, warmth and white balance exactly match the hands, chest and body "
    "of the first image, lit by the same light from the same direction. No color mismatch between "
    "face and body, no brightness difference, no visible seam or hard edge at the jaw, ears or "
    "neckline; blend the transition smoothly. Apply one consistent color grade and lighting across "
    "the whole photo so the person looks like a single real photograph. Photorealistic."
)


def _gpt_image_swap(model_bytes: bytes, user_bytes: bytes) -> bytes:
    import requests

    key = os.getenv("OPENAI_API_KEY") or os.getenv("CHATGPT_API_KEY") or ""
    if not key:
        raise HTTPException(
            status_code=500,
            detail="OPENAI_API_KEY/CHATGPT_API_KEY が設定されていません",
        )
    files = [
        ("image[]", ("model.jpg", model_bytes, "image/jpeg")),
        ("image[]", ("user.jpg",  user_bytes,  "image/jpeg")),
    ]
    data = {
        "model":   GPT_IMAGE_MODEL,
        "prompt":  GPT_IMAGE_PROMPT,
        "size":    "1024x1536",
        "quality": "high",
    }
    resp = requests.post(
        "https://api.openai.com/v1/images/edits",
        headers={"Authorization": "Bearer " + key},
        files=files,
        data=data,
        timeout=300,
    )
    if resp.status_code != 200:
        raise HTTPException(status_code=502, detail="gpt-image error: " + resp.text[:300])
    return base64.b64decode(resp.json()["data"][0]["b64_json"])


# ---------------------------------------------------------------------------
# FastAPI アプリ
# ---------------------------------------------------------------------------

app = FastAPI(title="MORO Kimono Face-Swap Service", version="0.2.0")


@app.get("/health")
def health():
    return {"status": "ok"}


@app.post("/swap")
async def swap(
    source: UploadFile = File(...),
    target: UploadFile = File(...),
    provider: str = Form("local"),
):
    source_bytes = await source.read()
    target_bytes = await target.read()

    if provider == "chatgpt":
        return Response(
            content=_gpt_image_swap(target_bytes, source_bytes),
            media_type="image/jpeg",
        )

    # --- ローカルモード：顔＋首合成 ---
    fa, swapper, gfpgan = _models()
    src = _decode(source_bytes)
    tgt = _decode(target_bytes)

    # _biggest() は最大の顔を返す（複数顔対応: Case 6）
    sf = _biggest(fa, src)
    tf = _biggest(fa, tgt)

    if sf is None:
        raise HTTPException(status_code=422, detail="ユーザー画像に顔が検出できませんでした")
    if tf is None:
        raise HTTPException(status_code=422, detail="モデル画像に顔が検出できませんでした")

    # Case 1: 顔が小さすぎる
    src_face_w = sf.bbox[2] - sf.bbox[0]
    src_face_h = sf.bbox[3] - sf.bbox[1]
    if src_face_w < FACE_MIN_PX or src_face_h < FACE_MIN_PX:
        raise HTTPException(
            status_code=422,
            detail=f"顔が小さすぎます（{int(src_face_w)}×{int(src_face_h)}px）。"
                   "顔が大きく写った写真をお使いください。",
        )

    # Case 4: 横顔・大きな角度
    if hasattr(sf, "pose") and sf.pose is not None:
        yaw = abs(float(sf.pose[1]))
        if yaw > MAX_YAW_DEG:
            raise HTTPException(
                status_code=422,
                detail=f"顔の向きが横すぎます（{int(yaw)}°）。正面向きの写真をお使いください。",
            )

    # Case 3, 5: 画像ジオメトリからneck_ratioを自動調整
    tgt_face_h      = tf.bbox[3] - tf.bbox[1]
    tgt_face_bottom = tf.bbox[3]
    img_h           = tgt.shape[0]
    face_fraction   = tgt_face_h / img_h
    bottom_fraction = tgt_face_bottom / img_h

    if bottom_fraction > 0.70:
        neck_ratio = 0.30   # タイトな構図 → 衿が近い
    elif face_fraction > 0.25:
        neck_ratio = 0.65   # 顔が大きく写っている → 首が見える
    else:
        neck_ratio = 0.50   # 標準

    # 顔スワップ（変更なし）
    swapped = swapper.get(tgt.copy(), tf, sf, paste_back=True)

    # 顔＋首マスク（Case 2 の境界クリップは関数内で処理）
    neck_mask = _build_neck_mask(tgt.shape, tf.bbox, neck_ratio=neck_ratio)

    # 色補正（Case 7 の極端な肌色差は関数内で自動制御）
    swapped_harmonized = _color_harmonize(swapped, tgt, neck_mask)

    # アルファ合成
    result = _composite(swapped_harmonized, tgt, neck_mask)

    # GFPGAN（変更なし）
    result_face = _biggest(fa, result)
    result = _enhance(gfpgan, result, result_face if result_face is not None else tf)

    ok, buf = cv2.imencode(".jpg", result, [cv2.IMWRITE_JPEG_QUALITY, 95])
    if not ok:
        raise HTTPException(status_code=500, detail="encode failed")
    return Response(content=buf.tobytes(), media_type="image/jpeg")
```

---

## デプロイ手順

```bash
# 1. 既存ファイルをバックアップ
cp /mnt/gxo_volume_01/kimono-pipeline/main.py \
   /mnt/gxo_volume_01/kimono-pipeline/main.py.bak.$(date +%Y%m%d)

# 2. Pillow をインストール（EXIF回転・PNG対応に必要）
/mnt/gxo_volume_01/kimono-pipeline/venv/bin/pip install Pillow

# 3. main.py を上記の完成コードで丸ごと置き換える

# 4. サービスを再起動
#    管理方法に合わせて以下のいずれか：
sudo supervisorctl restart kimono-pipeline
# または
sudo systemctl restart kimono-pipeline
```

---

## 動作確認（スモークテスト）

```bash
# ヘルスチェック
curl http://127.0.0.1:5005/health
# 期待値: {"status":"ok"}

# スワップテスト（実際の画像ファイルに変えて実行）
curl -X POST http://127.0.0.1:5005/swap \
  -F "source=@/tmp/user_face.jpg" \
  -F "target=@/tmp/kimono_model.jpg" \
  -F "provider=local" \
  --output /tmp/result.jpg

# 出力確認
file /tmp/result.jpg   # "JPEG image data" と表示されればOK
```

---

## 品質調整ガイド（実装後に目視チェックして調整）

| 問題 | 調整箇所 |
|------|---------|
| 首の継ぎ目がまだ見える | `neck_ratio` を増やす（例: 0.80） |
| 首マスクが衿の布地にかかる | `neck_ratio` を減らす（例: 0.40） |
| 境界付近の肌色が合わない | `_color_harmonize` の `blend` を増やす（例: 0.50） |
| 顔の色が不自然にずれた | `blend` を減らす（例: 0.20） |
| 顔周辺にぼんやりしたハロが出る | `blur_ksize` を小さくする（例: 41） |
| 境界にくっきりしたエッジが残る | `blur_ksize` を大きくする（例: 81） |

---

## イレギュラーケース 早見表

| # | ケース | 対応場所 | コード変更 |
|---|--------|---------|-----------|
| 1 | 顔が検出できない | エンドポイント | 422 返却（既存） |
| 1+ | 顔が小さすぎる | エンドポイント | `FACE_MIN_PX` チェック追加 |
| 2 | 首マスクが画像外 | `_build_neck_mask` | 座標クリップ（実装済み） |
| 3 | 長い髪が首を覆う | エンドポイント | `neck_ratio` 自動調整 |
| 4 | 横顔・大きな角度 | エンドポイント | `MAX_YAW_DEG` チェック追加 |
| 5 | 高衿の着物 | エンドポイント | `bottom_fraction` で自動調整 |
| 6 | 複数の顔 | `_biggest()` | 対応済み（変更不要） |
| 7 | 極端な肌色差 | `_color_harmonize` | `mean_delta` で自動制御 |
| 8 | PNG/グレースケール | `_decode` | `IMREAD_COLOR` + 明示変換 |
| 9 | GFPGANなし | `_models()` | 警告ログ追加（クラッシュしない） |
| 10 | EXIF回転 | `_decode` | Pillow `exif_transpose` |
