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,520 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import math
|
|
4
|
+
import sys
|
|
5
|
+
import numpy as np
|
|
6
|
+
import cv2
|
|
7
|
+
from typing import Any, Dict
|
|
8
|
+
|
|
9
|
+
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
|
10
|
+
|
|
11
|
+
# Font cache to prevent repeated font loading
|
|
12
|
+
_FONT_CACHE: Dict[tuple, Any] = {}
|
|
13
|
+
|
|
14
|
+
_FONT_PATHS = [
|
|
15
|
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
|
16
|
+
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
|
17
|
+
"/System/Library/Fonts/Arial.ttf",
|
|
18
|
+
"C:\\Windows\\Fonts\\arial.ttf",
|
|
19
|
+
"arial.ttf",
|
|
20
|
+
"Arial Bold.ttf",
|
|
21
|
+
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_cross_platform_font(font_size: int):
|
|
26
|
+
cache_key = ("system_font", font_size)
|
|
27
|
+
if cache_key in _FONT_CACHE:
|
|
28
|
+
return _FONT_CACHE[cache_key]
|
|
29
|
+
|
|
30
|
+
font = None
|
|
31
|
+
for font_path in _FONT_PATHS:
|
|
32
|
+
try:
|
|
33
|
+
font = ImageFont.truetype(font_path, font_size)
|
|
34
|
+
break
|
|
35
|
+
except OSError:
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
_FONT_CACHE[cache_key] = font
|
|
39
|
+
return font
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def hex_to_rgba(hex_color, alpha=180):
|
|
43
|
+
hex_color = hex_color.lstrip('#')
|
|
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
|
|
48
|
+
return rgb + (alpha,)
|
|
49
|
+
|
|
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
|
+
|
|
68
|
+
if box_style == "solid":
|
|
69
|
+
line_width = 2
|
|
70
|
+
shadow_width = line_width + 2
|
|
71
|
+
# Underlay + main stroke to match the solid green/black grid overlay style
|
|
72
|
+
draw.rectangle([x1, y1, x2, y2], outline=shadow_color, width=shadow_width)
|
|
73
|
+
draw.rectangle([x1, y1, x2, y2], outline=color, width=line_width)
|
|
74
|
+
elif box_style == "thin":
|
|
75
|
+
line_width = 2
|
|
76
|
+
# Simple solid line, no shadow for maximum "thinness"
|
|
77
|
+
draw.rectangle([x1, y1, x2, y2], outline=color, width=line_width)
|
|
78
|
+
else:
|
|
79
|
+
# Dashed box (default)
|
|
80
|
+
dash_length = 10
|
|
81
|
+
gap_length = 5
|
|
82
|
+
line_width = 5
|
|
83
|
+
shadow_width = line_width + 3
|
|
84
|
+
|
|
85
|
+
def draw_dashed_line(start_x, start_y, end_x, end_y):
|
|
86
|
+
# Ensure we always draw from smaller to larger coordinate
|
|
87
|
+
if start_x == end_x: # Vertical
|
|
88
|
+
y_min, y_max = min(start_y, end_y), max(start_y, end_y)
|
|
89
|
+
y = y_min
|
|
90
|
+
while y < y_max:
|
|
91
|
+
dash_end = min(y + dash_length, y_max)
|
|
92
|
+
# Underlay + main stroke makes the dash read clearly on any background.
|
|
93
|
+
draw.line([(start_x, y), (start_x, dash_end)], fill=shadow_color, width=shadow_width)
|
|
94
|
+
draw.line([(start_x, y), (start_x, dash_end)], fill=color, width=line_width)
|
|
95
|
+
y += dash_length + gap_length
|
|
96
|
+
else: # Horizontal
|
|
97
|
+
x_min, x_max = min(start_x, end_x), max(start_x, end_x)
|
|
98
|
+
x = x_min
|
|
99
|
+
while x < x_max:
|
|
100
|
+
dash_end = min(x + dash_length, x_max)
|
|
101
|
+
draw.line([(x, start_y), (dash_end, start_y)], fill=shadow_color, width=shadow_width)
|
|
102
|
+
draw.line([(x, start_y), (dash_end, start_y)], fill=color, width=line_width)
|
|
103
|
+
x += dash_length + gap_length
|
|
104
|
+
|
|
105
|
+
draw_dashed_line(x1, y1, x2, y1)
|
|
106
|
+
draw_dashed_line(x2, y1, x2, y2)
|
|
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)
|