captchakraken 2.4.0 → 2.6.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 (51) hide show
  1. package/LICENSE +47 -7
  2. package/README.md +24 -4
  3. package/dist/cli-invocation.d.ts +46 -0
  4. package/dist/cli-invocation.js +68 -0
  5. package/dist/index.d.ts +27 -0
  6. package/dist/index.js +29 -1
  7. package/dist/limits.d.ts +51 -0
  8. package/dist/limits.js +54 -0
  9. package/dist/model-name.d.ts +23 -0
  10. package/dist/model-name.js +70 -0
  11. package/dist/playwright-types.d.ts +28 -0
  12. package/dist/puppeteer-adapter.d.ts +11 -0
  13. package/dist/puppeteer-adapter.js +23 -0
  14. package/dist/python-command.d.ts +50 -0
  15. package/dist/python-command.js +60 -0
  16. package/dist/slide-geometry.d.ts +33 -0
  17. package/dist/slide-geometry.js +64 -0
  18. package/dist/solver.d.ts +214 -12
  19. package/dist/solver.js +1285 -165
  20. package/dist/trajectory.d.ts +31 -0
  21. package/dist/trajectory.js +162 -0
  22. package/dist/types.d.ts +128 -11
  23. package/dist/watcher.d.ts +99 -0
  24. package/dist/watcher.js +129 -0
  25. package/package.json +2 -5
  26. package/python/LICENSE +47 -7
  27. package/python/README.md +25 -8
  28. package/python/examples/README.md +36 -0
  29. package/python/examples/_harness.py +182 -94
  30. package/python/examples/demoHcaptcha.py +2 -1
  31. package/python/examples/demoRecaptcha.py +2 -1
  32. package/python/examples/watch_playwright.py +56 -0
  33. package/python/examples/with_playwright.py +43 -0
  34. package/python/pyproject.toml +4 -3
  35. package/python/src/captchakraken/__init__.py +5 -3
  36. package/python/src/captchakraken/action_types.py +24 -3
  37. package/python/src/captchakraken/cli.py +212 -2
  38. package/python/src/captchakraken/config.py +39 -5
  39. package/python/src/captchakraken/keyframes.py +690 -0
  40. package/python/src/captchakraken/models.json +120 -0
  41. package/python/src/captchakraken/page_solver.py +1465 -102
  42. package/python/src/captchakraken/pinned_model.json +5 -7
  43. package/python/src/captchakraken/planner.py +253 -59
  44. package/python/src/captchakraken/prompts.py +485 -0
  45. package/python/src/captchakraken/solver.py +182 -11
  46. package/python/src/captchakraken/timing.py +70 -0
  47. package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
  48. package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
  49. package/python/src/captchakraken/trajectory.py +2 -7
  50. package/python/src/captchakraken/watcher.py +193 -0
  51. package/python/src/captchakraken/planner_types.py +0 -74
@@ -50,10 +50,29 @@ PERP_REFIND = 3 # px: when the along-walk hits a cell, look this far
50
50
  # -> the gutter slanted away, step there and continue. Not
51
51
  # found -> a genuine wall, stop. (Replaces thickness/midpoint
52
52
  # band re-centering entirely.) ~3px tolerates ~25deg tilt.
53
+ NOISE_GAIN = 2.0 # how far the walk tolerances widen per unit of measured
54
+ # image noise (see image_noise / walk_tolerances). 0 restores
55
+ # the old fixed thresholds exactly.
53
56
  MAX_PERP_JUMP = 6.0 # px: max perpendicular re-center per save (bounds drift)
54
57
  MERGE_PX = 8.0 # px: cluster lines whose midline positions are this close
55
58
  MERGE_ANGLE = 0.07 # rad (~4°): and whose angles are this close
56
59
  MIN_CELL = 36 # px: minimum cell pitch (cells have a minimum size)
60
+ FLANK_PITCH_FRACS = (0.18, 0.26, 0.34, 0.42) # probe distances as a fraction of the CELL
61
+ # PITCH, not absolute px. hcaptcha's gutters are ~13px median and can
62
+ # run far wider, so any fixed offset lands INSIDE the gutter and
63
+ # compares gutter to gutter; scaling to the pitch always reaches cell
64
+ # content whatever the provider's gutter width.
65
+ GRID_FLANK_MIN_DE = 16.0 # LAB ΔE: median flank contrast the CHOSEN separators must show —
66
+ # does this lattice actually SEPARATE anything? Two things this gate
67
+ # got wrong first, both measured: (1) as a per-LINE filter it deletes
68
+ # the strays the off-lattice gate counts and ADDS false positives, so
69
+ # it runs on the chosen grid only; (2) averaging the two sides beats
70
+ # taking the weaker one — `min` punishes a real gutter with one pale
71
+ # neighbour, which is most of hcaptcha (lowest true grid 2.7 under
72
+ # `min` vs 17.4 under mean, against false positives at 9.0/7.2 and
73
+ # 18.0/14.1). At 16.0 a false positive goes with no target, guard or
74
+ # fixture cost; 18.1 clears both but sits 0.3 under the lowest real
75
+ # reCAPTCHA 4x4, which is fitting to samples rather than separating.
57
76
  LINE_STD_TOL = 7.0 # LAB: max std of color along a line (consistency gate)
58
77
  STEP = 1 # px: walk one pixel at a time (cheap: just a color test
59
78
  # per step; perpendicular thickness only computed on save)
