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.
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.
- 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.
- 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.
- 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.
- De-duplicate. The Duplicates page finds the exports that exist three times at different sizes, and separates those from genuinely similar frames.
- 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
| Table | Holds |
|---|---|
image | path (unique), sha256, dimensions, bytes, format, mtime, thumbnail path, embedding BLOB, model id, ingest time, job id |
label_set | named label presets and a use counter |
ingest_job | root path, status, totals, error, timestamps |
skip_record | job id, path, and the reason it was skipped |
schema_version | one 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
- No egress at runtime. The only outbound request in the product's life is the one-time model download. No fonts, scripts, styles or analytics load from anywhere — this is asserted by a test, and the favicon is inline so even that costs no request.
- Weights are hash-pinned. Every model file is checked against a known SHA-256 before loading. A substituted or truncated download is refused.
- Traversal is confined. Ingest roots are bounded by configuration, paths are resolved before they are trusted, and the thumbnail route refuses traversal.
- Originals are never written. Forgetting an image removes the row, not the file.
- No authentication in the product. Publishing LensLab means putting an authenticating proxy in front of it.
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.
| Endpoint | Does |
|---|---|
GET /healthz | JSON: model readiness, image count, database size, and the state of the last ingest job |
POST /api/identify | Identify 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/score | Score 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/duplicates | Exact and perceptual duplicate clusters |
POST /api/ingest | Start an ingest job for a folder |
GET /api/ingest/{job} | Live progress for a job |
GET /api/ingest/{job}/skips | Every skipped file and its reason |
POST /api/ingest/{job}/cancel | Stop a running job |
GET /api/library/grid | Thumbnail 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
| Symptom | Cause 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 nothing | Only .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 large | LENSLAB_MAX_UPLOAD_MB and
LENSLAB_MAX_PIXELS bound what will be decoded. |
| The demo asks for the PIN again | The server restarted. Sessions are signed with a per-process secret, so a restart logs demo users out by design. |