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.
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 +96 -11
  15. package/dist/selectors.js +226 -112
  16. package/dist/solver.d.ts +93 -13
  17. package/dist/solver.js +579 -208
  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 +621 -259
  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 +224 -131
  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
@@ -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 = cv2.absdiff(a, b)
151
- if diff.ndim == 3:
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
- return cv2.countNonZero(thr) / float(h * w)
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 frame_diff_ratio(frames[i], frames[j + 1]) <= steady_ratio:
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(frame_diff_ratio(frames[a], frames[b]) for b in indices if b != a)
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
- frame_diff_ratio(frames[i], frames[k]) <= params.steady_ratio for k in kept
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 frame_diff_ratio(frames[rep], frames[existing]) <= params.steady_ratio:
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 frame_diff_ratio(frames[state_reps[a]], frames[state_reps[b]]) < params.distinct_ratio:
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 frame_diff_ratio(frames[rep], frames[existing]) <= params.steady_ratio:
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
- cv2.imwrite(str(target), kf.image)
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: GeeTest and Prosopo report it, and so does the offline grader."""
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
- HCAPTCHA_IMAGES = "hcaptcha-images"
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/CaptchaKraken-Lora-v1.2",
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/Abyss",
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
  }