@@ -82,7 +101,24 @@ LATTICE_TOL = 0.18 # a same-colour line within this fraction of a pitch of a
82
101
  MAX_OFF_LATTICE = 1 # max same-colour internal lines allowed OFF the lattice
83
102
  # before the structure is judged photo texture, not a grid
84
103
  EVEN_TOL = 0.18 # consecutive cell gaps must match the median pitch within
85
- # this fraction (the equidistant-cells rule)
104
+ # this fraction (the equidistant-cells rule), checked on the
105
+ # candidate LINE positions before the lattice is built
106
+ MIN_IMAGE_AREA_COVERAGE = 0.30 # frac of the image AREA the detected grid must
107
+ # occupy. Distinct from MIN_GRID_COVERAGE below, which is a
108
+ # per-axis pitch ratio and currently unused. A
109
+ # captcha grid IS the puzzle, so it fills most of the widget;
110
+ # a lattice found in a corner is scenery. Measured over 2239
111
+ # correctly-detected real grids the floor is 0.348 (hcaptcha's
112
+ # low tail; recaptcha sits at 0.62+), so 0.30 keeps a ~14%
113
+ # margin. Deliberately NOT set to 0.33 — that would also catch
114
+ # an observed video-keyframe false positive at 0.322, but
115
+ # leaves only 5% headroom under real grids, which is buying a
116
+ # false-positive fix with a future missed grid.
117
+ CELL_REGULARITY_TOL = 0.12 # the same rule applied to the EMITTED cells: every cell's
118
+ # width/height must be within this fraction of the median. The
119
+ # line-position check above cannot see degenerate boxes built
120
+ # from near-duplicate lines, which is how a 1px-pitch "grid"
121
+ # reached the caller. See _boxes_are_regular.
86
122
  MIN_GRID_DIM = 3 # min cells per axis (currently >=3x3; rectangles like 6x4
87
123
  # are allowed — rows and cols may differ, each >= this)
88
124
  CORROB_FRAC = 0.9 # frac of a cell a perpendicular line must extend PAST a
@@ -160,6 +196,9 @@ MAX_VIRTUAL_NODES = 2 # hard cap on invented internal lines per completed ru
160
196
  # Recovers a single missing internal gutter (the common
161
197
  # sky-bordered-row case) and at most one outer-line extension;
162
198
  # prevents conjuring a whole grid from a 2-line fragment.
199
+ UNUSED_LINE_PENALTY = 400.0 # score added per corroborated internal line the
200
+ # candidate does NOT incorporate, so a 4x4's
201
+ # [r1,r2,r3] is not silently dropped to a 3-row [r2,r3].
163
202
  VIRTUAL_NODE_PENALTY = 600.0 # score added per invented node, so a fully-real
164
203
  # lattice of the same dimension always outranks a completed
165
204
  # one and fewer interpolations win.
@@ -178,6 +217,12 @@ EDGE_BLEED_PX = 6 # px: if a gutter's traced span reaches this close to the
178
217
  # a real extra cell — so it does NOT trigger a missing row/col.
179
218
  # Real captcha grids are inset from the edges (the gutters stop
180
219
  # ~60-120px short), so this never suppresses a true missing line.
220
+ OFF_LATTICE_CLUSTER_PX = 20 # px: off-lattice lines closer together than this are
221
+ # ONE object's edges (a roofline, a railing), not
222
+ # distinct cell boundaries, so they count once. Measured
223
+ # plateau is 17..23 on the real corpus: below it the two
224
+ # busy-tile 4x4s are lost, at 24 a textured drag puzzle
225
+ # becomes a false positive.
181
226
  MAX_OFF_LATTICE_CLEAN = 1 # max stray CLEAN off-lattice lines forgiven on a proven
182
227
  # clean grid (a horizon / wire / UI rule). Beyond this the
183
228
  # strays are counted — a textured photo whose white-ish edges
@@ -276,12 +321,53 @@ def _cells_have_content(lab, boxes, rows, cols, gutter_lines):
276
321
  return sum(1 for d in divs if d > CELL_DIVERGE_TOL) >= frac * len(divs)
277
322
 
278
323
 
324
+ #: Scale OKLab's native ranges (L 0..1, a/b ~±0.4) onto the CIELAB conventions the
325
+ #: thresholds in this module are written in (L 0..100, a/b in the same units). This
326
+ #: is a units change only — it keeps every tuned constant meaningful instead of
327
+ #: forcing a simultaneous recalibration of all of them.
328
+ _OK_L_SCALE = 100.0
329
+ _OK_AB_SCALE = 300.0
330
+
331
+ _OK_M1 = np.array([[0.4122214708, 0.5363325363, 0.0514459929],
332
+ [0.2119034982, 0.6806995451, 0.1073969566],
333
+ [0.0883024619, 0.2817188376, 0.6299787005]], dtype=np.float32)
334
+ _OK_M2 = np.array([[0.2104542553, 0.7936177850, -0.0040720468],
335
+ [1.9779984951, -2.4285922050, 0.4505937099],
336
+ [0.0259040371, 0.7827717662, -0.8086757660]], dtype=np.float32)
337
+
338
+ #: sRGB -> linear, as a 256-entry lookup. The transfer function is per-CHANNEL and
339
+ #: the input is 8-bit, so there are only 256 possible answers; a table turns the
340
+ #: pow() over every pixel into a gather. This is what keeps OKLab affordable.
341
+ _SRGB_TO_LINEAR = np.where(
342
+ np.arange(256, dtype=np.float32) / 255.0 <= 0.04045,
343
+ (np.arange(256, dtype=np.float32) / 255.0) / 12.92,
344
+ (((np.arange(256, dtype=np.float32) / 255.0) + 0.055) / 1.055) ** 2.4,
345
+ ).astype(np.float32)
346
+
347
+
279
348
  def _to_lab(img_bgr):
