# 09. VTON (가상 피팅) API

## 개요

VTON(Virtual Try-On)은 인물 사진 위에 옷장의 의류(고스트 마네킹 이미지)를 합성하여 결과 이미지를 돌려주는 API입니다. `POST /api/v1/vton/generate` 단일 엔드포인트로 제공하며, **stateless**(별도 DB 테이블 없이 파일만 저장)로 동작합니다.

주요 특성:

- **멀티 이미지 입력 LLM 호출**: 인물 1장 + 고스트 의류 N장을 **한 번의 LLM 호출**로 합성.
- **얼굴 필수**: 인물 사진에서 얼굴이 감지되지 않으면 400 오류.
- **고스트 이미지 필수**: 선택한 garment에 `ghost_image_path`가 없으면 오류. `source_image`(전신 인물 사진)는 의류 합성에 부적합하기 때문.
- **상반신 자동 대응**: 상반신만 찍힌 사진에 하의(bottom)를 함께 전달하면, LLM이 이를 감지해 해당 의류를 건너뜀. 건너뛴 의류 ID는 응답에서 `skipped_garment_ids`로 분리되어 반환됨.

---

## 아키텍처

```
Client (multipart)
     │  image + garment_ids[] (+ optional user_id)
     ▼
Router (routers/vton.py)
     │
     ▼
VtonService.generate()
  1. 얼굴 감지 (FaceAnalyzer.get_embeddings) ─→ 0장이면 400
  2. Garment 조회 (id in_[...])             ─→ 누락 있으면 404
  3. ghost_image_path 검증                  ─→ null이면 400
  4. person 이미지 리사이즈 + ghost 파일 로드
  5. llm.generate_vton(person, ghosts, categories)
  6. save_vton_image() → storage/vton/{user_id}/{uuid}.png
  7. LLM 응답 텍스트에서 "#N" 파싱 → used / skipped 분리
     │
     ▼
VtonGenerateResponse
  { result_image_url, result_image_path,
    used_garment_ids, skipped_garment_ids, llm_note }
```

- **프로바이더 패턴 재사용**: `BaseLLMProvider.generate_vton()`이 새로 추가되었고, `GeminiProvider`·`OpenAIProvider`가 모두 구현. 환경변수 `LLM_PROVIDER`로 전환.
- **얼굴 감지기 재사용**: 기존 싱글톤 `FaceAnalyzer`(insightface)를 그대로 사용.
- **이미지 저장 유틸 재사용**: `save_vton_image()`를 `app/utils/image.py`에 추가 (ghost 저장 함수와 동일 패턴).

---

## 관련 파일

| 파일 | 역할 |
|------|------|
| [app/routers/vton.py](../app/routers/vton.py) | POST /api/v1/vton/generate 엔드포인트 (얇은 레이어) |
| [app/services/vton_service.py](../app/services/vton_service.py) | VTON 전체 비즈니스 로직 (face 검증, DB 조회, LLM 호출, 저장) |
| [app/schemas/vton.py](../app/schemas/vton.py) | VtonGenerateResponse 스키마 |
| [app/services/llm/base.py](../app/services/llm/base.py) | `generate_vton` 추상 메서드 정의 |
| [app/services/llm/gemini.py](../app/services/llm/gemini.py) | Gemini 멀티-이미지 generate_content 구현 |
| [app/services/llm/openai.py](../app/services/llm/openai.py) | OpenAI Responses API 멀티 input_image 구현 |
| [app/services/llm/prompts.py](../app/services/llm/prompts.py) | `VTON_PROMPT` 템플릿 |
| [app/utils/image.py](../app/utils/image.py) | `save_vton_image()` 저장 헬퍼 |
| [app/dependencies.py](../app/dependencies.py) | `get_vton_service()` DI 팩토리 |

---

## 엔드포인트

### `POST /api/v1/vton/generate`

**요청** — `multipart/form-data`

| 필드 | 타입 | 필수 | 설명 |
|------|------|------|------|
| `image` | file (JPEG/PNG) | ✅ | 인물 사진 (얼굴이 감지되어야 함) |
| `garment_ids` | UUID 문자열 (반복) | ✅ | 적용할 의류 ID. 여러 번 반복 가능 |
| `user_id` | UUID 문자열 | 선택 | 결과 이미지 저장 폴더용. 기본: `00000000-0000-0000-0000-000000000000` |

**응답 200** — `application/json`

```json
{
  "result_image_url": "/storage/vton/00000000-0000-0000-0000-000000000000/4f...f.png",
  "result_image_path": "vton/00000000-0000-0000-0000-000000000000/4f...f.png",
  "used_garment_ids": ["c3a1...", "d8f5..."],
  "skipped_garment_ids": [],
  "llm_note": "Used all garments."
}
```

**필드 설명**:
- `used_garment_ids`: 실제로 합성에 사용된 의류 ID (요청 순서 유지).
- `skipped_garment_ids`: 상반신 전용 사진에 하의가 섞인 경우 등, LLM이 건너뛴 의류 ID.
- `llm_note`: LLM이 이미지와 함께 반환한 영문 한 문장 메모. UI에 그대로 노출 가능.

---

## 오류 처리

