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
@@ -1,15 +1,28 @@
1
- """Image in, actions out: grid detection plus the model, for one still or one keyframe set."""
1
+ """Image in, actions out: the element detector plus the model, for one still or one recorded animation."""
2
2
 
3
- import math
4
3
  import os
5
4
  import sys
6
5
  import tempfile
6
+ from dataclasses import dataclass
7
7
  from pathlib import Path
8
- from typing import Any, List, Optional, Sequence, Tuple, Union
8
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
9
9
 
10
+ import numpy as np
10
11
  from PIL import Image
11
12
 
12
- from .action_types import CaptchaAction, ClickAction, DoneAction, DragAction, TypeAction, WaitAction
13
+ from .action_types import (
14
+ CaptchaAction,
15
+ ClickAction,
16
+ DoneAction,
17
+ DragAction,
18
+ HoldAction,
19
+ RefreshAction,
20
+ SlideAction,
21
+ TypeAction,
22
+ WaitAction,
23
+ )
24
+ from .clipvideo import clip_video, time_to_index
25
+ from .keyframes import KeyframeSet, extract_keyframes, number_for_source_index, write_frame, write_keyframes
13
26
  from .kinds import ActionKind, PixelAnswerKind, PromptFamily, RetryMode, Vendor
14
27
  from .planner import ActionPlanner
15
28
  from .tool_calls.find_checkbox import find_checkbox
@@ -19,13 +32,12 @@ DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
19
32
  # ~1.2% ≈ ±6px on a 512px challenge.
20
33
  _PIXEL_BOX_HALF = float(os.getenv("CAPTCHA_PIXEL_BOX_HALF", "0.012"))
21
34
 
22
- # The only cell counts a trained grid puzzle has, and which of them each named vendor ships. UNKNOWN stays
23
- # permissive in both directions: the driver reports it for GeeTest and Prosopo, and the offline grader defaults
24
- # to it, so narrowing it would silently stop scoring reCAPTCHA 4x4 in evaluation.
35
+ # The grid expert was trained on reCAPTCHA's 3x3 and 4x4 and hCaptcha's 3x3 and nothing else, so only those
36
+ # boards are asked as grids; every other vendor's grid goes to the pixel expert, which was trained on them. A
37
+ # caller that names no vendor (the CLI, a script) gets the expert for any 3x3 or 4x4, which is what those boards
38
+ # most often are.
25
39
  GRID_CELL_SHAPES = {9: (3, 3), 16: (4, 4)}
26
40
  VENDOR_GRID_CELLS: dict[Vendor, frozenset[int]] = {Vendor.HCAPTCHA: frozenset({9}), Vendor.RECAPTCHA: frozenset({9, 16})}
27
- # Every true grid measures 0.000 cells out of true; the nearest false lattice measured 0.128.
28
- _GRID_REGULARITY_TOL = 0.02
29
41
 
30
42
 
31
43
  def _debug(message: str) -> None:
@@ -37,30 +49,46 @@ class UnsupportedCaptchaError(Exception):
37
49
  pass
38
50
 
39
51
 
40
- def _lattice_irregularity(grid_boxes) -> Optional[float]:
41
- """How far the cells stray from a regular lattice, in cells; None when not a square count."""
42
- boxes = [tuple(b) for b in grid_boxes]
43
- n = len(boxes)
44
- k = int(round(math.sqrt(n))) if n else 0
45
- if k < 2 or k * k != n:
46
- return None
47
- widths = [b[2] - b[0] for b in boxes]
48
- heights = [b[3] - b[1] for b in boxes]
49
- mid_w, mid_h = sorted(widths)[n // 2], sorted(heights)[n // 2]
50
- if mid_w <= 0 or mid_h <= 0:
51
- return None
52
- rows = [boxes[i * k:(i + 1) * k] for i in range(k)]
53
- cols = [[boxes[r * k + c] for r in range(k)] for c in range(k)]
54
- row_skew = max(max(b[1] for b in r) - min(b[1] for b in r) for r in rows) / mid_h
55
- col_skew = max(max(b[0] for b in c) - min(b[0] for b in c) for c in cols) / mid_w
56
- size_spread = max((max(widths) - min(widths)) / mid_w, (max(heights) - min(heights)) / mid_h)
57
- return max(row_skew, col_skew, size_spread)
52
+ @dataclass
53
+ class AnimatedRecording:
54
+ """One recorded burst of an animated challenge, cut for the driver and for the model.
55
+
56
+ The keyframes belong to the driver whichever way the model is asked: they say how many steady screens the
57
+ board has, and they are the sibling screens the wait gate compares an answer against. The model is shown
58
+ either the keyframes (prompt generation 2) or the frames themselves as one video (generation 3 on).
59
+ """
60
+
61
+ frames: List[np.ndarray]
62
+ fps: float
63
+ keyframes: KeyframeSet
64
+ keyframe_paths: List[str]
65
+
66
+
67
+ def cut_recording(frames: Sequence[np.ndarray], fps: float, out_dir: str) -> AnimatedRecording:
68
+ """Slice `frames` into keyframes written to `out_dir`, keeping the frames for a video ask."""
69
+ kfset = extract_keyframes(frames, fps=float(fps))
70
+ paths = [str(p) for p in write_keyframes(kfset, out_dir, stem="challenge")]
71
+ return AnimatedRecording(frames=list(frames), fps=float(fps), keyframes=kfset, keyframe_paths=paths)
72
+
73
+
74
+ def _first_frame(path: str) -> Image.Image:
75
+ """The first picture of a GIF or video file, in RGB."""
76
+ import av
77
+
78
+ try:
79
+ if path.lower().endswith(".gif"):
80
+ with Image.open(path) as img:
81
+ return img.convert("RGB")
82
+ with av.open(path) as container:
83
+ return next(container.decode(video=0)).to_image()
84
+ except (OSError, av.FFmpegError, StopIteration) as exc:
85
+ raise ValueError(f"Could not read video frame from {path}") from exc
58
86
 
59
87
 
60
88
  def _grid_dims(n_cells: int, puzzle_source: Vendor = Vendor.UNKNOWN):
61
- shape = GRID_CELL_SHAPES.get(n_cells)
62
- allowed = VENDOR_GRID_CELLS.get(Vendor(puzzle_source))
63
- return None if shape is None or (allowed is not None and n_cells not in allowed) else shape
89
+ vendor = Vendor(puzzle_source)
90
+ allowed = frozenset(GRID_CELL_SHAPES) if vendor == Vendor.UNKNOWN else VENDOR_GRID_CELLS.get(vendor, frozenset())
91
+ return GRID_CELL_SHAPES[n_cells] if n_cells in allowed else None
64
92
 
65
93
 
66
94
  class CaptchaSolver:
@@ -97,15 +125,16 @@ class CaptchaSolver:
97
125
  return actions
98
126
  raise UnsupportedCaptchaError("Could not read the text captcha")
99
127
 
100
- # The animated path never runs find_grid: it false-positives on the header and footer bands of hCaptcha's click puzzles.
101
- grid_boxes = find_grid(image_path)
128
+ # Generation 4 never runs find_grid: the pointer server's element detector numbers a grid's cells along with
129
+ # everything else on the board, and its answer is points (and element numbers) like any other board's.
130
+ grid_boxes = [] if self.planner.prompts.pointer else find_grid(image_path)
102
131
  dims = _grid_dims(len(grid_boxes), puzzle_source) if grid_boxes else None
103
- if grid_boxes and dims and self._is_real_grid(image_path, grid_boxes):
132
+ if dims:
104
133
  return self._solve_grid(image_path, grid_boxes, dims[0], dims[1], retry_mode=retry_mode)
105
134
  if grid_boxes:
106
135
  _debug(f"find_grid returned {len(grid_boxes)} cells; not solving as a grid")
107
136
 
108
- if img_h < 400:
137
+ if not self.planner.prompts.pointer:
109
138
  checkbox = find_checkbox(image_path)
110
139
  if checkbox:
111
140
  x, y, w, h = checkbox
@@ -117,49 +146,117 @@ class CaptchaSolver:
117
146
  return actions
118
147
  raise UnsupportedCaptchaError("Cannot solve this kind of captcha")
119
148
 
120
- def solve_keyframes(self, keyframe_paths: Sequence[str]) -> List[Union[ClickAction, DragAction]]:
149
+ def solve_keyframes(self, keyframe_paths: Sequence[str]) -> List[CaptchaAction]:
121
150
  """Solve an animated challenge from its keyframes, in model order (frame 1 first)."""
122
151
  paths = [str(p) for p in keyframe_paths]
123
152
  if not paths:
124
153
  raise UnsupportedCaptchaError("no keyframes to solve from")
125
154
  with Image.open(paths[0]) as im:
126
155
  self._image_size = im.size
127
- actions = self._to_actions(self.planner.get_keyframe_actions(paths), keyframe_paths=paths)
156
+
157
+ def wait_for(a: dict) -> dict:
158
+ # An out-of-range frame is dropped, not clamped: clamping a 7 to 6 would invent an intent.
159
+ frame = a.get("frame")
160
+ if not isinstance(frame, int) or not 1 <= frame <= len(paths):
161
+ return {}
162
+ return {"frame": frame, "await_keyframe": paths[frame - 1]}
163
+
164
+ actions = self._to_actions(self._ask_planner(lambda: self.planner.get_keyframe_actions(paths)),
165
+ wait_for=wait_for)
128
166
  if actions:
129
167
  return actions
130
168
  raise UnsupportedCaptchaError("Cannot solve this animated captcha")
131
169
 
132
- def _solve_pixel(self, image_path: str, text_mode: bool = False) -> List[Union[ClickAction, DragAction]]:
170
+ def solve_animated(self, recording: AnimatedRecording) -> List[CaptchaAction]:
171
+ """Solve a recorded animation the way the model's prompt generation was trained to be asked."""
172
+ if not self.planner.prompts.sends_video:
173
+ return self.solve_keyframes(recording.keyframe_paths)
174
+ if not recording.frames:
175
+ raise UnsupportedCaptchaError("no frames to solve from")
176
+ height, width = recording.frames[0].shape[:2]
177
+ self._image_size = (width, height)
178
+ clip = clip_video(recording.frames, recording.fps, max_pixels=self.planner.video_max_pixels())
179
+ written: Dict[int, str] = {}
180
+
181
+ def wait_for(a: dict) -> dict:
182
+ t = a.get("t")
183
+ if t is None:
184
+ return {}
185
+ source = time_to_index(clip, t)
186
+ if source not in written:
187
+ written[source] = self._await_frame(recording, source)
188
+ return {"t": t, "await_keyframe": written[source]}
189
+
190
+ actions = self._to_actions(self._ask_planner(lambda: self.planner.get_video_actions(clip)), wait_for=wait_for)
191
+ if actions:
192
+ return actions
193
+ raise UnsupportedCaptchaError("Cannot solve this animated captcha")
194
+
195
+ @staticmethod
196
+ def _ask_planner(ask: Callable[[], List[dict]]) -> List[dict]:
197
+ """A prompt generation that cannot be asked this way is an unsupported puzzle, not a crash."""
198
+ try:
199
+ return ask()
200
+ except ValueError as exc:
201
+ raise UnsupportedCaptchaError(str(exc)) from exc
202
+
203
+ @staticmethod
204
+ def _await_frame(recording: AnimatedRecording, source: int) -> str:
205
+ """The wait gate's reference for a video answer: the recorded frame `t` names, in its screen's file.
206
+
207
+ It replaces the keyframe of the screen it belongs to, so the gate's sibling check still compares the
208
+ answer against the OTHER screens only, while the page is matched against the exact moment chosen.
209
+ """
210
+ by_number = {kf.number: path for kf, path in zip(recording.keyframes.keyframes, recording.keyframe_paths)}
211
+ path = by_number[number_for_source_index(recording.keyframes, source)]
212
+ write_frame(path, recording.frames[source])
213
+ return path
214
+
215
+ def _solve_pixel(self, image_path: str, text_mode: bool = False) -> List[CaptchaAction]:
133
216
  return self._to_actions(self.planner.get_pixel_actions(image_path, text_mode=text_mode))
134
217
 
218
+ def pointer_fallback_actions(self) -> List[List[CaptchaAction]]:
219
+ """The complete plans after the first, and a refresh; [] for a non-pointer model."""
220
+ return [self._to_actions(plan) for plan in self.planner.pointer_fallback_actions()]
221
+
135
222
  def _to_actions(self, raw_actions: List[dict],
136
- keyframe_paths: Optional[Sequence[str]] = None) -> List[Union[ClickAction, DragAction]]:
137
- """Normalised 0-1 points become small boxes; a slide is a drag with no source."""
223
+ wait_for: Optional[Callable[[dict], dict]] = None) -> List[CaptchaAction]:
224
+ """Normalised 0-1 points become small boxes. A slide with a handle is generation 3's; one without is
225
+ generation 2's sourceless drag.
226
+
227
+ `wait_for` maps an animated answer to the fields that tell the driver which moment to wait for.
228
+ """
138
229
  r = _PIXEL_BOX_HALF
