hy-farm/tools/batch_prepare_icons.py
2026-05-29 19:54:56 +08:00

300 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import subprocess
import sys
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from PIL import Image, ImageDraw
@dataclass(frozen=True)
class IconSpec:
source: str
output_name: str
target_size: tuple[int, int]
tres_path: str
icon_height_ratio: float = 0.72
ICON_SPECS: tuple[IconSpec, ...] = (
IconSpec("ChatGPT Image 2026年5月11日 21_57_01 (1).png", "world", (156, 161), "assets/egret/menu2/menu2_m_word_png.tres", 0.78),
IconSpec("ChatGPT Image 2026年5月11日 21_57_01 (2).png", "home", (155, 145), "assets/egret/menu2/menu2_m_home_png.tres", 0.76),
IconSpec("ChatGPT Image 2026年5月11日 23_13_41 (4).png", "store", (86, 89), "assets/egret/menu2/menu2_m_shop_png.tres", 0.72),
IconSpec("ChatGPT Image 2026年5月11日 23_13_40 (2).png", "storage", (84, 101), "assets/egret/menu2/menu2_m_warehouse_png.tres", 0.72),
IconSpec("ChatGPT Image 2026年5月11日 23_13_42 (6).png", "friends", (74, 92), "assets/egret/menu2/menu2_m_rank_png.tres", 0.72),
IconSpec("ChatGPT Image 2026年5月11日 21_36_54 (1).png", "benefits_hall", (85, 89), "assets/egret/menu2/menu2_left_fuli_png.tres"),
IconSpec("ChatGPT Image 2026年5月11日 21_36_54 (2).png", "news", (86, 78), "assets/egret/menu2/menu2_left_notice_png.tres"),
IconSpec("ChatGPT Image 2026年5月11日 21_36_54 (3).png", "daily_check_in", (86, 78), "assets/egret/menu2/menu2_left_sign_png.tres"),
IconSpec("ChatGPT Image 2026年5月11日 21_36_55 (4).png", "online_gift", (85, 76), "assets/egret/menu2/menu2_left_rewardgift_png.tres"),
IconSpec("ChatGPT Image 2026年5月11日 21_36_55 (5).png", "log", (74, 74), "assets/egret/menu2/menu2_top_log_png.tres"),
IconSpec("ChatGPT Image 2026年5月11日 21_36_55 (6).png", "land_upgrade", (96, 82), "assets/egret/menu2/menu2_top_landup_png.tres"),
IconSpec("ChatGPT Image 2026年5月11日 21_36_56 (7).png", "top_up", (56, 80), "assets/egret/menu2/menu2_toppay_png.tres"),
IconSpec("ChatGPT Image 2026年5月11日 21_36_56 (8).png", "level_pack", (85, 80), "assets/egret/menu2/menu2_top_paygift_png.tres"),
IconSpec("ChatGPT Image 2026年5月11日 21_36_56 (9).png", "exchange", (93, 83), "assets/egret/menu2/menu2_top_crystal_png.tres"),
IconSpec("ChatGPT Image 2026年5月11日 22_15_09.png", "factory", (105, 90), "assets/egret/mofajiagong/mofajiagong_tubiao_png.tres", 0.70),
IconSpec("ChatGPT Image 2026年5月11日 21_36_57 (10).png", "merge", (78, 85), "assets/egret/menu2/menu2_tophecheng_png.tres"),
IconSpec("ChatGPT Image 2026年5月11日 21_57_02 (3).png", "lucky_wheel", (86, 83), "assets/egret/menu2/menu2_top_lottery_png.tres"),
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Batch remove green backgrounds, resize farm UI icons, and rewrite Godot .tres references."
)
parser.add_argument("--project", default="godot/FarmGodot", help="Godot project directory.")
parser.add_argument("--input-dir", default=str(Path.home() / "Downloads"), help="Directory containing source PNG files.")
parser.add_argument("--method", choices=("green", "ai", "auto"), default="green", help="Matting method.")
parser.add_argument("--install-ai", action="store_true", help="Install rembg/onnxruntime for --method ai.")
parser.add_argument("--preview", default="/tmp/farm_prepared_icons_preview.png", help="Preview image path.")
parser.add_argument(
"--cutout",
nargs=4,
action="append",
metavar=("SOURCE", "OUTPUT", "WIDTH", "HEIGHT"),
help="Remove green background from SOURCE and fit it into OUTPUT at WIDTHxHEIGHT.",
)
return parser.parse_args()
def maybe_install_ai() -> None:
subprocess.check_call([sys.executable, "-m", "pip", "install", "--user", "rembg", "onnxruntime"])
def remove_with_ai(image: Image.Image) -> Image.Image:
try:
from rembg import remove
except ImportError as exc:
raise RuntimeError("rembg is not installed. Run with --install-ai or use --method green.") from exc
return remove(image.convert("RGBA"))
def is_green_background(pixel: tuple[int, int, int, int]) -> bool:
r, g, b, a = pixel
return a > 0 and g >= 120 and g > r * 1.28 and g > b * 1.28
def remove_green_background(image: Image.Image) -> Image.Image:
image = image.convert("RGBA")
pix = image.load()
width, height = image.size
seen = bytearray(width * height)
queue: deque[tuple[int, int]] = deque()
def add_if_background(x: int, y: int) -> None:
index = y * width + x
if seen[index] or not is_green_background(pix[x, y]):
return
seen[index] = 1
queue.append((x, y))
for x in range(width):
add_if_background(x, 0)
add_if_background(x, height - 1)
for y in range(height):
add_if_background(0, y)
add_if_background(width - 1, y)
while queue:
x, y = queue.popleft()
if x > 0:
add_if_background(x - 1, y)
if x + 1 < width:
add_if_background(x + 1, y)
if y > 0:
add_if_background(x, y - 1)
if y + 1 < height:
add_if_background(x, y + 1)
for y in range(height):
for x in range(width):
if seen[y * width + x]:
r, g, b, _ = pix[x, y]
pix[x, y] = (r, g, b, 0)
else:
r, g, b, a = pix[x, y]
if a and is_green_background((r, g, b, a)):
pix[x, y] = (r, min(g, max(r, b) + 14), b, a)
return image
def remove_large_green_regions(image: Image.Image, min_area: int = 2048) -> Image.Image:
image = image.convert("RGBA")
pix = image.load()
width, height = image.size
seen = bytearray(width * height)
for y in range(height):
for x in range(width):
index = y * width + x
if seen[index] or not is_green_background(pix[x, y]):
continue
seen[index] = 1
queue: deque[tuple[int, int]] = deque([(x, y)])
component: list[tuple[int, int]] = []
while queue:
point_x, point_y = queue.popleft()
component.append((point_x, point_y))
for next_x, next_y in (
(point_x - 1, point_y),
(point_x + 1, point_y),
(point_x, point_y - 1),
(point_x, point_y + 1),
):
if next_x < 0 or next_x >= width or next_y < 0 or next_y >= height:
continue
next_index = next_y * width + next_x
if seen[next_index] or not is_green_background(pix[next_x, next_y]):
continue
seen[next_index] = 1
queue.append((next_x, next_y))
if len(component) >= min_area:
for point_x, point_y in component:
r, g, b, _ = pix[point_x, point_y]
pix[point_x, point_y] = (r, g, b, 0)
return image
def crop_visible(image: Image.Image, padding: int = 16) -> Image.Image:
bbox = image.getchannel("A").getbbox()
if bbox is None:
return image
left, top, right, bottom = bbox
return image.crop(
(
max(0, left - padding),
max(0, top - padding),
min(image.width, right + padding),
min(image.height, bottom + padding),
)
)
def fit_icon(image: Image.Image, target_size: tuple[int, int], icon_height_ratio: float) -> Image.Image:
cropped = crop_visible(image)
canvas = Image.new("RGBA", target_size, (0, 0, 0, 0))
max_width = max(1, int(target_size[0] * 0.96))
max_height = max(1, int(target_size[1] * icon_height_ratio))
cropped.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
x = (target_size[0] - cropped.width) // 2
y = max(0, int((target_size[1] * icon_height_ratio - cropped.height) * 0.5))
canvas.alpha_composite(cropped, (x, y))
return canvas
def fit_cutout(image: Image.Image, target_size: tuple[int, int]) -> Image.Image:
cropped = crop_visible(image, 0)
canvas = Image.new("RGBA", target_size, (0, 0, 0, 0))
cropped.thumbnail(target_size, Image.Resampling.LANCZOS)
x = (target_size[0] - cropped.width) // 2
y = (target_size[1] - cropped.height) // 2
canvas.alpha_composite(cropped, (x, y))
return canvas
def remove_background(image: Image.Image, method: str) -> Image.Image:
if method == "ai":
return remove_with_ai(image)
if method == "auto":
try:
return remove_with_ai(image)
except Exception:
return remove_green_background(image)
return remove_green_background(image)
def prepare_cutouts(args: argparse.Namespace) -> bool:
if not args.cutout:
return False
preview_items: list[tuple[str, Path]] = []
for source, output, width_text, height_text in args.cutout:
source_path = Path(source).expanduser().resolve()
output_path = Path(output).expanduser().resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
image = Image.open(source_path)
matted = remove_large_green_regions(image) if args.method == "green" else remove_background(image, args.method)
prepared = fit_cutout(matted, (int(width_text), int(height_text)))
prepared.save(output_path)
preview_items.append((output_path.stem, output_path))
build_preview(preview_items, Path(args.preview))
print(f"Prepared {len(preview_items)} cutouts")
print(f"Preview: {args.preview}")
return True
def write_tres(project: Path, spec: IconSpec) -> None:
tres_path = project / spec.tres_path
resource_path = f"res://assets/replacement/menu2/{spec.output_name}.png"
tres_path.write_text(
"[gd_resource type=\"AtlasTexture\" load_steps=2 format=3]\n\n"
f"[ext_resource type=\"Texture2D\" path=\"{resource_path}\" id=\"1_replacement\"]\n\n"
"[resource]\n"
"atlas = ExtResource(\"1_replacement\")\n"
f"region = Rect2(0, 0, {spec.target_size[0]}, {spec.target_size[1]})\n",
encoding="utf-8",
)
def build_preview(items: list[tuple[str, Path]], output_path: Path) -> None:
thumb = 116
label_height = 24
columns = 5
rows = (len(items) + columns - 1) // columns
preview = Image.new("RGBA", (columns * thumb, rows * (thumb + label_height)), (255, 255, 255, 255))
draw = ImageDraw.Draw(preview)
for index, (name, path) in enumerate(items):
bg = Image.new("RGBA", (thumb, thumb), (245, 245, 245, 255))
bg_draw = ImageDraw.Draw(bg)
for y in range(0, thumb, 10):
for x in range(0, thumb, 10):
if (x // 10 + y // 10) % 2:
bg_draw.rectangle((x, y, x + 9, y + 9), fill=(225, 225, 225, 255))
icon = Image.open(path).convert("RGBA")
icon.thumbnail((thumb - 8, thumb - 8), Image.Resampling.LANCZOS)
bg.alpha_composite(icon, ((thumb - icon.width) // 2, (thumb - icon.height) // 2))
x = (index % columns) * thumb
y = (index // columns) * (thumb + label_height)
preview.alpha_composite(bg, (x, y))
draw.text((x + 4, y + thumb + 3), name[:18], fill=(0, 0, 0, 255))
preview.save(output_path)
def main() -> None:
args = parse_args()
if args.install_ai:
maybe_install_ai()
if prepare_cutouts(args):
return
project = Path(args.project).resolve()
input_dir = Path(args.input_dir).resolve()
output_dir = project / "assets" / "replacement" / "menu2"
output_dir.mkdir(parents=True, exist_ok=True)
preview_items: list[tuple[str, Path]] = []
for spec in ICON_SPECS:
source_path = input_dir / spec.source
if not source_path.exists():
raise FileNotFoundError(source_path)
image = Image.open(source_path)
matted = remove_background(image, args.method)
output = fit_icon(matted, spec.target_size, spec.icon_height_ratio)
output_path = output_dir / f"{spec.output_name}.png"
output.save(output_path)
write_tres(project, spec)
preview_items.append((spec.output_name, output_path))
build_preview(preview_items, Path(args.preview))
print(f"Prepared {len(ICON_SPECS)} icons")
print(f"Preview: {args.preview}")
if __name__ == "__main__":
main()