JP Last sync: 2026-01-15

Chapter 2: Vision-Language Models

CLIP, BLIP, LLaVA - Understanding Cross-Modal Architectures

Reading Time: 35-40 min Code Examples: 10 Exercises: 4

2.1 Introduction to Vision-Language Models

Vision-Language Models (VLMs) are multimodal AI systems that can understand and reason about both images and text. They represent a critical advancement in AI, enabling applications from image captioning to visual question answering.

VLM Capabilities

2.2 CLIP: Contrastive Language-Image Pre-training

CLIP, released by OpenAI in 2021, revolutionized vision-language models by demonstrating that contrastive learning at scale creates powerful cross-modal representations.

CLIP Architecture

graph TB subgraph Input I[Image] T[Text] end subgraph Encoders VE[Vision Encoder
ViT/ResNet] TE[Text Encoder
Transformer] end subgraph Embeddings IE[Image Embedding
512/768-dim] TXE[Text Embedding
512/768-dim] end I --> VE T --> TE VE --> IE TE --> TXE IE --> CS[Cosine Similarity] TXE --> CS CS --> CL[Contrastive Loss] style VE fill:#e3f2fd style TE fill:#fff3e0 style CS fill:#9b59b6,color:white

Contrastive Learning Objective

CLIP learns by maximizing similarity between matched image-text pairs while minimizing similarity with unmatched pairs:

InfoNCE Loss

$$\mathcal{L}_{\text{CLIP}} = -\frac{1}{N}\sum_{i=1}^{N}\left[\log\frac{\exp(\text{sim}(v_i, t_i)/\tau)}{\sum_{j=1}^{N}\exp(\text{sim}(v_i, t_j)/\tau)}\right]$$

Where \(\text{sim}(v, t)\) is the cosine similarity and \(\tau\) is a temperature parameter.

CLIP Implementation

# CLIP for Zero-Shot Image Classification
from transformers import CLIPProcessor, CLIPModel
from PIL import Image
import requests
import torch

# Load CLIP model
model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")
processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")

# Load image
url = "https://images.unsplash.com/photo-1560807707-8cc77767d783?w=400"
image = Image.open(requests.get(url, stream=True).raw)

# Define candidate labels
labels = ["a photo of a dog", "a photo of a cat", "a photo of a bird"]

# Process inputs
inputs = processor(
    text=labels,
    images=image,
    return_tensors="pt",
    padding=True
)

# Get similarity scores
outputs = model(**inputs)
logits_per_image = outputs.logits_per_image
probs = logits_per_image.softmax(dim=1)

# Print results
for label, prob in zip(labels, probs[0]):
    print(f"{label}: {prob.item():.2%}")
# Output: "a photo of a dog: 98.5%"

CLIP for Image-Text Retrieval

# Image-Text Retrieval with CLIP
import torch
from transformers import CLIPProcessor, CLIPModel

class CLIPRetriever:
    def __init__(self, model_name="openai/clip-vit-base-patch32"):
        self.model = CLIPModel.from_pretrained(model_name)
        self.processor = CLIPProcessor.from_pretrained(model_name)
        self.image_embeddings = None
        self.images = []

    def index_images(self, images):
        """Pre-compute embeddings for image database"""
        self.images = images
        inputs = self.processor(images=images, return_tensors="pt", padding=True)

        with torch.no_grad():
            self.image_embeddings = self.model.get_image_features(**inputs)
            self.image_embeddings = self.image_embeddings / self.image_embeddings.norm(dim=-1, keepdim=True)

    def search(self, query_text, top_k=5):
        """Find most similar images to text query"""
        inputs = self.processor(text=[query_text], return_tensors="pt", padding=True)

        with torch.no_grad():
            text_embedding = self.model.get_text_features(**inputs)
            text_embedding = text_embedding / text_embedding.norm(dim=-1, keepdim=True)

        # Compute similarities
        similarities = (text_embedding @ self.image_embeddings.T).squeeze()
        top_indices = similarities.argsort(descending=True)[:top_k]

        return [(self.images[i], similarities[i].item()) for i in top_indices]