230
+ wait_for = wait_for or (lambda a: {})
139
231
 
140
232
  def box(cx: float, cy: float) -> List[float]:
141
233
  return [min(max(v, 0.0), 1.0) for v in (cx - r, cy - r, cx + r, cy + r)]
142
234
 
143
- def wait_for(a: dict) -> dict:
144
- # An out-of-range frame is dropped, not clamped: clamping a 7 to 6 would invent an intent.
145
- frame = a.get("frame")
146
- if not keyframe_paths or not isinstance(frame, int) or not 1 <= frame <= len(keyframe_paths):
147
- return {}
148
- return {"frame": frame, "await_keyframe": str(keyframe_paths[frame - 1])}
149
-
150
- out: List[Union[ClickAction, DragAction]] = []
235
+ out: List[CaptchaAction] = []
151
236
  for a in raw_actions:
152
237
  kind = PixelAnswerKind(a["kind"])
153
238
  if kind == PixelAnswerKind.CLICK:
154
239
  boxes = [box(x, y) for (x, y) in a.get("points", [])]
155
- if boxes:
156
- out.append(ClickAction(action=ActionKind.CLICK, target_bounding_boxes=boxes, **wait_for(a)))
240
+ # Only generation 3 sends an empty click, and it means nothing on the board matches: submit as is.
241
+ out.append(ClickAction(action=ActionKind.CLICK, target_bounding_boxes=boxes, **wait_for(a)) if boxes
242
+ else DoneAction(action=ActionKind.DONE))
243
+ elif kind == PixelAnswerKind.SLIDE and "handle" in a:
244
+ out.append(SlideAction(action=ActionKind.SLIDE, handle_bounding_box=box(*a["handle"]),
245
+ source_bounding_box=box(*a["src"]), target_bounding_box=box(*a["dst"])))
157
246
  elif kind in (PixelAnswerKind.DRAG, PixelAnswerKind.SLIDE):