280
- lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB).astype(np.float32)
281
- lab[:, :, 0] *= (100.0 / 255.0)
282
- lab[:, :, 1] -= 128.0
283
- lab[:, :, 2] -= 128.0
284
- return lab
349
+ """Perceptual colour for the whole detector — OKLab, scaled to CIELAB units.
350
+
351
+ CIELAB via CIE76 (what this used) is not perceptually uniform: it over-weights
352
+ a/b, so two colours that look identical can sit far apart when their chroma is
353
+ just JPEG noise. That is why the walk gates on the L channel alone — a
354
+ workaround for the colour space rather than a property of gutters. OKLab IS
355
+ close to uniform, so a single distance behaves sensibly across hues, which is
356
+ what a separator in some colour we have not seen yet needs.
357
+
358
+ Cost is a 256-entry LUT plus two 3x3 matrix products and a cube root:
359
+ ~2-3 ms on a captcha-sized image against find_grid's ~53 ms, measured. A GPU
360
+ was considered and rejected — the transfer would cost as much as the work, and
361
+ the line walk is sequential per step anyway.
362
+ """
363
+ rgb_lin = _SRGB_TO_LINEAR[img_bgr[:, :, ::-1]] # BGR -> RGB, linearised
364
+ lms = rgb_lin @ _OK_M1.T
365
+ np.cbrt(lms, out=lms)
366
+ ok = lms @ _OK_M2.T
367
+ ok[:, :, 0] *= _OK_L_SCALE
368
+ ok[:, :, 1] *= _OK_AB_SCALE
369
+ ok[:, :, 2] *= _OK_AB_SCALE
370
+ return ok
285
371
 
286
372
 
287
373
  # ── Stage A: vectorized O(n*m) ridge / run-length map ───────────────────────
@@ -521,19 +607,58 @@ def _batch_gather(lab, axis, along_i, perp_i):
521
607
  return lab[along_i, perp_i] # V line: along=y, perp=x
522
608
 
523
609
 
524
- def _batch_accept(pix, seed_L, prev_L, line_color):
610
+ def image_noise(lab):
611
+ """This image's noise floor: the 90th-percentile lightness difference between
612
+ ADJACENT BRIGHT pixels.
613
+
614
+ A gutter painted by a vendor's compositor is bit-identical white, so this is
615
+ ~0 and the walk tolerances stay exactly where they were tuned. Anything that
616
+ perturbs flat areas — JPEG, rescaling, fractional device-pixel-ratio, a
617
+ generator that dithers — raises it, and the tolerances widen to match.
618
+
619
+ Measured on flat BRIGHT regions specifically, not the whole image: tile
620
+ content is supposed to vary, and including it would report "noisy" for every
621
+ busy photo and defeat the point.
622
+ """
623
+ L = lab[:, :, 0]
624
+ bright = L > 85
625
+ dh = np.abs(np.diff(L, axis=1)); mh = bright[:, :-1] & bright[:, 1:]
626
+ dv = np.abs(np.diff(L, axis=0)); mv = bright[:-1, :] & bright[1:, :]
627
+ vals = np.concatenate([dh[mh], dv[mv]])
628
+ if vals.size < 100:
629
+ return 0.0
630
+ return float(np.percentile(vals, 90))
631
+
632
+
633
+ def walk_tolerances(noise):
634
+ """(seed_tol, step_tol, cont_tol^2) for an image with this noise floor.
635
+
636
+ Scaled rather than raised outright. A flat widening costs real detections —
637
+ measured on the corpus, SEED_L/STEP_L of 10/8 applied to every image drops a
638
+ pristine sample, and 14/12 drops four — because a pristine gutter given a
639
+ loose band lets the walk wander into tile content. Scaling leaves those
640
+ images at exactly the old thresholds (their noise is ~0) and spends the extra
641
+ slack only where the render actually is noisy.
642
+ """
643
+ return (SEED_L_TOL + NOISE_GAIN * noise,
644
+ STEP_L_TOL + NOISE_GAIN * noise,
645
+ (CONT_TOL + NOISE_GAIN * noise) ** 2)
646
+
647
+
648
+ def _batch_accept(pix, seed_L, prev_L, line_color, tols):
525
649
  """Vectorized copy of _trace_one.ok over M traces at once."""
650
+ seed_tol, step_tol, cont_tol2 = tols
526
651
  L = pix[:, 0]
527
652
  d0 = pix[:, 0] - line_color[:, 0]
528
653
  d1 = pix[:, 1] - line_color[:, 1]
529
654
  d2 = pix[:, 2] - line_color[:, 2]
530
655
  de2 = d0 * d0 + d1 * d1 + d2 * d2
531
- return ((np.abs(L - seed_L) <= SEED_L_TOL)
532
- & (np.abs(L - prev_L) <= STEP_L_TOL)
533
- & (de2 <= CONT_TOL2))
656
+ return ((np.abs(L - seed_L) <= seed_tol)
657
+ & (np.abs(L - prev_L) <= step_tol)
658
+ & (de2 <= cont_tol2))
534
659
 
535
660
 