# Usage
retriever = CLIPRetriever()
retriever.index_images(image_database)
results = retriever.search("a sunset over the ocean")

2.3 BLIP and BLIP-2: Bootstrapped Vision-Language

BLIP (Bootstrapping Language-Image Pre-training) improves upon CLIP by combining contrastive learning with generative objectives.

BLIP-2 Architecture: Q-Former

graph TB subgraph Frozen["Frozen Components"] VE2[Vision Encoder
ViT-G/14] LLM[Large Language Model
OPT/FlanT5] end subgraph Trainable["Trainable Q-Former"] QT[Learned Queries
32 tokens] SA[Self-Attention] CA[Cross-Attention
to Visual Features] end I2[Image] --> VE2 VE2 --> VF[Visual Features] VF --> CA QT --> SA SA --> CA CA --> QO[Query Output] QO --> FC[Linear Projection] FC --> LLM T2[Text Prompt] --> LLM LLM --> OUT[Generated Text] style QT fill:#9b59b6,color:white style SA fill:#9b59b6,color:white style CA fill:#9b59b6,color:white

Q-Former: The Key Innovation

The Q-Former (Querying Transformer) bridges frozen image encoders and LLMs:

# BLIP-2 for Image Captioning
from transformers import Blip2Processor, Blip2ForConditionalGeneration
from PIL import Image
import torch

# Load BLIP-2 model
processor = Blip2Processor.from_pretrained("Salesforce/blip2-opt-2.7b")
model = Blip2ForConditionalGeneration.from_pretrained(
    "Salesforce/blip2-opt-2.7b",
    torch_dtype=torch.float16
)
model.to("cuda")

# Process image
image = Image.open("example.jpg")
inputs = processor(image, return_tensors="pt").to("cuda", torch.float16)

# Generate caption
generated_ids = model.generate(**inputs, max_new_tokens=50)
caption = processor.batch_decode(generated_ids, skip_special_tokens=True)[0]
print(f"Caption: {caption}")

BLIP-2 for Visual Question Answering

# Visual QA with BLIP-2
def ask_about_image(image_path, question):
    image = Image.open(image_path)

    # Format prompt for QA
    prompt = f"Question: {question} Answer:"

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

    generated_ids = model.generate(
        **inputs,
        max_new_tokens=30,
        num_beams=5,
        early_stopping=True
    )

    answer = processor.batch_decode(generated_ids, skip_special_tokens=True)[0]
    return answer.strip()

# Example usage
answer = ask_about_image("street_scene.jpg", "How many people are in this image?")
print(f"Answer: {answer}")  # "There are 5 people in this image"

2.4 LLaVA: Visual Instruction Tuning

LLaVA (Large Language and Vision Assistant) demonstrates that simple architectures can achieve impressive results through visual instruction tuning.

LLaVA Architecture

graph LR subgraph Vision["Vision Encoder (CLIP)"] IMG[Image] --> CLIP[CLIP ViT-L/14] CLIP --> VT[Visual Tokens
576 tokens] end subgraph Projection["Projection Layer"] VT --> MLP[2-Layer MLP
GELU activation] MLP --> PV[Projected Visuals
LLM dimension] end subgraph LLM["Language Model"] PV --> Vicuna[Vicuna/LLaMA] TXT[Text Tokens] --> Vicuna Vicuna --> OUT2[Response] end style MLP fill:#9b59b6,color:white

LLaVA Training Strategy

Stage Data Trainable Purpose
Pre-training 558K image-caption pairs Projection layer only Align visual features to LLM space
Instruction Tuning 158K visual instructions Full model Learn to follow visual instructions

LLaVA Implementation

# Using LLaVA for Visual Conversation
from transformers import LlavaProcessor, LlavaForConditionalGeneration
from PIL import Image
import torch

