diff --git a/.github/workflows/search-eval.yml b/.github/workflows/search-eval.yml
new file mode 100644
index 0000000..af06fb3
--- /dev/null
+++ b/.github/workflows/search-eval.yml
@@ -0,0 +1,62 @@
+name: Search quality
+
+# Typed search with the real vision-language weights: does a query find the photos
+# it should? Compares SigLIP with SigLIP 2, one phrasing with several, and English
+# with Persian queries, on public labelled images. See scripts/eval_search.py.
+on:
+ workflow_dispatch:
+ push:
+ branches: ["claude/**", "release/**"]
+ paths:
+ - "scripts/eval_search.py"
+ - "scripts/calibrate.py"
+ - "src/vie/models.py"
+ - "src/vie/default.yaml"
+ - ".github/workflows/search-eval.yml"
+
+jobs:
+ eval:
+ name: ${{ matrix.model }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 120
+ strategy:
+ fail-fast: false
+ matrix:
+ model:
+ - google/siglip-so400m-patch14-384
+ - google/siglip2-so400m-patch14-384
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - name: Install the CPU stack
+ run: |
+ python -m pip install --upgrade pip
+ pip install "torch==2.5.1" "torchvision==0.20.1" \
+ --index-url https://download.pytorch.org/whl/cpu
+ pip install -e ".[models,cpu]"
+ pip freeze | grep -iE "^(torch|transformers|tokenizers|sentencepiece)="
+ - name: Cache model weights
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/huggingface
+ key: hf-${{ matrix.model }}-v1
+ - name: Fetch the labelled set
+ run: python scripts/fetch_calibration_data.py benchmarks/search-data --per-label 4
+ - name: Evaluate
+ # Whole frame only: the multi-crop layout is 6x the crops, too slow for a CPU runner.
+ run: python scripts/eval_search.py benchmarks/search-data --model ${{ matrix.model }} --layout 1
+ - name: Food and species thresholds on this model
+ # The food, species and food-gate thresholds were calibrated on SigLIP; this
+ # reports precision and recall at the configured values for each model.
+ run: |
+ python scripts/calibrate.py benchmarks/search-data --model ${{ matrix.model }} \
+ --out benchmarks/calibration-${{ strategy.job-index }}.json
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: search-eval-${{ strategy.job-index }}
+ path: |
+ benchmarks/search-eval-*.json
+ benchmarks/calibration-*.json
diff --git a/.github/workflows/showcase.yml b/.github/workflows/showcase.yml
new file mode 100644
index 0000000..f1a8e11
--- /dev/null
+++ b/.github/workflows/showcase.yml
@@ -0,0 +1,60 @@
+name: Showcase
+
+# Builds the search showcase (food, species, free text, Persian, photos-and-videos
+# concept search) with the real weights on public data, and commits the cards to
+# assets/showcase/search on the same branch. See scripts/make_search_showcase.py.
+on:
+ workflow_dispatch:
+ push:
+ branches: ["claude/**"]
+ paths:
+ - "scripts/make_search_showcase.py"
+ - ".github/workflows/showcase.yml"
+
+permissions:
+ contents: write
+
+jobs:
+ showcase:
+ runs-on: ubuntu-latest
+ timeout-minutes: 120
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.ref_name }}
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - name: Install the CPU stack
+ run: |
+ sudo apt-get update && sudo apt-get install -y libgl1 libglib2.0-0 libfribidi0 libraqm0
+ python -m pip install --upgrade pip
+ pip install "torch==2.5.1" "torchvision==0.20.1" \
+ --index-url https://download.pytorch.org/whl/cpu
+ pip install -e ".[models,cpu,video]" arabic-reshaper python-bidi
+ - name: Cache model weights
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cache/huggingface
+ ~/.cache/torch
+ ~/.insightface
+ key: showcase-models-v1
+ - name: Fetch public data
+ run: |
+ python scripts/fetch_calibration_data.py data/public --per-label 2
+ mkdir -p data/videos
+ for name in classroom face-demographics-walking-and-pause; do
+ curl -sSL -o "data/videos/$name.mp4" \
+ "https://raw.githubusercontent.com/intel-iot-devkit/sample-videos/master/$name.mp4"
+ done
+ - name: Build the showcase
+ run: python scripts/make_search_showcase.py --data data/public --videos data/videos
+ - name: Commit the cards
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add assets/showcase/search
+ git commit -m "docs: search showcase built with the real models" || exit 0
+ git pull --rebase origin "${{ github.ref_name }}"
+ git push origin "HEAD:${{ github.ref_name }}"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d4447d0..580366c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,89 @@
Each version is a git tag. Check one out to get that state of the project.
+## v0.6.0: search the whole archive by what it shows
+
+**One concept search over photos and videos.** `Engine.search_everything("a theatre
+performance")` ranks photos and video moments together on SigLIP's calibrated
+probability that the description fits, keeps the strongest moments in view, and
+draws video tiles with their timestamp. The notebook has a section for it.
+
+**Food and unknown species no longer touch pixels at query time.** Each detected
+animal and dish is encoded at index time, padded exactly as search pads it, in the
+same batched pass as the scene regions (schema v4). A food query, or a species
+outside ImageNet, used to decode every candidate image and run the image tower on
+its crops, so it took minutes on thousands of photos; it is now a matrix product
+over stored vectors through the paths' own ranking rule. An older index is still
+served by cropping.
+
+**Persian queries and SigLIP 2.** `google/siglip2-so400m-patch14-384` is supported
+(its tokenizer declares no length, so the 64 tokens both models trained on are now
+explicit) and understands Persian. A query is averaged over several phrasings
+(`text.query_templates`). `scripts/eval_search.py` and the *Search quality* workflow
+measure both choices with the real weights.
+
+**Measured defaults.** SigLIP 2 is the default vision-language model: equal on English
+queries (mAP 1.0 both), 0.945 against 0.738 on Persian, and with thresholds calibrated
+for it (`model_overrides`, food 0.92, species 0.84) level with SigLIP on food and species
+(precision 0.90–0.94 against 0.89 over two runs; species 0.95 both).
+Each model's thresholds follow it automatically, and a threshold set explicitly still
+wins. A Persian query is encoded as typed, since wrapping it in phrasings never helped.
+`text.min_probability: 0.001` lets an absent concept return nothing. Changing the model
+means `vie index --rebuild`; the index refuses to mix models.
+
+**Video faces at a fixed cost per person.** Each person keeps up to four exemplar
+views (the most typical, then the least alike) and their on-screen intervals,
+instead of a vector for every sighting; face search returns all of a person's
+intervals.
+
+## v0.5.0: one engine, more accurate faces, output worth showing
+
+**Faces, measured against student-face-gate.** `scripts/face_benchmark.py` puts ArcFace
+(this repo) and SFace (the gate) on identical aligned crops. ArcFace wins on every measure
+(d′ 8.08 against 5.86; 96.9% against 83.6% of hard head poses at each model's own
+threshold, with no wrong person accepted by either), so it stays. Two ideas came over from
+the gate, each measured here:
+
+* **Mirror averaging**, on by default (`face.flip_tta`): every face is embedded as seen and
+ mirrored. Hard poses 96.9% -> 97.6%, d′ 8.08 -> 8.43. One extra 112x112 pass per face.
+* **Several query photos**: `vie search face --image a.jpg --image b.jpg`. Each indexed face
+ keeps its best score over the query photos.
+
+Turning the recipe on re-indexes only the images that have faces, not the whole archive.
+
+**`vie.api.Engine`.** Every capability behind one object that loads each model once:
+incremental image and video indexing, the four searches, video search, result cards and
+annotated video. It refuses to prune when most of the archive vanishes at once, which is a
+disconnected drive far more often than a real deletion.
+
+**Output.** `vie.report.showcase` renders a search as a presentation-grade image, cropped
+around the matched subject. `vie.video.render` writes an annotated MP4: tracked faces,
+names from reference photos (with a margin over the runner-up, as in the gate) or grouped
+people, shot labels and a presence timeline, plus a card of who appeared for how long.
+
+**Found in a strict review of the whole codebase, and fixed:**
+
+* **Face indexing was doing 2.2x the work it needed.** InsightFace runs every model in
+ its pack on every face by default, including two landmark models and age/gender that
+ nothing here reads. Loading only detection and recognition: 1144 -> 527 ms on a
+ four-face 1080p frame, with identical faces and vectors.
+* **Unchanged files could be re-hashed forever.** A file whose content matched but
+ whose mtime had moved (a copy, a Drive sync, an extracted zip) was skipped without the
+ new mtime being stored, so every later run read it in full again. A gallery that
+ moved kept pointing at its old paths. Both are now recorded on the first such run.
+* **Pruning is guarded in the pipeline,** so `vie index` is protected too, not only the
+ notebook: if fewer than half of the indexed files are on disk, nothing is deleted.
+* **The annotated video ignored rotation flags** (portrait phone videos came out
+ sideways) and gave up on a damaged file; it now uses the analysis decoder for both.
+* **Persian names and file names** are shaped right to left on cards and videos.
+* Comments that still described CLIP, MobileNetV3 and a 768-d space now describe what
+ runs: SigLIP so400m, ConvNeXt-Tiny and the margin rule.
+
+**`notebooks/0_run_everything.ipynb`.** The whole system with one *Run all* in Colab. The
+index lives on Google Drive, so the first run reads the whole dataset and later runs read
+only new or changed files: 37 photos took 18.4 s, and with one photo added the next run
+took 1.1 s; two videos took 67.8 s the first time and 0.0 s the next. Every stage reports its own failure and the rest carry on.
+
## v0.4.0: video understanding
New `vie.video` package and `vie video` commands: shot detection, keyframes, zero-shot
diff --git a/README.md b/README.md
index 4ff1bb6..7e8e966 100644
--- a/README.md
+++ b/README.md
@@ -26,22 +26,48 @@ Faces, animals, food, plain-English descriptions, and moments inside a video, al
| Ask with | Example | You get |
|:--|:--|:--|
-| A face | `vie search face --image person.jpg` | Every photo of that person |
+| A face | `vie search face --image a.jpg --image b.jpg` | Every photo of that person; more photos find more |
| An animal | `vie search animal --query "red panda"` | Photos with the animal boxed |
| A dish | `vie search food --query "ghormeh sabzi"` | Photos of it, even with no detector class for it |
| A description | `vie search text --query "a man with a blue backpack"` | The closest matches |
+| A concept, everywhere | `Engine.search_everything("a theatre performance")` | Photos and video moments together, ranked on one scale |
| A video | `vie video analyze clip.mp4 --html report.html` | Shots, topics, people, speech and a summary |
+| An annotated video | `Engine.render_video(...)` | An MP4 with every face tracked, named or grouped, on a timeline |
| A moment | `vie video search --query "a goal being scored"` | Timestamps across all your videos |
## See it work
+
+
+
+
+Named from three reference photos each, taken from other recordings. Corridor and classroom: [MP4](assets/showcase/corridor_people.mp4) · [MP4](assets/showcase/classroom_people.mp4)
+
+
+
+| Face search with two query photos | Every face in a classroom, grouped |
+|:--|:--|
+|  |  |
+| **Who was in the video, and for how long** | **Corridor camera** |
+|  |  |
+
+Real output of the current code, regenerated by scripts/make_showcase.py. Footage: intel-iot-devkit/sample-videos, CC BY 4.0.
+
+Screenshots from the original notebooks
+
| Face search | Species search |
|:--|:--|
|  |  |
| **Food search** | **Free-text search** |
|  |  |
-Screenshots from the original notebooks, so the scores shown predate the current scoring.
+
+
+## Run everything in Colab
+
+[](https://colab.research.google.com/github/ParsaVictor/visual-intelligence-engine/blob/main/notebooks/0_run_everything.ipynb)
+
+[`notebooks/0_run_everything.ipynb`](notebooks/0_run_everything.ipynb) runs the whole system with one *Run all*: index, all four searches, video analysis, annotated output videos and presentation cards. The index lives on Google Drive, so **the first run reads the whole dataset and every later run reads only new or changed files**. Measured in the notebook itself on a 4-core CPU: indexing 37 photos took 18.4 s, and after one more photo was added the next run took 1.1 s; two videos took 67.8 s to analyse the first time and 0.0 s the next.
## Architecture
@@ -49,7 +75,7 @@ Faces, animals, food, plain-English descriptions, and moments inside a video, al
Three decisions carry most of the design:
-- **Index once, query cheaply.** A species the classifier knows is answered from stored predictions, with no model loaded at all.
+- **Index once, query cheaply.** A species the classifier knows is answered from stored predictions, with no model loaded at all. Every detected animal and dish also has its vector stored, so a food query or an unknown species is a matrix product, not a pass over the pixels.
- **Ask against rivals.** Instead of thresholding a raw similarity, each query competes with prompts like "a different kind of animal", which gives the model a way to say no.
- **Measure, then decide.** Thresholds and the search backend come from measurements on real data, including where they overturned the original assumptions.
@@ -67,6 +93,8 @@ More in [the architecture](docs/architecture.md), [the design decisions](docs/de
| **[Audit it honestly](docs/AUDIT.md)** | Found 25 defects behind 198 green tests, then fixed them all |
| **Test with real weights** | End-to-end tests, plus a CI job running every real model |
| **Understand video** | Shots, people, speech, timelines and search by moment |
+| **One engine** | Measured face upgrade, annotated output video, one-click Colab notebook |
+| **Search everything** | Concepts across photos and videos, Persian queries, food and species from stored vectors |
Next up: on-screen text search, natural-language summaries and a REST API. See the [roadmap](ROADMAP.md) and [changelog](CHANGELOG.md).
@@ -95,7 +123,44 @@ Every search also takes `--json` for scripts and `--html report.html` for a shar
## Measured
-Precision on 72 public labelled images with the default model, at 100% recall. The set is small and easy, so read this as a comparison between versions, not as expected accuracy on your archive.
+**Faces.** ArcFace (this repo) against SFace (the recogniser in
+[student-face-gate](https://github.com/ParsaVictor/student-face-gate)), on identical aligned crops:
+776 faces, 17,956 same-person pairs (3,716 with a head turn of 30° or more) and 18,472
+different-person pairs. Each model at the threshold its own project ships with.
+
+| Recogniser | Equal error rate | Separation d′ | Same person accepted | Hard poses accepted | Wrong person accepted |
+|:--|--:|--:|--:|--:|--:|
+| **ArcFace + mirror (default)** | **0.0%** | **8.43** | **98.9%** | **97.6%** | **0** |
+| ArcFace | 0.0% | 8.08 | 98.6% | 96.9% | 0 |
+| SFace + mirror | 0.21% | 6.07 | 91.2% | 86.9% | 0 |
+| SFace | 0.20% | 5.86 | 89.8% | 83.6% | 0 |
+
+ArcFace separates people about 40% more widely, so it stays the recogniser. From the gate
+come two measured ideas: averaging each face with its mirror image (now on by default) and
+searching with several photos of a person. This is a pose stress test on public footage, not
+LFW; reproduce it with [`scripts/face_benchmark.py`](scripts/face_benchmark.py), results in
+[`results/face_benchmark.json`](results/face_benchmark.json).
+
+**Typed search, SigLIP vs SigLIP 2** (`scripts/eval_search.py` in the *Search quality*
+workflow, real weights, 14 labelled queries over 92 public photos):
+
+| | SigLIP so400m | **SigLIP 2 so400m (default)** |
+|:--|--:|--:|
+| English query, mAP | 1.000 | 1.000 |
+| Persian query, mAP | 0.738 | **0.945** |
+| Food precision / recall, at each model's thresholds | 0.89 / 1.00 | 0.90–0.94 / 0.98–1.00 |
+| Unknown-species precision / recall | 0.95 / 1.00 | 0.95 / 0.95–1.00 |
+| Best right image of a present concept, calibrated probability (worst case) | 0.023 | 0.009 |
+| Best image of an absent concept (median of 8) | 0.00002 | 0.00001 |
+
+The last two rows set `text.min_probability: 0.001`: a concept that is not in the archive
+returns nothing rather than its least-bad five. SigLIP 2 ranges are over two calibration
+runs whose samples differed slightly (44 and 48 dish images): on food and species the two
+models are level within that noise, and Persian is where SigLIP 2 is clearly ahead. The English set is too easy to separate the
+models or to show what averaging phrasings adds; both score 1.0 either way. Results in
+[`results/search_eval.json`](results/search_eval.json).
+
+**Species and food.** Precision on 72 public labelled images with the default model, at 100% recall. The set is small and easy, so read this as a comparison between versions, not as expected accuracy on your archive.
| Search | v0.2 | Now |
|:--|--:|--:|
@@ -104,7 +169,7 @@ Precision on 72 public labelled images with the default model, at 100% recall. T
Vector search over 100,000 vectors on 4 CPU cores takes **10 ms**, and returns the exact answer.
-Not yet measured: accuracy on a real archive, face recognition quality, and anything on a GPU. Reproduce the numbers above with [`scripts/calibrate.py`](scripts/calibrate.py) and [`scripts/benchmark.py`](scripts/benchmark.py).
+Not yet measured: accuracy on a real archive, and anything on a GPU. Reproduce the numbers above with [`scripts/calibrate.py`](scripts/calibrate.py) and [`scripts/benchmark.py`](scripts/benchmark.py).
## Privacy
diff --git a/assets/showcase/classroom_people.gif b/assets/showcase/classroom_people.gif
new file mode 100644
index 0000000..8fea8d5
Binary files /dev/null and b/assets/showcase/classroom_people.gif differ
diff --git a/assets/showcase/classroom_people.mp4 b/assets/showcase/classroom_people.mp4
new file mode 100644
index 0000000..931e10f
Binary files /dev/null and b/assets/showcase/classroom_people.mp4 differ
diff --git a/assets/showcase/classroom_people.png b/assets/showcase/classroom_people.png
new file mode 100644
index 0000000..02903ab
Binary files /dev/null and b/assets/showcase/classroom_people.png differ
diff --git a/assets/showcase/corridor_people.mp4 b/assets/showcase/corridor_people.mp4
new file mode 100644
index 0000000..40a6b4f
Binary files /dev/null and b/assets/showcase/corridor_people.mp4 differ
diff --git a/assets/showcase/corridor_people.png b/assets/showcase/corridor_people.png
new file mode 100644
index 0000000..3a3625a
Binary files /dev/null and b/assets/showcase/corridor_people.png differ
diff --git a/assets/showcase/desk_named.gif b/assets/showcase/desk_named.gif
new file mode 100644
index 0000000..6391ce9
Binary files /dev/null and b/assets/showcase/desk_named.gif differ
diff --git a/assets/showcase/desk_named.mp4 b/assets/showcase/desk_named.mp4
new file mode 100644
index 0000000..86958a9
Binary files /dev/null and b/assets/showcase/desk_named.mp4 differ
diff --git a/assets/showcase/desk_named.png b/assets/showcase/desk_named.png
new file mode 100644
index 0000000..4134fff
Binary files /dev/null and b/assets/showcase/desk_named.png differ
diff --git a/assets/showcase/face-search.jpg b/assets/showcase/face-search.jpg
new file mode 100644
index 0000000..a0a6c08
Binary files /dev/null and b/assets/showcase/face-search.jpg differ
diff --git a/assets/showcase/search/01-food.jpg b/assets/showcase/search/01-food.jpg
new file mode 100644
index 0000000..aa4a23e
Binary files /dev/null and b/assets/showcase/search/01-food.jpg differ
diff --git a/assets/showcase/search/02-food.jpg b/assets/showcase/search/02-food.jpg
new file mode 100644
index 0000000..8ca3fb5
Binary files /dev/null and b/assets/showcase/search/02-food.jpg differ
diff --git a/assets/showcase/search/03-animal.jpg b/assets/showcase/search/03-animal.jpg
new file mode 100644
index 0000000..a6e7e0b
Binary files /dev/null and b/assets/showcase/search/03-animal.jpg differ
diff --git a/assets/showcase/search/04-animal.jpg b/assets/showcase/search/04-animal.jpg
new file mode 100644
index 0000000..ded6e1a
Binary files /dev/null and b/assets/showcase/search/04-animal.jpg differ
diff --git a/assets/showcase/search/05-text.jpg b/assets/showcase/search/05-text.jpg
new file mode 100644
index 0000000..85c0a1a
Binary files /dev/null and b/assets/showcase/search/05-text.jpg differ
diff --git a/assets/showcase/search/06-concept.jpg b/assets/showcase/search/06-concept.jpg
new file mode 100644
index 0000000..39228e4
Binary files /dev/null and b/assets/showcase/search/06-concept.jpg differ
diff --git a/assets/showcase/search/07-concept.jpg b/assets/showcase/search/07-concept.jpg
new file mode 100644
index 0000000..f5d7501
Binary files /dev/null and b/assets/showcase/search/07-concept.jpg differ
diff --git a/assets/showcase/search/08-concept.jpg b/assets/showcase/search/08-concept.jpg
new file mode 100644
index 0000000..0f86ebf
Binary files /dev/null and b/assets/showcase/search/08-concept.jpg differ
diff --git a/assets/showcase/search/09-concept.jpg b/assets/showcase/search/09-concept.jpg
new file mode 100644
index 0000000..6110557
Binary files /dev/null and b/assets/showcase/search/09-concept.jpg differ
diff --git a/assets/showcase/search/report.json b/assets/showcase/search/report.json
new file mode 100644
index 0000000..d0a5ca6
--- /dev/null
+++ b/assets/showcase/search/report.json
@@ -0,0 +1,163 @@
+{
+ "model": "google/siglip2-so400m-patch14-384",
+ "searches": [
+ {
+ "card": "01-food.jpg",
+ "kind": "food",
+ "query": "beef tartare",
+ "matches": 2,
+ "milliseconds": 1068.2,
+ "top": [
+ [
+ "food/beef_tartare_0.jpg",
+ 0.9995
+ ],
+ [
+ "food/beef_tartare_1.jpg",
+ 0.9987
+ ]
+ ]
+ },
+ {
+ "card": "02-food.jpg",
+ "kind": "food",
+ "query": "beignets",
+ "matches": 3,
+ "milliseconds": 434.3,
+ "top": [
+ [
+ "food/beignets_1.jpg",
+ 0.9999
+ ],
+ [
+ "food/beignets_0.jpg",
+ 0.9999
+ ],
+ [
+ "food/french_fries_0.jpg",
+ 0.9776
+ ]
+ ]
+ },
+ {
+ "card": "03-animal.jpg",
+ "kind": "animal",
+ "query": "cat",
+ "matches": 1,
+ "milliseconds": 8.7,
+ "top": [
+ [
+ "animal/cat_2.jpg",
+ 0.3769
+ ]
+ ]
+ },
+ {
+ "card": "04-animal.jpg",
+ "kind": "animal",
+ "query": "a puppy",
+ "matches": 2,
+ "milliseconds": 1061.5,
+ "top": [
+ [
+ "animal/dog_2.jpg",
+ 0.9703
+ ],
+ [
+ "animal/dog_0.jpg",
+ 0.9059
+ ]
+ ]
+ },
+ {
+ "card": "05-text.jpg",
+ "kind": "text",
+ "query": "a bowl of soup",
+ "matches": 0,
+ "milliseconds": 1363.4,
+ "top": []
+ },
+ {
+ "card": "06-concept.jpg",
+ "kind": "concept",
+ "query": "people sitting in a classroom",
+ "matches": 5,
+ "milliseconds": 2299.2,
+ "top": [
+ [
+ "classroom @ 0:10",
+ 0.415
+ ],
+ [
+ "stills/classroom_004.0.jpg",
+ 0.4105
+ ],
+ [
+ "classroom @ 0:28",
+ 0.3236
+ ],
+ [
+ "stills/classroom_014.0.jpg",
+ 0.2709
+ ],
+ [
+ "stills/classroom_024.0.jpg",
+ 0.1761
+ ]
+ ]
+ },
+ {
+ "card": "07-concept.jpg",
+ "kind": "concept",
+ "query": "گربه",
+ "matches": 3,
+ "milliseconds": 432.9,
+ "top": [
+ [
+ "animal/cat_1.jpg",
+ 0.014
+ ],
+ [
+ "animal/cat_0.jpg",
+ 0.0036
+ ],
+ [
+ "animal/cat_2.jpg",
+ 0.0026
+ ]
+ ]
+ },
+ {
+ "card": "08-concept.jpg",
+ "kind": "concept",
+ "query": "a theatre stage with actors",
+ "matches": 0,
+ "milliseconds": 1368.1,
+ "top": []
+ },
+ {
+ "card": "09-concept.jpg",
+ "kind": "concept",
+ "query": "تارتار گوشت",
+ "matches": 3,
+ "milliseconds": 433.6,
+ "top": [
+ [
+ "food/beef_tartare_1.jpg",
+ 0.7074
+ ],
+ [
+ "food/beef_tartare_0.jpg",
+ 0.4378
+ ],
+ [
+ "food/beignets_1.jpg",
+ 0.0029
+ ]
+ ]
+ }
+ ],
+ "index": "27 found · 27 newly indexed · 0 unchanged (skipped) · 0 removed · 0 failed · 1544.8s",
+ "videos": "scanned=1 indexed=1 skipped=0 pruned=0 failed=0",
+ "index_seconds": 1586.8
+}
\ No newline at end of file
diff --git a/docs/design-decisions.md b/docs/design-decisions.md
index 90e72ad..c2d44b2 100644
--- a/docs/design-decisions.md
+++ b/docs/design-decisions.md
@@ -187,3 +187,81 @@ A video can take minutes to analyse, so an optional stage that fails produces a
on the report rather than an exception that discards the result. This came from a real
run, in which Whisper running out of memory threw away a finished analysis. The same idea
is why indexing commits periodically and retries a failed batch image by image.
+
+## ArcFace over SFace, with two ideas taken from SFace's project
+
+[student-face-gate](https://github.com/ParsaVictor/student-face-gate) recognises faces with
+SFace (128-d, 39 MB, about 12 ms a face on a laptop CPU) because it must run on a gate PC
+with no GPU. This system uses ArcFace from InsightFace `buffalo_l` (512-d, about 6x the CPU
+cost). Accuracy decides here, so the two were measured head to head on identical aligned
+crops (`scripts/face_benchmark.py`):
+
+| | ArcFace | SFace |
+|:--|--:|--:|
+| Separation d′ between same and different people | **8.08** | 5.86 |
+| Hard head poses accepted at the shipped threshold | **96.9%** | 83.6% |
+| Equal error rate | **0.0%** | 0.2% |
+
+ArcFace stays. What the gate does better is not the model but how it uses it, and two of
+those habits carry over:
+
+* **Mirror averaging.** Embed the aligned face and its mirror image, sum, normalise. A
+ turned face then carries evidence from both sides. Measured here: hard poses 96.9% ->
+ 97.6%, d′ 8.08 -> 8.43, for one extra 112x112 pass per face, which is small next to
+ detection. On by default as `face.flip_tta`.
+* **Several photos of the person.** The gate enrols three photos and keeps the best match;
+ with a fixed threshold that took accepted visits from 86.1% to 96.3% with no wrong person
+ admitted. Here the same rule applies at query time: `--image` may be repeated.
+
+The index records which recipe produced its face vectors. Changing it re-indexes only the
+images that contain faces, so the upgrade does not cost a full rebuild.
+
+The benchmark is a pose stress test on public CC BY 4.0 footage, not LFW. It has two
+labelled identities for genuine pairs and uses "two faces in one frame are two people" for
+impostors, so its absolute numbers are optimistic; the comparison between models is the point.
+
+## Object vectors at index time, not crops at query time
+
+Food search and the out-of-vocabulary species path used to work like this: take
+every candidate image from SQL, decode it, crop each stored box, and run the image
+tower of the vision-language model on every crop, for every query. That is correct,
+and its cost grows with the archive: on thousands of photos one query took minutes.
+
+Since v0.6 each detected animal and dish is encoded once, at index time, in the same
+batched forward pass as the multi-crop regions, and stored in `object_embeddings`
+with its box, padded crop, confidence and class. The padding is the same function the
+search paths apply (`vie.pipeline.object_regions`), so a stored vector is the vector
+the crop path would have computed; a test checks the two paths agree on scores.
+
+At query time the stored vectors are handed to the paths' own ranking functions
+through a scorer over vectors (`_StoredVectors` in `vie.query`), so the decision rule,
+its rival prompts and its thresholds are unchanged. Only the work moved. The price is
+one extra vector per detected object, about 4.6 KB with SigLIP so400m, and one extra
+crop per object in the indexing batch.
+
+Images indexed before carry `objects_embedded = 0` and are still served by cropping.
+
+## One concept search over photos and videos
+
+A person looking for "a theatre performance" does not care whether it is in a photo
+or a film. `Engine.search_everything` scores both with the same text vector and ranks
+them on SigLIP's calibrated probability that the description fits, which is on one
+scale for both. Photos are scored by their best region and keyframes by their whole
+frame only (multi-crop for every keyframe would multiply video storage by six), so
+keyframes read slightly lower; the strongest few moments are therefore always kept.
+
+## A query is several phrasings, averaged
+
+"theatre", "a photo of theatre" and "a scene with theatre" land in slightly different
+places in the embedding space. Averaging them (prompt ensembling, `text.query_templates`)
+describes the concept more steadily than any single wording. `scripts/eval_search.py`
+measures it against the single phrasing on labelled photos with the real weights, for
+SigLIP and SigLIP 2, in English and Persian.
+
+## Faces in video: exemplars, not every sighting
+
+Storing a vector per face per keyframe costs up to 240 vectors per person per video,
+and every face search loaded them all. A person is a cluster already, so it is kept as
+up to four exemplar views, the most typical first and then the least alike
+(farthest-point sampling), plus the intervals they are on screen. A face search
+matches a person on any exemplar and returns all of their intervals.
diff --git a/notebooks/0_run_everything.ipynb b/notebooks/0_run_everything.ipynb
new file mode 100644
index 0000000..c61175c
--- /dev/null
+++ b/notebooks/0_run_everything.ipynb
@@ -0,0 +1,719 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "a2bde7e7",
+ "metadata": {},
+ "source": [
+ "\n",
+ "\n",
+ "# 🚀 سیستم کامل — همهچیز با یک «Run all»\n",
+ "\n",
+ "[](https://colab.research.google.com/github/ParsaVictor/visual-intelligence-engine/blob/main/notebooks/0_run_everything.ipynb)\n",
+ "\n",
+ "این نوتبوک کل سیستم را از اول تا آخر اجرا میکند:\n",
+ "\n",
+ "| مرحله | چه میکند |\n",
+ "|:--|:--|\n",
+ "| ۱. آمادهسازی | Drive را وصل میکند، GPU را پیدا میکند، بسته را نصب میکند |\n",
+ "| ۲. ایندکس عکسها | **بار اول همهی دیتاست را میخواند؛ از بار دوم فقط عکسهای جدید یا تغییرکرده را** |\n",
+ "| ۳. جستجو | چهره، حیوان، غذا و متن آزاد، با خروجی تصویری آمادهی ارائه. غذا و گونهی ناشناخته از بردارهای ذخیرهشده جواب داده میشوند و هیچ عکسی باز نمیشود |\n",
+ "| ۴. ویدیو | تحلیل ویدیوها، گزارش HTML و **ویدیوی خروجی با کادر چهره، نام افراد و خط زمانی** |\n",
+ "| ۵. جستجوی مفهوم | یک توضیح («تئاتر»، «کلاس درس») و همهی عکسها و لحظههای ویدیو که آن را نشان میدهند، در یک فهرست |\n",
+ "| ۶. خلاصه | همهی خروجیها در Drive، با زمان هر مرحله |\n",
+ "\n",
+ "## نحوهی اجرا (از گوشی هم میشود)\n",
+ "\n",
+ "1. منوی **Runtime → Change runtime type → T4 GPU**\n",
+ "2. عکسها را در Drive در پوشهی `MyDrive/vie/dataset` بگذارید (پوشه یا یک فایل `zip`). زیرپوشه هم اشکالی ندارد.\n",
+ "3. **Runtime → Run all**. اگر Drive اجازه خواست، تأیید کنید.\n",
+ "\n",
+ "اگر پوشهی دیتاست خالی باشد، نوتبوک یک مجموعهی کوچک عمومی دانلود میکند تا همهی مراحل باز هم اجرا شوند.\n",
+ "\n",
+ "## چرا بار دوم خیلی سریع است\n",
+ "\n",
+ "ایندکس روی **Google Drive** ذخیره میشود، نه روی دیسک موقت Colab که با هر بار بسته شدن پاک میشود.\n",
+ "هر عکس با اندازه و زمان تغییرش شناخته میشود؛ عکسی که عوض نشده **اصلاً باز نمیشود**،\n",
+ "و اگر هیچ عکس جدیدی نباشد **هیچ مدلی هم بار نمیشود**. اگر وسط کار قطع شود، کار انجامشده هر چند دقیقه در Drive ذخیره شده و ادامهی کار از همانجا شروع میشود.\n",
+ "\n",
+ "## دقت\n",
+ "\n",
+ "| بخش | مدل | چرا |\n",
+ "|:--|:--|:--|\n",
+ "| چهره | ArcFace (InsightFace `buffalo_l`) **+ میانگین با تصویر آینهای** | در مقایسهی مستقیم با SFace (ریپازیتوری گیت دانشجویی)، روی چهرههای با زاویهی سخت ٪۹۷٫۶ در برابر ٪۸۳٫۶ و بدون هیچ پذیرش اشتباه. جزئیات در `results/face_benchmark.json` |\n",
+ "| جستجوی چهره با چند عکس | بیشترین شباهت بین عکسهای پرسوجو | در گیت دانشجویی از ٪۸۶ به ٪۹۶ رسید |\n",
+ "| متن، حیوان، غذا | SigLIP 2 روی GPU | اندازهگیری با مدل واقعی. جستجوی انگلیسی برابر با SigLIP (mAP ۱٫۰)، جستجوی فارسی ۰٫۹۴۵ در برابر ۰٫۷۳۸، و در غذا و حیوان همسطح. روی CPU مدل سبکتر انتخاب میشود |\n",
+ "| «چیزی پیدا نشد» | کف احتمال ۰٫۰۰۱ | مفهومی که در آرشیو نیست، به جای چند نتیجهی بیربط، هیچ نتیجهای برنمیگرداند |\n",
+ "| جستجوی متنی | میانگین چند جملهبندی از هر پرسوجو | توصیف پایدارتر یک مفهوم نسبت به یک جملهی تنها |\n",
+ "\n",
+ "
"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9107f47f",
+ "metadata": {},
+ "source": [
+ "\n",
+ "\n",
+ "## ⚙️ تنظیمات\n",
+ "\n",
+ "**فقط همین سلول را تغییر دهید.** بقیهی سلولها خودکارند.\n",
+ "\n",
+ "
"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "efc3b663",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# ═══════════════════ تنظیمات ═══════════════════\n",
+ "DRIVE_ROOT = \"/content/drive/MyDrive/vie\" # همهچیز اینجا میماند و بین اجراها حفظ میشود\n",
+ "DATASET = f\"{DRIVE_ROOT}/dataset\" # پوشهی عکسها، یا مسیر یک فایل .zip\n",
+ "VIDEOS = f\"{DRIVE_ROOT}/videos\" # ویدیوها (اختیاری)\n",
+ "FACE_QUERIES = f\"{DRIVE_ROOT}/queries\" # queries/<نام فرد>/*.jpg ← ۱ تا ۳ عکس از هر نفر\n",
+ "KNOWN_PEOPLE = f\"{DRIVE_ROOT}/people\" # people/<نام>/*.jpg ← برای نامگذاری افراد در ویدیو\n",
+ "\n",
+ "ANIMAL_QUERIES = [\"cat\", \"dog\", \"bird\", \"horse\", \"zebra\"]\n",
+ "FOOD_QUERIES = [\"pizza\", \"kebab\", \"salad\", \"cake\"]\n",
+ "TEXT_QUERIES = [\"a man with a blue backpack\", \"people smiling at a party\",\n",
+ " \"a sunset over the sea\", \"a person reading a book\"]\n",
+ "VIDEO_QUERIES = [\"people talking\", \"a person walking in a corridor\"]\n",
+ "# جستجوی مفهوم: یک توضیح، همهی عکسها و لحظههای ویدیو که آن را نشان میدهند.\n",
+ "# با مدل پیشفرض روی GPU (SigLIP 2) فارسی هم میشود نوشت.\n",
+ "CONCEPT_QUERIES = [\"a theatre performance on stage\", \"people in a classroom\",\n",
+ " \"a celebration with friends\", \"تئاتر\", \"کلاس درس\"]\n",
+ "\n",
+ "QUALITY = \"auto\" # auto | best | english | fast (auto: SigLIP 2 روی GPU که فارسی هم میفهمد، مدل سبک روی CPU)\n",
+ "RENDER_VIDEOS = True # ساخت ویدیوی خروجی با کادر و نام افراد\n",
+ "MAX_RENDER_SECONDS = 180 # سقف طول هر ویدیوی خروجی، برای صرفهجویی در زمان\n",
+ "DEMO_IF_EMPTY = True # اگر دیتاست خالی بود، دادهی نمونهی عمومی دانلود شود\n",
+ "REPO_REF = \"claude/image-processing-optimization-7abxze\" # شاخه، برچسب یا commit؛ اگر نبود main\n",
+ "# نسخهی v0.5.0 (پیش از جستجوی مفهوم و SigLIP 2): REPO_REF = \"c3ef80c8e11f722908f1463eb7e950cd029cd7dd\""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "19bb0656",
+ "metadata": {},
+ "source": [
+ "\n",
+ "\n",
+ "## ۱. آمادهسازی\n",
+ "\n",
+ "بار اول ۳ تا ۵ دقیقه طول میکشد. هر خطایی که پیش بیاید با پیام فارسی نشان داده میشود و اجرا متوقف نمیشود.\n",
+ "\n",
+ "
"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "31321d9d",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import os, sys, json, time, shutil, sqlite3, subprocess, traceback, contextlib\n",
+ "from pathlib import Path\n",
+ "\n",
+ "IN_COLAB = \"google.colab\" in sys.modules\n",
+ "if IN_COLAB:\n",
+ " from google.colab import drive\n",
+ " drive.mount(\"/content/drive\")\n",
+ "elif DRIVE_ROOT.startswith(\"/content/\"):\n",
+ " DRIVE_ROOT = os.path.abspath(\"vie_drive\") # خارج از Colab: یک پوشهی محلی\n",
+ " DATASET, VIDEOS = f\"{DRIVE_ROOT}/dataset\", f\"{DRIVE_ROOT}/videos\"\n",
+ " FACE_QUERIES, KNOWN_PEOPLE = f\"{DRIVE_ROOT}/queries\", f\"{DRIVE_ROOT}/people\"\n",
+ "\n",
+ "GPU = subprocess.run(\"nvidia-smi -L\", shell=True, capture_output=True, text=True)\n",
+ "HAS_GPU = GPU.returncode == 0 and \"GPU\" in GPU.stdout\n",
+ "print(\"GPU:\", GPU.stdout.strip() if HAS_GPU else \"ندارد — روی CPU کندتر اجرا میشود \"\n",
+ " \"(Runtime → Change runtime type → T4 GPU)\")\n",
+ "\n",
+ "SRC = Path(\"/content/visual-intelligence-engine\" if IN_COLAB else \"vie-src\").resolve()\n",
+ "URL = \"https://github.com/ParsaVictor/visual-intelligence-engine.git\"\n",
+ "def fetch_ref(ref):\n",
+ " \"\"\"یک شاخه، برچسب یا شناسهی commit را در SRC میگذارد.\"\"\"\n",
+ " shutil.rmtree(SRC, ignore_errors=True)\n",
+ " if subprocess.run([\"git\", \"clone\", \"-q\", \"--depth\", \"1\", \"-b\", ref, URL, str(SRC)]).returncode == 0:\n",
+ " return True\n",
+ " # شناسهی commit با clone -b نمیآید؛ مستقیم fetch میشود.\n",
+ " shutil.rmtree(SRC, ignore_errors=True)\n",
+ " SRC.mkdir(parents=True)\n",
+ " git = lambda *a: subprocess.run([\"git\", \"-C\", str(SRC), *a], capture_output=True).returncode == 0\n",
+ " return git(\"init\", \"-q\") and git(\"remote\", \"add\", \"origin\", URL) and \\\n",
+ " git(\"fetch\", \"-q\", \"--depth\", \"1\", \"origin\", ref) and git(\"checkout\", \"-q\", \"FETCH_HEAD\")\n",
+ "\n",
+ "if not fetch_ref(REPO_REF):\n",
+ " print(f\"⚠️ نسخهی {REPO_REF} پیدا نشد؛ از main استفاده میشود\")\n",
+ " assert fetch_ref(\"main\"), \"دریافت کد از گیتهاب ممکن نشد\"\n",
+ "print(\"کد:\", subprocess.run([\"git\", \"-C\", str(SRC), \"log\", \"-1\", \"--format=%h %s\"],\n",
+ " capture_output=True, text=True).stdout.strip())\n",
+ "\n",
+ "def pip(*args):\n",
+ " r = subprocess.run([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", *args],\n",
+ " capture_output=True, text=True)\n",
+ " if r.returncode:\n",
+ " print(r.stderr[-2000:])\n",
+ " return r.returncode == 0\n",
+ "\n",
+ "t0 = time.time()\n",
+ "pip(\"-e\", f\"{SRC}[models,video]\")\n",
+ "# دقیقاً یکی از دو نسخهی onnxruntime، وگرنه با هم تداخل دارند\n",
+ "subprocess.run([sys.executable, \"-m\", \"pip\", \"uninstall\", \"-y\", \"-q\", \"onnxruntime\", \"onnxruntime-gpu\"],\n",
+ " capture_output=True)\n",
+ "pip(\"onnxruntime-gpu\" if HAS_GPU else \"onnxruntime\")\n",
+ "pip(\"arabic-reshaper\", \"python-bidi\") # نوشتن درست نامهای فارسی روی تصویر، اگر Pillow خودش نتواند\n",
+ "\n",
+ "# ── بررسی نصب در یک فرایند تازه، تا خطای نصب همینجا دیده شود نه وسط کار ──\n",
+ "CHECK = \"\"\"\n",
+ "import cv2, numpy, onnxruntime, insightface, torch, transformers, av, PIL\n",
+ "assert cv2.imdecode(numpy.frombuffer(cv2.imencode('.png', numpy.zeros((4, 4, 3), 'uint8'))[1], 'uint8'), 1) is not None\n",
+ "print('opencv', cv2.__version__, '| torch', torch.__version__, '| cuda', torch.cuda.is_available(),\n",
+ " '| onnx', onnxruntime.get_available_providers()[:2])\n",
+ "\"\"\"\n",
+ "probe = subprocess.run([sys.executable, \"-c\", CHECK], capture_output=True, text=True)\n",
+ "if probe.returncode and \"cv2\" in probe.stderr:\n",
+ " # Colab ships opencv-python and insightface brings the headless build; when the\n",
+ " # two disagree, cv2 breaks. Reinstalling one build cleanly fixes it.\n",
+ " print(\"OpenCV ناسازگار بود؛ دوباره نصب میشود…\")\n",
+ " pip(\"--force-reinstall\", \"--no-deps\", \"opencv-python-headless\")\n",
+ " probe = subprocess.run([sys.executable, \"-c\", CHECK], capture_output=True, text=True)\n",
+ "print(probe.stdout.strip() or probe.stderr[-1500:])\n",
+ "if HAS_GPU and \"CUDAExecutionProvider\" not in probe.stdout:\n",
+ " print(\"⚠️ مدل چهره روی GPU اجرا نمیشود (onnxruntime-gpu)؛ روی CPU کار میکند ولی کندتر.\")\n",
+ "\n",
+ "sys.path.insert(0, str(SRC / \"src\")) # نصب editable در kernel در حال اجرا بدون این دیده نمیشود\n",
+ "import vie\n",
+ "print(f\"✅ Visual Intelligence Engine {vie.__version__} آماده شد در {time.time() - t0:.0f} ثانیه\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "d332940b",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# ── ابزارهای کمکی نوتبوک ───────────────────────────────────────────\n",
+ "from IPython.display import HTML, Image as IPImage, Video, display\n",
+ "\n",
+ "STATUS = [] # خلاصهی نهایی: (مرحله، وضعیت، ثانیه، توضیح)\n",
+ "\n",
+ "@contextlib.contextmanager\n",
+ "def stage(title):\n",
+ " \"\"\"هر مرحله جدا اجرا میشود؛ خطای یک مرحله بقیه را متوقف نمیکند.\"\"\"\n",
+ " started = time.time()\n",
+ " display(HTML(f'▶ {title}
'))\n",
+ " try:\n",
+ " yield\n",
+ " STATUS.append((title, \"✅\", time.time() - started, \"\"))\n",
+ " except Exception as exc:\n",
+ " STATUS.append((title, \"⚠️\", time.time() - started, f\"{type(exc).__name__}: {exc}\"))\n",
+ " display(HTML(f'⚠️ این مرحله انجام نشد: '\n",
+ " f'{type(exc).__name__}: {exc}
بقیهی مراحل ادامه پیدا میکنند.
'))\n",
+ " traceback.print_exc(limit=3)\n",
+ "\n",
+ "def say(text):\n",
+ " display(HTML(f'{text}
'))\n",
+ "\n",
+ "def show_image(path, width=1100):\n",
+ " if Path(path).exists():\n",
+ " display(IPImage(filename=str(path), width=width))\n",
+ "\n",
+ "def show_video(path, width=900):\n",
+ " path = Path(path)\n",
+ " if not path.exists():\n",
+ " return\n",
+ " if path.stat().st_size < 40e6:\n",
+ " display(Video(str(path), embed=True, width=width, html_attributes=\"controls muted\"))\n",
+ " else:\n",
+ " say(f\"ویدیو بزرگتر از آن است که اینجا پخش شود؛ در Drive ببینید: {path}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8f688a99",
+ "metadata": {},
+ "source": [
+ "\n",
+ "\n",
+ "### دیتاست، ایندکس و مدلها\n",
+ "\n",
+ "- ایندکس اصلی در `DRIVE_ROOT/index` میماند. هنگام کار یک کپی روی دیسک سریع Colab ساخته میشود، چون SQLite روی Drive کند و شکننده است. هر چند دقیقه و در پایان هر مرحله، کپی به Drive برگردانده میشود.\n",
+ "- برای هر مدل یک ایندکس جدا ساخته میشود، پس عوض کردن `QUALITY` هیچوقت ایندکس را خراب نمیکند.\n",
+ "- مدلها یک بار دانلود میشوند و در `DRIVE_ROOT/models` میمانند.\n",
+ "\n",
+ "
"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "c4476d8e",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import zipfile, urllib.request\n",
+ "\n",
+ "for d in (DRIVE_ROOT, VIDEOS, FACE_QUERIES, KNOWN_PEOPLE, f\"{DRIVE_ROOT}/index\", f\"{DRIVE_ROOT}/outputs\"):\n",
+ " os.makedirs(d, exist_ok=True)\n",
+ "OUT = Path(f\"{DRIVE_ROOT}/outputs\")\n",
+ "LOCAL = Path(\"/content/vie_local\" if IN_COLAB else \"vie_local\").resolve()\n",
+ "LOCAL.mkdir(parents=True, exist_ok=True)\n",
+ "\n",
+ "# ── دیتاست: پوشه یا zip ──────────────────────────────────────────────\n",
+ "GALLERY = Path(DATASET)\n",
+ "if DATASET.lower().endswith(\".zip\"):\n",
+ " GALLERY = LOCAL / \"dataset\"\n",
+ " marker = GALLERY / \".extracted\"\n",
+ " zstat = os.stat(DATASET)\n",
+ " stamp = f\"{zstat.st_size}-{zstat.st_mtime_ns}\"\n",
+ " if not marker.exists() or marker.read_text() != stamp:\n",
+ " print(\"از حالت فشرده خارج میشود…\")\n",
+ " with zipfile.ZipFile(DATASET) as z:\n",
+ " z.extractall(GALLERY)\n",
+ " # extractall stamps every file \"now\", which would make the whole dataset\n",
+ " # look changed each session. Restoring the archive's own times lets the\n",
+ " # index skip unchanged photos without even reading them.\n",
+ " for info in z.infolist():\n",
+ " target = GALLERY / info.filename\n",
+ " if not info.is_dir() and target.exists():\n",
+ " when = time.mktime(info.date_time + (0, 0, -1))\n",
+ " os.utime(target, (when, when))\n",
+ " marker.write_text(stamp)\n",
+ "GALLERY.mkdir(parents=True, exist_ok=True)\n",
+ "\n",
+ "IMAGE_EXT = {\".jpg\", \".jpeg\", \".png\", \".webp\", \".bmp\", \".tif\", \".tiff\"}\n",
+ "def count_images(root):\n",
+ " return sum(1 for p in Path(root).rglob(\"*\") if p.suffix.lower() in IMAGE_EXT)\n",
+ "\n",
+ "# ── دادهی نمونه اگر دیتاست خالی است ─────────────────────────────────\n",
+ "RAW = \"https://raw.githubusercontent.com\"\n",
+ "DEMO_VIDEOS = [\"head-pose-face-detection-female-and-male.mp4\", \"face-demographics-walking-and-pause.mp4\",\n",
+ " \"classroom.mp4\"]\n",
+ "def fetch(url, dest):\n",
+ " dest = Path(dest)\n",
+ " if not dest.exists():\n",
+ " dest.parent.mkdir(parents=True, exist_ok=True)\n",
+ " urllib.request.urlretrieve(url, dest)\n",
+ " return dest\n",
+ "\n",
+ "if DEMO_IF_EMPTY and count_images(GALLERY) == 0:\n",
+ " say(\"دیتاست خالی است؛ یک مجموعهی کوچک عمومی (ویدیوهای CC BY 4.0 اینتل و عکسهای نمونه) دانلود میشود.\")\n",
+ " import av\n",
+ " for name in DEMO_VIDEOS + [\"head-pose-face-detection-female.mp4\"]:\n",
+ " fetch(f\"{RAW}/intel-iot-devkit/sample-videos/master/{name}\", LOCAL / \"demo_videos\" / name)\n",
+ " for name in (\"dog.jpg\", \"horses.jpg\", \"eagle.jpg\", \"giraffe.jpg\", \"person.jpg\"):\n",
+ " try:\n",
+ " fetch(f\"{RAW}/pjreddie/darknet/master/data/{name}\", GALLERY / \"animals\" / name)\n",
+ " except Exception as exc:\n",
+ " print(\"skip\", name, exc)\n",
+ " def stills(video, every, prefix):\n",
+ " with av.open(str(video)) as c:\n",
+ " nxt = 0.0\n",
+ " for f in c.decode(video=0):\n",
+ " if f.time is not None and f.time >= nxt:\n",
+ " f.to_image().save(GALLERY / \"people\" / f\"{prefix}_{f.time:06.1f}.jpg\", quality=92)\n",
+ " nxt += every\n",
+ " (GALLERY / \"people\").mkdir(parents=True, exist_ok=True)\n",
+ " for name, every in ((\"head-pose-face-detection-female-and-male.mp4\", 10),\n",
+ " (\"face-demographics-walking-and-pause.mp4\", 8), (\"classroom.mp4\", 8)):\n",
+ " stills(LOCAL / \"demo_videos\" / name, every, Path(name).stem[:12])\n",
+ " q = Path(FACE_QUERIES) / \"demo_person\"\n",
+ " if not q.exists():\n",
+ " q.mkdir(parents=True)\n",
+ " with av.open(str(LOCAL / \"demo_videos\" / \"head-pose-face-detection-female.mp4\")) as c:\n",
+ " for f in c.decode(video=0):\n",
+ " if f.time and f.time >= 1.0:\n",
+ " f.to_image().save(q / \"front.jpg\"); break\n",
+ " for name in DEMO_VIDEOS[:2]:\n",
+ " if not (Path(VIDEOS) / name).exists():\n",
+ " shutil.copy(LOCAL / \"demo_videos\" / name, Path(VIDEOS) / name)\n",
+ "\n",
+ "print(f\"📁 دیتاست: {GALLERY} — {count_images(GALLERY):,} عکس\")\n",
+ "\n",
+ "# ── انتخاب مدل ────────────────────────────────────────────────────────\n",
+ "BEST_MODEL = \"google/siglip2-so400m-patch14-384\"\n",
+ "AUTO_ON_GPU = \"best\" # بر اساس scripts/eval_search.py\n",
+ "# best: SigLIP 2، دقیقترین و چندزبانه · english: SigLIP قبلی · fast: مدل سبک برای CPU\n",
+ "# آستانههای غذا و حیوان برای هر مدل جدا اندازهگیری شدهاند و خودکار اعمال میشوند.\n",
+ "MODELS = {\"best\": BEST_MODEL, \"multilingual\": BEST_MODEL,\n",
+ " \"english\": \"google/siglip-so400m-patch14-384\", \"fast\": \"google/siglip-base-patch16-224\"}\n",
+ "VLM = MODELS.get(QUALITY) or (MODELS[AUTO_ON_GPU] if HAS_GPU else MODELS[\"fast\"])\n",
+ "slug = VLM.split(\"/\")[-1]\n",
+ "DB_DRIVE = Path(f\"{DRIVE_ROOT}/index/index-{slug}.db\")\n",
+ "DB_LOCAL = LOCAL / DB_DRIVE.name\n",
+ "\n",
+ "def sqlite_copy(src, dst):\n",
+ " \"\"\"کپی سازگار یک پایگاه SQLite، حتی وسط کار، و جایگزینی اتمی.\n",
+ "\n",
+ " نسخهی پشتیبان اول روی دیسک محلی ساخته میشود، چون SQLite روی Drive کند و\n",
+ " شکننده است. بعد یک فایل کامل کپی و با یک rename جایگزین میشود، پس فایل مقصد\n",
+ " هیچوقت نیمهکاره نمیماند.\n",
+ " \"\"\"\n",
+ " snap = LOCAL / (Path(src).name + \".snapshot\")\n",
+ " snap.unlink(missing_ok=True)\n",
+ " a, b = sqlite3.connect(src), sqlite3.connect(snap)\n",
+ " try:\n",
+ " a.backup(b)\n",
+ " finally:\n",
+ " a.close(); b.close()\n",
+ " tmp = Path(str(dst) + \".part\")\n",
+ " shutil.copyfile(snap, tmp)\n",
+ " os.replace(tmp, dst)\n",
+ "\n",
+ "if DB_DRIVE.exists() and (not DB_LOCAL.exists() or DB_DRIVE.stat().st_mtime > DB_LOCAL.stat().st_mtime):\n",
+ " sqlite_copy(DB_DRIVE, DB_LOCAL)\n",
+ " print(\"♻️ ایندکس قبلی از Drive بازیابی شد\")\n",
+ "\n",
+ "_last_save = [0.0]\n",
+ "def save_index(force=True):\n",
+ " if DB_LOCAL.exists() and (force or time.time() - _last_save[0] > 180):\n",
+ " sqlite_copy(DB_LOCAL, DB_DRIVE)\n",
+ " _last_save[0] = time.time()\n",
+ "\n",
+ "from vie.api import Engine\n",
+ "from vie.logging_setup import configure\n",
+ "configure(\"WARNING\")\n",
+ "engine = Engine.from_overrides({\n",
+ " \"paths\": {\"gallery\": str(GALLERY), \"videos\": str(VIDEOS), \"database\": str(DB_LOCAL),\n",
+ " \"model_cache\": f\"{DRIVE_ROOT}/models\"},\n",
+ " \"runtime\": {\"device\": \"auto\", \"low_memory\": not HAS_GPU},\n",
+ " \"models\": {\"vision_language\": VLM},\n",
+ " \"indexing\": {\"batch_size\": 16 if HAS_GPU else 4, \"workers\": 8, \"commit_every\": 50},\n",
+ " \"video\": {\"captions\": False, \"transcript\": False},\n",
+ "})\n",
+ "CFG = engine.config\n",
+ "before = engine.counts()\n",
+ "say(f\"مدل تصویر-متن: {VLM} · چهره: ArcFace + آینه · \"\n",
+ " f\"در ایندکس: {before['images']:,} عکس، {before['faces']:,} چهره، \"\n",
+ " f\"{before['videos']} ویدیو\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "277c5e90",
+ "metadata": {},
+ "source": [
+ "\n",
+ "\n",
+ "## ۲. ایندکس عکسها\n",
+ "\n",
+ "- **بار اول** همهی عکسها خوانده میشوند: چهره، حیوان و غذا تشخیص داده میشوند و بردار تصویر ساخته میشود.\n",
+ "- **از بار دوم** فقط عکسهای جدید یا تغییرکرده خوانده میشوند.\n",
+ "- عکسی که از دیتاست پاک شده باشد از ایندکس هم حذف میشود. اما اگر ناگهان بیشتر دیتاست غیب شده باشد (مثلاً Drive هنوز وصل نشده)، سیستم چیزی را پاک نمیکند.\n",
+ "\n",
+ "
"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "d1e0b500",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "with stage(\"ایندکس عکسها\"):\n",
+ " from tqdm.auto import tqdm\n",
+ " bar = tqdm(desc=\"عکسهای جدید\", unit=\"img\")\n",
+ " def on_progress(stats):\n",
+ " bar.n = stats.indexed; bar.refresh()\n",
+ " save_index(force=False) # هر ۳ دقیقه یک نسخهی امن در Drive\n",
+ " first = before[\"images\"] == 0\n",
+ " INDEX_REPORT = engine.index_images(progress=on_progress)\n",
+ " bar.close()\n",
+ " save_index()\n",
+ " r = INDEX_REPORT\n",
+ " if not r.pruning:\n",
+ " say(\"⚠️ بیشتر عکسهای ایندکسشده در پوشه پیدا نشدند، پس هیچ چیز از ایندکس حذف نشد. \"\n",
+ " \"مسیر DATASET یا اتصال Drive را بررسی کنید.\")\n",
+ " after = engine.counts()\n",
+ " kind = \"اولین ایندکس کامل\" if first else \"بهروزرسانی افزایشی\"\n",
+ " say(f\"{kind}: {r.scanned:,} عکس پیدا شد · {r.indexed:,} عکس تازه پردازش شد · \"\n",
+ " f\"{r.skipped:,} عکس بدون تغییر بود و اصلاً باز نشد · {r.pruned} حذف · \"\n",
+ " f\"{r.failed} خطا · {r.seconds:.1f} ثانیه\"\n",
+ " + (f\" · {r.images_per_second:.1f} عکس در ثانیه\" if r.indexed else \"\"))\n",
+ " say(f\"اکنون در ایندکس: {after['images']:,} عکس · {after['faces']:,} چهره · \"\n",
+ " f\"{after['animals']:,} عکس با حیوان · {after['food']:,} عکس با غذا\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "39c1bdac",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "with stage(\"اثبات افزایشی بودن: اجرای دوباره\"):\n",
+ " again = engine.index_images()\n",
+ " say(f\"اجرای دوباره روی همان دیتاست: {again.indexed} عکس پردازش شد، \"\n",
+ " f\"{again.skipped:,} عکس رد شد، در {again.seconds:.2f} ثانیه. \"\n",
+ " \"عکس جدید که اضافه کنید، فقط همان پردازش میشود.\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b01efb70",
+ "metadata": {},
+ "source": [
+ "\n",
+ "\n",
+ "## ۳. جستجو، با خروجی آمادهی ارائه\n",
+ "\n",
+ "برای هر جستجو یک تصویر (PNG) و یک گزارش HTML در `DRIVE_ROOT/outputs` ساخته میشود.\n",
+ "\n",
+ "- **چهره:** برای هر نفر یک پوشه در `queries` بسازید و ۱ تا ۳ عکس از او بگذارید. سیستم با همهی عکسها جستجو میکند و برای هر چهره در آرشیو بهترین شباهت را نگه میدارد. اگر پوشه خالی باشد، پرتکرارترین چهرهی آرشیو انتخاب میشود.\n",
+ "\n",
+ "
"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "5955f6a1",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "CARDS = OUT / \"cards\"; REPORTS = OUT / \"reports\"\n",
+ "CARDS.mkdir(exist_ok=True); REPORTS.mkdir(exist_ok=True)\n",
+ "SHOWN = []\n",
+ "\n",
+ "def present(result, name):\n",
+ " card = engine.save_card(result, CARDS / f\"{name}.png\")\n",
+ " engine.save_html(result, REPORTS / f\"{name}.html\")\n",
+ " SHOWN.append(card)\n",
+ " best = f\"{result.matches[0].display_score * 100:.1f}٪\" if result.matches else \"—\"\n",
+ " say(f\"«{result.query}» → {len(result.matches)} نتیجه · بهترین {best} · \"\n",
+ " f\"{result.milliseconds:.0f} میلیثانیه\")\n",
+ " show_image(card)\n",
+ "\n",
+ "def safe_name(text):\n",
+ " return \"\".join(c if c.isalnum() else \"-\" for c in text)[:40].strip(\"-\")\n",
+ "\n",
+ "with stage(\"جستجوی چهره\"):\n",
+ " people = [p for p in sorted(Path(FACE_QUERIES).iterdir()) if p.is_dir()]\n",
+ " jobs = [(p.name, sorted(x for x in p.iterdir() if x.suffix.lower() in IMAGE_EXT)[:5]) for p in people]\n",
+ " jobs = [(n, imgs) for n, imgs in jobs if imgs]\n",
+ " if not jobs:\n",
+ " # چهرهی پرتکرار بریده میشود، چون در عکس گروهی چهرهی بزرگتر شاید کس دیگری باشد\n",
+ " auto = engine.most_photographed_face(crop_to=OUT / \"queries\" / \"most-seen-person.jpg\")\n",
+ " jobs = [(\"most-seen-person\", [auto])] if auto else []\n",
+ " if not jobs:\n",
+ " say(\"هیچ چهرهای در ایندکس نیست.\")\n",
+ " for name, images in jobs:\n",
+ " present(engine.search_face(images, top_k=12), f\"face-{safe_name(name)}\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "db13fe63",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "with stage(\"جستجوی حیوان\"):\n",
+ " for q in ANIMAL_QUERIES:\n",
+ " result = engine.search_animal(q, top_k=8)\n",
+ " if result.matches:\n",
+ " present(result, f\"animal-{safe_name(q)}\")\n",
+ " else:\n",
+ " say(f\"«{q}»: در آرشیو پیدا نشد\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "764007eb",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "with stage(\"جستجوی غذا\"):\n",
+ " for q in FOOD_QUERIES:\n",
+ " result = engine.search_food(q, top_k=8)\n",
+ " if result.matches:\n",
+ " present(result, f\"food-{safe_name(q)}\")\n",
+ " else:\n",
+ " say(f\"«{q}»: در آرشیو پیدا نشد\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "c6d04182",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "with stage(\"جستجوی متنی آزاد\"):\n",
+ " for q in TEXT_QUERIES:\n",
+ " present(engine.search_text(q, top_k=8), f\"text-{safe_name(q)}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9c9969e9",
+ "metadata": {},
+ "source": [
+ "\n",
+ "\n",
+ "## ۴. ویدیو: تحلیل، گزارش و ویدیوی خروجی\n",
+ "\n",
+ "فقط ویدیوهای **جدید یا تغییرکرده** پردازش میشوند. برای هر کدام این خروجیها ساخته میشود:\n",
+ "\n",
+ "- **گزارش HTML:** نماها، موضوع، افراد، حیوان و غذا روی خط زمانی، و خلاصه به انگلیسی و فارسی.\n",
+ "- **ویدیوی خروجی MP4:** کادر هر چهره، نام فرد (اگر در `people/<نام>/` عکس مرجع گذاشته باشید)، برچسب نما و خط زمانی حضور هر نفر.\n",
+ "- **کارت افراد:** چه کسی، چقدر و از چه زمانی در ویدیو بوده است.\n",
+ "\n",
+ "
"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "8a13d9de",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "VID_OUT = OUT / \"videos\"; VID_OUT.mkdir(exist_ok=True)\n",
+ "RENDERED = []\n",
+ "\n",
+ "with stage(\"ویدیوها\"):\n",
+ " from vie.report.video import write_video_report\n",
+ " from vie.video.render import render_people_card\n",
+ " known = engine.known_people(KNOWN_PEOPLE)\n",
+ " if known:\n",
+ " say(\"افراد مرجع برای نامگذاری: \" + \"، \".join(f\"{k} ({len(v)} عکس)\" for k, v in known.items()))\n",
+ "\n",
+ " def after(key, path, analysis):\n",
+ " name = safe_name(Path(key).stem)\n",
+ " write_video_report(analysis, VID_OUT / f\"{name}.html\")\n",
+ " say(f\"🎬 {key}: {analysis.summary.get('fa') or analysis.summary.get('en', '')}\")\n",
+ " if RENDER_VIDEOS:\n",
+ " result = engine.render_video(path, VID_OUT / f\"{name}.mp4\", known=known or None,\n",
+ " analysis=analysis, title=Path(key).stem[:40],\n",
+ " max_seconds=MAX_RENDER_SECONDS,\n",
+ " detect_every=1 if HAS_GPU else 3)\n",
+ " card = VID_OUT / f\"{name}-people.png\"\n",
+ " render_people_card(result, title=Path(key).stem[:40]).save(card)\n",
+ " RENDERED.append((result.path, card))\n",
+ " say(f\"ویدیوی خروجی: {result.frames:,} فریم با سرعت {result.fps_processed:.1f} فریم در ثانیه · \"\n",
+ " + \"، \".join(f\"{p.label}: {p.seconds:.0f} ثانیه\" for p in result.people[:6]))\n",
+ " save_index()\n",
+ "\n",
+ " vstats = engine.index_videos(on_analysed=after)\n",
+ " save_index()\n",
+ " say(f\"ویدیوها: {vstats.scanned} پیدا شد · {vstats.indexed} ویدیوی تازه تحلیل شد · \"\n",
+ " f\"{vstats.skipped} بدون تغییر رد شد · {vstats.failed} خطا\")\n",
+ " if not RENDERED:\n",
+ " done = sorted(VID_OUT.glob(\"*.mp4\"))\n",
+ " RENDERED = [(p, p.with_name(p.stem + \"-people.png\")) for p in done]\n",
+ " if done:\n",
+ " say(\"ویدیوی تازهای نبود؛ خروجیهای قبلی نمایش داده میشوند.\")\n",
+ " for video, card in RENDERED[:3]:\n",
+ " show_video(video)\n",
+ " show_image(card)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "4437a018",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "with stage(\"جستجو در ویدیوها\"):\n",
+ " from vie.video.understand import format_clock\n",
+ " if engine.counts()[\"videos\"] == 0:\n",
+ " say(\"ویدیویی در ایندکس نیست.\")\n",
+ " for q in VIDEO_QUERIES:\n",
+ " moments = engine.search_videos(q, top_k=5)\n",
+ " rows = \"\".join(f\"| {m.video_key} | {format_clock(m.start)}–{format_clock(m.end)} | \"\n",
+ " f\"{m.score:.3f} | {m.kind} |
\" for m in moments)\n",
+ " say(f\"«{q}»\" + (f'' if rows else \": چیزی پیدا نشد\"))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "180c9625",
+ "metadata": {},
+ "source": [
+ "\n",
+ "\n",
+ "### جستجوی مفهوم در کل آرشیو\n",
+ "\n",
+ "یک توضیح، مثلاً «a theatre performance» یا با مدل چندزبانه «تئاتر». جواب شامل **همهی عکسها و لحظههای ویدیو** است که آن مفهوم را نشان میدهند، در یک فهرست مرتب. روی کاشی هر لحظهی ویدیو، زمانش نوشته شده است. این جستجو در ایندکس انجام میشود و هیچ عکس یا ویدیویی باز نمیشود، به جز همان چند فریمی که روی کارت نشان داده میشوند.\n",
+ "\n",
+ "
"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "fd804a89",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "with stage(\"جستجوی مفهوم در عکسها و ویدیوها\"):\n",
+ " from vie.vlm import has_right_to_left, is_multilingual\n",
+ " for q in CONCEPT_QUERIES:\n",
+ " if has_right_to_left(q) and not is_multilingual(VLM):\n",
+ " say(f\"«{q}» رد شد: مدل فعلی فقط انگلیسی میفهمد. برای فارسی QUALITY = \\\"best\\\" روی GPU\")\n",
+ " continue\n",
+ " result = engine.search_everything(q, top_k=12, frames_dir=OUT / \"frames\")\n",
+ " present(result, f\"concept-{safe_name(q)}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "3e2cbf78",
+ "metadata": {},
+ "source": [
+ "\n",
+ "\n",
+ "## ۵. خلاصه\n",
+ "\n",
+ "
"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "9431b9c1",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "save_index()\n",
+ "rows = \"\".join(f\"| {s} | {t} | {sec:.1f}s | {note[:160]} |
\"\n",
+ " for t, s, sec, note in STATUS)\n",
+ "final = engine.counts()\n",
+ "say(f\"\"\"
\n",
+ "در ایندکس: {final['images']:,} عکس · {final['faces']:,} چهره · {final['videos']} ویدیو
\n",
+ "خروجیها در Drive: {OUT} — {len(list(CARDS.glob('*.png')))} کارت جستجو،\n",
+ "{len(list(VID_OUT.glob('*.mp4')))} ویدیوی خروجی، گزارشهای HTML
\n",
+ "دفعهی بعد: عکس یا ویدیوی جدید را به همان پوشهها اضافه کنید و دوباره Run all بزنید؛ فقط موارد جدید پردازش میشوند.\"\"\")"
+ ]
+ }
+ ],
+ "metadata": {
+ "accelerator": "GPU",
+ "colab": {
+ "gpuType": "T4",
+ "name": "0_run_everything.ipynb",
+ "provenance": []
+ },
+ "kernelspec": {
+ "display_name": "Python 3",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/notebooks/README.md b/notebooks/README.md
index dca144a..3be3aa0 100644
--- a/notebooks/README.md
+++ b/notebooks/README.md
@@ -5,8 +5,14 @@ also lives in `src/vie/` on purpose: these are meant to be read top to bottom
and run in Colab without installing the package, so each one carries its own
config, model loading and helpers.
+**Start with `0_run_everything.ipynb`.** It installs the maintained package and runs the
+whole system on your Google Drive with one *Run all*: incremental indexing (only new files
+after the first run), all searches, video analysis, annotated output videos and
+presentation cards. The others below are standalone, read-top-to-bottom explanations.
+
| Notebook | Covers |
|:--|:--|
+| `0_run_everything.ipynb` | **The complete system in Colab**, on the current package |
| `1_face_recognition.ipynb` | InsightFace `buffalo_l`, 512-d ArcFace, indexing and search |
| `2_animal_recognition.ipynb` | MegaDetector + classifier + CLIP fallback, with a cell that **proves** the original species-mapping defect against the real ImageNet class list |
| `3_food_recognition.ipynb` | RT-DETR-X + CLIP contrastive prompts |
diff --git a/results/face_benchmark.json b/results/face_benchmark.json
new file mode 100644
index 0000000..7ea22e6
--- /dev/null
+++ b/results/face_benchmark.json
@@ -0,0 +1,60 @@
+{
+ "faces": 776,
+ "genuine_pairs": 17956,
+ "hard_pairs": 3716,
+ "impostor_pairs": 18472,
+ "models": {
+ "ArcFace buffalo_l (this repo)": {
+ "eer": 0.0,
+ "separation_d_prime": 8.08,
+ "genuine_mean": 0.777,
+ "impostor_max": 0.308,
+ "tar_at_far0": 1.0,
+ "hard_tar_at_far0": 1.0,
+ "threshold": 0.46,
+ "tar_at_threshold": 0.9856,
+ "hard_tar_at_threshold": 0.9691,
+ "far_at_threshold": 0.0,
+ "ms_per_face_cpu": 77.5
+ },
+ "ArcFace buffalo_l + mirror": {
+ "eer": 0.0,
+ "separation_d_prime": 8.43,
+ "genuine_mean": 0.787,
+ "impostor_max": 0.301,
+ "tar_at_far0": 1.0,
+ "hard_tar_at_far0": 1.0,
+ "threshold": 0.46,
+ "tar_at_threshold": 0.9888,
+ "hard_tar_at_threshold": 0.9755,
+ "far_at_threshold": 0.0,
+ "ms_per_face_cpu": 217.0
+ },
+ "SFace (student-face-gate)": {
+ "eer": 0.002,
+ "separation_d_prime": 5.86,
+ "genuine_mean": 0.776,
+ "impostor_max": 0.397,
+ "tar_at_far0": 0.9775,
+ "hard_tar_at_far0": 0.9779,
+ "threshold": 0.55,
+ "tar_at_threshold": 0.8975,
+ "hard_tar_at_threshold": 0.8361,
+ "far_at_threshold": 0.0,
+ "ms_per_face_cpu": 12.0
+ },
+ "SFace + mirror (student-face-gate)": {
+ "eer": 0.0021,
+ "separation_d_prime": 6.07,
+ "genuine_mean": 0.787,
+ "impostor_max": 0.402,
+ "tar_at_far0": 0.9816,
+ "hard_tar_at_far0": 0.9812,
+ "threshold": 0.55,
+ "tar_at_threshold": 0.9115,
+ "hard_tar_at_threshold": 0.8687,
+ "far_at_threshold": 0.0,
+ "ms_per_face_cpu": 13.3
+ }
+ }
+}
\ No newline at end of file
diff --git a/results/search_eval.json b/results/search_eval.json
new file mode 100644
index 0000000..fe5cda1
--- /dev/null
+++ b/results/search_eval.json
@@ -0,0 +1,122 @@
+{
+ "source": "Search quality workflow, run 42239834130, scripts/eval_search.py --layout 1",
+ "set": "fetch_calibration_data.py --per-label 4: 12 Food-101 dishes, cats, dogs, mini-ImageNet artefacts as distractors",
+ "queries": 14,
+ "models": {
+ "google/siglip-so400m-patch14-384": {
+ "label alone": {
+ "mAP": 1.0,
+ "top1": 1.0,
+ "r_precision": 1.0
+ },
+ "ensemble": {
+ "mAP": 1.0,
+ "top1": 1.0,
+ "r_precision": 1.0
+ },
+ "persian alone": {
+ "mAP": 0.7384,
+ "top1": 0.7143,
+ "r_precision": 0.6786
+ },
+ "persian ensemble": {
+ "mAP": 0.422,
+ "top1": 0.4286,
+ "r_precision": 0.3464
+ },
+ "nothing_found": {
+ "present_min": 0.0229,
+ "present_median": 0.8633,
+ "absent_max": 0.3612,
+ "absent_median": 1.7e-05,
+ "a theatre stage with actors": 0.00084
+ },
+ "seconds_to_embed_cpu": 325.2,
+ "at_configured_thresholds": {
+ "food (0.98)": {
+ "precision": 0.8889,
+ "recall": 1.0
+ },
+ "animal (0.91)": {
+ "precision": 0.9524,
+ "recall": 1.0
+ },
+ "food gate (-1.0)": {
+ "precision": 1.0,
+ "recall": 1.0
+ }
+ }
+ },
+ "google/siglip2-so400m-patch14-384": {
+ "label alone": {
+ "mAP": 1.0,
+ "top1": 1.0,
+ "r_precision": 1.0
+ },
+ "ensemble": {
+ "mAP": 1.0,
+ "top1": 1.0,
+ "r_precision": 1.0
+ },
+ "persian alone": {
+ "mAP": 0.9445,
+ "top1": 0.9286,
+ "r_precision": 0.9107
+ },
+ "persian ensemble": {
+ "mAP": 0.9373,
+ "top1": 1.0,
+ "r_precision": 0.875
+ },
+ "nothing_found": {
+ "present_min": 0.0092,
+ "present_median": 0.7466,
+ "absent_max": 0.0877,
+ "absent_median": 1.2e-05,
+ "a theatre stage with actors": 0.00072
+ },
+ "seconds_to_embed_cpu": 355.8,
+ "at_configured_thresholds": {
+ "food (0.98, SigLIP's)": {
+ "precision": 0.9348,
+ "recall": 0.8958
+ },
+ "animal (0.91, SigLIP's)": {
+ "precision": 1.0,
+ "recall": 0.9
+ },
+ "food (0.92, best F1 margin 2.49)": {
+ "precision": 0.94,
+ "recall": 0.9792
+ },
+ "animal (0.84, best F1 margin 1.62)": {
+ "precision": 0.9524,
+ "recall": 1.0
+ },
+ "food gate (-1.0)": {
+ "precision": 0.96,
+ "recall": 1.0
+ }
+ }
+ }
+ },
+ "calibration_run": "Search quality workflow, run 42241763687, scripts/calibrate.py",
+ "confirmation_run": {
+ "run": 42245151851,
+ "note": "same thresholds, sample of 44 dish images instead of 48",
+ "google/siglip2-so400m-patch14-384": {
+ "food (0.92)": {
+ "precision": 0.898,
+ "recall": 1.0
+ },
+ "animal (0.84)": {
+ "precision": 0.95,
+ "recall": 0.95
+ },
+ "food gate (-1.0)": {
+ "precision": 0.9565,
+ "recall": 1.0
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/scripts/eval_search.py b/scripts/eval_search.py
new file mode 100644
index 0000000..5b2ed05
--- /dev/null
+++ b/scripts/eval_search.py
@@ -0,0 +1,210 @@
+"""Measure typed search: does a query find the photos it should, and only those?
+
+ python scripts/fetch_calibration_data.py DATA --per-label 6
+ python scripts/eval_search.py DATA --model google/siglip2-so400m-patch14-384
+
+Every label in the labelled set (Food-101 dishes, cats, dogs) becomes a query, and
+the images carrying that label are the right answers. Images are embedded exactly as
+the index embeds them (whole frame plus multi-crop regions, best region wins), so the
+numbers describe the product, not a simplified copy of it.
+
+Reported per query variant:
+
+* ``mAP``: mean average precision over the whole ranking, the standard retrieval
+ measure. 1.0 means every right image ranks above every wrong one.
+* ``top1``: how often the first result is right.
+* ``r_precision``: precision within the first R results, R = number of right images.
+
+Variants: the label alone, the label averaged over ``text.query_templates`` (prompt
+ensembling), and the same in Persian, which only a multilingual model can answer.
+
+For a "nothing found" answer it also reports SigLIP's calibrated probability of the
+best right image for present concepts, against the best image at all for concepts
+that are absent from the set ("a theatre stage", "a football match"). A floor between
+the two lets search say "nothing here" instead of returning its five least-bad images.
+
+The set is small: read differences of a few points as noise, differences of ten as real.
+"""
+
+from __future__ import annotations
+
+import argparse
+import copy
+import json
+import os
+import time
+from pathlib import Path
+
+import numpy as np
+import yaml
+from PIL import Image
+
+from vie.config import DEFAULT_CONFIG_PATH, Config
+from vie.models import VisionLanguageEncoder, load_all
+from vie.quality import crop_regions
+from vie.vlm import default_score_mode
+
+#: Food-101 classes and the two animals, in Persian, for the multilingual variant.
+PERSIAN = {
+ "apple pie": "پای سیب", "baby back ribs": "دنده کبابی", "baklava": "باقلوا",
+ "beef carpaccio": "کارپاچیو گوشت", "beef tartare": "تارتار گوشت",
+ "beet salad": "سالاد چغندر", "beignets": "دونات فرانسوی", "bibimbap": "بیبیمباپ",
+ "bread pudding": "پودینگ نان", "breakfast burrito": "بوریتو صبحانه",
+ "bruschetta": "بروسکتا", "caesar salad": "سالاد سزار", "cannoli": "کانولی",
+ "caprese salad": "سالاد کاپرزه", "carrot cake": "کیک هویج", "ceviche": "سویچه",
+ "cheesecake": "چیزکیک", "cheese plate": "بشقاب پنیر", "chicken curry": "کاری مرغ",
+ "chicken quesadilla": "کسادیای مرغ", "chicken wings": "بال مرغ",
+ "chocolate cake": "کیک شکلاتی", "chocolate mousse": "موس شکلات", "churros": "چوروس",
+ "clam chowder": "سوپ صدف", "club sandwich": "کلاب ساندویچ", "crab cakes": "کیک خرچنگ",
+ "creme brulee": "کرم بروله", "croque madame": "کروک مادام", "cup cakes": "کاپ کیک",
+ "deviled eggs": "تخممرغ شکمپر", "donuts": "دونات", "dumplings": "دامپلینگ",
+ "edamame": "ادامامه", "eggs benedict": "تخممرغ بندیکت", "escargots": "حلزون",
+ "falafel": "فلافل", "filet mignon": "فیله مینیون", "fish and chips": "ماهی و سیبزمینی",
+ "foie gras": "فوآگرا", "french fries": "سیبزمینی سرخکرده",
+ "french onion soup": "سوپ پیاز", "french toast": "نان تست فرانسوی",
+ "fried calamari": "کالاماری سوخاری", "fried rice": "برنج سرخکرده",
+ "frozen yogurt": "ماست یخزده", "garlic bread": "نان سیر", "gnocchi": "نیوکی",
+ "greek salad": "سالاد یونانی", "grilled cheese sandwich": "ساندویچ پنیر کبابی",
+ "grilled salmon": "ماهی سالمون کبابی", "guacamole": "گواکامولی", "gyoza": "گیوزا",
+ "hamburger": "همبرگر", "hot and sour soup": "سوپ ترش و تند", "hot dog": "هاتداگ",
+ "huevos rancheros": "تخممرغ مکزیکی", "hummus": "حمص", "ice cream": "بستنی",
+ "lasagna": "لازانیا", "lobster bisque": "سوپ خرچنگ", "lobster roll sandwich": "ساندویچ خرچنگ",
+ "macaroni and cheese": "ماکارونی و پنیر", "macarons": "ماکارون", "miso soup": "سوپ میسو",
+ "mussels": "صدف", "nachos": "ناچو", "omelette": "املت", "onion rings": "حلقه پیاز",
+ "oysters": "صدف خوراکی", "pad thai": "پد تای", "paella": "پائیا", "pancakes": "پنکیک",
+ "panna cotta": "پاناکوتا", "peking duck": "اردک پکنی", "pho": "سوپ فو", "pizza": "پیتزا",
+ "pork chop": "استیک خوک", "poutine": "پوتین", "prime rib": "دنده گوساله",
+ "pulled pork sandwich": "ساندویچ گوشت ریشریش", "ramen": "رامن", "ravioli": "راویولی",
+ "red velvet cake": "کیک رد ولوت", "risotto": "ریزوتو", "samosa": "سمبوسه",
+ "sashimi": "ساشیمی", "scallops": "گوش ماهی", "seaweed salad": "سالاد جلبک",
+ "shrimp and grits": "میگو و بلغور", "spaghetti bolognese": "اسپاگتی بلونز",
+ "spaghetti carbonara": "اسپاگتی کاربونارا", "spring rolls": "اسپرینگ رول",
+ "steak": "استیک", "strawberry shortcake": "کیک توتفرنگی", "sushi": "سوشی",
+ "tacos": "تاکو", "takoyaki": "تاکویاکی", "tiramisu": "تیرامیسو",
+ "tuna tartare": "تارتار ماهی تن", "waffles": "وافل",
+ "cat": "گربه", "dog": "سگ",
+}
+PERSIAN_TEMPLATES = ["{query}", "عکسی از {query}", "تصویری از {query}"]
+
+#: Concepts with no image in the set, to see what search returns for "nothing".
+ABSENT = ["a theatre stage with actors", "a football match in a stadium",
+ "a snowy mountain", "a wedding ceremony", "a busy city street at night",
+ "a classroom with students", "a car race", "people dancing at a concert"]
+
+
+def average_precision(scores: np.ndarray, relevant: np.ndarray) -> float:
+ order = np.argsort(-scores, kind="stable")
+ hits = relevant[order]
+ if not hits.any():
+ return 0.0
+ precision_at_hit = np.cumsum(hits) / (np.arange(len(hits)) + 1)
+ return float(precision_at_hit[hits].mean())
+
+
+def evaluate(vectors: list[np.ndarray], labels: list[str], queries: dict[str, np.ndarray]
+ ) -> dict:
+ """Rank every image for every query, scoring an image by its best region."""
+ aps, top1, rprec = [], [], []
+ for label, query in queries.items():
+ scores = np.array([float((regions @ query).max()) for regions in vectors])
+ relevant = np.array([lbl == label for lbl in labels])
+ r = int(relevant.sum())
+ if not r:
+ continue
+ order = np.argsort(-scores, kind="stable")
+ aps.append(average_precision(scores, relevant))
+ top1.append(bool(relevant[order[0]]))
+ rprec.append(float(relevant[order[:r]].mean()))
+ return {"queries": len(aps), "mAP": round(float(np.mean(aps)), 4),
+ "top1": round(float(np.mean(top1)), 4),
+ "r_precision": round(float(np.mean(rprec)), 4)}
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument("data", type=Path)
+ parser.add_argument("--model", default="google/siglip-so400m-patch14-384")
+ parser.add_argument("--out", type=Path, default=None)
+ parser.add_argument("--layout", default=None,
+ help="crop layout; default the config's. '1' (whole frame) is 6x "
+ "faster, which a CPU runner needs for the 877M-parameter models")
+ args = parser.parse_args()
+
+ raw = copy.deepcopy(yaml.safe_load(DEFAULT_CONFIG_PATH.read_text(encoding="utf-8")))
+ raw["models"]["vision_language"] = args.model
+ raw["models"]["score_mode"] = default_score_mode(args.model)
+ raw["runtime"]["low_memory"] = bool(os.environ.get("VIE_LOW_MEMORY"))
+ raw["indexing"]["batch_size"] = 6
+ config = Config.from_dict(raw)
+ encoder = VisionLanguageEncoder(load_all(config))
+ layout = args.layout or (config.crop_layout if config.multi_crop else "1")
+
+ paths = sorted(p for p in args.data.rglob("*.jpg") if p.is_file())
+ labels = [p.relative_to(args.data).parts[1].replace("_", " ") for p in paths]
+ kinds = [p.relative_to(args.data).parts[0] for p in paths]
+ started = time.perf_counter()
+ vectors: list[np.ndarray] = []
+ for start in range(0, len(paths), 4):
+ # BGR, as the index sees images.
+ images = [np.asarray(Image.open(p).convert("RGB"))[:, :, ::-1].copy()
+ for p in paths[start:start + 4]]
+ regions = [crop_regions(im.shape[1], im.shape[0], layout) for im in images]
+ for found in encoder.embed_batch(images, regions):
+ vectors.append(np.vstack([v for _, v in found]))
+ print(f" embedded {len(vectors)}/{len(paths)} in {time.perf_counter() - started:.0f} s",
+ flush=True)
+ embed_s = time.perf_counter() - started
+ whole = [v[:1] for v in vectors] # region 0 is always the whole frame
+ print(f"{args.model}: {len(paths)} images, {embed_s:.0f} s to embed", flush=True)
+
+ targets = sorted({lbl for lbl, kind in zip(labels, kinds, strict=True) if kind != "other"})
+ templates = list(config.query_templates)
+ variants = {
+ "label alone": {t: encoder.encode_query(t, ["{query}"]) for t in targets},
+ "ensemble": {t: encoder.encode_query(t, templates) for t in targets},
+ }
+ translated = [t for t in targets if t in PERSIAN]
+ if translated:
+ variants["persian alone"] = {t: encoder.encode_query(PERSIAN[t], ["{query}"])
+ for t in translated}
+ variants["persian ensemble"] = {t: encoder.encode_query(PERSIAN[t], PERSIAN_TEMPLATES)
+ for t in translated}
+
+ report: dict = {"model": args.model, "layout": layout, "images": len(paths),
+ "queries": len(targets),
+ "templates": templates, "seconds_to_embed": round(embed_s, 1),
+ "multi_crop": {}, "whole_frame_only": {}}
+ for name, queries in variants.items():
+ report["multi_crop"][name] = evaluate(vectors, labels, queries)
+ report["whole_frame_only"][name] = evaluate(whole, labels, queries)
+
+ # What a "nothing found" floor would have to separate.
+ calibrated = getattr(encoder, "calibrated", lambda cosine: None)
+ present, absent = [], []
+ for label, query in variants["ensemble"].items():
+ best = max(float((v @ query).max()) for v, lbl in zip(vectors, labels, strict=True)
+ if lbl == label)
+ present.append(calibrated(best))
+ for concept in ABSENT:
+ query = encoder.encode_query(concept, templates)
+ absent.append(calibrated(max(float((v @ query).max()) for v in vectors)))
+ if all(p is not None for p in present + absent):
+ report["nothing_found"] = {
+ "present_best_probability": {"min": min(present), "median": float(np.median(present))},
+ "absent_best_probability": {"max": max(absent), "median": float(np.median(absent))},
+ "absent_queries": dict(zip(ABSENT, absent, strict=True)),
+ }
+
+ print(json.dumps(report, indent=2, ensure_ascii=False), flush=True)
+ print(f"\n{'variant':18s} {'multi-crop mAP':>15s} {'top1':>6s} {'whole mAP':>10s}")
+ for name in variants:
+ mc, wf = report["multi_crop"][name], report["whole_frame_only"][name]
+ print(f"{name:18s} {mc['mAP']:>15.3f} {mc['top1']:>6.2f} {wf['mAP']:>10.3f}")
+ out = args.out or Path(f"benchmarks/search-eval-{args.model.split('/')[-1]}.json")
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/face_benchmark.py b/scripts/face_benchmark.py
new file mode 100644
index 0000000..d1b6690
--- /dev/null
+++ b/scripts/face_benchmark.py
@@ -0,0 +1,207 @@
+"""Head-to-head face recognition benchmark: ArcFace (this repo) vs SFace (student-face-gate).
+
+Both recognisers see exactly the same aligned 112x112 crops, cut by the same
+detector with the same five landmarks, so the comparison isolates the recogniser.
+
+Ground truth comes from public CC-BY 4.0 test videos (intel-iot-devkit/sample-videos)
+without any hand labelling:
+
+* **genuine pairs**: two frames of a single-person video. The person turns their
+ head through large angles, so pairs with a big yaw difference are hard.
+* **impostor pairs**: two different faces in the *same frame* are necessarily two
+ different people, plus every pair across the two single-person videos (a woman
+ and a man).
+
+This is a stress test of pose, not a replacement for LFW or IJB-C. It is small,
+and the numbers are a comparison between models, not a promise of field accuracy.
+
+ python scripts/face_benchmark.py --videos DIR --sface face_recognition_sface_2021dec.onnx
+"""
+
+from __future__ import annotations
+
+import argparse
+import itertools
+import json
+import time
+from pathlib import Path
+
+import numpy as np
+
+SINGLE_PERSON = {
+ "head-pose-face-detection-female.mp4": "female",
+ "head-pose-face-detection-male.mp4": "male",
+}
+CROWD = [
+ "classroom.mp4",
+ "face-demographics-walking.mp4",
+ "face-demographics-walking-and-pause.mp4",
+ "people-detection.mp4",
+ "head-pose-face-detection-female-and-male.mp4",
+]
+
+
+def extract(videos: Path, every: float, app) -> list[dict]:
+ import av
+ from insightface.utils import face_align
+
+ faces: list[dict] = []
+ for name in list(SINGLE_PERSON) + CROWD:
+ path = videos / name
+ if not path.exists():
+ continue
+ container = av.open(str(path))
+ next_t, frame_no = 0.0, 0
+ for frame in container.decode(video=0):
+ t = float(frame.time or 0.0)
+ if t + 1e-6 < next_t:
+ continue
+ next_t = t + every
+ image = frame.to_ndarray(format="bgr24")
+ found = [f for f in app.get(image)
+ if f.det_score >= 0.5
+ and min(f.bbox[2] - f.bbox[0], f.bbox[3] - f.bbox[1]) >= 40]
+ if name in SINGLE_PERSON and found:
+ found = [max(found, key=lambda f: (f.bbox[2] - f.bbox[0])
+ * (f.bbox[3] - f.bbox[1]))]
+ for f in found:
+ yaw = float(f.pose[1]) if getattr(f, "pose", None) is not None else 0.0
+ faces.append({
+ "video": name, "frame": frame_no, "time": t, "yaw": yaw,
+ "identity": SINGLE_PERSON.get(name),
+ "crop": face_align.norm_crop(image, landmark=f.kps, image_size=112),
+ })
+ frame_no += 1
+ container.close()
+ return faces
+
+
+def unit(x: np.ndarray) -> np.ndarray:
+ return x / np.linalg.norm(x, axis=-1, keepdims=True)
+
+
+def embed_arcface(rec, crops, flip: bool) -> np.ndarray:
+ out = []
+ for start in range(0, len(crops), 64):
+ batch = crops[start:start + 64]
+ feats = rec.get_feat(batch)
+ if flip:
+ feats = feats + rec.get_feat([np.ascontiguousarray(c[:, ::-1]) for c in batch])
+ out.append(feats)
+ return unit(np.vstack(out).astype(np.float32))
+
+
+def embed_sface(model_path: str, crops, flip: bool) -> np.ndarray:
+ import cv2
+
+ sface = cv2.FaceRecognizerSF.create(model_path, "")
+ out = []
+ for crop in crops:
+ v = sface.feature(crop).ravel()
+ if flip:
+ v = v + sface.feature(np.ascontiguousarray(crop[:, ::-1])).ravel()
+ out.append(v)
+ return unit(np.vstack(out).astype(np.float32))
+
+
+def pairs(faces: list[dict]):
+ genuine, hard, impostor = [], [], []
+ by_id: dict[str, list[int]] = {}
+ for i, f in enumerate(faces):
+ if f["identity"]:
+ by_id.setdefault(f["identity"], []).append(i)
+ for members in by_id.values():
+ for a, b in itertools.combinations(members, 2):
+ genuine.append((a, b))
+ if abs(faces[a]["yaw"] - faces[b]["yaw"]) >= 30:
+ hard.append((a, b))
+ ids = list(by_id.values())
+ for x, y in itertools.combinations(ids, 2):
+ impostor.extend(itertools.product(x, y))
+ frames: dict[tuple[str, int], list[int]] = {}
+ for i, f in enumerate(faces):
+ if not f["identity"]:
+ frames.setdefault((f["video"], f["frame"]), []).append(i)
+ for members in frames.values():
+ impostor.extend(itertools.combinations(members, 2))
+ return genuine, hard, impostor
+
+
+def scores(emb: np.ndarray, idx) -> np.ndarray:
+ a, b = np.array(idx).T
+ return np.einsum("ij,ij->i", emb[a], emb[b])
+
+
+def metrics(gen: np.ndarray, hard: np.ndarray, imp: np.ndarray, threshold: float) -> dict:
+ # Every observed score is a candidate threshold. Sorted arrays and searchsorted
+ # give FAR and FRR at all of them in O(n log n) instead of a loop over each.
+ thresholds = np.unique(np.concatenate([gen, imp]))
+ imp_sorted, gen_sorted = np.sort(imp), np.sort(gen)
+ far = 1.0 - np.searchsorted(imp_sorted, thresholds, side="left") / len(imp)
+ frr = np.searchsorted(gen_sorted, thresholds, side="left") / len(gen)
+ k = int(np.argmin(np.abs(far - frr)))
+ zero_far = float(imp.max()) + 1e-6
+ return {
+ "eer": round(float((far[k] + frr[k]) / 2), 4),
+ "separation_d_prime": round(float((gen.mean() - imp.mean())
+ / np.sqrt((gen.var() + imp.var()) / 2)), 2),
+ "genuine_mean": round(float(gen.mean()), 3),
+ "impostor_max": round(float(imp.max()), 3),
+ "tar_at_far0": round(float((gen >= zero_far).mean()), 4),
+ "hard_tar_at_far0": round(float((hard >= zero_far).mean()), 4),
+ "threshold": threshold,
+ "tar_at_threshold": round(float((gen >= threshold).mean()), 4),
+ "hard_tar_at_threshold": round(float((hard >= threshold).mean()), 4),
+ "far_at_threshold": round(float((imp >= threshold).mean()), 6),
+ }
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--videos", type=Path, required=True)
+ parser.add_argument("--sface", required=True)
+ parser.add_argument("--every", type=float, default=0.5)
+ parser.add_argument("--out", type=Path, default=Path("results/face_benchmark.json"))
+ args = parser.parse_args()
+
+ from insightface.app import FaceAnalysis
+
+ app = FaceAnalysis(name="buffalo_l", providers=["CPUExecutionProvider"],
+ allowed_modules=["detection", "landmark_3d_68"])
+ app.prepare(ctx_id=-1, det_size=(640, 640))
+ faces = extract(args.videos, args.every, app)
+ crops = [f["crop"] for f in faces]
+ genuine, hard, impostor = pairs(faces)
+ print(f"{len(faces)} faces, {len(genuine)} genuine pairs ({len(hard)} with yaw gap >= 30), "
+ f"{len(impostor)} impostor pairs")
+
+ full = FaceAnalysis(name="buffalo_l", providers=["CPUExecutionProvider"],
+ allowed_modules=["detection", "recognition"])
+ full.prepare(ctx_id=-1, det_size=(640, 640))
+ rec = full.models["recognition"]
+
+ variants = {
+ # Each model at the operating threshold its own project ships with.
+ "ArcFace buffalo_l (this repo)": (lambda: embed_arcface(rec, crops, False), 0.46),
+ "ArcFace buffalo_l + mirror": (lambda: embed_arcface(rec, crops, True), 0.46),
+ "SFace (student-face-gate)": (lambda: embed_sface(args.sface, crops, False), 0.55),
+ "SFace + mirror (student-face-gate)": (lambda: embed_sface(args.sface, crops, True), 0.55),
+ }
+ report = {"faces": len(faces), "genuine_pairs": len(genuine), "hard_pairs": len(hard),
+ "impostor_pairs": len(impostor), "models": {}}
+ for name, (embed, threshold) in variants.items():
+ started = time.perf_counter()
+ emb = embed()
+ per_face = (time.perf_counter() - started) / len(crops) * 1000
+ result = metrics(scores(emb, genuine), scores(emb, hard), scores(emb, impostor), threshold)
+ result["ms_per_face_cpu"] = round(per_face, 1)
+ report["models"][name] = result
+ print(f"{name:38s} {json.dumps(result)}")
+
+ args.out.parent.mkdir(parents=True, exist_ok=True)
+ args.out.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
+ print(f"written to {args.out}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/fetch_calibration_data.py b/scripts/fetch_calibration_data.py
index 309b18e..2be78e6 100644
--- a/scripts/fetch_calibration_data.py
+++ b/scripts/fetch_calibration_data.py
@@ -60,13 +60,15 @@ def harvest(
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("out", type=Path)
+ parser.add_argument("--per-label", type=int, default=3,
+ help="images per dish; animals get this times 8/3 (search eval: 6)")
args = parser.parse_args()
# Food-101 is ordered by class, 250 validation images each.
harvest(args.out, "food", "ethz/food101", "default", "validation", "label",
- [i * 250 for i in range(0, 101, 9)], 100, 3)
+ [i * 250 for i in range(0, 101, 9)], 100, args.per_label)
harvest(args.out, "animal", "microsoft/cats_vs_dogs", "default", "train", "labels",
- [0, 12000], 100, 8)
+ [0, 12000], 100, max(8, args.per_label * 8 // 3))
# WordNet ids: n03 and n04 are artefacts, n01 and n02 mostly animals.
harvest(args.out, "other", "timm/mini-imagenet", "default", "validation", "label",
list(range(0, 11000, 500)), 20, 2, lambda label: label.startswith(("n03", "n04")))
diff --git a/scripts/make_search_showcase.py b/scripts/make_search_showcase.py
new file mode 100644
index 0000000..21b7d29
--- /dev/null
+++ b/scripts/make_search_showcase.py
@@ -0,0 +1,113 @@
+"""Regenerate assets/showcase/search/: food, species, free text, Persian, concept.
+
+Runs the whole system with the real weights (SigLIP 2, ConvNeXt, MegaDetector,
+RT-DETR, InsightFace) on public data, through :class:`vie.api.Engine`, exactly as
+the notebook does, and saves one presentation card per search.
+
+ python scripts/fetch_calibration_data.py DATA --per-label 2
+ python scripts/make_search_showcase.py --data DATA --videos VIDEOS --out assets/showcase/search
+
+DATA holds Food-101, cats-vs-dogs and mini-ImageNet images (see fetch_calibration_data.py);
+VIDEOS holds intel-iot-devkit/sample-videos files (CC BY 4.0). The *Showcase* workflow
+runs this on a CPU runner and commits the cards.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import shutil
+import time
+from pathlib import Path
+
+from eval_search import PERSIAN
+
+from vie.api import Engine
+from vie.video.decode import frames_at
+
+
+def build_gallery(data: Path, videos: Path, gallery: Path) -> list[str]:
+ """A small mixed archive: dishes, pets, unrelated objects and classroom stills."""
+ from PIL import Image
+
+ if gallery.exists():
+ shutil.rmtree(gallery)
+ dishes = sorted(p for p in (data / "food").iterdir() if p.is_dir())[:6]
+ picks = [(d, 2) for d in dishes]
+ picks += [(data / "animal" / "cat", 3), (data / "animal" / "dog", 3)]
+ picks += [(d, 1) for d in sorted((data / "other").iterdir())[:4]]
+ for folder, count in picks:
+ for i, source in enumerate(sorted(folder.glob("*.jpg"))[:count]):
+ target = gallery / folder.parent.name / f"{folder.name}_{i}.jpg"
+ target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy(source, target)
+ for name, times in (("classroom.mp4", [4, 14, 24]),
+ ("face-demographics-walking-and-pause.mp4", [30, 60])):
+ for frame in frames_at(videos / name, times, max_side=1280):
+ target = gallery / "stills" / f"{Path(name).stem[:12]}_{frame.time:05.1f}.jpg"
+ target.parent.mkdir(parents=True, exist_ok=True)
+ Image.fromarray(frame.image[:, :, ::-1]).save(target, quality=92)
+ return [d.name.replace("_", " ") for d in dishes]
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--data", type=Path, required=True)
+ parser.add_argument("--videos", type=Path, required=True)
+ parser.add_argument("--out", type=Path, default=Path("assets/showcase/search"))
+ parser.add_argument("--work", type=Path, default=Path("data/search-showcase"))
+ args = parser.parse_args()
+
+ gallery, clips = args.work / "gallery", args.work / "videos"
+ dishes = build_gallery(args.data, args.videos, gallery)
+ clips.mkdir(parents=True, exist_ok=True)
+ shutil.copy(args.videos / "classroom.mp4", clips / "classroom.mp4")
+
+ engine = Engine.from_overrides({
+ "paths": {"gallery": str(gallery), "videos": str(clips),
+ "database": str(args.work / "index.db")},
+ "runtime": {"device": "cpu", "low_memory": True},
+ "indexing": {"batch_size": 4, "workers": 4},
+ "video": {"max_duration": 30, "captions": False, "transcript": False},
+ })
+ report: dict = {"model": engine.config.models["vision_language"], "searches": []}
+ started = time.perf_counter()
+ report["index"] = engine.index_images().summary()
+ report["videos"] = engine.index_videos().summary()
+ report["index_seconds"] = round(time.perf_counter() - started, 1)
+ print(report["index"], "|", report["videos"], flush=True)
+
+ frames = args.work / "frames"
+ persian_dish = next((PERSIAN[d] for d in dishes if d in PERSIAN), None)
+ searches = [
+ ("food", dishes[0], lambda q: engine.search_food(q, top_k=8)),
+ ("food", dishes[1], lambda q: engine.search_food(q, top_k=8)),
+ ("animal", "cat", lambda q: engine.search_animal(q, top_k=8)),
+ ("animal", "a puppy", lambda q: engine.search_animal(q, top_k=8)),
+ ("text", "a bowl of soup", lambda q: engine.search_text(q, top_k=8)),
+ ("concept", "people sitting in a classroom",
+ lambda q: engine.search_everything(q, top_k=8, min_moments=2, frames_dir=frames)),
+ ("concept", "گربه", lambda q: engine.search_everything(q, top_k=8, frames_dir=frames)),
+ ("concept", "a theatre stage with actors",
+ lambda q: engine.search_everything(q, top_k=8, frames_dir=frames)),
+ ]
+ if persian_dish:
+ searches.append(("concept", persian_dish,
+ lambda q: engine.search_everything(q, top_k=8, frames_dir=frames)))
+
+ args.out.mkdir(parents=True, exist_ok=True)
+ for number, (kind, query, run) in enumerate(searches, 1):
+ result = run(query)
+ name = f"{number:02d}-{kind}"
+ engine.save_card(result, args.out / f"{name}.jpg")
+ entry = {"card": f"{name}.jpg", "kind": kind, "query": query,
+ "matches": len(result.matches), "milliseconds": round(result.milliseconds, 1),
+ "top": [(m.file_name, round(m.display_score, 4)) for m in result.matches[:5]]}
+ report["searches"].append(entry)
+ print(json.dumps(entry, ensure_ascii=False), flush=True)
+ (args.out / "report.json").write_text(json.dumps(report, indent=2, ensure_ascii=False),
+ encoding="utf-8")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/make_showcase.py b/scripts/make_showcase.py
new file mode 100644
index 0000000..bb3c5a2
--- /dev/null
+++ b/scripts/make_showcase.py
@@ -0,0 +1,141 @@
+"""Regenerate the face showcase in assets/showcase/ from public CC-BY 4.0 videos.
+
+Needs only InsightFace and PyAV (no torch, no GPU). Uses the same face recipe the
+index uses (ArcFace buffalo_l with mirror averaging), the same search ranking
+(:func:`vie.search.face.rank`) and the same renderers the notebook uses.
+
+ python scripts/make_showcase.py --videos DIR --out assets/showcase
+
+DIR holds files from https://github.com/intel-iot-devkit/sample-videos (CC BY 4.0).
+"""
+
+from __future__ import annotations
+
+import argparse
+import time
+from pathlib import Path
+
+from vie.adapters import face_vectors
+from vie.config import Config
+from vie.report.showcase import CardStats, save_search_card
+from vie.search import face as face_search
+from vie.video.render import render_annotated_video, render_people_card
+
+
+def face_app():
+ from insightface.app import FaceAnalysis
+
+ app = FaceAnalysis(name="buffalo_l", providers=["CPUExecutionProvider"],
+ allowed_modules=["detection", "recognition"])
+ app.prepare(ctx_id=-1, det_size=(640, 640))
+ return app
+
+
+def detector(app, min_px: int = 24):
+ def detect(image):
+ found = [f for f in app.get(image)
+ if f.det_score >= 0.5
+ and min(f.bbox[2] - f.bbox[0], f.bbox[3] - f.bbox[1]) >= min_px]
+ return list(zip([tuple(float(v) for v in f.bbox[:4]) for f in found],
+ face_vectors(app, image, found, True), strict=True))
+ return detect
+
+
+def stills(video: Path, out: Path, times: list[float], prefix: str) -> list[Path]:
+ """Save frames at the given seconds as JPEGs: a stand-in photo archive."""
+ import av
+
+ out.mkdir(parents=True, exist_ok=True)
+ wanted = sorted(times)
+ saved: list[Path] = []
+ with av.open(str(video)) as container:
+ for frame in container.decode(video=0):
+ if not wanted:
+ break
+ if frame.time is not None and frame.time >= wanted[0]:
+ path = out / f"{prefix}_{wanted.pop(0):05.1f}s.jpg"
+ frame.to_image().save(path, quality=92)
+ saved.append(path)
+ return saved
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--videos", type=Path, required=True)
+ parser.add_argument("--out", type=Path, default=Path("assets/showcase"))
+ parser.add_argument("--work", type=Path, default=Path("data/showcase"))
+ args = parser.parse_args()
+ config = Config.load()
+ app = face_app()
+ detect = detector(app)
+ v = args.videos
+
+ # ── a photo archive, cut from the videos ──────────────────────────
+ gallery = args.work / "gallery"
+ photos = (
+ stills(v / "head-pose-face-detection-female.mp4", gallery,
+ [8, 22, 37, 51, 66, 80, 95, 110, 124], "lobby_cam1")
+ + stills(v / "head-pose-face-detection-male.mp4", gallery,
+ [10, 30, 50, 70, 90, 110], "lobby_cam2")
+ + stills(v / "head-pose-face-detection-female-and-male.mp4", gallery,
+ [15, 45, 75, 105, 135], "desk_cam")
+ + stills(v / "face-demographics-walking-and-pause.mp4", gallery,
+ [6, 12, 25, 40, 55, 70, 85], "corridor")
+ + stills(v / "classroom.mp4", gallery, [3, 12, 21, 30], "classroom")
+ )
+ queries = stills(v / "head-pose-face-detection-female.mp4", args.work / "queries",
+ [1.0, 129.0], "query")
+
+ import cv2
+
+ indexed = []
+ for path in photos:
+ image = cv2.imread(str(path))
+ for box, vector in detect(image):
+ indexed.append((path.name, tuple(int(b) for b in box), vector, str(path)))
+ query_vectors = [detect(cv2.imread(str(q))) for q in queries]
+ query_vectors = [max(found, key=lambda d: (d[0][2] - d[0][0]) * (d[0][3] - d[0][1]))[1]
+ for found in query_vectors if found]
+
+ started = time.perf_counter()
+ best: dict[str, object] = {}
+ for vector in query_vectors:
+ for match in face_search.rank(vector, indexed, config):
+ if match.file_name not in best or match.score > best[match.file_name].score:
+ best[match.file_name] = match
+ matches = sorted(best.values(), key=lambda m: -m.score)
+ elapsed = (time.perf_counter() - started) * 1000
+ print(f"{len(indexed)} faces in {len(photos)} photos; {len(matches)} matches")
+ args.out.mkdir(parents=True, exist_ok=True)
+ save_search_card(matches, args.out / "face-search.jpg", kind="face",
+ query="The same person, across cameras and poses",
+ query_images=queries,
+ stats=CardStats(search_ms=elapsed, indexed_images=len(photos)),
+ max_results=12)
+
+ # ── videos ────────────────────────────────────────────────────────
+ known = {}
+ for name, video, at in (("Student 1042", "head-pose-face-detection-female.mp4", [2, 40, 128]),
+ ("Student 2177", "head-pose-face-detection-male.mp4", [3, 45, 120])):
+ refs = stills(v / video, args.work / "enrolled" / name, at, "ref")
+ known[name] = [max(found, key=lambda d: (d[0][2] - d[0][0]) * (d[0][3] - d[0][1]))[1]
+ for found in (detect(cv2.imread(str(r))) for r in refs) if found]
+
+ jobs = [
+ ("desk_named.mp4", "head-pose-face-detection-female-and-male.mp4",
+ "Front desk: enrolled students", known, 45),
+ ("corridor_people.mp4", "face-demographics-walking-and-pause.mp4",
+ "Corridor: who passed, and when", None, 90),
+ ("classroom_people.mp4", "classroom.mp4", "Classroom: every face, grouped", None, 20),
+ ]
+ for out_name, src, title, names, seconds in jobs:
+ result = render_annotated_video(v / src, args.work / out_name, detect=detect, known=names,
+ title=title, max_seconds=seconds, detect_every=2,
+ max_side=1280)
+ print(out_name, result.frames, "frames", f"{result.fps_processed:.1f} fps",
+ [(p.label, round(p.seconds, 1)) for p in result.people])
+ render_people_card(result, title=title).save(args.out / out_name.replace(".mp4", ".png"))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/vie/__init__.py b/src/vie/__init__.py
index 65bc0bb..7891529 100644
--- a/src/vie/__init__.py
+++ b/src/vie/__init__.py
@@ -1,6 +1,6 @@
"""Visual Intelligence Engine — multimodal search over an image archive."""
-__version__ = "0.4.0"
+__version__ = "0.6.0"
from vie.config import Config, ConfigError
from vie.scoring import Match, best_per_image, contrastive_score
diff --git a/src/vie/adapters.py b/src/vie/adapters.py
index baa1ef1..cb315b5 100644
--- a/src/vie/adapters.py
+++ b/src/vie/adapters.py
@@ -23,6 +23,49 @@
log = get_logger("adapters")
+def face_vectors(app: Any, image: Any, faces: Sequence[Any], flip: bool) -> list[Any]:
+ """Unit identity vectors for detected faces, optionally mirror-averaged.
+
+ InsightFace has already embedded each face as seen. With ``flip`` the aligned
+ crop is mirrored and embedded once more, in one batch for the whole image, and
+ the two raw vectors are summed before normalising. A face seen at an angle
+ then carries evidence from both of its sides, which is where single-view
+ embeddings lose the most (see scripts/face_benchmark.py).
+ """
+ import numpy as np
+
+ if not faces:
+ return []
+ recogniser = getattr(app, "models", {}).get("recognition")
+ if (not flip or recogniser is None
+ or any(getattr(face, "kps", None) is None for face in faces)):
+ return [face.normed_embedding for face in faces]
+
+ from insightface.utils import face_align
+
+ size = recogniser.input_size[0]
+ mirrored = [
+ np.ascontiguousarray(
+ face_align.norm_crop(image, landmark=face.kps, image_size=size)[:, ::-1]
+ )
+ for face in faces
+ ]
+ extra = np.asarray(recogniser.get_feat(mirrored), dtype=np.float32).reshape(len(faces), -1)
+ out = []
+ for face, other in zip(faces, extra, strict=True):
+ seen = getattr(face, "embedding", None)
+ if seen is None: # a FaceAnalysis built without its recognition module
+ seen = recogniser.get_feat(
+ [face_align.norm_crop(image, landmark=face.kps, image_size=size)]
+ )[0]
+ # Raw vectors are summed before normalising, as InsightFace's own flip test
+ # does: a raw norm tracks how much identity a view carries, so the clearer
+ # side of a turned face counts for more.
+ vector = np.asarray(seen, dtype=np.float32).ravel() + other
+ out.append(vector / max(float(np.linalg.norm(vector)), 1e-12))
+ return out
+
+
class InsightFaceDetector:
"""InsightFace ``buffalo_l``: RetinaFace detection + ArcFace embeddings.
@@ -38,18 +81,20 @@ def __init__(self, bundle: ModelBundle, config: Config) -> None:
self.rejected = 0
def detect(self, image: Any) -> list[tuple[Box, Any]]:
- out: list[tuple[Box, Any]] = []
- for face in self.bundle.face_app.get(image):
+ app = self.bundle.face_app
+ kept: list[tuple[Box, Any]] = []
+ for face in app.get(image):
box = tuple(int(v) for v in face.bbox[:4])
score = float(getattr(face, "det_score", 1.0))
if not is_usable_face(score, box, self.config.face.min_det_score,
self.config.face.min_face_px):
self.rejected += 1
continue
- # normed_embedding is unit length, which is what makes a dot
- # product equal cosine similarity downstream.
- out.append((box, face.normed_embedding))
- return out
+ kept.append((box, face))
+ # Unit length either way, which is what makes a dot product equal cosine
+ # similarity downstream.
+ vectors = face_vectors(app, image, [f for _, f in kept], self.config.face.flip_tta)
+ return [(box, vector) for (box, _), vector in zip(kept, vectors, strict=True)]
class MegaDetectorAdapter:
diff --git a/src/vie/api.py b/src/vie/api.py
new file mode 100644
index 0000000..34c5827
--- /dev/null
+++ b/src/vie/api.py
@@ -0,0 +1,516 @@
+"""One object for the whole system: index, search, understand video, present.
+
+The CLI starts a process per command, so every command loads its models again.
+A notebook, a demo or a service wants the opposite: load each model once and use
+it for everything. :class:`Engine` is that, built only from the tested pieces the
+CLI uses, so both paths give identical answers.
+
+ from vie.api import Engine
+ engine = Engine.from_overrides({"paths": {"gallery": "/data/photos"}})
+ report = engine.index_images() # first run: everything; later: only new files
+ result = engine.search_face(["me.jpg"])
+ engine.save_card(result, "face.png")
+
+Speed comes from not doing work twice:
+
+* an image whose size and modification time are unchanged is skipped without
+ being opened, and an index that is up to date loads no model at all;
+* every model loads on first use and stays loaded for the life of the engine;
+* searches narrow candidates in SQL before any model runs.
+"""
+
+from __future__ import annotations
+
+import time
+from collections.abc import Callable, Sequence
+from dataclasses import dataclass, field, replace
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+
+import yaml
+
+from vie.config import DEFAULT_CONFIG_PATH, Config, merge
+from vie.logging_setup import get_logger
+from vie.scoring import Match
+
+log = get_logger("api")
+
+
+def load_config(overrides: dict | None = None, base: str | Path | None = None) -> Config:
+ """The packaged defaults (or ``base``), with ``overrides`` merged on top."""
+ path = Path(base) if base else DEFAULT_CONFIG_PATH
+ with path.open("r", encoding="utf-8") as handle:
+ raw = yaml.safe_load(handle) or {}
+ raw = merge(raw, overrides or {})
+ # What the caller set explicitly wins over the selected model's thresholds:
+ # re-applying it as that model's override puts it last.
+ model = raw["models"]["vision_language"]
+ explicit = {k: v for k, v in (overrides or {}).items()
+ if k not in ("models", "model_overrides")}
+ if explicit:
+ raw.setdefault("model_overrides", {})
+ raw["model_overrides"][model] = merge(raw["model_overrides"].get(model) or {}, explicit)
+ config = Config.from_dict(raw)
+ config.validate()
+ return config
+
+
+@dataclass
+class IndexReport:
+ scanned: int
+ indexed: int
+ skipped: int
+ pruned: int
+ failed: int
+ seconds: float
+ #: False when pruning was off or refused; see :func:`vie.pipeline.build_index`.
+ pruning: bool = True
+
+ @property
+ def images_per_second(self) -> float:
+ return self.indexed / self.seconds if self.seconds else 0.0
+
+ def summary(self) -> str:
+ return (f"{self.scanned} found · {self.indexed} newly indexed · {self.skipped} unchanged "
+ f"(skipped) · {self.pruned} removed · {self.failed} failed · {self.seconds:.1f}s")
+
+
+@dataclass
+class SearchResult:
+ kind: str
+ query: str
+ matches: list[Match]
+ milliseconds: float
+ query_images: list[Path] = field(default_factory=list)
+ #: Per result, a short tag drawn on its tile, such as "VIDEO 01:24"; same order.
+ badges: list[str | None] = field(default_factory=list)
+
+
+class Engine:
+ """Every capability of the system behind one long-lived object."""
+
+ def __init__(self, config: Config) -> None:
+ self.config = config
+ self._bundle: Any = None
+ self._encoder: Any = None
+ self._video_engines: dict[Any, Any] = {}
+
+ @classmethod
+ def from_overrides(cls, overrides: dict | None = None,
+ base: str | Path | None = None) -> Engine:
+ return cls(load_config(overrides, base))
+
+ # ── models, each loaded once ──────────────────────────────────────
+
+ @property
+ def bundle(self) -> Any:
+ if self._bundle is None:
+ from vie.models import load_all
+
+ self._bundle = load_all(self.config)
+ return self._bundle
+
+ @property
+ def encoder(self) -> Any:
+ if self._encoder is None:
+ from vie.models import VisionLanguageEncoder
+
+ self._encoder = VisionLanguageEncoder(self.bundle)
+ return self._encoder
+
+ def _runtime(self, _config: Config | None = None) -> tuple[Any, Any, Any]:
+ return self.bundle, self.encoder, self.encoder
+
+ # ── the archive ───────────────────────────────────────────────────
+
+ def counts(self) -> dict[str, int]:
+ """How much is indexed. Opens no model."""
+ from vie.database import Index
+
+ if not Path(self.config.database_path).exists():
+ return {"images": 0, "faces": 0, "animals": 0, "food": 0, "videos": 0}
+ index = Index(self.config.database_path)
+ index.initialise()
+ with index.connect() as conn:
+ def one(sql: str) -> int:
+ try:
+ return int(conn.execute(sql).fetchone()[0])
+ except Exception: # noqa: BLE001 - a table that does not exist yet
+ return 0
+ return {
+ "images": one("SELECT COUNT(*) FROM gallery_meta"),
+ "faces": one("SELECT COUNT(*) FROM face_embeddings"),
+ "animals": one("SELECT COUNT(*) FROM gallery_meta WHERE has_animal = 1"),
+ "food": one("SELECT COUNT(*) FROM gallery_meta WHERE has_food = 1"),
+ "videos": one("SELECT COUNT(*) FROM videos"),
+ }
+
+ def index_images(
+ self,
+ *,
+ rebuild: bool = False,
+ rehash: bool = False,
+ prune: bool = True,
+ progress: Callable[[Any], None] | None = None,
+ ) -> IndexReport:
+ """Bring the index up to date with the gallery folder.
+
+ The first run reads every image. After that only new or changed files are
+ read; everything else is skipped from its size and modification time, and if
+ nothing changed no model is even loaded.
+
+ Pruning removes rows for files no longer on disk, but :func:`build_index`
+ refuses it when most of the archive seems to have vanished at once: that is a
+ disconnected drive or a mistyped path far more often than a real deletion.
+ """
+ from vie.adapters import build_detectors
+ from vie.models import SpeciesClassifier
+ from vie.pipeline import build_index
+
+ config = self.config
+ bundle = self.bundle
+ detectors = build_detectors(bundle, config)
+ encoder = self.encoder
+ classifier = SpeciesClassifier(bundle)
+ started = time.perf_counter()
+ stats = build_index(
+ config,
+ loader=detectors.loader,
+ size_of=detectors.size_of,
+ face_detector=detectors.face,
+ animal_detector=detectors.animal,
+ food_detector=detectors.food,
+ embed_batch=encoder.embed_batch,
+ food_gate=lambda vectors: encoder.food_gate_scores(vectors, config),
+ classify_crops=classifier.classify,
+ crop=detectors.crop,
+ class_names=lambda: bundle.class_names,
+ prune=prune,
+ rebuild=rebuild,
+ rehash=rehash,
+ on_progress=progress,
+ )
+ return IndexReport(stats.scanned, stats.indexed, stats.skipped, stats.pruned,
+ stats.failed, time.perf_counter() - started,
+ pruning=prune and not stats.prune_refused)
+
+ # ── search ────────────────────────────────────────────────────────
+
+ def _search(self, kind: str, *, query: str = "", images: Sequence[Path] = (),
+ top_k: int | None = None) -> SearchResult:
+ from vie.query import run_search
+
+ args = SimpleNamespace(kind=kind, query=query, top_k=top_k,
+ image=[Path(p) for p in images] or None)
+ started = time.perf_counter()
+ matches = run_search(args, self.config, self._runtime)
+ shown = query or " + ".join(Path(p).name for p in images)
+ return SearchResult(kind, shown, matches, (time.perf_counter() - started) * 1000,
+ [Path(p) for p in images])
+
+ def search_face(self, images: str | Path | Sequence[str | Path],
+ top_k: int | None = None) -> SearchResult:
+ """Every photo of this person. Several photos of them find more."""
+ if isinstance(images, str | Path):
+ images = [images]
+ return self._search("face", images=[Path(p) for p in images], top_k=top_k)
+
+ def search_animal(self, query: str, top_k: int | None = None) -> SearchResult:
+ return self._search("animal", query=query, top_k=top_k)
+
+ def search_food(self, query: str, top_k: int | None = None) -> SearchResult:
+ return self._search("food", query=query, top_k=top_k)
+
+ def search_text(self, query: str, top_k: int | None = None) -> SearchResult:
+ return self._search("text", query=query, top_k=top_k or self.config.top_k)
+
+ def search_everything(
+ self,
+ query: str,
+ *,
+ top_k: int = 12,
+ min_moments: int = 4,
+ frames_dir: str | Path | None = None,
+ ) -> SearchResult:
+ """One description, every photo and every video moment that shows it.
+
+ Photos are scored by their best region and video moments by their keyframes,
+ both through SigLIP's calibrated probability that the description fits, so
+ they rank on one scale. Video keyframes carry a whole-frame vector only, which
+ reads a little lower than a photo's best region, so the strongest
+ ``min_moments`` moments are kept even when photos outscore them.
+
+ Each moment becomes a result whose picture is the frame at its start, grabbed
+ from the video into ``frames_dir`` (a temporary folder by default).
+ """
+ import tempfile
+
+ from vie.query import query_vector
+
+ started = time.perf_counter()
+ photos = self.search_text(query, top_k=top_k).matches if self.counts()["images"] else []
+ moments: list[Any] = []
+ if self.counts()["videos"]:
+ from vie.video.store import VideoStore
+
+ store = VideoStore(self.config)
+ store.initialise()
+ with store.connect() as conn:
+ store.index.check_embedding_model(conn, self.config.models["vision_language"])
+ vector = query_vector(self.encoder, query, self.config)
+ moments = store.search_visual(conn, vector, top_k=top_k)
+ calibrate = getattr(self.encoder, "calibrated", None)
+
+ def probability(cosine: float) -> float:
+ value = calibrate(cosine) if calibrate is not None else None
+ return cosine if value is None else value
+
+ frames = Path(frames_dir or tempfile.mkdtemp(prefix="vie-frames-"))
+ clips: list[tuple[Match, str]] = []
+ floor = self.config.min_probability if calibrate is not None else 0.0
+ for moment in moments:
+ if floor and probability(moment.score) < floor:
+ continue # the same "nothing found" floor as photos
+ picture = grab_frame(moment.file_path, moment.start, frames)
+ if picture is None:
+ continue
+ clock = f"{int(moment.start // 60)}:{int(moment.start % 60):02d}"
+ clips.append((Match(file_name=f"{Path(moment.video_key).stem} @ {clock}",
+ file_path=str(picture), score=probability(moment.score)),
+ f"VIDEO {clock}"))
+
+ ranked = [(m.display_score, m, None) for m in photos]
+ ranked += [(m.score, m, badge) for m, badge in clips]
+ ranked.sort(key=lambda item: -item[0])
+ chosen = ranked[:top_k]
+ missing = [item for item in ranked[top_k:] if item[2] is not None]
+ have = sum(1 for item in chosen if item[2] is not None)
+ for item in missing[: max(0, min_moments - have)]:
+ # Make room by dropping the weakest photo, keeping the order by score.
+ photo_positions = [i for i, entry in enumerate(chosen) if entry[2] is None]
+ if not photo_positions:
+ break
+ chosen.pop(photo_positions[-1])
+ chosen.append(item)
+ chosen.sort(key=lambda item: -item[0])
+ matches = [replace(m, confidence=score) for score, m, _ in chosen]
+ return SearchResult("concept", query, matches, (time.perf_counter() - started) * 1000,
+ badges=[badge for _, _, badge in chosen])
+
+ def most_photographed_face(self, crop_to: str | Path | None = None,
+ sample: int = 3000) -> Path | None:
+ """A photo of whoever appears most often: a sensible demo query.
+
+ Counts, for a sample of indexed faces, how many others clear the match
+ threshold, and picks the best-connected face, preferring a large one.
+
+ A query photo is searched by its *largest* face, which in a group photo
+ need not be the face that was picked. Give ``crop_to`` and the picked face
+ is saved there, padded, and that file is returned instead of the photo.
+ """
+ import numpy as np
+
+ from vie.database import Index
+
+ if not Path(self.config.database_path).exists():
+ return None
+ index = Index(self.config.database_path)
+ with index.connect() as conn:
+ rows = list(index.iter_faces(conn))
+ scales = index.scales(conn)
+ if not rows:
+ return None
+ rng = np.random.default_rng(self.config.seed)
+ if len(rows) > sample:
+ rows = [rows[i] for i in rng.choice(len(rows), sample, replace=False)]
+ vectors = np.vstack([np.asarray(r[2], dtype=np.float32) for r in rows])
+ degree = (vectors @ vectors.T >= self.config.face.match_threshold).sum(axis=1)
+ area = np.array([(b[2] - b[0]) * (b[3] - b[1]) for _, b, _, _ in rows], dtype=float)
+ # lexsort's primary key is the last one: connections first, then size.
+ name, box, _, file_path = rows[int(np.lexsort((-area, -degree))[0])]
+ if crop_to is None:
+ return Path(file_path)
+
+ from vie.report.showcase import open_rgb
+
+ photo = open_rgb(file_path, max_side=100_000)
+ # Boxes are stored at the scale the index saw; map back to the file.
+ scale = scales.get(name, 1.0) or 1.0
+ x1, y1, x2, y2 = (v / scale for v in box)
+ pad = 0.6 * max(x2 - x1, y2 - y1)
+ region = (max(0, int(x1 - pad)), max(0, int(y1 - pad)),
+ min(photo.width, int(x2 + pad)), min(photo.height, int(y2 + pad)))
+ out = Path(crop_to)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ photo.crop(region).save(out, quality=95)
+ return out
+
+ # ── presentation ──────────────────────────────────────────────────
+
+ def save_card(self, result: SearchResult, path: str | Path, **kwargs: Any) -> Path:
+ """A presentation-grade PNG or JPEG of one search."""
+ from vie.report.showcase import CardStats, save_search_card
+
+ return save_search_card(
+ result.matches, path, kind=result.kind, query=result.query,
+ query_images=result.query_images, badges=result.badges or None,
+ stats=CardStats(search_ms=result.milliseconds,
+ indexed_images=self.counts()["images"]),
+ **kwargs,
+ )
+
+ def save_html(self, result: SearchResult, path: str | Path) -> Path:
+ from vie.report import write_search_report
+
+ return write_search_report(result.matches, path, kind=result.kind, query=result.query)
+
+ # ── faces as a function, for video ────────────────────────────────
+
+ def face_detect(self) -> Callable[[Any], list[tuple[Any, Any]]]:
+ """``frame -> [(box, unit vector)]`` with exactly the index's face recipe."""
+ from vie.adapters import InsightFaceDetector
+
+ detector = InsightFaceDetector(self.bundle, self.config)
+ return detector.detect
+
+ def known_people(self, folder: str | Path) -> dict[str, list[Any]]:
+ """Reference vectors from ``folder//*.jpg``, one entry per person."""
+ from vie.pipeline import IMAGE_SUFFIXES
+ from vie.query import query_face_embedding
+
+ root = Path(folder)
+ people: dict[str, list[Any]] = {}
+ if not root.is_dir():
+ return people
+ for person in sorted(p for p in root.iterdir() if p.is_dir()):
+ vectors = []
+ for photo in sorted(person.iterdir()):
+ if photo.suffix.lower() not in IMAGE_SUFFIXES:
+ continue
+ try:
+ vectors.append(query_face_embedding(photo, self.config, self.bundle))
+ except ValueError as exc:
+ log.warning("%s", exc)
+ if vectors:
+ people[person.name] = vectors
+ return people
+
+ # ── video ─────────────────────────────────────────────────────────
+
+ def video_engine(self, stages: Any = None) -> Any:
+ from vie.video.library import Stages, build_engine
+
+ stages = stages or Stages.from_config(self.config)
+ if stages not in self._video_engines:
+ self._video_engines[stages] = build_engine(self.config, self.bundle, self.encoder,
+ stages)
+ return self._video_engines[stages]
+
+ def index_videos(self, *, stages: Any = None, prune: bool = True,
+ on_analysed: Callable[[str, Path, Any], None] | None = None) -> Any:
+ """Analyse only new or changed videos; ``on_analysed`` sees each one."""
+ from vie.video.library import index_videos
+
+ return index_videos(self.config, self.video_engine(stages), prune=prune,
+ on_analysed=on_analysed)
+
+ def analyse_video(self, path: str | Path, *, max_duration: float | None = None,
+ stages: Any = None) -> Any:
+ if max_duration is None:
+ return self.video_engine(stages).analyse(path)
+ from vie.video.library import Stages, build_engine
+
+ config = replace(self.config, video=replace(self.config.video, max_duration=max_duration))
+ engine = build_engine(config, self.bundle, self.encoder,
+ stages or Stages.from_config(config))
+ return engine.analyse(path)
+
+ def render_video(self, source: str | Path, output: str | Path, *,
+ known: dict[str, list[Any]] | None = None, analysis: Any = None,
+ title: str | None = None, **kwargs: Any) -> Any:
+ """An annotated MP4: tracked faces, names or people, labels and a timeline."""
+ from vie.video.render import render_annotated_video
+
+ return render_annotated_video(
+ source, output, detect=self.face_detect(), known=known, title=title,
+ shot_labels=(shot_labeller(analysis, self.config.video.label_threshold)
+ if analysis is not None else None),
+ match_threshold=self.config.face.match_threshold,
+ cluster_threshold=self.config.video.face_cluster_threshold,
+ **kwargs,
+ )
+
+ def search_videos(self, query: str | None = None, image: str | Path | None = None,
+ top_k: int = 10) -> list[Any]:
+ """Moments across every indexed video, by description and speech, or by face.
+
+ Give exactly one of ``query`` or ``image``. For a query, transcript hits come
+ first, as in ``vie video search``: a spoken word is an exact match, while a
+ visual score is a cosine on another scale, so the two are not merged by score.
+ """
+ from vie.query import query_face_embedding, query_vector
+ from vie.video.store import VideoStore
+
+ if (query is None) == (image is None):
+ raise ValueError("give exactly one of query or image")
+ store = VideoStore(self.config)
+ store.initialise()
+ with store.connect() as conn:
+ store.index.check_embedding_model(conn, self.config.models["vision_language"])
+ if image is not None:
+ vector = query_face_embedding(image, self.config, self.bundle)
+ return store.search_face(conn, vector, top_k)
+ spoken = store.search_speech(conn, query, top_k)
+ seen = store.search_visual(conn, query_vector(self.encoder, query, self.config),
+ top_k=top_k)
+ return (spoken + seen)[:top_k]
+
+
+def grab_frame(video: str | Path, at: float, folder: Path, max_side: int = 1280) -> Path | None:
+ """Save the frame at ``at`` seconds as a JPEG in ``folder``; ``None`` if unreadable.
+
+ Through the analysis decoder, so a rotated phone video comes out upright.
+ """
+ from PIL import Image
+
+ from vie.video.decode import frames_at
+
+ target = folder / f"{Path(video).stem}-{at:08.2f}.jpg"
+ if target.exists():
+ return target
+ try:
+ frame = next(iter(frames_at(video, [at], max_side=max_side)), None)
+ except Exception: # noqa: BLE001 - a missing or damaged video loses one tile, not the card
+ log.warning("could not read %s at %.1f s", video, at)
+ return None
+ if frame is None:
+ return None
+ folder.mkdir(parents=True, exist_ok=True)
+ Image.fromarray(frame.image[:, :, ::-1]).save(target, quality=90)
+ return target
+
+
+def shot_labeller(analysis: Any, threshold: float = 0.4) -> Callable[[float], list[str]]:
+ """``time -> the current shot's strongest labels``, for the video's header.
+
+ ``threshold`` is the same presence share as ``video.label_threshold``: a label
+ the analysis would not call present in a shot is not shown on it either.
+ """
+ import bisect
+
+ shots = sorted(getattr(analysis, "shots", []) or [], key=lambda shot: shot.start)
+ starts = [shot.start for shot in shots]
+ labels = {item.shot: item for item in getattr(analysis, "shot_labels", []) or []}
+
+ def at(t: float) -> list[str]:
+ position = bisect.bisect_right(starts, t) - 1
+ if position < 0 or t > shots[position].end + 1e-6:
+ return []
+ found = labels.get(shots[position].index)
+ if found is None:
+ return []
+ ranked = sorted(found.labels.values(), key=lambda pair: -pair[1])
+ return [label for label, score in ranked if score >= threshold][:2]
+
+ return at
diff --git a/src/vie/cli.py b/src/vie/cli.py
index 1cda507..55376a7 100644
--- a/src/vie/cli.py
+++ b/src/vie/cli.py
@@ -1,7 +1,7 @@
"""Command-line interface.
vie index
- vie search face --image query.jpg
+ vie search face --image query.jpg [--image another.jpg ...]
vie search animal --query "red panda"
vie search food --query "French fries"
vie search text --query "a man with a blue backpack" --html results.html
@@ -71,7 +71,8 @@ def _build_parser() -> argparse.ArgumentParser:
kinds = search.add_subparsers(dest="kind", required=True)
face = kinds.add_parser("face", help="find a person by photo", parents=[common])
- face.add_argument("--image", required=True, type=Path)
+ face.add_argument("--image", required=True, type=Path, action="append",
+ help="a photo of the person; repeat with more photos for better recall")
face.add_argument("--top-k", type=int, default=None)
face.add_argument("--html", type=Path, default=None, help="write a visual report here")
@@ -213,7 +214,8 @@ def _run_search(args: argparse.Namespace, config: Config, as_json: bool) -> int:
if args.html is not None:
from vie.report import write_search_report
- query = str(args.image) if args.kind == "face" else args.query
+ query = (" + ".join(Path(p).name for p in args.image) if args.kind == "face"
+ else args.query)
write_search_report(matches, args.html, kind=args.kind, query=query)
log.info("report written to %s", args.html)
_render(matches, as_json)
@@ -293,6 +295,7 @@ def _run_video(args: argparse.Namespace, config: Config, as_json: bool) -> int:
def _run_video_search(args: argparse.Namespace, config: Config, as_json: bool) -> int:
from dataclasses import asdict
+ from vie.query import query_vector
from vie.video.store import VideoStore
from vie.video.understand import format_clock
@@ -321,7 +324,7 @@ def _run_video_search(args: argparse.Namespace, config: Config, as_json: bool) -
else:
moments = store.search_speech(conn, args.query, args.top_k)
moments += store.search_visual(
- conn, encoder.encode_text(args.query), top_k=args.top_k
+ conn, query_vector(encoder, args.query, config), top_k=args.top_k
)
if as_json:
diff --git a/src/vie/config.py b/src/vie/config.py
index d83ca8c..4de0c42 100644
--- a/src/vie/config.py
+++ b/src/vie/config.py
@@ -38,6 +38,32 @@ class ConfigError(ValueError):
"""Raised when a configuration is internally inconsistent."""
+def merge(base: dict, extra: dict) -> dict:
+ """Deep-merge ``extra`` over ``base`` without mutating either."""
+ import copy
+
+ out = copy.deepcopy(base)
+ for key, value in (extra or {}).items():
+ if isinstance(value, dict) and isinstance(out.get(key), dict):
+ out[key] = merge(out[key], value)
+ else:
+ out[key] = copy.deepcopy(value)
+ return out
+
+
+def with_model_overrides(raw: dict[str, Any]) -> dict[str, Any]:
+ """Apply ``model_overrides[]`` on top of the rest of the config.
+
+ Thresholds are on each model's own scale: SigLIP 2's logit margins run narrower
+ than SigLIP's, so the same number means a stricter cut. Each model's measured
+ thresholds live under its name and follow it automatically, so switching models
+ never silently keeps another model's thresholds.
+ """
+ model = (raw.get("models") or {}).get("vision_language", "")
+ override = (raw.get("model_overrides") or {}).get(model)
+ return merge(raw, override) if override else raw
+
+
@dataclass(frozen=True)
class FaceConfig:
match_threshold: float
@@ -47,6 +73,14 @@ class FaceConfig:
det_size_small: int
small_image_px: int
max_index_dimension: int
+ # Embed each face and its mirror image and average the two. Measured in
+ # scripts/face_benchmark.py; costs one extra recognition pass per face.
+ flip_tta: bool = True
+
+ @property
+ def recipe(self) -> str:
+ """What produced the stored face vectors, so a change can be noticed."""
+ return "arcface" + ("+flip" if self.flip_tta else "")
@dataclass(frozen=True)
@@ -126,6 +160,7 @@ class VideoConfig:
whisper_model: str = "small"
whisper_device: str = "auto"
face_cluster_threshold: float = 0.45
+ face_exemplars: int = 4
min_face_sightings: int = 2
presence_gap: float = 12.0
label_threshold: float = 0.40
@@ -162,6 +197,10 @@ class Config:
database_path: Path
model_cache: str
models: dict[str, str]
+ #: Phrasings a free-text query is averaged over; see ``encode_query``.
+ query_templates: tuple[str, ...] = ("{query}",)
+ #: Below this calibrated probability a free-text result is not shown at all.
+ min_probability: float = 0.0
# ---- construction -------------------------------------------------
@@ -178,6 +217,7 @@ def load(cls, path: str | Path | None = None) -> Config:
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> Config:
+ raw = with_model_overrides(raw)
return cls(
raw=raw,
face=FaceConfig(**raw["face"]),
@@ -199,6 +239,8 @@ def from_dict(cls, raw: dict[str, Any]) -> Config:
database_path=Path(raw["paths"]["database"]),
model_cache=str(raw["paths"].get("model_cache", "") or ""),
models=dict(raw["models"]),
+ query_templates=tuple(raw["text"].get("query_templates") or ("{query}",)),
+ min_probability=float(raw["text"].get("min_probability", 0.0)),
)
# ---- validation ---------------------------------------------------
@@ -206,6 +248,8 @@ def from_dict(cls, raw: dict[str, Any]) -> Config:
def validate(self) -> None:
"""Enforce the index/search invariants. Raises :class:`ConfigError`."""
problems: list[str] = []
+ if not self.query_templates or any("{query}" not in t for t in self.query_templates):
+ problems.append("text.query_templates must each contain {query}")
# A candidate the gate rejects is unreachable at search time, so the
# gate must never be the stricter of the two.
diff --git a/src/vie/database.py b/src/vie/database.py
index 735be18..7599671 100644
--- a/src/vie/database.py
+++ b/src/vie/database.py
@@ -34,7 +34,7 @@
from vie.embeddings import from_blob, to_blob
-SCHEMA_VERSION = 3
+SCHEMA_VERSION = 4
class SchemaError(RuntimeError):
@@ -68,7 +68,9 @@ class EmbeddingModelMismatch(SchemaError):
-- Boxes are stored in the coordinates of the capped image the models saw.
-- Dividing by this factor maps them back onto the original file.
index_scale REAL NOT NULL DEFAULT 1.0,
- mtime_ns INTEGER NOT NULL DEFAULT 0
+ mtime_ns INTEGER NOT NULL DEFAULT 0,
+ -- 1 once the image's object vectors are stored (see object_embeddings).
+ objects_embedded INTEGER NOT NULL DEFAULT 0
);
-- (file_name, bbox) is the natural key: one row per detected face. Without it
@@ -123,6 +125,22 @@ class EmbeddingModelMismatch(SchemaError):
FOREIGN KEY (file_name) REFERENCES gallery_meta (file_name) ON DELETE CASCADE
);
+-- A vision-language vector of each detected animal and dish, cropped and padded
+-- exactly as the search paths crop them. With these stored, a food query or an
+-- out-of-vocabulary species query is a matrix product: no image is decoded and no
+-- image model runs at query time.
+CREATE TABLE IF NOT EXISTS object_embeddings (
+ file_name TEXT NOT NULL,
+ kind TEXT NOT NULL, -- 'animal' or 'food'
+ bbox TEXT NOT NULL, -- the detector's box
+ crop TEXT NOT NULL, -- the padded box that was encoded
+ confidence REAL NOT NULL,
+ class_id INTEGER,
+ embedding BLOB NOT NULL,
+ PRIMARY KEY (file_name, kind, bbox),
+ FOREIGN KEY (file_name) REFERENCES gallery_meta (file_name) ON DELETE CASCADE
+);
+
CREATE INDEX IF NOT EXISTS idx_meta_face ON gallery_meta (has_face);
CREATE INDEX IF NOT EXISTS idx_meta_animal ON gallery_meta (has_animal);
CREATE INDEX IF NOT EXISTS idx_meta_food ON gallery_meta (has_food);
@@ -132,6 +150,7 @@ class EmbeddingModelMismatch(SchemaError):
#: Every table derived from an image, and so cleared when that image changes.
DERIVED_TABLES = (
"face_embeddings", "clip_embeddings", "animal_boxes", "animal_predictions", "food_boxes",
+ "object_embeddings",
)
_ADDED_COLUMNS = {
@@ -140,6 +159,9 @@ class EmbeddingModelMismatch(SchemaError):
"food_vlm_score": "REAL NOT NULL DEFAULT 0.0",
"index_scale": "REAL NOT NULL DEFAULT 1.0",
"mtime_ns": "INTEGER NOT NULL DEFAULT 0",
+ # 1 once an image's object vectors were written; 0 for images indexed before,
+ # which the search paths still serve by cropping at query time.
+ "objects_embedded": "INTEGER NOT NULL DEFAULT 0",
}
@@ -327,12 +349,14 @@ def upsert_image(
food_vlm_score: float = 0.0,
index_scale: float = 1.0,
mtime_ns: int = 0,
+ objects_embedded: bool = False,
) -> None:
conn.execute(
"""INSERT INTO gallery_meta
(file_name, file_path, content_hash, file_size, has_face, has_animal, has_food,
- food_by_detector, food_by_vlm, food_vlm_score, index_scale, mtime_ns)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ food_by_detector, food_by_vlm, food_vlm_score, index_scale, mtime_ns,
+ objects_embedded)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (file_name) DO UPDATE SET
file_path = excluded.file_path, content_hash = excluded.content_hash,
file_size = excluded.file_size, has_face = excluded.has_face,
@@ -340,10 +364,11 @@ def upsert_image(
food_by_detector = excluded.food_by_detector,
food_by_vlm = excluded.food_by_vlm,
food_vlm_score = excluded.food_vlm_score,
- index_scale = excluded.index_scale, mtime_ns = excluded.mtime_ns""",
+ index_scale = excluded.index_scale, mtime_ns = excluded.mtime_ns,
+ objects_embedded = excluded.objects_embedded""",
(file_name, file_path, hash_, size, int(has_face), int(has_animal), int(has_food),
int(food_by_detector), int(food_by_vlm), float(food_vlm_score),
- float(index_scale), int(mtime_ns)),
+ float(index_scale), int(mtime_ns), int(objects_embedded)),
)
def add_face(
@@ -424,6 +449,64 @@ def known_files(self, conn: sqlite3.Connection) -> dict[str, tuple[str, int, int
)
}
+ def add_object(
+ self, conn: sqlite3.Connection, file_name: str, kind: str, box: tuple,
+ crop: tuple, confidence: float, class_id: int | None, embedding: np.ndarray,
+ ) -> None:
+ conn.execute(
+ "INSERT OR REPLACE INTO object_embeddings "
+ "(file_name, kind, bbox, crop, confidence, class_id, embedding) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
+ (file_name, kind, format_box(box), format_box(crop), float(confidence),
+ class_id, to_blob(embedding)),
+ )
+
+ def object_vectors(
+ self, conn: sqlite3.Connection, kind: str
+ ) -> dict[str, list[tuple[tuple, tuple, float, int | None, np.ndarray]]]:
+ """``file_name -> [(box, crop, confidence, class_id, vector)]`` for one kind."""
+ found: dict[str, list[tuple[tuple, tuple, float, int | None, np.ndarray]]] = {}
+ for name, bbox, crop, confidence, class_id, blob in conn.execute(
+ "SELECT file_name, bbox, crop, confidence, class_id, embedding "
+ "FROM object_embeddings WHERE kind = ?", (kind,)
+ ):
+ found.setdefault(name, []).append(
+ (parse_box(bbox), parse_box(crop), confidence, class_id, from_blob(blob))
+ )
+ return found
+
+ def embedded_images(self, conn: sqlite3.Connection) -> set[str]:
+ """Images whose object vectors are stored, so search needs no pixels."""
+ return {r[0] for r in conn.execute(
+ "SELECT file_name FROM gallery_meta WHERE objects_embedded = 1")}
+
+ def whole_vectors(self, conn: sqlite3.Connection, names: set[str]) -> dict[str, np.ndarray]:
+ """The stored whole-frame vector of each named image."""
+ if not names:
+ return {}
+ return {
+ name: from_blob(blob)
+ for name, blob in conn.execute(
+ "SELECT file_name, embedding FROM clip_embeddings WHERE region = 'whole'")
+ if name in names
+ }
+
+ def refresh_files(
+ self, conn: sqlite3.Connection, rows: list[tuple[str, str, int, int]]
+ ) -> None:
+ """Record where unchanged files now are, and their current size and mtime.
+
+ ``rows`` is ``(file_name, file_path, size, mtime_ns)`` for files whose content
+ hash matched. Without this, a file whose mtime moved (a copy, a sync, an
+ extracted archive) is re-hashed on every run, and a gallery that moved keeps
+ paths to where it used to be.
+ """
+ conn.executemany(
+ "UPDATE gallery_meta SET file_path = ?, file_size = ?, mtime_ns = ? "
+ "WHERE file_name = ?",
+ [(path, size, mtime, name) for name, path, size, mtime in rows],
+ )
+
def candidates(self, conn: sqlite3.Connection, flag: str) -> list[GalleryRow]:
if flag not in {"has_face", "has_animal", "has_food"}:
raise ValueError(f"unknown flag {flag!r}")
diff --git a/src/vie/default.yaml b/src/vie/default.yaml
index 8ac9973..9acd321 100644
--- a/src/vie/default.yaml
+++ b/src/vie/default.yaml
@@ -51,8 +51,13 @@ models:
# openai/clip-vit-large-patch14 428M params ~75.5%
# google/siglip-base-patch16-224 203M params ~76.2% (runs on a laptop CPU)
# google/siglip-so400m-patch14-384 877M params ~83.2% (wants a GPU)
+ # google/siglip2-so400m-patch14-384 1.1B params (wants a GPU; multilingual)
+ # Measured here (results/search_eval.json, Search quality workflow): SigLIP 2 ties
+ # SigLIP on English queries (mAP 1.0 both) and leads on Persian (0.945 vs 0.738);
+ # with its own thresholds below it is level on food and species (precision 0.90-0.94
+ # vs 0.89 over two runs, species 0.95 both), so Persian decides.
# Changing this requires `vie index --rebuild`; the index refuses to mix models.
- vision_language: google/siglip-so400m-patch14-384
+ vision_language: google/siglip2-so400m-patch14-384
# "sigmoid" for SigLIP, "softmax" for CLIP -- they are trained with
# different objectives and their logits are not interchangeable.
score_mode: sigmoid
@@ -62,6 +67,9 @@ models:
face:
# ArcFace embeddings are L2-normalised, so a dot product is cosine similarity.
+ # Measured in scripts/face_benchmark.py: the strongest different-person pair
+ # scored 0.30, while 98.9% of same-person pairs (97.6% at hard head poses)
+ # scored at least 0.46. Lower it for recall, raise it to be stricter.
match_threshold: 0.46
# Quality gate at index time. Deliberately permissive: the goal is only to
@@ -75,6 +83,11 @@ face:
small_image_px: 400
# Image cap, in pixels, applied to the longest side before any model runs.
max_index_dimension: 1600
+ # Embed every face twice, as seen and mirrored, and average. Borrowed from
+ # student-face-gate, where it was measured on LFW; re-measured here for ArcFace
+ # in scripts/face_benchmark.py. Costs one extra 112x112 recognition pass per
+ # face, which is small next to detection. Changing it: `vie index --rebuild`.
+ flip_tta: true
animal:
# MegaDetector v5a raw YOLOv5 head: 0=animal, 1=person, 2=vehicle.
@@ -153,6 +166,22 @@ food:
text:
top_k: 5
+ # A free-text query is encoded in each of these phrasings and the vectors are
+ # averaged (prompt ensembling), which describes a concept more steadily than one
+ # wording. Measured in scripts/eval_search.py; ["{query}"] turns it off.
+ query_templates:
+ - "{query}"
+ - "a photo of {query}"
+ - "a picture showing {query}"
+ - "a scene with {query}"
+
+ # "Nothing found" instead of the least-bad few. SigLIP's calibrated probability
+ # that the description fits, measured in scripts/eval_search.py on both SigLIP and
+ # SigLIP 2: the right image of a present concept scored at least 0.009, while the
+ # best image for 7 of 8 absent concepts scored under 0.001 (median 0.00001).
+ # 0 shows everything. Applies to SigLIP models only, which calibrate.
+ min_probability: 0.001
+
# Multi-crop embeddings. One vector for the whole frame plus a 2x2 grid and
# a centre crop; a query scores against the best-matching region.
#
@@ -196,6 +225,10 @@ video:
whisper_device: auto # auto | cpu | cuda
face_cluster_threshold: 0.45 # the same ArcFace scale as face.match_threshold
+ # Face vectors kept per person per video, chosen to differ (frontal, turned, lit
+ # differently). Searching for someone compares against these; when they appear is
+ # stored as plain intervals. 4 keeps 1000 videos of ten people each under 100 MB.
+ face_exemplars: 4
min_face_sightings: 2 # one glimpse is more likely a false detection
presence_gap: 12.0 # seconds apart that still count as continuous
@@ -252,3 +285,15 @@ video:
- someone driving
- animals moving
- text and graphics on screen
+
+# Thresholds on each model's own scale, applied when that model is selected above.
+# SigLIP 2's logit margins run narrower than SigLIP's (median food margin 6.3 vs 8.5),
+# so SigLIP's 0.98 / 0.91 kept its precision but lost a tenth of the right answers.
+# Calibrated with scripts/calibrate.py on the labelled set, in the Search quality
+# workflow; the sections above hold SigLIP so400m's values.
+model_overrides:
+ google/siglip2-so400m-patch14-384:
+ food:
+ match_threshold: 0.92 # precision 0.90-0.94, recall 0.98-1.00 (0.98: 0.935 / 0.896)
+ animal:
+ match_threshold: 0.84 # precision 0.95, recall 0.95-1.00 (0.91: 1.000 / 0.900)
diff --git a/src/vie/models.py b/src/vie/models.py
index 975a0b5..286dee0 100644
--- a/src/vie/models.py
+++ b/src/vie/models.py
@@ -36,6 +36,9 @@
log = get_logger("models")
+#: SigLIP and SigLIP 2 both trained their text tower on exactly 64 tokens.
+SIGLIP_TEXT_TOKENS = 64
+
#: Pinned so a change to yolov5 master cannot alter detection behaviour.
YOLOV5_PIN = "v7.0"
@@ -198,7 +201,12 @@ def build() -> Any:
kwargs: dict[str, Any] = {}
if self.cache_dir:
kwargs["root"] = str(self.cache_dir / "insightface")
- app = FaceAnalysis(name=self.config.models["face_pack"], providers=providers, **kwargs)
+ # The pack also ships two landmark models and an age/gender model, which
+ # FaceAnalysis runs on every face by default. Nothing here reads their
+ # output; skipping them halved the time per face (1144 -> 527 ms on a
+ # four-face 1080p frame, CPU) with identical detections and embeddings.
+ app = FaceAnalysis(name=self.config.models["face_pack"], providers=providers,
+ allowed_modules=["detection", "recognition"], **kwargs)
size = self.config.face.det_size_large
app.prepare(ctx_id=0 if runtime.is_cuda else -1, det_size=(size, size))
log.info("loaded InsightFace %s", self.config.models["face_pack"])
@@ -343,10 +351,16 @@ def is_siglip(self) -> bool:
def _tokenize(self, texts: Sequence[str]) -> Any:
# SigLIP was trained on text padded to its full 64 tokens, and its text
# tower has no attention mask. padding=True produces a different, wrong
- # embedding with no error at all.
- padding = "max_length" if self.is_siglip else True
+ # embedding with no error at all. The length is given explicitly because
+ # SigLIP 2's Gemma tokenizer declares no maximum of its own, so
+ # "max_length" alone would pad to an effectively unbounded length.
+ if self.is_siglip:
+ return self.bundle.clip_processor(
+ text=list(texts), return_tensors="pt", padding="max_length",
+ max_length=SIGLIP_TEXT_TOKENS, truncation=True,
+ )
return self.bundle.clip_processor(
- text=list(texts), return_tensors="pt", padding=padding, truncation=True
+ text=list(texts), return_tensors="pt", padding=True, truncation=True
)
def encode_images(self, images: Sequence[Any], batch_size: int | None = None):
@@ -388,6 +402,29 @@ def encode_texts(self, texts: Sequence[str]):
def encode_text(self, text: str):
return self.encode_texts([text])[0]
+ def encode_query(self, text: str, templates: Sequence[str] | None = None):
+ """One vector for a search query, averaged over several phrasings of it.
+
+ "theatre" alone, "a photo of theatre" and "a scene showing theatre" land in
+ slightly different places; their mean is a steadier description of the
+ concept than any one of them (prompt ensembling). The templates come from
+ ``text.query_templates``; with the single template ``{query}`` this is
+ exactly :meth:`encode_text`.
+ """
+ import numpy as np
+
+ from vie.vlm import has_right_to_left
+
+ if templates is None and has_right_to_left(text):
+ # Measured (scripts/eval_search.py): English phrasings around a Persian
+ # query, or Persian ones, never beat the query as typed, and cost SigLIP
+ # up to 0.32 mAP. A right-to-left query is encoded as it is.
+ templates = ["{query}"]
+ templates = list(templates or self.bundle.config.query_templates or ["{query}"])
+ prompts = [t.format(query=text) for t in templates]
+ mean = self.encode_texts(prompts).mean(axis=0)
+ return (mean / max(float(np.linalg.norm(mean)), 1e-12)).astype(np.float32)
+
def encode_image(self, image: Any):
"""Encode one BGR array or RGB PIL image."""
return self.encode_images([bgr_to_pil(image) if hasattr(image, "shape") else image])[0]
diff --git a/src/vie/pipeline.py b/src/vie/pipeline.py
index 53eccc7..cc0134c 100644
--- a/src/vie/pipeline.py
+++ b/src/vie/pipeline.py
@@ -26,8 +26,8 @@
from typing import Any
from vie.config import Config
-from vie.database import Index, content_hash
-from vie.geometry import pad_box
+from vie.database import Index, content_hash, format_box
+from vie.geometry import Box, pad_box
from vie.indexing import BoxDetector, FaceDetector, ImageAnalysis, analyse_batch
from vie.logging_setup import get_logger
from vie.quality import crop_regions
@@ -71,11 +71,14 @@ class IndexStats:
skipped: int = 0
pruned: int = 0
failed: int = 0
+ #: Pruning was requested but refused, because too much of the index vanished.
+ prune_refused: bool = False
def summary(self) -> str:
return (
f"scanned={self.scanned} indexed={self.indexed} skipped={self.skipped} "
f"pruned={self.pruned} failed={self.failed}"
+ + (" prune=refused" if self.prune_refused else "")
)
@@ -144,14 +147,60 @@ class _Components:
crop: Callable[[Any, Any], Any] | None
-def _embed(items: list[_Item], config: Config, parts: _Components) -> list[list[tuple[str, Any]]]:
+#: Separates an object region's kind from its box in a region name: "food|10,20,90,80".
+OBJECT_SEP = "|"
+
+
+def object_regions(
+ size: tuple[int, int], analysis: ImageAnalysis, config: Config
+) -> list[tuple[str, Box, Box]]:
+ """``(name, detector box, padded crop)`` for every animal and dish to embed.
+
+ Padded exactly as :func:`vie.search.animal.select_crops` and
+ :func:`vie.search.food.select_crops` pad at query time, so a stored vector is the
+ vector those paths would have computed. Size floors were applied at detection;
+ the confidence floor is applied at query time, against the stored confidence.
+ Food boxes outside ``food.search_classes`` (a fork, a cup) are never searched,
+ so they are not embedded either.
+ """
+ width, height = size
+ out: list[tuple[str, Box, Box]] = []
+ for box, _ in analysis.animal_boxes:
+ crop = pad_box(box, width, height, ratio=config.animal.padding_ratio)
+ out.append((f"animal{OBJECT_SEP}{format_box(box)}", box, crop))
+ searchable = set(config.food.search_classes)
+ for box, _, class_id in analysis.food_detections:
+ if class_id is not None and class_id not in searchable:
+ continue
+ crop = pad_box(box, width, height, pixels=config.food.padding_px)
+ out.append((f"food{OBJECT_SEP}{format_box(box)}", box, crop))
+ return out
+
+
+def _embed(
+ items: list[_Item], analyses: list[ImageAnalysis], config: Config, parts: _Components
+) -> list[list[tuple[str, Any]]]:
+ """Every region vector of every image, objects included, in one batched pass."""
if parts.embed_batch is not None:
layout = config.crop_layout if config.multi_crop else "1"
- regions = [crop_regions(*item.size, layout) for item in items]
+ regions = [
+ crop_regions(*item.size, layout)
+ + [(name, crop) for name, _, crop in object_regions(item.size, analysis, config)]
+ for item, analysis in zip(items, analyses, strict=True)
+ ]
return parts.embed_batch([item.image for item in items], regions)
return [[("whole", parts.embed_image(item.image))] for item in items]
+def _split_objects(
+ vectors: list[tuple[str, Any]],
+) -> tuple[list[tuple[str, Any]], dict[str, Any]]:
+ """Separate scene regions (text search) from object regions (food and animals)."""
+ scene = [(name, v) for name, v in vectors if OBJECT_SEP not in name]
+ objects = {name: v for name, v in vectors if OBJECT_SEP in name}
+ return scene, objects
+
+
def _food_gate_scores(
vectors: list[list[tuple[str, Any]]], config: Config, parts: _Components
) -> list[float]:
@@ -201,6 +250,7 @@ def _write(
predictions: list[tuple[Any, int, float]],
config: Config,
gate_enabled: bool,
+ objects: dict[str, Any] | None = None,
) -> None:
food_by_vlm = gate_enabled and gate >= config.food.clip_gate_threshold
stat = item.path.stat()
@@ -221,6 +271,7 @@ def _write(
food_vlm_score=gate,
index_scale=item.scale,
mtime_ns=stat.st_mtime_ns,
+ objects_embedded=objects is not None,
)
for box, embedding in analysis.faces:
index.add_face(conn, item.key, box, embedding)
@@ -232,6 +283,16 @@ def _write(
index.add_food_box(conn, item.key, box, confidence, class_id)
for name, vector in regions:
index.add_clip(conn, item.key, vector, region=name)
+ if objects is not None:
+ confidence = {("animal", box): (c, None) for box, c in analysis.animal_boxes}
+ confidence.update({("food", box): (c, k) for box, c, k in analysis.food_detections})
+ for name, box, crop in object_regions(item.size, analysis, config):
+ vector = objects.get(name)
+ if vector is None: # an empty crop the encoder skipped
+ continue
+ kind = name.split(OBJECT_SEP, 1)[0]
+ score, class_id = confidence[(kind, box)]
+ index.add_object(conn, item.key, kind, box, crop, score, class_id, vector)
def _index_batch(
@@ -245,7 +306,10 @@ def _index_batch(
animal_detector=parts.animal_detector,
food_detector=parts.food_detector,
)
- vectors = _embed(items, config, parts)
+ embedded = _embed(items, analyses, config, parts)
+ split = [_split_objects(regions) for regions in embedded]
+ vectors = [scene for scene, _ in split]
+ objects = [found if parts.embed_batch is not None else None for _, found in split]
gates = _food_gate_scores(vectors, config, parts)
predictions = _classify_animals(items, analyses, config, parts)
except Exception:
@@ -261,11 +325,12 @@ def _index_batch(
return
gate_enabled = parts.food_gate is not None and config.food.clip_gate_enabled
- for item, analysis, regions, gate, found in zip(
- items, analyses, vectors, gates, predictions, strict=True
+ for item, analysis, regions, gate, found, stored in zip(
+ items, analyses, vectors, gates, predictions, objects, strict=True
):
try:
- _write(index, conn, item, analysis, regions, gate, found, config, gate_enabled)
+ _write(index, conn, item, analysis, regions, gate, found, config, gate_enabled,
+ stored)
stats.indexed += 1
except Exception:
log.exception("failed to store %s", item.key)
@@ -274,6 +339,19 @@ def _index_batch(
on_progress(stats)
+def _stale_face_images(index: Index, conn: Any, config: Config) -> set[str]:
+ """Images whose face vectors came from a different recipe than the config's.
+
+ Mixing recipes in one index would quietly shift every score, so those images
+ are re-indexed. Only images that have faces pay for it, not the whole archive.
+ An index written before the recipe was recorded used plain ArcFace.
+ """
+ found = index.get_meta(conn, "face_recipe") or "arcface"
+ if found == config.face.recipe:
+ return set()
+ return {row[0] for row in conn.execute("SELECT file_name FROM gallery_meta WHERE has_face = 1")}
+
+
def build_index(
config: Config,
*,
@@ -293,6 +371,7 @@ def build_index(
batch_size: int | None = None,
commit_every: int | None = None,
prune: bool = True,
+ prune_guard: float = 0.5,
rebuild: bool = False,
rehash: bool = False,
on_progress: Callable[[IndexStats], None] | None = None,
@@ -302,6 +381,9 @@ def build_index(
All model access is injected, which keeps this function testable end to end
without a GPU: the orchestration logic is what carries the risk here, not the
model calls.
+
+ ``prune`` drops rows for files no longer on disk, unless fewer than
+ ``prune_guard`` of the indexed files are still there (``0`` disables the guard).
"""
if embed_image is None and embed_batch is None:
raise ValueError("build_index needs embed_batch or embed_image")
@@ -333,30 +415,60 @@ def build_index(
index.bind_embedding_model(conn, config.models["vision_language"])
if prune:
- stats.pruned = index.prune_missing(conn, set(keys.values()))
- if stats.pruned:
- log.info("pruned %d rows for files no longer on disk", stats.pruned)
+ # An unmounted drive or a mistyped path looks exactly like "every photo
+ # was deleted". Rows cost minutes of GPU each to rebuild, so pruning most
+ # of the index at once is refused rather than trusted.
+ indexed = conn.execute("SELECT COUNT(*) FROM gallery_meta").fetchone()[0]
+ if indexed and len(keys) < indexed * prune_guard:
+ stats.prune_refused = True
+ log.warning(
+ "only %d of %d indexed images are on disk; not pruning. Check the "
+ "gallery path; to really drop them, run with --rebuild.",
+ len(keys), indexed,
+ )
+ else:
+ stats.pruned = index.prune_missing(conn, set(keys.values()))
+ if stats.pruned:
+ log.info("pruned %d rows for files no longer on disk", stats.pruned)
known = index.known_files(conn)
+ stored_paths = dict(conn.execute("SELECT file_name, file_path FROM gallery_meta"))
+ stale_faces = _stale_face_images(index, conn, config)
+ if stale_faces:
+ log.info("face recipe changed to %s: re-indexing the %d images with faces",
+ config.face.recipe, len(stale_faces))
trust_mtime = settings.trust_mtime and not rehash
pending: list[tuple[Path, str]] = []
+ refreshed: list[tuple[str, str, int, int]] = []
for path in paths:
key = keys[path]
stat = path.stat()
seen = known.get(key)
+ if key in stale_faces:
+ pending.append((path, content_hash(path)))
+ continue
if (
trust_mtime and seen is not None
and seen[1] == stat.st_size and seen[2] == stat.st_mtime_ns
):
stats.skipped += 1
+ if stored_paths.get(key) != str(path):
+ refreshed.append((key, str(path), stat.st_size, stat.st_mtime_ns))
continue
digest = content_hash(path)
if seen is not None and seen[0] == digest:
+ # Same content under a new mtime or path: remember both, so the
+ # next run skips it from its stat alone instead of hashing again.
stats.skipped += 1
+ refreshed.append((key, str(path), stat.st_size, stat.st_mtime_ns))
continue
pending.append((path, digest))
+ if refreshed:
+ index.refresh_files(conn, refreshed)
+ conn.commit()
if not pending:
+ index.set_meta(conn, "face_recipe", config.face.recipe)
log.info("index is up to date (%s)", stats.summary())
return stats
@@ -383,6 +495,9 @@ def decoded_items() -> Iterator[_Item]:
conn.commit() # checkpoint: an interruption keeps this much
last_commit = stats.indexed
log.info("checkpoint at %d images", stats.indexed)
+ # Recorded only once every stale image was redone, so an interrupted run
+ # picks the rest up next time.
+ index.set_meta(conn, "face_recipe", config.face.recipe)
log.info("indexing complete (%s)", stats.summary())
return stats
diff --git a/src/vie/query.py b/src/vie/query.py
index 8df6272..e35e5ea 100644
--- a/src/vie/query.py
+++ b/src/vie/query.py
@@ -90,37 +90,73 @@ def query_face_embedding(image_path: Any, config: Config, bundle: Any) -> Any:
app = bundle.face_app
ctx = 0 if bundle.runtime.is_cuda else -1
- def detect() -> list[tuple[Any, Any]]:
- return [(tuple(int(v) for v in f.bbox[:4]), f.normed_embedding) for f in app.get(image)]
+ from vie.adapters import face_vectors
- app.prepare(ctx_id=ctx, det_size=face_search.detection_size(width, height, config))
- faces = detect()
- if not faces:
- # Retry once at full detection size: small or tightly cropped portraits
- # are the common failure and this recovers most of them.
- app.prepare(ctx_id=ctx, det_size=(config.face.det_size_large, config.face.det_size_large))
+ def detect() -> list[tuple[Any, Any]]:
+ found = list(app.get(image))
+ if not found:
+ return []
+ # Only the subject needs the mirrored pass, not every bystander.
+ subject = max(found, key=lambda f: (f.bbox[2] - f.bbox[0]) * (f.bbox[3] - f.bbox[1]))
+ vector = face_vectors(app, image, [subject], config.face.flip_tta)[0]
+ return [(tuple(int(v) for v in subject.bbox[:4]), vector)]
+
+ try:
+ app.prepare(ctx_id=ctx, det_size=face_search.detection_size(width, height, config))
faces = detect()
+ if not faces:
+ # Retry once at full detection size: small or tightly cropped portraits
+ # are the common failure and this recovers most of them.
+ size = config.face.det_size_large
+ app.prepare(ctx_id=ctx, det_size=(size, size))
+ faces = detect()
+ finally:
+ # The bundle is shared with indexing and video, which expect the large size.
+ size = config.face.det_size_large
+ app.prepare(ctx_id=ctx, det_size=(size, size))
if not faces:
- raise ValueError("no face detected in the query image")
+ raise ValueError(f"no face detected in the query image {Path(image_path).name}")
return face_search.select_query_face(faces)[1]
+def query_images(value: Any) -> list[Path]:
+ """``--image`` may be given once or several times."""
+ if value is None:
+ return []
+ items = value if isinstance(value, list | tuple) else [value]
+ return [Path(item) for item in items]
+
+
def _search_face(args, config: Config, index: Index, load_runtime) -> list[Match]:
- if args.image is None:
+ images = query_images(args.image)
+ if not images:
raise ValueError("a face search needs a query image")
- if not Path(args.image).is_file():
- raise FileNotFoundError(f"query image not found: {args.image}")
+ for image in images:
+ if not image.is_file():
+ raise FileNotFoundError(f"query image not found: {image}")
bundle, _, _ = load_runtime(config)
- embedding = query_face_embedding(args.image, config, bundle)
+ embeddings = [query_face_embedding(image, config, bundle) for image in images]
with index.connect() as conn:
catalog = VectorCatalog.open(conn, "face_embeddings", config.search, _ann_cache(config))
- return face_search.rank_catalog(embedding, catalog, _file_paths(conn), config)
+ return face_search.rank_catalog_multi(embeddings, catalog, _file_paths(conn), config)
# ── free text ──────────────────────────────────────────────────────────
+def query_vector(encoder: Any, text: str, config: Config) -> Any:
+ """The vector a typed query is searched with, by text and video search alike."""
+ from vie.vlm import has_right_to_left, is_multilingual
+
+ model = config.models["vision_language"]
+ if has_right_to_left(text) and not is_multilingual(model):
+ log.warning("%s understands English only, so this query will match poorly; "
+ "google/siglip2-so400m-patch14-384 understands Persian", model)
+ encode = getattr(encoder, "encode_query", None)
+ return encode(text) if encode is not None else encoder.encode_text(text)
+
+
def _search_text(args, config: Config, index: Index, load_runtime) -> list[Match]:
query = args.query.strip()
if not query:
@@ -130,9 +166,16 @@ def _search_text(args, config: Config, index: Index, load_runtime) -> list[Match
catalog = VectorCatalog.open(conn, "clip_embeddings", config.search, _ann_cache(config))
paths = _file_paths(conn)
_, _, encoder = load_runtime(config)
- vector = encoder.encode_text(query)
+ vector = query_vector(encoder, query, config)
matches = text_search.rank_catalog(vector, catalog, paths, config, args.top_k)
- return text_search.with_confidence(matches, getattr(encoder, "calibrated", None))
+ calibrate = getattr(encoder, "calibrated", None)
+ matches = text_search.with_confidence(matches, calibrate)
+ if config.min_probability > 0 and calibrate is not None and calibrate(0.0) is not None:
+ found = len(matches)
+ matches = [m for m in matches if m.display_score >= config.min_probability]
+ if found and not matches:
+ log.info("nothing cleared text.min_probability=%s", config.min_probability)
+ return matches
# ── species ────────────────────────────────────────────────────────────
@@ -164,11 +207,18 @@ def _search_animal(args, config: Config, index: Index, load_runtime) -> list[Mat
log.info("no stored prediction cleared the floor; trying the open vocabulary")
rows = index.candidates(conn, "has_animal")
stored = index.animal_boxes(conn)
+ embedded = index.embedded_images(conn)
+ vectors = index.object_vectors(conn, "animal")
log.info("%d candidate images after the has_animal prefilter", len(rows))
- return _score_crops(
- query, rows, stored, animal_search.select_crops, animal_search.rank_with_clip,
- animal_search.Candidate, config, load_runtime,
+ fresh = [row for row in rows if row.file_name in embedded]
+ older = [row for row in rows if row.file_name not in embedded]
+ return best_per_image(
+ _score_stored(query, fresh, vectors, {}, "animal", animal_search.rank_with_clip,
+ animal_search.Candidate, config, load_runtime)
+ + _score_crops(query, older, stored, animal_search.select_crops,
+ animal_search.rank_with_clip, animal_search.Candidate, config,
+ load_runtime)
)
@@ -182,14 +232,90 @@ def _search_food(args, config: Config, index: Index, load_runtime) -> list[Match
with index.connect() as conn:
rows = index.candidates(conn, "has_food")
stored = index.food_boxes(conn, config.food.search_classes)
+ embedded = index.embedded_images(conn)
+ vectors = index.object_vectors(conn, "food")
+ # For a gate-admitted image with no dish box above the search floor, the
+ # whole frame is scored instead, as the crop path does.
+ wholes = index.whole_vectors(conn, {r.file_name for r in rows if r.file_name in embedded})
log.info("%d candidate images after the has_food prefilter", len(rows))
- return _score_crops(
- query, rows, stored, food_search.select_crops, food_search.rank,
- food_search.Candidate, config, load_runtime, whole_frame_fallback=True,
+ fresh = [row for row in rows if row.file_name in embedded]
+ older = [row for row in rows if row.file_name not in embedded]
+ return best_per_image(
+ _score_stored(query, fresh, vectors, wholes, "food", food_search.rank,
+ food_search.Candidate, config, load_runtime)
+ + _score_crops(query, older, stored, food_search.select_crops, food_search.rank,
+ food_search.Candidate, config, load_runtime, whole_frame_fallback=True)
)
+class _StoredVectors:
+ """A ``CropScorer`` over vectors computed at index time instead of over pixels.
+
+ The search paths' ranking functions take crops and a scorer; handing them stored
+ vectors and this scorer reuses their exact decision rule, while the work per
+ candidate drops from decoding an image and running the image tower to one dot
+ product per prompt.
+ """
+
+ def __init__(self, encoder: Any) -> None:
+ self.encoder = encoder
+
+ def score(self, vectors: list[Any], prompts: list[str]) -> list[list[float]]:
+ import numpy as np
+
+ if not vectors:
+ return []
+ texts = self.encoder.encode_texts(prompts)
+ return self.encoder.logits_from_similarity(np.vstack(vectors) @ texts.T).tolist()
+
+
+def _score_stored(
+ query: str,
+ rows: list[Any],
+ objects: dict[str, list[tuple[Any, Any, float, int | None, Any]]],
+ wholes: dict[str, Any],
+ kind: str,
+ rank: Any,
+ candidate_type: Any,
+ config: Config,
+ load_runtime: Any,
+) -> list[Match]:
+ """Score images indexed with object vectors: no image is opened.
+
+ The floors :func:`_score_crops` applies through ``select_crops`` are applied
+ here to the stored boxes: confidence at the search threshold, and for food only
+ the searchable classes. Size floors and padding were applied at index time.
+ """
+ if not rows:
+ return []
+ section = config.animal if kind == "animal" else config.food
+ searchable = set(config.food.search_classes) if kind == "food" else None
+ candidates: list[Any] = []
+ vectors: list[Any] = []
+ whole_frame: set[str] = set()
+ for row in rows:
+ kept = 0
+ for _, crop, confidence, class_id, vector in objects.get(row.file_name, []):
+ if confidence < section.search_confidence:
+ continue
+ if searchable is not None and class_id is not None and class_id not in searchable:
+ continue
+ candidates.append(candidate_type(row.file_name, row.file_path, crop, confidence))
+ vectors.append(vector)
+ kept += 1
+ if not kept and row.file_name in wholes:
+ candidates.append(candidate_type(row.file_name, row.file_path, (0, 0, 0, 0), 1.0))
+ vectors.append(wholes[row.file_name])
+ whole_frame.add(row.file_name)
+ if not candidates:
+ return []
+ _, scorer, _ = load_runtime(config)
+ matches = rank(query, candidates, vectors, _StoredVectors(scorer), config)
+ # A whole-frame match has no box worth drawing.
+ return [replace(m, box=None) if m.file_name in whole_frame else m for m in matches]
+
+
def _score_crops(
query: str,
rows: list[Any],
@@ -212,6 +338,7 @@ def _score_crops(
if not rows:
return []
+ log.info("%d images indexed before object vectors; cropping them at query time", len(rows))
_, scorer, _ = load_runtime(config)
step = max(1, config.search.crop_batch)
diff --git a/src/vie/report/showcase.py b/src/vie/report/showcase.py
new file mode 100644
index 0000000..6c1781f
--- /dev/null
+++ b/src/vie/report/showcase.py
@@ -0,0 +1,391 @@
+"""Presentation-grade result images: one PNG per search, ready to put in a deck.
+
+The HTML reports are for working with results. These are for showing them: a
+fixed-size image that looks the same in a slide, a chat message or a README.
+Same visual language as :mod:`vie.report.theme`: near-black ink, one warm signal
+colour, hairlines, and the photographs as the brightest thing on the page.
+
+Each tile is cropped around the detection rather than the centre of the photo,
+so the face, animal or dish that matched is what the viewer sees first.
+
+Persian and Arabic text (file names, people's names) is shaped right to left by
+Pillow's raqm layout when the build has it, which the PyPI wheels do; otherwise
+by ``arabic-reshaper`` and ``python-bidi`` if installed. Without either, Arabic
+script would come out as disconnected letters, so :func:`display_text` is applied
+to every string drawn.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from dataclasses import dataclass
+from functools import lru_cache
+from pathlib import Path
+from typing import Any
+
+from vie import __version__
+from vie.scoring import Match
+
+INK = (11, 13, 16)
+INK_2 = (18, 21, 26)
+INK_3 = (26, 30, 37)
+HAIR = (38, 43, 51)
+PAPER = (238, 240, 242)
+MUTED = (152, 160, 171)
+FAINT = (98, 106, 117)
+SIGNAL = (255, 180, 84)
+SIGNAL_INK = (28, 20, 5)
+LANES = [(255, 180, 84), (108, 196, 255), (143, 227, 136), (229, 140, 255), (255, 138, 122),
+ (255, 222, 110), (120, 230, 214), (255, 160, 200)]
+
+KIND_TITLES = {
+ "face": "Face search",
+ "animal": "Species search",
+ "food": "Dish search",
+ "text": "Free-text search",
+ "concept": "Concept search · photos and videos",
+ "video": "Video understanding",
+}
+
+
+#: What a score means, per search path, for the footnote and the score caption.
+SCORE_NOTES = {
+ "face": ("similarity", "ArcFace cosine similarity · the same person scores above "
+ "the match threshold"),
+ "animal": ("confidence", "classifier probability, or SigLIP margin over rival prompts"),
+ "food": ("confidence", "SigLIP margin over rival prompts"),
+ "text": ("confidence", "SigLIP's calibrated probability that the description fits"),
+ "concept": ("confidence", "SigLIP's calibrated probability that the description fits · "
+ "video tiles show the moment's first frame"),
+}
+
+
+@lru_cache(maxsize=64)
+def font(size: int, bold: bool = False) -> Any:
+ """A real TrueType face wherever one exists; Pillow's default otherwise.
+
+ DejaVu, which matplotlib ships, comes first: it covers Latin, Persian and
+ Arabic in one face. Cached, because looking a face up walks font folders.
+ """
+ from PIL import ImageFont
+
+ names = (["Inter-SemiBold.ttf", "DejaVuSans-Bold.ttf", "LiberationSans-Bold.ttf",
+ "Arial Bold.ttf", "arialbd.ttf", "segoeuib.ttf"] if bold else
+ ["Inter-Regular.ttf", "DejaVuSans.ttf", "LiberationSans-Regular.ttf",
+ "Arial.ttf", "arial.ttf", "segoeui.ttf"])
+ candidates: list[Path] = []
+ try:
+ import matplotlib
+
+ candidates += [Path(matplotlib.get_data_path()) / "fonts" / "ttf" / n for n in names]
+ except Exception: # noqa: BLE001 - matplotlib is optional here
+ pass
+ for root in ("/usr/share/fonts", "/usr/local/share/fonts", "C:/Windows/Fonts",
+ "/Library/Fonts", "/System/Library/Fonts"):
+ base = Path(root)
+ if base.is_dir():
+ for name in names:
+ candidates += list(base.rglob(name))[:1]
+ for path in candidates:
+ try:
+ return ImageFont.truetype(str(path), size)
+ except OSError:
+ continue
+ try:
+ return ImageFont.load_default(size)
+ except TypeError: # Pillow < 10.1
+ return ImageFont.load_default()
+
+
+def _has_raqm() -> bool:
+ try:
+ from PIL import features
+
+ return bool(features.check("raqm"))
+ except Exception: # noqa: BLE001
+ return False
+
+
+def display_text(text: str) -> str:
+ """Text ready for Pillow, with right-to-left scripts shaped where needed."""
+ if not any("\u0590" <= ch <= "\u08ff" or "\ufb1d" <= ch <= "\ufeff" for ch in text):
+ return text
+ if _has_raqm():
+ return text # Pillow shapes and orders it itself
+ try:
+ import arabic_reshaper
+ from bidi.algorithm import get_display
+
+ return get_display(arabic_reshaper.reshape(text))
+ except ImportError:
+ return text
+
+
+def _text_width(draw: Any, text: str, face: Any) -> int:
+ left, _, right, _ = draw.textbbox((0, 0), text, font=face)
+ return right - left
+
+
+def _ellipsize(draw: Any, text: str, face: Any, width: int, middle: bool = False) -> str:
+ """Shorten to fit ``width``. ``middle`` keeps the end, where file names differ."""
+ if _text_width(draw, text, face) <= width:
+ return text
+ if middle:
+ head, tail = text[: len(text) // 2], text[len(text) // 2:]
+ while head and tail and _text_width(draw, head + "…" + tail, face) > width:
+ head, tail = head[:-1], tail[1:]
+ return head + "…" + tail
+ while text and _text_width(draw, text + "…", face) > width:
+ text = text[:-1]
+ return text + "…"
+
+
+def open_rgb(path: str | Path, max_side: int = 1600) -> Any:
+ """Open any image as RGB, honouring EXIF rotation, capped in size."""
+ from PIL import Image, ImageOps
+
+ with Image.open(path) as handle:
+ image = ImageOps.exif_transpose(handle).convert("RGB")
+ image.thumbnail((max_side, max_side))
+ return image
+
+
+def focus_crop(image: Any, size: tuple[int, int], box: Sequence[int] | None,
+ box_scale: float = 1.0) -> tuple[Any, tuple[int, int, int, int] | None]:
+ """Cover-fit ``image`` into ``size``, centred on ``box`` when there is one.
+
+ Returns the tile and the box in tile coordinates. The box is kept whole when it
+ fits; a subject bigger than the tile is zoomed out to fit instead.
+ """
+ from PIL import Image
+
+ tw, th = size
+ w, h = image.size
+ scale = max(tw / w, th / h)
+ if box is not None:
+ bx1, by1, bx2, by2 = (v * box_scale for v in box)
+ bw, bh = max(1.0, bx2 - bx1), max(1.0, by2 - by1)
+ # Leave room around the subject: it should fill about half the tile.
+ scale = max(scale, min(tw / (bw * 2.2), th / (bh * 2.2)))
+ scale = min(scale, max(tw / w, th / h) * 3.0)
+ cx, cy = (bx1 + bx2) / 2, (by1 + by2) / 2
+ else:
+ cx, cy = w / 2, h / 2
+ resized = image.resize((max(1, round(w * scale)), max(1, round(h * scale))),
+ Image.Resampling.LANCZOS)
+ rw, rh = resized.size
+ left = int(min(max(cx * scale - tw / 2, 0), max(rw - tw, 0)))
+ top = int(min(max(cy * scale - th / 2, 0), max(rh - th, 0)))
+ tile = Image.new("RGB", size, INK_3)
+ tile.paste(resized.crop((left, top, left + tw, top + th)),
+ ((tw - min(tw, rw)) // 2, (th - min(th, rh)) // 2))
+ if box is None:
+ return tile, None
+ x1, y1, x2, y2 = (round(v * box_scale * scale) for v in box)
+ return tile, (x1 - left, y1 - top, x2 - left, y2 - top)
+
+
+def rounded(image: Any, radius: int) -> Any:
+ from PIL import Image, ImageDraw
+
+ mask = Image.new("L", image.size, 0)
+ ImageDraw.Draw(mask).rounded_rectangle((0, 0, *image.size), radius=radius, fill=255)
+ out = Image.new("RGBA", image.size)
+ out.paste(image, (0, 0), mask)
+ return out
+
+
+def draw_brackets(draw: Any, box: Sequence[int], colour: tuple[int, int, int],
+ width: int = 3, frac: float = 0.24) -> None:
+ """Corner brackets plus a hairline: reads as a detection without hiding it."""
+ x1, y1, x2, y2 = box
+ draw.rectangle(box, outline=colour + (110,), width=1)
+ arm = max(8, int(min(x2 - x1, y2 - y1) * frac))
+ for (x, y, dx, dy) in ((x1, y1, 1, 1), (x2, y1, -1, 1), (x1, y2, 1, -1), (x2, y2, -1, -1)):
+ draw.line([(x, y), (x + dx * arm, y)], fill=colour, width=width)
+ draw.line([(x, y), (x, y + dy * arm)], fill=colour, width=width)
+
+
+def pill(draw: Any, xy: tuple[int, int], text: str, face: Any,
+ fill: tuple[int, int, int], ink: tuple[int, int, int], pad: tuple[int, int] = (10, 5)
+ ) -> tuple[int, int, int, int]:
+ x, y = xy
+ text = display_text(text)
+ width = _text_width(draw, text, face)
+ _, top, _, bottom = draw.textbbox((0, 0), text, font=face)
+ rect = (x, y, x + width + pad[0] * 2, y + (bottom - top) + pad[1] * 2)
+ draw.rounded_rectangle(rect, radius=(rect[3] - rect[1]) // 2, fill=fill)
+ draw.text((x + pad[0], y + pad[1] - top), text, font=face, fill=ink)
+ return rect
+
+
+@dataclass
+class CardStats:
+ """Optional numbers for the header, all shown only when given."""
+
+ search_ms: float | None = None
+ indexed_images: int | None = None
+ candidates: int | None = None
+
+
+def render_search_card(
+ matches: Sequence[Match],
+ *,
+ kind: str,
+ query: str,
+ query_images: Sequence[str | Path] = (),
+ stats: CardStats | None = None,
+ columns: int = 4,
+ max_results: int = 8,
+ width: int = 1600,
+ badges: Sequence[str | None] | None = None,
+) -> Any:
+ """Render a search as one image. Returns a Pillow image.
+
+ ``badges`` tags results by position, e.g. ``"VIDEO 1:24"`` on a video moment.
+ """
+ from PIL import Image, ImageDraw
+
+ shown = list(matches[:max_results])
+ margin, gap = 56, 22
+ side = 300 if query_images else 0
+ grid_w = width - margin * 2 - (side + gap if side else 0)
+ # The column count stays fixed whatever the number of results, so a card with one
+ # match has the same tile size as a card with eight, not one tile the full width.
+ columns = max(1, columns)
+ tile_w = (grid_w - gap * (columns - 1)) // columns
+ tile_h = int(tile_w * 0.75)
+ caption_h = 92
+ rows = max(1, -(-len(shown) // columns))
+ header_h = 240
+ body_h = rows * (tile_h + caption_h) + (rows - 1) * gap if shown else 220
+ if side:
+ body_h = max(body_h, 60 + len(query_images[:3]) * (side + 16))
+ height = header_h + body_h + 120
+
+ canvas = Image.new("RGB", (width, height), INK)
+ draw = ImageDraw.Draw(canvas, "RGBA")
+ f_eyebrow, f_title = font(18, True), font(46, True)
+ f_name, f_small, f_score = font(19, True), font(16), font(26, True)
+
+ # ── header ────────────────────────────────────────────────────────
+ draw.text((margin, 50), KIND_TITLES.get(kind, "Search").upper(), font=f_eyebrow, fill=SIGNAL)
+ title = _ellipsize(draw, display_text(query), f_title, width - margin * 2)
+ draw.text((margin, 80), title, font=f_title, fill=PAPER)
+ chips: list[str] = [f"{len(matches)} match" + ("" if len(matches) == 1 else "es")]
+ if matches:
+ chips.append(f"best {max(m.display_score for m in matches) * 100:.1f}%")
+ if stats and stats.search_ms is not None:
+ chips.append(f"search {stats.search_ms:.0f} ms" if stats.search_ms >= 1
+ else f"search {stats.search_ms:.2f} ms")
+ if stats and stats.indexed_images is not None:
+ chips.append(f"{stats.indexed_images:,} images indexed")
+ x = margin
+ for n, chip in enumerate(chips):
+ w = _text_width(draw, chip, f_small) + 28
+ draw.rounded_rectangle((x, 150, x + w, 184), radius=17, fill=INK_2,
+ outline=SIGNAL if n == 0 else HAIR, width=1)
+ draw.text((x + 14, 157), chip, font=f_small, fill=PAPER)
+ x += w + 10
+ draw.line([(margin, header_h - 26), (width - margin, header_h - 26)], fill=HAIR, width=1)
+
+ top = header_h
+ left = margin
+ # ── query column ─────────────────────────────────────────────────
+ if side:
+ count = min(3, len(query_images))
+ heading = "QUERY" if count == 1 else f"QUERY · {count} PHOTOS"
+ draw.text((margin, top), heading, font=f_eyebrow, fill=MUTED)
+ y = top + 34
+ for path in query_images[:3]:
+ try:
+ tile, _ = focus_crop(open_rgb(path), (side, side), None)
+ except Exception: # noqa: BLE001 - one unreadable query must not stop the card
+ continue
+ masked = rounded(tile, 14)
+ canvas.paste(masked, (margin, y), masked)
+ draw.rounded_rectangle((margin, y, margin + side, y + side), radius=14,
+ outline=SIGNAL, width=2)
+ y += side + 16
+ left = margin + side + gap
+ draw.line([(left - gap // 2, top), (left - gap // 2, top + body_h)], fill=HAIR, width=1)
+
+ # ── results ───────────────────────────────────────────────────────
+ if not shown:
+ draw.text((left, top + 60), "Nothing in the index cleared the threshold.",
+ font=font(24), fill=MUTED)
+ from vie.report.theme import original_size
+
+ caption, note = SCORE_NOTES.get(kind, ("score", ""))
+ for i, match in enumerate(shown):
+ r, c = divmod(i, columns)
+ x0 = left + c * (tile_w + gap)
+ y0 = top + r * (tile_h + caption_h + gap)
+ try:
+ photo = open_rgb(match.file_path, 2400)
+ # Boxes are in original-file pixels; the photo here may be smaller.
+ original = original_size(match.file_path)
+ box_scale = photo.size[0] / original[0] if original and original[0] else 1.0
+ tile, box = focus_crop(photo, (tile_w, tile_h), match.box, box_scale)
+ except Exception: # noqa: BLE001
+ tile, box = Image.new("RGB", (tile_w, tile_h), INK_3), None
+ layer = Image.new("RGBA", tile.size, (0, 0, 0, 0))
+ tdraw = ImageDraw.Draw(layer)
+ if box is not None:
+ draw_brackets(tdraw, box, SIGNAL if i == 0 else PAPER, width=4 if i == 0 else 3)
+ # Soft shade at the top so the rank badge reads on any photo.
+ for k in range(46):
+ tdraw.line([(0, k), (tile_w, k)], fill=(0, 0, 0, int(120 * (1 - k / 46))))
+ tile = Image.alpha_composite(tile.convert("RGBA"), layer)
+ masked = rounded(tile.convert("RGB"), 14)
+ canvas.paste(masked, (x0, y0), masked)
+ draw.rounded_rectangle((x0, y0, x0 + tile_w, y0 + tile_h), radius=14, outline=HAIR, width=1)
+ pill(draw, (x0 + 12, y0 + 12), f"{i + 1:02d}", f_small,
+ SIGNAL if i == 0 else (0, 0, 0, 150), SIGNAL_INK if i == 0 else PAPER)
+ badge = badges[i] if badges is not None and i < len(badges) else None
+ if badge:
+ text = "▶ " + badge
+ bw = _text_width(draw, text, f_small) + 20
+ pill(draw, (x0 + tile_w - bw - 12, y0 + 12), text, f_small, LANES[1], SIGNAL_INK)
+
+ cy = y0 + tile_h + 14
+ value = max(0.0, min(1.0, match.display_score))
+ score = f"{value * 100:.1f}%"
+ sw = _text_width(draw, score, f_score)
+ draw.text((x0 + tile_w - sw, cy - 2), score, font=f_score,
+ fill=SIGNAL if i == 0 else PAPER)
+ name = _ellipsize(draw, display_text(Path(match.file_name).stem), f_name,
+ tile_w - sw - 16, middle=True)
+ draw.text((x0, cy), name, font=f_name, fill=PAPER)
+ cw = _text_width(draw, caption, f_small)
+ draw.text((x0 + tile_w - cw, cy + 30), caption, font=f_small, fill=FAINT)
+ # The species or dish that matched; for free text the label is only the
+ # name of the crop region, which means nothing to a reader.
+ if match.label and kind in ("animal", "food"):
+ draw.text((x0, cy + 28),
+ _ellipsize(draw, display_text(match.label), f_small, tile_w - cw - 16),
+ font=f_small, fill=MUTED)
+ by = cy + 60
+ draw.rounded_rectangle((x0, by, x0 + tile_w, by + 6), radius=3, fill=INK_3)
+ draw.rounded_rectangle((x0, by, x0 + max(6, int(tile_w * value)), by + 6), radius=3,
+ fill=SIGNAL if i == 0 else MUTED)
+
+ # ── footer ────────────────────────────────────────────────────────
+ fy = height - 70
+ draw.line([(margin, fy), (width - margin, fy)], fill=HAIR, width=1)
+ draw.text((margin, fy + 20), f"Visual Intelligence Engine {__version__}", font=f_small,
+ fill=FAINT)
+ draw.text((width - margin - _text_width(draw, note, f_small), fy + 20), note, font=f_small,
+ fill=FAINT)
+ return canvas
+
+
+def save_search_card(matches: Sequence[Match], path: str | Path, **kwargs: Any) -> Path:
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ image = render_search_card(matches, **kwargs)
+ if path.suffix.lower() in {".jpg", ".jpeg"}:
+ image.save(path, quality=90, optimize=True, progressive=True)
+ else:
+ image.save(path, optimize=True)
+ return path
diff --git a/src/vie/scoring.py b/src/vie/scoring.py
index 5c841f7..7211835 100644
--- a/src/vie/scoring.py
+++ b/src/vie/scoring.py
@@ -3,12 +3,12 @@
The single best idea in the original project was in the food module: instead of
thresholding a raw cosine similarity, it scored the user's query against two
rival prompts and took a softmax. That gives the model an explicit "none of the
-above" option, so the number that comes out is a calibrated posterior rather
-than an uncalibrated distance.
+above" option.
-The animal module did not do this — it used an ImageNet classifier with no
-confidence floor at all, and shipped 21.5% top-1 results as confirmed matches.
-This module makes the good pattern the shared default.
+:func:`contrastive_score` is that softmax, which is right for CLIP. SigLIP is
+scored differently, by its margin over the strongest rival; both live in
+:func:`vie.vlm.hypothesis_score`, which is what the search paths call. This module
+keeps the softmax and the :class:`Match` record every path returns.
"""
from __future__ import annotations
diff --git a/src/vie/search/animal.py b/src/vie/search/animal.py
index 6c5f84c..58e57e8 100644
--- a/src/vie/search/animal.py
+++ b/src/vie/search/animal.py
@@ -1,28 +1,28 @@
-"""Animal search — a hybrid of a supervised classifier and CLIP.
+"""Animal search: a hybrid of a supervised classifier and a vision-language model.
Why hybrid
----------
Two models answer "which animal is this", and they are good at different things:
-=================== ========== =========== ========== ==================
-model params GFLOPs/crop ImageNet vocabulary
-=================== ========== =========== ========== ==================
-MobileNetV3-Large 5.5 M 0.217 74.0 % 398 animal classes
-CLIP ViT-L/14 428 M ~81 ~75.5 % open
-=================== ========== =========== ========== ==================
+===================== ======== =========== ==================================
+model params ImageNet vocabulary
+===================== ======== =========== ==================================
+ConvNeXt-Tiny 28.6 M 82.5 % top-1 398 ImageNet animal classes
+SigLIP so400m 877 M ~83 % 0-shot open: any name a person can type
+===================== ======== =========== ==================================
-CLIP costs roughly **373x the compute per crop** for about 1.5 points of
-top-1 — a bad trade when the species is one the classifier already knows.
-But it is the *only* option when it is not: ``quokka`` is not an ImageNet
-class, so no supervised ImageNet model can ever return it.
+Published figures, not measured here. The classifier is about as accurate on the
+species it knows and far cheaper, and it runs once per crop at index time. SigLIP
+is the *only* option for a species outside ImageNet: ``quokka`` is not a class, so
+no ImageNet classifier can ever return it.
-So the classifier is the primary path and CLIP is the fallback:
+So the classifier is the primary path and SigLIP is the fallback:
.. code-block:: text
query -> resolve against ImageNet vocabulary
- in vocabulary -> precomputed predictions (pure SQL, no inference)
- out of it -> CLIP over candidate crops (expensive, rare)
+ in vocabulary -> stored predictions (pure SQL, no model)
+ out of it -> SigLIP over candidate crops (rival prompts, rare)
The classifier also moves to **index time**. Each animal crop is classified
once when the image is ingested and its top-k predictions are stored, so an
@@ -56,14 +56,14 @@
class CropScorer(Protocol):
- """Scores image crops against text prompts (CLIP)."""
+ """Scores image crops against text prompts (the vision-language model)."""
def score(self, crops: Sequence[object], prompts: Sequence[str]) -> list[list[float]]:
...
class CropClassifier(Protocol):
- """Classifies image crops into a fixed vocabulary (MobileNetV3)."""
+ """Classifies image crops into a fixed vocabulary (ImageNet-1k; ConvNeXt-Tiny by default)."""
def classify(self, crops: Sequence[object], top_k: int) -> list[list[tuple[int, float]]]:
"""Return ``(class_id, probability)`` pairs per crop, best first."""
diff --git a/src/vie/search/face.py b/src/vie/search/face.py
index 5a88d2c..4b646d9 100644
--- a/src/vie/search/face.py
+++ b/src/vie/search/face.py
@@ -83,6 +83,40 @@ def rank_catalog(
return best_per_image(matches)
+def rank_catalog_multi(
+ query_embeddings: list[np.ndarray],
+ catalog: VectorCatalog,
+ file_paths: dict[str, str],
+ config: Config,
+) -> list[Match]:
+ """Search with several photos of the same person; each face keeps its best score.
+
+ One photo is one pose under one light. student-face-gate measured what a second
+ and third photo buy at a fixed threshold, with no wrong person admitted:
+ 86.1% -> 92.8% -> 96.3% of genuine visits accepted. Taking the maximum over
+ the query photos is the same rule its matcher applies to enrolled templates.
+ """
+ if not query_embeddings:
+ raise ValueError("no query embedding")
+ if len(query_embeddings) == 1:
+ return rank_catalog(query_embeddings[0], catalog, file_paths, config)
+ exact = catalog.backend.name == "exact"
+ k = len(catalog) if exact else config.search.ann_candidates
+ best: dict[tuple[str, str], float] = {}
+ for embedding in query_embeddings:
+ for name, bbox, score in catalog.search(embedding, k):
+ key = (name, bbox)
+ if score > best.get(key, -1.0):
+ best[key] = score
+ matches = [
+ Match(file_name=name, file_path=file_paths.get(name, name), score=score,
+ box=parse_box(bbox))
+ for (name, bbox), score in best.items()
+ if score >= config.face.match_threshold
+ ]
+ return best_per_image(matches)
+
+
def select_query_face(faces: list[tuple[Box, np.ndarray]]) -> tuple[Box, np.ndarray] | None:
"""Pick the subject of a query photo: the largest detected face.
diff --git a/src/vie/search/food.py b/src/vie/search/food.py
index e433b78..ab12ee3 100644
--- a/src/vie/search/food.py
+++ b/src/vie/search/food.py
@@ -1,13 +1,15 @@
-"""Food search — detect tableware, then recognise the dish with CLIP.
-
-This module's decision rule is the one good idea the original project already
-had, kept intact: score the user's query against explicit rival prompts and take
-a softmax, rather than thresholding a raw similarity. That gives the model a
-"none of the above" option, which is why its demo scores read 96-99% while the
-free-text path's raw cosines read 23%.
-
-The animal path has since been rebuilt on the same pattern, so three of the four
-search paths now share one recognition rule.
+"""Food search: find plates and bowls, then recognise the dish with SigLIP.
+
+The decision rule is the best idea the original project had, kept: score the query
+against explicit rival prompts ("a completely different food", "an empty plate")
+instead of thresholding a raw similarity, which gives the model a way to say "none
+of these". :func:`vie.vlm.hypothesis_score` turns the logits into one number: a
+softmax posterior for CLIP, and for SigLIP the sigmoid of the margin over the
+strongest rival, which ``scripts/calibrate.py`` measured as the more stable cut.
+
+Candidates come from RT-DETR boxes on COCO's tableware and food classes, plus whole
+images the zero-cost SigLIP food gate admitted at index time, since a plate of
+ghormeh sabzi fires none of COCO's ten Western food classes.
Fixed here: the search pass used a different COCO class list and a different
confidence than indexing, which re-introduced class 56 (``chair``) — furniture
diff --git a/src/vie/search/text.py b/src/vie/search/text.py
index b58bfe4..5683744 100644
--- a/src/vie/search/text.py
+++ b/src/vie/search/text.py
@@ -1,17 +1,17 @@
-"""Open-vocabulary text search over CLIP image embeddings.
+"""Open-vocabulary text search over vision-language image embeddings.
-Both sides of the comparison are unit-length CLIP vectors in the same 768-d
-projected space, so the dot product is cosine similarity.
+Both sides are unit-length vectors from the configured model (SigLIP so400m by
+default, 1152-d; CLIP also works), so the dot product is cosine similarity.
-The change from the original is presentational but not cosmetic. It displayed
-raw cosine as a percentage, so its best result read ``23.30%`` — a perfectly
-healthy CLIP score that looks like failure to anyone reading it. CLIP cosines
-occupy a narrow band (roughly 0.15-0.35 for genuine matches) because the
-contrastive objective never required them to span [0, 1].
+Ranking uses the raw cosine. Display does not: the original showed the cosine as a
+percentage, so its best hit read ``23.30%``, a healthy score that looks like
+failure, because these models' cosines live in a narrow band well below 1.
+:func:`with_confidence` attaches what a reader should see instead: SigLIP's own
+calibrated probability when the model has a learned bias, and a relative
+confidence across the results otherwise.
-:func:`rank` therefore returns the raw similarity *and* a relative confidence
-computed across the candidate set, so the UI can show something interpretable
-without pretending the cosine itself is a probability.
+Each image is stored as several region vectors (multi-crop) and scores as its
+best region, so a small subject is not averaged away by the rest of the frame.
"""
from __future__ import annotations
diff --git a/src/vie/video/library.py b/src/vie/video/library.py
index ef5ba58..5272138 100644
--- a/src/vie/video/library.py
+++ b/src/vie/video/library.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -67,11 +68,19 @@ def summary(self) -> str:
)
-def index_videos(config: Config, engine: VideoEngine, *, prune: bool = True) -> VideoIndexStats:
+def index_videos(
+ config: Config,
+ engine: VideoEngine,
+ *,
+ prune: bool = True,
+ on_analysed: Callable[[str, Path, Any], None] | None = None,
+) -> VideoIndexStats:
"""Analyse every new or changed video, committing after each one.
A video can take minutes, so losing one to an interruption is the most this
can cost. A file that fails is counted and logged, and the run carries on.
+ ``on_analysed(key, path, analysis)`` runs after each video is stored, so a
+ caller can write its report while the analysis is still in memory.
"""
store = VideoStore(config)
store.initialise()
@@ -99,10 +108,18 @@ def index_videos(config: Config, engine: VideoEngine, *, prune: bool = True) ->
stats.skipped += 1
continue
try:
- store.save(conn, key, path, digest, engine.analyse(path))
+ analysis = engine.analyse(path)
+ store.save(conn, key, path, digest, analysis)
conn.commit()
stats.indexed += 1
except Exception:
log.exception("failed to analyse %s", key)
stats.failed += 1
+ continue
+ if on_analysed is not None:
+ try:
+ on_analysed(key, path, analysis)
+ except Exception:
+ # The analysis is stored; a failed report must not undo that.
+ log.exception("post-processing failed for %s", key)
return stats
diff --git a/src/vie/video/people.py b/src/vie/video/people.py
index 77eb269..bb3975a 100644
--- a/src/vie/video/people.py
+++ b/src/vie/video/people.py
@@ -50,6 +50,33 @@ def size(s: Sighting) -> int:
return max(self.sightings, key=size)
+def choose_exemplars(sightings: Sequence[Sighting], k: int) -> list[Sighting]:
+ """Up to ``k`` views that together cover how a person looked in a video.
+
+ The first is the most typical view (closest to the person's mean vector); each
+ next one is the view least like any already chosen (farthest-point sampling),
+ so a turned head or a change of light is kept rather than four near-copies of
+ the same frontal face. Searching against these instead of every sighting keeps
+ a person findable at a small, fixed cost per person.
+ """
+ if k <= 0 or not sightings:
+ return []
+ if len(sightings) <= k:
+ return list(sightings)
+ vectors = np.vstack([s.embedding for s in sightings]).astype(np.float32)
+ vectors /= np.linalg.norm(vectors, axis=1, keepdims=True)
+ mean = vectors.mean(axis=0)
+ chosen = [int(np.argmax(vectors @ mean))]
+ nearest = vectors @ vectors[chosen[0]]
+ while len(chosen) < k:
+ candidate = int(np.argmin(nearest))
+ if candidate in chosen:
+ break
+ chosen.append(candidate)
+ nearest = np.maximum(nearest, vectors @ vectors[candidate])
+ return [sightings[i] for i in sorted(chosen, key=lambda i: sightings[i].time)]
+
+
def merge_times(times: Sequence[float], gap: float, pad: float = 0.0) -> list[Interval]:
"""Join timestamps closer than ``gap`` into intervals."""
if not times:
diff --git a/src/vie/video/render.py b/src/vie/video/render.py
new file mode 100644
index 0000000..2e994ee
--- /dev/null
+++ b/src/vie/video/render.py
@@ -0,0 +1,500 @@
+"""An annotated copy of a video: who is on screen, when, and what the shot is about.
+
+The analysis pipeline looks at a few keyframes per shot, which is what makes it
+fast. This module is the opposite trade: it visits every frame (detecting faces on
+every ``detect_every``-th and tracking in between) to produce a watchable MP4 for a
+demo or a review.
+
+Identity, in order of preference:
+
+1. **Named people.** Give ``known`` a few reference vectors per name, and a face
+ is labelled when its track's mean vector clears ``match_threshold`` against
+ that person and beats the runner-up by ``margin``. The margin rule comes from
+ student-face-gate, where it removed wrong-person accepts at low thresholds.
+2. **Anonymous people.** Otherwise tracks are grouped into "Person 1, 2, ..." by
+ average similarity, on the same ArcFace scale as the rest of the system.
+
+A track's identity is decided from its *mean* embedding over every frame seen so
+far, not from one frame, so a single blurred or turned frame cannot relabel it.
+
+Frames come from :func:`vie.video.decode.iter_decoded`, the decoder the analysis
+uses, so a portrait phone video is upright and a truncated file still renders up to
+the damage. The output has no audio track: it is for watching who appears, and
+copying audio would tie the encoder to the source's codec.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Sequence
+from dataclasses import dataclass, field
+from fractions import Fraction
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+
+from vie.logging_setup import get_logger
+
+log = get_logger("video.render")
+
+
+@dataclass
+class Track:
+ id: int
+ box: tuple[float, float, float, float]
+ velocity: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0)
+ vector_sum: np.ndarray | None = None
+ views: int = 0
+ missed: int = 0
+ person: int | None = None
+ name: str | None = None
+ score: float = 0.0
+
+ @property
+ def vector(self) -> np.ndarray | None:
+ if self.vector_sum is None:
+ return None
+ return self.vector_sum / max(float(np.linalg.norm(self.vector_sum)), 1e-12)
+
+
+@dataclass
+class PersonRecord:
+ id: int
+ label: str
+ colour: tuple[int, int, int]
+ vector_sum: np.ndarray
+ seconds: float = 0.0
+ first: float = 0.0
+ last: float = 0.0
+ present: list[float] = field(default_factory=list)
+ thumbnail: Any = None
+ best_area: int = 0
+ best_quality: float = 0.0
+
+ @property
+ def vector(self) -> np.ndarray:
+ return self.vector_sum / max(float(np.linalg.norm(self.vector_sum)), 1e-12)
+
+
+@dataclass
+class RenderResult:
+ path: Path
+ frames: int
+ seconds: float
+ people: list[PersonRecord]
+ fps_processed: float
+
+
+def iou(a: Sequence[float], b: Sequence[float]) -> float:
+ ix1, iy1, ix2, iy2 = max(a[0], b[0]), max(a[1], b[1]), min(a[2], b[2]), min(a[3], b[3])
+ inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)
+ union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter
+ return inter / union if union > 0 else 0.0
+
+
+class FaceTracker:
+ """IoU tracking with appearance as a tie-breaker, and identity by mean vector."""
+
+ def __init__(
+ self,
+ *,
+ known: dict[str, Sequence[np.ndarray]] | None = None,
+ match_threshold: float = 0.46,
+ margin: float = 0.05,
+ cluster_threshold: float = 0.45,
+ max_missed: int = 8,
+ ) -> None:
+ self.known = {
+ name: np.vstack([v / np.linalg.norm(v) for v in vectors]).astype(np.float32)
+ for name, vectors in (known or {}).items() if len(vectors)
+ }
+ self.match_threshold = match_threshold
+ self.margin = margin
+ self.cluster_threshold = cluster_threshold
+ self.max_missed = max_missed
+ self.tracks: list[Track] = []
+ self.people: list[PersonRecord] = []
+ self._next_track = 1
+
+ def predict(self) -> None:
+ """Move every track along its velocity, for frames with no detection."""
+ for t in self.tracks:
+ t.box = tuple(b + v for b, v in zip(t.box, t.velocity, strict=True))
+
+ def update(self, detections: Sequence[tuple[Sequence[float], np.ndarray]]) -> None:
+ unmatched = list(range(len(detections)))
+ pairs = []
+ for ti, track in enumerate(self.tracks):
+ for di, (box, vector) in enumerate(detections):
+ overlap = iou(track.box, box)
+ appearance = float(track.vector @ vector) if track.vector is not None else 0.0
+ if overlap > 0.15 or appearance > 0.55:
+ pairs.append((overlap + 0.5 * appearance, ti, di))
+ used_t: set[int] = set()
+ for _, ti, di in sorted(pairs, reverse=True):
+ if ti in used_t or di not in unmatched:
+ continue
+ used_t.add(ti)
+ unmatched.remove(di)
+ self._absorb(self.tracks[ti], *detections[di])
+ for ti, track in enumerate(self.tracks):
+ if ti not in used_t:
+ track.missed += 1
+ self.tracks = [t for t in self.tracks if t.missed <= self.max_missed]
+ for di in unmatched:
+ track = Track(self._next_track, tuple(float(v) for v in detections[di][0]))
+ self._next_track += 1
+ self._absorb(track, *detections[di], fresh=True)
+ self.tracks.append(track)
+ for track in self.tracks:
+ if track.missed == 0:
+ self._identify(track)
+
+ def _absorb(self, track: Track, box: Sequence[float], vector: np.ndarray,
+ fresh: bool = False) -> None:
+ box = tuple(float(v) for v in box)
+ if not fresh:
+ # Smooth the box a little: detections jitter by a few pixels per frame.
+ track.velocity = tuple(0.5 * (n - o) for n, o in zip(box, track.box, strict=True))
+ box = tuple(0.6 * n + 0.4 * o for n, o in zip(box, track.box, strict=True))
+ track.box = box
+ track.missed = 0
+ track.vector_sum = vector.copy() if track.vector_sum is None else track.vector_sum + vector
+ track.views += 1
+
+ def _identify(self, track: Track) -> None:
+ vector = track.vector
+ if vector is None:
+ return
+ if self.known:
+ scores = sorted(((float((m @ vector).max()), name) for name, m in self.known.items()),
+ reverse=True)
+ best, name = scores[0]
+ runner = scores[1][0] if len(scores) > 1 else -1.0
+ if best >= self.match_threshold and best - runner >= self.margin:
+ track.name, track.score = name, best
+ track.person = self._person_for_name(name, vector)
+ else:
+ track.name, track.score, track.person = None, best, None
+ return
+ if track.person is not None:
+ person = self.people[track.person - 1]
+ track.score = float(person.vector @ vector)
+ if track.views % 4 == 0:
+ person.vector_sum = person.vector_sum + vector
+ return
+ if track.views < 3:
+ return # a glimpse or two is more often a false detection than a person
+ best, choice = self.cluster_threshold, None
+ for person in self.people:
+ score = float(person.vector @ vector)
+ busy = any(t.person == person.id and t is not track and t.missed == 0
+ for t in self.tracks)
+ if score >= best and not busy:
+ best, choice = score, person
+ if choice is None:
+ choice = self._new_person(f"Person {len(self.people) + 1}", vector)
+ best = 1.0
+ track.person, track.score = choice.id, best
+ track.name = choice.label
+
+ def _person_for_name(self, name: str, vector: np.ndarray) -> int:
+ for person in self.people:
+ if person.label == name:
+ return person.id
+ return self._new_person(name, vector).id
+
+ def _new_person(self, label: str, vector: np.ndarray) -> PersonRecord:
+ from vie.report.showcase import LANES
+
+ person = PersonRecord(len(self.people) + 1, label,
+ LANES[len(self.people) % len(LANES)], vector.copy())
+ self.people.append(person)
+ return person
+
+
+def _encoder(path: Path, width: int, height: int, fps: float):
+ """An H.264 writer where the build has one, MPEG-4 Part 2 otherwise."""
+ import av
+
+ # faststart moves the index to the front, so a browser can play while loading.
+ container = av.open(str(path), mode="w", options={"movflags": "+faststart"})
+ for codec in ("libx264", "h264", "libopenh264", "mpeg4"):
+ try:
+ # A fraction keeps 29.97 fps at 29.97; rounding to 30 drifts 3.6 s an hour.
+ stream = container.add_stream(codec, rate=Fraction(fps).limit_denominator(1001))
+ break
+ except Exception: # noqa: BLE001 - try the next encoder this build has
+ continue
+ else:
+ raise RuntimeError("no usable video encoder in this PyAV build")
+ stream.width, stream.height = width, height
+ stream.pix_fmt = "yuv420p"
+ if stream.codec_context.name == "libx264":
+ stream.options = {"crf": "20", "preset": "veryfast"}
+ return container, stream
+
+
+def _sharpness(patch: np.ndarray) -> float:
+ """Variance of the Laplacian, squashed to [0, 1): blur scores near zero."""
+ import cv2
+
+ if patch.size == 0:
+ return 0.0
+ gray = cv2.cvtColor(patch, cv2.COLOR_BGR2GRAY)
+ value = float(cv2.Laplacian(gray, cv2.CV_64F).var())
+ return value / (value + 60.0)
+
+
+def _format_clock(seconds: float) -> str:
+ seconds = max(0.0, seconds)
+ return f"{int(seconds // 60):02d}:{seconds % 60:05.2f}"
+
+
+def render_annotated_video(
+ source: str | Path,
+ output: str | Path,
+ *,
+ detect: Callable[[np.ndarray], list[tuple[Sequence[float], np.ndarray]]],
+ known: dict[str, Sequence[np.ndarray]] | None = None,
+ title: str | None = None,
+ shot_labels: Callable[[float], list[str]] | None = None,
+ detect_every: int = 2,
+ max_seconds: float | None = None,
+ max_side: int = 1280,
+ match_threshold: float = 0.46,
+ cluster_threshold: float = 0.45,
+ progress: Callable[[float], None] | None = None,
+ min_seconds: float = 1.0,
+) -> RenderResult:
+ """Write an annotated MP4 and return who appeared in it.
+
+ ``detect`` takes a BGR frame and returns ``(box, unit_vector)`` per face, which
+ is what :class:`vie.adapters.InsightFaceDetector` already produces, so faces here
+ use exactly the recipe the index uses.
+ """
+ import itertools
+ import time
+
+ import av
+ from PIL import Image, ImageDraw
+
+ from vie.report.showcase import (
+ FAINT,
+ HAIR,
+ INK,
+ MUTED,
+ PAPER,
+ SIGNAL,
+ SIGNAL_INK,
+ display_text,
+ draw_brackets,
+ font,
+ pill,
+ )
+ from vie.video import decode
+
+ source, output = Path(source), Path(output)
+ output.parent.mkdir(parents=True, exist_ok=True)
+ tracker = FaceTracker(known=known, match_threshold=match_threshold,
+ cluster_threshold=cluster_threshold,
+ max_missed=max(4, detect_every * 4))
+
+ info = decode.probe(source)
+ fps = info.fps or 25.0
+ duration = info.duration
+ if max_seconds:
+ duration = min(duration, max_seconds) if duration else max_seconds
+ decoded = decode.iter_decoded(source, max_side=max_side)
+ first = next(decoded, None)
+ if first is None:
+ raise decode.VideoDecodeError(f"{source} has no decodable frame")
+ # From a decoded frame, not the header: rotation swaps width and height.
+ out_h, out_w = (v // 2 * 2 for v in first.image.shape[:2])
+ # Shown in the strip and counted in the header only once seen this long, so a
+ # false detection that lasts a few frames never takes a lane.
+ min_frames = max(1, int(fps * min_seconds / 2))
+ unit = max(14, out_h // 34)
+ lane_pitch = int(unit * 1.15)
+ strip_h = (lane_pitch * 5 + unit) // 2 * 2 # room for five people, always even
+ writer, out_stream = _encoder(output, out_w, out_h + strip_h, fps)
+
+ f_title, f_label = font(int(unit * 1.25), True), font(unit, True)
+ f_small = font(int(unit * .85))
+ started = time.perf_counter()
+ frames = 0
+ t = 0.0
+ try:
+ for frame in itertools.chain([first], decoded):
+ t = frame.time
+ if max_seconds and t > max_seconds:
+ break
+ image = np.ascontiguousarray(frame.image[:out_h, :out_w])
+ detected_now = frames % max(1, detect_every) == 0
+ if detected_now:
+ tracker.update(detect(image))
+ else:
+ tracker.predict()
+
+ canvas = Image.new("RGB", (out_w, out_h + strip_h), INK)
+ canvas.paste(Image.fromarray(image[:, :, ::-1]), (0, 0))
+ draw = ImageDraw.Draw(canvas, "RGBA")
+
+ on_screen = 0
+ for track in tracker.tracks:
+ if track.missed > detect_every:
+ continue
+ x1, y1, x2, y2 = (int(v) for v in track.box)
+ person = tracker.people[track.person - 1] if track.person else None
+ colour = person.colour if person else (200, 205, 212)
+ draw_brackets(draw, (x1, y1, x2, y2), colour, width=max(2, unit // 6))
+ label = (f"{track.name} {track.score * 100:.0f}%" if track.name
+ else "unknown" if tracker.known else "…")
+ pill(draw, (x1, max(0, y1 - unit * 2)), label, f_label,
+ colour if person else (40, 44, 52),
+ SIGNAL_INK if person else PAPER, (unit // 2, unit // 4))
+ if person is not None:
+ on_screen += 1
+ if not person.present or t - person.present[-1] > 1e-6:
+ person.present.append(t)
+ area = (x2 - x1) * (y2 - y1)
+ # Thumbnails only from a real detection, never a predicted box, and
+ # the sharpest large view: the largest alone is usually motion blur.
+ if (detected_now and track.missed == 0 and x1 >= 0 and y1 >= 0
+ and area * 2 > person.best_area):
+ face = image[y1:y2, x1:x2]
+ quality = area * _sharpness(face)
+ if face.size and quality > person.best_quality:
+ pad = int((x2 - x1) * 0.3)
+ crop = image[max(0, y1 - pad):y2 + pad, max(0, x1 - pad):x2 + pad]
+ person.thumbnail = Image.fromarray(crop[:, :, ::-1].copy())
+ person.best_quality = quality
+ person.best_area = max(person.best_area, area)
+
+ # ── heads-up display ─────────────────────────────────────
+ hud_h = int(unit * 3.4)
+ for k in range(hud_h):
+ draw.line([(0, k), (out_w, k)], fill=(0, 0, 0, int(170 * (1 - k / hud_h))))
+ draw.text((unit, unit * 0.6), display_text((title or source.stem)[:60]), font=f_title,
+ fill=PAPER)
+ clock = _format_clock(t)
+ cw = draw.textbbox((0, 0), clock, font=f_title)[2]
+ draw.text((out_w - cw - unit, unit * 0.6), clock, font=f_title, fill=SIGNAL)
+ # On the same dark backing as the chips, so it reads over a bright window.
+ mark = "Visual Intelligence Engine"
+ mw = draw.textbbox((0, 0), mark, font=f_small)[2] + unit
+ pill(draw, (out_w - mw - unit, int(unit * 2.3)), mark, f_small, (0, 0, 0, 140),
+ MUTED, (unit // 2, unit // 5))
+ lanes = [p for p in tracker.people if len(p.present) >= min_frames]
+ chips = [f"{on_screen} on screen",
+ f"{len(lanes)} {'person' if len(lanes) == 1 else 'people'} so far"]
+ if shot_labels is not None:
+ chips = shot_labels(t)[:2] + chips
+ x = unit
+ for chip in chips:
+ r = pill(draw, (x, int(unit * 2.3)), chip, f_small, (0, 0, 0, 140), PAPER,
+ (unit // 2, unit // 5))
+ x = r[2] + unit // 2
+
+ # ── presence timeline ────────────────────────────────────
+ top = out_h
+ draw.line([(0, top), (out_w, top)], fill=HAIR, width=1)
+ lanes = lanes[:5] # in order of first appearance, so lanes never reshuffle
+ lane_h = max(4, lane_pitch - 6)
+ span = duration or max(t, 1.0)
+ label_w = int(unit * 7)
+ for li, person in enumerate(lanes):
+ ly = top + unit // 2 + li * lane_pitch
+ draw.text((unit, ly - 3), display_text(person.label[:14]), font=f_small, fill=MUTED)
+ for seen in person.present:
+ px = label_w + int((out_w - label_w - unit) * min(1.0, seen / span))
+ draw.line([(px, ly), (px, ly + lane_h)], fill=person.colour, width=2)
+ if not lanes:
+ draw.text((unit, top + strip_h // 3), "no faces yet", font=f_small, fill=FAINT)
+ head = label_w + int((out_w - label_w - unit) * min(1.0, t / span))
+ draw.line([(head, top + 4), (head, top + strip_h - 4)], fill=SIGNAL, width=2)
+
+ out = av.VideoFrame.from_image(canvas)
+ for packet in out_stream.encode(out):
+ writer.mux(packet)
+ frames += 1
+ if progress is not None and frames % 30 == 0 and span:
+ progress(min(1.0, t / span))
+ for packet in out_stream.encode():
+ writer.mux(packet)
+ finally:
+ writer.close()
+ decoded.close()
+
+ step = 1.0 / fps
+ for person in tracker.people:
+ if person.present:
+ person.first, person.last = person.present[0], person.present[-1]
+ person.seconds = len(person.present) * step
+ # Under ``min_seconds`` on screen is more often a false detection than a person.
+ # Kept in order of first appearance, which is also the order of their numbers.
+ people = sorted((p for p in tracker.people if p.seconds >= min_seconds),
+ key=lambda p: p.first)
+ elapsed = time.perf_counter() - started
+ log.info("annotated %d frames in %.1fs -> %s", frames, elapsed, output)
+ return RenderResult(output, frames, t, people, frames / elapsed if elapsed else 0.0)
+
+
+def render_people_card(result: RenderResult, *, title: str, width: int = 1600) -> Any:
+ """One image summarising who appeared and for how long."""
+ from PIL import Image, ImageDraw
+
+ from vie.report.showcase import (
+ FAINT,
+ HAIR,
+ INK,
+ INK_3,
+ MUTED,
+ PAPER,
+ SIGNAL,
+ display_text,
+ focus_crop,
+ font,
+ rounded,
+ )
+
+ people = result.people[:12]
+ margin, gap = 56, 36
+ # Balanced rows (7 people: 4 + 3, not 5 + 2), tiles as large as the width allows.
+ rows = max(1, -(-len(people) // 6))
+ cols = max(1, -(-len(people) // rows))
+ tile = min(300, (width - margin * 2 - gap * (cols - 1)) // cols)
+ height = 230 + rows * (tile + 140) + 80
+ canvas = Image.new("RGB", (width, height), INK)
+ draw = ImageDraw.Draw(canvas, "RGBA")
+ draw.text((margin, 50), "VIDEO UNDERSTANDING · PEOPLE", font=font(18, True), fill=SIGNAL)
+ draw.text((margin, 80), display_text(title[:60]), font=font(46, True), fill=PAPER)
+ draw.text((margin, 142), f"{len(result.people)} people · {result.frames:,} frames · "
+ f"{_format_clock(result.seconds)} analysed · {result.fps_processed:.1f} frames/s",
+ font=font(18), fill=MUTED)
+ draw.line([(margin, 190), (width - margin, 190)], fill=HAIR, width=1)
+ span = max(result.seconds, 1e-6)
+ for i, person in enumerate(people):
+ r, c = divmod(i, cols)
+ x, y = margin + c * (tile + gap), 220 + r * (tile + 140)
+ if person.thumbnail is not None:
+ face, _ = focus_crop(person.thumbnail, (tile, tile), None)
+ else:
+ face = Image.new("RGB", (tile, tile), INK_3)
+ masked = rounded(face, 16)
+ canvas.paste(masked, (x, y), masked)
+ draw.rounded_rectangle((x, y, x + tile, y + tile), radius=16, outline=person.colour,
+ width=3)
+ draw.text((x, y + tile + 12), display_text(person.label), font=font(22, True), fill=PAPER)
+ draw.text((x, y + tile + 42), f"{person.seconds:.1f}s on screen", font=font(15),
+ fill=MUTED)
+ draw.text((x, y + tile + 62), f"first seen {_format_clock(person.first)}", font=font(15),
+ fill=FAINT)
+ by = y + tile + 94
+ draw.rounded_rectangle((x, by, x + tile, by + 10), radius=5, fill=INK_3)
+ for seen in person.present[::2]:
+ px = x + int(tile * min(1.0, seen / span))
+ draw.line([(px, by), (px, by + 10)], fill=person.colour, width=1)
+ draw.text((margin, height - 50), "Faces are grouped by appearance; names come only from "
+ "reference photos you supply.", font=font(15), fill=FAINT)
+ return canvas
diff --git a/src/vie/video/store.py b/src/vie/video/store.py
index 352123d..c4f971a 100644
--- a/src/vie/video/store.py
+++ b/src/vie/video/store.py
@@ -19,7 +19,7 @@
from vie.database import Index, format_box
from vie.embeddings import from_blob, to_blob
from vie.video.engine import VideoAnalysis
-from vie.video.people import Interval, merge_times
+from vie.video.people import Interval, choose_exemplars, merge_times
VIDEO_SCHEMA = """
CREATE TABLE IF NOT EXISTS videos (
@@ -51,6 +51,17 @@
FOREIGN KEY (video_key) REFERENCES videos (video_key) ON DELETE CASCADE
);
+-- When each person is on screen, as intervals. Their face vectors are only the few
+-- exemplars in video_faces, so this is what a face search reports back.
+CREATE TABLE IF NOT EXISTS video_person_spans (
+ video_key TEXT NOT NULL,
+ person INTEGER NOT NULL,
+ start REAL NOT NULL,
+ end REAL NOT NULL,
+ PRIMARY KEY (video_key, person, start),
+ FOREIGN KEY (video_key) REFERENCES videos (video_key) ON DELETE CASCADE
+);
+
CREATE TABLE IF NOT EXISTS video_transcript (
video_key TEXT NOT NULL,
start REAL NOT NULL,
@@ -180,14 +191,21 @@ def save(
"VALUES (?, ?, ?, ?)",
(key, frame.time, frame.shot, to_blob(frame.vector)),
)
+ exemplars = self.config.video.face_exemplars
for person in analysis.people:
- for sighting in person.sightings:
+ for sighting in choose_exemplars(person.sightings, exemplars):
conn.execute(
"INSERT OR REPLACE INTO video_faces "
"(video_key, time, bbox, person, embedding) VALUES (?, ?, ?, ?, ?)",
(key, sighting.time, format_box(sighting.box), person.id,
to_blob(sighting.embedding)),
)
+ for span in person.intervals:
+ conn.execute(
+ "INSERT OR REPLACE INTO video_person_spans "
+ "(video_key, person, start, end) VALUES (?, ?, ?, ?)",
+ (key, person.id, span.start, span.end),
+ )
for segment in analysis.transcript:
conn.execute(
"INSERT OR REPLACE INTO video_transcript (video_key, start, end, text) "
@@ -273,30 +291,51 @@ def search_speech(
def search_face(
self, conn: sqlite3.Connection, query: np.ndarray, top_k: int = 10
) -> list[Moment]:
- """Where a given face appears, as intervals, across every video."""
+ """Where a given face appears, as intervals, across every video.
+
+ A person matches when any of their stored exemplar views clears the face
+ threshold, and the answer is every interval that person is on screen. An
+ index from before exemplars kept every sighting and has no spans; there the
+ matching sightings themselves are merged into intervals, as before.
+ """
rows = conn.execute(
- "SELECT f.video_key, v.file_path, f.time, f.embedding FROM video_faces f "
+ "SELECT f.video_key, v.file_path, f.time, f.person, f.embedding FROM video_faces f "
"JOIN videos v USING (video_key)"
).fetchall()
if not rows:
return []
- matrix = np.vstack([from_blob(r[3]) for r in rows])
+ matrix = np.vstack([from_blob(r[4]) for r in rows])
scores = matrix @ np.asarray(query, dtype=np.float32).ravel()
threshold = self.config.face.match_threshold
+ spans: dict[tuple[str, int], list[tuple[float, float]]] = {}
+ for key, person, start, end in conn.execute(
+ "SELECT video_key, person, start, end FROM video_person_spans"
+ ):
+ spans.setdefault((key, person), []).append((start, end))
+
+ best: dict[tuple[str, int], float] = {}
hits: dict[str, list[tuple[float, float]]] = {}
paths: dict[str, str] = {}
- for (key, path, time, _), score in zip(rows, scores.tolist(), strict=True):
- if score >= threshold:
+ for (key, path, time, person, _), score in zip(rows, scores.tolist(), strict=True):
+ if score < threshold:
+ continue
+ paths[key] = path
+ if (key, person) in spans:
+ best[(key, person)] = max(score, best.get((key, person), -1.0))
+ else:
hits.setdefault(key, []).append((time, score))
- paths[key] = path
- moments: list[Moment] = []
+ moments: list[Moment] = [
+ Moment(key, paths[key], start, end, score, "face")
+ for (key, person), score in best.items()
+ for start, end in spans[(key, person)]
+ ]
for key, entries in hits.items():
for span in _spans(entries, self.config.video.presence_gap):
inside = [s for t, s in entries if span.start <= t <= span.end]
moments.append(Moment(key, paths[key], span.start, span.end, max(inside), "face"))
- moments.sort(key=lambda m: m.score, reverse=True)
+ moments.sort(key=lambda m: (-m.score, m.video_key, m.start))
return moments[:top_k]
diff --git a/src/vie/vlm.py b/src/vie/vlm.py
index 887197f..9f94155 100644
--- a/src/vie/vlm.py
+++ b/src/vie/vlm.py
@@ -48,6 +48,16 @@ def is_siglip(model_name: str) -> bool:
return "siglip" in model_name.lower()
+def is_multilingual(model_name: str) -> bool:
+ """SigLIP 2 was trained on multilingual WebLI; SigLIP and CLIP on English."""
+ return "siglip2" in model_name.lower()
+
+
+def has_right_to_left(text: str) -> bool:
+ """Persian, Arabic or Hebrew script anywhere in ``text``."""
+ return any("\u0590" <= ch <= "\u08ff" or "\ufb1d" <= ch <= "\ufeff" for ch in text)
+
+
def default_score_mode(model_name: str) -> str:
return SIGMOID if is_siglip(model_name) else SOFTMAX
diff --git a/tests/test_integration.py b/tests/test_integration.py
index f6691e2..3ea3aac 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -260,7 +260,9 @@ def test_inference_is_batched(raw_config, tmp_path) -> None:
stats = stack.index(batch_size=4)
assert stats.indexed == 6
assert stack.encoder.forward_passes == 2
- assert stack.encoder.batch_sizes == [24, 12] # four and two images, six crops each
+ # Four and two images, six scene crops each, plus the fake animal every red image
+ # holds: its object vector rides in the same forward pass, not a second one.
+ assert stack.encoder.batch_sizes == [28, 14]
assert stack.food.batches == [4, 2]
diff --git a/tests/test_v05.py b/tests/test_v05.py
new file mode 100644
index 0000000..da2a96a
--- /dev/null
+++ b/tests/test_v05.py
@@ -0,0 +1,499 @@
+"""v0.5: face accuracy, the Engine API, result cards and the annotated video."""
+
+from __future__ import annotations
+
+import copy
+from pathlib import Path
+from types import SimpleNamespace
+
+import numpy as np
+import pytest
+from fakes import FakeStack, write_png
+
+from vie.config import Config
+from vie.database import Index
+from vie.scoring import Match
+
+
+def unit(v) -> np.ndarray:
+ v = np.asarray(v, dtype=np.float32)
+ return v / np.linalg.norm(v)
+
+
+@pytest.fixture()
+def cfg(raw_config: dict, tmp_path: Path) -> Config:
+ root = tmp_path / "gallery"
+ write_png(root / "red.png", (230, 20, 20))
+ write_png(root / "blue.png", (20, 20, 230))
+ raw = copy.deepcopy(raw_config)
+ raw["paths"] = {"gallery": str(root), "database": str(tmp_path / "index.db")}
+ return Config.from_dict(raw)
+
+
+# ── mirror averaging ──────────────────────────────────────────────────
+
+
+def test_mirror_vector_is_the_normalised_sum_of_both_views() -> None:
+ pytest.importorskip("insightface")
+ from vie.adapters import face_vectors
+
+ seen, mirrored = np.array([3.0, 0, 0, 0]), np.array([0, 4.0, 0, 0], dtype=np.float32)
+
+ class Rec:
+ input_size = (112, 112)
+
+ def get_feat(self, crops):
+ assert crops[0].shape == (112, 112, 3)
+ return np.vstack([mirrored for _ in crops])
+
+ face = SimpleNamespace(embedding=seen, normed_embedding=unit(seen),
+ kps=np.array([[38, 52], [74, 52], [56, 72], [42, 92], [70, 92]],
+ dtype=np.float32))
+ app = SimpleNamespace(models={"recognition": Rec()})
+ image = np.zeros((160, 160, 3), dtype=np.uint8)
+ (vector,) = face_vectors(app, image, [face], flip=True)
+ np.testing.assert_allclose(vector, unit([3.0, 4.0, 0, 0]), rtol=1e-6)
+ (plain,) = face_vectors(app, image, [face], flip=False)
+ np.testing.assert_allclose(plain, unit(seen))
+
+
+def test_mirror_falls_back_to_the_plain_vector_without_a_recogniser() -> None:
+ from vie.adapters import face_vectors
+
+ face = SimpleNamespace(normed_embedding=unit([1, 2, 3]))
+ assert face_vectors(SimpleNamespace(), None, [face], flip=True)[0] is face.normed_embedding
+
+
+# ── several query photos ──────────────────────────────────────────────
+
+
+class Catalog:
+ def __init__(self, rows):
+ self.rows = rows
+ self.backend = SimpleNamespace(name="exact")
+
+ def __len__(self):
+ return len(self.rows)
+
+ def search(self, query, k):
+ scored = [(name, bbox, float(vec @ query)) for name, bbox, vec in self.rows]
+ return sorted(scored, key=lambda r: -r[2])[:k]
+
+
+def test_a_second_query_photo_finds_the_pose_the_first_missed(cfg) -> None:
+ from vie.search.face import rank_catalog_multi
+
+ frontal, profile = unit([1, 0, 0]), unit([0, 1, 0])
+ catalog = Catalog([("front.jpg", "0,0,10,10", frontal),
+ ("side.jpg", "0,0,10,10", unit([0.1, 1, 0])),
+ ("stranger.jpg", "0,0,10,10", unit([0, 0, 1]))])
+ paths = {}
+ one = rank_catalog_multi([frontal], catalog, paths, cfg)
+ both = rank_catalog_multi([frontal, profile], catalog, paths, cfg)
+ assert [m.file_name for m in one] == ["front.jpg"]
+ assert sorted(m.file_name for m in both) == ["front.jpg", "side.jpg"]
+
+
+def test_face_search_accepts_several_images_end_to_end(cfg, tmp_path) -> None:
+ from vie.query import run_search
+
+ stack = FakeStack(cfg)
+ stack.index()
+ query = cfg.gallery_path / "blue.png"
+ args = SimpleNamespace(kind="face", query="", image=[query, query], top_k=None)
+ assert [m.file_name for m in run_search(args, cfg, stack.load_runtime)] == ["blue.png"]
+
+
+# ── changing the face recipe re-indexes only images with faces ───────
+
+
+def test_a_new_face_recipe_reindexes_only_the_images_with_faces(cfg) -> None:
+ stack = FakeStack(cfg)
+ assert stack.index().indexed == 2
+ assert stack.index().indexed == 0 # incremental: nothing changed, nothing read
+ index = Index(cfg.database_path)
+ with index.connect() as conn:
+ assert index.get_meta(conn, "face_recipe") == "arcface+flip"
+ index.set_meta(conn, "face_recipe", "arcface") # an index from before v0.5
+ stats = stack.index()
+ assert stats.indexed == 1 # blue.png has the face; red.png is left alone
+ assert stack.index().indexed == 0
+
+
+# ── the Engine ────────────────────────────────────────────────────────
+
+
+def test_load_config_merges_overrides_over_the_packaged_defaults(tmp_path) -> None:
+ from vie.api import load_config
+
+ config = load_config({"paths": {"gallery": str(tmp_path)}, "face": {"flip_tta": False}})
+ assert config.gallery_path == tmp_path
+ assert config.face.flip_tta is False
+ assert config.face.match_threshold == 0.46 # untouched defaults survive
+
+
+def test_pruning_most_of_the_index_at_once_is_refused(cfg) -> None:
+ """An unmounted drive looks like every photo was deleted; rows must survive it."""
+ stack = FakeStack(cfg)
+ stack.index()
+ for name in ("red.png", "blue.png"):
+ (cfg.gallery_path / name).unlink()
+ stats = stack.index()
+ assert stats.prune_refused and stats.pruned == 0
+ with Index(cfg.database_path).connect() as conn:
+ assert conn.execute("SELECT COUNT(*) FROM gallery_meta").fetchone()[0] == 2
+ forced = stack.index(prune_guard=0.0)
+ assert forced.pruned == 2 and not forced.prune_refused
+
+
+def test_engine_reports_a_refused_prune(cfg, monkeypatch) -> None:
+ import vie.adapters
+ import vie.models
+ import vie.pipeline
+ from vie.api import Engine
+
+ monkeypatch.setattr(vie.pipeline, "build_index", lambda config, **kw: SimpleNamespace(
+ scanned=0, indexed=0, skipped=0, pruned=0, failed=0, prune_refused=True))
+ monkeypatch.setattr(vie.adapters, "build_detectors", lambda b, c: SimpleNamespace(
+ loader=None, size_of=None, face=None, animal=None, food=None, crop=None))
+ monkeypatch.setattr(vie.models, "SpeciesClassifier", lambda b: SimpleNamespace(classify=None))
+ engine = Engine(cfg)
+ engine._bundle, engine._encoder = SimpleNamespace(), SimpleNamespace(
+ embed_batch=None, food_gate_scores=None)
+ assert engine.index_images().pruning is False
+
+
+def test_auto_face_query_is_a_crop_of_the_chosen_face(cfg, tmp_path) -> None:
+ from PIL import Image
+
+ from vie.api import Engine
+
+ FakeStack(cfg).index()
+ engine = Engine(cfg)
+ assert engine.most_photographed_face() == cfg.gallery_path / "blue.png"
+ crop = engine.most_photographed_face(crop_to=tmp_path / "query.jpg")
+ with Image.open(crop) as image:
+ # The fake face is 50x60 inside a 120x90 photo; padded, then clipped to it.
+ assert image.size[0] <= 120 and image.size[1] <= 90 and image.size[0] > 50
+
+
+def test_video_search_needs_exactly_one_of_query_or_image(cfg) -> None:
+ from vie.api import Engine
+
+ with pytest.raises(ValueError, match="exactly one"):
+ Engine(cfg).search_videos()
+
+
+# ── presentation ──────────────────────────────────────────────────────
+
+
+def test_result_card_renders_with_boxes_and_query(tmp_path) -> None:
+ pytest.importorskip("PIL")
+ from vie.report.showcase import CardStats, save_search_card
+
+ photos = [write_png(tmp_path / f"p{i}.png", (40 * i, 90, 160), (640, 480)) for i in range(5)]
+ matches = [Match(p.name, str(p), 0.9 - i * 0.1, (100, 80, 300, 320), label="zebra")
+ for i, p in enumerate(photos)]
+ out = save_search_card(matches, tmp_path / "card.png", kind="animal", query="zebra",
+ query_images=[photos[0]], stats=CardStats(search_ms=3.2,
+ indexed_images=5))
+ from PIL import Image
+
+ with Image.open(out) as image:
+ assert image.size[0] == 1600 and image.size[1] > 600
+
+
+def test_focus_crop_keeps_the_subject_in_the_tile() -> None:
+ from PIL import Image
+
+ from vie.report.showcase import focus_crop
+
+ image = Image.new("RGB", (2000, 1000))
+ _, box = focus_crop(image, (400, 300), (1700, 400, 1900, 600))
+ x1, y1, x2, y2 = box
+ assert 0 <= x1 < x2 <= 400 and 0 <= y1 < y2 <= 300
+
+
+# ── the annotated video ───────────────────────────────────────────────
+
+
+def test_tracker_keeps_identities_apart_and_stable() -> None:
+ from vie.video.render import FaceTracker
+
+ a, b = unit([1, 0, 0, 0]), unit([0, 1, 0, 0])
+ tracker = FaceTracker()
+ for step in range(6):
+ tracker.update([((10 + step, 10, 60 + step, 60), a), ((200, 10, 250, 60), b)])
+ labels = sorted(t.name for t in tracker.tracks)
+ assert labels == ["Person 1", "Person 2"]
+ assert len(tracker.people) == 2
+
+
+def test_tracker_names_only_with_a_margin_over_the_runner_up() -> None:
+ from vie.video.render import FaceTracker
+
+ known = {"Ava": [unit([1, 0, 0])], "Ben": [unit([0.98, 0.2, 0])]}
+ tracker = FaceTracker(known=known, margin=0.05)
+ tracker.update([((0, 0, 50, 50), unit([1, 0.1, 0]))])
+ assert tracker.tracks[0].name is None # the two references are too alike to call it
+ tracker = FaceTracker(known={"Ava": [unit([1, 0, 0])], "Ben": [unit([0, 1, 0])]})
+ tracker.update([((0, 0, 50, 50), unit([1, 0.1, 0]))])
+ assert tracker.tracks[0].name == "Ava"
+
+
+def test_render_writes_a_playable_video(tmp_path) -> None:
+ av = pytest.importorskip("av")
+ from vie.video.render import render_annotated_video, render_people_card
+
+ source = tmp_path / "in.mp4"
+ container = av.open(str(source), mode="w")
+ stream = container.add_stream("mpeg4", rate=10)
+ stream.width, stream.height, stream.pix_fmt = 160, 120, "yuv420p"
+ for i in range(30):
+ frame = np.full((120, 160, 3), 40 + i * 3, dtype=np.uint8)
+ for packet in stream.encode(av.VideoFrame.from_ndarray(frame, format="rgb24")):
+ container.mux(packet)
+ for packet in stream.encode():
+ container.mux(packet)
+ container.close()
+
+ face = unit([1, 0, 0, 0])
+ result = render_annotated_video(source, tmp_path / "out.mp4",
+ detect=lambda img: [((40, 30, 90, 90), face)],
+ title="test", detect_every=2)
+ assert result.frames == 30
+ assert [p.label for p in result.people] == ["Person 1"]
+ with av.open(str(result.path)) as check:
+ assert sum(1 for _ in check.decode(video=0)) == 30
+ assert render_people_card(result, title="test").size[0] == 1600
+
+
+def test_shot_labeller_reads_labels_of_the_current_shot() -> None:
+ from vie.api import shot_labeller
+
+ analysis = SimpleNamespace(
+ shots=[SimpleNamespace(index=1, start=5.0, end=9.0),
+ SimpleNamespace(index=0, start=0.0, end=5.0)],
+ shot_labels=[SimpleNamespace(shot=0, labels={"topic": ("an interview", 0.8),
+ "setting": ("in a studio", 0.6)}),
+ SimpleNamespace(shot=1, labels={"topic": ("a lecture", 0.3)})],
+ )
+ at = shot_labeller(analysis, threshold=0.4)
+ assert at(2.0) == ["an interview", "in a studio"]
+ assert at(6.0) == [] # below the presence threshold
+ assert at(20.0) == [] # past the last shot
+ assert shot_labeller(analysis, threshold=0.2)(6.0) == ["a lecture"]
+
+
+def test_right_to_left_text_is_prepared_and_latin_is_untouched() -> None:
+ from vie.report.showcase import display_text, font
+
+ assert display_text("Person 1 93%") == "Person 1 93%"
+ assert isinstance(display_text("دانشجو ۱۰۴۲"), str)
+ assert font(20) is font(20) # cached: looking up a face walks font folders
+
+
+# ── incremental runs stay incremental ─────────────────────────────────
+
+
+def test_a_touched_but_unchanged_file_is_hashed_once_then_never_again(cfg, monkeypatch) -> None:
+ """A sync or a copy moves the mtime. Hashing then must happen once, not forever."""
+ import os
+
+ import vie.pipeline as pipeline
+
+ stack = FakeStack(cfg)
+ stack.index()
+ target = cfg.gallery_path / "red.png"
+ os.utime(target, ns=(target.stat().st_atime_ns, target.stat().st_mtime_ns + 10**9))
+ hashed = []
+ real = pipeline.content_hash
+ monkeypatch.setattr(pipeline, "content_hash", lambda p: hashed.append(p) or real(p))
+ assert stack.index().indexed == 0
+ assert hashed == [target]
+ assert stack.index().indexed == 0
+ assert hashed == [target] # the new mtime was remembered
+
+
+def test_a_moved_gallery_keeps_its_index_and_points_at_the_new_place(
+ cfg, raw_config, tmp_path
+) -> None:
+ import shutil
+
+ FakeStack(cfg).index()
+ moved = tmp_path / "moved"
+ shutil.copytree(cfg.gallery_path, moved) # copytree keeps contents, not always mtimes
+ shutil.rmtree(cfg.gallery_path)
+ raw = copy.deepcopy(raw_config)
+ raw["paths"] = {"gallery": str(moved), "database": str(cfg.database_path)}
+ stats = FakeStack(Config.from_dict(raw)).index()
+ assert stats.indexed == 0 and stats.pruned == 0
+ with Index(cfg.database_path).connect() as conn:
+ paths = {Path(p).parent for (p,) in conn.execute("SELECT file_path FROM gallery_meta")}
+ assert paths == {moved}
+
+
+# ── food and unknown species are answered from stored vectors ─────────
+
+
+@pytest.fixture()
+def food_cfg(raw_config: dict, tmp_path: Path) -> Config:
+ root = tmp_path / "gallery"
+ write_png(root / "red.png", (230, 20, 20))
+ write_png(root / "green.png", (20, 230, 20))
+ write_png(root / "blue.png", (20, 20, 230))
+ raw = copy.deepcopy(raw_config)
+ raw["paths"] = {"gallery": str(root), "database": str(tmp_path / "index.db")}
+ return Config.from_dict(raw)
+
+
+def _no_pixels(monkeypatch) -> None:
+ import vie.adapters
+
+ def refuse(*args, **kwargs):
+ raise AssertionError("a query decoded an image")
+
+ monkeypatch.setattr(vie.adapters, "load_image", refuse)
+
+
+def test_object_vectors_are_stored_at_index_time(food_cfg) -> None:
+ FakeStack(food_cfg).index()
+ with Index(food_cfg.database_path).connect() as conn:
+ rows = conn.execute("SELECT file_name, kind FROM object_embeddings").fetchall()
+ flags = dict(conn.execute("SELECT file_name, objects_embedded FROM gallery_meta"))
+ assert rows == [("red.png", "animal")] # the fake detector's one animal
+ assert set(flags.values()) == {1}
+
+
+@pytest.mark.parametrize(("kind", "query", "expected"), [
+ ("food", "kebab", ["green.png"]), # gate-admitted, scored on its whole frame
+ ("animal", "red quokka", None), # out of vocabulary: the vector path
+])
+def test_food_and_unknown_species_open_no_image(food_cfg, monkeypatch, kind, query,
+ expected) -> None:
+ from vie.query import run_search
+
+ stack = FakeStack(food_cfg)
+ stack.index()
+ reference = run_search(SimpleNamespace(kind=kind, query=query, image=None, top_k=None),
+ food_cfg, stack.load_runtime)
+ _no_pixels(monkeypatch)
+ found = run_search(SimpleNamespace(kind=kind, query=query, image=None, top_k=None),
+ food_cfg, stack.load_runtime)
+ assert [m.file_name for m in found] == [m.file_name for m in reference]
+ if expected is not None:
+ assert [m.file_name for m in found] == expected
+
+
+def test_an_older_index_is_still_served_by_cropping(food_cfg) -> None:
+ from vie.query import run_search
+
+ stack = FakeStack(food_cfg)
+ stack.index()
+ request = SimpleNamespace(kind="food", query="kebab", image=None, top_k=None)
+ new = run_search(request, food_cfg, stack.load_runtime)
+ with Index(food_cfg.database_path).connect() as conn:
+ conn.execute("UPDATE gallery_meta SET objects_embedded = 0")
+ conn.execute("DELETE FROM object_embeddings")
+ old = run_search(request, food_cfg, stack.load_runtime)
+ assert [m.file_name for m in old] == [m.file_name for m in new] == ["green.png"]
+ assert old[0].score == pytest.approx(new[0].score, abs=1e-5)
+
+
+def test_an_unknown_species_is_found_from_its_stored_vector(raw_config, tmp_path,
+ monkeypatch) -> None:
+ from vie.query import run_search
+
+ write_png(tmp_path / "g" / "red.png", (230, 20, 20))
+ write_png(tmp_path / "g" / "blue.png", (20, 20, 230))
+ raw = copy.deepcopy(raw_config)
+ raw["paths"] = {"gallery": str(tmp_path / "g"), "database": str(tmp_path / "i.db")}
+ raw["animal"]["match_threshold"] = 0.5
+ config = Config.from_dict(raw)
+ stack = FakeStack(config)
+ stack.index()
+ _no_pixels(monkeypatch)
+ found = run_search(SimpleNamespace(kind="animal", query="red quokka", image=None,
+ top_k=None), config, stack.load_runtime)
+ assert [m.file_name for m in found] == ["red.png"] and found[0].box is not None
+
+
+# ── one description, photos and video moments together ────────────────
+
+
+def test_concept_search_returns_photos_and_video_moments(raw_config, tmp_path) -> None:
+ pytest.importorskip("av")
+ from test_video import write_video
+
+ from vie.api import Engine
+ from vie.report.showcase import save_search_card
+ from vie.video.engine import VideoEngine
+ from vie.video.library import index_videos
+
+ write_png(tmp_path / "gallery" / "red.png", (230, 20, 20))
+ write_png(tmp_path / "gallery" / "blue.png", (20, 20, 230))
+ write_video(tmp_path / "videos" / "clip.mp4", [((230, 20, 20), 4), ((20, 230, 20), 4)])
+ raw = copy.deepcopy(raw_config)
+ raw["paths"] = {"gallery": str(tmp_path / "gallery"), "videos": str(tmp_path / "videos"),
+ "database": str(tmp_path / "index.db")}
+ raw["video"]["sample_seconds"] = 0.5
+ raw["video"]["keyframe_seconds"] = 2.0
+ config = Config.from_dict(raw)
+ stack = FakeStack(config)
+ stack.index()
+ index_videos(config, VideoEngine(config, stack.encoder, on_stage=lambda s: None))
+
+ engine = Engine(config)
+ engine._bundle, engine._encoder = stack.bundle, stack.encoder
+ result = engine.search_everything("something red", top_k=4, min_moments=1,
+ frames_dir=tmp_path / "frames")
+ names = [m.file_name for m in result.matches]
+ assert "red.png" in names
+ videos = [i for i, badge in enumerate(result.badges) if badge]
+ assert videos and names[videos[0]].startswith("clip @ 0:0")
+ assert Path(result.matches[videos[0]].file_path).is_file() # the grabbed frame
+ card = save_search_card(result.matches, tmp_path / "card.png", kind="concept",
+ query=result.query, badges=result.badges)
+ assert card.is_file()
+
+
+# ── measured search defaults ──────────────────────────────────────────
+
+
+def test_a_persian_query_is_encoded_as_typed() -> None:
+ from vie.models import VisionLanguageEncoder
+
+ seen: list[list[str]] = []
+ encoder = VisionLanguageEncoder.__new__(VisionLanguageEncoder)
+ encoder.bundle = SimpleNamespace(config=SimpleNamespace(
+ query_templates=("{query}", "a photo of {query}")))
+ encoder.encode_texts = lambda prompts: seen.append(list(prompts)) or np.ones((len(prompts), 4))
+ encoder.encode_query("تئاتر")
+ encoder.encode_query("theatre")
+ assert seen == [["تئاتر"], ["theatre", "a photo of theatre"]]
+
+
+def test_nothing_found_floor_hides_improbable_text_results(cfg, raw_config) -> None:
+ from vie.query import run_search
+
+ stack = FakeStack(cfg)
+ stack.index()
+ request = SimpleNamespace(kind="text", query="something red", image=None, top_k=5)
+ shown = run_search(request, cfg, stack.load_runtime)
+ raw = copy.deepcopy(raw_config)
+ raw["paths"] = {"gallery": str(cfg.gallery_path), "database": str(cfg.database_path)}
+ raw["text"]["min_probability"] = 0.999
+ strict = Config.from_dict(raw)
+ assert shown and run_search(request, strict, stack.load_runtime) == []
+
+
+def test_each_model_brings_its_own_thresholds_and_explicit_settings_win() -> None:
+ from vie.api import load_config
+
+ v2 = load_config({"models": {"vision_language": "google/siglip2-so400m-patch14-384"}})
+ v1 = load_config({"models": {"vision_language": "google/siglip-so400m-patch14-384"}})
+ assert (v2.food.match_threshold, v2.animal.match_threshold) == (0.92, 0.84)
+ assert (v1.food.match_threshold, v1.animal.match_threshold) == (0.98, 0.91)
+ mine = load_config({"models": {"vision_language": "google/siglip2-so400m-patch14-384"},
+ "food": {"match_threshold": 0.95}})
+ assert mine.food.match_threshold == 0.95 and mine.animal.match_threshold == 0.84
diff --git a/tests/test_video.py b/tests/test_video.py
index 8a408dd..19bcb34 100644
--- a/tests/test_video.py
+++ b/tests/test_video.py
@@ -755,3 +755,31 @@ def test_cli_analyze_missing_file_is_a_clean_error(cfg, tmp_path, monkeypatch) -
def test_cli_search_needs_exactly_one_query(cfg, tmp_path) -> None:
code = cli.main(["video", "search", "--config", str(_config_file(cfg, tmp_path / "c.yaml"))])
assert code == 2
+
+
+def test_exemplars_are_the_typical_view_then_the_most_different() -> None:
+ from vie.video.people import Sighting, choose_exemplars
+
+ frontal = [_vector(1.0, 0.02 * i, 0.0) for i in range(8)]
+ turned = _vector(0.6, 0.0, 0.8)
+ sightings = [Sighting(float(i), (0, 0, 10, 10), v) for i, v in enumerate(frontal)]
+ sightings.append(Sighting(99.0, (0, 0, 10, 10), turned))
+ picked = choose_exemplars(sightings, 2)
+ assert len(picked) == 2 and picked[-1].time == 99.0 # the turned head is kept
+ assert choose_exemplars(sightings[:1], 4) == sightings[:1]
+ assert choose_exemplars(sightings, 0) == []
+
+
+def test_face_storage_is_capped_per_person_and_search_still_finds_them(
+ clip: Path, cfg: Config
+) -> None:
+ index_videos(cfg, engine(cfg, face_detector=FakeFaces()))
+ store = VideoStore(cfg)
+ with store.connect() as conn:
+ per_person = conn.execute(
+ "SELECT person, COUNT(*) FROM video_faces GROUP BY person").fetchall()
+ spans = conn.execute("SELECT COUNT(*) FROM video_person_spans").fetchone()[0]
+ moments = store.search_face(conn, np.eye(512, dtype=np.float32)[0])
+ assert per_person and all(count <= cfg.video.face_exemplars for _, count in per_person)
+ assert spans >= 1
+ assert moments and moments[0].kind == "face" and moments[0].start >= 11.0