Skip to content
Published on

멀티모달 AI 완전 정복: CLIP, LLaVA, GPT-4V, Gemini Vision 마스터하기

Authors

목차

  1. 멀티모달 AI 개요
  2. CLIP 심층 분석
  3. BLIP 계열
  4. LLaVA: 대규모 언어-비전 어시스턴트
  5. InstructBLIP
  6. GPT-4 Vision
  7. Gemini Vision
  8. Claude Vision
  9. 멀티모달 RAG
  10. 오픈소스 멀티모달 모델
  11. 비디오 이해 AI

1. 멀티모달 AI 개요

단일 모달리티의 한계

기존 AI 시스템은 텍스트, 이미지, 오디오 중 하나의 데이터 형태(모달리티)만 처리할 수 있었습니다. 이러한 단일 모달리티 접근 방식은 실세계의 복잡한 문제를 해결하는 데 근본적인 한계를 가지고 있습니다.

텍스트 전용 모델의 한계:

  • 이미지 설명 요청 시 이미지 자체를 분석 불가
  • 도표, 차트, 스크린샷의 내용 이해 불가
  • 시각적 컨텍스트 없는 의사결정

이미지 전용 모델의 한계:

  • 이미지 내 텍스트와 시각적 요소의 복합적 이해 불가
  • 언어 기반 질의응답 불가
  • 자연어 설명을 통한 검색 불가

멀티모달 AI의 가능성

멀티모달 AI는 여러 형태의 데이터를 동시에 처리하고 이해할 수 있는 시스템입니다. 인간의 자연스러운 인지 방식인 "보고, 듣고, 읽으면서 동시에 이해하는" 능력을 AI가 갖추게 됩니다.

주요 활용 분야:

  • 의료 진단: 의료 영상 + 환자 기록 텍스트 통합 분석
  • 자율주행: 카메라 + 라이더 + 지도 데이터 통합
  • 교육: 교재 이미지 + 설명 텍스트 자동 생성
  • 이커머스: 제품 사진 + 설명 + 리뷰 통합 처리
  • 문서 이해: 스캔 문서 OCR + 내용 분석
  • 창작: 텍스트 설명으로 이미지 생성 (DALL-E, Stable Diffusion)

비전-언어 모델의 발전사

2021: CLIP (OpenAI) - 대조 학습으로 이미지-텍스트 연결
2022: BLIP - 이미지 캡셔닝과 VQA 통합
2023: BLIP-2 - Q-Former로 효율적 멀티모달 학습
2023: LLaVA - 오픈소스 비전-언어 어시스턴트
2023: GPT-4V - 상업용 멀티모달 LLM
2023: Gemini - 구글의 멀티모달 파운데이션 모델
2024: Claude 3 Vision - Anthropic의 멀티모달 모델
2024: LLaVA-1.6, InternVL2, Qwen-VL2 - 오픈소스 개선
2025: 비디오 이해, 3D 이해로 확장

2. CLIP 심층 분석

CLIP의 핵심 아이디어

CLIP(Contrastive Language-Image Pre-training)은 2021년 OpenAI가 발표한 모델로, 4억 개의 이미지-텍스트 쌍으로 대조 학습(Contrastive Learning)을 수행하여 이미지와 텍스트를 동일한 임베딩 공간에 매핑합니다.

핵심 혁신: 별도의 레이블 없이 인터넷에서 수집한 이미지-캡션 쌍만으로 학습하여 강력한 제로샷 분류 능력을 획득했습니다.

CLIP 아키텍처

이미지 → [이미지 인코더 (ViT/ResNet)] → 이미지 임베딩 (512차원)
                                                    ↕ 유사도 측정
텍스트 → [텍스트 인코더 (Transformer)]→ 텍스트 임베딩 (512차원)

대조 학습 메커니즘:

배치 내 N개의 이미지-텍스트 쌍에서:

  • 올바른 쌍(diagonal)의 유사도는 최대화
  • 잘못된 쌍(off-diagonal)의 유사도는 최소화
import torch
import torch.nn.functional as F
from PIL import Image
import requests
from transformers import CLIPProcessor, CLIPModel

# CLIP 모델 로드
model = CLIPModel.from_pretrained("openai/clip-vit-large-patch14")
processor = CLIPProcessor.from_pretrained("openai/clip-vit-large-patch14")

def clip_zero_shot_classification(
    image: Image.Image,
    candidate_labels: list[str]
) -> dict[str, float]:
    """CLIP을 사용한 제로샷 이미지 분류."""

    # 텍스트 프롬프트 생성 (CLIP의 권장 형식)
    text_prompts = [f"a photo of a {label}" for label in candidate_labels]

    # 전처리
    inputs = processor(
        text=text_prompts,
        images=image,
        return_tensors="pt",
        padding=True
    )

    # 추론
    with torch.no_grad():
        outputs = model(**inputs)
        logits_per_image = outputs.logits_per_image
        probs = logits_per_image.softmax(dim=1)

    # 결과 반환
    return {
        label: prob.item()
        for label, prob in zip(candidate_labels, probs[0])
    }

# 사용 예시
image_url = "https://example.com/sample_image.jpg"
image = Image.open(requests.get(image_url, stream=True).raw)

labels = ["cat", "dog", "bird", "fish", "rabbit"]
results = clip_zero_shot_classification(image, labels)

# 확률 기준 정렬
sorted_results = sorted(results.items(), key=lambda x: x[1], reverse=True)
for label, prob in sorted_results:
    print(f"{label}: {prob:.4f} ({prob*100:.1f}%)")

이미지-텍스트 검색

import numpy as np
from typing import Union

class CLIPSearchEngine:
    """CLIP 기반 이미지-텍스트 검색 엔진."""

    def __init__(self, model_name: str = "openai/clip-vit-large-patch14"):
        self.model = CLIPModel.from_pretrained(model_name)
        self.processor = CLIPProcessor.from_pretrained(model_name)
        self.image_embeddings = []
        self.text_embeddings = []
        self.image_metadata = []
        self.text_metadata = []

    def encode_images(self, images: list[Image.Image]) -> torch.Tensor:
        """이미지 배치를 임베딩으로 변환."""
        inputs = self.processor(
            images=images,
            return_tensors="pt",
            padding=True
        )
        with torch.no_grad():
            image_features = self.model.get_image_features(**inputs)
            # L2 정규화
            image_features = F.normalize(image_features, p=2, dim=-1)
        return image_features

    def encode_texts(self, texts: list[str]) -> torch.Tensor:
        """텍스트 배치를 임베딩으로 변환."""
        inputs = self.processor(
            text=texts,
            return_tensors="pt",
            padding=True,
            truncation=True
        )
        with torch.no_grad():
            text_features = self.model.get_text_features(**inputs)
            text_features = F.normalize(text_features, p=2, dim=-1)
        return text_features

    def index_images(
        self,
        images: list[Image.Image],
        metadata: list[dict] = None
    ):
        """이미지를 인덱싱합니다."""
        embeddings = self.encode_images(images)
        self.image_embeddings.append(embeddings)
        if metadata:
            self.image_metadata.extend(metadata)

    def text_to_image_search(
        self,
        query: str,
        top_k: int = 5
    ) -> list[dict]:
        """텍스트 쿼리로 이미지를 검색합니다."""
        if not self.image_embeddings:
            return []

        # 모든 이미지 임베딩 결합
        all_embeddings = torch.cat(self.image_embeddings, dim=0)

        # 쿼리 인코딩
        query_embedding = self.encode_texts([query])

        # 코사인 유사도 계산 (이미 L2 정규화됨)
        similarities = (all_embeddings @ query_embedding.T).squeeze(-1)

        # Top-K 결과 선택
        top_indices = similarities.argsort(descending=True)[:top_k]

        results = []
        for idx in top_indices:
            idx = idx.item()
            result = {
                "index": idx,
                "similarity": similarities[idx].item()
            }
            if self.image_metadata:
                result.update(self.image_metadata[idx])
            results.append(result)

        return results

OpenCLIP (오픈소스 CLIP)

# OpenCLIP: 다양한 아키텍처와 학습 데이터 지원
# pip install open_clip_torch

import open_clip
import torch
from PIL import Image

# 사용 가능한 모델 목록 확인
available_models = open_clip.list_pretrained()
print("사용 가능한 모델:", available_models[:5])

# LAION-2B로 학습된 대형 모델 로드
model, preprocess_train, preprocess_val = open_clip.create_model_and_transforms(
    'ViT-H-14',
    pretrained='laion2b_s32b_b79k'
)
tokenizer = open_clip.get_tokenizer('ViT-H-14')

def compute_clip_similarity(
    image: Image.Image,
    texts: list[str],
    model=model,
    preprocess=preprocess_val,
    tokenizer=tokenizer
) -> list[float]:
    """이미지와 텍스트 목록 간의 CLIP 유사도를 계산합니다."""
    model.eval()

    # 이미지 전처리
    image_input = preprocess(image).unsqueeze(0)

    # 텍스트 토큰화
    text_input = tokenizer(texts)

    with torch.no_grad(), torch.cuda.amp.autocast():
        image_features = model.encode_image(image_input)
        text_features = model.encode_text(text_input)

        # 정규화
        image_features /= image_features.norm(dim=-1, keepdim=True)
        text_features /= text_features.norm(dim=-1, keepdim=True)

        # 유사도 계산
        similarity = (100.0 * image_features @ text_features.T).softmax(dim=-1)

    return similarity[0].tolist()

CLIP 파인튜닝

import torch
import torch.nn as nn
from torch.utils.data import DataLoader, Dataset
from transformers import CLIPModel, CLIPProcessor
import torch.optim as optim

class ImageTextDataset(Dataset):
    """이미지-텍스트 쌍 데이터셋."""

    def __init__(
        self,
        image_paths: list[str],
        texts: list[str],
        processor: CLIPProcessor
    ):
        self.image_paths = image_paths
        self.texts = texts
        self.processor = processor

    def __len__(self):
        return len(self.image_paths)

    def __getitem__(self, idx):
        image = Image.open(self.image_paths[idx]).convert("RGB")
        text = self.texts[idx]

        inputs = self.processor(
            images=image,
            text=text,
            return_tensors="pt",
            padding="max_length",
            max_length=77,
            truncation=True
        )

        return {
            "pixel_values": inputs["pixel_values"].squeeze(0),
            "input_ids": inputs["input_ids"].squeeze(0),
            "attention_mask": inputs["attention_mask"].squeeze(0)
        }

class CLIPFineTuner:
    """CLIP 모델 파인튜닝 클래스."""

    def __init__(self, model_name: str = "openai/clip-vit-base-patch32"):
        self.model = CLIPModel.from_pretrained(model_name)
        self.processor = CLIPProcessor.from_pretrained(model_name)
        self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
        self.model.to(self.device)

    def contrastive_loss(
        self,
        image_features: torch.Tensor,
        text_features: torch.Tensor,
        temperature: float = 0.07
    ) -> torch.Tensor:
        """대조 학습 손실 함수."""
        # 정규화
        image_features = F.normalize(image_features, dim=-1)
        text_features = F.normalize(text_features, dim=-1)

        # 유사도 행렬
        logits = torch.matmul(image_features, text_features.T) / temperature

        # 대각선이 정답 (i번째 이미지는 i번째 텍스트와 쌍)
        labels = torch.arange(len(logits)).to(self.device)

        # 양방향 크로스 엔트로피
        loss_i = F.cross_entropy(logits, labels)
        loss_t = F.cross_entropy(logits.T, labels)

        return (loss_i + loss_t) / 2

    def train(
        self,
        train_dataset: ImageTextDataset,
        num_epochs: int = 10,
        batch_size: int = 32,
        learning_rate: float = 1e-5
    ):
        """CLIP 파인튜닝 학습."""
        dataloader = DataLoader(
            train_dataset,
            batch_size=batch_size,
            shuffle=True,
            num_workers=4
        )

        optimizer = optim.AdamW(
            self.model.parameters(),
            lr=learning_rate,
            weight_decay=0.01
        )

        scheduler = optim.lr_scheduler.CosineAnnealingLR(
            optimizer,
            T_max=num_epochs
        )

        for epoch in range(num_epochs):
            total_loss = 0
            self.model.train()

            for batch in dataloader:
                pixel_values = batch["pixel_values"].to(self.device)
                input_ids = batch["input_ids"].to(self.device)
                attention_mask = batch["attention_mask"].to(self.device)

                # 순전파
                outputs = self.model(
                    pixel_values=pixel_values,
                    input_ids=input_ids,
                    attention_mask=attention_mask
                )

                image_features = outputs.image_embeds
                text_features = outputs.text_embeds

                # 손실 계산
                loss = self.contrastive_loss(image_features, text_features)

                # 역전파
                optimizer.zero_grad()
                loss.backward()
                optimizer.step()

                total_loss += loss.item()

            scheduler.step()
            avg_loss = total_loss / len(dataloader)
            print(f"Epoch {epoch+1}/{num_epochs}, Loss: {avg_loss:.4f}")

3. BLIP 계열

BLIP (Bootstrapping Language-Image Pre-training)

BLIP은 2022년 Salesforce Research에서 발표한 모델로, 이미지 캡셔닝, 이미지-텍스트 검색, 시각적 질의응답(VQA) 등 다양한 비전-언어 작업에서 뛰어난 성능을 보입니다.

핵심 혁신: Captioner와 Filter를 활용한 데이터 부트스트래핑으로 웹에서 수집한 노이즈 많은 이미지-텍스트 쌍을 정제합니다.

from transformers import BlipProcessor, BlipForConditionalGeneration
from transformers import BlipForQuestionAnswering
from PIL import Image
import torch

# BLIP 이미지 캡셔닝
class BLIPCaptioner:
    def __init__(self):
        self.processor = BlipProcessor.from_pretrained(
            "Salesforce/blip-image-captioning-large"
        )
        self.model = BlipForConditionalGeneration.from_pretrained(
            "Salesforce/blip-image-captioning-large",
            torch_dtype=torch.float16
        )
        self.device = "cuda" if torch.cuda.is_available() else "cpu"
        self.model.to(self.device)

    def caption(
        self,
        image: Image.Image,
        conditional_text: str = None,
        max_new_tokens: int = 50
    ) -> str:
        """이미지에 대한 캡션을 생성합니다."""
        if conditional_text:
            # 조건부 캡셔닝
            inputs = self.processor(
                image,
                conditional_text,
                return_tensors="pt"
            ).to(self.device, torch.float16)
        else:
            # 무조건부 캡셔닝
            inputs = self.processor(
                image,
                return_tensors="pt"
            ).to(self.device, torch.float16)

        with torch.no_grad():
            output = self.model.generate(
                **inputs,
                max_new_tokens=max_new_tokens,
                num_beams=4,
                early_stopping=True
            )

        return self.processor.decode(output[0], skip_special_tokens=True)

