Vision Encoders for VLMs › Code Implementation: Processing Images with Vision Encoders

Michael BrenndoerferFebruary 9, 20268 min read
Language AI HandbookAll chapters
Part 50 of 67Multimodal Models
Chapter 383Vision Encoders for VLMs › Code Implementation: Processing Images with Vision Encoders
Reading section 9 of 12 · Code Implementation: Processing Images with Vision Encoders · 8 minSection 9 of 12 · 8 min

Book contents

Section 9 of 12 · 8 min read

Vision Encoders for VLMs › Code Implementation: Processing Images with Vision EncodersOne section · Sections in this chapter9 / 12 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let us implement a complete pipeline for processing images through different vision encoder configurations, comparing CLIP-style and SigLIP-style processing to understand the practical differences in training objectives.

In[9]:
Code
import numpy as np
import torch
import torch.nn as nn
from PIL import Image


## Create synthetic image batch for demonstration
def create_synthetic_images(batch_size=2, size=224):
    """Create synthetic images with geometric patterns."""
    images = []
    for i in range(batch_size):
        # Create pattern with different frequency components
        x = np.linspace(-1, 1, size)
        y = np.linspace(-1, 1, size)
        xx, yy = np.meshgrid(x, y)

        # Different pattern per image
        pattern = np.sin(4 * np.pi * (xx + yy * (i + 1)))
        pattern = (pattern + 1) / 2  # Normalize to [0, 1]

        # Convert to RGB
        img_array = np.stack([pattern, pattern * 0.8, pattern * 0.6], axis=-1)
        img_array = (img_array * 255).astype(np.uint8)
        images.append(Image.fromarray(img_array))

    return images


## Preprocessing
def preprocess_images(images, size=224):
    """Convert PIL images to normalized tensors."""
    tensors = []
    for img in images:
        img = img.resize((size, size))
        arr = np.array(img).astype(np.float32) / 255.0
        # Normalize with ImageNet stats
        mean = np.array([0.485, 0.456, 0.406])
        std = np.array([0.229, 0.224, 0.225])
        arr = (arr - mean) / std
        tensor = torch.from_numpy(arr).permute(2, 0, 1).float()
        tensors.append(tensor)

    return torch.stack(tensors)


# Simulated components for demonstration
class PatchEmbedding(nn.Module):
    """Converts images to patch embeddings."""

    def __init__(
        self, img_size=224, patch_size=16, in_channels=3, embed_dim=768
    ):
        super().__init__()
        self.patch_size = patch_size
        self.num_patches = (img_size // patch_size) ** 2
        self.proj = nn.Conv2d(
            in_channels, embed_dim, kernel_size=patch_size, stride=patch_size
        )

    def forward(self, x):
        # x: (batch, channels, height, width)
        x = self.proj(x)  # (batch, embed_dim, H/P, W/P)
        x = x.flatten(2).transpose(1, 2)  # (batch, num_patches, embed_dim)
        return x


class VisionEncoder(nn.Module):
    """Simplified Vision Transformer encoder for VLM."""

    def __init__(
        self,
        img_size=224,
        patch_size=16,
        embed_dim=768,
        num_layers=12,
        num_heads=12,
    ):
        super().__init__()
        self.patch_embed = PatchEmbedding(img_size, patch_size, 3, embed_dim)
        self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim))

        # Position embeddings
        num_patches = self.patch_embed.num_patches
        self.pos_embed = nn.Parameter(
            torch.zeros(1, num_patches + 1, embed_dim)
        )

        # Transformer layers
        encoder_layer = nn.TransformerEncoderLayer(
            d_model=embed_dim,
            nhead=num_heads,
            dim_feedforward=embed_dim * 4,
            batch_first=True,
        )
        self.transformer = nn.TransformerEncoder(encoder_layer, num_layers)

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

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

        # Add position embeddings
        x = x + self.pos_embed

        # Transform
        x = self.transformer(x)
        return x  # (batch, num_patches+1, embed_dim)
Out[11]:
Console
Input tensor shape: torch.Size([2, 3, 224, 224])
Batch size: 2
Channels: 3
Resolution: 224x224

Encoder output shape: torch.Size([2, 197, 768])
Sequence length (patches + CLS): 197
Embedding dimension: 768
Total tokens for 2 images: 394

Resolution Scaling with Position Interpolation

Now let us demonstrate how to adapt a pre-trained encoder for higher resolution using position interpolation, a technique that allows models to process images larger than their training resolution without requiring complete retraining from scratch:

In[12]:
Code
def interpolate_pos_embed(pos_embed, new_num_patches):
    """
    Interpolate position embeddings for different resolution.

    Args:
        pos_embed: Original position embeddings (1, N+1, D)
        new_num_patches: New number of patches (N')
    Returns:
        Interpolated position embeddings (1, N'+1, D)
    """
    # Separate CLS token
    cls_embed = pos_embed[:, :1]
    patch_embed = pos_embed[:, 1:]

    # Get original grid size (assuming square)
    orig_size = int(np.sqrt(patch_embed.shape[1]))
    new_size = int(np.sqrt(new_num_patches))

    # Reshape to 2D grid
    patch_embed = patch_embed.reshape(1, orig_size, orig_size, -1)
    patch_embed = patch_embed.permute(0, 3, 1, 2)  # (1, D, H, W)

    # Interpolate
    patch_embed = torch.nn.functional.interpolate(
        patch_embed,
        size=(new_size, new_size),
        mode="bilinear",
        align_corners=False,
    )

    # Reshape back
    patch_embed = patch_embed.permute(0, 2, 3, 1).reshape(
        1, new_size * new_size, -1
    )

    # Concatenate CLS token back
    return torch.cat([cls_embed, patch_embed], dim=1)


