captchakraken 2.8.0 → 3.0.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/AGENTS.md +112 -0
- package/NOTICE +36 -0
- package/dist/cli-invocation.d.ts +9 -40
- package/dist/cli-invocation.js +16 -49
- package/dist/errors.d.ts +7 -27
- package/dist/errors.js +6 -25
- package/dist/humanize.d.ts +56 -240
- package/dist/humanize.js +159 -415
- package/dist/index.d.ts +5 -129
- package/dist/index.js +4 -98
- package/dist/kinds.d.ts +135 -0
- package/dist/kinds.js +70 -0
- package/dist/limits.d.ts +0 -48
- package/dist/limits.js +2 -48
- package/dist/model-name.d.ts +4 -19
- package/dist/model-name.js +32 -40
- package/dist/playwright-types.d.ts +25 -95
- package/dist/playwright-types.js +0 -23
- package/dist/puppeteer-adapter.d.ts +4 -43
- package/dist/puppeteer-adapter.js +22 -58
- package/dist/python-command.d.ts +0 -41
- package/dist/python-command.js +0 -37
- package/dist/selectors.d.ts +54 -0
- package/dist/selectors.js +117 -0
- package/dist/slide-geometry.d.ts +2 -29
- package/dist/slide-geometry.js +18 -44
- package/dist/solver.d.ts +112 -539
- package/dist/solver.js +1088 -2950
- package/dist/timing.d.ts +7 -40
- package/dist/timing.js +4 -36
- package/dist/token-usage.d.ts +1 -7
- package/dist/token-usage.js +10 -95
- package/dist/types.d.ts +77 -483
- package/dist/watcher.d.ts +5 -78
- package/dist/watcher.js +0 -56
- package/package.json +12 -4
- package/python/AGENTS.md +122 -0
- package/python/NOTICE +36 -0
- package/python/README.md +1 -2
- package/python/examples/README.md +10 -0
- package/python/examples/_harness.py +4 -73
- package/python/examples/demoHcaptcha.py +0 -13
- package/python/examples/demoRecaptcha.py +0 -10
- package/python/examples/watch_playwright.py +1 -26
- package/python/examples/with_local_gguf.py +28 -0
- package/python/examples/with_playwright.py +0 -19
- package/python/pyproject.toml +68 -19
- package/python/src/captchakraken/__init__.py +37 -45
- package/python/src/captchakraken/action_types.py +14 -36
- package/python/src/captchakraken/cli.py +262 -879
- package/python/src/captchakraken/config.py +60 -146
- package/python/src/captchakraken/errors.py +17 -95
- package/python/src/captchakraken/humanize.py +174 -597
- package/python/src/captchakraken/image_processor.py +16 -235
- package/python/src/captchakraken/keyframes.py +69 -434
- package/python/src/captchakraken/kinds.py +152 -0
- package/python/src/captchakraken/models.json +79 -21
- package/python/src/captchakraken/overlay.py +65 -498
- package/python/src/captchakraken/page_solver.py +1123 -2564
- package/python/src/captchakraken/planner.py +70 -428
- package/python/src/captchakraken/prompts.py +45 -261
- package/python/src/captchakraken/selectors.py +184 -0
- package/python/src/captchakraken/server_manager.py +7 -58
- package/python/src/captchakraken/solver.py +128 -522
- package/python/src/captchakraken/timing.py +20 -74
- package/python/src/captchakraken/tool_calls/board_painted.py +47 -0
- package/python/src/captchakraken/tool_calls/find_checkbox.py +20 -30
- package/python/src/captchakraken/tool_calls/find_grid.py +186 -1339
- package/python/src/captchakraken/tool_calls/track_piece.py +81 -57
- package/python/src/captchakraken/updater.py +7 -76
- package/python/src/captchakraken/watcher.py +12 -101
- package/scripts/copy-python.mjs +10 -10
- package/scripts/setup-python.js +1 -21
- package/dist/trajectory.d.ts +0 -58
- package/dist/trajectory.js +0 -266
- package/python/src/captchakraken/tool_calls/move_indicator.py +0 -431
- package/python/src/captchakraken/trajectory.py +0 -284
|
@@ -1,16 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import math
|
|
4
|
-
import sys
|
|
5
|
-
import numpy as np
|
|
6
|
-
import cv2
|
|
1
|
+
"""Numbered box overlays. The grid overlay the model reads is drawn here, so its look is frozen."""
|
|
2
|
+
|
|
7
3
|
from typing import Any, Dict
|
|
8
4
|
|
|
9
|
-
from PIL import Image, ImageDraw, ImageFont
|
|
5
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
10
6
|
|
|
11
|
-
|
|
12
|
-
_FONT_CACHE: Dict[tuple, Any] = {}
|
|
7
|
+
from .kinds import LabelPosition
|
|
13
8
|
|
|
9
|
+
_FONT_CACHE: Dict[int, Any] = {}
|
|
14
10
|
_FONT_PATHS = [
|
|
15
11
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
|
16
12
|
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
|
@@ -22,499 +18,70 @@ _FONT_PATHS = [
|
|
|
22
18
|
]
|
|
23
19
|
|
|
24
20
|
|
|
25
|
-
def
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
except OSError:
|
|
36
|
-
continue
|
|
21
|
+
def _font(size: int):
|
|
22
|
+
if size not in _FONT_CACHE:
|
|
23
|
+
_FONT_CACHE[size] = None
|
|
24
|
+
for path in _FONT_PATHS:
|
|
25
|
+
try:
|
|
26
|
+
_FONT_CACHE[size] = ImageFont.truetype(path, size)
|
|
27
|
+
break
|
|
28
|
+
except OSError:
|
|
29
|
+
continue
|
|
30
|
+
return _FONT_CACHE[size]
|
|
37
31
|
|
|
38
|
-
_FONT_CACHE[cache_key] = font
|
|
39
|
-
return font
|
|
40
32
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if len(hex_color) == 6:
|
|
45
|
-
rgb = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
|
|
46
|
-
else:
|
|
47
|
-
rgb = (255, 0, 0) # Fallback
|
|
33
|
+
def _rgba(hex_color: str, alpha: int):
|
|
34
|
+
hex_color = hex_color.lstrip("#")
|
|
35
|
+
rgb = tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4)) if len(hex_color) == 6 else (255, 0, 0)
|
|
48
36
|
return rgb + (alpha,)
|
|
49
37
|
|
|
50
|
-
def draw_enhanced_bounding_box(
|
|
51
|
-
draw,
|
|
52
|
-
bbox,
|
|
53
|
-
text=None,
|
|
54
|
-
number=None,
|
|
55
|
-
image_size=None,
|
|
56
|
-
color="#C0392B",
|
|
57
|
-
label_position="top-left",
|
|
58
|
-
box_style: str = "thin",
|
|
59
|
-
):
|
|
60
|
-
x1, y1, x2, y2 = bbox
|
|
61
|
-
# color is passed as argument
|
|
62
|
-
|
|
63
|
-
# Box style
|
|
64
|
-
# - dashed: good for multi-object overlays (keeps boxes distinct)
|
|
65
|
-
# - solid: match the 10% grid overlay aesthetic (green + black underlay)
|
|
66
|
-
shadow_color = (0, 0, 0, 210) # strong underlay for contrast
|
|
67
38
|
|
|
39
|
+
def _draw_box(draw, bbox, label, image_size, color, label_position, box_style):
|
|
40
|
+
x1, y1, x2, y2 = bbox
|
|
68
41
|
if box_style == "solid":
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
42
|
+
draw.rectangle([x1, y1, x2, y2], outline=(0, 0, 0, 210), width=4)
|
|
43
|
+
draw.rectangle([x1, y1, x2, y2], outline=color, width=2)
|
|
44
|
+
if not label:
|
|
45
|
+
return
|
|
46
|
+
img_w, img_h = image_size
|
|
47
|
+
font = _font(max(14, min(48, int(img_w * 0.035))))
|
|
48
|
+
tb = draw.textbbox((0, 0), label, font=font) if font else draw.textbbox((0, 0), label)
|
|
49
|
+
tw, th = tb[2] - tb[0], tb[3] - tb[1]
|
|
50
|
+
cw, ch = tw + 4, th + 4
|
|
51
|
+
if label_position is LabelPosition.CENTER:
|
|
52
|
+
bx1, by1 = (x1 + x2) / 2 - cw / 2, (y1 + y2) / 2 - ch / 2
|
|
53
|
+
elif label_position is LabelPosition.BOTTOM_RIGHT:
|
|
54
|
+
bx1, by1 = x2 - 4 - cw, y2 - 4 - ch
|
|
55
|
+
elif label_position is LabelPosition.BOTTOM_LEFT:
|
|
56
|
+
bx1, by1 = x1 + 4, y2 - 4 - ch
|
|
57
|
+
elif label_position is LabelPosition.TOP_RIGHT:
|
|
58
|
+
bx1, by1 = x2 - 4 - cw, y1 + 4
|
|
78
59
|
else:
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
draw_dashed_line(x2, y2, x1, y2)
|
|
108
|
-
draw_dashed_line(x1, y2, x1, y1)
|
|
109
|
-
|
|
110
|
-
label_text = ""
|
|
111
|
-
if number is not None:
|
|
112
|
-
label_text = str(number)
|
|
113
|
-
if text:
|
|
114
|
-
if label_text:
|
|
115
|
-
label_text += " " + text
|
|
116
|
-
else:
|
|
117
|
-
label_text = text
|
|
118
|
-
|
|
119
|
-
if label_text and image_size:
|
|
120
|
-
img_width, img_height = image_size
|
|
121
|
-
|
|
122
|
-
# Scale font based on image width (slightly larger for readability)
|
|
123
|
-
# Bump size a bit to make numbered overlays easier to read on captcha tiles.
|
|
124
|
-
base_font_size = max(14, min(48, int(img_width * 0.035)))
|
|
125
|
-
font = get_cross_platform_font(base_font_size)
|
|
126
|
-
|
|
127
|
-
# Get text size
|
|
128
|
-
if font:
|
|
129
|
-
bbox_text = draw.textbbox((0, 0), label_text, font=font)
|
|
130
|
-
else:
|
|
131
|
-
bbox_text = draw.textbbox((0, 0), label_text)
|
|
132
|
-
|
|
133
|
-
text_width = bbox_text[2] - bbox_text[0]
|
|
134
|
-
text_height = bbox_text[3] - bbox_text[1]
|
|
135
|
-
|
|
136
|
-
padding = 2 # Minimal padding
|
|
137
|
-
|
|
138
|
-
container_width = text_width + padding * 2
|
|
139
|
-
container_height = text_height + padding * 2
|
|
140
|
-
|
|
141
|
-
# Position logic
|
|
142
|
-
if label_position == "center":
|
|
143
|
-
# Center of the box
|
|
144
|
-
box_cx = (x1 + x2) / 2
|
|
145
|
-
box_cy = (y1 + y2) / 2
|
|
146
|
-
|
|
147
|
-
bg_x1 = box_cx - container_width / 2
|
|
148
|
-
bg_y1 = box_cy - container_height / 2
|
|
149
|
-
bg_x2 = box_cx + container_width / 2
|
|
150
|
-
bg_y2 = box_cy + container_height / 2
|
|
151
|
-
|
|
152
|
-
elif label_position == "bottom-right":
|
|
153
|
-
# Bottom Right of the box (inside)
|
|
154
|
-
bg_x2 = x2 - 4
|
|
155
|
-
bg_y2 = y2 - 4
|
|
156
|
-
bg_x1 = bg_x2 - container_width
|
|
157
|
-
bg_y1 = bg_y2 - container_height
|
|
158
|
-
|
|
159
|
-
elif label_position == "bottom-left":
|
|
160
|
-
# Bottom Left of the box (inside)
|
|
161
|
-
bg_x1 = x1 + 4
|
|
162
|
-
bg_y2 = y2 - 4
|
|
163
|
-
bg_x2 = bg_x1 + container_width
|
|
164
|
-
bg_y1 = bg_y2 - container_height
|
|
165
|
-
|
|
166
|
-
elif label_position == "top-right":
|
|
167
|
-
# Top Right of the box (inside)
|
|
168
|
-
bg_x2 = x2 - 4
|
|
169
|
-
bg_y1 = y1 + 4
|
|
170
|
-
bg_x1 = bg_x2 - container_width
|
|
171
|
-
bg_y2 = bg_y1 + container_height
|
|
172
|
-
|
|
173
|
-
else: # Default to top-left
|
|
174
|
-
bg_x1 = x1 + 4
|
|
175
|
-
bg_y1 = y1 + 4
|
|
176
|
-
bg_x2 = bg_x1 + container_width
|
|
177
|
-
bg_y2 = bg_y1 + container_height
|
|
178
|
-
|
|
179
|
-
# Text position
|
|
180
|
-
text_x = bg_x1 + (container_width - text_width) // 2
|
|
181
|
-
text_y = bg_y1 + (container_height - text_height) // 2 - bbox_text[1]
|
|
182
|
-
|
|
183
|
-
# Boundary checks (keep label inside image)
|
|
184
|
-
if bg_x1 < 0:
|
|
185
|
-
offset = -bg_x1
|
|
186
|
-
bg_x1 += offset
|
|
187
|
-
bg_x2 += offset
|
|
188
|
-
text_x += offset
|
|
189
|
-
if bg_y1 < 0:
|
|
190
|
-
offset = -bg_y1
|
|
191
|
-
bg_y1 += offset
|
|
192
|
-
bg_y2 += offset
|
|
193
|
-
text_y += offset
|
|
194
|
-
if bg_x2 > img_width:
|
|
195
|
-
offset = bg_x2 - img_width
|
|
196
|
-
bg_x1 -= offset
|
|
197
|
-
bg_x2 -= offset
|
|
198
|
-
text_x -= offset
|
|
199
|
-
if bg_y2 > img_height:
|
|
200
|
-
offset = bg_y2 - img_height
|
|
201
|
-
bg_y1 -= offset
|
|
202
|
-
bg_y2 -= offset
|
|
203
|
-
text_y -= offset
|
|
204
|
-
|
|
205
|
-
# Draw background and text
|
|
206
|
-
# Use the box color for the label background (e.g. red for high visibility)
|
|
207
|
-
fill_color = hex_to_rgba(color, alpha=200)
|
|
208
|
-
|
|
209
|
-
# Match label outline to the box color (instead of hardcoded white)
|
|
210
|
-
draw.rectangle([bg_x1, bg_y1, bg_x2, bg_y2], fill=fill_color, outline=color, width=2)
|
|
211
|
-
# Slight stroke helps keep text readable on busy backgrounds even at small sizes
|
|
212
|
-
draw.text(
|
|
213
|
-
(text_x, text_y),
|
|
214
|
-
label_text,
|
|
215
|
-
fill="white",
|
|
216
|
-
font=font,
|
|
217
|
-
stroke_width=2,
|
|
218
|
-
stroke_fill="black",
|
|
219
|
-
)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
def draw_red_border(draw, bbox, width=3):
|
|
223
|
-
"""Draw a solid red border around a bounding box."""
|
|
224
|
-
x1, y1, x2, y2 = bbox
|
|
225
|
-
color = "#FF0000"
|
|
226
|
-
draw.rectangle([x1, y1, x2, y2], outline=color, width=width)
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
def draw_arrow(draw, start, end, color="#FF0000", width=4):
|
|
230
|
-
"""Draw an arrow from start to end (high visibility for model feedback)."""
|
|
231
|
-
x1, y1 = start
|
|
232
|
-
x2, y2 = end
|
|
233
|
-
|
|
234
|
-
draw.line([start, end], fill=color, width=width)
|
|
235
|
-
|
|
236
|
-
# Arrow head
|
|
237
|
-
angle = math.atan2(y2 - y1, x2 - x1)
|
|
238
|
-
arrow_len = 20
|
|
239
|
-
angle_offset = math.pi / 6 # 30 degrees
|
|
240
|
-
|
|
241
|
-
p1 = (x2 - arrow_len * math.cos(angle - angle_offset), y2 - arrow_len * math.sin(angle - angle_offset))
|
|
242
|
-
p2 = (x2 - arrow_len * math.cos(angle + angle_offset), y2 - arrow_len * math.sin(angle + angle_offset))
|
|
243
|
-
|
|
244
|
-
draw.polygon([end, p1, p2], fill=color)
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
def add_overlays_to_image(image_path: str, boxes: list[dict], output_path: str = None, label_position="top-left"):
|
|
248
|
-
"""
|
|
249
|
-
Load an image, draw bounding boxes and numbered labels, and save it.
|
|
250
|
-
|
|
251
|
-
Args:
|
|
252
|
-
image_path: Path to source image
|
|
253
|
-
boxes: list of dicts with:
|
|
254
|
-
'bbox': [x1, y1, x2, y2] (normalized) OR [x, y, w, h] (pixels)
|
|
255
|
-
'text': str (optional)
|
|
256
|
-
'number': int/str (optional)
|
|
257
|
-
'color': str (optional)
|
|
258
|
-
output_path: Path to save result (defaults to overwriting image_path)
|
|
259
|
-
label_position: "top-left", "bottom-right", "center"
|
|
260
|
-
"""
|
|
261
|
-
try:
|
|
262
|
-
with Image.open(image_path) as img:
|
|
263
|
-
img = img.convert("RGBA")
|
|
264
|
-
draw = ImageDraw.Draw(img)
|
|
265
|
-
width, height = img.size
|
|
266
|
-
|
|
267
|
-
for box in boxes:
|
|
268
|
-
bbox = box["bbox"]
|
|
269
|
-
|
|
270
|
-
# Check for normalized coordinates (all <= 1.0)
|
|
271
|
-
# Note: This heuristic might fail for very small images or 1x1 pixel crops,
|
|
272
|
-
# but valid for standard captcha images.
|
|
273
|
-
is_normalized = all(x <= 1.0 for x in bbox)
|
|
274
|
-
|
|
275
|
-
if is_normalized:
|
|
276
|
-
# Assume [x_min, y_min, x_max, y_max] for normalized
|
|
277
|
-
x1 = bbox[0] * width
|
|
278
|
-
y1 = bbox[1] * height
|
|
279
|
-
x2 = bbox[2] * width
|
|
280
|
-
y2 = bbox[3] * height
|
|
281
|
-
else:
|
|
282
|
-
# Assume [x, y, w, h] for pixels (legacy support)
|
|
283
|
-
x, y, w, h = bbox
|
|
284
|
-
x1, y1, x2, y2 = x, y, x + w, y + h
|
|
285
|
-
|
|
286
|
-
bbox_coords = (x1, y1, x2, y2)
|
|
287
|
-
text = box.get("text")
|
|
288
|
-
number = box.get("number")
|
|
289
|
-
color = box.get("color", "#FF6B6B")
|
|
290
|
-
box_style = box.get("box_style") or box.get("style") or "thin"
|
|
291
|
-
|
|
292
|
-
draw_enhanced_bounding_box(
|
|
293
|
-
draw,
|
|
294
|
-
bbox_coords,
|
|
295
|
-
text=text,
|
|
296
|
-
number=number,
|
|
297
|
-
image_size=img.size,
|
|
298
|
-
color=color,
|
|
299
|
-
label_position=label_position,
|
|
300
|
-
box_style=box_style,
|
|
301
|
-
)
|
|
302
|
-
|
|
303
|
-
img = img.convert("RGB")
|
|
304
|
-
save_path = output_path if output_path else image_path
|
|
305
|
-
img.save(save_path)
|
|
306
|
-
|
|
307
|
-
except Exception as e:
|
|
308
|
-
print(f"Error adding overlays: {e}", file=sys.stderr)
|
|
309
|
-
raise
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
def draw_grid_overlay(draw, image_size, step=0.1):
|
|
313
|
-
"""Draw a 10% grid overlay with labels (more readable)."""
|
|
314
|
-
width, height = image_size
|
|
315
|
-
line_color = "#00FF00" # Bright green
|
|
316
|
-
text_color = line_color
|
|
317
|
-
# Darker background + stronger opacity to keep labels readable on bright images
|
|
318
|
-
bg_color = (0, 0, 0, 230)
|
|
319
|
-
# Make labels more prominent
|
|
320
|
-
font_size = max(14, min(32, int(width * 0.04)))
|
|
321
|
-
font = get_cross_platform_font(font_size)
|
|
322
|
-
# Thicker lines for a more pronounced overlay
|
|
323
|
-
line_width = 3
|
|
324
|
-
# Stronger underlay to keep grid visible over busy backgrounds
|
|
325
|
-
shadow_color = (0, 0, 0, 210)
|
|
326
|
-
label_outline_width = 3
|
|
327
|
-
label_pad = 8
|
|
328
|
-
|
|
329
|
-
# Draw vertical lines and labels
|
|
330
|
-
for i in range(1, int(1 / step)):
|
|
331
|
-
x = int(i * step * width)
|
|
332
|
-
# Add a subtle dark underlay to increase contrast on bright/green backgrounds
|
|
333
|
-
draw.line([(x, 0), (x, height)], fill=shadow_color, width=line_width + 4)
|
|
334
|
-
draw.line([(x, 0), (x, height)], fill=line_color, width=line_width)
|
|
335
|
-
label = f"{int(i * step * 100)}%"
|
|
336
|
-
if font:
|
|
337
|
-
bbox = draw.textbbox((0, 0), label, font=font)
|
|
338
|
-
else:
|
|
339
|
-
bbox = draw.textbbox((0, 0), label)
|
|
340
|
-
w = bbox[2] - bbox[0]
|
|
341
|
-
h = bbox[3] - bbox[1]
|
|
342
|
-
# Top label background
|
|
343
|
-
draw.rectangle(
|
|
344
|
-
[x + 2, 2, x + 2 + w + label_pad, 2 + h + label_pad],
|
|
345
|
-
fill=bg_color,
|
|
346
|
-
outline=line_color,
|
|
347
|
-
width=label_outline_width,
|
|
348
|
-
)
|
|
349
|
-
draw.text(
|
|
350
|
-
(x + 2 + label_pad // 2, 2 + label_pad // 2),
|
|
351
|
-
label,
|
|
352
|
-
fill=text_color,
|
|
353
|
-
font=font,
|
|
354
|
-
stroke_width=2,
|
|
355
|
-
stroke_fill="black",
|
|
356
|
-
)
|
|
357
|
-
|
|
358
|
-
# Draw horizontal lines and labels
|
|
359
|
-
for i in range(1, int(1 / step)):
|
|
360
|
-
y = int(i * step * height)
|
|
361
|
-
draw.line([(0, y), (width, y)], fill=shadow_color, width=line_width + 4)
|
|
362
|
-
draw.line([(0, y), (width, y)], fill=line_color, width=line_width)
|
|
363
|
-
label = f"{int(i * step * 100)}%"
|
|
364
|
-
if font:
|
|
365
|
-
bbox = draw.textbbox((0, 0), label, font=font)
|
|
366
|
-
else:
|
|
367
|
-
bbox = draw.textbbox((0, 0), label)
|
|
368
|
-
w = bbox[2] - bbox[0]
|
|
369
|
-
h = bbox[3] - bbox[1]
|
|
370
|
-
# Left label background
|
|
371
|
-
draw.rectangle(
|
|
372
|
-
[2, y + 2, 2 + w + label_pad, y + 2 + h + label_pad],
|
|
373
|
-
fill=bg_color,
|
|
374
|
-
outline=line_color,
|
|
375
|
-
width=label_outline_width,
|
|
376
|
-
)
|
|
377
|
-
draw.text(
|
|
378
|
-
(2 + label_pad // 2, y + 2 + label_pad // 2),
|
|
379
|
-
label,
|
|
380
|
-
fill=text_color,
|
|
381
|
-
font=font,
|
|
382
|
-
stroke_width=2,
|
|
383
|
-
stroke_fill="black",
|
|
384
|
-
)
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
def draw_edge_rulers(draw, image_size, step=0.1, tick_length=8):
|
|
388
|
-
"""Draw subtle ruler tick marks along image edges at percentage intervals.
|
|
389
|
-
Provides spatial reference without covering captcha content."""
|
|
390
|
-
width, height = image_size
|
|
391
|
-
tick_color = (180, 180, 180, 160) # Semi-transparent gray
|
|
392
|
-
label_color = (150, 150, 150, 200)
|
|
393
|
-
font = get_cross_platform_font(max(9, min(14, int(width * 0.02))))
|
|
394
|
-
|
|
395
|
-
for i in range(1, int(1 / step)):
|
|
396
|
-
pct = i * step
|
|
397
|
-
x = int(pct * width)
|
|
398
|
-
y = int(pct * height)
|
|
399
|
-
|
|
400
|
-
# Top and bottom edge ticks
|
|
401
|
-
draw.line([(x, 0), (x, tick_length)], fill=tick_color, width=1)
|
|
402
|
-
draw.line([(x, height - tick_length), (x, height)], fill=tick_color, width=1)
|
|
403
|
-
# Left and right edge ticks
|
|
404
|
-
draw.line([(0, y), (tick_length, y)], fill=tick_color, width=1)
|
|
405
|
-
draw.line([(width - tick_length, y), (width, y)], fill=tick_color, width=1)
|
|
406
|
-
|
|
407
|
-
# Small percentage labels at top and left edges only
|
|
408
|
-
label = f"{int(pct * 100)}"
|
|
409
|
-
if font:
|
|
410
|
-
draw.text((x + 2, 1), label, fill=label_color, font=font)
|
|
411
|
-
draw.text((1, y + 2), label, fill=label_color, font=font)
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
def add_drag_overlay(
|
|
415
|
-
image_path: str,
|
|
416
|
-
source_bbox: list[float],
|
|
417
|
-
target_bbox: list[float] = None,
|
|
418
|
-
target_center: tuple = None,
|
|
419
|
-
show_grid: bool = False,
|
|
420
|
-
foreground_image: Image.Image = None,
|
|
421
|
-
mask_points: list[list[float]] = None,
|
|
422
|
-
):
|
|
423
|
-
"""
|
|
424
|
-
Add drag-and-drop visualization.
|
|
425
|
-
- THIN GREEN box: The item being dragged at its current location.
|
|
426
|
-
"""
|
|
427
|
-
try:
|
|
428
|
-
# Load image with PIL
|
|
429
|
-
with Image.open(image_path) as img:
|
|
430
|
-
img = img.convert("RGBA")
|
|
431
|
-
|
|
432
|
-
x1, y1, x2, y2 = map(int, source_bbox)
|
|
433
|
-
# Clamp
|
|
434
|
-
w, h = img.size
|
|
435
|
-
x1 = max(0, x1); y1 = max(0, y1)
|
|
436
|
-
x2 = min(w, x2); y2 = min(h, y2)
|
|
437
|
-
|
|
438
|
-
# 1. Prepare Background - use cv2.inpaint for clean source removal
|
|
439
|
-
bg_rgb = np.array(img.convert("RGB"))
|
|
440
|
-
bg_bgr = cv2.cvtColor(bg_rgb, cv2.COLOR_RGB2BGR)
|
|
441
|
-
|
|
442
|
-
if mask_points:
|
|
443
|
-
inpaint_mask = np.zeros(bg_bgr.shape[:2], dtype=np.uint8)
|
|
444
|
-
pts = np.array([(int(p[0] * w), int(p[1] * h)) for p in mask_points], dtype=np.int32)
|
|
445
|
-
cv2.fillPoly(inpaint_mask, [pts], 255)
|
|
446
|
-
else:
|
|
447
|
-
inpaint_mask = np.zeros(bg_bgr.shape[:2], dtype=np.uint8)
|
|
448
|
-
inpaint_mask[y1:y2, x1:x2] = 255
|
|
449
|
-
|
|
450
|
-
kernel = np.ones((5, 5), np.uint8)
|
|
451
|
-
dilated_mask = cv2.dilate(inpaint_mask, kernel, iterations=2)
|
|
452
|
-
bg_inpainted = cv2.inpaint(bg_bgr, dilated_mask, 3, cv2.INPAINT_TELEA)
|
|
453
|
-
bg_inpainted_rgb = cv2.cvtColor(bg_inpainted, cv2.COLOR_BGR2RGB)
|
|
454
|
-
background = Image.fromarray(bg_inpainted_rgb).convert("RGBA")
|
|
455
|
-
|
|
456
|
-
# 2. Dim background slightly
|
|
457
|
-
dim_overlay = Image.new("RGBA", img.size, (0, 0, 0, 40))
|
|
458
|
-
background = Image.alpha_composite(background, dim_overlay)
|
|
459
|
-
|
|
460
|
-
# 3. Prepare Object
|
|
461
|
-
if foreground_image:
|
|
462
|
-
object_crop = foreground_image.resize((x2-x1, y2-y1))
|
|
463
|
-
elif mask_points:
|
|
464
|
-
# Create masked crop using mask_points
|
|
465
|
-
mask = Image.new("L", (w, h), 0)
|
|
466
|
-
draw_mask = ImageDraw.Draw(mask)
|
|
467
|
-
pts = [(p[0] * w, p[1] * h) for p in mask_points]
|
|
468
|
-
draw_mask.polygon(pts, fill=255)
|
|
469
|
-
|
|
470
|
-
object_rgba = img.convert("RGBA")
|
|
471
|
-
object_rgba.putalpha(mask)
|
|
472
|
-
object_crop = object_rgba.crop((x1, y1, x2, y2))
|
|
473
|
-
else:
|
|
474
|
-
object_crop = img.crop((x1, y1, x2, y2))
|
|
475
|
-
|
|
476
|
-
# 4. Paste at Target
|
|
477
|
-
if target_center:
|
|
478
|
-
tx, ty = target_center
|
|
479
|
-
else:
|
|
480
|
-
tx, ty = (x1 + x2) / 2, (y1 + y2) / 2
|
|
481
|
-
|
|
482
|
-
cw, ch = object_crop.size
|
|
483
|
-
paste_x = int(tx - cw / 2)
|
|
484
|
-
paste_y = int(ty - ch / 2)
|
|
485
|
-
|
|
486
|
-
background.alpha_composite(object_crop, (paste_x, paste_y))
|
|
487
|
-
|
|
488
|
-
# 5. Draw Overlays
|
|
489
|
-
draw = ImageDraw.Draw(background)
|
|
490
|
-
if show_grid:
|
|
491
|
-
draw_grid_overlay(draw, background.size, step=0.1)
|
|
492
|
-
|
|
493
|
-
# Subtle edge ruler marks for spatial reference
|
|
494
|
-
draw_edge_rulers(draw, background.size)
|
|
495
|
-
|
|
496
|
-
# THIN GREEN BOX around the item being dragged (no labels, no source box, no arrow)
|
|
497
|
-
draw_enhanced_bounding_box(draw, [paste_x, paste_y, paste_x + cw, paste_y + ch], color="#00FF00", box_style="thin", image_size=background.size)
|
|
498
|
-
|
|
499
|
-
background = background.convert("RGB")
|
|
500
|
-
background.save(image_path)
|
|
501
|
-
|
|
502
|
-
except Exception as e:
|
|
503
|
-
print(f"Error adding drag overlays: {e}", file=sys.stderr)
|
|
504
|
-
raise
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
if __name__ == "__main__":
|
|
508
|
-
parser = argparse.ArgumentParser(description="Add overlays to an image")
|
|
509
|
-
parser.add_argument("image_path", help="Path to the image to modify")
|
|
510
|
-
parser.add_argument("boxes_json", help="JSON string containing list of boxes with 'bbox' and 'text'")
|
|
511
|
-
|
|
512
|
-
args = parser.parse_args()
|
|
513
|
-
|
|
514
|
-
try:
|
|
515
|
-
boxes = json.loads(args.boxes_json)
|
|
516
|
-
add_overlays_to_image(args.image_path, boxes)
|
|
517
|
-
print(json.dumps({"status": "success"}))
|
|
518
|
-
except Exception as e:
|
|
519
|
-
print(json.dumps({"status": "error", "message": str(e)}))
|
|
520
|
-
sys.exit(1)
|
|
60
|
+
bx1, by1 = x1 + 4, y1 + 4
|
|
61
|
+
bx1 = min(max(bx1, 0), img_w - cw)
|
|
62
|
+
by1 = min(max(by1, 0), img_h - ch)
|
|
63
|
+
tx, ty = bx1 + (cw - tw) // 2, by1 + (ch - th) // 2 - tb[1]
|
|
64
|
+
draw.rectangle([bx1, by1, bx1 + cw, by1 + ch], fill=_rgba(color, 200), outline=color, width=2)
|
|
65
|
+
draw.text((tx, ty), label, fill="white", font=font, stroke_width=2, stroke_fill="black")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def add_overlays_to_image(image_path: str, boxes: list, output_path: str = None,
|
|
69
|
+
label_position: LabelPosition = LabelPosition.TOP_LEFT):
|
|
70
|
+
"""Draw each box (normalised [x1,y1,x2,y2] or pixel [x,y,w,h]) with its number/text label and save."""
|
|
71
|
+
label_position = LabelPosition(label_position)
|
|
72
|
+
with Image.open(image_path) as img:
|
|
73
|
+
img = img.convert("RGBA")
|
|
74
|
+
draw = ImageDraw.Draw(img)
|
|
75
|
+
width, height = img.size
|
|
76
|
+
for box in boxes:
|
|
77
|
+
bbox = box["bbox"]
|
|
78
|
+
if all(v <= 1.0 for v in bbox):
|
|
79
|
+
coords = (bbox[0] * width, bbox[1] * height, bbox[2] * width, bbox[3] * height)
|
|
80
|
+
else:
|
|
81
|
+
x, y, w, h = bbox
|
|
82
|
+
coords = (x, y, x + w, y + h)
|
|
83
|
+
label = " ".join(str(v) for v in (box.get("number"), box.get("text")) if v not in (None, ""))
|
|
84
|
+
_draw_box(draw, coords, label, img.size, box.get("color", "#FF6B6B"), label_position,
|
|
85
|
+
box.get("box_style") or box.get("style") or "thin")
|
|
86
|
+
# RGBA cannot be saved as JPEG; the failure would surface a stage later.
|
|
87
|
+
img.convert("RGB").save(output_path or image_path)
|