captchakraken 2.6.0 → 2.8.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 (36) hide show
  1. package/README.md +126 -16
  2. package/dist/cli-invocation.d.ts +2 -0
  3. package/dist/cli-invocation.js +5 -1
  4. package/dist/errors.d.ts +1 -1
  5. package/dist/humanize.d.ts +377 -0
  6. package/dist/humanize.js +716 -0
  7. package/dist/index.d.ts +33 -0
  8. package/dist/index.js +44 -1
  9. package/dist/playwright-types.d.ts +18 -0
  10. package/dist/solver.d.ts +115 -5
  11. package/dist/solver.js +583 -190
  12. package/dist/timing.d.ts +48 -0
  13. package/dist/timing.js +103 -0
  14. package/dist/trajectory.d.ts +27 -0
  15. package/dist/trajectory.js +104 -0
  16. package/dist/types.d.ts +129 -0
  17. package/package.json +11 -3
  18. package/python/README.md +76 -5
  19. package/python/pyproject.toml +21 -4
  20. package/python/src/captchakraken/__init__.py +36 -1
  21. package/python/src/captchakraken/cli.py +62 -4
  22. package/python/src/captchakraken/config.py +28 -2
  23. package/python/src/captchakraken/errors.py +24 -0
  24. package/python/src/captchakraken/humanize.py +929 -0
  25. package/python/src/captchakraken/keyframes.py +99 -0
  26. package/python/src/captchakraken/models.json +149 -13
  27. package/python/src/captchakraken/page_solver.py +588 -291
  28. package/python/src/captchakraken/pinned_model.json +3 -2
  29. package/python/src/captchakraken/planner.py +118 -9
  30. package/python/src/captchakraken/prompts.py +182 -0
  31. package/python/src/captchakraken/server_manager.py +9 -4
  32. package/python/src/captchakraken/solver.py +97 -57
  33. package/python/src/captchakraken/timing.py +8 -3
  34. package/python/src/captchakraken/tool_calls/find_grid.py +357 -20
  35. package/python/src/captchakraken/trajectory.py +117 -0
  36. package/python/src/captchakraken/updater.py +62 -1
@@ -18,6 +18,21 @@ from ..image_processor import ImageProcessor
18
18
  # region (white wall, sky, watermark haze) has "cells" the same colour as its
19
19
  # "gutters". Ported from the grid_tracer dev harness; see that harness + the
20
20
  # project memory note `project_find_grid_tracer_*` for the iteration history.
21
+ #
22
+ # SECOND CUE — the colour comb (_comb_lines) + the seal test (_seal_fraction).
23
+ # The tracer is a LOCAL walk, so it fails on the one board captcha vendors draw
24
+ # often: a gutter whose neighbours are nearly its own colour. It cannot tell the
25
+ # two apart pixel by pixel, every neighbour seeds a trace of its own, and the
26
+ # cluster average lands off the separator. The comb asks the complementary GLOBAL
27
+ # question — is this whole straight line the gutter colour, end to end? — and the
28
+ # seal asks it of a candidate LATTICE: does every separator run the full width of
29
+ # the grid it implies, so the cells are closed boxes and the rectangle really does
30
+ # repeat? That is positive, content-independent proof of a grid, which is what
31
+ # lets the content gates stand down (SEALED_DIVERGE_TOL) on a 4x4 of open sky
32
+ # whose tiles are photographs of nothing. Both only ever look for a colour the
33
+ # TRACER already proved is painted here, so neither can conjure a lattice out of a
34
+ # flat image. Measured over the 1423 real grid captures: 15 misses -> 3, with
35
+ # end-to-end false positives (find_grid + solver._is_real_grid) 23 -> 16.
21
36
  # =============================================================================
22
37
 
23
38
 
@@ -228,6 +243,68 @@ MAX_OFF_LATTICE_CLEAN = 1 # max stray CLEAN off-lattice lines forgiven on a pr
228
243
  # strays are counted — a textured photo whose white-ish edges
229
244
  # are clean produces several, so the off-lattice FP gate still
230
245
  # fires; a real sky-bordered grid has at most one or two.
