346 lines
12 KiB
Python
346 lines
12 KiB
Python
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()
|