# 05. 의류 분석 파이프라인

## 개요

의류 분석은 이 프로젝트의 핵심 기능입니다. 사진 한 장을 업로드하면 2단계로 처리됩니다:

**1단계: 분석 (빠름)** — `POST /api/v1/garments/analyze`
1. 이미지를 LLM에 적합한 크기로 리사이즈
2. 원본 이미지를 파일시스템에 저장
3. LLM으로 의류 분석 (카테고리, 태그 등)
4. DB에 Garment 레코드 저장

**2단계: 고스트 마네킹 (느림, 선택)** — `POST /api/v1/garments/{id}/ghost`
5. 기존 의류의 원본 이미지를 읽어 고스트 마네킹 이미지 생성
6. 결과를 파일시스템에 저장하고 DB 업데이트

**별도 기능: 스타일 분석** — `POST /api/v1/garments/style-analyze`
- 전체 코디 사진을 LLM에 보내서 색상 균형, 핏/실루엣, 디테일 포인트 평가
- DB 저장 없이 즉시 결과 반환 (stateless)
- 3개 카테고리별 1-5점 + 한국어 코멘트 + 종합 평가

## 관련 파일

| 파일 | 역할 |
|------|------|
| `app/routers/garment.py` | API 엔드포인트 (진입점) |
| `app/services/garment_service.py` | 파이프라인 오케스트레이션 |
| `app/services/llm/*.py` | LLM API 호출 |
| `app/utils/image.py` | 이미지 저장/리사이즈 |

---

## 전체 플로우 상세

### 1단계: API 진입 (`routers/garment.py`)

#### `POST /api/v1/garments/analyze` (통합 엔드포인트)

기존의 `analyze`와 `analyze-with-face`가 하나로 통합되었습니다.

```python
async def analyze_garment(
    image: UploadFile = File(...),            # 필수: 이미지 파일
    user_id: uuid.UUID | None = Form(None),   # 선택: 사용자 ID
    use_face_id: bool = Form(False),           # 선택: 얼굴 식별 사용 여부
    ...
):
    if user_id is not None:
        resolved_user_id = user_id
        # face_status: "not_used"
    elif use_face_id:
        result = await face_svc.identify(db, image_bytes)
        # face_status: "known", "unknown", "not_detected"
        if result is not None:
            resolved_user_id = result["user_id"]
    # 둘 다 없으면 → Unknown User fallback
    garments = await garment_svc.analyze_and_save(db, image_bytes, resolved_user_id)
```

**3가지 사용 모드 (우선순위 순서):**
1. `user_id` 지정 → 해당 사용자에게 저장 (face_status: `"not_used"`)
2. `use_face_id=true` → 얼굴 식별로 사용자 결정
3. 둘 다 없음 → Unknown User (`00000000-...`)에게 저장 (face_status: `"not_used"`)

**핵심 포인트: 같은 이미지**를 얼굴 식별과 의류 분석에 모두 사용합니다.

**face_status 값:**
```
user_id 지정                      → "not_used" (지정된 사용자)
use_face_id=true + 얼굴 식별 성공 → "known" (매칭된 사용자)
use_face_id=true + 얼굴 식별 실패 → "unknown" (unknown 사용자)
use_face_id=true + 얼굴 미감지    → "not_detected" (unknown 사용자)
둘 다 없음                        → "not_used" (unknown 사용자)
```

---

### 분석 파이프라인 (`services/garment_service.py`)

```python
class GarmentService:
    async def analyze_and_save(self, db, image_bytes, user_id):
        resolved_user_id = user_id or GLOBAL_USER_ID

        # 1. 이미지 리사이즈 (LLM 입력 최적화)
        resized = resize_image_if_needed(image_bytes)

        # 2. 원본 이미지 저장
        source_path = save_original_image(image_bytes, resolved_user_id)

        # 3. LLM 의류 분석
        items = await self.llm.analyze_garment(resized)

        # 4. DB 저장 (고스트 마네킹 없이)
        for item in items:
            garment = Garment(
                user_id=resolved_user_id,
                category_main=item.category_main,
                ...
                ghost_image_path=None,  # 고스트는 별도 엔드포인트에서 생성
            )
            db.add(garment)

        await db.commit()
        return garments
```

> **변경사항:** `analyze_and_save()`는 더 이상 `_generate_ghost()`를 호출하지 않습니다.
> 분석 응답에서 `ghost_image_url`은 항상 `null`입니다.

---

### 2-1: 이미지 리사이즈 (`utils/image.py`)

```python
def resize_image_if_needed(image_bytes, max_size=1536):
    img = Image.open(io.BytesIO(image_bytes))
    if img.mode == "RGBA":
        img = img.convert("RGB")          # RGBA → RGB (JPEG 호환)

    if max(w, h) <= max_size:
        # 크기 OK → JPEG로 변환만
        return img_to_jpeg_bytes()

    ratio = max_size / max(w, h)
    new_size = (int(w * ratio), int(h * ratio))
    img = img.resize(new_size, Image.LANCZOS)   # 고품질 리사이즈
    return img_to_jpeg_bytes()
```

