사진 보고 글 쓰는 AI — Vision LLM 상세페이지 자동 작성

Qwen2.5-VL · InternVL3로 상품 사진 입력 시 쇼핑몰 상세페이지 텍스트 자동 생성

2026.04.13

VisionLLMQwen2VLInternVLMultiModalInference

[미검증]

📌 0. 시리즈

응용편제목난이도핵심 기술
응용 1사진 10장으로 AI 캐릭터·프로필 이미지 만들기⭐⭐⭐FLUX.1 dev · LoRA · ComfyUI
응용 2내 목소리 AI 클론 — 유튜브 내레이터 자동화⭐⭐⭐F5-TTS · Kokoro · Sesame CSM-1B
응용 3상품 이미지 1장 → 15초 광고 영상 자동 생성⭐⭐⭐⭐Wan2.2 · HunyuanVideo 1.5 · LTX-2
응용 4공장 불량 자동 검사 — NG/OK 탐지 + 로봇 좌표 추출⭐⭐~⭐⭐⭐⭐⭐YOLOv12 · OpenCV · RealSense
응용 5영상 분위기 분석 → BGM 자동 생성 & 싱크⭐⭐⭐MusicGen · AudioCraft · Stable Audio Open
응용 6주제 한 줄 입력으로 유튜브 영상 완성 — AI 콘텐츠 자동화 파이프라인⭐⭐⭐⭐LangGraph · CrewAI · AutoGen 0.4
응용 7사진 보고 글 쓰는 AI — Vision LLM 상세페이지 자동 작성⭐⭐⭐⭐Qwen2.5-VL · InternVL3 · LLaVA-Next
응용 8내 PDF 문서를 AI가 읽는다 — 사내 지식 RAG 챗봇 구축⭐⭐⭐LlamaIndex · ChromaDB · Qdrant

📌 1. 들어가며

이 포스트에서 만들 것

이 포스트를 끝까지 따라하면 상품 이미지 1장만 업로드하면 아래 결과물이 자동으로 생성됩니다.

입력: 상품 이미지 1~5장 (JPG / PNG)
   ↓
Vision LLM (Qwen2.5-VL / InternVL3)
   │
   ├─ 상품 자동 인식 (색상 / 재질 / 기능 / 특징)
   ├─ 카테고리별 프롬프트 분기 (의류 / 식품 / 전자기기 등)
   └─ 출력 형식 선택 (JSON / Markdown / HTML)
   ↓
FastAPI 서버
   └─ 이미지 업로드 → 상세페이지 텍스트 즉시 반환
   ↓
출력:
  ├─ product_detail.json    (구조화 데이터)
  ├─ product_detail.md      (마크다운 상세페이지)
  ├─ product_detail.html    (쇼핑몰 바로 붙여넣기용)
  └─ DB 자동 저장 (MySQL / Supabase)

Vision LLM이란 — 이미지와 텍스트를 동시에 이해하는 모델

일반 LLM:       텍스트만 입력 → 텍스트 출력
Vision LLM:     이미지 + 텍스트 동시 입력 → 텍스트 출력

                    ┌─────────────┐
  [상품 이미지] ──→ │             │
                    │ Vision LLM  │ ──→ "블랙 컬러 스테인리스 텀블러,
  [질문 텍스트] ──→ │             │      보온 12시간, 500ml..."
                    └─────────────┘

대표 모델 (2026 기준):
  Qwen2.5-VL  (Alibaba)   → 오픈소스 최강, 한국어 우수
  InternVL3   (Shanghai)  → 초고해상도 이미지 특화
  LLaVA-Next  (LMSys)     → 경량, 빠른 추론
  GPT-4o      (OpenAI)    → API 방식, 최고 품질
  Gemini 2.0  (Google)    → API 방식, 멀티이미지 강점

📌 2. 환경 준비

2-1. VRAM별 모델 선택

VRAM권장 모델특징추론 속도
40GB+InternVL3-78B / Qwen2.5-VL-72B최고 품질, 복잡한 이미지 완벽 분석느림
16~24GBQwen2.5-VL-7B (권장)품질/속도 균형, 한국어 우수보통
8~12GBQwen2.5-VL-3B / LLaVA-Next-7B실용적 품질, 단순 제품 분석 가능빠름
GPU 없음OpenAI GPT-4o API / Gemini API최고 품질, 비용 발생매우 빠름
VRAM 16GB 기준 권장 모델: Qwen2.5-VL-7B-Instruct
  → 한국어 상세페이지 품질 우수
  → 상품 이미지 내 텍스트(라벨, 성분표) 인식 가능
  → 배치 처리 지원
  → MIT 라이선스 (상업적 사용 가능)

API 방식 (GPU 없음):

python# OpenAI GPT-4o API
pip install openai

