@createlex/createlexgenai 1.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/README.md +272 -0
- package/bin/createlex.js +5 -0
- package/package.json +45 -0
- package/python/activity_tracker.py +280 -0
- package/python/fastmcp.py +768 -0
- package/python/mcp_server_stdio.py +4720 -0
- package/python/requirements.txt +7 -0
- package/python/subscription_validator.py +199 -0
- package/python/ue_native_handler.py +573 -0
- package/python/ui_slice_host.py +637 -0
- package/src/cli.js +109 -0
- package/src/commands/config.js +56 -0
- package/src/commands/connect.js +100 -0
- package/src/commands/exec.js +148 -0
- package/src/commands/login.js +111 -0
- package/src/commands/logout.js +17 -0
- package/src/commands/serve.js +237 -0
- package/src/commands/setup.js +65 -0
- package/src/commands/status.js +126 -0
- package/src/commands/tools.js +133 -0
- package/src/core/auth-manager.js +147 -0
- package/src/core/config-store.js +81 -0
- package/src/core/discovery.js +71 -0
- package/src/core/ide-configurator.js +189 -0
- package/src/core/remote-execution.js +228 -0
- package/src/core/subscription.js +176 -0
- package/src/core/unreal-connection.js +318 -0
- package/src/core/web-remote-control.js +243 -0
- package/src/utils/logger.js +66 -0
- package/src/utils/python-manager.js +142 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
import tempfile
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from collections import deque
|
|
8
|
+
from typing import Any, Dict, List, Tuple
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from PIL import Image, ImageChops, ImageFilter
|
|
12
|
+
|
|
13
|
+
_HAS_PILLOW = True
|
|
14
|
+
_PILLOW_ERROR = ""
|
|
15
|
+
except Exception as pillow_error:
|
|
16
|
+
Image = None
|
|
17
|
+
ImageChops = None
|
|
18
|
+
ImageFilter = None
|
|
19
|
+
_HAS_PILLOW = False
|
|
20
|
+
_PILLOW_ERROR = str(pillow_error)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def has_pillow() -> Tuple[bool, str]:
|
|
24
|
+
return _HAS_PILLOW, _PILLOW_ERROR
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _sanitize_asset_name(name: str) -> str:
|
|
28
|
+
sanitized = re.sub(r"[^A-Za-z0-9_]+", "_", (name or "").strip())
|
|
29
|
+
sanitized = re.sub(r"_+", "_", sanitized).strip("_")
|
|
30
|
+
if not sanitized:
|
|
31
|
+
sanitized = f"UI_{uuid.uuid4().hex[:8]}"
|
|
32
|
+
if sanitized[0].isdigit():
|
|
33
|
+
sanitized = f"UI_{sanitized}"
|
|
34
|
+
return sanitized[:64]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _estimate_background_palette(rgb_image, sample_stride: int = 8, max_colors: int = 4):
|
|
38
|
+
width, height = rgb_image.size
|
|
39
|
+
pixels = rgb_image.load()
|
|
40
|
+
sampled_bins: Dict[tuple, int] = {}
|
|
41
|
+
|
|
42
|
+
def add_sample(x: int, y: int):
|
|
43
|
+
r, g, b = pixels[x, y]
|
|
44
|
+
key = (r // 16, g // 16, b // 16)
|
|
45
|
+
sampled_bins[key] = sampled_bins.get(key, 0) + 1
|
|
46
|
+
|
|
47
|
+
for x in range(0, width, sample_stride):
|
|
48
|
+
add_sample(x, 0)
|
|
49
|
+
add_sample(x, height - 1)
|
|
50
|
+
for y in range(0, height, sample_stride):
|
|
51
|
+
add_sample(0, y)
|
|
52
|
+
add_sample(width - 1, y)
|
|
53
|
+
|
|
54
|
+
if not sampled_bins:
|
|
55
|
+
return [(0, 0, 0)]
|
|
56
|
+
|
|
57
|
+
sorted_bins = sorted(sampled_bins.items(), key=lambda kv: kv[1], reverse=True)[:max_colors]
|
|
58
|
+
palette = []
|
|
59
|
+
for (rb, gb, bb), _ in sorted_bins:
|
|
60
|
+
palette.append((rb * 16 + 8, gb * 16 + 8, bb * 16 + 8))
|
|
61
|
+
return palette
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _build_foreground_mask(rgba_image, alpha_threshold: int, background_tolerance: int):
|
|
65
|
+
width, height = rgba_image.size
|
|
66
|
+
alpha_channel = rgba_image.split()[3]
|
|
67
|
+
alpha_data = alpha_channel.tobytes()
|
|
68
|
+
non_opaque_alpha = sum(1 for value in alpha_data if value < 250)
|
|
69
|
+
has_meaningful_alpha = non_opaque_alpha > max(32, (width * height) // 1000)
|
|
70
|
+
|
|
71
|
+
if has_meaningful_alpha:
|
|
72
|
+
alpha_mask = alpha_channel.point(lambda px: 255 if px > alpha_threshold else 0)
|
|
73
|
+
return alpha_mask, "alpha"
|
|
74
|
+
|
|
75
|
+
rgb = rgba_image.convert("RGB")
|
|
76
|
+
palette = _estimate_background_palette(rgb)
|
|
77
|
+
mask = Image.new("L", (width, height), 0)
|
|
78
|
+
rgb_pixels = rgb.load()
|
|
79
|
+
mask_pixels = mask.load()
|
|
80
|
+
|
|
81
|
+
for y in range(height):
|
|
82
|
+
for x in range(width):
|
|
83
|
+
r, g, b = rgb_pixels[x, y]
|
|
84
|
+
color_distance = min(abs(r - pr) + abs(g - pg) + abs(b - pb) for pr, pg, pb in palette)
|
|
85
|
+
if color_distance > background_tolerance:
|
|
86
|
+
mask_pixels[x, y] = 255
|
|
87
|
+
|
|
88
|
+
return mask, "color"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _threshold_mask(mask_image, threshold: int = 127):
|
|
92
|
+
return mask_image.point(lambda px: 255 if px > threshold else 0)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _expand_bbox(bbox, padding: int, width: int, height: int):
|
|
96
|
+
min_x, min_y, max_x, max_y = bbox
|
|
97
|
+
return [
|
|
98
|
+
max(0, int(min_x) - padding),
|
|
99
|
+
max(0, int(min_y) - padding),
|
|
100
|
+
min(width - 1, int(max_x) + padding),
|
|
101
|
+
min(height - 1, int(max_y) + padding),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _bbox_iou(bbox_a, bbox_b):
|
|
106
|
+
ax0, ay0, ax1, ay1 = bbox_a
|
|
107
|
+
bx0, by0, bx1, by1 = bbox_b
|
|
108
|
+
inter_x0 = max(ax0, bx0)
|
|
109
|
+
inter_y0 = max(ay0, by0)
|
|
110
|
+
inter_x1 = min(ax1, bx1)
|
|
111
|
+
inter_y1 = min(ay1, by1)
|
|
112
|
+
if inter_x1 < inter_x0 or inter_y1 < inter_y0:
|
|
113
|
+
return 0.0
|
|
114
|
+
inter_area = (inter_x1 - inter_x0 + 1) * (inter_y1 - inter_y0 + 1)
|
|
115
|
+
area_a = (ax1 - ax0 + 1) * (ay1 - ay0 + 1)
|
|
116
|
+
area_b = (bx1 - bx0 + 1) * (by1 - by0 + 1)
|
|
117
|
+
union_area = max(1, area_a + area_b - inter_area)
|
|
118
|
+
return inter_area / float(union_area)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _extract_active_bands(active_flags: List[bool], min_len: int):
|
|
122
|
+
bands = []
|
|
123
|
+
start_idx = -1
|
|
124
|
+
for idx, active in enumerate(active_flags):
|
|
125
|
+
if active and start_idx < 0:
|
|
126
|
+
start_idx = idx
|
|
127
|
+
elif not active and start_idx >= 0:
|
|
128
|
+
end_idx = idx - 1
|
|
129
|
+
if end_idx - start_idx + 1 >= min_len:
|
|
130
|
+
bands.append((start_idx, end_idx))
|
|
131
|
+
start_idx = -1
|
|
132
|
+
|
|
133
|
+
if start_idx >= 0:
|
|
134
|
+
end_idx = len(active_flags) - 1
|
|
135
|
+
if end_idx - start_idx + 1 >= min_len:
|
|
136
|
+
bands.append((start_idx, end_idx))
|
|
137
|
+
return bands
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _tighten_component_from_bbox(mask_image, bbox, minimum_area: int = 1):
|
|
141
|
+
width, height = mask_image.size
|
|
142
|
+
min_x, min_y, max_x, max_y = bbox
|
|
143
|
+
min_x = max(0, min(width - 1, int(min_x)))
|
|
144
|
+
min_y = max(0, min(height - 1, int(min_y)))
|
|
145
|
+
max_x = max(0, min(width - 1, int(max_x)))
|
|
146
|
+
max_y = max(0, min(height - 1, int(max_y)))
|
|
147
|
+
if max_x < min_x or max_y < min_y:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
mask_data = mask_image.tobytes()
|
|
151
|
+
area = 0
|
|
152
|
+
tight_min_x = max_x
|
|
153
|
+
tight_min_y = max_y
|
|
154
|
+
tight_max_x = min_x
|
|
155
|
+
tight_max_y = min_y
|
|
156
|
+
|
|
157
|
+
for y in range(min_y, max_y + 1):
|
|
158
|
+
row_offset = y * width
|
|
159
|
+
for x in range(min_x, max_x + 1):
|
|
160
|
+
if mask_data[row_offset + x] == 0:
|
|
161
|
+
continue
|
|
162
|
+
area += 1
|
|
163
|
+
if x < tight_min_x:
|
|
164
|
+
tight_min_x = x
|
|
165
|
+
if x > tight_max_x:
|
|
166
|
+
tight_max_x = x
|
|
167
|
+
if y < tight_min_y:
|
|
168
|
+
tight_min_y = y
|
|
169
|
+
if y > tight_max_y:
|
|
170
|
+
tight_max_y = y
|
|
171
|
+
|
|
172
|
+
if area < minimum_area or tight_max_x < tight_min_x or tight_max_y < tight_min_y:
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
bbox_width = tight_max_x - tight_min_x + 1
|
|
176
|
+
bbox_height = tight_max_y - tight_min_y + 1
|
|
177
|
+
return {
|
|
178
|
+
"bbox": [tight_min_x, tight_min_y, tight_max_x, tight_max_y],
|
|
179
|
+
"area": area,
|
|
180
|
+
"bbox_width": bbox_width,
|
|
181
|
+
"bbox_height": bbox_height,
|
|
182
|
+
"center_x": (tight_min_x + tight_max_x) / 2.0,
|
|
183
|
+
"center_y": (tight_min_y + tight_max_y) / 2.0,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _dedupe_components(components: List[Dict[str, Any]], max_components: int):
|
|
188
|
+
unique_components: List[Dict[str, Any]] = []
|
|
189
|
+
for component in sorted(components, key=lambda item: item.get("area", 0), reverse=True):
|
|
190
|
+
bbox = component.get("bbox")
|
|
191
|
+
if not bbox:
|
|
192
|
+
continue
|
|
193
|
+
duplicate = False
|
|
194
|
+
for existing in unique_components:
|
|
195
|
+
if _bbox_iou(bbox, existing.get("bbox")) > 0.88:
|
|
196
|
+
duplicate = True
|
|
197
|
+
break
|
|
198
|
+
if duplicate:
|
|
199
|
+
continue
|
|
200
|
+
unique_components.append(component)
|
|
201
|
+
if len(unique_components) >= max_components:
|
|
202
|
+
break
|
|
203
|
+
return unique_components
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _extract_projection_components(mask_image, min_area: int, max_components: int, min_row_fill: float, min_col_fill: float):
|
|
207
|
+
width, height = mask_image.size
|
|
208
|
+
mask_data = mask_image.tobytes()
|
|
209
|
+
row_counts = [0] * height
|
|
210
|
+
for y in range(height):
|
|
211
|
+
row_offset = y * width
|
|
212
|
+
row_counts[y] = sum(1 for x in range(width) if mask_data[row_offset + x] != 0)
|
|
213
|
+
|
|
214
|
+
min_row_pixels = max(2, int(width * min_row_fill))
|
|
215
|
+
row_active = [count >= min_row_pixels for count in row_counts]
|
|
216
|
+
row_bands = _extract_active_bands(row_active, min_len=max(4, height // 180))
|
|
217
|
+
|
|
218
|
+
components: List[Dict[str, Any]] = []
|
|
219
|
+
for row_start, row_end in row_bands:
|
|
220
|
+
band_height = row_end - row_start + 1
|
|
221
|
+
col_counts = [0] * width
|
|
222
|
+
for y in range(row_start, row_end + 1):
|
|
223
|
+
row_offset = y * width
|
|
224
|
+
for x in range(width):
|
|
225
|
+
if mask_data[row_offset + x] != 0:
|
|
226
|
+
col_counts[x] += 1
|
|
227
|
+
|
|
228
|
+
min_col_pixels = max(2, int(band_height * min_col_fill))
|
|
229
|
+
col_active = [count >= min_col_pixels for count in col_counts]
|
|
230
|
+
col_bands = _extract_active_bands(col_active, min_len=max(4, width // 260))
|
|
231
|
+
if not col_bands:
|
|
232
|
+
col_bands = [(0, width - 1)]
|
|
233
|
+
|
|
234
|
+
for col_start, col_end in col_bands:
|
|
235
|
+
tightened = _tighten_component_from_bbox(
|
|
236
|
+
mask_image,
|
|
237
|
+
[col_start, row_start, col_end, row_end],
|
|
238
|
+
minimum_area=min_area,
|
|
239
|
+
)
|
|
240
|
+
if tightened:
|
|
241
|
+
components.append(tightened)
|
|
242
|
+
|
|
243
|
+
components.sort(key=lambda item: item["area"], reverse=True)
|
|
244
|
+
return components[:max_components]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _extract_connected_components(mask_image, min_area: int, max_components: int):
|
|
248
|
+
width, height = mask_image.size
|
|
249
|
+
mask_data = mask_image.tobytes()
|
|
250
|
+
visited = bytearray(width * height)
|
|
251
|
+
components: List[Dict[str, Any]] = []
|
|
252
|
+
|
|
253
|
+
for y in range(height):
|
|
254
|
+
for x in range(width):
|
|
255
|
+
idx = y * width + x
|
|
256
|
+
if visited[idx] or mask_data[idx] == 0:
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
queue = deque([idx])
|
|
260
|
+
visited[idx] = 1
|
|
261
|
+
min_x = max_x = x
|
|
262
|
+
min_y = max_y = y
|
|
263
|
+
area = 0
|
|
264
|
+
|
|
265
|
+
while queue:
|
|
266
|
+
current_idx = queue.popleft()
|
|
267
|
+
cx = current_idx % width
|
|
268
|
+
cy = current_idx // width
|
|
269
|
+
area += 1
|
|
270
|
+
|
|
271
|
+
if cx < min_x:
|
|
272
|
+
min_x = cx
|
|
273
|
+
if cx > max_x:
|
|
274
|
+
max_x = cx
|
|
275
|
+
if cy < min_y:
|
|
276
|
+
min_y = cy
|
|
277
|
+
if cy > max_y:
|
|
278
|
+
max_y = cy
|
|
279
|
+
|
|
280
|
+
if cx > 0:
|
|
281
|
+
left_idx = current_idx - 1
|
|
282
|
+
if not visited[left_idx] and mask_data[left_idx] != 0:
|
|
283
|
+
visited[left_idx] = 1
|
|
284
|
+
queue.append(left_idx)
|
|
285
|
+
if cx < width - 1:
|
|
286
|
+
right_idx = current_idx + 1
|
|
287
|
+
if not visited[right_idx] and mask_data[right_idx] != 0:
|
|
288
|
+
visited[right_idx] = 1
|
|
289
|
+
queue.append(right_idx)
|
|
290
|
+
if cy > 0:
|
|
291
|
+
up_idx = current_idx - width
|
|
292
|
+
if not visited[up_idx] and mask_data[up_idx] != 0:
|
|
293
|
+
visited[up_idx] = 1
|
|
294
|
+
queue.append(up_idx)
|
|
295
|
+
if cy < height - 1:
|
|
296
|
+
down_idx = current_idx + width
|
|
297
|
+
if not visited[down_idx] and mask_data[down_idx] != 0:
|
|
298
|
+
visited[down_idx] = 1
|
|
299
|
+
queue.append(down_idx)
|
|
300
|
+
|
|
301
|
+
if area < min_area:
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
bbox_width = max_x - min_x + 1
|
|
305
|
+
bbox_height = max_y - min_y + 1
|
|
306
|
+
components.append(
|
|
307
|
+
{
|
|
308
|
+
"bbox": [min_x, min_y, max_x, max_y],
|
|
309
|
+
"area": area,
|
|
310
|
+
"bbox_width": bbox_width,
|
|
311
|
+
"bbox_height": bbox_height,
|
|
312
|
+
"center_x": (min_x + max_x) / 2.0,
|
|
313
|
+
"center_y": (min_y + max_y) / 2.0,
|
|
314
|
+
}
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
components.sort(key=lambda item: item["area"], reverse=True)
|
|
318
|
+
return components[:max_components]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _extract_aggressive_components(mask_image, min_area: int, max_components: int):
|
|
322
|
+
width, height = mask_image.size
|
|
323
|
+
image_area = max(1, width * height)
|
|
324
|
+
relaxed_min_area = max(24, int(min_area * 0.20))
|
|
325
|
+
candidate_components: List[Dict[str, Any]] = []
|
|
326
|
+
|
|
327
|
+
erosion_settings = [(3, 1), (3, 2), (5, 1), (5, 2), (7, 1)]
|
|
328
|
+
for kernel_size, iterations in erosion_settings:
|
|
329
|
+
if ImageFilter is None:
|
|
330
|
+
break
|
|
331
|
+
|
|
332
|
+
eroded_mask = mask_image
|
|
333
|
+
for _ in range(iterations):
|
|
334
|
+
eroded_mask = eroded_mask.filter(ImageFilter.MinFilter(kernel_size))
|
|
335
|
+
eroded_mask = _threshold_mask(eroded_mask, threshold=127)
|
|
336
|
+
|
|
337
|
+
connected = _extract_connected_components(eroded_mask, min_area=relaxed_min_area, max_components=max_components * 6)
|
|
338
|
+
projected = _extract_projection_components(
|
|
339
|
+
eroded_mask,
|
|
340
|
+
min_area=relaxed_min_area,
|
|
341
|
+
max_components=max_components * 6,
|
|
342
|
+
min_row_fill=0.008,
|
|
343
|
+
min_col_fill=0.012,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
expand_padding = kernel_size * iterations + 3
|
|
347
|
+
for component in connected + projected:
|
|
348
|
+
expanded_bbox = _expand_bbox(component["bbox"], expand_padding, width, height)
|
|
349
|
+
tightened = _tighten_component_from_bbox(mask_image, expanded_bbox, minimum_area=max(24, int(min_area * 0.25)))
|
|
350
|
+
if tightened:
|
|
351
|
+
candidate_components.append(tightened)
|
|
352
|
+
|
|
353
|
+
direct_projection = _extract_projection_components(
|
|
354
|
+
mask_image,
|
|
355
|
+
min_area=max(24, int(min_area * 0.25)),
|
|
356
|
+
max_components=max_components * 6,
|
|
357
|
+
min_row_fill=0.008,
|
|
358
|
+
min_col_fill=0.012,
|
|
359
|
+
)
|
|
360
|
+
candidate_components.extend(direct_projection)
|
|
361
|
+
|
|
362
|
+
deduped = _dedupe_components(candidate_components, max_components=max_components * 6)
|
|
363
|
+
non_dominant = [component for component in deduped if component.get("area", 0) / float(image_area) < 0.90]
|
|
364
|
+
if len(non_dominant) >= 2:
|
|
365
|
+
deduped = non_dominant
|
|
366
|
+
deduped.sort(key=lambda item: item.get("area", 0), reverse=True)
|
|
367
|
+
return deduped[:max_components]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _extract_components_for_mockup(mask_image, min_area: int, max_components: int, slice_mode: str):
|
|
371
|
+
connected_components = _extract_connected_components(mask_image, min_area=min_area, max_components=max_components)
|
|
372
|
+
metrics = {"connected_component_count": len(connected_components)}
|
|
373
|
+
if slice_mode == "connected":
|
|
374
|
+
return connected_components, "connected", metrics
|
|
375
|
+
|
|
376
|
+
should_try_aggressive = slice_mode == "aggressive" or len(connected_components) <= 1
|
|
377
|
+
if should_try_aggressive:
|
|
378
|
+
aggressive_components = _extract_aggressive_components(mask_image, min_area=min_area, max_components=max_components)
|
|
379
|
+
metrics["aggressive_component_count"] = len(aggressive_components)
|
|
380
|
+
if aggressive_components and (slice_mode == "aggressive" or len(aggressive_components) > len(connected_components)):
|
|
381
|
+
return aggressive_components, "aggressive", metrics
|
|
382
|
+
|
|
383
|
+
return connected_components, "connected", metrics
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _classify_component(component: Dict[str, Any], index: int, image_width: int, image_height: int, button_index: int):
|
|
387
|
+
width_ratio = component["bbox_width"] / float(max(1, image_width))
|
|
388
|
+
height_ratio = component["bbox_height"] / float(max(1, image_height))
|
|
389
|
+
area_ratio = component["area"] / float(max(1, image_width * image_height))
|
|
390
|
+
center_x_ratio = component["center_x"] / float(max(1, image_width))
|
|
391
|
+
center_y_ratio = component["center_y"] / float(max(1, image_height))
|
|
392
|
+
|
|
393
|
+
kind = f"slice_{index + 1:02d}"
|
|
394
|
+
label = kind
|
|
395
|
+
if index == 0 and area_ratio > 0.20:
|
|
396
|
+
kind = "panel_main"
|
|
397
|
+
label = kind
|
|
398
|
+
elif width_ratio > 0.16 and 0.03 < height_ratio < 0.26 and center_y_ratio > 0.50 and area_ratio < 0.18:
|
|
399
|
+
kind = "button_primary" if button_index == 0 else "button_secondary"
|
|
400
|
+
label = kind
|
|
401
|
+
elif center_x_ratio < 0.35 and center_y_ratio > 0.35:
|
|
402
|
+
kind = "icon_left"
|
|
403
|
+
label = kind
|
|
404
|
+
elif center_x_ratio > 0.65 and center_y_ratio > 0.35:
|
|
405
|
+
kind = "icon_right"
|
|
406
|
+
label = kind
|
|
407
|
+
elif width_ratio > 0.30 and height_ratio < 0.20 and center_y_ratio < 0.35:
|
|
408
|
+
kind = "header"
|
|
409
|
+
label = kind
|
|
410
|
+
|
|
411
|
+
return kind, label
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _assign_component_alpha(source_crop, mask_crop):
|
|
415
|
+
if source_crop.mode != "RGBA":
|
|
416
|
+
source_crop = source_crop.convert("RGBA")
|
|
417
|
+
_, _, _, source_alpha = source_crop.split()
|
|
418
|
+
composed_alpha = ImageChops.multiply(source_alpha, mask_crop)
|
|
419
|
+
source_crop.putalpha(composed_alpha)
|
|
420
|
+
return source_crop
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _apply_corner_chroma_key(rgba_image, tolerance: int = 24):
|
|
424
|
+
if rgba_image.mode != "RGBA":
|
|
425
|
+
rgba_image = rgba_image.convert("RGBA")
|
|
426
|
+
|
|
427
|
+
width, height = rgba_image.size
|
|
428
|
+
if width < 4 or height < 4:
|
|
429
|
+
return rgba_image
|
|
430
|
+
|
|
431
|
+
corner_samples = []
|
|
432
|
+
sample_points = [
|
|
433
|
+
(0, 0), (1, 1), (width - 1, 0), (width - 2, 1),
|
|
434
|
+
(0, height - 1), (1, height - 2), (width - 1, height - 1), (width - 2, height - 2),
|
|
435
|
+
]
|
|
436
|
+
for x, y in sample_points:
|
|
437
|
+
r, g, b, a = rgba_image.getpixel((max(0, min(width - 1, x)), max(0, min(height - 1, y))))
|
|
438
|
+
if a > 10:
|
|
439
|
+
corner_samples.append((r, g, b))
|
|
440
|
+
|
|
441
|
+
if len(corner_samples) < 4:
|
|
442
|
+
return rgba_image
|
|
443
|
+
|
|
444
|
+
avg_r = sum(color[0] for color in corner_samples) / float(len(corner_samples))
|
|
445
|
+
avg_g = sum(color[1] for color in corner_samples) / float(len(corner_samples))
|
|
446
|
+
avg_b = sum(color[2] for color in corner_samples) / float(len(corner_samples))
|
|
447
|
+
brightness = (avg_r + avg_g + avg_b) / 3.0
|
|
448
|
+
color_spread = max(
|
|
449
|
+
max(abs(color[0] - avg_r), abs(color[1] - avg_g), abs(color[2] - avg_b))
|
|
450
|
+
for color in corner_samples
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
# Only key very-flat bright corners to avoid damaging intended artwork.
|
|
454
|
+
if brightness < 165.0 or color_spread > max(12.0, float(tolerance)):
|
|
455
|
+
return rgba_image
|
|
456
|
+
|
|
457
|
+
keyed = rgba_image.copy()
|
|
458
|
+
pixels = keyed.load()
|
|
459
|
+
for y in range(height):
|
|
460
|
+
for x in range(width):
|
|
461
|
+
r, g, b, a = pixels[x, y]
|
|
462
|
+
if a <= 0:
|
|
463
|
+
continue
|
|
464
|
+
if (
|
|
465
|
+
abs(r - avg_r) <= tolerance and
|
|
466
|
+
abs(g - avg_g) <= tolerance and
|
|
467
|
+
abs(b - avg_b) <= tolerance
|
|
468
|
+
):
|
|
469
|
+
pixels[x, y] = (r, g, b, 0)
|
|
470
|
+
|
|
471
|
+
return keyed
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def auto_slice_ui_mockup_host(
|
|
475
|
+
source_image_path: str,
|
|
476
|
+
destination_folder: str = "/Game/UI/AutoSlices",
|
|
477
|
+
base_name: str = "UI_Mockup",
|
|
478
|
+
max_slices: int = 16,
|
|
479
|
+
min_component_area_ratio: float = 0.0025,
|
|
480
|
+
alpha_threshold: int = 8,
|
|
481
|
+
background_tolerance: int = 28,
|
|
482
|
+
include_full_image: bool = True,
|
|
483
|
+
margin_px: int = 3,
|
|
484
|
+
slice_mode: str = "auto",
|
|
485
|
+
enable_chroma_key: bool = True,
|
|
486
|
+
chroma_tolerance: int = 24,
|
|
487
|
+
) -> Dict[str, Any]:
|
|
488
|
+
if not _HAS_PILLOW:
|
|
489
|
+
return {"success": False, "error": f"Pillow is not available in MCP host runtime: {_PILLOW_ERROR}"}
|
|
490
|
+
|
|
491
|
+
if not source_image_path:
|
|
492
|
+
return {"success": False, "error": "Missing required argument: source_image_path"}
|
|
493
|
+
if not os.path.isfile(source_image_path):
|
|
494
|
+
return {"success": False, "error": f"source_image_path does not exist: {source_image_path}"}
|
|
495
|
+
if max_slices < 1:
|
|
496
|
+
return {"success": False, "error": "max_slices must be >= 1"}
|
|
497
|
+
if min_component_area_ratio <= 0 or min_component_area_ratio >= 1:
|
|
498
|
+
return {"success": False, "error": "min_component_area_ratio must be between 0 and 1"}
|
|
499
|
+
if slice_mode not in {"auto", "connected", "aggressive"}:
|
|
500
|
+
return {"success": False, "error": "slice_mode must be one of: auto, connected, aggressive"}
|
|
501
|
+
if chroma_tolerance < 0:
|
|
502
|
+
return {"success": False, "error": "chroma_tolerance must be >= 0"}
|
|
503
|
+
|
|
504
|
+
temp_dir = tempfile.mkdtemp(prefix="createlex_ui_host_slices_")
|
|
505
|
+
cleanup_on_fail = True
|
|
506
|
+
try:
|
|
507
|
+
with Image.open(source_image_path) as source_image:
|
|
508
|
+
rgba_image = source_image.convert("RGBA")
|
|
509
|
+
image_width, image_height = rgba_image.size
|
|
510
|
+
min_area = max(64, int(image_width * image_height * min_component_area_ratio))
|
|
511
|
+
|
|
512
|
+
foreground_mask, mask_mode = _build_foreground_mask(
|
|
513
|
+
rgba_image, alpha_threshold=alpha_threshold, background_tolerance=background_tolerance
|
|
514
|
+
)
|
|
515
|
+
components, segmentation_mode, segmentation_metrics = _extract_components_for_mockup(
|
|
516
|
+
foreground_mask,
|
|
517
|
+
min_area=min_area,
|
|
518
|
+
max_components=max_slices,
|
|
519
|
+
slice_mode=slice_mode,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
if not components:
|
|
523
|
+
segmentation_mode = "fallback_full_image"
|
|
524
|
+
segmentation_metrics = {"connected_component_count": 0}
|
|
525
|
+
components = [
|
|
526
|
+
{
|
|
527
|
+
"bbox": [0, 0, image_width - 1, image_height - 1],
|
|
528
|
+
"area": image_width * image_height,
|
|
529
|
+
"bbox_width": image_width,
|
|
530
|
+
"bbox_height": image_height,
|
|
531
|
+
"center_x": image_width / 2.0,
|
|
532
|
+
"center_y": image_height / 2.0,
|
|
533
|
+
}
|
|
534
|
+
]
|
|
535
|
+
|
|
536
|
+
sanitized_base = _sanitize_asset_name(base_name or os.path.splitext(os.path.basename(source_image_path))[0])
|
|
537
|
+
slice_records: List[Dict[str, Any]] = []
|
|
538
|
+
|
|
539
|
+
if include_full_image:
|
|
540
|
+
full_name = _sanitize_asset_name(f"{sanitized_base}_full_mockup")
|
|
541
|
+
full_path = os.path.join(temp_dir, f"{full_name}.png")
|
|
542
|
+
rgba_image.save(full_path, "PNG")
|
|
543
|
+
slice_records.append(
|
|
544
|
+
{
|
|
545
|
+
"name": full_name,
|
|
546
|
+
"label": "full_mockup",
|
|
547
|
+
"kind": "full_mockup",
|
|
548
|
+
"asset_path": "",
|
|
549
|
+
"file_path": full_path,
|
|
550
|
+
"bbox": [0, 0, image_width - 1, image_height - 1],
|
|
551
|
+
"area": image_width * image_height,
|
|
552
|
+
"width": image_width,
|
|
553
|
+
"height": image_height,
|
|
554
|
+
"is_fallback": True,
|
|
555
|
+
}
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
button_index = 0
|
|
559
|
+
for idx, component in enumerate(components):
|
|
560
|
+
min_x, min_y, max_x, max_y = component["bbox"]
|
|
561
|
+
min_x = max(0, min_x - margin_px)
|
|
562
|
+
min_y = max(0, min_y - margin_px)
|
|
563
|
+
max_x = min(image_width - 1, max_x + margin_px)
|
|
564
|
+
max_y = min(image_height - 1, max_y + margin_px)
|
|
565
|
+
crop_box = (min_x, min_y, max_x + 1, max_y + 1)
|
|
566
|
+
|
|
567
|
+
kind, label = _classify_component(component, idx, image_width, image_height, button_index)
|
|
568
|
+
if "button_" in kind:
|
|
569
|
+
button_index += 1
|
|
570
|
+
|
|
571
|
+
slice_name = _sanitize_asset_name(f"{sanitized_base}_{label}")
|
|
572
|
+
slice_file = os.path.join(temp_dir, f"{slice_name}.png")
|
|
573
|
+
|
|
574
|
+
source_crop = rgba_image.crop(crop_box)
|
|
575
|
+
mask_crop = foreground_mask.crop(crop_box)
|
|
576
|
+
final_crop = _assign_component_alpha(source_crop, mask_crop)
|
|
577
|
+
if enable_chroma_key:
|
|
578
|
+
final_crop = _apply_corner_chroma_key(final_crop, tolerance=chroma_tolerance)
|
|
579
|
+
final_bbox = final_crop.getbbox()
|
|
580
|
+
if final_bbox:
|
|
581
|
+
final_crop = final_crop.crop(final_bbox)
|
|
582
|
+
if final_crop.size[0] < 2 or final_crop.size[1] < 2:
|
|
583
|
+
continue
|
|
584
|
+
|
|
585
|
+
final_crop.save(slice_file, "PNG")
|
|
586
|
+
slice_records.append(
|
|
587
|
+
{
|
|
588
|
+
"name": slice_name,
|
|
589
|
+
"label": label,
|
|
590
|
+
"kind": kind,
|
|
591
|
+
"asset_path": "",
|
|
592
|
+
"file_path": slice_file,
|
|
593
|
+
"bbox": [min_x, min_y, max_x, max_y],
|
|
594
|
+
"area": component["area"],
|
|
595
|
+
"width": final_crop.size[0],
|
|
596
|
+
"height": final_crop.size[1],
|
|
597
|
+
"is_fallback": False,
|
|
598
|
+
}
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
if not slice_records:
|
|
602
|
+
return {"success": False, "error": "Host auto-slicing produced no slice images."}
|
|
603
|
+
|
|
604
|
+
manifest = {
|
|
605
|
+
"source_image_path": source_image_path,
|
|
606
|
+
"source_width": image_width,
|
|
607
|
+
"source_height": image_height,
|
|
608
|
+
"destination_folder": destination_folder,
|
|
609
|
+
"base_name": sanitized_base,
|
|
610
|
+
"generated_at": time.time(),
|
|
611
|
+
"mask_mode": mask_mode,
|
|
612
|
+
"slice_mode": slice_mode,
|
|
613
|
+
"segmentation_mode": segmentation_mode,
|
|
614
|
+
"segmentation_metrics": segmentation_metrics,
|
|
615
|
+
"enable_chroma_key": bool(enable_chroma_key),
|
|
616
|
+
"chroma_tolerance": int(chroma_tolerance),
|
|
617
|
+
"slices": slice_records,
|
|
618
|
+
"background_asset_path": "",
|
|
619
|
+
"host_side_slicing": True,
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
cleanup_on_fail = False
|
|
623
|
+
return {
|
|
624
|
+
"success": True,
|
|
625
|
+
"message": f"Generated {len(slice_records)} host-side slice files.",
|
|
626
|
+
"slice_count": len(slice_records),
|
|
627
|
+
"segmentation_mode": segmentation_mode,
|
|
628
|
+
"segmentation_metrics": segmentation_metrics,
|
|
629
|
+
"manifest": manifest,
|
|
630
|
+
"temp_dir": temp_dir,
|
|
631
|
+
}
|
|
632
|
+
except Exception as error:
|
|
633
|
+
return {"success": False, "error": f"Host auto-slice failure: {error}"}
|
|
634
|
+
finally:
|
|
635
|
+
if cleanup_on_fail and os.path.isdir(temp_dir):
|
|
636
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
637
|
+
|