# Load LLaVA model
model_id = "llava-hf/llava-1.5-7b-hf"
processor = LlavaProcessor.from_pretrained(model_id)
model = LlavaForConditionalGeneration.from_pretrained(
    model_id,
    torch_dtype=torch.float16,
    device_map="auto"
)

def chat_with_image(image_path, user_message):
    image = Image.open(image_path)

    # LLaVA conversation format
    conversation = [
        {
            "role": "user",
            "content": [
                {"type": "image"},
                {"type": "text", "text": user_message}
            ]
        }
    ]

    prompt = processor.apply_chat_template(conversation, add_generation_prompt=True)
    inputs = processor(prompt, image, return_tensors="pt").to("cuda")

    output = model.generate(**inputs, max_new_tokens=200)
    response = processor.decode(output[0], skip_special_tokens=True)

    return response

# Multi-turn conversation
response1 = chat_with_image("kitchen.jpg", "What do you see in this image?")
print(response1)

response2 = chat_with_image("kitchen.jpg", "What could I cook with the ingredients visible?")
print(response2)

2.5 Visual Tokenization Strategies

How images are converted to tokens significantly impacts VLM performance:

Patch-Based Tokenization (ViT-style)

# Patch-based visual tokenization
import torch
import torch.nn as nn

class PatchEmbedding(nn.Module):
    def __init__(self, img_size=224, patch_size=16, in_channels=3, embed_dim=768):
        super().__init__()
        self.img_size = img_size
        self.patch_size = patch_size
        self.n_patches = (img_size // patch_size) ** 2  # 196 for 224/16

        # Project patches to embedding dimension
        self.proj = nn.Conv2d(
            in_channels,
            embed_dim,
            kernel_size=patch_size,
            stride=patch_size
        )

        # Learnable position embeddings
        self.pos_embed = nn.Parameter(torch.randn(1, self.n_patches + 1, embed_dim))
        self.cls_token = nn.Parameter(torch.randn(1, 1, embed_dim))

    def forward(self, x):
        B = x.shape[0]

        # Create patch embeddings: (B, C, H, W) -> (B, embed_dim, H/P, W/P)
        x = self.proj(x)

        # Flatten: (B, embed_dim, n_patches^0.5, n_patches^0.5) -> (B, n_patches, embed_dim)
        x = x.flatten(2).transpose(1, 2)

        # Add CLS token
        cls_tokens = self.cls_token.expand(B, -1, -1)
        x = torch.cat([cls_tokens, x], dim=1)

        # Add position embeddings
        x = x + self.pos_embed

        return x  # (B, n_patches + 1, embed_dim)

# Example: 224x224 image -> 197 tokens (196 patches + 1 CLS)
patch_embed = PatchEmbedding()
image = torch.randn(1, 3, 224, 224)
tokens = patch_embed(image)
print(f"Visual tokens shape: {tokens.shape}")  # (1, 197, 768)

Comparison of Tokenization Approaches

Method Tokens/Image Resolution Used By
ViT-B/32 patches 49 + 1 CLS 224x224 CLIP
ViT-L/14 patches 256 + 1 CLS 224x224 LLaVA
Q-Former queries 32 (fixed) Any BLIP-2
High-res ViT 576+ 336x336+ LLaVA-1.5

2.6 Cross-Attention Mechanisms in VLMs

Cross-attention allows one modality to query information from another:

# Cross-Attention Implementation
class CrossAttention(nn.Module):
    def __init__(self, dim, num_heads=8):
        super().__init__()
        self.num_heads = num_heads
        self.head_dim = dim // num_heads
        self.scale = self.head_dim ** -0.5

        # Query from one modality, Key/Value from another
        self.q_proj = nn.Linear(dim, dim)
        self.k_proj = nn.Linear(dim, dim)
        self.v_proj = nn.Linear(dim, dim)
        self.out_proj = nn.Linear(dim, dim)

    def forward(self, query_tokens, kv_tokens):
        """
        query_tokens: (B, N_q, D) - e.g., text tokens
        kv_tokens: (B, N_kv, D) - e.g., image tokens
        """
        B, N_q, D = query_tokens.shape
        N_kv = kv_tokens.shape[1]

        # Project to Q, K, V
        Q = self.q_proj(query_tokens).view(B, N_q, self.num_heads, self.head_dim).transpose(1, 2)
        K = self.k_proj(kv_tokens).view(B, N_kv, self.num_heads, self.head_dim).transpose(1, 2)
        V = self.v_proj(kv_tokens).view(B, N_kv, self.num_heads, self.head_dim).transpose(1, 2)

        # Attention: text queries attend to image keys/values
        attn = (Q @ K.transpose(-2, -1)) * self.scale
        attn = attn.softmax(dim=-1)

        # Aggregate values
        out = (attn @ V).transpose(1, 2).reshape(B, N_q, D)
        return self.out_proj(out)

# Example: Text tokens (10) attend to image tokens (196)
cross_attn = CrossAttention(dim=768)
text_tokens = torch.randn(1, 10, 768)
image_tokens = torch.randn(1, 196, 768)
attended = cross_attn(text_tokens, image_tokens)
print(f"Output shape: {attended.shape}")  # (1, 10, 768)

2.7 Unified Embedding Spaces

VLMs create shared embedding spaces where images and text can be compared directly:

graph TB subgraph "Separate Spaces" IS[Image Space] TS[Text Space] end subgraph "Unified Space" US[Shared Embedding Space] IP[Image Point] TP[Text Point] end IS --> |Contrastive Learning| US TS --> |Contrastive Learning| US IP --> |Similar| TP style US fill:#9b59b6,color:white
# Visualizing CLIP's Unified Embedding Space
import numpy as np
from sklearn.manifold import TSNE
import matplotlib.pyplot as plt

def visualize_clip_space(images, texts, model, processor):
    """Visualize how images and texts cluster in CLIP space"""

    # Get image embeddings
    image_inputs = processor(images=images, return_tensors="pt", padding=True)
    with torch.no_grad():
        image_embeds = model.get_image_features(**image_inputs)
        image_embeds = image_embeds / image_embeds.norm(dim=-1, keepdim=True)

    # Get text embeddings
    text_inputs = processor(text=texts, return_tensors="pt", padding=True)
    with torch.no_grad():
        text_embeds = model.get_text_features(**text_inputs)
        text_embeds = text_embeds / text_embeds.norm(dim=-1, keepdim=True)

    # Combine and reduce dimensionality
    all_embeds = torch.cat([image_embeds, text_embeds], dim=0).numpy()
    tsne = TSNE(n_components=2, perplexity=min(30, len(all_embeds)-1))
    reduced = tsne.fit_transform(all_embeds)

    # Plot
    n_images = len(images)
    plt.figure(figsize=(10, 8))
    plt.scatter(reduced[:n_images, 0], reduced[:n_images, 1], c='blue', label='Images', s=100)
    plt.scatter(reduced[n_images:, 0], reduced[n_images:, 1], c='red', label='Texts', s=100)

    # Draw lines between matched pairs
    for i in range(min(n_images, len(texts))):
        plt.plot([reduced[i, 0], reduced[n_images+i, 0]],
                 [reduced[i, 1], reduced[n_images+i, 1]], 'g--', alpha=0.5)

    plt.legend()
    plt.title("CLIP Unified Embedding Space")
    plt.show()

2.8 Summary

Chapter 2 Key Takeaways

Exercises

Exercise 1: Zero-Shot Classification

Use CLIP to classify images into custom categories (e.g., "professional photo", "amateur photo", "AI-generated image"). Compare results with different text prompt formulations.

Exercise 2: Q-Former Analysis

Explain why BLIP-2 uses a fixed number of query tokens (32) regardless of image resolution. What are the trade-offs of this design choice?

Exercise 3: LLaVA Conversation

Implement a multi-turn visual conversation where you ask follow-up questions about an image. How does context from previous turns affect answers?

Exercise 4: Embedding Space Visualization

Create a visualization showing how CLIP embeddings cluster for images of different categories. Do semantically similar images cluster together?