246
+ # The colour comb (second detection cue — see _comb_lines):
247
+ COMB_TOL = 3.0 # LAB ΔE: every pixel of a comb line must be this close to the
248
+ # gutter colour. TIGHT on purpose, because this is the whole
249
+ # discriminator: on the failing 4x4s the gutter is pure white
250
+ # (L 100.0) against sky at L 95-97, so ~4 upward re-admits the
251
+ # very neighbours the tracer already drowned in, while real
252
+ # gutters read 0-1 along their entire length.
253
+ COMB_COVER = 0.9 # frac of the central scan a comb line must match. Below 1.0
254
+ # because a V gutter's scan band runs THROUGH the vendor's
255
+ # header/footer chrome — the grid does not fill the widget
256
+ # vertically — which costs a true gutter ~8% of its scan.
257
+ # A non-gutter line scores ~0 at COMB_TOL, so the gate sits in
258
+ # an empty gap rather than on a measured edge.
259
+ COMB_GAP = 8 # px: gaps this small are closed before a comb line's extent is
260
+ # measured. COMB_COVER already admits a line that misses a
261
+ # tenth of the scan, so the extent must tolerate the same
262
+ # misses: taking the strictly CONTIGUOUS run instead reported a
263
+ # full-width 520px gutter as a 43px stub (JPEG ringing where
264
+ # tile content meets the gutter) and killed it on the full-span
265
+ # gate — a line the tracer had read correctly before.
266
+ SEALED_DIVERGE_TOL = 2.0 # LAB ΔE: the cell-content bar for a lattice EVERY separator
267
+ # of which is sealed (see _seal_fraction). A sealed lattice is
268
+ # already proven structurally — a repeated rectangle closed on
269
+ # all sides by one painted colour — so the content gate is no
270
+ # longer carrying the proof and only has to rule out the flat
271
+ # image it exists for. It is deliberately NOT zero: a blank
272
+ # canvas seals every lattice you can draw on it and its cells
273
+ # read EXACTLY 0.0, while a 4x4 of open sky reads 2.3-11.2
274
+ # against a pure-white gutter. Sky is the whole point — those
275
+ # tiles are real photographs of nothing, and at the standard
276
+ # 12.0 they scored 1/16 and the grid was thrown away.
277
+ SEALED_FLANK_MIN_DE = 6.0 # LAB ΔE: the flank bar under the same proof. It asks the
278
+ # same question as GRID_FLANK_MIN_DE — do these separators
279
+ # divide anything? — of a lattice that has already answered it
280
+ # a stronger way, so it drops to a floor a uniform region still
281
+ # cannot clear. Real 4x4s over unbroken SKY measure 6.x-7.1,
282
+ # because both flanks of every gutter are the same sky; at 8.0
283
+ # two of them stayed undetected.
284
+ #
285
+ # What this admits, and why it is still the right number:
286
+ # geetest_v4_svg is line art, so its drawn strokes ARE sealed
287
+ # lattices, and 86 of them start returning a grid here (0 at
288
+ # 7.0+). They cost nothing. Measured end to end — find_grid
289
+ # followed by solver._is_real_grid, which is the only path a
290
+ # detection can reach a caller through — every one is rejected
291
+ # as a sprite board, by 0.31 against a bar of 6.0. False
292
+ # positives that actually REACH _solve_grid are 16 at 6.0, 7.0
293
+ # and 8.0 alike, against 23 before this cue existed. The
294
+ # threshold is therefore free on the FP side and only decides
295
+ # how many real grids are found, so it is set where the real
296
+ # grids are.
297
+ GRID_SEAL_MIN = 0.85 # frac of a chosen separator's length, ACROSS THE CANDIDATE
298
+ # GRID'S OWN EXTENT, that must be the gutter colour for the
299
+ # cells it borders to count as sealed. See _seal_fraction.
300
+ UNSEALED_PENALTY = 700.0 # score added per chosen separator that fails GRID_SEAL_MIN.
301
+ # Set ABOVE UNUSED_LINE_PENALTY: leaving a real internal line
302
+ # out costs 400, so without this a candidate that swallowed a
303
+ # half-width sky belt to avoid that charge outscored the true
304
+ # lattice. Sealing is the stronger evidence and must outrank it.
305
+ COMB_MAX_THICK = MAX_THICKNESS # px: a matching block wider than this is a BAND (a
306
+ # white footer, a sky belt, a blank margin), not a separator.
307
+ # Measured: real comb blocks are 2-12px, chrome bands 19-83.
231
308
  OFF_LATTICE_CLEAN_STD = 2.6 # in the off-lattice FP gate, a same-colour internal
232
309
  # line this clean that lies OFF the chosen pitch is treated as
233
310
  # a stray painted edge (sky horizon / UI rule), not proof the
@@ -296,7 +373,7 @@ def _cell_divergences(lab, boxes, gutter_color):
296
373
  return out
297
374
 
298
375
 
