""" 初始化预设素材库:用 Pillow 直接画 PNG(200×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' ))