LensLab

LensLab documentation

Everything needed to drive the demo, understand what it is doing, and integrate with it. Written for the person evaluating it, not for the person who built it.

1 · User manual

1.1 Access

LensLab runs on the machine it is installed on; there is no hosted instance, and this documentation site is static. A live walkthrough, or the package to run it on your own hardware, is available on request.

Runs onYour own machine — loopback only by default
ModelLocal ONNX CLIP weights, hash-pinned, downloaded once
EgressNone at runtime

LensLab has no authentication of its own: it is a single-user tool bound to the loopback interface. Publishing an instance means putting an authenticating proxy in front of it — the compose file says so at the line where a reader would be tempted otherwise.

1.2 The Studio — what is this, and does it match my words?

The Studio is the landing page of the demo and answers two different questions.

Identify is the one that needs nothing from you. Drop an image in, or paste one with Ctrl-V, and LensLab scores it against a built-in vocabulary across four independent facets — kind, subject, setting and look. They are scored separately on purpose, so a picture can be both “a screenshot” and “indoors” without those two competing. When nothing scores well the verdict says No confident match and shows its closest guess rather than asserting something wrong.

Score against my labels is the one where you supply the vocabulary. Type any labels at all, one per line — they need not come from any list, and there is no training step. You get confidence bars for a single image, or a contact sheet if you dropped several.

1.3 Library and search

Point Ingest a folder at a directory and LensLab walks it, decodes each supported image, builds a thumbnail, and computes one vector per image. Progress is live. Anything it cannot read is skipped with a stated reason and listed in the skip report — the batch never dies on one bad file. Re-ingesting the same folder is a no-op, because the stored path is unique.

Once ingested, Search takes plain English. “A colourful abstract wallpaper” ranks the library by meaning, not by filename or folder. Every tile also offers What is it? and Similar, both of which reuse the vector computed at ingest — they never look at the pixels again, which is why they return immediately.

1.4 Duplicates

Two different questions, reported separately rather than merged: byte-identical files, and perceptually near-identical images that differ in compression, crop or resize.

1.5 Presets and export

A set of labels you use often can be saved as a preset and reapplied from a dropdown. Every result table exports as CSV or JSON from /api/export/….

2 · A day in the life

A photographer has 40,000 images across a decade of shoots and needs every frame shot against a plain studio backdrop, for a portfolio cut.

  1. Ingest. Point LensLab at the archive. It walks the tree at roughly ten images a second on one core, skipping the three corrupt exports it finds and naming them in the skip report.
  2. Ask in English. Search “a product shot against a plain seamless background”. Results come back ranked by meaning in about a tenth of a second.
  3. Refine with your own words. The wording is not quite right, so score the top candidates against custom labels — “seamless white”, “grey backdrop”, “on location” — and save that as a preset for next time.
  4. De-duplicate. The Duplicates page finds the exports that exist three times at different sizes, and separates those from genuinely similar frames.
  5. Take it away. Export the shortlist as CSV and hand it to the retoucher.

At no point did a file leave the machine, and at no point was there a per-image charge.

3 · Technical architecture

A Python web application (FastAPI) serving server-rendered pages with HTMX, an ONNX build of CLIP running locally on the processor, and one SQLite file. Roughly 1,900 lines of application code.

3.1 Data model

TableHolds
imagepath (unique), sha256, dimensions, bytes, format, mtime, thumbnail path, embedding BLOB, model id, ingest time, job id
label_setnamed label presets and a use counter
ingest_jobroot path, status, totals, error, timestamps
skip_recordjob id, path, and the reason it was skipped
schema_versionone integer, the migration gate

The vector lives in the image row rather than in a separate index. At this scale a linear scan is faster than maintaining an approximate index, and it removes a whole class of drift between an index and the truth. model_id is stored per row because embeddings from different model versions are not comparable — that column is what makes a future model swap detectable rather than silently wrong.

3.2 Trust boundary

3.3 Running it

cd app
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python -m uvicorn app.main:app --port 8000

Then open the app, click Download model once (about 150 MB), and drop an image in the Studio. Under a container, docker compose up publishes to loopback only, mounts ./data read-write for the database, and mounts your picture library read-only. Every knob is documented in .env.example.

4 · API reference

Every endpoint returns an HTML fragment intended for HTMX, except /healthz and the export routes, which return JSON or CSV. Errors are returned as a fragment with the appropriate status code — bad input is a 422, never a 500.

EndpointDoes
GET /healthzJSON: model readiness, image count, database size, and the state of the last ingest job
POST /api/identifyIdentify one or more uploaded images against the built-in vocabulary. No labels field is sent
POST /api/identify/{id}Identify a library image from its stored vector — no decode, no image inference
POST /api/scoreScore uploads against labels (one per line) or a saved preset
GET /api/search?q=Rank the library against a phrase
GET /api/similar/{id}Rank the library against one of its own images, excluding the source
GET /api/duplicatesExact and perceptual duplicate clusters
POST /api/ingestStart an ingest job for a folder
GET /api/ingest/{job}Live progress for a job
GET /api/ingest/{job}/skipsEvery skipped file and its reason
POST /api/ingest/{job}/cancelStop a running job
GET /api/library/gridThumbnail grid fragment, paged
DELETE /api/library/{id}Forget an image. Removes the row, never the file
GET|POST /api/presets, DELETE /api/presets/{id} Manage saved label sets
GET /api/model/status, POST /api/model/download Model readiness, and the one-time hash-checked download
GET /api/export/{kind}.{fmt}Export a result table as csv or json
GET /thumb/{name}Serve a generated thumbnail. Refuses traversal

These paths are served by a LensLab instance running on your own machine, at whatever host and port you start it on.

5 · Troubleshooting

SymptomCause and fix
“The model is not downloaded yet”The weights have not been fetched. Click Download model once; it is about 150 MB and is hash-checked.
Ingest finds nothingOnly .jpg .jpeg .png .webp .gif .bmp are scanned. Check the skip report for the reason each file was passed over.
“No confident match”Working as intended. The image genuinely does not resemble anything in the vocabulary — a flat colour block, for example.
A file was refused as too largeLENSLAB_MAX_UPLOAD_MB and LENSLAB_MAX_PIXELS bound what will be decoded.
The demo asks for the PIN againThe server restarted. Sessions are signed with a per-process secret, so a restart logs demo users out by design.

Back to the overview