Files
cc-web/.trellis/tasks/07-17-gilded-wasteland-theme/research/extract_reference_chrome.py
2026-07-18 10:14:38 +08:00

295 lines
11 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
"""从用户提供的设计稿中提取 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()