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.
- streamembed/__init__.py +7 -0
- streamembed/api.py +205 -0
- streamembed/capture.py +88 -0
- streamembed/encoder.py +108 -0
- streamembed/engine/__init__.py +1 -0
- streamembed/engine/config.py +82 -0
- streamembed/engine/engine.py +90 -0
- streamembed/engine/llm.py +170 -0
- streamembed/engine/macs.py +46 -0
- streamembed/engine/model.py +254 -0
- streamembed/engine/qwen_embedder.py +106 -0
- streamembed/engine/spatial.py +126 -0
- streamembed/engine/temporal.py +108 -0
- streamembed/engine/vit.py +86 -0
- streamembed/gate.py +102 -0
- streamembed/index.py +138 -0
- streamembed/models.py +90 -0
- streamembed/static/results.html +148 -0
- streamembed/visualize.py +39 -0
- streamembed-0.1.0.dist-info/METADATA +122 -0
- streamembed-0.1.0.dist-info/RECORD +24 -0
- streamembed-0.1.0.dist-info/WHEEL +4 -0
- streamembed-0.1.0.dist-info/licenses/LICENSE +201 -0
- streamembed-0.1.0.dist-info/licenses/NOTICE +11 -0
streamembed/__init__.py
ADDED
|
@@ -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))
|