mirror of
https://github.com/marswaveai/skills.git
synced 2026-09-19 08:45:03 +08:00
1280 lines
45 KiB
Python
1280 lines
45 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Cola Avatar Pack Processor
|
||
Takes static PNGs, removes background if needed, and generates animated GIFs.
|
||
"""
|
||
|
||
import argparse
|
||
import os
|
||
import re
|
||
import sys
|
||
from collections import Counter, deque
|
||
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
|
||
|
||
# Animation configs per emotion
|
||
ANIMATIONS = {
|
||
'happy': {
|
||
'type': 'bounce_squash',
|
||
'frames': 8,
|
||
'duration': 100, # ms per frame
|
||
'amplitude': 14, # pixels
|
||
},
|
||
'sad': {
|
||
'type': 'shrink_sink',
|
||
'frames': 10,
|
||
'duration': 180,
|
||
'amplitude': 10,
|
||
},
|
||
'angry': {
|
||
'type': 'swell_shake',
|
||
'frames': 6,
|
||
'duration': 70,
|
||
'amplitude': 6,
|
||
},
|
||
'thinking': {
|
||
'type': 'tilt_zoom',
|
||
'frames': 8,
|
||
'duration': 200,
|
||
'amplitude': 3,
|
||
},
|
||
}
|
||
|
||
OUTPUT_SIZE = 256 # High-res for sharing/saving (@2x)
|
||
DISPLAY_SIZE = 128 # For chat display
|
||
BRAND_NAME = 'ColaOS'
|
||
|
||
|
||
def _try_rembg(img):
|
||
"""Try to remove background using rembg CLI. Returns RGBA image or None on failure."""
|
||
import subprocess, tempfile, shutil
|
||
if not shutil.which('rembg'):
|
||
return None
|
||
tmp_in = None
|
||
tmp_out = None
|
||
try:
|
||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f_in:
|
||
tmp_in = f_in.name
|
||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f_out:
|
||
tmp_out = f_out.name
|
||
img.save(tmp_in)
|
||
result = subprocess.run(
|
||
['rembg', 'i', tmp_in, tmp_out],
|
||
capture_output=True, timeout=60
|
||
)
|
||
if result.returncode == 0:
|
||
out = Image.open(tmp_out).convert('RGBA')
|
||
return out
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
for p in [tmp_in, tmp_out]:
|
||
if not p:
|
||
continue
|
||
try:
|
||
os.unlink(p)
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def remove_background(img):
|
||
"""Remove background. Tries rembg first (best quality), falls back to flood-fill.
|
||
|
||
Strategy:
|
||
1. If rembg is available and input has no meaningful transparency → use rembg
|
||
2. Otherwise: sample 4 corners to detect background color(s), flood-fill from
|
||
border pixels that match. Only connected regions touching the border are
|
||
removed — interior pixels of similar color are preserved.
|
||
"""
|
||
img = img.convert('RGBA')
|
||
|
||
# Check if already has meaningful transparency
|
||
alpha = img.getchannel('A')
|
||
transparent_count = sum(1 for a in alpha.getdata() if a < 128)
|
||
total = img.size[0] * img.size[1]
|
||
|
||
# If image has no transparency, try rembg first
|
||
if transparent_count <= total * 0.05:
|
||
rembg_result = _try_rembg(img)
|
||
if rembg_result is not None:
|
||
img = rembg_result
|
||
# Recompute transparency so branch 1 cleanup can run
|
||
alpha = img.getchannel('A')
|
||
transparent_count = sum(1 for a in alpha.getdata() if a < 128)
|
||
total = img.size[0] * img.size[1]
|
||
|
||
if transparent_count > total * 0.05:
|
||
# Image already has meaningful transparency (AI did partial bg removal),
|
||
# but may have opaque remnants (grid lines, checkerboard squares).
|
||
# Flood-fill from alpha=0 pixels into adjacent neutral/light opaque pixels.
|
||
# This extends the AI's background removal to cover missed artifacts.
|
||
w, h = img.size
|
||
pixels = img.load()
|
||
|
||
def _is_bg_remnant(r, g, b):
|
||
"""Neutral pixel — likely background remnant.
|
||
Threshold avg > 55 catches both light and dark checkerboard squares
|
||
(light ~150, dark ~70) while avoiding character outlines (avg 20-40).
|
||
Saturation < 30 prevents eroding colored content."""
|
||
avg = (r + g + b) / 3
|
||
return avg > 55 and max(r, g, b) - min(r, g, b) < 30
|
||
|
||
# Seed: all transparent pixels adjacent to an opaque neutral pixel
|
||
queue = deque()
|
||
visited = [[False] * h for _ in range(w)]
|
||
for y in range(h):
|
||
for x in range(w):
|
||
if pixels[x, y][3] >= 128:
|
||
continue
|
||
# Check 4 cardinal neighbors for opaque neutral pixels
|
||
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
|
||
nx, ny = x + dx, y + dy
|
||
if 0 <= nx < w and 0 <= ny < h and not visited[nx][ny]:
|
||
nr, ng, nb, na = pixels[nx, ny]
|
||
if na >= 128 and _is_bg_remnant(nr, ng, nb):
|
||
queue.append((nx, ny))
|
||
|
||
# Flood-fill through connected neutral/light opaque pixels
|
||
to_clear = []
|
||
while queue:
|
||
x, y = queue.popleft()
|
||
if x < 0 or x >= w or y < 0 or y >= h:
|
||
continue
|
||
if visited[x][y]:
|
||
continue
|
||
visited[x][y] = True
|
||
|
||
r, g, b, a = pixels[x, y]
|
||
if a < 128:
|
||
continue
|
||
if not _is_bg_remnant(r, g, b):
|
||
continue
|
||
|
||
to_clear.append((x, y))
|
||
queue.append((x + 1, y))
|
||
queue.append((x - 1, y))
|
||
queue.append((x, y + 1))
|
||
queue.append((x, y - 1))
|
||
|
||
for x, y in to_clear:
|
||
r, g, b, a = pixels[x, y]
|
||
pixels[x, y] = (r, g, b, 0)
|
||
|
||
# Stage B: remove small opaque islands disconnected from main character.
|
||
# These are enclosed background remnants (checkerboard, gap fill) that
|
||
# flood-fill can't reach because they're surrounded by character pixels.
|
||
SMALL_ISLAND_MAX_AREA = 64
|
||
NEUTRAL_SAT_THRESHOLD = 30
|
||
|
||
visited_cc = [[False] * h for _ in range(w)]
|
||
components = []
|
||
for sy in range(h):
|
||
for sx in range(w):
|
||
if visited_cc[sx][sy] or pixels[sx, sy][3] < 128:
|
||
continue
|
||
comp = []
|
||
cc_q = deque([(sx, sy)])
|
||
while cc_q:
|
||
cx, cy = cc_q.popleft()
|
||
if cx < 0 or cx >= w or cy < 0 or cy >= h:
|
||
continue
|
||
if visited_cc[cx][cy] or pixels[cx, cy][3] < 128:
|
||
continue
|
||
visited_cc[cx][cy] = True
|
||
comp.append((cx, cy))
|
||
cc_q.extend([(cx+1,cy),(cx-1,cy),(cx,cy+1),(cx,cy-1)])
|
||
components.append(comp)
|
||
|
||
if components:
|
||
# Largest component is the character — keep it
|
||
main_size = max(len(c) for c in components)
|
||
for comp in components:
|
||
if len(comp) == main_size:
|
||
continue
|
||
if len(comp) > SMALL_ISLAND_MAX_AREA:
|
||
continue
|
||
# Check if island is neutral (likely background, not character detail)
|
||
total_sat = 0
|
||
for cx, cy in comp:
|
||
r, g, b, _ = pixels[cx, cy]
|
||
total_sat += max(r, g, b) - min(r, g, b)
|
||
if total_sat / len(comp) < NEUTRAL_SAT_THRESHOLD:
|
||
for cx, cy in comp:
|
||
r, g, b, _ = pixels[cx, cy]
|
||
pixels[cx, cy] = (r, g, b, 0)
|
||
|
||
return img
|
||
|
||
w, h = img.size
|
||
pixels = img.load()
|
||
|
||
# Sample corner regions (4x4 block at each corner) to find background color(s)
|
||
corner_colors = Counter()
|
||
sample = 4
|
||
for cx, cy in [(0, 0), (w - sample, 0), (0, h - sample), (w - sample, h - sample)]:
|
||
for dx in range(sample):
|
||
for dy in range(sample):
|
||
x, y = min(cx + dx, w - 1), min(cy + dy, h - 1)
|
||
corner_colors[pixels[x, y][:3]] += 1
|
||
|
||
if not corner_colors:
|
||
return img
|
||
|
||
# Background colors: top colors that together cover > 90% of corner samples
|
||
# (high threshold ensures checkerboard backgrounds have both colors collected)
|
||
bg_colors = set()
|
||
total_corner = sum(corner_colors.values())
|
||
cumulative = 0
|
||
for color, count in corner_colors.most_common():
|
||
bg_colors.add(color)
|
||
cumulative += count
|
||
if cumulative > total_corner * 0.9:
|
||
break
|
||
|
||
def _color_close(c1, c2, threshold=40):
|
||
return all(abs(a - b) <= threshold for a, b in zip(c1, c2))
|
||
|
||
def _is_bg(rgb):
|
||
return any(_color_close(rgb, bg) for bg in bg_colors)
|
||
|
||
# Flood-fill from all border pixels that match background
|
||
visited = [[False] * h for _ in range(w)]
|
||
to_clear = []
|
||
queue = deque()
|
||
|
||
# Seed: all border pixels matching background
|
||
for x in range(w):
|
||
for y in [0, h - 1]:
|
||
if _is_bg(pixels[x, y][:3]):
|
||
queue.append((x, y))
|
||
for y in range(h):
|
||
for x in [0, w - 1]:
|
||
if _is_bg(pixels[x, y][:3]):
|
||
queue.append((x, y))
|
||
|
||
while queue:
|
||
x, y = queue.popleft()
|
||
if x < 0 or x >= w or y < 0 or y >= h:
|
||
continue
|
||
if visited[x][y]:
|
||
continue
|
||
visited[x][y] = True
|
||
|
||
rgb = pixels[x, y][:3]
|
||
if not _is_bg(rgb):
|
||
continue
|
||
|
||
to_clear.append((x, y))
|
||
queue.append((x + 1, y))
|
||
queue.append((x - 1, y))
|
||
queue.append((x, y + 1))
|
||
queue.append((x, y - 1))
|
||
|
||
for x, y in to_clear:
|
||
r, g, b, a = pixels[x, y]
|
||
pixels[x, y] = (r, g, b, 0)
|
||
|
||
return img
|
||
|
||
|
||
def _clean_transparent_rgb(img):
|
||
"""Zero out RGB values where alpha is 0 to prevent color bleed during resize.
|
||
|
||
Many AI image generators embed a checkerboard pattern in the RGB channels
|
||
of transparent pixels. NEAREST-neighbor resize can sample these dirty RGB
|
||
values at content boundaries, causing visible artifacts when composited.
|
||
"""
|
||
if img.mode != 'RGBA':
|
||
return img
|
||
pixels = img.load()
|
||
w, h = img.size
|
||
for y in range(h):
|
||
for x in range(w):
|
||
if pixels[x, y][3] == 0:
|
||
pixels[x, y] = (0, 0, 0, 0)
|
||
return img
|
||
|
||
|
||
def fit_to_canvas(img, canvas_size):
|
||
"""Resize image to fit within canvas while maintaining aspect ratio, then center it."""
|
||
# Auto-crop to content bounding box first (remove transparent/removed areas)
|
||
if img.mode == 'RGBA':
|
||
bbox = img.split()[3].getbbox() # bounding box of non-transparent area
|
||
if bbox:
|
||
img = img.crop(bbox)
|
||
|
||
# Clean transparent pixel RGB to prevent checkerboard bleed during NEAREST resize
|
||
img = _clean_transparent_rgb(img)
|
||
|
||
img_w, img_h = img.size
|
||
|
||
# Scale to fit
|
||
scale = min(canvas_size / img_w, canvas_size / img_h) * 0.9
|
||
new_w = int(img_w * scale)
|
||
new_h = int(img_h * scale)
|
||
|
||
resized = img.resize((new_w, new_h), Image.NEAREST) # NEAREST for pixel art
|
||
|
||
# Center on canvas
|
||
canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
|
||
offset_x = (canvas_size - new_w) // 2
|
||
offset_y = (canvas_size - new_h) // 2
|
||
canvas.paste(resized, (offset_x, offset_y), resized)
|
||
|
||
return canvas, offset_x, offset_y, new_w, new_h
|
||
|
||
|
||
def _deform(canvas, sx, sy, anchor_bottom=True):
|
||
"""Scale canvas content by (sx, sy) around bottom-center or center anchor.
|
||
|
||
Returns a new RGBA canvas of the same size with the deformed sprite.
|
||
"""
|
||
cs = canvas.size[0]
|
||
# Find content bounding box
|
||
bbox = canvas.split()[3].getbbox()
|
||
if not bbox:
|
||
return canvas.copy()
|
||
content = canvas.crop(bbox)
|
||
cw, ch = content.size
|
||
|
||
new_w = max(1, int(cw * sx))
|
||
new_h = max(1, int(ch * sy))
|
||
stretched = content.resize((new_w, new_h), Image.NEAREST)
|
||
|
||
frame = Image.new('RGBA', (cs, cs), (0, 0, 0, 0))
|
||
# Horizontal: keep centered
|
||
px = bbox[0] + (cw - new_w) // 2
|
||
if anchor_bottom:
|
||
# Anchor to original bottom edge — squash grows upward, stretch grows downward
|
||
py = bbox[3] - new_h
|
||
else:
|
||
# Anchor to vertical center
|
||
py = bbox[1] + (ch - new_h) // 2
|
||
frame.paste(stretched, (px, py), stretched)
|
||
return frame
|
||
|
||
|
||
def generate_bounce_squash_frames(img, config):
|
||
"""Bounce with squash on landing and stretch at apex.
|
||
|
||
Cycle: rise → apex(stretch) → fall → land(squash) → recover
|
||
"""
|
||
frames = []
|
||
canvas_size = OUTPUT_SIZE
|
||
base_canvas, ox, oy, w, h = fit_to_canvas(img, canvas_size)
|
||
amp = config['amplitude']
|
||
|
||
# 8 frames: [ground, rise, apex-stretch, hang, fall, land-squash, recover, rest]
|
||
keyframes = [
|
||
# (dy, sx, sy) dy<0 = up
|
||
(0, 1.0, 1.0), # ground
|
||
(-amp * 0.6, 0.95, 1.06), # rising — slight vertical stretch
|
||
(-amp, 0.92, 1.10), # apex — tall and thin
|
||
(-amp * 0.85, 0.94, 1.06), # hang — still stretched
|
||
(-amp * 0.3, 0.98, 1.02), # falling
|
||
(0, 1.12, 0.88), # landing squash — wide and flat
|
||
(0, 1.06, 0.94), # recovering
|
||
(0, 1.0, 1.0), # rest
|
||
]
|
||
|
||
for dy, sx, sy in keyframes:
|
||
deformed = _deform(base_canvas, sx, sy, anchor_bottom=True)
|
||
frame = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
|
||
frame.paste(deformed, (0, int(dy)), deformed)
|
||
frames.append(frame)
|
||
|
||
return frames
|
||
|
||
|
||
def generate_shrink_sink_frames(img, config):
|
||
"""Sad: character shrinks slightly and sinks, then slowly returns.
|
||
|
||
Conveys deflation — the character literally gets smaller.
|
||
"""
|
||
frames = []
|
||
canvas_size = OUTPUT_SIZE
|
||
base_canvas, ox, oy, w, h = fit_to_canvas(img, canvas_size)
|
||
amp = config['amplitude']
|
||
|
||
# 10 frames: deflate down → hold → slowly recover
|
||
keyframes = [
|
||
# (dy, scale)
|
||
(0, 1.0),
|
||
(amp * 0.2, 0.98),
|
||
(amp * 0.5, 0.95),
|
||
(amp * 0.8, 0.92),
|
||
(amp, 0.90), # deepest point
|
||
(amp, 0.90), # hold
|
||
(amp * 0.85, 0.91),
|
||
(amp * 0.6, 0.93),
|
||
(amp * 0.3, 0.96),
|
||
(0, 1.0), # back to normal
|
||
]
|
||
|
||
for dy, scale in keyframes:
|
||
deformed = _deform(base_canvas, scale, scale, anchor_bottom=True)
|
||
frame = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
|
||
frame.paste(deformed, (0, int(dy)), deformed)
|
||
frames.append(frame)
|
||
|
||
return frames
|
||
|
||
|
||
def generate_swell_shake_frames(img, config):
|
||
"""Angry: swell up then shake violently with decay.
|
||
|
||
First frame puffs up (scale > 1), then rapid left-right shake
|
||
with decreasing amplitude. Conveys contained rage bursting out.
|
||
"""
|
||
frames = []
|
||
canvas_size = OUTPUT_SIZE
|
||
base_canvas, ox, oy, w, h = fit_to_canvas(img, canvas_size)
|
||
amp = config['amplitude']
|
||
|
||
# 6 frames: swell → shake L → shake R → shake L(smaller) → settle → rest
|
||
keyframes = [
|
||
# (dx, sx, sy)
|
||
(0, 1.08, 1.08), # puff up
|
||
(-amp, 1.06, 1.04), # shake left (still swollen)
|
||
(amp, 1.04, 1.02), # shake right
|
||
(-int(amp*0.5), 1.02, 1.01), # smaller shake left
|
||
(int(amp*0.2), 1.01, 1.0), # settling right
|
||
(0, 1.0, 1.0), # rest
|
||
]
|
||
|
||
for dx, sx, sy in keyframes:
|
||
deformed = _deform(base_canvas, sx, sy, anchor_bottom=True)
|
||
frame = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
|
||
frame.paste(deformed, (dx, 0), deformed)
|
||
frames.append(frame)
|
||
|
||
return frames
|
||
|
||
|
||
def generate_tilt_zoom_frames(img, config):
|
||
"""Thinking: subtle head tilt via asymmetric scale, slight zoom on upper half.
|
||
|
||
Since we can't rotate with pixel-clean results, we fake the tilt by
|
||
shifting the sprite slightly and scaling up to suggest leaning in.
|
||
"""
|
||
frames = []
|
||
canvas_size = OUTPUT_SIZE
|
||
base_canvas, ox, oy, w, h = fit_to_canvas(img, canvas_size)
|
||
amp = config['amplitude']
|
||
|
||
# 8 frames: neutral → lean-in → hold(zoom) → hold → lean-out → rest → rest → rest
|
||
keyframes = [
|
||
# (dx, dy, scale)
|
||
(0, 0, 1.0), # neutral
|
||
(amp, -1, 1.02), # start leaning right + slight zoom
|
||
(amp+1, -2, 1.04), # full lean — zoomed in, thinking hard
|
||
(amp+1, -2, 1.04), # hold
|
||
(amp+1, -2, 1.04), # hold (longer pause = "still thinking")
|
||
(amp, -1, 1.02), # returning
|
||
(0, 0, 1.0), # back
|
||
(0, 0, 1.0), # rest
|
||
]
|
||
|
||
for dx, dy, scale in keyframes:
|
||
deformed = _deform(base_canvas, scale, scale, anchor_bottom=False)
|
||
frame = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
|
||
frame.paste(deformed, (dx, dy), deformed)
|
||
frames.append(frame)
|
||
|
||
return frames
|
||
|
||
|
||
FRAME_GENERATORS = {
|
||
'bounce_squash': generate_bounce_squash_frames,
|
||
'shrink_sink': generate_shrink_sink_frames,
|
||
'swell_shake': generate_swell_shake_frames,
|
||
'tilt_zoom': generate_tilt_zoom_frames,
|
||
}
|
||
|
||
|
||
def _find_unused_color(frames):
|
||
"""Find an RGB color not present in any frame, for use as transparent proxy."""
|
||
candidates = [(255, 0, 255), (0, 255, 0), (0, 0, 255), (1, 1, 1), (254, 0, 254)]
|
||
used = set()
|
||
for frame in frames:
|
||
rgb = frame.convert('RGB')
|
||
used.update(rgb.getdata())
|
||
for c in candidates:
|
||
if c not in used:
|
||
return c
|
||
# Fallback: brute-force search
|
||
for r in range(256):
|
||
for g in range(256):
|
||
if (r, g, 0) not in used:
|
||
return (r, g, 0)
|
||
return (255, 0, 255) # should never reach here
|
||
|
||
|
||
def _save_gif(rgba_frames, output_path, duration):
|
||
"""Convert RGBA frames to paletted GIF with transparency."""
|
||
bg_color = _find_unused_color(rgba_frames)
|
||
first_canvas = Image.new('RGB', rgba_frames[0].size, bg_color)
|
||
first_canvas.paste(rgba_frames[0], mask=rgba_frames[0].split()[3])
|
||
ref_palette_img = first_canvas.quantize(colors=255, method=Image.Quantize.MEDIANCUT)
|
||
ref_palette = ref_palette_img.getpalette()
|
||
|
||
# Find transparent index from the shared palette matching bg_color
|
||
trans_index = 0
|
||
for idx in range(0, len(ref_palette), 3):
|
||
if ref_palette[idx] == bg_color[0] and ref_palette[idx + 1] == bg_color[1] and ref_palette[idx + 2] == bg_color[2]:
|
||
trans_index = idx // 3
|
||
break
|
||
|
||
gif_frames = []
|
||
for frame in rgba_frames:
|
||
canvas = Image.new('RGB', frame.size, bg_color)
|
||
canvas.paste(frame, mask=frame.split()[3])
|
||
|
||
p_frame = canvas.quantize(palette=ref_palette_img, dither=0)
|
||
|
||
alpha = frame.split()[3]
|
||
p_data = list(p_frame.getdata())
|
||
a_data = list(alpha.getdata())
|
||
for j in range(len(p_data)):
|
||
if a_data[j] < 128:
|
||
p_data[j] = trans_index
|
||
|
||
p_frame.putdata(p_data)
|
||
p_frame.info['transparency'] = trans_index
|
||
gif_frames.append(p_frame)
|
||
|
||
gif_frames[0].save(
|
||
output_path,
|
||
save_all=True,
|
||
append_images=gif_frames[1:],
|
||
duration=duration,
|
||
loop=0,
|
||
disposal=2,
|
||
transparency=trans_index,
|
||
)
|
||
|
||
|
||
def _resize_frames(frames, size):
|
||
"""Resize RGBA frames to a new size using NEAREST for pixel art."""
|
||
resized = []
|
||
for f in frames:
|
||
resized.append(f.resize((size, size), Image.NEAREST))
|
||
return resized
|
||
|
||
|
||
def process_image(input_path, emotion, output_path, name=None):
|
||
"""Process a single image: remove bg, generate animated GIF.
|
||
|
||
Outputs two files:
|
||
{emotion}.gif — DISPLAY_SIZE for chat
|
||
{emotion}@2x.gif — OUTPUT_SIZE for sharing
|
||
"""
|
||
img = Image.open(input_path)
|
||
img = remove_background(img)
|
||
|
||
config = ANIMATIONS[emotion]
|
||
generator = FRAME_GENERATORS[config['type']]
|
||
frames = generator(img, config)
|
||
|
||
# Save display size without watermark (too small to read)
|
||
display_frames = _resize_frames(frames, DISPLAY_SIZE)
|
||
_save_gif(display_frames, output_path, config['duration'])
|
||
|
||
# Save @2x with watermark (full resolution)
|
||
if name:
|
||
frames = [add_watermark(f, name) for f in frames]
|
||
base, ext = os.path.splitext(output_path)
|
||
hires_path = f'{base}@2x{ext}'
|
||
_save_gif(frames, hires_path, config['duration'])
|
||
|
||
|
||
def add_watermark(img, name):
|
||
"""Add a subtle 'ColaOS · {name}' watermark in the bottom-right corner."""
|
||
w, h = img.size
|
||
is_rgba = img.mode == 'RGBA'
|
||
|
||
result = img.copy()
|
||
overlay = Image.new('RGBA', (w, h), (0, 0, 0, 0))
|
||
draw = ImageDraw.Draw(overlay)
|
||
|
||
font = load_font(10)
|
||
text = f'{BRAND_NAME} · {name}'
|
||
bbox = draw.textbbox((0, 0), text, font=font)
|
||
text_w = bbox[2] - bbox[0]
|
||
text_h = bbox[3] - bbox[1]
|
||
|
||
# Bottom-right corner with padding
|
||
margin = 6
|
||
x = w - text_w - margin
|
||
y = h - text_h - margin
|
||
|
||
# Semi-transparent white text
|
||
draw.text((x, y), text, fill=(255, 255, 255, 120), font=font)
|
||
|
||
if is_rgba:
|
||
result = Image.alpha_composite(result, overlay)
|
||
else:
|
||
result = result.convert('RGBA')
|
||
result = Image.alpha_composite(result, overlay)
|
||
|
||
return result
|
||
|
||
|
||
def save_base_image(input_path, output_path, name=None):
|
||
"""Save base image as transparent PNG in two sizes, plus the original.
|
||
|
||
Outputs:
|
||
base_image_original.png — original source image (background removed, uncropped)
|
||
base_image.png — DISPLAY_SIZE for chat (no watermark)
|
||
base_image@2x.png — OUTPUT_SIZE for sharing (with watermark)
|
||
"""
|
||
img = Image.open(input_path)
|
||
img = remove_background(img)
|
||
|
||
# Save original-resolution copy (background removed, before fit_to_canvas)
|
||
# Callers control when this function runs; when called, always refresh original.
|
||
base, ext = os.path.splitext(output_path)
|
||
original_path = f'{base}_original{ext}'
|
||
img.save(original_path, 'PNG')
|
||
|
||
canvas, _, _, _, _ = fit_to_canvas(img, OUTPUT_SIZE)
|
||
|
||
# Save @2x with watermark
|
||
hires_path = f'{base}@2x{ext}'
|
||
hires = add_watermark(canvas, name) if name else canvas
|
||
hires.save(hires_path, 'PNG')
|
||
|
||
# Save display size without watermark
|
||
display = canvas.resize((DISPLAY_SIZE, DISPLAY_SIZE), Image.NEAREST)
|
||
display.save(output_path, 'PNG')
|
||
|
||
|
||
# === Meme sticker generators ===
|
||
|
||
def _save_meme(result, output_path):
|
||
"""Save a meme sticker in both @2x and display sizes."""
|
||
base, ext = os.path.splitext(output_path)
|
||
result.save(f'{base}@2x{ext}', 'PNG')
|
||
result.resize((DISPLAY_SIZE, DISPLAY_SIZE), Image.NEAREST).save(output_path, 'PNG')
|
||
|
||
|
||
def generate_meme_confused(input_path, output_path):
|
||
"""Confused meme: AI-generated confused pose + single "?" symbol.
|
||
|
||
The character should already be in a confused pose (head tilted,
|
||
scratching head, body leaning). We add a single "?" to reinforce.
|
||
"""
|
||
img = Image.open(input_path)
|
||
img = remove_background(img)
|
||
canvas, _, _, _, _ = fit_to_canvas(img, OUTPUT_SIZE)
|
||
|
||
result = canvas.copy()
|
||
overlay = Image.new('RGBA', (OUTPUT_SIZE, OUTPUT_SIZE), (0, 0, 0, 0))
|
||
draw = ImageDraw.Draw(overlay)
|
||
|
||
# Find character position to place "?" relative to head
|
||
content_bbox = canvas.split()[3].getbbox()
|
||
if content_bbox:
|
||
# Place "?" to the right of head, upper area
|
||
qx = min(content_bbox[2] + 5, OUTPUT_SIZE - 50)
|
||
qy = content_bbox[1]
|
||
else:
|
||
qx = OUTPUT_SIZE - 60
|
||
qy = 20
|
||
|
||
font = load_pixel_font(70)
|
||
# Shadow
|
||
draw.text((qx + 2, qy + 2), "?", fill=(0, 0, 0, 60), font=font)
|
||
# Purple "?" (like the reference)
|
||
draw.text((qx, qy), "?", fill=(160, 100, 200, 230), font=font)
|
||
|
||
result = Image.alpha_composite(result, overlay)
|
||
_save_meme(result, output_path)
|
||
|
||
|
||
def generate_meme_annoyed(input_path, output_path):
|
||
"""Annoyed meme: AI-generated annoyed pose + scribble cloud.
|
||
|
||
The character should already be in an annoyed pose (half-closed eyes,
|
||
pursed lips, arms crossed, hunched). We add a scribble cloud near
|
||
the head to convey frustration/mental chaos.
|
||
"""
|
||
img = Image.open(input_path)
|
||
img = remove_background(img)
|
||
canvas, _, _, _, _ = fit_to_canvas(img, OUTPUT_SIZE)
|
||
|
||
result = canvas.copy()
|
||
overlay = Image.new('RGBA', (OUTPUT_SIZE, OUTPUT_SIZE), (0, 0, 0, 0))
|
||
draw = ImageDraw.Draw(overlay)
|
||
|
||
# Find head area for scribble placement — tight to the head
|
||
content_bbox = canvas.split()[3].getbbox()
|
||
if content_bbox:
|
||
# Scribble right at head, overlapping slightly
|
||
scrib_cx = content_bbox[2] - 5
|
||
scrib_cy = content_bbox[1] + 10
|
||
else:
|
||
scrib_cx = OUTPUT_SIZE - 50
|
||
scrib_cy = 40
|
||
|
||
# Draw scribble: dense tangled loops, round like hand-drawn doodle.
|
||
scrib_color = (40, 40, 40, 230)
|
||
import random
|
||
rng = random.Random(42)
|
||
r_cloud = 30
|
||
|
||
# Dense overlapping circular loops with varied sizes
|
||
# Smaller loops in center (tight tangle), bigger loops at edges (loose ends)
|
||
for _ in range(28):
|
||
ox = rng.randint(-r_cloud + 3, r_cloud - 3)
|
||
oy = rng.randint(-r_cloud + 3, r_cloud - 3)
|
||
dist = (ox * ox + oy * oy) ** 0.5
|
||
# Closer to center = smaller tighter loops
|
||
if dist < r_cloud * 0.5:
|
||
rx = rng.randint(6, 14)
|
||
ry = rng.randint(6, 12)
|
||
else:
|
||
rx = rng.randint(12, 22)
|
||
ry = rng.randint(10, 20)
|
||
start = rng.randint(0, 360)
|
||
extent = rng.randint(220, 350)
|
||
bbox = [scrib_cx + ox - rx, scrib_cy + oy - ry,
|
||
scrib_cx + ox + rx, scrib_cy + oy + ry]
|
||
draw.arc(bbox, start, start + extent, fill=scrib_color, width=2)
|
||
|
||
result = Image.alpha_composite(result, overlay)
|
||
_save_meme(result, output_path)
|
||
|
||
|
||
def generate_meme_cracked(input_path, output_path, locale='zh'):
|
||
"""Cracked meme: AI-generated distressed pose + lightning crack on face + text.
|
||
|
||
The character should be in a weary/collapsed expression. We draw a
|
||
single lightning-bolt crack down the face (NOT splitting the image)
|
||
and add "裂开"/"cracked" text above. References the classic Chinese meme format.
|
||
|
||
Adaptive behaviors:
|
||
- Crack color: light gray on dark characters, dark gray on light characters.
|
||
- Text position: shifts to side if character decorations (ears/horns/hats)
|
||
overlap with the default center-top text area.
|
||
"""
|
||
img = Image.open(input_path)
|
||
img = remove_background(img)
|
||
canvas, _, _, _, _ = fit_to_canvas(img, OUTPUT_SIZE)
|
||
|
||
result = canvas.copy()
|
||
overlay = Image.new('RGBA', (OUTPUT_SIZE, OUTPUT_SIZE), (0, 0, 0, 0))
|
||
draw = ImageDraw.Draw(overlay)
|
||
|
||
content_bbox = canvas.split()[3].getbbox()
|
||
if content_bbox:
|
||
face_cx = (content_bbox[0] + content_bbox[2]) // 2
|
||
face_top = content_bbox[1]
|
||
face_bottom = content_bbox[1] + (content_bbox[3] - content_bbox[1]) * 2 // 3
|
||
else:
|
||
face_cx = OUTPUT_SIZE // 2
|
||
face_top = 30
|
||
face_bottom = OUTPUT_SIZE * 2 // 3
|
||
|
||
# --- Adaptive crack color: sample character brightness along crack path ---
|
||
pixels = canvas.load()
|
||
|
||
# Refine face_cx: use centroid of opaque pixels in upper 1/3 of character
|
||
# (head region) instead of full bbox center, so the crack centers on the
|
||
# head even when the distressed pose has asymmetric limbs.
|
||
if content_bbox:
|
||
head_bottom = content_bbox[1] + (content_bbox[3] - content_bbox[1]) // 3
|
||
cx_sum, cx_count = 0, 0
|
||
for y_s in range(content_bbox[1], head_bottom):
|
||
for x_s in range(content_bbox[0], content_bbox[2]):
|
||
if pixels[x_s, y_s][3] > 128:
|
||
cx_sum += x_s
|
||
cx_count += 1
|
||
if cx_count > 0:
|
||
face_cx = cx_sum // cx_count
|
||
|
||
brightness_samples = []
|
||
for y_s in range(face_top, face_bottom, 4):
|
||
for x_s in range(max(0, face_cx - 15), min(OUTPUT_SIZE, face_cx + 15)):
|
||
r, g, b, a = pixels[x_s, y_s]
|
||
if a > 128:
|
||
brightness_samples.append(0.299 * r + 0.587 * g + 0.114 * b)
|
||
|
||
avg_brightness = sum(brightness_samples) / len(brightness_samples) if brightness_samples else 128
|
||
|
||
if avg_brightness < 120:
|
||
crack_color = (200, 195, 190, 230)
|
||
text_color = (60, 55, 50, 240)
|
||
text_shadow = (255, 255, 255, 80)
|
||
else:
|
||
crack_color = (90, 85, 80, 230)
|
||
text_color = (50, 40, 35, 240)
|
||
text_shadow = (0, 0, 0, 80)
|
||
|
||
# --- Adaptive text position: avoid overlapping character decorations ---
|
||
font = load_font(30)
|
||
text = "裂开" if locale == 'zh' else "cracked"
|
||
text_bbox = draw.textbbox((0, 0), text, font=font)
|
||
tw = text_bbox[2] - text_bbox[0]
|
||
th = text_bbox[3] - text_bbox[1]
|
||
|
||
top_margin = 4
|
||
text_center_x = face_cx - tw // 2
|
||
|
||
has_overlap = False
|
||
if content_bbox and content_bbox[1] < top_margin + th + 8:
|
||
for ty_c in range(top_margin, min(top_margin + th, OUTPUT_SIZE)):
|
||
for tx_c in range(max(0, text_center_x), min(OUTPUT_SIZE, text_center_x + tw)):
|
||
_, _, _, a = pixels[tx_c, ty_c]
|
||
if a > 64:
|
||
has_overlap = True
|
||
break
|
||
if has_overlap:
|
||
break
|
||
|
||
if has_overlap:
|
||
left_space = content_bbox[0]
|
||
right_space = OUTPUT_SIZE - content_bbox[2]
|
||
if right_space >= tw + 8:
|
||
tx = content_bbox[2] + 4
|
||
ty = face_top
|
||
elif left_space >= tw + 8:
|
||
tx = content_bbox[0] - tw - 4
|
||
ty = face_top
|
||
else:
|
||
tx = text_center_x
|
||
ty = top_margin
|
||
else:
|
||
tx = text_center_x
|
||
ty = top_margin
|
||
|
||
tx = max(4, min(OUTPUT_SIZE - tw - 4, tx))
|
||
ty = max(2, ty)
|
||
|
||
draw.text((tx + 1, ty + 1), text, fill=text_shadow, font=font)
|
||
draw.text((tx, ty), text, fill=text_color, font=font)
|
||
|
||
# --- Lightning bolt crack: starts below text, tapers thick→thin ---
|
||
crack_top = ty + th + 4
|
||
crack_h = face_bottom - crack_top
|
||
if crack_h < 20:
|
||
crack_top = face_top + 10
|
||
crack_h = face_bottom - crack_top
|
||
|
||
x, y = face_cx, crack_top
|
||
points = [(x, y)]
|
||
bolt_segments = [
|
||
(12, crack_h // 5),
|
||
(-20, crack_h // 5),
|
||
(18, crack_h // 5),
|
||
(-16, crack_h // 5),
|
||
(6, crack_h // 5),
|
||
]
|
||
for dx, dy in bolt_segments:
|
||
x += dx
|
||
y += dy
|
||
points.append((x, min(y, face_bottom)))
|
||
|
||
widths = [12, 9, 7, 5, 4]
|
||
for i in range(len(points) - 1):
|
||
w = widths[i] if i < len(widths) else 3
|
||
draw.line([points[i], points[i + 1]], fill=crack_color, width=w)
|
||
|
||
result = Image.alpha_composite(result, overlay)
|
||
_save_meme(result, output_path)
|
||
|
||
|
||
CARD_BG = (255, 255, 255)
|
||
|
||
# Five-element color mapping
|
||
WUXING_COLORS = {
|
||
'wood': {'primary': (45, 90, 39), 'light': (70, 130, 90)},
|
||
'fire': {'primary': (196, 30, 58), 'light': (213, 80, 80)},
|
||
'metal': {'primary': (140, 120, 75), 'light': (170, 155, 120)},
|
||
'water': {'primary': (27, 58, 92), 'light': (90, 127, 166)},
|
||
'earth': {'primary': (150, 110, 8), 'light': (196, 150, 58)},
|
||
}
|
||
|
||
# Rarity → number of filled diamonds (out of 5)
|
||
RARITY_FILLED = {
|
||
'common': 2,
|
||
'rare': 3,
|
||
'legendary': 5,
|
||
}
|
||
|
||
PIXEL_FONT_CANDIDATES = [
|
||
'/System/Library/Fonts/Menlo.ttc',
|
||
'/System/Library/Fonts/Courier.ttc',
|
||
'/System/Library/Fonts/Monaco.ttf',
|
||
]
|
||
|
||
FONT_CANDIDATES = [
|
||
'/System/Library/Fonts/Hiragino Sans GB.ttc',
|
||
'/System/Library/Fonts/STHeiti Medium.ttc',
|
||
'/System/Library/Fonts/STHeiti Light.ttc',
|
||
'/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
|
||
'/Library/Fonts/Arial Unicode.ttf',
|
||
]
|
||
|
||
|
||
def load_font(size):
|
||
for path in FONT_CANDIDATES:
|
||
try:
|
||
return ImageFont.truetype(path, size)
|
||
except (OSError, IOError):
|
||
continue
|
||
return ImageFont.load_default()
|
||
|
||
|
||
def load_pixel_font(size):
|
||
for path in PIXEL_FONT_CANDIDATES:
|
||
try:
|
||
return ImageFont.truetype(path, size)
|
||
except (OSError, IOError):
|
||
continue
|
||
return load_font(size)
|
||
|
||
|
||
def draw_rounded_rect(draw, xy, radius, fill=None, outline=None, width=1):
|
||
"""Draw a rounded rectangle using pieslice for clean corners."""
|
||
x0, y0, x1, y1 = xy
|
||
r = radius
|
||
d = r * 2
|
||
|
||
if fill:
|
||
# Fill: center + top/bottom strips + 4 corner pies
|
||
draw.rectangle([x0 + r, y0, x1 - r, y1], fill=fill)
|
||
draw.rectangle([x0, y0 + r, x1, y1 - r], fill=fill)
|
||
draw.pieslice([x0, y0, x0 + d, y0 + d], 180, 270, fill=fill)
|
||
draw.pieslice([x1 - d, y0, x1, y0 + d], 270, 360, fill=fill)
|
||
draw.pieslice([x0, y1 - d, x0 + d, y1], 90, 180, fill=fill)
|
||
draw.pieslice([x1 - d, y1 - d, x1, y1], 0, 90, fill=fill)
|
||
|
||
if outline:
|
||
# Outline: 4 arcs + 4 lines
|
||
draw.arc([x0, y0, x0 + d, y0 + d], 180, 270, fill=outline, width=width)
|
||
draw.arc([x1 - d, y0, x1, y0 + d], 270, 360, fill=outline, width=width)
|
||
draw.arc([x0, y1 - d, x0 + d, y1], 90, 180, fill=outline, width=width)
|
||
draw.arc([x1 - d, y1 - d, x1, y1], 0, 90, fill=outline, width=width)
|
||
draw.line([x0 + r, y0, x1 - r, y0], fill=outline, width=width)
|
||
draw.line([x0 + r, y1, x1 - r, y1], fill=outline, width=width)
|
||
draw.line([x0, y0 + r, x0, y1 - r], fill=outline, width=width)
|
||
draw.line([x1, y0 + r, x1, y1 - r], fill=outline, width=width)
|
||
|
||
|
||
def draw_diamond(draw, cx, cy, size, fill=None, outline=None):
|
||
"""Draw a pixel diamond (rotated square) centered at (cx, cy).
|
||
|
||
size: half-width of the diamond (total width = size*2 + 1).
|
||
fill: RGBA tuple for filled diamond, None for outline only.
|
||
outline: RGBA tuple for the outline color.
|
||
"""
|
||
points = [(cx, cy - size), (cx + size, cy), (cx, cy + size), (cx - size, cy)]
|
||
if fill:
|
||
draw.polygon(points, fill=fill, outline=outline or fill)
|
||
elif outline:
|
||
draw.polygon(points, fill=None, outline=outline)
|
||
|
||
|
||
def draw_rarity_diamonds(draw, cx, y, rarity, color, scale):
|
||
"""Draw 5 diamonds centered at (cx, y). Filled count determined by rarity.
|
||
|
||
color: RGBA tuple for the wuxing light color.
|
||
"""
|
||
filled_count = RARITY_FILLED.get(rarity, 1)
|
||
diamond_size = 4 * scale # half-width: 4*2=8px @2x → 9x9 visible diamond
|
||
spacing = 14 * scale # center-to-center distance
|
||
total_count = 5
|
||
total_w = (total_count - 1) * spacing
|
||
start_x = cx - total_w // 2
|
||
|
||
# Filled color = full opacity, outline color = 30% opacity premixed with white bg
|
||
filled_color = color + (255,)
|
||
# Premultiply outline alpha onto white background to avoid transparency holes
|
||
# when ImageDraw replaces opaque card pixels with semi-transparent ones.
|
||
alpha_frac = 77 / 255
|
||
outline_rgb = tuple(int(c * alpha_frac + 255 * (1 - alpha_frac)) for c in color)
|
||
outline_color = outline_rgb + (255,)
|
||
|
||
for i in range(total_count):
|
||
dx = start_x + i * spacing
|
||
if i < filled_count:
|
||
draw_diamond(draw, dx, y, diamond_size, fill=filled_color)
|
||
else:
|
||
draw_diamond(draw, dx, y, diamond_size, outline=outline_color)
|
||
|
||
|
||
def generate_profile_card(avatar_path, name, line1, line2, output_path, wuxing='wood', rarity='common'):
|
||
"""Generate a vertical profile card with pixel font, wuxing colors, and rounded border.
|
||
Renders at 2x resolution for Retina clarity.
|
||
Rarity shown as filled/outline diamonds at the top of the card."""
|
||
# Prefer original-resolution source if available (avoids upscale artifacts on regen)
|
||
base, ext = os.path.splitext(avatar_path)
|
||
original_path = f'{base}_original{ext}'
|
||
if os.path.exists(original_path):
|
||
avatar = Image.open(original_path).convert('RGBA')
|
||
else:
|
||
avatar = Image.open(avatar_path).convert('RGBA')
|
||
avatar = remove_background(avatar)
|
||
|
||
scale = 2
|
||
card_w = 320 * scale
|
||
card_h = 420 * scale
|
||
avatar_size = 180 * scale
|
||
padding = 24 * scale
|
||
border_radius = 16 * scale
|
||
border_width = 2 * scale
|
||
|
||
# Colors determined solely by wuxing — rarity does not affect colors
|
||
wx = WUXING_COLORS.get(wuxing, WUXING_COLORS['wood'])
|
||
name_color = wx['primary']
|
||
border_color = wx['light']
|
||
card_bg = CARD_BG
|
||
|
||
# Create card — minimal padding for border rendering
|
||
edge = border_width
|
||
canvas_w = card_w + edge * 2
|
||
canvas_h = card_h + edge * 2
|
||
canvas = Image.new('RGBA', (canvas_w, canvas_h), (0, 0, 0, 0))
|
||
draw = ImageDraw.Draw(canvas)
|
||
|
||
card_x = edge
|
||
card_y = edge
|
||
|
||
# Card border — uniform 40% opacity
|
||
border_color_alpha = border_color + (102,)
|
||
# Draw border as a separate layer for alpha support
|
||
border_layer = Image.new('RGBA', (canvas_w, canvas_h), (0, 0, 0, 0))
|
||
border_draw = ImageDraw.Draw(border_layer)
|
||
draw_rounded_rect(border_draw,
|
||
[card_x, card_y, card_x + card_w, card_y + card_h],
|
||
border_radius, fill=None, outline=border_color_alpha, width=border_width)
|
||
# Card fill
|
||
draw_rounded_rect(draw,
|
||
[card_x + border_width, card_y + border_width,
|
||
card_x + card_w - border_width, card_y + card_h - border_width],
|
||
border_radius - border_width, fill=card_bg + (255,))
|
||
canvas = Image.alpha_composite(canvas, border_layer)
|
||
draw = ImageDraw.Draw(canvas)
|
||
|
||
# Rarity diamonds — centered below top border
|
||
diamond_y = card_y + padding
|
||
diamond_cx = card_x + card_w // 2
|
||
draw_rarity_diamonds(draw, diamond_cx, diamond_y, rarity, border_color, scale)
|
||
|
||
# Resize and place avatar (centered, below diamonds)
|
||
avatar_fit, _, _, _, _ = fit_to_canvas(avatar, avatar_size)
|
||
avatar_x = card_x + (card_w - avatar_size) // 2
|
||
avatar_y = card_y + padding + 16 * scale
|
||
|
||
canvas.paste(avatar_fit, (avatar_x, avatar_y), avatar_fit)
|
||
|
||
# Draw text below avatar
|
||
draw = ImageDraw.Draw(canvas) # re-create after paste
|
||
|
||
# === TEXT RENDERING ===
|
||
|
||
# Name: wuxing primary color, sized to not overpower the avatar
|
||
font_name = load_font(32 * scale)
|
||
name_bbox = draw.textbbox((0, 0), name, font=font_name)
|
||
name_w = name_bbox[2] - name_bbox[0]
|
||
name_x = card_x + (card_w - name_w) // 2
|
||
name_y = avatar_y + avatar_size + 24 * scale + 10 * scale
|
||
draw.text((name_x, name_y), name, fill=name_color, font=font_name)
|
||
|
||
# Text area max width (card width minus padding on both sides)
|
||
text_max_w = card_w - padding * 4
|
||
|
||
def draw_centered_text_wrapped(draw, text, y, font, color, max_w):
|
||
"""Draw centered text, wrapping if it exceeds max width."""
|
||
bbox = draw.textbbox((0, 0), text, font=font)
|
||
text_w = bbox[2] - bbox[0]
|
||
line_h = bbox[3] - bbox[1] + 4 * scale
|
||
|
||
if text_w <= max_w:
|
||
# Fits in one line
|
||
tx = card_x + (card_w - text_w) // 2
|
||
draw.text((tx, y), text, fill=color, font=font)
|
||
return y + line_h
|
||
|
||
# Wrap: find split point
|
||
mid = len(text) // 2
|
||
# Look for comma or space near middle
|
||
best = mid
|
||
for offset in range(min(8, mid)):
|
||
for pos in [mid + offset, mid - offset]:
|
||
if 0 <= pos < len(text) and text[pos] in ',,、 ':
|
||
best = pos + 1
|
||
break
|
||
else:
|
||
continue
|
||
break
|
||
|
||
part1 = text[:best].rstrip(',, ')
|
||
part2 = text[best:].lstrip(',, ')
|
||
|
||
for part in [part1, part2]:
|
||
pb = draw.textbbox((0, 0), part, font=font)
|
||
pw = pb[2] - pb[0]
|
||
px = card_x + (card_w - pw) // 2
|
||
draw.text((px, y), part, fill=color, font=font)
|
||
y += line_h
|
||
return y
|
||
|
||
# Line 1 & 2: unified neutral gray, same font size for readability
|
||
desc_color = (90, 90, 90)
|
||
desc_font = load_font(16 * scale)
|
||
line1_end_y = draw_centered_text_wrapped(draw, line1, name_y + 64 * scale, desc_font, desc_color, text_max_w)
|
||
|
||
if line2 and line2.strip():
|
||
line1_end_y = draw_centered_text_wrapped(draw, line2, line1_end_y + 6 * scale, desc_font, desc_color, text_max_w)
|
||
|
||
# ColaOS branding — centered between last tagline and card bottom
|
||
brand_font = load_pixel_font(9 * scale)
|
||
brand_text = BRAND_NAME
|
||
brand_bbox = draw.textbbox((0, 0), brand_text, font=brand_font)
|
||
brand_w = brand_bbox[2] - brand_bbox[0]
|
||
brand_h = brand_bbox[3] - brand_bbox[1]
|
||
brand_x = card_x + (card_w - brand_w) // 2
|
||
brand_y = line1_end_y + (card_y + card_h - line1_end_y - brand_h) // 2
|
||
draw.text((brand_x, brand_y), brand_text, fill=border_color, font=brand_font)
|
||
|
||
# Save as PNG
|
||
canvas.save(output_path, 'PNG')
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description='Cola Avatar Pack Processor')
|
||
parser.add_argument('--base', required=True, help='Path to base/happy image')
|
||
parser.add_argument('--sad', help='Path to sad image')
|
||
parser.add_argument('--angry', help='Path to angry image')
|
||
parser.add_argument('--thinking', help='Path to thinking image')
|
||
parser.add_argument('--name', required=True, help='Cola name')
|
||
parser.add_argument('--line1', help='Profile tagline for card')
|
||
parser.add_argument('--line2', help='Secondary tagline for card')
|
||
parser.add_argument('--output', required=True, help='Output directory')
|
||
parser.add_argument('--wuxing', default='wood',
|
||
help='Five-element type: wood/fire/earth/metal/water')
|
||
parser.add_argument('--rarity', default='common',
|
||
help='Rarity tier: common/rare/legendary')
|
||
parser.add_argument('--profile-only', action='store_true',
|
||
help='Only generate profile card (Phase 1)')
|
||
parser.add_argument('--direct', action='store_true',
|
||
help='Output directly to --output dir (no subdirectory)')
|
||
parser.add_argument('--meme-confused', help='Path to confused pose image for meme')
|
||
parser.add_argument('--meme-annoyed', help='Path to annoyed pose image for meme')
|
||
parser.add_argument('--meme-cracked', help='Path to distressed pose image for cracked meme')
|
||
parser.add_argument('--regen-happy', action='store_true',
|
||
help='Regenerate happy.gif during expression-only regen')
|
||
parser.add_argument('--locale', default='zh',
|
||
help='Locale for meme text: zh or en')
|
||
args = parser.parse_args()
|
||
|
||
# Expand ~ in file paths
|
||
args.base = os.path.expanduser(args.base)
|
||
if args.sad:
|
||
args.sad = os.path.expanduser(args.sad)
|
||
if args.angry:
|
||
args.angry = os.path.expanduser(args.angry)
|
||
if args.thinking:
|
||
args.thinking = os.path.expanduser(args.thinking)
|
||
for attr in ('meme_confused', 'meme_annoyed', 'meme_cracked'):
|
||
val = getattr(args, attr.replace('-', '_'), None)
|
||
if val:
|
||
setattr(args, attr.replace('-', '_'), os.path.expanduser(val))
|
||
|
||
# Validate --name: letters, digits, CJK, spaces, hyphens, underscores, dots; max 64 chars
|
||
if not re.match(r'^[\w\s\-.\u4e00-\u9fff\u3400-\u4dbf]{1,64}$', args.name):
|
||
print(f'Error: invalid name "{args.name}" — '
|
||
'only letters, digits, CJK, spaces, hyphens, underscores, dots allowed (max 64 chars)',
|
||
file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
if args.direct:
|
||
output_dir = os.path.expanduser(args.output)
|
||
else:
|
||
output_dir = os.path.join(os.path.expanduser(args.output), f'cola_avatar_pack_{args.name}')
|
||
os.makedirs(output_dir, exist_ok=True)
|
||
|
||
# Save base image as PNG — skip if only regenerating expressions (original already exists)
|
||
base_output = os.path.join(output_dir, 'base_image.png')
|
||
original_exists = os.path.exists(os.path.join(output_dir, 'base_image_original.png'))
|
||
is_expression_only = original_exists and not args.line1 and not args.profile_only
|
||
if is_expression_only:
|
||
print(f'Skipping base image (original already exists)')
|
||
else:
|
||
print(f'Saving base image → {base_output}')
|
||
save_base_image(args.base, base_output, name=args.name)
|
||
|
||
# Generate profile card if at least line1 provided
|
||
if args.line1:
|
||
card_output = os.path.join(output_dir, 'profile_card.png')
|
||
print(f'Generating profile card → {card_output}')
|
||
generate_profile_card(
|
||
base_output, args.name, args.line1, args.line2 or '', card_output,
|
||
wuxing=args.wuxing, rarity=args.rarity
|
||
)
|
||
|
||
if args.profile_only:
|
||
print(f'\nProfile card done! Output: {output_dir}')
|
||
return
|
||
|
||
# Process expression images — skip any not provided
|
||
images = {
|
||
'sad': args.sad,
|
||
'angry': args.angry,
|
||
'thinking': args.thinking,
|
||
}
|
||
# Full generation always includes happy.
|
||
# Expression-only regen includes happy only when explicitly requested.
|
||
if not is_expression_only or args.regen_happy:
|
||
images['happy'] = args.base
|
||
|
||
available = {}
|
||
for emotion, path in images.items():
|
||
if path and os.path.exists(path):
|
||
available[emotion] = path
|
||
elif path:
|
||
print(f'Warning: {emotion} image not found: {path}, skipping', file=sys.stderr)
|
||
|
||
# Meme requests are independent from expression generation
|
||
has_meme_input = any([args.meme_confused, args.meme_annoyed, args.meme_cracked])
|
||
|
||
if not available and not has_meme_input:
|
||
print('Error: no expression or meme images found', file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
# Generate GIFs only when expression inputs exist
|
||
for emotion, input_path in available.items():
|
||
output_path = os.path.join(output_dir, f'{emotion}.gif')
|
||
print(f'Generating {emotion}.gif → {output_path}')
|
||
process_image(input_path, emotion, output_path, name=args.name)
|
||
|
||
# Generate meme stickers
|
||
if args.meme_confused:
|
||
meme_path = os.path.join(output_dir, 'meme_confused.png')
|
||
print(f'Generating meme_confused → {meme_path}')
|
||
generate_meme_confused(args.meme_confused, meme_path)
|
||
|
||
if args.meme_annoyed:
|
||
meme_path = os.path.join(output_dir, 'meme_annoyed.png')
|
||
print(f'Generating meme_annoyed → {meme_path}')
|
||
generate_meme_annoyed(args.meme_annoyed, meme_path)
|
||
|
||
if args.meme_cracked:
|
||
meme_path = os.path.join(output_dir, 'meme_cracked.png')
|
||
print(f'Generating meme_cracked → {meme_path}')
|
||
generate_meme_cracked(args.meme_cracked, meme_path, locale=args.locale)
|
||
|
||
print(f'\nDone! Output directory: {output_dir}')
|
||
print(f'Files:')
|
||
for f in sorted(os.listdir(output_dir)):
|
||
size = os.path.getsize(os.path.join(output_dir, f))
|
||
print(f' {f} ({size // 1024}KB)')
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|