# Google Gemini API
pip install google-generativeai

# 비용 참고 (2026 기준):
# GPT-4o:    이미지 1장 약 $0.001~0.003
# Gemini 2.0: 이미지 1장 약 $0.0005~0.001
# → 월 1000개 상품 처리 시 $0.5~3 수준

2-2. transformers, FastAPI 설치

bash# Python 환경 생성
python -m venv venv
source venv/bin/activate       # Linux/Mac
venv\Scripts\activate          # Windows

# PyTorch (CUDA 12.1)
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121

# Vision LLM 핵심 패키지
pip install transformers accelerate
pip install qwen-vl-utils       # Qwen2.5-VL 유틸리티
pip install flash-attn --no-build-isolation  # 추론 속도 2배 향상

# FastAPI 서버
pip install fastapi uvicorn python-multipart

# 이미지 처리
pip install pillow opencv-python

# DB 연동
pip install sqlalchemy pymysql   # MySQL
pip install supabase             # Supabase

# 4bit 양자화 (VRAM 절약)
pip install bitsandbytes

📌 3. Vision LLM 핵심 원리

3-1. 이미지 → 패치(Patch) 토큰 변환 과정

일반 이미지 (1280×720 픽셀)
          ↓
[Vision Encoder - ViT 계열]
  이미지를 14×14 픽셀 단위 패치로 분할
  1280×720 → 약 4,600개 패치
          ↓
[패치 임베딩]
  각 패치 → 고차원 벡터 (1개 패치 = 1개 토큰)
  "이 패치는 반짝이는 금속 질감"
  "이 패치는 검정색 뚜껑 부분"
          ↓
[토큰 압축 (Qwen2.5-VL: Dynamic Resolution)]
  4,600개 → 256~1,280개 토큰으로 동적 압축
  → 해상도에 따라 토큰 수 자동 조절
  → 고해상도 이미지 = 더 많은 토큰 = 더 세밀한 분석
          ↓
[LLM 입력]
  [이미지 토큰 256개] + [질문 텍스트 토큰 50개]
  → 총 306개 토큰이 LLM에 입력됨

3-2. 텍스트 + 이미지 토큰 통합 처리 구조

입력 시퀀스 구성:

<|system|>
당신은 쇼핑몰 상세페이지 작성 전문가입니다.
<|user|>
<|image_pad|><|image_pad|>...(이미지 토큰 256개)...<|image_pad|>
이 상품의 상세페이지를 작성해주세요.
<|assistant|>
                    ↓
           LLM Transformer
     (이미지 토큰 + 텍스트 토큰 교차 어텐션)
                    ↓
출력: "## 프리미엄 스테인리스 텀블러 500ml
      ### 제품 특징
      - 식품용 18/8 스테인리스 소재..."

핵심 원리:
  → 이미지 토큰과 텍스트 토큰이 같은 공간에서 어텐션
  → 모델이 "이미지 어느 부분을 보며 이 텍스트를 생성했는지" 학습됨
  → Qwen2.5-VL은 한국어 텍스트 토큰과 이미지 토큰의 연결 우수

📌 4. 모델별 설치 & 추론

4-1. Qwen2.5-VL 설치 & 기본 추론

python# models/qwen_vl.py
from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor
from qwen_vl_utils import process_vision_info
from PIL import Image
import torch

def load_qwen_vl(model_name="Qwen/Qwen2.5-VL-7B-Instruct",
                 use_4bit=False):
    """
    Qwen2.5-VL 모델 로드
    use_4bit=True → VRAM 절약 (16GB → 8GB)
    """
    print(f"모델 로드 중: {model_name}")

    quant_config = None
    if use_4bit:
        from transformers import BitsAndBytesConfig
        quant_config = BitsAndBytesConfig(
            load_in_4bit              = True,
            bnb_4bit_compute_dtype    = torch.bfloat16,
            bnb_4bit_use_double_quant = True,
            bnb_4bit_quant_type       = "nf4",
        )

    model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
        model_name,
        torch_dtype              = torch.bfloat16,
        device_map               = "auto",
        quantization_config      = quant_config,
        attn_implementation      = "flash_attention_2",  # 속도 향상
    )

    # 이미지 토큰 해상도 제어
    # min_pixels: 최소 해상도 (빠른 처리)
    # max_pixels: 최대 해상도 (정밀 분석)
    processor = AutoProcessor.from_pretrained(
        model_name,
        min_pixels = 256  * 28 * 28,    # 최소 256 패치
        max_pixels = 1280 * 28 * 28,    # 최대 1280 패치
    )

    print("✅ Qwen2.5-VL 로드 완료")
    return model, processor

