295 lines
11 KiB
Python
295 lines
11 KiB
Python
#!/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()
|