captchakraken 3.2.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -9
- package/LICENSE +165 -145
- package/NOTICE +4 -4
- package/README.md +10 -11
- package/dist/cli-invocation.d.ts +6 -3
- package/dist/cli-invocation.js +26 -10
- package/dist/humanize.d.ts +37 -1
- package/dist/humanize.js +143 -9
- package/dist/index.d.ts +13 -12
- package/dist/index.js +25 -33
- package/dist/kinds.d.ts +15 -2
- package/dist/kinds.js +12 -4
- package/dist/playwright-types.d.ts +3 -0
- package/dist/selectors.d.ts +96 -11
- package/dist/selectors.js +226 -112
- package/dist/solver.d.ts +93 -13
- package/dist/solver.js +579 -208
- package/dist/types.d.ts +53 -8
- package/dist/verdicts.d.ts +3 -0
- package/dist/verdicts.js +6 -0
- package/package.json +4 -4
- package/python/AGENTS.md +7 -11
- package/python/LICENSE +165 -145
- package/python/NOTICE +4 -4
- package/python/README.md +10 -9
- package/python/pyproject.toml +27 -13
- package/python/src/captchakraken/__init__.py +43 -46
- package/python/src/captchakraken/action_types.py +32 -1
- package/python/src/captchakraken/captchakraken.config.json +416 -0
- package/python/src/captchakraken/cli.py +66 -30
- package/python/src/captchakraken/clipvideo.py +258 -0
- package/python/src/captchakraken/humanize.py +141 -10
- package/python/src/captchakraken/image_processor.py +3 -7
- package/python/src/captchakraken/keyframes.py +96 -14
- package/python/src/captchakraken/kinds.py +14 -2
- package/python/src/captchakraken/models.json +28 -3
- package/python/src/captchakraken/page_solver.py +621 -259
- package/python/src/captchakraken/planner.py +298 -47
- package/python/src/captchakraken/pointer/__init__.py +20 -0
- package/python/src/captchakraken/pointer/detector.py +333 -0
- package/python/src/captchakraken/pointer/elements.py +273 -0
- package/python/src/captchakraken/pointer/heads.py +163 -0
- package/python/src/captchakraken/pointer/layout.py +319 -0
- package/python/src/captchakraken/pointer/marks.py +184 -0
- package/python/src/captchakraken/pointer/server.py +146 -0
- package/python/src/captchakraken/pointer/service.py +553 -0
- package/python/src/captchakraken/pointer/vllm_plugin.py +109 -0
- package/python/src/captchakraken/prompts.py +111 -34
- package/python/src/captchakraken/selectors.py +224 -131
- package/python/src/captchakraken/server_manager.py +18 -0
- package/python/src/captchakraken/solver.py +169 -88
- package/python/src/captchakraken/tool_calls/board_painted.py +11 -6
- package/python/src/captchakraken/tool_calls/fill_tracker.py +152 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +14 -59
- package/python/src/captchakraken/tool_calls/find_grid.py +273 -1191
- package/python/src/captchakraken/tool_calls/slide_tracker.py +1102 -0
- package/python/src/captchakraken/verdicts.py +16 -0
- package/dist/slide-geometry.d.ts +0 -6
- package/dist/slide-geometry.js +0 -38
- package/python/src/captchakraken/tool_calls/track_piece.py +0 -120
|
@@ -11,18 +11,13 @@ from typing import Any, Dict, List, Optional
|
|
|
11
11
|
import requests
|
|
12
12
|
from PIL import Image
|
|
13
13
|
|
|
14
|
-
from . import config, errors, prompts
|
|
14
|
+
from . import clipvideo, config, errors, prompts
|
|
15
|
+
from .clipvideo import ClipVideo
|
|
15
16
|
from .kinds import ActionKind, PixelAnswerKind, PromptFamily, RetryMode
|
|
16
17
|
from .server_manager import ensure_server
|
|
17
18
|
|
|
18
19
|
DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
|
|
19
20
|
|
|
20
|
-
# A floor, not a resize, and the AREA is clamped rather than the sides: predictions on a 277x285 GeeTest
|
|
21
|
-
# board landed 80-105px from the hand label at native size and 1-4px once upscaled, and squashing the
|
|
22
|
-
# aspect moves every tile centre.
|
|
23
|
-
MIN_PIXELS = 448 * 448
|
|
24
|
-
|
|
25
|
-
|
|
26
21
|
def _encode_image(path: str,
|
|
27
22
|
budget: "Optional[prompts.PixelBudget]" = None) -> tuple:
|
|
28
23
|
budget = budget or prompts.pixel_budget(None)
|
|
@@ -159,28 +154,6 @@ PIXEL_ACTION_PROMPT = _LATEST.action_prompt
|
|
|
159
154
|
|
|
160
155
|
|
|
161
156
|
|
|
162
|
-
# Greedy only, by measurement: (0.0, 0.35, 0.7) scored 0/5 against 4/5 and made the median failed attempt
|
|
163
|
-
# 24.0s against 13.9s, because a differing re-ask resets the no-progress counter. The level plumbing stays for
|
|
164
|
-
# a network-refusal trigger. Lives here once so the two ports cannot drift (TRIBAL_KNOWLEDGE.md).
|
|
165
|
-
RESAMPLE_TEMPERATURES = (0.0,)
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
def sampling_for_level(level: int) -> Dict[str, Any]:
|
|
169
|
-
temps = RESAMPLE_TEMPERATURES or (0.0,)
|
|
170
|
-
temp = temps[min(max(level, 0), len(temps) - 1)]
|
|
171
|
-
if not temp:
|
|
172
|
-
return {}
|
|
173
|
-
return {"temperature": temp, "seed": 1000 + level}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
def _sampling_from_env() -> Dict[str, Any]:
|
|
177
|
-
raw = os.environ.get("CAPTCHA_RESAMPLE_LEVEL", "").strip()
|
|
178
|
-
if not raw:
|
|
179
|
-
return {}
|
|
180
|
-
try:
|
|
181
|
-
return sampling_for_level(int(raw))
|
|
182
|
-
except ValueError:
|
|
183
|
-
return {}
|
|
184
157
|
|
|
185
158
|
|
|
186
159
|
#: The ceiling on one ask, and the old hardcoded value. Measured why it matters: against the hosted
|
|
@@ -206,6 +179,70 @@ def _warn_legacy_model_once(model: str) -> None:
|
|
|
206
179
|
print(f"[captchakraken] {prompts.LEGACY_MODEL_WARNING.format(model=model)}", file=sys.stderr)
|
|
207
180
|
|
|
208
181
|
|
|
182
|
+
def _norm_xy(x: Any, y: Any) -> Optional[tuple]:
|
|
183
|
+
"""A model point on the 0-1000 scale as 0-1; a normalised pair passes, anything off the image is clamped."""
|
|
184
|
+
try:
|
|
185
|
+
fx, fy = float(x) / 1000.0, float(y) / 1000.0
|
|
186
|
+
except (TypeError, ValueError):
|
|
187
|
+
return None
|
|
188
|
+
if not (0.0 <= fx <= 1.0 and 0.0 <= fy <= 1.0):
|
|
189
|
+
if 0.0 <= float(x) <= 1.0 and 0.0 <= float(y) <= 1.0:
|
|
190
|
+
fx, fy = float(x), float(y)
|
|
191
|
+
else:
|
|
192
|
+
fx, fy = min(max(fx, 0.0), 1.0), min(max(fy, 0.0), 1.0)
|
|
193
|
+
return (fx, fy)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# Closer than this (0-1 of the board) and two pointer clicks are one target. Small enough that neighbouring icons in a
|
|
197
|
+
# click-in-order puzzle stay two targets.
|
|
198
|
+
REPEATED_POINT = 0.004
|
|
199
|
+
# Closer than this (0-1 of the board) at both ends and two pointer drags move one piece to one place: the server's
|
|
200
|
+
# SAME_PLACE.
|
|
201
|
+
REPEATED_DRAG = 0.025
|
|
202
|
+
#: Refused answers sent back with one pointer ask: the server's MAX_AVOID.
|
|
203
|
+
POINTER_AVOID_MAX = 32
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _without_repeated_points(action: Dict[str, Any]) -> Dict[str, Any]:
|
|
207
|
+
"""A pointer click without its repeats. Two click slots that pick the same token answer the same point, and on a
|
|
208
|
+
tile board the second click deselects what the first selected."""
|
|
209
|
+
points = action.get("points")
|
|
210
|
+
if action.get("kind") != PixelAnswerKind.CLICK or not points:
|
|
211
|
+
return action
|
|
212
|
+
keep = [i for i, p in enumerate(points)
|
|
213
|
+
if all(abs(p[0] - q[0]) > REPEATED_POINT or abs(p[1] - q[1]) > REPEATED_POINT for q in points[:i])]
|
|
214
|
+
if len(keep) == len(points):
|
|
215
|
+
return action
|
|
216
|
+
ids = action.get("ids") or []
|
|
217
|
+
return {**action, "points": [points[i] for i in keep],
|
|
218
|
+
"ids": [ids[i] for i in keep] if len(ids) == len(points) else ids}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _same_drag(a: Dict[str, Any], b: Dict[str, Any]) -> bool:
|
|
222
|
+
return all(abs(a[end][i] - b[end][i]) <= REPEATED_DRAG for end in ("src", "dst") for i in (0, 1))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _is_drag(action: Dict[str, Any]) -> bool:
|
|
226
|
+
return action.get("kind") == PixelAnswerKind.DRAG
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _without_repeated_drags(plan: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
230
|
+
"""A pointer plan without a drag it already made: the piece is already there, and a board that counts its moves
|
|
231
|
+
counts the second one. The count head can ask for more drags than there are pieces, and then the extra slots read
|
|
232
|
+
the same piece and the same target."""
|
|
233
|
+
out: List[Dict[str, Any]] = []
|
|
234
|
+
for action in plan:
|
|
235
|
+
if not (_is_drag(action) and any(_is_drag(o) and _same_drag(action, o) for o in out)):
|
|
236
|
+
out.append(action)
|
|
237
|
+
return out
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _same_drags(a: List[Dict[str, Any]], b: List[Dict[str, Any]]) -> bool:
|
|
241
|
+
"""Do two plans make the same drags, one after the other?"""
|
|
242
|
+
return bool(a) and len(a) == len(b) and all(_is_drag(x) and _is_drag(y) and _same_drag(x, y)
|
|
243
|
+
for x, y in zip(a, b))
|
|
244
|
+
|
|
245
|
+
|
|
209
246
|
class ActionPlanner:
|
|
210
247
|
|
|
211
248
|
def __init__(
|
|
@@ -223,7 +260,19 @@ class ActionPlanner:
|
|
|
223
260
|
if config.is_hosted_endpoint(self.base_url) and prompts.is_legacy_hosted_name(self.model):
|
|
224
261
|
_warn_legacy_model_once(self.model)
|
|
225
262
|
self.api_key = api_key or config.api_key()
|
|
226
|
-
|
|
263
|
+
#: The pointer's answers on the current board, oldest first. The pointer's heads are an argmax, so a
|
|
264
|
+
#: re-ask cannot move them: once the caller sets `avoid_refused`, every ask names these and the
|
|
265
|
+
#: server answers with its likeliest OTHER answer. The caller clears both when the board changes.
|
|
266
|
+
self.pointer_answers: List[Dict[str, Any]] = []
|
|
267
|
+
#: The complete plans the server returned after the first (`actions[1:]`); the next retry uses them
|
|
268
|
+
#: without asking again. `pointer_has_actions` says the server answered with the list at all, so an
|
|
269
|
+
#: older server's `avoid` retry still works.
|
|
270
|
+
self.pointer_fallbacks: List[Dict[str, Any]] = []
|
|
271
|
+
self.pointer_has_actions = False
|
|
272
|
+
#: Earlier screens of the same paged puzzle, oldest first, as file paths. A FunCaptcha pager's answer
|
|
273
|
+
#: depends on the pages already seen; without them the model keeps answering "next arrow".
|
|
274
|
+
self.pointer_history: List[str] = []
|
|
275
|
+
self.avoid_refused = False
|
|
227
276
|
#: Seconds a single ask may take. The CALLER owns this: a solve has a budget, and a request
|
|
228
277
|
#: that outlives it cannot be cancelled once it is in flight — the deadline is only read
|
|
229
278
|
#: between steps. Left alone it is the old ceiling; `PageSolver` lowers it to what remains.
|
|
@@ -304,7 +353,11 @@ class ActionPlanner:
|
|
|
304
353
|
mime, b64 = _encode_image(p, self.pixel_budget)
|
|
305
354
|
parts.append({"type": "image_url",
|
|
306
355
|
"image_url": {"url": f"data:{mime};base64,{b64}"}})
|
|
356
|
+
return self._chat(prompt, parts, max_tokens=max_tokens, family=family)
|
|
307
357
|
|
|
358
|
+
def _chat(self, prompt: str, parts: List[Dict[str, Any]], max_tokens: int,
|
|
359
|
+
family: Optional[PromptFamily]) -> str:
|
|
360
|
+
"""POST one chat completion: the media parts, then the prompt, in one user message."""
|
|
308
361
|
messages = [
|
|
309
362
|
{
|
|
310
363
|
"role": "system",
|
|
@@ -326,10 +379,6 @@ class ActionPlanner:
|
|
|
326
379
|
"chat_template_kwargs": {"enable_thinking": False},
|
|
327
380
|
"reasoning_effort": "none",
|
|
328
381
|
}
|
|
329
|
-
sampling = self.sampling or _sampling_from_env()
|
|
330
|
-
if sampling:
|
|
331
|
-
payload.update(sampling)
|
|
332
|
-
|
|
333
382
|
headers = {
|
|
334
383
|
"Authorization": f"Bearer {self.api_key}",
|
|
335
384
|
"Content-Type": "application/json",
|
|
@@ -345,7 +394,7 @@ class ActionPlanner:
|
|
|
345
394
|
|
|
346
395
|
url = f"{self.base_url}/chat/completions"
|
|
347
396
|
self._log(f"POST {url} model={model} max_tokens={max_tokens} "
|
|
348
|
-
f"
|
|
397
|
+
f"parts={[p['type'] for p in parts]}")
|
|
349
398
|
|
|
350
399
|
resp = self._http.post(url, headers=headers, json=payload,
|
|
351
400
|
timeout=self.request_timeout_s or DEFAULT_REQUEST_TIMEOUT_S)
|
|
@@ -485,8 +534,111 @@ class ActionPlanner:
|
|
|
485
534
|
out.append(iv)
|
|
486
535
|
return out
|
|
487
536
|
|
|
537
|
+
#: Generation 4's endpoint, beside /chat/completions: the board in, generation 3's answer shape out.
|
|
538
|
+
_POINTER_PATH = "/pointer"
|
|
539
|
+
|
|
540
|
+
def _ask_pointer(self, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
541
|
+
"""POST one board to the pointer server. It numbers the board's elements, builds the prompt and reads the
|
|
542
|
+
answer from one prefill, so nothing here is a prompt."""
|
|
543
|
+
headers = {
|
|
544
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
545
|
+
"Content-Type": "application/json",
|
|
546
|
+
**routing_headers(hosted=config.is_hosted_endpoint(self.base_url)),
|
|
547
|
+
}
|
|
548
|
+
url = f"{self.base_url}{self._POINTER_PATH}"
|
|
549
|
+
self._log(f"POST {url} model={self.model} media={sorted(k for k in body if k not in ('model', 'avoid'))}")
|
|
550
|
+
if self.avoid_refused and self.pointer_answers:
|
|
551
|
+
body = {**body, "avoid": self.pointer_answers[-POINTER_AVOID_MAX:]}
|
|
552
|
+
self._log(f"asking past {len(body['avoid'])} refused answer(s)")
|
|
553
|
+
resp = self._http.post(url, headers=headers, json={"model": self.model, **body},
|
|
554
|
+
timeout=self.request_timeout_s or DEFAULT_REQUEST_TIMEOUT_S)
|
|
555
|
+
if not resp.ok:
|
|
556
|
+
raise errors.from_response(resp, url)
|
|
557
|
+
try:
|
|
558
|
+
data = resp.json()
|
|
559
|
+
except ValueError:
|
|
560
|
+
raise RuntimeError(f"the pointer server returned a non-JSON body from {url}: {(resp.text or '')[:300]}")
|
|
561
|
+
answer = data.get("answer") if isinstance(data, dict) else None
|
|
562
|
+
if not isinstance(answer, dict):
|
|
563
|
+
raise RuntimeError(f"the pointer server's reply from {url} has no answer: {str(data)[:300]}")
|
|
564
|
+
actions = data.get("actions") if isinstance(data, dict) else None
|
|
565
|
+
self.pointer_has_actions = isinstance(actions, list) and bool(actions)
|
|
566
|
+
self.pointer_fallbacks = self._other_plans(answer, actions) if self.pointer_has_actions else []
|
|
567
|
+
self._log(f"pointer answer: {answer}")
|
|
568
|
+
self.pointer_answers.append({k: v for k, v in answer.items() if k != "checks"})
|
|
569
|
+
return answer
|
|
570
|
+
|
|
571
|
+
def _other_plans(self, answer: Dict[str, Any], actions: List[Any]) -> List[Dict[str, Any]]:
|
|
572
|
+
"""The server's answers after the first, without any that only repeats a drag of one before it: pressed again
|
|
573
|
+
on a board that refused it, it costs a move and can tell the board nothing new. The action is taken as read: on
|
|
574
|
+
1,954 real boards, backups of another action rescued 2, and pressed 237 moves a vendor sees and refuses."""
|
|
575
|
+
seen = [_without_repeated_drags(self._normalize_general(answer))]
|
|
576
|
+
out = []
|
|
577
|
+
for other in [a for a in actions if isinstance(a, dict)][1:]:
|
|
578
|
+
if other.get("action") != answer.get("action"):
|
|
579
|
+
continue
|
|
580
|
+
plan = _without_repeated_drags(self._normalize_general(other))
|
|
581
|
+
if not any(_same_drags(plan, s) for s in seen):
|
|
582
|
+
seen.append(plan)
|
|
583
|
+
out.append(other)
|
|
584
|
+
return out
|
|
585
|
+
|
|
586
|
+
def pointer_fallback_actions(self) -> List[List[Dict[str, Any]]]:
|
|
587
|
+
"""The complete plans after the first, and a refresh when they run out; [] for a non-pointer model."""
|
|
588
|
+
if not self.pointer_has_actions:
|
|
589
|
+
return []
|
|
590
|
+
plans = [[_without_repeated_points(a) for a in _without_repeated_drags(self._normalize_general(f))]
|
|
591
|
+
for f in self.pointer_fallbacks]
|
|
592
|
+
return [p for p in plans if p] + [[{"kind": PixelAnswerKind.REFRESH, "points": []}]]
|
|
593
|
+
|
|
594
|
+
def get_pointer_actions(self, image_path: str) -> List[Dict[str, Any]]:
|
|
595
|
+
"""Generation 4 on a still: the screenshot as it is, PNG; the server sizes it and marks it.
|
|
596
|
+
|
|
597
|
+
On a retry the server's other complete plans come first, then a refresh: a refused answer is never
|
|
598
|
+
asked again, and the model's exhausted board is replaced rather than re-queried."""
|
|
599
|
+
if self.avoid_refused and self.pointer_has_actions:
|
|
600
|
+
if self.pointer_fallbacks:
|
|
601
|
+
answer = self.pointer_fallbacks.pop(0)
|
|
602
|
+
self.pointer_answers.append({k: v for k, v in answer.items() if k != "checks"})
|
|
603
|
+
self._log(f"pointer fallback: {answer.get('action')}")
|
|
604
|
+
else:
|
|
605
|
+
answer = {"action": ActionKind.REFRESH, "points": []}
|
|
606
|
+
self._log("pointer fallbacks exhausted; asking for a new board")
|
|
607
|
+
actions = [_without_repeated_points(a) for a in _without_repeated_drags(self._normalize_general(answer))]
|
|
608
|
+
else:
|
|
609
|
+
with open(image_path, "rb") as f:
|
|
610
|
+
raw = f.read()
|
|
611
|
+
body: Dict[str, Any] = {"image": base64.b64encode(raw).decode()}
|
|
612
|
+
if self.pointer_history:
|
|
613
|
+
history = []
|
|
614
|
+
for path in self.pointer_history[-8:]:
|
|
615
|
+
try:
|
|
616
|
+
with open(path, "rb") as f:
|
|
617
|
+
history.append(base64.b64encode(f.read()).decode())
|
|
618
|
+
except OSError:
|
|
619
|
+
continue
|
|
620
|
+
if history:
|
|
621
|
+
body["history"] = history
|
|
622
|
+
actions = [_without_repeated_points(a) for a in
|
|
623
|
+
_without_repeated_drags(self._normalize_general(self._ask_pointer(body)))]
|
|
624
|
+
self._log(f"pointer actions -> {actions}")
|
|
625
|
+
return actions
|
|
626
|
+
|
|
627
|
+
def get_general_actions(self, image_path: str) -> List[Dict[str, Any]]:
|
|
628
|
+
"""Generation 3: the one prompt, whatever the puzzle, and no family to route by."""
|
|
629
|
+
if self.prompts.pointer:
|
|
630
|
+
return self.get_pointer_actions(image_path)
|
|
631
|
+
raw = self._chat_with_image(self.prompts.general or "", image_path, max_tokens=512)
|
|
632
|
+
actions = self._normalize_general(self._parse_json(raw))
|
|
633
|
+
self._log(f"general actions -> {actions}")
|
|
634
|
+
return actions
|
|
635
|
+
|
|
488
636
|
def get_pixel_actions(self, image_path: str, text_mode: bool = False) -> List[Dict[str, Any]]:
|
|
489
|
-
|
|
637
|
+
if self.prompts.pointer:
|
|
638
|
+
return self.get_pointer_actions(image_path)
|
|
639
|
+
if self.prompts.general is not None:
|
|
640
|
+
return self.get_general_actions(image_path)
|
|
641
|
+
prompt = self.prompts.text_prompt() if text_mode else self.prompts.pixel_prompt()
|
|
490
642
|
raw = self._chat_with_image(prompt, image_path, max_tokens=512,
|
|
491
643
|
family=PromptFamily.TEXT if text_mode else PromptFamily.PIXEL)
|
|
492
644
|
data = self._parse_json(raw)
|
|
@@ -495,6 +647,9 @@ class ActionPlanner:
|
|
|
495
647
|
return actions
|
|
496
648
|
|
|
497
649
|
def get_keyframe_actions(self, keyframe_paths: List[str]) -> List[Dict[str, Any]]:
|
|
650
|
+
if self.prompts.sends_video:
|
|
651
|
+
raise ValueError(f"prompt generation {self.prompts.version} answers from the clip, not from keyframes; "
|
|
652
|
+
"use get_video_actions")
|
|
498
653
|
if not keyframe_paths:
|
|
499
654
|
return []
|
|
500
655
|
prompt = self.prompts.video_prompt(len(keyframe_paths))
|
|
@@ -508,6 +663,58 @@ class ActionPlanner:
|
|
|
508
663
|
self._log(f"keyframe actions (frame={frame}) -> {actions}")
|
|
509
664
|
return actions
|
|
510
665
|
|
|
666
|
+
def video_max_pixels(self) -> int:
|
|
667
|
+
"""The frame budget a clip is cut to: the model's pixel ceiling, as its training frames were cut."""
|
|
668
|
+
if not self.pixel_budget.maximum:
|
|
669
|
+
raise ValueError(
|
|
670
|
+
f"model {self.model!r} declares no pixel ceiling (pixel_budget in models.json, or "
|
|
671
|
+
f"{prompts.MAX_PIXELS_ENV}), and a video is cut to exactly that size. Register the model's band.")
|
|
672
|
+
return int(self.pixel_budget.maximum)
|
|
673
|
+
|
|
674
|
+
def get_video_actions(self, clip: ClipVideo) -> List[Dict[str, Any]]:
|
|
675
|
+
"""One request carrying the whole clip as video; each action names the moment `t` it acts on, or None."""
|
|
676
|
+
if not clip.frames:
|
|
677
|
+
return []
|
|
678
|
+
if not self.prompts.sends_video:
|
|
679
|
+
raise ValueError(f"prompt generation {self.prompts.version} answers from keyframes, not from a clip; "
|
|
680
|
+
"use get_keyframe_actions")
|
|
681
|
+
if self.prompts.pointer:
|
|
682
|
+
data: Any = self._ask_pointer({"frames": [base64.b64encode(clipvideo.encode_jpeg(f)).decode()
|
|
683
|
+
for f in clip.frames], "fps": clip.fps})
|
|
684
|
+
general = True
|
|
685
|
+
else:
|
|
686
|
+
parts = [{"type": "video_url", "video_url": {"url": clipvideo.data_url(clip)}}]
|
|
687
|
+
general = self.prompts.general is not None
|
|
688
|
+
raw = self._chat(self.prompts.video_prompt(), parts, max_tokens=512,
|
|
689
|
+
family=None if general else PromptFamily.VIDEO)
|
|
690
|
+
data = self._parse_json(raw)
|
|
691
|
+
actions = self._normalize_general(data) if general else self._normalize_pixel(data)
|
|
692
|
+
t = self._normalize_time(data, clip)
|
|
693
|
+
for a in actions:
|
|
694
|
+
a["t"] = t
|
|
695
|
+
self._log(f"video actions (t={t}) -> {actions}")
|
|
696
|
+
return actions
|
|
697
|
+
|
|
698
|
+
@staticmethod
|
|
699
|
+
def _normalize_time(data: Any, clip: ClipVideo) -> Optional[float]:
|
|
700
|
+
"""A time outside the clip is None, not clamped: clamping would invent an intent."""
|
|
701
|
+
if not isinstance(data, dict):
|
|
702
|
+
return None
|
|
703
|
+
raw = data.get("t")
|
|
704
|
+
if raw is None and isinstance(data.get("action"), dict):
|
|
705
|
+
raw = data["action"].get("t")
|
|
706
|
+
if isinstance(raw, bool):
|
|
707
|
+
return None
|
|
708
|
+
try:
|
|
709
|
+
t = float(raw)
|
|
710
|
+
except (TypeError, ValueError):
|
|
711
|
+
return None
|
|
712
|
+
# Half a pair's width either side of the first and last label: anything further names no moment shown.
|
|
713
|
+
slack = 1.0 / clip.fps
|
|
714
|
+
if not clip.timestamps or not clip.timestamps[0] - slack <= t <= clip.timestamps[-1] + slack:
|
|
715
|
+
return None
|
|
716
|
+
return t
|
|
717
|
+
|
|
511
718
|
@staticmethod
|
|
512
719
|
def _normalize_frame(data: Any, n_keyframes: int) -> Optional[int]:
|
|
513
720
|
"""Out of range is None, not clamped: clamping a 7 to 6 would invent an intent."""
|
|
@@ -522,20 +729,61 @@ class ActionPlanner:
|
|
|
522
729
|
return None
|
|
523
730
|
return n if 1 <= n <= n_keyframes else None
|
|
524
731
|
|
|
732
|
+
@staticmethod
|
|
733
|
+
def _normalize_general(data: Any) -> List[Dict[str, Any]]:
|
|
734
|
+
"""Generation 3's answer. Click and refresh are read here because an empty point list means something for
|
|
735
|
+
them (nothing matches; no control seen) and a click may name boxed ids; drag, hold and type have generation
|
|
736
|
+
2's shape and go through `_normalize_pixel`."""
|
|
737
|
+
if not isinstance(data, dict):
|
|
738
|
+
return []
|
|
739
|
+
act = data.get("action")
|
|
740
|
+
if act == ActionKind.SLIDE:
|
|
741
|
+
return ActionPlanner._normalize_slide(data)
|
|
742
|
+
if act == ActionKind.HOLD and "press" in data:
|
|
743
|
+
return ActionPlanner._normalize_hold(data)
|
|
744
|
+
if act not in (ActionKind.CLICK, ActionKind.REFRESH):
|
|
745
|
+
return ActionPlanner._normalize_pixel(data)
|
|
746
|
+
raw_points = data.get("points")
|
|
747
|
+
points = [xy for xy in (_norm_xy(p[0], p[1]) for p in (raw_points if isinstance(raw_points, list) else [])
|
|
748
|
+
if isinstance(p, (list, tuple)) and len(p) >= 2) if xy]
|
|
749
|
+
if act == ActionKind.REFRESH:
|
|
750
|
+
return [{"kind": PixelAnswerKind.REFRESH, "points": points[:1]}]
|
|
751
|
+
raw_ids = data.get("ids")
|
|
752
|
+
ids = [int(i) for i in (raw_ids if isinstance(raw_ids, list) else [])
|
|
753
|
+
if not isinstance(i, bool) and str(i).strip().isdigit()]
|
|
754
|
+
return [{"kind": PixelAnswerKind.CLICK, "points": points, "ids": ids}]
|
|
755
|
+
|
|
756
|
+
@staticmethod
|
|
757
|
+
def _answer_point(data: Dict[str, Any], key: str) -> Optional[tuple]:
|
|
758
|
+
v = data.get(key)
|
|
759
|
+
if not isinstance(v, (list, tuple)) or len(v) < 2 or any(isinstance(c, bool) for c in v[:2]):
|
|
760
|
+
return None
|
|
761
|
+
return _norm_xy(v[0], v[1])
|
|
762
|
+
|
|
763
|
+
@staticmethod
|
|
764
|
+
def _normalize_hold(data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
765
|
+
"""The press, and the fill's line when both its ends are named: one end alone says nothing about where to let
|
|
766
|
+
go, so the hold falls back to waiting for the widget to finish."""
|
|
767
|
+
press = ActionPlanner._answer_point(data, "press")
|
|
768
|
+
if press is None:
|
|
769
|
+
return []
|
|
770
|
+
src, dst = ActionPlanner._answer_point(data, "from"), ActionPlanner._answer_point(data, "to")
|
|
771
|
+
line = {"src": src, "dst": dst} if src is not None and dst is not None else {}
|
|
772
|
+
return [{"kind": PixelAnswerKind.HOLD, "points": [press], **line}]
|
|
773
|
+
|
|
774
|
+
@staticmethod
|
|
775
|
+
def _normalize_slide(data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
776
|
+
"""All three points or nothing: a slide missing one has no defined stop, and guessing it would steer the
|
|
777
|
+
handle to a place the model never named."""
|
|
778
|
+
handle, src, dst = (ActionPlanner._answer_point(data, k) for k in ("handle", "from", "to"))
|
|
779
|
+
if handle is None or src is None or dst is None:
|
|
780
|
+
return []
|
|
781
|
+
return [{"kind": PixelAnswerKind.SLIDE, "handle": handle, "src": src, "dst": dst}]
|
|
782
|
+
|
|
525
783
|
@staticmethod
|
|
526
784
|
def _normalize_pixel(data: Any) -> List[Dict[str, Any]]:
|
|
527
785
|
"""Every answer shape the model has been seen to produce, oldest last; `kind` is a PixelAnswerKind."""
|
|
528
|
-
|
|
529
|
-
try:
|
|
530
|
-
fx, fy = float(x) / 1000.0, float(y) / 1000.0
|
|
531
|
-
except (TypeError, ValueError):
|
|
532
|
-
return None
|
|
533
|
-
if not (0.0 <= fx <= 1.0 and 0.0 <= fy <= 1.0):
|
|
534
|
-
if 0.0 <= float(x) <= 1.0 and 0.0 <= float(y) <= 1.0:
|
|
535
|
-
fx, fy = float(x), float(y)
|
|
536
|
-
else:
|
|
537
|
-
fx, fy = min(max(fx, 0.0), 1.0), min(max(fy, 0.0), 1.0)
|
|
538
|
-
return (fx, fy)
|
|
786
|
+
norm_xy = _norm_xy
|
|
539
787
|
|
|
540
788
|
def flat_numbers(v: Any) -> List[float]:
|
|
541
789
|
nums: List[float] = []
|
|
@@ -696,6 +944,9 @@ class ActionPlanner:
|
|
|
696
944
|
xy = None
|
|
697
945
|
if xy:
|
|
698
946
|
pts.append(xy)
|
|
947
|
+
if pts and data.get("action") == ActionKind.HOLD:
|
|
948
|
+
# One button, held: a second point would be a second press the widget never asked for.
|
|
949
|
+
return [{"kind": PixelAnswerKind.HOLD, "points": pts[:1]}]
|
|
699
950
|
if pts:
|
|
700
951
|
out.append({"kind": PixelAnswerKind.CLICK, "points": pts})
|
|
701
952
|
return out
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""The pointer solver: a captcha answered from ONE forward pass, never generated.
|
|
2
|
+
|
|
3
|
+
The board is shown to a Qwen3.5 model (still, or a video), optionally with the
|
|
4
|
+
element detector's findings drawn over it as thin numbered outlines and listed
|
|
5
|
+
in the prompt. Every question the answer needs (which action, how many clicks,
|
|
6
|
+
where each one lands, what to type, which moment of a video) has a marker
|
|
7
|
+
token in the prompt, and small heads read each marker's hidden state from the
|
|
8
|
+
prefill. The answer always carries points; it carries an element's number too
|
|
9
|
+
when the point lands on a numbered element.
|
|
10
|
+
|
|
11
|
+
layout the prompt, the questions and the packed decision vector
|
|
12
|
+
elements detector output -> numbered elements, and the element under a point
|
|
13
|
+
marks the outlines and numbers drawn on the board
|
|
14
|
+
heads the torch heads (training and serving share them)
|
|
15
|
+
detector the element detector (Mask R-CNN, ResNet-18 FPN)
|
|
16
|
+
vllm_plugin the heads inside vLLM's pooling runner
|
|
17
|
+
service board in, answer out: detector, marks, vLLM, decode
|
|
18
|
+
|
|
19
|
+
Only `layout`, `elements` and `marks` import without torch.
|
|
20
|
+
"""
|