#!/usr/bin/env python3 """从用户提供的设计稿中提取 Wasteland 边框与实际尺寸图标。""" from pathlib import Path import json from PIL import Image, ImageFilter ROOT = Path('/home/cc-web') REFERENCE = ROOT / 'sessions/_attachments/f01c1388-b603-434a-b226-2ff5771d4925.webp' ICON_SHEET = ROOT / 'sessions/_attachments/43bd9ed6-2ba6-4c9d-bd9d-25b068cb4711.webp' WELCOME_MATERIAL = ROOT / 'sessions/_attachments/2b88b81d-c5a8-4d92-a518-afeea691eade.webp' ASSET_ROOT = ROOT / 'public/assets/themes/wasteland' FRAME_DIR = ASSET_ROOT / 'frames' ICON_DIR = ASSET_ROOT / 'icons/ui' TEXTURE_DIR = ASSET_ROOT / 'textures' def gold_alpha(pixel): r, g, b = pixel warm = max(0, min(r - b, g - b + 8)) light = max(0, (r + g) // 2 - 24) if r < 38 or g < 27 or warm < 8: return 0 return max(0, min(238, warm * 4 + light * 2)) def icon_alpha(pixel): r, g, b = pixel gold = r > 44 and g > 31 and r - b > 15 and g - b > 7 pale = r > 118 and g > 86 and r - b > 28 green = g > 58 and g - r > 5 and g - b > 10 if not (gold or pale or green): return 0 if green: score = (g - min(r, b)) * 4 + g else: score = (r - b) * 3 + (g - b) * 2 + (r + g) // 4 return max(0, min(255, score)) def crop_frame(source, name, box, zone): crop = source.crop(box).convert('RGB') width, height = crop.size rgba = Image.new('RGBA', crop.size, (0, 0, 0, 0)) src = crop.load() dst = rgba.load() for y in range(height): for x in range(width): if not zone(x, y, width, height): continue alpha = gold_alpha(src[x, y]) if alpha: r, g, b = src[x, y] dst[x, y] = (r, g, b, alpha) rgba.save(FRAME_DIR / f'{name}.png') return {'name': name, 'source_box': list(box), 'size': [width, height]} def extract_welcome_card(material): """从烘焙棋盘格的素材板中分离欢迎卡金属框和干净暗纹理。""" frame_box = (70, 0, 1165, 875) crop = material.crop(frame_box).convert('RGB') frame = Image.new('RGBA', crop.size, (0, 0, 0, 0)) src = crop.load() dst = frame.load() for y in range(crop.height): source_y = y + frame_box[1] for x in range(crop.width): source_x = x + frame_box[0] # 730px 以下只保留主框的中央下坠徽记,排除素材板的其他组件。 if source_y >= 730 and not 500 <= source_x <= 780: continue alpha = gold_alpha(src[x, y]) if alpha < 18: continue r, g, b = src[x, y] dst[x, y] = (r, g, b, alpha) frame.save(FRAME_DIR / 'welcome-card.png') surface_box = (350, 250, 950, 600) surface = material.crop(surface_box).convert('RGB') surface.save(TEXTURE_DIR / 'welcome-card-surface.webp', quality=92, method=6) return [ { 'name': 'welcome-card', 'source': WELCOME_MATERIAL.name, 'source_box': list(frame_box), 'size': list(frame.size), 'output': 'frames/welcome-card.png', }, { 'name': 'welcome-card-surface', 'source': WELCOME_MATERIAL.name, 'source_box': list(surface_box), 'size': list(surface.size), 'output': 'textures/welcome-card-surface.webp', }, ] def slot_box(card, variant): x, y, width, height = card bands = { '16': (x + 14, x + 74), '24': (x + 76, x + 150), '32': (x + 146, x + width - 12), } sx0, sx1 = bands[variant] return sx0, y + 30, sx1, min(y + 105, y + height - 24) def crop_icon(sheet, name, card, variant, canvas): sx0, sy0, sx1, sy1 = slot_box(card, variant) region = sheet.crop((sx0, sy0, sx1, sy1)).convert('RGB') width, height = region.size alpha = Image.new('L', region.size, 0) ap = alpha.load() rp = region.load() points = [] for y in range(height): for x in range(width): value = icon_alpha(rp[x, y]) if value: ap[x, y] = value points.append((x, y)) if not points: raise RuntimeError(f'没有检测到图标:{name}') left = max(0, min(p[0] for p in points) - 3) top = max(0, min(p[1] for p in points) - 3) right = min(width, max(p[0] for p in points) + 4) bottom = min(height, max(p[1] for p in points) + 4) icon = region.crop((left, top, right, bottom)).convert('RGBA') icon.putalpha(alpha.crop((left, top, right, bottom)).filter(ImageFilter.GaussianBlur(0.18))) target_w, target_h = canvas scale = min((target_w - 2) / icon.width, (target_h - 2) / icon.height) resized = icon.resize( (max(1, round(icon.width * scale)), max(1, round(icon.height * scale))), Image.Resampling.LANCZOS, ) output = Image.new('RGBA', canvas, (0, 0, 0, 0)) output.alpha_composite(resized, ((target_w - resized.width) // 2, (target_h - resized.height) // 2)) # 输入区图标按有效像素重心居中,避免纸飞机等非对称轮廓在按钮里视觉偏移。 if name in {'note', 'queue', 'send', 'stop'}: alpha = output.getchannel('A') pixels = alpha.load() total = weighted_x = weighted_y = 0 for py in range(target_h): for px in range(target_w): value = pixels[px, py] if not value: continue total += value weighted_x += px * value weighted_y += py * value if total: offset_x = round((target_w - 1) / 2 - weighted_x / total) offset_y = round((target_h - 1) / 2 - weighted_y / total) if offset_x or offset_y: centered = Image.new('RGBA', canvas, (0, 0, 0, 0)) centered.alpha_composite(output, (offset_x, offset_y)) output = centered output.save(ICON_DIR / f'{name}.png') return { 'name': name, 'variant': variant, 'slot_box': [sx0, sy0, sx1 - sx0, sy1 - sy0], 'detected_box': [sx0 + left, sy0 + top, right - left, bottom - top], 'size': [target_w, target_h], } def main(): FRAME_DIR.mkdir(parents=True, exist_ok=True) ICON_DIR.mkdir(parents=True, exist_ok=True) TEXTURE_DIR.mkdir(parents=True, exist_ok=True) reference = Image.open(REFERENCE).convert('RGB') sheet = Image.open(ICON_SHEET).convert('RGB') welcome_material = Image.open(WELCOME_MATERIAL).convert('RGB') border_zone = lambda x, y, w, h: x < 8 or x >= w - 8 or y < 8 or y >= h - 8 frames = [ crop_frame(reference, 'new-chat', (15, 9, 304, 58), lambda x, y, w, h: border_zone(x, y, w, h) or 225 <= x <= 239), crop_frame(reference, 'search', (15, 67, 304, 103), border_zone), crop_frame(reference, 'project', (15, 117, 304, 164), border_zone), crop_frame(reference, 'active-session', (15, 163, 304, 209), lambda x, y, w, h: x < 12 or x >= w - 7 or y < 7 or y >= h - 7), crop_frame( reference, 'composer', (557, 830, 1364, 931), lambda x, y, w, h: ( (y < 10) or (x < 10 and y < 77) or (x >= w - 10 and y < 77) or (64 <= y < 79) or (w // 2 - 56 <= x <= w // 2 + 56 and y >= 58) ), ), crop_frame(reference, 'composer-action', (1211, 845, 1257, 891), border_zone), crop_frame(reference, 'tool-call', (421, 603, 1381, 664), border_zone), ] # 设计稿输入框含两层顶线。UI 叠加时只保留内侧主蚀刻线, # 同时对称收紧左右 5px,使主竖线贴合真实容器边界;徽记保持几何中心不变。 composer_path = FRAME_DIR / 'composer.png' composer = Image.open(composer_path).convert('RGBA') composer = composer.crop((5, 5, composer.width - 5, composer.height)) composer = composer.resize((807, 101), Image.Resampling.LANCZOS) # 设计稿原始裁区的中心徽记视觉中心比画布中心向右约 8px。 # 只校正下半部装饰层,保留左右框边和顶部线的原始几何位置。 centered_composer = composer.copy() ornament_region = (96, 50, 711, 101) centered_composer.paste((0, 0, 0, 0), ornament_region) centered_composer.alpha_composite(composer.crop(ornament_region), (88, 50)) composer = centered_composer composer.save(composer_path) # 输入框不能把 807×101 的整图同时做横纵拉伸:窄窗口会让底部徽记 # 与真实 74px 框体错层。拆出只含设计稿顶部/侧边的框壳和独立徽记, # 由 CSS 分别控制尺寸,保证任意宽度下都只出现一条边框。 shell_height = 74 shell = Image.new('RGBA', (composer.width, shell_height), (0, 0, 0, 0)) composer_pixels = composer.load() shell_pixels = shell.load() for y in range(shell_height): for x in range(composer.width): if y < 14 or x < 14 or x >= composer.width - 14: shell_pixels[x, y] = composer_pixels[x, y] shell.save(FRAME_DIR / 'composer-shell.png') ornament_box = (130, 50, 677, 101) composer.crop(ornament_box).save(FRAME_DIR / 'composer-ornament.png') frames.extend([ { 'name': 'composer-shell', 'derived_from': 'composer.png', 'source_box': [0, 0, composer.width, shell_height], 'size': [composer.width, shell_height], }, { 'name': 'composer-ornament', 'derived_from': 'composer.png', 'source_box': [ ornament_box[0], ornament_box[1], ornament_box[2] - ornament_box[0], ornament_box[3] - ornament_box[1], ], 'size': [ornament_box[2] - ornament_box[0], ornament_box[3] - ornament_box[1]], }, ]) frames.extend(extract_welcome_card(welcome_material)) cards = { 'search': (582, 50, 247, 137), 'dropdown': (315, 50, 247, 137), 'theme': (50, 201, 245, 138), 'ai': (315, 201, 247, 138), 'status': (582, 201, 247, 138), 'attachment': (1111, 201, 247, 138), 'send': (1377, 201, 244, 138), 'queue': (846, 201, 246, 138), 'stop': (50, 354, 245, 136), 'settings': (315, 354, 247, 136), 'user': (582, 354, 247, 136), 'terminal': (846, 354, 246, 136), 'note': (582, 506, 247, 138), } specs = [ ('search', '16', (20, 20)), ('dropdown', '16', (14, 10)), ('theme', '24', (26, 26)), ('ai', '32', (36, 36)), ('status', '16', (16, 16)), ('attachment', '24', (26, 28)), ('send', '32', (30, 30)), ('queue', '32', (30, 30)), ('stop', '24', (26, 26)), ('settings', '16', (20, 20)), ('user', '32', (36, 36)), ('terminal', '16', (20, 18)), ('note', '32', (36, 36)), ] icons = [crop_icon(sheet, name, cards[name], variant, canvas) for name, variant, canvas in specs] (FRAME_DIR / 'manifest.json').write_text(json.dumps(frames, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') (ICON_DIR / 'manifest.json').write_text(json.dumps(icons, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') if __name__ == '__main__': main()