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,163 @@
1
+ """The pointer heads: hidden states of one prefill in, decisions out.
2
+
3
+ Shared by training (the Finetune repo's `src/pointer/model.py`, on transformers
4
+ hidden states) and serving (`vllm_plugin`, inside vLLM's pooler), so the two
5
+ read the same positions with the same arithmetic.
6
+
7
+ Option vectors, all from the last hidden layer:
8
+ token each vision token: where a point can land
9
+ moment each `<|vision_end|>`: one per frame pair of a video (one for a still)
10
+ and one query vector per question, at its `<|quad_end|>`. The element a point
11
+ names is the one under the point (`elements.ids_for`). Each slot ALSO reads the
12
+ number drawn on its element, 1..MAX_ELEMENTS or 0 for none, from its query alone:
13
+ a second answer to "which element", decided apart from the point, so the two can
14
+ check each other. On a plain board there are no numbers and it answers 0.
15
+
16
+ Each point also carries its runner-up tokens and each typed character its
17
+ runner-up, with their probabilities (`layout.variants`): a retry after a refused
18
+ answer takes the next-likeliest one instead of asking the same question again.
19
+
20
+ A point is a softmax over the chosen moment's tokens plus an offset inside the
21
+ winning token, so a coordinate is never decoded digit by digit. The offset head
22
+ normalises its input and answers linearly, clamped to the token only when
23
+ deciding: the hidden states it reads are large, and behind a final tanh a single
24
+ optimiser step could saturate it for good, leaving every point at its token's
25
+ centre. A typed answer
26
+ reads each `textK` marker through the language model's own head, one token per
27
+ marker, in parallel.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import math
32
+ from typing import Dict, List, Optional
33
+
34
+ import torch
35
+ import torch.nn as nn
36
+ import torch.nn.functional as F
37
+
38
+ from . import layout as L
39
+
40
+
41
+ class Positions:
42
+ """Where each kind of state sits in one prompt, from its token ids."""
43
+
44
+ def __init__(self, ids: torch.Tensor, token_ids: Dict[str, int]):
45
+ ids = ids.flatten()
46
+ self.q = (ids == token_ids[L.Q_END]).nonzero().flatten()
47
+ vis = (ids == token_ids[L.IMAGE_PAD]) | (ids == token_ids[L.VIDEO_PAD])
48
+ self.vis = vis.nonzero().flatten()
49
+ # A moment ends where its frame pair's tokens do. The chat template wraps a
50
+ # whole video in one more vision_start/end pair, whose end follows another
51
+ # end, not a vision token, and so is not a moment.
52
+ after_vis = torch.zeros_like(vis)
53
+ after_vis[1:] = vis[:-1]
54
+ self.moments = ((ids == token_ids[L.VISION_END]) & after_vis).nonzero().flatten()
55
+ # Earlier screens (`layout.chat`'s history) are images before the board:
56
+ # context the questions see, never a place a point lands. Only the last
57
+ # image is the board.
58
+ self.history = 0
59
+ if (ids == token_ids[L.IMAGE_PAD]).any() and self.moments.numel() > 1:
60
+ if (ids == token_ids[L.VIDEO_PAD]).any():
61
+ raise ValueError("a video board carries no earlier screens")
62
+ self.history = int(self.moments.numel()) - 1
63
+ self.vis = self.vis[self.vis > self.moments[-2]]
64
+ self.moments = self.moments[-1:]
65
+ if self.q.numel() != len(L.QUESTIONS):
66
+ raise ValueError(f"{self.q.numel()} question markers, expected {len(L.QUESTIONS)}")
67
+ n_m = max(int(self.moments.numel()), 1)
68
+ if self.vis.numel() == 0 or self.vis.numel() % n_m:
69
+ raise ValueError(f"{self.vis.numel()} vision tokens do not split into {n_m} moments")
70
+ self.per_moment = self.vis.numel() // n_m
71
+ self.n_moments = n_m
72
+
73
+
74
+ def token_ids(tokenizer) -> Dict[str, int]:
75
+ return {t: tokenizer.convert_tokens_to_ids(t)
76
+ for t in (L.Q_END, L.IMAGE_PAD, L.VIDEO_PAD, L.VISION_END)}
77
+
78
+
79
+ class Heads(nn.Module):
80
+ def __init__(self, d: int, dp: int = 256, text_rank: int = 256):
81
+ super().__init__()
82
+ self.action = nn.Linear(d, len(L.ACTIONS))
83
+ self.count = nn.Linear(d, L.N_COUNTS)
84
+ self.drag_count = nn.Linear(d, L.N_DRAG_COUNTS)
85
+ self.pq = nn.ModuleDict({k: nn.Linear(d, dp) for k in L.KINDS})
86
+ self.pk = nn.Linear(d, dp)
87
+ self.mq, self.mk = nn.Linear(d, dp), nn.Linear(d, dp)
88
+ self.offset = nn.Sequential(nn.LayerNorm(2 * d), nn.Linear(2 * d, 512), nn.GELU(), nn.Linear(512, 2))
89
+ self.elem = nn.ModuleDict({k: nn.Sequential(nn.LayerNorm(d), nn.Linear(d, L.MAX_ELEMENTS + 1))
90
+ for k in L.KINDS})
91
+ # A low-rank nudge before the language model's head, zero at the start, so
92
+ # a `textK` marker begins as the model's own next-token guess.
93
+ self.text_down = nn.Linear(d, text_rank, bias=False)
94
+ self.text_up = nn.Linear(text_rank, d, bias=False)
95
+ nn.init.zeros_(self.text_up.weight)
96
+ self.scale = 1 / math.sqrt(dp)
97
+
98
+ # ── scores (training reads these; `decide` takes their argmax) ───────────
99
+ def point(self, kind: str, hq, htok): # [d], [P,d] -> [P]
100
+ return (self.pk(htok) @ self.pq[kind](hq)) * self.scale
101
+
102
+ def moment(self, hq, hm): # [d], [M,d] -> [M]
103
+ return (self.mk(hm) @ self.mq(hq)) * self.scale
104
+
105
+ def off(self, hq, htok): # [d], [d] -> [2], in tokens, unclamped
106
+ return self.offset(torch.cat([hq, htok], -1))
107
+
108
+ def element(self, kind: str, hq): # [d] -> [MAX_ELEMENTS + 1]; 0 is none
109
+ return self.elem[kind](hq)
110
+
111
+ def text(self, hq, lm_weight: torch.Tensor): # [T,d], [V,d] -> [T,V]
112
+ z = hq.float() + self.text_up(self.text_down(hq.float()))
113
+ return z.to(lm_weight.dtype) @ lm_weight.T
114
+
115
+ # ── reading one prompt ───────────────────────────────────────────────────
116
+ def views(self, h: torch.Tensor, pos: Positions) -> Dict:
117
+ """h [N,d] (float32) -> the option and query vectors of one prompt."""
118
+ hm = h[pos.moments] if pos.moments.numel() else h[pos.vis].mean(0, keepdim=True)
119
+ return {"q": dict(zip(L.QUESTIONS, h[pos.q])),
120
+ "tok": h[pos.vis].reshape(pos.n_moments, pos.per_moment, -1), "moment": hm}
121
+
122
+ @torch.no_grad()
123
+ def decide(self, h: torch.Tensor, pos: Positions, lm_weight: Optional[torch.Tensor]) -> torch.Tensor:
124
+ """h [N,d] -> the packed decision vector (`layout.unpack` reads it).
125
+ `lm_weight` is the language model's output matrix [V,d], for typed text."""
126
+ v = self.views(h.float(), pos)
127
+ q = v["q"]
128
+ parts: List[torch.Tensor] = [
129
+ F.softmax(self.action(q["action"]), -1),
130
+ F.softmax(self.count(q["count"]), -1),
131
+ F.softmax(self.drag_count(q["count"]), -1),
132
+ ]
133
+ pm = F.softmax(self.moment(q["moment"], v["moment"]), -1)
134
+ m = int(pm.argmax())
135
+ tok = v["tok"][m]
136
+ alts: List[torch.Tensor] = []
137
+ for name, kind in L.SLOTS:
138
+ pp = F.softmax(self.point(kind, q[name], tok), -1)
139
+ top = pp.topk(min(1 + L.ALTS, pp.numel())).indices.tolist()
140
+ t = top[0]
141
+ dx, dy = self.off(q[name], tok[t]).clamp(-0.5, 0.5)
142
+ pe = F.softmax(self.element(kind, q[name]), -1)
143
+ e = int(pe.argmax())
144
+ parts.append(torch.stack([torch.tensor(float(t), device=h.device), dx, dy, pp[t],
145
+ torch.tensor(float(e), device=h.device), pe[e]]))
146
+ for k in range(1, 1 + L.ALTS):
147
+ if k < len(top):
148
+ a = top[k]
149
+ adx, ady = self.off(q[name], tok[a]).clamp(-0.5, 0.5)
150
+ alts.append(torch.stack([torch.tensor(float(a), device=h.device), adx, ady, pp[a]]))
151
+ else: # a board of one token has no runner-up: p 0 is never chosen
152
+ alts.append(torch.tensor([float(t), 0.0, 0.0, 0.0], device=h.device))
153
+ if lm_weight is not None:
154
+ hq = torch.stack([q[n] for n in L.TEXT])
155
+ pt, it = F.softmax(self.text(hq, lm_weight).float(), -1).topk(2, -1)
156
+ ids, text_alts = it[:, 0].float(), [pt[:, 0], it[:, 1].float(), pt[:, 1]]
157
+ else:
158
+ ids = torch.full((L.MAX_TEXT,), -1.0, device=h.device)
159
+ text_alts = [torch.zeros(L.MAX_TEXT, device=h.device), ids, torch.zeros(L.MAX_TEXT, device=h.device)]
160
+ parts += [ids, torch.tensor([float(m), float(pm[m])], device=h.device), *alts, *text_alts]
161
+ out = torch.cat([p.float().flatten() for p in parts])
162
+ assert out.numel() == L.PACKED, (out.numel(), L.PACKED)
163
+ return out
@@ -0,0 +1,319 @@
1
+ """What the pointer model is asked, and how its answer comes back.
2
+
3
+ The prompt is the SAME text for every captcha, with the board between its two
4
+ halves:
5
+
6
+ <|im_start|>user
7
+ Solve the captcha. ... PREFIX: identical on every request
8
+ <|vision_start|>[image | video]<|vision_end|>
9
+ action<|quad_end|> count<|quad_end|> click1<|quad_end|> ... moment<|quad_end|>
10
+
11
+ Everything up to the board is one cached prefix. The question markers have to
12
+ come AFTER the board: a causal model's state at a marker can only see what is
13
+ before it. They are fixed text too, so the board is the only thing a request
14
+ changes. Nothing about a board's elements is written in the prompt; a marked
15
+ board carries them as drawn outlines and numbers (`marks`), and the element a
16
+ point names is the one under it (`elements.ids_for`).
17
+
18
+ The heads read the hidden state at every `<|quad_end|>` (one per question), at
19
+ every vision token (the places a point can land) and at every `<|vision_end|>`
20
+ (one per moment of a video). All of those are found from the token ids alone, so
21
+ the same code reads them in training (transformers) and in serving (vLLM's
22
+ pooler).
23
+
24
+ The heads' decisions travel as one flat float vector (`pack` / `unpack`): a
25
+ pooling endpoint returns a tensor, not JSON. A point comes back as a vision
26
+ token index plus an offset inside it; `to_point` turns it into 0-1000 with the
27
+ board's token grid, which only the caller knows.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import copy
32
+ import math
33
+ from typing import Dict, List, Optional, Sequence, Tuple
34
+
35
+ from ..clipvideo import frame_size
36
+
37
+ ACTIONS = ("click", "drag", "slide", "hold", "type", "refresh")
38
+ MAX_CLICKS = 16
39
+ MAX_DRAGS = 4
40
+ MAX_TEXT = 16
41
+ MAX_ELEMENTS = 48
42
+ #: Earlier screens of the same puzzle a request may carry before the board
43
+ #: (`chat`'s `history`): FunCaptcha's pager shows 7 options and a solve may page
44
+ #: back, so this covers a solve that looks at each option once and returns.
45
+ MAX_HISTORY = 8
46
+ #: A still is resized to this many pixels at most, on the same 64 px lattice a
47
+ #: video frame is (`clipvideo.frame_size`), so neither processor resizes it again
48
+ #: and a token is always the same 32 px square of the board.
49
+ MAX_PIXELS = 720 * 720
50
+ #: Pixels per vision token: Qwen3.5's 16 px patch, merged 2x2.
51
+ TOKEN_PX = 32
52
+
53
+ Q_END = "<|quad_end|>"
54
+ IMAGE_PAD = "<|image_pad|>"
55
+ VIDEO_PAD = "<|video_pad|>"
56
+ VISION_END = "<|vision_end|>"
57
+ #: What vLLM's video processor searches the prompt for. It replaces this whole
58
+ #: span with the per-pair expansion, so a request that carries only `VIDEO_PAD`
59
+ #: gives it nothing to find (`Failed to apply prompt replacement`). Training's
60
+ #: transformers processor instead wraps `chat`'s bare pad in the outer pair
61
+ #: itself, which is why `chat` stays bare and only the serving request is wrapped.
62
+ VLLM_VIDEO = "<|vision_start|><|video_pad|><|vision_end|>"
63
+
64
+ #: (question, kind). A kind has its own query projections: what a click names
65
+ #: differs from what a drag picks up (`pick`), where it lets go (`drop`), and what
66
+ #: a slider presses. A slide's `from` is picked up and its `to` dropped on; a
67
+ #: hold presses like a handle, and its fill runs from `from` to where it is let go.
68
+ SLOTS: List[Tuple[str, str]] = (
69
+ [(f"click{k}", "click") for k in range(1, MAX_CLICKS + 1)]
70
+ + [(f"drag{k} {end}", kind) for k in range(1, MAX_DRAGS + 1)
71
+ for end, kind in (("from", "pick"), ("to", "drop"))]
72
+ + [("slide handle", "handle"), ("slide from", "pick"), ("slide to", "drop")]
73
+ + [("hold press", "handle"), ("hold from", "pick"), ("hold to", "drop")]
74
+ )
75
+ KINDS = ("click", "pick", "drop", "handle")
76
+ TEXT = [f"text{k}" for k in range(1, MAX_TEXT + 1)]
77
+ QUESTIONS: List[str] = ["action", "count"] + [s for s, _ in SLOTS] + TEXT + ["moment"]
78
+
79
+ INSTRUCTION = (
80
+ "Solve the captcha. Read its instruction and decide what a person would do. "
81
+ "Thin numbered outlines, where drawn, are ours and not part of the puzzle."
82
+ )
83
+ #: The fixed text after the board: one marker per question.
84
+ QUESTION_TEXT = " ".join(f"{q}{Q_END}" for q in QUESTIONS)
85
+
86
+ # ── the packed decision vector ──────────────────────────────────────────────
87
+ #: Per slot: vision token index (within the chosen moment), dx, dy (offset from
88
+ #: the token's centre, in tokens), p(token), the element number read off the
89
+ #: marks (0 for none), p(element).
90
+ SLOT_WIDTH = 6
91
+ N_COUNTS = MAX_CLICKS + 1 # clicks 0..16
92
+ N_DRAG_COUNTS = MAX_DRAGS # drags 1..4
93
+ #: Runner-up places per slot, after the decisions: each slot's next-likeliest
94
+ #: tokens (index, dx, dy, p), and each `textK` marker's p(token) with its
95
+ #: runner-up token and that one's p. A retry reads them (`variants`) when the
96
+ #: widget refused the first answer, so it never has to ask the same thing twice.
97
+ ALTS = 2
98
+ ALT_WIDTH = 4
99
+ PACKED = (len(ACTIONS) + N_COUNTS + N_DRAG_COUNTS + SLOT_WIDTH * len(SLOTS)
100
+ + MAX_TEXT + 2 + ALTS * ALT_WIDTH * len(SLOTS) + 3 * MAX_TEXT)
101
+
102
+
103
+ def board_size(width: int, height: int) -> Tuple[int, int]:
104
+ """(W, H) a still is shown at."""
105
+ h, w = frame_size(height, width, MAX_PIXELS)
106
+ return w, h
107
+
108
+
109
+ def token_grid(size: Tuple[int, int]) -> Tuple[int, int]:
110
+ """(columns, rows) of vision tokens on a board of `size` = board_size(...)."""
111
+ return size[0] // TOKEN_PX, size[1] // TOKEN_PX
112
+
113
+
114
+ def to_norm(x: float, y: float, size: Tuple[int, int]) -> List[int]:
115
+ return [int(round(x * 1000.0 / size[0])), int(round(y * 1000.0 / size[1]))]
116
+
117
+
118
+ def chat(processor, video: bool, history: int = 0) -> str:
119
+ """The whole prompt string, as training builds it and serving sends it: the
120
+ same for every board (see the module docstring).
121
+
122
+ `history`: how many earlier screens of the same puzzle come before the board,
123
+ oldest first, each an image exactly as it was sent then. A solve's next
124
+ request is then this one up to its board plus one more screen, so the cached
125
+ prefix covers every screen already seen; the questions after the last image
126
+ see them all, and points land on the last only (`heads.Positions`). A video
127
+ is one look and has none.
128
+
129
+ This is the TRAINING form: transformers' processor wraps the bare pad in the
130
+ outer `<|vision_start|>...<|vision_end|>` pair itself. A vLLM request must
131
+ instead carry `VLLM_VIDEO`, because vLLM searches the prompt for that whole
132
+ span and replaces it with its per-pair expansion."""
133
+ if video and history:
134
+ raise ValueError("a video board carries no earlier screens")
135
+ if not 0 <= history <= MAX_HISTORY:
136
+ raise ValueError(f"{history} earlier screens; at most {MAX_HISTORY}")
137
+ media = {"type": "video"} if video else {"type": "image"}
138
+ msgs = [{"role": "user", "content": [{"type": "text", "text": INSTRUCTION},
139
+ *({"type": "image"} for _ in range(history)), media,
140
+ {"type": "text", "text": QUESTION_TEXT}]}]
141
+ text = processor.apply_chat_template(msgs, tokenize=False, add_generation_prompt=False)
142
+ return text.replace("<|vision_start|><|video_pad|><|vision_end|>", VIDEO_PAD)
143
+
144
+
145
+ def unpack(v: Sequence[float]) -> Dict:
146
+ """The heads' flat vector -> {"action_p", "count_p", "drag_count_p", "slots":
147
+ {name: {...}}, "text_ids", "moment", "moment_p"}."""
148
+ v = [float(x) for x in v]
149
+ if len(v) != PACKED:
150
+ raise ValueError(f"decision vector has {len(v)} values, expected {PACKED}")
151
+ i = 0
152
+
153
+ def take(n):
154
+ nonlocal i
155
+ out = v[i:i + n]
156
+ i += n
157
+ return out
158
+ out = {"action_p": take(len(ACTIONS)), "count_p": take(N_COUNTS),
159
+ "drag_count_p": take(N_DRAG_COUNTS), "slots": {}}
160
+ for name, _ in SLOTS:
161
+ tok, dx, dy, pt, el, pe = take(SLOT_WIDTH)
162
+ out["slots"][name] = {"token": int(tok), "dx": dx, "dy": dy, "p_point": pt,
163
+ "element": int(el), "p_element": pe}
164
+ out["text_ids"] = [int(t) for t in take(MAX_TEXT)]
165
+ out["moment"], out["moment_p"] = take(2)
166
+ out["moment"] = int(out["moment"])
167
+ for name, _ in SLOTS:
168
+ alts = []
169
+ for _k in range(ALTS):
170
+ tok, dx, dy, pt = take(ALT_WIDTH)
171
+ alts.append({"token": int(tok), "dx": dx, "dy": dy, "p_point": pt})
172
+ out["slots"][name]["alts"] = alts
173
+ out["text_p"] = take(MAX_TEXT)
174
+ out["text_alt_ids"] = [int(t) for t in take(MAX_TEXT)]
175
+ out["text_alt_p"] = take(MAX_TEXT)
176
+ return out
177
+
178
+
179
+ def to_point(slot: Dict, grid: Tuple[int, int]) -> List[int]:
180
+ """A slot's token + offset -> [x, y] on 0-1000, for a token grid (cols, rows)."""
181
+ cols, rows = grid
182
+ r, c = divmod(slot["token"], cols)
183
+ x = (c + 0.5 + slot["dx"]) / cols * 1000
184
+ y = (r + 0.5 + slot["dy"]) / rows * 1000
185
+ return [int(round(min(max(x, 0), 1000))), int(round(min(max(y, 0), 1000)))]
186
+
187
+
188
+ def answer(decision: Dict, grid: Tuple[int, int], *, text: Optional[str] = None,
189
+ timestamps: Optional[Sequence[float]] = None) -> Dict:
190
+ """Unpacked decisions -> the generation-3 answer shape, points only
191
+ (`elements.ids_for` adds the element numbers). `text` is the decoded typed
192
+ string (the caller owns the tokenizer); `timestamps` are a video's pair times.
193
+
194
+ `checks` says how sure each decision was: the action's, the count's and the
195
+ moment's probability, and per slot used, in answer order, p(point) and the
196
+ element number read off the marks with its probability (None: no number)."""
197
+ def p(name):
198
+ return to_point(decision["slots"][name], grid)
199
+ a = decision.get("action", max(range(len(ACTIONS)), key=lambda k: decision["action_p"][k]))
200
+ act = ACTIONS[a]
201
+ out: Dict = {}
202
+ checks: Dict = {"action_p": round(decision["action_p"][a], 4)}
203
+ if timestamps:
204
+ out["t"] = float(timestamps[min(decision["moment"], len(timestamps) - 1)])
205
+ checks["moment_p"] = round(decision["moment_p"], 4)
206
+ out["action"] = act
207
+ names: List[str] = []
208
+ if act in ("click", "refresh"):
209
+ n = decision.get("count", max(range(N_COUNTS), key=lambda k: decision["count_p"][k]))
210
+ checks["count_p"] = round(decision["count_p"][n], 4)
211
+ if act == "refresh":
212
+ n = min(n, 1)
213
+ names = [f"click{k}" for k in range(1, n + 1)]
214
+ out["points"] = [p(s) for s in names]
215
+ elif act == "drag":
216
+ k = decision.get("drag_count", max(range(N_DRAG_COUNTS), key=lambda k: decision["drag_count_p"][k]))
217
+ checks["count_p"] = round(decision["drag_count_p"][k], 4)
218
+ drags = []
219
+ for j in range(1, k + 2):
220
+ drags.append({"from": p(f"drag{j} from"), "to": p(f"drag{j} to")})
221
+ names += [f"drag{j} from", f"drag{j} to"]
222
+ out["drags"] = drags
223
+ elif act == "slide":
224
+ names = ["slide handle", "slide from", "slide to"]
225
+ out.update({"handle": p("slide handle"), "from": p("slide from"), "to": p("slide to")})
226
+ elif act == "hold":
227
+ names = ["hold press", "hold from", "hold to"]
228
+ out.update({"press": p("hold press"), "from": p("hold from"), "to": p("hold to")})
229
+ else:
230
+ out["text"] = text or ""
231
+ checks["slots"] = [{"slot": s, "p_point": round(decision["slots"][s]["p_point"], 4),
232
+ "element": decision["slots"][s]["element"] or None,
233
+ "p_element": round(decision["slots"][s]["p_element"], 4)} for s in names]
234
+ if decision.get("retry"):
235
+ checks["retry"] = decision["retry"]
236
+ out["checks"] = checks
237
+ return out
238
+
239
+
240
+ def _argmax(p: Sequence[float]) -> int:
241
+ return max(range(len(p)), key=lambda k: p[k])
242
+
243
+
244
+ def _chosen(decision: Dict) -> Tuple[str, List[str]]:
245
+ """(the action `answer` takes, the slots its answer reads), as `answer` picks them."""
246
+ act = ACTIONS[decision.get("action", _argmax(decision["action_p"]))]
247
+ if act in ("click", "refresh"):
248
+ n = decision.get("count", _argmax(decision["count_p"]))
249
+ return act, [f"click{k}" for k in range(1, (min(n, 1) if act == "refresh" else n) + 1)]
250
+ if act == "drag":
251
+ k = decision.get("drag_count", _argmax(decision["drag_count_p"]))
252
+ return act, [f"drag{j} {end}" for j in range(1, k + 2) for end in ("from", "to")]
253
+ if act in ("slide", "hold"):
254
+ return act, [n for n, _ in SLOTS if n.startswith(act)]
255
+ return act, []
256
+
257
+
258
+ def variants(decision: Dict, stop_id: Optional[int] = None) -> List[Dict]:
259
+ """`decision`, then each one-place change to it, likeliest first: another action,
260
+ another count, one slot's point moved to its runner-up token, or one typed
261
+ character swapped for its runner-up. A change costs log(p_chosen / p_runner_up),
262
+ so the first is the place the model was least sure of.
263
+
264
+ The heads' scores are a distribution, so a refused answer is a fact about the
265
+ board: the next try is the next-likeliest answer, not the same one asked again.
266
+ Each variant names its change in `retry`. `stop_id` ends the typed text: markers
267
+ after it are not read, but the stop itself can be swapped (a longer answer)."""
268
+ def cost(p_top, p_alt):
269
+ return math.log(max(p_top, 1e-9)) - math.log(max(p_alt, 1e-9))
270
+
271
+ act, names = _chosen(decision)
272
+ changes = []
273
+ a = _argmax(decision["action_p"])
274
+ for k, p in enumerate(decision["action_p"]):
275
+ if k != a:
276
+ changes.append((cost(decision["action_p"][a], p), {"action": k}, f"action {ACTIONS[k]}"))
277
+ for key, probs in (("count", "count_p"), ("drag_count", "drag_count_p")):
278
+ if (key == "count") != (act == "click") or (key == "drag_count") != (act == "drag"):
279
+ continue
280
+ n = _argmax(decision[probs])
281
+ for k, p in enumerate(decision[probs]):
282
+ if k != n:
283
+ changes.append((cost(decision[probs][n], p), {key: k}, f"{key} {k + (key == 'drag_count')}"))
284
+ for name in names:
285
+ slot = decision["slots"][name]
286
+ for j, alt in enumerate(slot.get("alts") or [], 2):
287
+ changes.append((cost(slot["p_point"], alt["p_point"]), {"slot": (name, alt)}, f"{name} place {j}"))
288
+ if act == "type" and decision.get("text_alt_ids"):
289
+ ids = decision["text_ids"]
290
+ end = ids.index(stop_id) + 1 if stop_id is not None and stop_id in ids else len(ids)
291
+ for i in range(end):
292
+ changes.append((cost(decision["text_p"][i], decision["text_alt_p"][i]), {"text": i}, f"text{i + 1} place 2"))
293
+ out = [decision]
294
+ for _c, change, what in sorted(changes, key=lambda c: c[0]):
295
+ v = copy.deepcopy(decision)
296
+ if "slot" in change:
297
+ name, alt = change["slot"]
298
+ v["slots"][name].update({k: alt[k] for k in ("token", "dx", "dy", "p_point")})
299
+ elif "text" in change:
300
+ i = change["text"]
301
+ v["text_ids"][i] = v["text_alt_ids"][i]
302
+ else:
303
+ v.update(change)
304
+ v["retry"] = what
305
+ out.append(v)
306
+ return out
307
+
308
+
309
+ def slot_point(ans: Dict, slot: str) -> Optional[List[int]]:
310
+ """Where `answer`'s `slot` put its point (after any later move of it), or None."""
311
+ if slot.startswith("click"):
312
+ pts = ans.get("points") or []
313
+ k = int(slot[5:]) - 1
314
+ return pts[k] if k < len(pts) else None
315
+ if slot.startswith("drag"):
316
+ k, end = slot[4:].split()
317
+ drags = ans.get("drags") or []
318
+ return drags[int(k) - 1][end] if int(k) <= len(drags) else None
319
+ return ans.get(slot.split()[1])
@@ -0,0 +1,184 @@
1
+ """The outlines and numbers drawn over a board's elements.
2
+
3
+ Each numbered element gets a 1 px outline traced from its MASK, not its box: a
4
+ box around a long twisty shape (a chain, a line, a snake of tiles) covers half
5
+ the board and names nothing. Its number sits in a small tag of the outline's
6
+ colour just outside the object's top edge, so the tag hides the object's
7
+ background rather than the object.
8
+
9
+ The digits are a 5x7 pixel font defined here, not a system font: what the model
10
+ was trained on must be what it is shown, on every machine, to the pixel. Each
11
+ font pixel is drawn `SCALE` x `SCALE`, so a digit is 15x21 px with 3 px strokes
12
+ and spans more than one 16 px vision patch; at 1x a digit was a fifth of a
13
+ patch, too small to tell 3 from 8 from 9. The look is frozen. Changing anything
14
+ here changes the model's input and needs a retrain.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import Dict, List, Sequence, Set, Tuple
19
+
20
+ import numpy as np
21
+ from PIL import Image
22
+
23
+ from .elements import fill_holes
24
+
25
+ _GLYPHS = {
26
+ "0": ("01110", "10001", "10011", "10101", "11001", "10001", "01110"),
27
+ "1": ("00100", "01100", "00100", "00100", "00100", "00100", "01110"),
28
+ "2": ("01110", "10001", "00001", "00010", "00100", "01000", "11111"),
29
+ "3": ("11110", "00001", "00001", "01110", "00001", "00001", "11110"),
30
+ "4": ("00010", "00110", "01010", "10010", "11111", "00010", "00010"),
31
+ "5": ("11111", "10000", "11110", "00001", "00001", "10001", "01110"),
32
+ "6": ("00110", "01000", "10000", "11110", "10001", "10001", "01110"),
33
+ "7": ("11111", "00001", "00010", "00100", "01000", "01000", "01000"),
34
+ "8": ("01110", "10001", "10001", "01110", "10001", "10001", "01110"),
35
+ "9": ("01110", "10001", "10001", "01111", "00001", "00010", "01100"),
36
+ }
37
+ GLYPH_W, GLYPH_H = 5, 7
38
+ #: Board pixels per font pixel.
39
+ SCALE = 3
40
+ #: Tag padding around the digits, px.
41
+ PAD = 2
42
+ #: Outline and tag colours, by element number: saturated, mutually distinct, and
43
+ #: dark enough that white digits read on every one of them.
44
+ PALETTE = [
45
+ (230, 25, 75), (0, 130, 200), (60, 160, 40), (145, 30, 180), (245, 110, 0),
46
+ (0, 140, 140), (200, 30, 160), (110, 80, 20), (70, 70, 220), (160, 120, 0),
47
+ ]
48
+
49
+
50
+ def colour(n: int) -> Tuple[int, int, int]:
51
+ """The colour of element number `n` (1-based)."""
52
+ return PALETTE[(n - 1) % len(PALETTE)]
53
+
54
+
55
+ def _digits(text: str) -> np.ndarray:
56
+ cols = []
57
+ for k, ch in enumerate(text):
58
+ if k:
59
+ cols.append(np.zeros((GLYPH_H, 1), bool))
60
+ cols.append(np.array([[c == "1" for c in row] for row in _GLYPHS[ch]], bool))
61
+ return np.kron(np.hstack(cols), np.ones((SCALE, SCALE), bool))
62
+
63
+
64
+ def tag_size(n: int) -> Tuple[int, int]:
65
+ d = _digits(str(n))
66
+ return d.shape[1] + 2 * PAD, d.shape[0] + 2 * PAD
67
+
68
+
69
+ # Chain-code directions (dx, dy), counter-clockwise from east, as OpenCV's border follower numbers them.
70
+ _STEPS = ((1, 0), (1, -1), (0, -1), (-1, -1), (-1, 0), (-1, 1), (0, 1), (1, 1))
71
+
72
+
73
+ def _trace(m: np.ndarray, x0: int, y0: int) -> List[Tuple[int, int]]:
74
+ """The outer border that starts at (x0, y0), its component's first pixel in raster order.
75
+
76
+ Suzuki-Abe border following exactly as OpenCV's findContours walks it (CHAIN_APPROX_NONE): the drawn line
77
+ fills each diagonal step on one side, so the walk's direction and its revisits are part of the picture.
78
+ """
79
+ def lit(s: int, x: int, y: int) -> bool:
80
+ return bool(m[y + _STEPS[s][1], x + _STEPS[s][0]])
81
+
82
+ s = 4
83
+ while True:
84
+ s = (s - 1) & 7
85
+ if lit(s, x0, y0) or s == 4:
86
+ break
87
+ if not lit(s, x0, y0):
88
+ return [(x0, y0)]
89
+ second = (x0 + _STEPS[s][0], y0 + _STEPS[s][1])
90
+ pts: List[Tuple[int, int]] = []
91
+ x, y = x0, y0
92
+ while True:
93
+ s += 1
94
+ while not lit(s & 7, x, y):
95
+ s += 1
96
+ s &= 7
97
+ pts.append((x, y))
98
+ nxt = (x + _STEPS[s][0], y + _STEPS[s][1])
99
+ if nxt == (x0, y0) and (x, y) == second:
100
+ return pts
101
+ (x, y), s = nxt, (s + 4) & 7
102
+
103
+
104
+ def outline(mask: Tuple[Tuple[int, int], np.ndarray]) -> List[np.ndarray]:
105
+ """The mask's outer contours, as (N, 2) int arrays of board pixels.
106
+
107
+ A hole's edge is not drawn, nor is anything inside a hole, so the holes are filled before tracing.
108
+ """
109
+ (x, y), m = mask
110
+ if not m.size or not m.any():
111
+ return []
112
+ p = fill_holes(np.pad(m.astype(bool), 1))
113
+ seen: Set[Tuple[int, int]] = set()
114
+ cs = []
115
+ for row, col in np.argwhere(p[:, 1:] & ~p[:, :-1]):
116
+ start = (int(col) + 1, int(row))
117
+ if start not in seen:
118
+ c = _trace(p, *start)
119
+ seen.update(c)
120
+ cs.append(np.array(c) + np.array([x - 1, y - 1]))
121
+ return cs
122
+
123
+
124
+ def _anchors(cs: Sequence[np.ndarray], tw: int, th: int) -> List[Tuple[int, int]]:
125
+ """Tag positions (top-left) to try, best first: above the topmost point, left of
126
+ the leftmost, below the bottommost, right of the rightmost."""
127
+ pts = np.vstack(cs)
128
+ top = pts[np.lexsort((pts[:, 0], pts[:, 1]))[0]]
129
+ left = pts[np.lexsort((pts[:, 1], pts[:, 0]))[0]]
130
+ bot = pts[np.lexsort((pts[:, 0], -pts[:, 1]))[0]]
131
+ right = pts[np.lexsort((pts[:, 1], -pts[:, 0]))[0]]
132
+ return [(int(top[0]) - tw // 2, int(top[1]) - th), (int(left[0]) - tw, int(left[1]) - th // 2),
133
+ (int(bot[0]) - tw // 2, int(bot[1]) + 1), (int(right[0]) + 1, int(right[1]) - th // 2),
134
+ (int(top[0]) - tw // 2, int(top[1]) + 1)]
135
+
136
+
137
+ def _overlap(a, b) -> int:
138
+ return max(0, min(a[2], b[2]) - max(a[0], b[0])) * max(0, min(a[3], b[3]) - max(a[1], b[1]))
139
+
140
+
141
+ def draw(image: Image.Image, elements: Sequence[Dict]) -> Image.Image:
142
+ """The board with every element outlined and numbered (1-based, in list order).
143
+ A None keeps its number but draws nothing: a video frame is drawn with
144
+ `elements.on_frame`, so an element carries its track's number on every frame."""
145
+ out = np.array(image.convert("RGB"))
146
+ H, W = out.shape[:2]
147
+ plans = []
148
+ for n, e in enumerate(elements, 1):
149
+ if e is None: # a video track absent from this frame
150
+ continue
151
+ cs = outline(e["mask"])
152
+ if not cs:
153
+ x0, y0, x1, y1 = (int(round(v)) for v in e["box"])
154
+ cs = [np.array([[x0, y0], [x1, y0], [x1, y1], [x0, y1]])]
155
+ plans.append((n, cs))
156
+ for n, cs in plans:
157
+ c = colour(n)
158
+ for poly in cs:
159
+ p = np.clip(poly, [0, 0], [W - 1, H - 1])
160
+ out[p[:, 1], p[:, 0]] = c
161
+ # Contours are 8-connected; fill the diagonal steps so the line never
162
+ # reads as dotted at 1 px.
163
+ q = np.roll(p, -1, axis=0)
164
+ diag = (p[:, 0] != q[:, 0]) & (p[:, 1] != q[:, 1])
165
+ out[p[diag, 1], q[diag, 0]] = c
166
+ placed: List[Tuple[int, int, int, int]] = []
167
+ for n, cs in plans:
168
+ tw, th = tag_size(n)
169
+ best = None
170
+ for ax, ay in _anchors(cs, tw, th):
171
+ ax = min(max(ax, 0), W - tw)
172
+ ay = min(max(ay, 0), H - th)
173
+ r = (ax, ay, ax + tw, ay + th)
174
+ cost = sum(_overlap(r, o) for o in placed)
175
+ if best is None or cost < best[0]:
176
+ best = (cost, r)
177
+ if cost == 0:
178
+ break
179
+ x0, y0, x1, y1 = best[1]
180
+ out[y0:y1, x0:x1] = colour(n)
181
+ d = _digits(str(n))
182
+ out[y0 + PAD:y0 + PAD + d.shape[0], x0 + PAD:x0 + PAD + d.shape[1]][d] = (255, 255, 255)
183
+ placed.append(best[1])
184
+ return Image.fromarray(out)