158
247
  src = box(*a["src"]) if kind == PixelAnswerKind.DRAG else None
159
248
  out.append(DragAction(action=ActionKind.DRAG, source_bounding_box=src,
160
249
  target_bounding_box=box(*a["dst"]), **wait_for(a)))
161
250
  elif kind == PixelAnswerKind.TYPE:
162
251
  out.append(TypeAction(action=ActionKind.TYPE, text=a["text"]))
252
+ elif kind == PixelAnswerKind.HOLD and a.get("points"):
253
+ fill = ({"fill_from_bounding_box": box(*a["src"]), "fill_to_bounding_box": box(*a["dst"])}
254
+ if a.get("src") and a.get("dst") else {})
255
+ out.append(HoldAction(action=ActionKind.HOLD, target_bounding_box=box(*a["points"][0]), **fill))
256
+ elif kind == PixelAnswerKind.REFRESH:
257
+ points = a.get("points")
258
+ out.append(RefreshAction(action=ActionKind.REFRESH,
259
+ target_bounding_box=box(*points[0]) if points else None))
163
260
  return out
164
261
 
165
262
  def solveVideo(self, *args, **kwargs):
@@ -168,53 +265,29 @@ class CaptchaSolver:
168
265
  def _materialize_image(self, media_path: str) -> str:
169
266
  """A PNG path for `media_path`; a video contributes its first frame."""
170
267
  if any(media_path.lower().endswith(ext) for ext in (".mp4", ".gif", ".avi", ".webm")):
171
- import cv2
172
-
173
- cap = cv2.VideoCapture(media_path)
174
- ok, frame = cap.read()
175
- cap.release()
176
- if not ok:
177
- raise ValueError(f"Could not read video frame from {media_path}")
178
- self._image_size = (frame.shape[1], frame.shape[0])
268
+ frame = _first_frame(media_path)
269
+ self._image_size = frame.size
179
270
  with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tf:
180
- cv2.imwrite(tf.name, frame)
271
+ frame.save(tf, "PNG")
181
272
  self._temp_files.append(tf.name)
182
273
  return tf.name
183
274
  with Image.open(media_path) as img:
184
275
  self._image_size = img.size
185
276
  return media_path
186
277
 