# BLIP VQA (시각적 질의응답)
class BLIPVisualQA:
    def __init__(self):
        self.processor = BlipProcessor.from_pretrained(
            "Salesforce/blip-vqa-base"
        )
        self.model = BlipForQuestionAnswering.from_pretrained(
            "Salesforce/blip-vqa-base"
        )

    def answer(self, image: Image.Image, question: str) -> str:
        """이미지에 대한 질문에 답변합니다."""
        inputs = self.processor(image, question, return_tensors="pt")

        with torch.no_grad():
            output = self.model.generate(**inputs, max_new_tokens=50)

        return self.processor.decode(output[0], skip_special_tokens=True)

# 사용 예시
captioner = BLIPCaptioner()
vqa = BLIPVisualQA()

image = Image.open("sample.jpg")

# 캡션 생성
caption = captioner.caption(image)
print(f"캡션: {caption}")

# 조건부 캡션
cond_caption = captioner.caption(image, "a photo of")
print(f"조건부 캡션: {cond_caption}")

# VQA
answer = vqa.answer(image, "What color is the sky?")
print(f"답변: {answer}")

BLIP-2: Querying Transformer

BLIP-2는 2023년 발표된 BLIP의 후속 모델로, Q-Former(Querying Transformer)를 도입하여 동결된(frozen) 이미지 인코더와 동결된 LLM을 효율적으로 연결합니다.

Q-Former의 역할:

  • 이미지 인코더의 출력에서 가장 중요한 시각적 특징을 추출
  • 학습 가능한 쿼리 토큰 32개가 이미지 특징과 교류하여 압축된 표현 생성
  • 이 압축된 표현이 LLM의 입력으로 전달
from transformers import Blip2Processor, Blip2ForConditionalGeneration
import torch

class BLIP2Assistant:
    """BLIP-2 기반 시각 질의응답 어시스턴트."""

    def __init__(
        self,
        model_name: str = "Salesforce/blip2-opt-2.7b"
    ):
        self.processor = Blip2Processor.from_pretrained(model_name)
        self.model = Blip2ForConditionalGeneration.from_pretrained(
            model_name,
            torch_dtype=torch.float16,
            device_map="auto"
        )

    def generate_response(
        self,
        image: Image.Image,
        prompt: str = None,
        max_new_tokens: int = 200,
        temperature: float = 1.0
    ) -> str:
        """이미지와 (선택적) 프롬프트에 대한 응답을 생성합니다."""

        if prompt:
            inputs = self.processor(
                images=image,
                text=prompt,
                return_tensors="pt"
            ).to("cuda", torch.float16)
        else:
            inputs = self.processor(
                images=image,
                return_tensors="pt"
            ).to("cuda", torch.float16)

        with torch.no_grad():
            generated_ids = self.model.generate(
                **inputs,
                max_new_tokens=max_new_tokens,
                temperature=temperature,
                do_sample=temperature > 0
            )

        generated_text = self.processor.batch_decode(
            generated_ids,
            skip_special_tokens=True
        )[0].strip()

        return generated_text

    def batch_caption(
        self,
        images: list[Image.Image],
        batch_size: int = 8
    ) -> list[str]:
        """이미지 배치에 대한 캡션을 일괄 생성합니다."""
        all_captions = []

        for i in range(0, len(images), batch_size):
            batch = images[i:i + batch_size]

            inputs = self.processor(
                images=batch,
                return_tensors="pt",
                padding=True
            ).to("cuda", torch.float16)

            with torch.no_grad():
                generated_ids = self.model.generate(
                    **inputs,
                    max_new_tokens=50
                )

            captions = self.processor.batch_decode(
                generated_ids,
                skip_special_tokens=True
            )

            all_captions.extend([c.strip() for c in captions])

        return all_captions

# 사용 예시
assistant = BLIP2Assistant("Salesforce/blip2-flan-t5-xxl")
image = Image.open("document.png")

# 자유 형식 질문
response = assistant.generate_response(
    image,
    "Question: What is the main topic of this document? Answer:"
)
print(response)

# 대화형 세션
conversation_history = []
questions = [
    "이미지에 무엇이 보이나요?",
    "색상은 어떻게 되나요?",
    "배경은 어떤가요?"
]

for q in questions:
    history_text = "\n".join(conversation_history)
    prompt = f"{history_text}\nQuestion: {q} Answer:"
    answer = assistant.generate_response(image, prompt)
    print(f"Q: {q}")
    print(f"A: {answer}")
    conversation_history.append(f"Q: {q} A: {answer}")

4. LLaVA: 대규모 언어-비전 어시스턴트

LLaVA 아키텍처

LLaVA(Large Language and Vision Assistant)는 2023년 발표된 오픈소스 시각-언어 모델로, 강력한 LLM (LLaMA, Vicuna)과 CLIP 비전 인코더를 연결하여 인스트럭션 팔로잉 능력을 갖춘 멀티모달 챗봇을 구현합니다.

아키텍처 구성:

이미지 → [CLIP ViT-L/14] → 이미지 특징 (1024차원)
                      [선형 프로젝션 레이어]
                      [비주얼 토큰들]
           [LLM (LLaMA/Vicuna)][텍스트 토큰들]
                          최종 응답

LLaVA-1.5 개선사항:

  • MLP 프로젝션 레이어 (선형 → 2-레이어 MLP)
  • 고해상도 이미지 지원
  • 더 많은 학습 데이터

LLaVA-1.6 (LLaVA-NeXT) 개선사항:

  • Dynamic High Resolution: 최대 672x672 → 4배 더 많은 시각 토큰
  • 개선된 추론 및 OCR 능력
  • 다양한 종횡비 지원

HuggingFace로 LLaVA 사용

from transformers import LlavaNextProcessor, LlavaNextForConditionalGeneration
import torch
from PIL import Image

class LLaVAAssistant:
    """LLaVA-1.6 기반 시각 어시스턴트."""

    def __init__(
        self,
        model_name: str = "llava-hf/llava-v1.6-mistral-7b-hf"
    ):
        self.processor = LlavaNextProcessor.from_pretrained(model_name)
        self.model = LlavaNextForConditionalGeneration.from_pretrained(
            model_name,
            torch_dtype=torch.float16,
            low_cpu_mem_usage=True,
            device_map="auto"
        )

    def chat(
        self,
        image: Image.Image,
        message: str,
        max_new_tokens: int = 500,
        temperature: float = 0.7
    ) -> str:
        """이미지와 함께 대화합니다."""

        # LLaVA-1.6의 대화 형식
        conversation = [
            {
                "role": "user",
                "content": [
                    {"type": "image"},
                    {"type": "text", "text": message}
                ]
            }
        ]

        prompt = self.processor.apply_chat_template(
            conversation,
            add_generation_prompt=True
        )

        inputs = self.processor(
            prompt,
            image,
            return_tensors="pt"
        ).to("cuda")

        with torch.no_grad():
            output = self.model.generate(
                **inputs,
                max_new_tokens=max_new_tokens,
                temperature=temperature,
                do_sample=temperature > 0,
                pad_token_id=self.processor.tokenizer.eos_token_id
            )

        # 입력 제외하고 생성된 텍스트만 추출
        generated = output[0][inputs["input_ids"].shape[1]:]
        return self.processor.decode(generated, skip_special_tokens=True)

    def analyze_chart(self, chart_image: Image.Image) -> dict:
        """차트 이미지를 분석합니다."""
        analysis_prompts = [
            "이 차트의 제목은 무엇인가요?",
            "x축과 y축은 무엇을 나타내나요?",
            "가장 높은 값과 낮은 값은 무엇인가요?",
            "전체적인 트렌드를 설명해주세요.",
            "이 데이터에서 가장 중요한 인사이트는 무엇인가요?"
        ]

        results = {}
        for prompt in analysis_prompts:
            response = self.chat(chart_image, prompt)
            results[prompt] = response

        return results

    def extract_text_from_image(self, image: Image.Image) -> str:
        """이미지에서 텍스트를 추출합니다 (OCR)."""
        return self.chat(
            image,
            "이 이미지에 있는 모든 텍스트를 정확히 추출해주세요. "
            "텍스트만 반환하고 다른 설명은 추가하지 마세요."
        )


