captchakraken 3.2.1 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -9
- package/LICENSE +165 -145
- package/NOTICE +4 -4
- package/README.md +10 -11
- package/dist/cli-invocation.d.ts +6 -3
- package/dist/cli-invocation.js +26 -10
- package/dist/humanize.d.ts +37 -1
- package/dist/humanize.js +143 -9
- package/dist/index.d.ts +13 -12
- package/dist/index.js +25 -33
- package/dist/kinds.d.ts +15 -2
- package/dist/kinds.js +12 -4
- package/dist/playwright-types.d.ts +3 -0
- package/dist/selectors.d.ts +93 -11
- package/dist/selectors.js +226 -116
- package/dist/solver.d.ts +92 -12
- package/dist/solver.js +562 -203
- package/dist/types.d.ts +53 -8
- package/dist/verdicts.d.ts +3 -0
- package/dist/verdicts.js +6 -0
- package/package.json +4 -4
- package/python/AGENTS.md +7 -11
- package/python/LICENSE +165 -145
- package/python/NOTICE +4 -4
- package/python/README.md +10 -9
- package/python/pyproject.toml +27 -13
- package/python/src/captchakraken/__init__.py +43 -46
- package/python/src/captchakraken/action_types.py +32 -1
- package/python/src/captchakraken/captchakraken.config.json +416 -0
- package/python/src/captchakraken/cli.py +66 -30
- package/python/src/captchakraken/clipvideo.py +258 -0
- package/python/src/captchakraken/humanize.py +141 -10
- package/python/src/captchakraken/image_processor.py +3 -7
- package/python/src/captchakraken/keyframes.py +96 -14
- package/python/src/captchakraken/kinds.py +14 -2
- package/python/src/captchakraken/models.json +28 -3
- package/python/src/captchakraken/page_solver.py +595 -251
- package/python/src/captchakraken/planner.py +298 -47
- package/python/src/captchakraken/pointer/__init__.py +20 -0
- package/python/src/captchakraken/pointer/detector.py +333 -0
- package/python/src/captchakraken/pointer/elements.py +273 -0
- package/python/src/captchakraken/pointer/heads.py +163 -0
- package/python/src/captchakraken/pointer/layout.py +319 -0
- package/python/src/captchakraken/pointer/marks.py +184 -0
- package/python/src/captchakraken/pointer/server.py +146 -0
- package/python/src/captchakraken/pointer/service.py +553 -0
- package/python/src/captchakraken/pointer/vllm_plugin.py +109 -0
- package/python/src/captchakraken/prompts.py +111 -34
- package/python/src/captchakraken/selectors.py +221 -135
- package/python/src/captchakraken/server_manager.py +18 -0
- package/python/src/captchakraken/solver.py +169 -88
- package/python/src/captchakraken/tool_calls/board_painted.py +11 -6
- package/python/src/captchakraken/tool_calls/fill_tracker.py +152 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +14 -59
- package/python/src/captchakraken/tool_calls/find_grid.py +273 -1191
- package/python/src/captchakraken/tool_calls/slide_tracker.py +1102 -0
- package/python/src/captchakraken/verdicts.py +16 -0
- package/dist/slide-geometry.d.ts +0 -6
- package/dist/slide-geometry.js +0 -38
- package/python/src/captchakraken/tool_calls/track_piece.py +0 -120
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Turn a recorded captcha clip into the frames the model watches as video.
|
|
2
|
+
|
|
3
|
+
Models on prompt generation 3 and later see an animated captcha through the video pathway rather than as
|
|
4
|
+
numbered stills: every two consecutive frames share one temporal patch, so motion reaches the vision encoder
|
|
5
|
+
directly. The frames travel as a JPEG sequence (`data:video/jpeg;base64,f0,f1,...`), so no codec is involved
|
|
6
|
+
and the server does not resample them. The model labels its answer with the time printed before a frame
|
|
7
|
+
pair (`"t": 1.3`), and `time_to_index` turns that back into the recorded frame the driver waits for.
|
|
8
|
+
|
|
9
|
+
A verbatim port of the module the model was trained with: if this copy trimmed, sampled or sized a clip
|
|
10
|
+
differently, the time the model answers with would name a different moment than the one it watched.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import io
|
|
16
|
+
import math
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import List, Optional, Sequence, Tuple
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
from PIL import Image
|
|
22
|
+
|
|
23
|
+
from .keyframes import to_gray
|
|
24
|
+
|
|
25
|
+
#: Bump when the cutting LOGIC changes in a way the constants below do not show;
|
|
26
|
+
#: cached frame sets carry it and are re-cut on mismatch (`clips.materialize_video`).
|
|
27
|
+
CUT_VERSION = 3
|
|
28
|
+
FPS = 5.0
|
|
29
|
+
MAX_FRAMES = 32
|
|
30
|
+
JPEG_QUALITY = 90
|
|
31
|
+
SIZE_FACTOR = 64
|
|
32
|
+
TEMPORAL_PATCH = 2
|
|
33
|
+
LOOP_RATIO = 0.5
|
|
34
|
+
LOOP_MIN_PERIOD_S = 0.6
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ClipVideo:
|
|
39
|
+
frames: List[np.ndarray]
|
|
40
|
+
source_indices: List[int]
|
|
41
|
+
source_fps: float
|
|
42
|
+
period: Optional[int] = None
|
|
43
|
+
fps: float = FPS
|
|
44
|
+
timestamps: List[float] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
if not self.timestamps:
|
|
48
|
+
self.timestamps = pair_timestamps(len(self.frames), self.fps)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def frame_size(height: int, width: int, max_pixels: int) -> Tuple[int, int]:
|
|
52
|
+
"""(H, W): multiples of SIZE_FACTOR, area <= max_pixels.
|
|
53
|
+
|
|
54
|
+
The largest such frame whose aspect is within ~10% of the clip's; the closest
|
|
55
|
+
aspect only when none is. Most area first, because a 64-grid rarely hits the
|
|
56
|
+
exact aspect and a perfect-aspect frame half the size is a worse picture.
|
|
57
|
+
"""
|
|
58
|
+
aspect = height / width
|
|
59
|
+
cands = [(h, w) for h in range(SIZE_FACTOR, 4097, SIZE_FACTOR)
|
|
60
|
+
for w in range(SIZE_FACTOR, 4097, SIZE_FACTOR) if h * w <= max_pixels]
|
|
61
|
+
if not cands:
|
|
62
|
+
return SIZE_FACTOR, SIZE_FACTOR
|
|
63
|
+
err = {c: abs(math.log((c[0] / c[1]) / aspect)) for c in cands}
|
|
64
|
+
close = [c for c in cands if err[c] <= 0.1]
|
|
65
|
+
if close:
|
|
66
|
+
return max(close, key=lambda c: (c[0] * c[1], -err[c]))
|
|
67
|
+
return min(cands, key=lambda c: (err[c], -c[0] * c[1]))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def find_period(frames: Sequence[np.ndarray], source_fps: float) -> Optional[int]:
|
|
71
|
+
"""The period P (in source frames) at which the WHOLE clip repeats, or None.
|
|
72
|
+
|
|
73
|
+
The frame is split into 8x8 blocks. For every block that moves, frames P apart
|
|
74
|
+
must differ by less than LOOP_RATIO of what neighbouring frames differ by in
|
|
75
|
+
that block. Requiring every moving block (not the frame average) matters: one
|
|
76
|
+
object out of step with the others means the clip does NOT loop, and trimming
|
|
77
|
+
it would lose that object's other phase. A false "no loop" only costs tokens.
|
|
78
|
+
"""
|
|
79
|
+
blocks = 8
|
|
80
|
+
g = np.stack([resize_linear(to_gray(f), 128, 128).astype(np.float32) for f in frames])
|
|
81
|
+
n = len(g)
|
|
82
|
+
|
|
83
|
+
def per_block(d: np.ndarray) -> np.ndarray:
|
|
84
|
+
return d.reshape(d.shape[0], blocks, 128 // blocks, blocks, 128 // blocks).mean((2, 4)).mean(0)
|
|
85
|
+
|
|
86
|
+
step = per_block(np.abs(np.diff(g, axis=0)))
|
|
87
|
+
active = step > max(0.25 * float(step.max()), 0.3)
|
|
88
|
+
if not active.any():
|
|
89
|
+
return None
|
|
90
|
+
best_p, best_r = None, LOOP_RATIO
|
|
91
|
+
for p in range(max(2, int(round(LOOP_MIN_PERIOD_S * source_fps))), n - 2):
|
|
92
|
+
r = float((per_block(np.abs(g[p:] - g[:-p]))[active] / step[active]).max())
|
|
93
|
+
if r < best_r:
|
|
94
|
+
best_p, best_r = p, r
|
|
95
|
+
return best_p
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def pair_timestamps(n_frames: int, fps: float = FPS) -> List[float]:
|
|
99
|
+
"""The `<x.x seconds>` Qwen prints before each frame pair, as floats."""
|
|
100
|
+
n = n_frames + (n_frames % TEMPORAL_PATCH)
|
|
101
|
+
return [round((2 * k + 0.5) / fps, 1) for k in range(n // TEMPORAL_PATCH)]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def sample_indices(n_source: int, source_fps: float, period: Optional[int]) -> List[int]:
|
|
105
|
+
window = period if period else n_source
|
|
106
|
+
step = source_fps / FPS
|
|
107
|
+
idx = [int(round(i * step)) for i in range(int(math.floor((window - 1) / step)) + 1)]
|
|
108
|
+
idx = [i for i in idx if i < window] or [0]
|
|
109
|
+
if len(idx) > MAX_FRAMES:
|
|
110
|
+
idx = [int(round(x)) for x in np.linspace(0, window - 1, MAX_FRAMES)]
|
|
111
|
+
if len(idx) % TEMPORAL_PATCH:
|
|
112
|
+
nxt = min(idx[-1] + max(1, int(round(step))), n_source - 1)
|
|
113
|
+
idx.append(nxt)
|
|
114
|
+
return idx
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def clip_video(frames: Sequence[np.ndarray], source_fps: float, *, max_pixels: int) -> ClipVideo:
|
|
118
|
+
"""The frames the model watches, resized and in order, plus where each came from."""
|
|
119
|
+
if not frames:
|
|
120
|
+
raise ValueError("empty clip")
|
|
121
|
+
source_fps = float(source_fps) if source_fps and source_fps > 0 else 10.0
|
|
122
|
+
period = find_period(frames, source_fps) if len(frames) > 4 else None
|
|
123
|
+
idx = sample_indices(len(frames), source_fps, period)
|
|
124
|
+
h, w = frame_size(frames[0].shape[0], frames[0].shape[1], max_pixels)
|
|
125
|
+
out = [resize_area(frames[i], w, h) for i in idx]
|
|
126
|
+
return ClipVideo(frames=out, source_indices=idx, source_fps=source_fps, period=period)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# The resizes below reproduce OpenCV's uint8 INTER_LINEAR and INTER_AREA bit for bit, because the model was
|
|
130
|
+
# trained on frames OpenCV cut: the same pixels in means the same period found and the same picture watched.
|
|
131
|
+
|
|
132
|
+
def _linear_taps(src: int, dst: int, area: bool,
|
|
133
|
+
clamp: bool) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
134
|
+
"""Along one axis: the two source pixels each output pixel blends, and their 11-bit weights.
|
|
135
|
+
|
|
136
|
+
`area` is INTER_AREA's upscaling rule. Only the horizontal pass clamps its weight at the edges; the vertical
|
|
137
|
+
pass blends the clamped edge row with itself, whose rounded weights need not sum to 2048.
|
|
138
|
+
"""
|
|
139
|
+
inv = dst / src
|
|
140
|
+
d = np.arange(dst)
|
|
141
|
+
if area:
|
|
142
|
+
s = np.floor(d * (1.0 / inv)).astype(np.int64)
|
|
143
|
+
f = ((d + 1) - (s + 1) * inv).astype(np.float32)
|
|
144
|
+
f = np.where(f <= 0, np.float32(0), f - np.floor(f))
|
|
145
|
+
else:
|
|
146
|
+
f = ((d + 0.5) * (1.0 / inv) - 0.5).astype(np.float32)
|
|
147
|
+
s = np.floor(f).astype(np.int64)
|
|
148
|
+
f = f - s.astype(np.float32)
|
|
149
|
+
if clamp:
|
|
150
|
+
edge = (s < 0) | (s >= src - 1)
|
|
151
|
+
f[edge] = 0
|
|
152
|
+
s = np.clip(s, 0, src - 1)
|
|
153
|
+
w1 = np.rint(f * np.float32(2048)).astype(np.int32)
|
|
154
|
+
w0 = np.rint((np.float32(1) - f) * np.float32(2048)).astype(np.int32)
|
|
155
|
+
return np.clip(s, 0, src - 1), np.clip(s + 1, 0, src - 1), w0, w1
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def resize_linear(img: np.ndarray, w: int, h: int, area: bool = False) -> np.ndarray:
|
|
159
|
+
"""A uint8 image resized to (w, h) by OpenCV's fixed-point bilinear, with its SIMD rounding."""
|
|
160
|
+
x0, x1, a0, a1 = _linear_taps(img.shape[1], w, area, clamp=True)
|
|
161
|
+
y0, y1, b0, b1 = _linear_taps(img.shape[0], h, area, clamp=False)
|
|
162
|
+
across = (slice(None),) + (None,) * (img.ndim - 2)
|
|
163
|
+
down = (slice(None),) + (None,) * (img.ndim - 1)
|
|
164
|
+
s = img.astype(np.int32)
|
|
165
|
+
row = (np.take(s, x0, 1) * a0[across] + np.take(s, x1, 1) * a1[across]) >> 4
|
|
166
|
+
out = (((np.take(row, y0, 0) * b0[down]) >> 16) + ((np.take(row, y1, 0) * b1[down]) >> 16) + 2) >> 2
|
|
167
|
+
return out.astype(np.uint8)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _area_taps(src: int, dst: int) -> Tuple[np.ndarray, np.ndarray]:
|
|
171
|
+
"""Along one axis: the source pixels each output pixel averages and their float32 weights, in
|
|
172
|
+
OpenCV's order (padded with zero weights), since float32 sums depend on it."""
|
|
173
|
+
scale = src / dst
|
|
174
|
+
taps = []
|
|
175
|
+
for d in range(dst):
|
|
176
|
+
f1 = d * scale
|
|
177
|
+
f2 = f1 + scale
|
|
178
|
+
s2 = min(math.floor(f2), src - 1)
|
|
179
|
+
s1 = min(math.ceil(f1), s2)
|
|
180
|
+
cell = min(scale, src - f1)
|
|
181
|
+
t = [(s1 - 1, (s1 - f1) / cell)] if s1 - f1 > 1e-3 else []
|
|
182
|
+
t += [(x, 1 / cell) for x in range(s1, s2)]
|
|
183
|
+
t += [(s2, min(min(f2 - s2, 1.0), cell) / cell)] if f2 - s2 > 1e-3 else []
|
|
184
|
+
taps.append(t)
|
|
185
|
+
k = max(map(len, taps))
|
|
186
|
+
idx = np.array([[x for x, _ in t] + [0] * (k - len(t)) for t in taps])
|
|
187
|
+
wt = np.array([[a for _, a in t] + [0.0] * (k - len(t)) for t in taps], dtype=np.float32)
|
|
188
|
+
return idx, wt
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _area_pass(img: np.ndarray, axis: int, dst: int) -> np.ndarray:
|
|
192
|
+
idx, wt = _area_taps(img.shape[axis], dst)
|
|
193
|
+
shape = [1] * img.ndim
|
|
194
|
+
shape[axis] = dst
|
|
195
|
+
out = np.take(img, idx[:, 0], axis) * wt[:, 0].reshape(shape)
|
|
196
|
+
for j in range(1, idx.shape[1]):
|
|
197
|
+
out = out + np.take(img, idx[:, j], axis) * wt[:, j].reshape(shape)
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def resize_area(img: np.ndarray, w: int, h: int) -> np.ndarray:
|
|
202
|
+
"""A uint8 image resized to (w, h) as OpenCV's INTER_AREA does it: a box average shrinking, bilinear growing."""
|
|
203
|
+
H, W = img.shape[:2]
|
|
204
|
+
if w > W or h > H:
|
|
205
|
+
return resize_linear(img, w, h, area=True)
|
|
206
|
+
if W % w == 0 and H % h == 0:
|
|
207
|
+
sx, sy = W // w, H // h
|
|
208
|
+
s = img.astype(np.int64).reshape(h, sy, w, sx, *img.shape[2:]).sum((1, 3))
|
|
209
|
+
if (sx, sy) == (2, 2):
|
|
210
|
+
return ((s + 2) >> 2).astype(np.uint8)
|
|
211
|
+
return np.rint(s.astype(np.float32) * np.float32(1 / (sx * sy))).astype(np.uint8)
|
|
212
|
+
out = _area_pass(_area_pass(img.astype(np.float32), 1, w), 0, h)
|
|
213
|
+
return np.clip(np.rint(out), 0, 255).astype(np.uint8)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def encode_jpeg(frame: np.ndarray) -> bytes:
|
|
217
|
+
"""A BGR frame as JPEG at JPEG_QUALITY with 4:2:0 chroma, the settings the model was trained on."""
|
|
218
|
+
buf = io.BytesIO()
|
|
219
|
+
Image.fromarray(frame[..., ::-1]).save(buf, "JPEG", quality=JPEG_QUALITY, subsampling=2)
|
|
220
|
+
return buf.getvalue()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def data_url(clip: ClipVideo) -> str:
|
|
224
|
+
"""vLLM's `video_url` for this clip: a JPEG sequence, no container, no codec."""
|
|
225
|
+
return "data:video/jpeg;base64," + ",".join(
|
|
226
|
+
base64.b64encode(encode_jpeg(f)).decode("ascii") for f in clip.frames)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def media_io_kwargs(fps: float = FPS) -> dict:
|
|
230
|
+
"""What vLLM must be started with (`--media-io-kwargs`) to read data_url() as sent."""
|
|
231
|
+
return {"video": {"num_frames": -1, "fps": fps}}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def time_to_index(clip: ClipVideo, t: float) -> int:
|
|
235
|
+
"""The SOURCE frame a `"t"` answer names: the first frame of the nearest pair."""
|
|
236
|
+
k = min(range(len(clip.timestamps)), key=lambda i: abs(clip.timestamps[i] - float(t)))
|
|
237
|
+
j = min(TEMPORAL_PATCH * k, len(clip.source_indices) - 1)
|
|
238
|
+
return clip.source_indices[j]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def index_to_time(clip: ClipVideo, source_index: int) -> float:
|
|
242
|
+
"""The `"t"` that names a source frame: the FIRST pair lying wholly at or after it.
|
|
243
|
+
|
|
244
|
+
A label names the first moment the answer is known and clear, so it rounds
|
|
245
|
+
FORWARD by whole pairs, never to the nearest pair. Nearest put the bee's last
|
|
246
|
+
landing (frame 47) on a pair still showing the bee in flight. A frame outside
|
|
247
|
+
the kept loop is folded back into it first; a frame after the last pair takes
|
|
248
|
+
the last pair.
|
|
249
|
+
"""
|
|
250
|
+
s = int(source_index)
|
|
251
|
+
if clip.period:
|
|
252
|
+
s %= clip.period
|
|
253
|
+
idx = clip.source_indices
|
|
254
|
+
for k, ts in enumerate(clip.timestamps):
|
|
255
|
+
pair = idx[TEMPORAL_PATCH * k:TEMPORAL_PATCH * (k + 1)]
|
|
256
|
+
if pair and min(pair) >= s:
|
|
257
|
+
return ts
|
|
258
|
+
return clip.timestamps[-1]
|
|
@@ -8,10 +8,11 @@ touch mode that dispatches no motion between taps still has to say where the nex
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import math
|
|
11
12
|
import os
|
|
12
13
|
import random
|
|
13
14
|
import time
|
|
14
|
-
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
15
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
15
16
|
|
|
16
17
|
from cursory import generate_trajectory
|
|
17
18
|
|
|
@@ -22,6 +23,8 @@ Point = Tuple[float, float]
|
|
|
22
23
|
# Each device supplies its own table. An unknown kind yields no wait rather than raising, so adding
|
|
23
24
|
# a pause site cannot break a custom humanizer written against an older release.
|
|
24
25
|
PAUSE_KINDS = tuple(PauseKind)
|
|
26
|
+
# How often a held press looks at the widget, in ms: a frame hash per look, so not every frame.
|
|
27
|
+
HOLD_POLL_MS = (180.0, 320.0)
|
|
25
28
|
|
|
26
29
|
|
|
27
30
|
def _delay(ms: float) -> None:
|
|
@@ -38,10 +41,57 @@ def _same_point(a: Point, b: Point) -> bool:
|
|
|
38
41
|
return abs(float(a[0]) - float(b[0])) < 1e-6 and abs(float(a[1]) - float(b[1])) < 1e-6
|
|
39
42
|
|
|
40
43
|
|
|
44
|
+
# What one mouse event may cover. cursory's samples are up to 250 ms and 560 px apart, which the page sees as a
|
|
45
|
+
# cursor that vanishes and reappears: a teleport. A real mouse reports every 8 ms (125 Hz).
|
|
46
|
+
MAX_EVENT_GAP_MS = 8.0
|
|
47
|
+
MAX_EVENT_STEP_PX = 16.0
|
|
48
|
+
# How far a reach may swing off the straight line between its endpoints: a fraction of its length, at least a few
|
|
49
|
+
# pixels. cursory morphs a long recorded movement onto any pair of points, so a 5 px move swung 61 px wide and a
|
|
50
|
+
# 140 px one 300 px.
|
|
51
|
+
MAX_DEVIATION = 0.15
|
|
52
|
+
MIN_DEVIATION_PX = 3.0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _nearest_on_segment(p: Point, a: Point, b: Point) -> Point:
|
|
56
|
+
dx, dy = b[0] - a[0], b[1] - a[1]
|
|
57
|
+
length2 = dx * dx + dy * dy
|
|
58
|
+
if length2 == 0.0:
|
|
59
|
+
return a
|
|
60
|
+
k = max(0.0, min(1.0, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / length2))
|
|
61
|
+
return a[0] + k * dx, a[1] + k * dy
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _tame(points: List[Point], start: Point, end: Point) -> List[Point]:
|
|
65
|
+
"""Shrink the path's swing off the segment, shape kept, until it stays within MAX_DEVIATION of it."""
|
|
66
|
+
limit = max(MIN_DEVIATION_PX, MAX_DEVIATION * ((end[0] - start[0]) ** 2 + (end[1] - start[1]) ** 2) ** 0.5)
|
|
67
|
+
near = [_nearest_on_segment(p, start, end) for p in points]
|
|
68
|
+
worst = max((((p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2) ** 0.5 for p, q in zip(points, near)), default=0.0)
|
|
69
|
+
if worst <= limit:
|
|
70
|
+
return points
|
|
71
|
+
k = limit / worst
|
|
72
|
+
return [(q[0] + (p[0] - q[0]) * k, q[1] + (p[1] - q[1]) * k) for p, q in zip(points, near)]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _densify(points: List[Point], timings: List[float]) -> Tuple[List[Point], List[float]]:
|
|
76
|
+
"""Fill every gap wider than one mouse report with evenly timed samples along it."""
|
|
77
|
+
out_p, out_t = points[:1], timings[:1]
|
|
78
|
+
for (a, ta), (b, tb) in zip(zip(points, timings), zip(points[1:], timings[1:])):
|
|
79
|
+
dist = ((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2) ** 0.5
|
|
80
|
+
n = max(1, int(-(-(tb - ta) // MAX_EVENT_GAP_MS)), int(-(-dist // MAX_EVENT_STEP_PX)))
|
|
81
|
+
for i in range(1, n + 1):
|
|
82
|
+
f = i / n
|
|
83
|
+
out_p.append((a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f))
|
|
84
|
+
out_t.append(ta + (tb - ta) * f)
|
|
85
|
+
return out_p, out_t
|
|
86
|
+
|
|
87
|
+
|
|
41
88
|
def trajectory(start: Point, end: Point, frequency: int = 60) -> Tuple[List[Point], List[float]]:
|
|
42
|
-
"""A recorded human movement morphed onto the endpoints
|
|
89
|
+
"""A recorded human movement morphed onto the endpoints, held near the line between them and sampled at
|
|
90
|
+
least once per mouse report; timings are cumulative ms."""
|
|
43
91
|
points, timings = generate_trajectory(start, end, frequency=frequency)
|
|
44
|
-
|
|
92
|
+
start, end = (float(start[0]), float(start[1])), (float(end[0]), float(end[1]))
|
|
93
|
+
pts = _tame([(float(x), float(y)) for x, y in points], start, end)
|
|
94
|
+
return _densify(pts, [float(t) for t in timings])
|
|
45
95
|
|
|
46
96
|
|
|
47
97
|
class Humanizer:
|
|
@@ -60,6 +110,11 @@ class Humanizer:
|
|
|
60
110
|
def move(self, page: Any, to: Point) -> None:
|
|
61
111
|
raise NotImplementedError
|
|
62
112
|
|
|
113
|
+
def drift(self, page: Any, to: Point) -> None:
|
|
114
|
+
"""A held hand's pixel of wander. `move` is a reach, and a reach overshoots: on a 1px move it wandered up to
|
|
115
|
+
34px, off a thin bar, which the widget takes for a release."""
|
|
116
|
+
self.move(page, to)
|
|
117
|
+
|
|
63
118
|
def press(self, page: Any) -> None:
|
|
64
119
|
raise NotImplementedError
|
|
65
120
|
|
|
@@ -91,6 +146,36 @@ class Humanizer:
|
|
|
91
146
|
self.release(page)
|
|
92
147
|
|
|
93
148
|
|
|
149
|
+
def hold(human: "Humanizer", page: Any, to: Point, done: Callable[[], bool], max_ms: float) -> bool:
|
|
150
|
+
"""Press at `to` until `done()` says the widget completed, or `max_ms` passes; True when it completed.
|
|
151
|
+
|
|
152
|
+
Built on move/press/release so any humanizer can hold. The hand drifts a pixel or so meanwhile, because a
|
|
153
|
+
press that does not move for seconds is a synthetic one, and the button is released whatever `done` raises.
|
|
154
|
+
"""
|
|
155
|
+
human.move(page, to)
|
|
156
|
+
anchor = human.at
|
|
157
|
+
human.press(page)
|
|
158
|
+
t0 = time.monotonic()
|
|
159
|
+
try:
|
|
160
|
+
while True:
|
|
161
|
+
_delay(random.uniform(*HOLD_POLL_MS))
|
|
162
|
+
try:
|
|
163
|
+
getattr(human, "drift", human.move)(page, (anchor[0] + random.gauss(0.0, 0.8),
|
|
164
|
+
anchor[1] + random.gauss(0.0, 0.8)))
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
if done():
|
|
168
|
+
return True
|
|
169
|
+
if (time.monotonic() - t0) * 1000.0 >= max_ms:
|
|
170
|
+
return False
|
|
171
|
+
finally:
|
|
172
|
+
human.release(page)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# How much longer than the bare round trip a step takes when the browser re-draws it as a path of its own.
|
|
176
|
+
BROWSER_PATH_MS = 100.0
|
|
177
|
+
|
|
178
|
+
|
|
94
179
|
class MouseHumanizer(Humanizer):
|
|
95
180
|
name = HumanizationMode.MOUSE
|
|
96
181
|
hovers = True
|
|
@@ -104,11 +189,13 @@ class MouseHumanizer(Humanizer):
|
|
|
104
189
|
self._viewport_cache: Optional[Dict[str, float]] = None
|
|
105
190
|
self._down = False
|
|
106
191
|
self._cursor_seeded = False
|
|
192
|
+
self._browser_draws_path: Optional[bool] = None
|
|
107
193
|
|
|
108
194
|
def reset(self, page: Any) -> None:
|
|
109
195
|
self._viewport_cache = None
|
|
110
196
|
self._cursor_seeded = False
|
|
111
197
|
self._down = False
|
|
198
|
+
self._browser_draws_path = None
|
|
112
199
|
|
|
113
200
|
def _viewport(self, page: Any) -> Optional[Dict[str, float]]:
|
|
114
201
|
"""The window to keep the cursor inside; None under camoufox, which reports no viewport."""
|
|
@@ -140,22 +227,60 @@ class MouseHumanizer(Humanizer):
|
|
|
140
227
|
except Exception:
|
|
141
228
|
pass
|
|
142
229
|
|
|
230
|
+
def _clamp(self, page: Any, point: Point) -> Point:
|
|
231
|
+
# Clamp only when the viewport is known: a guessed edge coordinate deadlocks camoufox's juggler.
|
|
232
|
+
x, y = float(point[0]), float(point[1])
|
|
233
|
+
viewport = self._viewport(page)
|
|
234
|
+
if viewport is None:
|
|
235
|
+
return x, y
|
|
236
|
+
return max(1.0, min(x, viewport["width"] - 1.0)), max(1.0, min(y, viewport["height"] - 1.0))
|
|
237
|
+
|
|
238
|
+
def _measure_browser_path(self, page: Any, to: Point) -> bool:
|
|
239
|
+
"""Whether the browser draws its own path for every mouse.move(), as camoufox's humanize does.
|
|
240
|
+
|
|
241
|
+
Ours on top has every sample re-drawn: a 1px step took 190ms there against 1ms, and one GeeTest slider 82s
|
|
242
|
+
against 13s. One short step towards `to`, timed against a zero-length move (the bare round trip, so a remote
|
|
243
|
+
browser is not mistaken for one), tells them apart with no movement the page could single out.
|
|
244
|
+
"""
|
|
245
|
+
x0, y0 = self._clamp(page, self.at)
|
|
246
|
+
d = math.hypot(to[0] - x0, to[1] - y0)
|
|
247
|
+
step = self._clamp(page, to if d <= 3 else (x0 + 3 * (to[0] - x0) / d, y0 + 3 * (to[1] - y0) / d))
|
|
248
|
+
try:
|
|
249
|
+
page.mouse.move(x0, y0)
|
|
250
|
+
t = time.monotonic()
|
|
251
|
+
page.mouse.move(x0, y0)
|
|
252
|
+
round_trip = time.monotonic() - t
|
|
253
|
+
t = time.monotonic()
|
|
254
|
+
page.mouse.move(*step)
|
|
255
|
+
except Exception as exc:
|
|
256
|
+
if "Target closed" in str(exc) or "Session closed" in str(exc):
|
|
257
|
+
_log("could not move mouse; page or session closed")
|
|
258
|
+
return False
|
|
259
|
+
self.at = step
|
|
260
|
+
drawn = (time.monotonic() - t - round_trip) * 1000.0 > BROWSER_PATH_MS
|
|
261
|
+
if drawn:
|
|
262
|
+
_log("the browser draws its own pointer paths (camoufox humanize); handing it one move per gesture")
|
|
263
|
+
return drawn
|
|
264
|
+
|
|
143
265
|
def move(self, page: Any, to: Point) -> None:
|
|
144
266
|
self._seed_cursor(page)
|
|
145
267
|
if _same_point(self.at, to):
|
|
146
268
|
return
|
|
269
|
+
if self._browser_draws_path is None:
|
|
270
|
+
self._browser_draws_path = self._measure_browser_path(page, to)
|
|
271
|
+
if self._browser_draws_path:
|
|
272
|
+
self._trace(page, [to], [0.0])
|
|
273
|
+
return
|
|
147
274
|
points, timings = trajectory(self.at, to, self._frequency)
|
|
148
275
|
self._trace(page, points, timings)
|
|
149
276
|
|
|
277
|
+
def drift(self, page: Any, to: Point) -> None:
|
|
278
|
+
self._trace(page, [to], [0.0])
|
|
279
|
+
|
|
150
280
|
def _trace(self, page: Any, points: Sequence[Point], timings: Sequence[float]) -> None:
|
|
151
|
-
# Clamp only when the viewport is known: a guessed edge coordinate deadlocks camoufox's juggler.
|
|
152
|
-
viewport = self._viewport(page)
|
|
153
281
|
start = time.monotonic() * 1000.0
|
|
154
|
-
for
|
|
155
|
-
x, y =
|
|
156
|
-
if viewport is not None:
|
|
157
|
-
x = max(1.0, min(x, viewport["width"] - 1.0))
|
|
158
|
-
y = max(1.0, min(y, viewport["height"] - 1.0))
|
|
282
|
+
for point, t in zip(points, timings):
|
|
283
|
+
x, y = self._clamp(page, point)
|
|
159
284
|
try:
|
|
160
285
|
page.mouse.move(x, y)
|
|
161
286
|
except Exception as exc:
|
|
@@ -395,6 +520,12 @@ class MobileHumanizer(Humanizer):
|
|
|
395
520
|
for i, (x, y) in enumerate(points)])
|
|
396
521
|
self.at = to
|
|
397
522
|
|
|
523
|
+
def drift(self, page: Any, to: Point) -> None:
|
|
524
|
+
to = (float(to[0]), float(to[1]))
|
|
525
|
+
if self._down:
|
|
526
|
+
self._touch(page).move([(to[0], to[1], 0.0)])
|
|
527
|
+
self.at = to
|
|
528
|
+
|
|
398
529
|
def press(self, page: Any) -> None:
|
|
399
530
|
self._touch(page).down(*self.at)
|
|
400
531
|
self._down = True
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""The frame-diff primitive every movement check in both ports shares."""
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
from .keyframes import frame_diff_ratio, read_frame
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
class ImageProcessor:
|
|
@@ -11,14 +11,10 @@ class ImageProcessor:
|
|
|
11
11
|
Split from `detect_movement` so callers can report the number: GeeTest's permanent background shimmer
|
|
12
12
|
sits close enough to the default threshold to fire the freshness re-solve on nearly every inference.
|
|
13
13
|
"""
|
|
14
|
-
img1, img2 =
|
|
14
|
+
img1, img2 = read_frame(image1_path), read_frame(image2_path)
|
|
15
15
|
if img1 is None or img2 is None:
|
|
16
16
|
return 0.0
|
|
17
|
-
|
|
18
|
-
return 1.0
|
|
19
|
-
gray = cv2.cvtColor(cv2.absdiff(img1, img2), cv2.COLOR_BGR2GRAY)
|
|
20
|
-
_, thresh = cv2.threshold(gray, 30, 255, cv2.THRESH_BINARY)
|
|
21
|
-
return cv2.countNonZero(thresh) / float(thresh.shape[0] * thresh.shape[1])
|
|
17
|
+
return frame_diff_ratio(img1, img2)
|
|
22
18
|
|
|
23
19
|
@staticmethod
|
|
24
20
|
def detect_movement(image1_path: str, image2_path: str, threshold: float = 0.005) -> bool:
|