Files
AIGame/Tools/slice_ui_normal_atlas.py
udxandCursor b7a0bbcb89 Add hot-update boot pipeline, UI bundles/atlas assets, and design docs.
Ignore AIClient/Library and AIClient/HybridCLRData permanently; include HybridCLR/YooAsset project setup and UI tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 16:02:58 +08:00

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()