536
- def _walk_dir(lab, axis, along_seed, perp_seed, seed_ref, direction):
661
+ def _walk_dir(lab, axis, along_seed, perp_seed, seed_ref, direction, tols):
537
662
  """Walk all N seeds one direction in lockstep. Returns (last_along[N],
538
663
  last_perp[N], support[N]). Reproduces _trace_one.extend exactly: per-step
539
664
  accept test + ±PERP_REFIND perpendicular slant re-find (nearest first, up
@@ -568,7 +693,7 @@ def _walk_dir(lab, axis, along_seed, perp_seed, seed_ref, direction):
568
693
  pe = perp[ai]
569
694
  ali = al.astype(np.intp); pei = pe.astype(np.intp)
570
695
  pix = _batch_gather(lab, axis, ali, pei)
571
- ok = _batch_accept(pix, seed_L[ai], prev_L[ai], line_color[ai])
696
+ ok = _batch_accept(pix, seed_L[ai], prev_L[ai], line_color[ai], tols)
572
697
 
573
698
  new_perp = pe.copy()
574
699
  new_pix = pix.copy()
@@ -592,7 +717,7 @@ def _walk_dir(lab, axis, along_seed, perp_seed, seed_ref, direction):
592
717
  cpx = _batch_gather(lab, axis, al[sid].astype(np.intp),
593
718
  cand_pe.astype(np.intp))
594
719
  passc = _batch_accept(cpx, seed_L[ai[sid]],
595
- prev_L[ai[sid]], line_color[ai[sid]])
720
+ prev_L[ai[sid]], line_color[ai[sid]], tols)
596
721
  hit = sid[passc]
597
722
  if hit.size:
598
723
  new_perp[hit] = cand_pe[passc]
@@ -619,11 +744,11 @@ def _walk_dir(lab, axis, along_seed, perp_seed, seed_ref, direction):
619
744
  return last_along, last_perp, support
620
745
 
621
746
 
622
- def _walk_batch(lab, axis, along_seed, perp_seed, seed_ref):
747
+ def _walk_batch(lab, axis, along_seed, perp_seed, seed_ref, tols):
623
748
  """Walk N seeds both directions in lockstep. Returns a_lo,a_hi (min/max along
624
749
  reached), perp_lo,perp_hi (perp at those ends), support (steps both dirs)."""
625
- fa, fp, ns_f = _walk_dir(lab, axis, along_seed, perp_seed, seed_ref, +1)
626
- ba, bp, ns_b = _walk_dir(lab, axis, along_seed, perp_seed, seed_ref, -1)
750
+ fa, fp, ns_f = _walk_dir(lab, axis, along_seed, perp_seed, seed_ref, +1, tols)
751
+ ba, bp, ns_b = _walk_dir(lab, axis, along_seed, perp_seed, seed_ref, -1, tols)
627
752
  a_hi = np.maximum(along_seed.astype(np.float64), fa)
628
753
  a_lo = np.minimum(along_seed.astype(np.float64), ba)
629
754
  return a_lo, a_hi, bp, fp, ns_f + ns_b
@@ -922,11 +1047,13 @@ def _collect_seeds(lab, axis, ridge, c_lo, c_hi, span):
922
1047
  return along_seed, perp_seed, seed_ref
923
1048
 
924
1049
 
925
- def _trace_lines(lab, axis, seed_bias):
1050
+ def _trace_lines(lab, axis, seed_bias, tols=None):
926
1051
  """Vectorized: collect all seeds, walk them in lockstep (_walk_batch), then
927
1052
  build a PotentialGridLine per surviving trace with the same per-line gates as
928
1053
  the scalar path (SLANT_CAP, SUPPORT_FRAC span, LINE_STD_TOL)."""
929
1054
  h, w = lab.shape[:2]
1055
+ if tols is None:
1056
+ tols = walk_tolerances(image_noise(lab))
930
1057
  ridge = _build_ridge_map(lab, axis)
931
1058
  if axis == 1:
932
1059
  span = h; c_lo, c_hi = int(w * 0.2), int(w * 0.8)
@@ -935,7 +1062,7 @@ def _trace_lines(lab, axis, seed_bias):
935
1062
  along_seed, perp_seed, seed_ref = _collect_seeds(lab, axis, ridge, c_lo, c_hi, span)
936
1063
  if along_seed.size == 0:
937
1064
  return []
938
- a_lo, a_hi, perp_lo, perp_hi, support = _walk_batch(lab, axis, along_seed, perp_seed, seed_ref)
1065
+ a_lo, a_hi, perp_lo, perp_hi, support = _walk_batch(lab, axis, along_seed, perp_seed, seed_ref, tols)
939
1066
 
940
1067
  full = w if axis == 1 else h
941
1068
  lines = []
@@ -1031,8 +1158,29 @@ def _pick(group):
1031
1158
  becomes the cluster's max (its real length), and we record the cluster size."""
1032
1159
  best = max(group, key=lambda l: l.support)
1033
1160
  wsum = sum(l.support for l in group)
1034
- best.midline_pos = sum(l.midline_pos * l.support for l in group) / wsum
1161
+ centre = sum(l.midline_pos * l.support for l in group) / wsum
1162
+ # COLOUR comes from the member sitting on that centre, not from the longest
1163
+ # one. The two are routinely different members: a gutter is seeded from each
1164
+ # of its rows, and the longest trace is often the one seeded at its EDGE,
1165
+ # which then runs along tile content rather than the separator. Measured on
1166
+ # hcaptcha_images_ice_cream4: the kept trace sat 11 px off the gutter, on a
1167
+ # pale watermark that exists at some x and not others, and carried
1168
+ # color_std 2.80 while the on-centre traces of the same gutter measured
1169
+ # 0.55-0.80. Position was already re-centred here; the colour was not, so
1170
+ # every downstream colour gate judged the grid by a line that was not on it —
1171
+ # and `CLEAN_GUTTER_STD` (2.3) then withheld the pale-tile relaxation in
1172
+ # `_cells_have_content`, failing a correct 3x3 (2026-08-11).
1173
+ # Ordered by CLEANLINESS, not by distance to the centre: a member a hair
1174
+ # nearer the centre can still be the dirtier read, and preferring it swapped a
1175
+ # 0.00 trace for a 3.43 one on recaptcha_1774954808237_rhitt and lost a grid
1176
+ # that had detected fine. Restricted to members with comparable support, so a
1177
+ # three-sample fragment cannot win on a trivially perfect std.
1178
+ strong = [l for l in group if l.support >= 0.5 * best.support] or group
1179
+ cleanest = min(strong, key=lambda l: (l.color_std, abs(l.midline_pos - centre)))
1180
+ best.midline_pos = centre
1035
1181
  best.support = max(l.support for l in group)
1182
+ best.color_lab = cleanest.color_lab
1183
+ best.color_std = cleanest.color_std
1036
1184
  return best
1037
1185
 
1038
1186
 
@@ -1074,6 +1222,68 @@ def _internal(lines, total):
1074
1222
  if total * EDGE_MARGIN < l.midline_pos < total * (1 - EDGE_MARGIN)]
1075
1223
 
1076
1224
 
1225
+ def _boxes_are_regular(boxes, rows, cols):
1226
+ """Do the EMITTED cells actually form a regular grid — equal widths, equal
1227
+ heights, none degenerate?
1228
+
1229
+ `_even_spacing_ok` already checks the candidate LINE positions, but it runs
1230
+ before the lattice is generated and so cannot see what the boxes came out as.
1231
+ A lattice built from near-duplicate lines passes it and then emits cells one
1232
+ pixel wide: an observed false positive had column pitches
1233
+ [1, 1, 151, 1, 1, 151, 1, 1], which is "evenly spaced" by no useful definition
1234
+ yet cleared every gate upstream. Tightening EVEN_TOL does not catch this —
1235
+ measured from 0.18 down to 0.03, the result does not move — because the
1236
+ irregularity is created after that check, not before it.
1237
+
1238
+ This is the last word on geometry: whatever the tracer believed, the thing we
1239
+ hand back has to look like a grid.
1240
+ """
1241
+ if not boxes or rows < 1 or cols < 1:
1242
+ return False
1243
+
1244
+ # The EDGES first. Cell sizes alone are not enough: a lattice built from two
1245
+ # lines one pixel apart emits overlapping cells that each measure a plausible
1246
+ # ~50px, so a width/height check passes it. The tell is in the edge pitch —
1247
+ # an observed false positive on a text captcha had column edges
1248
+ # [206, 254, 255, 303] (pitch 48, 1, 48) and rows [1, 49, 1, 52, 47, 1].
1249
+ # "Evenly spaced" has to mean the LINES are evenly spaced.
1250
+ # Column edges from ONE row and row edges from ONE column — boxes are
1251
+ # row-major, so these index directly.
1252
+ #
1253
+ # NOT the set of every box's left edge. A grid with any slant rounds row 1's
1254
+ # second column to x=201 and row 2's to x=202, so the union contains both and
1255
+ # the pitch list reads [110, 1, 110, 1] — a phantom 1px separator invented by
1256
+ # inter-row rounding, on cells whose widths agree to 0.9%. That rejected 101
1257
+ # real hcaptcha grids and 4 prosopo ones. One row's edges are mutually
1258
+ # consistent by construction.
1259
+ if len(boxes) < rows * cols:
1260
+ return False
1261
+ col_edges = [boxes[c][0] for c in range(cols)]
1262
+ row_edges = [boxes[r * cols][1] for r in range(rows)]
1263
+ for edges in (col_edges, row_edges):
1264
+ if len(edges) < 2:
1265
+ continue # a single row/column has no pitch
1266
+ pitches = np.diff(np.array(sorted(edges), dtype=np.float64))
1267
+ if np.any(pitches < MIN_CELL):
1268
+ return False # duplicate / near-duplicate lines
1269
+ mp = float(np.median(pitches))
1270
+ if np.any(np.abs(pitches - mp) > CELL_REGULARITY_TOL * mp):
1271
+ return False # unevenly spaced separators
1272
+
1273
+ widths = np.array([b[2] - b[0] for b in boxes], dtype=np.float64)
1274
+ heights = np.array([b[3] - b[1] for b in boxes], dtype=np.float64)
1275
+ if widths.size == 0 or heights.size == 0:
1276
+ return False
1277
+ mw, mh = float(np.median(widths)), float(np.median(heights))
1278
+ if mw < MIN_CELL or mh < MIN_CELL:
1279
+ return False
1280
+ if np.any(np.abs(widths - mw) > CELL_REGULARITY_TOL * mw):
1281
+ return False
1282
+ if np.any(np.abs(heights - mh) > CELL_REGULARITY_TOL * mh):
1283
+ return False
1284
+ return True
1285
+
1286
+
1077
1287
  def _even_spacing_ok(positions, total):
