Files
game-cards-poker-design/backend/apps/projects/management/commands/seed_library.py
Developer 5ca000b8ab feat: 预设素材库 - 4 套主题 × 4 张图 = 16 张 PNG
主题:classical 古典宫廷(王子/皇后/国王/小丑)
      modern    现代人物(小孩/女青年/男青年/小丑鱼)
      astronomy 天文(星星/月亮/太阳/黑洞)
      minimal   简笔符号(圆点/♀/♂/叉)

改动:
- 新增 LibraryAsset 模型(全局素材库,theme_id/role/asset_id 索引)
- 新增 seed_library 管理命令,用 Pillow 画 16 张 PNG 素材
- 新增 /api/projects/library/ 列表 API
- 新增 /api/projects/{pid}/library/{id}/apply/ 应用 API
  把预设素材复制到 projects/<pid>/joker 或 face_card 下,作为该牌位的素材
- AssetPanel 加 tab 切换:「我的素材」+「预设主题」,主题可筛选、点击套用到当前牌
- 修复 generate_card_png 的 joker asset 匹配 bug:which 应该是 card_key(含前缀)才能匹配 asset_key

设计要点:
- 预设素材只画上半身(y=0~150),下半留空,让系统的'自动对称'流水线正确工作
- 预设素材 PNG 200×300,深色描边 + 半透明浅色填充,在任意牌面背景上叠加都清晰
2026-06-02 14:39:52 +08:00