187
- def _is_real_grid(self, image_path: str, grid_boxes: List[Tuple[int, int, int, int]]) -> bool:
188
- """Reject a lattice that is sheared, a sprite board on one background, or a flat image."""
189
- irregularity = _lattice_irregularity(grid_boxes)
190
- if irregularity is not None and irregularity > _GRID_REGULARITY_TOL:
191
- _debug(f"_is_real_grid: cells are {irregularity:.3f} of a cell out of true → reject")
192
- return False
193
- try:
194
- import cv2
195
- import numpy as np
196
-
197
- img = cv2.imread(image_path)
198
- if img is None:
199
- return True
200
- rois = [img[y1:y2, x1:x2].reshape(-1, 3) for (x1, y1, x2, y2) in grid_boxes
201
- if x2 > x1 and y2 > y1 and img[y1:y2, x1:x2].size]
202
- if not rois:
203
- return False
204
- # Real tiles are independent photographs; a sprite board shares one colour behind every cell.
205
- if len(rois) >= 4 and float(np.mean(np.std(np.array([np.median(r, axis=0) for r in rois]), axis=0))) < 6.0:
206
- _debug("_is_real_grid: every cell shares one background colour → reject")
207
- return False
208
- if float(np.mean([np.mean(np.std(r, axis=0)) for r in rois])) < 25.0:
209
- _debug("_is_real_grid: overall mean stddev too low → reject")
210
- return False
211
- return True
212
- except Exception as e:
213
- _debug(f"_is_real_grid check errored ({e}); accepting grid.")
214
- return True
278
+ def _cells_of(self, click: dict, grid_boxes: List[Tuple[int, int, int, int]]) -> List[int]:
279
+ """The 1-based cells a generation-3 click names: its ids when any is a cell, else where its points land."""
280
+ n = len(grid_boxes)
281
+ ids = [i for i in click.get("ids", []) if 1 <= i <= n]
282
+ if not ids:
283
+ img_w, img_h = self._image_size
284
+ ids = [c for c in (next((k + 1 for k, (x1, y1, x2, y2) in enumerate(grid_boxes)
285
+ if x1 <= x * img_w <= x2 and y1 <= y * img_h <= y2), None)
286
+ for (x, y) in click.get("points", [])) if c is not None]
287
+ return list(dict.fromkeys(ids))
215
288
 
216
289
  def _solve_grid(self, image_path: str, grid_boxes: List[Tuple[int, int, int, int]], rows: int, cols: int,
217
- retry_mode: Optional[RetryMode] = None) -> Union[ClickAction, DoneAction, WaitAction]:
290
+ retry_mode: Optional[RetryMode] = None) -> Union[CaptchaAction, List[CaptchaAction]]:
218
291
  try:
219
292
  cv_selected, cv_loading = detect_selected_cells(image_path, grid_boxes)
220
293
  except Exception as e:
@@ -226,7 +299,15 @@ class CaptchaSolver:
226
299
  overlay_path = tf.name
227
300
  self._temp_files.append(overlay_path)
228
301
  get_numbered_grid_overlay(image_path, grid_boxes, output_path=overlay_path)
229
- selected = self.planner.get_grid_selection(overlay_path, rows=rows, cols=cols, retry_mode=retry_mode)
302
+ if self.planner.prompts.general is not None:
303
+ raw = self.planner.get_general_actions(overlay_path)
304
+ if not raw:
305
+ raise UnsupportedCaptchaError("the model's answer to the grid could not be read")
306
+ if raw[0]["kind"] != PixelAnswerKind.CLICK:
307
+ return self._to_actions(raw)
308
+ selected = self._cells_of(raw[0], grid_boxes)
309
+ else:
310
+ selected = self.planner.get_grid_selection(overlay_path, rows=rows, cols=cols, retry_mode=retry_mode)
230
311
 
231
312
  final = []
232
313
  for n in selected:
@@ -1,7 +1,10 @@
1
1
  """Has the widget painted its puzzle yet, or are we photographing the rebuild hole?
2
2
 
3
- Measured on gt4.geetest.com's slide demo (2026-09-12): 6 of 52 requests carried a board with under 5% ink and
4
- every one came back dead centre. The settle gate cannot catch it because a blank panel is perfectly still.
3
+ First seen on GeeTest's slide demo (2026-09-12): 6 of 52 requests carried a board with under 5% ink. The settle gate
4
+ cannot catch it because a blank panel is perfectly still. Measured 2026-11-02 over 1,557 real captures of every
5
+ vendor: no painted board read as blank, and no board with its centre blanked read as painted. Asking the element
6
+ detector instead was tried and lost: it missed every text captcha and saw items at the edges of a blank centre, at
7
+ 0.2-0.4 s a call against this check's ~1 ms.
5
8
  Structure is measured, not ink, because geetest_v4_svg is line art on white. A caller must treat False as
6
9
  "wait a moment longer", never "give up": a gate that can refuse to ever take a picture is worse than the blank one.
7
10
  """
