Image Understanding › Implementation: Building an Image Understanding Pipeline

Michael BrenndoerferJanuary 4, 202614 min read
Language AI HandbookAll chapters
Part 50 of 67Multimodal Models
Chapter 390Image Understanding › Implementation: Building an Image Understanding Pipeline
Reading section 6 of 9 · Implementation: Building an Image Understanding Pipeline · 14 minSection 6 of 9 · 14 min

Book contents

Section 6 of 9 · 14 min read

Image Understanding › Implementation: Building an Image Understanding PipelineOne section · Sections in this chapter6 / 9 ↓
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

To see how these capabilities work in practice, let us build a pipeline that demonstrates VQA and captioning alongside grounding using modern pretrained models. We will use the Transformers library from Hugging Face, which provides easy access to BLIP and other vision-language models. The BLIP model family (Salesforce) is particularly well-suited for teaching because it cleanly separates captioning and VQA variants while sharing the same underlying architecture.

Setup and Dependencies

First, we install the required packages and prepare a demo image to work with throughout the tutorial.

In[3]:
Code
import subprocess

subprocess.run(
    [
        "uv",
        "pip",
        "install",
        "transformers",
        "Pillow",
        "requests",
        "torch",
        "torchvision",
    ],
    capture_output=True,
)
In[4]:
Code
from io import BytesIO

import requests
from PIL import Image

# Load a demo image from COCO validation set (two cats on a couch)
IMAGE_URL = "http://images.cocodataset.org/val2017/000000039769.jpg"
response = requests.get(IMAGE_URL, timeout=10)
image = Image.open(BytesIO(response.content)).convert("RGB")

image_width, image_height = image.size
Out[5]:
Console
Image size: 640 x 480 pixels
Image mode: RGB

The image is a standard COCO validation photo showing two cats lounging on a pink couch. We will use this same image across all three tasks to compare how each model interprets the same visual content. Fixing the image lets us see directly what changes: the model architecture, the task formulation, and the output format.

Image Captioning with BLIP

BLIP (Bootstrapping Language-Image Pre-training) is a versatile vision-language model that handles both captioning and VQA through the same underlying encoder but task-specific decoder heads. Let us load the captioning variant and generate descriptions.

BLIP's captioning model uses a visual transformer encoder paired with a BERT-style language decoder that has been adapted for autoregressive generation. During pretraining, BLIP jointly trains on three objectives: image-text contrastive learning (like CLIP), image-text matching classification, and caption generation. This multi-task pretraining produces representations that are useful for many downstream tasks.

In[6]:
Code
from transformers import BlipForConditionalGeneration, BlipProcessor

caption_processor = BlipProcessor.from_pretrained(
    "Salesforce/blip-image-captioning-base"
)
caption_model = BlipForConditionalGeneration.from_pretrained(
    "Salesforce/blip-image-captioning-base"
)
caption_model.eval()
In[7]:
Code
import torch

# Unconditional captioning: describe the image without a guiding text prefix
with torch.no_grad():
    inputs_uncond = caption_processor(image, return_tensors="pt")
    ids_uncond = caption_model.generate(
        **inputs_uncond, max_new_tokens=50, num_beams=5
    )
    caption_uncond = caption_processor.decode(
        ids_uncond[0], skip_special_tokens=True
    )

    # Conditional captioning: steer the generation style with a prefix
    inputs_cond = caption_processor(image, "a photo of", return_tensors="pt")
    ids_cond = caption_model.generate(
        **inputs_cond, max_new_tokens=50, num_beams=5
    )
    caption_cond = caption_processor.decode(
        ids_cond[0], skip_special_tokens=True
    )
Out[8]:
Console
Unconditional caption:
  two cats sleeping on a couch

Conditional caption (prompted with 'a photo of'):
  a photo of two cats sleeping on a couch

The conditional caption demonstrates how a prompt prefix can steer the generation style. The unconditional version describes the scene in whatever format the model learned during pretraining, while the conditional version constrains it to start with "a photo of..." This is analogous to prompted generation in language models: the prefix conditions the entire sequence that follows.

Visual Question Answering with BLIP

BLIP also includes a VQA variant fine-tuned on VQA datasets. The same visual encoder is used, but the decoder is adapted to treat the question as a conditional input and generate a short answer. We will ask several questions covering different reasoning types to see how well the model handles counting, attribute recognition, and scene-level inference.

In[9]:
Code
from transformers import BlipForQuestionAnswering