# Simulate resolution increase
orig_patches = 196  # 14x14 grid at 224px with 16x16 patches
new_patches = 784  # 28x28 grid at 448px with 16x16 patches

# Original position embeddings
pos_embed_224 = torch.randn(1, orig_patches + 1, 768)

# Interpolate for higher resolution
pos_embed_448 = interpolate_pos_embed(pos_embed_224, new_patches)
Out[13]:
Console
Original position embeddings: torch.Size([1, 197, 768])
Interpolated for 448px: torch.Size([1, 785, 768])
Sequence length increase: 785 / 197 = 4.0x

Position embedding memory:
  224px: 591.0 KB
  448px: 2355.0 KB
  Increase: 4.0x

Simulating SigLIP-style Processing

Let us compare the output distributions between standard contrastive training and SigLIP-style normalization to observe how the different loss functions affect the probability distribution:

In[14]:
Code
def compute_similarities(
    image_embeds, text_embeds, method="siglip", temperature=0.01
):
    """
    Compute image-text similarities using different methods.

    Args:
        image_embeds: (batch, dim) image embeddings
        text_embeds: (batch, dim) text embeddings
        method: 'clip' (softmax) or 'siglip' (sigmoid)
        temperature: scaling factor
    """
    # Normalize embeddings
    image_embeds = nn.functional.normalize(image_embeds, dim=-1)
    text_embeds = nn.functional.normalize(text_embeds, dim=-1)

    # Compute similarity matrix
    logits = torch.matmul(image_embeds, text_embeds.t()) / temperature

    if method == "clip":
        # CLIP uses softmax across batch (symmetric loss)
        probs = nn.functional.softmax(logits, dim=-1)
        loss = -torch.log(torch.diag(probs)).mean()
        return logits, probs, loss
    else:
        # SigLIP uses independent sigmoids
        # Create target matrix (identity for matching pairs)
        batch_size = image_embeds.shape[0]
        targets = torch.eye(batch_size, device=image_embeds.device)

        # Binary cross-entropy for each pair independently
        probs = torch.sigmoid(logits)
        loss = -(
            targets * torch.log(probs + 1e-6)
            + (1 - targets) * torch.log(1 - probs + 1e-6)
        ).mean()
        return logits, probs, loss


# Simulate batch of embeddings
torch.manual_seed(42)
batch_size = 4
embed_dim = 512

# Create partially aligned embeddings with substantial noise so that
# off-diagonal similarities are non-negligible -- this makes the
# CLIP vs SigLIP contrast visible in the rendered heatmaps.
image_embeds = torch.randn(batch_size, embed_dim)
text_embeds = image_embeds + torch.randn(batch_size, embed_dim) * 1.2
Out[15]:
Console
Contrastive Learning Comparison (4 image-text pairs):

CLIP-style (InfoNCE):
  Loss: 0.0003
  Diagonal probabilities (matches): [0.9997658  0.9997342  0.9996068  0.99971503]
  Off-diagonal mean: 0.0001

SigLIP-style (Sigmoid):
  Loss: 0.4514
  Diagonal probabilities (matches): [0.9998957  0.9999323  0.999851   0.99984634]
  Off-diagonal mean: 0.4373

Key difference:
  CLIP probabilities sum to 1.0 per row: 1.0000
  SigLIP probabilities are independent (no normalization constraint)
Out[16]:
Visualization
4x4 Blues heatmap showing CLIP softmax probabilities with high diagonal and near-zero off-diagonal values.
CLIP-style softmax probability matrix for 4 image-text pairs. Each row sums to 1.0 due to softmax normalization, forcing the model to choose one best match per image. High diagonal values indicate correct matching, while the near-zero off-diagonal values reflect the competitive suppression inherent to contrastive normalization.
4x4 Oranges heatmap showing SigLIP sigmoid probabilities with high diagonal and non-negligible off-diagonal values.
SigLIP-style sigmoid probability matrix for the same 4 image-text pairs. Each cell is an independent binary prediction unconstrained by row or column sums. The non-negligible off-diagonal values reflect that SigLIP can acknowledge partial or ambiguous matches without being penalized for assigning them moderate probability.

The demonstration illustrates how SigLIP treats each image-text pair as an independent prediction. This allows multiple pairs to have high probabilities simultaneously if they are all valid matches. In contrast, CLIP forces competition between pairs through softmax normalization, requiring the model to choose a single best match even when multiple valid matches exist. This independence allows SigLIP to express uncertainty about multiple potential matches without the hard constraint that probabilities must sum to 1. This provides more flexible training dynamics especially with noisy or ambiguous data.

The off-diagonal probabilities in the SigLIP output are not a sign of failure: they reflect the overlap between image-text pairs that share similar concepts. An image of a dog playing in a park has partial correspondence with text descriptions about parks and about dogs independently. SigLIP's loss function rewards the model for assigning high probability to the correct pair while still letting moderate probabilities for semantically related pairs, whereas CLIP's softmax forcibly suppresses all off-diagonal values toward zero regardless of their semantic relationship to the diagonal entry.