def infer_qwen_vl(model, processor, image_paths, prompt,
                  max_new_tokens=1024, temperature=0.3):
    """
    이미지(들) + 텍스트 프롬프트 → 텍스트 생성
    image_paths: 단일 경로 or 리스트 (최대 10장)
    """
    if isinstance(image_paths, str):
        image_paths = [image_paths]

    # 멀티이미지 메시지 구성
    content = []
    for path in image_paths:
        content.append({"type": "image", "image": path})
    content.append({"type": "text", "text": prompt})

    messages = [{"role": "user", "content": content}]

    # 전처리
    text         = processor.apply_chat_template(
        messages, tokenize=False, add_generation_prompt=True
    )
    image_inputs, _ = process_vision_info(messages)
    inputs       = processor(
        text         = [text],
        images       = image_inputs,
        padding      = True,
        return_tensors = "pt",
    ).to(model.device)

    # 추론
    with torch.no_grad():
        output_ids = model.generate(
            **inputs,
            max_new_tokens = max_new_tokens,
            temperature    = temperature,
            do_sample      = temperature > 0,
            repetition_penalty = 1.1,
        )

    # 입력 토큰 제거 후 디코딩
    trimmed = [
        out[len(inp):]
        for inp, out in zip(inputs.input_ids, output_ids)
    ]
    response = processor.batch_decode(
        trimmed, skip_special_tokens=True,
        clean_up_tokenization_spaces=False
    )[0]

    return response.strip()

4-2. InternVL3 설치 & 기본 추론

python# models/internvl3.py
# InternVL3 — 고해상도 상품 이미지 특화 (40GB+ VRAM)
from transformers import AutoTokenizer, AutoModel
import torchvision.transforms as T
from PIL import Image
import torch
import numpy as np

IMAGENET_MEAN = (0.485, 0.456, 0.406)
IMAGENET_STD  = (0.229, 0.224, 0.225)

def load_internvl3(model_name="OpenGVLab/InternVL3-78B"):
    tokenizer = AutoTokenizer.from_pretrained(
        model_name, trust_remote_code=True
    )
    model = AutoModel.from_pretrained(
        model_name,
        torch_dtype      = torch.bfloat16,
        device_map       = "auto",
        trust_remote_code = True,
    ).eval()
    return model, tokenizer

def build_transform(input_size=448):
    return T.Compose([
        T.Lambda(lambda img: img.convert("RGB")),
        T.Resize((input_size, input_size),
                 interpolation=T.InterpolationMode.BICUBIC),
        T.ToTensor(),
        T.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
    ])

def load_image_internvl(image_path, input_size=448, max_num=12):
    """InternVL3 이미지 전처리 (동적 해상도 분할)"""
    image     = Image.open(image_path).convert("RGB")
    transform = build_transform(input_size)

    # 고해상도 이미지를 여러 패치로 분할 (Dynamic Patching)
    pixel_values = transform(image).unsqueeze(0)
    return pixel_values.to(torch.bfloat16)

def infer_internvl3(model, tokenizer, image_path, prompt,
                    max_new_tokens=1024):
    pixel_values  = load_image_internvl(image_path)
    pixel_values  = pixel_values.to(model.device)

    generation_config = dict(max_new_tokens=max_new_tokens, do_sample=False)
    question = f"<image>\n{prompt}"

    response = model.chat(
        tokenizer, pixel_values,
        question, generation_config
    )
    return response

4-3. LLaVA-Next 경량 옵션

python# models/llava_next.py
# LLaVA-Next — 8GB VRAM에서 동작하는 경량 옵션
from transformers import LlavaNextProcessor, LlavaNextForConditionalGeneration
from PIL import Image
import torch

def load_llava_next(model_name="llava-hf/llava-v1.6-mistral-7b-hf"):
    processor = LlavaNextProcessor.from_pretrained(model_name)
    model     = LlavaNextForConditionalGeneration.from_pretrained(
        model_name,
        torch_dtype = torch.float16,
        device_map  = "auto",
        load_in_4bit = True,   # 4bit 양자화 — 8GB VRAM
    )
    return model, processor

def infer_llava(model, processor, image_path, prompt,
                max_new_tokens=512):
    image = Image.open(image_path).convert("RGB")

    conversation = [{
        "role": "user",
        "content": [
            {"type": "image"},
            {"type": "text", "text": prompt},
        ],
    }]
    text   = processor.apply_chat_template(
        conversation, add_generation_prompt=True
    )
    inputs = processor(
        images=image, text=text, return_tensors="pt"
    ).to(model.device)

    with torch.no_grad():
        output = model.generate(**inputs,
                                max_new_tokens=max_new_tokens)
    return processor.decode(output[0], skip_special_tokens=True)

📌 5. 프롬프트 설계

5-1. 쇼핑몰 상세페이지 프롬프트 템플릿

python# prompts/templates.py