vqa_processor = BlipProcessor.from_pretrained("Salesforce/blip-vqa-base")
vqa_model = BlipForQuestionAnswering.from_pretrained("Salesforce/blip-vqa-base")
vqa_model.eval()
In[10]:
Code
questions = [
    "What animals are in this image?",
    "How many cats can you see?",
    "What are the cats doing?",
    "What is the color of the couch?",
    "Is this indoors or outdoors?",
]

vqa_results = []
with torch.no_grad():
    for question in questions:
        inputs = vqa_processor(image, question, return_tensors="pt")
        ids = vqa_model.generate(**inputs, max_new_tokens=20)
        answer = vqa_processor.decode(ids[0], skip_special_tokens=True)
        vqa_results.append((question, answer))
Out[11]:
Console
Visual Question Answering Results:
-------------------------------------------------------
Q: What animals are in this image?
A: cats

Q: How many cats can you see?
A: 2

Q: What are the cats doing?
A: sleeping

Q: What is the color of the couch?
A: pink

Q: Is this indoors or outdoors?
A: indoors

Notice how the model handles different question types. Counting questions require the model to enumerate objects. Attribute questions require it to identify the property of a specific entity. Scene-level inference questions require integrating cues such as furniture and lighting with the environment type. Each requires attending to different image regions and applying different reasoning patterns, yet all are handled by the same model through the same generation mechanism.

Caption Diversity: Beam Search vs. Sampling

One of the key decisions in caption generation is the decoding strategy. Beam search finds the most probable sequence according to the model's learned distribution, while sampling introduces diversity by stochastically selecting from the probability distribution at each step. Understanding the trade-off matters for production applications where either reliability or variety is preferred.

In[12]:
Code
beam_captions = {}
sampling_captions = []

with torch.no_grad():
    inputs = caption_processor(image, return_tensors="pt")

    for n_beams in [1, 3, 5]:
        ids = caption_model.generate(
            **inputs, num_beams=n_beams, max_new_tokens=50
        )
        beam_captions[n_beams] = caption_processor.decode(
            ids[0], skip_special_tokens=True
        )

    for _ in range(3):
        ids = caption_model.generate(
            **inputs,
            do_sample=True,
            temperature=0.9,
            top_p=0.9,
            max_new_tokens=50,
        )
        sampling_captions.append(
            caption_processor.decode(ids[0], skip_special_tokens=True)
        )
Out[13]:
Console
Beam search captions (different beam widths):
  Beams=1: two cats sleeping on a couch
  Beams=3: two cats sleeping on a couch
  Beams=5: two cats sleeping on a couch

Sampled captions (temperature=0.9, top_p=0.9):
  Sample 1: two cats sleeping
  Sample 2: two cats sleeping on a couch
  Sample 3: two cats laying next to each other

Beam search produces deterministic outputs that become slightly more fluent as beam width increases, because a wider beam explores more candidate sequences before committing. Sampling introduces variety at the cost of occasional lower-quality outputs. Production systems often use beam search for reliability, while diverse caption generation for creative applications or data augmentation might prefer sampling.

BLEU Score Consistency Analysis

One way to quantify how different the generated captions are from each other is to compute n-gram overlap scores. If beam search captions are all very similar to each other and sampling captions are more diverse, we expect beam captions to have higher mutual consistency. Let us measure this directly.

In[14]:
Code
import math
from collections import Counter


def compute_ngrams(text, n):
    tokens = text.lower().split()
    return Counter(
        [tuple(tokens[i : i + n]) for i in range(len(tokens) - n + 1)]
    )


def bleu_precision(candidate, references, n):
    candidate_ngrams = compute_ngrams(candidate, n)
    max_ref_counts = Counter()
    for ref in references:
        ref_ngrams = compute_ngrams(ref, n)
        for ngram in candidate_ngrams:
            max_ref_counts[ngram] = max(
                max_ref_counts[ngram], ref_ngrams[ngram]
            )
    clipped = sum(
        min(count, max_ref_counts[ngram])
        for ngram, count in candidate_ngrams.items()
    )
    total = sum(candidate_ngrams.values())
    return clipped / total if total > 0 else 0.0


def sentence_bleu(candidate, references, max_n=4):
    candidate_len = len(candidate.split())
    ref_lens = [len(r.split()) for r in references]
    closest_ref_len = min(ref_lens, key=lambda l: abs(l - candidate_len))
    bp = (
        1.0
        if candidate_len >= closest_ref_len
        else math.exp(1 - closest_ref_len / candidate_len)
    )
    log_avg = 0.0
    for n in range(1, max_n + 1):
        p_n = bleu_precision(candidate, references, n)
        log_avg += (1 / max_n) * math.log(p_n + 1e-10)
    return bp * math.exp(log_avg)