299
- def _cells_have_content(lab, boxes, rows, cols, gutter_lines):
376
+ def _cells_have_content(lab, boxes, rows, cols, gutter_lines, sealed=False):
300
377
  """True if a MAJORITY of cells diverge from the gutter colour (real content,
301
378
  not a flat region). This is the FP killer. We do NOT require every row/column
302
379
  to have content: a legitimate grid can have an extrapolated OUTER row/column
@@ -318,7 +395,8 @@ def _cells_have_content(lab, boxes, rows, cols, gutter_lines):
318
395
  # and stay on the strict content fraction.
319
396
  gutter_std = float(np.mean([l.color_std for l in gutter_lines]))
320
397
  frac = CELL_DIVERGE_FRAC_CLEAN if gutter_std < CLEAN_GUTTER_STD else CELL_DIVERGE_FRAC
321
- return sum(1 for d in divs if d > CELL_DIVERGE_TOL) >= frac * len(divs)
398
+ tol = SEALED_DIVERGE_TOL if sealed else CELL_DIVERGE_TOL
399
+ return sum(1 for d in divs if d > tol) >= frac * len(divs)
322
400
 
323
401
 
324
402
  #: Scale OKLab's native ranges (L 0..1, a/b ~±0.4) onto the CIELAB conventions the
@@ -1184,6 +1262,114 @@ def _pick(group):
1184
1262
  return best
1185
1263
 
1186
1264
 
1265
+ # ── Second cue: the full-span colour comb ────────────────────────────
1266
+ # The tracer is a LOCAL walk, so it loses a gutter whose NEIGHBOURS are nearly its
1267
+ # own colour: every near-colour row seeds a trace of its own, _merge_lines clusters
1268
+ # the whole neighbourhood together, and the 2px painted line is averaged away by the
1269
+ # hundreds of pixels either side of it. Measured on recaptcha_1775818840074_rrv7m (a
1270
+ # 4x4 over open sky): pure-white gutters at x=103/200/297 came back as 66/96/215, and
1271
+ # no lattice was recoverable from them. hCaptcha's white-background artwork tiles fail
1272
+ # the same way on the other axis.
1273
+ #
1274
+ # The comb is the complementary GLOBAL test — is this whole straight line the gutter
1275
+ # colour, end to end? — which a near-colour neighbour fails outright at COMB_TOL even
1276
+ # though the local walk cannot tell the two apart. It is the "grid-like section
1277
+ # repeated" cue: the lattice is found from the REPETITION of whole matching lines
1278
+ # rather than from any one of them being individually traceable.
1279
+ #
1280
+ # The safety property is that the comb only ever looks for a colour the TRACER ALREADY
1281
+ # PROVED is painted in this image — a clean, uniform line it walked itself — so it
1282
+ # cannot conjure a lattice out of an image with no painted line at all, and everything
1283
+ # it proposes still faces every existing structural, colour and content gate.
1284
+ def _comb_lines(lab, axis, color):
1285
+ """Straight full-span lines every pixel of which is `color`: the gutters the
1286
+ tracer's local walk lost. Axis-aligned by construction, so this cue does not
1287
+ recover TILTED grids — the tracer still owns those."""
1288
+ h, w = lab.shape[:2]
1289
+ d = lab - color
1290
+ m = (d * d).sum(axis=2) < COMB_TOL * COMB_TOL
1291
+ if axis == 1: # H lines: spread over y, measured across x
1292
+ lo, hi = int(w * (0.5 - MAX_SEED_FRAC)), int(w * (0.5 + MAX_SEED_FRAC))
1293
+ cov = m[:, lo:hi].mean(axis=1)
1294
+ else: # V lines: spread over x, measured across y
1295
+ lo, hi = int(h * (0.5 - MAX_SEED_FRAC)), int(h * (0.5 + MAX_SEED_FRAC))
1296
+ cov = m[lo:hi, :].mean(axis=0)
1297
+ mid = (lo + hi) // 2
1298
+ total = h if axis == 1 else w
1299
+ out = []
1300
+ for blk in _split_runs(np.where(cov >= COMB_COVER)[0]):
1301
+ if len(blk) > COMB_MAX_THICK:
1302
+ continue
1303
+ pos = float(blk.mean())
1304
+ # The page MARGIN is the gutter colour too, and it runs the whole image.
1305
+ # _internal drops such a line from its own axis, but _corroborate reads the
1306
+ # perpendicular lines unfiltered — so a margin left in here corroborates
1307
+ # every line on the other axis, including the grid's own borders, and a 3x3
1308
+ # is reported as a 4x3. Apply the same edge rule at the source.
1309
+ if not (total * EDGE_MARGIN < pos < total * (1 - EDGE_MARGIN)):
1310
+ continue
1311
+ i = int(round(pos))
1312
+ along = m[i] if axis == 1 else m[:, i]
1313
+ # EXTENT is measured, not assumed: the run of gutter colour through the
1314
+ # scan centre. The full-span gate downstream compares it against the grid
1315
+ # width, which the scan band alone would always fail.
1316
+ idx = np.where(along)[0]
1317
+ grp = (np.split(idx, np.where(np.diff(idx) > COMB_GAP)[0] + 1)
1318
+ if idx.size else [])
1319
+ run = next((r for r in grp if r[0] <= mid <= r[-1]), None)
1320
+ if run is None or len(run) < MIN_RUN:
1321
+ continue
1322
+ a, b = float(run[0]), float(run[-1])
1323
+ cols = (lab[i, run] if axis == 1 else lab[run, i]).astype(np.float64)
1324
+ out.append(PotentialGridLine(
1325
+ orientation='h' if axis == 1 else 'v', angle=0.0,
1326
+ thickness=float(len(blk)),
1327
+ start=(a, pos) if axis == 1 else (pos, a),
1328
+ end=(b, pos) if axis == 1 else (pos, b),
1329
+ color_lab=cols.mean(axis=0),
1330
+ color_std=float(np.mean(np.std(cols, axis=0))),
1331
+ midline_pos=pos, support=int(len(run))))
1332
+ return out
1333
+
1334
+
1335
+ def _comb_axis(lines, comb):
1336
+ """One axis' lines with the comb's findings folded in.
1337
+
1338
+ Where the two overlap the comb wins on POSITION and only on position: it is the
1339
+ exact measurement (THIS line is the gutter colour, the one 7px away is not),
1340
+ whereas a trace is a cluster average that a near-colour neighbourhood drags off
1341
+ the gutter — rrv7m's true x=103 came back as x=96, and averaging the two would
1342
+ just split the difference. Everything else is kept from the longer TRACE, which
1343
+ walked the gutter's real extent and tilt; a comb line's extent stops at the first
1344
+ stretch of the line that is not quite the colour, so promoting it wholesale
1345
+ reported a full-width 520px gutter as a 43px stub and failed it on the full-span
1346
+ gate. The comb is only additive where it finds a line the tracer missed."""
1347
+ out, used = [], set()
1348
+ for c in comb:
1349
+ near = [l for l in lines if abs(l.midline_pos - c.midline_pos) < MERGE_PX]
1350
+ best = max(near, key=_line_extent, default=None)
1351
+ if best is not None and _line_extent(best) > _line_extent(c):
1352
+ best.midline_pos = c.midline_pos
1353
+ c = best
1354
+ used.update(id(l) for l in near)
1355
+ out.append(c)
1356
+ return sorted(out + [l for l in lines if id(l) not in used],
1357
+ key=lambda l: l.midline_pos)
1358
+
1359
+
1360
+ def _add_comb_lines(lab, h_lines, v_lines):
1361
+ """Both axes' lines with the colour comb applied. The colour is the median of the
1362
+ CLEAN traced lines of BOTH axes: a real grid's gutters are one colour, so either
1363
+ axis can supply it — which is precisely what lets a starved axis be rescued by
1364
+ the evidence the other one found."""
1365
+ clean = [l for l in h_lines + v_lines if l.color_std < CLEAN_LATTICE_STD]
1366
+ if not clean:
1367
+ return h_lines, v_lines
1368
+ color = np.median(np.array([l.color_lab for l in clean]), axis=0)
1369
+ return (_comb_axis(h_lines, _comb_lines(lab, 1, color)),
1370
+ _comb_axis(v_lines, _comb_lines(lab, 0, color)))
1371
+
1372
+
1187
1373
  # ── _generate_grid: cell boxes for a (rows x cols) grid of any size ──────────
1188
1374
  def _generate_grid(rows, cols, hs, vs, hd, vd, h, w, slant):
1189
1375
  """Cell bounding boxes for a `rows` x `cols` grid. `hs` are the `rows-1`
