captchakraken 2.1.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 (37) hide show
  1. package/README.md +54 -0
  2. package/dist/index.d.ts +56 -0
  3. package/dist/index.js +43 -0
  4. package/dist/playwright-types.d.ts +119 -0
  5. package/dist/playwright-types.js +25 -0
  6. package/dist/puppeteer-adapter.d.ts +70 -0
  7. package/dist/puppeteer-adapter.js +96 -0
  8. package/dist/solver.d.ts +264 -0
  9. package/dist/solver.js +1922 -0
  10. package/dist/token-usage.d.ts +15 -0
  11. package/dist/token-usage.js +102 -0
  12. package/dist/types.d.ts +248 -0
  13. package/dist/types.js +2 -0
  14. package/package.json +49 -0
  15. package/python/Dockerfile +41 -0
  16. package/python/README.md +71 -0
  17. package/python/examples/README.md +68 -0
  18. package/python/examples/_harness.py +158 -0
  19. package/python/examples/demoHcaptcha.py +20 -0
  20. package/python/examples/demoRecaptcha.py +17 -0
  21. package/python/pyproject.toml +79 -0
  22. package/python/src/captchakraken/__init__.py +61 -0
  23. package/python/src/captchakraken/action_types.py +56 -0
  24. package/python/src/captchakraken/cli.py +656 -0
  25. package/python/src/captchakraken/config.py +78 -0
  26. package/python/src/captchakraken/image_processor.py +244 -0
  27. package/python/src/captchakraken/overlay.py +520 -0
  28. package/python/src/captchakraken/planner.py +408 -0
  29. package/python/src/captchakraken/planner_types.py +74 -0
  30. package/python/src/captchakraken/server_manager.py +290 -0
  31. package/python/src/captchakraken/solver.py +434 -0
  32. package/python/src/captchakraken/timing.py +42 -0
  33. package/python/src/captchakraken/tool_calls/find_checkbox.py +72 -0
  34. package/python/src/captchakraken/tool_calls/find_grid.py +1762 -0
  35. package/python/src/captchakraken/tool_calls/move_indicator.py +431 -0
  36. package/scripts/copy-python.mjs +29 -0
  37. package/scripts/setup-python.js +104 -0
