#!/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()