@@ -1346,6 +1532,39 @@ def _flank_contrast(lab, line, pitch):
1346
1532
  return float(np.median((d_lo + d_hi) / 2.0))
1347
1533
 
1348
1534
 
1535
+ def _seal_fraction(lab, color, orientation, pos, angle, lo, hi):
1536
+ """Does this separator actually SEAL the cells it is supposed to divide?
1537
+
1538
+ A grid is one rectangle REPEATED, so every internal separator runs the whole way
1539
+ across the grid — the cells either side are closed boxes. A line that is the
1540
+ gutter colour for only part of that span is not sealing anything: it is a sky
1541
+ belt, a roofline or a chrome edge that happens to be pale where it was traced.
1542
+ That distinction is invisible to the per-line gates (which judge a line by the
1543
+ stretch it WAS traced along) and it is what the candidate loop needs, because the
1544
+ comb hands it real, clean, full-length white lines that are nonetheless not
1545
+ gutters.
1546
+
1547
+ Measured across the CANDIDATE's extent (lo..hi on the perpendicular axis), not the
1548
+ line's own or the image's: the same line seals a 3x3 and fails a 4x4 whose implied
1549
+ grid reaches further, which is exactly the discrimination the dimension choice
1550
+ needs."""
1551
+ h, w = lab.shape[:2]
1552
+ a = np.arange(max(0.0, lo), min(float(w if orientation == 'h' else h), hi), 2.0)
1553
+ if a.size < 3:
1554
+ return 0.0
1555
+ t = np.tan(angle)
1556
+ if orientation == 'h':
1557
+ xs, ys = a, pos + t * (a - w / 2.0)
1558
+ else:
1559
+ ys, xs = a, pos + t * (a - h / 2.0)
1560
+ jx = np.round(xs).astype(np.intp); jy = np.round(ys).astype(np.intp)
1561
+ ok = (jx >= 0) & (jx < w) & (jy >= 0) & (jy < h)
1562
+ if ok.sum() < 3:
1563
+ return 0.0
1564
+ d = lab[jy[ok], jx[ok]].astype(np.float64) - color
1565
+ return float(((d * d).sum(axis=1) < COMB_TOL * COMB_TOL).mean())
1566
+
1567
+
1349
1568
  def _line_span_perp(line):
1350
1569
  """(lo, hi) extent of a line along its OWN run direction (H -> x span, V -> y
1351
1570
  span) — i.e. how far it reaches in the perpendicular axis' coordinate."""
@@ -1371,12 +1590,46 @@ def _corroborate(lines, perp_lines, total):
1371
1590
  perp = sorted(perp_lines, key=lambda l: l.midline_pos)
1372
1591
  if len(lines) < 2:
1373
1592
  return lines
1374
- # cell size estimate: median gap between consecutive candidate lines (this axis)
1593
+ # Cell size estimate: median gap between consecutive candidate lines (this
1594
+ # axis) — over the gaps that COULD be a cell. A gap under MIN_CELL is not a
1595
+ # cell by this module's own definition, so letting one into the median can only
1596
+ # understate the pitch, and the pitch is a BAR: it is how far the perpendicular
1597
+ # lines must reach past a candidate before it counts as a real internal
1598
+ # separator. Understate it and chrome clears it.
1599
+ #
1600
+ # The comb is what made that reachable. It reports vendor chrome BELOW a board
1601
+ # — a button row, a brand mark — as clean full-span lines of the gutter colour,
1602
+ # which the tracer never produced, and they arrive clustered a few px apart
1603
+ # rather than at the cell pitch. On a 3x3 above a footer they took the median
1604
+ # from the true 87 px pitch to 55.8, which is low enough that the grid's own
1605
+ # BOTTOM BORDER became corroborated: the correct 3x3 was then charged
1606
+ # UNUSED_LINE_PENALTY for declining to build a row out of its own border, the
1607
+ # 4x3 that swallowed it scored 320 lower and won, and that lattice died outside
1608
+ # the candidate loop on _boxes_are_regular with nothing to fall back to.
1609
+ # Detection went 20/20 -> 5/20 on a generator whose boards are that shape.
1610
+ #
1611
+ # The GUARD below deliberately still reads the RAW median. Where strays drag it
1612
+ # under MIN_CELL outright, the perpendicular pitch is the better estimate and is
1613
+ # what should be used; filtering first lifts such an axis back over the line and
1614
+ # silently skips that rescue, which costs a real 4x4 its fourth row
1615
+ # (recaptcha_1776250807779_ttvu9 — raw median 34.5, filtered 80.9, perpendicular
1616
+ # 97.0, and only the last of those is the cell). So the two estimates answer two
1617
+ # different questions and both are kept.
1375
1618
  pos = [l.midline_pos for l in lines]
