Files
AIC-Project/Doc/design/UI/slice_style_board.py
T

413 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()