Created
May 7, 2026 18:00
-
-
Save motebaya/80d5b72e5131217f0ff3c270c1fea8cc to your computer and use it in GitHub Desktop.
a Twitch watermark generator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| 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") |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
result: