Last active
August 10, 2026 20:10
-
-
Save muety/9b258754a2e11bae1fe6f27dedc4476b to your computer and use it in GitHub Desktop.
Overlay a radial-fading backdrop plus centered title + subtitle on an image.
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
| #!/usr/bin/env python3 | |
| """ | |
| Overlay a radial-fading backdrop plus centered title + subtitle on an image. | |
| Usage: | |
| python backdrop_title.py <image> "<title>" [-s "<subtitle>"] | |
| [--title-size N] [--subtitle-size M] [--font-family "Inter"] | |
| [--backdrop-strength 0..1] [--no-radial] [--blur 0.02] | |
| [--text-y 0..100] [-o out.png] | |
| Disclaimer: written by AI. | |
| """ | |
| import argparse | |
| import os | |
| import subprocess | |
| import sys | |
| import numpy as np | |
| from PIL import Image, ImageDraw, ImageFilter, ImageFont # pip install pillow | |
| # Fallback font search order when the requested family can't be resolved. | |
| FALLBACK_FAMILIES = ["Inter", "DejaVu Sans", "Liberation Sans", "Arial", "Open Sans"] | |
| VARIABLE_INTER_PATH = os.path.expanduser("~/.local/share/fonts/Inter-VariableFont_slnt,wght.ttf") | |
| def resolve_font_path(family: str, bold: bool = False) -> str: | |
| """Resolve a font file path for the given family, falling back gracefully.""" | |
| # Try fontconfig first for the requested family. | |
| candidates = [family] + FALLBACK_FAMILIES if family else FALLBACK_FAMILIES | |
| for fam in candidates: | |
| try: | |
| res = subprocess.run( | |
| ["fc-match", "-f", "%{file}", fam], | |
| capture_output=True, text=True, timeout=5, check=False, | |
| ) | |
| path = res.stdout.strip() | |
| if path and os.path.isfile(path): | |
| return path | |
| except (FileNotFoundError, subprocess.TimeoutExpired): | |
| break # no fc-match available; switch to direct path lookup | |
| # Direct-path fallbacks. | |
| if os.path.isfile(VARIABLE_INTER_PATH): | |
| return VARIABLE_INTER_PATH | |
| for p in ( | |
| "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf" if bold | |
| else "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf", | |
| "/usr/share/fonts/liberation-sans-fonts/LiberationSans-Regular.ttf", | |
| ): | |
| if os.path.isfile(p): | |
| return p | |
| return "" # let PIL fall back to its bundled default | |
| def load_font(family: str, size: int, bold: bool = False) -> ImageFont.FreeTypeFont: | |
| path = resolve_font_path(family, bold=bold) | |
| try: | |
| font = ImageFont.truetype(path, size) if path else ImageFont.load_default() | |
| except Exception: | |
| font = ImageFont.load_default() | |
| if path == VARIABLE_INTER_PATH and isinstance(font, ImageFont.FreeTypeFont): | |
| try: | |
| font.set_variation_by_name("Bold" if bold else "Regular") | |
| except Exception: | |
| pass | |
| return font | |
| def make_backdrop_mask(size: tuple[int, int], strength: float, | |
| gamma: float = 1.3, radial: bool = True) -> Image.Image: | |
| """Return an 'L' mask for the dark backdrop. | |
| When `radial` is True the mask fades from 0 (clear) at the center up to | |
| `strength` at the corners. When False the mask is constant `strength` | |
| across the whole image. | |
| """ | |
| w, h = size | |
| if not radial: | |
| const = int(np.clip(strength * 255.0, 0, 255)) | |
| return Image.new("L", (w, h), const) | |
| ys, xs = np.mgrid[0:h, 0:w].astype(np.float32) | |
| cx, cy = (w - 1) / 2.0, (h - 1) / 2.0 | |
| dist = np.sqrt((xs - cx) ** 2 + (ys - cy) ** 2) | |
| max_dist = np.sqrt(cx * cx + cy * cy) or 1.0 | |
| norm = np.clip(dist / max_dist, 0.0, 1.0) | |
| # Smooth radial vignette: clear center, full strength at edges. | |
| alpha = strength * (norm ** gamma) | |
| mask = np.clip(alpha * 255.0, 0, 255).astype(np.uint8) | |
| return Image.fromarray(mask, mode="L") | |
| def text_size(draw: ImageDraw.ImageDraw, text: str, font) -> tuple[int, int]: | |
| """Return (width, height) accounting for multiline and font metrics.""" | |
| try: | |
| l, t, r, b = draw.textbbox((0, 0), text, font=font) | |
| return r - l, b - t | |
| except AttributeError: | |
| w, h = draw.textsize(text, font=font) | |
| return w, h | |
| def render(image_path: str, title: str, subtitle: str, | |
| title_size: int, subtitle_size: int, | |
| font_family: str, strength: float, | |
| output_path: str, blur: float, radial: bool = True, | |
| text_y: float = 50.0) -> str: | |
| img = Image.open(image_path).convert("RGB") | |
| w, h = img.size | |
| # --- Backdrop: dark layer composited through the mask. --- | |
| mask = make_backdrop_mask((w, h), strength=strength, radial=radial) | |
| if blur > 0: | |
| mask = mask.filter(ImageFilter.GaussianBlur(radius=blur * min(w, h))) | |
| backdrop = Image.new("RGB", (w, h), (0, 0, 0)) | |
| img = Image.composite(backdrop, img, mask) | |
| # --- Text. --- | |
| title_font = load_font(font_family, title_size, bold=True) | |
| sub_font = load_font(font_family, subtitle_size, bold=False) | |
| draw = ImageDraw.Draw(img) | |
| title_w, title_h = text_size(draw, title, title_font) | |
| sub_w, sub_h = (0, 0) | |
| if subtitle: | |
| sub_w, sub_h = text_size(draw, subtitle, sub_font) | |
| gap = int(title_size * 0.18) | |
| block_h = title_h + (gap + sub_h if subtitle else 0) | |
| # Place the vertical center of the text block at y = text_y% of the image | |
| # height. 50 (default) centers it; 0 puts the center on the top border. | |
| block_cy = int(h * (text_y / 100.0)) | |
| top = block_cy - block_h // 2 | |
| cx = w // 2 | |
| # Title (centered horizontally and on the topmost line of the block). | |
| draw.text((cx - title_w // 2, top), title, font=title_font, | |
| fill=(255, 255, 255), anchor="lt") | |
| if subtitle: | |
| sub_top = top + title_h + gap | |
| draw.text((cx - sub_w // 2, sub_top), subtitle, font=sub_font, | |
| fill=(235, 235, 235), anchor="lt") | |
| if output_path is None: | |
| base, ext = os.path.splitext(image_path) | |
| output_path = f"{base}_titled{ext or '.png'}" | |
| img.save(output_path) | |
| return output_path | |
| def build_parser() -> argparse.ArgumentParser: | |
| p = argparse.ArgumentParser(description="Add a radial-fading backdrop and centered title/subtitle to an image.") | |
| p.add_argument("image", help="Path to the input image.") | |
| p.add_argument("title", help="Large title text (required).") | |
| p.add_argument("-s", "--subtitle", default="", help="Smaller subtitle text placed below the title.") | |
| p.add_argument("--title-size", type=int, default=None, help="Title font size in px (default: ~8%% of image width).") | |
| p.add_argument("--subtitle-size", type=int, default=None, help="Subtitle font size in px (default: ~40%% of title size).") | |
| p.add_argument("--font-family", default="Inter", help="Font family name (default: Inter; any sans-serif fallback).") | |
| p.add_argument("--backdrop-strength", "--strength", dest="strength", type=float, default=0.6, metavar="0..1", help="Opacity/strength of the backdrop (0=none, 1=fully black at edges). Default 0.6.") | |
| p.add_argument("--no-radial", dest="radial", action="store_false", help="Disable the radial vignette; use a constant backdrop across the whole image (default: radial on).") | |
| p.add_argument("--blur", type=float, default=0.0, help="Backdrop mask blur as a fraction of the smaller image dimension (0=off; ignored when --no-radial is set).") | |
| p.add_argument("--text-y", dest="text_y", type=float, default=50.0, metavar="0..100", help="Vertical position of the text center as a percentage of image height (0=top border, 50=center [default], 100=bottom). Default 50.") | |
| p.add_argument("-o", "--output", default=None, help="Output path (default: <input>_titled.<ext>).") | |
| return p | |
| def main(argv=None) -> int: | |
| args = build_parser().parse_args(argv) | |
| if not os.path.isfile(args.image): | |
| print(f"error: input image not found: {args.image}", file=sys.stderr) | |
| return 2 | |
| with Image.open(args.image) as probe: | |
| iw = probe.size[0] | |
| if args.title_size is None: | |
| args.title_size = max(24, int(iw * 0.08)) | |
| if args.subtitle_size is None: | |
| args.subtitle_size = max(12, int(args.title_size * 0.4)) | |
| args.strength = max(0.0, min(1.0, args.strength)) | |
| args.text_y = max(0.0, min(100.0, args.text_y)) | |
| out = render( | |
| image_path=args.image, | |
| title=args.title, | |
| subtitle=args.subtitle, | |
| title_size=args.title_size, | |
| subtitle_size=args.subtitle_size, | |
| font_family=args.font_family, | |
| strength=args.strength, | |
| output_path=args.output, | |
| blur=args.blur, | |
| radial=args.radial, | |
| text_y=args.text_y, | |
| ) | |
| print(out) | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment