From 070a107873440d62b4519e6d91c2ba739d4b6fa2 Mon Sep 17 00:00:00 2001 From: skylar Date: Sun, 1 Nov 2026 07:03:26 -0800 Subject: Literate Doom Emacs configuration config.org tangles to config.el, which loads private/config.el last when it exists. bin/ holds the scripts some sections drive. Co-Authored-By: Claude Opus 5.5 --- bin/imgcollage | 122 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100755 bin/imgcollage (limited to 'bin/imgcollage') diff --git a/bin/imgcollage b/bin/imgcollage new file mode 100755 index 0000000..6211f87 --- /dev/null +++ b/bin/imgcollage @@ -0,0 +1,122 @@ +#!/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.abspath(__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() -- cgit v1.2.3