**왜 리사이즈하는가?**
- LLM API에 큰 이미지를 보내면 토큰 비용 증가, 응답 시간 증가
- 1536px 이하로 줄이면 분석 품질은 유지하면서 효율적

---

### 2-2: 원본 이미지 저장

```python
def save_original_image(image_bytes, user_id):
    # storage/originals/{user_id}/{random_hex}.jpg
    rel_path = Path("originals") / str(user_id)
    filename = f"{uuid.uuid4().hex}.jpg"
    (dest_dir / filename).write_bytes(image_bytes)
    return str(rel_path / filename)   # "originals/abc.../def.jpg"
```

- DB에는 **상대 경로**만 저장 (`originals/user_id/filename.jpg`)
- 실제 파일은 `storage/originals/user_id/filename.jpg`에 저장
- API에서 접근 시: `http://localhost:8000/storage/originals/user_id/filename.jpg`

---

### 2-3: LLM 의류 분석

`self.llm.analyze_garment(resized)` 호출 → 04-llm-analysis.md 참조

반환값 예시:
```python
[
    GarmentAnalysis(
        category_main="top",
        category_sub="t-shirt",
        description="블랙 코튼 라운드넥 반팔 티셔츠",
        tags={"color": ["black"], "pattern": "solid", ...}
    ),
    GarmentAnalysis(
        category_main="bottom",
        category_sub="jeans",
        description="인디고 블루 스트레이트 데님 진",
        tags={"color": ["blue"], "pattern": "solid", ...}
    ),
]
```

---

### 고스트 마네킹 생성 (별도 엔드포인트)

#### `POST /api/v1/garments/{id}/ghost`

분석 완료 후, 개별 의류에 대해 고스트 마네킹을 생성합니다.

```python
async def generate_ghost(self, db, garment_id):
    garment = await db.get(Garment, garment_id)

    # 디스크에서 원본 이미지 읽기
    source_bytes = read_image_from_disk(garment.source_image_path)

    # LLM으로 고스트 마네킹 생성
    result = await self.llm.generate_ghost_mannequin(source_bytes, garment, "edit")
    if result is None:
        raise HTTPException(...)

    # 결과 저장 및 DB 업데이트
    ghost_path = save_ghost_mannequin_image(result, garment.user_id)
    garment.ghost_image_path = ghost_path
    await db.commit()

    return {"id": garment.id, "ghost_image_url": f"/storage/{ghost_path}"}
```

**분리된 이유:**
- 분석은 빠르게 완료 (수 초), 고스트 마네킹 생성은 느림 (10초+)
- 클라이언트가 분석 결과를 먼저 확인한 후 필요한 의류만 고스트 생성 가능
- 실패 시 개별 재시도 가능

저장 경로: `storage/ghost_mannequin/{user_id}/{random_hex}.png`

---

### DB 저장 및 응답

```python
for item in items:
    garment = Garment(
        user_id=resolved_user_id,
        category_main=item.category_main,
        category_sub=item.category_sub,
        description=item.description,
        tags=item.tags,
        source_image_path=source_path,
        ghost_image_path=ghost_path,
    )
    db.add(garment)
    garments.append(garment)

await db.commit()

# 서버 생성 필드 (id, created_at 등) 로딩
for g in garments:
    await db.refresh(g)
```

---

## 라우터의 ORM → 스키마 변환

```python
def _garment_to_analysis_item(g: Garment) -> GarmentAnalysisItem:
    ghost_url = f"/storage/{g.ghost_image_path}" if g.ghost_image_path else None
    return GarmentAnalysisItem(
        id=g.id,
        category_main=g.category_main,
        category_sub=g.category_sub,
        description=g.description,
        tags=g.tags,
        ghost_image_url=ghost_url,      # DB 상대경로 → URL 변환
    )
```

---

## 삭제 (`DELETE /api/v1/garments/{garment_id}`)

```python
async def delete_garment(garment_id):
    garment = await db.get(Garment, garment_id)

    source_path = garment.source_image_path
    ghost_path = garment.ghost_image_path

    await db.delete(garment)     # DB에서 삭제
    await db.commit()

    # 파일 삭제 (best-effort: 실패해도 에러 안 남)
    delete_image(source_path)
    delete_image(ghost_path)
```

**순서가 중요합니다:**
1. 먼저 DB 레코드 삭제 (성공해야 함)
2. 그 다음 파일 삭제 (실패해도 OK)

파일 삭제가 실패하면 고아 파일이 남지만, DB는 일관성을 유지합니다.
