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.
- 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 +93 -11
- package/dist/selectors.js +226 -116
- package/dist/solver.d.ts +92 -12
- package/dist/solver.js +562 -203
- 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 +595 -251
- 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 +221 -135
- 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,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)
|