Skip to content

Instantly share code, notes, and snippets.

@muety
Last active August 10, 2026 20:10
Show Gist options
  • Select an option

  • Save muety/9b258754a2e11bae1fe6f27dedc4476b to your computer and use it in GitHub Desktop.

Select an option

Save muety/9b258754a2e11bae1fe6f27dedc4476b to your computer and use it in GitHub Desktop.
Overlay a radial-fading backdrop plus centered title + subtitle on an image.
#!/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