1376
1619
  gaps = np.diff(pos)
1377
- cell = float(np.median(gaps)) if len(gaps) else 0.0
1378
- if cell < MIN_CELL:
1379
- return lines
1620
+ raw_cell = float(np.median(gaps)) if len(gaps) else 0.0
1621
+ cell_gaps = [g for g in gaps if g >= MIN_CELL]
1622
+ cell = float(np.median(cell_gaps)) if cell_gaps else 0.0
1623
+ if raw_cell < MIN_CELL:
1624
+ # This axis' own spacing is not a usable cell estimate — a few stray lines
1625
+ # close together drag the median under one cell and corroboration was then
1626
+ # SKIPPED ENTIRELY, which is how a grid's own border survived as an internal
1627
+ # separator and a 3x3 came back as a 4x3. Cells are square, so fall back to
1628
+ # what this docstring always claimed to use: the perpendicular lines' pitch.
1629
+ pgaps = np.diff([l.midline_pos for l in perp])
1630
+ cell = float(np.median(pgaps)) if len(pgaps) else 0.0
1631
+ if cell < MIN_CELL:
1632
+ return lines
1380
1633
  need = CORROB_FRAC * cell
1381
1634
  kept = []
1382
1635
  for l in lines:
@@ -1607,8 +1860,26 @@ def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
1607
1860
  # filter H using V and V using H, on the raw traced lines.
1608
1861
  h_keep = _corroborate(h_lines, v_lines, h)
1609
1862
  v_keep = _corroborate(v_lines, h_lines, w)
1610
- n_hkeep = len(_internal(h_keep, h)) # # corroborated internal lines per axis
1611
- n_vkeep = len(_internal(v_keep, w)) # — a candidate should USE all of them
1863
+ h_keep_int = _internal(h_keep, h) # corroborated internal lines per axis
1864
+ v_keep_int = _internal(v_keep, w) # — a candidate should USE all of them
1865
+ # One reference colour for every seal test, so the answers can be cached across
1866
+ # candidates: it is the PAINTED colour the tracer proved is in this image, the
1867
+ # same one the comb went looking for, not a per-candidate mean.
1868
+ clean = [l for l in h_lines + v_lines if l.color_std < CLEAN_LATTICE_STD]
1869
+ seal_col = np.median(np.array([l.color_lab for l in clean]), axis=0) if clean else None
1870
+ seal_cache = {}
1871
+
1872
+ def sealed(orientation, pos, angle, lo, hi):
1873
+ """Is this separator the gutter colour the whole way across the candidate's
1874
+ grid? Asked of INVENTED lattice positions as well as traced ones — a node the
1875
+ image seals is not an invention, it is a gutter the tracer happened to miss."""
1876
+ if lab is None or seal_col is None:
1877
+ return True
1878
+ k = (orientation, round(pos), round(angle, 3), int(lo), int(hi))
1879
+ if k not in seal_cache:
1880
+ seal_cache[k] = (_seal_fraction(lab, seal_col, orientation, pos, angle,
1881
+ lo, hi) >= GRID_SEAL_MIN)
1882
+ return seal_cache[k]
1612
1883
  h_cand = _axis_candidates(h_keep, h)
1613
1884
  v_cand = _axis_candidates(v_keep, w)
1614
1885
  best = None
@@ -1671,7 +1942,6 @@ def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
1671
1942
  # and an unclamped count turns negative there — paying a BONUS
1672
1943
  # for inventing rows, which is how a half-pitch 5x4 once outscored
1673
1944
  # a 4x4 built from the same two real gutters.
1674
- unused = max(0, n_hkeep - (rows - 1)) + max(0, n_vkeep - (cols - 1))
1675
1945
  # Grid-span fit: the perpendicular gutters run the WHOLE grid and
1676
1946
  # stop at its outer borders. The grid's extrapolated borders are
1677
1947
  # one pitch beyond the outer internal lines: H rows occupy
@@ -1719,20 +1989,74 @@ def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
1719
1989
  + _missing(max(0.0, vy_hi - row_bot), hd, v_bot_edge and not v_top_edge)
1720
1990
  + _missing(max(0.0, col_lft - hx_lo), vd, h_lft_edge and not h_rgt_edge)
1721
1991
  + _missing(max(0.0, hx_hi - col_rgt), vd, h_rgt_edge and not h_lft_edge))
