Files
ely-eye/bench/visual_recall.py
ZacharyZhang-NY 5289cb4b01 Give spoken words a memory with an instant attached
An audio file used to produce no memory at all: _ingest_file returned an empty
batch for any suffix it did not know, with no log and no reason. It now raises
UnsupportedFile when you name the file and records a SkippedFile when it was a
directory walk, so a refusal is visible either way.

Speech is measured, not promised. Over 210.5 seconds of real Video-MME audio,
faster-whisper-tiny on CPU int8 transcribed at 51x realtime and reached word
error rate 0.242 against community subtitles nobody here wrote (a caption track
drops words a transcript keeps, so that is an upper bound). 66 speech atoms now
carry 550 word-level timestamps, 0 words out of order and 0 starting before
their own segment. The snapshot is 78.2 MB, under the 500 MB cap, and the report
is bench/reports/ASR.md.

Two library facts were measured rather than assumed. faster-whisper's own audio
loader is broken against PyAV 19 (av.open got an unexpected keyword
'metadata_errors') and it declares av with no version bound, so asr.decode_audio
decodes with PyAV directly and hands the model a float32 array instead of
pinning an old av with no cp314 wheel. And av.Error does not exist on PyAV 19 -
the real base is av.error.FFmpegError - so that is the only thing caught and my
own ValueErrors keep their messages.

Files over the 500-line rule were split rather than excused: ingestion.py (748)
into ingestion_common / ingestion_media / ingestion_repository / ingestion, and
cli.py (544) into cli_senses plus cli_common, following the cli_transactive
register pattern. MediaIngest declares _source_record abstract, so a half-wired
host fails at construction instead of returning None.

Verification: 207 tests pass; ruff clean on the 13 touched files; mypy clean on
them, and repo-wide mypy fell from 29 errors to 24 (5 fixed, none added);
pyright reports 0 errors on the touched files.
2026-10-07 21:18:28 -04:00

405 lines
16 KiB
Python