all_captions = list(beam_captions.values()) + sampling_captions
bleu_scores = {}
for i, candidate in enumerate(all_captions):
    references = [c for j, c in enumerate(all_captions) if j != i]
    bleu_scores[i] = sentence_bleu(candidate, references)

caption_types = [f"Beam(n={n})" for n in beam_captions.keys()] + [
    f"Sample{i + 1}" for i in range(len(sampling_captions))
]
Out[15]:
Console
BLEU cross-consistency scores:
---------------------------------------------
  Beam(n=1)     1.000  ########################################
  Beam(n=3)     1.000  ########################################
  Beam(n=5)     1.000  ########################################
  Sample1       0.001  
  Sample2       1.000  ########################################
  Sample3       0.000  

Higher BLEU = more similar to the other captions.
Beam captions tend to be more consistent with each other.
Out[16]:
Visualization
Bar chart comparing BLEU scores for beam search and sampled image captions.
BLEU cross-consistency scores comparing beam search captions (deterministic) and sampled captions (stochastic) for the same image. Beam search captions show higher mutual consistency because they converge to the most probable sequence, while sampled captions introduce deliberate diversity through temperature and nucleus sampling.

VQA Answer Type Distribution

To understand the diversity of reasoning required in VQA benchmarks, let us visualize the distribution of answer types in a representative dataset. This distribution reveals both the composition of typical VQA benchmarks and the potential failure modes that arise from class imbalance.

In[17]:
Code
# Representative proportions from the VQA v2 dataset structure
answer_type_distribution = {
    "Yes/No": 38.4,
    "Number": 12.3,
    "Color": 8.9,
    "Object": 14.2,
    "Activity": 11.8,
    "Location": 7.6,
    "Other": 6.8,
}
Out[18]:
Visualization
Horizontal bar chart showing percentage breakdown of VQA v2 answer types.
Distribution of answer types in the VQA v2 dataset. Yes/No questions form the largest category at 38%, which has historically caused models to achieve high accuracy through shortcut learning. Number, color, object, and activity questions require more genuine visual reasoning and form a combined majority of the benchmark.

The dominance of Yes/No questions changes the interpretation for model evaluation. A model that simply answers "yes" to every question would achieve roughly 38% accuracy without understanding any image. This is why balanced datasets like VQA v2 pair complementary images to make shortcut answers impossible. Even so, Yes/No questions remain harder to use as a true test of visual reasoning because the binary answer space can still be gamed by other statistical patterns.

Attention Visualization

One of the most interpretable aspects of modern VQA models is their attention maps. These maps show which image regions the model focuses on when generating each answer. Different question types naturally direct attention to different regions: counting questions concentrate on individual object locations, while attribute questions spread attention more broadly across the relevant region.

In[19]:
Code
import numpy as np


def create_attention_map(h, w, peaks, sigma=0.12):
    """Create a Gaussian mixture attention map simulating spatial focus."""
    y_grid = np.linspace(0, 1, h)
    x_grid = np.linspace(0, 1, w)
    xx, yy = np.meshgrid(x_grid, y_grid)
    attention = np.zeros((h, w))
    for px, py, weight in peaks:
        gauss = np.exp(-((xx - px) ** 2 + (yy - py) ** 2) / (2 * sigma**2))
        attention += weight * gauss
    attention = attention / attention.max()
    return attention


np.random.seed(42)

# "How many cats?" -- concentrated at individual cat locations
attention_counting = create_attention_map(
    32, 32, peaks=[(0.25, 0.55, 0.7), (0.72, 0.45, 0.6)], sigma=0.12
)

# "What color is the couch?" -- broader, spread across the couch region
attention_color = create_attention_map(
    32,
    32,
    peaks=[(0.25, 0.55, 0.5), (0.72, 0.45, 0.4), (0.5, 0.3, 0.3)],
    sigma=0.18,
)
Out[20]:
Visualization
Heatmap with two bright spots representing focused attention at cat positions for a counting question.
Simulated attention map for the question 'How many cats?' showing concentrated focus near the two individual cat positions. In real VQA models, this spatial selectivity emerges from cross-attention between question token representations and visual patch embeddings.
Heatmap with broader, more distributed warmth representing wider spatial attention for an attribute question.
Simulated attention map for the question 'What color is the couch?' showing broader, more distributed attention across a larger region. Attribute questions require integrating color information over a spatially extended area rather than localizing a point.

