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
|
@@ -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)
|