Files
Genarrative/scripts/generate-taonier-squish-recolor-variants.py

273 lines
8.6 KiB
Python

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()