@@ -0,0 +1,434 @@
1
+ """
2
+ CaptchaSolver (v2) — vLLM-backed.
3
+
4
+ Flow:
5
+ 1. find_grid → if a grid is detected, draw the numbered overlay, ask the
6
+ `captcha` LoRA which cells to click, return ClickActions with
7
+ per-tile bounding boxes.
8
+ 2. find_checkbox on small images → if a lone checkbox is detected, return a
9
+ ClickAction targeting it directly.
10
+ 3. Otherwise (click / drag / other still-image puzzle) → route the image to
11
+ the full-puzzle pixel/action path (planner.get_pixel_actions), which the
12
+ LoRA is trained on. Only raise UnsupportedCaptchaError if the model returns
13
+ nothing usable. Video challenges are filtered out upstream by the caller.
14
+
15
+ v1 had a SAM3-backed tool-using planner with detect/segment/drag-refine; it
16
+ lives on the `v1-old-architecture` branch.
17
+ """
18
+
19
+ import math
20
+ import os
21
+ import shutil
22
+ import sys
23
+ import tempfile
24
+ from datetime import datetime
25
+ from pathlib import Path
26
+ from typing import Any, List, Optional, Tuple, Union
27
+
28
+ from PIL import Image
29
+
30
+ from .action_types import (
31
+ CaptchaAction,
32
+ ClickAction,
33
+ DoneAction,
34
+ DragAction,
35
+ WaitAction,
36
+ )
37
+ from .image_processor import ImageProcessor
38
+ from .planner import ActionPlanner
39
+ from .timing import timed
40
+ from .tool_calls.find_checkbox import find_checkbox
41
+ from .tool_calls.find_grid import (
42
+ detect_selected_cells, find_grid, get_numbered_grid_overlay,
43
+ )
44
+
45
+ DEBUG = os.getenv("CAPTCHA_DEBUG", "0") == "1"
46
+
47
+ # Half-size (0–1 fraction) of the click/drag target box built around each point
48
+ # the model returns. The TS solver clicks the box center, so this only sets how
49
+ # much positional slack executeClick has; ~1.2% ≈ ±6px on a 512px challenge.
50
+ _PIXEL_BOX_HALF = float(os.getenv("CAPTCHA_PIXEL_BOX_HALF", "0.012"))
51
+
52
+
53
+ class UnsupportedCaptchaError(Exception):
54
+ """Raised when the captcha is neither a supported grid nor a checkbox."""
55
+
56
+
57
+ class DebugManager:
58
+ """Writes per-run artifacts under `latestDebugRun/` when CAPTCHA_DEBUG=1."""
59
+
60
+ def __init__(self, debug_enabled: bool):
61
+ self.enabled = debug_enabled
62
+ self.base_dir = Path("latestDebugRun").resolve()
63
+ self.log_file = self.base_dir / "log.txt"
64
+ if self.enabled:
65
+ self._setup_dir()
66
+
67
+ def _setup_dir(self):
68
+ if self.base_dir.exists():
69
+ try:
70
+ shutil.rmtree(self.base_dir)
71
+ except Exception as e:
72
+ print(f"[DebugManager] Warning: Could not clear debug dir: {e}", file=sys.stderr)
73
+ try:
74
+ self.base_dir.mkdir(parents=True, exist_ok=True)
75
+ with open(self.log_file, "w") as f:
76
+ f.write(f"Debug Run Started: {datetime.now()}\n")
77
+ except Exception as e:
78
+ print(f"[DebugManager] Error creating debug dir: {e}", file=sys.stderr)
79
+
80
+ def log(self, message: str):
81
+ if self.enabled:
82
+ print(f"[DEBUG] {message}", file=sys.stderr)
83
+ try:
84
+ with open(self.log_file, "a") as f:
85
+ f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {message}\n")
86
+ except Exception:
87
+ pass
88
+ elif DEBUG:
89
+ print(f"[Solver] {message}", file=sys.stderr)
90
+
91
+ def save_image(self, image_path: str, name: str) -> Optional[str]:
92
+ if not self.enabled:
93
+ return None
94
+ if not self.base_dir.exists():
95
+ self._setup_dir()
96
+ target = self.base_dir / name
97
+ try:
98
+ shutil.copy2(image_path, target)
99
+ self.log(f"Saved image: {name}")
100
+ return str(target)
101
+ except Exception as e:
102
+ self.log(f"Failed to save image {name}: {e}")
103
+ return None
104
+
105
+
106
+ class CaptchaSolver:
107
+ """v2 solver: OpenCV grid detection + vLLM `captcha` LoRA."""
108
+
109
+ def __init__(
110
+ self,
111
+ model: Optional[str] = None,
112
+ provider: str = "captchaKrakenApi",
113
+ api_key: Optional[str] = None,
114
+ ):
115
+ self.debug = DebugManager(DEBUG)
116
+ # `provider` kept for argv compatibility with the v1 CLI signature.
117
+ if provider not in {"captchaKrakenApi"}:
118
+ self.debug.log(f"Provider {provider!r} ignored; v2 only supports captchaKrakenApi.")
119
+ self.planner = ActionPlanner(
120
+ model=model, api_key=api_key, debug_callback=self.debug.log
121
+ )
122
+ self.image_processor = ImageProcessor(None, self.planner, self.debug)
123
+ self._image_size: Optional[Tuple[int, int]] = None
124
+ self._temp_files: List[str] = []
125
+
126
+ def __del__(self):
127
+ for f in self._temp_files:
128
+ if os.path.exists(f):
129
+ try:
130
+ os.unlink(f)
131
+ except Exception:
132
+ pass
133
+
134
+ # ------------------------------------------------------------------
135
+ # Public entry point
136
+ # ------------------------------------------------------------------
137
+ def solve(
138
+ self,
139
+ media_path: str,
140
+ instruction: str = "",
141
+ puzzle_source: str = "unknown",
142
+ retry_mode: Optional[str] = None,
143
+ ) -> Union[CaptchaAction, List[CaptchaAction]]:
144
+ media_path = str(Path(media_path).resolve())
145
+ if not os.path.exists(media_path):
146
+ raise FileNotFoundError(f"Media not found: {media_path}")
147
+
148
+ cv_image_path = self._materialize_image(media_path)
149
+ self.debug.save_image(cv_image_path, "00_base_image.png")
150
+ assert self._image_size is not None
151
+ img_w, img_h = self._image_size
152
+
153
+ with timed("solver.find_grid"):
154
+ grid_boxes = find_grid(cv_image_path)
155
+ if grid_boxes and self._is_real_grid(cv_image_path, grid_boxes):
156
+ self.debug.log(f"Detected grid with {len(grid_boxes)} cells")
157
+ return self._solve_grid(cv_image_path, grid_boxes, retry_mode=retry_mode)
158
+ elif grid_boxes:
159
+ # find_grid latched onto e.g. an hCaptcha click-puzzle's
160
+ # header/footer bands. Reject — only true grids are supported.
161
+ self.debug.log(
162
+ f"find_grid returned {len(grid_boxes)} cells but failed the "
163
+ "real-grid sanity check."
164
+ )
165
+
166
+ if img_h < 400:
167
+ with timed("solver.find_checkbox"):
168
+ checkbox = find_checkbox(cv_image_path)
169
+ if checkbox:
170
+ self.debug.log(f"Detected checkbox at {checkbox}")
171
+ x, y, w, h = checkbox
172
+ return ClickAction(
173
+ action="click",
174
+ target_bounding_boxes=[
175
+ [x / img_w, y / img_h, (x + w) / img_w, (y + h) / img_h]
176
+ ],
177
+ )
178
+
179
+ # Not a grid or checkbox → a click/drag/pixel puzzle. The full-puzzle
180
+ # LoRA is trained on all of these, so route the image to the pixel
181
+ # action path rather than bailing. (Video challenges are skipped
182
+ # upstream by the caller before they reach the solver, so anything here
183
+ # is a still-image puzzle worth attempting.)
184
+ actions = self._solve_pixel(cv_image_path)
185
+ if actions:
186
+ return actions
187
+
188
+ # The model returned nothing usable (blank/unrendered frame or a truly
189
+ # un-actionable image). Surface as unsupported so the caller fails fast
190
+ # instead of clicking nothing.
191
+ raise UnsupportedCaptchaError("Cannot solve this kind of captcha")
192
+
193
+ def _solve_pixel(
194
+ self, image_path: str
195
+ ) -> List[Union[ClickAction, DragAction]]:
196
+ """Turn the model's normalized 0–1 click/drag actions into ClickAction /
197
+ DragAction bboxes. Each point becomes a small box centered on it (the TS
198
+ solver clicks the box center)."""
199
+ raw_actions = self.planner.get_pixel_actions(image_path)
200
+ R = _PIXEL_BOX_HALF
201
+ clamp = lambda v: min(max(v, 0.0), 1.0)
202
+
203
+ def box(cx: float, cy: float) -> List[float]:
204
+ return [clamp(cx - R), clamp(cy - R), clamp(cx + R), clamp(cy + R)]
205
+
206
+ out: List[Union[ClickAction, DragAction]] = []
207
+ for a in raw_actions:
208
+ if a.get("kind") == "click":
209
+ boxes = [box(x, y) for (x, y) in a.get("points", [])]
210
+ if boxes:
211
+ out.append(
212
+ ClickAction(action="click", target_bounding_boxes=boxes)
213
+ )
214
+ elif a.get("kind") == "drag":
215
+ sx, sy = a["src"]
216
+ dx, dy = a["dst"]
217
+ out.append(
218
+ DragAction(
219
+ action="drag",
220
+ source_bounding_box=box(sx, sy),
221
+ target_bounding_box=box(dx, dy),
222
+ )
223
+ )
224
+ return out
225
+
226
+ # Back-compat alias.
227
+ def solveVideo(self, *args, **kwargs):
228
+ return self.solve(*args, **kwargs)
229
+
230
+ # ------------------------------------------------------------------
231
+ # Internals
232
+ # ------------------------------------------------------------------
233
+ def _materialize_image(self, media_path: str) -> str:
234
+ """Return a path to a static PNG (extracting first frame for videos)."""
235
+ is_video = any(media_path.lower().endswith(ext) for ext in [".mp4", ".gif", ".avi", ".webm"])
236
+ if is_video:
237
+ import cv2
238
+
239
+ cap = cv2.VideoCapture(media_path)
240
+ ok, frame = cap.read()
241
+ cap.release()
242
+ if not ok:
243
+ raise ValueError(f"Could not read video frame from {media_path}")
244
+ self._image_size = (frame.shape[1], frame.shape[0])
245
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tf:
246
+ cv2.imwrite(tf.name, frame)
247
+ self._temp_files.append(tf.name)
248
+ return tf.name
249
+
250
+ with Image.open(media_path) as img:
251
+ self._image_size = img.size
252
+ return media_path
253
+
254
+ def _is_real_grid(
255
+ self,
256
+ image_path: str,
257
+ grid_boxes: List[Tuple[int, int, int, int]],
258
+ ) -> bool:
259
+ """Reject candidates that look like click-puzzle false positives.
260
+
261
+ A real captcha grid has 9 or 16 photographic tiles. Each tile is
262
+ independent imagery so the per-cell color stddev is *all* high.
263
+
264
+ hCaptcha click puzzles look like a 3-row stack (header band /
265
+ center image / footer band). find_grid sometimes interprets the
266
+ band edges as 2 horizontal grid lines × 2 spurious vertical lines,
267
+ producing 9 "cells" where the top/bottom rows are mostly a single
268
+ flat color (the band). Filter on that.
269
+ """
270
+ try:
271
+ import cv2
272
+ import numpy as np
273
+
274
+ img = cv2.imread(image_path)
275
+ if img is None:
276
+ return True # Be permissive on read failure.
277
+
278
+ # Per-cell mean color standard deviation. Photo tiles ~ 30-80.
279
+ # Flat color band rows ~ 0-12.
280
+ stds: List[float] = []
281
+ for (x1, y1, x2, y2) in grid_boxes:
282
+ if x2 <= x1 or y2 <= y1:
283
+ continue
284
+ roi = img[y1:y2, x1:x2]
285
+ if roi.size == 0:
286
+ continue
287
+ # mean of per-channel stddev — robust to monochrome cells.
288
+ stds.append(float(np.mean(np.std(roi.reshape(-1, 3), axis=0))))
289
+
290
+ if not stds:
291
+ return False
292
+
293
+ n = len(grid_boxes)
294
+ side = 3 if n == 9 else 4 if n == 16 else int(round(n ** 0.5))
295
+ rows = [stds[i * side:(i + 1) * side] for i in range(side)]
296
+ cols = [stds[i::side] for i in range(side)]
297
+
298
+ self.debug.log(
299
+ f"_is_real_grid: per-cell stddev = {[round(s, 1) for s in stds]}"
300
+ )
301
+
302
+ # If the *whole* image is mostly flat (e.g. screenshotting the
303
+ # tiny hCaptcha anchor iframe with just the "I'm not a robot"
304
+ # text), find_grid hallucinates a 9-cell grid. Reject early.
305
+ import statistics
306
+ if statistics.mean(stds) < 25.0:
307
+ self.debug.log(
308
+ f"_is_real_grid: overall mean stddev "
309
+ f"{statistics.mean(stds):.1f} too low → reject."
310
+ )
311
+ return False
312
+
313
+ FLAT = 20.0
314
+ # Build a side×side flat-mask (1 = flat cell, 0 = rich cell).
315
+ flat_mask = [[1 if stds[r * side + c] < FLAT else 0
316
+ for c in range(side)] for r in range(side)]
317
+ flat_count = sum(sum(row) for row in flat_mask)
318
+
319
+ # Heuristic: in a *real* captcha grid, flat tiles (sky, asphalt)
320
+ # always form a spatially contiguous blob (top-left sky stack,
321
+ # bottom road row, etc.) because the underlying image is a single
322
+ # photo split into tiles. In a click-puzzle false grid, the flat
323
+ # cells are the **left + right margins** of the middle row,
324
+ # separated by the rich photo cell — non-contiguous.
325
+ #
326
+ # Use 4-connected components on flat cells. If we get more than
327
+ # one component with >=1 cell each, it's the click-puzzle pattern.
328
+ def components(mask):
329
+ seen = [[False] * side for _ in range(side)]
330
+ comps = 0
331
+ for r in range(side):
332
+ for c in range(side):
333
+ if mask[r][c] and not seen[r][c]:
334
+ comps += 1
335
+ stack = [(r, c)]
336
+ while stack:
337
+ y, x = stack.pop()
338
+ if seen[y][x] or not mask[y][x]:
339
+ continue
340
+ seen[y][x] = True
341
+ for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
342
+ ny, nx = y + dy, x + dx
343
+ if 0 <= ny < side and 0 <= nx < side:
344
+ stack.append((ny, nx))
345
+ return comps
346
+
347
+ if flat_count >= 2 and components(flat_mask) >= 2:
348
+ self.debug.log(
349
+ f"_is_real_grid: flat cells form >=2 disjoint components "
350
+ "→ reject (click-puzzle pattern)."
351
+ )
352
+ return False
353
+ return True
354
+ except Exception as e:
355
+ self.debug.log(f"_is_real_grid check errored ({e}); accepting grid.")
356
+ return True
357
+
358
+ def _solve_grid(
359
+ self,
360
+ image_path: str,
361
+ grid_boxes: List[Tuple[int, int, int, int]],
362
+ retry_mode: Optional[str] = None,
363
+ ) -> Union[ClickAction, DoneAction, WaitAction]:
364
+ n = len(grid_boxes)
365
+ if n == 9:
366
+ rows, cols = 3, 3
367
+ elif n == 16:
368
+ rows, cols = 4, 4
369
+ else:
370
+ cols = int(math.sqrt(n))
371
+ rows = math.ceil(n / cols)
372
+ self.debug.log(f"grid {rows}x{cols} ({n} cells)")
373
+
374
+ cv_selected: List[int] = []
375
+ cv_loading: List[int] = []
376
+ try:
377
+ cv_selected, cv_loading = detect_selected_cells(image_path, grid_boxes, self.debug)
378
+ if cv_selected:
379
+ self.debug.log(f"CV: cells already selected -> {cv_selected}")
380
+ if cv_loading:
381
+ self.debug.log(f"CV: cells loading -> {cv_loading}")
382
+ except Exception as e:
383
+ self.debug.log(f"detect_selected_cells failed: {e}")
384
+
385
+ # SINGLE canonical overlay: get_numbered_grid_overlay (RED labels,
386
+ # top-right, ALL cells 1..N) — byte-for-byte the same overlay used to
387
+ # generate the training data (scripts/build_grid_overlays.py) and the
388
+ # offline grader. The model was trained ONLY on this style; any other
389
+ # overlay (e.g. green, or skipping cells) is out-of-distribution and
390
+ # tanks the live solve rate. We number EVERY cell so the model sees the
391
+ # exact grid it trained on; already-selected/loading cells are filtered
392
+ # AFTER the model responds (below), never by renumbering the grid.
393
+ ext = os.path.splitext(image_path)[1] or ".png"
394
+ with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tf:
395
+ overlay_path = tf.name
396
+ self._temp_files.append(overlay_path)
397
+ get_numbered_grid_overlay(image_path, grid_boxes, output_path=overlay_path)
398
+ self.debug.save_image(overlay_path, "01_grid_overlay.png")
399
+
400
+ with timed("planner.grid"):
401
+ selected = self.planner.get_grid_selection(
402
+ overlay_path, rows=rows, cols=cols, retry_mode=retry_mode,
403
+ )
404
+
405
+ # Map the model's tile IDs to clicks. Skip cells the CV layer already
406
+ # flagged as selected (avoid re-toggling) or still loading.
407
+ final: List[int] = []
408
+ for n in selected:
409
+ try:
410
+ v = int(n)
411
+ except (TypeError, ValueError):
412
+ continue
413
+ if v < 1 or v > len(grid_boxes):
414
+ continue # hallucinated index
415
+ if v in cv_selected or v in cv_loading:
416
+ continue # already selected / not ready — don't re-click
417
+ final.append(v)
418
+
419
+ if not final:
420
+ # Nothing new to click: wait if tiles are still loading, else done.
421
+ if cv_loading:
422
+ return WaitAction(action="wait", duration_ms=1000)
423
+ return DoneAction(action="done")
424
+
425
+ img_w, img_h = self._image_size # type: ignore[misc]
426
+ bboxes: List[List[float]] = []
427
+ for v in final:
428
+ x1, y1, x2, y2 = grid_boxes[v - 1]
429
+ bboxes.append([x1 / img_w, y1 / img_h, x2 / img_w, y2 / img_h])
430
+ return ClickAction(action="click", target_bounding_boxes=bboxes)
431
+
432
+
433
+ def solve_captcha(media_path: str, instruction: str = "", **kwargs) -> Any:
434
+ return CaptchaSolver(**kwargs).solve(media_path, instruction)
@@ -0,0 +1,42 @@
1
+ """
2
+ Lightweight timing utilities.
3
+
4
+ Enable with env var:
5
+ CAPTCHA_TIMINGS=1
6
+
7
+ This prints a single-line timing record to stderr for each instrumented step.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+ import time
15
+ from contextlib import contextmanager
16
+ from typing import Iterator, Optional
17
+
18
+
19
+ def timings_enabled() -> bool:
20
+ return os.getenv("CAPTCHA_TIMINGS", "0") == "1"
21
+
22
+
23
+ @contextmanager
24
+ def timed(label: str, extra: Optional[str] = None) -> Iterator[None]:
25
+ """
26
+ Context manager that prints:
27
+ [TIMING] <label>: <ms>ms (<extra>)
28
+ to stderr when CAPTCHA_TIMINGS=1.
29
+ """
30
+ if not timings_enabled():
31
+ yield
32
+ return
33
+
34
+ t0 = time.perf_counter()
35
+ try:
36
+ yield
37
+ finally:
38
+ dt_ms = (time.perf_counter() - t0) * 1000.0
39
+ suffix = f" ({extra})" if extra else ""
40
+ print(f"[TIMING] {label}: {dt_ms:.2f}ms{suffix}", file=sys.stderr)
41
+
42
+
@@ -0,0 +1,72 @@
1
+ import cv2
2
+ import numpy as np
3
+ from typing import Optional, Tuple
4
+
5
+ def find_checkbox(image_path: str) -> Optional[Tuple[int, int, int, int]]:
6
+ """
7
+ Detects a checkbox in the image using lightweight computer vision techniques.
8
+ Returns the (x, y, w, h) of the best candidate or None.
9
+ """
10
+ img = cv2.imread(image_path)
11
+ if img is None:
12
+ return None
13
+
14
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
15
+
16
+ # Simple thresholding - efficient and works well for high contrast line art like checkboxes
17
+ _, binary = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV)
18
+
19
+ contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
20
+
21
+ height, width = img.shape[:2]
22
+ image_area = width * height
23
+
24
+ candidates = []
25
+
26
+ for cnt in contours:
27
+ x, y, w, h = cv2.boundingRect(cnt)
28
+ area = w * h
29
+ aspect_ratio = float(w) / h
30
+
31
+ # Filter based on properties
32
+
33
+ # 1. Aspect ratio should be close to 1 (square)
34
+ if not (0.8 < aspect_ratio < 1.2):
35
+ continue
36
+
37
+ # 2. Minimum absolute size (to avoid noise/text periods)
38
+ if w < 20 or h < 20:
39
+ continue
40
+
41
+ # 3. Area relative to image size
42
+ if not (0.001 * image_area < area < 0.05 * image_area):
43
+ continue
44
+
45
+ # 4. Solidity/Extent
46
+ contour_area = cv2.contourArea(cnt)
47
+ extent = contour_area / area
48
+ if extent < 0.8: # Stricter extent to avoid complex shapes
49
+ continue
50
+
51
+ # 5. Content check - Checkboxes are usually empty (white/solid)
52
+ # Crop margin to avoid border
53
+ roi = gray[y+5:y+h-5, x+5:x+w-5]
54
+ if roi.size > 0:
55
+ std_dev = np.std(roi)
56
+ if std_dev > 35.0: # High variance means complex content (image)
57
+ continue
58
+
59
+ candidates.append((x, y, w, h))
60
+
61
+ # Heuristic: Checkbox captchas typically have 1 single checkbox.
62
+ # If we find many candidates, it's likely a grid or something else.
63
+ if len(candidates) > 2:
64
+ return None
65
+
66
+ if not candidates:
67
+ return None
68
+
69
+ # Return the largest candidate (most likely the main checkbox)
70
+ best_candidate = max(candidates, key=lambda c: c[2] * c[3])
71
+ return best_candidate
72
+