BASE_SYSTEM_PROMPT = """당신은 쇼핑몰 상세페이지 작성 전문가입니다.
상품 이미지를 분석하여 구매 전환율을 극대화하는 상세페이지를 작성합니다.

작성 원칙:
1. 이미지에서 보이는 정보만 기술 (없는 정보 추측 금지)
2. 고객 구매 욕구를 자극하는 감성적 표현 사용
3. SEO에 유리한 자연스러운 키워드 포함
4. 명확한 제품 스펙과 특징 구분"""

DETAIL_PAGE_PROMPT = """이 상품 이미지를 분석하여 쇼핑몰 상세페이지를 작성해주세요.

반드시 아래 JSON 형식으로 응답하세요:
{{
  "product_name": "상품명 (간결하고 매력적으로)",
  "category": "카테고리",
  "headline": "핵심 가치를 담은 한 줄 헤드라인",
  "description": "상품 소개 2~3문장 (감성적)",
  "features": [
    {{"title": "특징명", "description": "설명"}}
  ],
  "specs": {{
    "색상": "...",
    "소재": "...",
    "크기": "...",
    "기타": "..."
  }},
  "target_customer": "주요 구매 고객층",
  "use_cases": ["활용 상황 1", "활용 상황 2"],
  "keywords": ["SEO 키워드1", "키워드2", "키워드3"],
  "caution": "주의사항 (이미지에서 보이는 경우만)"
}}

상품 이미지: {image_count}장"""

5-2. 제품 카테고리별 프롬프트 분기

python# prompts/category_prompts.py

CATEGORY_PROMPTS = {

    "fashion": """이 패션 상품 이미지를 분석하여 상세페이지를 작성하세요.
패션 상품 특화 항목:
- 핏(Fit): 슬림 / 레귤러 / 오버사이즈
- 소재 및 촉감 (이미지에서 유추 가능한 경우)
- 계절감 및 코디 제안
- 착용 시 분위기 묘사
- 사이즈 가이드 요청 여부
JSON 형식으로 반환하세요.""",

    "electronics": """이 전자기기/IT 상품 이미지를 분석하여 상세페이지를 작성하세요.
전자기기 특화 항목:
- 보이는 포트 / 버튼 / 인터페이스 목록
- 디자인 특징 (재질, 색상, 폼팩터)
- 용도 및 호환성 유추
- 기술적 신뢰감을 주는 표현
JSON 형식으로 반환하세요.""",

    "food": """이 식품 이미지를 분석하여 상세페이지를 작성하세요.
식품 특화 항목:
- 포장 외관에서 보이는 성분/용량/브랜드
- 맛 묘사 (색감, 질감에서 유추)
- 보관 방법 (포장에서 보이는 경우)
- 식욕을 자극하는 감성적 표현
- 안전 문구 (알레르기 등, 라벨에서 보이는 경우)
JSON 형식으로 반환하세요.""",

    "cosmetics": """이 화장품/뷰티 상품 이미지를 분석하여 상세페이지를 작성하세요.
뷰티 특화 항목:
- 제품 타입 (세럼 / 크림 / 토너 등)
- 용량 및 패키지 재질
- 브랜드 감성 및 타겟 피부 타입 유추
- 사용 순서 제안
- 성분 (라벨에서 보이는 경우)
JSON 형식으로 반환하세요.""",

    "furniture": """이 가구/인테리어 상품 이미지를 분석하여 상세페이지를 작성하세요.
인테리어 특화 항목:
- 스타일 (모던 / 북유럽 / 빈티지 등)
- 소재 및 마감
- 공간 활용 및 어울리는 인테리어 스타일
- 조립 여부 유추
- 분위기 묘사 (따뜻한 / 미니멀 등)
JSON 형식으로 반환하세요.""",
}

def get_category_prompt(category: str) -> str:
    """카테고리에 맞는 프롬프트 반환"""
    return CATEGORY_PROMPTS.get(category, DETAIL_PAGE_PROMPT)

def auto_detect_category(model, processor, image_path) -> str:
    """
    이미지에서 카테고리 자동 감지
    """
    detect_prompt = """이 상품의 카테고리를 다음 중 하나로만 답하세요:
fashion / electronics / food / cosmetics / furniture / other

카테고리:"""

    response = infer_qwen_vl(
        model, processor, image_path,
        detect_prompt, max_new_tokens=10
    )

    # 응답에서 카테고리 추출
    for cat in ["fashion", "electronics", "food", "cosmetics", "furniture"]:
        if cat in response.lower():
            return cat
    return "other"

5-3. 출력 형식 제어 (JSON / Markdown / HTML)

python# prompts/formatters.py
import json
from typing import Literal