# 실전 활용: 문서 분석 파이프라인
class DocumentAnalysisPipeline:
    """LLaVA를 사용한 문서 분석 파이프라인."""

    def __init__(self):
        self.llava = LLaVAAssistant()

    def analyze_document(self, document_image: Image.Image) -> dict:
        """문서 이미지를 종합 분석합니다."""

        # 1. 문서 타입 식별
        doc_type = self.llava.chat(
            document_image,
            "이 문서의 유형은 무엇인가요? (청구서, 계약서, 보고서, 양식 등)"
        )

        # 2. 텍스트 추출
        extracted_text = self.llava.extract_text_from_image(document_image)

        # 3. 핵심 정보 추출
        key_info = self.llava.chat(
            document_image,
            f"이 {doc_type}에서 다음 정보를 JSON 형식으로 추출해주세요: "
            "날짜, 발신인, 수신인, 금액(있는 경우), 주요 내용 요약"
        )

        # 4. 액션 아이템 식별
        action_items = self.llava.chat(
            document_image,
            "이 문서에서 필요한 조치사항이 있다면 목록으로 나열해주세요."
        )

        return {
            "document_type": doc_type,
            "extracted_text": extracted_text,
            "key_information": key_info,
            "action_items": action_items
        }

5. InstructBLIP

InstructBLIP의 핵심

InstructBLIP은 BLIP-2를 기반으로 하되, 다양한 인스트럭션을 따를 수 있도록 인스트럭션 튜닝을 적용한 모델입니다. Q-Former가 인스트럭션을 인식하여 관련 시각 특징을 추출하는 것이 핵심입니다.

from transformers import InstructBlipProcessor, InstructBlipForConditionalGeneration
import torch
from PIL import Image

class InstructBLIPAssistant:
    """InstructBLIP 기반 지시 따르기 어시스턴트."""

    def __init__(self, model_name: str = "Salesforce/instructblip-vicuna-7b"):
        self.processor = InstructBlipProcessor.from_pretrained(model_name)
        self.model = InstructBlipForConditionalGeneration.from_pretrained(
            model_name,
            torch_dtype=torch.float16,
            device_map="auto"
        )

    def instruct(
        self,
        image: Image.Image,
        instruction: str,
        max_new_tokens: int = 300
    ) -> str:
        """이미지에 대한 구체적인 지시를 수행합니다."""
        inputs = self.processor(
            images=image,
            text=instruction,
            return_tensors="pt"
        ).to("cuda", torch.float16)

        with torch.no_grad():
            outputs = self.model.generate(
                **inputs,
                do_sample=False,
                num_beams=5,
                max_new_tokens=max_new_tokens,
                min_length=1,
                top_p=0.9,
                repetition_penalty=1.5,
                length_penalty=1.0,
                temperature=1.0
            )

        generated_text = self.processor.batch_decode(
            outputs,
            skip_special_tokens=True
        )[0].strip()

        return generated_text

# 다양한 활용 예시
assistant = InstructBLIPAssistant()
image = Image.open("complex_diagram.png")

# 복잡한 다이어그램 설명
description = assistant.instruct(
    image,
    "이 다이어그램을 상세히 설명해주세요. 각 컴포넌트의 역할과 연결 관계를 포함하세요."
)

# 특정 객체 감지
objects = assistant.instruct(
    image,
    "이미지에서 발견되는 모든 객체를 목록으로 나열하고, 각 객체의 위치를 설명해주세요."
)

# 감정 분석
emotion = assistant.instruct(
    image,
    "이미지에 있는 사람들의 감정 상태를 분석하고, 그 근거를 설명해주세요."
)

# 비교 분석
if len([image]) > 1:  # 여러 이미지의 경우
    comparison = assistant.instruct(
        image,
        "이미지의 특징을 자세히 설명하고, 비슷한 이미지와 비교했을 때의 차이점을 설명해주세요."
    )

6. GPT-4 Vision

GPT-4V API 사용법

GPT-4 Vision은 OpenAI의 GPT-4 모델에 시각 능력을 추가한 것으로, 현재 가장 강력한 상업용 멀티모달 LLM 중 하나입니다.

import openai
import base64
from pathlib import Path
import httpx

client = openai.OpenAI()

def encode_image_to_base64(image_path: str) -> str:
    """이미지 파일을 Base64로 인코딩합니다."""
    with open(image_path, "rb") as image_file:
        return base64.b64encode(image_file.read()).decode('utf-8')

def image_url_to_base64(url: str) -> str:
    """URL에서 이미지를 다운로드하여 Base64로 인코딩합니다."""
    response = httpx.get(url)
    return base64.b64encode(response.content).decode('utf-8')