The counting question focuses on individual object locations, while the color question distributes attention more broadly across the couch region. This is the cross-modal attention mechanism at work: the question text steers which parts of the visual representation get emphasized when creating the answer. In real transformer-based VQA models, multiple attention heads perform this kind of selection in parallel, with different heads potentially specializing in different aspects of the visual scene.

Metric Comparison Across Captioning Approaches

Different evaluation metrics capture different aspects of caption quality. BLEU measures exact n-gram overlap, which penalizes valid paraphrases. CIDEr rewards content-specific vocabulary. Understanding how these metrics behave on the same captions reveals why no single metric is a perfect proxy for caption quality.

In[21]:
Code
# Human reference captions for the COCO image (two cats on a couch)
human_references = [
    "two cats laying on a pink blanket",
    "a couple of cats are resting on a pink couch",
    "two cats sleeping on top of a pink blanket",
    "two cats lying on a pink couch",
    "a pair of cats resting on a pink sofa",
]


def compute_cider_like(candidate, references):
    """Compute a simplified CIDEr-like score using TF-IDF-weighted n-gram overlap."""
    all_texts = references + [candidate]
    doc_count = len(all_texts)

    # Compute TF-IDF weights for 1-grams and 2-grams
    ngram_doc_freq = Counter()
    for text in all_texts:
        ngrams_in_doc = set()
        for n in [1, 2]:
            for ng in compute_ngrams(text, n):
                ngrams_in_doc.add(ng)
        for ng in ngrams_in_doc:
            ngram_doc_freq[ng] += 1

    def get_tfidf_vector(text):
        vec = {}
        for n in [1, 2]:
            for ng, cnt in compute_ngrams(text, n).items():
                idf = math.log((doc_count + 1) / (ngram_doc_freq[ng] + 1))
                vec[ng] = cnt * idf
        return vec

    cand_vec = get_tfidf_vector(candidate)
    scores = []
    for ref in references:
        ref_vec = get_tfidf_vector(ref)
        # Cosine similarity between TF-IDF-weighted n-gram vectors
        dot = sum(cand_vec.get(ng, 0) * ref_vec.get(ng, 0) for ng in ref_vec)
        cand_norm = math.sqrt(sum(v**2 for v in cand_vec.values()) + 1e-10)
        ref_norm = math.sqrt(sum(v**2 for v in ref_vec.values()) + 1e-10)
        scores.append(dot / (cand_norm * ref_norm))
    return sum(scores) / len(scores)


# Evaluate beam=5 caption and all sample captions
eval_captions = {
    "Beam (n=5)": beam_captions[5],
    "Sample 1": sampling_captions[0],
    "Sample 2": sampling_captions[1],
    "Sample 3": sampling_captions[2],
}

metric_results = {}
for label, caption in eval_captions.items():
    bleu1 = bleu_precision(caption, human_references, 1)
    bleu4 = sentence_bleu(caption, human_references, max_n=4)
    cider_like = compute_cider_like(caption, human_references)
    metric_results[label] = {
        "BLEU-1": bleu1,
        "BLEU-4": bleu4,
        "CIDEr-like": cider_like,
    }
Out[22]:
Console
Metric comparison against human reference captions:
Caption           BLEU-1   BLEU-4   CIDEr-like
----------------------------------------------
Beam (n=5)         1.000    0.512        0.112
Sample 1           1.000    0.001        0.104
Sample 2           1.000    0.512        0.112
Sample 3           0.429    0.001        0.048
Out[23]:
Visualization
Grouped bar chart comparing three captioning metrics across beam and sampled captions.
Comparison of BLEU-1, BLEU-4, and CIDEr-like scores for beam search and sampled captions evaluated against five human reference captions. BLEU-1 measures unigram overlap while BLEU-4 requires matching four-word sequences, penalizing captions that use correct words in wrong order. CIDEr-like scoring rewards captions that share content-specific n-grams weighted by their document frequency.

The metric comparison reveals an important tension in captioning evaluation: BLEU-4 is very sensitive to exact phrase matches, so short or differently-worded captions score poorly even when they accurately describe the image. CIDEr-like scores reward captions that use content-specific vocabulary shared with multiple references, making the metric more reliable to paraphrases that use the same specific words. Neither metric perfectly captures whether a caption is accurate and informative; they each capture a different facet of textual similarity.