feat: refresh creation config and visual assets
This commit is contained in:
272
scripts/generate-taonier-squish-recolor-variants.py
Normal file
272
scripts/generate-taonier-squish-recolor-variants.py
Normal file
@@ -0,0 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import colorsys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE_PATH = (
|
||||
REPO_ROOT
|
||||
/ "public"
|
||||
/ "branding"
|
||||
/ "taonier-logo-magic-dot-concepts"
|
||||
/ "taonier-magic-dot-squish.png"
|
||||
)
|
||||
OUTPUT_DIR = (
|
||||
REPO_ROOT / "public" / "branding" / "taonier-logo-squish-recolor-variants"
|
||||
)
|
||||
|
||||
|
||||
VARIANTS = [
|
||||
{
|
||||
"id": "taonier-squish-recolor-original-plus",
|
||||
"title": "原版提亮",
|
||||
"top": ("#ff3f74", "#ff5a8c"),
|
||||
"bottom": ("#10c6b1", "#19d5b8"),
|
||||
"star": ("#ffd249", "#ffc13c"),
|
||||
"background": "#fffdf8",
|
||||
"saturation": 1.04,
|
||||
"value": 1.02,
|
||||
},
|
||||
{
|
||||
"id": "taonier-squish-recolor-candy-mint",
|
||||
"title": "糖果薄荷",
|
||||
"top": ("#ff5aa0", "#ff7786"),
|
||||
"bottom": ("#1fd3c2", "#46e0cc"),
|
||||
"star": ("#ffe071", "#ffc64b"),
|
||||
"background": "#fffafd",
|
||||
"saturation": 1.02,
|
||||
"value": 1.05,
|
||||
},
|
||||
{
|
||||
"id": "taonier-squish-recolor-peach-jelly",
|
||||
"title": "桃桃果冻",
|
||||
"top": ("#ff6b8d", "#ff8b72"),
|
||||
"bottom": ("#30cfb7", "#72dec5"),
|
||||
"star": ("#ffe586", "#ffc75c"),
|
||||
"background": "#fffaf2",
|
||||
"saturation": 0.96,
|
||||
"value": 1.07,
|
||||
},
|
||||
{
|
||||
"id": "taonier-squish-recolor-pop-bright",
|
||||
"title": "亮彩出圈",
|
||||
"top": ("#ff2f82", "#ff4faf"),
|
||||
"bottom": ("#00c5b9", "#00d8e8"),
|
||||
"star": ("#fff15a", "#ffc735"),
|
||||
"background": "#fbfbff",
|
||||
"saturation": 1.10,
|
||||
"value": 1.03,
|
||||
},
|
||||
{
|
||||
"id": "taonier-squish-recolor-coral-soda",
|
||||
"title": "珊瑚苏打",
|
||||
"top": ("#ff674d", "#ff8372"),
|
||||
"bottom": ("#17c5a9", "#55dabc"),
|
||||
"star": ("#ffe26f", "#ffbe43"),
|
||||
"background": "#fff9ee",
|
||||
"saturation": 0.98,
|
||||
"value": 1.05,
|
||||
},
|
||||
{
|
||||
"id": "taonier-squish-recolor-bubble-q",
|
||||
"title": "泡泡Q感",
|
||||
"top": ("#ff68ba", "#ff77a0"),
|
||||
"bottom": ("#35d8c9", "#73e7d8"),
|
||||
"star": ("#fff08c", "#ffd35c"),
|
||||
"background": "#fffaff",
|
||||
"saturation": 0.92,
|
||||
"value": 1.09,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
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 blend_rgb(
|
||||
first: tuple[int, int, int], second: tuple[int, int, int], amount: float
|
||||
) -> tuple[int, int, int]:
|
||||
amount = max(0.0, min(1.0, amount))
|
||||
return tuple(round(first[index] * (1 - amount) + second[index] * amount) for index in range(3))
|
||||
|
||||
|
||||
def classify_pixel(red: int, green: int, blue: int) -> str | None:
|
||||
hue, saturation, value = colorsys.rgb_to_hsv(red / 255, green / 255, blue / 255)
|
||||
|
||||
if value < 0.42 or saturation < 0.09:
|
||||
return None
|
||||
if red > 145 and green > 105 and blue < 170 and red > blue + 20 and green > blue + 16:
|
||||
return "star"
|
||||
if red > 140 and red > green + 18 and red > blue + 12 and (hue < 0.08 or hue > 0.9):
|
||||
return "top"
|
||||
if green > 105 and blue > 88 and green > red + 15 and blue > red + 6 and 0.36 <= hue <= 0.58:
|
||||
return "bottom"
|
||||
return None
|
||||
|
||||
|
||||
def remap_color(
|
||||
red: int,
|
||||
green: int,
|
||||
blue: int,
|
||||
x: int,
|
||||
y: int,
|
||||
width: int,
|
||||
height: int,
|
||||
group: str,
|
||||
variant: dict[str, object],
|
||||
) -> tuple[int, int, int]:
|
||||
hue, saturation, value = colorsys.rgb_to_hsv(red / 255, green / 255, blue / 255)
|
||||
palette = variant[group]
|
||||
assert isinstance(palette, tuple)
|
||||
start = hex_to_rgb(palette[0])
|
||||
end = hex_to_rgb(palette[1])
|
||||
|
||||
if group == "top":
|
||||
gradient_position = 0.68 * (x / width) + 0.32 * (y / height)
|
||||
elif group == "bottom":
|
||||
gradient_position = 0.42 * (x / width) + 0.58 * (y / height)
|
||||
else:
|
||||
gradient_position = 0.18 * (x / width) + 0.82 * (y / height)
|
||||
|
||||
target = blend_rgb(start, end, gradient_position)
|
||||
target_hue, target_saturation, target_value = colorsys.rgb_to_hsv(
|
||||
target[0] / 255, target[1] / 255, target[2] / 255
|
||||
)
|
||||
|
||||
saturation_boost = float(variant["saturation"])
|
||||
value_boost = float(variant["value"])
|
||||
new_saturation = max(0.0, min(1.0, target_saturation * saturation_boost))
|
||||
# 原图本身有轻微明暗变化,这里保留它,让换色后仍然像同一个软泥形体。
|
||||
new_value = max(0.0, min(1.0, target_value * (0.78 + value * 0.22) * value_boost))
|
||||
recolored = colorsys.hsv_to_rgb(target_hue, new_saturation, new_value)
|
||||
|
||||
background = hex_to_rgb(str(variant["background"]))
|
||||
edge_coverage = max(0.0, min(1.0, (saturation - 0.08) / 0.55))
|
||||
if group == "star":
|
||||
edge_coverage = max(0.0, min(1.0, (saturation - 0.06) / 0.5))
|
||||
|
||||
foreground = tuple(round(channel * 255) for channel in recolored)
|
||||
return blend_rgb(background, foreground, edge_coverage)
|
||||
|
||||
|
||||
def recolor_variant(source: Image.Image, variant: dict[str, object]) -> Image.Image:
|
||||
image = source.convert("RGBA")
|
||||
width, height = image.size
|
||||
background = hex_to_rgb(str(variant["background"]))
|
||||
result = Image.new("RGBA", image.size, (*background, 255))
|
||||
|
||||
source_pixels = image.load()
|
||||
result_pixels = result.load()
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
red, green, blue, alpha = source_pixels[x, y]
|
||||
if alpha == 0:
|
||||
result_pixels[x, y] = (*background, 0)
|
||||
continue
|
||||
|
||||
group = classify_pixel(red, green, blue)
|
||||
if group is None:
|
||||
if red > 238 and green > 238 and blue > 238:
|
||||
result_pixels[x, y] = (*background, alpha)
|
||||
else:
|
||||
result_pixels[x, y] = (red, green, blue, alpha)
|
||||
continue
|
||||
|
||||
new_red, new_green, new_blue = remap_color(
|
||||
red, green, blue, x, y, width, height, group, variant
|
||||
)
|
||||
result_pixels[x, y] = (new_red, new_green, new_blue, alpha)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
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 paste_cell(
|
||||
sheet: Image.Image,
|
||||
image: Image.Image,
|
||||
label: str,
|
||||
index: int,
|
||||
font: ImageFont.FreeTypeFont | ImageFont.ImageFont,
|
||||
) -> None:
|
||||
cell = 310
|
||||
label_height = 54
|
||||
gap = 28
|
||||
columns = 4
|
||||
row = index // columns
|
||||
column = index % columns
|
||||
x = gap + column * (cell + gap)
|
||||
y = gap + row * (cell + label_height + gap)
|
||||
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
thumb = image.resize((cell, cell), Image.Resampling.LANCZOS)
|
||||
sheet.alpha_composite(thumb, (x, y))
|
||||
draw.rounded_rectangle(
|
||||
(x, y + cell, x + cell, y + cell + label_height),
|
||||
radius=10,
|
||||
fill=(255, 253, 248, 255),
|
||||
)
|
||||
text = f"{index:02d} {label}"
|
||||
text_box = draw.textbbox((0, 0), text, font=font)
|
||||
text_x = x + (cell - (text_box[2] - text_box[0])) / 2
|
||||
text_y = y + cell + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
||||
draw.text((text_x, text_y), text, fill=(50, 42, 36, 255), font=font)
|
||||
|
||||
|
||||
def build_contact_sheet(original: Image.Image, outputs: list[tuple[dict[str, object], Image.Image]]) -> Image.Image:
|
||||
cell = 310
|
||||
label_height = 54
|
||||
gap = 28
|
||||
columns = 4
|
||||
rows = 2
|
||||
width = columns * cell + (columns + 1) * gap
|
||||
height = rows * (cell + label_height) + (rows + 1) * gap
|
||||
sheet = Image.new("RGBA", (width, height), (246, 242, 235, 255))
|
||||
font = load_font(22)
|
||||
|
||||
paste_cell(sheet, original.convert("RGBA"), "原参考", 0, font)
|
||||
for index, (variant, image) in enumerate(outputs, start=1):
|
||||
paste_cell(sheet, image, str(variant["title"]), index, font)
|
||||
return sheet
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
source = Image.open(SOURCE_PATH)
|
||||
outputs: list[tuple[dict[str, object], Image.Image]] = []
|
||||
|
||||
for variant in VARIANTS:
|
||||
image = recolor_variant(source, variant)
|
||||
image.save(OUTPUT_DIR / f"{variant['id']}.png")
|
||||
outputs.append((variant, image))
|
||||
|
||||
contact_sheet = build_contact_sheet(source, outputs)
|
||||
contact_sheet.convert("RGB").save(OUTPUT_DIR / "taonier-squish-recolor-contact-sheet.png")
|
||||
|
||||
print(
|
||||
{
|
||||
"ok": True,
|
||||
"output_dir": str(OUTPUT_DIR),
|
||||
"files": [f"{variant['id']}.png" for variant in VARIANTS]
|
||||
+ ["taonier-squish-recolor-contact-sheet.png"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user