Skip to content

Instantly share code, notes, and snippets.

@motebaya
Created May 7, 2026 18:00
Show Gist options
  • Select an option

  • Save motebaya/80d5b72e5131217f0ff3c270c1fea8cc to your computer and use it in GitHub Desktop.

Select an option

Save motebaya/80d5b72e5131217f0ff3c270c1fea8cc to your computer and use it in GitHub Desktop.
a Twitch watermark generator
"""
Generates a Twitch-branded watermark PNG (transparent background) containing:
* A Twitch Glitch logo rendered from official SVG path data
* A username panel with a white-to-light-grey gradient
* A "FOLLOW" badge with Twitch-purple colour scheme
@github.com/motebaya - 2026-03-30 06:44 PM
"""
from __future__ import annotations
import argparse
import os
import re
from typing import Optional
from PIL import Image, ImageDraw, ImageFont, ImageFilter
FONT_DIR = os.path.join(
os.path.expanduser("~"),
"AppData", "Local", "Microsoft", "Windows", "Fonts",
)
# Outer shape of the Twitch Glitch speech bubble
TWITCH_OUTER_PATH = "M6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0z"
# Two eyes (vertical rectangles) - using absolute coordinates
TWITCH_EYE_LEFT = "M11.571 4.714h1.715v5.143H11.571z"
TWITCH_EYE_RIGHT = "M16.286 4.714H18v5.143h-1.714z"
# Type alias for an RGBA colour tuple
_RGBA = tuple[int, int, int, int]
# Type alias for a list of (x, y) float points
_Polygon = list[tuple[float, float]]
def create_gradient_image(
size: tuple[int, int],
colors: list[_RGBA],
direction: str = "horizontal",
) -> Image.Image:
"""Create an RGBA gradient image transitioning through multiple colour stops.
:param size: ``(width, height)`` of the output image.
:param colors: List of RGBA tuples defining colour stops.
:param direction: ``"horizontal"`` or ``"vertical"``.
:returns: New RGBA :class:`~PIL.Image.Image`.
"""
w, h = size
img = Image.new("RGBA", (w, h))
pixels = img.load()
num_segments = len(colors) - 1
length = w if direction == "horizontal" else h
for i in range(length):
t = i / max(length - 1, 1)
segment = min(int(t * num_segments), num_segments - 1)
local_t = t * num_segments - segment
c1, c2 = colors[segment], colors[segment + 1]
pixel = tuple(int(c1[k] + (c2[k] - c1[k]) * local_t) for k in range(4))
for j in range(h if direction == "horizontal" else w):
if direction == "horizontal":
pixels[i, j] = pixel
else:
pixels[j, i] = pixel
return img
def apply_rounded_mask(img: Image.Image, radius: int) -> Image.Image:
"""Return a copy of *img* with rounded corners applied via an alpha mask.
:param img: Source RGBA image.
:param radius: Corner radius in pixels.
:returns: New RGBA image with rounded corners.
"""
w, h = img.size
mask = Image.new("L", (w, h), 0)
ImageDraw.Draw(mask).rounded_rectangle([0, 0, w - 1, h - 1], radius=radius, fill=255)
result = img.copy()
result.putalpha(mask)
return result
def parse_svg_path(d_string: str) -> list[tuple[str, list[float]]]:
"""Parse an SVG path ``d`` attribute into a list of ``(command, args)`` tuples.
:param d_string: Raw SVG path data string.
:returns: Ordered list of ``(command_letter, [float, …])`` pairs.
"""
tokens = re.findall(r'[MmLlHhVvCcSsQqTtAaZz]|[-+]?[0-9]*\.?[0-9]+', d_string)
commands: list[tuple[str, list[float]]] = []
i = 0
while i < len(tokens):
if tokens[i].isalpha():
cmd = tokens[i]
i += 1
args: list[float] = []
while i < len(tokens) and not tokens[i].isalpha():
args.append(float(tokens[i]))
i += 1
commands.append((cmd, args))
else:
i += 1
return commands
def svg_path_to_polygons(
d_string: str,
scale: float = 1.0,
offset_x: float = 0,
offset_y: float = 0,
) -> list[_Polygon]:
"""Convert an SVG path ``d`` string to a list of polygon point lists.
Supports ``M``, ``L``, ``H``, ``V``, ``C``, ``S``, ``Q``, ``Z`` commands
(both absolute and relative). Curves are approximated with line segments.
:param d_string: SVG path data string.
:param scale: Uniform scale factor applied to all coordinates.
:param offset_x: Horizontal translation (pixels) applied after scaling.
:param offset_y: Vertical translation (pixels) applied after scaling.
:returns: List of polygons, each a list of ``(x, y)`` pixel tuples.
"""
def transform(x: float, y: float) -> tuple[float, float]:
return (x * scale + offset_x, y * scale + offset_y)
def bezier_cubic(
p0: tuple[float, float], p1: tuple[float, float],
p2: tuple[float, float], p3: tuple[float, float],
steps: int = 12,
) -> list[tuple[float, float]]:
pts = []
for i in range(1, steps + 1):
t, mt = i / steps, 1 - i / steps
pts.append((
mt**3*p0[0] + 3*mt**2*t*p1[0] + 3*mt*t**2*p2[0] + t**3*p3[0],
mt**3*p0[1] + 3*mt**2*t*p1[1] + 3*mt*t**2*p2[1] + t**3*p3[1],
))
return pts
def bezier_quadratic(
p0: tuple[float, float], p1: tuple[float, float],
p2: tuple[float, float], steps: int = 10,
) -> list[tuple[float, float]]:
pts = []
for i in range(1, steps + 1):
t, mt = i / steps, 1 - i / steps
pts.append((
mt**2*p0[0] + 2*mt*t*p1[0] + t**2*p2[0],
mt**2*p0[1] + 2*mt*t*p1[1] + t**2*p2[1],
))
return pts
commands = parse_svg_path(d_string)
polygons: list[_Polygon] = []
current_polygon: _Polygon = []
cx = cy = sx = sy = 0.0
last_cp: Optional[tuple[float, float]] = None
for cmd, args in commands:
if cmd == 'M':
if current_polygon:
polygons.append(current_polygon)
cx, cy = args[0], args[1]
sx, sy = cx, cy
current_polygon = [transform(cx, cy)]
j = 2
while j + 1 < len(args):
cx, cy = args[j], args[j + 1]
current_polygon.append(transform(cx, cy))
j += 2
last_cp = None
elif cmd == 'm':
if current_polygon:
polygons.append(current_polygon)
cx += args[0]; cy += args[1]
sx, sy = cx, cy
current_polygon = [transform(cx, cy)]
j = 2
while j + 1 < len(args):
cx += args[j]; cy += args[j + 1]
current_polygon.append(transform(cx, cy))
j += 2
last_cp = None
elif cmd == 'L':
j = 0
while j + 1 < len(args):
cx, cy = args[j], args[j + 1]
current_polygon.append(transform(cx, cy))
j += 2
last_cp = None
elif cmd == 'l':
j = 0
while j + 1 < len(args):
cx += args[j]; cy += args[j + 1]
current_polygon.append(transform(cx, cy))
j += 2
last_cp = None
elif cmd == 'H':
for val in args:
cx = val
current_polygon.append(transform(cx, cy))
last_cp = None
elif cmd == 'h':
for val in args:
cx += val
current_polygon.append(transform(cx, cy))
last_cp = None
elif cmd == 'V':
for val in args:
cy = val
current_polygon.append(transform(cx, cy))
last_cp = None
elif cmd == 'v':
for val in args:
cy += val
current_polygon.append(transform(cx, cy))
last_cp = None
elif cmd == 'C':
j = 0
while j + 5 < len(args):
p0 = (cx, cy)
p1 = (args[j], args[j + 1])
p2 = (args[j + 2], args[j + 3])
p3 = (args[j + 4], args[j + 5])
current_polygon.extend(transform(px, py) for px, py in bezier_cubic(p0, p1, p2, p3))
cx, cy = p3; last_cp = p2; j += 6
elif cmd == 'c':
j = 0
while j + 5 < len(args):
p0 = (cx, cy)
p1 = (cx + args[j], cy + args[j + 1])
p2 = (cx + args[j + 2], cy + args[j + 3])
p3 = (cx + args[j + 4], cy + args[j + 5])
current_polygon.extend(transform(px, py) for px, py in bezier_cubic(p0, p1, p2, p3))
last_cp = p2; cx, cy = p3; j += 6
elif cmd == 'S':
j = 0
while j + 3 < len(args):
p0 = (cx, cy)
p1 = (2*cx - last_cp[0], 2*cy - last_cp[1]) if last_cp else (cx, cy)
p2 = (args[j], args[j + 1])
p3 = (args[j + 2], args[j + 3])
current_polygon.extend(transform(px, py) for px, py in bezier_cubic(p0, p1, p2, p3))
last_cp = p2; cx, cy = p3; j += 4
elif cmd == 's':
j = 0
while j + 3 < len(args):
p0 = (cx, cy)
p1 = (2*cx - last_cp[0], 2*cy - last_cp[1]) if last_cp else (cx, cy)
p2 = (cx + args[j], cy + args[j + 1])
p3 = (cx + args[j + 2], cy + args[j + 3])
current_polygon.extend(transform(px, py) for px, py in bezier_cubic(p0, p1, p2, p3))
last_cp = p2; cx, cy = p3; j += 4
elif cmd == 'Q':
j = 0
while j + 3 < len(args):
p0 = (cx, cy)
p1 = (args[j], args[j + 1])
p2 = (args[j + 2], args[j + 3])
current_polygon.extend(transform(px, py) for px, py in bezier_quadratic(p0, p1, p2))
last_cp = p1; cx, cy = p2; j += 4
elif cmd == 'q':
j = 0
while j + 3 < len(args):
p0 = (cx, cy)
p1 = (cx + args[j], cy + args[j + 1])
p2 = (cx + args[j + 2], cy + args[j + 3])
current_polygon.extend(transform(px, py) for px, py in bezier_quadratic(p0, p1, p2))
last_cp = p1; cx, cy = p2; j += 4
elif cmd in ('Z', 'z'):
cx, cy = sx, sy
if current_polygon:
current_polygon.append(transform(sx, sy))
polygons.append(current_polygon)
current_polygon = []
last_cp = None
if current_polygon:
polygons.append(current_polygon)
return polygons
def draw_svg_path(
draw: ImageDraw.ImageDraw,
d_string: str,
scale: float,
offset_x: float,
offset_y: float,
fill_color: _RGBA,
) -> None:
"""Render an SVG path onto a PIL draw canvas as filled polygons.
:param draw: Active :class:`~PIL.ImageDraw.ImageDraw` context.
:param d_string: SVG path ``d`` attribute string.
:param scale: Uniform scale applied to all coordinates.
:param offset_x: Horizontal pixel offset applied after scaling.
:param offset_y: Vertical pixel offset applied after scaling.
:param fill_color: RGBA fill colour for the polygons.
"""
for poly in svg_path_to_polygons(d_string, scale, offset_x, offset_y):
if len(poly) >= 3:
draw.polygon(poly, fill=fill_color)
def _load_font(*names: str, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
"""Try loading fonts from *names* in order; fall back to the default font.
:param names: Font filenames relative to :data:`FONT_DIR`, tried in order.
:param size: Desired font size in points.
:returns: Loaded font object.
"""
for name in names:
try:
return ImageFont.truetype(os.path.join(FONT_DIR, name), size)
except IOError:
continue
return ImageFont.load_default()
def draw_twitch_logo(size: int = 200) -> Image.Image:
"""Render the Twitch Glitch icon from official SVG path data.
Produces a white speech-bubble shape on a purple gradient background
with purple eyes.
:param size: Square canvas size in pixels.
:returns: RGBA :class:`~PIL.Image.Image` of the logo.
"""
# --- 1. Purple gradient background ---
bg_colors: list[_RGBA] = [
(100, 65, 165, 255), # #6441A5 (classic Twitch purple)
(90, 55, 160, 255),
(115, 70, 190, 255),
(105, 60, 175, 255),
]
bg = apply_rounded_mask(
create_gradient_image((size, size), bg_colors, direction="vertical"),
int(size * 0.22),
)
# --- 2. Draw the Twitch Glitch icon ---
icon_layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
icon_draw = ImageDraw.Draw(icon_layer)
# Scale SVG (viewBox 0 0 24 24) to fit icon with margin
margin = size * 0.16
svg_scale = (size - 2 * margin) / 24.0
# Draw outer body filled white (the complete speech bubble silhouette)
draw_svg_path(icon_draw, TWITCH_OUTER_PATH, svg_scale, margin, margin, (255, 255, 255, 255))
# Draw eyes in purple (matching background colour)
eye_color: _RGBA = (100, 65, 165, 255)
draw_svg_path(icon_draw, TWITCH_EYE_LEFT, svg_scale, margin, margin, eye_color)
draw_svg_path(icon_draw, TWITCH_EYE_RIGHT, svg_scale, margin, margin, eye_color)
bg.alpha_composite(icon_layer)
return bg
def draw_follow_badge(width: int = 200, height: int = 55) -> Image.Image:
"""Render a "FOLLOW" badge with Twitch purple and semi-rounded corners.
:param width: Badge width in pixels.
:param height: Badge height in pixels.
:returns: RGBA :class:`~PIL.Image.Image` of the badge.
"""
badge_bg = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(badge_bg)
# Twitch purple: #9146FF
draw.rounded_rectangle(
[0, 0, width - 1, height - 1],
radius=int(height * 0.25),
fill=(145, 70, 255, 255),
)
font = _load_font("Inter_18pt-Bold.ttf", "Roboto-Bold.ttf", size=int(height * 0.42))
bbox = draw.textbbox((0, 0), "FOLLOW", font=font)
draw.text(
((width - (bbox[2] - bbox[0])) // 2, (height - (bbox[3] - bbox[1])) // 2 - bbox[1]),
"FOLLOW", fill="white", font=font,
)
return badge_bg
def draw_username_panel(
width: int,
height: int,
username: str = "",
) -> Image.Image:
"""Render the username panel with a white-to-light-grey gradient and centred text.
:param width: Panel width in pixels.
:param height: Panel height in pixels.
:param username: Optional username string to display.
:returns: RGBA :class:`~PIL.Image.Image` of the panel.
"""
panel_colors: list[_RGBA] = [
(255, 255, 255, 245),
(248, 248, 250, 240),
(235, 235, 240, 235),
(225, 225, 232, 230),
]
panel = apply_rounded_mask(
create_gradient_image((width, height), panel_colors, direction="horizontal"),
int(height * 0.18),
)
if username:
draw = ImageDraw.Draw(panel)
font = _load_font(
"Inter_24pt-SemiBold.ttf", "Roboto-Medium.ttf",
size=int(height * 0.28),
)
bbox = draw.textbbox((0, 0), username, font=font)
draw.text(
((width - (bbox[2] - bbox[0])) // 2, (height - (bbox[3] - bbox[1])) // 2 - bbox[1]),
username, fill=(50, 50, 55, 230), font=font,
)
return panel
def _measure_username_width(username: str, panel_height: int) -> int:
"""Return the pixel width needed to render *username* inside the panel.
:param username: Text to measure.
:param panel_height: Panel height used to derive the font size.
:returns: Text width in pixels, or ``0`` if *username* is empty.
"""
if not username:
return 0
font = _load_font(
"Inter_24pt-SemiBold.ttf", "Roboto-Medium.ttf",
size=int(panel_height * 0.28),
)
tmp_draw = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
bbox = tmp_draw.textbbox((0, 0), username, font=font)
return bbox[2] - bbox[0]
def create_twitch_watermark(
username: str = "",
output_filename: str = "twitch_watermark.png",
) -> Image.Image:
"""Compose and save the complete Twitch watermark PNG.
Combines the logo, username panel, and FOLLOW badge on a transparent canvas.
:param username: Channel name displayed on the panel (e.g. ``"@johndoe"``).
:param output_filename: Destination file path for the rendered PNG.
:returns: The composed RGBA :class:`~PIL.Image.Image`.
"""
logo_size = 200
badge_height = 55
panel_height = logo_size
min_panel_width = 400
text_padding = 100
panel_width = max(min_panel_width, _measure_username_width(username, panel_height) + text_padding)
badge_width = min(200, panel_width - 40)
gap_logo_panel = 15
badge_overlap_y = 18
shadow_blur = 12
shadow_offset = 6
padding = 30
logo_x = padding
logo_y = padding
panel_x = padding + logo_size + gap_logo_panel
panel_y = padding
badge_x = panel_x + (panel_width - badge_width) // 2
badge_y = panel_y + panel_height - badge_height + badge_overlap_y
canvas_w = panel_x + panel_width + padding
canvas_h = max(logo_y + logo_size + padding, badge_y + badge_height + padding)
canvas = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
# Drop shadows
def _drop_shadow(rect: list[int], radius: int, alpha: int, blur: int = shadow_blur) -> None:
layer = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
ImageDraw.Draw(layer).rounded_rectangle(rect, radius=radius, fill=(0, 0, 0, alpha))
canvas.alpha_composite(layer.filter(ImageFilter.GaussianBlur(blur)))
_drop_shadow(
[panel_x + 3, panel_y + shadow_offset, panel_x + panel_width + 3, panel_y + panel_height + shadow_offset],
radius=int(panel_height * 0.18), alpha=35,
)
_drop_shadow(
[logo_x + 3, logo_y + shadow_offset, logo_x + logo_size + 3, logo_y + logo_size + shadow_offset],
radius=int(logo_size * 0.22), alpha=40,
)
_drop_shadow(
[badge_x + 2, badge_y + 4, badge_x + badge_width + 2, badge_y + badge_height + 4],
radius=int(badge_height * 0.25), alpha=45, blur=8,
)
# Composite elements
for img, pos in [
(draw_username_panel(panel_width, panel_height, username), (panel_x, panel_y)),
(draw_twitch_logo(logo_size), (logo_x, logo_y)),
(draw_follow_badge(badge_width, badge_height), (badge_x, badge_y)),
]:
canvas.paste(img, pos, img)
canvas.save(output_filename, "PNG")
print(f"✓ Saved: {output_filename} ({canvas_w}x{canvas_h})")
return canvas
if __name__ == "__main__":
create_twitch_watermark("@filian", "twitch_watermark.png")
@motebaya

motebaya commented May 7, 2026

Copy link
Copy Markdown
Author

result:

twitch_watermark

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment