Technical Deep Dive
How the ML Stack Works
A one-hour walkthrough of the four machine-learning libraries that power ImageLab — what each one does, how it works internally, and exactly how the code uses it. Six modules, each collapsible.
Overview · ~5 min The Pipeline and Where ML Fits
ImageLab processes each photograph through five independent signals. Four of them are deterministic calculations — pixel arithmetic that returns the same answer every time. The fifth is a language model call. The ML libraries sit in the middle of the stack, between fast pixel operations and the expensive language model:
| Signal | Library | Question answered | Deterministic? |
|---|---|---|---|
| SHA-256 / dHash | pillow |
Have I seen this exact file before? Is it a near-duplicate? | Yes |
| CLIP embedding | torch + open-clip |
Is this the same subject from a different angle? | Reproducible |
| OCR text | easyocr |
What text is visible in this image? | Yes |
| Object classes | ultralytics |
What categories of object appear? | Yes |
| Description | anthropic |
What is it, qualitatively, and is it worth keeping? | No |
The layering is intentional: each signal is more expensive than the last, so expensive checks only run on what survives cheaper ones. SHA-256 is free. dHash is ~15 lines of PIL. Embeddings and features are local model inference — no API cost. Descriptions cost money per call, and are cached so changing ranking logic costs nothing.
Module 1 · ~12 min PyTorch — the Computation Framework
What it is
PyTorch is an open-source numerical computing library built around tensors — multidimensional arrays — with two defining properties: automatic differentiation (autograd) and first-class GPU/Apple Silicon support. Most deep learning models in production today are built in PyTorch.
ImageLab does not train anything. It uses PyTorch purely as a runtime: loading pre-trained weights and running forward passes. This is the simplest possible use of the library — but understanding what is happening helps interpret both correctness and performance.
A tensor is a generalisation of matrices to arbitrary dimensions. A grayscale image is a 2D tensor (height × width). An RGB image is 3D (channels × height × width). A batch of 8 RGB images is 4D (batch × channels × height × width).
During training, every operation on a tensor records itself in a computation graph so that gradients can be backpropagated. This is expensive memory-wise. During inference we do not need gradients at all — so ImageLab disables the graph entirely with torch.no_grad(), making inference faster and lighter.
Device selection
PyTorch can execute on CPU or GPU. On Apple Silicon Macs, the unified memory architecture is exposed as the mps (Metal Performance Shaders) backend. ImageLab prefers MPS when available, falling back to CPU:
Lazy model loading
The CLIP weights are ~600 MB. Loading them on every request would be unacceptable. ImageLab loads them exactly once, the first time embed_image() is called, then holds the model in a module-level variable protected by a threading lock:
Three points worth noting: model.eval() changes the behaviour of layers like BatchNormalisation and Dropout — they work differently during training vs inference. model.to(device) moves the entire parameter set to the chosen hardware. torch.set_grad_enabled(False) is a global switch that tells PyTorch not to build the computation graph at all — more aggressive than torch.no_grad() (which is a context manager).
The forward pass
A forward pass is the computation of the model's output given an input. No learning occurs. The input image is preprocessed into a normalised tensor (the preprocess transform handles resize, centre-crop, and channel normalisation), passed through the model, and the resulting feature vector is extracted:
Module 2 · ~12 min open-clip — Contrastive Image–Language Pretraining
What CLIP is
CLIP (Contrastive Language–Image Pretraining) is a model published by OpenAI in 2021, trained on 400 million (image, text) pairs scraped from the internet. The key idea is contrastive learning: given a batch of N image–text pairs, train the model so that the embedding of each image is close to its paired text caption and far from the other N-1 captions in the batch.
open_clip_torch is an open-source reimplementation with weights trained on the LAION-2B dataset (two billion English image–text pairs). ImageLab uses the ViT-B/32 variant trained on the laion2b_s34b_b79k checkpoint.
The vision encoder is a Vision Transformer. The input image is divided into 32×32 pixel patches (hence "B/32"), each patch is projected to a token embedding, and the sequence of patch tokens is processed by a standard transformer. The output is a single 512-dimensional vector representing the image.
The text encoder takes a tokenised string and produces another 512-dimensional vector. Training pushes the image and text vectors for matching pairs close together in this shared space. After training, images of similar concepts end up near each other, even if they look different.
Why L2-normalise?
Cosine similarity measures the angle between two vectors: cos(θ) = a·b / (|a| |b|). If both vectors are L2-normalised to unit length (|a| = |b| = 1), the denominator is always 1, and cosine similarity reduces to a plain dot product: a·b. This makes ranking 250 vectors a single matrix multiply — microseconds, no vector database required.
Text queries and cross-modal search
Because the image and text encoders share a vector space, you can search an image library with a text query. The text is embedded into the same 512-dimensional space, then ranked against every stored image vector by cosine similarity — no fine-tuning required. ImageLab implements this via embed_text():
This is how the Search page's text input works: the query string goes through the text tower, producing a 512-dim vector, which is then dot-producted against every stored image embedding. The results are images whose visual content aligns with the query concept.
Deep dive: what "ViT-B/32" means
The model name encodes the architecture. ViT = Vision Transformer. B = Base size (86M parameters — there are also Small, Large, and Huge variants). /32 = patch size of 32×32 pixels.
A 224×224 input image divided into 32×32 patches gives (224/32)² = 49 patch tokens. A learnable [CLS] token is prepended, making the sequence length 50. The transformer operates over these 50 tokens; the final [CLS] output is the 512-dim image representation.
A larger patch size (ViT-B/16, ViT-L/14) means finer-grained patch tokens and better accuracy, at the cost of more compute. ViT-B/32 is the fast variant — appropriate for a local tool running on CPU or MPS over a library of a few hundred images.
Module 3 · ~10 min EasyOCR — Reading Text in Images
What OCR is
Optical Character Recognition (OCR) converts pixel data containing text into a machine-readable string. Classical OCR (Tesseract) required clean, printed text in a uniform font. Modern neural OCR handles handwriting, stamps, logos, curved text, noise, and arbitrary orientations.
EasyOCR is a Python library that wraps a two-stage deep learning pipeline. It returns a list of detected text strings without requiring any configuration. In ImageLab it is used to extract any readable text from a photograph — part numbers stamped on hardware, labels, markings — and store the result as a searchable string.
Stage 1 uses CRAFT (Character Region Awareness for Text Detection) — a VGG-16 backbone trained to output a heatmap of character regions and another heatmap of character affinities (which characters belong to the same word). Connected-component analysis on these maps produces bounding polygons around text regions.
Stage 2 crops each detected region and passes it to a CRNN: a ResNet feature extractor produces a sequence of column features, a bidirectional LSTM reads the sequence in both directions and models character dependencies, and a CTC (Connectionist Temporal Classification) decoder converts the output probability sequence into a final string without needing character-level alignment labels during training.
How ImageLab uses it
The reader is loaded once, lazily, on the first call, using the same pattern as the CLIP model. The GPU is disabled (gpu=False) because the model is small enough that CPU inference is fast, and it avoids requiring CUDA:
readtext(rgb, detail=0) returns only the text strings, not the bounding boxes or confidence scores. The results are joined into a single space-separated string stored in the features table as ocr_text. This string is indexed for full-text search: if an image contains the text "PART-7734-A", a query for that string will find it directly without an LLM call.
Deep dive: what CTC decoding is
CTC (Connectionist Temporal Classification) solves a specific problem: the CRNN outputs one probability distribution over characters at each time step (each column of the feature map), but we do not know how many time steps each character spans. CTC introduces a special blank token and a rule that collapses repeated characters and blanks into a single character. This lets the model be trained end-to-end on (image, string) pairs without needing per-character position labels.
The beam search decoder finds the most probable string by exploring multiple character hypotheses at each time step and pruning low-probability paths. For short strings on clean images (part numbers, labels) this is fast and reliable.
Module 4 · ~10 min Ultralytics / YOLOv8 — Object Detection
What object detection is
Object detection answers: what categories of object are present and where are they (bounding box)? It is distinct from image classification (what is the single most likely label?) and from segmentation (which pixels belong to each object). Detection is the right tool for asking "does this photo contain a person, a car, a backpack?"
YOLO (You Only Look Once) changed object detection in 2016 by framing it as a single regression problem. Earlier two-stage detectors (R-CNN, Faster R-CNN) first proposed candidate regions, then classified each region — accurate but slow. YOLO passes the image through a backbone network once and directly predicts bounding boxes and class probabilities from a grid of cells.
YOLOv8 (Ultralytics, 2023) is the eighth major revision. The n variant ("nano") has 3.2M parameters and runs at ~80 FPS on a CPU — far faster than the larger models while retaining good accuracy on the 80 COCO object classes (person, car, bicycle, bottle, cup, chair, …). The pre-trained weights file is yolov8n.pt, 6 MB.
How ImageLab uses it
The same lazy-loading pattern as CLIP and EasyOCR. The model is loaded from the local yolov8n.pt file once:
The result is stored as a JSON array in the features table: e.g. [{"class": "bicycle", "confidence": 0.91, "bbox": [42.0, 18.3, 380.1, 290.7]}]. The bounding box is in (x1, y1, x2, y2) pixel coordinates.
Comparing two images by detected objects
For similarity search, what matters is not whether the same object instance appears, but whether the same categories appear. Two photos of bicycles should score higher against each other than a bicycle photo scores against a kitchen photo. Jaccard similarity on the set of detected classes achieves this:
If image A contains {bicycle, person} and image B contains {bicycle, car}, the Jaccard score is |{bicycle}| / |{bicycle, person, car}| = 1/3 ≈ 0.33. If both contain exactly {bicycle, person}, the score is 1.0. This is used as one of the weighted signals in the combined similarity score, alongside cosine embedding distance and dHash Hamming distance.
Module 5 · ~8 min Determinism Across the Stack
Why it matters
A pipeline that produces different results each time you run it is hard to debug, hard to trust, and hard to cache. ImageLab is designed around a clear distinction between what is deterministic (can be run again, will return the same answer, can be stored and trusted as a number) and what is not (should be labelled as an opinion, cached explicitly, never presented as a measurement).
| Operation | Level | Why |
|---|---|---|
sha256, dhash |
Fully deterministic | Pure pixel arithmetic. Same bytes → identical output, every time, on every machine. |
blur_score, color_histogram, edge_histogram |
Fully deterministic | Convolutions and histograms over fixed-format arrays. No randomness anywhere. |
| CLIP / YOLOv8 inference | Reproducible | Deterministic function of weights + input. May differ in the last few decimal places across machines/hardware due to floating-point precision differences in GPU vs CPU implementations. |
| EasyOCR inference | Reproducible | Same as above. Beam search is deterministic given a fixed beam width. |
dominant_palette (k-means) |
Seed-dependent | MiniBatchKMeans uses random_state=42 — deterministic given the same scikit-learn version. Technically reproducible, but depends on an explicit seed. |
| Claude descriptions | Non-deterministic | LLM sampling. Even at temperature 0 there is no bit-for-bit reproducibility guarantee. Cached by (sha256, model, prompt_version) so the same call is never made twice. |
The design rule
The P1 spec states it explicitly about the quality metrics: "Measured quality scores track the deliberately poor images perfectly — they're deterministic, they must." This is a hard requirement, not an aspiration. If a blur score or exposure clip value changes between runs on the same image, something is wrong.
The principle flows through the whole system. Look at how the detail page is designed: deterministic numbers (blur score, aspect ratio, exposure) are shown as numbers with no qualification. Claude's output is clearly labelled as a model opinion from a specific model at a specific prompt version. The two are never mixed.
Inference reproducibility in practice
For CLIP and YOLO, "reproducible" means: given the same model weights and the same input image, the output vector or detection list will be the same. This holds because:
- No sampling or random state is involved in a forward pass.
- The model is in
.eval()mode — BatchNorm uses running statistics, not batch statistics; Dropout is turned off. - Gradients are disabled — the computation is a pure function.
- The only caveat is hardware-level floating-point non-associativity (GPU thread ordering) — this can cause differences in the last 1–2 decimal places across different hardware, but cosine similarity is robust to this.
Module 6 · ~5 min Putting It Together — One Image Through the Pipeline
When you import a folder of images and run a full ingest, each image passes through every stage in sequence. Here is the complete journey, with the library responsible at each step:
| Step | Library | Output stored | Det.? |
|---|---|---|---|
| 1. Read file, compute SHA-256 | hashlib (stdlib) |
image.sha256 |
Yes |
| 2. Read EXIF, correct orientation | pillow |
image.captured_at, camera |
Yes |
| 3. Resize to 1568px working copy, generate 256px thumb | pillow |
Files in cache/working/, cache/thumbs/ |
Yes |
| 4. Compute dHash (perceptual hash) | pillow + numpy |
image.dhash |
Yes |
| 5. Embed image with CLIP | torch + open-clip |
embedding.vector (512-dim BLOB) |
Repr. |
| 6. Extract OCR text | easyocr |
features.ocr_text |
Yes |
| 7. Run YOLO detection | ultralytics |
features.yolo_objects (JSON) |
Yes |
| 8. Compute colour histogram, edges, blur, palette | numpy, scipy, sklearn |
features.* |
Yes |
| 9. Send to Claude for structured description | anthropic |
analysis.result_json |
No |
Cost structure
Steps 1–8 are local and free. Only step 9 costs money, and only when the cache key is new. A full re-run after changing the similarity weights: $0. A full re-run after changing the prompt profile: only new-profile images re-run. This means the cost of experimentation is bounded and predictable.
The separation is what makes this useful as a research tool: you can iterate on the ML-side signals (embeddings, YOLO weights, OCR postprocessing) freely and cheaply, then only spend on Claude when you have something worth describing. The deterministic foundation is what makes the LLM caching trustworthy.