151 lines
4.6 KiB
Python
151 lines
4.6 KiB
Python
#!/usr/bin/env python
|
|||
|
|
"""Finalize a generated social image with exact brand formatting.
|
||
|
|
|
||
|
|
This script keeps the AI-generated image and the company logo separate:
|
||
|
|
generate the base image first, then overlay the real logo here so the brand mark
|
||
|
|
does not get distorted by the image model.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
try:
|
||
|
|
from PIL import Image, ImageDraw, ImageOps
|
||
|
|
except ModuleNotFoundError as exc: # pragma: no cover - friendly CLI error
|
||
|
|
raise SystemExit(
|
||
|
|
"Pillow is required. Install it with: "
|
||
|
|
"python -m pip install Pillow"
|
||
|
|
) from exc
|
||
|
|
|
||
|
|
|
||
|
|
def _open_rgba(path: Path) -> Image.Image:
|
||
|
|
return Image.open(path).convert("RGBA")
|
||
|
|
|
||
|
|
|
||
|
|
def _fit_cover(img: Image.Image, size: tuple[int, int]) -> Image.Image:
|
||
|
|
target_w, target_h = size
|
||
|
|
src_w, src_h = img.size
|
||
|
|
scale = max(target_w / src_w, target_h / src_h)
|
||
|
|
resized = img.resize(
|
||
|
|
(round(src_w * scale), round(src_h * scale)),
|
||
|
|
Image.Resampling.LANCZOS,
|
||
|
|
)
|
||
|
|
left = (resized.width - target_w) // 2
|
||
|
|
top = (resized.height - target_h) // 2
|
||
|
|
return resized.crop((left, top, left + target_w, top + target_h))
|
||
|
|
|
||
|
|
|
||
|
|
def _fit_contain(img: Image.Image, size: tuple[int, int]) -> Image.Image:
|
||
|
|
target_w, target_h = size
|
||
|
|
src_w, src_h = img.size
|
||
|
|
scale = min(target_w / src_w, target_h / src_h)
|
||
|
|
resized = img.resize(
|
||
|
|
(round(src_w * scale), round(src_h * scale)),
|
||
|
|
Image.Resampling.LANCZOS,
|
||
|
|
)
|
||
|
|
canvas = Image.new("RGBA", size, (255, 255, 255, 255))
|
||
|
|
x = (target_w - resized.width) // 2
|
||
|
|
y = (target_h - resized.height) // 2
|
||
|
|
canvas.alpha_composite(resized, (x, y))
|
||
|
|
return canvas
|
||
|
|
|
||
|
|
|
||
|
|
def _parse_size(value: str) -> tuple[int, int]:
|
||
|
|
try:
|
||
|
|
w_text, h_text = value.lower().split("x", 1)
|
||
|
|
width = int(w_text.strip())
|
||
|
|
height = int(h_text.strip())
|
||
|
|
except Exception as exc:
|
||
|
|
raise argparse.ArgumentTypeError(
|
||
|
|
"Size must be formatted like 1200x628"
|
||
|
|
) from exc
|
||
|
|
if width < 300 or height < 300:
|
||
|
|
raise argparse.ArgumentTypeError("Size must be at least 300x300")
|
||
|
|
return width, height
|
||
|
|
|
||
|
|
|
||
|
|
def finalize_image(
|
||
|
|
source: Path,
|
||
|
|
logo: Path,
|
||
|
|
output: Path,
|
||
|
|
size: tuple[int, int],
|
||
|
|
fit: str,
|
||
|
|
logo_width_pct: float,
|
||
|
|
margin_pct: float,
|
||
|
|
plate: bool,
|
||
|
|
) -> None:
|
||
|
|
base = _open_rgba(source)
|
||
|
|
canvas = _fit_cover(base, size) if fit == "cover" else _fit_contain(base, size)
|
||
|
|
|
||
|
|
logo_img = _open_rgba(logo)
|
||
|
|
max_logo_w = round(size[0] * logo_width_pct / 100)
|
||
|
|
max_logo_h = round(size[1] * 0.12)
|
||
|
|
logo_img.thumbnail((max_logo_w, max_logo_h), Image.Resampling.LANCZOS)
|
||
|
|
|
||
|
|
margin = round(size[0] * margin_pct / 100)
|
||
|
|
plate_pad_x = max(12, round(logo_img.width * 0.24))
|
||
|
|
plate_pad_y = max(8, round(logo_img.height * 0.22))
|
||
|
|
|
||
|
|
logo_x = size[0] - margin - logo_img.width
|
||
|
|
logo_y = size[1] - margin - logo_img.height
|
||
|
|
|
||
|
|
if plate:
|
||
|
|
draw = ImageDraw.Draw(canvas)
|
||
|
|
rect = (
|
||
|
|
logo_x - plate_pad_x,
|
||
|
|
logo_y - plate_pad_y,
|
||
|
|
logo_x + logo_img.width + plate_pad_x,
|
||
|
|
logo_y + logo_img.height + plate_pad_y,
|
||
|
|
)
|
||
|
|
radius = max(10, round(min(logo_img.size) * 0.22))
|
||
|
|
draw.rounded_rectangle(
|
||
|
|
rect,
|
||
|
|
radius=radius,
|
||
|
|
fill=(255, 255, 255, 225),
|
||
|
|
outline=(229, 231, 235, 160),
|
||
|
|
width=1,
|
||
|
|
)
|
||
|
|
|
||
|
|
canvas.alpha_composite(logo_img, (logo_x, logo_y))
|
||
|
|
|
||
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
rgb = ImageOps.exif_transpose(canvas).convert("RGB")
|
||
|
|
rgb.save(output, quality=95, optimize=True)
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> None:
|
||
|
|
parser = argparse.ArgumentParser(
|
||
|
|
description="Resize a generated image and overlay the company logo.",
|
||
|
|
)
|
||
|
|
parser.add_argument("--source", required=True, type=Path)
|
||
|
|
parser.add_argument("--logo", required=True, type=Path)
|
||
|
|
parser.add_argument("--out", required=True, type=Path)
|
||
|
|
parser.add_argument("--size", type=_parse_size, default=(1200, 628))
|
||
|
|
parser.add_argument("--fit", choices=("cover", "contain"), default="cover")
|
||
|
|
parser.add_argument("--logo-width-pct", type=float, default=12.0)
|
||
|
|
parser.add_argument("--margin-pct", type=float, default=3.0)
|
||
|
|
parser.add_argument(
|
||
|
|
"--no-plate",
|
||
|
|
action="store_true",
|
||
|
|
help="Do not draw a subtle white plate behind the logo.",
|
||
|
|
)
|
||
|
|
args = parser.parse_args()
|
||
|
|
|
||
|
|
finalize_image(
|
||
|
|
source=args.source,
|
||
|
|
logo=args.logo,
|
||
|
|
output=args.out,
|
||
|
|
size=args.size,
|
||
|
|
fit=args.fit,
|
||
|
|
logo_width_pct=args.logo_width_pct,
|
||
|
|
margin_pct=args.margin_pct,
|
||
|
|
plate=not args.no_plate,
|
||
|
|
)
|
||
|
|
print(f"Saved branded image: {args.out}")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|