1992
+ # Does the repeated rectangle actually close? Every chosen
1993
+ # separator must be the gutter colour the whole way across the
1994
+ # grid this candidate implies.
1995
+ def _seal(orientation, pos, angle):
1996
+ return sealed(orientation, pos, angle,
1997
+ *((col_lft, col_rgt) if orientation == 'h'
1998
+ else (row_top, row_bot)))
1999
+ unsealed = sum(1 for l in alll
2000
+ if not _seal(l.orientation, l.midline_pos, l.angle))
2001
+ # Refund the invention charge for every node the IMAGE confirms.
2002
+ # A completed lattice pays VIRTUAL_NODE_PENALTY per interpolated
2003
+ # line because a guessed line is unsupported — but a guess the
2004
+ # gutter colour runs straight through is not a guess, it is the
2005
+ # gutter the tracer lost. Without the refund the true 4x4 on every
2006
+ # sky-backed reCAPTCHA lost by ~470 to a 3-row lattice built on a
2007
+ # pale belt: the correct answer was being charged 600 for the one
2008
+ # thing that made it correct.
2009
+ # Only INTERIOR nodes — ones bracketed by a real gutter on both
2010
+ # sides — can be refunded. An interpolated line sits in a span the
2011
+ # tracer has already proved is grid, so sealing it confirms a
2012
+ # gutter that was missed. An EXTRAPOLATED one grows the lattice
2013
+ # into unproven ground, and on a white-backgrounded hCaptcha board
2014
+ # the margin beyond the last gutter is the gutter colour too, so it
2015
+ # seals trivially: refunding it turned a correct 3x3 into a 3x4
2016
+ # whose extra column boundary ran down the middle of a tile.
2017
+ def _confirm(pos, lns, orientation, angle):
2018
+ if len(lns) < 2:
2019
+ return 0
2020
+ lo = min(l.midline_pos for l in lns)
2021
+ hi = max(l.midline_pos for l in lns)
2022
+ return sum(1 for p in pos
2023
+ if lo < p < hi
2024
+ and all(abs(p - l.midline_pos) >= 1.0 for l in lns)
2025
+ and _seal(orientation, p, angle))
2026
+ confirmed = (_confirm(hpos, hlns, 'h', h_ang)
2027
+ + _confirm(vpos, vlns, 'v', v_ang))
2028
+ # Prefer the candidate that USES every real internal separator, so
2029
+ # a 4x4's [r1,r2,r3] is not silently dropped to a 3-row [r2,r3].
2030
+ # Only SEALED lines are counted: the comb hands this loop clean,
2031
+ # full-length lines of the gutter colour that are nonetheless not
2032
+ # cell boundaries (a white sky belt, a chrome rule), and charging
2033
+ # a candidate 400 for declining to build a grid out of one is how
2034
+ # the true 4x4 lost on every sky-backed reCAPTCHA. A line that
2035
+ # seals nothing is not a separator, so leaving it out is free.
2036
+ chosen = {id(l) for l in alll}
2037
+ unused = (sum(1 for l in h_keep_int if id(l) not in chosen
2038
+ and _seal('h', l.midline_pos, l.angle))
2039
+ + sum(1 for l in v_keep_int if id(l) not in chosen
2040
+ and _seal('v', l.midline_pos, l.angle)))
1722
2041
  score = (hsc + vsc + s_diff * 1000 + abs(slant) * 500
1723
- + unused * UNUSED_LINE_PENALTY + ang_inc + span_pen)
2042
+ + unused * UNUSED_LINE_PENALTY + ang_inc + span_pen
2043
+ + unsealed * UNSEALED_PENALTY
2044
+ - confirmed * VIRTUAL_NODE_PENALTY)
1724
2045
  if score < best_score:
1725
2046
  boxes = _generate_grid(rows, cols, hpos, vpos, hd, vd, h, w, slant)
1726
2047
  # Cell-content gate IN the loop so a rejected (over-counted)
1727
2048
  # candidate lets a smaller valid one win, instead of killing
1728
2049
  # detection outright.
1729
- if boxes and _cells_have_content(lab, boxes, rows, cols, hlns + vlns):
2050
+ if boxes and _cells_have_content(lab, boxes, rows, cols,
2051
+ hlns + vlns, unsealed == 0):
1730
2052
  best_score = score
1731
2053
  best = (boxes, rows, cols, slant, avg,
1732
- sorted(hpos), hd, sorted(vpos), vd, hlns + vlns)
2054
+ sorted(hpos), hd, sorted(vpos), vd, hlns + vlns,
2055
+ unsealed == 0)
1733
2056
  if best is None:
1734
2057
  return None, None, None
1735
- boxes, rows, cols, slant, gutter_color, hpos, hd, vpos, vd, chosen_lns = best
2058
+ (boxes, rows, cols, slant, gutter_color, hpos, hd, vpos, vd, chosen_lns,
2059
+ fully_sealed) = best
1736
2060
  # Is the CHOSEN grid built from clean painted gutters? If so the lattice is
1737
2061
  # already proven a real grid (a textured-photo FP has noisy pseudo-gutters,
1738
2062
  # std well above the threshold). For such a proven grid we do NOT count a few
@@ -1755,7 +2079,7 @@ def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
1755
2079
  # the first internal line or BELOW the last — those are frame/chrome, not
1756
2080
  # evidence the central cells are irregular, and must not count. The central
1757
2081
  # closed region is the only reliable signal (per design).
1758
- def _off_lattice(lines, total, anchors, pitch):
2082
+ def _off_lattice(lines, total, anchors, pitch, orientation, ext):
1759
2083
  off_pos = []
1760
2084
  clean_skipped = 0
1761
2085
  lo, hi = anchors[0], anchors[-1]
@@ -1764,6 +2088,14 @@ def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
1764
2088
  continue # outside the central span — frame/chrome
1765
2089
  if _de(l.color_lab, gutter_color) > GRID_COLOR_TOL:
