captchakraken 2.3.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.
- package/LICENSE +47 -7
- package/README.md +24 -4
- package/dist/cli-invocation.d.ts +46 -0
- package/dist/cli-invocation.js +68 -0
- package/dist/errors.d.ts +45 -0
- package/dist/errors.js +74 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +52 -1
- package/dist/limits.d.ts +51 -0
- package/dist/limits.js +54 -0
- package/dist/model-name.d.ts +23 -0
- package/dist/model-name.js +70 -0
- package/dist/playwright-types.d.ts +28 -0
- package/dist/puppeteer-adapter.d.ts +11 -0
- package/dist/puppeteer-adapter.js +23 -0
- package/dist/python-command.d.ts +50 -0
- package/dist/python-command.js +60 -0
- package/dist/slide-geometry.d.ts +33 -0
- package/dist/slide-geometry.js +64 -0
- package/dist/solver.d.ts +214 -12
- package/dist/solver.js +1297 -165
- package/dist/trajectory.d.ts +31 -0
- package/dist/trajectory.js +162 -0
- package/dist/types.d.ts +128 -11
- package/dist/watcher.d.ts +99 -0
- package/dist/watcher.js +129 -0
- package/package.json +2 -5
- package/python/LICENSE +47 -7
- package/python/README.md +25 -8
- package/python/examples/README.md +36 -0
- package/python/examples/_harness.py +182 -94
- package/python/examples/demoHcaptcha.py +2 -1
- package/python/examples/demoRecaptcha.py +2 -1
- package/python/examples/watch_playwright.py +56 -0
- package/python/examples/with_playwright.py +43 -0
- package/python/pyproject.toml +4 -3
- package/python/src/captchakraken/__init__.py +5 -3
- package/python/src/captchakraken/action_types.py +24 -3
- package/python/src/captchakraken/cli.py +224 -2
- package/python/src/captchakraken/config.py +119 -17
- package/python/src/captchakraken/errors.py +195 -0
- package/python/src/captchakraken/keyframes.py +690 -0
- package/python/src/captchakraken/models.json +120 -0
- package/python/src/captchakraken/page_solver.py +1465 -102
- package/python/src/captchakraken/pinned_model.json +5 -7
- package/python/src/captchakraken/planner.py +328 -73
- package/python/src/captchakraken/prompts.py +485 -0
- package/python/src/captchakraken/solver.py +182 -11
- package/python/src/captchakraken/timing.py +70 -0
- package/python/src/captchakraken/tool_calls/find_grid.py +367 -52
- package/python/src/captchakraken/tool_calls/track_piece.py +96 -0
- package/python/src/captchakraken/trajectory.py +2 -7
- package/python/src/captchakraken/watcher.py +193 -0
- 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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
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) <=
|
|
532
|
-
& (np.abs(L - prev_L) <=
|
|
533
|
-
& (de2 <=
|
|
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
|
-
|
|
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
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
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
|
-
|
|
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 *
|
|
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
|
-
|
|
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
|
-
|
|
1526
|
-
|
|
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
|
-
|
|
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
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
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
|
-
|
|
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):
|