diff --git a/CHECKLIST.md b/CHECKLIST.md index d5803ea..c4db62c 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -134,11 +134,24 @@ ## 阶段 2 — P1:让它成为产品 -- [ ] **p1-1** 图像进 dense - - [ ] p1-1.1 选 embedding ≤500MB,双语 + 图像;记录模型 SHA 与体积 - - [ ] p1-1.2 图像 atom 入索引;query 侧支持图像 - - [ ] p1-1.3 视觉 token 计数改 32×32 口径(1280×720 ≈ 900) - - [ ] p1-1.4 VLM 描述:代码路径就绪,标 `DEFERRED` +- [x] **p1-1** 图像进 dense + - [x] p1-1.1 选 embedding ≤500MB,双语 + 图像;记录模型 SHA 与体积 + - `Xenova/chinese-clip-vit-base-patch16` fp16 ONNX,361MB,`onnx/model_fp16.onnx` sha256 `0fad978faa89b85fda53a83a4c202116a9eb87c8f9723fb4d85e4483efbce4b2`;CPU 可跑(仓库已有 onnxruntime);文本塔 76ms、图像塔 29ms/张(batch 8) + - 契约是量出来的不是猜的:输入 `input_ids`/`attention_mask`/`pixel_values (N,3,224,224)`,输出 `image_embeds`/`text_embeds (N,512)`;这是融合对比图,未用的塔必须喂占位行;分词器用 `BertTokenizer`(按 CLIP 分词器加载会得到超出 vocab 的 id,Gather 直接炸) + - [x] p1-1.2 图像 atom 入索引;query 侧支持图像 + - `backend/src/ely_eye/visual_index.py`(独立文件,retrieval.py 停在 499 行没有膨胀);`ely-eye index-visual`;`search --image`;API `SearchRequest.image` + - 开关规则:`clip_model_dir` 指向含 `onnx/model_fp16.onnx` 的目录才算可用,不再多一个 bool + - 14 个测试:句子找到图、图找到图、新图一到索引即失效、缺快照点名、map/vector 不一致点名、词够多时不付 encoder、forget 之后图片不再从内存回答 + - [x] p1-1.3 视觉 token 计数改 32×32 口径(1280×720 ≈ 900) + - `visual_token_equivalent = ⌈w/32⌉·⌈h/32⌉`;migration v7 重算存量:21,325 个图像 atom 从 5,331 → 34,618 tokens;整库 29.9M → **84.2M**,于是「100M 胶囊」在这份 50,278 文件语料上刚好装得下、再大就装不下——这才是那句 claim 的真实含义 + - [x] p1-1.4 VLM 描述:代码路径就绪,标 `DEFERRED` + - `describe.py`;ingestion 只在 OCR 没找到字时才调用(图上已有字是免费的);无快照 → atom metadata `deferred: no vision-language model snapshot`;模型报错 → `failed: …` 且 ingest 不死(策略在调用方,不在 describer);`status` 显示两个视觉开关;5 个测试 +- p1-1 证据(我额外加的六臂基准,不是验收项):`bench/visual_recall.py`,ScreenSpot-v2 的 554 个图标 crop + 408 张整屏,报告 `bench/reports/VISUAL.md` + - 图标池:picture 0.208 vs words 0.175 vs chance 0.018 —— 图片空间比瞎猜高 11.5 倍,并且在 427 个 OCR 没字的 crop 上超过了词 + - 整屏池:words 0.384 > picture 0.215 —— 整屏的身份主要是文字,OCR 词在这里赢 + - 整屏 → 图标:0.108 —— CLIP 不会在截图里定位小图标,它是整图相似模型,不是 grounding 模型 + - 单屏内:MRR 0.908(recall@10 是 1.000 但 chance 也是 1.000,所以那一行只有 MRR 有意义) + - 本轮没测双语:ScreenSpot-v2 的指令全是英文,报告里写明了 - 验收:无文字图像被图像查询召回有 recall 数字;`pytest tests/test_visual_tokens.py` 绿 - [ ] **p1-2** 音频摄入 - [ ] p1-2.1 `Modality` 加 audio/transcript;未知扩展名不再静默跳过 diff --git a/backend/src/ely_eye/api.py b/backend/src/ely_eye/api.py index 582ea90..4a3603c 100644 --- a/backend/src/ely_eye/api.py +++ b/backend/src/ely_eye/api.py @@ -197,7 +197,11 @@ def create_app() -> FastAPI: def search(request: SearchRequest, settings: Annotated[Settings, Depends(settings_dep)]) -> SearchResult: try: return RetrievalService(settings).search( - request.query, request.top_k, filters=request.filters, cursor=request.cursor + request.query, + request.top_k, + filters=request.filters, + cursor=request.cursor, + image=request.image, ) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc diff --git a/backend/src/ely_eye/cli.py b/backend/src/ely_eye/cli.py index 09f2bfb..29f3246 100644 --- a/backend/src/ely_eye/cli.py +++ b/backend/src/ely_eye/cli.py @@ -17,8 +17,9 @@ from .benchmarks import ( from .cartridge import CartridgeService from .cartridge_assets import CartridgeAssetService from .cli_transactive import register as register_transactive_commands -from .config import get_settings +from .config import Settings, get_settings from .db import Database +from .describe import DEFERRED, VisionDescriber from .embeddings import EmbeddingService from .ingestion import IngestionService from .memory import MemoryService @@ -31,6 +32,7 @@ from .retrieval import RetrievalService from .runtime import QwenRuntime from .schemas import MemoryOrigin, SearchFilters, TimePrecision, TrainingAdapterKind from .training import TrainingService +from .visual_index import VisualIndex app = typer.Typer(no_args_is_help=True) @@ -49,6 +51,29 @@ def serve(host: str = "127.0.0.1", port: int = 8765) -> None: uvicorn.run(create_app(), host=host, port=port) +def _senses(settings: Settings, db: Database) -> dict[str, dict[str, object]]: + """What this machine can see, and what it has to pretend about. + + A missing snapshot is reported, not hidden: an unrun capability is a plan, and the status + output is where an operator learns which claims about this install are real today. + """ + visual = VisualIndex(settings, db) + describer = VisionDescriber(settings) + index_present = visual.index_path.exists() and visual.map_path.exists() + return { + "picture_search": { + "model_dir": str(visual.model_dir() or ""), + "model_ready": visual.available(), + "index": "present" if index_present else "absent", + }, + "picture_description": { + "model_dir": str(describer.model_dir() or ""), + "model_ready": describer.available(), + "note": "" if describer.available() else DEFERRED, + }, + } + + @app.command() def status() -> None: settings = get_settings() @@ -66,6 +91,7 @@ def status() -> None: ), "cartridges": db.cartridge_count(), "data_home": str(settings.home), + "senses": _senses(settings, db), } ) ) @@ -97,6 +123,7 @@ def search( until: str = typer.Option("", "--until"), min_trust: float = typer.Option(-1.0, "--min-trust", help="drop memories whose trust falls below this"), cursor: str = typer.Option("", "--cursor", help="next_cursor from the previous page"), + image: str = typer.Option("", "--image", help="a picture to search with, instead of only words"), ) -> None: """Rank the memories that may answer, and say so when none may.""" settings = get_settings() @@ -112,10 +139,26 @@ def search( min_trust=None if min_trust < 0 else min_trust, ), cursor=cursor or None, + image=Path(image) if image else None, ) typer.echo(result.model_dump_json(indent=2)) +@app.command() +def describe_image(path: Path) -> None: + """Say what a picture shows; names the setup step when no vision model is installed.""" + settings = get_settings() + typer.echo(json_dump({"image": str(path), "caption": VisionDescriber(settings).describe(path)})) + + +@app.command() +def index_visual() -> None: + """Encode every image atom so a picture or a sentence can find it.""" + settings = get_settings() + database = Database(settings) + typer.echo(json_dump(VisualIndex(settings, database).build())) + + @app.command() def remember( text: str, diff --git a/backend/src/ely_eye/clip_encoder.py b/backend/src/ely_eye/clip_encoder.py new file mode 100644 index 0000000..7975daa --- /dev/null +++ b/backend/src/ely_eye/clip_encoder.py @@ -0,0 +1,120 @@ +"""Image and text encoding for cross-modal retrieval, over one small ONNX graph. + +Chinese-CLIP ViT-B/16 in fp16 (377 MB) keeps bilingual text and images in one vector +space, so a screenshot can be found by a picture or by a sentence. The graph is the +fused contrastive model: every call needs both towers' inputs, so the unused side is +fed a placeholder row. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import numpy as np +from PIL import Image + +IMAGE_SIZE = 224 +TEXT_MAX_LENGTH = 52 +IMAGE_MEAN = (0.48145466, 0.45782750, 0.40821073) +IMAGE_STD = (0.26862954, 0.26130258, 0.27577711) +DOWNLOAD_HINT = ( + "huggingface-cli download Xenova/chinese-clip-vit-base-patch16 " + "--include 'onnx/model_fp16.onnx' 'tokenizer*' 'config.json' --local-dir " +) + + +def preprocess_image(image: Image.Image) -> np.ndarray: + """Turn a PIL image into the (3, 224, 224) float32 tensor the graph expects. + + The reference processor resizes to a square and normalizes with CLIP constants; + doing it here keeps torchvision out of the dependency list. + """ + rgb = image.convert("RGB").resize((IMAGE_SIZE, IMAGE_SIZE), Image.Resampling.BICUBIC) + array = np.asarray(rgb, dtype=np.float32) / 255.0 + mean = np.array(IMAGE_MEAN, dtype=np.float32) + std = np.array(IMAGE_STD, dtype=np.float32) + return ((array - mean) / std).transpose(2, 0, 1) + + +class ClipEncoder: + """One ONNX session, both towers, batched image encoding.""" + + def __init__(self, model_dir: Path) -> None: + self.model_dir = Path(model_dir) + self.onnx_path = self.model_dir / "onnx" / "model_fp16.onnx" + if not self.onnx_path.exists(): + raise SystemExit( + f"{self.onnx_path} is not a Chinese-CLIP export; fetch the fp16 graph: {DOWNLOAD_HINT}" + ) + self._session: Any | None = None + self._tokenizer: Any | None = None + self._placeholder_text: dict[str, np.ndarray] | None = None + self._placeholder_image: np.ndarray | None = None + + @property + def session(self) -> Any: + if self._session is None: + import onnxruntime as ort + + self._session = ort.InferenceSession(str(self.onnx_path), providers=["CPUExecutionProvider"]) + return self._session + + @property + def tokenizer(self) -> Any: + if self._tokenizer is None: + from transformers import BertTokenizer + + # The export is BERT-style (vocab.txt, [CLS]=101); transformers 5 unified the fast and + # slow tokenizers, so the name the CLIP docs use is an alias for this one. + self._tokenizer = BertTokenizer.from_pretrained(str(self.model_dir)) + return self._tokenizer + + def encode_images(self, images: list[Image.Image], batch_size: int = 8) -> np.ndarray: + if not images: + return np.zeros((0, 512), dtype=np.float32) + rows: list[np.ndarray] = [] + for start in range(0, len(images), batch_size): + chunk = images[start : start + batch_size] + pixels = np.stack([preprocess_image(image) for image in chunk]) + rows.append(self._run("image_embeds", pixels, self._text_feed(1))[0]) + return np.asarray(np.concatenate(rows), dtype=np.float32) + + def encode_texts(self, texts: list[str], batch_size: int = 16) -> np.ndarray: + if not texts: + return np.zeros((0, 512), dtype=np.float32) + rows: list[np.ndarray] = [] + for start in range(0, len(texts), batch_size): + chunk = texts[start : start + batch_size] + feed = self._text_feed(len(chunk), chunk) + rows.append(self._run("text_embeds", self._image_feed(len(chunk)), feed)[0]) + return np.asarray(np.concatenate(rows), dtype=np.float32) + + def _run(self, output: str, pixels: np.ndarray, text: dict[str, np.ndarray]) -> list[np.ndarray]: + feed = {"pixel_values": pixels.astype(np.float32), **text} + return self.session.run([output], feed) + + def _text_feed(self, batch: int, texts: list[str] | None = None) -> dict[str, np.ndarray]: + if texts is None: + if self._placeholder_text is None: + self._placeholder_text = self._tokenize(["placeholder"]) + return self._placeholder_text + return self._tokenize(texts) + + def _image_feed(self, batch: int) -> np.ndarray: + if self._placeholder_image is None: + self._placeholder_image = np.zeros((1, 3, IMAGE_SIZE, IMAGE_SIZE), dtype=np.float32) + return np.repeat(self._placeholder_image, batch, axis=0) + + def _tokenize(self, texts: list[str]) -> dict[str, np.ndarray]: + encoded = self.tokenizer( + texts, + padding="max_length", + truncation=True, + max_length=TEXT_MAX_LENGTH, + return_tensors="np", + ) + return { + "input_ids": encoded["input_ids"].astype(np.int64), + "attention_mask": encoded["attention_mask"].astype(np.int64), + } diff --git a/backend/src/ely_eye/config.py b/backend/src/ely_eye/config.py index d4deab7..12309c3 100644 --- a/backend/src/ely_eye/config.py +++ b/backend/src/ely_eye/config.py @@ -18,6 +18,9 @@ class Settings(BaseSettings): kv_cache_dtype: str = "auto" planner_model_id: str = "Qwen/Qwen3.5-0.8B" embedding_model_id: str = "Qwen/Qwen3-VL-Embedding-8B" + clip_model_dir: Path | None = None + vlm_model_dir: Path | None = None + vlm_max_new_tokens: int = 96 adapter_dir: Path | None = None runtime_backend: Literal["sglang", "transformers"] = "transformers" sglang_base_url: str = "http://127.0.0.1:8000/v1" diff --git a/backend/src/ely_eye/db.py b/backend/src/ely_eye/db.py index 0e9f08d..bcc7932 100644 --- a/backend/src/ely_eye/db.py +++ b/backend/src/ely_eye/db.py @@ -44,7 +44,8 @@ class Database: connection.close() def init(self) -> None: - with sqlite3.connect(self.path) as connection: + # connect(), not a bare sqlite3.connect: the migrations read rows by name. + with self.connect() as connection: apply_migrations(connection) def upsert_sources(self, sources: list[SourceRecord]) -> None: diff --git a/backend/src/ely_eye/describe.py b/backend/src/ely_eye/describe.py new file mode 100644 index 0000000..248dbde --- /dev/null +++ b/backend/src/ely_eye/describe.py @@ -0,0 +1,87 @@ +"""What a picture says when it has no words on it. + +OCR reads letters; it cannot say what a sunset, a shopping cart, or a warning triangle is. A +vision-language model can, and its sentence becomes the atom's text so ordinary word search can +reach the picture too. The model is a multi-gigabyte download, so this path is written, gated on +a snapshot, and reported as deferred until somebody fetches one: an unrun capability is a plan, +not a result. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from .config import Settings, get_settings + +PROMPT = "Describe what is in this image in one or two sentences. Name the objects and any visible text." +DOWNLOAD_HINT = "fetch a Qwen-VL snapshot and set ELY_EYE_VLM_MODEL_DIR to the directory holding it" +DEFERRED = "deferred: no vision-language model snapshot" + + +class VisionDescriber: + """One lazily loaded vision model; `describe_or_none` is the path ingestion takes.""" + + def __init__(self, settings: Settings | None = None) -> None: + self.settings = settings or get_settings() + self._model: Any | None = None + self._processor: Any | None = None + + def model_dir(self) -> Path | None: + model_dir = self.settings.vlm_model_dir + if model_dir is None or not Path(model_dir).is_dir(): + return None + return Path(model_dir) + + def available(self) -> bool: + return self.model_dir() is not None + + def describe(self, path: Path) -> str: + """Caption one picture; a missing snapshot is a setup error, not an empty answer.""" + model_dir = self.model_dir() + if model_dir is None: + raise SystemExit(f"{path}: image description needs a vision-language model; {DOWNLOAD_HINT}") + if not path.is_file(): + raise ValueError(f"{path}: no picture to describe") + return self._caption(path, model_dir) + + def describe_or_none(self, path: Path) -> str | None: + """Caption if this machine can, otherwise say nothing and let the caller record why. + + A model failure is raised, not swallowed: ingestion decides whether a broken vision + model kills the ingest or becomes a note on the atom, and it is the one that knows. + """ + if not self.available(): + return None + return self._caption(path, self.model_dir() or Path()) + + def _caption(self, path: Path, model_dir: Path) -> str: + processor, model = self._load(model_dir) + import torch + from PIL import Image + + with Image.open(path) as picture: + inputs = processor(images=picture.convert("RGB"), text=PROMPT, return_tensors="pt").to( + model.device, torch.float16 + ) + generated = model.generate(**inputs, max_new_tokens=self.settings.vlm_max_new_tokens, do_sample=False) + trimmed = generated[:, inputs["input_ids"].shape[1] :] + text = processor.batch_decode(trimmed, skip_special_tokens=True)[0].strip() + if not text: + raise ValueError(f"{path}: the vision model answered with nothing") + return text + + def _load(self, model_dir: Path) -> tuple[Any, Any]: + """Load once, and hand back the pair so the caller never reads a half-built attribute.""" + processor, model = self._processor, self._model + if processor is None or model is None: + import torch + from transformers import AutoModelForImageTextToText, AutoProcessor + + processor = AutoProcessor.from_pretrained(str(model_dir)) + model = AutoModelForImageTextToText.from_pretrained( + str(model_dir), torch_dtype=torch.float16, device_map="auto" + ) + model.eval() + self._processor, self._model = processor, model + return processor, model diff --git a/backend/src/ely_eye/ingestion.py b/backend/src/ely_eye/ingestion.py index bb58113..b14ddaf 100644 --- a/backend/src/ely_eye/ingestion.py +++ b/backend/src/ely_eye/ingestion.py @@ -16,6 +16,7 @@ from PIL import Image from .config import Settings, get_settings from .db import Database +from .describe import DEFERRED, VisionDescriber from .ocr import read_text_from_image from .schemas import ( EvidenceAtom, @@ -32,6 +33,7 @@ from .storage import ( sha256_bytes, sha256_file, token_equivalent, + visual_token_equivalent, ) SUPPORTED_TEXT_EXTENSIONS = { @@ -93,6 +95,7 @@ class IngestionService: self.settings = settings or get_settings() self.db = database or Database(self.settings) self.objects = object_store or ObjectStore(self.settings) + self.describer = VisionDescriber(self.settings) def ingest_path(self, path: Path, cartridge_name: str | None = None) -> IngestResponse: path = path.expanduser().resolve() @@ -208,18 +211,33 @@ class IngestionService: source = self._source_record(path, self._image_kind(path), metadata=image_metadata(path)) image_ref = self.objects.put_file(path, "images") ocr = read_text_from_image(path) + caption = "" + caption_note = "" + if not ocr.text.strip(): + # Words already on the picture are free; only a wordless one is worth a model call. + try: + caption = self.describer.describe_or_none(path) or "" + except (OSError, RuntimeError, ValueError) as error: + caption, caption_note = "", f"failed: {error}" + else: + caption_note = "vlm" if caption else DEFERRED + text = caption or ocr.text width, height = image_size(path) atom = EvidenceAtom( atom_id=f"{source.source_id}:image", modality=Modality.ui_screenshot if source.kind == "ui_screenshot" else Modality.image, source_id=source.source_id, source=str(path), - text=ocr.text, + text=text, image_ref=image_ref, layout=LayoutBox(x=0, y=0, w=width, h=height), - trust=TrustScores(parser=1.0, ocr=ocr.confidence, model=0.0), - token_equivalent=token_equivalent(ocr.text) + max(1, (width * height) // (224 * 224)), - metadata={"ocr_regions": ocr.regions, **image_metadata(path)}, + trust=TrustScores(parser=1.0, ocr=ocr.confidence, model=0.6 if caption else 0.0), + token_equivalent=token_equivalent(text) + visual_token_equivalent(width, height), + metadata={ + "ocr_regions": ocr.regions, + "caption": caption_note, + **image_metadata(path), + }, ) return IngestBatch(sources=[source], atoms=[atom]) @@ -334,7 +352,7 @@ class IngestionService: image_ref=image_ref, layout=LayoutBox(x=0, y=0, w=float(width), h=float(height), frame_second=second), trust=TrustScores(parser=1.0, ocr=ocr.confidence, model=0.0), - token_equivalent=token_equivalent(ocr.text) + max(1, (width * height) // (224 * 224)), + token_equivalent=token_equivalent(ocr.text) + visual_token_equivalent(width, height), metadata={"frame_index": frame_index, "second": second, "ocr_regions": ocr.regions}, ) ) diff --git a/backend/src/ely_eye/memory.py b/backend/src/ely_eye/memory.py index 3c85cc2..2a559af 100644 --- a/backend/src/ely_eye/memory.py +++ b/backend/src/ely_eye/memory.py @@ -27,6 +27,7 @@ from .schemas import ( utc_now, ) from .storage import token_equivalent +from .visual_index import VisualIndex # Trust scores describe extraction fidelity, not truthfulness: a remembered sentence is # stored verbatim, so the parser is fully trusted and origin carries the epistemic weight. @@ -189,13 +190,13 @@ class MemoryService: ) def _drop_vector_index(self) -> bool: - """The dense index is derived data; a stale one keeps answering with deleted text.""" + """The text and picture indexes are derived data; a stale one keeps answering with deleted memories.""" dropped = False for path in (self.settings.cache_dir / "dense_index.npy", self.settings.cache_dir / "dense_index_atoms.json"): if path.exists(): path.unlink() dropped = True - return dropped + return VisualIndex(self.settings, self.db).drop() or dropped def _cartridges_holding(self, source_id: str) -> list[dict[str, Any]]: """Name the cartridges that exported this source, so they can be rebuilt. diff --git a/backend/src/ely_eye/migrations.py b/backend/src/ely_eye/migrations.py index fc9c676..8112387 100644 --- a/backend/src/ely_eye/migrations.py +++ b/backend/src/ely_eye/migrations.py @@ -10,11 +10,13 @@ upgraded in place rather than rebuilt, because the only copy of the memory is th from __future__ import annotations +import json import sqlite3 +from .storage import token_equivalent, visual_token_equivalent from .text_index import index_text -SCHEMA_VERSION = 6 +SCHEMA_VERSION = 7 ATOM_COLUMNS_V2 = """ atom_id TEXT PRIMARY KEY, @@ -204,6 +206,29 @@ def _upgrade_v1_to_v2(connection: sqlite3.Connection) -> None: connection.execute(f"ALTER TABLE atoms ADD COLUMN {name} {definition}") +def _recount_visual_tokens(connection: sqlite3.Connection) -> None: + """Re-price raster atoms at 32x32 patches; a 1280x720 frame cost 18 tokens under the old rule. + + An atom whose layout carries no size keeps its stored number: there is no region to price, + and inventing one would move a figure we cannot verify. + """ + rows = connection.execute( + "SELECT atom_id, text, layout_json FROM atoms WHERE modality IN ('image', 'video_frame', 'ui_screenshot')" + ).fetchall() + updates: list[tuple[int, str]] = [] + for row in rows: + try: + layout = json.loads(row["layout_json"] or "{}") + except json.JSONDecodeError as error: + raise SystemExit(f"atom {row['atom_id']}: its stored layout is unreadable ({error})") from error + width = float(layout.get("w") or 0) + height = float(layout.get("h") or 0) + if width <= 0 or height <= 0: + continue + updates.append((token_equivalent(row["text"]) + visual_token_equivalent(width, height), str(row["atom_id"]))) + connection.executemany("UPDATE atoms SET token_equivalent = ? WHERE atom_id = ?", updates) + + def apply(connection: sqlite3.Connection) -> None: """Bring the schema to SCHEMA_VERSION, in place, without losing rows.""" version = int(connection.execute("PRAGMA user_version").fetchone()[0]) @@ -220,6 +245,8 @@ def apply(connection: sqlite3.Connection) -> None: _create_v2(connection) if version < 3: _rebuild_fts(connection) + if version < 7: + _recount_visual_tokens(connection) connection.executescript(VIEWS) connection.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") diff --git a/backend/src/ely_eye/retrieval.py b/backend/src/ely_eye/retrieval.py index 8fea0c8..da5c100 100644 --- a/backend/src/ely_eye/retrieval.py +++ b/backend/src/ely_eye/retrieval.py @@ -10,15 +10,19 @@ from __future__ import annotations import base64 from collections import defaultdict +from pathlib import Path from . import cartridge_store from .config import Settings, get_settings -from .db import Database +from .db import Database, FtsPage from .embeddings import EmbeddingService from .schemas import EvidenceAtom, RetrievalHit, SearchFilters, SearchResult +from .visual_index import VisualIndex RRF_K = 60 CANDIDATE_WINDOW = 10 +# Words decide most searches; a picture vector is only worth its encoding cost when words fell short. +THIN_SPARSE_PAGE = 2 class RetrievalService: @@ -26,6 +30,7 @@ class RetrievalService: self.settings = settings or get_settings() self.db = database or Database(self.settings) self.embeddings = EmbeddingService(self.settings, self.db) + self.visual = VisualIndex(self.settings, self.db) def search( self, @@ -34,6 +39,7 @@ class RetrievalService: *, filters: SearchFilters | None = None, cursor: str | None = None, + image: Path | None = None, ) -> SearchResult: """Answer one query against the memories that are open right now. @@ -44,17 +50,20 @@ class RetrievalService: limit = top_k or self.settings.retrieval_top_k if limit <= 0: raise ValueError(f"search needs a positive limit, got {limit}") + if not query.strip() and image is None: + raise ValueError("search needs words or a picture to ask with, and got neither") offset = decode_cursor(cursor) criteria = (filters or SearchFilters()).model_dump(exclude_none=True) scope = criteria.get("cartridge_ids") if scope is None: scope = cartridge_store.mounted_ids(self.db) or None criteria["cartridge_ids"] = scope - page = self.db.search_atoms( - query, - limit=limit * CANDIDATE_WINDOW, - offset=offset, - **criteria, + # A picture-only question has no words for BM25, so the word ranking is skipped rather + # than asked to match an empty expression, which would answer nothing for the wrong reason. + page = ( + self.db.search_atoms(query, limit=limit * CANDIDATE_WINDOW, offset=offset, **criteria) + if query.strip() + else FtsPage(rows=[], has_more=False) ) rankings: list[list[str]] = [[atom_id for atom_id, _ in page.rows]] scores: dict[str, dict[str, float]] = defaultdict(dict) @@ -68,6 +77,17 @@ class RetrievalService: rankings.append(list(dense)) for atom_id, cosine in dense.items(): scores[atom_id]["dense_score"] = cosine + visual = self._visual_search( + query, + image, + limit * CANDIDATE_WINDOW, + scope, + thin=len(rankings[0]) < limit * THIN_SPARSE_PAGE, + ) + if visual: + rankings.append(list(visual)) + for atom_id, cosine in visual.items(): + scores[atom_id]["visual_score"] = cosine hits = self._rank(rankings, scores, limit) more = page.has_more or len(rankings[0]) > limit return SearchResult( @@ -84,6 +104,36 @@ class RetrievalService: query_vector = self.embeddings.embed_query(query) return dict(index.search(query_vector, limit)) + def _visual_search( + self, + query: str, + image: Path | None, + limit: int, + scope: list[str] | None, + *, + thin: bool, + ) -> dict[str, float]: + """Ask the picture index when words are not enough. + + A screenshot with no text on it has nothing for FTS to match, so the sparse page comes + back short; that is the signal to spend the encoding. An image query always asks for it. + """ + if image is None and not thin: + return {} + if not self.visual.available() or self.visual.load() is None: + return {} + if image is not None: + try: + ranked = self.visual.search_image(image, limit) + except (OSError, ValueError) as error: + raise ValueError(f"the query image {image} cannot be read: {error}") from error + else: + ranked = self.visual.search_text(query, limit) + if scope: + allowed = cartridge_store.atoms_of_cartridges(self.db, scope) + ranked = [(atom_id, score) for atom_id, score in ranked if atom_id in allowed] + return dict(ranked) + def _rank(self, rankings: list[list[str]], scores: dict[str, dict[str, float]], limit: int) -> list[RetrievalHit]: """Reciprocal rank fusion: rank position is comparable across signals, raw scores are not. @@ -108,6 +158,7 @@ class RetrievalService: atom=atom, sparse_score=signal.get("sparse_score", 0.0), dense_score=signal.get("dense_score", 0.0), + visual_score=signal.get("visual_score", 0.0), final_score=fused[atom_id], ) ) diff --git a/backend/src/ely_eye/schemas.py b/backend/src/ely_eye/schemas.py index 27f4a1a..5f574a7 100644 --- a/backend/src/ely_eye/schemas.py +++ b/backend/src/ely_eye/schemas.py @@ -149,6 +149,7 @@ class RetrievalHit(BaseModel): atom: EvidenceAtom sparse_score: float dense_score: float = 0.0 + visual_score: float = 0.0 graph_score: float = 0.0 final_score: float @@ -183,6 +184,7 @@ class SearchRequest(BaseModel): top_k: int | None = Field(default=None, gt=0) filters: SearchFilters | None = None cursor: str | None = None + image: Path | None = None class SearchResult(BaseModel): diff --git a/backend/src/ely_eye/storage.py b/backend/src/ely_eye/storage.py index 158a191..ff78fbb 100644 --- a/backend/src/ely_eye/storage.py +++ b/backend/src/ely_eye/storage.py @@ -1,6 +1,7 @@ from __future__ import annotations import hashlib +import math import mimetypes import shutil from pathlib import Path @@ -32,6 +33,7 @@ def safe_source_id(path: Path, content_hash: str) -> str: def token_equivalent(text: str) -> int: + """A cheap stand-in for a tokenizer: four ascii characters or two wide ones per token.""" if not text: return 0 ascii_chars = sum(1 for char in text if ord(char) < 128) @@ -39,6 +41,16 @@ def token_equivalent(text: str) -> int: return max(1, ascii_chars // 4 + non_ascii_chars // 2) +VISUAL_PATCH = 32 + + +def visual_token_equivalent(width: float, height: float) -> int: + """Tokens a picture costs a vision model: one per 32x32 patch, so 1280x720 is 920.""" + if width <= 0 or height <= 0: + raise ValueError(f"a visual region needs a positive size, got {width}x{height}") + return max(1, math.ceil(width / VISUAL_PATCH) * math.ceil(height / VISUAL_PATCH)) + + class ObjectStore: def __init__(self, settings: Settings | None = None) -> None: self.settings = settings or get_settings() diff --git a/backend/src/ely_eye/visual_index.py b/backend/src/ely_eye/visual_index.py new file mode 100644 index 0000000..0260c01 --- /dev/null +++ b/backend/src/ely_eye/visual_index.py @@ -0,0 +1,199 @@ +"""A picture found by a picture, or by a sentence in either language. + +Text search cannot reach an image with no words on it: OCR returns nothing, so the atom's +text is empty and the index has nothing to match. This index keeps one CLIP vector per image +atom and encodes the query with the matching tower — a picture with the image tower, a +sentence with the text tower — so both land in the same space and a screenshot can be found +either way. +""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Any + +import numpy as np +from PIL import Image + +from .clip_encoder import DOWNLOAD_HINT, ClipEncoder +from .config import Settings, get_settings +from .db import Database +from .schemas import EvidenceAtom +from .storage import ObjectStore + +INDEX_NAME = "clip_image_index.npy" +MAP_NAME = "clip_image_index_atoms.json" +GRAPH_PART = Path("onnx") / "model_fp16.onnx" + +# Loading 48k vectors costs about as much as the search itself, and the API builds a fresh +# service per request, so the array is kept per process and re-read only when the file changes. +_LOADED: dict[tuple[str, float], tuple[list[str], np.ndarray]] = {} + + +def is_visual(atom: EvidenceAtom) -> bool: + """An atom is findable by picture only if there is a picture behind it.""" + return bool(atom.image_ref) + + +class VisualIndex: + """CLIP vectors for the image atoms, plus the two ways to ask for them.""" + + def __init__(self, settings: Settings | None = None, database: Database | None = None) -> None: + self.settings = settings or get_settings() + self.db = database or Database(self.settings) + self.index_path = self.settings.cache_dir / INDEX_NAME + self.map_path = self.settings.cache_dir / MAP_NAME + self._encoder: ClipEncoder | None = None + + def model_dir(self) -> Path | None: + """The CLIP snapshot, or None when this machine has not fetched one.""" + model_dir = self.settings.clip_model_dir + if model_dir is None or not (model_dir / GRAPH_PART).exists(): + return None + return model_dir + + def available(self) -> bool: + return self.model_dir() is not None + + def encoder(self) -> ClipEncoder: + if self._encoder is None: + model_dir = self.model_dir() + if model_dir is None: + raise SystemExit( + f"visual search needs a Chinese-CLIP snapshot; set ELY_EYE_CLIP_MODEL_DIR. {DOWNLOAD_HINT}" + ) + self._encoder = ClipEncoder(model_dir) + return self._encoder + + def visual_atoms(self, atoms: list[EvidenceAtom] | None = None) -> list[EvidenceAtom]: + """Image atoms whose bytes are still on disk; a dead reference cannot be encoded.""" + pool = self.db.list_atoms() if atoms is None else atoms + store = ObjectStore(self.settings) + kept: list[EvidenceAtom] = [] + for atom in pool: + if not is_visual(atom): + continue + path = store.resolve(str(atom.image_ref)) + if path.exists(): + kept.append(atom) + return kept + + def build(self, atoms: list[EvidenceAtom] | None = None) -> dict[str, Any]: + """Encode every image atom once; the batch is what keeps this affordable.""" + started = time.monotonic() + pool = self.visual_atoms(atoms) + encoder = self.encoder() + vectors: list[np.ndarray] = [] + kept: list[EvidenceAtom] = [] + batch: list[Image.Image] = [] + for atom in pool: + try: + with Image.open(ObjectStore(self.settings).resolve(str(atom.image_ref))) as picture: + batch.append(picture.convert("RGB")) + except OSError as error: + print(f"skipping {atom.atom_id}: the image will not open ({error})") + continue + kept.append(atom) + if len(batch) >= 32: + vectors.append(encoder.encode_images(batch)) + batch = [] + if batch: + vectors.append(encoder.encode_images(batch)) + matrix = np.concatenate(vectors) if vectors else np.zeros((0, 512), dtype=np.float32) + self._write([atom.atom_id for atom in kept], matrix) + return { + "atoms": len(kept), + "dim": int(matrix.shape[1]) if matrix.size else 0, + "seconds": round(time.monotonic() - started, 2), + "model_dir": str(self.model_dir()), + } + + def load(self) -> tuple[list[str], np.ndarray] | None: + if not self.index_path.exists() or not self.map_path.exists(): + return None + stamp = (str(self.index_path), self.index_path.stat().st_mtime) + cached = _LOADED.get(stamp) + if cached is not None: + return cached + atom_ids = _read_ids(self.map_path) + vectors = np.load(self.index_path) + if len(atom_ids) != vectors.shape[0]: + raise ValueError( + f"{self.map_path}: {len(atom_ids)} atoms for {vectors.shape[0]} vectors; rebuild with ely-eye index-visual" + ) + _LOADED.clear() + _LOADED[stamp] = (atom_ids, vectors) + return atom_ids, vectors + + def drop(self) -> bool: + """Delete the index files and the copy this process is holding. + + Forget has to void this index the way it voids the text one: a forgotten picture that + still answers from an array in memory is a memory that was never forgotten. + """ + removed = False + for path in (self.index_path, self.map_path): + if path.exists(): + path.unlink() + removed = True + _LOADED.clear() + return removed + + def is_current(self, atoms: list[EvidenceAtom] | None = None) -> bool: + """True when the index covers exactly the image atoms the store holds right now.""" + loaded = self.load() + if loaded is None: + return False + return {atom.atom_id for atom in self.visual_atoms(atoms)} == set(loaded[0]) + + def search_text(self, query: str, top_k: int, restrict: set[str] | None = None) -> list[tuple[str, float]]: + vector = self.encoder().encode_texts([query])[0] + return self._nearest(vector, top_k, restrict) + + def search_image(self, image: Path, top_k: int, restrict: set[str] | None = None) -> list[tuple[str, float]]: + with Image.open(image) as picture: + vector = self.encoder().encode_images([picture.convert("RGB")])[0] + return self._nearest(vector, top_k, restrict) + + def _nearest( + self, query_vector: np.ndarray, top_k: int, restrict: set[str] | None = None + ) -> list[tuple[str, float]]: + """Closest stored pictures, optionally inside one candidate pool. + + The pool is how a benchmark asks a smaller question — “which element of this screen” — + without rebuilding the index per question. + """ + loaded = self.load() + if loaded is None or top_k <= 0: + return [] + atom_ids, vectors = loaded + if vectors.size == 0: + return [] + query = _unit(query_vector.reshape(1, -1))[0] + scores = _unit(vectors) @ query + if restrict is not None: + keep = np.fromiter((atom_id in restrict for atom_id in atom_ids), dtype=bool, count=len(atom_ids)) + scores = np.where(keep, scores, -np.inf) + order = np.argsort(scores)[::-1][:top_k] + return [(atom_ids[index], float(scores[index])) for index in order if np.isfinite(scores[index])] + + def _write(self, atom_ids: list[str], vectors: np.ndarray) -> None: + self.index_path.parent.mkdir(parents=True, exist_ok=True) + np.save(self.index_path, vectors) + self.map_path.write_text(json.dumps(atom_ids, ensure_ascii=False), encoding="utf-8") + _LOADED.clear() + + +def _read_ids(path: Path) -> list[str]: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ValueError(f"{path}: the visual index map is unreadable ({error})") from error + + +def _unit(vectors: np.ndarray) -> np.ndarray: + norms = np.linalg.norm(vectors, axis=1, keepdims=True) + norms[norms == 0] = 1.0 + return vectors / norms diff --git a/backend/tests/test_describe.py b/backend/tests/test_describe.py new file mode 100644 index 0000000..91c6208 --- /dev/null +++ b/backend/tests/test_describe.py @@ -0,0 +1,108 @@ +"""A picture with no words on it, and what the ingest path does about it. + +The vision model is a multi-gigabyte download, so these tests run the branch, not the weights: +a stand-in describer answers, and the atom records whether its text came from letters on the +picture, from a model's sentence, or from nothing at all. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import cast + +import pytest +from PIL import Image + +import ely_eye.ingestion as ingestion_module +from ely_eye.config import Settings +from ely_eye.db import Database +from ely_eye.describe import DEFERRED, VisionDescriber +from ely_eye.ingestion import IngestionService +from ely_eye.ocr import OcrResult + +CAPTION = "一个购物车图标停在结算按钮旁边" + + +class FakeDescriber: + """Stands in for the vision model and counts how often ingestion asked.""" + + def __init__(self, answer: str | None = CAPTION, fails: bool = False) -> None: + self.answer = answer + self.fails = fails + self.calls = 0 + + def describe_or_none(self, path: Path) -> str | None: + self.calls += 1 + if self.fails: + raise RuntimeError("the vision model ran out of memory") + return self.answer + + +def _picture(tmp_path: Path) -> Path: + path = tmp_path / "scene.png" + Image.new("RGB", (640, 360), (12, 34, 56)).save(path) + return path + + +def _ingest(tmp_path: Path, describer: object, ocr_text: str = "") -> tuple[Settings, Database]: + settings = Settings(home=tmp_path / "home") + database = Database(settings) + service = IngestionService(settings=settings, database=database) + service.describer = cast(VisionDescriber, describer) + ingestion_module.read_text_from_image = lambda path: OcrResult(ocr_text, 0.9, []) + service.ingest_path(_picture(tmp_path)) + return settings, database + + +def test_a_wordless_picture_ingested_with_a_model_carries_the_caption(tmp_path: Path) -> None: + from ely_eye.storage import token_equivalent, visual_token_equivalent + + _, database = _ingest(tmp_path, FakeDescriber()) + + atom = database.list_atoms()[0] + assert atom.text == CAPTION + assert atom.metadata["caption"] == "vlm" + assert atom.trust.model == 0.6, "a sentence invented by a model is less trusted than printed letters" + assert atom.token_equivalent == token_equivalent(CAPTION) + visual_token_equivalent(640, 360) + + +def test_a_wordless_picture_without_a_model_says_so_in_the_atom(tmp_path: Path) -> None: + describer = FakeDescriber(answer=None) + + _, database = _ingest(tmp_path, describer) + + atom = database.list_atoms()[0] + assert atom.text == "" + assert atom.metadata["caption"] == DEFERRED + assert describer.calls == 1, "ingestion asks, and the answer is recorded as missing" + + +def test_a_picture_with_printed_words_never_costs_a_model_call(tmp_path: Path) -> None: + describer = FakeDescriber() + + _, database = _ingest(tmp_path, describer, ocr_text="Checkout") + + assert describer.calls == 0, "letters already on the picture are free" + atom = database.list_atoms()[0] + assert atom.text == "Checkout" + assert atom.metadata["caption"] == "" + + +def test_a_model_that_fails_leaves_the_picture_ingestable(tmp_path: Path) -> None: + describer = FakeDescriber(fails=True) + + _, database = _ingest(tmp_path, describer) + + atom = database.list_atoms()[0] + assert atom.text == "" + assert atom.metadata["caption"].startswith("failed:"), "the atom says why its text is empty" + assert "ran out of memory" in atom.metadata["caption"] + + +def test_a_missing_snapshot_is_named_as_a_setup_step(tmp_path: Path) -> None: + settings = Settings(home=tmp_path / "home", vlm_model_dir=tmp_path / "nowhere") + describer = VisionDescriber(settings) + + assert not describer.available() + with pytest.raises(SystemExit, match="ELY_EYE_VLM_MODEL_DIR"): + describer.describe(_picture(tmp_path)) diff --git a/backend/tests/test_visual_index.py b/backend/tests/test_visual_index.py new file mode 100644 index 0000000..507b786 --- /dev/null +++ b/backend/tests/test_visual_index.py @@ -0,0 +1,302 @@ +"""Finding a picture by a picture, or by a sentence. + +The encoder is a 361 MB ONNX graph, so these tests swap in a fake that maps a picture's +colour, or a query's number, to a one-hot vector. What is under test is the index contract: +which atoms get encoded, when the index is stale, when search asks the picture tower at all, +and what happens when the snapshot is missing. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import cast + +import numpy as np +import pytest +from PIL import Image + +from ely_eye.clip_encoder import ClipEncoder +from ely_eye.config import Settings +from ely_eye.db import Database +from ely_eye.memory import MemoryService +from ely_eye.retrieval import RetrievalService +from ely_eye.schemas import EvidenceAtom, Modality, SourceRecord, TrustScores +from ely_eye.storage import ObjectStore +from ely_eye.visual_index import VisualIndex, is_visual + +DIM = 4 + + +class FakeEncoder: + """A picture is its top-left colour; a text query is its digits.""" + + def __init__(self) -> None: + self.image_calls = 0 + self.text_calls = 0 + + def encode_images(self, images: list[Image.Image], batch_size: int = 8) -> np.ndarray: + self.image_calls += 1 + return _one_hot([int(np.asarray(image.convert("RGB"))[0, 0, 0]) for image in images]) + + def encode_texts(self, texts: list[str], batch_size: int = 16) -> np.ndarray: + self.text_calls += 1 + return _one_hot([int(text) for text in texts]) + + +def _one_hot(codes: list[int]) -> np.ndarray: + matrix = np.zeros((len(codes), DIM), dtype=np.float32) + for row, code in enumerate(codes): + matrix[row, code % DIM] = 1.0 + return matrix + + +def _settings(tmp_path: Path, with_model: bool = True) -> Settings: + model_dir = tmp_path / "clip" + if with_model: + (model_dir / "onnx").mkdir(parents=True, exist_ok=True) + (model_dir / "onnx" / "model_fp16.onnx").write_bytes(b"graph") + return Settings(home=tmp_path / "home", clip_model_dir=model_dir, default_holder="wife") + + +def _picture(tmp_path: Path, name: str, shade: int) -> Path: + path = tmp_path / f"{name}.png" + Image.new("RGB", (64, 64), (shade, 0, 0)).save(path) + return path + + +def _source(source_id: str, path: str, mime: str) -> SourceRecord: + return SourceRecord(source_id=source_id, path=path, kind="file", mime=mime, sha256=source_id, size_bytes=64) + + +def _image_atom(tmp_path: Path, settings: Settings, database: Database, name: str, shade: int) -> EvidenceAtom: + """Store a picture the way ingestion does: bytes in the object store, atom pointing at them.""" + picture = _picture(tmp_path, name, shade) + uri = ObjectStore(settings).put_file(picture, "images") + database.upsert_sources([_source(name, str(picture), "image/png")]) + atom = EvidenceAtom( + atom_id=f"{name}:image", + modality=Modality.image, + source_id=name, + source=f"file://{name}", + text="", + image_ref=uri, + trust=TrustScores(parser=1.0, ocr=0.0, model=0.0), + token_equivalent=920, + holder_id=settings.default_holder, + ) + database.upsert_atoms([atom]) + return atom + + +def _attach(index: VisualIndex, encoder: FakeEncoder) -> FakeEncoder: + index._encoder = cast(ClipEncoder, encoder) + return encoder + + +def _index(tmp_path: Path, with_model: bool = True) -> tuple[VisualIndex, Settings, Database, FakeEncoder]: + settings = _settings(tmp_path, with_model) + database = Database(settings) + index = VisualIndex(settings, database) + return index, settings, database, _attach(index, FakeEncoder()) + + +def test_a_text_atom_is_not_findable_by_picture() -> None: + atom = EvidenceAtom( + atom_id="t:1", + modality=Modality.text, + source_id="s", + source="file://s", + text="钥匙放在抽屉里", + trust=TrustScores(parser=1.0, ocr=0.0, model=0.0), + token_equivalent=6, + ) + assert not is_visual(atom) + + +def test_only_images_whose_bytes_survive_are_encoded(tmp_path: Path) -> None: + index, settings, database, _ = _index(tmp_path) + _image_atom(tmp_path, settings, database, "keep", 1) + dead = _image_atom(tmp_path, settings, database, "gone", 2) + ObjectStore(settings).resolve(str(dead.image_ref)).unlink() + + kept = index.visual_atoms() + + assert [atom.atom_id for atom in kept] == ["keep:image"] + + +def test_a_sentence_finds_the_picture_it_describes(tmp_path: Path) -> None: + index, settings, database, encoder = _index(tmp_path) + for shade in (1, 2, 3): + _image_atom(tmp_path, settings, database, f"pic{shade}", shade) + index.build() + + ranked = index.search_text("2", top_k=3) + + assert ranked[0][0] == "pic2:image" + assert ranked[0][1] == pytest.approx(1.0) + assert encoder.text_calls == 1 + + +def test_a_picture_finds_the_picture_it_looks_like(tmp_path: Path) -> None: + index, settings, database, _ = _index(tmp_path) + for shade in (1, 2, 3): + _image_atom(tmp_path, settings, database, f"pic{shade}", shade) + index.build() + + ranked = index.search_image(_picture(tmp_path, "query", 3), top_k=2) + + assert [atom_id for atom_id, _ in ranked] == ["pic3:image", "pic1:image"] + + +def test_the_index_is_stale_the_moment_a_picture_arrives(tmp_path: Path) -> None: + index, settings, database, _ = _index(tmp_path) + _image_atom(tmp_path, settings, database, "first", 1) + index.build() + assert index.is_current() + + _image_atom(tmp_path, settings, database, "second", 2) + + assert not index.is_current(), "a new picture must not be invisible to search" + + +def test_a_pool_limits_the_answer_without_rebuilding_the_index(tmp_path: Path) -> None: + index, settings, database, _ = _index(tmp_path) + for shade in (1, 2, 3): + _image_atom(tmp_path, settings, database, f"pic{shade}", shade) + index.build() + + ranked = index.search_text("3", top_k=3, restrict={"pic1:image", "pic3:image"}) + assert [atom_id for atom_id, _ in ranked] == ["pic3:image", "pic1:image"] + + outside = index.search_text("3", top_k=3, restrict={"pic1:image", "pic2:image"}) + assert "pic3:image" not in [atom_id for atom_id, _ in outside] + + +def test_forgetting_a_picture_voids_the_picture_index(tmp_path: Path) -> None: + index, settings, database, _ = _index(tmp_path) + atom = _image_atom(tmp_path, settings, database, "pic1", 1) + index.build() + assert index.load() is not None, "warm the copy this process holds" + + report = MemoryService(settings, database).forget(source_id=atom.source_id) + + assert report.vector_index_dropped + assert not index.index_path.exists() and not index.map_path.exists() + assert index.load() is None, "a forgotten picture must not keep answering from memory" + + +def test_a_missing_snapshot_is_named_rather_than_mumbled(tmp_path: Path) -> None: + settings = _settings(tmp_path, with_model=False) + + index = VisualIndex(settings, Database(settings)) + + assert not index.available() + with pytest.raises(SystemExit, match="ELY_EYE_CLIP_MODEL_DIR"): + index.encoder() + + +def test_a_map_that_disagrees_with_the_vectors_is_refused(tmp_path: Path) -> None: + index, settings, database, _ = _index(tmp_path) + _image_atom(tmp_path, settings, database, "only", 1) + index.build() + index.map_path.write_text('["only:image", "ghost:image"]', encoding="utf-8") + + with pytest.raises(ValueError, match="2 atoms for 1 vectors"): + index.load() + + +def test_a_corrupt_map_names_the_file(tmp_path: Path) -> None: + index, settings, database, _ = _index(tmp_path) + _image_atom(tmp_path, settings, database, "only", 1) + index.build() + index.map_path.write_text("not json", encoding="utf-8") + + with pytest.raises(ValueError, match="visual index map is unreadable"): + index.load() + + +def _search_service(encoder: FakeEncoder, settings: Settings, database: Database) -> RetrievalService: + service = RetrievalService(settings, database) + _attach(service.visual, encoder) + return service + + +def test_words_that_come_up_short_let_the_picture_index_answer(tmp_path: Path) -> None: + index, settings, database, encoder = _index(tmp_path) + for shade in (1, 2, 3): + _image_atom(tmp_path, settings, database, f"pic{shade}", shade) + index.build() + service = _search_service(encoder, settings, database) + + result = service.search("7", top_k=3) + + assert result.hits[0].atom.atom_id == "pic3:image" + assert result.hits[0].visual_score == pytest.approx(1.0) + assert encoder.text_calls == 1 + + +def test_words_that_already_fill_the_page_do_not_pay_for_a_picture(tmp_path: Path) -> None: + index, settings, database, encoder = _index(tmp_path) + for shade in range(1, 9): + _image_atom(tmp_path, settings, database, f"pic{shade}", shade) + database.upsert_sources([_source(f"note{shade}", f"/tmp/note{shade}.md", "text/markdown")]) + database.upsert_atoms( + [ + EvidenceAtom( + atom_id=f"note{shade}:text", + modality=Modality.text, + source_id=f"note{shade}", + source=f"file://note{shade}", + text="widget gadget sprocket", + trust=TrustScores(parser=1.0, ocr=0.0, model=0.0), + token_equivalent=3, + holder_id=settings.default_holder, + ) + ] + ) + index.build() + service = _search_service(encoder, settings, database) + + result = service.search("widget gadget sprocket", top_k=2) + + assert encoder.text_calls == 0, "a page full of word matches does not need the encoder" + assert all(hit.visual_score == 0.0 for hit in result.hits) + + +def test_an_image_query_uses_the_image_tower(tmp_path: Path) -> None: + index, settings, database, encoder = _index(tmp_path) + for shade in (1, 2): + _image_atom(tmp_path, settings, database, f"pic{shade}", shade) + index.build() + service = _search_service(encoder, settings, database) + + picture_calls = encoder.image_calls + + result = service.search("", top_k=2, image=_picture(tmp_path, "q", 2)) + + assert result.hits[0].atom.atom_id == "pic2:image" + assert encoder.image_calls == picture_calls + 1, "the query picture goes through the image tower" + assert encoder.text_calls == 0, "a picture-only question has no words to encode" + + +def test_an_unreadable_query_image_is_the_caller_s_problem(tmp_path: Path) -> None: + index, settings, database, encoder = _index(tmp_path) + _image_atom(tmp_path, settings, database, "pic1", 1) + index.build() + service = _search_service(encoder, settings, database) + broken = tmp_path / "broken.png" + broken.write_text("not a picture", encoding="utf-8") + + with pytest.raises(ValueError, match="cannot be read"): + service.search("", image=broken) + + +def test_a_search_without_a_snapshot_never_touches_the_encoder(tmp_path: Path) -> None: + _, settings, database, encoder = _index(tmp_path, with_model=False) + _image_atom(tmp_path, settings, database, "pic1", 1) + service = _search_service(encoder, settings, database) + + result = service.search("pic1", top_k=2) + + assert encoder.text_calls == 0 + assert all(hit.visual_score == 0.0 for hit in result.hits) diff --git a/backend/tests/test_visual_tokens.py b/backend/tests/test_visual_tokens.py new file mode 100644 index 0000000..f1321fd --- /dev/null +++ b/backend/tests/test_visual_tokens.py @@ -0,0 +1,99 @@ +"""What a picture costs the model that reads it. + +The first version divided pixels by 224x224, which priced a 1280x720 frame at 18 tokens — +less than its caption. A vision model spends one token per patch, and Qwen3-VL's patch is +32x32, so the same frame is 920. A context budget built on the old number holds six times +less picture than it claims. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from PIL import Image + +import ely_eye.ingestion as ingestion_module +from ely_eye.config import Settings +from ely_eye.db import Database +from ely_eye.ingestion import IngestionService +from ely_eye.migrations import SCHEMA_VERSION, apply +from ely_eye.ocr import OcrResult +from ely_eye.storage import token_equivalent, visual_token_equivalent + +FRAME = (1280, 720) + + +def _ingest(tmp_path: Path, settings: Settings) -> Database: + """Run the real ingest path; each caller decides what its OCR says.""" + database = Database(settings) + Image.new("RGB", FRAME, (10, 20, 30)).save(tmp_path / "frame.png") + IngestionService(settings=settings, database=database).ingest_path(tmp_path / "frame.png") + return database + + +def test_a_frame_is_priced_by_the_patches_a_vision_model_sees() -> None: + assert visual_token_equivalent(1280, 720) == 920 + + +def test_a_small_tile_still_costs_more_than_a_single_token() -> None: + assert visual_token_equivalent(224, 224) == 49 + + +def test_a_region_without_a_size_is_refused_rather_than_guessed() -> None: + for width, height in ((0, 720), (1280, -1)): + with pytest.raises(ValueError, match="needs a positive size"): + visual_token_equivalent(width, height) + + +def test_an_ingested_image_carries_its_text_and_its_patches(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + settings = Settings(home=tmp_path) + monkeypatch.setattr(ingestion_module, "read_text_from_image", lambda path: OcrResult("购物车", 0.9, [])) + + database = _ingest(tmp_path, settings) + atom = database.list_atoms()[0] + + assert atom.token_equivalent == token_equivalent("购物车") + 920 + + +def test_an_existing_store_is_repriced_by_the_migration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + settings = Settings(home=tmp_path) + monkeypatch.setattr(ingestion_module, "read_text_from_image", lambda path: OcrResult("", 0.0, [])) + database = _ingest(tmp_path, settings) + note = tmp_path / "notes.md" + note.write_text("# 钥匙放在玄关抽屉里\n", encoding="utf-8") + IngestionService(settings=settings, database=database).ingest_path(note) + with database.connect() as connection: + connection.execute("UPDATE atoms SET token_equivalent = 18 WHERE modality = 'image'") + connection.execute("PRAGMA user_version = 6") + + with database.connect() as connection: + apply(connection) + + atoms = {atom.modality.value: atom for atom in database.list_atoms()} + assert atoms["image"].token_equivalent == 920, "the old 224x224 price must not survive the upgrade" + assert atoms["text"].token_equivalent == token_equivalent(note.read_text(encoding="utf-8")) + with database.connect() as connection: + assert int(connection.execute("PRAGMA user_version").fetchone()[0]) == SCHEMA_VERSION + + +def test_an_atom_without_a_layout_size_keeps_the_number_it_has(tmp_path: Path) -> None: + """Nothing can be priced from a missing region, so the stored figure is left alone.""" + settings = Settings(home=tmp_path) + database = Database(settings) + with database.connect() as connection: + apply(connection) + connection.execute("PRAGMA user_version = 6") + connection.execute( + "INSERT INTO sources (source_id, path, kind, mime, sha256, size_bytes, created_at, metadata_json)" + " VALUES ('s', '/tmp/x.png', 'image', 'image/png', 'h', 1, '2026-01-01T00:00:00+00:00', '{}')" + ) + connection.execute( + "INSERT INTO atoms (atom_id, source_id, modality, source, ingested_at, text, embedding_refs_json," + " layout_json, relations_json, trust_json, metadata_json, token_equivalent, origin)" + " VALUES ('a1', 's', 'image', '/tmp/x.png', '2026-01-01T00:00:00+00:00', 'x', '[]', '{}', '[]', '{}', '{}', 18, 'artifact')" + ) + + with database.connect() as connection: + apply(connection) + assert int(connection.execute("SELECT token_equivalent FROM atoms WHERE atom_id = 'a1'").fetchone()[0]) == 18 diff --git a/bench/reports/VISUAL.md b/bench/reports/VISUAL.md new file mode 100644 index 0000000..b05bd79 --- /dev/null +++ b/bench/reports/VISUAL.md @@ -0,0 +1,18 @@ +# VISUAL: can a library of wordless pictures be searched? (commit 7e1fe649f) + +- generated 2026-10-08T00:37:50.278445+00:00 +- corpus: OS-Copilot/ScreenSpot-v2 (icon instructions), 554 icon crops and 408 whole screenshots stored as separate memories, 427 crops with no OCR text +- encoder: Xenova/chinese-clip-vit-base-patch16 sha256 0fad978faa89b85f +- machine: 32 cpus, load [46.81, 44.0, 33.68] +- instructions containing Chinese: 0 (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 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| words → icon crops | crops | 0.090 | 0.152 | 0.175 | 0.0181 | 0.118 | 0.146 | 0.1716 | 0.2096 | +| a sentence → icon crops (picture space) | crops | 0.081 | 0.155 | 0.208 | 0.0181 | 0.116 | 0.176 | 0.1399 | 0.1998 | +| the whole screenshot → icon crops | crops | 0.038 | 0.083 | 0.108 | 0.0181 | 0.058 | 0.085 | 0.1599 | 0.22 | +| words → whole screenshots | shots | 0.121 | 0.296 | 0.384 | 0.0245 | 0.196 | 0.345 | 0.1362 | 0.2038 | +| a sentence → whole screenshots (picture space) | shots | 0.049 | 0.146 | 0.215 | 0.0245 | 0.091 | 0.183 | 0.136 | 0.204 | +| a sentence → the icons of that one screen | own_screen | 0.827 | 1.000 | 1.000 | 1.0000 | 0.908 | 0.993 | 0.1358 | 0.1959 | + +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. diff --git a/bench/reports/visual.json b/bench/reports/visual.json new file mode 100644 index 0000000..4c250cb --- /dev/null +++ b/bench/reports/visual.json @@ -0,0 +1,166 @@ +{ + "generated_at": "2026-10-08T00:37:50.278445+00:00", + "commit": "7e1fe649f68bcb25108e2781d912c8a80e3bbfc8", + "dataset": "OS-Copilot/ScreenSpot-v2 (icon instructions)", + "model": { + "repo": "Xenova/chinese-clip-vit-base-patch16", + "sha256": "0fad978faa89b85fda53a83a4c202116a9eb87c8f9723fb4d85e4483efbce4b2" + }, + "targets": 554, + "screenshots": 408, + "crops_without_ocr_text": 427, + "instruction_languages": { + "cjk_instructions": 0 + }, + "machine": { + "system": "Linux", + "release": "6.18.40.1-microsoft-standard-WSL2", + "cpus": 32, + "threads_per_query": 4, + "load_average_1_5_15": [ + 46.81, + 44.0, + 33.68 + ] + }, + "arms": [ + { + "label": "words → icon crops", + "key": "words->crops", + "n_answerable": 554, + "n_unanswerable": 0, + "recall_at_1": 0.09025270758122744, + "recall_at_5": 0.15162454873646208, + "recall_at_10": 0.17509025270758122, + "ndcg_at_10": 0.13173348831922826, + "mrr": 0.11802403873703514, + "hit_at_10": 0.17509025270758122, + "hit_at_10_wilson_lower": 0.1457109350541202, + "abstention_rate_answerable": 0.04512635379061372, + "abstention_rate_unanswerable": 0.0, + "top_hit_from_holder_unanswerable": 0.0, + "latency": { + "queries": 554, + "p50": 0.1716, + "p95": 0.2096 + }, + "chance_recall_at_10": 0.0181, + "pool": "crops" + }, + { + "label": "a sentence → icon crops (picture space)", + "key": "picture_words->crops", + "n_answerable": 554, + "n_unanswerable": 0, + "recall_at_1": 0.0812274368231047, + "recall_at_5": 0.1552346570397112, + "recall_at_10": 0.2075812274368231, + "ndcg_at_10": 0.13734530518050886, + "mrr": 0.1158894905736061, + "hit_at_10": 0.2075812274368231, + "hit_at_10_wilson_lower": 0.1758785967641179, + "abstention_rate_answerable": 0.0, + "abstention_rate_unanswerable": 0.0, + "top_hit_from_holder_unanswerable": 0.0, + "latency": { + "queries": 554, + "p50": 0.1399, + "p95": 0.1998 + }, + "chance_recall_at_10": 0.0181, + "pool": "crops" + }, + { + "label": "the whole screenshot → icon crops", + "key": "picture->crops", + "n_answerable": 554, + "n_unanswerable": 0, + "recall_at_1": 0.03790613718411552, + "recall_at_5": 0.08303249097472924, + "recall_at_10": 0.10830324909747292, + "ndcg_at_10": 0.069893811531563, + "mrr": 0.058046100509999435, + "hit_at_10": 0.10830324909747292, + "hit_at_10_wilson_lower": 0.08507164644767994, + "abstention_rate_answerable": 0.0, + "abstention_rate_unanswerable": 0.0, + "top_hit_from_holder_unanswerable": 0.0, + "latency": { + "queries": 554, + "p50": 0.1599, + "p95": 0.22 + }, + "chance_recall_at_10": 0.0181, + "pool": "crops" + }, + { + "label": "words → whole screenshots", + "key": "words->shots", + "n_answerable": 554, + "n_unanswerable": 0, + "recall_at_1": 0.12093862815884476, + "recall_at_5": 0.296028880866426, + "recall_at_10": 0.3844765342960289, + "ndcg_at_10": 0.24046684872963128, + "mrr": 0.1959028135923443, + "hit_at_10": 0.3844765342960289, + "hit_at_10_wilson_lower": 0.34489504523476483, + "abstention_rate_answerable": 0.0, + "abstention_rate_unanswerable": 0.0, + "top_hit_from_holder_unanswerable": 0.0, + "latency": { + "queries": 554, + "p50": 0.1362, + "p95": 0.2038 + }, + "chance_recall_at_10": 0.0245, + "pool": "shots" + }, + { + "label": "a sentence → whole screenshots (picture space)", + "key": "picture_words->shots", + "n_answerable": 554, + "n_unanswerable": 0, + "recall_at_1": 0.048736462093862815, + "recall_at_5": 0.14620938628158844, + "recall_at_10": 0.2148014440433213, + "ndcg_at_10": 0.11948802140007041, + "mrr": 0.09050126067274082, + "hit_at_10": 0.2148014440433213, + "hit_at_10_wilson_lower": 0.18262874778266808, + "abstention_rate_answerable": 0.0, + "abstention_rate_unanswerable": 0.0, + "top_hit_from_holder_unanswerable": 0.0, + "latency": { + "queries": 554, + "p50": 0.136, + "p95": 0.204 + }, + "chance_recall_at_10": 0.0245, + "pool": "shots" + }, + { + "label": "a sentence → the icons of that one screen", + "key": "picture_words->own_screen", + "n_answerable": 554, + "n_unanswerable": 0, + "recall_at_1": 0.8267148014440433, + "recall_at_5": 1.0, + "recall_at_10": 1.0, + "ndcg_at_10": 0.9318097223990938, + "mrr": 0.9080024067388689, + "hit_at_10": 1.0, + "hit_at_10_wilson_lower": 0.9931137086353976, + "abstention_rate_answerable": 0.0, + "abstention_rate_unanswerable": 0.0, + "top_hit_from_holder_unanswerable": 0.0, + "latency": { + "queries": 554, + "p50": 0.1358, + "p95": 0.1959 + }, + "chance_recall_at_10": 1.0, + "pool": "own_screen" + } + ] +} \ No newline at end of file diff --git a/bench/visual_recall.py b/bench/visual_recall.py new file mode 100644 index 0000000..0e904c7 --- /dev/null +++ b/bench/visual_recall.py @@ -0,0 +1,406 @@ +"""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 sys +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +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 metrics import aggregate, chance_recall_at_k, score_query +from 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()