1078
1288
  """True if `positions` (sorted internal-line coords) are EVENLY spaced — every
1079
1289
  consecutive INTERNAL gap is within EVEN_TOL of the median. This is the core
@@ -1104,6 +1314,38 @@ def _even_spacing_ok(positions, total):
1104
1314
  return True, pitch
1105
1315
 
1106
1316
 
1317
+ def _flank_contrast(lab, line, pitch):
1318
+ """Median over the line's length of the WEAKER side's colour distance to the line
1319
+ itself, probing several fractions of the cell pitch each side and keeping the
1320
+ nearest real content. High for a separator between two filled cells; ~0 for a line
1321
+ that separates nothing."""
1322
+ h, w = lab.shape[:2]
1323
+ (x0, y0), (x1, y1) = line.start, line.end
1324
+ if line.orientation == 'h':
1325
+ xs = np.arange(min(x0, x1), max(x0, x1) + 1, 3.0)
1326
+ ys = line.midline_pos + np.tan(line.angle) * (xs - w / 2.0)
1327
+ else:
1328
+ ys = np.arange(min(y0, y1), max(y0, y1) + 1, 3.0)
1329
+ xs = line.midline_pos + np.tan(line.angle) * (ys - h / 2.0)
1330
+ jx = np.round(xs).astype(np.intp); jy = np.round(ys).astype(np.intp)
1331
+ ok = (jx >= 0) & (jx < w) & (jy >= 0) & (jy < h)
1332
+ if ok.sum() < 3:
1333
+ return 0.0
1334
+ jx, jy = jx[ok], jy[ok]
1335
+ d_lo = d_hi = None
1336
+ for fr in FLANK_PITCH_FRACS:
1337
+ fo = max(3, int(round(fr * pitch)))
1338
+ if line.orientation == 'h':
1339
+ a = lab[np.clip(jy - fo, 0, h - 1), jx]; b = lab[np.clip(jy + fo, 0, h - 1), jx]
1340
+ else:
1341
+ a = lab[jy, np.clip(jx - fo, 0, w - 1)]; b = lab[jy, np.clip(jx + fo, 0, w - 1)]
1342
+ da = np.sqrt(np.sum((a.astype(np.float64) - line.color_lab) ** 2, axis=1))
1343
+ db = np.sqrt(np.sum((b.astype(np.float64) - line.color_lab) ** 2, axis=1))
1344
+ d_lo = da if d_lo is None else np.maximum(d_lo, da)
1345
+ d_hi = db if d_hi is None else np.maximum(d_hi, db)
1346
+ return float(np.median((d_lo + d_hi) / 2.0))
1347
+
1348
+
1107
1349
  def _line_span_perp(line):
1108
1350
  """(lo, hi) extent of a line along its OWN run direction (H -> x span, V -> y
1109
1351
  span) — i.e. how far it reaches in the perpendicular axis' coordinate."""
@@ -1308,25 +1550,34 @@ def _axis_candidates(lines, total):
1308
1550
  run_idx.append(best_k)
1309
1551
  pos = lines[best_k].midline_pos
1310
1552
  jj = best_k
1311
- if len(run_idx) < 2:
1312
- continue
1313
- positions = [lines[r].midline_pos for r in run_idx]
1314
- ok, pitch = _even_spacing_ok(positions, total)
1315
- if not ok:
1316
- continue
1317
- grp = [lines[r] for r in run_idx]
1318
- dim = len(run_idx) + 1 # OPEN: K internal lines -> K+1 cells
1319
- if dim < MIN_GRID_DIM:
1320
- continue
1321
- key = (dim, tuple(round(p) for p in positions))
1322
- if key in seen:
1323
- continue
1324
- seen.add(key)
1325
- ang_pen = max(l.angle for l in grp) - min(l.angle for l in grp)
1326
- center_off = abs((positions[0] + positions[-1]) / 2 - total / 2) / total
1327
- scale_err = abs(pitch - total / dim) / total
1328
- score = center_off * 1000 + scale_err * 500 + ang_pen * 200
1329
- cand.setdefault(dim, []).append((positions, score, pitch, grp))
1553
+ # Emit EVERY prefix of the grown run, not just the maximal one. The
1554
+ # run's last line may be the grid's outer BORDER rather than an
1555
+ # internal separator (a geetest/prosopo panel draws one), and then the
1556
+ # correct lattice is the shorter prefix — which the greedy grow had
1557
+ # already swallowed, so it was never a candidate and detection fell
1558
+ # through to a half-pitch completed lattice. Longer runs still win on
1559
+ # score: the `unused` penalty in extract_grid_from_lines charges every
1560
+ # corroborated line a candidate leaves out, so a genuine 4x4 is never
1561
+ # dropped to its 3-row prefix.
1562
+ for end in range(2, len(run_idx) + 1):
1563
+ sub = run_idx[:end]
1564
+ positions = [lines[r].midline_pos for r in sub]
1565
+ ok, pitch = _even_spacing_ok(positions, total)
1566
+ if not ok:
1567
+ continue
1568
+ grp = [lines[r] for r in sub]
1569
+ dim = len(sub) + 1 # OPEN: K internal lines -> K+1 cells
1570
+ if dim < MIN_GRID_DIM:
1571
+ continue
1572
+ key = (dim, tuple(round(p) for p in positions))
1573
+ if key in seen:
1574
+ continue
1575
+ seen.add(key)
1576
+ ang_pen = max(l.angle for l in grp) - min(l.angle for l in grp)
1577
+ center_off = abs((positions[0] + positions[-1]) / 2 - total / 2) / total
1578
+ scale_err = abs(pitch - total / dim) / total
1579
+ score = center_off * 1000 + scale_err * 500 + ang_pen * 200
1580
+ cand.setdefault(dim, []).append((positions, score, pitch, grp))
1330
1581
  # Lattice completion: add candidates that interpolate/extrapolate a missing