class GPT4VisionAnalyzer:
    """GPT-4 Vision 기반 이미지 분석기."""

    def __init__(self, model: str = "gpt-4o"):
        self.client = openai.OpenAI()
        self.model = model

    def analyze_image(
        self,
        image_source: str,  # 파일 경로 또는 URL
        prompt: str,
        is_url: bool = True,
        detail: str = "high",  # "low", "high", "auto"
        max_tokens: int = 1000
    ) -> str:
        """단일 이미지를 분석합니다."""

        if is_url:
            image_content = {
                "type": "image_url",
                "image_url": {
                    "url": image_source,
                    "detail": detail
                }
            }
        else:
            # 로컬 파일
            base64_image = encode_image_to_base64(image_source)
            ext = Path(image_source).suffix.lower()
            media_type_map = {
                ".jpg": "image/jpeg",
                ".jpeg": "image/jpeg",
                ".png": "image/png",
                ".gif": "image/gif",
                ".webp": "image/webp"
            }
            media_type = media_type_map.get(ext, "image/jpeg")

            image_content = {
                "type": "image_url",
                "image_url": {
                    "url": f"data:{media_type};base64,{base64_image}",
                    "detail": detail
                }
            }

        response = self.client.chat.completions.create(
            model=self.model,
            messages=[
                {
                    "role": "user",
                    "content": [
                        image_content,
                        {"type": "text", "text": prompt}
                    ]
                }
            ],
            max_tokens=max_tokens
        )

        return response.choices[0].message.content

    def analyze_multiple_images(
        self,
        image_sources: list[dict],  # [{"source": "...", "is_url": True}]
        prompt: str,
        max_tokens: int = 2000
    ) -> str:
        """여러 이미지를 동시에 분석합니다."""
        content = []

        for img_info in image_sources:
            source = img_info["source"]
            is_url = img_info.get("is_url", True)

            if is_url:
                content.append({
                    "type": "image_url",
                    "image_url": {"url": source, "detail": "high"}
                })
            else:
                base64_image = encode_image_to_base64(source)
                content.append({
                    "type": "image_url",
                    "image_url": {
                        "url": f"data:image/jpeg;base64,{base64_image}"
                    }
                })

        content.append({"type": "text", "text": prompt})

        response = self.client.chat.completions.create(
            model=self.model,
            messages=[{"role": "user", "content": content}],
            max_tokens=max_tokens
        )

        return response.choices[0].message.content

    def analyze_chart_or_graph(self, image_source: str) -> dict:
        """차트나 그래프를 구조화된 형식으로 분석합니다."""
        prompt = """이 차트/그래프를 분석하여 다음 JSON 형식으로 반환해주세요:
{
  "chart_type": "막대/선/원/산점도 등",
  "title": "차트 제목",
  "x_axis": {"label": "X축 레이블", "unit": "단위"},
  "y_axis": {"label": "Y축 레이블", "unit": "단위"},
  "data_series": [{"name": "시리즈명", "trend": "상승/하락/유지"}],
  "key_findings": ["발견사항1", "발견사항2"],
  "data_range": {"min": 0, "max": 0},
  "anomalies": ["이상치 설명"]
}"""

        response = self.analyze_image(
            image_source,
            prompt,
            detail="high",
            max_tokens=1500
        )

        import json
        try:
            # JSON 파싱 시도
            start = response.find('{')
            end = response.rfind('}') + 1
            if start >= 0 and end > start:
                return json.loads(response[start:end])
        except json.JSONDecodeError:
            pass

        return {"raw_response": response}

    def extract_structured_data_from_document(
        self,
        document_image_path: str
    ) -> dict:
        """문서 이미지에서 구조화된 데이터를 추출합니다."""
        prompt = """이 문서에서 다음 정보를 JSON 형식으로 추출해주세요:
1. 문서 유형
2. 날짜 (있는 경우)
3. 발신인/작성자
4. 수신인 (있는 경우)
5. 주요 내용 요약 (3-5문장)
6. 주요 수치 데이터 (표, 금액 등)
7. 서명/승인 여부

JSON 형식:
{
  "document_type": "",
  "date": "",
  "author": "",
  "recipient": "",
  "summary": "",
  "numerical_data": [],
  "signature_present": false
}"""

        return self.analyze_chart_or_graph.__func__(self, document_image_path)


# 실전 활용: 이커머스 제품 분석
def analyze_product_images(image_urls: list[str]) -> dict:
    """여러 제품 이미지를 분석합니다."""
    analyzer = GPT4VisionAnalyzer()

    image_sources = [{"source": url, "is_url": True} for url in image_urls]

    result = analyzer.analyze_multiple_images(
        image_sources,
        prompt="""이 제품 이미지들을 분석하여 다음을 JSON으로 반환해주세요:
{
  "product_name": "추정 제품명",
  "category": "제품 카테고리",
  "color_options": ["색상 목록"],
  "key_features": ["주요 특징"],
  "condition": "새 제품/중고 등",
  "quality_score": 0-10,
  "marketing_description": "마케팅용 설명 (100자)",
  "seo_keywords": ["SEO 키워드"]
}"""
    )

    return result

7. Gemini Vision

Gemini의 멀티모달 능력

Google의 Gemini는 처음부터 멀티모달을 고려하여 설계된 파운데이션 모델입니다. 특히 Gemini 1.5 Pro는 100만 토큰 컨텍스트 윈도우로 장시간 비디오, 긴 문서, 다수의 이미지를 처리할 수 있습니다.

import google.generativeai as genai
import PIL.Image
from pathlib import Path
import base64

# API 키 설정
genai.configure(api_key="YOUR_GEMINI_API_KEY")

class GeminiVisionAnalyzer:
    """Gemini Vision 기반 분석기."""

    def __init__(self, model_name: str = "gemini-1.5-pro"):
        self.model = genai.GenerativeModel(model_name)
        self.vision_model = genai.GenerativeModel("gemini-1.5-flash")

    def analyze_image(
        self,
        image_path: str,
        prompt: str
    ) -> str:
        """이미지를 분석합니다."""
        image = PIL.Image.open(image_path)
        response = self.model.generate_content([prompt, image])
        return response.text

    def analyze_with_url(self, image_url: str, prompt: str) -> str:
        """URL의 이미지를 분석합니다."""
        import httpx
        image_data = httpx.get(image_url).content

        image_part = {
            "mime_type": "image/jpeg",
            "data": base64.b64encode(image_data).decode('utf-8')
        }

        response = self.model.generate_content([
            {"text": prompt},
            image_part
        ])
        return response.text

    def analyze_video(
        self,
        video_path: str,
        questions: list[str]
    ) -> dict:
        """비디오를 분석합니다. (Gemini 1.5 Pro의 강점)"""

        # 비디오 파일 업로드
        print(f"비디오 업로드 중: {video_path}")
        video_file = genai.upload_file(
            path=video_path,
            display_name="analysis_video"
        )

        # 업로드 완료 대기
        import time
        while video_file.state.name == "PROCESSING":
            print("처리 중...")
            time.sleep(10)
            video_file = genai.get_file(video_file.name)

        if video_file.state.name == "FAILED":
            raise ValueError("비디오 처리 실패")

        print(f"비디오 업로드 완료: {video_file.uri}")

        # 질문별 분석
        results = {}
        for question in questions:
            response = self.model.generate_content(
                [video_file, question],
                request_options={"timeout": 600}
            )
            results[question] = response.text

        # 파일 삭제 (선택적)
        genai.delete_file(video_file.name)

        return results

    def analyze_multiple_images_interleaved(
        self,
        image_text_pairs: list[dict]  # [{"image": PIL.Image, "text": str}]
    ) -> str:
        """이미지와 텍스트가 교차 배치된 복합 쿼리를 처리합니다."""
        content = []

        for pair in image_text_pairs:
            if "text" in pair:
                content.append(pair["text"])
            if "image" in pair:
                content.append(pair["image"])

        response = self.model.generate_content(content)
        return response.text

    def process_document_batch(
        self,
        document_images: list[PIL.Image.Image],
        extraction_schema: str
    ) -> list[dict]:
        """여러 문서를 일괄 처리합니다 (Gemini의 긴 컨텍스트 활용)."""
        import json

        # 모든 이미지를 하나의 요청으로 처리
        content = [f"다음 {len(document_images)}개의 문서를 분석해주세요:\n"]

        for i, img in enumerate(document_images, 1):
            content.append(f"\n--- 문서 {i} ---")
            content.append(img)

        content.append(f"\n각 문서에 대해 다음 JSON 스키마로 데이터를 추출하세요:\n{extraction_schema}")

        response = self.model.generate_content(content)

        # JSON 파싱
        try:
            text = response.text
            # JSON 배열 추출
            start = text.find('[')
            end = text.rfind(']') + 1
            if start >= 0 and end > start:
                return json.loads(text[start:end])
        except json.JSONDecodeError:
            return [{"raw_response": response.text}]


# 활용 예시: 비디오 분석
analyzer = GeminiVisionAnalyzer()

