300 lines
12 KiB
Python
Executable File
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()
|