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
|
@@ -1,15 +1,28 @@
|
|
|
1
|
-
"""Image in, actions out:
|
|
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
|
|
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
|
|
23
|
-
#
|
|
24
|
-
#
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
62
|
-
allowed = VENDOR_GRID_CELLS.get(
|
|
63
|
-
return
|
|
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
|
-
#
|
|
101
|
-
|
|
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
|
|
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
|
|
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[
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
137
|
-
"""Normalised 0-1 points become small boxes
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
188
|
-
"""
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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:
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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
|
-
|
|
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))
|