captchakraken 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +43 -0
- package/dist/playwright-types.d.ts +119 -0
- package/dist/playwright-types.js +25 -0
- package/dist/puppeteer-adapter.d.ts +70 -0
- package/dist/puppeteer-adapter.js +96 -0
- package/dist/solver.d.ts +264 -0
- package/dist/solver.js +1922 -0
- package/dist/token-usage.d.ts +15 -0
- package/dist/token-usage.js +102 -0
- package/dist/types.d.ts +248 -0
- package/dist/types.js +2 -0
- package/package.json +49 -0
- package/python/Dockerfile +41 -0
- package/python/README.md +71 -0
- package/python/examples/README.md +68 -0
- package/python/examples/_harness.py +158 -0
- package/python/examples/demoHcaptcha.py +20 -0
- package/python/examples/demoRecaptcha.py +17 -0
- package/python/pyproject.toml +79 -0
- package/python/src/captchakraken/__init__.py +61 -0
- package/python/src/captchakraken/action_types.py +56 -0
- package/python/src/captchakraken/cli.py +656 -0
- package/python/src/captchakraken/config.py +78 -0
- package/python/src/captchakraken/image_processor.py +244 -0
- package/python/src/captchakraken/overlay.py +520 -0
- package/python/src/captchakraken/planner.py +408 -0
- package/python/src/captchakraken/planner_types.py +74 -0
- package/python/src/captchakraken/server_manager.py +290 -0
- package/python/src/captchakraken/solver.py +434 -0
- package/python/src/captchakraken/timing.py +42 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +72 -0
- package/python/src/captchakraken/tool_calls/find_grid.py +1762 -0
- package/python/src/captchakraken/tool_calls/move_indicator.py +431 -0
- package/scripts/copy-python.mjs +29 -0
- package/scripts/setup-python.js +104 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""Detect the hCaptcha "Move" draggable indicator and the movable object below it.
|
|
2
|
+
|
|
3
|
+
TEAL-BODY-FIRST redesign. hCaptcha drag-style puzzles (connect_path,
|
|
4
|
+
drag_missing_slot, missing_piece, tetris_fit, fish_swim_different,
|
|
5
|
+
drag_numbered_line_pieces, ...) stack one or more draggable CARDS, each topped by
|
|
6
|
+
a dark rounded "Move" pill: a four-arrow glyph (⊹) plus the white word "Move".
|
|
7
|
+
|
|
8
|
+
This module gives two pure-OpenCV steps (no GPU, no network):
|
|
9
|
+
|
|
10
|
+
1. find_move_indicators(im) -> [x, y, w, h] of every Move pill
|
|
11
|
+
2. find_movable_content(im, pill) -> the card / object box BELOW one pill
|
|
12
|
+
|
|
13
|
+
Detection strategy (step 1) — TEAL BODY FIRST. The pill BODY is a fixed UI
|
|
14
|
+
element rendered as a flat dark desaturated TEAL: HSV hue ~108 (BGR ~(56,45,38),
|
|
15
|
+
channels ordered B>G>R), saturation ~82, value ~56. This colour is BYTE-STABLE
|
|
16
|
+
and opacity-robust: it stays teal even when the white text is faint, fragmented,
|
|
17
|
+
or sliced by a thin horizontal artifact (the failure mode of a wordmark-first
|
|
18
|
+
detector on pills embedded in a dark side-panel).
|
|
19
|
+
|
|
20
|
+
So we:
|
|
21
|
+
|
|
22
|
+
* Build a mask of pill-teal pixels (hue [103,113], S>=40, V in [35,95]),
|
|
23
|
+
close it into solid horizontal blobs, and take connected components with
|
|
24
|
+
pill GEOMETRY (rounded rectangle: w 50-175, h 16-46, aspect 2.0-7.5, and a
|
|
25
|
+
high-extent FILLED rectangle). This catches pills on light cards AND dark
|
|
26
|
+
panels AND artifact-sliced pills, because the body colour is robust.
|
|
27
|
+
* CONFIRM each teal candidate is a real Move pill by requiring SOME white
|
|
28
|
+
wordmark evidence inside the body (a minimum count of bright low-saturation
|
|
29
|
+
pixels spread horizontally across the interior). Because candidate generation
|
|
30
|
+
is teal-locked, confirmation can be LENIENT on text faintness without
|
|
31
|
+
exploding false positives — the only competitors are textured photographic
|
|
32
|
+
tiles, whose bright pixels are sparse and clustered, not a spread wordmark.
|
|
33
|
+
* UNION with the legacy wordmark-first path (for any pill the teal mask misses)
|
|
34
|
+
and dedup overlapping boxes.
|
|
35
|
+
|
|
36
|
+
Post-filters: a vertical band [0.12H, 0.87H] (excludes the bottom "Skip" button
|
|
37
|
+
and top chrome) and IoU/center dedup. Byte-deterministic per image.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
from typing import List, Optional
|
|
43
|
+
|
|
44
|
+
import cv2
|
|
45
|
+
import numpy as np
|
|
46
|
+
|
|
47
|
+
# ── Move-pill body colour ────────────────────────────────────────────────────
|
|
48
|
+
# Flat dark desaturated TEAL: HSV hue locked at ~108, BGR ordered B>G>R with a
|
|
49
|
+
# tight characteristic spacing (B-G≈11, G-R≈7). Byte-stable across hundreds of
|
|
50
|
+
# real pill bodies; photographic tiles scatter across hue space and break the
|
|
51
|
+
# B>G>R ordering.
|
|
52
|
+
_PILL_HUE = 108
|
|
53
|
+
|
|
54
|
+
# Geometry / threshold constants (px on the ~520-wide hCaptcha canvas; gates are
|
|
55
|
+
# structural, not absolute coordinates, so they hold at other sizes).
|
|
56
|
+
_BRIGHT_V = 150 # min HSV V for "white ink" (glyph + text)
|
|
57
|
+
_BRIGHT_S = 90 # max HSV S for "white ink"
|
|
58
|
+
_DARK_V = 110 # below this V is "dark pill body"
|
|
59
|
+
_Y_BAND = (0.12, 0.87) # keep pills inside the puzzle body; excludes the bottom
|
|
60
|
+
# "Skip" button and top chrome.
|
|
61
|
+
|
|
62
|
+
# ── teal candidate-generation params ─────────────────────────────────────────
|
|
63
|
+
_TEAL_HUE_LO, _TEAL_HUE_HI = 103, 113 # pill teal hue window (±5 of 108)
|
|
64
|
+
_TEAL_S_MIN = 40 # pill body saturation floor
|
|
65
|
+
_TEAL_V_LO, _TEAL_V_HI = 35, 95 # pill body value window
|
|
66
|
+
_TEAL_W = (50, 175) # pill body width range (px)
|
|
67
|
+
_TEAL_H = (16, 46) # pill body height range (px)
|
|
68
|
+
_TEAL_AR = (2.0, 7.5) # pill aspect ratio (w/h)
|
|
69
|
+
_TEAL_EXTENT = 0.80 # filled-rectangle floor (area / w·h)
|
|
70
|
+
|
|
71
|
+
# ── confirmation params (white wordmark evidence inside the teal body) ───────
|
|
72
|
+
# Measured separation on the corpus:
|
|
73
|
+
# real pills : bright_frac 0.08-0.16, bright_px 160-190, hspan ≥ ~0.5·w
|
|
74
|
+
# (even content-blank/faint/sliced cases clear these)
|
|
75
|
+
# FP teal tiles : bright_frac ≤ 0.04, bright_px ≤ 76, hspan small/fragmented
|
|
76
|
+
# We require a spread (hspan) AND a mass (frac) of bright ink, which lenient on
|
|
77
|
+
# faintness but rejects the sparse, clustered bright pixels of photo texture.
|
|
78
|
+
_CONF_BRIGHT_FRAC = 0.055 # min bright-pixel fraction inside the body
|
|
79
|
+
_CONF_BRIGHT_PX = 80 # min absolute bright-pixel count inside the body
|
|
80
|
+
_CONF_HSPAN = 0.45 # bright ink must span ≥ this fraction of body width
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def find_move_indicators(im: np.ndarray) -> List[List[int]]:
|
|
84
|
+
"""Return ``[x, y, w, h]`` boxes (pixels) of every hCaptcha "Move" pill in the
|
|
85
|
+
BGR image ``im``. Empty list when there is none (e.g. grid / click puzzles).
|
|
86
|
+
"""
|
|
87
|
+
H, W = im.shape[:2]
|
|
88
|
+
|
|
89
|
+
# Teal-body-first candidates (the primary path), UNION the legacy wordmark
|
|
90
|
+
# path (recovers anything the teal mask misses), then dedup.
|
|
91
|
+
cands = _teal_body_pills(im)
|
|
92
|
+
cands.extend(_wordmark_runs(im))
|
|
93
|
+
|
|
94
|
+
y_lo, y_hi = _Y_BAND[0] * H, _Y_BAND[1] * H
|
|
95
|
+
cands = [c for c in cands if y_lo <= c[1] + c[3] / 2 <= y_hi]
|
|
96
|
+
|
|
97
|
+
merged: List[List[int]] = []
|
|
98
|
+
for c in sorted(cands, key=lambda c: -c[2] * c[3]):
|
|
99
|
+
if all(not _centers_overlap(c, m) for m in merged):
|
|
100
|
+
merged.append(c)
|
|
101
|
+
# stable ordering: top-to-bottom, then left-to-right
|
|
102
|
+
merged.sort(key=lambda c: (c[1], c[0]))
|
|
103
|
+
return merged
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def find_movable_content(
|
|
107
|
+
im: np.ndarray,
|
|
108
|
+
indicator: List[int],
|
|
109
|
+
*,
|
|
110
|
+
pad: int = 4,
|
|
111
|
+
) -> Optional[List[int]]:
|
|
112
|
+
"""Given one Move pill ``indicator`` (``[x, y, w, h]``), return the bounding
|
|
113
|
+
box ``[x, y, w, h]`` of the movable CARD/object that sits directly BELOW it.
|
|
114
|
+
|
|
115
|
+
The pill caps the top of a draggable card. We walk down from just under the
|
|
116
|
+
pill, gathering the contiguous run of card rows (rows that contain real,
|
|
117
|
+
non-background content) until we hit a gap (the margin before the next card
|
|
118
|
+
or the panel edge), and bound it horizontally to the column the pill heads.
|
|
119
|
+
Pure OpenCV; returns None if no content is found under the pill.
|
|
120
|
+
"""
|
|
121
|
+
H, W = im.shape[:2]
|
|
122
|
+
px, py, pw, ph = indicator
|
|
123
|
+
gray = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY)
|
|
124
|
+
|
|
125
|
+
# Horizontal search window: centred on the pill, a bit wider (cards are
|
|
126
|
+
# usually a touch wider than their pill), clamped to the frame.
|
|
127
|
+
cx = px + pw / 2
|
|
128
|
+
half = max(pw, 70) * 0.75
|
|
129
|
+
sx0 = int(max(0, cx - half))
|
|
130
|
+
sx1 = int(min(W, cx + half))
|
|
131
|
+
|
|
132
|
+
# Start just below the pill; scan downward.
|
|
133
|
+
top = min(H - 1, py + ph + 1)
|
|
134
|
+
|
|
135
|
+
# Per-row "has content" signal: a row inside a card has internal structure
|
|
136
|
+
# (edges) and/or a spread of intensities; a margin/gap row is flat.
|
|
137
|
+
band = gray[:, sx0:sx1]
|
|
138
|
+
row_std = band.std(axis=1)
|
|
139
|
+
# Robust background estimate from the rows immediately under the pill margin.
|
|
140
|
+
gap_thresh = max(6.0, float(np.median(row_std)) * 0.5)
|
|
141
|
+
|
|
142
|
+
# Find the contiguous content run starting at/after `top`.
|
|
143
|
+
y = top
|
|
144
|
+
# skip an initial thin margin (up to ~14px) between pill and card body
|
|
145
|
+
skip = 0
|
|
146
|
+
while y < H and row_std[y] < gap_thresh and skip < 16:
|
|
147
|
+
y += 1
|
|
148
|
+
skip += 1
|
|
149
|
+
content_start = y
|
|
150
|
+
run_gap = 0
|
|
151
|
+
last_content = content_start
|
|
152
|
+
while y < H:
|
|
153
|
+
if row_std[y] >= gap_thresh:
|
|
154
|
+
last_content = y
|
|
155
|
+
run_gap = 0
|
|
156
|
+
else:
|
|
157
|
+
run_gap += 1
|
|
158
|
+
if run_gap >= 10: # a real gap ends this card
|
|
159
|
+
break
|
|
160
|
+
y += 1
|
|
161
|
+
if last_content <= content_start:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
y0, y1 = content_start, last_content + 1
|
|
165
|
+
|
|
166
|
+
# Tighten horizontally to the actual content columns within the run.
|
|
167
|
+
sub = gray[y0:y1, sx0:sx1]
|
|
168
|
+
col_std = sub.std(axis=0)
|
|
169
|
+
cthr = max(6.0, float(np.median(col_std)) * 0.5)
|
|
170
|
+
cols = np.where(col_std >= cthr)[0]
|
|
171
|
+
if cols.size:
|
|
172
|
+
cx0 = sx0 + int(cols.min())
|
|
173
|
+
cx1 = sx0 + int(cols.max()) + 1
|
|
174
|
+
else:
|
|
175
|
+
cx0, cx1 = sx0, sx1
|
|
176
|
+
|
|
177
|
+
bx = max(0, cx0 - pad)
|
|
178
|
+
by = max(0, y0 - pad)
|
|
179
|
+
bw = min(W - bx, (cx1 - cx0) + 2 * pad)
|
|
180
|
+
bh = min(H - by, (y1 - y0) + 2 * pad)
|
|
181
|
+
if bw < 12 or bh < 12:
|
|
182
|
+
return None
|
|
183
|
+
return [int(bx), int(by), int(bw), int(bh)]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ── internals ────────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _teal_body_pills(im: np.ndarray) -> List[List[int]]:
|
|
190
|
+
"""PRIMARY path: find pills directly by their flat-teal BODY, then confirm
|
|
191
|
+
each with white-wordmark evidence inside.
|
|
192
|
+
|
|
193
|
+
Candidate generation is teal-locked (opacity-robust, survives faint/sliced
|
|
194
|
+
text), so it catches the dark-side-panel pills a wordmark-first detector
|
|
195
|
+
drops. Confirmation is lenient on text faintness because the only things that
|
|
196
|
+
can pass the teal+geometry gate are real pills and the rare photographic tile
|
|
197
|
+
that happens to median teal — and those have sparse, clustered bright pixels,
|
|
198
|
+
not a spread wordmark.
|
|
199
|
+
"""
|
|
200
|
+
H, W = im.shape[:2]
|
|
201
|
+
hsv = cv2.cvtColor(im, cv2.COLOR_BGR2HSV)
|
|
202
|
+
Hh = hsv[:, :, 0]
|
|
203
|
+
S = hsv[:, :, 1]
|
|
204
|
+
V = hsv[:, :, 2]
|
|
205
|
+
|
|
206
|
+
mask = (
|
|
207
|
+
(Hh >= _TEAL_HUE_LO)
|
|
208
|
+
& (Hh <= _TEAL_HUE_HI)
|
|
209
|
+
& (S >= _TEAL_S_MIN)
|
|
210
|
+
& (V >= _TEAL_V_LO)
|
|
211
|
+
& (V <= _TEAL_V_HI)
|
|
212
|
+
).astype(np.uint8)
|
|
213
|
+
|
|
214
|
+
# Close into solid horizontal blobs (bridge the white glyph/text gaps inside
|
|
215
|
+
# the body and any thin artifact slice through it).
|
|
216
|
+
closed = cv2.morphologyEx(
|
|
217
|
+
mask, cv2.MORPH_CLOSE,
|
|
218
|
+
cv2.getStructuringElement(cv2.MORPH_RECT, (15, 3)),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
n, lbl, stats, _ = cv2.connectedComponentsWithStats(closed)
|
|
222
|
+
out: List[List[int]] = []
|
|
223
|
+
for i in range(1, n):
|
|
224
|
+
x, y, w, h, a = stats[i]
|
|
225
|
+
if not (_TEAL_W[0] <= w <= _TEAL_W[1] and _TEAL_H[0] <= h <= _TEAL_H[1]):
|
|
226
|
+
continue
|
|
227
|
+
ar = w / max(h, 1)
|
|
228
|
+
if not (_TEAL_AR[0] <= ar <= _TEAL_AR[1]):
|
|
229
|
+
continue
|
|
230
|
+
if a / float(w * h) < _TEAL_EXTENT: # must be a FILLED rounded rectangle
|
|
231
|
+
continue
|
|
232
|
+
if not _wordmark_inside(im, x, y, w, h):
|
|
233
|
+
continue
|
|
234
|
+
# Emit a box padded to span the full pill (the teal blob is the inner
|
|
235
|
+
# body; the rounded corners + a little margin extend slightly beyond).
|
|
236
|
+
bx = max(0, x - 4)
|
|
237
|
+
by = max(0, y - 4)
|
|
238
|
+
bw = min(W - bx, w + 8)
|
|
239
|
+
bh = min(H - by, h + 8)
|
|
240
|
+
out.append([int(bx), int(by), int(bw), int(bh)])
|
|
241
|
+
return out
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _wordmark_inside(im: np.ndarray, x: int, y: int, w: int, h: int) -> bool:
|
|
245
|
+
"""CONFIRMATION: require white-wordmark evidence inside the teal body.
|
|
246
|
+
|
|
247
|
+
A real pill carries "[glyph] Move" — bright (V>150, S<90) ink spread
|
|
248
|
+
horizontally across the body interior. Photographic teal tiles have only
|
|
249
|
+
sparse, clustered bright pixels. We gate on (a) enough bright mass (count and
|
|
250
|
+
fraction) and (b) horizontal spread, all measured inside the candidate body.
|
|
251
|
+
"""
|
|
252
|
+
sub = im[y:y + h, x:x + w]
|
|
253
|
+
if sub.size == 0:
|
|
254
|
+
return False
|
|
255
|
+
hsv = cv2.cvtColor(sub, cv2.COLOR_BGR2HSV)
|
|
256
|
+
bright = (hsv[:, :, 2] > _BRIGHT_V) & (hsv[:, :, 1] < _BRIGHT_S)
|
|
257
|
+
bcount = int(bright.sum())
|
|
258
|
+
if bcount < _CONF_BRIGHT_PX:
|
|
259
|
+
return False
|
|
260
|
+
if bright.mean() < _CONF_BRIGHT_FRAC:
|
|
261
|
+
return False
|
|
262
|
+
cols = np.where(bright.sum(axis=0) > 0)[0]
|
|
263
|
+
if cols.size == 0:
|
|
264
|
+
return False
|
|
265
|
+
span = cols.max() - cols.min() + 1
|
|
266
|
+
if span < _CONF_HSPAN * w:
|
|
267
|
+
return False
|
|
268
|
+
return True
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _wordmark_runs(im: np.ndarray) -> List[List[int]]:
|
|
272
|
+
"""LEGACY (union) path: find Move pills by their white "[glyph] Move"
|
|
273
|
+
wordmark, then gate each on a dark pill body, a left glyph cluster, and the
|
|
274
|
+
flat-teal pill body colour. Recovers any pill the teal-body path misses."""
|
|
275
|
+
H, W = im.shape[:2]
|
|
276
|
+
hsv = cv2.cvtColor(im, cv2.COLOR_BGR2HSV)
|
|
277
|
+
V = hsv[:, :, 2]
|
|
278
|
+
S = hsv[:, :, 1]
|
|
279
|
+
bright = ((V > _BRIGHT_V) & (S < _BRIGHT_S)).astype(np.uint8)
|
|
280
|
+
|
|
281
|
+
# Strip large solid bright blobs (cards / shapes / instruction text); keep the
|
|
282
|
+
# thin glyph + letter strokes. This makes the wordmark pop whether the pill is
|
|
283
|
+
# on a light card or inside a dark side-panel.
|
|
284
|
+
op = cv2.morphologyEx(
|
|
285
|
+
bright, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
|
|
286
|
+
)
|
|
287
|
+
n, lbl, stats, _ = cv2.connectedComponentsWithStats(op)
|
|
288
|
+
bigsolid = np.zeros_like(bright)
|
|
289
|
+
for i in range(1, n):
|
|
290
|
+
x, y, w, h, a = stats[i]
|
|
291
|
+
if a > 400 and (w > 40 or h > 22):
|
|
292
|
+
bigsolid[lbl == i] = 1
|
|
293
|
+
bigsolid = cv2.dilate(bigsolid, cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)))
|
|
294
|
+
textonly = cv2.subtract(bright, bigsolid)
|
|
295
|
+
|
|
296
|
+
# Close strokes horizontally into "[glyph] Move" wordmark blobs.
|
|
297
|
+
closed = cv2.morphologyEx(
|
|
298
|
+
textonly * 255,
|
|
299
|
+
cv2.MORPH_CLOSE,
|
|
300
|
+
cv2.getStructuringElement(cv2.MORPH_RECT, (23, 3)),
|
|
301
|
+
)
|
|
302
|
+
nn, ll, ss, _ = cv2.connectedComponentsWithStats(closed)
|
|
303
|
+
out: List[List[int]] = []
|
|
304
|
+
for i in range(1, nn):
|
|
305
|
+
x, y, w, h, a = ss[i]
|
|
306
|
+
ar = w / max(h, 1)
|
|
307
|
+
if not (2.8 < ar < 13 and 5 <= h <= 22 and 30 <= w <= 130):
|
|
308
|
+
continue
|
|
309
|
+
sub = V[y:y + h, x:x + w]
|
|
310
|
+
tmask = textonly[y:y + h, x:x + w] > 0
|
|
311
|
+
body = sub[~tmask]
|
|
312
|
+
if body.size == 0:
|
|
313
|
+
continue
|
|
314
|
+
if np.median(body) > 85: # pill body must be dark
|
|
315
|
+
continue
|
|
316
|
+
if (body < 100).mean() < 0.45: # solidly dark, not a stray run
|
|
317
|
+
continue
|
|
318
|
+
if not _glyph_present(bright, x, y, w, h, H, W):
|
|
319
|
+
continue
|
|
320
|
+
if not _pill_body_match(im, x, y, w, h):
|
|
321
|
+
continue
|
|
322
|
+
bx = max(0, x - 30)
|
|
323
|
+
by = max(0, y - 9)
|
|
324
|
+
bw = min(W - bx, w + 50)
|
|
325
|
+
bh = min(H - by, h + 20)
|
|
326
|
+
out.append([int(bx), int(by), int(bw), int(bh)])
|
|
327
|
+
return out
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _glyph_present(
|
|
331
|
+
bright: np.ndarray, x: int, y: int, w: int, h: int, H: int, W: int
|
|
332
|
+
) -> bool:
|
|
333
|
+
"""The move glyph (four-arrow symbol) is a compact bright cluster immediately
|
|
334
|
+
LEFT of "Move". Require a bright cluster in the left ~40% of the wordmark.
|
|
335
|
+
"FINISH" / "Skip" labels have NO bright pixels there, so they fail.
|
|
336
|
+
|
|
337
|
+
The glyph renders at varying opacity: when crisp it's a tall plus/cross that
|
|
338
|
+
spans most of the text height; when faint only its vertical stroke + arrow
|
|
339
|
+
tips clear the brightness threshold. So accept either a tall-enough cluster
|
|
340
|
+
OR a smaller cluster with enough concentrated bright mass — both clearly
|
|
341
|
+
distinguish a glyph from "no glyph at all"."""
|
|
342
|
+
gy0, gy1 = max(0, y - 3), min(H, y + h + 3)
|
|
343
|
+
gx0, gx1 = max(0, x - 7), min(W, x + w)
|
|
344
|
+
sub = bright[gy0:gy1, gx0:gx1]
|
|
345
|
+
if sub.size == 0:
|
|
346
|
+
return False
|
|
347
|
+
sh, sw = sub.shape
|
|
348
|
+
lw = max(5, int(sw * 0.40))
|
|
349
|
+
left = sub[:, :lw]
|
|
350
|
+
rows = np.where(left.sum(axis=1) > 0)[0]
|
|
351
|
+
if rows.size == 0:
|
|
352
|
+
return False
|
|
353
|
+
vext = rows.max() - rows.min() + 1
|
|
354
|
+
mass = int(left.sum())
|
|
355
|
+
if vext >= 0.35 * sh and mass >= 6:
|
|
356
|
+
return True
|
|
357
|
+
if mass >= 10 and vext >= 0.25 * sh:
|
|
358
|
+
return True
|
|
359
|
+
return False
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _pill_body_match(im: np.ndarray, x: int, y: int, w: int, h: int) -> bool:
|
|
363
|
+
"""True iff the dark body inside the wordmark bbox is the flat teal Move pill.
|
|
364
|
+
|
|
365
|
+
Trims padding to a central core, takes the dark (non-text) pixels, and gates
|
|
366
|
+
on: (1) median hue within 4 of the pill teal (108); (2) BGR channel ordering
|
|
367
|
+
B>G>R with the pill's spacing (B-G in [5,15], G-R in [3,13]); (3) saturation
|
|
368
|
+
>= 40; (4) flat fill — grayscale std <= 24 AND hue std <= 14 (a photo tile
|
|
369
|
+
that coincidentally medians teal is still textured, so its hue/gray std blow
|
|
370
|
+
past these). Models the rendered UI element, not coordinates."""
|
|
371
|
+
tx = int(w * 0.15)
|
|
372
|
+
ty = int(h * 0.12)
|
|
373
|
+
x0, x1 = x + tx, x + w - tx
|
|
374
|
+
y0, y1 = y + ty, y + h - ty
|
|
375
|
+
if x1 - x0 < 10 or y1 - y0 < 6:
|
|
376
|
+
x0, x1, y0, y1 = x, x + w, y, y + h
|
|
377
|
+
crop = im[y0:y1, x0:x1]
|
|
378
|
+
if crop.size == 0:
|
|
379
|
+
return False
|
|
380
|
+
hsv = cv2.cvtColor(crop, cv2.COLOR_BGR2HSV)
|
|
381
|
+
Hh = hsv[:, :, 0].astype(np.float32)
|
|
382
|
+
V = hsv[:, :, 2].astype(np.float32)
|
|
383
|
+
S = hsv[:, :, 1].astype(np.float32)
|
|
384
|
+
bright = (V > _BRIGHT_V) & (S < _BRIGHT_S)
|
|
385
|
+
dark = (V < _DARK_V) & (~bright)
|
|
386
|
+
if dark.sum() < 15:
|
|
387
|
+
return False
|
|
388
|
+
px = crop.reshape(-1, 3).astype(np.float32)[dark.reshape(-1)]
|
|
389
|
+
b, g, r = np.median(px, axis=0)
|
|
390
|
+
hue_med = float(np.median(Hh[dark]))
|
|
391
|
+
sat_med = float(np.median(S[dark]))
|
|
392
|
+
if abs(hue_med - _PILL_HUE) > 4: # body must be the pill teal
|
|
393
|
+
return False
|
|
394
|
+
if not (5 <= b - g <= 15 and 3 <= g - r <= 13): # B>G>R, pill channel spacing
|
|
395
|
+
return False
|
|
396
|
+
if sat_med < 40:
|
|
397
|
+
return False
|
|
398
|
+
gray_std = float(px.mean(axis=1).std())
|
|
399
|
+
hue_std = float(Hh[dark].std())
|
|
400
|
+
# Flat fill, not photo texture. The pill body is a single rendered teal, so
|
|
401
|
+
# its hue is essentially constant (hue_std ≤ ~1.5 across every real pill);
|
|
402
|
+
# the corpus's coincidental-teal photo tiles, even when they median teal, are
|
|
403
|
+
# textured and scatter in hue (hue_std 3-44). hue_std ≤ 2 removes all four
|
|
404
|
+
# legacy false positives while keeping every real legacy-only recovery.
|
|
405
|
+
if gray_std > 24 or hue_std > 2.0:
|
|
406
|
+
return False
|
|
407
|
+
return True
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _iou(a: List[int], b: List[int]) -> float:
|
|
411
|
+
ax, ay, aw, ah = a
|
|
412
|
+
bx, by, bw, bh = b
|
|
413
|
+
ix0, iy0 = max(ax, bx), max(ay, by)
|
|
414
|
+
ix1, iy1 = min(ax + aw, bx + bw), min(ay + ah, by + bh)
|
|
415
|
+
iw, ih = max(0, ix1 - ix0), max(0, iy1 - iy0)
|
|
416
|
+
inter = iw * ih
|
|
417
|
+
return 0.0 if inter == 0 else inter / (aw * ah + bw * bh - inter)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _centers_overlap(a: List[int], b: List[int]) -> bool:
|
|
421
|
+
"""Dedup test robust to differently-sized boxes of the same pill: True if
|
|
422
|
+
IoU>=0.3 OR either box's center lies inside the other."""
|
|
423
|
+
if _iou(a, b) >= 0.3:
|
|
424
|
+
return True
|
|
425
|
+
ax, ay, aw, ah = a
|
|
426
|
+
bx, by, bw, bh = b
|
|
427
|
+
acx, acy = ax + aw / 2, ay + ah / 2
|
|
428
|
+
bcx, bcy = bx + bw / 2, by + bh / 2
|
|
429
|
+
return (bx <= acx <= bx + bw and by <= acy <= by + bh) or (
|
|
430
|
+
ax <= bcx <= ax + aw and ay <= bcy <= ay + ah
|
|
431
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Bundle the sibling `../python` (the `captchakraken` package) into `./python`
|
|
2
|
+
// so the published npm package is self-contained: the JS solver shells out to
|
|
3
|
+
// this bundled engine for OpenCV grid detection and the vLLM planner.
|
|
4
|
+
//
|
|
5
|
+
// npm can only pack files inside the package root, so the single source of truth
|
|
6
|
+
// at repo-root `python/` is copied in here at build time. Runtime source (src/)
|
|
7
|
+
// + pyproject are what the postinstall `pip install .` needs; tests, caches,
|
|
8
|
+
// venvs and build artifacts are skipped.
|
|
9
|
+
import { cpSync, rmSync, existsSync } from 'node:fs'
|
|
10
|
+
import { dirname, resolve } from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
|
|
13
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
14
|
+
const src = resolve(here, '../../python')
|
|
15
|
+
const dest = resolve(here, '../python')
|
|
16
|
+
|
|
17
|
+
if (!existsSync(src)) {
|
|
18
|
+
console.error(`[copy-python] source engine not found at ${src}`)
|
|
19
|
+
process.exit(1)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SKIP = new Set(['.venv', '__pycache__', 'dist', 'build', '.pytest_cache', '.ruff_cache', 'tests'])
|
|
23
|
+
|
|
24
|
+
rmSync(dest, { recursive: true, force: true })
|
|
25
|
+
cpSync(src, dest, {
|
|
26
|
+
recursive: true,
|
|
27
|
+
filter: (p) => !p.split(/[\\/]/).some((seg) => SKIP.has(seg) || seg.endsWith('.egg-info')),
|
|
28
|
+
})
|
|
29
|
+
console.log('[copy-python] bundled python/ engine -> js/python/')
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* postinstall bootstrap: create a local venv under `python/.venv` and install the
|
|
3
|
+
* bundled `captchakraken` package so `python -m captchakraken.cli` works out-of-the-box.
|
|
4
|
+
*
|
|
5
|
+
* Opt out:
|
|
6
|
+
* CAPTCHA_KRAKEN_SKIP_PYTHON_SETUP=1
|
|
7
|
+
*
|
|
8
|
+
* Force a specific system python:
|
|
9
|
+
* CAPTCHA_KRAKEN_PYTHON=/path/to/python3
|
|
10
|
+
*/
|
|
11
|
+
const { spawnSync } = require('child_process');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
function run(cmd, args, opts = {}) {
|
|
16
|
+
const res = spawnSync(cmd, args, { stdio: 'inherit', ...opts });
|
|
17
|
+
if (res.error) throw res.error;
|
|
18
|
+
if (res.status !== 0) {
|
|
19
|
+
throw new Error(`Command failed (${res.status}): ${cmd} ${args.join(' ')}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function exists(p) {
|
|
24
|
+
try {
|
|
25
|
+
fs.accessSync(p);
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function venvPython(venvDir) {
|
|
33
|
+
const candidates = [
|
|
34
|
+
path.join(venvDir, 'bin', 'python'),
|
|
35
|
+
path.join(venvDir, 'bin', 'python3'),
|
|
36
|
+
path.join(venvDir, 'Scripts', 'python.exe'),
|
|
37
|
+
path.join(venvDir, 'Scripts', 'python'),
|
|
38
|
+
];
|
|
39
|
+
for (const c of candidates) if (exists(c)) return c;
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveSystemPython() {
|
|
44
|
+
if (process.env.CAPTCHA_KRAKEN_PYTHON) return process.env.CAPTCHA_KRAKEN_PYTHON;
|
|
45
|
+
// Try python3 first, then python.
|
|
46
|
+
return process.platform === 'win32' ? 'python' : 'python3';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function main() {
|
|
50
|
+
if (process.env.CAPTCHA_KRAKEN_SKIP_PYTHON_SETUP === '1') {
|
|
51
|
+
console.log('[CaptchaKraken] Skipping python setup (CAPTCHA_KRAKEN_SKIP_PYTHON_SETUP=1).');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const pkgRoot = path.resolve(__dirname, '..');
|
|
56
|
+
const cliRoot = path.join(pkgRoot, 'python');
|
|
57
|
+
const venvDir = path.join(cliRoot, '.venv');
|
|
58
|
+
|
|
59
|
+
if (!exists(path.join(cliRoot, 'pyproject.toml'))) {
|
|
60
|
+
console.log(`[CaptchaKraken] No bundled python engine found at ${cliRoot}; skipping python setup.`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let py = venvPython(venvDir);
|
|
65
|
+
if (py) {
|
|
66
|
+
console.log(`[CaptchaKraken] Found existing venv python at ${py}; ensuring package is installed...`);
|
|
67
|
+
} else {
|
|
68
|
+
const sysPy = resolveSystemPython();
|
|
69
|
+
console.log(`[CaptchaKraken] Creating venv at ${venvDir} using ${sysPy}...`);
|
|
70
|
+
run(sysPy, ['-m', 'venv', venvDir], { cwd: cliRoot });
|
|
71
|
+
py = venvPython(venvDir);
|
|
72
|
+
if (!py) {
|
|
73
|
+
throw new Error('[CaptchaKraken] Failed to locate venv python after venv creation.');
|
|
74
|
+
}
|
|
75
|
+
console.log('[CaptchaKraken] Upgrading pip/setuptools/wheel...');
|
|
76
|
+
run(py, ['-m', 'pip', 'install', '--upgrade', 'pip', 'setuptools', 'wheel'], { cwd: cliRoot });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Install the `captchakraken` package with its CORE (lightweight) deps only:
|
|
80
|
+
// OpenCV grid detection + the HTTP planner that talks to a vLLM server. The
|
|
81
|
+
// heavy serving stack (vllm/torch, the `[serve]` extra) is NOT installed here
|
|
82
|
+
// — that's what setup.sh installs for people self-hosting the model.
|
|
83
|
+
console.log('[CaptchaKraken] Installing bundled python package (core deps)...');
|
|
84
|
+
run(py, ['-m', 'pip', 'install', '.'], { cwd: cliRoot });
|
|
85
|
+
|
|
86
|
+
console.log('[CaptchaKraken] Python environment ready.');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
main();
|
|
91
|
+
} catch (err) {
|
|
92
|
+
const strict = process.env.CAPTCHA_KRAKEN_PYTHON_SETUP_STRICT === '1';
|
|
93
|
+
console.warn('[CaptchaKraken] Python setup failed during postinstall.');
|
|
94
|
+
console.warn('Reason:', err && err.message ? err.message : String(err));
|
|
95
|
+
console.warn(
|
|
96
|
+
'You can re-run setup later, or skip it entirely with CAPTCHA_KRAKEN_SKIP_PYTHON_SETUP=1.\n' +
|
|
97
|
+
'- Ensure you have Python 3.10+ installed\n' +
|
|
98
|
+
'- Optionally set CAPTCHA_KRAKEN_PYTHON=/path/to/python3\n' +
|
|
99
|
+
'- If you want the install to fail on setup errors, set CAPTCHA_KRAKEN_PYTHON_SETUP_STRICT=1'
|
|
100
|
+
);
|
|
101
|
+
if (strict) process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|