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
|
@@ -9,12 +9,13 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
11
|
import os
|
|
12
|
+
from contextvars import ContextVar
|
|
12
13
|
from dataclasses import dataclass, field
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
15
16
|
|
|
16
|
-
import cv2
|
|
17
17
|
import numpy as np
|
|
18
|
+
from PIL import Image, ImageOps
|
|
18
19
|
|
|
19
20
|
from .kinds import KeyframeMode
|
|
20
21
|
|
|
@@ -38,6 +39,11 @@ DEFAULT_MIN_STEADY_COVERAGE = 0.5
|
|
|
38
39
|
# The one pixel threshold shared with every other movement check in the project.
|
|
39
40
|
_PIXEL_DELTA = 30
|
|
40
41
|
|
|
42
|
+
# Grey = (B, G, R) . weights >> 15, rounded: OpenCV's fixed-point BGR2GRAY, which every threshold above was
|
|
43
|
+
# measured through. Equal to it on all 2^24 colours. Elementwise, not a matrix product: BLAS threads on a busy
|
|
44
|
+
# machine made the product 20x slower than the arithmetic.
|
|
45
|
+
_GRAY_WEIGHTS = (3735, 19235, 9798)
|
|
46
|
+
|
|
41
47
|
# One constant for both choosing the label's frame and the live wait: the two must ask the same question.
|
|
42
48
|
MATCH_REGION_HALF = 0.06
|
|
43
49
|
|
|
@@ -147,17 +153,66 @@ def frame_diff_ratio(a: np.ndarray, b: np.ndarray) -> float:
|
|
|
147
153
|
return 1.0
|
|
148
154
|
if a.shape != b.shape:
|
|
149
155
|
return 1.0
|
|
150
|
-
diff =
|
|
151
|
-
|
|
152
|
-
diff = cv2.cvtColor(diff, cv2.COLOR_BGR2GRAY)
|
|
153
|
-
_, thr = cv2.threshold(diff, _PIXEL_DELTA, 255, cv2.THRESH_BINARY)
|
|
154
|
-
h, w = thr.shape[:2]
|
|
156
|
+
diff = np.maximum(a, b) - np.minimum(a, b)
|
|
157
|
+
h, w = diff.shape[:2]
|
|
155
158
|
if h * w == 0:
|
|
156
159
|
return 1.0
|
|
157
|
-
|
|
160
|
+
# Grey > delta, compared before the shift: the same pixels, one pass fewer over the frame.
|
|
161
|
+
moved = _scaled_luma(diff) >= (_PIXEL_DELTA + 1) << 15 if diff.ndim == 3 else diff > _PIXEL_DELTA
|
|
162
|
+
return int(np.count_nonzero(moved)) / float(h * w)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _scaled_luma(frame: np.ndarray) -> np.ndarray:
|
|
166
|
+
"""A BGR uint8 frame's luma times 2^15, rounding offset included."""
|
|
167
|
+
luma = np.multiply(frame[..., 0], _GRAY_WEIGHTS[0], dtype=np.uint32)
|
|
168
|
+
luma += np.multiply(frame[..., 1], _GRAY_WEIGHTS[1], dtype=np.uint32)
|
|
169
|
+
luma += np.multiply(frame[..., 2], _GRAY_WEIGHTS[2], dtype=np.uint32)
|
|
170
|
+
luma += 1 << 14
|
|
171
|
+
return luma
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def to_gray(frame: np.ndarray) -> np.ndarray:
|
|
175
|
+
"""A BGR uint8 frame's luma, as uint8."""
|
|
176
|
+
return (_scaled_luma(frame) >> 15).astype(np.uint8)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def read_frame(path: str | os.PathLike) -> Optional[np.ndarray]:
|
|
180
|
+
"""The image at `path` as an (H, W, 3) BGR uint8 array, or None if it cannot be decoded.
|
|
181
|
+
|
|
182
|
+
BGR because every frame in the project is: recordings, keyframes and the files they are written to.
|
|
183
|
+
"""
|
|
184
|
+
try:
|
|
185
|
+
with Image.open(path) as img:
|
|
186
|
+
rgb = np.asarray(ImageOps.exif_transpose(img).convert("RGB"))
|
|
187
|
+
except OSError:
|
|
188
|
+
return None
|
|
189
|
+
return np.ascontiguousarray(rgb[..., ::-1])
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def write_frame(path: str | os.PathLike, frame: np.ndarray) -> None:
|
|
193
|
+
"""Write a BGR (or single-channel) uint8 frame; the format follows the extension."""
|
|
194
|
+
Image.fromarray(frame[..., ::-1] if frame.ndim == 3 else frame).save(path)
|
|
158
195
|
|
|
159
196
|
|
|
160
197
|
|
|
198
|
+
#: The diffs already measured during the extraction in progress, keyed by the two frames' identities.
|
|
199
|
+
#: A 100-frame clip asks ~8,000 diffs of ~1,100 distinct pairs: the medoid, merge and dedup passes keep
|
|
200
|
+
#: re-comparing the same screens. `extract_keyframes` holds every frame alive for the duration, so an
|
|
201
|
+
#: id cannot be reused while it is a key.
|
|
202
|
+
_PAIR_CACHE: ContextVar[Optional[Dict[Tuple[int, int], float]]] = ContextVar("_PAIR_CACHE", default=None)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _pair_diff(a: np.ndarray, b: np.ndarray) -> float:
|
|
206
|
+
"""`frame_diff_ratio`, remembered for the extraction in progress (the diff is symmetric)."""
|
|
207
|
+
cache = _PAIR_CACHE.get()
|
|
208
|
+
if cache is None:
|
|
209
|
+
return frame_diff_ratio(a, b)
|
|
210
|
+
key = (id(a), id(b)) if id(a) <= id(b) else (id(b), id(a))
|
|
211
|
+
if key not in cache:
|
|
212
|
+
cache[key] = frame_diff_ratio(a, b)
|
|
213
|
+
return cache[key]
|
|
214
|
+
|
|
215
|
+
|
|
161
216
|
def _anchor_runs(frames: Sequence[np.ndarray], steady_ratio: float) -> List[Tuple[int, int]]:
|
|
162
217
|
"""Anchored on the run's first frame: a slow pan has every consecutive diff under the threshold."""
|
|
163
218
|
runs: List[Tuple[int, int]] = []
|
|
@@ -165,7 +220,7 @@ def _anchor_runs(frames: Sequence[np.ndarray], steady_ratio: float) -> List[Tupl
|
|
|
165
220
|
n = len(frames)
|
|
166
221
|
while i < n:
|
|
167
222
|
j = i
|
|
168
|
-
while j + 1 < n and
|
|
223
|
+
while j + 1 < n and _pair_diff(frames[i], frames[j + 1]) <= steady_ratio:
|
|
169
224
|
j += 1
|
|
170
225
|
runs.append((i, j))
|
|
171
226
|
i = j + 1
|
|
@@ -177,7 +232,7 @@ def _medoid(frames: Sequence[np.ndarray], indices: Sequence[int]) -> int:
|
|
|
177
232
|
return indices[0]
|
|
178
233
|
best_idx, best_cost = indices[0], float("inf")
|
|
179
234
|
for a in indices:
|
|
180
|
-
cost = sum(
|
|
235
|
+
cost = sum(_pair_diff(frames[a], frames[b]) for b in indices if b != a)
|
|
181
236
|
if cost < best_cost:
|
|
182
237
|
best_idx, best_cost = a, cost
|
|
183
238
|
return best_idx
|
|
@@ -208,7 +263,7 @@ def _distinct_indices(
|
|
|
208
263
|
|
|
209
264
|
def is_new(i: int) -> bool:
|
|
210
265
|
return not any(
|
|
211
|
-
|
|
266
|
+
_pair_diff(frames[i], frames[k]) <= params.steady_ratio for k in kept
|
|
212
267
|
)
|
|
213
268
|
|
|
214
269
|
for i in candidates:
|
|
@@ -241,7 +296,7 @@ def _steady_screens(
|
|
|
241
296
|
for s, e in holds:
|
|
242
297
|
rep = _medoid(frames, list(range(s, e + 1)))
|
|
243
298
|
for k, existing in enumerate(state_reps):
|
|
244
|
-
if
|
|
299
|
+
if _pair_diff(frames[rep], frames[existing]) <= params.steady_ratio:
|
|
245
300
|
hold_state.append(k)
|
|
246
301
|
break
|
|
247
302
|
else:
|
|
@@ -278,7 +333,7 @@ def _detect_cycle(
|
|
|
278
333
|
|
|
279
334
|
for a in range(len(state_reps)):
|
|
280
335
|
for b in range(a + 1, len(state_reps)):
|
|
281
|
-
if
|
|
336
|
+
if _pair_diff(frames[state_reps[a]], frames[state_reps[b]]) < params.distinct_ratio:
|
|
282
337
|
return None
|
|
283
338
|
|
|
284
339
|
per_frame = [-1] * n
|
|
@@ -306,7 +361,7 @@ def _drop_smeared(
|
|
|
306
361
|
for start, end in holds:
|
|
307
362
|
rep = _medoid(frames, list(range(start, end + 1)))
|
|
308
363
|
for k, existing in enumerate(reps):
|
|
309
|
-
if
|
|
364
|
+
if _pair_diff(frames[rep], frames[existing]) <= params.steady_ratio:
|
|
310
365
|
state_of_hold.append(k)
|
|
311
366
|
break
|
|
312
367
|
else:
|
|
@@ -342,8 +397,19 @@ def extract_keyframes(
|
|
|
342
397
|
fps: float = 10.0,
|
|
343
398
|
params: Optional[KeyframeParams] = None,
|
|
344
399
|
) -> KeyframeSet:
|
|
400
|
+
# A list, so each frame is one object that lives as long as the cache keyed on its id
|
|
401
|
+
# (indexing a 4-D array would make a new, collectable view per access).
|
|
402
|
+
frames = list(frames)
|
|
345
403
|
if not frames:
|
|
346
404
|
raise ValueError("no frames to extract keyframes from")
|
|
405
|
+
token = _PAIR_CACHE.set({})
|
|
406
|
+
try:
|
|
407
|
+
return _extract_keyframes(frames, fps, params)
|
|
408
|
+
finally:
|
|
409
|
+
_PAIR_CACHE.reset(token)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _extract_keyframes(frames: Sequence[np.ndarray], fps: float, params: Optional[KeyframeParams]) -> KeyframeSet:
|
|
347
413
|
p = params or KeyframeParams()
|
|
348
414
|
n = len(frames)
|
|
349
415
|
|
|
@@ -412,7 +478,7 @@ def write_keyframes(
|
|
|
412
478
|
target = out / name
|
|
413
479
|
if kf.image is None:
|
|
414
480
|
raise ValueError(f"keyframe {kf.number} has no image to write")
|
|
415
|
-
|
|
481
|
+
write_frame(target, kf.image)
|
|
416
482
|
paths.append(target)
|
|
417
483
|
names.append(name)
|
|
418
484
|
|
|
@@ -421,4 +487,20 @@ def write_keyframes(
|
|
|
421
487
|
return paths
|
|
422
488
|
|
|
423
489
|
|
|
490
|
+
def number_for_source_index(kfset: KeyframeSet, source_index: int) -> int:
|
|
491
|
+
"""The keyframe a given source frame belongs to (1-based), by POSITION.
|
|
492
|
+
|
|
493
|
+
Exact in cycle mode (the frame's own state), else the nearest keyframe in time. A transition
|
|
494
|
+
frame (state -1) has no state of its own, so where the pixels are at hand, matching by
|
|
495
|
+
appearance around the action point is the better question.
|
|
496
|
+
"""
|
|
497
|
+
if not kfset.keyframes:
|
|
498
|
+
raise ValueError("keyframe set is empty")
|
|
499
|
+
if kfset.mode == KeyframeMode.CYCLE and 0 <= source_index < len(kfset.frame_states):
|
|
500
|
+
state = kfset.frame_states[source_index]
|
|
501
|
+
if state >= 0:
|
|
502
|
+
return state + 1
|
|
503
|
+
return min(kfset.keyframes, key=lambda k: abs(k.source_index - source_index)).number
|
|
504
|
+
|
|
505
|
+
|
|
424
506
|
|
|
@@ -3,7 +3,7 @@ from enum import StrEnum
|
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
class Vendor(StrEnum):
|
|
6
|
-
"""`puzzle_source` everywhere. UNKNOWN must stay permissive:
|
|
6
|
+
"""`puzzle_source` everywhere. UNKNOWN must stay permissive: the CLI, scripts and the offline grader report it."""
|
|
7
7
|
|
|
8
8
|
HCAPTCHA = "hcaptcha"
|
|
9
9
|
RECAPTCHA = "recaptcha"
|
|
@@ -16,6 +16,12 @@ class Vendor(StrEnum):
|
|
|
16
16
|
PROSOPO = "prosopo"
|
|
17
17
|
MTCAPTCHA = "mtcaptcha"
|
|
18
18
|
BOTDETECT = "botdetect"
|
|
19
|
+
HUMAN = "human"
|
|
20
|
+
AWSWAF = "awswaf"
|
|
21
|
+
CAPTCHAFOX = "captchafox"
|
|
22
|
+
DINGXIANG = "dingxiang"
|
|
23
|
+
FUNCAPTCHA = "funcaptcha"
|
|
24
|
+
PROTON = "proton"
|
|
19
25
|
UNKNOWN = "unknown"
|
|
20
26
|
|
|
21
27
|
|
|
@@ -25,6 +31,9 @@ class ActionKind(StrEnum):
|
|
|
25
31
|
TYPE = "type"
|
|
26
32
|
WAIT = "wait"
|
|
27
33
|
DONE = "done"
|
|
34
|
+
HOLD = "hold"
|
|
35
|
+
REFRESH = "refresh"
|
|
36
|
+
SLIDE = "slide"
|
|
28
37
|
|
|
29
38
|
|
|
30
39
|
class PixelAnswerKind(StrEnum):
|
|
@@ -34,6 +43,8 @@ class PixelAnswerKind(StrEnum):
|
|
|
34
43
|
DRAG = "drag"
|
|
35
44
|
SLIDE = "slide"
|
|
36
45
|
TYPE = "type"
|
|
46
|
+
HOLD = "hold"
|
|
47
|
+
REFRESH = "refresh"
|
|
37
48
|
|
|
38
49
|
|
|
39
50
|
class RetryMode(StrEnum):
|
|
@@ -126,7 +137,8 @@ class Phase(StrEnum):
|
|
|
126
137
|
GRID_LOAD = "grid-load"
|
|
127
138
|
FADE_WAIT = "fade-wait"
|
|
128
139
|
BOARD_PAINT = "board-paint"
|
|
129
|
-
|
|
140
|
+
BOARD_IMAGES = "board-images"
|
|
141
|
+
HCAPTCHA_IMAGES = "board-images" # the old name; an alias of BOARD_IMAGES
|
|
130
142
|
AWAIT_NEXT_ROUND = "await-next-round"
|
|
131
143
|
AWAIT_VERDICT = "await-verdict"
|
|
132
144
|
POST_SUBMIT_DELAY = "post-submit-delay"
|
|
@@ -120,7 +120,7 @@
|
|
|
120
120
|
"an expert name only ever reaches the wire for a caller who asked for a",
|
|
121
121
|
"routed model in the first place."
|
|
122
122
|
],
|
|
123
|
-
"latest": "CaptchaKraken/
|
|
123
|
+
"latest": "CaptchaKraken/MidnightV2-4B-q4",
|
|
124
124
|
"_comment_hosted_default": [
|
|
125
125
|
"WHAT THE HOSTED ENDPOINT IS ASKED FOR, which is not what gets DOWNLOADED.",
|
|
126
126
|
"",
|
|
@@ -141,9 +141,33 @@
|
|
|
141
141
|
"before, because it serves whatever its operator loaded and asking it for",
|
|
142
142
|
"`abyss` would 404."
|
|
143
143
|
],
|
|
144
|
-
"hosted_default": "CaptchaKraken/
|
|
144
|
+
"hosted_default": "CaptchaKraken/MidnightV2-4B-q4",
|
|
145
145
|
|
|
146
146
|
"models": {
|
|
147
|
+
"CaptchaKraken/MidnightV2-4B-q4": {
|
|
148
|
+
"prompt_version": "4",
|
|
149
|
+
"kind": "merged",
|
|
150
|
+
"base_model": "CaptchaKraken/MidnightV2-4B-q4",
|
|
151
|
+
"lora_revision": "main",
|
|
152
|
+
"lora_name": "captcha-pointer",
|
|
153
|
+
"pixel_budget": {
|
|
154
|
+
"min": 4096,
|
|
155
|
+
"max": 518400
|
|
156
|
+
},
|
|
157
|
+
"note": "Midnight V2 4B, GPTQ W4A16, with the pointer heads. The default self-hosted and hosted model. The server owns the fixed prompt and reads the answer from one prefill at /v1/pointer."
|
|
158
|
+
},
|
|
159
|
+
"CaptchaKraken/MidnightV2-4B": {
|
|
160
|
+
"prompt_version": "4",
|
|
161
|
+
"kind": "merged",
|
|
162
|
+
"base_model": "CaptchaKraken/MidnightV2-4B",
|
|
163
|
+
"lora_revision": "main",
|
|
164
|
+
"lora_name": "captcha-pointer",
|
|
165
|
+
"pixel_budget": {
|
|
166
|
+
"min": 4096,
|
|
167
|
+
"max": 518400
|
|
168
|
+
},
|
|
169
|
+
"note": "Midnight V2 4B at bf16. The full-precision alternative to the default Q4 checkpoint."
|
|
170
|
+
},
|
|
147
171
|
"CaptchaKraken/CaptchaKraken-Lora-v1.2": {
|
|
148
172
|
"prompt_version": "2",
|
|
149
173
|
"kind": "lora",
|
|
@@ -309,6 +333,7 @@
|
|
|
309
333
|
"abyss-general": "CaptchaKraken/Abyss-general",
|
|
310
334
|
"abyss-video": "CaptchaKraken/Abyss-video",
|
|
311
335
|
"abyss-text": "CaptchaKraken/Abyss-text",
|
|
312
|
-
"abyss-27b": "CaptchaKraken/Abyss-27B"
|
|
336
|
+
"abyss-27b": "CaptchaKraken/Abyss-27B",
|
|
337
|
+
"captcha-pointer": "CaptchaKraken/MidnightV2-4B-q4"
|
|
313
338
|
}
|
|
314
339
|
}
|