296 lines
12 KiB
Python
296 lines
12 KiB
Python
from __future__ import annotations
|
||
|
||
import math
|
||
from pathlib import Path
|
||
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||
OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-abstract-mascot-concepts"
|
||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-abstract-mascot-contact-sheet.png"
|
||
|
||
SIZE = 1024
|
||
SCALE = 4
|
||
|
||
INK = "#121212"
|
||
INK_BLUE = "#101418"
|
||
CREAM = "#fff5df"
|
||
CLAY = "#d77750"
|
||
CLAY_DARK = "#b95f3f"
|
||
GOLD = "#ffd25d"
|
||
MINT = "#31c7a9"
|
||
CORAL = "#ff6a5f"
|
||
|
||
VARIANTS = [
|
||
("taonier-abstract-mascot-clay-bean", "陶泥豆偶", "clay_bean"),
|
||
("taonier-abstract-mascot-mold-baby", "模胚小灵", "mold_baby"),
|
||
("taonier-abstract-mascot-dot-face", "泥点面偶", "dot_face"),
|
||
("taonier-abstract-mascot-soft-totem", "软陶图腾", "soft_totem"),
|
||
("taonier-abstract-mascot-clay-seed", "陶泥种子", "clay_seed"),
|
||
("taonier-abstract-mascot-work-puppet", "作品泥灵", "work_puppet"),
|
||
]
|
||
|
||
|
||
def hex_to_rgb(value: str) -> tuple[int, int, int]:
|
||
value = value.removeprefix("#")
|
||
return tuple(int(value[index : index + 2], 16) for index in (0, 2, 4))
|
||
|
||
|
||
def s(value: float) -> int:
|
||
return round(value * SCALE)
|
||
|
||
|
||
def circle(draw: ImageDraw.ImageDraw, cx: float, cy: float, radius: float, fill: str) -> None:
|
||
draw.ellipse((s(cx - radius), s(cy - radius), s(cx + radius), s(cy + radius)), fill=hex_to_rgb(fill))
|
||
|
||
|
||
def rounded_rect(
|
||
draw: ImageDraw.ImageDraw,
|
||
box: tuple[float, float, float, float],
|
||
radius: float,
|
||
fill: str,
|
||
) -> None:
|
||
draw.rounded_rectangle(tuple(s(value) for value in box), radius=s(radius), fill=hex_to_rgb(fill))
|
||
|
||
|
||
def polygon(cx: float, cy: float, radius: float, sides: int, rotation: float) -> list[tuple[int, int]]:
|
||
return [
|
||
(s(cx + math.cos(rotation + math.tau * index / sides) * radius), s(cy + math.sin(rotation + math.tau * index / sides) * radius))
|
||
for index in range(sides)
|
||
]
|
||
|
||
|
||
def star_points(cx: float, cy: float, outer: float, inner: float, count: int = 4) -> list[tuple[int, int]]:
|
||
points = []
|
||
for index in range(count * 2):
|
||
radius = outer if index % 2 == 0 else inner
|
||
angle = -math.pi / 2 + index * math.pi / count
|
||
points.append((s(cx + math.cos(angle) * radius), s(cy + math.sin(angle) * radius)))
|
||
return points
|
||
|
||
|
||
def draw_clay_bean(draw: ImageDraw.ImageDraw) -> None:
|
||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#101010"))
|
||
rounded_rect(draw, (270, 214, 754, 820), 228, CREAM)
|
||
circle(draw, 650, 330, 112, CLAY)
|
||
circle(draw, 676, 354, 86, CREAM)
|
||
circle(draw, 430, 470, 34, INK)
|
||
circle(draw, 590, 470, 34, INK)
|
||
draw.polygon(star_points(512, 618, 66, 28), fill=hex_to_rgb(GOLD))
|
||
rounded_rect(draw, (360, 742, 664, 790), 24, CLAY)
|
||
|
||
|
||
def draw_mold_baby(draw: ImageDraw.ImageDraw) -> None:
|
||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb(INK_BLUE))
|
||
draw.polygon(polygon(512, 498, 304, 8, math.pi / 8), fill=hex_to_rgb(CREAM))
|
||
circle(draw, 512, 398, 126, "#101418")
|
||
circle(draw, 512, 398, 62, GOLD)
|
||
circle(draw, 390, 570, 30, "#101418")
|
||
circle(draw, 634, 570, 30, "#101418")
|
||
rounded_rect(draw, (380, 704, 644, 758), 27, MINT)
|
||
rounded_rect(draw, (318, 268, 460, 326), 29, CORAL)
|
||
|
||
|
||
def draw_dot_face(draw: ImageDraw.ImageDraw) -> None:
|
||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#17110e"))
|
||
rounded_rect(draw, (254, 254, 770, 770), 190, CLAY)
|
||
rounded_rect(draw, (330, 320, 694, 706), 140, CREAM)
|
||
circle(draw, 440, 478, 30, "#17110e")
|
||
circle(draw, 584, 478, 30, "#17110e")
|
||
rounded_rect(draw, (458, 594, 566, 638), 22, CLAY)
|
||
circle(draw, 512, 254, 54, GOLD)
|
||
circle(draw, 512, 254, 24, "#17110e")
|
||
|
||
|
||
def draw_soft_totem(draw: ImageDraw.ImageDraw) -> None:
|
||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#111111"))
|
||
rounded_rect(draw, (340, 188, 684, 836), 172, CREAM)
|
||
circle(draw, 512, 336, 112, CLAY)
|
||
rounded_rect(draw, (390, 442, 634, 722), 122, "#111111")
|
||
circle(draw, 512, 582, 58, GOLD)
|
||
circle(draw, 432, 332, 24, INK)
|
||
circle(draw, 592, 332, 24, INK)
|
||
rounded_rect(draw, (404, 782, 620, 842), 30, CREAM)
|
||
|
||
|
||
def draw_clay_seed(draw: ImageDraw.ImageDraw) -> None:
|
||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#101418"))
|
||
draw.pieslice((s(236), s(176), s(788), s(832)), start=218, end=578, fill=hex_to_rgb(CREAM))
|
||
circle(draw, 618, 326, 72, "#101418")
|
||
circle(draw, 618, 326, 34, GOLD)
|
||
circle(draw, 438, 488, 30, "#101418")
|
||
rounded_rect(draw, (506, 548, 632, 594), 23, CLAY)
|
||
draw.polygon(star_points(512, 682, 58, 24), fill=hex_to_rgb(GOLD))
|
||
|
||
|
||
def draw_work_puppet(draw: ImageDraw.ImageDraw) -> None:
|
||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#101010"))
|
||
rounded_rect(draw, (286, 300, 738, 756), 150, CREAM)
|
||
circle(draw, 286, 530, 88, "#101010")
|
||
circle(draw, 738, 530, 88, "#101010")
|
||
circle(draw, 512, 300, 76, GOLD)
|
||
circle(draw, 430, 474, 24, "#101010")
|
||
circle(draw, 594, 474, 24, "#101010")
|
||
draw.polygon(star_points(512, 604, 68, 28), fill=hex_to_rgb("#101010"))
|
||
draw.polygon(star_points(512, 604, 34, 14), fill=hex_to_rgb(GOLD))
|
||
rounded_rect(draw, (360, 744, 664, 798), 27, CLAY)
|
||
|
||
|
||
DRAWERS = {
|
||
"clay_bean": draw_clay_bean,
|
||
"mold_baby": draw_mold_baby,
|
||
"dot_face": draw_dot_face,
|
||
"soft_totem": draw_soft_totem,
|
||
"clay_seed": draw_clay_seed,
|
||
"work_puppet": draw_work_puppet,
|
||
}
|
||
|
||
|
||
def render_variant(style: str) -> Image.Image:
|
||
image = Image.new("RGB", (SIZE * SCALE, SIZE * SCALE), hex_to_rgb("#111111"))
|
||
draw = ImageDraw.Draw(image)
|
||
DRAWERS[style](draw)
|
||
return image.resize((SIZE, SIZE), Image.Resampling.LANCZOS)
|
||
|
||
|
||
def build_svg(style: str) -> str:
|
||
# PNG 是评审主物料;SVG 保留几何结构,供后续人工矢量微调。
|
||
if style == "clay_bean":
|
||
body = f'''
|
||
<rect width="1024" height="1024" rx="160" fill="#101010"/>
|
||
<rect x="270" y="214" width="484" height="606" rx="228" fill="{CREAM}"/>
|
||
<circle cx="650" cy="330" r="112" fill="{CLAY}"/>
|
||
<circle cx="676" cy="354" r="86" fill="{CREAM}"/>
|
||
<circle cx="430" cy="470" r="34" fill="{INK}"/>
|
||
<circle cx="590" cy="470" r="34" fill="{INK}"/>
|
||
<path d="M512 552L540 590L578 618L540 646L512 684L484 646L446 618L484 590Z" fill="{GOLD}"/>
|
||
<rect x="360" y="742" width="304" height="48" rx="24" fill="{CLAY}"/>'''
|
||
elif style == "mold_baby":
|
||
body = f'''
|
||
<rect width="1024" height="1024" rx="160" fill="{INK_BLUE}"/>
|
||
<path d="M628 217L759 272L814 402L759 724L628 779H396L265 724L210 402L265 272L396 217Z" fill="{CREAM}"/>
|
||
<circle cx="512" cy="398" r="126" fill="{INK_BLUE}"/>
|
||
<circle cx="512" cy="398" r="62" fill="{GOLD}"/>
|
||
<circle cx="390" cy="570" r="30" fill="{INK_BLUE}"/>
|
||
<circle cx="634" cy="570" r="30" fill="{INK_BLUE}"/>
|
||
<rect x="380" y="704" width="264" height="54" rx="27" fill="{MINT}"/>
|
||
<rect x="318" y="268" width="142" height="58" rx="29" fill="{CORAL}"/>'''
|
||
elif style == "dot_face":
|
||
body = f'''
|
||
<rect width="1024" height="1024" rx="160" fill="#17110e"/>
|
||
<rect x="254" y="254" width="516" height="516" rx="190" fill="{CLAY}"/>
|
||
<rect x="330" y="320" width="364" height="386" rx="140" fill="{CREAM}"/>
|
||
<circle cx="440" cy="478" r="30" fill="#17110e"/>
|
||
<circle cx="584" cy="478" r="30" fill="#17110e"/>
|
||
<rect x="458" y="594" width="108" height="44" rx="22" fill="{CLAY}"/>
|
||
<circle cx="512" cy="254" r="54" fill="{GOLD}"/>
|
||
<circle cx="512" cy="254" r="24" fill="#17110e"/>'''
|
||
elif style == "soft_totem":
|
||
body = f'''
|
||
<rect width="1024" height="1024" rx="160" fill="#111111"/>
|
||
<rect x="340" y="188" width="344" height="648" rx="172" fill="{CREAM}"/>
|
||
<circle cx="512" cy="336" r="112" fill="{CLAY}"/>
|
||
<rect x="390" y="442" width="244" height="280" rx="122" fill="#111111"/>
|
||
<circle cx="512" cy="582" r="58" fill="{GOLD}"/>
|
||
<circle cx="432" cy="332" r="24" fill="{INK}"/>
|
||
<circle cx="592" cy="332" r="24" fill="{INK}"/>
|
||
<rect x="404" y="782" width="216" height="60" rx="30" fill="{CREAM}"/>'''
|
||
elif style == "clay_seed":
|
||
body = f'''
|
||
<rect width="1024" height="1024" rx="160" fill="{INK_BLUE}"/>
|
||
<path d="M236 504C236 294 382 176 512 176C676 176 788 322 788 504C788 702 648 832 512 832C372 832 236 702 236 504Z" fill="{CREAM}"/>
|
||
<circle cx="618" cy="326" r="72" fill="{INK_BLUE}"/>
|
||
<circle cx="618" cy="326" r="34" fill="{GOLD}"/>
|
||
<circle cx="438" cy="488" r="30" fill="{INK_BLUE}"/>
|
||
<rect x="506" y="548" width="126" height="46" rx="23" fill="{CLAY}"/>
|
||
<path d="M512 624L536 658L570 682L536 706L512 740L488 706L454 682L488 658Z" fill="{GOLD}"/>'''
|
||
else:
|
||
body = f'''
|
||
<rect width="1024" height="1024" rx="160" fill="#101010"/>
|
||
<rect x="286" y="300" width="452" height="456" rx="150" fill="{CREAM}"/>
|
||
<circle cx="286" cy="530" r="88" fill="#101010"/>
|
||
<circle cx="738" cy="530" r="88" fill="#101010"/>
|
||
<circle cx="512" cy="300" r="76" fill="{GOLD}"/>
|
||
<circle cx="430" cy="474" r="24" fill="#101010"/>
|
||
<circle cx="594" cy="474" r="24" fill="#101010"/>
|
||
<path d="M512 536L540 576L580 604L540 632L512 672L484 632L444 604L484 576Z" fill="#101010"/>
|
||
<path d="M512 570L526 590L546 604L526 618L512 638L498 618L478 604L498 590Z" fill="{GOLD}"/>
|
||
<rect x="360" y="744" width="304" height="54" rx="27" fill="{CLAY}"/>'''
|
||
return f'''<?xml version="1.0" encoding="UTF-8"?>
|
||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||
{body}
|
||
</svg>
|
||
'''
|
||
|
||
|
||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||
candidates = [
|
||
Path("C:/Windows/Fonts/msyh.ttc"),
|
||
Path("C:/Windows/Fonts/simhei.ttf"),
|
||
Path("C:/Windows/Fonts/simsun.ttc"),
|
||
]
|
||
for candidate in candidates:
|
||
if candidate.exists():
|
||
return ImageFont.truetype(str(candidate), size)
|
||
return ImageFont.load_default()
|
||
|
||
|
||
def build_contact_sheet(previews: list[tuple[str, str, Image.Image]]) -> Image.Image:
|
||
cell_size = 320
|
||
label_height = 60
|
||
gap = 28
|
||
columns = 3
|
||
rows = 2
|
||
width = columns * cell_size + (columns + 1) * gap
|
||
height = rows * (cell_size + label_height) + (rows + 1) * gap
|
||
sheet = Image.new("RGB", (width, height), "#eee9df")
|
||
draw = ImageDraw.Draw(sheet)
|
||
font = load_font(23)
|
||
|
||
for index, (_, title, preview) in enumerate(previews):
|
||
row = index // columns
|
||
column = index % columns
|
||
x = gap + column * (cell_size + gap)
|
||
y = gap + row * (cell_size + label_height + gap)
|
||
thumbnail = preview.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
||
sheet.paste(thumbnail, (x, y))
|
||
draw.rounded_rectangle(
|
||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
||
radius=10,
|
||
fill="#fffdf8",
|
||
)
|
||
label = f"{index + 1:02d} {title}"
|
||
text_box = draw.textbbox((0, 0), label, font=font)
|
||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
||
draw.text((text_x, text_y), label, fill="#302a25", font=font)
|
||
return sheet
|
||
|
||
|
||
def main() -> None:
|
||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||
previews: list[tuple[str, str, Image.Image]] = []
|
||
|
||
for asset_id, title, style in VARIANTS:
|
||
preview = render_variant(style)
|
||
preview.save(OUTPUT_DIR / f"{asset_id}.png")
|
||
(OUTPUT_DIR / f"{asset_id}.svg").write_text(build_svg(style), encoding="utf-8")
|
||
previews.append((asset_id, title, preview))
|
||
|
||
contact_sheet = build_contact_sheet(previews)
|
||
contact_sheet.save(CONTACT_SHEET_PATH, quality=95)
|
||
print(
|
||
{
|
||
"ok": True,
|
||
"output_dir": str(OUTPUT_DIR),
|
||
"files": [f"{asset_id}.png" for asset_id, _, _ in VARIANTS]
|
||
+ [f"{asset_id}.svg" for asset_id, _, _ in VARIANTS]
|
||
+ [CONTACT_SHEET_PATH.name],
|
||
}
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|