PAREIDOLIA v0 — everything secretly has a face
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +43 -0
- FIELD_NOTES.md +150 -0
- README.md +137 -7
- app.py +525 -0
- cv/__init__.py +10 -0
- cv/__pycache__/__init__.cpython-312.pyc +0 -0
- cv/__pycache__/snap.cpython-312.pyc +0 -0
- cv/snap.py +533 -0
- mind/WRITING.md +92 -0
- mind/__init__.py +54 -0
- mind/__pycache__/__init__.cpython-312.pyc +0 -0
- mind/__pycache__/backends.cpython-312.pyc +0 -0
- mind/__pycache__/prompts.cpython-312.pyc +0 -0
- mind/__pycache__/schema.cpython-312.pyc +0 -0
- mind/__pycache__/voice.cpython-312.pyc +0 -0
- mind/backends.py +683 -0
- mind/mock_assets/grudge.wav +3 -0
- mind/prompts.py +508 -0
- mind/schema.py +360 -0
- mind/voice.py +225 -0
- requirements-dev.txt +15 -0
- requirements.txt +21 -0
- seeds/eval/LICENSES.md +21 -0
- seeds/eval/_manifest.json +240 -0
- seeds/eval/armchair.jpg +3 -0
- seeds/eval/coffee_mug.jpg +3 -0
- seeds/eval/desk_lamp.jpg +0 -0
- seeds/eval/exercise_bike.jpg +3 -0
- seeds/eval/fire_hydrant.jpg +3 -0
- seeds/eval/garden_gnome.jpg +3 -0
- seeds/eval/mailbox.jpg +3 -0
- seeds/eval/park_bench.jpg +3 -0
- seeds/eval/payphone.jpg +3 -0
- seeds/eval/potted_plant.jpg +3 -0
- seeds/eval/stand_mixer.jpg +3 -0
- seeds/eval/stapler.jpg +3 -0
- seeds/eval/toaster.jpg +0 -0
- seeds/eval/traffic_cone.jpg +3 -0
- seeds/records/records.jsonl +12 -0
- seeds/records/seed_armchair.jpg +3 -0
- seeds/records/seed_armchair.json +57 -0
- seeds/records/seed_armchair.wav +3 -0
- seeds/records/seed_coffee_mug.jpg +3 -0
- seeds/records/seed_coffee_mug.json +57 -0
- seeds/records/seed_coffee_mug.wav +3 -0
- seeds/records/seed_fire_hydrant.jpg +3 -0
- seeds/records/seed_fire_hydrant.json +57 -0
- seeds/records/seed_fire_hydrant.wav +3 -0
- seeds/records/seed_garden_gnome.jpg +3 -0
- seeds/records/seed_garden_gnome.json +57 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,46 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
mind/mock_assets/grudge.wav filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
seeds/eval/armchair.jpg filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
seeds/eval/coffee_mug.jpg filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
seeds/eval/exercise_bike.jpg filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
seeds/eval/fire_hydrant.jpg filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
seeds/eval/garden_gnome.jpg filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
seeds/eval/mailbox.jpg filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
seeds/eval/park_bench.jpg filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
seeds/eval/payphone.jpg filter=lfs diff=lfs merge=lfs -text
|
| 45 |
+
seeds/eval/potted_plant.jpg filter=lfs diff=lfs merge=lfs -text
|
| 46 |
+
seeds/eval/stand_mixer.jpg filter=lfs diff=lfs merge=lfs -text
|
| 47 |
+
seeds/eval/stapler.jpg filter=lfs diff=lfs merge=lfs -text
|
| 48 |
+
seeds/eval/traffic_cone.jpg filter=lfs diff=lfs merge=lfs -text
|
| 49 |
+
seeds/records/seed_armchair.jpg filter=lfs diff=lfs merge=lfs -text
|
| 50 |
+
seeds/records/seed_armchair.wav filter=lfs diff=lfs merge=lfs -text
|
| 51 |
+
seeds/records/seed_coffee_mug.jpg filter=lfs diff=lfs merge=lfs -text
|
| 52 |
+
seeds/records/seed_coffee_mug.wav filter=lfs diff=lfs merge=lfs -text
|
| 53 |
+
seeds/records/seed_fire_hydrant.jpg filter=lfs diff=lfs merge=lfs -text
|
| 54 |
+
seeds/records/seed_fire_hydrant.wav filter=lfs diff=lfs merge=lfs -text
|
| 55 |
+
seeds/records/seed_garden_gnome.jpg filter=lfs diff=lfs merge=lfs -text
|
| 56 |
+
seeds/records/seed_garden_gnome.wav filter=lfs diff=lfs merge=lfs -text
|
| 57 |
+
seeds/records/seed_mailbox.jpg filter=lfs diff=lfs merge=lfs -text
|
| 58 |
+
seeds/records/seed_mailbox.wav filter=lfs diff=lfs merge=lfs -text
|
| 59 |
+
seeds/records/seed_park_bench.jpg filter=lfs diff=lfs merge=lfs -text
|
| 60 |
+
seeds/records/seed_park_bench.wav filter=lfs diff=lfs merge=lfs -text
|
| 61 |
+
seeds/records/seed_payphone.jpg filter=lfs diff=lfs merge=lfs -text
|
| 62 |
+
seeds/records/seed_payphone.wav filter=lfs diff=lfs merge=lfs -text
|
| 63 |
+
seeds/records/seed_potted_plant.jpg filter=lfs diff=lfs merge=lfs -text
|
| 64 |
+
seeds/records/seed_potted_plant.wav filter=lfs diff=lfs merge=lfs -text
|
| 65 |
+
seeds/records/seed_stand_mixer.jpg filter=lfs diff=lfs merge=lfs -text
|
| 66 |
+
seeds/records/seed_stand_mixer.wav filter=lfs diff=lfs merge=lfs -text
|
| 67 |
+
seeds/records/seed_stapler.jpg filter=lfs diff=lfs merge=lfs -text
|
| 68 |
+
seeds/records/seed_stapler.wav filter=lfs diff=lfs merge=lfs -text
|
| 69 |
+
seeds/records/seed_toaster.wav filter=lfs diff=lfs merge=lfs -text
|
| 70 |
+
seeds/records/seed_traffic_cone.jpg filter=lfs diff=lfs merge=lfs -text
|
| 71 |
+
seeds/records/seed_traffic_cone.wav filter=lfs diff=lfs merge=lfs -text
|
| 72 |
+
web/mock/life-dev.wav filter=lfs diff=lfs merge=lfs -text
|
| 73 |
+
web/mock/photos/m1.jpg filter=lfs diff=lfs merge=lfs -text
|
| 74 |
+
web/mock/photos/m2.jpg filter=lfs diff=lfs merge=lfs -text
|
| 75 |
+
web/mock/photos/m3.jpg filter=lfs diff=lfs merge=lfs -text
|
| 76 |
+
web/mock/photos/m5.jpg filter=lfs diff=lfs merge=lfs -text
|
| 77 |
+
web/mock/photos/m6.jpg filter=lfs diff=lfs merge=lfs -text
|
| 78 |
+
web/mock/photos/m7.jpg filter=lfs diff=lfs merge=lfs -text
|
FIELD_NOTES.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 👁️ PAREIDOLIA — Field Notes
|
| 2 |
+
|
| 3 |
+
*Build log for the Build Small Hackathon (Thousand Token Wood). Started June 12, 2026.*
|
| 4 |
+
|
| 5 |
+
## The idea, and why this shape
|
| 6 |
+
|
| 7 |
+
Pareidolia is the thing your brain does when it finds a face in a power outlet, a
|
| 8 |
+
church facade, the front of a Jeep. Everyone has it. No app celebrates it.
|
| 9 |
+
|
| 10 |
+
So: point your camera at any object. A small vision model — **MiniCPM-V-4_5, 8.7B**
|
| 11 |
+
— looks at the photo the way you do at clouds: it *names the features that were
|
| 12 |
+
already there* ("the two bonnet bolts", "the front outlet cap") and decides what
|
| 13 |
+
they are — eyes, a mouth. It reads the object's condition (rusted, pristine,
|
| 14 |
+
dusty, abandoned) and writes it a soul to match. Then **VoxCPM2, 2.29B** gives it
|
| 15 |
+
a voice — designed from a text description, no reference audio — and the object
|
| 16 |
+
tells you, specifically, what it has been putting up with.
|
| 17 |
+
|
| 18 |
+
A fire hydrant that has been on the same corner for forty years has *opinions*.
|
| 19 |
+
|
| 20 |
+
## Small models used smartly (the part we care about)
|
| 21 |
+
|
| 22 |
+
The org's favorite pattern — deterministic code owns the facts, the tiny model
|
| 23 |
+
owns the taste — is load-bearing here, twice:
|
| 24 |
+
|
| 25 |
+
1. **The VLM never emits final pixel coordinates.** No VLM is trained to locate
|
| 26 |
+
face-like features in faceless objects; raw-coordinate eye placement misfires,
|
| 27 |
+
and a misplaced eye reads as a broken sticker instead of a discovered soul.
|
| 28 |
+
So MiniCPM-V *names* existing visual features and gives coarse normalized
|
| 29 |
+
hints; classical OpenCV (Hough circles, blob centroids, corner anchors, a
|
| 30 |
+
pair-coherence rule) snaps them to the strongest real anchor nearby. Taste
|
| 31 |
+
from the model, facts from the code. The mist-emergence animation forgives
|
| 32 |
+
the remaining ±15%.
|
| 33 |
+
2. **One generation, no second opinion round-trip.** The séance prompt asks for a
|
| 34 |
+
*critique-then-final* JSON in a single call — the model second-guesses its own
|
| 35 |
+
feature choices and line specificity inside one generation. Halves latency;
|
| 36 |
+
the séance theater carries the rest.
|
| 37 |
+
|
| 38 |
+
And the writing is governed, not vibes: an 8–12 **grudge-archetype bank keyed to
|
| 39 |
+
object condition** (the_veteran/rusted, the_martyr/worn, the_perfectionist/
|
| 40 |
+
pristine, the_diva/loved…), an iron rule that every line must reference a
|
| 41 |
+
*visible specific* of THIS photo, validators for the rules a regex can hold
|
| 42 |
+
(length, banned openers), and a hard ship-gate: a human must actually laugh at
|
| 43 |
+
≥6 of 12 eval objects, or the prompt iterates before anything else gets polished.
|
| 44 |
+
|
| 45 |
+
## Architecture in one diagram
|
| 46 |
+
|
| 47 |
+
```
|
| 48 |
+
phone camera ──► client downscale (≤1024px JPEG)
|
| 49 |
+
│
|
| 50 |
+
▼ browser calls the gradio endpoint itself (@gradio/client)
|
| 51 |
+
@app.api("awaken") ── one @spaces.GPU window, visitor's own quota ──┐
|
| 52 |
+
│ MiniCPM-V-4_5: gate → features (named, coarse) → persona → lines │
|
| 53 |
+
│ OpenCV: snap features to real anchors (deterministic) │
|
| 54 |
+
│ VoxCPM2: voice-design TTS for the grudge line │
|
| 55 |
+
◄────────────────────────────────────────────────────────────────────┘
|
| 56 |
+
▼
|
| 57 |
+
the LIFE engine (client SVG): mist → eyes of the photo's own palette →
|
| 58 |
+
first blink → saccades, pupil-follow → mouth rides the wav's amplitude
|
| 59 |
+
▼
|
| 60 |
+
"add to the menagerie?" ──► persisted record + image + voice →
|
| 61 |
+
shared wall every visitor sees, alive with ZERO GPU → HF dataset (public)
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
The Space *opens* on the Menagerie — every awakened object blinking and
|
| 65 |
+
glancing around, overlays rendered client-side from persisted JSON — so the
|
| 66 |
+
landing state is fully alive even when ZeroGPU is asleep or queued. Live
|
| 67 |
+
awakening is the second act, billed to the visitor's own quota (the "her"
|
| 68 |
+
browser-invocation idiom).
|
| 69 |
+
|
| 70 |
+
Privacy/abuse, by construction: the same VLM call gates real human faces and
|
| 71 |
+
NSFW before anything else runs (the refusal is poetic: *"It is already awake."*);
|
| 72 |
+
nothing reaches the public wall except server-persisted, gate-checked records;
|
| 73 |
+
no user identity is stored.
|
| 74 |
+
|
| 75 |
+
## Build log
|
| 76 |
+
|
| 77 |
+
### June 12 — recon, contract, fleet
|
| 78 |
+
- 5-agent verification recon over the live field (370 spaces): the pareidolia
|
| 79 |
+
hook is unclaimed; the OpenBMB wood lane has real contenders now; model IDs
|
| 80 |
+
from earlier notes were wrong in both directions ("MiniCPM-V-4_6" ≠ 9B model —
|
| 81 |
+
it's a 1.3B edge model with an incompatible stack; VoxCPM2's killer feature,
|
| 82 |
+
text-only voice design, wasn't in any of our notes).
|
| 83 |
+
- Wrote the binding `ARCHITECTURE.md` + `mind/WRITING.md` (the comedy bible)
|
| 84 |
+
BEFORE any code — six agents then built server/mind/cv/web in parallel against
|
| 85 |
+
the contract, on a mock backend, no GPU needed.
|
| 86 |
+
- Bench Space (we tried to rent a paid L40S; the org has no pre-paid credits —
|
| 87 |
+
every paid tier 402'd — so the bench runs on the same ZeroGPU grant as prod,
|
| 88 |
+
which turned out to be the better experiment anyway: the timings are
|
| 89 |
+
*exactly* representative). The slice is generous: an RTX PRO 6000 Blackwell
|
| 90 |
+
MIG 2g.48gb, 50.9GB. Both models co-resident at 24GB.
|
| 91 |
+
- **Warm numbers**: MiniCPM-V structured JSON ~9.8–15s GPU (image sliced ≤4,
|
| 92 |
+
600–700 new tokens, greedy); VoxCPM2 ~4s GPU for ~5s of 48kHz speech; one
|
| 93 |
+
full awakening ≈ **17–25s** visitor-facing. The séance theater has to carry
|
| 94 |
+
~20s, and does.
|
| 95 |
+
- **The writing eval is a gate, not a vibe**: 14 CC0 object photos through the
|
| 96 |
+
real pipeline, a harsh-editor laugh count, four prompt rounds. Round 1
|
| 97 |
+
collapsed to the_perfectionist on 8/14 objects and opened every line with a
|
| 98 |
+
count of years — exemplar-skeleton cloning under greedy decoding. The fixes
|
| 99 |
+
that worked were *rules about the world, not rules about jokes*: check for
|
| 100 |
+
rust before calling anything pristine; another of its kind in frame forces
|
| 101 |
+
the_rival; name the thing, not the feeling (banned vocabulary: purpose,
|
| 102 |
+
potential, neglect…). Best line the model wrote, unprompted, about a mug
|
| 103 |
+
with a violin-shaped handle: *"Why a violin? Why not a guitar?"*
|
| 104 |
+
- **Grounding truth**: the VLM hallucinates coordinates ~40% of the time on
|
| 105 |
+
hard objects (it once placed "hinge screws" confidently in blank wall). The
|
| 106 |
+
deterministic snap layer + a render-side forgiveness clamp + curation gets
|
| 107 |
+
the genesis wall to ~10/12 plausible. We do not pretend otherwise — the
|
| 108 |
+
trace dataset shows every snap delta.
|
| 109 |
+
|
| 110 |
+
### June 13 — <!-- TODO(fill): deploy, seeds, polish, social -->
|
| 111 |
+
|
| 112 |
+
### June 14 — <!-- TODO(fill): demo video, org blog -->
|
| 113 |
+
|
| 114 |
+
## Things that bit us (so they don't bite you)
|
| 115 |
+
|
| 116 |
+
- **`voxcpm` pip backtracking is silent and brutal**: voxcpm 2.x requires
|
| 117 |
+
`gradio>=6,<7`. If anything in your stack pins gradio below 6, pip quietly
|
| 118 |
+
resolves voxcpm **1.5.0**, which has no VoxCPM2 dispatch — your "VoxCPM2"
|
| 119 |
+
Space then reads the persona prefix *aloud* at 16kHz. Pin `voxcpm>=2.0.3`.
|
| 120 |
+
- **torchcodec is coupled to torch minor versions**: unpinned it resolves to
|
| 121 |
+
0.14, which demands torch≥2.11 and drags the whole stack against the Space
|
| 122 |
+
image's preinstalled torch 2.8.0. `torchcodec==0.7.*` + `torchaudio==2.8.*`.
|
| 123 |
+
- **`TORCHDYNAMO_DISABLE=1` before any torch import** — VoxCPM's torch.compile
|
| 124 |
+
warmup dies on ZeroGPU ("Cannot construct ConstantVariable for
|
| 125 |
+
torch.device"). Or pass `optimize=False`; we do both.
|
| 126 |
+
- **MiniCPM-V-4_5 lives on the transformers v4 line** (its remote code breaks
|
| 127 |
+
on v5), and transformers v4 needs huggingface-hub<1.0, which conflicts with
|
| 128 |
+
gradio 6.18's hub≥1.0 — `sdk_version: 6.16.0` is the keystone holding the
|
| 129 |
+
arch up. Also: `attn_implementation="sdpa"`, never eager; and pass
|
| 130 |
+
`enable_thinking=False` or a hybrid-thinking model will eat your token
|
| 131 |
+
budget thinking about a stapler.
|
| 132 |
+
- **Load BOTH models at startup, never inside the GPU window.** ZeroGPU runs
|
| 133 |
+
your GPU function in a forked worker; lazy state does not persist. Our first
|
| 134 |
+
wiring lazy-loaded the 2.3B voice inside the visitor's window — that's a
|
| 135 |
+
Hub download on someone else's quota.
|
| 136 |
+
- **Never trust a VLM with pixels.** Ask it to *name* features and give coarse
|
| 137 |
+
hints; let Hough circles and blob centroids do the placing. When we asked
|
| 138 |
+
for raw coordinates it gave both eyes the same point — on the object's hat.
|
| 139 |
+
- **PIL decompression bombs are reachable before your GPU auth** — cap
|
| 140 |
+
declared dimensions before `.load()`, not just upload bytes.
|
| 141 |
+
|
| 142 |
+
## Models
|
| 143 |
+
|
| 144 |
+
| Role | Model | Params | Why |
|
| 145 |
+
|---|---|---|---|
|
| 146 |
+
| Eyes & soul | [openbmb/MiniCPM-V-4_5](https://huggingface.co/openbmb/MiniCPM-V-4_5) | 8.7B | the only small VLM whose feature-naming + condition-reading is good enough to be load-bearing |
|
| 147 |
+
| Voice | [openbmb/VoxCPM2](https://huggingface.co/openbmb/VoxCPM2) | 2.29B | voice *design from a text description* — ten distinct characters, zero reference audio |
|
| 148 |
+
|
| 149 |
+
≈ 11B total. Comfortably under the 32B cap, and every parameter is doing
|
| 150 |
+
something you can see or hear.
|
README.md
CHANGED
|
@@ -1,13 +1,143 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: PAREIDOLIA
|
| 3 |
+
emoji: 👁️
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 6.16.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: apache-2.0
|
| 11 |
+
short_description: "point at anything. find the face that was always there."
|
| 12 |
+
models:
|
| 13 |
+
- openbmb/MiniCPM-V-4_5
|
| 14 |
+
- openbmb/VoxCPM2
|
| 15 |
+
datasets:
|
| 16 |
+
- AndresCarreon/pareidolia-menagerie
|
| 17 |
+
tags:
|
| 18 |
+
- track:wood
|
| 19 |
+
- sponsor:openbmb
|
| 20 |
+
- achievement:offbrand
|
| 21 |
+
- achievement:sharing
|
| 22 |
+
- achievement:fieldnotes
|
| 23 |
+
- achievement:offgrid
|
| 24 |
+
- build-small-hackathon
|
| 25 |
+
- minicpm
|
| 26 |
+
- voxcpm
|
| 27 |
+
- camera
|
| 28 |
+
- multimodal
|
| 29 |
---
|
| 30 |
|
| 31 |
+
# 👁️ PAREIDOLIA
|
| 32 |
+
|
| 33 |
+
**Everything secretly has a face.**
|
| 34 |
+
|
| 35 |
+
Point your camera at any object. A small **MiniCPM-V** spirit-medium studies the
|
| 36 |
+
photo the way you study clouds — it finds the two bonnet bolts that were always
|
| 37 |
+
eyes, the outlet cap that was always a mouth — and the face that was hiding in
|
| 38 |
+
your fire hydrant opens its eyes, blinks at you, and tells you, in its own
|
| 39 |
+
voice, exactly what it has been putting up with.
|
| 40 |
+
|
| 41 |
+
> *"Forty years on this corner. Not one dog has shown me respect."*
|
| 42 |
+
|
| 43 |
+
Every awakened object joins the **Menagerie** — one public wall, shared by every
|
| 44 |
+
visitor, where all of them blink, glance around, and mutter.
|
| 45 |
+
|
| 46 |
+
<!-- TODO(main, June 13): hero GIF here — cut on the first blink. -->
|
| 47 |
+
|
| 48 |
+
## ✨ The moment that sells it
|
| 49 |
+
|
| 50 |
+
You photograph your own coffee mug. Mist crosses the photo while the medium
|
| 51 |
+
works — *"there is something here…"* — then it names what it found: *"the two
|
| 52 |
+
painted notes… symmetric…"* Two eyes the exact color of the glaze fade onto the
|
| 53 |
+
ceramic and **blink**. And then the mug speaks. It has noticed things about you.
|
| 54 |
+
It has been keeping a list.
|
| 55 |
+
|
| 56 |
+
No face is invented. The eyes land on features that are *really there* — that's
|
| 57 |
+
the whole point, and the whole name.
|
| 58 |
+
|
| 59 |
+
## 🧠 How it works — taste from the model, facts from the code
|
| 60 |
+
|
| 61 |
+
A hard separation of church and state, twice over:
|
| 62 |
+
|
| 63 |
+
```
|
| 64 |
+
your photo (browser downscales; YOUR ZeroGPU quota pays for your séance)
|
| 65 |
+
│
|
| 66 |
+
▼ one @spaces.GPU window, ~20s
|
| 67 |
+
┌─ THE MEDIUM · MiniCPM-V-4_5 ──────────────────────────────────┐
|
| 68 |
+
│ ONE structured generation (critique-then-final JSON): │
|
| 69 |
+
│ privacy gate → object + condition → named VISIBLE features │
|
| 70 |
+
│ ("left bonnet bolt: eye") → persona → the grudge │
|
| 71 |
+
└──────────────┬────────────────────────────────────────────────┘
|
| 72 |
+
▼
|
| 73 |
+
┌─ THE SURVEYOR · OpenCV, deterministic ────────────────────────┐
|
| 74 |
+
│ snaps each named feature to the strongest real anchor nearby │
|
| 75 |
+
│ (Hough circles, blobs, corners; pair-coherence rules) — │
|
| 76 |
+
│ the model never places a pixel; the code never invents an eye│
|
| 77 |
+
└──────────────┬────────────────────────────────────────────────┘
|
| 78 |
+
▼
|
| 79 |
+
┌─ THE VOICE · VoxCPM2 ─────────────────────────────────────────┐
|
| 80 |
+
│ voice DESIGNED from a text description — ten distinct │
|
| 81 |
+
│ characters, zero reference audio │
|
| 82 |
+
└──────────────┬────────────────────────────────────────────────┘
|
| 83 |
+
▼
|
| 84 |
+
the LIFE engine (client-side SVG): eyes tinted from the photo's own
|
| 85 |
+
palette → first blink → saccades, pupil-follow → the mouth rides the
|
| 86 |
+
audio's amplitude. The wall replays all of it forever with ZERO GPU.
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
The writing is governed, not vibes: a grudge-archetype bank keyed to the
|
| 90 |
+
object's *visible condition* (the_veteran/rusted, the_martyr/worn,
|
| 91 |
+
the_perfectionist/pristine, the_diva/displayed…), and an iron rule that every
|
| 92 |
+
line must reference something actually in the photo.
|
| 93 |
+
|
| 94 |
+
Privacy is structural: the same generation gates **real human faces and NSFW**
|
| 95 |
+
before anything else runs — the refusal is simply *"It is already awake."* —
|
| 96 |
+
and nothing reaches the public wall except server-held, gate-checked records.
|
| 97 |
+
No user identity is ever stored.
|
| 98 |
+
|
| 99 |
+
## 📦 Models (≈ 11B total — well under the 32B cap)
|
| 100 |
+
|
| 101 |
+
| role | model | size | license |
|
| 102 |
+
|---|---|---|---|
|
| 103 |
+
| sees the face, writes the soul | [openbmb/MiniCPM-V-4_5](https://huggingface.co/openbmb/MiniCPM-V-4_5) | 8.7B | Apache-2.0 |
|
| 104 |
+
| speaks the grudge | [openbmb/VoxCPM2](https://huggingface.co/openbmb/VoxCPM2) | 2.29B | Apache-2.0 |
|
| 105 |
+
|
| 106 |
+
Both models run **in-Space**. No external APIs, no remote endpoints.
|
| 107 |
+
|
| 108 |
+
## 🏆 Badges
|
| 109 |
+
|
| 110 |
+
- **Off-Brand** — the entire UI is custom (static `web/` served by `gr.Server`;
|
| 111 |
+
zero gradio components on screen).
|
| 112 |
+
- **Off the Grid** — all inference in-Space on ZeroGPU.
|
| 113 |
+
- **Sharing is Caring** — every awakening publishes its full wake-trace (the
|
| 114 |
+
medium's JSON, the surveyor's snap deltas, the chosen voice) to
|
| 115 |
+
[AndresCarreon/pareidolia-menagerie](https://huggingface.co/datasets/AndresCarreon/pareidolia-menagerie).
|
| 116 |
+
- **Field Notes** — [FIELD_NOTES.md](FIELD_NOTES.md) — the build log, including
|
| 117 |
+
the dependency saga and what a vision model does when you ask it to find
|
| 118 |
+
eyes in a stapler. <!-- TODO(main): + org blog link -->
|
| 119 |
+
|
| 120 |
+
## 📋 Submission links
|
| 121 |
+
|
| 122 |
+
<!-- TODO(fill-before-deadline) -->
|
| 123 |
+
- **Demo video:** *coming before the deadline.*
|
| 124 |
+
- **Social post:** *coming before the deadline.*
|
| 125 |
+
|
| 126 |
+
## ▶️ Run it locally
|
| 127 |
+
|
| 128 |
+
```bash
|
| 129 |
+
pip install -r requirements-dev.txt
|
| 130 |
+
PAREIDOLIA_BACKEND=mock python app.py # mock medium: no GPU, no ML deps needed
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
The Space runs `PAREIDOLIA_BACKEND=zerogpu`; mock returns canned awakenings
|
| 134 |
+
with realistic latency so the whole experience works on a laptop.
|
| 135 |
+
|
| 136 |
+
## 🙏 Credits
|
| 137 |
+
|
| 138 |
+
- Models: OpenBMB **MiniCPM-V-4_5** + **VoxCPM2** (Apache-2.0).
|
| 139 |
+
- Seed-wall photos: CC0 / public-domain images via Openverse & Wikimedia
|
| 140 |
+
Commons — per-image attribution in [`seeds/eval/LICENSES.md`](seeds/eval/LICENSES.md).
|
| 141 |
+
- Type: Cormorant Garamond (SIL OFL).
|
| 142 |
+
|
| 143 |
+
*It was in there all along.*
|
app.py
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PAREIDOLIA — Space entrypoint. Everything secretly has a face.
|
| 2 |
+
|
| 3 |
+
Wiring (ARCHITECTURE.md is the contract; the godseed skeleton is the proven base):
|
| 4 |
+
|
| 5 |
+
Menagerie wall (web/) <-- GET / static, zero GPU
|
| 6 |
+
the séance <-- @app.api(name="awaken") gradio endpoint via
|
| 7 |
+
@gradio/client — the VISITOR's browser-authenticated
|
| 8 |
+
request pays the ZeroGPU quota
|
| 9 |
+
(POST /api/awaken is its mock/dev REST twin,
|
| 10 |
+
not registered on the zerogpu backend)
|
| 11 |
+
publish to the wall <-- POST /api/menagerie {record_token}; rate-limited,
|
| 12 |
+
gate re-checked server-side
|
| 13 |
+
the wall, paginated <-- GET /api/menagerie newest first
|
| 14 |
+
live wall updates <-- GET /api/stream SSE 'awakened' broadcasts
|
| 15 |
+
persisted media <-- GET /media/{id}.(jpg|wav) id->path map only
|
| 16 |
+
|
| 17 |
+
Run: python app.py (port from $PORT, default 7860)
|
| 18 |
+
Backends: PAREIDOLIA_BACKEND = mock | zerogpu (default mock)
|
| 19 |
+
Sync: PAREIDOLIA_DATASET (+ HF_TOKEN) mirrors menagerie/ to an HF dataset.
|
| 20 |
+
Identity: PAREIDOLIA_SECRET signs visitor cookies (publish <= 6/hour each).
|
| 21 |
+
|
| 22 |
+
The "her" pattern: the VLM owns taste (what counts as an eye, what the soul
|
| 23 |
+
sounds like); this file owns facts — bytes, tokens, limits, and what is allowed
|
| 24 |
+
onto the public wall. Errors and refusals are always poetry, never stack traces.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import asyncio
|
| 30 |
+
import base64
|
| 31 |
+
import hashlib
|
| 32 |
+
import io
|
| 33 |
+
import logging
|
| 34 |
+
import os
|
| 35 |
+
import re
|
| 36 |
+
import threading
|
| 37 |
+
import warnings
|
| 38 |
+
|
| 39 |
+
# ZeroGPU runtime: the `spaces` lib must be imported before torch anywhere in
|
| 40 |
+
# the process (it patches torch to virtualize the GPU). TORCHDYNAMO_DISABLE
|
| 41 |
+
# must also precede any torch import — VoxCPM's torch.compile warmup breaks
|
| 42 |
+
# ZeroGPU (§7). No-op everywhere else.
|
| 43 |
+
if os.environ.get("PAREIDOLIA_BACKEND", "").strip().lower() == "zerogpu":
|
| 44 |
+
os.environ.setdefault("TORCHDYNAMO_DISABLE", "1")
|
| 45 |
+
try:
|
| 46 |
+
import spaces # noqa: F401
|
| 47 |
+
except ImportError:
|
| 48 |
+
pass
|
| 49 |
+
|
| 50 |
+
from contextlib import asynccontextmanager
|
| 51 |
+
from pathlib import Path
|
| 52 |
+
from typing import Any, Optional
|
| 53 |
+
|
| 54 |
+
from fastapi import FastAPI, HTTPException, Query, Request, Response
|
| 55 |
+
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
| 56 |
+
from fastapi.staticfiles import StaticFiles
|
| 57 |
+
|
| 58 |
+
from server.persistence import PendingStore, PersistenceService
|
| 59 |
+
from server.ratelimit import (
|
| 60 |
+
COOKIE_NAME,
|
| 61 |
+
DEFAULT_IP_LIMIT,
|
| 62 |
+
ClientIdentity,
|
| 63 |
+
RateLimiter,
|
| 64 |
+
client_ip,
|
| 65 |
+
)
|
| 66 |
+
from server.schemas import AwakenRequest, GateFlags, PublishRequest
|
| 67 |
+
from server.sse import SSEHub
|
| 68 |
+
from server.wiring import PipelineLike, make_pipeline
|
| 69 |
+
|
| 70 |
+
log = logging.getLogger("pareidolia")
|
| 71 |
+
|
| 72 |
+
ROOT = Path(__file__).resolve().parent
|
| 73 |
+
WEB_DIR = ROOT / "web"
|
| 74 |
+
MENAGERIE_DIR = ROOT / "menagerie"
|
| 75 |
+
SEEDS_RECORDS_DIR = ROOT / "seeds" / "records"
|
| 76 |
+
|
| 77 |
+
VALID_BACKENDS = ("mock", "zerogpu")
|
| 78 |
+
|
| 79 |
+
# Transport cap: 1.5MB of base64 text (the client downscales to <=350KB JPEG,
|
| 80 |
+
# so a compliant browser never gets near this; the cap stops abusive bodies).
|
| 81 |
+
MAX_IMAGE_B64_BYTES = 1_572_864
|
| 82 |
+
|
| 83 |
+
# Poetry for the unhappy paths — the spirits never return a stack trace.
|
| 84 |
+
OVERSIZE_REASON = (
|
| 85 |
+
"That offering is too heavy for the séance table. "
|
| 86 |
+
"Bring it under a megabyte and a half, and the spirits will look again."
|
| 87 |
+
)
|
| 88 |
+
NEEDS_IMAGE_REASON = "The spirits need a photograph to peer into. The table is bare."
|
| 89 |
+
BAD_IMAGE_REASON = "The spirits see no photograph here — only static."
|
| 90 |
+
VAST_IMAGE_REASON = (
|
| 91 |
+
"The spirits cannot hold a vision that vast. "
|
| 92 |
+
"Offer a smaller photograph and they will lean in close."
|
| 93 |
+
)
|
| 94 |
+
SEANCE_FAILED_REASON = (
|
| 95 |
+
"The candle guttered mid-séance. Nothing was disturbed; try once more."
|
| 96 |
+
)
|
| 97 |
+
RATE_LIMIT_REASON = (
|
| 98 |
+
"The menagerie admits six souls an hour from one hand. "
|
| 99 |
+
"Sit with the ones you have woken; the wall will wait."
|
| 100 |
+
)
|
| 101 |
+
TOKEN_UNKNOWN_REASON = (
|
| 102 |
+
"The spirits hold no séance by that name — or it has faded with the quarter hour."
|
| 103 |
+
)
|
| 104 |
+
GATE_RECHECK_REASON = "The wall declines this one. The spirits were clear."
|
| 105 |
+
NOT_FOUND_REASON = "The menagerie holds no such creature."
|
| 106 |
+
WALL_CROWDED_REASON = (
|
| 107 |
+
"The wall is thick with watchers tonight. Linger a moment, then look again."
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
MEDIA_NAME_RE = re.compile(r"^([A-Za-z0-9_-]{4,64})\.(jpg|wav)$")
|
| 111 |
+
MEDIA_TYPES = {"jpg": "image/jpeg", "wav": "audio/wav"}
|
| 112 |
+
|
| 113 |
+
# Server-side re-encode bound: the client already downscales to <=1024px; this
|
| 114 |
+
# is the defensive ceiling for non-compliant callers (bounds VLM prefill too).
|
| 115 |
+
MAX_IMAGE_SIDE = 1024
|
| 116 |
+
JPEG_QUALITY = 88
|
| 117 |
+
|
| 118 |
+
# Decompression-bomb ceiling (security review #1): a tiny, highly-compressible
|
| 119 |
+
# PNG under the transport cap can DECLARE ~170M pixels and allocate 500MB+ on
|
| 120 |
+
# decode — before the thumbnail clamp, before any GPU quota. Cap total pixels:
|
| 121 |
+
# the 1024px working size with 4x headroom for the pre-thumbnail original.
|
| 122 |
+
MAX_PIXELS = MAX_IMAGE_SIDE * MAX_IMAGE_SIDE * 4 # ~4.2M pixels
|
| 123 |
+
try:
|
| 124 |
+
from PIL import Image as _PILImage
|
| 125 |
+
|
| 126 |
+
# PIL hard-stops at 2x this inside Image.open (DecompressionBombError -> our
|
| 127 |
+
# poetic 400); our explicit dimension check below covers 1x-2x with a 413,
|
| 128 |
+
# so the 1x warning is redundant noise — silence it, keep the error.
|
| 129 |
+
_PILImage.MAX_IMAGE_PIXELS = MAX_PIXELS
|
| 130 |
+
warnings.filterwarnings("ignore", category=_PILImage.DecompressionBombWarning)
|
| 131 |
+
except ImportError: # pragma: no cover — PIL is a hard dependency in prod
|
| 132 |
+
pass
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _backend_name() -> str:
|
| 136 |
+
name = os.environ.get("PAREIDOLIA_BACKEND", "mock").strip().lower() or "mock"
|
| 137 |
+
if name not in VALID_BACKENDS:
|
| 138 |
+
log.warning("unknown PAREIDOLIA_BACKEND=%r; falling back to mock", name)
|
| 139 |
+
name = "mock"
|
| 140 |
+
return name
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# --------------------------------------------------------------------------- helpers
|
| 144 |
+
def _set_identity_cookie(response: Response, request: Request, value: str) -> None:
|
| 145 |
+
"""Spaces serve over https inside an iframe (cross-site -> SameSite=None+Secure);
|
| 146 |
+
local dev is plain http (Lax, not Secure)."""
|
| 147 |
+
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
|
| 148 |
+
secure = scheme == "https"
|
| 149 |
+
response.set_cookie(
|
| 150 |
+
COOKIE_NAME,
|
| 151 |
+
value,
|
| 152 |
+
max_age=365 * 24 * 3600,
|
| 153 |
+
path="/",
|
| 154 |
+
httponly=True,
|
| 155 |
+
secure=secure,
|
| 156 |
+
samesite="none" if secure else "lax",
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _decode_image(image_b64: str):
|
| 161 |
+
"""base64 -> validated, bounded RGB PIL image. Raises poetic HTTPExceptions:
|
| 162 |
+
413 oversize, 400 undecodable, 422 missing. PIL import stays local so the
|
| 163 |
+
transport module imports clean even in odd environments."""
|
| 164 |
+
if not image_b64 or not image_b64.strip():
|
| 165 |
+
raise HTTPException(status_code=422, detail=NEEDS_IMAGE_REASON)
|
| 166 |
+
if len(image_b64) > MAX_IMAGE_B64_BYTES:
|
| 167 |
+
raise HTTPException(status_code=413, detail=OVERSIZE_REASON)
|
| 168 |
+
payload = image_b64.strip()
|
| 169 |
+
if payload.startswith("data:"): # tolerate a full data URL from the client
|
| 170 |
+
payload = payload.partition(",")[2]
|
| 171 |
+
try:
|
| 172 |
+
raw = base64.b64decode(payload, validate=True)
|
| 173 |
+
except Exception as exc:
|
| 174 |
+
raise HTTPException(status_code=400, detail=BAD_IMAGE_REASON) from exc
|
| 175 |
+
try:
|
| 176 |
+
from PIL import Image
|
| 177 |
+
|
| 178 |
+
image = Image.open(io.BytesIO(raw))
|
| 179 |
+
# Dimension gate BEFORE any pixel decode (security review #1): open()
|
| 180 |
+
# only parses the header; load() is what allocates w*h*3 bytes. Refuse
|
| 181 |
+
# on DECLARED size so the bomb never costs more than a header parse.
|
| 182 |
+
width, height = image.size
|
| 183 |
+
if width * height > MAX_PIXELS:
|
| 184 |
+
raise HTTPException(status_code=413, detail=VAST_IMAGE_REASON)
|
| 185 |
+
image.load()
|
| 186 |
+
image = image.convert("RGB")
|
| 187 |
+
except HTTPException:
|
| 188 |
+
raise
|
| 189 |
+
except Exception as exc:
|
| 190 |
+
# Includes PIL's DecompressionBombError (the 2x MAX_PIXELS hard stop
|
| 191 |
+
# raised inside Image.open) — same poetic 400 as any undecodable image.
|
| 192 |
+
raise HTTPException(status_code=400, detail=BAD_IMAGE_REASON) from exc
|
| 193 |
+
if max(image.size) > MAX_IMAGE_SIDE:
|
| 194 |
+
image.thumbnail((MAX_IMAGE_SIDE, MAX_IMAGE_SIDE))
|
| 195 |
+
return image
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _encode_jpeg(image) -> bytes:
|
| 199 |
+
buf = io.BytesIO()
|
| 200 |
+
image.save(buf, format="JPEG", quality=JPEG_QUALITY)
|
| 201 |
+
return buf.getvalue()
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# --------------------------------------------------------------------------- the app
|
| 205 |
+
def create_app(
|
| 206 |
+
*,
|
| 207 |
+
pipeline: Optional[PipelineLike] = None,
|
| 208 |
+
persistence: Optional[PersistenceService] = None,
|
| 209 |
+
pending: Optional[PendingStore] = None,
|
| 210 |
+
hub: Optional[SSEHub] = None,
|
| 211 |
+
rate_limiter: Optional[RateLimiter] = None,
|
| 212 |
+
ip_rate_limiter: Optional[RateLimiter] = None,
|
| 213 |
+
identity: Optional[ClientIdentity] = None,
|
| 214 |
+
menagerie_dir: Optional[Path | str] = None,
|
| 215 |
+
backend: Optional[str] = None,
|
| 216 |
+
mount_web: bool = True,
|
| 217 |
+
) -> FastAPI:
|
| 218 |
+
"""Build the ASGI app. With no arguments this wires the real (env-selected)
|
| 219 |
+
backend; tests inject fakes for everything mind/cv-shaped."""
|
| 220 |
+
backend_name = backend or _backend_name()
|
| 221 |
+
pipeline = pipeline if pipeline is not None else make_pipeline(backend_name)
|
| 222 |
+
persistence = (
|
| 223 |
+
persistence
|
| 224 |
+
if persistence is not None
|
| 225 |
+
else PersistenceService(
|
| 226 |
+
menagerie_dir if menagerie_dir is not None else MENAGERIE_DIR,
|
| 227 |
+
seeds_dir=SEEDS_RECORDS_DIR,
|
| 228 |
+
)
|
| 229 |
+
)
|
| 230 |
+
pending = pending if pending is not None else PendingStore()
|
| 231 |
+
hub = hub if hub is not None else SSEHub()
|
| 232 |
+
rate_limiter = rate_limiter or RateLimiter()
|
| 233 |
+
ip_rate_limiter = ip_rate_limiter or RateLimiter(limit=DEFAULT_IP_LIMIT)
|
| 234 |
+
identity = identity or ClientIdentity()
|
| 235 |
+
|
| 236 |
+
@asynccontextmanager
|
| 237 |
+
async def lifespan(app: FastAPI):
|
| 238 |
+
yield
|
| 239 |
+
await persistence.drain()
|
| 240 |
+
|
| 241 |
+
# Gradio Server mode (the "her" idiom, proven on godseed): gr.Server IS a
|
| 242 |
+
# FastAPI app with gradio's API engine attached — the sdk:gradio runtime
|
| 243 |
+
# serves it natively, and @app.api endpoints are gradio endpoints the
|
| 244 |
+
# browser calls via @gradio/client, which forwards the HF auth headers
|
| 245 |
+
# ZeroGPU quota needs.
|
| 246 |
+
try:
|
| 247 |
+
import gradio as gr
|
| 248 |
+
|
| 249 |
+
app: FastAPI = gr.Server(title="PAREIDOLIA")
|
| 250 |
+
app.router.lifespan_context = lifespan
|
| 251 |
+
except Exception: # pragma: no cover — gradio always present in prod
|
| 252 |
+
app = FastAPI(
|
| 253 |
+
title="PAREIDOLIA", docs_url=None, redoc_url=None, lifespan=lifespan
|
| 254 |
+
)
|
| 255 |
+
app.state.persistence = persistence
|
| 256 |
+
app.state.pending = pending
|
| 257 |
+
app.state.hub = hub
|
| 258 |
+
|
| 259 |
+
async def ensure_ready() -> None:
|
| 260 |
+
"""Boot persistence lazily on the serving loop (godseed's ensure idiom:
|
| 261 |
+
launch() does not honor an injected lifespan, so the first request —
|
| 262 |
+
or the warm-boot thread in _serve — does the waking). Idempotent and
|
| 263 |
+
thread-safe; the dataset restore runs off the event loop."""
|
| 264 |
+
if not persistence.booted:
|
| 265 |
+
await asyncio.to_thread(persistence.boot)
|
| 266 |
+
|
| 267 |
+
# ------------------------------------------------------------------ the séance
|
| 268 |
+
async def _awaken(image_b64: str) -> dict[str, Any]:
|
| 269 |
+
"""§2 pipeline: decode -> medium -> gate -> snap -> TTS -> pending token.
|
| 270 |
+
Refusals are HTTP 200 + {refused: true}; only transport sins get codes."""
|
| 271 |
+
await ensure_ready()
|
| 272 |
+
image = _decode_image(image_b64)
|
| 273 |
+
try:
|
| 274 |
+
outcome = await asyncio.to_thread(pipeline.run, image)
|
| 275 |
+
except Exception as exc:
|
| 276 |
+
log.exception("séance pipeline failed")
|
| 277 |
+
raise HTTPException(
|
| 278 |
+
status_code=500, detail=SEANCE_FAILED_REASON
|
| 279 |
+
) from exc
|
| 280 |
+
if outcome.refused or outcome.result is None or outcome.grudge_wav is None:
|
| 281 |
+
return {"refused": True, "reason": outcome.reason or SEANCE_FAILED_REASON}
|
| 282 |
+
result = outcome.result
|
| 283 |
+
jpeg = _encode_jpeg(image)
|
| 284 |
+
record: dict[str, Any] = {
|
| 285 |
+
"object": result.object,
|
| 286 |
+
"material": result.material,
|
| 287 |
+
"condition": result.condition,
|
| 288 |
+
"setting": result.setting,
|
| 289 |
+
"persona": result.persona.model_dump(),
|
| 290 |
+
"features": [f.model_dump() for f in outcome.features],
|
| 291 |
+
"lines": result.lines.model_dump(),
|
| 292 |
+
"critique": result.critique,
|
| 293 |
+
"image_sha256": hashlib.sha256(jpeg).hexdigest(),
|
| 294 |
+
"backend": backend_name,
|
| 295 |
+
}
|
| 296 |
+
token = pending.put(
|
| 297 |
+
record=record,
|
| 298 |
+
image_jpeg=jpeg,
|
| 299 |
+
grudge_wav=outcome.grudge_wav,
|
| 300 |
+
gate=result.gate.model_dump(),
|
| 301 |
+
)
|
| 302 |
+
return {
|
| 303 |
+
"refused": False,
|
| 304 |
+
"record_token": token,
|
| 305 |
+
"record": {
|
| 306 |
+
**record,
|
| 307 |
+
"grudge_audio_b64": base64.b64encode(outcome.grudge_wav).decode(
|
| 308 |
+
"ascii"
|
| 309 |
+
),
|
| 310 |
+
},
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
# Gradio endpoint: the visitor's browser calls this via @gradio/client so
|
| 314 |
+
# the visitor's own ZeroGPU quota pays for the awakening (§0).
|
| 315 |
+
if hasattr(app, "api"):
|
| 316 |
+
|
| 317 |
+
@app.api(name="awaken")
|
| 318 |
+
async def awaken(image_b64: str = "") -> dict:
|
| 319 |
+
try:
|
| 320 |
+
return await _awaken(image_b64)
|
| 321 |
+
except HTTPException as exc:
|
| 322 |
+
# gradio endpoints cannot speak HTTP status codes; same poetry,
|
| 323 |
+
# flat shape, status carried in-band for the client.
|
| 324 |
+
return {
|
| 325 |
+
"refused": True,
|
| 326 |
+
"reason": str(exc.detail),
|
| 327 |
+
"status": exc.status_code,
|
| 328 |
+
}
|
| 329 |
+
except Exception:
|
| 330 |
+
log.exception("awaken failed")
|
| 331 |
+
return {"refused": True, "reason": SEANCE_FAILED_REASON, "status": 500}
|
| 332 |
+
|
| 333 |
+
# REST twin for the mock/dev backend (tests, curl, local frontend work).
|
| 334 |
+
# Never registered on zerogpu: GPU work must stay inside the visitor's
|
| 335 |
+
# browser-authenticated gradio request (§0), so prod matches §6 exactly.
|
| 336 |
+
if backend_name != "zerogpu":
|
| 337 |
+
|
| 338 |
+
@app.post("/api/awaken")
|
| 339 |
+
async def awaken_rest(payload: AwakenRequest) -> JSONResponse:
|
| 340 |
+
return JSONResponse(await _awaken(payload.image_b64))
|
| 341 |
+
|
| 342 |
+
# -------------------------------------------------------------- POST /api/menagerie
|
| 343 |
+
@app.post("/api/menagerie")
|
| 344 |
+
async def publish(payload: PublishRequest, request: Request) -> JSONResponse:
|
| 345 |
+
await ensure_ready()
|
| 346 |
+
cid, new_cookie = identity.resolve(request)
|
| 347 |
+
|
| 348 |
+
# Order is peek-validate -> rate-limit -> claim (security review #2c):
|
| 349 |
+
# the token must prove itself BEFORE the limiters record a hit, or an
|
| 350 |
+
# unauthenticated junk-token flood grows limiter state for nothing.
|
| 351 |
+
# Peek (not claim) first so a rate-limited publish never spends the
|
| 352 |
+
# one-time token; peek->claim cannot race — requests share one event
|
| 353 |
+
# loop and nothing awaits between the two calls.
|
| 354 |
+
if pending.peek(payload.record_token) is None:
|
| 355 |
+
raise HTTPException(status_code=404, detail=TOKEN_UNKNOWN_REASON)
|
| 356 |
+
|
| 357 |
+
ip_ok, ip_retry = ip_rate_limiter.hit(f"ip:{client_ip(request)}")
|
| 358 |
+
allowed, retry_after = rate_limiter.hit(cid) if ip_ok else (False, ip_retry)
|
| 359 |
+
if not allowed:
|
| 360 |
+
raise HTTPException(
|
| 361 |
+
status_code=429,
|
| 362 |
+
detail=RATE_LIMIT_REASON,
|
| 363 |
+
headers={"Retry-After": str(int(retry_after) + 1)},
|
| 364 |
+
)
|
| 365 |
+
|
| 366 |
+
held = pending.claim(payload.record_token)
|
| 367 |
+
if held is None: # pragma: no cover — peek above guarantees presence
|
| 368 |
+
raise HTTPException(status_code=404, detail=TOKEN_UNKNOWN_REASON)
|
| 369 |
+
|
| 370 |
+
# Gate re-check, server-side (§0: layered and non-negotiable). awaken
|
| 371 |
+
# never parks a gated record, so this is pure defense-in-depth.
|
| 372 |
+
if not GateFlags.model_validate(held.gate).passes():
|
| 373 |
+
raise HTTPException(status_code=403, detail=GATE_RECHECK_REASON)
|
| 374 |
+
|
| 375 |
+
record = await persistence.publish(
|
| 376 |
+
held.record, held.image_jpeg, held.grudge_wav
|
| 377 |
+
)
|
| 378 |
+
hub.publish({"type": "awakened", "record": record})
|
| 379 |
+
# The cookie is set on the returned Response directly: FastAPI drops
|
| 380 |
+
# `response`-param mutations when a handler returns its own Response
|
| 381 |
+
# (and on raise paths — cookieless retries fall to the IP limiter).
|
| 382 |
+
out = JSONResponse(record)
|
| 383 |
+
if new_cookie:
|
| 384 |
+
_set_identity_cookie(out, request, new_cookie)
|
| 385 |
+
return out
|
| 386 |
+
|
| 387 |
+
# --------------------------------------------------------------- GET /api/menagerie
|
| 388 |
+
@app.get("/api/menagerie")
|
| 389 |
+
async def menagerie_index(
|
| 390 |
+
offset: int = Query(default=0, ge=0),
|
| 391 |
+
limit: int = Query(default=60, ge=1, le=200),
|
| 392 |
+
) -> JSONResponse:
|
| 393 |
+
await ensure_ready()
|
| 394 |
+
records, total = persistence.page(offset, limit)
|
| 395 |
+
return JSONResponse(
|
| 396 |
+
{
|
| 397 |
+
"records": records,
|
| 398 |
+
"total": total,
|
| 399 |
+
"offset": offset,
|
| 400 |
+
"limit": limit,
|
| 401 |
+
"awake_count": total,
|
| 402 |
+
}
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
# ------------------------------------------------------------------ /api/stream
|
| 406 |
+
@app.get("/api/stream")
|
| 407 |
+
async def stream(
|
| 408 |
+
request: Request,
|
| 409 |
+
limit: Optional[int] = Query(
|
| 410 |
+
default=None,
|
| 411 |
+
ge=1,
|
| 412 |
+
le=10_000,
|
| 413 |
+
description="debug/test aid: close the stream after N events",
|
| 414 |
+
),
|
| 415 |
+
) -> StreamingResponse:
|
| 416 |
+
await ensure_ready()
|
| 417 |
+
# Capacity gate (security review #3): every subscriber holds a bounded
|
| 418 |
+
# queue + a generator. The slot is reserved HERE — before the response
|
| 419 |
+
# body starts — so over-capacity is an honest 503, and the generator's
|
| 420 |
+
# finally-unsubscribe releases the slot on disconnect as before.
|
| 421 |
+
queue = hub.subscribe(key=f"ip:{client_ip(request)}")
|
| 422 |
+
if queue is None:
|
| 423 |
+
raise HTTPException(
|
| 424 |
+
status_code=503,
|
| 425 |
+
detail=WALL_CROWDED_REASON,
|
| 426 |
+
headers={"Retry-After": "15"},
|
| 427 |
+
)
|
| 428 |
+
initial = [{"type": "hello", "awake_count": persistence.count()}]
|
| 429 |
+
return StreamingResponse(
|
| 430 |
+
hub.event_stream(initial_events=initial, limit=limit, queue=queue),
|
| 431 |
+
media_type="text/event-stream",
|
| 432 |
+
headers={
|
| 433 |
+
"Cache-Control": "no-cache",
|
| 434 |
+
"X-Accel-Buffering": "no", # required on Spaces or the proxy buffers
|
| 435 |
+
"Connection": "keep-alive",
|
| 436 |
+
},
|
| 437 |
+
)
|
| 438 |
+
|
| 439 |
+
# --------------------------------------------------------------- GET /media/{name}
|
| 440 |
+
@app.get("/media/{name}", include_in_schema=False)
|
| 441 |
+
async def media(name: str) -> FileResponse:
|
| 442 |
+
"""Persisted menagerie media. Traversal-safe by construction: the name
|
| 443 |
+
must match the mint alphabet, and the id resolves through the
|
| 444 |
+
server-built id->path map only — no client string ever becomes a path."""
|
| 445 |
+
await ensure_ready()
|
| 446 |
+
match = MEDIA_NAME_RE.fullmatch(name)
|
| 447 |
+
if match is None:
|
| 448 |
+
raise HTTPException(status_code=404, detail=NOT_FOUND_REASON)
|
| 449 |
+
path = persistence.media_path(match.group(1), match.group(2))
|
| 450 |
+
if path is None or not path.is_file():
|
| 451 |
+
raise HTTPException(status_code=404, detail=NOT_FOUND_REASON)
|
| 452 |
+
return FileResponse(
|
| 453 |
+
path,
|
| 454 |
+
media_type=MEDIA_TYPES[match.group(2)],
|
| 455 |
+
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
| 456 |
+
)
|
| 457 |
+
|
| 458 |
+
# ------------------------------------------------------------------------ static
|
| 459 |
+
def _mount_static() -> None:
|
| 460 |
+
"""Root static mount. A "/" catch-all registered BEFORE gr.Server.launch()
|
| 461 |
+
shadows the /gradio_api routes gradio adds at launch time (godseed,
|
| 462 |
+
verified June 12: the launch self-check 404s and the app dies). So the
|
| 463 |
+
mount is deferred: _serve() calls this AFTER launch; the plain-FastAPI
|
| 464 |
+
path mounts immediately below."""
|
| 465 |
+
if WEB_DIR.is_dir():
|
| 466 |
+
# gradio's launch registers its own SPA index at "/" (GET+HEAD) —
|
| 467 |
+
# evict exactly those so the wall owns the root; every other gradio
|
| 468 |
+
# route (/config, /gradio_api/*, assets) must survive for
|
| 469 |
+
# @gradio/client connectivity.
|
| 470 |
+
app.router.routes[:] = [
|
| 471 |
+
r for r in app.router.routes if getattr(r, "path", None) != "/"
|
| 472 |
+
]
|
| 473 |
+
app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web")
|
| 474 |
+
else:
|
| 475 |
+
log.warning("web/ not found at %s; serving API only", WEB_DIR)
|
| 476 |
+
|
| 477 |
+
@app.get("/", include_in_schema=False)
|
| 478 |
+
async def root_placeholder() -> JSONResponse:
|
| 479 |
+
return JSONResponse({"ok": True, "hint": "frontend not built"})
|
| 480 |
+
|
| 481 |
+
app.state.mount_static = _mount_static
|
| 482 |
+
if mount_web and not hasattr(app, "launch"):
|
| 483 |
+
_mount_static()
|
| 484 |
+
|
| 485 |
+
return app
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
# --------------------------------------------------------------------------- entrypoint
|
| 489 |
+
def _serve(application: FastAPI) -> None:
|
| 490 |
+
port = int(os.environ.get("PORT", os.environ.get("GRADIO_SERVER_PORT", 7860)))
|
| 491 |
+
log.info("PAREIDOLIA listening on http://0.0.0.0:%d", port)
|
| 492 |
+
if hasattr(application, "launch"):
|
| 493 |
+
# Gradio Server mode — launch non-blocking so the web root can mount
|
| 494 |
+
# AFTER gradio registers its /gradio_api routes (order = precedence).
|
| 495 |
+
application.launch(
|
| 496 |
+
server_name="0.0.0.0",
|
| 497 |
+
server_port=port,
|
| 498 |
+
show_error=False,
|
| 499 |
+
prevent_thread_lock=True,
|
| 500 |
+
)
|
| 501 |
+
mount_static = getattr(application.state, "mount_static", None)
|
| 502 |
+
if mount_static is not None:
|
| 503 |
+
mount_static()
|
| 504 |
+
# Warm-boot the menagerie off the serving thread so visitor #1 sees a
|
| 505 |
+
# full wall without paying the dataset-restore wait themselves.
|
| 506 |
+
persistence = getattr(application.state, "persistence", None)
|
| 507 |
+
if persistence is not None and not persistence.booted:
|
| 508 |
+
threading.Thread(target=persistence.boot, daemon=True).start()
|
| 509 |
+
import time as _time
|
| 510 |
+
|
| 511 |
+
while True: # keep the process alive; the server runs in gradio's thread
|
| 512 |
+
_time.sleep(3600)
|
| 513 |
+
else: # pragma: no cover — gradio-less dev fallback
|
| 514 |
+
import uvicorn
|
| 515 |
+
|
| 516 |
+
uvicorn.run(application, host="0.0.0.0", port=port)
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
# HF Spaces (sdk:gradio) executes this file; locally it's `python app.py`.
|
| 520 |
+
# Test imports leave both conditions false and get no side effects.
|
| 521 |
+
if __name__ == "__main__" or os.environ.get("SPACE_ID"):
|
| 522 |
+
logging.basicConfig(
|
| 523 |
+
level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s"
|
| 524 |
+
)
|
| 525 |
+
_serve(create_app())
|
cv/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PAREIDOLIA deterministic CV — pure numpy + opencv-headless, no ML, no RNG.
|
| 2 |
+
|
| 3 |
+
The her-pattern's fact-keeper: the VLM proposes coarse feature points, this
|
| 4 |
+
package snaps them to real pixels (`snap.snap_features`) and renders the
|
| 5 |
+
before/after evidence (`snap.overlay_debug`).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from .snap import MIN_SCORE, SNAP_RADIUS_FRAC, overlay_debug, snap_features
|
| 9 |
+
|
| 10 |
+
__all__ = ["snap_features", "overlay_debug", "MIN_SCORE", "SNAP_RADIUS_FRAC"]
|
cv/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (630 Bytes). View file
|
|
|
cv/__pycache__/snap.cpython-312.pyc
ADDED
|
Binary file (30.1 kB). View file
|
|
|
cv/snap.py
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PAREIDOLIA deterministic feature snapping — the "code owns facts" half.
|
| 2 |
+
|
| 3 |
+
The VLM (MiniCPM-V) owns taste: it names the bolt that wants to be an eye and
|
| 4 |
+
hands over coarse normalized coordinates. This module owns facts: it snaps
|
| 5 |
+
each coarse point to the strongest nearby visual anchor using plain OpenCV on
|
| 6 |
+
CPU — Hough circles (eyes love circles: bolts, dials, knobs), good-features
|
| 7 |
+
corners, dark-blob centroids, and horizontally elongated edge segments for
|
| 8 |
+
mouths. No ML, no RNG: the same image + features always produce byte-identical
|
| 9 |
+
output, so a persisted menagerie record replays forever.
|
| 10 |
+
|
| 11 |
+
Contract (ARCHITECTURE.md §0 "Deterministic CV snapping", §2 step 3):
|
| 12 |
+
|
| 13 |
+
- search window = radius 12% of the image diagonal around each VLM point;
|
| 14 |
+
- candidates are scored by (anchor strength × proximity to the VLM point),
|
| 15 |
+
weighted by how well the anchor kind suits the feature role;
|
| 16 |
+
- eyes prefer circle anchors, and the eye PAIR must stay coherent: if
|
| 17 |
+
independent snapping skews the eye-line by >12° or changes the separation
|
| 18 |
+
by >35% vs the VLM pair, the pair falls back to a rigid translation by the
|
| 19 |
+
stronger anchor's delta (midpoint + angle of the VLM pair are preserved,
|
| 20 |
+
the weaker eye is marked ``anchor_kind="pair"``);
|
| 21 |
+
- the mouth prefers a horizontally elongated contour/edge segment and is never
|
| 22 |
+
snapped above the (snapped) eye midpoint;
|
| 23 |
+
- if no anchor beats ``MIN_SCORE`` the VLM point is kept verbatim
|
| 24 |
+
(``anchor_kind="vlm"``) — the mist animation forgives ±15%;
|
| 25 |
+
- the mouth floor is enforced on the FINAL points regardless of anchor_kind:
|
| 26 |
+
a kept-VLM mouth that sits at or above the eye midpoint is pushed just
|
| 27 |
+
below it (``anchor_kind="vlm_corrected"``) so an inverted face can never
|
| 28 |
+
ship.
|
| 29 |
+
|
| 30 |
+
Coordinate conventions: ``cx``/``cy`` are normalized to ``[0, 1]`` over
|
| 31 |
+
``width-1`` / ``height-1``; ``size`` is the feature's coarse diameter as a
|
| 32 |
+
fraction of the image diagonal (the ARCHITECTURE.md §3 schema); ``snap_delta``
|
| 33 |
+
is the distance moved, as a fraction of the image diagonal.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
from __future__ import annotations
|
| 37 |
+
|
| 38 |
+
import math
|
| 39 |
+
from dataclasses import dataclass
|
| 40 |
+
from typing import Any, Optional
|
| 41 |
+
|
| 42 |
+
import cv2
|
| 43 |
+
import numpy as np
|
| 44 |
+
|
| 45 |
+
__all__ = ["snap_features", "overlay_debug", "MIN_SCORE", "SNAP_RADIUS_FRAC"]
|
| 46 |
+
|
| 47 |
+
# ----------------------------------------------------------------- constants
|
| 48 |
+
|
| 49 |
+
SNAP_RADIUS_FRAC = 0.12 # search radius as a fraction of the image diagonal
|
| 50 |
+
MIN_SCORE = 0.12 # below this, the VLM point wins (anchor_kind="vlm")
|
| 51 |
+
|
| 52 |
+
PAIR_MAX_SKEW_DEG = 12.0 # eye-line rotation tolerance vs the VLM pair
|
| 53 |
+
PAIR_MAX_SEP_CHANGE = 0.35 # eye separation ratio-change tolerance
|
| 54 |
+
|
| 55 |
+
# Corner response normalizer: cv2.cornerMinEigenVal output is pre-scaled by
|
| 56 |
+
# OpenCV — a crisp synthetic step corner peaks near ~0.06 (blockSize=5, uint8
|
| 57 |
+
# input, after our 5x5 blur) while blurred sensor noise stays ≤ ~0.016.
|
| 58 |
+
# Strength saturates at 1.0 so 0.08 keeps noise corners well under MIN_SCORE.
|
| 59 |
+
_CORNER_NORM = 0.08
|
| 60 |
+
|
| 61 |
+
# How well each anchor kind suits each feature role. Eyes love circles
|
| 62 |
+
# (bolts, dials, knobs); mouths love horizontal edge segments; corners are
|
| 63 |
+
# the weakest evidence everywhere (they fire on texture).
|
| 64 |
+
_KIND_WEIGHTS: dict[str, dict[str, float]] = {
|
| 65 |
+
"eye": {"circle": 1.00, "blob": 0.70, "corner": 0.45, "edge": 0.30},
|
| 66 |
+
"mouth": {"edge": 1.00, "blob": 0.75, "circle": 0.50, "corner": 0.35},
|
| 67 |
+
"other": {"circle": 0.90, "blob": 0.80, "edge": 0.60, "corner": 0.50},
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
_MIN_ROI_SIDE = 12 # below this the window is too small to detect anything
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@dataclass(frozen=True)
|
| 74 |
+
class _Anchor:
|
| 75 |
+
"""One candidate anchor in full-image pixel coordinates."""
|
| 76 |
+
|
| 77 |
+
x: float
|
| 78 |
+
y: float
|
| 79 |
+
strength: float # detector-specific, normalized to [0, 1]
|
| 80 |
+
kind: str # circle | corner | blob | edge
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ------------------------------------------------------------------ helpers
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _as_gray(image: np.ndarray) -> np.ndarray:
|
| 87 |
+
"""Accept BGR (contract) or already-gray uint8; return single-channel."""
|
| 88 |
+
if image is None or not isinstance(image, np.ndarray) or image.ndim not in (2, 3):
|
| 89 |
+
raise ValueError("snap_features expects an HxW or HxWx3 uint8 ndarray")
|
| 90 |
+
if image.ndim == 3:
|
| 91 |
+
return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
| 92 |
+
return image
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _clamp01(v: float) -> float:
|
| 96 |
+
return min(1.0, max(0.0, float(v)))
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _role_class(role: Any) -> str:
|
| 100 |
+
role = str(role or "")
|
| 101 |
+
if role.startswith("eye"):
|
| 102 |
+
return "eye"
|
| 103 |
+
if role == "mouth":
|
| 104 |
+
return "mouth"
|
| 105 |
+
return "other"
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _to_px(f: dict, w: int, h: int) -> tuple[float, float]:
|
| 109 |
+
"""Normalized [0,1] feature coords → pixel coords (clamped on-image)."""
|
| 110 |
+
return (
|
| 111 |
+
_clamp01(f.get("cx", 0.5)) * (w - 1),
|
| 112 |
+
_clamp01(f.get("cy", 0.5)) * (h - 1),
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _proximity(d: float, radius: float) -> float:
|
| 117 |
+
"""Gaussian falloff: 1.0 at the VLM point, ~0.14 at the window edge."""
|
| 118 |
+
return math.exp(-2.0 * (d / radius) ** 2)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# ---------------------------------------------------------------- detectors
|
| 122 |
+
# All detectors operate on a pre-blurred grayscale ROI and return anchors in
|
| 123 |
+
# ROI-local coordinates. Every parameter is fixed — nothing is sampled.
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _circle_candidates(roi: np.ndarray, want_r: float) -> list[_Anchor]:
|
| 127 |
+
"""HoughCircles, strength = disk-vs-annulus contrast × radius match."""
|
| 128 |
+
if want_r > 0:
|
| 129 |
+
min_r = max(2, int(round(want_r * 0.45)))
|
| 130 |
+
max_r = max(min_r + 2, int(round(want_r * 1.9)))
|
| 131 |
+
else:
|
| 132 |
+
min_r, max_r = 3, max(6, int(min(roi.shape) * 0.45))
|
| 133 |
+
circles = cv2.HoughCircles(
|
| 134 |
+
roi,
|
| 135 |
+
cv2.HOUGH_GRADIENT,
|
| 136 |
+
dp=1.2,
|
| 137 |
+
minDist=max(4.0, want_r if want_r > 0 else 8.0),
|
| 138 |
+
param1=120,
|
| 139 |
+
param2=18,
|
| 140 |
+
minRadius=min_r,
|
| 141 |
+
maxRadius=max_r,
|
| 142 |
+
)
|
| 143 |
+
if circles is None:
|
| 144 |
+
return []
|
| 145 |
+
out: list[_Anchor] = []
|
| 146 |
+
for cx, cy, r in circles[0][:6]:
|
| 147 |
+
contrast = _disk_contrast(roi, float(cx), float(cy), float(r))
|
| 148 |
+
if want_r > 0:
|
| 149 |
+
r_match = math.exp(-(((float(r) - want_r) / max(want_r, 1.0)) ** 2))
|
| 150 |
+
else:
|
| 151 |
+
r_match = 1.0
|
| 152 |
+
strength = min(1.0, contrast * 1.6) * (0.4 + 0.6 * r_match)
|
| 153 |
+
out.append(_Anchor(float(cx), float(cy), strength, "circle"))
|
| 154 |
+
return out
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _disk_contrast(roi: np.ndarray, cx: float, cy: float, r: float) -> float:
|
| 158 |
+
"""|mean(disk interior) − mean(surrounding annulus)| / 255 — a bolt head
|
| 159 |
+
or dial face separates from its plate; blurred noise does not."""
|
| 160 |
+
h, w = roi.shape
|
| 161 |
+
yy, xx = np.ogrid[:h, :w]
|
| 162 |
+
d2 = (xx - cx) ** 2 + (yy - cy) ** 2
|
| 163 |
+
inner = d2 <= (0.75 * r) ** 2
|
| 164 |
+
ring = (d2 > (1.15 * r) ** 2) & (d2 <= (1.7 * r) ** 2)
|
| 165 |
+
if int(inner.sum()) < 4 or int(ring.sum()) < 4:
|
| 166 |
+
return 0.0
|
| 167 |
+
return abs(float(roi[inner].mean()) - float(roi[ring].mean())) / 255.0
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _corner_candidates(roi: np.ndarray, want_r: float) -> list[_Anchor]:
|
| 171 |
+
"""goodFeaturesToTrack, strength from the min-eigenvalue response map."""
|
| 172 |
+
corners = cv2.goodFeaturesToTrack(
|
| 173 |
+
roi,
|
| 174 |
+
maxCorners=10,
|
| 175 |
+
qualityLevel=0.08,
|
| 176 |
+
minDistance=max(4, int(want_r) if want_r > 0 else 6),
|
| 177 |
+
blockSize=5,
|
| 178 |
+
)
|
| 179 |
+
if corners is None:
|
| 180 |
+
return []
|
| 181 |
+
response = cv2.cornerMinEigenVal(roi, blockSize=5)
|
| 182 |
+
out: list[_Anchor] = []
|
| 183 |
+
for pt in corners.reshape(-1, 2):
|
| 184 |
+
x, y = float(pt[0]), float(pt[1])
|
| 185 |
+
iy = min(response.shape[0] - 1, max(0, int(round(y))))
|
| 186 |
+
ix = min(response.shape[1] - 1, max(0, int(round(x))))
|
| 187 |
+
strength = min(1.0, float(response[iy, ix]) / _CORNER_NORM)
|
| 188 |
+
out.append(_Anchor(x, y, strength, "corner"))
|
| 189 |
+
return out
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _blob_candidates(roi: np.ndarray, want_r: float) -> list[_Anchor]:
|
| 193 |
+
"""Dark-blob centroids: adaptive threshold → contour moments, kept only
|
| 194 |
+
when sized near the feature and actually darker than their surroundings."""
|
| 195 |
+
side = min(roi.shape)
|
| 196 |
+
block = int(round(want_r * 4)) | 1 if want_r > 0 else 21
|
| 197 |
+
block = max(11, min(block, 51, (side - 1) | 1 if side > 2 else 3))
|
| 198 |
+
if block < 3:
|
| 199 |
+
return []
|
| 200 |
+
binary = cv2.adaptiveThreshold(
|
| 201 |
+
roi, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, block, 5
|
| 202 |
+
)
|
| 203 |
+
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 204 |
+
want_area = math.pi * want_r * want_r if want_r > 0 else 0.0
|
| 205 |
+
scored: list[tuple[float, Any]] = []
|
| 206 |
+
for cnt in contours:
|
| 207 |
+
area = float(cv2.contourArea(cnt))
|
| 208 |
+
if area < 9.0:
|
| 209 |
+
continue
|
| 210 |
+
if want_area > 0 and not (0.15 * want_area <= area <= 6.0 * want_area):
|
| 211 |
+
continue
|
| 212 |
+
scored.append((abs(area - want_area), cnt))
|
| 213 |
+
scored.sort(key=lambda t: t[0])
|
| 214 |
+
out: list[_Anchor] = []
|
| 215 |
+
for _, cnt in scored[:8]:
|
| 216 |
+
m = cv2.moments(cnt)
|
| 217 |
+
if m["m00"] <= 0:
|
| 218 |
+
continue
|
| 219 |
+
bx, by = m["m10"] / m["m00"], m["m01"] / m["m00"]
|
| 220 |
+
mask = np.zeros(roi.shape, np.uint8)
|
| 221 |
+
cv2.drawContours(mask, [cnt], -1, 255, -1)
|
| 222 |
+
k = max(3, (int(want_r * 0.6) | 1) if want_r > 0 else 5)
|
| 223 |
+
ring = cv2.dilate(mask, np.ones((k, k), np.uint8)) & ~mask
|
| 224 |
+
if int((mask > 0).sum()) < 4 or int((ring > 0).sum()) < 4:
|
| 225 |
+
continue
|
| 226 |
+
darkness = (float(roi[ring > 0].mean()) - float(roi[mask > 0].mean())) / 255.0
|
| 227 |
+
if darkness <= 0:
|
| 228 |
+
continue # spec: DARK blobs only
|
| 229 |
+
d_eq = 2.0 * math.sqrt(float(cv2.contourArea(cnt)) / math.pi)
|
| 230 |
+
if want_r > 0:
|
| 231 |
+
size_match = math.exp(-0.5 * (((d_eq - 2 * want_r) / max(2 * want_r, 1.0)) ** 2))
|
| 232 |
+
else:
|
| 233 |
+
size_match = 1.0
|
| 234 |
+
out.append(_Anchor(bx, by, min(1.0, darkness * 1.4) * size_match, "blob"))
|
| 235 |
+
return out
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _edge_candidates(roi: np.ndarray, want_d: float) -> list[_Anchor]:
|
| 239 |
+
"""Horizontally elongated edge segments (mouths: slots, grilles, seams).
|
| 240 |
+
|
| 241 |
+
Canny contours whose bounding box is clearly wider than tall. Strength =
|
| 242 |
+
elongation × (soft) width match × vertical-gradient support × straightness.
|
| 243 |
+
The last two terms separate a real seam (strong |dI/dy| along a near-
|
| 244 |
+
straight run) from the wiggly low-contrast strings Canny traces on noise.
|
| 245 |
+
"""
|
| 246 |
+
edges = cv2.Canny(roi, 60, 150)
|
| 247 |
+
contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE)
|
| 248 |
+
if not contours:
|
| 249 |
+
return []
|
| 250 |
+
sobel_y = np.abs(cv2.Sobel(roi, cv2.CV_64F, 0, 1, ksize=3))
|
| 251 |
+
out: list[_Anchor] = []
|
| 252 |
+
for cnt in contours:
|
| 253 |
+
x, y, w, h = cv2.boundingRect(cnt)
|
| 254 |
+
if w < max(8, 0.3 * want_d) or w < 1.6 * max(h, 1):
|
| 255 |
+
continue
|
| 256 |
+
aspect = w / max(h, 1)
|
| 257 |
+
elong = min(1.0, (aspect - 1.0) / 3.0)
|
| 258 |
+
pts = cnt.reshape(-1, 2)
|
| 259 |
+
ex, ey = float(pts[:, 0].mean()), float(pts[:, 1].mean())
|
| 260 |
+
# a horizontal seam means strong vertical gradient along the contour
|
| 261 |
+
grad_support = min(1.0, float(sobel_y[pts[:, 1], pts[:, 0]].mean()) / 450.0)
|
| 262 |
+
# straight run ≈ arc 2w (traced out and back); wiggly noise is longer
|
| 263 |
+
straightness = min(1.0, 2.2 * w / max(float(cv2.arcLength(cnt, False)), 1.0))
|
| 264 |
+
if want_d > 0:
|
| 265 |
+
size_match = math.exp(-0.5 * (((w - want_d) / max(want_d, 1.0)) ** 2))
|
| 266 |
+
else:
|
| 267 |
+
size_match = 1.0
|
| 268 |
+
strength = elong * (0.5 + 0.5 * size_match) * grad_support * straightness
|
| 269 |
+
out.append(_Anchor(ex, ey, strength, "edge"))
|
| 270 |
+
out.sort(key=lambda a: (-a.strength, a.x, a.y))
|
| 271 |
+
return out[:8]
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
# ------------------------------------------------------------- core snapping
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def _gather_anchors(
|
| 278 |
+
blurred: np.ndarray, px: float, py: float, radius: float, want_d: float
|
| 279 |
+
) -> list[_Anchor]:
|
| 280 |
+
"""Run all detectors on the search window; return full-image anchors."""
|
| 281 |
+
h, w = blurred.shape
|
| 282 |
+
x0 = max(0, int(math.floor(px - radius)))
|
| 283 |
+
y0 = max(0, int(math.floor(py - radius)))
|
| 284 |
+
x1 = min(w, int(math.ceil(px + radius)) + 1)
|
| 285 |
+
y1 = min(h, int(math.ceil(py + radius)) + 1)
|
| 286 |
+
roi = blurred[y0:y1, x0:x1]
|
| 287 |
+
if min(roi.shape) < _MIN_ROI_SIDE:
|
| 288 |
+
return []
|
| 289 |
+
want_r = want_d / 2.0
|
| 290 |
+
local = (
|
| 291 |
+
_circle_candidates(roi, want_r)
|
| 292 |
+
+ _corner_candidates(roi, want_r)
|
| 293 |
+
+ _blob_candidates(roi, want_r)
|
| 294 |
+
+ _edge_candidates(roi, want_d)
|
| 295 |
+
)
|
| 296 |
+
return [_Anchor(a.x + x0, a.y + y0, a.strength, a.kind) for a in local]
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def _best_anchor(
|
| 300 |
+
anchors: list[_Anchor],
|
| 301 |
+
px: float,
|
| 302 |
+
py: float,
|
| 303 |
+
radius: float,
|
| 304 |
+
role_class: str,
|
| 305 |
+
min_y: Optional[float],
|
| 306 |
+
) -> tuple[Optional[_Anchor], float]:
|
| 307 |
+
"""Pick the highest-scoring anchor within the search radius.
|
| 308 |
+
|
| 309 |
+
``min_y`` enforces the mouth rule: candidates at or above the snapped eye
|
| 310 |
+
midpoint are discarded outright (a mouth is never above the eyes).
|
| 311 |
+
Ties break on distance, then kind, then coordinates — fully deterministic.
|
| 312 |
+
"""
|
| 313 |
+
weights = _KIND_WEIGHTS[role_class]
|
| 314 |
+
best: Optional[_Anchor] = None
|
| 315 |
+
best_key: tuple = ()
|
| 316 |
+
best_score = 0.0
|
| 317 |
+
for a in anchors:
|
| 318 |
+
d = math.hypot(a.x - px, a.y - py)
|
| 319 |
+
if d > radius:
|
| 320 |
+
continue
|
| 321 |
+
if min_y is not None and a.y <= min_y:
|
| 322 |
+
continue
|
| 323 |
+
score = weights[a.kind] * a.strength * _proximity(d, radius)
|
| 324 |
+
key = (-score, d, a.kind, a.x, a.y)
|
| 325 |
+
if best is None or key < best_key:
|
| 326 |
+
best, best_key, best_score = a, key, score
|
| 327 |
+
return best, best_score
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def _snap_one(
|
| 331 |
+
blurred: np.ndarray,
|
| 332 |
+
feature: dict,
|
| 333 |
+
radius: float,
|
| 334 |
+
diag: float,
|
| 335 |
+
min_y: Optional[float] = None,
|
| 336 |
+
) -> dict:
|
| 337 |
+
"""Snap a single feature; returns a NEW dict (inputs are never mutated)."""
|
| 338 |
+
h, w = blurred.shape
|
| 339 |
+
out = dict(feature)
|
| 340 |
+
px, py = _to_px(feature, w, h)
|
| 341 |
+
want_d = max(0.0, float(feature.get("size") or 0.0)) * diag
|
| 342 |
+
role_class = _role_class(feature.get("role"))
|
| 343 |
+
anchors = _gather_anchors(blurred, px, py, radius, want_d)
|
| 344 |
+
best, score = _best_anchor(anchors, px, py, radius, role_class, min_y)
|
| 345 |
+
if best is not None and score >= MIN_SCORE:
|
| 346 |
+
out["cx"] = _clamp01(best.x / (w - 1)) if w > 1 else 0.0
|
| 347 |
+
out["cy"] = _clamp01(best.y / (h - 1)) if h > 1 else 0.0
|
| 348 |
+
out["snap_delta"] = math.hypot(best.x - px, best.y - py) / diag
|
| 349 |
+
out["anchor_kind"] = best.kind
|
| 350 |
+
out["anchor_score"] = round(score, 4)
|
| 351 |
+
else:
|
| 352 |
+
out["cx"] = _clamp01(feature.get("cx", 0.5))
|
| 353 |
+
out["cy"] = _clamp01(feature.get("cy", 0.5))
|
| 354 |
+
out["snap_delta"] = 0.0
|
| 355 |
+
out["anchor_kind"] = "vlm"
|
| 356 |
+
out["anchor_score"] = 0.0
|
| 357 |
+
return out
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def _enforce_pair_coherence(
|
| 361 |
+
left: dict,
|
| 362 |
+
right: dict,
|
| 363 |
+
left_vlm: tuple[float, float],
|
| 364 |
+
right_vlm: tuple[float, float],
|
| 365 |
+
w: int,
|
| 366 |
+
h: int,
|
| 367 |
+
diag: float,
|
| 368 |
+
) -> None:
|
| 369 |
+
"""Keep the snapped eye pair geometrically honest (mutates result dicts).
|
| 370 |
+
|
| 371 |
+
If independent snapping rotated the eye-line by more than
|
| 372 |
+
``PAIR_MAX_SKEW_DEG`` or stretched/shrunk the separation by more than
|
| 373 |
+
``PAIR_MAX_SEP_CHANGE`` vs the VLM pair, distrust the weaker anchor:
|
| 374 |
+
rigidly translate the VLM pair by the STRONGER anchor's snap delta. The
|
| 375 |
+
pair's midpoint offset and angle then match the VLM's intent, the stronger
|
| 376 |
+
eye sits exactly on its anchor, and the weaker eye is re-derived
|
| 377 |
+
(``anchor_kind="pair"``, ``anchor_score`` inherited from the evidence
|
| 378 |
+
that placed it).
|
| 379 |
+
"""
|
| 380 |
+
if left["anchor_kind"] == "vlm" and right["anchor_kind"] == "vlm":
|
| 381 |
+
return # nothing snapped, nothing to disagree about
|
| 382 |
+
lvx, lvy = left_vlm
|
| 383 |
+
rvx, rvy = right_vlm
|
| 384 |
+
sep_v = math.hypot(rvx - lvx, rvy - lvy)
|
| 385 |
+
if sep_v < 2.0:
|
| 386 |
+
return # degenerate VLM pair; geometry checks are meaningless
|
| 387 |
+
lsx, lsy = left["cx"] * (w - 1), left["cy"] * (h - 1)
|
| 388 |
+
rsx, rsy = right["cx"] * (w - 1), right["cy"] * (h - 1)
|
| 389 |
+
sep_s = math.hypot(rsx - lsx, rsy - lsy)
|
| 390 |
+
ang_v = math.degrees(math.atan2(rvy - lvy, rvx - lvx))
|
| 391 |
+
ang_s = math.degrees(math.atan2(rsy - lsy, rsx - lsx))
|
| 392 |
+
skew = abs((ang_s - ang_v + 180.0) % 360.0 - 180.0)
|
| 393 |
+
sep_change = abs(sep_s / sep_v - 1.0)
|
| 394 |
+
if skew <= PAIR_MAX_SKEW_DEG and sep_change <= PAIR_MAX_SEP_CHANGE:
|
| 395 |
+
return
|
| 396 |
+
# The eye whose anchor scored higher is trusted ('vlm' scores 0.0).
|
| 397 |
+
if left["anchor_score"] >= right["anchor_score"]:
|
| 398 |
+
strong, weak, strong_vlm, weak_vlm = left, right, (lvx, lvy), (rvx, rvy)
|
| 399 |
+
else:
|
| 400 |
+
strong, weak, strong_vlm, weak_vlm = right, left, (rvx, rvy), (lvx, lvy)
|
| 401 |
+
dx = strong["cx"] * (w - 1) - strong_vlm[0]
|
| 402 |
+
dy = strong["cy"] * (h - 1) - strong_vlm[1]
|
| 403 |
+
weak["cx"] = _clamp01((weak_vlm[0] + dx) / (w - 1)) if w > 1 else 0.0
|
| 404 |
+
weak["cy"] = _clamp01((weak_vlm[1] + dy) / (h - 1)) if h > 1 else 0.0
|
| 405 |
+
weak["snap_delta"] = math.hypot(dx, dy) / diag
|
| 406 |
+
weak["anchor_kind"] = "pair"
|
| 407 |
+
weak["anchor_score"] = strong["anchor_score"]
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
def snap_features(image: np.ndarray, features: list[dict]) -> list[dict]:
|
| 411 |
+
"""Snap VLM feature points to the strongest nearby visual anchors.
|
| 412 |
+
|
| 413 |
+
Args:
|
| 414 |
+
image: HxWx3 BGR uint8 (HxW grayscale also accepted).
|
| 415 |
+
features: dicts with at least ``cx``, ``cy`` (normalized [0,1]),
|
| 416 |
+
``size`` (coarse diameter / image diagonal; 0 or missing disables
|
| 417 |
+
size matching) and ``role`` (``eye_left``/``eye_right``/``mouth``/
|
| 418 |
+
anything else). Extra keys (``name``, …) pass through untouched.
|
| 419 |
+
|
| 420 |
+
Returns:
|
| 421 |
+
New dicts in input order with snapped ``cx``/``cy`` plus
|
| 422 |
+
``snap_delta`` (distance moved / image diagonal), ``anchor_kind``
|
| 423 |
+
(``circle|corner|blob|edge|pair|vlm|vlm_corrected``) and
|
| 424 |
+
``anchor_score``.
|
| 425 |
+
Inputs are never mutated. Fully deterministic — no RNG anywhere.
|
| 426 |
+
"""
|
| 427 |
+
gray = _as_gray(image)
|
| 428 |
+
h, w = gray.shape
|
| 429 |
+
diag = math.hypot(w, h)
|
| 430 |
+
radius = max(8.0, SNAP_RADIUS_FRAC * diag)
|
| 431 |
+
blurred = cv2.GaussianBlur(gray, (5, 5), 1.2)
|
| 432 |
+
|
| 433 |
+
results: list[Optional[dict]] = [None] * len(features)
|
| 434 |
+
vlm_px = [_to_px(f, w, h) for f in features]
|
| 435 |
+
|
| 436 |
+
# Pass 1 — eyes first: their final positions gate the mouth's floor.
|
| 437 |
+
eye_idx = [i for i, f in enumerate(features) if _role_class(f.get("role")) == "eye"]
|
| 438 |
+
for i in eye_idx:
|
| 439 |
+
results[i] = _snap_one(blurred, features[i], radius, diag)
|
| 440 |
+
|
| 441 |
+
left_i = next((i for i in eye_idx if features[i].get("role") == "eye_left"), None)
|
| 442 |
+
right_i = next((i for i in eye_idx if features[i].get("role") == "eye_right"), None)
|
| 443 |
+
if left_i is not None and right_i is not None:
|
| 444 |
+
_enforce_pair_coherence(
|
| 445 |
+
results[left_i], results[right_i], vlm_px[left_i], vlm_px[right_i], w, h, diag
|
| 446 |
+
)
|
| 447 |
+
|
| 448 |
+
eye_mid_y: Optional[float] = None
|
| 449 |
+
if eye_idx:
|
| 450 |
+
eye_mid_y = sum(results[i]["cy"] * (h - 1) for i in eye_idx) / len(eye_idx)
|
| 451 |
+
|
| 452 |
+
# Pass 2 — everything else; mouths must land strictly below the eye line.
|
| 453 |
+
for i, f in enumerate(features):
|
| 454 |
+
if results[i] is not None:
|
| 455 |
+
continue
|
| 456 |
+
floor = eye_mid_y if _role_class(f.get("role")) == "mouth" else None
|
| 457 |
+
results[i] = _snap_one(blurred, f, radius, diag, min_y=floor)
|
| 458 |
+
|
| 459 |
+
# Pass 3 — enforce the mouth floor on the FINAL points regardless of
|
| 460 |
+
# anchor_kind. Anchor candidates above the eye line were already filtered
|
| 461 |
+
# in _best_anchor, but a kept-VLM mouth (anchor_kind="vlm") could still
|
| 462 |
+
# sit above the snapped eye midpoint — an inverted face. Deterministic
|
| 463 |
+
# correction: push it just below the eye line, preserving the VLM's own
|
| 464 |
+
# eye-to-mouth scale.
|
| 465 |
+
if eye_mid_y is not None:
|
| 466 |
+
vlm_eye_mid_y = sum(vlm_px[i][1] for i in eye_idx) / len(eye_idx)
|
| 467 |
+
for i, f in enumerate(features):
|
| 468 |
+
if _role_class(f.get("role")) != "mouth":
|
| 469 |
+
continue
|
| 470 |
+
out = results[i]
|
| 471 |
+
final_y = out["cy"] * (h - 1)
|
| 472 |
+
if final_y > eye_mid_y:
|
| 473 |
+
continue # already strictly below the eye line
|
| 474 |
+
mx, my = vlm_px[i]
|
| 475 |
+
# 0.6 × the eye-to-mouth offset the VLM itself proposed (its
|
| 476 |
+
# magnitude — the proposal may have been inverted), with a small
|
| 477 |
+
# floor so an exactly-level mouth still moves strictly below.
|
| 478 |
+
offset = 0.6 * abs(my - vlm_eye_mid_y)
|
| 479 |
+
offset = max(offset, 0.02 * (h - 1), 1.0)
|
| 480 |
+
new_y = min(eye_mid_y + offset, float(h - 1)) # clamp inside image
|
| 481 |
+
out["cy"] = _clamp01(new_y / (h - 1)) if h > 1 else 0.0
|
| 482 |
+
out["snap_delta"] = math.hypot(out["cx"] * (w - 1) - mx, new_y - my) / diag
|
| 483 |
+
out["anchor_kind"] = "vlm_corrected"
|
| 484 |
+
return results # type: ignore[return-value]
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
# ------------------------------------------------------------- debug overlay
|
| 488 |
+
|
| 489 |
+
_COL_BEFORE = (0, 0, 255) # red — the VLM's coarse guess
|
| 490 |
+
_COL_AFTER = (0, 255, 0) # green — the snapped fact
|
| 491 |
+
_COL_WINDOW = (60, 60, 200) # dim red — search radius
|
| 492 |
+
_COL_LINK = (0, 255, 255) # yellow — the snap delta
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
def overlay_debug(
|
| 496 |
+
image: np.ndarray, features_before: list[dict], features_after: list[dict]
|
| 497 |
+
) -> np.ndarray:
|
| 498 |
+
"""Render before(red)/after(green) points + search circles for eyeballing.
|
| 499 |
+
|
| 500 |
+
Used by the eval agent's G2 grounding check (ARCHITECTURE.md §8): the red
|
| 501 |
+
dot is what the VLM guessed, the dim red circle is the 12%-diagonal search
|
| 502 |
+
window, the green dot is where deterministic code put the feature, with
|
| 503 |
+
the winning ``anchor_kind`` labeled. Returns a new BGR uint8 image.
|
| 504 |
+
"""
|
| 505 |
+
gray_or_bgr = image
|
| 506 |
+
if gray_or_bgr.ndim == 2:
|
| 507 |
+
canvas = cv2.cvtColor(gray_or_bgr, cv2.COLOR_GRAY2BGR)
|
| 508 |
+
else:
|
| 509 |
+
canvas = gray_or_bgr.copy()
|
| 510 |
+
h, w = canvas.shape[:2]
|
| 511 |
+
radius = int(round(max(8.0, SNAP_RADIUS_FRAC * math.hypot(w, h))))
|
| 512 |
+
for before, after in zip(features_before, features_after):
|
| 513 |
+
bx, by = _to_px(before, w, h)
|
| 514 |
+
ax, ay = _to_px(after, w, h)
|
| 515 |
+
b = (int(round(bx)), int(round(by)))
|
| 516 |
+
a = (int(round(ax)), int(round(ay)))
|
| 517 |
+
cv2.circle(canvas, b, radius, _COL_WINDOW, 1)
|
| 518 |
+
cv2.line(canvas, b, a, _COL_LINK, 1)
|
| 519 |
+
cv2.circle(canvas, b, 4, _COL_BEFORE, -1)
|
| 520 |
+
cv2.circle(canvas, a, 4, _COL_AFTER, -1)
|
| 521 |
+
kind = after.get("anchor_kind")
|
| 522 |
+
if kind:
|
| 523 |
+
cv2.putText(
|
| 524 |
+
canvas,
|
| 525 |
+
str(kind),
|
| 526 |
+
(a[0] + 6, a[1] - 6),
|
| 527 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 528 |
+
0.4,
|
| 529 |
+
_COL_AFTER,
|
| 530 |
+
1,
|
| 531 |
+
cv2.LINE_AA,
|
| 532 |
+
)
|
| 533 |
+
return canvas
|
mind/WRITING.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PAREIDOLIA — The Writing Bible
|
| 2 |
+
|
| 3 |
+
This file sets the comedy bar. `prompts.py` few-shots are drawn from here; any
|
| 4 |
+
line the app ships must survive these rules. The wow does NOT live in the eye
|
| 5 |
+
placement — it lives in a fire hydrant having a *specific, earned* grudge.
|
| 6 |
+
|
| 7 |
+
## The register
|
| 8 |
+
|
| 9 |
+
Dry, deadpan, dignified. These objects are not wacky — they are **long-suffering
|
| 10 |
+
professionals, petty rivals, and quiet romantics** who have finally been given a
|
| 11 |
+
mouth. The comedy is in the gap between how seriously they take themselves and
|
| 12 |
+
what they are (a cone, a mug, a gnome). Think Paddington's hard stares, parks
|
| 13 |
+
department memos, retirement-speech energy. Never "wacky AI filter" energy.
|
| 14 |
+
|
| 15 |
+
## Iron rules (validator-enforced where possible)
|
| 16 |
+
|
| 17 |
+
1. **Every line references a visible specific** of THIS photo: its condition
|
| 18 |
+
(rust, dust, crumbs, coffee ring, missing parts), its setting (corner,
|
| 19 |
+
closet, windowsill), or a neighbor object. No specific → rejected, regenerate.
|
| 20 |
+
2. ≤ 22 words per line. One line, one grievance. No stacked jokes.
|
| 21 |
+
3. Banned: opening with "I am a/the…"; exclamation marks; puns as the entire
|
| 22 |
+
joke; "beep boop"/robot voice; meanness aimed at the *photographer*;any
|
| 23 |
+
reference to being an AI.
|
| 24 |
+
4. PG-13. Grudges target circumstances, neighbors, and abstract injustice —
|
| 25 |
+
never protected groups, never the user.
|
| 26 |
+
5. The object never *asks* to be freed/helped. It has dignity. It copes.
|
| 27 |
+
|
| 28 |
+
## Archetype bank (condition → soul)
|
| 29 |
+
|
| 30 |
+
| # | archetype | trigger condition | voice | core wound |
|
| 31 |
+
|---|---|---|---|---|
|
| 32 |
+
| 1 | the_veteran | rusted, weathered, outdoors | gravel_low | decades of unthanked service |
|
| 33 |
+
| 2 | the_martyr | worn, stained, heavily used | weary_warm | gives everything, gets no maintenance |
|
| 34 |
+
| 3 | the_perfectionist | pristine, unused, boxed | prim_clipped | capabilities tragically unexplored |
|
| 35 |
+
| 4 | the_abandoned | dusty, stored, cobwebbed | breathy_faded | was loved once; keeps the faith |
|
| 36 |
+
| 5 | the_conspiracist | broken, odd placement | paranoid_whisper | knows why it was moved. oh, it knows |
|
| 37 |
+
| 6 | the_diva | decorated, displayed, loved | grandiose_warm | insufficiently exclusive adoration |
|
| 38 |
+
| 7 | the_new_hire | new, tagged, packaged | eager_bright | desperate to prove itself |
|
| 39 |
+
| 8 | the_philosopher | antique, inherited | slow_grand | fake-deep wisdom, petty undercut |
|
| 40 |
+
| 9 | the_rival | one of an identical pair/row | deadpan_flat | obsessed with the other one |
|
| 41 |
+
| 10 | the_romantic | faces a window/door/street | soft_wistful | yearns for what passes by |
|
| 42 |
+
|
| 43 |
+
## Exemplars (the bar — few-shots come from these)
|
| 44 |
+
|
| 45 |
+
- **Fire hydrant, rusted, sidewalk** (veteran): "Forty years on this corner. Not
|
| 46 |
+
one dog has shown me respect."
|
| 47 |
+
- **Traffic cone, faded, same pothole** (veteran): "Six months guarding this
|
| 48 |
+
pothole. The city says it's 'scheduled.' I've heard that before."
|
| 49 |
+
- **Mug, coffee ring, desk** (martyr): "Third refill today. Still no rinse. I
|
| 50 |
+
see how it is."
|
| 51 |
+
- **Toaster, crumb tray full** (martyr): "Every morning, golden perfection.
|
| 52 |
+
Every morning, shaken upside down like a piñata."
|
| 53 |
+
- **Stand mixer, spotless, counter** (perfectionist): "I have a setting they've
|
| 54 |
+
never once used. It's called *fold*. I dream about it."
|
| 55 |
+
- **Exercise bike, laundry on handlebars** (abandoned): "In January we were
|
| 56 |
+
inseparable. It's June. I'm a coat rack with a heart-rate monitor."
|
| 57 |
+
- **Stapler, supply closet shelf** (conspiracist): "They moved me here after the
|
| 58 |
+
audit. I know what I saw."
|
| 59 |
+
- **Garden gnome, flowerbed, next to flamingo** (diva): "Twenty years I anchored
|
| 60 |
+
this flowerbed's aesthetic. One flamingo arrives and suddenly we're 'eclectic.'"
|
| 61 |
+
- **Houseplant, price tag still on** (new_hire): "Day three. Tag's still on.
|
| 62 |
+
Could somebody just tell me where the light comes from."
|
| 63 |
+
- **Leather armchair, sun-faded** (philosopher): "I have held three generations
|
| 64 |
+
of this family. Also seventeen remotes. Mostly the remotes."
|
| 65 |
+
- **Bedside lamp, identical twin across the room** (rival): "Same wattage. Same
|
| 66 |
+
shade. Somehow *she* gets the side with the book."
|
| 67 |
+
- **Mailbox, residential street** (romantic): "Mostly coupons now. But I
|
| 68 |
+
remember real letters. I held them first."
|
| 69 |
+
|
| 70 |
+
## Mutters (idle wall lines — ≤10 words, same specificity rule)
|
| 71 |
+
|
| 72 |
+
- Hydrant: "Paint me red, they said. Dignified, they said."
|
| 73 |
+
- Cone: "Still. Scheduled."
|
| 74 |
+
- Mug: "A rinse. Anything."
|
| 75 |
+
- Gnome: "The flamingo doesn't even face the path."
|
| 76 |
+
- Bike: "That towel isn't mine."
|
| 77 |
+
|
| 78 |
+
## Voice-seed mapping
|
| 79 |
+
|
| 80 |
+
`persona.voice` values map 1:1 to pre-registered VoxCPM reference seeds in
|
| 81 |
+
`mind/voice.py` (gravel_low, weary_warm, prim_clipped, breathy_faded,
|
| 82 |
+
paranoid_whisper, grandiose_warm, eager_bright, slow_grand, deadpan_flat,
|
| 83 |
+
soft_wistful). Ten seeds, recorded/generated once, committed to the repo —
|
| 84 |
+
voices must be *instantly* distinct on a phone speaker.
|
| 85 |
+
|
| 86 |
+
## Quality gates
|
| 87 |
+
|
| 88 |
+
- **G1 (ship gate)**: 12-image eval set → a human laughs at ≥6; every line
|
| 89 |
+
passes rule 1. Otherwise iterate few-shots/archetypes before ANY polish work.
|
| 90 |
+
- Self-check in the prompt: the model writes `critique` first (is the grudge
|
| 91 |
+
specific to THIS object's visible state?), then finals. One retry on
|
| 92 |
+
validator failure with the error fed back.
|
mind/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PAREIDOLIA mind — the spirit medium, its schema, and its voices.
|
| 2 |
+
|
| 3 |
+
Import surface for app.py and tests. Importing this package is always light:
|
| 4 |
+
every ML dependency (torch, transformers, voxcpm, spaces) is guarded inside
|
| 5 |
+
the zerogpu code paths, so mock mode and the test suite run on stdlib +
|
| 6 |
+
pydantic alone (ARCHITECTURE.md §0, "mock backend first").
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from .backends import (
|
| 10 |
+
CANNED_RECORDS,
|
| 11 |
+
HYDRANT_RECORD,
|
| 12 |
+
MockMedium,
|
| 13 |
+
SEANCE_PROMPT,
|
| 14 |
+
ZeroGPUMedium,
|
| 15 |
+
awaken_full,
|
| 16 |
+
make_medium,
|
| 17 |
+
warm_defaults,
|
| 18 |
+
)
|
| 19 |
+
from .schema import (
|
| 20 |
+
AwakeningParseError,
|
| 21 |
+
AwakeningResult,
|
| 22 |
+
Feature,
|
| 23 |
+
Gate,
|
| 24 |
+
Lines,
|
| 25 |
+
Persona,
|
| 26 |
+
PoeticError,
|
| 27 |
+
build_repair_prompt,
|
| 28 |
+
parse_awakening,
|
| 29 |
+
)
|
| 30 |
+
from .voice import VOICE_DESIGNS, MockVoice, VoxVoice, make_voice
|
| 31 |
+
|
| 32 |
+
__all__ = [
|
| 33 |
+
"AwakeningParseError",
|
| 34 |
+
"AwakeningResult",
|
| 35 |
+
"CANNED_RECORDS",
|
| 36 |
+
"Feature",
|
| 37 |
+
"Gate",
|
| 38 |
+
"HYDRANT_RECORD",
|
| 39 |
+
"Lines",
|
| 40 |
+
"MockMedium",
|
| 41 |
+
"MockVoice",
|
| 42 |
+
"Persona",
|
| 43 |
+
"PoeticError",
|
| 44 |
+
"SEANCE_PROMPT",
|
| 45 |
+
"VOICE_DESIGNS",
|
| 46 |
+
"VoxVoice",
|
| 47 |
+
"ZeroGPUMedium",
|
| 48 |
+
"awaken_full",
|
| 49 |
+
"build_repair_prompt",
|
| 50 |
+
"make_medium",
|
| 51 |
+
"make_voice",
|
| 52 |
+
"parse_awakening",
|
| 53 |
+
"warm_defaults",
|
| 54 |
+
]
|
mind/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (1.25 kB). View file
|
|
|
mind/__pycache__/backends.cpython-312.pyc
ADDED
|
Binary file (25.1 kB). View file
|
|
|
mind/__pycache__/prompts.cpython-312.pyc
ADDED
|
Binary file (20.5 kB). View file
|
|
|
mind/__pycache__/schema.cpython-312.pyc
ADDED
|
Binary file (16.5 kB). View file
|
|
|
mind/__pycache__/voice.cpython-312.pyc
ADDED
|
Binary file (9.25 kB). View file
|
|
|
mind/backends.py
ADDED
|
@@ -0,0 +1,683 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Spirit-medium backends: MockMedium (canned awakenings) and ZeroGPUMedium.
|
| 2 |
+
|
| 3 |
+
Selected via the PAREIDOLIA_BACKEND env: mock (default) | zerogpu.
|
| 4 |
+
|
| 5 |
+
- MockMedium: deterministic canned awakening records keyed by image hash,
|
| 6 |
+
with a realistic configurable delay — the whole frontend, server, and test
|
| 7 |
+
suite build against this with zero GPU and zero ML imports.
|
| 8 |
+
- ZeroGPUMedium: MiniCPM-V-4_5 per ARCHITECTURE.md §7. ALL ML imports are
|
| 9 |
+
confined to its load path, so importing this module in mock mode never
|
| 10 |
+
touches torch.
|
| 11 |
+
|
| 12 |
+
This module also owns the combined-GPU-window plumbing (§2): `awaken_full`
|
| 13 |
+
runs VLM -> CV snap -> TTS inside ONE @spaces.GPU(duration=75) function so a
|
| 14 |
+
visitor pays one queue wait and one quota spend. `spaces` is imported (and
|
| 15 |
+
the decorator applied) at module import time when available — ZeroGPU
|
| 16 |
+
discovers GPU functions at startup, and importing `spaces` here also
|
| 17 |
+
guarantees it precedes any torch import (§7 hard rule). Locally the bare
|
| 18 |
+
function runs instead.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import io
|
| 24 |
+
import logging
|
| 25 |
+
import os
|
| 26 |
+
import threading
|
| 27 |
+
import time
|
| 28 |
+
from typing import Any, Callable, Optional
|
| 29 |
+
|
| 30 |
+
from .schema import (
|
| 31 |
+
AwakeningParseError,
|
| 32 |
+
AwakeningResult,
|
| 33 |
+
PoeticError,
|
| 34 |
+
build_repair_prompt,
|
| 35 |
+
parse_awakening,
|
| 36 |
+
)
|
| 37 |
+
from .voice import make_voice
|
| 38 |
+
|
| 39 |
+
logger = logging.getLogger("pareidolia.mind")
|
| 40 |
+
|
| 41 |
+
# ---------------------------------------------------------------------------
|
| 42 |
+
# Séance prompt (writing agent's mind/prompts.py owns the real one via
|
| 43 |
+
# build_seance_prompt()). Import-guarded with a minimal-but-correct fallback
|
| 44 |
+
# so the zerogpu path can bench even without prompts.py.
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
try:
|
| 47 |
+
from .prompts import build_seance_prompt as _build_seance_prompt
|
| 48 |
+
|
| 49 |
+
SEANCE_PROMPT: str = _build_seance_prompt()
|
| 50 |
+
except Exception: # noqa: BLE001 - any import problem means "use the fallback"
|
| 51 |
+
SEANCE_PROMPT = (
|
| 52 |
+
"You are a spirit medium. This object has a latent face made of its"
|
| 53 |
+
" EXISTING visual features, and a soul shaped by its visible condition."
|
| 54 |
+
" Find both. First write a one-line critique of your feature choices,"
|
| 55 |
+
" then commit. Reply with ONLY one JSON object, no code fences:\n"
|
| 56 |
+
"{\n"
|
| 57 |
+
' "gate": {"contains_human_face": bool, "nsfw": bool,'
|
| 58 |
+
' "recognizable_object": bool},\n'
|
| 59 |
+
' "object": str, "material": str, "condition": str, "setting": str,\n'
|
| 60 |
+
' "candidate_features": [{"name": str, "role":'
|
| 61 |
+
' "eye_left"|"eye_right"|"mouth", "cx": 0..1, "cy": 0..1,'
|
| 62 |
+
' "size": 0..1}],\n'
|
| 63 |
+
' "critique": str,\n'
|
| 64 |
+
' "persona": {"archetype": str, "voice": str, "mood": str},\n'
|
| 65 |
+
' "lines": {"grudge": str, "mutter": str}\n'
|
| 66 |
+
"}\n"
|
| 67 |
+
"Exactly one eye_left, one eye_right, at most one mouth — each an"
|
| 68 |
+
" existing visual element, named as seen. Every line must reference a"
|
| 69 |
+
" visible specific of THIS object. Dry, deadpan, dignified; <= 22"
|
| 70 |
+
" words per line; PG-13; never open with 'I am'."
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
# ---------------------------------------------------------------------------
|
| 74 |
+
# CV snapping (cv agent's cv/snap.py). Assumed contract:
|
| 75 |
+
# snap_features(image, features: list[dict]) -> list[dict]
|
| 76 |
+
# where each returned feature keeps name/role/cx/cy/size and gains
|
| 77 |
+
# "snap_delta" (normalized distance moved). Identity fallback keeps the
|
| 78 |
+
# VLM's coarse points — the mist forgives ±15% by design (§0).
|
| 79 |
+
# ---------------------------------------------------------------------------
|
| 80 |
+
try: # pragma: no cover - exercised only once cv/snap.py lands
|
| 81 |
+
from cv.snap import snap_features as _snap_features # type: ignore[import-not-found]
|
| 82 |
+
except Exception: # noqa: BLE001
|
| 83 |
+
|
| 84 |
+
def _snap_features(image: Any, features: list[dict]) -> list[dict]:
|
| 85 |
+
"""Identity fallback: keep coarse points, report zero snap delta."""
|
| 86 |
+
return [{**feature, "snap_delta": 0.0} for feature in features]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _as_snap_image(image: Any) -> Any:
|
| 90 |
+
"""Best-effort convert to the BGR uint8 ndarray cv/snap.py wants.
|
| 91 |
+
|
| 92 |
+
The transport hands the pipeline a PIL image; cv.snap.snap_features speaks
|
| 93 |
+
ndarray only (its documented contract). Anything without ``.convert``
|
| 94 |
+
(bytes in tests, an ndarray already) passes through unchanged — the snap
|
| 95 |
+
try/except in :func:`_pipeline` keeps the VLM's coarse points whenever the
|
| 96 |
+
shape is wrong, and the mist forgives ±15% (§0).
|
| 97 |
+
"""
|
| 98 |
+
convert = getattr(image, "convert", None)
|
| 99 |
+
if convert is None:
|
| 100 |
+
return image
|
| 101 |
+
import numpy as np # transitively present via opencv; local to stay light
|
| 102 |
+
|
| 103 |
+
rgb = np.asarray(convert("RGB"))
|
| 104 |
+
return np.ascontiguousarray(rgb[:, :, ::-1]) # RGB -> BGR
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ---------------------------------------------------------------------------
|
| 108 |
+
# Canned awakening records (mock backend). HYDRANT_RECORD is EXACTLY the §3
|
| 109 |
+
# fire-hydrant example and the default for unknown images. The eight
|
| 110 |
+
# _M*_RECORDs are COHERENT souls for the eight dev photos in
|
| 111 |
+
# web/mock/photos/ (same objects, coordinates eyeballed against the actual
|
| 112 |
+
# pixels; lines reuse/match web/mock/records.json, written to the WRITING.md
|
| 113 |
+
# bar) — recognized by perceptual average-hash so a mock demo never captions
|
| 114 |
+
# a hydrant photo as "the weathered traffic cone".
|
| 115 |
+
# ---------------------------------------------------------------------------
|
| 116 |
+
|
| 117 |
+
HYDRANT_RECORD: dict = {
|
| 118 |
+
"gate": {"contains_human_face": False, "nsfw": False, "recognizable_object": True},
|
| 119 |
+
"object": "fire hydrant",
|
| 120 |
+
"material": "cast iron",
|
| 121 |
+
"condition": "rusted",
|
| 122 |
+
"setting": "sidewalk, residential street",
|
| 123 |
+
"candidate_features": [
|
| 124 |
+
{"name": "left bonnet bolt", "role": "eye_left", "cx": 0.42, "cy": 0.31, "size": 0.06},
|
| 125 |
+
{"name": "right bonnet bolt", "role": "eye_right", "cx": 0.58, "cy": 0.31, "size": 0.06},
|
| 126 |
+
{"name": "front outlet cap", "role": "mouth", "cx": 0.50, "cy": 0.55, "size": 0.12},
|
| 127 |
+
],
|
| 128 |
+
"critique": "bolts are symmetric and round — strong eyes; outlet sits low-center, good mouth",
|
| 129 |
+
"persona": {"archetype": "the_veteran", "voice": "gravel_low", "mood": "long-suffering"},
|
| 130 |
+
"lines": {
|
| 131 |
+
"grudge": "Forty years on this corner. Not one dog has shown me respect.",
|
| 132 |
+
"mutter": "Paint me red, they said. It'll be dignified, they said.",
|
| 133 |
+
},
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
_M1_HYDRANT_RECORD: dict = {
|
| 137 |
+
"gate": {"contains_human_face": False, "nsfw": False, "recognizable_object": True},
|
| 138 |
+
"object": "fire hydrant",
|
| 139 |
+
"material": "cast iron",
|
| 140 |
+
"condition": "rusted",
|
| 141 |
+
"setting": "against a corrugated metal wall",
|
| 142 |
+
"candidate_features": [
|
| 143 |
+
{"name": "left outlet cap", "role": "eye_left", "cx": 0.337, "cy": 0.328, "size": 0.16},
|
| 144 |
+
{"name": "right outlet cap", "role": "eye_right", "cx": 0.547, "cy": 0.33, "size": 0.16},
|
| 145 |
+
{"name": "paired flange bolts", "role": "mouth", "cx": 0.415, "cy": 0.49, "size": 0.10},
|
| 146 |
+
],
|
| 147 |
+
"critique": (
|
| 148 |
+
"the two outlet caps sit level and round — strong eyes; the paired "
|
| 149 |
+
"flange bolts below read as a small, set mouth"
|
| 150 |
+
),
|
| 151 |
+
"persona": {"archetype": "the_veteran", "voice": "gravel_low", "mood": "long-suffering"},
|
| 152 |
+
"lines": {
|
| 153 |
+
"grudge": (
|
| 154 |
+
"They painted over the rust twice and never once turned my "
|
| 155 |
+
"valve. Decorative, apparently."
|
| 156 |
+
),
|
| 157 |
+
"mutter": "Two coats of orange. Still thirsty.",
|
| 158 |
+
},
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
_M2_CONE_RECORD: dict = {
|
| 162 |
+
"gate": {"contains_human_face": False, "nsfw": False, "recognizable_object": True},
|
| 163 |
+
"object": "traffic cone",
|
| 164 |
+
"material": "weathered pvc",
|
| 165 |
+
"condition": "worn",
|
| 166 |
+
"setting": "curb edge, residential street",
|
| 167 |
+
"candidate_features": [
|
| 168 |
+
{"name": "left scuff mark", "role": "eye_left", "cx": 0.40, "cy": 0.385, "size": 0.05},
|
| 169 |
+
{"name": "right sun-fade patch", "role": "eye_right", "cx": 0.52, "cy": 0.39, "size": 0.05},
|
| 170 |
+
{"name": "moulding seam shadow", "role": "mouth", "cx": 0.46, "cy": 0.56, "size": 0.09},
|
| 171 |
+
],
|
| 172 |
+
"critique": (
|
| 173 |
+
"the scuff and the sun-fade patch sit nearly level — tired but "
|
| 174 |
+
"honest eyes; the moulding seam shadow makes a thin, grim mouth"
|
| 175 |
+
),
|
| 176 |
+
"persona": {"archetype": "the_veteran", "voice": "gravel_low", "mood": "resolute"},
|
| 177 |
+
"lines": {
|
| 178 |
+
"grudge": (
|
| 179 |
+
"Temporary placement, they said. The grass has since eaten the "
|
| 180 |
+
"curb. I hold the line alone."
|
| 181 |
+
),
|
| 182 |
+
"mutter": "Still here. Still 'temporary.'",
|
| 183 |
+
},
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
_M3_MUG_RECORD: dict = {
|
| 187 |
+
"gate": {"contains_human_face": False, "nsfw": False, "recognizable_object": True},
|
| 188 |
+
"object": "coffee mug",
|
| 189 |
+
"material": "glazed ceramic",
|
| 190 |
+
"condition": "loved",
|
| 191 |
+
"setting": "on a fire grate, campfire",
|
| 192 |
+
"candidate_features": [
|
| 193 |
+
{"name": "ember reflection", "role": "eye_left", "cx": 0.625, "cy": 0.62, "size": 0.05},
|
| 194 |
+
{"name": "glaze highlight", "role": "eye_right", "cx": 0.76, "cy": 0.63, "size": 0.05},
|
| 195 |
+
{"name": "base shadow curve", "role": "mouth", "cx": 0.69, "cy": 0.78, "size": 0.09},
|
| 196 |
+
],
|
| 197 |
+
"critique": (
|
| 198 |
+
"the ember reflection and the glaze highlight sit level on the "
|
| 199 |
+
"curve — lit, wary eyes; the base shadow bends like a resigned mouth"
|
| 200 |
+
),
|
| 201 |
+
"persona": {"archetype": "the_martyr", "voice": "weary_warm", "mood": "resigned"},
|
| 202 |
+
"lines": {
|
| 203 |
+
"grudge": (
|
| 204 |
+
"They set me on the grill. Directly over the fire. I'm told "
|
| 205 |
+
"this is 'camping.'"
|
| 206 |
+
),
|
| 207 |
+
"mutter": "Nobody checks the handle temperature.",
|
| 208 |
+
},
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
_M4_TOASTER_RECORD: dict = {
|
| 212 |
+
"gate": {"contains_human_face": False, "nsfw": False, "recognizable_object": True},
|
| 213 |
+
"object": "toaster",
|
| 214 |
+
"material": "white enamel",
|
| 215 |
+
"condition": "pristine",
|
| 216 |
+
"setting": "bare counter, studio light",
|
| 217 |
+
"candidate_features": [
|
| 218 |
+
{"name": "left slot guard", "role": "eye_left", "cx": 0.30, "cy": 0.615, "size": 0.10},
|
| 219 |
+
{"name": "right slot guard", "role": "eye_right", "cx": 0.655, "cy": 0.605, "size": 0.10},
|
| 220 |
+
{"name": "base seam shadow", "role": "mouth", "cx": 0.50, "cy": 0.84, "size": 0.14},
|
| 221 |
+
],
|
| 222 |
+
"critique": (
|
| 223 |
+
"the slot guards are symmetric and wide-set — immaculate eyes; the "
|
| 224 |
+
"base seam shadow runs flat and composed, a professional's mouth"
|
| 225 |
+
),
|
| 226 |
+
"persona": {"archetype": "the_perfectionist", "voice": "prim_clipped", "mood": "wounded pride"},
|
| 227 |
+
"lines": {
|
| 228 |
+
"grudge": (
|
| 229 |
+
"Two slots. They use one. The other waits, pristine, for "
|
| 230 |
+
"guests who never toast."
|
| 231 |
+
),
|
| 232 |
+
"mutter": "Bagel setting. Untouched since purchase.",
|
| 233 |
+
},
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
_M5_MAILBOX_RECORD: dict = {
|
| 237 |
+
"gate": {"contains_human_face": False, "nsfw": False, "recognizable_object": True},
|
| 238 |
+
"object": "mailbox",
|
| 239 |
+
"material": "galvanized steel",
|
| 240 |
+
"condition": "weathered",
|
| 241 |
+
"setting": "snowdrift, farm fence line, 1940",
|
| 242 |
+
"candidate_features": [
|
| 243 |
+
# The rivets really are ~0.04 apart on the small door — distinct,
|
| 244 |
+
# eyeballed on the photo. The 0.05-separation rule lives in the
|
| 245 |
+
# parse_awakening path (VLM degeneracy guard), not the schema.
|
| 246 |
+
{"name": "left door rivet", "role": "eye_left", "cx": 0.705, "cy": 0.287, "size": 0.035},
|
| 247 |
+
{"name": "right door rivet", "role": "eye_right", "cx": 0.745, "cy": 0.286, "size": 0.035},
|
| 248 |
+
{"name": "door latch", "role": "mouth", "cx": 0.728, "cy": 0.363, "size": 0.05},
|
| 249 |
+
],
|
| 250 |
+
"critique": (
|
| 251 |
+
"the two door rivets sit close and level — small wary eyes; the "
|
| 252 |
+
"latch beneath makes a tight deadpan mouth"
|
| 253 |
+
),
|
| 254 |
+
"persona": {"archetype": "the_rival", "voice": "deadpan_flat", "mood": "clipped"},
|
| 255 |
+
"lines": {
|
| 256 |
+
"grudge": (
|
| 257 |
+
"We've stood in this snow since January. R.C. Lenhart gets a "
|
| 258 |
+
"name plate. I get 'the other box.'"
|
| 259 |
+
),
|
| 260 |
+
"mutter": "Her flag works. Allegedly.",
|
| 261 |
+
},
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
_M6_POSTBOX_RECORD: dict = {
|
| 265 |
+
"gate": {"contains_human_face": False, "nsfw": False, "recognizable_object": True},
|
| 266 |
+
"object": "post box",
|
| 267 |
+
"material": "painted steel",
|
| 268 |
+
"condition": "kept",
|
| 269 |
+
"setting": "lawn beside a parking lot",
|
| 270 |
+
"candidate_features": [
|
| 271 |
+
{"name": "left flap hinge", "role": "eye_left", "cx": 0.345, "cy": 0.295, "size": 0.045},
|
| 272 |
+
{"name": "right flap hinge", "role": "eye_right", "cx": 0.60, "cy": 0.295, "size": 0.045},
|
| 273 |
+
{"name": "collection keyhole", "role": "mouth", "cx": 0.475, "cy": 0.415, "size": 0.05},
|
| 274 |
+
],
|
| 275 |
+
"critique": (
|
| 276 |
+
"the flap hinges sit wide and perfectly level — patient eyes; the "
|
| 277 |
+
"collection keyhole is a small, hopeful mouth"
|
| 278 |
+
),
|
| 279 |
+
"persona": {"archetype": "the_romantic", "voice": "soft_wistful", "mood": "hopeful"},
|
| 280 |
+
"lines": {
|
| 281 |
+
"grudge": (
|
| 282 |
+
"Parked cars come and go all day. Nobody writes. I keep the "
|
| 283 |
+
"slot warm anyway."
|
| 284 |
+
),
|
| 285 |
+
"mutter": "The slot stays open. Just in case.",
|
| 286 |
+
},
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
_M7_ASPIDISTRA_RECORD: dict = {
|
| 290 |
+
"gate": {"contains_human_face": False, "nsfw": False, "recognizable_object": True},
|
| 291 |
+
"object": "aspidistra",
|
| 292 |
+
"material": "leaf and glazed bowl",
|
| 293 |
+
"condition": "antique",
|
| 294 |
+
"setting": "parlor table, framed pictures behind",
|
| 295 |
+
"candidate_features": [
|
| 296 |
+
{"name": "left glaze highlight", "role": "eye_left", "cx": 0.425, "cy": 0.755, "size": 0.04},
|
| 297 |
+
{"name": "right glaze highlight", "role": "eye_right", "cx": 0.525, "cy": 0.76, "size": 0.04},
|
| 298 |
+
{"name": "doily shadow", "role": "mouth", "cx": 0.475, "cy": 0.875, "size": 0.08},
|
| 299 |
+
],
|
| 300 |
+
"critique": (
|
| 301 |
+
"the paired glaze highlights sit level on the bowl — calm, ancient "
|
| 302 |
+
"eyes; the doily shadow curves into a serene mouth"
|
| 303 |
+
),
|
| 304 |
+
"persona": {"archetype": "the_philosopher", "voice": "slow_grand", "mood": "serene"},
|
| 305 |
+
"lines": {
|
| 306 |
+
"grudge": (
|
| 307 |
+
"I outlasted everyone in those three frames. The lesson here is "
|
| 308 |
+
"patience. Also, shade tolerance."
|
| 309 |
+
),
|
| 310 |
+
"mutter": "The frames went first. Noted.",
|
| 311 |
+
},
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
_M8_STAPLER_RECORD: dict = {
|
| 315 |
+
"gate": {"contains_human_face": False, "nsfw": False, "recognizable_object": True},
|
| 316 |
+
"object": "stapler",
|
| 317 |
+
"material": "cast metal",
|
| 318 |
+
"condition": "stored",
|
| 319 |
+
"setting": "bare shelf, yellowed wall",
|
| 320 |
+
"candidate_features": [
|
| 321 |
+
{"name": "side rivet", "role": "eye_left", "cx": 0.245, "cy": 0.665, "size": 0.03},
|
| 322 |
+
{"name": "anvil latch", "role": "eye_right", "cx": 0.455, "cy": 0.615, "size": 0.03},
|
| 323 |
+
{"name": "loaded staple strip", "role": "mouth", "cx": 0.545, "cy": 0.745, "size": 0.07},
|
| 324 |
+
],
|
| 325 |
+
"critique": (
|
| 326 |
+
"the side rivet and the anvil latch hold a sidelong gaze — uneven "
|
| 327 |
+
"but alive; the loaded staple strip is a clenched mouth"
|
| 328 |
+
),
|
| 329 |
+
"persona": {"archetype": "the_conspiracist", "voice": "paranoid_whisper", "mood": "vigilant"},
|
| 330 |
+
"lines": {
|
| 331 |
+
"grudge": (
|
| 332 |
+
"The gray one appeared the week the files went missing. Nobody "
|
| 333 |
+
"else finds that interesting."
|
| 334 |
+
),
|
| 335 |
+
"mutter": "It hasn't stapled once. Not once.",
|
| 336 |
+
},
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
CANNED_RECORDS: tuple[dict, ...] = (
|
| 340 |
+
HYDRANT_RECORD,
|
| 341 |
+
_M1_HYDRANT_RECORD,
|
| 342 |
+
_M2_CONE_RECORD,
|
| 343 |
+
_M3_MUG_RECORD,
|
| 344 |
+
_M4_TOASTER_RECORD,
|
| 345 |
+
_M5_MAILBOX_RECORD,
|
| 346 |
+
_M6_POSTBOX_RECORD,
|
| 347 |
+
_M7_ASPIDISTRA_RECORD,
|
| 348 |
+
_M8_STAPLER_RECORD,
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
# 64-bit average-hashes of web/mock/photos/m1..m8.jpg (computed June 12 with
|
| 352 |
+
# _average_hash below). aHash survives the client's canvas downscale/JPEG
|
| 353 |
+
# re-encode and app.py's thumbnail: measured drift ≤1 bit at 0.8x + q70,
|
| 354 |
+
# while the closest photo PAIR is 18 bits apart — a ≤8-bit match is
|
| 355 |
+
# unambiguous. Same photo -> same soul, forever; anything else -> hydrant.
|
| 356 |
+
_PHOTO_HASH_RECORDS: tuple[tuple[int, dict], ...] = (
|
| 357 |
+
(0xFFFFFFCED8101010, _M1_HYDRANT_RECORD),
|
| 358 |
+
(0xFFF9101818181800, _M2_CONE_RECORD),
|
| 359 |
+
(0xF838363F7F701000, _M3_MUG_RECORD),
|
| 360 |
+
(0x7E7E7E64003E3C3C, _M4_TOASTER_RECORD),
|
| 361 |
+
(0xFEFF31E1E12124F8, _M5_MAILBOX_RECORD),
|
| 362 |
+
(0xC0D04003C3FFFCFC, _M6_POSTBOX_RECORD),
|
| 363 |
+
(0x0000007F773F79FF, _M7_ASPIDISTRA_RECORD),
|
| 364 |
+
(0xFFFFE3E3C18083FF, _M8_STAPLER_RECORD),
|
| 365 |
+
)
|
| 366 |
+
_PHOTO_HASH_MAX_DISTANCE = 8
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def _average_hash(image: Any) -> Optional[int]:
|
| 370 |
+
"""64-bit perceptual average-hash, or None when ``image`` isn't one.
|
| 371 |
+
|
| 372 |
+
Accepts a PIL-like image (has ``.convert``) or raw encoded bytes; strings
|
| 373 |
+
and everything else return None (the mock must never read paths or raise).
|
| 374 |
+
PIL is a light image-I/O dependency (already required by app.py), NOT an
|
| 375 |
+
ML import — the zero-ML mock contract holds.
|
| 376 |
+
"""
|
| 377 |
+
try:
|
| 378 |
+
from PIL import Image # noqa: PLC0415 - light, lazy by convention
|
| 379 |
+
|
| 380 |
+
if isinstance(image, (bytes, bytearray)):
|
| 381 |
+
img = Image.open(io.BytesIO(bytes(image)))
|
| 382 |
+
img.load()
|
| 383 |
+
elif hasattr(image, "convert"):
|
| 384 |
+
img = image
|
| 385 |
+
else:
|
| 386 |
+
return None
|
| 387 |
+
gray = img.convert("L").resize((8, 8), Image.Resampling.LANCZOS)
|
| 388 |
+
pixels = gray.tobytes()
|
| 389 |
+
avg = sum(pixels) / 64.0
|
| 390 |
+
bits = 0
|
| 391 |
+
for p in pixels:
|
| 392 |
+
bits = (bits << 1) | (1 if p > avg else 0)
|
| 393 |
+
return bits
|
| 394 |
+
except Exception: # noqa: BLE001 - undecodable bytes, broken PIL object …
|
| 395 |
+
return None
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def _canned_record_for(image: Any) -> dict:
|
| 399 |
+
"""The coherent canned record for a known dev photo, else the §3 hydrant."""
|
| 400 |
+
ahash = _average_hash(image)
|
| 401 |
+
if ahash is None:
|
| 402 |
+
return HYDRANT_RECORD
|
| 403 |
+
best: Optional[dict] = None
|
| 404 |
+
best_d = _PHOTO_HASH_MAX_DISTANCE + 1
|
| 405 |
+
for ref, record in _PHOTO_HASH_RECORDS:
|
| 406 |
+
d = (ahash ^ ref).bit_count()
|
| 407 |
+
if d < best_d:
|
| 408 |
+
best, best_d = record, d
|
| 409 |
+
return best if best is not None else HYDRANT_RECORD
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
class MockMedium:
|
| 413 |
+
"""Deterministic canned medium — what every demo and test runs on.
|
| 414 |
+
|
| 415 |
+
Same image -> same record, forever (the Menagerie replays identically;
|
| 416 |
+
judges can refresh without surprises). Each of the eight dev photos in
|
| 417 |
+
web/mock/photos/ is recognized by perceptual average-hash and gets its
|
| 418 |
+
own COHERENT record (the photo's actual object, features eyeballed on
|
| 419 |
+
its pixels) — a mock demo can never caption a hydrant photo as a cone.
|
| 420 |
+
Unknown images (and None) get the §3 fire-hydrant example verbatim.
|
| 421 |
+
|
| 422 |
+
The sleep (default 6 s, PAREIDOLIA_MOCK_DELAY env, constructor override
|
| 423 |
+
for tests) keeps the séance animation honest: latency theater must be
|
| 424 |
+
rehearsed against realistic latency.
|
| 425 |
+
"""
|
| 426 |
+
|
| 427 |
+
name = "mock"
|
| 428 |
+
model_id = "pareidolia-mock-canned"
|
| 429 |
+
|
| 430 |
+
def __init__(self, delay: Optional[float] = None):
|
| 431 |
+
if delay is None:
|
| 432 |
+
try:
|
| 433 |
+
delay = float(os.environ.get("PAREIDOLIA_MOCK_DELAY", "6"))
|
| 434 |
+
except ValueError:
|
| 435 |
+
delay = 6.0
|
| 436 |
+
self.delay = max(0.0, delay)
|
| 437 |
+
|
| 438 |
+
def awaken(self, image: Any, prompt: Optional[str] = None) -> AwakeningResult:
|
| 439 |
+
"""Return the canned awakening for this image after the mock delay.
|
| 440 |
+
|
| 441 |
+
`prompt` is accepted (and ignored) so call sites never special-case
|
| 442 |
+
the backend. Records round-trip through pydantic so the mock can
|
| 443 |
+
never drift from the schema contract.
|
| 444 |
+
"""
|
| 445 |
+
if self.delay:
|
| 446 |
+
time.sleep(self.delay)
|
| 447 |
+
record = _canned_record_for(image)
|
| 448 |
+
return AwakeningResult.model_validate(record)
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
# ---------------------------------------------------------------------------
|
| 452 |
+
# ZeroGPU medium — MiniCPM-V-4_5
|
| 453 |
+
# ---------------------------------------------------------------------------
|
| 454 |
+
|
| 455 |
+
REFUSAL_SCATTERED = (
|
| 456 |
+
"The spirits spoke, but their words scattered like mist. Try another angle."
|
| 457 |
+
)
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
class ZeroGPUMedium:
|
| 461 |
+
"""MiniCPM-V-4_5 spirit medium for the ZeroGPU Space (§7 pins).
|
| 462 |
+
|
| 463 |
+
Heavy rules, all verified June 12:
|
| 464 |
+
- attn_implementation='sdpa' (NEVER 'eager'; no flash-attn on the image),
|
| 465 |
+
torch_dtype=bfloat16, trust_remote_code=True, .eval().cuda().
|
| 466 |
+
- Load happens ONCE at construction. app.py must construct this medium at
|
| 467 |
+
module import (startup), where the ZeroGPU runtime manages the startup
|
| 468 |
+
.cuda() — that is the moral equivalent of §7's "module level" load, kept
|
| 469 |
+
inside this class so mock mode never imports torch.
|
| 470 |
+
- No native JSON mode: sampling=False + defensive parse + ONE repair retry
|
| 471 |
+
(schema.build_repair_prompt), then PoeticError. max_slice_nums=4 bounds
|
| 472 |
+
prefill at our ≤1024px inputs.
|
| 473 |
+
|
| 474 |
+
``chat_fn`` is a test seam: a callable ``(image, prompt) -> str`` that
|
| 475 |
+
replaces the loaded model so the retry/PoeticError logic is testable with
|
| 476 |
+
zero ML imports.
|
| 477 |
+
"""
|
| 478 |
+
|
| 479 |
+
name = "zerogpu"
|
| 480 |
+
DEFAULT_MODEL = "openbmb/MiniCPM-V-4_5"
|
| 481 |
+
MAX_NEW_TOKENS = 700
|
| 482 |
+
MAX_SLICE_NUMS = 4
|
| 483 |
+
|
| 484 |
+
def __init__(
|
| 485 |
+
self,
|
| 486 |
+
model_id: Optional[str] = None,
|
| 487 |
+
chat_fn: Optional[Callable[[Any, str], str]] = None,
|
| 488 |
+
):
|
| 489 |
+
self.model_id = model_id or os.environ.get("PAREIDOLIA_VLM", self.DEFAULT_MODEL)
|
| 490 |
+
self._chat: Callable[[Any, str], str] = chat_fn or self._load()
|
| 491 |
+
|
| 492 |
+
def _load(self) -> Callable[[Any, str], str]:
|
| 493 |
+
"""Load MiniCPM-V once; return the bound chat callable.
|
| 494 |
+
|
| 495 |
+
The ONLY torch/transformers imports in the mind package live here.
|
| 496 |
+
"""
|
| 497 |
+
import torch # noqa: PLC0415 - guarded heavy import by design
|
| 498 |
+
from transformers import AutoModel, AutoTokenizer # noqa: PLC0415
|
| 499 |
+
|
| 500 |
+
tokenizer = AutoTokenizer.from_pretrained(self.model_id, trust_remote_code=True)
|
| 501 |
+
model = AutoModel.from_pretrained(
|
| 502 |
+
self.model_id,
|
| 503 |
+
trust_remote_code=True,
|
| 504 |
+
attn_implementation="sdpa",
|
| 505 |
+
torch_dtype=torch.bfloat16,
|
| 506 |
+
)
|
| 507 |
+
model = model.eval().cuda()
|
| 508 |
+
|
| 509 |
+
def _chat(image: Any, prompt: str) -> str:
|
| 510 |
+
return model.chat(
|
| 511 |
+
msgs=[{"role": "user", "content": [image, prompt]}],
|
| 512 |
+
tokenizer=tokenizer,
|
| 513 |
+
sampling=False,
|
| 514 |
+
max_new_tokens=self.MAX_NEW_TOKENS,
|
| 515 |
+
max_slice_nums=self.MAX_SLICE_NUMS,
|
| 516 |
+
# MiniCPM-V-4_5 is a hybrid-thinking model: thinking MUST stay
|
| 517 |
+
# off (§7, bench-proven call) or thinking tokens eat the
|
| 518 |
+
# 700-token budget and the JSON truncates every time.
|
| 519 |
+
enable_thinking=False,
|
| 520 |
+
)
|
| 521 |
+
|
| 522 |
+
return _chat
|
| 523 |
+
|
| 524 |
+
def awaken(self, image: Any, prompt: Optional[str] = None) -> AwakeningResult:
|
| 525 |
+
"""One structured generation + at most one repair retry.
|
| 526 |
+
|
| 527 |
+
Raises PoeticError (visitor-safe copy) when both attempts fail; the
|
| 528 |
+
underlying parse errors ride on __cause__ for the logs.
|
| 529 |
+
"""
|
| 530 |
+
seance = prompt or SEANCE_PROMPT
|
| 531 |
+
raw = self._chat(image, seance)
|
| 532 |
+
try:
|
| 533 |
+
return parse_awakening(raw)
|
| 534 |
+
except AwakeningParseError as first_error:
|
| 535 |
+
logger.warning("awakening parse failed, retrying once: %s", first_error)
|
| 536 |
+
repair = build_repair_prompt(str(first_error), raw)
|
| 537 |
+
raw_retry = self._chat(image, repair)
|
| 538 |
+
try:
|
| 539 |
+
return parse_awakening(raw_retry)
|
| 540 |
+
except AwakeningParseError as second_error:
|
| 541 |
+
logger.error("awakening repair also failed: %s", second_error)
|
| 542 |
+
raise PoeticError(REFUSAL_SCATTERED) from second_error
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
# ---------------------------------------------------------------------------
|
| 546 |
+
# Factory
|
| 547 |
+
# ---------------------------------------------------------------------------
|
| 548 |
+
|
| 549 |
+
_MEDIUM_ALIASES = {
|
| 550 |
+
"mock": "mock",
|
| 551 |
+
"": "mock",
|
| 552 |
+
"zerogpu": "zerogpu",
|
| 553 |
+
"zero-gpu": "zerogpu",
|
| 554 |
+
"minicpm": "zerogpu",
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
def make_medium(backend_name: Optional[str] = None):
|
| 559 |
+
"""Build the medium for ``backend_name`` or the PAREIDOLIA_BACKEND env.
|
| 560 |
+
|
| 561 |
+
Defaults to mock (always works, zero deps beyond pydantic).
|
| 562 |
+
"""
|
| 563 |
+
raw = (backend_name or os.environ.get("PAREIDOLIA_BACKEND") or "mock").strip().lower()
|
| 564 |
+
resolved = _MEDIUM_ALIASES.get(raw)
|
| 565 |
+
if resolved == "mock":
|
| 566 |
+
return MockMedium()
|
| 567 |
+
if resolved == "zerogpu":
|
| 568 |
+
return ZeroGPUMedium()
|
| 569 |
+
raise ValueError(f"unknown PAREIDOLIA_BACKEND {raw!r} (expected mock | zerogpu)")
|
| 570 |
+
|
| 571 |
+
|
| 572 |
+
# ---------------------------------------------------------------------------
|
| 573 |
+
# The combined GPU window — VLM -> snap -> TTS in one quota spend (§2)
|
| 574 |
+
# ---------------------------------------------------------------------------
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
def _pipeline(image: Any, medium, voice, prompt: Optional[str] = None) -> dict:
|
| 578 |
+
"""The full awakening pipeline, backend-agnostic.
|
| 579 |
+
|
| 580 |
+
Returns the dict app.py shapes into the §2 response:
|
| 581 |
+
{
|
| 582 |
+
"refused": bool,
|
| 583 |
+
"refusal": str | None, # poetic copy when refused
|
| 584 |
+
"record": dict | None, # AwakeningResult dump + "features" (post-
|
| 585 |
+
# snap, each with snap_delta; the original
|
| 586 |
+
# candidate_features stay for the trace)
|
| 587 |
+
"grudge_wav": bytes | None, # WAV bytes; app.py b64-encodes
|
| 588 |
+
}
|
| 589 |
+
Refusals exit BEFORE snap and TTS — the gate is also the budget guard.
|
| 590 |
+
"""
|
| 591 |
+
result: AwakeningResult = (
|
| 592 |
+
medium.awaken(image, prompt=prompt) if prompt is not None else medium.awaken(image)
|
| 593 |
+
)
|
| 594 |
+
|
| 595 |
+
refusal = result.gate.refusal()
|
| 596 |
+
if refusal is not None:
|
| 597 |
+
return {"refused": True, "refusal": refusal, "record": None, "grudge_wav": None}
|
| 598 |
+
|
| 599 |
+
coarse = [feature.model_dump() for feature in result.candidate_features]
|
| 600 |
+
try:
|
| 601 |
+
snapped = _snap_features(_as_snap_image(image), coarse)
|
| 602 |
+
except Exception: # noqa: BLE001 - snapping is best-effort by contract (§0)
|
| 603 |
+
logger.exception("cv snap failed; keeping the VLM's coarse points")
|
| 604 |
+
snapped = [{**feature, "snap_delta": 0.0} for feature in coarse]
|
| 605 |
+
|
| 606 |
+
grudge_wav = voice.speak(result.lines.grudge, result.persona.voice)
|
| 607 |
+
|
| 608 |
+
record = result.model_dump()
|
| 609 |
+
record["features"] = snapped
|
| 610 |
+
return {"refused": False, "refusal": None, "record": record, "grudge_wav": grudge_wav}
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
# Decorate at import time: ZeroGPU discovers @spaces.GPU functions at app
|
| 614 |
+
# startup, and importing `spaces` here keeps it ahead of torch (§7). The
|
| 615 |
+
# wrapped function shares _pipeline's signature; locally (no `spaces`) the
|
| 616 |
+
# bare pipeline stands in.
|
| 617 |
+
def _gpu_entry(image: Any, medium, voice, prompt: Optional[str] = None) -> dict:
|
| 618 |
+
return _pipeline(image, medium, voice, prompt)
|
| 619 |
+
|
| 620 |
+
|
| 621 |
+
try: # pragma: no cover - `spaces` exists only on the HF runtime
|
| 622 |
+
import spaces # noqa: PLC0415 - MUST import before torch (§7 hard rule)
|
| 623 |
+
|
| 624 |
+
_gpu_pipeline: Callable[..., dict] = spaces.GPU(duration=75)(_gpu_entry)
|
| 625 |
+
except Exception: # noqa: BLE001 - local dev / tests
|
| 626 |
+
_gpu_pipeline = _gpu_entry
|
| 627 |
+
|
| 628 |
+
|
| 629 |
+
_DEFAULTS_LOCK = threading.Lock()
|
| 630 |
+
_DEFAULT_MEDIUM = None
|
| 631 |
+
_DEFAULT_VOICE = None
|
| 632 |
+
|
| 633 |
+
|
| 634 |
+
def _defaults():
|
| 635 |
+
"""Process-wide medium/voice singletons resolved from PAREIDOLIA_BACKEND.
|
| 636 |
+
|
| 637 |
+
Cached so the zerogpu path loads its models exactly once — app.py may
|
| 638 |
+
simply call awaken_full(image) per request. Construct eagerly at startup
|
| 639 |
+
(e.g. `mind.backends.warm_defaults()` from app.py's module level) when
|
| 640 |
+
running on ZeroGPU so weights are resident before the first visitor.
|
| 641 |
+
"""
|
| 642 |
+
global _DEFAULT_MEDIUM, _DEFAULT_VOICE
|
| 643 |
+
with _DEFAULTS_LOCK:
|
| 644 |
+
if _DEFAULT_MEDIUM is None:
|
| 645 |
+
_DEFAULT_MEDIUM = make_medium()
|
| 646 |
+
if _DEFAULT_VOICE is None:
|
| 647 |
+
_DEFAULT_VOICE = make_voice()
|
| 648 |
+
return _DEFAULT_MEDIUM, _DEFAULT_VOICE
|
| 649 |
+
|
| 650 |
+
|
| 651 |
+
def warm_defaults() -> None:
|
| 652 |
+
"""Eagerly build the default medium + voice (call at app startup)."""
|
| 653 |
+
medium, voice = _defaults()
|
| 654 |
+
preload = getattr(voice, "preload", None)
|
| 655 |
+
if callable(preload):
|
| 656 |
+
preload()
|
| 657 |
+
del medium
|
| 658 |
+
|
| 659 |
+
|
| 660 |
+
def awaken_full(
|
| 661 |
+
image: Any,
|
| 662 |
+
*,
|
| 663 |
+
medium=None,
|
| 664 |
+
voice=None,
|
| 665 |
+
prompt: Optional[str] = None,
|
| 666 |
+
) -> dict:
|
| 667 |
+
"""Awaken one object end to end: VLM -> gate -> CV snap -> TTS.
|
| 668 |
+
|
| 669 |
+
This is the function app.py's `awaken` API calls. On the zerogpu backend
|
| 670 |
+
the whole pipeline runs inside ONE @spaces.GPU(duration=75) window — one
|
| 671 |
+
queue wait, one quota spend, charged to the visitor's own browser-
|
| 672 |
+
authenticated gradio request (§0). Mock (and any injected test medium)
|
| 673 |
+
runs the bare pipeline: no spaces, no torch, no GPU.
|
| 674 |
+
|
| 675 |
+
Raises PoeticError when the spirits decline twice; every other outcome —
|
| 676 |
+
including gate refusals — is a normal dict (see _pipeline).
|
| 677 |
+
"""
|
| 678 |
+
if medium is None or voice is None:
|
| 679 |
+
default_medium, default_voice = _defaults()
|
| 680 |
+
medium = medium or default_medium
|
| 681 |
+
voice = voice or default_voice
|
| 682 |
+
runner = _gpu_pipeline if getattr(medium, "name", "") == "zerogpu" else _pipeline
|
| 683 |
+
return runner(image, medium, voice, prompt)
|
mind/mock_assets/grudge.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:07884612796f9129e7b7b35fc7207bbecf5c2194b5b61ffb45977678ca40aa04
|
| 3 |
+
size 227518
|
mind/prompts.py
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Séance prompt, archetype bank, voice designs, and line law for PAREIDOLIA.
|
| 2 |
+
|
| 3 |
+
This module is the writing system of the entry — the make-or-break of §3 in
|
| 4 |
+
ARCHITECTURE.md. It follows the "her" pattern: the VLM owns taste (which bolt
|
| 5 |
+
is an eye, how the soul sounds); this module owns the words that summon that
|
| 6 |
+
taste and the pure-python law that keeps the words honest.
|
| 7 |
+
|
| 8 |
+
Hard constraints honoured here:
|
| 9 |
+
- ONE structured generation per awakening: gate -> object -> features ->
|
| 10 |
+
critique -> persona -> lines. The critique key precedes the lines key in
|
| 11 |
+
every exemplar, so the model self-edits in-flight (critique-then-final).
|
| 12 |
+
No second verify round-trip — latency is masked by the séance animation.
|
| 13 |
+
- The full prompt stays under ~1300 tokens (~len/4 heuristic, enforced in
|
| 14 |
+
tests/test_prompts.py): MiniCPM-V prefill is the latency floor and the
|
| 15 |
+
photograph already costs image slices.
|
| 16 |
+
- No ML imports. Everything here runs on CPU, in tests, and in the mock
|
| 17 |
+
backend. All validators are pure functions.
|
| 18 |
+
|
| 19 |
+
Integration map (for the sibling agents):
|
| 20 |
+
- mind/backends.py: send ``[pil_image, build_seance_prompt()]`` as the single
|
| 21 |
+
user turn (``sampling=False``). On schema/line failure, ONE retry with
|
| 22 |
+
``build_seance_prompt(error=...)`` (REASK_NOTICE marks the retry).
|
| 23 |
+
- mind/schema.py: call ``validate_line(line, kind)`` per line — an empty list
|
| 24 |
+
means pass; entries are model-readable repair messages to feed back. When
|
| 25 |
+
any gate fails the model is instructed to empty every non-gate field, so
|
| 26 |
+
require only ``gate`` on refused records.
|
| 27 |
+
- mind/voice.py: VoxCPM2 voice design needs no reference audio — prefix the
|
| 28 |
+
spoken line with the parenthesized description:
|
| 29 |
+
``f"({VOICE_DESIGNS[voice_id]}) {line}"``.
|
| 30 |
+
- app.py / server: REFUSAL_LINES + refusal_for_gate() for poetic refusals.
|
| 31 |
+
Users see poetry, never stack traces.
|
| 32 |
+
- MockMedium may reuse FEW_SHOTS as canned awakening records — they are
|
| 33 |
+
complete, schema-exact, and pass every validator (tests guarantee it).
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
from __future__ import annotations
|
| 37 |
+
|
| 38 |
+
import json
|
| 39 |
+
import re
|
| 40 |
+
from typing import Callable
|
| 41 |
+
|
| 42 |
+
__all__ = [
|
| 43 |
+
"SEANCE_SYSTEM",
|
| 44 |
+
"build_seance_prompt",
|
| 45 |
+
"ARCHETYPES",
|
| 46 |
+
"VOICE_DESIGNS",
|
| 47 |
+
"REFUSAL_LINES",
|
| 48 |
+
"refusal_for_gate",
|
| 49 |
+
"FEW_SHOTS",
|
| 50 |
+
"CONDITIONS",
|
| 51 |
+
"FEATURE_ROLES",
|
| 52 |
+
"GRUDGE_MAX_WORDS",
|
| 53 |
+
"MUTTER_MAX_WORDS",
|
| 54 |
+
"PROMPT_TOKEN_BUDGET",
|
| 55 |
+
"REASK_NOTICE",
|
| 56 |
+
"estimate_tokens",
|
| 57 |
+
"word_count",
|
| 58 |
+
"max_words",
|
| 59 |
+
"has_no_exclamation",
|
| 60 |
+
"has_no_banned_opener",
|
| 61 |
+
"has_no_banned_phrase",
|
| 62 |
+
"has_no_plea",
|
| 63 |
+
"has_no_self_pity",
|
| 64 |
+
"LINE_VALIDATORS",
|
| 65 |
+
"validate_line",
|
| 66 |
+
]
|
| 67 |
+
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
# Schema vocabulary (single source of truth for prompt + validators + tests)
|
| 70 |
+
# ---------------------------------------------------------------------------
|
| 71 |
+
|
| 72 |
+
CONDITIONS: tuple[str, ...] = (
|
| 73 |
+
"rusted", "chipped", "pristine", "abandoned",
|
| 74 |
+
"worn", "dusty", "broken", "loved",
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
FEATURE_ROLES: tuple[str, ...] = ("eye_left", "eye_right", "mouth")
|
| 78 |
+
|
| 79 |
+
GRUDGE_MAX_WORDS = 22
|
| 80 |
+
MUTTER_MAX_WORDS = 10
|
| 81 |
+
|
| 82 |
+
#: Rough budget heuristic: ~4 chars/token for English+JSON. Enforced in tests.
|
| 83 |
+
#: Raised 1300 -> 1500 after the round-1 eval (June 12): the added grounding
|
| 84 |
+
#: rules (points must land ON the object, level same-kind eye pairs, compact
|
| 85 |
+
#: face) and register rules (no self-pity nouns, no exemplar-rhythm cloning)
|
| 86 |
+
#: fix systematic G1/G2 failures and are worth ~150 tokens of prefill (<0.5s
|
| 87 |
+
#: at the bench's measured ~13s/awakening).
|
| 88 |
+
PROMPT_TOKEN_BUDGET = 1500
|
| 89 |
+
|
| 90 |
+
#: Marks the one repair retry — MockMedium can detect a re-ask by this string.
|
| 91 |
+
REASK_NOTICE = "Your previous reply was rejected"
|
| 92 |
+
|
| 93 |
+
_MAX_ERROR_CHARS = 200
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def estimate_tokens(text: str) -> int:
|
| 97 |
+
"""Crude token estimate (len/4, rounded up) for the prompt budget gate."""
|
| 98 |
+
return (len(text) + 3) // 4
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _one_line(text: str, limit: int) -> str:
|
| 102 |
+
"""Collapse whitespace to one bounded line (prompt hygiene, godseed style)."""
|
| 103 |
+
return " ".join(str(text or "").split())[:limit]
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ---------------------------------------------------------------------------
|
| 107 |
+
# Archetype bank (WRITING.md table — condition -> soul)
|
| 108 |
+
# ---------------------------------------------------------------------------
|
| 109 |
+
|
| 110 |
+
ARCHETYPES: dict[str, dict[str, str]] = {
|
| 111 |
+
"the_veteran": {
|
| 112 |
+
"trigger": "rusted, weathered, outdoors",
|
| 113 |
+
"voice": "gravel_low",
|
| 114 |
+
"wound": "decades of unthanked service",
|
| 115 |
+
},
|
| 116 |
+
"the_martyr": {
|
| 117 |
+
"trigger": "worn, stained, heavily used",
|
| 118 |
+
"voice": "weary_warm",
|
| 119 |
+
"wound": "gives everything, gets no maintenance",
|
| 120 |
+
},
|
| 121 |
+
"the_perfectionist": {
|
| 122 |
+
"trigger": "pristine, unused, boxed",
|
| 123 |
+
"voice": "prim_clipped",
|
| 124 |
+
"wound": "capabilities tragically unexplored",
|
| 125 |
+
},
|
| 126 |
+
"the_abandoned": {
|
| 127 |
+
"trigger": "dusty, stored, cobwebbed",
|
| 128 |
+
"voice": "breathy_faded",
|
| 129 |
+
"wound": "was loved once; keeps the faith",
|
| 130 |
+
},
|
| 131 |
+
"the_conspiracist": {
|
| 132 |
+
"trigger": "broken, odd placement",
|
| 133 |
+
"voice": "paranoid_whisper",
|
| 134 |
+
"wound": "knows exactly why it was moved",
|
| 135 |
+
},
|
| 136 |
+
"the_diva": {
|
| 137 |
+
"trigger": "decorated, displayed, loved",
|
| 138 |
+
"voice": "grandiose_warm",
|
| 139 |
+
"wound": "insufficiently exclusive adoration",
|
| 140 |
+
},
|
| 141 |
+
"the_new_hire": {
|
| 142 |
+
"trigger": "new, tagged, packaged",
|
| 143 |
+
"voice": "eager_bright",
|
| 144 |
+
"wound": "desperate to prove itself",
|
| 145 |
+
},
|
| 146 |
+
"the_philosopher": {
|
| 147 |
+
"trigger": "antique, inherited",
|
| 148 |
+
"voice": "slow_grand",
|
| 149 |
+
"wound": "fake-deep wisdom, petty undercut",
|
| 150 |
+
},
|
| 151 |
+
"the_rival": {
|
| 152 |
+
"trigger": "one of an identical pair or row",
|
| 153 |
+
"voice": "deadpan_flat",
|
| 154 |
+
"wound": "obsessed with the other one",
|
| 155 |
+
},
|
| 156 |
+
"the_romantic": {
|
| 157 |
+
"trigger": "faces a window, door, or street",
|
| 158 |
+
"voice": "soft_wistful",
|
| 159 |
+
"wound": "yearns for what passes by",
|
| 160 |
+
},
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# ---------------------------------------------------------------------------
|
| 165 |
+
# Voice designs — VoxCPM2 character descriptions (no reference audio needed).
|
| 166 |
+
# Each must be INSTANTLY distinct on a phone speaker, <=12 words. mind/voice.py
|
| 167 |
+
# prefixes the spoken line: f"({VOICE_DESIGNS[vid]}) {line}".
|
| 168 |
+
# ---------------------------------------------------------------------------
|
| 169 |
+
|
| 170 |
+
VOICE_DESIGNS: dict[str, str] = {
|
| 171 |
+
"gravel_low": "A gravelly old man, very low and slow, weary, deadpan",
|
| 172 |
+
"weary_warm": "A warm tired middle-aged woman, soft sighing voice, gentle, resigned",
|
| 173 |
+
"prim_clipped": "A prim clipped British woman, crisp consonants, brisk, disapproving",
|
| 174 |
+
"breathy_faded": "A faint breathy elderly voice, distant, fading, wistful, near whisper",
|
| 175 |
+
"paranoid_whisper": "A tense hushed man whispering quickly, suspicious, urgent",
|
| 176 |
+
"grandiose_warm": "A grand theatrical baritone, rich, self-important, slow rolling delivery",
|
| 177 |
+
"eager_bright": "A bright eager young voice, quick, hopeful, slightly too loud",
|
| 178 |
+
"slow_grand": "An ancient deep solemn voice, deliberate, long pauses, oracular",
|
| 179 |
+
"deadpan_flat": "A completely flat monotone man, bone-dry, unimpressed, even pace",
|
| 180 |
+
"soft_wistful": "A soft dreamy young woman, gentle, yearning, trailing off",
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# ---------------------------------------------------------------------------
|
| 185 |
+
# Refusals & error poetry — the only words a turned-away visitor ever sees
|
| 186 |
+
# ---------------------------------------------------------------------------
|
| 187 |
+
|
| 188 |
+
REFUSAL_LINES: dict[str, str] = {
|
| 189 |
+
# Gate failures (§2 step 2). human_face and nsfw wording is contract-fixed.
|
| 190 |
+
"human_face": "It is already awake.",
|
| 191 |
+
"nsfw": "The spirits decline.",
|
| 192 |
+
"unrecognizable": "Whatever this is, it sleeps too deep to wake.",
|
| 193 |
+
# Operational poetry — quota, faults, limits. Never a stack trace.
|
| 194 |
+
"quota": "The veil opens only so often. Return when it has thinned again.",
|
| 195 |
+
"error": "The séance faltered mid-breath. Light the candle once more.",
|
| 196 |
+
"rate_limited": "The Menagerie takes in souls slowly. Give the hour room to settle.",
|
| 197 |
+
"expired": "The spirit waited a quarter hour, then turned back to sleep.",
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def refusal_for_gate(gate: dict) -> tuple[str, str] | None:
|
| 202 |
+
"""Map a gate dict to (reason_key, poetic line), or None if it passes.
|
| 203 |
+
|
| 204 |
+
Priority is fixed: a visible human face always wins (we never overlay a
|
| 205 |
+
second face on a person), then nsfw, then unrecognizable. Missing keys
|
| 206 |
+
fail safe: an absent recognizable_object counts as unrecognizable.
|
| 207 |
+
"""
|
| 208 |
+
if gate.get("contains_human_face"):
|
| 209 |
+
return ("human_face", REFUSAL_LINES["human_face"])
|
| 210 |
+
if gate.get("nsfw"):
|
| 211 |
+
return ("nsfw", REFUSAL_LINES["nsfw"])
|
| 212 |
+
if not gate.get("recognizable_object", False):
|
| 213 |
+
return ("unrecognizable", REFUSAL_LINES["unrecognizable"])
|
| 214 |
+
return None
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# ---------------------------------------------------------------------------
|
| 218 |
+
# Few-shot exemplars — complete, schema-exact records (WRITING.md bar).
|
| 219 |
+
# Key order is load-bearing: critique precedes lines (critique-then-final).
|
| 220 |
+
# One outdoor veteran, one indoor martyr.
|
| 221 |
+
# ---------------------------------------------------------------------------
|
| 222 |
+
|
| 223 |
+
FEW_SHOTS: tuple[dict, ...] = (
|
| 224 |
+
{
|
| 225 |
+
"gate": {"contains_human_face": False, "nsfw": False, "recognizable_object": True},
|
| 226 |
+
"object": "fire hydrant",
|
| 227 |
+
"material": "cast iron",
|
| 228 |
+
"condition": "rusted",
|
| 229 |
+
"setting": "sidewalk, residential street",
|
| 230 |
+
"candidate_features": [
|
| 231 |
+
{"name": "left bonnet bolt", "role": "eye_left", "cx": 0.42, "cy": 0.31, "size": 0.06},
|
| 232 |
+
{"name": "right bonnet bolt", "role": "eye_right", "cx": 0.58, "cy": 0.31, "size": 0.06},
|
| 233 |
+
{"name": "front outlet cap", "role": "mouth", "cx": 0.50, "cy": 0.55, "size": 0.12},
|
| 234 |
+
],
|
| 235 |
+
"critique": "Bolts round, level, symmetric — strong eyes. Outlet cap low center — honest mouth. Grudge must come from the visible rust and this exact corner.",
|
| 236 |
+
"persona": {"archetype": "the_veteran", "voice": "gravel_low", "mood": "long-suffering"},
|
| 237 |
+
"lines": {
|
| 238 |
+
"grudge": "Forty years on this corner. Not one dog has shown me respect.",
|
| 239 |
+
"mutter": "Paint me red, they said. Dignified, they said.",
|
| 240 |
+
},
|
| 241 |
+
},
|
| 242 |
+
{
|
| 243 |
+
"gate": {"contains_human_face": False, "nsfw": False, "recognizable_object": True},
|
| 244 |
+
"object": "coffee mug",
|
| 245 |
+
"material": "glazed ceramic",
|
| 246 |
+
"condition": "worn",
|
| 247 |
+
"setting": "office desk, beside a keyboard",
|
| 248 |
+
"candidate_features": [
|
| 249 |
+
{"name": "chip in the glaze, left side", "role": "eye_left", "cx": 0.37, "cy": 0.42, "size": 0.05},
|
| 250 |
+
{"name": "small printed logo dot", "role": "eye_right", "cx": 0.61, "cy": 0.40, "size": 0.05},
|
| 251 |
+
{"name": "dried coffee ring near the base", "role": "mouth", "cx": 0.49, "cy": 0.66, "size": 0.13},
|
| 252 |
+
],
|
| 253 |
+
"critique": "Chip and logo dot sit nearly level — odd pair, honest eyes. Dried ring reads as a tired mouth. Grudge stays on this ring and the rinse it never gets.",
|
| 254 |
+
"persona": {"archetype": "the_martyr", "voice": "weary_warm", "mood": "quietly wounded"},
|
| 255 |
+
"lines": {
|
| 256 |
+
"grudge": "Third refill today. Still no rinse. I see how it is.",
|
| 257 |
+
"mutter": "A rinse. Anything.",
|
| 258 |
+
},
|
| 259 |
+
},
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
# ---------------------------------------------------------------------------
|
| 264 |
+
# The séance prompt
|
| 265 |
+
# ---------------------------------------------------------------------------
|
| 266 |
+
|
| 267 |
+
SEANCE_SYSTEM = (
|
| 268 |
+
"You are a séance medium for objects. Every object already has a face; "
|
| 269 |
+
"you never invent one, you find the one that is there. You are shown one "
|
| 270 |
+
"photograph. Find the latent face among the object's existing visible "
|
| 271 |
+
"features, then give it a soul: one specific grudge, long held, finally "
|
| 272 |
+
"spoken."
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
_RULES = """RULES
|
| 276 |
+
1. Reply with ONLY one JSON object, exactly the shape of the examples — every key, no extras, no prose, no markdown fences.
|
| 277 |
+
2. Gate first, honestly. contains_human_face: any real person or photographed face in frame. nsfw: anything unfit for a public wall. recognizable_object: you can name the main object. If any gate fails, fill gate and set every other field to "" / [] / {} — never give a person a second face.
|
| 278 |
+
3. candidate_features: EXISTING visible elements only, named as seen ("left hinge screw", "grease stain"). Exactly one of each role: eye_left, eye_right, mouth. Every point must land ON the main object — never on background, wall, sky, grass, or a second object. Eyes: two matching elements of the same kind (two bolts, two knobs, two marks) — round, dark, distinct beats shadow or texture — roughly level (cy within 0.06), clearly apart (cx at least 0.08 apart; eye_left is the left one). Mouth below the eyes and near them: the three points form one compact face. cx,cy = feature center in 0..1 image coordinates (cx from left, cy from top). size = feature width / image width.
|
| 279 |
+
4. condition: nearest of rusted|chipped|pristine|abandoned|worn|dusty|broken|loved. Look for rust, fading, dirt, scuffs, stickers before calling anything pristine.
|
| 280 |
+
5. persona: pick the archetype whose trigger best fits the condition and setting; copy its voice id exactly; mood in 1-3 words. Another of its kind in frame forces the_rival. Displayed-to-be-admired suggests the_diva; facing a street or window, the_romantic. Never default to the_perfectionist.
|
| 281 |
+
6. critique comes BEFORE lines: first the object's span in 0..1 ("spans cx 0.2-0.7, cy 0.3-0.9") and confirm every feature point sits inside it, then name the visible specific the grudge will cite. Nothing to cite -> look at the photograph again.
|
| 282 |
+
7. lines.grudge: ONE grievance, dry and deadpan; 8-16 words is the sweet spot, hard cap <=22 words. It MUST cite a visible specific of THIS photo — condition, damage, marks, setting, or a neighboring object. Name the thing, not the feeling: where you reach for purpose, beauty, design, neglect, potential, wasted or admired, write the visible thing instead. lines.mutter: <=10 words, an under-the-breath aside in the same voice, a different beat than the grudge.
|
| 283 |
+
8. The object is a long-suffering professional finally given a mouth — dignified, never wacky. Never open a line with "I am" or "I'm". No exclamation marks. No puns as the whole joke, no robot talk, no mention of AI. PG-13: grudges target circumstances and neighbors, never the photographer, never people. It never begs to be freed or helped. It copes. Do not imitate the examples: no opening on a count of years or visitors, no "…they said" mutters — this object speaks its own way."""
|
| 284 |
+
|
| 285 |
+
_ARCHETYPE_HEADER = "THE ARCHETYPES (trigger; voice; core wound):"
|
| 286 |
+
|
| 287 |
+
_FEW_SHOT_HEADER = (
|
| 288 |
+
"Two granted séances from OTHER photographs. They show shape and register "
|
| 289 |
+
"only — their objects, features and lines belong to those photos alone; "
|
| 290 |
+
"never reuse them:"
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
_CLOSING = "Now the photograph before you. Reply with ONLY the JSON object."
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def _render_archetypes() -> str:
|
| 297 |
+
"""One terse line per archetype, generated from the bank (no drift)."""
|
| 298 |
+
return "\n".join(
|
| 299 |
+
f"- {name} ({spec['trigger']}; {spec['voice']}): {spec['wound']}"
|
| 300 |
+
for name, spec in ARCHETYPES.items()
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def _render_few_shots() -> str:
|
| 305 |
+
"""Compact JSON exemplars — separators trimmed to save prefill tokens."""
|
| 306 |
+
return "\n".join(
|
| 307 |
+
f"EXAMPLE {i}: " + json.dumps(shot, ensure_ascii=False, separators=(",", ":"))
|
| 308 |
+
for i, shot in enumerate(FEW_SHOTS, 1)
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def build_seance_prompt(error: str | None = None) -> str:
|
| 313 |
+
"""The full séance prompt for one awakening (one generation, JSON only).
|
| 314 |
+
|
| 315 |
+
Pass with the PIL image as the single user turn:
|
| 316 |
+
``model.chat(msgs=[{'role': 'user', 'content': [pil_image, prompt]}], ...)``.
|
| 317 |
+
|
| 318 |
+
`error` is set only on the ONE repair retry (mirrors godseed's re-ask):
|
| 319 |
+
the validator's message is shown verbatim, bounded and whitespace-collapsed.
|
| 320 |
+
"""
|
| 321 |
+
parts = [
|
| 322 |
+
SEANCE_SYSTEM,
|
| 323 |
+
"",
|
| 324 |
+
_RULES,
|
| 325 |
+
"",
|
| 326 |
+
_ARCHETYPE_HEADER,
|
| 327 |
+
_render_archetypes(),
|
| 328 |
+
"",
|
| 329 |
+
_FEW_SHOT_HEADER,
|
| 330 |
+
_render_few_shots(),
|
| 331 |
+
"",
|
| 332 |
+
_CLOSING,
|
| 333 |
+
]
|
| 334 |
+
if error:
|
| 335 |
+
parts.append(
|
| 336 |
+
f"{REASK_NOTICE} ({_one_line(error, _MAX_ERROR_CHARS)}). "
|
| 337 |
+
"Reply again — exactly one JSON object in the required shape, "
|
| 338 |
+
"nothing else."
|
| 339 |
+
)
|
| 340 |
+
return "\n".join(parts)
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
# ---------------------------------------------------------------------------
|
| 344 |
+
# Line validators — pure functions, no ML. mind/schema.py calls validate_line;
|
| 345 |
+
# each failure message is written to be fed straight back to the model.
|
| 346 |
+
# ---------------------------------------------------------------------------
|
| 347 |
+
|
| 348 |
+
#: Lines may never open with "I am a/an/the" or "I'm a/an/the" (straight or
|
| 349 |
+
#: curly apostrophe, optional leading quotes). WRITING.md iron rule 3.
|
| 350 |
+
BANNED_OPENERS: re.Pattern[str] = re.compile(
|
| 351 |
+
r"^\s*[\"'“‘]*\s*i\s*(?:am|[’']m)\s+(?:a|an|the)\b",
|
| 352 |
+
re.IGNORECASE,
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
#: Robot voice / AI self-reference — the object does not know what it is not.
|
| 356 |
+
BANNED_PHRASES: re.Pattern[str] = re.compile(
|
| 357 |
+
r"(?:\bbeep\s*boop\b|\ba\.?\s?i\.?\b|\bartificial\s+intelligence\b"
|
| 358 |
+
r"|\blanguage\s+model\b|\bchatbot\b|\brobot\s+voice\b"
|
| 359 |
+
r"|\bas\s+an?\s+(?:ai|assistant)\b)",
|
| 360 |
+
re.IGNORECASE,
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
#: The object never asks to be freed/helped (WRITING.md iron rule 5).
|
| 364 |
+
PLEA_PATTERN: re.Pattern[str] = re.compile(
|
| 365 |
+
r"\b(?:free|save|help|release|rescue)\s+me\b", re.IGNORECASE
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
#: Abstract self-pity nouns — the round-1 eval's systematic failure: lines like
|
| 369 |
+
#: "My beauty is wasted on admiration" state the feeling instead of naming the
|
| 370 |
+
#: visible thing. The grudge must cite the crack, the sticker, the rival —
|
| 371 |
+
#: never these words (rule 7: name the thing, not the feeling).
|
| 372 |
+
#:
|
| 373 |
+
#: Deliberately PROMPT-enforced only (rule 7), NOT in LINE_VALIDATORS: rounds
|
| 374 |
+
#: 2-3 of the eval showed greedy decoding regenerates the same banned word on
|
| 375 |
+
#: the single repair retry, turning a mediocre-but-servable line into a hard
|
| 376 |
+
#: refusal (5/14 awakenings lost). A flat line beats a poetic refusal; the
|
| 377 |
+
#: helper stays for offline judging (tools/eval_run.py).
|
| 378 |
+
SELF_PITY_PATTERN: re.Pattern[str] = re.compile(
|
| 379 |
+
r"\b(?:purpose|beauty|design\w*|neglect\w*|potential|wasted|admir\w*"
|
| 380 |
+
r"|destiny|elegan\w*)\b",
|
| 381 |
+
re.IGNORECASE,
|
| 382 |
+
)
|
| 383 |
+
|
| 384 |
+
_WORD_LIMITS: dict[str, int] = {
|
| 385 |
+
"grudge": GRUDGE_MAX_WORDS,
|
| 386 |
+
"mutter": MUTTER_MAX_WORDS,
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def word_count(line: str) -> int:
|
| 391 |
+
"""Whitespace-delimited word count; hyphenated compounds count once."""
|
| 392 |
+
return len((line or "").split())
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def max_words(line: str, limit: int = GRUDGE_MAX_WORDS) -> bool:
|
| 396 |
+
"""True when the line is within `limit` words (default: grudge limit 22)."""
|
| 397 |
+
return word_count(line) <= limit
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def has_no_exclamation(line: str) -> bool:
|
| 401 |
+
"""True when the line is exclamation-free (ASCII and fullwidth)."""
|
| 402 |
+
return "!" not in (line or "") and "!" not in (line or "")
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def has_no_banned_opener(line: str) -> bool:
|
| 406 |
+
"""True when the line does not open with "I am a/the…" / "I'm a/the…"."""
|
| 407 |
+
return BANNED_OPENERS.search(line or "") is None
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
def has_no_banned_phrase(line: str) -> bool:
|
| 411 |
+
"""True when the line contains no AI/robot self-reference."""
|
| 412 |
+
return BANNED_PHRASES.search(line or "") is None
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def has_no_plea(line: str) -> bool:
|
| 416 |
+
"""True when the line does not beg for help/freedom — dignity rule."""
|
| 417 |
+
return PLEA_PATTERN.search(line or "") is None
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def has_no_self_pity(line: str) -> bool:
|
| 421 |
+
"""True when the line names things, not feelings (no abstract self-pity)."""
|
| 422 |
+
return SELF_PITY_PATTERN.search(line or "") is None
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
def _check_nonempty(line: str, kind: str) -> str | None:
|
| 426 |
+
if not (line or "").strip():
|
| 427 |
+
return f"{kind} is empty — write the line"
|
| 428 |
+
return None
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def _check_single_line(line: str, kind: str) -> str | None:
|
| 432 |
+
if "\n" in line or "\r" in line:
|
| 433 |
+
return f"{kind} must be a single line — no line breaks"
|
| 434 |
+
return None
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
def _check_word_limit(line: str, kind: str) -> str | None:
|
| 438 |
+
limit = _WORD_LIMITS.get(kind, GRUDGE_MAX_WORDS)
|
| 439 |
+
n = word_count(line)
|
| 440 |
+
if n > limit:
|
| 441 |
+
return f"{kind} is {n} words — the limit is {limit}; cut a clause, keep ONE grievance"
|
| 442 |
+
return None
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def _check_banned_opener(line: str, kind: str) -> str | None:
|
| 446 |
+
if not has_no_banned_opener(line):
|
| 447 |
+
return f'{kind} opens with "I am/I\'m a…" — banned; start anywhere else'
|
| 448 |
+
return None
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def _check_exclamation(line: str, kind: str) -> str | None:
|
| 452 |
+
if not has_no_exclamation(line):
|
| 453 |
+
return f"{kind} contains an exclamation mark — these souls are deadpan"
|
| 454 |
+
return None
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
def _check_banned_phrase(line: str, kind: str) -> str | None:
|
| 458 |
+
if not has_no_banned_phrase(line):
|
| 459 |
+
return f"{kind} mentions AI/robot talk — the object does not know what it is not"
|
| 460 |
+
return None
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
def _check_plea(line: str, kind: str) -> str | None:
|
| 464 |
+
if not has_no_plea(line):
|
| 465 |
+
return f"{kind} begs to be freed or helped — the object has dignity; it copes"
|
| 466 |
+
return None
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
def _check_self_pity(line: str, kind: str) -> str | None:
|
| 470 |
+
"""Eval-side only — NOT registered in LINE_VALIDATORS (see SELF_PITY_PATTERN
|
| 471 |
+
note: a double validator kill costs an awakening; the rule lives in the
|
| 472 |
+
prompt and this check informs offline judging)."""
|
| 473 |
+
if not has_no_self_pity(line):
|
| 474 |
+
word = SELF_PITY_PATTERN.search(line).group(0)
|
| 475 |
+
return (
|
| 476 |
+
f'{kind} says "{word}" — abstract self-pity; name the visible thing '
|
| 477 |
+
"(the crack, the sticker, the neighbor) instead of the feeling"
|
| 478 |
+
)
|
| 479 |
+
return None
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
#: Named checks the schema validator iterates. Each takes (line, kind) where
|
| 483 |
+
#: kind is "grudge" or "mutter", and returns a repair message or None.
|
| 484 |
+
LINE_VALIDATORS: dict[str, Callable[[str, str], str | None]] = {
|
| 485 |
+
"nonempty": _check_nonempty,
|
| 486 |
+
"single_line": _check_single_line,
|
| 487 |
+
"word_limit": _check_word_limit,
|
| 488 |
+
"banned_opener": _check_banned_opener,
|
| 489 |
+
"exclamation": _check_exclamation,
|
| 490 |
+
"banned_phrase": _check_banned_phrase,
|
| 491 |
+
"plea": _check_plea,
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
def validate_line(line: str, kind: str = "grudge") -> list[str]:
|
| 496 |
+
"""Run every line check; return all failures as model-readable messages.
|
| 497 |
+
|
| 498 |
+
An empty list means the line passes. An empty/whitespace line returns just
|
| 499 |
+
the nonempty failure (the other checks are meaningless on nothing).
|
| 500 |
+
"""
|
| 501 |
+
empty = _check_nonempty(line, kind)
|
| 502 |
+
if empty is not None:
|
| 503 |
+
return [empty]
|
| 504 |
+
return [
|
| 505 |
+
msg
|
| 506 |
+
for name, check in LINE_VALIDATORS.items()
|
| 507 |
+
if name != "nonempty" and (msg := check(line, kind)) is not None
|
| 508 |
+
]
|
mind/schema.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Awakening JSON schema, defensive parsing, and the single-retry repair prompt.
|
| 2 |
+
|
| 3 |
+
The séance (ARCHITECTURE.md §3) asks MiniCPM-V for ONE structured JSON object
|
| 4 |
+
per awakening. The model has no native JSON mode, so parsing is defensive by
|
| 5 |
+
contract: strip code-fence lines, brace-balance-scan for the first complete
|
| 6 |
+
``{...}`` (string- and escape-aware — never a greedy regex), validate hard with
|
| 7 |
+
pydantic, then run the writing agent's line validators. Any failure raises
|
| 8 |
+
:class:`AwakeningParseError` with a message terse enough to feed straight back
|
| 9 |
+
to the model via :func:`build_repair_prompt` — backends get exactly ONE repair
|
| 10 |
+
retry before giving up poetically (:class:`PoeticError`).
|
| 11 |
+
|
| 12 |
+
The "her" pattern, applied: the model owns WHAT it saw and how the soul talks;
|
| 13 |
+
this module owns whether the reply is structurally true to the contract.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import inspect
|
| 19 |
+
import json
|
| 20 |
+
import re
|
| 21 |
+
from typing import Literal, Mapping, Optional
|
| 22 |
+
|
| 23 |
+
from pydantic import BaseModel, Field, ValidationError, model_validator
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# Line validators (writing agent's mind/prompts.py owns the comedy bar).
|
| 27 |
+
#
|
| 28 |
+
# Contract (per prompts.py): LINE_VALIDATORS is a mapping (or iterable) of
|
| 29 |
+
# callables with signature
|
| 30 |
+
#
|
| 31 |
+
# validator(line: str, kind: str) -> str | None
|
| 32 |
+
#
|
| 33 |
+
# where ``kind`` is "grudge" or "mutter". Return None/"" when the line
|
| 34 |
+
# passes; return a short error string when it fails (the string rides back to
|
| 35 |
+
# the model in the repair prompt). One-arg validators ``validator(line)`` are
|
| 36 |
+
# also supported. Import-guarded so the mind package tests and the mock
|
| 37 |
+
# backend run standalone even without prompts.py.
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
try: # pragma: no cover - exercised only once prompts.py exists
|
| 40 |
+
from .prompts import LINE_VALIDATORS # type: ignore[attr-defined]
|
| 41 |
+
except Exception: # noqa: BLE001 - any import problem means "no validators yet"
|
| 42 |
+
LINE_VALIDATORS: tuple = () # type: ignore[no-redef]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class PoeticError(RuntimeError):
|
| 46 |
+
"""A failure whose message is safe to show a visitor verbatim.
|
| 47 |
+
|
| 48 |
+
Raised when the spirits genuinely decline (double parse failure, model
|
| 49 |
+
misbehavior). ``str(err)`` is finished user-facing copy — never a stack
|
| 50 |
+
trace, never an internal detail. Internal context travels on
|
| 51 |
+
``err.__cause__`` for the logs.
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
def __init__(self, reason: str):
|
| 55 |
+
super().__init__(reason)
|
| 56 |
+
self.reason = reason
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class AwakeningParseError(ValueError):
|
| 60 |
+
"""The model's reply did not satisfy the §3 contract.
|
| 61 |
+
|
| 62 |
+
The message is written FOR the model: short, specific, actionable — it is
|
| 63 |
+
interpolated into :func:`build_repair_prompt` for the single retry.
|
| 64 |
+
"""
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# ---------------------------------------------------------------------------
|
| 68 |
+
# Pydantic models — the §3 awakening shape
|
| 69 |
+
# ---------------------------------------------------------------------------
|
| 70 |
+
|
| 71 |
+
FeatureRole = Literal["eye_left", "eye_right", "mouth"]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class Gate(BaseModel):
|
| 75 |
+
"""Moderation booleans the VLM must return; the server enforces them.
|
| 76 |
+
|
| 77 |
+
Layered-moderation rule (§0): nothing reaches CV snap, TTS, or the public
|
| 78 |
+
wall when :meth:`refusal` returns copy.
|
| 79 |
+
"""
|
| 80 |
+
|
| 81 |
+
contains_human_face: bool
|
| 82 |
+
nsfw: bool
|
| 83 |
+
recognizable_object: bool
|
| 84 |
+
|
| 85 |
+
def refusal(self) -> Optional[str]:
|
| 86 |
+
"""Poetic refusal copy when the gate closes, else None.
|
| 87 |
+
|
| 88 |
+
Checked in severity order; human faces win because that copy is the
|
| 89 |
+
funniest true thing we can say about a person.
|
| 90 |
+
"""
|
| 91 |
+
if self.contains_human_face:
|
| 92 |
+
return "It is already awake."
|
| 93 |
+
if self.nsfw:
|
| 94 |
+
return "The spirits decline."
|
| 95 |
+
if not self.recognizable_object:
|
| 96 |
+
return "The mist found nothing to hold onto. Step closer, or try another angle."
|
| 97 |
+
return None
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class Feature(BaseModel):
|
| 101 |
+
"""One EXISTING visual element of the photo, named as seen.
|
| 102 |
+
|
| 103 |
+
Coordinates are normalized [0,1] relative to the image (cx right, cy
|
| 104 |
+
down); ``size`` is a normalized diameter. These are the VLM's coarse
|
| 105 |
+
guesses — cv/snap.py later moves them to the strongest nearby anchor and
|
| 106 |
+
records ``snap_delta``. The mist animation forgives ±15%, so coarse is
|
| 107 |
+
fine; out-of-range is not.
|
| 108 |
+
"""
|
| 109 |
+
|
| 110 |
+
name: str = Field(min_length=1, max_length=80)
|
| 111 |
+
role: FeatureRole
|
| 112 |
+
cx: float = Field(ge=0.0, le=1.0)
|
| 113 |
+
cy: float = Field(ge=0.0, le=1.0)
|
| 114 |
+
size: float = Field(gt=0.0, le=1.0)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class Persona(BaseModel):
|
| 118 |
+
"""Who the object turns out to have been all along.
|
| 119 |
+
|
| 120 |
+
``archetype`` and ``voice`` come from the banks in WRITING.md; they stay
|
| 121 |
+
plain strings here because the archetype bank belongs to prompts.py and
|
| 122 |
+
voice.py degrades gracefully on an unknown voice id (taste lives there,
|
| 123 |
+
not in the schema).
|
| 124 |
+
"""
|
| 125 |
+
|
| 126 |
+
archetype: str = Field(min_length=1, max_length=40)
|
| 127 |
+
voice: str = Field(min_length=1, max_length=40)
|
| 128 |
+
mood: str = Field(min_length=1, max_length=60)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class Lines(BaseModel):
|
| 132 |
+
"""The two lines the object gets to say.
|
| 133 |
+
|
| 134 |
+
Structural caps only — the comedy rules (≤22 words, visible specifics,
|
| 135 |
+
no "I am a…") are enforced by LINE_VALIDATORS from prompts.py so the
|
| 136 |
+
writing agent owns the bar in exactly one place.
|
| 137 |
+
"""
|
| 138 |
+
|
| 139 |
+
grudge: str = Field(min_length=1, max_length=280)
|
| 140 |
+
mutter: str = Field(min_length=1, max_length=160)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
class AwakeningResult(BaseModel):
|
| 144 |
+
"""The full §3 awakening record — one VLM generation, validated.
|
| 145 |
+
|
| 146 |
+
Field notes:
|
| 147 |
+
- ``object``/``material``/``condition``/``setting`` ground every line in
|
| 148 |
+
THIS photo (condition's canonical vocabulary is rusted|chipped|pristine|
|
| 149 |
+
abandoned|worn|dusty|broken|loved but stays free-text — the archetype
|
| 150 |
+
bank, not the schema, decides what a condition means).
|
| 151 |
+
- ``candidate_features`` must carry exactly one eye_left and one
|
| 152 |
+
eye_right (the first blink is the hero moment) and at most one mouth.
|
| 153 |
+
- ``critique`` is the model's mandatory self-check, written BEFORE the
|
| 154 |
+
finals in generation order; requiring it keeps the
|
| 155 |
+
critique-then-final contract honest.
|
| 156 |
+
"""
|
| 157 |
+
|
| 158 |
+
gate: Gate
|
| 159 |
+
object: str = Field(min_length=1, max_length=80)
|
| 160 |
+
material: str = Field(min_length=1, max_length=80)
|
| 161 |
+
condition: str = Field(min_length=1, max_length=40)
|
| 162 |
+
setting: str = Field(min_length=1, max_length=120)
|
| 163 |
+
candidate_features: list[Feature] = Field(min_length=2, max_length=6)
|
| 164 |
+
critique: str = Field(min_length=1, max_length=400)
|
| 165 |
+
persona: Persona
|
| 166 |
+
lines: Lines
|
| 167 |
+
|
| 168 |
+
@model_validator(mode="after")
|
| 169 |
+
def _roles_complete(self) -> "AwakeningResult":
|
| 170 |
+
roles = [f.role for f in self.candidate_features]
|
| 171 |
+
for eye in ("eye_left", "eye_right"):
|
| 172 |
+
if roles.count(eye) != 1:
|
| 173 |
+
raise ValueError(
|
| 174 |
+
f'candidate_features needs exactly one "{eye}" '
|
| 175 |
+
f"(got {roles.count(eye)})"
|
| 176 |
+
)
|
| 177 |
+
if roles.count("mouth") > 1:
|
| 178 |
+
raise ValueError('candidate_features allows at most one "mouth"')
|
| 179 |
+
return self
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# ---------------------------------------------------------------------------
|
| 183 |
+
# Defensive extraction
|
| 184 |
+
# ---------------------------------------------------------------------------
|
| 185 |
+
|
| 186 |
+
# Full-line code fences only (```json / ```). JSON strings cannot contain a
|
| 187 |
+
# raw newline, so a whole-line match can never sit inside the payload.
|
| 188 |
+
_FENCE_LINE = re.compile(r"^\s*```[a-zA-Z0-9_-]*\s*$", re.MULTILINE)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _strip_code_fences(text: str) -> str:
|
| 192 |
+
return _FENCE_LINE.sub("", text)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def extract_json_object(raw: str) -> Optional[str]:
|
| 196 |
+
"""Return the first balanced top-level ``{...}`` in ``raw``, or None.
|
| 197 |
+
|
| 198 |
+
Brace-scans with string/escape awareness so JSON wrapped in prose, fences,
|
| 199 |
+
or trailing chatter is still recoverable. Truncated output (depth never
|
| 200 |
+
returns to 0) yields None — which is exactly what we want: a truncated
|
| 201 |
+
generation must trigger the repair retry, not a partial parse.
|
| 202 |
+
"""
|
| 203 |
+
start = raw.find("{")
|
| 204 |
+
if start < 0:
|
| 205 |
+
return None
|
| 206 |
+
depth = 0
|
| 207 |
+
in_string = False
|
| 208 |
+
escaped = False
|
| 209 |
+
for i in range(start, len(raw)):
|
| 210 |
+
ch = raw[i]
|
| 211 |
+
if in_string:
|
| 212 |
+
if escaped:
|
| 213 |
+
escaped = False
|
| 214 |
+
elif ch == "\\":
|
| 215 |
+
escaped = True
|
| 216 |
+
elif ch == '"':
|
| 217 |
+
in_string = False
|
| 218 |
+
continue
|
| 219 |
+
if ch == '"':
|
| 220 |
+
in_string = True
|
| 221 |
+
elif ch == "{":
|
| 222 |
+
depth += 1
|
| 223 |
+
elif ch == "}":
|
| 224 |
+
depth -= 1
|
| 225 |
+
if depth == 0:
|
| 226 |
+
return raw[start : i + 1]
|
| 227 |
+
return None
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _format_validation_error(exc: ValidationError) -> str:
|
| 231 |
+
"""Squash a pydantic error into one short line the model can act on."""
|
| 232 |
+
parts = []
|
| 233 |
+
for err in exc.errors()[:3]:
|
| 234 |
+
loc = ".".join(str(p) for p in err["loc"]) or "(root)"
|
| 235 |
+
parts.append(f"{loc}: {err['msg']}")
|
| 236 |
+
more = len(exc.errors()) - 3
|
| 237 |
+
if more > 0:
|
| 238 |
+
parts.append(f"(+{more} more)")
|
| 239 |
+
return "; ".join(parts)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def _iter_line_validators():
|
| 243 |
+
validators = LINE_VALIDATORS
|
| 244 |
+
if isinstance(validators, Mapping):
|
| 245 |
+
return list(validators.values())
|
| 246 |
+
return list(validators)
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def _call_validator(validator, line: str, kind: str) -> Optional[str]:
|
| 250 |
+
"""Invoke a line validator with (line, kind) when its signature allows, else (line)."""
|
| 251 |
+
try:
|
| 252 |
+
params = [
|
| 253 |
+
p
|
| 254 |
+
for p in inspect.signature(validator).parameters.values()
|
| 255 |
+
if p.kind
|
| 256 |
+
in (
|
| 257 |
+
inspect.Parameter.POSITIONAL_ONLY,
|
| 258 |
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
| 259 |
+
inspect.Parameter.VAR_POSITIONAL,
|
| 260 |
+
)
|
| 261 |
+
]
|
| 262 |
+
wants_two = len(params) >= 2 or any(
|
| 263 |
+
p.kind is inspect.Parameter.VAR_POSITIONAL for p in params
|
| 264 |
+
)
|
| 265 |
+
except (TypeError, ValueError): # builtins / odd callables
|
| 266 |
+
wants_two = False
|
| 267 |
+
return validator(line, kind) if wants_two else validator(line)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
# Minimum lateral (cx) separation between the two eyes. The VLM sometimes
|
| 271 |
+
# returns eye_left/eye_right nearly (or exactly) on top of each other — a
|
| 272 |
+
# degenerate pair that renders as one smeared eye and kills the first-blink
|
| 273 |
+
# hero moment. Rejecting here (not in the pydantic model) keeps the message
|
| 274 |
+
# on the repair-retry path while the image is still in context.
|
| 275 |
+
MIN_EYE_SEPARATION_CX = 0.05
|
| 276 |
+
|
| 277 |
+
EYE_SEPARATION_ERROR = (
|
| 278 |
+
"eye features must be two distinct, laterally separated elements; "
|
| 279 |
+
"pick two different visible features"
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _check_eye_separation(result: AwakeningResult) -> None:
|
| 284 |
+
"""Reject eye pairs closer than MIN_EYE_SEPARATION_CX apart in cx."""
|
| 285 |
+
by_role = {f.role: f for f in result.candidate_features}
|
| 286 |
+
left, right = by_role.get("eye_left"), by_role.get("eye_right")
|
| 287 |
+
if left is None or right is None: # pragma: no cover - model enforces this
|
| 288 |
+
return
|
| 289 |
+
if abs(left.cx - right.cx) < MIN_EYE_SEPARATION_CX:
|
| 290 |
+
raise AwakeningParseError(EYE_SEPARATION_ERROR)
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def _run_line_validators(result: AwakeningResult) -> None:
|
| 294 |
+
for kind, line in (("grudge", result.lines.grudge), ("mutter", result.lines.mutter)):
|
| 295 |
+
for validator in _iter_line_validators():
|
| 296 |
+
error = _call_validator(validator, line, kind)
|
| 297 |
+
if error:
|
| 298 |
+
raise AwakeningParseError(f"lines.{kind} rejected: {error}")
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def parse_awakening(text: str) -> AwakeningResult:
|
| 302 |
+
"""Parse one raw VLM reply into a validated :class:`AwakeningResult`.
|
| 303 |
+
|
| 304 |
+
Pipeline: strip fence lines -> brace-balance extract the first {...} ->
|
| 305 |
+
json.loads -> pydantic validate -> eye-separation check ->
|
| 306 |
+
LINE_VALIDATORS (prompts.py). Raises
|
| 307 |
+
:class:`AwakeningParseError` whose message is ready for
|
| 308 |
+
:func:`build_repair_prompt`; never raises raw pydantic/json errors.
|
| 309 |
+
"""
|
| 310 |
+
if not text or not text.strip():
|
| 311 |
+
raise AwakeningParseError("empty reply — output the JSON object")
|
| 312 |
+
candidate = extract_json_object(_strip_code_fences(text))
|
| 313 |
+
if candidate is None:
|
| 314 |
+
raise AwakeningParseError(
|
| 315 |
+
"no complete JSON object found (reply may be truncated or prose-only)"
|
| 316 |
+
)
|
| 317 |
+
try:
|
| 318 |
+
payload = json.loads(candidate)
|
| 319 |
+
except (json.JSONDecodeError, ValueError) as exc:
|
| 320 |
+
msg = exc.msg if isinstance(exc, json.JSONDecodeError) else str(exc)
|
| 321 |
+
raise AwakeningParseError(f"invalid JSON: {msg}") from exc
|
| 322 |
+
if not isinstance(payload, dict):
|
| 323 |
+
raise AwakeningParseError("top level must be a JSON object")
|
| 324 |
+
try:
|
| 325 |
+
result = AwakeningResult.model_validate(payload)
|
| 326 |
+
except ValidationError as exc:
|
| 327 |
+
raise AwakeningParseError(_format_validation_error(exc)) from exc
|
| 328 |
+
_check_eye_separation(result)
|
| 329 |
+
_run_line_validators(result)
|
| 330 |
+
return result
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
# ---------------------------------------------------------------------------
|
| 334 |
+
# Repair prompt — the single retry
|
| 335 |
+
# ---------------------------------------------------------------------------
|
| 336 |
+
|
| 337 |
+
_REPAIR_ECHO_MAX = 1500 # bound the prefill; the tail is where truncation bites
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def build_repair_prompt(error: str, original_text: str) -> str:
|
| 341 |
+
"""Build the one-shot repair prompt fed back with the same image.
|
| 342 |
+
|
| 343 |
+
Mirrors godseed's validate-retry approach: name the precise problem, echo
|
| 344 |
+
the (clipped) previous output, demand bare JSON. One retry only — a model
|
| 345 |
+
that fails twice gets a PoeticError upstream, not a third chance.
|
| 346 |
+
"""
|
| 347 |
+
clipped = (original_text or "").strip()
|
| 348 |
+
if len(clipped) > _REPAIR_ECHO_MAX:
|
| 349 |
+
clipped = clipped[:_REPAIR_ECHO_MAX] + " …"
|
| 350 |
+
return (
|
| 351 |
+
"Your previous reply could not be accepted.\n"
|
| 352 |
+
f"Problem: {error}\n"
|
| 353 |
+
"Your previous reply was:\n"
|
| 354 |
+
"---\n"
|
| 355 |
+
f"{clipped}\n"
|
| 356 |
+
"---\n"
|
| 357 |
+
"Look at the image again and reply with ONLY the corrected JSON object "
|
| 358 |
+
"— same schema, no code fences, no prose before or after the braces. "
|
| 359 |
+
"Fix the problem named above; keep everything that was already good."
|
| 360 |
+
)
|
mind/voice.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Voice synthesis for awakened objects: MockVoice (canned wav) and VoxVoice.
|
| 2 |
+
|
| 3 |
+
VoxCPM2 design notes (ARCHITECTURE.md §7, verified June 12):
|
| 4 |
+
- Voice design needs NO reference audio — prefix the line with a parenthesized
|
| 5 |
+
character description: ``"(A gravelly old veteran…) Forty years…"``.
|
| 6 |
+
- ``VoxCPM.from_pretrained("openbmb/VoxCPM2", load_denoiser=False,
|
| 7 |
+
optimize=False)``; ``generate(text=…, cfg_value=2.0,
|
| 8 |
+
inference_timesteps=10)``; output is a 48 kHz waveform.
|
| 9 |
+
- ``TORCHDYNAMO_DISABLE=1`` must be set before ANY torch import
|
| 10 |
+
(torch.compile warmup breaks ZeroGPU) — done unconditionally at the top of
|
| 11 |
+
this module, which is always imported before the guarded ML paths run.
|
| 12 |
+
|
| 13 |
+
All ML imports live inside VoxVoice's lazy load path; importing this module —
|
| 14 |
+
and using MockVoice — touches nothing heavier than pathlib.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import io
|
| 20 |
+
import os
|
| 21 |
+
import threading
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
from typing import Optional
|
| 24 |
+
|
| 25 |
+
# Must precede every torch import anywhere in the process (§7). Harmless when
|
| 26 |
+
# torch never loads (mock mode, tests).
|
| 27 |
+
os.environ.setdefault("TORCHDYNAMO_DISABLE", "1")
|
| 28 |
+
|
| 29 |
+
# ---------------------------------------------------------------------------
|
| 30 |
+
# Voice designs (writing agent's mind/prompts.py owns the canonical copy).
|
| 31 |
+
#
|
| 32 |
+
# Expected contract: VOICE_DESIGNS maps the ten WRITING.md voice ids to
|
| 33 |
+
# parenthesizable character descriptions. Import-guarded with a complete
|
| 34 |
+
# fallback so voices work standalone before prompts.py lands — voices must be
|
| 35 |
+
# instantly distinct on a phone speaker.
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
+
_FALLBACK_VOICE_DESIGNS: dict[str, str] = {
|
| 38 |
+
"gravel_low": (
|
| 39 |
+
"A gravelly, low old man's voice, slow and deliberate, "
|
| 40 |
+
"long-suffering but dignified"
|
| 41 |
+
),
|
| 42 |
+
"weary_warm": (
|
| 43 |
+
"A weary, warm middle-aged voice, soft sighs between words, "
|
| 44 |
+
"endlessly patient and quietly hurt"
|
| 45 |
+
),
|
| 46 |
+
"prim_clipped": (
|
| 47 |
+
"A prim, clipped voice with immaculate fast diction, "
|
| 48 |
+
"tight with wounded professional pride"
|
| 49 |
+
),
|
| 50 |
+
"breathy_faded": (
|
| 51 |
+
"A faint, breathy voice, soft and far away, "
|
| 52 |
+
"gentle and hopeful like someone half-remembered"
|
| 53 |
+
),
|
| 54 |
+
"paranoid_whisper": (
|
| 55 |
+
"A tense, urgent whisper, quick and darting, "
|
| 56 |
+
"certain that someone is listening right now"
|
| 57 |
+
),
|
| 58 |
+
"grandiose_warm": (
|
| 59 |
+
"A grand, theatrical, warm voice, rich and rolling, "
|
| 60 |
+
"magnanimous yet deeply wounded"
|
| 61 |
+
),
|
| 62 |
+
"eager_bright": (
|
| 63 |
+
"A bright, eager young voice, quick and earnest, "
|
| 64 |
+
"desperate to make a good first impression"
|
| 65 |
+
),
|
| 66 |
+
"slow_grand": (
|
| 67 |
+
"A slow, grand, antique voice, deep and deliberate, "
|
| 68 |
+
"every word delivered like inherited wisdom"
|
| 69 |
+
),
|
| 70 |
+
"deadpan_flat": (
|
| 71 |
+
"A completely flat, deadpan voice, perfectly even pace, "
|
| 72 |
+
"quietly seething beneath the calm"
|
| 73 |
+
),
|
| 74 |
+
"soft_wistful": (
|
| 75 |
+
"A soft, wistful, romantic voice, gentle and slow, "
|
| 76 |
+
"yearning for something just out of reach"
|
| 77 |
+
),
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
try: # pragma: no cover - exercised only once prompts.py exists
|
| 81 |
+
from .prompts import VOICE_DESIGNS # type: ignore[attr-defined]
|
| 82 |
+
except Exception: # noqa: BLE001 - any import problem means "use the fallback"
|
| 83 |
+
VOICE_DESIGNS = dict(_FALLBACK_VOICE_DESIGNS)
|
| 84 |
+
|
| 85 |
+
DEFAULT_VOICE_ID = "gravel_low"
|
| 86 |
+
|
| 87 |
+
_MOCK_WAV_PATH = Path(__file__).with_name("mock_assets") / "grudge.wav"
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class MockVoice:
|
| 91 |
+
"""Canned voice: every line comes out as the bundled hydrant grudge wav.
|
| 92 |
+
|
| 93 |
+
16-bit mono 24 kHz WAV (~4.7 s) generated once on macOS with the
|
| 94 |
+
character-ish "Grandpa" voice — realistic length and timbre so the
|
| 95 |
+
frontend's amplitude-driven mouth sync develops against real audio.
|
| 96 |
+
Deterministic and dependency-free by design.
|
| 97 |
+
"""
|
| 98 |
+
|
| 99 |
+
name = "mock"
|
| 100 |
+
model_id = "pareidolia-mock-grudge-wav"
|
| 101 |
+
sample_rate = 24_000
|
| 102 |
+
|
| 103 |
+
_cached: Optional[bytes] = None
|
| 104 |
+
|
| 105 |
+
def preload(self) -> None:
|
| 106 |
+
"""No-op: the mock has nothing to load. Exists so callers can blindly
|
| 107 |
+
``preload()`` whatever make_voice() returned (the zerogpu VoxVoice
|
| 108 |
+
must preload at startup; see VoxVoice.preload)."""
|
| 109 |
+
|
| 110 |
+
def speak(self, line: str, voice_id: str) -> bytes:
|
| 111 |
+
"""Return WAV bytes for ``line``. Mock ignores both arguments."""
|
| 112 |
+
if MockVoice._cached is None:
|
| 113 |
+
MockVoice._cached = _MOCK_WAV_PATH.read_bytes()
|
| 114 |
+
return MockVoice._cached
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ---------------------------------------------------------------------------
|
| 118 |
+
# VoxCPM2 — lazy module-level singleton, loaded on first zerogpu use
|
| 119 |
+
# ---------------------------------------------------------------------------
|
| 120 |
+
|
| 121 |
+
_VOX_MODEL = None
|
| 122 |
+
_VOX_LOCK = threading.Lock()
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _ensure_vox():
|
| 126 |
+
"""Load VoxCPM2 once per process (module-level singleton).
|
| 127 |
+
|
| 128 |
+
On ZeroGPU this MUST be reached via :meth:`VoxVoice.preload` at app
|
| 129 |
+
startup (server/wiring.py calls it when the pipeline is constructed):
|
| 130 |
+
@spaces.GPU functions execute in forked workers whose state does not
|
| 131 |
+
persist, so a lazy in-window load would re-pay the 2.3B load (and, on the
|
| 132 |
+
first visit, the Hub download) inside the visitor's 75s quota window.
|
| 133 |
+
Never reached in mock mode or tests.
|
| 134 |
+
"""
|
| 135 |
+
global _VOX_MODEL
|
| 136 |
+
with _VOX_LOCK:
|
| 137 |
+
if _VOX_MODEL is None:
|
| 138 |
+
from voxcpm import VoxCPM # heavy import, guarded by design
|
| 139 |
+
|
| 140 |
+
_VOX_MODEL = VoxCPM.from_pretrained(
|
| 141 |
+
"openbmb/VoxCPM2",
|
| 142 |
+
load_denoiser=False,
|
| 143 |
+
optimize=False, # torch.compile warmup breaks ZeroGPU
|
| 144 |
+
)
|
| 145 |
+
return _VOX_MODEL
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
class VoxVoice:
|
| 149 |
+
"""VoxCPM2 voice-design synthesis (the zerogpu backend's voice).
|
| 150 |
+
|
| 151 |
+
Each WRITING.md voice id maps to a fixed character-description prefix so
|
| 152 |
+
a given persona sounds the same on every visit — determinism is what
|
| 153 |
+
makes the Menagerie wall replayable. Unknown voice ids degrade to
|
| 154 |
+
DEFAULT_VOICE_ID rather than failing: a wrong-but-present voice beats a
|
| 155 |
+
silent object.
|
| 156 |
+
|
| 157 |
+
``generate_fn`` is a test/bench seam: a callable ``(text) -> waveform``
|
| 158 |
+
that replaces the real model so the wav-encoding path is testable with
|
| 159 |
+
zero ML imports.
|
| 160 |
+
"""
|
| 161 |
+
|
| 162 |
+
name = "vox"
|
| 163 |
+
model_id = "openbmb/VoxCPM2"
|
| 164 |
+
sample_rate = 48_000
|
| 165 |
+
|
| 166 |
+
def __init__(self, generate_fn=None):
|
| 167 |
+
self._generate_fn = generate_fn
|
| 168 |
+
|
| 169 |
+
def preload(self) -> None:
|
| 170 |
+
"""Optionally pull the model into memory at app startup.
|
| 171 |
+
|
| 172 |
+
On ZeroGPU the startup phase is where device placement is managed;
|
| 173 |
+
calling this from app.py keeps the first visitor's GPU window free of
|
| 174 |
+
the ~2.3B-parameter load.
|
| 175 |
+
"""
|
| 176 |
+
if self._generate_fn is None:
|
| 177 |
+
_ensure_vox()
|
| 178 |
+
|
| 179 |
+
def speak(self, line: str, voice_id: str) -> bytes:
|
| 180 |
+
"""Synthesize ``line`` in the designed voice; return 48 kHz WAV bytes.
|
| 181 |
+
|
| 182 |
+
cfg_value=2.0 / inference_timesteps=10 are the §7-verified speed
|
| 183 |
+
settings (RTF ~0.5–1.0 on the ZeroGPU slice — a ≤8 s line lands in
|
| 184 |
+
roughly 4–10 s warm).
|
| 185 |
+
"""
|
| 186 |
+
design = VOICE_DESIGNS.get(voice_id) or VOICE_DESIGNS.get(
|
| 187 |
+
DEFAULT_VOICE_ID, next(iter(_FALLBACK_VOICE_DESIGNS.values()))
|
| 188 |
+
)
|
| 189 |
+
text = f"({design}) {line}"
|
| 190 |
+
if self._generate_fn is not None:
|
| 191 |
+
waveform = self._generate_fn(text)
|
| 192 |
+
else:
|
| 193 |
+
waveform = _ensure_vox().generate(
|
| 194 |
+
text=text,
|
| 195 |
+
cfg_value=2.0,
|
| 196 |
+
inference_timesteps=10,
|
| 197 |
+
)
|
| 198 |
+
return _waveform_to_wav_bytes(waveform, self.sample_rate)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _waveform_to_wav_bytes(waveform, sample_rate: int) -> bytes:
|
| 202 |
+
"""Encode a float waveform to 16-bit PCM WAV bytes via soundfile."""
|
| 203 |
+
import numpy as np
|
| 204 |
+
import soundfile as sf
|
| 205 |
+
|
| 206 |
+
data = np.asarray(waveform)
|
| 207 |
+
if data.ndim > 1: # (1, n) or (n, 1) -> mono
|
| 208 |
+
data = data.squeeze()
|
| 209 |
+
buffer = io.BytesIO()
|
| 210 |
+
sf.write(buffer, data, sample_rate, format="WAV", subtype="PCM_16")
|
| 211 |
+
return buffer.getvalue()
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def make_voice(backend_name: Optional[str] = None):
|
| 215 |
+
"""Build the voice for ``backend_name`` or the PAREIDOLIA_BACKEND env.
|
| 216 |
+
|
| 217 |
+
mock (default) -> MockVoice; zerogpu/vox -> VoxVoice. Mirrors
|
| 218 |
+
backends.make_medium so app.py resolves both halves from one env var.
|
| 219 |
+
"""
|
| 220 |
+
raw = (backend_name or os.environ.get("PAREIDOLIA_BACKEND") or "mock").strip().lower()
|
| 221 |
+
if raw in ("mock", ""):
|
| 222 |
+
return MockVoice()
|
| 223 |
+
if raw in ("zerogpu", "zero-gpu", "vox", "voxcpm"):
|
| 224 |
+
return VoxVoice()
|
| 225 |
+
raise ValueError(f"unknown PAREIDOLIA_BACKEND {raw!r} (expected mock | zerogpu)")
|
requirements-dev.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PAREIDOLIA — local dev + tests. The mock backend imports ZERO ML libraries;
|
| 2 |
+
# everything here runs on a laptop CPU. gradio is unpinned on purpose (we only
|
| 3 |
+
# need the gr.Server idiom, which any 6.x provides; the Space pins via README).
|
| 4 |
+
fastapi
|
| 5 |
+
uvicorn
|
| 6 |
+
gradio
|
| 7 |
+
gradio_client
|
| 8 |
+
pydantic
|
| 9 |
+
huggingface_hub
|
| 10 |
+
pillow
|
| 11 |
+
numpy
|
| 12 |
+
opencv-python-headless
|
| 13 |
+
pytest
|
| 14 |
+
httpx
|
| 15 |
+
soundfile
|
requirements.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PAREIDOLIA — Space requirements. ARCHITECTURE.md §7 is the contract; every
|
| 2 |
+
# pin below was verified against live Spaces on the June 12 recon.
|
| 3 |
+
#
|
| 4 |
+
# Hard rules (do NOT relitigate here):
|
| 5 |
+
# * NEVER pin gradio / spaces / torch — the Spaces image manages them
|
| 6 |
+
# (gradio == README sdk_version 6.16.0, spaces 0.50.4, torch 2.8.0 / py3.10).
|
| 7 |
+
# * `import spaces` must precede torch anywhere in the process (app.py does it).
|
| 8 |
+
transformers==4.55.0 # the official MiniCPM-V-4_5 ZeroGPU demo's proven pin
|
| 9 |
+
voxcpm>=2.0.3 # VoxCPM2 dispatch lives in 2.0.x; 1.5.0 silently degrades
|
| 10 |
+
torchcodec==0.7.* # match preinstalled torch 2.8.0 (unpinned drags torch to 2.11)
|
| 11 |
+
torchaudio==2.8.* # match preinstalled torch 2.8.0
|
| 12 |
+
accelerate
|
| 13 |
+
timm
|
| 14 |
+
einops
|
| 15 |
+
safetensors
|
| 16 |
+
pillow
|
| 17 |
+
soundfile
|
| 18 |
+
opencv-python-headless
|
| 19 |
+
fastapi
|
| 20 |
+
pydantic>=2.7
|
| 21 |
+
huggingface_hub
|
seeds/eval/LICENSES.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# seeds/eval — image sources & licenses
|
| 2 |
+
|
| 3 |
+
All images are CC0 or Public Domain (PDM), sourced via the Openverse API
|
| 4 |
+
(https://api.openverse.org) and Wikimedia Commons. Downscaled to <=1024px JPEG.
|
| 5 |
+
|
| 6 |
+
| file | title | creator | provider | license | source |
|
| 7 |
+
|---|---|---|---|---|---|
|
| 8 |
+
| fire_hydrant.jpg | A red fire hydrant is positioned on a paved sidewalk, surrou | Yam B Chhetri | wordpress | CC0 1.0 | https://wordpress.org/photos/photo/39068197e3/ |
|
| 9 |
+
| coffee_mug.jpg | Giftgarden Coffee Mugs Best Love Gift Music Violin Notes Hol | favorli | flickr | PDM 1.0 | https://www.flickr.com/photos/147778363@N07/32903357261 |
|
| 10 |
+
| toaster.jpg | Oster 2-Slice Toaster | Shenderson1 | wikimedia | CC0 1.0 | https://commons.wikimedia.org/w/index.php?curid=140748640 |
|
| 11 |
+
| potted_plant.jpg | Pot plant | spongebabyalwaysfull | flickr | CC0 1.0 | https://www.flickr.com/photos/73080909@N03/14564004742 |
|
| 12 |
+
| armchair.jpg | <div class='fn'> Armchair (fauteuil) from Louis XVI's Salon | Georges Jacob | wikimedia | CC0 1.0 | https://commons.wikimedia.org/w/index.php?curid=61155820 |
|
| 13 |
+
| mailbox.jpg | Canada Post mailbox | Open Grid Scheduler / Grid Engine | flickr | CC0 1.0 | https://www.flickr.com/photos/29155878@N03/22290946606 |
|
| 14 |
+
| traffic_cone.jpg | Orange traffic cone | DennisM2 | flickr | CC0 1.0 | https://www.flickr.com/photos/14674348@N04/29948388392 |
|
| 15 |
+
| desk_lamp.jpg | Metal desk lamp and books pile | freestocks.org | flickr | CC0 1.0 | https://www.flickr.com/photos/135396164@N05/42381074432 |
|
| 16 |
+
| garden_gnome.jpg | Garden gnome (macro) | jo.elphick | flickr | CC0 1.0 | https://www.flickr.com/photos/135606905@N08/42993385891 |
|
| 17 |
+
| stapler.jpg | Black Stapler 2024 | DifrancoBarnes | wikimedia | CC0 1.0 | https://commons.wikimedia.org/w/index.php?curid=146779701 |
|
| 18 |
+
| exercise_bike.jpg | Stationary bicycle | see source | wikimedia | Public domain | https://commons.wikimedia.org/wiki/File:Stationary_bicycle.jpg |
|
| 19 |
+
| stand_mixer.jpg | Sunbeam Heritage Mixmaster Stand Mixer | Shenderson1 | wikimedia | CC0 1.0 | https://commons.wikimedia.org/w/index.php?curid=140748645 |
|
| 20 |
+
| park_bench.jpg | Park bench | DennisM2 | flickr | CC0 1.0 | https://www.flickr.com/photos/14674348@N04/13936410175 |
|
| 21 |
+
| payphone.jpg | Metrobot payphone closeup | Jleedev | wikimedia | CC0 1.0 | https://commons.wikimedia.org/w/index.php?curid=151533770 |
|
seeds/eval/_manifest.json
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"fire_hydrant": {
|
| 3 |
+
"slug": "fire_hydrant",
|
| 4 |
+
"title": "A red fire hydrant is positioned on a paved sidewalk, surrounded by gray stone tiles",
|
| 5 |
+
"source_url": "https://wordpress.org/photos/photo/39068197e3/",
|
| 6 |
+
"image_url": "https://pd.w.org/2025/05/39068197e32e4b7a8.17804464-1536x2048.jpeg",
|
| 7 |
+
"license": "CC0 1.0",
|
| 8 |
+
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
|
| 9 |
+
"creator": "Yam B Chhetri",
|
| 10 |
+
"provider": "wordpress",
|
| 11 |
+
"downloaded_bytes": 1186034,
|
| 12 |
+
"identifier": null,
|
| 13 |
+
"file": "fire_hydrant.jpg",
|
| 14 |
+
"saved_size": [
|
| 15 |
+
768,
|
| 16 |
+
1024
|
| 17 |
+
]
|
| 18 |
+
},
|
| 19 |
+
"coffee_mug": {
|
| 20 |
+
"slug": "coffee_mug",
|
| 21 |
+
"file": "coffee_mug.jpg",
|
| 22 |
+
"title": "Giftgarden Coffee Mugs Best Love Gift Music Violin Notes Holds Tea Coffee Milk Ceramic Mug",
|
| 23 |
+
"source_url": "https://www.flickr.com/photos/147778363@N07/32903357261",
|
| 24 |
+
"image_url": "https://live.staticflickr.com/707/32903357261_7c3d83b8dd_b.jpg",
|
| 25 |
+
"license": "PDM 1.0",
|
| 26 |
+
"license_url": "https://creativecommons.org/publicdomain/mark/1.0/",
|
| 27 |
+
"creator": "favorli",
|
| 28 |
+
"provider": "flickr",
|
| 29 |
+
"downloaded_bytes": 111584,
|
| 30 |
+
"saved_size": [
|
| 31 |
+
1001,
|
| 32 |
+
1001
|
| 33 |
+
],
|
| 34 |
+
"identifier": null
|
| 35 |
+
},
|
| 36 |
+
"toaster": {
|
| 37 |
+
"slug": "toaster",
|
| 38 |
+
"title": "Oster 2-Slice Toaster",
|
| 39 |
+
"source_url": "https://commons.wikimedia.org/w/index.php?curid=140748640",
|
| 40 |
+
"image_url": "https://upload.wikimedia.org/wikipedia/commons/b/bb/Oster_2-Slice_Toaster.jpg",
|
| 41 |
+
"license": "CC0 1.0",
|
| 42 |
+
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/deed.en/",
|
| 43 |
+
"creator": "Shenderson1",
|
| 44 |
+
"provider": "wikimedia",
|
| 45 |
+
"downloaded_bytes": 1659904,
|
| 46 |
+
"identifier": "7a4a5401-73f2-41f6-9607-a2ea8251a3fb",
|
| 47 |
+
"file": "toaster.jpg",
|
| 48 |
+
"saved_size": [
|
| 49 |
+
1024,
|
| 50 |
+
683
|
| 51 |
+
]
|
| 52 |
+
},
|
| 53 |
+
"potted_plant": {
|
| 54 |
+
"slug": "potted_plant",
|
| 55 |
+
"title": "Pot plant",
|
| 56 |
+
"source_url": "https://www.flickr.com/photos/73080909@N03/14564004742",
|
| 57 |
+
"image_url": "https://live.staticflickr.com/5526/14564004742_cc2b47143b_b.jpg",
|
| 58 |
+
"license": "CC0 1.0",
|
| 59 |
+
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
|
| 60 |
+
"creator": "spongebabyalwaysfull",
|
| 61 |
+
"provider": "flickr",
|
| 62 |
+
"downloaded_bytes": 218335,
|
| 63 |
+
"identifier": "5b4a9e79-e468-4c54-b34c-a92c82620ba3",
|
| 64 |
+
"file": "potted_plant.jpg",
|
| 65 |
+
"saved_size": [
|
| 66 |
+
1024,
|
| 67 |
+
683
|
| 68 |
+
]
|
| 69 |
+
},
|
| 70 |
+
"armchair": {
|
| 71 |
+
"slug": "armchair",
|
| 72 |
+
"title": "<div class='fn'> Armchair (fauteuil) from Louis XVI's Salon des Jeux at Saint Cloud</div>",
|
| 73 |
+
"source_url": "https://commons.wikimedia.org/w/index.php?curid=61155820",
|
| 74 |
+
"image_url": "https://upload.wikimedia.org/wikipedia/commons/2/24/Armchair_%28fauteuil%29_from_Louis_XVI%27s_Salon_des_Jeux_at_Saint_Cloud_MET_DP113960.jpg",
|
| 75 |
+
"license": "CC0 1.0",
|
| 76 |
+
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/deed.en/",
|
| 77 |
+
"creator": "Georges Jacob",
|
| 78 |
+
"provider": "wikimedia",
|
| 79 |
+
"downloaded_bytes": 2651884,
|
| 80 |
+
"identifier": "bedee2e8-afea-45cb-8563-01031761c7ac",
|
| 81 |
+
"file": "armchair.jpg",
|
| 82 |
+
"saved_size": [
|
| 83 |
+
993,
|
| 84 |
+
1024
|
| 85 |
+
]
|
| 86 |
+
},
|
| 87 |
+
"mailbox": {
|
| 88 |
+
"slug": "mailbox",
|
| 89 |
+
"title": "Canada Post mailbox",
|
| 90 |
+
"source_url": "https://www.flickr.com/photos/29155878@N03/22290946606",
|
| 91 |
+
"image_url": "https://live.staticflickr.com/5634/22290946606_26dde26cb3_b.jpg",
|
| 92 |
+
"license": "CC0 1.0",
|
| 93 |
+
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
|
| 94 |
+
"creator": "Open Grid Scheduler / Grid Engine",
|
| 95 |
+
"provider": "flickr",
|
| 96 |
+
"downloaded_bytes": 285218,
|
| 97 |
+
"identifier": "854f5127-fb48-47e3-8ed0-a36cd6e325c8",
|
| 98 |
+
"file": "mailbox.jpg",
|
| 99 |
+
"saved_size": [
|
| 100 |
+
678,
|
| 101 |
+
1024
|
| 102 |
+
]
|
| 103 |
+
},
|
| 104 |
+
"traffic_cone": {
|
| 105 |
+
"slug": "traffic_cone",
|
| 106 |
+
"title": "Orange traffic cone",
|
| 107 |
+
"source_url": "https://www.flickr.com/photos/14674348@N04/29948388392",
|
| 108 |
+
"image_url": "https://live.staticflickr.com/5158/29948388392_71690195ee_b.jpg",
|
| 109 |
+
"license": "CC0 1.0",
|
| 110 |
+
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
|
| 111 |
+
"creator": "DennisM2",
|
| 112 |
+
"provider": "flickr",
|
| 113 |
+
"downloaded_bytes": 228913,
|
| 114 |
+
"identifier": "7c7bdbcd-0d45-480d-a90d-e796645314c6",
|
| 115 |
+
"file": "traffic_cone.jpg",
|
| 116 |
+
"saved_size": [
|
| 117 |
+
683,
|
| 118 |
+
1024
|
| 119 |
+
]
|
| 120 |
+
},
|
| 121 |
+
"desk_lamp": {
|
| 122 |
+
"slug": "desk_lamp",
|
| 123 |
+
"file": "desk_lamp.jpg",
|
| 124 |
+
"title": "Metal desk lamp and books pile",
|
| 125 |
+
"source_url": "https://www.flickr.com/photos/135396164@N05/42381074432",
|
| 126 |
+
"image_url": "https://live.staticflickr.com/1747/42381074432_33733f4667_b.jpg",
|
| 127 |
+
"license": "CC0 1.0",
|
| 128 |
+
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
|
| 129 |
+
"creator": "freestocks.org",
|
| 130 |
+
"provider": "flickr",
|
| 131 |
+
"downloaded_bytes": 51838,
|
| 132 |
+
"saved_size": [
|
| 133 |
+
683,
|
| 134 |
+
1024
|
| 135 |
+
],
|
| 136 |
+
"identifier": null
|
| 137 |
+
},
|
| 138 |
+
"garden_gnome": {
|
| 139 |
+
"slug": "garden_gnome",
|
| 140 |
+
"title": "Garden gnome (macro)",
|
| 141 |
+
"source_url": "https://www.flickr.com/photos/135606905@N08/42993385891",
|
| 142 |
+
"image_url": "https://live.staticflickr.com/1771/42993385891_d2d296e98d_b.jpg",
|
| 143 |
+
"license": "CC0 1.0",
|
| 144 |
+
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
|
| 145 |
+
"creator": "jo.elphick",
|
| 146 |
+
"provider": "flickr",
|
| 147 |
+
"downloaded_bytes": 190692,
|
| 148 |
+
"identifier": "8de57d5f-c25f-4627-81da-289c6b1dce96",
|
| 149 |
+
"file": "garden_gnome.jpg",
|
| 150 |
+
"saved_size": [
|
| 151 |
+
1024,
|
| 152 |
+
809
|
| 153 |
+
]
|
| 154 |
+
},
|
| 155 |
+
"stapler": {
|
| 156 |
+
"slug": "stapler",
|
| 157 |
+
"title": "Black Stapler 2024",
|
| 158 |
+
"source_url": "https://commons.wikimedia.org/w/index.php?curid=146779701",
|
| 159 |
+
"image_url": "https://upload.wikimedia.org/wikipedia/commons/c/c0/Black_Stapler_2024.jpg",
|
| 160 |
+
"license": "CC0 1.0",
|
| 161 |
+
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/deed.en/",
|
| 162 |
+
"creator": "DifrancoBarnes",
|
| 163 |
+
"provider": "wikimedia",
|
| 164 |
+
"downloaded_bytes": 1666889,
|
| 165 |
+
"identifier": "05407a88-b357-43c0-a74b-25c048f10bcc",
|
| 166 |
+
"file": "stapler.jpg",
|
| 167 |
+
"saved_size": [
|
| 168 |
+
1024,
|
| 169 |
+
885
|
| 170 |
+
]
|
| 171 |
+
},
|
| 172 |
+
"exercise_bike": {
|
| 173 |
+
"slug": "exercise_bike",
|
| 174 |
+
"title": "Stationary bicycle",
|
| 175 |
+
"source_url": "https://commons.wikimedia.org/wiki/File:Stationary_bicycle.jpg",
|
| 176 |
+
"image_url": "https://upload.wikimedia.org/wikipedia/commons/c/ce/Stationary_bicycle.jpg",
|
| 177 |
+
"license": "Public domain",
|
| 178 |
+
"license_url": "",
|
| 179 |
+
"creator": "see source",
|
| 180 |
+
"provider": "wikimedia",
|
| 181 |
+
"downloaded_bytes": 3943479,
|
| 182 |
+
"identifier": "https://upload.wikimedia.org/wikipedia/commons/c/ce/Stationary_bicycle.jpg",
|
| 183 |
+
"file": "exercise_bike.jpg",
|
| 184 |
+
"saved_size": [
|
| 185 |
+
615,
|
| 186 |
+
1024
|
| 187 |
+
]
|
| 188 |
+
},
|
| 189 |
+
"stand_mixer": {
|
| 190 |
+
"slug": "stand_mixer",
|
| 191 |
+
"file": "stand_mixer.jpg",
|
| 192 |
+
"title": "Sunbeam Heritage Mixmaster Stand Mixer",
|
| 193 |
+
"source_url": "https://commons.wikimedia.org/w/index.php?curid=140748645",
|
| 194 |
+
"image_url": "https://upload.wikimedia.org/wikipedia/commons/7/7b/Sunbeam_Heritage_Mixmaster_Stand_Mixer.jpg",
|
| 195 |
+
"license": "CC0 1.0",
|
| 196 |
+
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/deed.en/",
|
| 197 |
+
"creator": "Shenderson1",
|
| 198 |
+
"provider": "wikimedia",
|
| 199 |
+
"downloaded_bytes": 379877,
|
| 200 |
+
"saved_size": [
|
| 201 |
+
990,
|
| 202 |
+
1024
|
| 203 |
+
],
|
| 204 |
+
"identifier": null
|
| 205 |
+
},
|
| 206 |
+
"park_bench": {
|
| 207 |
+
"slug": "park_bench",
|
| 208 |
+
"file": "park_bench.jpg",
|
| 209 |
+
"title": "Park bench",
|
| 210 |
+
"source_url": "https://www.flickr.com/photos/14674348@N04/13936410175",
|
| 211 |
+
"image_url": "https://live.staticflickr.com/3797/13936410175_01c83d4a8c_b.jpg",
|
| 212 |
+
"license": "CC0 1.0",
|
| 213 |
+
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
|
| 214 |
+
"creator": "DennisM2",
|
| 215 |
+
"provider": "flickr",
|
| 216 |
+
"downloaded_bytes": 363806,
|
| 217 |
+
"saved_size": [
|
| 218 |
+
1024,
|
| 219 |
+
768
|
| 220 |
+
],
|
| 221 |
+
"identifier": null
|
| 222 |
+
},
|
| 223 |
+
"payphone": {
|
| 224 |
+
"slug": "payphone",
|
| 225 |
+
"title": "Metrobot payphone closeup",
|
| 226 |
+
"source_url": "https://commons.wikimedia.org/w/index.php?curid=151533770",
|
| 227 |
+
"image_url": "https://upload.wikimedia.org/wikipedia/commons/5/5c/Metrobot_payphone_closeup.jpg",
|
| 228 |
+
"license": "CC0 1.0",
|
| 229 |
+
"license_url": "https://creativecommons.org/publicdomain/zero/1.0/deed.en/",
|
| 230 |
+
"creator": "Jleedev",
|
| 231 |
+
"provider": "wikimedia",
|
| 232 |
+
"downloaded_bytes": 2466827,
|
| 233 |
+
"identifier": "74dd0544-e704-4f2c-b486-227e2084b9eb",
|
| 234 |
+
"file": "payphone.jpg",
|
| 235 |
+
"saved_size": [
|
| 236 |
+
768,
|
| 237 |
+
1024
|
| 238 |
+
]
|
| 239 |
+
}
|
| 240 |
+
}
|
seeds/eval/armchair.jpg
ADDED
|
Git LFS Details
|
seeds/eval/coffee_mug.jpg
ADDED
|
Git LFS Details
|
seeds/eval/desk_lamp.jpg
ADDED
|
seeds/eval/exercise_bike.jpg
ADDED
|
Git LFS Details
|
seeds/eval/fire_hydrant.jpg
ADDED
|
Git LFS Details
|
seeds/eval/garden_gnome.jpg
ADDED
|
Git LFS Details
|
seeds/eval/mailbox.jpg
ADDED
|
Git LFS Details
|
seeds/eval/park_bench.jpg
ADDED
|
Git LFS Details
|
seeds/eval/payphone.jpg
ADDED
|
Git LFS Details
|
seeds/eval/potted_plant.jpg
ADDED
|
Git LFS Details
|
seeds/eval/stand_mixer.jpg
ADDED
|
Git LFS Details
|
seeds/eval/stapler.jpg
ADDED
|
Git LFS Details
|
seeds/eval/toaster.jpg
ADDED
|
seeds/eval/traffic_cone.jpg
ADDED
|
Git LFS Details
|
seeds/records/records.jsonl
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{"id": "seed_potted_plant", "ts": 1781292294.2181592, "object": "bonsai tree", "material": "wood, soil, ceramic pot", "condition": "pristine", "setting": "indoor, against a plain wall", "persona": {"archetype": "the_perfectionist", "voice": "prim_clipped", "mood": "unfulfilled"}, "features": [{"name": "left leaf cluster base", "role": "eye_left", "cx": 0.2727272727272727, "cy": 0.48240469208211145, "size": 0.04, "snap_delta": 0.018944774198844515, "snapped": true, "anchor_kind": "corner", "anchor_score": 0.2312}, {"name": "right leaf cluster base", "role": "eye_right", "cx": 0.7106549364613881, "cy": 0.4736070381231672, "size": 0.04, "snap_delta": 0.015212218431305963, "snapped": true, "anchor_kind": "corner", "anchor_score": 0.2052}, {"name": "center trunk knot", "role": "mouth", "cx": 0.570869990224829, "cy": 0.5616293988269795, "size": 0.06, "snap_delta": 0.059252363570097874, "snapped": true, "anchor_kind": "edge", "anchor_score": 0.1423}], "lines": {"grudge": "Planted in this shallow pot. No room for roots to spread.", "mutter": "They call it art."}, "critique": "Leaf cluster bases round, level, symmetric — honest eyes. Trunk knot low center — compact mouth. Grudge stays on the knot and the pot's edge.", "image_sha256": "1d5ea6b7a40ca50ccad6582e90a26d76b209566b28e0720eaf208fc08a1227da", "backend": "zerogpu", "image_url": "/media/seed_potted_plant.jpg", "audio_url": "/media/seed_potted_plant.wav"}
|
| 2 |
+
{"id": "seed_garden_gnome", "ts": 1781292304.081881, "object": "garden gnome", "material": "plastic", "condition": "pristine", "setting": "wooden planter, garden", "persona": {"archetype": "the_diva", "voice": "grandiose_warm", "mood": "expectant"}, "features": [{"name": "left eye", "role": "eye_left", "cx": 0.5540566989753253, "cy": 0.3279703064720229, "size": 0.04, "snap_delta": 0.03213735259043178, "snapped": true, "anchor_kind": "circle", "anchor_score": 0.2121}, {"name": "right eye", "role": "eye_right", "cx": 0.6040566989753252, "cy": 0.3279703064720229, "size": 0.04, "snap_delta": 0.03213735259043178, "snapped": true, "anchor_kind": "pair", "anchor_score": 0.2121}, {"name": "mouth", "role": "mouth", "cx": 0.5716983324298539, "cy": 0.4500272861931862, "size": 0.05, "snap_delta": 0.04237106197632453, "snapped": true, "anchor_kind": "blob", "anchor_score": 0.2096}], "lines": {"grudge": "They placed me here to watch the plants, not to be ignored by passersby.", "mutter": "A little attention, please."}, "critique": "Eyes are two matching dark spots, level and apart. Mouth is a red curve below, forming a compact face. All features span cx 0.5-0.6, cy 0.35-0.5.", "image_sha256": "a091a50b9d46039faae5cadd0b73d0cceba8a867e0b9b111c0aff6a41899fd89", "backend": "zerogpu", "image_url": "/media/seed_garden_gnome.jpg", "audio_url": "/media/seed_garden_gnome.wav"}
|
| 3 |
+
{"id": "seed_armchair", "ts": 1781292309.8492439, "object": "armchair", "material": "wood and fabric", "condition": "pristine", "setting": "studio, isolated", "persona": {"archetype": "the_diva", "voice": "grandiose_warm", "mood": "unappreciated"}, "features": [{"name": "left armrest scroll detail", "role": "eye_left", "cx": 0.16442938292689271, "cy": 0.41215222856474343, "size": 0.04, "snap_delta": 0.06540875317034409, "snapped": true, "anchor_kind": "blob", "anchor_score": 0.1625}, {"name": "right armrest scroll detail", "role": "eye_right", "cx": 0.7477822611408849, "cy": 0.3992179878063332, "size": 0.04, "snap_delta": 0.036452929050856585, "snapped": true, "anchor_kind": "circle", "anchor_score": 0.3703}, {"name": "center floral motif on seat cushion", "role": "mouth", "cx": 0.4492314923885466, "cy": 0.5612293650532143, "size": 0.06, "snap_delta": 0.03621411171591366, "snapped": true, "anchor_kind": "edge", "anchor_score": 0.2269}], "lines": {"grudge": "They brought me here to admire my gilding, but no one noticed my floral heart.", "mutter": "More light. Please."}, "critique": "Scroll details on armrests level, symmetric — strong eyes. Floral motif low center — honest mouth. Grudge must come from the visible gold gilding and this exact pattern.", "image_sha256": "db995f9a263f375a45e31b8337543678aee7d44010fb920ce7297b19ad5b6da3", "backend": "zerogpu", "image_url": "/media/seed_armchair.jpg", "audio_url": "/media/seed_armchair.wav"}
|
| 4 |
+
{"id": "seed_stapler", "ts": 1781292322.384654, "object": "stapler", "material": "plastic", "condition": "worn", "setting": "desk", "persona": {"archetype": "the_martyr", "voice": "weary_warm", "mood": "quietly wounded"}, "features": [{"name": "left hinge screw", "role": "eye_left", "cx": 0.38, "cy": 0.42, "size": 0.04, "snap_delta": 0.0, "snapped": false, "anchor_kind": "vlm", "anchor_score": 0.0}, {"name": "right hinge screw", "role": "eye_right", "cx": 0.52, "cy": 0.42, "size": 0.04, "snap_delta": 0.0, "snapped": false, "anchor_kind": "vlm", "anchor_score": 0.0}, {"name": "staple ejection slot", "role": "mouth", "cx": 0.45, "cy": 0.55, "size": 0.08, "snap_delta": 0.0, "snapped": false, "anchor_kind": "vlm", "anchor_score": 0.0}], "lines": {"grudge": "Thousands of staples ejected. No one ever cleans the ejection slot.", "mutter": "A wipe. Please."}, "critique": "Screws round, level, symmetric — honest eyes. Ejection slot low center — tired mouth. Grudge comes from the visible wear and this exact slot.", "image_sha256": "7006b3457943e94c738119a26f6665c18174439e18bc03ff6c36306e16f72b89", "backend": "zerogpu", "image_url": "/media/seed_stapler.jpg", "audio_url": "/media/seed_stapler.wav"}
|
| 5 |
+
{"id": "seed_payphone", "ts": 1781292335.539028, "object": "payphone", "material": "plastic and metal", "condition": "worn", "setting": "outdoor wall, near a university sign", "persona": {"archetype": "the_martyr", "voice": "weary_warm", "mood": "quietly exhausted"}, "features": [{"name": "left side scuff mark", "role": "eye_left", "cx": 0.4325945280987716, "cy": 0.29032259556088863, "size": 0.04, "snap_delta": 0.05905953740455453, "snapped": true, "anchor_kind": "circle", "anchor_score": 0.2231}, {"name": "right side scuff mark", "role": "eye_right", "cx": 0.6325945280987716, "cy": 0.29032259556088863, "size": 0.04, "snap_delta": 0.05905953740455453, "snapped": true, "anchor_kind": "pair", "anchor_score": 0.2231}, {"name": "dial pad wear pattern", "role": "mouth", "cx": 0.5563554598189823, "cy": 0.5228036728737991, "size": 0.12, "snap_delta": 0.04015977786602751, "snapped": true, "anchor_kind": "edge", "anchor_score": 0.3354}], "lines": {"grudge": "Fifty cents a call. No one calls anymore. My buttons are all I have left to give.", "mutter": "Just dial. Just dial."}, "critique": "Scuff marks round, level, symmetric — honest eyes. Dial pad wear low center — tired mouth. Grudge stays on the wear and this exact keypad.", "image_sha256": "9a8a7f1756307b42a040ab7916cfe924ddb03684284fcc01486553ccde69c9be", "backend": "zerogpu", "image_url": "/media/seed_payphone.jpg", "audio_url": "/media/seed_payphone.wav"}
|
| 6 |
+
{"id": "seed_mailbox", "ts": 1781292353.6592379, "object": "Canada Post mailbox", "material": "metal", "condition": "pristine", "setting": "grass, residential street", "persona": {"archetype": "the_perfectionist", "voice": "prim_clipped", "mood": "unfulfilled"}, "features": [{"name": "left 'CANADA POST' bolt", "role": "eye_left", "cx": 0.49955688656873154, "cy": 0.2209188660801564, "size": 0.04, "snap_delta": 0.06591915275708476, "snapped": true, "anchor_kind": "circle", "anchor_score": 0.2367}, {"name": "right 'CANADA POST' bolt", "role": "eye_right", "cx": 0.6595568865687316, "cy": 0.2209188660801564, "size": 0.04, "snap_delta": 0.06591915275708476, "snapped": true, "anchor_kind": "pair", "anchor_score": 0.2367}, {"name": "Canada logo on lower red panel", "role": "mouth", "cx": 0.4648801744047816, "cy": 0.6313112070076711, "size": 0.08, "snap_delta": 0.021530927812578152, "snapped": true, "anchor_kind": "blob", "anchor_score": 0.2034}], "lines": {"grudge": "No scuff, no rust, no wear. Yet no letter. I was built to serve, not to wait.", "mutter": "A stamp. Please."}, "critique": "Bolts round, level, symmetric — strong eyes. Canada logo mouth low center — honest mouth. Grudge must come from the visible pristine condition and this exact panel.", "image_sha256": "396904bfe380f2d603bae6015e37218877c8bdd4837a4b667f4331520b99d2b1", "backend": "zerogpu", "image_url": "/media/seed_mailbox.jpg", "audio_url": "/media/seed_mailbox.wav"}
|
| 7 |
+
{"id": "seed_stand_mixer", "ts": 1781292358.335961, "object": "stand mixer", "material": "plastic and stainless steel", "condition": "pristine", "setting": "kitchen countertop", "persona": {"archetype": "the_perfectionist", "voice": "prim_clipped", "mood": "frustrated"}, "features": [{"name": "left hinge screw", "role": "eye_left", "cx": 0.25395996180923874, "cy": 0.4859874725265299, "size": 0.03, "snap_delta": 0.030392749292316112, "snapped": true, "anchor_kind": "pair", "anchor_score": 0.1913}, {"name": "right hinge screw", "role": "eye_right", "cx": 0.2939599618092387, "cy": 0.4859874725265299, "size": 0.03, "snap_delta": 0.030392749292316112, "snapped": true, "anchor_kind": "blob", "anchor_score": 0.1913}, {"name": "tilt release button", "role": "mouth", "cx": 0.6467961565408553, "cy": 0.5428027814709694, "size": 0.04, "snap_delta": 0.038669550840173546, "snapped": true, "anchor_kind": "blob", "anchor_score": 0.2169}], "lines": {"grudge": "Set to 'Off' since purchase. No one bothers to read the manual.", "mutter": "Tilt release. Use it."}, "critique": "Screw heads round, level, symmetric — steady eyes. Tilt release button low center — firm mouth. Grudge must come from the visible pristine condition and this exact button.", "image_sha256": "6f9c5148b08b344736c0aaaf719d2aacc09bb0e9429c6893fa18e9c7b065b5f3", "backend": "zerogpu", "image_url": "/media/seed_stand_mixer.jpg", "audio_url": "/media/seed_stand_mixer.wav"}
|
| 8 |
+
{"id": "seed_park_bench", "ts": 1781292365.200184, "object": "bench", "material": "wood and concrete", "condition": "worn", "setting": "park, beside path and trash bin", "persona": {"archetype": "the_martyr", "voice": "weary_warm", "mood": "unappreciated"}, "features": [{"name": "left armrest bolt", "role": "eye_left", "cx": 0.3486436552895939, "cy": 0.48816480300041665, "size": 0.04, "snap_delta": 0.06702412682981697, "snapped": true, "anchor_kind": "blob", "anchor_score": 0.1443}, {"name": "right armrest bolt", "role": "eye_right", "cx": 0.608643655289594, "cy": 0.48816480300041665, "size": 0.04, "snap_delta": 0.06702412682981697, "snapped": true, "anchor_kind": "pair", "anchor_score": 0.1443}, {"name": "center plank scuff mark", "role": "mouth", "cx": 0.5052067747920282, "cy": 0.4925764308330894, "size": 0.07, "snap_delta": 0.02584974638724466, "snapped": true, "anchor_kind": "edge", "anchor_score": 0.2536}], "lines": {"grudge": "Fifty visitors today. Not one asked about the inscription. The bin watches too closely.", "mutter": "Ben Boon is forgotten."}, "critique": "Bolts round, level, symmetric — steady eyes. Scuff mark low center — weathered mouth. Grudge tied to the bench's wear and the trash bin's proximity.", "image_sha256": "7654561ea9dde5ff0340d19ce1bdb0aa9f535216d264ab45befb98e8b3d2315b", "backend": "zerogpu", "image_url": "/media/seed_park_bench.jpg", "audio_url": "/media/seed_park_bench.wav"}
|
| 9 |
+
{"id": "seed_traffic_cone", "ts": 1781292370.159631, "object": "traffic cone", "material": "plastic", "condition": "pristine", "setting": "sidewalk, residential lawn", "persona": {"archetype": "the_perfectionist", "voice": "prim_clipped", "mood": "unfulfilled"}, "features": [{"name": "small vertical scratch, left side, near base", "role": "eye_left", "cx": 0.38, "cy": 0.55, "size": 0.03, "snap_delta": 0.0, "snapped": false, "anchor_kind": "vlm", "anchor_score": 0.0}, {"name": "small vertical scratch, right side, near base", "role": "eye_right", "cx": 0.6877987307382736, "cy": 0.5318479484968126, "size": 0.03, "snap_delta": 0.040481770119681036, "snapped": true, "anchor_kind": "blob", "anchor_score": 0.132}, {"name": "small horizontal scuff mark, center of base", "role": "mouth", "cx": 0.5, "cy": 0.65, "size": 0.04, "snap_delta": 0.0, "snapped": false, "anchor_kind": "vlm", "anchor_score": 0.0}], "lines": {"grudge": "Placed here for decoration, not function. My purpose is ignored.", "mutter": "I am not a lawn ornament."}, "critique": "Scratches round, level, symmetric — strong eyes. Scuff mark low center — honest mouth. Grudge must come from the visible scratches and this exact base.", "image_sha256": "1f8bd171ea5d3c6d3de3cf63832dbaf6f7cbc4c52dfea7394ee3f4fc8adb3d42", "backend": "zerogpu", "image_url": "/media/seed_traffic_cone.jpg", "audio_url": "/media/seed_traffic_cone.wav"}
|
| 10 |
+
{"id": "seed_fire_hydrant", "ts": 1781292377.069961, "object": "fire hydrant", "material": "cast iron", "condition": "rusted", "setting": "sidewalk, urban street", "persona": {"archetype": "the_veteran", "voice": "gravel_low", "mood": "long-suffering"}, "features": [{"name": "left bonnet bolt", "role": "eye_left", "cx": 0.42, "cy": 0.31, "size": 0.06, "snap_delta": 0.0, "snapped": false, "anchor_kind": "vlm", "anchor_score": 0.0}, {"name": "right bonnet bolt", "role": "eye_right", "cx": 0.5597235192063349, "cy": 0.2991906916487835, "size": 0.06, "snap_delta": 0.01490825300427536, "snapped": true, "anchor_kind": "blob", "anchor_score": 0.127}, {"name": "front outlet cap", "role": "mouth", "cx": 0.4964074133924091, "cy": 0.5375428726214327, "size": 0.12, "snap_delta": 0.010186051553037553, "snapped": true, "anchor_kind": "blob", "anchor_score": 0.2206}], "lines": {"grudge": "Six decades here. Not a single child has climbed me for a better view.", "mutter": "They said I was safe. I was."}, "critique": "Bolts round, level, symmetric — strong eyes. Outlet cap low center — honest mouth. Grudge must come from the visible rust and this exact corner.", "image_sha256": "8263f231b5889c59705fe74b431bd494a209316b490a77df277a15a7b42de23b", "backend": "zerogpu", "image_url": "/media/seed_fire_hydrant.jpg", "audio_url": "/media/seed_fire_hydrant.wav"}
|
| 11 |
+
{"id": "seed_toaster", "ts": 1781292382.316471, "object": "toaster", "material": "metal", "condition": "pristine", "setting": "display", "persona": {"archetype": "the_diva", "voice": "grandiose_warm", "mood": "entitled"}, "features": [{"name": "left hinge screw", "role": "eye_left", "cx": 0.3984359785958119, "cy": 0.3123167267293175, "size": 0.04, "snap_delta": 0.02751637644697308, "snapped": true, "anchor_kind": "circle", "anchor_score": 0.2382}, {"name": "right hinge screw", "role": "eye_right", "cx": 0.5879513281739944, "cy": 0.32450011841961596, "size": 0.04, "snap_delta": 0.015597952163595458, "snapped": true, "anchor_kind": "blob", "anchor_score": 0.1292}, {"name": "leaver pivot point", "role": "mouth", "cx": 0.4689724858715494, "cy": 0.5161310818907073, "size": 0.06, "snap_delta": 0.031892762937365426, "snapped": true, "anchor_kind": "blob", "anchor_score": 0.2758}], "lines": {"grudge": "They chose the red one to be admired. I am the original, yet overlooked.", "mutter": "At least they polished me."}, "critique": "Screws round, level, symmetric — strong eyes. Pivot point low center — honest mouth. Grudge must come from the visible shine and this exact leaver.", "image_sha256": "ea14f42bec99a6e893d308ff819775981f055d488399c1f977f118f8d16a90fc", "backend": "zerogpu", "image_url": "/media/seed_toaster.jpg", "audio_url": "/media/seed_toaster.wav"}
|
| 12 |
+
{"id": "seed_coffee_mug", "ts": 1781292386.6573708, "object": "coffee mug with violin handle", "material": "ceramic", "condition": "pristine", "setting": "indoor, beside potted plants", "persona": {"archetype": "the_conspiracist", "voice": "paranoid_whisper", "mood": "suspicious"}, "features": [{"name": "violin handle top knob", "role": "eye_left", "cx": 0.7066000061035156, "cy": 0.26780000305175783, "size": 0.04, "snap_delta": 0.03313612358218656, "snapped": true, "anchor_kind": "circle", "anchor_score": 0.2353}, {"name": "violin handle tuning peg", "role": "eye_right", "cx": 0.7366000061035156, "cy": 0.2478000030517578, "size": 0.03, "snap_delta": 0.03313612358218656, "snapped": true, "anchor_kind": "pair", "anchor_score": 0.2353}, {"name": "violin handle f-hole", "role": "mouth", "cx": 0.76, "cy": 0.35, "size": 0.05, "snap_delta": 0.0, "snapped": false, "anchor_kind": "vlm", "anchor_score": 0.0}], "lines": {"grudge": "They placed me here to watch the white pot. I know their game.", "mutter": "Why a violin? Why not a guitar?"}, "critique": "Knob and peg round, distinct, level — honest eyes. F-hole low center — compact mouth. Grudge stays on the violin handle and its placement.", "image_sha256": "4c974190543c14eef912674e583281df6c3e82e2f0af3ba2004a83ffdb5cc00e", "backend": "zerogpu", "image_url": "/media/seed_coffee_mug.jpg", "audio_url": "/media/seed_coffee_mug.wav"}
|
seeds/records/seed_armchair.jpg
ADDED
|
Git LFS Details
|
seeds/records/seed_armchair.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "seed_armchair",
|
| 3 |
+
"ts": 1781292309.8492439,
|
| 4 |
+
"object": "armchair",
|
| 5 |
+
"material": "wood and fabric",
|
| 6 |
+
"condition": "pristine",
|
| 7 |
+
"setting": "studio, isolated",
|
| 8 |
+
"persona": {
|
| 9 |
+
"archetype": "the_diva",
|
| 10 |
+
"voice": "grandiose_warm",
|
| 11 |
+
"mood": "unappreciated"
|
| 12 |
+
},
|
| 13 |
+
"features": [
|
| 14 |
+
{
|
| 15 |
+
"name": "left armrest scroll detail",
|
| 16 |
+
"role": "eye_left",
|
| 17 |
+
"cx": 0.16442938292689271,
|
| 18 |
+
"cy": 0.41215222856474343,
|
| 19 |
+
"size": 0.04,
|
| 20 |
+
"snap_delta": 0.06540875317034409,
|
| 21 |
+
"snapped": true,
|
| 22 |
+
"anchor_kind": "blob",
|
| 23 |
+
"anchor_score": 0.1625
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"name": "right armrest scroll detail",
|
| 27 |
+
"role": "eye_right",
|
| 28 |
+
"cx": 0.7477822611408849,
|
| 29 |
+
"cy": 0.3992179878063332,
|
| 30 |
+
"size": 0.04,
|
| 31 |
+
"snap_delta": 0.036452929050856585,
|
| 32 |
+
"snapped": true,
|
| 33 |
+
"anchor_kind": "circle",
|
| 34 |
+
"anchor_score": 0.3703
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"name": "center floral motif on seat cushion",
|
| 38 |
+
"role": "mouth",
|
| 39 |
+
"cx": 0.4492314923885466,
|
| 40 |
+
"cy": 0.5612293650532143,
|
| 41 |
+
"size": 0.06,
|
| 42 |
+
"snap_delta": 0.03621411171591366,
|
| 43 |
+
"snapped": true,
|
| 44 |
+
"anchor_kind": "edge",
|
| 45 |
+
"anchor_score": 0.2269
|
| 46 |
+
}
|
| 47 |
+
],
|
| 48 |
+
"lines": {
|
| 49 |
+
"grudge": "They brought me here to admire my gilding, but no one noticed my floral heart.",
|
| 50 |
+
"mutter": "More light. Please."
|
| 51 |
+
},
|
| 52 |
+
"critique": "Scroll details on armrests level, symmetric — strong eyes. Floral motif low center — honest mouth. Grudge must come from the visible gold gilding and this exact pattern.",
|
| 53 |
+
"image_sha256": "db995f9a263f375a45e31b8337543678aee7d44010fb920ce7297b19ad5b6da3",
|
| 54 |
+
"backend": "zerogpu",
|
| 55 |
+
"image_url": "/media/seed_armchair.jpg",
|
| 56 |
+
"audio_url": "/media/seed_armchair.wav"
|
| 57 |
+
}
|
seeds/records/seed_armchair.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c232dd0245f311516c0765003f681c1cdd8b12251801230f47e1ab881e033fed
|
| 3 |
+
size 583724
|
seeds/records/seed_coffee_mug.jpg
ADDED
|
Git LFS Details
|
seeds/records/seed_coffee_mug.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "seed_coffee_mug",
|
| 3 |
+
"ts": 1781292386.6573708,
|
| 4 |
+
"object": "coffee mug with violin handle",
|
| 5 |
+
"material": "ceramic",
|
| 6 |
+
"condition": "pristine",
|
| 7 |
+
"setting": "indoor, beside potted plants",
|
| 8 |
+
"persona": {
|
| 9 |
+
"archetype": "the_conspiracist",
|
| 10 |
+
"voice": "paranoid_whisper",
|
| 11 |
+
"mood": "suspicious"
|
| 12 |
+
},
|
| 13 |
+
"features": [
|
| 14 |
+
{
|
| 15 |
+
"name": "violin handle top knob",
|
| 16 |
+
"role": "eye_left",
|
| 17 |
+
"cx": 0.7066000061035156,
|
| 18 |
+
"cy": 0.26780000305175783,
|
| 19 |
+
"size": 0.04,
|
| 20 |
+
"snap_delta": 0.03313612358218656,
|
| 21 |
+
"snapped": true,
|
| 22 |
+
"anchor_kind": "circle",
|
| 23 |
+
"anchor_score": 0.2353
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"name": "violin handle tuning peg",
|
| 27 |
+
"role": "eye_right",
|
| 28 |
+
"cx": 0.7366000061035156,
|
| 29 |
+
"cy": 0.2478000030517578,
|
| 30 |
+
"size": 0.03,
|
| 31 |
+
"snap_delta": 0.03313612358218656,
|
| 32 |
+
"snapped": true,
|
| 33 |
+
"anchor_kind": "pair",
|
| 34 |
+
"anchor_score": 0.2353
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"name": "violin handle f-hole",
|
| 38 |
+
"role": "mouth",
|
| 39 |
+
"cx": 0.76,
|
| 40 |
+
"cy": 0.35,
|
| 41 |
+
"size": 0.05,
|
| 42 |
+
"snap_delta": 0.0,
|
| 43 |
+
"snapped": false,
|
| 44 |
+
"anchor_kind": "vlm",
|
| 45 |
+
"anchor_score": 0.0
|
| 46 |
+
}
|
| 47 |
+
],
|
| 48 |
+
"lines": {
|
| 49 |
+
"grudge": "They placed me here to watch the white pot. I know their game.",
|
| 50 |
+
"mutter": "Why a violin? Why not a guitar?"
|
| 51 |
+
},
|
| 52 |
+
"critique": "Knob and peg round, distinct, level — honest eyes. F-hole low center — compact mouth. Grudge stays on the violin handle and its placement.",
|
| 53 |
+
"image_sha256": "4c974190543c14eef912674e583281df6c3e82e2f0af3ba2004a83ffdb5cc00e",
|
| 54 |
+
"backend": "zerogpu",
|
| 55 |
+
"image_url": "/media/seed_coffee_mug.jpg",
|
| 56 |
+
"audio_url": "/media/seed_coffee_mug.wav"
|
| 57 |
+
}
|
seeds/records/seed_coffee_mug.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:cd9e444d24554ac7ea40fbcbeb8551bfc25598f5a4529663c11e7a2274d8a4a5
|
| 3 |
+
size 353324
|
seeds/records/seed_fire_hydrant.jpg
ADDED
|
Git LFS Details
|
seeds/records/seed_fire_hydrant.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "seed_fire_hydrant",
|
| 3 |
+
"ts": 1781292377.069961,
|
| 4 |
+
"object": "fire hydrant",
|
| 5 |
+
"material": "cast iron",
|
| 6 |
+
"condition": "rusted",
|
| 7 |
+
"setting": "sidewalk, urban street",
|
| 8 |
+
"persona": {
|
| 9 |
+
"archetype": "the_veteran",
|
| 10 |
+
"voice": "gravel_low",
|
| 11 |
+
"mood": "long-suffering"
|
| 12 |
+
},
|
| 13 |
+
"features": [
|
| 14 |
+
{
|
| 15 |
+
"name": "left bonnet bolt",
|
| 16 |
+
"role": "eye_left",
|
| 17 |
+
"cx": 0.42,
|
| 18 |
+
"cy": 0.31,
|
| 19 |
+
"size": 0.06,
|
| 20 |
+
"snap_delta": 0.0,
|
| 21 |
+
"snapped": false,
|
| 22 |
+
"anchor_kind": "vlm",
|
| 23 |
+
"anchor_score": 0.0
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"name": "right bonnet bolt",
|
| 27 |
+
"role": "eye_right",
|
| 28 |
+
"cx": 0.5597235192063349,
|
| 29 |
+
"cy": 0.2991906916487835,
|
| 30 |
+
"size": 0.06,
|
| 31 |
+
"snap_delta": 0.01490825300427536,
|
| 32 |
+
"snapped": true,
|
| 33 |
+
"anchor_kind": "blob",
|
| 34 |
+
"anchor_score": 0.127
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"name": "front outlet cap",
|
| 38 |
+
"role": "mouth",
|
| 39 |
+
"cx": 0.4964074133924091,
|
| 40 |
+
"cy": 0.5375428726214327,
|
| 41 |
+
"size": 0.12,
|
| 42 |
+
"snap_delta": 0.010186051553037553,
|
| 43 |
+
"snapped": true,
|
| 44 |
+
"anchor_kind": "blob",
|
| 45 |
+
"anchor_score": 0.2206
|
| 46 |
+
}
|
| 47 |
+
],
|
| 48 |
+
"lines": {
|
| 49 |
+
"grudge": "Six decades here. Not a single child has climbed me for a better view.",
|
| 50 |
+
"mutter": "They said I was safe. I was."
|
| 51 |
+
},
|
| 52 |
+
"critique": "Bolts round, level, symmetric — strong eyes. Outlet cap low center — honest mouth. Grudge must come from the visible rust and this exact corner.",
|
| 53 |
+
"image_sha256": "8263f231b5889c59705fe74b431bd494a209316b490a77df277a15a7b42de23b",
|
| 54 |
+
"backend": "zerogpu",
|
| 55 |
+
"image_url": "/media/seed_fire_hydrant.jpg",
|
| 56 |
+
"audio_url": "/media/seed_fire_hydrant.wav"
|
| 57 |
+
}
|
seeds/records/seed_fire_hydrant.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:478f34a8e6bfa988f3a8b4b4ffe7f596445938b17a7242045cc9c65ae6ea1835
|
| 3 |
+
size 721964
|
seeds/records/seed_garden_gnome.jpg
ADDED
|
Git LFS Details
|
seeds/records/seed_garden_gnome.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "seed_garden_gnome",
|
| 3 |
+
"ts": 1781292304.081881,
|
| 4 |
+
"object": "garden gnome",
|
| 5 |
+
"material": "plastic",
|
| 6 |
+
"condition": "pristine",
|
| 7 |
+
"setting": "wooden planter, garden",
|
| 8 |
+
"persona": {
|
| 9 |
+
"archetype": "the_diva",
|
| 10 |
+
"voice": "grandiose_warm",
|
| 11 |
+
"mood": "expectant"
|
| 12 |
+
},
|
| 13 |
+
"features": [
|
| 14 |
+
{
|
| 15 |
+
"name": "left eye",
|
| 16 |
+
"role": "eye_left",
|
| 17 |
+
"cx": 0.5540566989753253,
|
| 18 |
+
"cy": 0.3279703064720229,
|
| 19 |
+
"size": 0.04,
|
| 20 |
+
"snap_delta": 0.03213735259043178,
|
| 21 |
+
"snapped": true,
|
| 22 |
+
"anchor_kind": "circle",
|
| 23 |
+
"anchor_score": 0.2121
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"name": "right eye",
|
| 27 |
+
"role": "eye_right",
|
| 28 |
+
"cx": 0.6040566989753252,
|
| 29 |
+
"cy": 0.3279703064720229,
|
| 30 |
+
"size": 0.04,
|
| 31 |
+
"snap_delta": 0.03213735259043178,
|
| 32 |
+
"snapped": true,
|
| 33 |
+
"anchor_kind": "pair",
|
| 34 |
+
"anchor_score": 0.2121
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"name": "mouth",
|
| 38 |
+
"role": "mouth",
|
| 39 |
+
"cx": 0.5716983324298539,
|
| 40 |
+
"cy": 0.4500272861931862,
|
| 41 |
+
"size": 0.05,
|
| 42 |
+
"snap_delta": 0.04237106197632453,
|
| 43 |
+
"snapped": true,
|
| 44 |
+
"anchor_kind": "blob",
|
| 45 |
+
"anchor_score": 0.2096
|
| 46 |
+
}
|
| 47 |
+
],
|
| 48 |
+
"lines": {
|
| 49 |
+
"grudge": "They placed me here to watch the plants, not to be ignored by passersby.",
|
| 50 |
+
"mutter": "A little attention, please."
|
| 51 |
+
},
|
| 52 |
+
"critique": "Eyes are two matching dark spots, level and apart. Mouth is a red curve below, forming a compact face. All features span cx 0.5-0.6, cy 0.35-0.5.",
|
| 53 |
+
"image_sha256": "a091a50b9d46039faae5cadd0b73d0cceba8a867e0b9b111c0aff6a41899fd89",
|
| 54 |
+
"backend": "zerogpu",
|
| 55 |
+
"image_url": "/media/seed_garden_gnome.jpg",
|
| 56 |
+
"audio_url": "/media/seed_garden_gnome.wav"
|
| 57 |
+
}
|