413 lines
15 KiB
Python
413 lines
15 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Slice Doc/design/UI/ui_kit_style_board.png into per-sprite PNGs
|
||
named per Doc/design/UI_Pic_Set.md, and write a missing-list report.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from pathlib import Path
|
||
|
||
from PIL import Image
|
||
|
||
ROOT = Path(r"d:\UD\AI\AIC#Project\Doc\design\UI")
|
||
SRC = ROOT / "ui_kit_style_board.png"
|
||
OUT = ROOT / "slices"
|
||
OUT.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Target sizes from UI_Pic_Set.md (W, H). Used to resize after crop when set.
|
||
TARGETS = {
|
||
"ui_panel_main": (512, 384),
|
||
"ui_panel_popup": (384, 256),
|
||
"ui_panel_content": (384, 256),
|
||
"ui_panel_header": (512, 96),
|
||
"ui_panel_footer": (512, 96),
|
||
"ui_panel_tooltip": (320, 160),
|
||
"ui_list_item_normal": (384, 96),
|
||
"ui_list_item_selected": (384, 96),
|
||
"ui_list_item_disabled": (384, 96),
|
||
"ui_overlay_dim": (256, 256),
|
||
"ui_divider_h": (512, 16),
|
||
"ui_divider_v": (16, 256),
|
||
"ui_badge_base": (128, 64),
|
||
"ui_badge_alert": (128, 64),
|
||
"btn_primary_normal": (256, 96),
|
||
"btn_primary_hover": (256, 96),
|
||
"btn_primary_pressed": (256, 96),
|
||
"btn_primary_disabled": (256, 96),
|
||
"btn_secondary_normal": (256, 96),
|
||
"btn_secondary_pressed": (256, 96),
|
||
"btn_warning_normal": (224, 96),
|
||
"btn_warning_pressed": (224, 96),
|
||
"btn_small_normal": (192, 72),
|
||
"btn_small_hover": (192, 72),
|
||
"btn_small_pressed": (192, 72),
|
||
"btn_small_disabled": (192, 72),
|
||
"tab_active": (224, 80),
|
||
"tab_inactive": (224, 80),
|
||
"seg_left": (160, 80),
|
||
"seg_mid": (160, 80),
|
||
"seg_right": (160, 80),
|
||
"btn_close": (80, 80),
|
||
"btn_back": (80, 80),
|
||
"btn_home": (80, 80),
|
||
"input_bg_normal": (384, 80),
|
||
"input_bg_focus": (384, 80),
|
||
"input_bg_error": (384, 80),
|
||
"dropdown_bg": (384, 80),
|
||
"dropdown_arrow": (64, 64),
|
||
"input_caret": (8, 64),
|
||
"ico_keyboard": (64, 64),
|
||
"ico_search": (64, 64),
|
||
"ico_clear": (64, 64),
|
||
"ico_copy": (64, 64),
|
||
"ico_paste": (64, 64),
|
||
"progress_bg": (512, 40),
|
||
"progress_fill_blue": (512, 40),
|
||
"progress_fill_green": (512, 40),
|
||
"progress_fill_warn": (472, 40),
|
||
"slider_track": (512, 32),
|
||
"slider_fill": (512, 32),
|
||
"slider_handle": (64, 64),
|
||
"scrollbar_track_v": (32, 512),
|
||
"scrollbar_thumb_v": (32, 128),
|
||
"scrollbar_track_h": (512, 32),
|
||
"scrollbar_thumb_h": (128, 32),
|
||
"loading_ring_0": (64, 64),
|
||
"loading_ring_1": (64, 64),
|
||
"toggle_bg_off": (112, 56),
|
||
"toggle_bg_on": (112, 56),
|
||
"toggle_knob": (48, 48),
|
||
"checkbox_off": (64, 64),
|
||
"checkbox_on": (64, 64),
|
||
"radio_off": (64, 64),
|
||
"radio_on": (64, 64),
|
||
"ico_check": (64, 64),
|
||
"ico_lock": (64, 64),
|
||
"ico_unlock": (64, 64),
|
||
"ico_warning": (64, 64),
|
||
"ico_info": (64, 64),
|
||
"ico_plus": (64, 64),
|
||
"ico_minus": (64, 64),
|
||
"slot_empty": (96, 96),
|
||
"slot_selected": (96, 96),
|
||
"slot_locked": (96, 96),
|
||
"avatar_frame": (128, 128),
|
||
"rank_medal_1": (80, 80),
|
||
"rank_medal_2": (80, 80),
|
||
"rank_medal_3": (80, 80),
|
||
"ico_coin": (80, 80),
|
||
"menu_bg": (384, 320),
|
||
"menu_item_normal": (384, 64),
|
||
"menu_item_hover": (384, 64),
|
||
"menu_item_disabled": (384, 64),
|
||
"toast_bg": (320, 96),
|
||
"notice_bg": (320, 96),
|
||
"drag_handle": (160, 32),
|
||
"qr_scan_frame": (128, 128),
|
||
"ico_server": (64, 64),
|
||
"ico_user": (64, 64),
|
||
"ico_chat": (64, 64),
|
||
"ico_mail": (64, 64),
|
||
"ico_settings": (64, 64),
|
||
"ico_sound_on": (64, 64),
|
||
"ico_sound_off": (64, 64),
|
||
"ico_scan": (64, 64),
|
||
"ico_refresh": (64, 64),
|
||
"ico_download": (64, 64),
|
||
"ico_upload": (64, 64),
|
||
"ico_bag": (64, 64),
|
||
"ico_item": (64, 64),
|
||
"ico_sword": (64, 64),
|
||
"ico_shield": (64, 64),
|
||
"ico_map": (64, 64),
|
||
"ico_star": (64, 64),
|
||
"ico_heart": (64, 64),
|
||
"ico_diamond": (64, 64),
|
||
"ico_key": (64, 64),
|
||
"ico_clock": (64, 64),
|
||
"ico_calendar": (64, 64),
|
||
"reserved_large_0": (512, 256),
|
||
"reserved_large_1": (512, 256),
|
||
"reserved_icon_grid": (784, 192),
|
||
}
|
||
|
||
# Manual crops on 1024x1024 style board: name -> (x0, y0, x1, y1)
|
||
# Mapped from visual layout + profile probes.
|
||
CROPS: dict[str, tuple[int, int, int, int]] = {
|
||
# --- panels ---
|
||
"ui_panel_main": (18, 14, 348, 334),
|
||
"ui_panel_popup": (360, 21, 578, 195),
|
||
"ui_panel_content": (580, 29, 736, 328), # card body (illustration+text+btn)
|
||
"ui_panel_header": (748, 24, 993, 66),
|
||
"ui_panel_footer": (750, 80, 942, 118),
|
||
"ui_panel_tooltip": (748, 133, 993, 198),
|
||
# list items (right of main / under popup) — cream / blue / grey row set near card
|
||
"ui_list_item_normal": (518, 210, 736, 255),
|
||
"ui_list_item_selected": (518, 258, 736, 303),
|
||
"ui_list_item_disabled": (518, 305, 736, 340),
|
||
"ui_overlay_dim": (696, 213, 810, 326),
|
||
"ui_divider_h": (833, 229, 913, 236),
|
||
"ui_divider_v": (932, 215, 941, 329),
|
||
"ui_badge_base": (954, 228, 1009, 268), # blue ribbon chip nearby — best badge-like
|
||
"ui_badge_alert": (958, 281, 1001, 325),
|
||
# --- buttons (3 states each; OK/CANCEL/WARN/SMALL) ---
|
||
# primary: 3 states; hover approximated from middle brighter if present — board has normal/pressed/disabled
|
||
"btn_primary_normal": (27, 356, 162, 392),
|
||
"btn_primary_pressed": (27, 398, 162, 432),
|
||
"btn_primary_disabled": (27, 437, 162, 471),
|
||
"btn_secondary_normal": (183, 356, 317, 392),
|
||
"btn_secondary_pressed": (183, 398, 317, 432),
|
||
"btn_warning_normal": (337, 356, 462, 392),
|
||
"btn_warning_pressed": (337, 398, 462, 432),
|
||
"btn_small_normal": (485, 357, 590, 392),
|
||
"btn_small_pressed": (485, 398, 590, 432),
|
||
"btn_small_disabled": (485, 438, 590, 470),
|
||
# tabs: joined strip — split left/right
|
||
"tab_active": (624, 357, 716, 396),
|
||
"tab_inactive": (716, 357, 807, 396),
|
||
# segments L/M/R from middle LMR row
|
||
"seg_left": (621, 408, 685, 448),
|
||
"seg_mid": (685, 408, 749, 448),
|
||
"seg_right": (749, 408, 813, 448),
|
||
"btn_close": (866, 357, 914, 408),
|
||
"btn_back": (937, 357, 986, 408),
|
||
"btn_home": (866, 417, 915, 471),
|
||
# --- input ---
|
||
"input_bg_normal": (24, 500, 162, 548),
|
||
"input_bg_focus": (178, 500, 314, 548),
|
||
"input_bg_error": (327, 500, 463, 548),
|
||
"dropdown_bg": (479, 500, 604, 548),
|
||
"dropdown_arrow": (636, 504, 672, 548),
|
||
"ico_keyboard": (696, 504, 739, 548),
|
||
"ico_search": (755, 504, 799, 548),
|
||
"ico_clear": (817, 504, 857, 548),
|
||
"ico_copy": (875, 504, 914, 548),
|
||
"ico_paste": (931, 504, 975, 548),
|
||
# --- progress / slider / scrollbar / loading ---
|
||
"progress_bg": (23, 572, 157, 620),
|
||
"progress_fill_blue": (170, 572, 296, 620),
|
||
"progress_fill_green": (310, 572, 432, 620),
|
||
"progress_fill_warn": (459, 572, 593, 620),
|
||
# sliders: take blue track+handle as fill/handle sources
|
||
"slider_track": (23, 572, 157, 620), # empty track doubles as slider track look
|
||
"slider_fill": (170, 572, 250, 620),
|
||
"slider_handle": (250, 575, 290, 615), # approximate knob from blue slider
|
||
"scrollbar_track_v": (625, 568, 662, 692),
|
||
"scrollbar_thumb_v": (628, 590, 658, 650),
|
||
"scrollbar_track_h": (664, 650, 900, 688),
|
||
"scrollbar_thumb_h": (760, 652, 840, 686),
|
||
"loading_ring_0": (949, 570, 1003, 630),
|
||
"loading_ring_1": (876, 570, 935, 630),
|
||
# --- toggle / checkbox / radio / status (y ~655-690) ---
|
||
"toggle_bg_on": (27, 652, 82, 690),
|
||
"toggle_bg_off": (95, 652, 151, 690),
|
||
"checkbox_on": (165, 652, 193, 690),
|
||
"checkbox_off": (207, 652, 233, 690),
|
||
"radio_on": (246, 652, 271, 690),
|
||
"radio_off": (284, 652, 309, 690),
|
||
"ico_lock": (323, 650, 353, 690),
|
||
"ico_unlock": (364, 650, 396, 690),
|
||
"ico_warning": (408, 650, 438, 690),
|
||
"ico_info": (449, 650, 479, 690),
|
||
"ico_plus": (491, 650, 520, 690),
|
||
"ico_minus": (532, 650, 560, 690),
|
||
"slot_empty": (573, 648, 610, 692),
|
||
"slot_selected": (625, 648, 662, 692),
|
||
"slot_locked": (675, 648, 715, 692),
|
||
"avatar_frame": (734, 640, 800, 698),
|
||
"rank_medal_1": (851, 648, 889, 692),
|
||
"rank_medal_2": (901, 648, 939, 692),
|
||
"rank_medal_3": (800, 648, 838, 692),
|
||
"ico_coin": (952, 648, 992, 692),
|
||
# --- menu / toast / business icons ---
|
||
"menu_bg": (15, 707, 176, 861),
|
||
"menu_item_normal": (18, 712, 172, 748),
|
||
"menu_item_hover": (18, 750, 172, 786),
|
||
"menu_item_disabled": (18, 788, 172, 824),
|
||
"toast_bg": (354, 716, 550, 750), # success green used as toast-like
|
||
"notice_bg": (354, 765, 550, 797),
|
||
"drag_handle": (581, 712, 609, 837), # vertical dotted strip as drag-like
|
||
"qr_scan_frame": (629, 718, 728, 824), # corner brackets / reticle
|
||
# icon grid 5 cols x 4 rows
|
||
# cols: 749-784, 798-838, 853-893, 907-946, 960-1002
|
||
# rows: 710-749, 758-791, 799-834, 839-875
|
||
"ico_server": (749, 710, 784, 749),
|
||
"ico_user": (798, 710, 838, 749),
|
||
"ico_chat": (853, 710, 893, 749),
|
||
"ico_mail": (907, 710, 946, 749),
|
||
"ico_settings": (960, 710, 1002, 749),
|
||
"ico_sound_on": (749, 758, 784, 791),
|
||
"ico_scan": (798, 758, 838, 791),
|
||
"ico_refresh": (853, 758, 893, 791),
|
||
"ico_download": (907, 758, 946, 791),
|
||
"ico_upload": (960, 758, 1002, 791),
|
||
"ico_bag": (749, 799, 784, 834),
|
||
"ico_sword": (798, 799, 838, 834),
|
||
"ico_shield": (853, 799, 893, 834),
|
||
"ico_map": (907, 799, 946, 834),
|
||
"ico_star": (960, 799, 1002, 834),
|
||
"ico_heart": (749, 839, 784, 875),
|
||
"ico_diamond": (798, 839, 838, 875),
|
||
"ico_key": (853, 839, 893, 875),
|
||
"ico_clock": (907, 839, 946, 875),
|
||
"ico_calendar": (960, 839, 1002, 875),
|
||
# reserved placeholders at bottom
|
||
"reserved_large_0": (14, 884, 345, 994),
|
||
"reserved_large_1": (356, 884, 644, 994),
|
||
"reserved_icon_grid": (656, 889, 1006, 990),
|
||
}
|
||
|
||
# Extra derived crops / aliases filled later in code when possible
|
||
# Items present on board but not 1:1 (documented in notes)
|
||
|
||
|
||
def make_transparent(crop: Image.Image, bg_rgb=(248, 236, 210), tol=28) -> Image.Image:
|
||
"""Clear only background pixels reachable from the image border (keeps cream panel fills)."""
|
||
import numpy as np
|
||
from collections import deque
|
||
|
||
rgba = np.array(crop.convert("RGBA"))
|
||
h, w = rgba.shape[:2]
|
||
br, bg, bb = bg_rgb
|
||
dist = (
|
||
np.abs(rgba[:, :, 0].astype(np.int16) - br)
|
||
+ np.abs(rgba[:, :, 1].astype(np.int16) - bg)
|
||
+ np.abs(rgba[:, :, 2].astype(np.int16) - bb)
|
||
)
|
||
is_bg = dist <= tol * 3
|
||
visited = np.zeros((h, w), dtype=bool)
|
||
q = deque()
|
||
for x in range(w):
|
||
for y in (0, h - 1):
|
||
if is_bg[y, x] and not visited[y, x]:
|
||
visited[y, x] = True
|
||
q.append((x, y))
|
||
for y in range(h):
|
||
for x in (0, w - 1):
|
||
if is_bg[y, x] and not visited[y, x]:
|
||
visited[y, x] = True
|
||
q.append((x, y))
|
||
while q:
|
||
x, y = q.popleft()
|
||
for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)):
|
||
if 0 <= nx < w and 0 <= ny < h and not visited[ny, nx] and is_bg[ny, nx]:
|
||
visited[ny, nx] = True
|
||
q.append((nx, ny))
|
||
rgba[visited, 3] = 0
|
||
return Image.fromarray(rgba, "RGBA")
|
||
|
||
def trim_alpha(im: Image.Image, pad: int = 2) -> Image.Image:
|
||
bbox = im.getbbox()
|
||
if not bbox:
|
||
return im
|
||
x0, y0, x1, y1 = bbox
|
||
x0 = max(0, x0 - pad)
|
||
y0 = max(0, y0 - pad)
|
||
x1 = min(im.width, x1 + pad)
|
||
y1 = min(im.height, y1 + pad)
|
||
return im.crop((x0, y0, x1, y1))
|
||
|
||
|
||
def export_one(src: Image.Image, name: str, box: tuple[int, int, int, int]) -> Path:
|
||
x0, y0, x1, y1 = box
|
||
x0 = max(0, x0)
|
||
y0 = max(0, y0)
|
||
x1 = min(src.width, x1)
|
||
y1 = min(src.height, y1)
|
||
crop = src.crop((x0, y0, x1, y1))
|
||
crop = make_transparent(crop)
|
||
crop = trim_alpha(crop, pad=1)
|
||
tw, th = TARGETS.get(name, (crop.width, crop.height))
|
||
# Keep aspect: fit into target canvas with transparent pad (Unity-friendly fixed size)
|
||
fitted = Image.new("RGBA", (tw, th), (0, 0, 0, 0))
|
||
# scale to fit
|
||
scale = min(tw / max(crop.width, 1), th / max(crop.height, 1))
|
||
nw = max(1, int(crop.width * scale))
|
||
nh = max(1, int(crop.height * scale))
|
||
resized = crop.resize((nw, nh), Image.Resampling.LANCZOS)
|
||
ox = (tw - nw) // 2
|
||
oy = (th - nh) // 2
|
||
fitted.paste(resized, (ox, oy), resized)
|
||
path = OUT / f"{name}.png"
|
||
fitted.save(path, "PNG")
|
||
return path
|
||
|
||
|
||
def main():
|
||
src = Image.open(SRC).convert("RGB")
|
||
|
||
# Derive toggle_knob from on-toggle right half circle-ish
|
||
if "toggle_bg_on" in CROPS:
|
||
x0, y0, x1, y1 = CROPS["toggle_bg_on"]
|
||
# knob approx right third
|
||
CROPS["toggle_knob"] = (x0 + (x1 - x0) * 2 // 3 - 4, y0 + 4, x1 - 2, y1 - 4)
|
||
|
||
# Derive ico_check from green home-alt / check circle if present
|
||
CROPS.setdefault("ico_check", (937, 417, 988, 471))
|
||
|
||
# btn_primary_hover: board has no dedicated hover — skip (missing)
|
||
# btn_small_hover: missing
|
||
# ico_sound_off: board only shows sound_on — missing
|
||
# ico_item: no dedicated item icon in grid (chest is in list, not grid) — missing
|
||
# input_caret: not on board — missing
|
||
|
||
exported = []
|
||
for name, box in sorted(CROPS.items()):
|
||
if name not in TARGETS:
|
||
continue
|
||
p = export_one(src, name, box)
|
||
exported.append(name)
|
||
print("OK", name, "->", p.name)
|
||
|
||
missing = sorted(set(TARGETS) - set(exported))
|
||
report = {
|
||
"source": str(SRC.name),
|
||
"out_dir": str(OUT.as_posix()),
|
||
"exported_count": len(exported),
|
||
"exported": exported,
|
||
"missing_count": len(missing),
|
||
"missing": missing,
|
||
"notes": [
|
||
"btn_primary_hover / btn_small_hover: style board only shows 3 button states (normal/pressed/disabled), no hover.",
|
||
"input_caret: no caret sprite on style board.",
|
||
"ico_sound_off: only sound-on speaker present in icon grid.",
|
||
"ico_item: no dedicated item/prop icon in the 5x4 grid (chest appears only inside attribute list).",
|
||
"Some crops (slider_handle, toggle_knob, avatar_frame, slots) are approximate extractions from dense rows.",
|
||
"Composite panels (main/popup) keep decorative header/close baked in; Unity may need further cleanup for pure 9-slice bases.",
|
||
"toast_bg cropped from Success banner; notice_bg from Notice banner. No separate plain toast shell without text.",
|
||
"reserved_* exported from dashed placeholder regions at bottom.",
|
||
"Exported PNGs are resized to UI_Pic_Set.md target WxH with transparent padding.",
|
||
],
|
||
}
|
||
report_path = ROOT / "slice_missing_report.json"
|
||
report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
|
||
|
||
md_path = ROOT / "MISSING.md"
|
||
lines = [
|
||
"# UI 切图缺失清单",
|
||
"",
|
||
f"来源:`{SRC.name}` → 输出目录:`slices/`",
|
||
"",
|
||
f"- 已导出:**{len(exported)}** / {len(TARGETS)}",
|
||
f"- 缺失:**{len(missing)}**",
|
||
"",
|
||
"## 缺失列表",
|
||
"",
|
||
]
|
||
for m in missing:
|
||
tw, th = TARGETS[m]
|
||
lines.append(f"- `{m}` (目标 {tw}x{th})")
|
||
lines += ["", "## 说明", ""]
|
||
for n in report["notes"]:
|
||
lines.append(f"- {n}")
|
||
md_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||
|
||
print("\nexported", len(exported), "missing", len(missing))
|
||
print("report", report_path)
|
||
print("missing md", md_path)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|