545 lines
17 KiB
Python
545 lines
17 KiB
Python
from collections import deque
|
|
import math
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageChops, ImageDraw, ImageFont
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
REFERENCE_PATH = (
|
|
REPO_ROOT
|
|
/ "public"
|
|
/ "branding"
|
|
/ "taonier-logo-punch-hole-concepts"
|
|
/ "taonier-punch-color-inlay.png"
|
|
)
|
|
OUTPUT_DIR = (
|
|
REPO_ROOT
|
|
/ "public"
|
|
/ "branding"
|
|
/ "taonier-logo-ref04-locked-color-concepts"
|
|
)
|
|
|
|
|
|
def is_red(pixel):
|
|
r, g, b = pixel
|
|
return r > 160 and g < 155 and b < 145 and r - g > 45
|
|
|
|
|
|
def is_cyan(pixel):
|
|
r, g, b = pixel
|
|
return r < 120 and g > 125 and b > 125 and b - r > 65
|
|
|
|
|
|
def is_open_light(pixel):
|
|
r, g, b = pixel
|
|
lum = (r + g + b) // 3
|
|
return lum > 174 and max(pixel) - min(pixel) < 105 and not is_red(pixel) and not is_cyan(pixel)
|
|
|
|
|
|
def colorize(pixel, target, category):
|
|
r, g, b = pixel
|
|
lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
|
|
if category == "dark":
|
|
factor = 0.88 + min(lum, 0.42) * 0.44
|
|
else:
|
|
factor = 0.9 + lum * 0.2
|
|
return tuple(max(0, min(255, round(channel * factor))) for channel in target)
|
|
|
|
|
|
def build_masks(image):
|
|
width, height = image.size
|
|
pixels = image.load()
|
|
open_mask = bytearray(width * height)
|
|
|
|
for y in range(height):
|
|
for x in range(width):
|
|
if is_open_light(pixels[x, y]):
|
|
open_mask[y * width + x] = 1
|
|
|
|
# 从画布边缘连通的浅色区域是外部背景;剩下的浅色闭合区域就是中孔。
|
|
external_mask = bytearray(width * height)
|
|
queue = deque()
|
|
for x in range(width):
|
|
for y in (0, height - 1):
|
|
index = y * width + x
|
|
if open_mask[index] and not external_mask[index]:
|
|
external_mask[index] = 1
|
|
queue.append((x, y))
|
|
for y in range(height):
|
|
for x in (0, width - 1):
|
|
index = y * width + x
|
|
if open_mask[index] and not external_mask[index]:
|
|
external_mask[index] = 1
|
|
queue.append((x, y))
|
|
|
|
while queue:
|
|
x, y = queue.popleft()
|
|
for next_x, next_y in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
|
|
if 0 <= next_x < width and 0 <= next_y < height:
|
|
index = next_y * width + next_x
|
|
if open_mask[index] and not external_mask[index]:
|
|
external_mask[index] = 1
|
|
queue.append((next_x, next_y))
|
|
|
|
hole_mask = bytearray(width * height)
|
|
red_mask = bytearray(width * height)
|
|
cyan_mask = bytearray(width * height)
|
|
dark_mask = bytearray(width * height)
|
|
for y in range(height):
|
|
for x in range(width):
|
|
index = y * width + x
|
|
pixel = pixels[x, y]
|
|
if open_mask[index] and not external_mask[index]:
|
|
hole_mask[index] = 1
|
|
elif not external_mask[index]:
|
|
if is_red(pixel):
|
|
red_mask[index] = 1
|
|
elif is_cyan(pixel):
|
|
cyan_mask[index] = 1
|
|
elif not open_mask[index]:
|
|
dark_mask[index] = 1
|
|
|
|
return {
|
|
"dark": dark_mask,
|
|
"red": red_mask,
|
|
"cyan": cyan_mask,
|
|
"hole": hole_mask,
|
|
}
|
|
|
|
|
|
def mask_bounds(mask, width, height):
|
|
xs = []
|
|
ys = []
|
|
for index, value in enumerate(mask):
|
|
if value:
|
|
xs.append(index % width)
|
|
ys.append(index // width)
|
|
return min(xs), min(ys), max(xs), max(ys)
|
|
|
|
|
|
def draw_center_content(size, hole_mask, variant):
|
|
width, height = size
|
|
hole_bounds = mask_bounds(hole_mask, width, height)
|
|
left, top, right, bottom = hole_bounds
|
|
center_x = (left + right) / 2
|
|
center_y = (top + bottom) / 2
|
|
hole_w = right - left
|
|
hole_h = bottom - top
|
|
|
|
scale = 4
|
|
layer = Image.new("RGBA", (width * scale, height * scale), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(layer)
|
|
|
|
def box(cx, cy, w, h):
|
|
return [
|
|
int((cx - w / 2) * scale),
|
|
int((cy - h / 2) * scale),
|
|
int((cx + w / 2) * scale),
|
|
int((cy + h / 2) * scale),
|
|
]
|
|
|
|
def rounded(cx, cy, w, h, radius, fill):
|
|
draw.rounded_rectangle(box(cx, cy, w, h), radius=int(radius * scale), fill=fill)
|
|
|
|
def ellipse(cx, cy, w, h, fill):
|
|
draw.ellipse(box(cx, cy, w, h), fill=fill)
|
|
|
|
def star(cx, cy, outer, inner, fill):
|
|
points = []
|
|
for index in range(10):
|
|
angle = -90 + index * 36
|
|
radius = outer if index % 2 == 0 else inner
|
|
points.append(
|
|
(
|
|
int((cx + radius * math.cos(math.radians(angle))) * scale),
|
|
int((cy + radius * math.sin(math.radians(angle))) * scale),
|
|
)
|
|
)
|
|
draw.polygon(points, fill=fill)
|
|
|
|
def sparkle(cx, cy, radius, fill, with_rays=False):
|
|
points = []
|
|
point_count = 128
|
|
for index in range(point_count):
|
|
theta = -math.pi / 2 + index * math.tau / point_count
|
|
pulse = abs(math.cos(2 * theta)) ** 4.2
|
|
current_radius = radius * (0.12 + 0.88 * pulse)
|
|
points.append(
|
|
(
|
|
int((cx + math.cos(theta) * current_radius * 0.78) * scale),
|
|
int((cy + math.sin(theta) * current_radius * 1.12) * scale),
|
|
)
|
|
)
|
|
draw.polygon(points, fill=fill)
|
|
if not with_rays:
|
|
return
|
|
|
|
ray_color = fill
|
|
line_width = max(4, int(radius * 0.11 * scale))
|
|
cap = line_width // 2
|
|
|
|
def rounded_line(start, end):
|
|
draw.line(
|
|
(
|
|
int(start[0] * scale),
|
|
int(start[1] * scale),
|
|
int(end[0] * scale),
|
|
int(end[1] * scale),
|
|
),
|
|
fill=ray_color,
|
|
width=line_width,
|
|
)
|
|
for point in (start, end):
|
|
draw.ellipse(
|
|
(
|
|
int(point[0] * scale) - cap,
|
|
int(point[1] * scale) - cap,
|
|
int(point[0] * scale) + cap,
|
|
int(point[1] * scale) + cap,
|
|
),
|
|
fill=ray_color,
|
|
)
|
|
|
|
rounded_line((cx - radius * 1.48, cy - radius * 0.15), (cx - radius * 1.18, cy - radius * 0.08))
|
|
rounded_line((cx - radius * 1.35, cy + radius * 0.5), (cx - radius * 1.1, cy + radius * 0.3))
|
|
rounded_line((cx + radius * 1.12, cy - radius * 0.42), (cx + radius * 1.33, cy - radius * 0.64))
|
|
rounded_line((cx + radius * 1.25, cy + radius * 0.08), (cx + radius * 1.52, cy + radius * 0.15))
|
|
|
|
if variant == "cream_seed":
|
|
rounded(center_x, center_y, hole_w * 0.42, hole_h * 0.34, 44, (244, 216, 166, 255))
|
|
elif variant == "soft_dot":
|
|
rounded(center_x, center_y, hole_w * 0.32, hole_h * 0.28, 36, (250, 219, 157, 255))
|
|
elif variant == "double_piece":
|
|
rounded(center_x - hole_w * 0.08, center_y + hole_h * 0.01, hole_w * 0.24, hole_h * 0.22, 30, (249, 202, 174, 255))
|
|
rounded(center_x + hole_w * 0.13, center_y - hole_h * 0.02, hole_w * 0.22, hole_h * 0.21, 28, (143, 207, 205, 255))
|
|
elif variant == "tiny_kernel":
|
|
ellipse(center_x, center_y, hole_w * 0.26, hole_h * 0.24, (252, 223, 157, 255))
|
|
elif variant == "filled_core":
|
|
rounded(center_x, center_y, hole_w * 0.58, hole_h * 0.5, 58, (248, 231, 196, 255))
|
|
ellipse(center_x - hole_w * 0.09, center_y + hole_h * 0.02, hole_w * 0.13, hole_h * 0.12, (240, 93, 82, 255))
|
|
ellipse(center_x + hole_w * 0.1, center_y - hole_h * 0.02, hole_w * 0.13, hole_h * 0.12, (20, 183, 196, 255))
|
|
elif variant == "clay_pearl":
|
|
rounded(center_x, center_y, hole_w * 0.36, hole_h * 0.3, 40, (255, 226, 177, 255))
|
|
ellipse(center_x + hole_w * 0.06, center_y - hole_h * 0.05, hole_w * 0.08, hole_h * 0.07, (255, 246, 220, 180))
|
|
elif variant == "cream_star":
|
|
star(center_x, center_y, min(hole_w, hole_h) * 0.17, min(hole_w, hole_h) * 0.075, (255, 223, 154, 255))
|
|
elif variant == "small_star":
|
|
star(center_x, center_y, min(hole_w, hole_h) * 0.135, min(hole_w, hole_h) * 0.06, (255, 231, 177, 255))
|
|
elif variant == "soft_star_badge":
|
|
rounded(center_x, center_y, hole_w * 0.38, hole_h * 0.34, 42, (255, 239, 207, 255))
|
|
star(center_x, center_y, min(hole_w, hole_h) * 0.115, min(hole_w, hole_h) * 0.052, (238, 129, 80, 255))
|
|
elif variant == "coral_star":
|
|
star(center_x, center_y, min(hole_w, hole_h) * 0.15, min(hole_w, hole_h) * 0.067, (241, 108, 82, 255))
|
|
elif variant == "mint_star":
|
|
star(center_x, center_y, min(hole_w, hole_h) * 0.15, min(hole_w, hole_h) * 0.067, (78, 198, 183, 255))
|
|
elif variant == "soft_sparkle":
|
|
sparkle(center_x, center_y, min(hole_w, hole_h) * 0.18, (255, 205, 61, 255), True)
|
|
elif variant == "small_sparkle":
|
|
sparkle(center_x, center_y, min(hole_w, hole_h) * 0.145, (255, 214, 91, 255), True)
|
|
elif variant == "bright_sparkle":
|
|
sparkle(center_x, center_y, min(hole_w, hole_h) * 0.17, (255, 197, 43, 255), True)
|
|
elif variant == "quiet_sparkle":
|
|
sparkle(center_x, center_y, min(hole_w, hole_h) * 0.155, (255, 224, 139, 255), False)
|
|
|
|
layer = layer.resize(size, Image.Resampling.LANCZOS)
|
|
alpha = Image.frombytes("L", size, bytes(255 if value else 0 for value in hole_mask))
|
|
layer_alpha = layer.getchannel("A")
|
|
layer.putalpha(ImageChops.multiply(layer_alpha, alpha))
|
|
return layer
|
|
|
|
|
|
VARIANTS = [
|
|
{
|
|
"id": "taonier-ref04-locked-warm-ink",
|
|
"label": "01 warm",
|
|
"dark": (63, 58, 53),
|
|
"red": (243, 82, 69),
|
|
"cyan": (14, 183, 198),
|
|
"content": "cream_seed",
|
|
},
|
|
{
|
|
"id": "taonier-ref04-locked-blue-ink",
|
|
"label": "02 blue",
|
|
"dark": (30, 39, 72),
|
|
"red": (255, 89, 84),
|
|
"cyan": (28, 181, 207),
|
|
"content": "soft_dot",
|
|
},
|
|
{
|
|
"id": "taonier-ref04-locked-plum-ink",
|
|
"label": "03 plum",
|
|
"dark": (69, 53, 72),
|
|
"red": (255, 98, 86),
|
|
"cyan": (34, 188, 198),
|
|
"content": "double_piece",
|
|
},
|
|
{
|
|
"id": "taonier-ref04-locked-green-ink",
|
|
"label": "04 green",
|
|
"dark": (11, 83, 78),
|
|
"red": (255, 107, 88),
|
|
"cyan": (68, 209, 192),
|
|
"content": "tiny_kernel",
|
|
},
|
|
{
|
|
"id": "taonier-ref04-locked-shrink-core",
|
|
"label": "05 fill",
|
|
"dark": (43, 43, 47),
|
|
"red": (239, 84, 75),
|
|
"cyan": (17, 178, 193),
|
|
"content": "filled_core",
|
|
},
|
|
{
|
|
"id": "taonier-ref04-locked-soft-charcoal",
|
|
"label": "06 soft",
|
|
"dark": (82, 76, 68),
|
|
"red": (242, 105, 90),
|
|
"cyan": (37, 188, 195),
|
|
"content": "clay_pearl",
|
|
},
|
|
]
|
|
|
|
STAR_OUTPUT_DIR = (
|
|
REPO_ROOT
|
|
/ "public"
|
|
/ "branding"
|
|
/ "taonier-logo-ref04-warm-star-concepts"
|
|
)
|
|
|
|
STAR_VARIANTS = [
|
|
{
|
|
"id": "taonier-ref04-warm-star-terracotta",
|
|
"label": "01 clay",
|
|
"dark": (121, 76, 54),
|
|
"red": (244, 86, 70),
|
|
"cyan": (15, 184, 198),
|
|
"content": "cream_star",
|
|
},
|
|
{
|
|
"id": "taonier-ref04-warm-star-caramel",
|
|
"label": "02 caramel",
|
|
"dark": (142, 94, 51),
|
|
"red": (247, 91, 73),
|
|
"cyan": (13, 185, 196),
|
|
"content": "small_star",
|
|
},
|
|
{
|
|
"id": "taonier-ref04-warm-star-cocoa",
|
|
"label": "03 cocoa",
|
|
"dark": (89, 64, 47),
|
|
"red": (240, 88, 72),
|
|
"cyan": (17, 181, 194),
|
|
"content": "soft_star_badge",
|
|
},
|
|
{
|
|
"id": "taonier-ref04-warm-star-rust",
|
|
"label": "04 rust",
|
|
"dark": (111, 62, 54),
|
|
"red": (249, 93, 75),
|
|
"cyan": (15, 184, 198),
|
|
"content": "cream_star",
|
|
},
|
|
{
|
|
"id": "taonier-ref04-warm-star-olive",
|
|
"label": "05 olive",
|
|
"dark": (92, 81, 48),
|
|
"red": (245, 94, 76),
|
|
"cyan": (25, 185, 187),
|
|
"content": "coral_star",
|
|
},
|
|
{
|
|
"id": "taonier-ref04-warm-star-plum",
|
|
"label": "06 plum",
|
|
"dark": (95, 57, 66),
|
|
"red": (250, 94, 81),
|
|
"cyan": (26, 185, 197),
|
|
"content": "mint_star",
|
|
},
|
|
]
|
|
|
|
SPARKLE_OUTPUT_DIR = (
|
|
REPO_ROOT
|
|
/ "public"
|
|
/ "branding"
|
|
/ "taonier-logo-ref04-warm-sparkle-concepts"
|
|
)
|
|
|
|
SPARKLE_V2_OUTPUT_DIR = (
|
|
REPO_ROOT
|
|
/ "public"
|
|
/ "branding"
|
|
/ "taonier-logo-ref04-warm-sparkle-v2-concepts"
|
|
)
|
|
|
|
PALETTE_TRANSFER_OUTPUT_DIR = (
|
|
REPO_ROOT
|
|
/ "public"
|
|
/ "branding"
|
|
/ "taonier-logo-ref04-palette-transfer"
|
|
)
|
|
|
|
SPARKLE_VARIANTS = [
|
|
{
|
|
"id": "taonier-ref04-warm-sparkle-terracotta",
|
|
"label": "01 clay",
|
|
"dark": (121, 76, 54),
|
|
"red": (244, 86, 70),
|
|
"cyan": (15, 184, 198),
|
|
"content": "soft_sparkle",
|
|
},
|
|
{
|
|
"id": "taonier-ref04-warm-sparkle-rust",
|
|
"label": "02 rust",
|
|
"dark": (111, 62, 54),
|
|
"red": (249, 93, 75),
|
|
"cyan": (15, 184, 198),
|
|
"content": "soft_sparkle",
|
|
},
|
|
{
|
|
"id": "taonier-ref04-warm-sparkle-caramel",
|
|
"label": "03 caramel",
|
|
"dark": (142, 94, 51),
|
|
"red": (247, 91, 73),
|
|
"cyan": (13, 185, 196),
|
|
"content": "small_sparkle",
|
|
},
|
|
{
|
|
"id": "taonier-ref04-warm-sparkle-cocoa",
|
|
"label": "04 cocoa",
|
|
"dark": (89, 64, 47),
|
|
"red": (240, 88, 72),
|
|
"cyan": (17, 181, 194),
|
|
"content": "bright_sparkle",
|
|
},
|
|
{
|
|
"id": "taonier-ref04-warm-sparkle-clay-quiet",
|
|
"label": "05 quiet",
|
|
"dark": (121, 76, 54),
|
|
"red": (244, 86, 70),
|
|
"cyan": (15, 184, 198),
|
|
"content": "quiet_sparkle",
|
|
},
|
|
{
|
|
"id": "taonier-ref04-warm-sparkle-plum",
|
|
"label": "06 plum",
|
|
"dark": (95, 57, 66),
|
|
"red": (250, 94, 81),
|
|
"cyan": (26, 185, 197),
|
|
"content": "soft_sparkle",
|
|
},
|
|
]
|
|
|
|
PALETTE_TRANSFER_VARIANTS = [
|
|
{
|
|
"id": "taonier-ref04-palette-transfer-warm-yellow-sparkle",
|
|
"label": "transfer",
|
|
"dark": (224, 162, 58),
|
|
"red": (255, 113, 132),
|
|
"cyan": (91, 213, 192),
|
|
"content": "soft_sparkle",
|
|
},
|
|
]
|
|
|
|
|
|
def apply_variant(reference, masks, variant):
|
|
image = reference.copy().convert("RGBA")
|
|
source = reference.convert("RGB")
|
|
width, height = source.size
|
|
source_pixels = source.load()
|
|
result_pixels = image.load()
|
|
|
|
for y in range(height):
|
|
for x in range(width):
|
|
index = y * width + x
|
|
pixel = source_pixels[x, y]
|
|
if masks["dark"][index]:
|
|
result_pixels[x, y] = (*colorize(pixel, variant["dark"], "dark"), 255)
|
|
elif masks["red"][index]:
|
|
result_pixels[x, y] = (*colorize(pixel, variant["red"], "accent"), 255)
|
|
elif masks["cyan"][index]:
|
|
result_pixels[x, y] = (*colorize(pixel, variant["cyan"], "accent"), 255)
|
|
|
|
content = draw_center_content(source.size, masks["hole"], variant["content"])
|
|
return Image.alpha_composite(image, content).convert("RGB")
|
|
|
|
|
|
def build_contact_sheet(items, output_path):
|
|
thumb = 260
|
|
label_h = 34
|
|
pad = 18
|
|
cols = 4
|
|
rows = (len(items) + cols - 1) // cols
|
|
sheet_w = cols * thumb + (cols + 1) * pad
|
|
sheet_h = rows * (thumb + label_h) + (rows + 1) * pad
|
|
sheet = Image.new("RGB", (sheet_w, sheet_h), "#f7f3ea")
|
|
draw = ImageDraw.Draw(sheet)
|
|
try:
|
|
font = ImageFont.truetype("arial.ttf", 18)
|
|
except OSError:
|
|
font = ImageFont.load_default()
|
|
|
|
for index, (label, path) in enumerate(items):
|
|
image = Image.open(path).convert("RGB")
|
|
image.thumbnail((thumb, thumb), Image.Resampling.LANCZOS)
|
|
row, col = divmod(index, cols)
|
|
x = pad + col * (thumb + pad)
|
|
y = pad + row * (thumb + label_h + pad)
|
|
bg = Image.new("RGB", (thumb, thumb), "#fffaf1")
|
|
bg.paste(image, ((thumb - image.width) // 2, (thumb - image.height) // 2))
|
|
sheet.paste(bg, (x, y))
|
|
draw.rectangle((x, y, x + thumb - 1, y + thumb - 1), outline="#ded5c6", width=1)
|
|
bbox = draw.textbbox((0, 0), label, font=font)
|
|
draw.text((x + (thumb - (bbox[2] - bbox[0])) // 2, y + thumb + 8), label, fill="#211f1c", font=font)
|
|
|
|
sheet.save(output_path)
|
|
|
|
|
|
def generate_set(output_dir, variants, contact_name):
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
reference = Image.open(REFERENCE_PATH).convert("RGB")
|
|
masks = build_masks(reference)
|
|
contact_items = [("REF-04", REFERENCE_PATH)]
|
|
|
|
for variant in variants:
|
|
output_path = output_dir / f"{variant['id']}.png"
|
|
apply_variant(reference, masks, variant).save(output_path)
|
|
contact_items.append((variant["label"], output_path))
|
|
|
|
build_contact_sheet(
|
|
contact_items,
|
|
output_dir / contact_name,
|
|
)
|
|
|
|
|
|
def main():
|
|
generate_set(
|
|
OUTPUT_DIR,
|
|
VARIANTS,
|
|
"taonier-logo-ref04-locked-color-contact-sheet.png",
|
|
)
|
|
generate_set(
|
|
STAR_OUTPUT_DIR,
|
|
STAR_VARIANTS,
|
|
"taonier-logo-ref04-warm-star-contact-sheet.png",
|
|
)
|
|
generate_set(
|
|
SPARKLE_OUTPUT_DIR,
|
|
SPARKLE_VARIANTS,
|
|
"taonier-logo-ref04-warm-sparkle-contact-sheet.png",
|
|
)
|
|
generate_set(
|
|
SPARKLE_V2_OUTPUT_DIR,
|
|
SPARKLE_VARIANTS,
|
|
"taonier-logo-ref04-warm-sparkle-v2-contact-sheet.png",
|
|
)
|
|
generate_set(
|
|
PALETTE_TRANSFER_OUTPUT_DIR,
|
|
PALETTE_TRANSFER_VARIANTS,
|
|
"taonier-logo-ref04-palette-transfer-contact-sheet.png",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|