Skip to content

Instantly share code, notes, and snippets.

@motebaya
Created April 24, 2026 22:19
Show Gist options
  • Select an option

  • Save motebaya/0559fdedc555cbdcc60c32224ce62c19 to your computer and use it in GitHub Desktop.

Select an option

Save motebaya/0559fdedc555cbdcc60c32224ce62c19 to your computer and use it in GitHub Desktop.
Bulk image collage generator with random rotation, shadow, and spread layout.
#!/usr/bin/env python3
import argparse
import math
import random
from pathlib import Path
from PIL import Image, ImageOps, ImageFilter
SUPPORTED_EXTS = {".jpg", ".jpeg", ".png"}
def load_images(folder: Path):
"""Load all supported image file paths from a directory.
:param folder: Directory to scan for image files.
:type folder: Path
:returns: List of paths to supported image files.
:rtype: list[Path]
:raises FileNotFoundError: If no supported image files exist in the folder.
"""
files = [
p for p in folder.iterdir()
if p.is_file() and p.suffix.lower() in SUPPORTED_EXTS
]
if not files:
raise FileNotFoundError("No .jpg, .jpeg, or .png files found in target folder.")
return files
def center_crop_square(img: Image.Image) -> Image.Image:
"""Crop an image to a centered square using the shortest dimension.
:param img: Source image to crop.
:type img: Image.Image
:returns: Square-cropped RGBA image.
:rtype: Image.Image
"""
img = ImageOps.exif_transpose(img).convert("RGBA")
w, h = img.size
side = min(w, h)
left = (w - side) // 2
top = (h - side) // 2
return img.crop((left, top, left + side, top + side))
def add_shadow(
img: Image.Image,
blur=18,
offset=(8, 10),
opacity=120
):
"""Render a drop shadow behind the given image on a transparent canvas.
:param img: Source image with alpha channel.
:type img: Image.Image
:param blur: Gaussian blur radius for the shadow.
:type blur: int
:param offset: Horizontal and vertical shadow displacement ``(dx, dy)``.
:type offset: tuple[int, int]
:param opacity: Shadow opacity value ``(0–255)``.
:type opacity: int
:returns: New RGBA image containing the shadow composited beneath the source.
:rtype: Image.Image
"""
shadow = Image.new("RGBA", img.size, (0, 0, 0, 0))
shadow.putalpha(img.getchannel("A").point(lambda p: opacity if p > 0 else 0))
shadow = shadow.filter(ImageFilter.GaussianBlur(blur))
canvas = Image.new(
"RGBA",
(img.width + abs(offset[0]) + blur * 2, img.height + abs(offset[1]) + blur * 2),
(0, 0, 0, 0),
)
canvas.alpha_composite(shadow, (blur + max(offset[0], 0), blur + max(offset[1], 0)))
canvas.alpha_composite(img, (blur + max(-offset[0], 0), blur + max(-offset[1], 0)))
return canvas
def add_border(
img: Image.Image,
border_size=8,
color=(255, 255, 255, 255)
):
"""Expand the image with a uniform solid border.
:param img: Source image.
:type img: Image.Image
:param border_size: Border thickness in pixels.
:type border_size: int
:param color: Fill colour as an RGBA tuple.
:type color: tuple[int, int, int, int]
:returns: Image with the border applied.
:rtype: Image.Image
"""
return ImageOps.expand(img, border=border_size, fill=color)
def make_photo_card(
file_path: Path,
min_size: int,
max_size: int,
rotation_range: float,
border_size: int,
):
"""Create a randomly sized, rotated, bordered photo card with a drop shadow.
:param file_path: Path to the source image file.
:type file_path: Path
:param min_size: Minimum square dimension after resize.
:type min_size: int
:param max_size: Maximum square dimension after resize.
:type max_size: int
:param rotation_range: Maximum rotation angle in degrees (applied symmetrically).
:type rotation_range: float
:param border_size: Border thickness in pixels.
:type border_size: int
:returns: Composited RGBA photo card ready for placement.
:rtype: Image.Image
"""
with Image.open(file_path) as raw:
img = center_crop_square(raw)
size = random.randint(min_size, max_size)
img = add_border(
img.resize((size, size), Image.Resampling.LANCZOS),
border_size=border_size,
)
return add_shadow(
img.rotate(
random.uniform(-rotation_range, rotation_range),
expand=True,
resample=Image.Resampling.BICUBIC,
fillcolor=(0, 0, 0, 0),
)
)
def generate_collage(
image_files,
output_path: Path,
width=2560,
height=1440,
max_photos=150,
seed=None,
):
"""Arrange photo cards into a scattered collage and save the result.
Images are distributed across a jittered grid so that coverage stays
uniform while maintaining a natural, overlapping aesthetic.
:param image_files: Paths to source image files.
:type image_files: list[Path]
:param output_path: Destination file path for the collage JPEG.
:type output_path: Path
:param width: Canvas width in pixels.
:type width: int
:param height: Canvas height in pixels.
:type height: int
:param max_photos: Upper limit of photos to include.
:type max_photos: int
:param seed: Optional random seed for reproducible output.
:type seed: int or None
:returns: The path where the collage was saved.
:rtype: Path
"""
if seed is not None:
random.seed(seed)
canvas = Image.new("RGBA", (width, height), (245, 245, 245, 255))
selected = image_files[:]
random.shuffle(selected)
selected = selected[:max_photos]
total = len(selected)
base_size = int(math.sqrt((width * height) / max(1, total)) * 1.35)
min_photo = max(90, int(base_size * 0.75))
max_photo = max(140, int(base_size * 1.35))
cols = math.ceil(math.sqrt(total * width / height))
rows = math.ceil(total / cols)
cell_w = width / cols
cell_h = height / rows
positions = [
(
int((col + 0.5) * cell_w) + random.randint(-int(cell_w * 0.38), int(cell_w * 0.38)),
int((row + 0.5) * cell_h) + random.randint(-int(cell_h * 0.38), int(cell_h * 0.38)),
)
for row in range(rows)
for col in range(cols)
]
random.shuffle(positions)
clamp = lambda v, lo, hi: max(lo, min(v, hi))
for index, file_path in enumerate(selected):
try:
card = make_photo_card(
file_path=file_path,
min_size=min_photo,
max_size=max_photo,
rotation_range=12,
border_size=max(4, int(min(width, height) * 0.004)),
)
cx, cy = positions[index]
overflow = int(max_photo * 0.35)
canvas.alpha_composite(
card,
(
clamp(int(cx - card.width / 2), -overflow, width - card.width + overflow),
clamp(int(cy - card.height / 2), -overflow, height - card.height + overflow),
),
)
print(f"[OK] Added: {file_path.name}")
except Exception as e:
print(f"[SKIP] {file_path.name}: {e}")
canvas.convert("RGB").save(output_path, quality=95, optimize=True)
return output_path
def main():
"""Parse CLI arguments and generate a photo collage from a target folder."""
parser = argparse.ArgumentParser(
description="Combine many photos into one overlapping collage for YouTube profile cover/avatar."
)
parser.add_argument(
"targetfolder",
help="Folder containing .jpg, .jpeg, and .png images.",
)
parser.add_argument(
"--size", type=int, default=1080,
help="Output canvas size. Default: 1080",
)
parser.add_argument(
"--max", type=int, default=100,
help="Maximum photos to use. Default: 100",
)
parser.add_argument(
"--seed", type=int, default=None,
help="Random seed for repeatable result.",
)
parser.add_argument("--width", type=int, default=2560)
parser.add_argument("--height", type=int, default=1440)
args = parser.parse_args()
folder = Path(args.targetfolder).expanduser().resolve()
if not folder.exists() or not folder.is_dir():
raise NotADirectoryError(f"Target folder not found: {folder}")
image_files = load_images(folder)
output_path = folder / "combined_collage_youtube_profile.jpg"
print(f"Found {len(image_files)} images.")
print(f"Generating collage: {output_path}")
print(f"DONE: {generate_collage(image_files=image_files, output_path=output_path, width=args.width, height=args.height, max_photos=args.max, seed=args.seed)}")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment