Vision Transformers and the Patch-Token Primitive
Before anything multimodal, an image has to become a sequence of tokens a transformer can eat. The 2020 ViT paper answered this with 16x16 pixel patches, a linear projection, and a position embedding. Five years later every 2026 frontier model (Claude Opus 4.7 at 2576px native, Gemini 3.1 Pro, Qwen3.5-Omni) still begins this way — the encoder changed from ViT to DINOv2 to SigLIP 2, register tokens were added, the positional scheme became 2D-RoPE, but the primitive held. This lesson reads the patch-token pipeline end to end and builds it in stdlib Python so the rest of Phase 12 has a concrete mental model for "visual tokens."
Prerequisites: Phase 7 (Transformers), Phase 4 (Computer Vision)
Video coming soon
An AI-narrated walkthrough for this module is on the way. Everything you need is in the lesson below.
#Learning Objectives
- Convert an HxWx3 image into a sequence of patch tokens with correct positional encoding.
- Compute sequence length, parameter count, and FLOPs for a ViT of a given (patch size, resolution, hidden dim, depth).
- Name the three upgrades that took ViT from 2020 research to 2026 production: self-supervised pretraining (DINO / MAE), register tokens, and native-resolution packing.
- Pick between CLS pooling, mean pooling, and register tokens for a downstream task.
#The Problem
Transformers operate on sequences of vectors. Text is already a sequence (bytes or tokens). An image is a 2D grid of pixels with three color channels — not a sequence. If you flatten every pixel, a 224x224 RGB image becomes 150,528 tokens, and self-attention at that length is a non-starter (quadratic in sequence length).
Pre-2020 approaches bolted a CNN feature extractor onto the front: ResNet produces a 7x7 feature map of 2048-dim vectors, feed those 49 tokens to a transformer. This works but inherits the CNN's biases (translation equivariance, local receptive fields) and loses the transformer's appetite for scale.
Dosovitskiy et al. (2020) asked the blunt question: what if we skip the CNN? Split the image into fixed-size patches (say 16x16 pixels), linearly project each patch into a vector, add a positional embedding, and feed the sequence to a vanilla transformer. At the time this was heresy — vision without convolutions. With enough data (JFT-300M, then LAION) it beat ResNet on ImageNet and kept improving.
By 2026 the ViT primitive is the unquestioned foundation. Every open-weights VLM's vision tower is some descendant (DINOv2, SigLIP 2, CLIP, EVA, InternViT). The question is no longer "should we use patches?" but "what patch size, what resolution schedule, what pretraining objective, what positional encoding."
#The Concept
#Patches as tokens
Given an image x of shape (H, W, 3) and a patch size P, you carve the image into a grid of (H/P) x (W/P) non-overlapping patches. Each patch is a P x P x 3 cube of pixels. Flatten each cube to a 3 P^2 vector. Apply a shared linear projection W_E of shape (3 P^2, D) to map each patch into the model's hidden dimension D.
For the ViT-B/16 canonical config:
- Resolution 224, patch size 16 → grid 14x14 → 196 patch tokens.
- Each patch is
16 x 16 x 3 = 768pixel values, projected toD = 768. - Add a learnable
[CLS]token → sequence length 197.
The patch projection is mathematically identical to a 2D convolution with kernel size P, stride P, and D output channels. That is how production code actually implements it — nn.Conv2d(3, D, kernel_size=P, stride=P). The "linear projection" framing is conceptual; the kernel framing is efficient.
#Positional embeddings
Patches have no inherent order — the transformer sees them as a bag. Early ViTs added a learnable 1D positional embedding (one 768-dim vector per position, 197 of them). Works, but ties the model to the training resolution: at inference you have to interpolate the position table if you change the grid.
Modern vision backbones use 2D-RoPE (Qwen2-VL's M-RoPE, SigLIP 2's default) or factorized 2D positions. 2D-RoPE rotates the query and key vectors based on the patch's (row, column) index, so the model infers relative 2D position from the rotation angle. No position table. The model handles arbitrary grid sizes at inference.
#CLS token, pooled output, and register tokens
What is the image-level representation? Three choices coexist:
[CLS]token. Prepend a learnable vector to the patch sequence. After all transformer blocks, the CLS token's hidden state is the image representation. Inherited from BERT. Used by original ViT, CLIP.- Mean pool. Average the patch tokens' output hidden states. Used by SigLIP, DINOv2, most modern VLMs.
- Register tokens. Darcet et al. (2023) observed that ViTs trained without an explicit sink token develop high-norm "artifact" patches that hijack self-attention. Adding 4–16 learnable register tokens absorbs this load and improves dense-prediction quality (segmentation, depth). DINOv2 and SigLIP 2 both ship with registers.
The choice matters for downstream tasks. CLS is fine for classification. For VLMs that feed patch tokens into an LLM, you skip pooling entirely — every patch becomes an LLM input token. Registers get discarded before handoff (they are scaffolding, not content).
#Pretraining: supervised, contrastive, masked, self-distilled
The 2020 ViT was pretrained with supervised classification on JFT-300M. Quickly supplanted by:
- CLIP (2021): contrastive image-text on 400M pairs. Lesson 12.02.
- MAE (2021, He et al.): mask 75% of patches, reconstruct pixels. Self-supervised, works on pure images.
- DINO (2021) / DINOv2 (2023): self-distillation with student-teacher, no labels, no captions. The 2023 DINOv2 ViT-g/14 is the strongest purely-visual backbone and the default for "dense features" use cases.
- SigLIP / SigLIP 2 (2023, 2025): CLIP with a sigmoid loss and NaFlex for native aspect ratio. The dominant vision tower in 2026 open VLMs (Qwen, Idefics2, LLaVA-OneVision).
Your choice of pretraining determines what the backbone is good for: CLIP/SigLIP for semantic matching with text, DINOv2 for dense visual features, MAE as a starting point for downstream finetuning.
#Scaling laws
ViT scaling (Zhai et al. 2022) established that a ViT's quality obeys predictable laws in model size, data size, and compute. At fixed compute:
- Bigger model + more data → better quality.
- Patch size is a lever on sequence length vs fidelity. Patch 14 (typical for DINOv2/SigLIP SO400m) gives more tokens per image than patch 16; better for OCR and dense tasks, worse for speed.
- Resolution is the other big lever. Going from 224 to 384 to 512 almost always helps, at quadratic cost in FLOPs.
ViT-g/14 (1B params, patch 14, resolution 224 → 256 tokens) and SigLIP SO400m/14 (400M params, patch 14) are the two workhorse encoders for 2026 open VLMs.
#Parameter count for a ViT
The full calculation lives in code/main.py. For ViT-B/16 at 224:
patch_embed = 3 * 16 * 16 * 768 + 768 = 591k
cls + pos = 768 + 197 * 768 = 152k
block = 4 * 768^2 (QKVO) + 2 * 4 * 768^2 (MLP) + 2 * 2*768 (LN)
= 12 * 768^2 + 3k = 7.1M
12 blocks = 85M
final LN = 1.5k
total ≈ 86MBall-park every ViT this way before you load the checkpoint. The backbone size sets your VRAM floor in any downstream VLM.
#2026 production config
The encoder most open VLMs ship with in 2026 is SigLIP 2 SO400m/14 at native resolution (NaFlex). It has:
- 400M parameters.
- Patch size 14, default resolution 384 → 729 patch tokens per image.
- Mean pool for image-level tasks; all 729 patches flow into the LLM for VQA.
- 4 register tokens, discarded before LLM handoff.
- 2D-RoPE with image-level scaling for native aspect ratio.
Every decision in that config traces back to a paper you can read.
image-patch-tokens#Use It
code/main.py is a patch tokenizer and geometry calculator. It takes (image H, W, patch P, hidden D, depth L) and reports:
- Grid shape and sequence length after patching.
- Token sequence for a synthetic 8x8 pixel toy image (walk through the flatten + project path).
- Parameter count broken down by patch embed, position embed, transformer blocks, and head.
- FLOPs per forward pass at the target resolution.
- A comparison table across ViT-B/16 @ 224, ViT-L/14 @ 336, DINOv2 ViT-g/14 @ 224, SigLIP SO400m/14 @ 384.
Run it. Match the parameter counts to the published numbers. Play with patch size and resolution to feel the token-count cost.
#Ship It
This lesson produces outputs/skill-patch-geometry-reader.md. Given a ViT config (patch size, resolution, hidden dim, depth), it produces a token-count, parameter-count, and VRAM estimate with justifications. Use this skill whenever you pick a vision backbone for a VLM — it prevents "the tokens exploded and my LLM context filled up" surprises.
#Exercises
Compute the patch-token sequence length for Qwen2.5-VL at native 1280x720 input with patch size 14. How does that compare to a CLS-only representation?
A 1080p frame (1920x1080) at patch 14 produces how many tokens? At 30 FPS over a 5-minute video, how many total visual tokens? Which cost saves you most: pooling, frame sampling, or token merging?
Implement mean pooling over patch tokens in pure Python. Verify that mean-pool over 196 tokens of a DINOv2 output matches what the model's
forwardreturns when you ask for a pooled embedding.Read Section 3 of "Vision Transformers Need Registers" (arXiv:2309.16588). Describe in two sentences what artifact the registers absorb and why it matters for downstream dense prediction.
Modify
code/main.pyto support patch-n'-pack: given a list of images of different resolutions, produce a single packed sequence and the block-diagonal attention mask. Verify against Lesson 12.06 when you reach it.
#Key Terms
| Term | What people say | What it actually means |
|---|---|---|
| Patch | "16x16 pixel square" | A fixed-size non-overlapping region of the input image; becomes one token |
| Patch embedding | "Linear projection" | A shared learned matrix (or Conv2d with stride=P) mapping flattened patch pixels to D-dim vectors |
| CLS token | "Class token" | Prepended learnable vector whose final hidden state represents the whole image; optional in 2026 |
| Register token | "Sink token" | Extra learnable tokens that absorb the high-norm attention artifacts ViTs develop during pretraining |
| Position embedding | "Positional info" | Per-position vector or rotation making the sequence-order-aware; 2D-RoPE is the modern default |
| Grid | "Patch grid" | The (H/P) x (W/P) 2D array of patches for a given resolution and patch size |
| NaFlex | "Native flexible resolution" | SigLIP 2 feature: single model serves multiple aspect ratios and resolutions without retraining |
| Backbone | "Vision tower" | The pretrained image encoder whose patch-token outputs feed the LLM in a VLM |
| Pooling | "Image-level summary" | Strategy to turn patch tokens into one vector: CLS, mean, attention pool, or register-based |
| Patch 14 vs 16 | "Finer vs coarser grid" | Patch 14 produces more tokens per image, better fidelity for OCR, slower; patch 16 is the classic default |
#Further Reading
- Dosovitskiy et al. — An Image is Worth 16x16 Words (arXiv:2010.11929) — original ViT.
- He et al. — Masked Autoencoders Are Scalable Vision Learners (arXiv:2111.06377) — MAE, self-supervised pretraining.
- Oquab et al. — DINOv2 (arXiv:2304.07193) — self-distillation at scale, no labels.
- Darcet et al. — Vision Transformers Need Registers (arXiv:2309.16588) — register tokens and artifact analysis.
- Tschannen et al. — SigLIP 2 (arXiv:2502.14786) — the 2026 default vision tower.
- Zhai et al. — Scaling Vision Transformers (arXiv:2106.04560) — empirical scaling laws.
Run this lesson in the Code Lab
A real JupyterLab, with this lesson's code files (and a ready-to-run notebook). The Quick Lab opens instantly in your browser. The Cloud Lab adds a terminal, saved files and the full toolchain.
"""Vision transformer patch tokenizer and geometry calculator — stdlib Python.
Given a ViT config (patch size, resolution, hidden dim, depth, heads), computes:
- grid shape and sequence length after patch tokenization
- per-component parameter count (patch embed, pos, blocks, LN)
- FLOPs per forward (dominated by attention + MLP)
- comparison table across canonical 2026 encoders
Also walks a toy 8x8 grayscale image through the patch-flatten-project pipeline
so the primitive is concrete. No numpy, no torch — just ints and lists.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class ViTConfig:
name: str
image_size: int
patch_size: int
hidden: int
depth: int
heads: int
registers: int = 0
cls_token: bool = True
ZOO = [
ViTConfig("ViT-B/16 @ 224", 224, 16, 768, 12, 12),
ViTConfig("ViT-L/14 @ 336 (CLIP)", 336, 14, 1024, 24, 16),
ViTConfig("DINOv2 ViT-g/14 @ 224", 224, 14, 1536, 40, 24, registers=4),
ViTConfig("SigLIP SO400m/14 @ 378", 378, 14, 1152, 27, 16, registers=4,
cls_token=False),
ViTConfig("Qwen2.5-VL ViT @ 896x896", 896, 14, 1280, 32, 16),
]
def grid_shape(image_size: int, patch_size: int) -> tuple[int, int]:
if image_size <= 0 or patch_size <= 0:
raise ValueError(f"image_size and patch_size must be positive, got {image_size=} {patch_size=}")
if image_size % patch_size != 0:
raise ValueError(f"image_size ({image_size}) must be divisible by patch_size ({patch_size})")
g = image_size // patch_size
return (g, g)
def seq_length(cfg: ViTConfig) -> int:
h, w = grid_shape(cfg.image_size, cfg.patch_size)
extra = (1 if cfg.cls_token else 0) + cfg.registers
return h * w + extra
def patch_embed_params(cfg: ViTConfig) -> int:
p = cfg.patch_size
return 3 * p * p * cfg.hidden + cfg.hidden
def pos_embed_params(cfg: ViTConfig) -> int:
return seq_length(cfg) * cfg.hidden
def cls_register_params(cfg: ViTConfig) -> int:
n = (1 if cfg.cls_token else 0) + cfg.registers
return n * cfg.hidden
def block_params(cfg: ViTConfig) -> int:
d = cfg.hidden
qkvo = 4 * d * d + 4 * d
mlp = 2 * d * 4 * d + d + 4 * d
ln = 2 * 2 * d
return qkvo + mlp + ln
def total_params(cfg: ViTConfig) -> dict:
pe = patch_embed_params(cfg)
po = pos_embed_params(cfg)
cr = cls_register_params(cfg)
bl = block_params(cfg) * cfg.depth
fl = 2 * cfg.hidden
total = pe + po + cr + bl + fl
return {"patch_embed": pe, "position": po, "cls+reg": cr,
"blocks": bl, "final_ln": fl, "total": total}
def flops_per_forward(cfg: ViTConfig) -> int:
n = seq_length(cfg)
d = cfg.hidden
attn = 4 * n * d * d + 2 * n * n * d
mlp = 2 * n * d * 4 * d * 2
return cfg.depth * (attn + mlp)
def fmt(n: int) -> str:
if n >= 1_000_000_000:
return f"{n / 1e9:.2f}B"
if n >= 1_000_000:
return f"{n / 1e6:.1f}M"
if n >= 1_000:
return f"{n / 1e3:.1f}K"
return str(n)
def patch_toy_image() -> None:
"""Walk an 8x8 grayscale image through patch-tokenize with P=4.
Grid is 2x2 → 4 tokens. Each patch is 4x4=16 pixels flat."""
print("\nToy image patch tokenization (8x8 grayscale, patch_size=4)")
print("-" * 60)
img = [[(r * 8 + c) % 256 for c in range(8)] for r in range(8)]
print("pixel grid (row 0..7):")
for row in img:
print(" " + " ".join(f"{v:3d}" for v in row))
P = 4
patches = []
for pr in range(0, 8, P):
for pc in range(0, 8, P):
patch = []
for dr in range(P):
for dc in range(P):
patch.append(img[pr + dr][pc + dc])
patches.append(patch)
print(f"\npatches ({len(patches)} total, each length {P*P}):")
for i, p in enumerate(patches):
print(f" patch {i}: {p}")
fake_W = [[((i + j) % 5) - 2 for j in range(P * P)] for i in range(4)]
embeddings = []
for patch in patches:
emb = []
for row in fake_W:
s = sum(r * v for r, v in zip(row, patch, strict=True))
emb.append(s)
embeddings.append(emb)
print("\nlinear projection (P*P=16 -> hidden=4):")
for i, emb in enumerate(embeddings):
print(f" token {i}: {emb}")
print("→ 4 tokens of dim 4 ready for the transformer.")
def print_config(cfg: ViTConfig) -> None:
params = total_params(cfg)
seq = seq_length(cfg)
gh, gw = grid_shape(cfg.image_size, cfg.patch_size)
fl = flops_per_forward(cfg)
print(f"\n{cfg.name}")
print("-" * 60)
print(f" image : {cfg.image_size}x{cfg.image_size}")
print(f" patch size : {cfg.patch_size}")
print(f" grid : {gh}x{gw}")
print(f" seq length : {seq} (incl {'CLS' if cfg.cls_token else 'no CLS'},"
f" {cfg.registers} registers)")
print(f" hidden / depth : {cfg.hidden} / {cfg.depth}")
print(f" patch embed : {fmt(params['patch_embed'])}")
print(f" position embed : {fmt(params['position'])}")
print(f" blocks total : {fmt(params['blocks'])}")
print(f" ** total params **: {fmt(params['total'])}")
print(f" flops / forward : {fmt(fl)}")
def main() -> None:
print("=" * 60)
print("VIT PATCH-TOKEN GEOMETRY CALCULATOR (Phase 12, Lesson 01)")
print("=" * 60)
patch_toy_image()
for cfg in ZOO:
print_config(cfg)
print("\n" + "=" * 60)
print("KEY RATIOS")
print("-" * 60)
vit_b = ZOO[0]
qwen = ZOO[-1]
print(f" ViT-B/16 @ 224 seq length: {seq_length(vit_b)}")
print(f" Qwen2.5-VL @ 896 seq length: {seq_length(qwen)}")
print(f" ratio: {seq_length(qwen) / seq_length(vit_b):.1f}x more tokens")
print(" That is why high-resolution VLMs need token-merging or pooling.")
if __name__ == "__main__":
main()
Reusable outputs from this lesson. Copy them into your own work.
---
name: patch-geometry-reader
description: Read a ViT config and produce a patch-token, parameter, and VRAM analysis for downstream VLM planning.
version: 1.0.0
phase: 12
lesson: 01
tags: [vit, patch-tokens, dinov2, siglip, vlm-backbone]
---
Given a vision backbone config (patch size, resolution, hidden dim, depth, heads, optional registers), produce a geometry analysis that tells the caller how many tokens this encoder will emit, how much VRAM it costs to run, and whether it is the right pick for a downstream VLM or dense-prediction task.
Produce:
1. Patch grid and sequence length. Grid shape (H/P, W/P). Sequence length including CLS, registers, and any pooling token. Highlight multi-resolution support (NaFlex, AnyRes) when declared.
2. Parameter breakdown. Patch embed, position embed, transformer blocks (attention + MLP), final LN, totals in both exact counts and human-readable (e.g., 86.4M).
3. FLOPs per forward. Attention (4 N D^2 + 2 N^2 D per block) and MLP (16 N D^2 per block), summed across depth. Flag quadratic-in-N costs that will bite at high resolution.
4. VRAM estimate. Activation memory at inference for a single forward on one image, plus KV-equivalent cache if the encoder feeds a downstream LLM.
5. Pooling recommendation. CLS, mean patch, register-based, or skip-pooling-for-VLM, based on the declared downstream task.
Hard rejects:
- Any analysis that treats patch tokens as pixel-identical to the input. The projection is a learned linear map; patches are abstract vectors, not pixels.
- Claiming CLS is always the right pooling. Modern dense-feature and VLM paths skip CLS entirely.
- Treating 2D-RoPE and learned positional embeddings as interchangeable without noting NaFlex-style native-resolution flexibility.
Refusal rules:
- If the provided config declares a patch size that does not evenly divide the image size, refuse — this is not a NaFlex-compatible config without a declared padding scheme.
- If the caller asks for exact pretrained weight counts for proprietary models (Gemini, Claude, GPT-5), refuse — these are not published.
- If the target deployment VRAM is under 4GB for a ViT-g/14-class model, refuse and recommend a SigLIP SO400m/14 or smaller backbone.
Output: a one-page geometry analysis with token count, parameter breakdown, FLOPs estimate, VRAM budget, and a recommended pooling strategy. End with a "what to read next" paragraph pointing to the SigLIP 2 paper (arXiv:2502.14786) for NaFlex details, the DINOv2 paper for dense features, or Lesson 12.06 for patch-n'-pack implementation.
The text and code of this lesson are based on AI Engineering from Scratch by Rohit Ghumare, MIT licensed.