captchakraken 3.2.1 → 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.
Files changed (60) hide show
  1. package/AGENTS.md +5 -9
  2. package/LICENSE +165 -145
  3. package/NOTICE +4 -4
  4. package/README.md +10 -11
  5. package/dist/cli-invocation.d.ts +6 -3
  6. package/dist/cli-invocation.js +26 -10
  7. package/dist/humanize.d.ts +37 -1
  8. package/dist/humanize.js +143 -9
  9. package/dist/index.d.ts +13 -12
  10. package/dist/index.js +25 -33
  11. package/dist/kinds.d.ts +15 -2
  12. package/dist/kinds.js +12 -4
  13. package/dist/playwright-types.d.ts +3 -0
  14. package/dist/selectors.d.ts +93 -11
  15. package/dist/selectors.js +226 -116
  16. package/dist/solver.d.ts +92 -12
  17. package/dist/solver.js +562 -203
  18. package/dist/types.d.ts +53 -8
  19. package/dist/verdicts.d.ts +3 -0
  20. package/dist/verdicts.js +6 -0
  21. package/package.json +4 -4
  22. package/python/AGENTS.md +7 -11
  23. package/python/LICENSE +165 -145
  24. package/python/NOTICE +4 -4
  25. package/python/README.md +10 -9
  26. package/python/pyproject.toml +27 -13
  27. package/python/src/captchakraken/__init__.py +43 -46
  28. package/python/src/captchakraken/action_types.py +32 -1
  29. package/python/src/captchakraken/captchakraken.config.json +416 -0
  30. package/python/src/captchakraken/cli.py +66 -30
  31. package/python/src/captchakraken/clipvideo.py +258 -0
  32. package/python/src/captchakraken/humanize.py +141 -10
  33. package/python/src/captchakraken/image_processor.py +3 -7
  34. package/python/src/captchakraken/keyframes.py +96 -14
  35. package/python/src/captchakraken/kinds.py +14 -2
  36. package/python/src/captchakraken/models.json +28 -3
  37. package/python/src/captchakraken/page_solver.py +595 -251
  38. package/python/src/captchakraken/planner.py +298 -47
  39. package/python/src/captchakraken/pointer/__init__.py +20 -0
  40. package/python/src/captchakraken/pointer/detector.py +333 -0
  41. package/python/src/captchakraken/pointer/elements.py +273 -0
  42. package/python/src/captchakraken/pointer/heads.py +163 -0
  43. package/python/src/captchakraken/pointer/layout.py +319 -0
  44. package/python/src/captchakraken/pointer/marks.py +184 -0
  45. package/python/src/captchakraken/pointer/server.py +146 -0
  46. package/python/src/captchakraken/pointer/service.py +553 -0
  47. package/python/src/captchakraken/pointer/vllm_plugin.py +109 -0
  48. package/python/src/captchakraken/prompts.py +111 -34
  49. package/python/src/captchakraken/selectors.py +221 -135
  50. package/python/src/captchakraken/server_manager.py +18 -0
  51. package/python/src/captchakraken/solver.py +169 -88
  52. package/python/src/captchakraken/tool_calls/board_painted.py +11 -6
  53. package/python/src/captchakraken/tool_calls/fill_tracker.py +152 -0
  54. package/python/src/captchakraken/tool_calls/find_checkbox.py +14 -59
  55. package/python/src/captchakraken/tool_calls/find_grid.py +273 -1191
  56. package/python/src/captchakraken/tool_calls/slide_tracker.py +1102 -0
  57. package/python/src/captchakraken/verdicts.py +16 -0
  58. package/dist/slide-geometry.d.ts +0 -6
  59. package/dist/slide-geometry.js +0 -38
  60. 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