video_questions = [
    "비디오의 전체 내용을 요약해주세요.",
    "주요 장면들을 타임스탬프와 함께 나열해주세요.",
    "비디오에서 언급된 주요 키워드나 개념은 무엇인가요?",
    "비디오의 주제와 목적은 무엇인가요?"
]

results = analyzer.analyze_video("lecture_video.mp4", video_questions)
for question, answer in results.items():
    print(f"\n질문: {question}")
    print(f"답변: {answer}")

8. Claude Vision

Claude Vision API

Anthropic의 Claude 3.5 Sonnet은 강력한 시각 능력을 제공하며, 특히 문서 이해, 코드 스크린샷 분석, 세밀한 이미지 해석에서 뛰어난 성능을 보입니다.

import anthropic
import base64
import httpx
from pathlib import Path

client = anthropic.Anthropic()

class ClaudeVisionAnalyzer:
    """Claude Vision 기반 이미지 분석기."""

    def __init__(self, model: str = "claude-3-5-sonnet-20241022"):
        self.client = anthropic.Anthropic()
        self.model = model

    def _prepare_image_content(
        self,
        image_source: str,
        is_url: bool = True
    ) -> dict:
        """이미지 콘텐츠를 Claude API 형식으로 준비합니다."""
        if is_url:
            return {
                "type": "image",
                "source": {
                    "type": "url",
                    "url": image_source
                }
            }
        else:
            # 로컬 파일을 Base64로 인코딩
            with open(image_source, "rb") as f:
                image_data = base64.standard_b64encode(f.read()).decode("utf-8")

            ext = Path(image_source).suffix.lower()
            media_type_map = {
                ".jpg": "image/jpeg",
                ".jpeg": "image/jpeg",
                ".png": "image/png",
                ".gif": "image/gif",
                ".webp": "image/webp"
            }
            media_type = media_type_map.get(ext, "image/jpeg")

            return {
                "type": "image",
                "source": {
                    "type": "base64",
                    "media_type": media_type,
                    "data": image_data
                }
            }

    def analyze(
        self,
        image_source: str,
        prompt: str,
        is_url: bool = True,
        system_prompt: str = None,
        max_tokens: int = 1000
    ) -> str:
        """이미지를 분석합니다."""
        image_content = self._prepare_image_content(image_source, is_url)

        messages = [
            {
                "role": "user",
                "content": [
                    image_content,
                    {"type": "text", "text": prompt}
                ]
            }
        ]

        kwargs = {
            "model": self.model,
            "max_tokens": max_tokens,
            "messages": messages
        }

        if system_prompt:
            kwargs["system"] = system_prompt

        response = self.client.messages.create(**kwargs)
        return response.content[0].text

    def analyze_code_screenshot(
        self,
        screenshot_path: str
    ) -> dict:
        """코드 스크린샷을 분석하고 코드를 추출합니다."""
        system_prompt = """당신은 코드 분석 전문가입니다.
스크린샷에서 코드를 정확히 추출하고 분석하세요."""

        extraction_prompt = """이 코드 스크린샷에서:
1. 코드를 정확히 추출하세요 (들여쓰기 포함)
2. 프로그래밍 언어를 식별하세요
3. 코드의 주요 기능을 설명하세요
4. 잠재적인 버그나 개선사항을 제안하세요

다음 JSON 형식으로 응답하세요:
{
  "language": "프로그래밍 언어",
  "code": "추출된 코드",
  "description": "코드 설명",
  "potential_issues": ["이슈1", "이슈2"],
  "improvements": ["개선사항1", "개선사항2"]
}"""

        response = self.analyze(
            screenshot_path,
            extraction_prompt,
            is_url=False,
            system_prompt=system_prompt,
            max_tokens=2000
        )

        import json
        try:
            start = response.find('{')
            end = response.rfind('}') + 1
            return json.loads(response[start:end])
        except json.JSONDecodeError:
            return {"raw_response": response}

    def compare_images(
        self,
        image_sources: list[tuple[str, bool]],  # (source, is_url) 쌍
        comparison_prompt: str
    ) -> str:
        """여러 이미지를 비교 분석합니다."""
        content = []

        for source, is_url in image_sources:
            content.append(self._prepare_image_content(source, is_url))

        content.append({"type": "text", "text": comparison_prompt})

        response = self.client.messages.create(
            model=self.model,
            max_tokens=2000,
            messages=[{"role": "user", "content": content}]
        )

        return response.content[0].text

    def analyze_ui_design(self, ui_screenshot_path: str) -> dict:
        """UI 디자인 스크린샷을 분석합니다."""
        prompt = """이 UI 스크린샷을 UX/UI 전문가 관점에서 분석해주세요:

분석 항목:
1. 레이아웃 구조
2. 색상 팔레트
3. 타이포그래피
4. 사용성 (Usability) 평가
5. 접근성 (Accessibility) 이슈
6. 개선 제안사항

JSON 형식으로 반환해주세요:
{
  "layout": "레이아웃 설명",
  "color_palette": ["주요 색상들"],
  "typography": "타이포그래피 평가",
  "usability_score": 0-10,
  "usability_issues": ["이슈들"],
  "accessibility_issues": ["접근성 문제"],
  "improvements": ["개선 제안"]
}"""

        return self.analyze(
            ui_screenshot_path,
            prompt,
            is_url=False,
            max_tokens=1500
        )

# 사용 예시
analyzer = ClaudeVisionAnalyzer()

# 이미지 분석
result = analyzer.analyze(
    "https://example.com/product.jpg",
    "이 제품의 특징을 자세히 설명하고, 잠재적 고객층을 추천해주세요.",
    is_url=True
)
print(result)

9. 멀티모달 RAG

멀티모달 RAG 개요

멀티모달 RAG는 텍스트뿐만 아니라 이미지, 표, 차트 등 다양한 형태의 콘텐츠를 인덱싱하고 검색하는 시스템입니다.

이미지 인덱싱 전략

import torch
import numpy as np
from PIL import Image
from transformers import CLIPModel, CLIPProcessor
import chromadb
from chromadb.utils.embedding_functions import OpenCLIPEmbeddingFunction
import base64
import io

