#!/usr/bin/env python3 """imgcollage: build a single masonry-style collage image from a folder. A headless CLI wrapper around the layout/rendering engine from PhotoCollage (https://github.com/adrienverge/PhotoCollage, GPL-2.0-or-later). It reuses that project's `photocollage.collage` and `photocollage.render` modules verbatim (vendored alongside this script) and drives them with the same auto-layout heuristic PhotoCollage's GTK app uses (see UserCollage.make_page in its gtkgui.py), without needing GTK/pycairo or any GUI at all. """ import argparse import math import os import random import sys sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) from photocollage import collage, render # noqa: E402 IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".webp"} def collect_images(paths): files = [] skipped = [] for p in paths: if os.path.isdir(p): for name in sorted(os.listdir(p)): full = os.path.join(p, name) if os.path.splitext(name)[1].lower() in IMAGE_EXTS: files.append(full) elif os.path.isfile(full): skipped.append(full) elif os.path.splitext(p)[1].lower() in IMAGE_EXTS: files.append(p) else: skipped.append(p) if skipped: print(f"Ignoring {len(skipped)} non-image file(s): " f"{', '.join(os.path.basename(s) for s in skipped)}", file=sys.stderr) return files def auto_no_cols(photolist, ratio): avg_ratio = sum(p.h / p.w for p in photolist) / len(photolist) virtual_no_imgs = 2 * len(photolist) return max(1, int(round(math.sqrt(avg_ratio / ratio * virtual_no_imgs)))) def main(): ap = argparse.ArgumentParser( description="Arrange a folder of differently-sized images into one " "masonry-style collage image, auto-computing the layout.") ap.add_argument("images", nargs="+", help="image files and/or directories of images") ap.add_argument("-o", "--output", default="collage.jpg", help="output file (default: collage.jpg)") ap.add_argument("-W", "--width", type=int, default=2000, help="output width in px (default: 2000)") ap.add_argument("-H", "--height", type=int, default=1500, help="output height in px (default: 1500)") ap.add_argument("-c", "--cols", type=int, default=0, help="number of columns (default: auto)") ap.add_argument("-b", "--border", type=float, default=1.0, help="border width as %% of poster size (default: 1.0, " "use 0 for no border)") ap.add_argument("--border-color", default="black", help="border color, any PIL-recognized name/hex " "(default: black)") ap.add_argument("--seed", type=int, default=None, help="random seed, for a reproducible layout") args = ap.parse_args() if args.seed is not None: random.seed(args.seed) filelist = collect_images(args.images) if not filelist: sys.exit("No images found.") try: photolist = render.build_photolist(filelist) except render.BadPhoto as e: sys.exit(f"Could not read image: {e.photoname}") ratio = args.height / args.width no_cols = args.cols or auto_no_cols(photolist, ratio) page = collage.Page(1.0, ratio, no_cols) random.shuffle(photolist) for photo in photolist: page.add_cell(photo) page.adjust() enlargement = args.width / page.w page.scale(enlargement) errors = [] task = render.RenderingTask( page, border_width=args.border / 100.0 * max(page.w, page.h), border_color=args.border_color, quality=render.QUALITY_BEST, output_file=args.output, on_fail=errors.append, ) task.run() # run synchronously in this thread; no need for start()/join() if errors: sys.exit(f"Rendering failed: {errors[0]}") if os.path.exists(args.output): print(f"Wrote {args.output} " f"({int(page.w)}x{int(page.h)}, {no_cols} columns, " f"{len(photolist)} images)") if __name__ == "__main__": main()