def format_output(raw_json: dict,
                  format: Literal["json", "markdown", "html"]) -> str:
    """
    Vision LLM 출력 JSON → 원하는 형식으로 변환
    """
    if format == "json":
        return json.dumps(raw_json, ensure_ascii=False, indent=2)

    elif format == "markdown":
        md = f"# {raw_json.get('product_name', '상품명')}\n\n"
        md += f"> {raw_json.get('headline', '')}\n\n"
        md += f"{raw_json.get('description', '')}\n\n"

        md += "## 주요 특징\n"
        for feat in raw_json.get("features", []):
            md += f"- **{feat['title']}**: {feat['description']}\n"

        md += "\n## 제품 스펙\n"
        for k, v in raw_json.get("specs", {}).items():
            md += f"| {k} | {v} |\n"

        md += f"\n## 이런 분께 추천\n{raw_json.get('target_customer', '')}\n\n"

        md += "## 활용 상황\n"
        for uc in raw_json.get("use_cases", []):
            md += f"- {uc}\n"

        if raw_json.get("caution"):
            md += f"\n> ⚠️ **주의사항**: {raw_json['caution']}\n"

        return md

    elif format == "html":
        feats = "".join([
            f"<li><strong>{f['title']}</strong>: {f['description']}</li>"
            for f in raw_json.get("features", [])
        ])
        specs = "".join([
            f"<tr><td>{k}</td><td>{v}</td></tr>"
            for k, v in raw_json.get("specs", {}).items()
        ])
        uc = "".join([
            f"<li>{u}</li>"
            for u in raw_json.get("use_cases", [])
        ])

        html = f"""<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>{raw_json.get('product_name', '')}</title>
</head>
<body>
  <h1>{raw_json.get('product_name', '')}</h1>
  <p class="headline"><em>{raw_json.get('headline', '')}</em></p>
  <p>{raw_json.get('description', '')}</p>

  <h2>주요 특징</h2>
  <ul>{feats}</ul>

  <h2>제품 스펙</h2>
  <table border="1">
    <tr><th>항목</th><th>내용</th></tr>
    {specs}
  </table>

  <h2>이런 분께 추천</h2>
  <p>{raw_json.get('target_customer', '')}</p>

  <h2>활용 상황</h2>
  <ul>{uc}</ul>
</body>
</html>"""
        return html

📌 6. FastAPI 서버 구성

6-1. 이미지 업로드 엔드포인트

python# server/main.py
from fastapi import FastAPI, File, UploadFile, Form, HTTPException
from fastapi.responses import JSONResponse
from typing import List, Optional
import uvicorn
import shutil, os, uuid, json, asyncio
from PIL import Image

from models.qwen_vl          import load_qwen_vl, infer_qwen_vl
from prompts.templates        import BASE_SYSTEM_PROMPT, DETAIL_PAGE_PROMPT
from prompts.category_prompts import auto_detect_category, get_category_prompt
from prompts.formatters       import format_output

app    = FastAPI(title="Vision LLM 상세페이지 자동 작성 API")
UPLOAD = "./uploads"
os.makedirs(UPLOAD, exist_ok=True)

# 앱 시작 시 모델 로드 (1회)
model, processor = None, None

@app.on_event("startup")
async def startup_event():
    global model, processor
    print("모델 로드 중...")
    model, processor = load_qwen_vl(
        "Qwen/Qwen2.5-VL-7B-Instruct",
        use_4bit=False   # VRAM 8GB → True
    )
    print("✅ 서버 준비 완료")

@app.get("/health")
async def health():
    return {"status": "ok", "model": "Qwen2.5-VL-7B"}

6-2. 추론 결과 반환

