summaryrefslogtreecommitdiff
path: root/bin/imgcollage
blob: 50ef8033605ba6caa995683f33d4000ac590f332 (plain)
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.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()