captchakraken 3.2.0 → 4.0.0
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.
- package/AGENTS.md +5 -9
- package/LICENSE +165 -145
- package/NOTICE +4 -4
- package/README.md +10 -11
- package/dist/cli-invocation.d.ts +6 -3
- package/dist/cli-invocation.js +26 -10
- package/dist/humanize.d.ts +37 -1
- package/dist/humanize.js +143 -9
- package/dist/index.d.ts +13 -12
- package/dist/index.js +25 -33
- package/dist/kinds.d.ts +15 -2
- package/dist/kinds.js +12 -4
- package/dist/playwright-types.d.ts +3 -0
- package/dist/selectors.d.ts +96 -11
- package/dist/selectors.js +226 -112
- package/dist/solver.d.ts +93 -13
- package/dist/solver.js +579 -208
- package/dist/types.d.ts +53 -8
- package/dist/verdicts.d.ts +3 -0
- package/dist/verdicts.js +6 -0
- package/package.json +4 -4
- package/python/AGENTS.md +7 -11
- package/python/LICENSE +165 -145
- package/python/NOTICE +4 -4
- package/python/README.md +10 -9
- package/python/pyproject.toml +27 -13
- package/python/src/captchakraken/__init__.py +43 -46
- package/python/src/captchakraken/action_types.py +32 -1
- package/python/src/captchakraken/captchakraken.config.json +416 -0
- package/python/src/captchakraken/cli.py +66 -30
- package/python/src/captchakraken/clipvideo.py +258 -0
- package/python/src/captchakraken/humanize.py +141 -10
- package/python/src/captchakraken/image_processor.py +3 -7
- package/python/src/captchakraken/keyframes.py +96 -14
- package/python/src/captchakraken/kinds.py +14 -2
- package/python/src/captchakraken/models.json +28 -3
- package/python/src/captchakraken/page_solver.py +621 -259
- package/python/src/captchakraken/planner.py +298 -47
- package/python/src/captchakraken/pointer/__init__.py +20 -0
- package/python/src/captchakraken/pointer/detector.py +333 -0
- package/python/src/captchakraken/pointer/elements.py +273 -0
- package/python/src/captchakraken/pointer/heads.py +163 -0
- package/python/src/captchakraken/pointer/layout.py +319 -0
- package/python/src/captchakraken/pointer/marks.py +184 -0
- package/python/src/captchakraken/pointer/server.py +146 -0
- package/python/src/captchakraken/pointer/service.py +553 -0
- package/python/src/captchakraken/pointer/vllm_plugin.py +109 -0
- package/python/src/captchakraken/prompts.py +111 -34
- package/python/src/captchakraken/selectors.py +224 -131
- package/python/src/captchakraken/server_manager.py +18 -0
- package/python/src/captchakraken/solver.py +169 -88
- package/python/src/captchakraken/tool_calls/board_painted.py +11 -6
- package/python/src/captchakraken/tool_calls/fill_tracker.py +152 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +14 -59
- package/python/src/captchakraken/tool_calls/find_grid.py +273 -1191
- package/python/src/captchakraken/tool_calls/slide_tracker.py +1102 -0
- package/python/src/captchakraken/verdicts.py +16 -0
- package/dist/slide-geometry.d.ts +0 -6
- package/dist/slide-geometry.js +0 -38
- package/python/src/captchakraken/tool_calls/track_piece.py +0 -120
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
"""Board in, answer out: the detector, the marks, one vLLM prefill, the decode.
|
|
2
|
+
|
|
3
|
+
svc = PointerService("/models/pointer-0.8b", detector="/models/detector_r18.pt")
|
|
4
|
+
svc.solve(image=PIL.Image) # a still
|
|
5
|
+
svc.solve(frames=[rgb arrays]) # a video, `clipvideo`-cut
|
|
6
|
+
|
|
7
|
+
A still is resized to `layout.board_size`; the detector runs on that, and the
|
|
8
|
+
elements it finds are drawn on it (`marks`). A video's frames are detected one
|
|
9
|
+
by one, linked across the clip (`elements.track`, so an element keeps one number)
|
|
10
|
+
and drawn on every frame. With no detector, or `marked=False`, the board goes
|
|
11
|
+
plain. The prompt text is the same for every board (`layout.chat`). The answer is
|
|
12
|
+
generation 3's shape, plus the 1-based number of the element under each point
|
|
13
|
+
(`elements.ids_for`) when the board was marked, and `checks`: how sure each
|
|
14
|
+
decision was, and whether each point's element is the one the model read off
|
|
15
|
+
the marks (`cross_check`).
|
|
16
|
+
|
|
17
|
+
Needs the `serve` extra (vLLM, torch, torchvision).
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import collections
|
|
22
|
+
import math
|
|
23
|
+
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Dict, List, Optional, Sequence, Union
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
from PIL import Image
|
|
29
|
+
|
|
30
|
+
from .. import clipvideo as CV
|
|
31
|
+
from . import elements as E
|
|
32
|
+
from . import layout as L
|
|
33
|
+
from . import marks as M
|
|
34
|
+
from . import vllm_plugin
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cross_check(ans: Dict, size, els: Sequence[Optional[Dict]]) -> Dict:
|
|
38
|
+
"""Put the element under each point beside the one the model read off the marks.
|
|
39
|
+
|
|
40
|
+
Each `checks` slot gains `under`: the 1-based element its (final) point lands on, or None. `checks["agree"]`
|
|
41
|
+
is whether every slot's two answers name the same element, or None on a board with no elements, where there
|
|
42
|
+
is nothing to read. Two answers decided apart that differ mean the board was misread."""
|
|
43
|
+
checks = ans.get("checks")
|
|
44
|
+
if checks is None:
|
|
45
|
+
return ans
|
|
46
|
+
has_elements = any(e is not None for e in els)
|
|
47
|
+
for s in checks["slots"]:
|
|
48
|
+
at = L.slot_point(ans, s["slot"])
|
|
49
|
+
i = E.element_at(at, size, els) if has_elements and at is not None else None
|
|
50
|
+
s["under"] = None if i is None else i + 1
|
|
51
|
+
checks["agree"] = all(s["under"] == s["element"] for s in checks["slots"]) if has_elements else None
|
|
52
|
+
return ans
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _distinct_clicks(ans: Dict, els: Sequence[Optional[Dict]] = ()) -> Dict:
|
|
56
|
+
"""Drop a click that repeats an earlier one: in the same place, or anywhere in
|
|
57
|
+
the same cell, where the second press deselects the first. Two clicks apart on
|
|
58
|
+
any other element are two answers: a line has two ends to click."""
|
|
59
|
+
pts = ans.get("points")
|
|
60
|
+
if not pts:
|
|
61
|
+
return ans
|
|
62
|
+
ids = ans.get("ids") or [None] * len(pts)
|
|
63
|
+
in_cell = [i is not None and els[i - 1]["class"] == "cell" for i in ids]
|
|
64
|
+
keep = [k for k, p in enumerate(pts)
|
|
65
|
+
if not any((abs(p[0] - q[0]) <= SAME_PLACE and abs(p[1] - q[1]) <= SAME_PLACE)
|
|
66
|
+
or (in_cell[k] and ids[j] == ids[k]) for j, q in enumerate(pts[:k]))]
|
|
67
|
+
if len(keep) == len(pts):
|
|
68
|
+
return ans
|
|
69
|
+
out = {**ans, "points": [pts[k] for k in keep]}
|
|
70
|
+
if "ids" in ans:
|
|
71
|
+
out["ids"] = [ids[k] for k in keep]
|
|
72
|
+
checks = ans.get("checks")
|
|
73
|
+
if checks is not None:
|
|
74
|
+
names = [f"click{k + 1}" for k in keep]
|
|
75
|
+
out["checks"] = {**checks, "slots": [s for s in checks["slots"]
|
|
76
|
+
if not s["slot"].startswith("click") or s["slot"] in names]}
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
#: Two points this close (0-1000) press the same place.
|
|
81
|
+
SAME_PLACE = 25
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _distinct_drags(ans: Dict) -> Dict:
|
|
85
|
+
"""Drop a drag that repeats an earlier one: the piece is already there, so the
|
|
86
|
+
plan does nothing more than its shorter self and must not pass as a new answer."""
|
|
87
|
+
drags = ans.get("drags")
|
|
88
|
+
if not drags or len(drags) < 2:
|
|
89
|
+
return ans
|
|
90
|
+
|
|
91
|
+
def near(p, q):
|
|
92
|
+
return abs(p[0] - q[0]) <= SAME_PLACE and abs(p[1] - q[1]) <= SAME_PLACE
|
|
93
|
+
keep = [k for k, d in enumerate(drags)
|
|
94
|
+
if not any(near(d["from"], e["from"]) and near(d["to"], e["to"]) for e in drags[:k])]
|
|
95
|
+
if len(keep) == len(drags):
|
|
96
|
+
return ans
|
|
97
|
+
out = {**ans, "drags": [drags[k] for k in keep]}
|
|
98
|
+
checks = ans.get("checks")
|
|
99
|
+
if checks is not None:
|
|
100
|
+
# The slots name a drag by its place in the list, so the ones that stay are renumbered.
|
|
101
|
+
ends = {f"drag{k + 1} {end}": f"drag{n + 1} {end}" for n, k in enumerate(keep) for end in ("from", "to")}
|
|
102
|
+
out["checks"] = {**checks, "slots": [{**s, "slot": ends.get(s["slot"], s["slot"])} for s in checks["slots"]
|
|
103
|
+
if not s["slot"].startswith("drag") or s["slot"] in ends]}
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _places(ans: Dict) -> List:
|
|
108
|
+
"""Where performing `ans` presses, in order: an element number where the point
|
|
109
|
+
has one, else the point."""
|
|
110
|
+
out = []
|
|
111
|
+
pts, ids = ans.get("points") or [], ans.get("ids") or []
|
|
112
|
+
out += [ids[k] if k < len(ids) and ids[k] is not None else p for k, p in enumerate(pts)]
|
|
113
|
+
for d in ans.get("drags") or []:
|
|
114
|
+
out += [d.get("from_id") or d["from"], d.get("to_id") or d["to"]]
|
|
115
|
+
for end in ("handle", "press", "from", "to"):
|
|
116
|
+
if ans.get(end) is not None:
|
|
117
|
+
out.append(ans.get(f"{end}_id") or ans[end])
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def same_effect(a: Dict, b: Dict) -> bool:
|
|
122
|
+
"""Would performing `a` do what performing `b` did: the same action, text and
|
|
123
|
+
places, clicks in any order. A point a hair from another, or on the same
|
|
124
|
+
element, is the same place."""
|
|
125
|
+
if a.get("action") != b.get("action") or (a.get("text") or "") != (b.get("text") or ""):
|
|
126
|
+
return False
|
|
127
|
+
pa, pb = _places(a), _places(b)
|
|
128
|
+
if len(pa) != len(pb):
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
def same(x, y):
|
|
132
|
+
if isinstance(x, int) or isinstance(y, int):
|
|
133
|
+
return x == y
|
|
134
|
+
return abs(x[0] - y[0]) <= SAME_PLACE and abs(x[1] - y[1]) <= SAME_PLACE
|
|
135
|
+
if a.get("action") == "click":
|
|
136
|
+
left = list(pb)
|
|
137
|
+
for x in pa:
|
|
138
|
+
k = next((k for k, y in enumerate(left) if same(x, y)), None)
|
|
139
|
+
if k is None:
|
|
140
|
+
return False
|
|
141
|
+
left.pop(k)
|
|
142
|
+
return True
|
|
143
|
+
return all(same(x, y) for x, y in zip(pa, pb))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
#: Cyrillic capitals the Latin alphabet draws identically. The letter detector reads one
|
|
147
|
+
#: box at a time and cannot tell which script a box is in, so readings are compared folded.
|
|
148
|
+
HOMOGLYPHS_FROM, HOMOGLYPHS_TO = "АВЕКМНОРСТУХаеорсух", "ABEKMHOPCTYXaeopcyx"
|
|
149
|
+
HOMOGLYPHS = str.maketrans(HOMOGLYPHS_FROM, HOMOGLYPHS_TO)
|
|
150
|
+
_TO_CYRILLIC = str.maketrans(HOMOGLYPHS_TO, HOMOGLYPHS_FROM)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def fold(text: str) -> str:
|
|
154
|
+
"""`text` as the letter detector can be held to it: lookalike scripts and case merged."""
|
|
155
|
+
return (text or "").translate(HOMOGLYPHS).casefold().translate(HOMOGLYPHS)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
#: Two letter boxes overlapping this much are one letter read twice.
|
|
159
|
+
SAME_LETTER_IOU = 0.4
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _iou(a: Sequence[float], b: Sequence[float]) -> float:
|
|
163
|
+
w = min(a[2], b[2]) - max(a[0], b[0])
|
|
164
|
+
h = min(a[3], b[3]) - max(a[1], b[1])
|
|
165
|
+
if w <= 0 or h <= 0:
|
|
166
|
+
return 0.0
|
|
167
|
+
inter = w * h
|
|
168
|
+
return inter / ((a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def letter_slots(dets: Sequence[Dict]) -> List[Dict]:
|
|
172
|
+
"""The letter detector's boxes -> its letters, left to right: {"box", "score", "chars":
|
|
173
|
+
{folded character: score}}. The detector suppresses overlaps within a class only, so
|
|
174
|
+
one glyph comes back as each lookalike it resembles (A and Cyrillic А, 0 and O): of
|
|
175
|
+
boxes overlapping by SAME_LETTER_IOU, the surest stands for the letter and the rest
|
|
176
|
+
are its other readings."""
|
|
177
|
+
slots: List[Dict] = []
|
|
178
|
+
for d in sorted(dets, key=lambda d: -d["score"]):
|
|
179
|
+
home = next((s for s in slots if _iou(d["box"], s["box"]) >= SAME_LETTER_IOU), None)
|
|
180
|
+
if home is None:
|
|
181
|
+
slots.append({"box": d["box"], "score": d["score"], "char": d["class"], "chars": {}, "show": {}})
|
|
182
|
+
home = slots[-1]
|
|
183
|
+
c = fold(d["class"])
|
|
184
|
+
home["chars"][c] = max(home["chars"].get(c, 0.0), d["score"])
|
|
185
|
+
home["show"].setdefault(c, d["class"])
|
|
186
|
+
return sorted(slots, key=lambda s: s["box"][0] + s["box"][2])
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _script(c: str) -> str:
|
|
190
|
+
if "\u4e00" <= c <= "\u9fff":
|
|
191
|
+
return "han"
|
|
192
|
+
if "\u0400" <= c <= "\u04ff":
|
|
193
|
+
return "cyrillic"
|
|
194
|
+
return "latin" if c.isalpha() else "digit"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def in_board_script(slots: Sequence[Dict], like: str) -> List[Dict]:
|
|
198
|
+
"""`slots` with the readings no letter of the board's script could be: the board is
|
|
199
|
+
in the script most of `like` (the model's text) is, and a 王 on a Latin board is the
|
|
200
|
+
detector's guess at a stroke, not a letter of the code. Cyrillic boards keep Latin
|
|
201
|
+
readings, which `fold` made of their lookalikes. A box left with no reading is still
|
|
202
|
+
a box: `fuse` may count it, but the model names it."""
|
|
203
|
+
seen = collections.Counter(_script(c) for c in like or "" if _script(c) != "digit")
|
|
204
|
+
board = seen.most_common(1)[0][0] if seen else None
|
|
205
|
+
if board is None:
|
|
206
|
+
return list(slots)
|
|
207
|
+
allowed = {board, "digit", *(("latin",) if board == "cyrillic" else ())}
|
|
208
|
+
return [{**s, "chars": {c: p for c, p in s["chars"].items() if _script(c) in allowed}} for s in slots]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def letter_slots_counted(slots: Sequence[Dict]) -> List[Dict]:
|
|
212
|
+
"""The letters of `letter_slots` sure enough to count (LETTER_MIN_SCORE)."""
|
|
213
|
+
return [s for s in slots if s["score"] >= LETTER_MIN_SCORE]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def typed_chars(d: Dict, detokenize, stop_id: int) -> List[Dict]:
|
|
217
|
+
"""The decision's typed characters as `fuse` takes them: each marker's top choice
|
|
218
|
+
and runner-up, {"chars": {folded: p}, "show": {folded: as typed}}."""
|
|
219
|
+
out = []
|
|
220
|
+
for t, p, a, ap in zip(d["text_ids"], d["text_p"], d["text_alt_ids"], d["text_alt_p"]):
|
|
221
|
+
if t == stop_id:
|
|
222
|
+
break
|
|
223
|
+
chars: Dict[str, float] = {}
|
|
224
|
+
show: Dict[str, str] = {}
|
|
225
|
+
for i, q in ((t, p), (a, ap)):
|
|
226
|
+
if i == stop_id:
|
|
227
|
+
continue
|
|
228
|
+
c = detokenize([i])
|
|
229
|
+
chars[fold(c)] = chars.get(fold(c), 0.0) + q
|
|
230
|
+
show.setdefault(fold(c), c)
|
|
231
|
+
out.append({"chars": chars, "show": show})
|
|
232
|
+
return out
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def read_letters(dets: Sequence[Dict]) -> str:
|
|
236
|
+
"""The letter detector's boxes -> the code they spell, left to right.
|
|
237
|
+
|
|
238
|
+
The detector suppresses overlaps within a class only, so one glyph comes back as
|
|
239
|
+
each lookalike it resembles (A and Cyrillic А, P and Р): of boxes overlapping by
|
|
240
|
+
SAME_LETTER_IOU, the surest stands for the letter."""
|
|
241
|
+
return "".join(s["char"] for s in letter_slots_counted(letter_slots(dets)))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
#: A letter box this sure counts as a letter in `read_letters`; fainter ones are left to
|
|
245
|
+
#: `fuse`, which keeps one only when the model typed something there too.
|
|
246
|
+
LETTER_MIN_SCORE = 0.3
|
|
247
|
+
#: The faintest box `fuse` is shown (the detector's own default floor is 0.3).
|
|
248
|
+
FAINTEST_LETTER = 0.05
|
|
249
|
+
#: `fuse`'s odds. A character neither reader proposed is worth MODEL_FLOOR / CNN_FLOOR of
|
|
250
|
+
#: a proposal. A model character with no box under it is a letter the detector missed
|
|
251
|
+
#: (CNN_MISSED) or a marker that landed on a letter already read (MODEL_EXTRA, DOUBLED
|
|
252
|
+
#: when it repeats its neighbour: R99VK for R9VK, the model's usual slip); a box the
|
|
253
|
+
#: model typed nothing for is a letter the model skipped (MODEL_MISSED) or noise.
|
|
254
|
+
MODEL_FLOOR, CNN_FLOOR = 0.2, 0.2
|
|
255
|
+
CNN_MISSED, MODEL_EXTRA, DOUBLED = 0.05, 0.1, 0.3
|
|
256
|
+
MODEL_MISSED = 0.03
|
|
257
|
+
#: Joint readings tried before the model's own answers.
|
|
258
|
+
FUSED_TRIES = 2
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def fuse(model: Sequence[Dict], slots: Sequence[Dict]) -> List[Dict]:
|
|
262
|
+
"""The code both readers support. `model`: the typed characters in order, each
|
|
263
|
+
{"chars": {folded character: p}, "show": {folded: as typed}} (the head's top choice
|
|
264
|
+
and runner-up); `slots`: `letter_slots`. The two are aligned in reading order, and
|
|
265
|
+
every position picks the character with the most evidence from both, so the model
|
|
266
|
+
fixes a box the detector misread and the boxes fix a doubled or wrong model letter.
|
|
267
|
+
|
|
268
|
+
-> the chosen characters, each {"char", "margin"}: margin is the log odds of the
|
|
269
|
+
choice over the runner-up character at that place (inf when there was none), the
|
|
270
|
+
place a retry should change first."""
|
|
271
|
+
lg = math.log
|
|
272
|
+
n, m = len(model), len(slots)
|
|
273
|
+
NEG = -1e18
|
|
274
|
+
best = [[NEG] * (m + 1) for _ in range(n + 1)]
|
|
275
|
+
back: List[List[Optional[tuple]]] = [[None] * (m + 1) for _ in range(n + 1)]
|
|
276
|
+
best[0][0] = 0.0
|
|
277
|
+
|
|
278
|
+
def pick(mc: Dict, cc: Dict):
|
|
279
|
+
cands = sorted(((lg(mc.get(c, 0.0) + MODEL_FLOOR) + lg(cc.get(c, 0.0) + CNN_FLOOR), c)
|
|
280
|
+
for c in {*mc, *cc}), reverse=True)
|
|
281
|
+
if len(cands) == 1:
|
|
282
|
+
return cands[0][0], cands[0][1], None, math.inf
|
|
283
|
+
return cands[0][0], cands[0][1], cands[1][1], cands[0][0] - cands[1][0]
|
|
284
|
+
|
|
285
|
+
def top(chars: Dict):
|
|
286
|
+
if not chars:
|
|
287
|
+
return None, 0.0
|
|
288
|
+
c = max(chars, key=chars.get)
|
|
289
|
+
return c, chars[c]
|
|
290
|
+
for i in range(n + 1):
|
|
291
|
+
for j in range(m + 1):
|
|
292
|
+
here = best[i][j]
|
|
293
|
+
if here == NEG:
|
|
294
|
+
continue
|
|
295
|
+
if i < n and j < m:
|
|
296
|
+
sc, c, alt, margin = pick(model[i]["chars"], slots[j]["chars"])
|
|
297
|
+
step = (i, j, c, alt, margin)
|
|
298
|
+
if here + sc > best[i + 1][j + 1]:
|
|
299
|
+
best[i + 1][j + 1], back[i + 1][j + 1] = here + sc, step
|
|
300
|
+
if i < n:
|
|
301
|
+
c, p = top(model[i]["chars"])
|
|
302
|
+
doubled = any(top(model[k]["chars"])[0] == c for k in (i - 1, i + 1) if 0 <= k < n)
|
|
303
|
+
keep, drop = lg(CNN_MISSED) + lg(p), lg(DOUBLED if doubled else MODEL_EXTRA)
|
|
304
|
+
sc = max(keep, drop)
|
|
305
|
+
if here + sc > best[i + 1][j]:
|
|
306
|
+
best[i + 1][j] = here + sc
|
|
307
|
+
back[i + 1][j] = (i, j, *((c, None) if keep >= drop else (None, c)), abs(keep - drop))
|
|
308
|
+
if j < m:
|
|
309
|
+
c, p = top(slots[j]["chars"])
|
|
310
|
+
keep = lg(MODEL_MISSED) + lg(p) if c is not None else -math.inf
|
|
311
|
+
drop = lg(1.0 - p + 1e-6)
|
|
312
|
+
sc = max(keep, drop)
|
|
313
|
+
if here + sc > best[i][j + 1]:
|
|
314
|
+
best[i][j + 1] = here + sc
|
|
315
|
+
back[i][j + 1] = (i, j, *((c, None) if keep >= drop else (None, c)), abs(keep - drop))
|
|
316
|
+
out: List[Dict] = []
|
|
317
|
+
i, j = n, m
|
|
318
|
+
while (i, j) != (0, 0):
|
|
319
|
+
pi, pj, c, alt, margin = back[i][j]
|
|
320
|
+
|
|
321
|
+
def show(c):
|
|
322
|
+
if c is None:
|
|
323
|
+
return ""
|
|
324
|
+
return (model[pi]["show"].get(c) if pi < i else None) or (slots[pj]["show"].get(c) if pj < j else None) or c
|
|
325
|
+
out.append({"char": show(c), "alt": show(alt), "margin": margin})
|
|
326
|
+
i, j = pi, pj
|
|
327
|
+
return out[::-1]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def fused_texts(model: Sequence[Dict], slots: Sequence[Dict], tries: int = 2) -> List[str]:
|
|
331
|
+
"""`fuse`'s code, then the code with its least sure choice made the other way (the
|
|
332
|
+
runner-up character, or a letter kept or dropped), and so on: `tries` distinct codes,
|
|
333
|
+
written in the board's script (`_in_board_script`; a board whose script no letter
|
|
334
|
+
tells is tried in Latin, then Cyrillic, before any letter changes). With no box
|
|
335
|
+
found, the model's own text alone."""
|
|
336
|
+
if not model:
|
|
337
|
+
return []
|
|
338
|
+
if not slots:
|
|
339
|
+
return _in_board_script("".join(m["show"][max(m["chars"], key=m["chars"].get)] for m in model), slots)[:tries]
|
|
340
|
+
steps = fuse(model, slots)
|
|
341
|
+
out = _in_board_script("".join(s["char"] for s in steps), slots)
|
|
342
|
+
for k in sorted(range(len(steps)), key=lambda k: steps[k]["margin"]):
|
|
343
|
+
if len(out) >= tries or steps[k]["margin"] == math.inf:
|
|
344
|
+
break
|
|
345
|
+
text = _in_board_script("".join(s["alt"] if q == k else s["char"] for q, s in enumerate(steps)), slots)[0]
|
|
346
|
+
if text and text not in out:
|
|
347
|
+
out.append(text)
|
|
348
|
+
return [t for t in out if t][:tries]
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _in_board_script(text: str, slots: Sequence[Dict]) -> List[str]:
|
|
352
|
+
"""`text` with each lookalike letter written in the board's script, a typed Cyrillic
|
|
353
|
+
Р is not the P a Latin board asks for: the script its letters that have no lookalike
|
|
354
|
+
are in, in `text` and in the detector's reading. With none to tell (ЕСКХ), or as
|
|
355
|
+
many each way, Latin, the script nearly every live board is in, then Cyrillic: the
|
|
356
|
+
detector's classes for a lookalike are no guide on real boards (it reads Х in a
|
|
357
|
+
Latin 6X6XXT), and the model types Latin for both."""
|
|
358
|
+
look = set(HOMOGLYPHS_FROM + HOMOGLYPHS_TO)
|
|
359
|
+
seen = collections.Counter(_script(c) for c in text + "".join(s["char"] for s in slots)
|
|
360
|
+
if c not in look and _script(c) in ("latin", "cyrillic"))
|
|
361
|
+
latin, cyrillic = text.translate(HOMOGLYPHS), text.translate(_TO_CYRILLIC)
|
|
362
|
+
if seen["cyrillic"] > seen["latin"]:
|
|
363
|
+
return [cyrillic]
|
|
364
|
+
if seen["latin"] > seen["cyrillic"] or latin == cyrillic:
|
|
365
|
+
return [latin]
|
|
366
|
+
return [latin, cyrillic]
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def read_answers(d: Dict, size, els, detokenize, stop_id: Optional[int],
|
|
370
|
+
timestamps=None, avoid: Sequence[Dict] = (), letters: Optional[Sequence[Dict]] = None,
|
|
371
|
+
top: int = 3) -> List[Dict]:
|
|
372
|
+
"""Unpacked decisions -> the likeliest distinct answers, best first.
|
|
373
|
+
|
|
374
|
+
`avoid`: answers already performed on this board and refused. `top` answers are
|
|
375
|
+
returned, each doing something none of `avoid` and none of the answers before it
|
|
376
|
+
did, so a deterministic model still moves when an answer is refused and never
|
|
377
|
+
goes back onto one already tried. With every variant refused, the heads' own
|
|
378
|
+
answer is the one that comes back.
|
|
379
|
+
|
|
380
|
+
`letters`: the letter detector's letters on a typed board (`letter_slots`). The
|
|
381
|
+
code typed is the one the model and the detector read together (`fused_texts`):
|
|
382
|
+
first their joint reading, then it with its least sure choice made the other way,
|
|
383
|
+
then the model's own `variants`, then the detector's reading. `checks` carries the
|
|
384
|
+
detector's reading and whether it spells what the model read on its own (`fold`ed):
|
|
385
|
+
two readers that agree are almost always right (all 25 such real boards), and the
|
|
386
|
+
fusion matters where they do not."""
|
|
387
|
+
if top < 1:
|
|
388
|
+
raise ValueError("top must be at least 1")
|
|
389
|
+
grid = L.token_grid(size)
|
|
390
|
+
if timestamps is not None and els:
|
|
391
|
+
els = E.on_frame(els, E.moment_frame(d["moment"], len(els[0]["frames"])))
|
|
392
|
+
order = L.variants(d, stop_id)
|
|
393
|
+
reading = model = slots = None
|
|
394
|
+
if letters is not None and stop_id is not None and L._chosen(order[0])[0] == "type":
|
|
395
|
+
chars = typed_chars(d, detokenize, stop_id)
|
|
396
|
+
model = "".join(c["show"][max(c["chars"], key=c["chars"].get)] for c in chars)
|
|
397
|
+
slots = in_board_script(letters, model)
|
|
398
|
+
reading = "".join(s["char"] for s in letter_slots_counted(letters))
|
|
399
|
+
joint = [{**order[0], "typed": t, **({"retry": "joint reading"} if k else {})}
|
|
400
|
+
for k, t in enumerate(fused_texts(chars, slots, FUSED_TRIES))]
|
|
401
|
+
order = [*joint, *order, {**order[0], "typed": _in_board_script(reading, slots)[0],
|
|
402
|
+
"retry": "letter detector's reading"}]
|
|
403
|
+
first = None
|
|
404
|
+
answers: List[Dict] = []
|
|
405
|
+
for v in order:
|
|
406
|
+
ids = v["text_ids"]
|
|
407
|
+
if stop_id is not None and stop_id in ids:
|
|
408
|
+
ids = ids[:ids.index(stop_id)]
|
|
409
|
+
text = v.get("typed")
|
|
410
|
+
if text is None:
|
|
411
|
+
text = detokenize(ids)
|
|
412
|
+
if slots is not None and L._chosen(v)[0] == "type":
|
|
413
|
+
text = _in_board_script(text, slots)[0]
|
|
414
|
+
ans = L.answer(v, grid, text=text, timestamps=timestamps)
|
|
415
|
+
ans = cross_check(_distinct_drags(_distinct_clicks(E.ids_for(ans, size, els), els)), size, els)
|
|
416
|
+
if reading is not None and ans["action"] == "type":
|
|
417
|
+
ans["checks"]["letters"] = reading
|
|
418
|
+
ans["checks"]["letters_agree"] = fold(model) == fold(reading)
|
|
419
|
+
first = first or ans
|
|
420
|
+
if any(same_effect(ans, r) for r in avoid):
|
|
421
|
+
continue
|
|
422
|
+
if any(same_effect(ans, r) for r in answers):
|
|
423
|
+
continue
|
|
424
|
+
answers.append(ans)
|
|
425
|
+
if len(answers) >= top:
|
|
426
|
+
break
|
|
427
|
+
return answers or ([first] if first is not None else [])
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def read_answer(d: Dict, size, els, detokenize, stop_id: Optional[int],
|
|
431
|
+
timestamps=None, avoid: Sequence[Dict] = (), letters: Optional[Sequence[Dict]] = None) -> Dict:
|
|
432
|
+
"""The single likeliest answer; `read_answers` with `top=1`."""
|
|
433
|
+
return read_answers(d, size, els, detokenize, stop_id, timestamps, avoid, letters, top=1)[0]
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class PointerService:
|
|
437
|
+
def __init__(self, checkpoint: Union[str, Path], detector: Union[str, Path, None] = None,
|
|
438
|
+
gpu_memory_utilization: float = 0.45, max_model_len: int = 8192,
|
|
439
|
+
letters: Union[str, Path, None] = None, **llm_kwargs):
|
|
440
|
+
vllm_plugin.register()
|
|
441
|
+
from transformers import AutoProcessor
|
|
442
|
+
from vllm import LLM
|
|
443
|
+
self.processor = AutoProcessor.from_pretrained(str(checkpoint))
|
|
444
|
+
tok = self.processor.tokenizer
|
|
445
|
+
self.stop_id = tok.convert_tokens_to_ids("<|im_end|>")
|
|
446
|
+
# The heads read a hidden state at every prompt token, and vLLM's prefix
|
|
447
|
+
# cache returns only the uncached suffix. It is not an optimisation we
|
|
448
|
+
# may take: a cached prompt is refused by the plugin.
|
|
449
|
+
if llm_kwargs.pop("enable_prefix_caching", False):
|
|
450
|
+
raise ValueError("the pointer heads need every prompt token; prefix caching is not supported")
|
|
451
|
+
self.llm = LLM(model=str(checkpoint), runner="pooling", max_model_len=max_model_len,
|
|
452
|
+
gpu_memory_utilization=gpu_memory_utilization,
|
|
453
|
+
limit_mm_per_prompt={"image": 1 + L.MAX_HISTORY, "video": 1},
|
|
454
|
+
mm_processor_kwargs={"min_pixels": 64 * 64, "max_pixels": L.MAX_PIXELS},
|
|
455
|
+
enable_prefix_caching=False, **llm_kwargs)
|
|
456
|
+
self.detector = None
|
|
457
|
+
if detector is not None:
|
|
458
|
+
from .detector import Detector
|
|
459
|
+
self.detector = Detector(detector)
|
|
460
|
+
#: The letter detector (`scripts/build_glyph_dataset.py` in the Finetune repo): on a board the
|
|
461
|
+
#: model types into, the characters it finds, left to right (`read_answer`'s `letters`).
|
|
462
|
+
self.letters = None
|
|
463
|
+
if letters is not None:
|
|
464
|
+
from .detector import Detector
|
|
465
|
+
self.letters = Detector(letters)
|
|
466
|
+
|
|
467
|
+
# ── one board ────────────────────────────────────────────────────────────
|
|
468
|
+
def _shown(self, image: Image.Image, marked: bool):
|
|
469
|
+
"""(the board at the size the model sees, its numbered elements, the
|
|
470
|
+
picture sent): the same for a screen sent now or again as history."""
|
|
471
|
+
board = image.convert("RGB").resize(L.board_size(*image.size), Image.LANCZOS)
|
|
472
|
+
els = E.number(self.detector.detect(board)) if (marked and self.detector is not None) else []
|
|
473
|
+
return board, els, (M.draw(board, els) if els else board)
|
|
474
|
+
|
|
475
|
+
def prepare(self, image: Optional[Image.Image] = None, frames: Optional[Sequence[np.ndarray]] = None,
|
|
476
|
+
marked: bool = True, history: Sequence[Image.Image] = ()) -> Dict:
|
|
477
|
+
"""The request vLLM gets, and what decoding its answer needs. `history`:
|
|
478
|
+
the puzzle's earlier screens, oldest first, as they were sent (marked the
|
|
479
|
+
same way), so the prompt up to them is the one already cached."""
|
|
480
|
+
if (image is None) == (frames is None):
|
|
481
|
+
raise ValueError("give exactly one of image or frames")
|
|
482
|
+
if frames is not None and history:
|
|
483
|
+
raise ValueError("a video board carries no earlier screens")
|
|
484
|
+
if frames is not None:
|
|
485
|
+
fr = np.stack([np.asarray(f) for f in frames])
|
|
486
|
+
n, h, w = fr.shape[:3]
|
|
487
|
+
if (h, w) != CV.frame_size(h, w, L.MAX_PIXELS):
|
|
488
|
+
raise ValueError(f"frames are {w}x{h}; cut them with clipvideo.clip_video first")
|
|
489
|
+
meta = {"fps": CV.FPS, "frames_indices": list(range(n)), "total_num_frames": n,
|
|
490
|
+
"duration": n / CV.FPS, "video_backend": "opencv", "do_sample_frames": False}
|
|
491
|
+
tracks = []
|
|
492
|
+
if marked and self.detector is not None:
|
|
493
|
+
ims = [Image.fromarray(f) for f in fr]
|
|
494
|
+
tracks = E.track([E.number(self.detector.detect(im)) for im in ims])
|
|
495
|
+
if tracks:
|
|
496
|
+
fr = np.stack([np.asarray(M.draw(im, E.on_frame(tracks, k)))
|
|
497
|
+
for k, im in enumerate(ims)])
|
|
498
|
+
prompt = L.chat(self.processor, True).replace(L.VIDEO_PAD, L.VLLM_VIDEO)
|
|
499
|
+
return {"request": {"prompt": prompt,
|
|
500
|
+
"multi_modal_data": {"video": (fr, meta)},
|
|
501
|
+
"mm_processor_kwargs": {"do_sample_frames": False}},
|
|
502
|
+
"size": (w, h), "elements": tracks, "shown": None,
|
|
503
|
+
"timestamps": CV.pair_timestamps(n)}
|
|
504
|
+
board, els, shown = self._shown(image, marked)
|
|
505
|
+
earlier = [self._shown(h, marked)[2] for h in history]
|
|
506
|
+
return {"request": {"prompt": L.chat(self.processor, False, history=len(earlier)),
|
|
507
|
+
"multi_modal_data": {"image": [*earlier, shown] if earlier else shown}},
|
|
508
|
+
"size": board.size, "elements": els, "shown": shown, "timestamps": None, "board": board}
|
|
509
|
+
|
|
510
|
+
def decode_answers(self, prep: Dict, vector: Sequence[float], avoid: Sequence[Dict] = (),
|
|
511
|
+
top: int = 3) -> List[Dict]:
|
|
512
|
+
v = [float(x) for x in vector]
|
|
513
|
+
per_moment, _moments = v[L.PACKED:L.PACKED + vllm_plugin.TRAILER]
|
|
514
|
+
grid = L.token_grid(prep["size"])
|
|
515
|
+
if int(per_moment) != grid[0] * grid[1]:
|
|
516
|
+
raise ValueError(f"vLLM saw {int(per_moment)} tokens per moment, the board has {grid}: "
|
|
517
|
+
"the processor resized it, so points would be misplaced")
|
|
518
|
+
d = L.unpack(v[:L.PACKED])
|
|
519
|
+
return read_answers(d, prep["size"], prep["elements"], self.processor.tokenizer.decode, self.stop_id,
|
|
520
|
+
prep["timestamps"], avoid, self.count_letters(prep, d), top)
|
|
521
|
+
|
|
522
|
+
def decode(self, prep: Dict, vector: Sequence[float], avoid: Sequence[Dict] = ()) -> Dict:
|
|
523
|
+
"""The single likeliest answer; `decode_answers` with `top=1`."""
|
|
524
|
+
return self.decode_answers(prep, vector, avoid, 1)[0]
|
|
525
|
+
|
|
526
|
+
def count_letters(self, prep: Dict, d: Dict) -> Optional[List[Dict]]:
|
|
527
|
+
"""The letter detector's `letter_slots` on a still the heads would type into; else None."""
|
|
528
|
+
if self.letters is None or prep.get("board") is None:
|
|
529
|
+
return None
|
|
530
|
+
if L.ACTIONS[max(range(len(L.ACTIONS)), key=lambda k: d["action_p"][k])] != "type":
|
|
531
|
+
return None
|
|
532
|
+
return letter_slots(self.letters.detect(prep["board"], min_score=FAINTEST_LETTER))
|
|
533
|
+
|
|
534
|
+
def solve(self, image: Optional[Image.Image] = None, frames: Optional[Sequence[np.ndarray]] = None,
|
|
535
|
+
marked: bool = True, history: Sequence[Image.Image] = (), avoid: Sequence[Dict] = (),
|
|
536
|
+
top: int = 3) -> Dict:
|
|
537
|
+
"""-> {"answer": the likeliest generation-3 dict, "answers": the `top` likeliest
|
|
538
|
+
distinct ones, "elements": numbered elements (board px; a video's are
|
|
539
|
+
`elements.track` tracks), "shown": the image the model saw (None for a video),
|
|
540
|
+
"size": board (W, H)}. `avoid`: see `read_answers`."""
|
|
541
|
+
return self.solve_many([{"image": image, "frames": frames, "marked": marked, "history": history,
|
|
542
|
+
"avoid": avoid}], top=top)[0]
|
|
543
|
+
|
|
544
|
+
def solve_many(self, boards: List[Dict], top: int = 3) -> List[Dict]:
|
|
545
|
+
preps = [self.prepare(b.get("image"), b.get("frames"), b.get("marked", True), b.get("history", ()))
|
|
546
|
+
for b in boards]
|
|
547
|
+
outs = self.llm.embed([p["request"] for p in preps], use_tqdm=False)
|
|
548
|
+
results = []
|
|
549
|
+
for b, p, o in zip(boards, preps, outs):
|
|
550
|
+
answers = self.decode_answers(p, o.outputs.embedding, b.get("avoid", ()), top)
|
|
551
|
+
results.append({"answer": answers[0], "answers": answers, "elements": p["elements"],
|
|
552
|
+
"shown": p["shown"], "size": p["size"]})
|
|
553
|
+
return results
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""The pointer model inside vLLM: one prefill, the heads in the pooler, no decoding.
|
|
2
|
+
|
|
3
|
+
Registered as a vLLM general plugin (`pyproject.toml`'s `vllm.general_plugins`
|
|
4
|
+
entry point), so `vllm serve <checkpoint> --runner pooling` and
|
|
5
|
+
`vllm.LLM(<checkpoint>, runner="pooling")` find it by the architecture name an
|
|
6
|
+
exported checkpoint declares, `CaptchaPointerForConditionalGeneration`.
|
|
7
|
+
|
|
8
|
+
The model is vLLM's own Qwen3.5 with one extra module, `pointer_heads` (the
|
|
9
|
+
client's `heads.Heads`, loaded from the checkpoint's `pointer_heads.*` weights).
|
|
10
|
+
Its pooler gathers each request's last hidden states across chunked prefill
|
|
11
|
+
(vLLM's `AllPool`), finds the positions it reads from the prompt's token ids,
|
|
12
|
+
and returns the packed decision vector `layout.unpack` reads. Nothing is
|
|
13
|
+
sampled and no token is generated.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Set
|
|
18
|
+
|
|
19
|
+
ARCHITECTURE = "CaptchaPointerForConditionalGeneration"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def register() -> None:
|
|
23
|
+
from vllm import ModelRegistry
|
|
24
|
+
if ARCHITECTURE not in ModelRegistry.get_supported_archs():
|
|
25
|
+
ModelRegistry.register_model(ARCHITECTURE, "captchakraken.pointer.vllm_plugin:PointerQwen3_5")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _model_class():
|
|
29
|
+
import torch
|
|
30
|
+
from vllm.model_executor.layers.pooler.abstract import Pooler
|
|
31
|
+
from vllm.model_executor.layers.pooler.common import PoolingParamsUpdate
|
|
32
|
+
from vllm.model_executor.layers.pooler.tokwise.methods import AllPool
|
|
33
|
+
from vllm.model_executor.models.interfaces_base import default_pooling_type
|
|
34
|
+
from vllm.model_executor.models.qwen3_5 import Qwen3_5ForConditionalGeneration, Qwen3_5ProcessingInfo
|
|
35
|
+
from vllm.model_executor.models.qwen3_vl import Qwen3VLDummyInputsBuilder, Qwen3VLMultiModalProcessor
|
|
36
|
+
from vllm.multimodal import MULTIMODAL_REGISTRY
|
|
37
|
+
|
|
38
|
+
from . import layout as L
|
|
39
|
+
from .heads import Heads, Positions
|
|
40
|
+
|
|
41
|
+
class PointerPooler(Pooler):
|
|
42
|
+
def __init__(self, model):
|
|
43
|
+
super().__init__()
|
|
44
|
+
self.gather = AllPool()
|
|
45
|
+
# Not a submodule: the model owns the heads and the output matrix.
|
|
46
|
+
self.__dict__["model"] = model
|
|
47
|
+
|
|
48
|
+
def get_supported_tasks(self) -> Set:
|
|
49
|
+
return {"embed"}
|
|
50
|
+
|
|
51
|
+
def get_pooling_updates(self, task):
|
|
52
|
+
return PoolingParamsUpdate(requires_token_ids=True)
|
|
53
|
+
|
|
54
|
+
def forward(self, hidden_states, pooling_metadata):
|
|
55
|
+
m = self.model
|
|
56
|
+
out = []
|
|
57
|
+
for h, ids in zip(self.gather(hidden_states, pooling_metadata),
|
|
58
|
+
pooling_metadata.get_prompt_token_ids_cpu()):
|
|
59
|
+
if h is None: # a prefill chunk; the request is not finished yet
|
|
60
|
+
out.append(None)
|
|
61
|
+
continue
|
|
62
|
+
if not bool(ids.any()):
|
|
63
|
+
# vLLM's start-up profile run: all-zero dummy tokens, no prompt. It
|
|
64
|
+
# sizes memory from this output, so it gets one of the real size.
|
|
65
|
+
# A real prompt without its markers still fails in Positions.
|
|
66
|
+
out.append(torch.zeros(L.PACKED + TRAILER, device=h.device))
|
|
67
|
+
continue
|
|
68
|
+
if h.shape[0] != ids.shape[0]:
|
|
69
|
+
raise RuntimeError(f"{h.shape[0]} hidden states for a {ids.shape[0]}-token prompt: "
|
|
70
|
+
"the heads need them all (is prefix caching on?)")
|
|
71
|
+
pos = Positions(ids, m.pointer_token_ids)
|
|
72
|
+
v = m.pointer_heads.decide(h, pos, m.language_model.lm_head.weight)
|
|
73
|
+
out.append(torch.cat([v, torch.tensor([float(pos.per_moment), float(pos.n_moments)],
|
|
74
|
+
device=v.device)]))
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
@default_pooling_type(seq_pooling_type="ALL", tok_pooling_type="ALL")
|
|
78
|
+
@MULTIMODAL_REGISTRY.register_processor(Qwen3VLMultiModalProcessor, info=Qwen3_5ProcessingInfo,
|
|
79
|
+
dummy_inputs=Qwen3VLDummyInputsBuilder)
|
|
80
|
+
class PointerQwen3_5(Qwen3_5ForConditionalGeneration):
|
|
81
|
+
is_pooling_model = True
|
|
82
|
+
|
|
83
|
+
def __init__(self, *, vllm_config, prefix: str = "model"):
|
|
84
|
+
super().__init__(vllm_config=vllm_config, prefix=prefix)
|
|
85
|
+
cfg = vllm_config.model_config.hf_config
|
|
86
|
+
meta = cfg.pointer
|
|
87
|
+
if int(meta["packed"]) != L.PACKED:
|
|
88
|
+
raise ValueError(f"checkpoint packs {meta['packed']} values, this client {L.PACKED}: "
|
|
89
|
+
"the checkpoint was exported by a different pointer layout")
|
|
90
|
+
self.pointer_token_ids = {k: int(v) for k, v in meta["token_ids"].items()}
|
|
91
|
+
self.pointer_heads = Heads(cfg.text_config.hidden_size).float()
|
|
92
|
+
self.pooler = PointerPooler(self)
|
|
93
|
+
|
|
94
|
+
return PointerQwen3_5
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def __getattr__(name):
|
|
98
|
+
# vLLM resolves "module:PointerQwen3_5" lazily; build the class on first use so
|
|
99
|
+
# importing this module (for `register`) never imports vLLM's model code.
|
|
100
|
+
if name == "PointerQwen3_5":
|
|
101
|
+
cls = _model_class()
|
|
102
|
+
globals()[name] = cls
|
|
103
|
+
return cls
|
|
104
|
+
raise AttributeError(name)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
#: Values the pooler appends after the layout's packed vector: vision tokens per
|
|
108
|
+
#: moment, and moments. The caller checks them against the board it sent.
|
|
109
|
+
TRAILER = 2
|