python@app.post("/generate-detail")
async def generate_detail(
    images:    List[UploadFile] = File(...),
    category:  Optional[str]   = Form(None),    # 없으면 자동 감지
    format:    str              = Form("json"),   # json / markdown / html
    language:  str              = Form("ko"),     # ko / en / ja
):
    """
    상품 이미지 업로드 → 상세페이지 자동 생성
    """
    if len(images) > 5:
        raise HTTPException(400, "이미지는 최대 5장까지 업로드 가능합니다.")

    # 이미지 저장
    session_id  = str(uuid.uuid4())[:8]
    session_dir = os.path.join(UPLOAD, session_id)
    os.makedirs(session_dir, exist_ok=True)
    image_paths = []

    for img in images:
        ext  = os.path.splitext(img.filename)[-1].lower()
        if ext not in [".jpg", ".jpeg", ".png", ".webp"]:
            raise HTTPException(400, f"지원하지 않는 형식: {ext}")

        path = os.path.join(session_dir, img.filename)
        with open(path, "wb") as f:
            shutil.copyfileobj(img.file, f)

        # 이미지 최대 크기 제한 (VRAM 보호)
        pil_img = Image.open(path)
        if max(pil_img.size) > 2048:
            pil_img.thumbnail((2048, 2048), Image.LANCZOS)
            pil_img.save(path)

        image_paths.append(path)

    # 카테고리 자동 감지
    if not category:
        category = auto_detect_category(model, processor, image_paths[0])
        print(f"🔍 카테고리 자동 감지: {category}")

    # 언어별 프롬프트 조정
    lang_suffix = {
        "ko": "한국어로 작성하세요.",
        "en": "Write in English.",
        "ja": "日本語で書いてください。",
    }.get(language, "한국어로 작성하세요.")

    prompt = get_category_prompt(category) + f"\n{lang_suffix}"
    prompt = prompt.format(image_count=len(image_paths))

    # 추론
    try:
        raw_response = infer_qwen_vl(
            model, processor,
            image_paths, prompt,
            max_new_tokens = 1500,
            temperature    = 0.2,   # 낮은 온도 → 안정적 JSON
        )

        # JSON 파싱
        if "```json" in raw_response:
            raw_response = raw_response.split("```json").split("```")[1]
        elif "```" in raw_response:
            raw_response = raw_response.split("```").split("```")[0]

        result_json = json.loads(raw_response.strip())

    except json.JSONDecodeError:
        # JSON 파싱 실패 시 텍스트 그대로 반환
        result_json = {"raw_text": raw_response}

    # 형식 변환
    formatted = format_output(result_json, format)

    # 임시 파일 정리
    shutil.rmtree(session_dir, ignore_errors=True)

    return JSONResponse({
        "session_id": session_id,
        "category":   category,
        "format":     format,
        "result":     formatted if format != "json" else result_json,
    })

6-3. 배치 처리 (여러 상품 한번에)

pythonimport asyncio
from concurrent.futures import ThreadPoolExecutor

executor = ThreadPoolExecutor(max_workers=2)   # GPU 동시 추론 수

@app.post("/generate-batch")
async def generate_batch(
    files: List[UploadFile] = File(...),
    format: str = Form("json"),
):
    """
    여러 상품 이미지를 한 번에 처리 (비동기 배치)
    files: [product1.jpg, product2.jpg, ...]
    """
    MAX_BATCH = 20
    if len(files) > MAX_BATCH:
        raise HTTPException(400, f"배치 최대 {MAX_BATCH}개")

    results     = []
    loop        = asyncio.get_event_loop()
    semaphore   = asyncio.Semaphore(2)    # 동시 처리 최대 2개

    async def process_one(file: UploadFile, idx: int):
        async with semaphore:
            session_id  = str(uuid.uuid4())[:8]
            path        = f"./uploads/batch_{session_id}.jpg"
            with open(path, "wb") as f:
                shutil.copyfileobj(file.file, f)

            # CPU 블로킹 작업을 별도 스레드에서 실행
            def run_infer():
                category = auto_detect_category(model, processor, path)
                prompt   = get_category_prompt(category)
                raw      = infer_qwen_vl(
                    model, processor, path, prompt,
                    max_new_tokens=800, temperature=0.2
                )
                os.remove(path)
                return {"idx": idx, "filename": file.filename,
                        "category": category, "result": raw}

            result = await loop.run_in_executor(executor, run_infer)
            return result

    # 모든 파일 비동기 처리
    tasks   = [process_one(f, i) for i, f in enumerate(files)]
    results = await asyncio.gather(*tasks, return_exceptions=True)

    # 성공/실패 분리
    success = [r for r in results if isinstance(r, dict)]
    errors  = [str(r) for r in results if isinstance(r, Exception)]

    return JSONResponse({
        "total":   len(files),
        "success": len(success),
        "failed":  len(errors),
        "results": success,
        "errors":  errors,
    })

if __name__ == "__main__":
    uvicorn.run("server.main:app",
                host="0.0.0.0", port=8000,
                reload=False, workers=1)

서버 실행 및 테스트:

bash# 서버 실행
uvicorn server.main:app --host 0.0.0.0 --port 8000

# 테스트 (curl)
curl -X POST "http://localhost:8000/generate-detail" \
  -F "images=@./product.jpg" \
  -F "category=fashion" \
  -F "format=markdown" \
  -F "language=ko"

# Python 테스트
import requests

with open("./product.jpg", "rb") as f:
    resp = requests.post(
        "http://localhost:8000/generate-detail",
        files  = {"images": ("product.jpg", f, "image/jpeg")},
        data   = {"format": "markdown", "language": "ko"},
    )
print(resp.json()["result"])

📌 7. CMS 자동 저장 연동

REST API → DB 저장 예시 (MySQL / Supabase)

python# db/save_to_db.py
from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime
from sqlalchemy.orm import sessionmaker, declarative_base
from datetime import datetime
import json

Base = declarative_base()

