Ignore AIClient/Library and AIClient/HybridCLRData permanently; include HybridCLR/YooAsset project setup and UI tooling. Co-authored-by: Cursor <cursoragent@cursor.com>
166 lines
5.9 KiB
Python
166 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Slice UI Normal atlas into Common single sprites."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections import deque
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
ATLAS = Path(r"d:\UD\AI\AIGame\Tools\output\ui_normal_atlas_gpt.png")
|
|
OUT_DIR = Path(r"d:\UD\AI\AIGame\AIClient\Assets\Bundles\UI\Texture\Common")
|
|
REPORT = Path(r"d:\UD\AI\AIGame\Tools\output\ui_normal_slice_report.json")
|
|
|
|
# From Doc/UI_Normal_Set.md (left-top origin)
|
|
SLICES = [
|
|
("ui_panel_main", 8, 8, 480, 256),
|
|
("ui_panel_popup", 496, 8, 360, 240),
|
|
("ui_panel_content", 864, 8, 320, 200),
|
|
("ui_panel_header", 1192, 8, 400, 80),
|
|
("ui_overlay_dim", 1600, 8, 192, 192),
|
|
("ui_divider_h", 1192, 96, 400, 16),
|
|
("btn_primary_normal", 8, 280, 224, 88),
|
|
("btn_primary_pressed", 240, 280, 224, 88),
|
|
("btn_primary_disabled", 472, 280, 224, 88),
|
|
("btn_secondary_normal", 704, 280, 224, 88),
|
|
("btn_secondary_pressed", 936, 280, 224, 88),
|
|
("btn_small_normal", 1168, 280, 160, 64),
|
|
("btn_small_pressed", 1336, 280, 160, 64),
|
|
("btn_close", 1504, 280, 72, 72),
|
|
("btn_back", 1584, 280, 72, 72),
|
|
("scrollbar_track_v", 1664, 280, 28, 160),
|
|
("scrollbar_thumb_v", 1700, 280, 28, 80),
|
|
("input_bg_normal", 8, 400, 360, 72),
|
|
("input_bg_focus", 376, 400, 360, 72),
|
|
("ui_list_item_normal", 744, 400, 320, 72),
|
|
("ui_list_item_selected", 1072, 400, 320, 72),
|
|
("tab_active", 1400, 400, 200, 72),
|
|
("tab_inactive", 1608, 400, 200, 72),
|
|
("progress_bg", 8, 504, 400, 32),
|
|
("progress_fill", 416, 504, 400, 32),
|
|
("slider_track", 824, 504, 320, 28),
|
|
("slider_fill", 1152, 504, 320, 28),
|
|
("slider_handle", 1480, 496, 56, 56),
|
|
("toast_bg", 1544, 496, 280, 80),
|
|
("toggle_bg_off", 8, 616, 112, 56),
|
|
("toggle_bg_on", 128, 616, 112, 56),
|
|
("toggle_knob", 248, 616, 48, 48),
|
|
("checkbox_off", 304, 616, 56, 56),
|
|
("checkbox_on", 368, 616, 56, 56),
|
|
("ico_search", 432, 616, 56, 56),
|
|
("ico_check", 496, 616, 56, 56),
|
|
("ico_settings", 560, 616, 56, 56),
|
|
("ico_refresh", 624, 616, 56, 56),
|
|
]
|
|
|
|
|
|
def find_boxes(arr: np.ndarray, alpha_thr: int = 24, min_px: int = 60):
|
|
mask = arr[:, :, 3] > alpha_thr
|
|
h, w = mask.shape
|
|
visited = np.zeros_like(mask, dtype=bool)
|
|
boxes = []
|
|
for y in range(h):
|
|
for x in np.where(mask[y] & ~visited[y])[0]:
|
|
if visited[y, x] or not mask[y, x]:
|
|
continue
|
|
q = deque([(int(y), int(x))])
|
|
visited[y, x] = True
|
|
minx = maxx = int(x)
|
|
miny = maxy = int(y)
|
|
cnt = 0
|
|
while q:
|
|
cy, cx = q.popleft()
|
|
cnt += 1
|
|
for ny, nx in ((cy - 1, cx), (cy + 1, cx), (cy, cx - 1), (cy, cx + 1)):
|
|
if 0 <= ny < h and 0 <= nx < w and mask[ny, nx] and not visited[ny, nx]:
|
|
visited[ny, nx] = True
|
|
q.append((ny, nx))
|
|
minx = min(minx, nx)
|
|
maxx = max(maxx, nx)
|
|
miny = min(miny, ny)
|
|
maxy = max(maxy, ny)
|
|
bw, bh = maxx - minx + 1, maxy - miny + 1
|
|
if cnt >= min_px and bw >= 10 and bh >= 8:
|
|
boxes.append(
|
|
{
|
|
"miny": miny,
|
|
"minx": minx,
|
|
"maxy": maxy,
|
|
"maxx": maxx,
|
|
"w": bw,
|
|
"h": bh,
|
|
"cx": (minx + maxx) / 2,
|
|
"cy": (miny + maxy) / 2,
|
|
"px": cnt,
|
|
}
|
|
)
|
|
return boxes
|
|
|
|
|
|
def match_slices(boxes: list[dict]) -> list[dict]:
|
|
used = set()
|
|
matches = []
|
|
for name, x, y, w, h in SLICES:
|
|
pcx, pcy = x + w / 2, y + h / 2
|
|
best_i, best_score = None, 1e18
|
|
for i, b in enumerate(boxes):
|
|
if i in used:
|
|
continue
|
|
# prefer position; lightly penalize size mismatch
|
|
dist = (b["cx"] - pcx) ** 2 + (b["cy"] - pcy) ** 2
|
|
size_pen = ((b["w"] - w) / max(w, 1)) ** 2 + ((b["h"] - h) / max(h, 1)) ** 2
|
|
score = dist + size_pen * 8000
|
|
if score < best_score:
|
|
best_score = score
|
|
best_i = i
|
|
if best_i is None:
|
|
raise RuntimeError(f"no box for {name}")
|
|
used.add(best_i)
|
|
b = boxes[best_i]
|
|
matches.append(
|
|
{
|
|
"name": name,
|
|
"planned": [x, y, w, h],
|
|
"box": [b["minx"], b["miny"], b["w"], b["h"]],
|
|
"score": round(best_score, 1),
|
|
}
|
|
)
|
|
return matches
|
|
|
|
|
|
def main() -> None:
|
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
img = Image.open(ATLAS).convert("RGBA")
|
|
arr = np.array(img)
|
|
boxes = find_boxes(arr)
|
|
if len(boxes) != len(SLICES):
|
|
raise SystemExit(f"expected {len(SLICES)} components, got {len(boxes)}")
|
|
|
|
matches = match_slices(boxes)
|
|
written = []
|
|
for m in matches:
|
|
x, y, w, h = m["box"]
|
|
crop = img.crop((x, y, x + w, y + h))
|
|
# pad 2px transparent to reduce bleed when bilinear sampling
|
|
pad = 2
|
|
canvas = Image.new("RGBA", (w + pad * 2, h + pad * 2), (0, 0, 0, 0))
|
|
canvas.paste(crop, (pad, pad))
|
|
out = OUT_DIR / f"{m['name']}.png"
|
|
canvas.save(out)
|
|
written.append({"name": m["name"], "path": str(out), **m})
|
|
|
|
REPORT.write_text(json.dumps({"count": len(written), "items": written}, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
print(f"sliced {len(written)} sprites -> {OUT_DIR}")
|
|
print(f"report -> {REPORT}")
|
|
for m in matches[:5]:
|
|
print(m["name"], "planned", m["planned"], "->", m["box"], "score", m["score"])
|
|
print("...")
|
|
for m in matches[-3:]:
|
|
print(m["name"], "planned", m["planned"], "->", m["box"], "score", m["score"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|