1331
1582
  # internal gutter from a clean partial run (sky-bordered grids). They carry a
1332
1583
  # virtual-node penalty so they only win when no fully-real lattice of the same
@@ -1415,7 +1666,12 @@ def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
1415
1666
  # under-count, e.g. a 4x4's [r1,r2,r3] dropped to a 3-row
1416
1667
  # [r2,r3]). Penalise each corroborated line the candidate leaves
1417
1668
  # UNUSED. (rows-1) H lines and (cols-1) V lines are used.
1418
- unused = (n_hkeep - (rows - 1)) + (n_vkeep - (cols - 1))
1669
+ # CLAMPED AT 0 PER AXIS: a completed lattice can use MORE lines
1670
+ # than were corroborated (the extras are invented virtual nodes),
1671
+ # and an unclamped count turns negative there — paying a BONUS
1672
+ # for inventing rows, which is how a half-pitch 5x4 once outscored
1673
+ # a 4x4 built from the same two real gutters.
1674
+ unused = max(0, n_hkeep - (rows - 1)) + max(0, n_vkeep - (cols - 1))
1419
1675
  # Grid-span fit: the perpendicular gutters run the WHOLE grid and
1420
1676
  # stop at its outer borders. The grid's extrapolated borders are
1421
1677
  # one pitch beyond the outer internal lines: H rows occupy
@@ -1464,7 +1720,7 @@ def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
1464
1720
  + _missing(max(0.0, col_lft - hx_lo), vd, h_lft_edge and not h_rgt_edge)
1465
1721
  + _missing(max(0.0, hx_hi - col_rgt), vd, h_rgt_edge and not h_lft_edge))
1466
1722
  score = (hsc + vsc + s_diff * 1000 + abs(slant) * 500
1467
- + unused * 400 + ang_inc + span_pen)
1723
+ + unused * UNUSED_LINE_PENALTY + ang_inc + span_pen)
1468
1724
  if score < best_score:
1469
1725
  boxes = _generate_grid(rows, cols, hpos, vpos, hd, vd, h, w, slant)
1470
1726
  # Cell-content gate IN the loop so a rejected (over-counted)
@@ -1500,7 +1756,7 @@ def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
1500
1756
  # evidence the central cells are irregular, and must not count. The central
1501
1757
  # closed region is the only reliable signal (per design).
1502
1758
  def _off_lattice(lines, total, anchors, pitch):
1503
- n = 0
1759
+ off_pos = []
1504
1760
  clean_skipped = 0
1505
1761
  lo, hi = anchors[0], anchors[-1]
1506
1762
  for l in _internal(lines, total):
@@ -1522,11 +1778,31 @@ def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
1522
1778
  and clean_skipped < MAX_OFF_LATTICE_CLEAN):
1523
1779
  clean_skipped += 1
1524
1780
  continue
1525
- n += 1
1526
- return n
1781
+ off_pos.append(l.midline_pos)
1782
+ # Count CLUSTERS, not lines. Two off-lattice lines closer together than
1783
+ # OFF_LATTICE_CLUSTER_PX cannot both be cell boundaries — cells have a minimum
1784
+ # size — so they are one busy tile's internal edges (a roofline, a railing, a
1785
+ # horizon) and are one piece of evidence, not several. The textured-photo FP
1786
+ # mode scatters strays across the WHOLE grid, so it still counts many clusters
1787
+ # and is still rejected.
1788
+ off_pos.sort()
1789
+ return sum(1 for i, p in enumerate(off_pos)
1790
+ if i == 0 or p - off_pos[i - 1] > OFF_LATTICE_CLUSTER_PX)
1527
1791
  if (_off_lattice(h_lines, h, hpos, hd) > MAX_OFF_LATTICE
1528
1792
  or _off_lattice(v_lines, w, vpos, vd) > MAX_OFF_LATTICE):
1529
1793
  return None, None, None
1794
+ if not _boxes_are_regular(boxes, rows, cols):
1795
+ return None, None, None
1796
+ # Do the chosen separators actually SEPARATE anything? (see GRID_FLANK_MIN_DE).
1797
+ # An H separator's flanks lie a row pitch away, a V separator's a column pitch away.
1798
+ if lab is not None:
1799
+ if float(np.median([_flank_contrast(lab, l, hd if l.orientation == 'h' else vd)
1800
+ for l in chosen_lns])) < GRID_FLANK_MIN_DE:
1801
+ return None, None, None
1802
+ x0 = min(b[0] for b in boxes); x1 = max(b[2] for b in boxes)
1803
+ y0 = min(b[1] for b in boxes); y1 = max(b[3] for b in boxes)
1804
+ if (x1 - x0) * (y1 - y0) < MIN_IMAGE_AREA_COVERAGE * w * h:
1805
+ return None, None, None
1530
1806
  return boxes, (rows, cols), slant
1531
1807
 
1532
1808
 
@@ -1536,10 +1812,11 @@ def _detect_grid(image_path, seed_bias=0.0):
1536
1812
  return None
