Skip to content

Instantly share code, notes, and snippets.

@m-bartlett
Created December 7, 2021 06:44
Show Gist options
  • Save m-bartlett/916fead54b71fff175cd2d1d7f723da0 to your computer and use it in GitHub Desktop.
Save m-bartlett/916fead54b71fff175cd2d1d7f723da0 to your computer and use it in GitHub Desktop.
Random collage/montage generator with drop-shadow.

These are the sample input files in $PWD for the below examples:

Mochi 1.png Millie3 2.png Millie1 3.png Maiya4 4.png Maiya3 5.png Maiya1 6.png Maisy 7.png Koa2 8.png Koa 9.png Hans 10.png greta3 11.png Ellie1 12.png Condor 13.png Chloe 14.png Carsen 15.png thumb_Greta 16.png



Here are some sample executions and outputs:

    python collage.py          \
      --background '#00000000' \
      --width 2000             \
      --height 1000            \
      --density 23             \
      --noise 100              \
      --border 170             \
      --shadow-size 3          \
      --  *.png

sample_output



    python collage.py        \
      --width 2000           \
      --height 1000          \
      --density 23           \
      --noise 100            \
      --background '#8f1c1c' \
      --border 170           \
      --shadow-size 4        \
      --  *.png

sample_output

#!/usr/bin/env python3
from PIL import Image, ImageFilter, ImageOps
import sys
import argparse
import random
import math
parser = argparse.ArgumentParser()
parser.add_argument(
"--width", "-W", type=int, default=1920,
help="Width in pixels of output image"
)
parser.add_argument(
"--height", "-H", type=int, default=1080,
help="Height in pixels of output image"
)
parser.add_argument(
"--background", "-B", type=str, default="#13254B",
help="Background color beneath the collage, specify '#00000000' for completely transparent background"
)
parser.add_argument(
"--border", "-b", type=int, default=100,
help="""
Size of offset from edges for the initial repetition grid, creating a border of empty space.
This is calculated before noise is added to sample placement, so this does not guarantee any border.
A larger value makes it more likely samples will not be placed outside the image boundary.
"""
)
parser.add_argument(
"--density", "-d", type=float, default=10,
help="Repetition density of samples, measured in quantity of samples intersected by the image diagonal."
)
parser.add_argument(
"--scale", "-s", type=float, default=1.0,
help="""
Scale factor for resizing of the repeated items (useful for when -W and -H are very large or small)
"""
)
parser.add_argument(
"--noise", "-n", type=int, default=50,
help="Maximum range for randomization of grid placement and resizing of repeated samples in pixels"
)
parser.add_argument(
"--shadow-size", "-S", type=int, default=10,
help="Shadow length in pixels around each side of foreground"
)
parser.add_argument('paths', nargs='+')
args = parser.parse_args()
def get_diagonal(w,h):
return math.sqrt(w**2 + h**2)
def cycle(items): # there's itertools.cycle but this spells out the generator
while True:
yield from items
background = Image.new('RGBA', (args.width, args.height), args.background)
images = []
min_width = 0xffffffff
min_height = 0xffffffff
# Add drop-shadow to each base image so we don't
# have to add it to each randomized placement
for image_path in args.paths:
image = Image.open(image_path).convert('RGBA')
shadow_size2 = args.shadow_size * 2
shadow_width = image.width+shadow_size2
shadow_height = image.height+shadow_size2
shadow_size = (shadow_width,shadow_height)
shadow_offset = ( (ss_2:=args.shadow_size//2), ss_2 )
shadow = Image.new('RGBA', shadow_size, (0, 0, 0, 0))
shadow.paste(Image.new('RGBA', image.size, (0,0,0,255)), shadow_offset, image)
shadow = shadow.filter(ImageFilter.GaussianBlur(radius=args.shadow_size))
shadow.paste(image, shadow_offset, image)
images.append(shadow)
if min_width > shadow.width: min_width = shadow.width
if min_height > shadow.height: min_height = shadow.height
min_width = round(min_width*args.scale)
min_height = round(min_height*args.scale)
min_size = (min_width, min_height)
for i in images:
i.thumbnail(min_size) # thumbnail mutates in place
border = args.border
x_range = (border, args.width - border )
y_range = (border, args.height - border)
diagonal = get_diagonal(args.width, args.height)
x_density = round(args.width * args.density / diagonal) or 1
y_density = round(args.height * args.density / diagonal) or 1
points = x_density * y_density
x_step = (x_range[1]-border) // (x_density-1)
y_step = (y_range[1]-border) // (y_density-1)
image_iter = cycle(images)
i = 0
y = border
for yi in range(y_density):
x = border
random.shuffle(images)
for xi in range(x_density):
image = image_iter.__next__()
rotation = random.random()*60 * (-1 if random.random() > 0.5 else 1)
x_noise = random.randint(-args.noise, args.noise)
y_noise = random.randint(-args.noise, args.noise)
_image = image.rotate(rotation, expand=True, translate=None, fillcolor=(0,0,0,0))
if random.choice((True, False)):
_image = _image.transpose(Image.FLIP_LEFT_RIGHT)
while True:
try:
_scale = (random.random() * args.noise * random.choice((1,-1)))
_image.thumbnail((min_width+_scale, min_height+_scale), Image.ANTIALIAS)
break
except ValueError:
continue
_x = x + x_noise - (image.width >> 1)
_y = y + y_noise - (image.height >> 1)
background.alpha_composite(_image, dest=(_x,_y))
i +=1
print(f"\r{100*i/points:0.2f}% ", end='')
x += x_step
y += y_step
background.save('collage.png')
print("done")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment