· 8 min read

Image Search in C#: Find Photos by Description with CLIP, Offline

Search a folder of photos by typing what is in them, from C#, with no cloud API, no Python and no ONNX export. CLIP runs in your process from one NuGet package. Tested on 1,000 COCO photos: 19 ms per photo to index, and the right photo in the top 5 for 85% of captions.

Type "a dog on a beach" and get back the photos of a dog on a beach, from a folder nobody has tagged. In C#, on your own machine, with one NuGet package.

dotnet add package Kjarni
using Kjarni;

using var index = new ImageIndex();
index.AddDirectory("/home/me/Pictures");

foreach (var hit in index.Search("a dog on a beach", 5))
    Console.WriteLine($"{hit.Score:F3}  {hit.Path}");

The first run downloads the CLIP model (580 MB) and caches it. After that it works with no network. Every number in this post comes from running this code, Kjarni 0.4.2 from NuGet, on 1,000 photos from the COCO dataset.

Try it on your own photos

You need the .NET 8 SDK. Then:

dotnet new console -o PhotoSearch
cd PhotoSearch
dotnet add package Kjarni

Replace Program.cs with:

using Kjarni;

var folder = args.Length > 0 ? args[0] : Environment.GetFolderPath(Environment.SpecialFolder.MyPictures);
var indexFile = Path.Combine(folder, ".photos.kjimg");

using var index = new ImageIndex();
if (File.Exists(indexFile))
{
    Console.WriteLine($"Loaded {index.Load(indexFile)} photos from {indexFile}");
}
else
{
    Console.WriteLine($"Indexing {folder}, this takes a moment the first time...");
    var report = index.AddDirectory(folder);
    index.Save(indexFile);
    Console.WriteLine($"Indexed {report.Added} photos, skipped {report.Skipped}");
}

while (true)
{
    Console.Write("\nSearch: ");
    var query = Console.ReadLine();
    if (string.IsNullOrWhiteSpace(query)) break;

    foreach (var hit in index.Search(query, 5))
        Console.WriteLine($"  {hit.Score:F3}  {hit.Path}");
}

And run it on a folder of photos, or with no argument to use your Pictures folder. Here it is on the COCO photos used in this post:

dotnet run -- ~/coco-photos
Indexing /home/me/coco-photos, this takes a moment the first time...
Indexed 1000 photos, skipped 0

Search: a dog on a beach
  0.311  /home/me/coco-photos/000000052891.jpg
  0.227  /home/me/coco-photos/000000050326.jpg
  ...

The index is saved beside the photos as .photos.kjimg, so the next run starts searching at once. Delete that file to index the folder again. An empty line quits.

The other ways to do it

  • A cloud vision API tags or captions each photo for you. Every photo is uploaded, you pay per image, and the search only knows the tags the service chose.
  • A Python service with PyTorch and Faiss next to your .NET app. It works, but you now deploy and run a second runtime.
  • CLIP exported to ONNX, run with ONNX Runtime. You still write the query tokenizer, the image resize, crop and normalisation, and the search yourself, and they have to match how the model was trained.

ImageIndex does all of that in your process: it decodes JPEG and PNG, prepares each image the way CLIP expects, tokenizes the query and ranks by cosine similarity.

How CLIP finds a photo from a sentence

CLIP is two encoders trained together on hundreds of millions of image and caption pairs: one turns an image into a vector, the other turns text into a vector of the same size. They were trained so that a photo and a sentence describing it end up pointing in nearly the same direction.

photo  -> resize, crop 224x224 -> vision encoder -> 512 numbers ┐
                                                                ├─ cosine similarity
"a dog on a beach" -> tokenizer -> text encoder -> 512 numbers  ┘

So the photos are embedded once, when you index them, and only the query is embedded at search time. Nothing reads filenames, EXIF tags or captions. The pixels are all it uses.

Indexing a folder

using var index = new ImageIndex();

var report = index.AddDirectory(photosDir);
Console.WriteLine($"indexed {report.Added}, skipped {report.Skipped}");

index.Save("photos.kjimg");

AddDirectory walks subfolders, indexes every JPEG and PNG, and returns how many it added and how many it could not read. A corrupt file is skipped and counted, not thrown, so one broken download does not stop a scan of the whole library.

On an Intel i7-13700 (24 threads), with the model already cached:

StepTime
Load the model279 ms
Index 1,000 photos19.3 s, about 19 ms per photo
Save the index18 ms, 6.2 MB on disk
Load the saved index14 ms
Peak memory, model and indexabout 900 MB

Indexing uses every core: photos are decoded and embedded in parallel. Run it on a background thread in a desktop app, because a large library takes minutes.

Save writes the vectors and paths, not the images. Next time, Load the file instead of re-embedding everything:

using var index = new ImageIndex();
if (File.Exists("photos.kjimg"))
    index.Load("photos.kjimg");
else
    index.AddDirectory(photosDir);

To add one new photo, call index.Add(path). Adding the same path again replaces its vector rather than adding a duplicate.

Searching

foreach (var hit in index.Search("a red double decker bus", 3))
    Console.WriteLine($"{hit.Score:F3}  {Path.GetFileName(hit.Path)}");

Here are the top results for a few queries. The description column is what a human wrote about that photo in COCO's annotations. The search never saw those captions; they are here so you can tell whether the hit is right.

QueryScoreWhat the photo shows
a dog on a beach0.311"A shaggy dog is at the beach with a frisbee."
someone surfing0.333"A man in black wetsuit on white surfboard riding wave."
a birthday cake with candles0.292"A hand holding a knife and about to cut a large birthday cake."
a red double decker bus0.339"The red, double decker bus is driving past other buses."
snow covered mountains0.261"A group of skiers have left a trail through the snow on the mountain."
a cat sleeping on a laptop0.352"An orange cat rests on a white counter top."
0.351"A kitten lies down on the keyboard of a laptop."
0.351"A cat resting on an open laptop computer."

The last query shows what to expect when it misses. The top hit is a sleeping cat, but not on a laptop. The two right answers are one thousandth behind it. Show users several results, not just the first.

What the scores mean

The scores are cosine similarities, and with CLIP they are small even for a perfect match: the hits above are all between 0.26 and 0.36. The order matters, but a score does not tell you how confident to be. Do not set a cut-off like "only show results above 0.5"; nothing will pass. If you need to hide weak results, compare each score with the top score of the same query rather than with a fixed number.

How good is it?

Picking queries that work is easy. To measure it, I used COCO's own captions: for each of the 1,000 photos, search with its first human caption and see where that photo ranks among all 1,000 indexed.

Photo rankedShare of captions
First56.9%
In the top 585.4%
In the top 1091.9%

This is a strict test. A caption like "A person that is going out in the water" fits dozens of photos in the set, and when another surfer outranks the one it was written about, the test counts a miss even though a user would be happy. On a larger library the photo you want drops further down for the same reason, so a results grid of 20 or so works better than a list of 3.

A search over the 1,000 photos took 30 ms on average across the 1,000 searches of this test. Most of it is encoding the query with CLIP's text encoder, so it grows slowly with the library: an index of 100,000 entries searched in about 60 ms.

What it does not do

  • Only JPEG and PNG. HEIC from iPhones, WebP, RAW and GIF are not read, and AddDirectory leaves them out of Skipped too, which only counts JPEG and PNG files that failed. Compare Added with your own count of files, and convert the others first.
  • Black-and-white JPEGs need 0.4.2 or newer. Earlier versions skip them and count them in Skipped.
  • No "find photos like this one". Search takes text. There is no image-to-image search in the C# API yet.
  • English queries. This CLIP model was trained on English captions, so write queries in English.
  • No reading of text in images. CLIP sometimes matches a word written in a photo, but do not rely on it to find a receipt by its total or a sign by what it says.
  • No removal. There is no method to take one photo out of the index. If photos are deleted, check File.Exists(hit.Path) before showing a hit, or rebuild the index.

Searching is safe from several threads at once. Adding is not: do not call Add or AddDirectory while another thread searches.

FAQ

Does it need an internet connection?

Only the first time, to download the model. After that, indexing and searching run offline, and no photo or query leaves the machine.

Does it need a GPU or Python?

No. It runs on the CPU, inside your .NET process. The NuGet package holds the native library for Windows x64, Linux x64 and arm64, and macOS on Apple Silicon.

Which .NET versions does it work with?

The package targets .NET 8 and .NET Standard 2.1. These numbers were measured on .NET 8.

How many photos can it handle?

I tested a saved index of 100,000 entries. It loaded in 1.4 s, a search took about 60 ms, and the process peaked at 1.5 GB of memory. The saved file is large, 690 MB, because the vectors are stored as text. Indexing is the slow part: at 19 ms per photo on a 24-thread desktop, 100,000 photos takes about 32 minutes, once.

Which CLIP model is it?

LAION's ViT-B/32, trained on LAION-2B (laion/CLIP-ViT-B-32-laion2B-s34B-b79K). Pass a directory to new ImageIndex(modelDir) to use another CLIP checkpoint in the same format.

About the test photos

The photos are the first 1,000 images, by ID, of the COCO 2017 validation set. The captions are COCO's. The images are Flickr photos under their owners' Creative Commons licences, which is why this post shows captions rather than the photos themselves. See cocodataset.org.

Getting it

NuGet:   dotnet add package Kjarni
Package: https://www.nuget.org/packages/Kjarni
GitHub:  https://github.com/olafurjohannsson/kjarni
Docs:    https://kjarni.ai/docs/

Kjarni is MIT licensed. Questions and bug reports go to GitHub issues.

Kjarni runs the same engine on every platform: