469 lines
14 KiB
Python
Executable File
469 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Frame Composer - Utilities for composing visual elements into frames.
|
|
|
|
Provides functions for drawing shapes, text, emojis, and compositing elements
|
|
together to create animation frames.
|
|
"""
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
import numpy as np
|
|
from typing import Optional
|
|
|
|
|
|
def create_blank_frame(width: int, height: int, color: tuple[int, int, int] = (255, 255, 255)) -> Image.Image:
|
|
"""
|
|
Create a blank frame with solid color background.
|
|
|
|
Args:
|
|
width: Frame width
|
|
height: Frame height
|
|
color: RGB color tuple (default: white)
|
|
|
|
Returns:
|
|
PIL Image
|
|
"""
|
|
return Image.new('RGB', (width, height), color)
|
|
|
|
|
|
def draw_circle(frame: Image.Image, center: tuple[int, int], radius: int,
|
|
fill_color: Optional[tuple[int, int, int]] = None,
|
|
outline_color: Optional[tuple[int, int, int]] = None,
|
|
outline_width: int = 1) -> Image.Image:
|
|
"""
|
|
Draw a circle on a frame.
|
|
|
|
Args:
|
|
frame: PIL Image to draw on
|
|
center: (x, y) center position
|
|
radius: Circle radius
|
|
fill_color: RGB fill color (None for no fill)
|
|
outline_color: RGB outline color (None for no outline)
|
|
outline_width: Outline width in pixels
|
|
|
|
Returns:
|
|
Modified frame
|
|
"""
|
|
draw = ImageDraw.Draw(frame)
|
|
x, y = center
|
|
bbox = [x - radius, y - radius, x + radius, y + radius]
|
|
draw.ellipse(bbox, fill=fill_color, outline=outline_color, width=outline_width)
|
|
return frame
|
|
|
|
|
|
def draw_rectangle(frame: Image.Image, top_left: tuple[int, int], bottom_right: tuple[int, int],
|
|
fill_color: Optional[tuple[int, int, int]] = None,
|
|
outline_color: Optional[tuple[int, int, int]] = None,
|
|
outline_width: int = 1) -> Image.Image:
|
|
"""
|
|
Draw a rectangle on a frame.
|
|
|
|
Args:
|
|
frame: PIL Image to draw on
|
|
top_left: (x, y) top-left corner
|
|
bottom_right: (x, y) bottom-right corner
|
|
fill_color: RGB fill color (None for no fill)
|
|
outline_color: RGB outline color (None for no outline)
|
|
outline_width: Outline width in pixels
|
|
|
|
Returns:
|
|
Modified frame
|
|
"""
|
|
draw = ImageDraw.Draw(frame)
|
|
draw.rectangle([top_left, bottom_right], fill=fill_color, outline=outline_color, width=outline_width)
|
|
return frame
|
|
|
|
|
|
def draw_line(frame: Image.Image, start: tuple[int, int], end: tuple[int, int],
|
|
color: tuple[int, int, int] = (0, 0, 0), width: int = 2) -> Image.Image:
|
|
"""
|
|
Draw a line on a frame.
|
|
|
|
Args:
|
|
frame: PIL Image to draw on
|
|
start: (x, y) start position
|
|
end: (x, y) end position
|
|
color: RGB line color
|
|
width: Line width in pixels
|
|
|
|
Returns:
|
|
Modified frame
|
|
"""
|
|
draw = ImageDraw.Draw(frame)
|
|
draw.line([start, end], fill=color, width=width)
|
|
return frame
|
|
|
|
|
|
def draw_text(frame: Image.Image, text: str, position: tuple[int, int],
|
|
font_size: int = 40, color: tuple[int, int, int] = (0, 0, 0),
|
|
centered: bool = False) -> Image.Image:
|
|
"""
|
|
Draw text on a frame.
|
|
|
|
Args:
|
|
frame: PIL Image to draw on
|
|
text: Text to draw
|
|
position: (x, y) position (top-left unless centered=True)
|
|
font_size: Font size in pixels
|
|
color: RGB text color
|
|
centered: If True, center text at position
|
|
|
|
Returns:
|
|
Modified frame
|
|
"""
|
|
draw = ImageDraw.Draw(frame)
|
|
|
|
# Try to use default font, fall back to basic if not available
|
|
try:
|
|
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", font_size)
|
|
except:
|
|
font = ImageFont.load_default()
|
|
|
|
if centered:
|
|
bbox = draw.textbbox((0, 0), text, font=font)
|
|
text_width = bbox[2] - bbox[0]
|
|
text_height = bbox[3] - bbox[1]
|
|
x = position[0] - text_width // 2
|
|
y = position[1] - text_height // 2
|
|
position = (x, y)
|
|
|
|
draw.text(position, text, fill=color, font=font)
|
|
return frame
|
|
|
|
|
|
def draw_emoji(frame: Image.Image, emoji: str, position: tuple[int, int], size: int = 60) -> Image.Image:
|
|
"""
|
|
Draw emoji text on a frame (requires system emoji support).
|
|
|
|
Args:
|
|
frame: PIL Image to draw on
|
|
emoji: Emoji character(s)
|
|
position: (x, y) position
|
|
size: Emoji size in pixels
|
|
|
|
Returns:
|
|
Modified frame
|
|
"""
|
|
draw = ImageDraw.Draw(frame)
|
|
|
|
# Use Apple Color Emoji font on macOS
|
|
try:
|
|
font = ImageFont.truetype("/System/Library/Fonts/Apple Color Emoji.ttc", size)
|
|
except:
|
|
# Fallback to text-based emoji
|
|
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size)
|
|
|
|
draw.text(position, emoji, font=font, embedded_color=True)
|
|
return frame
|
|
|
|
|
|
def composite_layers(base: Image.Image, overlay: Image.Image,
|
|
position: tuple[int, int] = (0, 0), alpha: float = 1.0) -> Image.Image:
|
|
"""
|
|
Composite one image on top of another.
|
|
|
|
Args:
|
|
base: Base image
|
|
overlay: Image to overlay on top
|
|
position: (x, y) position to place overlay
|
|
alpha: Opacity of overlay (0.0 = transparent, 1.0 = opaque)
|
|
|
|
Returns:
|
|
Composite image
|
|
"""
|
|
# Convert to RGBA for transparency support
|
|
base_rgba = base.convert('RGBA')
|
|
overlay_rgba = overlay.convert('RGBA')
|
|
|
|
# Apply alpha
|
|
if alpha < 1.0:
|
|
overlay_rgba = overlay_rgba.copy()
|
|
overlay_rgba.putalpha(int(255 * alpha))
|
|
|
|
# Paste overlay onto base
|
|
base_rgba.paste(overlay_rgba, position, overlay_rgba)
|
|
|
|
# Convert back to RGB
|
|
return base_rgba.convert('RGB')
|
|
|
|
|
|
def draw_stick_figure(frame: Image.Image, position: tuple[int, int], scale: float = 1.0,
|
|
color: tuple[int, int, int] = (0, 0, 0), line_width: int = 3) -> Image.Image:
|
|
"""
|
|
Draw a simple stick figure.
|
|
|
|
Args:
|
|
frame: PIL Image to draw on
|
|
position: (x, y) center position of head
|
|
scale: Size multiplier
|
|
color: RGB line color
|
|
line_width: Line width in pixels
|
|
|
|
Returns:
|
|
Modified frame
|
|
"""
|
|
draw = ImageDraw.Draw(frame)
|
|
x, y = position
|
|
|
|
# Scale dimensions
|
|
head_radius = int(15 * scale)
|
|
body_length = int(40 * scale)
|
|
arm_length = int(25 * scale)
|
|
leg_length = int(35 * scale)
|
|
leg_spread = int(15 * scale)
|
|
|
|
# Head
|
|
draw.ellipse([x - head_radius, y - head_radius, x + head_radius, y + head_radius],
|
|
outline=color, width=line_width)
|
|
|
|
# Body
|
|
body_start = y + head_radius
|
|
body_end = body_start + body_length
|
|
draw.line([(x, body_start), (x, body_end)], fill=color, width=line_width)
|
|
|
|
# Arms
|
|
arm_y = body_start + int(body_length * 0.3)
|
|
draw.line([(x - arm_length, arm_y), (x + arm_length, arm_y)], fill=color, width=line_width)
|
|
|
|
# Legs
|
|
draw.line([(x, body_end), (x - leg_spread, body_end + leg_length)], fill=color, width=line_width)
|
|
draw.line([(x, body_end), (x + leg_spread, body_end + leg_length)], fill=color, width=line_width)
|
|
|
|
return frame
|
|
|
|
|
|
def create_gradient_background(width: int, height: int,
|
|
top_color: tuple[int, int, int],
|
|
bottom_color: tuple[int, int, int]) -> Image.Image:
|
|
"""
|
|
Create a vertical gradient background.
|
|
|
|
Args:
|
|
width: Frame width
|
|
height: Frame height
|
|
top_color: RGB color at top
|
|
bottom_color: RGB color at bottom
|
|
|
|
Returns:
|
|
PIL Image with gradient
|
|
"""
|
|
frame = Image.new('RGB', (width, height))
|
|
draw = ImageDraw.Draw(frame)
|
|
|
|
# Calculate color step for each row
|
|
r1, g1, b1 = top_color
|
|
r2, g2, b2 = bottom_color
|
|
|
|
for y in range(height):
|
|
# Interpolate color
|
|
ratio = y / height
|
|
r = int(r1 * (1 - ratio) + r2 * ratio)
|
|
g = int(g1 * (1 - ratio) + g2 * ratio)
|
|
b = int(b1 * (1 - ratio) + b2 * ratio)
|
|
|
|
# Draw horizontal line
|
|
draw.line([(0, y), (width, y)], fill=(r, g, b))
|
|
|
|
return frame
|
|
|
|
|
|
def draw_emoji_enhanced(frame: Image.Image, emoji: str, position: tuple[int, int],
|
|
size: int = 60, shadow: bool = True,
|
|
shadow_offset: tuple[int, int] = (2, 2)) -> Image.Image:
|
|
"""
|
|
Draw emoji with optional shadow for better visual quality.
|
|
|
|
Args:
|
|
frame: PIL Image to draw on
|
|
emoji: Emoji character(s)
|
|
position: (x, y) position
|
|
size: Emoji size in pixels (minimum 12)
|
|
shadow: Whether to add drop shadow
|
|
shadow_offset: Shadow offset
|
|
|
|
Returns:
|
|
Modified frame
|
|
"""
|
|
draw = ImageDraw.Draw(frame)
|
|
|
|
# Ensure minimum size to avoid font rendering errors
|
|
size = max(12, size)
|
|
|
|
# Use Apple Color Emoji font on macOS
|
|
try:
|
|
font = ImageFont.truetype("/System/Library/Fonts/Apple Color Emoji.ttc", size)
|
|
except:
|
|
# Fallback to text-based emoji
|
|
try:
|
|
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size)
|
|
except:
|
|
font = ImageFont.load_default()
|
|
|
|
# Draw shadow first if enabled
|
|
if shadow and size >= 20: # Only draw shadow for larger emojis
|
|
shadow_pos = (position[0] + shadow_offset[0], position[1] + shadow_offset[1])
|
|
# Draw semi-transparent shadow (simulated by drawing multiple times)
|
|
for offset in range(1, 3):
|
|
try:
|
|
draw.text((shadow_pos[0] + offset, shadow_pos[1] + offset),
|
|
emoji, font=font, embedded_color=True, fill=(0, 0, 0, 100))
|
|
except:
|
|
pass # Skip shadow if it fails
|
|
|
|
# Draw main emoji
|
|
try:
|
|
draw.text(position, emoji, font=font, embedded_color=True)
|
|
except:
|
|
# Fallback to basic drawing if embedded color fails
|
|
draw.text(position, emoji, font=font, fill=(0, 0, 0))
|
|
|
|
return frame
|
|
|
|
|
|
def draw_circle_with_shadow(frame: Image.Image, center: tuple[int, int], radius: int,
|
|
fill_color: tuple[int, int, int],
|
|
shadow_offset: tuple[int, int] = (3, 3),
|
|
shadow_color: tuple[int, int, int] = (0, 0, 0)) -> Image.Image:
|
|
"""
|
|
Draw a circle with drop shadow.
|
|
|
|
Args:
|
|
frame: PIL Image to draw on
|
|
center: (x, y) center position
|
|
radius: Circle radius
|
|
fill_color: RGB fill color
|
|
shadow_offset: (x, y) shadow offset
|
|
shadow_color: RGB shadow color
|
|
|
|
Returns:
|
|
Modified frame
|
|
"""
|
|
draw = ImageDraw.Draw(frame)
|
|
x, y = center
|
|
|
|
# Draw shadow
|
|
shadow_center = (x + shadow_offset[0], y + shadow_offset[1])
|
|
shadow_bbox = [
|
|
shadow_center[0] - radius,
|
|
shadow_center[1] - radius,
|
|
shadow_center[0] + radius,
|
|
shadow_center[1] + radius
|
|
]
|
|
draw.ellipse(shadow_bbox, fill=shadow_color)
|
|
|
|
# Draw main circle
|
|
bbox = [x - radius, y - radius, x + radius, y + radius]
|
|
draw.ellipse(bbox, fill=fill_color)
|
|
|
|
return frame
|
|
|
|
|
|
def draw_rounded_rectangle(frame: Image.Image, top_left: tuple[int, int],
|
|
bottom_right: tuple[int, int], radius: int,
|
|
fill_color: Optional[tuple[int, int, int]] = None,
|
|
outline_color: Optional[tuple[int, int, int]] = None,
|
|
outline_width: int = 1) -> Image.Image:
|
|
"""
|
|
Draw a rectangle with rounded corners.
|
|
|
|
Args:
|
|
frame: PIL Image to draw on
|
|
top_left: (x, y) top-left corner
|
|
bottom_right: (x, y) bottom-right corner
|
|
radius: Corner radius
|
|
fill_color: RGB fill color (None for no fill)
|
|
outline_color: RGB outline color (None for no outline)
|
|
outline_width: Outline width
|
|
|
|
Returns:
|
|
Modified frame
|
|
"""
|
|
draw = ImageDraw.Draw(frame)
|
|
x1, y1 = top_left
|
|
x2, y2 = bottom_right
|
|
|
|
# Draw rounded rectangle using PIL's built-in method
|
|
draw.rounded_rectangle([x1, y1, x2, y2], radius=radius,
|
|
fill=fill_color, outline=outline_color, width=outline_width)
|
|
|
|
return frame
|
|
|
|
|
|
def add_vignette(frame: Image.Image, strength: float = 0.5) -> Image.Image:
|
|
"""
|
|
Add a vignette effect (darkened edges) to frame.
|
|
|
|
Args:
|
|
frame: PIL Image
|
|
strength: Vignette strength (0.0-1.0)
|
|
|
|
Returns:
|
|
Frame with vignette
|
|
"""
|
|
width, height = frame.size
|
|
|
|
# Create radial gradient mask
|
|
center_x, center_y = width // 2, height // 2
|
|
max_dist = ((width / 2) ** 2 + (height / 2) ** 2) ** 0.5
|
|
|
|
# Create overlay
|
|
overlay = Image.new('RGB', (width, height), (0, 0, 0))
|
|
pixels = overlay.load()
|
|
|
|
for y in range(height):
|
|
for x in range(width):
|
|
# Calculate distance from center
|
|
dx = x - center_x
|
|
dy = y - center_y
|
|
dist = (dx ** 2 + dy ** 2) ** 0.5
|
|
|
|
# Calculate vignette value
|
|
vignette = min(1, (dist / max_dist) * strength)
|
|
value = int(255 * (1 - vignette))
|
|
pixels[x, y] = (value, value, value)
|
|
|
|
# Blend with original using multiply
|
|
frame_array = np.array(frame, dtype=np.float32) / 255
|
|
overlay_array = np.array(overlay, dtype=np.float32) / 255
|
|
|
|
result = frame_array * overlay_array
|
|
result = (result * 255).astype(np.uint8)
|
|
|
|
return Image.fromarray(result)
|
|
|
|
|
|
def draw_star(frame: Image.Image, center: tuple[int, int], size: int,
|
|
fill_color: tuple[int, int, int],
|
|
outline_color: Optional[tuple[int, int, int]] = None,
|
|
outline_width: int = 1) -> Image.Image:
|
|
"""
|
|
Draw a 5-pointed star.
|
|
|
|
Args:
|
|
frame: PIL Image to draw on
|
|
center: (x, y) center position
|
|
size: Star size (outer radius)
|
|
fill_color: RGB fill color
|
|
outline_color: RGB outline color (None for no outline)
|
|
outline_width: Outline width
|
|
|
|
Returns:
|
|
Modified frame
|
|
"""
|
|
import math
|
|
draw = ImageDraw.Draw(frame)
|
|
x, y = center
|
|
|
|
# Calculate star points
|
|
points = []
|
|
for i in range(10):
|
|
angle = (i * 36 - 90) * math.pi / 180 # 36 degrees per point, start at top
|
|
radius = size if i % 2 == 0 else size * 0.4 # Alternate between outer and inner
|
|
px = x + radius * math.cos(angle)
|
|
py = y + radius * math.sin(angle)
|
|
points.append((px, py))
|
|
|
|
# Draw star
|
|
draw.polygon(points, fill=fill_color, outline=outline_color, width=outline_width)
|
|
|
|
return frame |