captchakraken 2.6.1 → 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.
- package/README.md +90 -7
- package/dist/cli-invocation.d.ts +2 -0
- package/dist/cli-invocation.js +5 -1
- package/dist/errors.d.ts +1 -1
- package/dist/humanize.d.ts +377 -0
- package/dist/humanize.js +716 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +44 -1
- package/dist/playwright-types.d.ts +18 -0
- package/dist/solver.d.ts +112 -5
- package/dist/solver.js +527 -174
- package/dist/timing.d.ts +48 -0
- package/dist/timing.js +103 -0
- package/dist/trajectory.d.ts +27 -0
- package/dist/trajectory.js +104 -0
- package/dist/types.d.ts +99 -0
- package/package.json +1 -1
- package/python/README.md +43 -0
- package/python/pyproject.toml +1 -1
- package/python/src/captchakraken/__init__.py +36 -1
- package/python/src/captchakraken/cli.py +62 -4
- package/python/src/captchakraken/config.py +28 -2
- package/python/src/captchakraken/errors.py +24 -0
- package/python/src/captchakraken/humanize.py +929 -0
- package/python/src/captchakraken/keyframes.py +99 -0
- package/python/src/captchakraken/models.json +149 -13
- package/python/src/captchakraken/page_solver.py +476 -270
- package/python/src/captchakraken/pinned_model.json +3 -2
- package/python/src/captchakraken/planner.py +118 -9
- package/python/src/captchakraken/prompts.py +182 -0
- package/python/src/captchakraken/server_manager.py +9 -4
- package/python/src/captchakraken/solver.py +97 -57
- package/python/src/captchakraken/timing.py +8 -3
- package/python/src/captchakraken/tool_calls/find_grid.py +357 -20
- package/python/src/captchakraken/trajectory.py +117 -0
- 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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
1378
|
-
if
|
|
1379
|
-
|
|
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
|
-
|
|
1611
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
1792
|
-
|
|
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])) <
|
|
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
|
-
|
|
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
|