import argparse import json from collections import deque from pathlib import Path from PIL import Image UI_ORDER = ["back", "settings", "tile", "remove", "match", "shuffle"] VIEW_COUNT = 5 ITEM_COUNT = 20 GRID_SIZE = 10 def green_screen_score(pixel): red, green, blue, alpha = pixel if alpha == 0: return 1.0 red = float(red) green = float(green) blue = float(blue) green_lead = green - max(red, blue) if green < 96.0 or green_lead <= 18.0: return 0.0 green_ratio = green / max(red + blue, 1.0) if green_ratio <= 0.9: return 0.0 return max( 0.0, min( 1.0, ((green - 96.0) / 128.0) * 0.34 + ((green_lead - 18.0) / 120.0) * 0.46 + ((green_ratio - 0.9) / 2.4) * 0.20, ), ) def white_screen_score(pixel): red, green, blue, alpha = pixel if alpha == 0: return 1.0 red = float(red) green = float(green) blue = float(blue) max_channel = max(red, green, blue) min_channel = min(red, green, blue) average = (red + green + blue) / 3.0 if average < 188.0 or min_channel < 168.0: return 0.0 spread = max_channel - min_channel neutrality = 1.0 - max(0.0, min(1.0, (spread - 6.0) / 34.0)) brightness = max(0.0, min(1.0, (average - 188.0) / 55.0)) floor = max(0.0, min(1.0, (min_channel - 168.0) / 60.0)) return max(0.0, min(1.0, neutrality * (brightness * 0.85 + floor * 0.15))) def apply_green_screen_alpha(source): image = source.convert("RGBA") pixels = image.load() width, height = image.size for y in range(height): for x in range(width): red, green, blue, alpha = pixels[x, y] score = green_screen_score((red, green, blue, alpha)) if score >= 0.82: pixels[x, y] = (red, green, blue, 0) elif score >= 0.34: next_alpha = int(round(alpha * (1.0 - min(1.0, score * 1.08)))) if next_alpha < 10: next_alpha = 0 pixels[x, y] = (red, green, blue, next_alpha) return image def make_background_opaque(source): image = source.convert("RGBA") width, height = image.size edge_pixels = [] pixels = image.load() for x in range(width): edge_pixels.append(pixels[x, 0]) edge_pixels.append(pixels[x, height - 1]) for y in range(1, max(1, height - 1)): edge_pixels.append(pixels[0, y]) edge_pixels.append(pixels[width - 1, y]) weighted = [0, 0, 0, 0] for red, green, blue, alpha in edge_pixels: if alpha < 32: continue weighted[0] += red * alpha weighted[1] += green * alpha weighted[2] += blue * alpha weighted[3] += alpha matte = ( tuple(channel // weighted[3] for channel in weighted[:3]) if weighted[3] > 0 else (246, 243, 236) ) for y in range(height): for x in range(width): red, green, blue, alpha = pixels[x, y] if alpha == 255: continue inv = 255 - alpha pixels[x, y] = ( (red * alpha + matte[0] * inv + 127) // 255, (green * alpha + matte[1] * inv + 127) // 255, (blue * alpha + matte[2] * inv + 127) // 255, 255, ) return image def visible(pixel, threshold=36): return pixel[3] >= threshold def detect_components(image, alpha_threshold=36): width, height = image.size pixels = image.load() visited = bytearray(width * height) min_area = max(16, min(800, (width * height) // 12000)) components = [] for start in range(width * height): if visited[start]: continue sx = start % width sy = start // width if not visible(pixels[sx, sy], alpha_threshold): visited[start] = 1 continue queue = deque([(sx, sy)]) visited[start] = 1 min_x = max_x = sx min_y = max_y = sy area = 0 while queue: x, y = queue.pop() area += 1 min_x = min(min_x, x) max_x = max(max_x, x) min_y = min(min_y, y) max_y = max(max_y, y) for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)): if nx < 0 or ny < 0 or nx >= width or ny >= height: continue index = ny * width + nx if visited[index]: continue visited[index] = 1 if visible(pixels[nx, ny], alpha_threshold): queue.append((nx, ny)) if area >= min_area: components.append( { "x": min_x, "y": min_y, "width": max_x - min_x + 1, "height": max_y - min_y + 1, "area": area, } ) return sort_components_by_original_position(components) def sort_components_by_original_position(components): if not components: return [] average_height = sum(component["height"] for component in components) / len(components) row_tolerance = max(2.0, average_height * 0.65) rows = [] for component in sorted(components, key=lambda item: (item["y"], item["x"])): center_y = component["y"] + component["height"] / 2.0 target_row = None for row in rows: row_center = sum(item["y"] + item["height"] / 2.0 for item in row) / len(row) if abs(row_center - center_y) <= row_tolerance: target_row = row break if target_row is None: rows.append([component]) else: target_row.append(component) sorted_components = [] for row in rows: sorted_components.extend(sorted(row, key=lambda item: item["x"])) return sorted_components def trim_visible_bounds(image): width, height = image.size pixels = image.load() min_x = width min_y = height max_x = -1 max_y = -1 visible_count = 0 for y in range(height): for x in range(width): if not visible(pixels[x, y], 12): continue visible_count += 1 min_x = min(min_x, x) min_y = min(min_y, y) max_x = max(max_x, x) max_y = max(max_y, y) min_visible = max(10, min(120, (width * height) // 540)) if visible_count < min_visible or max_x <= min_x or max_y <= min_y: return image return image.crop((min_x, min_y, max_x + 1, max_y + 1)) def crop_region(image, component): x = component["x"] y = component["y"] width = component["width"] height = component["height"] return trim_visible_bounds(image.crop((x, y, x + width, y + height))) def fallback_grid_slice(image, item_count=ITEM_COUNT): width, height = image.size slices = [] items_per_row = GRID_SIZE // VIEW_COUNT for item_index in range(item_count): row = item_index // items_per_row start_col = (item_index % items_per_row) * VIEW_COUNT for view_index in range(VIEW_COUNT): col = start_col + view_index x0 = col * width // GRID_SIZE x1 = (col + 1) * width // GRID_SIZE y0 = row * height // GRID_SIZE y1 = (row + 1) * height // GRID_SIZE cell = image.crop((x0, y0, x1, y1)) slices.append((item_index, view_index, trim_visible_bounds(cell))) return slices def save_ui_slices(image, out_dir): components = detect_components(image, 36) slices_dir = out_dir / "03-ui-slices" slices_dir.mkdir(parents=True, exist_ok=True) regions = [] for index, component in enumerate(components[: len(UI_ORDER)]): label = UI_ORDER[index] output = slices_dir / f"{index + 1:02d}-{label}.png" crop_region(image, component).save(output) regions.append({**component, "label": label, "file": str(output)}) return { "detectedCount": len(components), "usedCount": len(regions), "regions": regions, } def save_item_slices(image, out_dir): components = detect_components(image, 36) slices_dir = out_dir / "07-item-slices" slices_dir.mkdir(parents=True, exist_ok=True) expected = ITEM_COUNT * VIEW_COUNT use_components = len(components) >= expected if use_components: source_slices = [ (index // VIEW_COUNT, index % VIEW_COUNT, crop_region(image, component)) for index, component in enumerate(components[:expected]) ] else: source_slices = fallback_grid_slice(image) items = [] for item_index in range(ITEM_COUNT): item_dir = slices_dir / f"item-{item_index + 1:02d}" item_dir.mkdir(parents=True, exist_ok=True) views = [] for _, view_index, crop in [ entry for entry in source_slices if entry[0] == item_index ]: output = item_dir / f"view-{view_index + 1:02d}.png" crop.save(output) views.append({"viewIndex": view_index + 1, "file": str(output)}) items.append({"itemIndex": item_index + 1, "views": views}) return { "method": "alpha-components" if use_components else "fallback-grid", "detectedCount": len(components), "expectedCount": expected, "items": items, "regions": components[:expected], } def main(): parser = argparse.ArgumentParser() parser.add_argument("--out-dir", required=True) args = parser.parse_args() out_dir = Path(args.out_dir).resolve() level_scene = Image.open(out_dir / "01-level-scene.raw.png").convert("RGBA") ui_raw = Image.open(out_dir / "02-ui-spritesheet.raw.png").convert("RGBA") background_raw = Image.open(out_dir / "04-background.raw.png").convert("RGBA") item_raw = Image.open(out_dir / "06-item-spritesheet.raw.png").convert("RGBA") ui_transparent = apply_green_screen_alpha(ui_raw) item_transparent = apply_green_screen_alpha(item_raw) background_opaque = make_background_opaque(background_raw) ui_transparent.save(out_dir / "02-ui-spritesheet.transparent.png") background_opaque.save(out_dir / "05-background.opaque.png") item_transparent.save(out_dir / "06-item-spritesheet.transparent.png") ui_manifest = save_ui_slices(ui_transparent, out_dir) item_manifest = save_item_slices(item_transparent, out_dir) manifest = { "levelScene": { "file": str(out_dir / "01-level-scene.raw.png"), "size": level_scene.size, }, "uiSpritesheet": { "rawFile": str(out_dir / "02-ui-spritesheet.raw.png"), "transparentFile": str(out_dir / "02-ui-spritesheet.transparent.png"), "size": ui_transparent.size, **ui_manifest, }, "background": { "rawFile": str(out_dir / "04-background.raw.png"), "opaqueFile": str(out_dir / "05-background.opaque.png"), "size": background_opaque.size, }, "itemSpritesheet": { "rawFile": str(out_dir / "06-item-spritesheet.raw.png"), "transparentFile": str(out_dir / "06-item-spritesheet.transparent.png"), "size": item_transparent.size, **item_manifest, }, } (out_dir / "08-export-manifest.json").write_text( json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) print(json.dumps({"ok": True, "manifest": str(out_dir / "08-export-manifest.json")}, ensure_ascii=False)) if __name__ == "__main__": main()