@@ -10,7 +13,7 @@ from __future__ import annotations
10
13
 
11
14
  from typing import Optional, Sequence
12
15
 
13
- # Trimmed in from every edge: the caption and icon row are chrome that paints early, on a blank board too.
16
+ # Trimmed in from every edge: a caption, an icon row or a slider track is chrome that paints early, on a blank board too.
14
17
  CENTRE_BOX = (0.15, 0.25, 0.85, 0.75)
15
18
 
16
19
  # Below this is JPEG noise and anti-aliasing on a flat fill.
@@ -21,12 +24,14 @@ TEXTURE_FLOOR = 0.015
21
24
 
22
25
 
23
26
  def centre_texture(path: str, box: Optional[Sequence[float]] = None) -> Optional[float]:
24
- import cv2
25
27
  import numpy as np
26
28
 
27
- img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
28
- if img is None:
29
+ from ..keyframes import read_frame, to_gray
30
+
31
+ frame = read_frame(path)
32
+ if frame is None:
29
33
  return None
34
+ img = to_gray(frame)
30
35
  h, w = img.shape[:2]
31
36
  b = tuple(box) if box is not None else CENTRE_BOX
32
37
  x1, y1 = int(b[0] * w), int(b[1] * h)
@@ -0,0 +1,152 @@
1
+ """Follow a press-and-hold widget's fill, and say when to let go.
2
+
3
+ The model names three points: what to press, where the fill starts and where it should be released. Holding makes
4
+ something grow from `start` toward `end` (a bar filling, a ring closing along its chord, a pill colouring in), so
5
+ the shot at the press and a later shot differ along that line up to the fill's leading edge, and nowhere past it.
6
+
7
+ `fill_step` reads one look: the leading edge in pixels along start->end, and whether to release now. Nothing about
8
+ the widget is read from its DOM, and nothing is kept between calls: the caller passes back what earlier looks
9
+ returned, so the CLI worker can serve the JS port the same way.
10
+
11
+ All coordinates are SHOT pixels; the caller converts to CSS with its own device scale.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from typing import Any, Dict, Optional, Sequence, Tuple, Union
18
+
19
+ import numpy as np
20
+ from PIL import Image as PILImage
21
+ from scipy import ndimage
22
+
23
+ Image = Union[str, np.ndarray]
24
+ Point = Tuple[float, float]
25
+
26
+ # A pixel the fill has reached differs from the press in at least one channel by this much; a re-composited page
27
+ # shot jitters by a few levels at edges, and the 3x3 blur takes most of that out.
28
+ _CHANGED = 24.0
29
+ # Half-width of the band read across the line: the model's points are a few pixels off the bar's axis.
30
+ _BAND_PX = 2
31
+ # The edge is the furthest changed sample whose last _RUN_PX samples are mostly changed: a lone flicker past the
32
+ # fill (a cursor, a badge) is not the fill.
33
+ _RUN_PX = 6
34
+ _RUN_SHARE = 0.6
35
+ # Read this far past `end` too, so a fill that overshot between looks is seen as past, not as stalled at it.
36
+ _PAST = 0.15
37
+
38
+
39
+ # A timed release is aimed this long after the predicted arrival. Early and late do not cost the same: when `end` is
40
+ # where the fill stops (HUMAN's pill, a ring closing) the widget completes on its own and a release a few ms early
41
+ # starts it over, while a late one is free; on a mark mid-bar this is a few pixels of the mark's tolerance.
42
+ _LATE_S = 0.06
43
+ # The pace is fitted over this many looks: two alone turn a pixel of edge noise on a slow fill into a release a
44
+ # whole tolerance off, and a widget that eases changes pace over more than a few.
45
+ _PACE_LOOKS = 4
46
+
47
+
48
+ class FillError(ValueError):
49
+ """The line cannot be read: a degenerate segment or shots of different sizes."""
50
+
51
+
52
+ def _rgb(img: Image) -> np.ndarray:
53
+ """The shot's three colour channels, each softened by a 3x3 binomial blur; their order does not matter here."""
54
+ if isinstance(img, str):
55
+ try:
56
+ with PILImage.open(img) as f:
57
+ a = np.asarray(f.convert("RGB"))
58
+ except OSError as exc:
59
+ raise FillError(f"cannot read {img!r}") from exc
60
+ else:
61
+ a = np.asarray(img)
62
+ if a.ndim == 2:
63
+ a = np.repeat(a[..., None], 3, axis=2)
64
+ out = a[..., :3].astype(np.float32)
65
+ for axis in (0, 1):
66
+ out = ndimage.correlate1d(out, [0.25, 0.5, 0.25], axis=axis, mode="mirror")
67
+ return out
68
+
69
+
70
+ def profile(first: Image, current: Image, start: Point, end: Point) -> Tuple[np.ndarray, float]:
71
+ """How much each pixel step along start->end (and _PAST beyond) changed since the press, and the line's length."""
72
+ a, b = _rgb(first), _rgb(current)
73
+ if a.shape != b.shape:
74
+ raise FillError(f"shots differ in size: {a.shape[:2]} vs {b.shape[:2]}")
75
+ h, w = a.shape[:2]
76
+ (x0, y0), (x1, y1) = (float(v) for v in start), (float(v) for v in end)
77
+ length = math.hypot(x1 - x0, y1 - y0)
78
+ if length < 2.0:
79
+ raise FillError("the fill's start and end are the same point")
80
+ ux, uy = (x1 - x0) / length, (y1 - y0) / length
81
+ steps = np.arange(int(math.ceil(length * (1 + _PAST))) + 1, dtype=np.float32)
82
+ diff = np.abs(a - b).max(axis=2)
83
+ across = []
84
+ for o in range(-_BAND_PX, _BAND_PX + 1):
85
+ xs = np.clip(np.round(x0 + steps * ux - o * uy), 0, w - 1).astype(int)
86
+ ys = np.clip(np.round(y0 + steps * uy + o * ux), 0, h - 1).astype(int)
87
+ across.append(diff[ys, xs])
88
+ # The median across the band: the bar's edge running along one side of the line is not the fill.
89
+ return np.median(np.stack(across), axis=0), length
90
+
91
+
92
+ def _solid(changes: np.ndarray) -> np.ndarray:
93
+ """The changed steps whose last _RUN_PX steps are mostly changed too."""
94
+ on = changes > _CHANGED
95
+ run = np.convolve(on.astype(np.float32), np.ones(_RUN_PX, np.float32), mode="full")[:len(on)]
96
+ count = np.minimum(np.arange(1, len(on) + 1), _RUN_PX)
97
+ return on & (run / count >= _RUN_SHARE)
98
+
99
+
100
+ def leading_edge(changes: np.ndarray) -> Optional[float]:
101
+ """The furthest step the fill has reached, or None when nothing along the line has changed."""
102
+ idx = np.flatnonzero(_solid(changes))
103
+ return float(idx[-1]) if len(idx) else None
104
+
105
+
106
+ def trailing_edge(changes: np.ndarray) -> Optional[float]:
107
+ """Where the fill was when the reference was shot: the changed run starts there, because what had already filled
108
+ by then looks the same in both shots."""
109
+ idx = np.flatnonzero(_solid(changes[::-1]))
110
+ return float(len(changes) - 1 - idx[-1]) if len(idx) else None
111
+
112
+
113
+ def _pace(looks: Sequence[Tuple[float, float]]) -> Optional[float]:
114
+ """Pixels per second, least squares over (t, edge) looks; None from fewer than two."""
115
+ if len(looks) < 2:
116
+ return None
117
+ ts, es = np.array(looks, dtype=np.float64).T
118
+ if np.ptp(ts) <= 0:
119
+ return None
120
+ return float(np.polyfit(ts, es, 1)[0])
121
+
122
+
123
+ def fill_step(first: Image, current: Image, *, start: Point, end: Point, t: float,
124
+ history: Sequence[Dict[str, Any]] = (), poll_s: float = 0.25, tol: float = 2.0,
125
+ first_t: Optional[float] = None) -> Dict[str, Any]:
126
+ """One look at a held widget: how far its fill has come, and whether to release it now.
127
+
128
+ `t` is the caller's clock (seconds) when this shot was taken and `history` the `observation` of each earlier
129
+ look, oldest first; `first_t` is when `first` was shot, which gives the first look a pace of its own (see
130
+ `trailing_edge`) so a mark close to the start is not passed while two looks are gathered. `release` is true once the edge is within `tol` of `end` or past it, or when at its measured
131
+ pace the fill reaches `end` before the next look, `poll_s` away. `release_in` is then how long after `t` to let
132
+ go, a little after the arrival (`_LATE_S`): releasing on the look instead lands up to a whole look's travel past
133
+ the mark, and a fast fill covers more than a mark's tolerance in one.
134
+ """
135
+ changes, length = profile(first, current, start, end)
136
+ edge = leading_edge(changes)
137
+ obs = {"t": float(t), "edge": edge}
138
+ out: Dict[str, Any] = {"edge": edge, "length": length, "observation": obs, "release": False, "release_in": None,
139
+ "speed": None}
140
+ if edge is None:
141
+ return out
142
+ short = length - edge
143
+ seen = [(float(o["t"]), float(o["edge"])) for o in history if o.get("edge") is not None and float(o["t"]) < t]
144
+ back = trailing_edge(changes) if first_t is not None and float(first_t) < t else None
145
+ if back is not None and back < edge and all(float(first_t) < ts for ts, _ in seen):
146
+ seen = [(float(first_t), back)] + seen
147
+ out["speed"] = _pace(seen[-(_PACE_LOOKS - 1):] + [(t, edge)])
148
+ if short <= tol:
149
+ out["release"], out["release_in"] = True, 0.0
150
+ elif out["speed"] and out["speed"] > 0 and short / out["speed"] <= poll_s:
151
+ out["release"], out["release_in"] = True, short / out["speed"] + _LATE_S
152
+ return out
@@ -1,66 +1,21 @@
1
- """Where the driver clicks first, before any model runs. Neither a false positive nor a false negative raises."""
1
+ """Where the driver clicks first, before any model runs: the tick box of a vendor's
2
+ checkbox widget, as the element detector's `checkbox` class finds it. Neither a
3
+ false positive nor a false negative raises."""
2
4
 