class MultimodalRAGSystem:
    """멀티모달 RAG 시스템."""

    def __init__(self):
        # CLIP 모델 초기화
        self.clip_model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")
        self.clip_processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")

        # ChromaDB 초기화
        self.chroma_client = chromadb.Client()
        self.image_collection = self.chroma_client.get_or_create_collection(
            name="images",
            metadata={"hnsw:space": "cosine"}
        )
        self.text_collection = self.chroma_client.get_or_create_collection(
            name="texts"
        )

    def get_image_embedding(self, image: Image.Image) -> np.ndarray:
        """이미지를 CLIP 임베딩으로 변환합니다."""
        inputs = self.clip_processor(
            images=image,
            return_tensors="pt"
        )
        with torch.no_grad():
            features = self.clip_model.get_image_features(**inputs)
            features = torch.nn.functional.normalize(features, p=2, dim=-1)
        return features.numpy()[0]

    def get_text_embedding(self, text: str) -> np.ndarray:
        """텍스트를 CLIP 임베딩으로 변환합니다."""
        inputs = self.clip_processor(
            text=[text],
            return_tensors="pt",
            padding=True,
            truncation=True
        )
        with torch.no_grad():
            features = self.clip_model.get_text_features(**inputs)
            features = torch.nn.functional.normalize(features, p=2, dim=-1)
        return features.numpy()[0]

    def index_image(
        self,
        image: Image.Image,
        image_id: str,
        metadata: dict = None
    ):
        """이미지를 인덱싱합니다."""
        embedding = self.get_image_embedding(image)

        # 이미지를 Base64로 저장
        buffer = io.BytesIO()
        image.save(buffer, format="PNG")
        image_b64 = base64.b64encode(buffer.getvalue()).decode('utf-8')

        doc_metadata = {"image_b64": image_b64}
        if metadata:
            doc_metadata.update(metadata)

        self.image_collection.add(
            embeddings=[embedding.tolist()],
            ids=[image_id],
            metadatas=[doc_metadata]
        )

    def search_images_by_text(
        self,
        query: str,
        n_results: int = 5
    ) -> list[dict]:
        """텍스트로 이미지를 검색합니다."""
        query_embedding = self.get_text_embedding(query)

        results = self.image_collection.query(
            query_embeddings=[query_embedding.tolist()],
            n_results=n_results,
            include=["metadatas", "distances", "ids"]
        )

        retrieved = []
        for i in range(len(results['ids'][0])):
            metadata = results['metadatas'][0][i]
            image_b64 = metadata.pop('image_b64', None)

            image = None
            if image_b64:
                image_bytes = base64.b64decode(image_b64)
                image = Image.open(io.BytesIO(image_bytes))

            retrieved.append({
                "id": results['ids'][0][i],
                "distance": results['distances'][0][i],
                "metadata": metadata,
                "image": image
            })

        return retrieved

    def multimodal_rag_query(
        self,
        question: str,
        vision_model_fn,  # GPT-4V, Claude Vision 등
        n_image_results: int = 3
    ) -> str:
        """멀티모달 RAG 쿼리를 수행합니다."""

        # 관련 이미지 검색
        relevant_images = self.search_images_by_text(question, n_image_results)

        if not relevant_images:
            return vision_model_fn(question=question, images=[])

        # 검색된 이미지로 응답 생성
        retrieved_images = [r["image"] for r in relevant_images if r["image"]]
        metadata_info = [
            f"이미지 {i+1}: {r['metadata']}"
            for i, r in enumerate(relevant_images)
        ]

        enhanced_prompt = f"""
질문: {question}

관련 이미지 정보:
{chr(10).join(metadata_info)}

위의 이미지들을 참고하여 질문에 답변해주세요.
각 이미지의 관련 내용을 구체적으로 인용하세요.
"""

        return vision_model_fn(question=enhanced_prompt, images=retrieved_images)

ColPali: PDF 페이지 검색

# ColPali: 비전 언어 모델로 PDF 페이지 직접 검색
# pip install colpali-engine

from colpali_engine.models import ColPali, ColPaliProcessor
import torch

class ColPaliPDFSearch:
    """ColPali를 사용한 PDF 페이지 검색."""

    def __init__(self, model_name: str = "vidore/colpali-v1.2"):
        self.model = ColPali.from_pretrained(
            model_name,
            torch_dtype=torch.float16,
            device_map="cuda"
        )
        self.processor = ColPaliProcessor.from_pretrained(model_name)

    def index_pdf_pages(
        self,
        page_images: list[Image.Image]
    ) -> torch.Tensor:
        """PDF 페이지 이미지들을 인덱싱합니다."""
        all_embeddings = []

        batch_size = 4
        for i in range(0, len(page_images), batch_size):
            batch = page_images[i:i + batch_size]
            inputs = self.processor.process_images(batch)
            inputs = {k: v.to("cuda") for k, v in inputs.items()}

            with torch.no_grad():
                embeddings = self.model(**inputs)

            all_embeddings.append(embeddings)

        return torch.cat(all_embeddings, dim=0)

    def search(
        self,
        query: str,
        page_embeddings: torch.Tensor,
        top_k: int = 3
    ) -> list[int]:
        """쿼리로 관련 PDF 페이지를 검색합니다."""
        # 쿼리 임베딩
        query_inputs = self.processor.process_queries([query])
        query_inputs = {k: v.to("cuda") for k, v in query_inputs.items()}

        with torch.no_grad():
            query_embedding = self.model(**query_inputs)

        # MaxSim 스코어 계산 (ColPali의 핵심)
        scores = self.processor.score_multi_vector(
            query_embedding,
            page_embeddings
        )

        # Top-K 페이지 인덱스 반환
        top_indices = scores[0].argsort(descending=True)[:top_k]
        return top_indices.tolist()

10. 오픈소스 멀티모달 모델

Phi-3 Vision (Microsoft)

from transformers import AutoModelForCausalLM, AutoProcessor
import torch
from PIL import Image

class Phi3VisionModel:
    """Microsoft Phi-3 Vision 모델."""

    def __init__(self):
        model_id = "microsoft/Phi-3-vision-128k-instruct"

        self.model = AutoModelForCausalLM.from_pretrained(
            model_id,
            device_map="cuda",
            trust_remote_code=True,
            torch_dtype=torch.bfloat16,
            _attn_implementation='flash_attention_2'  # CUDA 필요
        )

        self.processor = AutoProcessor.from_pretrained(
            model_id,
            trust_remote_code=True
        )

    def analyze(self, image: Image.Image, prompt: str) -> str:
        """이미지를 분석합니다."""
        messages = [
            {"role": "user", "content": f"<|image_1|>\n{prompt}"}
        ]

        prompt_text = self.processor.tokenizer.apply_chat_template(
            messages,
            tokenize=False,
            add_generation_prompt=True
        )

        inputs = self.processor(
            prompt_text,
            [image],
            return_tensors="pt"
        ).to("cuda")

        with torch.no_grad():
            output = self.model.generate(
                **inputs,
                max_new_tokens=500,
                eos_token_id=self.processor.tokenizer.eos_token_id
            )

        generated = output[0][inputs['input_ids'].shape[1]:]
        return self.processor.decode(generated, skip_special_tokens=True)

Qwen-VL (Alibaba)

from transformers import Qwen2VLForConditionalGeneration, AutoProcessor
from qwen_vl_utils import process_vision_info
import torch

class QwenVLModel:
    """Qwen2-VL 멀티모달 모델."""

    def __init__(self, model_name: str = "Qwen/Qwen2-VL-7B-Instruct"):
        self.model = Qwen2VLForConditionalGeneration.from_pretrained(
            model_name,
            torch_dtype=torch.bfloat16,
            attn_implementation="flash_attention_2",
            device_map="auto"
        )
        self.processor = AutoProcessor.from_pretrained(
            model_name,
            min_pixels=256*28*28,
            max_pixels=1280*28*28
        )

    def analyze_image(
        self,
        image_path: str,
        question: str
    ) -> str:
        """이미지를 분석합니다."""
        messages = [
            {
                "role": "user",
                "content": [
                    {
                        "type": "image",
                        "image": image_path
                    },
                    {
                        "type": "text",
                        "text": question
                    }
                ]
            }
        ]

        text = self.processor.apply_chat_template(
            messages,
            tokenize=False,
            add_generation_prompt=True
        )

        image_inputs, video_inputs = process_vision_info(messages)

        inputs = self.processor(
            text=[text],
            images=image_inputs,
            videos=video_inputs,
            padding=True,
            return_tensors="pt"
        ).to("cuda")

        with torch.no_grad():
            output_ids = self.model.generate(**inputs, max_new_tokens=512)

        generated_ids = [
            output_ids[len(input_ids):]
            for input_ids, output_ids in zip(inputs.input_ids, output_ids)
        ]

        return self.processor.batch_decode(
            generated_ids,
            skip_special_tokens=True,
            clean_up_tokenization_spaces=False
        )[0]