"""Can a library of wordless pictures be searched?
ScreenSpot-v2 is the only corpus in this suite whose gold answer is a *region of a screen*, and
its icon targets carry almost no text. That makes it the honest test of the picture index: a
sentence about an icon has to reach the icon itself, not a transcription of it.
Six arms, three questions:
words BM25 over whatever OCR found — what the product did before any picture vector
picture a sentence, or a whole screenshot, asked of the CLIP space
pool the same question at three granularities: among all icon crops, among whole
screenshots, and among the icons of one screen (the shape ScreenSpot itself poses)
"""
from __future__ import annotations
import hashlib
import json
import re
import statistics
import subprocess
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from ely_eye.config import Settings
from ely_eye.db import Database
from ely_eye.ocr import read_text_from_image
from ely_eye.retrieval import RetrievalService
from ely_eye.schemas import (
EvidenceAtom,
Modality,
SourceRecord,
TrustScores,
)
from ely_eye.storage import (
ObjectStore,
token_equivalent,
visual_token_equivalent,
)
from ely_eye.visual_index import VisualIndex
from bench.metrics import aggregate, chance_recall_at_k, score_query
from bench.systems import machine_facts
ROOT = Path(__file__).resolve().parent
CORPUS = ROOT / "corpus" / "OS-Copilot" / "ScreenSpot-v2"
IMAGES = CORPUS / "images"
IMAGE_ZIP = CORPUS / "screenspotv2_image.zip"
CROPS = ROOT / "runs" / "visual" / "crops"
HOME = ROOT / "runs" / "visual"
MODEL_DIR = ROOT / "models" / "chinese-clip-vit-base-patch16"
REPORTS = ROOT / "reports"
TOP_K = 10
@dataclass(frozen=True)
class Arm:
"""One way to ask, one pool to answer from, one kind of gold."""
label: str
signal: str # words | picture_words | picture
pool: str # crops | shots | own_screen
gold: str # crop | screen
@property
def key(self) -> str:
return f"{self.signal}->{self.pool}"
ARMS = (
Arm("words → icon crops", "words", "crops", "crop"),
Arm("a sentence → icon crops (picture space)", "picture_words", "crops", "crop"),
Arm("the whole screenshot → icon crops", "picture", "crops", "crop"),
Arm("words → whole screenshots", "words", "shots", "screen"),
Arm("a sentence → whole screenshots (picture space)", "picture_words", "shots", "screen"),
Arm("a sentence → the icons of that one screen", "picture_words", "own_screen", "crop"),
)
def git_commit() -> str:
return subprocess.run(
["git", "rev-parse", "HEAD"], cwd=ROOT.parent, check=True, capture_output=True, text=True
).stdout.strip()
def model_sha256() -> str:
graph = MODEL_DIR / "onnx" / "model_fp16.onnx"
if not graph.exists():
raise SystemExit(f"{graph} is missing; fetch the Chinese-CLIP fp16 export first")
digest = hashlib.sha256()
with graph.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def ensure_images() -> None:
"""The dataset ships its screenshots inside one zip; unpack them once, flat."""
if IMAGES.exists() and any(IMAGES.iterdir()):
return
if not IMAGE_ZIP.exists():
raise SystemExit(f"{IMAGE_ZIP} is missing; download the ScreenSpot-v2 corpus first")
import zipfile
started = time.monotonic()
with zipfile.ZipFile(IMAGE_ZIP) as archive:
names = [name for name in archive.namelist() if name.lower().endswith((".png", ".jpg", ".jpeg"))]
IMAGES.mkdir(parents=True, exist_ok=True)
for name in names:
target = IMAGES / Path(name).name
if not target.exists():
target.write_bytes(archive.read(name))
print(f"unpacked {len(names)} screenshots into {IMAGES} in {time.monotonic() - started:.1f}s", flush=True)
def icon_targets() -> list[dict[str, Any]]:
"""Every icon instruction, with its crop and the screenshot it came from."""
ensure_images()
targets: list[dict[str, Any]] = []
for name in ("mobile", "desktop", "web"):
rows = _read_rows(CORPUS / f"screenspot_{name}_v2.json")
for row in rows:
if row.get("data_type") != "icon":
continue
picture = CORPUS / "images" / row["img_filename"]
if not picture.exists():
raise SystemExit(f"{picture}: ScreenSpot names a screenshot that is not on disk")
stem = Path(row["img_filename"]).stem
crop_path = CROPS / f"{stem}-{len(targets)}.png"
screenshot_path = CROPS / f"{stem}-full.png"
CROPS.mkdir(parents=True, exist_ok=True)
from PIL import Image
with Image.open(picture) as screen:
left, top, width, height = [int(value) for value in row["bbox"]]
if not crop_path.exists():
screen.crop((left, top, left + width, top + height)).convert("RGB").save(crop_path)
if not screenshot_path.exists():
screen.convert("RGB").save(screenshot_path)
targets.append(
{
"instruction": row["instruction"],
"source_id": f"{stem}-icon-{len(targets)}",
"screen_id": f"screen-{stem}",
"crop": crop_path,
"screenshot": screenshot_path,
"size": (width, height),
"holder": row.get("data_source"),
}
)
if not targets:
raise SystemExit(f"{CORPUS}: no icon targets found; is the ScreenSpot-v2 corpus downloaded?")
return targets
def screenshot_targets(targets: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""One memory per distinct screenshot: the granularity the product actually stores."""
screens: dict[str, dict[str, Any]] = {}
for target in targets:
screen = screens.setdefault(
target["screen_id"],
{"source_id": target["screen_id"], "screenshot": target["screenshot"], "crops": []},
)
screen["crops"].append(target["source_id"])
return list(screens.values())
def _read_rows(path: Path) -> list[dict[str, Any]]:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise ValueError(f"{path}: the ScreenSpot instruction file is unreadable ({error})") from error
def _atom(settings: Settings, store: ObjectStore, path: Path, source_id: str, modality: Modality) -> EvidenceAtom:
ocr = read_text_from_image(path)
width, height = _size(path)
return EvidenceAtom(
atom_id=f"{source_id}:image",
modality=modality,
source_id=source_id,
source=str(path),
text=ocr.text,
image_ref=store.put_file(path, "images"),
trust=TrustScores(parser=1.0, ocr=ocr.confidence, model=0.0),
token_equivalent=token_equivalent(ocr.text) + visual_token_equivalent(width, height),
scenario="ui-referencing",
)
def ingest(settings: Settings, targets: list[dict[str, Any]], screens: list[dict[str, Any]]) -> None:
"""Store each crop and each screenshot as one memory, the way ingestion would."""
database = Database(settings)
store = ObjectStore(settings)
atoms: list[EvidenceAtom] = []
sources: list[SourceRecord] = []
for position, target in enumerate(targets):
path = Path(target["crop"])
atoms.append(_atom(settings, store, path, str(target["source_id"]), Modality.image))
sources.append(_source(str(target["source_id"]), path))
if (position + 1) % 100 == 0:
print(f"ingested {position + 1}/{len(targets)} crops", flush=True)
for position, screen in enumerate(screens):
path = Path(screen["screenshot"])
atoms.append(_atom(settings, store, path, str(screen["source_id"]), Modality.ui_screenshot))
sources.append(_source(str(screen["source_id"]), path))
if (position + 1) % 100 == 0:
print(f"ingested {position + 1}/{len(screens)} screenshots", flush=True)
database.upsert_sources(sources)
database.upsert_atoms(atoms)
def _source(source_id: str, path: Path) -> SourceRecord:
return SourceRecord(
source_id=source_id,
path=str(path),
kind="image",
mime="image/png",
sha256=hashlib.sha256(path.read_bytes()).hexdigest(),
size_bytes=path.stat().st_size,
)
def _size(path: Path) -> tuple[int, int]:
from PIL import Image
with Image.open(path) as picture:
return picture.size
def run_arm(
arm: Arm,
targets: list[dict[str, Any]],
pools: dict[str, Any],
index: VisualIndex,
retrieval: RetrievalService,
source_of_atom: dict[str, str],
) -> tuple[list[list[str]], list[float]]:
"""Ask every instruction one way, and keep the answers in corpus ids."""
ranked: list[list[str]] = []
seconds: list[float] = []
for position, target in enumerate(targets):
pool = _pool(arm, target, pools)
started = time.monotonic()
if arm.signal == "words":
hits = [hit.atom.atom_id for hit in retrieval.search(target["instruction"], TOP_K * 4).hits]
answer = [hit for hit in hits if pool is None or hit in pool][:TOP_K]
elif arm.signal == "picture_words":
answer = [atom_id for atom_id, _ in index.search_text(target["instruction"], TOP_K, pool)]
else:
answer = [atom_id for atom_id, _ in index.search_image(target["screenshot"], TOP_K, pool)]
seconds.append(time.monotonic() - started)
ranked.append([source_of_atom[atom_id] for atom_id in answer if atom_id in source_of_atom])
if (position + 1) % 100 == 0:
print(f"{arm.label}: {position + 1}/{len(targets)}", flush=True)
return ranked, seconds
def _pool(arm: Arm, target: dict[str, Any], pools: dict[str, Any]) -> set[str] | None:
if arm.pool == "own_screen":
return {
atom_id
for atom_id, source_id in pools["atom_of_target"].items()
if source_id in set(pools["screen_crops"][target["screen_id"]])
}
return pools.get(arm.pool)
def score(arm: Arm, targets: list[dict[str, Any]], ranked: list[list[str]], seconds: list[float]) -> dict[str, Any]:
scores = [
score_query(
f"{target['source_id']}, {target['instruction'][:40]}",
hits,
{target["source_id"] if arm.gold == "crop" else target["screen_id"]},
)
for target, hits in zip(targets, ranked, strict=True)
]
ordered = sorted(seconds)
return {
"label": arm.label,
"key": arm.key,
**aggregate(scores),
"latency": {
"queries": len(scores),
"p50": round(statistics.median(ordered), 4),
"p95": round(ordered[min(len(ordered) - 1, int(len(ordered) * 0.95))], 4),
},
}
def main() -> None:
settings = Settings(home=HOME, clip_model_dir=MODEL_DIR)
targets = icon_targets()
screens = screenshot_targets(targets)
database = Database(settings)
if database.atom_count() != len(targets) + len(screens):
started = time.monotonic()
ingest(settings, targets, screens)
print(f"ingested {len(targets)} crops + {len(screens)} screenshots in {time.monotonic() - started:.1f}s", flush=True)
source_of_atom = {atom.atom_id: atom.source_id for atom in database.list_atoms()}
crops = {atom.atom_id for atom in database.list_atoms() if atom.modality is Modality.image}
shots = {atom.atom_id for atom in database.list_atoms() if atom.modality is Modality.ui_screenshot}
wordless = sum(
1 for atom in database.list_atoms() if atom.atom_id in crops and not atom.text.strip()
)
index = VisualIndex(settings, database)
if not index.is_current():
started = time.monotonic()
built = index.build()
print(f"encoded {built} in {time.monotonic() - started:.1f}s", flush=True)
retrieval = RetrievalService(settings, database)
pools = {
"crops": crops,
"shots": shots,
"atom_of_target": source_of_atom,
"screen_crops": {screen["source_id"]: screen["crops"] for screen in screens},
}
per_screen = [len(screen["crops"]) for screen in screens]
rows = []
for arm in ARMS:
ranked, seconds = run_arm(arm, targets, pools, index, retrieval, source_of_atom)
row = score(arm, targets, ranked, seconds)
row["chance_recall_at_10"] = round(
chance_recall_at_k(
1,
len(crops) if arm.pool == "crops" else len(shots) if arm.pool == "shots" else int(statistics.median(per_screen)),
TOP_K,
),
4,
)
row["pool"] = arm.pool
rows.append(row)
report = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"commit": git_commit(),
"dataset": "OS-Copilot/ScreenSpot-v2 (icon instructions)",
"model": {"repo": "Xenova/chinese-clip-vit-base-patch16", "sha256": model_sha256()},
"targets": len(targets),
"screenshots": len(screens),
"crops_without_ocr_text": wordless,
"instruction_languages": {
"cjk_instructions": sum(1 for t in targets if re.search(r"[一-鿿]", t["instruction"]))
},
"machine": machine_facts(),
"arms": rows,
}
REPORTS.mkdir(parents=True, exist_ok=True)
(REPORTS / "visual.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
(REPORTS / "VISUAL.md").write_text(markdown(report), encoding="utf-8")
for row in rows:
print(
f"{row['label']}: recall@10 {row['recall_at_10']:.3f} (chance {row['chance_recall_at_10']:.3f}) "
f"mrr {row['mrr']:.3f} p50 {row['latency']['p50']}s"
)
print(f"wrote {REPORTS / 'VISUAL.md'}")
def markdown(report: dict[str, Any]) -> str:
lines = [
f"# VISUAL: can a library of wordless pictures be searched? (commit {report['commit'][:9]})",
"",
f"- generated {report['generated_at']}",
(
f"- corpus: {report['dataset']}, {report['targets']} icon crops and {report['screenshots']} whole "
f"screenshots stored as separate memories, {report['crops_without_ocr_text']} crops with no OCR text"
),
f"- encoder: {report['model']['repo']} sha256 {report['model']['sha256'][:16]}",
f"- machine: {report['machine']['cpus']} cpus, load {report['machine']['load_average_1_5_15']}",
(
f"- instructions containing Chinese: {report['instruction_languages']['cjk_instructions']} "
"(ScreenSpot-v2 is English, so this run does not test the bilingual claim)"
),
"",
"| ask | pool | recall@1 | recall@5 | recall@10 | chance@10 | MRR | hit@10 lower 95% | p50 s | p95 s |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
]
for row in report["arms"]:
lines.append(
f"| {row['label']} | {row['pool']} | {row['recall_at_1']:.3f} | {row['recall_at_5']:.3f} "
f"| {row['recall_at_10']:.3f} | {row['chance_recall_at_10']:.4f} | {row['mrr']:.3f} "
f"| {row['hit_at_10_wilson_lower']:.3f} | {row['latency']['p50']} | {row['latency']['p95']} |"
)
lines += [
"",
(
"The words rows are what the product could do before any picture vector: if OCR found no words, "
"there is nothing to match. The picture rows ask the same sentence, or the whole screenshot, of the "
"CLIP space. The pools change the question: among every icon in the library, among whole screenshots "
"(the granularity the store keeps), and among one screen's icons, which is the shape ScreenSpot "
"itself poses. A number is only comparable inside its own pool, so chance is printed per row."
),
"",
]
return "\n".join(lines)
if __name__ == "__main__":
main()