3
- import cv2
4
- import numpy as np
5
5
  from typing import Optional, Tuple
6
6
 
7
- def find_checkbox(image_path: str) -> Optional[Tuple[int, int, int, int]]:
8
- img = cv2.imread(image_path)
9
- if img is None:
10
- return None
11
-
12
- gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
13
-
14
- _, binary = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV)
15
-
16
- contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
17
-
18
- height, width = img.shape[:2]
19
- image_area = width * height
20
-
21
- candidates = []
22
-
23
- for cnt in contours:
24
- x, y, w, h = cv2.boundingRect(cnt)
25
- area = w * h
26
- aspect_ratio = float(w) / h
27
-
28
-
29
- if not (0.8 < aspect_ratio < 1.2):
30
- continue
7
+ #: Measured on widgets in a language the detector never trained on: every one of
8
+ #: 96 tick boxes scored >= 0.997, a ticked or passed widget 0, and the highest
9
+ #: `checkbox` score on 600 real puzzle boards was 0.145. 0.6 sits far from both.
10
+ MIN_SCORE = 0.6
31
11
 
32
- # Below 20px a glyph is indistinguishable from a box.
33
- if w < 20 or h < 20:
34
- continue
35
12
 
36
- # Above 5% of the image it is a panel or the challenge frame, not the checkbox inside one.
37
- if not (0.001 * image_area < area < 0.05 * image_area):
38
- continue
39
-
40
- # Every checkbox widget puts its box at the left; a square on the right is a logo or a badge.
41
- if x + w / 2 > width / 2:
42
- continue
43
-
44
- contour_area = cv2.contourArea(cnt)
45
- extent = contour_area / area
46
- if extent < 0.8:
47
- continue
48
-
49
- # A checkbox is empty inside; without this the first tile of a 3x3 grid gets clicked as the widget.
50
- roi = gray[y+5:y+h-5, x+5:x+w-5]
51
- if roi.size > 0:
52
- std_dev = np.std(roi)
53
- if std_dev > 35.0:
54
- continue
55
-
56
- candidates.append((x, y, w, h))
57
-
58
- # Many candidates means a grid, not a checkbox page.
59
- if len(candidates) > 2:
60
- return None
13
+ def find_checkbox(image_path: str) -> Optional[Tuple[int, int, int, int]]:
14
+ """(x, y, w, h) of the most confident tick box in pixels, or None."""
15
+ from ..pointer.detector import detect_file
61
16
 
62
- if not candidates:
17
+ boxes = [d for d in detect_file(image_path) if d["class"] == "checkbox" and d["score"] >= MIN_SCORE]
18
+ if not boxes:
63
19
  return None
64
-
65
- best_candidate = max(candidates, key=lambda c: c[2] * c[3])
66
- return best_candidate
20
+ x0, y0, x1, y1 = max(boxes, key=lambda d: d["score"])["box"]
21
+ return int(round(x0)), int(round(y0)), int(round(x1 - x0)), int(round(y1 - y0))