로컬 실행 가이드 (Ollama)

# Ollama로 멀티모달 모델 로컬 실행
# ollama.ai에서 Ollama 설치

# LLaVA 모델 다운로드 및 실행
ollama pull llava:13b

# 이미지와 함께 모델 실행
ollama run llava:13b
import ollama
from pathlib import Path

class OllamaVisionModel:
    """Ollama를 사용한 로컬 비전 모델."""

    def __init__(self, model: str = "llava:13b"):
        self.model = model

    def analyze(
        self,
        image_path: str,
        prompt: str
    ) -> str:
        """로컬 모델로 이미지를 분석합니다."""
        response = ollama.chat(
            model=self.model,
            messages=[
                {
                    "role": "user",
                    "content": prompt,
                    "images": [image_path]
                }
            ]
        )
        return response["message"]["content"]

    def batch_analyze(
        self,
        image_paths: list[str],
        prompt: str
    ) -> list[str]:
        """여러 이미지를 순차 분석합니다."""
        results = []
        for path in image_paths:
            result = self.analyze(path, prompt)
            results.append(result)
        return results

# 사용 예시
model = OllamaVisionModel("llava:13b")
result = model.analyze(
    "/path/to/image.jpg",
    "이 이미지에 무엇이 있나요? 자세히 설명해주세요."
)
print(result)

11. 비디오 이해 AI

비디오 이해의 과제

비디오 이해는 시간적 정보를 포함하는 멀티모달 태스크로, 정적 이미지 이해보다 훨씬 복잡합니다.

주요 과제:

  • 시간적 의존성: 프레임 간의 시간적 관계 이해
  • 대용량 데이터: 1분 비디오 = 약 1800 프레임 (30fps)
  • 동작 인식: 움직임 패턴 파악
  • 다중 스케일: 짧은 동작과 긴 이벤트 동시 이해

VideoMAE를 활용한 비디오 특징 추출

from transformers import VideoMAEImageProcessor, VideoMAEModel
import torch
import numpy as np

class VideoFeatureExtractor:
    """VideoMAE를 사용한 비디오 특징 추출기."""

    def __init__(self, model_name: str = "MCG-NJU/videomae-base"):
        self.processor = VideoMAEImageProcessor.from_pretrained(model_name)
        self.model = VideoMAEModel.from_pretrained(model_name)

    def extract_video_features(
        self,
        video_frames: list,  # PIL 이미지 또는 numpy 배열 목록
        num_frames: int = 16  # VideoMAE는 보통 16프레임 사용
    ) -> torch.Tensor:
        """비디오 프레임에서 특징을 추출합니다."""

        # 균일하게 프레임 샘플링
        total_frames = len(video_frames)
        indices = np.linspace(0, total_frames - 1, num_frames, dtype=int)
        sampled_frames = [video_frames[i] for i in indices]

        # 전처리
        inputs = self.processor(sampled_frames, return_tensors="pt")

        with torch.no_grad():
            outputs = self.model(**inputs)

        # [batch, num_patches, hidden_size] 형태
        return outputs.last_hidden_state

# OpenCV로 비디오 프레임 추출
import cv2

def extract_frames_from_video(
    video_path: str,
    target_fps: int = 1
) -> list:
    """비디오에서 프레임을 추출합니다."""
    cap = cv2.VideoCapture(video_path)
    fps = cap.get(cv2.CAP_PROP_FPS)
    frame_interval = int(fps / target_fps)

    frames = []
    frame_count = 0

    while cap.isOpened():
        ret, frame = cap.read()
        if not ret:
            break

        if frame_count % frame_interval == 0:
            # BGR을 RGB로 변환
            frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
            from PIL import Image
            pil_frame = Image.fromarray(frame_rgb)
            frames.append(pil_frame)

        frame_count += 1

    cap.release()
    return frames

Gemini로 긴 비디오 이해

import google.generativeai as genai
import time

class LongVideoUnderstanding:
    """Gemini 1.5 Pro를 사용한 긴 비디오 이해 시스템."""

    def __init__(self):
        self.model = genai.GenerativeModel("gemini-1.5-pro")

    def analyze_long_video(
        self,
        video_path: str,
        analysis_tasks: list[str]
    ) -> dict:
        """최대 1시간 길이의 비디오를 분석합니다."""

        print("비디오 업로드 중...")
        video_file = genai.upload_file(
            path=video_path,
            display_name="long_video_analysis"
        )

        # 처리 완료 대기
        while video_file.state.name == "PROCESSING":
            print(f"처리 중... (상태: {video_file.state.name})")
            time.sleep(15)
            video_file = genai.get_file(video_file.name)

        if video_file.state.name != "ACTIVE":
            raise RuntimeError(f"비디오 처리 실패: {video_file.state.name}")

        print(f"업로드 완료 (URI: {video_file.uri})")

        results = {}

        for task in analysis_tasks:
            print(f"분석 중: {task}")
            response = self.model.generate_content(
                [video_file, task],
                request_options={"timeout": 900}
            )
            results[task] = response.text

        # 업로드된 파일 정리
        genai.delete_file(video_file.name)
        print("파일 정리 완료")

        return results

    def create_video_summary(self, video_path: str) -> dict:
        """비디오의 종합 요약을 생성합니다."""
        tasks = [
            "비디오의 전체 내용을 3-5문장으로 요약해주세요.",
            "주요 장면을 타임스탬프와 함께 나열해주세요. 형식: MM:SS - 설명",
            "비디오에 등장하는 주요 인물, 사물, 장소를 목록으로 나열해주세요.",
            "비디오에서 강조된 핵심 메시지나 결론은 무엇인가요?",
            "이 비디오의 대상 시청자와 목적은 무엇인가요?"
        ]

        return self.analyze_long_video(video_path, tasks)

# 비디오 이해 시스템 활용
video_analyzer = LongVideoUnderstanding()
summary = video_analyzer.create_video_summary("lecture.mp4")

for task, result in summary.items():
    print(f"\n{'='*50}")
    print(f"질문: {task}")
    print(f"답변: {result}")

마치며

멀티모달 AI는 빠르게 발전하고 있으며, 텍스트, 이미지, 비디오를 통합적으로 이해하는 능력이 점점 더 강력해지고 있습니다.

이 가이드에서 다룬 핵심 내용:

  • CLIP: 대조 학습으로 이미지-텍스트를 동일 공간에 매핑, 제로샷 분류의 기반
  • BLIP/BLIP-2: 부트스트래핑과 Q-Former로 효율적인 멀티모달 학습
  • LLaVA: 오픈소스 비전-언어 어시스턴트의 표준
  • GPT-4V / Claude Vision: 상업용 최고 성능 멀티모달 LLM
  • Gemini 1.5: 100만 토큰 컨텍스트로 긴 비디오와 문서 처리
  • 멀티모달 RAG: CLIP 임베딩으로 이미지를 검색 가능한 지식베이스로 구축
  • 오픈소스 생태계: Phi-3 Vision, Qwen-VL 등 로컬 실행 가능한 강력한 모델들

앞으로의 방향은 더 긴 비디오 이해, 3D 공간 이해, 실시간 멀티모달 처리로 나아가고 있습니다. 이 분야는 매우 빠르게 발전하므로 지속적인 학습이 필요합니다.

참고 자료