1537
1813
  h, w = img.shape[:2]
1538
1814
  lab = _to_lab(img)
1539
- h_lines = _trace_lines(lab, axis=1, seed_bias=seed_bias)
1815
+ tols = walk_tolerances(image_noise(lab)) # once per image, not per axis
1816
+ h_lines = _trace_lines(lab, axis=1, seed_bias=seed_bias, tols=tols)
1540
1817
  if len(_internal(h_lines, h)) < 2:
1541
1818
  return None
1542
- v_lines = _trace_lines(lab, axis=0, seed_bias=-seed_bias)
1819
+ v_lines = _trace_lines(lab, axis=0, seed_bias=-seed_bias, tols=tols)
1543
1820
  if len(_internal(v_lines, w)) < 2:
1544
1821
  return None
1545
1822
  boxes, dims, slant = extract_grid_from_lines(h_lines, v_lines, h, w, lab=lab)
@@ -1684,19 +1961,37 @@ def is_cell_selected(image_path, grid_boxes, cell_number, debug_manager=None):
1684
1961
  selected, _ = detect_selected_cells(image_path, grid_boxes, debug_manager)
1685
1962
  return cell_number in selected
1686
1963
 
1964
+ # How far outside the teal blob a white pixel may sit and still be read as part
1965
+ # of the mark. Two pixels, because the ringed rendering draws the white ON the
1966
+ # disc's rim and fragments the teal underneath it, so a strict inside-the-hull
1967
+ # test finds only a quarter of those. Wider does not pay: at 4px the phantom
1968
+ # rate doubles for ~1 point of recall.
1969
+ _HCAPTCHA_GLYPH_SLACK_PX = 2.0
1970
+
1971
+
1687
1972
  def _has_hcaptcha_check(roi):
1688
1973
  """Detect hCaptcha's selected-state blue circle in the top-right corner.
1689
1974
 
1690
- The badge is a small filled cyan-teal circle (~10-14 px) with a white
1691
- checkmark glyph inside. Naively counting blue-dominant pixels matches
1692
- sky tiles, so we require BOTH:
1693
- - >=8 cyan-teal pixels (B high, G mid-high, R low)
1694
- - >=2 near-white pixels (the checkmark) in the same patch
1975
+ The badge is a small cyan-teal circle (~10-14 px) carrying a white glyph.
1976
+ Counting pixels is not enough on its own and never was: >=8 teal pixels
1977
+ ANYWHERE in the corner plus >=2 near-white pixels ANYWHERE in the same patch
1978
+ is satisfied by blue sky with a white pole in it, and that tile is then
1979
+ reported as already selected — so the solver drops it from the model's
1980
+ answer and never clicks it. Measured at 74 phantom selections over 3051
1981
+ corners of boards with nothing selected on them.
1982
+
1983
+ What a badge has and a photograph does not is that the white BELONGS TO the
1984
+ teal mark: it sits inside the disc, or hugs its rim. So the counts stay as a
1985
+ cheap gate, and the verdict is that >=2 of the white pixels lie within
1986
+ `_HCAPTCHA_GLYPH_SLACK_PX` of the largest teal blob's convex hull. A photo
1987
+ puts the teal in one place and the white in another, and fails that.
1695
1988
  """
1696
1989
  if roi is None or roi.size == 0:
1697
1990
  return False
1698
1991
  flat = roi.reshape(-1, 3).astype(np.int32)
1699
- # Teal/cyan: B>120, G>80, R<80, AND B-R gap > 60.
1992
+ # Teal/cyan: B>120, G>80, R<80, AND B-R gap > 60. Deliberately loose — the
1993
+ # badge's exact colour is not known to a delta-E's precision, and pinning it
1994
+ # to one costs more real badges than it saves phantoms.
1700
1995
  teal = (
1701
1996
  (flat[:, 0] > 120)
1702
1997
  & (flat[:, 1] > 80)
@@ -1705,7 +2000,27 @@ def _has_hcaptcha_check(roi):
1705
2000
  )
1706
2001
  # Bright white check mark inside the circle.
1707
2002
  white = (flat[:, 0] > 220) & (flat[:, 1] > 220) & (flat[:, 2] > 220)
1708
- return int(teal.sum()) >= 8 and int(white.sum()) >= 2
2003
+ if int(teal.sum()) < 8 or int(white.sum()) < 2:
2004
+ return False
2005
+
2006
+ h, w = roi.shape[:2]
2007
+ # Close first: the glyph cuts the disc into pieces, and the mark is one
2008
+ # blob, so the pieces have to be put back together before the largest is
2009
+ # taken.
2010
+ mask = cv2.morphologyEx(
2011
+ teal.reshape(h, w).astype(np.uint8) * 255, cv2.MORPH_CLOSE, np.ones((3, 3), np.uint8)
2012
+ )
2013
+ contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
2014
+ if not contours:
2015
+ return False
2016
+ hull = cv2.convexHull(max(contours, key=cv2.contourArea))
2017
+ ys, xs = np.nonzero(white.reshape(h, w))
2018
+ inside = sum(
2019
+ 1
2020
+ for y, x in zip(ys, xs)
2021
+ if cv2.pointPolygonTest(hull, (float(x), float(y)), True) >= -_HCAPTCHA_GLYPH_SLACK_PX
2022
+ )
2023
+ return inside >= 2
1709
2024
 
1710
2025
 
1711
2026
  def _has_badge(roi, rgb):