1766
2090
  continue # different colour — not a gutter
2091
+ if not sealed(orientation, l.midline_pos, l.angle, *ext):
2092
+ # Gutter-coloured for only PART of the grid's width, so it divides
2093
+ # nothing: a pale belt across a sky, a roofline, a chrome rule. This
2094
+ # gate exists to catch cell boundaries that break the pitch, and a
2095
+ # line that is not a boundary anywhere is not evidence of that. Three
2096
+ # such belts on one sky-backed 4x4 were enough to reject the correct
2097
+ # lattice on a gate that allows one stray.
2098
+ continue
1767
2099
  # distance to the nearest lattice node (anchor + k*pitch)
1768
2100
  off = min(abs((l.midline_pos - anchors[0]) - round((l.midline_pos - anchors[0]) / pitch) * pitch),
1769
2101
  abs((l.midline_pos - anchors[-1]) - round((l.midline_pos - anchors[-1]) / pitch) * pitch))
@@ -1788,8 +2120,10 @@ def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
1788
2120
  off_pos.sort()
1789
2121
  return sum(1 for i, p in enumerate(off_pos)
1790
2122
  if i == 0 or p - off_pos[i - 1] > OFF_LATTICE_CLUSTER_PX)
1791
- if (_off_lattice(h_lines, h, hpos, hd) > MAX_OFF_LATTICE
1792
- or _off_lattice(v_lines, w, vpos, vd) > MAX_OFF_LATTICE):
2123
+ rows_ext = (hpos[0] - hd, hpos[-1] + hd)
2124
+ cols_ext = (vpos[0] - vd, vpos[-1] + vd)
2125
+ if (_off_lattice(h_lines, h, hpos, hd, 'h', cols_ext) > MAX_OFF_LATTICE
2126
+ or _off_lattice(v_lines, w, vpos, vd, 'v', rows_ext) > MAX_OFF_LATTICE):
1793
2127
  return None, None, None
1794
2128
  if not _boxes_are_regular(boxes, rows, cols):
1795
2129
  return None, None, None
@@ -1797,7 +2131,8 @@ def extract_grid_from_lines(h_lines, v_lines, h, w, lab=None):
1797
2131
  # An H separator's flanks lie a row pitch away, a V separator's a column pitch away.
1798
2132
  if lab is not None:
1799
2133
  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:
2134
+ for l in chosen_lns])) < (SEALED_FLANK_MIN_DE if fully_sealed
2135
+ else GRID_FLANK_MIN_DE):
1801
2136
  return None, None, None
1802
2137
  x0 = min(b[0] for b in boxes); x1 = max(b[2] for b in boxes)
1803
2138
  y0 = min(b[1] for b in boxes); y1 = max(b[3] for b in boxes)
@@ -1814,10 +2149,12 @@ def _detect_grid(image_path, seed_bias=0.0):
1814
2149
  lab = _to_lab(img)
1815
2150
  tols = walk_tolerances(image_noise(lab)) # once per image, not per axis
1816
2151
  h_lines = _trace_lines(lab, axis=1, seed_bias=seed_bias, tols=tols)
1817
- if len(_internal(h_lines, h)) < 2:
1818
- return None
1819
2152
  v_lines = _trace_lines(lab, axis=0, seed_bias=-seed_bias, tols=tols)
1820
- if len(_internal(v_lines, w)) < 2:
2153
+ # Both axes are traced before either is judged: the comb takes its colour from
2154
+ # whichever axis found a clean painted line, so an axis with too few traces of
2155
+ # its own is exactly the case the other axis' evidence is there to rescue.
2156
+ h_lines, v_lines = _add_comb_lines(lab, h_lines, v_lines)
2157
+ if len(_internal(h_lines, h)) < 2 or len(_internal(v_lines, w)) < 2:
1821
2158
  return None
1822
2159
  boxes, dims, slant = extract_grid_from_lines(h_lines, v_lines, h, w, lab=lab)
1823
2160
  return boxes
@@ -18,6 +18,9 @@ What makes the output human rather than a lerp:
18
18
  - sub-pixel jitter that scales with speed (fast motion is sloppier)
19
19
  - a small overshoot-and-correct on longer moves, which is the single most
20
20
  recognisable human tell and the one a linear interpolation never produces