| 상태 | 조건 | `detail` |
|------|------|----------|
| 400 | `garment_ids` 비어있음 | `"garment_ids는 최소 1개 이상이어야 합니다."` |
| 400 | 이미지 디코딩 실패 | `"이미지를 디코딩할 수 없습니다."` |
| 400 | 얼굴 미감지 | `"얼굴을 감지할 수 없습니다. 인물 사진이 필요합니다."` |
| 400 | garment에 `ghost_image_path` 없음 | `"고스트 마네킹이 생성되지 않은 의류입니다: {id}. 먼저 POST /api/v1/garments/{id}/ghost 로 고스트를 생성하세요."` |
| 400 | ghost 파일이 디스크에 없음 | `"고스트 이미지 파일이 없습니다: {path}"` |
| 404 | garment_id가 DB에 존재하지 않음 | `"의류를 찾을 수 없습니다: {missing_id}"` |
| 502 | LLM이 이미지를 반환하지 않음 | `"LLM이 VTON 이미지를 반환하지 않았습니다"` |
| 502 | LLM 호출 중 예외 | `"LLM VTON 생성 실패: {err}"` |
| 422 | `garment_ids` 필드 누락 | FastAPI 기본 validation 응답 |

---

## 프롬프트 설계 (`VTON_PROMPT`)

`app/services/llm/prompts.py`에 정의. 핵심 지시사항:

1. **입력 구조 명시**: "첫 이미지는 인물, 이후 N장은 고스트 의류" + `{catalog}` 플레이스홀더로 `"#1=top, #2=bottom"`처럼 번호별 카테고리를 알려줌.
2. **인물의 얼굴/포즈/정체성 그대로 유지** — 얼굴을 수정하지 말 것.
3. **의류의 색상/텍스처/패턴/로고 그대로 유지** — 일반화된 다른 옷으로 대체하지 말 것.
4. **상반신 전용 사진 감지 위임** — "다리/하반신이 프레임에 없으면 bottom 카테고리 의류를 건너뛸 것. 프레임을 확장하거나 다리를 지어내지 말 것."
5. **출력**: 합성된 이미지 한 장 + 영문 한 문장(건너뛴 의류를 `#N` 번호로 보고).
   - 건너뛴 것이 없으면 `"Used all garments."`
   - 예: `"Skipped #2 because the person's legs are not visible in frame."`

### 왜 상반신 감지를 LLM에 위임하나?

- LLM은 이미지 전체를 이해하므로 잘린 부위를 판단할 수 있음 (단순 bbox 휴리스틱보다 견고).
- 프롬프트 한 줄로 비즈니스 로직이 명확해짐.
- 건너뛴 결과는 LLM이 **같은 응답 안에 텍스트로 반환**하므로 추가 호출 없이 `used/skipped`를 분리 가능.
- 서버는 `#N` 정규식만 파싱 → 파싱 실패 시 모두 `used`로 넣고 원본 `llm_note`는 그대로 응답에 포함 (graceful degradation).

---

## 사용 시나리오

### 1. 사전 준비 — garment에 ghost 생성

```bash
# 의류 분석 + 저장 (자동으로 ghost가 생성되지 않음)
curl -F image=@tests/fixtures/person_tshirt_jeans.jpg \
  "http://localhost:3003/api/v1/garments/analyze"
# 응답에서 garment_id 확인

# 별도로 ghost 생성
curl -X POST "http://localhost:3003/api/v1/garments/<id>/ghost"
# 응답에 ghost_image_url 포함되어야 함
```

### 2. VTON 호출

```bash
curl -X POST "http://localhost:3003/api/v1/vton/generate" \
  -F image=@tests/fixtures/person.jpg \
  -F garment_ids=<id_top> \
  -F garment_ids=<id_bottom>
```

### 3. 상반신 사진에 하의 포함한 경우

- 클라이언트는 상하의 모두 선택해도 됨.
- 서버는 오류 없이 200을 반환.
- 응답의 `skipped_garment_ids`에 하의 ID가 포함되고, `llm_note`에 `"Skipped #2 because ..."` 메시지가 들어감.
- 프론트엔드(v7 VTON 탭)는 이 메타를 그대로 사용자에게 표시.

### 4. 프론트엔드 호출 (v7)

```js
import { generateVton, listGarments } from "./api.js";

const { items } = await listGarments({ size: 100 });
const ghostIds = items.filter(g => g.ghost_image_url).map(g => g.id);

const result = await generateVton(personFile, ghostIds.slice(0, 2), {
  userName: "엄마",
});
console.log(result.result_image_url);
if (result.skipped_garment_ids.length) {
  console.log(result.llm_note);  // "Skipped #2 because ..."
}
```

---

## 제한사항

- **성능**: 멀티 이미지 LLM 호출이 느리므로 동기 요청으로 수~수십 초 걸릴 수 있음. 프론트엔드는 로딩 상태를 반드시 표시해야 함.
- **일관성**: LLM의 합성 품질은 모델·입력 이미지에 따라 편차가 큼. 동일 입력에 대해 재현성이 보장되지 않음.
- **DB 영속화 없음**: VTON 결과는 파일만 남고 별도 테이블 레코드가 없음. 결과 이미지 URL을 클라이언트가 저장해야 함.
- **고스트 품질에 의존**: VTON 품질은 입력 ghost 이미지의 품질에 크게 좌우됨. ghost가 흐리거나 인물이 덜 지워진 경우 합성도 왜곡됨.
