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,126 @@
1
+ """Token Pruning, for spatial redundancy: drop 32x32-px blocks (one LLM token each) before the ViT, within a frame.
2
+
3
+ Much of a screen is blank or flat (backgrounds, margins, whitespace, table fills). A block whose pixels are predictable from its left or
4
+ upper neighbour carries little information, so it enters neither the ViT nor the LLM. Rules over the processor's pixel values (tau 8
5
+ grey levels = 8/255):
6
+
7
+ lt (Token Pruning, "LT8") drop a block iff every pixel is within tau of its LEFT or its UPPER neighbour block, comparing
8
+ original pixels (order-free version of PixelPrune). Every block is decided independently -> a few vectorised array ops.
9
+ lt-grid (Token Pruning with Grid Keep, "LT8t3", paper default) lt alone shrinks a large flat region (blank page, empty panel) to a
10
+ single token, so the model loses its size and layout. Grid Keep keeps one block per tile x tile block tile of each flat
11
+ region (paper: 3x3; MethodConfig.grid_tile), at its own position. No training; kept blocks are a superset of lt's.
12
+ pixelprune PixelPrune Pred-2D: raster-order scan with a lossless-coding predictor from the reconstructed left / up / up-left
13
+ neighbours (kept for comparison; sequential, slower on the CPU).
14
+
15
+ Masks are computed on the host (numpy) from the CPU processor output.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import numpy as np
20
+ import torch
21
+
22
+
23
+ def _blocks(pixel_values: torch.Tensor, grid_thw: torch.Tensor) -> np.ndarray:
24
+ """(blocks_h, blocks_w, 4*1536) view of the processor output (patches are ordered block by block, 4 per block)."""
25
+ bh, bw = int(grid_thw[0, 1]) // 2, int(grid_thw[0, 2]) // 2
26
+ return np.asarray(pixel_values, dtype=np.float32).reshape(bh, bw, -1)
27
+
28
+
29
+ def lt_keep_mask(pixel_values: torch.Tensor, grid_thw: torch.Tensor, tau: float) -> np.ndarray:
30
+ """Keep mask (raster order) of the order-free left-or-upper rule. At least one block is always kept."""
31
+ blocks = _blocks(pixel_values, grid_thw)
32
+ drop = np.zeros(blocks.shape[:2], bool)
33
+ drop[:, 1:] |= np.abs(blocks[:, 1:] - blocks[:, :-1]).max(-1) <= tau
34
+ drop[1:, :] |= np.abs(blocks[1:] - blocks[:-1]).max(-1) <= tau
35
+ keep = ~drop
36
+ if not keep.any(): # a perfectly uniform screen: keep the top-left block so the LLM still sees one visual token
37
+ keep[0, 0] = True
38
+ return keep.reshape(-1)
39
+
40
+
41
+ def pixelprune_keep_mask(pixel_values: torch.Tensor, grid_thw: torch.Tensor, tau: float) -> np.ndarray:
42
+ """Keep mask (raster order) of PixelPrune Pred-2D. tau == 0 means bit-exact equality. The first block is always kept.
43
+
44
+ Prediction per block (a = left, b = up, c = up-left, all reconstructed):
45
+ if c == b and a != b: predict a elif c == a and a != b: predict b else: predict a
46
+ First row predicts from the left, first column from above. A dropped block's reconstruction is its prediction.
47
+ """
48
+ blocks = _blocks(pixel_values, grid_thw)
49
+ bh, bw = blocks.shape[:2]
50
+ rec = blocks.copy()
51
+ keep = np.ones((bh, bw), bool)
52
+ if tau == 0:
53
+ eq = np.array_equal
54
+ else:
55
+ def eq(x, y):
56
+ return float(np.abs(x - y).max()) <= tau
57
+ for r in range(bh):
58
+ for c in range(bw):
59
+ if r == 0 and c == 0:
60
+ continue
61
+ if r == 0:
62
+ pred = rec[0, c - 1]
63
+ elif c == 0:
64
+ pred = rec[r - 1, 0]
65
+ else:
66
+ a, b, cc = rec[r, c - 1], rec[r - 1, c], rec[r - 1, c - 1]
67
+ cb, ca, ab = eq(cc, b), eq(cc, a), eq(a, b)
68
+ pred = a if (cb and not ab) else b if (ca and not ab) else a
69
+ if eq(blocks[r, c], pred):
70
+ keep[r, c] = False
71
+ rec[r, c] = pred
72
+ return keep.reshape(-1)
73
+
74
+
75
+ def _flat_components(eq_left: np.ndarray, eq_up: np.ndarray) -> np.ndarray:
76
+ """Connected components of the lt equality graph (edge to the left block where eq_left, to the upper block where eq_up).
77
+ One label per block = the smallest raster index in its component (min-label propagation along the edges + pointer jumping)."""
78
+ bh, bw = eq_left.shape
79
+ lab = np.arange(bh * bw).reshape(bh, bw)
80
+ hl, vu = eq_left[:, 1:], eq_up[1:]
81
+ while True:
82
+ old = lab.copy()
83
+ m = np.minimum(lab[:, 1:], lab[:, :-1])
84
+ lab[:, 1:] = np.where(hl, m, lab[:, 1:])
85
+ lab[:, :-1] = np.where(hl, np.minimum(lab[:, :-1], m), lab[:, :-1])
86
+ m = np.minimum(lab[1:], lab[:-1])
87
+ lab[1:] = np.where(vu, m, lab[1:])
88
+ lab[:-1] = np.where(vu, np.minimum(lab[:-1], m), lab[:-1])
89
+ flat = lab.reshape(-1)
90
+ lab = flat[flat].reshape(bh, bw)
91
+ if np.array_equal(lab, old):
92
+ return lab
93
+
94
+
95
+ def gridkeep_keep_mask(pixel_values: torch.Tensor, grid_thw: torch.Tensor, tau: float, tile: int = 2) -> np.ndarray:
96
+ """Keep mask (raster order) of lt + Grid Keep. The flat components of lt (connected through its left / upper equalities) are
97
+ cut into tile x tile block tiles; every (component, tile) group without a kept block keeps its raster-first (top-left) block,
98
+ which is encoded normally at its own position."""
99
+ blocks = _blocks(pixel_values, grid_thw)
100
+ bh, bw = blocks.shape[:2]
101
+ eq_left = np.zeros((bh, bw), bool)
102
+ eq_up = np.zeros((bh, bw), bool)
103
+ eq_left[:, 1:] = np.abs(blocks[:, 1:] - blocks[:, :-1]).max(-1) <= tau
104
+ eq_up[1:, :] = np.abs(blocks[1:] - blocks[:-1]).max(-1) <= tau
105
+ keep = (~(eq_left | eq_up)).reshape(-1) # = lt_keep_mask; block (0, 0) is always kept
106
+ lab = _flat_components(eq_left, eq_up)
107
+ rr, cc = np.meshgrid(np.arange(bh), np.arange(bw), indexing="ij")
108
+ ntc = -(-bw // tile)
109
+ group = (lab * (-(-bh // tile) * ntc) + (rr // tile) * ntc + cc // tile).reshape(-1) # (component, tile) id
110
+ orphan = np.flatnonzero(~keep & ~np.isin(group, group[keep])) # dropped blocks of groups without a kept block, raster order
111
+ if len(orphan):
112
+ _, first = np.unique(group[orphan], return_index=True) # raster-first member of each such group
113
+ keep[orphan[first]] = True
114
+ return keep
115
+
116
+
117
+ RULES = {"lt": lt_keep_mask, "lt-grid": gridkeep_keep_mask, "pixelprune": pixelprune_keep_mask}
118
+
119
+
120
+ def keep_mask(rule: str, pixel_values: torch.Tensor, grid_thw: torch.Tensor, tau: float, grid_tile: int = 3):
121
+ """Keep mask of `rule`, or None for rule "none" (all blocks kept). `grid_tile` is the Grid Keep tile side (lt-grid only)."""
122
+ if rule == "none":
123
+ return None
124
+ if rule == "lt-grid":
125
+ return gridkeep_keep_mask(pixel_values, grid_thw, tau, tile=grid_tile)
126
+ return RULES[rule](pixel_values, grid_thw, tau)
@@ -0,0 +1,108 @@
1
+ """KV Reuse, for temporal redundancy: per-stream cache state, change detection and the refresh rule.
2
+
3
+ Consecutive screenshots (every 5 s) are often identical; only small regions change (typing, cursor). Across frames, a block counts as
4
+ changed if any pixel differs by more than delta (8 grey levels = 8/255) from when it was LAST computed (not the previous frame, so slow
5
+ drifts cannot accumulate unseen). Changed kept blocks are recomputed; the ViT reuses the cached K/V of all other kept blocks (vit.py),
6
+ and the LLM reuses the cached K/V of their visual tokens (llm.py).
7
+
8
+ Full refresh (recompute all kept blocks) when the stream starts, when more than rho of the kept blocks changed (a new screen), or every
9
+ K frames since the last refresh (bounds the drift of the approximate reuse).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from typing import Optional
15
+
16
+ import numpy as np
17
+ import torch
18
+
19
+ from .config import MethodConfig
20
+ from .model import Grid, Model
21
+
22
+
23
+ def to_device(idx: np.ndarray, m: Model) -> torch.Tensor:
24
+ # changed: index sets are built on the host and uploaded (MPS index tensors from nonzero() were corrupted under memory pressure)
25
+ return torch.from_numpy(np.ascontiguousarray(idx, dtype=np.int64)).to(m.device)
26
+
27
+
28
+ class StreamState:
29
+ """Everything one stream carries from frame to frame (one per Engine; reset when a new stream starts)."""
30
+
31
+ def __init__(self, m: Model, g: Grid, approx_llm: bool):
32
+ nl, n, nh, hd = len(m.vit.blocks), g.n_patches, m.vit_heads, m.vit_head_dim
33
+ out = m.vcfg.out_hidden_size
34
+ self.key = g.key
35
+ # ViT: post-RoPE K/V of every patch in every ViT layer, so changed blocks attend over unchanged ones without recomputing them
36
+ self.vit_k = torch.empty(nl, n, nh, hd, dtype=m.dtype, device=m.device)
37
+ self.vit_v = torch.empty_like(self.vit_k)
38
+ # KV Reuse (ViT): per-block ViT outputs (merger = LLM visual token, DeepStack = features added to early LLM layers)
39
+ self.merged = torch.empty(g.n_blocks, out, dtype=m.dtype, device=m.device)
40
+ self.deepstack = torch.empty(len(m.deepstack_layers), g.n_blocks, out, dtype=m.dtype, device=m.device)
41
+ # KV Reuse (ViT): pixels of each block at its last recompute (the change-detection reference)
42
+ self.ref = torch.empty(g.n_blocks, 4 * m.patch_w.shape[1], dtype=torch.float32, device=m.device)
43
+ # LLM: post-RoPE K/V of every LLM layer per FULL-grid slot (slot = position in the prompt with all blocks), so a block keeps
44
+ # its slot even when Token Pruning keeps a different set of blocks next frame (~100 MB fp16)
45
+ self.llm_k = self.llm_v = None
46
+ if approx_llm:
47
+ tc = m.tcfg
48
+ self.llm_k = torch.empty(tc.num_hidden_layers, g.length, tc.num_key_value_heads, tc.head_dim, dtype=m.dtype, device=m.device)
49
+ self.llm_v = torch.empty_like(self.llm_k)
50
+ self.reset(m, g)
51
+
52
+ def reset(self, m: Model, g: Grid):
53
+ self.valid = torch.zeros(g.n_blocks, dtype=torch.bool, device=m.device) # block has cached ViT state
54
+ self.since_refresh = 0
55
+ self.prev_kept: Optional[np.ndarray] = None # kept blocks of the previous frame (LLM prefix reuse)
56
+ self.prev_emb: Optional[torch.Tensor] = None # previous embedding (returned unchanged when nothing changed)
57
+ self.exact_kv = None # exact LLM prefix reuse: HF DynamicCache of the previous frame
58
+ self.llm_valid = np.zeros(g.length, bool) if self.llm_k is not None else None # KV Reuse (LLM): slot has cached K/V
59
+
60
+
61
+ @dataclass
62
+ class FramePlan:
63
+ """What to compute for one frame."""
64
+ kept: np.ndarray # block ids fed to ViT/LLM after Token Pruning (sorted)
65
+ recompute: np.ndarray # block ids that go through the ViT this frame (subset of kept; = kept on a refresh)
66
+ refresh: bool
67
+ changed_frac: float # fraction of kept blocks that changed (-1 at stream start / without KV Reuse (ViT))
68
+ since_refresh: int # frames since the last refresh, after this frame
69
+ state: Optional[StreamState]
70
+ kept_t: torch.Tensor = None # device copies of the index sets
71
+ recompute_t: torch.Tensor = None
72
+ # LLM part, filled by llm.plan_llm()
73
+ prefix: int = 0 # P: positions [0, P) are bit-identical to the previous frame
74
+ skip_llm: bool = False # nothing changed at all: reuse the previous embedding
75
+ llm_rows: Optional[np.ndarray] = None # KV Reuse (LLM): positions recomputed in the LLM (R)
76
+
77
+
78
+ @torch.inference_mode()
79
+ def plan_frame(cfg: MethodConfig, m: Model, g: Grid, state: Optional[StreamState], block_pixels: torch.Tensor, first: bool,
80
+ keep_mask: Optional[np.ndarray]) -> FramePlan:
81
+ """Kept blocks (Token Pruning) and recomputed blocks (KV Reuse) of one frame. block_pixels: (n_blocks, 4*1536) float32 on the
82
+ device."""
83
+ kept = np.flatnonzero(keep_mask) if keep_mask is not None else np.arange(g.n_blocks)
84
+ kept_t = to_device(kept, m)
85
+ if state is None: # no KV Reuse (ViT): every kept block goes through the ViT
86
+ return FramePlan(kept, kept, True, -1.0, 0, None, kept_t, kept_t)
87
+ refresh = first or state.since_refresh + 1 >= cfg.refresh_every
88
+ frac = -1.0
89
+ changed = None
90
+ if not first:
91
+ changed = ((block_pixels - state.ref).abs().amax(1) > cfg.delta) | ~state.valid
92
+ frac = float(changed[kept_t].float().mean()) # float32 mean on the device (rounding decides exact ties at rho)
93
+ refresh = refresh or frac > cfg.refresh_rho
94
+ if refresh:
95
+ recompute, recompute_t = kept, kept_t
96
+ else:
97
+ recompute = kept[changed.cpu().numpy()[kept]]
98
+ recompute_t = to_device(recompute, m)
99
+ return FramePlan(kept, recompute, refresh, frac, 0 if refresh else state.since_refresh + 1, state, kept_t, recompute_t)
100
+
101
+
102
+ def commit(plan: FramePlan, emb: torch.Tensor):
103
+ """After the frame: remember what the next frame's prefix reuse / skip needs."""
104
+ st = plan.state
105
+ if st is not None:
106
+ st.prev_kept = plan.kept
107
+ st.prev_emb = emb
108
+ st.since_refresh = plan.since_refresh
@@ -0,0 +1,86 @@
1
+ """ViT forward over a subset of blocks: plain (full screenshot / Token Pruning) or partial with cached K/V (KV Reuse (ViT)).
2
+
3
+ Re-implements the HF Qwen3-VL vision forward layer by layer (same modules and weights) so that only some patches are queries:
4
+ * Token Pruning: only the patches of kept blocks enter (changed: stock HF always runs every patch).
5
+ * KV Reuse: only patches of recomputed blocks are queries in EVERY layer; their fresh post-RoPE K/V are written into the stream's cache
6
+ and they attend over the cached K/V of all kept blocks. Unchanged blocks are never run; their merger / DeepStack outputs come from a
7
+ per-block cache. Attention in the Qwen3-VL ViT is full (non-causal) over the image, so unchanged blocks do not see the change: this is
8
+ the approximation of KV Reuse (ViT).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import numpy as np
13
+ import torch
14
+ import torch.nn.functional as F
15
+ from transformers.models.qwen3_vl import modeling_qwen3_vl as MQ
16
+
17
+ from .model import Grid, Model, embed_patches
18
+ from .temporal import FramePlan, to_device
19
+
20
+
21
+ def patch_rows(blocks: np.ndarray) -> np.ndarray:
22
+ """Patch row ids of blocks (each block = 4 consecutive patch rows in the processor's order)."""
23
+ return (blocks[:, None] * 4 + np.arange(4)).reshape(-1)
24
+
25
+
26
+ @torch.inference_mode()
27
+ def run_vit(m: Model, g: Grid, plan: FramePlan, pixels: torch.Tensor, block_pixels: torch.Tensor, linear_patch_embed: bool):
28
+ """Returns (visual tokens of the kept blocks, list of DeepStack features of the kept blocks), both in kept order."""
29
+ st = plan.state
30
+ if len(plan.recompute) == 0: # nothing changed: everything from the cache
31
+ return _from_cache(m, plan)
32
+ V = m.vit
33
+ q_rows = to_device(patch_rows(plan.recompute), m)
34
+ partial = st is not None and len(plan.recompute) < len(plan.kept)
35
+ kv_rows = to_device(patch_rows(plan.kept), m) if partial else None
36
+ x = embed_patches(m, pixels[q_rows].to(m.dtype), linear_patch_embed)
37
+ x = x + g.vit_pos[q_rows].to(x.dtype)
38
+ cos, sin = g.vit_cos[q_rows], g.vit_sin[q_rows]
39
+ n = x.shape[0]
40
+ deepstack = []
41
+ for layer, blk in enumerate(V.blocks):
42
+ q, k, v = KERNELS["vit_qkv"](blk, x, cos, sin, m.vit_heads, m.vit_head_dim)
43
+ if st is not None: # changed: keep fresh K/V for later frames (also on a refresh)
44
+ st.vit_k[layer][q_rows] = k
45
+ st.vit_v[layer][q_rows] = v
46
+ if partial: # changed: attend over all kept blocks, unchanged ones from the cache
47
+ k, v = st.vit_k[layer][kv_rows], st.vit_v[layer][kv_rows]
48
+ # changed: one SDPA call over a single image (no cu_seqlens varlen path); identical for batch-1 inputs
49
+ o = F.scaled_dot_product_attention(q.transpose(0, 1)[None], k.transpose(0, 1)[None], v.transpose(0, 1)[None],
50
+ dropout_p=0.0, scale=blk.attn.scaling, is_causal=False)
51
+ x = KERNELS["vit_out"](blk, x, o, n)
52
+ if layer in m.deepstack_layers:
53
+ deepstack.append(V.deepstack_merger_list[m.deepstack_layers.index(layer)](x))
54
+ merged = V.merger(x)
55
+ if st is None:
56
+ return merged, deepstack
57
+ c = plan.recompute_t
58
+ st.merged[c] = merged
59
+ for i in range(len(deepstack)):
60
+ st.deepstack[i][c] = deepstack[i]
61
+ st.valid[c] = True
62
+ st.ref[c] = block_pixels[c]
63
+ return _from_cache(m, plan)
64
+
65
+
66
+ def _vit_qkv(blk, x, cos, sin, heads: int, head_dim: int):
67
+ """norm1 -> qkv projection -> 2-D RoPE (the dense part before attention)."""
68
+ h = blk.norm1(x)
69
+ q, k, v = blk.attn.qkv(h).reshape(-1, 3, heads, head_dim).permute(1, 0, 2, 3).unbind(0)
70
+ q, k = MQ.apply_rotary_pos_emb_vision(q, k, cos, sin)
71
+ return q, k, v
72
+
73
+
74
+ def _vit_out(blk, x, o, n: int):
75
+ """attention output projection + residual, then norm2 -> MLP + residual (the dense part after attention)."""
76
+ x = x + blk.attn.proj(o[0].transpose(0, 1).reshape(n, -1))
77
+ return x + blk.mlp(blk.norm2(x))
78
+
79
+
80
+ # changed: the dense per-block parts are looked up here so cpu_compile.enable() can swap in torch.compile'd versions (same ops, same order)
81
+ KERNELS = {"vit_qkv": _vit_qkv, "vit_out": _vit_out}
82
+
83
+
84
+ def _from_cache(m: Model, plan: FramePlan):
85
+ st, kept = plan.state, plan.kept_t
86
+ return st.merged[kept], [st.deepstack[i][kept] for i in range(len(m.deepstack_layers))]
streamembed/gate.py ADDED
@@ -0,0 +1,102 @@
1
+ """The "if changed" capture gate of the paper's capture-interval experiment (policy G5_0.1).
2
+
3
+ A captured screenshot is encoded if it is the first one, or if more than 10 % of its Grid keep 3x3 kept blocks (32x32 RGB) differ by more
4
+ than 32/255 from the last encoded frame, or as a heartbeat 30 s after the last encode.
5
+
6
+ Numpy port of `timing/c2_gate.py`, generalised from a fixed 1280x704 grid to any resolution divisible by 32. One addition to the paper's
7
+ gate: a heartbeat is skipped when no block differs by more than 8/255 at all. An idle screen would otherwise add identical rows every 30 s,
8
+ and near-duplicates crowd the top results.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+ import numpy as np
15
+ from PIL import Image
16
+
17
+ BLOCK = 32
18
+ TAU = 8 # a block is flat if it matches its left or upper neighbour within this
19
+ DIFF = 32 # a block changed if any value differs from the last encoded frame by more than this
20
+ FRACTION = 0.1 # encode if more than this fraction of the kept blocks changed
21
+ HEARTBEAT_S = 30.0
22
+ TILE = 3
23
+ IDLE_DIFF = 8 # below this everywhere, the screen did not change
24
+
25
+
26
+ def blocks(img: Image.Image) -> np.ndarray:
27
+ """(blocks_h, blocks_w, 3 * 32 * 32) uint8 array of an RGB image whose sides are multiples of 32."""
28
+ a = np.asarray(img.convert("RGB"))
29
+ h, w = a.shape[0] // BLOCK, a.shape[1] // BLOCK
30
+ assert a.shape[0] == h * BLOCK and a.shape[1] == w * BLOCK, f"sides must be multiples of {BLOCK}: {a.shape}"
31
+ return a.reshape(h, BLOCK, w, BLOCK, 3).transpose(0, 2, 1, 3, 4).reshape(h, w, -1)
32
+
33
+
34
+ def _max_abs_diff(a: np.ndarray, b: np.ndarray) -> np.ndarray:
35
+ """Per-block max |a - b| on the last axis, without widening the uint8 values."""
36
+ return (np.maximum(a, b) - np.minimum(a, b)).max(-1)
37
+
38
+
39
+ def _components(eq_left: np.ndarray, eq_up: np.ndarray) -> np.ndarray:
40
+ """Connected components of the flat-block graph (min-label propagation, with pointer jumping)."""
41
+ h, w = eq_left.shape
42
+ lab = np.arange(h * w).reshape(h, w)
43
+ while True:
44
+ old = lab.copy()
45
+ m = np.minimum(lab[:, 1:], lab[:, :-1])
46
+ lab[:, 1:] = np.where(eq_left[:, 1:], m, lab[:, 1:])
47
+ lab[:, :-1] = np.where(eq_left[:, 1:], np.minimum(lab[:, :-1], m), lab[:, :-1])
48
+ m = np.minimum(lab[1:], lab[:-1])
49
+ lab[1:] = np.where(eq_up[1:], m, lab[1:])
50
+ lab[:-1] = np.where(eq_up[1:], np.minimum(lab[:-1], m), lab[:-1])
51
+ lab = lab.reshape(-1)
52
+ lab = lab[lab].reshape(h, w)
53
+ if np.array_equal(lab, old):
54
+ return lab
55
+
56
+
57
+ def kept_blocks(b: np.ndarray) -> np.ndarray:
58
+ """Block ids (raster order) kept by spatial pruning with Grid keep: non-flat blocks, plus one block per (flat component, 3x3 tile)."""
59
+ h, w = b.shape[:2]
60
+ eq_left, eq_up = np.zeros((h, w), bool), np.zeros((h, w), bool)
61
+ eq_left[:, 1:] = _max_abs_diff(b[:, 1:], b[:, :-1]) <= TAU
62
+ eq_up[1:, :] = _max_abs_diff(b[1:], b[:-1]) <= TAU
63
+ keep = ~(eq_left | eq_up).reshape(-1)
64
+ if not keep.any():
65
+ keep[0] = True
66
+ rows, cols = np.meshgrid(np.arange(h), np.arange(w), indexing="ij")
67
+ tiles_per_row = -(-w // TILE)
68
+ tile = ((rows // TILE) * tiles_per_row + cols // TILE).reshape(-1)
69
+ key = _components(eq_left, eq_up).reshape(-1) * (-(-h // TILE) * tiles_per_row) + tile
70
+ covered = np.isin(key, np.unique(key[keep]))
71
+ todo = np.flatnonzero(~keep & ~covered)
72
+ if len(todo):
73
+ uniq, inv = np.unique(key[todo], return_inverse=True)
74
+ first = np.full(len(uniq), -1)
75
+ first[inv[::-1]] = todo[::-1]
76
+ keep[first] = True
77
+ return np.flatnonzero(keep)
78
+
79
+
80
+ @dataclass
81
+ class Decision:
82
+ encode: bool
83
+ idle: bool # nothing on screen changed since the last encoded frame
84
+
85
+
86
+ class Gate:
87
+ def __init__(self):
88
+ self.ref: np.ndarray | None = None
89
+ self.last_ts = 0.0
90
+
91
+ def __call__(self, img: Image.Image, ts: float) -> Decision:
92
+ b = blocks(img)
93
+ if self.ref is None or self.ref.shape != b.shape:
94
+ encode, idle = True, False
95
+ else:
96
+ diff = _max_abs_diff(b, self.ref).reshape(-1)
97
+ idle = bool((diff <= IDLE_DIFF).all())
98
+ heartbeat = ts - self.last_ts >= HEARTBEAT_S
99
+ encode = not idle and (heartbeat or float((diff > DIFF)[kept_blocks(b)].mean()) > FRACTION)
100
+ if encode:
101
+ self.ref, self.last_ts = b, ts
102
+ return Decision(encode, idle)
streamembed/index.py ADDED
@@ -0,0 +1,138 @@
1
+ """The index folder: one fp16 vector and one JSON line per stored frame, appended in stream order.
2
+
3
+ index.json encoder name; vectors from different encoders must not share an index
4
+ vectors.f16 2048 fp16 per frame; row i belongs to line i of frames.jsonl
5
+ frames.jsonl {"ts", "path", "app", "title"} per frame
6
+ seen.jsonl [row, ts]: the screen of that row was still on screen at ts (unchanged captures)
7
+ images/ the stored JPEGs
8
+ mu.npy, codes.bin "binary" only: centring vector and 64-B sign codes of the first 512 dimensions
9
+
10
+ Search is exact ("flat"), or for "binary" above BINARY_FROM frames: Hamming top 1,000 over the codes, then exact re-rank (the paper's index).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import threading
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+
20
+ DIM, CODE_DIM, CANDIDATES = 2048, 512, 1000
21
+ BINARY_FROM = 1000
22
+ INDEX_TYPES = ("flat", "binary")
23
+
24
+
25
+ def _codes(v: np.ndarray, mu: np.ndarray) -> np.ndarray:
26
+ t = v[:, :CODE_DIM].astype(np.float32)
27
+ t /= np.linalg.norm(t, axis=1, keepdims=True).clip(1e-9)
28
+ return np.packbits(t >= mu, axis=1)
29
+
30
+
31
+ class Index:
32
+ def __init__(self, root: Path, index_type: str, encoder_name: str):
33
+ if index_type not in INDEX_TYPES:
34
+ raise ValueError(f"Invalid index type: {index_type} not in {list(INDEX_TYPES)}")
35
+ self.root, self.type, self.lock = Path(root), index_type, threading.RLock()
36
+ self.images = self.root / "images"
37
+ self.images.mkdir(parents=True, exist_ok=True)
38
+ self.root.chmod(0o700)
39
+ info = self.root / "index.json"
40
+ if info.exists() and json.loads(info.read_text())["encoder"] != encoder_name:
41
+ raise ValueError(f"{root} was built with encoder {json.loads(info.read_text())['encoder']!r}, not {encoder_name!r}. "
42
+ "Use another index_dir.")
43
+ info.write_text(json.dumps({"encoder": encoder_name}))
44
+ self.vec_path, self.meta_path, self.seen_path = self.root / "vectors.f16", self.root / "frames.jsonl", self.root / "seen.jsonl"
45
+ for p in (self.vec_path, self.meta_path, self.seen_path):
46
+ p.touch()
47
+ self.frames = [json.loads(l) for l in self.meta_path.read_text().splitlines() if l.strip()]
48
+ for m in self.frames:
49
+ m["ts_last"] = m["ts"]
50
+ n = min(len(self.frames), self.vec_path.stat().st_size // (DIM * 2)) # a crash between the two appends leaves one longer
51
+ self._truncate(n)
52
+ for row, ts in (json.loads(l) for l in self.seen_path.read_text().splitlines() if l.strip()):
53
+ if row < n:
54
+ self.frames[row]["ts_last"] = max(self.frames[row]["ts_last"], ts)
55
+ self.mu = self.codes = None
56
+ if self.type == "binary" and n > BINARY_FROM:
57
+ self._load_codes()
58
+
59
+ def __len__(self) -> int:
60
+ return len(self.frames)
61
+
62
+ def _truncate(self, n: int) -> None:
63
+ self.frames = self.frames[:n]
64
+ with open(self.vec_path, "r+b") as f:
65
+ f.truncate(n * DIM * 2)
66
+ self.meta_path.write_text("".join(json.dumps({k: m[k] for k in ("ts", "path", "app", "title")}) + "\n" for m in self.frames))
67
+
68
+ def _rows(self) -> np.ndarray:
69
+ return np.memmap(self.vec_path, np.float16, "r", shape=(len(self.frames), DIM))
70
+
71
+ def _load_codes(self) -> None:
72
+ mu_path, code_path = self.root / "mu.npy", self.root / "codes.bin"
73
+ if not mu_path.exists():
74
+ sample = self._rows()[np.linspace(0, len(self) - 1, min(len(self), 50_000), dtype=np.int64)]
75
+ t = np.asarray(sample[:, :CODE_DIM], np.float32)
76
+ np.save(mu_path, (t / np.linalg.norm(t, axis=1, keepdims=True).clip(1e-9)).mean(0))
77
+ code_path.write_bytes(b"")
78
+ self.mu = np.load(mu_path)
79
+ self.codes = np.fromfile(code_path, np.uint8).reshape(-1, CODE_DIM // 8)[:len(self)]
80
+ if len(self.codes) < len(self): # codes lag after a crash or a "flat" session
81
+ self.codes = np.concatenate([self.codes, _codes(np.asarray(self._rows()[len(self.codes):]), self.mu)])
82
+ self.codes.tofile(code_path)
83
+
84
+ def add(self, vector: np.ndarray, meta: dict) -> int:
85
+ with self.lock:
86
+ v = vector.astype(np.float16).reshape(1, DIM)
87
+ with open(self.vec_path, "ab") as f: # vector first: on reopen, a vector without a line is dropped
88
+ v.tofile(f)
89
+ line = {k: meta[k] for k in ("ts", "path", "app", "title")}
90
+ with open(self.meta_path, "a") as f:
91
+ f.write(json.dumps(line) + "\n")
92
+ self.frames.append({**line, "ts_last": line["ts"]})
93
+ if self.codes is not None:
94
+ c = _codes(v, self.mu)
95
+ self.codes = np.concatenate([self.codes, c])
96
+ with open(self.root / "codes.bin", "ab") as f:
97
+ c.tofile(f)
98
+ elif self.type == "binary" and len(self) > BINARY_FROM:
99
+ self._load_codes()
100
+ return len(self) - 1
101
+
102
+ def still_on_screen(self, ts: float) -> None:
103
+ """The last stored screen is unchanged at ts."""
104
+ with self.lock:
105
+ if self.frames:
106
+ self.frames[-1]["ts_last"] = ts
107
+ with open(self.seen_path, "a") as f:
108
+ f.write(json.dumps([len(self) - 1, ts]) + "\n")
109
+
110
+ def search(self, q: np.ndarray, k: int, dedupe_s: float = 30.0) -> list[tuple[int, float]]:
111
+ """(row, cosine) best first. A match within dedupe_s seconds of a better one is dropped (screens stay up for a while)."""
112
+ with self.lock:
113
+ n = len(self)
114
+ if n == 0:
115
+ return []
116
+ rows, q = self._rows(), q.astype(np.float32)
117
+ if self.codes is None:
118
+ ids = np.arange(n)
119
+ scores = np.concatenate([np.asarray(rows[s:s + 8192], np.float32) @ q for s in range(0, n, 8192)])
120
+ else:
121
+ t = q[:CODE_DIM] / max(float(np.linalg.norm(q[:CODE_DIM])), 1e-9)
122
+ qc = np.packbits(t >= self.mu).view(np.uint64)
123
+ dist = np.bitwise_count(self.codes.view(np.uint64) ^ qc).sum(1)
124
+ ids = np.sort(np.argpartition(dist, min(CANDIDATES, n) - 1)[:CANDIDATES])
125
+ scores = np.asarray(rows[ids], np.float32) @ q
126
+ out, kept = [], []
127
+ for j in np.argsort(-scores):
128
+ ts = self.frames[ids[j]]["ts"]
129
+ if all(abs(ts - t) >= dedupe_s for t in kept):
130
+ kept.append(ts)
131
+ out.append((int(ids[j]), float(scores[j])))
132
+ if len(out) == k:
133
+ break
134
+ return out
135
+
136
+ def around(self, row: int, n: int) -> list[int]:
137
+ """Rows recorded just before and after `row`, including it (stream order is time order)."""
138
+ return list(range(max(0, row - n), min(len(self), row + n + 1)))
streamembed/models.py ADDED
@@ -0,0 +1,90 @@
1
+ """Which model encodes screens and questions. A model is a base model from its own Hugging Face repo, pinned to a commit, plus two
2
+ optional LoRAs (frame adapter, question LoRA) from the StreamEmbed model repo. `models.json` in that repo lists them:
3
+
4
+ {"qwen3-vl-2b": {"base": "Qwen/Qwen3-VL-Embedding-2B", "revision": "<commit>",
5
+ "frame_lora": "qwen3-vl-2b/frame_adapter.safetensors", "query_lora": "qwen3-vl-2b/query_lora.safetensors",
6
+ "scale": 1.0, "architecture": "qwen3-vl", "description": "..."}}
7
+
8
+ The model repo is a Hugging Face repo id, or a local folder with the same layout (set by `configure(models_repo=...)` or
9
+ $STREAMEMBED_MODELS). Base models without LoRAs are built in, so they work without the model repo.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ MODELS_REPO = os.environ.get("STREAMEMBED_MODELS", "dayoon/streamembed")
19
+ DEFAULT_MODEL = "qwen3-vl-2b"
20
+ STREAM_ARCHITECTURES = ("qwen3-vl",) # the stream encoder re-implements the Qwen3-VL forward pass
21
+
22
+ BUILTIN = {
23
+ "qwen3-vl-2b-base": dict(base="Qwen/Qwen3-VL-Embedding-2B", revision="9f2f7e710d6d81056aa5c0a4f04764fec6bb7bda", architecture="qwen3-vl",
24
+ description="Qwen3-VL-Embedding-2B without LoRAs"),
25
+ }
26
+
27
+
28
+ @dataclass
29
+ class ModelSpec:
30
+ name: str
31
+ base: str
32
+ revision: str | None
33
+ frame_lora: Path | None
34
+ query_lora: Path | None
35
+ scale: float
36
+ architecture: str
37
+
38
+ @property
39
+ def weights(self) -> tuple[Path, Path] | None:
40
+ return (self.frame_lora, self.query_lora) if self.frame_lora and self.query_lora else None
41
+
42
+ @property
43
+ def encoder_name(self) -> str:
44
+ """Recorded in each index: vectors from another model or base commit must not share it."""
45
+ return f"{self.name}@{(self.revision or 'main')[:12]}"
46
+
47
+
48
+ def _fetch(repo: str, filename: str) -> Path:
49
+ if Path(repo).expanduser().is_dir():
50
+ p = Path(repo).expanduser() / filename
51
+ if not p.is_file():
52
+ raise FileNotFoundError(f"{p} not found")
53
+ return p
54
+ from huggingface_hub import hf_hub_download
55
+ try:
56
+ return Path(hf_hub_download(repo, filename, local_files_only=True)) # once cached, no network
57
+ except Exception:
58
+ return Path(hf_hub_download(repo, filename))
59
+
60
+
61
+ def _manifest(repo: str) -> dict:
62
+ return json.loads(_fetch(repo, "models.json").read_text())
63
+
64
+
65
+ def available_models(repo: str | None = None) -> list[str]:
66
+ """Model names: the model repo's, then the built-in base models. The repo's are left out if it cannot be reached."""
67
+ try:
68
+ names = list(_manifest(repo or MODELS_REPO))
69
+ except Exception:
70
+ names = []
71
+ return names + [n for n in BUILTIN if n not in names]
72
+
73
+
74
+ def resolve(name: str, repo: str | None = None) -> ModelSpec:
75
+ repo = repo or MODELS_REPO
76
+ if name in BUILTIN:
77
+ entry = BUILTIN[name]
78
+ else:
79
+ try:
80
+ listing = _manifest(repo)
81
+ except Exception as e:
82
+ raise RuntimeError(f"Could not read models.json from {repo!r} ({e}). Built-in models: {list(BUILTIN)}") from e
83
+ if name not in listing:
84
+ raise ValueError(f"Unknown model {name!r}; available: {list(listing) + list(BUILTIN)}")
85
+ entry = listing[name]
86
+ arch = entry.get("architecture", "qwen3-vl")
87
+ if arch not in STREAM_ARCHITECTURES:
88
+ raise NotImplementedError(f"{name}: architecture {arch!r} is not supported yet; the stream encoder supports {STREAM_ARCHITECTURES}")
89
+ loras = [_fetch(repo, entry[k]) if entry.get(k) else None for k in ("frame_lora", "query_lora")]
90
+ return ModelSpec(name, entry["base"], entry.get("revision"), loras[0], loras[1], float(entry.get("scale", 1.0)), arch)