21
+
22
+ `generate_swipe` is the SAME contract for a FINGER, and it is a different model
23
+ rather than the mouse model with different constants — see its docstring.
21
24
  """
22
25
 
23
26
  from __future__ import annotations
@@ -165,3 +168,117 @@ def generate_trajectory(
165
168
  # sample must be the point the caller asked for or clicks drift off-target.
166
169
  points[-1] = end
167
170
  return points, timings
171
+
172
+
173
+ # ── Touch ───────────────────────────────────────────────────────────────────
174
+ #
175
+ # Fitts's law holds for direct touch too, but with a slower intercept and a much
176
+ # wider effective target: a fingertip contact patch is ~9mm across, which is the
177
+ # 44pt / 48dp minimum both platform guidelines are built around. So the same
178
+ # distance takes longer AND is aimed less precisely than with a mouse.
179
+ _TOUCH_FITTS_A_MS = 160.0
180
+ _TOUCH_FITTS_B_MS = 135.0
181
+ _TOUCH_WIDTH_PX = 44.0
182
+
183
+ # How flick-like the launch is. 0 would be the mouse's symmetric bell; 1 would
184
+ # be an instantaneous jump at t=0. See `_ease_touch`.
185
+ _TOUCH_LAUNCH = 0.7
186
+
187
+ # A finger drags across glass on a short wrist/thumb pivot, so it bows far less
188
+ # than a hand moving a mouse across a desk.
189
+ _TOUCH_BOW_CAP_PX = 40.0
190
+
191
+ # The reported contact point wanders, because it is the CENTROID of a soft patch
192
+ # rolling under pressure rather than a rigid sensor. `_TOUCH_WOBBLE_DECAY` is the
193
+ # AR(1) coefficient that makes that wander low-frequency: white per-sample noise
194
+ # would show up in a spectrum as nothing a finger produces.
195
+ _TOUCH_WOBBLE_PX = 0.55
196
+ _TOUCH_WOBBLE_DECAY = 0.82
197
+
198
+
199
+ def _ease_touch(t: float) -> float:
200
+ """Asymmetric ease: a finger leaves fast and brakes late.
201
+
202
+ A mouse hand is symmetric (`_ease_in_out` — accelerate, decelerate, equally).
203
+ A finger is not: the launch is a flick off the contact point and the arrival
204
+ is a brake. Blending the two rather than using a pure ease-out is what keeps
205
+ the launch from being an instantaneous jump at t=0, which no digitizer would
206
+ ever report.
207
+ """
208
+ return (1.0 - _TOUCH_LAUNCH) * _ease_in_out(t) + _TOUCH_LAUNCH * (1.0 - (1.0 - t) ** 2.4)
209
+
210
+
211
+ def generate_swipe(
212
+ target_start: Sequence[float],
213
+ target_end: Sequence[float],
214
+ frequency: int = 90,
215
+ frequency_randomizer: float = 0.10,
216
+ ) -> Tuple[List[Point], List[float]]:
217
+ """A FINGER travelling from `target_start` to `target_end`, same contract as
218
+ `generate_trajectory`: `(points, timings)`, timings cumulative from 0, last
219
+ point exactly `target_end`.
220
+
221
+ A separate model rather than `generate_trajectory` with other constants,
222
+ because the three things that make the mouse output human are each WRONG
223
+ here:
224
+
225
+ - **No overshoot-and-correct.** That tell is a hand arriving past a target
226
+ it cannot see under the cursor. A finger occludes its own target and
227
+ commits; the correction, when there is one, is a second gesture.
228
+ - **A different velocity profile.** `_ease_touch`, not `_ease_in_out` —
229
+ see above.
230
+ - **Different jitter.** The mouse model adds white noise scaled by speed
231
+ (a hand tracking a path imprecisely). A digitizer instead reports a
232
+ wandering centroid, which is low-frequency and roughly speed
233
+ INDEPENDENT, so this is a smoothed random walk.
234
+
235
+ `frequency` defaults to 90: digitizers sample at 120Hz+, but touchmove is
236
+ coalesced to the compositor, so more samples than this buys nothing and
237
+ costs a dispatch round-trip each.
238
+ """
239
+ start: Point = (float(target_start[0]), float(target_start[1]))
240
+ end: Point = (float(target_end[0]), float(target_end[1]))
241
+
242
+ dx, dy = end[0] - start[0], end[1] - start[1]
243
+ distance = math.hypot(dx, dy)
244
+
245
+ if distance < 1e-6:
246
+ return [end], [0.0]
247
+
248
+ duration_ms = _TOUCH_FITTS_A_MS + _TOUCH_FITTS_B_MS * math.log2(
249
+ distance / _TOUCH_WIDTH_PX + 1.0
250
+ )
251
+ duration_ms *= random.uniform(0.85, 1.20)
252
+
253
+ steps = max(2, int(round(duration_ms / 1000.0 * max(1, frequency))))
254
+
255
+ if distance < _SHORT_MOVE_PX:
256
+ c1, c2 = start, end
257
+ else:
258
+ c1, c2 = _control_points(start, end, distance)
259
+ # Pull the mouse model's bow in: `_control_points` is shared, and its
260
+ # arc is sized for a hand crossing a desk.
261
+ shrink = min(1.0, _TOUCH_BOW_CAP_PX / max(_TOUCH_BOW_CAP_PX, distance * 0.16))
262
+ c1 = (start[0] + (c1[0] - start[0]) * shrink, start[1] + (c1[1] - start[1]) * shrink)
263
+ c2 = (start[0] + (c2[0] - start[0]) * shrink, start[1] + (c2[1] - start[1]) * shrink)
264
+
265
+ points: List[Point] = []
266
+ timings: List[float] = []
267
+ elapsed = 0.0
268
+ per_step = duration_ms / steps
269
+ wob_x = wob_y = 0.0
270
+
271
+ for i in range(steps + 1):
272
+ t = _ease_touch(i / steps)
273
+ px, py = _cubic_bezier(start, c1, c2, end, t)
274
+
275
+ wob_x = wob_x * _TOUCH_WOBBLE_DECAY + random.gauss(0.0, _TOUCH_WOBBLE_PX)
276
+ wob_y = wob_y * _TOUCH_WOBBLE_DECAY + random.gauss(0.0, _TOUCH_WOBBLE_PX)
277
+
278
+ points.append((px + wob_x, py + wob_y))
279
+ timings.append(elapsed)
280
+ elapsed += per_step * random.uniform(1.0 - frequency_randomizer, 1.0 + frequency_randomizer)
281
+
282
+ # Land exactly where the caller asked, same reason as the mouse model.
283
+ points[-1] = end
284
+ return points, timings