streamembed 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ """StreamEmbed: search your own screen history by describing what you saw. Everything runs on your machine."""
2
+ from .api import (Capture, Result, available_index_types, available_models, build_index_from_directory, build_index_from_now, configure, query_index,
3
+ visualize_results)
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["Capture", "Result", "available_index_types", "available_models", "build_index_from_directory", "build_index_from_now", "configure", "query_index",
7
+ "visualize_results"]
streamembed/api.py ADDED
@@ -0,0 +1,205 @@
1
+ """Index screenshots from a folder or from the screen, query them, look at the results.
2
+
3
+ import streamembed as se
4
+ se.build_index_from_directory("screenshots")
5
+ capture = se.build_index_from_now(interval=10) # keeps capturing in the background
6
+ results = se.query_index("the flight search page with fares to Denver", top_k=10)
7
+ se.visualize_results(results)
8
+
9
+ Everything acts on one index folder (default ./streamembed_index); `configure()` changes it and the other settings.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import threading
14
+ import time
15
+ import webbrowser
16
+ from dataclasses import asdict, dataclass
17
+ from pathlib import Path
18
+ from typing import Iterator
19
+
20
+ from .capture import folder_frames, screen_frames, screen_permission
21
+ from .gate import Gate
22
+ from .index import INDEX_TYPES, Index
23
+ from . import models as _models
24
+ from .models import DEFAULT_MODEL, resolve
25
+
26
+ JPEG_QUALITY = 85
27
+
28
+
29
+ @dataclass
30
+ class Result:
31
+ id: int # row in the index
32
+ score: float # cosine similarity
33
+ time: str # capture time, local, "YYYY-MM-DD HH:MM:SS"
34
+ ts: float # the same, unix seconds
35
+ app: str | None
36
+ title: str | None # window title; for folder screenshots, the source file
37
+ path: str # the stored JPEG
38
+
39
+ def to_dict(self) -> dict:
40
+ return asdict(self)
41
+
42
+
43
+ class Capture:
44
+ """Screen capture in the background: `stop()`, `pause()`, `resume()`, `wait()`, and `stats` (captured, encoded, idle, skipped, errors)."""
45
+
46
+ def __init__(self, interval: float, duration: float | None, frames: Iterator):
47
+ self.stats = dict(captured=0, encoded=0, idle=0, skipped=0, errors=0)
48
+ self._stop, self._pause = threading.Event(), threading.Event()
49
+ self._thread = threading.Thread(target=_ingest, args=(frames, self.stats, self._stop, self._pause, interval), daemon=True)
50
+ self._thread.start()
51
+ if duration:
52
+ threading.Timer(duration, self.stop).start()
53
+
54
+ @property
55
+ def running(self) -> bool:
56
+ return self._thread.is_alive()
57
+
58
+ def pause(self) -> None:
59
+ self._pause.set()
60
+
61
+ def resume(self) -> None:
62
+ self._pause.clear()
63
+
64
+ def stop(self) -> None:
65
+ self._stop.set()
66
+ self._thread.join(timeout=30)
67
+
68
+ def wait(self) -> None:
69
+ """Block until the capture stops (after `duration`, or Ctrl-C)."""
70
+ try:
71
+ while self._thread.is_alive():
72
+ self._thread.join(1.0)
73
+ except KeyboardInterrupt:
74
+ self.stop()
75
+
76
+
77
+ # ---- settings and the lazily loaded pieces (the model takes ~20 s and ~8 GB, so it loads once, on first use)
78
+ _cfg = dict(index_dir=Path("streamembed_index"), index_type="flat", model=DEFAULT_MODEL, models_repo=None, device="auto", width=1280)
79
+ _state: dict = dict(encoder=None, index=None, capture=None, query=None)
80
+
81
+
82
+ def available_index_types() -> list[str]:
83
+ return list(INDEX_TYPES)
84
+
85
+
86
+ def available_models() -> list[str]:
87
+ """Model names from the configured model repo, then the built-in base models."""
88
+ return _models.available_models(_cfg["models_repo"])
89
+
90
+
91
+ def configure(index_dir: str | Path = "streamembed_index", index_type: str = "flat", model: str = DEFAULT_MODEL,
92
+ models_repo: str | Path | None = None, device: str = "auto", width: int = 1280, encoder=None) -> None:
93
+ """index_dir: where frames, vectors and images go. index_type: "flat" (exact) or "binary" (1-bit codes + re-rank).
94
+ model: a name from `available_models()`. models_repo: the Hugging Face repo or local folder with models.json (default
95
+ $STREAMEMBED_MODELS, else the StreamEmbed repo). encoder: a stand-in, for tests."""
96
+ if index_type not in INDEX_TYPES:
97
+ raise ValueError(f"Invalid index type: {index_type} not in {available_index_types()}")
98
+ if _state["capture"] is not None:
99
+ _state["capture"].stop()
100
+ _cfg.update(index_dir=Path(index_dir), index_type=index_type, model=model, models_repo=str(models_repo) if models_repo else None,
101
+ device=device, width=width)
102
+ _state.update(encoder=encoder, index=None, capture=None, query=None)
103
+
104
+
105
+ def _encoder():
106
+ if _state["encoder"] is None:
107
+ from .encoder import Encoder
108
+ spec = resolve(_cfg["model"], _cfg["models_repo"])
109
+ _state["encoder"] = Encoder(spec.weights, _cfg["device"], spec.base, spec.revision, (spec.scale, spec.scale), spec.encoder_name)
110
+ return _state["encoder"]
111
+
112
+
113
+ def _index() -> Index:
114
+ if _state["index"] is None:
115
+ _state["index"] = Index(_cfg["index_dir"], _cfg["index_type"], _encoder().name)
116
+ return _state["index"]
117
+
118
+
119
+ def _ingest(frames: Iterator, stats: dict, stop: threading.Event | None = None, pause: threading.Event | None = None,
120
+ interval: float | None = None) -> dict:
121
+ """frames -> change gate -> JPEG -> stream encoder -> index. One call is one stream."""
122
+ enc, idx, gate = _encoder(), _index(), Gate()
123
+ if hasattr(enc, "reset_stream"):
124
+ enc.reset_stream() # nothing is reused from the previous source's frames
125
+ it = iter(frames)
126
+ while stop is None or not stop.is_set():
127
+ tick = time.monotonic()
128
+ if pause is not None and pause.is_set(): # checked before taking a frame: nothing is captured while paused
129
+ stop.wait(0.5)
130
+ continue
131
+ try:
132
+ item = next(it)
133
+ except StopIteration:
134
+ break
135
+ try:
136
+ if item is None:
137
+ stats["skipped"] += 1
138
+ else:
139
+ img, ts, app, title = item
140
+ stats["captured"] += 1
141
+ d = gate(img, ts)
142
+ if d.idle:
143
+ stats["idle"] += 1
144
+ idx.still_on_screen(ts)
145
+ if d.encode:
146
+ rel = Path("images") / time.strftime("%Y-%m-%d", time.localtime(ts)) / f"{int(ts * 1000)}_{len(idx)}.jpg"
147
+ (idx.root / rel).parent.mkdir(parents=True, exist_ok=True)
148
+ img.save(idx.root / rel, "JPEG", quality=JPEG_QUALITY)
149
+ vec, _ = enc.encode_frame(idx.root / rel)
150
+ idx.add(vec, dict(ts=ts, path=str(rel), app=app, title=title))
151
+ stats["encoded"] += 1
152
+ except Exception:
153
+ stats["errors"] += 1
154
+ if interval and stop is not None:
155
+ stop.wait(max(0.0, interval - (time.monotonic() - tick)))
156
+ return stats
157
+
158
+
159
+ # ---- the API
160
+ def build_index_from_directory(directory: str | Path, order: str = "mtime") -> dict:
161
+ """Index every screenshot in `directory` (searched recursively) as one stream. order="mtime" uses each file's time as its capture
162
+ time; order="name" sorts by path. Unchanged screens are skipped. Returns the counts."""
163
+ if _state["capture"] is not None and _state["capture"].running:
164
+ raise RuntimeError("Stop the running capture first: both feed one stream encoder.")
165
+ return _ingest(folder_frames(Path(directory), _cfg["width"], order), dict(captured=0, encoded=0, idle=0, skipped=0, errors=0))
166
+
167
+
168
+ def build_index_from_now(interval: float = 5.0, duration: float | None = None, block: bool = False, frames: Iterator | None = None) -> Capture:
169
+ """Capture the primary display every `interval` seconds from now on, in the background (or until it stops, with block=True).
170
+ Skipped while the screen is locked or a password manager or private browsing window is in front. `frames` replaces the screen (tests)."""
171
+ if _state["capture"] is not None and _state["capture"].running:
172
+ raise RuntimeError("A capture is already running.")
173
+ if frames is None:
174
+ if not screen_permission():
175
+ raise PermissionError("Screen Recording permission is missing. Allow it for this app in System Settings > Privacy & Security "
176
+ "> Screen Recording, then run again.")
177
+ frames = screen_frames(_cfg["width"])
178
+ _encoder(), _index() # load before the thread starts, so errors surface here
179
+ _state["capture"] = Capture(interval, duration, frames)
180
+ if block:
181
+ _state["capture"].wait()
182
+ return _state["capture"]
183
+
184
+
185
+ def query_index(query: str, top_k: int = 10) -> list[Result]:
186
+ """Screens that best match a description. A match within 30 s of a better one is dropped."""
187
+ idx = _index()
188
+ hits = idx.search(_encoder().encode_query(query), top_k)
189
+ _state["query"] = query
190
+ out = []
191
+ for row, score in hits:
192
+ f = idx.frames[row]
193
+ out.append(Result(row, round(score, 4), time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(f["ts"])), f["ts"], f["app"],
194
+ f["title"], str((idx.root / f["path"]).resolve())))
195
+ return out
196
+
197
+
198
+ def visualize_results(results: list[Result], path: str | Path | None = None, open_browser: bool = True, context: int = 15) -> Path:
199
+ """Write an HTML page of the results and open it. Each result opens full size with the `context` frames before and after it."""
200
+ from .visualize import write_page
201
+ idx = _index()
202
+ out = write_page(idx, results, Path(path) if path else idx.root / "results.html", context, _state["query"])
203
+ if open_browser:
204
+ webbrowser.open(out.resolve().as_uri())
205
+ return out
streamembed/capture.py ADDED
@@ -0,0 +1,88 @@
1
+ """Frames to index: the primary display every few seconds, or a folder of screenshots. Each frame is (image, ts, app, title)."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Iterator
8
+
9
+ from PIL import Image
10
+
11
+ BLOCK = 32
12
+ EXTS = {".jpg", ".jpeg", ".png", ".webp"}
13
+ # Not captured when the frontmost app or window title contains one of these (case-insensitive). Other windows on screen are not checked.
14
+ EXCLUDE_APPS = ("1password", "bitwarden", "dashlane", "lastpass", "keychain access", "keepass")
15
+ EXCLUDE_TITLES = ("private browsing", "incognito", "inprivate")
16
+ MACOS = sys.platform == "darwin"
17
+
18
+
19
+ def fit_width(img: Image.Image, width: int) -> Image.Image:
20
+ """Resize to `width` keeping the aspect ratio, both sides multiples of 32 (one block is 32x32 px)."""
21
+ w = max(BLOCK, width // BLOCK * BLOCK)
22
+ h = max(BLOCK, round(img.height * w / img.width / BLOCK) * BLOCK)
23
+ img = img.convert("RGB")
24
+ return img if img.size == (w, h) else img.resize((w, h), Image.BICUBIC)
25
+
26
+
27
+ def folder_frames(folder: Path, width: int, order: str = "mtime", spacing_s: float = 5.0) -> Iterator[tuple]:
28
+ """order="mtime": by file time, and each file's time is its capture time. order="name": by path, stamped `spacing_s` apart, ending now."""
29
+ if order not in ("mtime", "name"):
30
+ raise ValueError(f"order must be 'mtime' or 'name', not {order!r}")
31
+ files = [p for p in Path(folder).rglob("*") if p.suffix.lower() in EXTS and p.is_file()]
32
+ if not files:
33
+ raise FileNotFoundError(f"No screenshots (.jpg, .jpeg, .png, .webp) found in {folder}")
34
+ files.sort(key=(lambda p: (p.stat().st_mtime, str(p))) if order == "mtime" else str)
35
+ start = time.time() - len(files) * spacing_s
36
+ for i, p in enumerate(files):
37
+ with Image.open(p) as im:
38
+ yield fit_width(im, width), p.stat().st_mtime if order == "mtime" else start + i * spacing_s, None, str(p)
39
+
40
+
41
+ # ---- the screen. macOS reports permission, lock state and the front window; elsewhere these checks pass.
42
+ def screen_permission() -> bool:
43
+ """Without Screen Recording permission macOS returns only the wallpaper."""
44
+ if not MACOS:
45
+ return True
46
+ import Quartz
47
+ if Quartz.CGPreflightScreenCaptureAccess():
48
+ return True
49
+ Quartz.CGRequestScreenCaptureAccess() # shows the system prompt
50
+ return False
51
+
52
+
53
+ def _locked() -> bool:
54
+ if not MACOS:
55
+ return False
56
+ import Quartz
57
+ session = Quartz.CGSessionCopyCurrentDictionary()
58
+ return bool(session and session.get("CGSSessionScreenIsLocked", 0))
59
+
60
+
61
+ def _front_window() -> tuple[str | None, str | None]:
62
+ """(app, window title) of the frontmost normal window."""
63
+ if not MACOS:
64
+ return None, None
65
+ import Quartz
66
+ options = Quartz.kCGWindowListOptionOnScreenOnly | Quartz.kCGWindowListExcludeDesktopElements
67
+ for win in Quartz.CGWindowListCopyWindowInfo(options, Quartz.kCGNullWindowID) or []:
68
+ if win.get("kCGWindowLayer", 1) == 0:
69
+ return win.get("kCGWindowOwnerName"), win.get("kCGWindowName") or None
70
+ return None, None
71
+
72
+
73
+ def private(app: str | None, title: str | None) -> bool:
74
+ a, t = (app or "").casefold(), (title or "").casefold()
75
+ return any(x in a for x in EXCLUDE_APPS) or any(x in t for x in EXCLUDE_TITLES)
76
+
77
+
78
+ def screen_frames(width: int) -> Iterator[tuple | None]:
79
+ """One item per call: a frame, or None while the screen is locked or a private window is in front."""
80
+ import mss
81
+ with mss.mss() as sct:
82
+ while True:
83
+ app, title = _front_window()
84
+ if _locked() or private(app, title):
85
+ yield None
86
+ continue
87
+ shot = sct.grab(sct.monitors[1])
88
+ yield fit_width(Image.frombytes("RGB", shot.size, shot.rgb), width), time.time(), app, title
streamembed/encoder.py ADDED
@@ -0,0 +1,108 @@
1
+ """One model, two sets of LLM attention weights.
2
+
3
+ Frames are encoded with the consistency adapter and questions with the screenshot LoRA. Both are the same base model plus a low-rank update
4
+ of the same attention layers, so instead of loading the model twice the LLM attention weights are held twice and swapped by reference for
5
+ the length of a question. The ViT is not stored twice because a text question never runs it. Weights are computed exactly as `merge_lora`
6
+ does (W + B @ A in fp32, cast back), so results equal two separately loaded models.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ import time
12
+ from contextlib import contextmanager
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+ import torch
17
+ import torch.nn.functional as F
18
+
19
+ from .engine.config import MODEL_ID, PRESETS, QUERY_INSTRUCTION
20
+ from .engine.engine import Engine, preprocess
21
+ from .engine.model import Model, _lora_pairs, pick_device
22
+ from .engine.qwen_embedder import Qwen3VLEmbedder
23
+
24
+ METHOD = "S+G+T+L" # the paper's method: spatial pruning with Grid keep + temporal reuse (ViT and LLM)
25
+
26
+
27
+ def _updated(w: torch.Tensor, a: torch.Tensor, b: torch.Tensor, scale: float = 1.0) -> torch.Tensor:
28
+ return (w.data.float() + scale * (b.float() @ a.float())).to(w.dtype)
29
+
30
+
31
+ class Encoder:
32
+ def __init__(self, weights: tuple[Path, Path] | None, device: str = "auto", model_id: str = MODEL_ID, revision: str | None = None,
33
+ scales: tuple[float, float] = (1.0, 1.0), name: str | None = None):
34
+ """weights: (frame adapter, question LoRA) PEFT state dicts, or None for the base model. revision: the base model commit.
35
+ scales: LoRA alpha / rank of each."""
36
+ device = pick_device(device)
37
+ dtype = torch.float32 if device == "cpu" else torch.float16
38
+ try:
39
+ emb = Qwen3VLEmbedder(model_id, dtype=dtype, local_files_only=True, revision=revision) # once cached, never touch the network
40
+ except OSError:
41
+ emb = Qwen3VLEmbedder(model_id, dtype=dtype, revision=revision) # first run: download
42
+ query_weights = self._merge(emb.model, *weights, *scales) if weights is not None else []
43
+ emb.model.to(device).eval()
44
+ # Read the frame weights after .to(device), which may replace the Parameter objects
45
+ self._swap = [(mod, mod.weight, torch.nn.Parameter(q.to(device), requires_grad=False)) for mod, q in query_weights]
46
+ self.m = Model(embedder=emb, device=torch.device(device), dtype=dtype)
47
+ self.cfg = PRESETS[METHOD]
48
+ self.engine = Engine(self.m, self.cfg)
49
+ self.lock = threading.Lock() # one forward at a time: the swap must not overlap a frame
50
+ self._first = True
51
+ self.name = name or f"{Path(model_id).name}:{weights[0].name if weights else 'zero-shot'}:{METHOD}"
52
+
53
+ @staticmethod
54
+ def _merge(model: torch.nn.Module, adapter: Path, query: Path, frame_scale: float = 1.0,
55
+ query_scale: float = 1.0) -> list[tuple[torch.nn.Module, torch.Tensor]]:
56
+ """Merge the adapter into the model; return (module, query weight) for every LLM layer either LoRA touches."""
57
+ frame_pairs = {name: (a, b) for name, a, b in _lora_pairs(str(adapter))}
58
+ query_pairs = {name: (a, b) for name, a, b in _lora_pairs(str(query)) if "language_model" in name}
59
+ out = []
60
+ for name in sorted(frame_pairs.keys() | query_pairs.keys()):
61
+ mod = model.get_submodule(name)
62
+ w = mod.weight
63
+ if "language_model" in name:
64
+ out.append((mod, _updated(w, *query_pairs[name], query_scale) if name in query_pairs else w.data.clone()))
65
+ if name in frame_pairs:
66
+ w.data = _updated(w, *frame_pairs[name], frame_scale)
67
+ return out
68
+
69
+ @contextmanager
70
+ def _query_weights(self):
71
+ for mod, _, q in self._swap:
72
+ mod.weight = q
73
+ try:
74
+ yield
75
+ finally:
76
+ for mod, f, _ in self._swap:
77
+ mod.weight = f
78
+
79
+ def reset_stream(self) -> None:
80
+ """The next frame starts a new stream: nothing is reused from frames of another source."""
81
+ with self.lock:
82
+ self._first = True
83
+
84
+ def encode_frame(self, image_path: str | Path) -> tuple[np.ndarray, float]:
85
+ """Next frame of the stream -> (2048-d unit vector, encode milliseconds)."""
86
+ t = time.perf_counter()
87
+ frame = preprocess(self.m, str(image_path), self.cfg)
88
+ with self.lock:
89
+ emb, _ = self.engine.step(frame, first=self._first)
90
+ self._first = False
91
+ v = emb.float().cpu().numpy()
92
+ self._trim()
93
+ return v, (time.perf_counter() - t) * 1000
94
+
95
+ def _trim(self) -> None:
96
+ """Metal keeps freed buffers unless asked to release them. On an M2 Pro over 245 frames, releasing after every frame cut peak Metal
97
+ memory from 10.3 GB (every 50 frames) to 7.1 GB, with no slowdown (mean 675 vs 695 ms)."""
98
+ if self.m.device.type == "mps":
99
+ torch.mps.empty_cache()
100
+
101
+ def encode_query(self, text: str) -> np.ndarray:
102
+ with self.lock, self._query_weights():
103
+ self.m.core.rope_deltas = None # cached by the stock forward; must not leak between prompts
104
+ e = self.m.embedder.embed([self.m.embedder.format_model_input(text=text, instruction=QUERY_INSTRUCTION)], normalize=False)
105
+ self.m.core.rope_deltas = None
106
+ v = F.normalize(e.float(), p=2, dim=-1)[0].cpu().numpy()
107
+ self._trim()
108
+ return v
@@ -0,0 +1 @@
1
+ """Stream encoder from the paper: spatial pruning (with Grid keep) and temporal reuse. Code names follow the paper code (Token Pruning, KV Reuse)."""
@@ -0,0 +1,82 @@
1
+ """Method settings: which efficiency components are on, their thresholds, and named presets.
2
+
3
+ Pixel values from the Qwen3-VL processor are normalised to [-1, 1], so one 8-bit grey level is 1/127.5. Thresholds are given in grey
4
+ levels on the command line (tau 8, delta 8) and converted with LEVEL.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from dataclasses import dataclass, replace
10
+
11
+ MODEL_ID = os.environ.get("STREAMEMBED_MODEL", "Qwen/Qwen3-VL-Embedding-2B")
12
+ LEVEL = 1 / 127.5 # one 8-bit grey level in processor units
13
+ QUERY_INSTRUCTION = "Retrieve the computer screen that matches the user's description."
14
+ MAX_OCR_CHARS = 12000 # OCR text is cut to this many characters before text embedding
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class MethodConfig:
19
+ """Efficiency components of the screenshot route.
20
+
21
+ spatial Token Pruning rule: "none" | "lt" (drop a block equal within tau to its left OR upper block) | "lt-grid"
22
+ (paper: lt + Grid Keep, one block per flat region per grid_tile x grid_tile block tile kept) | "pixelprune" (Pred-2D)
23
+ tau_levels Token Pruning threshold in grey levels (8 = 8/255)
24
+ grid_tile Grid Keep tile side in blocks (paper: 3, i.e. 3x3-block tiles)
25
+ temporal_vit KV Reuse (ViT): recompute only changed blocks in the ViT (cached K/V for the rest); implies exact LLM prefix reuse
26
+ delta_levels KV Reuse change threshold in grey levels (block changed if max |pixel - pixel at its last recompute| > delta)
27
+ refresh_rho KV Reuse: full refresh when more than this fraction of the kept blocks changed ...
28
+ refresh_every ... or every K-th frame since the last refresh
29
+ approx_llm KV Reuse (LLM): recompute only changed visual tokens + text after the first change (needs temporal_vit)
30
+ linear_patch_embed linear patch embedding ("+pe"): ViT patch embedding as F.linear instead of Conv3d (identical math; MPS Conv3d
31
+ is very slow)
32
+ """
33
+ spatial: str = "none"
34
+ tau_levels: float = 8.0
35
+ grid_tile: int = 3
36
+ temporal_vit: bool = False
37
+ delta_levels: float = 8.0
38
+ refresh_rho: float = 0.9
39
+ refresh_every: int = 10
40
+ approx_llm: bool = False
41
+ linear_patch_embed: bool = False
42
+
43
+ def __post_init__(self):
44
+ assert self.spatial in ("none", "lt", "lt-grid", "pixelprune"), self.spatial
45
+ assert self.temporal_vit or not self.approx_llm, "KV Reuse (LLM) reuses per-block state that only KV Reuse (ViT) keeps"
46
+ assert self.grid_tile >= 1, self.grid_tile
47
+
48
+ @property
49
+ def tau(self) -> float:
50
+ return self.tau_levels * LEVEL
51
+
52
+ @property
53
+ def delta(self) -> float:
54
+ return self.delta_levels * LEVEL
55
+
56
+ @property
57
+ def name(self) -> str:
58
+ """Short tag, e.g. LT8t3TL+pe (Token Pruning rule + tau, t3 = Grid Keep with 3x3 tiles, T = KV Reuse (ViT), L = KV Reuse
59
+ (LLM), +pe = linear patch embedding)."""
60
+ s = {"none": "FULL", "lt": f"LT{self.tau_levels:g}", "lt-grid": f"LT{self.tau_levels:g}t{self.grid_tile}",
61
+ "pixelprune": f"PP{self.tau_levels:g}"}[self.spatial]
62
+ if self.spatial == "none" and self.temporal_vit:
63
+ s = ""
64
+ s += ("T" if self.temporal_vit else "") + ("L" if self.approx_llm else "")
65
+ if self.temporal_vit and (self.refresh_rho, self.refresh_every, self.delta_levels) != (0.9, 10, 8.0):
66
+ s += f"_r{self.refresh_rho:g}k{self.refresh_every}d{self.delta_levels:g}"
67
+ return s + ("+pe" if self.linear_patch_embed else "")
68
+
69
+ def with_(self, **kw) -> "MethodConfig":
70
+ return replace(self, **kw)
71
+
72
+
73
+ # Named presets (paper rows). Explicit CLI flags override preset values.
74
+ PRESETS: dict[str, MethodConfig] = {
75
+ "full": MethodConfig(), # Full screenshot, stock Conv3d patch embedding
76
+ "full+pe": MethodConfig(linear_patch_embed=True), # Full screenshot, linear patch embedding
77
+ "S": MethodConfig(spatial="lt", linear_patch_embed=True), # Token Pruning
78
+ "S+T": MethodConfig(spatial="lt", temporal_vit=True, linear_patch_embed=True), # + KV Reuse (ViT) (+ exact LLM prefix reuse)
79
+ "S+T+L": MethodConfig(spatial="lt", temporal_vit=True, approx_llm=True, linear_patch_embed=True), # + KV Reuse (LLM)
80
+ "S+G": MethodConfig(spatial="lt-grid", linear_patch_embed=True), # Token Pruning with Grid Keep
81
+ "S+G+T+L": MethodConfig(spatial="lt-grid", temporal_vit=True, approx_llm=True, linear_patch_embed=True), # paper method
82
+ }
@@ -0,0 +1,90 @@
1
+ """Engine: one screenshot in, one embedding out, carrying per-stream state between frames.
2
+
3
+ Engine.step: Token Pruning keep set + KV Reuse (ViT) plan (change detection, refresh rule)
4
+ -> LLM plan (prefix P, skip, KV Reuse (LLM) rows R)
5
+ -> ViT (full or partial) -> sync -> LLM (exact prefix reuse or KV Reuse (LLM)) -> stats / MAC row -> commit state
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from dataclasses import dataclass
11
+ from typing import Optional
12
+
13
+ import numpy as np
14
+ import torch
15
+
16
+ from . import llm
17
+ from .config import MethodConfig
18
+ from .macs import llm_macs_rows, llm_macs_suffix, vit_macs
19
+ from .model import Grid, Model, grid
20
+ from .spatial import keep_mask
21
+ from .temporal import FramePlan, StreamState, commit, plan_frame
22
+ from .vit import run_vit
23
+
24
+
25
+ @dataclass
26
+ class FrameInputs:
27
+ inputs: dict # processor output (CPU tensors)
28
+ keep_mask: Optional[np.ndarray] # Token Pruning keep mask (None = all blocks)
29
+
30
+
31
+ def preprocess(m: Model, image_path: str, cfg: MethodConfig) -> FrameInputs:
32
+ """Image file -> processor tensors (+ Token Pruning keep mask). CPU only, so it can run in DataLoader workers."""
33
+ emb = m.embedder
34
+ inputs = emb.preprocess([emb.format_model_input(image=image_path)])
35
+ return FrameInputs(inputs, keep_mask(cfg.spatial, inputs["pixel_values"], inputs["image_grid_thw"], cfg.tau, cfg.grid_tile))
36
+
37
+
38
+ class Engine:
39
+ """One engine per stream and config (own caches)."""
40
+
41
+ def __init__(self, m: Model, cfg: MethodConfig):
42
+ self.m, self.cfg = m, cfg
43
+ self.state: Optional[StreamState] = None
44
+
45
+ @torch.inference_mode()
46
+ def step(self, frame: FrameInputs, first: bool, timings: Optional[dict] = None) -> tuple[torch.Tensor, dict]:
47
+ """Embed one frame. first=True starts a new stream (drops all reuse state). Returns (embedding (2048,), stats row)."""
48
+ m, cfg = self.m, self.cfg
49
+ inputs = {k: v.to(m.device) for k, v in frame.inputs.items()}
50
+ g = grid(m, inputs)
51
+ pixels = inputs["pixel_values"].float()
52
+ block_pixels = pixels.reshape(g.n_blocks, -1)
53
+
54
+ state = None
55
+ if cfg.temporal_vit:
56
+ if self.state is None or self.state.key != g.key: # new resolution / prompt: fresh caches
57
+ self.state = StreamState(m, g, approx_llm=cfg.approx_llm)
58
+ first = True
59
+ state = self.state
60
+ if first:
61
+ state.reset(m, g)
62
+
63
+ plan = plan_frame(cfg, m, g, state, block_pixels, first, frame.keep_mask)
64
+ llm.plan_llm(g, plan, cfg.approx_llm)
65
+ visual_tokens, deepstack = run_vit(m, g, plan, pixels, block_pixels, cfg.linear_patch_embed)
66
+ m.sync()
67
+ if timings is not None:
68
+ timings["t_vit_done"] = time.perf_counter()
69
+ run_llm = llm.run_approx if cfg.approx_llm else llm.run_exact
70
+ emb = run_llm(m, g, plan, visual_tokens, deepstack)
71
+ row = stats_row(m, g, plan, cfg.approx_llm)
72
+ commit(plan, emb)
73
+ return emb, row
74
+
75
+
76
+ def stats_row(m: Model, g: Grid, plan: FramePlan, approx_llm: bool) -> dict:
77
+ """Per-frame work counters and MACs."""
78
+ nk, nc = len(plan.kept), len(plan.recompute)
79
+ L = g.llm_len(nk)
80
+ P = L if plan.skip_llm else plan.prefix
81
+ if plan.skip_llm:
82
+ n_rows, lm = 0, 0
83
+ elif approx_llm:
84
+ n_rows, lm = len(plan.llm_rows), llm_macs_rows(m, plan.llm_rows)
85
+ else:
86
+ n_rows, lm = L - P, llm_macs_suffix(m, P, L)
87
+ return dict(n_kept=nk, n_comp_blocks=nc, full=bool(plan.refresh), changed_frac=plan.changed_frac,
88
+ vit_q_patches=4 * nc, vit_kv_patches=4 * nk, vit_macs=vit_macs(m, 4 * nc, 4 * nk, nc) if nc else 0,
89
+ llm_comp_tokens=n_rows, llm_len=L, llm_macs=lm, since_full=plan.since_refresh, llm_skip=bool(plan.skip_llm),
90
+ llm_P=int(P))