1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
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()
|