summaryrefslogtreecommitdiff
path: root/bin/imgcollage
diff options
context:
space:
mode:
authorskylar <me@casper>2026-11-01 07:03:26 -0800
committerskylar <me@casper>2026-11-01 07:03:26 -0800
commit070a107873440d62b4519e6d91c2ba739d4b6fa2 (patch)
treeae4e1c3540cdd01b3289ed6f96ec3ea69507f73e /bin/imgcollage
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 <noreply@anthropic.com>
Diffstat (limited to 'bin/imgcollage')
-rwxr-xr-xbin/imgcollage122
1 files changed, 122 insertions, 0 deletions
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()