# OpenVibe.Vision > See with the best model for the job. **Live site:** https://openvibe.vision · **API docs:** https://openvibe.services · **For AI:** https://openvibe.vision/llms.txt **Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test` **Status:** v1, the §5.8b faces work and the network tools written and tested (see [STATUS.json](STATUS.json)). **Domain:** `openvibe.vision` · **Port:** 5130 · **Service id:** `vision` · **Env prefix:** `VISION` **License:** AGPL-3.0-only (same as every OpenVibe service). OpenVibe.Vision answers questions about a picture. The cheapest engine goes first: the local Tesseract reader answers a read question for nothing; otherwise OpenVibe.AI's routed vision model answers. The same image can be run through the local reader and up to three AI model hints side by side, with the model that actually answered, its cost and its measured latency. The UI check reads HTML, a public URL or a screenshot on this host and returns overflow, cut-off text, low contrast, overlap and layering faults as JSON. Face tools run locally; identifying matches require separate, purpose-scoped consent. ## v1 scope (plan §5.8) - **Ask a picture.** Describe, read, identify, count, translate. Upload a file, send a data URL, or paste a public image URL. Every answer reports the engine, model, provider, route, why it was chosen, the cost and the latency. - **OCR with a local engine first.** Tesseract 5 runs on this host; no image leaves it and no model is called. An AI fallback is opt-in (`fallback=1`) and runs only when the local pass found no usable text. - **Compare models side by side.** One image, the local reader and one to three AI model hints. AI's router is the authority: `requested_model` is only the hint, `model`/`provider` are what AI says answered. Each sample records cost and latency; `GET /api/v1/benchmarks` aggregates them with no subject or answer text. - **The UI check as an API.** `POST /api/v1/ui-check` is what Design's Arena will call, with a service token holding `vision.ui.check`. It also works for a signed-in person and, for HTML/URL/screenshot, anonymously. - **Consent-scoped faces, no looks in AI text.** Face detection and blur are open privacy tools. Matching requires a separate signed consent for each purpose. Public AI text is passed through `noLooks`; an answer that names a person's looks is replaced by a neutral sentence. - **Privacy.** General answers store no image bytes: a sha256, media type and byte count are stored. Opt-in face uploads, vectors and templates are encrypted and private. Answers, compare samples, UI checks and every face row are exported and erased with the person's account. - **Not in v1.** Public Arena leaderboards, video, computer-use benchmarks and OpenCV jobs on Run (v2/v3). v1 has no public records, so it writes no Search document and has no outbox. ## Faces with consent (§5.8b) `npm run models:faces` downloads pinned OpenCV Zoo YuNet and SFace weights into `DATA_DIR/models`, checking a SHA-256 before installation. They are not in git. YuNet's directory is MIT licensed; SFace's directory is Apache 2.0 licensed. **Open question before production distribution:** [SFace weight and training-data provenance](https://github.com/opencv/opencv_zoo/issues/318) needs upstream clarification. The service starts without weights or `VISION_FACE_KEY`; `/api/ready` reports an optional degraded face check. Detect/blur need only YuNet; enrolment, verification and cast matching also need SFace and the key, and answer 503 until they exist. All face inference uses `onnxruntime-node` on this host, never a cloud model. The key is `VISION_FACE_KEY` (base64 encoding of 32 random bytes). AES-256-GCM encrypts templates, Vision uploads and face vectors under the person's Network subject. Losing this key makes stored face data unreadable; rotate it only with a re-encryption migration. `VISION_FACE_MATCH_THRESHOLD` defaults to the OpenCV SFace cosine example threshold 0.363 and needs calibration before consequential use. `VISION_FACE_LIBRARY_REGIONS` is a comma-separated allowlist; empty disables library grouping everywhere. `VISION_FACE_PRIVACY_RATE_LIMIT_PER_MIN` limits anonymous detect/blur, alongside the API address limit. No face template enters Search, model prompts or Bench. The four signed text versions are exposed by `GET /api/v1/faces/consents/text`. A Network person signs one purpose at a time by sending `signed_name` and the current `text_version` to `POST /api/v1/faces/consents/:feature` (`verify`, `find`, `cast`, `library`). Cast also needs `adult: true` and a named `channel`; library needs an allowed `region`. The signed name and server time are kept in `face_consents`. `DELETE /api/v1/faces/consents/:feature` revokes and immediately deletes that purpose's template; cast revocation also removes past tags. `/people` has the same choices and `/people/privacy` explains face retention. The site's shared legal privacy policy remains at `/privacy`. | Route | Result and scope | |---|---| | `POST /api/v1/faces/detect`, `/blur` | Anyone; boxes/count or image/png redaction, no templates or tracking. | | `POST /api/v1/faces/enrol/:feature` | Network person only; one face image after that feature's consent (`verify`, `find`, `cast`). | | `POST /api/v1/faces/verify` | Person only; `{score,match,threshold,liveness:"not_checked"}` against their own verify template. A match alone must never unlock anything: use a tested liveness component, second factor and non-biometric route before any consequential use. | | `POST /api/v1/faces/media`, `GET /api/v1/faces/media/:id`, `POST /api/v1/faces/find` | Person only; encrypted own Vision uploads, owner-only links and self-search matches. Media objects are deferred until a source-owner grant and open-time access check exist. | | `POST /api/v1/faces/cast/match` | Live service token with `vision.face.cast.match` and signed `channel_id` claim; compares only that channel's enrolled roster. Requires on-stream notice from Live. Person can inspect their own tags at `GET /api/v1/faces/cast/tags`. | | `POST /api/v1/faces/library/cluster`, `GET /api/v1/faces/library/groups`, `POST /api/v1/faces/library/correct` | Person only, separate opt-in and allowed region. Unknown groups have no label. Corrections: `exclude`, `merge`, `split`; switching off erases groups. | | `GET/POST /api/v1/faces/libraries`, `GET/PATCH/DELETE /api/v1/faces/libraries/:id` | Person only; the owner's own familiar-face library and its camera ids/region. | | `POST /api/v1/faces/libraries/:id/enroll`, `/match`, `GET /people`, `PATCH/DELETE /people/:personId` | Person only; name a crop, match only this library, rename/mute or delete a name. Strict mode answers `pending_consent` with no template until consent. | | `POST /api/v1/faces/libraries/:id/people/:personId/request`, `/consent-check`, `POST /api/v1/faces/consent-requests/:token/accept` · `/deny` | Person only; the private consent link and the registry re-check. | ### Familiar faces (F1) A camera owner turns on Familiar faces for a camera and names the people that camera sees. `POST /api/v1/faces/libraries` creates the owner's own library (`camera_ids`, `region`), `POST /api/v1/faces/libraries/:id/enroll` names a face from the crop the camera saw, and `POST /api/v1/faces/libraries/:id/match` compares only that library. `DELETE /api/v1/faces/libraries/:id` and `DELETE /api/v1/faces/libraries/:id/people/:personId` delete the templates and the encrypted crops at once. The region is the camera's own setting. `VISION_FACE_STRICT_REGIONS` (default `US-IL,US-TX,US-WA,EU,EU-*,GB,UK,UNKNOWN`) puts a library in strict mode: the named person's `face.recognize` choice is checked in Network's consent registry, and until it allows the `library` feature the person is `pending_consent` with no template. The owner can re-issue a private link (`POST …/people/:personId/request`), the person accepts it at `POST /api/v1/faces/consent-requests/:token/accept` (or denies it), and `POST …/consent-check` re-reads the registry for pending people. A name can be muted (still matched, alerts held). A library whose region is outside the strict list (or explicitly `region_mode: "open"`) names a face freely. F1 adds `face_libraries`, `face_library_people`, `face_library_crops` and `face_consent_requests` in `migrations/0005_face_libraries.sql`, and `face_templates.library_id`. The templates and crops use the same AES-256-GCM key handling as every other face row; a crop is stored only so a strict-mode template can be made after consent, and a delete removes it. `VISION_FACE_CONSENT_REQUEST_DAYS` (default 14) sets the link lifetime. `face_consents`, `face_templates`, `face_media`, `face_tags` and `face_clusters` are created by `migrations/0003_faces.sql`. A daily job (off in `VISION_DRILL`) deletes inactive templates at three years since last use and revoked consent audit rows within one year of purpose end. ADR-033 export and account deletion include all five tables. The account export carries encrypted template and upload ciphertext; it does not expose the server key. ## What runs where | Step | Where | Cost | Leaves the host? | |---|---|---|---| | Image decode, rotate, resize, sha256 | this host, `sharp` | 0 | no | | OCR | this host, Tesseract 5 (`OMP_THREAD_LIMIT=1`) | 0 | no | | UI check (HTML/URL) | this host, static parser + WCAG maths | 0 | the URL fetch only (public HTTP) | | UI check (screenshot) | this host, OCR boxes + `sharp` pixels | 0 | no | | Face detect, blur and consented matching | this host, YuNet + SFace + ONNX Runtime | 0 | no | | Ask / describe / identify / count / translate | OpenVibe.AI's routed vision model | AI's own accounting | the normalised image, to OpenVibe.AI | | OCR AI fallback | OpenVibe.AI's routed vision model | AI's own accounting | only after an empty local pass and with consent | | Compare | local + AI hints | local free; AI's own accounting | the normalised image, to OpenVibe.AI | Vision holds no provider key, adds no provider and calls no paid provider directly. `ai.run.create` is the only outbound capability; AI's quotas and its own route decide whether a run is allowed. ## Owns | Table | Holds | Migration | |---|---|---| | `vision_answers` | `vis_` + ULID, subject (`usr_…` or null), requester, task `ask\|describe\|ocr\|compare`, question, mode, source, public `source_url`, image sha256/mime/bytes, cleaned `text`, `answer` jsonb (engine, reason, words, samples, run id), model/provider/route, cost, latency | `migrations/0001_initial.sql` | | `vision_compare_samples` | `vcm_` + ULID, answer, subject, task, engine `local\|ai`, requested model, answered model/provider/route, ok, text, cost, latency, error code | `0001_initial.sql` | | `vision_ui_checks` | `vuc_` + ULID, subject, requester, source `html\|url\|screenshot`, URL, image sha256, viewport, engine `static-html\|screenshot`, findings jsonb, summary jsonb | `0001_initial.sql` | | `account_data_events` | openvibe-sdk/account-data's export/deletion receipts | `0001_initial.sql` | | `telemetry_samples` | aggregated request telemetry, pruned after 14 days | `0001_initial.sql` | | `face_consents` | subject, feature `verify\|find\|cast\|library`, channel scope, signed text version/name, granted_at, revoked_at, last_used_at, purpose_ends_at | `migrations/0003_faces.sql` | | `face_templates` | subject, feature, scope, AES-GCM ciphertext of the SFace vector, created/last_used times | `0003_faces.sql` | | `face_media` | owner, AES-GCM ciphertext of an opt-in Vision upload and its face vectors, media type | `0003_faces.sql` | | `face_tags` | consenting subject, channel, private media reference and time; removed on cast revocation | `0003_faces.sql` | | `face_clusters` | owner, media, encrypted vector, group id, optional label (null while unknown), excluded flag | `0003_faces.sql` | | `event_outbox` | the events Vision publishes wait here until OpenVibe.Events takes them: the envelope, its own event id (the idempotency key), the attempts and the last error; sent rows are pruned after 7 days | `migrations/0004_event_outbox.sql` | General answer tables store no image bytes, token or provider key. The opt-in face library stores encrypted uploads and vectors. ## Does not own - **Models, routing, quotas and provider keys** — OpenVibe.AI. Vision makes first-party `ai.run.create` runs with namespace `ai.*`; AI decides the provider/model and reports the provenance. - **Identity, roles and accounts** — OpenVibe.Network. Vision verifies session and service tokens against its JWKS. - **The local OCR binary and language data** — the host (Tesseract under `/usr/bin` and `/usr/share`). - **Search** — OpenVibe.Search. v1 has no public records to index. - **Events, the host and the database** — OpenVibe.Events, OpenVibe.Host. ## Capabilities and grants Contracts registers Vision's capabilities; the review routes add three more. The service checks each machine token's grant on its route; private media and face identity operations also require the verified owner or the person's purpose-scoped consent. | Capability | Routes | Typical caller | |---|---|---| | `vision.image.ask` | `POST /api/v1/ask`, `GET /api/v1/answers/:id` | a first-party service or a person | | `vision.text.read` | `POST /api/v1/ocr` (the AI fallback also needs a person or this grant) | a first-party service or a person | | `vision.model.compare` | `POST /api/v1/compare` | a first-party service or a person | | `vision.ui.check` | `POST /api/v1/ui-check`, `GET /api/v1/ui-checks/:id` | **OpenVibe.Design's Arena**, or a person | | `vision.face.cast.match` | `POST /api/v1/faces/cast/match` (a signed `channel_id` claim must match the request) | **OpenVibe.Live**, or a person inspecting their own tags | | `vision.alt.write` | `POST /api/v1/alt` | Pics, Media and Chat | | `vision.media.find` | `POST /api/v1/index`, `GET`/`POST /api/v1/find` (owner user token) | Media and Search | | `vision.receipt.read` | `POST /api/v1/receipt` | Food | | `vision.product.match` | `POST /api/v1/product` | Deals | | `vision.camera.watch` | `POST /api/v1/watch` (owner user token) | Cam | | `vision.video.moments` | `POST /api/v1/moments` (owner user token) | Live and Media | | `vision.face.detect`, `vision.face.blur` | `POST /api/v1/faces/detect`, `/blur` | Public privacy tools; a service token needs the matching grant | | `vision.face.consent.manage`, `vision.face.verify.self`, `vision.face.find.self`, `vision.face.library`, `vision.face.cast.read` | `/api/v1/faces/*` | The person themselves, with separate signed consent for each matching purpose | | `vision.models.read`, `vision.benchmarks.read` | `GET /api/v1/models`, `/benchmarks` | Public reads; a service token needs the matching grant | | `vision.image.review` | `POST /api/v1/review` | Media, Live and Community (the unsafe-image review) | | `vision.review.read`, `vision.review.decide` | `GET /api/v1/review`, `GET /api/v1/review/:id`, `POST /api/v1/review/:id/decide`, `GET /api/v1/review/reports` | OpenVibe staff, and an operator's own service token | Outgoing: **`ai.run.create`** to OpenVibe.AI (audience `openvibe.ai`, namespace `ai.*`), minted by Network for the client `vision`. No new AI capability, no provider key, no direct paid provider. ## MCP and events `POST /mcp` serves Streamable HTTP JSON-RPC 2.0 (`initialize`, `ping`, `tools/list`, `tools/call`). `/.well-known/oauth-protected-resource` advertises its Network authorization server; a private tool without a token responds with a `WWW-Authenticate` challenge pointing to that metadata. The tools are `vision.ask_image`, `vision.read_text`, `vision.translate_sign`, `vision.find_similar`, `vision.search_my_media`, `vision.describe_clip`, `vision.detect_objects`, `vision.blur_faces`, `vision.check_ui`, `vision.compare_models` and `vision.get_job`, and five that run one prompted task each through the routed model (VIS-6; also `task=` on `POST /api/v1/ask`, no question needed): `vision.meal` (`meal`: foods, ingredients, portions, rough calories), `vision.describe_surroundings` (`surroundings`), `vision.identify_plant` (`plant`), `vision.identify_landmark` (`landmark`) and `vision.screenshot_components` (`components`). Face enrolment, verification, cast matching and consent mutation are available only through their person or Live-specific API routes, never through MCP. When configured with Events and a Network client secret, successful owner results publish `vision.result.ready` with provenance, cost and retention, without image or answer text. A watch result with `source_ref` and detections publishes `vision.alert` with classes and confidence. Publishing failures are logged without exposing payloads. ## API JSON under `/api/v1`; errors are `application/problem+json` with a `code`. An image is a multipart `image` field, a JSON `image` data URL, or a JSON `image_url` public URL. A person is the `vision_at` session cookie or a Network user token as a Bearer. A session write must come from `https://openvibe.vision` (403 `vision.cross_site`). A service token must hold the route's capability (403 `capability.denied`). | Route | What it does | |---|---| | `POST /api/v1/ask` (alias `POST /api/v1/describe`) | `{ task?, question, mode?, lang?, model?, image_url? }` or multipart `image`; returns `{ answer }`. A read question is local-first, otherwise AI. | | `POST /api/v1/ocr` | multipart/data URL/URL; `{ fallback?, lang?, model? }`; returns the OCR row with `text`, `answer.confidence`, `answer.words`, cost 0. | | `POST /api/v1/compare` | `{ task?, question?, models?: string[] }` plus an image; returns `{ answer, compare }`, one row per engine. | | `POST /api/v1/ui-check` | `{ html }`, `{ url }`, or a screenshot plus `{ viewport: { w, h } }`; returns `{ check }` with `findings`, `counts`, `severity`, `warnings`. | | `GET /api/v1/answers/:id` | one saved answer: its owner, or a service holding `vision.image.ask`. | | `GET /api/v1/ui-checks/:id` | one saved UI check: its owner, or a service holding `vision.ui.check`. | | `GET /api/v1/models` | AI's offered `provider:model` hints and the local engine. No subject data. | | `GET /api/v1/benchmarks?task=&limit=` | aggregate compare samples: task, engine, provider, model, samples, mean latency, mean cost. No subject or answer text. | | `GET /api/v1/ping`, `/api/health`, `/api/ready`, `/release.json`, `/metrics` | service endpoints; readiness and metrics are truthful, metrics loopback-only. | | `POST /api/v1/review` | `{ image_url }`, an image, or `{ text }` plus `{ object_type, object_id, owner_subject?, preview_url? }`; returns `{ category, plain_words, action, public, status, review, report? }`. Exactly one of `fine`, `sex_acts`, `minor_sexual`. | | `GET /api/v1/review?category=&status=` | the operator's queue (staff or `vision.review.read`); `GET /api/v1/review/:id` is the owner's or staff's single row. | | `POST /api/v1/review/:id/decide` | staff or `vision.review.decide`: `{ category }` must be one of the same three. | | `POST /api/v1/review/:id/reveal` | staff only: one 60-second Media operator view, logged on the report. | | `GET /api/v1/review/reports?status=` | the operator report rows made by category 3. | Example — the call Design's Arena makes: ```sh curl -s -X POST https://openvibe.vision/api/v1/ui-check \ -H "Authorization: Bearer $VISION_SERVICE_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"html":"