DINOv2: A Complete Guide to Meta AI's Vision Foundation Model for Label-Free Image Embeddings
Hey everyone, in this tutorial I want to introduce you to one of the most important models in computer vision from the past few years, namely DINOv2 by Meta AI. If you often work on computer vision projects, whether it is image classification, image retrieval, segmentation, or even depth estimation, you have probably been frustrated because you need a lot of labeled data first before you can train a good model. This is exactly where DINOv2 comes to the rescue. This model is trained in a self-supervised way, meaning it learns from millions of images without any labels at all, yet the features or embeddings it produces are incredibly strong and can be used for many downstream tasks just by adding a small head on top.
I have used DINOv2 myself in several real projects, from building an image similarity search system to product classification, and honestly the results often beat models I painstakingly trained from scratch. What I love is that we do not need to train the backbone again, we just use its embeddings as features and then train a simple classifier on top. In this tutorial we will cover everything from installation, how to load the model via torch.hub and HuggingFace transformers, how to extract the CLS token and patch embeddings, how to use features for image similarity and retrieval, all the way to training a linear classifier which is called linear probing. So relax, follow along step by step, and I guarantee by the end you will understand exactly how it works.
Introduction: What Is DINOv2 and Why It Matters
DINOv2 is the successor to DINO (self-DIstillation with NO labels) developed by Meta AI. It is called self-supervised learning, which means the model learns visual representations without needing any human annotation. Just imagine, everyone, this model is trained on a massive dataset of hundreds of millions of automatically curated images, and it learns on its own how to "understand" the content of an image. The end result is a foundation model, conceptually similar to large language models but for images.
Why is it called a foundation model? Because this single model can serve as the foundation for many different tasks. The features produced by DINOv2 are general-purpose, meaning they are not specific to one task. You can take its embeddings and use them for:
- Image classification with just linear probing
- Image retrieval or similar-image search based on embedding similarity
- Semantic segmentation by adding a lightweight decoder head
- Depth estimation
- Clustering and other visual analysis
What makes DINOv2 special compared to models like CLIP is that it learns purely from images, without text. CLIP needs image-text pairs, while DINOv2 only needs images. The effect is that DINOv2 features are very rich spatially. It understands local structure within an image really well, which is why it is excellent for dense prediction tasks like segmentation and depth.
The architecture behind DINOv2 is the Vision Transformer (ViT). An image is split into small patches, usually 14x14 pixels per patch, and then each patch is turned into a token and processed through a transformer. There is one special token called the CLS token that serves as a global summary of the entire image, and there are patch tokens that represent each local part of the image. Later we will play with these two types of tokens a lot.
DINOv2 comes in several sizes, everyone, so you can choose based on your needs and resources:
| Model | Name | Parameters | Embedding Dimension |
|-------|------|-----------|---------------------|
| ViT-S/14 | dinov2vits14 | 21 million | 384 |
| ViT-B/14 | dinov2vitb14 | 86 million | 768 |
| ViT-L/14 | dinov2vitl14 | 300 million | 1024 |
| ViT-g/14 | dinov2vitg14 | 1.1 billion | 1536 |
For learning and prototyping, I recommend starting with ViT-S or ViT-B because they are lightweight and fast. If you need maximum accuracy and have a big GPU, then move up to ViT-L or ViT-g. The number 14 at the end of the name means the patch size is 14 pixels.
Installation: Setting Up the Environment
Before we start coding, let us prepare the environment first. I always recommend using a virtual environment so the dependencies stay clean and do not clash with other projects. Here is how to set it up.
First, create a virtual environment and activate it:
python -m venv dinov2-env
source dinov2-env/bin/activate # Linux/Mac
or on Windows: dinov2-env\Scripts\activate
Next install PyTorch. This is the most important component. If you have an NVIDIA GPU, install the CUDA version so it runs fast:
# CUDA version (if you have an NVIDIA GPU)
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121
CPU-only version (if you do not have a GPU)
pip install torch torchvision
Then install the supporting libraries we will need throughout this tutorial:
pip install transformers pillow numpy scikit-learn matplotlib
A quick explanation of each library, everyone:
torchandtorchvisionare the foundation for loading and running the DINOv2 modeltransformersfrom HuggingFace for an easier way to load the modelpillow(PIL) for reading and processing imagesnumpyfor array manipulationscikit-learnfor building a linear classifier and computing cosine similaritymatplotlibfor visualizing results
To check whether PyTorch and your GPU are detected correctly, run this script:
import torch
print("PyTorch version:", torch.version)
print("CUDA available:", torch.cuda.isavailable())
if torch.cuda.isavailable():
print("GPU name:", torch.cuda.getdevicename(0))
If the output shows CUDA available True, your GPU is ready to use. If it shows False that is fine too, DINOv2 still runs on CPU, it is just a bit slower for large models.
Basic Usage: Loading the Model and Extracting Embeddings
Alright everyone, now we get into the most exciting part, extracting embeddings from an image. There are two main ways to load DINOv2, via torch.hub directly from Meta's official repo, or via HuggingFace transformers. I will show you both.
Method 1: Loading via torch.hub
This is the most direct way from Meta AI's official repo. Just one line and the model gets downloaded automatically:
import torch
Load the Small DINOv2 model (the lightest one)
model = torch.hub.load('facebookresearch/dinov2', 'dinov2vits14')
model.eval() # set to evaluation mode
Move it to GPU if available
device = 'cuda' if torch.cuda.isavailable() else 'cpu'
model = model.to(device)
print("Model loaded successfully to", device)
Now let us try extracting features from an image. Before it goes into the model, the image needs to be preprocessed first, that is resized and normalized using ImageNet statistics. Here is the complete code:
import torch
import torchvision.transforms as T
from PIL import Image
Define preprocessing according to the DINOv2 standard
transform = T.Compose([
T.Resize(256, interpolation=T.InterpolationMode.BICUBIC),
T.CenterCrop(224),
T.ToTensor(),
T.Normalize(
mean=(0.485, 0.456, 0.406),
std=(0.229, 0.224, 0.225),
),
])
Read the image and preprocess it
image = Image.open('sampleimage.jpg').convert('RGB')
imgtensor = transform(image).unsqueeze(0).to(device) # add batch dimension
Extract embedding (global CLS token)
with torch.nograd():
features = model(imgtensor)
print("Embedding shape:", features.shape) # [1, 384] for ViT-S
The features output here is the CLS token, a single vector that summarizes the entire image. For ViT-S the dimension is 384, for ViT-B 768, and so on according to the table above. This vector is the "fingerprint" of the image that we can use for various tasks.
Getting the CLS Token and Patch Tokens Together
Sometimes we do not just need the global summary, but also features for each part of the image. For that, DINOv2 has a forwardfeatures method that gives us access to both the CLS token and patch tokens at once:
with torch.nograd():
output = model.forwardfeatures(imgtensor)
output is a dictionary
clstoken = output['xnormclstoken'] # [1, 384] global summary
patchtokens = output['xnormpatchtokens'] # [1, 256, 384] per-patch features
print("CLS token shape:", clstoken.shape)
print("Patch token shape:", patchtokens.shape)
Why are there 256 patches? Because a 224x224 image is divided into 14x14 patches, resulting in 16x16 = 256 patches. Each patch has a 384-dimensional vector (for ViT-S). These patch tokens are super important if you want to work on segmentation or want to understand which part of the image matters.
Method 2: Loading via HuggingFace Transformers
If you are more familiar with the HuggingFace ecosystem, there is an alternative way that in my opinion is cleaner for production because the preprocessing is bundled:
from transformers import AutoImageProcessor, AutoModel
from PIL import Image
import torch
Load the processor and model
processor = AutoImageProcessor.frompretrained('facebook/dinov2-base')
model = AutoModel.frompretrained('facebook/dinov2-base')
device = 'cuda' if torch.cuda.isavailable() else 'cpu'
model = model.to(device).eval()
Process the image
image = Image.open('sampleimage.jpg').convert('RGB')
inputs = processor(images=image, returntensors='pt').to(device)
with torch.nograd():
outputs = model(**inputs)
lasthiddenstate: [1, 257, 768]
index 0 is the CLS token, the rest are 256 patch tokens
lasthidden = outputs.lasthiddenstate
clstoken = lasthidden[:, 0, :] # [1, 768]
patchtokens = lasthidden[:, 1:, :] # [1, 256, 768]
pooleroutput is also available as a summary
pooled = outputs.pooleroutput # [1, 768]
print("CLS token:", clstoken.shape)
print("Patch tokens:", patchtokens.shape)
The nice thing about using HuggingFace is that AutoImageProcessor handles all the correct preprocessing, so you do not need to worry about resizing and normalizing manually. The model names available on HuggingFace include facebook/dinov2-small, facebook/dinov2-base, facebook/dinov2-large, and facebook/dinov2-giant.
Advanced Usage: Retrieval, Similarity, and Linear Probing
Now that we can extract embeddings, it is time to use them for something useful. In this section we cover three practical applications: image similarity, image retrieval, and linear probing for classification.
Image Similarity: Measuring How Similar Two Images Are
The concept is simple, everyone. Two similar images will have embeddings that are close together in vector space. The most common way to measure this is cosine similarity. A value of 1 means very similar, a value of 0 means no relationship, and a value of -1 means opposite.
import torch
import torch.nn.functional as F
def extractembedding(imagepath, model, transform, device):
"""Extract the CLS embedding from a single image."""
image = Image.open(imagepath).convert('RGB')
imgtensor = transform(image).unsqueeze(0).to(device)
with torch.nograd():
emb = model(imgtensor)
return emb
Get embeddings of two images
emb1 = extractembedding('cat1.jpg', model, transform, device)
emb2 = extractembedding('cat2.jpg', model, transform, device)
Compute cosine similarity
similarity = F.cosinesimilarity(emb1, emb2).item()
print(f"Similarity: {similarity:.4f}")
if similarity > 0.6:
print("These two images are similar!")
else:
print("These two images are different.")
I often use this technique for detecting duplicate images or grouping similar product photos. The 0.6 threshold is just an example, you have to tune it yourself based on your data.
Image Retrieval: Building an Image Search System
Now this is the cooler part. Imagine you have a database of thousands of images, then you provide one query image, and the system must return the most similar images. This is called image retrieval, and DINOv2 is really good at it. The idea is we precompute the embeddings of all images in the database, then when a query comes, we compare the query embedding against all the embeddings in the database.
import torch
import torch.nn.functional as F
import os
def buildindex(imagefolder, model, transform, device):
"""Precompute embeddings for all images in a folder."""
embeddings = []
paths = []
for fname in os.listdir(imagefolder):
if fname.lower().endswith(('.jpg', '.png', '.jpeg')):
path = os.path.join(imagefolder, fname)
emb = extractembedding(path, model, transform, device)
embeddings.append(emb)
paths.append(path)
# Concatenate into one tensor and normalize
embeddings = torch.cat(embeddings, dim=0)
embeddings = F.normalize(embeddings, dim=1)
return embeddings, paths
def searchsimilar(querypath, embeddings, paths, model, transform, device, topk=5):
"""Find the topk most similar images to the query."""
qemb = extractembedding(querypath, model, transform, device)
qemb = F.normalize(qemb, dim=1)
# Dot product between normalized embeddings = cosine similarity
scores = torch.mm(qemb, embeddings.t()).squeeze(0)
top = torch.topk(scores, k=min(topk, len(paths)))
results = []
for score, idx in zip(top.values, top.indices):
results.append((paths[idx], score.item()))
return results
Example usage
indexemb, indexpaths = buildindex('imagedatabase/', model, transform, device)
results = searchsimilar('query.jpg', indexemb, indexpaths, model, transform, device, topk=5)
print("Most similar images:")
for path, score in results:
print(f" {path} -> score {score:.4f}")
The important trick here, everyone, is normalizing the embeddings with F.normalize. After normalization, the dot product between two vectors automatically equals the cosine similarity, so we can compute the similarity of all images at once using super fast matrix multiplication. For a very large database (millions of images), you can upgrade to a vector search library like FAISS to make it even faster.
Linear Probing: Training a Simple Classifier on Top of DINOv2
This is the true power of a foundation model. Instead of training the entire model from scratch which needs a lot of data and time, we just extract DINOv2 embeddings (frozen, unchanged), then train a simple linear classifier on top. This technique is called linear probing, and often the result is already very good with just a little labeled data.
The flow goes like this: extract embeddings of all training images, then train a logistic regression or linear layer to map embeddings to class labels. Let us look at two approaches.
The first approach uses scikit-learn, suitable for small to medium datasets:
import torch
import numpy as np
from sklearn.linearmodel import LogisticRegression
from sklearn.metrics import accuracyscore
def extractdatasetfeatures(pathlist, model, transform, device):
"""Extract embeddings for a list of images, return a numpy array."""
features = []
model.eval()
for path in pathlist:
image = Image.open(path).convert('RGB')
t = transform(image).unsqueeze(0).to(device)
with torch.nograd():
emb = model(t)
features.append(emb.cpu().numpy().squeeze(0))
return np.array(features)
Suppose we have training data and labels
trainpaths = ['img1.jpg', 'img2.jpg', '...'] # list of paths
trainlabels = [0, 1, 0] # label for each image
testpaths = ['test1.jpg', 'test2.jpg']
testlabels = [0, 1]
Extract features
Xtrain = extractdatasetfeatures(trainpaths, model, transform, device)
Xtest = extractdatasetfeatures(testpaths, model, transform, device)
Train a linear classifier
clf = LogisticRegression(maxiter=1000, C=1.0)
clf.fit(Xtrain, trainlabels)
Evaluate
pred = clf.predict(Xtest)
accuracy = accuracyscore(testlabels, pred)
print(f"Accuracy: {accuracy:.4f}")
The second approach uses pure PyTorch, suitable if your dataset is large and you want to train on GPU with mini-batches:
import torch
import torch.nn as nn
class LinearProbe(nn.Module):
"""A simple linear classifier on top of DINOv2 embeddings."""
def init(self, inputdim, numclasses):
super().init()
self.fc = nn.Linear(inputdim, numclasses)
def forward(self, x):
return self.fc(x)
Assume Xtrain and ytrain are already in tensor form
Xtrain: [N, 384], ytrain: [N]
Xtraint = torch.tensor(Xtrain, dtype=torch.float32).to(device)
ytraint = torch.tensor(trainlabels, dtype=torch.long).to(device)
probe = LinearProbe(inputdim=384, numclasses=2).to(device)
optimizer = torch.optim.AdamW(probe.parameters(), lr=1e-3, weightdecay=1e-4)
criterion = nn.CrossEntropyLoss()
Training loop
probe.train()
for epoch in range(50):
optimizer.zerograd()
logits = probe(Xtraint)
loss = criterion(logits, ytraint)
loss.backward()
optimizer.step()
if (epoch + 1) % 10 == 0:
print(f"Epoch {epoch+1}, Loss: {loss.item():.4f}")
Inference
probe.eval()
with torch.nograd():
Xtestt = torch.tensor(Xtest, dtype=torch.float32).to(device)
pred = probe(Xtestt).argmax(dim=1)
print("Predictions:", pred.cpu().numpy())
The key point I want to emphasize, everyone: because the DINOv2 backbone is frozen and only the classifier is trained, the training is super fast and needs far less data than training a model from scratch. I once got over 90% accuracy with just a few dozen images per class. This is what makes foundation models so powerful.
Tip: Cache Embeddings So You Do Not Repeat Work
Because extracting embeddings is the most time-consuming part, I always recommend caching the results. Extract once, save to disk, then next time just load it. This saves a lot of time if you keep experimenting back and forth:
import numpy as np
Save embeddings to disk
np.save('trainfeatures.npy', Xtrain)
np.save('trainlabels.npy', np.array(trainlabels))
Load them again anytime
Xtrain = np.load('trainfeatures.npy')
trainlabels = np.load('trainlabels.npy')
Best Practices: Practical Tips from Experience
After using DINOv2 for quite a while across various projects, there are a few things I want to share so you do not fall into the same holes I did.
First, about model size selection. Do not immediately jump to the biggest ViT-g. Start with ViT-S or ViT-B for prototyping. Often ViT-B is already enough for most cases, and the accuracy gap versus ViT-L is not worth the extra computational cost. Move up to a bigger model only if you really need it and have hit a wall.
Second, always use model.eval() and torch.nograd() during inference. This is not just about habit, it genuinely matters. model.eval() disables dropout and makes batch norm consistent, while torch.nograd() disables gradient computation which makes inference much more memory efficient and faster. I once forgot to add torch.nograd() and my GPU ran out of memory even though it was just inference.
Third, about preprocessing. Consistency is key. If you extract the database embeddings using a certain preprocessing, make sure the query uses exactly the same preprocessing. Even a small difference in resize or normalization can make the embeddings mismatch and the retrieval results go haywire. That is why I like using HuggingFace AutoImageProcessor for consistency.
Fourth, normalize embeddings before computing similarity. Like I showed in the retrieval section, L2 normalization with F.normalize makes cosine similarity computation easy and fast. This is a best practice you must apply.
Fifth, for large datasets, think about batching. Do not extract embeddings one by one if you have thousands of images, that is slow. Process per batch so the GPU is used to the maximum:
import torch
from torch.utils.data import DataLoader, Dataset
class ImageDataset(Dataset):
def init(self, paths, transform):
self.paths = paths
self.transform = transform
def len(self):
return len(self.paths)
def getitem(self, idx):
img = Image.open(self.paths[idx]).convert('RGB')
return self.transform(img)
def extractbatch(paths, model, transform, device, batchsize=32):
dataset = ImageDataset(paths, transform)
loader = DataLoader(dataset, batchsize=batchsize, numworkers=4)
allfeatures = []
model.eval()
with torch.nograd():
for batch in loader:
batch = batch.to(device)
emb = model(batch)
allfeatures.append(emb.cpu())
return torch.cat(allfeatures, dim=0)
Sixth, if you need high production performance, consider using mixed precision (FP16) or exporting to ONNX. Mixed precision can reduce memory usage and speed up inference without significantly sacrificing accuracy:
# Inference with mixed precision
with torch.nograd():
with torch.autocast(devicetype='cuda', dtype=torch.float16):
emb = model(imgtensor)
Seventh, for dense tasks like segmentation, do not use the CLS token, use the patch tokens. The CLS token is a global summary, while patch tokens store spatial information for each part of the image. You can reshape the patch tokens from [1, 256, dim] into a grid [16, 16, dim] for visualization or as input to a segmentation decoder.
Eighth, be careful about licensing and usage. DINOv2 is released under a fairly permissive license, but still check the latest terms from Meta before using it commercially. This is important to stay legally safe.
Conclusion
Alright everyone, we have reached the end of the tutorial. We learned a lot today, from the basic concept of DINOv2 as a self-supervised vision foundation model, how to load the model via torch.hub and HuggingFace transformers, how to extract the CLS token and patch embeddings, all the way to real applications like image similarity, image retrieval, and linear probing for classification.
What I most want you to take home from this tutorial is the mindset. In this era of foundation models, we do not always have to train models from scratch painstakingly with mountains of data. Just take a strong pretrained model like DINOv2, extract high-quality embeddings from it, then train a small head on top for your specific task. This approach saves time, saves data, saves resources, and often produces even better results.
DINOv2 is truly a game changer for computer vision, especially for those of you working with limited labeled data. Try experimenting on your own, use images from your own projects, and feel for yourself how powerful the resulting embeddings are. Start with something simple like image retrieval, then move up to linear probing, and once you feel confident, try dense tasks like segmentation.
If you run into difficulties or have questions, do not hesitate to experiment and read the official documentation. Hands-on practice is the best teacher. I hope this tutorial is useful for all of you, and see you in the next tutorial. Happy coding and keep up the spirit of learning!