429 lines
17 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.
"""
初始化预设素材库:用 Pillow 直接画 PNG200×300存到 media/library/,并写入 LibraryAsset 表。
主题4 套 × 4 张 = 16 张):
classical 古典宫廷:王子/皇后/国王/宫廷小丑
modern 现代人物:小孩/女青年/男青年/小丑鱼
astronomy 天文: 星星/月亮/太阳/黑洞
minimal 简笔符号:圆点/♀/♂/叉
风格:深色描边 4px + 半透明浅色填充,便于在任意牌面背景上叠加。
"""
import os
import math
from django.core.management.base import BaseCommand
from django.conf import settings
from apps.projects.models import LibraryAsset
from PIL import Image, ImageDraw
W, H = 200, 300
STROKE = (26, 26, 26, 255)
STROKE_W = 4
# 关键:素材主体只画在 y=20~200上半身y=200~300 留空。
# 因为后端会把整图 fill 到 body 区域,再切上半 + 翻转下半拼成上下对称,
# 所以素材本身只需要"上半身",下半由系统自动翻转生成。
BODY_TOP = 20
BODY_BOTTOM = 200
def new_canvas():
img = Image.new('RGBA', (W, H), (255, 255, 255, 0))
return img, ImageDraw.Draw(img)
def shift_up(img, dy=-30):
"""把图整体上移 dy 像素,下方留空。
因为素材只需要画上半身,下半由系统对称生成。"""
out = Image.new('RGBA', (W, H), (0, 0, 0, 0))
out.paste(img, (0, dy), img)
return out
def crop_to_upper(img):
"""把图裁到 y=0~150 区域(只保留上半身),下面 150px 留空。
后端 fill 整图后切上下半 = 上半=原图上半(0~150),下半=原图下半(150~300 留空)
→ 翻转下半也是空白 → 整张牌上半身 + 空白(不会有奇怪的下半)"""
out = Image.new('RGBA', (W, H), (0, 0, 0, 0))
upper = img.crop((0, 0, W, 150))
out.paste(upper, (0, 0), upper)
return out
# ----------------- 古典宫廷 -----------------
def classical_prince():
img, d = new_canvas()
# 头发
d.polygon([(50, 110), (55, 130), (145, 130), (150, 110), (145, 100), (55, 100)],
fill=(180, 140, 80, 100), outline=STROKE, width=STROKE_W)
# 脸
d.ellipse((60, 100, 140, 180), fill=(255, 224, 189, 150), outline=STROKE, width=STROKE_W)
# 眼睛
d.ellipse((85, 134, 91, 140), fill=STROKE)
d.ellipse((109, 134, 115, 140), fill=STROKE)
# 嘴
d.arc((90, 148, 110, 162), start=0, end=180, fill=STROKE, width=3)
# 小皇冠
d.polygon([(70, 78), (78, 60), (86, 75), (100, 55), (114, 75), (122, 60), (130, 78)],
fill=(255, 220, 80, 130), outline=STROKE, width=STROKE_W)
d.ellipse((96, 52, 104, 60), fill=(255, 220, 80, 200), outline=STROKE, width=2)
# 衣领
d.line([(60, 200), (100, 220), (140, 200)], fill=STROKE, width=STROKE_W)
return img
def classical_queen():
img, d = new_canvas()
# 长发(披肩)
d.polygon([(45, 150), (50, 220), (150, 220), (155, 150),
(160, 100), (140, 80), (60, 80), (40, 100)],
fill=(200, 80, 80, 100), outline=STROKE, width=STROKE_W)
# 脸
d.ellipse((62, 102, 138, 178), fill=(255, 224, 189, 150), outline=STROKE, width=STROKE_W)
# 眼睛
d.ellipse((85, 134, 91, 140), fill=STROKE)
d.ellipse((109, 134, 115, 140), fill=STROKE)
# 嘴
d.arc((90, 150, 110, 164), start=0, end=180, fill=(200, 60, 60, 255), width=3)
# 高尖皇冠
d.polygon([(75, 75), (100, 30), (125, 75)],
fill=(255, 220, 80, 130), outline=STROKE, width=STROKE_W)
d.ellipse((96, 30, 104, 40), fill=(255, 220, 80, 200), outline=STROKE, width=2)
# 皇冠装饰线
d.arc((80, 65, 120, 85), start=0, end=180, fill=STROKE, width=2)
return img
def classical_king():
img, d = new_canvas()
# 头发
d.polygon([(55, 115), (58, 135), (142, 135), (145, 115), (140, 100), (60, 100)],
fill=(120, 90, 60, 100), outline=STROKE, width=STROKE_W)
# 脸
d.ellipse((60, 100, 140, 180), fill=(255, 224, 189, 150), outline=STROKE, width=STROKE_W)
# 眼睛
d.ellipse((85, 130, 91, 136), fill=STROKE)
d.ellipse((109, 130, 115, 136), fill=STROKE)
# 嘴
d.line([(88, 152), (112, 152)], fill=STROKE, width=3)
# 大胡子
d.polygon([(75, 158), (80, 178), (100, 185), (120, 178), (125, 158),
(115, 170), (100, 175), (85, 170)],
fill=(120, 90, 60, 130), outline=STROKE, width=STROKE_W)
# 厚重皇冠
d.polygon([(65, 88), (70, 65), (85, 80), (100, 50), (115, 80), (130, 65), (135, 88)],
fill=(255, 220, 80, 130), outline=STROKE, width=STROKE_W)
# 皇冠红宝石
d.rectangle((95, 70, 105, 76), fill=(200, 40, 40, 200), outline=STROKE, width=1)
# 衣领
d.line([(55, 205), (100, 225), (145, 205)], fill=STROKE, width=STROKE_W)
return img
def classical_joker():
img, d = new_canvas()
# 双角帽
d.polygon([(60, 95), (70, 35), (80, 85)], fill=(220, 40, 40, 130), outline=STROKE, width=STROKE_W)
d.polygon([(140, 95), (130, 35), (120, 85)], fill=(40, 80, 200, 130), outline=STROKE, width=STROKE_W)
d.ellipse((65, 30, 75, 40), fill=(220, 40, 40, 200), outline=STROKE, width=2)
d.ellipse((125, 30, 135, 40), fill=(40, 80, 200, 200), outline=STROKE, width=2)
# 脸
d.ellipse((58, 93, 142, 177), fill=(255, 235, 200, 180), outline=STROKE, width=STROKE_W)
# 菱形眼
d.polygon([(80, 130), (86, 125), (92, 130), (86, 135)], fill=STROKE)
d.polygon([(108, 130), (114, 125), (120, 130), (114, 135)], fill=STROKE)
# 大嘴
d.arc((78, 148, 122, 175), start=0, end=180, fill=(200, 40, 40, 80), width=3)
d.arc((78, 148, 122, 175), start=0, end=180, fill=STROKE, width=3)
# 领结
d.polygon([(90, 200), (100, 195), (110, 200), (100, 215)], fill=(220, 40, 40, 200), outline=STROKE, width=2)
return img
# ----------------- 现代人物 -----------------
def modern_child():
img, d = new_canvas()
# 短发
d.chord((55, 80, 145, 115), start=180, end=360, fill=(60, 60, 60, 130), outline=STROKE, width=STROKE_W)
# 脸
d.ellipse((62, 97, 138, 173), fill=(255, 225, 190, 180), outline=STROKE, width=STROKE_W)
# 大眼睛
d.ellipse((83, 128, 93, 138), fill=STROKE)
d.ellipse((107, 128, 117, 138), fill=STROKE)
# 腮红
d.ellipse((73, 145, 83, 155), fill=(255, 150, 150, 100))
d.ellipse((117, 145, 127, 155), fill=(255, 150, 150, 100))
# 嘴
d.arc((93, 150, 107, 160), start=0, end=180, fill=STROKE, width=3)
return img
def modern_young_woman():
img, d = new_canvas()
# 长发
d.polygon([(45, 150), (50, 240), (150, 240), (155, 150),
(160, 90), (140, 80), (60, 80), (40, 90)],
fill=(80, 40, 30, 130), outline=STROKE, width=STROKE_W)
# 脸(椭圆)
d.ellipse((65, 98, 135, 178), fill=(255, 225, 190, 180), outline=STROKE, width=STROKE_W)
# 眉
d.arc((80, 116, 96, 128), start=200, end=340, fill=STROKE, width=3)
d.arc((104, 116, 120, 128), start=200, end=340, fill=STROKE, width=3)
# 眼睛
d.ellipse((85, 132, 91, 138), fill=STROKE)
d.ellipse((109, 132, 115, 138), fill=STROKE)
# 嘴
d.arc((90, 152, 110, 166), start=0, end=180, fill=STROKE, width=3)
return img
def modern_young_man():
img, d = new_canvas()
# 短发
d.chord((55, 80, 145, 115), start=180, end=360, fill=(40, 30, 20, 130), outline=STROKE, width=STROKE_W)
# 脸(方下颌:圆角矩形近似)
d.rounded_rectangle((65, 100, 135, 180), radius=18, fill=(255, 225, 190, 180), outline=STROKE, width=STROKE_W)
# 眉
d.line([(78, 122), (98, 122)], fill=STROKE, width=3)
d.line([(102, 122), (122, 122)], fill=STROKE, width=3)
# 眼睛
d.ellipse((85, 133, 91, 139), fill=STROKE)
d.ellipse((109, 133, 115, 139), fill=STROKE)
# 嘴
d.line([(90, 158), (110, 158)], fill=STROKE, width=3)
# 胡茬点
d.ellipse((88, 168, 92, 172), fill=STROKE)
d.ellipse((98, 170, 102, 174), fill=STROKE)
d.ellipse((108, 168, 112, 172), fill=STROKE)
return img
def modern_clownfish():
img, d = new_canvas()
# 鱼身
d.ellipse((45, 112, 155, 188), fill=(255, 140, 40, 130), outline=STROKE, width=STROKE_W)
# 嘴部
d.polygon([(55, 150), (40, 145), (40, 155)], fill=(255, 140, 40, 150), outline=STROKE, width=STROKE_W)
# 眼睛(白底+黑瞳)
d.ellipse((58, 125, 74, 141), fill=(255, 255, 255, 230), outline=STROKE, width=2)
d.ellipse((62, 130, 70, 138), fill=STROKE)
# 白条纹
d.arc((75, 110, 90, 200), start=270, end=90, fill=(255, 255, 255, 200), width=8)
d.arc((100, 105, 115, 200), start=270, end=90, fill=(255, 255, 255, 200), width=6)
# 背鳍
d.polygon([(85, 112), (100, 88), (115, 112)], fill=(255, 140, 40, 130), outline=STROKE, width=STROKE_W)
# 腹鳍
d.polygon([(90, 188), (95, 208), (105, 188)], fill=(255, 140, 40, 130), outline=STROKE, width=STROKE_W)
# 尾鳍
d.polygon([(152, 150), (180, 120), (180, 180)], fill=(255, 140, 40, 130), outline=STROKE, width=STROKE_W)
return img
# ----------------- 天文 -----------------
def astronomy_star():
img, d = new_canvas()
cx, cy = 100, 140
r_out, r_in = 60, 25
pts = []
for i in range(10):
a = -math.pi / 2 + i * math.pi / 5
r = r_out if i % 2 == 0 else r_in
pts.append((cx + r * math.cos(a), cy + r * math.sin(a)))
d.polygon(pts, fill=(255, 220, 80, 130), outline=STROKE, width=STROKE_W)
# 中心高光
d.ellipse((94, 134, 106, 146), fill=(255, 255, 200, 200), outline=STROKE, width=1)
return img
def astronomy_moon():
img, d = new_canvas()
# 大圆
d.ellipse((45, 80, 155, 220), fill=(240, 240, 210, 180), outline=STROKE, width=STROKE_W)
# 遮挡圆(右侧偏移实现弯月)
d.ellipse((75, 80, 165, 220), fill=(0, 0, 0, 0)) # 透明
# 用图层减法实现:在 PIL 中不好做,直接画月牙
img2 = Image.new('RGBA', (W, H), (255, 255, 255, 0))
d2 = ImageDraw.Draw(img2)
d2.ellipse((45, 80, 155, 220), fill=(240, 240, 210, 180), outline=STROKE, width=STROKE_W)
# 透明圆覆盖(用镂空 mask
mask = Image.new('L', (W, H), 0)
dm = ImageDraw.Draw(mask)
dm.ellipse((80, 80, 165, 220), fill=255)
out = Image.new('RGBA', (W, H), (0, 0, 0, 0))
out.paste(img2, (0, 0), mask)
d = ImageDraw.Draw(out)
d.ellipse((45, 80, 155, 220), outline=STROKE, width=STROKE_W)
# 表面纹理
d.ellipse((85, 125, 95, 135), fill=(180, 180, 160, 130))
d.ellipse((95, 165, 102, 172), fill=(180, 180, 160, 130))
d.ellipse((75, 155, 82, 162), fill=(180, 180, 160, 130))
return out
def astronomy_sun():
img, d = new_canvas()
# 8 道光芒
rays = [
(100, 55, 100, 80),
(100, 220, 100, 245),
(30, 150, 55, 150),
(145, 150, 170, 150),
(50, 100, 68, 118),
(132, 118, 150, 100),
(50, 200, 68, 182),
(132, 182, 150, 200),
]
for x1, y1, x2, y2 in rays:
d.line([(x1, y1), (x2, y2)], fill=STROKE, width=5)
# 太阳主体
d.ellipse((55, 105, 145, 195), fill=(255, 180, 40, 150), outline=STROKE, width=STROKE_W)
# 笑眼
d.arc((73, 128, 88, 143), start=200, end=340, fill=STROKE, width=3)
d.arc((112, 128, 127, 143), start=200, end=340, fill=STROKE, width=3)
# 嘴
d.arc((85, 155, 115, 175), start=0, end=180, fill=STROKE, width=3)
return img
def astronomy_blackhole():
img, d = new_canvas()
# 吸积盘(两层椭圆)
d.ellipse((30, 128, 170, 172), fill=(120, 60, 200, 100), outline=STROKE, width=STROKE_W)
d.ellipse((42, 134, 158, 166), fill=(200, 100, 50, 130), outline=STROKE, width=STROKE_W)
# 中心黑洞
d.ellipse((78, 128, 122, 172), fill=(0, 0, 0, 240), outline=STROKE, width=STROKE_W)
# 高光
d.ellipse((85, 134, 115, 142), fill=(255, 180, 80, 200))
return img
# ----------------- 简笔 -----------------
def minimal_dot():
img, d = new_canvas()
d.ellipse((45, 95, 155, 205), fill=(80, 180, 200, 150), outline=STROKE, width=STROKE_W)
# 内圈虚线
d.ellipse((65, 115, 135, 185), outline=STROKE, width=2)
# 笑脸
d.ellipse((82, 138, 88, 144), fill=STROKE)
d.ellipse((112, 138, 118, 144), fill=STROKE)
d.arc((85, 155, 115, 170), start=0, end=180, fill=STROKE, width=3)
return img
def minimal_venus():
img, d = new_canvas()
d.ellipse((60, 80, 140, 160), fill=(220, 80, 140, 150), outline=STROKE, width=STROKE_W)
d.line([(100, 160), (100, 230)], fill=STROKE, width=8)
d.line([(80, 200), (120, 200)], fill=STROKE, width=8)
return img
def minimal_mars():
img, d = new_canvas()
d.ellipse((40, 120, 120, 200), fill=(60, 120, 220, 150), outline=STROKE, width=STROKE_W)
d.line([(115, 120), (155, 80)], fill=STROKE, width=6)
d.line([(135, 75), (158, 75)], fill=STROKE, width=6)
d.line([(158, 75), (158, 98)], fill=STROKE, width=6)
return img
def minimal_cross():
img, d = new_canvas()
# 双色叉:先画浅红底,再深色描边
d.line([(55, 95), (155, 225)], fill=(200, 40, 40, 130), width=14)
d.line([(155, 95), (55, 225)], fill=(200, 40, 40, 130), width=14)
d.line([(55, 95), (155, 225)], fill=STROKE, width=4)
d.line([(155, 95), (55, 225)], fill=STROKE, width=4)
return img
# 元数据
THEMES = [
{
'theme_id': 'classical', 'theme_name': '古典宫廷',
'description': '王子/皇后/国王/宫廷小丑,传统扑克牌',
'items': [
('J', 'prince', classical_prince, '王子'),
('Q', 'queen', classical_queen, '皇后'),
('K', 'king', classical_king, '国王'),
('joker', 'jester', classical_joker, '小丑'),
],
},
{
'theme_id': 'modern', 'theme_name': '现代人物',
'description': '小孩/女青年/男青年/小丑鱼,日常生活',
'items': [
('J', 'child', modern_child, '小孩'),
('Q', 'young_woman', modern_young_woman, '女青年'),
('K', 'young_man', modern_young_man, '男青年'),
('joker', 'clownfish', modern_clownfish, '小丑鱼'),
],
},
{
'theme_id': 'astronomy', 'theme_name': '天文',
'description': '星星/月亮/太阳/黑洞',
'items': [
('J', 'star', astronomy_star, '星星'),
('Q', 'moon', astronomy_moon, '月亮'),
('K', 'sun', astronomy_sun, '太阳'),
('joker', 'blackhole', astronomy_blackhole, '黑洞'),
],
},
{
'theme_id': 'minimal', 'theme_name': '简笔符号',
'description': '圆点/♀/♂/×,极简风格',
'items': [
('J', 'dot', minimal_dot, '圆点'),
('Q', 'venus', minimal_venus, ''),
('K', 'mars', minimal_mars, ''),
('joker', 'cross', minimal_cross, '×'),
],
},
]
class Command(BaseCommand):
help = '用 Pillow 画 16 张预设素材 PNG存到 media/library/,并写入 LibraryAsset 表'
def handle(self, *args, **options):
lib_dir = os.path.join(settings.MEDIA_ROOT, 'library')
os.makedirs(lib_dir, exist_ok=True)
written = 0
for theme in THEMES:
theme_dir = os.path.join(lib_dir, theme['theme_id'])
os.makedirs(theme_dir, exist_ok=True)
for role, asset_id, gen_fn, role_name in theme['items']:
img = gen_fn()
# 上移 30 让主体更靠上,再裁掉 y=150 之后(只留上半身)
img = shift_up(img, dy=-30)
img = crop_to_upper(img)
file_name = f'{asset_id}.png'
file_path = os.path.join(theme_dir, file_name)
img.save(file_path)
rel_path = f'library/{theme["theme_id"]}/{file_name}'
label = f'{theme["theme_name"]}·{role_name}'
obj, created = LibraryAsset.objects.update_or_create(
theme_id=theme['theme_id'],
asset_id=asset_id,
defaults=dict(
theme_name=theme['theme_name'],
role=role,
role_name=role_name,
label=label,
description=theme['description'],
file_path=rel_path,
file_name=file_name,
),
)
verb = 'created' if created else 'updated'
self.stdout.write(f' [{verb}] {theme["theme_id"]}/{file_name} -> {label}')
written += 1
self.stdout.write(self.style.SUCCESS(
f'\n已生成 {written} 张 PNG 素材({len(THEMES)} 套主题)\n'
f'文件目录: media/library/\n'
f'数据库表: apps_projects_libraryasset'
))