Created
April 24, 2026 22:19
-
-
Save motebaya/0559fdedc555cbdcc60c32224ce62c19 to your computer and use it in GitHub Desktop.
Bulk image collage generator with random rotation, shadow, and spread layout.
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 | |
| 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