class ProductDetail(Base):
    __tablename__ = "product_details"

    id           = Column(Integer, primary_key=True, autoincrement=True)
    product_name = Column(String(200))
    category     = Column(String(50))
    headline     = Column(String(300))
    description  = Column(Text)
    features_json= Column(Text)   # JSON 문자열
    specs_json   = Column(Text)
    keywords_json= Column(Text)
    html_content = Column(Text)
    md_content   = Column(Text)
    created_at   = Column(DateTime, default=datetime.utcnow)
    image_path   = Column(String(500))

# ─── MySQL 연결 ───────────────────────────────────────────

def get_mysql_engine(
    host="localhost", port=3306,
    user="root", password="yourpassword",
    database="shop_cms"
):
    url = f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}"
    return create_engine(url, echo=False)

def save_to_mysql(result_json: dict, image_path: str,
                  engine=None):
    """Vision LLM 결과 → MySQL 저장"""
    if engine is None:
        engine = get_mysql_engine()

    Base.metadata.create_all(engine)
    Session = sessionmaker(bind=engine)
    session = Session()

    record = ProductDetail(
        product_name  = result_json.get("product_name", ""),
        category      = result_json.get("category", ""),
        headline      = result_json.get("headline", ""),
        description   = result_json.get("description", ""),
        features_json = json.dumps(result_json.get("features", []),
                                   ensure_ascii=False),
        specs_json    = json.dumps(result_json.get("specs", {}),
                                   ensure_ascii=False),
        keywords_json = json.dumps(result_json.get("keywords", []),
                                   ensure_ascii=False),
        html_content  = format_output(result_json, "html"),
        md_content    = format_output(result_json, "markdown"),
        image_path    = image_path,
    )

    session.add(record)
    session.commit()
    product_id = record.id
    session.close()

    print(f"✅ MySQL 저장 완료: ID={product_id}")
    return product_id

# ─── Supabase 연결 ────────────────────────────────────────

from supabase import create_client

SUPABASE_URL = "https://xxxx.supabase.co"
SUPABASE_KEY = "your-anon-key"

def save_to_supabase(result_json: dict, image_path: str):
    """Vision LLM 결과 → Supabase 저장"""
    client = create_client(SUPABASE_URL, SUPABASE_KEY)

    data = {
        "product_name": result_json.get("product_name"),
        "category":     result_json.get("category"),
        "headline":     result_json.get("headline"),
        "description":  result_json.get("description"),
        "features":     result_json.get("features", []),
        "specs":        result_json.get("specs", {}),
        "keywords":     result_json.get("keywords", []),
        "html_content": format_output(result_json, "html"),
        "md_content":   format_output(result_json, "markdown"),
        "image_path":   image_path,
    }

    response = client.table("product_details").insert(data).execute()
    record_id = response.data[0]["id"]
    print(f"✅ Supabase 저장 완료: ID={record_id}")
    return record_id

# ─── FastAPI 엔드포인트와 통합 ────────────────────────────

@app.post("/generate-and-save")
async def generate_and_save(
    image:    UploadFile = File(...),
    format:   str        = Form("json"),
    db_type:  str        = Form("mysql"),   # mysql / supabase
):
    """이미지 업로드 → 상세페이지 생성 → DB 자동 저장"""

    # 이미지 저장
    image_path = f"./uploads/{uuid.uuid4()}_{image.filename}"
    with open(image_path, "wb") as f:
        shutil.copyfileobj(image.file, f)

    # Vision LLM 추론
    category = auto_detect_category(model, processor, image_path)
    prompt   = get_category_prompt(category)
    raw      = infer_qwen_vl(model, processor, image_path, prompt,
                              max_new_tokens=1500, temperature=0.2)

    try:
        if "```json" in raw:
            raw = raw.split("```json").split("```")[1]
        result_json = json.loads(raw.strip())
    except:
        result_json = {"raw_text": raw}

    # DB 저장
    if db_type == "mysql":
        record_id = save_to_mysql(result_json, image_path)
    elif db_type == "supabase":
        record_id = save_to_supabase(result_json, image_path)

    return JSONResponse({
        "record_id": record_id,
        "category":  category,
        "result":    result_json,
    })

📌 8. 결과 확인 & 트러블슈팅

설명이 너무 짧거나 길 때

python# 설명이 너무 짧을 때 (200토큰 미만)
# → max_new_tokens 증가 + 프롬프트 강화

SHORT_RESPONSE_FIX = """
이미지를 아주 꼼꼼하게 분석하여 최대한 자세한 상세페이지를 작성하세요.
각 특징(features)은 최소 5개 이상 작성하세요.
설명(description)은 최소 3문장 이상 작성하세요.

<div class="code-output standalone-output"><span class="code-output-label">Output</span><pre><code># 설명이 너무 길 때 (2000토큰 초과)
# → max_new_tokens 제한 + 프롬프트 압축 지시

CONCISE_PROMPT_ADD = &quot;&quot;&quot;
간결하게 작성하세요:
- features: 최대 4개
- description: 2문장 이내
- specs: 핵심 3가지만</code></pre></div>


# 응답 길이 자동 조절
def adaptive_infer(model, processor, image_path, prompt,
                   target_min=300, target_max=1200):
    response = infer_qwen_vl(model, processor, image_path,
                              prompt, max_new_tokens=1500)
    token_count = len(response.split())

    if token_count < target_min:
        print(f"⚠️  응답 너무 짧음 ({token_count}토큰) → 재시도")
        response = infer_qwen_vl(
            model, processor, image_path,
            prompt + SHORT_RESPONSE_FIX,
            max_new_tokens=2000, temperature=0.5
        )
    elif token_count > target_max:
        print(f"⚠️  응답 너무 김 ({token_count}토큰) → 재시도")
        response = infer_qwen_vl(
            model, processor, image_path,
            prompt + CONCISE_PROMPT_ADD,
            max_new_tokens=800, temperature=0.1
        )

    return response

이미지를 잘못 인식할 때

python# 원인 1: 이미지 해상도가 너무 낮음
# → 최소 512x512 이상 사용 권장
from PIL import Image

def check_and_upscale(image_path, min_size=512):
    img = Image.open(image_path)
    w, h = img.size
    if min(w, h) < min_size:
        scale = min_size / min(w, h)
        new_w, new_h = int(w * scale), int(h * scale)
        img = img.resize((new_w, new_h), Image.LANCZOS)
        img.save(image_path)
        print(f"이미지 업스케일: {w}x{h}{new_w}x{new_h}")

# 원인 2: 배경이 복잡하여 상품을 못 찾음
# → 응용 3에서 사용한 rembg로 배경 제거 후 입력
from rembg import remove
def preprocess_product_image(input_path, output_path):
    with open(input_path, "rb") as f:
        result = remove(f.read())
    with open(output_path, "wb") as f:
        f.write(result)

# 원인 3: 조명이 어두워 색상 오인식
# → OpenCV로 자동 밝기 보정
import cv2, numpy as np

def enhance_image(image_path):
    img = cv2.imread(image_path)
    lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)
    l, a, b = cv2.split(lab)
    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
    l = clahe.apply(l)
    enhanced = cv2.merge([l, a, b])
    enhanced = cv2.cvtColor(enhanced, cv2.COLOR_LAB2BGR)
    cv2.imwrite(image_path, enhanced)

추론 속도 개선 (4bit 양자화)

python# 방법 1: 4bit 양자화 (BitsAndBytes)
# VRAM 사용량 약 40~50% 감소, 속도 약 10~20% 감소
model, processor = load_qwen_vl(
    "Qwen/Qwen2.5-VL-7B-Instruct",
    use_4bit=True   # VRAM: 16GB → 8GB
)

# 방법 2: Flash Attention 2 활성화 (설치 필수)
# pip install flash-attn --no-build-isolation
# → 추론 속도 1.5~2배 향상 (A100/H100 기준)

# 방법 3: 이미지 토큰 수 줄이기
# 단순 제품 → 토큰 적게 / 복잡한 제품 → 토큰 많이
processor = AutoProcessor.from_pretrained(
    "Qwen/Qwen2.5-VL-7B-Instruct",
    min_pixels = 128 * 28 * 28,    # 더 작게 (빠름)
    max_pixels = 512 * 28 * 28,    # 더 작게 (빠름)
)

# 방법 4: 추론 시간 측정
import time

def timed_infer(model, processor, image_path, prompt):
    start    = time.time()
    response = infer_qwen_vl(model, processor, image_path, prompt)
    elapsed  = time.time() - start
    tokens   = len(response.split())
    tps      = tokens / elapsed

    print(f"⏱️  추론 시간: {elapsed:.1f}초 / {tokens}토큰 / {tps:.1f} tokens/s")
    return response

# 기대 성능 (RTX 4090 기준):
# Qwen2.5-VL-7B (bf16):  약 8~15초 / 이미지 1장
# Qwen2.5-VL-7B (4bit):  약 6~10초 / 이미지 1장
# LLaVA-Next-7B (4bit):  약 3~6초  / 이미지 1장

완성 체크리스트

  • Qwen2.5-VL 모델 로드 및 단일 이미지 추론 확인
  • 카테고리 자동 감지 정확도 확인 (5~10개 상품 테스트)
  • JSON / Markdown / HTML 출력 형식 변환 확인
  • FastAPI 서버 실행 및 /generate-detail 엔드포인트 테스트
  • 배치 처리 /generate-batch 10개 이상 동시 처리 확인
  • MySQL 또는 Supabase 저장 확인
  • 4bit 양자화 적용 후 속도/품질 비교