@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.
@@ -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
+